下载一个大文件传到一半断了,续传很直接:本地已经落了 N 字节,发个 Range: bytes=N- 从第 N 字节接着下就行。Nebula 的下载早就这么做了。

上传反过来,难得多。这次补上的是上传断点续传:大文件传到一半断网、或 App 被关掉,重新拖上去同一个文件,会接着传,不从头再来。

为什么上传不能照抄下载

下载能续传,是因为本地文件支持随机写——你想从哪个偏移接着写都行。但上传的目标是对象存储里的一个 object,而 object 不能追加写:一次 PUT 就是一个完整对象,你没法"往一个传了一半的 object 后面接着塞字节"。传一半断了,那半个对象根本不存在。

所以上传续传只有一条路:分片上传(multipart)。把大文件切成若干分片,逐个上传;每个分片独立成功,服务端替你留着;全部传完再发一个"完成"请求把它们拼成最终对象。分片上传本来是为"大文件不占内存"设计的,但它恰好给了续传的支点——已经传成功的分片,服务端一直留着,只要我们知道哪些传完了,就能只补剩下的。

关键:把"传到哪了"存下来

分片上传的生命周期是:初始化 → 拿到 upload_id → 逐个 upload_part(拿到每片 ETag)→ complete(带上所有 <片号, ETag>)。续传要复用的,就是这个 upload_id 和已传分片的 ETag 列表。

所以每传完一个分片,我们就把进度落到 SQLite:

let etag = provider.upload_part(remote_path, &upload_id, n, bytes).await?;
done.push((n, etag));
self.save_session(&key, &upload_id, size, mtime, part_size, &done); // 落库

upload_sessions 表里存 upload_id + 已完成分片 + 文件大小 + 修改时间。下次上传同一个文件,先查有没有匹配的会话:

let (upload_id, mut done) = match self.load_session(&key, size, mtime, part_size) {
    Some(resumed) => resumed,               // 命中 → 复用 upload_id + 跳过已传分片
    None => (provider.begin_multipart(...).await?, Vec::new()),  // 全新上传
};

会话键取 (账号, 远端路径, 本地路径) 的 MD5,再用文件大小 + 修改时间双重校验——文件被改过(大小或 mtime 变了),旧会话作废、重新来,避免把新旧内容拼成一个损坏对象。分片按偏移从磁盘现读现传,内存始终只占一个分片。

反直觉的一点:出错时不要清理

分片上传都有个 abort 接口,用来放弃上传、清掉服务端已传的分片。直觉上,上传失败就该 abort 清理干净——但那正好毁掉续传

一旦 abort,服务端那些辛辛苦苦传上去的分片就没了,upload_id 也失效,下次只能从头再传。所以我们反着来:出错时什么都不清理,让会话和服务端分片都留着:

let etag = provider.upload_part(...).await?;  // 失败就直接 ? 向上抛,不 abort

传输面板里点"重试",重新跑一遍上传命令,它查到未完成的会话,从断点接着传。“重试"在这里天然就是"续传”。至于永久放弃的残留分片,对象存储的生命周期规则会自动过期回收,不用我们操心。

又一次:对每家云一视同仁

续传逻辑活在 App 层,只依赖 provider trait 上新加的四个方法(begin_multipart / upload_part / complete_multipart / abort_multipart)。这四个方法有安全的默认实现——默认返回"不支持",调用方就回退到整体上传。凡是实现了它们、并把 resumable_upload 能力置真的 provider,就自动获得断点续传。

目前接入的每家云都实现了(它们的 SDK 本就有这套分片生命周期),所以七家全都能续传;以后新接入的云,不实现也不会出错(自动回退),实现了就自动升级。能力建在公共抽象上,续传逻辑只写一次,厂商越加越多,覆盖越广。

小结

上传续传的三块拼图:用分片上传拿到"服务端留着已传分片"这个支点;把 upload_id + 已完成分片 持久化,靠文件大小/mtime 匹配同一次上传;以及最反直觉的一条——失败别清理,让重试变成续传。传到 90% 断网,接着传就是了。