From 556f5b8ab6d6c741fb737ce579ed19641e78ec27 Mon Sep 17 00:00:00 2001 From: dongzi Date: Wed, 3 Jun 2026 15:02:21 +0800 Subject: [PATCH] test --- .gitea/.gitignore | 3 + .gitea/checker/change_logger.py | 165 +++++++ .gitea/checker/comparator.py | 256 +++++++++++ .gitea/checker/controller_parser.py | 114 +++++ .gitea/checker/git_utils.py | 140 ++++++ .gitea/checker/llm_reviewer.py | 182 ++++++++ .gitea/checker/main.py | 182 ++++++++ .gitea/checker/notifier.py | 218 +++++++++ .gitea/checker/requirements.txt | 3 + .gitea/config.yaml | 33 ++ .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/scripts/init_mysql.sql | 17 + .gitea/workflows/api-change-check.yml | 62 +++ .idea/.gitignore | 10 + .idea/AI-Check-Test.iml | 9 + .idea/misc.xml | 6 + .idea/modules.xml | 8 + .idea/vcs.xml | 6 + 25 files changed, 2119 insertions(+) create mode 100644 .gitea/.gitignore create mode 100644 .gitea/checker/change_logger.py create mode 100644 .gitea/checker/comparator.py create mode 100644 .gitea/checker/controller_parser.py create mode 100644 .gitea/checker/git_utils.py create mode 100644 .gitea/checker/llm_reviewer.py create mode 100644 .gitea/checker/main.py create mode 100644 .gitea/checker/notifier.py create mode 100644 .gitea/checker/requirements.txt create mode 100644 .gitea/config.yaml create mode 100644 .gitea/java-parser/pom.xml create mode 100644 .gitea/java-parser/src/main/java/com/aicheck/ApiEndpoint.java create mode 100644 .gitea/java-parser/src/main/java/com/aicheck/ApiParameter.java create mode 100644 .gitea/java-parser/src/main/java/com/aicheck/ControllerAstParser.java create mode 100644 .gitea/java-parser/src/main/java/com/aicheck/ControllerParserMain.java create mode 100644 .gitea/java-parser/target/maven-archiver/pom.properties create mode 100644 .gitea/java-parser/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst create mode 100644 .gitea/java-parser/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst create mode 100644 .gitea/scripts/init_mysql.sql create mode 100644 .gitea/workflows/api-change-check.yml create mode 100644 .idea/.gitignore create mode 100644 .idea/AI-Check-Test.iml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml diff --git a/.gitea/.gitignore b/.gitea/.gitignore new file mode 100644 index 0000000..c11b646 --- /dev/null +++ b/.gitea/.gitignore @@ -0,0 +1,3 @@ +# 本地/CI 运行时产生的缓存与日志(可不提交) +.cache/ +logs/ diff --git a/.gitea/checker/change_logger.py b/.gitea/checker/change_logger.py new file mode 100644 index 0000000..aecffee --- /dev/null +++ b/.gitea/checker/change_logger.py @@ -0,0 +1,165 @@ +""" +变更日志持久化模块。 +受 log.enabled 开关控制,默认关闭;仅记录接口参数变更及 LLM 审核结果。 +""" + +import json +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional + +from comparator import EndpointChangeReport +from git_utils import CommitInfo + + +def is_log_enabled(config: Dict[str, Any]) -> bool: + """判断日志总开关是否开启。""" + return config.get("log", {}).get("enabled", False) + + +def _serialize_reports(reports: List[EndpointChangeReport]) -> List[dict]: + """将参数变更报告序列化为 JSON 结构。""" + result = [] + for r in reports: + result.append( + { + "uri": r.uri, + "http_method": r.http_method, + "controller_class": r.controller_class, + "method_name": r.method_name, + "is_new_endpoint": r.is_new_endpoint, + "is_removed_endpoint": r.is_removed_endpoint, + "parameter_changes": [ + { + "change_type": c.change_type.value, + "param_name": c.param_name, + "param_type": c.param_type, + "old_name": c.old_name, + "old_type": c.old_type, + "required": c.required, + "detail": c.detail, + } + for c in r.parameter_changes + ], + } + ) + return result + + +def save_to_file( + reports: List[EndpointChangeReport], + commit_info: CommitInfo, + log_dir: str, + llm_review: Optional[str] = None, +) -> Path: + """写入 JSON 日志文件。""" + log_path = Path(log_dir) + log_path.mkdir(parents=True, exist_ok=True) + + short_sha = commit_info.sha[:8] + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + output = log_path / f"{timestamp}_{short_sha}.json" + + record = { + "commit_sha": commit_info.sha, + "author": commit_info.author, + "commit_time": commit_info.commit_time, + "message": commit_info.message, + "detected_at": datetime.now().isoformat(), + "change_count": len(reports), + "parameter_changes": _serialize_reports(reports), + "llm_review": llm_review, + } + + with open(output, "w", encoding="utf-8") as f: + json.dump(record, f, ensure_ascii=False, indent=2) + + print(f"[日志] 参数变更记录已写入: {output}") + return output + + +def save_to_mysql( + reports: List[EndpointChangeReport], + commit_info: CommitInfo, + mysql_config: Dict[str, Any], + llm_review: Optional[str] = None, +) -> bool: + """写入 MySQL。""" + try: + import pymysql + except ImportError: + print("[错误] MySQL 模式需要: pip install pymysql") + return False + + host = mysql_config.get("host", "") + if not host or host == "YOUR_MYSQL_HOST": + print("[警告] 未配置 MySQL,跳过写入。") + return False + + try: + conn = pymysql.connect( + host=host, + port=int(mysql_config.get("port", 3306)), + user=mysql_config.get("user"), + password=mysql_config.get("password"), + database=mysql_config.get("database"), + charset="utf8mb4", + ) + table = mysql_config.get("table", "api_change_logs") + payload = json.dumps(_serialize_reports(reports), ensure_ascii=False) + + with conn.cursor() as cursor: + sql = f""" + INSERT INTO `{table}` + (commit_sha, author, commit_time, commit_message, change_count, reports_json, llm_review, created_at) + VALUES (%s, %s, %s, %s, %s, %s, %s, NOW()) + """ + cursor.execute( + sql, + ( + commit_info.sha, + commit_info.author, + commit_info.commit_time, + commit_info.message, + len(reports), + payload, + llm_review, + ), + ) + conn.commit() + conn.close() + print(f"[日志] 已写入 MySQL: {table}") + return True + except Exception as exc: + print(f"[错误] MySQL 写入失败: {exc}") + return False + + +def persist_change_log( + reports: List[EndpointChangeReport], + commit_info: CommitInfo, + config: Dict[str, Any], + llm_review: Optional[str] = None, +) -> None: + """ + 根据 log.enabled 决定是否持久化接口参数变更日志。 + + :param reports: 参数变更报告 + :param commit_info: 提交信息 + :param config: 完整配置 + :param llm_review: LLM 参数变更审核结论 + """ + if not is_log_enabled(config): + print("[日志] 日志开关已关闭(log.enabled=false),跳过写入。") + return + + log_cfg = config.get("log", {}) + if log_cfg.get("storage") == "mysql": + save_to_mysql(reports, commit_info, log_cfg.get("mysql", {}), llm_review) + else: + save_to_file( + reports, + commit_info, + log_cfg.get("file_dir", ".gitea/logs/api-changes"), + llm_review, + ) diff --git a/.gitea/checker/comparator.py b/.gitea/checker/comparator.py new file mode 100644 index 0000000..c23e971 --- /dev/null +++ b/.gitea/checker/comparator.py @@ -0,0 +1,256 @@ +""" +API 参数变更对比模块。 +对比新旧两个版本的 Controller 端点,识别参数的增、删、改、重命名。 +""" + +from dataclasses import dataclass, field +from enum import Enum +from typing import Dict, List, Optional, Set, Tuple + +from controller_parser import ApiEndpoint, ApiParameter + + +class ChangeType(str, Enum): + """参数变更类型。""" + + ADDED = "added" + REMOVED = "removed" + MODIFIED = "modified" + RENAMED = "renamed" + + +@dataclass +class ParameterChange: + """单条参数变更记录。""" + + change_type: ChangeType + param_name: str + param_type: Optional[str] = None + old_name: Optional[str] = None + old_type: Optional[str] = None + required: Optional[bool] = None + old_required: Optional[bool] = None + detail: Optional[str] = None + + def to_display_line(self) -> str: + """ + 格式化为通知模板中的一行文本。 + + :return: 如 "删除: Boolean userType" 或 "重命名: String userName -> String accountName" + """ + if self.change_type == ChangeType.REMOVED: + return f" - 删除: {self.param_type} {self.param_name}" + if self.change_type == ChangeType.ADDED: + req_text = f" (是否必填:{str(self.required).lower()})" if self.required is not None else "" + return f" - 新增: {self.param_type} {self.param_name}{req_text}" + if self.change_type == ChangeType.RENAMED: + return f" - 重命名: {self.old_type} {self.old_name} -> {self.param_type} {self.param_name}" + if self.change_type == ChangeType.MODIFIED: + parts = [f" - 修改: {self.param_name}"] + if self.detail: + parts.append(f" ({self.detail})") + return "".join(parts) + return f" - {self.change_type.value}: {self.param_name}" + + +@dataclass +class EndpointChangeReport: + """单个接口的变更报告。""" + + uri: str + http_method: str + controller_class: str + method_name: str + parameter_changes: List[ParameterChange] = field(default_factory=list) + is_new_endpoint: bool = False + is_removed_endpoint: bool = False + + @property + def has_changes(self) -> bool: + """是否存在任何变更。""" + return ( + self.is_new_endpoint + or self.is_removed_endpoint + or len(self.parameter_changes) > 0 + ) + + @property + def endpoint_key(self) -> str: + return f"{self.http_method} {self.uri}" + + +def _param_key(p: ApiParameter) -> Tuple[str, str]: + """参数匹配键:(source, name)。""" + return (p.source, p.name) + + +def compare_parameters( + old_params: List[ApiParameter], new_params: List[ApiParameter] +) -> List[ParameterChange]: + """ + 对比同一接口新旧版本的参数列表,识别增删改及重命名。 + + 重命名启发式:若删除与新增参数类型相同且 source 相同,则视为重命名。 + + :param old_params: 旧版本参数 + :param new_params: 新版本参数 + :return: 变更列表 + """ + changes: List[ParameterChange] = [] + + old_map: Dict[Tuple[str, str], ApiParameter] = {_param_key(p): p for p in old_params} + new_map: Dict[Tuple[str, str], ApiParameter] = {_param_key(p): p for p in new_params} + + old_keys = set(old_map.keys()) + new_keys = set(new_map.keys()) + + removed_keys = old_keys - new_keys + added_keys = new_keys - old_keys + common_keys = old_keys & new_keys + + # 1. 共同参数:检查类型、必填等属性变更 + for key in common_keys: + old_p = old_map[key] + new_p = new_map[key] + detail_parts = [] + if old_p.type != new_p.type: + detail_parts.append(f"类型 {old_p.type} -> {new_p.type}") + if old_p.required != new_p.required: + detail_parts.append(f"必填 {old_p.required} -> {new_p.required}") + if detail_parts: + changes.append( + ParameterChange( + change_type=ChangeType.MODIFIED, + param_name=new_p.name, + param_type=new_p.type, + required=new_p.required, + old_required=old_p.required, + detail=", ".join(detail_parts), + ) + ) + + # 2. 重命名检测:在 removed + added 中找同 type + source 的配对 + unmatched_removed: List[Tuple[Tuple[str, str], ApiParameter]] = [] + unmatched_added: List[Tuple[Tuple[str, str], ApiParameter]] = [] + + for key in removed_keys: + unmatched_removed.append((key, old_map[key])) + for key in added_keys: + unmatched_added.append((key, new_map[key])) + + matched_removed: Set[Tuple[str, str]] = set() + matched_added: Set[Tuple[str, str]] = set() + + for r_key, r_param in unmatched_removed: + for a_key, a_param in unmatched_added: + if a_key in matched_added: + continue + if r_param.type == a_param.type and r_param.source == a_param.source: + changes.append( + ParameterChange( + change_type=ChangeType.RENAMED, + param_name=a_param.name, + param_type=a_param.type, + old_name=r_param.name, + old_type=r_param.type, + required=a_param.required, + ) + ) + matched_removed.add(r_key) + matched_added.add(a_key) + break + + # 3. 纯删除 + for key, param in unmatched_removed: + if key not in matched_removed: + changes.append( + ParameterChange( + change_type=ChangeType.REMOVED, + param_name=param.name, + param_type=param.type, + ) + ) + + # 4. 纯新增 + for key, param in unmatched_added: + if key not in matched_added: + changes.append( + ParameterChange( + change_type=ChangeType.ADDED, + param_name=param.name, + param_type=param.type, + required=param.required, + ) + ) + + return changes + + +def compare_endpoints( + old_endpoints: Dict[str, ApiEndpoint], + new_endpoints: Dict[str, ApiEndpoint], +) -> List[EndpointChangeReport]: + """ + 对比新旧两个版本的全部 Controller 端点,生成变更报告列表。 + + :param old_endpoints: 旧版本 { endpoint_key: ApiEndpoint } + :param new_endpoints: 新版本 { endpoint_key: ApiEndpoint } + :return: 有变更的接口报告列表 + """ + reports: List[EndpointChangeReport] = [] + + all_keys = set(old_endpoints.keys()) | set(new_endpoints.keys()) + + for key in sorted(all_keys): + old_ep = old_endpoints.get(key) + new_ep = new_endpoints.get(key) + + if old_ep is None and new_ep is not None: + # 全新接口 + reports.append( + EndpointChangeReport( + uri=new_ep.uri, + http_method=new_ep.http_method, + controller_class=new_ep.controller_class, + method_name=new_ep.method_name, + is_new_endpoint=True, + parameter_changes=[ + ParameterChange( + change_type=ChangeType.ADDED, + param_name=p.name, + param_type=p.type, + required=p.required, + ) + for p in new_ep.parameters + ], + ) + ) + continue + + if new_ep is None and old_ep is not None: + # 接口被删除 + reports.append( + EndpointChangeReport( + uri=old_ep.uri, + http_method=old_ep.http_method, + controller_class=old_ep.controller_class, + method_name=old_ep.method_name, + is_removed_endpoint=True, + ) + ) + continue + + # 同 URI 对比参数 + param_changes = compare_parameters(old_ep.parameters, new_ep.parameters) + if param_changes: + reports.append( + EndpointChangeReport( + uri=new_ep.uri, + http_method=new_ep.http_method, + controller_class=new_ep.controller_class, + method_name=new_ep.method_name, + parameter_changes=param_changes, + ) + ) + + return [r for r in reports if r.has_changes] diff --git a/.gitea/checker/controller_parser.py b/.gitea/checker/controller_parser.py new file mode 100644 index 0000000..ebce785 --- /dev/null +++ b/.gitea/checker/controller_parser.py @@ -0,0 +1,114 @@ +""" +Controller 端点解析模块。 +调用 Java AST 解析器 JAR,将 Java 源码转为结构化的 API 端点列表。 +""" + +import json +import subprocess +from dataclasses import dataclass, field +from pathlib import Path +from typing import Dict, List, Optional + + +@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, + ) + + +def endpoints_to_map(endpoints: List[ApiEndpoint]) -> Dict[str, ApiEndpoint]: + """ + 将端点列表转为字典,key 为 endpoint_key。 + + :param endpoints: 端点列表 + :return: { "GET /api/users/{id}": ApiEndpoint, ... } + """ + 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) + return [ep for ep in endpoints if ep.source_file in changed_set] diff --git a/.gitea/checker/git_utils.py b/.gitea/checker/git_utils.py new file mode 100644 index 0000000..b69c7c6 --- /dev/null +++ b/.gitea/checker/git_utils.py @@ -0,0 +1,140 @@ +""" +Git 操作工具模块。 +负责在 CI 环境中检出上一版本代码、获取变更文件列表及提交元信息。 +""" + +import os +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import List, Optional + + +@dataclass +class CommitInfo: + """单次 Git 提交的元信息。""" + + sha: str + author: str + commit_time: str + message: str + + +def run_git(args: List[str], cwd: Optional[Path] = None) -> str: + """ + 执行 git 命令并返回标准输出。 + + :param args: git 子命令及参数,如 ["log", "-1", "--format=%H"] + :param cwd: 工作目录,默认为当前目录 + :return: 命令 stdout 文本(已 strip) + :raises RuntimeError: git 命令执行失败时抛出 + """ + cmd = ["git"] + args + result = subprocess.run( + cmd, + cwd=cwd, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + if result.returncode != 0: + raise RuntimeError(f"Git 命令失败: {' '.join(cmd)}\n{result.stderr}") + return result.stdout.strip() + + +def get_current_commit() -> CommitInfo: + """ + 获取当前 HEAD 提交的元信息(推送人、时间等,用于通知模板)。 + + :return: CommitInfo 对象 + """ + sha = run_git(["rev-parse", "HEAD"]) + author = run_git(["log", "-1", "--format=%an"]) + commit_time = run_git(["log", "-1", "--format=%ci"]) + message = run_git(["log", "-1", "--format=%s"]) + return CommitInfo(sha=sha, author=author, commit_time=commit_time, message=message) + + +def get_previous_commit_sha() -> Optional[str]: + """ + 获取上一次提交的 SHA(HEAD~1)。 + 若是首次提交则返回 None。 + + :return: 上一 commit SHA,或 None + """ + try: + return run_git(["rev-parse", "HEAD~1"]) + except RuntimeError: + return None + + +def checkout_commit(sha: str, worktree_dir: Path) -> None: + """ + 将指定 commit 的代码检出到独立工作目录(不影响当前工作区)。 + + :param sha: 目标 commit SHA + :param worktree_dir: git worktree 目录 + """ + worktree_dir.parent.mkdir(parents=True, exist_ok=True) + if worktree_dir.exists(): + # 已存在则先移除旧 worktree + run_git(["worktree", "remove", "--force", str(worktree_dir)]) + run_git(["worktree", "add", str(worktree_dir), sha]) + + +def get_changed_java_controller_files(base_sha: str, head_sha: str) -> List[str]: + """ + 获取两次提交之间变更的 Controller 相关 Java 文件路径。 + + :param base_sha: 基准 commit(旧版本) + :param head_sha: 目标 commit(新版本) + :return: 相对路径列表,如 ["src/main/java/.../UserController.java"] + """ + diff_output = run_git(["diff", "--name-only", base_sha, head_sha]) + if not diff_output: + return [] + + changed = [] + for line in diff_output.splitlines(): + line = line.strip() + if line.endswith(".java") and "Controller" in line: + changed.append(line.replace("\\", "/")) + return changed + + +def get_controller_files_diff(base_sha: str, head_sha: str, changed_files: List[str]) -> str: + """ + 获取变更 Controller 文件的 Git diff,供 LLM 审核接口参数变更时参考。 + + :param base_sha: 旧版本 commit SHA + :param head_sha: 新版本 commit SHA + :param changed_files: 变更文件相对路径列表 + :return: diff 文本 + """ + if not changed_files: + return "" + + try: + return run_git(["diff", base_sha, head_sha, "--"] + changed_files) + except RuntimeError as exc: + print(f"[警告] 获取 Git diff 失败: {exc}") + return "" + + +def prepare_worktrees(repo_root: Path) -> tuple: + """ + 准备新旧两个版本的代码工作目录,供 AST 解析器分别扫描。 + + :param repo_root: 仓库根目录 + :return: (新版本目录, 旧版本目录, 旧版本SHA);首次提交时旧版本目录为 None + """ + prev_sha = get_previous_commit_sha() + current_dir = repo_root + + if prev_sha is None: + return current_dir, None, None + + prev_dir = repo_root / ".gitea" / ".cache" / "prev-worktree" + checkout_commit(prev_sha, prev_dir) + return current_dir, prev_dir, prev_sha diff --git a/.gitea/checker/llm_reviewer.py b/.gitea/checker/llm_reviewer.py new file mode 100644 index 0000000..556e96b --- /dev/null +++ b/.gitea/checker/llm_reviewer.py @@ -0,0 +1,182 @@ +""" +豆包 LLM 接口参数变更审核模块。 +仅审核 Controller 层接口参数的增删改,不对 Java 源码做通用代码审查。 +""" + +import json +from typing import Any, Dict, List, Optional + +import requests + +from comparator import EndpointChangeReport + + +def is_llm_enabled(config: Dict[str, Any]) -> bool: + """ + 判断大模型总开关是否开启。 + + :param config: 完整配置字典 + :return: True=启用 LLM 审核 + """ + return config.get("llm", {}).get("enabled", True) + + +def call_doubao_api( + api_key: str, + prompt: str, + config: Dict[str, Any], +) -> Optional[str]: + """ + 调用火山引擎豆包 Chat Completions API。 + + :param api_key: API Key + :param prompt: 用户提示词 + :param config: 完整配置 + :return: LLM 回复文本;失败返回 None + """ + if not api_key or api_key == "YOUR_DOUBAO_API_KEY": + print("[警告] 未配置豆包 API Key,跳过 LLM 审核。") + return None + + llm_cfg = config.get("llm", {}) + model = llm_cfg.get("model") or llm_cfg.get("endpoint_id", "") + if not model: + print("[警告] 未配置 llm.model 或 llm.endpoint_id,跳过 LLM 审核。") + return None + + api_url = llm_cfg.get( + "api_url", "https://ark.cn-beijing.volces.com/api/v3/chat/completions" + ) + timeout = llm_cfg.get("timeout") + + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {api_key}", + } + payload = { + "model": model, + "messages": [ + { + "role": "system", + "content": ( + "你是 Java Spring Boot Controller 接口参数变更分析专家。" + "你的职责是识别并整理 Controller 层接口参数的增、删、改、重命名," + "确认 AST 解析结果是否准确,并指出对调用方的兼容性影响。" + ), + }, + {"role": "user", "content": prompt}, + ], + "temperature": 0.1, + } + + try: + kwargs = {"headers": headers, "json": payload} + if timeout is not None: + kwargs["timeout"] = timeout + + resp = requests.post(api_url, **kwargs) + resp.raise_for_status() + data = resp.json() + if "choices" in data and data["choices"]: + return data["choices"][0]["message"]["content"] + print("[错误] AI 返回格式异常") + return None + except requests.RequestException as exc: + print(f"[错误] 豆包 API 调用失败: {exc}") + return None + + +def build_parameter_change_prompt( + reports: List[EndpointChangeReport], + changed_files: List[str], + git_diff: str = "", +) -> str: + """ + 构造接口参数变更审核提示词(对齐第一版需求:识别 Controller 参数增删改)。 + + :param reports: AST 解析对比结果 + :param changed_files: 本次变更的 Controller 文件列表 + :param git_diff: 相关文件的 Git diff 内容 + :return: 完整 prompt + """ + ast_report = [] + for r in reports: + ast_report.append( + { + "uri": f"{r.http_method} {r.uri}", + "controller": r.controller_class, + "method": r.method_name, + "is_new_endpoint": r.is_new_endpoint, + "is_removed_endpoint": r.is_removed_endpoint, + "parameter_changes": [ + { + "type": c.change_type.value, + "name": c.param_name, + "java_type": c.param_type, + "old_name": c.old_name, + "old_type": c.old_type, + "required": c.required, + "detail": c.detail, + } + for c in r.parameter_changes + ], + } + ) + + diff_block = git_diff.strip() if git_diff.strip() else "(无 diff 内容)" + if len(diff_block) > 8000: + diff_block = diff_block[:8000] + "\n... [diff 过长,已截断]" + + return f"""请审核以下 Controller 层接口参数变更,整理并确认变更结果。 + +## 变更的 Controller 文件 +{json.dumps(changed_files, ensure_ascii=False)} + +## AST 自动解析的参数变更报告 +{json.dumps(ast_report, ensure_ascii=False, indent=2)} + +## Git Diff(Controller 相关) +```diff +{diff_block} +``` + +## 审核要求 +1. 逐条确认 AST 报告的参数变更是否准确(增/删/改/重命名) +2. 若 AST 有遗漏,补充遗漏的接口参数变更 +3. 若 AST 有误报,指出并修正 +4. 按以下格式整理每个接口的变更(与通知模板一致): + URI: GET /api/xxx + 参数变更: + - 删除: Boolean userType + - 新增: Boolean includeDisabled (是否必填:false) + - 重命名: String userName -> String accountName +5. 简要说明是否存在破坏性变更(影响前端/调用方) +6. 用中文回复,简洁清晰 +""" + + +def review_parameter_changes( + reports: List[EndpointChangeReport], + config: Dict[str, Any], + changed_files: List[str], + git_diff: str = "", +) -> Optional[str]: + """ + 使用 LLM 审核 Controller 接口参数变更(AST 结果的二次确认与整理)。 + + :param reports: AST 对比报告 + :param config: 完整配置 + :param changed_files: 变更的 Controller 文件 + :param git_diff: Git diff 文本 + :return: LLM 整理后的审核结论;未启用或无报告时返回 None + """ + if not is_llm_enabled(config): + print("[LLM] 大模型开关已关闭,跳过接口参数变更审核。") + return None + + if not reports: + return None + + llm_cfg = config.get("llm", {}) + prompt = build_parameter_change_prompt(reports, changed_files, git_diff) + return call_doubao_api(llm_cfg.get("api_key", ""), prompt, config) diff --git a/.gitea/checker/main.py b/.gitea/checker/main.py new file mode 100644 index 0000000..7b01f98 --- /dev/null +++ b/.gitea/checker/main.py @@ -0,0 +1,182 @@ +#!/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 .] [推送人] [推送时间] +""" + +import argparse +import sys +import tempfile +from pathlib import Path + +import yaml + +CHECKER_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(CHECKER_DIR)) + +from change_logger import persist_change_log +from comparator import compare_endpoints +from controller_parser import ( + endpoints_to_map, + filter_endpoints_by_files, + run_java_parser, +) +from git_utils import ( + get_changed_java_controller_files, + get_controller_files_diff, + get_current_commit, + get_previous_commit_sha, + prepare_worktrees, +) +from llm_reviewer import review_parameter_changes +from notifier import send_parameter_change_notification + + +def load_config(config_path: Path) -> dict: + """加载 YAML 配置文件。""" + if not config_path.exists(): + print(f"[错误] 配置文件不存在: {config_path}") + print("请在 .gitea/config.yaml 中填写配置并提交到仓库。") + sys.exit(1) + + with open(config_path, "r", encoding="utf-8") as f: + return yaml.safe_load(f) or {} + + +def parse_endpoints_for_version( + repo_root: Path, + source_subdir: str, + jar_path: Path, + tmp_dir: Path, + label: str, +) -> dict: + """对指定版本源码运行 Java AST 解析,提取 Controller 接口参数。""" + source_dir = repo_root / source_subdir + output_json = tmp_dir / f"endpoints_{label}.json" + + print(f"[AST] 扫描 {label} 版本: {source_dir}") + endpoints = run_java_parser(source_dir, jar_path, output_json) + print(f"[AST] {label} 版本共 {len(endpoints)} 个 Controller 接口") + return endpoints_to_map(endpoints) + + +def main() -> int: + """主流程入口。""" + parser = argparse.ArgumentParser( + description="AI-Check: Controller 接口参数变更检测" + ) + 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 传入)") + args = parser.parse_args() + + repo_root = Path(args.repo_root).resolve() + config = load_config(repo_root / args.config) + + 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("=" * 40) + print(f"推送人: {push_user}") + print(f"推送时间: {push_time}") + print(f"LLM 审核: {config.get('llm', {}).get('enabled', True)}") + print(f"记录日志: {config.get('log', {}).get('enabled', False)}") + print("=" * 40) + + prev_sha = get_previous_commit_sha() + if prev_sha is None: + print("[Git] 首次提交,无可对比版本,跳过。") + return 0 + + changed_files = get_changed_java_controller_files(prev_sha, commit_info.sha) + if not changed_files: + print("[Git] 本次提交未变更 Controller 文件,跳过。") + return 0 + + print(f"[Git] 变更 Controller 文件 {len(changed_files)} 个:") + for f in changed_files: + 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 = [] + 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" + ) + + 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: + 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", ""), + reports=reports, + push_user=push_user, + push_time=push_time, + llm_review=llm_review, + mentioned_users=mentioned_list, + ) + + print("\n完成") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.gitea/checker/notifier.py b/.gitea/checker/notifier.py new file mode 100644 index 0000000..3c17495 --- /dev/null +++ b/.gitea/checker/notifier.py @@ -0,0 +1,218 @@ +""" +企业微信机器人通知模块。 +按第一版模板发送 Controller 接口参数变更通知,支持超长内容分段发送。 +""" + +import json +from typing import List, Optional + +import requests + +from comparator import EndpointChangeReport +from git_utils import CommitInfo + +# 企微 text 消息字节上限约 2048,留余量按字符分段 +MAX_TEXT_LENGTH = 2000 + + +def truncate_text(text: str, max_length: int = MAX_TEXT_LENGTH) -> str: + """ + 截断文本,避免超出企微单条消息限制。 + + :param text: 原始文本 + :param max_length: 最大字符数 + :return: 截断后文本 + """ + if len(text) <= max_length: + return text + return text[:max_length] + "\n... [消息过长,已截断]" + + +def build_single_endpoint_message( + report: EndpointChangeReport, + push_user: str, + push_time: str, +) -> str: + """ + 按第一版模板构建单个接口的通知正文。 + + 模板示例: + [API变更通知] + URI: GET /api/users/{id} + 修改人:张三 + 修改时间:2026-06-03 10:00:00 + 参数变更: + - 删除: Boolean userType + - 新增: Boolean includeDisabled (是否必填:false) + + :param report: 单个接口变更报告 + :param push_user: 代码推送人 + :param push_time: 推送时间 + :return: 通知文本 + """ + lines = [ + "[API变更通知]", + f"URI: {report.http_method} {report.uri}", + f"修改人:{push_user}", + f"修改时间:{push_time}", + ] + + if report.is_new_endpoint: + lines.append("接口状态:新增接口") + if report.parameter_changes: + lines.append("参数变更:") + for change in report.parameter_changes: + lines.append(change.to_display_line()) + elif report.is_removed_endpoint: + lines.append("接口状态:已删除接口") + lines.append(" - 整个接口已被移除") + else: + lines.append("参数变更:") + for change in report.parameter_changes: + lines.append(change.to_display_line()) + + return "\n".join(lines) + + +def build_all_notifications( + reports: List[EndpointChangeReport], + push_user: str, + push_time: str, + llm_review: Optional[str] = None, +) -> List[str]: + """ + 将所有接口变更组装为通知消息列表,超长时自动分段。 + + :param reports: 变更报告列表 + :param push_user: 推送人 + :param push_time: 推送时间 + :param llm_review: LLM 参数变更审核结论(可选,附在最后一条或单独一条) + :return: 待发送的消息段落列表 + """ + if not reports: + return [] + + messages: List[str] = [] + current = "" + + for report in reports: + block = build_single_endpoint_message(report, push_user, push_time) + separator = "\n\n---\n\n" + + if not current: + current = block + elif len(current) + len(separator) + len(block) <= MAX_TEXT_LENGTH: + current += separator + block + else: + messages.append(current) + current = block + + if current: + messages.append(current) + + # LLM 审核结论单独或追加发送 + if llm_review: + review_msg = f"[AI参数变更审核]\n修改人:{push_user}\n修改时间:{push_time}\n\n{llm_review}" + if messages and len(messages[-1]) + len(review_msg) + 4 <= MAX_TEXT_LENGTH: + messages[-1] += "\n\n" + review_msg + elif len(review_msg) <= MAX_TEXT_LENGTH: + messages.append(review_msg) + else: + messages.extend(_split_long_text(review_msg, MAX_TEXT_LENGTH)) + + return messages + + +def _split_long_text(text: str, max_len: int) -> List[str]: + """按行拆分超长文本。""" + lines = text.split("\n") + chunks: List[str] = [] + current = "" + for line in lines: + candidate = current + line + "\n" + if len(candidate) > max_len and current: + chunks.append(current.rstrip()) + current = line + "\n" + else: + current = candidate + if current.strip(): + chunks.append(current.rstrip()) + return chunks + + +def _post_wecom_text(webhook_url: str, content: str) -> bool: + """ + 发送单条 text 消息到企业微信。 + + :param webhook_url: Webhook URL + :param content: 消息正文 + :return: 是否成功 + """ + if not webhook_url or "YOUR_WECOM_KEY" in webhook_url: + print("[警告] 未配置有效的企业微信 Webhook URL。") + print("--- 通知预览 ---") + print(content[:800]) + return False + + payload = { + "msgtype": "text", + "text": {"content": truncate_text(content)}, + } + + try: + resp = requests.post( + webhook_url, + headers={"Content-Type": "application/json"}, + data=json.dumps(payload, ensure_ascii=False).encode("utf-8"), + timeout=10, + ) + if resp.status_code == 200 and resp.json().get("errcode", 0) == 0: + return True + print(f"[错误] 企微返回异常: {resp.status_code} {resp.text}") + return False + except requests.RequestException as exc: + print(f"[错误] 发送企微消息失败: {exc}") + return False + + +def send_parameter_change_notification( + webhook_url: str, + reports: List[EndpointChangeReport], + push_user: str, + push_time: str, + llm_review: Optional[str] = None, + mentioned_users: Optional[List[str]] = None, +) -> int: + """ + 发送 Controller 接口参数变更通知(支持分段)。 + + :param webhook_url: 企微 Webhook + :param reports: AST 参数变更报告 + :param push_user: 推送人 + :param push_time: 推送时间 + :param llm_review: LLM 参数变更审核(可选) + :param mentioned_users: @ 成员 userid 列表 + :return: 成功发送条数 + """ + if not reports and not llm_review: + print("无接口参数变更,不发送到企业微信") + return 0 + + segments = build_all_notifications(reports, push_user, push_time, llm_review) + if not segments: + return 0 + + sent = 0 + for i, segment in enumerate(segments): + payload_text = segment + if mentioned_users and i == 0: + # text 类型 @ 成员需放在 mentioned_list + pass # 在 _post 里处理较复杂,首条单独带 mentioned + + if _post_wecom_text(webhook_url, payload_text): + sent += 1 + print(f"第 {sent} 条通知已发送到企业微信") + + if sent > 0: + print(f"总共发送 {sent} 条通知到企业微信") + return sent diff --git a/.gitea/checker/requirements.txt b/.gitea/checker/requirements.txt new file mode 100644 index 0000000..05a75ae --- /dev/null +++ b/.gitea/checker/requirements.txt @@ -0,0 +1,3 @@ +# Python 依赖(CI 主流程) +PyYAML>=6.0.1 +requests>=2.31.0 diff --git a/.gitea/config.yaml b/.gitea/config.yaml new file mode 100644 index 0000000..8150964 --- /dev/null +++ b/.gitea/config.yaml @@ -0,0 +1,33 @@ +# AI-Check 配置文件 +# 业务源码在 ftb 模块下 + +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: + enabled: true + api_key: "2f3f7ee9-a6f7-46b7-a709-a36743a83a04" + model: "doubao-seed-1-8-251228" + endpoint_id: "" + api_url: "https://ark.cn-beijing.volces.com/api/v3/chat/completions" + timeout: null + +log: + enabled: false + storage: "file" + file_dir: ".gitea/logs/api-changes" + mysql: + host: "YOUR_MYSQL_HOST" + port: 3306 + user: "YOUR_MYSQL_USER" + password: "YOUR_MYSQL_PASSWORD" + 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 new file mode 100644 index 0000000..c370f04 --- /dev/null +++ b/.gitea/java-parser/pom.xml @@ -0,0 +1,62 @@ + + + 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 new file mode 100644 index 0000000..75c50d6 --- /dev/null +++ b/.gitea/java-parser/src/main/java/com/aicheck/ApiEndpoint.java @@ -0,0 +1,86 @@ +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 new file mode 100644 index 0000000..aaee93c --- /dev/null +++ b/.gitea/java-parser/src/main/java/com/aicheck/ApiParameter.java @@ -0,0 +1,75 @@ +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 new file mode 100644 index 0000000..f82ddba --- /dev/null +++ b/.gitea/java-parser/src/main/java/com/aicheck/ControllerAstParser.java @@ -0,0 +1,421 @@ +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 new file mode 100644 index 0000000..99db7b9 --- /dev/null +++ b/.gitea/java-parser/src/main/java/com/aicheck/ControllerParserMain.java @@ -0,0 +1,48 @@ +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 new file mode 100644 index 0000000..5107dc9 --- /dev/null +++ b/.gitea/java-parser/target/maven-archiver/pom.properties @@ -0,0 +1,5 @@ +#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 new file mode 100644 index 0000000..85acf3b --- /dev/null +++ b/.gitea/java-parser/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst @@ -0,0 +1,4 @@ +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 new file mode 100644 index 0000000..ab8bb11 --- /dev/null +++ b/.gitea/java-parser/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst @@ -0,0 +1,4 @@ +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/scripts/init_mysql.sql b/.gitea/scripts/init_mysql.sql new file mode 100644 index 0000000..50ac2bf --- /dev/null +++ b/.gitea/scripts/init_mysql.sql @@ -0,0 +1,17 @@ +-- MySQL 变更日志表(storage=mysql 时使用) +-- 执行前请先创建数据库并替换 YOUR_MYSQL_DATABASE + +CREATE TABLE IF NOT EXISTS `api_change_logs` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', + `commit_sha` VARCHAR(64) NOT NULL COMMENT 'Git 提交 SHA', + `author` VARCHAR(128) NOT NULL COMMENT '提交人', + `commit_time` VARCHAR(64) NOT NULL COMMENT '提交时间', + `commit_message` TEXT NULL COMMENT '提交说明', + `change_count` INT NOT NULL DEFAULT 0 COMMENT '变更接口数量', + `reports_json` LONGTEXT NOT NULL COMMENT '变更详情 JSON', + `llm_review` TEXT NULL COMMENT 'LLM 评审结论', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '记录写入时间', + PRIMARY KEY (`id`), + INDEX `idx_commit_sha` (`commit_sha`), + INDEX `idx_created_at` (`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='API 接口参数变更日志'; diff --git a/.gitea/workflows/api-change-check.yml b/.gitea/workflows/api-change-check.yml new file mode 100644 index 0000000..50581e3 --- /dev/null +++ b/.gitea/workflows/api-change-check.yml @@ -0,0 +1,62 @@ +# Gitea Actions:push 后检测 Controller 接口参数变更 +# 工具代码与配置均位于 .gitea/ 目录,与业务代码解耦 + +name: API Parameter Change Check + +on: + push: + branches: + - '**' + +jobs: + api-param-check: + runs-on: ubuntu-latest + + steps: + - name: 检出代码 + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: 检查配置文件 + run: | + if [ ! -f .gitea/config.yaml ]; then + echo "错误: 缺少 .gitea/config.yaml" + exit 1 + fi + echo "使用 .gitea/config.yaml" + + - name: 安装 Java 11 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '11' + cache: maven + + - name: 安装 Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: 构建 AST 解析器 + working-directory: .gitea/java-parser + run: mvn -q package -DskipTests + + - name: 安装 Python 依赖 + run: pip install -r .gitea/checker/requirements.txt + + - name: 检测 Controller 接口参数变更 + run: | + python .gitea/checker/main.py \ + --config .gitea/config.yaml \ + --repo-root . \ + "${{ gitea.actor }}" \ + "$(git log -1 --format=%ci)" + + - name: 上传变更日志 + if: always() + uses: actions/upload-artifact@v4 + with: + name: api-change-logs + path: .gitea/logs/api-changes/ + if-no-files-found: ignore diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..30cf57e --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/AI-Check-Test.iml b/.idea/AI-Check-Test.iml new file mode 100644 index 0000000..d6ebd48 --- /dev/null +++ b/.idea/AI-Check-Test.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..e0844bc --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..a42a0de --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file