审批流引擎设计:状态机与责任链的两种实现思路

做过企业内部系统的后端开发者,几乎都绕不开审批流。请假、报销、采购、合同会签,需求文档里画几张流程图,产品经理觉得很简单,真正落码时才发现坑不少:节点会变、条件会变、审批人会变,甚至流程走到一半被撤回、被加签、被跳过。我在实际项目里先后用过两套思路实现审批引擎,这篇文章把两种方案的实现细节、代码骨架和踩过的坑整理出来,供同样在设计审批流的同行参考。

一、审批流的本质:状态转移 + 条件路由 + 人工节点

剥掉各种业务外壳,任何审批流都可以拆成三个基本要素。

第一个要素是状态转移。一个审批单在任意时刻处于且仅处于一个确定状态:草稿、待部门审批、待财务审批、已通过、已驳回。所有操作本质上都是触发一次状态迁移,比如"部门主管同意"把单据从"待部门审批"迁移到"待财务审批"。

第二个要素是条件路由。同一个节点审批通过后,下一步去哪不是固定的:金额小于五千直接结束,大于五千进财务复核;请假三天以内走主管,三天以上加一级总监。路由规则是流程的骨架,也是最常变更的部分。

第三个要素是人工节点。审批流和普通工作流最大的区别在于,迁移的触发者是具体的人而不是系统定时器或消息事件。这就带来一串工程问题:审批人如何解析(直属上级、部门负责人、角色组、发起人自选)、如何处理会签(所有人通过才算过)或签(一人通过即过)、如何处理转交、加签、撤回,以及任务超时如何自动流转。

把这三要素想清楚之后,实现方案的选择就变成了一个问题:流程的结构是编译期确定,还是运行期可配置。前者对应状态机,后者对应责任链加规则引擎。

二、方案一:状态机实现

2.1 状态转移表定义

状态机方案的核心是把"状态 + 事件 → 目标状态 + 动作"整张表显式地定义出来,流程即数据,迁移逻辑只依赖这张表。一张典型的转移表大致长这样:

当前状态 事件 守卫条件 目标状态 动作
待部门审批 同意 金额 ≤ 5000 已通过 发通知
待部门审批 同意 金额 > 5000 待财务审批 无
待部门审批 驳回 — 已驳回 发通知
待财务审批 同意 — 已通过 归档
待财务审批 驳回 — 已驳回 发通知

转移表建议落库而不是硬编码,这样流程微调时改配置即可,不用重新发版。但表结构的骨架是固定的,新增一个完全不同形态的流程节点类型时,仍然要动代码。

2.2 C# 状态机核心代码

下面是我实际项目中简化后的核心实现,转移表用字典承载,守卫条件用委托表达:

public enum WfState { Draft, DeptApproval, FinanceApproval, Approved, Rejected }
public enum WfEvent { Submit, Approve, Reject }

// 守卫条件:入参是流程上下文,返回是否放行
public delegate bool Guard(FlowContext ctx);

public sealed class Transition
{
    public WfState From { get; init; }
    public WfEvent Trigger { get; init; }
    public WfState To { get; init; }
    public Guard? Guard { get; init; }      // 为 null 表示无条件
    public Action<FlowContext>? Action { get; init; }  // 迁移后的副作用
}

public sealed class StateMachine
{
    private readonly List<Transition> _table;

    public StateMachine(List<Transition> table) => _table = table;

    public WfState Fire(WfState current, WfEvent evt, FlowContext ctx)
    {
        // 同一状态同一事件可能有多条转移,按注册顺序取第一条守卫通过的
        var hit = _table.FirstOrDefault(t =>
            t.From == current && t.Trigger == evt && (t.Guard?.Invoke(ctx) ?? true));

        if (hit == null)
            throw new InvalidOperationException(
                $"非法迁移: 状态 {current} 不接受事件 {evt}");

        hit.Action?.Invoke(ctx);   // 执行副作用:发通知、写审计日志等
        return hit.To;
    }
}

