升级 Spring Boot 4 后流程起不来、登录却报缺租户:Forge Admin 补上 applicationTaskExecutor 与旧 Sa-Token Session

Forge Admin 是一套 Java 17 / Spring Boot / Vue 的开源后台框架,自带流程引擎、多租户、插件底座和鉴权会话。演示环境:www.dlforgelab.com:8084 。底座升到 Spring Boot 4.1.1 那天,现场不像「依赖树里多两行版本号」,更像两台机器同时报警:
- 流程服务起不来 :装配
ProcessEngine时缺名为applicationTaskExecutor的 Bean; - 管理端 Token 还在 Redis 里 ,业务接口却打出「设置租户上下文失败」「访问租户表...缺少租户上下文」------排障第一反应几乎全是改
TenantInterceptor。
两处都不是某个业务 Controller 写坏了,而是 Boot 4 的任务执行器装配约定变了 ,以及 Sa-Token 1.45 删掉了旧 Session 类型名 。Forge 在 commit 0ae90d31a(2026-10-13 10:11 CST)里用 AutoConfiguration 顺序、配置双保险、同包名空子类把坑钉死。开源仓库:gitee.com/ForgeLab/fo... (GitHub 镜像:github.com/yaomindong1... )。
TL;DR(可先收藏)
- Boot 4:上下文里已有自定义
Executor(如flowEventExecutor、logTaskExecutor)时,默认不再 创建applicationTaskExecutor。 - Flowable 8 的
ProcessEngineAutoConfiguration按该 Qualifier 强制注入------缺 Bean,引擎就起不来。 - Forge 在
forge-plugin-flow用@AutoConfigureBefore(ProcessEngine...)+@ConditionalOnMissingBean(name="applicationTaskExecutor")补齐;yml 加spring.task.execution.mode=force作双保险。 - Sa-Token ≤1.38 的
sa-token-redis-jackson把 Session 写成SaSessionForJacksonCustomized;1.45.0 已删该类 ;Jackson3 按@class解析 →InvalidTypeIdException。 - 读 Session 失败后,
SessionHelper.getLoginUser→TenantInterceptor打 ERROR →DefaultTenantLineHandler抛「缺租户」------表象是租户,根因是反序列化。 - 修法:在
forge-starter-auth保留同全限定名空子类 只读旧数据;或清 RedisAuthorization:*后重登。先查 Session,别先改租户拦截器。
版本矩阵(本稿场景)
| 组件 | 版本 / Artifact |
|---|---|
| Spring Boot | 4.1.1(forge-server/.../forge-dependencies/pom.xml) |
| Flowable | 8.0.0 |
| Sa-Token | 1.45.0(sa-token-spring-boot4-starter + sa-token-redis-jackson) |
| 语言 / 前端 | Java 17 / Vue 管理端 |
| 主修复提交 | 0ae90d31aba897ec959a1d3ef5d52443d03a1341 |
下面按「现象 → 根因 → 框架里怎么落」拆两个坑。重点不在背 API,而在 Forge 为什么把补丁放进 plugin / starter,而不是散落在业务模块。
坑 A:流程起不来------缺 applicationTaskExecutor
现象
按源码推演,发布前请作者验证:Boot 4 + Flowable 8 启动或装配 ProcessEngine 失败时,日志会指向缺少名为 applicationTaskExecutor 的 Bean。更让人懵的是:Forge 流程侧本身早就注册了业务线程池,并不是「完全没 Executor」。
触发「默认 Bean 被跳过」的自定义池至少有两处。流程事件异步池:
java
// FlowAutoConfiguration.java L131--L141
@Bean(name = "flowEventExecutor")
public Executor flowEventExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(8);
executor.setQueueCapacity(200);
executor.setThreadNamePrefix("flow-event-");
executor.setKeepAliveSeconds(60);
executor.initialize();
return executor;
}
日志 starter 里还有 LogThreadPoolConfig 的 @Bean("logTaskExecutor"),专门给异步落库操作日志用。这些池的本意 是隔离业务异步,不是顶替 Boot 默认的 applicationTaskExecutor------但在 Boot 4 的新约定下,它们会改变自动配置的分支选择。
根因:命名契约,不是「线程不够」
Boot 4 的 TaskExecutionAutoConfiguration 约定变了:只要上下文里已经有自定义 Executor,默认就不再创建 applicationTaskExecutor 。Flowable 8 的 ProcessEngineAutoConfiguration 却仍按该 Qualifier 强制注入。调用链可以压成四行:
text
自定义 Executor 注册(flowEventExecutor / logTaskExecutor ...)
→ Boot4 TaskExecutionAutoConfiguration 跳过默认 applicationTaskExecutor
→ Flowable ProcessEngine 按 Qualifier 注入失败
→ 流程服务起不来
这是典型的框架级「命名契约」冲突:业务侧合理多池,引擎侧认死一个 Bean 名。只调大 corePoolSize、只查 Flowable 数据源,都碰不到根因。

