1. 为什么要在业务系统里把工作流和权限绑在一起做过企业级业务系统的人多半都遇到过这种场景一个请假单提交上去先到直属主管再到部门负责人最后到人事归档每一步该谁审、能看什么字段、能不能驳回全都得管得明明白白。如果只是单纯用状态字段硬编码流程一改就得动代码权限一变就得改数据库维护成本高得离谱。这时候把工作流引擎引进来让流程定义和业务代码解耦再把权限框架接上让谁能操作哪个环节变成可配置的东西才是正经做法。这篇内容围绕的是一套把工作流引擎、Spring Boot 和权限框架整合到一起的实战思路。核心目标很明确搭出一套能跑通流程定义、任务流转、节点级权限控制的最小可用系统并且这套系统要能直接往真实业务上套。适合已经写过 Spring Boot 项目、对权限控制有基本概念、但还没把工作流真正落地到生产项目里的开发者。如果你正在做 OA、审批、工单、合同流转这类系统这篇的踩坑记录和配置细节应该能帮你省下不少返工时间。需要先说明一点工作流引擎本身是个偏重的组件它有自己的数据库表、自己的会话机制、自己的任务分配逻辑。把它和权限框架塞进同一个 Spring 容器里最容易出问题的地方不是功能写不出来而是两套体系对当前用户是谁这件事的理解不一致。下面我会从选型、环境搭建、流程定义、权限打通、踩坑排查几个角度把这条链路完整走一遍。2. 技术选型背后的取舍逻辑2.1 工作流引擎为什么选它市面上能用的工作流引擎不算少常见的有 Activiti、Flowable、Camunda 这几家它们其实同源后两者都是从 Activiti 分支出来的。选 Activiti 的理由很实际文档存量多、社区案例密集、和 Spring Boot 的整合方案成熟遇到问题搜一下基本都有答案。对于大多数中小型业务系统来说它的功能完全够用没必要为了追求新版本去承担迁移风险。不过要提醒一句Activiti 7 之后的版本在架构上做了较大调整和 Spring Boot 的整合方式和 6.x 有明显差异。如果你参考的教程是 6.x 的而自己拉的是 7.x 的依赖会出现类找不到、配置不生效这类问题。我的建议是新项目直接用 6.0.0 或 7.x 中一个稳定版本锁定版本号别让 Maven 自动升级。下面这套配置以 6.0.0 为主线因为它的整合方式最直观理解之后再迁移到新版本会轻松很多。2.2 权限框架的定位要摆正权限框架负责的是认证和授权两件事认证解决你是谁授权解决你能干什么。在工作流场景里授权这件事会变得比普通 CRUD 系统复杂因为权限的粒度不再只是能不能访问某个接口而是能不能处理某个流程节点上的某个任务。这里有个常见的认知误区很多人以为把权限框架接进来流程节点的审批人就能自动管好了。实际上不是。权限框架管的是系统级的角色和资源而流程节点的候选人、候选组是工作流引擎自己的一套分配机制。两者需要做的是映射而不是替代。也就是说流程定义里写的候选组要能和权限框架里的角色对应上这样登录用户查自己的待办任务时才能通过角色匹配到该他处理的节点。2.3 版本搭配的坑要提前避开组件推荐版本说明Spring Boot2.7.x与 Activiti 6.0.0 兼容性最稳Activiti6.0.0整合方案成熟文档多权限框架1.7.x 及以上支持自定义 Realm 和过滤器数据库MySQL 8.x注意驱动版本和时区配置JDK8 或 11避免用过高版本导致字节码问题这张表不是随便列的。Spring Boot 2.7 和 Activiti 6.0.0 搭配时自动配置能省掉大量手写 Bean 的工作。如果 Spring Boot 升到 3.x底层从 Java EE 换成了 Jakarta EEActiviti 6.0.0 里的 javax 包路径会直接报错得换用支持 Jakarta 的版本这时候整合方式又变了。所以版本这件事宁可保守一点先把功能跑通再考虑升级。3. 环境搭建阶段最容易翻车的几个点3.1 依赖引入不是复制粘贴就完事引入 Activiti 的 starter 之后它会自动往数据库里建二十多张表表名大多以 ACT_ 开头。这些表分几类ACT_RE_ 是流程定义相关的ACT_RU_ 是运行时数据ACT_HI_ 是历史数据ACT_ID_ 是身份信息。理解这个分类很重要因为后面排查问题时你得知道该去查哪张表。依赖配置里有个细节容易被忽略Activiti 默认会检查数据库表是否存在不存在就自动创建。但在生产环境里自动建表这个行为是有风险的尤其是多人协作时可能因为权限或并发导致建表失败。稳妥的做法是在开发阶段让它自动建上线前把database-schema-update设成false用脚本手动初始化。spring: activiti: database-schema-update: true db-history-used: true history-level: full check-process-definitions: falsehistory-level设成full是为了保留完整的流程历史方便后面做流程追溯和审计。代价是历史表数据增长快需要定期归档。如果业务对历史要求不高设成audit或activity能省不少空间。3.2 权限框架的过滤器链顺序不能乱权限框架接入 Spring Boot 后会注册一系列过滤器来处理认证和授权。这些过滤器的执行顺序非常关键如果顺序错了会出现接口明明配了权限却拦不住或者登录接口被自己拦死的情况。核心原则是登录相关接口、静态资源、流程定义部署接口要放行业务接口要拦截。放行的路径要写在过滤器链的前面拦截规则写在后面。我见过有人把/**的拦截规则写在放行规则前面结果整个系统除了登录页全都 401排查半天才发现是顺序问题。Bean public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager) { ShiroFilterFactoryBean factoryBean new ShiroFilterFactoryBean(); factoryBean.setSecurityManager(securityManager); MapString, String filterChain new LinkedHashMap(); filterChain.put(/login, anon); filterChain.put(/static/**, anon); filterChain.put(/activiti/**, authc); filterChain.put(/**, authc); factoryBean.setFilterChainDefinitionMap(filterChain); return factoryBean; }注意这里用的是LinkedHashMap因为它能保证插入顺序而过滤器链恰恰依赖顺序。用普通的HashMap顺序不可控是个隐藏的雷。3.3 两套会话机制怎么统一这是整合过程中最核心的一个问题。权限框架有自己的 Subject 和 Session工作流引擎在查询当前用户的待办任务时也需要知道当前用户是谁。如果两边各管各的就会出现登录了但查不到待办的诡异现象。解决思路是让工作流引擎通过权限框架来获取当前用户。具体做法是在调用工作流 API 之前把权限框架里的当前用户身份同步到工作流引擎能读到的地方。Activiti 提供了IdentityService来管理用户和组可以在用户登录成功后把用户信息同步进去也可以在查询待办时直接用权限框架的用户 ID 去拼查询条件。我个人的做法偏向后者因为同步用户信息会带来数据一致性问题——权限框架里改了用户工作流这边没同步就会对不上。直接用当前登录用户的 ID 去查任务逻辑更简单也不容易出错。4. 流程定义与业务代码的对接方式4.1 流程文件放哪里、怎么部署流程定义文件通常是 BPMN 2.0 格式的 XML后缀是.bpmn20.xml或.bpmn。默认情况下Activiti 会扫描classpath:/processes/目录下的文件并自动部署。这个默认行为在开发阶段很方便但生产环境里流程变更往往需要走审批不能改个文件重启就生效。更可控的方式是关闭自动部署通过接口手动触发部署。这样每次部署都会生成一条部署记录和对应的流程定义版本旧版本还能保留正在跑的流程不受影响。部署接口大致长这样PostMapping(/deploy) public String deploy(RequestParam(file) MultipartFile file) { try { Deployment deployment repositoryService.createDeployment() .addInputStream(file.getOriginalFilename(), file.getInputStream()) .name(file.getOriginalFilename()) .deploy(); return 部署成功部署ID deployment.getId(); } catch (Exception e) { return 部署失败 e.getMessage(); } }这里有个经验部署时最好带上name否则在流程定义列表里看到的都是文件名流程一多就分不清哪个是哪个。另外同一个流程文件重复部署会生成新版本版本号自增查询时默认取最新版本。4.2 启动流程实例时怎么带上业务数据流程实例启动时通常需要关联业务数据比如请假单的 ID、申请人、请假天数。这些数据有两种存法一种是作为流程变量存进引擎另一种是存在自己的业务表里流程变量只存业务表的主键。两种方式各有适用场景。如果这些数据在流程流转过程中需要被网关条件判断用到比如请假天数大于 3 天走不同分支那就必须存成流程变量。如果只是展示用存业务表主键更干净避免流程变量表膨胀。MapString, Object variables new HashMap(); variables.put(applyUserId, currentUserId); variables.put(days, leaveDays); variables.put(businessKey, leaveId); ProcessInstance instance runtimeService.startProcessInstanceByKey(leaveProcess, leaveId, variables);注意startProcessInstanceByKey的第二个参数是 businessKey它会把流程实例和业务数据关联起来。后面查历史流程时可以用 businessKey 反查业务单据这个关联关系非常有用。4.3 任务查询怎么和权限挂钩查询待办任务时不能简单地把所有任务都查出来必须过滤出当前用户有权处理的任务。Activiti 的任务查询 API 支持按候选人、候选组、办理人过滤。ListTask tasks taskService.createTaskQuery() .taskCandidateOrAssigned(currentUserId) .taskCandidateGroupIn(currentUserRoles) .orderByTaskCreateTime().desc() .list();taskCandidateOrAssigned会同时匹配直接指派给我和我是候选人的任务taskCandidateGroupIn则匹配我所属的候选组的任务。把当前用户的角色列表传进去就能拿到该他处理的所有任务。这一步是权限和工作流真正打通的地方也是整个整合的核心价值所在。5. 节点级权限控制的实现细节5.1 候选人、候选组和权限角色的映射流程定义里每个用户任务节点可以配置 assignee办理人、candidateUsers候选人、candidateGroups候选组。其中 candidateGroups 是最适合和权限角色做映射的因为角色是稳定的而具体的人是会变动的。映射的做法是流程定义里写角色编码比如candidateGroupsROLE_MANAGER权限框架里也维护一套角色登录用户的角色列表里如果有ROLE_MANAGER就能匹配到这个节点。这样流程定义不需要关心具体是谁只关心哪个角色能办人员变动时只改权限配置不动流程。这里有个细节要注意Activiti 的候选组是字符串匹配大小写敏感。如果流程里写的是manager权限框架里存的是ROLE_MANAGER那就匹配不上。建议统一命名规范比如都用大写加前缀的形式避免这种低级错误。5.2 任务签收与权限校验的配合任务签收claim是把一个候选任务变成我的任务的动作。签收之后其他候选人就看不到这个任务了。这个动作必须做权限校验否则 A 用户可以把 B 用户的任务签收走。PostMapping(/task/claim/{taskId}) public String claim(PathVariable String taskId) { Task task taskService.createTaskQuery().taskId(taskId).singleResult(); if (task null) { return 任务不存在; } String currentUserId getCurrentUserId(); ListString roles getCurrentUserRoles(); boolean canClaim currentUserId.equals(task.getAssignee()) || roles.containsAll(getCandidateGroups(taskId)); if (!canClaim) { return 无权签收该任务; } taskService.claim(taskId, currentUserId); return 签收成功; }这段代码的关键在于签收前先校验当前用户是否在候选范围内。工作流引擎本身不会帮你做这个校验它只负责执行签收动作。权限校验必须由业务代码补上这是很多人容易漏掉的一步。5.3 流程节点的按钮级权限除了能不能处理任务还有一个更细的粒度在某个节点上用户能看到哪些操作按钮。比如申请人节点只能提交和撤回审批人节点能同意和驳回归档节点只能查看。这个粒度的控制工作流引擎本身不提供需要结合流程定义里的节点信息和权限框架的角色来判断。做法是根据当前任务所在的节点 ID查一张配置表表里记录哪个角色在哪个节点能做哪些操作。前端拿到这个配置后动态渲染按钮。节点角色可执行操作申请申请人提交、撤回主管审批主管同意、驳回、转办人事归档人事查看、归档结束全部查看这张配置表可以放在数据库里也可以放在配置文件里。放数据库的好处是能动态调整放配置文件的好处是简单。中小项目建议先放配置文件等权限需求复杂了再迁到数据库。6. 踩坑排查的完整链路6.1 待办任务查不出来的排查过程这是整合后最常遇到的问题明明流程已经流转到某个节点候选组也配了但用户登录后待办列表是空的。排查这个问题的思路要一层层往下走。第一步确认流程实例是否真的到了那个节点。查ACT_RU_TASK表看有没有对应的任务记录。如果没有说明流程根本没走到这一步问题出在流程定义或流转逻辑上跟权限无关。第二步如果任务记录存在看它的ASSIGNEE_和CANDIDATE_字段。ASSIGNEE_是直接办理人如果这个字段有值那任务就只属于这个人候选组查询是查不到的。很多人配了候选组但流程里又误配了 assignee导致候选组失效。第三步如果任务没有 assignee看候选组。候选组信息存在ACT_RU_IDENTITYLINK_表里TYPE_为candidate的记录就是候选关系。查一下GROUP_ID_的值和当前用户的角色列表比对看是否匹配。不匹配的话要么是命名不一致要么是用户角色没加载对。第四步如果候选组匹配上了但查询还是空检查查询代码里有没有加其他过滤条件比如流程定义的 key 写错了、分页参数有问题。这种低级错误反而最常见。6.2 流程变量取不到值的几种情况流程变量在网关条件判断、任务监听器里经常用到取不到值会导致流程走错分支或者报空指针。常见原因有几个一是变量作用域问题。流程变量分全局变量和局部变量全局变量在整个流程实例内可见局部变量只在当前执行分支内可见。如果在一个分支里设了局部变量另一个分支去取是取不到的。排查时查ACT_RU_VARIABLE表看变量的EXECUTION_ID_和PROC_INST_ID_判断它的作用域。二是变量类型问题。Activiti 支持多种变量类型如果存的是字符串 3网关条件里按数字比较就会出问题。建议存变量时明确类型数字就存 Integer 或 Long别图省事全存字符串。三是变量名拼写问题。这个听起来很蠢但实际排查中遇到的频率不低。流程定义里写的是applyDays代码里存的是applydays大小写不一致取不到值。建议变量名统一用小驼峰并且在流程定义和代码里用常量管理避免手写。6.3 权限框架拦截了工作流内部请求工作流引擎在流转过程中可能会触发一些内部请求比如任务监听器里调用其他服务。如果这些请求被权限框架拦截了会导致流程卡住。排查方法是看日志里有没有 401 或 403 的记录如果有看被拦截的 URL 是什么。如果是工作流内部调用的接口需要在过滤器链里放行或者给内部调用加上认证信息。注意放行内部接口时要谨慎不能简单地把整个路径放行否则会留下安全漏洞。更稳妥的做法是给内部调用配置独立的认证凭证或者用内网隔离的方式限制访问来源。7. 从能跑到好用还差哪些功夫7.1 流程图的动态高亮用户看流程时最直观的是看到流程图并且当前节点高亮显示。Activiti 提供了生成流程图的能力可以拿到流程定义图片再根据当前活动节点在图片上画框。实现思路是先用repositoryService拿到流程定义的输入流生成图片再用runtimeService查当前活动节点拿到节点坐标最后用图形库在图片上叠加高亮。这个过程涉及坐标转换因为 BPMN 文件里的坐标和图片像素坐标有缩放关系需要按比例换算。BpmnModel bpmnModel repositoryService.getBpmnModel(processDefinitionId); ProcessDiagramGenerator generator processEngineConfiguration.getProcessDiagramGenerator(); InputStream imageStream generator.generateDiagram( bpmnModel, png, runtimeService.getActiveActivityIds(processInstanceId), Collections.emptyList(), 宋体, 宋体, 宋体, null, 1.0, true);字体设置这里要注意默认字体在某些环境下会显示成方块指定中文字体能避免这个问题。另外generateDiagram的性能一般流程复杂时生成较慢建议加缓存。7.2 待办任务的性能优化待办查询在用户量大、流程多的时候会成为性能瓶颈。优化方向有几个一是加索引。Activiti 自带的表索引不一定覆盖所有查询场景根据实际查询条件在ACT_RU_TASK的ASSIGNEE_、PROC_INST_ID_等字段上补索引。二是分页查询。别一次性把所有待办查出来用listPage分页前端滚动加载。三是缓存角色信息。每次查待办都要拿当前用户的角色列表如果角色信息每次都查数据库开销不小。可以在登录时把角色缓存起来或者用本地缓存存一段时间。四是历史数据归档。ACT_HI_系列表会随着流程运行不断增长定期把已完成很久的流程历史归档到其他表或删掉能明显提升查询速度。7.3 流程版本升级时的兼容处理流程定义改了之后重新部署会生成新版本。这时候正在跑的旧流程实例还在用旧版本新启动的用新版本。如果新旧版本的节点结构差异大可能会出现旧实例流转到新版本不存在的节点导致流程卡死。处理原则是流程定义变更要尽量向后兼容不要删除已有节点新增节点要保证旧实例不会走到。如果必须做不兼容变更要么等旧实例全部跑完再部署新版本要么在代码里做版本判断对不同版本的实例走不同逻辑。我在实际项目里的做法是流程定义变更前先统计一下当前有多少在跑的实例如果数量少就等它们跑完如果数量多就评估变更影响必要时做数据迁移。这个决策没有标准答案得看业务能接受多大的风险。8. 一些实际用下来才明白的经验整合这套东西功能跑通只是第一步真正难的是让它稳定运行、方便维护。我踩过的坑里印象最深的是权限和工作流的用户体系不一致导致的待办丢失问题排查了大半天才定位到是候选组命名大小写的问题。这种问题在文档里不会写只有实际遇到了才会长记性。另一个体会是流程定义不要设计得太复杂。我见过一个审批流程光网关就有七八个条件判断嵌套好几层后来业务一改整个流程几乎重画。流程设计要留冗余能拆成子流程的就拆能简化的条件就简化别为了追求一步到位把流程画成迷宫。还有一点工作流引擎的日志级别在开发阶段建议调到 DEBUG能看到 SQL 和内部执行过程排查问题方便很多。上线后调回 INFO 或 WARN避免日志量过大。这个切换很简单但很多人忘了做结果生产环境日志刷屏真正的问题反而被淹没了。最后说个关于测试的建议工作流的测试不能只测正常流程要重点测驳回、撤回、转办、跳转这些异常路径。这些路径在实际使用中触发频率不低但开发时容易被忽略上线后一出问题就是流程卡死影响面很大。写测试用例时把每个节点的每种操作都覆盖一遍虽然费时间但能省下后面大量的救火时间。