From 1b19e8366efd1d5ad5a57e26b6a064a18ef11bab Mon Sep 17 00:00:00 2001 From: dongzi Date: Thu, 4 Jun 2026 09:53:36 +0800 Subject: [PATCH] =?UTF-8?q?py=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/checker/comparator.py | 48 +++-- .gitea/checker/controller_ast_parser.py | 47 ++++- .gitea/checker/llm_reviewer.py | 107 ++++------ .gitea/checker/notifier.py | 248 ++++++++++++------------ 4 files changed, 245 insertions(+), 205 deletions(-) diff --git a/.gitea/checker/comparator.py b/.gitea/checker/comparator.py index 851fd3d..1b73e66 100644 --- a/.gitea/checker/comparator.py +++ b/.gitea/checker/comparator.py @@ -32,25 +32,47 @@ class ParameterChange: old_required: Optional[bool] = None detail: Optional[str] = None - def to_display_line(self) -> str: + 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}' - :return: 如 "删除: Boolean userType" 或 "重命名: String userName -> String accountName" - """ if self.change_type == ChangeType.REMOVED: - return f" - 删除: {self.param_type} {self.param_name}" + return ( + f'【删除】 ' + f'`{self.param_type}` ~~{self.param_name}~~' + ) if self.change_type == ChangeType.ADDED: - req_text = f" (是否必填:{str(self.required).lower()})" if self.required is not None else "" - return f" - 新增: {self.param_type} {self.param_name}{req_text}" + tag = ( + '必填' + if req_required + else '可选' + ) + return ( + f'【新增】 ' + f'`{self.param_type}` **{self.param_name}** · {tag}' + ) if self.change_type == ChangeType.RENAMED: - return f" - 重命名: {self.old_type} {self.old_name} -> {self.param_type} {self.param_name}" + return ( + f'【重命名】 ' + f'`{self.old_type}` {self.old_name} → ' + f'`{self.param_type}` **{self.param_name}**' + ) if self.change_type == ChangeType.MODIFIED: - parts = [f" - 修改: {self.param_name}"] - if self.detail: - parts.append(f" ({self.detail})") - return "".join(parts) - return f" - {self.change_type.value}: {self.param_name}" + detail = f' · {self.detail}' if self.detail else "" + return f'【修改】 **{self.param_name}**{detail}' + return f'- {self.param_name}' @dataclass diff --git a/.gitea/checker/controller_ast_parser.py b/.gitea/checker/controller_ast_parser.py index 94a69f6..9c51237 100644 --- a/.gitea/checker/controller_ast_parser.py +++ b/.gitea/checker/controller_ast_parser.py @@ -26,6 +26,44 @@ from models import ApiEndpoint, ApiParameter MAPPING_ANNS = {"GetMapping", "PostMapping", "PutMapping", "DeleteMapping", "PatchMapping", "RequestMapping"} CONTROLLER_ANNS = {"RestController", "Controller"} +# Spring MVC 框架自动注入参数,不属于 API 调用方入参,解析时忽略 +FRAMEWORK_PARAM_TYPES = { + "HttpServletRequest", + "HttpServletResponse", + "HttpSession", + "ServletRequest", + "ServletResponse", + "WebRequest", + "NativeWebRequest", + "Model", + "ModelMap", + "RedirectAttributes", + "BindingResult", + "Errors", + "Authentication", + "Principal", + "Locale", + "TimeZone", + "InputStream", + "OutputStream", + "Reader", + "Writer", + "HttpHeaders", + "UriComponentsBuilder", +} + + +def _is_framework_param(type_name: str, param_name: str) -> bool: + """判断是否为框架注入参数(非 API 调用方需要传递)。""" + simple = type_name.split(".")[-1].replace(">", "").replace("<", "").strip() + if simple in FRAMEWORK_PARAM_TYPES: + return True + if param_name in ("request", "response") and ( + simple.endswith("Request") or simple.endswith("Response") + ): + return True + return False + def _ann_simple_name(ann: Annotation) -> str: """获取注解简单类名。""" @@ -272,14 +310,19 @@ class ControllerAstParser: return None def _extract_param(self, param: FormalParameter) -> List[ApiParameter]: - """提取方法参数,@RequestBody 展开 DTO 字段。""" + """提取方法参数,@RequestBody 展开 DTO 字段;忽略框架注入参数。""" type_name = _type_to_str(param.type) + name = _param_name(param) + + if _is_framework_param(type_name, name): + return [] + if _has_ann(param, "RequestBody"): return self._expand_dto(type_name, "body") return [ ApiParameter( - name=_param_name(param), + name=name, type=type_name, required=_param_required(param), source=_param_source(param), diff --git a/.gitea/checker/llm_reviewer.py b/.gitea/checker/llm_reviewer.py index 556e96b..d9db5db 100644 --- a/.gitea/checker/llm_reviewer.py +++ b/.gitea/checker/llm_reviewer.py @@ -1,6 +1,6 @@ """ 豆包 LLM 接口参数变更审核模块。 -仅审核 Controller 层接口参数的增删改,不对 Java 源码做通用代码审查。 +LLM 仅输出简短的兼容性提示,详细变更由 AST + Markdown 通知展示。 """ import json @@ -10,14 +10,17 @@ import requests from comparator import EndpointChangeReport +# 写入 prompt,不在通知中展示 +FRAMEWORK_IGNORE_HINT = """ +以下参数类型/名称属于 Spring MVC 框架自动注入,不是 API 调用方入参,审核时必须忽略,不要在结果中提及: +HttpServletRequest、HttpServletResponse、HttpSession、ServletRequest、ServletResponse、 +WebRequest、NativeWebRequest、Model、ModelMap、RedirectAttributes、BindingResult、 +Authentication、Principal 等。 +""" + def is_llm_enabled(config: Dict[str, Any]) -> bool: - """ - 判断大模型总开关是否开启。 - - :param config: 完整配置字典 - :return: True=启用 LLM 审核 - """ + """判断大模型总开关是否开启。""" return config.get("llm", {}).get("enabled", True) @@ -26,14 +29,7 @@ def call_doubao_api( prompt: str, config: Dict[str, Any], ) -> Optional[str]: - """ - 调用火山引擎豆包 Chat Completions API。 - - :param api_key: API Key - :param prompt: 用户提示词 - :param config: 完整配置 - :return: LLM 回复文本;失败返回 None - """ + """调用豆包 API。""" if not api_key or api_key == "YOUR_DOUBAO_API_KEY": print("[警告] 未配置豆包 API Key,跳过 LLM 审核。") return None @@ -41,7 +37,7 @@ def call_doubao_api( llm_cfg = config.get("llm", {}) model = llm_cfg.get("model") or llm_cfg.get("endpoint_id", "") if not model: - print("[警告] 未配置 llm.model 或 llm.endpoint_id,跳过 LLM 审核。") + print("[警告] 未配置 llm.model,跳过 LLM 审核。") return None api_url = llm_cfg.get( @@ -59,9 +55,9 @@ def call_doubao_api( { "role": "system", "content": ( - "你是 Java Spring Boot Controller 接口参数变更分析专家。" - "你的职责是识别并整理 Controller 层接口参数的增、删、改、重命名," - "确认 AST 解析结果是否准确,并指出对调用方的兼容性影响。" + "你是 Java Spring Boot API 变更分析专家。" + "你只负责输出简短的兼容性风险提示,不重复罗列接口参数明细。" + + FRAMEWORK_IGNORE_HINT ), }, {"role": "user", "content": prompt}, @@ -73,13 +69,11 @@ def call_doubao_api( kwargs = {"headers": headers, "json": payload} if timeout is not None: kwargs["timeout"] = timeout - resp = requests.post(api_url, **kwargs) resp.raise_for_status() data = resp.json() if "choices" in data and data["choices"]: return data["choices"][0]["message"]["content"] - print("[错误] AI 返回格式异常") return None except requests.RequestException as exc: print(f"[错误] 豆包 API 调用失败: {exc}") @@ -92,66 +86,49 @@ def build_parameter_change_prompt( git_diff: str = "", ) -> str: """ - 构造接口参数变更审核提示词(对齐第一版需求:识别 Controller 参数增删改)。 - - :param reports: AST 解析对比结果 - :param changed_files: 本次变更的 Controller 文件列表 - :param git_diff: 相关文件的 Git diff 内容 - :return: 完整 prompt + 构造 LLM 提示词:只要求输出兼容性摘要,不要求重复参数列表。 """ ast_report = [] for r in reports: ast_report.append( { "uri": f"{r.http_method} {r.uri}", - "controller": r.controller_class, - "method": r.method_name, - "is_new_endpoint": r.is_new_endpoint, - "is_removed_endpoint": r.is_removed_endpoint, - "parameter_changes": [ + "is_new": r.is_new_endpoint, + "is_removed": r.is_removed_endpoint, + "changes": [ { "type": c.change_type.value, "name": c.param_name, "java_type": c.param_type, - "old_name": c.old_name, - "old_type": c.old_type, "required": c.required, - "detail": c.detail, } for c in r.parameter_changes ], } ) - diff_block = git_diff.strip() if git_diff.strip() else "(无 diff 内容)" - if len(diff_block) > 8000: - diff_block = diff_block[:8000] + "\n... [diff 过长,已截断]" + diff_block = git_diff.strip()[:6000] if git_diff.strip() else "(无)" - return f"""请审核以下 Controller 层接口参数变更,整理并确认变更结果。 + return f"""请根据以下 Controller 接口参数变更,**仅输出「兼容性提示」**,要求: -## 变更的 Controller 文件 +{FRAMEWORK_IGNORE_HINT} + +## 输出格式(严格遵守) +- 只输出 3~6 行 Markdown,不要输出「整体说明」「接口变更详情」等标题 +- 不要逐条重复 URI 和参数列表(通知里已有) +- 不要提及「排除框架注入」相关字样 +- 重点说明:是否有破坏性变更、哪些必填参数调用方必须传入 +- 全新 Controller 说明「均为新接口,对现有调用方无破坏」即可 +- 语气简洁,可用 ... 标注风险项 + +## 变更文件 {json.dumps(changed_files, ensure_ascii=False)} -## AST 自动解析的参数变更报告 +## AST 变更摘要 {json.dumps(ast_report, ensure_ascii=False, indent=2)} -## Git Diff(Controller 相关) -```diff +## Git Diff {diff_block} -``` - -## 审核要求 -1. 逐条确认 AST 报告的参数变更是否准确(增/删/改/重命名) -2. 若 AST 有遗漏,补充遗漏的接口参数变更 -3. 若 AST 有误报,指出并修正 -4. 按以下格式整理每个接口的变更(与通知模板一致): - URI: GET /api/xxx - 参数变更: - - 删除: Boolean userType - - 新增: Boolean includeDisabled (是否必填:false) - - 重命名: String userName -> String accountName -5. 简要说明是否存在破坏性变更(影响前端/调用方) -6. 用中文回复,简洁清晰 """ @@ -161,20 +138,8 @@ def review_parameter_changes( changed_files: List[str], git_diff: str = "", ) -> Optional[str]: - """ - 使用 LLM 审核 Controller 接口参数变更(AST 结果的二次确认与整理)。 - - :param reports: AST 对比报告 - :param config: 完整配置 - :param changed_files: 变更的 Controller 文件 - :param git_diff: Git diff 文本 - :return: LLM 整理后的审核结论;未启用或无报告时返回 None - """ - if not is_llm_enabled(config): - print("[LLM] 大模型开关已关闭,跳过接口参数变更审核。") - return None - - if not reports: + """LLM 审核,返回简短兼容性提示。""" + if not is_llm_enabled(config) or not reports: return None llm_cfg = config.get("llm", {}) diff --git a/.gitea/checker/notifier.py b/.gitea/checker/notifier.py index 3c17495..6be628f 100644 --- a/.gitea/checker/notifier.py +++ b/.gitea/checker/notifier.py @@ -1,162 +1,174 @@ """ -企业微信机器人通知模块。 -按第一版模板发送 Controller 接口参数变更通知,支持超长内容分段发送。 +企业微信 Markdown 通知模块。 +支持加粗、颜色(info/comment/warning),新增接口与变更接口使用不同展示模板。 """ import json +import re from typing import List, Optional import requests from comparator import EndpointChangeReport -from git_utils import CommitInfo -# 企微 text 消息字节上限约 2048,留余量按字符分段 -MAX_TEXT_LENGTH = 2000 +# 企微 Markdown 单条上限 4096 字符,留余量 +MAX_MD_LENGTH = 3800 -def truncate_text(text: str, max_length: int = MAX_TEXT_LENGTH) -> str: - """ - 截断文本,避免超出企微单条消息限制。 - - :param text: 原始文本 - :param max_length: 最大字符数 - :return: 截断后文本 - """ +def truncate_text(text: str, max_length: int = MAX_MD_LENGTH) -> str: + """截断超长消息。""" if len(text) <= max_length: return text - return text[:max_length] + "\n... [消息过长,已截断]" + return text[:max_length] + "\n\n... 消息过长,已截断" -def build_single_endpoint_message( - report: EndpointChangeReport, - push_user: str, - push_time: str, -) -> str: +def _format_endpoint_block(report: EndpointChangeReport) -> str: """ - 按第一版模板构建单个接口的通知正文。 + 格式化单个接口块。 - 模板示例: - [API变更通知] - URI: GET /api/users/{id} - 修改人:张三 - 修改时间:2026-06-03 10:00:00 - 参数变更: - - 删除: Boolean userType - - 新增: Boolean includeDisabled (是否必填:false) - - :param report: 单个接口变更报告 - :param push_user: 代码推送人 - :param push_time: 推送时间 - :return: 通知文本 + 新增接口:只列参数,不出现「参数变更」字样。 + 变更接口:用颜色区分增删改。 + 删除接口:整接口标红。 """ - lines = [ - "[API变更通知]", - f"URI: {report.http_method} {report.uri}", - f"修改人:{push_user}", - f"修改时间:{push_time}", - ] + uri_line = f"**{report.http_method}** `{report.uri}`" + + if report.is_removed_endpoint: + return ( + f"### 【已删除接口】\n" + f"{uri_line}\n" + f"该接口已被移除" + ) if report.is_new_endpoint: - lines.append("接口状态:新增接口") + lines = [ + f"### 【新增接口】 {uri_line}", + ] if report.parameter_changes: - lines.append("参数变更:") for change in report.parameter_changes: - lines.append(change.to_display_line()) - elif report.is_removed_endpoint: - lines.append("接口状态:已删除接口") - lines.append(" - 整个接口已被移除") - else: - lines.append("参数变更:") - for change in report.parameter_changes: - lines.append(change.to_display_line()) + lines.append(change.to_markdown_line(plain=True)) + else: + lines.append('无入参') + return "\n".join(lines) + # 已有接口的参数变更 + lines = [f"### {uri_line}"] + if report.parameter_changes: + for change in report.parameter_changes: + lines.append(change.to_markdown_line(plain=False)) + else: + lines.append('(无参数变化)') return "\n".join(lines) -def build_all_notifications( +def build_markdown_notification( reports: List[EndpointChangeReport], push_user: str, push_time: str, - llm_review: Optional[str] = None, -) -> List[str]: + llm_summary: Optional[str] = None, +) -> str: """ - 将所有接口变更组装为通知消息列表,超长时自动分段。 + 构建完整 Markdown 通知正文。 - :param reports: 变更报告列表 - :param push_user: 推送人 - :param push_time: 推送时间 - :param llm_review: LLM 参数变更审核结论(可选,附在最后一条或单独一条) - :return: 待发送的消息段落列表 + :param reports: AST 变更报告 + :param push_user: 推送人 + :param push_time: 推送时间 + :param llm_summary: LLM 兼容性摘要(可选,简短) + :return: Markdown 文本 """ - if not reports: - return [] + parts = [ + "## API 接口参数变更通知", + f"**修改人:** {push_user}", + f"**修改时间:** {push_time}", + "", + ] - messages: List[str] = [] - current = "" + # 新增 Controller(全部为新接口)与变更接口分组 + new_reports = [r for r in reports if r.is_new_endpoint] + changed_reports = [r for r in reports if not r.is_new_endpoint and not r.is_removed_endpoint] + removed_reports = [r for r in reports if r.is_removed_endpoint] - for report in reports: - block = build_single_endpoint_message(report, push_user, push_time) - separator = "\n\n---\n\n" + if new_reports: + controllers = sorted({r.controller_class for r in new_reports}) + parts.append(f"### 新增 Controller `{', '.join(controllers)}`") + parts.append("") + for report in new_reports: + parts.append(_format_endpoint_block(report)) + parts.append("") - if not current: - current = block - elif len(current) + len(separator) + len(block) <= MAX_TEXT_LENGTH: - current += separator + block - else: - messages.append(current) - current = block + if changed_reports: + parts.append("### 接口参数变更") + parts.append("") + for report in changed_reports: + parts.append(_format_endpoint_block(report)) + parts.append("") - if current: - messages.append(current) + if removed_reports: + parts.append("### 已删除接口") + parts.append("") + for report in removed_reports: + parts.append(_format_endpoint_block(report)) + parts.append("") - # LLM 审核结论单独或追加发送 - if llm_review: - review_msg = f"[AI参数变更审核]\n修改人:{push_user}\n修改时间:{push_time}\n\n{llm_review}" - if messages and len(messages[-1]) + len(review_msg) + 4 <= MAX_TEXT_LENGTH: - messages[-1] += "\n\n" + review_msg - elif len(review_msg) <= MAX_TEXT_LENGTH: - messages.append(review_msg) - else: - messages.extend(_split_long_text(review_msg, MAX_TEXT_LENGTH)) + if llm_summary: + cleaned = llm_summary.strip() + # 去掉 LLM 可能输出的「排除框架注入」类说明 + cleaned = re.sub( + r"(排除Spring MVC框架自动注入的[^)]+)", + "", + cleaned, + ) + cleaned = re.sub( + r"排除Spring MVC框架自动注入的[`\w/]+[`\w/、/]*[。\.]?", + "", + cleaned, + ) + if cleaned: + parts.append("---") + parts.append("### 兼容性提示") + parts.append(cleaned) - return messages + return "\n".join(parts).strip() -def _split_long_text(text: str, max_len: int) -> List[str]: - """按行拆分超长文本。""" +def _split_markdown(text: str, max_len: int) -> List[str]: + """按 ### 标题块拆分超长 Markdown。""" + if len(text) <= max_len: + return [text] + lines = text.split("\n") chunks: List[str] = [] - current = "" + current: List[str] = [] + for line in lines: - candidate = current + line + "\n" - if len(candidate) > max_len and current: - chunks.append(current.rstrip()) - current = line + "\n" + if line.startswith("### ") and current and len("\n".join(current)) > 200: + chunks.append("\n".join(current)) + current = [line] else: - current = candidate - if current.strip(): - chunks.append(current.rstrip()) - return chunks + current.append(line) + if len("\n".join(current)) >= max_len: + chunks.append("\n".join(current)) + current = [] + + if current: + if chunks and len("\n".join(current)) < 200: + chunks[-1] = chunks[-1] + "\n" + "\n".join(current) + else: + chunks.append("\n".join(current)) + + return chunks or [truncate_text(text)] -def _post_wecom_text(webhook_url: str, content: str) -> bool: - """ - 发送单条 text 消息到企业微信。 - - :param webhook_url: Webhook URL - :param content: 消息正文 - :return: 是否成功 - """ +def _post_wecom_markdown(webhook_url: str, content: str) -> bool: + """发送企微 Markdown 消息。""" if not webhook_url or "YOUR_WECOM_KEY" in webhook_url: print("[警告] 未配置有效的企业微信 Webhook URL。") print("--- 通知预览 ---") - print(content[:800]) + print(content[:1000]) return False payload = { - "msgtype": "text", - "text": {"content": truncate_text(content)}, + "msgtype": "markdown", + "markdown": {"content": truncate_text(content)}, } try: @@ -184,32 +196,30 @@ def send_parameter_change_notification( mentioned_users: Optional[List[str]] = None, ) -> int: """ - 发送 Controller 接口参数变更通知(支持分段)。 + 发送 Markdown 格式的接口参数变更通知。 :param webhook_url: 企微 Webhook - :param reports: AST 参数变更报告 + :param reports: 变更报告 :param push_user: 推送人 :param push_time: 推送时间 - :param llm_review: LLM 参数变更审核(可选) - :param mentioned_users: @ 成员 userid 列表 + :param llm_review: LLM 兼容性摘要 + :param mentioned_users: @ 成员(Markdown 暂不支持,保留参数) :return: 成功发送条数 """ if not reports and not llm_review: print("无接口参数变更,不发送到企业微信") return 0 - segments = build_all_notifications(reports, push_user, push_time, llm_review) - if not segments: - return 0 + full_md = build_markdown_notification(reports, push_user, push_time, llm_review) + segments = _split_markdown(full_md, MAX_MD_LENGTH) sent = 0 for i, segment in enumerate(segments): - payload_text = segment - if mentioned_users and i == 0: - # text 类型 @ 成员需放在 mentioned_list - pass # 在 _post 里处理较复杂,首条单独带 mentioned - - if _post_wecom_text(webhook_url, payload_text): + if i > 0: + segment = ( + f"(续 {i + 1}/{len(segments)})\n\n" + segment + ) + if _post_wecom_markdown(webhook_url, segment): sent += 1 print(f"第 {sent} 条通知已发送到企业微信")