ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Activiti 7 + Spring Boot + Shiro 工作流权限系统整合实战

Activiti 7 + Spring Boot + Shiro 工作流权限系统整合实战

1. 项目整体设计与思路拆解

1.1 为什么选择 Activiti + Spring Boot + Shiro 这套组合

做过企业级审批系统的人都知道,工作流引擎和权限框架几乎是绕不开的两座大山。我最早接触工作流是在一个合同审批项目里,当时用的是纯手写状态机,十几个审批节点加上会签、驳回、撤回这些逻辑,代码写到后面连自己都不敢改。后来换成 Activiti,才发现流程定义和业务代码解耦之后,维护成本能降一个数量级。

这套组合的核心逻辑其实很清晰:Activiti 负责“事怎么流转”,Shiro 负责“谁能操作”,Spring Boot 负责“把这两者粘起来并快速交付”。三者各司其职,边界干净。

先说 Activiti。它是基于 BPMN 2.0 规范的开源工作流引擎,核心能力是把业务流程抽象成流程图,通过流程定义文件驱动实际的业务流转。相比自己写状态机,Activiti 自带流程实例管理、任务分配、历史记录、网关分支、会签、子流程等能力,这些功能如果自研,工作量至少翻三倍。选它还有一个现实原因:国内企业审批场景(请假、报销、采购、合同)几乎都能用 BPMN 标准图表达,Activiti 的生态和资料相对成熟,遇到问题好查。

再说 Shiro。权限管理这块,Spring Security 功能更全但配置偏重,Shiro 相对轻量,拦截器链和 Realm 的设计对中小型项目更友好。尤其是它和 Activiti 的 TaskQuery 结合时,我们可以很方便地把“当前登录用户”注入到任务查询条件里,实现“只看到自己的待办”。这一点在实战中非常关键,后面会详细展开。

Spring Boot 的价值不用多说,自动装配、起步依赖、内嵌容器,让整个项目的搭建从原来的两三天压缩到半天。三者整合的关键难点不在单个框架,而在线程上下文、事务边界、过滤器链顺序这三个交叉点上,这也是我踩坑最多的地方。

1.2 整体架构分层与模块职责

一个能落地的工作流权限项目,我习惯按下面这种方式分层,避免后期业务膨胀后代码失控:

层级职责关键类/组件
控制层接收请求、参数校验、返回统一响应Controller
业务层流程启动、任务办理、业务数据落库Service
流程层封装 Activiti API,屏蔽引擎细节WorkflowService
权限层认证、授权、会话管理Shiro Realm、Filter
持久层业务表 + Activiti 表Mapper/Repository
配置层引擎配置、Shiro 配置、事务配置Config 类

这里有个设计取舍值得说:不要把 Activiti 的 API 直接暴露在 Controller 里。我见过不少项目在 Controller 里直接调runtimeService.startProcessInstanceByKey,短期看省事,但一旦流程逻辑变化(比如启动前要校验业务状态、启动后要发通知),就得改 Controller,违反开闭原则。正确做法是抽一层 WorkflowService,把“启动流程 + 写业务表 + 记录发起人”封装成一个事务方法。

1.3 版本选型与依赖关系梳理

版本兼容是这套组合最容易翻车的地方。Activiti 7 之后和 Spring Boot 2.x 的整合方式变化较大,很多老教程用的是 Activiti 5/6,直接照搬会报一堆类找不到。我的建议是:

  • Spring Boot 2.7.x + Activiti 7.1.0.M6 是相对稳定的组合,Activiti 7 对 Spring Boot 2.x 支持较好。
  • 如果团队更熟悉老版本,Spring Boot 2.3.x + Activiti 6.0.0 也是成熟方案,资料更多。
  • Shiro 用 1.11.0 以上版本,低版本存在一些已知的安全问题,升级成本很低,没必要省这一步。

依赖引入时要注意:Activiti 7 的 starter 会自动装配引擎,但数据源必须显式配置,否则启动时报找不到 DataSource。另外 Activiti 默认会创建 25 张表(ACT_ 开头),第一次启动时databaseSchemaUpdate设为true让它自动建表,生产环境记得改成false并用脚本管理。

