Skip to content

feat(upload): add multipart uploader with resumable chunked transfer#577

Open
thetapilla wants to merge 7 commits into
OpenListTeam:mainfrom
thetapilla:feat/multipart-upload
Open

feat(upload): add multipart uploader with resumable chunked transfer#577
thetapilla wants to merge 7 commits into
OpenListTeam:mainfrom
thetapilla:feat/multipart-upload

Conversation

@thetapilla

@thetapilla thetapilla commented Jul 5, 2026

Copy link
Copy Markdown

Summary / 摘要

为上传界面新增 Multipart 上传方式(配套后端分块上传 API),解决经 CDN 反代(如 Cloudflare 免费计划 100MB 请求体上限)时网页端无法上传大文件的问题。

用户可感知的变化:

  • 上传方式下拉框新增 Multipart 选项(由后端 multipart_enabled 公共设置门控,后端未启用或版本过旧时不显示,自动向后兼容)
  • 断点续传:上传中断(断网、刷新页面)后重新拖入同一文件,自动从已接收的分片之后继续;勾选"尝试秒传"时以文件哈希作为身份凭证
  • 失败重试:上传失败的行新增"重试"按钮,复用仍持有的 File 句柄从断点恢复,无需重新选择文件
  • 秒传:勾选"尝试秒传"时哈希随会话初始化上报,秒传命中后剩余分片立即停发
  • 选中 Multipart 时"添加为任务"复选框禁用——multipart 会话是同步流水线(进度可查、失败可断点重试),与任务队列"先收完再传"的语义互斥,详见后端 PR 的设计说明

实现要点:

  • 新增 src/pages/home/uploads/multipart.ts:3 并发升序分片调度;init 返回的已收区间直接驱动续传;秒传完成信号使剩余分片停发
  • 流控与容错:429/409 视为流控信号(独立于错误预算耐心重试);分片请求网络错误后先探询会话状态再决定重试或报错,避免把"会话已完成的提前响应"误报为失败
  • 单分片以下的小文件静默回退 Stream 直传
  • init 幂等(依托后端续传语义),瞬时网络故障自动重试而不判死整个上传
  • uploader 注册表新增条目并按公共设置门控;i18n 新增重试按钮与两个设置项的英文文案

兼容性说明:不改变既有 Stream / Form / HTTP Direct 上传方式的任何行为(对现有代码的修改均以"选中 Multipart"为条件门控);重试按钮对所有上传方式的失败行生效(非 multipart 方式点击重试等效于重新上传该文件)。

  • This PR has breaking changes.
    / 此 PR 包含破坏性变更。
  • This PR changes public API, config, storage format, or migration behavior.
    / 此 PR 修改了公开 API、配置、存储格式或迁移行为。
  • This PR requires corresponding changes in related repositories.
    / 此 PR 需要关联仓库同步修改。

Related repository PRs / 关联仓库 PR:

Related Issues / 关联 Issue

Relates to OpenListTeam/OpenList#460

Testing / 测试

测试平台:macOS arm64,Node 26 / pnpm 11。

  • go test ./...
    不适用前端仓
  • Manual test / 手动测试:

自动化检查:

  • pnpm build 构建通过
  • pnpm lint(tsc):本 PR 未引入任何新类型错误(仓库现存 4 个与本 PR 无关的既有错误,干净分支上同样存在)
  • 提交经 husky pre-commit 的 prettier 自动格式化

浏览器手动验证(对接本地后端配套分支联调):

  • 覆盖已有文件 × 尝试秒传四种组合行为符合预期
  • 大文件上传中刷新页面后重拖同一文件,进度从断点起跳继续
  • 3×3 大文件并发上传全程无误报,进入后端写入阶段后进度正常收尾
  • 失败行重试按钮从断点恢复上传
  • 秒传命中场景(123 云盘真实账号):哈希计算完成后随即完成,无剩余分片发送
  • 后端关闭 multipart_enabled 后下拉框不再显示 Multipart 选项

Checklist / 检查清单

  • I have read CONTRIBUTING.
    / 我已阅读 CONTRIBUTING
  • I confirm this contribution follows the repository license, contribution policy, and code of conduct.
    / 我确认此贡献符合仓库许可证、贡献规范和行为准则。
  • I have formatted the changed code with gofmt, go fmt, or prettier where applicable.
    / 我已按适用情况使用 gofmtgo fmtprettier 格式化变更代码。
  • I have requested review from relevant maintainers or code owners where applicable.
    / 我已在适用情况下请求相关维护者或代码所有者审查。

AI Disclosure / AI 使用声明

  • This PR includes AI-assisted content.
    / 此 PR 包含 AI 辅助内容。

Tools used / 使用工具:

  • ChatGPT
  • Codex
  • GitHub Copilot
  • Claude
  • Gemini
  • Other (please specify) / 其他(请注明):

