若依项目不引入Flowable:用状态机设计轻量级审批流
2026/9/19 2:24:22 网站建设 项目流程

接手一个基于若依的二次开发项目,最让人头疼的不是 CRUD,而是“加审批流”这种听起来简单、做起来没底的需求。团队里同事的第一反应基本都是“上 Flowable”,领导也默认工作流就该配个引擎。但我翻了实际需求之后发现,这个系统里的审批链路固定得不能再固定,为了三个审批节点把 BPMN 引擎整个塞进来,后续的部署、学习、维护成本全压在团队身上。这篇文章就把我给若依加审批流、但没碰 Flowable 的完整方案写出来,包括数据表怎么设计、状态机怎么写、和若依的权限/消息/日志怎么对接,以及实际跑了一个月后踩到的坑。适合在若依基础上做功能扩展、又被“工作流引擎”这个词吓住的开发者参考。

1. 先泼冷水:Flowable 不是审批流的标配

很多团队选择 Flowable,并不是因为业务需要它,而是因为“业界都在用”“以后需求会变复杂”。但技术选型最怕这种没有边界的假设。在决定引入一个重引擎之前,我习惯先把它的真实成本摊开算一遍。

1.1 一个“上引擎”决定背后的隐性成本

Flowable 这类工作流引擎,本质上是一个完整的流程运行时环境。引入它意味着要维护引擎自身的流程部署、流程实例、任务、变量、历史数据等等一套独立体系。落在若依这种后台管理框架上,你会发现几个很现实的问题。

第一是学习曲线。团队里不是每个人都研究过 BPMN 2.0,也不是每个人都理解流程定义、流程实例、任务节点、网关、监听器这些概念。新人接手一个用 Flowable 搭的审批模块,首先要去补引擎知识,然后才能改业务,这个周期比想象中长得多。

第二是部署和升级成本。Flowable 自带几十张表,版本升级还要专门处理数据迁移。如果项目部署在单节点 K8s 上,以后要迁移到云服务器,Flowable 的整套环境也得跟着走一遍,稍微出点问题就影响线上审批。

第三是定制难度。若依的场景下,审批往往要和若依自己的用户、角色、部门体系绑定,要跟业务表的状态字段联动。Flowable 的设计是通用的,通用意味着你要做很多适配工作。反过来,如果自己维护一套轻量状态机,这些联动逻辑就是自己写的,想怎么改都行。

这不是否定 Flowable,而是说它不应该成为默认选项。它适合复杂的、跨系统的、需要可视化和流程版本管理的场景,但若依上常见的单业务审批,往往用不到这个复杂度。

1.2 什么项目真的可以绕开工作流引擎

根据我这次的实际经验,下面几种情况完全可以不碰引擎,直接自研轻量审批流:

  • 流程链路固定或基本固定,比如“申请人 -> 直属领导 -> 部门领导 -> 人事归档”,节点的先后关系变化很少。
  • 驳回规则简单,无非是“驳回到发起人”或“驳回到上一节点”两种模式。
  • 不需要图形化拖拽流程设计器,用表格配置甚至代码配置链路就够。
  • 审批节点数量少,单个流程不超过五六个节点。
  • 团队对现有业务系统的掌控力强,希望审批逻辑完全在自己的代码里,而不是散落在引擎的 XML 和监听器里。

我当时把项目的流程需求拉了个清单:请假、报销、采购、合同会签,每个流程都是 2 到 5 个节点,没有复杂的并行网关,没有跨系统会签。这种量级,用状态机加三张表就能覆盖,而且覆盖得还很舒服。

1.3 什么样的流程复杂度必须上引擎

反过来,如果出现以下几种苗头,我劝你不要硬扛,老老实实用 Flowable:

  • 审批链路经常需要动态调整,业务人员要自己拖流程图。
  • 存在复杂的并行汇聚、子流程、多实例会签。
  • 需要做流程版本管理,老流程没走完,新流程已经改了。
  • 平台化需求,多个业务系统都要接同一套流程能力。

判断标准很朴素:审批流如果只是业务系统里的一个“功能模块”,那它就是状态机;如果它是公司级的“基础能力平台”,那才轮到引擎出场。若依这种单体后台,绝大多数所谓审批需求都是前者。

