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:
pp-bot
2026-03-23 20:02:46 +09:00
parent aeeda6f059
commit 83774f5f61
6 changed files with 1096 additions and 0 deletions

View File

@@ -0,0 +1,163 @@
# 文件下载 — 架构文档
> 对应 Gitea issues #14#18
> 参考实现:`im-client-ios-swift-demo` FileDownloadManager + AudioPlaybackService + ChatMessageBubble
---
## 1. 功能范围
| Issue | 功能 | 状态 |
|-------|------|------|
| #14 | CDN URL 解析器CdnUrlResolver | ✅ 已实现 |
| #15 | FileDownloadManager — 下载服务 + 缓存 | ✅ 已实现 |
| #16 | FileMessageBubble — 文件气泡三态 UI | ✅ 已实现 |
| #17 | AudioMessageBubble — 语音下载框架 | ✅ 框架(播放需 audioplayers |
| #18 | VideoMessageBubble — 视频缩略图框架 | ✅ 框架(播放需 video_player |
---
## 2. 目录结构
```
core/services/
├── cdn_url_resolver.dart # CDN 路径 → 完整 URL
└── file_download_manager.dart # 下载服务(进度追踪 + 缓存)
features/chat/view/widgets/
├── file_message_bubble.dart # 文件消息气泡(三态 UI
├── audio_message_bubble.dart # 语音消息气泡(下载框架)
└── video_message_bubble.dart # 视频消息气泡(缩略图框架)
```
---
## 3. 核心概念
### 3.1 FileDownloadState
```
idle → 未下载(显示下载图标)
downloading(progress: 0.0~1.0) → 下载中(进度条)
downloaded(localPath: String) → 已缓存(显示打开图标)
failed(error: String) → 失败(显示重试图标)
```
### 3.2 CDN URL 路由策略
```
相对路径 Files/xxx.pdf → apiBaseUrl/Files/xxx.pdf
完整 HTTP http://xxx.com/f → 直接使用
S3 URL *.amazonaws.com → apiBaseUrl + S3 path通过 API 代理)
```
S3 桶是私有的,直接访问返回 403。所有下载都必须经过 `apiBaseUrl` 代理。
### 3.3 缓存策略
```
缓存目录Directory.systemTemp/im_file_cache/
文件名: {url.hashCode 8位 hex}_{sanitized_filename}
命中条件:文件存在 && 大小 > 0
清理策略:由 OS 自动清理 temp 目录(同 iOS Library/Caches 语义)
```
---
## 4. 数据流
### 4.1 文件消息下载
```
FileMessageBubble.onTap
└─ fileDownloadManagerProvider.notifier.download(url, fileName)
├─ CdnUrlResolver.resolve(url) → 完整下载 URL
├─ _cacheKeyFor(url, fileName) → 本地文件名
├─ File(cachePath).exists() ?
│ true → state = downloaded(cachePath)
│ false → NetworksSdkApi.executeDownload(url, savePath, onProgress)
│ → state = downloading(progress)
│ → state = downloaded(cachePath) / failed(error)
└─ 返回 localPath供 open_filex 打开)
```
### 4.2 语音消息
```
AudioMessageBubble
└─ 点击播放按钮
└─ FileDownloadManager.download(voiceUrl, 'voice_{cmid}.m4a')
→ downloaded(localPath)
→ TODO: AudioPlayer().play(DeviceFileSource(localPath))
```
### 4.3 视频消息
```
VideoMessageBubble
└─ 显示Image.network(CdnUrlResolver.resolve(thumbUrl)) // 缩略图
└─ 点击播放按钮
→ TODO: Navigator 推送全屏视频页video_player 流式播放)
```
---
## 5. Provider 设计
```
fileDownloadManagerProvider
NotifierProvider<FileDownloadManager, Map<String, FileDownloadState>>
└─ NetworksSdkApi (via networkSdkApiProvider)
```
与 Settings ViewModel 一致,使用手动 `Notifier<T>` + `NotifierProvider`
不依赖 `@riverpod` 代码生成。
---
## 6. 消息类型路由rawContent 解析)
| typ | 类型 | rawContent 格式 |
|-----|------|----------------|
| 3 | 语音 | `{"url":"...","duration":N}` |
| 4 | 视频 | `{"url":"...","thumb":"...","size":N}` |
| 6 | 文件 | `{"url":"...","name":"...","size":N}` |
| 24 | 视频(秘密) | 同 typ=4 |
---
## 7. 待完成
- **audioplayers 接入**`AudioMessageBubble` 中 TODO 解开,安装 `audioplayers: ^6.0.0`
- **video_player 接入**`VideoMessageBubble` 点击推送全屏路由,安装 `video_player: ^2.9.0`
- **open_filex 接入**`FileMessageBubble` downloaded 态打开文件,安装 `open_filex: ^3.0.3`
- **XOR 解密**`secret/` 路径文件解密cipher_guard_sdk 接入时实现)
### 接入 open_filex 步骤
```yaml
# apps/im_app/pubspec.yaml
dependencies:
open_filex: ^3.0.3
```
```dart
// FileMessageBubble downloaded 态 onTap
import 'package:open_filex/open_filex.dart';
await OpenFilex.open(localPath);
```
### 接入 audioplayers 步骤
```yaml
# apps/im_app/pubspec.yaml
dependencies:
audioplayers: ^6.0.0
```
```dart
// AudioMessageBubble 播放:
import 'package:audioplayers/audioplayers.dart';
final player = AudioPlayer();
await player.play(DeviceFileSource(localPath));
```