ops-console v1.0.2 (versionCode 8) 源码入库
This commit is contained in:
355
lib/services/cos/cos_client.dart
Normal file
355
lib/services/cos/cos_client.dart
Normal 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}';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user