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"