流萤简历是我做的 AI 简历与求职资料工作台。这是专栏第 3 篇,从边写边看的编辑器出发,拆开自动保存、版本冲突和人机并发修改这条链路。
产品特写:写简历的时候,注意力应该留在经历上
打开流萤的编辑器,左边组织章节,中间修改经历,右边看纸张上的实际效果。你可以继续输入、调整模板,再让 AI 参与内容加工。界面没有要求每改一句话都点一次保存,但这意味着系统要认真处理"正在写"和"已经确认保存"之间的距离。

这张图最值得看的是同一份内容的两种呈现:输入区负责当前草稿,纸张负责预览。预览更新只说明浏览器状态变了,云端是否保存成功,是另一件事。把它们混在一起,就容易出现页面看着已经改好,刷新后却回到旧内容的体验。
自动保存一开始常被写成一个带延迟的请求。真正需要设计的是:哪个状态算脏,哪个快照能作为共同基线,旧请求何时失效,以及人和 AI 同时修改时怎样保留意图。
连续输入为什么不能一直推迟保存
常见防抖写法是每次变化都取消旧定时器,等待停止输入 600ms 再发请求。防抖适合搜索建议,但写作场景有一个问题:用户连续输入几十秒,保存也可能被连续推迟几十秒。
当前 CloudResumeSync.changed() 采用固定窗口。第一次检测到脏数据时创建 600ms 定时器,窗口内的后续输入不再延后它。窗口到期进入保存流程,保存期间继续产生的新内容会由后续循环处理。

固定窗口不等于承诺每 600ms 都有一次云端成功。网络、事务和冲突处理可能更慢。它保证的是连续输入不会一直阻止保存开始,这个语义比笼统说"做了防抖"更准确。
脏数据由当前内存与 baseline 比较。baseline 保存最近一次确认的简历对象、稳定序列化结果和 revision。稳定序列化让对象字段顺序不影响内容比较;代码还用对象引用对应的 WeakMap 缓存序列化结果,减少对同一不可变对象的重复处理。这依赖状态更新替换对象,不能一边原地改对象一边期待引用缓存自动发现变化。
串行的不是 HTTP 外壳,而是保存责任
用户可能反复触发 flush(),路由切换和保存入口也可能同时要求把草稿送出。当前实现用一个 flight Promise 表示正在进行的保存过程;已经有过程在运行时,返回同一个 Promise。
这避免两个保存循环同时挑中同一份脏简历。真正的保存循环每轮重新计算脏 ID,保存一份,再继续检查。对于一个账号下多份简历,也沿同一过程串行发送。
这里最容易写错的是请求成功后的处理。假设发出快照 A,网络返回之前用户写到 B。响应确认的是 A,系统应该把 A 记为新的基线,随后发现 B 仍然脏;如果把响应中的 A 直接替换回编辑器,用户刚输入的 B 就丢了。
当前成功路径主要更新基线,不把正常保存响应强制回灌当前草稿。这个区分能直接解释产品体验:用户持续打字时不应该被一次正常保存拉回旧状态。需要真正调整当前草稿的,是冲突合并或资源恢复这样的特殊流程。
保存状态也要从数据推导。hasPending() 同时关注进行中的保存与当前脏内容,不能只看一个布尔值 isSaving。请求刚结束时,如果新输入还没写出去,"没有请求在飞"仍然不等于"已经全部保存"。
服务端要阻止旧快照覆盖新内容
revision 是每份云端简历的整数版本。客户端提交它读取时的 revision,服务端更新条件同时包含账号、简历 ID、未删除状态与该版本,成功后递增。这是乐观并发控制:先允许编辑,在写入时检查基础是否仍然有效。
下面是等价的 SQL 机制示意,不是仓库逐字摘录:
sql
UPDATE cloud_resumes
SET data = :candidate, revision = revision + 1
WHERE user_id = :owner
AND local_resume_id = :id
AND revision = :expected_revision
AND deleted_at IS NULL
RETURNING *;
没有更新到行,可能是版本变化,也可能是远端已经删除。服务端读取当前内容,交给客户端走冲突分支。只有整数递增不够,条件写入必须与数据更新放在同一个数据库语句中,否则"先查版本,再无条件更新"中间仍有竞争窗口。
还有一个不那么显眼的分支:数据库写入成功,HTTP 响应丢失。客户端使用原 revision 重发同一内容时,版本已经不匹配,但服务端若发现当前内容与候选一致,就返回已保存结果。这避免把传输层的不确定性误报为新的内容冲突。
这不是所有业务都能照搬的"内容相同即成功"。它适用于这里的整份简历状态写入;转账、累加次数或追加消息等动作,需要另行定义操作身份。判断重试安全性,要先看操作是设置一个状态,还是产生一次不可重复的副作用。
三方合并,需要三份内容
设想浏览器读取版本 7,基础姓名是"xxx",技能是旧内容,项目只有 A。用户在浏览器补项目 A 的描述,AI 同时通过 API 新增项目 B 并修改技能,云端先变成版本 8。
浏览器带 7 提交会冲突。此时只比较"本地"和"远端",无法知道哪个字段是双方真正改过的。基础快照给出了第三个坐标:把每个字段的本地变化与远端变化分别相对它判断。
| 字段 | 基础版本 7 | 浏览器草稿 | 远端版本 8 | 合并结果 |
|---|---|---|---|---|
| 技能 | 旧技能 | 旧技能 | AI 补充 | 远端技能 |
| 项目 A 描述 | 原描述 | 人工补充 | 原描述 | 人工补充 |
| 项目 B | 不存在 | 不存在 | AI 新增 | 保留 B |
| 姓名 | xxx | 用户修改 | AI 同时修改 | 当前浏览器草稿 |
表格最后一行是明确的产品策略。网页保存冲突时,同一标量的重叠变化优先当前浏览器草稿,避免打断正在输入的人。不同字段则尽量保留双方变化。

