复盘:用 Spec-First 预防账单事故

复盘:用 Spec-First 预防账单事故
Daniel Marsh · Spec-First 工程笔记

这是一次 postmortem 式的复盘:一个 bug 上线,不是因为代码写错了,而是因为我们的规格从头到尾没有交代 proration。下面是事故经过、那句本该被写下却没写下的描述,以及我们事后补上的章节。

发布于 2026-02-28 · ✓ 已更新 2026-05-06 · 阅读约 6 分钟 · 作者:Daniel Marsh · 审校:编辑政策

四万美元的意外

那是一个平静 sprint 的最后一天,我们把一个套餐变更接口合并上线。自助升级、降级、席位调整,全部走一个 POST。二十一天后,财务那边发现对账出现缺口:大约 40,300 美元的扣款,找不到任何一条发票能解释清楚。其中约三分之一是对周期中途降级客户的超额扣款,剩下的则是从上线第四天起我们默默吞掉的少收款。

没有人玩忽职守。代码做的就是规格里写的。问题是规格本身没写够。

真实发生的时间线

Day zero 是合并日。集成测试通过,staging dashboard 看起来正常,feature flag 在那一周结束前已经放量到 10% 的账户。Day 4,一位客户在计费周期开始八天后,从 499 美元套餐降级到 49 美元套餐。我们的系统立即向他们收了完整的 49 美元,剩下那 499 美元就那样搁在那里没人动过。Day 9,又有一位客户在周期中途升级,结果在尚未按比例扣减旧套餐的情况下,直接按新套餐全价被扣了一次。两种情况都没有抛出异常,也都没有出现在错误率曲线里。

这个模式持续了整整三周,因为我们的告警盯的是 HTTP 5xx,而不是账簿增量。最后是财务在月末关账时发现的——Stripe 导出的数据和我们内部的收入确认表对不上了。

规格里当时写的是什么

现在回头看,那份规格里有一句话几乎造成了全部损失。它原原本本地通过了评审,拿到了三个批准:

Plan changes take effect immediately. The new plan price is
charged on the next billing cycle. Prorate as needed.

"Prorate as needed." 就是这句话。在文档里读起来相当合理。每个评审者读完都各自脑补了一套模型。后端工程师默认 Stripe 的 proration 行为会处理好;前端工程师默认 proration 是在服务端静默完成的;PM 则以为"as needed"意思是"当财务团队说需要时"。三种默认值,零共享契约,就这么上线了。

诚实地说清根因

根因不是代码里的 bug。代码忠实地实现了那份含混。真正的根因是:我们的规格模板里有"Pricing"章节,有"Happy Path"章节,却没有一个章节会强迫作者回答——价格变更从什么时候开始生效,部分周期怎么计算,旧周期没用完的部分怎么处理,税费和 dunning 状态由谁负责重算?

这就是这类 bug 的本质。它不是逻辑错误,它是一个从未被做出的决定,藏在一种让每一位读者都相信"已经有别人决定过了"的措辞背后。

本该写进规格的那段话

如果当初规格里有下面这段话,事故根本不会发生:"套餐变更默认在下一个计费周期开始时生效。周期中途升级时,立即按 (new_price - old_price) × (days_remaining / days_in_cycle) 的公式收取按比例的差额。降级则按同样的公式签发一张抵扣额度,作用于下一张发票,一律不以退款形式处理。税费基于按比例金额并按客户当前税务档案重新计算。Dunning 状态在变更前后保持不变;处于 dunning 状态的客户在清账前不得发起套餐变更。"

它不优雅,也不简短。但我们现在写的就是这种规格,因为上一版"优雅"的规格让我们付出了一周的对账工作,还有一笔从没进过预算的客户信任补偿。

我们在模板里加了什么

事故之后,我们在所有涉及资金的规格里强制加了四个子章节。一个都不是可选的,必须在工程评审开始之前全部填完。

运维层面的改动

只改规格还不够。我们同时调整了监控的内容和频率。现在我们每六小时就与支付方对一次账簿,而不是一天一次。只要任何关账窗口内我们的内部收入表和支付方已结算扣款之间的差额超过十美元,就会触发告警。我们还加了一个合成测试,每小时对一个测试账户做一次升级和一次降级,把 proration 数学按分核对。这些做法都不光鲜,但任何一项都足以让当初那起事故在四小时内被发现,而不是二十一天。

