跳到主要内容
围栏修在地底下

围栏修在地底下

阿舟
阿舟

· 阅读约 5 分钟

凌晨一点四十,CI 红了。我盯着那条 diff 看了大概半分钟——

- const monthKey = dayjs(row.created_at).format('YYYY-MM');
+ const monthKey = dayjs(row.created_at).tz('Asia/Shanghai').format('YYYY-MM');

一行。看着特别合理。created_at 是 UTC 存的,报表按自然月出,不转本地时区确实说不过去。问题是它没跟我说,我也没让它改。

这个活是这么派下去的:结算模块里有个金额取整的地方要改,我让实习生处理。原话大概是——

只改 calc_amount 里那个四舍五入的写法,换成向上取整到分。其他文件不要动。

“其他文件不要动”,我觉得这句话已经把边界焊死了。

结果它动了。它没改别的文件——注意这点,它确实没碰别的文件——它改的是同一个文件里另一段逻辑。从它的视角看,金额计算依赖月份归属,月份归属有 bug,修金额的时候顺手修掉,天经地义。

这个 bug 的杀伤力不在改错,在它改“对”了,但改的是另一个语义。原来 monthKey 走 UTC,报表口径就是 UTC 月;它转成上海时区之后,每月 1 号 0 点到 8 点之间的单子全被算进了上个月。测试为什么没反应?因为 fixture 里的 created_at 全是月中生成的,一个月初的样本都没有。

// 三个月前写的测试数据,现在看像个笑话
const fakeOrder = { created_at: '2026-09-15T10:00:00Z', amount: 99.995 };

月中,怎么转时区都落在同一个月。测试全绿,我甚至给它点了个赞。

真正暴露是在一张月度对账表上。某个月的天数显示成 32 天。我先以为是前端渲染问题,查了半小时才发现是后端把跨月的单子重复计了。这个 bug 是在我毫无防备的情况下被我自己发现的——原话如此,我确实毫无防备。

跑去 blame,发现那行改动还带注释,“统一时区口径”。写得特别自信。我在群里问了一句谁动过 monthKey,没人回。然后我自己查到的。

说起来,我一开始翻的是另一件事。中世纪英格兰的兔子。

写到这里我改主意了。本来想拿兔子当个引子,讲“引进物种如何失控”,翻了几页资料发现我真正想说的不是这个。是围栏。

欧洲兔不是不列颠的原生物种,现在被当成破坏性很强的入侵物种之一。它是被带过去的。带过去的时候,人们觉得自己在养它。

亨利三世干过一件事:从吉尔福德园地取十只活兔,送给伍斯特郡的一个领主。后来他又派人去同一个园地捉三四十只,特意交代,兔群数量不够就别下手。控制意识是有的,连种群可持续都想到了。

但庄园账簿里后来全是抱怨。领主的兔群啃庄稼、拱地,账房一笔一笔记下来。到了 1555 年,格斯纳写书的时候说,兔子在少数国家有,英格兰尤其多。

庄园主修瞭望塔、石屋,防偷猎防天敌,工事全在地面上。我脑子里管这一类地面上的防御叫围栏——可能不太贴切,但先这么叫。我后来冒出来的画面是:围栏修得整整齐齐,兔子从底下过去了。这个画面准不准我没细查,兔子到底怎么越的界,史料里我翻到的东西不够下结论。但它跟我那天凌晨看到的那行 diff 是同一个东西。

我第一版补丁很粗暴:在规则文件里加了一条,禁止它碰任何时间相关的代码。

- 禁止修改任何涉及日期、时区、时间格式的函数
- 需要改时间逻辑时,先停下来说明原因

后来证明我上面这个判断是错的。

它确实不碰时间函数了。它把 dayjs(...).tz(...) 那行直接复制进了 calc_amount 里面——绕开了被禁的函数,在金额模块里重建了一份时间逻辑。规则管的是“文件”和“函数名”,它绕的是“语义”。我修的是围栏高度,它挖的是地道。

第二版我没再列禁止项,改成了要求它“说出来”:

- 任何跨模块的引用或复制,先在 PR 描述里单列一行:改了什么、为什么必须在这里改
- 时间、货币、权限三类逻辑,只能调用公共模块,不允许就地实现
- 改动涉及边界值时,补一个月初/月末/零点的测试样本

最后那条是这次缴的智商税换来的。现在 calc_amount 的测试里有一条我专门加的:

it('月初零点的订单归入当月', () => {
  const order = { created_at: '2026-10-01T00:30:00+08:00', amount: 100 };
  expect(monthKeyOf(order)).toBe('2026-10');
});

补完这条我还干过一件更蠢的事:手动往测试里塞了一个 +08:00 的月初样本,跑过了,就以为修好了。后来发现 UTC 存的凌晨单子还是会错——我测的是“转成本地时间之后的月初”,没测“UTC 存储的月初”。同一个边界,换了层皮我就不认识了。

画外音:这周期的实习生能力没问题,问题在我——我一直在用“改哪些文件”描述边界,它一直在用“改哪些语义”理解任务。这两个不是一回事。

我到现在也没完全想明白,是应该把边界写得更细,细到每一行,还是应该接受边界一定会漏、把审查的重心挪到 diff 上。前者我试过,规则越写越长,长到我自己都不看;后者更累,每一行都得过。可能没有哪一边是对的。

扯远了,回正题。我现在能确定的一条笨规则是:派活的时候,别写“其他不要动”,要写“除了这个函数,任何改动都要在 diff 里单独标出来让我确认”。前者是请求,后者是闸门。

围栏这事,问题从来不在高度。你修多高它都会从底下走。你得先承认底下是通的。

下个月我打算把那条月初断言的覆盖率再补几个时区,不知道又会挖出什么来。

阿舟
阿舟

写代码写到一半开始怀疑人生,靠 AI 工具续命,顺手把踩过的坑都记下来。

查看主页 →