
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); MapString, Filter filters new LinkedHashMap(); filters.put(authc, new JwtFilter()); factory.setFilters(filters); MapString, 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 的候选组编码保持一致查询时把当前用户的角色列表传进去。ListTask 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 groupIdorg.activiti/groupId artifactIdactiviti-spring-boot-starter-basic/artifactId version7.1.0.M6/version /dependency dependency groupIdorg.apache.shiro/groupId artifactIdshiro-spring-boot-web-starter/artifactId version1.11.0/version /dependency dependency groupIdorg.crazycake/groupId artifactIdshiro-redis/artifactId version3.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. 设置流程变量 MapString, 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, MapString, 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 列表即可。ListString activeIds runtimeService.getActiveActivityIds(processInstanceId); // 流程结束后从历史查 ListHistoricActivityInstance his historyService .createHistoricActivityInstanceQuery() .processInstanceId(processInstanceId) .list();4. 常见问题与排查技巧实录4.1 启动报错与依赖冲突速查这套组合的启动报错集中在三类我整理成速查表报错信息根本原因解决方案NoSuchBeanDefinitionException: dataSourceActiviti 自动装配找不到数据源显式配置 spring.datasource或排除 Activiti 自动配置Table ACT_RE_DEPLOYMENT doesnt exist未开启自动建表database-schema-update 设为 trueNoClassDefFoundError: javax/xml/bindJDK 11 移除了 JAXB手动引入 jaxb-api 依赖Shiro 注解不生效未开启注解支持配置 AuthorizationAttributeSourceAdvisor过滤器链顺序错乱用了 HashMap改用 LinkedHashMap其中 JAXB 那个问题在 JDK 11 以上必现因为 Activiti 内部依赖 JAXB 解析 BPMN XML。加两个依赖即可dependency groupIdjavax.xml.bind/groupId artifactIdjaxb-api/artifactId version2.3.1/version /dependency dependency groupIdorg.glassfish.jaxb/groupId artifactIdjaxb-runtime/artifactId version2.3.1/version /dependency4.2 权限与流程交叉的典型故障故障一待办列表查不到任务。最常见的原因是候选组编码不匹配。Shiro 里角色编码是ROLE_MANAGERBPMN 里候选组写的是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或者干脆用无状态 TokenJWT替代 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 时默认就是共用但如果你手动配置了多个事务管理器就要显式指定。我个人在实际操作中的体会是这套组合的难点从来不在单个框架的使用而在三者的边界处理事务边界、权限边界、上下文边界。把这三个边界理清楚项目就成功了一大半。剩下的就是根据具体业务场景在流程定义和权限规则上做细化。建议新手先用一个最简单的请假流程跑通全链路再逐步加入会签、驳回、委托这些复杂场景不要一上来就设计大而全的流程那样很容易在细节里迷失方向。