OA 流程审批是怎么跑起来的

· 约 7 分钟读完

每个打工人都点过”同意”或”驳回”,但很少有人想过点下去之后发生了什么。一张请假单从提交到归档,中间经过的节点、分支、会签,全靠一套工作流引擎在推。这篇把 OA 审批拆成三层,看看它到底是怎么跑起来的。

第一层:流程建模

审批流程本质上是一张有向图。每个节点代表一个动作,节点之间的连线带着流转条件。

常见的节点类型:

  • 开始节点:流程入口,用户点”提交”时触发
  • 审批节点:分配给某个人或某个角色,等待操作
  • 会签节点:多人并行审批,按规则汇聚(全部通过 / 一票通过 / 按比例)
  • 条件分支:根据表单字段做判断,比如金额大于 5000 走总监审批
  • 并行网关:fork 出多条路径同时执行,全部完成后 join
  • 结束节点:流程归档

这张图的描述格式,业界主流是 BPMN 2.0(Business Process Model and Notation)。它是一套 XML 规范,主流引擎都支持解析。前端通常用可视化设计器让业务人员拖拽编排,保存后生成 BPMN XML 或 JSON 存到数据库。

主流的开源引擎有 Activiti、Flowable、Camunda,三者同源(Activiti 是最早的,Flowable 从 Activiti fork 出来,Camunda 独立发展),API 风格相近,核心都是围绕 BPMN 规范做解析和执行。

第二层:流程执行

用户提交申请后,引擎开始推进流程实例。整个运行时可以理解为一个状态机在图上走 token。

token 是工作流领域借自 Petri 网的术语,可以理解为流程图上的”执行指针”——标记当前走到了哪个节点。串行审批时只有一个 token;碰到并行网关 fork 时,一个 token 分裂成多个,join 时等全部到齐才合并继续。

创建流程实例

引擎根据流程定义创建一个实例,绑定这次申请的业务数据(请假天数、报销金额等)。这些数据作为流程变量存储,后续节点可以读取和判断。

Map<String, Object> variables = new HashMap<>();
variables.put("amount", 8000);
variables.put("applicant", "zhangsan");

ProcessInstance instance = runtimeService
    .startProcessInstanceByKey("expense-approval", variables);

节点流转

引擎拿到当前节点后,按类型决定下一步:

  • 审批节点:生成一条待办任务(task),推送给审批人。审批人操作后,引擎根据结果决定流向——同意则往下走,驳回则回退到指定节点或直接结束
  • 条件网关:评估每条出线上的表达式,选中的那条继续走。比如 ${amount > 5000} 为 true 就走总监线
  • 会签节点:给多人同时生成任务,等所有人(或达到比例)完成后才汇聚到下一个节点
  • 并行网关:fork 时同时激活多条路径的 token,join 时等所有 token 到齐才放行

审批人的操作

审批人能做的不只是同意和驳回:

  • 同意:推进到下一个节点
  • 驳回:回退到上一个节点,或直接打回发起人
  • 转办:把这条任务移交给别人处理
  • 加签:临时加一个人进来审批,原任务挂起等新任务完成

到达结束节点

所有路径走完,流程实例状态变为”已完成”,审批记录归档。业务系统收到回调后执行后续逻辑——比如报销单审批通过后触发财务打款。

数据模型

支撑流程运转的核心表通常有四张:

职责关键字段
流程定义表存 BPMN 模板,支持版本化定义ID、版本号、XML内容
流程实例表每次发起一条实例ID、定义ID、发起人、状态、业务关联键
任务表每个审批节点生成一条任务ID、实例ID、审批人、节点名、状态
流转历史表完整的操作日志操作人、操作类型、审批意见、耗时

Flowable 的表名前缀是 ACT_,运行时表前缀 ACT_RU_,历史表前缀 ACT_HI_。比如 ACT_RU_TASK 存当前活跃的待办任务,ACT_HI_TASKINST 存历史任务。

流程实例和业务数据之间通过一个 business_key 关联。引擎不关心业务表的结构,只负责流转;业务系统通过 business_key 把审批状态和自己的订单、报销单、请假单对应起来。

第三层:审批人解析

“这个节点该谁审”是整个系统里最容易变复杂的部分。常见的确定方式:

固定指定:节点直接绑定某个用户 ID。最简单,但换人就得改流程定义,只适合极少变动的场景。

