Files
CloudHome-Ops/lib/services/upload_service.dart

384 lines
13 KiB
Dart
Raw Normal View History

import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:dio/dio.dart';
import 'package:file_picker/file_picker.dart';
import 'package:image_picker/image_picker.dart';
import 'api_client.dart';
import 'cos/cos_client.dart';
/// 公司介绍(小类/详情)图片与视频上传服务。
///
/// 对齐 Web 端 `company-intro-subcategory-form.vue` 使用的上传通道:
/// 1. 计算文件 MD5;
/// 2. `GET /theme/resource/check-md5` 判断是否已存在(秒传,跳过 COS);
/// 3. `GET /theme/upload/credential` 获取腾讯云 STS 临时凭证(含 bucket/region/key);
/// 4. 用 STS 临时密钥直传 COS(putObject,原生签名 q-sign-algorithm=sha1);
/// 5. `POST /theme/resource/register` 注册 tv_resource;
/// 6. 解析访问 URL(`theme/` 走 ipscdn CDN)返回。
///
/// 复用 [ApiClient.instance.dio],自动携带 Bearer 令牌、解包 `{code,msg,data}` 信封。
class UploadService {
UploadService({Dio? dio}) : _dio = dio ?? ApiClient.instance.dio;
final Dio _dio;
/// 主题上传资源 CDN 根地址(与 Web 端 themeCdnBase 一致)。
static const String _themeCdnBase = 'https://ipscdn.ainetv.cn';
/// 上传单张图片/视频,返回可访问的 CDN URL。
///
/// [mediaType] 资源类型:image | video | file,用于后端凭证与落库。
///
/// 失败统一抛出 [ApiException],message 为可展示的中文错误。
Future<String> uploadImage(XFile file, {String mediaType = 'image'}) async {
final ext = _suffixOf(file.path);
final bytes = await file.readAsBytes();
final fileMd5 = md5.convert(bytes).toString(); // 32 位小写十六进制
// 1. 秒传判断:已存在则直接返回 URL,跳过 COS 直传。
final instant = await _tryInstantUpload(fileMd5);
if (instant != null) return instant;
// 2. 获取 STS 临时凭证(含 bucket / region / key)。
final cred = await _fetchStsCredential(fileMd5, ext, mediaType);
if (cred.key.isEmpty || cred.bucket.isEmpty || cred.region.isEmpty) {
throw const ApiException(500, '上传凭证数据不完整(缺少 key/bucket/region)');
}
// 3. 用 STS 临时密钥直传 COS。
final provider = TemporaryCredentialProvider(
fetchCredentials: () async => CosCredentialData(
startTime: cred.startTime,
expiredTime: cred.expiredTime,
credentials: CosCredential(
tmpSecretId: cred.tmpSecretId,
tmpSecretKey: cred.tmpSecretKey,
sessionToken: cred.securityToken,
),
requestId: '',
),
);
final cos = CosClient.withTemporaryCredential(
temporaryCredentialProvider: provider,
region: cred.region,
bucket: cred.bucket,
);
final result = await cos.putObjectFromFile(
objectKey: cred.key,
file: File(file.path),
);
if (!result.success) {
throw ApiException(500, '云存储上传失败:${result.message ?? "未知错误"}');
}
// 4. 解析可访问 URL(theme/ 走 ipscdn CDN)。
final url = _resolveUrl(cred.key, cred.url);
// 5. 注册 tv_resource(落库,供后续秒传与关联)。
await _registerResource(
fileName: file.name,
fileKey: cred.key,
fileUrl: url,
fileType: mediaType,
fileSize: bytes.length,
suffix: ext,
fileMd5: fileMd5,
);
return url;
}
/// 按原文件名上传 APK 文件(对齐 Web 端 `uploadThemeFileByOriginalName`)。
///
/// 流程:
/// 1. `GET /theme/upload/credential?fileName=<原始文件名>&suffix=apk&type=file` 获取 STS 凭证;
/// 2. 用 STS 临时密钥直传 COS(putObject);
/// 3. 返回可访问 URL(不注册 tv_resource,APK 不走 MD5 秒传)。
///
/// [onProgress] 上传进度回调(0~100)。
///
/// 失败抛出 [ApiException]。
Future<String> uploadApkByOriginalName(
PlatformFile file, {
void Function(int percent)? onProgress,
}) async {
final fileName = file.name.trim();
if (fileName.isEmpty) {
throw const ApiException(400, '文件名不能为空');
}
final ext = _suffixOf(fileName);
final safeExt = (ext == 'apk') ? 'apk' : (ext.isNotEmpty ? ext : 'apk');
// 1. 获取 STS 凭证(fileName = 原始文件名,非 MD5)。
final cred = await _fetchStsCredentialByName(
fileName: fileName,
suffix: safeExt,
type: 'file',
);
if (cred.key.isEmpty || cred.bucket.isEmpty || cred.region.isEmpty) {
throw const ApiException(500, '上传凭证数据不完整(缺少 key/bucket/region)');
}
// 2. 直传 COS。
final provider = TemporaryCredentialProvider(
fetchCredentials: () async => CosCredentialData(
startTime: cred.startTime,
expiredTime: cred.expiredTime,
credentials: CosCredential(
tmpSecretId: cred.tmpSecretId,
tmpSecretKey: cred.tmpSecretKey,
sessionToken: cred.securityToken,
),
requestId: '',
),
);
final cos = CosClient.withTemporaryCredential(
temporaryCredentialProvider: provider,
region: cred.region,
bucket: cred.bucket,
);
// file_picker 的 PlatformFile 可能有 path(Android 通常有)。
final filePath = file.path;
if (filePath == null || filePath.isEmpty) {
throw const ApiException(400, '无法读取选中文件的路径');
}
final localFile = File(filePath);
if (!localFile.existsSync()) {
throw ApiException(400, '文件不存在:$filePath');
}
final result = await cos.putObjectFromFile(
objectKey: cred.key,
file: localFile,
contentType: 'application/vnd.android.package-archive',
onProgress: (count, total) {
if (total > 0 && onProgress != null) {
onProgress(((count / total) * 100).round());
}
},
);
if (!result.success) {
throw ApiException(500, '云存储上传失败:${result.message ?? "未知错误"}');
}
// 3. 解析访问 URL。
final url = _resolveUrl(cred.key, cred.url);
if (url.isEmpty) {
throw const ApiException(500, '未返回访问地址');
}
return url;
}
/// 打开系统文件选择器,仅允许 .apk 文件。
///
/// 返回选中的 [PlatformFile],用户取消返回 null。
static Future<PlatformFile?> pickApkFile() async {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['apk'],
);
final files = result?.files;
if (files == null || files.isEmpty) return null;
return files.first;
}
/// 取文件扩展名(不含点,小写)。
String _suffixOf(String path) {
final name = path.split(Platform.pathSeparator).last;
final idx = name.lastIndexOf('.');
return idx > 0 ? name.substring(idx + 1).toLowerCase() : '';
}
/// 秒传判断:已存在同 MD5 资源则直接返回其访问 URL,否则返回 null。
///
/// 任何异常都不阻塞主流程(当作未命中秒传)。
Future<String?> _tryInstantUpload(String fileMd5) async {
try {
final resp = await _dio.get<Map<String, dynamic>>(
'/theme/resource/check-md5',
queryParameters: {'fileMd5': fileMd5},
);
final data = resp.data;
if (data == null) return null;
bool uploaded = data['uploaded'] == true;
dynamic resourceRaw = data['resource'];
if (data['data'] is Map) {
final inner = data['data'] as Map;
if (inner['uploaded'] == true) {
uploaded = true;
resourceRaw ??= inner['resource'] ?? inner;
}
}
if (!uploaded) return null;
final r = resourceRaw is Map
? Map<String, dynamic>.from(resourceRaw as Map)
: null;
if (r == null) return null;
final id = r['id'] ?? r['resourceId'] ?? r['resource_id'];
if (id == null || id == 0) return null;
final key = (r['key'] ?? r['fileKey'] ?? '').toString();
final rawUrl = (r['url'] ?? r['fileUrl'] ?? '').toString();
final url = _resolveUrl(key, rawUrl);
return url.isNotEmpty ? url : null;
} catch (_) {
return null;
}
}
/// 获取 STS 临时凭证(以文件 MD5 作为 fileName,用于主题资源秒传)。
Future<_StsCred> _fetchStsCredential(
String fileMd5,
String ext,
String mediaType,
) async {
final resp = await _dio.get<Map<String, dynamic>>(
'/theme/upload/credential',
queryParameters: <String, dynamic>{
'fileName': fileMd5,
if (ext.isNotEmpty) 'suffix': ext,
'type': mediaType,
},
);
final data = resp.data;
if (data == null) {
throw const ApiException(500, '获取上传凭证失败:响应为空');
}
return _StsCred.fromJson(Map<String, dynamic>.from(data as Map));
}
/// 获取 STS 临时凭证(以原始文件名作为 fileName,用于 APK 等需保留原名的场景)。
///
/// 对齐 Web 端 `fetchThemeCredentialByOriginalName`:
/// `GET /theme/upload/credential?fileName=<原始名>&suffix=<扩展名>&type=file`
Future<_StsCred> _fetchStsCredentialByName({
required String fileName,
required String suffix,
required String type,
}) async {
final resp = await _dio.get<Map<String, dynamic>>(
'/theme/upload/credential',
queryParameters: <String, dynamic>{
'fileName': fileName,
'suffix': suffix,
'type': type,
},
);
final data = resp.data;
if (data == null) {
throw const ApiException(500, '获取上传凭证失败:响应为空');
}
return _StsCred.fromJson(Map<String, dynamic>.from(data as Map));
}
/// 注册 tv_resource。
Future<void> _registerResource({
required String fileName,
required String fileKey,
required String fileUrl,
required String fileType,
required int fileSize,
required String suffix,
required String fileMd5,
}) async {
try {
await _dio.post<Map<String, dynamic>>(
'/theme/resource/register',
data: <String, dynamic>{
'fileName': fileName,
'fileKey': fileKey,
'fileUrl': fileUrl,
'fileType': fileType,
'fileSize': fileSize,
'suffix': suffix,
'fileMd5': fileMd5,
},
);
} catch (e) {
// 直传已成功,但资源记录未落库:影响后续秒传与关联,明确提示。
throw ApiException(500, '资源注册失败:${formatApiError(e)}');
}
}
/// 解析访问 URL:theme/ 路径走 ipscdn CDN,其余沿用后端 url 或历史 CDN。
static String _resolveUrl(String key, String apiUrl) {
final k = key.replaceFirst(RegExp(r'^/+'), '');
if (k.startsWith('theme/')) {
return '$_themeCdnBase/$k';
}
if (apiUrl.startsWith('http')) return apiUrl;
if (k.startsWith('CloudMedia')) return 'https://metarialcdn.ainetv.cn/$k';
return apiUrl.isNotEmpty ? apiUrl : '$_themeCdnBase/$k';
}
}
/// 归一化后的 STS 临时凭证(兼容后端大小写字段名)。
class _StsCred {
_StsCred({
required this.tmpSecretId,
required this.tmpSecretKey,
required this.securityToken,
required this.startTime,
required this.expiredTime,
required this.bucket,
required this.region,
required this.key,
required this.url,
});
final String tmpSecretId;
final String tmpSecretKey;
final String securityToken;
final int startTime;
final int expiredTime;
final String bucket;
final String region;
final String key;
final String url;
static String _pick(Map m, List<String> keys) {
for (final k in keys) {
final v = m[k];
if (v != null && v.toString().trim().isNotEmpty) return v.toString().trim();
}
return '';
}
static int _pickInt(Map m, List<String> keys) {
for (final k in keys) {
final v = m[k];
if (v != null) {
final n = v is int ? v : int.tryParse(v.toString());
if (n != null && n > 0) return n;
}
}
return 0;
}
factory _StsCred.fromJson(Map<String, dynamic> m) {
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
var start = _pickInt(m, ['StartTime', 'startTime']);
if (start == 0) start = now - 60;
var exp = _pickInt(m, ['ExpiredTime', 'expiredTime']);
if (exp == 0) exp = now + 1800;
return _StsCred(
tmpSecretId: _pick(m, ['TmpSecretId', 'tmpSecretId']),
tmpSecretKey: _pick(m, ['TmpSecretKey', 'tmpSecretKey']),
securityToken: _pick(m, ['SecurityToken', 'securityToken', 'sessionToken']),
startTime: start,
expiredTime: exp,
bucket: _pick(m, ['bucket', 'Bucket']),
region: _pick(m, ['region', 'Region']),
key: _pick(m, ['key', 'Key']),
url: _pick(m, ['url']),
);
}
}