Spring Boot 整合 Activiti 工作流:从踩坑到成功的Demo
本文记录所有踩坑经历与解决方案,附带完整代码,希望能帮你节省时间。
一、背景
工作流引擎不是新东西,但很多初学者在第一步——**整合 Spring Boot**——就会被各种依赖冲突、自动建表失败、测试用例跑不通等问题劝退。本文从零开始,搭建一个基于 Spring Boot 2.7.18 + Activiti 7.1.0.M6 的请假审批 Demo,包含部署、启动、员工申请、经理审批、HR 备案、驳回等完整功能。
二、大坑预警:Activiti 与 MyBatis-Plus 的版本冲突
**这是整个整合过程中最恶心的问题,没有之一。**
Activiti 7.1.0.M6 内部依赖了 **MyBatis 3.5.0**。而 MyBatis-Plus 3.5.x 需要 MyBatis 3.5.9+,因为新版本中 `Configuration` 类增加了 `setShrinkWhitespacesInSql(boolean)` 和 `parsePendingMethods(boolean)` 等方法。如果你的项目同时引入了 Activiti 和 MyBatis-Plus,启动时会报类似:
```
java.lang.NoSuchMethodError: 'void org.apache.ibatis.session.Configuration.setShrinkWhitespacesInSql(boolean)'
```
尝试过的解决方案(均失败)
1. 在 pom.xml 中排除 Activiti 传递的 mybatis,并手动指定高版本
xml
<exclusion>
<groupId>org.mybatis</groupId>
<artifactId>mybatis</artifactId>
</exclusion>
<!-- 再手动引入 3.5.13 -->
依然报错,因为 IDEA / Maven 的依赖仲裁有时会“顽强”地拉低版本。
2. dependencyManagement 强制锁定版本
仍然无效,运行时 classpath 中还是出现 3.5.0。
最终出路一:彻底放弃 MyBatis-Plus
改用 Spring Data JPA。业务表 `leave_order` 只有简单增删改查,JPA 完全够用,而且与 Activiti 零冲突。
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency>
最终出路二(经验证可行):升级 MyBatis 版本
如果坚持使用 MyBatis-Plus,可以尝试将 MyBatis 版本升级到 **3.5.16**(配合 MyBatis-Plus 3.5.7),经实际测试可以解决冲突。pom 配置如下
<properties> <mybatis.version>3.5.16</mybatis.version> <mybatis-plus.version>3.5.7</mybatis-plus.version> </properties> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>${mybatis.version}</version> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>${mybatis-plus.version}</version> </dependency>并在 Activiti 依赖中排除旧 mybatis:
<dependency> <groupId>org.activiti</groupId> <artifactId>activiti-spring-boot-starter</artifactId> <version>7.1.0.M6</version> <exclusions> <exclusion> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> </exclusion> </exclusions> </dependency>
> 注意:如果排除后仍然报错,请检查是否还有其他依赖(如 `activiti-engine`)传递了旧版本,需要一并排除。
三、第二个坑:Activiti 自动建表失败
配置明明写了 `spring.activiti.database-schema-update=true`,但启动后数据库里一张 `ACT_` 开头的表都没有。
原因分析
1. `application.yml` 中漏写了数据源配置(很多人把数据源写在 `application.properties` 里,但忘记移到 `yml` 中,而 Activiti 只认当前文件中的配置)。
2. MySQL 8.x 需要额外参数:`nullCatalogMeansCurrent=true`,否则 Activiti 会扫描所有库而找不到表。
3. 数据库用户权限不足:无法执行 `CREATE TABLE`。
解决方案
确保 `application.yml` 中同时包含数据源和 Activiti 配置:
spring: datasource: url: jdbc:mysql://localhost:3306/activiti_demo?useUnicode=true&characterEncoding=utf8&serverTimezone=GMT%2B8&nullCatalogMeansCurrent=true&useSSL=false username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver activiti: database-schema-update: true db-history-used: true history-level: full async-executor-activate: false security: enabled: false # 禁用 Activiti 自带的 Spring Security,否则会要求 UserDetailsService
重启后,控制台会看到类似日志:
performing create on engine with resource org/activiti/db/create/activiti.mysql.create.engine.sql
然后数据库中出现 25 张 `ACT_*` 表。(后来我才发现,我的数据库没连上,导致没有及时刷新而对应的所有表都没有,后来一刷新全出来了)
---
四、第三个坑:如果你只是单纯的想使用工作流那就得注意他自带的Security里面的坑太多了
在整合 Activiti 工作流引擎时,很多开发者会直接引入activiti-spring-boot-starter,然后惊讶地发现:项目还没开始写流程,启动就报错了,或者莫名其妙出现一个登录页面。这一切的根源,都是 Activiti 自带的 Spring Security 模块在作祟。
坑点1:启动失败,要求 UserDetailsService
NoSuchBeanDefinitionException: No qualifying bean of type 'org.springframework.security.core.userdetails.UserDetailsService' available原因:
ActivitiSpringIdentityAutoConfiguration中有一个userGroupManager方法依赖了UserDetailsService,而你的项目没有提供。
坑点2:还是要求 UserGroupManager
解决方案
主要是这个usergroupManager你还不能不给他,activiti的配置里面没有明确的security配置让你去关闭,所以我们只能自己实现他的配置类,我的做法是这样的
package com.lucky.acitivitistudy.config; import org.activiti.api.runtime.shared.identity.UserGroupManager; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import java.util.Collections; import java.util.List; /** * Activiti身份管理配置,全部返回空列表,确保不受权限控制影响 */ @Configuration public class ActivitiIdentityConfig { @Bean public UserGroupManager userGroupManager() { return new UserGroupManager() { @Override public List<String> getUserGroups(String userId) { return Collections.emptyList(); } @Override public List<String> getUserRoles(String s) { return List.of(); } @Override public List<String> getGroups() { return List.of(); } @Override public List<String> getUsers() { return List.of(); } }; } }直接返回空列表
然后在启动类上面加上这个:exclude排除一部分,就可以了
package com.lucky.acitivitistudy; import org.activiti.api.runtime.shared.identity.UserGroupManager; import org.activiti.core.common.spring.identity.config.ActivitiSpringIdentityAutoConfiguration; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration; @SpringBootApplication(exclude = { SecurityAutoConfiguration.class, ActivitiSpringIdentityAutoConfiguration.class }) public class AcitivitiStudyApplication { public static void main(String[] args) { SpringApplication.run(AcitivitiStudyApplication.class, args); } }
五、下面是我的代码和测试(需要的可以去github上自行拉取代码)
链接:Lucky
测试用例:
package com.lucky.acitivitistudy; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import org.hamcrest.Matchers; import org.junit.jupiter.api.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @SpringBootTest @AutoConfigureMockMvc @TestMethodOrder(MethodOrderer.OrderAnnotation.class) public class AcitivitiStudyApplicationTests { @Autowired private MockMvc mockMvc; @Autowired private ObjectMapper objectMapper; private static String managerTaskId; // 经理的任务ID @Test @Order(1) void testDeployProcess() throws Exception { mockMvc.perform(post("/api/leave/deploy")) .andExpect(status().isOk()) .andExpect(content().string(org.hamcrest.Matchers.containsString("流程部署成功"))) .andDo(result -> System.out.println("✅ 流程部署成功")); } @Test @Order(2) void testStartProcess() throws Exception { MultiValueMap<String, String> params = new LinkedMultiValueMap<>(); params.add("applicant", "zhangsan"); params.add("days", "2"); params.add("reason", "test"); mockMvc.perform(post("/api/leave/start") .params(params) .contentType(MediaType.APPLICATION_FORM_URLENCODED)) .andExpect(status().isOk()) .andExpect(content().string(org.hamcrest.Matchers.containsString("请假申请已提交"))) .andDo(result -> System.out.println("✅ 请假流程启动成功: " + result.getResponse().getContentAsString())); } @Test @Order(3) void testCompleteApplyTask() throws Exception { // 查询申请人 zhangsan 的待办任务(员工填写申请) MvcResult result = mockMvc.perform(get("/api/leave/tasks/todo") .param("assignee", "zhangsan")) .andExpect(status().isOk()) .andReturn(); String json = result.getResponse().getContentAsString(); JsonNode tasks = objectMapper.readTree(json); Assertions.assertTrue(tasks.isArray() && tasks.size() > 0, "未查询到申请人的待办任务"); // 修改点:字段名为 "id" String applyTaskId = tasks.get(0).get("id").asText(); // 完成员工申请任务 mockMvc.perform(post("/api/leave/complete") .param("taskId", applyTaskId) .param("approved", "true") .param("comment", "提交申请")) .andExpect(status().isOk()) .andDo(result2 -> System.out.println("✅ 员工申请任务已完成")); } @Test @Order(4) void testQueryManagerTasks() throws Exception { // 现在流程应该流转到经理审批,查询经理的待办 MvcResult result = mockMvc.perform(get("/api/leave/tasks/todo") .param("assignee", "manager")) .andExpect(status().isOk()) .andReturn(); String json = result.getResponse().getContentAsString(); JsonNode tasks = objectMapper.readTree(json); Assertions.assertTrue(tasks.isArray() && tasks.size() > 0, "未查询到经理的待办任务"); // 修改点:字段名为 "id" managerTaskId = tasks.get(0).get("id").asText(); System.out.println("✅ 查询到经理的待办任务,taskId=" + managerTaskId); } @Test @Order(5) void testCompleteHrTask() throws Exception { // 查询 hr 的待办任务 MvcResult result = mockMvc.perform(get("/api/leave/tasks/todo") .param("assignee", "hr")) .andExpect(status().isOk()) .andReturn(); String json = result.getResponse().getContentAsString(); JsonNode tasks = objectMapper.readTree(json); Assertions.assertTrue(tasks.isArray() && tasks.size() > 0, "未查询到HR的待办任务"); String hrTaskId = tasks.get(0).get("id").asText(); mockMvc.perform(post("/api/leave/complete") .param("taskId", hrTaskId) .param("approved", "true") .param("comment", "同意")) .andExpect(status().isOk()); System.out.println("✅ HR任务已完成,流程结束"); } @Test @Order(6) void testCompleteManagerTask() throws Exception { Assertions.assertNotNull(managerTaskId, "managerTaskId不应为空"); mockMvc.perform(post("/api/leave/complete") .param("taskId", managerTaskId) .param("approved", "true") .param("comment", "我同意了")) .andExpect(status().isOk()) .andExpect(content().string(Matchers.containsString("任务处理完成"))) .andDo(result -> System.out.println("✅ 经理审批完成: " + result.getResponse().getContentAsString())); } }如果小于等于三天的请假则执行order为1235,否则执行12345。
整体流程是这样的
员工 ──(填写申请)──> 完成申请任务 ──> 网关判断天数
│
┌──────────────────────┴────────────────┐
│ (days >= 3) │ (days < 3)
▼ ▼
经理审批 ──(同意)──> HR备案 ──> 结束 HR备案 ──> 结束
│
└──(驳回)──> 返回员工重新填写
更多推荐


所有评论(0)