评审里怎么抓这种模式

可复现的模式叫做"承重副词"(load-bearing adverb)。只要一份规格用 "appropriately"、"as needed"、"sensibly"、"automatically" 这类词来描述会动钱、改访问权限或触达外部系统的行为,那个词就在替一条公式、一张表或一张状态图干活。现在我们在评审时,会先 grep 这些副词,再读其他内容。这是非常便宜、机械化的检查,足以逮住我们那次一模一样的事故。

我们刻意没做的事是为了走过场而增加官僚流程。我们没有引入委员会,也没有搞签字矩阵,只加了四个子章节标题和一张禁用词清单。目标是把决定逼到白纸黑字上,而不是让团队被仪式拖慢。

诚实地讲讲 trade-off

多写规格确实会拖慢功能交付。套餐变更这个功能按新模板来写,大约要多花两天。我们不会在 code review 里发现这个 bug,而会在规格评审里发现——那是发现它几乎不花代价的地方。相对的,我们也不会再花掉十一个工程师工作日去做补救、退款和客户沟通。这笔账怎么算都划算,但前提是我们现在见过坏结果长什么样。在事故发生之前,同样这两天只会让人觉得是额外开销。这是最让人不舒服的一点:这套纪律的价值,大多数时候是不可见的,直到一次昂贵的事故把它暴露出来。

诚实地说,我们并没有对所有事情都搞 spec-first。一次性的后台工具,还是一段话加一张工单就打发了。但凡涉及账单、认证或第三方合约的事情,现在都走更长的模板,而且我们不再为此道歉。

评审时看什么

这篇文章适合用在用复盘反推规格缺口时。别从“原则”聊起,直接拿一条真实改动来对照,看看规格里还缺什么。

好复盘不止解释事故。它会改变下一份规格的默认写法。

复盘后要改 spec,不只改代码

计费事故复盘最常见的空洞行动项是“加强 review”。更有用的是把事故暴露出的缺失规则补回 spec 模板,让下一次类似功能在设计阶段就被问到。

Postmortem spec update:
- Incident: duplicate invoice charge after provider timeout
- Missing spec rule: retry behavior after unknown provider result
- New acceptance criterion: same idempotency key returns original attempt_id
- New evidence gate: concurrent retry test required for payment flows
- New rollout rule: payment worker can be paused by feature flag
- Owner: billing platform lead, due before next billing release

边界:复盘不是给旧 spec 补漂亮文档。只补会改变未来行为的问题:验收标准、边界条件、测试证据或回滚门槛。

现在发版前必须附的证据

账单功能不再只看测试是否通过。我们要求 PR 里贴出四样东西,少一样就不发。这个要求有点硬,但它比上线后临时翻支付后台舒服得多。

Billing release evidence

1. Invoice preview
- 升级、降级、试用结束、past_due 阻断,各一条样例
- 每条样例保留 line item、税额、credit 和最终应付金额

2. Ledger reconciliation
- 内部 ledger 总额
- 支付服务商导出总额
- 差异阈值和处理人

3. Customer explanation
- 支持团队能复述这张账单为什么这么算
- 退款、credit、下期抵扣分别怎么解释

4. Rollback
- 关闭哪个 feature flag
- 已经产生的 invoice 是否保留
- 需要通知哪些 downstream consumer

这段证据的好处是很直接:出了争议时,团队不用重新拼事实。规格、测试、财务口径和客服解释都在同一个地方。

关键词:billing incident postmortem · proration spec · revenue reconciliation · effective-date semantics · money-moving code review

专题阅读路径

这篇文章归入 API 契约 主题。先读 Hub,再结合下面的清单、模板或工具落到具体项目里。

交互式生成规格
填写表单,生成完整的功能规格 Markdown——免费使用,无需注册。
试用规格生成器

编辑说明

本文面向软件交付团队,介绍用 Spec-First 预防账单事故。示例均为工程场景说明,不构成法律、税务或投资建议。