返回博客

新增枚举值 API 返回 200 但数据消失——N 层白名单各自为政的静默丢弃

在一个工作流编辑器里新增一种步骤类型,本以为只要改 TypeScript 的 union 类型就够了。结果:编译通过,前端创建成功,PATCH 接口返回 200,响应体里清清楚楚写着新步骤,但一旦关闭标签页再重新打开——步骤消失得无影无踪。

现象

  1. 给判别联合类型加一个新成员:'sleep'
  2. TypeScript 编译 0 错误,编辑器可以创建该步骤
  3. PATCH /api/flows/:id 返回 200 OK,响应体包含 sleep 步骤
  4. 直接 SELECT 数据库,该行不存在
  5. 关闭 + 重新打开编辑器,步骤消失

没有任何报错,日志安静如初。

根因

TypeScript 的 union 是编译期约束,运行时没有任何守卫效果。这条数据在持久化路径上要穿越多个独立的字符串白名单,只要有一处没更新就会静默丢弃:

| 层 | 典型代码 | 漏掉时的行为 | |---|---|---| | 服务端入参校验 | STEP_TYPES.has(value) | 400 或宽松模式直接忽略 | | 写库前 normalize | normalizeStepType(v) 白名单 | 返回 null.filter(Boolean) 过滤 → INSERT 少一行 | | MySQL ENUM 列 | ENUM('navigate','click',...) | Data truncated for column 或存入空字符串 | | IPC 校验(Electron) | browserStepTypes Set | step_type_invalid 错误 | | 展示映射表 | STEP_ICON / STEP_LABEL | 显示占位符但步骤本体仍丢失 | | 引擎派发 switch | runStep(step) | unknown type 抛错或走到 default 什么都不做 |

其中最隐蔽的是 normalize-on-write 层。以 Next.js 项目为例,服务端典型写法是:

function normalizeStepType(v) {
  const allowed = ['navigate', 'click', 'wait_for_login']
  return allowed.includes(v) ? v : null
}
 
// 写库前
const rows = steps
  .map(s => buildRow(s))  // buildRow 调用 normalizeStepType
  .filter(Boolean)        // null 被无声过滤

sleep 不在 allowed 里 → normalizeStepType 返回 nullfilter(Boolean) 过滤掉 → INSERT 少了这一行。而 PATCH 接口的响应是从内存中的 next 对象组装的,不是 DB 读回来的,所以响应体始终"正确",用户完全感知不到丢弃。

坑:PATCH 返回 200 + 响应体有数据 ≠ 数据已入库

接口响应来自内存拼装的 next 对象,不是 DB readback。normalize-on-write 函数把新枚举值悄悄过滤成 null,经 .filter(Boolean) 消失,INSERT 少行,但 HTTP 响应无异常。下次 GET 从 DB 读 → 步骤消失。

排查

怀疑"数据保存后消失"时,第一步绕过 API,直查数据库

mysql -e "SELECT step_index, step_type FROM flow_steps WHERE flow_id = ?"

如果行数少于响应体里的步骤数,就是写路径的某层把它过滤掉了。

然后在 normalize 函数顶部加 console.log 确认白名单加载状态:

const TYPE_WHITELIST = Object.freeze(['navigate', 'click', 'sleep'])
console.log('[rpa] whitelist:', TYPE_WHITELIST.join(','))

重启服务后看终端输出。如果 sleep 没出现,说明文件变更没进内存(Next.js dev HMR 对 .mjs 文件并不可靠,改完要手动重启)。

正解

新增一个枚举值时,用 grep 找出所有需要同步更新的地方,一个 commit 全部改完:

grep -rln 'navigate.*click\|STEP_TYPE\|BrowserStepType\|step_type'

逐一核对清单:

  • [ ] 客户端 TS union(编译期)
  • [ ] 服务端入参校验 Set(STEP_TYPES.add('sleep')
  • [ ] 服务端 normalizeStepType 白名单数组
  • [ ] MySQL ENUM migration(ALTER TABLE ... MODIFY COLUMN step_type ENUM(..., 'sleep')
  • [ ] IPC 校验 Set(Electron/Tauri 场景)
  • [ ] 引擎 dispatch switch + sub-runner
  • [ ] 图标 / 标签 / 摘要展示映射表
  • [ ] 可创建步骤列表 STARTERS
  • [ ] defaultParamsFor switch

还要加一个 round-trip 测试,明确断言从 DB 读回:

it('新步骤类型持久化 round-trip', async () => {
  const flow = await getFlow(id)
  flow.steps.push({ id: 'x', type: 'sleep', params: { ms: 1000 } })
  await updateFlow(id, flow)
  const reread = await getFlow(id)  // 必须重新从 DB 取,不能复用内存对象
  expect(reread.steps.find(s => s.id === 'x')).toBeDefined()
})
正解:grep 找全所有白名单,一次性更新 + round-trip 测试

新增枚举值不是"改一处 TS 类型"就完事。用 grep 找出全部 N 个独立白名单,一个 commit 同步更新,测试必须包含关闭再重新打开(从 DB 读回)的 round-trip 验证。

防御性升级:normalize 函数应该抛错而不是返回 null

return null 改为 throw new Error('unknown step type: ' + v)——让未知类型在写库前立刻爆出来,而不是被 .filter(Boolean) 悄悄吞掉。响亮的失败优于静默的丢弃。

一句话外卖

TypeScript union 是编译期幻觉,运行时每一层独立白名单才是真正的守门人——新增枚举值必须用 grep 找全所有白名单并一次提交,配合 DB round-trip 测试才算完成。