commit ebf30399fb785bed87be5fb73574c8950a847a46 Author: dongzi Date: Tue Jun 9 17:51:03 2026 +0800 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c299960 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +# ---> Java +# Compiled class file +*.class + +# Log file +*.log + +# BlueJ files +*.ctxt + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.jar +!.gitea/workflows/code-checker.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..30cf57e --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/dependency-reduced-pom.xml b/dependency-reduced-pom.xml new file mode 100644 index 0000000..bfdcb07 --- /dev/null +++ b/dependency-reduced-pom.xml @@ -0,0 +1,51 @@ + + + 4.0.0 + com.codechecker + code-checker + 1.0.0 + + code-checker + + + maven-compiler-plugin + 3.13.0 + + + maven-shade-plugin + 3.6.0 + + + package + + shade + + + + + com.codechecker.CodeCheckMain + + + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + + + + + + + + + + + 11 + 3.25.10 + 11 + UTF-8 + + diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..6912686 --- /dev/null +++ b/pom.xml @@ -0,0 +1,82 @@ + + + 4.0.0 + + com.codechecker + code-checker + 1.0.0 + jar + + + 11 + 11 + UTF-8 + 3.25.10 + + + + + com.github.javaparser + javaparser-symbol-solver-core + ${javaparser.version} + + + org.yaml + snakeyaml + 2.2 + + + com.squareup.okhttp3 + okhttp + 4.12.0 + + + info.picocli + picocli + 4.7.6 + + + + + code-checker + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + + org.apache.maven.plugins + maven-shade-plugin + 3.6.0 + + + package + + shade + + + + + com.codechecker.CodeCheckMain + + + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + + + + + + + + + + diff --git a/src/main/java/com/codechecker/CodeCheckMain.java b/src/main/java/com/codechecker/CodeCheckMain.java new file mode 100644 index 0000000..f26ae68 --- /dev/null +++ b/src/main/java/com/codechecker/CodeCheckMain.java @@ -0,0 +1,178 @@ +package com.codechecker; + +import com.codechecker.analyzer.ClassChangeAnalyzer; +import com.codechecker.analyzer.DtoNestIndex; +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; + +/** + * CLI 入口:加载配置 → 扫描 git 变更 → 分析影响 → 输出/发送企微通知。 + */ +@Command(name = "code-checker", mixinStandardHelpOptions = true, + description = "检测类变更与 API 变更并发送企业微信通知") +public class CodeCheckMain implements Callable { + @Option(names = "--config", required = true, description = "配置文件路径") + private Path config; + + @Option(names = "--repo-root", required = true, description = "仓库根目录") + private Path repoRoot; + + @Option(names = "--old-sha", required = true, description = "旧提交 SHA") + private String oldSha; + + @Option(names = "--new-sha", required = true, description = "新提交 SHA") + private String newSha; + + @Option(names = "--modifier", required = true, description = "修改人") + private String modifier; + + @Option(names = "--modify-time", required = true, description = "修改时间") + private String modifyTime; + + /** 程序入口 */ + public static void main(String[] args) { + int exitCode = new CommandLine(new CodeCheckMain()).execute(args); + System.exit(exitCode); + } + + /** 主流程:类变更与 API 变更检测,支持 Dto 跟进与重叠通知策略 */ + @Override + public Integer call() throws Exception { + AppConfig appConfig = AppConfig.load(config.toAbsolutePath()); + if (!appConfig.isMasterEnabled()) { + System.out.println("变更检测已全部关闭(checker.enabled=false)"); + return 0; + } + + GitChangeScanner gitScanner = new GitChangeScanner(repoRoot.toAbsolutePath()); + DtoNestIndex nestIndex = DtoNestIndex.build(repoRoot.toAbsolutePath(), appConfig); + List classReports = List.of(); + List apiReports = List.of(); + + if (appConfig.isClassCheckEnabled()) { + classReports = analyzeClassChanges(appConfig, gitScanner, nestIndex); + } else { + System.out.println("类变更检测已关闭(class_check.enabled=false)"); + } + + if (appConfig.isApiCheckEnabled()) { + apiReports = analyzeApiChanges(appConfig, gitScanner, classReports, nestIndex); + } else { + System.out.println("API 变更检测已关闭(api_check.enabled=false)"); + } + + OverlapNotificationFilter.FilterResult filtered = OverlapNotificationFilter.apply( + classReports, apiReports, appConfig.getDtoOverlapMode(), nestIndex); + int totalSent = sendClassNotifications(appConfig, filtered.classReports()) + + sendApiNotifications(appConfig, filtered.apiReports()); + + if (totalSent == 0 && appConfig.isOnlyOnChange()) { + System.out.println("无变更,静默退出"); + } + return 0; + } + + private List analyzeClassChanges(AppConfig appConfig, GitChangeScanner gitScanner, + DtoNestIndex nestIndex) throws Exception { + System.out.println("=== 类变更检测 ==="); + EndpointIndexBuilder indexBuilder = new EndpointIndexBuilder(); + Map endpointIndex = indexBuilder.buildIndex(repoRoot.toAbsolutePath(), appConfig); + System.out.println("已索引接口数量: " + endpointIndex.size()); + + ClassChangeAnalyzer analyzer = new ClassChangeAnalyzer(gitScanner); + List reports = analyzer.analyze( + repoRoot.toAbsolutePath(), appConfig, oldSha, newSha, endpointIndex, nestIndex); + System.out.println("检测到需通知的类变更数量: " + reports.size()); + return reports; + } + + private List analyzeApiChanges(AppConfig appConfig, GitChangeScanner gitScanner, + List classReports, + DtoNestIndex nestIndex) throws Exception { + System.out.println("=== API 变更检测 ==="); + ApiFileChangeScanner fileScanner = new ApiFileChangeScanner(gitScanner); + Set changedApiFiles = new LinkedHashSet<>(fileScanner.scanChangedFiles( + repoRoot.toAbsolutePath(), appConfig.getAllApiScanDirs(), oldSha, newSha)); + + ApiChangeAnalyzer analyzer = new ApiChangeAnalyzer(gitScanner); + List 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 followUpReports = dtoAnalyzer.analyze( + repoRoot.toAbsolutePath(), appConfig, oldSha, newSha, classReports, changedApiFiles, nestIndex); + 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 dedupeApiReports(List reports) { + Map 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 reports) { + if (reports.isEmpty()) { + return 0; + } + WeComNotifier notifier = new WeComNotifier(); + if (appConfig.isWecomEnabled()) { + return notifier.sendAll(appConfig.getWecomWebhookUrl(), reports, modifier, modifyTime); + } + notifier.logAll(reports, modifier, modifyTime); + return reports.size(); + } + + private int sendApiNotifications(AppConfig appConfig, List reports) { + if (reports.isEmpty()) { + return 0; + } + ApiChangeNotifier notifier = new ApiChangeNotifier(); + return notifier.sendAll(appConfig.getWecomWebhookUrl(), reports, modifier, modifyTime, + appConfig.isWecomEnabled()); + } +} diff --git a/src/main/java/com/codechecker/analyzer/ClassChangeAnalyzer.java b/src/main/java/com/codechecker/analyzer/ClassChangeAnalyzer.java new file mode 100644 index 0000000..97cd605 --- /dev/null +++ b/src/main/java/com/codechecker/analyzer/ClassChangeAnalyzer.java @@ -0,0 +1,131 @@ +package com.codechecker.analyzer; + +import com.codechecker.config.AppConfig; +import com.codechecker.git.GitChangeScanner; +import com.codechecker.model.ChangedClassFile; +import com.codechecker.model.ClassChangeKind; +import com.codechecker.model.ClassChangeReport; +import com.codechecker.model.FieldChange; +import com.codechecker.model.FieldInfo; +import com.codechecker.parser.ClassDeclParser; +import com.codechecker.parser.ClassFieldParser; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * 编排 git 扫描、字段 diff、影响分析,生成待通知的 ClassChangeReport 列表。 + */ +public class ClassChangeAnalyzer { + private final GitChangeScanner gitScanner; + private final ClassFieldParser classFieldParser = new ClassFieldParser(); + private final FieldDiffEngine fieldDiffEngine = new FieldDiffEngine(); + private final ImpactAnalyzer impactAnalyzer = new ImpactAnalyzer(); + private final ClassDeclParser classDeclParser = new ClassDeclParser(); + + public ClassChangeAnalyzer(GitChangeScanner gitScanner) { + this.gitScanner = gitScanner; + } + + /** 扫描变更文件并逐条分析,无实质变更的 MODIFIED 会被跳过 */ + public List analyze(Path repoRoot, AppConfig config, String oldSha, String newSha, + Map endpointIndex, + DtoNestIndex nestIndex) throws IOException { + List changedFiles = gitScanner.scanChangedClasses(oldSha, newSha); + List reports = new ArrayList<>(); + + for (ChangedClassFile changedFile : changedFiles) { + if (changedFile.getStatus() == ChangedClassFile.ChangeStatus.DELETED) { + reports.add(analyzeDeleted(changedFile, config, repoRoot, oldSha, endpointIndex, nestIndex)); + continue; + } + ClassChangeReport report = analyzeModifiedOrRenamed(changedFile, config, repoRoot, oldSha, newSha, + endpointIndex, nestIndex); + if (report != null) { + reports.add(report); + } + } + return reports; + } + + /** 处理删除:标记 DELETED 并分析影响(基于旧源码) */ + private ClassChangeReport analyzeDeleted(ChangedClassFile changedFile, AppConfig config, Path repoRoot, + String oldSha, Map endpointIndex, + DtoNestIndex nestIndex) + throws IOException { + String path = changedFile.getRelativePath(); + String oldSource = gitScanner.readFileAtCommit(oldSha, path); + + String classDescription = classDeclParser.extractClassDescription( + oldSource, changedFile.getClassName()); + + ClassChangeReport report = new ClassChangeReport( + changedFile.getClassName(), + null, + changedFile.getClassType(), + ClassChangeKind.DELETED, + path, + config.isDtoEntityConversionEnabled(), + classDescription + ); + impactAnalyzer.analyze(report, endpointIndex, config, repoRoot, oldSource, oldSource, nestIndex); + return report; + } + + /** 处理修改/重命名:字段 diff → 判定 changeKind → 影响分析 */ + private ClassChangeReport analyzeModifiedOrRenamed(ChangedClassFile changedFile, AppConfig config, + Path repoRoot, String oldSha, String newSha, + Map endpointIndex, + DtoNestIndex nestIndex) + throws IOException { + String oldPath = changedFile.pathForOldCommit(); + String newPath = changedFile.getRelativePath(); + + String oldSource = gitScanner.readFileAtCommit(oldSha, oldPath); + String newSource = gitScanner.readFileAtCommit(newSha, newPath); + if (newSource == null || newSource.isBlank()) { + newSource = gitScanner.readFileAtHead(newPath); + } + + String oldFallback = ClassDeclParser.classNameFromPath(oldPath); + String newFallback = ClassDeclParser.classNameFromPath(newPath); + String oldClassName = changedFile.getOldClassName() != null + ? changedFile.getOldClassName() + : classDeclParser.resolveClassName(oldSource, oldFallback); + String newClassName = classDeclParser.resolveClassName(newSource, newFallback); + + List oldFields = classFieldParser.parseFields(oldSource, oldClassName); + List newFields = classFieldParser.parseFields(newSource, newClassName); + List fieldChanges = fieldDiffEngine.diff(oldFields, newFields); + + boolean renamed = !oldClassName.equals(newClassName); + ClassChangeKind changeKind; + if (renamed && fieldChanges.isEmpty()) { + changeKind = ClassChangeKind.RENAME_ONLY; + } else if (renamed) { + changeKind = ClassChangeKind.RENAME_AND_FIELDS; + } else if (!fieldChanges.isEmpty()) { + changeKind = ClassChangeKind.FIELDS_ONLY; + } else { + return null; + } + + String classDescription = classDeclParser.extractClassDescription(newSource, newClassName); + + ClassChangeReport report = new ClassChangeReport( + newClassName, + renamed ? oldClassName : null, + changedFile.getClassType(), + changeKind, + newPath, + config.isDtoEntityConversionEnabled(), + classDescription + ); + fieldChanges.forEach(report::addFieldChange); + impactAnalyzer.analyze(report, endpointIndex, config, repoRoot, newSource, oldSource, nestIndex); + return report; + } +} diff --git a/src/main/java/com/codechecker/analyzer/DtoNestIndex.java b/src/main/java/com/codechecker/analyzer/DtoNestIndex.java new file mode 100644 index 0000000..65e027a --- /dev/null +++ b/src/main/java/com/codechecker/analyzer/DtoNestIndex.java @@ -0,0 +1,135 @@ +package com.codechecker.analyzer; + +import com.codechecker.config.AppConfig; +import com.codechecker.model.ClassType; +import com.codechecker.model.FieldInfo; +import com.codechecker.parser.ClassFieldParser; +import com.codechecker.parser.TypeNameUtils; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Stream; + +/** + * Dto/Vo 嵌套关系索引:反向查找祖先容器(用于影响分析与 API 跟进)。 + */ +public class DtoNestIndex { + 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 int maxDepth; + private final Map> ancestorsOf = new LinkedHashMap<>(); + private final Map sourceByClass = new HashMap<>(); + + private DtoNestIndex(int maxDepth) { + this.maxDepth = maxDepth; + } + + public static DtoNestIndex build(Path repoRoot, AppConfig config) throws IOException { + DtoNestIndex index = new DtoNestIndex(config.getNestMaxDepth()); + ClassFieldParser fieldParser = new ClassFieldParser(); + for (String dir : config.getModelDirs()) { + Path root = repoRoot.resolve(dir.replace('\\', '/')); + if (!Files.exists(root)) { + continue; + } + try (Stream paths = Files.walk(root)) { + paths.filter(path -> path.toString().endsWith(".java")) + .forEach(path -> { + String className = path.getFileName().toString().replace(".java", ""); + if (ClassType.fromClassName(className) != ClassType.DTO + && ClassType.fromClassName(className) != ClassType.VO) { + return; + } + try { + String source = Files.readString(path, StandardCharsets.UTF_8); + index.sourceByClass.put(className, source); + } catch (IOException ignored) { + // 跳过无法读取的文件 + } + }); + } + } + for (Map.Entry entry : index.sourceByClass.entrySet()) { + String rootClass = entry.getKey(); + List fields = fieldParser.parseFields(entry.getValue(), rootClass); + Set visiting = new LinkedHashSet<>(); + index.walkNested(rootClass, fields, rootClass, 1, visiting, fieldParser); + } + return index; + } + + /** 自身 + 所有祖先 Dto/Vo 类名(用于接口影响匹配) */ + public Set expandImpactNames(String className) { + Set names = new LinkedHashSet<>(); + if (className != null && !className.isBlank()) { + names.add(className); + names.addAll(ancestorsOf.getOrDefault(className, Set.of())); + } + return names; + } + + /** 是否被其他 Dto/Vo 嵌套引用(存在至少一个祖先容器) */ + public boolean hasAncestors(String className) { + Set ancestors = ancestorsOf.get(className); + return ancestors != null && !ancestors.isEmpty(); + } + + /** 嵌套类型的 @RequestBody 根 Dto 祖先(仅 Dto 后缀) */ + public Set findRequestBodyRoots(String className) { + Set roots = new LinkedHashSet<>(); + if (className != null && className.endsWith("Dto")) { + roots.add(className); + } + for (String ancestor : ancestorsOf.getOrDefault(className, Set.of())) { + if (ancestor.endsWith("Dto")) { + roots.add(ancestor); + } + } + return roots; + } + + public int getMaxDepth() { + return maxDepth; + } + + private void walkNested(String ownerClass, List fields, String rootAncestor, + int depth, Set visiting, ClassFieldParser fieldParser) { + if (depth > maxDepth) { + return; + } + for (FieldInfo field : fields) { + for (String nestedType : TypeNameUtils.peelDirectTypeNames(field.getType())) { + if (isLeafType(nestedType) || nestedType.equals(ownerClass)) { + continue; + } + ancestorsOf.computeIfAbsent(nestedType, k -> new LinkedHashSet<>()).add(rootAncestor); + if (!visiting.add(nestedType)) { + continue; + } + String nestedSource = sourceByClass.get(nestedType); + if (nestedSource != null) { + List nestedFields = fieldParser.parseFields(nestedSource, nestedType); + walkNested(nestedType, nestedFields, rootAncestor, depth + 1, visiting, fieldParser); + } + visiting.remove(nestedType); + } + } + } + + private boolean isLeafType(String simpleType) { + return LEAF_TYPES.contains(simpleType) || simpleType.endsWith("[]"); + } +} diff --git a/src/main/java/com/codechecker/analyzer/EndpointIndexBuilder.java b/src/main/java/com/codechecker/analyzer/EndpointIndexBuilder.java new file mode 100644 index 0000000..5dadfb4 --- /dev/null +++ b/src/main/java/com/codechecker/analyzer/EndpointIndexBuilder.java @@ -0,0 +1,37 @@ +package com.codechecker.analyzer; + +import com.codechecker.config.AppConfig; +import com.codechecker.model.ApiEndpoint; +import com.codechecker.parser.EndpointParser; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 预扫描 Controller/Feign 目录,构建 endpointKey → ApiEndpoint 索引。 + */ +public class EndpointIndexBuilder { + private final EndpointParser endpointParser = new EndpointParser(); + + /** 合并 Controller 与 Feign 扫描结果 */ + public Map buildIndex(Path repoRoot, AppConfig config) throws IOException { + Map index = new LinkedHashMap<>(); + for (String dir : config.getControllerScanDirs()) { + addEndpoints(index, endpointParser.scanControllerDirectory(repoRoot.resolve(dir), dir)); + } + for (String dir : config.getFeignScanDirs()) { + addEndpoints(index, endpointParser.scanFeignDirectory(repoRoot.resolve(dir), dir)); + } + return index; + } + + /** 按 endpointKey 去重写入索引 */ + private void addEndpoints(Map index, List endpoints) { + for (ApiEndpoint endpoint : endpoints) { + index.putIfAbsent(endpoint.endpointKey(), endpoint); + } + } +} diff --git a/src/main/java/com/codechecker/analyzer/FieldDiffEngine.java b/src/main/java/com/codechecker/analyzer/FieldDiffEngine.java new file mode 100644 index 0000000..2549c1b --- /dev/null +++ b/src/main/java/com/codechecker/analyzer/FieldDiffEngine.java @@ -0,0 +1,166 @@ +package com.codechecker.analyzer; + +import com.codechecker.model.FieldChange; +import com.codechecker.model.FieldInfo; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 对比新旧字段列表,产出新增/删除/类型修改/重命名(纯注释变更忽略)。 + */ +public class FieldDiffEngine { + + /** + * 按字段名对比;删除+新增且说明匹配时合并为重命名。 + * 输出顺序:按新字段声明顺序,未配对的删除字段置于末尾。 + */ + public List diff(List oldFields, List newFields) { + Map oldMap = toMap(oldFields); + Map newMap = toMap(newFields); + + List modified = new ArrayList<>(); + List added = new ArrayList<>(); + List removed = new ArrayList<>(); + + for (FieldInfo newField : newFields) { + FieldInfo oldField = oldMap.get(newField.getName()); + if (oldField == null) { + added.add(newField); + } else if (!oldField.getType().equals(newField.getType())) { + modified.add(FieldChange.modified(oldField, newField, buildTypeDetail(oldField, newField))); + } + // 仅 @Schema / 注释文案变化:不纳入字段变更 + } + + for (FieldInfo oldField : oldFields) { + if (!newMap.containsKey(oldField.getName())) { + removed.add(oldField); + } + } + + List renamed = pairRenames(removed, added); + return mergeInOrder(newFields, renamed, modified, added, removed); + } + + /** + * 将删除+新增配对为字段重命名。 + * 优先:说明相同且类型相同;其次:说明相同但类型不同(重命名+改类型)。 + */ + private List pairRenames(List removed, List added) { + List renames = new ArrayList<>(); + Set matchedRemoved = new LinkedHashSet<>(); + Set matchedAdded = new LinkedHashSet<>(); + + for (FieldInfo oldField : removed) { + FieldInfo pair = findRenamePair(oldField, added, matchedAdded, true); + if (pair == null) { + pair = findRenamePair(oldField, added, matchedAdded, false); + } + if (pair != null) { + renames.add(FieldChange.renamed(oldField, pair)); + matchedRemoved.add(oldField); + matchedAdded.add(pair); + } + } + + removed.removeIf(matchedRemoved::contains); + added.removeIf(matchedAdded::contains); + return renames; + } + + private FieldInfo findRenamePair(FieldInfo removed, List added, + Set excluded, boolean requireSameType) { + for (FieldInfo candidate : added) { + if (excluded.contains(candidate)) { + continue; + } + if (!descriptionsMatch(removed, candidate)) { + continue; + } + if (requireSameType && !removed.getType().equals(candidate.getType())) { + continue; + } + if (!requireSameType && removed.getType().equals(candidate.getType())) { + continue; + } + return candidate; + } + return null; + } + + /** 说明相同(非空)或双方均为空时视为匹配 */ + private boolean descriptionsMatch(FieldInfo oldField, FieldInfo newField) { + String oldDesc = normalizeDescription(oldField.getDescription()); + String newDesc = normalizeDescription(newField.getDescription()); + if (oldDesc.isEmpty() && newDesc.isEmpty()) { + return true; + } + if (oldDesc.isEmpty() || newDesc.isEmpty()) { + return false; + } + return oldDesc.equals(newDesc); + } + + private String normalizeDescription(String description) { + return description == null ? "" : description.trim(); + } + + /** 按新字段声明顺序合并各变更类型 */ + private List mergeInOrder(List newFields, List renamed, + List modified, List added, + List removed) { + Map renamedByNewName = new LinkedHashMap<>(); + for (FieldChange change : renamed) { + renamedByNewName.put(change.getFieldName(), change); + } + + Map modifiedByName = new LinkedHashMap<>(); + for (FieldChange change : modified) { + modifiedByName.put(change.getFieldName(), change); + } + + Set emitted = new LinkedHashSet<>(); + List result = new ArrayList<>(); + + for (FieldInfo newField : newFields) { + String name = newField.getName(); + if (renamedByNewName.containsKey(name)) { + result.add(renamedByNewName.get(name)); + emitted.add(name); + } else if (modifiedByName.containsKey(name)) { + result.add(modifiedByName.get(name)); + emitted.add(name); + } else if (added.stream().anyMatch(f -> f.getName().equals(name))) { + result.add(FieldChange.added(newField)); + emitted.add(name); + } + } + + for (FieldInfo oldField : removed) { + result.add(FieldChange.removed(oldField)); + } + return result; + } + + /** 字段列表转 LinkedHashMap,保持声明顺序 */ + private Map toMap(List fields) { + Map map = new LinkedHashMap<>(); + for (FieldInfo field : fields) { + map.put(field.getName(), field); + } + return map; + } + + /** 构造类型变化描述,如 Integer → String */ + private String buildTypeDetail(FieldInfo oldField, FieldInfo newField) { + if (oldField.getType().equals(newField.getType())) { + return ""; + } + return oldField.getType() + " → " + newField.getType(); + } +} diff --git a/src/main/java/com/codechecker/analyzer/ImpactAnalyzer.java b/src/main/java/com/codechecker/analyzer/ImpactAnalyzer.java new file mode 100644 index 0000000..f2db5e4 --- /dev/null +++ b/src/main/java/com/codechecker/analyzer/ImpactAnalyzer.java @@ -0,0 +1,113 @@ +package com.codechecker.analyzer; + +import com.codechecker.config.AppConfig; +import com.codechecker.model.ApiEndpoint; +import com.codechecker.model.ClassChangeReport; +import com.codechecker.model.ClassType; + +import java.util.ArrayList; +import com.codechecker.parser.ConversionParser; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 根据变更报告匹配受影响的 HTTP 接口与 Dto→Entity 转换目标。 + */ +public class ImpactAnalyzer { + private final ConversionParser conversionParser = new ConversionParser(); + + /** + * 填充 report 的影响列表;新旧类名均参与匹配;Entity/Model 不匹配接口。 + */ + public void analyze(ClassChangeReport report, Map endpointIndex, + AppConfig config, Path repoRoot, String newSource, String oldSource, + DtoNestIndex nestIndex) throws IOException { + Set matchNames = namesForMatching(report, nestIndex); + + if (report.getClassType() != ClassType.ENTITY && report.getClassType() != ClassType.MODEL) { + matchEndpoints(report, endpointIndex, matchNames); + } + + report.setObjectRoleLabels(NestedObjectRoleResolver.resolve(report, nestIndex, endpointIndex)); + + if (!config.isDtoEntityConversionEnabled()) { + return; + } + + analyzeConversion(report, config, repoRoot, newSource, oldSource, matchNames); + } + + /** 收集新旧类名及嵌套祖先 Dto/Vo,用于接口/转换匹配 */ + private Set namesForMatching(ClassChangeReport report, DtoNestIndex nestIndex) { + Set names = new LinkedHashSet<>(); + names.add(report.getClassName()); + if (report.getOldClassName() != null && !report.getOldClassName().isBlank()) { + names.add(report.getOldClassName()); + } + if (nestIndex != null + && (report.getClassType() == ClassType.DTO || report.getClassType() == ClassType.VO)) { + for (String name : new ArrayList<>(names)) { + names.addAll(nestIndex.expandImpactNames(name)); + } + } + return names; + } + + /** 在接口索引中匹配入参/返回类型 */ + private void matchEndpoints(ClassChangeReport report, Map endpointIndex, + Set matchNames) { + List inputImpacts = new ArrayList<>(); + List frontendImpacts = new ArrayList<>(); + + for (ApiEndpoint endpoint : endpointIndex.values()) { + if (matchesAnyType(endpoint.getParamTypes(), matchNames)) { + inputImpacts.add(endpoint); + } + if (matchesAnyType(endpoint.getReturnTypes(), matchNames)) { + frontendImpacts.add(endpoint); + } + } + + inputImpacts.forEach(report::addInputImpact); + frontendImpacts.forEach(report::addFrontendImpact); + } + + /** 扫描 convert 方法与 BeanUtils.copyProperties 关联的 Entity */ + private void analyzeConversion(ClassChangeReport report, AppConfig config, Path repoRoot, + String newSource, String oldSource, Set matchNames) throws IOException { + for (String name : matchNames) { + if (newSource != null && !newSource.isBlank()) { + conversionParser.findConvertTargetsInClass(newSource, name) + .forEach(report::addConversionEntity); + } + if (oldSource != null && !oldSource.isBlank() && !oldSource.equals(newSource)) { + conversionParser.findConvertTargetsInClass(oldSource, name) + .forEach(report::addConversionEntity); + } + for (String scanDir : config.getConversionScanDirs()) { + conversionParser.findBeanUtilsTargets(repoRoot.resolve(scanDir), name) + .forEach(report::addConversionEntity); + } + } + } + + /** 类型集合中是否包含任一目标类名 */ + private boolean matchesAnyType(Collection types, Set 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 getFeignScanDirs() { + return feignScanDirs; + } + + /** BeanUtils / convert 扫描目录 */ + public List getConversionScanDirs() { + return conversionScanDirs; + } + + /** 企微 Webhook 地址 */ + public String getWecomWebhookUrl() { + return wecomWebhookUrl; + } + + /** 企微通知开关 */ + public boolean isWecomEnabled() { + return wecomEnabled; + } + + /** 无变更时是否打印提示后退出 */ + public boolean isOnlyOnChange() { + return onlyOnChange; + } + + /** Dto 类变更后是否继续检测受影响接口的 API 参数变更 */ + public boolean isDtoApiFollowUpEnabled() { + return dtoApiFollowUpEnabled; + } + + /** Dto/Vo 嵌套展开最大深度(默认 3,可按需调至 4、5) */ + public int getNestMaxDepth() { + return nestMaxDepth; + } + + /** API 变更检测总开关 */ + public boolean isApiCheckEnabled() { + return apiCheckEnabled; + } + + /** Dto 类变更与 API 参数变更重叠时的通知策略 */ + public DtoOverlapMode getDtoOverlapMode() { + return dtoOverlapMode; + } + + /** 是否排除 Spring 框架注入参数 */ + public boolean isApiExcludeFrameworkParams() { + return apiExcludeFrameworkParams; + } + + /** API 检测 Controller 扫描目录 */ + public List getApiControllerScanDirs() { + return apiControllerScanDirs; + } + + /** API 检测 Feign 扫描目录 */ + public List getApiFeignScanDirs() { + return apiFeignScanDirs; + } + + /** API 检测所有扫描目录(Controller + Feign) */ + public List getAllApiScanDirs() { + List dirs = new ArrayList<>(); + dirs.addAll(apiControllerScanDirs); + dirs.addAll(apiFeignScanDirs); + return dirs; + } +} diff --git a/src/main/java/com/codechecker/config/DtoOverlapMode.java b/src/main/java/com/codechecker/config/DtoOverlapMode.java new file mode 100644 index 0000000..a62d1bb --- /dev/null +++ b/src/main/java/com/codechecker/config/DtoOverlapMode.java @@ -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; + } + } +} diff --git a/src/main/java/com/codechecker/git/GitChangeScanner.java b/src/main/java/com/codechecker/git/GitChangeScanner.java new file mode 100644 index 0000000..f7716bf --- /dev/null +++ b/src/main/java/com/codechecker/git/GitChangeScanner.java @@ -0,0 +1,295 @@ +package com.codechecker.git; + +import com.codechecker.model.ChangedClassFile; +import com.codechecker.model.ClassType; +import com.codechecker.parser.ClassDeclParser; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** + * 执行 git diff,识别 Dto/Vo/Entity/Model 的修改、删除、重命名(含 R* 与同目录 D+A 配对)。 + */ +public class GitChangeScanner { + private final Path repoRoot; + private final ClassDeclParser classDeclParser = new ClassDeclParser(); + + public GitChangeScanner(Path repoRoot) { + this.repoRoot = repoRoot; + } + + /** 扫描两次提交间的模型类变更 */ + public List scanChangedClasses(String oldSha, String newSha) throws IOException { + List lines = runGit("diff", "--name-status", oldSha, newSha); + List deletions = new ArrayList<>(); + Map additionsByParent = new LinkedHashMap<>(); + List result = new ArrayList<>(); + + for (String line : lines) { + if (line.isBlank()) { + continue; + } + String[] parts = line.split("\t"); + if (parts.length < 2) { + continue; + } + String status = parts[0].trim(); + + if (status.startsWith("R") && parts.length >= 3) { + ChangedClassFile renamed = buildRenamed(parts[1], parts[2], oldSha, newSha); + if (renamed != null) { + result.add(renamed); + } + continue; + } + + String path = normalizePath(parts[parts.length - 1]); + if (!path.endsWith(".java")) { + continue; + } + + String fallbackName = ClassDeclParser.classNameFromPath(path); + ClassType classType = ClassType.fromClassName(fallbackName); + if (classType == null) { + continue; + } + + if (status.equals("A")) { + String newSource = readFileAtCommit(newSha, path); + String className = classDeclParser.resolveClassName(newSource, fallbackName); + additionsByParent.computeIfAbsent(parentDir(path), k -> new PendingAdd()) + .add(path, className, classType, newSource); + continue; + } + + if (status.equals("D")) { + String oldSource = readFileAtCommit(oldSha, path); + String className = classDeclParser.resolveClassName(oldSource, fallbackName); + deletions.add(new ChangedClassFile(path, ChangedClassFile.ChangeStatus.DELETED, className, classType)); + continue; + } + + if (status.startsWith("M")) { + ChangedClassFile modified = buildModified(path, oldSha, newSha, fallbackName, classType); + if (modified != null) { + result.add(modified); + } + } + } + + pairDeleteAndAdd(deletions, additionsByParent, oldSha, result); + result.addAll(deletions); + return result; + } + + /** 处理 git R 状态:路径重命名 */ + private ChangedClassFile buildRenamed(String oldPathRaw, String newPathRaw, + String oldSha, String newSha) throws IOException { + String oldPath = normalizePath(oldPathRaw); + String newPath = normalizePath(newPathRaw); + if (!oldPath.endsWith(".java") || !newPath.endsWith(".java")) { + return null; + } + + String oldFallback = ClassDeclParser.classNameFromPath(oldPath); + String newFallback = ClassDeclParser.classNameFromPath(newPath); + ClassType classType = ClassType.fromClassName(newFallback); + if (classType == null) { + classType = ClassType.fromClassName(oldFallback); + } + if (classType == null) { + return null; + } + + String oldSource = readFileAtCommit(oldSha, oldPath); + String newSource = readFileAtCommit(newSha, newPath); + String oldClassName = classDeclParser.resolveClassName(oldSource, oldFallback); + String newClassName = classDeclParser.resolveClassName(newSource, newFallback); + + return new ChangedClassFile(newPath, oldPath, ChangedClassFile.ChangeStatus.RENAMED, + newClassName, oldClassName, classType); + } + + /** 处理 M 状态:同路径下对比 AST 类名判断是否重命名 */ + private ChangedClassFile buildModified(String path, String oldSha, String newSha, + String fallbackName, ClassType classType) throws IOException { + String oldSource = readFileAtCommit(oldSha, path); + String newSource = readFileAtCommit(newSha, path); + if (newSource == null || newSource.isBlank()) { + newSource = readFileAtHead(path); + } + String oldClassName = classDeclParser.resolveClassName(oldSource, fallbackName); + String newClassName = classDeclParser.resolveClassName(newSource, fallbackName); + + if (oldClassName.equals(newClassName)) { + return new ChangedClassFile(path, ChangedClassFile.ChangeStatus.MODIFIED, + newClassName, classType); + } + return new ChangedClassFile(path, path, ChangedClassFile.ChangeStatus.RENAMED, + newClassName, oldClassName, classType); + } + + /** 同目录 D+A 配对为 RENAMED(Git 未显式标记 R 时) */ + private void pairDeleteAndAdd(List deletions, + Map additionsByParent, + String oldSha, + List result) throws IOException { + List unpaired = new ArrayList<>(); + + for (ChangedClassFile deleted : deletions) { + String parent = parentDir(deleted.getRelativePath()); + PendingAdd pending = additionsByParent.get(parent); + if (pending == null || pending.isEmpty()) { + unpaired.add(deleted); + continue; + } + + PendingAdd.Candidate candidate = pending.poll(deleted.getClassType()); + if (candidate == null) { + unpaired.add(deleted); + continue; + } + + String oldSource = readFileAtCommit(oldSha, deleted.getRelativePath()); + String oldClassName = classDeclParser.resolveClassName(oldSource, deleted.getClassName()); + result.add(new ChangedClassFile(candidate.path(), deleted.getRelativePath(), + ChangedClassFile.ChangeStatus.RENAMED, + candidate.className(), oldClassName, deleted.getClassType())); + } + + deletions.clear(); + deletions.addAll(unpaired); + } + + /** 取路径父目录,用于 D+A 配对 */ + private static String parentDir(String path) { + int idx = path.lastIndexOf('/'); + return idx >= 0 ? path.substring(0, idx) : ""; + } + + /** 读取指定 commit 下的文件内容 */ + public String readFileAtCommit(String commitSha, String relativePath) throws IOException { + List lines = runGit("show", commitSha + ":" + relativePath); + if (lines.isEmpty()) { + return ""; + } + if (lines.size() == 1 && lines.get(0).startsWith("fatal:")) { + return ""; + } + return String.join("\n", lines); + } + + /** 读取工作区 HEAD 文件(commit 中缺失时的回退) */ + public String readFileAtHead(String relativePath) throws IOException { + Path file = repoRoot.resolve(relativePath); + if (!Files.exists(file)) { + return null; + } + return Files.readString(file, StandardCharsets.UTF_8); + } + + /** 两次提交间变更文件路径列表(--name-only) */ + public List diffNameOnly(String oldSha, String newSha) throws IOException { + return runGit("diff", "--name-only", oldSha, newSha); + } + + /** 在 repoRoot 下执行 git 命令并返回 stdout 行 */ + private List runGit(String... args) throws IOException { + String[] command = new String[args.length + 3]; + command[0] = "git"; + command[1] = "-C"; + command[2] = repoRoot.toString(); + System.arraycopy(args, 0, command, 3, args.length); + + ProcessBuilder builder = new ProcessBuilder(command); + builder.redirectErrorStream(true); + Process process = builder.start(); + + List output = new ArrayList<>(); + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + output.add(line); + } + } + + try { + int exitCode = process.waitFor(); + if (exitCode != 0 && !isBenignGitShowFailure(args, output)) { + throw new IOException("git 命令失败: " + String.join(" ", command) + + "\n" + String.join("\n", output)); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("git 命令被中断", e); + } + return output; + } + + /** git show 文件不存在等情况视为可忽略 */ + private boolean isBenignGitShowFailure(String[] args, List output) { + if (args.length > 0 && "show".equals(args[0])) { + String joined = String.join("\n", output).toLowerCase(Locale.ROOT); + return joined.contains("exists on disk") || joined.contains("bad object") + || joined.contains("path") && joined.contains("does not exist"); + } + return false; + } + + /** 统一路径分隔符为 / */ + private String normalizePath(String path) { + return path.replace("\\", "/"); + } + + /** 同目录新增文件缓冲,供 D+A 配对 */ + private static final class PendingAdd { + private final Map> byType = new HashMap<>(); + + void add(String path, String className, ClassType classType, String source) { + byType.computeIfAbsent(classType, k -> new ArrayList<>()) + .add(new Candidate(path, className)); + } + + boolean isEmpty() { + return byType.values().stream().allMatch(List::isEmpty); + } + + /** 按类型取出一个候选新增文件 */ + Candidate poll(ClassType classType) { + List list = byType.get(classType); + if (list == null || list.isEmpty()) { + return null; + } + return list.remove(0); + } + + private static final class Candidate { + private final String path; + private final String className; + + private Candidate(String path, String className) { + this.path = path; + this.className = className; + } + + private String path() { + return path; + } + + private String className() { + return className; + } + } + } +} diff --git a/src/main/java/com/codechecker/model/ApiEndpoint.java b/src/main/java/com/codechecker/model/ApiEndpoint.java new file mode 100644 index 0000000..d07db6e --- /dev/null +++ b/src/main/java/com/codechecker/model/ApiEndpoint.java @@ -0,0 +1,57 @@ +package com.codechecker.model; + +import java.util.LinkedHashSet; +import java.util.Set; + +/** + * 索引中的 HTTP/Feign 接口:方法、URI、入参/返回类型简单名。 + */ +public class ApiEndpoint { + private final String httpMethod; + private final String uri; + private final String sourceFile; + private final Set paramTypes; + private final Set returnTypes; + + public ApiEndpoint(String httpMethod, String uri, String sourceFile, + Set paramTypes, Set returnTypes) { + this.httpMethod = httpMethod; + this.uri = uri; + this.sourceFile = sourceFile; + this.paramTypes = paramTypes == null ? Set.of() : new LinkedHashSet<>(paramTypes); + this.returnTypes = returnTypes == null ? Set.of() : new LinkedHashSet<>(returnTypes); + } + + public String getHttpMethod() { + return httpMethod; + } + + public String getUri() { + return uri; + } + + /** 定义该接口的 Java 源文件相对路径 */ + public String getSourceFile() { + return sourceFile; + } + + /** 入参涉及的类型简单名集合 */ + public Set getParamTypes() { + return paramTypes; + } + + /** 返回值涉及的类型简单名集合(已剥离泛型包装) */ + public Set getReturnTypes() { + return returnTypes; + } + + /** 去重用键:METHOD + URI */ + public String endpointKey() { + return httpMethod + " " + uri; + } + + /** 通知展示行:GET /api/foo */ + public String displayLine() { + return httpMethod + " " + uri; + } +} diff --git a/src/main/java/com/codechecker/model/ChangedClassFile.java b/src/main/java/com/codechecker/model/ChangedClassFile.java new file mode 100644 index 0000000..09d38a3 --- /dev/null +++ b/src/main/java/com/codechecker/model/ChangedClassFile.java @@ -0,0 +1,67 @@ +package com.codechecker.model; + +/** + * Git 扫描得到的单个 Java 模型类变更记录。 + */ +public class ChangedClassFile { + /** Git diff 状态:修改 / 删除 / 重命名 */ + public enum ChangeStatus { + MODIFIED, DELETED, RENAMED + } + + private final String relativePath; + private final String oldRelativePath; + private final ChangeStatus status; + private final String className; + private final String oldClassName; + private final ClassType classType; + + /** 修改或删除(无路径变化) */ + public ChangedClassFile(String relativePath, ChangeStatus status, String className, ClassType classType) { + this(relativePath, null, status, className, null, classType); + } + + /** 重命名或同路径类名变更 */ + public ChangedClassFile(String relativePath, String oldRelativePath, ChangeStatus status, + String className, String oldClassName, ClassType classType) { + this.relativePath = relativePath; + this.oldRelativePath = oldRelativePath; + this.status = status; + this.className = className; + this.oldClassName = oldClassName; + this.classType = classType; + } + + /** 新提交中的相对路径 */ + public String getRelativePath() { + return relativePath; + } + + /** 旧提交中的相对路径,未变路径则为 null */ + public String getOldRelativePath() { + return oldRelativePath; + } + + /** 读取旧版本源码时使用的路径 */ + public String pathForOldCommit() { + return oldRelativePath != null ? oldRelativePath : relativePath; + } + + public ChangeStatus getStatus() { + return status; + } + + /** 当前简单类名 */ + public String getClassName() { + return className; + } + + /** 重命名前简单类名 */ + public String getOldClassName() { + return oldClassName; + } + + public ClassType getClassType() { + return classType; + } +} diff --git a/src/main/java/com/codechecker/model/ClassChangeKind.java b/src/main/java/com/codechecker/model/ClassChangeKind.java new file mode 100644 index 0000000..b3c1fbd --- /dev/null +++ b/src/main/java/com/codechecker/model/ClassChangeKind.java @@ -0,0 +1,15 @@ +package com.codechecker.model; + +/** + * 单次类变更的类型,决定通知内容与影响分析策略。 + */ +public enum ClassChangeKind { + /** 文件已删除 */ + DELETED, + /** 仅字段变更 */ + FIELDS_ONLY, + /** 仅类名变更,字段不变 */ + RENAME_ONLY, + /** 类名与字段同时变更 */ + RENAME_AND_FIELDS +} diff --git a/src/main/java/com/codechecker/model/ClassChangeReport.java b/src/main/java/com/codechecker/model/ClassChangeReport.java new file mode 100644 index 0000000..195632b --- /dev/null +++ b/src/main/java/com/codechecker/model/ClassChangeReport.java @@ -0,0 +1,136 @@ +package com.codechecker.model; + +import java.util.ArrayList; +import java.util.List; + +/** + * 单次类变更的完整报告:变更类型、字段 diff、接口/转换影响,供通知渲染。 + */ +public class ClassChangeReport { + private final String className; + private final String oldClassName; + private final ClassType classType; + private final ClassChangeKind changeKind; + private final String sourceFile; + private final String classDescription; + private final List fieldChanges = new ArrayList<>(); + private final List inputImpactEndpoints = new ArrayList<>(); + private final List conversionEntities = new ArrayList<>(); + private final List frontendImpactEndpoints = new ArrayList<>(); + private final boolean conversionCheckEnabled; + private List objectRoleLabels = List.of(); + + public ClassChangeReport(String className, String oldClassName, ClassType classType, + ClassChangeKind changeKind, String sourceFile, + boolean conversionCheckEnabled, String classDescription) { + this.className = className; + this.oldClassName = oldClassName; + this.classType = classType; + this.changeKind = changeKind; + this.sourceFile = sourceFile; + this.conversionCheckEnabled = conversionCheckEnabled; + this.classDescription = classDescription == null ? "" : classDescription.trim(); + } + + /** 当前(新)简单类名 */ + public String getClassName() { + return className; + } + + /** 重命名前的简单类名,未重命名则为 null */ + public String getOldClassName() { + return oldClassName; + } + + /** 是否发生类名变更 */ + public boolean isRenamed() { + return oldClassName != null && !oldClassName.equals(className); + } + + /** 是否仅类名变更、字段无变化 */ + public boolean isRenameOnly() { + return changeKind == ClassChangeKind.RENAME_ONLY; + } + + public ClassType getClassType() { + return classType; + } + + public ClassChangeKind getChangeKind() { + return changeKind; + } + + /** Git 相对路径,通知「文件路径」展示用 */ + public String getSourceFile() { + return sourceFile; + } + + /** 类级中文说明(@Schema / 类 Javadoc),无则空串 */ + public String getClassDescription() { + return classDescription; + } + + /** 是否整文件删除 */ + public boolean isDeleted() { + return changeKind == ClassChangeKind.DELETED; + } + + public List getFieldChanges() { + return fieldChanges; + } + + /** 入参引用该类的接口(request 影响) */ + public List getInputImpactEndpoints() { + return inputImpactEndpoints; + } + + /** Dto→Entity 转换目标类名列表 */ + public List getConversionEntities() { + return conversionEntities; + } + + /** 返回值引用该类的接口(response 影响) */ + public List getFrontendImpactEndpoints() { + return frontendImpactEndpoints; + } + + /** 是否启用类转换检测 */ + public boolean isConversionCheckEnabled() { + return conversionCheckEnabled; + } + + /** 对象角色标签(如「嵌套对象」「顶层对象」),仅 Dto/Vo 且存在嵌套时非空 */ + public List getObjectRoleLabels() { + return objectRoleLabels; + } + + public void setObjectRoleLabels(List labels) { + this.objectRoleLabels = labels == null || labels.isEmpty() ? List.of() : List.copyOf(labels); + } + + /** 追加一条字段变更 */ + public void addFieldChange(FieldChange change) { + fieldChanges.add(change); + } + + /** 追加 request 影响接口(按 endpointKey 去重) */ + public void addInputImpact(ApiEndpoint endpoint) { + if (inputImpactEndpoints.stream().noneMatch(e -> e.endpointKey().equals(endpoint.endpointKey()))) { + inputImpactEndpoints.add(endpoint); + } + } + + /** 追加关联 Entity 类名(去重) */ + public void addConversionEntity(String entityName) { + if (!conversionEntities.contains(entityName)) { + conversionEntities.add(entityName); + } + } + + /** 追加 response 影响接口(按 endpointKey 去重) */ + public void addFrontendImpact(ApiEndpoint endpoint) { + if (frontendImpactEndpoints.stream().noneMatch(e -> e.endpointKey().equals(endpoint.endpointKey()))) { + frontendImpactEndpoints.add(endpoint); + } + } +} diff --git a/src/main/java/com/codechecker/model/ClassType.java b/src/main/java/com/codechecker/model/ClassType.java new file mode 100644 index 0000000..ee2fa8f --- /dev/null +++ b/src/main/java/com/codechecker/model/ClassType.java @@ -0,0 +1,47 @@ +package com.codechecker.model; + +/** + * 目标模型类后缀类型,决定通知模版中展示哪些影响段落。 + */ +public enum ClassType { + DTO("Dto"), + VO("Vo"), + ENTITY("Entity"), + MODEL("Model"); + + private final String label; + + ClassType(String label) { + this.label = label; + } + + /** 通知中展示的类型标签 */ + public String getLabel() { + return label; + } + + /** 根据简单类名后缀识别类型,不匹配则 null */ + public static ClassType fromClassName(String className) { + if (className.endsWith("Dto")) { + return DTO; + } + if (className.endsWith("VO")) { + return VO; + } + if (className.endsWith("Vo")) { + return VO; + } + if (className.endsWith("Entity")) { + return ENTITY; + } + if (className.endsWith("Model")) { + return MODEL; + } + return null; + } + + /** 判断类名是否属于当前类型 */ + public boolean isTargetSuffix(String className) { + return fromClassName(className) == this; + } +} diff --git a/src/main/java/com/codechecker/model/FieldChange.java b/src/main/java/com/codechecker/model/FieldChange.java new file mode 100644 index 0000000..0870d1f --- /dev/null +++ b/src/main/java/com/codechecker/model/FieldChange.java @@ -0,0 +1,95 @@ +package com.codechecker.model; + +/** + * 字段级 diff 结果,用于通知中的 [新增]/[删除]/[修改]/[重命名] 行。 + */ +public class FieldChange { + /** 字段变更种类 */ + public enum ChangeKind { + ADDED, REMOVED, MODIFIED, RENAMED + } + + private final ChangeKind kind; + private final String fieldName; + private final String oldFieldName; + private final String description; + private final String oldType; + private final String newType; + private final String oldDescription; + private final String detail; + + private FieldChange(ChangeKind kind, String fieldName, String oldFieldName, String description, + String oldType, String newType, String oldDescription, String detail) { + this.kind = kind; + this.fieldName = fieldName; + this.oldFieldName = oldFieldName; + this.description = description; + this.oldType = oldType; + this.newType = newType; + this.oldDescription = oldDescription; + this.detail = detail; + } + + /** 构造新增字段变更 */ + public static FieldChange added(FieldInfo field) { + return new FieldChange(ChangeKind.ADDED, field.getName(), null, field.getDescription(), + null, field.getType(), null, null); + } + + /** 构造删除字段变更 */ + public static FieldChange removed(FieldInfo field) { + return new FieldChange(ChangeKind.REMOVED, field.getName(), null, field.getDescription(), + field.getType(), null, field.getDescription(), null); + } + + /** 构造修改字段变更,detail 通常为类型变化描述 */ + public static FieldChange modified(FieldInfo oldField, FieldInfo newField, String detail) { + return new FieldChange(ChangeKind.MODIFIED, newField.getName(), null, newField.getDescription(), + oldField.getType(), newField.getType(), oldField.getDescription(), detail); + } + + /** 构造字段重命名;类型变化时 detail 为 oldType → newType */ + public static FieldChange renamed(FieldInfo oldField, FieldInfo newField) { + String typeDetail = oldField.getType().equals(newField.getType()) + ? null + : oldField.getType() + " → " + newField.getType(); + return new FieldChange(ChangeKind.RENAMED, newField.getName(), oldField.getName(), + newField.getDescription(), oldField.getType(), newField.getType(), + oldField.getDescription(), typeDetail); + } + + public ChangeKind getKind() { + return kind; + } + + public String getFieldName() { + return fieldName; + } + + /** 重命名前的字段名,仅 RENAMED 时有值 */ + public String getOldFieldName() { + return oldFieldName; + } + + /** 变更后的字段说明(通知「说明」段) */ + public String getDescription() { + return description; + } + + public String getOldType() { + return oldType; + } + + public String getNewType() { + return newType; + } + + public String getOldDescription() { + return oldDescription; + } + + /** 结构性变更详情,重命名时为类型变化描述 */ + public String getDetail() { + return detail; + } +} diff --git a/src/main/java/com/codechecker/model/FieldInfo.java b/src/main/java/com/codechecker/model/FieldInfo.java new file mode 100644 index 0000000..00d8055 --- /dev/null +++ b/src/main/java/com/codechecker/model/FieldInfo.java @@ -0,0 +1,52 @@ +package com.codechecker.model; + +import java.util.Objects; + +/** + * 解析后的单个字段:名称、类型、业务说明(Schema/注释)。 + */ +public class FieldInfo { + private final String name; + private final String type; + private final String description; + + public FieldInfo(String name, String type, String description) { + this.name = name; + this.type = type; + this.description = description == null ? "" : description; + } + + /** 字段名 */ + public String getName() { + return name; + } + + /** 字段类型(简单名) */ + public String getType() { + return type; + } + + /** 字段说明文案 */ + public String getDescription() { + return description; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof FieldInfo)) { + return false; + } + FieldInfo other = (FieldInfo) o; + return Objects.equals(name, other.name) + && Objects.equals(type, other.type) + && Objects.equals(description, other.description); + } + + @Override + public int hashCode() { + return Objects.hash(name, type, description); + } +} diff --git a/src/main/java/com/codechecker/notify/OverlapNotificationFilter.java b/src/main/java/com/codechecker/notify/OverlapNotificationFilter.java new file mode 100644 index 0000000..948dd7c --- /dev/null +++ b/src/main/java/com/codechecker/notify/OverlapNotificationFilter.java @@ -0,0 +1,218 @@ +package com.codechecker.notify; + +import com.codechecker.analyzer.DtoNestIndex; +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 classReports; + private final List apiReports; + + public FilterResult(List classReports, List apiReports) { + this.classReports = classReports; + this.apiReports = apiReports; + } + + public List classReports() { + return classReports; + } + + public List apiReports() { + return apiReports; + } + } + + public static FilterResult apply(List classReports, + List apiReports, + DtoOverlapMode mode, + DtoNestIndex nestIndex) { + if (mode == DtoOverlapMode.BOTH) { + return new FilterResult(classReports, apiReports); + } + Set overlapKeys = buildOverlapKeys(classReports, nestIndex); + if (overlapKeys.isEmpty()) { + return new FilterResult(classReports, apiReports); + } + if (mode == DtoOverlapMode.CLASS_ONLY) { + return new FilterResult(classReports, filterApiReports(apiReports, overlapKeys)); + } + Set apiOverlapKeys = buildApiOverlapKeys(apiReports); + return new FilterResult(filterClassReportsForApiOnly(classReports, apiOverlapKeys, nestIndex), apiReports); + } + + /** + * 重叠键使用 @RequestBody 根 Dto(如 PunishmentsApprovalDto),与 API 参数通知 parentDto 对齐; + * 嵌套子 Dto(如 UserSelfDto)通过 nestIndex 解析到根 Dto。 + */ + private static Set buildOverlapKeys(List classReports, + DtoNestIndex nestIndex) { + Set keys = new LinkedHashSet<>(); + for (ClassChangeReport report : classReports) { + if (report.getClassType() != ClassType.DTO) { + continue; + } + if (!hasDtoFieldChanges(report)) { + continue; + } + Set bodyRoots = requestBodyRoots(report, nestIndex); + for (ApiEndpoint endpoint : report.getInputImpactEndpoints()) { + for (String rootDto : bodyRoots) { + keys.add(new OverlapKey(rootDto, endpoint.endpointKey())); + } + } + } + return keys; + } + + private static Set requestBodyRoots(ClassChangeReport report, DtoNestIndex nestIndex) { + Set roots = new LinkedHashSet<>(); + if (nestIndex != null) { + roots.addAll(nestIndex.findRequestBodyRoots(report.getClassName())); + if (report.getOldClassName() != null && !report.getOldClassName().isBlank()) { + roots.addAll(nestIndex.findRequestBodyRoots(report.getOldClassName())); + } + } + if (roots.isEmpty() && report.getClassName().endsWith("Dto")) { + roots.add(report.getClassName()); + } + return roots; + } + + private static List filterApiReports(List apiReports, + Set overlapKeys) { + List kept = new ArrayList<>(); + for (EndpointChangeReport report : apiReports) { + if (!matchesOverlap(report, overlapKeys)) { + kept.add(report); + } + } + return kept; + } + + private static List filterClassReportsForApiOnly(List classReports, + Set apiOverlapKeys, + DtoNestIndex nestIndex) { + List kept = new ArrayList<>(); + for (ClassChangeReport report : classReports) { + if (!shouldSuppressClassForApiOnly(report, apiOverlapKeys, nestIndex)) { + kept.add(report); + } + } + return kept; + } + + private static Set buildApiOverlapKeys(List apiReports) { + Set 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 apiOverlapKeys, + DtoNestIndex nestIndex) { + if (report.getClassType() != ClassType.DTO || !hasDtoFieldChanges(report)) { + return false; + } + Set bodyRoots = requestBodyRoots(report, nestIndex); + for (ApiEndpoint endpoint : report.getInputImpactEndpoints()) { + for (String rootDto : bodyRoots) { + if (apiOverlapKeys.contains(new OverlapKey(rootDto, endpoint.endpointKey()))) { + return true; + } + } + } + return false; + } + + private static boolean matchesOverlap(EndpointChangeReport report, Set 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 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(); + } + } +} diff --git a/src/main/java/com/codechecker/notify/WeComNotifier.java b/src/main/java/com/codechecker/notify/WeComNotifier.java new file mode 100644 index 0000000..e5085fa --- /dev/null +++ b/src/main/java/com/codechecker/notify/WeComNotifier.java @@ -0,0 +1,394 @@ +package com.codechecker.notify; + +import com.codechecker.model.ApiEndpoint; +import com.codechecker.model.ClassChangeReport; +import com.codechecker.model.ClassType; +import com.codechecker.model.FieldChange; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; + +import java.io.IOException; +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * 将 ClassChangeReport 渲染为企业微信 Markdown 并发送(或仅日志输出)。 + *

