3. 订单模型设计

订单模型是支付链路的核心数据结构,它同时承担三件事:描述一笔交易当前处于什么阶段、约束哪些操作可以发生、以及在本地与服务器之间保持可对账的一致性。如果订单模型设计得随意,最常见的后果是:用户已付款但本地仍显示待支付、重复发起支付、退款状态与服务器不一致、对账时无法判断哪条记录为准。本节围绕状态机、字段定义和一致性策略展开,并给出可直接参考的模型设计。

一、订单状态机:先定义“合法迁移”,再谈字段

订单状态机不是简单枚举几个状态,而是定义“状态 + 事件 → 新状态”的迁移规则。生产环境中建议把状态分为三类:终态(不可再变,如已支付、已关闭、已退款)、中间态(等待外部结果,如支付中、退款中)、可操作态(可继续推进,如待支付)。关键原则是:任何状态迁移必须由明确事件触发,且迁移表是唯一权威,代码中不允许出现“直接把 status 改成某值”的写法。

以一笔普通支付订单为例,典型状态与事件如下:

  • CREATED(已创建):订单在本地生成,尚未请求支付。事件 SUBMIT_PAY → PAYING。
  • PAYING(支付中):已向支付渠道发起请求,等待结果。事件 PAY_SUCCESS → PAID;PAY_FAIL → PAY_FAILED;TIMEOUT → PAYING 保持并进入轮询/查询。
  • PAID(已支付):终态之一,可触发发货或权益发放。事件 REFUND → REFUNDING。
  • PAY_FAILED(支付失败):可重试,事件 SUBMIT_PAY → PAYING;也可 CLOSE → CLOSED。
  • REFUNDING(退款中):事件 REFUND_SUCCESS → REFUNDED;REFUND_FAIL → PAID(回退,需记录失败原因)。
  • REFUNDED(已退款)、CLOSED(已关闭):终态。

下面这张状态迁移图把“状态、事件、迁移方向”放在同一张图里,便于在评审和排障时快速对照。图中实线表示正常迁移,虚线表示回退或重试路径。

订单状态机:状态 + 事件 → 新状态 CREATED 已创建 PAYING 支付中 PAID 已支付(终态) PAY_FAILED 支付失败 REFUNDING 退款中 REFUNDED 已退款(终态) CLOSED 已关闭(终态) SUBMIT_PAY PAY_SUCCESS PAY_FAIL SUBMIT_PAY(重试) CLOSE REFUND REFUND_SUCCESS REFUND_FAIL(回退) 实线:正常迁移 虚线:重试 / 回退路径 终态:PAID、REFUNDED、CLOSED

二、字段定义:区分“业务字段”与“一致性字段”

订单表字段可以分成四组,每组承担不同职责,混在一起会导致后续难以排查问题。

分组字段示例作用与注意点
标识字段order_id、out_trade_no、channel_trade_no本地订单号与渠道订单号必须分开保存,对账时以渠道号为准。
业务字段product_id、amount、currency、user_id金额统一用最小货币单位(分)存整数,避免浮点误差。
状态字段status、version、updated_atversion 用于乐观锁,防止并发覆盖。
一致性字段sync_state、last_sync_at、server_status记录本地与服务器是否一致,是排障的关键依据。

其中 sync_state 建议取值:SYNCED(本地与服务器一致)、PENDING(本地已变更,待同步)、CONFLICT(本地与服务器冲突,需人工或规则裁决)。server_status 保存服务器返回的权威状态,与本地 status 分开存储,便于对比。

三、本地与服务器一致性:三种策略与选择依据

一致性不是“永远相同”,而是“在可接受时间内收敛到相同”。常见策略有三种:

  1. 服务器权威 + 本地缓存:所有状态变更以服务器为准,本地只做展示缓存。适合支付、退款等强一致场景。实现上,本地写入后立即标记 PENDING,收到服务器回调或主动查询结果后更新为 SYNCED。
  2. 本地先行 + 最终一致:本地先落库并展示,后台异步同步。适合非关键路径,如订单备注、物流信息。必须设置重试与超时,避免长期 PENDING。
  3. 双向同步 + 冲突裁决:两端都可能修改,需要版本号或时间戳裁决。支付订单一般不采用,因为金额和状态不允许随意合并。

对支付订单,推荐“服务器权威 + 本地缓存 + 主动查询兜底”。具体做法是:本地发起支付后写入 PAYING 且 sync_state=PENDING;收到支付回调后,用 out_trade_no 查询服务器订单,若服务器为 PAID 则本地更新为 PAID、SYNCED;若超过 30 秒未收到回调,启动轮询查询,间隔 2s、4s、8s 递增,最多 5 次。若查询结果与本地冲突,以服务器为准并记录 CONFLICT 日志。

一致性判断的实用口诀:先看 sync_state,再看 server_status 与本地 status 是否相同,最后看 updated_at 谁更新。三者结合能快速定位是“未同步”还是“真冲突”。

四、可直接参考的订单模型示例

下面给出一个精简但完整的订单模型定义,字段命名与状态取值可直接用于 Android 本地 Room 实体与服务器 DTO 的映射设计。示例参数为教学假设,实际项目需按业务调整。

@Entity(tableName = "orders")
data class OrderEntity(
    @PrimaryKey val orderId: String,          // 本地订单号
    val outTradeNo: String,                   // 提交给渠道的商户订单号
    val channelTradeNo: String?,              // 渠道返回的交易号
    val userId: String,
    val productId: String,
    val amountCents: Long,                    // 金额,单位:分
    val currency: String = "CNY",
    val status: String,                       // CREATED/PAYING/PAID/PAY_FAILED/REFUNDING/REFUNDED/CLOSED
    val serverStatus: String?,                // 服务器权威状态
    val syncState: String = "PENDING",        // SYNCED/PENDING/CONFLICT
    val version: Int = 0,                     // 乐观锁版本
    val lastSyncAt: Long?,                    // 最近一次同步时间戳
    val createdAt: Long,
    val updatedAt: Long
)

状态迁移在代码中应集中到一个 OrderStateMachine 中,输入当前状态与事件,输出新状态或抛出非法迁移异常。这样,任何新增状态或事件都只需改一处,测试也能围绕迁移表编写用例。例如:CREATED + SUBMIT_PAY → PAYING 合法;PAID + SUBMIT_PAY → 非法,必须拒绝,防止重复支付。

常见坑:把 serverStatus 直接覆盖本地 status 而不记录 syncState,会导致冲突时无法追溯;或者用 updatedAt 作为唯一裁决依据,但两端时钟不同步,容易误判。建议以服务器返回的版本号或状态为准,本地时间仅作参考。

总结来说,订单模型设计的核心是:状态机约束“能发生什么”,字段定义区分“业务与一致性职责”,一致性策略决定“以谁为准、多久收敛”。三者配合,才能在支付、退款、对账等环节中保持可解释、可追溯、可恢复。