# BladeX-Tool Spring Boot 4 升级适配指南 > 适用工程:`blade-tool`(SpringBlade 核心工具工程,开源扁平版布局) > 升级区间:Spring Boot 3.5.13 → **4.1.0**,Spring Cloud 2025.0.2 → **2025.1.2** > 文档定位:照此文档逐模块、逐文件操作即可完成升级;每个主题给出「为什么改 → 改哪个文件 → 改前/改后示例」 > 文档性质:升级指导 / 迁移规范。既供工程师直接阅读执行,也供 AI 依此拆解任务、逐文件推进迁移。 --- ## 0. 怎么用这份文档 1. 先读 **第 1 节版本矩阵** 与 **第 2 节执行顺序**,建立全局认知。 2. **严格按第 2 节的顺序推进**:注解处理器(总闸门)→ 依赖坐标 → 底层模块 import → 运行期适配 → 第三方组件。顺序错了会连锁编译失败。 3. 每个主题按「改前 → 改后」示例照做,改完 `mvn clean install -DskipTests` 验证编译。 4. 编译通过即为本文档的交付边界;**应用启动与集成测试由你在真实环境执行**(见第 13 节运行期验证要点)。 --- ## 1. 版本矩阵(升级前 → 升级后) ### 1.1 框架核心 | 组件 | 升级前 | 升级后 | 说明 | |------|--------|--------|------| | JDK | 17 | **21** | Boot 4 最低仍是 17,本次一并主动升到 21 LTS(见 §16.1);**构建需 JDK 21** | | Spring Framework | 6.2.17 | 7.0.8 | 由 Boot BOM 传递,**不再单独 import `spring-framework-bom`** | | Spring Boot | 3.5.13 | **4.1.0** | 总闸 | | Spring Cloud | 2025.0.2 | **2025.1.2** | 与 Boot 4 强绑定 | | Spring Cloud Alibaba | 2025.0.0.0 | **2025.1.0.0** | 对应 Cloud 2025.1 线 | | Spring Boot Admin | 3.5.8 | **4.1.1** | SBA 大版本必须跟随 Boot 大.次版本 | ### 1.2 中间件与第三方 | 组件 | 升级前 | 升级后 | 处置 | |------|--------|--------|------| | mica-auto(注解处理器) | 3.1.6 | **4.0.1** | 换坐标版本(总闸门,见 §4) | | MyBatis-Plus | 3.5.14 | **3.5.17** | 换坐标 + **`IService`/`ServiceImpl` 包迁移**(见 §8) | | mybatis-spring | 3.0.5 | **4.0.0** | MP 3.5.17 的 boot4 装配要求,Spring 7 线 | | Web 容器 | Undertow | **Tomcat 11** | Undertow 在 Boot 4 下线(见 §6) | | JSON 栈 | Jackson 2(Boot 默认) | **Jackson 2.21.4 + `spring-boot-jackson2` 兼容** | Boot 4 默认 Jackson 3,BladeX 锁回 2(见 §5) | | springdoc | 2.8.13 | **3.0.3** | v3 线才支持 Boot 4 | | Knife4j | 4.5.0 | **移除** | 无 Boot 4 版,改用 springdoc 原生 UI(见 §9) | | Druid starter | `druid-spring-boot-3-starter` | **`druid-spring-boot-4-starter:1.2.28`** | 坐标改名 | | hibernate-validator | 9.0.1.Final(显式 pin) | **9.1.0.Final**(BOM 托管) | 移除过时 pin | | spring-boot-starter-aop | 存在 | **`spring-boot-starter-aspectj`** | Boot 4 移除了 aop starter | | spring-retry | BOM 托管 | **2.0.13**(自管) | Boot 4 BOM 不再托管 | | Apache HttpClient 4 | 非 BOM 托管 | **4.5.14**(自管) | Boot BOM 仅托管 HttpClient 5;HttpClient 4 自 Boot 3.1 起已不在 BOM,JustAuth 需要故自管 | ### 1.3 删除 / 新增的坐标 | 动作 | 坐标 | 原因 | |------|------|------| | 删除 | `org.springframework:spring-framework-bom`(import) | 由 `spring-boot-dependencies` 统一托管 SF 7,避免版本漂移 | | 删除 | `com.github.xiaoymin:knife4j-dependencies`(import)+ `knife4j-openapi3-jakarta-spring-boot-starter` | 无 Boot 4 版 | | 新增 | `org.springframework.boot:spring-boot-jackson2`(+ `jackson-databind`) | Jackson 2 兼容降级 | | 新增 | `org.springframework.boot:spring-boot-cache` | Boot 4 缓存自动配置类拆分至独立模块 | | 新增 | `org.springframework.boot:spring-boot-restclient` | Boot 4 把 `RestTemplateBuilder` 拆到独立模块 | | 新增 | `com.baomidou:mybatis-plus-extension` | 本模块直接使用 `MybatisPlusInterceptor`/分页/租户等 extension 类,按"直接依赖显式声明"补齐(经 `mybatis-plus-spring` 仍可传递获得,显式声明更稳健) | | 改名 | `spring-boot-starter-web` → `spring-boot-starter-webmvc` | Undertow 退场,默认 Tomcat | --- ## 2. 升级执行顺序(强约束) blade-tool 是核心工程,下游 Cloud / Boot 通过 `blade-core-bom` 继承整套版本。**升级必须 blade-tool 先行**,内部顺序不可打乱: ``` ① 改父 POM 版本矩阵与坐标(§3) ② 升 mica-auto 到 4.0.1 —— 注解处理器是编译期总闸门(§4) ③ 底层模块 import 迁包:launch / cloud / tool / redis / log 等(§5.3 映射表) ④ 运行期适配:Jackson 2 降级(§5)、Web 容器(§6)、全局异常(§7) ⑤ 第三方组件:MyBatis-Plus 包迁移(§8)、Swagger(§9)、Boot4 移除坐标(§10) ⑥ 全量 mvn clean install(务必带 clean,重新生成 spring.factories / *.imports) ``` > **为什么 mica-auto 是总闸门**:BladeX 全工程的 `@AutoConfiguration`、`EnvironmentPostProcessor`、`LauncherService` 都靠 mica-auto 在编译期生成 `META-INF/spring/*.imports` 与 `META-INF/spring.factories`。mica-auto 3.1.6 把 `EnvironmentPostProcessor` 写成旧键 `org.springframework.boot.env.EnvironmentPostProcessor`,4.0.1 才写 Boot 4 的新键 `org.springframework.boot.EnvironmentPostProcessor`。 > ⚠️【勘误 · 别误判失效方式】Boot **4.1.0 仍向后兼容**:`SpringFactoriesEnvironmentPostProcessorsFactory#getEnvironmentPostProcessors()` 会**同时加载新键与旧键**(旧键实例经内部 `Adapter` 包装后照常执行),旧接口 `org.springframework.boot.env.EnvironmentPostProcessor` 标 `@Deprecated(since=4.0.0, forRemoval=true)`、要到 **Boot 4.2.0** 才移除。因此"不升 mica-auto 会**运行期静默失效**"的说法对 4.1.0 **不成立**——真实两种结果是:① 源码仍用旧接口 + mica 3.1.6 写旧键 → 旧键仍被加载执行(不失效);② 源码已改新接口但 mica 仍 3.1.6 写旧键 → 类型不匹配,启动**显式抛 `IllegalArgumentException`**(大声失败,非静默)。升 mica-auto 到 4.0.1 仍是**必须动作**(对齐新键、面向 4.2.0),只是失效机制并非"静默"。 --- ## 3. 父 POM 版本矩阵与坐标(`pom.xml`) ### 3.1 `` 版本属性 ```xml 6.2.17 3.5.13 3.5.8 2025.0.2 2025.0.0.0 3.1.6 3.0.5 3.5.14 4.5.0 4.1.0 4.1.1 2025.1.2 2025.1.0.0 4.0.1 4.0.0 3.5.17 2.0.13 4.5.14 ``` ### 3.2 `` 变更 **删除** `spring-framework-bom` import(让 Boot BOM 统一管 SF 7): ```xml org.springframework spring-framework-bom ${spring.version} pom import ``` **删除** `knife4j-dependencies` import: ```xml com.github.xiaoymin knife4j-dependencies ${knife4j.version} pom import ``` **坐标改名**(boot3 → boot4)+ **springdoc 升 3.0.3**: ```xml mybatis-plus-spring-boot3-starter → mybatis-plus-spring-boot4-starter druid-spring-boot-3-starter → druid-spring-boot-4-starter springdoc-openapi-starter-webflux-ui: 2.8.13 → 3.0.3 springdoc-openapi-starter-webmvc-ui : 2.8.13 → 3.0.3 ``` **移除过时 pin**:删掉 `hibernate-validator` 的 `9.0.1.Final` 显式管理条目(Boot 4.1 BOM 托管为 9.1.0.Final)。 **新增自管坐标**(Boot 4 BOM 不再托管): ```xml org.springframework.retry spring-retry ${spring.retry.version} org.apache.httpcomponents httpclient ${httpclient.version} ``` ### 3.3 父 POM 公共 `` ```xml org.springframework.boot spring-boot-starter-aop org.springframework.boot spring-boot-starter-aspectj ``` --- ## 4. mica-auto 注解处理器(总闸门) **改动**:仅版本升级(§3.1 已改 `mica.auto.version` = 4.0.1)。mica-auto 4.0.1 的 `BootAutoType` 内置了 Boot 4 的新注册键,升级后重新编译即自动生成正确的 `spring.factories`。 **配套改一处 import**——`blade-starter-loadbalancer` 唯一的 `EnvironmentPostProcessor`: 文件:`blade-starter-loadbalancer/.../rule/GrayscaleEnvPostProcessor.java` ```java // 改前 import org.springframework.boot.env.EnvironmentPostProcessor; // 改后(Boot 4 该接口上移根包) import org.springframework.boot.EnvironmentPostProcessor; ``` **验证**:`mvn clean install` 后检查生成物,键应为新的根包键: ```properties # blade-starter-loadbalancer/target/classes/META-INF/spring.factories org.springframework.boot.EnvironmentPostProcessor=\ org.springblade.core.loadbalancer.rule.GrayscaleEnvPostProcessor ``` > 若这里仍是 `org.springframework.boot.env.EnvironmentPostProcessor`,说明 mica-auto 没升到 4.0.1。该键在 Boot 4.1.0 仍向后兼容加载执行(见 §2 勘误),但要到 **4.2.0** 旧接口移除后才彻底失效;应升 mica-auto 到 4.0.1 写入新键对齐,不要留旧键。 --- ## 5. Boot 4 自动配置拆包 import 迁移 + Jackson 2 降级 ### 5.1 背景:为什么会大面积编译断裂 Spring Boot 4.0 把巨型 `spring-boot-autoconfigure` 拆成按技术域划分的独立模块,包名从 `org.springframework.boot.autoconfigure..*` 重排为 `org.springframework.boot..autoconfigure.*`,部分类还顺带改名(如 `RedisAutoConfiguration` → `DataRedisAutoConfiguration`)。旧 import 直接编译不过。 ### 5.2 【务必牢记】包名迁移映射表(已在 Boot 4.1.0 实测) | 旧包 / 类 | 新包 / 类 | 所需 starter | |-----------|-----------|--------------| | `autoconfigure.web.ServerProperties` | `web.server.autoconfigure.ServerProperties` | spring-boot-web-server | | `autoconfigure.web.servlet.WebMvcRegistrations` | `webmvc.autoconfigure.WebMvcRegistrations` | spring-boot-webmvc | | `autoconfigure.web.servlet.WebMvcProperties` | `webmvc.autoconfigure.WebMvcProperties` | spring-boot-webmvc | | `autoconfigure.web.servlet.error.BasicErrorController` | `webmvc.autoconfigure.error.BasicErrorController` | spring-boot-webmvc | | `autoconfigure.web.servlet.error.ErrorMvcAutoConfiguration` | `webmvc.autoconfigure.error.ErrorMvcAutoConfiguration` | spring-boot-webmvc | | `web.servlet.error.DefaultErrorAttributes/ErrorAttributes/ErrorController` | `webmvc.error.*` | spring-boot-webmvc | | `autoconfigure.cache.CacheProperties/CacheManagerCustomizer(s)` | `cache.autoconfigure.*` | spring-boot-cache | | `autoconfigure.data.redis.RedisAutoConfiguration` | `data.redis.autoconfigure.**DataRedisAutoConfiguration**`(改名) | spring-boot-data-redis | | `autoconfigure.jdbc.DataSourceProperties/DataSourceAutoConfiguration` | `jdbc.autoconfigure.*` | spring-boot-jdbc | | `autoconfigure.jackson.JacksonAutoConfiguration` | `jackson.autoconfigure.JacksonAutoConfiguration` | spring-boot-jackson(Jackson 3) | | `autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer` | `jackson2.autoconfigure.*`(deprecated-for-removal) | spring-boot-jackson2 | | `web.context.WebServerInitializedEvent` | `web.server.context.WebServerInitializedEvent` | spring-boot-web-server | | `web.client.RestTemplateBuilder` | `restclient.RestTemplateBuilder` | spring-boot-restclient | | `boot.env.EnvironmentPostProcessor` | `boot.EnvironmentPostProcessor` | spring-boot | > **保持不变、不要跟着乱改**:`autoconfigure.AutoConfiguration` 注解本体、`autoconfigure.AutoConfigureBefore/After`、`autoconfigure.condition.ConditionalOn*` / `SearchStrategy`、`autoconfigure.SpringBootApplication` / `EnableAutoConfiguration`、`autoconfigure.web.ErrorProperties`、`web.error.ErrorAttributeOptions`、`web.servlet.FilterRegistrationBean` / `ServletRegistrationBean` 均**未迁包**。 ### 5.3 逐文件 import 迁移(blade-tool 实际受影响清单) | 文件 | 改动 | |------|------| | `blade-core-launch/.../server/ServerInfo.java` | `ServerProperties` → `web.server.autoconfigure` | | `blade-core-launch/.../StartEventListener.java` | `WebServerInitializedEvent` → `web.server.context` | | `blade-core-cloud/.../version/BladeWebMvcRegistrations.java` | `WebMvcRegistrations` → `webmvc.autoconfigure` | | `blade-core-cloud/.../version/VersionMappingAutoConfiguration.java` | `WebMvcRegistrations` → `webmvc.autoconfigure` | | `blade-core-cloud/.../http/RestTemplateConfiguration.java` | `RestTemplateBuilder` → `restclient`;并把 `restTemplate()`/`lbRestTemplate()` 改为链式 `requestFactory(...).build()`(Boot 4 的 `RestTemplateBuilder` 不可变,旧的"先 `requestFactory(...)` 再单独 `build()`"两行式会丢弃携带 OkHttp3 工厂的 builder,顺带修掉该隐患) | | `blade-core-tool/.../config/RedisConfiguration.java` | `RedisAutoConfiguration` → `DataRedisAutoConfiguration`(import + `@AutoConfigureBefore` 参数一起改) | | `blade-starter-redis/.../config/RedisTemplateConfiguration.java` | 同上(import + `@AutoConfiguration(before=...)` 参数) | | `blade-starter-redis/.../config/RedisCacheManagerConfig.java` | `CacheManagerCustomizer(s)` → `cache.autoconfigure` | | `blade-starter-redis/.../config/BladeRedisCacheAutoConfiguration.java` | `CacheManagerCustomizers` / `CacheProperties` → `cache.autoconfigure` | | `blade-starter-log/.../config/BladeErrorMvcAutoConfiguration.java` | 错误类拆两包 + `ServerProperties`(见 §7.2) | | `blade-starter-log/.../error/BladeErrorController.java` | `BasicErrorController` → `webmvc.autoconfigure.error`;`ErrorAttributes` → `webmvc.error` | | `blade-starter-log/.../error/BladeErrorAttributes.java` | `DefaultErrorAttributes` → `webmvc.error` | | `blade-starter-transaction/.../annotation/SeataCloudApplication.java` | `DataSourceAutoConfiguration` → `jdbc.autoconfigure` | | `blade-core-tool/.../config/JacksonConfiguration.java` | Jackson 2 降级重构(见 §5.5) | **改名类(RedisAutoConfiguration → DataRedisAutoConfiguration)示例**——import 与注解参数必须一起改: ```java // 改前 import org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration; @AutoConfiguration(before = {RedisConfiguration.class, RedisAutoConfiguration.class}) // 改后 import org.springframework.boot.data.redis.autoconfigure.DataRedisAutoConfiguration; @AutoConfiguration(before = {RedisConfiguration.class, DataRedisAutoConfiguration.class}) ``` ### 5.4 拆包后各模块 POM 补齐 split-starter 新包里的类只有对应 starter 在 classpath 时才存在,以下为**硬性补齐**(不补则「找不到符号」): | 模块 POM | 新增依赖 | 原因 | |----------|----------|------| | `blade-starter-redis/pom.xml` | `org.springframework.boot:spring-boot-cache` | 直接引用 `cache.autoconfigure.*`,而 `spring-boot-starter-data-redis` 不带 cache | | `blade-core-cloud/pom.xml` | `org.springframework.boot:spring-boot-restclient` | `RestTemplateBuilder` 迁到此模块,webmvc starter 不含 | | `blade-core-tool/pom.xml` | `spring-boot-jackson2` + `jackson-databind` | Jackson 2 降级(见 §5.5) | 版本一律由 BOM 托管,**不写 ``**。 ### 5.5 Jackson 2 降级与序列化管线重构 **背景**:Boot 4 默认底座是 Jackson 3(`tools.jackson.*`),BladeX 全链路(`JsonUtil`、`@BladeView` 视图过滤、大数转字符串、null 转空、读写分离转换器)重度依赖 Jackson 2(`com.fasterxml.jackson.*`)。方案是引 `spring-boot-jackson2` 兼容 starter 锁回 Jackson 2,并**不再自建主 `ObjectMapper` Bean 抢占框架 builder 装配管线**,改贡献 `Jackson2ObjectMapperBuilderCustomizer`。 **① `blade-core-tool/pom.xml` 新增依赖**: ```xml org.springframework.boot spring-boot-jackson2 com.fasterxml.jackson.core jackson-databind ``` **② `JacksonConfiguration.java` 重构**——从「自建主 ObjectMapper」改为「贡献 builder 定制器」: ```java // 改前:自建主 ObjectMapper,抢占 builder 管线,且 import 的 JacksonAutoConfiguration 在 Boot4 已不存在 @AutoConfiguration(before = JacksonAutoConfiguration.class) @ConditionalOnClass(ObjectMapper.class) @EnableConfigurationProperties(BladeJacksonProperties.class) public class JacksonConfiguration { @Bean @ConditionalOnMissingBean public ObjectMapper objectMapper() { ObjectMapper objectMapper = JsonUtil.getInstance(); objectMapper.enable(DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT); objectMapper.findAndRegisterModules(); return objectMapper; } } // 改后:贡献 Jackson2ObjectMapperBuilderCustomizer,由框架构建主 ObjectMapper @AutoConfiguration @ConditionalOnClass(ObjectMapper.class) @EnableConfigurationProperties(BladeJacksonProperties.class) @SuppressWarnings({"deprecation", "removal"}) public class JacksonConfiguration { @Bean public Jackson2ObjectMapperBuilderCustomizer bladeJacksonCustomizer() { return builder -> { builder.featuresToEnable( MapperFeature.DEFAULT_VIEW_INCLUSION, // JsonReadFeature 不被 Jackson2ObjectMapperBuilder 接受,改用等价的 JsonParser.Feature JsonParser.Feature.ALLOW_UNQUOTED_CONTROL_CHARS, JsonParser.Feature.ALLOW_BACKSLASH_ESCAPING_ANY_CHARACTER, JsonParser.Feature.ALLOW_SINGLE_QUOTES, DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT); builder.featuresToDisable( SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, SerializationFeature.FAIL_ON_EMPTY_BEANS, DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); builder.locale(Locale.CHINA); builder.timeZone(TimeZone.getTimeZone(ZoneId.systemDefault())); builder.dateFormat(new SimpleDateFormat(DateUtil.PATTERN_DATETIME, Locale.CHINA)); builder.modulesToInstall(new BladeJavaTimeModule()); builder.findModulesViaServiceLoader(true); builder.annotationIntrospector(introspector -> new BladeViewAnnotationIntrospector()); }; } } ``` **③ `MessageConfiguration.java`**——必须重写 `extendMessageConverters`(**不是** `configureMessageConverters`),在框架构建好的默认转换器列表上原地替换 JSON / String 转换器。 > ⚠️【务必看清,这是升级中最隐蔽的一个坑】Spring 7 的 `WebMvcConfigurationSupport.getMessageConverters()` 执行顺序是:先 `configureMessageConverters(list)` → 若 list 仍为空才 `addDefaultHttpMessageConverters(list)`(这一步才注册 ByteArray/String/Resource/ResourceRegion/Form 以及 JSON 转换器)→ 最后 `extendMessageConverters(list)`。 > 若重写 `configureMessageConverters` 并向 list 添加任何转换器,list 变为非空,`addDefaultHttpMessageConverters` 会被**整段跳过**,MVC 最终只剩下你加入的这一个转换器——文件下载、`byte[]`/图片/PDF、纯文本、表单请求体全部 406/`HttpMessageNotWritableException` 失败。**编译与纯 JSON 接口都正常,极难在冒烟测试中发现。** > 正解是重写 `extendMessageConverters`:它在默认列表构建完成之后回调,拿到的是**完整**的默认转换器,再原地替换 JSON(因 §5.5⑤ 已强制 `preferred-json-mapper=jackson2`,默认 JSON 转换器为 `MappingJackson2HttpMessageConverter`,属 `AbstractJackson2HttpMessageConverter`)与 String 转换器即可,其余转换器原样保留。 ```java @Override @SuppressWarnings("removal") public void extendMessageConverters(List> converters) { MappingApiJackson2HttpMessageConverter bladeJson = new MappingApiJackson2HttpMessageConverter(objectMapper, properties); boolean jsonReplaced = false; ListIterator> iterator = converters.listIterator(); while (iterator.hasNext()) { HttpMessageConverter converter = iterator.next(); if (converter instanceof AbstractJackson2HttpMessageConverter) { iterator.set(bladeJson); jsonReplaced = true; } else if (converter instanceof StringHttpMessageConverter) { iterator.set(new StringHttpMessageConverter(Charsets.UTF_8)); } } // 兜底:未命中默认 JSON 转换器时插到队首,确保 blade 转换器优先生效 if (!jsonReplaced) { converters.add(0, bladeJson); } } ``` **④ 抑制弃用 / 待删除告警**:Boot4 下 Jackson 2 的 Spring 集成类(`AbstractJackson2HttpMessageConverter`、`MappingJackson2HttpMessageConverter`、`MappingJacksonValue`、`Jackson2ObjectMapperBuilderCustomizer` 等)被标记为 deprecated-for-removal,部分 Jackson 2 `ObjectMapper` mutator 也被弃用,需逐类补 `@SuppressWarnings`。作用域按就近原则——弃用点在类声明(如 `extends` 待删除父类)或贯穿整类的放**类级**,只落在单个方法内的放**方法级**;`deprecation`(普通弃用)与 `removal`(待删除弃用)是不同 lint 类别,按该类实际触发的类别精确填写、不冗余: | 类 / 方法 | 作用域 | 注解 | 触发点 | |---|---|---|---| | `jackson/AbstractReadWriteJackson2HttpMessageConverter` | 类级 | `@SuppressWarnings("removal")` | `extends AbstractJackson2HttpMessageConverter`(待删除),类声明即触发 | | `jackson/MappingApiJackson2HttpMessageConverter` | 类级 | `@SuppressWarnings("deprecation")` | 覆盖 `initWriteObjectMapper` 里 Jackson 2 可变式定制 API(`setSerializerFactory` / `getSerializerProvider().setNullValueSerializer`);⚠️ **勘误**:在锁定的 jackson-databind 2.21.4 中这两个方法**尚未**标 `@Deprecated`(经 javap 核实),故此处属**前向/防御性**抑制,本类无 removal | | `jackson/BladeViewResponseAdvice` | 类级 | `@SuppressWarnings("removal")` | 用 `MappingJacksonValue`(待删除)包装 `@BladeView` 视图响应 | | `config/JacksonConfiguration` | 类级 | `@SuppressWarnings({"deprecation", "removal"})` | `JsonParser.Feature.*`(弃用)+ `Jackson2ObjectMapperBuilderCustomizer`(待删除) | | `config/MessageConfiguration#extendMessageConverters` | 方法级 | `@SuppressWarnings("removal")` | 方法内引用 `AbstractJackson2HttpMessageConverter`(待删除) | | `blade-core-cloud RestTemplateConfiguration#configMessageConverters` | 方法级 | `@SuppressWarnings("removal")` | 方法内引用 `MappingJackson2HttpMessageConverter`(待删除) | > 与 Jackson 无关的 `org.springframework.lang.Nullable/NonNull` 在 Spring 7 也被弃用(官方推 JSpecify),那是一处独立、面广的迁移,不在本表范围,别混入 Jackson 的 `@SuppressWarnings`。 **⑤ 【关键】强制 preferred-json-mapper = jackson2**——新增一个 `LauncherService`,否则 Boot 4 默认注册 Jackson 3 转换器,§5.5③ 的 `instanceof AbstractJackson2HttpMessageConverter` 命不中,Blade 的 JSON 处理静默失效: 新增 `blade-core-tool/.../config/JacksonLauncherServiceImpl.java`: ```java @AutoService(LauncherService.class) public class JacksonLauncherServiceImpl implements LauncherService { @Override public void launcher(SpringApplicationBuilder builder, String appName, String profile) { Properties props = System.getProperties(); // 命令式转换器与反应式编解码器分别指定首选实现,两处同锁 Jackson 2,servlet / webflux 两侧序列化一致 props.setProperty("spring.http.converters.preferred-json-mapper", "jackson2"); props.setProperty("spring.http.codecs.preferred-json-mapper", "jackson2"); } @Override public int getOrder() { return Ordered.HIGHEST_PRECEDENCE; } } ``` ### 5.6 Redis pub/sub 容器与 Boot 4.1 新增 `@RedisListener` 自动配置的竞争 **背景**:Boot 4.1 的 `spring-boot-data-redis` 新增了注解驱动监听自动配置 `DataRedisAnnotationDrivenConfiguration`(由 `DataRedisAutoConfiguration` `@Import`,条件 `@ConditionalOnClass({org.springframework.data.redis.annotation.EnableRedisListeners, org.springframework.messaging.Message})`),用于支撑新的 `@RedisListener` 注解。它会注册一个 `@Bean(name = "redisMessageListenerContainer")` + `@ConditionalOnMissingBean` 的默认容器。 BladeX 自有的 Redis 发布订阅(`@RPubSubListener` + `RPubSubListenerDetector`)在 `blade-starter-redis` 的 `RedisPubSubConfiguration` 里**同样**注册一个同名 `redisMessageListenerContainer`(也是 `@ConditionalOnMissingBean`)。`spring-messaging`(经 spring-data-redis 传递)与 `EnableRedisListeners` 在 BladeX classpath 上均存在,故 Boot 这个自动配置**会激活**,与 BladeX 的容器**同名、同条件**竞争。 **行为分析**:两者都 `@ConditionalOnMissingBean` → 不会重复 Bean 报错,功能上任一容器都能用(BladeX 的 detector 只是 `getBean(RedisMessageListenerContainer.class)` 抓容器再挂监听);但**哪个容器胜出取决于自动配置处理顺序**——`RedisPubSubConfiguration` 原先未声明任何 `@AutoConfigureBefore/After`,顺序不确定,存在隐性非确定性。 **改动**(`blade-starter-redis/.../config/RedisPubSubConfiguration.java`)——让 BladeX 容器确定性胜出: ```java // 改前 @AutoConfiguration public class RedisPubSubConfiguration { // 改后(新增 import org.springframework.boot.data.redis.autoconfigure.DataRedisAutoConfiguration) @AutoConfiguration(before = DataRedisAutoConfiguration.class) public class RedisPubSubConfiguration { ``` > 写法与同模块 `RedisTemplateConfiguration`(§5.3)一致:排在 `DataRedisAutoConfiguration` 之前,BladeX 的 `redisMessageListenerContainer` 先注册,Boot 的 `@ConditionalOnMissingBean` 随即退让;容器所需的 `RedisConnectionFactory` 由 bean 方法入参在实例化时解析,不受配置类排序影响。 > **与 §5.3 两处 Redis 改动的区别**:§5.3 里 `RedisConfiguration` / `RedisTemplateConfiguration` 调 `before=` 是因**类改名**(`RedisAutoConfiguration`→`DataRedisAutoConfiguration`)顺带修正既有 `before` 列表;本处 `RedisPubSubConfiguration` 原本**无** `before`、属**新增**,根因是 Boot 4.1 新引入的 `@RedisListener` 自动配置,是独立的适配点。 > **运行期验证**:启动后确认 `@RPubSubListener` 收发一次正常即可。 --- ## 6. Web 容器:Undertow → Tomcat **背景**:Spring Boot 4 把基线抬到 Servlet 6.1 / Jakarta EE 11(Tomcat 11、Jetty 12.1),Undertow 当时无对应 GA,Spring 遂移除了 Undertow 的 starter 与自动配置——`spring-boot-starter-undertow` 已无 4.x 正式版(仓库仅剩从未转正的 `4.0.0-M1` 里程碑),回归默认 Tomcat 11。 **① `blade-core-launch/pom.xml`**——`web` + `undertow` 两个依赖合并为一个 `webmvc`: ```xml org.springframework.boot spring-boot-starter-web org.springframework.boot spring-boot-starter-tomcat org.springframework.boot spring-boot-starter-undertow org.springframework.boot spring-boot-starter-webmvc ``` **② `blade-core-boot/src/main/resources/bootstrap.yml`**——`server.undertow.*` 换算为 `server.tomcat.*`: ```yaml # 改前 server: undertow: buffer-size: 1024 direct-buffers: true threads: io: 16 worker: 400 # 改后(Tomcat 无独立 IO 线程 / buffer 概念:worker→threads.max、io→threads.min-spare,buffer 项废弃) server: tomcat: # 线程配置 threads: # 最大工作线程数(可同时处理请求的线程上限) max: 400 # 最小空闲工作线程数(保持存活的常驻线程数) min-spare: 16 ``` **③(可选)`logback_dev.xml`**——Undertow 日志器换 Tomcat 命名空间:`io.undertow`→`org.apache.catalina`、`org.xnio.nio`→`org.apache.coyote`。 **④ `blade-core-boot/src/main/resources/bootstrap.yml`——Servlet 编码配置改前缀 `server.servlet.encoding` → `spring.servlet.encoding`** Spring Boot 4.0 把 Servlet 编码属性从 `server.servlet.encoding.*` 迁到新的 `spring-boot-servlet` 模块下的 `spring.servlet.encoding.*`(绑定类 `org.springframework.boot.servlet.autoconfigure.ServletEncodingProperties`)。旧前缀在 Boot 4.1.0 的配置元数据里被标记为 `level: error`(非 warning)、`since: 4.0.0`,属**硬迁移**:旧键不再生效,只保留元数据供 IDE / 校验工具报错提示。判定依据是 Boot 自身随包发布的 `META-INF/spring-configuration-metadata.json`,比任何博客都权威。 ```yaml # 改前(server 命名空间下,Boot 4 起报错) server: servlet: encoding: charset: UTF-8 force: true # 改后(并入既有 spring.servlet 块,与 multipart 平级) spring: servlet: multipart: max-file-size: 256MB max-request-size: 1024MB # 编码配置 encoding: charset: UTF-8 force: true ``` > 键名一一对应,仅前缀 `server` → `spring`:`charset` / `force` / `enabled` / `force-request` / `force-response` 均照迁。`force: true` 是 BladeX 的刻意设置(Boot 默认 `force=false`),强制请求与响应都用 UTF-8,避免中文响应乱码,故保留而非删除。YAML 里 `spring:` 顶层键唯一,须把 `encoding` 并入已存在的 `spring.servlet` 块,不能新起一个 `spring:` 造成重复键。 > **下游连锁**:blade-core-launch 是全工程唯一声明 Web 容器 starter 的地方,下游所有 app(尤其未自带容器 starter 的 BladeX-Boot)会**静默从 Undertow 切到 Tomcat**,须回归测试。 --- ## 7. 全局异常:SF 7 校验异常体系适配 **背景**:方法级 `@Validated` 校验自 Spring 6.1 起改抛 `HandlerMethodValidationException`(Boot 3.5 的 Spring 6.2 即具备,本次补齐其全局处理);Boot 4 另有错误处理链拆包、静态资源 404 的 `NoResourceFoundException`,以及 `ErrorProperties` 来源从 `ServerProperties` 迁到 `WebProperties`。 ### 7.1 `blade-starter-log/.../error/BladeRestExceptionTranslator.java` 新增/调整处理器 ```java // 去掉对 Hibernate 内部类 PathImpl 的依赖,改用标准 jakarta.validation.Path // 改前 import org.hibernate.validator.internal.engine.path.PathImpl; String path = ((PathImpl) violation.getPropertyPath()).getLeafNode().getName(); // 改后 import jakarta.validation.Path; String path = ""; for (Path.Node node : violation.getPropertyPath()) { path = node.getName(); } // 新增 SF7 方法级校验异常(注意:用 getParameterValidationResults(),SF7 无 getAllValidationResults()) @ExceptionHandler(HandlerMethodValidationException.class) @ResponseStatus(HttpStatus.BAD_REQUEST) public R handleError(HandlerMethodValidationException e) { String message = e.getParameterValidationResults().stream() .flatMap(result -> result.getResolvableErrors().stream() .map(error -> String.format("%s:%s", result.getMethodParameter().getParameterName(), error.getDefaultMessage()))) .findFirst() .orElse(ResultCode.PARAM_VALID_ERROR.getMessage()); return R.fail(ResultCode.PARAM_VALID_ERROR, message); } // 新增 静态资源 404(Spring 6.1+ 起 ResourceHttpRequestHandler 对缺失静态资源抛此异常,与已移除的 throw-exception-if-no-handler-found 无关) @ExceptionHandler(NoResourceFoundException.class) @ResponseStatus(HttpStatus.NOT_FOUND) public R handleError(NoResourceFoundException e) { log.warn("404资源不存在:{}", e.getResourcePath()); return R.fail(ResultCode.NOT_FOUND); // 只回 404、不把资源路径回显给客户端(避免信息泄露) } // 同时把既有 NoHandlerFoundException 处理器对齐:log.error→log.warn、去掉 e.getMessage() 返回参(只回 ResultCode.NOT_FOUND) // 新增 上传超限 413 @ExceptionHandler(MaxUploadSizeExceededException.class) @ResponseStatus(HttpStatus.PAYLOAD_TOO_LARGE) public R handleError(MaxUploadSizeExceededException e) { return R.fail(ResultCode.FAILURE, "上传文件大小超过系统限制"); } ``` 新增 import:`org.springframework.web.method.annotation.HandlerMethodValidationException`、`org.springframework.web.multipart.MaxUploadSizeExceededException`、`org.springframework.web.servlet.resource.NoResourceFoundException`。 ### 7.2 `blade-starter-log/.../config/BladeErrorMvcAutoConfiguration.java`——`ServerProperties.getError()` 已移除 Boot 4 把 `ErrorProperties` 的来源从 `ServerProperties` 挪到了 `WebProperties`: ```java // 改前 import org.springframework.boot.autoconfigure.web.ServerProperties; private final ServerProperties serverProperties; return new BladeErrorController(errorAttributes, serverProperties.getError()); // 改后 import org.springframework.boot.autoconfigure.web.WebProperties; @EnableConfigurationProperties({BladeLogProperties.class, WebProperties.class}) private final WebProperties webProperties; return new BladeErrorController(errorAttributes, webProperties.getError()); ``` --- ## 8. MyBatis-Plus 3.5.17 包迁移(易漏) **背景**:MyBatis-Plus 3.5.17 起把 `IService` / `ServiceImpl` 从 `com.baomidou.mybatisplus.extension.service.*` 迁到了 **`com.baomidou.mybatisplus.spring.service.*`**(位于 `mybatis-plus-spring` 模块)。 **① `blade-starter-mybatis/pom.xml` 显式声明 `mybatis-plus-extension`**(本模块直接使用 `MybatisPlusInterceptor`、分页/租户插件等 extension 类;虽经 `mybatis-plus-spring` 仍可传递获得,按"直接使用即显式声明"补齐更稳健): ```xml com.baomidou mybatis-plus-extension ``` **② 源码 import 迁移**(blade-tool 内 5 处,业务工程凡用到 `IService`/`ServiceImpl` 同样要改): ```java // 改前 import com.baomidou.mybatisplus.extension.service.IService; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; // 改后 import com.baomidou.mybatisplus.spring.service.IService; import com.baomidou.mybatisplus.spring.service.impl.ServiceImpl; ``` 受影响文件:`BaseService`、`BaseServiceImpl`(blade-starter-mybatis)、`TenantGuard`(blade-starter-tenant)、`IReportFileService`、`ReportFileServiceImpl`(blade-starter-report)。 > `com.baomidou.mybatisplus.extension.plugins.*`(拦截器、分页、租户)**未迁包**,不要改。 --- ## 9. Swagger:移除 Knife4j,改用 springdoc 3.0.3 原生 UI **背景**:Knife4j 最新 4.5.0 仅绑定 springdoc 2.x / Boot 2.2~3.x,无 Boot 4 版且已停更,Boot 4 下不可用。 **① `blade-starter-swagger/pom.xml`**: ```xml com.github.xiaoymin knife4j-openapi3-jakarta-spring-boot-starter org.springdoc springdoc-openapi-starter-webmvc-ui ``` **② 删除** vendored 的 knife4j 影子类 `blade-starter-swagger/src/main/java/com/github/xiaoymin/knife4j/spring/extension/Knife4jOpenApiCustomizer.java`(整个 `com/github/xiaoymin` 目录树)。 **③ `SwaggerLauncherServiceImpl.java`**:删掉 `knife4j.enable` / `knife4j.production` 两处属性写入(prod/dev 两个分支都删),`springdoc.*` 开关已完整覆盖启停。同时清掉两处历史遗留: - `springdoc.api-usage.enabled`——springdoc 并无此属性(宽松绑定下静默无效),两个分支都删。 - dev 分支的 `spring.mvc.pathmatch.matching-strategy=ANT_PATH_MATCHER`——这是 springdoc 2 / Boot 2.6 时代的旧 workaround,springdoc 3 + Boot4 原生走 `PathPatternParser`,且它只在非 prod 设置会导致 dev 用 AntPathMatcher、prod 用 PathPatternParser,两套路径匹配语义不一致(尾斜杠、`{*path}` 通配、矩阵变量),必须删除让各环境统一。 **④ `SwaggerWebConfiguration.java`**:删掉 `doc.html` 与 `/webjars/**` 资源映射(springdoc 自托管),连带去掉 `implements WebMvcConfigurer` 与 `addResourceHandlers` 方法;**保留** `@AutoConfiguration` + `@BladePropertySource("classpath:/blade-swagger.yml")`(它负责加载默认配置)。 **⑤ `blade-swagger.yml`**:删掉整段 `knife4j:` 配置;`springdoc.swagger-ui` 的 `tags-sorter` / `operations-sorter` **均不设**——接口与分组顺序改由 BladeX 自研的 `@ApiOrder` 控制(见 §9⑧),设 `alpha` / `method` 会在前端覆盖自定义顺序。 > `SwaggerAutoConfiguration.java` 的 springdoc 依赖(`io.swagger.v3.oas.models.*` 与 `org.springdoc.core.*`)在 3.0.3 下包路径一致、**无需迁移改动**;但⚠️ 它并非"零改动"——本次它正是 `@ApiOrder` 排序引擎的实现载体(见 §9⑧):新增 `GlobalOperationCustomizer`(写 `x-order`)、`GlobalOpenApiCustomizer`(按序重排 paths/tags)及 ASM 字节码行号读取等逻辑,是本节最大的一处代码新增。 > **注意**:springdoc 3.0.3 内部经 swagger-core 仍依赖 Jackson 2,须配合 §5 的 `spring-boot-jackson2` 在 classpath,否则 Swagger-UI 启动报 Jackson 版本错。 **⑥ 安全放行(springdoc 路径)**:springdoc UI 与规格端点需匿名可达。框架默认放行清单 `blade-core-secure` 的 `SecureRegistry.defaultExcludePatterns` 已含 `/v3/api-docs/**`、`/swagger-ui/**`、`/swagger-ui.html`(`/swagger-ui/**` 为 UI 静态资源,`/swagger-ui.html` 为跳转入口),下游 servlet 应用继承即可。若应用另有自建放行清单(Boot 单体的 `BladeConfiguration`、Cloud 网关的 `AuthProvider`),须同步补齐这三项,并去掉 `/doc.html`、`/swagger-resources/**` 等 knife4j / springfox 旧路径。 **⑦ 代码生成器模板**:`blade-starter-develop/src/main/resources/templates/controller.java.vm` 里的 `@ApiOperationSupport` import 与注解一并删除——它是 `.vm` 资源,下游按 `*.java` 批量清理的脚本覆盖不到,须单独处理,否则该生成器产出的 Controller 引用已移除的 knife4j 包、编译不过(排序改用 §9⑧ 的 `@ApiOrder`)。删干净后**还须在模板 `@Tag` 上方补一行无参 `@ApiOrder` + 其 import**(`org.springblade.core.swagger.annotation.ApiOrder`),让生成的 Controller 与手写控制器一致带排序(见下「落地约定」);下游 Boot(`src/{main,test}/resources/templates/`)、Cloud(`blade-ops/blade-develop/src/{main,test}/resources/templates/`)的同名模板副本一并补。 **⑧ 接口排序:`@ApiOrder`(替代 knife4j 的 `@ApiOperationSupport(order = n)`)** springdoc / OpenAPI 3 无接口排序原生注解,blade-starter-swagger 新增 `org.springblade.core.swagger.annotation.ApiOrder`: - **方法上** `@ApiOrder(n)`:该接口显式序号,值越小越靠前。 - **类上** `@ApiOrder(n)`:`n` 为该控制器分组(`@Tag`)的序号,类内接口按源码声明顺序排列;个别方法可再标方法级注解精确覆盖。 - 未标注:回落路径 / tag 名字母序。 实现在 `SwaggerAutoConfiguration`:`GlobalOperationCustomizer` 读注解写入 `x-order` 扩展(类级用字节码行号取声明序,反射不保证方法顺序)并记录「tag → 类级序号」;`GlobalOpenApiCustomizer` 按 `x-order` 重排 `paths`、按类级序号重排顶层 `tags`。swagger-ui 仅在两个 sorter 未设时保留 spec 顺序(§9⑤),并须保持 `springdoc.writer-with-order-by-keys` 默认 `false`。 **落地约定(本次统一)**:三工程**每个带 `@Tag` 的控制器**均在 `@Tag` 上方补一行**无参 `@ApiOrder`**(值取默认 `Integer.MAX_VALUE`),方法体只保留 `@Operation`——效果是分组内接口按**源码声明顺序**展示、各 `@Tag` 分组之间因类级序号相等而回落 tag 名字母序;仅当个别接口要打破源码顺序时,才在该方法上补 `@ApiOrder(n)` 精确覆盖。删 knife4j 排序注解(§3.1 / §4.2)后**只删不补**会让接口在 springdoc UI 下按字母序、丢失原有顺序,故「删旧 + 补 `@ApiOrder`」是同一件事的两半。代码生成器模板 `controller.java.vm`(blade-tool `blade-starter-develop` 源模板 + 下游 Boot/Cloud `blade-develop` 的 main/test 副本,共 5 份)同步在 `@Tag` 上方生成 `@ApiOrder`,新生成的 Controller 开箱即带排序。 > 提示:类级 `@ApiOrder(n)` 的 `value` 用作 `@Tag` 分组之间的排序序号(`SwaggerAutoConfiguration#recordTagOrder` 写入、`GlobalOpenApiCustomizer` 据此重排顶层 `tags`);分组内各接口不受此值影响,按源码声明顺序排列。本次全量采用**无参**形式,`value` 恒为默认值、各分组落 tag 名字母序;如需固定分组顺序,给对应控制器标 `@ApiOrder(n)` 即可。 --- ## 10. Boot 4 BOM 移除的坐标(自管版本) | 坐标 | Boot 4 状况 | 处置 | |------|------------|------| | `spring-boot-starter-aop` | 已移除 | 换 `spring-boot-starter-aspectj`(§3.3) | | `org.springframework.retry:spring-retry` | BOM 不再托管 | 自管 2.0.13(§3.2),`RetryConfiguration` 无源码改动 | | `org.apache.httpcomponents:httpclient`(HttpClient 4) | 非 BOM 托管(Boot BOM 仅托管 HttpClient 5) | 自管 4.5.14(§3.2),`blade-starter-social` 的 JustAuth 依赖它 | --- ## 11. Druid / 数据源坐标 `blade-starter-mybatis/pom.xml` 与 `blade-core-boot/pom.xml`: ``` druid-spring-boot-3-starter → druid-spring-boot-4-starter ``` `blade-core-boot/pom.xml` 另需:`mybatis-plus-spring-boot3-starter → mybatis-plus-spring-boot4-starter`。 > Boot 4 把 JDBC 从 web starter 拆出;本工程用数据源的模块通过 `druid-spring-boot-4-starter` 已传递 `spring-boot-jdbc`,无需单独声明。 ### 11.1 Nacos 客户端(Cloud 微服务) `blade-core-cloud/pom.xml` 重新声明的 `com.alibaba.nacos:nacos-client` 上排除 `com.alibaba.nacos:logback-adapter`,并把 `alibaba.nacos.version` 升到 `3.2.2`。Boot4 托管的 logback 版本较新,Nacos 自带的 logback 适配器与之不匹配,不排除时启动期存在绑定失败风险: ```xml com.alibaba.nacos nacos-client com.alibaba.nacos logback-adapter ``` --- ## 12. 编译验证 ```bash # 全量 clean install(务必带 clean,重新生成 spring.factories / *.imports) mvn clean install -DskipTests -Dmaven.test.skip=true ``` 通过标准:24 个模块全部 `SUCCESS`。验证生成物: ```bash # 1. EnvironmentPostProcessor 注册键为新的根包键 cat blade-starter-loadbalancer/target/classes/META-INF/spring.factories # 期望:org.springframework.boot.EnvironmentPostProcessor=...GrayscaleEnvPostProcessor # 2. JacksonLauncherServiceImpl 已注册 cat blade-core-tool/target/classes/META-INF/services/org.springblade.core.launch.service.LauncherService ``` --- ## 13. 运行期验证要点(交由真实环境执行) 编译通过不等于运行正确,升级后重点验证: 1. **JSON 序列化**:`@BladeView` 视图过滤、大数转字符串、null 转空、读写分离转换器是否生效(确认活跃转换器为 `MappingApiJackson2HttpMessageConverter`)。 2. **MyBatis-Plus + Druid** 在 Boot 4 下的自动装配与 SQL 执行。 3. **Web 容器** 已切 Tomcat,压测吞吐特征与 Undertow 时代不同。 4. **spring-retry 2.0.13 / Seata / springdoc UI** 在 Spring 7 运行期兼容。 5. **UReport 报表模块**(`blade-starter-report`)编译通过,运行期需验证。 --- ## 14. 下游工程连锁影响(重要) blade-tool 发版后,下游 SpringBlade Cloud / Boot 通过 `blade-core-bom` 自动获得整套 Boot 4 版本管理。但下游仍需自行处理: - **MyBatis-Plus 包迁移(⚠️ 静默运行期风险)**:MyBatis-Plus 3.5.17 把 `IService`/`ServiceImpl` 从 `com.baomidou.mybatisplus.extension.service.*` 迁到了 `com.baomidou.mybatisplus.spring.service.*`。业务代码里凡 `import com.baomidou.mybatisplus.extension.service.IService/ServiceImpl` 都要改到 `spring.service`(§8)。**升级前务必全量 grep 下游二开工程**——任何仍编译在旧包上的二开模块或第三方库会在运行期抛 `NoClassDefFoundError`;若二开生态面广、一时改不完,可临时把 MyBatis-Plus 钉在 `3.5.16`(该版本仍保留旧 `extension.service` 包),推迟这次迁移。 - **Web 容器切 Tomcat**:静默继承,须回归。下游工程**自己的** yml 里若有 `server.undertow.*`(如 `application.yml`、`bootstrap.yml`、Nacos 配置模板)也要一并换成 `server.tomcat.*`,否则那段是被忽略的死配置、原线程调优静默失效;`logback` 里的 `io.undertow`/`org.xnio` 日志器可换成 `org.apache.catalina`/`org.apache.coyote`。 - **Knife4j 彻底移除**(无 Boot4 版):下游须一并删干净——`knife4j-openapi3-ui` / `knife4j-gateway-spring-boot-starter` 依赖改 springdoc 3.0.3、`doc.html` 入口去除、yml 里的 `knife4j:` 配置整段删除、业务控制器里的 `@ApiOperationSupport` / `@ApiSort` 注解与 import 全部删除(不保留 `knife4j-annotations` 兼容包)。接口排序改用 BladeX 的 `@ApiOrder`(见 §9⑧)。 - **各自直接依赖**:如 `dynamic-datasource-spring-boot3-starter`(Cloud)需换 boot4 版。 (下游 Cloud / Boot 的详细升级步骤见同目录另两份文档。) --- ## 15. Spring 7 空值注解迁移(JSpecify) > 本次升级已执行该迁移(采用下文**方案一 · 机械替换**)。本节记录背景、映射、落地范围与验证结果。 ### 背景 Spring Framework 7 把自带的空值注解 `org.springframework.lang.@Nullable / @NonNull / @NonNullApi / @NonNullFields` 全部标记为 `@Deprecated(since = "7.0")`(**仅弃用,未标 forRemoval**),官方转向业界标准 **JSpecify**(`org.jspecify.annotations.*`)。 **官方依据**(可直接查证): - Spring 随源码发布的 `org.springframework.lang.Nullable` javadoc 明写 `@deprecated use {@link org.jspecify.annotations.Nullable} instead`,类上带 `@Deprecated(since = "7.0")`;且该包 `package-info.java` 自身已改用 `@org.jspecify.annotations.NullMarked`(即 Spring 官方源码内部也已切到 JSpecify)。 - Spring Framework 参考文档 · Null-safety:https://docs.spring.io/spring-framework/reference/core/null-safety.html - Spring 官方博客《Null-safe applications with Spring Boot 4》(2025-11-12):https://spring.io/blog/2025/11/12/null-safe-applications-with-spring-boot-4/ - 立项 issue《Migrate to JSpecify annotations for nullability constraints》#28797:https://github.com/spring-projects/spring-framework/issues/28797 官方在上述博客中明确**建议整个生态**(含 Reactor、Micrometer 及依赖 Spring 的各库)统一采用 JSpecify——这正是本次把 Reactor / Jakarta / JetBrains 等其他库的空值注解也一并归一到 JSpecify 的依据。 ### 依赖 JSpecify **1.0.0** 已由 Boot 4.1 BOM 托管,并经 `spring-core` 以 compile 作用域传递到 classpath——**迁移无需新增任何依赖**。 ### 完成情况 本次分两步,把三工程**所有**空值注解统一到 JSpecify: **第一步 · Spring 弃用注解迁移**(`org.springframework.lang.*` → `org.jspecify.annotations.*`) - **blade-tool**:60 个文件、63 处 import(56 `@Nullable` + 7 `@NonNull`);无包级 `@NonNullApi/@NonNullFields`,故无需 `@NullMarked`。 - **Cloud**:1 个文件(`blade-gateway/.../filter/GatewayFilter.java`)。 - **Boot**:无 `org.springframework.lang.*` 引用,无需改动。 - 落地方式:import 就地替换,最小 diff;14 处数组 / 可变参数入参 + 4 处数组返回值按 TYPE_USE 校正(见下)。 **第二步 · 其他库空值注解归一**(统一到 JSpecify,消除多套注解并存) | 文件 | 原注解 | 改后 | |---|---|---| | `blade-starter-i18n`:`I18nLocaleResolver` / `I18nAutoConfiguration` | `jakarta.annotation.@Nonnull`(4 处) | `org.jspecify.annotations.@NonNull` | | `blade-core-cloud`:`OkHttp3ClientHttpRequestFactory` | `org.jetbrains.annotations.@NotNull`(1 处) | `org.jspecify.annotations.@NonNull` | | Cloud `blade-gateway`:`JwtCrypto` | `reactor.util.annotation.@Nullable`(3 处) | `org.jspecify.annotations.@Nullable` | > 简单名变化需留意:`@Nonnull` / `@NotNull`(旧库表示"非空")在 JSpecify 中统一为 `@NonNull`,故这几处除换 import 外还需改注解名;`@Nullable` 简单名不变、仅换 import。这几处均为标量参数 / 返回值(无数组),不涉及 TYPE_USE 位移;import 均放到分组内的正确字母序位置。归一后三工程只剩 `org.jspecify.annotations.*` 一套空值注解——共 **68 处 import(57 `@Nullable` + 11 `@NonNull`)**。 ### 迁移映射(Spring 弃用注解) | 旧(`org.springframework.lang`) | 新(`org.jspecify.annotations`) | |---|---| | `@Nullable` | `@Nullable` | | `@NonNull` | `@NonNull`(或在 `@NullMarked` 作用域下直接删除) | | `@NonNullApi` / `@NonNullFields`(package-info) | `@NullMarked`(包 / 模块级) | ### ⚠️ 关键差异:声明级 → 类型用法级(TYPE_USE) JSpecify 的注解是 **`@Target(TYPE_USE)`**,Spring 的是 `@Target({METHOD, PARAMETER, FIELD})` 声明级。类型用法注解必须**紧贴类型**,以下位置换 import 后需手工校正: - **返回值**:`public @Nullable String get()`——注解须紧贴返回类型,不能放在 `public` 等修饰符之前。 - **数组**:`String @Nullable []`(数组本身可空)与 `@Nullable String[]`(元素可空)语义不同,TYPE_USE 能区分、声明级不能。 - **泛型**:`List<@Nullable String>` 只有 TYPE_USE 能标注类型参数。 简单的字段 / 参数 / 普通返回类型可直接换 import(视觉位置不变);上述三类位置需调整。换完**必须编译核验**,并把 IDE 的空值分析规则切到 JSpecify。 **本次实际处理**:14 处数组 / 可变参数入参(全在 `blade-core-tool` 的 `JsonUtil` / `Func` / `CollectionUtil` / `DigestUtil` / `StringUtil` 共 13 个方法,其中 `DigestUtil.slowEquals` 的两个 `byte[]` 入参各计一处),以及 4 处 `byte[]` 数组返回值(`AesUtil.decryptFormHex` / `decryptFormBase64`、`HexUtil.decode`、`RedisKeySerializer.serialize`),语义均为"该数组 / 可变参数 / 返回值可为 null",故按 TYPE_USE 规则把注解放在数组位: ```java // 换 import 后仍需手工校正的数组位(否则语义从"数组可空"漂移成"元素可空") public static T readValue(byte @Nullable [] content, Class valueType) // 入参:由 @Nullable byte[] content 调整 public static String format(@Nullable String message, Object @Nullable ... arguments) // 可变参数同理 public static byte @Nullable [] decryptFormHex(@Nullable String content, String aesTextKey) // 返回值:注解须在数组位,不能置于 public 修饰符前 ``` > **能这么放心机械替换的根因**:Spring 的 `@Nullable/@NonNull` 是**声明级**(`@Target({METHOD, PARAMETER, FIELD})`),所以现存用法必然只落在方法 / 参数 / 字段声明上——这些位置 JSpecify 的 TYPE_USE 注解都能绑定到对应类型,编译等价;唯独数组 / 可变参数的"元素位 vs 数组位"才有语义差,单独校正即可。其余标量字段 / 参数 / 返回值仅换 import,位置不变。 ### 采用方案:方案一 · 机械替换(本次已执行) - **方案一 · 机械替换(已采用,低风险)**:逐 import 替换 `org.springframework.lang.Nullable → org.jspecify.annotations.Nullable`、`NonNull → org.jspecify.annotations.NonNull`,并按上文校正数组 / 可变参数的 TYPE_USE 位置。语义等价,脚本批量完成。 - **方案二 · JSpecify 惯用法(未采用)**:在包级 `package-info.java` 或模块级加 `@NullMarked`(默认一切非空),仅在可空处保留 `@Nullable`、删除显式 `@NonNull`。更贴合 JSpecify 设计,但"默认非空"会暴露隐含可空点、需逐一甄别,风险与工作量更高;可作为后续代码现代化的独立议题。 ### 归一边界(哪些不并入 JSpecify) 本次已把三工程内所有**纯元数据**空值注解并入 JSpecify;唯一需排除的是 **`lombok.@NonNull`**——它在编译期**生成运行时判空代码**(非纯元数据),换成 JSpecify 会丢掉该运行时检查、属行为变更。经全量扫描,三工程**均不存在** `lombok.@NonNull`,故本次无此顾虑;后续若引入,不得机械替换。 ### 验证 blade-tool(24 模块)、Cloud(23 模块)、Boot 均 `mvn clean install` `BUILD SUCCESS`。全量 grep 复核:三工程空值注解**仅存** `org.jspecify.annotations.*` 一套(68 处 import),零 `org.springframework.lang.*` / `jakarta.annotation` / `reactor.util.annotation` / `org.jetbrains.annotations` 残留,无 `@Nonnull` / `@NotNull` 遗留用法,无重复 import,无元素位数组注解。TYPE_USE 数组语法(`byte @Nullable []`)编译通过;IDE 空值分析规则可切到 JSpecify。 --- ## 16. 升级收尾:JDK 21、依赖时效性与打包优化 > 核心迁移完成后同批落地的一批收尾优化。三工程均在 JDK 21 下 `mvn clean install` `BUILD SUCCESS`。 ### 16.1 JDK 17 → 21 三工程 `` 由 17 抬到 **21 LTS**(Boot 4 最低仍是 17,此为主动升级)。`maven-compiler-plugin` 以 `${java.version}` 驱动 `source`/`target`,改属性即全生效;三份 README 的 JDK 徽章与技术栈表同步改 21+。**升级后构建需 JDK 21**。 此外,运行期基础镜像须同步升级:Boot 单体 1 个、Cloud 微服务 9 个 `Dockerfile` 的基础镜像由 `bladex/alpine-java:openjdk17_cn_slim` 改为 `openjdk21_cn_slim`(注意注释里的阿里云备用镜像行 `registry.cn-hangzhou.aliyuncs.com/bladex-repo/alpine-java:openjdk17_cn_slim` 也要一并改成 `openjdk21_cn_slim`)——`java.version` 只决定编译目标,基础镜像才决定容器内实际运行的 JRE,两者版本号必须对齐,否则 JDK 21 编出的字节码在 openjdk17 镜像里会 `UnsupportedClassVersionError` 起不来。blade-tool 为纯库工程无 Dockerfile,不涉及。 ### 16.2 依赖时效性刷新 对照 Boot 4.1.0 BOM 与 Maven Central 核过一轮第三方版本: | 依赖 | 处置 | 说明 | |---|---|---| | lombok | **去 pin**,回落 BOM `1.18.46` | 本地原 pin `1.18.42` 反而把 BOM 降级了 | | commons-lang3 | **去 pin** | 原 pin `3.20.0` 与 BOM 完全一致,属冗余自管 | | guava | `33.5.0-jre` → `33.6.0-jre` | 非 BOM 托管,自管刷新 | | maven-compiler-plugin | `3.14.1` → `3.15.0` | 对齐 Boot 4.1.0 自身构建/BOM 版本 | | maven-jar-plugin | `3.4.2` → `3.5.0` | 对齐 Boot 4.1.0 BOM | | disruptor | **删除孤儿属性** | 全仓零引用的死属性 | | okhttp / protostuff | **保留** | okhttp 5.x 跨大版本、有硬阻断风险;protostuff 1.8.0 已是最新 GA(Java 21 下依赖 `sun.misc.Unsafe`,Redis 序列化路径建议运行期验证) | ### 16.3 清理未使用的 JAXB 依赖 `blade-core-launch` 原声明 `javax.xml.bind:jaxb-api` + `com.sun.xml.bind:jaxb-core/jaxb-impl`(旧 javax 坐标,当年为 JDK 11+ 补 JAXB)。四维度全量扫描确认三工程**零处使用** JAXB(无 `@Xml*` 注解、无 `JAXBContext`/`Marshaller`/`Unmarshaller`、无 Spring OXM/WebService),属历史死依赖,已删除。删后重复的 JAXB 运行时实现(RI)消除;现代 `jakarta.xml.bind` API 仍由框架传递引入,将来若需 XML 绑定,按 `jakarta.xml.bind:jakarta.xml.bind-api` + `org.glassfish.jaxb:jaxb-runtime` 引入即可(勿用回旧 javax 那套)。保留 `jakarta.activation-api`(现代 Activation 规范,非 JAXB)。此外,父 POM `` 里与旧 JAXB 配套的孤儿托管条目(`javax.xml.bind:jaxb-api`、`com.sun.xml.bind:jaxb-core/jaxb-impl`、`javax.activation:activation`)已无任何模块引用,一并删除。 ### 16.4 清理 Boot 4 已移除的死配置键 `blade-core-boot/bootstrap.yml` 删除 `spring.mvc.throw-exception-if-no-handler-found`——该键在 Boot 4 配置元数据里已 `level=error`、无替代(`since 3.2.0`;新默认即抛 `NoHandlerFoundException`,行为不变)。留着是被静默忽略的死键、会被 IDE / 配置校验 / `spring-boot-properties-migrator` 标红,且随核心 starter 传播下游。 ### 16.5 javadoc 发布打包:规避模块路径报错 `-P release` 发布时 `maven-javadoc-plugin` 在 JDK 9+ 下走模块路径,会因**内嵌 Tomcat 与 servlet-api 分裂包**、以及**个别依赖自动模块名无法推导**(如 `nacos-client-3.2.2` 的 `META-INF/services` 声明了不在同 jar 的 provider 类)而报大量 javadoc 错误(原 `failOnError=false` 已让其非致命)。根治:release profile 的 javadoc 配置加 `true`(走类路径、不推导模块描述符),所有 javadoc 报错归零、javadoc jar 正常产出。此为构建期问题,与运行期无关。 ### 16.6 `BladeErrorController` 补 `@SuppressWarnings("removal")` `blade-starter-log/.../error/BladeErrorController#errorHtml` 用到 SF7 标记 `forRemoval` 的 `MappingJackson2JsonView`,与 §5.5④ 其余 6 处策略一致,补 `@SuppressWarnings("removal")` 消除告警。 ### 16.7 启用 Boot 4.1 进程信息贡献器(可观测性收尾) Boot 4.1 新增 `ProcessInfoContributor`,可让 `/actuator/info` 携带进程运行时长、启动时间、时区、工作目录等信息(便于 Spring Boot Admin 面板观测线上运行状态)。该贡献器默认关闭,需显式开启 `management.info.process.enabled=true`。 BladeX 在 `blade-core-launch` 的 `BladeApplication.createSpringApplicationBuilder` 默认 props 块里(紧邻既有 `info.version` / `info.desc` 等 actuator info 默认)加一行: ```java props.setProperty("management.info.process.enabled", "true"); ``` - **放这里的原因**:`spring-boot-starter-actuator` 是父 POM 的全局公共依赖,全下游 app(Cloud + Boot)继承,一处默认惠及所有应用、零下游改动(契合同工程 `JacksonLauncherServiceImpl` / `SwaggerLauncherServiceImpl` 框架级设默认值的惯例)。 - **只开 `process`**:未开 `env`——`management.info.env.*` 会把已解析的配置属性值暴露到 `/actuator/info`,有泄露风险。 - **可覆盖**:命令行 `--management.info.process.enabled=false` 优先级高于 System 属性,可关闭。 - **无对外泄露**:BladeX 默认仅放行 `/actuator/health`(见 `SecureRegistry` / `BladeRequestFilter`),`/info` 端点受访问控制保护。 --- ## 17. 方法注释规范统一 + 代码生成器模板同步 > 与 SB4 迁移同批推进的注释治理:按分层收敛方法注释风格,并让代码生成器产出的代码开箱即符合该规范。此节为「改造要求 + 改造记录」,后续新增 / 二开代码及生成器改动均以此为准对照着改。 ### 17.1 分层注释规范(改造要求) | 层 | 规范 | 为什么 | |---|---|---| | **Controller** | 方法 javadoc **只留摘要行**,不写 `@param`/`@return` | 接口文档由 springdoc 的 `@Operation`/`@Parameter` 承载,javadoc 标签与其重复且易残缺;摘要行已够 IDE 速览 | | **Service / Mapper 接口** | 方法 javadoc **必须完整**:每个 `@param` 有意义描述、`@return` 有意义描述,不留空标签;`void` 方法不写 `@return` | 接口是调用方唯一契约来源(实现类 override 方法继承其注释),契约注释残缺会顺 IDE 悬浮提示传播到所有调用点 | | **ServiceImpl** | public override 方法**不强制**注释(继承接口契约);**private 方法必须完整**(摘要 + `@param`/`@return`) | 私有方法无接口契约兜底,是实现内唯一注释来源 | | **Entity / VO / DTO / Wrapper** | 不在本轮注释规范范围;字段用 OpenAPI 3 `@Schema`,Wrapper 的 override 方法同 ServiceImpl 处理 | — | 补充硬性要求: - 描述从架构师视角写,准确、平实、专业;不写口水话、不逐行复述代码、不堆砌黑话。 - `@param` 名与方法签名**严格一致**;多个 `@param` 描述列对齐;**对齐同文件既有规范注释的措辞**,不另造风格。 - 空标签形态(须消除):`@param page`(参数名后为空)、`@return`(空)。完整形态(目标):`@param page 分页参数` / `@return 用户分页数据`。 - 接口排序注解 `@ApiOrder`:每个带 `@Tag` 的 Controller 在 `@Tag` 上方加无参 `@ApiOrder`,详见 §9⑧。 ### 17.2 改造记录(Boot + Cloud) - **Controller**:含方法级标签的控制器 javadoc 精简为摘要行(Boot 6 文件 / Cloud 8 文件),删除空 `@param`/`@return`;类级 `@author`/`@since` 不动。 - **Service / Mapper 接口**:填全空 `@param`/`@return`(Boot 23 文件、Cloud 25 文件,约 195 个接口方法),对齐同文件既有措辞;并补齐 1 处**缺失**的 `@param`(Boot `MenuMapper#grantTreeByRole`)。 - **ServiceImpl private 方法**:补全 javadoc——两工程各 `RoleServiceImpl#grantRoleMenu/grantDataScope/grantApiScope`、`TenantServiceImpl#getTenantId`。 - **`void` 方法**:删除多余空 `@return`(如 `IUserService#importUser`,void 无返回值)。 - 复核口径:全改动**仅注释行**(零签名 / 代码 / import 变动),目标层空标签、缺失 `@param`、错配 `@param` 三项全部归零。 - 范围之外(已知待办):Cloud Feign 层 `*Client` 接口与 `ResponseProvider` 尚有空标签,不属本轮四层,后续如需同规格补;`RegionMapper#lazyList/lazyTree` 声明返回 `List` 而 SQL 实为行政区划,疑历史类型笔误,因禁改签名仅按真实语义写 `@return`。 ### 17.3 代码生成器模板同步(`blade-starter-develop/src/main/resources/templates/`) 让生成的代码开箱符合 §17.1,并保持 SB4 / 最新 Swagger 风格: - `controller.java.vm`:`@Tag` 上方 `@ApiOrder` + import(见 §9⑧⑦);方法 javadoc 本即摘要行,无 `@param`/`@return`。 - `service.java.vm` / `mapper.java.vm`:`select{Entity}Page` 空标签补为 `@param page 分页参数` / `@param {entityPath} {comment}查询条件` / `@return {comment}分页数据`。 - `serviceImpl.java.vm`:仅 public override 方法、无 private 方法,按规范不加注释。 - 模板已是 SB4 风格:`jakarta.*`、OpenAPI 3(`io.swagger.v3.oas.annotations.*`,含实体 / VO 的 `@Schema`),无 `javax.*` / knife4j / springfox。 - 下游 Boot、Cloud 各自 `blade-develop` 内的 `controller.java.vm` 模板副本同步同一改动(见 §9⑧⑦)。 --- ## 18. 代码生成器 `blade-starter-develop` 结构重构与生成器逻辑收敛 > 承接 §17 的模板治理,进一步把生成器模块的**模板目录**与**Java 逻辑**重构到最新形态,并修复 MyBatis-Plus Generator 的过期 API。此节为「改造要求 + 改造记录」,便于后续读取与检验。 ### 18.1 模板目录结构(`blade-starter-develop/src/main/resources/templates/`) - `api/`:9 个后端 `.vm`(controller / entity / entityDTO / entityVO / mapper / mapper.xml / service / serviceImpl / wrapper)。 - `saber/`:`api.js.vm` + `crud.vue.vm`(前端 **Vue3 Composition API + TypeScript** 现代化版)。 - `sql/`:`menu.sql.vm`(菜单初始化 SQL 模板)。 - `code.properties`:生成器参数配置。 ### 18.2 生成器 Java 逻辑收敛 `develop` 包收敛后仅保留 `CodeGenerator`(入口)与 `support/BladeCodeGenerator`(配置): - `BladeCodeGenerator`:后端模板路径指向 `/templates/api/*`、前端 `/templates/saber/{api.js,crud.vue}.vm`;移除按前端类型的多目录分流逻辑(生成器统一使用 `saber` 前端模板)与 `DevelopConstant` 依赖;菜单 SQL 经 `customFile`(`menu.sql`)+ `outputCustomFile` 接线,用 `IdWorker` 生成 5 个菜单 ID(主 / 增 / 改 / 删 / 查),输出到 `{outputDir}/sql/{entity}.menu.sql`。**注:`systemName` 字段予以保留**(默认值由 `DevelopConstant.SABER3_NAME` 改为字面量 `"Saber"`),因下游 Boot/Cloud 的 `CodeController` 与测试 `CodeGenerator` 仍调用 `setSystemName(...)`——保留该字段可让下游**零改动**编译通过,只是它不再参与前端模板分流。 - `CodeGenerator`:移除 `SYSTEM_NAME` 与 `setSystemName`。 - `DevelopConstant.java`:整文件删除(全仓零引用)。 ### 18.3 修复 MyBatis-Plus Generator 3.5.8 过期 API(`templateConfig` → builder 级 `*Template`) - **过期点**:`TemplateConfig.Builder` 的 `entity/service/serviceImpl/mapper/xml/controller` 六个模板路径方法在 3.5.8 已 `@Deprecated`。 - **改法**(参照 BladeX-Tool 商业版思路、以本工程 Velocity/`.vm` 自行适配,非照搬其 Beetl 源码):删除 `.templateConfig(builder -> builder.disable(TemplateType.ENTITY).entity(...) … .controller(...))`,改用 `strategyConfig` 各 builder 的非过期方法: - `entityBuilder().javaTemplate("/templates/api/entity.java.vm")` - `serviceBuilder().serviceTemplate(…service.java.vm).serviceImplTemplate(…serviceImpl.java.vm)` - `mapperBuilder().mapperTemplate(…mapper.java.vm).mapperXmlTemplate(…mapper.xml.vm)` - `controllerBuilder().template(…controller.java.vm)` 并移除随之无用的 `import …generator.config.TemplateType;`。 - **语义等价依据**:生成引擎运行时直接读取 builder 级字段(`AbstractTemplateEngine` 用 `entity.getJavaTemplate()`、`mapper.getMapperTemplatePath()` 等);`VelocityTemplateEngine.templateFilePath` 对 `.vm` 结尾做条件判断(已带则不再追加),故沿用带 `.vm` 的完整路径安全;旧 `.disable(TemplateType.ENTITY)` 本被紧随的 `.entity(...)`(内部置 `disableEntity=false`)抵消,移除对生成行为无影响。 ### 18.4 验证 - JDK 21 下 `mvn -pl blade-starter-develop compile` **成功**,`-Dmaven.compiler.showDeprecation=true` 对 `BladeCodeGenerator` **无 deprecation 告警**。 - 代码引用的后端(`api/`)、前端(`saber/`)与菜单 SQL(`sql/`)模板路径与磁盘一一对应;无 `DevelopConstant` 残留。`systemName` 字段**按下游 API 兼容需要保留于 `BladeCodeGenerator`**(仅前端分流逻辑移除,见 §18.2),不属残留。 - 注:`saber/crud.vue.vm`(Vue3+TS)属采纳的现代化模板,运行期生成未连库实测;`beforeOutputFile` 里历史遗留的 `System.out.println` 与本次重构无关,按最小改动保留。 ### 18.5 生成物 POJO 包结构(entity / dto / vo 归入 `pojo/`) 旧版生成的 `entity/`、`dto/`、`vo/` 平铺在模块根;5.0.0 起统一归入 `pojo/` 子包(`wrapper` / `mapper` / `controller` / `service`(含 `service/impl`)位置不变)。以 `notice` 为例: ``` notice/pojo/entity/Notice.java package …notice.pojo.entity notice/pojo/dto/NoticeDTO.java package …notice.pojo.dto notice/pojo/vo/NoticeVO.java package …notice.pojo.vo notice/wrapper/NoticeWrapper.java package …notice.wrapper (不动) notice/{mapper,controller,service,service/impl}/… (不动) ``` 实现(`BladeCodeGenerator` + 2 个模板;entity/dto/vo 三模板不涉此项): - `packageConfig` 的 entity 子包设为 **`.entity("pojo.entity")`**(与 `.serviceImpl("service.impl")→service/impl/` 同机制,MP 把点转成目录层级)。 - `outputCustomFile` 里 dto、vo 两处自定义输出路径**各含一层 `pojo/`**;wrapper 输出路径在模块根。 - dto/vo 的包由模板既有的 `$package.Entity.replace("entity","dto"/"vo")` 自动推导(entity 为 `pojo.entity` 时得 `pojo.dto`/`pojo.vo`),这三个模板无需为此改动。 - `wrapper.java.vm` 与 `controller.java.vm` 内 wrapper 包的推导用 **`$package.Entity.replace("pojo.entity","wrapper")`**,使 wrapper 落在模块根(而非 `pojo.wrapper`);其对 entity/vo 的 import 自动指向 `pojo.*`。 **下游连锁**:Boot/Cloud 各带一套本地模板副本,在 classpath 上遮蔽 blade-tool jar 内模板 —— 下游实际用本地副本生成,故本节与 §18.1 的目录形态需在下游本地副本上一并对齐(目录 `api/`/`saber/`、wrapper 推导同上),否则要么命不中本地副本(回落 jar 内模板)、要么 wrapper 包路径与目录不符而编译失败。 **验证**:`blade-starter-develop` JDK21 `clean install` 通过,jar 内模板仅 `api/`(9)+`saber/`(2);notice 结构推演 dto→`pojo.dto`、vo→`pojo.vo`、wrapper→根 三处「包↔路径」一致。 > ⚠️ 维护提示:`.vm` 是 Velocity 模板,常被 IDE 当 Java 误格式化(如把 `package $!{package.Entity};` 拆行、`#foreach($pkg in $!{table.importPackages})` 打断而生成坏代码)。改动后务必核对指令配平(`#if`+`#foreach` 数 == `#end` 数)与实际产物,勿让编辑器自动重排 `.vm`。