反规范化标签字段别让用户填——从 FK 派生,不是表单输入
某后台管理系统上线半年后,客服开始反复报同一个问题:"为什么这条记录的分类是空的?"运营说:"我明明选了,为什么查不到?"开发的第一反应是「前端验证没加严」,于是加了 required,问题还是出现。第二次加了后端校验,还是出现。来回三轮,分类字段依然千疮百孔:有的是空字符串,有的是中文全称,有的是旧版英文名,16 个列表页、报表、权限规则各自硬编码字面量做过滤。
根本原因不在验证,在于这个字段根本不该由用户填写。
现象
- 新记录的分类字段在生产库频繁出现
NULL或"",尽管表单有 required 提示 - 同一业务含义的历史数据有多种写法:
'专利'、'patent'、'专利权'、'' - 多个消费端(列表筛选、导出报表、权限判断)各自硬编码字面量:
WHERE category = '专利' - 业务重命名一个分类,需要改动 10+ 处代码,且老数据永远与新名称不一致
根因
这类字段本质是派生字段,它的值完全由另一列(通常是外键或带业务语义的编码字段)+ 一条有限的业务规则决定:
report_code 前缀 "PT" → 专利类
report_code 前缀 "SR" → 软著类
report_code 前缀 "SB" → 商标类
但 schema 把它设计成普通自由文本列,表单允许用户直接填写或选择。三种失效路径随之而来:
- 创建竞态:FK 编码字段和分类标签字段分别填写,用户只填了编码忘了分类 → 空值
- 重命名漂移:业务把"专利"改成"专利权",新记录用新名称,旧记录永久保留旧名称
- 消费碎片化:每个读取点各自硬编码字面量,重命名需要全库 grep + 手动改
反规范化(把标签冗余存一份)对读性能是合理的,但这不等于写路径可以接受任意输入。一旦让用户或前端自由填写这个列,它就同时变成了「用户输入」和「派生值」,两者语义冲突,数据必然漂移。
正解
四步一起做,缺任何一步都会留下漂移残差。
第一步:建 SSOT const map
// src/constants/record-category.ts
// 唯一真相来源:编码前缀 → 分类标签
export const CODE_TO_CATEGORY: Record<string, string> = {
PT: '专利',
SR: '软著',
SB: '商标',
// 只在这里加,其他地方不许出现字面量
}
export function deriveCategoryFromCode(code: string): string | null {
const prefix = code.slice(0, 2).toUpperCase()
return CODE_TO_CATEGORY[prefix] ?? null
}
// 消费端反查:标签 → 编码前缀列表(支持重命名后的多对一)
export function categoryToCodePrefixes(category: string): string[] {
return Object.entries(CODE_TO_CATEGORY)
.filter(([, v]) => v === category)
.map(([k]) => k)
}第二步:service 层写入时自动派生
// 创建时自动填充,不从 input 读 category
async function createRecord(input: RecordCreateInput) {
const category = deriveCategoryFromCode(input.record_code)
if (!category) throw new BizError('未知编码前缀,无法派生分类')
return db.insert('records', {
...input,
category, // 派生,不来自 input
})
}
// 更新时若 FK 变了,重新派生标签
async function updateRecord(id: string, patch: RecordUpdateInput) {
if (patch.record_code) {
patch.category = deriveCategoryFromCode(patch.record_code)
}
return db.update('records', id, patch)
}同时在 zod / class-validator 的 input schema 里把 category 字段剔除,让表单物理上无法传入该值。
第三步:一次性回填历史脏数据
// scripts/backfill-category.ts
const rows = await db.query(
'SELECT id, record_code FROM records WHERE category IS NULL OR category = ""'
)
for (const r of rows) {
const cat = deriveCategoryFromCode(r.record_code)
if (cat) await db.update('records', r.id, { category: cat })
else console.warn('unmatched prefix:', r.record_code) // 人工复核
}第四步:消费端用反查替代硬编码字面量
// 之前
const list = await db.query('SELECT * FROM records WHERE category = ?', ['专利'])
// 之后:通过 FK 列过滤,category 列只用于展示
const prefixes = categoryToCodePrefixes('专利')
const list = await db.query(
`SELECT * FROM records WHERE SUBSTRING(record_code, 1, 2) IN (${prefixes.map(() => '?').join(',')})`,
prefixes
)如果保留 category 列做快速筛选,也必须把所有读取点路由到同一个 helper,确保重命名只需改 const map 一处。
把 FK → 标签 的映射抽成一份 const map,service 层写入时调用 derive 函数自动填充,input schema 剔除该字段让外部无法绕过,消费端通过 reverse lookup 函数替换硬编码字面量。重命名只改 map 一处,全链路跟着走。
一句话外卖
如果一列的值可以从另一列 + 有限业务规则完全计算出来,它的写路径就不属于用户——让 service 层派生,把字面量关进 const map,消费端通过反查函数读,不要在 16 个地方各自硬编码。