This commit is contained in:
@@ -7,7 +7,7 @@ from dataclasses import dataclass, field
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Dict, List, Optional, Set, Tuple
|
from typing import Dict, List, Optional, Set, Tuple
|
||||||
|
|
||||||
from controller_parser import ApiEndpoint, ApiParameter
|
from models import ApiEndpoint, ApiParameter
|
||||||
|
|
||||||
|
|
||||||
class ChangeType(str, Enum):
|
class ChangeType(str, Enum):
|
||||||
|
|||||||
@@ -1,114 +1,43 @@
|
|||||||
"""
|
"""
|
||||||
Controller 端点解析模块。
|
Controller 端点解析模块(纯 Python,无需 Java)。
|
||||||
调用 Java AST 解析器 JAR,将 Java 源码转为结构化的 API 端点列表。
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
|
||||||
import subprocess
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, List, Optional
|
from typing import Dict, List
|
||||||
|
|
||||||
|
from models import ApiEndpoint, ApiParameter
|
||||||
@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]:
|
def endpoints_to_map(endpoints: List[ApiEndpoint]) -> Dict[str, ApiEndpoint]:
|
||||||
"""
|
"""端点列表转字典,key 为 endpoint_key。"""
|
||||||
将端点列表转为字典,key 为 endpoint_key。
|
|
||||||
|
|
||||||
:param endpoints: 端点列表
|
|
||||||
:return: { "GET /api/users/{id}": ApiEndpoint, ... }
|
|
||||||
"""
|
|
||||||
return {ep.endpoint_key: ep for ep in endpoints}
|
return {ep.endpoint_key: ep for ep in endpoints}
|
||||||
|
|
||||||
|
|
||||||
def filter_endpoints_by_files(
|
def filter_endpoints_by_files(
|
||||||
endpoints: List[ApiEndpoint], changed_files: List[str]
|
endpoints: List[ApiEndpoint], changed_files: List[str]
|
||||||
) -> List[ApiEndpoint]:
|
) -> List[ApiEndpoint]:
|
||||||
"""
|
"""仅保留变更文件中的端点。"""
|
||||||
仅保留源文件在变更列表中的端点(缩小对比范围)。
|
|
||||||
|
|
||||||
:param endpoints: 全部端点
|
|
||||||
:param changed_files: 变更文件相对路径列表
|
|
||||||
:return: 过滤后的端点
|
|
||||||
"""
|
|
||||||
if not changed_files:
|
if not changed_files:
|
||||||
return endpoints
|
return endpoints
|
||||||
changed_set = set(changed_files)
|
changed_set = {f.replace("\\", "/") for f in changed_files}
|
||||||
return [ep for ep in endpoints if ep.source_file in changed_set]
|
return [ep for ep in endpoints if ep.source_file in changed_set]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_endpoints_from_files(
|
||||||
|
repo_root: Path,
|
||||||
|
source_subdir: str,
|
||||||
|
file_paths: List[str],
|
||||||
|
file_contents: Dict[str, str],
|
||||||
|
) -> List[ApiEndpoint]:
|
||||||
|
"""
|
||||||
|
解析指定 Controller 文件,提取接口参数(仅解析传入文件,不全量扫描)。
|
||||||
|
|
||||||
|
:param repo_root: 仓库根
|
||||||
|
:param source_subdir: 源码目录(相对仓库根)
|
||||||
|
:param file_paths: 文件路径列表
|
||||||
|
:param file_contents: 路径 -> 源码内容
|
||||||
|
:return: ApiEndpoint 列表
|
||||||
|
"""
|
||||||
|
from controller_ast_parser import parse_controller_files
|
||||||
|
|
||||||
|
return parse_controller_files(repo_root, source_subdir, file_paths, file_contents)
|
||||||
|
|||||||
@@ -122,6 +122,20 @@ def get_controller_files_diff(base_sha: str, head_sha: str, changed_files: List[
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def get_file_content_at_commit(commit_sha: str, file_path: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
读取指定 commit 下某个文件的内容(无需 git worktree,更快)。
|
||||||
|
|
||||||
|
:param commit_sha: commit SHA
|
||||||
|
:param file_path: 相对仓库根目录的文件路径
|
||||||
|
:return: 文件内容;该 commit 中不存在则返回 None
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return run_git(["show", f"{commit_sha}:{file_path}"])
|
||||||
|
except RuntimeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def prepare_worktrees(repo_root: Path) -> tuple:
|
def prepare_worktrees(repo_root: Path) -> tuple:
|
||||||
"""
|
"""
|
||||||
准备新旧两个版本的代码工作目录,供 AST 解析器分别扫描。
|
准备新旧两个版本的代码工作目录,供 AST 解析器分别扫描。
|
||||||
|
|||||||
@@ -1,23 +1,12 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
AI-Check 主入口 — Controller 层接口参数变更检测
|
AI-Check 主入口 — Controller 层接口参数变更检测(纯 Python,无 Java 依赖)
|
||||||
|
|
||||||
流程(对齐第一版需求):
|
|
||||||
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 argparse
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
@@ -29,14 +18,14 @@ from comparator import compare_endpoints
|
|||||||
from controller_parser import (
|
from controller_parser import (
|
||||||
endpoints_to_map,
|
endpoints_to_map,
|
||||||
filter_endpoints_by_files,
|
filter_endpoints_by_files,
|
||||||
run_java_parser,
|
parse_endpoints_from_files,
|
||||||
)
|
)
|
||||||
from git_utils import (
|
from git_utils import (
|
||||||
get_changed_java_controller_files,
|
get_changed_java_controller_files,
|
||||||
get_controller_files_diff,
|
get_controller_files_diff,
|
||||||
get_current_commit,
|
get_current_commit,
|
||||||
|
get_file_content_at_commit,
|
||||||
get_previous_commit_sha,
|
get_previous_commit_sha,
|
||||||
prepare_worktrees,
|
|
||||||
)
|
)
|
||||||
from llm_reviewer import review_parameter_changes
|
from llm_reviewer import review_parameter_changes
|
||||||
from notifier import send_parameter_change_notification
|
from notifier import send_parameter_change_notification
|
||||||
@@ -53,20 +42,53 @@ def load_config(config_path: Path) -> dict:
|
|||||||
return yaml.safe_load(f) or {}
|
return yaml.safe_load(f) or {}
|
||||||
|
|
||||||
|
|
||||||
def parse_endpoints_for_version(
|
def _read_file_safe(path: Path) -> str:
|
||||||
|
"""读取文件内容。"""
|
||||||
|
try:
|
||||||
|
return path.read_text(encoding="utf-8", errors="ignore")
|
||||||
|
except OSError as exc:
|
||||||
|
print(f"[警告] 无法读取 {path}: {exc}")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _load_version_contents(
|
||||||
|
repo_root: Path,
|
||||||
|
file_paths: list,
|
||||||
|
commit_sha: Optional[str] = None,
|
||||||
|
) -> dict:
|
||||||
|
"""加载文件内容;commit_sha 为空则读工作区,否则 git show。"""
|
||||||
|
contents = {}
|
||||||
|
for fp in file_paths:
|
||||||
|
norm = fp.replace("\\", "/")
|
||||||
|
if commit_sha:
|
||||||
|
text = get_file_content_at_commit(commit_sha, norm)
|
||||||
|
if text is not None:
|
||||||
|
contents[norm] = text
|
||||||
|
else:
|
||||||
|
text = _read_file_safe(repo_root / norm)
|
||||||
|
if text:
|
||||||
|
contents[norm] = text
|
||||||
|
return contents
|
||||||
|
|
||||||
|
|
||||||
|
def parse_changed_endpoints(
|
||||||
repo_root: Path,
|
repo_root: Path,
|
||||||
source_subdir: str,
|
source_subdir: str,
|
||||||
jar_path: Path,
|
changed_files: list,
|
||||||
tmp_dir: Path,
|
old_sha: str,
|
||||||
label: str,
|
label: str,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""对指定版本源码运行 Java AST 解析,提取 Controller 接口参数。"""
|
"""解析变更 Controller 文件在新/旧版本的端点。"""
|
||||||
source_dir = repo_root / source_subdir
|
if label == "new":
|
||||||
output_json = tmp_dir / f"endpoints_{label}.json"
|
contents = _load_version_contents(repo_root, changed_files)
|
||||||
|
else:
|
||||||
|
contents = _load_version_contents(repo_root, changed_files, commit_sha=old_sha)
|
||||||
|
|
||||||
print(f"[AST] 扫描 {label} 版本: {source_dir}")
|
print(f"[AST] 解析 {label} 版本 {len(contents)} 个 Controller 文件")
|
||||||
endpoints = run_java_parser(source_dir, jar_path, output_json)
|
endpoints = parse_endpoints_from_files(
|
||||||
print(f"[AST] {label} 版本共 {len(endpoints)} 个 Controller 接口")
|
repo_root, source_subdir, changed_files, contents
|
||||||
|
)
|
||||||
|
print(f"[AST] {label} 版本共 {len(endpoints)} 个接口")
|
||||||
return endpoints_to_map(endpoints)
|
return endpoints_to_map(endpoints)
|
||||||
|
|
||||||
|
|
||||||
@@ -75,25 +97,27 @@ def main() -> int:
|
|||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="AI-Check: Controller 接口参数变更检测"
|
description="AI-Check: Controller 接口参数变更检测"
|
||||||
)
|
)
|
||||||
parser.add_argument("--config", default=".gitea/config.yaml", help="配置文件路径(相对仓库根目录)")
|
parser.add_argument(
|
||||||
|
"--config", default=".gitea/config.yaml", help="配置文件路径"
|
||||||
|
)
|
||||||
parser.add_argument("--repo-root", default=".", help="Git 仓库根目录")
|
parser.add_argument("--repo-root", default=".", help="Git 仓库根目录")
|
||||||
parser.add_argument("push_user", nargs="?", default=None, help="推送人(CI 传入)")
|
parser.add_argument("push_user", nargs="?", default=None, help="推送人")
|
||||||
parser.add_argument("push_time", nargs="?", default=None, help="推送时间(CI 传入)")
|
parser.add_argument("push_time", nargs="?", default=None, help="推送时间")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
repo_root = Path(args.repo_root).resolve()
|
repo_root = Path(args.repo_root).resolve()
|
||||||
config = load_config(repo_root / args.config)
|
config_path = Path(args.config)
|
||||||
|
if not config_path.is_absolute():
|
||||||
|
config_path = repo_root / config_path
|
||||||
|
config = load_config(config_path)
|
||||||
|
|
||||||
source_subdir = config.get("source_dir", "src/main/java")
|
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()
|
commit_info = get_current_commit()
|
||||||
push_user = args.push_user or commit_info.author
|
push_user = args.push_user or commit_info.author
|
||||||
push_time = args.push_time or commit_info.commit_time
|
push_time = args.push_time or commit_info.commit_time
|
||||||
|
|
||||||
print("Controller 接口参数变更检测")
|
print("Controller 接口参数变更检测(纯 Python)")
|
||||||
print("=" * 40)
|
print("=" * 40)
|
||||||
print(f"推送人: {push_user}")
|
print(f"推送人: {push_user}")
|
||||||
print(f"推送时间: {push_time}")
|
print(f"推送时间: {push_time}")
|
||||||
@@ -116,57 +140,44 @@ def main() -> int:
|
|||||||
print(f" - {f}")
|
print(f" - {f}")
|
||||||
|
|
||||||
git_diff = get_controller_files_diff(prev_sha, commit_info.sha, changed_files)
|
git_diff = get_controller_files_diff(prev_sha, commit_info.sha, changed_files)
|
||||||
current_dir, prev_dir, _ = prepare_worktrees(repo_root)
|
|
||||||
|
|
||||||
reports = []
|
new_map = parse_changed_endpoints(
|
||||||
|
repo_root, source_subdir, changed_files, prev_sha, "new"
|
||||||
|
)
|
||||||
|
old_map = parse_changed_endpoints(
|
||||||
|
repo_root, source_subdir, changed_files, prev_sha, "old"
|
||||||
|
)
|
||||||
|
|
||||||
|
new_filtered = endpoints_to_map(
|
||||||
|
filter_endpoints_by_files(list(new_map.values()), changed_files)
|
||||||
|
)
|
||||||
|
old_filtered = endpoints_to_map(
|
||||||
|
filter_endpoints_by_files(list(old_map.values()), changed_files)
|
||||||
|
)
|
||||||
|
|
||||||
|
reports = compare_endpoints(old_filtered, new_filtered)
|
||||||
|
print(f"[对比] 检测到 {len(reports)} 个接口存在参数变更")
|
||||||
|
|
||||||
llm_review = None
|
llm_review = None
|
||||||
|
if reports:
|
||||||
with tempfile.TemporaryDirectory(prefix="ai-check-") as tmp:
|
llm_review = review_parameter_changes(
|
||||||
tmp_dir = Path(tmp)
|
reports, config, changed_files, git_diff
|
||||||
|
|
||||||
# 1. AST 解析 + 参数对比
|
|
||||||
new_map = parse_endpoints_for_version(
|
|
||||||
current_dir, source_subdir, jar_path, tmp_dir, "new"
|
|
||||||
)
|
|
||||||
old_map = parse_endpoints_for_version(
|
|
||||||
prev_dir, source_subdir, jar_path, tmp_dir, "old"
|
|
||||||
)
|
)
|
||||||
|
if llm_review:
|
||||||
|
print("[LLM] 参数变更审核完成")
|
||||||
|
|
||||||
new_filtered = endpoints_to_map(
|
|
||||||
filter_endpoints_by_files(list(new_map.values()), changed_files)
|
|
||||||
)
|
|
||||||
old_filtered = endpoints_to_map(
|
|
||||||
filter_endpoints_by_files(list(old_map.values()), changed_files)
|
|
||||||
)
|
|
||||||
|
|
||||||
reports = compare_endpoints(old_filtered, new_filtered)
|
|
||||||
print(f"[对比] 检测到 {len(reports)} 个接口存在参数变更")
|
|
||||||
|
|
||||||
# 2. LLM 审核接口参数变更(非代码审查)
|
|
||||||
if reports:
|
|
||||||
llm_review = review_parameter_changes(
|
|
||||||
reports, config, changed_files, git_diff
|
|
||||||
)
|
|
||||||
if llm_review:
|
|
||||||
print(f"[LLM] 参数变更审核完成")
|
|
||||||
|
|
||||||
# 3. 写日志(开关控制)
|
|
||||||
persist_change_log(reports, commit_info, config, llm_review)
|
persist_change_log(reports, commit_info, config, llm_review)
|
||||||
|
|
||||||
# 4. 企微通知
|
|
||||||
notify_cfg = config.get("notify", {})
|
notify_cfg = config.get("notify", {})
|
||||||
only_on_change = notify_cfg.get("only_on_change", True)
|
if notify_cfg.get("only_on_change", True) and not reports:
|
||||||
|
|
||||||
if only_on_change and not reports:
|
|
||||||
print("[通知] 无接口参数变更,跳过企微通知。")
|
print("[通知] 无接口参数变更,跳过企微通知。")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
mentioned = notify_cfg.get("mentioned_users", "")
|
mentioned = notify_cfg.get("mentioned_users", "")
|
||||||
mentioned_list = [u.strip() for u in mentioned.split(",") if u.strip()] or None
|
mentioned_list = [u.strip() for u in mentioned.split(",") if u.strip()] or None
|
||||||
|
|
||||||
wecom_cfg = config.get("wecom", {})
|
|
||||||
send_parameter_change_notification(
|
send_parameter_change_notification(
|
||||||
webhook_url=wecom_cfg.get("webhook_url", ""),
|
webhook_url=config.get("wecom", {}).get("webhook_url", ""),
|
||||||
reports=reports,
|
reports=reports,
|
||||||
push_user=push_user,
|
push_user=push_user,
|
||||||
push_time=push_time,
|
push_time=push_time,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
# Python 依赖(CI 主流程)
|
# Python 依赖(纯 Python AST 解析,无需 Java)
|
||||||
PyYAML>=6.0.1
|
PyYAML>=6.0.1
|
||||||
requests>=2.31.0
|
requests>=2.31.0
|
||||||
|
javalang>=0.13.0
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
# AI-Check 配置文件
|
# ============================================================
|
||||||
# 业务源码在 ftb 模块下
|
# AI-Check 配置文件(位于 .gitea/ 目录,与业务代码解耦)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# 业务 Java 源码目录(相对仓库根目录)
|
||||||
|
# 单模块: src/main/java
|
||||||
|
# 多模块: ftb/src/main/java
|
||||||
source_dir: "ftb/src/main/java"
|
source_dir: "ftb/src/main/java"
|
||||||
|
|
||||||
java_parser_jar: ".gitea/java-parser/target/controller-parser-1.0.0.jar"
|
# ---------- 企业微信机器人 ----------
|
||||||
|
|
||||||
wecom:
|
wecom:
|
||||||
webhook_url: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=61f08cc9-b734-4dff-a931-7f33654c0a81"
|
webhook_url: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=61f08cc9-b734-4dff-a931-7f33654c0a81"
|
||||||
|
|
||||||
|
# ---------- 豆包 LLM(审核接口参数变更)----------
|
||||||
llm:
|
llm:
|
||||||
enabled: true
|
enabled: true
|
||||||
api_key: "2f3f7ee9-a6f7-46b7-a709-a36743a83a04"
|
api_key: "2f3f7ee9-a6f7-46b7-a709-a36743a83a04"
|
||||||
@@ -16,6 +20,7 @@ llm:
|
|||||||
api_url: "https://ark.cn-beijing.volces.com/api/v3/chat/completions"
|
api_url: "https://ark.cn-beijing.volces.com/api/v3/chat/completions"
|
||||||
timeout: null
|
timeout: null
|
||||||
|
|
||||||
|
# ---------- 变更日志 ----------
|
||||||
log:
|
log:
|
||||||
enabled: false
|
enabled: false
|
||||||
storage: "file"
|
storage: "file"
|
||||||
@@ -28,6 +33,7 @@ log:
|
|||||||
database: "YOUR_MYSQL_DATABASE"
|
database: "YOUR_MYSQL_DATABASE"
|
||||||
table: "api_change_logs"
|
table: "api_change_logs"
|
||||||
|
|
||||||
|
# ---------- 通知 ----------
|
||||||
notify:
|
notify:
|
||||||
only_on_change: true
|
only_on_change: true
|
||||||
mentioned_users: ""
|
mentioned_users: ""
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
<?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>
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
package com.aicheck;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 单个 Controller 接口端点的模型,包含 URI、HTTP 方法及参数列表。
|
|
||||||
*/
|
|
||||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
|
||||||
public class ApiEndpoint {
|
|
||||||
|
|
||||||
/** HTTP 方法:GET / POST / PUT / DELETE / PATCH */
|
|
||||||
private String httpMethod;
|
|
||||||
|
|
||||||
/** 完整 URI 路径,如 /api/users/{id} */
|
|
||||||
private String uri;
|
|
||||||
|
|
||||||
/** 所属 Controller 类名 */
|
|
||||||
private String controllerClass;
|
|
||||||
|
|
||||||
/** Java 方法名 */
|
|
||||||
private String methodName;
|
|
||||||
|
|
||||||
/** 源文件相对路径 */
|
|
||||||
private String sourceFile;
|
|
||||||
|
|
||||||
/** 接口参数列表 */
|
|
||||||
private List<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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
package com.aicheck;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 单个接口参数的模型,对应 Controller 方法上的一个入参。
|
|
||||||
*/
|
|
||||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
|
||||||
public class ApiParameter {
|
|
||||||
|
|
||||||
/** 参数名称(@RequestParam / @PathVariable 的 value,或字段名) */
|
|
||||||
private String name;
|
|
||||||
|
|
||||||
/** Java 类型,如 String、Long、Boolean */
|
|
||||||
private String type;
|
|
||||||
|
|
||||||
/** 是否必填(来自 required 属性或 @NotNull 等,默认 true) */
|
|
||||||
private boolean required = true;
|
|
||||||
|
|
||||||
/** 参数来源:query / path / body / header / form */
|
|
||||||
private String source;
|
|
||||||
|
|
||||||
/** 参数说明(来自 @ApiParam、@Parameter 等注解的 description) */
|
|
||||||
private String description;
|
|
||||||
|
|
||||||
public ApiParameter() {
|
|
||||||
}
|
|
||||||
|
|
||||||
public ApiParameter(String name, String type, boolean required, String source) {
|
|
||||||
this.name = name;
|
|
||||||
this.type = type;
|
|
||||||
this.required = required;
|
|
||||||
this.source = source;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getName() {
|
|
||||||
return name;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setName(String name) {
|
|
||||||
this.name = name;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getType() {
|
|
||||||
return type;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setType(String type) {
|
|
||||||
this.type = type;
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean isRequired() {
|
|
||||||
return required;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setRequired(boolean required) {
|
|
||||||
this.required = required;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getSource() {
|
|
||||||
return source;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setSource(String source) {
|
|
||||||
this.source = source;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getDescription() {
|
|
||||||
return description;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setDescription(String description) {
|
|
||||||
this.description = description;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,421 +0,0 @@
|
|||||||
package com.aicheck;
|
|
||||||
|
|
||||||
import com.github.javaparser.StaticJavaParser;
|
|
||||||
import com.github.javaparser.ast.CompilationUnit;
|
|
||||||
import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration;
|
|
||||||
import com.github.javaparser.ast.body.FieldDeclaration;
|
|
||||||
import com.github.javaparser.ast.body.MethodDeclaration;
|
|
||||||
import com.github.javaparser.ast.body.Parameter;
|
|
||||||
import com.github.javaparser.ast.expr.AnnotationExpr;
|
|
||||||
import com.github.javaparser.ast.expr.MemberValuePair;
|
|
||||||
import com.github.javaparser.ast.expr.NormalAnnotationExpr;
|
|
||||||
import com.github.javaparser.ast.expr.SingleMemberAnnotationExpr;
|
|
||||||
import com.github.javaparser.ast.type.ClassOrInterfaceType;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Arrays;
|
|
||||||
import java.util.HashSet;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.Set;
|
|
||||||
import java.util.stream.Collectors;
|
|
||||||
import java.util.stream.Stream;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 基于 JavaParser 的 Spring Controller AST 解析器。
|
|
||||||
* 扫描指定目录下的 Java 文件,提取带 @RestController / @Controller 注解类中的接口定义。
|
|
||||||
*/
|
|
||||||
public class ControllerAstParser {
|
|
||||||
|
|
||||||
/** Spring 映射注解 -> HTTP 方法 */
|
|
||||||
private static final Set<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("/+", "/");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
package com.aicheck;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.nio.file.Paths;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Java AST 解析器命令行入口。
|
|
||||||
* 用法:java -jar controller-parser.jar <源码目录> [输出JSON文件路径]
|
|
||||||
*
|
|
||||||
* 示例:
|
|
||||||
* java -jar controller-parser.jar ./src/main/java ./endpoints.json
|
|
||||||
*/
|
|
||||||
public class ControllerParserMain {
|
|
||||||
|
|
||||||
private static final ObjectMapper MAPPER = new ObjectMapper()
|
|
||||||
.enable(SerializationFeature.INDENT_OUTPUT);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 程序入口:解析指定目录并输出 JSON。
|
|
||||||
*
|
|
||||||
* @param args [0]=源码目录, [1]=可选的输出文件路径(默认 stdout)
|
|
||||||
*/
|
|
||||||
public static void main(String[] args) throws IOException {
|
|
||||||
if (args.length < 1) {
|
|
||||||
System.err.println("用法: java -jar controller-parser.jar <源码目录> [输出JSON路径]");
|
|
||||||
System.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
Path sourceDir = Paths.get(args[0]).toAbsolutePath().normalize();
|
|
||||||
ControllerAstParser parser = new ControllerAstParser();
|
|
||||||
List<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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
#Generated by Maven
|
|
||||||
#Wed Jun 03 11:29:14 GMT+08:00 2026
|
|
||||||
groupId=com.aicheck
|
|
||||||
artifactId=controller-parser
|
|
||||||
version=1.0.0
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
com\aicheck\ControllerParserMain.class
|
|
||||||
com\aicheck\ControllerAstParser.class
|
|
||||||
com\aicheck\ApiParameter.class
|
|
||||||
com\aicheck\ApiEndpoint.class
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
C:\Users\EDY\Desktop\AI-Check\java-parser\src\main\java\com\aicheck\ControllerAstParser.java
|
|
||||||
C:\Users\EDY\Desktop\AI-Check\java-parser\src\main\java\com\aicheck\ApiParameter.java
|
|
||||||
C:\Users\EDY\Desktop\AI-Check\java-parser\src\main\java\com\aicheck\ControllerParserMain.java
|
|
||||||
C:\Users\EDY\Desktop\AI-Check\java-parser\src\main\java\com\aicheck\ApiEndpoint.java
|
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
# Gitea Actions:push 后检测 Controller 接口参数变更
|
# Gitea Actions:Controller 接口参数变更检测(纯 Python,无 Java 构建)
|
||||||
# 工具代码与配置均位于 .gitea/ 目录,与业务代码解耦
|
|
||||||
# 检出方式:使用 gitea.token 直连内网 Git 仓库(避免 actions/checkout 网络问题)
|
|
||||||
|
|
||||||
name: API接口参数变更检测
|
name: API接口参数变更检测
|
||||||
run-name: ${{ gitea.actor }}的API参数变更检测
|
run-name: ${{ gitea.actor }}的API参数变更检测
|
||||||
@@ -9,7 +7,6 @@ on: [push]
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
api-param-check:
|
api-param-check:
|
||||||
# 排除指定分支(与现有 AI 审查 workflow 保持一致,可按需修改)
|
|
||||||
if: ${{ gitea.ref != 'refs/heads/pre' && gitea.ref != 'refs/heads/dev' && gitea.ref != 'refs/heads/master-2.0' }}
|
if: ${{ gitea.ref != 'refs/heads/pre' && gitea.ref != 'refs/heads/dev' && gitea.ref != 'refs/heads/master-2.0' }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
@@ -28,29 +25,15 @@ jobs:
|
|||||||
echo "错误: 缺少 .gitea/config.yaml"
|
echo "错误: 缺少 .gitea/config.yaml"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "使用 .gitea/config.yaml"
|
|
||||||
|
|
||||||
- name: 安装 Java、Maven 和 Python
|
- name: 安装 Python 和依赖
|
||||||
run: |
|
run: |
|
||||||
echo "安装运行环境..."
|
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y openjdk-11-jdk maven python3 python3-pip
|
sudo apt-get install -y python3 python3-pip
|
||||||
java -version
|
|
||||||
mvn -version
|
|
||||||
python3 --version
|
|
||||||
echo "环境准备完成"
|
|
||||||
|
|
||||||
- name: 安装 Python 依赖
|
|
||||||
run: |
|
|
||||||
python3 -m pip install --break-system-packages -r .gitea/checker/requirements.txt
|
python3 -m pip install --break-system-packages -r .gitea/checker/requirements.txt
|
||||||
|
|
||||||
- name: 构建 AST 解析器
|
|
||||||
working-directory: .gitea/java-parser
|
|
||||||
run: mvn -q package -DskipTests
|
|
||||||
|
|
||||||
- name: 检测 Controller 接口参数变更
|
- name: 检测 Controller 接口参数变更
|
||||||
run: |
|
run: |
|
||||||
echo "执行 API 参数变更检测..."
|
|
||||||
COMMIT_TIME=$(git log -1 --format=%cd --date=format:'%Y-%m-%d %H:%M:%S')
|
COMMIT_TIME=$(git log -1 --format=%cd --date=format:'%Y-%m-%d %H:%M:%S')
|
||||||
python3 .gitea/checker/main.py \
|
python3 .gitea/checker/main.py \
|
||||||
--config .gitea/config.yaml \
|
--config .gitea/config.yaml \
|
||||||
|
|||||||
Reference in New Issue
Block a user