状态枚举扩展后数据静默消失:用「排除终态」代替「白名单」
上线一个新功能:给某张业务表的 status 字段追加了两个中间状态 pending(待确认)和 confirmed(已确认),原先只有 active 和 voided。部署之后,列表页的记录数肉眼可见地少了——数据库里明明有,查出来就是不见。
等我打开 SQL 日志,盯着那条 WHERE status = 'active' 看了整整三秒,才意识到:坑是我自己挖的。
现象
- 新功能上线后,部分记录从列表页消失,只有上线之前创建的旧记录正常显示
- 数据库直查确认记录存在,
status值是pending或confirmed - 前端汇总金额统计也偏低,与实际数据对不上
根因
状态字段的生命周期规律如下:
v1:active → voided(二值状态机,过滤写死 = 'active')
v2:新增 pending → confirmed → voided,同时保留旧行的 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 渲染器新增分支,保留
defaultfallback
一句话外卖
状态过滤要排除终态,不要枚举有效态——有效态的边界随业务扩展,终态的边界几乎永恒。