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:
+1
-1
@@ -11,7 +11,7 @@ import org.springframework.stereotype.Service;
|
||||
/**
|
||||
* 分组删除守卫:去重总数据(2026-09 从 shopkey 收回本模块,文案保持不变)。
|
||||
*/
|
||||
@Service
|
||||
@Service("dedupeGroupDeletionGuard")
|
||||
@RequiredArgsConstructor
|
||||
public class GroupDeletionGuard implements GroupDeletionGuardPort {
|
||||
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ import org.springframework.stereotype.Service;
|
||||
/**
|
||||
* 分组删除守卫:品牌数据库(不符合 ASIN)数据(2026-09 从 shopkey 收回本模块,文案保持不变)。
|
||||
*/
|
||||
@Service
|
||||
@Service("invalidAsinGroupDeletionGuard")
|
||||
@RequiredArgsConstructor
|
||||
public class GroupDeletionGuard implements GroupDeletionGuardPort {
|
||||
|
||||
|
||||
+89
@@ -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/", "");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user