按角色或岗位:节点绑定的是角色(如”部门经理”),运行时查组织架构拿到具体人。发起人调到别的部门,审批人自动跟着变。

动态表达式:在节点上配一个表达式,引擎在运行时求值。比如:

${orgService.getDirectManager(applicant)}

引擎调用注入的 orgService Bean,传入发起人 ID,返回直属主管。这种方式最灵活,但表达式写错了排查成本高。

发起人自选:表单里放一个”选择审批人”的控件,用户提交时指定。适合非标准流程,比如跨部门协作。

会签列表:配置多人并行审批,可以是固定名单,也可以是表达式返回的列表。汇聚规则(全部通过、一票通过、按比例)在节点属性里配。

实际项目里这几种经常混用。第一个节点按角色走直属主管,金额超限后走固定的财务总监,最后一个节点让发起人自选抄送人。

和业务系统的集成方式

工作流引擎和业务系统之间有两种常见的集成模式:

嵌入式:引擎作为一个 JAR 包嵌入到业务应用里,共享同一个数据源和事务。Flowable 和 Camunda 都支持这种模式。好处是事务一致性天然保证,坏处是引擎升级和业务发版耦合在一起。

独立部署:引擎单独跑一个服务,业务系统通过 REST API 调用。好处是引擎可以被多个业务系统共享,坏处是跨服务的事务一致性需要额外处理(比如用消息队列做最终一致)。

中小项目一般选嵌入式,大型 OA 平台倾向独立部署。

自己写一个轻量引擎

如果审批场景不复杂——串行审批、条件分支、简单会签就够用——可以跳过 BPMN 那套重型 XML,自己用几百行代码搓一个。

用 JSON 定义流程

不需要 BPMN XML,一个 JSON 数组就能描述整条审批链:

{
  "key": "expense-approval",
  "version": 1,
  "nodes": [
    { "id": "start", "type": "START", "next": "manager" },
    {
      "id": "manager",
      "type": "APPROVAL",
      "assignee": "ROLE:direct_manager",
      "next": "amount_check"
    },
    {
      "id": "amount_check",
      "type": "CONDITION",
      "branches": [
        { "expr": "amount > 5000", "next": "director" },
        { "expr": "true", "next": "end" }
      ]
    },
    {
      "id": "director",
      "type": "APPROVAL",
      "assignee": "USER:fixed_director_id",
      "next": "end"
    },
    { "id": "end", "type": "END" }
  ]
}

每个节点就是一个对象:type 决定行为,next 指向下一个节点,条件节点用 branches 数组按顺序匹配。存数据库一条记录,比 BPMN 的 XML 解析省掉一整层复杂度。

三张表跑全程

不需要 Flowable 那二三十张 ACT_ 表,三张就够:

CREATE TABLE wf_instance (
    id          BIGINT PRIMARY KEY AUTO_INCREMENT,
    definition  VARCHAR(64)  NOT NULL,
    current_node VARCHAR(64) NOT NULL,
    variables   JSON,
    status      ENUM('RUNNING','COMPLETED','TERMINATED') DEFAULT 'RUNNING',
    applicant   VARCHAR(64)  NOT NULL,
    created_at  DATETIME     DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE wf_task (
    id          BIGINT PRIMARY KEY AUTO_INCREMENT,
    instance_id BIGINT       NOT NULL,
    node_id     VARCHAR(64)  NOT NULL,
    assignee    VARCHAR(64)  NOT NULL,
    action      ENUM('PENDING','APPROVED','REJECTED') DEFAULT 'PENDING',
    comment     VARCHAR(512),
    operated_at DATETIME
);

CREATE TABLE wf_log (
    id          BIGINT PRIMARY KEY AUTO_INCREMENT,
    instance_id BIGINT       NOT NULL,
    node_id     VARCHAR(64)  NOT NULL,
    operator    VARCHAR(64),
    action      VARCHAR(32),
    comment     VARCHAR(512),
    created_at  DATETIME     DEFAULT CURRENT_TIMESTAMP
);

wf_instancecurrent_node 字段就是那个 token——永远指向流程当前停留的节点。variables 用 JSON 字段存表单数据,条件分支时直接拿来求值。

引擎核心:一个 advance 方法

整个引擎的核心逻辑就是一个递归推进方法——查当前节点,决定下一步,更新 token 位置:

public void advance(Long instanceId, String action, String operator) {
    WfInstance inst = instanceMapper.selectById(instanceId);
    NodeDef current = loadNode(inst.getDefinition(), inst.getCurrentNode());

    switch (current.getType()) {
        case APPROVAL:
            handleApproval(inst, current, action, operator);
            break;
        case CONDITION:
            String nextId = evaluateCondition(current, inst.getVariables());
            moveTo(inst, nextId);
            advance(instanceId, null, null);
            break;
        case END:
            inst.setStatus("COMPLETED");
            instanceMapper.updateById(inst);
            return;
        default:
            moveTo(inst, current.getNext());
            advance(instanceId, null, null);
    }
}

private void handleApproval(WfInstance inst, NodeDef node,
                            String action, String operator) {
    if ("APPROVED".equals(action)) {
        writeLog(inst, node, operator, action);
        moveTo(inst, node.getNext());
        advance(inst.getId(), null, null);
    } else if ("REJECTED".equals(action)) {
        writeLog(inst, node, operator, action);
        inst.setStatus("TERMINATED");
        instanceMapper.updateById(inst);
    }
}

private void moveTo(WfInstance inst, String nodeId) {
    inst.setCurrentNode(nodeId);
    instanceMapper.updateById(inst);
    NodeDef next = loadNode(inst.getDefinition(), nodeId);
    if ("APPROVAL".equals(next.getType())) {
        String assignee = resolveAssignee(next, inst);
        createTask(inst.getId(), nodeId, assignee);
    }
}

条件分支走到 CONDITION 节点时不停留,直接求值后递归推进到下一个节点。只有 APPROVAL 节点会停下来等人操作。驳回这里直接终止流程,如果需要打回到发起人重新提交,改成 moveTo(inst, "start") 的下一个节点就行。

条件表达式求值

条件分支需要一个表达式引擎。Java 自带的 ScriptEngine 可以跑 JavaScript 表达式,把流程变量注入进去就能用:

private String evaluateCondition(NodeDef node, Map<String, Object> variables) {
    ScriptEngine engine = new ScriptEngineManager().getEngineByName("js");
    Bindings bindings = engine.createBindings();
    bindings.putAll(variables);

    for (Branch branch : node.getBranches()) {
        Boolean result = (Boolean) engine.eval(branch.getExpr(), bindings);
        if (Boolean.TRUE.equals(result)) {
            return branch.getNext();
        }
    }
    throw new IllegalStateException("没有匹配的分支,检查流程定义");
}

如果项目里已经有 Spring,用 SpEL 也行,写法更贴近 Java。

这个方案的边界

这套轻量实现能覆盖大部分中小项目的审批场景:串行审批、条件分支、固定人/角色指定。加上会签也不难——在 APPROVAL 节点支持多人 assignee,加一个计数器判断是否全部通过。

但碰到这几种需求就该考虑上 Flowable 了:并行网关(多条路径同时执行再汇聚)、子流程嵌套、流程定义的热迁移(在途实例切新版本)、可视化流程追踪。这些功能自己写的话,复杂度会指数级上升。

容易踩的坑

流程定义的版本管理:修改流程定义后,已经在跑的实例还是按旧版本走。如果想让在途实例切到新版本,需要做流程迁移(process migration),这是个高风险操作,建议在测试环境充分验证。

会签的完成条件:Flowable 里会签用多实例(multi-instance)实现,完成条件是一个表达式。写错了会导致任务永远无法汇聚,或者一个人同意就直接通过。上线前务必用边界 case 测:0 人完成、1 人完成、全部完成。

审批人为空:表达式返回 null 或者角色下没有挂人,引擎会抛异常,流程卡死。需要在节点上配一个兜底策略——要么指定默认审批人,要么触发异常处理子流程通知管理员。

驳回到哪:BPMN 规范里没有”驳回”这个概念,驳回本质上是”跳转到历史节点”。Flowable 提供了 runtimeService.createChangeActivityStateBuilder() 来做节点跳转,但跳转目标必须是当前实例走过的节点,否则会报错。如果流程里有并行网关,驳回到网关之前的节点会导致 token 混乱,需要额外处理。

性能:高并发场景下,引擎的历史表增长很快。ACT_HI_VARINST(历史变量表)尤其容易膨胀,因为每个流程变量的每次变更都会记一条。定期归档历史数据,或者关闭不需要的历史记录级别。