提示:Activiti 7 的activiti-spring-boot-starter和activiti-spring-boot-starter-basic是两个不同的包,前者包含 REST 支持,后者更轻量。只做后端整合选 basic 即可,避免引入不必要的依赖。

2. 核心细节解析与实操要点

2.1 Activiti 引擎配置的关键参数

引擎配置看起来简单,但几个参数直接决定项目能不能跑起来。我整理了一份实战中必调的配置清单:

spring: activiti: database-schema-update: true db-history-used: true history-level: full check-process-definitions: false async-executor-activate: false

逐个解释为什么这么设:

database-schema-update: true让引擎启动时自动比对并创建表结构,开发阶段省事。但生产环境一定要关掉,改用 Flyway 或 Liquibase 管理表结构变更,否则引擎升级时可能悄悄改表,风险不可控。

db-history-used: true和history-level: full是一对。历史数据是审批系统的命脉,谁在什么时候办了哪个节点、耗时多久、审批意见是什么,全靠历史表。full级别会记录所有变量和明细,存储开销大一些,但查询体验好。如果只是简单审批,audit级别也够用。

check-process-definitions: false这个参数很多人忽略。默认情况下引擎启动会扫描processes/目录下的 bpmn 文件并自动部署,开发时方便,但每次重启都重新部署会产生大量重复的流程定义版本。关掉它,改用代码或管理接口手动部署,版本控制更清晰。

async-executor-activate: false是关掉异步执行器。如果你的流程里有定时边界事件、异步任务,这个必须开;如果只是普通审批流,关掉能省一个线程池,减少资源占用。

2.2 Shiro 认证授权链路设计

Shiro 的核心是三个概念:Subject(当前用户)、SecurityManager(安全管理器)、Realm(数据源)。整合 Spring Boot 时,最容易出错的是过滤器链的顺序和Realm 的注入方式。

先看过滤器链配置,这是权限控制的第一道闸门:

@Bean public ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager) { ShiroFilterFactoryBean factory = new ShiroFilterFactoryBean(); factory.setSecurityManager(securityManager); Map<String, Filter> filters = new LinkedHashMap<>(); filters.put("authc", new JwtFilter()); factory.setFilters(filters); Map<String, String> chain = new LinkedHashMap<>(); chain.put("/login", "anon"); chain.put("/workflow/**", "authc"); chain.put("/**", "authc"); factory.setFilterChainDefinitionMap(chain); return factory; }

这里用LinkedHashMap而不是HashMap是有原因的:Shiro 的过滤器链是顺序匹配的,第一个匹配上的规则生效。用 HashMap 顺序不确定,可能导致/login被authc拦截,登录接口反而进不去。这个坑我踩过一次,排查了半天。

Realm 的设计要解决一个核心问题:认证和授权分离。认证负责校验用户名密码,授权负责加载用户的角色和权限。很多教程把两者混在一个方法里,导致每次鉴权都查一次数据库。正确做法是认证时查用户,授权时查角色权限,并用缓存减少查询。

public class UserRealm extends AuthorizingRealm { @Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { String username = (String) principals.getPrimaryPrincipal(); SimpleAuthorizationInfo info = new SimpleAuthorizationInfo(); info.setRoles(roleService.findRolesByUser(username)); info.setStringPermissions(permService.findPermsByUser(username)); return info; } @Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) { String username = (String) token.getPrincipal(); User user = userService.findByUsername(username); if (user == null) throw new UnknownAccountException(); return new SimpleAuthenticationInfo(username, user.getPassword(), getName()); } }

2.3 工作流与权限的交叉点处理

这是整个项目最核心也最容易出问题的地方。工作流和权限不是两个独立模块,它们在三个地方深度交叉:

第一,待办任务的权限过滤。用户登录后看到的待办列表,必须是“分配给自己的任务”或“自己所在候选组的任务”。Activiti 的 TaskQuery 提供了taskAssignee和taskCandidateGroup两个条件,但候选组需要和 Shiro 的角色对应起来。我的做法是:Shiro 的角色编码和 Activiti 的候选组编码保持一致,查询时把当前用户的角色列表传进去。