2. 三张表一套状态机:轻量审批流的数据模型

既然不上引擎,第一件事就是把数据模型设计好。我最终的落地方案只用了三张核心表:审批实例表、审批节点表、审批记录表。这三张表足够覆盖提交、同意、驳回、撤回、转办、委托等常见操作。

2.1 用请假流程反推数据结构

我们拿最常见的请假流程来推演。员工提交请假单,表单先落在业务表biz_leave里,状态是“草稿”。点提交之后,系统生成一条审批实例,同时按照预定义的链路生成一串节点。

链条大概是这样的:

  • 节点一:直属领导审批
  • 节点二:部门领导审批
  • 节点三:人事归档确认

这个链条不是存在某个 XML 文件里,而是由代码根据业务类型构建,生成后落到数据库的审批节点表。每审批完一个节点,就把当前节点标记为已通过,同时把“当前节点”指针移到下一个节点,直到全部节点完成,整个实例状态变为“已通过”。

理解了这个过程,数据模型其实就顺理成章了:一张表记录“这单审批整体走到哪了、什么状态”,一张表记录“这条审批链上有哪些节点、各自什么状态”,再一张表记录“每一步操作谁在什么时候做了什么”。

2.2 建表 SQL 与关键字段的“为什么”

以下是我实际使用的建表语句,去掉了业务相关字段,只保留审批核心部分。

-- 审批实例表 CREATE TABLE `biz_approval_instance` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', `biz_type` varchar(32) NOT NULL COMMENT '业务类型: leave/reimburse/purchase', `biz_id` varchar(64) NOT NULL COMMENT '业务单号', `current_node_key` varchar(64) DEFAULT NULL COMMENT '当前待审批节点标识', `status` varchar(16) NOT NULL DEFAULT 'DRAFT' COMMENT '实例状态: DRAFT/RUNNING/APPROVED/REJECTED/CANCELED', `initiator_id` bigint NOT NULL COMMENT '发起人ID', `version` int NOT NULL DEFAULT '0' COMMENT '乐观锁版本', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_biz_type_biz_id` (`biz_type`, `biz_id`), KEY `idx_initiator` (`initiator_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审批实例表';
-- 审批节点表 CREATE TABLE `biz_approval_node` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', `instance_id` bigint NOT NULL COMMENT '审批实例ID', `node_key` varchar(64) NOT NULL COMMENT '节点标识,如 LEADER/DEPT_LEADER/HR', `node_name` varchar(64) NOT NULL COMMENT '节点名称', `approver_type` varchar(16) NOT NULL COMMENT '审批人类型: USER/ROLE/DEPT', `approver_value` varchar(255) NOT NULL COMMENT '用户ID/角色编码/部门ID', `node_status` varchar(16) NOT NULL DEFAULT 'PENDING' COMMENT '节点状态: PENDING/APPROVED/REJECTED/CANCELED/SKIPPED', `sort_no` int NOT NULL DEFAULT '1' COMMENT '节点顺序', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `finish_time` datetime DEFAULT NULL COMMENT '完成时间', PRIMARY KEY (`id`), KEY `idx_instance_sort` (`instance_id`, `sort_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审批节点表';
-- 审批记录表 CREATE TABLE `biz_approval_record` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', `instance_id` bigint NOT NULL COMMENT '审批实例ID', `node_key` varchar(64) DEFAULT NULL COMMENT '节点标识', `action` varchar(16) NOT NULL COMMENT '操作: SUBMIT/AGREE/REJECT/WITHDRAW/TRANSFER/DELEGATE/END', `operator_id` bigint NOT NULL COMMENT '操作人ID', `operator_name` varchar(64) NOT NULL COMMENT '操作人姓名', `comment` varchar(500) DEFAULT NULL COMMENT '审批意见', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '操作时间', PRIMARY KEY (`id`), KEY `idx_instance_time` (`instance_id`, `create_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审批记录表';

三个表的职责边界必须分清楚:

  • 实例表只关心“整单审批处于什么位置”。它上面的current_node_key字段是流转的核心指针,所有“当前该谁审批”的判断都靠它。
  • 节点表关心“这条链路每个节点的处理结果”。注意我没有设计“流程定义表”,节点数据是在提交那一刻按业务类型生成的。
  • 记录表是纯审计日志,只负责追加,永远不 UPDATE。这一步对后续排查问题和出报表非常重要。

