健康类应用里,数据错了比功能少了更致命。这篇文章收集 HealthLoop 四次真实故障,它们的共同点是:单看每一行代码都对,合起来会悄悄弄丢或弄脏用户数据。
一、软删除记录被误判为"不存在",重复删除直接报错
实现健康记录删除时,自测里"重复删除应幂等返回计数 0"的用例炸了:第二次删除抛 RECORD_NOT_FOUND。
根因在复用。单条删除先读文档判断归属,读取时复用了通用解析器 parseRecord()——这个解析器有个固有约定:deletedAtMs !== null 的记录直接返回 null(展示层和统计层靠它过滤已删数据)。于是"已软删除"被误判成"从未存在"。
修复:删除路径改用原始文档读取判断存在与归属,再单独检查 deletedAtMs === null 决定是否执行软删;幂等语义明确为"已删除 → 成功返回、计数 0"。
教训:一个解析函数在不同调用方眼里语义不同("展示过滤" vs "存在性判断")。复用前先问:它对"脏数据/已删数据"的隐含策略是什么?
二、格式噪声不该弄丢整条记录
真实输入"晚上和朋友吃了火锅",AI 通道和回退云函数先后返回 OUTPUT_INVALID——双通道全挂,但页面显示正常。
根因:新接入的字段复用了数据库入库的严格校验去校验模型原始输出。而识别模型会漏写可空字段、把 complex 写成 complex_meal、把"3 分饱"写成"3分饱"、把选项简化为字符串数组——每种偏差单独看都能恢复,严格校验一律整条拒绝。
修复:确立两个信任边界。模型输出先过"可恢复格式规范化"(只修已知、语义可恢复的差异),规范化后仍走严格 schema;规范化前的原始结构永远不能直接写库。新增回归样本覆盖五种格式噪声。
教训:LLM 输出和数据库输入是两个不同的信任级别,"任何偏差即整条拒绝"会让真实用户丢记录。
三、先怀疑实现,再怀疑测试,最后怀疑测试的语义
周报"源记录指纹随删除变化"的用例失败,两个指纹相等。第一反应查指纹函数——没毛病。再查测试——测试自己写错了:拿"完整记录 + 已删除记录"的集合过滤掉删除项,得到的集合和原集合一模一样,指纹当然相等。
修复:改测真实的 stale 触发场景——新增一条记录、修改 updatedAtMs,指纹都应变化;同时保留"顺序无关"的正向断言。
教训:断言失败时按"实现 → 测试 → 测试的语义"三步排查,第三步最容易被跳过,也最常被冤枉成实现 bug。
四、"失败不清空":更新语义的默认陷阱
详情页份量重估链路是 recognizeInput → 取重估候选 → 写回记录。如果重估时模型没给出新估算(返回 null),原代码会把 null 写回——一次重估失败就清掉记录原有的估算。用户无感知地丢了历史数据。
修复加守卫:估算为 null 时不调更新接口,提示"未能更新估算,可补充更具体的份量后重试"。注意区分场景:录入表单里的重估发生在保存之前,候选还没落库,null 估算只是不显示,不丢数据——那个位置不需要守卫。守卫的位置取决于"旧值是否已经存在"。
五、配套:update 与 set 的语义差
云函数仓储层还有一个高频坑:文档 set 是整文档替换,只传 {deletedAtMs} 会把 userId、事实字段、发生时间全抹掉,且抹掉后连分类删除、全量删除都命中不了它;update 才是局部字段更新。
规则:写仓储更新方法默认用 update 局部更新;只有运行时不支持 update 才回退 set,且回退时必须 {...existingRaw, ...patch} 整文档合并。
小结
| 故障 | 一句话规律 |
|---|---|
| 软删误判不存在 | 复用的解析器先问隐含策略 |
| 格式噪声丢记录 | 模型输出与数据库是两个信任级别 |
| 指纹用例假失败 | 排查顺序:实现 → 测试 → 测试的语义 |
| 重估清空估算 | 更新前先问:新值缺失会不会覆盖旧值 |
| set 抹字段 | 局部更新用 update,回退 set 要整文档合并 |
数据安全类的 bug 很少报警,都是事后才发现"数据不对了"。所以每条修复都要配一个永久回归用例——这也是这个项目给每个 bug 写内存桩回归的原因。