Forge 怎么改:补在插件自动配置里
Forge 没有让每个业务服务手写一个 applicationTaskExecutor,而是把兼容收敛进 forge-plugin-flow------谁引入流程插件,谁自动带上装配补丁。
① 在 ProcessEngine 之前补 Bean (FlowApplicationTaskExecutorConfiguration.java L1--L35):
java
@AutoConfiguration
@AutoConfigureBefore(name = "org.flowable.spring.boot.ProcessEngineAutoConfiguration")
public class FlowApplicationTaskExecutorConfiguration {
public static final String APPLICATION_TASK_EXECUTOR_BEAN_NAME = "applicationTaskExecutor";
@Bean(name = APPLICATION_TASK_EXECUTOR_BEAN_NAME)
@ConditionalOnMissingBean(name = APPLICATION_TASK_EXECUTOR_BEAN_NAME)
public AsyncTaskExecutor applicationTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(16);
executor.setQueueCapacity(200);
executor.setThreadNamePrefix("app-task-");
executor.setKeepAliveSeconds(60);
executor.initialize();
return executor;
}
}
三个细节值得抄进自己的框架:
@AutoConfigureBefore用 类全名字符串,避免编译期强依赖 Flowable 装配类,插件模块边界更干净;@ConditionalOnMissingBean(name=...)保证别人已经提供同名 Bean 时不抢,可与mode=force共存;- Bean 名常量抽成
APPLICATION_TASK_EXECUTOR_BEAN_NAME,和 Flowable 约定的 Qualifier 字面量对齐,改名时只改一处。
② 写入 Boot 4 的 AutoConfiguration.imports,且排在 FlowAutoConfiguration 前面:
text
com.mdframe.forge.starter.flow.config.FlowApplicationTaskExecutorConfiguration
com.mdframe.forge.starter.flow.config.FlowAutoConfiguration
顺序本身就是文档:先补齐引擎依赖的 Bean,再跑 Forge 自己的 Flow 自动配置(里面才注册 flowEventExecutor)。按源码推演,发布前请作者验证:若把两行顺序颠倒,在极端启动时序下仍可能先撞上 ProcessEngine 注入窗口------所以 imports 顺序不是「好看」,是契约。
③ 配置双保险 (forge-flow-server 的 application.yml L35--L38):
yaml
# Boot 4:自定义 Executor 存在时默认不创建 applicationTaskExecutor;Flowable 8 强制依赖该 Bean
spring:
task:
execution:
mode: force
mode=force 告诉 Boot:即便已有自定义 Executor,也强制创建默认任务执行器。Forge 选择 代码补 Bean + yml force 双轨------插件可随依赖落库,服务配置再兜一层,避免只改代码或只改配置时漏某个部署形态(单体 / 拆分 flow-server)。
表 1:Executor 装配链(可收藏)
| 环节 | Boot 3 直觉 | Boot 4 + Flowable 8 现实 | Forge 落点 |
|---|---|---|---|
自定义 flowEventExecutor 等 |
与默认池无关 | 可导致默认 applicationTaskExecutor 不再创建 |
保留业务池,另补同名 Bean |
| Flowable ProcessEngine | 常能吃到默认池 | Qualifier 强制依赖该 Bean 名 | @AutoConfigureBefore 抢在引擎前 |
| 只改业务代码 | 往往无效 | 根因在自动配置顺序 | 放进 forge-plugin-flow starter |
| 运维开关 | 少见 | spring.task.execution.mode=force |
application.yml 双保险 |
| 模块边界 | 随便写 @Configuration |
可被扫包遗漏 | META-INF/.../AutoConfiguration.imports 显式登记 |
坑 B:登录像租户坏了------旧 Session 类型读不出
现象:Token 还在,业务全像「缺租户」
升级后 Redis 里仍有 Authorization:*,Token 看着没过期。但读 Session 时出现:
text
InvalidTypeIdException: Could not resolve type id
'cn.dev33.satoken.dao.SaSessionForJacksonCustomized'
随后链路可以用源码钉死(不是玄学):
SessionHelper.getLoginUser()在无显式身份、无任务上下文时,最终走到StpUtil.getTokenSession().get("loginUser")(L28--L40);TenantInterceptor.preHandle反射调用getLoginUser;若抛的不是「未登录」类异常,就会落入log.error("设置租户上下文失败", e)(L104--L105);- 租户上下文没建起来,访问带租户列的表时,
DefaultTenantLineHandler在严格模式下抛:访问租户表[...]时缺少租户上下文(L89--L92)。
于是排障同学很容易去改多租户拦截器、放宽 strictMode、甚至怀疑数据权限------全是误诊 。仓库 code-copilot/memory/pitfalls/security.md(发现日期 2026-10-13)写得很狠:先查 Session 反序列化,别先改租户拦截器。
根因:类型 ID 指向了已删除的类
- Sa-Token ≤1.38 的
sa-token-redis-jackson序列化 Session 时写入"@class":"cn.dev33.satoken.dao.SaSessionForJacksonCustomized"; - 1.45.0 起官方 jar 删掉该类;
- Forge 使用
sa-token-spring-boot4-starter,走 Jackson3 按@class解析 → 类型找不到 → 整段登录态读失败。
Token 键还在,只是 JSON 里的类型 ID 指向了一个当前 classpath 没有的类。这比「Session 过期」更隐蔽:Redis TTL 正常、前端还带着 Authorization,业务却一片 500。
误诊链可以画成:
text
StpUtil.getTokenSession()
→ Jackson3 按 @class 实例化
→ 缺类失败(InvalidTypeIdException)
→ SessionHelper.getLoginUser 失败
→ TenantInterceptor 反射失败打 ERROR「设置租户上下文失败」
→ DefaultTenantLineHandler「缺少租户上下文」
→ 误诊为「租户拦截器坏了」
Forge 怎么改:同包名空子类 + 单测钉契约
① 同包名空子类,只为读旧数据 (包必须是 cn.dev33.satoken.dao,一个字母都不能差):
java
package cn.dev33.satoken.dao;
import cn.dev33.satoken.session.SaSession;
/**
* Sa-Token ≤1.38 sa-token-redis-jackson 写入 Redis 的 Session 类型。
* 1.45.0 起该类从官方 jar 移除......保留空子类仅为兼容读旧数据,新写入走 SaSession。
*/
public class SaSessionForJacksonCustomized extends SaSession {
private static final long serialVersionUID = -7852311037327718900L;
public SaSessionForJacksonCustomized() {}
public SaSessionForJacksonCustomized(String id) {
super(id);
}
}
放在 forge-starter-auth 而不是某个业务模块:鉴权底座升级时所有管理端 / 网关侧读 Session 的路径一起受益。注释也写清边界------不为了「继续用旧类型写」 ,新写入走 SaSession。
② 单测用真实 Redis 字段形状 + SaJsonTemplateForJackson3 (SaSessionForJacksonCustomizedCompatTest L1--L32):
java
// 字段形状取自本机 Redis 存量 Authorization:login:token-session:*(1.38 redis-jackson 写出)
String legacyJson = """
{"@class":"cn.dev33.satoken.dao.SaSessionForJacksonCustomized",\
"id":"Authorization:login:token-session:legacy-token",\
"type":"Token-Session","loginType":"login","loginId":null,\
"token":"legacy-token","createTime":1710000000000,\
"dataMap":{"@class":"java.util.concurrent.ConcurrentHashMap"},\
"tokenSignList":["java.util.Vector",[]]}
""";
SaSession session = new SaJsonTemplateForJackson3().jsonToObject(legacyJson, SaSession.class);
assertThat(session).isInstanceOf(SaSessionForJacksonCustomized.class);
assertThat(session.getId()).isEqualTo("Authorization:login:token-session:legacy-token");
这类测试的价值不在「覆盖率数字」,而在 把生产 Redis 的字段形状固化进仓库:下次再升 Sa-Token / Jackson,单测先红,而不是客户环境先红。
③ 运维备选 :清 Redis 中 Authorization:* 后让用户重登------新会话写 SaSession,不再依赖旧类型名。兼容类与清键是 二选一或双保险:要平滑升级就留空子类;能接受全员重登、想减少幽灵类型,就清键。
表 2:误诊链 vs 真根因(可收藏)
| 你看到的日志 | 容易误判成 | 实际先查 | Forge 依据 |
|---|---|---|---|
InvalidTypeIdException + SaSessionForJacksonCustomized |
Jackson 全局配置坏了 | Redis Session JSON 的 @class |
CompatTest 字段形状 |
设置租户上下文失败 |
TenantInterceptor 逻辑错 | getLoginUser / getTokenSession 是否已炸 |
Interceptor L68--L105 |
访问租户表[...]缺少租户上下文 |
多租户没开 / 表配置错 | 上游 Session 是否读出 LoginUser | DefaultTenantLineHandler L89--L92 |
| Redis 仍有 Token | 「已登录却 500」玄学 | 键在 ≠ 能反序列化成当前 classpath 类型 | pitfalls security.md 2026-10-13 |
框架落地:为什么补丁要进 starter / plugin
Forge Admin 的模块习惯是「能力进底座,业务只消费」。这两处坑如果散落在各个 *-server:
- flow 单体、flow 拆分服务、带流程的集成测试,都要各自抄一份
applicationTaskExecutor; - 管理端、开放能力入口凡是读
SessionHelper的路径,都可能漏掉兼容类。
因此:
- 坑 A 进
forge-plugin-flow:跟 Flowable 同生命周期,imports 顺序可控; - 坑 B 进
forge-starter-auth:跟 Sa-Token 同生命周期,包名兼容属于鉴权契约。
这和「业务里临时 new 一个线程池 / 临时 try-catch 吞掉租户异常」是两种产品观。后者能糊过演示,前者才能在 Boot 大版本升级时只改两处底座。
可带走的自检清单
升级 Boot 4 + Flowable 8 + Sa-Token 1.45 时,建议按这个顺序过一遍:
- 启动失败搜
applicationTaskExecutor:是否因自定义 Executor 导致默认 Bean 缺失? - 有 Flowable:是否在
ProcessEngineAutoConfiguration之前 补了同名 Bean?AutoConfiguration.imports顺序对不对? - 是否配置了
spring.task.execution.mode=force(或等价强制策略)? - 登录后业务 500:先搜
InvalidTypeIdException/SaSessionForJacksonCustomized,再碰租户代码。 - 兼容类包名必须与 Redis 里
@class逐字一致;单测用真实 JSON 形状,不要只测「能 new 出来」。 - 可接受全员重登时,清
Authorization:*往往比长期维护幽灵类型更干净;要平滑升级则保留空子类。
按源码推演,发布前请作者验证:若环境从未用过 ≤1.38 的 redis-jackson Session,可能碰不到坑 B;但只要 Redis 里残留过旧 @class,Boot4 一上 Jackson3 就会集中爆发。坑 A 则几乎是「只要自定义了 Executor 又引了 Flowable 8」的稳定路径。
和本系列其它文的差异
- 不是「插件独立 Flyway /
@RequiresFeature/ 离线许可证」(10-11); - 不是「复杂待办分页 COUNT(*) 解析失败」(10-12);
- 本篇切口是 底座大版本升级的 Bean 契约 + Session 类型契约,故障反差在「流程起不来」和「登录却像租户全挂」。
Forge Admin 把兼容收敛在 starter / plugin 层:业务模块不用各自抄一遍补丁。源码检索关键字:FlowApplicationTaskExecutorConfiguration、SaSessionForJacksonCustomized、commit 0ae90d31a。
下一篇若继续写升级实战,会优先看 Admin JAR 主应用 classpath Flyway (与插件独立历史表不同切口),或 Boot 4 回归矩阵里其它「起得来但行为变了」的坑。欢迎 Star / Issue:gitee.com/ForgeLab/fo... 。