biz_type + biz_id的唯一索引给整单审批加了一层硬约束:同一张业务单只能有一条审批实例。这是防止并发提交的关键兜底。

2.3 不建流程定义表:固定链路的妥协方案

很多做传统工作流的人会问:你怎么没有流程定义表?流程模板都存在哪?

我的答案是:业务方也没有要求后台可以自由配置流程,链路就是写在代码里的。每个业务类型对应一个构建器,提交时把节点列表生成出来。这样写的最大好处是链路逻辑可调试、可单测,坏处是改链路必须发版。但对于大多数内部管理系统,这个坏处完全可以接受。

如果担心以后要支持配置化,可以预留一张biz_approval_flow_config表,把节点列表以 JSON 的方式存进去,提交时解析 JSON 生成节点。这就算从“硬编码链路”平滑升级到“配置化链路”了,而且完全可控,不至于一上来就搞个引擎。

3. 核心流转逻辑:审批、驳回、撤回、转办的代码实现

数据模型定了,接下来是流转逻辑。这一节是整套方案的重头戏,我会把提交、同意、驳回、撤回、转办这些关键操作的实现思路和代码片段完整放出来。代码基于 Spring Boot + MyBatis,落在若依框架里可以直接套用。

3.1 提交:创建实例并落到第一个节点

提交审批的入口接收业务类型、业务单号和当前登录用户。核心逻辑是:创建审批实例,生成节点链路,把实例指针指向第一个节点,状态改为运行中,最后写一条提交记录。

