diff --git a/.gitea/checker/comparator.py b/.gitea/checker/comparator.py
index c23e971..851fd3d 100644
--- a/.gitea/checker/comparator.py
+++ b/.gitea/checker/comparator.py
@@ -7,7 +7,7 @@ from dataclasses import dataclass, field
from enum import Enum
from typing import Dict, List, Optional, Set, Tuple
-from controller_parser import ApiEndpoint, ApiParameter
+from models import ApiEndpoint, ApiParameter
class ChangeType(str, Enum):
diff --git a/.gitea/checker/controller_parser.py b/.gitea/checker/controller_parser.py
index ebce785..b77d669 100644
--- a/.gitea/checker/controller_parser.py
+++ b/.gitea/checker/controller_parser.py
@@ -1,114 +1,43 @@
"""
-Controller 端点解析模块。
-调用 Java AST 解析器 JAR,将 Java 源码转为结构化的 API 端点列表。
+Controller 端点解析模块(纯 Python,无需 Java)。
"""
-import json
-import subprocess
-from dataclasses import dataclass, field
from pathlib import Path
-from typing import Dict, List, Optional
+from typing import Dict, List
-
-@dataclass
-class ApiParameter:
- """单个接口参数。"""
-
- name: str
- type: str
- required: bool = True
- source: str = "query"
- description: Optional[str] = None
-
-
-@dataclass
-class ApiEndpoint:
- """单个 Controller 接口端点。"""
-
- http_method: str
- uri: str
- controller_class: str
- method_name: str
- source_file: str
- parameters: List[ApiParameter] = field(default_factory=list)
-
- @property
- def endpoint_key(self) -> str:
- """唯一标识:HTTP 方法 + URI,用于跨版本匹配。"""
- return f"{self.http_method} {self.uri}"
-
-
-def run_java_parser(source_dir: Path, jar_path: Path, output_json: Path) -> List[ApiEndpoint]:
- """
- 调用 JavaParser JAR 扫描源码目录,返回解析结果。
-
- :param source_dir: Java 源码根目录
- :param jar_path: controller-parser JAR 路径
- :param output_json: 临时 JSON 输出路径
- :return: ApiEndpoint 列表
- :raises RuntimeError: Java 进程失败或 JAR 不存在
- """
- if not jar_path.exists():
- raise RuntimeError(
- f"Java 解析器 JAR 不存在: {jar_path}\n"
- "请先在 .gitea/java-parser 目录执行: mvn -q package"
- )
-
- cmd = ["java", "-jar", str(jar_path), str(source_dir), str(output_json)]
- result = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8")
-
- if result.returncode != 0:
- raise RuntimeError(f"Java 解析器执行失败:\n{result.stderr}")
-
- with open(output_json, "r", encoding="utf-8") as f:
- raw = json.load(f)
-
- return [_dict_to_endpoint(item) for item in raw]
-
-
-def _dict_to_endpoint(data: dict) -> ApiEndpoint:
- """将 JSON 字典转换为 ApiEndpoint 对象。"""
- params = [
- ApiParameter(
- name=p.get("name", ""),
- type=p.get("type", ""),
- required=p.get("required", True),
- source=p.get("source", "query"),
- description=p.get("description"),
- )
- for p in data.get("parameters", [])
- ]
- return ApiEndpoint(
- http_method=data.get("httpMethod", "GET"),
- uri=data.get("uri", "/"),
- controller_class=data.get("controllerClass", ""),
- method_name=data.get("methodName", ""),
- source_file=data.get("sourceFile", ""),
- parameters=params,
- )
+from models import ApiEndpoint, ApiParameter
def endpoints_to_map(endpoints: List[ApiEndpoint]) -> Dict[str, ApiEndpoint]:
- """
- 将端点列表转为字典,key 为 endpoint_key。
-
- :param endpoints: 端点列表
- :return: { "GET /api/users/{id}": ApiEndpoint, ... }
- """
+ """端点列表转字典,key 为 endpoint_key。"""
return {ep.endpoint_key: ep for ep in endpoints}
def filter_endpoints_by_files(
endpoints: List[ApiEndpoint], changed_files: List[str]
) -> List[ApiEndpoint]:
- """
- 仅保留源文件在变更列表中的端点(缩小对比范围)。
-
- :param endpoints: 全部端点
- :param changed_files: 变更文件相对路径列表
- :return: 过滤后的端点
- """
+ """仅保留变更文件中的端点。"""
if not changed_files:
return endpoints
- changed_set = set(changed_files)
+ changed_set = {f.replace("\\", "/") for f in changed_files}
return [ep for ep in endpoints if ep.source_file in changed_set]
+
+
+def parse_endpoints_from_files(
+ repo_root: Path,
+ source_subdir: str,
+ file_paths: List[str],
+ file_contents: Dict[str, str],
+) -> List[ApiEndpoint]:
+ """
+ 解析指定 Controller 文件,提取接口参数(仅解析传入文件,不全量扫描)。
+
+ :param repo_root: 仓库根
+ :param source_subdir: 源码目录(相对仓库根)
+ :param file_paths: 文件路径列表
+ :param file_contents: 路径 -> 源码内容
+ :return: ApiEndpoint 列表
+ """
+ from controller_ast_parser import parse_controller_files
+
+ return parse_controller_files(repo_root, source_subdir, file_paths, file_contents)
diff --git a/.gitea/checker/git_utils.py b/.gitea/checker/git_utils.py
index b69c7c6..1d5bd30 100644
--- a/.gitea/checker/git_utils.py
+++ b/.gitea/checker/git_utils.py
@@ -122,6 +122,20 @@ def get_controller_files_diff(base_sha: str, head_sha: str, changed_files: List[
return ""
+def get_file_content_at_commit(commit_sha: str, file_path: str) -> Optional[str]:
+ """
+ 读取指定 commit 下某个文件的内容(无需 git worktree,更快)。
+
+ :param commit_sha: commit SHA
+ :param file_path: 相对仓库根目录的文件路径
+ :return: 文件内容;该 commit 中不存在则返回 None
+ """
+ try:
+ return run_git(["show", f"{commit_sha}:{file_path}"])
+ except RuntimeError:
+ return None
+
+
def prepare_worktrees(repo_root: Path) -> tuple:
"""
准备新旧两个版本的代码工作目录,供 AST 解析器分别扫描。
diff --git a/.gitea/checker/main.py b/.gitea/checker/main.py
index 7b01f98..b201845 100644
--- a/.gitea/checker/main.py
+++ b/.gitea/checker/main.py
@@ -1,23 +1,12 @@
#!/usr/bin/env python3
"""
-AI-Check 主入口 — Controller 层接口参数变更检测
-
-流程(对齐第一版需求):
- 1. Git 检出新旧代码
- 2. JavaParser AST 解析 Controller 方法签名与参数
- 3. 对比增 / 删 / 改 / 重命名
- 4. (可选)LLM 审核参数变更结果
- 5. (可选)写入日志
- 6. 企业微信通知(URI + 参数变更明细)
-
-用法:
- python .gitea/checker/main.py [--config .gitea/config.yaml] [--repo-root .] [推送人] [推送时间]
+AI-Check 主入口 — Controller 层接口参数变更检测(纯 Python,无 Java 依赖)
"""
import argparse
import sys
-import tempfile
from pathlib import Path
+from typing import Optional
import yaml
@@ -29,14 +18,14 @@ from comparator import compare_endpoints
from controller_parser import (
endpoints_to_map,
filter_endpoints_by_files,
- run_java_parser,
+ parse_endpoints_from_files,
)
from git_utils import (
get_changed_java_controller_files,
get_controller_files_diff,
get_current_commit,
+ get_file_content_at_commit,
get_previous_commit_sha,
- prepare_worktrees,
)
from llm_reviewer import review_parameter_changes
from notifier import send_parameter_change_notification
@@ -53,20 +42,53 @@ def load_config(config_path: Path) -> dict:
return yaml.safe_load(f) or {}
-def parse_endpoints_for_version(
+def _read_file_safe(path: Path) -> str:
+ """读取文件内容。"""
+ try:
+ return path.read_text(encoding="utf-8", errors="ignore")
+ except OSError as exc:
+ print(f"[警告] 无法读取 {path}: {exc}")
+ return ""
+
+
+def _load_version_contents(
+ repo_root: Path,
+ file_paths: list,
+ commit_sha: Optional[str] = None,
+) -> dict:
+ """加载文件内容;commit_sha 为空则读工作区,否则 git show。"""
+ contents = {}
+ for fp in file_paths:
+ norm = fp.replace("\\", "/")
+ if commit_sha:
+ text = get_file_content_at_commit(commit_sha, norm)
+ if text is not None:
+ contents[norm] = text
+ else:
+ text = _read_file_safe(repo_root / norm)
+ if text:
+ contents[norm] = text
+ return contents
+
+
+def parse_changed_endpoints(
repo_root: Path,
source_subdir: str,
- jar_path: Path,
- tmp_dir: Path,
+ changed_files: list,
+ old_sha: str,
label: str,
) -> dict:
- """对指定版本源码运行 Java AST 解析,提取 Controller 接口参数。"""
- source_dir = repo_root / source_subdir
- output_json = tmp_dir / f"endpoints_{label}.json"
+ """解析变更 Controller 文件在新/旧版本的端点。"""
+ if label == "new":
+ contents = _load_version_contents(repo_root, changed_files)
+ else:
+ contents = _load_version_contents(repo_root, changed_files, commit_sha=old_sha)
- print(f"[AST] 扫描 {label} 版本: {source_dir}")
- endpoints = run_java_parser(source_dir, jar_path, output_json)
- print(f"[AST] {label} 版本共 {len(endpoints)} 个 Controller 接口")
+ print(f"[AST] 解析 {label} 版本 {len(contents)} 个 Controller 文件")
+ endpoints = parse_endpoints_from_files(
+ repo_root, source_subdir, changed_files, contents
+ )
+ print(f"[AST] {label} 版本共 {len(endpoints)} 个接口")
return endpoints_to_map(endpoints)
@@ -75,25 +97,27 @@ def main() -> int:
parser = argparse.ArgumentParser(
description="AI-Check: Controller 接口参数变更检测"
)
- parser.add_argument("--config", default=".gitea/config.yaml", help="配置文件路径(相对仓库根目录)")
+ parser.add_argument(
+ "--config", default=".gitea/config.yaml", help="配置文件路径"
+ )
parser.add_argument("--repo-root", default=".", help="Git 仓库根目录")
- parser.add_argument("push_user", nargs="?", default=None, help="推送人(CI 传入)")
- parser.add_argument("push_time", nargs="?", default=None, help="推送时间(CI 传入)")
+ parser.add_argument("push_user", nargs="?", default=None, help="推送人")
+ parser.add_argument("push_time", nargs="?", default=None, help="推送时间")
args = parser.parse_args()
repo_root = Path(args.repo_root).resolve()
- config = load_config(repo_root / args.config)
+ config_path = Path(args.config)
+ if not config_path.is_absolute():
+ config_path = repo_root / config_path
+ config = load_config(config_path)
source_subdir = config.get("source_dir", "src/main/java")
- jar_path = repo_root / config.get(
- "java_parser_jar", ".gitea/java-parser/target/controller-parser-1.0.0.jar"
- )
commit_info = get_current_commit()
push_user = args.push_user or commit_info.author
push_time = args.push_time or commit_info.commit_time
- print("Controller 接口参数变更检测")
+ print("Controller 接口参数变更检测(纯 Python)")
print("=" * 40)
print(f"推送人: {push_user}")
print(f"推送时间: {push_time}")
@@ -116,57 +140,44 @@ def main() -> int:
print(f" - {f}")
git_diff = get_controller_files_diff(prev_sha, commit_info.sha, changed_files)
- current_dir, prev_dir, _ = prepare_worktrees(repo_root)
- reports = []
+ new_map = parse_changed_endpoints(
+ repo_root, source_subdir, changed_files, prev_sha, "new"
+ )
+ old_map = parse_changed_endpoints(
+ repo_root, source_subdir, changed_files, prev_sha, "old"
+ )
+
+ new_filtered = endpoints_to_map(
+ filter_endpoints_by_files(list(new_map.values()), changed_files)
+ )
+ old_filtered = endpoints_to_map(
+ filter_endpoints_by_files(list(old_map.values()), changed_files)
+ )
+
+ reports = compare_endpoints(old_filtered, new_filtered)
+ print(f"[对比] 检测到 {len(reports)} 个接口存在参数变更")
+
llm_review = None
-
- with tempfile.TemporaryDirectory(prefix="ai-check-") as tmp:
- tmp_dir = Path(tmp)
-
- # 1. AST 解析 + 参数对比
- new_map = parse_endpoints_for_version(
- current_dir, source_subdir, jar_path, tmp_dir, "new"
- )
- old_map = parse_endpoints_for_version(
- prev_dir, source_subdir, jar_path, tmp_dir, "old"
+ if reports:
+ llm_review = review_parameter_changes(
+ reports, config, changed_files, git_diff
)
+ if llm_review:
+ print("[LLM] 参数变更审核完成")
- new_filtered = endpoints_to_map(
- filter_endpoints_by_files(list(new_map.values()), changed_files)
- )
- old_filtered = endpoints_to_map(
- filter_endpoints_by_files(list(old_map.values()), changed_files)
- )
-
- reports = compare_endpoints(old_filtered, new_filtered)
- print(f"[对比] 检测到 {len(reports)} 个接口存在参数变更")
-
- # 2. LLM 审核接口参数变更(非代码审查)
- if reports:
- llm_review = review_parameter_changes(
- reports, config, changed_files, git_diff
- )
- if llm_review:
- print(f"[LLM] 参数变更审核完成")
-
- # 3. 写日志(开关控制)
persist_change_log(reports, commit_info, config, llm_review)
- # 4. 企微通知
notify_cfg = config.get("notify", {})
- only_on_change = notify_cfg.get("only_on_change", True)
-
- if only_on_change and not reports:
+ if notify_cfg.get("only_on_change", True) and not reports:
print("[通知] 无接口参数变更,跳过企微通知。")
return 0
mentioned = notify_cfg.get("mentioned_users", "")
mentioned_list = [u.strip() for u in mentioned.split(",") if u.strip()] or None
- wecom_cfg = config.get("wecom", {})
send_parameter_change_notification(
- webhook_url=wecom_cfg.get("webhook_url", ""),
+ webhook_url=config.get("wecom", {}).get("webhook_url", ""),
reports=reports,
push_user=push_user,
push_time=push_time,
diff --git a/.gitea/checker/requirements.txt b/.gitea/checker/requirements.txt
index 05a75ae..cd09485 100644
--- a/.gitea/checker/requirements.txt
+++ b/.gitea/checker/requirements.txt
@@ -1,3 +1,4 @@
-# Python 依赖(CI 主流程)
+# Python 依赖(纯 Python AST 解析,无需 Java)
PyYAML>=6.0.1
requests>=2.31.0
+javalang>=0.13.0
diff --git a/.gitea/config.yaml b/.gitea/config.yaml
index 8150964..1976755 100644
--- a/.gitea/config.yaml
+++ b/.gitea/config.yaml
@@ -1,13 +1,17 @@
-# AI-Check 配置文件
-# 业务源码在 ftb 模块下
+# ============================================================
+# AI-Check 配置文件(位于 .gitea/ 目录,与业务代码解耦)
+# ============================================================
+# 业务 Java 源码目录(相对仓库根目录)
+# 单模块: src/main/java
+# 多模块: ftb/src/main/java
source_dir: "ftb/src/main/java"
-java_parser_jar: ".gitea/java-parser/target/controller-parser-1.0.0.jar"
-
+# ---------- 企业微信机器人 ----------
wecom:
webhook_url: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=61f08cc9-b734-4dff-a931-7f33654c0a81"
+# ---------- 豆包 LLM(审核接口参数变更)----------
llm:
enabled: true
api_key: "2f3f7ee9-a6f7-46b7-a709-a36743a83a04"
@@ -16,6 +20,7 @@ llm:
api_url: "https://ark.cn-beijing.volces.com/api/v3/chat/completions"
timeout: null
+# ---------- 变更日志 ----------
log:
enabled: false
storage: "file"
@@ -28,6 +33,7 @@ log:
database: "YOUR_MYSQL_DATABASE"
table: "api_change_logs"
+# ---------- 通知 ----------
notify:
only_on_change: true
mentioned_users: ""
diff --git a/.gitea/java-parser/pom.xml b/.gitea/java-parser/pom.xml
deleted file mode 100644
index c370f04..0000000
--- a/.gitea/java-parser/pom.xml
+++ /dev/null
@@ -1,62 +0,0 @@
-
-
- 4.0.0
-
- com.aicheck
- controller-parser
- 1.0.0
- jar
- Controller Parameter AST Parser
- 基于 JavaParser 解析 Spring Controller 接口参数
-
-
- 11
- 11
- UTF-8
- 3.25.10
- 2.17.2
-
-
-
-
-
- com.github.javaparser
- javaparser-core
- ${javaparser.version}
-
-
-
- com.fasterxml.jackson.core
- jackson-databind
- ${jackson.version}
-
-
-
-
-
-
- org.apache.maven.plugins
- maven-shade-plugin
- 3.6.0
-
-
- package
-
- shade
-
-
-
-
- com.aicheck.ControllerParserMain
-
-
- false
-
-
-
-
-
-
-
diff --git a/.gitea/java-parser/src/main/java/com/aicheck/ApiEndpoint.java b/.gitea/java-parser/src/main/java/com/aicheck/ApiEndpoint.java
deleted file mode 100644
index 75c50d6..0000000
--- a/.gitea/java-parser/src/main/java/com/aicheck/ApiEndpoint.java
+++ /dev/null
@@ -1,86 +0,0 @@
-package com.aicheck;
-
-import com.fasterxml.jackson.annotation.JsonInclude;
-import java.util.ArrayList;
-import java.util.List;
-
-/**
- * 单个 Controller 接口端点的模型,包含 URI、HTTP 方法及参数列表。
- */
-@JsonInclude(JsonInclude.Include.NON_NULL)
-public class ApiEndpoint {
-
- /** HTTP 方法:GET / POST / PUT / DELETE / PATCH */
- private String httpMethod;
-
- /** 完整 URI 路径,如 /api/users/{id} */
- private String uri;
-
- /** 所属 Controller 类名 */
- private String controllerClass;
-
- /** Java 方法名 */
- private String methodName;
-
- /** 源文件相对路径 */
- private String sourceFile;
-
- /** 接口参数列表 */
- private List parameters = new ArrayList<>();
-
- public String getHttpMethod() {
- return httpMethod;
- }
-
- public void setHttpMethod(String httpMethod) {
- this.httpMethod = httpMethod;
- }
-
- public String getUri() {
- return uri;
- }
-
- public void setUri(String uri) {
- this.uri = uri;
- }
-
- public String getControllerClass() {
- return controllerClass;
- }
-
- public void setControllerClass(String controllerClass) {
- this.controllerClass = controllerClass;
- }
-
- public String getMethodName() {
- return methodName;
- }
-
- public void setMethodName(String methodName) {
- this.methodName = methodName;
- }
-
- public String getSourceFile() {
- return sourceFile;
- }
-
- public void setSourceFile(String sourceFile) {
- this.sourceFile = sourceFile;
- }
-
- public List getParameters() {
- return parameters;
- }
-
- public void setParameters(List parameters) {
- this.parameters = parameters;
- }
-
- /**
- * 生成唯一标识,用于跨版本比对接口是否为同一个。
- * 格式:HTTP_METHOD + 空格 + URI
- */
- public String getEndpointKey() {
- return httpMethod + " " + uri;
- }
-}
diff --git a/.gitea/java-parser/src/main/java/com/aicheck/ApiParameter.java b/.gitea/java-parser/src/main/java/com/aicheck/ApiParameter.java
deleted file mode 100644
index aaee93c..0000000
--- a/.gitea/java-parser/src/main/java/com/aicheck/ApiParameter.java
+++ /dev/null
@@ -1,75 +0,0 @@
-package com.aicheck;
-
-import com.fasterxml.jackson.annotation.JsonInclude;
-
-/**
- * 单个接口参数的模型,对应 Controller 方法上的一个入参。
- */
-@JsonInclude(JsonInclude.Include.NON_NULL)
-public class ApiParameter {
-
- /** 参数名称(@RequestParam / @PathVariable 的 value,或字段名) */
- private String name;
-
- /** Java 类型,如 String、Long、Boolean */
- private String type;
-
- /** 是否必填(来自 required 属性或 @NotNull 等,默认 true) */
- private boolean required = true;
-
- /** 参数来源:query / path / body / header / form */
- private String source;
-
- /** 参数说明(来自 @ApiParam、@Parameter 等注解的 description) */
- private String description;
-
- public ApiParameter() {
- }
-
- public ApiParameter(String name, String type, boolean required, String source) {
- this.name = name;
- this.type = type;
- this.required = required;
- this.source = source;
- }
-
- public String getName() {
- return name;
- }
-
- public void setName(String name) {
- this.name = name;
- }
-
- public String getType() {
- return type;
- }
-
- public void setType(String type) {
- this.type = type;
- }
-
- public boolean isRequired() {
- return required;
- }
-
- public void setRequired(boolean required) {
- this.required = required;
- }
-
- public String getSource() {
- return source;
- }
-
- public void setSource(String source) {
- this.source = source;
- }
-
- public String getDescription() {
- return description;
- }
-
- public void setDescription(String description) {
- this.description = description;
- }
-}
diff --git a/.gitea/java-parser/src/main/java/com/aicheck/ControllerAstParser.java b/.gitea/java-parser/src/main/java/com/aicheck/ControllerAstParser.java
deleted file mode 100644
index f82ddba..0000000
--- a/.gitea/java-parser/src/main/java/com/aicheck/ControllerAstParser.java
+++ /dev/null
@@ -1,421 +0,0 @@
-package com.aicheck;
-
-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.MethodDeclaration;
-import com.github.javaparser.ast.body.Parameter;
-import com.github.javaparser.ast.expr.AnnotationExpr;
-import com.github.javaparser.ast.expr.MemberValuePair;
-import com.github.javaparser.ast.expr.NormalAnnotationExpr;
-import com.github.javaparser.ast.expr.SingleMemberAnnotationExpr;
-import com.github.javaparser.ast.type.ClassOrInterfaceType;
-
-import java.io.IOException;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Optional;
-import java.util.Set;
-import java.util.stream.Collectors;
-import java.util.stream.Stream;
-
-/**
- * 基于 JavaParser 的 Spring Controller AST 解析器。
- * 扫描指定目录下的 Java 文件,提取带 @RestController / @Controller 注解类中的接口定义。
- */
-public class ControllerAstParser {
-
- /** Spring 映射注解 -> HTTP 方法 */
- private static final Set MAPPING_ANNOTATIONS = Set.of(
- "GetMapping", "PostMapping", "PutMapping", "DeleteMapping", "PatchMapping", "RequestMapping"
- );
-
- /** 标识 Controller 的类级别注解 */
- private static final Set CONTROLLER_ANNOTATIONS = Set.of("RestController", "Controller");
-
- /**
- * 解析目录下所有 Java 文件中的 Controller 接口。
- *
- * @param rootDir 项目根目录或源码目录
- * @return 解析出的所有 API 端点列表
- */
- public List parseDirectory(Path rootDir) throws IOException {
- List endpoints = new ArrayList<>();
-
- if (!Files.exists(rootDir)) {
- return endpoints;
- }
-
- try (Stream paths = Files.walk(rootDir)) {
- List javaFiles = paths
- .filter(p -> p.toString().endsWith(".java"))
- .filter(p -> p.toString().contains("Controller"))
- .collect(Collectors.toList());
-
- for (Path javaFile : javaFiles) {
- endpoints.addAll(parseFile(javaFile, rootDir));
- }
- }
-
- return endpoints;
- }
-
- /**
- * 解析单个 Java 源文件。
- *
- * @param javaFile 源文件路径
- * @param rootDir 根目录,用于计算相对路径
- */
- public List parseFile(Path javaFile, Path rootDir) throws IOException {
- List endpoints = new ArrayList<>();
- String source = Files.readString(javaFile);
- CompilationUnit cu = StaticJavaParser.parse(source);
-
- String relativePath = rootDir.relativize(javaFile).toString().replace("\\", "/");
-
- for (ClassOrInterfaceDeclaration clazz : cu.findAll(ClassOrInterfaceDeclaration.class)) {
- if (!isController(clazz)) {
- continue;
- }
-
- String classBasePath = extractClassBasePath(clazz);
-
- for (MethodDeclaration method : clazz.getMethods()) {
- Optional endpointOpt = parseMethod(method, clazz, classBasePath, relativePath, rootDir);
- endpointOpt.ifPresent(endpoints::add);
- }
- }
-
- return endpoints;
- }
-
- /**
- * 判断类是否为 Spring Controller(含 @RestController 或 @Controller)。
- */
- private boolean isController(ClassOrInterfaceDeclaration clazz) {
- return clazz.getAnnotations().stream()
- .anyMatch(a -> CONTROLLER_ANNOTATIONS.contains(getSimpleAnnotationName(a)));
- }
-
- /**
- * 提取类级别 @RequestMapping 的基础路径。
- */
- private String extractClassBasePath(ClassOrInterfaceDeclaration clazz) {
- for (AnnotationExpr annotation : clazz.getAnnotations()) {
- if ("RequestMapping".equals(getSimpleAnnotationName(annotation))) {
- return normalizePath(extractAnnotationStringValue(annotation, "value", "path"));
- }
- }
- return "";
- }
-
- /**
- * 解析单个 Controller 方法,若不含映射注解则返回 empty。
- */
- private Optional parseMethod(
- MethodDeclaration method,
- ClassOrInterfaceDeclaration clazz,
- String classBasePath,
- String relativePath,
- Path rootDir) {
-
- for (AnnotationExpr annotation : method.getAnnotations()) {
- String annName = getSimpleAnnotationName(annotation);
- if (!MAPPING_ANNOTATIONS.contains(annName)) {
- continue;
- }
-
- ApiEndpoint endpoint = new ApiEndpoint();
- endpoint.setControllerClass(clazz.getNameAsString());
- endpoint.setMethodName(method.getNameAsString());
- endpoint.setSourceFile(relativePath);
- endpoint.setHttpMethod(resolveHttpMethod(annName, annotation));
- endpoint.setUri(joinPaths(classBasePath, normalizePath(extractAnnotationStringValue(annotation, "value", "path"))));
- endpoint.setParameters(extractParameters(method, rootDir));
-
- return Optional.of(endpoint);
- }
-
- return Optional.empty();
- }
-
- /**
- * 从映射注解推断 HTTP 方法。
- */
- private String resolveHttpMethod(String annName, AnnotationExpr annotation) {
- switch (annName) {
- case "GetMapping":
- return "GET";
- case "PostMapping":
- return "POST";
- case "PutMapping":
- return "PUT";
- case "DeleteMapping":
- return "DELETE";
- case "PatchMapping":
- return "PATCH";
- case "RequestMapping":
- String method = extractAnnotationStringValue(annotation, "method");
- if (!method.isEmpty()) {
- return method.replace("RequestMethod.", "").toUpperCase();
- }
- return "GET";
- default:
- return "GET";
- }
- }
-
- /**
- * 提取方法的所有入参(含 @RequestBody DTO 字段展开)。
- */
- private List extractParameters(MethodDeclaration method, Path rootDir) {
- List params = new ArrayList<>();
-
- for (Parameter param : method.getParameters()) {
- String paramType = param.getType().asString();
-
- // @RequestBody:尝试展开 DTO 类字段
- if (hasAnnotation(param, "RequestBody")) {
- params.addAll(expandDtoFields(paramType, rootDir, "body"));
- continue;
- }
-
- ApiParameter apiParam = new ApiParameter();
- apiParam.setType(paramType);
- apiParam.setSource(resolveParameterSource(param));
- apiParam.setName(resolveParameterName(param));
- apiParam.setRequired(resolveRequired(param));
- apiParam.setDescription(extractParamDescription(param));
- params.add(apiParam);
- }
-
- return params;
- }
-
- /**
- * 展开 @RequestBody DTO 类的字段为独立参数(便于对比字段增删改)。
- */
- private List expandDtoFields(String typeName, Path rootDir, String source) {
- List fields = new ArrayList<>();
- Optional dtoFile = findJavaFileBySimpleName(typeName, rootDir);
-
- if (dtoFile.isEmpty()) {
- // 找不到 DTO 源文件时,保留整体类型
- ApiParameter body = new ApiParameter();
- body.setName(typeName);
- body.setType(typeName);
- body.setSource(source);
- body.setRequired(true);
- fields.add(body);
- return fields;
- }
-
- try {
- CompilationUnit cu = StaticJavaParser.parse(dtoFile.get());
- for (FieldDeclaration field : cu.findAll(FieldDeclaration.class)) {
- if (field.isStatic()) {
- continue;
- }
- for (var variable : field.getVariables()) {
- ApiParameter fp = new ApiParameter();
- fp.setName(variable.getNameAsString());
- fp.setType(field.getElementType().asString());
- fp.setSource(source);
- fp.setRequired(!hasAnnotation(field, "Nullable"));
- fields.add(fp);
- }
- }
- } catch (IOException ignored) {
- // 解析失败时退化为整体 body 参数
- ApiParameter body = new ApiParameter();
- body.setName(typeName);
- body.setType(typeName);
- body.setSource(source);
- fields.add(body);
- }
-
- return fields;
- }
-
- /**
- * 在源码目录中按简单类名查找 Java 文件。
- */
- private Optional findJavaFileBySimpleName(String typeName, Path rootDir) {
- String simpleName = typeName.contains(".") ? typeName.substring(typeName.lastIndexOf('.') + 1) : typeName;
- simpleName = simpleName.replace(">", "").replace("<", "").trim();
-
- try (Stream paths = Files.walk(rootDir)) {
- final String target = simpleName;
- return paths
- .filter(p -> p.getFileName().toString().equals(target + ".java"))
- .findFirst();
- } catch (IOException e) {
- return Optional.empty();
- }
- }
-
- /**
- * 判断参数来源:query / path / header / form / body。
- */
- private String resolveParameterSource(Parameter param) {
- if (hasAnnotation(param, "PathVariable")) return "path";
- if (hasAnnotation(param, "RequestHeader")) return "header";
- if (hasAnnotation(param, "RequestPart")) return "form";
- if (hasAnnotation(param, "ModelAttribute")) return "form";
- return "query";
- }
-
- /**
- * 解析参数名称:优先取注解 value/name,否则用变量名。
- */
- private String resolveParameterName(Parameter param) {
- for (String ann : Arrays.asList("RequestParam", "PathVariable", "RequestHeader", "RequestPart")) {
- if (hasAnnotation(param, ann)) {
- Optional opt = param.getAnnotationByName(ann);
- if (opt.isPresent()) {
- String val = extractAnnotationStringValue(opt.get(), "value", "name");
- if (!val.isEmpty()) {
- return val;
- }
- }
- }
- }
- return param.getNameAsString();
- }
-
- /**
- * 解析参数是否必填。
- */
- private boolean resolveRequired(Parameter param) {
- if (hasAnnotation(param, "RequestParam")) {
- Optional opt = param.getAnnotationByName("RequestParam");
- if (opt.isPresent()) {
- String required = extractAnnotationMemberValue(opt.get(), "required");
- if ("false".equalsIgnoreCase(required)) {
- return false;
- }
- }
- }
- if (param.getType() instanceof ClassOrInterfaceType) {
- ClassOrInterfaceType cit = (ClassOrInterfaceType) param.getType();
- if ("Optional".equals(cit.getNameAsString())) {
- return false;
- }
- }
- return !hasAnnotation(param, "Nullable");
- }
-
- /**
- * 提取 @ApiParam / @Parameter 的 description。
- */
- private String extractParamDescription(Parameter param) {
- for (String ann : Arrays.asList("ApiParam", "Parameter", "Schema")) {
- Optional opt = param.getAnnotationByName(ann);
- if (opt.isPresent()) {
- return extractAnnotationStringValue(opt.get(), "description", "value");
- }
- }
- return null;
- }
-
- private boolean hasAnnotation(Object node, String simpleName) {
- if (node instanceof Parameter) {
- Parameter p = (Parameter) node;
- return p.getAnnotationByName(simpleName).isPresent();
- }
- if (node instanceof FieldDeclaration) {
- FieldDeclaration f = (FieldDeclaration) node;
- return f.getAnnotationByName(simpleName).isPresent();
- }
- return false;
- }
-
- /**
- * 获取注解的简单名称(去掉包名)。
- */
- private String getSimpleAnnotationName(AnnotationExpr annotation) {
- String name = annotation.getNameAsString();
- int dot = name.lastIndexOf('.');
- return dot >= 0 ? name.substring(dot + 1) : name;
- }
-
- /**
- * 从注解中提取字符串属性,支持 value/path/name 等多个候选 key。
- */
- private String extractAnnotationStringValue(AnnotationExpr annotation, String... keys) {
- Set keySet = new HashSet<>(Arrays.asList(keys));
-
- if (annotation instanceof SingleMemberAnnotationExpr) {
- SingleMemberAnnotationExpr single = (SingleMemberAnnotationExpr) annotation;
- return stripQuotes(single.getMemberValue().toString());
- }
-
- if (annotation instanceof NormalAnnotationExpr) {
- NormalAnnotationExpr normal = (NormalAnnotationExpr) annotation;
- for (MemberValuePair pair : normal.getPairs()) {
- if (keySet.contains(pair.getNameAsString())) {
- return stripQuotes(pair.getValue().toString());
- }
- }
- }
-
- return "";
- }
-
- /**
- * 提取注解成员的原始字符串值。
- */
- private String extractAnnotationMemberValue(AnnotationExpr annotation, String key) {
- if (annotation instanceof NormalAnnotationExpr) {
- NormalAnnotationExpr normal = (NormalAnnotationExpr) annotation;
- for (MemberValuePair pair : normal.getPairs()) {
- if (key.equals(pair.getNameAsString())) {
- return stripQuotes(pair.getValue().toString());
- }
- }
- }
- return "";
- }
-
- private String stripQuotes(String value) {
- return value.replace("\"", "").replace("'", "").trim();
- }
-
- /**
- * 拼接类级别与方法级别的路径。
- */
- private String joinPaths(String base, String methodPath) {
- String b = normalizePath(base);
- String m = normalizePath(methodPath);
-
- if (b.isEmpty()) return m.isEmpty() ? "/" : m;
- if (m.isEmpty()) return b;
-
- if (b.endsWith("/") && m.startsWith("/")) {
- return b + m.substring(1);
- }
- if (!b.endsWith("/") && !m.startsWith("/")) {
- return b + "/" + m;
- }
- return b + m;
- }
-
- /**
- * 规范化路径:确保以 / 开头,去除多余斜杠。
- */
- private String normalizePath(String path) {
- if (path == null || path.isBlank()) {
- return "";
- }
- path = path.trim();
- if (!path.startsWith("/")) {
- path = "/" + path;
- }
- return path.replaceAll("/+", "/");
- }
-}
diff --git a/.gitea/java-parser/src/main/java/com/aicheck/ControllerParserMain.java b/.gitea/java-parser/src/main/java/com/aicheck/ControllerParserMain.java
deleted file mode 100644
index 99db7b9..0000000
--- a/.gitea/java-parser/src/main/java/com/aicheck/ControllerParserMain.java
+++ /dev/null
@@ -1,48 +0,0 @@
-package com.aicheck;
-
-import com.fasterxml.jackson.databind.ObjectMapper;
-import com.fasterxml.jackson.databind.SerializationFeature;
-
-import java.io.IOException;
-import java.nio.file.Path;
-import java.nio.file.Paths;
-import java.util.List;
-
-/**
- * Java AST 解析器命令行入口。
- * 用法:java -jar controller-parser.jar <源码目录> [输出JSON文件路径]
- *
- * 示例:
- * java -jar controller-parser.jar ./src/main/java ./endpoints.json
- */
-public class ControllerParserMain {
-
- private static final ObjectMapper MAPPER = new ObjectMapper()
- .enable(SerializationFeature.INDENT_OUTPUT);
-
- /**
- * 程序入口:解析指定目录并输出 JSON。
- *
- * @param args [0]=源码目录, [1]=可选的输出文件路径(默认 stdout)
- */
- public static void main(String[] args) throws IOException {
- if (args.length < 1) {
- System.err.println("用法: java -jar controller-parser.jar <源码目录> [输出JSON路径]");
- System.exit(1);
- }
-
- Path sourceDir = Paths.get(args[0]).toAbsolutePath().normalize();
- ControllerAstParser parser = new ControllerAstParser();
- List endpoints = parser.parseDirectory(sourceDir);
-
- String json = MAPPER.writeValueAsString(endpoints);
-
- if (args.length >= 2) {
- Path output = Paths.get(args[1]);
- MAPPER.writeValue(output.toFile(), endpoints);
- System.out.println("已解析 " + endpoints.size() + " 个接口,输出至: " + output);
- } else {
- System.out.println(json);
- }
- }
-}
diff --git a/.gitea/java-parser/target/maven-archiver/pom.properties b/.gitea/java-parser/target/maven-archiver/pom.properties
deleted file mode 100644
index 5107dc9..0000000
--- a/.gitea/java-parser/target/maven-archiver/pom.properties
+++ /dev/null
@@ -1,5 +0,0 @@
-#Generated by Maven
-#Wed Jun 03 11:29:14 GMT+08:00 2026
-groupId=com.aicheck
-artifactId=controller-parser
-version=1.0.0
diff --git a/.gitea/java-parser/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst b/.gitea/java-parser/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst
deleted file mode 100644
index 85acf3b..0000000
--- a/.gitea/java-parser/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst
+++ /dev/null
@@ -1,4 +0,0 @@
-com\aicheck\ControllerParserMain.class
-com\aicheck\ControllerAstParser.class
-com\aicheck\ApiParameter.class
-com\aicheck\ApiEndpoint.class
diff --git a/.gitea/java-parser/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst b/.gitea/java-parser/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst
deleted file mode 100644
index ab8bb11..0000000
--- a/.gitea/java-parser/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst
+++ /dev/null
@@ -1,4 +0,0 @@
-C:\Users\EDY\Desktop\AI-Check\java-parser\src\main\java\com\aicheck\ControllerAstParser.java
-C:\Users\EDY\Desktop\AI-Check\java-parser\src\main\java\com\aicheck\ApiParameter.java
-C:\Users\EDY\Desktop\AI-Check\java-parser\src\main\java\com\aicheck\ControllerParserMain.java
-C:\Users\EDY\Desktop\AI-Check\java-parser\src\main\java\com\aicheck\ApiEndpoint.java
diff --git a/.gitea/workflows/api-change-check.yml b/.gitea/workflows/api-change-check.yml
index ebce322..1d6b856 100644
--- a/.gitea/workflows/api-change-check.yml
+++ b/.gitea/workflows/api-change-check.yml
@@ -1,6 +1,4 @@
-# Gitea Actions:push 后检测 Controller 接口参数变更
-# 工具代码与配置均位于 .gitea/ 目录,与业务代码解耦
-# 检出方式:使用 gitea.token 直连内网 Git 仓库(避免 actions/checkout 网络问题)
+# Gitea Actions:Controller 接口参数变更检测(纯 Python,无 Java 构建)
name: API接口参数变更检测
run-name: ${{ gitea.actor }}的API参数变更检测
@@ -9,7 +7,6 @@ on: [push]
jobs:
api-param-check:
- # 排除指定分支(与现有 AI 审查 workflow 保持一致,可按需修改)
if: ${{ gitea.ref != 'refs/heads/pre' && gitea.ref != 'refs/heads/dev' && gitea.ref != 'refs/heads/master-2.0' }}
runs-on: ubuntu-latest
@@ -28,29 +25,15 @@ jobs:
echo "错误: 缺少 .gitea/config.yaml"
exit 1
fi
- echo "使用 .gitea/config.yaml"
- - name: 安装 Java、Maven 和 Python
+ - name: 安装 Python 和依赖
run: |
- echo "安装运行环境..."
sudo apt-get update
- sudo apt-get install -y openjdk-11-jdk maven python3 python3-pip
- java -version
- mvn -version
- python3 --version
- echo "环境准备完成"
-
- - name: 安装 Python 依赖
- run: |
+ sudo apt-get install -y python3 python3-pip
python3 -m pip install --break-system-packages -r .gitea/checker/requirements.txt
- - name: 构建 AST 解析器
- working-directory: .gitea/java-parser
- run: mvn -q package -DskipTests
-
- name: 检测 Controller 接口参数变更
run: |
- echo "执行 API 参数变更检测..."
COMMIT_TIME=$(git log -1 --format=%cd --date=format:'%Y-%m-%d %H:%M:%S')
python3 .gitea/checker/main.py \
--config .gitea/config.yaml \