这篇记的是「训练历史与记录修正」功能里最迷惑的一次排查:代码改对了,同步脚本一跑,改动没了,而报错信息完全不指向被覆盖的文件。

项目的双目录结构

这个项目里 shared/ 是领域逻辑的真源,miniprogram/shared/ 是微信运行时能加载的镜像副本,同步方向固定为 root → miniprogram(安装时生成、逐文件校验)。

为什么必须有镜像?因为小程序运行时只能引用 miniprogram/ 内的文件——直接从页面 import 仓库根的 shared/,编译不报错,运行时白屏。目录边界是运行时约束,不是编译期约束。

故障经过

我在副本 miniprogram/shared/ 里给训练场次解析加了 deletedAtMs 字段,本地跑页面似乎正常。随后跑了一次同步脚本——副本被真源覆盖,那两处改动被静默还原。

之后自检失败,报错是:

跨账号或已删除明细返回 null
parsed.deletedAtMs === undefined

这个报错看起来完全是业务逻辑问题:解析器没返回 deletedAtMs?判断写错?排查方向被带偏了整整一轮——实际是源码根本没生效,"已删除场次应返回 null"这条链路从头到尾没跑过新代码。

最迷惑的地方在于:镜像一致性检查是绿的。它能发现"两边不一致",发现不了"我在错的一边改"——同步之后两边确实一致了,一致的是旧内容。

修法与约定

约定很简单:改 shared/ 下的任何东西,先改根目录,再跑同步。 现在这条已经进了项目规范,并且 npm run check 里 shared-runtime 守门。

同一次交付里的另外三个坑

断言钉在实现细节上,重构就红。 结算自检原来断言 sourceTrainingSessionId: session.sessionId 出现在云函数文件里。摘要派生抽到域模块后这条直接失败——但它想守的其实是"结算写入的摘要保留来源场次"。改成两条更贴近意图的断言:域模块里仍有该字段写入 + 云函数确实委托了该派生函数。断言要锚在意图与契约上,不要锚在文件与行文上,否则每次合理重构都要改测试。

where({ deletedAtMs: null }) 的 null 有两种含义。 软删只写在嵌套 session 对象里时,列表查询(按 owner + status 过滤)照样命中——数据层删了、列表照返,等于没删。修法是给文档顶层冗余一个 deletedAtMs,查询直接带 deletedAtMs: null。旧文档没有这个字段,Mongo 语义里 {field: null} 同时匹配"字段不存在"与"字段为 null",所以旧数据零迁移。但这个语义也意味着不能用它区分"从未删除"与"显式置空"——将来若要做"已删除可恢复",必须换显式状态字段。

分页游标不能用纯时间戳。 startedAtMs:sessionId 复合游标:同一毫秒内可能有多场训练(fixture 刻意构造同毫秒),纯时间戳游标会漏或重。三页拼起来 Set.size === 5 的断言就是为了守这一点。

这条交付里几个值得保留的设计决策

  • 摘要派生只能有一处实现:结算和修正共用 buildActivityRecord,recordId 由 sessionId 稳定派生,修正天然是"更新同一条记录",不会产生第二条摘要;
  • 0 个完成组不是"修正",是"该删":把唯一完成组改成未完成,结算口径不允许入账,明确报错"修正后没有完成组,请改为删除",不静默容忍;
  • 云端明确 NOT_FOUND 时不回退本地缓存:网络失败可以降级读本机归档(页面标注"仅本机记录"),但云端明确说"已删除"就必须穿透——否则云端删了,本机缓存还让人看得到,是最尴尬的隐私问题;
  • 版本乐观锁 + 全有或全无:修正提交 expectedVersion,服务端事务内比对;一条 revision 指向不存在的组,整批拒绝。

教训

  1. 双目录结构下,"改在哪一边"是硬纪律:真源改起,同步收尾;
  2. 一致性检查只能证明"两边一样",不能证明"改到了该改的地方"——undefined 报错先确认代码生效了;
  3. 测试断言锚意图不锚文件,合理重构才不用陪跑改测试;
  4. 软删要能被查询层过滤(顶层字段),删除语义要在客户端穿透缓存。