返回博客

normalize 函数的白名单悄悄吃掉了你刚写入的字段

数据库字段值莫名其妙地变回 NULL。PATCH 接口返回 200,SQL 也写进去了,但隔几秒又丢了。最诡异的是:让字段消失的那次 PATCH,根本没有动这个字段。

这种「隔山打牛」式的数据丢失,让人第一反应去查事务、查 ORM、查并发——结果都没问题。真正的凶手藏在写路径里一个貌不惊人的 normalize() 函数。

现象

某后台管理系统通过数据库迁移给子记录表新增了三列(field_afield_bfield_c)。批量更新接口调用成功,DB 里确实写进去了。但只要对同一行的任意其他字段执行一次普通 PATCH(比如更新备注),这三列就会悄悄变回 NULL。

时序如下:

09:12:07  POST /apply-all   → 批量写入 field_a='X'(4 条子记录)✓
09:12:20  PATCH /child/2    → 更新 description='...'(不涉及 field_a)
09:12:20  DB 查询           → child/2 的 field_a 变成 NULL ✗
09:12:21  DB 查询           → child/3、child/4 同样丢失 ✗

根因

写路径的处理流程长这样:

const aggregate = await readAggregate(id)
aggregate.children = normalizeChildren(aggregate.children)  // ← 陷阱在这里
const target = aggregate.children.find(x => x.id === childId)
if (Object.hasOwn(patch, 'description')) target.description = patch.description
await upsertAggregate(aggregate)   // DELETE+INSERT 全量重写

normalizeChildren 是个「防御性重建」函数,最初是为了清洗 LLM/解析器吐出的畸形 JSON,所以它显式枚举已知字段、丢弃其余一切

function normalizeChild(raw) {
  return {
    id: raw.id,
    companyName: raw.companyName || raw.company,
    description: raw.description || '',
    jobTitle: raw.jobTitle || '',
    // ... 若干老字段 ...
    // ← 新加的 field_a / field_b / field_c 没有出现在这里
  }
}

schema 迁移加了新列,但没有人记得去更新这个白名单。于是每次 PATCH:

  1. readAggregate 从 DB 读出带 field_a='X' 的数据
  2. normalizeChildren 重建对象,field_a 不在白名单 → 变成 undefined
  3. upsertAggregate 执行 DELETE + INSERTclean(undefined) → 写入空字符串或 NULL

新字段就这样被每一次无辜的 PATCH 静默清零。

坑:normalize 白名单是隐式的第二份 schema 定义

normalize / rebuild / sanitize 类函数一旦采用「显式字段枚举 + 返回新对象」的写法,就成了一份与 DB schema 并存的影子字段清单。加列时只改了迁移文件,没改白名单,两份定义悄悄漂移——而写路径上的 DELETE+INSERT 会把漂移放大成静默数据丢失。这个坑在 PATCH 返回 200、迁移已应用的情况下几乎无迹可查。

正解

把新字段加进 normalize 白名单,同时兼容 camelCase(内存对象)和 snake_case(DB 行)两种输入:

function normalizeChild(raw) {
  return {
    id: raw.id,
    companyName: raw.companyName || raw.company,
    description: cleanString(raw.description),
    jobTitle: cleanString(raw.jobTitle),
    // 迁移新增的字段——每次改 schema 都要同步更新这里
    fieldA: cleanString(raw.fieldA || raw.field_a),
    fieldB: cleanString(raw.fieldB || raw.field_b),
    fieldC: cleanString(raw.fieldC || raw.field_c),
  }
}

如果场景允许,更推荐把「重建」改为「合并覆盖」,这样未来新字段会自动穿透:

function normalizeChild(raw) {
  const cleaned = { ...raw }  // 保留所有未知字段,不丢弃
  cleaned.description = cleanString(raw.description)
  cleaned.startDate = normalizeDate(raw.startDate)
  // 只覆盖需要清洗的字段
  return cleaned
}
正解:合并覆盖优于显式重建

{ ...raw, ...overrides } 或先展开再逐字段覆盖的方式写 normalize,让未声明的字段自动穿透。只有在明确需要「丢弃未知键」的安全场景(如公开 API 输出脱敏)才使用显式重建——此时必须把 normalize 白名单和迁移文件放在同一个 PR 里一起改。

快速验证

怀疑这个 bug 时,用下面的隔离测试 60 秒确认:

// 1. 直接写入新字段
await db.query(`UPDATE child SET field_a='expected' WHERE id=?`, [childId])
// 2. 对同一行执行一次不相关的 PATCH
await patchChild(parentId, childId, { description: 'whatever' })
// 3. 读回来——若 field_a 变成 NULL,bug 确认
const [row] = await db.query(`SELECT field_a FROM child WHERE id=?`, [childId])

排查清单

怀疑「PATCH A 导致字段 B 丢失」时,按这个顺序查:

  1. PATCH handler 里有没有 normalize() / rebuild() / sanitize() 调用?
  2. 那个函数是「重建」(显式 return { field1, field2 })还是「合并」?重建 = 候选凶手。
  3. 对比函数里的字段列表和 DB schema——哪些列不在白名单里?
  4. 写路径是 DELETE+INSERT 还是 UPDATE 全量列?全量写 + 字段缺失 = 静默 NULL。

一句话外卖

加列迁移后,grep normalize\|rebuild\|sanitize 找到所有字段白名单,逐一 diff——它们是被遗忘的第二份 schema,漂移必然导致静默数据丢失。