getFrontendImpactEndpoints() {
+ return frontendImpactEndpoints;
+ }
+
+ /** 是否启用类转换检测 */
+ public boolean isConversionCheckEnabled() {
+ return conversionCheckEnabled;
+ }
+
+ /** 追加一条字段变更 */
+ public void addFieldChange(FieldChange change) {
+ fieldChanges.add(change);
+ }
+
+ /** 追加 request 影响接口(按 endpointKey 去重) */
+ public void addInputImpact(ApiEndpoint endpoint) {
+ if (inputImpactEndpoints.stream().noneMatch(e -> e.endpointKey().equals(endpoint.endpointKey()))) {
+ inputImpactEndpoints.add(endpoint);
+ }
+ }
+
+ /** 追加关联 Entity 类名(去重) */
+ public void addConversionEntity(String entityName) {
+ if (!conversionEntities.contains(entityName)) {
+ conversionEntities.add(entityName);
+ }
+ }
+
+ /** 追加 response 影响接口(按 endpointKey 去重) */
+ public void addFrontendImpact(ApiEndpoint endpoint) {
+ if (frontendImpactEndpoints.stream().noneMatch(e -> e.endpointKey().equals(endpoint.endpointKey()))) {
+ frontendImpactEndpoints.add(endpoint);
+ }
+ }
+}
diff --git a/.gitea/checker/src/main/java/com/autoCheck/model/ClassType.java b/.gitea/checker/src/main/java/com/autoCheck/model/ClassType.java
new file mode 100644
index 0000000..ee02e9a
--- /dev/null
+++ b/.gitea/checker/src/main/java/com/autoCheck/model/ClassType.java
@@ -0,0 +1,47 @@
+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;
+ }
+
+ /** 根据简单类名后缀识别类型,不匹配则 null */
+ public static ClassType fromClassName(String className) {
+ if (className.endsWith("Dto")) {
+ return DTO;
+ }
+ if (className.endsWith("VO")) {
+ return VO;
+ }
+ if (className.endsWith("Vo")) {
+ return VO;
+ }
+ if (className.endsWith("Entity")) {
+ return ENTITY;
+ }
+ if (className.endsWith("Model")) {
+ return MODEL;
+ }
+ return null;
+ }
+
+ /** 判断类名是否属于当前类型 */
+ public boolean isTargetSuffix(String className) {
+ return fromClassName(className) == this;
+ }
+}
diff --git a/.gitea/checker/src/main/java/com/autoCheck/model/FieldChange.java b/.gitea/checker/src/main/java/com/autoCheck/model/FieldChange.java
new file mode 100644
index 0000000..929102d
--- /dev/null
+++ b/.gitea/checker/src/main/java/com/autoCheck/model/FieldChange.java
@@ -0,0 +1,78 @@
+package com.aicheck.model;
+
+/**
+ * 字段级 diff 结果,用于通知中的 [新增]/[删除]/[修改] 行。
+ */
+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);
+ }
+
+ /** 构造修改字段变更,detail 通常为类型变化描述 */
+ 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;
+ }
+
+ /** 结构性变更详情,如 Integer → String */
+ public String getDetail() {
+ return detail;
+ }
+}
diff --git a/.gitea/checker/src/main/java/com/autoCheck/model/FieldInfo.java b/.gitea/checker/src/main/java/com/autoCheck/model/FieldInfo.java
new file mode 100644
index 0000000..1ee6176
--- /dev/null
+++ b/.gitea/checker/src/main/java/com/autoCheck/model/FieldInfo.java
@@ -0,0 +1,52 @@
+package com.aicheck.model;
+
+import java.util.Objects;
+
+/**
+ * 解析后的单个字段:名称、类型、业务说明(Schema/注释)。
+ */
+public class FieldInfo {
+ private final String name;
+ private final String type;
+ private final String description;
+
+ public FieldInfo(String name, String type, String description) {
+ this.name = name;
+ this.type = type;
+ this.description = description == null ? "" : description;
+ }
+
+ /** 字段名 */
+ public String getName() {
+ return name;
+ }
+
+ /** 字段类型(简单名) */
+ public String getType() {
+ return type;
+ }
+
+ /** 字段说明文案 */
+ public String getDescription() {
+ return description;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof FieldInfo)) {
+ return false;
+ }
+ FieldInfo other = (FieldInfo) o;
+ return Objects.equals(name, other.name)
+ && Objects.equals(type, other.type)
+ && Objects.equals(description, other.description);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(name, type, description);
+ }
+}
diff --git a/.gitea/checker/src/main/java/com/autoCheck/notify/WeComNotifier.java b/.gitea/checker/src/main/java/com/autoCheck/notify/WeComNotifier.java
new file mode 100644
index 0000000..770e39f
--- /dev/null
+++ b/.gitea/checker/src/main/java/com/autoCheck/notify/WeComNotifier.java
@@ -0,0 +1,342 @@
+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;
+
+/**
+ * 将 ClassChangeReport 渲染为企业微信 Markdown 并发送(或仅日志输出)。
+ *
+ * 使用 webhook {@code markdown}(v1):引用块 + 换行排版,三色 font(info/comment/warning)。
+ * v1 不支持无序列表,各项以 {@code >标签:值} 分行展示。
+ */
+public class WeComNotifier {
+ private static final int MAX_LENGTH = 3800;
+ private static final MediaType JSON = MediaType.get("application/json; charset=utf-8");
+
+ private final OkHttpClient client = new OkHttpClient.Builder()
+ .connectTimeout(10, TimeUnit.SECONDS)
+ .readTimeout(10, TimeUnit.SECONDS)
+ .build();
+
+ /** 逐条发送企微通知,返回成功条数 */
+ public int sendAll(String webhookUrl, List reports, String modifier, String modifyTime) {
+ if (reports == null || reports.isEmpty()) {
+ System.out.println("无类变更,不发送到企业微信");
+ return 0;
+ }
+
+ int sent = 0;
+ for (ClassChangeReport report : reports) {
+ String markdown = buildMarkdown(report, modifier, modifyTime);
+ if (postMarkdown(webhookUrl, markdown)) {
+ sent++;
+ System.out.println("已发送类变更通知: " + report.getClassName());
+ }
+ }
+ if (sent > 0) {
+ System.out.println("总共发送 " + sent + " 条类变更通知到企业微信");
+ }
+ return sent;
+ }
+
+ /** 企微关闭时打印 Markdown 到控制台 */
+ public void logAll(List reports, String modifier, String modifyTime) {
+ if (reports == null || reports.isEmpty()) {
+ System.out.println("无类变更,无日志输出");
+ return;
+ }
+
+ System.out.println("企业微信通知已关闭(wecom.enabled=false),以下结果仅输出到日志:");
+ for (int i = 0; i < reports.size(); i++) {
+ ClassChangeReport report = reports.get(i);
+ System.out.println("========== 类变更 [" + (i + 1) + "/" + reports.size()
+ + "]: " + report.getClassName() + " ==========");
+ System.out.println(buildMarkdown(report, modifier, modifyTime));
+ System.out.println("========== 结束 ==========");
+ }
+ System.out.println("共 " + reports.size() + " 条类变更结果(未发送到企业微信)");
+ }
+
+ /** 组装完整 Markdown 正文(引用块 + 换行,每项独立一行) */
+ public String buildMarkdown(ClassChangeReport report, String modifier, String modifyTime) {
+ StringBuilder sb = new StringBuilder();
+ sb.append("# 【类变更通知】").append("\n\n");
+ appendHeader(sb, report, modifier, modifyTime);
+
+ sb.append("\n## 【对象变更细节】").append("\n\n");
+ appendChangeDetails(sb, report);
+
+ sb.append("\n## 【影响范围】").append("\n\n");
+ appendImpactSections(sb, report);
+ return truncate(sb.toString());
+ }
+
+ /** 头部元信息,每项一行引用 */
+ private void appendHeader(StringBuilder sb, ClassChangeReport report,
+ String modifier, String modifyTime) {
+ sb.append(quoteKv("变更对象", colorInfo(safe(report.getClassName()))
+ + "(" + report.getClassType().getLabel() + ")")).append("\n");
+ sb.append(quoteKv("修改人", colorComment(modifier))).append("\n");
+ sb.append(quoteKv("时间", colorComment(modifyTime))).append("\n");
+ sb.append(quoteKv("路径", colorComment(report.getSourceFile()))).append("\n");
+ }
+
+ /** 渲染删除 / 重命名 / 字段变更 */
+ private void appendChangeDetails(StringBuilder sb, ClassChangeReport report) {
+ if (report.isDeleted()) {
+ sb.append(quoteLine(colorWarning("[已删除]") + " "
+ + colorComment("该类文件已被移除"))).append("\n");
+ return;
+ }
+
+ if (report.isRenamed()) {
+ sb.append(quoteLine(colorWarning("[类名变更]") + " "
+ + colorComment(safe(report.getOldClassName())) + " → "
+ + colorInfo(safe(report.getClassName())))).append("\n");
+ }
+
+ if (report.isRenameOnly()) {
+ sb.append(quoteLine(colorComment("字段无变化"))).append("\n");
+ return;
+ }
+
+ if (!report.getFieldChanges().isEmpty()) {
+ sb.append(quoteLine(colorComment("共 "
+ + report.getFieldChanges().size() + " 项字段变更"))).append("\n\n");
+ for (int i = 0; i < report.getFieldChanges().size(); i++) {
+ if (i > 0) {
+ sb.append("\n");
+ }
+ sb.append(formatFieldChange(report.getFieldChanges().get(i)));
+ }
+ sb.append("\n");
+ }
+ }
+
+ /** 按类类型选择影响段落 */
+ private void appendImpactSections(StringBuilder sb, ClassChangeReport report) {
+ appendImpactByType(sb, report);
+ }
+
+ /** Dto/Vo/Entity/Model 各展示不同的 request/response/转换段落 */
+ private void appendImpactByType(StringBuilder sb, ClassChangeReport report) {
+ switch (report.getClassType()) {
+ case DTO:
+ appendSectionIfNeeded(sb, report, true, false, true);
+ break;
+ case VO:
+ appendSectionIfNeeded(sb, report, false, true, true);
+ break;
+ case ENTITY:
+ case MODEL:
+ appendSectionIfNeeded(sb, report, false, false, true);
+ break;
+ default:
+ appendSectionIfNeeded(sb, report, true, true, true);
+ }
+ }
+
+ /** 按需追加 request / response / 类转换三个小节 */
+ private void appendSectionIfNeeded(StringBuilder sb, ClassChangeReport report,
+ boolean showRequest, boolean showResponse, boolean showConversion) {
+ if (showRequest) {
+ sb.append("### 影响 request 接口").append("\n");
+ appendEndpointList(sb, report.getInputImpactEndpoints());
+ sb.append("\n");
+ }
+ if (showResponse) {
+ sb.append("### 影响 response 接口").append("\n");
+ appendEndpointList(sb, report.getFrontendImpactEndpoints());
+ sb.append("\n");
+ }
+ if (showConversion) {
+ sb.append("### 类转换影响").append("\n");
+ appendConversionList(sb, report);
+ }
+ }
+
+ /** 渲染关联 Entity,每项一行 */
+ private void appendConversionList(StringBuilder sb, ClassChangeReport report) {
+ if (!report.isConversionCheckEnabled()) {
+ sb.append(quoteLine(colorComment("未开启检测"))).append("\n");
+ return;
+ }
+ if (report.getConversionEntities().isEmpty()) {
+ sb.append(quoteLine(colorComment("无"))).append("\n");
+ return;
+ }
+ for (String entity : report.getConversionEntities()) {
+ sb.append(quoteKv("Entity", colorInfo(safe(entity)))).append("\n");
+ }
+ }
+
+ /** 渲染接口,每项一行 */
+ private void appendEndpointList(StringBuilder sb, List endpoints) {
+ if (endpoints == null || endpoints.isEmpty()) {
+ sb.append(quoteLine(colorComment("无"))).append("\n");
+ return;
+ }
+ for (ApiEndpoint endpoint : endpoints) {
+ sb.append(formatEndpointLine(endpoint)).append("\n");
+ }
+ }
+
+ /** 接口行:> POST `/path` */
+ private String formatEndpointLine(ApiEndpoint endpoint) {
+ String line = endpoint.displayLine();
+ int space = line.indexOf(' ');
+ if (space > 0) {
+ String method = line.substring(0, space).trim();
+ String path = line.substring(space).trim();
+ return quoteLine(colorInfo(method) + " " + inlineCode(path));
+ }
+ return quoteLine(inlineCode(safe(line)));
+ }
+
+ /**
+ * 单条字段变更:引用块多行,字段间空行分隔。
+ * 避免 font 内嵌 bold。
+ */
+ private String formatFieldChange(FieldChange change) {
+ String fieldName = inlineCode(safe(change.getFieldName()));
+ String desc = change.getDescription() == null ? "" : change.getDescription();
+ String descPart = desc.isBlank()
+ ? colorComment("(无说明)")
+ : colorComment(desc);
+
+ switch (change.getKind()) {
+ case ADDED:
+ return quoteLine(tagAdded() + " " + fieldName) + "\n"
+ + quoteKv("说明", descPart);
+ case REMOVED:
+ return quoteLine(tagRemoved() + " " + fieldName) + "\n"
+ + quoteKv("说明", descPart);
+ case MODIFIED:
+ default:
+ StringBuilder block = new StringBuilder();
+ block.append(quoteLine(tagModified() + " " + fieldName)).append("\n");
+ block.append(quoteKv("说明", descPart));
+ String detail = change.getDetail();
+ if (detail != null && !detail.isBlank()) {
+ block.append("\n").append(quoteKv("类型", formatTypeChange(detail)));
+ }
+ return block.toString();
+ }
+ }
+
+ /** 类型变化:旧 warning → 新 info */
+ private String formatTypeChange(String detail) {
+ int arrow = detail.indexOf(" → ");
+ if (arrow < 0) {
+ return colorWarning(safe(detail));
+ }
+ String oldType = detail.substring(0, arrow).trim();
+ String newType = detail.substring(arrow + 3).trim();
+ return colorWarning(safe(oldType)) + " → " + colorInfo(safe(newType));
+ }
+
+ private String tagAdded() {
+ return colorInfo("[新增]");
+ }
+
+ private String tagRemoved() {
+ return colorWarning("[删除]");
+ }
+
+ private String tagModified() {
+ return colorWarning("[修改]");
+ }
+
+ /** 引用行:{@code >标签:值} */
+ private String quoteKv(String key, String value) {
+ return "> " + key + ":" + value;
+ }
+
+ /** 纯引用行 */
+ private String quoteLine(String content) {
+ return "> " + content;
+ }
+
+ /** 行内代码 */
+ private String inlineCode(String text) {
+ return "`" + text.replace("`", "'") + "`";
+ }
+
+ private String colorInfo(String text) {
+ return "" + text + "";
+ }
+
+ private String colorComment(String text) {
+ return "" + safe(text) + "";
+ }
+
+ private String colorWarning(String text) {
+ return "" + text + "";
+ }
+
+ /** 转义 HTML 特殊字符,避免破坏 font 标签 */
+ private String safe(String text) {
+ if (text == null) {
+ return "";
+ }
+ return text.replace("&", "&").replace("<", "<").replace(">", ">");
+ }
+
+ /** POST 企微 Webhook(markdown v1) */
+ private boolean postMarkdown(String webhookUrl, String content) {
+ if (webhookUrl == null || webhookUrl.isBlank() || webhookUrl.contains("YOUR_WECOM")) {
+ System.out.println("[警告] 未配置有效的企业微信 Webhook URL");
+ System.out.println("--- 通知预览 ---");
+ System.out.println(content.length() > 1000 ? content.substring(0, 1000) : content);
+ return false;
+ }
+
+ String payload = "{\"msgtype\":\"markdown\",\"markdown\":{\"content\":"
+ + jsonEscape(content) + "}}";
+ Request request = new Request.Builder()
+ .url(webhookUrl)
+ .post(RequestBody.create(payload, JSON))
+ .build();
+ try (Response response = client.newCall(request).execute()) {
+ if (response.isSuccessful() && response.body() != null) {
+ String body = response.body().string();
+ return body.contains("\"errcode\":0");
+ }
+ System.out.println("[错误] 企微返回异常: " + response.code()
+ + (response.body() != null ? " " + response.body().string() : ""));
+ return false;
+ } catch (IOException e) {
+ System.out.println("[错误] 发送企微消息失败: " + e.getMessage());
+ return false;
+ }
+ }
+
+ /** 超长消息截断(企微上限 4096 字节 UTF-8) */
+ private String truncate(String text) {
+ if (text.length() <= MAX_LENGTH) {
+ return text;
+ }
+ return text.substring(0, MAX_LENGTH) + "\n\n... 消息过长,已截断";
+ }
+
+ /** JSON 字符串转义 */
+ private String jsonEscape(String text) {
+ String escaped = text
+ .replace("\\", "\\\\")
+ .replace("\"", "\\\"")
+ .replace("\n", "\\n")
+ .replace("\r", "");
+ return "\"" + escaped + "\"";
+ }
+}
diff --git a/.gitea/checker/src/main/java/com/autoCheck/parser/ClassDeclParser.java b/.gitea/checker/src/main/java/com/autoCheck/parser/ClassDeclParser.java
new file mode 100644
index 0000000..d01df8a
--- /dev/null
+++ b/.gitea/checker/src/main/java/com/autoCheck/parser/ClassDeclParser.java
@@ -0,0 +1,82 @@
+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.TypeDeclaration;
+
+/**
+ * 从 Java 源文件路径或 AST 解析类名(简单名 / 全限定名)。
+ */
+public class ClassDeclParser {
+
+ /**
+ * 从源码 AST 提取主类名;解析失败或未找到时回退为路径推导的类名。
+ */
+ public String resolveClassName(String source, String fallbackFromPath) {
+ if (source == null || source.isBlank()) {
+ return fallbackFromPath;
+ }
+ try {
+ CompilationUnit cu = StaticJavaParser.parse(source);
+ for (TypeDeclaration> type : cu.getTypes()) {
+ if (type instanceof ClassOrInterfaceDeclaration) {
+ return type.getNameAsString();
+ }
+ }
+ } catch (Exception ignored) {
+ // 回退路径类名
+ }
+ return fallbackFromPath;
+ }
+
+ /** 从 .java 路径提取文件名(无扩展名)作为类名 */
+ public static String classNameFromPath(String path) {
+ String fileName = path.substring(path.lastIndexOf('/') + 1);
+ if (!fileName.endsWith(".java")) {
+ return fileName;
+ }
+ return fileName.substring(0, fileName.length() - 5);
+ }
+
+ /**
+ * 全限定类名:package + 类名;源码无 package 时从文件路径推断。
+ */
+ public String resolveQualifiedClassName(String source, String relativePath, String fallbackClassName) {
+ String simpleName = resolveClassName(source, fallbackClassName);
+ if (source != null && !source.isBlank()) {
+ try {
+ CompilationUnit cu = StaticJavaParser.parse(source);
+ String packageName = cu.getPackageDeclaration()
+ .map(p -> p.getNameAsString())
+ .orElse("");
+ if (!packageName.isBlank()) {
+ return packageName + "." + simpleName;
+ }
+ } catch (Exception ignored) {
+ // 回退路径推断
+ }
+ }
+ return inferQualifiedFromPath(relativePath, simpleName);
+ }
+
+ /** 从 src/main/java/ 后的路径推断 package.className */
+ public static String inferQualifiedFromPath(String relativePath, String className) {
+ if (relativePath == null || relativePath.isBlank()) {
+ return className;
+ }
+ String normalized = relativePath.replace('\\', '/');
+ String marker = "src/main/java/";
+ int idx = normalized.indexOf(marker);
+ if (idx < 0) {
+ return className;
+ }
+ String subPath = normalized.substring(idx + marker.length());
+ int lastSlash = subPath.lastIndexOf('/');
+ if (lastSlash <= 0) {
+ return className;
+ }
+ String packageName = subPath.substring(0, lastSlash).replace('/', '.');
+ return packageName + "." + className;
+ }
+}
diff --git a/.gitea/checker/src/main/java/com/autoCheck/parser/ClassFieldParser.java b/.gitea/checker/src/main/java/com/autoCheck/parser/ClassFieldParser.java
new file mode 100644
index 0000000..7de0f1b
--- /dev/null
+++ b/.gitea/checker/src/main/java/com/autoCheck/parser/ClassFieldParser.java
@@ -0,0 +1,135 @@
+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 com.github.javaparser.ast.expr.AnnotationExpr;
+import com.github.javaparser.ast.expr.Expression;
+import com.github.javaparser.ast.expr.NormalAnnotationExpr;
+import com.github.javaparser.ast.expr.SingleMemberAnnotationExpr;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * 解析模型类字段:名称、类型、业务说明(注解或 Javadoc)。
+ */
+public class ClassFieldParser {
+
+ /** 解析指定类的实例字段列表 */
+ public List parseFields(String source, String expectedClassName) {
+ if (source == null || source.isBlank()) {
+ return List.of();
+ }
+ CompilationUnit cu = StaticJavaParser.parse(source);
+ ClassOrInterfaceDeclaration classDecl = findClass(cu, expectedClassName);
+ if (classDecl == null) {
+ return List.of();
+ }
+ return parseClassFields(classDecl);
+ }
+
+ /** 按类名查找类声明,找不到则取第一个类 */
+ private ClassOrInterfaceDeclaration findClass(CompilationUnit cu, String expectedClassName) {
+ if (expectedClassName != null && !expectedClassName.isBlank()) {
+ for (TypeDeclaration> type : cu.getTypes()) {
+ if (type instanceof ClassOrInterfaceDeclaration) {
+ ClassOrInterfaceDeclaration classDecl = (ClassOrInterfaceDeclaration) type;
+ if (classDecl.getNameAsString().equals(expectedClassName)) {
+ return classDecl;
+ }
+ }
+ }
+ }
+ for (TypeDeclaration> type : cu.getTypes()) {
+ if (type instanceof ClassOrInterfaceDeclaration) {
+ return (ClassOrInterfaceDeclaration) type;
+ }
+ }
+ return null;
+ }
+
+ /** 提取非 static final 字段,跳过常量 */
+ private List parseClassFields(ClassOrInterfaceDeclaration classDecl) {
+ Map fields = new LinkedHashMap<>();
+ for (FieldDeclaration fieldDecl : classDecl.getFields()) {
+ if (fieldDecl.isStatic() && fieldDecl.isFinal()) {
+ continue;
+ }
+ String type = TypeNameUtils.typeToString(fieldDecl.getElementType());
+ String description = extractFieldLabel(fieldDecl);
+ for (VariableDeclarator variable : fieldDecl.getVariables()) {
+ fields.put(variable.getNameAsString(), new FieldInfo(variable.getNameAsString(), type, description));
+ }
+ }
+ return new ArrayList<>(fields.values());
+ }
+
+ /**
+ * 字段说明:@Schema(description) > @ApiModelProperty > Javadoc,均无则空串。
+ */
+ String extractFieldLabel(FieldDeclaration fieldDecl) {
+ for (AnnotationExpr annotation : fieldDecl.getAnnotations()) {
+ String annName = annotation.getNameAsString();
+ if ("Schema".equals(annName)) {
+ String description = readAnnotationStringValue(annotation, "description");
+ if (!description.isEmpty()) {
+ return description;
+ }
+ }
+ if ("ApiModelProperty".equals(annName)) {
+ String value = readAnnotationStringValue(annotation, "value");
+ if (!value.isEmpty()) {
+ return value;
+ }
+ }
+ }
+ return extractJavadoc(fieldDecl);
+ }
+
+ /** 读取注解中的字符串属性值 */
+ private String readAnnotationStringValue(AnnotationExpr annotation, String attributeName) {
+ if (annotation.isNormalAnnotationExpr()) {
+ NormalAnnotationExpr normal = annotation.asNormalAnnotationExpr();
+ for (var pair : normal.getPairs()) {
+ if (pair.getNameAsString().equals(attributeName)) {
+ return literalString(pair.getValue());
+ }
+ }
+ return "";
+ }
+ if (annotation.isSingleMemberAnnotationExpr()) {
+ SingleMemberAnnotationExpr single = annotation.asSingleMemberAnnotationExpr();
+ if ("value".equals(attributeName) || "description".equals(attributeName)) {
+ return literalString(single.getMemberValue());
+ }
+ }
+ return "";
+ }
+
+ /** 提取字符串字面量值 */
+ private String literalString(Expression expression) {
+ if (expression.isStringLiteralExpr()) {
+ return expression.asStringLiteralExpr().getValue().trim();
+ }
+ return "";
+ }
+
+ /** 从字段 Javadoc 提取首段描述 */
+ private String extractJavadoc(FieldDeclaration fieldDecl) {
+ Optional javadoc = fieldDecl.getJavadocComment();
+ if (javadoc.isEmpty()) {
+ return "";
+ }
+ String text = javadoc.get().parse().getDescription().toText();
+ return text == null ? "" : text.trim().replaceAll("\\s+", " ");
+ }
+}
diff --git a/.gitea/checker/src/main/java/com/autoCheck/parser/ConversionParser.java b/.gitea/checker/src/main/java/com/autoCheck/parser/ConversionParser.java
new file mode 100644
index 0000000..28a783f
--- /dev/null
+++ b/.gitea/checker/src/main/java/com/autoCheck/parser/ConversionParser.java
@@ -0,0 +1,100 @@
+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.Set;
+import java.util.stream.Stream;
+
+/**
+ * 扫描 Dto→Entity 转换关系:convert 方法返回值、BeanUtils.copyProperties 调用。
+ */
+public class ConversionParser {
+
+ /** 在类内查找 convert 方法,收集返回 Entity 的类型名 */
+ public Set findConvertTargetsInClass(String source, String className) {
+ Set entities = new LinkedHashSet<>();
+ if (source == null || source.isBlank()) {
+ return entities;
+ }
+ CompilationUnit cu = StaticJavaParser.parse(source);
+ for (TypeDeclaration> type : cu.getTypes()) {
+ if (type instanceof ClassOrInterfaceDeclaration) {
+ ClassOrInterfaceDeclaration classDecl = (ClassOrInterfaceDeclaration) type;
+ if (!classDecl.getNameAsString().equals(className)) {
+ continue;
+ }
+ for (MethodDeclaration method : classDecl.getMethods()) {
+ if (!"convert".equals(method.getNameAsString())) {
+ continue;
+ }
+ String returnType = TypeNameUtils.simpleName(TypeNameUtils.typeToString(method.getType()));
+ if (returnType.endsWith("Entity")) {
+ entities.add(returnType);
+ }
+ }
+ }
+ }
+ return entities;
+ }
+
+ /** 递归扫描目录,查找 BeanUtils.copyProperties(sourceClass, *Entity) */
+ public Set findBeanUtilsTargets(Path rootDir, String sourceClassName) throws IOException {
+ Set entities = new LinkedHashSet<>();
+ if (!Files.exists(rootDir)) {
+ return entities;
+ }
+ try (Stream paths = Files.walk(rootDir)) {
+ paths.filter(path -> path.toString().endsWith(".java")).forEach(path -> {
+ try {
+ String source = Files.readString(path, StandardCharsets.UTF_8);
+ entities.addAll(scanBeanUtilsInSource(source, sourceClassName));
+ } catch (IOException ignored) {
+ // 跳过
+ }
+ });
+ }
+ return entities;
+ }
+
+ /** 在单文件源码中扫描 BeanUtils.copyProperties 调用 */
+ private Set scanBeanUtilsInSource(String source, String sourceClassName) {
+ Set entities = new LinkedHashSet<>();
+ CompilationUnit cu = StaticJavaParser.parse(source);
+ cu.accept(new VoidVisitorAdapter() {
+ @Override
+ public void visit(MethodCallExpr call, Void arg) {
+ super.visit(call, arg);
+ if (!call.getNameAsString().equals("copyProperties")) {
+ return;
+ }
+ if (call.getScope().isEmpty()) {
+ return;
+ }
+ String scope = call.getScope().get().toString();
+ if (!scope.endsWith("BeanUtils")) {
+ return;
+ }
+ if (call.getArguments().size() < 2) {
+ return;
+ }
+ String firstArg = TypeNameUtils.simpleName(call.getArguments().get(0).toString());
+ String secondArg = TypeNameUtils.simpleName(call.getArguments().get(1).toString());
+ if (sourceClassName.equals(firstArg) && secondArg.endsWith("Entity")) {
+ entities.add(secondArg);
+ }
+ }
+ }, null);
+ return entities;
+ }
+}
diff --git a/.gitea/checker/src/main/java/com/autoCheck/parser/EndpointParser.java b/.gitea/checker/src/main/java/com/autoCheck/parser/EndpointParser.java
new file mode 100644
index 0000000..5cb3010
--- /dev/null
+++ b/.gitea/checker/src/main/java/com/autoCheck/parser/EndpointParser.java
@@ -0,0 +1,301 @@
+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;
+
+/**
+ * 扫描 Controller / Feign 接口,提取 HTTP 方法、URI、入参/返回类型。
+ */
+public class EndpointParser {
+ private static final Set MAPPING_ANNOTATIONS = Set.of(
+ "GetMapping", "PostMapping", "PutMapping", "DeleteMapping", "PatchMapping", "RequestMapping"
+ );
+ private static final Map MAPPING_DEFAULT_METHOD = Map.of(
+ "GetMapping", "GET",
+ "PostMapping", "POST",
+ "PutMapping", "PUT",
+ "DeleteMapping", "DELETE",
+ "PatchMapping", "PATCH"
+ );
+
+ /** 扫描 @RestController / @Controller 目录 */
+ public List scanControllerDirectory(Path rootDir, String relativePrefix) throws IOException {
+ return scanDirectory(rootDir, relativePrefix, ScanMode.CONTROLLER);
+ }
+
+ /** 扫描 @FeignClient 接口目录 */
+ public List scanFeignDirectory(Path rootDir, String relativePrefix) throws IOException {
+ return scanDirectory(rootDir, relativePrefix, ScanMode.FEIGN);
+ }
+
+ /** 递归 walk 目录下 .java 并解析 */
+ private List scanDirectory(Path rootDir, String relativePrefix, ScanMode mode) throws IOException {
+ if (!Files.exists(rootDir)) {
+ return List.of();
+ }
+ List endpoints = new ArrayList<>();
+ try (Stream paths = Files.walk(rootDir)) {
+ paths.filter(path -> path.toString().endsWith(".java")).forEach(path -> {
+ try {
+ String source = Files.readString(path, StandardCharsets.UTF_8);
+ String relativePath = toRelativePath(relativePrefix, rootDir, path);
+ endpoints.addAll(parseCompilationUnit(source, relativePath, mode));
+ } catch (IOException ignored) {
+ // 跳过无法读取的文件
+ }
+ });
+ }
+ return endpoints;
+ }
+
+ /** 解析单个编译单元,过滤 Controller 或 Feign */
+ private List parseCompilationUnit(String source, String relativePath, ScanMode mode) {
+ CompilationUnit cu = StaticJavaParser.parse(source);
+ List endpoints = new ArrayList<>();
+
+ for (TypeDeclaration> type : cu.getTypes()) {
+ if (!(type instanceof ClassOrInterfaceDeclaration)) {
+ continue;
+ }
+ ClassOrInterfaceDeclaration declaration = (ClassOrInterfaceDeclaration) type;
+ if (mode == ScanMode.CONTROLLER && !isController(declaration)) {
+ continue;
+ }
+ if (mode == ScanMode.FEIGN && !isFeignClient(declaration)) {
+ continue;
+ }
+
+ String basePath = mode == ScanMode.FEIGN
+ ? joinPaths(extractFeignBasePath(declaration), extractTypeLevelPath(declaration))
+ : extractTypeLevelPath(declaration);
+ for (MethodDeclaration method : declaration.getMethods()) {
+ if (mode == ScanMode.FEIGN && declaration.isInterface()) {
+ endpoints.addAll(parseMethod(method, basePath, relativePath));
+ } else if (mode == ScanMode.CONTROLLER && !declaration.isInterface()) {
+ endpoints.addAll(parseMethod(method, basePath, relativePath));
+ }
+ }
+ }
+ return endpoints;
+ }
+
+ /** 解析方法上的 Mapping 注解,生成 ApiEndpoint */
+ private List parseMethod(MethodDeclaration method, String basePath, String sourceFile) {
+ List endpoints = new ArrayList<>();
+ for (AnnotationExpr annotation : method.getAnnotations()) {
+ String annName = annotation.getNameAsString();
+ if (!MAPPING_ANNOTATIONS.contains(annName)) {
+ continue;
+ }
+ List subPaths = extractPaths(annotation);
+ List httpMethods = extractHttpMethods(annotation, annName);
+ for (String httpMethod : httpMethods) {
+ for (String subPath : subPaths) {
+ String uri = joinPaths(basePath, subPath);
+ Set paramTypes = extractParamTypes(method);
+ Set returnTypes = TypeNameUtils.peelDirectTypeNames(method.getType());
+ endpoints.add(new ApiEndpoint(httpMethod, uri, sourceFile, paramTypes, returnTypes));
+ }
+ }
+ }
+ return endpoints;
+ }
+
+ /** 收集方法入参类型简单名 */
+ private Set extractParamTypes(MethodDeclaration method) {
+ Set paramTypes = new LinkedHashSet<>();
+ for (Parameter parameter : method.getParameters()) {
+ Type type = parameter.getType();
+ paramTypes.add(TypeNameUtils.simpleName(TypeNameUtils.typeToString(type)));
+ paramTypes.addAll(TypeNameUtils.peelDirectTypeNames(type));
+ }
+ return paramTypes;
+ }
+
+ /** 是否 Spring Controller */
+ private boolean isController(ClassOrInterfaceDeclaration declaration) {
+ return declaration.getAnnotations().stream()
+ .anyMatch(ann -> {
+ String name = ann.getNameAsString();
+ return "RestController".equals(name) || "Controller".equals(name);
+ });
+ }
+
+ /** 是否 Feign 客户端接口 */
+ private boolean isFeignClient(ClassOrInterfaceDeclaration declaration) {
+ return declaration.isInterface() && declaration.getAnnotations().stream()
+ .anyMatch(ann -> "FeignClient".equals(ann.getNameAsString()));
+ }
+
+ /** 类级 @RequestMapping 路径 */
+ private String extractTypeLevelPath(ClassOrInterfaceDeclaration declaration) {
+ for (AnnotationExpr annotation : declaration.getAnnotations()) {
+ if ("RequestMapping".equals(annotation.getNameAsString())) {
+ List paths = extractPaths(annotation);
+ if (!paths.isEmpty()) {
+ return paths.get(0);
+ }
+ }
+ }
+ return "";
+ }
+
+ /** @FeignClient(path=...) 基础路径 */
+ private String extractFeignBasePath(ClassOrInterfaceDeclaration declaration) {
+ for (AnnotationExpr annotation : declaration.getAnnotations()) {
+ if ("FeignClient".equals(annotation.getNameAsString())) {
+ List paths = AnnotationValueReader.readStringArray(annotation, "path");
+ if (!paths.isEmpty()) {
+ return paths.get(0);
+ }
+ }
+ }
+ return "";
+ }
+
+ /** 从 Mapping 注解读取 value/path */
+ private List extractPaths(AnnotationExpr annotation) {
+ return AnnotationValueReader.readStringArray(annotation, "value", "path");
+ }
+
+ /** 推断 HTTP 方法;RequestMapping 无 method 时默认 GET */
+ private List extractHttpMethods(AnnotationExpr annotation, String annName) {
+ if (!"RequestMapping".equals(annName)) {
+ return List.of(MAPPING_DEFAULT_METHOD.getOrDefault(annName, "GET"));
+ }
+ List methods = AnnotationValueReader.readEnumArray(annotation, "method");
+ if (methods.isEmpty()) {
+ return List.of("GET");
+ }
+ return methods;
+ }
+
+ /** 拼接类级与方法级路径 */
+ private String joinPaths(String base, String sub) {
+ String normalizedBase = normalizePath(base);
+ String normalizedSub = normalizePath(sub);
+ if (normalizedBase.isEmpty()) {
+ return normalizedSub.isEmpty() ? "/" : normalizedSub;
+ }
+ if (normalizedSub.isEmpty()) {
+ return normalizedBase;
+ }
+ String joined = normalizedBase + "/" + normalizedSub.substring(1);
+ return joined.replaceAll("/+", "/");
+ }
+
+ /** 规范化 URI 路径 */
+ private String normalizePath(String path) {
+ if (path == null || path.isBlank()) {
+ return "";
+ }
+ String trimmed = path.trim();
+ if (!trimmed.startsWith("/")) {
+ trimmed = "/" + trimmed;
+ }
+ return trimmed.replaceAll("/+", "/");
+ }
+
+ /** 生成相对仓库根的路径 */
+ private String toRelativePath(String relativePrefix, Path rootDir, Path file) {
+ String relative = rootDir.relativize(file).toString().replace("\\", "/");
+ if (relativePrefix == null || relativePrefix.isBlank()) {
+ return relative;
+ }
+ String prefix = relativePrefix.endsWith("/")
+ ? relativePrefix.substring(0, relativePrefix.length() - 1)
+ : relativePrefix;
+ return prefix + "/" + relative;
+ }
+
+ private enum ScanMode {
+ CONTROLLER, FEIGN
+ }
+
+ /** 从注解 AST 读取字符串或枚举数组 */
+ static final class AnnotationValueReader {
+ private AnnotationValueReader() {
+ }
+
+ static List readStringArray(AnnotationExpr annotation, String... keys) {
+ NodeList> values = readArrayValues(annotation, keys);
+ List result = new ArrayList<>();
+ for (Object value : values) {
+ String text = value.toString().replace("\"", "").trim();
+ if (!text.isBlank()) {
+ result.add(text);
+ }
+ }
+ if (result.isEmpty()) {
+ result.add("");
+ }
+ return result;
+ }
+
+ static List readEnumArray(AnnotationExpr annotation, String key) {
+ NodeList> values = readArrayValues(annotation, key);
+ List result = new ArrayList<>();
+ for (Object value : values) {
+ String text = value.toString().trim();
+ if (text.contains(".")) {
+ text = text.substring(text.lastIndexOf('.') + 1);
+ }
+ result.add(text.toUpperCase(Locale.ROOT));
+ }
+ return result;
+ }
+
+ private static NodeList> readArrayValues(AnnotationExpr annotation, String... keys) {
+ if (annotation.isSingleMemberAnnotationExpr()) {
+ Expression value = annotation.asSingleMemberAnnotationExpr().getMemberValue();
+ if (value.isArrayInitializerExpr()) {
+ return value.asArrayInitializerExpr().getValues();
+ }
+ return new NodeList<>(value);
+ }
+ if (annotation.isNormalAnnotationExpr()) {
+ var pairs = annotation.asNormalAnnotationExpr().getPairs();
+ for (var pair : pairs) {
+ for (String key : keys) {
+ if (pair.getNameAsString().equals(key)) {
+ if (pair.getValue().isArrayInitializerExpr()) {
+ return pair.getValue().asArrayInitializerExpr().getValues();
+ }
+ return new NodeList<>(pair.getValue());
+ }
+ }
+ }
+ for (var pair : pairs) {
+ if ("value".equals(pair.getNameAsString())) {
+ if (pair.getValue().isArrayInitializerExpr()) {
+ return pair.getValue().asArrayInitializerExpr().getValues();
+ }
+ return new NodeList<>(pair.getValue());
+ }
+ }
+ }
+ return new NodeList<>();
+ }
+ }
+}
diff --git a/.gitea/checker/src/main/java/com/autoCheck/parser/TypeNameUtils.java b/.gitea/checker/src/main/java/com/autoCheck/parser/TypeNameUtils.java
new file mode 100644
index 0000000..fb0c946
--- /dev/null
+++ b/.gitea/checker/src/main/java/com/autoCheck/parser/TypeNameUtils.java
@@ -0,0 +1,117 @@
+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;
+
+/**
+ * Java 类型名工具:转字符串、取简单名、剥离 ActionResult/List 等泛型包装。
+ */
+public final class TypeNameUtils {
+ /** 需要向内层继续剥离的包装类型 */
+ private static final Set WRAPPER_TYPES = Set.of(
+ "ActionResult", "List", "PageListVO", "Set", "Collection", "Iterable", "Optional"
+ );
+
+ private TypeNameUtils() {
+ }
+
+ /** Type 转无空白字符串 */
+ public static String typeToString(Type type) {
+ if (type == null) {
+ return "Object";
+ }
+ return type.toString().replaceAll("\\s+", "");
+ }
+
+ /** 取类型简单名,去掉包名与泛型 */
+ public static String simpleName(String typeName) {
+ if (typeName == null || typeName.isBlank()) {
+ return "";
+ }
+ String cleaned = typeName.replaceAll("\\s+", "");
+ int genericStart = cleaned.indexOf('<');
+ String base = genericStart >= 0 ? cleaned.substring(0, genericStart) : cleaned;
+ int dot = base.lastIndexOf('.');
+ return dot >= 0 ? base.substring(dot + 1) : base;
+ }
+
+ /** 从 Type AST 收集实际业务类型简单名(穿透包装泛型) */
+ public static Set peelDirectTypeNames(Type type) {
+ Set result = new LinkedHashSet<>();
+ collectPeelTargets(type, result);
+ return result;
+ }
+
+ /** 从类型字符串收集实际业务类型简单名 */
+ public static Set peelDirectTypeNames(String typeName) {
+ Set result = new LinkedHashSet<>();
+ collectPeelTargets(typeName, result);
+ return result;
+ }
+
+ /** 递归收集:包装类型则进入泛型参数,否则记录简单名 */
+ private static void collectPeelTargets(Type type, Set result) {
+ if (type == null) {
+ return;
+ }
+ if (type.isClassOrInterfaceType()) {
+ ClassOrInterfaceType classType = type.asClassOrInterfaceType();
+ String name = simpleName(classType.getNameAsString());
+ if (WRAPPER_TYPES.contains(name) && classType.getTypeArguments().isPresent()) {
+ for (Type arg : classType.getTypeArguments().get()) {
+ collectPeelTargets(arg, result);
+ }
+ return;
+ }
+ result.add(name);
+ return;
+ }
+ result.add(simpleName(typeToString(type)));
+ }
+
+ /** 字符串版递归收集 */
+ private static void collectPeelTargets(String typeName, Set result) {
+ String cleaned = typeName.replaceAll("\\s+", "");
+ int genericStart = cleaned.indexOf('<');
+ if (genericStart < 0) {
+ result.add(simpleName(cleaned));
+ return;
+ }
+ String outer = simpleName(cleaned.substring(0, genericStart));
+ String inner = cleaned.substring(genericStart + 1, cleaned.lastIndexOf('>'));
+ if (WRAPPER_TYPES.contains(outer)) {
+ for (String part : splitGenericArgs(inner)) {
+ collectPeelTargets(part, result);
+ }
+ return;
+ }
+ result.add(outer);
+ }
+
+ /** 按逗号分割泛型参数,支持嵌套 <> */
+ private static List splitGenericArgs(String inner) {
+ List parts = new java.util.ArrayList<>();
+ int depth = 0;
+ StringBuilder current = new StringBuilder();
+ for (char ch : inner.toCharArray()) {
+ if (ch == '<') {
+ depth++;
+ } else if (ch == '>') {
+ depth--;
+ } else if (ch == ',' && depth == 0) {
+ parts.add(current.toString().trim());
+ current.setLength(0);
+ continue;
+ }
+ current.append(ch);
+ }
+ if (current.length() > 0) {
+ parts.add(current.toString().trim());
+ }
+ return parts;
+ }
+}
diff --git a/.gitea/config.yaml b/.gitea/config.yaml
index 9312346..945eeed 100644
--- a/.gitea/config.yaml
+++ b/.gitea/config.yaml
@@ -1,27 +1,47 @@
# ============================================================
# 类变更检测配置
+# 由 CI 流水线加载;jar 位于 .gitea/workflows/class-checker.jar
+# 修改后 push 即可生效,无需重新打包 jar(除非改动了 Java 源码)
# ============================================================
+# 总开关。false 时跳过全部检测,流水线直接成功退出
class_check:
enabled: true
+ # Dto → Entity 类转换影响检测开关
+ # true:分析 Dto 变更是否通过 convert() 或 BeanUtils.copyProperties 影响到 Entity
+ # false:通知中「② 类转换影响」段落显示「未开启检测」
dto_entity_conversion:
- enabled: true
+ enabled: false
+ # 模型类源码目录(相对仓库根路径,可配置多个)
+ # 用于声明 Vo/Dto/Entity/Model 所在模块;当前版本按 git diff 全仓库扫描,
+ # 类名须以 Dto、Vo、VO、Entity、Model 结尾才会纳入检测
model_dirs:
- jnpf-ftb/jnpf-ftb-entity/src/main/java
+ # 接口索引扫描目录,用于分析类变更对 API 的影响范围
endpoint_scan:
controllers:
+ # Spring @RestController / @Controller 所在目录
+ # 解析 @RequestMapping 等注解,建立「HTTP 方法 + 路径 → 入参/返回值类型」索引
- jnpf-ftb/jnpf-ftb-biz/src/main/java
feign_apis:
+ # OpenFeign @FeignClient 接口所在目录
+ # 解析 Feign 接口方法签名,补充远程调用端的影响范围
- jnpf-ftb/jnpf-ftb-api/src/main/java
conversion_scan:
+ # Dto → Entity 转换代码扫描目录(相对仓库根路径,可配置多个)
+ # 在这些目录中搜索 BeanUtils.copyProperties(source, target) 等调用,
+ # 判断哪些 Entity 会因 Dto 字段变更而受影响
- jnpf-ftb/jnpf-ftb-biz/src/main/java
+# 企业微信通知开关 # false:不发送企微,完整通知内容仅打印到 CI 日志
wecom:
+ enabled: true
webhook_url: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=61f08cc9-b734-4dff-a931-7f33654c0a81"
+# true:无变更时打印「无类变更,静默退出」后正常结束(不发送通知)
notify:
only_on_change: true
diff --git a/.gitea/workflows/class-change-check.yml b/.gitea/workflows/class-change-check.yml
index f4148e7..e6e4017 100644
--- a/.gitea/workflows/class-change-check.yml
+++ b/.gitea/workflows/class-change-check.yml
@@ -6,39 +6,31 @@ 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
+ runs-on: jdk11
steps:
- name: 检出代码
run: |
- git config --global http.sslVerify false
- git clone "https://${{ gitea.token }}@git.niujiekeji.com/${{ gitea.repository }}.git" .
+ git clone --depth=2 \
+ "http://oauth2:${{ gitea.token }}@host.docker.internal:3000/${{ gitea.repository }}.git" \
+ .
git checkout ${{ gitea.sha }}
- echo "当前提交: $(git rev-parse HEAD)"
- echo "上一提交: $(git rev-parse HEAD~1 2>/dev/null || echo '无')"
- - name: 检查配置文件
+ - name: 检查配置文件与预编译 jar
run: |
if [ ! -f .gitea/config.yaml ]; then
echo "错误: 缺少 .gitea/config.yaml"
exit 1
fi
+ if [ ! -f .gitea/workflows/class-checker.jar ]; then
+ echo "错误: 缺少 .gitea/workflows/class-checker.jar"
+ echo "请本地执行: powershell -File scripts/build-class-checker.ps1"
+ exit 1
+ fi
- - name: 安装 JDK 和 Maven
+ - name: 验证 JDK
run: |
- if ! command -v java >/dev/null 2>&1; then
- apt-get update -qq
- apt-get install -y openjdk-11-jdk
- fi
- if ! command -v mvn >/dev/null 2>&1; then
- apt-get update -qq
- apt-get install -y maven
- fi
echo "Java: $(java -version 2>&1 | head -1)"
- echo "Maven: $(mvn -version 2>&1 | head -1)"
-
- - name: 编译检测工具
- run: mvn -q -f .gitea/class-checker/pom.xml package -DskipTests
- name: 执行类变更检测
run: |
@@ -48,7 +40,7 @@ jobs:
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 \
+ java -jar .gitea/workflows/class-checker.jar \
--config .gitea/config.yaml \
--repo-root . \
--old-sha "$OLD_SHA" \
diff --git a/.gitea/workflows/class-checker.jar b/.gitea/workflows/class-checker.jar
new file mode 100644
index 0000000..060ba80
Binary files /dev/null and b/.gitea/workflows/class-checker.jar differ
diff --git a/.gitignore b/.gitignore
index 9154f4c..b1ada3d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,6 +13,7 @@
# Package Files #
*.jar
+!.gitea/workflows/class-checker.jar
*.war
*.nar
*.ear
@@ -24,3 +25,10 @@
hs_err_pid*
replay_pid*
+# local env
+.env.gitea
+gitea-runner/data/
+
+# maven build output(提交 .gitea/workflows/class-checker.jar 即可)
+.gitea/checker/target/
+
diff --git a/Dockerfile.gitea-job b/Dockerfile.gitea-job
new file mode 100644
index 0000000..09d9698
--- /dev/null
+++ b/Dockerfile.gitea-job
@@ -0,0 +1,12 @@
+# Gitea Actions 任务容器:预装 headless JDK 11 + Git
+ARG UBUNTU_IMAGE=ubuntu:24.04
+FROM ${UBUNTU_IMAGE}
+
+RUN sed -i 's|http://archive.ubuntu.com/ubuntu|http://mirrors.aliyun.com/ubuntu|g; \
+ s|http://security.ubuntu.com/ubuntu|http://mirrors.aliyun.com/ubuntu|g' \
+ /etc/apt/sources.list.d/ubuntu.sources \
+ && apt-get update -qq \
+ && apt-get install -y --no-install-recommends \
+ openjdk-11-jdk-headless git ca-certificates curl \
+ && rm -rf /var/lib/apt/lists/* \
+ && java -version
diff --git a/docker-compose.gitea.yml b/docker-compose.gitea.yml
new file mode 100644
index 0000000..14f0590
--- /dev/null
+++ b/docker-compose.gitea.yml
@@ -0,0 +1,48 @@
+services:
+ gitea:
+ image: ${GITEA_IMAGE:-docker.m.daocloud.io/gitea/gitea:1.22-rootless}
+ pull_policy: if_not_present
+ container_name: gitea
+ restart: unless-stopped
+ environment:
+ GITEA__server__DOMAIN: localhost
+ GITEA__server__ROOT_URL: http://localhost:3000/
+ GITEA__server__SSH_DOMAIN: localhost
+ GITEA__server__SSH_PORT: 2222
+ GITEA__actions__ENABLED: "true"
+ GITEA__actions__DEFAULT_ACTIONS_URL: "https://gitea.com/actions"
+ ports:
+ - "3000:3000"
+ - "2222:2222"
+ volumes:
+ - gitea-data:/data
+
+ gitea-job:
+ build:
+ context: .
+ dockerfile: Dockerfile.gitea-job
+ args:
+ UBUNTU_IMAGE: ${UBUNTU_IMAGE:-docker.m.daocloud.io/ubuntu:24.04}
+ image: ai-check-gitea-job:latest
+ profiles: ["build"]
+
+ gitea-runner:
+ image: ${GITEA_RUNNER_IMAGE:-docker.m.daocloud.io/gitea/act_runner:0.2.11}
+ pull_policy: if_not_present
+ container_name: gitea-act-runner
+ restart: unless-stopped
+ depends_on:
+ - gitea
+ environment:
+ CONFIG_FILE: /config.yaml
+ GITEA_INSTANCE_URL: ${GITEA_INSTANCE_URL:-http://gitea:3000}
+ GITEA_RUNNER_REGISTRATION_TOKEN: ${GITEA_RUNNER_REGISTRATION_TOKEN:-}
+ GITEA_RUNNER_NAME: ${GITEA_RUNNER_NAME:-local-gitea-runner}
+ volumes:
+ - ./gitea-runner/config.yaml:/config.yaml:ro
+ - gitea-runner-data:/data
+ - /var/run/docker.sock:/var/run/docker.sock
+
+volumes:
+ gitea-data:
+ gitea-runner-data:
diff --git a/gitea-runner/config.yaml b/gitea-runner/config.yaml
new file mode 100644
index 0000000..7c2c796
--- /dev/null
+++ b/gitea-runner/config.yaml
@@ -0,0 +1,10 @@
+log:
+ level: info
+
+runner:
+ file: .runner
+ capacity: 2
+ timeout: 3h
+ labels:
+ - "ubuntu-latest:docker://ai-check-gitea-job:latest"
+ - "linux:docker://ai-check-gitea-job:latest"
diff --git a/scripts/build-class-checker.ps1 b/scripts/build-class-checker.ps1
new file mode 100644
index 0000000..cf9d389
--- /dev/null
+++ b/scripts/build-class-checker.ps1
@@ -0,0 +1,25 @@
+# 本地打包 class-checker 并复制到 .gitea/workflows/
+$ErrorActionPreference = "Stop"
+$Root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
+$CheckerDir = Join-Path $Root ".gitea\class-checker"
+$WorkflowsDir = Join-Path $Root ".gitea\workflows"
+$TargetJar = Join-Path $CheckerDir "target\class-checker.jar"
+$OutputJar = Join-Path $WorkflowsDir "class-checker.jar"
+
+Write-Host ">> 编译 class-checker..."
+Push-Location $Root
+& mvn -q -f .gitea/class-checker/pom.xml package -DskipTests
+if ($LASTEXITCODE -ne 0) {
+ Pop-Location
+ Write-Error "Maven 编译失败,exit code: $LASTEXITCODE"
+}
+Pop-Location
+
+if (-not (Test-Path $TargetJar)) {
+ Write-Error "编译失败,未找到 $TargetJar"
+}
+
+New-Item -ItemType Directory -Force -Path $WorkflowsDir | Out-Null
+Copy-Item -Force $TargetJar $OutputJar
+Write-Host ">> 已输出: $OutputJar"
+Write-Host ">> 请 commit 并 push .gitea/workflows/class-checker.jar"