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 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 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 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 _tryInstantUpload(String fileMd5) async { try { final resp = await _dio.get>( '/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.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>( '/theme/upload/credential', queryParameters: { 'fileName': fileMd5, if (ext.isNotEmpty) 'suffix': ext, 'type': mediaType, }, ); final data = resp.data; if (data == null) { throw const ApiException(500, '获取上传凭证失败:响应为空'); } return _StsCred.fromJson(Map.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>( '/theme/upload/credential', queryParameters: { 'fileName': fileName, 'suffix': suffix, 'type': type, }, ); final data = resp.data; if (data == null) { throw const ApiException(500, '获取上传凭证失败:响应为空'); } return _StsCred.fromJson(Map.from(data as Map)); } /// 注册 tv_resource。 Future _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>( '/theme/resource/register', data: { '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 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 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 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']), ); } }