test
Some checks failed
API Parameter Change Check / api-param-check (push) Failing after 3s

This commit is contained in:
2026-06-03 15:02:21 +08:00
parent 2eacae577c
commit 556f5b8ab6
25 changed files with 2119 additions and 0 deletions

3
.gitea/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
# 本地/CI 运行时产生的缓存与日志(可不提交)
.cache/
logs/

View File

@@ -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,
)

View File

@@ -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]

View File

@@ -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]

140
.gitea/checker/git_utils.py Normal file
View File

@@ -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]:
"""
获取上一次提交的 SHAHEAD~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

View File

@@ -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 DiffController 相关)
```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)

182
.gitea/checker/main.py Normal file
View File

@@ -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())

218
.gitea/checker/notifier.py Normal file
View File

@@ -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

View File

@@ -0,0 +1,3 @@
# Python 依赖CI 主流程)
PyYAML>=6.0.1
requests>=2.31.0

33
.gitea/config.yaml Normal file
View File

@@ -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: ""

View File

@@ -0,0 +1,62 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.aicheck</groupId>
<artifactId>controller-parser</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<name>Controller Parameter AST Parser</name>
<description>基于 JavaParser 解析 Spring Controller 接口参数</description>
<properties>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<javaparser.version>3.25.10</javaparser.version>
<jackson.version>2.17.2</jackson.version>
</properties>
<dependencies>
<!-- Java AST 解析库 -->
<dependency>
<groupId>com.github.javaparser</groupId>
<artifactId>javaparser-core</artifactId>
<version>${javaparser.version}</version>
</dependency>
<!-- JSON 输出,供 Python 主程序读取 -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>${jackson.version}</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.6.0</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>com.aicheck.ControllerParserMain</mainClass>
</transformer>
</transformers>
<createDependencyReducedPom>false</createDependencyReducedPom>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

View File

@@ -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<ApiParameter> 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<ApiParameter> getParameters() {
return parameters;
}
public void setParameters(List<ApiParameter> parameters) {
this.parameters = parameters;
}
/**
* 生成唯一标识,用于跨版本比对接口是否为同一个。
* 格式HTTP_METHOD + 空格 + URI
*/
public String getEndpointKey() {
return httpMethod + " " + uri;
}
}

View File

@@ -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;
}
}

View File

@@ -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<String> MAPPING_ANNOTATIONS = Set.of(
"GetMapping", "PostMapping", "PutMapping", "DeleteMapping", "PatchMapping", "RequestMapping"
);
/** 标识 Controller 的类级别注解 */
private static final Set<String> CONTROLLER_ANNOTATIONS = Set.of("RestController", "Controller");
/**
* 解析目录下所有 Java 文件中的 Controller 接口。
*
* @param rootDir 项目根目录或源码目录
* @return 解析出的所有 API 端点列表
*/
public List<ApiEndpoint> parseDirectory(Path rootDir) throws IOException {
List<ApiEndpoint> endpoints = new ArrayList<>();
if (!Files.exists(rootDir)) {
return endpoints;
}
try (Stream<Path> paths = Files.walk(rootDir)) {
List<Path> 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<ApiEndpoint> parseFile(Path javaFile, Path rootDir) throws IOException {
List<ApiEndpoint> 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<ApiEndpoint> 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<ApiEndpoint> 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<ApiParameter> extractParameters(MethodDeclaration method, Path rootDir) {
List<ApiParameter> 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<ApiParameter> expandDtoFields(String typeName, Path rootDir, String source) {
List<ApiParameter> fields = new ArrayList<>();
Optional<Path> 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<Path> findJavaFileBySimpleName(String typeName, Path rootDir) {
String simpleName = typeName.contains(".") ? typeName.substring(typeName.lastIndexOf('.') + 1) : typeName;
simpleName = simpleName.replace(">", "").replace("<", "").trim();
try (Stream<Path> 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<AnnotationExpr> 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<AnnotationExpr> 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<AnnotationExpr> 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<String> 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("/+", "/");
}
}

View File

@@ -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<ApiEndpoint> 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);
}
}
}

View File

@@ -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

View File

@@ -0,0 +1,4 @@
com\aicheck\ControllerParserMain.class
com\aicheck\ControllerAstParser.class
com\aicheck\ApiParameter.class
com\aicheck\ApiEndpoint.class

View File

@@ -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

View File

@@ -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 接口参数变更日志';

View File

@@ -0,0 +1,62 @@
# Gitea Actionspush 后检测 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

10
.idea/.gitignore generated vendored Normal file
View File

@@ -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

9
.idea/AI-Check-Test.iml generated Normal file
View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

6
.idea/misc.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager" version="2" languageLevel="JDK_11" default="true" project-jdk-name="11" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/AI-Check-Test.iml" filepath="$PROJECT_DIR$/.idea/AI-Check-Test.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>