List<Task> tasks = taskService.createTaskQuery() .taskCandidateGroupIn(currentUserRoles) .or() .taskAssignee(currentUsername) .endOr() .orderByTaskCreateTime().desc() .list();

第二,流程节点的操作权限。不是所有登录用户都能办理所有任务。比如财务审批节点,只有财务角色能操作。这个校验不能只靠前端隐藏按钮,后端在complete任务前必须校验当前用户是否有权办理该任务。

Task task = taskService.createTaskQuery().taskId(taskId).singleResult(); if (task == null) throw new BusinessException("任务不存在"); boolean isAssignee = currentUsername.equals(task.getAssignee()); boolean isCandidate = taskService.createTaskQuery() .taskId(taskId) .taskCandidateGroupIn(currentUserRoles) .count() > 0; if (!isAssignee && !isCandidate) throw new BusinessException("无权办理该任务");

第三,流程启动的权限控制。谁能发起哪种流程,也要受权限约束。比如只有 HR 能发起入职流程,只有采购能发起采购流程。这个用 Shiro 的@RequiresPermissions注解在 Service 方法上即可。

注意:Shiro 的注解默认不生效,需要在配置类里开启AuthorizationAttributeSourceAdvisor,否则@RequiresPermissions形同虚设。这个坑非常隐蔽,因为不报错,只是权限校验被跳过。

3. 实操过程与核心环节实现

3.1 环境搭建与项目骨架生成

先把骨架搭起来。用 Spring Initializr 生成项目,勾选 Web、MySQL、MyBatis(或 JPA)。然后手动加三个依赖:

<dependency> <groupId>org.activiti</groupId> <artifactId>activiti-spring-boot-starter-basic</artifactId> <version>7.1.0.M6</version> </dependency> <dependency> <groupId>org.apache.shiro</groupId> <artifactId>shiro-spring-boot-web-starter</artifactId> <version>1.11.0</version> </dependency> <dependency> <groupId>org.crazycake</groupId> <artifactId>shiro-redis</artifactId> <version>3.3.1</version> </dependency>

第三个依赖是 Shiro 的 Redis 缓存实现,用于分布式会话和权限缓存。单机项目可以不加,但生产环境基本都要。

数据库准备两个库(或同库不同前缀):业务库放用户、角色、权限、业务表;Activiti 库放 ACT_ 开头的表。也可以同库,但表前缀区分开,方便备份和迁移。

3.2 BPMN 流程定义与部署

流程定义文件放在src/main/resources/processes/下,命名规范建议用流程key.bpmn20.xml。一个典型的请假流程包含:发起 → 部门经理审批 → HR 审批 → 结束。用 BPMN 画出来就是几个 UserTask 加连线。

部署流程有两种方式。开发阶段用自动部署,生产环境用代码部署:

@PostConstruct public void deploy() { Deployment deployment = repositoryService.createDeployment() .name("leave-process") .addClasspathResource("processes/leave.bpmn20.xml") .deploy(); log.info("流程部署完成,部署ID:{}", deployment.getId()); }

部署后可以用repositoryService.createProcessDefinitionQuery()查询流程定义,确认版本号和 key 是否正确。每次部署同一个 key 的流程,版本号会自增,启动流程时默认用最新版本。如果需要指定版本,用startProcessInstanceById而不是startProcessInstanceByKey。

3.3 流程启动与业务数据绑定

启动流程时,通常要把业务表的 ID 作为业务键(businessKey)传进去,这样后续可以通过业务键反查流程实例。

