2026-04-16 合同断点续传与分片上传面试材料
文档定位
这份材料不是实现方案,也不是开发计划,而是给面试时“讲故事”和“扛追问”用的口述稿。
你可以把它分成 4 层来使用:
- 先背
3-5 分钟 STAR 主讲稿 - 再理解
技术拆解 - 然后练
高频追问 - 最后用
技术亮点做拔高
一句话版本
我在既有的 draft-contract 和 change 两个合同管理页面里,落地了一套基于 Cloudflare R2 multipart 的大文件分片上传和断点续传方案。前端负责切片、并发上传和恢复;Nitro 只负责上传控制面;Neon Postgres 负责记录上传会话和分片状态;最终把上传结果和业务表单提交解耦,形成完整的 CRUD 闭环。
一、3-5 分钟 STAR 主讲稿
下面这段是你可以直接练口述的版本。
1. Situation
我当时做的是合同管理场景,具体有两个既有业务页面,一个是合同草稿 draft-contract,一个是合同变更 change。这两个页面本来都需要支持附件上传,但如果继续走普通的整文件上传,问题会很明显:文件一大就容易失败,网络一断就得整文件重传,编辑态下旧附件、新附件、删除附件也很难统一处理。
2. Task
所以我的任务不是单纯做一个上传组件,而是把“分片上传 + 断点续传 + 业务 CRUD”整套闭环落到这两个页面里。目标有两个:
- 大文件上传要稳定,能暂停、恢复、失败重试
- 上传结果要能和合同新增、编辑、删除流程真正打通,而不是只做一个 demo
3. Action
我的核心设计是把系统拆成两层。
第一层是数据面,也就是文件本身怎么传。我没有让大文件经过业务服务端中转,而是让浏览器直接把每个分片上传到 Cloudflare R2。这样服务端不会承受大文件流量压力,上传性能和成本都更合理。
第二层是控制面,也就是谁来管理上传过程。我用 Nitro 来负责创建上传会话、查询已上传分片、给每个分片签发临时上传 URL、最后完成 multipart merge。然后我用 Neon Postgres 记录上传会话表和分片表,这样刷新页面后还能恢复现场,做到断点续传。
前端这边,我把上传能力抽成了一个共享的状态机和上传组件,统一服务 draft-contract 和 change 两个页面。状态机里会维护文件指纹、会话 ID、分片状态、上传进度、暂停/恢复/失败重试这些状态。
另外一个比较关键的点是,我没有把“上传完成”和“业务提交成功”绑死在一起。上传完成只代表对象已经成功放到 R2,但真正写入正式附件表,是在合同的 create 或 update 成功时才物化。这么做是为了避免出现“文件已经传上去了,但业务主记录还没创建成功”的脏数据问题。
在 change 页面里,我还处理了编辑态附件差量维护的问题,也就是旧附件有的保留、有的删除,同时又新增了一批新附件。这个场景比单纯上传更贴近真实业务。
4. Result
最后这套方案在两个合同页面里都形成了完整闭环:支持分片上传、断点恢复、附件新增、编辑、删除,以及编辑态附件差量更新。它的价值不只是“上传成功”,而是把上传真正融进了业务流程里,同时架构上也比较清晰,前端、服务端、对象存储、数据库各自职责分明。
二、你先要搞懂的核心概念
这一部分不是给面试官背的,是给你先学会这套技术。
1. 什么叫分片上传
分片上传就是把一个大文件拆成很多小块,每一块单独上传。
比如一个 100MB 文件,如果每片 5MB,那就会拆成 20 片。
前端不会一次性传 100MB,而是传第 1 片、第 2 片、第 3 片……
这样做的好处是:
- 单片失败时,只需要重传这一片
- 可以做并发上传,提高速度
- 可以记录每片是否成功,支持断点续传
2. 什么叫断点续传
断点续传的重点不是“暂停后继续”这四个字,而是:
前端和服务端都知道“这个文件已经传到第几片了”。
比如一个文件共 20 片:
- 前 12 片已经上传成功
- 第 13 片失败
- 页面刷新了
这时候如果系统还能找回这个上传会话,并知道前 12 片已经存在,那么它只需要继续传第 13 到 20 片,这就叫断点续传。
3. 为什么一定要有“上传会话”
因为分片上传不是一次请求,而是一组请求。
所以系统必须有一个“会话”来代表:
- 这是哪个文件
- 一共多少片
- 当前已经完成哪些片
- 这个文件最终要落到 R2 的哪个对象 key
- R2 返回的 multipart upload ID 是什么
这个“上传会话”就是整套方案的锚点。
4. 为什么不能只靠前端本地记忆
因为只存在浏览器本地还不够:
- 用户可能刷新页面
- 浏览器缓存可能丢失
- 用户可能换一个 tab
- 还要和真实的云端上传状态对齐
所以真正可靠的做法是:
- 前端本地缓存一份映射,方便快速恢复
- 服务端数据库再保存一份权威状态,负责最终对账
三、整体架构怎么讲
你可以把它讲成一句很清楚的话:
我把这套上传方案拆成了“浏览器直传 R2 的数据面”和“Nitro + Neon 的上传控制面”。
1. 数据面
数据面就是文件字节真正怎么传。
这里我选的是:
- 浏览器切片
- 浏览器直接 PUT 到 R2
- 服务端不搬运大文件
你可以这样解释:
大文件最占资源的是文件流本身,所以我不让业务服务端当中转站,而是让浏览器拿着服务端签发的临时 URL 直接上传到 R2。
2. 控制面
控制面就是上传过程怎么被管理。
这里由 Nitro + Neon 来做:
init:创建或恢复上传会话status:查询哪些片已经传完sign-part:给某一片签发临时上传 URLcomplete:把所有片合并成最终对象abort:取消上传
你可以这样说:
服务端不传大文件,但服务端仍然掌控整个上传流程。这样既减轻了服务端流量压力,又保留了对上传权限和最终完成动作的控制。
3. 业务面
业务面就是合同表单本身。
这里我做了一层解耦:
- 上传完成,只代表文件对象已经在 R2 里
- 业务提交成功,才会把这些上传结果物化为正式附件记录
这个点很重要,面试官大概率会认可。
四、完整上传链路怎么讲
这一段你要尽量讲顺。
链路步骤
- 用户选择文件
- 前端按固定
chunkSize切片 - 前端生成一个
resumeFingerprint - 前端调用
upload/init - 服务端创建或返回已有上传会话
- 前端调用
upload/status,拿到已完成分片列表 - 前端只上传缺失的分片
- 每个分片上传前,先调用
upload/sign-part - 服务端返回这个分片的 presigned URL
- 浏览器把这个分片直接 PUT 到 R2
- 分片成功后拿到
ETag - 所有分片完成后,前端调用
upload/complete - 服务端通知 R2 执行 multipart complete
- 服务端返回最终文件 URL、对象 key、会话 ID
- 前端把这些结果放进表单附件数组
- 用户最后点击业务提交
- 业务 create/update 接口再把这些上传结果物化成正式附件记录
这一段的人话解释
你可以这么总结:
我把上传拆成了两阶段。第一阶段是“文件对象上传完成”,第二阶段是“业务提交成功并关联附件”。这样能避免脏数据,也更符合真实业务流程。
五、表设计为什么要分三层
这一部分特别适合面试官追问。
1. ct_upload_sessions
这张表是上传会话主表。
它记录的是:
- 这个文件是谁
- 属于哪个业务类型
- 文件名、大小、MIME 类型
- 分片大小、总分片数
- 对应的 R2
uploadId - 当前状态
- 最终对象 key 和 public URL
你可以这样解释:
这张表负责描述“一次上传任务”的整体状态。
2. ct_upload_session_parts
这张表是分片明细表。
它记录的是:
- 第几片
- 这一片的
ETag - 这一片多大
- 什么时候上传成功
你可以这样解释:
断点续传的关键不是知道“文件传了一半”,而是知道“具体哪几片已经成功”。所以我单独拆了分片表。
3. ct_attachments
这张表是最终业务附件表。
它记录的是:
- 附件属于哪个合同或合同变更
- 最终对象 URL
- 对象 key
- MIME 类型
- 存储提供方
- 来源 uploadSessionId
你可以这样解释:
ct_upload_sessions是上传过程数据,ct_attachments是业务最终结果数据,这两者不能混在一起。
4. 为什么不能只用一张表
因为一张表会把“过程态”和“结果态”混在一起,问题很多:
- 文件还没传完,业务表就被污染
- 很难表示某一片失败
- 很难表示已上传但尚未绑定业务主记录
- 编辑态下也很难维护清楚
一句话回答:
我是故意把过程态和结果态拆开的,这样状态表达更完整,也更容易做恢复和回收。
六、前端断点续传状态机怎么讲
面试官如果偏前端,会很喜欢这一段。
我定义的核心状态
idlefingerprintinginitiatinguploadingpausedresumingcompletingcompletedfailedaborted
为什么要有状态机
因为上传不是一个普通按钮点击动作,而是一个长流程。
如果没有状态机,就会很混乱:
- 什么时候能提交
- 什么时候能暂停
- 什么时候应该重试
- 什么时候要阻止关闭弹窗
- 什么时候附件可以被业务表单消费
你可以这样说:
我把上传过程建模成状态机,本质上是为了把 UI 行为、网络行为和业务提交行为统一起来。
前端还做了什么
- 用本地缓存保存
resumeFingerprint -> sessionId - 页面刷新后根据 sessionId 去服务端查询真实状态
- 只补传缺失分片
- 支持暂停、恢复、失败重试
- 统一抽成共享组件,供两个页面复用
七、为什么 change 页面比 draft-contract 更有技术含量
这个点很适合你在面试里主动拔高。
draft-contract
它相对直接:
- 新增合同草稿
- 编辑合同草稿
- 上传新附件
- 删除附件
change
它复杂在编辑态差量维护。
因为用户编辑一个合同变更时,附件可能同时存在三类:
- 旧附件,继续保留
- 旧附件,这次删除
- 新附件,这次新增
所以我不能简单地“把附件数组整体覆盖掉”,而要在提交时拆成:
retainAttachmentIdsdeleteAttachmentIdsnewUploadSessionIdsattachmentMetas
一句话讲法:
change页面真正难的不是上传,而是“编辑态附件差量同步”,这比单纯新增上传更接近真实业务。
八、为什么这样选型,而不是别的方案
这一段是高频追问区。
1. 为什么不用普通整文件上传
短答:
因为大文件场景下,整文件上传失败成本太高,一旦中断通常要全量重传。
展开版:
- 大文件失败重传成本高
- 服务端流量压力大
- 很难支持稳定的暂停/恢复
- 很难做分片级重试
2. 为什么不用服务端中转上传
短答:
因为那会让业务服务端承受大文件流量和带宽压力,不划算。
展开版:
- 浏览器传给服务端
- 服务端再传给 R2
- 相当于文件流走了两次
- 服务端会成为性能瓶颈
3. 为什么不用数据库存文件
短答:
数据库存结构化数据,对象存储存大文件,这是更合理的职责分离。
展开版:
- 数据库存大文件不经济
- 查询、备份、扩容都不友好
- PostgreSQL 更适合存元数据和关系
- R2 更适合存对象
4. 为什么不用现成的 tus 或 Uppy
短答:
我评估过,但当前项目需要和既有 Nitro、Neon、合同业务 CRUD 深度耦合,所以第一版选择自己实现可控性更高。
展开版:
- 需要和既有业务模型、附件表、编辑态差量逻辑强整合
- 需要和自定义的上传控制面接口对齐
- 第一版更重视可解释性和可控性
- 但后续完全可以抽象成更通用方案
九、真实边界和坑,怎么讲更像做过
这一段会让你显得不是只看过博客。
1. CORS 是真实坑
浏览器直传 R2 时,不是签了 presigned URL 就万事大吉。
还要保证 bucket CORS 允许:
http://localhost:8080PUTGETHEAD- 以及预检请求
否则浏览器会在 OPTIONS 预检阶段就失败。
2. r2_upload_id 长度不是拍脑袋定的
一开始如果把它建成 varchar(255),是可能翻车的。
因为 R2 返回的 UploadId 实测可能更长,所以后来改成 text 更稳。
这个点你可以讲成:
这类字段不能想当然,最好按真实平台返回值和安全边界建模。
3. 不是所有数据库驱动都支持同样的事务能力
如果底层是 neon-http 这类连接方式,有些事务写法并不总是能直接按你想的那样跑通,所以实现时要看实际驱动能力,而不是只看 ORM 理想写法。
4. 业务提交和上传完成不能混为一谈
这是这套设计最关键的边界之一。
上传完成,只是对象已经落到 R2。
业务提交成功,才代表这个文件正式属于某个合同记录。
如果这两个动作绑死,很容易出现:
- 文件上传成功了
- 但合同主记录创建失败了
- 最后留下无主附件
十、面试官高频追问与答法
Q1:你这个断点续传是怎么做到“不重复上传”的?
短答:
我前端会先根据文件信息生成一个
resumeFingerprint,再通过上传会话 ID 去服务端查哪些分片已经成功。后续只上传缺失分片,不会整文件重传。
展开版:
本地只负责快速找回 sessionId,真正权威状态还是服务端数据库里的
upload_sessions和upload_session_parts。所以即使页面刷新,也能和云端真实状态重新对齐。
Q2:为什么前端要做指纹,不直接拿文件名当唯一键?
短答:
因为文件名不可靠,重名很常见。
展开版:
第一版我用的是轻量级指纹,比如
file.name + file.size + lastModified + chunkSize做 hash。它不是绝对强一致的文件内容 hash,但对当前业务场景已经足够,而且成本低。
Q3:如果某一片上传失败怎么办?
短答:
单片重试,不影响其他已成功分片。
展开版:
我把失败粒度控制在 part 级别,失败后最多重试几次,超过阈值才把整个文件状态标成
failed,让用户手动继续或重试。
Q4:刷新页面后为什么还能恢复?
短答:
因为我不是只靠内存状态,而是本地缓存加服务端会话表双保险。
展开版:
页面刷新后,前端会先通过指纹找回旧 sessionId,再调用
status对账已上传分片。只要会话还有效,就能从中断点继续。
Q5:你怎么防止上传成功但业务提交失败的脏数据?
短答:
我把上传完成和附件物化拆成两阶段。
展开版:
upload/complete只负责完成对象上传,不直接写正式附件表。真正写ct_attachments是在合同 create/update 成功时才做,这样业务数据和附件关系才是干净的。
Q6:编辑态附件为什么难?
短答:
因为不是单纯新增,而是同时有保留、删除、新增三类动作。
展开版:
所以我没有做“整包覆盖”,而是提交差量:保留哪些旧附件、删除哪些旧附件、新增哪些新附件,这样更符合真实业务,也避免误删。
十一、技术亮点怎么拔高
如果面试官问“你觉得这里最体现你能力的点是什么”,你可以从下面挑 2 到 3 个说。
1. 控制面和数据面分离
浏览器直传 R2 负责大文件数据流,Nitro + Neon 负责会话、签名、状态和最终完成动作,这样架构职责清晰,也更利于扩展。
2. 上传过程和业务提交解耦
这不是做一个孤立上传控件,而是把上传过程嵌进真实合同 CRUD 里,同时避免脏数据。
3. 共享上传状态机抽象
我没有把上传逻辑写死在单页里,而是抽成共享上传组件和状态机,让
draft-contract与change两个页面共用。
4. 编辑态附件差量维护
change页面不是简单数组覆盖,而是按“保留、删除、新增”做差量同步,这个设计更贴近真实企业系统。
5. 可靠性设计
方案里不仅有上传成功路径,也覆盖了失败重试、页面刷新恢复、取消上传、CORS、字段长度、平台约束这些真实边界。
十二、你怎么把它讲得像自己真的做过
不要一上来就讲一堆术语。
建议你按下面顺序讲:
第一段:先讲业务痛点
我做的是合同管理场景,附件不是玩具功能,文件大、网络波动、编辑态附件维护都是真问题。
第二段:再讲核心决策
所以我没有走普通整文件上传,而是选了浏览器直传 R2 的 multipart 方案,服务端只做控制面。
第三段:再讲关键设计
我把上传过程拆成上传会话、分片状态、最终附件物化三层,这样既能做断点续传,也能和业务 CRUD 闭环打通。
第四段:最后讲结果
最后两个页面都支持了分片上传、断点恢复、编辑态附件差量维护,而且服务端不会被大文件流量拖垮。
这四段讲顺了,面试官一般就会开始追细节。
细节部分你再从这份材料里拆回答。
十三、速记卡
如果你时间很紧,只背下面这些句子。
速记 1
我把这套方案拆成了浏览器直传 R2 的数据面,以及 Nitro + Neon 的上传控制面。
速记 2
upload_sessions记录一次上传任务,upload_session_parts记录哪些分片已经成功,ct_attachments记录最终业务附件。
速记 3
断点续传的本质不是暂停继续,而是系统能知道哪些分片已经传完,并且只补传缺失分片。
速记 4
上传完成不等于业务成功,所以我把“对象上传完成”和“附件物化写库”拆成了两个阶段。
速记 5
change页面最有技术含量的地方,不是上传本身,而是编辑态附件的差量同步。
十四、最后的练习方法
你别试图一次把全部技术细节吃透。
建议你这样练:
第 1 轮
只背熟 STAR 主讲稿,能顺着说完 3 分钟。
第 2 轮
重点理解 3 个问题:
- 为什么不用整文件上传
- 为什么要有上传会话和分片表
- 为什么要把上传完成和业务提交解耦
第 3 轮
把 change 页面编辑态附件差量维护讲明白。
第 4 轮
对着“高频追问”练短答版,每题控制在 20 秒到 40 秒。
十五、你面试时最稳的一种说法
如果你怕紧张,就用下面这个版本开场:
我这次做的不是一个简单上传按钮,而是把大文件分片上传、断点续传和合同业务 CRUD 真正打通。核心思路是浏览器直传 Cloudflare R2,Nitro 负责上传控制面,Neon 负责记录会话和分片状态。这样既能减轻服务端压力,又能支持刷新恢复、失败重试和编辑态附件差量维护。这个项目里我觉得最有价值的点,是把上传过程数据和最终业务附件数据分开建模,避免了文件上传成功但业务提交失败带来的脏数据问题。
这段你先练熟,再慢慢往外扩。