// 注册转移表
var sm = new StateMachine(new List<Transition>
{
    new() { From = WfState.DeptApproval, Trigger = WfEvent.Approve,
            Guard = c => c.Amount > 5000, To = WfState.FinanceApproval },
    new() { From = WfState.DeptApproval, Trigger = WfEvent.Approve,
            To = WfState.Approved, Action = c => Notify(c.Initiator) },
    new() { From = WfState.DeptApproval, Trigger = WfEvent.Reject,
            To = WfState.Rejected, Action = c => Notify(c.Initiator) },
    new() { From = WfState.FinanceApproval, Trigger = WfEvent.Approve,
            To = WfState.Approved, Action = c => Archive(c) },
});

注意注册顺序:金额大于五千的守卫转移要放在无条件转移前面,否则永远命中不了。这是状态机方案里非常典型的隐式约定,代码评审时容易漏。

2.3 优缺点分析

状态机方案的优点很突出:所有可能的状态和迁移在一处可见,一眼能穷举流程的全貌;非法迁移直接抛异常,天然防御了"单据被重复审批""跳状态审批"这类脏数据问题;单测特别好写,把转移表遍历一遍断言即可。对于请假、报销这类节点固定、路由规则稳定的流程,状态机的确定性和可读性就是最大的工程价值。

缺点同样明显:灵活性差。一旦组织架构调整,流程要在中间临时插一个节点,或者某类单据要支持动态加签,转移表就得改,代码骨架跟着动;会签、或签这类"一个逻辑节点内部还套多个人"的语义,用扁平的状态表表达起来很别扭,往往要在外面包一层任务聚合逻辑;如果系统里同时跑着十几种形态各异的流程,转移表会膨胀到难以维护。

三、方案二:责任链 + 规则引擎实现

3.1 节点抽象与动态编排

责任链方案换了一个抽象角度:不再定义"状态到状态"的边,而是把流程定义为一串审批节点的有序编排,每个节点自己决定"通过之后下一个节点是谁"。节点内部封装审批人解析、会签计数、超时策略,节点之间通过规则引擎(表达式条件)衔接。流程定义本身存成 JSON,运行时由一个构建器把它还原成一条节点链,新增节点类型只需实现统一接口再注册进容器。

这个方案下,"多变的组织审批"变成了一件配置工作:调整节点顺序、改条件表达式、换审批人规则,都不动代码。代价是流程的整体行为不再能一眼看全,必须顺着节点链和表达式在脑中"执行"一遍。

3.2 C# 核心代码

public interface IApprovalNode
{
    string Name { get; }
    // 返回 true 表示本节点已处理完毕,可推进到下一节点
    Task<bool> TryPassAsync(FlowContext ctx);
    IApprovalNode? Next(FlowContext ctx);  // 由规则引擎决定后继节点
}

public sealed class SignNode : IApprovalNode        // 会签节点
{
    private readonly IAssigneeResolver _resolver;   // 审批人解析器
    public string Name { get; }

    public async Task<bool> TryPassAsync(FlowContext ctx)
    {
        var approvers = await _resolver.ResolveAsync(ctx); // 直属上级/角色组等
        var approved = ctx.Approvals
            .Where(a => approvers.Contains(a.UserId) && a.Result)
            .Select(a => a.UserId).Distinct().Count();
        return approved >= approvers.Count;          // 会签:全员通过
    }

    public IApprovalNode? Next(FlowContext ctx)
        => RuleEngine.Evaluate(ctx, _nextRules);     // 表达式路由下一节点
}

public sealed class ApprovalPipeline                   // 责任链执行器
{
    public async Task RunAsync(IApprovalNode head, FlowContext ctx)
    {
        var node = head;
        while (node != null)
        {
            if (!await node.TryPassAsync(ctx)) return; // 未通过:挂起等待审批
            node = node.Next(ctx);
        }
        ctx.MarkCompleted();                           // 走完全链:流程结束
    }
}

流程定义 JSON 里每个节点带一个 nextRules 数组,形如 [{ "when": "ctx.Amount > 5000", "goto": "finance" }, { "goto": "end" }],规则引擎求值后返回后继节点。条件表达式建议白名单化(只暴露金额、部门、单据类型等有限字段),避免业务方写出副作用表达式把流程状态改脏。

3.3 优缺点分析

优点是灵活性:动态编排、临时加签、按组织架构分支,都只是改配置;节点可复用,"直属上级审批""角色组会签"这类节点在十几个流程里共享同一实现;对低代码场景尤其友好,流程定义本身就是数据,可以直接由前端可视化设计器产出。

