Files
AI-Check-Test/.gitea/checker/comparator.py
2026-06-05 14:23:46 +08:00

369 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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
def to_markdown_line(self, *, plain: bool = False) -> str:
"""
格式化为企微 Markdown 行。
plain=True 时用于新增接口,直接列出参数,不加「新增」前缀。
"""
req_optional = self.required is False
req_required = self.required is True
if plain and self.change_type == ChangeType.ADDED:
tag = (
'<font color="warning">必填</font>'
if req_required
else '<font color="comment">可选</font>'
)
return f'> `{self.param_type}` **{self.param_name}** · {tag}'
if self.change_type == ChangeType.REMOVED:
return (
f'<font color="warning">【删除】</font> '
f'`{self.param_type}` ~~{self.param_name}~~'
)
if self.change_type == ChangeType.ADDED:
tag = (
'<font color="warning">必填</font>'
if req_required
else '<font color="comment">可选</font>'
)
return (
f'<font color="info">【新增】</font> '
f'`{self.param_type}` **{self.param_name}** · {tag}'
)
if self.change_type == ChangeType.RENAMED:
return (
f'<font color="comment">【重命名】</font> '
f'`{self.old_type}` {self.old_name}'
f'`{self.param_type}` **{self.param_name}**'
)
if self.change_type == ChangeType.MODIFIED:
detail = f' · <font color="comment">{self.detail}</font>' if self.detail else ""
return f'<font color="warning">【修改】</font> **{self.param_name}**{detail}'
return f'- {self.param_name}'
@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 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(f"类型 {old_p.type} -> {new_p.type}")
if old_p.required != new_p.required:
detail_parts.append(f"必填 {old_p.required} -> {new_p.required}")
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),
)
)
# 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,
)
)
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,
)
)
# 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,
)
)
return changes
def _method_identity(ep: ApiEndpoint) -> Tuple[str, str]:
"""生成方法的唯一标识:(source_file, method_name)。"""
return (ep.source_file or "", ep.method_name)
def compare_endpoints(
old_endpoints: Dict[str, ApiEndpoint],
new_endpoints: Dict[str, ApiEndpoint],
) -> List[EndpointChangeReport]:
"""
对比新旧两个版本的全部 Controller 端点,生成变更报告列表。
匹配策略(方案 1
- 使用 (source_file, method_name) 作为「同一个 Java 方法」的唯一标识。
- 只要同一个 Java 方法被修改,就分别判断「请求方式是否变」和「路径是否变」。
- 支持「同时改请求方式 + 路径」时生成两条独立报告。
- 支持「同时改请求方式/路径 + 参数」时生成多条独立报告。
"""
reports: List[EndpointChangeReport] = []
# 1. 构建基于方法标识的映射
old_by_identity: Dict[Tuple[str, str], List[ApiEndpoint]] = {}
new_by_identity: Dict[Tuple[str, str], List[ApiEndpoint]] = {}
for ep in old_endpoints.values():
identity = _method_identity(ep)
old_by_identity.setdefault(identity, []).append(ep)
for ep in new_endpoints.values():
identity = _method_identity(ep)
new_by_identity.setdefault(identity, []).append(ep)
all_identities = set(old_by_identity.keys()) | set(new_by_identity.keys())
for identity in all_identities:
old_list = old_by_identity.get(identity, [])
new_list = new_by_identity.get(identity, [])
# 简单场景:同一个方法在旧版和新版各出现一次
if len(old_list) == 1 and len(new_list) == 1:
old_ep = old_list[0]
new_ep = new_list[0]
# 判断请求方式是否改变
if old_ep.http_method != new_ep.http_method:
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,
is_method_changed=True,
old_http_method=old_ep.http_method,
)
)
# 如果同时有参数变更,额外生成参数报告
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,
)
)
# 判断路径是否改变
if old_ep.uri != new_ep.uri:
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,
is_renamed_endpoint=True,
old_uri=old_ep.uri,
)
)
# 如果同时有参数变更,额外生成参数报告(避免重复)
if old_ep.http_method == new_ep.http_method:
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,
)
)
# 如果请求方式和路径都没变,只检测参数变更
if old_ep.http_method == new_ep.http_method and old_ep.uri == new_ep.uri:
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,
)
)
elif len(old_list) == 0 and len(new_list) > 0:
# 新增接口
for new_ep in new_list:
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,
is_new_endpoint=True,
parameter_changes=[
ParameterChange(
change_type=ChangeType.ADDED,
param_name=p.name,
param_type=p.type,
required=p.required,
)
for p in new_ep.parameters
],
)
)
elif len(old_list) > 0 and len(new_list) == 0:
# 删除接口
for old_ep in old_list:
reports.append(
EndpointChangeReport(
uri=old_ep.uri,
http_method=old_ep.http_method,
controller_class=old_ep.controller_class,
method_name=old_ep.method_name,
source_file=old_ep.source_file,
is_removed_endpoint=True,
)
)
return [r for r in reports if r.has_changes]