返回博客

反规范化标签字段别让用户填——从 FK 派生,不是表单输入

某后台管理系统上线半年后,客服开始反复报同一个问题:"为什么这条记录的分类是空的?"运营说:"我明明选了,为什么查不到?"开发的第一反应是「前端验证没加严」,于是加了 required,问题还是出现。第二次加了后端校验,还是出现。来回三轮,分类字段依然千疮百孔:有的是空字符串,有的是中文全称,有的是旧版英文名,16 个列表页、报表、权限规则各自硬编码字面量做过滤。

根本原因不在验证,在于这个字段根本不该由用户填写。

现象

  • 新记录的分类字段在生产库频繁出现 NULL"",尽管表单有 required 提示
  • 同一业务含义的历史数据有多种写法:'专利''patent''专利权'''
  • 多个消费端(列表筛选、导出报表、权限判断)各自硬编码字面量:WHERE category = '专利'
  • 业务重命名一个分类,需要改动 10+ 处代码,且老数据永远与新名称不一致

根因

这类字段本质是派生字段,它的值完全由另一列(通常是外键或带业务语义的编码字段)+ 一条有限的业务规则决定:

report_code 前缀 "PT" → 专利类
report_code 前缀 "SR" → 软著类
report_code 前缀 "SB" → 商标类

但 schema 把它设计成普通自由文本列,表单允许用户直接填写或选择。三种失效路径随之而来:

  1. 创建竞态:FK 编码字段和分类标签字段分别填写,用户只填了编码忘了分类 → 空值
  2. 重命名漂移:业务把"专利"改成"专利权",新记录用新名称,旧记录永久保留旧名称
  3. 消费碎片化:每个读取点各自硬编码字面量,重命名需要全库 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 一处。

正解:写路径派生,读路径反查,字面量只活在 const map 里

把 FK → 标签 的映射抽成一份 const map,service 层写入时调用 derive 函数自动填充,input schema 剔除该字段让外部无法绕过,消费端通过 reverse lookup 函数替换硬编码字面量。重命名只改 map 一处,全链路跟着走。

一句话外卖

如果一列的值可以从另一列 + 有限业务规则完全计算出来,它的写路径就不属于用户——让 service 层派生,把字面量关进 const map,消费端通过反查函数读,不要在 16 个地方各自硬编码。