接口类对象解析

This commit is contained in:
2026-06-05 16:35:51 +08:00
parent 3cba3bb74e
commit 03fb9766a6
7 changed files with 192 additions and 49 deletions

View File

@@ -34,6 +34,8 @@ class ParameterChange:
description: Optional[str] = None
old_description: Optional[str] = None
source: str = "query"
parent_dto: Optional[str] = None
body_param_name: Optional[str] = None
def _change_tag(self) -> str:
"""变更类型标签(企微颜色)。"""
@@ -192,6 +194,8 @@ def compare_parameters(
description=new_p.description,
old_description=old_p.description,
source=new_p.source,
parent_dto=new_p.parent_dto,
body_param_name=new_p.body_param_name,
)
)
@@ -223,6 +227,8 @@ def compare_parameters(
description=a_param.description,
old_description=r_param.description,
source=a_param.source,
parent_dto=a_param.parent_dto,
body_param_name=a_param.body_param_name,
)
)
matched_removed.add(r_key)
@@ -239,6 +245,8 @@ def compare_parameters(
param_type=param.type,
description=param.description,
source=param.source,
parent_dto=param.parent_dto,
body_param_name=param.body_param_name,
)
)
@@ -253,6 +261,8 @@ def compare_parameters(
required=param.required,
description=param.description,
source=param.source,
parent_dto=param.parent_dto,
body_param_name=param.body_param_name,
)
)
@@ -411,6 +421,8 @@ def compare_endpoints(
required=p.required,
description=p.description,
source=p.source,
parent_dto=p.parent_dto,
body_param_name=p.body_param_name,
)
for p in ep.parameters
],

View File

