4. 支付订单模型与状态机

支付订单模型与状态机 学习卡片

订单模型:把“支付结果”从布尔值升级为可审计状态

支付订单不能只用 isPaid: Boolean 表达。真实业务里,一笔订单会经历“待支付 → 支付中 → 成功/失败 → 已退款”等阶段,且每个阶段都有不同的可执行动作与数据字段。下面先定义订单数据模型,再用状态机约束迁移。

数据模型定义

订单模型需要同时承载“业务标识、渠道信息、金额、状态、时间戳、失败原因”六类信息。以下 Kotlin 数据类可直接用于 Android 端本地缓存与服务端同步:

enum class PayStatus {
    PENDING,    // 待支付:订单已创建,未发起渠道调用
    PAYING,     // 支付中:已调起渠道,等待结果回调
    SUCCESS,    // 成功:渠道确认收款
    FAILED,     // 失败:渠道返回失败或超时关闭
    REFUNDED    // 已退款:成功后退款完成
}

data class PayOrder(
    val orderId: String,          // 业务订单号,全局唯一
    val channel: String,          // "WECHAT" / "ALIPAY"
    val amountFen: Long,          // 金额,单位:分,避免浮点误差
    val status: PayStatus = PayStatus.PENDING,
    val prepayId: String? = null, // 渠道预支付标识
    val tradeNo: String? = null,  // 渠道交易号,成功后写入
    val failReason: String? = null,
    val createdAt: Long,          // 创建时间戳(ms)
    val updatedAt: Long           // 状态最后变更时间戳(ms)
)
金额统一用“分”存储为 Long,可彻底规避 0.1 + 0.2 类浮点误差;tradeNo 只在 SUCCESS 后才有值,是后续退款与对账的关键字段。

状态机:合法迁移与非法迁移

状态机的核心是“白名单迁移”:只有列出的 (from, to) 组合才被允许,其余一律拒绝并抛出异常。这样可以从代码层面阻止“待支付直接跳到已退款”这类脏数据。

PENDING 待支付 PAYING 支付中 SUCCESS 成功 FAILED 失败 REFUNDED 已退款 发起支付 渠道回调成功 渠道回调失败/超时 发起退款 ✗ 非法:未支付不可退款 ✗ 非法:失败订单不可直接转成功 终态

状态机实现:白名单 + 守卫

用一张迁移表描述合法路径,配合 transition() 函数做校验。任何不在表中的迁移都会抛出 IllegalStateException,便于在开发期快速暴露问题。

object PayStateMachine {

    // 合法迁移白名单:from -> 允许的 to 集合
    private val allowed: Map<PayStatus, Set<PayStatus>> = mapOf(
        PayStatus.PENDING  to setOf(PayStatus.PAYING),
        PayStatus.PAYING   to setOf(PayStatus.SUCCESS, PayStatus.FAILED),
        PayStatus.SUCCESS  to setOf(PayStatus.REFUNDED),
        PayStatus.FAILED   to emptySet(),
        PayStatus.REFUNDED to emptySet()
    )

    fun canTransit(from: PayStatus, to: PayStatus): Boolean =
        allowed[from]?.contains(to) == true

    fun transition(order: PayOrder, to: PayStatus, now: Long = System.currentTimeMillis()): PayOrder {
        require(canTransit(order.status, to)) {
            "非法状态迁移: ${order.status} -> $to (orderId=${order.orderId})"
        }
        return order.copy(status = to, updatedAt = now)
    }
}

在支付流程中驱动状态

把状态机嵌入实际支付动作,形成“创建订单 → 发起支付 → 回调处理 → 退款”的闭环。下面演示关键调用点:

// 1. 创建订单:初始为 PENDING
var order = PayOrder(
    orderId = "ORD20240521001",
    channel = "WECHAT",
    amountFen = 1990,
    createdAt = System.currentTimeMillis(),
    updatedAt = System.currentTimeMillis()
)

// 2. 调起渠道前:PENDING -> PAYING
order = PayStateMachine.transition(order, PayStatus.PAYING)

// 3. 渠道回调成功:PAYING -> SUCCESS,并写入 tradeNo
order = PayStateMachine.transition(order, PayStatus.SUCCESS)
    .copy(tradeNo = "4200001234202405210001")

// 4. 用户申请退款:SUCCESS -> REFUNDED
order = PayStateMachine.transition(order, PayStatus.REFUNDED)

// 5. 非法尝试会被拦截
// PayStateMachine.transition(order, PayStatus.PAYING)
// -> IllegalStateException: 非法状态迁移: REFUNDED -> PAYING
回调可能重复到达。处理回调时应先判断 order.status:若已是 SUCCESS 或 REFUNDED,直接忽略重复通知,避免状态被反复覆盖。

验证要点

  • 合法路径全覆盖:PENDING→PAYING→SUCCESS→REFUNDED 与 PENDING→PAYING→FAILED 两条主链均可走通。
  • 非法路径被拦截:PENDING→REFUNDED、FAILED→SUCCESS、REFUNDED→PAYING 均抛出异常。
  • 终态不可逆:FAILED 与 REFUNDED 的出边集合为空,任何迁移尝试都应失败。
  • 时间戳更新:每次成功迁移后 updatedAt 必须大于 createdAt,便于审计与对账。