Compare commits

...

15 Commits

Author SHA1 Message Date
4ebb71f7a0 测试:普通参数--新增&修改&删除
All checks were successful
API接口参数变更检测 / api-param-check (push) Successful in 24s
2026-06-05 17:24:59 +08:00
88aff91e5d 测试:普通参数--删除
All checks were successful
API接口参数变更检测 / api-param-check (push) Successful in 19s
2026-06-05 17:22:54 +08:00
d0aeefa1e7 测试:普通参数--新增
All checks were successful
API接口参数变更检测 / api-param-check (push) Successful in 19s
2026-06-05 17:21:19 +08:00
eec5608cce 测试:参数修改
All checks were successful
API接口参数变更检测 / api-param-check (push) Successful in 21s
2026-06-05 17:18:27 +08:00
380bc41fc4 测试:路径修改
All checks were successful
API接口参数变更检测 / api-param-check (push) Successful in 21s
2026-06-05 17:16:56 +08:00
eb4856ab56 测试:新增接口
All checks were successful
API接口参数变更检测 / api-param-check (push) Successful in 21s
2026-06-05 16:57:00 +08:00
326419bc53 测试:删除接口
All checks were successful
API接口参数变更检测 / api-param-check (push) Successful in 20s
2026-06-05 16:54:04 +08:00
a128241ccc 测试:请求方式变更
All checks were successful
API接口参数变更检测 / api-param-check (push) Successful in 20s
2026-06-05 16:52:20 +08:00
3e6fb3012b 接口类对象解析
All checks were successful
API接口参数变更检测 / api-param-check (push) Successful in 20s
2026-06-05 16:44:10 +08:00
2d1292262a 接口类对象解析
Some checks failed
API接口参数变更检测 / api-param-check (push) Has been cancelled
2026-06-05 16:43:57 +08:00
51145e7c78 接口类对象解析
Some checks failed
API接口参数变更检测 / api-param-check (push) Has been cancelled
2026-06-05 16:41:16 +08:00
90b0045659 接口类对象解析
All checks were successful
API接口参数变更检测 / api-param-check (push) Successful in 20s
2026-06-05 16:39:16 +08:00
a1c28570d4 接口类对象解析 2026-06-05 16:39:02 +08:00
ba3f1c6507 接口类对象解析
All checks were successful
API接口参数变更检测 / api-param-check (push) Successful in 19s
2026-06-05 16:36:16 +08:00
03fb9766a6 接口类对象解析 2026-06-05 16:35:51 +08:00
8 changed files with 197 additions and 53 deletions

View File

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

View File

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

View File

