
两位有权编辑群公告的人都打开了第 7 版。甲把“会议周五举行”改成周六并保存公告已经变成第 8 版乙仍在旧页面里改成周日稍后点了保存。若系统只按请求到达顺序替换全文甲刚保存的修改就会被覆盖。问题不在于谁点得更快而在于保存操作没有说明这份修改是基于哪一版内容做出的。“允许编辑”与“允许覆盖当前版本”需要分别判断。米米商聊的产品方手机端资料确认群主和管理员可以发布及编辑群公告。本文以这一真实功能为背景把两位有权编辑者修改同一内容作为独立教学情境。资料没有说明其版本字段、并发控制或桌面端完整操作本文不判断当前客户端是否采用下面的方案。代码、数据与图均为通用设计示例不是产品源码或实际架构文章与配图使用 AI 辅助示例另做独立验证。1. 编辑权限与修改基线回答不同的问题可以把一次保存拆成三个输入输入回答的问题教学数据目标公告标识正在修改哪份内容notice-1修改基线版本编辑者当时看到哪一版expectedRevision: 7本地新正文编辑者希望提交什么“会议周日举行”权限回答“此刻还能不能编辑”基线回答“是否仍在修改原来读到的那一版”。即使两位编辑者都有权限也不能由此推导后来提交的全文可以无条件覆盖别人刚完成的修改。客户端应在打开内容时保留基线版本保存时把它与新正文一起提交。版本比较发生在负责保存的服务或存储侧不能只在编辑器里比较一次。2. 检查版本与写入必须作为一个整体如果流程是“读取版本一致 → 等待其他操作 → 无条件写入”两个保存仍可能都通过检查然后先后覆盖。因此要表达的是仅当当前版本仍等于修改基线才执行这次写入。成功后返回新的版本版本不一致则返回冲突并保留当前内容。不要把版本冲突混成“网络异常”也不要直接把新版本号配上旧全文再次提交。后者会让保护条件失去作用。图独立教学模型。甲先保存形成新版本乙的旧基线产生冲突保留基线、草稿与有权读取的最新内容不是 App 界面或产品现有机制。下面用一个单进程内存对象演示。每次成功保存都增加版本包括正文恰好相同的情况这是本例的更新序号规则不是产品版本号或字数限制。functioncreateAnnouncementStore(initial){const{id,revision,text}initial??{};if(typeofid!string||!id||id!id.trim()||!Number.isSafeInteger(revision)||revision1||typeoftext!string){thrownewTypeError(invalid-initial-state);}letstateObject.freeze({id,revision,text});constsnapshot()({...state});returnObject.freeze({read:snapshot,save(command,canEdit){if(canEdit!true)return{kind:forbidden};if(!command||typeofcommand!object||Array.isArray(command))return{kind:invalid};const{id:targetId,expectedRevision,text:nextText}command;if(targetId!state.id)return{kind:wrong-resource};if(!Number.isSafeInteger(expectedRevision)||expectedRevision1||typeofnextText!string||!nextText.trim()){return{kind:invalid};}if(expectedRevision!state.revision){return{kind:conflict,current:snapshot()};}if(!Number.isSafeInteger(state.revision1)){return{kind:version-exhausted};}stateObject.freeze({id:state.id,revision:state.revision1,text:nextText});return{kind:saved,current:snapshot()};}});}函数复制输入字段在比较版本之后直接更新自身状态中间没有await或外部调用。它只演示同一个实例中的条件写入。Node.js 官方资料说明了事件循环中回调的执行方式这不等于数据库或多个服务进程也能共享这份内存状态。Node.js事件循环与回调若实际内容保存在数据库需要由条件更新、事务或相应存储机制保证比较与写入的原子性。不能把示例里的两行判断复制到数据库读取和写入之间就声称已经避免多进程竞争。canEdit是调用方传入的可信权限判断函数不认证账号也不计算群角色。实际保存时应根据当前权限判断不能直接接受客户端提交的canEdit: true。本例的read仅用于教学快照也没有实现真实读取鉴权返回当前正文前仍需遵守实际读取规则。3. 用 HTTP 条件请求表达相同的约束HTTP 接口可以让读取响应携带强ETag保存时发送对应的If-Match例如If-Match: opaque-ann-v7。这个值应使用服务端返回的标签客户端不应自行猜测或把本地时间当成它。RFC 9110 规定If-Match使用强比较条件不成立时服务端不能执行该修改通常以412 Precondition Failed表示失败。标准也为能够确定该请求已经成功执行的情况保留了成功响应的可能所以客户端不应把任何版本不匹配都解释成“别人修改了”。RFC 9110If-Match几个容易混淆的细节W/开头的弱标签不能在强比较中匹配成功。If-Match: *检查的是当前表示是否存在并不等于“还是我打开时的那一版”。编辑器需要使用读到的具体标签来约束基线。标签要能区分目标表示的变化同一资源删除后重建时也不能复用会让旧草稿误匹配的标识或版本。它不是某个编辑者的权限凭证。MDN 对If-Match的说明也列出了强比较及避免丢失更新的用途。MDNIf-Match前面的 JavaScript 函数使用整数expectedRevision返回conflict没有解析 HTTP 头也没有实现完整 HTTP 服务。它与ETag、If-Match是表达相同设计意图的不同层次不能把函数测试当作 HTTP 条件请求集成测试。4. 冲突后保留基线、草稿与最新内容只弹出“保存失败”还不够。编辑器最好保留三份信息内容用途当时读到的基线判断本地改动从哪里开始尚未保存的本地草稿保留编辑者已经输入的修改有权读取的最新内容解释为何旧基线已经失效对于一整段公告文本这三份内容并不自动导出一种正确合并。可以让编辑者对照差异、复制需要的部分再明确确认下一次提交没有可靠规则时不要静默合并更不要用最新正文直接覆盖本地输入框。网络响应丢失也需要保留草稿。原保存可能已经成功只是客户端没有拿到结果。回读状态、识别已处理操作与保留草稿是不同职责本文模型没有实现请求去重日志不能据一次conflict断言是谁修改或原保存一定没有成功。权限变化则是另一条路径当编辑资格已被移除应停止写入不能因为旧页面仍开着就继续保存。草稿是否可以留在本地、多久清理需要另行定义不能把“保留输入”承诺成永久保存。5. 验证旧版本不能覆盖新内容把下面代码接在第一段函数后用 Node.js 运行。会议文案、公告标识和版本都是教学数据。constassertrequire(node:assert/strict);conststorecreateAnnouncementStore({id:notice-1,revision:7,text:会议周五举行});constbaseAstore.read();constbaseBstore.read();constlocalDraftB{id:baseB.id,expectedRevision:baseB.revision,text:会议周日举行};constdraftBeforeJSON.stringify(localDraftB);assert.deepEqual(store.save({id:baseA.id,expectedRevision:baseA.revision,text:会议周六举行},true),{kind:saved,current:{id:notice-1,revision:8,text:会议周六举行}});assert.deepEqual(store.save(localDraftB,true),{kind:conflict,current:{id:notice-1,revision:8,text:会议周六举行}});assert.equal(store.read().text,会议周六举行);assert.equal(JSON.stringify(localDraftB),draftBefore);// 冲突后不自动更换草稿基线并重试合并或覆盖需先明确确认。console.log(旧版本未覆盖新内容本地草稿未被改写);本轮在 Node.js v24.19.0 中实际运行了文章两个代码块并补充独立边界检查共 18 类通过覆盖旧版本、未来版本、重复旧提交、目标不匹配、权限拒绝与撤销、输入类型、快照不反向改写状态以及两个保存任务基于同一版本调度时只有一个成功等。调度检查作用于同一个内存实例不是网络并发、数据库隔离级别或吞吐量测试。没有验证产品客户端、真实群公告接口、账号权限系统或 HTTP 头处理。6. 让“保存成功”具有可解释的条件开头的乙仍提交第 7 版时系统应该明确告知基线已经变化保留乙的草稿和当前第 8 版让下一次提交建立在新的明确选择上。只按到达顺序覆盖会把旧页面里的全文当成对当前内容的确认。因此公告编辑可以把目标、权限、基线与正文一起考虑权限决定能否写基线决定能否按原修改上下文写条件写入决定如何保存冲突处理决定怎样继续编辑。这样既保护别人已经完成的修改也保留当前编辑者的工作并如实解释本次保存为什么成功或失败。参考资料功能背景产品方提供的手机端群公告说明仅采用已确认的发布和编辑功能不推测桌面流程或内部并发机制。RFC 9110 §13.1.1If-Match条件请求、强比较与失败处理。MDNIf-Match标签、弱标签的边界及丢失更新问题。Node.jsDon’t Block the Event Loop事件循环与回调执行模型不构成跨进程存储保证。