接口类对象解析
This commit is contained in:
@@ -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
|
||||
],
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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("")
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
# ---------- 企业微信机器人 ----------
|
||||
|
||||
Reference in New Issue
Block a user