@Transactional public void startLeaveProcess(LeaveForm form, String username) { // 1. 保存业务数据 leaveMapper.insert(form); // 2. 设置流程变量 Map<String, Object> vars = new HashMap<>(); vars.put("applicant", username); vars.put("days", form.getDays()); vars.put("deptManager", form.getDeptManager()); // 3. 启动流程,业务键用业务表主键 ProcessInstance instance = runtimeService.startProcessInstanceByKey( "leave", String.valueOf(form.getId()), vars); // 4. 回写流程实例ID到业务表 form.setProcessInstanceId(instance.getId()); leaveMapper.updateById(form); }

这里的事务边界很关键:业务落库、流程启动、回写实例ID必须在同一个事务里。否则流程启动了但业务数据没保存,或者业务保存了流程没启动,都会产生脏数据。Activiti 默认和 Spring 共用事务管理器,只要方法上加@Transactional就能保证一致性。

3.4 待办任务查询与办理

待办查询前面已经给了核心代码,这里补充分页和性能优化。Activiti 的 TaskQuery 支持listPage,但总数查询和列表查询是两次数据库交互,数据量大时要注意。我的经验是待办列表一般不会超过几百条,直接list()加内存分页也够用,但如果要做历史任务查询,必须用listPage。

办理任务时,除了调用taskService.complete,还要处理审批意见。Activiti 提供了taskService.addComment记录意见,但更推荐用流程变量或独立的审批记录表,因为 Comment 表的查询能力有限。

@Transactional public void completeTask(String taskId, String username, String comment, Map<String, Object> vars) { Task task = taskService.createTaskQuery().taskId(taskId).singleResult(); if (task == null) throw new BusinessException("任务不存在"); // 权限校验 checkTaskPermission(task, username); // 记录审批意见 taskService.addComment(taskId, task.getProcessInstanceId(), comment); // 设置变量并完成任务 if (vars != null) taskService.setVariables(taskId, vars); taskService.complete(taskId, vars); }

3.5 历史流程查询与流程图追踪

审批系统离不开历史查询和流程图展示。历史查询用historyService.createHistoricProcessInstanceQuery(),可以按发起人、时间范围、流程状态过滤。流程图追踪用repositoryService.getProcessDiagram(processDefinitionId)拿到图片流,再结合runtimeService.getActiveActivityIds或历史活动记录,把当前节点高亮出来。

高亮实现有个小技巧:Activiti 生成的流程图是 PNG,节点坐标信息在 BPMN 的 XML 里。可以用BpmnModel解析出节点坐标,然后在前端用 Canvas 或 SVG 叠加高亮框。后端只需要返回当前活动节点 ID 列表即可。

List<String> activeIds = runtimeService.getActiveActivityIds(processInstanceId); // 流程结束后从历史查 List<HistoricActivityInstance> his = historyService .createHistoricActivityInstanceQuery() .processInstanceId(processInstanceId) .list();

4. 常见问题与排查技巧实录

4.1 启动报错与依赖冲突速查

这套组合的启动报错集中在三类,我整理成速查表:

报错信息根本原因解决方案
NoSuchBeanDefinitionException: dataSourceActiviti 自动装配找不到数据源显式配置 spring.datasource,或排除 Activiti 自动配置
Table 'ACT_RE_DEPLOYMENT' doesn't exist未开启自动建表database-schema-update 设为 true
NoClassDefFoundError: javax/xml/bindJDK 11+ 移除了 JAXB手动引入 jaxb-api 依赖
Shiro 注解不生效未开启注解支持配置 AuthorizationAttributeSourceAdvisor
过滤器链顺序错乱用了 HashMap改用 LinkedHashMap

其中 JAXB 那个问题在 JDK 11 以上必现,因为 Activiti 内部依赖 JAXB 解析 BPMN XML。加两个依赖即可:

<dependency> <groupId>javax.xml.bind</groupId> <artifactId>jaxb-api</artifactId> <version>2.3.1</version> </dependency> <dependency> <groupId>org.glassfish.jaxb</groupId> <artifactId>jaxb-runtime</artifactId> <version>2.3.1</version> </dependency>

4.2 权限与流程交叉的典型故障

故障一:待办列表查不到任务。最常见的原因是候选组编码不匹配。Shiro 里角色编码是ROLE_MANAGER,BPMN 里候选组写的是manager,大小写或前缀不一致就查不到。排查方法:先查ACT_RU_IDENTITYLINK表,看任务的候选组到底是什么值,再和当前用户的角色列表比对。

故障二:任务办理报“无权操作”。除了权限校验逻辑本身,还要注意任务是否已经被别人抢走。Activiti 的候选任务在claim之前 assignee 是空的,如果两个用户同时打开待办,一个先 claim 并 complete,另一个再操作就会失败。解决方案是办理前重新查询任务状态,或者用乐观锁。

故障三:流程变量丢失。在任务办理时设置的变量,如果作用域不对,下一个节点可能读不到。Activiti 的变量分全局变量(setVariable)和本地变量(setVariableLocal)。全局变量在整个流程实例内可见,本地变量只在当前执行分支可见。审批场景一般用全局变量,但会签分支里要注意变量覆盖问题。

4.3 性能优化与生产环境注意事项

第一,历史数据清理。ACT_HI_ 开头的表会随流程运行不断增长,一个日活千级的系统,半年就能积累百万级历史记录。必须定期归档或清理。Activiti 提供了historyService.deleteHistoricProcessInstance,但更推荐按时间分区,把半年前的数据迁移到归档表。

第二,任务查询加索引。Activiti 自带的表索引在数据量大时不够用,尤其是ACT_RU_TASK表的ASSIGNEE_和PROC_INST_ID_字段。建议手动加复合索引:

CREATE INDEX idx_task_assignee ON ACT_RU_TASK(ASSIGNEE_); CREATE INDEX idx_task_proc_inst ON ACT_RU_TASK(PROC_INST_ID_);

第三,Shiro 缓存要配。每次鉴权都查数据库,QPS 一高就成瓶颈。用 Redis 缓存角色权限,设置合理的过期时间(比如 30 分钟),并在角色变更时主动清除缓存。

第四,异步执行器慎用。如果流程里有定时任务或异步节点,async-executor-activate必须开启,但要配置合理的线程池大小。默认配置在高并发下会排队,建议根据实际任务量调整async-executor-core-pool-size和async-executor-max-pool-size。

提示:生产环境部署前,务必把database-schema-update改为false,并用数据库迁移工具管理表结构。我见过因为引擎自动改表导致生产事故的案例,代价很大。

4.4 独家避坑经验分享

说几个文档里不会写、但实战中一定会遇到的坑。

坑一:Shiro 的 Session 和 Activiti 的上下文冲突。Shiro 默认用 Session 管理登录状态,而 Activiti 在某些异步场景下会丢失 SecurityManager 的上下文。解决方案是在异步任务执行前手动绑定 Subject,或者干脆用无状态 Token(JWT)替代 Session。

坑二:流程定义文件的中文乱码。BPMN 文件里如果节点名称是中文,部署后可能显示乱码。原因是文件编码和引擎解析编码不一致。解决办法是在activiti.cfg.xml或配置类里指定processEngineConfiguration.setXmlEncoding("UTF-8"),并确保 BPMN 文件本身是 UTF-8 无 BOM 格式。

坑三:驳回和撤回的实现。Activiti 原生没有“驳回”概念,需要自己实现。常见做法是查历史活动,找到目标节点,然后用runtimeService.createChangeActivityStateBuilder跳转。但这个 API 在并行网关场景下行为复杂,建议驳回逻辑单独设计,不要和正常流转混在一起。

坑四:会签节点的完成条件。会签用multiInstance实现,完成条件nrOfCompletedInstances == nrOfInstances表示全部同意才通过。但如果要支持“一票否决”,条件要改成nrOfCompletedInstances > 0 && 否决变量为true。这个条件表达式写在 BPMN 的completionCondition里,写错了会导致流程卡死。

坑五:事务回滚后流程数据不一致。如果业务方法抛异常,事务回滚,但 Activiti 的某些操作(比如addComment)可能已经提交。解决方案是把所有 Activiti 操作都放在同一个事务方法内,并确保引擎和业务共用事务管理器。Spring Boot 整合 Activiti 时默认就是共用,但如果你手动配置了多个事务管理器,就要显式指定。

我个人在实际操作中的体会是,这套组合的难点从来不在单个框架的使用,而在三者的边界处理:事务边界、权限边界、上下文边界。把这三个边界理清楚,项目就成功了一大半。剩下的就是根据具体业务场景,在流程定义和权限规则上做细化。建议新手先用一个最简单的请假流程跑通全链路,再逐步加入会签、驳回、委托这些复杂场景,不要一上来就设计大而全的流程,那样很容易在细节里迷失方向。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进