py修改
All checks were successful
API接口参数变更检测 / api-param-check (push) Successful in 16s

This commit is contained in:
2026-06-04 09:53:36 +08:00
parent a8cde16c17
commit 1b19e8366e
4 changed files with 245 additions and 205 deletions

View File

@@ -32,25 +32,47 @@ class ParameterChange:
old_required: Optional[bool] = None old_required: Optional[bool] = None
detail: Optional[str] = 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 = (
'<font color="warning">必填</font>'
if req_required
else '<font color="comment">可选</font>'
)
return f'> `{self.param_type}` **{self.param_name}** · {tag}'
:return: 如 "删除: Boolean userType""重命名: String userName -> String accountName"
"""
if self.change_type == ChangeType.REMOVED: if self.change_type == ChangeType.REMOVED:
return f" - 删除: {self.param_type} {self.param_name}" return (
f'<font color="warning">【删除】</font> '
f'`{self.param_type}` ~~{self.param_name}~~'
)
if self.change_type == ChangeType.ADDED: if self.change_type == ChangeType.ADDED:
req_text = f" (是否必填:{str(self.required).lower()})" if self.required is not None else "" tag = (
return f" - 新增: {self.param_type} {self.param_name}{req_text}" '<font color="warning">必填</font>'
if req_required
else '<font color="comment">可选</font>'
)
return (
f'<font color="info">【新增】</font> '
f'`{self.param_type}` **{self.param_name}** · {tag}'
)
if self.change_type == ChangeType.RENAMED: if self.change_type == ChangeType.RENAMED:
return f" - 重命名: {self.old_type} {self.old_name} -> {self.param_type} {self.param_name}" return (
f'<font color="comment">【重命名】</font> '
f'`{self.old_type}` {self.old_name}'
f'`{self.param_type}` **{self.param_name}**'
)
if self.change_type == ChangeType.MODIFIED: if self.change_type == ChangeType.MODIFIED:
parts = [f" - 修改: {self.param_name}"] detail = f' · <font color="comment">{self.detail}</font>' if self.detail else ""
if self.detail: return f'<font color="warning">【修改】</font> **{self.param_name}**{detail}'
parts.append(f" ({self.detail})") return f'- {self.param_name}'
return "".join(parts)
return f" - {self.change_type.value}: {self.param_name}"
@dataclass @dataclass

View File

@@ -26,6 +26,44 @@ from models import ApiEndpoint, ApiParameter
MAPPING_ANNS = {"GetMapping", "PostMapping", "PutMapping", "DeleteMapping", "PatchMapping", "RequestMapping"} MAPPING_ANNS = {"GetMapping", "PostMapping", "PutMapping", "DeleteMapping", "PatchMapping", "RequestMapping"}
CONTROLLER_ANNS = {"RestController", "Controller"} 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: def _ann_simple_name(ann: Annotation) -> str:
"""获取注解简单类名。""" """获取注解简单类名。"""
@@ -272,14 +310,19 @@ class ControllerAstParser:
return None return None
def _extract_param(self, param: FormalParameter) -> List[ApiParameter]: def _extract_param(self, param: FormalParameter) -> List[ApiParameter]:
"""提取方法参数,@RequestBody 展开 DTO 字段。""" """提取方法参数,@RequestBody 展开 DTO 字段;忽略框架注入参数"""
type_name = _type_to_str(param.type) type_name = _type_to_str(param.type)
name = _param_name(param)
if _is_framework_param(type_name, name):
return []
if _has_ann(param, "RequestBody"): if _has_ann(param, "RequestBody"):
return self._expand_dto(type_name, "body") return self._expand_dto(type_name, "body")
return [ return [
ApiParameter( ApiParameter(
name=_param_name(param), name=name,
type=type_name, type=type_name,
required=_param_required(param), required=_param_required(param),
source=_param_source(param), source=_param_source(param),

View File

@@ -1,6 +1,6 @@
""" """
豆包 LLM 接口参数变更审核模块。 豆包 LLM 接口参数变更审核模块。
仅审核 Controller 层接口参数的增删改,不对 Java 源码做通用代码审查 LLM 仅输出简短的兼容性提示,详细变更由 AST + Markdown 通知展示
""" """
import json import json
@@ -10,14 +10,17 @@ import requests
from comparator import EndpointChangeReport 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: def is_llm_enabled(config: Dict[str, Any]) -> bool:
""" """判断大模型总开关是否开启。"""
判断大模型总开关是否开启。
:param config: 完整配置字典
:return: True=启用 LLM 审核
"""
return config.get("llm", {}).get("enabled", True) return config.get("llm", {}).get("enabled", True)
@@ -26,14 +29,7 @@ def call_doubao_api(
prompt: str, prompt: str,
config: Dict[str, Any], config: Dict[str, Any],
) -> Optional[str]: ) -> Optional[str]:
""" """调用豆包 API。"""
调用火山引擎豆包 Chat Completions API。
:param api_key: API Key
:param prompt: 用户提示词
:param config: 完整配置
:return: LLM 回复文本;失败返回 None
"""
if not api_key or api_key == "YOUR_DOUBAO_API_KEY": if not api_key or api_key == "YOUR_DOUBAO_API_KEY":
print("[警告] 未配置豆包 API Key跳过 LLM 审核。") print("[警告] 未配置豆包 API Key跳过 LLM 审核。")
return None return None
@@ -41,7 +37,7 @@ def call_doubao_api(
llm_cfg = config.get("llm", {}) llm_cfg = config.get("llm", {})
model = llm_cfg.get("model") or llm_cfg.get("endpoint_id", "") model = llm_cfg.get("model") or llm_cfg.get("endpoint_id", "")
if not model: if not model:
print("[警告] 未配置 llm.model 或 llm.endpoint_id,跳过 LLM 审核。") print("[警告] 未配置 llm.model跳过 LLM 审核。")
return None return None
api_url = llm_cfg.get( api_url = llm_cfg.get(
@@ -59,9 +55,9 @@ def call_doubao_api(
{ {
"role": "system", "role": "system",
"content": ( "content": (
"你是 Java Spring Boot Controller 接口参数变更分析专家。" "你是 Java Spring Boot API 变更分析专家。"
"的职责是识别并整理 Controller 层接口参数的增、删、改、重命名," "只负责输出简短的兼容性风险提示,不重复罗列接口参数明细。"
"确认 AST 解析结果是否准确,并指出对调用方的兼容性影响。" + FRAMEWORK_IGNORE_HINT
), ),
}, },
{"role": "user", "content": prompt}, {"role": "user", "content": prompt},
@@ -73,13 +69,11 @@ def call_doubao_api(
kwargs = {"headers": headers, "json": payload} kwargs = {"headers": headers, "json": payload}
if timeout is not None: if timeout is not None:
kwargs["timeout"] = timeout kwargs["timeout"] = timeout
resp = requests.post(api_url, **kwargs) resp = requests.post(api_url, **kwargs)
resp.raise_for_status() resp.raise_for_status()
data = resp.json() data = resp.json()
if "choices" in data and data["choices"]: if "choices" in data and data["choices"]:
return data["choices"][0]["message"]["content"] return data["choices"][0]["message"]["content"]
print("[错误] AI 返回格式异常")
return None return None
except requests.RequestException as exc: except requests.RequestException as exc:
print(f"[错误] 豆包 API 调用失败: {exc}") print(f"[错误] 豆包 API 调用失败: {exc}")
@@ -92,66 +86,49 @@ def build_parameter_change_prompt(
git_diff: str = "", git_diff: str = "",
) -> str: ) -> str:
""" """
构造接口参数变更审核提示词(对齐第一版需求:识别 Controller 参数增删改) 构造 LLM 提示词:只要求输出兼容性摘要,不要求重复参数列表
:param reports: AST 解析对比结果
:param changed_files: 本次变更的 Controller 文件列表
:param git_diff: 相关文件的 Git diff 内容
:return: 完整 prompt
""" """
ast_report = [] ast_report = []
for r in reports: for r in reports:
ast_report.append( ast_report.append(
{ {
"uri": f"{r.http_method} {r.uri}", "uri": f"{r.http_method} {r.uri}",
"controller": r.controller_class, "is_new": r.is_new_endpoint,
"method": r.method_name, "is_removed": r.is_removed_endpoint,
"is_new_endpoint": r.is_new_endpoint, "changes": [
"is_removed_endpoint": r.is_removed_endpoint,
"parameter_changes": [
{ {
"type": c.change_type.value, "type": c.change_type.value,
"name": c.param_name, "name": c.param_name,
"java_type": c.param_type, "java_type": c.param_type,
"old_name": c.old_name,
"old_type": c.old_type,
"required": c.required, "required": c.required,
"detail": c.detail,
} }
for c in r.parameter_changes for c in r.parameter_changes
], ],
} }
) )
diff_block = git_diff.strip() if git_diff.strip() else "(无 diff 内容" diff_block = git_diff.strip()[:6000] if git_diff.strip() else "(无)"
if len(diff_block) > 8000:
diff_block = diff_block[:8000] + "\n... [diff 过长,已截断]"
return f"""审核以下 Controller 接口参数变更,整理并确认变更结果。 return f"""根据以下 Controller 接口参数变更,**仅输出「兼容性提示」**,要求:
## 变更的 Controller 文件 {FRAMEWORK_IGNORE_HINT}
## 输出格式(严格遵守)
- 只输出 36 行 Markdown不要输出「整体说明」「接口变更详情」等标题
- 不要逐条重复 URI 和参数列表(通知里已有)
- 不要提及「排除框架注入」相关字样
- 重点说明:是否有破坏性变更、哪些必填参数调用方必须传入
- 全新 Controller 说明「均为新接口,对现有调用方无破坏」即可
- 语气简洁,可用 <font color="warning">...</font> 标注风险项
## 变更文件
{json.dumps(changed_files, ensure_ascii=False)} {json.dumps(changed_files, ensure_ascii=False)}
## AST 自动解析的参数变更报告 ## AST 变更摘要
{json.dumps(ast_report, ensure_ascii=False, indent=2)} {json.dumps(ast_report, ensure_ascii=False, indent=2)}
## Git DiffController 相关) ## Git Diff
```diff
{diff_block} {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], changed_files: List[str],
git_diff: str = "", git_diff: str = "",
) -> Optional[str]: ) -> Optional[str]:
""" """LLM 审核,返回简短兼容性提示。"""
使用 LLM 审核 Controller 接口参数变更AST 结果的二次确认与整理)。 if not is_llm_enabled(config) or not reports:
: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:
return None return None
llm_cfg = config.get("llm", {}) llm_cfg = config.get("llm", {})

