ops-console v1.0.2 (versionCode 8) 源码入库

This commit is contained in:
txh
2026-09-03 18:01:05 +08:00
commit 685fff27c8
170 changed files with 53049 additions and 0 deletions

View File

@@ -0,0 +1,355 @@
// 腾讯云 COS 直传客户端(纯 Dart 实现,对齐 Web 端 cos-js-sdk-v5 的 STS 临时密钥签名)。
//
// 来源:flutter_cos_client (MIT) 的本地化副本。
// 之所以 vendoring 而非直接依赖 pub 包:
// - 原 pub 包 (0.0.1) 在生成签名时硬编码 `expires = now + 3600`,
// 当后端下发的 STS 临时密钥有效期短于 1 小时时,COS 会以 403 拒绝
// (q-sign-time 末尾超出凭证 ExpiredTime)。此处改为尊重凭证自身的 ExpiredTime。
// - 移除对未维护 0.0.1 包的依赖,便于长期维护。
// - 去掉 mime 依赖,用内置扩展名→Content-Type 映射。
import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:dio/dio.dart';
/// 临时云 API 凭据。
class CosCredential {
/// 临时密钥 Id。
final String tmpSecretId;
/// 临时密钥 Key。
final String tmpSecretKey;
/// 请求时需要用的 token 字符串。
final String sessionToken;
CosCredential({
required this.tmpSecretId,
required this.tmpSecretKey,
required this.sessionToken,
});
}
/// COS 临时凭据数据。
class CosCredentialData {
/// 密钥的起始时间,UNIX 时间戳(秒)。
final int startTime;
/// 密钥的失效时间,UNIX 时间戳(秒)。
final int expiredTime;
/// 临时云 API 凭据。
final CosCredential credentials;
/// 请求 ID。
final String requestId;
CosCredentialData({
required this.startTime,
required this.expiredTime,
required this.credentials,
required this.requestId,
});
/// 检查凭据是否已过期(提前 5 分钟判断为过期)。
bool get isExpired {
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
return now >= (expiredTime - 300);
}
}
/// 临时秘钥提供者。
class TemporaryCredentialProvider {
/// 获取临时凭据的异步函数。
final Future<CosCredentialData> Function() fetchCredentials;
/// 缓存的临时凭据。
CosCredentialData? _cachedCredentials;
TemporaryCredentialProvider({required this.fetchCredentials});
/// 获取有效的临时凭据(缓存过期则重新获取)。
Future<CosCredentialData> getCredentials() async {
if (_cachedCredentials == null || _cachedCredentials!.isExpired) {
_cachedCredentials = await fetchCredentials();
}
return _cachedCredentials!;
}
/// 清除缓存的凭据。
void clearCache() {
_cachedCredentials = null;
}
}
/// 腾讯云 COS 客户端。
class CosClient {
/// 地域,例如:ap-beijing。
final String region;
/// 存储桶名称,格式:bucketname-appid。
final String bucket;
/// 临时秘钥提供者。
final TemporaryCredentialProvider? temporaryCredentialProvider;
late final Dio _dio;
/// 构造函数(使用临时密钥提供者)。
CosClient.withTemporaryCredential({
required this.temporaryCredentialProvider,
required this.region,
required this.bucket,
BaseOptions? dioOptions,
}) {
_dio = Dio(dioOptions ?? BaseOptions());
}
/// 获取存储桶的 Host。
String get _host => '$bucket.cos.$region.myqcloud.com';
/// 上传对象(字节数据)。
Future<CosUploadResult> putObject({
required String objectKey,
required List<int> fileBytes,
String? fileName,
String? contentType,
void Function(int count, int total)? onProgress,
}) async {
// 确保 objectKey 以 / 开头。
if (!objectKey.startsWith('/')) {
objectKey = '/$objectKey';
}
final fileLength = fileBytes.length;
// 计算 Content-MD5。
final md5Digest = md5.convert(fileBytes);
final contentMd5 = base64.encode(md5Digest.bytes);
// 确定 Content-Type。
contentType ??= _getContentType(fileName ?? '');
// 获取密钥(临时)。
String currentSecretId;
String currentSecretKey;
String? sessionToken;
int signExpires;
if (temporaryCredentialProvider != null) {
final credentials = await temporaryCredentialProvider!.getCredentials();
currentSecretId = credentials.credentials.tmpSecretId;
currentSecretKey = credentials.credentials.tmpSecretKey;
sessionToken = credentials.credentials.sessionToken;
// 关键修复:签名有效期不得超过 STS 凭证自身的 ExpiredTime,
// 否则 COS 会以 403 拒绝(q-sign-time 末尾超出凭证有效期)。
signExpires = credentials.expiredTime;
} else {
throw ArgumentError('CosClient 仅支持临时密钥(STS)方式');
}
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
// 构建请求头(临时密钥需包含 x-cos-security-token)。
final headers = <String, String>{
'content-length': fileLength.toString(),
'content-md5': contentMd5,
'content-type': contentType,
'host': _host,
};
if (sessionToken != null) {
headers['x-cos-security-token'] = sessionToken;
}
// 生成签名。
final authorization = _generateAuthorization(
method: 'put',
uri: objectKey,
headers: headers,
queryParams: {},
now: now,
expires: signExpires,
secretId: currentSecretId,
secretKey: currentSecretKey,
);
// 构建请求 URL。
final url = 'https://$_host$objectKey';
// 构建实际请求头。
final requestHeaders = <String, String>{
'Authorization': authorization,
'Content-Type': contentType,
'Content-Length': fileLength.toString(),
'Content-MD5': contentMd5,
'Host': _host,
};
if (sessionToken != null) {
requestHeaders['x-cos-security-token'] = sessionToken;
}
try {
final response = await _dio.put(
url,
data: Stream.fromIterable(fileBytes.map((e) => [e])),
options: Options(
headers: requestHeaders,
contentType: contentType,
responseType: ResponseType.plain,
validateStatus: (status) => status != null && status < 500,
),
onSendProgress: onProgress,
);
if (response.statusCode == 200) {
return CosUploadResult(
success: true,
statusCode: response.statusCode!,
etag: response.headers.value('etag'),
message: 'Upload successful',
url: url,
);
}
return CosUploadResult(
success: false,
statusCode: response.statusCode!,
message: 'Upload failed: ${response.statusMessage}, ${response.data}',
);
} catch (e) {
return CosUploadResult(
success: false,
statusCode: -1,
message: 'Upload error: $e',
);
}
}
/// 从文件上传对象(便捷方法)。
Future<CosUploadResult> putObjectFromFile({
required String objectKey,
required File file,
String? contentType,
void Function(int count, int total)? onProgress,
}) async {
final fileBytes = await file.readAsBytes();
return putObject(
objectKey: objectKey,
fileBytes: fileBytes,
fileName: file.path,
contentType: contentType,
onProgress: onProgress,
);
}
/// 生成 Authorization 签名(腾讯云 q-sign-algorithm=sha1)。
String _generateAuthorization({
required String method,
required String uri,
required Map<String, String> headers,
required Map<String, String> queryParams,
required int now,
required int expires,
required String secretId,
required String secretKey,
}) {
final keyTime = '$now;$expires';
final signKey = _hmacSha1(secretKey, keyTime);
final urlParamList = _generateParamList(queryParams);
final httpParameters = _generateHttpParameters(queryParams);
final headerList = _generateParamList(headers);
final httpHeaders = _generateHttpParameters(headers);
final httpString =
'${method.toLowerCase()}\n$uri\n$httpParameters\n$httpHeaders\n';
final httpStringSha1 = sha1.convert(utf8.encode(httpString)).toString();
final stringToSign = 'sha1\n$keyTime\n$httpStringSha1\n';
final signature = _hmacSha1(signKey, stringToSign);
return 'q-sign-algorithm=sha1'
'&q-ak=$secretId'
'&q-sign-time=$keyTime'
'&q-key-time=$keyTime'
'&q-header-list=$headerList'
'&q-url-param-list=$urlParamList'
'&q-signature=$signature';
}
/// HMAC-SHA1 加密。
String _hmacSha1(String key, String data) {
final hmac = Hmac(sha1, utf8.encode(key));
final digest = hmac.convert(utf8.encode(data));
return digest.toString();
}
/// 生成参数列表(key 排序后用 ; 分隔)。
String _generateParamList(Map<String, String> params) {
if (params.isEmpty) return '';
final keys = params.keys.toList()..sort();
return keys.map((k) => k.toLowerCase()).join(';');
}
/// 生成 HTTP 参数字符串(key=value 格式,用 & 分隔,需 URL 编码)。
String _generateHttpParameters(Map<String, String> params) {
if (params.isEmpty) return '';
final keys = params.keys.toList()..sort();
return keys
.map((k) =>
'${Uri.encodeComponent(k.toLowerCase())}=${Uri.encodeComponent(params[k]!)}')
.join('&');
}
/// 根据文件扩展名获取 Content-Type(替代 mime 依赖)。
String _getContentType(String filePath) {
final lower = filePath.toLowerCase();
final dot = lower.lastIndexOf('.');
final ext = dot >= 0 ? lower.substring(dot + 1) : '';
const map = <String, String>{
'jpg': 'image/jpeg',
'jpeg': 'image/jpeg',
'png': 'image/png',
'gif': 'image/gif',
'webp': 'image/webp',
'bmp': 'image/bmp',
'mp4': 'video/mp4',
'webm': 'video/webm',
'mov': 'video/quicktime',
'm4v': 'video/x-m4v',
'pdf': 'application/pdf',
};
return map[ext] ?? 'application/octet-stream';
}
}
/// COS 上传结果。
class CosUploadResult {
/// 是否成功。
final bool success;
/// HTTP 状态码。
final int statusCode;
/// ETag(文件的唯一标识)。
final String? etag;
/// 消息。
final String? message;
/// 对象的 COS 访问地址。
final String? url;
CosUploadResult({
required this.success,
required this.statusCode,
this.etag,
this.message,
this.url,
});
@override
String toString() {
return 'CosUploadResult{success: $success, statusCode: $statusCode, '
'etag: $etag, message: $message, url: $url}';
}
}