ParkingServerApplication.java 16.5 KB
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} 一起扫进来。
 *
 * <h2>scanBasePackages 为什么是这一长串</h2>
 *
 * 它是**老工程 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」。
 *
 * 逐项来源:
 * <ul>
 *   <li>{@code com.zteits.{park,order,pay,bill,charge,business,sys}} —— 6 个业务模块 + 系统域,
 *       7 个启动类的共同项</li>
 *   <li>{@code com.zteits.wxcp} —— 老 {@code zteits-wxcp-portal}(独立 JVM,端口 18031)
 *       的「企业微信服务子系统」按文件随迁进 {@code parking-module-system} 后留下的包,
 *       清单见 {@code tools/p2_import_legacy.py} 的 {@code EXPLICIT_SOURCES}</li>
 *   <li>{@code com.zteits.glockcollect} —— 老 {@code zteits-glock-collection}(独立 JVM,
 *       德飞地锁 TCP 网关)的包。该模块本轮**未随迁**(netty 5.0.0.Alpha2 与 Redisson 的
 *       netty 4.1.x 冲突),{@code parking-module-datacollect} 里只放了一个空实现,
 *       用来满足 order 两处裸 {@code @Autowired DfGroundLockMsgPushService};
 *       包名沿用老包名,P5 搬入真实实现时无需再动扫描配置</li>
 *   <li>{@code com.zteits.ipay}、{@code com.zteits.configuration} —— 只有
 *       {@code OrderApplication} 扫(前者的支付宝/微信/建行支付实现,后者的通用配置)</li>
 *   <li>{@code com.clouds.common.redis} —— 7 个启动类都扫({@code RedisCacheUtil} 是
 *       {@code AppTokenUtil.getBean("redisCacheUtil")} 按名取的,必须注册)</li>
 *   <li>{@code com.clouds.common.threadpool} —— 只有 park / order 扫</li>
 *   <li>{@code com.clouds.common.web} —— 只有 order / pay 扫({@code SpringContextUtil}
 *       要做 Bean 才能被 {@code AppTokenUtil} 取到 ApplicationContext)</li>
 *   <li>{@code com.clouds.common.oss} —— 只有 pay 扫({@code OssClientService} 三个包装类)</li>
 * </ul>
 *
 * <h2>三个「故意没扫」的包</h2>
 *
 * <ul>
 *   <li>{@code com.clouds.common.cache} / {@code aop} / {@code log} / {@code config}
 *       —— 老工程 7 个启动类本来就没扫。已逐个核对:{@code cache} 下全是静态工具类、
 *       {@code log.LogSelf} 是纯工具,都不是 Bean({@code ICache} 的实现散在
 *       {@code com.zteits.{park,sys,pay}.cache} 里,那些包**在**扫描列表内,所以
 *       {@code RefreshCache} 注入的 {@code Map<String,ICache>} 照样能装配上)。
 *       唯一带 {@code @Component} 的是 {@code aop.DaoLogAspect},老工程从未扫它,
 *       这里保持一致 —— 多扫会凭空多一层 DAO 日志 AOP。</li>
 *   <li>{@code com.zteits.clouds.api} —— 纯契约(接口 + DTO),无实现类,没有 Bean。</li>
 * </ul>
 *
 * <h2>扫描顺序:{@code com.clouds.common.*} 为什么必须写在最前面</h2>
 *
 * Spring 按 {@code basePackages} 的书写顺序扫描,扫描顺序 = BeanDefinition 注册顺序,
 * 也就是(无依赖关系时的)Bean 创建顺序。老框架里有一批 CacheUtil 把
 * {@code RedisCacheUtil} **缓存进了静态字段**:
 *
 * <pre>
 * private static RedisCacheUtil redisCacheUtil = RedisCacheFactory.getRedisCacheUtil();  // 41 个类这么写
 * </pre>
 *
 * 而 {@code RedisCacheFactory} 的静态字段是在它自己的
 * {@code setApplicationContext} 回调里才赋值的。静态字段只在**类加载时**求值一次,
 * 所以只要有一个 {@code com.zteits.*} 的 Bean 在它之前被创建、且创建期间就类加载了这些
 * CacheUtil 类,那个静态字段就被**永久钉死为 null**,之后每次调用都 NPE。
 * 老代码里确实有这种写法:{@code TdCAcctPersonServiceImpl} 的 {@code @PostConstruct}
 * 直接起后台线程,线程第一件事就是查字典(走 CacheUtil)。
 *
 * <p>把 {@code com.clouds.common.redis} 排到最前,{@code RedisCacheFactory} 就会在任何
 * {@code com.zteits.*} Bean 之前建好,上面那条竞态窗口随之关闭。**这是启动能否成功的必要条件,
 * 不要因为「看起来只是顺序」而把它调回去**。老工程 7 个 JVM 各自的扫描列表里
 * {@code com.clouds.common.redis} 也都排在业务包之前,合并后必须保持。</p>
 *
 * <p>⚠️⚠️ <b>2026-10-15 实测补正</b>:上句的「任何 {@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.*} 挪到**最前**,连这两个新工程包一起让位
 * ——这本来就是本节结论的字面要求。</p>
 *
 * <h2>@MapperScan 为什么是两条</h2>
 *
 * 两条各自管一段,互不重叠(靠 {@code annotationClass} 区分):
 *
 * <ol>
 *   <li>新代码:{@code com.zteits.parking} 下**带 {@code @Mapper} 注解**的 Mapper
 *       —— 其实这条已经由 {@code ParkingMybatisAutoConfiguration} 的
 *       {@code @AutoConfiguration + @MapperScan} 提供,这里不重复声明。</li>
 *   <li>存量:{@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 个包下。</li>
 * </ol>
 *
 * ⚠️ 两处红线:
 * <ul>
 *   <li>**绝不能**扫 {@code com.zteits.*.util}:里面有 tk.mybatis 的 {@code MyMapper<T>}
 *       标记接口,老代码自己的注释就写着「该接口不能被扫描到,否则会出错」。</li>
 *   <li>{@code nameGenerator = LegacyMapperBeanNameGenerator.class} **不能省**:
 *       存量有 9 对不同包同名的 Mapper(order↔pay 7 对、pay↔business 2 对),
 *       默认的简单类名命名会让 Spring 抛 {@code ConflictingBeanDefinitionException}。
 *       详见该类的注释。</li>
 * </ul>
 *
 * <h2>为什么要排除 {@code com.zteits.charge.config.ChargeConfig}</h2>
 *
 * 它是**本次搬迁里唯一一个来自封闭 Jar 的冲突**:依附在 {@code parking-legacy-common}
 * 上的 {@code zteits-parking-charge-spring-boot-starter}(源码不可改)自己把自己撞了:
 *
 * <pre>
 * &#64;Configuration
 * &#64;ComponentScan("com.zteits.charge")        // ← 与我们的扫描重复
 * public class ChargeConfig {
 *     &#64;Bean(name = "chargeService")          // ← 与下面那个 &#64;Component 同 bean 名
 *     public ChargeService chargeService() { return new ChargeService(); }
 * }
 * &#64;Component public class ChargeService {}   // 同一个 jar 里
 * </pre>
 *
 * 老工程能跑,只因 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}。两扇门都要关:
 * <ol>
 *   <li>{@code @EnableAutoConfiguration(exclude = ...)} —— 关掉 spring.factories 那扇门
 *       (也可以用配置 {@code spring.autoconfigure.exclude},但那样就成了「同一件事写在两个
 *       文件里」,且丢掉编译期校验,故不采用)</li>
 *   <li>{@code @ComponentScan(excludeFilters = ...)} —— 关掉包扫描那扇门</li>
 * </ol>
 *
 * 排除它**不损失任何能力**:{@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);
    }

}