normalize 函数的白名单悄悄吃掉了你刚写入的字段
数据库字段值莫名其妙地变回 NULL。PATCH 接口返回 200,SQL 也写进去了,但隔几秒又丢了。最诡异的是:让字段消失的那次 PATCH,根本没有动这个字段。
这种「隔山打牛」式的数据丢失,让人第一反应去查事务、查 ORM、查并发——结果都没问题。真正的凶手藏在写路径里一个貌不惊人的 normalize() 函数。
现象
某后台管理系统通过数据库迁移给子记录表新增了三列(field_a、field_b、field_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:
readAggregate从 DB 读出带field_a='X'的数据normalizeChildren重建对象,field_a不在白名单 → 变成undefinedupsertAggregate执行DELETE + INSERT,clean(undefined)→ 写入空字符串或 NULL
新字段就这样被每一次无辜的 PATCH 静默清零。
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 丢失」时,按这个顺序查:
- PATCH handler 里有没有
normalize()/rebuild()/sanitize()调用? - 那个函数是「重建」(显式
return { field1, field2 })还是「合并」?重建 = 候选凶手。 - 对比函数里的字段列表和 DB schema——哪些列不在白名单里?
- 写路径是 DELETE+INSERT 还是 UPDATE 全量列?全量写 + 字段缺失 = 静默 NULL。
一句话外卖
加列迁移后,
grep normalize\|rebuild\|sanitize找到所有字段白名单,逐一 diff——它们是被遗忘的第二份 schema,漂移必然导致静默数据丢失。