状态机是业务代码中最容易失控的部分。本文记录如何用 Cursor 的 Agent 模式,将一个充斥 if-else 的订单状态机安全重构为有限状态机模式,包含状态图生成、迁移验证和测试覆盖的完整流程。

起因:一段没人敢改的代码
我们项目里有一个订单模块,核心文件 order-service.ts 大概 800 行。它负责订单从创建到完成/取消的整个生命周期,包含支付回调、退款、部分退款、超时取消等场景。
上周产品提了一个需求:超过 30 分钟未支付的订单自动取消后,如果用户重新下单,同样的商品可以享受一次"超时补偿优惠券"。这个需求涉及订单取消逻辑的改动。
我打开 order-service.ts,看了 20 分钟,关掉了。
不是因为看不懂,而是因为不敢改。那个文件里布满了这样的逻辑:
typescript
if (order.status === 'pending' && payment.status === 'paid') {
if (order.items.every(item => item.shipped)) {
order.status = 'completed';
} else if (order.items.some(item => item.shipped)) {
order.status = 'partially_shipped';
} else {
order.status = 'paid';
}
} else if (order.status === 'paid' && refund.amount === order.total) {
order.status = 'refunded';
} else if (order.status === 'paid' && refund.amount < order.total) {
order.status = 'partially_refunded';
} else if (order.status === 'pending' && Date.now() - order.createdAt > 30 * 60 * 1000) {
order.status = 'cancelled';
}
// ... 还有大约 700 行类似的逻辑
状态转换规则散落在至少 6 个函数里,有些在 service 层,有些在 controller 层,有些甚至在前端的工具函数里。没有一个地方能说清楚"订单从生到死到底有几种状态,什么条件触发什么迁移"。
如果你也维护过超过一年的业务系统,你应该知道这种感觉------不是代码写得不好,而是需求一个一个叠上来,状态机的结构被增量式破坏,最后成了一团谁都理不清的乱麻。
我决定在改需求之前,先把这个状态机重构了。
第一步:先让 AI 梳理出完整的状态图
重构状态机最怕的是遗漏隐式状态迁移。有些迁移是文档里写的,有些是 bug fix 时偷偷加的,有些是运营工具里硬编码的。
我把整个 order-service.ts 和相关的 controller、utils 文件全部在 Cursor 中打开。Cursor 的代码库索引功能让它理解了这些文件之间的调用关系。然后我给出第一个指令:
markdown
分析 order-service.ts 及其所有引用文件,提取订单状态机的所有信息。
要求:
1. 列出所有可能的订单状态(在代码中出现过的枚举值或字符串字面量)
2. 列出所有状态迁移,格式为:
当前状态 → 目标状态 : 触发条件 (代码依据: 文件名 + 行号)
3. 标注出可能存在冲突的迁移(即同一个条件下指向不同的目标状态)
4. 标注出代码中硬编码的、但缺失目标状态定义的字符串
输出为 Markdown 文档,保存为 /docs/order-state-machine.md
Cursor 索引了整个项目后,输出了一份详细的文档。我截取部分:
markdown
## 订单状态汇总
- pending
- paid
- processing
- shipped
- partially_shipped
- completed
- cancelled
- refunded
- partially_refunded
- expired (只在退款回调中出现,未在前端枚举中定义------疑似废弃状态)
## 状态迁移(部分)
| 当前状态 | 目标状态 | 触发条件 | 代码依据 |
|---------|---------|---------|---------|
| pending | paid | 支付回调成功 | order-service.ts:142 |
| pending | cancelled | 超时30分钟 / 用户手动取消 | order-service.ts:88, cancel-handler.ts:33 |
| paid | processing | 仓库开始拣货 | fulfillment-service.ts:56 |
| paid | refunded | 全额退款申请通过 | refund-service.ts:201 |
| paid | partially_refunded | 部分退款申请通过 | refund-service.ts:218 |
| processing | shipped | 全部商品出库 | fulfillment-service.ts:89 |
| processing | partially_shipped | 部分商品出库 | fulfillment-service.ts:95 |
| shipped | completed | 用户确认收货 | order-service.ts:245 |
| partially_shipped | completed | 所有剩余商品出库 + 收货 | order-service.ts:267 |
## 潜在冲突
- paid → refunded 和 paid → processing 的并发冲突:
退款和发货可能同时触发,代码中仅有乐观锁,未做状态冲突处理 (refund-service.ts:201)
这一步的价值是巨大的。 它把散落在 6 个文件、800 行代码里的隐式状态机,变成了一张可视化的状态图。我不需要靠人脑去 grep 所有状态赋值语句,AI 帮我做了这件事。
第二步:将状态图转换为有限状态机代码
拿到完整的状态图后,我开始重构。我的目标是:用有限状态机库(xstate)替代手写的 if-else 逻辑。
用 xstate 的理由是:
- TypeScript 原生支持,有类型推导。
- 可视化工具可以直接生成状态图,方便团队理解和审查。
- 内置并发状态处理(支付和发货是两个正交的子状态)。
我把第一步生成的状态图文档贴在 Cursor 中,然后给出第二个指令:
markdown
根据 /docs/order-state-machine.md 中提取的状态和迁移,生成 xstate v5 的状态机定义。
要求:
1. 使用 TypeScript
2. 为每个状态定义 context(订单数据)
3. 所有迁移条件显式声明 guard 函数
4. 处理 paid 状态下退款和发货的并发问题(用并行状态)
5. 处理"部分退款 + 部分发货"的复合场景
6. 输出为单文件 src/machines/order-machine.ts
Cursor 的 Agent 模式(Claude Sonnet 4 模型)分析了状态图,生成了完整的状态机代码。下面是核心部分:
typescript
// src/machines/order-machine.ts
import { setup, assign } from 'xstate';
// 事件类型
type OrderEvent =
| { type: 'PAY'; paymentId: string }
| { type: 'CANCEL'; reason: string }
| { type: 'START_FULFILLMENT' }
| { type: 'ITEM_SHIPPED'; itemId: string }
| { type: 'ALL_ITEMS_SHIPPED' }
| { type: 'CONFIRM_DELIVERY' }
| { type: 'REFUND_REQUEST'; refundId: string; amount: number }
| { type: 'REFUND_APPROVED'; refundId: string }
| { type: 'FULL_REFUND_COMPLETE' };
// 上下文
interface OrderContext {
orderId: string;
totalAmount: number;
refundedAmount: number;
items: Array<{ id: string; shipped: boolean }>;
createdAt: number;
}
const orderMachine = setup({
types: {
context: {} as OrderContext,
events: {} as OrderEvent,
},
guards: {
isFullRefund: ({ context, event }) => {
if (event.type !== 'REFUND_REQUEST') return false;
return event.amount === context.totalAmount;
},
isPartialRefund: ({ context, event }) => {
if (event.type !== 'REFUND_REQUEST') return false;
return event.amount < context.totalAmount;
},
allItemsShipped: ({ context }) => {
return context.items.every(item => item.shipped);
},
isExpired: ({ context }) => {
return Date.now() - context.createdAt > 30 * 60 * 1000;
},
},
}).createMachine({
id: 'order',
initial: 'pending',
context: {
orderId: '',
totalAmount: 0,
refundedAmount: 0,
items: [],
createdAt: Date.now(),
},
states: {
pending: {
on: {
PAY: { target: 'paid' },
CANCEL: { target: 'cancelled' },
},
// 30分钟超时自动取消
after: {
1800000: {
target: 'cancelled',
guard: 'isExpired',
},
},
},
paid: {
// 用并行状态处理"支付状态"和"履约状态"两个独立维度
type: 'parallel',
states: {
paymentStatus: {
initial: 'settled',
states: {
settled: {
on: {
REFUND_REQUEST: [
{ target: 'fully_refunded', guard: 'isFullRefund' },
{ target: 'partially_refunded', guard: 'isPartialRefund' },
],
},
},
partially_refunded: {
on: {
REFUND_REQUEST: [
{ target: 'fully_refunded', guard: 'isFullRefund' },
{ target: 'partially_refunded', guard: 'isPartialRefund', reenter: true },
],
},
},
fully_refunded: {
type: 'final',
},
},
},
fulfillmentStatus: {
initial: 'unfulfilled',
states: {
unfulfilled: {
on: {
START_FULFILLMENT: 'processing',
},
},
processing: {
on: {
ITEM_SHIPPED: [
{ target: 'shipped', guard: 'allItemsShipped' },
{ target: 'partially_shipped' },
],
},
},
partially_shipped: {
on: {
ITEM_SHIPPED: [
{ target: 'shipped', guard: 'allItemsShipped' },
{ target: 'partially_shipped', reenter: true },
],
},
},
shipped: {
on: {
CONFIRM_DELIVERY: 'completed',
},
},
completed: {
type: 'final',
},
},
},
},
onDone: {
target: 'completed',
},
},
completed: {
type: 'final',
},
cancelled: {
type: 'final',
},
},
});
export { orderMachine, type OrderContext, type OrderEvent };
关键设计点:
-
并行状态 :
paid状态内部拆成paymentStatus和fulfillmentStatus两个正交子状态。退款和发货互不阻塞,各自独立演进。这解决了原先代码中"退款和发货可能并发冲突"的问题。 -
Guard 函数显式化:原先散落在 if-else 里的条件判断("是否全额退款"、"是否全部发货"),现在每个都有名字和定义,写单元测试时可以直接覆盖。
-
自转移(reenter) :
partially_refunded和partially_shipped状态下,再次触发同类事件时 reenter 自身。这正确处理了"先退 10 块、再退 20 块"这种多次部分退款场景。 -
超时自动迁移 :
pending状态下 30 分钟自动取消,用 xstate 的after声明式处理,不需要手动写 setTimeout。
第三步:替换原有的业务逻辑代码
状态机定义好了,接下来要把它接入现有的 service 层。原先 order-service.ts 里充斥着对 order.status 的直接赋值,现在全部改为通过状态机迁移。
我让 Cursor 帮我改造 order-service.ts:
scss
用 src/machines/order-machine.ts 中定义的状态机,重写 order-service.ts。
要求:
1. 删除所有直接对 order.status 赋值的代码
2. 每次状态变更改为调用 orderMachine.transition()
3. transition 之前检查迁移是否合法(状态机自身会校验)
4. 保留所有日志、通知、数据库更新等副作用
5. 如果某个事件在当前状态下不合法,抛出明确的错误
Cursor 的 Agent 模式重写了整个文件。核心变更:
改造前(典型的老代码):
typescript
async function handlePaymentCallback(orderId: string, paymentResult: PaymentResult) {
const order = await orderRepo.findById(orderId);
if (order.status !== 'pending') {
throw new Error('订单状态异常');
}
if (paymentResult.success) {
order.status = 'paid';
order.paymentId = paymentResult.paymentId;
await orderRepo.save(order);
await notificationService.sendPaymentSuccess(order.userId);
}
}
改造后:
typescript
import { interpret } from 'xstate';
import { orderMachine } from '../machines/order-machine';
async function handlePaymentCallback(orderId: string, paymentResult: PaymentResult) {
const order = await orderRepo.findById(orderId);
// 从持久化状态恢复状态机快照
const snapshot = restoreSnapshot(order);
const actor = interpret(orderMachine).start(snapshot);
if (paymentResult.success) {
try {
// 合法的状态迁移:pending → paid
actor.send({ type: 'PAY', paymentId: paymentResult.paymentId });
// 持久化新状态
await orderRepo.save({
...order,
status: actor.getSnapshot().value, // 'paid'
machineState: JSON.stringify(actor.getPersistedSnapshot()),
paymentId: paymentResult.paymentId,
});
await notificationService.sendPaymentSuccess(order.userId);
} catch (e) {
// 如果当前状态不允许 PAY 事件,xstate 会抛出错误
logger.error('支付回调状态迁移失败', { orderId, currentState: actor.getSnapshot().value, error: e });
throw new OrderStateError(`订单 ${orderId} 当前状态不允许支付操作`);
}
}
}
关键变化:
- 不再手动检查
order.status !== 'pending',状态机自己会校验。 - 状态迁移和副作用(通知、日志)解耦了。迁移失败直接抛错,不会出现"状态改了但通知没发"的半成品情况。
- 整个状态机快照被持久化到数据库(
machineState字段),下次加载时直接恢复,状态机的历史和上下文都不会丢失。
第四步:让 AI 自动生成状态迁移测试
状态机的最大好处之一是可以自动生成所有合法和非法状态迁移的测试用例。我让 Cursor 做这件事:
css
为 src/machines/order-machine.ts 生成完整的测试文件。
测试覆盖:
1. 所有合法的状态迁移(happy path)
2. 所有非法的事件触发(如 pending 状态下不能 CONFIRM_DELIVERY)
3. 并行状态的各种组合(如 refund + ship 的交错)
4. 超时自动取消
使用 Vitest + @xstate/test
Cursor 生成了一个 280 行的测试文件。我摘取核心部分:
typescript
// src/machines/__tests__/order-machine.test.ts
import { createTestMachine } from '@xstate/test';
import { describe, it, expect } from 'vitest';
import { orderMachine } from '../order-machine';
describe('Order State Machine', () => {
// 定义所有合法路径
const testMachine = createTestMachine({
test: (testContext) => {
// 每个状态下的断言
},
states: {
pending: {
test: ({ context }) => {
expect(context.status).toBe('pending');
},
meta: { test: ({ eventCases }) => {
// pending 状态只能接收 PAY 和 CANCEL
eventCases.valid('PAY');
eventCases.valid('CANCEL');
eventCases.invalid('CONFIRM_DELIVERY'); // 非法
eventCases.invalid('REFUND_REQUEST'); // 非法
}}
},
// ... 其他状态类似
}
});
// 自动生成并执行所有路径
testMachine.testSimple({
initialState: 'pending',
states: { /* ... */ }
});
});
// 手动测试关键场景
describe('并发退款和发货', () => {
it('退款后仍可继续发货', () => {
const actor = interpret(orderMachine).start();
actor.send({ type: 'PAY', paymentId: '123' });
actor.send({ type: 'REFUND_REQUEST', refundId: 'r1', amount: 10 }); // 部分退款
actor.send({ type: 'START_FULFILLMENT' });
actor.send({ type: 'ITEM_SHIPPED', itemId: 'item-1' });
const snapshot = actor.getSnapshot();
// 此时支付子状态为 partially_refunded,履约子状态为 processing
expect(snapshot.value).toHaveProperty('paid');
});
it('已全额退款的订单不能发货', () => {
const actor = interpret(orderMachine).start();
actor.send({ type: 'PAY', paymentId: '123' });
actor.send({ type: 'REFUND_REQUEST', refundId: 'r1', amount: 100 });
actor.send({ type: 'REFUND_APPROVED', refundId: 'r1' });
actor.send({ type: 'FULL_REFUND_COMPLETE' });
// 全额退款后,整个 paid 状态 done,不能发货
expect(() => actor.send({ type: 'START_FULFILLMENT' })).toThrow();
});
});
@xstate/test 的价值:它可以自动遍历状态机定义的所有可能路径,确保每个状态都定义了正确的合法/非法事件。如果将来有人修改状态机(比如新增一个状态但忘记定义某些事件的行为),测试会自动失败。
重构效果对比
| 指标 | 重构前 | 重构后 |
|---|---|---|
| 核心逻辑行数 | ~800 行 | ~120 行(状态机定义)+ ~60 行(service 适配) |
| 状态定义 | 散落 6 个文件 | 集中在 1 个文件 |
| 状态迁移可见性 | 靠 grep 查找 | 一张可视化状态图 |
| 并发处理 | if-else 中手动加锁 | 并行状态天然隔离 |
| 测试覆盖 | 8 个测试(覆盖主要 happy path) | 37 个测试(覆盖所有路径) |
| 新增状态迁移的改动量 | 改 3-4 个文件 | 改 1 个状态机定义 + 1 个事件处理 |
最重要的是心理层面的变化:我终于敢改这个模块了。 改之前战战兢兢,改之后知道状态机会帮我拦住所有非法迁移,测试会告诉我哪里漏了。
踩过的坑
-
xstate 序列化问题 :
getPersistedSnapshot()返回的 JSON 体积较大(约 2KB),对于高频读写的订单表来说有些浪费。最终我选择只持久化当前状态名称和 context 中的关键字段,恢复时根据状态名直接machine.start(stateName),然后手动填充 context。这样数据库只需要存储一个状态枚举和几个关键字段。 -
旧数据兼容 :重构前的订单数据没有
machineState字段。我写了一个迁移脚本,根据旧订单的status字段推测状态机初始状态。AI 帮我生成了这个脚本,处理了 200 万行历史数据。 -
性能 :xstate 的
interpret和send是同步操作,单次迁移耗时 <1ms,对接口响应时间无影响。但每个请求都interpret(machine).start(snapshot)会浪费 CPU,我在 service 层加了一个轻量的状态校验函数,对于纯查询接口直接返回当前状态,只在需要迁移时才启动解释器。
复用的迁移模板
如果你也想重构一个状态机,可以按这个流程走:
- 让 AI 逆向分析:把所有相关文件在 Cursor 中打开,让 AI 生成完整的状态图和迁移表。
- 人审核状态图:确认 AI 没有遗漏隐式迁移,标注产品需求中明确但不常触发的边界状态。
- 用 AI 生成状态机代码:选择合适的状态机库(xstate / Robot / 自研),交给 AI 生成初版。
- 替换旧代码 :在 service 层用
send()替代直接赋值,保留所有副作用。 - 让 AI 生成全覆盖测试:这是状态机重构的最终安全网。
你项目里有没有那种"谁都不敢动"的模块?你是硬着头皮改的,还是重构了?欢迎评论区聊聊。