编程 订单状态机的状态语义与恢复路径:Closed 可恢复,Cancelled 只退款

2026-10-23 00:03:59

订单状态机的状态语义与恢复路径:Closed 可恢复,Cancelled 只退款

把「已关闭」和「已取消」合并成一个终态,问题往往出现在支付成功回调迟到的时候:用户在超时临界点完成支付,网关已经扣款,通知却延迟到达,CloseWorker 已先把本地订单置成终态。终态只有一个时,引擎无法判断该不该补发权益——补发违背用户取消意图,不补发则吞掉一笔已扣款订单,最后只能人工翻日志对账。另一种写法用 is_paid / is_closed / is_cancelled 三个布尔字段拼状态,并发下能拼出 paid=1 且 cancelled=1 的非法组合,之后每个读状态的代码都要各自解释一遍。

orderflow 的做法是把终态拆成语义不同的两种,各配一条独立的恢复路径。项目地址:。它面向单商品虚拟交付订单(数字内容 / 虚拟商品 / 会员权益),封装订单创建、支付超时关闭、支付回调处理、履约交付、状态推送和幂等补偿;核心包零第三方依赖,数据库 / Redis / 支付网关通过 drivers/ 子包注入。

状态枚举与合法迁移

状态值是确定的六个:

状态值语义
Pending0已创建,待支付
Paid10网关支付已确认
Delivered20已交付
Completed30终态
Closed40系统 / 超时关闭,网关确认已付时可恢复
Cancelled50用户主动取消,不恢复、不履约

status.go 的 CanTransitionTo 定义合法迁移:

从可到达
Pending(0)Paid / Closed / Cancelled
Paid(10)Delivered;Closed 方向由 CASReopenPaid 单独处理
Delivered(20)Completed
Completed(30)/ Closed(40)/ Cancelled(50)终态,不再跃迁

OrderStatus.IsTerminal() 对 Completed / Closed / Cancelled 返回 true。注意 Closed 虽然是终态,却保留一条受控的逆向入口;Cancelled 没有。

Create                  CASConfirmPaid
┌───────────────────────┐   ┌───────────────────────┐
│                       ▼   │                       ▼
start ──►  Pending ─────► Paid ─────► Delivered ─────► Completed
│  ▲         │          (FinalizePaid)
│  │         │
│  │ CASReopenPaid(特殊路径:关闭后网关确认已付)
▼  │
Closed        Cancelled
(可特殊恢复)     (不恢复)

支付超时仍走 Pending -> Closed (reason=timeout),没有独立的 Expired 状态。关闭原因用 ClosedReason 枚举区分:timeout / superseded / manual / enqueue_fail。这个枚举是后面区分「系统关单」与「用户取消」的依据。

恢复路径一:Closed 收到 paid 回调

这是生产里最容易踩的竞态,由 engine_notify.go:188 handleClosedPaidNotify 处理。触发条件是 CloseWorker 抢先关单:

CloseWorker: ReserveExpired → Engine.Close → Gateway.CloseOrder
(3 次重试 + IsIgnorableCloseError)
→ CASClose(Pending→Closed) → Cache Set/Publish
→ OnClosed(reason=timeout)

随后回调到达:HandleNotify → ParseNotify 验签 → GetByNo 得到 Closed → handleClosedPaidNotify → QueryOrder(3 次重试)。

  • 网关确认 Paid 且金额匹配:CASReopenPaid(Closed→Paid) → OnReopened(旁路)→ OnPaid(回到标准路径)→ FinalizePaidOrder(Paid→Delivered) → OnDelivered。
  • 网关非 Paid 或金额不一致:OnAnomaly(AnomalyPaidOnClosed / AnomalyAmountMismatch),订单不恢复,转人工对账。
  • 无论异常与否都向网关返回 nil,再 AckNotify,避免重推风暴。

这条路径有四道防线:

  1. 只认网关 QueryOrder 的结果。 notify 里写着 paid 也要回查,对抗伪造通知和网关自身 bug 造成的状态污染。
  2. 金额二次校验。 query.TotalAmount 与订单 PayAmount 不一致直接 OnAnomaly,拒绝恢复。
  3. CASReopenPaid 是独立 CAS 方法,与 CASConfirmPaid 语义分离,driver 可单独打审计日志或报警;affected=0 表示并发已处理完,当次 skip。
  4. OnReopened 先触发、OnPaid 后触发,重入不重复发权益靠的是 OnPaid 自身的幂等约束。

恢复路径二:Cancelled 收到 paid 回调

Cancelled 表示用户主动取消,语义上不可逆。Engine 不会把它恢复为 Paid,也不会发货或发放权益。

HandleNotify 看到 Cancelled 加 Paid notify 时,先 QueryOrder 复核网关真实状态:

  • 网关确认 Paid 且金额匹配:订单保持 Cancelled,先追加 Cancelled -> Cancelled 审计流水,触发 AnomalyPaidOnCancelled,再触发 OnPaidAfterCancelled,返回 nil。
  • 网关查询失败:AnomalyGatewayQueryFailed,不恢复。
  • 金额不匹配:AnomalyAmountMismatch,不恢复。
  • 网关未确认 Paid:AnomalyPaidOnCancelled,不恢复。

业务方必须把 OnPaidAfterCancelled 或 paid_on_cancelled anomaly 接入幂等退款、对账或人工流程。核心包不会主动调用 RefundGateway.Refund。推荐以 (order_no, trade_no) 为唯一键写入退款 outbox,再由退款 worker 调 RefundGateway.Refund。不要在 OnAnomaly 或 OnPaidAfterCancelled 内补发权益——那等于绕过用户的取消意图。

两条路径的差别可以压成一句:Closed 是系统替用户做的决定,可以用网关事实推翻;Cancelled 是用户做的决定,只能用退款收尾。

