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)一律非法。下面用一张状态迁移图把合法路径与终态直观呈现出来。

PENDING 待支付 PAYING 支付中 SUCCESS 成功 FAILED 失败 REFUNDED 已退款 发起支付 下单校验失败 回调/查询成功 失败/超时关闭 发起退款 终态 终态 终态

迁移校验与状态机实现

把合法迁移写成一张表,校验函数只做查表判断,逻辑集中、易测试、易扩展。下面实现状态机核心,并给出订单创建与状态推进的示例。

// 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→PAYINGtransit(order, PAYING)返回新对象,status === PAYING
合法迁移 PAYING→SUCCESStransit(order, SUCCESS, {channelTradeNo})status === SUCCESS,paidAt 被写入
合法迁移 SUCCESS→REFUNDEDtransit(order, REFUNDED)status === REFUNDED,refundedAt 被写入
非法迁移 REFUNDED→SUCCESStransit(order, SUCCESS)抛出异常,原对象状态不变
非法迁移 FAILED→PAYINGtransit(failedOrder, PAYING)抛出异常
终态不可再迁移FAILED / REFUNDED 的迁移表允许集合为空
状态机的最小可验证闭环:创建订单得到 PENDING → 推进到 PAYING → 推进到 SUCCESS 并回填渠道单号 → 推进到 REFUNDED;随后对终态发起任意迁移都应抛异常。跑通这条链路,订单模型与状态机即达到可用状态。