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