fix(startup): 两个 GroupDeletionGuard 显式命名 + Bean 名唯一性守卫测试

事故:2026-09-14 边界收敛新增 invalidasin/dedupe 两个同名 GroupDeletionGuard,
Spring 默认用简单类名做 Bean 名 → 启动抛 ConflictingBeanDefinitionException,
主机 A 的 java-server 连续重启失败(health 不通),**单测全绿也发现不了**
(不起完整 Spring 上下文),部署后才知道。

- 两个守卫分别显式命名 @Service("invalidAsinGroupDeletionGuard") / ("dedupeGroupDeletionGuard")
- 新增 SpringComponentBeanNameUniquenessTest:扫描 main 源码,同简单名的组件必须显式命名,
  否则红测试(把这次事故固化成可回归的守卫)
- 已重新打包部署:JAR 1f1e82bda227f2b0768574f8fd7c32c9,双节点 health=200
This commit is contained in:
2026-09-14 10:37:44 +08:00
parent c55c4a140b
commit bcf66dc1d7
3 changed files with 91 additions and 2 deletions
@@ -11,7 +11,7 @@ import org.springframework.stereotype.Service;
/**
* 分组删除守卫:去重总数据(2026-09 从 shopkey 收回本模块,文案保持不变)。
*/
@Service
@Service("dedupeGroupDeletionGuard")
@RequiredArgsConstructor
public class GroupDeletionGuard implements GroupDeletionGuardPort {
@@ -11,7 +11,7 @@ import org.springframework.stereotype.Service;
/**
* 分组删除守卫:品牌数据库(不符合 ASIN)数据(2026-09 从 shopkey 收回本模块,文案保持不变)。
*/
@Service
@Service("invalidAsinGroupDeletionGuard")
@RequiredArgsConstructor
public class GroupDeletionGuard implements GroupDeletionGuardPort {
@@ -0,0 +1,89 @@
package com.nanri.aiimage;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Spring 组件默认 Bean 名(简单类名)唯一性守卫。
*
* <p>2026-09-14 事故:把共享类下沉到 common 时新增了两个同名 {@code GroupDeletionGuard}
* invalidasin / dedupe 各一),Spring 默认用简单类名做 Bean 名 → 启动直接抛
* ConflictingBeanDefinitionException**而单测不起完整上下文,全绿也发现不了**,
* 直到部署后线上 health 不通才暴露(主机 A 连续重启失败)。
*
* <p>本测试扫描 main 源码:若两个 Spring 组件简单名相同,则**两者都必须**显式命名
* {@code @Service("xxx")} / {@code @Component("xxx")} 等)才允许;否则红。
*/
class SpringComponentBeanNameUniquenessTest {
private static final Pattern COMPONENT_ANNOTATION = Pattern.compile(
"^@(Service|Component|Repository|Controller|RestController|Configuration)\\b(.*)$",
Pattern.MULTILINE);
private static final Pattern EXPLICIT_NAME = Pattern.compile("^\\s*\\(\\s*\"[^\"]+\"");
private static final Pattern CLASS_DECLARATION = Pattern.compile("^public\\s+(?:final\\s+)?class\\s+(\\w+)",
Pattern.MULTILINE);
private record Component(String simpleName, String path, boolean explicitBeanName) {
}
@Test
void duplicateComponentSimpleNamesMustBeExplicitlyNamed() throws IOException {
Map<String, List<Component>> bySimpleName = new LinkedHashMap<>();
for (Path file : mainSourceFiles()) {
String source = Files.readString(file, StandardCharsets.UTF_8);
Matcher annotation = COMPONENT_ANNOTATION.matcher(source);
if (!annotation.find()) {
continue;
}
Matcher classDeclaration = CLASS_DECLARATION.matcher(source);
if (!classDeclaration.find()) {
continue;
}
boolean explicit = EXPLICIT_NAME.matcher(annotation.group(2)).find();
Component component = new Component(classDeclaration.group(1), shortPath(file), explicit);
bySimpleName.computeIfAbsent(component.simpleName(), ignored -> new ArrayList<>()).add(component);
}
List<String> violations = new ArrayList<>();
for (Map.Entry<String, List<Component>> entry : bySimpleName.entrySet()) {
if (entry.getValue().size() < 2) {
continue;
}
List<Component> unnamed = entry.getValue().stream().filter(c -> !c.explicitBeanName()).toList();
if (!unnamed.isEmpty()) {
violations.add("简单类名 " + entry.getKey() + " 重复且未显式命名:"
+ unnamed.stream().map(Component::path).toList()
+ "(另一个:" + entry.getValue().stream().map(Component::path).toList() + "");
}
}
assertTrue(violations.isEmpty(),
"Spring 组件默认 Bean 名冲突会导致启动失败(单测发现不了),"
+ "请给其中一个加 @Service(\"显式名\")\n" + String.join("\n", violations));
}
private static List<Path> mainSourceFiles() throws IOException {
Path root = Paths.get("src", "main", "java");
try (Stream<Path> stream = Files.walk(root)) {
return stream.filter(path -> path.toString().endsWith(".java")).toList();
}
}
private static String shortPath(Path path) {
return path.toString().replace('\\', '/').replace("src/main/java/com/nanri/aiimage/", "");
}
}