源码update
All checks were successful
CodeChecker 变更检测 / code-check (push) Successful in 20s

This commit is contained in:
2026-06-09 15:43:04 +08:00
parent c8840e2af0
commit f94c24a0ab
10 changed files with 540 additions and 24 deletions

View File

@@ -3,20 +3,28 @@ package com.codechecker;
import com.codechecker.analyzer.ClassChangeAnalyzer;
import com.codechecker.analyzer.EndpointIndexBuilder;
import com.codechecker.api.analyzer.ApiChangeAnalyzer;
import com.codechecker.api.analyzer.DtoImpactedApiAnalyzer;
import com.codechecker.api.model.ApiChangeKind;
import com.codechecker.api.model.EndpointChangeReport;
import com.codechecker.api.notify.ApiChangeNotifier;
import com.codechecker.api.scanner.ApiFileChangeScanner;
import com.codechecker.config.AppConfig;
import com.codechecker.git.GitChangeScanner;
import com.codechecker.model.ApiEndpoint;
import com.codechecker.model.ClassChangeReport;
import com.codechecker.notify.OverlapNotificationFilter;
import com.codechecker.notify.WeComNotifier;
import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.Callable;
/**
@@ -49,7 +57,7 @@ public class CodeCheckMain implements Callable<Integer> {
System.exit(exitCode);
}
/** 主流程:类变更与 API 变更独立检测、分条通知 */
/** 主流程:类变更与 API 变更检测,支持 Dto 跟进与重叠通知策略 */
@Override
public Integer call() throws Exception {
AppConfig appConfig = AppConfig.load(config.toAbsolutePath());
@@ -59,27 +67,34 @@ public class CodeCheckMain implements Callable<Integer> {
}
GitChangeScanner gitScanner = new GitChangeScanner(repoRoot.toAbsolutePath());
int totalSent = 0;
List<ClassChangeReport> classReports = List.of();
List<EndpointChangeReport> apiReports = List.of();
if (appConfig.isClassCheckEnabled()) {
totalSent += executeClassCheck(appConfig, gitScanner);
classReports = analyzeClassChanges(appConfig, gitScanner);
} else {
System.out.println("类变更检测已关闭class_check.enabled=false");
}
if (appConfig.isApiCheckEnabled()) {
totalSent += executeApiCheck(appConfig, gitScanner);
apiReports = analyzeApiChanges(appConfig, gitScanner, classReports);
} else {
System.out.println("API 变更检测已关闭api_check.enabled=false");
}
OverlapNotificationFilter.FilterResult filtered = OverlapNotificationFilter.apply(
classReports, apiReports, appConfig.getDtoOverlapMode());
int totalSent = sendClassNotifications(appConfig, filtered.classReports())
+ sendApiNotifications(appConfig, filtered.apiReports());
if (totalSent == 0 && appConfig.isOnlyOnChange()) {
System.out.println("无变更,静默退出");
}
return 0;
}
private int executeClassCheck(AppConfig appConfig, GitChangeScanner gitScanner) throws Exception {
private List<ClassChangeReport> analyzeClassChanges(AppConfig appConfig, GitChangeScanner gitScanner)
throws Exception {
System.out.println("=== 类变更检测 ===");
EndpointIndexBuilder indexBuilder = new EndpointIndexBuilder();
Map<String, ApiEndpoint> endpointIndex = indexBuilder.buildIndex(repoRoot.toAbsolutePath(), appConfig);
@@ -89,6 +104,55 @@ public class CodeCheckMain implements Callable<Integer> {
List<ClassChangeReport> reports = analyzer.analyze(
repoRoot.toAbsolutePath(), appConfig, oldSha, newSha, endpointIndex);
System.out.println("检测到需通知的类变更数量: " + reports.size());
return reports;
}
private List<EndpointChangeReport> analyzeApiChanges(AppConfig appConfig, GitChangeScanner gitScanner,
List<ClassChangeReport> classReports) throws Exception {
System.out.println("=== API 变更检测 ===");
ApiFileChangeScanner fileScanner = new ApiFileChangeScanner(gitScanner);
Set<String> changedApiFiles = new LinkedHashSet<>(fileScanner.scanChangedFiles(
repoRoot.toAbsolutePath(), appConfig.getAllApiScanDirs(), oldSha, newSha));
ApiChangeAnalyzer analyzer = new ApiChangeAnalyzer(gitScanner);
List<EndpointChangeReport> reports = new ArrayList<>();
if (!changedApiFiles.isEmpty()) {
reports.addAll(analyzer.analyze(repoRoot.toAbsolutePath(), appConfig, oldSha, newSha));
}
if (appConfig.isDtoApiFollowUpEnabled() && !classReports.isEmpty()) {
DtoImpactedApiAnalyzer dtoAnalyzer = new DtoImpactedApiAnalyzer(gitScanner);
List<EndpointChangeReport> followUpReports = dtoAnalyzer.analyze(
repoRoot.toAbsolutePath(), appConfig, oldSha, newSha, classReports, changedApiFiles);
if (!followUpReports.isEmpty()) {
System.out.println("Dto 跟进检测到 API 参数变更数量: " + followUpReports.size());
reports.addAll(followUpReports);
}
}
reports = dedupeApiReports(reports);
System.out.println("检测到需通知的 API 变更数量: " + reports.size());
return reports;
}
private List<EndpointChangeReport> dedupeApiReports(List<EndpointChangeReport> reports) {
Map<String, EndpointChangeReport> merged = new LinkedHashMap<>();
for (EndpointChangeReport report : reports) {
String key = report.getChangeKind() + "|" + report.getHttpMethod() + "|" + report.getUri();
EndpointChangeReport existing = merged.get(key);
if (existing == null) {
merged.put(key, report);
continue;
}
if (report.getChangeKind() == ApiChangeKind.PARAM_CHANGED
&& existing.getChangeKind() == ApiChangeKind.PARAM_CHANGED) {
report.getParameterChanges().forEach(existing::addParameterChange);
}
}
return new ArrayList<>(merged.values());
}
private int sendClassNotifications(AppConfig appConfig, List<ClassChangeReport> reports) {
if (reports.isEmpty()) {
return 0;
}
@@ -100,12 +164,7 @@ public class CodeCheckMain implements Callable<Integer> {
return reports.size();
}
private int executeApiCheck(AppConfig appConfig, GitChangeScanner gitScanner) throws Exception {
System.out.println("=== API 变更检测 ===");
ApiChangeAnalyzer analyzer = new ApiChangeAnalyzer(gitScanner);
List<EndpointChangeReport> reports = analyzer.analyze(
repoRoot.toAbsolutePath(), appConfig, oldSha, newSha);
System.out.println("检测到需通知的 API 变更数量: " + reports.size());
private int sendApiNotifications(AppConfig appConfig, List<EndpointChangeReport> reports) {
if (reports.isEmpty()) {
return 0;
}

View File

@@ -34,7 +34,7 @@ public class ApiChangeAnalyzer {
EndpointSnapshotParser parser = new EndpointSnapshotParser(config.isApiExcludeFrameworkParams());
ParameterDiffEngine parameterDiffEngine = new ParameterDiffEngine(
repoRoot, buildSearchDirs(config));
repoRoot, buildSearchDirs(config), gitScanner, oldSha, newSha);
EndpointDiffEngine endpointDiffEngine = new EndpointDiffEngine(parameterDiffEngine);
List<EndpointSnapshot> oldSnapshots = new ArrayList<>();

View File

@@ -0,0 +1,133 @@
package com.codechecker.api.analyzer;
import com.codechecker.api.model.ApiChangeKind;
import com.codechecker.api.model.EndpointChangeReport;
import com.codechecker.api.model.EndpointSnapshot;
import com.codechecker.api.model.ParameterChange;
import com.codechecker.api.parser.EndpointSnapshotParser;
import com.codechecker.config.AppConfig;
import com.codechecker.git.GitChangeScanner;
import com.codechecker.model.ApiEndpoint;
import com.codechecker.model.ClassChangeReport;
import com.codechecker.model.ClassType;
import java.io.IOException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 类变更Dto 字段)后,对受影响的 Controller 继续 API 参数 diff产出 PARAM_CHANGED 报告。
*/
public class DtoImpactedApiAnalyzer {
private final GitChangeScanner gitScanner;
public DtoImpactedApiAnalyzer(GitChangeScanner gitScanner) {
this.gitScanner = gitScanner;
}
public List<EndpointChangeReport> analyze(Path repoRoot, AppConfig config,
String oldSha, String newSha,
List<ClassChangeReport> classReports,
Set<String> alreadyScannedFiles) throws IOException {
Map<String, Set<String>> controllerToDtos = collectImpactedControllers(classReports, alreadyScannedFiles);
if (controllerToDtos.isEmpty()) {
return List.of();
}
EndpointSnapshotParser parser = new EndpointSnapshotParser(config.isApiExcludeFrameworkParams());
ParameterDiffEngine parameterDiffEngine = new ParameterDiffEngine(
repoRoot, buildSearchDirs(config), gitScanner, oldSha, newSha);
EndpointDiffEngine endpointDiffEngine = new EndpointDiffEngine(parameterDiffEngine);
List<EndpointSnapshot> oldSnapshots = new ArrayList<>();
List<EndpointSnapshot> newSnapshots = new ArrayList<>();
for (String path : controllerToDtos.keySet()) {
boolean feign = isFeignPath(path, config);
String oldSource = gitScanner.readFileAtCommit(oldSha, path);
String newSource = gitScanner.readFileAtCommit(newSha, path);
oldSnapshots.addAll(parser.parseSource(oldSource, path, feign));
newSnapshots.addAll(parser.parseSource(newSource, path, feign));
}
List<EndpointChangeReport> reports = new ArrayList<>();
for (EndpointChangeReport report : endpointDiffEngine.diff(oldSnapshots, newSnapshots)) {
if (report.getChangeKind() != ApiChangeKind.PARAM_CHANGED || !report.hasParameterChanges()) {
continue;
}
String relatedDto = findRelatedDto(report, controllerToDtos);
if (relatedDto == null) {
continue;
}
reports.add(EndpointChangeReport.dtoFollowUp(report, relatedDto));
}
return reports;
}
private Map<String, Set<String>> collectImpactedControllers(List<ClassChangeReport> classReports,
Set<String> alreadyScannedFiles) {
Map<String, Set<String>> controllerToDtos = new LinkedHashMap<>();
for (ClassChangeReport report : classReports) {
if (report.getClassType() != ClassType.DTO || report.getFieldChanges().isEmpty()) {
continue;
}
Set<String> dtoNames = dtoNames(report);
for (ApiEndpoint endpoint : report.getInputImpactEndpoints()) {
String controllerFile = endpoint.getSourceFile();
if (alreadyScannedFiles.contains(controllerFile)) {
continue;
}
controllerToDtos.computeIfAbsent(controllerFile, k -> new LinkedHashSet<>()).addAll(dtoNames);
}
}
return controllerToDtos;
}
private String findRelatedDto(EndpointChangeReport report, Map<String, Set<String>> controllerToDtos) {
Set<String> impactedDtos = controllerToDtos.getOrDefault(report.getSourceFile(), Set.of());
for (ParameterChange change : report.getParameterChanges()) {
if (!"body".equals(change.getSource())) {
continue;
}
String parentDto = change.getParentDto();
if (parentDto != null && impactedDtos.contains(parentDto)) {
return parentDto;
}
}
return null;
}
private Set<String> dtoNames(ClassChangeReport report) {
Set<String> names = new LinkedHashSet<>();
names.add(report.getClassName());
if (report.getOldClassName() != null && !report.getOldClassName().isBlank()) {
names.add(report.getOldClassName());
}
return names;
}
private List<String> buildSearchDirs(AppConfig config) {
List<String> dirs = new ArrayList<>();
dirs.addAll(config.getModelDirs());
dirs.addAll(config.getAllApiScanDirs());
return dirs;
}
private boolean isFeignPath(String path, AppConfig config) {
String normalized = path.replace('\\', '/');
for (String dir : config.getApiFeignScanDirs()) {
String prefix = dir.replace('\\', '/');
if (!prefix.endsWith("/")) {
prefix = prefix + "/";
}
if (normalized.startsWith(prefix)) {
return true;
}
}
return false;
}
}

View File

@@ -6,6 +6,7 @@ import com.codechecker.api.model.MethodParameterSnapshot;
import com.codechecker.api.model.ParameterChange;
import com.codechecker.api.parser.NestedDtoFieldParser;
import com.codechecker.api.parser.NestedFieldInfo;
import com.codechecker.git.GitChangeScanner;
import com.codechecker.model.FieldChange;
import com.codechecker.model.FieldInfo;
@@ -31,8 +32,9 @@ public class ParameterDiffEngine {
private final NestedDtoFieldParser nestedDtoFieldParser;
private final FieldDiffEngine fieldDiffEngine = new FieldDiffEngine();
public ParameterDiffEngine(Path repoRoot, List<String> searchDirs) {
this.nestedDtoFieldParser = new NestedDtoFieldParser(repoRoot, searchDirs);
public ParameterDiffEngine(Path repoRoot, List<String> searchDirs,
GitChangeScanner gitScanner, String oldSha, String newSha) {
this.nestedDtoFieldParser = new NestedDtoFieldParser(repoRoot, searchDirs, gitScanner, oldSha, newSha);
}
public List<ParameterChange> diff(EndpointSnapshot oldSnap, EndpointSnapshot newSnap) throws IOException {
@@ -175,8 +177,8 @@ public class ParameterDiffEngine {
private List<ParameterChange> diffBodyDto(MethodParameterSnapshot oldParam,
MethodParameterSnapshot newParam) throws IOException {
List<NestedFieldInfo> oldFields = nestedDtoFieldParser.parseNestedFields(oldParam.getDtoClassName());
List<NestedFieldInfo> newFields = nestedDtoFieldParser.parseNestedFields(newParam.getDtoClassName());
List<NestedFieldInfo> oldFields = nestedDtoFieldParser.parseNestedFieldsAtOldCommit(oldParam.getDtoClassName());
List<NestedFieldInfo> newFields = nestedDtoFieldParser.parseNestedFieldsAtNewCommit(newParam.getDtoClassName());
List<FieldChange> fieldChanges = fieldDiffEngine.diff(toFieldInfo(oldFields), toFieldInfo(newFields));
List<ParameterChange> result = new ArrayList<>();
for (FieldChange fc : fieldChanges) {
@@ -206,7 +208,7 @@ public class ParameterDiffEngine {
private List<ParameterChange> addedBodyChanges(MethodParameterSnapshot param) throws IOException {
List<ParameterChange> list = new ArrayList<>();
for (NestedFieldInfo field : nestedDtoFieldParser.parseNestedFields(param.getDtoClassName())) {
for (NestedFieldInfo field : nestedDtoFieldParser.parseNestedFieldsAtNewCommit(param.getDtoClassName())) {
list.add(ParameterChange.added(field.getPath(), field.getType(), field.getDescription(),
"body", param.getName(), param.getDtoClassName(), field.getPath()));
}
@@ -215,7 +217,7 @@ public class ParameterDiffEngine {
private List<ParameterChange> removedBodyChanges(MethodParameterSnapshot param) throws IOException {
List<ParameterChange> list = new ArrayList<>();
for (NestedFieldInfo field : nestedDtoFieldParser.parseNestedFields(param.getDtoClassName())) {
for (NestedFieldInfo field : nestedDtoFieldParser.parseNestedFieldsAtOldCommit(param.getDtoClassName())) {
list.add(ParameterChange.removed(field.getPath(), field.getType(), field.getDescription(),
"body", param.getName(), param.getDtoClassName(), field.getPath()));
}

View File

@@ -15,11 +15,20 @@ public class EndpointChangeReport {
private final String sourceFile;
private final String controllerClass;
private final String endpointDescription;
private final boolean dtoFollowUp;
private final String relatedDtoClassName;
private final List<ParameterChange> parameterChanges = new ArrayList<>();
public EndpointChangeReport(ApiChangeKind changeKind, String httpMethod, String oldHttpMethod,
String uri, String oldUri, String sourceFile, String controllerClass,
String endpointDescription) {
this(changeKind, httpMethod, oldHttpMethod, uri, oldUri, sourceFile, controllerClass,
endpointDescription, false, null);
}
public EndpointChangeReport(ApiChangeKind changeKind, String httpMethod, String oldHttpMethod,
String uri, String oldUri, String sourceFile, String controllerClass,
String endpointDescription, boolean dtoFollowUp, String relatedDtoClassName) {
this.changeKind = changeKind;
this.httpMethod = httpMethod;
this.oldHttpMethod = oldHttpMethod;
@@ -28,6 +37,25 @@ public class EndpointChangeReport {
this.sourceFile = sourceFile;
this.controllerClass = controllerClass;
this.endpointDescription = endpointDescription == null ? "" : endpointDescription;
this.dtoFollowUp = dtoFollowUp;
this.relatedDtoClassName = relatedDtoClassName;
}
/** 基于已有报告创建 Dto 跟进产生的副本 */
public static EndpointChangeReport dtoFollowUp(EndpointChangeReport source, String relatedDtoClassName) {
EndpointChangeReport copy = new EndpointChangeReport(
source.getChangeKind(),
source.getHttpMethod(),
source.getOldHttpMethod(),
source.getUri(),
source.getOldUri(),
source.getSourceFile(),
source.getControllerClass(),
source.getEndpointDescription(),
true,
relatedDtoClassName);
source.getParameterChanges().forEach(copy::addParameterChange);
return copy;
}
public ApiChangeKind getChangeKind() {
@@ -73,4 +101,12 @@ public class EndpointChangeReport {
public boolean hasParameterChanges() {
return !parameterChanges.isEmpty();
}
public boolean isDtoFollowUp() {
return dtoFollowUp;
}
public String getRelatedDtoClassName() {
return relatedDtoClassName;
}
}

View File

@@ -1,5 +1,7 @@
package com.codechecker.api.parser;
import com.codechecker.git.GitChangeScanner;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -27,6 +29,24 @@ public class JavaSourceLocator {
return Optional.of(Files.readString(path.get()));
}
public Optional<String> readSourceAtCommit(GitChangeScanner gitScanner, String sha,
String simpleClassName) throws IOException {
Optional<String> relativePath = findRelativePath(simpleClassName);
if (relativePath.isEmpty()) {
return Optional.empty();
}
String source = gitScanner.readFileAtCommit(sha, relativePath.get());
if (source == null || source.isBlank()) {
return Optional.empty();
}
return Optional.of(source);
}
public Optional<String> findRelativePath(String simpleClassName) throws IOException {
Optional<Path> path = findFile(simpleClassName);
return path.map(p -> repoRoot.relativize(p).toString().replace('\\', '/'));
}
public Optional<Path> findFile(String simpleClassName) throws IOException {
String fileName = simpleClassName + ".java";
for (String dir : searchDirs) {

View File

@@ -1,5 +1,6 @@
package com.codechecker.api.parser;
import com.codechecker.git.GitChangeScanner;
import com.codechecker.model.FieldInfo;
import com.codechecker.parser.ClassFieldParser;
import com.codechecker.parser.TypeNameUtils;
@@ -25,25 +26,40 @@ public class NestedDtoFieldParser {
private final ClassFieldParser classFieldParser = new ClassFieldParser();
private final JavaSourceLocator sourceLocator;
private final GitChangeScanner gitScanner;
private final String oldSha;
private final String newSha;
public NestedDtoFieldParser(Path repoRoot, List<String> searchDirs) {
public NestedDtoFieldParser(Path repoRoot, List<String> searchDirs,
GitChangeScanner gitScanner, String oldSha, String newSha) {
this.sourceLocator = new JavaSourceLocator(repoRoot, searchDirs);
this.gitScanner = gitScanner;
this.oldSha = oldSha;
this.newSha = newSha;
}
public List<NestedFieldInfo> parseNestedFields(String dtoClassName) throws IOException {
public List<NestedFieldInfo> parseNestedFieldsAtOldCommit(String dtoClassName) throws IOException {
return parseNestedFields(dtoClassName, oldSha);
}
public List<NestedFieldInfo> parseNestedFieldsAtNewCommit(String dtoClassName) throws IOException {
return parseNestedFields(dtoClassName, newSha);
}
private List<NestedFieldInfo> parseNestedFields(String dtoClassName, String sha) throws IOException {
Set<String> visiting = new HashSet<>();
List<NestedFieldInfo> result = new ArrayList<>();
collectFields(dtoClassName, "", visiting, result);
collectFields(dtoClassName, "", visiting, result, sha);
return result;
}
private void collectFields(String className, String prefix, Set<String> visiting,
List<NestedFieldInfo> out) throws IOException {
List<NestedFieldInfo> out, String sha) throws IOException {
if (className == null || className.isBlank() || visiting.contains(className)) {
return;
}
visiting.add(className);
Optional<String> source = sourceLocator.readSourceBySimpleName(className);
Optional<String> source = readSource(className, sha);
if (source.isEmpty()) {
visiting.remove(className);
return;
@@ -56,12 +72,19 @@ public class NestedDtoFieldParser {
out.add(new NestedFieldInfo(path, field.getType(), field.getDescription()));
} else {
out.add(new NestedFieldInfo(path, field.getType(), field.getDescription()));
collectFields(simpleType, path, visiting, out);
collectFields(simpleType, path, visiting, out, sha);
}
}
visiting.remove(className);
}
private Optional<String> readSource(String className, String sha) throws IOException {
if (sha != null && gitScanner != null) {
return sourceLocator.readSourceAtCommit(gitScanner, sha, className);
}
return sourceLocator.readSourceBySimpleName(className);
}
private boolean isLeafType(String simpleType) {
return LEAF_TYPES.contains(simpleType) || simpleType.endsWith("[]");
}

View File

@@ -25,10 +25,12 @@ public class AppConfig {
private boolean wecomEnabled = true;
private boolean onlyOnChange = true;
private boolean dtoApiFollowUpEnabled = true;
private boolean apiCheckEnabled = true;
private boolean apiExcludeFrameworkParams = true;
private List<String> apiControllerScanDirs = new ArrayList<>();
private List<String> apiFeignScanDirs = new ArrayList<>();
private DtoOverlapMode dtoOverlapMode = DtoOverlapMode.BOTH;
/** 从 YAML 文件加载配置 */
@SuppressWarnings("unchecked")
@@ -49,6 +51,9 @@ public class AppConfig {
Map<String, Object> classCheck = mapOrEmpty(root.get("class_check"));
config.classCheckEnabled = boolOrDefault(classCheck.get("enabled"), true);
Map<String, Object> dtoApiFollowUp = mapOrEmpty(classCheck.get("dto_api_follow_up"));
config.dtoApiFollowUpEnabled = boolOrDefault(dtoApiFollowUp.get("enabled"), true);
Map<String, Object> conversion = mapOrEmpty(classCheck.get("dto_entity_conversion"));
config.dtoEntityConversionEnabled = boolOrDefault(conversion.get("enabled"), true);
@@ -64,6 +69,7 @@ public class AppConfig {
Map<String, Object> notify = mapOrEmpty(root.get("notify"));
config.onlyOnChange = boolOrDefault(notify.get("only_on_change"), true);
config.dtoOverlapMode = DtoOverlapMode.fromString(stringOrEmpty(notify.get("dto_overlap_mode")));
Map<String, Object> apiCheck = mapOrEmpty(root.get("api_check"));
config.apiCheckEnabled = boolOrDefault(apiCheck.get("enabled"), true);
@@ -169,11 +175,21 @@ public class AppConfig {
return onlyOnChange;
}
/** Dto 类变更后是否继续检测受影响接口的 API 参数变更 */
public boolean isDtoApiFollowUpEnabled() {
return dtoApiFollowUpEnabled;
}
/** API 变更检测总开关 */
public boolean isApiCheckEnabled() {
return apiCheckEnabled;
}
/** Dto 类变更与 API 参数变更重叠时的通知策略 */
public DtoOverlapMode getDtoOverlapMode() {
return dtoOverlapMode;
}
/** 是否排除 Spring 框架注入参数 */
public boolean isApiExcludeFrameworkParams() {
return apiExcludeFrameworkParams;

View File

@@ -0,0 +1,24 @@
package com.codechecker.config;
/**
* Dto 类变更与 API 参数变更重叠时的通知策略。
*/
public enum DtoOverlapMode {
/** 仅发类变更通知,抑制重叠的 API 参数通知 */
CLASS_ONLY,
/** 仅发 API 参数通知,抑制重叠的类变更通知 */
API_ONLY,
/** 两类通知均发送 */
BOTH;
public static DtoOverlapMode fromString(String value) {
if (value == null || value.isBlank()) {
return BOTH;
}
try {
return DtoOverlapMode.valueOf(value.trim().toUpperCase());
} catch (IllegalArgumentException ex) {
return BOTH;
}
}
}

View File

@@ -0,0 +1,203 @@
package com.codechecker.notify;
import com.codechecker.api.model.ApiChangeKind;
import com.codechecker.api.model.EndpointChangeReport;
import com.codechecker.api.model.ParameterChange;
import com.codechecker.config.DtoOverlapMode;
import com.codechecker.model.ApiEndpoint;
import com.codechecker.model.ClassChangeReport;
import com.codechecker.model.ClassType;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
/**
* 按配置过滤 Dto 类变更与 API 参数变更的重叠通知。
*/
public class OverlapNotificationFilter {
public static final class FilterResult {
private final List<ClassChangeReport> classReports;
private final List<EndpointChangeReport> apiReports;
public FilterResult(List<ClassChangeReport> classReports, List<EndpointChangeReport> apiReports) {
this.classReports = classReports;
this.apiReports = apiReports;
}
public List<ClassChangeReport> classReports() {
return classReports;
}
public List<EndpointChangeReport> apiReports() {
return apiReports;
}
}
public static FilterResult apply(List<ClassChangeReport> classReports,
List<EndpointChangeReport> apiReports,
DtoOverlapMode mode) {
if (mode == DtoOverlapMode.BOTH) {
return new FilterResult(classReports, apiReports);
}
Set<OverlapKey> overlapKeys = buildOverlapKeys(classReports);
if (overlapKeys.isEmpty()) {
return new FilterResult(classReports, apiReports);
}
if (mode == DtoOverlapMode.CLASS_ONLY) {
return new FilterResult(classReports, filterApiReports(apiReports, overlapKeys));
}
Set<OverlapKey> apiOverlapKeys = buildApiOverlapKeys(apiReports);
return new FilterResult(filterClassReportsForApiOnly(classReports, apiOverlapKeys), apiReports);
}
private static Set<OverlapKey> buildOverlapKeys(List<ClassChangeReport> classReports) {
Set<OverlapKey> keys = new LinkedHashSet<>();
for (ClassChangeReport report : classReports) {
if (report.getClassType() != ClassType.DTO) {
continue;
}
if (!hasDtoFieldChanges(report)) {
continue;
}
Set<String> dtoNames = dtoNames(report);
for (ApiEndpoint endpoint : report.getInputImpactEndpoints()) {
for (String dtoName : dtoNames) {
keys.add(new OverlapKey(dtoName, endpoint.endpointKey()));
}
}
}
return keys;
}
private static List<EndpointChangeReport> filterApiReports(List<EndpointChangeReport> apiReports,
Set<OverlapKey> overlapKeys) {
List<EndpointChangeReport> kept = new ArrayList<>();
for (EndpointChangeReport report : apiReports) {
if (!matchesOverlap(report, overlapKeys)) {
kept.add(report);
}
}
return kept;
}
private static List<ClassChangeReport> filterClassReportsForApiOnly(List<ClassChangeReport> classReports,
Set<OverlapKey> apiOverlapKeys) {
List<ClassChangeReport> kept = new ArrayList<>();
for (ClassChangeReport report : classReports) {
if (!shouldSuppressClassForApiOnly(report, apiOverlapKeys)) {
kept.add(report);
}
}
return kept;
}
private static Set<OverlapKey> buildApiOverlapKeys(List<EndpointChangeReport> apiReports) {
Set<OverlapKey> keys = new LinkedHashSet<>();
for (EndpointChangeReport report : apiReports) {
if (report.getChangeKind() != ApiChangeKind.PARAM_CHANGED) {
continue;
}
String endpointKey = report.getHttpMethod() + " " + report.getUri();
for (ParameterChange change : report.getParameterChanges()) {
if (!"body".equals(change.getSource())) {
continue;
}
String parentDto = change.getParentDto();
if (parentDto != null && !parentDto.isBlank()) {
keys.add(new OverlapKey(parentDto, endpointKey));
}
}
if (report.isDtoFollowUp()) {
String relatedDto = report.getRelatedDtoClassName();
if (relatedDto != null && !relatedDto.isBlank()) {
keys.add(new OverlapKey(relatedDto, endpointKey));
}
}
}
return keys;
}
private static boolean shouldSuppressClassForApiOnly(ClassChangeReport report,
Set<OverlapKey> apiOverlapKeys) {
if (report.getClassType() != ClassType.DTO || !hasDtoFieldChanges(report)) {
return false;
}
for (ApiEndpoint endpoint : report.getInputImpactEndpoints()) {
for (String dtoName : dtoNames(report)) {
if (apiOverlapKeys.contains(new OverlapKey(dtoName, endpoint.endpointKey()))) {
return true;
}
}
}
return false;
}
private static boolean matchesOverlap(EndpointChangeReport report, Set<OverlapKey> overlapKeys) {
if (report.getChangeKind() != ApiChangeKind.PARAM_CHANGED) {
return false;
}
String endpointKey = report.getHttpMethod() + " " + report.getUri();
for (ParameterChange change : report.getParameterChanges()) {
if (!"body".equals(change.getSource())) {
continue;
}
String parentDto = change.getParentDto();
if (parentDto == null || parentDto.isBlank()) {
continue;
}
if (overlapKeys.contains(new OverlapKey(parentDto, endpointKey))) {
return true;
}
}
if (report.isDtoFollowUp()) {
String relatedDto = report.getRelatedDtoClassName();
if (relatedDto != null && overlapKeys.contains(new OverlapKey(relatedDto, endpointKey))) {
return true;
}
}
return false;
}
private static boolean hasDtoFieldChanges(ClassChangeReport report) {
return !report.getFieldChanges().isEmpty();
}
private static Set<String> dtoNames(ClassChangeReport report) {
Set<String> names = new LinkedHashSet<>();
names.add(report.getClassName());
if (report.getOldClassName() != null && !report.getOldClassName().isBlank()) {
names.add(report.getOldClassName());
}
return names;
}
private static final class OverlapKey {
private final String dtoClassName;
private final String endpointKey;
private OverlapKey(String dtoClassName, String endpointKey) {
this.dtoClassName = dtoClassName;
this.endpointKey = endpointKey;
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (!(obj instanceof OverlapKey)) {
return false;
}
OverlapKey other = (OverlapKey) obj;
return dtoClassName.equals(other.dtoClassName) && endpointKey.equals(other.endpointKey);
}
@Override
public int hashCode() {
return dtoClassName.hashCode() * 31 + endpointKey.hashCode();
}
}
}