03|自动保存真正难的部分,是保存期间又发生了修改

流萤简历是我做的 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 资料文档,看看富文本、纯文字和块身份怎样共存。

相关推荐
浪遏15 分钟前
04|同一篇资料,怎样同时交给编辑器、搜索和 AI
typescript
浪遏16 分钟前
09|让 Mermaid 和 HTML 成为可编辑内容块
typescript
️公子5 小时前
可插拔 13 款实时棋牌对战平台实践:房间容器、Plugin SDK 与幂等账本
websocket·typescript·游戏开发·monorepo·插件架构·棋牌平台·实时对战
FungLeo5 小时前
成为全栈·Vue 管理后台篇·Vue3 + Vite + Naive UI:后台工程基座怎么搭
前端·vue.js·ui·typescript·vite·前端工程化·biome
FungLeo6 小时前
成为全栈·Vue管理后台篇·<scriptsetup>:把页面拆成可读、可测的 Vue 组件
前端·javascript·vue.js·typescript·前端组件化·composition api·vue sfc
theMuseCatcher3 天前
Vue3导航菜单(Menu)
typescript·vue3·less
theMuseCatcher4 天前
Vue3评论(Comment)
typescript·vue3
afumu4 天前
把五个网页版 AI 拉进同一个群聊:不用 API key 的多 Agent 编排
人工智能·chrome·typescript
孟陬6 天前
Foobar 技术周刊#160:大模型让大规模形式化软件验证成为现实丨AI写C++代码之后会发生什么?丨我们真的需要alpha() 函数吗丨重试,有时会让故障更
前端·后端·typescript