简介:面向Java Web工作流初学者的一份请假审批实例,以员工提交申请、主管审批、人事记录为主线,帮助理解流程建模、任务流转与权限控制。内容围绕Struts2与jBPM展开,含Action、Service、DAO分层代码、流程定义及登录、申请、经理和老板审批等JSP页面,可运行小型流程演示。压缩包共42个文件、约51KB,以12个XML配置、8个JSP、3个Java源文件、3个class及4个properties为主,涵盖struts.xml、jBPM/Hibernate配置和leave.jpdl.xml流程定义,可查看框架整合与节点设置。已有253人学习下载。读者可参考完整工程目录、流程定义与页面交互样例,理解Struts2请求处理、拦截器配置及中文编码处理思路,也能借鉴JSP表单与审批页组织方式,适合课程设计或工作流入门对照学习。
1. 请假审批流程为什么一定要引入工作流引擎:从一张审批单说起
一套工作流请假实例,第一版往往只有三个节点:提交、主管批、人事存档。等业务方补上几句“超过三天要总监签”“人事可以代提”“驳回改完直接回原节点”,原来那串 if-else 就开始失控——改一次状态机,回归测试要跑一整天。工作流请假实例真正要解决的核心问题,是让流程规则变成可以单独部署、单独回滚的资源,使节点、审批人、分支条件不再被硬编码在业务代码里,并不只是解决审批本身能不能做的问题。这套方案按 Spring Boot + Flowable 落地,覆盖流程建模、审批人分配、会签驳回撤回、数据绑定和验收口径,适合正在做 OA 类毕设或课程设计、要给内部系统补审批能力的同学直接照着改。
2. 技术选型与总体架构:Spring Boot + Flowable 的六层结构划分
2.1 自研状态机与上引擎:用一张对比表定方案
做请假审批,最省事的做法是给leave_apply加一个status字段,0 待审、1 通过、2 驳回,配三个接口。第一周确实快,问题出在第三个月:审批节点从两个变成四个,中间插进来一个“人事备案”,还得支持“三天以上加签总监”。状态机就变成 5 个枚举值、9 条迁移规则、一堆写死在 service 里的判断,谁改谁心里没底。
我一般会拿这张表跟导师或需求方对齐,避免后面反复解释为什么不用手写:
| 维度 | 自研状态机 | 工作流引擎(Flowable) |
|---|---|---|
| 增加一个审批节点 | 改代码、加枚举、补分支判断 | 改 BPMN 文件,重新部署 |
| 审批人调整 | 改配置再发版 | 候选人/候选组,运行时确定 |
| 历史追溯 | 自己再写一套日志表 | act_hi_*按节点天然留痕 |
| 前两周开发速度 | 快,不用理解引擎 | 慢,要先摸清引擎的表结构 |
| 第三个月改规则 | 回归整条链路 | 只回归受影响的节点 |
| 流程图展示 | 前端手画,和代码容易脱节 | BPMN 自带坐标,可直接渲染高亮 |
结论很明确:节点少但规则变得勤、还需要给用户看流转图的场景,引擎的收益在第二个月才显现;反过来,如果整条流程永远只有两个节点且不会变,状态机反而更轻。请假审批属于前者,所以选 Flowable——它的 Spring Boot starter 集成度高,act_*表结构稳定,中文资料也多,毕设答辩时讲得清楚。
2.2 六层结构与职责边界
架构上我把它切成六层,重点在“引擎 API 不许乱跑”:
- 接口层:
LeaveController、TaskController,只负责参数校验和响应包装,不出现RuntimeService。 - 流程服务层:
LeaveProcessService,唯一允许调RuntimeService、TaskService、HistoryService的地方,负责发起、完成、驳回、撤回。 - 业务服务层:
LeaveApplyService,管请假单的增删改查、天数和类型校验,不关心流程走到哪个节点。 - 流程资源层:
resources/processes/leave.bpmn20.xml,流程图和条件表达式都在这里,改流程不动 Java。 - 数据访问层:MyBatis Mapper,业务表用一套,引擎表用引擎 API 读。
- 基础设施层:身份适配、消息通知、定时任务。
这条纪律看着像形式主义,实际是整套方案能长期维护的关键。LeaveController一旦直接注入TaskService去写“完成任务 + 改业务状态”,驳回逻辑再进来就必然出现逻辑重复,同一套审批动作会在 Controller、Service 和监听器里各抓到一个,改一处漏两处是必然的。约束住之后,审批动作只有一种写法,验收时也只需要盯住一个类。
2.3 工程骨架与核心配置
依赖上只引流程引擎的 starter,版本统一交给父 POM 的dependencyManagement管理,别在每个模块里各写一个版本号:
<dependency> <groupId>org.flowable</groupId> <artifactId>flowable-spring-boot-starter-process</artifactId> </dependency> <!-- 只用引擎自带的 act_id_* 身份表时引入,改用自定义用户表则不需要 --> <dependency> <groupId>org.flowable</groupId> <artifactId>flowable-spring-boot-starter-actuator</artifactId> <optional>true</optional> </dependency>配置文件里几个参数是必须显式写的,默认值容易带来隐患:
spring: datasource: url: jdbc:mysql://127.0.0.1:3306/leave_flow?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: leave password: leave_pwd flowable: # 调试期自动建表,生产环境改成 false 并改用 SQL 脚本初始化 database-schema-update: true # 部署时校验 BPMN,能把表达式写错这类低级问题挡在启动阶段 check-process-definitions: true # 自动部署 classpath:/processes/ 下所有 bpmn20.xml process-definition-location-prefix: classpath*:/processes/ # 边界定时事件、异步任务全部依赖它,关掉之后超时提醒永远不触发 async-executor-activate: true # audit 会记录变量和任务,full 才记录 act_hi_detail,按需要选 history-level: auditdatabase-schema-update这一项尤其需要说明:本地开发开着是因为省事,但引擎自动建表时不会带业务需要的索引和注释,上线前通常的做法是关掉它,用引擎自带脚本导出一次 DDL 手工核对。history-level选audit够用,想查“某个变量在第几步被谁改过”才需要full,代价是历史表体积翻几倍。
身份适配有两种思路:小项目直接把用户和部门同步进act_id_user、act_id_group,查询最快;已有用户体系的项目,实现引擎的UserGroupLookup接口让它回查你的sys_user表,省掉同步任务,代价是每次任务查询多一次联表。前者适合课程设计,后者适合已有系统二次开发。
3. 流程建模:节点、网关与审批人分配的落地写法
3.1 从业务语言翻译成 BPMN 节点
把需求原话“员工提交,主管审批,超过三天加总监会签,最后人事备案”翻译成节点,会得到这样一条主干:开始事件 → 主管审批 → 天数排他网关 → 总监会签(多实例)→ 人事备案 → 状态回写 → 结束。这里有两个容易做错的决定。
第一个是“提交”要不要做成用户任务。我的做法是不要:提交动作由前端调发起接口完成,流程一启动就直接停在“主管审批”,用户待办里不会出现一条“自己审自己”的任务,也省掉一次complete调用。第二个是“会签”用多实例用户任务而不是并行网关,多实例节点在历史表里的形态更规整,nrOfCompletedInstances这类计数变量可以直接拿来做完成条件。
<?xml version="1.0" encoding="UTF-8"?> <definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:flowable="http://flowable.org/bpmn" targetNamespace="http://example.org/leave"> <process id="leaveProcess" name="请假审批" isExecutable="true"> <!-- initiator 会把当前登录人写进 applyUserId 变量,后续回写和通知都靠它 --> <startEvent id="start" flowable:initiator="applyUserId"/> <!-- 审批人用表达式取流程变量,发起时算好,不在图里写死人名 --> <userTask id="deptLeaderApprove" name="部门主管审批" flowable:assignee="${deptLeader}"/> <exclusiveGateway id="daysGateway" name="请假天数分流"/> <!-- 多实例会签:directorList 里的每个人各生成一个任务实例 --> <userTask id="directorSign" name="总监会签" flowable:assignee="${director}"> <multiInstanceLoopCharacteristics isSequential="false" flowable:collection="${directorList}" flowable:elementVariable="director"> <!-- 全部通过才结束,配合 rejectFlag 支持一票否决 --> <completionCondition>${nrOfCompletedInstances == nrOfInstances || rejectFlag}</completionCondition> </multiInstanceLoopCharacteristics> </userTask> <!-- 候选组审批:人事岗位谁认领谁办,避免把具体账号焊死在流程里 --> <userTask id="hrRecord" name="人事备案" flowable:candidateGroups="hr"/> <!-- 回写业务状态用委托表达式,指向一个 Spring Bean --> <serviceTask id="writeBack" name="回写审批结果" flowable:delegateExpression="${leaveStatusDelegate}"/> <endEvent id="end"/> <sequenceFlow sourceRef="start" targetRef="deptLeaderApprove"/> <sequenceFlow sourceRef="deptLeaderApprove" targetRef="daysGateway"/> <sequenceFlow sourceRef="daysGateway" targetRef="directorSign"> <conditionExpression xsi:type="tFormalExpression"><![CDATA[${days > 3}]]></conditionExpression> </sequenceFlow> <sequenceFlow sourceRef="daysGateway" targetRef="hrRecord"> <conditionExpression xsi:type="tFormalExpression"><![CDATA[${days <= 3}]]></conditionExpression> </sequenceFlow> <sequenceFlow sourceRef="directorSign" targetRef="hrRecord"/> <sequenceFlow sourceRef="hrRecord" targetRef="writeBack"/> <sequenceFlow sourceRef="writeBack" targetRef="end"/> </process> </definitions>几个参数直接决定成败:flowable:collection里放的是流程变量名而不是表达式结果,写成directorList加花括号${}都行,但集合元素类型要是字符串或能取出assignee的对象;flowable:elementVariable声明的变量名必须和assignee里引用的一致,否则会报找不到director;completionCondition默认就是全部完成才结束,写出来是为了让一票否决的rejectFlag有地方挂。排他网关的两条连线必须条件互斥且覆盖全集,漏掉days = 3这种边界值,流程会直接卡在网关抛异常。
3.2 变量约定与类型陷阱
发起时把变量一次性算好,是整条链路最省心的做法:
@Transactional(rollbackFor = Exception.class) public String start(Long applyId) { LeaveApply apply = applyMapper.selectById(applyId); Map<String, Object> vars = new HashMap<>(); // 天数必须是数值类型,前端传字符串会让 ${days > 3} 的比较结果不可预期 vars.put("days", apply.getDays().intValue()); vars.put("leaveType", apply.getLeaveType()); vars.put("deptLeader", orgService.getDeptLeader(apply.getDeptId())); vars.put("directorList", orgService.listDirector(apply.getDeptId())); // businessKey 用业务单号,后面查历史、对账、回写全靠它 ProcessInstance pi = runtimeService.startProcessInstanceByKey( "leaveProcess", apply.getApplyNo(), vars); leaveApplyService.markRunning(applyId, pi.getId()); return pi.getId(); }days这里踩过的坑最多:数据库是DECIMAL(4,1),实体是BigDecimal,直接丢进变量表后,JUEL 表达式里${days > 3}的比较行为和预期不完全一致,因为引擎解析出来的是字符串形式。统一转成Integer或Double再传,是最省事的做法。businessKey一定要传,而且要用业务单号而不是主键 id——主键在不同环境会变,单号不会,后面查历史、排查线上问题全靠它。
变量表的体积也要控制,只在其中放基本类型和String。往流程变量里塞一个实体对象,短期能跑,但引擎默认序列化方式会让act_ge_bytearray里堆满二进制记录,而且这个类以后不能随便改字段。我的习惯是:流程变量只放“流程判断需要的东西”,业务数据一律回业务表查。
3.3 三种审批人分配方式怎么选
写死assignee="zhangsan"是最不可取的一种,测试环境和生产环境一定对不上。剩下三种各有场景:
第一种是表达式取变量,即上面 BPMN 里的${deptLeader},适合“审批人由组织架构决定,发起时就能算出来”的情况,主管审批、总监会签都属于这种。第二种是候选组candidateGroups="hr",适合“岗位审批”——人事今天是谁不重要,重要的是人事这个组里有人认领。第三种是发起后再动态添加候选人,适合会签人数不确定或者需要临时加签的场景。
任务查询这一块也必须配合,否则待办列表会莫名其妙少东西:
// 我参与过的任务:候选人、已认领、被指派三种情况都要查出来 List<Task> todo = taskService.createTaskQuery() .processDefinitionKey("leaveProcess") .or() .taskCandidateOrAssigned(userId) .taskCandidateGroupIn(groupIds) // 候选组任务,必须显式带上用户所属组 .endOr() .orderByTaskCreateTime().desc() .list();只用taskCandidateOrAssigned而漏掉taskCandidateGroupIn,候选组生成的任务在待办里就是查不到,现象是“流程明明走到人事了,人事说没看到”。这是我在第一次交付时被问到最多的问题。
3.4 会签、驳回与撤回:三个高频动作
会签本身靠多实例节点就够了,真正麻烦的是“一票否决”:任何一个总监驳回,整条流程要立刻跳走。做法是在任务完成监听器里判断本次审批结果,把rejectFlag设为 true,completionCondition就会立即结束多实例节点:
@Component("directorTaskListener") public class DirectorTaskListener implements TaskListener { @Override public void notify(DelegateTask task) { String action = (String) task.getVariable("action_" + task.getId()); if ("REJECT".equals(action)) { // 设为 true 后,completionCondition 立刻成立,剩余会签任务被销毁 task.setVariable("rejectFlag", true); } } }驳回和撤回,我用的是引擎的跳转能力,核心就是ChangeActivityStateBuilder:
public void reject(String processInstanceId, String taskId, String targetActivityId, String comment) { Task task = taskService.createTaskQuery().taskId(taskId).singleResult(); // 记录本轮审批动作,业务侧留痕,历史表删不掉但口径不好统一 recordService.save(task, "REJECT", comment); // 把当前节点整条执行流移动到目标节点,中间节点不会执行 runtimeService.createChangeActivityStateBuilder() .processInstanceId(processInstanceId) .moveActivityIdTo(task.getTaskDefinitionKey(), targetActivityId) .changeState(); // 驳回要把轮次加一,避免第二轮读到第一轮的变量 leaveApplyService.nextRound(processInstanceId); }moveActivityIdTo的参数是“当前活动节点 id”和“目标活动节点 id”,不是任务 id,写错会抛ActivityNotFound。这里的关键是驳回目标要允许配置:驳回到发起人、驳回到上一节点、驳回到任意已审批节点,三种策略在真实需求里都会被提到,所以我把它做成参数从接口传进来,而不是在代码里写死。
撤回的逻辑要更谨慎。已经产生过审批意见的单子被撤回,历史记录里会留下一条“办过但作废”的任务,这是正常的,act_hi_taskinst的DELETE_REASON_会写明原因。我的做法是:只允许撤回“当前任务还没被处理”的单据,撤回动作等价于把流程拉回第一个用户任务并作废后续记录,同时业务侧标记状态为“已撤回”。超过一步的撤回,宁可让用户重新提交——这是血泪经验,硬做“撤回中间任意步”会让历史记录变得没法解释。
4. 数据模型设计:业务表、流程表与审批记录如何绑定
4.1 三张业务表撑起整套数据
引擎的act_*表负责“流程走到哪”,业务表负责“请假单长什么样”,两者用businessKey和流程实例 id 连接。业务侧我一般建两张核心表加一张附件表:
CREATE TABLE leave_apply ( id BIGINT PRIMARY KEY AUTO_INCREMENT, apply_no VARCHAR(32) NOT NULL COMMENT '业务单号,同时作为 businessKey', apply_user_id VARCHAR(64) NOT NULL COMMENT '申请人', dept_id VARCHAR(64) NOT NULL COMMENT '部门,用来算审批人', leave_type TINYINT NOT NULL COMMENT '1事假 2病假 3年假', start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, days DECIMAL(4,1) NOT NULL COMMENT '折算后的天数,半天用 .5', reason VARCHAR(500), status TINYINT NOT NULL DEFAULT 0 COMMENT '0草稿 1审批中 2通过 3驳回 4撤回', process_instance_id VARCHAR(64) COMMENT '发起后回填', round INT NOT NULL DEFAULT 1 COMMENT '第几轮审批,驳回后 +1', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_apply_no (apply_no), KEY idx_user_status (apply_user_id, status), KEY idx_proc (process_instance_id) ) COMMENT='请假单'; CREATE TABLE leave_task_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, apply_id BIGINT NOT NULL, process_instance_id VARCHAR(64) NOT NULL, task_id VARCHAR(64) COMMENT '引擎任务 id,可为空', node_key VARCHAR(64) NOT NULL COMMENT 'BPMN 节点 id', node_name VARCHAR(64) COMMENT '节点中文名,前端直接用', handler_id VARCHAR(64) COMMENT '处理人', action VARCHAR(16) NOT NULL COMMENT 'APPROVE/REJECT/REVOKE/DELEGATE', comment VARCHAR(500), round INT NOT NULL DEFAULT 1, handle_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_apply_round (apply_id, round) ) COMMENT='审批记录,业务侧留痕';为什么引擎已经有act_hi_taskinst了还要再存一份?三个原因:一是历史表结构由引擎版本决定,升级时字段可能变;二是多轮驳回后,靠act_hi_*区分“第几轮谁批的”要写很绕的子查询,业务表加个round字段一行就解决;三是统计口径——人事要算“平均审批时长”,用业务表比联三张引擎表好写得多。
round这个字段是整个数据模型里最容易被忽略、又最关键的一个。驳回之后如果只是把流程跳回去,第一轮已经设置的变量(比如前面提到的rejectFlag)会残留,第二轮走到会签节点时可能直接判定为“已驳回”而瞬间结束。用轮次隔离变量名,或者在驳回时显式清理关键变量,二者选一个,别指望引擎帮你兜底。
4.2 状态回写:同库同事务是底线
业务状态和流程状态必须一致,靠的是一个委托 Bean:
@Component("leaveStatusDelegate") public class LeaveStatusDelegate implements JavaDelegate { @Autowired private LeaveApplyMapper applyMapper; @Override public void execute(DelegateExecution execution) { String applyNo = execution.getProcessInstanceBusinessKey(); // 与流程在同一个事务里,要么都成功,要么一起回滚 int rows = applyMapper.finishByNo(applyNo, LeaveStatus.APPROVED.getCode()); if (rows == 0) { // 抛异常让引擎回滚,避免出现"流程结束但单子还在审批中"的脏数据 throw new FlowableException("回写审批结果失败: " + applyNo); } } }前提是 Flowable 和业务表在同一个数据源、同一个事务里,Spring 的DataSourceTransactionManager会把引擎操作和你的 Mapper 操作纳管到一起。如果业务库和引擎库分开部署,就不能这么写,得改用本地消息表或事务消息:业务侧提交时插一条待发消息,异步任务把流程状态同步过去,用幂等键防重复。分开部署本身没问题,但很多人只在业务侧加@Transactional,引擎侧的事务边界没对齐,一旦回写失败就出现“流程走完了、单子还是审批中”的经典脏数据,排查起来极费时间。
4.3 表单校验与前端提交
天数、起始时间这类字段,前端算一遍、后端再算一遍,两边口径必须一致:
// 提交前统一折算天数,半天按 0.5 计,避免和后端算法对不上 function buildLeavePayload(form) { const start = new Date(form.startTime).getTime(); const end = new Date(form.endTime).getTime(); if (!start || !end || end <= start) { return { ok: false, msg: '结束时间必须晚于开始时间' }; } // 按自然日折算,跨月也走同一套逻辑 const days = Math.round((end - start) / 86400000 * 2) / 2; if (days > 15) { return { ok: false, msg: '单次请假不得超过 15 天,请走特殊流程' }; } return { ok: true, payload: { applyNo: form.applyNo, leaveType: Number(form.leaveType), days, // 数值类型,直接进流程变量 reason: form.reason.trim() || '事假' } }; }参数上要盯住三点:days必须是数值,前端传"3"这种字符串,到后端要么强制转换要么在 JUEL 里出错;applyNo由后端预生成(比如日期 + 序列),前端只回传,避免用户刷新页面生成两张单;reason允许空但要给默认值,因为 BPMN 的表达式一旦引用到空值,某些引擎版本会抛PropertyNotFoundException。
4.4 审批时间线与痕迹查询
前端的时间线不需要自己拼,引擎历史表加业务记录表各取所需:
-- 节点级痕迹,DELETE_REASON_ 能看出这条任务是正常完成还是被驳回销毁 SELECT t.NAME_ AS nodeName, t.ASSIGNEE_ AS handler, t.START_TIME_ AS startTime, t.END_TIME_ AS endTime, t.DELETE_REASON_ AS deleteReason FROM act_hi_taskinst t WHERE t.PROC_INST_ID_ = #{processInstanceId} ORDER BY t.START_TIME_ ASC; -- 审批意见走业务表,多轮驳回用 round 分组展示 SELECT round, node_name, handler_id, action, comment, handle_time FROM leave_task_record WHERE apply_id = #{applyId} ORDER BY round ASC, handle_time ASC;时间线渲染时按round分组,第一轮被驳回的节点用灰色显示,第二轮重新走一遍。DELETE_REASON_里会出现Change activity to xxx这类引擎写入的原因,前端不要直接展示给用户,翻译成“流程被驳回”就够了。
5. 避坑与排查:五处最容易出问题的地方
5.1 待办列表空白:任务生成了,但没人能看见
现象:后台日志显示任务已经创建,数据库act_ru_task里也有记录,但审批人登录进去待办是空的。
原因基本只有两个:查询语句只用了taskCandidateOrAssigned而漏掉taskCandidateGroupIn,候选组任务过滤掉了;或者项目没有接身份服务,act_id_membership里没有用户与组的对应关系,引擎根本不知道这个人属于hr组。
解决:先把taskCandidateGroupIn(loggedUserGroupIds)补上,再把用户所属组从业务库查出来作为入参。如果用的是自定义用户表,实现UserGroupLookup并注册成 Spring Bean,让它按用户 id 返回组列表;用一个简单的单测固定住接口,输入用户 id,断言返回包含hr,这比手动检查更快。
5.2 流转图一片灰色:流程能跑,节点不高亮
现象:前端用 bpmn-js 渲染流程图,边框和节点都在,但当前节点永远不高亮,历史走过的路径也不标色。
原因:从建模工具导出 BPMN 时只保留了流程语义,没有保留BPMNDiagram、BPMNPlane、BPMNEdge这些图形信息,引擎查不到节点坐标,前端自然没法定位高亮元素。
解决:导出时勾选“包含图形信息”,或者检查 XML 里是否存在<bpmndi:BPMNDiagram>节点。部署后从act_ge_bytearray里查DEPLOYMENT_ID_对应的资源,确认图形信息确实存进去了。另外高亮查询要用HistoryService拿历史活动实例再比对节点 id,不要试图从前端自己维护状态——这套状态一定和引擎对不上。
5.3 驳回之后数据异常:第二轮审批读到第一轮的变量
现象:单子被驳回、员工改完重新提交,第二轮走到会签节点,还没人点审批就直接结束了;或者审批意见串到了上一轮的记录里。
原因:moveActivityIdTo只挪动执行流,不清理流程变量。第一轮设置的rejectFlag、action_xxx仍然在act_ru_variable里,completionCondition一读到rejectFlag = true立刻成立。审批记录如果只按processInstanceId查不按轮次过滤,也会串。
解决:驳回时显式删除关键变量,或者变量名带轮次后缀;业务记录表加round字段并且查询强制带轮次;在completionCondition里加一层判断,只认本轮的变量。我习惯在驳回接口里统一走一个cleanRoundVariables(pid, round)方法,把非流程控制必需的变量清干净,宁可多写一个方法,也别指望每次都能想起来。
5.4 超时提醒从不触发:定时边界事件形同摆设
现象:BPMN 里挂了boundaryEvent加timeDuration,设计上三天不审批自动提醒,实际跑起来从来不提醒。
原因:flowable.async-executor-activate没开,或者开了但数据库里act_ru_job有记录、执行器没消费;还有一种情况是部署时用的时钟和预期不一致,测试环境改了系统时间导致到期时间算错。
解决:先确认配置项为 true,再查act_ru_job有没有DUEDATE_已经过期的记录;有记录但不消费,看启动日志里异步执行器线程有没有起来。测试时把时长改成PT1M验证链路,验完再改回真实时长,别一上来就等三天。
5.5 两个人同时点审批:乐观锁异常带出一堆脏状态
现象:主管和人事几乎同时操作,日志里冒出FlowableOptimisticLockingException,用户看到 500,回头一看单子状态不对,有的人任务已经办了但业务状态没变。
原因:引擎对执行流用版本号做乐观锁,并发修改同一条执行记录必然有一方失败。如果业务方法外层只加了@Transactional而没有捕获重试,失败方的业务侧改动一起回滚,用户重试时又收到“任务已不存在”。
解决:给审批接口加一层轻量重试,捕获乐观锁异常后重新查任务、再执行一次,通常第二次就能成;同时在业务侧对leave_apply的status更新加状态机校验,只允许从“审批中”迁出,避免重复提交把状态改乱。前端加一个提交按钮的防抖和 loading 态,能挡掉八成重复点击。
6. 验收与答辩:一张清单逐项确认
这套东西交付前,我会按下面这张表逐项确认。表里的每一条都能在十分钟内复现,不会出现“我本地是好的”这种扯不清的情况:
| 验收项 | 判定标准 | 复现方式 |
|---|---|---|
| 流程发起 | 业务单状态变为审批中,act_ru_task出现主管任务 | 提交一张 2 天事假 |
| 分支正确 | 3 天走人事,4 天走总监会签 | 分别提交 3 天和 4 天两张单 |
| 会签生效 | 两位总监都通过才进入人事备案 | 一人通过、一人驳回各测一次 |
| 驳回回退 | 回到指定节点,业务状态变为驳回,轮次 +1 | 主管驳回后修改重提 |
| 撤回限制 | 已办任务不可撤回,未办任务可撤回到发起环节 | 两种状态各点一次 |
| 状态一致 | 流程结束后业务状态与流程状态一致 | 结束后查act_hi_procinst与业务表 |
| 时间线 | 节点、处理人、意见、轮次完整 | 前端时间线截图比对 |
| 超时提醒 | 到期后生成提醒记录 | 把时长临时改为 1 分钟 |
答辩时最常被追问的有三个点,提前准备好答案能省很多解释:一是“为什么不用状态机”,用第 2 章那张对比表回答,重点讲规则变更成本;二是“引擎的 28 张表你怎么管的”,回答业务表自己维护、引擎表只用 API 读不直接写;三是“驳回怎么实现”,把moveActivityIdTo和变量清理讲清楚,能顺带展示你对历史表结构的理解。演示顺序建议按“发起 → 主管驳回 → 修改重提 → 会签 → 完成 → 看时间线”走一遍,一条链路把分支、回退、留痕全带出来,比逐页讲代码有效得多。
从那以后我每次交这类项目之前,都会把这张验收表打印出来,一条条勾过去,没勾完不演示。踩过的坑大多不是技术难题,是“以为测过了”。希望帮到你。
本文还有配套的精品资源,点击获取