自动保存静默丢数据:草稿模式与严格校验的双模式设计
在一个可视化流程/自动化编辑器里接到 bug 反馈:"我改了两个步骤,一个都没存上。"网络面板看起来自动保存每隔 700ms 就触发一次,状态码是 200——但刷新页面,编辑消失了。这种「看似保存成功、实则丢失」的问题,往往不是网络问题,而是校验策略的设计缺陷。
现象
用户在流程编辑器里增量构建:
- 拖入一个「等待人工介入」步骤,
message字段留空(稍后填) - 编辑上方「跳转」步骤,填入完整的 URL(这是一次合法的、完整的编辑)
- 防抖 700ms 后自动保存触发,把整份快照 PATCH 到服务端
- 服务端发现其中一个步骤
message为空 →400 step xxx: message required - URL 编辑永远没被持久化
用户报告:"改了好几次,都没存上。"
根因
服务端的校验函数只有一个模式:每次保存都运行全量的字段完整性检查,无论当前实体是草稿还是已激活状态。用户增量构建是常态,但服务端把「草稿保存」当「发布前检查」来对待。
于是有人想到一个直觉上合理的客户端补丁——预校验,有未填步骤就跳过自动保存:
// ❌ 反模式:静默丢弃合法编辑
useEffect(() => {
if (!hasUserEditedRef.current) return
if (snapshot === lastSavedSnapshot) return
const hasPendingStep = subflows.some(s =>
safeSteps(s.steps).some(step => stepPendingDetail(step).needs),
)
if (hasPendingStep) return // ← 静默跳过,用户毫不知情
const timer = setTimeout(() => persistFlow('auto'), 700)
return () => clearTimeout(timer)
}, [/* deps */])hasPendingStep 为 true 时,整个自动保存被跳过。不管有几个步骤已经填写完整,只要流程里存在任意一个未完成的步骤,所有修改都会静默丢失。用户界面没有任何报错,看起来像是「保存成功了」,实则什么都没存。
随着防御性逻辑越堆越多(editingStepId || picked || hasPendingStep || isOffline || ...),每个条件都是一个新的静默丢失陷阱。
正解
把策略决策移到服务端,按实体的 status 切换两种校验模式:
- 草稿模式(
status === 'draft'):允许不完整的字段,只做结构性校验(ID 存在、步骤类型合法、引用不悬空)。 - 激活模式(
status === 'active'):严格校验,所有必填字段必须非空。
服务端:校验器接收 allowIncompleteSteps 选项
// validation.mjs
export function validateStep(step, variables, options = {}) {
const errs = []
// 结构性检查始终运行——与用户填没填字段无关
if (!step.id) errs.push('step.id required')
if (!STEP_TYPES.has(step.type)) errs.push(`step.type invalid: ${step.type}`)
// 字段完整性只在非宽松模式下检查
const lenient = Boolean(options.allowIncompleteSteps)
const p = step.params || {}
switch (step.type) {
case 'navigate':
if (!lenient && !p.url) errs.push(`step ${step.id}: url required`)
break
case 'pause_for_human':
if (!lenient && !p.message) errs.push(`step ${step.id}: message required`)
break
case 'click':
if (!lenient && !selectorOk(p.selector)) errs.push(`step ${step.id}: selector required`)
break
}
return errs
}服务端:调用处按 status 决定模式
// 更新流程时
const allowIncompleteSteps = (next.status || 'draft') === 'draft'
const errs = validateFlow(next, { allowIncompleteSteps })
if (errs.length) throw httpErr(400, errs.join('; '))草稿保存宽松,激活时严格——一行条件,策略清晰。
客户端:自动保存无条件触发
// ✓ 正确:不设完整性门禁,由服务端决定
useEffect(() => {
if (editingStepId || picked) return // 编辑中途不保存,合理
if (!hasUserEditedRef.current) return // 首次挂载跳过,合理
if (snapshot === lastSavedSnapshot) return // 去重,合理
// 不再有 hasPendingStep 门禁——草稿模式下服务端接受不完整步骤
// UI 里仍然展示 🟡 待填提示,那是视觉反馈,不是保存前置条件
const timer = setTimeout(() => persistFlow('auto'), 700)
return () => clearTimeout(timer)
}, [/* deps */])用户点击「启用 / 发布」时,PATCH 携带 status: 'active',服务端切换为严格模式,逐字段报错,前端展示给用户。这是唯一应该咬住用户的地方。草稿期间的所有自动保存,无论字段填没填完,都应该无障碍落库。
关键区分
结构性错误(重复 ID、未知步骤类型、悬空引用)即便在草稿模式下也应该拒绝——这是数据损坏,不是进行中的工作。只有「必填字段为空」这一类才在草稿模式下放行。
UI 指示器 ≠ 保存门禁。在步骤列表里展示 🟡「待填…」角标是良好的用户反馈;把它当作 if (!pending) 才保存 的条件,就会滑入上面描述的陷阱。两件事彻底解耦。
一句话外卖
草稿就是允许残缺的,服务端只有一种校验模式才是根因——按
status切换宽松/严格两档,客户端不做完整性门禁,自动保存才不会静默丢数据。