OA 流程审批是怎么跑起来的
每个打工人都点过”同意”或”驳回”,但很少有人想过点下去之后发生了什么。一张请假单从提交到归档,中间经过的节点、分支、会签,全靠一套工作流引擎在推。这篇把 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_instance 的 current_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(历史变量表)尤其容易膨胀,因为每个流程变量的每次变更都会记一条。定期归档历史数据,或者关闭不需要的历史记录级别。