履约失败:订单停在 Paid

OnPaid 失败不会回滚支付状态:

HandleNotify → CASConfirmPaid OK → OnPaid FAIL(DB 抖动 / 外部 API 超时)
→ OnAnomaly(DeliveryFailed) → 订单停留在 Paid
DeliveryFallback worker 周期扫描 → Store.FindPaidUndelivered
→ Engine.ReconcilePaid → 重走 OnPaid + FinalizePaidOrder(幂等)

因此 OnPaid 必须幂等:以 orderNo 为幂等键,副作用写同一份数据源并带唯一约束,外部 API 调用前先查本地完成标记。重复回调、fallback 重试、CASReopenPaid 恢复这三条路都会重入同一个钩子。

钩子执行时序

CAS Pending -> Paid(Store.CASConfirmPaid)
→ OnPaid(ctx, order, notify)        业务侧权益发放,必须幂等
→ Store.FinalizePaidOrder(order, bill)  订单 -> Delivered,写账单
→ StatusCache.Set(Delivered) + StatusStream.Publish(Delivered)
→ OnDelivered(ctx, order)           旁路观察,失败仅告警

关键不变量:任何一步失败,Engine 都不向支付网关返回错误,避免重试风暴;失败订单停在 Paid,由 ReconcilePaid 供 fallback worker 后续补偿。

回调与钩子代码

// 路由:POST /api/v1/orders/notify/{channel}
func notifyHandler(engine *orderflow.Engine[*myorder.Order], gateway orderflow.PaymentGateway) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ch := orderflow.Channel(r.PathValue("channel"))
if err := engine.HandleNotify(r.Context(), ch, r); err != nil {
// HandleNotify 只在"解析 / CAS 系统错误"时返回 err
// 业务异常(金额不一致、状态机例外)已走 OnAnomaly,这里 err == nil
engine.Logger().Error(r.Context(), "handle notify fatal", orderflow.Err(err))
http.Error(w, "internal", http.StatusInternalServerError)
return
}
if err := gateway.AckNotify(ch, w); err != nil {
engine.Logger().Error(r.Context(), "ack notify failed", orderflow.Err(err))
}
}
}
onPaid := func(ctx context.Context, o *myorder.Order, n orderflow.NotifyResult) error {
// 此钩子可能被多次调用(重复回调 / fallback 重试 / CASReopenPaid 恢复)
// 以 orderNo 为幂等键,副作用写入同一份数据源 + 唯一约束
return vipSvc.Activate(ctx, vipsvc.ActivateReq{
OrderNo:  o.OrderNo(),
UserID:   o.UserID(),
Duration: decodeDuration(o.Extra()["duration"]),
})
}

onPaidAfterCancelled := func(ctx context.Context, o *myorder.Order, n orderflow.NotifyResult) {
// 退款幂等强约束:以 (orderNo, tradeNo) 为唯一键写入退款 outbox
// 不要在这里直接发权益;订单已被用户取消,Engine 会保持 Cancelled
refundOutbox.Enqueue(ctx, refundoutbox.Task{
OrderNo: o.OrderNo(),
TradeNo: n.TransactionID,
Amount:  n.TotalAmount,
Channel: n.Channel,
Reason:  "paid_after_cancelled",
})
}

CAS 与条件更新

Store[O] 的三个 CAS 方法返回 (int64, error),int64 是 affected 行数,0 表示并发已抢先推进了状态。落到 MySQL 就是带前置状态的条件更新:

-- CASConfirmPaid: Pending -> Paid
UPDATE orders SET status = 10, paid_at = ?, trade_no = ?
WHERE order_no = ? AND status = 0;

-- CASClose: Pending -> Closed
UPDATE orders SET status = 40, closed_reason = ?
WHERE order_no = ? AND status = 0;

-- CASReopenPaid: Closed -> Paid,必须独立于 CASConfirmPaid
UPDATE orders SET status = 10, paid_at = ?, trade_no = ?
WHERE order_no = ? AND status = 40;

FinalizePaidOrder 必须在同一事务里把订单置为 Delivered、写账单、写业务扩展:

UPDATE orders SET status = 20 WHERE order_no = ? AND status = 10;
INSERT INTO order_bill (order_no, trade_no, amount, channel) VALUES (?, ?, ?, ?);

gormstore 不主动建表,参考 schema 在 drivers/gormstore/examples/sql/reference_schema.sql。

退款与其他接入项

v1.7.0+ 提供 RefundGateway 协议层抽象,屏蔽微信 / 支付宝退款 SDK 差异;审批工作流、金额计算、退款记录持久化、反向核销仍由业务方编排。

延时队列 drivers/rediszq 基于 Redis ZSET 双集合租约,集群部署时 key 必须用 hash tag 保证同 slot。StatusCache / StatusStream 一包两用,支持轮询与 SSE;Redis Pub/Sub 不保留历史,SSE 要先 Poll 拿首帧再 Subscribe。生产接入清单见 PRODUCTION_CHECKLIST.md。

适用与不适用

适用:单商品订单(一笔订单对应一个商品 / 服务 / 权益);虚拟交付,无物流;中国大陆主流三方支付(微信、支付宝、银联,经 paymgrgw driver);支付回调驱动的状态机;延时关单加幂等履约;退款自行编排。

不适用:多商品订单(购物车、多 SKU 拆单、混合履约);跨境支付(Stripe / PayPal / 卡组织直连);SaaS 订阅 / 周期扣款 / 自动续费;to B 合同流程(线下打款、对公审批、分期收款、发票流);实物订单(物流、库存、仓配、退换货);复杂分账 / 多渠道结算。

推荐文章

程序员茄子在线接单