384 lines
13 KiB
Dart
384 lines
13 KiB
Dart
|
|
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']),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|