返回博客

状态枚举扩展后数据静默消失:用「排除终态」代替「白名单」

上线一个新功能:给某张业务表的 status 字段追加了两个中间状态 pending(待确认)和 confirmed(已确认),原先只有 activevoided。部署之后,列表页的记录数肉眼可见地少了——数据库里明明有,查出来就是不见。

等我打开 SQL 日志,盯着那条 WHERE status = 'active' 看了整整三秒,才意识到:坑是我自己挖的

现象

  • 新功能上线后,部分记录从列表页消失,只有上线之前创建的旧记录正常显示
  • 数据库直查确认记录存在,status 值是 pendingconfirmed
  • 前端汇总金额统计也偏低,与实际数据对不上

根因

状态字段的生命周期规律如下:

v1activevoided(二值状态机,过滤写死 = 'active'

v2:新增 pendingconfirmedvoided,同时保留旧行的 active

问题就出在这里:针对 v1 写的过滤条件只枚举了已知的「有效」值,没有为未来的中间态预留空间。每次状态机扩展,所有 WHERE status = 'active' 的地方都会静默漏掉新加的状态值,不报错、不警告,直接消失。

这不是偶发 bug,是一个结构性脆弱点:有效状态的集合会随产品迭代不断增长,而终态(作废、取消、删除)几乎不变。

坑:白名单过滤随状态扩展必然腐烂
-- 脆弱写法:每次加新状态都要全局搜索更新,而且极易遗漏
WHERE status = 'active'
 
-- 前端同样脆弱
.filter((a) => a.status === 'active')

白名单需要人工维护,遗漏一处就静默丢数据,没有任何报错信号。

正解

把过滤语义反转:排除已知的终态,而非枚举已知的有效态。终态是稳定的,中间态随业务扩展,不影响过滤逻辑。

正解:排除终态,让过滤天然兼容未来状态
-- SQL 侧
WHERE status != 'voided' AND is_deleted = 0
-- 多终态时
WHERE status NOT IN ('voided', 'cancelled') AND is_deleted = 0
// 前端列表过滤
upstream.filter((a) => a.status !== 'voided')
 
// Badge 渲染:枚举每个已知值,保留 fallback 兜底未知值
function statusBadge(status: string) {
  if (status === 'active' || status === 'confirmed')
    return <Badge variant="success">有效</Badge>
  if (status === 'pending')
    return <Badge variant="warning">待确认</Badge>
  if (status === 'voided')
    return <Badge variant="error">已作废</Badge>
  return <Badge>{status}</Badge>  // 未来新值不崩,仅降级显示
}

扩展状态值时的 Code Review 检查清单

每次给 status 枚举加新值,必须执行:

# grep 所有白名单过滤点,逐一判断能否改为 != 终态
grep -rn "status = '" src/
grep -rn "status === '" src/
grep -rn "status IN (" src/

逐条确认:

  • [ ] SQL WHERE status = '...' → 改为 != 终态(或 NOT IN
  • [ ] 前端 .filter(a => a.status === '...') → 同上
  • [ ] SUM / COUNT 聚合使用相同语义
  • [ ] Badge 渲染器新增分支,保留 default fallback

一句话外卖

状态过滤要排除终态,不要枚举有效态——有效态的边界随业务扩展,终态的边界几乎永恒。