返回博客

修了一个 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。

坑:修一处发射点,其余 N 处继续出血

字面量 '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 全域 → SSoT helper → 迁移所有发射点 → 堵写入侧
  1. 修复前先 grep 字面量,把所有发射点列成清单
  2. 若命中 ≥ 3 处,停止单点 patch,改为提 SSoT helper 并一次迁移全部
  3. 合约测试断言过滤行为,防止未来回退
  4. 写入侧存 null 而非哨兵字符串,从根上切断污染链

一句话外卖

魔法字面量一旦以 fallback 身份写进 DB,就会以分布式 bug 的形式在所有读取路径上轮番重现——只有 grep 全域、集中过滤、堵住写入侧才能真正根治。