目录del
This commit is contained in:
3
.gitea/.gitignore
vendored
3
.gitea/.gitignore
vendored
@@ -1,3 +0,0 @@
|
|||||||
# 本地/CI 运行时产生的缓存与日志(可不提交)
|
|
||||||
.cache/
|
|
||||||
logs/
|
|
||||||
102
.gitea/1.md
Normal file
102
.gitea/1.md
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
---
|
||||||
|
|
||||||
|
#### 需求拆解:
|
||||||
|
**[类变更类通知]** Vo、Dto、Model、Entity **目前只针对****<u>修改 </u>****<u><font style="background-color:#FBDE28;">删除也需要</font></u>**
|
||||||
|
|
||||||
|
**需要展示的内容:**
|
||||||
|
|
||||||
|
修改人、修改时间 (方便后续前端对接)
|
||||||
|
|
||||||
|
对象变更细节:变更了(增删改查)哪些字段 + 字段说明
|
||||||
|
|
||||||
|
影响范围:类的变更影响了哪些接口的使用(展示出影响的接口List)
|
||||||
|
|
||||||
|
* **入参影响** --> dto改变 --> 展示出影响的接口List
|
||||||
|
* **类转换影响** --> dto到entity的转换(这种类型需要 配置开关 判断是否需要检测) --> 展示出Entity类?
|
||||||
|
* **对前端的影响** --> Vo的变动 --> 展示出影响的接口List
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
#### 一、请求链路
|
||||||
|
Push
|
||||||
|
|
||||||
|
↓
|
||||||
|
|
||||||
|
Gitea Runner
|
||||||
|
|
||||||
|
↓
|
||||||
|
|
||||||
|
Pipeline
|
||||||
|
|
||||||
|
↓
|
||||||
|
|
||||||
|
GitDiff获取变更
|
||||||
|
|
||||||
|
↓
|
||||||
|
|
||||||
|
JavaParser解析(AST Controller变化\DTO变化\VO变化\ENTITY变化)
|
||||||
|
|
||||||
|
↓
|
||||||
|
|
||||||
|
企业微信通知
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
#### 二、架构&技术选型
|
||||||
|
| 组件 | 方案 |
|
||||||
|
| --- | --- |
|
||||||
|
| Git Diff | Git |
|
||||||
|
| 源码解析 | JavaParser |
|
||||||
|
| 引用分析 | JavaParser Symbol Solver |
|
||||||
|
| Spring Endpoint扫描 | Spring Mapping AST |
|
||||||
|
| 通知 | 企业微信机器人 webhook |
|
||||||
|
| CI集成 | Jenkins/GitLab CI |
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
#### 三、分层说明
|
||||||
|
| **<font style="color:rgba(20, 20, 20, 0.92);">层级</font>** | **<font style="color:rgba(20, 20, 20, 0.92);">组件</font>** | **<font style="color:rgba(20, 20, 20, 0.92);">职责</font>** |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| **<font style="color:rgba(20, 20, 20, 0.92);">触发层</font>** | <font style="color:rgba(20, 20, 20, 0.92);">Git Push</font> | <font style="color:rgba(20, 20, 20, 0.92);">开发者提交代码,触发 CI 流程</font> |
|
||||||
|
| **<font style="color:rgba(20, 20, 20, 0.92);">CI/CD 层</font>** | <font style="color:rgba(20, 20, 20, 0.92);">Gitea Runner + Pipeline</font> | <font style="color:rgba(20, 20, 20, 0.92);">监听 Push 事件,编排流水线任务</font> |
|
||||||
|
| **<font style="color:rgba(20, 20, 20, 0.92);">解析层</font>** | <font style="color:rgba(20, 20, 20, 0.92);">GitDiff + JavaParser</font> | <font style="color:rgba(20, 20, 20, 0.92);">获取 diff,按 AST 解析 Controller/DTO/VO/Entity 变更</font> |
|
||||||
|
| **<font style="color:rgba(20, 20, 20, 0.92);">通知层</font>** | <font style="color:rgba(20, 20, 20, 0.92);">企业微信</font> | <font style="color:rgba(20, 20, 20, 0.92);">将分析结果推送给相关开发人员</font> |
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
#### 四、通知模版
|
||||||
|
【类变更通知】
|
||||||
|
|
||||||
|
■ 变更对象:{ClassName}({类类型:Dto/Vo/Entity/Model})
|
||||||
|
■ 修改人:{Modifier}
|
||||||
|
■ 修改时间:{ModifyTime}
|
||||||
|
|
||||||
|
────────────────────────────────
|
||||||
|
▶ 对象变更细节
|
||||||
|
────────────────────────────────
|
||||||
|
字段变更列表:
|
||||||
|
[新增] 字段名: fieldA 说明: {字段说明}
|
||||||
|
[删除] 字段名: fieldB 说明: {字段说明}
|
||||||
|
[修改] 字段名: fieldC 说明: {原说明 → 新说明 / 类型变更等}
|
||||||
|
|
||||||
|
────────────────────────────────
|
||||||
|
▶ 影响范围
|
||||||
|
────────────────────────────────
|
||||||
|
① 入参影响(Dto变更导致接口参数变化):
|
||||||
|
影响接口列表:
|
||||||
|
- GET /api/attendance/dayStaList
|
||||||
|
- POST /api/attendance/export
|
||||||
|
(若无影响则显示:无)
|
||||||
|
|
||||||
|
② 类转换影响(Dto → Entity 转换,已开启检测):
|
||||||
|
涉及Entity类:{EntityClassName}
|
||||||
|
(若开关关闭或未检测到影响则显示:无 / 未开启检测)
|
||||||
|
|
||||||
|
③ 前端影响(Vo变更导致返回结构变化):
|
||||||
|
影响接口列表:
|
||||||
|
- GET /api/attendance/detail
|
||||||
|
- GET /api/attendance/summary
|
||||||
|
(若无影响则显示:无)
|
||||||
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
"""
|
|
||||||
变更日志持久化模块。
|
|
||||||
受 log.enabled 开关控制,默认关闭;仅记录接口参数变更及 LLM 审核结果。
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
|
|
||||||
from comparator import EndpointChangeReport
|
|
||||||
from git_utils import CommitInfo
|
|
||||||
|
|
||||||
|
|
||||||
def is_log_enabled(config: Dict[str, Any]) -> bool:
|
|
||||||
"""判断日志总开关是否开启。"""
|
|
||||||
return config.get("log", {}).get("enabled", False)
|
|
||||||
|
|
||||||
|
|
||||||
def _serialize_reports(reports: List[EndpointChangeReport]) -> List[dict]:
|
|
||||||
"""将参数变更报告序列化为 JSON 结构。"""
|
|
||||||
result = []
|
|
||||||
for r in reports:
|
|
||||||
result.append(
|
|
||||||
{
|
|
||||||
"uri": r.uri,
|
|
||||||
"http_method": r.http_method,
|
|
||||||
"controller_class": r.controller_class,
|
|
||||||
"method_name": r.method_name,
|
|
||||||
"is_new_endpoint": r.is_new_endpoint,
|
|
||||||
"is_removed_endpoint": r.is_removed_endpoint,
|
|
||||||
"parameter_changes": [
|
|
||||||
{
|
|
||||||
"change_type": c.change_type.value,
|
|
||||||
"param_name": c.param_name,
|
|
||||||
"param_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
|
|
||||||
],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def save_to_file(
|
|
||||||
reports: List[EndpointChangeReport],
|
|
||||||
commit_info: CommitInfo,
|
|
||||||
log_dir: str,
|
|
||||||
llm_review: Optional[str] = None,
|
|
||||||
) -> Path:
|
|
||||||
"""写入 JSON 日志文件。"""
|
|
||||||
log_path = Path(log_dir)
|
|
||||||
log_path.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
short_sha = commit_info.sha[:8]
|
|
||||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
||||||
output = log_path / f"{timestamp}_{short_sha}.json"
|
|
||||||
|
|
||||||
record = {
|
|
||||||
"commit_sha": commit_info.sha,
|
|
||||||
"author": commit_info.author,
|
|
||||||
"commit_time": commit_info.commit_time,
|
|
||||||
"message": commit_info.message,
|
|
||||||
"detected_at": datetime.now().isoformat(),
|
|
||||||
"change_count": len(reports),
|
|
||||||
"parameter_changes": _serialize_reports(reports),
|
|
||||||
"llm_review": llm_review,
|
|
||||||
}
|
|
||||||
|
|
||||||
with open(output, "w", encoding="utf-8") as f:
|
|
||||||
json.dump(record, f, ensure_ascii=False, indent=2)
|
|
||||||
|
|
||||||
print(f"[日志] 参数变更记录已写入: {output}")
|
|
||||||
return output
|
|
||||||
|
|
||||||
|
|
||||||
def save_to_mysql(
|
|
||||||
reports: List[EndpointChangeReport],
|
|
||||||
commit_info: CommitInfo,
|
|
||||||
mysql_config: Dict[str, Any],
|
|
||||||
llm_review: Optional[str] = None,
|
|
||||||
) -> bool:
|
|
||||||
"""写入 MySQL。"""
|
|
||||||
try:
|
|
||||||
import pymysql
|
|
||||||
except ImportError:
|
|
||||||
print("[错误] MySQL 模式需要: pip install pymysql")
|
|
||||||
return False
|
|
||||||
|
|
||||||
host = mysql_config.get("host", "")
|
|
||||||
if not host or host == "YOUR_MYSQL_HOST":
|
|
||||||
print("[警告] 未配置 MySQL,跳过写入。")
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
conn = pymysql.connect(
|
|
||||||
host=host,
|
|
||||||
port=int(mysql_config.get("port", 3306)),
|
|
||||||
user=mysql_config.get("user"),
|
|
||||||
password=mysql_config.get("password"),
|
|
||||||
database=mysql_config.get("database"),
|
|
||||||
charset="utf8mb4",
|
|
||||||
)
|
|
||||||
table = mysql_config.get("table", "api_change_logs")
|
|
||||||
payload = json.dumps(_serialize_reports(reports), ensure_ascii=False)
|
|
||||||
|
|
||||||
with conn.cursor() as cursor:
|
|
||||||
sql = f"""
|
|
||||||
INSERT INTO `{table}`
|
|
||||||
(commit_sha, author, commit_time, commit_message, change_count, reports_json, llm_review, created_at)
|
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s, NOW())
|
|
||||||
"""
|
|
||||||
cursor.execute(
|
|
||||||
sql,
|
|
||||||
(
|
|
||||||
commit_info.sha,
|
|
||||||
commit_info.author,
|
|
||||||
commit_info.commit_time,
|
|
||||||
commit_info.message,
|
|
||||||
len(reports),
|
|
||||||
payload,
|
|
||||||
llm_review,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
print(f"[日志] 已写入 MySQL: {table}")
|
|
||||||
return True
|
|
||||||
except Exception as exc:
|
|
||||||
print(f"[错误] MySQL 写入失败: {exc}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def persist_change_log(
|
|
||||||
reports: List[EndpointChangeReport],
|
|
||||||
commit_info: CommitInfo,
|
|
||||||
config: Dict[str, Any],
|
|
||||||
llm_review: Optional[str] = None,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
根据 log.enabled 决定是否持久化接口参数变更日志。
|
|
||||||
|
|
||||||
:param reports: 参数变更报告
|
|
||||||
:param commit_info: 提交信息
|
|
||||||
:param config: 完整配置
|
|
||||||
:param llm_review: LLM 参数变更审核结论
|
|
||||||
"""
|
|
||||||
if not is_log_enabled(config):
|
|
||||||
print("[日志] 日志开关已关闭(log.enabled=false),跳过写入。")
|
|
||||||
return
|
|
||||||
|
|
||||||
log_cfg = config.get("log", {})
|
|
||||||
if log_cfg.get("storage") == "mysql":
|
|
||||||
save_to_mysql(reports, commit_info, log_cfg.get("mysql", {}), llm_review)
|
|
||||||
else:
|
|
||||||
save_to_file(
|
|
||||||
reports,
|
|
||||||
commit_info,
|
|
||||||
log_cfg.get("file_dir", ".gitea/logs/api-changes"),
|
|
||||||
llm_review,
|
|
||||||
)
|
|
||||||
@@ -1,449 +0,0 @@
|
|||||||
"""
|
|
||||||
API 参数变更对比模块。
|
|
||||||
对比新旧两个版本的 Controller 端点,识别参数的增、删、改、重命名。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from enum import Enum
|
|
||||||
from typing import Dict, List, Optional, Set, Tuple
|
|
||||||
|
|
||||||
from models import ApiEndpoint, ApiParameter
|
|
||||||
|
|
||||||
|
|
||||||
class ChangeType(str, Enum):
|
|
||||||
"""参数变更类型。"""
|
|
||||||
|
|
||||||
ADDED = "added"
|
|
||||||
REMOVED = "removed"
|
|
||||||
MODIFIED = "modified"
|
|
||||||
RENAMED = "renamed"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ParameterChange:
|
|
||||||
"""单条参数变更记录。"""
|
|
||||||
|
|
||||||
change_type: ChangeType
|
|
||||||
param_name: str
|
|
||||||
param_type: Optional[str] = None
|
|
||||||
old_name: Optional[str] = None
|
|
||||||
old_type: Optional[str] = None
|
|
||||||
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"
|
|
||||||
parent_dto: Optional[str] = None
|
|
||||||
body_param_name: Optional[str] = None
|
|
||||||
|
|
||||||
def _change_tag(self) -> str:
|
|
||||||
"""变更类型标签(企微颜色)。"""
|
|
||||||
tags = {
|
|
||||||
ChangeType.ADDED: '<font color="info">**新增**</font>',
|
|
||||||
ChangeType.REMOVED: '<font color="warning">**删除**</font>',
|
|
||||||
ChangeType.RENAMED: '<font color="comment">**重命名**</font>',
|
|
||||||
ChangeType.MODIFIED: '<font color="warning">**修改**</font>',
|
|
||||||
}
|
|
||||||
return tags.get(self.change_type, "")
|
|
||||||
|
|
||||||
def _required_tag(self) -> str:
|
|
||||||
"""必填/可选标签。"""
|
|
||||||
if self.required is True:
|
|
||||||
return '<font color="warning">必填</font>'
|
|
||||||
if self.required is False:
|
|
||||||
return '<font color="comment">可选</font>'
|
|
||||||
return ""
|
|
||||||
|
|
||||||
def to_markdown_block(self, index: int = 1) -> str:
|
|
||||||
"""格式化为企微友好的参数变更卡片(列表式,非表格)。"""
|
|
||||||
lines: List[str] = []
|
|
||||||
desc = self.description or self.old_description
|
|
||||||
|
|
||||||
if self.change_type == ChangeType.RENAMED:
|
|
||||||
lines.append(f"**{index}. `{self.param_name}`** {self._change_tag()}")
|
|
||||||
lines.append(f"> `{self.old_name}` → `{self.param_name}`")
|
|
||||||
if desc:
|
|
||||||
lines.append(f"> 说明:{desc}")
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
if self.change_type == ChangeType.ADDED:
|
|
||||||
type_part = f" · `{self.param_type}`" if self.param_type else ""
|
|
||||||
req_part = f" · {self._required_tag()}" if self._required_tag() else ""
|
|
||||||
lines.append(
|
|
||||||
f"**{index}. `{self.param_name}`**{type_part}{req_part} {self._change_tag()}"
|
|
||||||
)
|
|
||||||
if desc:
|
|
||||||
lines.append(f"> 说明:{desc}")
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
if self.change_type == ChangeType.REMOVED:
|
|
||||||
type_part = f" · `{self.param_type}`" if self.param_type else ""
|
|
||||||
lines.append(
|
|
||||||
f"**{index}. `{self.param_name}`**{type_part} {self._change_tag()}"
|
|
||||||
)
|
|
||||||
if desc:
|
|
||||||
lines.append(f"> 说明:{desc}")
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
# MODIFIED
|
|
||||||
lines.append(f"**{index}. `{self.param_name}`** {self._change_tag()}")
|
|
||||||
if desc:
|
|
||||||
lines.append(f"> 说明:{desc}")
|
|
||||||
if self.detail:
|
|
||||||
lines.append(f"> 变更:{self.detail}")
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
def to_table_row(self) -> str:
|
|
||||||
"""兼容旧调用,委托至卡片块。"""
|
|
||||||
return self.to_markdown_block(1)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class EndpointChangeReport:
|
|
||||||
"""单个接口的变更报告。"""
|
|
||||||
|
|
||||||
uri: str
|
|
||||||
http_method: str
|
|
||||||
controller_class: str
|
|
||||||
method_name: str
|
|
||||||
source_file: str = ""
|
|
||||||
parameter_changes: List[ParameterChange] = field(default_factory=list)
|
|
||||||
is_new_endpoint: bool = False
|
|
||||||
is_removed_endpoint: bool = False
|
|
||||||
is_renamed_endpoint: bool = False
|
|
||||||
old_uri: Optional[str] = None
|
|
||||||
is_method_changed: bool = False
|
|
||||||
old_http_method: Optional[str] = None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def has_changes(self) -> bool:
|
|
||||||
"""是否存在任何变更。"""
|
|
||||||
return (
|
|
||||||
self.is_new_endpoint
|
|
||||||
or self.is_removed_endpoint
|
|
||||||
or self.is_renamed_endpoint
|
|
||||||
or self.is_method_changed
|
|
||||||
or len(self.parameter_changes) > 0
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def endpoint_key(self) -> str:
|
|
||||||
return f"{self.http_method} {self.uri}"
|
|
||||||
|
|
||||||
|
|
||||||
def _param_key(p: ApiParameter) -> Tuple[str, str]:
|
|
||||||
"""参数匹配键:(source, name)。"""
|
|
||||||
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]:
|
|
||||||
"""
|
|
||||||
对比同一接口新旧版本的参数列表,识别增删改及重命名。
|
|
||||||
|
|
||||||
重命名启发式:若删除与新增参数类型相同且 source 相同,则视为重命名。
|
|
||||||
|
|
||||||
:param old_params: 旧版本参数
|
|
||||||
:param new_params: 新版本参数
|
|
||||||
:return: 变更列表
|
|
||||||
"""
|
|
||||||
changes: List[ParameterChange] = []
|
|
||||||
|
|
||||||
old_map: Dict[Tuple[str, str], ApiParameter] = {_param_key(p): p for p in old_params}
|
|
||||||
new_map: Dict[Tuple[str, str], ApiParameter] = {_param_key(p): p for p in new_params}
|
|
||||||
|
|
||||||
old_keys = set(old_map.keys())
|
|
||||||
new_keys = set(new_map.keys())
|
|
||||||
|
|
||||||
removed_keys = old_keys - new_keys
|
|
||||||
added_keys = new_keys - old_keys
|
|
||||||
common_keys = old_keys & new_keys
|
|
||||||
|
|
||||||
# 1. 共同参数:检查类型、必填等属性变更
|
|
||||||
for key in common_keys:
|
|
||||||
old_p = old_map[key]
|
|
||||||
new_p = new_map[key]
|
|
||||||
detail_parts = []
|
|
||||||
if 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:
|
|
||||||
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(
|
|
||||||
change_type=ChangeType.MODIFIED,
|
|
||||||
param_name=new_p.name,
|
|
||||||
param_type=new_p.type,
|
|
||||||
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,
|
|
||||||
parent_dto=new_p.parent_dto,
|
|
||||||
body_param_name=new_p.body_param_name,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# 2. 重命名检测:在 removed + added 中找同 type + source 的配对
|
|
||||||
unmatched_removed: List[Tuple[Tuple[str, str], ApiParameter]] = []
|
|
||||||
unmatched_added: List[Tuple[Tuple[str, str], ApiParameter]] = []
|
|
||||||
|
|
||||||
for key in removed_keys:
|
|
||||||
unmatched_removed.append((key, old_map[key]))
|
|
||||||
for key in added_keys:
|
|
||||||
unmatched_added.append((key, new_map[key]))
|
|
||||||
|
|
||||||
matched_removed: Set[Tuple[str, str]] = set()
|
|
||||||
matched_added: Set[Tuple[str, str]] = set()
|
|
||||||
|
|
||||||
for r_key, r_param in unmatched_removed:
|
|
||||||
for a_key, a_param in unmatched_added:
|
|
||||||
if a_key in matched_added:
|
|
||||||
continue
|
|
||||||
if r_param.type == a_param.type and r_param.source == a_param.source:
|
|
||||||
changes.append(
|
|
||||||
ParameterChange(
|
|
||||||
change_type=ChangeType.RENAMED,
|
|
||||||
param_name=a_param.name,
|
|
||||||
param_type=a_param.type,
|
|
||||||
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,
|
|
||||||
parent_dto=a_param.parent_dto,
|
|
||||||
body_param_name=a_param.body_param_name,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
matched_removed.add(r_key)
|
|
||||||
matched_added.add(a_key)
|
|
||||||
break
|
|
||||||
|
|
||||||
# 3. 纯删除
|
|
||||||
for key, param in unmatched_removed:
|
|
||||||
if key not in matched_removed:
|
|
||||||
changes.append(
|
|
||||||
ParameterChange(
|
|
||||||
change_type=ChangeType.REMOVED,
|
|
||||||
param_name=param.name,
|
|
||||||
param_type=param.type,
|
|
||||||
description=param.description,
|
|
||||||
source=param.source,
|
|
||||||
parent_dto=param.parent_dto,
|
|
||||||
body_param_name=param.body_param_name,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# 4. 纯新增
|
|
||||||
for key, param in unmatched_added:
|
|
||||||
if key not in matched_added:
|
|
||||||
changes.append(
|
|
||||||
ParameterChange(
|
|
||||||
change_type=ChangeType.ADDED,
|
|
||||||
param_name=param.name,
|
|
||||||
param_type=param.type,
|
|
||||||
required=param.required,
|
|
||||||
description=param.description,
|
|
||||||
source=param.source,
|
|
||||||
parent_dto=param.parent_dto,
|
|
||||||
body_param_name=param.body_param_name,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return changes
|
|
||||||
|
|
||||||
|
|
||||||
def compare_endpoints(
|
|
||||||
old_endpoints: Dict[str, ApiEndpoint],
|
|
||||||
new_endpoints: Dict[str, ApiEndpoint],
|
|
||||||
) -> List[EndpointChangeReport]:
|
|
||||||
"""
|
|
||||||
对比新旧两个版本的全部 Controller 端点,生成变更报告列表。
|
|
||||||
|
|
||||||
支持以下变更类型检测:
|
|
||||||
- HTTP 方法变更(GET → POST 等)
|
|
||||||
- URI 路径变更(路径重命名)
|
|
||||||
- 新增 / 删除接口
|
|
||||||
- 参数变更
|
|
||||||
"""
|
|
||||||
reports: List[EndpointChangeReport] = []
|
|
||||||
|
|
||||||
old_keys = set(old_endpoints.keys())
|
|
||||||
new_keys = set(new_endpoints.keys())
|
|
||||||
|
|
||||||
removed_keys = old_keys - new_keys
|
|
||||||
added_keys = new_keys - old_keys
|
|
||||||
common_keys = old_keys & new_keys
|
|
||||||
|
|
||||||
# 收集未匹配的 removed / added
|
|
||||||
unmatched_removed: List[Tuple[str, ApiEndpoint]] = []
|
|
||||||
unmatched_added: List[Tuple[str, ApiEndpoint]] = []
|
|
||||||
|
|
||||||
for key in removed_keys:
|
|
||||||
unmatched_removed.append((key, old_endpoints[key]))
|
|
||||||
for key in added_keys:
|
|
||||||
unmatched_added.append((key, new_endpoints[key]))
|
|
||||||
|
|
||||||
matched_removed: Set[str] = set()
|
|
||||||
matched_added: Set[str] = set()
|
|
||||||
|
|
||||||
# 1. HTTP 方法变更检测(uri + controller 相同,但 method 不同)
|
|
||||||
# 放宽匹配条件:只要同一个 Controller 的同一个 URI 请求方式改变,就识别为「修改请求方式」
|
|
||||||
# 不再要求 method_name 相同(允许方法重命名场景)
|
|
||||||
# 如果同时有参数变更,生成两条独立报告(方法变更 + 参数变更),互不干扰
|
|
||||||
for r_key, r_ep in unmatched_removed:
|
|
||||||
for a_key, a_ep in unmatched_added:
|
|
||||||
if a_key in matched_added:
|
|
||||||
continue
|
|
||||||
if (
|
|
||||||
r_ep.uri == a_ep.uri
|
|
||||||
and r_ep.controller_class == a_ep.controller_class
|
|
||||||
and r_ep.http_method != a_ep.http_method
|
|
||||||
):
|
|
||||||
# 先生成纯方法变更报告
|
|
||||||
reports.append(
|
|
||||||
EndpointChangeReport(
|
|
||||||
uri=a_ep.uri,
|
|
||||||
http_method=a_ep.http_method,
|
|
||||||
controller_class=a_ep.controller_class,
|
|
||||||
method_name=a_ep.method_name,
|
|
||||||
source_file=a_ep.source_file,
|
|
||||||
is_method_changed=True,
|
|
||||||
old_http_method=r_ep.http_method,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
# 再检测参数变更,如果有则额外生成参数报告
|
|
||||||
param_changes = compare_parameters(r_ep.parameters, a_ep.parameters)
|
|
||||||
if param_changes:
|
|
||||||
reports.append(
|
|
||||||
EndpointChangeReport(
|
|
||||||
uri=a_ep.uri,
|
|
||||||
http_method=a_ep.http_method,
|
|
||||||
controller_class=a_ep.controller_class,
|
|
||||||
method_name=a_ep.method_name,
|
|
||||||
source_file=a_ep.source_file,
|
|
||||||
parameter_changes=param_changes,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
matched_removed.add(r_key)
|
|
||||||
matched_added.add(a_key)
|
|
||||||
break
|
|
||||||
|
|
||||||
# 2. URI 路径变更检测(method + controller + method_name 相同,但 uri 不同)
|
|
||||||
# 如果同时有参数变更,生成两条独立报告(路径变更 + 参数变更),互不干扰
|
|
||||||
for r_key, r_ep in unmatched_removed:
|
|
||||||
if r_key in matched_removed:
|
|
||||||
continue
|
|
||||||
for a_key, a_ep in unmatched_added:
|
|
||||||
if a_key in matched_added:
|
|
||||||
continue
|
|
||||||
if (
|
|
||||||
r_ep.http_method == a_ep.http_method
|
|
||||||
and r_ep.controller_class == a_ep.controller_class
|
|
||||||
and r_ep.method_name == a_ep.method_name
|
|
||||||
and r_ep.uri != a_ep.uri
|
|
||||||
):
|
|
||||||
# 先生成纯路径变更报告
|
|
||||||
reports.append(
|
|
||||||
EndpointChangeReport(
|
|
||||||
uri=a_ep.uri,
|
|
||||||
http_method=a_ep.http_method,
|
|
||||||
controller_class=a_ep.controller_class,
|
|
||||||
method_name=a_ep.method_name,
|
|
||||||
source_file=a_ep.source_file,
|
|
||||||
is_renamed_endpoint=True,
|
|
||||||
old_uri=r_ep.uri,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
# 再检测参数变更,如果有则额外生成参数报告
|
|
||||||
param_changes = compare_parameters(r_ep.parameters, a_ep.parameters)
|
|
||||||
if param_changes:
|
|
||||||
reports.append(
|
|
||||||
EndpointChangeReport(
|
|
||||||
uri=a_ep.uri,
|
|
||||||
http_method=a_ep.http_method,
|
|
||||||
controller_class=a_ep.controller_class,
|
|
||||||
method_name=a_ep.method_name,
|
|
||||||
source_file=a_ep.source_file,
|
|
||||||
parameter_changes=param_changes,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
matched_removed.add(r_key)
|
|
||||||
matched_added.add(a_key)
|
|
||||||
break
|
|
||||||
|
|
||||||
# 3. 剩余未匹配的 removed → 删除接口
|
|
||||||
for key, ep in unmatched_removed:
|
|
||||||
if key not in matched_removed:
|
|
||||||
reports.append(
|
|
||||||
EndpointChangeReport(
|
|
||||||
uri=ep.uri,
|
|
||||||
http_method=ep.http_method,
|
|
||||||
controller_class=ep.controller_class,
|
|
||||||
method_name=ep.method_name,
|
|
||||||
source_file=ep.source_file,
|
|
||||||
is_removed_endpoint=True,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# 4. 剩余未匹配的 added → 新增接口
|
|
||||||
for key, ep in unmatched_added:
|
|
||||||
if key not in matched_added:
|
|
||||||
reports.append(
|
|
||||||
EndpointChangeReport(
|
|
||||||
uri=ep.uri,
|
|
||||||
http_method=ep.http_method,
|
|
||||||
controller_class=ep.controller_class,
|
|
||||||
method_name=ep.method_name,
|
|
||||||
source_file=ep.source_file,
|
|
||||||
is_new_endpoint=True,
|
|
||||||
parameter_changes=[
|
|
||||||
ParameterChange(
|
|
||||||
change_type=ChangeType.ADDED,
|
|
||||||
param_name=p.name,
|
|
||||||
param_type=p.type,
|
|
||||||
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
|
|
||||||
],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# 5. 共同 URI:对比参数变更
|
|
||||||
for key in common_keys:
|
|
||||||
old_ep = old_endpoints[key]
|
|
||||||
new_ep = new_endpoints[key]
|
|
||||||
param_changes = compare_parameters(old_ep.parameters, new_ep.parameters)
|
|
||||||
if param_changes:
|
|
||||||
reports.append(
|
|
||||||
EndpointChangeReport(
|
|
||||||
uri=new_ep.uri,
|
|
||||||
http_method=new_ep.http_method,
|
|
||||||
controller_class=new_ep.controller_class,
|
|
||||||
method_name=new_ep.method_name,
|
|
||||||
source_file=new_ep.source_file,
|
|
||||||
parameter_changes=param_changes,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return [r for r in reports if r.has_changes]
|
|
||||||
@@ -1,558 +0,0 @@
|
|||||||
"""
|
|
||||||
纯 Python Controller AST 解析器(基于 javalang,无需 Java/Maven)。
|
|
||||||
仅解析 Spring @RestController / @Controller 的接口映射与参数。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Dict, List, Optional
|
|
||||||
|
|
||||||
import javalang
|
|
||||||
from javalang.tree import (
|
|
||||||
Annotation,
|
|
||||||
ClassDeclaration,
|
|
||||||
ElementValuePair,
|
|
||||||
FieldDeclaration,
|
|
||||||
FormalParameter,
|
|
||||||
Literal,
|
|
||||||
MemberReference,
|
|
||||||
MethodDeclaration,
|
|
||||||
)
|
|
||||||
|
|
||||||
from models import ApiEndpoint, ApiParameter
|
|
||||||
|
|
||||||
# javax.validation 必填注解
|
|
||||||
REQUIRED_FIELD_ANNS = {"NotNull", "NotEmpty", "NotBlank"}
|
|
||||||
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:
|
|
||||||
"""获取注解简单类名。"""
|
|
||||||
return ann.name.split(".")[-1]
|
|
||||||
|
|
||||||
|
|
||||||
def _literal_str(node) -> str:
|
|
||||||
"""从 AST 节点提取字符串或布尔值。"""
|
|
||||||
if node is None:
|
|
||||||
return ""
|
|
||||||
if isinstance(node, Literal):
|
|
||||||
v = node.value
|
|
||||||
if isinstance(v, bool):
|
|
||||||
return str(v).lower()
|
|
||||||
return str(v or "").strip('"').strip("'")
|
|
||||||
if isinstance(node, MemberReference):
|
|
||||||
return node.member
|
|
||||||
if isinstance(node, bool):
|
|
||||||
return str(node).lower()
|
|
||||||
return str(node).strip('"').strip("'")
|
|
||||||
|
|
||||||
|
|
||||||
def _collect_ann_members(ann: Annotation) -> Dict[str, str]:
|
|
||||||
"""
|
|
||||||
解析注解成员为字典。
|
|
||||||
支持 @GetMapping("/path") 与 @RequestParam(value="x", required=false)。
|
|
||||||
"""
|
|
||||||
members: Dict[str, str] = {}
|
|
||||||
el = ann.element
|
|
||||||
if el is None:
|
|
||||||
return members
|
|
||||||
if isinstance(el, ElementValuePair):
|
|
||||||
members[el.name] = _literal_str(el.value)
|
|
||||||
elif isinstance(el, list):
|
|
||||||
for item in el:
|
|
||||||
if isinstance(item, ElementValuePair):
|
|
||||||
members[item.name] = _literal_str(item.value)
|
|
||||||
else:
|
|
||||||
members["value"] = _literal_str(el)
|
|
||||||
return members
|
|
||||||
|
|
||||||
|
|
||||||
def _ann_string(ann: Annotation, *keys: str) -> str:
|
|
||||||
"""从注解提取字符串属性。"""
|
|
||||||
members = _collect_ann_members(ann)
|
|
||||||
for k in keys:
|
|
||||||
if k in members and members[k]:
|
|
||||||
return members[k]
|
|
||||||
if "value" in members:
|
|
||||||
return members["value"]
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def _type_to_str(type_node) -> str:
|
|
||||||
"""Java 类型节点转字符串。"""
|
|
||||||
if type_node is None:
|
|
||||||
return "Object"
|
|
||||||
if isinstance(type_node, javalang.tree.BasicType):
|
|
||||||
return type_node.name
|
|
||||||
if isinstance(type_node, javalang.tree.ReferenceType):
|
|
||||||
name = type_node.name or "Object"
|
|
||||||
if type_node.arguments:
|
|
||||||
args = ",".join(_type_to_str(a.type) for a in type_node.arguments)
|
|
||||||
return f"{name}<{args}>"
|
|
||||||
if type_node.sub_type:
|
|
||||||
return _type_to_str(type_node.sub_type)
|
|
||||||
return name
|
|
||||||
return str(type_node)
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_path(path: str) -> str:
|
|
||||||
"""规范化 URI 路径。"""
|
|
||||||
if not path or not path.strip():
|
|
||||||
return ""
|
|
||||||
path = path.strip()
|
|
||||||
if not path.startswith("/"):
|
|
||||||
path = "/" + path
|
|
||||||
return re.sub(r"/+", "/", path)
|
|
||||||
|
|
||||||
|
|
||||||
def _join_paths(base: str, sub: str) -> str:
|
|
||||||
"""拼接类级与方法级路径。"""
|
|
||||||
b, m = _normalize_path(base), _normalize_path(sub)
|
|
||||||
if not b:
|
|
||||||
return m or "/"
|
|
||||||
if not m:
|
|
||||||
return b
|
|
||||||
if b.endswith("/") and m.startswith("/"):
|
|
||||||
return b + m[1:]
|
|
||||||
if not b.endswith("/") and not m.startswith("/"):
|
|
||||||
return b + "/" + m
|
|
||||||
return b + m
|
|
||||||
|
|
||||||
|
|
||||||
def _http_method(ann_name: str, ann: Annotation) -> str:
|
|
||||||
"""从映射注解推断 HTTP 方法。
|
|
||||||
|
|
||||||
支持大小写不敏感匹配,避免 PUTMapping、POSTMapping 等不规范写法导致解析失败。
|
|
||||||
"""
|
|
||||||
mapping = {
|
|
||||||
"GetMapping": "GET",
|
|
||||||
"PostMapping": "POST",
|
|
||||||
"PutMapping": "PUT",
|
|
||||||
"DeleteMapping": "DELETE",
|
|
||||||
"PatchMapping": "PATCH",
|
|
||||||
}
|
|
||||||
# 大小写不敏感匹配
|
|
||||||
for key, value in mapping.items():
|
|
||||||
if key.lower() == ann_name.lower():
|
|
||||||
return value
|
|
||||||
if ann_name.lower() == "requestmapping":
|
|
||||||
m = _ann_string(ann, "method")
|
|
||||||
if m:
|
|
||||||
return m.replace("RequestMethod.", "").upper()
|
|
||||||
return "GET"
|
|
||||||
return "GET"
|
|
||||||
|
|
||||||
|
|
||||||
def _has_ann(node, name: str) -> bool:
|
|
||||||
"""节点是否含指定注解。"""
|
|
||||||
anns = getattr(node, "annotations", None) or []
|
|
||||||
return any(_ann_simple_name(a) == name for a in anns)
|
|
||||||
|
|
||||||
|
|
||||||
def _find_ann(node, name: str) -> Optional[Annotation]:
|
|
||||||
"""查找指定注解。"""
|
|
||||||
for a in getattr(node, "annotations", None) or []:
|
|
||||||
if _ann_simple_name(a) == name:
|
|
||||||
return a
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _param_source(param: FormalParameter) -> str:
|
|
||||||
"""参数来源:path / query / header / form / body。"""
|
|
||||||
if _has_ann(param, "PathVariable"):
|
|
||||||
return "path"
|
|
||||||
if _has_ann(param, "RequestHeader"):
|
|
||||||
return "header"
|
|
||||||
if _has_ann(param, "RequestPart") or _has_ann(param, "ModelAttribute"):
|
|
||||||
return "form"
|
|
||||||
if _has_ann(param, "RequestBody"):
|
|
||||||
return "body"
|
|
||||||
return "query"
|
|
||||||
|
|
||||||
|
|
||||||
def _param_name(param: FormalParameter) -> str:
|
|
||||||
"""解析参数名。"""
|
|
||||||
for ann_name in ("RequestParam", "PathVariable", "RequestHeader", "RequestPart"):
|
|
||||||
ann = _find_ann(param, ann_name)
|
|
||||||
if ann:
|
|
||||||
val = _ann_string(ann, "value", "name")
|
|
||||||
if val:
|
|
||||||
return val
|
|
||||||
return param.name
|
|
||||||
|
|
||||||
|
|
||||||
def _param_required(param: FormalParameter) -> bool:
|
|
||||||
"""是否必填。"""
|
|
||||||
ann = _find_ann(param, "RequestParam")
|
|
||||||
if ann:
|
|
||||||
members = _collect_ann_members(ann)
|
|
||||||
if members.get("required", "").lower() == "false":
|
|
||||||
return False
|
|
||||||
type_name = _type_to_str(param.type)
|
|
||||||
if type_name.startswith("Optional"):
|
|
||||||
return False
|
|
||||||
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 _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]:
|
|
||||||
"""按注解名或形参名匹配 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 解析器。
|
|
||||||
只解析传入的文件,不扫描整个目录(CI 更快)。
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, repo_root: Path, source_dirs: List[Path]):
|
|
||||||
"""
|
|
||||||
:param repo_root: 仓库根目录
|
|
||||||
:param source_dirs: Java 源码根目录列表(用于查找 DTO 等)
|
|
||||||
"""
|
|
||||||
self.repo_root = repo_root
|
|
||||||
self.source_dirs = source_dirs
|
|
||||||
self._dto_cache: Dict[str, List[ApiParameter]] = {}
|
|
||||||
self._current_source = ""
|
|
||||||
|
|
||||||
def parse_file_content(self, source: str, repo_relative_path: str) -> List[ApiEndpoint]:
|
|
||||||
"""
|
|
||||||
解析单个 Java 源文件内容。
|
|
||||||
|
|
||||||
:param source: 源码文本
|
|
||||||
:param repo_relative_path: 相对仓库根目录的路径(与 git diff 一致)
|
|
||||||
:return: 端点列表
|
|
||||||
"""
|
|
||||||
endpoints: List[ApiEndpoint] = []
|
|
||||||
self._current_source = source
|
|
||||||
try:
|
|
||||||
tree = javalang.parse.parse(source)
|
|
||||||
except (javalang.parser.JavaSyntaxError, RecursionError) as exc:
|
|
||||||
print(f"[警告] 解析失败 {repo_relative_path}: {exc}")
|
|
||||||
return endpoints
|
|
||||||
|
|
||||||
for type_decl in tree.types or []:
|
|
||||||
if not isinstance(type_decl, ClassDeclaration):
|
|
||||||
continue
|
|
||||||
if not self._is_controller(type_decl):
|
|
||||||
continue
|
|
||||||
|
|
||||||
class_path = ""
|
|
||||||
for ann in type_decl.annotations or []:
|
|
||||||
if _ann_simple_name(ann) == "RequestMapping":
|
|
||||||
class_path = _ann_string(ann, "value", "path")
|
|
||||||
break
|
|
||||||
|
|
||||||
for method in type_decl.methods or []:
|
|
||||||
ep = self._parse_method(method, type_decl.name, class_path, repo_relative_path)
|
|
||||||
if ep:
|
|
||||||
endpoints.append(ep)
|
|
||||||
|
|
||||||
return endpoints
|
|
||||||
|
|
||||||
def _is_controller(self, cls: ClassDeclaration) -> bool:
|
|
||||||
"""是否为 Controller 类。"""
|
|
||||||
return any(_ann_simple_name(a) in CONTROLLER_ANNS for a in (cls.annotations or []))
|
|
||||||
|
|
||||||
def _parse_method(
|
|
||||||
self,
|
|
||||||
method: MethodDeclaration,
|
|
||||||
class_name: str,
|
|
||||||
class_path: str,
|
|
||||||
source_file: str,
|
|
||||||
) -> Optional[ApiEndpoint]:
|
|
||||||
"""解析带映射注解的方法。"""
|
|
||||||
for ann in method.annotations or []:
|
|
||||||
ann_name = _ann_simple_name(ann)
|
|
||||||
if ann_name not in MAPPING_ANNS:
|
|
||||||
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, javadoc_params))
|
|
||||||
|
|
||||||
return ApiEndpoint(
|
|
||||||
http_method=_http_method(ann_name, ann),
|
|
||||||
uri=_join_paths(class_path, method_path),
|
|
||||||
controller_class=class_name,
|
|
||||||
method_name=method.name,
|
|
||||||
source_file=source_file.replace("\\", "/"),
|
|
||||||
parameters=params,
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
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 []
|
|
||||||
|
|
||||||
if _has_ann(param, "RequestBody"):
|
|
||||||
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 [
|
|
||||||
ApiParameter(
|
|
||||||
name=name,
|
|
||||||
type=type_name,
|
|
||||||
required=_param_required(param),
|
|
||||||
source=_param_source(param),
|
|
||||||
description=description,
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
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()
|
|
||||||
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,
|
|
||||||
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,
|
|
||||||
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] = []
|
|
||||||
for type_decl in tree.types or []:
|
|
||||||
if not isinstance(type_decl, ClassDeclaration):
|
|
||||||
continue
|
|
||||||
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(
|
|
||||||
name=decl.name,
|
|
||||||
type=_type_to_str(field.type),
|
|
||||||
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,
|
|
||||||
description=body_param_desc,
|
|
||||||
parent_dto=simple,
|
|
||||||
body_param_name=body_param_name or None,
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
self._dto_cache[cache_key] = fields
|
|
||||||
return fields
|
|
||||||
|
|
||||||
def _find_dto_file(self, simple_name: str) -> Optional[Path]:
|
|
||||||
"""在配置的源码目录及仓库内 src/main/java 中查找 DTO 文件。"""
|
|
||||||
target = f"{simple_name}.java"
|
|
||||||
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_subdirs: List[str],
|
|
||||||
file_paths: List[str],
|
|
||||||
file_contents: Dict[str, str],
|
|
||||||
) -> List[ApiEndpoint]:
|
|
||||||
"""
|
|
||||||
批量解析指定 Controller 文件(仅解析传入的文件,不全量扫描)。
|
|
||||||
|
|
||||||
:param repo_root: 仓库根目录
|
|
||||||
:param source_subdirs: 源码子目录列表(相对仓库根)
|
|
||||||
:param file_paths: 要解析的文件路径列表(相对仓库根)
|
|
||||||
:param file_contents: {文件路径: 源码内容}
|
|
||||||
:return: 所有端点
|
|
||||||
"""
|
|
||||||
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:
|
|
||||||
norm = path.replace("\\", "/")
|
|
||||||
content = file_contents.get(norm)
|
|
||||||
if not content:
|
|
||||||
continue
|
|
||||||
endpoints.extend(parser.parse_file_content(content, norm))
|
|
||||||
|
|
||||||
return endpoints
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
"""
|
|
||||||
Controller 端点解析模块(纯 Python,无需 Java)。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Dict, List
|
|
||||||
|
|
||||||
from models import ApiEndpoint, ApiParameter
|
|
||||||
|
|
||||||
|
|
||||||
def endpoints_to_map(endpoints: List[ApiEndpoint]) -> Dict[str, ApiEndpoint]:
|
|
||||||
"""端点列表转字典,key 为 endpoint_key。"""
|
|
||||||
return {ep.endpoint_key: ep for ep in endpoints}
|
|
||||||
|
|
||||||
|
|
||||||
def filter_endpoints_by_files(
|
|
||||||
endpoints: List[ApiEndpoint], changed_files: List[str]
|
|
||||||
) -> List[ApiEndpoint]:
|
|
||||||
"""仅保留变更文件中的端点。"""
|
|
||||||
if not changed_files:
|
|
||||||
return endpoints
|
|
||||||
changed_set = {f.replace("\\", "/") for f in changed_files}
|
|
||||||
return [ep for ep in endpoints if ep.source_file in changed_set]
|
|
||||||
|
|
||||||
|
|
||||||
def parse_endpoints_from_files(
|
|
||||||
repo_root: Path,
|
|
||||||
source_subdirs: List[str],
|
|
||||||
file_paths: List[str],
|
|
||||||
file_contents: Dict[str, str],
|
|
||||||
) -> List[ApiEndpoint]:
|
|
||||||
"""
|
|
||||||
解析指定 Controller 文件,提取接口参数(仅解析传入文件,不全量扫描)。
|
|
||||||
|
|
||||||
: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_subdirs, file_paths, file_contents)
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
"""
|
|
||||||
Git 操作工具模块。
|
|
||||||
负责在 CI 环境中检出上一版本代码、获取变更文件列表及提交元信息。
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import List, Optional
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class CommitInfo:
|
|
||||||
"""单次 Git 提交的元信息。"""
|
|
||||||
|
|
||||||
sha: str
|
|
||||||
author: str
|
|
||||||
commit_time: str
|
|
||||||
message: str
|
|
||||||
|
|
||||||
|
|
||||||
def run_git(args: List[str], cwd: Optional[Path] = None) -> str:
|
|
||||||
"""
|
|
||||||
执行 git 命令并返回标准输出。
|
|
||||||
|
|
||||||
:param args: git 子命令及参数,如 ["log", "-1", "--format=%H"]
|
|
||||||
:param cwd: 工作目录,默认为当前目录
|
|
||||||
:return: 命令 stdout 文本(已 strip)
|
|
||||||
:raises RuntimeError: git 命令执行失败时抛出
|
|
||||||
"""
|
|
||||||
cmd = ["git"] + args
|
|
||||||
result = subprocess.run(
|
|
||||||
cmd,
|
|
||||||
cwd=cwd,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
encoding="utf-8",
|
|
||||||
errors="replace",
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
raise RuntimeError(f"Git 命令失败: {' '.join(cmd)}\n{result.stderr}")
|
|
||||||
return result.stdout.strip()
|
|
||||||
|
|
||||||
|
|
||||||
def get_current_commit() -> CommitInfo:
|
|
||||||
"""
|
|
||||||
获取当前 HEAD 提交的元信息(推送人、时间等,用于通知模板)。
|
|
||||||
|
|
||||||
:return: CommitInfo 对象
|
|
||||||
"""
|
|
||||||
sha = run_git(["rev-parse", "HEAD"])
|
|
||||||
author = run_git(["log", "-1", "--format=%an"])
|
|
||||||
commit_time = run_git(["log", "-1", "--format=%ci"])
|
|
||||||
message = run_git(["log", "-1", "--format=%s"])
|
|
||||||
return CommitInfo(sha=sha, author=author, commit_time=commit_time, message=message)
|
|
||||||
|
|
||||||
|
|
||||||
def get_previous_commit_sha() -> Optional[str]:
|
|
||||||
"""
|
|
||||||
获取上一次提交的 SHA(HEAD~1)。
|
|
||||||
若是首次提交则返回 None。
|
|
||||||
|
|
||||||
:return: 上一 commit SHA,或 None
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return run_git(["rev-parse", "HEAD~1"])
|
|
||||||
except RuntimeError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def checkout_commit(sha: str, worktree_dir: Path) -> None:
|
|
||||||
"""
|
|
||||||
将指定 commit 的代码检出到独立工作目录(不影响当前工作区)。
|
|
||||||
|
|
||||||
:param sha: 目标 commit SHA
|
|
||||||
:param worktree_dir: git worktree 目录
|
|
||||||
"""
|
|
||||||
worktree_dir.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
if worktree_dir.exists():
|
|
||||||
# 已存在则先移除旧 worktree
|
|
||||||
run_git(["worktree", "remove", "--force", str(worktree_dir)])
|
|
||||||
run_git(["worktree", "add", str(worktree_dir), sha])
|
|
||||||
|
|
||||||
|
|
||||||
def get_changed_java_controller_files(base_sha: str, head_sha: str) -> List[str]:
|
|
||||||
"""
|
|
||||||
获取两次提交之间变更的 Controller 相关 Java 文件路径。
|
|
||||||
|
|
||||||
:param base_sha: 基准 commit(旧版本)
|
|
||||||
:param head_sha: 目标 commit(新版本)
|
|
||||||
:return: 相对路径列表,如 ["src/main/java/.../UserController.java"]
|
|
||||||
"""
|
|
||||||
diff_output = run_git(["diff", "--name-only", base_sha, head_sha])
|
|
||||||
if not diff_output:
|
|
||||||
return []
|
|
||||||
|
|
||||||
changed = []
|
|
||||||
for line in diff_output.splitlines():
|
|
||||||
line = line.strip()
|
|
||||||
if line.endswith(".java") and "Controller" in line:
|
|
||||||
changed.append(line.replace("\\", "/"))
|
|
||||||
return changed
|
|
||||||
|
|
||||||
|
|
||||||
def get_controller_files_diff(base_sha: str, head_sha: str, changed_files: List[str]) -> str:
|
|
||||||
"""
|
|
||||||
获取变更 Controller 文件的 Git diff,供 LLM 审核接口参数变更时参考。
|
|
||||||
|
|
||||||
:param base_sha: 旧版本 commit SHA
|
|
||||||
:param head_sha: 新版本 commit SHA
|
|
||||||
:param changed_files: 变更文件相对路径列表
|
|
||||||
:return: diff 文本
|
|
||||||
"""
|
|
||||||
if not changed_files:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
try:
|
|
||||||
return run_git(["diff", base_sha, head_sha, "--"] + changed_files)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
print(f"[警告] 获取 Git diff 失败: {exc}")
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def get_file_content_at_commit(commit_sha: str, file_path: str) -> Optional[str]:
|
|
||||||
"""
|
|
||||||
读取指定 commit 下某个文件的内容(无需 git worktree,更快)。
|
|
||||||
|
|
||||||
:param commit_sha: commit SHA
|
|
||||||
:param file_path: 相对仓库根目录的文件路径
|
|
||||||
:return: 文件内容;该 commit 中不存在则返回 None
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return run_git(["show", f"{commit_sha}:{file_path}"])
|
|
||||||
except RuntimeError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def prepare_worktrees(repo_root: Path) -> tuple:
|
|
||||||
"""
|
|
||||||
准备新旧两个版本的代码工作目录,供 AST 解析器分别扫描。
|
|
||||||
|
|
||||||
:param repo_root: 仓库根目录
|
|
||||||
:return: (新版本目录, 旧版本目录, 旧版本SHA);首次提交时旧版本目录为 None
|
|
||||||
"""
|
|
||||||
prev_sha = get_previous_commit_sha()
|
|
||||||
current_dir = repo_root
|
|
||||||
|
|
||||||
if prev_sha is None:
|
|
||||||
return current_dir, None, None
|
|
||||||
|
|
||||||
prev_dir = repo_root / ".gitea" / ".cache" / "prev-worktree"
|
|
||||||
checkout_commit(prev_sha, prev_dir)
|
|
||||||
return current_dir, prev_dir, prev_sha
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
"""
|
|
||||||
豆包 LLM 接口参数变更审核模块。
|
|
||||||
LLM 仅输出简短的兼容性提示,详细变更由 AST + Markdown 通知展示。
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
|
|
||||||
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:
|
|
||||||
"""判断大模型总开关是否开启。"""
|
|
||||||
return config.get("llm", {}).get("enabled", True)
|
|
||||||
|
|
||||||
|
|
||||||
def call_doubao_api(
|
|
||||||
api_key: str,
|
|
||||||
prompt: str,
|
|
||||||
config: Dict[str, Any],
|
|
||||||
) -> Optional[str]:
|
|
||||||
"""调用豆包 API。"""
|
|
||||||
if not api_key or api_key == "YOUR_DOUBAO_API_KEY":
|
|
||||||
print("[警告] 未配置豆包 API Key,跳过 LLM 审核。")
|
|
||||||
return None
|
|
||||||
|
|
||||||
llm_cfg = config.get("llm", {})
|
|
||||||
model = llm_cfg.get("model") or llm_cfg.get("endpoint_id", "")
|
|
||||||
if not model:
|
|
||||||
print("[警告] 未配置 llm.model,跳过 LLM 审核。")
|
|
||||||
return None
|
|
||||||
|
|
||||||
api_url = llm_cfg.get(
|
|
||||||
"api_url", "https://ark.cn-beijing.volces.com/api/v3/chat/completions"
|
|
||||||
)
|
|
||||||
timeout = llm_cfg.get("timeout")
|
|
||||||
|
|
||||||
headers = {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Authorization": f"Bearer {api_key}",
|
|
||||||
}
|
|
||||||
payload = {
|
|
||||||
"model": model,
|
|
||||||
"messages": [
|
|
||||||
{
|
|
||||||
"role": "system",
|
|
||||||
"content": (
|
|
||||||
"你是 Java Spring Boot API 变更分析专家。"
|
|
||||||
"你只负责输出简短的兼容性风险提示,不重复罗列接口参数明细。"
|
|
||||||
+ FRAMEWORK_IGNORE_HINT
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{"role": "user", "content": prompt},
|
|
||||||
],
|
|
||||||
"temperature": 0.1,
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
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"]
|
|
||||||
return None
|
|
||||||
except requests.RequestException as exc:
|
|
||||||
print(f"[错误] 豆包 API 调用失败: {exc}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def build_parameter_change_prompt(
|
|
||||||
reports: List[EndpointChangeReport],
|
|
||||||
changed_files: List[str],
|
|
||||||
git_diff: str = "",
|
|
||||||
) -> str:
|
|
||||||
"""
|
|
||||||
构造 LLM 提示词:只要求输出兼容性摘要,不要求重复参数列表。
|
|
||||||
"""
|
|
||||||
ast_report = []
|
|
||||||
for r in reports:
|
|
||||||
ast_report.append(
|
|
||||||
{
|
|
||||||
"uri": f"{r.http_method} {r.uri}",
|
|
||||||
"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,
|
|
||||||
"required": c.required,
|
|
||||||
}
|
|
||||||
for c in r.parameter_changes
|
|
||||||
],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
diff_block = git_diff.strip()[:6000] if git_diff.strip() else "(无)"
|
|
||||||
|
|
||||||
return f"""请根据以下 Controller 接口参数变更,**仅输出「兼容性提示」**,要求:
|
|
||||||
|
|
||||||
{FRAMEWORK_IGNORE_HINT}
|
|
||||||
|
|
||||||
## 输出格式(严格遵守)
|
|
||||||
- 只输出 3~6 行 Markdown,不要输出「整体说明」「接口变更详情」等标题
|
|
||||||
- 不要逐条重复 URI 和参数列表(通知里已有)
|
|
||||||
- 不要提及「排除框架注入」相关字样
|
|
||||||
- 重点说明:是否有破坏性变更、哪些必填参数调用方必须传入
|
|
||||||
- 全新 Controller 说明「均为新接口,对现有调用方无破坏」即可
|
|
||||||
- 语气简洁,可用 <font color="warning">...</font> 标注风险项
|
|
||||||
|
|
||||||
## 变更文件
|
|
||||||
{json.dumps(changed_files, ensure_ascii=False)}
|
|
||||||
|
|
||||||
## AST 变更摘要
|
|
||||||
{json.dumps(ast_report, ensure_ascii=False, indent=2)}
|
|
||||||
|
|
||||||
## Git Diff
|
|
||||||
{diff_block}
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def review_parameter_changes(
|
|
||||||
reports: List[EndpointChangeReport],
|
|
||||||
config: Dict[str, Any],
|
|
||||||
changed_files: List[str],
|
|
||||||
git_diff: str = "",
|
|
||||||
) -> Optional[str]:
|
|
||||||
"""LLM 审核,返回简短兼容性提示。"""
|
|
||||||
if not is_llm_enabled(config) or not reports:
|
|
||||||
return None
|
|
||||||
|
|
||||||
llm_cfg = config.get("llm", {})
|
|
||||||
prompt = build_parameter_change_prompt(reports, changed_files, git_diff)
|
|
||||||
return call_doubao_api(llm_cfg.get("api_key", ""), prompt, config)
|
|
||||||
@@ -1,207 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
AI-Check 主入口 — Controller 层接口参数变更检测(纯 Python,无 Java 依赖)
|
|
||||||
"""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
CHECKER_DIR = Path(__file__).resolve().parent
|
|
||||||
sys.path.insert(0, str(CHECKER_DIR))
|
|
||||||
|
|
||||||
from change_logger import persist_change_log
|
|
||||||
from comparator import compare_endpoints
|
|
||||||
from controller_parser import (
|
|
||||||
endpoints_to_map,
|
|
||||||
filter_endpoints_by_files,
|
|
||||||
parse_endpoints_from_files,
|
|
||||||
)
|
|
||||||
from git_utils import (
|
|
||||||
get_changed_java_controller_files,
|
|
||||||
get_controller_files_diff,
|
|
||||||
get_current_commit,
|
|
||||||
get_file_content_at_commit,
|
|
||||||
get_previous_commit_sha,
|
|
||||||
)
|
|
||||||
from llm_reviewer import review_parameter_changes
|
|
||||||
from notifier import send_parameter_change_notification
|
|
||||||
|
|
||||||
|
|
||||||
def load_config(config_path: Path) -> dict:
|
|
||||||
"""加载 YAML 配置文件。"""
|
|
||||||
if not config_path.exists():
|
|
||||||
print(f"[错误] 配置文件不存在: {config_path}")
|
|
||||||
print("请在 .gitea/config.yaml 中填写配置并提交到仓库。")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
with open(config_path, "r", encoding="utf-8") as f:
|
|
||||||
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:
|
|
||||||
return path.read_text(encoding="utf-8", errors="ignore")
|
|
||||||
except OSError as exc:
|
|
||||||
print(f"[警告] 无法读取 {path}: {exc}")
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def _load_version_contents(
|
|
||||||
repo_root: Path,
|
|
||||||
file_paths: list,
|
|
||||||
commit_sha: Optional[str] = None,
|
|
||||||
) -> dict:
|
|
||||||
"""加载文件内容;commit_sha 为空则读工作区,否则 git show。"""
|
|
||||||
contents = {}
|
|
||||||
for fp in file_paths:
|
|
||||||
norm = fp.replace("\\", "/")
|
|
||||||
if commit_sha:
|
|
||||||
text = get_file_content_at_commit(commit_sha, norm)
|
|
||||||
if text is not None:
|
|
||||||
contents[norm] = text
|
|
||||||
else:
|
|
||||||
text = _read_file_safe(repo_root / norm)
|
|
||||||
if text:
|
|
||||||
contents[norm] = text
|
|
||||||
return contents
|
|
||||||
|
|
||||||
|
|
||||||
def parse_changed_endpoints(
|
|
||||||
repo_root: Path,
|
|
||||||
source_subdirs: list,
|
|
||||||
changed_files: list,
|
|
||||||
old_sha: str,
|
|
||||||
label: str,
|
|
||||||
) -> dict:
|
|
||||||
"""解析变更 Controller 文件在新/旧版本的端点。"""
|
|
||||||
if label == "new":
|
|
||||||
contents = _load_version_contents(repo_root, changed_files)
|
|
||||||
else:
|
|
||||||
contents = _load_version_contents(repo_root, changed_files, commit_sha=old_sha)
|
|
||||||
|
|
||||||
print(f"[AST] 解析 {label} 版本 {len(contents)} 个 Controller 文件")
|
|
||||||
endpoints = parse_endpoints_from_files(
|
|
||||||
repo_root, source_subdirs, changed_files, contents
|
|
||||||
)
|
|
||||||
print(f"[AST] {label} 版本共 {len(endpoints)} 个接口")
|
|
||||||
return endpoints_to_map(endpoints)
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
"""主流程入口。"""
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="AI-Check: Controller 接口参数变更检测"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--config", default=".gitea/config.yaml", help="配置文件路径"
|
|
||||||
)
|
|
||||||
parser.add_argument("--repo-root", default=".", help="Git 仓库根目录")
|
|
||||||
parser.add_argument("push_user", nargs="?", default=None, help="推送人")
|
|
||||||
parser.add_argument("push_time", nargs="?", default=None, help="推送时间")
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
repo_root = Path(args.repo_root).resolve()
|
|
||||||
config_path = Path(args.config)
|
|
||||||
if not config_path.is_absolute():
|
|
||||||
config_path = repo_root / config_path
|
|
||||||
config = load_config(config_path)
|
|
||||||
|
|
||||||
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
|
|
||||||
push_time = args.push_time or commit_info.commit_time
|
|
||||||
|
|
||||||
print("Controller 接口参数变更检测(纯 Python)")
|
|
||||||
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)
|
|
||||||
|
|
||||||
prev_sha = get_previous_commit_sha()
|
|
||||||
if prev_sha is None:
|
|
||||||
print("[Git] 首次提交,无可对比版本,跳过。")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
changed_files = [f.replace("\\", "/") for f in get_changed_java_controller_files(prev_sha, commit_info.sha)]
|
|
||||||
if not changed_files:
|
|
||||||
print("[Git] 本次提交未变更 Controller 文件,跳过。")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
print(f"[Git] 变更 Controller 文件 {len(changed_files)} 个:")
|
|
||||||
for f in changed_files:
|
|
||||||
print(f" - {f}")
|
|
||||||
|
|
||||||
git_diff = get_controller_files_diff(prev_sha, commit_info.sha, changed_files)
|
|
||||||
|
|
||||||
new_map = parse_changed_endpoints(
|
|
||||||
repo_root, source_subdirs, changed_files, prev_sha, "new"
|
|
||||||
)
|
|
||||||
old_map = parse_changed_endpoints(
|
|
||||||
repo_root, source_subdirs, changed_files, prev_sha, "old"
|
|
||||||
)
|
|
||||||
|
|
||||||
new_filtered = endpoints_to_map(
|
|
||||||
filter_endpoints_by_files(list(new_map.values()), changed_files)
|
|
||||||
)
|
|
||||||
old_filtered = endpoints_to_map(
|
|
||||||
filter_endpoints_by_files(list(old_map.values()), changed_files)
|
|
||||||
)
|
|
||||||
|
|
||||||
reports = compare_endpoints(old_filtered, new_filtered)
|
|
||||||
print(f"[对比] 检测到 {len(reports)} 个接口存在参数变更")
|
|
||||||
|
|
||||||
llm_review = None
|
|
||||||
if reports:
|
|
||||||
llm_review = review_parameter_changes(
|
|
||||||
reports, config, changed_files, git_diff
|
|
||||||
)
|
|
||||||
if llm_review:
|
|
||||||
print("[LLM] 参数变更审核完成")
|
|
||||||
|
|
||||||
persist_change_log(reports, commit_info, config, llm_review)
|
|
||||||
|
|
||||||
notify_cfg = config.get("notify", {})
|
|
||||||
if notify_cfg.get("only_on_change", True) and not reports:
|
|
||||||
print("[通知] 无接口参数变更,跳过企微通知。")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
mentioned = notify_cfg.get("mentioned_users", "")
|
|
||||||
mentioned_list = [u.strip() for u in mentioned.split(",") if u.strip()] or None
|
|
||||||
|
|
||||||
send_parameter_change_notification(
|
|
||||||
webhook_url=config.get("wecom", {}).get("webhook_url", ""),
|
|
||||||
reports=reports,
|
|
||||||
push_user=push_user,
|
|
||||||
push_time=push_time,
|
|
||||||
llm_review=llm_review,
|
|
||||||
mentioned_users=mentioned_list,
|
|
||||||
)
|
|
||||||
|
|
||||||
print("\n完成")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
"""
|
|
||||||
Controller 端点数据模型。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import List, Optional
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ApiParameter:
|
|
||||||
"""单个接口参数。"""
|
|
||||||
|
|
||||||
name: str
|
|
||||||
type: str
|
|
||||||
required: bool = True
|
|
||||||
source: str = "query"
|
|
||||||
description: Optional[str] = None
|
|
||||||
parent_dto: Optional[str] = None
|
|
||||||
body_param_name: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ApiEndpoint:
|
|
||||||
"""单个 Controller 接口端点。"""
|
|
||||||
|
|
||||||
http_method: str
|
|
||||||
uri: str
|
|
||||||
controller_class: str
|
|
||||||
method_name: str
|
|
||||||
source_file: str
|
|
||||||
parameters: List[ApiParameter] = field(default_factory=list)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def endpoint_key(self) -> str:
|
|
||||||
return f"{self.http_method} {self.uri}"
|
|
||||||
@@ -1,525 +0,0 @@
|
|||||||
"""
|
|
||||||
企业微信 Markdown 通知模块。
|
|
||||||
支持加粗、颜色(info/comment/warning),新增接口与变更接口使用不同展示模板。
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
from collections import OrderedDict
|
|
||||||
from typing import List, Optional, Tuple
|
|
||||||
|
|
||||||
import requests
|
|
||||||
|
|
||||||
from comparator import EndpointChangeReport, ParameterChange
|
|
||||||
|
|
||||||
# 企微 Markdown 单条上限 4096 字符,留余量
|
|
||||||
MAX_MD_LENGTH = 3800
|
|
||||||
|
|
||||||
|
|
||||||
def truncate_text(text: str, max_length: int = MAX_MD_LENGTH) -> str:
|
|
||||||
"""截断超长消息。"""
|
|
||||||
if len(text) <= max_length:
|
|
||||||
return text
|
|
||||||
return text[:max_length] + "\n\n<font color=\"comment\">... 消息过长,已截断</font>"
|
|
||||||
|
|
||||||
|
|
||||||
def _format_param_change_list(changes: List[ParameterChange]) -> List[str]:
|
|
||||||
"""生成企微友好的普通参数变更列表(卡片式)。"""
|
|
||||||
if not changes:
|
|
||||||
return ['<font color="comment">无</font>']
|
|
||||||
lines = ["", f"共 **{len(changes)}** 项变更", ""]
|
|
||||||
for i, change in enumerate(changes, 1):
|
|
||||||
lines.append(change.to_markdown_block(i))
|
|
||||||
if i < len(changes):
|
|
||||||
lines.append("")
|
|
||||||
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]:
|
|
||||||
"""生成接口参数变动详情区块。"""
|
|
||||||
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.extend(_format_body_dto_groups(body_changes))
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
if regular_changes:
|
|
||||||
lines.append("**普通参数变更**")
|
|
||||||
lines.extend(_format_param_change_list(regular_changes))
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
if not body_changes and not regular_changes:
|
|
||||||
lines.append('<font color="comment">无</font>')
|
|
||||||
|
|
||||||
return lines
|
|
||||||
|
|
||||||
|
|
||||||
def _format_endpoint_block(report: EndpointChangeReport) -> str:
|
|
||||||
"""
|
|
||||||
格式化单个接口块,按模板匹配格式输出。
|
|
||||||
全路径类名显示为 source_file(相对仓库根的完整 .java 路径)。
|
|
||||||
"""
|
|
||||||
change_type = "新增接口" if report.is_new_endpoint else ("删除接口" if report.is_removed_endpoint else "修改参数")
|
|
||||||
uri_line = f"**{report.http_method}** `{report.uri}`"
|
|
||||||
file_path = report.source_file or report.controller_class
|
|
||||||
class_line = f"- **全路径类名:** <font color=\"info\">**{file_path}**</font>"
|
|
||||||
|
|
||||||
header = [
|
|
||||||
f"- **变更类型:** <font color=\"warning\">**{change_type}**</font>",
|
|
||||||
f"- **URI:** {uri_line}",
|
|
||||||
class_line,
|
|
||||||
]
|
|
||||||
|
|
||||||
if report.is_removed_endpoint:
|
|
||||||
return "\n".join(header + ["", f"<font color=\"warning\">**该接口已被移除**</font>"])
|
|
||||||
|
|
||||||
return "\n".join(header + _format_param_details_section(report))
|
|
||||||
|
|
||||||
|
|
||||||
def build_markdown_notification(
|
|
||||||
reports: List[EndpointChangeReport],
|
|
||||||
push_user: str,
|
|
||||||
push_time: str,
|
|
||||||
llm_summary: Optional[str] = None,
|
|
||||||
) -> str:
|
|
||||||
"""
|
|
||||||
构建完整 Markdown 通知正文。
|
|
||||||
|
|
||||||
:param reports: AST 变更报告
|
|
||||||
:param push_user: 推送人
|
|
||||||
:param push_time: 推送时间
|
|
||||||
:param llm_summary: LLM 兼容性摘要(可选,简短)
|
|
||||||
:return: Markdown 文本
|
|
||||||
"""
|
|
||||||
parts: List[str] = []
|
|
||||||
|
|
||||||
# 所有 API 级变更(新增、修改路径、修改请求方式、删除、参数变更)统一走 model1.md 路径变更通知
|
|
||||||
method_changed_reports = [r for r in reports if r.is_method_changed]
|
|
||||||
renamed_reports = [r for r in reports if r.is_renamed_endpoint]
|
|
||||||
new_reports = [r for r in reports if r.is_new_endpoint]
|
|
||||||
# 参数变更报告:只包含「URI/方法未变,仅参数变化」的报告
|
|
||||||
# 路径变更 + 参数变更、方法变更 + 参数变更 场景已在上层 comparator 中拆分为独立报告
|
|
||||||
changed_reports = [
|
|
||||||
r for r in reports
|
|
||||||
if not r.is_new_endpoint
|
|
||||||
and not r.is_removed_endpoint
|
|
||||||
and not r.is_renamed_endpoint
|
|
||||||
and not r.is_method_changed
|
|
||||||
]
|
|
||||||
removed_reports = [r for r in reports if r.is_removed_endpoint]
|
|
||||||
|
|
||||||
# 1. 新增接口 → 走 API路径变更通知
|
|
||||||
for report in new_reports:
|
|
||||||
path_md = build_path_change_markdown(
|
|
||||||
old_uri="-",
|
|
||||||
new_uri=report.uri,
|
|
||||||
change_type="新增接口",
|
|
||||||
push_user=push_user,
|
|
||||||
push_time=push_time,
|
|
||||||
file_name=report.source_file or report.controller_class,
|
|
||||||
)
|
|
||||||
parts.append(path_md)
|
|
||||||
parts.append("")
|
|
||||||
|
|
||||||
# 2. 修改请求方式 → 使用独立的新模板 【API请求方式变更通知】
|
|
||||||
for report in method_changed_reports:
|
|
||||||
method_md = build_method_change_markdown(
|
|
||||||
uri=report.uri,
|
|
||||||
old_method=report.old_http_method or "?",
|
|
||||||
new_method=report.http_method,
|
|
||||||
push_user=push_user,
|
|
||||||
push_time=push_time,
|
|
||||||
file_name=report.source_file or report.controller_class,
|
|
||||||
)
|
|
||||||
parts.append(method_md)
|
|
||||||
parts.append("")
|
|
||||||
|
|
||||||
# 3. 修改路径 → 走 API路径变更通知
|
|
||||||
for report in renamed_reports:
|
|
||||||
path_md = build_path_change_markdown(
|
|
||||||
old_uri=report.old_uri or "-",
|
|
||||||
new_uri=report.uri,
|
|
||||||
change_type="修改路径",
|
|
||||||
push_user=push_user,
|
|
||||||
push_time=push_time,
|
|
||||||
file_name=report.source_file or report.controller_class,
|
|
||||||
)
|
|
||||||
parts.append(path_md)
|
|
||||||
parts.append("")
|
|
||||||
|
|
||||||
# 4. 删除接口 → 走 API路径变更通知
|
|
||||||
for report in removed_reports:
|
|
||||||
path_md = build_path_change_markdown(
|
|
||||||
old_uri=report.uri,
|
|
||||||
new_uri="已删除",
|
|
||||||
change_type="删除接口",
|
|
||||||
push_user=push_user,
|
|
||||||
push_time=push_time,
|
|
||||||
file_name=report.source_file or report.controller_class,
|
|
||||||
)
|
|
||||||
parts.append(path_md)
|
|
||||||
parts.append("")
|
|
||||||
|
|
||||||
# 4. 普通参数变更(非路径变更)仍使用 model.md 格式
|
|
||||||
if changed_reports:
|
|
||||||
parts.append("# 【API参数变更通知】")
|
|
||||||
parts.append(f"- **修改人:** {push_user}")
|
|
||||||
parts.append(f"- **修改时间:** {push_time}")
|
|
||||||
parts.append("")
|
|
||||||
for report in changed_reports:
|
|
||||||
parts.append(_format_endpoint_block(report))
|
|
||||||
parts.append("")
|
|
||||||
|
|
||||||
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("### <font color=\"comment\">【兼容性提示】</font>")
|
|
||||||
parts.append(cleaned)
|
|
||||||
|
|
||||||
return "\n".join(parts).strip()
|
|
||||||
|
|
||||||
|
|
||||||
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: List[str] = []
|
|
||||||
|
|
||||||
for line in lines:
|
|
||||||
if line.startswith("### ") and current and len("\n".join(current)) > 200:
|
|
||||||
chunks.append("\n".join(current))
|
|
||||||
current = [line]
|
|
||||||
else:
|
|
||||||
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_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[:1000])
|
|
||||||
return False
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"msgtype": "markdown",
|
|
||||||
"markdown": {"content": truncate_text(content)},
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
resp = requests.post(
|
|
||||||
webhook_url,
|
|
||||||
headers={"Content-Type": "application/json"},
|
|
||||||
data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
|
|
||||||
timeout=10,
|
|
||||||
)
|
|
||||||
if resp.status_code == 200 and resp.json().get("errcode", 0) == 0:
|
|
||||||
return True
|
|
||||||
print(f"[错误] 企微返回异常: {resp.status_code} {resp.text}")
|
|
||||||
return False
|
|
||||||
except requests.RequestException as exc:
|
|
||||||
print(f"[错误] 发送企微消息失败: {exc}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def send_parameter_change_notification(
|
|
||||||
webhook_url: str,
|
|
||||||
reports: List[EndpointChangeReport],
|
|
||||||
push_user: str,
|
|
||||||
push_time: str,
|
|
||||||
llm_review: Optional[str] = None,
|
|
||||||
mentioned_users: Optional[List[str]] = None,
|
|
||||||
) -> int:
|
|
||||||
"""
|
|
||||||
发送 Markdown 格式的接口变更通知。
|
|
||||||
|
|
||||||
严格按变更类型拆分,各自独立构建和发送企微通知:
|
|
||||||
- 方法变更 → 独立调用 build_method_change_markdown
|
|
||||||
- 路径变更(新增/修改/删除) → 独立调用 build_path_change_markdown
|
|
||||||
- 参数变更 → 独立调用 _format_endpoint_block
|
|
||||||
|
|
||||||
不同类型之间完全互不干扰,各自走独立分支。
|
|
||||||
"""
|
|
||||||
if not reports and not llm_review:
|
|
||||||
print("无接口参数变更,不发送到企业微信")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
# 按类型严格分组(互不重叠)
|
|
||||||
method_changed_reports = [r for r in reports if r.is_method_changed]
|
|
||||||
renamed_reports = [r for r in reports if r.is_renamed_endpoint]
|
|
||||||
new_reports = [r for r in reports if r.is_new_endpoint]
|
|
||||||
removed_reports = [r for r in reports if r.is_removed_endpoint]
|
|
||||||
changed_reports = [
|
|
||||||
r for r in reports
|
|
||||||
if not r.is_new_endpoint
|
|
||||||
and not r.is_removed_endpoint
|
|
||||||
and not r.is_renamed_endpoint
|
|
||||||
and not r.is_method_changed
|
|
||||||
]
|
|
||||||
|
|
||||||
sent = 0
|
|
||||||
|
|
||||||
# ========== 1. 请求方式变更通知(独立分支) ==========
|
|
||||||
for report in method_changed_reports:
|
|
||||||
md = build_method_change_markdown(
|
|
||||||
uri=report.uri,
|
|
||||||
old_method=report.old_http_method or "?",
|
|
||||||
new_method=report.http_method,
|
|
||||||
push_user=push_user,
|
|
||||||
push_time=push_time,
|
|
||||||
file_name=report.source_file or report.controller_class,
|
|
||||||
)
|
|
||||||
if _post_wecom_markdown(webhook_url, md):
|
|
||||||
sent += 1
|
|
||||||
print(f"第 {sent} 条通知已发送到企业微信(请求方式变更)")
|
|
||||||
|
|
||||||
# ========== 2. 路径变更通知(新增/修改/删除) ==========
|
|
||||||
# 新增接口
|
|
||||||
for report in new_reports:
|
|
||||||
md = build_path_change_markdown(
|
|
||||||
old_uri="-",
|
|
||||||
new_uri=report.uri,
|
|
||||||
change_type="新增接口",
|
|
||||||
push_user=push_user,
|
|
||||||
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} 条通知已发送到企业微信(新增接口)")
|
|
||||||
|
|
||||||
# 修改路径
|
|
||||||
for report in renamed_reports:
|
|
||||||
md = build_path_change_markdown(
|
|
||||||
old_uri=report.old_uri or "-",
|
|
||||||
new_uri=report.uri,
|
|
||||||
change_type="修改路径",
|
|
||||||
push_user=push_user,
|
|
||||||
push_time=push_time,
|
|
||||||
file_name=report.source_file or report.controller_class,
|
|
||||||
)
|
|
||||||
if _post_wecom_markdown(webhook_url, md):
|
|
||||||
sent += 1
|
|
||||||
print(f"第 {sent} 条通知已发送到企业微信(修改路径)")
|
|
||||||
|
|
||||||
# 删除接口
|
|
||||||
for report in removed_reports:
|
|
||||||
md = build_path_change_markdown(
|
|
||||||
old_uri=report.uri,
|
|
||||||
new_uri="已删除",
|
|
||||||
change_type="删除接口",
|
|
||||||
push_user=push_user,
|
|
||||||
push_time=push_time,
|
|
||||||
file_name=report.source_file or report.controller_class,
|
|
||||||
)
|
|
||||||
if _post_wecom_markdown(webhook_url, md):
|
|
||||||
sent += 1
|
|
||||||
print(f"第 {sent} 条通知已发送到企业微信(删除接口)")
|
|
||||||
|
|
||||||
# ========== 3. 参数变更通知(独立分支) ==========
|
|
||||||
if changed_reports:
|
|
||||||
# 构建参数变更通知(只包含参数变更报告,对齐 model.md)
|
|
||||||
parts: List[str] = []
|
|
||||||
parts.append("# 【API参数变更通知】")
|
|
||||||
parts.append(f"- **修改人:** {push_user}")
|
|
||||||
parts.append(f"- **修改时间:** {push_time}")
|
|
||||||
parts.append("")
|
|
||||||
for report in changed_reports:
|
|
||||||
parts.append(_format_endpoint_block(report))
|
|
||||||
parts.append("")
|
|
||||||
if llm_review:
|
|
||||||
parts.append("---")
|
|
||||||
parts.append("### <font color=\"comment\">兼容性提示</font>")
|
|
||||||
parts.append(llm_review.strip())
|
|
||||||
|
|
||||||
md = "\n".join(parts).strip()
|
|
||||||
if _post_wecom_markdown(webhook_url, md):
|
|
||||||
sent += 1
|
|
||||||
print(f"第 {sent} 条通知已发送到企业微信(参数变更)")
|
|
||||||
|
|
||||||
if sent > 0:
|
|
||||||
print(f"总共发送 {sent} 条通知到企业微信")
|
|
||||||
return sent
|
|
||||||
|
|
||||||
|
|
||||||
def build_path_change_markdown(
|
|
||||||
old_uri: str,
|
|
||||||
new_uri: str,
|
|
||||||
change_type: str,
|
|
||||||
push_user: str,
|
|
||||||
push_time: str,
|
|
||||||
file_name: str,
|
|
||||||
) -> str:
|
|
||||||
"""构建 API路径变更通知,完全匹配 model1.md 模板,并加强视觉区分。
|
|
||||||
|
|
||||||
支持的 change_type:
|
|
||||||
- 新增接口 / 删除接口 / 修改路径 / 修改请求方式
|
|
||||||
|
|
||||||
改进点:
|
|
||||||
- 标题使用【】风格
|
|
||||||
- 头部信息缩进 + 颜色高亮
|
|
||||||
- URI 详情使用列表(更直观)
|
|
||||||
- 「修改请求方式」额外展示方法变更
|
|
||||||
"""
|
|
||||||
# 变更类型高亮
|
|
||||||
type_highlight = f"<font color=\"warning\">**{change_type}**</font>"
|
|
||||||
|
|
||||||
# 全路径类名高亮
|
|
||||||
class_highlight = f"<font color=\"info\">**{file_name}**</font>"
|
|
||||||
|
|
||||||
# 根据变更类型优化 URI 展示
|
|
||||||
if change_type == "新增接口":
|
|
||||||
old_display = "`-`"
|
|
||||||
new_display = f"<font color=\"info\">**`{new_uri}`**</font> ← <font color=\"info\">**新增**</font>"
|
|
||||||
elif change_type == "删除接口":
|
|
||||||
old_display = f"<font color=\"warning\">**`{old_uri}`**</font> ← <font color=\"warning\">**已删除**</font>"
|
|
||||||
new_display = "`已删除`"
|
|
||||||
else: # 修改路径
|
|
||||||
old_display = f"<font color=\"warning\">~~`{old_uri}`~~</font> ← <font color=\"warning\">**旧路径**</font>"
|
|
||||||
new_display = f"<font color=\"info\">**`{new_uri}`**</font> ← <font color=\"info\">**新路径**</font>"
|
|
||||||
|
|
||||||
parts = [
|
|
||||||
"# 【API路径变更通知】",
|
|
||||||
"",
|
|
||||||
f" 变更类型: {type_highlight}",
|
|
||||||
f" 全路径类名: {class_highlight}",
|
|
||||||
f" 修改人: {push_user}",
|
|
||||||
f" 修改时间: {push_time}",
|
|
||||||
"",
|
|
||||||
"---------------------------------------",
|
|
||||||
"",
|
|
||||||
"#### 【URI变更详情】",
|
|
||||||
f"- **原路径:** {old_display}",
|
|
||||||
f"- **新路径:** {new_display}",
|
|
||||||
"",
|
|
||||||
]
|
|
||||||
return "\n".join(parts).strip()
|
|
||||||
|
|
||||||
|
|
||||||
def build_method_change_markdown(
|
|
||||||
uri: str,
|
|
||||||
old_method: str,
|
|
||||||
new_method: str,
|
|
||||||
push_user: str,
|
|
||||||
push_time: str,
|
|
||||||
file_name: str,
|
|
||||||
) -> str:
|
|
||||||
"""构建【API请求方式变更通知】独立模板。
|
|
||||||
|
|
||||||
格式参考 model1.md,但专门针对 HTTP 方法变更场景设计,
|
|
||||||
突出「原请求方式 → 新请求方式」的对比。
|
|
||||||
"""
|
|
||||||
type_highlight = '<font color="warning">**修改请求方式**</font>'
|
|
||||||
class_highlight = f'<font color="info">**{file_name}**</font>'
|
|
||||||
uri_highlight = f'<font color="info">**`{uri}`**</font>'
|
|
||||||
old_m = f'<font color="warning">**{old_method}**</font>'
|
|
||||||
new_m = f'<font color="info">**{new_method}**</font>'
|
|
||||||
|
|
||||||
parts = [
|
|
||||||
"# 【API请求方式变更通知】",
|
|
||||||
"",
|
|
||||||
f" 变更类型: {type_highlight}",
|
|
||||||
f" 全路径类名: {class_highlight}",
|
|
||||||
f" 修改人: {push_user}",
|
|
||||||
f" 修改时间: {push_time}",
|
|
||||||
"",
|
|
||||||
"---------------------------------------",
|
|
||||||
"",
|
|
||||||
"#### 【请求方式变更详情】",
|
|
||||||
f"- **URI:** {uri_highlight}",
|
|
||||||
f"- **原请求方式:** {old_m}",
|
|
||||||
f"- **新请求方式:** {new_m} ← <font color=\"info\">**请求方式已变更**</font>",
|
|
||||||
"",
|
|
||||||
]
|
|
||||||
return "\n".join(parts).strip()
|
|
||||||
|
|
||||||
|
|
||||||
def send_path_change_notification(
|
|
||||||
webhook_url: str,
|
|
||||||
old_uri: str,
|
|
||||||
new_uri: str,
|
|
||||||
change_type: str,
|
|
||||||
push_user: str,
|
|
||||||
push_time: str,
|
|
||||||
file_name: str,
|
|
||||||
) -> bool:
|
|
||||||
"""发送路径变更通知。"""
|
|
||||||
md = build_path_change_markdown(old_uri, new_uri, change_type, push_user, push_time, file_name)
|
|
||||||
return _post_wecom_markdown(webhook_url, md)
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
# Python 依赖(版本锁定,避免 CI pip 冲突)
|
|
||||||
PyYAML==6.0.2
|
|
||||||
requests==2.32.3
|
|
||||||
charset-normalizer==3.4.1
|
|
||||||
urllib3==2.2.3
|
|
||||||
certifi==2024.8.30
|
|
||||||
idna==3.10
|
|
||||||
javalang==0.13.0
|
|
||||||
six==1.16.0
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
# ============================================================
|
|
||||||
# AI-Check 配置文件(位于 .gitea/ 目录,与业务代码解耦)
|
|
||||||
# ============================================================
|
|
||||||
|
|
||||||
# ---------- API 变动检查 ----------
|
|
||||||
# 总开关:false 时跳过 Controller 接口参数变更检测(不对比、不通知)
|
|
||||||
check:
|
|
||||||
enabled: true
|
|
||||||
|
|
||||||
# 业务 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"
|
|
||||||
|
|
||||||
# ---------- 企业微信机器人 ----------
|
|
||||||
wecom:
|
|
||||||
webhook_url: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=61f08cc9-b734-4dff-a931-7f33654c0a81"
|
|
||||||
|
|
||||||
# ---------- 豆包 LLM(审核接口参数变更)----------
|
|
||||||
llm:
|
|
||||||
enabled: false
|
|
||||||
api_key: "2f3f7ee9-a6f7-46b7-a709-a36743a83a04"
|
|
||||||
model: "doubao-seed-1-8-251228"
|
|
||||||
endpoint_id: ""
|
|
||||||
api_url: "https://ark.cn-beijing.volces.com/api/v3/chat/completions"
|
|
||||||
timeout: null
|
|
||||||
|
|
||||||
# ---------- 变更日志 ----------
|
|
||||||
log:
|
|
||||||
enabled: false
|
|
||||||
storage: "file"
|
|
||||||
file_dir: ".gitea/logs/api-changes"
|
|
||||||
mysql:
|
|
||||||
host: "YOUR_MYSQL_HOST"
|
|
||||||
port: 3306
|
|
||||||
user: "YOUR_MYSQL_USER"
|
|
||||||
password: "YOUR_MYSQL_PASSWORD"
|
|
||||||
database: "YOUR_MYSQL_DATABASE"
|
|
||||||
table: "api_change_logs"
|
|
||||||
|
|
||||||
# ---------- 通知 ----------
|
|
||||||
notify:
|
|
||||||
only_on_change: true
|
|
||||||
mentioned_users: ""
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
# 【API参数变更通知】
|
|
||||||
|
|
||||||
- **变更类型:** {新增接口 / 修改参数 / 删除接口}
|
|
||||||
- **URI:** {Method} {URI}
|
|
||||||
- **修改人:** {Modifier}
|
|
||||||
- **修改时间:** {ModifyTime}
|
|
||||||
- **全路径类名:** {FileName}
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 接口参数变动详情
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 类对象变更
|
|
||||||
(如有对象替换或对象属性变更)
|
|
||||||
|
|
||||||
- **对象:** {类名}
|
|
||||||
- **变更方式:** {对象属性变更 / 对象替换(旧类A → 新类B)}
|
|
||||||
- **属性变更明细:**
|
|
||||||
- [新增] 属性: `attr1` 说明: {说明}
|
|
||||||
- [删除] 属性: `attr2` 说明: {说明}
|
|
||||||
- [修改] 属性: `attr3` 说明: {说明}
|
|
||||||
|
|
||||||
### 【参数变更】
|
|
||||||
- **变更列表:**
|
|
||||||
|
|
||||||
| 字段 | 说明 | 变更 |
|
|
||||||
|------|------|------|
|
|
||||||
| `pageSize` | 每页条数 | 新增必填 |
|
|
||||||
| `keyword` | 搜索关键词 | 类型由String改为Long |
|
|
||||||
| `startTime` | 开始时间 | 删除 |
|
|
||||||
|
|
||||||
(如无变更,显示:无)
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
# 【API路径变更通知】
|
|
||||||
|
|
||||||
变更类型: {新增接口 / 修改路径 / 删除接口}
|
|
||||||
全路径类名: {FullClassName}
|
|
||||||
修改人: {Modifier}
|
|
||||||
修改时间: {ModifyTime}
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### 【URI变更详情】
|
|
||||||
- **原路径:** `{OldURI}` *(新增时显示:-)*
|
|
||||||
- **新路径:** `{NewURI}` *(删除时显示:已删除 / -)*
|
|
||||||
|
|
||||||
**示例:**
|
|
||||||
- 全路径类名:`com.example.controller.UserController`
|
|
||||||
- 原路径:`/api/users/{id}`
|
|
||||||
- 新路径:`/api/users/getall`
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
-- MySQL 变更日志表(storage=mysql 时使用)
|
|
||||||
-- 执行前请先创建数据库并替换 YOUR_MYSQL_DATABASE
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS `api_change_logs` (
|
|
||||||
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
|
||||||
`commit_sha` VARCHAR(64) NOT NULL COMMENT 'Git 提交 SHA',
|
|
||||||
`author` VARCHAR(128) NOT NULL COMMENT '提交人',
|
|
||||||
`commit_time` VARCHAR(64) NOT NULL COMMENT '提交时间',
|
|
||||||
`commit_message` TEXT NULL COMMENT '提交说明',
|
|
||||||
`change_count` INT NOT NULL DEFAULT 0 COMMENT '变更接口数量',
|
|
||||||
`reports_json` LONGTEXT NOT NULL COMMENT '变更详情 JSON',
|
|
||||||
`llm_review` TEXT NULL COMMENT 'LLM 评审结论',
|
|
||||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '记录写入时间',
|
|
||||||
PRIMARY KEY (`id`),
|
|
||||||
INDEX `idx_commit_sha` (`commit_sha`),
|
|
||||||
INDEX `idx_created_at` (`created_at`)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='API 接口参数变更日志';
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
# Gitea Actions:Controller 接口参数变更检测(纯 Python,无 Java 构建)
|
|
||||||
|
|
||||||
name: API接口参数变更检测
|
|
||||||
run-name: ${{ gitea.actor }}的API参数变更检测
|
|
||||||
|
|
||||||
on: [push]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
api-param-check:
|
|
||||||
if: ${{ gitea.ref != 'refs/heads/pre' && gitea.ref != 'refs/heads/dev' && gitea.ref != 'refs/heads/master-2.0' }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: 内置权限检出代码
|
|
||||||
run: |
|
|
||||||
git config --global http.sslVerify false
|
|
||||||
git clone "https://${{ gitea.token }}@git.niujiekeji.com/${{ gitea.repository }}.git" .
|
|
||||||
git checkout ${{ gitea.sha }}
|
|
||||||
echo "当前提交: $(git rev-parse HEAD)"
|
|
||||||
echo "上一提交: $(git rev-parse HEAD~1 2>/dev/null || echo '无')"
|
|
||||||
|
|
||||||
- name: 检查配置文件
|
|
||||||
run: |
|
|
||||||
if [ ! -f .gitea/config.yaml ]; then
|
|
||||||
echo "错误: 缺少 .gitea/config.yaml"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: 安装 Python 依赖
|
|
||||||
run: |
|
|
||||||
if ! python3 -m venv .gitea/.venv 2>/dev/null; then
|
|
||||||
sudo apt-get update -qq
|
|
||||||
sudo apt-get install -y python3-venv
|
|
||||||
python3 -m venv .gitea/.venv
|
|
||||||
fi
|
|
||||||
.gitea/.venv/bin/pip install --upgrade pip
|
|
||||||
.gitea/.venv/bin/pip install -r .gitea/checker/requirements.txt
|
|
||||||
|
|
||||||
- name: 检测 Controller 接口参数变更
|
|
||||||
run: |
|
|
||||||
COMMIT_TIME=$(git log -1 --format=%cd --date=format:'%Y-%m-%d %H:%M:%S')
|
|
||||||
.gitea/.venv/bin/python .gitea/checker/main.py \
|
|
||||||
--config .gitea/config.yaml \
|
|
||||||
--repo-root . \
|
|
||||||
"${{ gitea.actor }}" \
|
|
||||||
"$COMMIT_TIME"
|
|
||||||
Reference in New Issue
Block a user