ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

OA办公自动化系统源码:可视化审批流程引擎设计与实现

OA办公自动化系统源码:可视化审批流程引擎设计与实现

简介:这套OA系统源码面向希望研究办公自动化架构、二次开发审批流程的开发者与团队,提供可自行定制审批环节的完整实现,适合用于请假、报销等业务请求的电子化流转场景,也可作为学习Web端与移动端协同开发的参考项目。zip压缩包共2000个文件,约43.54MB,涵盖aspx页面、cs源码、js与css前端资源、gif/png/jpg图片素材、dll程序集、doc说明文档以及db/mdb/mdf等数据库文件,兼顾界面、业务逻辑与数据访问等层面。已有6933人学习,说明其在OA与审批流程主题下具有一定参考价值。读者可从中梳理移动端与Web端应用、业务逻辑层、数据库工具类库及公共模块的组织方式,理解审批规则如何落到代码与数据表中,并基于现有结构修改流程节点、表单和权限配置,减少从零搭建框架的成本。

1. 从一张三天的事假单说起:为什么流程要能从代码里移出来

一张三天的事假单,在不少团队里要走五轮签字:直属主管、部门负责人、人事、财务、分管领导。任何一环出差,单子就躺在某人的待办里过夜,发起人只能天天刷新页面。行政把这类抱怨归成一句话——流程改不动。这也是 OA 办公自动化系统最容易被低估的地方:真正决定好不好用的,不是菜单栏堆了多少功能,而是审批流程能不能由业务方自己画、自己改、自己上线。一套完整的 OA 系统源码,核心价值就集中在这一层,它把流程定义从 Java 的 if-else 里抽出来,变成可视化的节点和连线,配置人员在页面上拖几下,就能配出「请假天数大于 3 走总经理」这样的分支。下面按需求、选型、总体设计、模块实现、避坑、验收的路径,把这套源码拆到能直接上手改的程度,做毕设的同学可以照着搭结构,要交付的内部系统也能拿它当底稿。

2. 需求拆解与技术选型:OA 系统绕不开的四个真问题和三条实现路线

2.1 四个真问题:组织、表单、流程、留痕

OA 的需求看着杂,真正硬的只有四块,其余都是这四块的组合。

第一块是组织架构。部门树、岗位、人员、汇报关系,任何一条审批规则最终都要落到「谁是发起人的直属主管」这种问题上。组织数据没理顺,流程配得再漂亮也流转不下去。源码里通常会有sys_dept、sys_user、sys_role三张基础表,外加一张sys_user_role关联表,部门表用parent_id加ancestors字段存路径,这样查某个部门的所有下级只需要一次LIKE '0,1,5,%',不用递归。

第二块是表单。请假单、报销单、用章申请,字段各不相同。硬编码意味着每加一种单据就改一次代码,所以成熟的 OA 会把表单也做成可配置的:字段名、类型、是否必填、校验规则存成 JSON,业务数据单独存一份 JSON 到业务表,两边用form_id关联。这样新增一张单据只是新增一条表单记录,不动代码。

第三块是流程,也是这套源码的重点。流程要能表达顺序审批、条件分支、会签、或签、抄送、驳回、撤回,还要支持流程运行中途换人。这些能力不能靠散落在 Service 里的 if 判断堆出来,得有一个独立的流程引擎层。

第四块是留痕。谁在什么时间、以什么身份、对哪个节点做了什么操作,必须有一条不可改的记录。合规审计要看,出问题时排查也要看。审批记录表通常只增不改,字段包括流程实例、节点、操作人、动作类型(通过/驳回/转办/加签)、意见、时间。

这四块拆清楚,后面的表结构和模块划分基本就定死了。

2.2 技术选型:为什么流程逻辑不该写进业务代码

见过不少课设级的 OA,请假审批是这么写的:Service 里一串if (days > 3) { leaderApprove(); } else { managerApprove(); }。一开始只有一种单据,看着挺清爽;等到要加报销、加班、用章,每个业务类里都复制一遍类似的判断,改一次审批规则要动五六个文件,测试也没法覆盖全。这就是典型的把流程逻辑和业务逻辑糊在一起。

合理的分层是:业务层只管「这张单据的数据对不对」,流程层只管「下一步该谁审」,两层通过biz_id和proc_inst_id双向关联。业务层提交时调一次startProcess(bizId, procKey, vars),流程层返回实例 ID;流程每推进一步,回调业务层更新单据状态。中间靠流程变量(vars)传递判断依据,比如请假天数、报销金额。

技术栈上,企业级 OA 源码常见的组合是 Spring Boot + MyBatis-Plus + MySQL + Redis 做后端,Vue 3 + Element Plus 做前端,流程图用自绘 SVG 或开源画布库。Redis 一般用来缓存流程定义(发布后基本不变,读多写少)和待办数量角标。如果你手上的源码用的是别的组合,比如 .NET 或者 Python 的 FastAPI,下面讲的模型和表结构一样能套,只是语法换一下。

提示:选型时优先看流程定义怎么存、怎么解析,而不是看前端界面有多花哨。界面可以重写,引擎重写成本高一个量级。

2.3 三条实现路线:硬编码、集成开源引擎、自研轻量引擎

市面上做审批流程,绕不开这三种做法,各自的适用面差别很大。

路线典型做法优点代价
硬编码Service 里写 if-else 和状态字段上手最快,两三天能跑通一条线加一种单据改一次代码,流程无法由业务方维护
集成开源引擎引入 Flowable / Activiti / Camunda,用 BPMN 2.0 建模功能全,会签、网关、子流程都现成学习曲线陡,BPMN 概念多,改一次流程要重新部署,二次开发容易被框架牵着走
自研轻量引擎流程定义存 JSON,节点+连线,自己写流转计算模型简单,业务方能看懂,改流程只改数据复杂场景(并行网关嵌套、动态子流程)要自己补

对课程设计、毕业设计、中小型内部 OA 来说,第三条路性价比最高。BPMN 2.0 那套规范有两百多页,真正用到的其实就七八个元素,为了这几个元素去啃整个框架,投入产出不成比例。自研轻量引擎的边界很清楚:节点类型控制在开始、审批、条件分支、抄送、结束五种,多人审批用「会签/或签」两个模式表达,条件用表达式而不是网关图形。这套约束足够覆盖 90% 的行政办公场景,剩下的 10% 用「加签」和「转办」人工兜底。

2.4 拿到源码先看哪几个目录

一份结构完整的 OA 源码,后端一般能按四层切开,找目录时按这个顺序看效率最高。

controller层放接口,重点看ProcessDefController(流程定义的增删改查和发布)和TaskController(待办、已办、审批动作)。service层是业务编排,ProcessInstService.start()和TaskService.complete()是两个入口。engine层是纯计算,不该依赖任何业务表,输入流程定义和变量,输出下一批节点,这一层最适合写单元测试。mapper层管持久化。

前端对应看三个页面:流程设计器(拖拽画布)、表单设计器(字段配置)、任务中心(待办列表和审批弹窗)。流程设计器导出的 JSON 结构,就是后端引擎的唯一输入,两边必须对齐——这一点后面第 5 章会展开讲,是踩坑重灾区。

3. 流程引擎总体设计:节点、连线、表单组成的三层模型与七张表

3.1 三层模型:定义层、实例层、任务层

把流程想成一棵会走的树,三层模型就很好理解。

定义层是「模板」,描述这个流程长什么样:有哪些节点、节点之间怎么连、每个节点由谁审、什么条件下走哪条线。定义层一旦发布就不该再改,改就是新版本。

实例层是「一次具体的运行」,描述某张请假单从发起到归档的整个过程:当前停在哪、走到第几步、状态是审批中还是已结束、发起人是谁、关联哪张业务单据。一个流程定义可以对应成千上万个实例。

任务层是「某一刻某个人手上的活」,一条待办就是一条任务记录。会签节点上有三个人要审,就有三条任务记录,全部通过后实例才往下走。这一层是查询待办列表、统计工作量、做超时提醒的数据来源。

三层分开的好处是查询互不干扰:待办列表只查任务层,配了(assignee_id, status, arrive_time)联合索引后,几万条数据翻页也是毫秒级;流程监控页面查实例层;流程设计页面只碰定义层。

3.2 七张表支撑一条审批链

核心表大概七张,缺一张就会有功能补不上。

-- 流程定义:同一个 proc_key 允许多版本并存,发布时锁版本 CREATE TABLE oa_process_def ( id BIGINT PRIMARY KEY AUTO_INCREMENT, proc_key VARCHAR(64) NOT NULL COMMENT '流程标识,如 leave_apply', proc_name VARCHAR(128) NOT NULL COMMENT '流程名称', version INT NOT NULL DEFAULT 1 COMMENT '版本号,只增不减', status TINYINT NOT NULL DEFAULT 0 COMMENT '0草稿 1已发布 2已停用', form_id BIGINT NOT NULL COMMENT '绑定的表单模板', graph_json LONGTEXT NOT NULL COMMENT '节点与连线的完整快照', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_key_ver (proc_key, version) ) COMMENT '流程定义'; -- 流程实例:一张业务单据对应一条 CREATE TABLE oa_process_inst ( id BIGINT PRIMARY KEY AUTO_INCREMENT, proc_key VARCHAR(64) NOT NULL, def_version INT NOT NULL COMMENT '启动时冻结的版本号', biz_id BIGINT NOT NULL COMMENT '业务单据主键', biz_title VARCHAR(255) NOT NULL COMMENT '冗余标题,待办列表直接展示', originator BIGINT NOT NULL COMMENT '发起人', cur_node VARCHAR(64) NULL COMMENT '当前停留节点', status TINYINT NOT NULL DEFAULT 1 COMMENT '1审批中 2已通过 3已驳回 4已撤回', start_time DATETIME NOT NULL, end_time DATETIME NULL, KEY idx_originator (originator, start_time), KEY idx_biz (biz_id) ) COMMENT '流程实例'; -- 任务实例:会签节点上人手一条 CREATE TABLE oa_task_inst ( id BIGINT PRIMARY KEY AUTO_INCREMENT, inst_id BIGINT NOT NULL, node_id VARCHAR(64) NOT NULL, node_name VARCHAR(128) NOT NULL, assignee_id BIGINT NOT NULL COMMENT '处理人', status TINYINT NOT NULL DEFAULT 0 COMMENT '0待办 1已通过 2已驳回 3已转办 4已跳过', arrive_time DATETIME NOT NULL, finish_time DATETIME NULL, KEY idx_assignee (assignee_id, status, arrive_time), KEY idx_inst_node (inst_id, node_id, status) ) COMMENT '任务实例'; -- 审批记录:只增不改,审计和流程图回放都靠它 CREATE TABLE oa_approve_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, inst_id BIGINT NOT NULL, task_id BIGINT NULL, node_id VARCHAR(64) NOT NULL, operator BIGINT NOT NULL, action VARCHAR(16) NOT NULL COMMENT 'PASS/REJECT/TRANSFER/ADD_SIGN/REVOKE', comment VARCHAR(500) NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_inst (inst_id, create_time) ) COMMENT '审批记录';

剩下三张是表单模板oa_form_def、抄送记录oa_copy_send、附件表oa_attachment。oa_task_inst上的两个索引别省:idx_assignee撑起「我的待办」查询,idx_inst_node撑起会签节点的剩余人数统计。少了后者,会签判断就得全表扫,节点一多直接拖慢整个审批动作。

注意:biz_title这类冗余字段在范式上不干净,但待办列表每天要被查几千次,每次都去 join 业务表不划算。冗余的代价是业务单据改名时要同步更新,用事件回调处理即可。

3.3 流程定义存 JSON 还是拆关系表

定义层有两种存法:一种是把节点、连线拆成oa_process_node、oa_process_edge两张表,另一种是整棵图序列化成 JSON 存一个字段。

拆表的好处是能按节点查、能做统计(比如「哪些流程用了部门负责人审批」),坏处是一次读取要 join 两次再在内存里拼树,流程一复杂查询就变慢,前端画布加载时会明显卡一下。

JSON 的好处是一次读取拿到全图,前端直接渲染,后端解析也快;坏处是数据库层面没法对单个节点做查询。

实际项目里我一般选 JSON,理由很直接:流程定义是「整体读、整体写」的对象,从来不会出现「只更新第 3 个节点的名字」这种需求,拆表反而制造了不必要的复杂度。真要按节点统计,把关键信息在发布时抽一份到索引表就够了。

JSON 的结构大致长这样:

{ "key": "leave_apply", "nodes": [ {"id": "start", "type": "start", "name": "发起"}, {"id": "n1", "type": "approve", "name": "直属主管", "assigneeRule": "DEPT_LEADER", "multiMode": "SINGLE"}, {"id": "n2", "type": "approve", "name": "部门负责人", "assigneeRule": "ROLE", "assigneeValue": "dept_manager", "multiMode": "ALL"}, {"id": "n3", "type": "approve", "name": "总经理", "assigneeRule": "USER", "assigneeValue": "10086", "multiMode": "SINGLE"}, {"id": "end", "type": "end", "name": "归档"} ], "edges": [ {"from": "start", "to": "n1"}, {"from": "n1", "to": "n2"}, {"from": "n2", "to": "n3", "condition": "days > 3"}, {"from": "n2", "to": "end", "condition": "days <= 3"}, {"from": "n3", "to": "end"} ] }

nodes数组里每个元素的type决定引擎怎么处理它:approve要解析审批人并生成任务,end直接把实例置为已通过。edges数组是邻接关系,condition为空表示无条件流转。multiMode只有两个取值,SINGLE是或签(一人通过即过),ALL是会签(全部通过才过)。把会签和或签压缩成一个字段而不是两种节点类型,配置界面能少一个下拉框,引擎判断也少一个分支。

assigneeRule是审批人解析规则,这是整个引擎里最容易被写歪的地方,下一节单独说。

3.4 审批人规则的六种解析方式

审批人不能直接写死用户 ID,否则人员一离职流程就断。常见做法是存「规则 + 参数」,运行时解析成具体的人。

第一类是固定人,assigneeRule = USER,参数是用户 ID 列表。适合总经理、财务负责人这种岗位稳定的场景。第二类是角色,ROLE,参数是角色编码,解析成该角色下所有启用的人。第三类是部门主管,DEPT_LEADER,从发起人所在部门往上找,取leader_id。第四类是发起人自选,SELF_SELECT,发起时在表单里选人,选完的结果写进流程变量。第五类是发起人本人,INITIATOR,用在「确认」类节点。第六类是上级的上级,LEADER_LEVEL加层级参数,用于金额特别大的场景。

解析入口统一成一个方法,输入规则和流程变量,输出人员 ID 列表:

public List<Long> resolve(String rule, String value, Map<String, Object> vars) { switch (rule) { case "USER": // 固定人:value 是逗号分隔的用户 ID return Arrays.stream(value.split(",")).map(Long::valueOf).collect(Collectors.toList()); case "ROLE": // 角色:取该角色下所有启用状态的人 return userRoleMapper.selectUserIdsByRoleCode(value); case "DEPT_LEADER": // 部门主管:先拿发起人部门,再取部门负责人 Long deptId = userMapper.selectDeptId(Long.valueOf(vars.get("originator").toString())); return Collections.singletonList(deptMapper.selectLeaderId(deptId)); case "INITIATOR": return Collections.singletonList(Long.valueOf(vars.get("originator").toString())); case "SELF_SELECT": return (List<Long>) vars.get("node_" + value + "_assignees"); default: throw new ProcessException("未知的审批人规则:" + rule); } }

参数说明上,vars里至少要有originator,自选节点还需要对应的node_xxx_assignees键。返回空列表时不要静默跳过,第 5 章会讲为什么。

4. 核心模块实现:从流程定义解析到待办任务生成并写入数据库的完整链路

4.1 发布前的定义校验:把错误挡在配置阶段

流程设计器允许用户随便连线,配出「审批节点没有任何出边」「条件分支两条线条件一模一样」这种结构,跑起来就是死循环或者走错路。发布接口里必须先做一轮校验,把问题挡在配置阶段,而不是等业务人员提交单据时才报错。

public void validate(ProcessDef def) { // 1. 必须有且只有一个 start、至少一个 end long startCount = def.getNodes().stream().filter(n -> "start".equals(n.getType())).count(); long endCount = def.getNodes().stream().filter(n -> "end".equals(n.getType())).count(); if (startCount != 1) throw new ProcessException("开始节点必须且只能有一个"); if (endCount == 0) throw new ProcessException("至少需要一个结束节点"); // 2. 除 end 外,每个节点至少有一条出边 for (NodeDef n : def.getNodes()) { if ("end".equals(n.getType())) continue; if (def.edgesOf(n.getId()).isEmpty()) { throw new ProcessException("节点【" + n.getName() + "】没有后续节点"); } } // 3. 审批节点必须配了审批人规则 for (NodeDef n : def.getNodes()) { if ("approve".equals(n.getType()) && StringUtils.isBlank(n.getAssigneeRule())) { throw new ProcessException("节点【" + n.getName() + "】未配置审批人"); } } // 4. 从 start 做一次可达性遍历,孤立节点直接拒绝 Set<String> reachable = new HashSet<>(); Deque<String> stack = new ArrayDeque<>(Collections.singletonList("start")); while (!stack.isEmpty()) { String cur = stack.pop(); if (!reachable.add(cur)) continue; def.edgesOf(cur).forEach(e -> stack.push(e.getTarget())); } for (NodeDef n : def.getNodes()) { if (!reachable.contains(n.getId())) { throw new ProcessException("节点【" + n.getName() + "】不可达,检查连线"); } } }

这段校验的关键在第四步。可视化的画布很容易拖出一个悬空节点,用户以为删掉了,其实只是挪到画布外面,nodes数组里还留着。可达性遍历能从根上发现这类脏数据,比让它在运行时报 NPE 强得多。校验通过后,status从 0 改成 1,version加 1,同时把graph_json完整复制一份到版本记录里——这一条是第 5 章第一个坑的解药。

4.2 流转计算:下一个节点是谁

引擎最核心的一个方法,输入当前节点和流程变量,输出下一批可执行节点。

public List<NodeDef> nextNodes(ProcessDef def, String current, Map<String, Object> vars) { List<NodeDef> result = new ArrayList<>(); List<EdgeDef> edges = def.edgesOf(current); for (EdgeDef edge : edges) { // 无条件连线直接通过 if (StringUtils.isBlank(edge.getCondition())) { result.add(def.node(edge.getTarget())); continue; } // 有条件则求值,false 直接跳过这条线 if (evaluator.eval(edge.getCondition(), vars)) { result.add(def.node(edge.getTarget())); } } // 条件分支必须命中且只命中一条,命中 0 条或多条都说明配置有问题 if (def.isBranch(current) && result.size() != 1) { throw new ProcessException("节点【" + def.node(current).getName() + "】分支命中 " + result.size() + " 条,请检查条件"); } return result; }

def是发版时冻结的定义快照,current是节点 ID,vars是流程变量(请假天数、报销金额、发起人部门等)。返回的列表长度在普通节点上一般是 1,在并行场景下可能大于 1。isBranch用来标记条件节点,这类节点走「恰好一条」的强约束,配错了立刻抛异常,而不是悄悄选第一条——静默选错在审批系统里是致命的,单据会流向错误的审批人,事后很难解释。

条件表达式建议用 Aviator 或 SpEL 这类沙箱求值器,别用 Java 的ScriptEngine。求值器要加编译缓存,同一条表达式只编译一次,否则每个审批动作都要重新解析字符串,压测时能看到明显开销。

@Component public class ExpressionEvaluator { // 编译缓存:表达式数量有限,缓存命中率接近 100% private final Map<String, Expression> cache = new ConcurrentHashMap<>(); public boolean eval(String exp, Map<String, Object> vars) { try { Expression compiled = cache.computeIfAbsent(exp, e -> AviatorEvaluator.compile(e, true)); return Boolean.TRUE.equals(compiled.execute(vars)); } catch (Exception ex) { // 变量缺失、类型不匹配都会走到这里,包装成业务异常方便前端提示 throw new ProcessException("条件表达式求值失败:" + exp + "," + ex.getMessage()); } } }

AviatorEvaluator.compile(e, true)的第二个参数开启缓存。求值失败一定要包成业务异常并带上表达式原文,否则用户在页面上只会看到一个 500,根本不知道是哪个条件写错了。

4.3 会签、或签与回退的判定

会签节点收到审批动作后,不能直接推进流程,得先看这个节点上还有没有人没审。

@Transactional(rollbackFor = Exception.class) public TaskResult complete(Long taskId, Long userId, String action, String comment) { // 1. CAS 抢占任务:带 status=0 条件更新,防并发重复提交 int updated = taskMapper.finish(taskId, userId, action, comment); if (updated == 0) { throw new ProcessException("任务已被处理,或不属于当前用户"); } TaskInst task = taskMapper.selectById(taskId); ProcessInst inst = instMapper.selectById(task.getInstId()); NodeDef node = defCache.get(inst).node(task.getNodeId()); // 2. 驳回:整条实例直接结束,不走后续节点 if ("REJECT".equals(action)) { instMapper.close(inst.getId(), 3); return TaskResult.rejected(); } // 3. 或签:一人通过即可,其余待办置为已跳过 if ("SINGLE".equals(node.getMultiMode())) { taskMapper.skipOthers(inst.getId(), node.getId(), taskId); } // 4. 会签:统计本节点剩余待办数,还有人就停在这 int remain = taskMapper.countPending(inst.getId(), node.getId()); if (remain > 0) { return TaskResult.pending(node.getId()); } // 5. 本节点全部通过,推进到下一批节点 List<NodeDef> next = engine.nextNodes(defCache.get(inst), node.getId(), inst.getVars()); advance(inst, next); return TaskResult.advanced(next); }

几个细节值得说。第一步的 CAS 更新是整段代码里最关键的一行,finish的 SQL 里带AND status = 0 AND assignee_id = #{userId},两个人同时点通过时只有一个能更新成功,另一个拿到 0 直接报错。少了这一行,会签节点上可能出现同一个人被算两次通过,或者流程被推进两次。

第三步的skipOthers是把同节点其他待办的状态改成 4(已跳过),不是删掉。删掉的话审批记录就断了,回放流程图时会缺一块。

第五步的advance要处理一批节点:普通节点生成任务、结束节点关闭实例、条件节点继续递归往下算。递归深度要设上限,防止配错的自环结构把栈打爆。

4.4 待办、已办、抄送三类列表的查询实现

任务中心页面基本是三张列表,SQL 各有各的讲究。

-- 我的待办:处理人是我 + 任务未办 + 实例还在审批中 SELECT t.id AS task_id, t.node_name, t.arrive_time, i.biz_title, i.originator, i.start_time FROM oa_task_inst t JOIN oa_process_inst i ON i.id = t.inst_id WHERE t.assignee_id = #{userId} AND t.status = 0 AND i.status = 1 ORDER BY t.arrive_time DESC LIMIT #{offset}, #{size}; -- 我的已办:按完成时间倒序,超过三个月的走归档表 SELECT t.id AS task_id, t.node_name, t.finish_time, r.action, r.comment, i.biz_title FROM oa_task_inst t JOIN oa_process_inst i ON i.id = t.inst_id LEFT JOIN oa_approve_record r ON r.task_id = t.id WHERE t.assignee_id = #{userId} AND t.status IN (1, 2, 3) AND t.finish_time >= #{beginTime} ORDER BY t.finish_time DESC LIMIT #{offset}, #{size};

第一条完全命中idx_assignee(assignee_id, status, arrive_time),从等值条件一路走到排序字段,不用回表排序。第二条的finish_time范围条件放在status IN之后,索引会退化成部分扫描,所以加了时间下限——已办列表默认只查最近三个月,更早的走归档查询,这是我在数据量上到百万级之后才补上的约束。

抄送不占待办,单独一张oa_copy_send表,inst_id+receiver_id+read_flag。抄送记录在流程到达该节点时批量插入,不参与流转判定,所以查询最简单,也最容易被忽略索引——(receiver_id, read_flag, create_time)建上就行。

4.5 撤回、转办、加签的状态处理

这三个动作不属于「正常流转」,但实际使用频率很高,做不好会直接引起投诉。

撤回的约束是:只能撤回自己发起的、且当前节点还没有人处理的实例。实现上判断oa_task_inst里该实例是否存在status = 1的记录,存在就拒绝,然后把所有status = 0的任务置为已撤回,实例状态改成 4。已有人审批过的不能撤回,这是业务规则,不是技术限制,写死在 Service 里更安全。

转办是把一条待办的处理人从 A 换成 B,任务记录不新增,只改assignee_id,同时在oa_approve_record里插一条TRANSFER记录写明原处理人和新处理人。加签正相反,是在当前节点新增一条任务,原来的任务保持不动,会签的剩余人数统计自然会把新人算进去——这也是为什么countPending要按(inst_id, node_id, status=0)统计,而不是用一个计数器字段。

用记录统计而不用计数器,是这套设计里我最坚持的一点。计数器在加签、转办、回退三种操作下都有可能算错,一旦算错,流程就永久卡死在某个节点,只能人工改数据库。用COUNT(*)虽然每次多一次查询,但这个查询走idx_inst_node,成本可以忽略。

5. 避坑与排查:审批流程上线前最容易出问题的五个地方

5.1 流程定义改完,在途实例全乱

现象:流程上线一周后,业务方要求把「部门负责人」节点改成「分管领导」,配置人员在设计器里直接改掉并保存。改完之后,所有还在审批中的单据,页面上的流程图变了,但待办还在原来那个人手上;有的单据走到下一节点时直接报「节点不存在」。

原因:实例启动时只记了proc_key,没记version,运行时每次都用最新版本的graph_json去解析节点。定义一改,旧实例的node_id在新图里找不到对应节点,流转计算就断了。

解决:实例表加def_version字段,启动时冻结版本号;每次流转都按(proc_key, version)取定义快照,取不到就从缓存里回源,绝不读最新版本。发布新版本时把旧版本的graph_json原样保留,status只在有在途实例时保持可用。这条约束落地后,改流程就变成了「新单走新流程、老单走老流程」,业务方也能接受。

5.2 审批人解析结果为空,流程直接卡死

现象:某个部门的负责人离职,岗位空缺了一周。期间该部门提交的请假单,走到主管节点后没有任何人收到待办,发起人看到的流程图停在「直属主管」,状态一直是审批中。

原因:resolve方法返回了空列表,引擎把空列表当成「这个节点没有人要审」,既不生成任务也不报错,实例就停在那里。

解决:解析结果为空时必须走预设策略,不能静默。策略一般三种:跳过该节点直接进下一节点、转给流程管理员、直接抛异常让发起人看到提示。我倾向于第三种——静默跳过会让审批链少一环,事后追责时说不清楚;抛异常虽然体验差一点,但问题当场暴露。上线前把部门负责人字段设为必填,再加一条定时巡检,扫出「无负责人部门」推给管理员,基本能根治。

5.3 会签节点驳回后重新提交,通过人数算错

现象:会签节点三个人,A 通过、B 驳回,流程退回发起人。发起人改完数据重新提交,再次到达该节点,此时只需要两个人通过,流程就往下走了,C 从头到尾没看到这条单子。

原因:用了一个approved_count字段记录通过人数,驳回时只把状态改回去,没有清零计数;重新提交时在同一批任务记录上继续累加,数字早就超了。

解决:彻底删掉计数器字段,改成每次判断都实时COUNT(status = 0)。同时约定驳回后重新提交不复用旧任务记录,而是为该节点重新生成一批任务,旧记录保留但标记归档。这样既有完整的历史轨迹,又不会互相污染。代价是任务表数据量翻倍,用按月分表或者定期归档解决,比算错人数划算得多。

5.4 条件表达式被当成普通字符串拼接

现象:条件分支上配了amount > 5000,某次提交的单据里amount是空值,接口直接返回 500,前端弹出一个没有信息量的错误提示;另一次,有人在表单备注里写了段奇怪的文本,提交后条件求值报语法错误。

原因:变量没有默认值,表达式求值时取到null参与比较;更严重的情况是把用户输入的内容拼进了表达式字符串,等于把求值器当成了执行入口。

解决:两条线一起做。求值前给流程变量兜底,数值型默认 0,字符串型默认空串,日期型默认当前时间,杜绝 null 参与比较。表达式本身只允许配置人员从设计器里选变量、选运算符生成,不接受自由文本输入,变量名走白名单校验,只放行[a-zA-Z_][a-zA-Z0-9_]*这种格式。求值器选沙箱模式的,别用能访问系统类的实现。

5.5 两个人同时点通过,流程走了两遍

现象:或签节点上配了两个人,测试时两人几乎同时点了「通过」,结果流程直接跳过了下一个审批节点,事后查审批记录,两条通过记录时间戳只差几十毫秒。

原因:判断「剩余待办数」和「推进流程」这两步之间没有原子性。两个请求各自查到剩余 1 条,各自认为自己是最后一个,于是都执行了advance。

解决:推进动作要加分布式锁,锁的粒度是流程实例 ID,不是节点 ID——同一实例的任何推进都必须串行。同时任务完成用带状态条件的 CAS 更新,UPDATE ... WHERE id = ? AND status = 0,影响行数为 0 直接拒绝。两层保险叠上,压测时把并发打到 50 也没再复现。

6. 验收与答辩:用一条端到端流程证明系统真的能跑

流程引擎这类东西,演示时最怕只截几张配置界面就说「功能已完成」。评审的人想看的是:一条真实的单据,从发起到归档,中间经历条件分支、会签、驳回重提,每一步的状态都对得上。所以验收环节我建议直接把一条完整链路跑一遍,边跑边对照数据。

先准备一条覆盖度足够高的流程:请假申请,天数小于等于 3 天走「直属主管 → 归档」,大于 3 天走「直属主管 → 部门负责人(会签,两人)→ 总经理 → 归档」。这条线同时压到了条件分支、会签、多人节点,覆盖率比配五条简单流程还高。

演示顺序按下面这张表走,每一步都要打开数据库核对状态:

步骤操作预期状态核对点
1发起 2 天请假实例 status=1,当前节点 n1任务表新增 1 条,assignee 是直属主管
2主管通过实例 status=2,结束时间非空无新任务生成,审批记录 1 条 PASS
3发起 5 天请假实例 status=1,当前节点 n1条件变量 days=5 已写入实例
4主管通过当前节点 n2,任务表新增 2 条会签节点按角色解析出 2 人
5第一位会签人通过实例仍停在 n2剩余待办数=1,流程未推进
6第二位会签人驳回实例 status=3两条任务分别为 1 和 2
7发起人修改后重提实例 status=1,回到 n1为 n1 重新生成任务,旧记录归档
8走完全部节点实例 status=2,当前节点 end审批记录条数与操作次数一致

第 5 步和第 6 步是重点。很多人演示会签时只让一个人点通过就完事,恰恰漏掉了「剩余人数」这个最核心的判定。第 7 步的驳回重提更能看出设计功底——如果重提之后旧任务被覆盖,历史轨迹断了,评审一问「上一次是谁驳回的」就答不上来。

答辩时还有两个问题出现频率很高,提前想清楚怎么答。一个是「为什么不用 Flowable」,答成本:BPMN 的元素有几十个,业务方学不会,我们只保留五个节点类型,配置界面一屏放得下,业务方培训半小时就能自己改流程。另一个是「流程定义改了怎么办」,答版本冻结,然后现场演示改一版流程,再打开一个旧单据,指出它的流程图还是老样子、待办还在原来的人手上——这一手比讲十分钟架构有说服力。

如果时间充裕,再加一个数据核对脚本,跑完演示后自动比对实例状态、任务条数、审批记录条数三者是否自洽:

-- 校验:已结束实例的每个审批节点,任务数应等于审批记录数 SELECT i.id, i.proc_key, COUNT(DISTINCT t.id) AS task_cnt, COUNT(DISTINCT r.id) AS rec_cnt FROM oa_process_inst i LEFT JOIN oa_task_inst t ON t.inst_id = i.id AND t.status <> 4 LEFT JOIN oa_approve_record r ON r.inst_id = i.id WHERE i.status IN (2, 3) GROUP BY i.id, i.proc_key HAVING task_cnt <> rec_cnt;

这条 SQL 查不出数据,说明流程状态是自洽的;查出哪怕一条,就得回去看是不是漏写了审批记录。这套校验我现在当成上线前的固定动作。

踩过一次坑之后我养成了习惯:任何流程功能的验收,都不看界面截图,只认这条端到端链路加一次数据自洽核对,跑不通就不算完成。流程图画得再漂亮,一条单据走不完都是空谈。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进