+ * 使用 webhook {@code markdown}(v1):引用块 + 换行排版,三色 font(info/comment/warning)。 + * v1 不支持无序列表,各项以 {@code >标签: 值} 分行展示(冒号后两空格)。 + */ +public class WeComNotifier { + 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 int sendAll(String webhookUrl, List reports, String modifier, String modifyTime) { + if (reports == null || reports.isEmpty()) { + System.out.println("无类变更,不发送到企业微信"); + return 0; + } + + int sent = 0; + for (ClassChangeReport report : reports) { + String markdown = buildMarkdown(report, modifier, modifyTime); + if (postMarkdown(webhookUrl, markdown)) { + sent++; + System.out.println("已发送类变更通知: " + report.getClassName()); + } + } + if (sent > 0) { + System.out.println("总共发送 " + sent + " 条类变更通知到企业微信"); + } + return sent; + } + + /** 企微关闭时打印 Markdown 到控制台 */ + public void logAll(List reports, String modifier, String modifyTime) { + if (reports == null || reports.isEmpty()) { + System.out.println("无类变更,无日志输出"); + return; + } + + System.out.println("企业微信通知已关闭(wecom.enabled=false),以下结果仅输出到日志:"); + for (int i = 0; i < reports.size(); i++) { + ClassChangeReport report = reports.get(i); + System.out.println("========== 类变更 [" + (i + 1) + "/" + reports.size() + + "]: " + report.getClassName() + " =========="); + System.out.println(buildMarkdown(report, modifier, modifyTime)); + System.out.println("========== 结束 =========="); + } + System.out.println("共 " + reports.size() + " 条类变更结果(未发送到企业微信)"); + } + + /** 组装完整 Markdown 正文(引用块 + 换行,每项独立一行) */ + public String buildMarkdown(ClassChangeReport report, String modifier, String modifyTime) { + StringBuilder sb = new StringBuilder(); + sb.append("# 【类变更通知】").append("\n\n"); + appendHeader(sb, report, modifier, modifyTime); + + sb.append("\n## 【对象变更细节】").append("\n\n"); + appendChangeDetails(sb, report); + + sb.append("\n## 【影响范围】").append("\n\n"); + appendImpactSections(sb, report); + return truncate(sb.toString()); + } + + /** 变更对象行:类名(绿)+ 可选中文说明 + 嵌套角色标签(灰,整行加粗) */ + private String formatChangeTarget(ClassChangeReport report) { + StringBuilder line = new StringBuilder(colorInfo(safe(report.getClassName()))); + String description = report.getClassDescription(); + if (description != null && !description.isBlank()) { + line.append("(").append(colorComment(description)).append(")"); + } + for (String role : report.getObjectRoleLabels()) { + line.append("(").append(colorComment(role)).append(")"); + } + return line.toString(); + } + + /** 头部元信息,每项一行引用(加粗) */ + private void appendHeader(StringBuilder sb, ClassChangeReport report, + String modifier, String modifyTime) { + sb.append(quoteKvBold("变更对象", formatChangeTarget(report))).append("\n"); + sb.append(quoteKvBold("修改人", colorComment(modifier))).append("\n"); + sb.append(quoteKvBold("时间", colorComment(modifyTime))).append("\n"); + sb.append(quoteKvBold("路径", colorComment(report.getSourceFile()))).append("\n"); + } + + /** 渲染删除 / 重命名 / 字段变更 */ + private void appendChangeDetails(StringBuilder sb, ClassChangeReport report) { + if (report.isDeleted()) { + sb.append(quoteLine(colorWarning("[已删除]") + " " + + colorComment("该类文件已被移除"))).append("\n"); + return; + } + + if (report.isRenamed()) { + sb.append(quoteLine(colorWarning("[类名变更]") + " " + + colorComment(safe(report.getOldClassName())) + " → " + + colorInfo(safe(report.getClassName())))).append("\n"); + } + + if (report.isRenameOnly()) { + sb.append(quoteLine(colorComment("字段无变化"))).append("\n"); + return; + } + + if (!report.getFieldChanges().isEmpty()) { + int count = report.getFieldChanges().size(); + sb.append(quoteLine("**共 " + colorWarning(String.valueOf(count)) + " 项变更**")) + .append("\n\n"); + for (int i = 0; i < report.getFieldChanges().size(); i++) { + if (i > 0) { + sb.append("\n"); + } + sb.append(formatFieldChange(report.getFieldChanges().get(i))); + } + sb.append("\n"); + } + } + + /** 按类类型选择影响段落 */ + private void appendImpactSections(StringBuilder sb, ClassChangeReport report) { + appendImpactByType(sb, report); + } + + /** Dto/Vo 均展示 request + response(二者可能交叉);Entity/Model 仅类转换 */ + private void appendImpactByType(StringBuilder sb, ClassChangeReport report) { + switch (report.getClassType()) { + case DTO: + case VO: + appendSectionIfNeeded(sb, report, true, true, true); + break; + case ENTITY: + case MODEL: + appendSectionIfNeeded(sb, report, false, false, true); + break; + default: + appendSectionIfNeeded(sb, report, true, true, true); + } + } + + /** 按需追加 request / response / 类转换三个小节 */ + private void appendSectionIfNeeded(StringBuilder sb, ClassChangeReport report, + boolean showRequest, boolean showResponse, boolean showConversion) { + if (showRequest) { + sb.append("### 影响 request 接口").append("\n"); + appendEndpointList(sb, report.getInputImpactEndpoints()); + sb.append("\n"); + } + if (showResponse) { + sb.append("### 影响 response 接口").append("\n"); + appendEndpointList(sb, report.getFrontendImpactEndpoints()); + sb.append("\n"); + } + if (showConversion && report.isConversionCheckEnabled()) { + sb.append("### 类转换影响").append("\n"); + appendConversionList(sb, report); + } + } + + /** 渲染关联 Entity,每项一行 */ + private void appendConversionList(StringBuilder sb, ClassChangeReport report) { + if (report.getConversionEntities().isEmpty()) { + sb.append(quoteLine(colorComment("无"))).append("\n"); + return; + } + for (String entity : report.getConversionEntities()) { + sb.append(quoteKv("Entity", colorInfo(safe(entity)))).append("\n"); + } + } + + /** 渲染接口,每项一行 */ + private void appendEndpointList(StringBuilder sb, List endpoints) { + if (endpoints == null || endpoints.isEmpty()) { + sb.append(quoteLine(colorComment("无"))).append("\n"); + return; + } + for (ApiEndpoint endpoint : endpoints) { + sb.append(formatEndpointLine(endpoint)).append("\n"); + } + } + + /** 接口行:> POST `/path` */ + private String formatEndpointLine(ApiEndpoint endpoint) { + String line = endpoint.displayLine(); + int space = line.indexOf(' '); + if (space > 0) { + String method = line.substring(0, space).trim(); + String path = line.substring(space).trim(); + return quoteLine(colorInfo(method) + " " + inlineCode(path)); + } + return quoteLine(inlineCode(safe(line))); + } + + /** 单条字段变更:标签、说明、类型合并为一行,字段间空行分隔 */ + private String formatFieldChange(FieldChange change) { + String fieldName = inlineCode(safe(change.getFieldName())); + String desc = change.getDescription() == null ? "" : change.getDescription(); + String descPart = desc.isBlank() + ? colorComment("(无说明)") + : colorComment(desc); + + switch (change.getKind()) { + case ADDED: { + StringBuilder line = new StringBuilder(); + line.append(tagAdded()).append(" ").append(fieldName) + .append(" 说明: ").append(descPart); + appendFieldType(line, change); + return quoteLine(line.toString()); + } + case REMOVED: { + StringBuilder line = new StringBuilder(); + line.append(tagRemoved()).append(" ").append(fieldName) + .append(" 说明: ").append(descPart); + appendFieldType(line, change); + return quoteLine(line.toString()); + } + case RENAMED: { + StringBuilder renameLine = new StringBuilder(); + renameLine.append(tagRenamed()).append(" ") + .append(colorComment(safe(change.getOldFieldName()))).append(" → ") + .append(colorInfo(safe(change.getFieldName()))) + .append(" 说明: ").append(descPart); + appendFieldType(renameLine, change); + return quoteLine(renameLine.toString()); + } + case MODIFIED: + default: { + StringBuilder line = new StringBuilder(); + line.append(tagModified()).append(" ").append(fieldName) + .append(" 说明: ").append(descPart); + appendFieldType(line, change); + return quoteLine(line.toString()); + } + } + } + + /** 追加字段类型:新增/重命名(仅改名)用 info,删除用 warning,修改/重命名(改类型)用 old → new */ + private void appendFieldType(StringBuilder line, FieldChange change) { + if (change.getKind() == FieldChange.ChangeKind.RENAMED + || change.getKind() == FieldChange.ChangeKind.MODIFIED) { + String typeDetail = change.getDetail(); + if (typeDetail != null && !typeDetail.isBlank()) { + line.append(" 类型: ").append(formatTypeChange(typeDetail)); + return; + } + } + String singleType = change.getKind() == FieldChange.ChangeKind.REMOVED + ? change.getOldType() + : change.getNewType(); + if (singleType == null || singleType.isBlank()) { + return; + } + line.append(" 类型: "); + if (change.getKind() == FieldChange.ChangeKind.REMOVED) { + line.append(colorWarning(singleType)); + } else { + line.append(colorInfo(singleType)); + } + } + + /** 类型变化:旧 warning → 新 info;泛型尖括号原样展示,不做 HTML 转义 */ + private 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); + } + + private String tagAdded() { + return colorInfo("[新增]"); + } + + private String tagRemoved() { + return colorWarning("[删除]"); + } + + private String tagModified() { + return colorWarning("[修改]"); + } + + private String tagRenamed() { + return colorWarning("[重命名]"); + } + + /** 引用行:{@code >标签: 值}(冒号后两空格) */ + private String quoteKv(String key, String value) { + return "> " + key + ": " + value; + } + + /** 加粗引用行:用于类变更通知头部 */ + private String quoteKvBold(String key, String value) { + return "> **" + key + ": " + value + "**"; + } + + /** 纯引用行 */ + private String quoteLine(String content) { + return "> " + content; + } + + /** 行内代码 */ + private String inlineCode(String text) { + return "`" + text.replace("`", "'") + "`"; + } + + private String colorInfo(String text) { + return "" + text + ""; + } + + private String colorComment(String text) { + return "" + safe(text) + ""; + } + + private String colorWarning(String text) { + return "" + text + ""; + } + + /** 转义 HTML 特殊字符,避免破坏 font 标签 */ + private String safe(String text) { + if (text == null) { + return ""; + } + return text.replace("&", "&").replace("<", "<").replace(">", ">"); + } + + /** POST 企微 Webhook(markdown v1) */ + 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) { + String body = response.body().string(); + return body.contains("\"errcode\":0"); + } + System.out.println("[错误] 企微返回异常: " + response.code() + + (response.body() != null ? " " + response.body().string() : "")); + return false; + } catch (IOException e) { + System.out.println("[错误] 发送企微消息失败: " + e.getMessage()); + return false; + } + } + + /** 超长消息截断(企微上限 4096 字节 UTF-8) */ + private String truncate(String text) { + if (text.length() <= MAX_LENGTH) { + return text; + } + return text.substring(0, MAX_LENGTH) + "\n\n... 消息过长,已截断"; + } + + /** JSON 字符串转义 */ + 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/parser/ClassDeclParser.java b/src/main/java/com/codechecker/parser/ClassDeclParser.java new file mode 100644 index 0000000..eafa1b2 --- /dev/null +++ b/src/main/java/com/codechecker/parser/ClassDeclParser.java @@ -0,0 +1,183 @@ +package com.codechecker.parser; + +import com.github.javaparser.StaticJavaParser; +import com.github.javaparser.ast.CompilationUnit; +import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration; +import com.github.javaparser.ast.body.TypeDeclaration; +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; + +/** + * 从 Java 源文件路径或 AST 解析类名(简单名 / 全限定名)及类级中文说明。 + */ +public class ClassDeclParser { + + /** + * 从源码 AST 提取主类名;解析失败或未找到时回退为路径推导的类名。 + */ + public String resolveClassName(String source, String fallbackFromPath) { + if (source == null || source.isBlank()) { + return fallbackFromPath; + } + try { + CompilationUnit cu = StaticJavaParser.parse(source); + for (TypeDeclaration type : cu.getTypes()) { + if (type instanceof ClassOrInterfaceDeclaration) { + return type.getNameAsString(); + } + } + } catch (Exception ignored) { + // 回退路径类名 + } + return fallbackFromPath; + } + + /** 从 .java 路径提取文件名(无扩展名)作为类名 */ + public static String classNameFromPath(String path) { + String fileName = path.substring(path.lastIndexOf('/') + 1); + if (!fileName.endsWith(".java")) { + return fileName; + } + return fileName.substring(0, fileName.length() - 5); + } + + /** + * 全限定类名:package + 类名;源码无 package 时从文件路径推断。 + */ + public String resolveQualifiedClassName(String source, String relativePath, String fallbackClassName) { + String simpleName = resolveClassName(source, fallbackClassName); + if (source != null && !source.isBlank()) { + try { + CompilationUnit cu = StaticJavaParser.parse(source); + String packageName = cu.getPackageDeclaration() + .map(p -> p.getNameAsString()) + .orElse(""); + if (!packageName.isBlank()) { + return packageName + "." + simpleName; + } + } catch (Exception ignored) { + // 回退路径推断 + } + } + return inferQualifiedFromPath(relativePath, simpleName); + } + + /** + * 提取类级中文说明:@Schema(description/title) > 类 Javadoc 首段。 + */ + public String extractClassDescription(String source, String expectedClassName) { + if (source == null || source.isBlank()) { + return ""; + } + try { + CompilationUnit cu = StaticJavaParser.parse(source); + ClassOrInterfaceDeclaration classDecl = findClass(cu, expectedClassName); + if (classDecl == null) { + return ""; + } + String fromSchema = readSchemaDescription(classDecl); + if (!fromSchema.isEmpty()) { + return fromSchema; + } + return extractClassJavadoc(classDecl); + } catch (Exception ignored) { + return ""; + } + } + + private ClassOrInterfaceDeclaration findClass(CompilationUnit cu, String expectedClassName) { + if (expectedClassName != null && !expectedClassName.isBlank()) { + for (TypeDeclaration type : cu.getTypes()) { + if (type instanceof ClassOrInterfaceDeclaration) { + ClassOrInterfaceDeclaration classDecl = (ClassOrInterfaceDeclaration) type; + if (classDecl.getNameAsString().equals(expectedClassName)) { + return classDecl; + } + } + } + } + for (TypeDeclaration type : cu.getTypes()) { + if (type instanceof ClassOrInterfaceDeclaration) { + return (ClassOrInterfaceDeclaration) type; + } + } + return null; + } + + private String readSchemaDescription(ClassOrInterfaceDeclaration classDecl) { + for (AnnotationExpr annotation : classDecl.getAnnotations()) { + if (!"Schema".equals(annotation.getNameAsString())) { + continue; + } + String description = readAnnotationStringValue(annotation, "description"); + if (!description.isEmpty()) { + return description; + } + String title = readAnnotationStringValue(annotation, "title"); + if (!title.isEmpty()) { + return title; + } + } + return ""; + } + + private 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)) { + return literalString(single.getMemberValue()); + } + } + return ""; + } + + private String literalString(Expression expression) { + if (expression.isStringLiteralExpr()) { + return expression.asStringLiteralExpr().getValue().trim(); + } + return ""; + } + + private String extractClassJavadoc(ClassOrInterfaceDeclaration classDecl) { + Optional javadoc = classDecl.getJavadocComment(); + if (javadoc.isEmpty()) { + return ""; + } + String text = javadoc.get().parse().getDescription().toText(); + return text == null ? "" : text.trim().replaceAll("\\s+", " "); + } + + /** 从 src/main/java/ 后的路径推断 package.className */ + public static String inferQualifiedFromPath(String relativePath, String className) { + if (relativePath == null || relativePath.isBlank()) { + return className; + } + String normalized = relativePath.replace('\\', '/'); + String marker = "src/main/java/"; + int idx = normalized.indexOf(marker); + if (idx < 0) { + return className; + } + String subPath = normalized.substring(idx + marker.length()); + int lastSlash = subPath.lastIndexOf('/'); + if (lastSlash <= 0) { + return className; + } + String packageName = subPath.substring(0, lastSlash).replace('/', '.'); + return packageName + "." + className; + } +} diff --git a/src/main/java/com/codechecker/parser/ClassFieldParser.java b/src/main/java/com/codechecker/parser/ClassFieldParser.java new file mode 100644 index 0000000..15be0c1 --- /dev/null +++ b/src/main/java/com/codechecker/parser/ClassFieldParser.java @@ -0,0 +1,135 @@ +package com.codechecker.parser; + +import com.codechecker.model.FieldInfo; +import com.github.javaparser.StaticJavaParser; +import com.github.javaparser.ast.CompilationUnit; +import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration; +import com.github.javaparser.ast.body.FieldDeclaration; +import com.github.javaparser.ast.body.TypeDeclaration; +import com.github.javaparser.ast.body.VariableDeclarator; +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.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * 解析模型类字段:名称、类型、业务说明(注解或 Javadoc)。 + */ +public class ClassFieldParser { + + /** 解析指定类的实例字段列表 */ + public List parseFields(String source, String expectedClassName) { + if (source == null || source.isBlank()) { + return List.of(); + } + CompilationUnit cu = StaticJavaParser.parse(source); + ClassOrInterfaceDeclaration classDecl = findClass(cu, expectedClassName); + if (classDecl == null) { + return List.of(); + } + return parseClassFields(classDecl); + } + + /** 按类名查找类声明,找不到则取第一个类 */ + private ClassOrInterfaceDeclaration findClass(CompilationUnit cu, String expectedClassName) { + if (expectedClassName != null && !expectedClassName.isBlank()) { + for (TypeDeclaration type : cu.getTypes()) { + if (type instanceof ClassOrInterfaceDeclaration) { + ClassOrInterfaceDeclaration classDecl = (ClassOrInterfaceDeclaration) type; + if (classDecl.getNameAsString().equals(expectedClassName)) { + return classDecl; + } + } + } + } + for (TypeDeclaration type : cu.getTypes()) { + if (type instanceof ClassOrInterfaceDeclaration) { + return (ClassOrInterfaceDeclaration) type; + } + } + return null; + } + + /** 提取非 static final 字段,跳过常量 */ + private List parseClassFields(ClassOrInterfaceDeclaration classDecl) { + Map fields = new LinkedHashMap<>(); + for (FieldDeclaration fieldDecl : classDecl.getFields()) { + if (fieldDecl.isStatic() && fieldDecl.isFinal()) { + continue; + } + String type = TypeNameUtils.typeToString(fieldDecl.getElementType()); + String description = extractFieldLabel(fieldDecl); + for (VariableDeclarator variable : fieldDecl.getVariables()) { + fields.put(variable.getNameAsString(), new FieldInfo(variable.getNameAsString(), type, description)); + } + } + return new ArrayList<>(fields.values()); + } + + /** + * 字段说明:@Schema(description) > @ApiModelProperty > Javadoc,均无则空串。 + */ + String extractFieldLabel(FieldDeclaration fieldDecl) { + for (AnnotationExpr annotation : fieldDecl.getAnnotations()) { + String annName = annotation.getNameAsString(); + if ("Schema".equals(annName)) { + String description = readAnnotationStringValue(annotation, "description"); + if (!description.isEmpty()) { + return description; + } + } + if ("ApiModelProperty".equals(annName)) { + String value = readAnnotationStringValue(annotation, "value"); + if (!value.isEmpty()) { + return value; + } + } + } + return extractJavadoc(fieldDecl); + } + + /** 读取注解中的字符串属性值 */ + private 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)) { + return literalString(single.getMemberValue()); + } + } + return ""; + } + + /** 提取字符串字面量值 */ + private String literalString(Expression expression) { + if (expression.isStringLiteralExpr()) { + return expression.asStringLiteralExpr().getValue().trim(); + } + return ""; + } + + /** 从字段 Javadoc 提取首段描述 */ + private String extractJavadoc(FieldDeclaration fieldDecl) { + Optional javadoc = fieldDecl.getJavadocComment(); + if (javadoc.isEmpty()) { + return ""; + } + String text = javadoc.get().parse().getDescription().toText(); + return text == null ? "" : text.trim().replaceAll("\\s+", " "); + } +} diff --git a/src/main/java/com/codechecker/parser/ConversionParser.java b/src/main/java/com/codechecker/parser/ConversionParser.java new file mode 100644 index 0000000..6bfc1ec --- /dev/null +++ b/src/main/java/com/codechecker/parser/ConversionParser.java @@ -0,0 +1,100 @@ +package com.codechecker.parser; + +import com.github.javaparser.StaticJavaParser; +import com.github.javaparser.ast.CompilationUnit; +import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration; +import com.github.javaparser.ast.body.MethodDeclaration; +import com.github.javaparser.ast.body.TypeDeclaration; +import com.github.javaparser.ast.expr.MethodCallExpr; +import com.github.javaparser.ast.visitor.VoidVisitorAdapter; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.stream.Stream; + +/** + * 扫描 Dto→Entity 转换关系:convert 方法返回值、BeanUtils.copyProperties 调用。 + */ +public class ConversionParser { + + /** 在类内查找 convert 方法,收集返回 Entity 的类型名 */ + public Set findConvertTargetsInClass(String source, String className) { + Set entities = new LinkedHashSet<>(); + if (source == null || source.isBlank()) { + return entities; + } + CompilationUnit cu = StaticJavaParser.parse(source); + for (TypeDeclaration type : cu.getTypes()) { + if (type instanceof ClassOrInterfaceDeclaration) { + ClassOrInterfaceDeclaration classDecl = (ClassOrInterfaceDeclaration) type; + if (!classDecl.getNameAsString().equals(className)) { + continue; + } + for (MethodDeclaration method : classDecl.getMethods()) { + if (!"convert".equals(method.getNameAsString())) { + continue; + } + String returnType = TypeNameUtils.simpleName(TypeNameUtils.typeToString(method.getType())); + if (returnType.endsWith("Entity")) { + entities.add(returnType); + } + } + } + } + return entities; + } + + /** 递归扫描目录,查找 BeanUtils.copyProperties(sourceClass, *Entity) */ + public Set findBeanUtilsTargets(Path rootDir, String sourceClassName) throws IOException { + Set entities = new LinkedHashSet<>(); + if (!Files.exists(rootDir)) { + return entities; + } + try (Stream paths = Files.walk(rootDir)) { + paths.filter(path -> path.toString().endsWith(".java")).forEach(path -> { + try { + String source = Files.readString(path, StandardCharsets.UTF_8); + entities.addAll(scanBeanUtilsInSource(source, sourceClassName)); + } catch (IOException ignored) { + // 跳过 + } + }); + } + return entities; + } + + /** 在单文件源码中扫描 BeanUtils.copyProperties 调用 */ + private Set scanBeanUtilsInSource(String source, String sourceClassName) { + Set entities = new LinkedHashSet<>(); + CompilationUnit cu = StaticJavaParser.parse(source); + cu.accept(new VoidVisitorAdapter() { + @Override + public void visit(MethodCallExpr call, Void arg) { + super.visit(call, arg); + if (!call.getNameAsString().equals("copyProperties")) { + return; + } + if (call.getScope().isEmpty()) { + return; + } + String scope = call.getScope().get().toString(); + if (!scope.endsWith("BeanUtils")) { + return; + } + if (call.getArguments().size() < 2) { + return; + } + String firstArg = TypeNameUtils.simpleName(call.getArguments().get(0).toString()); + String secondArg = TypeNameUtils.simpleName(call.getArguments().get(1).toString()); + if (sourceClassName.equals(firstArg) && secondArg.endsWith("Entity")) { + entities.add(secondArg); + } + } + }, null); + return entities; + } +} diff --git a/src/main/java/com/codechecker/parser/EndpointParser.java b/src/main/java/com/codechecker/parser/EndpointParser.java new file mode 100644 index 0000000..8dee22f --- /dev/null +++ b/src/main/java/com/codechecker/parser/EndpointParser.java @@ -0,0 +1,301 @@ +package com.codechecker.parser; + +import com.codechecker.model.ApiEndpoint; +import com.github.javaparser.StaticJavaParser; +import com.github.javaparser.ast.CompilationUnit; +import com.github.javaparser.ast.NodeList; +import com.github.javaparser.ast.expr.Expression; +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.type.Type; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.stream.Stream; + +/** + * 扫描 Controller / Feign 接口,提取 HTTP 方法、URI、入参/返回类型。 + */ +public class EndpointParser { + 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" + ); + + /** 扫描 @RestController / @Controller 目录 */ + public List scanControllerDirectory(Path rootDir, String relativePrefix) throws IOException { + return scanDirectory(rootDir, relativePrefix, ScanMode.CONTROLLER); + } + + /** 扫描 @FeignClient 接口目录 */ + public List scanFeignDirectory(Path rootDir, String relativePrefix) throws IOException { + return scanDirectory(rootDir, relativePrefix, ScanMode.FEIGN); + } + + /** 递归 walk 目录下 .java 并解析 */ + private List scanDirectory(Path rootDir, String relativePrefix, ScanMode mode) throws IOException { + if (!Files.exists(rootDir)) { + return List.of(); + } + List endpoints = new ArrayList<>(); + try (Stream paths = Files.walk(rootDir)) { + paths.filter(path -> path.toString().endsWith(".java")).forEach(path -> { + try { + String source = Files.readString(path, StandardCharsets.UTF_8); + String relativePath = toRelativePath(relativePrefix, rootDir, path); + endpoints.addAll(parseCompilationUnit(source, relativePath, mode)); + } catch (IOException ignored) { + // 跳过无法读取的文件 + } + }); + } + return endpoints; + } + + /** 解析单个编译单元,过滤 Controller 或 Feign */ + private List parseCompilationUnit(String source, String relativePath, ScanMode mode) { + CompilationUnit cu = StaticJavaParser.parse(source); + List endpoints = new ArrayList<>(); + + for (TypeDeclaration type : cu.getTypes()) { + if (!(type instanceof ClassOrInterfaceDeclaration)) { + continue; + } + ClassOrInterfaceDeclaration declaration = (ClassOrInterfaceDeclaration) type; + if (mode == ScanMode.CONTROLLER && !isController(declaration)) { + continue; + } + if (mode == ScanMode.FEIGN && !isFeignClient(declaration)) { + continue; + } + + String basePath = mode == ScanMode.FEIGN + ? joinPaths(extractFeignBasePath(declaration), extractTypeLevelPath(declaration)) + : extractTypeLevelPath(declaration); + for (MethodDeclaration method : declaration.getMethods()) { + if (mode == ScanMode.FEIGN && declaration.isInterface()) { + endpoints.addAll(parseMethod(method, basePath, relativePath)); + } else if (mode == ScanMode.CONTROLLER && !declaration.isInterface()) { + endpoints.addAll(parseMethod(method, basePath, relativePath)); + } + } + } + return endpoints; + } + + /** 解析方法上的 Mapping 注解,生成 ApiEndpoint */ + private List parseMethod(MethodDeclaration method, String basePath, String sourceFile) { + List endpoints = new ArrayList<>(); + for (AnnotationExpr annotation : method.getAnnotations()) { + String annName = annotation.getNameAsString(); + if (!MAPPING_ANNOTATIONS.contains(annName)) { + continue; + } + List subPaths = extractPaths(annotation); + List httpMethods = extractHttpMethods(annotation, annName); + for (String httpMethod : httpMethods) { + for (String subPath : subPaths) { + String uri = joinPaths(basePath, subPath); + Set paramTypes = extractParamTypes(method); + Set returnTypes = TypeNameUtils.peelDirectTypeNames(method.getType()); + endpoints.add(new ApiEndpoint(httpMethod, uri, sourceFile, paramTypes, returnTypes)); + } + } + } + return endpoints; + } + + /** 收集方法入参类型简单名 */ + private Set extractParamTypes(MethodDeclaration method) { + Set paramTypes = new LinkedHashSet<>(); + for (Parameter parameter : method.getParameters()) { + Type type = parameter.getType(); + paramTypes.add(TypeNameUtils.simpleName(TypeNameUtils.typeToString(type))); + paramTypes.addAll(TypeNameUtils.peelDirectTypeNames(type)); + } + return paramTypes; + } + + /** 是否 Spring Controller */ + private boolean isController(ClassOrInterfaceDeclaration declaration) { + return declaration.getAnnotations().stream() + .anyMatch(ann -> { + String name = ann.getNameAsString(); + return "RestController".equals(name) || "Controller".equals(name); + }); + } + + /** 是否 Feign 客户端接口 */ + private boolean isFeignClient(ClassOrInterfaceDeclaration declaration) { + return declaration.isInterface() && declaration.getAnnotations().stream() + .anyMatch(ann -> "FeignClient".equals(ann.getNameAsString())); + } + + /** 类级 @RequestMapping 路径 */ + private String extractTypeLevelPath(ClassOrInterfaceDeclaration declaration) { + for (AnnotationExpr annotation : declaration.getAnnotations()) { + if ("RequestMapping".equals(annotation.getNameAsString())) { + List paths = extractPaths(annotation); + if (!paths.isEmpty()) { + return paths.get(0); + } + } + } + return ""; + } + + /** @FeignClient(path=...) 基础路径 */ + private String extractFeignBasePath(ClassOrInterfaceDeclaration declaration) { + for (AnnotationExpr annotation : declaration.getAnnotations()) { + if ("FeignClient".equals(annotation.getNameAsString())) { + List paths = AnnotationValueReader.readStringArray(annotation, "path"); + if (!paths.isEmpty()) { + return paths.get(0); + } + } + } + return ""; + } + + /** 从 Mapping 注解读取 value/path */ + private List extractPaths(AnnotationExpr annotation) { + return AnnotationValueReader.readStringArray(annotation, "value", "path"); + } + + /** 推断 HTTP 方法;RequestMapping 无 method 时默认 GET */ + private List extractHttpMethods(AnnotationExpr annotation, String annName) { + if (!"RequestMapping".equals(annName)) { + return List.of(MAPPING_DEFAULT_METHOD.getOrDefault(annName, "GET")); + } + List methods = AnnotationValueReader.readEnumArray(annotation, "method"); + if (methods.isEmpty()) { + return List.of("GET"); + } + return 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; + } + String joined = normalizedBase + "/" + normalizedSub.substring(1); + return joined.replaceAll("/+", "/"); + } + + /** 规范化 URI 路径 */ + private String normalizePath(String path) { + if (path == null || path.isBlank()) { + return ""; + } + String trimmed = path.trim(); + if (!trimmed.startsWith("/")) { + trimmed = "/" + trimmed; + } + return trimmed.replaceAll("/+", "/"); + } + + /** 生成相对仓库根的路径 */ + private String toRelativePath(String relativePrefix, Path rootDir, Path file) { + String relative = rootDir.relativize(file).toString().replace("\\", "/"); + if (relativePrefix == null || relativePrefix.isBlank()) { + return relative; + } + String prefix = relativePrefix.endsWith("/") + ? relativePrefix.substring(0, relativePrefix.length() - 1) + : relativePrefix; + return prefix + "/" + relative; + } + + private enum ScanMode { + CONTROLLER, FEIGN + } + + /** 从注解 AST 读取字符串或枚举数组 */ + static final class AnnotationValueReader { + private AnnotationValueReader() { + } + + static 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; + } + + static 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 static 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/parser/TypeNameUtils.java b/src/main/java/com/codechecker/parser/TypeNameUtils.java new file mode 100644 index 0000000..27ed236 --- /dev/null +++ b/src/main/java/com/codechecker/parser/TypeNameUtils.java @@ -0,0 +1,117 @@ +package com.codechecker.parser; + +import com.github.javaparser.ast.type.ClassOrInterfaceType; +import com.github.javaparser.ast.type.Type; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** + * Java 类型名工具:转字符串、取简单名、剥离 ActionResult/List 等泛型包装。 + */ +public final class TypeNameUtils { + /** 需要向内层继续剥离的包装类型 */ + private static final Set WRAPPER_TYPES = Set.of( + "ActionResult", "List", "PageListVO", "Set", "Collection", "Iterable", "Optional" + ); + + private TypeNameUtils() { + } + + /** Type 转无空白字符串 */ + public static String typeToString(Type type) { + if (type == null) { + return "Object"; + } + return type.toString().replaceAll("\\s+", ""); + } + + /** 取类型简单名,去掉包名与泛型 */ + public static String simpleName(String typeName) { + if (typeName == null || typeName.isBlank()) { + return ""; + } + String cleaned = typeName.replaceAll("\\s+", ""); + int genericStart = cleaned.indexOf('<'); + String base = genericStart >= 0 ? cleaned.substring(0, genericStart) : cleaned; + int dot = base.lastIndexOf('.'); + return dot >= 0 ? base.substring(dot + 1) : base; + } + + /** 从 Type AST 收集实际业务类型简单名(穿透包装泛型) */ + public static Set peelDirectTypeNames(Type type) { + Set result = new LinkedHashSet<>(); + collectPeelTargets(type, result); + return result; + } + + /** 从类型字符串收集实际业务类型简单名 */ + public static Set peelDirectTypeNames(String typeName) { + Set result = new LinkedHashSet<>(); + collectPeelTargets(typeName, result); + return result; + } + + /** 递归收集:包装类型则进入泛型参数,否则记录简单名 */ + private static void collectPeelTargets(Type type, Set result) { + if (type == null) { + return; + } + if (type.isClassOrInterfaceType()) { + ClassOrInterfaceType classType = type.asClassOrInterfaceType(); + String name = simpleName(classType.getNameAsString()); + if (WRAPPER_TYPES.contains(name) && classType.getTypeArguments().isPresent()) { + for (Type arg : classType.getTypeArguments().get()) { + collectPeelTargets(arg, result); + } + return; + } + result.add(name); + return; + } + result.add(simpleName(typeToString(type))); + } + + /** 字符串版递归收集 */ + private static void collectPeelTargets(String typeName, Set result) { + String cleaned = typeName.replaceAll("\\s+", ""); + int genericStart = cleaned.indexOf('<'); + if (genericStart < 0) { + result.add(simpleName(cleaned)); + return; + } + String outer = simpleName(cleaned.substring(0, genericStart)); + String inner = cleaned.substring(genericStart + 1, cleaned.lastIndexOf('>')); + if (WRAPPER_TYPES.contains(outer)) { + for (String part : splitGenericArgs(inner)) { + collectPeelTargets(part, result); + } + return; + } + result.add(outer); + } + + /** 按逗号分割泛型参数,支持嵌套 <> */ + private static List splitGenericArgs(String inner) { + List parts = new java.util.ArrayList<>(); + int depth = 0; + StringBuilder current = new StringBuilder(); + for (char ch : inner.toCharArray()) { + if (ch == '<') { + depth++; + } else if (ch == '>') { + depth--; + } else if (ch == ',' && depth == 0) { + parts.add(current.toString().trim()); + current.setLength(0); + continue; + } + current.append(ch); + } + if (current.length() > 0) { + parts.add(current.toString().trim()); + } + return parts; + } +}