From 03fb9766a60697a83aecbb9ff2c3d095a9ca924f Mon Sep 17 00:00:00 2001 From: dongzi Date: Fri, 5 Jun 2026 16:35:51 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E7=B1=BB=E5=AF=B9=E8=B1=A1?= =?UTF-8?q?=E8=A7=A3=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/checker/comparator.py | 12 +++ .gitea/checker/controller_ast_parser.py | 118 ++++++++++++++++++------ .gitea/checker/controller_parser.py | 12 +-- .gitea/checker/main.py | 24 ++++- .gitea/checker/models.py | 2 + .gitea/checker/notifier.py | 61 ++++++++++-- .gitea/config.yaml | 12 ++- 7 files changed, 192 insertions(+), 49 deletions(-) diff --git a/.gitea/checker/comparator.py b/.gitea/checker/comparator.py index d50d9ed..af0065e 100644 --- a/.gitea/checker/comparator.py +++ b/.gitea/checker/comparator.py @@ -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 ], diff --git a/.gitea/checker/controller_ast_parser.py b/.gitea/checker/controller_ast_parser.py index 043bf28..527aaef 100644 --- a/.gitea/checker/controller_ast_parser.py +++ b/.gitea/checker/controller_ast_parser.py @@ -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: diff --git a/.gitea/checker/controller_parser.py b/.gitea/checker/controller_parser.py index b77d669..99cf810 100644 --- a/.gitea/checker/controller_parser.py +++ b/.gitea/checker/controller_parser.py @@ -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) diff --git a/.gitea/checker/main.py b/.gitea/checker/main.py index 2b146aa..97f0d09 100644 --- a/.gitea/checker/main.py +++ b/.gitea/checker/main.py @@ -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( diff --git a/.gitea/checker/models.py b/.gitea/checker/models.py index 1608e5d..17a277d 100644 --- a/.gitea/checker/models.py +++ b/.gitea/checker/models.py @@ -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 diff --git a/.gitea/checker/notifier.py b/.gitea/checker/notifier.py index 355010e..4c31788 100644 --- a/.gitea/checker/notifier.py +++ b/.gitea/checker/notifier.py @@ -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... 消息过长,已截断" -def _format_param_change_list(changes: List) -> List[str]: - """生成企微友好的参数变更列表(卡片式)。""" +def _format_param_change_list(changes: List[ParameterChange]) -> List[str]: + """生成企微友好的普通参数变更列表(卡片式)。""" if not changes: return [''] 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 [''] + + 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("") diff --git a/.gitea/config.yaml b/.gitea/config.yaml index 880d623..5d3ff89 100644 --- a/.gitea/config.yaml +++ b/.gitea/config.yaml @@ -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" # ---------- 企业微信机器人 ----------