对应的决策顺序可以直接从 mergeValue() 的前三个分支看出来:
ts
if (same(local, remote)) return local;
if (same(local, base)) return remote;
if (same(remote, base)) return local;
same() 使用稳定序列化比较值,并单独处理字段缺失的标记。只有双方都相对基础变化、而且彼此不同,才进入对象、数组或重叠标量的后续规则;这避免把远端独立修改一概当成冲突。
对象可以逐字段合并,条目数组则按唯一 ID 匹配。工作经历的顺序可以变化,但 ID 不应跟着数组下标变化;否则一次排序就可能被识别成"删除旧经历再创建新经历"。
没有可用唯一 ID 的数组,当前实现会在重叠变化时采用本地整体。数组顺序也有策略:本地顺序没变时采用远端,否则优先本地,再补齐其他仍存在条目。产品中的"拖动经历排序"会进入内容合并语义,它并不是完全独立的装饰状态。
源码函数叫 mergeResumeCrdt,但仅凭名字不能称它为完整的 CRDT。CRDT 通常指能在既定条件下保证并发副本收敛的数据结构;这里从代码能确认的是基于共同快照、按字段与 ID 合并、重叠时采用调用方优先的实现。它没有展示字符操作日志、向量时钟或任意提交顺序下的收敛证明。
本地优先,仍然有需要说清的代价
同一段项目描述,用户和 AI 各改了一版,当前网页路径不会自动逐句拼接。它保留本地整个标量,远端对这段内容的修改可能不会进入最终结果。这比把富文本字符串胡乱拼接更可控,但不能宣传成"所有改动都无损保留"。
反过来,CLI 简历脚本在同字段冲突时选择自己的候选。两个客户端都优先自己,并不自然获得一个与顺序无关的最终值。实际最终状态仍受提交顺序、后续脏检查和有限次重试影响。
因此,验证时要分别看"独立变化是否保留"和"重叠变化是否遵循策略"。把所有冲突归为一次"自动解决",会掩盖用户真正关心的内容取舍。
网页保存一次最多尝试四轮,也就是首次加最多三次冲突后的重试。代码只对明确的 CONFLICT 进入合并流程,不把网络故障、解析错误和账号问题统一当作"再来一次"。持续竞争超过上限时保留草稿并显示失败,避免无限循环占用请求和用户注意力。
删除冲突为什么要恢复成另一份简历
远端已经删除,而本地还有未保存输入,是另一个产品问题。直接以旧 ID 重建,可能把用户在另一端执行的删除撤销;直接丢弃本地,又会吞掉正在写的内容。
当前实现生成新 ID,把本地简历改名为"(已恢复)",更新创建与修改时间,再作为新资源进入保存。旧 ID 的删除语义保留,当前草稿也获得一个可找到的位置。

