7天学会SpringBoot+Vue3企业级项目RuoyiOffice(六):流程篇——接入审批,让业务单据真正流转起来
🌐 文档地址:https://ruoyioffice.com
👇 文章底部获取源码和演示地址 👇
💬 :17156169080(获取产品咨询)
这是《7天学会SpringBoot+Vue3企业级项目RuoyiOffice》系列第 6 篇。前五天我们有了接口、页面和权限边界,但用车申请还只是一张“填完就躺在表里”的单据。第六天接入审批:让它能提交、能被不同的人审批、能被撤回和驳回,并且审批的结果能准确地回到业务单据上。读完你能回答三个问题:业务单据和流程实例到底是什么关系、一次审批的结果经过哪几站才落到业务表、哪些状态必须由业务模块自己管。
▲ 本篇核心视觉:从上到下依次是入口、业务模块、BPM 引擎和状态回写通道。向下的箭头是“提交”,左侧向上的箭头是“回写”,两者合起来就是一条闭环;右下角的虚线框提醒回写失败只记日志。
引言:先分清两个对象
接入审批最常见的误区,是以为“单据”和“流程”是同一个东西。实际上它们是两个对象,各有各的生命周期:
|
|
|
|
|
|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
businessKey |
|
|
|
|
|
用车申请的关系可以用一句话概括:单据主键作为 businessKey 传给引擎,引擎生成的实例编号回填到单据的 process_instance_id。两边靠这一对键互相找到对方。
说明:本文代码节选自
yudao-module-bpm、yudao-framework与yudao-module-oa,为便于阅读省略了日志与部分空行。截图里的审批详情用的是费用申请单,用来展示通用的审批界面,用车申请的业务代码仍是本文主线。
一、流程模型:把一张单据接到一个流程 key 上
在“流程中心”的流程模型里,新建模型的第一步是填写基本信息。其中最关键的是流程标识(key):用车申请的 key 是 oa_car_apply_bill,它与后端枚举 OaBillTypeEnum 里的定义、前端页面里的 PROCESS_DEFINITION_KEY 三处必须一致。
▲ 流程模型的“基本信息”步骤:流程标识决定单据与流程的对应关系,流程类型可选 BPMN 设计器或 SIMPLE 设计器,“发起所需权限”可以填写类似 oa:car-apply-bill:create 的权限标识。
模型后面还有表单设计、流程设计和更多设置。和前端页面直接相关的有两个配置,保存在模型的 JSON 里:
|
|
|
|
|---|---|---|
formCustomCreatePath |
/oa/car/car-apply-info |
|
formCustomViewPath |
/oa/car/carapply/info/index.vue |
|
这两个值如果还指向已经删掉的页面路径,现象就是“待办详情空白”。修法是改流程模型里的路径(或出数据库脚本),不是去改前端的路由匹配。这个原则在第四篇已经强调过一次。
流程设计器里的节点和审批人策略,也有对应的真实枚举,下面列出常用的几类:
|
|
|
|
|---|---|---|
|
|
|
BpmSimpleModelNodeTypeEnum
|
|
|
|
|
|
|
|
BpmUserTaskApproveMethodEnum
|
|
|
|
BpmUserTaskRejectHandlerTypeEnum
|
|
|
|
BpmTaskCandidateStrategyEnum
|
“发起人自选”这一项和第四篇的 startUserSelectAssignees 是同一件事:提交时由发起人选定该节点的审批人,前端把选择结果放进提交请求,后端传给引擎。
用户从哪里发起?既可以在业务菜单里点“新增”,也可以从流程中心的发起大厅选择流程:
▲ 发起大厅:流程按分类排列,OA 用车申请单在 OA 协同办公分类里。点击后会按模型里的 formCustomCreatePath 打开对应的前端页面。
二、提交:业务模块与引擎第一次握手
用户在表单页点击“提交”,第四篇已经讲到前端调用 /submit。后端 CarApplyBillServiceImpl.submitCarApplyBill 的最后一段,是把单据交给引擎:
// 保存或更新CarApplyBillDO carApplyBill = BeanUtils.toBean(saveReqVO, CarApplyBillDO.class).setProcessStatus(BpmTaskStatusEnum.RUNNING.getStatus());carApplyBillMapper.insertOrUpdate(carApplyBill);// 智能提交 BPM 流程(如果流程实例不存在则创建,存在则审批发起人任务)Map<String, Object> processInstanceVariables = BpmProcessVariableUtils.buildBillVariables(saveReqVO);String processInstanceId = processInstanceApi.submitProcessInstance(Long.valueOf(saveReqVO.getCreator()),new BpmProcessInstanceCreateReqDTO().setProcessDefinitionKey(OaBillTypeEnum.OA_CAR_APPLY_BILL.getProcessDefinitionKey()).setVariables(processInstanceVariables).setStartUserSelectAssignees(saveReqVO.getStartUserSelectAssignees()).setBusinessKey(String.valueOf(carApplyBill.getId()))).getCheckedData();// 将工作流的编号,更新到单据中carApplyBillMapper.updateById(new CarApplyBillDO().setId(carApplyBill.getId()).setProcessInstanceId(processInstanceId));
注释里写的“智能提交”,指的是引擎侧的 submitProcessInstance 会判断:同一个 businessKey 下是否已有活动中的实例。
public String submitProcessInstance(Long userId, BpmProcessInstanceCreateReqDTO createReqDTO) {Map<String, List<Long>> startUserSelectAssignees = getEffectiveStartUserSelectAssignees(createReqDTO.getStartUserSelectAssignees());return FlowableUtils.executeAuthenticatedUserId(userId, () -> {// 1. 根据businessKey查找现有的流程实例ProcessInstance existingInstance = findActiveProcessInstanceByBusinessKey(createReqDTO.getProcessDefinitionKey(), createReqDTO.getBusinessKey());if (existingInstance != null) {// 2. 如果流程实例存在,查找发起人的待办任务并审批Task startUserTask = findStartUserTask(userId, existingInstance.getId());if (startUserTask != null) {// 2.2 更新流程变量(如果有新的变量) ...省略// 更新单据状态updateProcessInstanceRunning(existingInstance);// 2.3 审批发起人任务BpmTaskApproveReqVO approveReqVO = new BpmTaskApproveReqVO().setId(startUserTask.getId()).setReason("重新提交申请").setNextAssignees(startUserSelectAssignees);taskService.approveTask(userId, approveReqVO);return existingInstance.getId();} else {throw exception(TASK_NOT_EXISTS);}} else {// 3. 如果流程实例不存在,创建新的流程实例ProcessDefinition definition = processDefinitionService.getActiveProcessDefinition(createReqDTO.getProcessDefinitionKey());return createProcessInstance0(userId, definition, createReqDTO.getVariables(),createReqDTO.getBusinessKey(), startUserSelectAssignees);}});}
这段逻辑解释了三种提交场景为什么能共用一个接口:
|
|
|
|
|---|---|---|
|
|
|
process_instance_id
|
|
|
|
|
|
|
|
|
submitProcessInstance 的参数里没有任何用车业务字段,业务字段通过 buildBillVariables 变成流程变量,用于条件分支和审批人表达式。引擎只拿到“它判断路径所需要的变量”,而不是整张单据。
三、审批:待办里有哪些动作
提交成功后,引擎根据设计器里的配置生成第一批待办任务。审批人在 PC 或移动端都能看到:
▲ 移动端审批页:四个标签对应四种视角。卡片上的“任务节点”显示当前停在哪个节点,这里是部门负责人。
PC 端的审批详情分为“单据信息、审批信息、流程图”等页签,底部是操作栏:
▲ 审批详情(以费用申请为例):时间线展示已完成与当前节点,审批记录表列出每个节点的处理结果,底部操作栏集中了所有审批动作。
这些动作在后端 BpmTaskController 与 BpmProcessInstanceController 中有对应的接口:
|
|
|
|
|---|---|---|
|
|
PUT /bpm/task/approve |
batch-approve 批量同意
|
|
|
PUT /bpm/task/reject |
|
|
|
PUT /bpm/task/return |
list-by-return 提供可选节点
|
|
|
PUT /bpm/task/delegate
PUT /bpm/task/transfer
|
|
|
|
PUT /bpm/task/create-sign
DELETE /bpm/task/delete-sign
|
|
|
|
PUT /bpm/task/copy |
|
|
|
PUT /bpm/task/withdraw
PUT /bpm/task/withdraw-to-start
|
|
|
|
DELETE /bpm/process-instance/cancel-by-start-user
delete 等
|
|
|
|
PUT /bpm/process-instance/resubmit |
|
业务页面自己并不实现这些动作。第四篇的 info/index.vue 只处理“保存、提交、撤回、删除”,其中撤回直接调用引擎的接口:
// 撤回async function handleRevoke(reason: string) {if (formData.value.processInstanceId !== undefined &&formData.value.processInstanceId !== null) {loading.value = true;try {await withdrawProcessToStart({processInstanceId: formData.value.processInstanceId,reason: reason || '制单人撤回',});message.success('撤回成功');await loadData();} catch (error) {console.error('撤回失败:', error);} finally {loading.value = false;}}}
撤回成功后页面调用 loadData() 重新拉取,页面是否可编辑,由新的流程状态决定,这与第四篇的 computeBusinessFormReadonly 前后呼应。
四、回写:审批结果怎么回到业务表
审批人点击“同意”之后,业务表怎么知道?答案是引擎在实例状态变化时发布一个通知,业务模块的监听器收到后调用约定好的 Service 方法。这条链路有四站。
第一站:通知管理器。BpmNotificationManager 根据流程 key 选择通知方式,默认是本地事件,并且默认异步发送:
private void sendNotification(BpmProcessInstanceStatusMessage message, String processDefinitionKey) {// 获取通知方式BpmNotificationTypeEnum notificationType = getNotificationType(processDefinitionKey);// 获取对应的处理器BpmNotificationHandler handler = handlerMap.get(notificationType);if (handler == null) {log.warn("[sendNotification] 未找到对应的通知处理器: {}", notificationType);return;}// 发送通知if (Boolean.TRUE.equals(asyncProcess)) {Long tenantId = TenantContextHolder.getTenantId();CompletableFuture.runAsync(() -> {try {if (tenantId != null) {TenantContextHolder.setTenantId(tenantId);}handler.handleNotification(message);} catch (Exception e) {log.error("[sendNotification] 异步通知处理失败", e);} finally {TenantContextHolder.clear();}});} else {// 同步处理 ...省略}}
对应的配置项是 yudao.bpm.notification.default-type(默认 local)与 yudao.bpm.notification.async(默认 true)。
第二站:本地事件发布。BpmLocalEventNotificationHandler 把消息转成 BpmProcessInstanceStatusEvent,交给 Spring 事件机制。发布过程被 try/catch 包住,失败只写日志。
第三站:模块监听器。 每个业务模块有一个很薄的监听器子类,OA 的只有指定“我是 OA”和“用哪个工厂”两件事:
public class OaLocalNotificationListener extends AbstractFlowLocalNotificationListener<OaBillTypeEnum> {private OaFlowBillServiceFactory flowBillServiceFactory;protected SystemEnum getSystem() {return SystemEnum.OA;}protected FlowBillServiceFactory<OaBillTypeEnum> getFlowBillServiceFactory() {return flowBillServiceFactory;}}
真正的逻辑在抽象父类里:先按流程 key 前缀判断“是不是我的流程”,再把实例事件分发出去:
@Overridepublic void onApplicationEvent(BpmProcessInstanceStatusEvent event) {if (event.getProcessInstanceInfo() == null || event.getEventType() == null) {return;}String processDefinitionKey = event.getProcessDefinitionKey();if (!FlowProcessPrefixUtils.match(getSystem(), processDefinitionKey)) {log.debug("[onApplicationEvent] 非本模块流程,跳过处理: {}", processDefinitionKey);return;}BpmEventTypeEnum eventType = event.getEventType();if (eventType.isProcessInstanceEvent()) {handleProcessInstanceEvent(event);return;}if (eventType.isTaskEvent()) {handleTaskEvent(event);}}
FlowProcessPrefixUtils.match 按“系统编码加下划线”的前缀判断,OA 的流程 key 以 oa_ 开头,所以 oa_car_apply_bill 会被 OA 监听器接手,而其他模块的流程会被跳过。新增流程时流程 key 的前缀命名是硬约定,起错了前缀,事件就没人接收。
第四站:回写与钩子。 监听器通过工厂按流程 key 找到对应的 FlowBillService 实现,在正确的租户上下文里调用 updateProcessStatus,随后根据最终状态触发生命周期钩子:
Runnable hookAction = null;if (BpmProcessInstanceStatusEnum.APPROVE.getStatus().equals(status)) {hookAction = () -> flowBillService.onProcessApproved(businessKey);} else if (BpmProcessInstanceStatusEnum.REJECT.getStatus().equals(status)) {hookAction = () -> flowBillService.onProcessRejected(businessKey);} else if (BpmProcessInstanceStatusEnum.CANCEL.getStatus().equals(status)) {hookAction = () -> flowBillService.onProcessCancelled(businessKey);}if (hookAction != null) {executeWithTenant(message, hookAction);}
FlowBillService 接口要求业务模块实现 getSupportedBillType 和 updateProcessStatus,三个钩子与 deleteBill 默认是空实现,按需重写。用车申请单的实现里,状态回写之外还多做了一件业务的事:审批通过时,把还车状态置为“待还车”:
public void updateProcessStatus(String businessKey, Integer status) {Long id = Long.parseLong(businessKey);// 校验用车申请单存在validateCarApplyBillExists(id);// 更新流程状态CarApplyBillDO updateObj = new CarApplyBillDO();updateObj.setId(id);updateObj.setProcessStatus(status);// 如果审批通过,设置还车状态为待还车if (APPROVE.getStatus().equals(status)) {updateObj.setReturnStatus(CarReturnStatusEnum.PENDING_RETURN.getStatus());}carApplyBillMapper.updateById(updateObj);}
两张单据之间的联动,也是通过同样的回写入口完成的。还车申请单审批的结果,会反过来修改它关联的用车申请单:
private void handleApplyBillReturnStatus(Long returnBillId, Integer status) {try {CarReturnBillDO returnBill = getCarReturnBill(returnBillId);if (returnBill == null || returnBill.getApplyBill() == null) {return;}CarApplyBillDO applyBill = carApplyBillService.getCarApplyBillByCode(returnBill.getApplyBill());if (applyBill == null) {return;}// 根据流程状态处理用车申请单的还车状态if (BpmTaskStatusEnum.APPROVE.getStatus().equals(status)) {// 审批通过:标记为已还车carApplyBillService.markAsReturned(applyBill.getId());} else if (BpmTaskStatusEnum.REJECT.getStatus().equals(status) ||BpmTaskStatusEnum.CANCEL.getStatus().equals(status) ||BpmTaskStatusEnum.RETURN.getStatus().equals(status)) {// 审批拒绝、取消或退回:回滚为未还车carApplyBillService.markAsNotReturned(applyBill.getId());}} catch (Exception e) {log.error("[handleApplyBillReturnStatus] 处理用车申请单还车状态失败,returnBillId: {}, status: {}", returnBillId, status, e);}}
把这四站放在一起,状态的对应关系是:
|
|
|
process_status
|
|
|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return_status
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
五、闭环:提交、发起、审批、回写
现在可以把“提交 → 发起 → 审批 → 回写”完整放进一张时序图:
▲ 时序图:前 6 步是同步的提交与发起,第 7 到 9 步是审批,第 10 到 13 步是事件驱动的异步回写。注意第 2 步先把单据状态写成“审批中”,第 13 步才由引擎的最终结果覆盖它。
|
|
|
|
|
|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
六、为什么业务模块不能把状态全交给引擎
读到这里可能有人会问:既然引擎已经有状态,业务表为什么还要自己存 process_status 和 return_status?有四个理由:
|
|
|
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
反过来,业务模块也不应该重新实现审批本身:谁来审、几个人会签、驳回到哪里,这些全部交给流程模型配置。业务代码只管三件事:提交时调用引擎、实现回写接口、处理自己的业务进度。
七、常见故障矩阵
|
|
|
|
|
|---|---|---|---|
|
|
|
formCustomViewPath 与实际页面文件
|
|
|
|
formCustomCreatePath
|
|
|
|
|
|
updateProcessStatus、通知处理失败
|
|
|
|
|
|
|
|
|
|
oa_ 等开头
|
|
|
|
|
|
|
|
|
|
handleApplyBillReturnStatus
|
|
|
|
|
|
|
八、本文发现的几处不足
- 回写没有重试与补偿。
通知默认异步,发布失败与处理失败都只记日志;一旦监听器抛出异常,引擎已经完成审批,业务单据却可能停在旧状态,需要依赖日志或对账脚本发现。 - 任务级事件目前只是日志。
抽象监听器里,任务通过、拒绝、撤回、转办、委派的处理方法只有日志输出,业务状态由实例级事件驱动,没有落库动作。 - 还车联动吞掉了异常。
handleApplyBillReturnStatus捕获所有异常后只记日志,不抛出也不重试。 - 还车驳回的回滚值待确认。
markAsNotReturned实际写入的是枚举 NOT_EFFECTIVE(未生效),而不是“待还车”,是否符合业务预期,需要产品和研发共同确认,本文没有擅自修改。 - 没有真实运行验证。
本文所有结论来自阅读源码与现有截图,没有在本地跑一遍完整审批流程,上线前请在测试环境走完“提交、同意、驳回、撤回、重新提交”五个场景。
九、今天学完你应该能做到
-
能说出业务单据和流程实例各管什么,并解释 businessKey与process_instance_id的对应关系。 -
能讲清“智能提交”在三种场景下引擎里分别发生什么。 -
能画出审批结果回写业务表的四站,并指出哪一站默认是异步的。 -
能说出为什么业务表要保留自己的状态字段,以及业务进度为什么不能交给引擎。 -
能根据现象判断该查流程模型、通知链路还是业务回写。
常见问题(FAQ)
业务表里的 process_status 和引擎里的状态为什么要分开?
引擎状态描述审批进度,业务表状态用于列表筛选、导出和业务判断。两者靠回写保持一致。业务进度(如还车状态)则完全属于业务模块,引擎并不知道。
流程 key 可以随便起吗?
不可以。后端监听器按“模块编码加下划线”的前缀判断流程归属,前端页面里的 PROCESS_DEFINITION_KEY、枚举里的流程标识、流程模型里的 key 三处必须一致。
回写是同步还是异步?
默认是异步:yudao.bpm.notification.async 默认为 true,通过独立线程执行。也可以改为同步,但回写失败仍然只会记录日志,不会回滚审批。
单体和微服务部署,回写方式一样吗?
通知方式由 yudao.bpm.notification.default-type 决定,默认是本地事件,适合单体。微服务场景下仓库还提供了消息队列等通知处理器,选择哪一种要结合部署架构,并在测试环境验证。
驳回后用户怎么改了再提交?
驳回后实例状态变为“审批不通过”,在前端的可编辑状态集合里。用户修改后再次点击提交,引擎会找到同一 businessKey 下的活动实例,审批发起人节点完成重新提交。
系列进度与下一篇预告
|
|
|
|
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
功能、页面、权限、审批都已成型,最后一天要回答的是:怎么把它放到真实环境里,并且出了问题能找得到、退得回。下一篇进入上线篇:从开发、测试、生产的差异说起,梳理单体与微服务的取舍、前端构建与 Nginx、HTTPS 与跨域、中间件与配置、日志与备份,最后给出一份可以直接对照的上线验收清单。想深入同一业务的审批设计,可延伸阅读 公务用车:申请占坑、还车释放、撞时段怎么拦。
如果这篇对你有用,点个「在看」或收藏。
🌐 演示地址:https://ruoyioffice.com/web
📦 GitHub 源码:https://github.com/yuqing2026/ruoyi-office
📦 Gitee 源码:https://gitee.com/yqzy1688/ruoyi-office
💬 微信:17156169080(获取产品咨询)
打开演示地址直接查看系统。

