This commit is contained in:
3
.gitea/.gitignore
vendored
Normal file
3
.gitea/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# 本地/CI 运行时产生的缓存与日志(可不提交)
|
||||
.cache/
|
||||
logs/
|
||||
165
.gitea/checker/change_logger.py
Normal file
165
.gitea/checker/change_logger.py
Normal 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,
|
||||
)
|
||||
256
.gitea/checker/comparator.py
Normal file
256
.gitea/checker/comparator.py
Normal 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]
|
||||
114
.gitea/checker/controller_parser.py
Normal file
114
.gitea/checker/controller_parser.py
Normal 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
140
.gitea/checker/git_utils.py
Normal 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]:
|
||||
"""
|
||||
获取上一次提交的 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
|
||||
182
.gitea/checker/llm_reviewer.py
Normal file
182
.gitea/checker/llm_reviewer.py
Normal 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 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)
|
||||
182
.gitea/checker/main.py
Normal file
182
.gitea/checker/main.py
Normal 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
218
.gitea/checker/notifier.py
Normal 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
|
||||
3
.gitea/checker/requirements.txt
Normal file
3
.gitea/checker/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
# Python 依赖(CI 主流程)
|
||||
PyYAML>=6.0.1
|
||||
requests>=2.31.0
|
||||
33
.gitea/config.yaml
Normal file
33
.gitea/config.yaml
Normal 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: ""
|
||||
62
.gitea/java-parser/pom.xml
Normal file
62
.gitea/java-parser/pom.xml
Normal 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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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("/+", "/");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
5
.gitea/java-parser/target/maven-archiver/pom.properties
Normal file
5
.gitea/java-parser/target/maven-archiver/pom.properties
Normal 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
|
||||
@@ -0,0 +1,4 @@
|
||||
com\aicheck\ControllerParserMain.class
|
||||
com\aicheck\ControllerAstParser.class
|
||||
com\aicheck\ApiParameter.class
|
||||
com\aicheck\ApiEndpoint.class
|
||||
@@ -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
|
||||
17
.gitea/scripts/init_mysql.sql
Normal file
17
.gitea/scripts/init_mysql.sql
Normal 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 接口参数变更日志';
|
||||
62
.gitea/workflows/api-change-check.yml
Normal file
62
.gitea/workflows/api-change-check.yml
Normal file
@@ -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
|
||||
10
.idea/.gitignore
generated
vendored
Normal file
10
.idea/.gitignore
generated
vendored
Normal 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
9
.idea/AI-Check-Test.iml
generated
Normal 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
6
.idea/misc.xml
generated
Normal 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
8
.idea/modules.xml
generated
Normal 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
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
Reference in New Issue
Block a user