diff --git a/.gitea/checker/comparator.py b/.gitea/checker/comparator.py index b5faeaa..b52b59c 100644 --- a/.gitea/checker/comparator.py +++ b/.gitea/checker/comparator.py @@ -31,48 +31,37 @@ class ParameterChange: required: Optional[bool] = None old_required: Optional[bool] = None detail: Optional[str] = None + description: Optional[str] = None + old_description: Optional[str] = None + source: str = "query" + + def _escape_cell(self, text: str) -> str: + """表格单元格转义。""" + return text.replace("|", "\\|").replace("\n", " ") + + def _change_label(self) -> str: + """变更列文案,对齐 model.md。""" + if self.change_type == ChangeType.ADDED: + if self.required is False: + return "新增可选" + return "新增必填" + if self.change_type == ChangeType.REMOVED: + return "删除" + if self.change_type == ChangeType.RENAMED: + return f"重命名 {self.old_name} → {self.param_name}" + if self.change_type == ChangeType.MODIFIED: + return self.detail or "修改" + return "-" + + def to_table_row(self) -> str: + """格式化为 model.md 参数变更表格行。""" + desc = self._escape_cell(self.description or "-") + change = self._escape_cell(self._change_label()) + return f"| `{self.param_name}` | {desc} | {change} |" def to_markdown_line(self, *, plain: bool = False) -> str: - """ - 格式化为企微 Markdown 行。 - plain=True 时用于新增接口,直接列出参数,不加「新增」前缀。 - """ - req_optional = self.required is False - req_required = self.required is True - - if plain and self.change_type == ChangeType.ADDED: - tag = ( - '必填' - if req_required - else '可选' - ) - return f'> `{self.param_type}` **{self.param_name}** · {tag}' - - if self.change_type == ChangeType.REMOVED: - return ( - f'【删除】 ' - f'`{self.param_type}` ~~{self.param_name}~~' - ) - if self.change_type == ChangeType.ADDED: - tag = ( - '必填' - if req_required - else '可选' - ) - return ( - f'【新增】 ' - f'`{self.param_type}` **{self.param_name}** · {tag}' - ) - if self.change_type == ChangeType.RENAMED: - return ( - f'【重命名】 ' - f'`{self.old_type}` {self.old_name} → ' - f'`{self.param_type}` **{self.param_name}**' - ) - if self.change_type == ChangeType.MODIFIED: - detail = f' · {self.detail}' if self.detail else "" - return f'【修改】 **{self.param_name}**{detail}' - return f'- {self.param_name}' + """兼容旧调用,委托至表格行。""" + return self.to_table_row() @dataclass @@ -113,6 +102,11 @@ def _param_key(p: ApiParameter) -> Tuple[str, str]: return (p.source, p.name) +def _format_type_change(old_type: str, new_type: str) -> str: + """类型变更文案。""" + return f"类型由{old_type}改为{new_type}" + + def compare_parameters( old_params: List[ApiParameter], new_params: List[ApiParameter] ) -> List[ParameterChange]: @@ -143,9 +137,16 @@ def compare_parameters( new_p = new_map[key] detail_parts = [] if old_p.type != new_p.type: - detail_parts.append(f"类型 {old_p.type} -> {new_p.type}") + detail_parts.append(_format_type_change(old_p.type, new_p.type)) if old_p.required != new_p.required: - detail_parts.append(f"必填 {old_p.required} -> {new_p.required}") + req_label = lambda r: "必填" if r else "可选" + detail_parts.append( + f"必填性由{req_label(old_p.required)}改为{req_label(new_p.required)}" + ) + if old_p.description != new_p.description: + detail_parts.append( + f"说明由{old_p.description or '-'}改为{new_p.description or '-'}" + ) if detail_parts: changes.append( ParameterChange( @@ -155,6 +156,9 @@ def compare_parameters( required=new_p.required, old_required=old_p.required, detail=", ".join(detail_parts), + description=new_p.description, + old_description=old_p.description, + source=new_p.source, ) ) @@ -183,6 +187,9 @@ def compare_parameters( old_name=r_param.name, old_type=r_param.type, required=a_param.required, + description=a_param.description, + old_description=r_param.description, + source=a_param.source, ) ) matched_removed.add(r_key) @@ -197,6 +204,8 @@ def compare_parameters( change_type=ChangeType.REMOVED, param_name=param.name, param_type=param.type, + description=param.description, + source=param.source, ) ) @@ -209,6 +218,8 @@ def compare_parameters( param_name=param.name, param_type=param.type, required=param.required, + description=param.description, + source=param.source, ) ) @@ -365,6 +376,8 @@ def compare_endpoints( param_name=p.name, param_type=p.type, required=p.required, + description=p.description, + source=p.source, ) for p in ep.parameters ], diff --git a/.gitea/checker/controller_ast_parser.py b/.gitea/checker/controller_ast_parser.py index ba76cc1..043bf28 100644 --- a/.gitea/checker/controller_ast_parser.py +++ b/.gitea/checker/controller_ast_parser.py @@ -233,6 +233,64 @@ def _param_required(param: FormalParameter) -> bool: return not _has_ann(param, "Nullable") +JAVADOC_PARAM_RE = re.compile( + r"@param\s+(\w+)\s+(.*?)(?=\n\s*\*\s*@|\n\s*\*/|\Z)", + re.DOTALL, +) + + +def _clean_javadoc_text(text: str) -> str: + """清理 JavaDoc 行前缀与多余空白。""" + cleaned = re.sub(r"\s*\*\s?", " ", text) + return re.sub(r"\s+", " ", cleaned).strip() + + +def _parse_javadoc_params(javadoc: str) -> Dict[str, str]: + """从 JavaDoc 块解析 @param 名称 -> 说明。""" + if not javadoc: + return {} + result: Dict[str, str] = {} + for match in JAVADOC_PARAM_RE.finditer(javadoc): + name = match.group(1) + desc = _clean_javadoc_text(match.group(2)) + if desc: + result[name] = desc + return result + + +def _extract_javadoc_before_line(source: str, target_line: int) -> str: + """ + 提取目标行之前紧邻的 JavaDoc 块。 + target_line 为 1-indexed(与方法声明行号一致)。 + """ + if not source or target_line <= 1: + return "" + lines = source.splitlines() + idx = target_line - 2 + while idx >= 0 and not lines[idx].strip(): + idx -= 1 + while idx >= 0 and lines[idx].strip().startswith("@"): + idx -= 1 + if idx < 0 or not lines[idx].strip().endswith("*/"): + return "" + end_idx = idx + while idx >= 0 and not lines[idx].strip().startswith("/**"): + idx -= 1 + if idx < 0: + return "" + return "\n".join(lines[idx : end_idx + 1]) + + +def _lookup_param_description( + javadoc_params: Dict[str, str], param: FormalParameter, resolved_name: str +) -> Optional[str]: + """按注解名或形参名匹配 JavaDoc @param 说明。""" + for key in (resolved_name, param.name): + if key and key in javadoc_params: + return javadoc_params[key] + return None + + class ControllerAstParser: """ 基于 javalang 的 Controller 解析器。 @@ -247,6 +305,7 @@ class ControllerAstParser: self.repo_root = repo_root self.source_dir = source_dir self._dto_cache: Dict[str, List[ApiParameter]] = {} + self._current_source = "" def parse_file_content(self, source: str, repo_relative_path: str) -> List[ApiEndpoint]: """ @@ -257,6 +316,7 @@ class ControllerAstParser: :return: 端点列表 """ endpoints: List[ApiEndpoint] = [] + self._current_source = source try: tree = javalang.parse.parse(source) except (javalang.parser.JavaSyntaxError, RecursionError) as exc: @@ -300,9 +360,16 @@ class ControllerAstParser: continue method_path = _ann_string(ann, "value", "path") + javadoc_params: Dict[str, str] = {} + if getattr(method, "position", None) and method.position: + javadoc = _extract_javadoc_before_line( + self._current_source, method.position.line + ) + javadoc_params = _parse_javadoc_params(javadoc) + params = [] for p in method.parameters or []: - params.extend(self._extract_param(p)) + params.extend(self._extract_param(p, javadoc_params)) return ApiEndpoint( http_method=_http_method(ann_name, ann), @@ -314,10 +381,13 @@ class ControllerAstParser: ) return None - def _extract_param(self, param: FormalParameter) -> List[ApiParameter]: + def _extract_param( + self, param: FormalParameter, javadoc_params: Optional[Dict[str, str]] = None + ) -> List[ApiParameter]: """提取方法参数,@RequestBody 展开 DTO 字段;忽略框架注入参数。""" type_name = _type_to_str(param.type) name = _param_name(param) + javadoc_params = javadoc_params or {} if _is_framework_param(type_name, name): return [] @@ -325,12 +395,14 @@ class ControllerAstParser: if _has_ann(param, "RequestBody"): return self._expand_dto(type_name, "body") + description = _lookup_param_description(javadoc_params, param, name) return [ ApiParameter( name=name, type=type_name, required=_param_required(param), source=_param_source(param), + description=description, ) ] @@ -347,7 +419,8 @@ class ControllerAstParser: return result try: - tree = javalang.parse.parse(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) except (javalang.parser.JavaSyntaxError, OSError): result = [ApiParameter(name=simple, type=type_name, required=True, source=source)] self._dto_cache[simple] = result @@ -360,6 +433,14 @@ class ControllerAstParser: for field in type_decl.fields or []: if "static" in (field.modifiers or []): continue + field_javadoc = "" + if getattr(field, "position", None) and field.position: + field_javadoc = _extract_javadoc_before_line( + dto_source, field.position.line + ) + field_desc = _clean_javadoc_text( + field_javadoc.replace("/**", "").replace("*/", "").strip() + ) or None for decl in field.declarators: fields.append( ApiParameter( @@ -367,6 +448,7 @@ class ControllerAstParser: type=_type_to_str(field.type), required=not _has_ann(field, "Nullable"), source=source, + description=field_desc, ) ) diff --git a/.gitea/checker/notifier.py b/.gitea/checker/notifier.py index 3f3ad33..3a37066 100644 --- a/.gitea/checker/notifier.py +++ b/.gitea/checker/notifier.py @@ -22,6 +22,46 @@ def truncate_text(text: str, max_length: int = MAX_MD_LENGTH) -> str: return text[:max_length] + "\n\n... 消息过长,已截断" +def _format_param_table(changes: List) -> List[str]: + """按 model.md 生成参数变更 Markdown 表格。""" + if not changes: + return [''] + lines = [ + "", + "| 字段 | 说明 | 变更 |", + "|------|------|------|", + ] + for change in changes: + lines.append(change.to_table_row()) + return lines + + +def _format_param_details_section(report: EndpointChangeReport) -> List[str]: + """生成接口参数变动详情区块(对齐 model.md)。""" + body_changes = [c for c in report.parameter_changes if c.source == "body"] + regular_changes = [c for c in report.parameter_changes if c.source != "body"] + lines = ["", "---", "", "## 接口参数变动详情", "", "---", ""] + + if body_changes: + lines.append("### 类对象变更") + lines.append("") + lines.append("- **参数变更列表:**") + lines.extend(_format_param_table(body_changes)) + lines.append("") + + if regular_changes: + lines.append("### 普通参数变更(非对象字段)") + lines.append("") + lines.append("- **参数变更列表:**") + lines.extend(_format_param_table(regular_changes)) + lines.append("") + + if not body_changes and not regular_changes: + lines.append('') + + return lines + + def _format_endpoint_block(report: EndpointChangeReport) -> str: """ 格式化单个接口块,按模板匹配格式输出。 @@ -41,21 +81,7 @@ def _format_endpoint_block(report: EndpointChangeReport) -> str: if report.is_removed_endpoint: return "\n".join(header + ["", f"**该接口已被移除**"]) - detail_lines = ["", "---------------------------------------", "", "## 【接口参数变动详情】", ""] - - if report.is_new_endpoint: - detail_lines.append("### **新增接口参数**") - else: - detail_lines.append("### **参数变更明细**") - - if report.parameter_changes: - for change in report.parameter_changes: - md = change.to_markdown_line(plain=report.is_new_endpoint) - detail_lines.append(md) - else: - detail_lines.append('') - - return "\n".join(header + detail_lines) + return "\n".join(header + _format_param_details_section(report)) def build_markdown_notification( @@ -142,7 +168,7 @@ def build_markdown_notification( parts.append(path_md) parts.append("") - # 4. 普通参数变更(非路径变更)仍使用原有格式 + # 4. 普通参数变更(非路径变更)仍使用 model.md 格式 if changed_reports: parts.append("# 【API参数变更通知】") parts.append(f"- **修改人:** {push_user}") @@ -291,6 +317,9 @@ def send_parameter_change_notification( push_time=push_time, file_name=report.source_file or report.controller_class, ) + if report.parameter_changes: + param_section = "\n".join(_format_param_details_section(report)).strip() + md = f"{md}\n\n{param_section}" if _post_wecom_markdown(webhook_url, md): sent += 1 print(f"第 {sent} 条通知已发送到企业微信(新增接口)") @@ -325,9 +354,9 @@ def send_parameter_change_notification( # ========== 3. 参数变更通知(独立分支) ========== if changed_reports: - # 构建参数变更通知(只包含参数变更报告) + # 构建参数变更通知(只包含参数变更报告,对齐 model.md) 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_time}") parts.append("") diff --git a/ftb/src/main/java/ftb/test/controller/CultureClockInController.java b/ftb/src/main/java/ftb/test/controller/CultureClockInController.java index 75a7395..32b3952 100644 --- a/ftb/src/main/java/ftb/test/controller/CultureClockInController.java +++ b/ftb/src/main/java/ftb/test/controller/CultureClockInController.java @@ -132,7 +132,7 @@ public class CultureClockInController { * @return jnpf.base.ActionResult */ @GetMapping(value = "/dynamic1") - public ActionResult getRecordList(@RequestParam(value = "cursorDate", required = false) Boolean cursorDate, + public ActionResult getRecordList(@RequestParam(value = "cursorDate1", required = false) Boolean cursorDate, @RequestParam(value = "limitNum", required = false, defaultValue = "10") Integer limitNum) { limitNum = Math.max(10, Math.min(limitNum, 30));