@@ -23,7 +23,8 @@ from javalang.tree import (
from models import ApiEndpoint, ApiParameter
MAPPING_ANNS = {"GetMapping", "PostMapping", "PutMapping", "DeleteMapping", "PatchMapping", "RequestMapping"}
# javax.validation 必填注解
REQUIRED_FIELD_ANNS = {"NotNull", "NotEmpty", "NotBlank"}
CONTROLLER_ANNS = {"RestController", "Controller"}
# Spring MVC 框架自动注入参数,不属于 API 调用方入参,解析时忽略
@@ -281,6 +282,16 @@ def _extract_javadoc_before_line(source: str, target_line: int) -> str:
return "\n".join(lines[idx : end_idx + 1])
def _field_required(field: FieldDeclaration) -> bool:
"""DTO 字段是否必填(@NotNull / @NotEmpty / @NotBlank"""
if _has_ann(field, "Nullable"):
return False
for ann in field.annotations or []:
if _ann_simple_name(ann) in REQUIRED_FIELD_ANNS:
return True
return False
def _lookup_param_description(
javadoc_params: Dict[str, str], param: FormalParameter, resolved_name: str
) -> Optional[str]:
@@ -297,13 +308,13 @@ class ControllerAstParser:
只解析传入的文件不扫描整个目录CI 更快)。
"""
def __init__(self, repo_root: Path, source_dir: Path):
def __init__(self, repo_root: Path, source_dirs: List[Path]):
"""
:param repo_root: 仓库根目录
:param source_dir: Java 源码根目录repo_root 下的相对路径对应的绝对路径
:param repo_root: 仓库根目录
:param source_dirs: Java 源码根目录列表(用于查找 DTO 等
"""
self.repo_root = repo_root
self.source_dir = source_dir
self.source_dirs = source_dirs
self._dto_cache: Dict[str, List[ApiParameter]] = {}
self._current_source = ""
@@ -393,7 +404,13 @@ class ControllerAstParser:
return []
if _has_ann(param, "RequestBody"):
return self._expand_dto(type_name, "body")
body_desc = _lookup_param_description(javadoc_params, param, name)
return self._expand_dto(
type_name,
"body",
body_param_name=param.name,
body_param_desc=body_desc,
)
description = _lookup_param_description(javadoc_params, param, name)
return [
@@ -406,24 +423,51 @@ class ControllerAstParser:
)
]
def _expand_dto(self, type_name: str, source: str) -> List[ApiParameter]:
"""展开 @RequestBody DTO 字段。"""
def _expand_dto(
self,
type_name: str,
source: str,
body_param_name: str = "",
body_param_desc: Optional[str] = None,
) -> List[ApiParameter]:
"""展开 @RequestBody DTO 一级字段。"""
simple = type_name.split(".")[-1].replace(">", "").replace("<", "").strip()
if simple in self._dto_cache:
return self._dto_cache[simple]
cache_key = f"{simple}:{body_param_name}"
if cache_key in self._dto_cache:
return self._dto_cache[cache_key]
dto_file = self._find_dto_file(simple)
if not dto_file:
result = [ApiParameter(name=simple, type=type_name, required=True, source=source)]
self._dto_cache[simple] = result
result = [
ApiParameter(
name=simple,
type=type_name,
required=True,
source=source,
description=body_param_desc,
parent_dto=simple,
body_param_name=body_param_name or None,
)
]
self._dto_cache[cache_key] = result
return result
try:
dto_source = dto_file.read_text(encoding="utf-8", errors="ignore")
tree = javalang.parse.parse(dto_source)
except (javalang.parser.JavaSyntaxError, OSError):
result = [ApiParameter(name=simple, type=type_name, required=True, source=source)]
self._dto_cache[simple] = result
result = [
ApiParameter(
name=simple,
type=type_name,
required=True,
source=source,
description=body_param_desc,
parent_dto=simple,
body_param_name=body_param_name or None,
)
]
self._dto_cache[cache_key] = result
return result
fields: List[ApiParameter] = []
@@ -446,45 +490,61 @@ class ControllerAstParser:
ApiParameter(
name=decl.name,
type=_type_to_str(field.type),
required=not _has_ann(field, "Nullable"),
required=_field_required(field),
source=source,
description=field_desc,
parent_dto=simple,
body_param_name=body_param_name or None,
)
)
if not fields:
fields = [ApiParameter(name=simple, type=type_name, required=True, source=source)]
fields = [
ApiParameter(
name=simple,
type=type_name,
required=True,
source=source,
description=body_param_desc,
parent_dto=simple,
body_param_name=body_param_name or None,
)
]
self._dto_cache[simple] = fields
self._dto_cache[cache_key] = fields
return fields
def _find_dto_file(self, simple_name: str) -> Optional[Path]:
"""在源码目录中查找 DTO 文件。"""
if not self.source_dir.exists():
return None
"""配置的源码目录及仓库内 src/main/java 中查找 DTO 文件。"""
target = f"{simple_name}.java"
for path in self.source_dir.rglob(target):
return path
for source_dir in self.source_dirs:
if source_dir.exists():
for path in source_dir.rglob(target):
return path
if self.repo_root.exists():
for path in self.repo_root.rglob(target):
if "src/main/java" in path.as_posix():
return path
return None
def parse_controller_files(
repo_root: Path,
source_subdir: str,
source_subdirs: List[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: {文件路径: 源码内容}
:param repo_root: 仓库根目录
:param source_subdirs: 源码子目录列表(相对仓库根)
:param file_paths: 要解析的文件路径列表(相对仓库根)
:param file_contents: {文件路径: 源码内容}
:return: 所有端点
"""
source_dir = (repo_root / source_subdir).resolve()
parser = ControllerAstParser(repo_root, source_dir)
source_dirs = [(repo_root / sub).resolve() for sub in source_subdirs]
parser = ControllerAstParser(repo_root, source_dirs)
endpoints: List[ApiEndpoint] = []
for path in file_paths:

View File

@@ -25,19 +25,19 @@ def filter_endpoints_by_files(
def parse_endpoints_from_files(
repo_root: Path,
source_subdir: str,
source_subdirs: List[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: 路径 -> 源码内容
:param repo_root: 仓库根
:param source_subdirs: 源码目录列表(相对仓库根)
: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)
return parse_controller_files(repo_root, source_subdirs, file_paths, file_contents)

View File

@@ -42,6 +42,14 @@ def load_config(config_path: Path) -> dict:
return yaml.safe_load(f) or {}
def resolve_source_subdirs(config: dict) -> list:
"""从配置解析 Java 源码目录列表(支持 source_dirs 多模块)。"""
dirs = config.get("source_dirs")
if dirs:
return [str(d) for d in dirs]
return [config.get("source_dir", "src/main/java")]
def _read_file_safe(path: Path) -> str:
"""读取文件内容。"""
try:
@@ -73,7 +81,7 @@ def _load_version_contents(
def parse_changed_endpoints(
repo_root: Path,
source_subdir: str,
source_subdirs: list,
changed_files: list,
old_sha: str,
label: str,
@@ -86,7 +94,7 @@ def parse_changed_endpoints(
print(f"[AST] 解析 {label} 版本 {len(contents)} 个 Controller 文件")
endpoints = parse_endpoints_from_files(
repo_root, source_subdir, changed_files, contents
repo_root, source_subdirs, changed_files, contents
)
print(f"[AST] {label} 版本共 {len(endpoints)} 个接口")
return endpoints_to_map(endpoints)
@@ -111,7 +119,11 @@ def main() -> int:
config_path = repo_root / config_path
config = load_config(config_path)
source_subdir = config.get("source_dir", "src/main/java")
if not config.get("check", {}).get("enabled", True):
print("[检查] API 变动检查已关闭check.enabled=false跳过。")
return 0
source_subdirs = resolve_source_subdirs(config)
commit_info = get_current_commit()
push_user = args.push_user or commit_info.author
@@ -121,6 +133,8 @@ def main() -> int:
print("=" * 40)
print(f"推送人: {push_user}")
print(f"推送时间: {push_time}")
print(f"API 变动检查: {config.get('check', {}).get('enabled', True)}")
print(f"源码目录: {', '.join(source_subdirs)}")
print(f"LLM 审核: {config.get('llm', {}).get('enabled', True)}")
print(f"记录日志: {config.get('log', {}).get('enabled', False)}")
print("=" * 40)
@@ -142,10 +156,10 @@ def main() -> int:
git_diff = get_controller_files_diff(prev_sha, commit_info.sha, changed_files)
new_map = parse_changed_endpoints(
repo_root, source_subdir, changed_files, prev_sha, "new"
repo_root, source_subdirs, changed_files, prev_sha, "new"
)
old_map = parse_changed_endpoints(
repo_root, source_subdir, changed_files, prev_sha, "old"
repo_root, source_subdirs, changed_files, prev_sha, "old"
)
new_filtered = endpoints_to_map(

View File

@@ -15,6 +15,8 @@ class ApiParameter:
required: bool = True
source: str = "query"
description: Optional[str] = None
parent_dto: Optional[str] = None
body_param_name: Optional[str] = None
@dataclass

View File

@@ -5,11 +5,12 @@
import json
import re
from typing import List, Optional
from collections import OrderedDict
from typing import List, Optional, Tuple
import requests
from comparator import EndpointChangeReport
from comparator import EndpointChangeReport, ParameterChange
# 企微 Markdown 单条上限 4096 字符,留余量
MAX_MD_LENGTH = 3800
@@ -22,8 +23,8 @@ def truncate_text(text: str, max_length: int = MAX_MD_LENGTH) -> str:
return text[:max_length] + "\n\n<font color=\"comment\">... 消息过长,已截断</font>"
def _format_param_change_list(changes: List) -> List[str]:
"""生成企微友好的参数变更列表(卡片式)。"""
def _format_param_change_list(changes: List[ParameterChange]) -> List[str]:
"""生成企微友好的普通参数变更列表(卡片式)。"""
if not changes:
return ['<font color="comment">无</font>']
lines = ["", f"共 **{len(changes)}** 项变更", ""]
@@ -34,6 +35,52 @@ def _format_param_change_list(changes: List) -> List[str]:
return lines
def _body_dto_group_key(change: ParameterChange) -> Tuple[str, str]:
"""类对象变更分组键:(body 参数名, DTO 类名)。"""
return (change.body_param_name or "body", change.parent_dto or "")
def _format_body_field_line(change: ParameterChange, *, is_last: bool) -> List[str]:
"""格式化 DTO 一级字段变更行。"""
branch = "└─" if is_last else "├─"
desc = change.description or change.old_description
type_part = f" · `{change.param_type}`" if change.param_type else ""
req_part = f" · {change._required_tag()}" if change._required_tag() else ""
lines = [f"{branch} `{change.param_name}`{type_part}{req_part} {change._change_tag()}"]
if desc:
lines.append(f"> 说明:{desc}")
if change.change_type.value == "modified" and change.detail:
lines.append(f"> 变更:{change.detail}")
if change.change_type.value == "renamed":
lines.append(f"> `{change.old_name}` → `{change.param_name}`")
return lines
def _format_body_dto_groups(changes: List[ParameterChange]) -> List[str]:
"""按 DTO 分组展示 @RequestBody 一级字段。"""
if not changes:
return ['<font color="comment">无</font>']
groups: OrderedDict[Tuple[str, str], List[ParameterChange]] = OrderedDict()
for change in changes:
key = _body_dto_group_key(change)
groups.setdefault(key, []).append(change)
lines: List[str] = ["", f"共 **{len(groups)}** 个类对象 · **{len(changes)}** 项字段变更", ""]
for (param_name, dto_name), group in groups.items():
label = param_name or "body"
dto_part = f" · `{dto_name}`" if dto_name else ""
lines.append(f"**{label}**{dto_part}")
lines.append("")
for i, change in enumerate(group):
lines.extend(_format_body_field_line(change, is_last=(i == len(group) - 1)))
lines.append("")
if lines and lines[-1] == "":
lines.pop()
return lines
def _format_param_details_section(report: EndpointChangeReport) -> List[str]:
"""生成接口参数变动详情区块。"""
body_changes = [c for c in report.parameter_changes if c.source == "body"]
@@ -41,12 +88,12 @@ def _format_param_details_section(report: EndpointChangeReport) -> List[str]:
lines = ["", "---------------------------------------", "", "#### 【接口参数变动详情】", ""]
if body_changes:
lines.append("**类对象变更**")
lines.extend(_format_param_change_list(body_changes))
lines.append("**类对象变更(一级字段)**")
lines.extend(_format_body_dto_groups(body_changes))
lines.append("")
if regular_changes:
# lines.append("**普通参数变更**")
lines.append("**普通参数变更**")
lines.extend(_format_param_change_list(regular_changes))
lines.append("")

View File

@@ -2,9 +2,17 @@
# AI-Check 配置文件(位于 .gitea/ 目录,与业务代码解耦)
# ============================================================
# ---------- API 变动检查 ----------
# 总开关false 时跳过 Controller 接口参数变更检测(不对比、不通知)
check:
enabled: false
# 业务 Java 源码目录(相对仓库根目录)
# 单模块: src/main/java
# 多模块: ftb/src/main/java
# 单模块: source_dir: "src/main/java"
# 多模块: 使用 source_dirs优先于 source_dir
source_dirs:
- "jnpf-ftb/jnpf-ftb-biz/src/main/java"
- "jnpf-ftb/jnpf-ftb-entity/src/main/java"
source_dir: "ftb/src/main/java"
# ---------- 企业微信机器人 ----------