Usage scope / 使用范围:

  • Code generation / 代码生成

  • Refactoring / 重构

  • Documentation / 文档

  • Tests / 测试

  • Translation / 翻译

  • Review assistance / 审查辅助

  • I have reviewed and validated all AI-assisted content included in this PR.
    / 我已审核并验证此 PR 中的所有 AI 辅助内容。

  • I have ensured that all AI-assisted commits include Co-Authored-By attribution.
    / 我已确保所有 AI 辅助提交都包含 Co-Authored-By 归属信息。

  • I can reproduce all AI-assisted content included in this PR without any AI tools.
    / 我可以在没有任何 AI 工具的情况下重现此 PR 中包含的所有 AI 辅助内容。

- Add a chunked resumable uploader driving /api/fs/multipart with three concurrent ascending chunk requests
- Resume interrupted sessions transparently from init's received ranges and fall back to Stream for single-chunk files
- Treat flow control (429/409) and transient network errors as retryable, probing session status before declaring failure
- Stop sending remaining chunks once the server reports the session completed (rapid upload)
- Register the uploader gated by the multipart_enabled public setting
- Add a Retry button on failed rows that reuses the kept File handle, resuming multipart uploads from the breakpoint
- Disable "add as task" while the multipart uploader is selected, since sessions are synchronous pipelines
- Report progress and speed from a high-water mark: a chunk rejected by flow control or a network hiccup drops its in-flight bytes from the running sum until it is resent, which made the displayed speed go negative and the progress bar jump backwards
- Rejected chunks now show as a progress plateau with speed falling to zero
- The chunk size setting accepts any positive integer now; mention the
  8-chunk per-session server buffer instead of the removed 1-90 range