View File

@@ -1,162 +1,174 @@
""" """
企业微信机器人通知模块。 企业微信 Markdown 通知模块。
按第一版模板发送 Controller 接口参数变更通知,支持超长内容分段发送 支持加粗、颜色info/comment/warning新增接口与变更接口使用不同展示模板
""" """
import json import json
import re
from typing import List, Optional from typing import List, Optional
import requests import requests
from comparator import EndpointChangeReport from comparator import EndpointChangeReport
from git_utils import CommitInfo
# 企微 text 消息字节上限约 2048留余量按字符分段 # 企微 Markdown 单条上限 4096 字符,留余量
MAX_TEXT_LENGTH = 2000 MAX_MD_LENGTH = 3800
def truncate_text(text: str, max_length: int = MAX_TEXT_LENGTH) -> str: def truncate_text(text: str, max_length: int = MAX_MD_LENGTH) -> str:
""" """截断超长消息。"""
截断文本,避免超出企微单条消息限制。
:param text: 原始文本
:param max_length: 最大字符数
:return: 截断后文本
"""
if len(text) <= max_length: if len(text) <= max_length:
return text return text
return text[:max_length] + "\n... [消息过长,已截断]" return text[:max_length] + "\n\n<font color=\"comment\">... 消息过长,已截断</font>"
def build_single_endpoint_message( def _format_endpoint_block(report: EndpointChangeReport) -> str:
report: EndpointChangeReport,
push_user: str,
push_time: str,
) -> 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 = [ uri_line = f"**{report.http_method}** `{report.uri}`"
"[API变更通知]",
f"URI: {report.http_method} {report.uri}", if report.is_removed_endpoint:
f"修改人:{push_user}", return (
f"修改时间:{push_time}", f"### <font color=\"warning\">【已删除接口】</font>\n"
] f"{uri_line}\n"
f"<font color=\"comment\">该接口已被移除</font>"
)
if report.is_new_endpoint: if report.is_new_endpoint:
lines.append("接口状态:新增接口") lines = [
f"### <font color=\"info\">【新增接口】</font> {uri_line}",
]
if report.parameter_changes: if report.parameter_changes:
lines.append("参数变更:")
for change in report.parameter_changes: for change in report.parameter_changes:
lines.append(change.to_display_line()) lines.append(change.to_markdown_line(plain=True))
elif report.is_removed_endpoint:
lines.append("接口状态:已删除接口")
lines.append(" - 整个接口已被移除")
else: else:
lines.append("参数变更:") lines.append('<font color="comment">无入参</font>')
for change in report.parameter_changes: return "\n".join(lines)
lines.append(change.to_display_line())
# 已有接口的参数变更
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('<font color="comment">(无参数变化)</font>')
return "\n".join(lines) return "\n".join(lines)
def build_all_notifications( def build_markdown_notification(
reports: List[EndpointChangeReport], reports: List[EndpointChangeReport],
push_user: str, push_user: str,
push_time: str, push_time: str,
llm_review: Optional[str] = None, llm_summary: Optional[str] = None,
) -> List[str]: ) -> str:
""" """
将所有接口变更组装为通知消息列表,超长时自动分段 构建完整 Markdown 通知正文
:param reports: 变更报告列表 :param reports: AST 变更报告
:param push_user: 推送人 :param push_user: 推送人
:param push_time: 推送时间 :param push_time: 推送时间
:param llm_review: LLM 参数变更审核结论(可选,附在最后一条或单独一条 :param llm_summary: LLM 兼容性摘要(可选,简短
:return: 待发送的消息段落列表 :return: Markdown 文本
""" """
if not reports: parts = [
return [] "## <font color=\"warning\">API 接口参数变更通知</font>",
f"**修改人:** {push_user}",
f"**修改时间:** {push_time}",
"",
]
messages: List[str] = [] # 新增 Controller全部为新接口与变更接口分组
current = "" 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: if new_reports:
block = build_single_endpoint_message(report, push_user, push_time) controllers = sorted({r.controller_class for r in new_reports})
separator = "\n\n---\n\n" parts.append(f"### <font color=\"info\">新增 Controller</font> `{', '.join(controllers)}`")
parts.append("")
for report in new_reports:
parts.append(_format_endpoint_block(report))
parts.append("")
if not current: if changed_reports:
current = block parts.append("### <font color=\"warning\">接口参数变更</font>")
elif len(current) + len(separator) + len(block) <= MAX_TEXT_LENGTH: parts.append("")
current += separator + block for report in changed_reports:
else: parts.append(_format_endpoint_block(report))
messages.append(current) parts.append("")
current = block
if current: if removed_reports:
messages.append(current) parts.append("### <font color=\"warning\">已删除接口</font>")
parts.append("")
for report in removed_reports:
parts.append(_format_endpoint_block(report))
parts.append("")
# LLM 审核结论单独或追加发送 if llm_summary:
if llm_review: cleaned = llm_summary.strip()
review_msg = f"[AI参数变更审核]\n修改人:{push_user}\n修改时间:{push_time}\n\n{llm_review}" # 去掉 LLM 可能输出的「排除框架注入」类说明
if messages and len(messages[-1]) + len(review_msg) + 4 <= MAX_TEXT_LENGTH: cleaned = re.sub(
messages[-1] += "\n\n" + review_msg r"排除Spring MVC框架自动注入的[^]+",
elif len(review_msg) <= MAX_TEXT_LENGTH: "",
messages.append(review_msg) cleaned,
else: )
messages.extend(_split_long_text(review_msg, MAX_TEXT_LENGTH)) cleaned = re.sub(
r"排除Spring MVC框架自动注入的[`\w/]+[`\w/、/]*[。\.]?",
"",
cleaned,
)
if cleaned:
parts.append("---")
parts.append("### <font color=\"comment\">兼容性提示</font>")
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") lines = text.split("\n")
chunks: List[str] = [] chunks: List[str] = []
current = "" current: List[str] = []
for line in lines: for line in lines:
candidate = current + line + "\n" if line.startswith("### ") and current and len("\n".join(current)) > 200:
if len(candidate) > max_len and current: chunks.append("\n".join(current))
chunks.append(current.rstrip()) current = [line]
current = line + "\n"
else: else:
current = candidate current.append(line)
if current.strip(): if len("\n".join(current)) >= max_len:
chunks.append(current.rstrip()) chunks.append("\n".join(current))
return chunks 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: def _post_wecom_markdown(webhook_url: str, content: str) -> bool:
""" """发送企微 Markdown 消息。"""
发送单条 text 消息到企业微信。
:param webhook_url: Webhook URL
:param content: 消息正文
:return: 是否成功
"""
if not webhook_url or "YOUR_WECOM_KEY" in webhook_url: if not webhook_url or "YOUR_WECOM_KEY" in webhook_url:
print("[警告] 未配置有效的企业微信 Webhook URL。") print("[警告] 未配置有效的企业微信 Webhook URL。")
print("--- 通知预览 ---") print("--- 通知预览 ---")
print(content[:800]) print(content[:1000])
return False return False
payload = { payload = {
"msgtype": "text", "msgtype": "markdown",
"text": {"content": truncate_text(content)}, "markdown": {"content": truncate_text(content)},
} }
try: try:
@@ -184,32 +196,30 @@ def send_parameter_change_notification(
mentioned_users: Optional[List[str]] = None, mentioned_users: Optional[List[str]] = None,
) -> int: ) -> int:
""" """
发送 Controller 接口参数变更通知(支持分段) 发送 Markdown 格式的接口参数变更通知。
:param webhook_url: 企微 Webhook :param webhook_url: 企微 Webhook
:param reports: AST 参数变更报告 :param reports: 变更报告
:param push_user: 推送人 :param push_user: 推送人
:param push_time: 推送时间 :param push_time: 推送时间
:param llm_review: LLM 参数变更审核(可选) :param llm_review: LLM 兼容性摘要
:param mentioned_users: @ 成员 userid 列表 :param mentioned_users: @ 成员Markdown 暂不支持,保留参数)
:return: 成功发送条数 :return: 成功发送条数
""" """
if not reports and not llm_review: if not reports and not llm_review:
print("无接口参数变更,不发送到企业微信") print("无接口参数变更,不发送到企业微信")
return 0 return 0
segments = build_all_notifications(reports, push_user, push_time, llm_review) full_md = build_markdown_notification(reports, push_user, push_time, llm_review)
if not segments: segments = _split_markdown(full_md, MAX_MD_LENGTH)
return 0
sent = 0 sent = 0
for i, segment in enumerate(segments): for i, segment in enumerate(segments):
payload_text = segment if i > 0:
if mentioned_users and i == 0: segment = (
# text 类型 @ 成员需放在 mentioned_list f"<font color=\"comment\">(续 {i + 1}/{len(segments)}</font>\n\n" + segment
pass # 在 _post 里处理较复杂,首条单独带 mentioned )
if _post_wecom_markdown(webhook_url, segment):
if _post_wecom_text(webhook_url, payload_text):
sent += 1 sent += 1
print(f"{sent} 条通知已发送到企业微信") print(f"{sent} 条通知已发送到企业微信")