classNames) {
+ if (types == null) {
+ return false;
+ }
+ for (String type : types) {
+ if (classNames.contains(type)) {
+ return true;
+ }
+ }
+ return false;
+ }
+}
diff --git a/src/main/java/com/codechecker/analyzer/NestedObjectRoleResolver.java b/src/main/java/com/codechecker/analyzer/NestedObjectRoleResolver.java
new file mode 100644
index 0000000..0944862
--- /dev/null
+++ b/src/main/java/com/codechecker/analyzer/NestedObjectRoleResolver.java
@@ -0,0 +1,54 @@
+package com.codechecker.analyzer;
+
+import com.codechecker.model.ApiEndpoint;
+import com.codechecker.model.ClassChangeReport;
+import com.codechecker.model.ClassType;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 判定 Dto/Vo 在类变更通知中的对象角色标签(方案 B:嵌套 + 可选顶层)。
+ *
+ * 仅当存在嵌套祖先时标注;纯顶层不标注;既嵌套又直接作接口根类型时同时标注。
+ */
+public final class NestedObjectRoleResolver {
+
+ private NestedObjectRoleResolver() {
+ }
+
+ public static List resolve(ClassChangeReport report, DtoNestIndex nestIndex,
+ Map endpointIndex) {
+ if (report.getClassType() != ClassType.DTO && report.getClassType() != ClassType.VO) {
+ return List.of();
+ }
+ if (nestIndex == null) {
+ return List.of();
+ }
+ String className = report.getClassName();
+ if (!nestIndex.hasAncestors(className)) {
+ return List.of();
+ }
+ List labels = new ArrayList<>();
+ labels.add("嵌套对象");
+ if (isDirectEndpointType(className, endpointIndex)) {
+ labels.add("顶层对象");
+ }
+ return List.copyOf(labels);
+ }
+
+ /** 是否直接出现在接口入参或返回值类型(非仅经祖先传播) */
+ private static boolean isDirectEndpointType(String className, Map endpointIndex) {
+ if (className == null || className.isBlank() || endpointIndex == null) {
+ return false;
+ }
+ for (ApiEndpoint endpoint : endpointIndex.values()) {
+ if (endpoint.getParamTypes().contains(className)
+ || endpoint.getReturnTypes().contains(className)) {
+ return true;
+ }
+ }
+ return false;
+ }
+}
diff --git a/src/main/java/com/codechecker/api/analyzer/ApiChangeAnalyzer.java b/src/main/java/com/codechecker/api/analyzer/ApiChangeAnalyzer.java
new file mode 100644
index 0000000..4515d0d
--- /dev/null
+++ b/src/main/java/com/codechecker/api/analyzer/ApiChangeAnalyzer.java
@@ -0,0 +1,74 @@
+package com.codechecker.api.analyzer;
+
+import com.codechecker.api.model.EndpointChangeReport;
+import com.codechecker.api.model.EndpointSnapshot;
+import com.codechecker.api.parser.EndpointSnapshotParser;
+import com.codechecker.api.scanner.ApiFileChangeScanner;
+import com.codechecker.config.AppConfig;
+import com.codechecker.git.GitChangeScanner;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * API 变更分析编排(与 {@link com.codechecker.analyzer.ClassChangeAnalyzer} 平行、互不调用)。
+ */
+public class ApiChangeAnalyzer {
+ private final GitChangeScanner gitScanner;
+ private final ApiFileChangeScanner fileScanner;
+
+ public ApiChangeAnalyzer(GitChangeScanner gitScanner) {
+ this.gitScanner = gitScanner;
+ this.fileScanner = new ApiFileChangeScanner(gitScanner);
+ }
+
+ public List analyze(Path repoRoot, AppConfig config,
+ String oldSha, String newSha) throws IOException {
+ List changedFiles = fileScanner.scanChangedFiles(
+ repoRoot, config.getAllApiScanDirs(), oldSha, newSha);
+ if (changedFiles.isEmpty()) {
+ return List.of();
+ }
+
+ EndpointSnapshotParser parser = new EndpointSnapshotParser(config.isApiExcludeFrameworkParams());
+ ParameterDiffEngine parameterDiffEngine = new ParameterDiffEngine(
+ repoRoot, buildSearchDirs(config), gitScanner, oldSha, newSha, config.getNestMaxDepth());
+ EndpointDiffEngine endpointDiffEngine = new EndpointDiffEngine(parameterDiffEngine);
+
+ List oldSnapshots = new ArrayList<>();
+ List newSnapshots = new ArrayList<>();
+
+ for (String path : changedFiles) {
+ 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));
+ }
+
+ return endpointDiffEngine.diff(oldSnapshots, newSnapshots);
+ }
+
+ private List buildSearchDirs(AppConfig config) {
+ List 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;
+ }
+}
diff --git a/src/main/java/com/codechecker/api/analyzer/DtoImpactedApiAnalyzer.java b/src/main/java/com/codechecker/api/analyzer/DtoImpactedApiAnalyzer.java
new file mode 100644
index 0000000..3b0736d
--- /dev/null
+++ b/src/main/java/com/codechecker/api/analyzer/DtoImpactedApiAnalyzer.java
@@ -0,0 +1,150 @@
+package com.codechecker.api.analyzer;
+
+import com.codechecker.analyzer.DtoNestIndex;
+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/Vo 嵌套字段)后,对受影响的 Controller 继续 API 参数 diff,产出 PARAM_CHANGED 报告。
+ */
+public class DtoImpactedApiAnalyzer {
+ private final GitChangeScanner gitScanner;
+
+ public DtoImpactedApiAnalyzer(GitChangeScanner gitScanner) {
+ this.gitScanner = gitScanner;
+ }
+
+ public List analyze(Path repoRoot, AppConfig config,
+ String oldSha, String newSha,
+ List classReports,
+ Set alreadyScannedFiles,
+ DtoNestIndex nestIndex) throws IOException {
+ Map> controllerToDtos = collectImpactedControllers(classReports, alreadyScannedFiles,
+ nestIndex);
+ if (controllerToDtos.isEmpty()) {
+ return List.of();
+ }
+
+ EndpointSnapshotParser parser = new EndpointSnapshotParser(config.isApiExcludeFrameworkParams());
+ ParameterDiffEngine parameterDiffEngine = new ParameterDiffEngine(
+ repoRoot, buildSearchDirs(config), gitScanner, oldSha, newSha, config.getNestMaxDepth());
+ EndpointDiffEngine endpointDiffEngine = new EndpointDiffEngine(parameterDiffEngine);
+
+ List oldSnapshots = new ArrayList<>();
+ List 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 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> collectImpactedControllers(List classReports,
+ Set alreadyScannedFiles,
+ DtoNestIndex nestIndex) {
+ Map> controllerToDtos = new LinkedHashMap<>();
+ for (ClassChangeReport report : classReports) {
+ if (report.getFieldChanges().isEmpty()) {
+ continue;
+ }
+ if (report.getClassType() != ClassType.DTO && report.getClassType() != ClassType.VO) {
+ continue;
+ }
+ Set bodyRoots = resolveBodyRoots(report, nestIndex);
+ if (bodyRoots.isEmpty()) {
+ continue;
+ }
+ for (ApiEndpoint endpoint : report.getInputImpactEndpoints()) {
+ String controllerFile = endpoint.getSourceFile();
+ if (alreadyScannedFiles.contains(controllerFile)) {
+ continue;
+ }
+ controllerToDtos.computeIfAbsent(controllerFile, k -> new LinkedHashSet<>()).addAll(bodyRoots);
+ }
+ }
+ return controllerToDtos;
+ }
+
+ private Set resolveBodyRoots(ClassChangeReport report, DtoNestIndex nestIndex) {
+ if (nestIndex == null) {
+ Set names = new LinkedHashSet<>();
+ if (report.getClassName().endsWith("Dto")) {
+ names.add(report.getClassName());
+ }
+ return names;
+ }
+ Set roots = new LinkedHashSet<>();
+ roots.addAll(nestIndex.findRequestBodyRoots(report.getClassName()));
+ if (report.getOldClassName() != null && !report.getOldClassName().isBlank()) {
+ roots.addAll(nestIndex.findRequestBodyRoots(report.getOldClassName()));
+ }
+ return roots;
+ }
+
+ private String findRelatedDto(EndpointChangeReport report, Map> controllerToDtos) {
+ Set 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 List buildSearchDirs(AppConfig config) {
+ List 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;
+ }
+}
diff --git a/src/main/java/com/codechecker/api/analyzer/EndpointDiffEngine.java b/src/main/java/com/codechecker/api/analyzer/EndpointDiffEngine.java
new file mode 100644
index 0000000..84fd5f5
--- /dev/null
+++ b/src/main/java/com/codechecker/api/analyzer/EndpointDiffEngine.java
@@ -0,0 +1,126 @@
+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 java.io.IOException;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 接口快照对比:路径 / 方法 / 增删 / 参数(拆分报告,互不混合类型)。
+ */
+public class EndpointDiffEngine {
+ private final ParameterDiffEngine parameterDiffEngine;
+
+ public EndpointDiffEngine(ParameterDiffEngine parameterDiffEngine) {
+ this.parameterDiffEngine = parameterDiffEngine;
+ }
+
+ public List diff(List oldSnapshots,
+ List newSnapshots) throws IOException {
+ Map oldMap = indexByFingerprint(oldSnapshots);
+ Map newMap = indexByFingerprint(newSnapshots);
+ List reports = new ArrayList<>();
+
+ for (String fp : newMap.keySet()) {
+ if (!oldMap.containsKey(fp)) {
+ EndpointSnapshot snap = newMap.get(fp);
+ reports.add(new EndpointChangeReport(
+ ApiChangeKind.NEW_ENDPOINT,
+ snap.getHttpMethod(), null,
+ snap.getUri(), null,
+ snap.getSourceFile(), snap.getControllerClass(),
+ snap.getMethodDescription()));
+ }
+ }
+ for (String fp : oldMap.keySet()) {
+ if (!newMap.containsKey(fp)) {
+ EndpointSnapshot snap = oldMap.get(fp);
+ reports.add(new EndpointChangeReport(
+ ApiChangeKind.REMOVED_ENDPOINT,
+ snap.getHttpMethod(), null,
+ snap.getUri(), null,
+ snap.getSourceFile(), snap.getControllerClass(),
+ snap.getMethodDescription()));
+ }
+ }
+ for (String fp : oldMap.keySet()) {
+ if (!newMap.containsKey(fp)) {
+ continue;
+ }
+ EndpointSnapshot oldSnap = oldMap.get(fp);
+ EndpointSnapshot newSnap = newMap.get(fp);
+ reports.addAll(diffMatched(oldSnap, newSnap));
+ }
+ return reports;
+ }
+
+ private List diffMatched(EndpointSnapshot oldSnap,
+ EndpointSnapshot newSnap) throws IOException {
+ List reports = new ArrayList<>();
+ boolean pathChanged = !oldSnap.getUri().equals(newSnap.getUri());
+ boolean methodChanged = !oldSnap.getHttpMethod().equalsIgnoreCase(newSnap.getHttpMethod());
+
+ if (pathChanged) {
+ reports.add(new EndpointChangeReport(
+ ApiChangeKind.PATH_CHANGED,
+ newSnap.getHttpMethod(), null,
+ newSnap.getUri(), oldSnap.getUri(),
+ newSnap.getSourceFile(), newSnap.getControllerClass(),
+ preferDescription(newSnap, oldSnap)));
+ }
+ if (methodChanged) {
+ reports.add(new EndpointChangeReport(
+ ApiChangeKind.METHOD_CHANGED,
+ newSnap.getHttpMethod(), oldSnap.getHttpMethod(),
+ newSnap.getUri(), null,
+ newSnap.getSourceFile(), newSnap.getControllerClass(),
+ preferDescription(newSnap, oldSnap)));
+ }
+
+ List paramChanges = parameterDiffEngine.diff(oldSnap, newSnap);
+ if (!paramChanges.isEmpty()) {
+ if (pathChanged || methodChanged) {
+ EndpointChangeReport paramReport = new EndpointChangeReport(
+ ApiChangeKind.PARAM_CHANGED,
+ newSnap.getHttpMethod(), methodChanged ? oldSnap.getHttpMethod() : null,
+ newSnap.getUri(), pathChanged ? oldSnap.getUri() : null,
+ newSnap.getSourceFile(), newSnap.getControllerClass(),
+ preferDescription(newSnap, oldSnap));
+ paramChanges.forEach(paramReport::addParameterChange);
+ reports.add(paramReport);
+ } else {
+ EndpointChangeReport paramReport = new EndpointChangeReport(
+ ApiChangeKind.PARAM_CHANGED,
+ newSnap.getHttpMethod(), null,
+ newSnap.getUri(), null,
+ newSnap.getSourceFile(), newSnap.getControllerClass(),
+ preferDescription(newSnap, oldSnap));
+ paramChanges.forEach(paramReport::addParameterChange);
+ reports.add(paramReport);
+ }
+ }
+ return reports;
+ }
+
+ private String preferDescription(EndpointSnapshot primary, EndpointSnapshot fallback) {
+ if (primary != null && primary.getMethodDescription() != null
+ && !primary.getMethodDescription().isBlank()) {
+ return primary.getMethodDescription();
+ }
+ return fallback == null ? "" : fallback.getMethodDescription();
+ }
+
+ private Map indexByFingerprint(List snapshots) {
+ Map map = new LinkedHashMap<>();
+ for (EndpointSnapshot snap : snapshots) {
+ map.putIfAbsent(snap.getFingerprint(), snap);
+ }
+ return map;
+ }
+}
diff --git a/src/main/java/com/codechecker/api/analyzer/ParameterDiffEngine.java b/src/main/java/com/codechecker/api/analyzer/ParameterDiffEngine.java
new file mode 100644
index 0000000..4a241c5
--- /dev/null
+++ b/src/main/java/com/codechecker/api/analyzer/ParameterDiffEngine.java
@@ -0,0 +1,234 @@
+package com.codechecker.api.analyzer;
+
+import com.codechecker.analyzer.FieldDiffEngine;
+import com.codechecker.api.model.EndpointSnapshot;
+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;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * 接口入参 diff(普通参数 + RequestBody 嵌套 Dto 字段)。
+ *
+ * path/query 规则:
+ * - 形参名+类型相同,仅绑定名变 → 重命名
+ * - 形参名+绑定名相同,仅类型变 → 类型变更
+ * - 仅形参名变(绑定名不变)→ 不通知
+ * - 类型与绑定名同时变,或三者都变 → 先删除后新增
+ */
+public class ParameterDiffEngine {
+ private final NestedDtoFieldParser nestedDtoFieldParser;
+ private final FieldDiffEngine fieldDiffEngine = new FieldDiffEngine();
+
+ public ParameterDiffEngine(Path repoRoot, List searchDirs,
+ GitChangeScanner gitScanner, String oldSha, String newSha, int maxDepth) {
+ this.nestedDtoFieldParser = new NestedDtoFieldParser(repoRoot, searchDirs, gitScanner, oldSha, newSha, maxDepth);
+ }
+
+ public List diff(EndpointSnapshot oldSnap, EndpointSnapshot newSnap) throws IOException {
+ List changes = new ArrayList<>();
+ changes.addAll(diffBodyParams(oldSnap, newSnap));
+ changes.addAll(diffBindingParams(oldSnap, newSnap));
+ return changes;
+ }
+
+ private List diffBodyParams(EndpointSnapshot oldSnap, EndpointSnapshot newSnap)
+ throws IOException {
+ Map oldParams = filterBySource(oldSnap, "body");
+ Map newParams = filterBySource(newSnap, "body");
+ List changes = new ArrayList<>();
+
+ for (Map.Entry entry : newParams.entrySet()) {
+ MethodParameterSnapshot oldParam = oldParams.get(entry.getKey());
+ MethodParameterSnapshot newParam = entry.getValue();
+ if (oldParam == null) {
+ changes.addAll(addedBodyChanges(newParam));
+ } else {
+ changes.addAll(diffBodyDto(oldParam, newParam));
+ }
+ }
+ for (Map.Entry entry : oldParams.entrySet()) {
+ if (!newParams.containsKey(entry.getKey())) {
+ changes.addAll(removedBodyChanges(entry.getValue()));
+ }
+ }
+ return changes;
+ }
+
+ private List diffBindingParams(EndpointSnapshot oldSnap, EndpointSnapshot newSnap) {
+ Map oldParams = filterBindingParams(oldSnap);
+ Map newParams = filterBindingParams(newSnap);
+ List changes = new ArrayList<>();
+
+ List unmatchedOld = new ArrayList<>();
+ List unmatchedNew = new ArrayList<>();
+
+ for (Map.Entry entry : newParams.entrySet()) {
+ MethodParameterSnapshot oldParam = oldParams.get(entry.getKey());
+ MethodParameterSnapshot newParam = entry.getValue();
+ if (oldParam == null) {
+ unmatchedNew.add(newParam);
+ } else if (!oldParam.getType().equals(newParam.getType())) {
+ changes.add(ParameterChange.modified(
+ newParam.displayName(),
+ newParam.getType(),
+ newParam.getDescription(),
+ oldParam.getType() + " → " + newParam.getType(),
+ newParam.getSource(),
+ null, null, null));
+ }
+ }
+ for (Map.Entry entry : oldParams.entrySet()) {
+ if (!newParams.containsKey(entry.getKey())) {
+ unmatchedOld.add(entry.getValue());
+ }
+ }
+
+ pairRenamedBindingParams(unmatchedOld, unmatchedNew, changes);
+
+ for (MethodParameterSnapshot removed : unmatchedOld) {
+ changes.add(ParameterChange.removed(
+ removed.displayName(), removed.getType(), removed.getDescription(),
+ removed.getSource(), null, null, null));
+ }
+ for (MethodParameterSnapshot added : unmatchedNew) {
+ changes.add(ParameterChange.added(
+ added.displayName(), added.getType(), added.getDescription(),
+ added.getSource(), null, null, null));
+ }
+ return changes;
+ }
+
+ /** 形参名+类型相同,仅绑定名变化 → 重命名 */
+ private void pairRenamedBindingParams(List unmatchedOld,
+ List unmatchedNew,
+ List changes) {
+ Set pairedOld = new HashSet<>();
+ Set pairedNew = new HashSet<>();
+
+ for (MethodParameterSnapshot oldParam : unmatchedOld) {
+ if (pairedOld.contains(oldParam)) {
+ continue;
+ }
+ for (MethodParameterSnapshot newParam : unmatchedNew) {
+ if (pairedNew.contains(newParam)) {
+ continue;
+ }
+ if (!oldParam.getSource().equals(newParam.getSource())) {
+ continue;
+ }
+ if (!oldParam.getName().equals(newParam.getName())) {
+ continue;
+ }
+ if (!oldParam.getType().equals(newParam.getType())) {
+ continue;
+ }
+ changes.add(ParameterChange.renamed(
+ oldParam.getBindingName(),
+ newParam.getBindingName(),
+ newParam.getType(),
+ newParam.getDescription(),
+ newParam.getSource(),
+ null, null, null));
+ pairedOld.add(oldParam);
+ pairedNew.add(newParam);
+ break;
+ }
+ }
+ unmatchedOld.removeIf(pairedOld::contains);
+ unmatchedNew.removeIf(pairedNew::contains);
+ }
+
+ private Map filterBindingParams(EndpointSnapshot snap) {
+ Map map = new LinkedHashMap<>();
+ for (MethodParameterSnapshot p : snap.getParameters()) {
+ if (isBindingParam(p)) {
+ map.put(p.identityKey(), p);
+ }
+ }
+ return map;
+ }
+
+ private Map filterBySource(EndpointSnapshot snap, String source) {
+ Map map = new LinkedHashMap<>();
+ for (MethodParameterSnapshot p : snap.getParameters()) {
+ if (source.equals(p.getSource())) {
+ map.put(p.identityKey(), p);
+ }
+ }
+ return map;
+ }
+
+ private boolean isBindingParam(MethodParameterSnapshot param) {
+ return "path".equals(param.getSource()) || "query".equals(param.getSource());
+ }
+
+ private List diffBodyDto(MethodParameterSnapshot oldParam,
+ MethodParameterSnapshot newParam) throws IOException {
+ List oldFields = nestedDtoFieldParser.parseNestedFieldsAtOldCommit(oldParam.getDtoClassName());
+ List newFields = nestedDtoFieldParser.parseNestedFieldsAtNewCommit(newParam.getDtoClassName());
+ List fieldChanges = fieldDiffEngine.diff(toFieldInfo(oldFields), toFieldInfo(newFields));
+ List result = new ArrayList<>();
+ for (FieldChange fc : fieldChanges) {
+ result.add(mapFieldChange(fc, newParam.getName(), newParam.getDtoClassName()));
+ }
+ return result;
+ }
+
+ private ParameterChange mapFieldChange(FieldChange fc, String bodyParamName, String dtoName) {
+ String path = fc.getFieldName();
+ switch (fc.getKind()) {
+ case ADDED:
+ return ParameterChange.added(path, fc.getNewType(), fc.getDescription(),
+ "body", bodyParamName, dtoName, path);
+ case REMOVED:
+ return ParameterChange.removed(path, fc.getOldType(), fc.getDescription(),
+ "body", bodyParamName, dtoName, path);
+ case RENAMED:
+ return ParameterChange.renamed(fc.getOldFieldName(), fc.getFieldName(),
+ fc.getNewType(), fc.getDescription(), "body", bodyParamName, dtoName, path);
+ case MODIFIED:
+ default:
+ return ParameterChange.modified(path, fc.getNewType(), fc.getDescription(),
+ fc.getDetail(), "body", bodyParamName, dtoName, path);
+ }
+ }
+
+ private List addedBodyChanges(MethodParameterSnapshot param) throws IOException {
+ List list = new ArrayList<>();
+ for (NestedFieldInfo field : nestedDtoFieldParser.parseNestedFieldsAtNewCommit(param.getDtoClassName())) {
+ list.add(ParameterChange.added(field.getPath(), field.getType(), field.getDescription(),
+ "body", param.getName(), param.getDtoClassName(), field.getPath()));
+ }
+ return list;
+ }
+
+ private List removedBodyChanges(MethodParameterSnapshot param) throws IOException {
+ List list = new ArrayList<>();
+ for (NestedFieldInfo field : nestedDtoFieldParser.parseNestedFieldsAtOldCommit(param.getDtoClassName())) {
+ list.add(ParameterChange.removed(field.getPath(), field.getType(), field.getDescription(),
+ "body", param.getName(), param.getDtoClassName(), field.getPath()));
+ }
+ return list;
+ }
+
+ private List toFieldInfo(List nested) {
+ List result = new ArrayList<>();
+ for (NestedFieldInfo info : nested) {
+ result.add(new FieldInfo(info.getPath(), info.getType(), info.getDescription()));
+ }
+ return result;
+ }
+}
diff --git a/src/main/java/com/codechecker/api/model/ApiChangeKind.java b/src/main/java/com/codechecker/api/model/ApiChangeKind.java
new file mode 100644
index 0000000..e731ffc
--- /dev/null
+++ b/src/main/java/com/codechecker/api/model/ApiChangeKind.java
@@ -0,0 +1,12 @@
+package com.codechecker.api.model;
+
+/**
+ * API 变更类型(与类变更 {@link com.codechecker.model.ClassChangeKind} 独立)。
+ */
+public enum ApiChangeKind {
+ NEW_ENDPOINT,
+ REMOVED_ENDPOINT,
+ PATH_CHANGED,
+ METHOD_CHANGED,
+ PARAM_CHANGED
+}
diff --git a/src/main/java/com/codechecker/api/model/EndpointChangeReport.java b/src/main/java/com/codechecker/api/model/EndpointChangeReport.java
new file mode 100644
index 0000000..3765c28
--- /dev/null
+++ b/src/main/java/com/codechecker/api/model/EndpointChangeReport.java
@@ -0,0 +1,112 @@
+package com.codechecker.api.model;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 单条 API 变更报告(路径 / 方法 / 参数各自独立,不与其他类型混合)。
+ */
+public class EndpointChangeReport {
+ private final ApiChangeKind changeKind;
+ private final String httpMethod;
+ private final String oldHttpMethod;
+ private final String uri;
+ private final String oldUri;
+ private final String sourceFile;
+ private final String controllerClass;
+ private final String endpointDescription;
+ private final boolean dtoFollowUp;
+ private final String relatedDtoClassName;
+ private final List 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;
+ this.uri = uri;
+ this.oldUri = oldUri;
+ 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() {
+ return changeKind;
+ }
+
+ public String getHttpMethod() {
+ return httpMethod;
+ }
+
+ public String getOldHttpMethod() {
+ return oldHttpMethod;
+ }
+
+ public String getUri() {
+ return uri;
+ }
+
+ public String getOldUri() {
+ return oldUri;
+ }
+
+ public String getSourceFile() {
+ return sourceFile;
+ }
+
+ public String getControllerClass() {
+ return controllerClass;
+ }
+
+ public String getEndpointDescription() {
+ return endpointDescription;
+ }
+
+ public List getParameterChanges() {
+ return parameterChanges;
+ }
+
+ public void addParameterChange(ParameterChange change) {
+ parameterChanges.add(change);
+ }
+
+ public boolean hasParameterChanges() {
+ return !parameterChanges.isEmpty();
+ }
+
+ public boolean isDtoFollowUp() {
+ return dtoFollowUp;
+ }
+
+ public String getRelatedDtoClassName() {
+ return relatedDtoClassName;
+ }
+}
diff --git a/src/main/java/com/codechecker/api/model/EndpointSnapshot.java b/src/main/java/com/codechecker/api/model/EndpointSnapshot.java
new file mode 100644
index 0000000..4087283
--- /dev/null
+++ b/src/main/java/com/codechecker/api/model/EndpointSnapshot.java
@@ -0,0 +1,72 @@
+package com.codechecker.api.model;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 单个 HTTP/Feign 接口快照。
+ */
+public class EndpointSnapshot {
+ private final String fingerprint;
+ private final String httpMethod;
+ private final String uri;
+ private final String sourceFile;
+ private final String controllerClass;
+ private final String methodName;
+ private final String methodDescription;
+ private final List parameters;
+
+ public EndpointSnapshot(String fingerprint, String httpMethod, String uri, String sourceFile,
+ String controllerClass, String methodName, String methodDescription,
+ List parameters) {
+ this.fingerprint = fingerprint;
+ this.httpMethod = httpMethod;
+ this.uri = uri;
+ this.sourceFile = sourceFile;
+ this.controllerClass = controllerClass;
+ this.methodName = methodName;
+ this.methodDescription = methodDescription == null ? "" : methodDescription;
+ this.parameters = parameters == null ? List.of() : new ArrayList<>(parameters);
+ }
+
+ /** 跨 commit 配对同一 Java 方法;不含参数信息,参数 diff 由 ParameterDiffEngine 负责 */
+ public static String buildFingerprint(String sourceFile, String methodName) {
+ return sourceFile + "#" + methodName;
+ }
+
+ public String getFingerprint() {
+ return fingerprint;
+ }
+
+ public String getHttpMethod() {
+ return httpMethod;
+ }
+
+ public String getUri() {
+ return uri;
+ }
+
+ public String getSourceFile() {
+ return sourceFile;
+ }
+
+ public String getControllerClass() {
+ return controllerClass;
+ }
+
+ public String getMethodName() {
+ return methodName;
+ }
+
+ public String getMethodDescription() {
+ return methodDescription;
+ }
+
+ public List getParameters() {
+ return parameters;
+ }
+
+ public String endpointKey() {
+ return httpMethod + " " + uri;
+ }
+}
diff --git a/src/main/java/com/codechecker/api/model/MethodParameterSnapshot.java b/src/main/java/com/codechecker/api/model/MethodParameterSnapshot.java
new file mode 100644
index 0000000..718d1a5
--- /dev/null
+++ b/src/main/java/com/codechecker/api/model/MethodParameterSnapshot.java
@@ -0,0 +1,71 @@
+package com.codechecker.api.model;
+
+/**
+ * 接口方法入参快照。
+ */
+public class MethodParameterSnapshot {
+ private final String name;
+ private final String bindingName;
+ private final String type;
+ private final String source;
+ private final boolean required;
+ private final String description;
+ private final String dtoClassName;
+
+ public MethodParameterSnapshot(String name, String bindingName, String type, String source,
+ boolean required, String description, String dtoClassName) {
+ this.name = name;
+ this.bindingName = bindingName == null || bindingName.isBlank() ? name : bindingName;
+ this.type = type;
+ this.source = source;
+ this.required = required;
+ this.description = description;
+ this.dtoClassName = dtoClassName;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ /** 对外绑定名(@PathVariable / @RequestParam 的 value/name,缺省为形参名) */
+ public String getBindingName() {
+ return bindingName;
+ }
+
+ public String getType() {
+ return type;
+ }
+
+ /** body / path / query / simple */
+ public String getSource() {
+ return source;
+ }
+
+ public boolean isRequired() {
+ return required;
+ }
+
+ public String getDescription() {
+ return description;
+ }
+
+ public String getDtoClassName() {
+ return dtoClassName;
+ }
+
+ /** path/query 按绑定名匹配,避免仅 Java 形参重命名误报 */
+ public String identityKey() {
+ if ("path".equals(source) || "query".equals(source)) {
+ return source + ":" + bindingName;
+ }
+ return source + ":" + name;
+ }
+
+ /** 通知展示名:path/query 展示绑定名 */
+ public String displayName() {
+ if ("path".equals(source) || "query".equals(source)) {
+ return bindingName;
+ }
+ return name;
+ }
+}
diff --git a/src/main/java/com/codechecker/api/model/ParameterChange.java b/src/main/java/com/codechecker/api/model/ParameterChange.java
new file mode 100644
index 0000000..3e0af5c
--- /dev/null
+++ b/src/main/java/com/codechecker/api/model/ParameterChange.java
@@ -0,0 +1,112 @@
+package com.codechecker.api.model;
+
+/**
+ * API 参数或 RequestBody 嵌套字段变更。
+ */
+public class ParameterChange {
+ public enum ChangeType {
+ ADDED, REMOVED, MODIFIED, RENAMED
+ }
+
+ private final ChangeType changeType;
+ private final String paramName;
+ private final String oldName;
+ private final String paramType;
+ private final String description;
+ private final String oldDescription;
+ private final String source;
+ private final String bodyParamName;
+ private final String parentDto;
+ private final String fieldPath;
+ private final String detail;
+
+ private ParameterChange(ChangeType changeType, String paramName, String oldName,
+ String paramType, String description, String oldDescription,
+ String source, String bodyParamName, String parentDto,
+ String fieldPath, String detail) {
+ this.changeType = changeType;
+ this.paramName = paramName;
+ this.oldName = oldName;
+ this.paramType = paramType;
+ this.description = description;
+ this.oldDescription = oldDescription;
+ this.source = source;
+ this.bodyParamName = bodyParamName;
+ this.parentDto = parentDto;
+ this.fieldPath = fieldPath;
+ this.detail = detail;
+ }
+
+ public static ParameterChange added(String name, String type, String desc, String source,
+ String bodyParam, String dto, String fieldPath) {
+ return new ParameterChange(ChangeType.ADDED, name, null, type, desc, null,
+ source, bodyParam, dto, fieldPath, null);
+ }
+
+ public static ParameterChange removed(String name, String type, String desc, String source,
+ String bodyParam, String dto, String fieldPath) {
+ return new ParameterChange(ChangeType.REMOVED, name, null, type, desc, null,
+ source, bodyParam, dto, fieldPath, null);
+ }
+
+ public static ParameterChange modified(String name, String type, String desc,
+ String detail, String source, String bodyParam,
+ String dto, String fieldPath) {
+ return new ParameterChange(ChangeType.MODIFIED, name, null, type, desc, null,
+ source, bodyParam, dto, fieldPath, detail);
+ }
+
+ public static ParameterChange renamed(String oldName, String newName, String type, String desc,
+ String source, String bodyParam, String dto, String fieldPath) {
+ return new ParameterChange(ChangeType.RENAMED, newName, oldName, type, desc, null,
+ source, bodyParam, dto, fieldPath, null);
+ }
+
+ public ChangeType getChangeType() {
+ return changeType;
+ }
+
+ public String getParamName() {
+ return paramName;
+ }
+
+ public String getOldName() {
+ return oldName;
+ }
+
+ public String getParamType() {
+ return paramType;
+ }
+
+ public String getDescription() {
+ return description;
+ }
+
+ public String getSource() {
+ return source;
+ }
+
+ public String getBodyParamName() {
+ return bodyParamName;
+ }
+
+ public String getParentDto() {
+ return parentDto;
+ }
+
+ public String getFieldPath() {
+ return fieldPath;
+ }
+
+ public String getDetail() {
+ return detail;
+ }
+
+ public boolean isBodyField() {
+ return "body".equals(source);
+ }
+
+ public String displayName() {
+ return fieldPath == null || fieldPath.isBlank() ? paramName : fieldPath;
+ }
+}
diff --git a/src/main/java/com/codechecker/api/notify/ApiChangeNotifier.java b/src/main/java/com/codechecker/api/notify/ApiChangeNotifier.java
new file mode 100644
index 0000000..f620d05
--- /dev/null
+++ b/src/main/java/com/codechecker/api/notify/ApiChangeNotifier.java
@@ -0,0 +1,272 @@
+package com.codechecker.api.notify;
+
+import com.codechecker.api.model.ApiChangeKind;
+import com.codechecker.api.model.EndpointChangeReport;
+import com.codechecker.api.model.ParameterChange;
+import com.codechecker.common.MarkdownStyles;
+import com.codechecker.common.WeComMarkdownSender;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * API 变更通知(路径 / 请求方式 / 参数分类型、分条发送,与类变更通知解耦)。
+ */
+public class ApiChangeNotifier {
+ private final WeComMarkdownSender sender = new WeComMarkdownSender();
+
+ public int sendAll(String webhookUrl, List reports,
+ String modifier, String modifyTime, boolean wecomEnabled) {
+ if (reports == null || reports.isEmpty()) {
+ System.out.println("无 API 变更,不发送通知");
+ return 0;
+ }
+ int sent = 0;
+ for (EndpointChangeReport report : reports) {
+ String markdown = buildMarkdown(report, modifier, modifyTime);
+ if (wecomEnabled) {
+ if (sender.send(webhookUrl, markdown)) {
+ sent++;
+ System.out.println("已发送 API 变更通知: " + report.getChangeKind()
+ + " " + report.getHttpMethod() + " " + report.getUri());
+ }
+ } else {
+ sender.logPreview("API 变更 [" + report.getChangeKind() + "]", markdown);
+ sent++;
+ }
+ }
+ if (sent > 0) {
+ System.out.println("总共发送 " + sent + " 条 API 变更通知");
+ }
+ return sent;
+ }
+
+ public String buildMarkdown(EndpointChangeReport report, String modifier, String modifyTime) {
+ ApiChangeKind kind = report.getChangeKind();
+ if (kind == ApiChangeKind.PATH_CHANGED
+ || kind == ApiChangeKind.NEW_ENDPOINT
+ || kind == ApiChangeKind.REMOVED_ENDPOINT) {
+ return buildPathMarkdown(report, modifier, modifyTime);
+ }
+ if (kind == ApiChangeKind.METHOD_CHANGED) {
+ return buildMethodMarkdown(report, modifier, modifyTime);
+ }
+ return buildParamMarkdown(report, modifier, modifyTime);
+ }
+
+ private String buildPathMarkdown(EndpointChangeReport report, String modifier, String modifyTime) {
+ String changeLabel;
+ switch (report.getChangeKind()) {
+ case NEW_ENDPOINT:
+ changeLabel = "新增接口";
+ break;
+ case REMOVED_ENDPOINT:
+ changeLabel = "删除接口";
+ break;
+ default:
+ changeLabel = "修改路径";
+ break;
+ }
+ StringBuilder sb = new StringBuilder();
+ sb.append("# 【API路径变更通知】").append("\n\n");
+ sb.append(MarkdownStyles.quoteKvBold("变更类型", MarkdownStyles.colorWarning(changeLabel))).append("\n");
+ sb.append(MarkdownStyles.quoteKvBold("路径",
+ MarkdownStyles.colorInfo(MarkdownStyles.safe(report.getSourceFile())))).append("\n");
+ sb.append(MarkdownStyles.quoteKvBold("修改人", MarkdownStyles.colorComment(modifier))).append("\n");
+ sb.append(MarkdownStyles.quoteKvBold("时间", MarkdownStyles.colorComment(modifyTime))).append("\n");
+ sb.append("\n## 【URI变更详情】").append("\n\n");
+ sb.append(MarkdownStyles.quoteKvBold("接口说明", formatEndpointDescription(report))).append("\n");
+ appendPathUriLines(sb, report, changeLabel);
+ return sb.toString();
+ }
+
+ private void appendPathUriLines(StringBuilder sb, EndpointChangeReport report, String changeLabel) {
+ if ("新增接口".equals(changeLabel)) {
+ sb.append(MarkdownStyles.quoteKvBold("原路径", "`-`")).append("\n");
+ sb.append(MarkdownStyles.quoteKvBold("新路径",
+ formatUriWithMethod(report.getHttpMethod(), report.getUri(), true)
+ + " " + MarkdownStyles.colorInfo("[新增]"))).append("\n");
+ } else if ("删除接口".equals(changeLabel)) {
+ sb.append(MarkdownStyles.quoteKvBold("原路径",
+ formatUriWithMethod(report.getHttpMethod(), report.getUri(), false)
+ + " " + MarkdownStyles.colorWarning("[已删除]"))).append("\n");
+ sb.append(MarkdownStyles.quoteKvBold("新路径", "`已删除`")).append("\n");
+ } else {
+ sb.append(MarkdownStyles.quoteKvBold("原路径",
+ formatUriWithMethod(report.getHttpMethod(), report.getOldUri(), false)
+ + " " + MarkdownStyles.colorWarning("[旧路径]"))).append("\n");
+ sb.append(MarkdownStyles.quoteKvBold("新路径",
+ formatUriWithMethod(report.getHttpMethod(), report.getUri(), true)
+ + " " + MarkdownStyles.colorInfo("[新路径]"))).append("\n");
+ }
+ }
+
+ private String buildMethodMarkdown(EndpointChangeReport report, String modifier, String modifyTime) {
+ StringBuilder sb = new StringBuilder();
+ sb.append("# 【API请求方式变更通知】").append("\n\n");
+ sb.append(MarkdownStyles.quoteKvBold("变更类型", MarkdownStyles.colorWarning("修改请求方式"))).append("\n");
+ sb.append(MarkdownStyles.quoteKvBold("路径",
+ MarkdownStyles.colorInfo(MarkdownStyles.safe(report.getSourceFile())))).append("\n");
+ sb.append(MarkdownStyles.quoteKvBold("修改人", MarkdownStyles.colorComment(modifier))).append("\n");
+ sb.append(MarkdownStyles.quoteKvBold("时间", MarkdownStyles.colorComment(modifyTime))).append("\n");
+ sb.append("\n## 【请求方式变更详情】").append("\n\n");
+ sb.append(MarkdownStyles.quoteKvBold("接口说明", formatEndpointDescription(report))).append("\n");
+ sb.append(MarkdownStyles.quoteKvBold("URI", MarkdownStyles.colorInfo(report.getUri()))).append("\n");
+ sb.append(MarkdownStyles.quoteKvBold("原请求方式",
+ MarkdownStyles.colorWarning(report.getOldHttpMethod()))).append("\n");
+ sb.append(MarkdownStyles.quoteKvBold("新请求方式",
+ MarkdownStyles.colorInfo(report.getHttpMethod()) + " "
+ + MarkdownStyles.colorInfo("[请求方式已变更]"))).append("\n");
+ return sb.toString();
+ }
+
+ private String buildParamMarkdown(EndpointChangeReport report, String modifier, String modifyTime) {
+ StringBuilder sb = new StringBuilder();
+ sb.append("# 【API参数变更通知】").append("\n\n");
+ sb.append(MarkdownStyles.quoteKvBold("修改人", MarkdownStyles.colorComment(modifier))).append("\n");
+ sb.append(MarkdownStyles.quoteKvBold("时间", MarkdownStyles.colorComment(modifyTime))).append("\n");
+ //sb.append(MarkdownStyles.quoteKvBold("变更类型", MarkdownStyles.colorWarning("修改参数"))).append("\n");
+ sb.append(MarkdownStyles.quoteKvBold("URI",
+ MarkdownStyles.colorInfo(report.getHttpMethod()) + " "
+ + MarkdownStyles.inlineCode(report.getUri()))).append("\n");
+ sb.append(MarkdownStyles.quoteKvBold("路径",
+ MarkdownStyles.colorInfo(MarkdownStyles.safe(report.getSourceFile())))).append("\n");
+ sb.append("\n## 【接口参数变动详情】").append("\n\n");
+ appendParameterDetails(sb, report);
+ return sb.toString();
+ }
+
+ private void appendParameterDetails(StringBuilder sb, EndpointChangeReport report) {
+ List bodyChanges = new ArrayList<>();
+ List regularChanges = new ArrayList<>();
+ for (ParameterChange change : report.getParameterChanges()) {
+ if (change.isBodyField()) {
+ bodyChanges.add(change);
+ } else {
+ regularChanges.add(change);
+ }
+ }
+ if (!bodyChanges.isEmpty()) {
+ sb.append("**类对象变更(含嵌套对象字段)**").append("\n\n");
+ appendBodyGroups(sb, bodyChanges);
+ sb.append("\n");
+ }
+ if (!regularChanges.isEmpty()) {
+ sb.append("**普通参数变更**").append("\n\n");
+ sb.append(MarkdownStyles.quoteLine("**共 "
+ + MarkdownStyles.colorWarning(String.valueOf(regularChanges.size()))
+ + " 项变更**")).append("\n\n");
+ for (ParameterChange change : regularChanges) {
+ sb.append(formatParameterLine(change)).append("\n\n");
+ }
+ }
+ if (bodyChanges.isEmpty() && regularChanges.isEmpty()) {
+ sb.append(MarkdownStyles.quoteLine(MarkdownStyles.colorComment("无"))).append("\n");
+ }
+ }
+
+ private void appendBodyGroups(StringBuilder sb, List bodyChanges) {
+ Map> groups = new LinkedHashMap<>();
+ for (ParameterChange change : bodyChanges) {
+ String key = change.getParentDto() == null || change.getParentDto().isBlank()
+ ? (change.getBodyParamName() == null ? "body" : change.getBodyParamName())
+ : change.getParentDto();
+ groups.computeIfAbsent(key, k -> new ArrayList<>()).add(change);
+ }
+ int total = bodyChanges.size();
+ sb.append(MarkdownStyles.quoteLine("**共 "
+ + MarkdownStyles.colorWarning(String.valueOf(groups.size()))
+ + " 个类对象 · "
+ + MarkdownStyles.colorWarning(String.valueOf(total))
+ + " 项变更**")).append("\n\n");
+ for (List group : groups.values()) {
+ ParameterChange first = group.get(0);
+ if (first.getParentDto() != null && !first.getParentDto().isBlank()) {
+ sb.append("**").append(MarkdownStyles.inlineCode(first.getParentDto())).append("**");
+ } else if (first.getBodyParamName() != null && !first.getBodyParamName().isBlank()) {
+ sb.append("**").append(MarkdownStyles.inlineCode(first.getBodyParamName())).append("**");
+ }
+ sb.append("\n\n");
+ for (ParameterChange change : group) {
+ sb.append(formatParameterLine(change)).append("\n\n");
+ }
+ }
+ }
+
+ private String formatParameterLine(ParameterChange change) {
+ String tag;
+ switch (change.getChangeType()) {
+ case ADDED:
+ tag = MarkdownStyles.colorInfo("[新增]");
+ break;
+ case REMOVED:
+ tag = MarkdownStyles.colorWarning("[删除]");
+ break;
+ case RENAMED:
+ tag = MarkdownStyles.colorWarning("[重命名]");
+ break;
+ case MODIFIED:
+ tag = MarkdownStyles.colorWarning("[类型变更]");
+ break;
+ default:
+ tag = MarkdownStyles.colorWarning("[修改]");
+ break;
+ }
+ String name = MarkdownStyles.inlineCode(MarkdownStyles.safe(change.displayName()));
+ String desc = change.getDescription() == null || change.getDescription().isBlank()
+ ? MarkdownStyles.colorComment("(无说明)")
+ : MarkdownStyles.colorComment(change.getDescription());
+ StringBuilder line = new StringBuilder();
+ if (change.getChangeType() == ParameterChange.ChangeType.RENAMED) {
+ line.append(tag).append(" ")
+ .append(MarkdownStyles.colorComment(MarkdownStyles.safe(change.getOldName()))).append(" → ")
+ .append(MarkdownStyles.colorInfo(MarkdownStyles.safe(change.getParamName())))
+ .append(" 说明: ").append(desc);
+ } else {
+ line.append(tag).append(" ").append(name).append(" 说明: ").append(desc);
+ }
+ appendParameterType(line, change);
+ return MarkdownStyles.quoteLine(line.toString());
+ }
+
+ private void appendParameterType(StringBuilder line, ParameterChange change) {
+ String typePart = resolveTypePart(change);
+ if (!typePart.isBlank()) {
+ line.append(" 类型: ").append(typePart);
+ }
+ }
+
+ private String formatUriWithMethod(String httpMethod, String uri, boolean isNew) {
+ String path = MarkdownStyles.inlineCode(MarkdownStyles.safe(uri));
+ if (httpMethod == null || httpMethod.isBlank()) {
+ return path;
+ }
+ String methodPart = isNew
+ ? MarkdownStyles.colorInfo(httpMethod.toUpperCase())
+ : MarkdownStyles.colorWarning(httpMethod.toUpperCase());
+ return methodPart + " " + path;
+ }
+
+ private String formatEndpointDescription(EndpointChangeReport report) {
+ String desc = report.getEndpointDescription();
+ if (desc == null || desc.isBlank()) {
+ return MarkdownStyles.colorComment("(无说明)");
+ }
+ return MarkdownStyles.colorComment(MarkdownStyles.safe(desc));
+ }
+
+ private String resolveTypePart(ParameterChange change) {
+ if (change.getChangeType() == ParameterChange.ChangeType.MODIFIED
+ && change.getDetail() != null && !change.getDetail().isBlank()) {
+ return MarkdownStyles.formatTypeChange(change.getDetail());
+ }
+ if (change.getParamType() != null && !change.getParamType().isBlank()) {
+ boolean isNew = change.getChangeType() == ParameterChange.ChangeType.ADDED
+ || change.getChangeType() == ParameterChange.ChangeType.RENAMED;
+ return MarkdownStyles.formatSingleType(change.getParamType(), isNew);
+ }
+ return "";
+ }
+}
diff --git a/src/main/java/com/codechecker/api/parser/EndpointSnapshotParser.java b/src/main/java/com/codechecker/api/parser/EndpointSnapshotParser.java
new file mode 100644
index 0000000..7a92204
--- /dev/null
+++ b/src/main/java/com/codechecker/api/parser/EndpointSnapshotParser.java
@@ -0,0 +1,313 @@
+package com.codechecker.api.parser;
+
+import com.codechecker.api.model.EndpointSnapshot;
+import com.codechecker.api.model.MethodParameterSnapshot;
+import com.codechecker.parser.TypeNameUtils;
+import com.github.javaparser.StaticJavaParser;
+import com.github.javaparser.ast.CompilationUnit;
+import com.github.javaparser.ast.NodeList;
+import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration;
+import com.github.javaparser.ast.body.MethodDeclaration;
+import com.github.javaparser.ast.body.Parameter;
+import com.github.javaparser.ast.body.TypeDeclaration;
+import com.github.javaparser.ast.expr.AnnotationExpr;
+import com.github.javaparser.ast.expr.Expression;
+import com.github.javaparser.ast.expr.NormalAnnotationExpr;
+import com.github.javaparser.ast.type.Type;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * 解析 Controller / Feign 接口完整快照(含入参明细)。
+ */
+public class EndpointSnapshotParser {
+ private static final Set MAPPING_ANNOTATIONS = Set.of(
+ "GetMapping", "PostMapping", "PutMapping", "DeleteMapping", "PatchMapping", "RequestMapping"
+ );
+ private static final Map MAPPING_DEFAULT_METHOD = Map.of(
+ "GetMapping", "GET",
+ "PostMapping", "POST",
+ "PutMapping", "PUT",
+ "DeleteMapping", "DELETE",
+ "PatchMapping", "PATCH"
+ );
+ private static final Set FRAMEWORK_PARAM_TYPES = Set.of(
+ "HttpServletRequest", "HttpServletResponse", "BindingResult", "Principal",
+ "Authentication", "Model", "ModelMap", "UriComponentsBuilder", "WebRequest",
+ "NativeWebRequest", "Errors", "Locale"
+ );
+
+ private final boolean excludeFrameworkParams;
+
+ public EndpointSnapshotParser(boolean excludeFrameworkParams) {
+ this.excludeFrameworkParams = excludeFrameworkParams;
+ }
+
+ public List parseSource(String source, String sourceFile, boolean feignMode) {
+ if (source == null || source.isBlank()) {
+ return List.of();
+ }
+ CompilationUnit cu = StaticJavaParser.parse(source);
+ List snapshots = new ArrayList<>();
+ for (TypeDeclaration> type : cu.getTypes()) {
+ if (!(type instanceof ClassOrInterfaceDeclaration)) {
+ continue;
+ }
+ ClassOrInterfaceDeclaration decl = (ClassOrInterfaceDeclaration) type;
+ if (feignMode && !isFeignClient(decl)) {
+ continue;
+ }
+ if (!feignMode && !isController(decl)) {
+ continue;
+ }
+ String basePath = feignMode
+ ? joinPaths(extractFeignBasePath(decl), extractTypeLevelPath(decl))
+ : extractTypeLevelPath(decl);
+ String className = decl.getNameAsString();
+ for (MethodDeclaration method : decl.getMethods()) {
+ if (feignMode && !decl.isInterface()) {
+ continue;
+ }
+ if (!feignMode && decl.isInterface()) {
+ continue;
+ }
+ snapshots.addAll(parseMethod(method, basePath, sourceFile, className));
+ }
+ }
+ return snapshots;
+ }
+
+ private List parseMethod(MethodDeclaration method, String basePath,
+ String sourceFile, String className) {
+ List result = new ArrayList<>();
+ for (AnnotationExpr annotation : method.getAnnotations()) {
+ String annName = annotation.getNameAsString();
+ if (!MAPPING_ANNOTATIONS.contains(annName)) {
+ continue;
+ }
+ List subPaths = readStringArray(annotation, "value", "path");
+ List httpMethods = extractHttpMethods(annotation, annName);
+ List params = extractParameters(method);
+ String methodDescription = MethodDescriptionExtractor.extract(method);
+ String fingerprint = EndpointSnapshot.buildFingerprint(sourceFile, method.getNameAsString());
+ for (String httpMethod : httpMethods) {
+ for (String subPath : subPaths) {
+ String uri = joinPaths(basePath, subPath);
+ result.add(new EndpointSnapshot(fingerprint, httpMethod, uri, sourceFile,
+ className, method.getNameAsString(), methodDescription, params));
+ }
+ }
+ }
+ return result;
+ }
+
+ private List extractParameters(MethodDeclaration method) {
+ Map paramDescriptions = MethodParamJavadocExtractor.extract(method);
+ List params = new ArrayList<>();
+ for (Parameter parameter : method.getParameters()) {
+ String typeName = TypeNameUtils.typeToString(parameter.getType());
+ String simple = TypeNameUtils.simpleName(typeName);
+ if (excludeFrameworkParams && FRAMEWORK_PARAM_TYPES.contains(simple)) {
+ continue;
+ }
+ String source = resolveParamSource(parameter);
+ String paramName = parameter.getNameAsString();
+ String bindingName = resolveBindingName(parameter, source, paramName);
+ boolean required = resolveRequired(parameter, source);
+ String dtoName = "body".equals(source) ? simple : "";
+ String description = paramDescriptions.getOrDefault(paramName, "");
+ params.add(new MethodParameterSnapshot(
+ paramName,
+ bindingName,
+ typeName,
+ source,
+ required,
+ description,
+ dtoName
+ ));
+ }
+ return params;
+ }
+
+ private String resolveBindingName(Parameter parameter, String source, String paramName) {
+ if (!"path".equals(source) && !"query".equals(source)) {
+ return paramName;
+ }
+ String annName = "path".equals(source) ? "PathVariable" : "RequestParam";
+ for (AnnotationExpr ann : parameter.getAnnotations()) {
+ if (!annName.equals(ann.getNameAsString())) {
+ continue;
+ }
+ List bindings = readStringArray(ann, "value", "name");
+ for (String binding : bindings) {
+ if (binding != null && !binding.isBlank()) {
+ return binding;
+ }
+ }
+ }
+ return paramName;
+ }
+
+ private String resolveParamSource(Parameter parameter) {
+ for (AnnotationExpr ann : parameter.getAnnotations()) {
+ String name = ann.getNameAsString();
+ if ("RequestBody".equals(name)) {
+ return "body";
+ }
+ if ("PathVariable".equals(name)) {
+ return "path";
+ }
+ if ("RequestParam".equals(name)) {
+ return "query";
+ }
+ }
+ return "simple";
+ }
+
+ private boolean resolveRequired(Parameter parameter, String source) {
+ if ("query".equals(source)) {
+ for (AnnotationExpr ann : parameter.getAnnotations()) {
+ if ("RequestParam".equals(ann.getNameAsString()) && ann.isNormalAnnotationExpr()) {
+ for (var pair : ann.asNormalAnnotationExpr().getPairs()) {
+ if ("required".equals(pair.getNameAsString())) {
+ return !"false".equalsIgnoreCase(pair.getValue().toString().trim());
+ }
+ }
+ }
+ }
+ }
+ return !"query".equals(source);
+ }
+
+ private boolean isController(ClassOrInterfaceDeclaration decl) {
+ return decl.getAnnotations().stream()
+ .anyMatch(ann -> {
+ String n = ann.getNameAsString();
+ return "RestController".equals(n) || "Controller".equals(n);
+ });
+ }
+
+ private boolean isFeignClient(ClassOrInterfaceDeclaration decl) {
+ return decl.isInterface() && decl.getAnnotations().stream()
+ .anyMatch(ann -> "FeignClient".equals(ann.getNameAsString()));
+ }
+
+ private String extractTypeLevelPath(ClassOrInterfaceDeclaration decl) {
+ for (AnnotationExpr annotation : decl.getAnnotations()) {
+ if ("RequestMapping".equals(annotation.getNameAsString())) {
+ List paths = readStringArray(annotation, "value", "path");
+ if (!paths.isEmpty()) {
+ return paths.get(0);
+ }
+ }
+ }
+ return "";
+ }
+
+ private String extractFeignBasePath(ClassOrInterfaceDeclaration decl) {
+ for (AnnotationExpr annotation : decl.getAnnotations()) {
+ if ("FeignClient".equals(annotation.getNameAsString())) {
+ List paths = readStringArray(annotation, "path");
+ if (!paths.isEmpty()) {
+ return paths.get(0);
+ }
+ }
+ }
+ return "";
+ }
+
+ private List extractHttpMethods(AnnotationExpr annotation, String annName) {
+ if (!"RequestMapping".equals(annName)) {
+ return List.of(MAPPING_DEFAULT_METHOD.getOrDefault(annName, "GET"));
+ }
+ List methods = readEnumArray(annotation, "method");
+ return methods.isEmpty() ? List.of("GET") : methods;
+ }
+
+ private String joinPaths(String base, String sub) {
+ String normalizedBase = normalizePath(base);
+ String normalizedSub = normalizePath(sub);
+ if (normalizedBase.isEmpty()) {
+ return normalizedSub.isEmpty() ? "/" : normalizedSub;
+ }
+ if (normalizedSub.isEmpty()) {
+ return normalizedBase;
+ }
+ return (normalizedBase + "/" + normalizedSub.substring(1)).replaceAll("/+", "/");
+ }
+
+ private String normalizePath(String path) {
+ if (path == null || path.isBlank()) {
+ return "";
+ }
+ String trimmed = path.trim();
+ if (!trimmed.startsWith("/")) {
+ trimmed = "/" + trimmed;
+ }
+ return trimmed.replaceAll("/+", "/");
+ }
+
+ private List readStringArray(AnnotationExpr annotation, String... keys) {
+ NodeList> values = readArrayValues(annotation, keys);
+ List result = new ArrayList<>();
+ for (Object value : values) {
+ String text = value.toString().replace("\"", "").trim();
+ if (!text.isBlank()) {
+ result.add(text);
+ }
+ }
+ if (result.isEmpty()) {
+ result.add("");
+ }
+ return result;
+ }
+
+ private List readEnumArray(AnnotationExpr annotation, String key) {
+ NodeList> values = readArrayValues(annotation, key);
+ List result = new ArrayList<>();
+ for (Object value : values) {
+ String text = value.toString().trim();
+ if (text.contains(".")) {
+ text = text.substring(text.lastIndexOf('.') + 1);
+ }
+ result.add(text.toUpperCase(Locale.ROOT));
+ }
+ return result;
+ }
+
+ private NodeList> readArrayValues(AnnotationExpr annotation, String... keys) {
+ if (annotation.isSingleMemberAnnotationExpr()) {
+ Expression value = annotation.asSingleMemberAnnotationExpr().getMemberValue();
+ if (value.isArrayInitializerExpr()) {
+ return value.asArrayInitializerExpr().getValues();
+ }
+ return new NodeList<>(value);
+ }
+ if (annotation.isNormalAnnotationExpr()) {
+ var pairs = annotation.asNormalAnnotationExpr().getPairs();
+ for (var pair : pairs) {
+ for (String key : keys) {
+ if (pair.getNameAsString().equals(key)) {
+ if (pair.getValue().isArrayInitializerExpr()) {
+ return pair.getValue().asArrayInitializerExpr().getValues();
+ }
+ return new NodeList<>(pair.getValue());
+ }
+ }
+ }
+ for (var pair : pairs) {
+ if ("value".equals(pair.getNameAsString())) {
+ if (pair.getValue().isArrayInitializerExpr()) {
+ return pair.getValue().asArrayInitializerExpr().getValues();
+ }
+ return new NodeList<>(pair.getValue());
+ }
+ }
+ }
+ return new NodeList<>();
+ }
+}
diff --git a/src/main/java/com/codechecker/api/parser/JavaSourceLocator.java b/src/main/java/com/codechecker/api/parser/JavaSourceLocator.java
new file mode 100644
index 0000000..80cf6bc
--- /dev/null
+++ b/src/main/java/com/codechecker/api/parser/JavaSourceLocator.java
@@ -0,0 +1,68 @@
+package com.codechecker.api.parser;
+
+import com.codechecker.git.GitChangeScanner;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Optional;
+import java.util.stream.Stream;
+
+/**
+ * 按简单类名在仓库中定位 .java 源文件。
+ */
+public class JavaSourceLocator {
+ private final Path repoRoot;
+ private final List searchDirs;
+
+ public JavaSourceLocator(Path repoRoot, List searchDirs) {
+ this.repoRoot = repoRoot;
+ this.searchDirs = searchDirs;
+ }
+
+ public Optional readSourceBySimpleName(String simpleClassName) throws IOException {
+ Optional path = findFile(simpleClassName);
+ if (path.isEmpty()) {
+ return Optional.empty();
+ }
+ return Optional.of(Files.readString(path.get()));
+ }
+
+ public Optional readSourceAtCommit(GitChangeScanner gitScanner, String sha,
+ String simpleClassName) throws IOException {
+ Optional 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 findRelativePath(String simpleClassName) throws IOException {
+ Optional path = findFile(simpleClassName);
+ return path.map(p -> repoRoot.relativize(p).toString().replace('\\', '/'));
+ }
+
+ public Optional findFile(String simpleClassName) throws IOException {
+ String fileName = simpleClassName + ".java";
+ for (String dir : searchDirs) {
+ Path root = repoRoot.resolve(dir.replace('\\', '/'));
+ if (!Files.exists(root)) {
+ continue;
+ }
+ try (Stream walk = Files.walk(root)) {
+ Optional found = walk
+ .filter(p -> p.getFileName().toString().equals(fileName))
+ .findFirst();
+ if (found.isPresent()) {
+ return found;
+ }
+ }
+ }
+ return Optional.empty();
+ }
+}
diff --git a/src/main/java/com/codechecker/api/parser/MethodDescriptionExtractor.java b/src/main/java/com/codechecker/api/parser/MethodDescriptionExtractor.java
new file mode 100644
index 0000000..bbea177
--- /dev/null
+++ b/src/main/java/com/codechecker/api/parser/MethodDescriptionExtractor.java
@@ -0,0 +1,74 @@
+package com.codechecker.api.parser;
+
+import com.github.javaparser.ast.body.MethodDeclaration;
+import com.github.javaparser.ast.comments.JavadocComment;
+import com.github.javaparser.ast.expr.AnnotationExpr;
+import com.github.javaparser.ast.expr.Expression;
+import com.github.javaparser.ast.expr.NormalAnnotationExpr;
+import com.github.javaparser.ast.expr.SingleMemberAnnotationExpr;
+
+import java.util.Optional;
+
+/**
+ * 提取接口方法中文说明:@Operation(summary) > @Operation(description) > Javadoc 首段。
+ */
+public final class MethodDescriptionExtractor {
+ private MethodDescriptionExtractor() {
+ }
+
+ public static String extract(MethodDeclaration method) {
+ if (method == null) {
+ return "";
+ }
+ for (AnnotationExpr annotation : method.getAnnotations()) {
+ if (!"Operation".equals(annotation.getNameAsString())) {
+ continue;
+ }
+ String summary = readAnnotationStringValue(annotation, "summary");
+ if (!summary.isEmpty()) {
+ return summary;
+ }
+ String description = readAnnotationStringValue(annotation, "description");
+ if (!description.isEmpty()) {
+ return description;
+ }
+ }
+ return extractMethodJavadoc(method);
+ }
+
+ private static String extractMethodJavadoc(MethodDeclaration method) {
+ Optional javadoc = method.getJavadocComment();
+ if (javadoc.isEmpty()) {
+ return "";
+ }
+ String text = javadoc.get().parse().getDescription().toText();
+ return text == null ? "" : text.trim().replaceAll("\\s+", " ");
+ }
+
+ private static String readAnnotationStringValue(AnnotationExpr annotation, String attributeName) {
+ if (annotation.isNormalAnnotationExpr()) {
+ NormalAnnotationExpr normal = annotation.asNormalAnnotationExpr();
+ for (var pair : normal.getPairs()) {
+ if (pair.getNameAsString().equals(attributeName)) {
+ return literalString(pair.getValue());
+ }
+ }
+ return "";
+ }
+ if (annotation.isSingleMemberAnnotationExpr()) {
+ SingleMemberAnnotationExpr single = annotation.asSingleMemberAnnotationExpr();
+ if ("value".equals(attributeName) || "description".equals(attributeName)
+ || "summary".equals(attributeName)) {
+ return literalString(single.getMemberValue());
+ }
+ }
+ return "";
+ }
+
+ private static String literalString(Expression expression) {
+ if (expression.isStringLiteralExpr()) {
+ return expression.asStringLiteralExpr().getValue().trim();
+ }
+ return "";
+ }
+}
diff --git a/src/main/java/com/codechecker/api/parser/MethodParamJavadocExtractor.java b/src/main/java/com/codechecker/api/parser/MethodParamJavadocExtractor.java
new file mode 100644
index 0000000..a3dbcd7
--- /dev/null
+++ b/src/main/java/com/codechecker/api/parser/MethodParamJavadocExtractor.java
@@ -0,0 +1,67 @@
+package com.codechecker.api.parser;
+
+import com.github.javaparser.ast.body.MethodDeclaration;
+import com.github.javaparser.ast.comments.JavadocComment;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * 从方法 Javadoc 的 @param 标签提取形参说明(按 Java 形参名匹配)。
+ */
+public final class MethodParamJavadocExtractor {
+ private static final Pattern PARAM_TAG = Pattern.compile(
+ "@param\\s+(\\w+)\\s+(.+?)(?=\\s*@param\\s+|\\s*@return\\s+|\\s*@throws\\s+|\\s*@see\\s+|\\s*\\*/|$)",
+ Pattern.DOTALL);
+
+ private MethodParamJavadocExtractor() {
+ }
+
+ public static Map extract(MethodDeclaration method) {
+ Map descriptions = new HashMap<>();
+ if (method == null) {
+ return descriptions;
+ }
+ Optional javadoc = method.getJavadocComment();
+ if (javadoc.isEmpty()) {
+ return descriptions;
+ }
+ String raw = javadoc.get().getContent();
+ if (raw == null || raw.isBlank()) {
+ return descriptions;
+ }
+ String normalized = raw.replace('\r', '\n');
+ Matcher matcher = PARAM_TAG.matcher(normalized);
+ while (matcher.find()) {
+ String paramName = matcher.group(1).trim();
+ String desc = cleanDescription(matcher.group(2));
+ if (!paramName.isBlank()) {
+ descriptions.put(paramName, desc);
+ }
+ }
+ return descriptions;
+ }
+
+ private static String cleanDescription(String text) {
+ if (text == null) {
+ return "";
+ }
+ StringBuilder sb = new StringBuilder();
+ for (String line : text.split("\n")) {
+ String trimmed = line.trim();
+ if (trimmed.startsWith("*")) {
+ trimmed = trimmed.substring(1).trim();
+ }
+ if (!trimmed.isEmpty()) {
+ if (sb.length() > 0) {
+ sb.append(' ');
+ }
+ sb.append(trimmed);
+ }
+ }
+ return sb.toString().trim();
+ }
+}
diff --git a/src/main/java/com/codechecker/api/parser/NestedDtoFieldParser.java b/src/main/java/com/codechecker/api/parser/NestedDtoFieldParser.java
new file mode 100644
index 0000000..d44603f
--- /dev/null
+++ b/src/main/java/com/codechecker/api/parser/NestedDtoFieldParser.java
@@ -0,0 +1,99 @@
+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;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+
+/**
+ * 递归展开 Dto/Vo 嵌套字段(dot path),与类变更字段解析解耦但复用 ClassFieldParser。
+ */
+public class NestedDtoFieldParser {
+ private static final Set LEAF_TYPES = Set.of(
+ "String", "Integer", "int", "Long", "long", "Boolean", "boolean", "Double", "double",
+ "Float", "float", "Short", "short", "Byte", "byte", "Character", "char",
+ "BigDecimal", "BigInteger", "Date", "LocalDate", "LocalDateTime", "LocalTime",
+ "Instant", "Timestamp", "Object", "Void", "void"
+ );
+
+ private final ClassFieldParser classFieldParser = new ClassFieldParser();
+ private final JavaSourceLocator sourceLocator;
+ private final GitChangeScanner gitScanner;
+ private final String oldSha;
+ private final String newSha;
+ private final int maxDepth;
+
+ public NestedDtoFieldParser(Path repoRoot, List searchDirs,
+ GitChangeScanner gitScanner, String oldSha, String newSha, int maxDepth) {
+ this.sourceLocator = new JavaSourceLocator(repoRoot, searchDirs);
+ this.gitScanner = gitScanner;
+ this.oldSha = oldSha;
+ this.newSha = newSha;
+ this.maxDepth = maxDepth;
+ }
+
+ public List parseNestedFieldsAtOldCommit(String dtoClassName) throws IOException {
+ return parseNestedFields(dtoClassName, oldSha);
+ }
+
+ public List parseNestedFieldsAtNewCommit(String dtoClassName) throws IOException {
+ return parseNestedFields(dtoClassName, newSha);
+ }
+
+ private List parseNestedFields(String dtoClassName, String sha) throws IOException {
+ Set visiting = new HashSet<>();
+ List result = new ArrayList<>();
+ collectFields(dtoClassName, "", visiting, result, sha, 1);
+ return result;
+ }
+
+ private void collectFields(String className, String prefix, Set visiting,
+ List out, String sha, int depth) throws IOException {
+ if (className == null || className.isBlank() || visiting.contains(className) || depth > maxDepth) {
+ return;
+ }
+ visiting.add(className);
+ Optional source = readSource(className, sha);
+ if (source.isEmpty()) {
+ visiting.remove(className);
+ return;
+ }
+ List fields = classFieldParser.parseFields(source.get(), className);
+ for (FieldInfo field : fields) {
+ String path = prefix.isBlank() ? field.getName() : prefix + "." + field.getName();
+ Set nestedTypes = TypeNameUtils.peelDirectTypeNames(field.getType());
+ boolean expanded = false;
+ for (String nestedType : nestedTypes) {
+ if (isLeafType(nestedType) || nestedType.equals(className)) {
+ continue;
+ }
+ expanded = true;
+ // 嵌套字段路径用类型简单类名(如 UserSelfDto.nickName),不用成员名(userDtos.nickName)
+ collectFields(nestedType, nestedType, visiting, out, sha, depth + 1);
+ }
+ if (!expanded) {
+ out.add(new NestedFieldInfo(path, field.getType(), field.getDescription()));
+ }
+ }
+ visiting.remove(className);
+ }
+
+ private Optional 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("[]");
+ }
+}
diff --git a/src/main/java/com/codechecker/api/parser/NestedFieldInfo.java b/src/main/java/com/codechecker/api/parser/NestedFieldInfo.java
new file mode 100644
index 0000000..870817e
--- /dev/null
+++ b/src/main/java/com/codechecker/api/parser/NestedFieldInfo.java
@@ -0,0 +1,28 @@
+package com.codechecker.api.parser;
+
+/**
+ * DTO 嵌套字段扁平化条目(dot path)。
+ */
+public class NestedFieldInfo {
+ private final String path;
+ private final String type;
+ private final String description;
+
+ public NestedFieldInfo(String path, String type, String description) {
+ this.path = path;
+ this.type = type;
+ this.description = description;
+ }
+
+ public String getPath() {
+ return path;
+ }
+
+ public String getType() {
+ return type;
+ }
+
+ public String getDescription() {
+ return description;
+ }
+}
diff --git a/src/main/java/com/codechecker/api/scanner/ApiFileChangeScanner.java b/src/main/java/com/codechecker/api/scanner/ApiFileChangeScanner.java
new file mode 100644
index 0000000..40b3686
--- /dev/null
+++ b/src/main/java/com/codechecker/api/scanner/ApiFileChangeScanner.java
@@ -0,0 +1,56 @@
+package com.codechecker.api.scanner;
+
+import com.codechecker.git.GitChangeScanner;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * 扫描 API 相关 Java 文件变更(Controller / Feign),与类变更扫描解耦。
+ */
+public class ApiFileChangeScanner {
+ private final GitChangeScanner gitScanner;
+
+ public ApiFileChangeScanner(GitChangeScanner gitScanner) {
+ this.gitScanner = gitScanner;
+ }
+
+ /** 返回两次提交间变更的 .java 相对路径(位于 scanDirs 下) */
+ public List scanChangedFiles(Path repoRoot, List scanDirs,
+ String oldSha, String newSha) throws IOException {
+ Set changed = new LinkedHashSet<>();
+ List diffLines = gitScanner.diffNameOnly(oldSha, newSha);
+ for (String line : diffLines) {
+ String path = normalize(line);
+ if (!path.endsWith(".java")) {
+ continue;
+ }
+ if (isUnderScanDirs(path, scanDirs)) {
+ changed.add(path);
+ }
+ }
+ return new ArrayList<>(changed);
+ }
+
+ private boolean isUnderScanDirs(String relativePath, List scanDirs) {
+ String normalized = relativePath.replace('\\', '/');
+ for (String dir : scanDirs) {
+ String prefix = dir.replace('\\', '/');
+ if (!prefix.endsWith("/")) {
+ prefix = prefix + "/";
+ }
+ if (normalized.startsWith(prefix)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private String normalize(String path) {
+ return path.replace('\\', '/').trim();
+ }
+}
diff --git a/src/main/java/com/codechecker/common/MarkdownStyles.java b/src/main/java/com/codechecker/common/MarkdownStyles.java
new file mode 100644
index 0000000..9a579f7
--- /dev/null
+++ b/src/main/java/com/codechecker/common/MarkdownStyles.java
@@ -0,0 +1,62 @@
+package com.codechecker.common;
+
+/**
+ * 企微 Markdown v1 公共样式(类变更 / API 变更通知共用)。
+ */
+public final class MarkdownStyles {
+ private MarkdownStyles() {
+ }
+
+ public static String colorInfo(String text) {
+ return "" + text + "";
+ }
+
+ public static String colorComment(String text) {
+ return "" + safe(text) + "";
+ }
+
+ public static String colorWarning(String text) {
+ return "" + text + "";
+ }
+
+ public static String quoteKvBold(String key, String value) {
+ return "> **" + key + ": " + value + "**";
+ }
+
+ public static String quoteKv(String key, String value) {
+ return "> " + key + ": " + value;
+ }
+
+ public static String quoteLine(String content) {
+ return "> " + content;
+ }
+
+ public static String inlineCode(String text) {
+ return "`" + text.replace("`", "'") + "`";
+ }
+
+ /** 类型展示:泛型尖括号不转义 */
+ public static String formatTypeChange(String detail) {
+ int arrow = detail.indexOf(" → ");
+ if (arrow < 0) {
+ return colorWarning(detail);
+ }
+ String oldType = detail.substring(0, arrow).trim();
+ String newType = detail.substring(arrow + 3).trim();
+ return colorWarning(oldType) + " → " + colorInfo(newType);
+ }
+
+ public static String formatSingleType(String type, boolean isNew) {
+ if (type == null || type.isBlank()) {
+ return "";
+ }
+ return isNew ? colorInfo(type) : colorWarning(type);
+ }
+
+ public static String safe(String text) {
+ if (text == null) {
+ return "";
+ }
+ return text.replace("&", "&").replace("<", "<").replace(">", ">");
+ }
+}
diff --git a/src/main/java/com/codechecker/common/WeComMarkdownSender.java b/src/main/java/com/codechecker/common/WeComMarkdownSender.java
new file mode 100644
index 0000000..3d6c56c
--- /dev/null
+++ b/src/main/java/com/codechecker/common/WeComMarkdownSender.java
@@ -0,0 +1,75 @@
+package com.codechecker.common;
+
+import okhttp3.MediaType;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.RequestBody;
+import okhttp3.Response;
+
+import java.io.IOException;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 企微 Markdown 发送(与具体变更类型解耦)。
+ */
+public class WeComMarkdownSender {
+ private static final int MAX_LENGTH = 3800;
+ private static final MediaType JSON = MediaType.get("application/json; charset=utf-8");
+
+ private final OkHttpClient client = new OkHttpClient.Builder()
+ .connectTimeout(10, TimeUnit.SECONDS)
+ .readTimeout(10, TimeUnit.SECONDS)
+ .build();
+
+ public boolean send(String webhookUrl, String content) {
+ return postMarkdown(webhookUrl, truncate(content));
+ }
+
+ public void logPreview(String title, String content) {
+ System.out.println("========== " + title + " ==========");
+ System.out.println(content);
+ System.out.println("========== 结束 ==========");
+ }
+
+ private boolean postMarkdown(String webhookUrl, String content) {
+ if (webhookUrl == null || webhookUrl.isBlank() || webhookUrl.contains("YOUR_WECOM")) {
+ System.out.println("[警告] 未配置有效的企业微信 Webhook URL");
+ System.out.println("--- 通知预览 ---");
+ System.out.println(content.length() > 1000 ? content.substring(0, 1000) : content);
+ return false;
+ }
+
+ String payload = "{\"msgtype\":\"markdown\",\"markdown\":{\"content\":"
+ + jsonEscape(content) + "}}";
+ Request request = new Request.Builder()
+ .url(webhookUrl)
+ .post(RequestBody.create(payload, JSON))
+ .build();
+ try (Response response = client.newCall(request).execute()) {
+ if (response.isSuccessful() && response.body() != null) {
+ return response.body().string().contains("\"errcode\":0");
+ }
+ System.out.println("[错误] 企微返回异常: " + response.code());
+ return false;
+ } catch (IOException e) {
+ System.out.println("[错误] 发送企微消息失败: " + e.getMessage());
+ return false;
+ }
+ }
+
+ private String truncate(String text) {
+ if (text.length() <= MAX_LENGTH) {
+ return text;
+ }
+ return text.substring(0, MAX_LENGTH) + "\n\n... 消息过长,已截断";
+ }
+
+ private String jsonEscape(String text) {
+ String escaped = text
+ .replace("\\", "\\\\")
+ .replace("\"", "\\\"")
+ .replace("\n", "\\n")
+ .replace("\r", "");
+ return "\"" + escaped + "\"";
+ }
+}
diff --git a/src/main/java/com/codechecker/config/AppConfig.java b/src/main/java/com/codechecker/config/AppConfig.java
new file mode 100644
index 0000000..6b35058
--- /dev/null
+++ b/src/main/java/com/codechecker/config/AppConfig.java
@@ -0,0 +1,239 @@
+package com.codechecker.config;
+
+import org.yaml.snakeyaml.Yaml;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 读取 .gitea/workflows/code-check-config.yaml,提供检测开关、扫描目录、企微配置等。
+ */
+public class AppConfig {
+ private boolean masterEnabled = true;
+ private boolean classCheckEnabled = true;
+ private boolean dtoEntityConversionEnabled = true;
+ private List modelDirs = new ArrayList<>();
+ private List controllerScanDirs = new ArrayList<>();
+ private List feignScanDirs = new ArrayList<>();
+ private List conversionScanDirs = new ArrayList<>();
+ private String wecomWebhookUrl = "";
+ private boolean wecomEnabled = true;
+ private boolean onlyOnChange = true;
+
+ private boolean dtoApiFollowUpEnabled = true;
+ private int nestMaxDepth = 3;
+ private boolean apiCheckEnabled = true;
+ private boolean apiExcludeFrameworkParams = true;
+ private List apiControllerScanDirs = new ArrayList<>();
+ private List apiFeignScanDirs = new ArrayList<>();
+ private DtoOverlapMode dtoOverlapMode = DtoOverlapMode.BOTH;
+
+ /** 从 YAML 文件加载配置 */
+ @SuppressWarnings("unchecked")
+ public static AppConfig load(Path configPath) throws IOException {
+ Yaml yaml = new Yaml();
+ Map root;
+ try (InputStream in = Files.newInputStream(configPath)) {
+ root = yaml.load(in);
+ }
+ if (root == null) {
+ root = Map.of();
+ }
+
+ AppConfig config = new AppConfig();
+ Map checker = mapOrEmpty(root.get("checker"));
+ config.masterEnabled = boolOrDefault(checker.get("enabled"), true);
+
+ Map classCheck = mapOrEmpty(root.get("class_check"));
+ config.classCheckEnabled = boolOrDefault(classCheck.get("enabled"), true);
+
+ Map dtoApiFollowUp = mapOrEmpty(classCheck.get("dto_api_follow_up"));
+ config.dtoApiFollowUpEnabled = boolOrDefault(dtoApiFollowUp.get("enabled"), true);
+
+ Map nestIndex = mapOrEmpty(classCheck.get("nest_index"));
+ config.nestMaxDepth = intOrDefault(nestIndex.get("max_depth"), 3);
+
+ Map conversion = mapOrEmpty(classCheck.get("dto_entity_conversion"));
+ config.dtoEntityConversionEnabled = boolOrDefault(conversion.get("enabled"), true);
+
+ config.modelDirs = stringList(classCheck.get("model_dirs"));
+ Map endpointScan = mapOrEmpty(classCheck.get("endpoint_scan"));
+ config.controllerScanDirs = stringList(endpointScan.get("controllers"));
+ config.feignScanDirs = stringList(endpointScan.get("feign_apis"));
+ config.conversionScanDirs = stringList(classCheck.get("conversion_scan"));
+
+ Map wecom = mapOrEmpty(root.get("wecom"));
+ config.wecomWebhookUrl = stringOrEmpty(wecom.get("webhook_url"));
+ config.wecomEnabled = boolOrDefault(wecom.get("enabled"), true);
+
+ Map 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 apiCheck = mapOrEmpty(root.get("api_check"));
+ config.apiCheckEnabled = boolOrDefault(apiCheck.get("enabled"), true);
+ config.apiExcludeFrameworkParams = boolOrDefault(apiCheck.get("exclude_framework_params"), true);
+ Map apiEndpointScan = mapOrEmpty(apiCheck.get("endpoint_scan"));
+ config.apiControllerScanDirs = stringList(apiEndpointScan.get("controllers"));
+ config.apiFeignScanDirs = stringList(apiEndpointScan.get("feign_apis"));
+ if (config.apiControllerScanDirs.isEmpty()) {
+ config.apiControllerScanDirs = new ArrayList<>(config.controllerScanDirs);
+ }
+ if (config.apiFeignScanDirs.isEmpty()) {
+ config.apiFeignScanDirs = new ArrayList<>(config.feignScanDirs);
+ }
+
+ return config;
+ }
+
+ /** 安全转为 Map,非 Map 则返回空 Map */
+ @SuppressWarnings("unchecked")
+ private static Map mapOrEmpty(Object value) {
+ if (value instanceof Map) {
+ return (Map) value;
+ }
+ return Map.of();
+ }
+
+ /** 安全转为字符串列表 */
+ @SuppressWarnings("unchecked")
+ private static List stringList(Object value) {
+ if (value instanceof List) {
+ List> list = (List>) value;
+ List result = new ArrayList<>();
+ for (Object item : list) {
+ if (item != null) {
+ result.add(item.toString());
+ }
+ }
+ return result;
+ }
+ return new ArrayList<>();
+ }
+
+ /** 安全转为 boolean,缺省用 defaultValue */
+ private static boolean boolOrDefault(Object value, boolean defaultValue) {
+ if (value instanceof Boolean) {
+ return (Boolean) value;
+ }
+ return defaultValue;
+ }
+
+ /** 安全转为 int,缺省用 defaultValue */
+ private static int intOrDefault(Object value, int defaultValue) {
+ if (value instanceof Number) {
+ return ((Number) value).intValue();
+ }
+ if (value != null) {
+ try {
+ return Integer.parseInt(value.toString().trim());
+ } catch (NumberFormatException ignored) {
+ // 使用默认值
+ }
+ }
+ return defaultValue;
+ }
+
+ /** 安全转为字符串,null 则空串 */
+ private static String stringOrEmpty(Object value) {
+ return value == null ? "" : value.toString();
+ }
+
+ /** 变更检测总开关(checker.enabled,控制 class_check + api_check) */
+ public boolean isMasterEnabled() {
+ return masterEnabled;
+ }
+
+ /** 类变更检测开关(class_check.enabled) */
+ public boolean isClassCheckEnabled() {
+ return classCheckEnabled;
+ }
+
+ /** Dto→Entity 类转换检测开关 */
+ public boolean isDtoEntityConversionEnabled() {
+ return dtoEntityConversionEnabled;
+ }
+
+ /** 模型类目录(预留,当前扫描仍按类名后缀) */
+ public List getModelDirs() {
+ return modelDirs;
+ }
+
+ /** Controller 扫描目录 */
+ public List getControllerScanDirs() {
+ return controllerScanDirs;
+ }
+
+ /** Feign 接口扫描目录 */
+ public List