状态图示例:用状态机描述订阅的生命周期

以 SaaS 订阅为例的 UML 状态机图:状态、事件、守卫条件、选择节点、组合状态,以及状态图能提前发现的 bug。

· 创始人兼开发者

发布于

状态图示例:用状态机描述订阅的生命周期

订阅 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 期间、付款重试仍在进行时保留访问权限,本例也是如此。把这个决定写进图里;它恰恰是最容易被遗忘的那个。

构建转换

逐个查看每个状态,问一问在这个状态下可能发生哪些事件。

  1. 开始。 subscribe [trial] 进入 trialing;subscribe [no trial] 进入 incomplete。
  2. 首次付款。 在 incomplete 状态下,invoice paid 进入 active。如果 23 小时内没有付款,订阅会变为 incomplete_expired,Stripe 会作废这张账单。这个状态是终态:要重新尝试,客户需要发起一个新订阅。
  3. 试用结束。 trial ends 进入一个选择节点。[payment succeeds] 进入 active;在本例中,[no payment method] 进入 paused。Stripe 允许你选择这种情况下的处理方式:创建账单(默认)、取消或暂停。只画你实际使用的那种设置,不要三种都画。
  4. 续费。 在 active 状态下,renewal payment fails 进入 past_due,invoice paid 再让它回来。
  5. 重试用尽。 又一个选择节点:根据你的设置,订阅会进入 unpaid 或 canceled,或者停留在 past_due。在 unpaid 状态下,支付账单会让它回到 active。
  6. 退出。 cancel 让 active 进入 canceled。周期结束时取消并不是一个单独的状态:订阅会带着一个标志保持 active,直到周期结束,然后进入 canceled。

用组合状态表示访问权限

这里有三个状态会授予访问权限:trialing、active 和 past_due。把它们画在同一个组合状态 Access granted 中,规则只需要写一次:entry / grant access 和 exit / revoke access。任何离开组合状态的转换都会撤销访问权限,无论它从哪个状态出发,所以没有哪条路径会漏掉这一步。在组合状态内部的状态之间移动,比如从 trialing 到 active,并不会离开组合状态,也不会执行这些动作。

起点通向 trialing 或 incomplete。trialing、active 和 past due 位于 Access granted 框内。试用结束经过一个选择节点,进入 active 或 paused。续费失败使 active 变为 past due;重试用尽后经过选择节点进入 unpaid 或 canceled,最后到达终止状态。

15 个组件 · 17 个连接

在 Archboard 中打开
图表内容
  • 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
使用 Stripe Billing 文档中订阅状态的 SaaS 订阅状态机:试用与首次付款、一个涵盖所有可访问状态的组合状态、续费失败,以及订阅结束的两种方式。

这张图的读法如下:

  • 顶部。 起点按是否试用分开:组合状态内的 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 中构建

  1. 使用 Google 或 GitHub 登录,并新建一张图。
  2. 为每个订阅状态添加一个 UML 节点包中的 状态。
  3. 在 trialing、active 和 past_due 外面添加一个基础节点包中的 框架,命名为 Access granted。
  4. 从基础节点包中添加一个小的实心 椭圆 表示初始状态,为每个选择节点添加一个 菱形,为每个终止状态添加一个圆中带点的 图标。
  5. 用标注为 event [guard] / action 的连线连接各个状态。
  6. 添加一个 UML 注释,写明组合状态的进入和退出动作。

这个示例共用 14 个图表节点,在 Free 套餐的限制之内。借助通过 MCP 连接的 AI 智能体,你可以粘贴状态枚举,让它先起草各个状态,再由你逐条检查转换。

常见问题

状态图和流程图有什么区别? 流程图按顺序展示一个过程的步骤。状态图展示一个对象可能处于的状态,以及哪些事件让它在这些状态之间移动;因此它回答的是“允许什么”,而不是“接下来发生什么”。

状态列的每个取值都应该是一个状态吗? 凡是会改变行为的取值都应该是。如果两个取值在任何情况下行为都相同,就把它们合并;如果一个取值掩盖了两种行为,就把它拆开。

webhook 在状态机里处于什么位置? 它们是事件。先校验 webhook,再从服务商获取当前状态,然后应用转换,因为事件可能乱序到达,也可能重复到达。

我需要组合状态吗? 当多个状态共享一条规则(比如访问权限或计费)时就用它。它能把一条本来要在很多箭头上重复的规则,变成一个进入动作和一个退出动作。

转换标签要写得多详细? 用领域术语命名事件,只有当同一个事件引出两条箭头时才加守卫条件。对正确性有影响的动作,比如授予访问权限,要画到图上。

免费开始 — 1 个席位,无需信用卡。

相关文章

← 返回博客

一分钟打开画布

使用 Google 或 GitHub 登录。单席位免费,团队加入后再升级。