JeecgBoot集成Activiti 5.22工作流引擎:从装配到业务闭环实战 简介面向Java企业级开发者的JeecgBoot与Activiti5.22集成实践资料包围绕Spring Boot快速开发平台与BPMN2.0工作流引擎的组合解决将流程建模、部署、启动、任务处理、监控优化嵌入业务系统的常见问题。压缩包共2000个文件体积约26.83MB文件类型以566个Java源码、360个JavaScript、321个Vue页面、299份SQL、109份XML为主覆盖后端逻辑、前端交互、流程定义与数据库脚本另有大量图片和样式资源便于对照界面效果。已有10318人学习下载。资料结合JeecgBoot的代码生成器与Activiti的事件机制、监听器配置可帮助开发者理解核心集成步骤掌握从依赖引入、流程模型创建到部署运行的完整链路并参考其中示例快速搭建具备流程管理能力的企业应用适合具备Spring Boot基础、需要落地工作流模块的中高级Java工程师。1. 把 JeecgBoot 和 Activiti 5.22 装在一起到底解决什么问题先说结论JeecgBoot 集成 Activiti 5.22 这件事真正难的不是把引擎跑起来而是让流程引擎和你已有的用户体系、业务表单、单据状态这三样东西咬合在一起。JeecgBoot 强在在线表单、代码生成、权限和报表但它没有内置一套成熟的工作流引擎Activiti 5.22 是 5.x 系列最后一个稳定版本API 简单、社区资料多很多团队在给 JeecgBoot 补审批能力时第一反应就是把它接进来。这个方案能解决的是用 JeecgBoot 在线表单做业务单据用 Activiti 负责流程流转两边通过 businessKey 关联审批完成后回写业务表状态。适合正在用 JeecgBoot 做 OA、报销、合同、请假审批的团队也适合想把旧系统里的 Activiti 5 流程迁到 JeecgBoot 里的人。后面的内容会按「依赖装配 → 跑通闭环 → 业务打通 → 踩坑排查」的顺序给你一条可以直接照着做的路径。2. 把 Activiti 5.22 依赖和数据源装进 JeecgBoot单库优先的引擎装配2.1 选型为什么选 5.22 而不是 6.x 或 7.x很多第一次接触的人会问Activiti 都出到 7 了为什么非要集成 5.22 这个老版本。真实情况是市面上大量存量项目的工作流代码、流程文件、监听器都是按 5.x API 写的直接升级到 6.x 或 7.x 意味着ProcessEngine的装配方式变了、部分历史表结构变了、Spring Boot 自动配置的坐标也变了迁移成本不小。5.22 是 5 系列最后一个版本API 稳定网上能搜到的资料和踩坑记录也最多遇到问题基本都能找到答案。Activiti 5.22 属于javax时代的产品如果你手里的 JeecgBoot 是 Spring Boot 2.x 那没问题如果是 Spring Boot 3.x包名从javax换到jakarta会导致 Activiti 的 Spring 集成类加载出错这种情况不建议硬上 5.22。所以这个选型的前提是你的 JeecgBoot 还是 Spring Boot 2.x或者你愿意为工作流单独维护一套兼容层。我一般会先确认目标项目里 Spring Boot 的大版本再决定是否走 5.22 路线。5.22 还有一个隐形成本它没有官方 starterSpring Boot 项目里必须手工装配引擎不能用spring-boot-starter-activiti一键搞定。这既是缺点也是优点——装配过程是显式的出了问题你知道去哪里查不至于像黑匣子一样只能重启试运气。2.2 Maven 依赖引入 activiti-spring 还是纯 activiti-engine常见做法是同时引入两个依赖activiti-spring负责把引擎挂到 Spring 容器里activiti-engine显式锁定引擎版本。这样做的原因是activiti-spring会把activiti-engine作为传递依赖带进来但版本仲裁在复杂的 JeecgBoot 工程里容易出意外单独写一行更可控。dependency groupIdorg.activiti/groupId artifactIdactiviti-spring/artifactId version5.22.0/version /dependency dependency groupIdorg.activiti/groupId artifactIdactiviti-engine/artifactId version5.22.0/version /dependency这里有两个参数层面的注意点。第一不要轻易排除 Activiti 自带的 mybatis 依赖5.22 内部强依赖mybatis 3.2.x强行替换成高版本会在流程查询时出现 XML 映射不兼容这个坑留着第 5 章细说。第二activiti-spring的自动装配机制对 Spring Boot 并不友好所以引入了它之后也不要指望它帮你创建ProcessEngine我们接下来手工配一个配置类接管装配。如果你的工程里已经有大量自定义的 Jackson、Shiro、MyBatis-Plus 配置引入 Activiti 后最典型的现象是启动时出现 Bean 冲突或类型转换异常。遇到这种情况不用慌先看是不是依赖仲裁把mybatis或spring-beans版本改了常见的做法是引入后单独跑一个最小启动类验证依赖树不要直接塞进大工程里一起编译。2.3 数据源规划先单库跑通再考虑独立库集成方案里第一个要拍板的事Activiti 的 ACT_* 表放在哪个数据库。我给一个明确的建议——项目初期用单库也就是 Activiti 的 25 张左右 ACT_ 表和 JeecgBoot 的业务表放在同一个数据库实例里等流程稳定了再考虑拆独立库。方案优点缺点适合阶段单库混用事务可控流程回写业务表在一个事务里ACT_* 表与业务表耦合备份恢复时容易误伤开发联调、项目上线初期独立库权限隔离清晰业务库不会被流程表干扰跨库事务没有内置方案监听器回写状态需要额外补偿机制流程量大、有独立运维要求的团队选择单库还有一个现实原因Activiti 5.22 的 Spring 集成方案在设计上就是和 Spring 事务管理器绑在一起的。业务表和流程表同库时taskService.complete()和你的业务状态更新可以放进同一个Transactional要么一起成功要么一起回滚。一旦拆成两个库Activiti 完成事务后业务库更新失败你就得自己写对账任务处理那些「流程已结束但单据还挂着未审批」的脏数据。不要在生产环境第一天就把自己逼到必须做分布式事务的境地。2.4 引擎装配代码与参数说明下面是核心的引擎装配类我一般会放在config包下和业务配置分开。这段代码在 JeecgBoot 里基本可以原样复制只需要根据你的数据源调整注入方式。Configuration public class ActivitiConfig { Bean(destroyMethod close) public ProcessEngine processEngine(DataSource dataSource, PlatformTransactionManager transactionManager) { SpringProcessEngineConfiguration cfg new SpringProcessEngineConfiguration(); cfg.setDataSource(dataSource); cfg.setTransactionManager(transactionManager); cfg.setDatabaseSchemaUpdate(true); cfg.setHistoryLevel(HistoryLevel.FULL); cfg.setJobExecutorActivate(true); cfg.setActivityFontName(宋体); cfg.setLabelFontName(宋体); return cfg.buildProcessEngine(); } Bean public RepositoryService repositoryService(ProcessEngine processEngine) { return processEngine.getRepositoryService(); } Bean public RuntimeService runtimeService(ProcessEngine processEngine) { return processEngine.getRuntimeService(); } Bean public TaskService taskService(ProcessEngine processEngine) { return processEngine.getTaskService(); } Bean public HistoryService historyService(ProcessEngine processEngine) { return processEngine.getHistoryService(); } }几个关键参数的说明destroyMethod close保证应用停掉时引擎资源一起释放不加的话 Spring 容器关闭时可能残留后台线程。databaseSchemaUpdate开发环境用true引擎启动时自动建表或升级结构生产环境务必改成none由 DBA 统一管理脚本。HistoryLevel.FULL很重要它决定历史表里保留多少数据。如果只是默认级别流程结束后很多变量和历史节点查不到后面做已办列表、流程追踪图都会缺数据。activityFontName和labelFontName是给流程图的节点和标签指定字体不配置的话 Linux 服务器上生成的流程图片中文全是方块。jobExecutorActivate(true)表示启动定时任务执行器。如果你的流程里没有定时器事件可以设false少一个后台线程就少一份日志噪音。注意DataSource如果是 JeecgBoot 里现成的主数据源直接注入即可因为SpringProcessEngineConfiguration会单独构建自己的 SqlSessionFactory不会和 MyBatis-Plus 共享会话工厂。如果你后续拆独立库就新建一个独立的DataSourceBean 和独立的DataSourceTransactionManager只给 Activiti 用。2.5 启动后必须做的三个验证引擎装配完成后不要急着写流程。先重启项目确认三件事。第一看启动日志里有没有ProcessEngine created这行字。没有的话说明你的配置类没被 Spring 扫描到或者容器里同时存在另一个ProcessEngineBean 把它覆盖了。第二连上数据库确认ACT_GE_PROPERTY表已经被创建并且schema.version字段的值是5.22.0。如果这张表不存在说明databaseSchemaUpdate没生效最常见的原因是配置类没加载或者数据源被换成了别的代理对象。第三用一个最简单的 SQL 检查一下引擎连的是不是预期库SELECT * FROM act_ge_property WHERE name_ schema.version;这一步是在验证表前缀。如果 JeecgBoot 的在线表单表也有类似表名或者你的数据库里有多个ACT_GE_PROPERTY说明引擎建表时可能落到了别的库。这类问题越早发现越好等流程跑起来再查就是几个小时起步的排障。3. 部署第一条请假流程从 BPMN 文件到待办审批闭环3.1 BPMN 文件放哪里如何让它启动时自动部署流程文件建议统一放在src/main/resources/processes/目录下每个业务流程一个.bpmn20.xml文件。开发阶段最省事的做法是写一个启动部署器应用启动时自动把流程定义部署到引擎里不用每次打开 Activiti Modeler 手动上传。Component public class ActivitiDeployer { private final RepositoryService repositoryService; public ActivitiDeployer(RepositoryService repositoryService) { this.repositoryService repositoryService; } PostConstruct public void deployOnStartup() { Deployment deployment repositoryService.createDeployment() .addClasspathResource(processes/leave.bpmn20.xml) .name(请假流程) .category(oa) .enableDuplicateFiltering() .deploy(); } }这段代码里最需要理解的是enableDuplicateFiltering()。它的作用是如果这个流程定义之前已经部署过且部署名和资源内容都没变就跳过重复部署。听起来很方便但实际开发中经常给人挖坑——你改了 BPMN 文件内容只要流程 key 没变它依然认为「重复」了导致你改的流程永远不生效。开发阶段我一般不加这行或者部署名里带上时间戳保证每次修改都能重新部署。生产环境的做法不一样那时候就不建议用PostConstruct自动部署了流程版本应该走发布流程由管理员通过管理端上传 BPMN或者用 CI 脚本在发版时显式部署。自动部署在开发期是省事利器上线后就是失控的根源。3.2 请假流程图BPMN XML 写多细才合适对于刚集成的团队第一张流程图不要画得太复杂一条线性审批链加一个条件网关就够了。下面是一个最小可运行的请假流程定义包含了发起、部门审批、条件判断、人事确认、结束五个节点。?xml version1.0 encodingUTF-8? definitions xmlnshttp://www.omg.org/spec/BPMN/20100524/MODEL xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xmlns:activitihttp://activiti.org/bpmn targetNamespacehttp://activiti.org/bpmn20 process idleave name请假流程 isExecutabletrue startEvent idstartEvent activiti:initiatorapplyUser / userTask iddeptApprove name部门审批 activiti:assignee${deptApprover} / exclusiveGateway idjudge name是否通过 / userTask idhrConfirm name人事确认 activiti:assignee${hrApprover} / endEvent idendEvent / sequenceFlow idf1 sourceRefstartEvent targetRefdeptApprove / sequenceFlow idf2 sourceRefdeptApprove targetRefjudge / sequenceFlow idf3 sourceRefjudge targetRefendEvent conditionExpression xsi:typetFormalExpression![CDATA[${!approved}]]/conditionExpression /sequenceFlow sequenceFlow idf4 sourceRefjudge targetRefhrConfirm conditionExpression xsi:typetFormalExpression![CDATA[${approved}]]/conditionExpression /sequenceFlow sequenceFlow idf5 sourceRefhrConfirm targetRefendEvent / /process /definitions这里有两个值得注意的写法。activiti:initiatorapplyUser表示流程发起人引擎会把当前操作人记录到流程实例上后面做发起人查询和历史归属都用得上但前提是发起流程前必须调用Authentication.setAuthenticatedUserId()。activiti:assignee${deptApprover}是用户任务的办理人它从流程变量里动态取值所以发起流程时必须有deptApprover这个变量否则流程会卡在「没有办理人」的状态。如果你后面要支持多人审批、会签或或者签5.22 还支持activiti:candidateUsers和activiti:candidateGroups但第一版流程建议只用assignee把变量传递的逻辑先跑通再考虑复杂分配。3.3 发起、查询待办、审批的最小接口流程定义部署好之后需要一组最基础的接口把流程跑起来。下面是去掉鉴权和参数校验后的核心代码你可以在 JeecgBoot 的 Controller 里直接套用。RestController RequestMapping(/workflow) public class WorkflowController { private final RuntimeService runtimeService; private final TaskService taskService; public WorkflowController(RuntimeService runtimeService, TaskService taskService) { this.runtimeService runtimeService; this.taskService taskService; } /** 发起流程 */ PostMapping(/start) public ResultString start(RequestBody StartRequest req) { Authentication.setAuthenticatedUserId(req.getApplyUser()); MapString, Object vars new HashMap(); vars.put(deptApprover, req.getDeptApprover()); vars.put(approved, false); ProcessInstance pi runtimeService.startProcessInstanceByKey( req.getProcessKey(), req.getBusinessKey(), vars); return Result.OK(pi.getId()); } /** 查询某人待办 */ GetMapping(/todo) public ResultListTaskVO todo(RequestParam String username) { ListTask tasks taskService.createTaskQuery() .taskAssignee(username) .active() .orderByTaskCreateTime().desc() .list(); return Result.OK(tasks.stream().map(TaskVO::new).toList()); } /** 执行审批 */ PostMapping(/complete) public ResultString complete(RequestBody CompleteRequest req) { Authentication.setAuthenticatedUserId(req.getOperator()); MapString, Object vars new HashMap(); vars.put(approved, req.isApproved()); vars.put(hrApprover, req.getHrApprover()); taskService.complete(req.getTaskId(), vars); return Result.OK(审批完成); } }三个接口对应的业务含义是/start里startProcessInstanceByKey的第一个参数是 BPMN 里的process id第二个参数businessKey后续用来绑定 JeecgBoot 表单主键第三个参数是流程变量。/todo查询待办时用了.active()它过滤掉挂起状态的流程实例避免把暂停的任务也返回给前端。/complete审批时把approved作为网关判断依据这个变量会直接影响流程走向所以发起和审批两个接口里的变量命名必须和 BPMN 里conditionExpression保持一致。Authentication.setAuthenticatedUserId这个调用很多人会漏掉。它不负责权限判断只负责把当前操作人写入历史记录。如果不调用流程实例的发起人字段为空后面做已办列表、「我发起的」这种查询全会出问题。3.4 跑通后检查什么最小闭环跑通后用下面几条 SQL 验证数据落位SELECT * FROM act_ru_task WHERE assignee_ lisi; SELECT * FROM act_hi_proc_inst WHERE business_key_ BUS-2024-001; SELECT * FROM act_hi_task_inst WHERE proc_inst_id_ 具体流程实例ID;检查点有三个ACT_RU_TASK里能查到当前待办任务说明用户任务分配正确ACT_HI_PROC_INST里有完整流程实例说明businessKey和发起人已记录ACT_HI_TASK_INST里能看到每一个历史节点的操作时间和办理人说明HistoryLevel.FULL生效。如果第三条查不到数据基本就是历史级别配置问题。4. 把流程变量和业务表绑定businessKey、用户体系与状态回写4.1 JeecgBoot 在线表单负责数据Activiti 负责流转很多团队初学集成时会纠结一个问题表单数据到底存哪里是存 JeecgBoot 在线表单生成的表还是存 Activiti 的ACT_*表。答案是业务数据永远存业务表Activiti 只存流程数据。JeecgBoot 的在线表单生成一张物理表用户在前端填完单子后保存拿到一个主键 ID这个 ID 就是流程的businessKey。流程变量里只放「谁审批」「审批结果」这类流转控制字段不要把整张表单 JSON 塞进流程变量里否则历史表会迅速膨胀查询性能直线下降。在实际项目里遇到最常见的问题是把表单内容放进变量后业务方要求改表单字段结果发现流程实例里存的是旧结构旧流程审批页直接报错。正确做法是流程变量只存轻量字段重数据走业务表需要展示时用businessKey去反查在线表单。这样才能保证业务表和流程引擎各自演进。我自己在接集成方案时先把这条边界当作铁律告诉业务开发能省掉后面大量返工。4.2 用 businessKey 打通单据与流程实例businessKey是 JeecgBoot 表单和 Activiti 流程实例之间的唯一关联键。发起流程时把在线表单保存后的主键传进去之后任何时候都可以通过流程实例反查业务表也可以从业务表反查流程状态。// 保存JeecgBoot在线表单得到业务主键 String businessKey leaveFormService.save(formData); // 发起流程前先检查这个业务主键是否已有运行中的流程实例 long runningCount runtimeService.createExecutionQuery() .processInstanceBusinessKey(businessKey) .count(); if (runningCount 0) { throw new BizException(该单据已在审批中不能重复发起); } // 绑定businessKey发起流程 ProcessInstance pi runtimeService.startProcessInstanceByKey( leave, businessKey, vars);这段代码里有一个不值得写在需求文档里、但上线必踩的细节重复发起。审批系统最常见的脏数据就是同一个人对同一张单子点了两次「提交」流程引擎生成了两条流程实例业务方最后要对账才能发现。所以在发起前用processInstanceBusinessKey查一下运行中的实例数量是最低成本的事前控制。还需要注意businessKey是字符串类型最大长度 255。如果你用雪花 ID 没问题如果用数据库自增 ID 也没问题但不要传对象过去引擎会做类型转换然后存成字符串。这个坑不大但能让你少一次控制台报错排查。4.3 流程结束后的状态回写监听器怎么绑审批流结束以后业务表不能还停在「审批中」必须有一个机制把状态更新掉。常见做法是给流程定义加一个执行监听器监听整个流程实例的end事件。在流程结束节点触发时通过businessKey反查业务表更新状态为「已通过」或「已驳回」。Component public class LeaveProcessEndListener implements ExecutionListener { private final LeaveService leaveService; public LeaveProcessEndListener(LeaveService leaveService) { this.leaveService leaveService; } Override public void notify(DelegateExecution execution) { String businessKey execution.getProcessInstanceBusinessKey(); Boolean approved (Boolean) execution.getVariable(approved); String status Boolean.TRUE.equals(approved) ? APPROVED : REJECTED; leaveService.updateStatusByBusinessKey(businessKey, status); } }监听器写好后最难的其实是把它挂到流程定义上。如果你直接在 BPMN 里写class属性Activiti 会自己反射 new 一个实例这个实例不在 Spring 容器里里面的leaveService是 null运行时就会报空指针。常见做法是把监听器配置成 Spring Bean然后在引擎配置里显式注册到 beans 中BPMN 里用表达式引用。Bean public LeaveProcessEndListener leaveProcessEndListener(LeaveService leaveService) { return new LeaveProcessEndListener(leaveService); }cfg.setBeans(Collections.singletonMap( leaveProcessEndListener, leaveProcessEndListener));BPMN 的process元素里这样绑定process idleave name请假流程 isExecutabletrue extensionElements activiti:executionListener eventend expression${leaveProcessEndListener} / /extensionElements /process这里的expression${leaveProcessEndListener}会从引擎配置的 beans 里找名字叫leaveProcessEndListener的实例。需要注意监听器里更新业务表状态时如果业务表和流程库在同一个事务里流程结束和状态更新会一起提交如果是独立库更新失败不会回滚流程必须自己补对账任务。这就是前面第 2.3 节建议单库起步的原因状态回写这件事在单库方案里是白送的在独立库方案里是额外工作。4.4 用户体系打通任务分配到底存 username 还是 userId用户表打通是集成里最绕的一环。JeecgBoot 的登录用户表是sys_user主键是id还有username字段。Activiti 自己也有ACT_ID_*用户表但默认和sys_user没有任何关系。如果你去同步用户表就要处理新增、禁用、离职各种情况非常容易漏。更常见的做法是不碰ACT_ID_*表流程变量和任务分配统一用sys_user.username。发起流程时把deptApprover设置为某个人的 username查询待办时也用当前登录用户的 username 去查taskAssignee。这样流程引擎只存字符串真正展示姓名、头像时再通过 JeecgBoot 用户服务反查。有一个容易被忽略的坑离职员工的待办怎么办。如果任务还在一个已离职用户头上流程会卡死。业务侧需要在同步机制里加一道人员禁用时把该用户名下未完成的待办任务查询出来批量办结或重新指派。这个逻辑不是 Activiti 提供的要自己在用户服务里写。4.5 控制事务边界审批完成和业务回写要么一起成功要么一起回滚审批操作一般包含两步taskService.complete()推进流程监听器里更新业务表状态。在单库方案下把两个动作放进同一个 Spring 事务里是最稳妥的。Transactional public void completeWithCallback(String taskId, String operator, boolean approved, String hrApprover) { Authentication.setAuthenticatedUserId(operator); MapString, Object vars new HashMap(); vars.put(approved, approved); vars.put(hrApprover, hrApprover); taskService.complete(taskId, vars); }这里要理解 Activiti 和 Spring 事务的协作方式。taskService.complete()内部会触发监听器监听器里对业务表的更新也参与当前事务。如果监听器抛异常整个complete会回滚任务还在原来的待办列表里不会出现「流程走了、业务状态没更新」的中间态。如果你在监听器里捕获异常后吞掉那事务就不会回滚反而会出现脏数据。所以监听器里不要随意 catch要让异常抛出去交给事务机制处理。如果你确实拆了独立库这个事务保证就没了。那时候的兜底方案一般是加一张流程状态同步表记录「流程实例 ID、业务表名、业务主键、同步状态」由一个定时任务扫描同步失败的数据做补偿。方案复杂不少所以还是那句话先单库后拆分。5. JeecgBoot 集成 Activiti 5.22 的 5 个典型坑5.1 引擎启动了但 ACT_GE_PROPERTY 表始终没有自动创建现象项目能正常启动日志里看不到ProcessEngine created数据库里也没有任何ACT_*表一旦发起流程就报「Table act_ge_property doesnt exist」。原因多数情况是databaseSchemaUpdate(true)根本没生效。产生这个问题的根源在于ProcessEngineBean 不是由你的配置类创建的而是容器里另一个自动配置抢先创建了默认引擎把真正的配置给淹没了。解决在启动类上加exclude把 Activiti 的自动配置排除掉然后确认配置类被 Spring 扫描。更快的定位方式是加启动日志logging: level: org.activiti: debug只要true生效debug 日志里一定会出现建表 DDL。如果日志里完全没建表语句基本就是引擎装配类没有生效。5.2 流程查询时抛 ClassCastException指向 mybatis 的 SqlSessionFactory现象引擎启动正常但一调用taskService.createTaskQuery()或runtimeService的查询方法就抛出类似org.apache.ibatis.session.SqlSessionFactory cannot be cast的异常。原因Activiti 5.22 内置的是 mybatis 3.2.x而 JeecgBoot 的 MyBatis-Plus 依赖会推动 mybatis 版本更高。两个版本的类型定义在某些内部接口上不兼容导致 Activiti 拿到的 SqlSessionFactory 不是它期望的那一个。解决不要在自己的pom.xml里显式排除或覆盖 Activiti 传递的 mybatis 版本让 5.22 用它自己配套的 3.2.x。如果 Maven 仲裁仍然把 mybatis 升上去了就用dependencyManagement把版本钉死回 3.2.2并重新跑一遍最小启动类验证。这个问题的特征非常明显报错信息和 mybatis 强相关第一时间别去查业务代码先查依赖树。5.3 改了流程图重启后执行结果还是旧逻辑现象BPMN 文件里把一个审批节点删了重启应用后发起的流程还是走老路径旧节点依然出现。原因enableDuplicateFiltering()在起作用。它的判定标准包含流程部署名和资源文件标识对于内容变化但流程 key 相同的文件默认认为是重复部署直接跳过。解决开发阶段要么去掉enableDuplicateFiltering()要么部署名里带上版本标记。如果已经产生了旧部署先手动清掉相关表再重启DELETE FROM act_ge_bytearray; DELETE FROM act_re_deployment; DELETE FROM act_re_procdef;注意这个 SQL 只能用于开发环境生产环境的流程定义删除要慎重因为历史流程数据可能还引用着旧的流程定义 ID。每次改完流程文件不生效时不要怀疑缓存先去看act_re_deployment表里最新一条记录的部署时间。5.4 流程追踪图里的中文全变成方块现象流程图解析没问题流程也能走但生成的过程追踪图里中文节点名称全是乱码方块。原因引擎生成图片时用的activityFontName和labelFontName没设置或者设置了但服务器操作系统里没有对应的中文字体。开发机是 Windows 没问题部署到精简版 Linux 容器就暴露了。解决先在引擎配置里设置字体名称然后在容器里补充中文字体。Linux 环境一般安装fontconfig和中文字体包后重启应用即生效。这个坑在容器化部署时特别常见因为它不在启动日志里报错只会在你打开流程追踪图时发现图片不对劲。5.5 监听器里注入的业务 Service 是 null现象LeaveProcessEndListener的notify方法里调用leaveService.updateStatus()时报空指针但LeaveService在 Controller 里注入是正常的。原因BPMN 里如果直接写class属性指定监听器类Activiti 会反射创建实例这个实例不经过 Spring 容器所以构造器注入和字段注入全部失效。解决把监听器拆成两个步骤。第一步把监听器注册为 Spring Bean第二步在引擎配置里通过setBeans把 Bean 的名字和实例传给 Activiti然后 BPMN 里改用expression引用。具体代码在第 4.3 节已经给出。判断标准很简单如果监听器里用到任何 Spring 管理的 Service就不要用class方式一律用expression。这个坑还有个更隐蔽的变体有人把监听器写成Component同时也在setBeans里注册了但 BPMN 里仍然写class属性。此时 Spring 容器里有一个经过完整注入的 BeanActiviti 却仍然反射 new 了一个裸实例Bug 依旧。排查时先看 BPMN 节点上的写法别只看配置类。6. 收口把流程能力封装成一个业务侧唯一的审批入口集成做完后如果每个业务方都直接操作runtimeService和taskService流程逻辑很快就会散落各处改一个审批规则要翻好几个 Controller。我的做法是在引擎 Service 之上再包一层门面WorkflowFacade业务方只对接这层接口完全不感知 Activiti 的存在。Service public class WorkflowFacade { private final RuntimeService runtimeService; private final TaskService taskService; public WorkflowFacade(RuntimeService runtimeService, TaskService taskService) { this.runtimeService runtimeService; this.taskService taskService; } /** 统一发起入口 */ public String start(String processKey, String businessKey, String operator, MapString, Object vars) { Authentication.setAuthenticatedUserId(operator); return runtimeService.startProcessInstanceByKey( processKey, businessKey, vars).getId(); } /** 统一待办查询返回业务侧VO */ public ListTaskVO todoPage(String username, int pageNo, int pageSize) { ListTask tasks taskService.createTaskQuery() .taskAssignee(username) .active() .orderByTaskCreateTime().desc() .listPage(pageNo * pageSize, pageSize); return tasks.stream().map(this::toVO).toList(); } /** 统一审批入口 */ public void approve(String taskId, String operator, MapString, Object vars) { Authentication.setAuthenticatedUserId(operator); taskService.complete(taskId, vars); } }收口之后的第一个好处是事务边界统一。业务方不再需要自己组合complete和状态更新门面方法上直接加Transactional整条链路在同一个事务里。第二个好处是后续想在待办接口里批量回填 JeecgBoot 业务表单信息时只需要改一处不用每个 Controller 都同步修改。验证这套封装是否完整的标准不是业务方能不能调通接口而是一个冒烟测试能不能跑通。我每次接新项目都会先写一个SpringBootTest测试保存一张请假单 → 发起流程 → 查询待办 → 审批通过 → 断言业务表状态已经更新为APPROVED。这个测试跑通才说明流程定义、用户分配、监听器回写、事务边界全都接上了。一个更值得注意的习惯开发阶段别在启动部署器里做太多自动化。新同事改完 BPMN 后如果流程不生效先引导他查部署表再决定要不要清理流程定义。流程版本管理这件事早期靠约定量大了之后再上专门的发布流程。先跑通再规范比一上来就设计一套复杂发布体系实用得多。希望帮到你。本文还有配套的精品资源点击获取