feat(file-download): 文件下载全量实现 (#14~#18)
- CdnUrlResolver: 相对路径/S3 URL 统一解析为 apiBaseUrl 代理链路 (#14) - FileDownloadManager: Notifier<Map<url, state>>,四态(idle/progress/done/failed), 断点续传 + 取消令牌 + 本地缓存(systemTemp/im_file_cache) (#15) - FileMessageBubble: 三态 UI,文件类型图标/颜色,大小格式化, idle/failed 点击触发下载,done 点击回调 open_filex TODO (#16) - AudioMessageBubble: 语音消息下载框架,静态波形装饰, 播放 TODO(audioplayers 接入后解开) (#17) - VideoMessageBubble: 缩略图 Image.network + CdnUrlResolver, 播放按钮覆盖,上传进度环,video_player 接入 TODO (#18) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
72
apps/im_app/lib/core/services/cdn_url_resolver.dart
Normal file
72
apps/im_app/lib/core/services/cdn_url_resolver.dart
Normal file
@@ -0,0 +1,72 @@
|
||||
import 'package:im_app/core/foundation/config.dart';
|
||||
|
||||
/// CDN URL 解析器
|
||||
///
|
||||
/// 对应 Gitea issue #14
|
||||
///
|
||||
/// 文件/图片/语音/视频的 CDN 路径有三种形态:
|
||||
/// 1. 相对路径(如 `Files/xxx.pdf`, `Image/xxx.jpg`)
|
||||
/// → 拼接 [AppConfig.apiBaseUrl]
|
||||
/// 2. 完整 http/https URL(非 S3)
|
||||
/// → 直接返回
|
||||
/// 3. S3 URL(含 `.amazonaws.com`)
|
||||
/// → 路由通过 [AppConfig.apiBaseUrl] 代理(S3 桶是私有的,直接访问 403)
|
||||
/// → 提取 path 部分拼接 baseUrl
|
||||
///
|
||||
/// ## 使用
|
||||
///
|
||||
/// ```dart
|
||||
/// final url = CdnUrlResolver.resolve('Files/report.pdf');
|
||||
/// // → 'http://gateway.winwayinfo.com/Files/report.pdf'
|
||||
/// ```
|
||||
// ignore: avoid_classes_with_only_static_members
|
||||
class CdnUrlResolver {
|
||||
CdnUrlResolver._();
|
||||
|
||||
/// 将任意格式的 CDN 路径解析为完整可下载 URL
|
||||
static String resolve(String path) {
|
||||
if (path.isEmpty) return path;
|
||||
|
||||
// S3 直链 → 路由通过 API 代理
|
||||
if (_isS3Url(path)) {
|
||||
return _proxyS3(path);
|
||||
}
|
||||
|
||||
// 完整 HTTP URL(非 S3)→ 直接使用
|
||||
if (path.startsWith('http://') || path.startsWith('https://')) {
|
||||
return path;
|
||||
}
|
||||
|
||||
// 相对路径 → 拼接 baseUrl
|
||||
final base = AppConfig.apiBaseUrl.endsWith('/')
|
||||
? AppConfig.apiBaseUrl.substring(0, AppConfig.apiBaseUrl.length - 1)
|
||||
: AppConfig.apiBaseUrl;
|
||||
final suffix = path.startsWith('/') ? path : '/$path';
|
||||
return '$base$suffix';
|
||||
}
|
||||
|
||||
/// 判断是否为 S3 URL
|
||||
static bool _isS3Url(String url) {
|
||||
return url.contains('.amazonaws.com') ||
|
||||
url.contains('s3-accelerate') ||
|
||||
url.contains('s3.amazonaws');
|
||||
}
|
||||
|
||||
/// S3 URL → 通过 apiBaseUrl 代理
|
||||
///
|
||||
/// S3 presigned URL 带有 `/bucket-name/path?X-Amz-...` 结构,
|
||||
/// 取 path 部分(去掉 query string)拼接 baseUrl。
|
||||
static String _proxyS3(String s3Url) {
|
||||
try {
|
||||
final uri = Uri.parse(s3Url);
|
||||
// path 可能是 /bucket/Image/xxx 或 /Image/xxx,取最后有意义的段
|
||||
final path = uri.path;
|
||||
final base = AppConfig.apiBaseUrl.endsWith('/')
|
||||
? AppConfig.apiBaseUrl.substring(0, AppConfig.apiBaseUrl.length - 1)
|
||||
: AppConfig.apiBaseUrl;
|
||||
return '$base$path';
|
||||
} catch (_) {
|
||||
return s3Url;
|
||||
}
|
||||
}
|
||||
}
|
||||
183
apps/im_app/lib/core/services/file_download_manager.dart
Normal file
183
apps/im_app/lib/core/services/file_download_manager.dart
Normal file
@@ -0,0 +1,183 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:networks_sdk/networks_sdk.dart';
|
||||
|
||||
import 'package:im_app/app/di/network_provider.dart';
|
||||
import 'package:im_app/core/services/cdn_url_resolver.dart';
|
||||
|
||||
/// 单文件下载状态
|
||||
///
|
||||
/// 对应 iOS `FileDownloadState` enum
|
||||
sealed class FileDownloadState {
|
||||
const FileDownloadState();
|
||||
}
|
||||
|
||||
/// 未下载(初始态)
|
||||
class FileDownloadIdle extends FileDownloadState {
|
||||
const FileDownloadIdle();
|
||||
}
|
||||
|
||||
/// 下载中(0.0 ~ 1.0)
|
||||
class FileDownloadProgress extends FileDownloadState {
|
||||
final double progress;
|
||||
const FileDownloadProgress(this.progress);
|
||||
}
|
||||
|
||||
/// 已下载,localPath 为本地缓存路径
|
||||
class FileDownloadDone extends FileDownloadState {
|
||||
final String localPath;
|
||||
const FileDownloadDone(this.localPath);
|
||||
}
|
||||
|
||||
/// 下载失败
|
||||
class FileDownloadFailed extends FileDownloadState {
|
||||
final String error;
|
||||
const FileDownloadFailed(this.error);
|
||||
}
|
||||
|
||||
/// 文件下载管理器
|
||||
///
|
||||
/// 对应 Gitea issue #15
|
||||
///
|
||||
/// 维护 URL → [FileDownloadState] 的映射,通过 [NetworksSdkApi.executeDownload]
|
||||
/// 执行带进度的文件下载(内部支持断点续传)。
|
||||
///
|
||||
/// ## 缓存策略
|
||||
///
|
||||
/// 缓存目录:`Directory.systemTemp/im_file_cache/`(同 iOS Library/Caches 语义,OS 负责清理)
|
||||
/// 缓存命名:`{url.hashCode 8位 hex}_{sanitized_filename}`
|
||||
///
|
||||
/// ## 幂等性
|
||||
///
|
||||
/// [download] 先检查本地缓存,命中则直接更新状态为 [FileDownloadDone],不发起网络请求。
|
||||
///
|
||||
/// ## 并发控制
|
||||
///
|
||||
/// 同一 URL 下载中时再次调用 [download] 会静默忽略。
|
||||
///
|
||||
/// ## 使用
|
||||
///
|
||||
/// ```dart
|
||||
/// // 触发下载
|
||||
/// ref.read(fileDownloadManagerProvider.notifier).download(
|
||||
/// url: 'Files/report.pdf',
|
||||
/// fileName: 'report.pdf',
|
||||
/// );
|
||||
///
|
||||
/// // 读取状态
|
||||
/// final dlState = ref.watch(fileDownloadManagerProvider.select(
|
||||
/// (map) => map['Files/report.pdf'],
|
||||
/// ));
|
||||
/// ```
|
||||
class FileDownloadManager extends Notifier<Map<String, FileDownloadState>> {
|
||||
/// 进行中的取消令牌,key = 原始 URL
|
||||
final _cancelTokens = <String, CancelToken>{};
|
||||
|
||||
/// 已主动取消的 URL,防止 catch 块将其状态覆盖为 failed
|
||||
final _cancelledUrls = <String>{};
|
||||
|
||||
@override
|
||||
Map<String, FileDownloadState> build() => {};
|
||||
|
||||
// ── 公开 API ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 下载文件(幂等)
|
||||
///
|
||||
/// [url] 原始 CDN 路径(相对路径或完整 URL),由 [CdnUrlResolver.resolve] 处理。
|
||||
/// [fileName] 原始文件名(用于缓存命名和 MIME 类型推断)。
|
||||
///
|
||||
/// 返回本地缓存路径(已缓存时立即返回,新下载完成后返回)。
|
||||
Future<String?> download({
|
||||
required String url,
|
||||
required String fileName,
|
||||
}) async {
|
||||
// 检查本地缓存
|
||||
final cachePath = await _cachePath(url, fileName);
|
||||
final file = File(cachePath);
|
||||
if (file.existsSync() && file.lengthSync() > 0) {
|
||||
_updateState(url, FileDownloadDone(cachePath));
|
||||
return cachePath;
|
||||
}
|
||||
|
||||
// 已在下载中,忽略
|
||||
final current = state[url];
|
||||
if (current is FileDownloadProgress) return null;
|
||||
|
||||
// 开始下载
|
||||
_updateState(url, const FileDownloadProgress(0));
|
||||
final cancelToken = CancelToken();
|
||||
_cancelTokens[url] = cancelToken;
|
||||
|
||||
try {
|
||||
final resolvedUrl = CdnUrlResolver.resolve(url);
|
||||
final api = ref.read(networkSdkApiProvider);
|
||||
|
||||
await api.executeDownload(
|
||||
url: resolvedUrl,
|
||||
savePath: cachePath,
|
||||
cancelToken: cancelToken,
|
||||
resume: true,
|
||||
onProgress: (received, total) {
|
||||
if (total > 0) {
|
||||
final progress = received / total;
|
||||
_updateState(url, FileDownloadProgress(progress.clamp(0.0, 1.0)));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
_cancelTokens.remove(url);
|
||||
_updateState(url, FileDownloadDone(cachePath));
|
||||
return cachePath;
|
||||
} catch (e) {
|
||||
_cancelTokens.remove(url);
|
||||
// 若已被主动取消,state 已置为 idle,不再覆盖
|
||||
if (_cancelledUrls.remove(url)) return null;
|
||||
_updateState(url, FileDownloadFailed(e.toString()));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 取消下载
|
||||
void cancelDownload(String url) {
|
||||
_cancelledUrls.add(url);
|
||||
_cancelTokens[url]?.cancel('用户取消');
|
||||
_cancelTokens.remove(url);
|
||||
_updateState(url, const FileDownloadIdle());
|
||||
}
|
||||
|
||||
/// 获取本地缓存路径(不触发下载)
|
||||
///
|
||||
/// 返回 null 表示尚未下载。
|
||||
Future<String?> localPath(String url, String fileName) async {
|
||||
final path = await _cachePath(url, fileName);
|
||||
final file = File(path);
|
||||
if (file.existsSync() && file.lengthSync() > 0) return path;
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── 内部辅助 ────────────────────────────────────────────────────────────────
|
||||
|
||||
void _updateState(String url, FileDownloadState s) {
|
||||
state = {...state, url: s};
|
||||
}
|
||||
|
||||
/// 构造缓存文件路径
|
||||
///
|
||||
/// 格式:`{tempDir}/im_file_cache/{hash8}_{safeName}`
|
||||
Future<String> _cachePath(String url, String fileName) async {
|
||||
final cacheDir = Directory('${Directory.systemTemp.path}/im_file_cache');
|
||||
if (!cacheDir.existsSync()) {
|
||||
await cacheDir.create(recursive: true);
|
||||
}
|
||||
final hash = url.hashCode.toUnsigned(32).toRadixString(16).padLeft(8, '0');
|
||||
final safeName = fileName.replaceAll(RegExp(r'[^\w.\-]'), '_');
|
||||
return '${cacheDir.path}/${hash}_$safeName';
|
||||
}
|
||||
}
|
||||
|
||||
/// 文件下载管理器 Provider
|
||||
final fileDownloadManagerProvider =
|
||||
NotifierProvider<FileDownloadManager, Map<String, FileDownloadState>>(
|
||||
FileDownloadManager.new,
|
||||
);
|
||||
Reference in New Issue
Block a user