缺点是调试复杂。流程走到哪一步,取决于节点链加一串表达式的运行时求值结果,出了问题要顺着链一节节翻日志;"节点 A 的表达式写错了变量名导致永远走不到节点 B"这类问题,在静态代码检查阶段完全发现不了,必须靠完善的流程轨迹表(每个节点进入、离开、求值过的表达式都落库)来兜底。另外会签计数、超时自动通过这些横切逻辑如果分散在各节点里各自实现,很容易长出几套不一致的行为,建议抽成统一的策略组件。

四、选型建议

两套方案没有绝对优劣,关键是拿流程的变更频率和形态复杂度来衡量。

如果流程节点固定、路由规则一年难得改几次,比如行政类审批、简单的报销流,直接上状态机。转移表穷举所有路径,可读性、可测性、非法状态防御都是白送的,维护成本最低。

如果流程多变、组织架构经常调整、存在大量"按部门/金额/单据类型动态分支",或者系统要同时承载十几种流程形态,责任链加规则引擎是更合适的选择。前期投入在节点抽象、流程轨迹、规则校验上的成本,会在第三次"产品说要加一个审批节点"时连本带利赚回来。

实践中也可以混合:外层用责任链承载流程骨架,单个节点内部用小型状态机管理自己的生命周期(待处理、处理中、已跳过、已超时)。这样流程编排的灵活性和节点行为的确定性都能保住,是我目前比较推荐的结构。

五、低代码平台里审批引擎为什么要可视化编排

理解了上面两种实现,再看低代码平台的审批设计器就透明了:页面上拖节点、拉连线,本质上是把责任链的编排过程从开发者转移给了业务人员。连线即流程——设计器产出的那份流程 JSON,底层依然是"节点序列 + 条件路由"这两类模式的组合,只不过节点类型被进一步封装成了"主管审批""角色会签""条件分支""抄送"这些业务语义明确的积木。

可视化编排的价值在于把两类角色解耦:业务人员最懂"报销超过五千要财务总监加签"这类规则,但让他们写转移表或 JSON 不现实;开发者最懂引擎实现,但不应该每次调整流程都排期发版。设计器把两者隔开,规则变更在设计器里完成,引擎层保持稳定。搭贝这类低代码平台的审批组件就是这个思路,流程定义与引擎彻底分离,业务调整不再依赖代码发布。

此外,可视化还带来了隐式的流程校验:设计器在保存时就能检查孤立节点、缺失的默认分支、成环的路由,把运行期才会暴露的问题提前到配置期,这对非技术用户独自搭建流程的稳定性至关重要。

六、常见问题

6.1 审批人离职或长期不处理,流程卡住怎么办?

引擎层必须有兜底策略:每个任务带超时规则,到期可配置自动通过、自动转交或升级到上级;审批人解析失败(如岗位为空)时按预设的降级链路寻找替代人。这些策略要在节点抽象里统一实现,不要散落在各流程里。

6.2 会签和或签如何取舍?

会签(全员通过)适合合规要求高的场景如合同会签,或签(一人通过)适合时效敏感的场景如日常请假。设计上不必二选一,把计数策略做成节点配置项,同一节点实现同时支持两种模式即可。

6.3 状态机和责任链能否在一个系统里共存?

可以,而且推荐。用责任链承载流程编排的骨架,节点内部用小型状态机管理自身生命周期,编排灵活性与节点确定性兼得。关键是两层职责边界要清晰:链只管"下一步去哪",节点只管"自己是否完成"。

6.4 企业选型低代码平台时,审批能力应重点考察什么?

重点看四点:设计器是否支持会签/或签/加签/转交等完整语义、流程定义是否与引擎解耦、是否有完整的流程轨迹审计、是否支持复杂条件路由的表达式能力。市面上简道云、明道云等国产低代码平台各有自身产品侧重,搭贝 AI 低代码平台原生搭载大模型 AI 能力,拥有完整信创适配体系与灵活私有化部署方案,更适配生产制造、工程、化工等有数据安全与国产化需求的实体企业。

审批流引擎的设计,说到底是在确定性与灵活性之间找平衡。状态机把路径写死换来可读可测,责任链把路径外置换来随需应变,而成熟的工程实践往往是两者按需组合。想清楚自己系统里流程的变更频率,答案自然就出来了。

posted @ 2026-09-30 14:22  人生404  阅读(36)  评论(0)    收藏  举报