审核流程别再用 if + status 硬写了------状态机引擎设计实战(从原理到落地)
凡是「提交 → 初审 → 复审 → 补件回流 → 下游开通 」这种带人工多级审核的系统(开户审核、商家入驻、内容审核、提现审核......都是这个形态),流程一复杂,
status字段 + 一堆if就会失控。这篇先把状态机的通用原理 讲清楚(FSM 四要素、DFA/NFA、状态爆炸、怎么落库),再讲我用一个领域无关的状态机引擎把它做对的实战。代码为示意(已脱敏)。
一、先说为什么 if + status 会崩
审核流程刚开始都很简单,于是很多人用一个 status 字段 + 一堆 if 硬写:
go
func Approve(app *Application, operator Staff) error {
if app.Status == "FIRST_REVIEWING" {
app.Status = "PROVISIONING"
} else if app.Status == "SECOND_REVIEWING" {
app.Status = "PROVISIONING"
} else {
return errors.New("非法操作")
}
// ...还要判断这个人有没有权限、要不要发通知、要不要记日志
}
每个动作(审批/驳回/补件/重置/认领)一个函数,每个函数里一堆 if status == X。问题随流程变复杂迅速爆发:合法性判断散落各处、加状态要翻遍所有 if、没人能一眼说清完整流程。审核流程本质是个状态机,就该用状态机的方式表达。
二、先补基础:FSM 到底是什么
FSM = Finite State Machine(有限状态机) :一个系统在任意时刻都处于"有限个状态"中的某一个,根据收到的"事件 "按预定义的"规则"切换到另一个状态。
任何一个 FSM 都包含四要素:
| 组件 | 含义 | 音乐播放器例子 | 审核系统例子 |
|---|---|---|---|
| 状态 State | 系统当前的样子 | 播放中 | 初审-审核中 |
| 事件 Event | 触发变化的动作 | 按播放键 | 审核员点"通过" |
| 转移 Transition | 状态变化的规则 | 暂停 + 播放键 → 播放中 | 初审.审核中 + APPROVE → 下游开通.待开通 |
| 动作 Action | 转移时附带做的事 | 从暂停处接着放 | 发通知、写审计日志 |
核心思想:把所有状态流转规则集中到一张表里(我叫它 GotoMap),用代码定义------规则是"数据",不是散落的 if。
三、DFA vs NFA:审核系统必须坚持 DFA
状态机分两种,这个区别直接决定你的设计能不能用:
DFA(确定性有限自动机) :任意状态下,对每个事件,下一个状态唯一确定。即"一个 stage 下某 status 的一条出边,只对应一个目标"。
css
A ──APPROVE──▶ B
A ──REJECT───▶ C 每个事件 → 唯一目标
A ──REQUEST_DOCS──▶ D
NFA(非确定性有限自动机) :同一状态 + 同一事件,可能跳到多个状态。
css
┌──▶ B (条件1)
A ─APPROVE─┤
└──▶ C (条件2) 同一事件、多个可能目标,要靠运行时再猜
审核系统必须坚持 DFA :每条出边的目标唯一、可预测、可审计------监管问"这个动作会把单子带到哪",你得答得出唯一 答案,不能"看情况"。所以我的
GotoMap里每个(stage, status, event)只映射一个目标 State。
这里有个一定会被问到的点先回答了:"那如果一条转移的目标要看运行时数据(条件不同去 B 或 C),不就是 NFA 吗?" ------ 不是,区别在于"含糊"放在哪:
- NFA 写法(❌) :在表里给一个
(状态, 事件)挂两个目标(A+X→B和A+X→C)------表本身含糊,光看表不知道去哪。 - 我的写法(✅ 仍是 DFA) :表里
A+X只指向唯一默认目标 B ;特例"去 C"放在一段显式的钩子代码 里运行时改写。表永远确定,"会变"这件事被收敛进一个看得见的钩子。
打个比方:NFA 像路牌写"前面可能左转也可能右转 "(路牌本身含糊);我的做法 像路牌明确写"直行 ",但路口有个交警 遇特殊情况手动指挥改道------路牌(转移表)永远确定,改道是交警(钩子)这个看得见的角色干的。
这样换来三个好处:表能一眼读懂默认流转、能自动做一致性校验(每个入口唯一出口)、默认行为可预测,而特例集中在一处。
四、第一个决策:用「二维坐标」而不是一维 status
我用 (stage, status) 二维坐标而不是单一 status:
-
Stage(阶段) :
FIRST_REVIEW(初审)、SECOND_REVIEW(复审)、CUSTOMER_ACTION(等申请人补件)、PROVISIONING(下游开通)、TERMINAL(终态)。 -
Status(阶段内状态) :
PENDING(待认领)、IN_REVIEW(审核中)、PENDING_DOCS(待补件)......
为什么二维?因为同一个状态在不同阶段含义不同:PENDING 在初审是"待初审认领",在复审是"待复审认领"。二维坐标让"阶段流转"和"阶段内状态流转"正交,状态集合保持精简。
示意规模:几个业务阶段 + 1 个虚拟入口,十几个状态、十多个事件、二十多条转移规则。
五、状态爆炸了怎么办
状态机最大的坑是状态/转移组合爆炸。几个实战手段,按优先级:
-
二维坐标降维 (上一节):用
stage × status,别把笛卡尔积铺平成一维(避免FIRST_PENDING/SECOND_PENDING/FIRST_REVIEWING/... 无限膨胀)。 -
正交维度拆开,别塞进一个 status :比如"审核进度"和"通知状态(未发/已发/已读)"是两个互不相关的维度 。合成一个 status,状态数就是
进度 × 通知的乘积,必爆。正确做法:通知状态用独立字段(甚至独立的小状态机),不进审核状态机。 -
分清"状态"和"数据" :很多看着像状态的东西其实是数据字段 ------重试次数、风险等级、补件轮次。它们不该变成状态。判断标准:加一个状态前先问"它会改变当前能做哪些动作吗?"------不会,就用字段,别进状态机。
-
分层 / 子状态机:一个大阶段内部很复杂时,把它做成子状态机,而不是把子状态全提到顶层。
-
条件用守卫/钩子承载,不用新状态:与其为"高风险"再开一个状态,不如用一个守卫判断。
六、核心架构:领域无关引擎 + 业务转移表
最关键的原则:引擎不懂业务,业务规则是数据。
go
pkg/fsm/ ← 通用引擎(完全不知道什么是"审核")
├── fsm.go ← 类型:State / GotoMap / Event / Hook
└── base_fsm.go ← 引擎:Can / GoToNext / Event / 钩子调度
biz/audit_fsm/ ← 业务层
├── goto_map.go ← 全部转移规则(数据,不是 if)
├── event_functions.go ← 各事件的副作用钩子
└── audit_fsm.go ← 强类型 wrapper
引擎:纯粹、可复用
go
type State struct{ Stage, Status string } // 二维坐标
type GotoMap map[string]map[string]map[string]State // stage->status->event->目标
type BaseFsm struct {
Current State
gotoMap GotoMap
eventFunc EventFuncMap
}
// Can: 当前坐标能不能接受这个事件
func (b *BaseFsm) Can(event string) bool {
m, ok := b.gotoMap[b.Current.Stage]
if !ok { return false }
em, ok := m[b.Current.Status]
if !ok { return false }
_, ok = em[event]
return ok
}
业务:规则就是一张表
go
var auditGotoMap = GotoMap{
StageStart: {StatusInit: {
EventSubmit: {StageFirstReview, StatusPending},
EventAutoApprove: {StageProvisioning, StatusApproved}, // 声明式,见难点2
}},
StageFirstReview: {
StatusInReview: {
EventApprove: {StageProvisioning, StatusApproved},
EventSubmitSecondReview: {StageSecondReview, StatusPending},
EventRequestDocs: {StageCustomerAction, StatusPendingDocs},
EventReject: {StageTerminal, StatusRejected},
},
},
// ...
StageTerminal: {}, // 终态,无出边
}
完整流程一眼可见(就是这张表);加状态/改流程只动数据;合法性判断收敛成一句 Can();引擎能被其它业务(提现审核、工单流转)复用。
七、三时机钩子:转移不只是改个状态
改坐标之外还要换审核人、发通知、记审计。引擎在一次转移里开了三个钩子时机:
go
// before_event -> 状态变更 -> enter_state -> after_event
func (b *BaseFsm) Event(ctx context.Context, event string, args ...any) error {
if !b.Can(event) {
return fmt.Errorf("%s.%s 不接受事件 %q: %w",
b.Current.Stage, b.Current.Status, event, ErrNoTransition)
}
e := &Event{FSM: b, Name: event, Src: b.Current,
Dst: b.gotoMap[b.Current.Stage][b.Current.Status][event], Args: args}
b.runHook(e, BeforeEvent) // ① 前置校验,置 Err → 状态不变
if e.Err != nil { return e.Err }
b.Current = e.Dst // ② 真正改坐标
b.runHook(e, EnterState) // ③ 进入新状态(可改写目标)
if e.Err != nil { return e.Err } // 置 Err → 状态已变,调用方需回滚
b.runHook(e, AfterEvent) // ④ 后置副作用
return e.Err
}
钩子靠 e.Err 中断,三个时机回滚语义不同:Before 置 Err 状态没变;Enter 置 Err 状态已变需调用方回滚;After 置 Err 状态已终局。
八、真正的难点(这几个才是文章的价值)
难点 1:有些转移目标,静态表里写不出来(DFA 怎么不退化成 NFA)
场景 :有时一条转移的目标取决于运行时数据 ,静态表里写死不了。举个跟审核无关的通用例子(电商订单最好懂):支付成功 默认进 待发货,但如果是虚拟商品 就要直接进 已完成------目标得看运行时的商品类型。我坚持 GotoMap 是 DFA,所以做法是:表里给唯一默认目标,用 EnterState 钩子按条件改写:
go
var hooks = EventFuncMap{
EventPaid: {
EnterState: func(e *Event) {
order := e.Args[0].(*Order)
// GotoMap 默认目标是「待发货」;虚拟商品改写成「已完成」
if order.IsVirtual {
e.FSM.SetCurrent(State{StageDone, StatusCompleted})
}
},
},
}
关键不是这个例子本身,而是套路 :当目标依赖运行时数据时,别在转移表里给一个事件挂多个目标(那就成 NFA 了)------而是表里放唯一默认目标(保持 DFA),把"条件分支"收敛进一个显式的 EnterState 钩子。表永远确定、可校验,分支逻辑集中在一处可见。
难点 2:表里有一条「只写不跑」的规则
先解释个词:"触发(fire)一条转移" = 程序真的走这条规则、按它把状态改过去。
我的转移表里有一条 创建 → 自动通过 → 待开通,但它永远不会真的被触发。为什么?因为**"提交/创建单子"这一步根本不走状态机**------提交时,代码直接根据"能不能自动通过"把单子放到起点(能自动过 → 直接放"待开通";不能 → 放"待初审"),没必要为"创建"也跑一遍状态机。
那为什么还把这条规则写进表里?纯粹是给人看 + 给校验工具用的------让"自动通过最后会落到哪个状态"在表里有据可查,并标一句注释"这条只是声明,程序不会真走它"。
取舍:好处是创建不用硬套一次状态机空转;代价是"创建时怎么定状态"的真实逻辑在另一段代码里、不在表里,看表的人得知道这条是"摆设"。
难点 3:引擎用字符串,业务层包成方法
引擎为了通用,对外接口长这样:Event("APPROVE", 参数...)------事件名是个字符串、参数是任意类型。灵活,但危险:
- 事件名拼错(
"APPROVE"写成"APPORVE")编译器不报错,上线才发现; - 参数类型传错,运行时才崩。
解决办法:业务层给每个事件包一个明确的方法,调用方只调方法、不直接碰字符串:
go
type AuditFsm struct{ fsm *fsm.BaseFsm }
func (f *AuditFsm) Approve(ctx context.Context) (fsm.State, error) {
if err := f.fsm.Event(ctx, EventApprove); err != nil { // 字符串只在这一处出现
return fsm.State{}, err
}
return f.fsm.GetCurrent(), nil
}
这样事件名和参数都被方法签名固定死、编译器帮你查 ,再也不怕拼错或传错类型。一句话:引擎要通用,只能用"字符串 + 任意参数"这种松散接口;业务层在外面包一层"一个事件一个方法",把松散收紧成编译期安全。
难点 4:两个人同时审同一单,怎么不乱
两个审核员同时点"通过"同一个单子,怎么保证不出乱子(状态被改两次、或改到一半)?
状态机引擎自己不管这事------它只是个"算下一个状态"的纯逻辑,就算加锁也只能锁住单台机器,部署多台时没用。
所以并发交给数据库 扛,用乐观锁:更新时带上"我刚才看到的版本号",谁先改成功版本号就 +1;第二个人再改时发现"版本号对不上了" → 更新失败 → 提示他"单子状态已变,请刷新"。这样同一时刻只有一个人能真正改成功。具体怎么落库见下一节。
难点 5:流程改版了,没走完的老单子怎么办
场景 :流程要改版------比如监管新要求加一道"强化审核"环节,新的转移表(V2)和老的(V1)不一样了。但线上有一堆按老流程走到一半 的单子,不能让它们突然按新规则走------很可能跳到一个老流程里根本不存在的状态,审核员也懵。
解法 :给每个单子记一个 flow_version 字段(创建时定死)。引擎按这个版本选对应的转移表------老单子认 V1 表、新单子认 V2 表,两套流程并存、各走各的、互不干扰,直到老单子全部走完。就像 App 灰度发布:老用户还按旧版规则走,新用户用新版,不强制老的立刻切。
关键 :这个"版本位"一开始就要留 (哪怕现在只有 V1)。等真要改版时,加一张 V2 表就行;要是一开始没留,事后改版会非常痛苦------要么强行迁移所有在途单子,要么写一堆"如果是老单子就......否则......"的兼容 if。留一个字段,比事后补便宜太多。
九、状态机怎么落库:表设计
状态机怎么持久化?两条原则:
① 当前状态直接落在业务行上,不建独立"状态表"。
sql
CREATE TABLE audit_application (
id BIGINT PRIMARY KEY,
biz_id BIGINT NOT NULL, -- 业务主键
audit_stage VARCHAR(32) NOT NULL, -- 当前阶段
audit_status VARCHAR(32) NOT NULL, -- 阶段内状态
flow_version VARCHAR(8) NOT NULL, -- 流程版本 → 选哪张 GotoMap
lock_version INT NOT NULL DEFAULT 0, -- 乐观锁
-- ... 业务字段
KEY idx_stage_status (audit_stage, audit_status) -- 工作台按状态捞任务
);
(audit_stage, audit_status)= 当前二维坐标;flow_version选用哪张转移表;lock_version乐观锁;idx_stage_status让"捞所有待复审认领"走索引。
② 转移历史 = 独立的 append-only 审计日志表。
sql
CREATE TABLE audit_log (
id BIGINT PRIMARY KEY,
application_id BIGINT NOT NULL,
from_stage VARCHAR(32), from_status VARCHAR(32),
to_stage VARCHAR(32), to_status VARCHAR(32),
event VARCHAR(32) NOT NULL,
operator VARCHAR(64),
created_at DATETIME NOT NULL,
KEY idx_app (application_id)
);
一次转移在一个事务里完成两件事:乐观锁守卫更新坐标 + INSERT 一条审计日志(只增不改):
go
err := tx.Do(ctx, func(ctx context.Context) error {
// 乐观锁:坐标或版本变了 → affected=0 → 冲突回滚
affected, err := repo.UpdateCoordinate(ctx, id, from, to, cmd.LockVersion)
if err != nil { return err }
if affected == 0 { return ErrStateChanged }
return repo.AppendAuditLog(ctx, id, from, to, event, operator) // append-only
})
为什么不建"独立 FSM 状态表"?当前状态就是一个坐标,放业务行最简单、最少 join;历史放日志表。别为状态机单独造一套表,那是过度设计。 而那张 append-only 的
audit_log,就是合规要的"可追溯证据链"。
十、踩坑与诚实的权衡
真实行为分散在四处 :一条转移的完整行为可能在 ① GotoMap(目标)② 钩子(副作用/动态改写)③ usecase 守卫("谁能做这个动作"的权限,不放 FSM 里)④ data 层(提交直接选坐标)。只看 GotoMap 会误判 。应对:表里用注释标注,权限判断留 usecase 层------FSM 只管"状态允不允许",不管"这个人允不允许"。
终态无出边、构造时不校验 state 存在 :引擎允许构造"停在终态、无出边"的 FSM(从库里恢复已结束记录时需要),所以 New() 只校验 stage 声明过------这是刻意的。
十一、小结
- 审核流程本质是状态机,规则应该是数据(转移表),不是散落的
if。 - 坚持 DFA(每条出边唯一目标);目标依赖运行时数据时,用"唯一默认目标 + 钩子改写",别退化成 NFA。
- 二维坐标 + 正交拆分 + 分清状态与数据 是防状态爆炸的核心手段。
- 领域无关引擎 + 业务转移表 + 三时机钩子:引擎可复用,规则一眼可见。
- 落库 :当前坐标放业务行(带
lock_version/flow_version),转移历史放 append-only 审计日志,转移 + 日志同事务、乐观锁守卫并发。
状态机不是为了"显得高级",而是当流程复杂到 if 扛不住时,把规则从代码里抽成数据------这才是它真正的价值。
这是我「审核系统设计」系列的一篇,后面会写幂等设计 、审核拒绝码与对外脱敏 、CQRS 读模型。觉得有用点个赞关注呀 🙌