From 2c20a26af8b46ee5131feb4ac52b81c4a9287816 Mon Sep 17 00:00:00 2001 From: dongzi Date: Wed, 3 Jun 2026 15:33:24 +0800 Subject: [PATCH] =?UTF-8?q?=E8=84=9A=E6=9C=AC=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/checker/comparator.py | 2 +- .gitea/checker/controller_parser.py | 123 ++--- .gitea/checker/git_utils.py | 14 + .gitea/checker/main.py | 149 ++++--- .gitea/checker/requirements.txt | 3 +- .gitea/config.yaml | 14 +- .gitea/java-parser/pom.xml | 62 --- .../main/java/com/aicheck/ApiEndpoint.java | 86 ---- .../main/java/com/aicheck/ApiParameter.java | 75 ---- .../java/com/aicheck/ControllerAstParser.java | 421 ------------------ .../com/aicheck/ControllerParserMain.java | 48 -- .../target/maven-archiver/pom.properties | 5 - .../compile/default-compile/createdFiles.lst | 4 - .../compile/default-compile/inputFiles.lst | 4 - .gitea/workflows/api-change-check.yml | 23 +- 15 files changed, 136 insertions(+), 897 deletions(-) delete mode 100644 .gitea/java-parser/pom.xml delete mode 100644 .gitea/java-parser/src/main/java/com/aicheck/ApiEndpoint.java delete mode 100644 .gitea/java-parser/src/main/java/com/aicheck/ApiParameter.java delete mode 100644 .gitea/java-parser/src/main/java/com/aicheck/ControllerAstParser.java delete mode 100644 .gitea/java-parser/src/main/java/com/aicheck/ControllerParserMain.java delete mode 100644 .gitea/java-parser/target/maven-archiver/pom.properties delete mode 100644 .gitea/java-parser/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst delete mode 100644 .gitea/java-parser/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst 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 \