修了一个 URL 参数 bug,同名字面量从另外四个地方继续出血
某后台管理系统在串联枚举选项接口时,日志里突然开始冒出大量 500:Unknown column 'value' in 'field list'。很快定位到一个 URL 构建函数把 value_field=value 拼进了查询参数,而下层视图里根本没有名为 value 的真实列——只是一个前端默认的占位字符串。
fix 很简单,两行过滤掉这个哨兵值,提 PR、合并、上线。
第二天,产品发来消息:审批流模块又 500 了。同样的错误,同样的列名。
现象
第一轮修复只针对「报告出来的那一个 URL 构建路径」。然而 'value' 这个魔法字面量,以 fallback 的形式散落在至少五个独立位置:
- 枚举级联的 hook(第一轮修复点)
- 单字段预设枚举的 hook(遗漏,次日触发 500)
- 表单面板组件的三处内联逻辑(各自拼 URLSearchParams)
任何一个用户流触碰到尚未修复的发射点,bug 都会重现。
根因
问题的源头不是 URL 构建层,而是更上游的写入侧:UI 组件在保存字段配置时,对空选择做了如下兜底:
// 危险:把 UI 展示用的虚拟别名写入了数据库
const valueField = col.presetEnumValueField || 'value''value' 在前端接口层是虚拟别名(代表「用默认值列」),但一旦被持久化到数据库,后续所有 URL 构建函数从 DB 读出这一列时都会原样追加 value_field=value,最终在 DB 视图里找不到真实的 value 列,抛出 500。
字面量 'value' 同时存在于多个独立文件,修复某条路径只消灭了一个发射点。其他路径只要被用户触碰到,500 依旧。而且,只要 DB 里还有历史写入的脏数据,每次读出来都会触发同样的 bug。
UI fallback ──写入──► DB 字段值 = 'value'
│
├──► URL Builder A ← 第一轮已修
├──► URL Builder B ← 继续出血
├──► URL Builder C ← 继续出血
└──► URL Builder D ← 继续出血
正解
第一步:grep 全域,不要只改报告的那个文件
grep -rn "'value'\|\"value\"" src/ \
| grep -v test \
| grep -v "^src/lib/enum-options-url.ts"得到所有发射点清单,一次性评估影响面,再动手。
第二步:提 SSoT helper,把过滤规则集中到一处
// src/lib/enum-options-url.ts
export function resolveEffectiveValueField(
valueField: string | null | undefined
): string | null {
if (!valueField) return null
// 'value' / 'name' 是接口层虚拟别名,不是真实 DB 列,不能发给后端
if (valueField === 'value' || valueField === 'name') return null
return valueField
}
export function buildEnumOptionsUrlParams(input: {
enumId: string
valueField?: string | null
// ...其余参数
}): URLSearchParams {
const p = new URLSearchParams()
p.set('enum_id', input.enumId)
const vf = resolveEffectiveValueField(input.valueField)
if (vf) p.set('value_field', vf)
return p
}第三步:所有发射点切换到 helper,不再各自内联
从 new URLSearchParams().set('value_field', x) 全部换成 buildEnumOptionsUrlParams({ ..., valueField: x })。以后要改过滤逻辑,只改一个文件。
第四步:写合约测试钉住规则
describe('resolveEffectiveValueField', () => {
it.each(['value', 'name'])('虚拟别名 %s 应返回 null', (alias) => {
expect(resolveEffectiveValueField(alias)).toBe(null)
})
it('真实列名原样保留', () => {
expect(resolveEffectiveValueField('employee_no')).toBe('employee_no')
})
})第五步:堵住 DB 写入侧
// 保存字段配置时,不写 'value',写 null
const valueField = raw || null历史脏数据可按需补一个 migration 将 'value' 改为 NULL,否则老数据一直是定时炸弹。
- 修复前先 grep 字面量,把所有发射点列成清单
- 若命中 ≥ 3 处,停止单点 patch,改为提 SSoT helper 并一次迁移全部
- 合约测试断言过滤行为,防止未来回退
- 写入侧存
null而非哨兵字符串,从根上切断污染链
一句话外卖
魔法字面量一旦以 fallback 身份写进 DB,就会以分布式 bug 的形式在所有读取路径上轮番重现——只有 grep 全域、集中过滤、堵住写入侧才能真正根治。