TREK 文件上传队列完全解析:超时控制与大文件上传的 5 层设计
【免费下载链接】TREKA self-hosted travel/trip planner with real-time collaboration, interactive maps, PWA support, SSO, budgets, packing lists, and more.项目地址: https://gitcode.com/GitHub_Trending/nomad22/TREK
TREK 是一个自托管的旅行计划工具,支持实时协作、互动地图、PWA、预算和打包清单等功能。其中「文件上传队列」(uploadQueue)是它处理照片、文档、视频等大文件的核心机制:并发 3 路上传、失败自动重试、按字节计算进度、每个文件携带幂等键,让 500 MB 的视频备份也能稳定上传到服务器。本文带你从前端队列到后端幂等中间件,看懂这套设计的全部 5 层。
一、为什么上传会被 8 秒超时"打断"?
问题源自一个真实 bug(#1495):TREK 的全局 axios 实例设置了 8 秒超时(client.ts):
| 配置项 | 值 | 含义 |
|---|---|---|
timeout | 8000 ms | 整个请求的总时限,不是空闲时限 |
withCredentials | true | 携带登录 Cookie |
axios 的超时是"整个请求的截止线",而不是"没有数据流动的超时"。这意味着:一张手机照片在弱网下推送超过 8 秒,请求就会在半途被掐断,服务器端(multer 中间件)报出Request aborted错误。
最初的修复是手动给封面图上传加timeout: 0(取消超时),结果同样的 bug 在另外 7 个上传接口上"复活"了——其中包括两个支持500 MB的接口(文档上传、备份恢复)。
💡 核心教训:超时豁免不能靠"每个调用点记得写",必须收敛到唯一入口成为默认行为。
二、第一道防线:postMultipart 统一入口
所有文件上传现在都必须经过 client.ts 中的postMultipart()函数,它做三件事:
timeout: 0—— 彻底取消请求超时,大文件想传多久传多久;- 自动附带幂等键—— 传入
idempotencyKey时设置X-Idempotency-Key请求头; - 透传进度回调——
onUploadProgress让 UI 实时显示上传百分比。
代码注释里写得很直白:"Every upload therefore has to opt out with timeout: 0... Centralizing makes the correct behavior the default instead of something you have to remember."
目前走这个入口的接口覆盖了全部 15 个 multipart 上传点,从 5 MB 的头像到 500 MB 的视频:
测试如何"钉死"这 15 个接口?
uploadTimeout.test.ts 用 13 个编号用例(FE-API-UPLOAD-001 ~ 013)逐一断言:每个 multipart 调用都必须以timeout: 0发出。任何一个新接口忘了走postMultipart,CI 立刻红灯——这就是"把正确行为变成默认"的自动化保障。
| 用例 | 接口 | 文件大小上限 |
|---|---|---|
| 001 | 头像上传/auth/avatar | 5 MB |
| 002 / 006 / 012 | 行程、旅程、收藏封面 | 20 MB |
| 007 / 008 | 旅程照片 / 画廊视频 | 20 MB /500 MB |
| 009 | 行程文件/trips/:id/files | 500 MB |
| 011 | 备份恢复/backup/upload-restore | 500 MB |
三、第二道防线:uploadQueue 弹性上传队列
真正管理"一批文件"上传的是 uploadQueue.ts 中的uploadFilesResilient(),它是队列层的核心。设计上有 4 个关键机制:
1️⃣ 并发 worker 池(默认 3 路)
函数用共享游标idx+ 多个worker()协程实现"取下一个文件就上传下一个"的模式,默认最多 3 个文件同时在传。既比串行快,又不会把上传带宽和服务端 multer 打爆。
2️⃣ 按"错误性质"决定是否重试
isRetryable()的判断规则非常克制:
- 4xx 错误不重试(400~499)——请求本身有问题(比如文件超上限、格式错误),重试一万次也不会成功;
- 5xx 和网络错误才重试,默认重试 2 次;
- 重试采用递增退避:第 n 次重试前等待
400 × n毫秒,避免瞬间重发挤垮弱网。
3️⃣ 每个文件一个幂等键,重试不会重复入库
每次上传前用crypto.randomUUID()生成幂等键,并通过X-Idempotency-Key发送。服务端 idempotency.ts 中间件会按「键 + 用户 + 方法 + 路径」四元组查重:命中则直接回放缓存的响应。
这套机制的意义在于:网络抖动导致响应丢失、客户端自动重发同一文件时,服务器不会存两份。缓存记录保留 24 小时,由调度器定期清理;响应体超过 256 KB 则不缓存,防止备份接口的大 JSON 撑爆去重表。
4️⃣ 按字节加权的全局进度
UploadProgress不只是"完成 3/10 个文件",而是把所有文件的已上传字节数 / 总字节数汇总成百分比——传 2 个 50 MB 大文件时进度条不会在"1 个传完"时跳一半,失败文件也会单独计数并归入failed列表返回。
四、第三道防线:大文件的预处理
队列之外,TREK 还针对视频这类超大文件做了浏览器端预处理,避免服务端转码:
- videoPoster.ts:上传视频前,浏览器先用
<video>元素解码,抽取一帧画到 canvas 导出为 JPEG 海报图,和视频一起上传作为缩略图。若解码失败或 10 秒内无响应,海报置空继续上传,绝不让预处理阻塞主流程; - journeyStore.ts:旅程相册的
uploadPhotos/uploadGalleryPhotos直接复用uploadFilesResilient(),上传成功后乐观更新本地 store,UI 秒级展示新照片。
服务端则按扩展名区分限制:视频扩展名适用500 MB上限,其他文件另有限制,避免一刀切误伤(见 files.controller.ts 的注释)。
五、总结:5 层防线一图流
| 层级 | 位置 | 职责 |
|---|---|---|
| ① 超时豁免 | client.tspostMultipart() | 上传请求统一timeout: 0 |
| ② 回归测试 | uploadTimeout.test.ts | 钉死全部 15 个上传接口 |
| ③ 弹性队列 | uploadQueue.ts | 3 路并发、4xx 不重试、退避重试、字节进度 |
| ④ 服务端幂等 | idempotency.ts | 重放缓存响应,杜绝重复入库 |
| ⑤ 客户端预处理 | videoPoster.ts | 浏览器抽帧生成海报,免服务端转码 |
给开发者的启示:TREK 的上传体系最值得借鉴的,不是某个具体参数,而是"把默认值改对 + 用测试钉死"的方法论——超时豁免收敛进唯一入口,再让 CI 保证没人能绕过它。这套思路同样适用于任何需要处理大文件上传的项目。
【免费下载链接】TREKA self-hosted travel/trip planner with real-time collaboration, interactive maps, PWA support, SSO, budgets, packing lists, and more.项目地址: https://gitcode.com/GitHub_Trending/nomad22/TREK
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考