☰
SpringBoot整合Activiti与Shiro:工作流权限审批系统实战
2026/10/12 13:51:41 网站建设 项目流程

1. 为什么要在业务系统里同时引入工作流与权限框架

做过企业级业务系统的人大概都有这种体会:一个审批类需求刚上线时,流程简单得用几个状态字段就能糊弄过去,可一旦业务跑起来,节点越加越多、审批人越来越复杂、还要支持驳回、加签、转办、撤回,这时候再靠手写状态机就是给自己挖坑。我见过太多项目在第二期需求评审时被业务方一句“这个流程能不能改一下”直接问懵,最后只能推倒重来。

这个项目要解决的核心问题,就是把Activiti工作流引擎和SpringBoot、Shiro权限框架这三样东西揉到一起,搭出一套能真正落地的审批系统骨架。Activiti负责流程的定义、流转、任务分配和历史追溯;SpringBoot负责把整个工程的组织结构、依赖注入、自动配置打理干净;Shiro负责登录认证、角色授权、细粒度到按钮级别的权限控制。三者各司其职,但整合起来有一堆坑,尤其是任务候选人、候选组和Shiro角色之间的映射关系,处理不好就会出现“流程走到某节点却没人能审批”的尴尬局面。

这套东西适合谁看?如果你已经写过SpringBoot项目,对MVC那一套不陌生,但一提到工作流就只知道画个流程图却不知道怎么和业务表关联,那这篇内容就是给你准备的。如果你还在用if-else判断审批状态,那更应该看看正规军是怎么打的。我会把整合过程中的设计思路、关键配置、踩过的坑都摊开讲,代码片段可以直接抄,参数选择我会告诉你为什么这么定。

2. 整体架构设计与技术选型考量

2.1 三层职责划分的底层逻辑

先把三个框架的边界划清楚,这是整个项目不跑偏的前提。很多整合失败的案例,根源就在于职责混乱——有人把权限判断写进流程监听器里,有人把流程节点状态塞进Shiro的授权信息里,最后代码变成一团乱麻。

我的划分原则是这样的:Shiro管“谁能进这个系统、谁能点这个按钮”,Activiti管“这个单据现在走到哪、下一步该谁处理”。两者通过用户身份这个纽带连接。具体来说,Shiro的Subject里存着当前登录用户的身份信息,Activiti在创建任务时通过AssignmentHandler或者任务监听器,把这个身份信息转换成流程的候选人。

SpringBoot在这里的角色是粘合剂。它通过starter机制把Activiti的ProcessEngine、Shiro的SecurityManager都纳入IOC容器管理,让它们能共享数据源、共享事务管理器。这一点非常关键,因为流程流转和业务数据更新必须在同一个事务里,否则就会出现“任务已创建但业务单据状态没改”的数据不一致。

2.2 版本选择与依赖冲突处理

版本这块我踩过血泪教训。Activiti 6和Activiti 7的API差异很大,Activiti 7走的是SpringBoot 2.x+的路线,但它的云版本Activiti Cloud又是另一套东西,别搞混。我建议用Activiti 6.0.0配合SpringBoot 2.3.x,这个组合最稳,社区资料也最全。Shiro用1.7.x以上版本,低版本有反序列化相关的安全隐患,这个不用我多说。

依赖冲突主要集中在两点:一是Activiti自带的mybatis版本和SpringBoot管理的版本可能打架,需要在pom里显式排除再引入;二是Shiro的starter和SpringBoot的web starter在过滤器注册顺序上要协调。我的做法是在pom里把Activiti的mybatis依赖排除掉,统一用SpringBoot管理的版本,然后在配置类里手动调整FilterRegistrationBean的顺序,确保Shiro的过滤器在Activiti的Web过滤器之前执行。

<dependency> <groupId>org.activiti</groupId> <artifactId>activiti-spring-boot-starter-basic</artifactId> <version>6.0.0</version> <exclusions> <exclusion> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> </exclusion> </exclusions> </dependency>

注意:排除mybatis后要确保SpringBoot的mybatis starter版本在3.5.x以上,否则Activiti的查询会报奇怪的SQL语法错误。

2.3 数据库表结构规划思路

Activiti默认会建25张表(ACT_开头),这些表不要动,也不要去改它的字段。业务表单独建,通过BUSINESS_KEY字段和流程实例关联。我见过有人为了“省事”把业务数据直接塞进Activiti的变量表里,结果流程一多,变量表膨胀到几千万行,查询慢得没法看。

