资讯详情 OA办公自动化系统源码:可视化审批流程引擎设计与实现
📅 2026/10/25 16:48:12
简介这套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 ListLong resolve(String rule, String value, MapString, 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 (ListLong) 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 做一次可达性遍历孤立节点直接拒绝 SetString reachable new HashSet(); DequeString 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 改成 1version加 1同时把graph_json完整复制一份到版本记录里——这一条是第 5 章第一个坑的解药。4.2 流转计算下一个节点是谁引擎最核心的一个方法输入当前节点和流程变量输出下一批可执行节点。public ListNodeDef nextNodes(ProcessDef def, String current, MapString, Object vars) { ListNodeDef result new ArrayList(); ListEdgeDef 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是节点 IDvars是流程变量请假天数、报销金额、发起人部门等。返回的列表长度在普通节点上一般是 1在并行场景下可能大于 1。isBranch用来标记条件节点这类节点走「恰好一条」的强约束配错了立刻抛异常而不是悄悄选第一条——静默选错在审批系统里是致命的单据会流向错误的审批人事后很难解释。条件表达式建议用 Aviator 或 SpEL 这类沙箱求值器别用 Java 的ScriptEngine。求值器要加编译缓存同一条表达式只编译一次否则每个审批动作都要重新解析字符串压测时能看到明显开销。Component public class ExpressionEvaluator { // 编译缓存表达式数量有限缓存命中率接近 100% private final MapString, Expression cache new ConcurrentHashMap(); public boolean eval(String exp, MapString, 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 抢占任务带 status0 条件更新防并发重复提交 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. 本节点全部通过推进到下一批节点 ListNodeDef 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_idreceiver_idread_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, status0)统计而不是用一个计数器字段。用记录统计而不用计数器是这套设计里我最坚持的一点。计数器在加签、转办、回退三种操作下都有可能算错一旦算错流程就永久卡死在某个节点只能人工改数据库。用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 天请假实例 status1当前节点 n1任务表新增 1 条assignee 是直属主管2主管通过实例 status2结束时间非空无新任务生成审批记录 1 条 PASS3发起 5 天请假实例 status1当前节点 n1条件变量 days5 已写入实例4主管通过当前节点 n2任务表新增 2 条会签节点按角色解析出 2 人5第一位会签人通过实例仍停在 n2剩余待办数1流程未推进6第二位会签人驳回实例 status3两条任务分别为 1 和 27发起人修改后重提实例 status1回到 n1为 n1 重新生成任务旧记录归档8走完全部节点实例 status2当前节点 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 查不出数据说明流程状态是自洽的查出哪怕一条就得回去看是不是漏写了审批记录。这套校验我现在当成上线前的固定动作。踩过一次坑之后我养成了习惯任何流程功能的验收都不看界面截图只认这条端到端链路加一次数据自洽核对跑不通就不算完成。流程图画得再漂亮一条单据走不完都是空谈。希望帮到你。本文还有配套的精品资源点击获取