From ec3bd1d0b2817e27042e5d05efd993dc6df93a45 Mon Sep 17 00:00:00 2001 From: dongzi Date: Fri, 5 Jun 2026 18:21:45 +0800 Subject: [PATCH] commit --- .../class-checker/dependency-reduced-pom.xml | 51 ++++ .gitea/class-checker/pom.xml | 82 +++++ .../main/java/com/aicheck/ClassCheckMain.java | 74 +++++ .../aicheck/analyzer/ClassChangeAnalyzer.java | 72 +++++ .../analyzer/EndpointIndexBuilder.java | 32 ++ .../com/aicheck/analyzer/FieldDiffEngine.java | 54 ++++ .../com/aicheck/analyzer/ImpactAnalyzer.java | 59 ++++ .../java/com/aicheck/config/AppConfig.java | 121 ++++++++ .../com/aicheck/git/GitChangeScanner.java | 128 ++++++++ .../java/com/aicheck/model/ApiEndpoint.java | 49 +++ .../com/aicheck/model/ChangedClassFile.java | 35 +++ .../com/aicheck/model/ClassChangeReport.java | 79 +++++ .../java/com/aicheck/model/ClassType.java | 41 +++ .../java/com/aicheck/model/FieldChange.java | 69 +++++ .../java/com/aicheck/model/FieldInfo.java | 46 +++ .../com/aicheck/notify/WeComNotifier.java | 179 +++++++++++ .../com/aicheck/parser/ClassFieldParser.java | 58 ++++ .../com/aicheck/parser/ConversionParser.java | 94 ++++++ .../com/aicheck/parser/EndpointParser.java | 282 ++++++++++++++++++ .../com/aicheck/parser/TypeNameUtils.java | 106 +++++++ .../target/maven-archiver/pom.properties | 5 + .../compile/default-compile/createdFiles.lst | 24 ++ .../compile/default-compile/inputFiles.lst | 18 ++ .gitea/config.yaml | 27 ++ .gitea/workflows/class-change-check.yml | 50 ++++ 25 files changed, 1835 insertions(+) create mode 100644 .gitea/class-checker/dependency-reduced-pom.xml create mode 100644 .gitea/class-checker/pom.xml create mode 100644 .gitea/class-checker/src/main/java/com/aicheck/ClassCheckMain.java create mode 100644 .gitea/class-checker/src/main/java/com/aicheck/analyzer/ClassChangeAnalyzer.java create mode 100644 .gitea/class-checker/src/main/java/com/aicheck/analyzer/EndpointIndexBuilder.java create mode 100644 .gitea/class-checker/src/main/java/com/aicheck/analyzer/FieldDiffEngine.java create mode 100644 .gitea/class-checker/src/main/java/com/aicheck/analyzer/ImpactAnalyzer.java create mode 100644 .gitea/class-checker/src/main/java/com/aicheck/config/AppConfig.java create mode 100644 .gitea/class-checker/src/main/java/com/aicheck/git/GitChangeScanner.java create mode 100644 .gitea/class-checker/src/main/java/com/aicheck/model/ApiEndpoint.java create mode 100644 .gitea/class-checker/src/main/java/com/aicheck/model/ChangedClassFile.java create mode 100644 .gitea/class-checker/src/main/java/com/aicheck/model/ClassChangeReport.java create mode 100644 .gitea/class-checker/src/main/java/com/aicheck/model/ClassType.java create mode 100644 .gitea/class-checker/src/main/java/com/aicheck/model/FieldChange.java create mode 100644 .gitea/class-checker/src/main/java/com/aicheck/model/FieldInfo.java create mode 100644 .gitea/class-checker/src/main/java/com/aicheck/notify/WeComNotifier.java create mode 100644 .gitea/class-checker/src/main/java/com/aicheck/parser/ClassFieldParser.java create mode 100644 .gitea/class-checker/src/main/java/com/aicheck/parser/ConversionParser.java create mode 100644 .gitea/class-checker/src/main/java/com/aicheck/parser/EndpointParser.java create mode 100644 .gitea/class-checker/src/main/java/com/aicheck/parser/TypeNameUtils.java create mode 100644 .gitea/class-checker/target/maven-archiver/pom.properties create mode 100644 .gitea/class-checker/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst create mode 100644 .gitea/class-checker/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst create mode 100644 .gitea/config.yaml create mode 100644 .gitea/workflows/class-change-check.yml diff --git a/.gitea/class-checker/dependency-reduced-pom.xml b/.gitea/class-checker/dependency-reduced-pom.xml new file mode 100644 index 0000000..cd6d6a9 --- /dev/null +++ b/.gitea/class-checker/dependency-reduced-pom.xml @@ -0,0 +1,51 @@ + + + 4.0.0 + com.aicheck + class-checker + 1.0.0 + + class-checker + + + maven-compiler-plugin + 3.13.0 + + + maven-shade-plugin + 3.6.0 + + + package + + shade + + + + + com.aicheck.ClassCheckMain + + + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + + + + + + + + + + + 11 + 3.25.10 + 11 + UTF-8 + + diff --git a/.gitea/class-checker/pom.xml b/.gitea/class-checker/pom.xml new file mode 100644 index 0000000..f4200ec --- /dev/null +++ b/.gitea/class-checker/pom.xml @@ -0,0 +1,82 @@ + + + 4.0.0 + + com.aicheck + class-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 + + + + + class-checker + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + + org.apache.maven.plugins + maven-shade-plugin + 3.6.0 + + + package + + shade + + + + + com.aicheck.ClassCheckMain + + + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + + + + + + + + + + diff --git a/.gitea/class-checker/src/main/java/com/aicheck/ClassCheckMain.java b/.gitea/class-checker/src/main/java/com/aicheck/ClassCheckMain.java new file mode 100644 index 0000000..7ce48fc --- /dev/null +++ b/.gitea/class-checker/src/main/java/com/aicheck/ClassCheckMain.java @@ -0,0 +1,74 @@ +package com.aicheck; + +import com.aicheck.analyzer.ClassChangeAnalyzer; +import com.aicheck.analyzer.EndpointIndexBuilder; +import com.aicheck.config.AppConfig; +import com.aicheck.git.GitChangeScanner; +import com.aicheck.model.ApiEndpoint; +import com.aicheck.model.ClassChangeReport; +import com.aicheck.notify.WeComNotifier; +import picocli.CommandLine; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; + +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Callable; + +@Command(name = "class-checker", mixinStandardHelpOptions = true, + description = "检测 Vo/Dto/Entity/Model 类变更并发送企业微信通知") +public class ClassCheckMain 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 ClassCheckMain()).execute(args); + System.exit(exitCode); + } + + @Override + public Integer call() throws Exception { + AppConfig appConfig = AppConfig.load(config.toAbsolutePath()); + if (!appConfig.isEnabled()) { + System.out.println("类变更检测已关闭(class_check.enabled=false)"); + return 0; + } + + GitChangeScanner gitScanner = new GitChangeScanner(repoRoot.toAbsolutePath()); + 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); + System.out.println("检测到需通知的类变更数量: " + reports.size()); + + if (reports.isEmpty()) { + if (appConfig.isOnlyOnChange()) { + System.out.println("无类变更,静默退出"); + } + return 0; + } + + WeComNotifier notifier = new WeComNotifier(); + notifier.sendAll(appConfig.getWecomWebhookUrl(), reports, modifier, modifyTime); + return 0; + } +} diff --git a/.gitea/class-checker/src/main/java/com/aicheck/analyzer/ClassChangeAnalyzer.java b/.gitea/class-checker/src/main/java/com/aicheck/analyzer/ClassChangeAnalyzer.java new file mode 100644 index 0000000..eb64caa --- /dev/null +++ b/.gitea/class-checker/src/main/java/com/aicheck/analyzer/ClassChangeAnalyzer.java @@ -0,0 +1,72 @@ +package com.aicheck.analyzer; + +import com.aicheck.config.AppConfig; +import com.aicheck.git.GitChangeScanner; +import com.aicheck.model.ChangedClassFile; +import com.aicheck.model.ClassChangeReport; +import com.aicheck.model.FieldChange; +import com.aicheck.model.FieldInfo; +import com.aicheck.parser.ClassFieldParser; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +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(); + + public ClassChangeAnalyzer(GitChangeScanner gitScanner) { + this.gitScanner = gitScanner; + } + + public List analyze(Path repoRoot, AppConfig config, String oldSha, String newSha, + Map endpointIndex) throws IOException { + List changedFiles = gitScanner.scanChangedClasses(oldSha, newSha); + List reports = new ArrayList<>(); + + for (ChangedClassFile changedFile : changedFiles) { + if (changedFile.getStatus() == ChangedClassFile.ChangeStatus.DELETED) { + ClassChangeReport report = new ClassChangeReport( + changedFile.getClassName(), + changedFile.getClassType(), + changedFile.getRelativePath(), + true, + config.isDtoEntityConversionEnabled() + ); + String oldSource = gitScanner.readFileAtCommit(oldSha, changedFile.getRelativePath()); + impactAnalyzer.analyze(report, endpointIndex, config, repoRoot, oldSource); + reports.add(report); + continue; + } + + String oldSource = gitScanner.readFileAtCommit(oldSha, changedFile.getRelativePath()); + String newSource = gitScanner.readFileAtCommit(newSha, changedFile.getRelativePath()); + if (newSource == null || newSource.isBlank()) { + newSource = gitScanner.readFileAtHead(changedFile.getRelativePath()); + } + List oldFields = classFieldParser.parseFields(oldSource, changedFile.getClassName()); + List newFields = classFieldParser.parseFields(newSource, changedFile.getClassName()); + List fieldChanges = fieldDiffEngine.diff(oldFields, newFields); + if (fieldChanges.isEmpty()) { + continue; + } + + ClassChangeReport report = new ClassChangeReport( + changedFile.getClassName(), + changedFile.getClassType(), + changedFile.getRelativePath(), + false, + config.isDtoEntityConversionEnabled() + ); + fieldChanges.forEach(report::addFieldChange); + impactAnalyzer.analyze(report, endpointIndex, config, repoRoot, newSource); + reports.add(report); + } + return reports; + } +} diff --git a/.gitea/class-checker/src/main/java/com/aicheck/analyzer/EndpointIndexBuilder.java b/.gitea/class-checker/src/main/java/com/aicheck/analyzer/EndpointIndexBuilder.java new file mode 100644 index 0000000..6edb92a --- /dev/null +++ b/.gitea/class-checker/src/main/java/com/aicheck/analyzer/EndpointIndexBuilder.java @@ -0,0 +1,32 @@ +package com.aicheck.analyzer; + +import com.aicheck.config.AppConfig; +import com.aicheck.model.ApiEndpoint; +import com.aicheck.parser.EndpointParser; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public class EndpointIndexBuilder { + private final EndpointParser endpointParser = new EndpointParser(); + + 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; + } + + private void addEndpoints(Map index, List endpoints) { + for (ApiEndpoint endpoint : endpoints) { + index.putIfAbsent(endpoint.endpointKey(), endpoint); + } + } +} diff --git a/.gitea/class-checker/src/main/java/com/aicheck/analyzer/FieldDiffEngine.java b/.gitea/class-checker/src/main/java/com/aicheck/analyzer/FieldDiffEngine.java new file mode 100644 index 0000000..aebac05 --- /dev/null +++ b/.gitea/class-checker/src/main/java/com/aicheck/analyzer/FieldDiffEngine.java @@ -0,0 +1,54 @@ +package com.aicheck.analyzer; + +import com.aicheck.model.FieldChange; +import com.aicheck.model.FieldInfo; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public class FieldDiffEngine { + public List diff(List oldFields, List newFields) { + Map oldMap = toMap(oldFields); + Map newMap = toMap(newFields); + List changes = new ArrayList<>(); + + for (Map.Entry entry : newMap.entrySet()) { + FieldInfo oldField = oldMap.get(entry.getKey()); + if (oldField == null) { + changes.add(FieldChange.added(entry.getValue())); + } else if (!oldField.equals(entry.getValue())) { + changes.add(FieldChange.modified(oldField, entry.getValue(), buildDetail(oldField, entry.getValue()))); + } + } + + for (Map.Entry entry : oldMap.entrySet()) { + if (!newMap.containsKey(entry.getKey())) { + changes.add(FieldChange.removed(entry.getValue())); + } + } + return changes; + } + + private Map toMap(List fields) { + Map map = new LinkedHashMap<>(); + for (FieldInfo field : fields) { + map.put(field.getName(), field); + } + return map; + } + + private String buildDetail(FieldInfo oldField, FieldInfo newField) { + List parts = new ArrayList<>(); + if (!oldField.getType().equals(newField.getType())) { + parts.add(oldField.getType() + " → " + newField.getType()); + } + if (!oldField.getDescription().equals(newField.getDescription())) { + String oldDesc = oldField.getDescription().isBlank() ? "无" : oldField.getDescription(); + String newDesc = newField.getDescription().isBlank() ? "无" : newField.getDescription(); + parts.add("说明:" + oldDesc + " → " + newDesc); + } + return String.join(";", parts); + } +} diff --git a/.gitea/class-checker/src/main/java/com/aicheck/analyzer/ImpactAnalyzer.java b/.gitea/class-checker/src/main/java/com/aicheck/analyzer/ImpactAnalyzer.java new file mode 100644 index 0000000..2c7aff5 --- /dev/null +++ b/.gitea/class-checker/src/main/java/com/aicheck/analyzer/ImpactAnalyzer.java @@ -0,0 +1,59 @@ +package com.aicheck.analyzer; + +import com.aicheck.config.AppConfig; +import com.aicheck.model.ApiEndpoint; +import com.aicheck.model.ClassChangeReport; +import com.aicheck.model.ClassType; +import com.aicheck.parser.ConversionParser; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +public class ImpactAnalyzer { + private final ConversionParser conversionParser = new ConversionParser(); + + public void analyze(ClassChangeReport report, Map endpointIndex, + AppConfig config, Path repoRoot, String classSource) throws IOException { + if (report.getClassType() == ClassType.ENTITY) { + return; + } + + String className = report.getClassName(); + List inputImpacts = new ArrayList<>(); + List frontendImpacts = new ArrayList<>(); + + for (ApiEndpoint endpoint : endpointIndex.values()) { + if (matchesType(endpoint.getParamTypes(), className)) { + inputImpacts.add(endpoint); + } + if (matchesType(endpoint.getReturnTypes(), className)) { + frontendImpacts.add(endpoint); + } + } + + inputImpacts.forEach(report::addInputImpact); + frontendImpacts.forEach(report::addFrontendImpact); + + if (!config.isDtoEntityConversionEnabled()) { + return; + } + + if (classSource != null && !classSource.isBlank()) { + conversionParser.findConvertTargetsInClass(classSource, className) + .forEach(report::addConversionEntity); + } + + for (String scanDir : config.getConversionScanDirs()) { + conversionParser.findBeanUtilsTargets(repoRoot.resolve(scanDir), className) + .forEach(report::addConversionEntity); + } + } + + private boolean matchesType(Collection types, String className) { + return types != null && types.contains(className); + } +} diff --git a/.gitea/class-checker/src/main/java/com/aicheck/config/AppConfig.java b/.gitea/class-checker/src/main/java/com/aicheck/config/AppConfig.java new file mode 100644 index 0000000..7811a95 --- /dev/null +++ b/.gitea/class-checker/src/main/java/com/aicheck/config/AppConfig.java @@ -0,0 +1,121 @@ +package com.aicheck.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; + +public class AppConfig { + private boolean enabled = 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 onlyOnChange = true; + + @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 classCheck = mapOrEmpty(root.get("class_check")); + config.enabled = boolOrDefault(classCheck.get("enabled"), true); + + 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")); + + Map notify = mapOrEmpty(root.get("notify")); + config.onlyOnChange = boolOrDefault(notify.get("only_on_change"), true); + + return config; + } + + @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<>(); + } + + private static boolean boolOrDefault(Object value, boolean defaultValue) { + if (value instanceof Boolean) { + return (Boolean) value; + } + return defaultValue; + } + + private static String stringOrEmpty(Object value) { + return value == null ? "" : value.toString(); + } + + public boolean isEnabled() { + return enabled; + } + + public boolean isDtoEntityConversionEnabled() { + return dtoEntityConversionEnabled; + } + + public List getModelDirs() { + return modelDirs; + } + + public List getControllerScanDirs() { + return controllerScanDirs; + } + + public List getFeignScanDirs() { + return feignScanDirs; + } + + public List getConversionScanDirs() { + return conversionScanDirs; + } + + public String getWecomWebhookUrl() { + return wecomWebhookUrl; + } + + public boolean isOnlyOnChange() { + return onlyOnChange; + } +} diff --git a/.gitea/class-checker/src/main/java/com/aicheck/git/GitChangeScanner.java b/.gitea/class-checker/src/main/java/com/aicheck/git/GitChangeScanner.java new file mode 100644 index 0000000..6e86cf2 --- /dev/null +++ b/.gitea/class-checker/src/main/java/com/aicheck/git/GitChangeScanner.java @@ -0,0 +1,128 @@ +package com.aicheck.git; + +import com.aicheck.model.ChangedClassFile; +import com.aicheck.model.ClassType; + +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.List; +import java.util.Locale; + +public class GitChangeScanner { + private final Path repoRoot; + + 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 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(); + String path = normalizePath(parts[parts.length - 1]); + if (!path.endsWith(".java")) { + continue; + } + + String className = extractClassName(path); + ClassType classType = ClassType.fromClassName(className); + if (classType == null) { + continue; + } + + if (status.equals("A")) { + continue; + } + if (status.equals("D")) { + result.add(new ChangedClassFile(path, ChangedClassFile.ChangeStatus.DELETED, className, classType)); + } else if (status.startsWith("M") || status.startsWith("R")) { + result.add(new ChangedClassFile(path, ChangedClassFile.ChangeStatus.MODIFIED, className, classType)); + } + } + return result; + } + + 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); + } + + 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); + } + + 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; + } + + 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("\\", "/"); + } + + private String extractClassName(String path) { + String fileName = path.substring(path.lastIndexOf('/') + 1); + return fileName.substring(0, fileName.length() - 5); + } +} diff --git a/.gitea/class-checker/src/main/java/com/aicheck/model/ApiEndpoint.java b/.gitea/class-checker/src/main/java/com/aicheck/model/ApiEndpoint.java new file mode 100644 index 0000000..6f454b4 --- /dev/null +++ b/.gitea/class-checker/src/main/java/com/aicheck/model/ApiEndpoint.java @@ -0,0 +1,49 @@ +package com.aicheck.model; + +import java.util.LinkedHashSet; +import java.util.Set; + +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; + } + + public String getSourceFile() { + return sourceFile; + } + + public Set getParamTypes() { + return paramTypes; + } + + public Set getReturnTypes() { + return returnTypes; + } + + public String endpointKey() { + return httpMethod + " " + uri; + } + + public String displayLine() { + return httpMethod + " " + uri; + } +} diff --git a/.gitea/class-checker/src/main/java/com/aicheck/model/ChangedClassFile.java b/.gitea/class-checker/src/main/java/com/aicheck/model/ChangedClassFile.java new file mode 100644 index 0000000..abc4854 --- /dev/null +++ b/.gitea/class-checker/src/main/java/com/aicheck/model/ChangedClassFile.java @@ -0,0 +1,35 @@ +package com.aicheck.model; + +public class ChangedClassFile { + public enum ChangeStatus { + MODIFIED, DELETED + } + + private final String relativePath; + private final ChangeStatus status; + private final String className; + private final ClassType classType; + + public ChangedClassFile(String relativePath, ChangeStatus status, String className, ClassType classType) { + this.relativePath = relativePath; + this.status = status; + this.className = className; + this.classType = classType; + } + + public String getRelativePath() { + return relativePath; + } + + public ChangeStatus getStatus() { + return status; + } + + public String getClassName() { + return className; + } + + public ClassType getClassType() { + return classType; + } +} diff --git a/.gitea/class-checker/src/main/java/com/aicheck/model/ClassChangeReport.java b/.gitea/class-checker/src/main/java/com/aicheck/model/ClassChangeReport.java new file mode 100644 index 0000000..c4f8f8b --- /dev/null +++ b/.gitea/class-checker/src/main/java/com/aicheck/model/ClassChangeReport.java @@ -0,0 +1,79 @@ +package com.aicheck.model; + +import java.util.ArrayList; +import java.util.List; + +public class ClassChangeReport { + private final String className; + private final ClassType classType; + private final String sourceFile; + private final boolean deleted; + 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; + + public ClassChangeReport(String className, ClassType classType, String sourceFile, + boolean deleted, boolean conversionCheckEnabled) { + this.className = className; + this.classType = classType; + this.sourceFile = sourceFile; + this.deleted = deleted; + this.conversionCheckEnabled = conversionCheckEnabled; + } + + public String getClassName() { + return className; + } + + public ClassType getClassType() { + return classType; + } + + public String getSourceFile() { + return sourceFile; + } + + public boolean isDeleted() { + return deleted; + } + + public List getFieldChanges() { + return fieldChanges; + } + + public List getInputImpactEndpoints() { + return inputImpactEndpoints; + } + + public List getConversionEntities() { + return conversionEntities; + } + + public List getFrontendImpactEndpoints() { + return frontendImpactEndpoints; + } + + public boolean isConversionCheckEnabled() { + return conversionCheckEnabled; + } + + public void addFieldChange(FieldChange change) { + fieldChanges.add(change); + } + + public void addInputImpact(ApiEndpoint endpoint) { + inputImpactEndpoints.add(endpoint); + } + + public void addConversionEntity(String entityName) { + if (!conversionEntities.contains(entityName)) { + conversionEntities.add(entityName); + } + } + + public void addFrontendImpact(ApiEndpoint endpoint) { + frontendImpactEndpoints.add(endpoint); + } +} diff --git a/.gitea/class-checker/src/main/java/com/aicheck/model/ClassType.java b/.gitea/class-checker/src/main/java/com/aicheck/model/ClassType.java new file mode 100644 index 0000000..85c126b --- /dev/null +++ b/.gitea/class-checker/src/main/java/com/aicheck/model/ClassType.java @@ -0,0 +1,41 @@ +package com.aicheck.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; + } + + 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/.gitea/class-checker/src/main/java/com/aicheck/model/FieldChange.java b/.gitea/class-checker/src/main/java/com/aicheck/model/FieldChange.java new file mode 100644 index 0000000..2122654 --- /dev/null +++ b/.gitea/class-checker/src/main/java/com/aicheck/model/FieldChange.java @@ -0,0 +1,69 @@ +package com.aicheck.model; + +public class FieldChange { + public enum ChangeKind { + ADDED, REMOVED, MODIFIED + } + + private final ChangeKind kind; + private final String fieldName; + 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 description, + String oldType, String newType, String oldDescription, String detail) { + this.kind = kind; + this.fieldName = fieldName; + 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(), field.getDescription(), + null, field.getType(), null, null); + } + + public static FieldChange removed(FieldInfo field) { + return new FieldChange(ChangeKind.REMOVED, field.getName(), field.getDescription(), + field.getType(), null, field.getDescription(), null); + } + + public static FieldChange modified(FieldInfo oldField, FieldInfo newField, String detail) { + return new FieldChange(ChangeKind.MODIFIED, newField.getName(), newField.getDescription(), + oldField.getType(), newField.getType(), oldField.getDescription(), detail); + } + + public ChangeKind getKind() { + return kind; + } + + public String getFieldName() { + return fieldName; + } + + 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/.gitea/class-checker/src/main/java/com/aicheck/model/FieldInfo.java b/.gitea/class-checker/src/main/java/com/aicheck/model/FieldInfo.java new file mode 100644 index 0000000..ed96958 --- /dev/null +++ b/.gitea/class-checker/src/main/java/com/aicheck/model/FieldInfo.java @@ -0,0 +1,46 @@ +package com.aicheck.model; + +import java.util.Objects; + +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/.gitea/class-checker/src/main/java/com/aicheck/notify/WeComNotifier.java b/.gitea/class-checker/src/main/java/com/aicheck/notify/WeComNotifier.java new file mode 100644 index 0000000..3907e22 --- /dev/null +++ b/.gitea/class-checker/src/main/java/com/aicheck/notify/WeComNotifier.java @@ -0,0 +1,179 @@ +package com.aicheck.notify; + +import com.aicheck.model.ApiEndpoint; +import com.aicheck.model.ClassChangeReport; +import com.aicheck.model.ClassType; +import com.aicheck.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; + +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; + } + + public String buildMarkdown(ClassChangeReport report, String modifier, String modifyTime) { + StringBuilder sb = new StringBuilder(); + sb.append("# 【类变更通知】").append("\n\n"); + sb.append("■ 变更对象:**").append(report.getClassName()).append("**(") + .append(report.getClassType().getLabel()).append(")").append("\n"); + sb.append("■ 修改人:").append(modifier).append("\n"); + sb.append("■ 修改时间:").append(modifyTime).append("\n\n"); + sb.append("────────────────────────────────").append("\n"); + sb.append("▶ 对象变更细节").append("\n"); + sb.append("────────────────────────────────").append("\n"); + + if (report.isDeleted()) { + sb.append("[已删除] 该类文件已被移除").append("\n\n"); + } else { + sb.append("字段变更列表:").append("\n"); + for (FieldChange change : report.getFieldChanges()) { + sb.append(formatFieldChange(change)).append("\n"); + } + sb.append("\n"); + } + + sb.append("────────────────────────────────").append("\n"); + sb.append("▶ 影响范围").append("\n"); + sb.append("────────────────────────────────").append("\n"); + appendImpactSections(sb, report); + return truncate(sb.toString()); + } + + private void appendImpactSections(StringBuilder sb, ClassChangeReport report) { + if (report.getClassType() == ClassType.ENTITY) { + sb.append("① 入参影响(Dto变更导致接口参数变化):").append("\n"); + sb.append(" 无").append("\n\n"); + sb.append("② 类转换影响(Dto → Entity 转换,已开启检测):").append("\n"); + sb.append(" 无").append("\n\n"); + sb.append("③ 前端影响(Vo变更导致返回结构变化):").append("\n"); + sb.append(" 无").append("\n"); + return; + } + + sb.append("① 入参影响(Dto变更导致接口参数变化):").append("\n"); + appendEndpointList(sb, report.getInputImpactEndpoints()); + + sb.append("\n"); + sb.append("② 类转换影响(Dto → Entity 转换"); + if (report.isConversionCheckEnabled()) { + sb.append(",已开启检测"); + } + sb.append("):").append("\n"); + if (!report.isConversionCheckEnabled()) { + sb.append(" 未开启检测").append("\n\n"); + } else if (report.getConversionEntities().isEmpty()) { + sb.append(" 无").append("\n\n"); + } else { + for (String entity : report.getConversionEntities()) { + sb.append(" 涉及Entity类:").append(entity).append("\n"); + } + sb.append("\n"); + } + + sb.append("③ 前端影响(Vo变更导致返回结构变化):").append("\n"); + appendEndpointList(sb, report.getFrontendImpactEndpoints()); + } + + private void appendEndpointList(StringBuilder sb, List endpoints) { + if (endpoints == null || endpoints.isEmpty()) { + sb.append(" 无").append("\n"); + return; + } + sb.append(" 影响接口列表:").append("\n"); + for (ApiEndpoint endpoint : endpoints) { + sb.append(" - ").append(endpoint.displayLine()).append("\n"); + } + } + + private String formatFieldChange(FieldChange change) { + String desc = change.getDescription() == null || change.getDescription().isBlank() + ? "无" : change.getDescription(); + switch (change.getKind()) { + case ADDED: + return " [新增] 字段名: " + change.getFieldName() + " 说明: " + desc; + case REMOVED: + return " [删除] 字段名: " + change.getFieldName() + " 说明: " + desc; + case MODIFIED: + default: + String detail = change.getDetail() == null || change.getDetail().isBlank() + ? desc : change.getDetail(); + return " [修改] 字段名: " + change.getFieldName() + " 说明: " + detail; + } + } + + 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; + } + } + + 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/.gitea/class-checker/src/main/java/com/aicheck/parser/ClassFieldParser.java b/.gitea/class-checker/src/main/java/com/aicheck/parser/ClassFieldParser.java new file mode 100644 index 0000000..7e37c65 --- /dev/null +++ b/.gitea/class-checker/src/main/java/com/aicheck/parser/ClassFieldParser.java @@ -0,0 +1,58 @@ +package com.aicheck.parser; + +import com.aicheck.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 java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +public class ClassFieldParser { + public List parseFields(String source, String expectedClassName) { + if (source == null || source.isBlank()) { + return List.of(); + } + CompilationUnit cu = StaticJavaParser.parse(source); + for (TypeDeclaration type : cu.getTypes()) { + if (type instanceof ClassOrInterfaceDeclaration) { + ClassOrInterfaceDeclaration classDecl = (ClassOrInterfaceDeclaration) type; + if (classDecl.getNameAsString().equals(expectedClassName)) { + return parseClassFields(classDecl); + } + } + } + return List.of(); + } + + 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 = extractDescription(fieldDecl); + for (VariableDeclarator variable : fieldDecl.getVariables()) { + fields.put(variable.getNameAsString(), new FieldInfo(variable.getNameAsString(), type, description)); + } + } + return new ArrayList<>(fields.values()); + } + + private String extractDescription(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/.gitea/class-checker/src/main/java/com/aicheck/parser/ConversionParser.java b/.gitea/class-checker/src/main/java/com/aicheck/parser/ConversionParser.java new file mode 100644 index 0000000..95fa0ec --- /dev/null +++ b/.gitea/class-checker/src/main/java/com/aicheck/parser/ConversionParser.java @@ -0,0 +1,94 @@ +package com.aicheck.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.List; +import java.util.Set; +import java.util.stream.Stream; + +public class ConversionParser { + 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; + } + + 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; + } + + 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/.gitea/class-checker/src/main/java/com/aicheck/parser/EndpointParser.java b/.gitea/class-checker/src/main/java/com/aicheck/parser/EndpointParser.java new file mode 100644 index 0000000..a9bc4d3 --- /dev/null +++ b/.gitea/class-checker/src/main/java/com/aicheck/parser/EndpointParser.java @@ -0,0 +1,282 @@ +package com.aicheck.parser; + +import com.aicheck.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; + +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" + ); + + public List scanControllerDirectory(Path rootDir, String relativePrefix) throws IOException { + return scanDirectory(rootDir, relativePrefix, ScanMode.CONTROLLER); + } + + public List scanFeignDirectory(Path rootDir, String relativePrefix) throws IOException { + return scanDirectory(rootDir, relativePrefix, ScanMode.FEIGN); + } + + 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; + } + + 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; + } + + 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; + } + + private boolean isController(ClassOrInterfaceDeclaration declaration) { + return declaration.getAnnotations().stream() + .anyMatch(ann -> { + String name = ann.getNameAsString(); + return "RestController".equals(name) || "Controller".equals(name); + }); + } + + private boolean isFeignClient(ClassOrInterfaceDeclaration declaration) { + return declaration.isInterface() && declaration.getAnnotations().stream() + .anyMatch(ann -> "FeignClient".equals(ann.getNameAsString())); + } + + 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 ""; + } + + 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 ""; + } + + private List extractPaths(AnnotationExpr annotation) { + return AnnotationValueReader.readStringArray(annotation, "value", "path"); + } + + 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("/+", "/"); + } + + 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 + } + + 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/.gitea/class-checker/src/main/java/com/aicheck/parser/TypeNameUtils.java b/.gitea/class-checker/src/main/java/com/aicheck/parser/TypeNameUtils.java new file mode 100644 index 0000000..ac844af --- /dev/null +++ b/.gitea/class-checker/src/main/java/com/aicheck/parser/TypeNameUtils.java @@ -0,0 +1,106 @@ +package com.aicheck.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; + +public final class TypeNameUtils { + private static final Set WRAPPER_TYPES = Set.of( + "ActionResult", "List", "PageListVO", "Set", "Collection", "Iterable", "Optional" + ); + + private TypeNameUtils() { + } + + 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; + } + + 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; + } +} diff --git a/.gitea/class-checker/target/maven-archiver/pom.properties b/.gitea/class-checker/target/maven-archiver/pom.properties new file mode 100644 index 0000000..1a9049c --- /dev/null +++ b/.gitea/class-checker/target/maven-archiver/pom.properties @@ -0,0 +1,5 @@ +#Generated by Maven +#Fri Jun 05 18:18:14 GMT+08:00 2026 +groupId=com.aicheck +artifactId=class-checker +version=1.0.0 diff --git a/.gitea/class-checker/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst b/.gitea/class-checker/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst new file mode 100644 index 0000000..df65d79 --- /dev/null +++ b/.gitea/class-checker/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst @@ -0,0 +1,24 @@ +com\aicheck\model\FieldChange$ChangeKind.class +com\aicheck\analyzer\ClassChangeAnalyzer.class +com\aicheck\notify\WeComNotifier.class +com\aicheck\model\ClassType.class +com\aicheck\notify\WeComNotifier$1.class +com\aicheck\model\ChangedClassFile.class +com\aicheck\analyzer\FieldDiffEngine.class +com\aicheck\parser\ConversionParser$1.class +com\aicheck\parser\ConversionParser.class +com\aicheck\config\AppConfig.class +com\aicheck\parser\ClassFieldParser.class +com\aicheck\analyzer\ImpactAnalyzer.class +com\aicheck\ClassCheckMain.class +com\aicheck\model\ApiEndpoint.class +com\aicheck\model\ClassChangeReport.class +com\aicheck\parser\EndpointParser.class +com\aicheck\analyzer\EndpointIndexBuilder.class +com\aicheck\parser\TypeNameUtils.class +com\aicheck\model\ChangedClassFile$ChangeStatus.class +com\aicheck\parser\EndpointParser$AnnotationValueReader.class +com\aicheck\model\FieldInfo.class +com\aicheck\git\GitChangeScanner.class +com\aicheck\model\FieldChange.class +com\aicheck\parser\EndpointParser$ScanMode.class diff --git a/.gitea/class-checker/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst b/.gitea/class-checker/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst new file mode 100644 index 0000000..cc52d98 --- /dev/null +++ b/.gitea/class-checker/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst @@ -0,0 +1,18 @@ +C:\Users\EDY\Desktop\Demo\AI-Check-Test\.gitea\class-checker\src\main\java\com\aicheck\analyzer\ClassChangeAnalyzer.java +C:\Users\EDY\Desktop\Demo\AI-Check-Test\.gitea\class-checker\src\main\java\com\aicheck\analyzer\EndpointIndexBuilder.java +C:\Users\EDY\Desktop\Demo\AI-Check-Test\.gitea\class-checker\src\main\java\com\aicheck\analyzer\FieldDiffEngine.java +C:\Users\EDY\Desktop\Demo\AI-Check-Test\.gitea\class-checker\src\main\java\com\aicheck\analyzer\ImpactAnalyzer.java +C:\Users\EDY\Desktop\Demo\AI-Check-Test\.gitea\class-checker\src\main\java\com\aicheck\ClassCheckMain.java +C:\Users\EDY\Desktop\Demo\AI-Check-Test\.gitea\class-checker\src\main\java\com\aicheck\config\AppConfig.java +C:\Users\EDY\Desktop\Demo\AI-Check-Test\.gitea\class-checker\src\main\java\com\aicheck\git\GitChangeScanner.java +C:\Users\EDY\Desktop\Demo\AI-Check-Test\.gitea\class-checker\src\main\java\com\aicheck\model\ApiEndpoint.java +C:\Users\EDY\Desktop\Demo\AI-Check-Test\.gitea\class-checker\src\main\java\com\aicheck\model\ChangedClassFile.java +C:\Users\EDY\Desktop\Demo\AI-Check-Test\.gitea\class-checker\src\main\java\com\aicheck\model\ClassChangeReport.java +C:\Users\EDY\Desktop\Demo\AI-Check-Test\.gitea\class-checker\src\main\java\com\aicheck\model\ClassType.java +C:\Users\EDY\Desktop\Demo\AI-Check-Test\.gitea\class-checker\src\main\java\com\aicheck\model\FieldChange.java +C:\Users\EDY\Desktop\Demo\AI-Check-Test\.gitea\class-checker\src\main\java\com\aicheck\model\FieldInfo.java +C:\Users\EDY\Desktop\Demo\AI-Check-Test\.gitea\class-checker\src\main\java\com\aicheck\notify\WeComNotifier.java +C:\Users\EDY\Desktop\Demo\AI-Check-Test\.gitea\class-checker\src\main\java\com\aicheck\parser\ClassFieldParser.java +C:\Users\EDY\Desktop\Demo\AI-Check-Test\.gitea\class-checker\src\main\java\com\aicheck\parser\ConversionParser.java +C:\Users\EDY\Desktop\Demo\AI-Check-Test\.gitea\class-checker\src\main\java\com\aicheck\parser\EndpointParser.java +C:\Users\EDY\Desktop\Demo\AI-Check-Test\.gitea\class-checker\src\main\java\com\aicheck\parser\TypeNameUtils.java diff --git a/.gitea/config.yaml b/.gitea/config.yaml new file mode 100644 index 0000000..ba21758 --- /dev/null +++ b/.gitea/config.yaml @@ -0,0 +1,27 @@ +# ============================================================ +# 类变更检测配置 +# ============================================================ + +class_check: + enabled: true + + dto_entity_conversion: + enabled: true + + model_dirs: + - jnpf-ftb/jnpf-ftb-entity/src/main/java + + endpoint_scan: + controllers: + - jnpf-ftb/jnpf-ftb-biz/src/main/java + feign_apis: + - jnpf-ftb/jnpf-ftb-api/src/main/java + + conversion_scan: + - jnpf-ftb/jnpf-ftb-biz/src/main/java + +wecom: + webhook_url: "YOUR_WECOM_WEBHOOK_URL" + +notify: + only_on_change: true diff --git a/.gitea/workflows/class-change-check.yml b/.gitea/workflows/class-change-check.yml new file mode 100644 index 0000000..b0e7262 --- /dev/null +++ b/.gitea/workflows/class-change-check.yml @@ -0,0 +1,50 @@ +name: 类变更检测 +run-name: ${{ gitea.actor }}的类变更检测 + +on: [push] + +jobs: + class-change-check: + if: ${{ gitea.ref != 'refs/heads/pre' && gitea.ref != 'refs/heads/dev' && gitea.ref != 'refs/heads/master-2.0' }} + runs-on: ubuntu-latest + + steps: + - name: 检出代码 + run: | + git config --global http.sslVerify false + git clone "https://${{ gitea.token }}@git.niujiekeji.com/${{ gitea.repository }}.git" . + git checkout ${{ gitea.sha }} + echo "当前提交: $(git rev-parse HEAD)" + echo "上一提交: $(git rev-parse HEAD~1 2>/dev/null || echo '无')" + + - name: 检查配置文件 + run: | + if [ ! -f .gitea/config.yaml ]; then + echo "错误: 缺少 .gitea/config.yaml" + exit 1 + fi + + - name: 设置 JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + + - name: 编译检测工具 + run: mvn -q -f .gitea/class-checker/pom.xml package -DskipTests + + - name: 执行类变更检测 + run: | + OLD_SHA=$(git rev-parse HEAD~1 2>/dev/null || echo "") + if [ -z "$OLD_SHA" ]; then + echo "首次提交,跳过类变更检测" + exit 0 + fi + COMMIT_TIME=$(git log -1 --format=%cd --date=format:'%Y-%m-%d %H:%M:%S') + java -jar .gitea/class-checker/target/class-checker.jar \ + --config .gitea/config.yaml \ + --repo-root . \ + --old-sha "$OLD_SHA" \ + --new-sha "$(git rev-parse HEAD)" \ + --modifier "${{ gitea.actor }}" \ + --modify-time "$COMMIT_TIME"