审核流程别再用 if + status 硬写了——状态机引擎设计实战(从原理到落地)

审核流程别再用 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→BA+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 在初审是"待初审认领",在复审是"待复审认领"。二维坐标让"阶段流转"和"阶段内状态流转"正交,状态集合保持精简。

stateDiagram-v2 [*] --> FIRST_REVIEW: SUBMIT [*] --> PROVISIONING: 自动通过(不走FSM) FIRST_REVIEW --> PROVISIONING: APPROVE FIRST_REVIEW --> SECOND_REVIEW: SUBMIT_SECOND_REVIEW FIRST_REVIEW --> CUSTOMER_ACTION: REQUEST_DOCS SECOND_REVIEW --> PROVISIONING: APPROVE SECOND_REVIEW --> TERMINAL: REJECT CUSTOMER_ACTION --> FIRST_REVIEW: SUPPLEMENT(补件回流) PROVISIONING --> TERMINAL: PROVISION_SUCCESS PROVISIONING --> PROVISIONING: PROVISION_FAIL(进异常队列)

示意规模:几个业务阶段 + 1 个虚拟入口,十几个状态、十多个事件、二十多条转移规则。

五、状态爆炸了怎么办

状态机最大的坑是状态/转移组合爆炸。几个实战手段,按优先级:

  1. 二维坐标降维 (上一节):用 stage × status,别把笛卡尔积铺平成一维(避免 FIRST_PENDING/SECOND_PENDING/FIRST_REVIEWING/... 无限膨胀)。

  2. 正交维度拆开,别塞进一个 status :比如"审核进度"和"通知状态(未发/已发/已读)"是两个互不相关的维度 。合成一个 status,状态数就是 进度 × 通知 的乘积,必爆。正确做法:通知状态用独立字段(甚至独立的小状态机),不进审核状态机。

  3. 分清"状态"和"数据" :很多看着像状态的东西其实是数据字段 ------重试次数、风险等级、补件轮次。它们不该变成状态。判断标准:加一个状态前先问"它会改变当前能做哪些动作吗?"------不会,就用字段,别进状态机。

  4. 分层 / 子状态机:一个大阶段内部很复杂时,把它做成子状态机,而不是把子状态全提到顶层。

  5. 条件用守卫/钩子承载,不用新状态:与其为"高风险"再开一个状态,不如用一个守卫判断。

六、核心架构:领域无关引擎 + 业务转移表

最关键的原则:引擎不懂业务,业务规则是数据。

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 读模型。觉得有用点个赞关注呀 🙌

相关推荐
SiliconGazer7 天前
第15届国赛满分代码解析(下)—— 运动轨迹算法、按键交互与完整状态机
算法·状态机·stc15f2k60s2·浮点运算·蓝桥杯国赛·运动轨迹、·向量分解
Emerson_202616 天前
kanzi--属性插值、状态机、动画
动画·状态机·hmi·kanzi·属性插值
winlife_19 天前
让 AI 写敌人状态机,并用脚本化场景验证状态转换正确:funplay-unity-mcp 实战
人工智能·unity·游戏引擎·ai编程·状态机·mcp
Tisfy1 个月前
LeetCode 3121.统计特殊字母的数量 II:状态机
算法·leetcode·题解·状态机
都在酒里1 个月前
STM32有限状态机(FSM)详解,综合应用总结(二)
stm32·单片机·嵌入式硬件·状态机
吴声子夜歌1 个月前
状态机——SpringStateMachine并行区域状态流转
状态机·并行状态流转
吴声子夜歌1 个月前
状态机——SpringStateMachine嵌套状态流转
java·状态机·嵌套状态
吴声子夜歌1 个月前
状态机——枚举实现简单状态机
java·枚举·状态机
吴声子夜歌1 个月前
状态机——并行分支聚合
java·状态机·分支聚合