返回博客

自动保存静默丢数据:草稿模式与严格校验的双模式设计

在一个可视化流程/自动化编辑器里接到 bug 反馈:"我改了两个步骤,一个都没存上。"网络面板看起来自动保存每隔 700ms 就触发一次,状态码是 200——但刷新页面,编辑消失了。这种「看似保存成功、实则丢失」的问题,往往不是网络问题,而是校验策略的设计缺陷。

现象

用户在流程编辑器里增量构建:

  1. 拖入一个「等待人工介入」步骤,message 字段留空(稍后填)
  2. 编辑上方「跳转」步骤,填入完整的 URL(这是一次合法的、完整的编辑)
  3. 防抖 700ms 后自动保存触发,把整份快照 PATCH 到服务端
  4. 服务端发现其中一个步骤 message 为空 → 400 step xxx: message required
  5. 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 */])
坑:客户端跳过保存,合法编辑一起陪葬

hasPendingSteptrue 时,整个自动保存被跳过。不管有几个步骤已经填写完整,只要流程里存在任意一个未完成的步骤,所有修改都会静默丢失。用户界面没有任何报错,看起来像是「保存成功了」,实则什么都没存。

随着防御性逻辑越堆越多(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 */])
正解:status 状态跳转是唯一的严格校验门

用户点击「启用 / 发布」时,PATCH 携带 status: 'active',服务端切换为严格模式,逐字段报错,前端展示给用户。这是唯一应该咬住用户的地方。草稿期间的所有自动保存,无论字段填没填完,都应该无障碍落库。

关键区分

结构性错误(重复 ID、未知步骤类型、悬空引用)即便在草稿模式下也应该拒绝——这是数据损坏,不是进行中的工作。只有「必填字段为空」这一类才在草稿模式下放行。

UI 指示器 ≠ 保存门禁。在步骤列表里展示 🟡「待填…」角标是良好的用户反馈;把它当作 if (!pending) 才保存 的条件,就会滑入上面描述的陷阱。两件事彻底解耦。

一句话外卖

草稿就是允许残缺的,服务端只有一种校验模式才是根因——按 status 切换宽松/严格两档,客户端不做完整性门禁,自动保存才不会静默丢数据。