订阅 bug 本质上是状态 bug。客户取消后仍能访问;逾期账户付了款却依旧被锁;没有绑卡的用户试用结束了,却没有任何规则说明接下来会发生什么。这些问题,每一个都是一条没人画出来的转换。
状态机图通常也叫状态图,它列出一个对象可能处于的每一种状态,以及把它从一种状态带到另一种状态的每一个事件。缺失的转换会在评审时暴露,而不是出现在客服工单里。本文为一个 SaaS 订阅绘制状态图,采用 Stripe Billing 文档中的订阅状态,方便你把模型和真实系统对照;用到的表示法会在出现时讲解。
什么时候该画状态机
当单个对象有一个带规则的生命周期时,就值得画一张:订单、订阅、支付、客服工单、部署。下面这些信号你一定不陌生:
- 一个
status列有三个以上的取值,代码里很多地方都在检查它。 - 存在
is_canceled和is_paused这类可能互相矛盾的布尔标志。 - 团队在争论某件事能否从某个状态“发生”。
对于多个参与方之间的对话,状态图并不合适,应该用时序图展示消息顺序;对于涉及很多人和交接环节的业务流程,BPMN 图更合适。
表示法
| 元素 | 表示法 | 含义 |
|---|---|---|
| 状态 | 圆角矩形 | 对象在事件到来之前停留的状态 |
| 初始伪状态 | 实心小圆点 | 状态机开始的地方 |
| 终止状态 | 圆环内的实心圆点 | 状态机已经结束 |
| 转换 | 标注为 event [guard] / action 的箭头 |
事件发生且守卫条件为真时发生转换 |
| 选择 | 菱形 | 由各条出边上的守卫条件决定目标 |
| 组合状态 | 包含其他状态的状态 | 内部所有状态共享的规则 |
| 内部行为 | entry /、exit /、do / |
在进入、退出或停留期间执行 |
转换标签的每一部分都是可选的。invoice paid 单独就是一个事件,[setting: unpaid] 单独就是一个守卫条件,cancel / revoke access 则是带动作的事件。语法和符号均来自 UML 2.5.1 规范。
订阅状态
示例使用 Stripe 订阅概览中的状态,并假设采用自动扣卡付款:
| 状态 | 含义 | 访问权限 |
|---|---|---|
incomplete |
首次付款尚未完成;客户有 23 小时时间 | 无 |
incomplete_expired |
首次付款始终没有成功;不会产生任何费用 | 无 |
trialing |
处于试用期 | 有 |
active |
状态良好 | 有 |
past_due |
最新账单付款失败或尚未尝试 | 由你决定 |
unpaid |
重试已用尽,且你的设置选择了这个结果 | 无 |
paused |
试用结束时没有付款方式,并选择了暂停 | 无 |
canceled |
已结束;Stripe 将其描述为终止状态 | 无 |
很多产品在 past_due 期间、付款重试仍在进行时保留访问权限,本例也是如此。把这个决定写进图里;它恰恰是最容易被遗忘的那个。
构建转换
逐个查看每个状态,问一问在这个状态下可能发生哪些事件。
- 开始。
subscribe [trial]进入trialing;subscribe [no trial]进入incomplete。 - 首次付款。 在
incomplete状态下,invoice paid进入active。如果 23 小时内没有付款,订阅会变为incomplete_expired,Stripe 会作废这张账单。这个状态是终态:要重新尝试,客户需要发起一个新订阅。 - 试用结束。
trial ends进入一个选择节点。[payment succeeds]进入active;在本例中,[no payment method]进入paused。Stripe 允许你选择这种情况下的处理方式:创建账单(默认)、取消或暂停。只画你实际使用的那种设置,不要三种都画。 - 续费。 在
active状态下,renewal payment fails进入past_due,invoice paid再让它回来。 - 重试用尽。 又一个选择节点:根据你的设置,订阅会进入
unpaid或canceled,或者停留在past_due。在unpaid状态下,支付账单会让它回到active。 - 退出。
cancel让active进入canceled。周期结束时取消并不是一个单独的状态:订阅会带着一个标志保持active,直到周期结束,然后进入canceled。
用组合状态表示访问权限
这里有三个状态会授予访问权限:trialing、active 和 past_due。把它们画在同一个组合状态 Access granted 中,规则只需要写一次:entry / grant access 和 exit / revoke access。任何离开组合状态的转换都会撤销访问权限,无论它从哪个状态出发,所以没有哪条路径会漏掉这一步。在组合状态内部的状态之间移动,比如从 trialing 到 active,并不会离开组合状态,也不会执行这些动作。
图表内容
- Access granted
- trialing
- active
- past_due
- paused
- incomplete
- incomplete_expired
- unpaid
- canceled
- subscribe [trial]
- subscribe [no trial]
- invoice paid
- after 23 h / void invoice
- trial ends
- [payment succeeds]
- [no payment method]
- resume [payment succeeds]
- renewal payment fails
- retries exhausted
- [setting: unpaid]
- [setting: cancel]
- cancel
这张图的读法如下:
- 顶部。 起点按是否试用分开:组合状态内的
trialing,或者组合状态外的incomplete。在incomplete状态下,付款进入active,23 小时没有付款则进入incomplete_expired,再到达右侧的终止状态。 - 中部,Access granted。
trial ends到达一个选择节点:付款成功时进入active,没有付款方式时进入左侧的paused。续费失败让active进入past_due,付款后再回到原状态。 - 底部。 重试用尽时,第二个选择节点把订阅送往
unpaid或canceled。unpaid仍可通过付款回到active;canceled则在终止状态结束。 - 进入框内的连线授予访问权限,离开框的连线撤销访问权限。
从图到代码
这张图对应一张转换表。表里没有的转换一律拒绝:
type Status = 'incomplete' | 'incomplete_expired' | 'trialing' | 'active'
| 'past_due' | 'unpaid' | 'paused' | 'canceled';
const transitions: Record<Status, Partial<Record<string, Status>>> = {
incomplete: { invoice_paid: 'active', expired: 'incomplete_expired' },
trialing: { trial_ended_paid: 'active', trial_ended_no_method: 'paused' },
active: { payment_failed: 'past_due', cancel: 'canceled' },
past_due: { invoice_paid: 'active', retries_exhausted: 'unpaid' },
unpaid: { invoice_paid: 'active' },
paused: { resumed: 'active' },
incomplete_expired: {},
canceled: {},
};
function next(status: Status, event: string): Status {
const target = transitions[status][event];
if (!target) throw new Error(`${event} is not allowed in ${status}`);
return target;
}
如果事件来自支付服务商的 webhook,不要假设它们按顺序到达。Stripe 的 webhook 文档明确说明,它不保证事件按生成顺序送达。把每个事件都当作一次重新获取订阅当前状态的信号,然后在这个状态上应用转换。
常见错误
- 遗漏事件。 对每个状态,逐一检查每个事件。“客户在
past_due状态下取消会怎样?”就是图应该回答的问题。 - 把标志画成状态。 周期结束时取消、折扣或席位数量都是状态的属性,而不是状态。
- 把动作画成状态。 “发送催款邮件”是转换上的动作或
entry行为,而不是订阅停留的地方。 - 没有终止状态。 标出生命周期在哪里结束,免得有人写出让已取消订阅“复活”的代码。
- 守卫条件重叠或有空缺。 从同一个选择节点出发的守卫条件必须恰好覆盖每种情况一次;必要时加上
[else]。 - 一张图里有两个生命周期。 订阅和它的账单各有一台状态机。用事件把它们连起来,而不是合并成一张图。
把图放在实现它的代码旁边,并在同一个拉取请求中一起评审,正如不会过时的架构图一文所述。
在 Archboard 中构建
- 使用 Google 或 GitHub 登录,并新建一张图。
- 为每个订阅状态添加一个 UML 节点包中的 状态。
- 在
trialing、active和past_due外面添加一个基础节点包中的 框架,命名为 Access granted。 - 从基础节点包中添加一个小的实心 椭圆 表示初始状态,为每个选择节点添加一个 菱形,为每个终止状态添加一个圆中带点的 图标。
- 用标注为
event [guard] / action的连线连接各个状态。 - 添加一个 UML 注释,写明组合状态的进入和退出动作。
这个示例共用 14 个图表节点,在 Free 套餐的限制之内。借助通过 MCP 连接的 AI 智能体,你可以粘贴状态枚举,让它先起草各个状态,再由你逐条检查转换。
常见问题
状态图和流程图有什么区别? 流程图按顺序展示一个过程的步骤。状态图展示一个对象可能处于的状态,以及哪些事件让它在这些状态之间移动;因此它回答的是“允许什么”,而不是“接下来发生什么”。
状态列的每个取值都应该是一个状态吗? 凡是会改变行为的取值都应该是。如果两个取值在任何情况下行为都相同,就把它们合并;如果一个取值掩盖了两种行为,就把它拆开。
webhook 在状态机里处于什么位置? 它们是事件。先校验 webhook,再从服务商获取当前状态,然后应用转换,因为事件可能乱序到达,也可能重复到达。
我需要组合状态吗? 当多个状态共享一条规则(比如访问权限或计费)时就用它。它能把一条本来要在很多箭头上重复的规则,变成一个进入动作和一个退出动作。
转换标签要写得多详细? 用领域术语命名事件,只有当同一个事件引出两条箭头时才加守卫条件。对正确性有影响的动作,比如授予访问权限,要画到图上。