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