正确的做法是:业务主表存核心字段,流程实例ID和任务ID作为外键关联字段存在业务表里。这样查“我的待办”时,先用Activiti的API查出任务列表,拿到businessKey,再去业务表里查详情。虽然多一次查询,但数据边界清晰,后期维护成本低得多。

3. Shiro权限体系与Activiti任务分配的整合方案

3.1 认证流程的定制化改造

Shiro默认的认证是UsernamePasswordToken,但企业系统往往需要支持多种登录方式,比如手机号+验证码、工号+密码。我的做法是自定义一个Realm,继承AuthorizingRealm,在doGetAuthenticationInfo方法里根据登录类型走不同的校验逻辑。

这里有个细节要注意:Activiti在创建任务时,如果指定了候选人,它会去查用户表。但Activiti自带的IdentityService管理的用户表和Shiro的Realm是两套东西。我的建议是不要用Activiti的IdentityService,而是通过自定义的TaskListener,在任务创建时从Shiro的Realm里查用户信息,然后手动设置候选人。这样用户数据只有一份,避免同步问题。

public class CustomRealm extends AuthorizingRealm { @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(user, user.getPassword(), getName()); } }

3.2 角色与流程候选组的映射策略

这是整个整合中最容易出问题的地方。Activiti的候选人可以是单个用户(assignee),也可以是候选组(candidateGroup)。Shiro的角色体系里,一个用户可以有多个角色。那么问题来了:流程定义时写的候选组,到底对应Shiro的哪个概念?

我的方案是:在Shiro里维护一套“业务角色”,这套角色和Activiti的候选组一一对应。比如流程里写candidateGroups="dept_manager",那Shiro里就有一个角色叫dept_manager。用户被赋予这个角色后,就能看到对应的待办任务。

查询待办的时候,不能直接用taskService.createTaskQuery().taskCandidateUser(username),因为这样查不到候选组的任务。正确的姿势是先把用户的所有角色查出来,然后:

List<String> roleCodes = shiroService.getRoleCodesByUser(username); List<Task> tasks = taskService.createTaskQuery() .taskCandidateUser(username) .taskCandidateGroupIn(roleCodes) .orderByTaskCreateTime().desc() .list();

提示:taskCandidateUser和taskCandidateGroupIn是OR关系,不是AND,所以这样写能同时查到个人任务和角色任务。

3.3 任务监听器中的权限校验

流程流转到某个节点时,有时候需要动态判断审批人。比如金额小于1万走部门经理,大于1万走总监。这种逻辑不要写在流程定义里,而是通过任务监听器在运行时动态设置。

我通常实现一个TaskListener,在notify方法里根据流程变量判断,然后调用delegateTask.addCandidateGroup()或者delegateTask.setAssignee()。这里要注意,监听器里不要做太重的操作,比如查数据库、调远程接口,因为监听器是在流程引擎的事务里执行的,耗时太长会拖垮整个流程。

public class DynamicAssigneeListener implements TaskListener { @Override public void notify(DelegateTask delegateTask) { Double amount = (Double) delegateTask.getVariable("amount"); if (amount != null && amount > 10000) { delegateTask.addCandidateGroup("director"); } else { delegateTask.addCandidateGroup("dept_manager"); } } }

4. 完整实操流程与关键环节实现

4.1 工程搭建与基础配置

新建SpringBoot工程,pom里引入web、mybatis、mysql、shiro-spring-boot-web-starter、activiti-spring-boot-starter-basic。application.yml里配置数据源和Activiti的自动建表策略:

spring: datasource: url: jdbc:mysql://localhost:3306/workflow_demo?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 activiti: database-schema-update: true check-process-definitions: true process-definition-location-prefix: classpath:/processes/ history-level: full

history-level设为full是为了能查历史审批记录,如果只设为audit,有些变量查不到。database-schema-update设为true会自动建表,生产环境建议设为false,用SQL脚本手动初始化。

4.2 流程定义文件的编写要点

在resources/processes目录下放bpmn文件。我习惯用Eclipse或者IDEA的Activiti插件画流程图,但生成的XML要检查几个地方:userTask的activiti:candidateGroups属性、sequenceFlow的条件表达式、以及startEvent的formKey。

一个典型的请假流程bpmn片段:

<userTask id="deptApprove" name="部门经理审批" activiti:candidateGroups="dept_manager"> <extensionElements> <activiti:taskListener event="create" class="com.demo.listener.DynamicAssigneeListener"/> </extensionElements> </userTask>

注意:bpmn文件里的中文名称要确保文件编码是UTF-8,否则部署时会报XML解析错误。

4.3 业务表与流程的关联实现

业务表设计一个leave_apply表,字段包括id、user_id、days、reason、status、process_instance_id、create_time。status字段用来做列表展示,实际的状态以Activiti的流程实例为准。

