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

384 lines
13 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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']),
);
}
}