订单状态机工程实践 把工作流从 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 行足够覆盖多数业务;跨服务守住"各自拥有状态 + 事件驱动",避免造出全局状态机。
评论(0)
登录 后参与评论。