Comment thread src/pages/home/uploads/multipart.ts Outdated
Comment on lines +163 to +167
} catch (_) {
// network hiccup, or the server fast-rejected without draining the
// body (flow control) and the browser reported it as an error
resp = undefined
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Axios 拦截器使 catch 块对网络错误失效

Axios 的响应拦截器错误处理器将网络错误转换为 resolved 值返回:

(error) => {
// response error
console.error(error) // for debug
// notificationService.show({
// status: "danger",
// title: error.code,
// description: error.message,
// });
return {
code: axios.isCancel(error) ? -1 : error.response?.status,
message: error.message,
}
},

这意味着 r.put(...) 对于网络错误不会 reject,而是 resolve 为 { code: undefined, message: "Network Error" }。于是 multipart.ts 中的 catch 块几乎不可达:

try {
    resp = await r.put(...)
} catch (_) {
    // 这段代码对 Axios 拦截的错误不会被触发
    resp = undefined
}

由于 resp.codeundefined(不是 429 也不是 409),直接 throw 导致整个上传中止:

if (resp && resp.code !== 429 && resp.code !== 409) {
    throw new Error(resp.message)
}

这破坏了代码中原设计用于瞬态网络故障时的重试逻辑。对于快速上传完成后的网络错误,也无法正确降级。

建议修复:将 try/catch 改为检查 resp.code

// 方案 A:修改 catch 逻辑以兼容 Axios 拦截器的行为
try {
    resp = await r.put(...)
} catch (_) {
    resp = undefined
}
// 改为显式检查
if (!resp || typeof resp.code !== 'number' || resp.code === undefined) {
    resp = undefined
}

或者更彻底的方案:让错误拦截器区分 HTTP 业务错误(保留原样)和网络错误(仍然 reject)。

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

错误成立。逐调用点排查同类错误,发现同一病灶实际有四处,已在 72e26dc 一并修复:

  • sendChunk(review 指出):网络错误落进 code !== 429/409 分支即硬失败,导致无法自动重发;
  • init 的重试循环:catch 永不触发,重试等于不存在;
  • complete 的 .catch(() => undefined):CDN 掐断 complete 是设计上的预期场景,但轮询兜底整段不可达——未勾秒传(无哈希)时 receiving 会话不可复用,手动重试等于全量重传,这处影响最大;
  • 终态轮询的 catch { continue }:收尾阶段一次断连会误杀整个上传。

实际修法与建议的方案 A 有两点偏差,说明原因:

  1. 判别式没有用 typeof resp.code !== 'number':CDN/网关产生的 502/524 会带真实 HTTP 状态码进错误拦截器(error.response?.status 是数字),按 typeof 判别会把它们当成业务错误硬失败,而这恰是最需要自动重试的错误类型。改为按"是否服务器 envelope"判别:业务响应恒为 HTTP 200、且 Respdata 字段无 omitempty 序列化,故 typeof code === "number" && "data" in resp 即 envelope,拦截器产物只有 {code, message} 必然落空;另特判 code === -1(取消)终止而非无限重试。决定统一复用为一个 mpRequest helper 并应用于全部六个调用点。
  2. 没有采纳"让拦截器区分网络错误重新 reject":全站所有调用方目前都遵循"永不 reject",改 request.ts 影响不可控,修复保持控制在 multipart.ts 内。

测试:8 个回归场景(正常上传/流控 429/业务 400/秒传/小文件回退/断点续传/重灌/取消)与修复前行为一致;4 个故障场景(chunk 网络抖动、chunk 502、complete 响应被吞、init 抖动)旧代码硬失败,新代码全部自动恢复。测试使用故障注入手动复现服务端错误。

Comment on lines +54 to +55
const fallbackThreshold =
Math.max(1, getSettingNumber("multipart_chunk_size", 10)) * 1024 * 1024

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

X-Chunk-Size 未发送给服务端

前端读取了 multipart_chunk_size 设置用于计算回退阈值,但没有将其作为 X-Chunk-Size 头发送给服务端。而服务端 multipartChunkSize() 使用自己的默认配置(也可能是 10MB)。如果前端的设置与服务端不一致,分片阈值判断可能出现偏差。虽然实际的切片逻辑使用服务端返回的 session.chunk_size,数据完整性不受影响,但用户配置无法生效。

建议修复:在 initHeaders 中加入 X-Chunk-Size

if (getSettingNumber("multipart_chunk_size", 10) > 0) {
    initHeaders["X-Chunk-Size"] = getSettingNumber("multipart_chunk_size", 10) * 1024 * 1024
}

@thetapilla thetapilla Jul 14, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已在 041572a 补上:initHeaders["X-Chunk-Size"] = fallbackThreshold
回退阈值本身就是字节数的"建议分片大小",于是直接复用同一个数。

前端 getSettingNumber("multipart_chunk_size") 与服务端 setting.GetInt(conf.MultipartChunkSize) 读的是同一个 PUBLIC 设置,正常不存在"前端与服务端不一致";切片一致性也始终由 init 回传的 session.chunk_size 保证。这个 header 有实质收益当且仅当场景为设置刚被修改、旧标签页尚未刷新的窗口期上传。服务端给建议值的逻辑为 OpenListTeam/OpenList#2723server/handles/multipart.go 的这个函数:

const multipartMinChunkSize = int64(1) << 20 // 1MB

// multipartChunkSize resolves the effective chunk size. The admin setting is
// the ceiling: a client may suggest a smaller chunk via X-Chunk-Size but never
// a larger one — the server buffers a window of several chunks per session, so
// an unbounded client suggestion would translate directly into server-side
// disk usage.
func multipartChunkSize(requested int64) int64 {
	ceiling := int64(setting.GetInt(conf.MultipartChunkSize, 10)) << 20
	if ceiling < multipartMinChunkSize {
		ceiling = multipartMinChunkSize
	}
	size := ceiling
	if requested > 0 && requested < ceiling {
		size = max(requested, multipartMinChunkSize)
	}
	return size
}

令 F = 标签页缓存值 = 前端发送的 requested,S = 服务端当前设置 = ceiling

场景 回传 chunk_size 与不发的差异
F = S(稳态,绝大多数时间) requested == ceiling → 取 ceiling 无差异
F > S(管理员刚下调) 超上限 → 返回 S 无差异
F < S(管理员刚上调) requested < ceiling → 采纳 F 有实质差异

F < S 举例:标签页缓存 10MB、服务端已上调为 100MB,上传 50MB 文件——标签页按 10MB 阈值判定"该走分块",不发 header 时服务端会按 100MB 切出单分片(一个 50MB 请求体外加 init/complete 两个陪跑请求);发 header 后按 10MB 切 5 片。

另外没有沿用建议片段里的 if ... > 0:发送值直接复用上文的 fallbackThreshold 并已经有 Math.max(1, …),且这个设置经管理端校验只能保存 ≥1 的整数、服务端对建议值还有 [1MB, 管理值] 的限制。

The shared axios instance resolves transport failures into a bare
{code, message} instead of rejecting, so every try/catch in multipart.ts
was unreachable: a network hiccup on a chunk failed the whole file, the
init retry loop never engaged, the CDN-cut fallback for complete (poll
to the verdict) was dead code, and a blip during the final poll killed
an upload that was finishing fine.

Route every request through a helper that tells genuine server
envelopes (numeric code with a data key, always HTTP 200) apart from
transport-layer failures (no data key; CDN-level errors carry a numeric
HTTP status), returns undefined for the latter so the existing
probe/retry paths finally engage, and turns cancellation (-1) into a
terminating error.
Send X-Chunk-Size with the same value the multipart-vs-stream fallback
decision was made with. The server clamps it to [1MB, admin setting],
so this is a no-op in steady state; when an admin has just raised the
setting and a stale tab still holds the old value, it keeps the
session's chunking consistent with the tab's own fallback reasoning
instead of degenerating into a single oversized chunk.
@thetapilla thetapilla requested a review from xrgzs July 14, 2026 11:40
Comment thread src/pages/home/uploads/uploads.ts Outdated
Comment on lines +33 to +37
{
name: "Multipart",
upload: MultipartUpload,
available: () => getSettingBool("multipart_enabled"),
},

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

个人认为可以放第一项

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 7d55451.
或许可以问问其他 Member 该不该放第一项,我肯定是没意见的。
不过小文件也能回退到 Stream 就是了

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants