资讯动态

BPMN工作流引擎请假审批实战:建模、状态机与驳回撤回

发布时间:2026/10/24 2:34:11 来源:尧图企业网站定制
简介这份「工作流请假实例」面向刚开始接触工作流与Java Web开发的学习者围绕企业请假申请、主管审批、人事记录等典型环节演示如何把业务流程转化为可配置、可追踪的自动化流程并借Struts2的Action、拦截器与JSP视图串联请求处理、业务逻辑和页面展示。压缩包共42个文件、约51KB以xml、jsp、java、properties及class文件为主包含流程与框架配置、登录及申请审批页面、Action与业务代码、字符编码等参数设置目录精炼便于按模块阅读和二次调试。资源已有253人学习适合作为入门样例帮助读者理解工作流建模、Struts2请求流转、中文编码处理以及审批状态推进的基本组织方式并可从配置和代码的对应关系中积累排错与改造思路。1. 需求切入为什么请假审批是工作流项目的第一个练手场「工作流」这个词在课程设计里出现频率极高但真正能跑起来的「请假实例」并不多——交上来的东西大多只有一张流程图加一个空荡荡的待办列表。要验证一套流程引擎有没有用对请假审批是最合适的切入点角色明确申请人、部门经理、分管领导、人事有真实的条件分支请假天数决定要不要多一级审批有可回退的状态驳回、撤回单据字段又足够少不至于把时间全耗在设计表单上。这套方案面向两类人正在做毕设或课设、需要一个能当场演示完整流程的学生以及要给内部系统补一条审批流、不想从引擎源码啃起的开发者。下面的顺序是先把流程建模和数据表定死再落到提交、审批、驳回、撤回四个动作的实现最后给出一份可以照着演的验收清单。2. 流程建模把请假单拆成可执行的 BPMN 节点与条件分支流程建模这一步决定了后面所有代码的写法。很多人上来就画图画完发现审批人写死在节点上换个部门就得改流程图重新部署这时候再回头重构成本就高了。我的做法是先把「谁在什么条件下审批什么」写成一张表再翻译成 BPMN。2.1 节点划分与角色映射从申请人到人事备案的四类参与者一个能交付的请假流程节点不用多六个就够开始事件、部门经理审批、天数判断网关、分管领导审批、人事备案、结束事件。驳回不单独建节点而是从当前审批节点把执行流挪回发起节点这样流程图上只有一条主线答辩时讲起来也清楚。参与者只有四类但落到引擎里要区分「唯一处理人」和「候选人池」两种写法。assignee是唯一处理人任务一创建就绑定到某个人candidateUsers和candidateGroups是候选人池谁都可以看到谁先签收claim谁处理。部门经理这个节点我一般用候选组因为实际场景里正副经理都可能审批写死一个人就是给自己挖坑人事备案同理用固定候选组HR更稳。节点类型处理人来源引擎配置提交申请startEvent当前登录人由接口触发不配处理人部门经理审批userTask申请人所属部门candidateGroups${deptManagerGroup}天数判断exclusiveGateway无表达式网关分管领导审批userTask固定岗位组candidateGroupsLEADER人事备案userTask人事组candidateGroups${hrGroup}结束endEvent无无候选组的字符串建议带业务前缀比如DEPT_MANAGER:1001冒号后面是部门 ID。这样一张任务表就能同时服务多个部门不用给每个部门建一个角色。代价是查询时必须拼字符串所以变量一定要在启动流程时就放进去不能等到审批时再补。2.2 条件网关与流程变量请假天数阈值怎么落到表达式上天数判断是这套流程里唯一的业务分支。常见的翻车点在于变量类型业务表里days是DECIMAL(4,1)因为半天假要记 0.5但流程表达式里如果直接拿小数比大小跨语言序列化时容易出现3.0 3判成 false 这种玄学问题。我的处理是把「半天」当最小单位流程变量只传整数halfDays1 天记 2半天记 1网关里比较halfDays 6等价于「超过 3 天加一级审批」。!-- 天数判断网关halfDays 是整数6 表示 3 天 -- exclusiveGateway iddaysGateway name请假天数判断/ sequenceFlow sourceRefdaysGateway targetRefleaderApprove conditionExpression xsi:typetFormalExpression ![CDATA[${halfDays 6}]] /conditionExpression /sequenceFlow sequenceFlow sourceRefdaysGateway targetRefhrRecord conditionExpression xsi:typetFormalExpression ![CDATA[${halfDays 6}]] /conditionExpression /sequenceFlowconditionExpression里必须用 CDATA 包住否则和会被 XML 解析器当成标签起始符部署时直接报解析错误这个错在日志里只显示行号第一次遇到能盯半天。网关的两条出线条件要互斥且覆盖全集别出现和都有、等于的情况没分支引擎会抛「No outgoing sequence flow could be selected」。如果后面要扩成会签比如三天以上需要两位领导都同意就在 userTask 上加多实例completionCondition写${nrOfCompletedInstances / nrOfInstances 1}表示全票通过写0.5就是过半通过。这一条建议先不做等主线跑通再加否则调试时会分不清是网关的问题还是会签的问题。2.3 流程定义文件与部署key、版本和业务单据号的约定流程定义的 key 一旦上线就别改它是启动实例的唯一入口。我习惯用leave_apply这种纯业务名不带版本号版本交给引擎自己管。同一个 key 每部署一次就会生成一个新版本startProcessInstanceByKey默认启动最新版本而已经在跑的老实例继续走它当初那个版本——这是很多人没意识到的行为改完流程图发现「老单据还是老样子」不是没生效是设计如此。// 部署文件放 resources/processes 下随应用启动自动部署一次 Bean public CommandLineRunner deployProcess(RepositoryService repositoryService) { return args - { String resource processes/leave_apply.bpmn20.xml; long count repositoryService.createDeploymentQuery() .deploymentName(leave-apply).count(); // 已部署过就跳过避免每次重启都产生新版本 if (count 0) { repositoryService.createDeployment() .name(leave-apply) .addClasspathResource(resource) .deploy(); } }; }deploymentName是用来去重的重复部署会让版本号一路涨到几十排查问题时很难判断线上跑的到底是哪一版。生产环境更稳的做法是关掉自动部署改成后台上传 BPMN 文件并记录版本说明出了问题能回滚到指定版本。启动实例时的businessKey一定要传而且要用业务单号而不是主键 ID。原因在于待办列表最终要展示单号给审批人看用businessKey可以直接从引擎表关联出业务数据少一次映射用主键的话跨库或分表后这个关联就断了。3. 数据与状态设计业务表、流程变量与任务查询的对应关系流程引擎自己有一整套ACT_开头的表但那些表是给引擎用的不是给业务用的。业务侧必须有自己的表否则改一个字段就要动引擎表这是绝对不能碰的红线。3.1 业务表与流程实例的解耦主表加留痕表的两层结构主表只存请假单本身的字段外加一个proc_inst_id指回流程实例一个status表示业务状态。特别注意不要在业务表里存task_id——任务是会变的今天在部门经理手上明天就到人事手上了存下来第二天就是脏数据。CREATE TABLE biz_leave ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键, leave_no VARCHAR(32) NOT NULL COMMENT 请假单号同时作为流程 businessKey, applicant_id BIGINT NOT NULL COMMENT 申请人用户ID, dept_id BIGINT NOT NULL COMMENT 申请人所属部门ID, leave_type TINYINT NOT NULL COMMENT 1事假 2病假 3年假 4调休, start_time DATETIME NOT NULL COMMENT 开始时间, end_time DATETIME NOT NULL COMMENT 结束时间, days DECIMAL(4,1) NOT NULL COMMENT 请假天数半天记0.5仅用于展示, reason VARCHAR(255) NOT NULL COMMENT 请假事由, status TINYINT NOT NULL DEFAULT 0 COMMENT 0草稿 1审批中 2驳回 3撤回 4通过 5作废, proc_inst_id VARCHAR(64) DEFAULT NULL COMMENT 流程实例ID提交后回填, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_leave_no (leave_no), KEY idx_applicant_status (applicant_id, status), KEY idx_proc_inst (proc_inst_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT请假单主表;days保留小数是为了前台展示真正参与计算的是代码里换算出来的halfDays。uk_leave_no唯一索引不只是防重还兼了一个幂等的作用同一单号重复提交会直接被数据库挡住比在应用层判断可靠。留痕表单独一张每次提交、同意、驳回、撤回都插一条带上节点名称快照和操作人。节点名称要存快照而不是查引擎表因为流程版本升级后老节点的名字可能变了历史记录得保持当时的语义。CREATE TABLE biz_leave_record ( id BIGINT NOT NULL AUTO_INCREMENT, leave_id BIGINT NOT NULL COMMENT 请假单ID, task_id VARCHAR(64) DEFAULT NULL COMMENT 任务ID用于回溯, node_name VARCHAR(64) NOT NULL COMMENT 节点名称快照, operator_id BIGINT NOT NULL COMMENT 操作人, action TINYINT NOT NULL COMMENT 1提交 2同意 3驳回 4撤回 5作废, comment VARCHAR(255) DEFAULT NULL COMMENT 审批意见, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_leave (leave_id, create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT请假审批留痕表;3.2 状态机设计五个状态和它们之间的合法迁移状态字段最怕的是「谁都能改」。四个接口各写一遍setStatus跑两个月就会出现「已通过的单子又被驳回」这种灵异状态。我的做法是把迁移规则集中到一个枚举里服务层调用前先校验非法迁移直接抛异常。当前状态允许动作目标状态触发点0 草稿提交1 审批中启动流程实例成功后1 审批中审批通过4 通过最后一个任务完成1 审批中驳回2 驳回回退到发起节点1 审批中撤回3 撤回无人处理删除实例2 驳回重新提交1 审批中更新单据后继续同一实例0/2/3作废5 作废申请人主动取消这张表要贴在接口文档里前端按钮的显隐直接就按它来。注意「驳回」和「撤回」是两个不同的终态驳回是审批人否掉、申请人可以改完再提撤回是申请人自己收回、单据作废。两者在列表页要用不同颜色区分否则审批人看到「被撤回」会以为自己漏审了。状态变更的最佳位置是流程监听器而不是业务接口。给流程挂一个全局的ExecutionListener在end事件里根据流程变量决定写「通过」还是「驳回」业务代码就不用关心流程什么时候结束了。这样即使有人在引擎控制台手动结束实例状态也是对的。3.3 待办与已办查询一条 SQL 同时覆盖直接指派和候选组待办列表是最容易被写成全表扫描的地方。引擎的ACT_RU_TASK表在数据量上来后可能有几十万行直接select *再在内存里过滤页面转圈是必然的。更隐蔽的问题是越权只按流程实例过滤、不按处理人过滤A 部门经理能看到 B 部门的单子。正确写法是两条查询做UNION一条查指派给自己的一条查候选组匹配的。用UNION而不是OR是因为OR会让优化器放弃索引而且同一个人如果同时在两个候选组里OR版本会出现重复行。-- 待办直接指派给自己的任务 SELECT t.ID_ AS task_id, t.NAME_ AS node_name, t.CREATE_TIME_ AS arrive_time, l.leave_no, l.leave_type, l.days, l.start_time, l.reason FROM ACT_RU_TASK t JOIN biz_leave l ON l.proc_inst_id t.PROC_INST_ID_ WHERE t.ASSIGNEE_ #{userId} UNION -- 待办候选组匹配但还没人签收的任务 SELECT t.ID_, t.NAME_, t.CREATE_TIME_, l.leave_no, l.leave_type, l.days, l.start_time, l.reason FROM ACT_RU_TASK t JOIN ACT_RU_IDENTITYLINK i ON i.TASK_ID_ t.ID_ AND i.TYPE_ candidate JOIN biz_leave l ON l.proc_inst_id t.PROC_INST_ID_ WHERE t.ASSIGNEE_ IS NULL AND i.GROUP_ID_ IN (DEPT_MANAGER:1001, HR, LEADER)ASSIGNEE_ IS NULL这个条件不能省否则已经被人签收的任务还会出现在其他人的候选列表里两个人同时点「同意」第二个会收到「任务不存在」的报错。候选组列表从当前登录人的角色缓存里取不要在前端传前端传参等于把越权入口直接开放。已办列表换ACT_HI_TASKINST关联ACT_HI_PROCINST条件用ASSIGNEE_ #{userId}加END_TIME_ IS NOT NULL。历史表的数据量比运行表大得多务必带时间范围默认只查最近三个月需要更多再让用户手动选。4. 后端实现提交、审批、驳回、撤回四个动作的落地流程引擎的 API 不算多难的是每个动作背后的事务边界和状态同步。四个动作我按「谁发起、谁处理、失败怎么办」逐个过一遍。4.1 提交一次事务里落单、起流程、回填实例 ID提交要在一个事务里完成三件事插业务单、启动流程实例、把实例 ID 回填到业务单。三件事分开做中间任何一步失败都会留下孤儿数据——业务单显示「草稿」流程实例已经在跑了。Transactional(rollbackFor Exception.class) public String submit(LeaveSubmitDTO dto, Long userId) { // 1. 先落业务单状态为草稿 BizLeave leave new BizLeave(); leave.setLeaveNo(genLeaveNo(userId)); // 单号规则LY 日期 序号 leave.setApplicantId(userId); leave.setDeptId(dto.getDeptId()); leave.setLeaveType(dto.getLeaveType()); leave.setStartTime(dto.getStartTime()); leave.setEndTime(dto.getEndTime()); leave.setDays(dto.getDays()); leave.setReason(dto.getReason()); leave.setStatus(LeaveStatus.DRAFT.getCode()); leaveMapper.insert(leave); // 2. 流程变量一次性放全不要等审批时再补 MapString, Object vars new HashMap(8); vars.put(applicantId, userId); vars.put(halfDays, (int) Math.round(dto.getDays().doubleValue() * 2)); vars.put(leaveType, dto.getLeaveType()); vars.put(deptManagerGroup, DEPT_MANAGER: dto.getDeptId()); vars.put(hrGroup, HR); // 3. businessKey 用单号后续查实例不必再绕业务表 ProcessInstance pi runtimeService.startProcessInstanceByKey( leave_apply, leave.getLeaveNo(), vars); leave.setProcInstId(pi.getId()); leave.setStatus(LeaveStatus.RUNNING.getCode()); leaveMapper.updateById(leave); return leave.getLeaveNo(); }halfDays用Math.round换算而不是强转是因为3.4999强转会变成 3半天假就丢了。vars必须在启动时一次性放全后面 2.2 里的网关表达式依赖它如果等到complete时才setVariable网关取值那一刻变量还不存在直接抛Unknown property。单号生成不能靠数据库自增再拼那样并发下会重复。规则用「前缀 日期 当日序号」序号从 Redis 自增拿或者用数据库唯一索引兜底加重试二者选一即可。4.2 审批与驳回任务完成、意见留痕和回退到发起节点审批看起来最简单一个complete就完了但候选组任务有个坑没签收就直接完成会抛「当前用户没有权限操作该任务」。所以要先判断assignee是否为空空了先claim。Transactional(rollbackFor Exception.class) public void approve(String taskId, Long userId, String comment) { Task task taskService.createTaskQuery().taskId(taskId).singleResult(); if (task null) { throw new BizException(任务不存在或已被他人处理); } // 候选组任务必须先签收否则 complete 会抛权限异常 if (task.getAssignee() null) { taskService.claim(taskId, userId); } // 审批意见写进引擎的 comment 表和留痕表互为备份 taskService.addComment(taskId, task.getProcessInstanceId(), comment); taskService.complete(taskId, Collections.singletonMap(approved, true)); }驳回和审批是两条完全不同的路径。最省事的驳回写法是「删实例 把状态改成驳回」用户重新提交时再起一个新实例。这个做法在小数据量下看着能跑但历史里会留一堆被删的实例流程时长统计直接失真属于典型的血泪经验。规范做法是把执行流从当前节点挪回发起节点Transactional(rollbackFor Exception.class) public void reject(String taskId, Long userId, String comment) { Task task taskService.createTaskQuery().taskId(taskId).singleResult(); if (task null) { throw new BizException(任务不存在或已被他人处理); } taskService.addComment(taskId, task.getProcessInstanceId(), 驳回 comment); // 当前节点 - 发起节点沿用同一个流程实例 runtimeService.createChangeActivityStateBuilder() .processInstanceId(task.getProcessInstanceId()) .moveActivityIdTo(task.getTaskDefinitionKey(), applyNode) .changeState(); }moveActivityIdTo的两个参数是「源节点 ID」和「目标节点 ID」都要用 BPMN 里的id而不是name。目标节点applyNode是一个 userTask指派给applicantId这样驳回后申请人的待办里会立刻出现「修改并重新提交」。重新提交时走的不是startProcessInstanceByKey而是complete掉发起节点上的那个任务。这一点如果写错就会出现一条单据对应两个流程实例待办列表里同一张单出现两次——这是答辩时最容易被问住的地方。4.3 撤回先判定边界再决定删实例还是走回退撤回的语义是「我提完反悔了」。判定条件有两个流程实例还在运行中没结束并且没有任何审批人处理过。Transactional(rollbackFor Exception.class) public void withdraw(String leaveNo, Long userId) { ProcessInstance pi runtimeService.createProcessInstanceQuery() .processInstanceBusinessKey(leaveNo) .singleResult(); if (pi null) { throw new BizException(流程已结束无法撤回); } // 已完成的历史任务数大于 0说明已经有人审过 long finished historyService.createHistoricTaskInstanceQuery() .processInstanceId(pi.getId()) .finished() .count(); if (finished 0) { throw new BizException(已有审批人处理无法撤回请联系审批人驳回); } // 无人处理直接删除实例监听器里同步业务状态为撤回 runtimeService.deleteProcessInstance(pi.getId(), 申请人主动撤回); BizLeave update new BizLeave(); update.setId(leaveMapper.selectIdByNo(leaveNo)); update.setStatus(LeaveStatus.WITHDRAWN.getCode()); leaveMapper.updateById(update); }deleteProcessInstance是逻辑删除历史表里还留着记录所以「撤回」的单据在历史列表里查得到只是运行表里没有了。这一点要在接口文档里写清楚否则前端做「撤回后还能不能再编辑」的判断时会反复确认。如果业务上允许「审批人已经看了但还没点同意」也撤回就把它改成moveActivityIdTo回退到发起节点再补一个流程变量标记撤回原因。两种策略不要混用选一种写进需求文档后面所有判断都按这一种来。5. 避坑与排查请假流程最容易翻车的五个点下面五条是我在不同项目里反复见过的每条都按「现象、原因、解决」写。调试流程问题的通用顺序是先看引擎日志里抛的异常类名再查ACT_RU_EXECUTION看执行流停在哪最后对照业务表和流程变量。别一上来就改代码。5.1 网关报变量不存在启动即失败现象提交请假单接口直接返回 500日志里是Unknown property used in expression: ${halfDays 6}。原因变量放进流程的时机不对。常见写法是先起实例、再runtimeService.setVariable而网关在实例启动后立刻求值那一刻变量还没写进去。另一种情况是setVariable加在了executionId上但网关读的是processInstanceId作用域。解决所有参与网关判断的变量必须在startProcessInstanceByKey的第三个参数里一次给全。确实需要中途写入的就在开始事件后挂一个ExecutionListener在start事件里统一setVariable不要散在各个业务接口里。5.2 一张单据出现两条待办驳回后重新提交惹的祸现象单据被驳回申请人改完重新提交部门经理的待办里出现两条一模一样的记录。原因重新提交时又调了一次startProcessInstanceByKey老实例没结束新实例又起来了两个实例的businessKey相同singleResult()直接抛Query returned more than one result。解决驳回时用moveActivityIdTo回退重新提交只做「更新业务字段 complete发起节点任务」全程只有一个实例。上线前可以在biz_leave上加一个「同一leave_no只能有一个未结束实例」的校验做兜底。5.3 业务状态和流程状态对不上事务边界没对齐现象数据库里单据显示「审批中」但流程实例根本不存在或者反过来流程已结束单据还挂在审批中。原因引擎和业务用了不同的数据源Transactional只回滚业务库引擎库提交了。或者启动实例的代码在事务外方法内抛异常后业务回滚、流程已经起好了。解决让引擎表和业务表在同一个数据源、同一个事务里这是最省心的做法。实在要分库就上本地消息表业务事务里写一条待启动记录定时任务扫描后调引擎并把结果回写再配一个每日对账任务把状态不一致的单子捞出来人工修。5.4 越权看到别人的待办查询条件漏了处理人现象A 部门的经理登录后能看到 B 部门的请假单点进去还能审批。原因待办查询只按PROC_INST_ID_关联业务表没加ASSIGNEE_和候选组过滤或者候选组字符串从请求参数里取前端随便传一个就绕过了。解决候选组必须由后端根据当前登录人的角色和部门算出来绝不信前端传参。SQL 里t.ASSIGNEE_ #{userId}和i.GROUP_ID_ IN (...)两个条件缺一不可同时给ACT_RU_IDENTITYLINK的TASK_ID_建索引否则数据量上来后这条查询会拖慢整个列表页。5.5 已办列表永远是空的历史级别配错了现象审批人明明点过同意业务留痕表里有记录但「已办」页一条都查不到。原因引擎的历史级别配成了none或activity任务级的历史根本不落库或者代码查的是ACT_RU_TASK任务完成后运行表里的记录就被删了。解决历史级别至少配到audit已办查询统一走ACT_HI_TASKINST条件加END_TIME_ IS NOT NULL。改配置只能影响之后的数据之前的历史补不回来所以这个配置要在第一次部署时就定好属于典型的没有后悔药。6. 验收与答辩一张表跑完六个场景流程类项目最怕「只演示了一条顺利路径」。答辩时老师或评审最常问的就是「驳回怎么办」「两个人同时审会怎样」答不上来前面的设计讲得再好也打折。我一般会先把下面这张验收表跑一遍每行都要在页面上点出来而不是嘴上说。场景操作路径期望结果检查点短假直通提交 2 天事假部门经理审批后直接到人事最后通过网关走halfDays 6分支长假加签提交 5 天年假部门经理通过后进入分管领导再到人事网关走halfDays 6分支驳回重提部门经理驳回申请人修改后重提同一流程实例待办不重复proc_inst_id前后一致主动撤回提交后未审批即撤回实例被删除状态为撤回历史表里能查到删除记录并发审批两个候选人同时打开同一任务后操作者提示「已被处理」claim生效无脏写越权访问用其他部门账号访问待办列表为空直接调接口返回 403候选组由后端计算演示顺序建议按「短假直通 → 长假加签 → 驳回重提 → 撤回」走这条线把主干和分支都覆盖了五分钟能演完。演之前先把数据库里的测试数据清干净尤其是ACT_表历史数据太多会让待办列表混进上次演示的单子现场翻车多半是这个原因。答辩提问里出现频率最高的三个可以提前准备为什么用候选组而不是直接指定审批人——答案是组织架构会变写死就要改流程图流程变量存在哪——存在引擎的变量表里按实例 ID 隔离业务表不重复存并发审批怎么保证——靠claim加数据库行锁任务完成后运行表记录被删第二个请求自然查不到。回答时尽量落到具体的表名和方法名上比讲概念有说服力。还有一个演示技巧把流程实例 ID 显示在单据详情页的角落评审问「这条流程走到哪了」的时候直接打开引擎自带的历史查询按实例 ID 一查每个节点的进出时间都在。这比口头解释流程走向直观得多也顺便证明了你对引擎表的熟悉程度。从那以后我每次做流程类项目都会在写完第一个接口之前先把这张验收表列出来六行跑不通就不往下加功能。看起来是拖慢了进度实际上是省掉了后期反复返工的时间。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