订单状态机的状态语义与恢复路径:Closed 可恢复,Cancelled 只退款
把「已关闭」和「已取消」合并成一个终态,问题往往出现在支付成功回调迟到的时候:用户在超时临界点完成支付,网关已经扣款,通知却延迟到达,CloseWorker 已先把本地订单置成终态。终态只有一个时,引擎无法判断该不该补发权益——补发违背用户取消意图,不补发则吞掉一笔已扣款订单,最后只能人工翻日志对账。另一种写法用 is_paid / is_closed / is_cancelled 三个布尔字段拼状态,并发下能拼出 paid=1 且 cancelled=1 的非法组合,之后每个读状态的代码都要各自解释一遍。
orderflow 的做法是把终态拆成语义不同的两种,各配一条独立的恢复路径。项目地址:。它面向单商品虚拟交付订单(数字内容 / 虚拟商品 / 会员权益),封装订单创建、支付超时关闭、支付回调处理、履约交付、状态推送和幂等补偿;核心包零第三方依赖,数据库 / Redis / 支付网关通过 drivers/ 子包注入。
状态枚举与合法迁移
状态值是确定的六个:
| 状态 | 值 | 语义 |
|---|---|---|
| Pending | 0 | 已创建,待支付 |
| Paid | 10 | 网关支付已确认 |
| Delivered | 20 | 已交付 |
| Completed | 30 | 终态 |
| Closed | 40 | 系统 / 超时关闭,网关确认已付时可恢复 |
| Cancelled | 50 | 用户主动取消,不恢复、不履约 |
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,避免重推风暴。
这条路径有四道防线:
- 只认网关 QueryOrder 的结果。 notify 里写着 paid 也要回查,对抗伪造通知和网关自身 bug 造成的状态污染。
- 金额二次校验。
query.TotalAmount与订单PayAmount不一致直接OnAnomaly,拒绝恢复。 CASReopenPaid是独立 CAS 方法,与CASConfirmPaid语义分离,driver 可单独打审计日志或报警;affected=0表示并发已处理完,当次 skip。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 合同流程(线下打款、对公审批、分期收款、发票流);实物订单(物流、库存、仓配、退换货);复杂分账 / 多渠道结算。