上传任务暂停功能修复 - 开发笔记

January 7, 2026
6 min read
By devshan

Table of Contents

This is a list of all the sections in this post. Click on any of them to jump to that section.

时间:2026-01-07
模块:传输任务状态管理
核心文件:client/lib/core/state/app_state.dartcore/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;
}

时序问题

  1. pauseTransfer 调用 cancelToken.cancel() → token 立即变为 cancelled
  2. 异步上传循环在下一个 tick 检测到 isCancelled
  3. 此时 _updateTransferStatus 可能还未执行
  4. 上传循环调用 _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 启动时没有正确从数据库加载传输任务。

验证:检查 _loadS3ConfigrefreshS3Configs 方法,确认缺少 _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 表

字段类型说明
idTEXT主键
s3ConfigIdTEXTS3 配置 ID
typeINTEGER任务类型
nameTEXT文件名
statusINTEGER状态
progressINTEGER进度 0-100
localPathTEXT本地路径(断点续传)
remotePathTEXT远程路径(断点续传)
fileIdTEXT文件 ID(断点续传)
errorTEXT错误信息
savedPathTEXT保存路径
createdAtDATETIME创建时间

五、状态流转图

┌─────────────┐
│ queued │
└──────┬──────┘
│ 开始执行
┌─────────────┐
┌──────────│ running │──────────┐
│ └──────┬──────┘ │
│ 暂停 │ 完成 │ 出错
▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ paused │ │ success │ │ failed │
└──────┬──────┘ └─────────────┘ └─────────────┘
│ 继续
┌─────────────┐
│ queued │ (重新入队,带断点续传参数)
└─────────────┘

六、设计取舍与决策

6.1 为什么用 CancelToken 而不是状态标志?

选项 A:使用自定义状态标志 isPaused
选项 B:复用 Dio 的 CancelToken 机制

选择 B 的原因

  1. CancelToken 是 Dio 原生支持的机制,可直接取消 HTTP 请求
  2. 避免引入额外的状态变量和同步问题
  3. 暂停和取消本质上都是”中断当前操作”,语义一致

代价

  • 需要在检测到取消后判断是暂停还是真正取消
  • 通过检查当前 TransferStatus 来区分

6.2 为什么先更新状态再取消 token?

问题本质:异步操作的时序不确定性

选项 A:先取消 token,再更新状态(原方案)
选项 B:先更新状态,再取消 token(新方案)

选择 B 的原因

  • 状态更新是同步操作,立即生效
  • 异步上传循环检测到取消时,状态已经是 paused
  • 消除了竞态条件的时间窗口

6.3 为什么需要多个检查点?

上传流程中有多个可能检测到取消的位置:

  1. 上传开始前
  2. API 调用期间
  3. 轮询进度循环中
  4. 后端返回 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 修改文件

  1. client/lib/core/state/app_state.dart

    • pauseTransfer:调整执行顺序
    • cancelTransfer:增加 paused 状态处理
    • _doPathModeUpload:多个检查点增加状态判断
    • _handleUploadCancelled:增加双重保险
    • _loadS3Config / refreshS3Configs:添加传输任务加载
  2. core/internal/api/files.go

    • doUploadByPath:在生成 fileId 后立即设置到 progress

8.2 关键修改点

位置修改内容目的
pauseTransfer L2988-2989先更新状态再取消 token消除竞态条件
cancelTransfer L2904-2911处理 paused 状态允许取消暂停任务
_doPathModeUpload L1784-1795启动前检查防止启动时误取消
_doPathModeUpload L1814-1827API 后检查防止 API 期间误取消
_doPathModeUpload L1833-1847轮询中检查防止轮询时误取消
_doPathModeUpload L1859-1869cancelled 状态检查防止后端取消误处理
_handleUploadCancelled L1994-1999状态双重检查最后防线
files.go L217-220提前设置 fileId支持断点续传

九、测试验证清单

  • 暂停运行中的上传任务 → 状态变为 paused
  • 暂停排队中的上传任务 → 状态变为 paused
  • 继续暂停的任务 → 断点续传,不从 0 开始
  • App 重启后 → 暂停任务仍在列表中
  • 取消暂停的任务 → 状态变为 cancelled
  • 快速连续暂停/继续 → 无异常

本笔记记录了完整的问题分析、方案设计、实现细节和经验总结,可作为后续类似问题的参考。