3. 支付订单模型与状态机实现
订单模型:把“钱的状态”建模成不可变事实
支付订单不是一张普通业务表,它记录的是资金流转的当前快照。核心字段应围绕“谁、向谁、多少钱、什么状态、渠道单号、时间线”来设计。下面给出一个可直接落地的订单实体与状态枚举定义(TypeScript 示例,字段命名与后续签名、回调章节保持一致)。
// order.model.ts
export enum OrderStatus {
PENDING = 'PENDING', // 待支付:订单已创建,未发起渠道请求
PAYING = 'PAYING', // 支付中:已向渠道发起请求,等待结果
SUCCESS = 'SUCCESS', // 成功:渠道确认收款
FAILED = 'FAILED', // 失败:渠道明确拒绝或超时关闭
REFUNDED = 'REFUNDED', // 已退款:成功订单完成退款
}
export interface PaymentOrder {
orderId: string; // 商户订单号,全局唯一,参与签名
merchantId: string; // 商户号
amount: number; // 金额,单位:分(整数,避免浮点误差)
currency: string; // 币种,如 CNY
subject: string; // 商品标题
channel: string; // 渠道标识,如 ALIPAY / WECHAT
channelTradeNo?: string; // 渠道侧交易号,回调后回填
status: OrderStatus; // 当前状态
createdAt: number; // 创建时间戳(ms)
paidAt?: number; // 支付成功时间戳
refundedAt?: number; // 退款完成时间戳
failReason?: string; // 失败原因,便于排障
}
金额统一用“分”存储为整数,是支付系统的硬约束。任何以元为单位的浮点数在签名、对账、退款计算中都会引入精度风险。
状态机:用合法迁移表约束每一次状态变更
状态机的价值在于:任何一次状态写入都必须先通过迁移校验,非法迁移直接拒绝。这样能防止“已退款订单被再次置为成功”这类脏数据。合法迁移路径如下:
- PENDING → PAYING:用户发起支付,向渠道下单成功。
- PENDING → FAILED:下单前校验失败(如金额非法、渠道不可用)。
- PAYING → SUCCESS:收到渠道成功回调或主动查询确认成功。
- PAYING → FAILED:渠道返回失败,或超时未支付被关闭。
- SUCCESS → REFUNDED:对成功订单发起退款并确认完成。
其余迁移(如 FAILED → SUCCESS、REFUNDED → SUCCESS、SUCCESS → PAYING)一律非法。下面用一张状态迁移图把合法路径与终态直观呈现出来。
迁移校验与状态机实现
把合法迁移写成一张表,校验函数只做查表判断,逻辑集中、易测试、易扩展。下面实现状态机核心,并给出订单创建与状态推进的示例。
// order.state-machine.ts
import { OrderStatus, PaymentOrder } from './order.model';
// 合法迁移表:key 为当前状态,value 为允许迁移到的状态集合
const TRANSITIONS: Record<OrderStatus, OrderStatus[]> = {
[OrderStatus.PENDING]: [OrderStatus.PAYING, OrderStatus.FAILED],
[OrderStatus.PAYING]: [OrderStatus.SUCCESS, OrderStatus.FAILED],
[OrderStatus.SUCCESS]: [OrderStatus.REFUNDED],
[OrderStatus.FAILED]: [], // 终态
[OrderStatus.REFUNDED]: [], // 终态
};
export function canTransit(from: OrderStatus, to: OrderStatus): boolean {
return TRANSITIONS[from]?.includes(to) ?? false;
}
export function transit(order: PaymentOrder, to: OrderStatus, patch: Partial<PaymentOrder> = {}): PaymentOrder {
if (!canTransit(order.status, to)) {
throw new Error(`非法状态迁移: ${order.status} -> ${to}, orderId=${order.orderId}`);
}
const next: PaymentOrder = { ...order, ...patch, status: to };
if (to === OrderStatus.SUCCESS) next.paidAt = Date.now();
if (to === OrderStatus.REFUNDED) next.refundedAt = Date.now();
return next;
}
创建订单时状态固定为 PENDING;发起支付时迁移到 PAYING;收到渠道回调后依据结果迁移到 SUCCESS 或 FAILED。下面演示完整链路,并验证非法迁移会被拒绝。
// demo.ts
import { OrderStatus, PaymentOrder } from './order.model';
import { transit } from './order.state-machine';
function createOrder(orderId: string, amount: number): PaymentOrder {
return {
orderId,
merchantId: 'M10086',
amount, // 单位:分
currency: 'CNY',
subject: '测试商品',
channel: 'ALIPAY',
status: OrderStatus.PENDING,
createdAt: Date.now(),
};
}
// 1. 创建订单
let order = createOrder('ORD20240101001', 1990); // 19.90 元
console.log(order.status); // PENDING
// 2. 发起支付
order = transit(order, OrderStatus.PAYING);
console.log(order.status); // PAYING
// 3. 渠道回调成功,回填渠道单号
order = transit(order, OrderStatus.SUCCESS, { channelTradeNo: 'ALI_88990011' });
console.log(order.status, order.paidAt); // SUCCESS, 时间戳
// 4. 发起退款
order = transit(order, OrderStatus.REFUNDED);
console.log(order.status); // REFUNDED
// 5. 非法迁移验证:已退款订单不能再置为成功
try {
transit(order, OrderStatus.SUCCESS);
} catch (e) {
console.log((e as Error).message); // 非法状态迁移: REFUNDED -> SUCCESS, orderId=ORD20240101001
}
状态推进必须由服务端统一执行,客户端上报的状态只能作为参考。回调接口要做幂等处理:同一笔订单重复收到 SUCCESS 回调时,若当前已是 SUCCESS,应直接返回成功而不重复写入 paidAt。
关键校验点与验证方法
| 校验点 | 输入/条件 | 预期结果 |
|---|---|---|
| 创建订单初始状态 | createOrder 返回对象 | status === PENDING |
| 合法迁移 PENDING→PAYING | transit(order, PAYING) | 返回新对象,status === PAYING |
| 合法迁移 PAYING→SUCCESS | transit(order, SUCCESS, {channelTradeNo}) | status === SUCCESS,paidAt 被写入 |
| 合法迁移 SUCCESS→REFUNDED | transit(order, REFUNDED) | status === REFUNDED,refundedAt 被写入 |
| 非法迁移 REFUNDED→SUCCESS | transit(order, SUCCESS) | 抛出异常,原对象状态不变 |
| 非法迁移 FAILED→PAYING | transit(failedOrder, PAYING) | 抛出异常 |
| 终态不可再迁移 | FAILED / REFUNDED 的迁移表 | 允许集合为空 |
状态机的最小可验证闭环:创建订单得到 PENDING → 推进到 PAYING → 推进到 SUCCESS 并回填渠道单号 → 推进到 REFUNDED;随后对终态发起任意迁移都应抛异常。跑通这条链路,订单模型与状态机即达到可用状态。