🔍 登录 注册

订单状态机工程实践 把工作流从 if-else 里救出来

Z ZeroIsStart 2026-10-23 👁 0 ⏱ 6min

订单状态机工程实践 把工作流从 if-else 里救出来

订单、退款、发布审核这类流程,代码里最常见的形态是越来越长的 if status == ... && action == ...。半年后没人敢改,因为谁也不知道少一个分支会不会漏掉某条路径。状态机把散落的状态判断收拢成一份可读的转移表,附带免费的审计与并发保护。

一、先画表再写代码

任何流程在写代码前先落成一张表,四个要素:状态、事件、守卫、副作用。

状态:  draft  pending_review  published  rejected  archived
事件:  SUBMIT  APPROVE  REJECT  UNPUBLISH  REPUBLISH

转移表:
  draft          --SUBMIT-->    pending_review
  pending_review --APPROVE-->   published
  pending_review --REJECT-->    rejected
  rejected       --SUBMIT-->    pending_review
  published      --UNPUBLISH--> archived
  archived       --REPUBLISH--> published

表既是产品文档也是测试用例来源。任何"新增一个状态"的需求必须先改表再改代码。

二、声明式定义与执行器

type Status string
type Event string

const (
    Draft    Status = "draft"
    Pending  Status = "pending_review"
    Public   Status = "published"
    Rejected Status = "rejected"
    Archived Status = "archived"
)

type Transition struct {
    From       Status
    Event      Event
    To         Status
    Guard      func(context.Context, *Order) error
    SideEffect func(context.Context, *Order) error
}

type Machine struct{ table map[Status]map[Event]Transition }

func New(rows ...Transition) *Machine {
    m := &Machine{table: map[Status]map[Event]Transition{}}
    for _, r := range rows {
        if m.table[r.From] == nil { m.table[r.From] = map[Event]Transition{} }
        if _, dup := m.table[r.From][r.Event]; dup {
            panic(fmt.Sprintf("duplicate transition %s/%s", r.From, r.Event))
        }
        m.table[r.From][r.Event] = r
    }
    return m
}

var ErrIllegalTransition = errors.New("illegal state transition")

func (m *Machine) Apply(ctx context.Context, o *Order, ev Event) error {
    t, ok := m.table[o.Status][ev]
    if !ok {
        return fmt.Errorf("%w: %s + %s", ErrIllegalTransition, o.Status, ev)
    }
    if t.Guard != nil {
        if err := t.Guard(ctx, o); err != nil { return err }
    }
    before := o.Status
    o.Status = t.To
    if t.SideEffect != nil {
        if err := t.SideEffect(ctx, o); err != nil {
            o.Status = before
            return err
        }
    }
    o.Audit = append(o.Audit, AuditEntry{From: before, Event: ev, To: t.To, At: time.Now()})
    return nil
}

两条要点:非法转移必须报错而不是静默忽略(上层据此返回 409),以及副作用失败要回滚状态。

三、用乐观锁处理并发审批

并发风险是"两个人同时审批同一条"。版本号乐观锁最省事且不需要行锁:

func (s *Service) Transition(ctx context.Context, id int64, ev Event, actor string) error {
    for attempt := 0; attempt < 3; attempt++ {
        var o Order
        if err := s.db.First(&o, id).Error; err != nil { return err }

        clone := o
        if err := s.machine.Apply(ctx, &clone, ev); err != nil { return err }

        res := s.db.Model(&Order{}).
            Where("id = ? AND version = ?", id, o.Version).
            Updates(map[string]any{"status": clone.Status, "version": o.Version + 1})
        if res.Error != nil { return res.Error }
        if res.RowsAffected == 1 {
            return s.recordAudit(ctx, id, o.Status, ev, clone.Status, actor)
        }
    }
    return ErrConflict
}

版本冲突返回 409,让前端提示"该记录已被他人修改,请刷新",而不是死循环重试。

四、把权限收进守卫

越权审批是常见漏洞,而守卫函数天然是权限校验点:

func OnlyStaff(allowed ...string) func(context.Context, *Order) error {
    return func(ctx context.Context, _ *Order) error {
        role := RoleFrom(ctx)
        for _, r := range allowed { if r == role { return nil } }
        return ErrForbidden
    }
}

func MustBePaid(_ context.Context, o *Order) error {
    if !o.Paid { return errors.New("未支付不能发货") }
    return nil
}

"谁能做什么"集中在一处,安全评审只需读一次表定义,而不是在各个 handler 里找散落的 if。

五、审计与可解释性

type AuditEntry struct {
    From  Status    `json:"from"`
    Event Event     `json:"event"`
    To    Status    `json:"to"`
    Actor string    `json:"actor"`
    At    time.Time `json:"at"`
    Note  string    `json:"note,omitempty"`
}

审计表单独建、只追加不修改。工单排查一条 SQL 还原全过程:

SELECT from_status, event, to_status, actor, created_at
FROM flow_audits WHERE target_type='order' AND target_id=? ORDER BY id;

六、别过度设计

三条判据:状态数不超过 3 且无守卫无副作用就别引入框架;只有运营确实需要后台动态改规则时才把转移表存 DB(配置化的运维成本通常高于发版成本);需要长时间等待外部事件的流程应引入待事件表或工作流引擎,不要靠轮询把状态机改成调度器。

流程跨多个服务时,每个服务各自拥有一个状态机(订单、库存、退款状态彼此独立),用事件解耦。全局大状态机会成为系统所有耦合的汇聚点。

小结

落地形态就是一张转移表 + Apply 函数 + 乐观锁 + 审计流水,把状态相关的所有决策从散落各处的 if 收拢成单点,换来可读、可测、可审计、可评审。Go 手写 200 行足够覆盖多数业务;跨服务守住"各自拥有状态 + 事件驱动",避免造出全局状态机。

读完了?给作者一点支持

本文采用 CC BY 4.0 协议转载 · © ZeroIsStart

评论(0)

登录 后参与评论。

  • 还没有评论,来抢沙发。

🔗 相关文章