目录del

This commit is contained in:
2026-06-05 18:00:43 +08:00
parent 4ebb71f7a0
commit c3c73b6fb3
17 changed files with 102 additions and 2456 deletions

3
.gitea/.gitignore vendored
View File

@@ -1,3 +0,0 @@
# 本地/CI 运行时产生的缓存与日志(可不提交)
.cache/
logs/

102
.gitea/1.md Normal file
View 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
(若无影响则显示:无)

View File

@@ -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,
)

View File

@@ -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]

View File

@@ -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

View File

@@ -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)

View File

@@ -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]:
"""
获取上一次提交的 SHAHEAD~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

View File

@@ -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}
## 输出格式(严格遵守)
- 只输出 36 行 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)

View File

@@ -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())

View File

@@ -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}"

View File

@@ -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)

View File

@@ -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

View File

@@ -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: ""

View File

@@ -1,34 +0,0 @@
# 【API参数变更通知】
- **变更类型:** {新增接口 / 修改参数 / 删除接口}
- **URI** {Method} {URI}
- **修改人:** {Modifier}
- **修改时间:** {ModifyTime}
- **全路径类名:** {FileName}
---
## 接口参数变动详情
---
### 类对象变更
(如有对象替换或对象属性变更)
- **对象:** {类名}
- **变更方式:** {对象属性变更 / 对象替换旧类A → 新类B}
- **属性变更明细:**
- [新增] 属性: `attr1` 说明: {说明}
- [删除] 属性: `attr2` 说明: {说明}
- [修改] 属性: `attr3` 说明: {说明}
### 【参数变更】
- **变更列表:**
| 字段 | 说明 | 变更 |
|------|------|------|
| `pageSize` | 每页条数 | 新增必填 |
| `keyword` | 搜索关键词 | 类型由String改为Long |
| `startTime` | 开始时间 | 删除 |
(如无变更,显示:无)

View File

@@ -1,17 +0,0 @@
# 【API路径变更通知】
变更类型: {新增接口 / 修改路径 / 删除接口}
全路径类名: {FullClassName}
修改人: {Modifier}
修改时间: {ModifyTime}
---
#### 【URI变更详情】
- **原路径:** `{OldURI}` *(新增时显示:-*
- **新路径:** `{NewURI}` *(删除时显示:已删除 / -*
**示例:**
- 全路径类名:`com.example.controller.UserController`
- 原路径:`/api/users/{id}`
- 新路径:`/api/users/getall`

View File

@@ -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 接口参数变更日志';

View File

@@ -1,46 +0,0 @@
# Gitea ActionsController 接口参数变更检测(纯 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"