发起流程的代码:

@Transactional public void startLeaveProcess(LeaveApply apply, String username) { // 1. 保存业务数据 apply.setStatus("审批中"); leaveApplyMapper.insert(apply); // 2. 设置流程变量 Map<String, Object> variables = new HashMap<>(); variables.put("applyUser", username); variables.put("days", apply.getDays()); variables.put("amount", apply.getAmount()); // 3. 启动流程实例,businessKey用业务表主键 String businessKey = String.valueOf(apply.getId()); ProcessInstance instance = runtimeService.startProcessInstanceByKey( "leaveProcess", businessKey, variables); // 4. 回写流程实例ID apply.setProcessInstanceId(instance.getId()); leaveApplyMapper.updateById(apply); }

这里@Transactional是必须的,保证业务数据和流程实例同时成功或同时失败。businessKey用业务表主键,这样从任务反查业务数据时直接businessKey就能拿到。

4.4 待办任务与已办任务的查询实现

待办查询前面已经讲了,核心是候选人+候选组的OR查询。已办任务查询用historyService.createHistoricTaskInstanceQuery().taskAssignee(username).finished()。这里有个性能优化点:如果历史任务很多,不要一次性查出来,要分页。Activiti的查询API支持listPage(offset, limit)。

我一般会封装一个TaskQueryService,把待办、已办、流程跟踪都封装成方法。流程跟踪用historyService.createHistoricActivityInstanceQuery().processInstanceId(pid).orderByHistoricActivityInstanceStartTime().asc(),这样能拿到完整的流转路径,前端画个时间轴就能展示。

4.5 审批操作的完整实现

审批通过:

@Transactional public void approve(String taskId, String comment, String username) { Task task = taskService.createTaskQuery().taskId(taskId).singleResult(); if (task == null) { throw new RuntimeException("任务不存在或已处理"); } // 添加审批意见 taskService.addComment(taskId, task.getProcessInstanceId(), comment); // 完成任务 taskService.complete(taskId); // 更新业务状态 updateBusinessStatus(task.getProcessInstanceId()); }

驳回稍微复杂一点,需要先找到目标节点,然后用runtimeService.createChangeActivityStateBuilder().moveActivityIdTo(currentActId, targetActId).changeState()。这个API是Activiti 6才有的,5.x版本要用其他方式实现。

5. 常见问题排查与避坑经验实录

5.1 任务查不到候选人问题

这是最高频的问题。现象是流程启动了,但待办列表是空的。排查步骤:先查ACT_RU_TASK表,看ASSIGNEE_和CANDIDATE_字段有没有值。如果ASSIGNEE_为空且候选组也没设置,那就是流程定义里没写候选人。如果候选组设置了但查不到,检查Shiro里用户的角色编码和流程里的候选组编码是否完全一致,大小写敏感。

还有一个隐蔽的坑:taskCandidateGroupIn传的是角色编码列表,但如果用户有角色但角色编码是中文,Activiti查询时会因为编码问题查不到。所以角色编码一律用英文。

5.2 流程变量类型不匹配问题

Activiti的变量是强类型的。如果你在启动时put了一个Integer,在监听器里用getVariable("amount")强转成Double,会抛ClassCastException。我的经验是:金额类变量统一用Double,数量类统一用Integer,字符串统一用String,在流程定义的条件表达式里也要对应类型。

条件表达式写错也是常见问题。比如${amount > 10000},如果amount是String类型,比较结果可能不符合预期。所以变量类型一定要在启动流程前就确定好。

5.3 事务与异步执行的冲突

Activiti的异步任务(比如定时边界事件)默认是在独立事务里执行的,这时候Shiro的SecurityManager可能拿不到当前用户上下文。如果异步任务里需要用户信息,要在流程变量里把用户信息传进去,而不是从Shiro的ThreadLocal里取。

另外,@Transactional和Activiti的complete方法一起用时,要注意事务传播级别。如果业务方法本身有事务,complete会加入当前事务,这是对的。但如果业务方法没有事务,complete会自己开一个事务,这时候如果后续业务操作失败,流程已经流转了,数据就不一致了。

5.4 常见问题速查表

问题现象可能原因排查方法解决方案
待办列表为空候选人/候选组未设置查ACT_RU_TASK表检查bpmn定义和监听器
流程启动报错流程定义未部署查ACT_RE_PROCDEF表检查processes目录和部署配置
审批后状态未更新事务未生效检查@Transactional注解确保业务方法和complete在同一事务
历史记录查不到history-level配置过低查ACT_HI_ACTINST表改为full级别
候选人查询慢候选组过多看SQL执行计划加索引或改用assignee

5.5 独家避坑技巧

第一个技巧:流程定义文件不要频繁改ID。流程定义的key一旦上线就不要改,改了会导致历史流程实例关联不上。如果非要改,用版本控制,Activiti会自动生成新版本。

第二个技巧:审批意见用addComment而不是setVariable。addComment会存到ACT_HI_COMMENT表,和任务关联,查询方便。setVariable存到变量表,数据量大了查询慢。

第三个技巧:前端传taskId时要做权限校验。不能只靠前端隐藏按钮,后端在complete之前要校验当前用户是否有权限处理这个任务。校验逻辑是:查任务的候选人和候选组,判断当前用户是否在其中。

private void checkTaskPermission(String taskId, String username) { Task task = taskService.createTaskQuery().taskId(taskId).singleResult(); if (task == null) { throw new RuntimeException("任务不存在"); } if (username.equals(task.getAssignee())) { return; } List<String> roleCodes = shiroService.getRoleCodesByUser(username); List<IdentityLink> links = taskService.getIdentityLinksForTask(taskId); boolean hasPermission = links.stream() .anyMatch(link -> "candidate".equals(link.getType()) && (username.equals(link.getUserId()) || roleCodes.contains(link.getGroupId()))); if (!hasPermission) { throw new RuntimeException("无权处理该任务"); } }

这个校验看起来简单,但能挡住大部分越权操作。我见过有系统因为没做这个校验,普通用户通过改URL就能审批总监节点的任务,这是很严重的安全漏洞。

6. 权限缓存与流程性能优化

6.1 Shiro缓存配置

Shiro默认每次授权都会查数据库,用户一多数据库压力很大。必须配缓存。我用的是EhCache,在ShiroConfig里设置securityManager.setCacheManager(new EhCacheManager())。缓存的是AuthorizationInfo,也就是用户的角色和权限列表。用户角色变更时要手动清缓存,调用shiroService.clearAuthorizationCache(username)。

Activiti这边也有缓存,主要是流程定义的缓存。默认是开启的,不用额外配置。但如果流程定义文件很大,部署时会慢,这是正常的。

6.2 待办查询的性能优化

待办查询是最高频的操作,优化空间很大。第一,给ACT_RU_TASK表的ASSIGNEE_和CANDIDATE_字段加索引。第二,如果候选组很多,考虑用taskCandidateGroupIn的时候限制数量,或者改用自定义SQL直接查。第三,待办列表不要查全量字段,只查需要的。

我实测过一个场景:5000个用户,每人平均20个待办,用Activiti原生API查询平均耗时200ms,加了索引后降到50ms。如果还嫌慢,可以把待办数据同步到Redis里,用空间换时间。

6.3 历史数据归档策略

ACT_HI_开头的表会随着流程运行不断增长。我的做法是:每季度归档一次,把已完成超过半年的流程实例数据迁移到历史归档表,然后从Activiti的历史表里删除。删除的时候要注意外键关联,先删ACT_HI_DETAIL,再删ACT_HI_COMMENT,最后删ACT_HI_PROCINST。

归档脚本用存储过程或者定时任务实现,不要手动删,容易删出问题。归档前一定要备份,这是铁律。

7. 项目扩展方向与个人实操体会

这套骨架搭好之后,扩展方向其实很多。比如加个流程设计器,让业务人员自己画流程图,前端用bpmn-js,后端提供部署接口。再比如加个消息通知,任务创建时发邮件或者站内信,用TaskListener的create事件触发。还可以加流程监控,用Activiti的historyService查所有运行中的流程实例,统计每个节点的平均耗时,找出瓶颈节点。

我在实际项目里踩过最大的坑是流程定义和业务代码的耦合。一开始我把业务逻辑写在Service里,流程流转也写在Service里,后来流程一改,Service就要跟着改。后来我把流程相关的操作全部抽到ProcessService里,业务Service只负责业务数据,两者通过businessKey关联,改流程的时候业务代码基本不用动。

还有一个体会是:不要试图用工作流解决所有问题。有些简单的状态流转,用状态机就够了,上工作流反而增加复杂度。工作流适合节点多、审批人动态、需要历史追溯的场景。选型的时候要克制,不要为了用而用。

最后分享一个小技巧:Activiti的runtimeService.getVariables()返回的是Map,如果流程变量很多,每次查都全量返回,性能不好。可以用getVariablesLocal()只查当前节点的局部变量,或者用getVariable()单个查。这个细节在变量多的流程里能省不少时间。

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

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

立即咨询