@@ -25,19 +25,19 @@ def filter_endpoints_by_files(
def parse_endpoints_from_files( def parse_endpoints_from_files(
repo_root: Path, repo_root: Path,
source_subdir: str, source_subdirs: List[str],
file_paths: List[str], file_paths: List[str],
file_contents: Dict[str, str], file_contents: Dict[str, str],
) -> List[ApiEndpoint]: ) -> List[ApiEndpoint]:
""" """
解析指定 Controller 文件,提取接口参数(仅解析传入文件,不全量扫描)。 解析指定 Controller 文件,提取接口参数(仅解析传入文件,不全量扫描)。
:param repo_root: 仓库根 :param repo_root: 仓库根
:param source_subdir: 源码目录(相对仓库根) :param source_subdirs: 源码目录列表(相对仓库根)
:param file_paths: 文件路径列表 :param file_paths: 文件路径列表
:param file_contents: 路径 -> 源码内容 :param file_contents: 路径 -> 源码内容
:return: ApiEndpoint 列表 :return: ApiEndpoint 列表
""" """
from controller_ast_parser import parse_controller_files 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 {} 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: def _read_file_safe(path: Path) -> str:
"""读取文件内容。""" """读取文件内容。"""
try: try:
@@ -73,7 +81,7 @@ def _load_version_contents(
def parse_changed_endpoints( def parse_changed_endpoints(
repo_root: Path, repo_root: Path,
source_subdir: str, source_subdirs: list,
changed_files: list, changed_files: list,
old_sha: str, old_sha: str,
label: str, label: str,
@@ -86,7 +94,7 @@ def parse_changed_endpoints(
print(f"[AST] 解析 {label} 版本 {len(contents)} 个 Controller 文件") print(f"[AST] 解析 {label} 版本 {len(contents)} 个 Controller 文件")
endpoints = parse_endpoints_from_files( 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)} 个接口") print(f"[AST] {label} 版本共 {len(endpoints)} 个接口")
return endpoints_to_map(endpoints) return endpoints_to_map(endpoints)
@@ -111,7 +119,11 @@ def main() -> int:
config_path = repo_root / config_path config_path = repo_root / config_path
config = load_config(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() commit_info = get_current_commit()
push_user = args.push_user or commit_info.author push_user = args.push_user or commit_info.author
@@ -121,6 +133,8 @@ def main() -> int:
print("=" * 40) print("=" * 40)
print(f"推送人: {push_user}") print(f"推送人: {push_user}")
print(f"推送时间: {push_time}") 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"LLM 审核: {config.get('llm', {}).get('enabled', True)}")
print(f"记录日志: {config.get('log', {}).get('enabled', False)}") print(f"记录日志: {config.get('log', {}).get('enabled', False)}")
print("=" * 40) print("=" * 40)
@@ -142,10 +156,10 @@ def main() -> int:
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)
new_map = parse_changed_endpoints( 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( 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( new_filtered = endpoints_to_map(

View File

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

View File

@@ -5,11 +5,12 @@
import json import json
import re import re
from typing import List, Optional from collections import OrderedDict
from typing import List, Optional, Tuple
import requests import requests
from comparator import EndpointChangeReport from comparator import EndpointChangeReport, ParameterChange
# 企微 Markdown 单条上限 4096 字符,留余量 # 企微 Markdown 单条上限 4096 字符,留余量
MAX_MD_LENGTH = 3800 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>" 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: if not changes:
return ['<font color="comment">无</font>'] return ['<font color="comment">无</font>']
lines = ["", f"共 **{len(changes)}** 项变更", ""] lines = ["", f"共 **{len(changes)}** 项变更", ""]
@@ -34,6 +35,52 @@ def _format_param_change_list(changes: List) -> List[str]:
return lines 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]: def _format_param_details_section(report: EndpointChangeReport) -> List[str]:
"""生成接口参数变动详情区块。""" """生成接口参数变动详情区块。"""
body_changes = [c for c in report.parameter_changes if c.source == "body"] 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 = ["", "---------------------------------------", "", "#### 【接口参数变动详情】", ""] lines = ["", "---------------------------------------", "", "#### 【接口参数变动详情】", ""]
if body_changes: if body_changes:
lines.append("**类对象变更**") lines.append("**类对象变更(一级字段)**")
lines.extend(_format_param_change_list(body_changes)) lines.extend(_format_body_dto_groups(body_changes))
lines.append("") lines.append("")
if regular_changes: if regular_changes:
# lines.append("**普通参数变更**") lines.append("**普通参数变更**")
lines.extend(_format_param_change_list(regular_changes)) lines.extend(_format_param_change_list(regular_changes))
lines.append("") lines.append("")
@@ -351,7 +398,7 @@ def send_parameter_change_notification(
# 构建参数变更通知(只包含参数变更报告,对齐 model.md # 构建参数变更通知(只包含参数变更报告,对齐 model.md
parts: List[str] = [] parts: List[str] = []
parts.append("# 【API参数变更通知】") parts.append("# 【API参数变更通知】")
parts.append(f"- **修改人:** {push_user if push_user.startswith('@') else '@' + push_user}") parts.append(f"- **修改人:** {push_user}")
parts.append(f"- **修改时间:** {push_time}") parts.append(f"- **修改时间:** {push_time}")
parts.append("") parts.append("")
for report in changed_reports: for report in changed_reports:

View File

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

View File

@@ -286,11 +286,11 @@ public class WebStatisticsController implements FtbStatisticsApi {
@NoDataSourceBind @NoDataSourceBind
@Operation(summary = "日统计触发") @Operation(summary = "日统计触发")
@GetMapping("/dayStatisticsTriggered") @GetMapping("/dayStatisticsTriggered11")
public ActionResult<Boolean> dayStatisticsTriggered(@RequestParam("tenantId") String tenantId, public ActionResult<Boolean> dayStatisticsTriggered(@RequestParam("tenantId") String tenantId,
@RequestParam("groupId") String groupId, @RequestParam("userId") Boolean userId,
@RequestParam("userId") String userId, @RequestParam("user") Integer user,
@RequestParam("day") String day) throws LoginException { @RequestParam("day") Boolean day) throws LoginException {
StatisticsSingleDto courseEventDTO = StatisticsSingleDto.builder() StatisticsSingleDto courseEventDTO = StatisticsSingleDto.builder()
.tenantId(tenantId) .tenantId(tenantId)
.groupId(groupId) .groupId(groupId)