package com.zteits.parking.server; import com.clouds.common.mybatis.LegacyMapperBeanNameGenerator; import com.zteits.charge.config.ChargeConfig; import org.mybatis.spring.annotation.MapperScan; import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringBootConfiguration; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.FilterType; import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.annotation.EnableScheduling; /** * 项目的启动类 * * ⚠️ 注意:不要扫描 {@code com.zteits.parking.framework} 包! * 框架层(parking-framework)的组件全部通过 Spring Boot 的 * {@code META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports} 注册为 * {@code @AutoConfiguration} 类,如果这里再直接扫描该包,会导致它们被重复注册。 * 同理也不要图省事扫 {@code com.zteits}:那会把 {@code com.zteits.parking.framework} 一起扫进来。 * *

scanBasePackages 为什么是这一长串

* * 它是**老工程 9 个独立 JVM 各自 {@code @ComponentScan} 的并集**(前 7 个逐个抄自 * {@code ParkApplication} / {@code OrderApplication} / {@code PayApplication} / * {@code BillApplication} / {@code ChargeApplication} / {@code BusinessApplication} / * {@code com.zteits.sys.Application},第 8 个是随迁的企业微信服务子系统、 * 第 9 个是只有空实现的设备采集,均见下)。 * 合并单 Jar 后这些包必须一起扫,否则任何一个 * {@code @Component}/{@code @Service} 都不会注册,运行起来是「启动成功、调用报无此 Bean」。 * * 逐项来源: * * *

三个「故意没扫」的包

* * * *

扫描顺序:{@code com.clouds.common.*} 为什么必须写在最前面

* * Spring 按 {@code basePackages} 的书写顺序扫描,扫描顺序 = BeanDefinition 注册顺序, * 也就是(无依赖关系时的)Bean 创建顺序。老框架里有一批 CacheUtil 把 * {@code RedisCacheUtil} **缓存进了静态字段**: * *
 * private static RedisCacheUtil redisCacheUtil = RedisCacheFactory.getRedisCacheUtil();  // 41 个类这么写
 * 
* * 而 {@code RedisCacheFactory} 的静态字段是在它自己的 * {@code setApplicationContext} 回调里才赋值的。静态字段只在**类加载时**求值一次, * 所以只要有一个 {@code com.zteits.*} 的 Bean 在它之前被创建、且创建期间就类加载了这些 * CacheUtil 类,那个静态字段就被**永久钉死为 null**,之后每次调用都 NPE。 * 老代码里确实有这种写法:{@code TdCAcctPersonServiceImpl} 的 {@code @PostConstruct} * 直接起后台线程,线程第一件事就是查字典(走 CacheUtil)。 * *

把 {@code com.clouds.common.redis} 排到最前,{@code RedisCacheFactory} 就会在任何 * {@code com.zteits.*} Bean 之前建好,上面那条竞态窗口随之关闭。**这是启动能否成功的必要条件, * 不要因为「看起来只是顺序」而把它调回去**。老工程 7 个 JVM 各自的扫描列表里 * {@code com.clouds.common.redis} 也都排在业务包之前,合并后必须保持。

* *

⚠️⚠️ 2026-10-15 实测补正:上句的「任何 {@code com.zteits.*} Bean」**包括新工程自己的 * {@code com.zteits.parking.server} / {@code com.zteits.parking.module}**。这两个包原先写在 * 4 个 {@code com.clouds.common.*} **之前**,恰好与本节结论相抵——而且**靠服务层依赖就足以踩中**: * 控制器是用 {@code @Resource} 把服务层「拽」进创建流程的(服务层 BeanDefinition 注册在再后面也没用), * 所以只要新栈有一个控制器的传递性依赖能触达带 {@code @PostConstruct} 起线程的 * {@code TdCAcctPersonServiceImpl},整条链就会先于 {@code RedisCacheFactory} 建好。 * 之前 46 个已迁控制器之所以没炸,只是**没有一个能触达它**;批 6 第十五切片引进的 4 个统计控制器 * ({@code EmployeeStatisticController} → {@code TpEEmployeeChargeService}、 * {@code UrbanIncomeStatisticController} → {@code PayOrderQueryService}, * 两者都再注入 {@code TdCAcctPersonService})是**第一批触达它的**,于是启动**确定性失败** * (两次复现,故障链与上文一字不差:{@code Thread-2} 在 {@code TdCAcctPersonServiceImpl.java:771} * NPE → {@code aliOssClientConfig} NPE → 启动中止)。 * 修法就是把 4 个 {@code com.clouds.common.*} 挪到**最前**,连这两个新工程包一起让位 * ——这本来就是本节结论的字面要求。

* *

@MapperScan 为什么是两条

* * 两条各自管一段,互不重叠(靠 {@code annotationClass} 区分): * *
    *
  1. 新代码:{@code com.zteits.parking} 下**带 {@code @Mapper} 注解**的 Mapper * —— 其实这条已经由 {@code ParkingMybatisAutoConfiguration} 的 * {@code @AutoConfiguration + @MapperScan} 提供,这里不重复声明。
  2. *
  3. 存量:{@code com.zteits.{bill,business,charge,order,park,pay,sys}.mapper} * —— 老 Mapper 接口**没有 {@code @Mapper} 注解**,所以必须靠第 2 条 * 「按包扫、不限注解」收进来。这 7 个包名来自 `tools/p2_audit_mapper_ns.py` * 对全部 651 个存量 mapper XML 的 namespace 审计:**每一个**都落在这 7 个包下。
  4. *
* * ⚠️ 两处红线: * * *

为什么要排除 {@code com.zteits.charge.config.ChargeConfig}

* * 它是**本次搬迁里唯一一个来自封闭 Jar 的冲突**:依附在 {@code parking-legacy-common} * 上的 {@code zteits-parking-charge-spring-boot-starter}(源码不可改)自己把自己撞了: * *
 * @Configuration
 * @ComponentScan("com.zteits.charge")        // ← 与我们的扫描重复
 * public class ChargeConfig {
 *     @Bean(name = "chargeService")          // ← 与下面那个 @Component 同 bean 名
 *     public ChargeService chargeService() { return new ChargeService(); }
 * }
 * @Component public class ChargeService {}   // 同一个 jar 里
 * 
* * 老工程能跑,只因 Spring Boot 1.5 的 {@code spring.main.allow-bean-definition-overriding} * 默认是 {@code true},后注册的 {@code @Bean} 把先扫到的 {@code @Component} 悄悄顶掉了; * Boot 2.1 起默认 {@code false},合并单 Jar 后启动即抛 * {@code BeanDefinitionOverrideException}(报错原文见 AGENTS.md 坑 13)。 * * ⚠️ **只关一扇门没用,实测过**:只配 {@code spring.autoconfigure.exclude} 时, * {@code ChargeConfig} 仍会被 {@code @ComponentScan} 当成 {@code @Configuration} * 扫进来(它在条件报告的 Exclusions 段里出现了两次,错误照旧)——因为 * {@code @Configuration} 本身就是 {@code @Component}。两扇门都要关: *
    *
  1. {@code @EnableAutoConfiguration(exclude = ...)} —— 关掉 spring.factories 那扇门 * (也可以用配置 {@code spring.autoconfigure.exclude},但那样就成了「同一件事写在两个 * 文件里」,且丢掉编译期校验,故不采用)
  2. *
  3. {@code @ComponentScan(excludeFilters = ...)} —— 关掉包扫描那扇门
  4. *
* * 排除它**不损失任何能力**:{@code @Bean chargeService()} 的实现就是 * {@code new ChargeService()},而 {@code ChargeService} 本身带 {@code @Component}, * 本来就会被 {@code com.zteits.charge} 这条扫描注册成**同名**的 {@code chargeService} * (那 5 个 {@code @Resource} 字段由 {@code AutowiredAnnotationBeanPostProcessor} * 注入,两条路得到的实例完全等价);{@code ChargeConfig} 自己的 * {@code @ComponentScan("com.zteits.charge")} 又与我们那条扫描重合。 * 新老两个仓库的 .java/.xml 全 grep 过 {@code ChargeConfig}:**零引用**。 * * 为什么不用 {@code spring.main.allow-bean-definition-overriding: true} 一了百了: * 那个开关连「两个**不同类**撞名」也会一起放行,等于把本次重构最该发现的错误静默掉 * (源码侧那 9 组真冲突正是靠启动报错逐一揪出来、再补上显式 bean 名修掉的)。 * 这里是封闭 jar 内部的自我重复,**精确排除 > 全局放水**。 */ @EnableAsync @EnableScheduling // 这三条就是 @SpringBootApplication 的全部内容,展开只是为了能挂 excludeFilters // (@SpringBootApplication 自带的扫描注释不接受过滤器)。语义完全等价: // @SpringBootConfiguration == @Configuration + 一个供测试识别的标记 // @EnableAutoConfiguration == 开启自动配置 // @ComponentScan == 包扫描(原来的 scanBasePackages 挪到 basePackages) @SpringBootConfiguration @EnableAutoConfiguration(exclude = ChargeConfig.class) @ComponentScan( basePackages = { // ⚠️⚠️ 这 4 个 com.clouds.common.* 必须**排在最前**,即排在**所有** com.zteits.* 之前, // 这里面**包括紧随其后的新工程两个包** `${parking.info.base-package}.server` / // `.module`(它们也都是 com.zteits.*,2026-10-15 批 6 第十五切片实测踩过,见类注释 // 「扫描顺序」一节的实测补正)。这不是风格问题,是**启动能不能过**的问题: // Spring 按 basePackages 的书写顺序扫描 → 该顺序即 BeanDefinition 注册顺序 // → 也就是无依赖关系时的 Bean 创建顺序。而老框架里有 41 个 CacheUtil 类把 // RedisCacheUtil **存进了静态字段**(`private static RedisCacheUtil x = // RedisCacheFactory.getRedisCacheUtil();`),RedisCacheFactory 那个静态字段 // 又是在它自己的 setApplicationContext 里才赋值的。只要有任何一个 // com.zteits.* 的 Bean 在创建期间就类加载了这些 CacheUtil(老代码里有 // @PostConstruct 直接起后台线程、线程里立刻查字典的写法),静态字段就被永久 // 钉死成 null,之后每次调用都 NPE。 // ⚠️ 实测故障链:TdCAcctPersonServiceImpl(@PostConstruct 起线程) 先 NPE, // 紧接着 AliOssClientConfig.afterPropertiesSet(建 Bean 时读 OSS 字典)再 NPE, // 启动直接失败。两类都只靠「RedisCacheFactory 更早建好」才能避免。 "com.clouds.common.redis", "com.clouds.common.threadpool", "com.clouds.common.web", "com.clouds.common.oss", // ↓↓↓ 新工程自身的两个包:**必须让位于上面的 com.clouds.common.***(理由同上)↓↓↓ "${parking.info.base-package}.server", "${parking.info.base-package}.module", // ↓↓↓ 存量 7 个 JVM 的 @ComponentScan 并集(详见类注释)↓↓↓ "com.zteits.park", "com.zteits.order", "com.zteits.pay", "com.zteits.bill", "com.zteits.charge", "com.zteits.business", "com.zteits.sys", "com.zteits.ipay", "com.zteits.configuration", // 第 8 个 JVM:企业微信(老 zteits-wxcp-portal,端口 18031)。 // 它整体不搬(门户 Web 层留在老工程),只有「企业微信服务子系统」 // 按文件随迁进了 parking-module-system,包名沿用 com.zteits.wxcp, // 清单见 tools/p2_import_legacy.py 的 EXPLICIT_SOURCES。 // 必须在这里扫,否则 SendMessageServiceImpl(@Service)不注册, // 4 处裸 @Autowired SendMessageService 的调用方启动即失败。 "com.zteits.wxcp", // 第 9 个 JVM:设备采集(老 zteits-glock-collection,德飞地锁 TCP 网关)。 // 真实实现(netty 5.0.0.Alpha2)本轮不随迁,parking-module-datacollect 里 // 只有一个空实现 NoopDfGroundLockMsgPushService,用来满足 order 的两处 // 裸 @Autowired DfGroundLockMsgPushService。包名沿用 com.zteits.glockcollect, // 这样 P5 搬入真实实现时无需再动这里。详见该类注释。 "com.zteits.glockcollect" }, // 封闭 jar 里那个自我冲突的 @Configuration,理由见类注释。 // 用 ASSIGNABLE_TYPE 而非 REGEX:编译期就能校验这个类还在,jar 换版本时会直接编译失败 // (老工程的 portal 启动类排除 WebMvcConfigurer 用的也是 ASSIGNABLE_TYPE,此处沿用同一写法)。 excludeFilters = @ComponentScan.Filter( type = FilterType.ASSIGNABLE_TYPE, classes = ChargeConfig.class)) @MapperScan(basePackages = { "com.zteits.park.mapper", "com.zteits.order.mapper", "com.zteits.pay.mapper", "com.zteits.bill.mapper", "com.zteits.charge.mapper", "com.zteits.business.mapper", "com.zteits.sys.mapper" }, nameGenerator = LegacyMapperBeanNameGenerator.class) public class ParkingServerApplication { public static void main(String[] args) { SpringApplication.run(ParkingServerApplication.class, args); } }