#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ P2 上线前的「能不能启动」体检:Bean 名冲突扫描。 ## 为什么需要这个脚本 老工程是 **7 个独立 JVM**:park / order / pay / bill / charge / business / sys, 各起各的 Spring 容器。合成单 Jar 后它们跑在**同一个** ApplicationContext 里, 于是「另一个 JVM 里的同名 Bean」变成了自己的冲突。 Spring 的 bean 名规则: * 显式命名——`@Service("com.zteits.sys.biz.SysOrgServiceImpl")` → bean 名就是这串全限定名,**天然不会撞**(全仓 211 个文件这么写, 且存量代码有 `@Qualifier("全限定名")`、`SpringContextUtil.getBean("全限定名")` 这类按名取用,所以这正是本项目的既有命名习惯) * 默认命名——光写 `@Service` / `@Component` / `@Repository` → bean 名 = 首字母小写的**简单类名** 而 ClassPathBeanDefinitionScanner 遇到「同一个 bean 名、两个不同类」时抛 `ConflictingBeanDefinitionException`(Spring 5.3 的 checkCandidate 会直接抛, 不取决于 allowBeanDefinitionOverriding),启动直接失败。 ## 两类冲突,处置方式完全不同 1. **Mapper 接口**(`@MapperScan` 收进来的)——已用 `@MapperScan(nameGenerator = LegacyMapperBeanNameGenerator.class)` 统一改成 「全限定类名当 Bean 名」,**已消解**。本脚本仍把它们列出来,是为了留痕: 它们说明「这几个包里的同名表在六个库合并时也要一起看」(见方案 §4.4 冲突排查)。 2. **业务类**(`@Component`/`@Service`)——`--fix` 给「非首个」的补上显式 bean 名 (值 = 全限定类名,与存量 211 个文件的写法一致),不动任何业务代码。 注意:这些同名类**内容各不相同**(已逐个比对过,不是复制粘贴的重复类), 所以只能改名不能合并——为什么合并不掉,看它们的行数与差异就知道了。 ## 三个 bean 名来源,缺一不可(前两个是本脚本踩过的坑) 一次真实的启动失败(`chargeService`)证明,**只看源码里的构造型注解是不够的**: | # | 来源 | 为什么漏不得 | |---|---|---| | 1 | 源码的 `@Component`/`@Service`/… + Mapper 接口 | 最直观的一个 | | 2 | **源码的 `@Bean` 方法名** | `@Bean` 方法的 bean 名默认就是**方法名**,跟类名毫无关系 | | 3 | **依赖 Jar 里落在扫描包内的类** | 扫描是按包名走的,第三方/封闭 Jar 只要包名落在扫描范围内,**它的类照样会被扫进来** | 第 3 条尤其阴:本次唯一命中的 `zteits-parking-charge-spring-boot-starter`(封闭源码)里,**同一个 Jar 内部** `@Configuration ChargeConfig` 的 `@Bean(name="chargeService")` 与它自己 `@Component ChargeService` 撞了名——两个类**都不在新工程的源码里**, 任何只读源码的审计都不可能发现。老工程靠 Boot 1.5 默认 `spring.main.allow-bean-definition-overriding=true` 蒙混过去, Boot 2.1 起默认 false,合成单 Jar 后启动即抛 `BeanDefinitionOverrideException`。 ⚠️ 同名的 jar 内部配置还有第二个坑:`@Configuration` **本身就是 `@Component`**, 所以 `spring.autoconfigure.exclude` 只关掉了 spring.factories 那扇门, 包扫描那扇门还得靠 `@ComponentScan(excludeFilters=...)` 一起关(两处都得改, 见 `ParkingServerApplication` 的类注释)。 ## 判读 * 撞了但**至少一方是显式命名** → 显式名与简单类名不同则无害(归入「存疑」段) * 退出码:0 = 未发现会导致启动失败的冲突;1 = 有(可直接用于流水线卡口) 用法: python3 -I tools/p2_audit_beans.py # 只报告 python3 -I tools/p2_audit_beans.py --fix # 给冲突方补显式 bean 名 """ import argparse import io import os import re import sys import zipfile CLOUD = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # 参与合并的模块(老代码搬进来的那批 + 新工程自己的,一起查) SCAN_DIRS = [ "parking-module-api", "parking-framework/parking-legacy-common", "parking-framework/parking-common", "parking-module-system", "parking-module-park", "parking-module-order", "parking-module-pay", "parking-module-bill", "parking-module-charge", "parking-module-business", # 设备采集(德飞地锁):只有一个空实现 NoopDfGroundLockMsgPushService。 # 它的 bean 名也是全限定名,且**故意**与 P5 要搬进来的真实实现同名(那是个响亮的 # 提醒,见该类注释),所以这里也要扫 —— 否则这个刻意的撞名会被漏掉。 "parking-module-datacollect", ] SRC = "src/main/java" SKIP_DIR_NAMES = {"target", "out", "recover"} # 会注册成 Bean 的构造型注解 STEREOTYPES = ( "Service", "Component", "Repository", "Configuration", "RestController", "Controller", "ControllerAdvice", "RestControllerAdvice", ) # 启动类(用来判断 Mapper 的 Bean 名是否已被全限定名生成器改过) SERVER_APP = os.path.join( CLOUD, "parking-server/src/main/java/com/zteits/parking/server/ParkingServerApplication.java") MAPPER_NAME_GENERATOR = "LegacyMapperBeanNameGenerator" ANNO_RE = re.compile( # ⚠️ 注解名与参数之间**只能吃空格/制表符,不能吃换行**:\s 会把注解后的换行一起吞掉, # 于是 --fix 会把 ("fqn") 插到换行之后,写出 `@Service\n("fqn")public class X` # ——虽然合法,但没人这么写。第一版就栽在这里,实测已修正。 r"^([ \t]*)@(?:org\.springframework\.stereotype\.)?" # 1: 缩进(允许写成全限定) r"(" + "|".join(STEREOTYPES) + r")" # 2: 注解名 r"[ \t]*(?:\(\s*(?:value\s*=\s*)?\"([^\"]*)\"\s*\))?" # 3: 显式 bean 名 , re.M, ) # 顶层类型声明:本脚本用文件名当类名(Java 要求 public 类名==文件名), # 这里再抽一次是为了确认「这个注解确实是加在顶层类型上的」 TYPE_RE = re.compile(r"^\s*(?:public\s+|abstract\s+|final\s+)*(class|interface|enum)\s+(\w+)", re.M) def find_stereotype(src): """返回 (注解名, 显式名 or None, 注解起点, 注解终点),只认「紧邻顶层类型声明」的那一个。""" for m in ANNO_RE.finditer(src): anno, explicit = m.group(2), m.group(3) tail = src[m.end():] # 允许中间还有别的注解(@Transactional / @Lazy / @Order ...) tail = re.sub(r"^(\s*@\w+(\([^)]*\))?)+", "", tail.lstrip(), count=8) if TYPE_RE.match(tail) or TYPE_RE.match(tail.lstrip()): at = m.start() # 整段匹配起点(含缩进),仅用于展示 end = m.end(2) # ⚠️ 注解名**之后**的位置:--fix 就插在这里,保持 `@X("fqn")` return anno, explicit, at, end return None def decapitalize(name): return name[:1].lower() + name[1:] def mapper_names_are_fqns(): if not os.path.isfile(SERVER_APP): return False with open(SERVER_APP, encoding="utf-8", errors="surrogateescape") as fh: return MAPPER_NAME_GENERATOR in fh.read() def read_server_app(): if not os.path.isfile(SERVER_APP): return "" with open(SERVER_APP, encoding="utf-8", errors="surrogateescape") as fh: return fh.read() # 启动类里「两扇门」的排除写法:@EnableAutoConfiguration(exclude = X.class) # 与 @ComponentScan(excludeFilters = ... classes = X.class)。 # 两种写法的右值都是 `X.class`,一个正则一起吃。 EXCLUDED_CLASS_RE = re.compile(r"(?:exclude\s*=|classes\s*=\s*)\{?\s*([\w.]+)\.class") def server_excluded_simple_names(src): """启动类里被排除的类的**简单名**集合。 判据直接读启动类源码,而不是在脚本里另抄一份名单 —— 否则启动类改了排除项, 脚本还在拿旧名单说「已处置」,就成了两个文件各说各话。 """ return {m.rsplit(".", 1)[-1] for m in EXCLUDED_CLASS_RE.findall(src)} def owner_class_simples(group): """一组来源里出现的**简单类名**集合。 标识是「封闭Jar 类」的全限定名,或「@Bean 方法」的 `com.x.Y#beanMethod`(取 # 前)。 """ out = set() for _, ident, _ in group: cls = ident.split("#")[0] if "." in cls: out.add(cls.rsplit(".", 1)[-1]) return out def scan(): beans = [] # (beanName, kind, simpleName, fqn, file, explicit, anno_span) for mod in SCAN_DIRS: base = os.path.join(CLOUD, mod, SRC) if not os.path.isdir(base): continue for root, dirs, files in os.walk(base): dirs[:] = [d for d in dirs if d not in SKIP_DIR_NAMES] for f in files: if not f.endswith(".java"): continue p = os.path.join(root, f) with open(p, encoding="utf-8", errors="surrogateescape") as fh: src = fh.read() simple = f[:-5] pkg = re.search(r"^\s*package\s+([\w.]+)\s*;", src, re.M) pkg = pkg.group(1) if pkg else "" fqn = f"{pkg}.{simple}" hit = find_stereotype(src) if hit: anno, explicit, at, end = hit name = explicit if explicit else decapitalize(simple) beans.append((name, f"@{anno}", simple, fqn, p, bool(explicit), (at, end))) continue # Mapper 接口:不带 @Mapper 也算(老 DAO 靠 @MapperScan 收) if (simple.endswith("Mapper") and pkg.endswith(".mapper")) \ or ("dal" in p and simple.endswith("Mapper")): beans.append((decapitalize(simple), "@MapperScan", simple, fqn, p, False, None)) return beans # --------------------------------------------------------------------------- # 盲点 2:源码里的 @Bean 方法名 # --------------------------------------------------------------------------- # @Bean 方法的 bean 名默认 = **方法名**(与类名无关)。所以只统计类是不够的。 BEAN_METHOD_RE = re.compile( r"@Bean\b[ \t]*(?:\([ \t]*([^)]*?)[ \t]*\))?[ \t]*(?:\n[ \t]*)*" r"(?:public|protected|private)?[ \t]*(?:static[ \t]+)?(?:final[ \t]+)?" r"[\w<>\[\],.?\s]+?[ \t]+(\w+)[ \t]*\(", re.M, ) # 任何模块的 @Bean 都可能注册成 Bean(@Configuration 可能在别的模块里被扫到), # 所以这里比 SCAN_DIRS 多扫一层 parking-framework/*(各 starter 的自动配置类)。 def bean_scan_dirs(): dirs = list(SCAN_DIRS) fw = os.path.join(CLOUD, "parking-framework") if os.path.isdir(fw): for d in sorted(os.listdir(fw)): rel = os.path.join("parking-framework", d) if rel not in dirs and os.path.isdir(os.path.join(CLOUD, rel, SRC)): dirs.append(rel) return dirs def scan_bean_methods(): """源码里的 @Bean 方法 → (bean名, 方法名, 相对路径, 是否显式命名)。""" out = [] for mod in bean_scan_dirs(): base = os.path.join(CLOUD, mod, SRC) if not os.path.isdir(base): continue for root, dirs, files in os.walk(base): dirs[:] = [d for d in dirs if d not in SKIP_DIR_NAMES] for f in files: if not f.endswith(".java"): continue p = os.path.join(root, f) with open(p, encoding="utf-8", errors="surrogateescape") as fh: src = fh.read() for m in BEAN_METHOD_RE.finditer(src): args, method = m.group(1), m.group(2) explicit = None if args: # name={...} / name="..." / value=... / 直接写字符串 mm = (re.search(r'name[ \t]*=[ \t]*[\{"\']([^"\',}]+)', args) or re.search(r'value[ \t]*=[ \t]*[\{"\']([^"\',}]+)', args) or re.search(r'^[ \t]*["\']([^"\']+)', args)) if mm: explicit = mm.group(1).strip() out.append((explicit or method, method, os.path.relpath(p, CLOUD), bool(explicit))) return out # --------------------------------------------------------------------------- # 盲点 3:依赖 Jar 里落在扫描包内的类(需要一个最小 class 文件读取器) # --------------------------------------------------------------------------- STEREOTYPES_FQN = { "org.springframework.stereotype.Component", "org.springframework.stereotype.Service", "org.springframework.stereotype.Repository", "org.springframework.stereotype.Controller", "org.springframework.web.bind.annotation.RestController", "org.springframework.web.bind.annotation.ControllerAdvice", "org.springframework.web.bind.annotation.RestControllerAdvice", "org.springframework.context.annotation.Configuration", } BEAN_ANNO_FQN = "org.springframework.context.annotation.Bean" def _u2(b, i): return (b[i] << 8) | b[i + 1], i + 2 def _u4(b, i): return int.from_bytes(b[i:i + 4], "big"), i + 4 class ClassFile: """最小的 Java class 读取器:只为拿到「类注解、方法名、方法上的注解」。 故意不依赖 javap:审计脚本要能在任何机器上跑,多一个外部命令就多一个失败点。 只实现到「够用」为止——不解码方法体、不看字段,遇到没见过的常量池 tag 就报错, 这样万一将来撞上 Java 9+ 新增的 tag,是**报错**而不是**静默漏检**。 """ _CONSTANT_SIZES = {3: 4, 4: 4, 9: 4, 10: 4, 11: 4, 12: 4, 17: 4, 18: 4, 7: 2, 8: 2, 16: 2, 19: 2, 20: 2, 15: 3} def __init__(self, data): self.pool = {} self.class_annos = [] # [(注解全限定名, {元素名: 值})] self.methods = [] # [(方法名, [(注解全限定名, {元素名: 值})])] self._read(data) # -- 解析 -------------------------------------------------------------- def _read(self, d): if d[:4] != b"\xca\xfe\xba\xbe": raise ValueError("不是 class 文件") i = 8 count, i = _u2(d, i) idx = 1 while idx < count: tag = d[i] i += 1 if tag == 1: n, i = _u2(d, i) self.pool[idx] = d[i:i + n].decode("utf-8", "replace") i += n elif tag in (5, 6): # Long / Double 占两个槽位 i += 8 idx += 1 elif tag in self._CONSTANT_SIZES: i += self._CONSTANT_SIZES[tag] else: raise ValueError(f"未知常量池 tag {tag}") idx += 1 i += 6 # access_flags / this / super n, i = _u2(d, i) i += 2 * n # interfaces _, i = self._members(d, i) # fields(用不上,跳过) members, i = self._members(d, i) # methods self.methods = [(self.pool.get(ni, "?"), annos) for ni, annos in members] self.class_annos = self._class_annos(d, i) def _class_annos(self, d, i): """类级属性里的 RuntimeVisibleAnnotations —— 类头上的 @Component / @Configuration。""" n, i = _u2(d, i) annos = [] for _ in range(n): an_i, i = _u2(d, i) ln, i = _u4(d, i) if self.pool.get(an_i, "") == "RuntimeVisibleAnnotations": annos = self._annos(d[i:i + ln]) i += ln return annos def _members(self, d, i): n, i = _u2(d, i) out = [] for _ in range(n): _acc, i = _u2(d, i) name_i, i = _u2(d, i) _desc, i = _u2(d, i) ac, i = _u2(d, i) attrs = {} for _ in range(ac): an_i, i = _u2(d, i) ln, i = _u4(d, i) attrs[self.pool.get(an_i, "")] = d[i:i + ln] i += ln blob = attrs.get("RuntimeVisibleAnnotations") out.append((name_i, self._annos(blob) if blob else [])) return out, i def _annos(self, blob): i = 0 n, i = _u2(blob, i) out = [] for _ in range(n): t_i, i = _u2(blob, i) pairs, i = self._pairs(blob, i) out.append((self._type(t_i), pairs)) return out def _pairs(self, blob, i): n, i = _u2(blob, i) out = {} for _ in range(n): name_i, i = _u2(blob, i) val, i = self._value(blob, i) out[self.pool.get(name_i, "")] = val return out, i def _value(self, blob, i): """element_value → (值, 新位置)。 只解出「字符串 / 类名 / 数组 / 嵌套注解」,其余(数字、枚举)返回 None 但**照样把位置推对**——审计只需要 bean 名,不需要把注解解全。 """ tag = chr(blob[i]) i += 1 if tag == "e": # 枚举 _t, i = _u2(blob, i) _c, i = _u2(blob, i) return None, i if tag == "@": # 嵌套注解 t_i, i = _u2(blob, i) pairs, i = self._pairs(blob, i) return (self._type(t_i), pairs), i if tag == "[": # 数组 n, i = _u2(blob, i) items = [] for _ in range(n): v, i = self._value(blob, i) items.append(v) return items, i if tag in "BCDFIJSZsc": # 基本类型常量 / 字符串(s)/ 类字面量(c) c_i, i = _u2(blob, i) return (self.pool.get(c_i) if tag in ("s", "c") else None), i raise ValueError(f"未知 element_value tag {tag}") def _type(self, t_i): raw = self.pool.get(t_i, "") if raw.startswith("L") and raw.endswith(";"): return raw[1:-1].replace("/", ".") return raw def fat_jar_path(): tgt = os.path.join(CLOUD, "parking-server", "target") if not os.path.isdir(tgt): return None cands = [f for f in os.listdir(tgt) if f.startswith("parking-server-") and f.endswith(".jar")] return os.path.join(tgt, sorted(cands)[-1]) if cands else None def scan_foreign_jars(): """扫 fat jar 的 BOOT-INF/lib,找出『自带 com.zteits / com.clouds 类』的**非本工程** Jar。 「非本工程」的判定:文件名以 parking- 开头就是本反应的模块 (parking-common / parking-framework / parking-module-* / parking-legacy-common / parking-spring-boot-*)。其余的一律当外部件审。 返回 (构造型 Bean 列表, @Bean 方法列表, 扫过的 Jar 清单); fat jar 不存在时返回 None(提示先打包)。 """ fat = fat_jar_path() if not fat: return None beans, bean_methods, jars = [], [], [] with zipfile.ZipFile(fat) as outer: for entry in outer.namelist(): if not (entry.startswith("BOOT-INF/lib/") and entry.endswith(".jar")): continue name = entry.rsplit("/", 1)[-1] if name.startswith("parking-"): continue with zipfile.ZipFile(io.BytesIO(outer.read(entry))) as inner: classes = [c for c in inner.namelist() if c.endswith(".class") and (c.startswith("com/zteits/") or c.startswith("com/clouds/"))] if not classes: continue jars.append((name, len(classes))) for c in classes: try: cf = ClassFile(inner.read(c)) except Exception as e: # noqa: BLE001 — 读不动就报出来,别静默漏 print(f" !! 解析失败 {c}({name}):{e}") continue fqn = c[:-6].replace("/", ".") simple = fqn.rsplit(".", 1)[-1] kinds = sorted({a.rsplit(".", 1)[-1] for a, _ in cf.class_annos if a in STEREOTYPES_FQN}) if kinds: beans.append((decapitalize(simple), simple, fqn, name, kinds)) for mname, mannos in cf.methods: for a, elems in mannos: if a != BEAN_ANNO_FQN: continue val = elems.get("name") or elems.get("value") if isinstance(val, list): val = val[0] if val else None bean_methods.append((val or mname, mname, fqn, name)) return beans, bean_methods, jars # --------------------------------------------------------------------------- # 盲点 2 + 3 的汇总比对 # --------------------------------------------------------------------------- def report_extra_conflicts(beans): """把「源码 @Bean 方法名」「封闭 Jar 的类」一起并进 bean 名全集,再查一遍同名。 做法:给每个 bean 名登记**所有**它的来源(标识 = 全限定名,@Bean 则加上 #方法名), 只要有 ≥2 个不同标识用了同一个 bean 名,就是一次启动期 BeanDefinitionOverrideException / ConflictingBeanDefinitionException。 **已在启动类里显式处置的两类不计入**(判据现读 `ParkingServerApplication` 源码): Mapper 之间的同名(`@MapperScan` 的 `nameGenerator` 已改成全限定名)、 撞名方落在排除名单里的(两扇门都关掉的那个封闭 Jar 配置)。 它们仍会打印出来留痕,但不再影响退出码。 返回 True 表示发现了真冲突。 """ print("\n== 盲点复查:@Bean 方法名 + 封闭 Jar 里的类 ==") owners = {} # bean名 -> [(来源标签, 标识, 位置)] for b in beans: # 标签带上 b[1](@Service / @MapperScan / …):下面判「这批同名是不是 # 纯 Mapper 之间的事」要靠它,光写「源码构造型」就分不出来了。 owners.setdefault(b[0], []).append(("源码" + b[1], b[3], f"{b[1]} {b[3]}")) for name, method, path, explicit in scan_bean_methods(): owners.setdefault(name, []).append( ("源码 @Bean", f"{path}#{method}", f"@Bean {method}() {path}")) foreign = scan_foreign_jars() if foreign is None: print(" (没找到 parking-server 的 fat jar,跳过「封闭 Jar」这一半;" "先 mvn -pl parking-server -am -DskipTests package 再跑本脚本)") else: jars, jar_beans, jar_methods = foreign[2], foreign[0], foreign[1] if not jars: print(" · 没有任何依赖 Jar 携带 com.zteits / com.clouds 的类 —— 这一类冲突为空") for name, cnt in jars: print(f" · {name}:{cnt} 个 com.zteits/com.clouds 类") for name, simple, fqn, jarname, kinds in jar_beans: owners.setdefault(name, []).append( ("封闭Jar " + "/".join(kinds), fqn, f"{fqn} ({jarname})")) for name, method, fqn, jarname in jar_methods: owners.setdefault(name, []).append( ("封闭Jar @Bean", f"{fqn}#{method}", f"@Bean {method}() {fqn} ({jarname})")) raw = {n: g for n, g in owners.items() if len({ident for _, ident, _ in g}) > 1} if not raw: print(" ✔ 这三类来源之间没有同名") return False # ---- 把「已在启动类里显式处置」的两类剔出去,否则退出码永远是 1(假阳性)---- # 判据全部现读 ParkingServerApplication 的源码,而不是在脚本里另记一份名单: # 否则启动类哪天改了排除项,脚本还会拿着旧名单说「已处置」。 # (a) 纯 Mapper 之间的同名 —— @MapperScan(nameGenerator = LegacyMapperBeanNameGenerator) # 已把 bean 名改成全限定名,不再是简单类名; # (b) 撞名方落在排除名单里 —— 封闭 Jar 里的类改不了,只能从 # @EnableAutoConfiguration(exclude=…) 与 @ComponentScan(excludeFilters=…) 两扇门排掉。 app_src = read_server_app() excluded = server_excluded_simple_names(app_src) mapper_handled = MAPPER_NAME_GENERATOR in app_src conflicts, resolved = {}, {} for n, g in raw.items(): if mapper_handled and all(lbl == "源码@MapperScan" for lbl, _, _ in g): why = ("Mapper 同名,已被 @MapperScan(nameGenerator = " "LegacyMapperBeanNameGenerator) 消解") elif excluded and (owner_class_simples(g) & excluded): hit = "/".join(sorted(owner_class_simples(g) & excluded)) why = (f"撞名方在启动类的排除名单里({hit})—— 两扇门都已关:" "@EnableAutoConfiguration(exclude=…) 与 @ComponentScan(excludeFilters=…)") else: conflicts[n] = g continue resolved[n] = (g, why) if resolved: print(f"\n· 已处置、不计入真冲突 {len(resolved)} 组:") for name, (g, why) in sorted(resolved.items()): print(f" [{name}] {why}") for label, ident, where in g: print(f" {label:<22} {where}") if not conflicts: print(" ✔ 除上面已处置的之外,这三类来源之间没有同名") return False print(f"\n!! 真冲突 {len(conflicts)} 组(启动会抛 " f"BeanDefinitionOverrideException / ConflictingBeanDefinitionException):") for name, g in sorted(conflicts.items()): print(f" [{name}]") for label, ident, where in g: print(f" {label:<22} {where}") print(" 处置:给其中一方补显式 bean 名(值 = 全限定类名,存量既有习惯)。" "\n 若撞名方来自封闭 Jar,改不了它 —— 就把它从" "\n @ComponentScan(excludeFilters=...) 与" " @EnableAutoConfiguration(exclude=...) 两处一起排掉") return True def main(): ap = argparse.ArgumentParser() ap.add_argument("--fix", action="store_true", help="给「默认命名且撞名」的非首个类补显式 bean 名(值=全限定类名)") args = ap.parse_args() beans = scan() print(f"扫到候选 Bean {len(beans)} 个" f"(显式命名 {sum(1 for b in beans if b[5])}," f"默认命名 {sum(1 for b in beans if not b[5])})\n") groups = {} for b in beans: groups.setdefault(b[0], []).append(b) conflicts = {n: g for n, g in groups.items() if len(g) > 1} if not conflicts: print("✔ 源码内无 bean 名冲突(@Bean 方法与封闭 Jar 另查,见下)") real, benign, mapper_groups = {}, {}, {} for name, g in sorted(conflicts.items()): if len({x[3] for x in g}) == 1: continue # 同一个类被扫到两次,无害 is_mapper = all(x[1] == "@MapperScan" for x in g) if is_mapper: mapper_groups[name] = g elif any(x[5] for x in g): benign[name] = g else: real[name] = g if mapper_groups: fqn_ok = mapper_names_are_fqns() head = ("✔ Mapper 同名(已被 LegacyMapperBeanNameGenerator 消解,仅留痕)" if fqn_ok else "!! Mapper 同名(**未被消解**:启动类上没找到 LegacyMapperBeanNameGenerator)") print(f"{head} {len(mapper_groups)} 组:") for name, g in sorted(mapper_groups.items()): print(f" [{name}]") for b in g: print(f" {b[3]}") print(" ↑ 这些同名意味着「六库合并时这几张表要一起看」(方案 §4.4)\n") if benign: print(f"· 同名但含显式命名(显式名恰好等于别的类的简单名,通常无害){len(benign)} 组:") for name, g in sorted(benign.items()): print(f" {name}") for b in g: print(f" {'显式' if b[5] else '默认'} {b[3]}") print() fixed = 0 if not real: print("✔ 无「两个默认命名类撞名」的真冲突") else: print(f"!! 业务类真冲突 {len(real)} 组(两个及以上默认命名类同名,启动会抛 " f"ConflictingBeanDefinitionException):") for name, g in sorted(real.items()): print(f" [{name}]") for b in g: print(f" {b[1]:<12} {b[3]}") if args.fix: for b in g[1:]: # 保首个,其余补显式名 p = b[4] if not b[6]: print(f" !! {os.path.relpath(p, CLOUD)} 未定位到注解,跳过") continue with open(p, encoding="utf-8", errors="surrogateescape") as fh: src = fh.read() at, end = b[6] src = src[:end] + f'("{b[3]}")' + src[end:] with open(p, "w", encoding="utf-8", errors="surrogateescape") as fh: fh.write(src) fixed += 1 print(f" -> 已补显式 bean 名:{os.path.relpath(p, CLOUD)}") if args.fix: print(f"\n共补 {fixed} 处;请重新编译。") else: print("\n(未加 --fix,未改动任何文件)") extra_bad = report_extra_conflicts(beans) bad = bool(real) or extra_bad print() print("!! 存在会让启动直接失败的 Bean 名冲突,先处理再打包。" if bad else "✔ 三个来源合起来看,未发现会让启动失败的 Bean 名冲突。") return 1 if bad else 0 if __name__ == "__main__": sys.exit(main())