Skip to content

状态机设计

订单 · 支付 · 库存 · 优惠券 · 配送 · 售后 — 完整状态流转 + 非法状态防御


一、订单状态机

状态定义

状态含义允许操作
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_qtyreserved_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 → 自动退款 → 通知用户
530min 超时释放时,用户刚好支付成功先检查订单状态:已 paid → 不释放;仍 pending_payment → 释放
6退款请求成功但微信退款回调丢失worker 定时查询退款单状态(主动查询),补偿更新
7优惠券锁定后支付超时,释放优惠券时发现已被删除释放操作对不存在的券返回成功(已删除=已不可用,无需释放)
8库存 CAS 冲突超过 3 次返回 ErrConcurrencyExhausted → 前端提示用户重试
9配送中订单被后台取消先标记取消 → 异步退款 → 通知骑手
10订单已 completed 收到支付回调检查已支付 → 返回成功(幂等),不重复处理

八、超时处理机制(worker 进程)


相关链接

鼎味肉市 · 纯线上猪肉零售