状态机设计
订单 · 支付 · 库存 · 优惠券 · 配送 · 售后 — 完整状态流转 + 非法状态防御
一、订单状态机
状态定义
| 状态 | 含义 | 允许操作 |
|---|---|---|
created | 订单刚创建,尚未生成支付单 | 取消 |
pending_payment | 等待用户支付 | 支付回调、取消、超时 |
paid | 已支付,等待确认库存 | 确认库存、申请退款 |
confirmed | 库存已扣减,等待备货 | 备货、申请退款 |
preparing | 备货中 | 开始配送、取消(后台) |
delivering | 配送中 | 确认送达、申请退款 |
completed | 已签收 | 申请售后 |
cancelled | 已取消(终态) | — |
refunding | 退款中 | 退款成功、退款驳回 |
refunded | 已退款(终态) | — |
非法状态转换检测
go
// order/state_machine.go
var allowedTransitions = map[OrderStatus][]OrderStatus{
Created: {PendingPayment, Cancelled},
PendingPayment: {Paid, Cancelled},
Paid: {Confirmed, Refunding},
Confirmed: {Preparing, Refunding},
Preparing: {Delivering, Refunding},
Delivering: {Completed, Refunding},
Completed: {Refunding},
Refunding: {Refunded, Delivering}, // 驳回回到配送
}
func (s *OrderStatus) CanTransitionTo(target OrderStatus) bool {
for _, allowed := range allowedTransitions[*s] {
if allowed == target {
return true
}
}
return false
}二、支付状态机
支付幂等设计(必须)
sql
-- payments 表:多个唯一约束保证幂等
CREATE TABLE payments (
id BIGSERIAL PRIMARY KEY,
payment_no VARCHAR(64) UNIQUE NOT NULL, -- 唯一约束 #1
order_no VARCHAR(32) UNIQUE NOT NULL, -- 唯一约束 #2:一个订单一个支付单
wechat_txn_id VARCHAR(64) UNIQUE, -- 唯一约束 #3:微信交易号不重复
amount_fen BIGINT NOT NULL,
status VARCHAR(16) DEFAULT 'created',
-- ...
);
CREATE UNIQUE INDEX idx_payments_order ON payments(order_no);
CREATE UNIQUE INDEX idx_payments_wechat_txn ON payments(wechat_txn_id) WHERE wechat_txn_id IS NOT NULL;go
// 微信回调处理(幂等)
func (s *PaymentService) HandleCallback(ctx context.Context, body []byte) error {
// 1. 验签
notif, err := s.wechatClient.ParseNotify(body)
if err != nil {
return fmt.Errorf("signature verify: %w", err)
}
// 2. 幂等:按 wechat_txn_id 查是否已处理
existing, _ := s.repo.FindByWechatTxnID(ctx, notif.TransactionID)
if existing != nil && existing.Status == "paid" {
// 重复回调,直接返回成功
return nil
}
// 3. 更新支付单状态(带乐观锁,防止并发)
rows, err := s.repo.UpdateStatus(ctx, notif.OutTradeNo, "paid", notif.TransactionID)
if err != nil || rows == 0 {
// 可能已被并发处理,查一下确认
return s.verifyAlreadyPaid(ctx, notif.OutTradeNo)
}
// 4. 通知 order 模块(同进程函数调用)
if err := s.orderSvc.OnPaymentPaid(ctx, notif.OutTradeNo); err != nil {
// 订单更新失败不影响支付状态——记录 error log,worker 会重试
slog.Error("order OnPaymentPaid failed", "order_no", notif.OutTradeNo, "error", err)
}
// 5. 写入 outbox(通知事件)
s.outbox.Publish(ctx, outbox.Event{
Type: "payment.paid",
AggregateID: notif.OutTradeNo,
})
return nil
}退款幂等
sql
CREATE TABLE refunds (
id BIGSERIAL PRIMARY KEY,
refund_no VARCHAR(64) UNIQUE NOT NULL, -- 唯一约束
payment_no VARCHAR(64) NOT NULL,
order_no VARCHAR(32) NOT NULL,
amount_fen BIGINT NOT NULL,
reason TEXT,
status VARCHAR(16) DEFAULT 'created',
created_at TIMESTAMPTZ DEFAULT NOW()
);三、库存锁定与流水
库存锁定流程
库存流水表(必须)
sql
CREATE TABLE inventory_movements (
id BIGSERIAL PRIMARY KEY,
product_id BIGINT NOT NULL,
batch_no VARCHAR(64), -- 🆕 批次号(生鲜必须)
movement_type VARCHAR(32) NOT NULL, -- inbound / lock / confirm / release / refund_rollback / adjust
quantity INT NOT NULL, -- 正=入库/回滚,负=锁定/扣减/释放
lock_id VARCHAR(64), -- 关联的锁定 ID
order_no VARCHAR(32), -- 关联订单
operator_id BIGINT, -- 操作人(系统操作为 0)
balance_total INT NOT NULL, -- 操作后总库存
balance_reserved INT NOT NULL, -- 操作后已锁定
note TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_im_product ON inventory_movements(product_id, created_at);
CREATE INDEX idx_im_lock ON inventory_movements(lock_id);
CREATE INDEX idx_im_order ON inventory_movements(order_no);关键原则:库存不能只记录 total_qty 和 reserved_qty 的最终值。必须记录每一次变动的完整流水,才能事后追溯到任何一笔操作。
四、优惠券锁定状态机
锁定与释放必须幂等
go
// coupon/service.go
func (s *CouponService) Lock(ctx context.Context, userID, couponID int64, orderNo string) error {
// 带条件的 UPDATE,防止重复锁定
result := s.db.Exec(`
UPDATE user_coupons
SET status = 'locked', locked_at = NOW(), order_no = ?
WHERE id = ? AND user_id = ? AND status = 'unused'
AND expires_at > NOW()
`, orderNo, couponID, userID)
if result.RowsAffected == 0 {
// 查一下当前状态
coupon := s.repo.FindByID(ctx, couponID)
if coupon.Status == "locked" {
return nil // 已锁定,幂等返回
}
return ErrCouponNotAvailable
}
return nil
}
func (s *CouponService) Release(ctx context.Context, couponID int64, orderNo string) error {
// 只释放"被同一订单锁定"的券
result := s.db.Exec(`
UPDATE user_coupons
SET status = 'unused', locked_at = NULL, order_no = NULL
WHERE id = ? AND status = 'locked' AND order_no = ?
`, couponID, orderNo)
if result.RowsAffected == 0 {
// 可能已使用或已释放,幂等返回
return nil
}
return nil
}五、配送任务状态机
六、售后状态机
七、异常场景处理(10 条关键场景)
| # | 场景 | 处理方式 |
|---|---|---|
| 1 | 微信支付回调到达时订单还是 created(未生成支付单) | 幂等重试:支付模块记录回调,等待 worker 重试关联 |
| 2 | 同一笔支付收到两次微信回调 | wechat_txn_id UNIQUE 约束 → 第二个回调直接返回 200,不重复处理 |
| 3 | 库存锁定成功后,支付单创建失败 | Saga 补偿:释放库存 + 释放优惠券 + 订单取消 |
| 4 | 支付成功但库存确认失败(库存已被后台调整) | 订单进入 refunding → 自动退款 → 通知用户 |
| 5 | 30min 超时释放时,用户刚好支付成功 | 先检查订单状态:已 paid → 不释放;仍 pending_payment → 释放 |
| 6 | 退款请求成功但微信退款回调丢失 | worker 定时查询退款单状态(主动查询),补偿更新 |
| 7 | 优惠券锁定后支付超时,释放优惠券时发现已被删除 | 释放操作对不存在的券返回成功(已删除=已不可用,无需释放) |
| 8 | 库存 CAS 冲突超过 3 次 | 返回 ErrConcurrencyExhausted → 前端提示用户重试 |
| 9 | 配送中订单被后台取消 | 先标记取消 → 异步退款 → 通知骑手 |
| 10 | 订单已 completed 收到支付回调 | 检查已支付 → 返回成功(幂等),不重复处理 |