时间:2026-01-07
模块:传输任务状态管理
核心文件:client/lib/core/state/app_state.dart、core/internal/api/files.go
一、问题背景
1.1 我这边用的时候发现
我这边用的时候记录:进行中的上传任务,暂停后直接消失了,没办法在 UI 上的任何地方找到。
1.2 预期行为
- 暂停任务后,任务应保持在传输列表中,状态显示为
paused - App 重启后,暂停的任务应能从数据库恢复
- 点击继续后,应支持断点续传(从上次进度继续)
- 暂停状态的任务可以被取消
二、问题分析与根因定位
2.1 第一轮排查:状态被覆盖为 cancelled
现象:暂停后任务消失,且控制台输出取消相关日志。
假设:暂停操作后,异步上传循环检测到 cancelToken.isCancelled,将状态覆盖为 cancelled。
验证方法:添加 debug 日志,观察状态流转。
根因确认:存在竞态条件(Race Condition)。
原有代码执行顺序:
// pauseTransfer 方法
final uploadJob = _uploadJobs[id];
if (uploadJob != null) {
uploadJob.cancelToken.cancel('手动暂停'); // ① 先取消 token
_updateTransferStatus(id, TransferStatus.paused, null); // ② 再更新状态
return;
}异步上传循环:
// _doPathModeUpload 方法
if (job.cancelToken.isCancelled) { // ③ 检测到取消
// 此时状态可能还是 running(② 尚未执行)
_handleUploadCancelled(job); // ④ 将状态设为 cancelled
return;
}时序问题:
pauseTransfer调用cancelToken.cancel()→ token 立即变为 cancelled- 异步上传循环在下一个 tick 检测到
isCancelled - 此时
_updateTransferStatus可能还未执行 - 上传循环调用
_handleUploadCancelled,将状态覆盖为cancelled
2.2 第二轮排查:断点续传失败
现象:暂停任务后再继续,上传从 0% 重新开始,未实现断点续传。
假设:数据库中没有保存 fileId,导致 resumeTransfer 时无法获取续传信息。
验证:查询数据库,确认 fileId 字段为 null。
根因:后端只在上传完成时才设置 fileId 到 progress 中。
原有代码:
// doUploadByPath 方法
// ... 上传完成后
if progress != nil {
progress.Phase = "done"
progress.Progress = 100
progress.FileID = fileID // 只在完成时设置
progress.Metadata = &metadata
}前端轮询时获取的 progress 中没有 fileId,导致数据库中该字段始终为 null。
2.3 第三轮排查:App 重启后任务消失
现象:正常暂停后任务显示正确,但 App 重启后暂停任务消失。
假设:App 启动时没有正确从数据库加载传输任务。
验证:检查 _loadS3Config 和 refreshS3Configs 方法,确认缺少 _loadTransfersFromDb 调用。
根因:传输任务加载依赖于 S3 配置激活,但相关方法未触发加载。
三、解决方案
3.1 修复竞态条件:调整执行顺序
核心思想:先更新状态,再取消 token
修改后:
void pauseTransfer(String id) {
// ...
final uploadJob = _uploadJobs[id];
if (uploadJob != null) {
_updateTransferStatus(id, TransferStatus.paused, null); // ① 先更新状态
uploadJob.cancelToken.cancel('手动暂停'); // ② 再取消 token
return;
}
// ...
}补充防护:在上传循环的多个检查点增加状态判断
// _doPathModeUpload 方法中的多个检查点
if (job.cancelToken.isCancelled) {
final idx = _transfers.indexWhere((t) => t.id == job.id);
if (idx >= 0 && _transfers[idx].status == TransferStatus.paused) {
// 暂停:通知后端取消上传任务,但保留前端任务记录
await api.cancelUpload(taskId);
_runningUploads--;
notifyListeners();
return; // 不调用 _handleUploadCancelled
}
// 真正取消
await api.cancelUpload(taskId);
_handleUploadCancelled(job);
return;
}_handleUploadCancelled 也增加双重保险:
void _handleUploadCancelled(_UploadJob job) {
final idx = _transfers.indexWhere((t) => t.id == job.id);
if (idx >= 0) {
final currentStatus = _transfers[idx].status;
// 双重保险:如果已经是 paused 状态,不要覆盖
if (currentStatus == TransferStatus.paused) {
_runningUploads--;
notifyListeners();
return;
}
_transfers[idx] = _transfers[idx].copyWith(
status: TransferStatus.cancelled,
error: '已取消',
);
}
// ...
}3.2 修复断点续传:提前设置 fileId
核心思想:fileId 在生成后立即设置到 progress,而非等到上传完成
修改后(files.go):
func (s *Server) doUploadByPath(...) {
// ...
// 断点续传:如果指定了 resumeFileID,则继续上传;否则新建文件
fileID := resumeFileID
if fileID == "" {
fileID = newFileID()
}
// 立即设置 fileID,让前端能在上传过程中获取(用于断点续传)
if progress != nil {
progress.FileID = fileID
}
// ... 继续上传流程
}前端轮询进度时保存 fileId:
// 保存进度,同时保存 fileId 用于断点续传
db.updateTransfer(
id: job.id,
progress: progress.progress,
size: fileSize,
fileId: progress.fileId, // 关键:保存 fileId
);3.3 修复 App 启动加载:补充调用点
在 S3 配置加载相关方法中添加传输任务加载:
Future<void> _loadS3Config() async {
// ... 加载配置
await _loadTransfersFromDb(); // 添加
}
Future<void> refreshS3Configs() async {
// ... 刷新配置
await _loadTransfersFromDb(); // 添加
}3.4 修复暂停任务的取消功能
问题:暂停状态的任务无法被取消。
原因:cancelTransfer 方法只处理队列中和进行中的任务,不处理已暂停的任务。
修复:增加对 paused 状态的处理
void cancelTransfer(String id) {
// 检查是否是暂停状态
final idx = _transfers.indexWhere((t) => t.id == id);
if (idx >= 0 && _transfers[idx].status == TransferStatus.paused) {
_updateTransferStatus(id, TransferStatus.cancelled, '已取消');
_uploadJobs.remove(id);
_downloadJobs.remove(id);
_importJobs.remove(id);
return;
}
// ... 其他逻辑
}四、关键数据结构
4.1 TransferStatus 状态枚举
enum TransferStatus {
queued, // 排队中
running, // 运行中
finishing, // 收尾中
paused, // 已暂停
success, // 成功
failed, // 失败
cancelled, // 已取消
}4.2 _UploadJob 结构
class _UploadJob {
final String id;
final String filePath;
final String fileName;
final String remotePath;
final CancelToken cancelToken;
// 断点续传字段
final String? resumeFileId; // 续传时使用的文件 ID
final int startChunk; // 续传起始分块
}4.3 数据库 TransferTasks 表
| 字段 | 类型 | 说明 |
|---|---|---|
| id | TEXT | 主键 |
| s3ConfigId | TEXT | S3 配置 ID |
| type | INTEGER | 任务类型 |
| name | TEXT | 文件名 |
| status | INTEGER | 状态 |
| progress | INTEGER | 进度 0-100 |
| localPath | TEXT | 本地路径(断点续传) |
| remotePath | TEXT | 远程路径(断点续传) |
| fileId | TEXT | 文件 ID(断点续传) |
| error | TEXT | 错误信息 |
| savedPath | TEXT | 保存路径 |
| createdAt | DATETIME | 创建时间 |
五、状态流转图
┌─────────────┐ │ queued │ └──────┬──────┘ │ 开始执行 ▼ ┌─────────────┐ ┌──────────│ running │──────────┐ │ └──────┬──────┘ │ │ 暂停 │ 完成 │ 出错 ▼ ▼ ▼ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ paused │ │ success │ │ failed │ └──────┬──────┘ └─────────────┘ └─────────────┘ │ 继续 ▼ ┌─────────────┐ │ queued │ (重新入队,带断点续传参数) └─────────────┘六、设计取舍与决策
6.1 为什么用 CancelToken 而不是状态标志?
选项 A:使用自定义状态标志 isPaused
选项 B:复用 Dio 的 CancelToken 机制
选择 B 的原因:
- CancelToken 是 Dio 原生支持的机制,可直接取消 HTTP 请求
- 避免引入额外的状态变量和同步问题
- 暂停和取消本质上都是”中断当前操作”,语义一致
代价:
- 需要在检测到取消后判断是暂停还是真正取消
- 通过检查当前 TransferStatus 来区分
6.2 为什么先更新状态再取消 token?
问题本质:异步操作的时序不确定性
选项 A:先取消 token,再更新状态(原方案)
选项 B:先更新状态,再取消 token(新方案)
选择 B 的原因:
- 状态更新是同步操作,立即生效
- 异步上传循环检测到取消时,状态已经是 paused
- 消除了竞态条件的时间窗口
6.3 为什么需要多个检查点?
上传流程中有多个可能检测到取消的位置:
- 上传开始前
- API 调用期间
- 轮询进度循环中
- 后端返回 cancelled 状态时
每个检查点都需要判断是暂停还是取消,确保任何时刻暂停都不会被误处理为取消。
6.4 fileId 设置时机的权衡
选项 A:上传完成后设置 fileId
选项 B:生成后立即设置 fileId
选择 B 的原因:
- 断点续传需要在暂停时知道 fileId
- 前端需要在轮询进度时持久化 fileId 到数据库
- 即使上传未完成,S3 上的分块对象已经存在,fileId 有效
七、经验教训
7.1 竞态条件是异步编程的常见陷阱
- 状态更新和异步操作的顺序至关重要
- 同步操作应在异步操作之前完成
- 使用”先设置期望状态,再触发操作”的模式
7.2 多检查点防护是必要的
- 异步流程中的任何中断点都可能发生状态检查
- 不能依赖单一检查点,需要在所有关键位置添加防护
- 双重保险胜于单点防护
7.3 断点续传需要完整的状态持久化
- fileId、localPath、remotePath、progress 都需要持久化
- 前端需要在上传过程中实时保存进度信息
- 后端需要尽早返回 fileId,而非等到完成
7.4 调试日志的价值
- 在定位竞态条件时,详细的日志输出帮助还原时序
- 关键状态变更点都应该有日志
- 问题解决后清理调试日志,保持代码整洁
八、最终代码清单
8.1 修改文件
-
client/lib/core/state/app_state.dartpauseTransfer:调整执行顺序cancelTransfer:增加 paused 状态处理_doPathModeUpload:多个检查点增加状态判断_handleUploadCancelled:增加双重保险_loadS3Config/refreshS3Configs:添加传输任务加载
-
core/internal/api/files.godoUploadByPath:在生成 fileId 后立即设置到 progress
8.2 关键修改点
| 位置 | 修改内容 | 目的 |
|---|---|---|
| pauseTransfer L2988-2989 | 先更新状态再取消 token | 消除竞态条件 |
| cancelTransfer L2904-2911 | 处理 paused 状态 | 允许取消暂停任务 |
| _doPathModeUpload L1784-1795 | 启动前检查 | 防止启动时误取消 |
| _doPathModeUpload L1814-1827 | API 后检查 | 防止 API 期间误取消 |
| _doPathModeUpload L1833-1847 | 轮询中检查 | 防止轮询时误取消 |
| _doPathModeUpload L1859-1869 | cancelled 状态检查 | 防止后端取消误处理 |
| _handleUploadCancelled L1994-1999 | 状态双重检查 | 最后防线 |
| files.go L217-220 | 提前设置 fileId | 支持断点续传 |
九、测试验证清单
- 暂停运行中的上传任务 → 状态变为 paused
- 暂停排队中的上传任务 → 状态变为 paused
- 继续暂停的任务 → 断点续传,不从 0 开始
- App 重启后 → 暂停任务仍在列表中
- 取消暂停的任务 → 状态变为 cancelled
- 快速连续暂停/继续 → 无异常
本笔记记录了完整的问题分析、方案设计、实现细节和经验总结,可作为后续类似问题的参考。