@Transactional(rollbackFor = Exception.class) public void submit(String bizType, String bizId, Long initiatorId) { // 1. 校验业务单状态,确保是草稿状态 Leave leave = leaveMapper.selectByLeaveNo(bizId); if (leave == null || !"DRAFT".equals(leave.getStatus())) { throw new ServiceException("业务单不存在或已提交"); } // 2. 创建审批实例,幂等靠唯一索引兜底 ApprovalInstance instance = new ApprovalInstance(); instance.setBizType(bizType); instance.setBizId(bizId); instance.setStatus("DRAFT"); instance.setInitiatorId(initiatorId); instanceMapper.insert(instance); // 3. 生成节点链路,这是按业务类型定制的 List<ApprovalNode> nodes = buildApprovalNodes(instance.getId(), bizType); for (ApprovalNode node : nodes) { approvalNodeMapper.insert(node); } // 4. 指针指向第一个节点 ApprovalNode first = nodes.get(0); instance.setCurrentNodeKey(first.getNodeKey()); instance.setStatus("RUNNING"); instanceMapper.updateById(instance); // 5. 记录提交动作 recordAction(instance.getId(), null, "SUBMIT", initiatorId, "提交审批"); }

buildApprovalNodes方法里就是根据业务类型 switch 创建节点列表。每个节点的approver_typeapprover_value在这个阶段确定。直属领导可以取发起人部门的主管,部门领导可以按角色编码查,这样就和若依的组织架构挂上了钩。

这里有个细节需要注意:审批实例表不存冗余的业务快照,例如请假天数、金额这些信息都不放进去。需要展示的时候直接查业务表,避免两份数据不一致。这也让审批模块保持通用性,接新的业务类型时不需要改审批表结构。

3.2 同意:死锁风险与行锁的正确姿势

同意操作是整个流转里最容易写错的地方。核心问题是并发:审批人快速点了两次“通过”,或者多个审批人同时操作,很可能产生脏数据。

我的做法是查询实例时用SELECT ... FOR UPDATE行锁,把整条审批实例锁住,让同一时间只有一个操作能真正执行流转。这样代码逻辑就线性化了,不需要再纠结乐观锁的版本号比较。

@Transactional(rollbackFor = Exception.class) public void approve(Long instanceId, String nodeKey, Long operatorId, String comment) { // 1. 行锁锁定实例,防止并发流转 ApprovalInstance instance = approvalInstanceMapper.selectByIdForUpdate(instanceId); if (instance == null || !"RUNNING".equals(instance.getStatus())) { throw new ServiceException("审批实例不存在或已结束"); } if (!nodeKey.equals(instance.getCurrentNodeKey())) { throw new ServiceException("当前审批节点已变化,请刷新后重试"); } // 2. 校验当前节点 ApprovalNode node = approvalNodeMapper.selectByInstanceAndKey(instanceId, nodeKey); if (!"PENDING".equals(node.getNodeStatus())) { throw new ServiceException("该节点已处理,不能重复操作"); } checkApprover(node, operatorId); // 3. 当前节点置为通过 node.setNodeStatus("APPROVED"); node.setFinishTime(new Date()); approvalNodeMapper.updateById(node); // 4. 找下一个待办节点 ApprovalNode next = approvalNodeMapper.selectNextPending(instanceId, node.getSortNo()); if (next == null) { // 5. 没有下一节点,整单审批完成 instance.setStatus("APPROVED"); instance.setCurrentNodeKey(null); approvalInstanceMapper.updateById(instance); // 6. 回调业务单状态 callBackBizStatus(instance, "APPROVED"); } else { // 有下一节点,指针后移 instance.setCurrentNodeKey(next.getNodeKey()); approvalInstanceMapper.updateById(instance); } // 7. 记录审批意见 recordAction(instanceId, nodeKey, "AGREE", operatorId, comment); }

关键点在于第 6 步的回调。审批流模块不应该直接 update 业务表,最好通过一个事件发布器或者策略接口来触发业务侧的状态变更。我这里的callBackBizStatus就是根据bizType分发到具体业务处理器,这样审批模块和业务模块解耦。

selectByIdForUpdate虽然简单粗暴,但要注意一个坑:所有涉及流转的操作,包括同意、驳回、撤回、转办,都必须先锁同一个实例对象。如果有的地方锁实例表,有的地方锁节点表,就会出现死锁。我的原则是“一切流转先锁实例”,保持加锁顺序一致。

3.3 驳回的两种模式与实现差异

驳回需求看着简单,实际是业务沟通的重灾区。我调研后发现,项目里真正需要的就是两种模式:驳回到发起人,以及驳回到上一节点。

驳回到发起人时,实例状态直接置为 REJECTED,当前节点指针清空,同时回调业务单,把业务状态置为“已驳回”。发起人修改后可以重新提交,重新提交走的链路仍然完整生成一遍。

@Transactional(rollbackFor = Exception.class) public void reject(Long instanceId, String nodeKey, Long operatorId, String comment, boolean backToInitiator) { ApprovalInstance instance = approvalInstanceMapper.selectByIdForUpdate(instanceId); // 省略基础校验,和 approve 类似 ApprovalNode node = approvalNodeMapper.selectByInstanceAndKey(instanceId, nodeKey); node.setNodeStatus("REJECTED"); node.setFinishTime(new Date()); approvalNodeMapper.updateById(node); if (backToInitiator) { instance.setCurrentNodeKey(null); instance.setStatus("REJECTED"); approvalInstanceMapper.updateById(instance); callBackBizStatus(instance, "REJECTED"); } else { ApprovalNode prev = approvalNodeMapper.selectPrev(instanceId, node.getSortNo()); if (prev == null) { // 没有上一节点时兜底为驳回到发起人 instance.setCurrentNodeKey(null); instance.setStatus("REJECTED"); } else { // 上一节点回到待办状态 prev.setNodeStatus("PENDING"); prev.setFinishTime(null); approvalNodeMapper.updateById(prev); instance.setCurrentNodeKey(prev.getNodeKey()); instance.setStatus("RUNNING"); } approvalInstanceMapper.updateById(instance); } recordAction(instanceId, nodeKey, "REJECT", operatorId, comment); }

驳回到上一节点时,有个容易漏掉的细节:把上一节点的finish_time清空,node_status改回 PENDING。否则已办列表和待办列表会出现同一节点既显示“已处理”又显示“待处理”的鬼畜现象。这个坑后面我会专门说。

驳回意见里还应该带上“当前节点是谁驳回的”“驳回到了哪里”,这些信息直接在记录表里查就能完整还原,不需要额外的字段。

3.4 撤回、转办、委派:一个字段能解决的事

发起人可以撤回。前提是当前节点还没有被审批人处理,也就是node_status还是 PENDING。撤回时把当前节点终止掉,实例状态置为 CANCELED。

@Transactional(rollbackFor = Exception.class) public void withdraw(Long instanceId, Long operatorId, String comment) { ApprovalInstance instance = approvalInstanceMapper.selectByIdForUpdate(instanceId); if (!operatorId.equals(instance.getInitiatorId())) { throw new ServiceException("只有发起人可以撤回"); } if (!"RUNNING".equals(instance.getStatus())) { throw new ServiceException("当前状态不允许撤回"); } ApprovalNode node = approvalNodeMapper.selectByInstanceAndKey(instanceId, instance.getCurrentNodeKey()); if (!"PENDING".equals(node.getNodeStatus())) { throw new ServiceException("审批人已处理,无法撤回"); } node.setNodeStatus("CANCELED"); node.setFinishTime(new Date()); approvalNodeMapper.updateById(node); instance.setCurrentNodeKey(null); instance.setStatus("CANCELED"); approvalInstanceMapper.updateById(instance); recordAction(instanceId, null, "WITHDRAW", operatorId, comment); callBackBizStatus(instance, "CANCELED"); }

转办更简单。当前审批人觉得这事不该自己批,可以把任务转给另一人。实现上就是修改节点表里的approver_value字段,换成目标用户的 ID。

@Transactional(rollbackFor = Exception.class) public void transfer(Long instanceId, String nodeKey, Long operatorId, Long targetUserId, String comment) { ApprovalNode node = approvalNodeMapper.selectByInstanceAndKey(instanceId, nodeKey); checkApprover(node, operatorId); if (!"PENDING".equals(node.getNodeStatus())) { throw new ServiceException("当前节点已处理"); } node.setApproverType("USER"); node.setApproverValue(String.valueOf(targetUserId)); approvalNodeMapper.updateById(node); recordAction(instanceId, nodeKey, "TRANSFER", operatorId, "转办给: " + targetUserId + " " + comment); }

委派则要区分一下:转办是把审批权完全交出去,委派是临时找人代理,处理完结果要回归给原审批人。严格做委派需要多一张委托关系表,记录原审批人和代理人。但对若依项目来说,大多数场景转办就够用了,委派功能可以先不做,等业务方明确提出来再扩展也不迟。

3.5 会签和或签:先用最土的办法顶上

如果你的审批流需求里有“多个人同时审批”,先别慌。大部分项目说的会签,其实就是“这几个人都要同意才算过”,而不是严格的 BPMN 多实例会签。

我的做法是在节点表里加一个字段approval_mode,取值ANYALL。“或签”表示任一审批人同意即可,“会签”表示所有审批人都要同意。代码里需要把会签节点的审批人 ID 列表存储下来,并记录哪些人已经批了。

实现细节是在节点表加total_approversapproved_approvers两个字段。每次有人同意,就把approved_approvers加一,达到总数才推进到下一节点。这种数字统计的写法虽然土,但对于内部系统的审批场景完全够用,而且很容易排查问题。

如果后期会签人数不确定、或者要支持“按比例通过”,那个时候再考虑上 Flowable 也不晚。至少前期不用为这个边缘场景付出巨大的引擎成本。

4. 跟若依的整合:权限、接口、前端联动与安全细节

审批模块的算法再漂亮,最终要跑在若依框架里。这个章节讲怎么和若依的用户、角色、权限、菜单、日志体系无缝衔接。

4.1 审批人解析:绕开跨库查询,复用若依用户服务

审批节点的approver_value我设计成三种类型:USER、ROLE、DEPT。USER 直接存用户 ID,ROLE 存若依的角色编码,DEPT 存部门 ID。审批时要根据类型解析出具体的用户 ID 列表。

如果审批模块和若依的系统管理模块在同一个应用里,直接注入SysUserService查询就行。下面是按角色编码查用户的示例:

public List<Long> parseApproverIds(ApprovalNode node) { if ("USER".equals(node.getApproverType())) { return Collections.singletonList(Long.parseLong(node.getApproverValue())); } if ("ROLE".equals(node.getApproverType())) { // 若依自带按角色查用户的接口 List<SysUser> users = sysUserService.selectAllocatedList( new SysUser() {{ setRoleKey(node.getApproverValue()); }} ); return users.stream().map(SysUser::getUserId).collect(Collectors.toList()); } if ("DEPT".equals(node.getApproverType())) { // 查部门负责人,若依的 SysDept 里有 leader 字段 SysDept dept = sysDeptService.selectDeptById(Long.parseLong(node.getApproverValue())); return Collections.singletonList(dept.getLeaderId()); } throw new ServiceException("不支持的审批人类型"); }

如果将来审批模块被拆成独立微服务,不再直接依赖若依用户表,就需要在审批调用方传入“审批人 ID 列表”或通过 Feign 调用系统服务。但当前若依单体项目中,直接用框架的 Service 是最经济的方案,不需要自己再拼 SQL 查用户表。

4.2 接口设计:业务方只认状态,前端只认按钮

审批接口的 URL 我建议做成通用型,而不是每个业务类型写一套。比如:

  • POST /approval/instance/submit提交审批
  • POST /approval/instance/approve同意
  • POST /approval/instance/reject驳回
  • POST /approval/instance/withdraw撤回
  • POST /approval/instance/transfer转办
  • GET /approval/instance/detail审批详情

这样做的最大好处是,新接一个业务类型时,前端审批弹窗组件可以直接复用,后端也只需要新增一个节点构建器和业务回调器。

接口上的权限控制直接用若依的注解:

@PreAuthorize("@ss.hasPermi('approval:instance:approve')") @PostMapping("/approval/instance/approve") public AjaxResult approve(@RequestBody ApproveRequest request) { return success(approvalService.approve(request.getInstanceId(), request.getNodeKey(), getUserId(), request.getComment())); }

前端按钮用若依的v-hasPermi指令控制显示。注意,按钮显示只是用户体验层面,真正的权限校验必须落在后端。因为当前审批人是谁、能不能批这个节点,只有后端根据实例状态和节点状态才能判断,这个判断不能依赖前端传参。

有一个细节:审批详情接口一定要返回一个 DTO,里面包含当前实例状态、当前节点信息、当前用户是否可审批、是否可撤回等计算好的布尔值。前端拿到这个 DTO 直接控制按钮,不要在页面里自己拼逻辑判断。否则审批状态一多,前端就要跟着写一堆 if else,维护起来非常痛苦。

如果你用的若依是 Vue3 + TypeScript 分支,这里还容易踩一个坑:审批详情接口返回的currentNodeKey字段在流程结束后是 null,TypeScript 里如果把它定义成string类型,模板中写detail.currentNodeKey.length会直接编译报错。建议接口返回类型定义成string | null,或者后端统一返回空字符串。

4.3 待办与消息:别在事务里发通知

待办列表和已办列表是审批模块的门面。待办的查询逻辑是:查出biz_approval_nodenode_status = 'PENDING'且审批人是当前用户的数据,join 实例表和业务表返回。已办的查询逻辑是:查biz_approval_recordoperator_id = 当前用户且 action 属于 AGREE、REJECT、TRANSFER、DELEGATE 的数据。

这里有个教训:消息通知不能直接写在审批事务里。如果审批通过的同时发站内信,消息服务一旦超时,整个事务回滚,审批明明已经通过了,业务单状态却还没改过来,用户看到的就是“系统卡了”。

正确做法是使用 Spring 的@TransactionalEventListener,监听事务提交成功后再发送消息。或者更简单粗暴一点:在审批记录表里记下动作后,另起一个异步任务扫描“已审批但未通知”的记录,补发通知。后一种方案对团队要求低,不容易踩“事务还没提交,异步线程已经查不到数据”的坑。

消息内容至少要包含业务单号、审批结果、审批意见,点击消息可以跳转到对应的业务详情页。若依后台有站内信模块,直接调用它的接口保存即可,不需要单独搭消息系统。

4.4 动态表单脚本与存储型 XSS 的防护

若依生态里有人会配合表单设计器使用,让节点支持“动态执行脚本”。比如“金额大于 5000 走总监审批,否则走经理审批”,这个需求很常见。我实现的方案是在节点表加一个condition字段,存储一个 SpEL 表达式或者一个可选的脚本标识,流转时判断表达式是否满足,满足才进入该节点。

但这里必须提醒:动态执行脚本是一把双刃剑。如果表单设计器允许业务人员填写任意 JavaScript 或 SpEL,服务器端执行这些脚本等于开门迎接 RCE 攻击。我最后的折中方案是:不执行任意脚本,只预置一批固定的表达式模板,业务人员只能选择模板、填写阈值参数。这样既满足了动态条件的诉求,又不会开放任意代码执行。

另一个容易被忽视的点是存储型 XSS。审批意见如果不做过滤直接存库,前端再用v-html渲染,恶意用户可以在审批意见里塞一段脚本,任何打开审批详情的用户都会中招。若依的富文本组件输出前一定要做白名单过滤,比如后端统一调用 Jsoup.clean 去掉 script 标签和事件属性。别以为审批系统是内部系统就不会有人攻击,安全防线不能省。

5. 实际跑起来后踩过的四个坑

方案上线一个月,表面上风平浪静,实际遇到的坑一个接一个。这里完整记录四个比较有代表性的问题,每一个都是我花时间排查过的,写出来帮大家少走弯路。

5.1 并发提交的漏网之鱼

第一版代码里,提交审批的逻辑是先查业务表状态,再 insert 审批实例。当时想得很简单,觉得业务单状态是“草稿”才允许提交,而草稿状态只会出现一次。

结果上线第三天就出问题了:用户快速点了两次提交,两个请求几乎同时进来,都查到业务单是草稿状态,都认为自己可以提交,结果创建了两条审批实例。业务数据直接乱套。

这个问题的根子在于“检查状态”和“修改状态”之间存在时间窗口,不是加一重 if 判断能解决的。我后来的修复方式是在biz_approval_instance表加唯一索引uk_biz_type_biz_id,数据库层面保证同一业务单只能有一条审批实例。同时把“更新业务单状态为提交中”这个动作放在事务最前面,形成条件更新:

UPDATE biz_leave SET status = 'SUBMITTING' WHERE leave_no = #{leaveNo} AND status = 'DRAFT'

如果影响行数为 0,说明业务单已经不是草稿状态,直接拒绝提交。这个条件更新配合唯一索引,双保险,彻底消除了并发提交问题。

5.2 已办与待办的状态漂移

驳回到上一节点后,审批人 B 同时收到了两份矛盾的数据:已办列表里 B 有一张“已审批”的单子,待办列表里 B 又看到同一条单子“待审批”。

排查后发现问题出在“驳回到上一节点”时,我只改了实例表的当前节点指针,没有把上一节点的状态恢复成 PENDING。上一节点在第一次审批时已经被标记为 APPROVED,现在虽然当前指针指回来了,但节点状态还是 APPROVED,待办查询自然就漏掉了它。

修复方法就是 3.3 节代码里写的:驳回到上一节点时,把上一节点的node_status重置为 PENDING,同时清空finish_time。这个动作要放在和驳回同一个事务里,否则会出现短暂的不一致窗口。

我后来写了一个自动化校验脚本,每十分钟扫一遍所有 RUNNING 状态的实例,检查当前节点指针指向的节点是不是真的处于 PENDING。如果发现不一致,立刻告警并输出完整实例、节点数据。这个脚本成了审批模块的守护神。

5.3 审批通过瞬间下游回调失败

审批流程走完后,系统要回调业务模块,把请假单状态改成“已通过”。最初版本里回调是同步的,一旦业务模块抛异常,整个审批事务回滚,审批人明明点了“通过”,提交后被告知“操作失败”,实际数据却没有变化。

这个问题的本质是“审批结果”和“业务状态更新”的耦合。审批的核心动作是记录并推进状态,业务状态更新只是它的一个副作用,副作用不应该影响主流程。

我调整后的策略是:审批事务里只做审批相关操作,写记录表。回调业务状态通过事件监听在事务提交后执行,失败则重试三次,仍然失败就记录到一张失败任务表,由定时任务补偿。这样审批人永远不会因为下游业务问题看到“审批失败”的提示。

这种设计也方便以后接更多业务类型。审批模块只管审批,业务方自己监听审批完成事件去更新自己的单据,互不干扰。

5.4 数据量上来了,列表查询带头掉链子

刚上线时待办列表只有几百条数据,怎么查都快。跑了不到两个月,审批记录表突破几万条,待办列表的分页查询开始变慢,有一次甚至慢查询超时。

查看执行计划后发现问题出在排序和联表查询上。待办列表本质上是查节点表,再 join 实例表和业务表,最后还要按业务单的创建时间排序。业务表是分模块的,审批模块没法建业务表的索引,所以排序只能让数据库临时 filesort,数据一多就慢了。

我的优化思路是分步骤查询:第一步只查节点表,用索引过滤出当前用户待审批的instance_id列表;第二步根据这些 ID 批量查业务表的数据,在内存里组装。分页时先按节点的实例 ID 分页,再回表查业务数据。这样每次 join 的数量大幅减少,查询稳定在毫秒级。

另外一个建议是:审批记录表是做审计和留痕用的,它只增不改,数据量增长一定比业务表快。上线前就要规划好归档策略,比如三个月前的记录迁移到归档库,页面默认只查最近三个月。这个操作越早做越省事,等数据堆积到百万级别再处理就很痛苦了。

6. 上线一个月的复盘:这套方案够用在哪、乏力在哪

没有哪套方案是万能的。这个轻量审批流跑了一个月,整体是稳的,但我也清楚它的边界在哪里。这个章节做个阶段性复盘,给后来者一个诚实的参考。

6.1 与 Flowable 的直接对比

很多同事问我,自研这套和直接用 Flowable 到底差多少。我整理了一个对比表,可以直观看到差异。

对比维度Flowable轻量自研(本文方案)
数据表数量几十张表三张表
概念学习成本高,需要理解 BPMN、流程实例、任务、网关低,只要懂状态机
流程可视化配置支持,有模型设计器不支持,链路靠代码或 JSON 配置
动态链路支持强,支持运行时变更弱,变更需要发版或改配置
流程版本管理内置支持需要自己实现
复杂会签/子流程支持支持有限,依赖扩展字段
与若依系统耦合度低,相对独立高,直接复用若依的用户、权限服务
排障成本需要理解引擎内部运行机制代码自己写的,日志一看就懂
适合场景跨系统、复杂流程、平台级能力内部系统、固定链路、快速交付

从表里能看出来,Flowable 的优势集中在“复杂流程”和“平台化能力”上,而若依单体项目里的审批需求通常够不到这个复杂度。反过来,轻量自研在排障成本、交付速度、可维护性上的优势非常实际。

6.2 预留的演进点:动态条件跳过节点、流程版本化

虽然目前链路是代码写死的,但我在设计时留了几个扩展点,防止需求突变时推倒重来。

一个是节点表里的condition字段。虽然现在只支持预置的 SpEL 表达式模板,但字段已经预留了。真要支持动态跳转,只需要在构建节点时把表达式解析结果纳入判断即可,不用改表结构。

另一个是biz_type维度的扩展性。所有节点构建逻辑都收敛在buildApprovalNodes工厂方法里,新接一个业务类型就是加一个 case 和对应的回调处理器。目前接新流程的开发工作量,大约一个下午就能搞定,包括前端审批弹窗复用。

如果有一天业务方需求真的膨胀到“运营人员要自己画流程图”,那再考虑引入 Flowable 也不迟。而且有了这套自研状态机的理解,团队去学 Flowable 反而更顺畅,因为很多概念是相通的。

6.3 我的选型标准:不是小而美,是够用且兜得住

回看整个决策过程,我最庆幸的不是“没被喷”,而是把选型标准拉回到了业务本身。我当时的判断依据有三个:

第一,这套审批流要解决的是若依系统内部的业务审批,不是做一个给全公司复用的流程平台。第二,团队规模有限,如果引入 Flowable,真正投入在业务功能开发的精力会被引擎学习和维护摊薄。第三,时间窗口紧,项目要求一个月内跑通三个审批流程,自研方案可以把控每个环节,风险完全在掌控内。

当然,选型没有银弹。如果你所在的项目已经明确要用流程引擎承载多个系统的复杂流程,那 Flowable 就是正确答案,千万不要拿这篇文章作为拒绝引擎的借口。技术选型的本质,永远是让复杂度匹配真实需求,而不是反过来让需求迁就技术栈的清高。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询