独立创建却碰到相同 ID、没有共同基线时,也采用恢复副本保留双方。这个分支的作用是避免凭一个资源标识就假设双方一定有共同历史。
删除服务本身使用软删除并递增版本,且在同一事务里撤销关联分享链接。这样"删除一份简历"同时影响内容和原来向外暴露的地址,不会只让个人列表里看不见,公开分享仍继续有效。
从旧浏览器存储迁移,要确认到哪一步才能删源
登录账号的云同步与游客草稿不是一回事。当前同步器不把登录账号简历正文继续持久化到浏览器;它会处理历史浏览器数据的迁移,但不能据此把整个产品描述成离线优先编辑器。
迁移过程先记录迁移所属账号,再逐份提交并验证服务端回执中的内容摘要,随后重新获取云端列表。只有全部确认,而且本地旧值仍与开始时的快照一致,才清理旧浏览器存储。
为什么还要检查本地旧值?因为另一个旧标签页可能在迁移期间继续写那个存储键。如果只凭"刚才的上传成功"删键,就会删除迁移开始以后出现的新内容。检查原始快照是为了保护一个已知的外部生命周期竞争。
迁移中途失败时保留完整源数据。服务端按账号与内容摘要保存回执,重试已完成部分不会不断生成副本。缺少历史时间戳的记录也要使用稳定的归一化值,否则每次重试生成新的"当前时间",同一简历就会得到不同摘要。
换账号和关闭同步器,也是保存协议的一部分
浏览器 Cookie 可能随着登录切换变化,而旧请求还没结束。同步器绑定 ownerId,请求携带账号变化头,并检查返回的账号归属。停止时清理定时器、取消进行中的请求,也让晚到的响应不能继续替换新账号状态。
这类检查有具体的故障对象:旧账号的内容进入新账号界面,或旧请求使用新会话误写。它们属于真实边界,不是为了"更安全"到处检查字段。
网络失败时,当前登录账号草稿继续留在内存,重新 flush 可以再保存。但页面关闭后未保存内存不能保证恢复,文章也不应该把"保留草稿"扩张成"任何断网关页都不丢"。这个限制会影响产品提示、离开保护和后续离线能力的设计。
怎样验证自动保存,而不只看提示文字
一个有意义的测试要人为控制响应时序。发出 A 的请求后暂停它,继续输入 B,再放行 A,确认编辑器仍是 B,保存循环还会把 B 写出。测试两个简历时,也要确认第一份等待期间第二份没有被漏掉。
冲突场景分别覆盖独立字段、同一字段、条目新增、条目删除与排序;删除场景检查恢复副本是否使用新 ID;迁移场景检查部分失败、换账号和源快照变化时旧数据仍保留。仓库的 cloud-resume-sync.test.ts 已有这些方向的隔离测试,阅读时可以对照它们如何模拟网络。
你可以在流萤简历里用一份占位简历试试边写边看,再切换模板观察内容如何继续保留。自动保存值得信任,靠的是每一次确认都只确认自己实际写出的快照。下一篇换到 AI 资料文档,看看富文本、纯文字和块身份怎样共存。