This commit is contained in:
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
*.sh text eol=lf
|
||||||
|
scripts/* text eol=lf
|
||||||
525
.gitea/checker/api-templates/11.py
Normal file
525
.gitea/checker/api-templates/11.py
Normal file
@@ -0,0 +1,525 @@
|
|||||||
|
"""
|
||||||
|
企业微信 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)
|
||||||
51
.gitea/checker/dependency-reduced-pom.xml
Normal file
51
.gitea/checker/dependency-reduced-pom.xml
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
<groupId>com.aicheck</groupId>
|
||||||
|
<artifactId>class-checker</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<build>
|
||||||
|
<finalName>class-checker</finalName>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<artifactId>maven-compiler-plugin</artifactId>
|
||||||
|
<version>3.13.0</version>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<artifactId>maven-shade-plugin</artifactId>
|
||||||
|
<version>3.6.0</version>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<phase>package</phase>
|
||||||
|
<goals>
|
||||||
|
<goal>shade</goal>
|
||||||
|
</goals>
|
||||||
|
<configuration>
|
||||||
|
<transformers>
|
||||||
|
<transformer>
|
||||||
|
<mainClass>com.aicheck.ClassCheckMain</mainClass>
|
||||||
|
</transformer>
|
||||||
|
</transformers>
|
||||||
|
<filters>
|
||||||
|
<filter>
|
||||||
|
<artifact>*:*</artifact>
|
||||||
|
<excludes>
|
||||||
|
<exclude>META-INF/*.SF</exclude>
|
||||||
|
<exclude>META-INF/*.DSA</exclude>
|
||||||
|
<exclude>META-INF/*.RSA</exclude>
|
||||||
|
</excludes>
|
||||||
|
</filter>
|
||||||
|
</filters>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
<properties>
|
||||||
|
<maven.compiler.target>11</maven.compiler.target>
|
||||||
|
<javaparser.version>3.25.10</javaparser.version>
|
||||||
|
<maven.compiler.source>11</maven.compiler.source>
|
||||||
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
|
</properties>
|
||||||
|
</project>
|
||||||
55
.gitea/checker/notify-templates/README.md
Normal file
55
.gitea/checker/notify-templates/README.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# 类变更通知模版
|
||||||
|
|
||||||
|
Push 触发 CI 后,按变更类的后缀(`Dto` / `Vo` / `Entity` / `Model`)选用对应模版生成企业微信 Markdown 通知。
|
||||||
|
|
||||||
|
## 企微语法说明
|
||||||
|
|
||||||
|
使用 webhook **`markdown`**(v1),支持 font 三色;**不支持无序列表**,故各项以**引用块 + 换行**分行展示。
|
||||||
|
|
||||||
|
| 语法 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `#` / `##` / `###` | 标题 |
|
||||||
|
| `` `行内代码` `` | 字段名、URI |
|
||||||
|
| `>` | 引用行(每项一行) |
|
||||||
|
| `<font color="info">` | 绿:类名、新增、HTTP 方法、新类型 |
|
||||||
|
| `<font color="comment">` | 灰:说明、路径、无影响 |
|
||||||
|
| `<font color="warning">` | 橙:[修改]/[删除]、旧类型 |
|
||||||
|
|
||||||
|
## 布局约定
|
||||||
|
|
||||||
|
1. **# 类变更通知** — 头部 4 项,每项一行 `>标签:值`
|
||||||
|
2. **## 对象变更细节** — 每条变更独立引用块,字段间空行分隔
|
||||||
|
3. **## 影响范围** — 各 ### 小节内,每项一行引用
|
||||||
|
|
||||||
|
## 公共头部
|
||||||
|
|
||||||
|
```
|
||||||
|
# 类变更通知
|
||||||
|
|
||||||
|
> 变更对象:<font color="info">ApplyAttendanceChangeDto</font>(Dto)
|
||||||
|
> 修改人:<font color="comment">dongzi</font>
|
||||||
|
> 时间:<font color="comment">2026-06-07 20:14:35</font>
|
||||||
|
> 路径:<font color="comment">jnpf-ftb/.../ApplyAttendanceChangeDto.java</font>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 影响范围
|
||||||
|
|
||||||
|
| 类类型 | request | response | 类转换 |
|
||||||
|
|--------|:-------:|:--------:|:------:|
|
||||||
|
| Dto | ✅ | ❌ | ✅ |
|
||||||
|
| Vo | ❌ | ✅ | ✅ |
|
||||||
|
| Entity / Model | ❌ | ❌ | ✅ |
|
||||||
|
|
||||||
|
## 模版文件
|
||||||
|
|
||||||
|
| 文件 | 场景 |
|
||||||
|
|------|------|
|
||||||
|
| [field-description.md](./field-description.md) | 字段说明与行格式 |
|
||||||
|
| [dto.md](./dto.md) | Dto |
|
||||||
|
| [vo.md](./vo.md) | Vo |
|
||||||
|
| [entity.md](./entity.md) | Entity |
|
||||||
|
| [model.md](./model.md) | Model |
|
||||||
|
|
||||||
|
## 实现
|
||||||
|
|
||||||
|
`WeComNotifier.buildMarkdown()` · 消息类型 `markdown` · 路径取自 `ClassChangeReport.sourceFile`
|
||||||
90
.gitea/checker/notify-templates/dto.md
Normal file
90
.gitea/checker/notify-templates/dto.md
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# Dto 类变更通知模版
|
||||||
|
|
||||||
|
**识别规则**:类名以 `Dto` 结尾。
|
||||||
|
**影响范围**:request + 类转换。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 完整示例(字段修改)
|
||||||
|
|
||||||
|
```
|
||||||
|
# 类变更通知
|
||||||
|
|
||||||
|
> 变更对象:<font color="info">ApplyAttendanceChangeDto</font>(Dto)
|
||||||
|
> 修改人:<font color="comment">dongzi</font>
|
||||||
|
> 时间:<font color="comment">2026-06-07 20:14:35</font>
|
||||||
|
> 路径:<font color="comment">jnpf-ftb/jnpf-ftb-entity/src/main/java/jnpf/model/workflow/dto/ApplyAttendanceChangeDto.java</font>
|
||||||
|
|
||||||
|
## 对象变更细节
|
||||||
|
|
||||||
|
> <font color="comment">共 4 项字段变更</font>
|
||||||
|
|
||||||
|
> <font color="warning">[修改]</font> `taskId`
|
||||||
|
> 说明:<font color="comment">流程主键</font>
|
||||||
|
> 类型:<font color="warning">Integer</font> → <font color="info">String</font>
|
||||||
|
|
||||||
|
> <font color="warning">[修改]</font> `changeUserId`
|
||||||
|
> 说明:<font color="comment">变更人员id</font>
|
||||||
|
> 类型:<font color="warning">String</font> → <font color="info">Integer</font>
|
||||||
|
|
||||||
|
> <font color="info">[新增]</font> `storeId`
|
||||||
|
> 说明:<font color="comment">门店ID</font>
|
||||||
|
|
||||||
|
> <font color="warning">[删除]</font> `oldField`
|
||||||
|
> 说明:<font color="comment">已废弃字段</font>
|
||||||
|
|
||||||
|
## 影响范围
|
||||||
|
|
||||||
|
### 影响 request 接口
|
||||||
|
> <font color="info">POST</font> `/apply/clockIn`
|
||||||
|
> <font color="info">PUT</font> `/apply/clockIn/{id}`
|
||||||
|
|
||||||
|
### 类转换影响
|
||||||
|
> <font color="comment">未开启检测</font>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 示例(类删除)
|
||||||
|
|
||||||
|
```
|
||||||
|
## 对象变更细节
|
||||||
|
|
||||||
|
> <font color="warning">[已删除]</font> <font color="comment">该类文件已被移除</font>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 示例(仅类名变更)
|
||||||
|
|
||||||
|
```
|
||||||
|
## 对象变更细节
|
||||||
|
|
||||||
|
> <font color="warning">[类名变更]</font> <font color="comment">ApplyAttendanceChangeDto</font> → <font color="info">ApplyAttendanceChangeNewDto</font>
|
||||||
|
> <font color="comment">字段无变化</font>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 示例(类名 + 字段同时变更)
|
||||||
|
|
||||||
|
```
|
||||||
|
## 对象变更细节
|
||||||
|
|
||||||
|
> <font color="warning">[类名变更]</font> <font color="comment">ApplyAttendanceChangeDto</font> → <font color="info">ApplyAttendanceChangeNewDto</font>
|
||||||
|
|
||||||
|
> <font color="comment">共 1 项字段变更</font>
|
||||||
|
|
||||||
|
> <font color="warning">[修改]</font> `changeMinute`
|
||||||
|
> 说明:<font color="comment">变更分钟数</font>
|
||||||
|
> 类型:<font color="warning">Integer</font> → <font color="info">String</font>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 占位符
|
||||||
|
|
||||||
|
| 占位符 | 来源 |
|
||||||
|
|--------|------|
|
||||||
|
| 路径 | Git 相对路径,`ClassChangeReport.sourceFile` |
|
||||||
|
| 说明 | `@Schema` / 注释 |
|
||||||
51
.gitea/checker/notify-templates/entity.md
Normal file
51
.gitea/checker/notify-templates/entity.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# Entity 类变更通知模版
|
||||||
|
|
||||||
|
**识别规则**:类名以 `Entity` 结尾。
|
||||||
|
**影响范围**:仅类转换(不展示 request/response 接口)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 完整示例(字段修改)
|
||||||
|
|
||||||
|
```
|
||||||
|
# 类变更通知
|
||||||
|
|
||||||
|
> 变更对象:<font color="info">TrainingPositionEntity</font>(Entity)
|
||||||
|
> 修改人:<font color="comment">张三</font>
|
||||||
|
> 时间:<font color="comment">2026-06-07 14:30:00</font>
|
||||||
|
> 路径:<font color="comment">jnpf-ftb/jnpf-ftb-entity/src/main/java/jnpf/entity/training/TrainingPositionEntity.java</font>
|
||||||
|
|
||||||
|
## 对象变更细节
|
||||||
|
|
||||||
|
> <font color="comment">共 1 项字段变更</font>
|
||||||
|
|
||||||
|
> <font color="warning">[修改]</font> `createTime`
|
||||||
|
> 说明:<font color="comment">创建时间</font>
|
||||||
|
> 类型:<font color="warning">Date</font> → <font color="info">LocalDateTime</font>
|
||||||
|
|
||||||
|
## 影响范围
|
||||||
|
|
||||||
|
### 类转换影响
|
||||||
|
> Entity:<font color="info">TrainingPositionEntity</font>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 示例(类删除)
|
||||||
|
|
||||||
|
```
|
||||||
|
## 对象变更细节
|
||||||
|
|
||||||
|
> <font color="warning">[已删除]</font> <font color="comment">该类文件已被移除</font>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 示例(仅类名变更)
|
||||||
|
|
||||||
|
```
|
||||||
|
## 对象变更细节
|
||||||
|
|
||||||
|
> <font color="warning">[类名变更]</font> <font color="comment">TrainingPositionEntity</font> → <font color="info">TrainingPositionNewEntity</font>
|
||||||
|
> <font color="comment">字段无变化</font>
|
||||||
|
```
|
||||||
47
.gitea/checker/notify-templates/field-description.md
Normal file
47
.gitea/checker/notify-templates/field-description.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# 字段说明规则
|
||||||
|
|
||||||
|
字段变更采用 **引用块 + 换行 + font 颜色**,遵循企微 `markdown` v1(不支持列表)。
|
||||||
|
|
||||||
|
## 说明提取优先级
|
||||||
|
|
||||||
|
| 优先级 | 来源 |
|
||||||
|
|:------:|------|
|
||||||
|
| 1 | `@Schema(description = "...")` |
|
||||||
|
| 2 | `@ApiModelProperty` |
|
||||||
|
| 3 | `/** ... */` 字段注释 |
|
||||||
|
| 4 | 空串 |
|
||||||
|
|
||||||
|
## 字段变更行格式
|
||||||
|
|
||||||
|
```
|
||||||
|
> <font color="comment">共 4 项字段变更</font>
|
||||||
|
|
||||||
|
> <font color="warning">[修改]</font> `taskId`
|
||||||
|
> 说明:<font color="comment">流程主键</font>
|
||||||
|
> 类型:<font color="warning">Integer</font> → <font color="info">String</font>
|
||||||
|
|
||||||
|
> <font color="info">[新增]</font> `storeId`
|
||||||
|
> 说明:<font color="comment">门店ID</font>
|
||||||
|
```
|
||||||
|
|
||||||
|
| 操作 | 标签 | 类型行 |
|
||||||
|
|------|------|--------|
|
||||||
|
| 新增 | info `[新增]` | 无 |
|
||||||
|
| 删除 | warning `[删除]` | 无 |
|
||||||
|
| 修改 | warning `[修改]` | 仅类型变化时出现 |
|
||||||
|
|
||||||
|
- 字段间用**空行**分隔,便于对照
|
||||||
|
- 说明为空时显示 `<font color="comment">(无说明)</font>`
|
||||||
|
- 不要在 `<font>` 内嵌 `**bold**`
|
||||||
|
|
||||||
|
## 接口行格式
|
||||||
|
|
||||||
|
```
|
||||||
|
> <font color="info">POST</font> `/apply/clockIn`
|
||||||
|
```
|
||||||
|
|
||||||
|
## 实现
|
||||||
|
|
||||||
|
- `ClassFieldParser.extractFieldLabel()`
|
||||||
|
- `FieldDiffEngine` — 仅类型变化产生 `[修改]`
|
||||||
|
- `WeComNotifier.formatFieldChange()`
|
||||||
51
.gitea/checker/notify-templates/model.md
Normal file
51
.gitea/checker/notify-templates/model.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# Model 类变更通知模版
|
||||||
|
|
||||||
|
**识别规则**:类名以 `Model` 结尾。
|
||||||
|
**影响范围**:仅类转换(不展示 request/response 接口)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 完整示例(字段修改)
|
||||||
|
|
||||||
|
```
|
||||||
|
# 类变更通知
|
||||||
|
|
||||||
|
> 变更对象:<font color="info">AttendanceRuleModel</font>(Model)
|
||||||
|
> 修改人:<font color="comment">张三</font>
|
||||||
|
> 时间:<font color="comment">2026-06-07 14:30:00</font>
|
||||||
|
> 路径:<font color="comment">jnpf-ftb/jnpf-ftb-entity/src/main/java/jnpf/model/attendance/AttendanceRuleModel.java</font>
|
||||||
|
|
||||||
|
## 对象变更细节
|
||||||
|
|
||||||
|
> <font color="comment">共 1 项字段变更</font>
|
||||||
|
|
||||||
|
> <font color="warning">[修改]</font> `ruleType`
|
||||||
|
> 说明:<font color="comment">规则类型</font>
|
||||||
|
> 类型:<font color="warning">Date</font> → <font color="info">String</font>
|
||||||
|
|
||||||
|
## 影响范围
|
||||||
|
|
||||||
|
### 类转换影响
|
||||||
|
> Entity:<font color="info">AttendanceRuleEntity</font>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 示例(类删除)
|
||||||
|
|
||||||
|
```
|
||||||
|
## 对象变更细节
|
||||||
|
|
||||||
|
> <font color="warning">[已删除]</font> <font color="comment">该类文件已被移除</font>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 示例(仅类名变更)
|
||||||
|
|
||||||
|
```
|
||||||
|
## 对象变更细节
|
||||||
|
|
||||||
|
> <font color="warning">[类名变更]</font> <font color="comment">AttendanceRuleModel</font> → <font color="info">AttendanceRuleNewModel</font>
|
||||||
|
> <font color="comment">字段无变化</font>
|
||||||
|
```
|
||||||
57
.gitea/checker/notify-templates/vo.md
Normal file
57
.gitea/checker/notify-templates/vo.md
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# Vo 类变更通知模版
|
||||||
|
|
||||||
|
**识别规则**:类名以 `Vo` 或 `VO` 结尾。
|
||||||
|
**影响范围**:response + 类转换。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 完整示例(字段修改)
|
||||||
|
|
||||||
|
```
|
||||||
|
# 类变更通知
|
||||||
|
|
||||||
|
> 变更对象:<font color="info">AttendanceDetailVo</font>(Vo)
|
||||||
|
> 修改人:<font color="comment">张三</font>
|
||||||
|
> 时间:<font color="comment">2026-06-07 14:30:00</font>
|
||||||
|
> 路径:<font color="comment">jnpf-ftb/jnpf-ftb-entity/src/main/java/jnpf/model/attendance/vo/AttendanceDetailVo.java</font>
|
||||||
|
|
||||||
|
## 对象变更细节
|
||||||
|
|
||||||
|
> <font color="comment">共 2 项字段变更</font>
|
||||||
|
|
||||||
|
> <font color="info">[新增]</font> `overtimeHours`
|
||||||
|
> 说明:<font color="comment">加班时长</font>
|
||||||
|
|
||||||
|
> <font color="warning">[修改]</font> `status`
|
||||||
|
> 说明:<font color="comment">考勤状态</font>
|
||||||
|
> 类型:<font color="warning">Integer</font> → <font color="info">String</font>
|
||||||
|
|
||||||
|
## 影响范围
|
||||||
|
|
||||||
|
### 影响 response 接口
|
||||||
|
> <font color="info">GET</font> `/api/attendance/detail`
|
||||||
|
|
||||||
|
### 类转换影响
|
||||||
|
> Entity:<font color="info">AttendanceDetailEntity</font>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 示例(类删除)
|
||||||
|
|
||||||
|
```
|
||||||
|
## 对象变更细节
|
||||||
|
|
||||||
|
> <font color="warning">[已删除]</font> <font color="comment">该类文件已被移除</font>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 示例(仅类名变更)
|
||||||
|
|
||||||
|
```
|
||||||
|
## 对象变更细节
|
||||||
|
|
||||||
|
> <font color="warning">[类名变更]</font> <font color="comment">AttendanceDetailVo</font> → <font color="info">AttendanceDetailNewVo</font>
|
||||||
|
> <font color="comment">字段无变化</font>
|
||||||
|
```
|
||||||
82
.gitea/checker/pom.xml
Normal file
82
.gitea/checker/pom.xml
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<groupId>com.aicheck</groupId>
|
||||||
|
<artifactId>class-checker</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<packaging>jar</packaging>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<maven.compiler.source>11</maven.compiler.source>
|
||||||
|
<maven.compiler.target>11</maven.compiler.target>
|
||||||
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
|
<javaparser.version>3.25.10</javaparser.version>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.github.javaparser</groupId>
|
||||||
|
<artifactId>javaparser-symbol-solver-core</artifactId>
|
||||||
|
<version>${javaparser.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.yaml</groupId>
|
||||||
|
<artifactId>snakeyaml</artifactId>
|
||||||
|
<version>2.2</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.squareup.okhttp3</groupId>
|
||||||
|
<artifactId>okhttp</artifactId>
|
||||||
|
<version>4.12.0</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>info.picocli</groupId>
|
||||||
|
<artifactId>picocli</artifactId>
|
||||||
|
<version>4.7.6</version>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<finalName>class-checker</finalName>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-compiler-plugin</artifactId>
|
||||||
|
<version>3.13.0</version>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-shade-plugin</artifactId>
|
||||||
|
<version>3.6.0</version>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<phase>package</phase>
|
||||||
|
<goals>
|
||||||
|
<goal>shade</goal>
|
||||||
|
</goals>
|
||||||
|
<configuration>
|
||||||
|
<transformers>
|
||||||
|
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
|
||||||
|
<mainClass>com.aicheck.ClassCheckMain</mainClass>
|
||||||
|
</transformer>
|
||||||
|
</transformers>
|
||||||
|
<filters>
|
||||||
|
<filter>
|
||||||
|
<artifact>*:*</artifact>
|
||||||
|
<excludes>
|
||||||
|
<exclude>META-INF/*.SF</exclude>
|
||||||
|
<exclude>META-INF/*.DSA</exclude>
|
||||||
|
<exclude>META-INF/*.RSA</exclude>
|
||||||
|
</excludes>
|
||||||
|
</filter>
|
||||||
|
</filters>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
81
.gitea/checker/prompt.md
Normal file
81
.gitea/checker/prompt.md
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
---
|
||||||
|
|
||||||
|
#### 需求拆解:
|
||||||
|
**[类变更类通知]** Vo、Dto、Model、Entity **目前只针对****<u>修改 </u>****<u><font style="background-color:#FBDE28;">删除也需要</font></u>**
|
||||||
|
|
||||||
|
**需要展示的内容:**
|
||||||
|
|
||||||
|
修改人、修改时间 (方便后续前端对接)
|
||||||
|
|
||||||
|
对象变更细节:变更了(增删改查)哪些字段 + 字段说明
|
||||||
|
|
||||||
|
影响范围:类的变更影响了哪些接口的使用(展示出影响的接口List)
|
||||||
|
|
||||||
|
* **入参影响** --> dto改变 --> 展示出影响的接口List
|
||||||
|
* **类转换影响** --> dto到entity的转换(这种类型需要 配置开关 判断是否需要检测) --> 展示出Entity类?
|
||||||
|
* **对前端的影响** --> Vo的变动 --> 展示出影响的接口List
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
#### 一、请求链路
|
||||||
|
Push
|
||||||
|
|
||||||
|
↓
|
||||||
|
|
||||||
|
Gitea + act_runner
|
||||||
|
|
||||||
|
↓
|
||||||
|
|
||||||
|
Pipeline
|
||||||
|
|
||||||
|
↓
|
||||||
|
|
||||||
|
GitDiff获取变更
|
||||||
|
|
||||||
|
↓
|
||||||
|
|
||||||
|
JavaParser解析(AST Controller变化\DTO变化\VO变化\ENTITY变化)
|
||||||
|
|
||||||
|
↓
|
||||||
|
|
||||||
|
企业微信通知
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
#### 二、架构&技术选型
|
||||||
|
| 组件 | 方案 |
|
||||||
|
| --- | --- |
|
||||||
|
| Git Diff | Git |
|
||||||
|
| 源码解析 | JavaParser |
|
||||||
|
| 引用分析 | JavaParser Symbol Solver |
|
||||||
|
| Spring Endpoint扫描 | Spring Mapping AST |
|
||||||
|
| 通知 | 企业微信机器人 webhook |
|
||||||
|
| CI集成 | Gitea Actions |
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
#### 三、分层说明
|
||||||
|
| **<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 + act_runner</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> |
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
#### 四、通知模版
|
||||||
|
|
||||||
|
模版已按类类型拆分至 [notify-templates/](./notify-templates/) 目录:
|
||||||
|
|
||||||
|
| 类类型 | 模版文件 | 影响范围段落 |
|
||||||
|
|--------|----------|--------------|
|
||||||
|
| Dto | [dto.md](./notify-templates/dto.md) | request + 类转换 |
|
||||||
|
| Vo | [vo.md](./notify-templates/vo.md) | response + 类转换 |
|
||||||
|
| Entity | [entity.md](./notify-templates/entity.md) | 类转换 |
|
||||||
|
| Model | [model.md](./notify-templates/model.md) | 类转换 |
|
||||||
|
|
||||||
|
详见 [notify-templates/README.md](./notify-templates/README.md)(含企微颜色样式、全路径类名、字段说明规则)。
|
||||||
|
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package com.aicheck;
|
||||||
|
|
||||||
|
import com.aicheck.analyzer.ClassChangeAnalyzer;
|
||||||
|
import com.aicheck.analyzer.EndpointIndexBuilder;
|
||||||
|
import com.aicheck.config.AppConfig;
|
||||||
|
import com.aicheck.git.GitChangeScanner;
|
||||||
|
import com.aicheck.model.ApiEndpoint;
|
||||||
|
import com.aicheck.model.ClassChangeReport;
|
||||||
|
import com.aicheck.notify.WeComNotifier;
|
||||||
|
import picocli.CommandLine;
|
||||||
|
import picocli.CommandLine.Command;
|
||||||
|
import picocli.CommandLine.Option;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.Callable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CLI 入口:加载配置 → 扫描 git 变更 → 分析影响 → 输出/发送企微通知。
|
||||||
|
*/
|
||||||
|
@Command(name = "class-checker", mixinStandardHelpOptions = true,
|
||||||
|
description = "检测 Vo/Dto/Entity/Model 类变更并发送企业微信通知")
|
||||||
|
public class ClassCheckMain implements Callable<Integer> {
|
||||||
|
@Option(names = "--config", required = true, description = "配置文件路径")
|
||||||
|
private Path config;
|
||||||
|
|
||||||
|
@Option(names = "--repo-root", required = true, description = "仓库根目录")
|
||||||
|
private Path repoRoot;
|
||||||
|
|
||||||
|
@Option(names = "--old-sha", required = true, description = "旧提交 SHA")
|
||||||
|
private String oldSha;
|
||||||
|
|
||||||
|
@Option(names = "--new-sha", required = true, description = "新提交 SHA")
|
||||||
|
private String newSha;
|
||||||
|
|
||||||
|
@Option(names = "--modifier", required = true, description = "修改人")
|
||||||
|
private String modifier;
|
||||||
|
|
||||||
|
@Option(names = "--modify-time", required = true, description = "修改时间")
|
||||||
|
private String modifyTime;
|
||||||
|
|
||||||
|
/** 程序入口 */
|
||||||
|
public static void main(String[] args) {
|
||||||
|
int exitCode = new CommandLine(new ClassCheckMain()).execute(args);
|
||||||
|
System.exit(exitCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 主流程:索引接口 → 分析变更 → 通知 */
|
||||||
|
@Override
|
||||||
|
public Integer call() throws Exception {
|
||||||
|
AppConfig appConfig = AppConfig.load(config.toAbsolutePath());
|
||||||
|
if (!appConfig.isEnabled()) {
|
||||||
|
System.out.println("类变更检测已关闭(class_check.enabled=false)");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
GitChangeScanner gitScanner = new GitChangeScanner(repoRoot.toAbsolutePath());
|
||||||
|
EndpointIndexBuilder indexBuilder = new EndpointIndexBuilder();
|
||||||
|
Map<String, ApiEndpoint> endpointIndex = indexBuilder.buildIndex(repoRoot.toAbsolutePath(), appConfig);
|
||||||
|
System.out.println("已索引接口数量: " + endpointIndex.size());
|
||||||
|
|
||||||
|
ClassChangeAnalyzer analyzer = new ClassChangeAnalyzer(gitScanner);
|
||||||
|
List<ClassChangeReport> reports = analyzer.analyze(
|
||||||
|
repoRoot.toAbsolutePath(), appConfig, oldSha, newSha, endpointIndex);
|
||||||
|
System.out.println("检测到需通知的类变更数量: " + reports.size());
|
||||||
|
|
||||||
|
if (reports.isEmpty()) {
|
||||||
|
if (appConfig.isOnlyOnChange()) {
|
||||||
|
System.out.println("无类变更,静默退出");
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
WeComNotifier notifier = new WeComNotifier();
|
||||||
|
if (appConfig.isWecomEnabled()) {
|
||||||
|
notifier.sendAll(appConfig.getWecomWebhookUrl(), reports, modifier, modifyTime);
|
||||||
|
} else {
|
||||||
|
notifier.logAll(reports, modifier, modifyTime);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package com.aicheck.analyzer;
|
||||||
|
|
||||||
|
import com.aicheck.config.AppConfig;
|
||||||
|
import com.aicheck.git.GitChangeScanner;
|
||||||
|
import com.aicheck.model.ChangedClassFile;
|
||||||
|
import com.aicheck.model.ClassChangeKind;
|
||||||
|
import com.aicheck.model.ClassChangeReport;
|
||||||
|
import com.aicheck.model.FieldChange;
|
||||||
|
import com.aicheck.model.FieldInfo;
|
||||||
|
import com.aicheck.parser.ClassDeclParser;
|
||||||
|
import com.aicheck.parser.ClassFieldParser;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 编排 git 扫描、字段 diff、影响分析,生成待通知的 ClassChangeReport 列表。
|
||||||
|
*/
|
||||||
|
public class ClassChangeAnalyzer {
|
||||||
|
private final GitChangeScanner gitScanner;
|
||||||
|
private final ClassFieldParser classFieldParser = new ClassFieldParser();
|
||||||
|
private final FieldDiffEngine fieldDiffEngine = new FieldDiffEngine();
|
||||||
|
private final ImpactAnalyzer impactAnalyzer = new ImpactAnalyzer();
|
||||||
|
private final ClassDeclParser classDeclParser = new ClassDeclParser();
|
||||||
|
|
||||||
|
public ClassChangeAnalyzer(GitChangeScanner gitScanner) {
|
||||||
|
this.gitScanner = gitScanner;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 扫描变更文件并逐条分析,无实质变更的 MODIFIED 会被跳过 */
|
||||||
|
public List<ClassChangeReport> analyze(Path repoRoot, AppConfig config, String oldSha, String newSha,
|
||||||
|
Map<String, com.aicheck.model.ApiEndpoint> endpointIndex) throws IOException {
|
||||||
|
List<ChangedClassFile> changedFiles = gitScanner.scanChangedClasses(oldSha, newSha);
|
||||||
|
List<ClassChangeReport> reports = new ArrayList<>();
|
||||||
|
|
||||||
|
for (ChangedClassFile changedFile : changedFiles) {
|
||||||
|
if (changedFile.getStatus() == ChangedClassFile.ChangeStatus.DELETED) {
|
||||||
|
reports.add(analyzeDeleted(changedFile, config, repoRoot, oldSha, endpointIndex));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ClassChangeReport report = analyzeModifiedOrRenamed(changedFile, config, repoRoot, oldSha, newSha, endpointIndex);
|
||||||
|
if (report != null) {
|
||||||
|
reports.add(report);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return reports;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 处理删除:标记 DELETED 并分析影响(基于旧源码) */
|
||||||
|
private ClassChangeReport analyzeDeleted(ChangedClassFile changedFile, AppConfig config, Path repoRoot,
|
||||||
|
String oldSha, Map<String, com.aicheck.model.ApiEndpoint> endpointIndex)
|
||||||
|
throws IOException {
|
||||||
|
String path = changedFile.getRelativePath();
|
||||||
|
String oldSource = gitScanner.readFileAtCommit(oldSha, path);
|
||||||
|
|
||||||
|
ClassChangeReport report = new ClassChangeReport(
|
||||||
|
changedFile.getClassName(),
|
||||||
|
null,
|
||||||
|
changedFile.getClassType(),
|
||||||
|
ClassChangeKind.DELETED,
|
||||||
|
path,
|
||||||
|
config.isDtoEntityConversionEnabled()
|
||||||
|
);
|
||||||
|
impactAnalyzer.analyze(report, endpointIndex, config, repoRoot, oldSource, oldSource);
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 处理修改/重命名:字段 diff → 判定 changeKind → 影响分析 */
|
||||||
|
private ClassChangeReport analyzeModifiedOrRenamed(ChangedClassFile changedFile, AppConfig config,
|
||||||
|
Path repoRoot, String oldSha, String newSha,
|
||||||
|
Map<String, com.aicheck.model.ApiEndpoint> endpointIndex)
|
||||||
|
throws IOException {
|
||||||
|
String oldPath = changedFile.pathForOldCommit();
|
||||||
|
String newPath = changedFile.getRelativePath();
|
||||||
|
|
||||||
|
String oldSource = gitScanner.readFileAtCommit(oldSha, oldPath);
|
||||||
|
String newSource = gitScanner.readFileAtCommit(newSha, newPath);
|
||||||
|
if (newSource == null || newSource.isBlank()) {
|
||||||
|
newSource = gitScanner.readFileAtHead(newPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
String oldFallback = ClassDeclParser.classNameFromPath(oldPath);
|
||||||
|
String newFallback = ClassDeclParser.classNameFromPath(newPath);
|
||||||
|
String oldClassName = changedFile.getOldClassName() != null
|
||||||
|
? changedFile.getOldClassName()
|
||||||
|
: classDeclParser.resolveClassName(oldSource, oldFallback);
|
||||||
|
String newClassName = classDeclParser.resolveClassName(newSource, newFallback);
|
||||||
|
|
||||||
|
List<FieldInfo> oldFields = classFieldParser.parseFields(oldSource, oldClassName);
|
||||||
|
List<FieldInfo> newFields = classFieldParser.parseFields(newSource, newClassName);
|
||||||
|
List<FieldChange> fieldChanges = fieldDiffEngine.diff(oldFields, newFields);
|
||||||
|
|
||||||
|
boolean renamed = !oldClassName.equals(newClassName);
|
||||||
|
ClassChangeKind changeKind;
|
||||||
|
if (renamed && fieldChanges.isEmpty()) {
|
||||||
|
changeKind = ClassChangeKind.RENAME_ONLY;
|
||||||
|
} else if (renamed) {
|
||||||
|
changeKind = ClassChangeKind.RENAME_AND_FIELDS;
|
||||||
|
} else if (!fieldChanges.isEmpty()) {
|
||||||
|
changeKind = ClassChangeKind.FIELDS_ONLY;
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
ClassChangeReport report = new ClassChangeReport(
|
||||||
|
newClassName,
|
||||||
|
renamed ? oldClassName : null,
|
||||||
|
changedFile.getClassType(),
|
||||||
|
changeKind,
|
||||||
|
newPath,
|
||||||
|
config.isDtoEntityConversionEnabled()
|
||||||
|
);
|
||||||
|
fieldChanges.forEach(report::addFieldChange);
|
||||||
|
impactAnalyzer.analyze(report, endpointIndex, config, repoRoot, newSource, oldSource);
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package com.aicheck.analyzer;
|
||||||
|
|
||||||
|
import com.aicheck.config.AppConfig;
|
||||||
|
import com.aicheck.model.ApiEndpoint;
|
||||||
|
import com.aicheck.parser.EndpointParser;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预扫描 Controller/Feign 目录,构建 endpointKey → ApiEndpoint 索引。
|
||||||
|
*/
|
||||||
|
public class EndpointIndexBuilder {
|
||||||
|
private final EndpointParser endpointParser = new EndpointParser();
|
||||||
|
|
||||||
|
/** 合并 Controller 与 Feign 扫描结果 */
|
||||||
|
public Map<String, ApiEndpoint> buildIndex(Path repoRoot, AppConfig config) throws IOException {
|
||||||
|
Map<String, ApiEndpoint> index = new LinkedHashMap<>();
|
||||||
|
for (String dir : config.getControllerScanDirs()) {
|
||||||
|
addEndpoints(index, endpointParser.scanControllerDirectory(repoRoot.resolve(dir), dir));
|
||||||
|
}
|
||||||
|
for (String dir : config.getFeignScanDirs()) {
|
||||||
|
addEndpoints(index, endpointParser.scanFeignDirectory(repoRoot.resolve(dir), dir));
|
||||||
|
}
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按 endpointKey 去重写入索引 */
|
||||||
|
private void addEndpoints(Map<String, ApiEndpoint> index, List<ApiEndpoint> endpoints) {
|
||||||
|
for (ApiEndpoint endpoint : endpoints) {
|
||||||
|
index.putIfAbsent(endpoint.endpointKey(), endpoint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package com.aicheck.analyzer;
|
||||||
|
|
||||||
|
import com.aicheck.model.FieldChange;
|
||||||
|
import com.aicheck.model.FieldInfo;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对比新旧字段列表,产出新增/删除/类型修改(纯注释变更忽略)。
|
||||||
|
*/
|
||||||
|
public class FieldDiffEngine {
|
||||||
|
|
||||||
|
/** 按字段名对比,仅类型变化记为 MODIFIED */
|
||||||
|
public List<FieldChange> diff(List<FieldInfo> oldFields, List<FieldInfo> newFields) {
|
||||||
|
Map<String, FieldInfo> oldMap = toMap(oldFields);
|
||||||
|
Map<String, FieldInfo> newMap = toMap(newFields);
|
||||||
|
List<FieldChange> changes = new ArrayList<>();
|
||||||
|
|
||||||
|
for (Map.Entry<String, FieldInfo> entry : newMap.entrySet()) {
|
||||||
|
FieldInfo oldField = oldMap.get(entry.getKey());
|
||||||
|
FieldInfo newField = entry.getValue();
|
||||||
|
if (oldField == null) {
|
||||||
|
changes.add(FieldChange.added(newField));
|
||||||
|
} else if (!oldField.getType().equals(newField.getType())) {
|
||||||
|
changes.add(FieldChange.modified(oldField, newField, buildTypeDetail(oldField, newField)));
|
||||||
|
}
|
||||||
|
// 仅 @Schema / 注释文案变化:不纳入字段变更
|
||||||
|
}
|
||||||
|
|
||||||
|
for (Map.Entry<String, FieldInfo> entry : oldMap.entrySet()) {
|
||||||
|
if (!newMap.containsKey(entry.getKey())) {
|
||||||
|
changes.add(FieldChange.removed(entry.getValue()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 字段列表转 LinkedHashMap,保持声明顺序 */
|
||||||
|
private Map<String, FieldInfo> toMap(List<FieldInfo> fields) {
|
||||||
|
Map<String, FieldInfo> map = new LinkedHashMap<>();
|
||||||
|
for (FieldInfo field : fields) {
|
||||||
|
map.put(field.getName(), field);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 构造类型变化描述,如 Integer → String */
|
||||||
|
private String buildTypeDetail(FieldInfo oldField, FieldInfo newField) {
|
||||||
|
if (oldField.getType().equals(newField.getType())) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return oldField.getType() + " → " + newField.getType();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package com.aicheck.analyzer;
|
||||||
|
|
||||||
|
import com.aicheck.config.AppConfig;
|
||||||
|
import com.aicheck.model.ApiEndpoint;
|
||||||
|
import com.aicheck.model.ClassChangeReport;
|
||||||
|
import com.aicheck.model.ClassType;
|
||||||
|
import com.aicheck.parser.ConversionParser;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据变更报告匹配受影响的 HTTP 接口与 Dto→Entity 转换目标。
|
||||||
|
*/
|
||||||
|
public class ImpactAnalyzer {
|
||||||
|
private final ConversionParser conversionParser = new ConversionParser();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 填充 report 的影响列表;RENAME_ONLY 跳过;Entity/Model 不匹配接口。
|
||||||
|
*/
|
||||||
|
public void analyze(ClassChangeReport report, Map<String, ApiEndpoint> endpointIndex,
|
||||||
|
AppConfig config, Path repoRoot, String newSource, String oldSource) throws IOException {
|
||||||
|
if (report.isRenameOnly()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<String> matchNames = namesForMatching(report);
|
||||||
|
|
||||||
|
if (report.getClassType() != ClassType.ENTITY && report.getClassType() != ClassType.MODEL) {
|
||||||
|
matchEndpoints(report, endpointIndex, matchNames);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.isDtoEntityConversionEnabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
analyzeConversion(report, config, repoRoot, newSource, oldSource, matchNames);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 收集新旧类名用于接口/转换匹配 */
|
||||||
|
private Set<String> namesForMatching(ClassChangeReport report) {
|
||||||
|
Set<String> names = new LinkedHashSet<>();
|
||||||
|
names.add(report.getClassName());
|
||||||
|
if (report.getOldClassName() != null && !report.getOldClassName().isBlank()) {
|
||||||
|
names.add(report.getOldClassName());
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 在接口索引中匹配入参/返回类型 */
|
||||||
|
private void matchEndpoints(ClassChangeReport report, Map<String, ApiEndpoint> endpointIndex,
|
||||||
|
Set<String> matchNames) {
|
||||||
|
List<ApiEndpoint> inputImpacts = new ArrayList<>();
|
||||||
|
List<ApiEndpoint> frontendImpacts = new ArrayList<>();
|
||||||
|
|
||||||
|
for (ApiEndpoint endpoint : endpointIndex.values()) {
|
||||||
|
if (matchesAnyType(endpoint.getParamTypes(), matchNames)) {
|
||||||
|
inputImpacts.add(endpoint);
|
||||||
|
}
|
||||||
|
if (matchesAnyType(endpoint.getReturnTypes(), matchNames)) {
|
||||||
|
frontendImpacts.add(endpoint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inputImpacts.forEach(report::addInputImpact);
|
||||||
|
frontendImpacts.forEach(report::addFrontendImpact);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 扫描 convert 方法与 BeanUtils.copyProperties 关联的 Entity */
|
||||||
|
private void analyzeConversion(ClassChangeReport report, AppConfig config, Path repoRoot,
|
||||||
|
String newSource, String oldSource, Set<String> matchNames) throws IOException {
|
||||||
|
for (String name : matchNames) {
|
||||||
|
if (newSource != null && !newSource.isBlank()) {
|
||||||
|
conversionParser.findConvertTargetsInClass(newSource, name)
|
||||||
|
.forEach(report::addConversionEntity);
|
||||||
|
}
|
||||||
|
if (oldSource != null && !oldSource.isBlank() && !oldSource.equals(newSource)) {
|
||||||
|
conversionParser.findConvertTargetsInClass(oldSource, name)
|
||||||
|
.forEach(report::addConversionEntity);
|
||||||
|
}
|
||||||
|
for (String scanDir : config.getConversionScanDirs()) {
|
||||||
|
conversionParser.findBeanUtilsTargets(repoRoot.resolve(scanDir), name)
|
||||||
|
.forEach(report::addConversionEntity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 类型集合中是否包含任一目标类名 */
|
||||||
|
private boolean matchesAnyType(Collection<String> types, Set<String> classNames) {
|
||||||
|
if (types == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (String type : types) {
|
||||||
|
if (classNames.contains(type)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
144
.gitea/checker/src/main/java/com/autoCheck/config/AppConfig.java
Normal file
144
.gitea/checker/src/main/java/com/autoCheck/config/AppConfig.java
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
package com.aicheck.config;
|
||||||
|
|
||||||
|
import org.yaml.snakeyaml.Yaml;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取 .gitea/config.yaml,提供检测开关、扫描目录、企微配置等。
|
||||||
|
*/
|
||||||
|
public class AppConfig {
|
||||||
|
private boolean enabled = true;
|
||||||
|
private boolean dtoEntityConversionEnabled = true;
|
||||||
|
private List<String> modelDirs = new ArrayList<>();
|
||||||
|
private List<String> controllerScanDirs = new ArrayList<>();
|
||||||
|
private List<String> feignScanDirs = new ArrayList<>();
|
||||||
|
private List<String> conversionScanDirs = new ArrayList<>();
|
||||||
|
private String wecomWebhookUrl = "";
|
||||||
|
private boolean wecomEnabled = true;
|
||||||
|
private boolean onlyOnChange = true;
|
||||||
|
|
||||||
|
/** 从 YAML 文件加载配置 */
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public static AppConfig load(Path configPath) throws IOException {
|
||||||
|
Yaml yaml = new Yaml();
|
||||||
|
Map<String, Object> root;
|
||||||
|
try (InputStream in = Files.newInputStream(configPath)) {
|
||||||
|
root = yaml.load(in);
|
||||||
|
}
|
||||||
|
if (root == null) {
|
||||||
|
root = Map.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
AppConfig config = new AppConfig();
|
||||||
|
Map<String, Object> classCheck = mapOrEmpty(root.get("class_check"));
|
||||||
|
config.enabled = boolOrDefault(classCheck.get("enabled"), true);
|
||||||
|
|
||||||
|
Map<String, Object> conversion = mapOrEmpty(classCheck.get("dto_entity_conversion"));
|
||||||
|
config.dtoEntityConversionEnabled = boolOrDefault(conversion.get("enabled"), true);
|
||||||
|
|
||||||
|
config.modelDirs = stringList(classCheck.get("model_dirs"));
|
||||||
|
Map<String, Object> endpointScan = mapOrEmpty(classCheck.get("endpoint_scan"));
|
||||||
|
config.controllerScanDirs = stringList(endpointScan.get("controllers"));
|
||||||
|
config.feignScanDirs = stringList(endpointScan.get("feign_apis"));
|
||||||
|
config.conversionScanDirs = stringList(classCheck.get("conversion_scan"));
|
||||||
|
|
||||||
|
Map<String, Object> wecom = mapOrEmpty(root.get("wecom"));
|
||||||
|
config.wecomWebhookUrl = stringOrEmpty(wecom.get("webhook_url"));
|
||||||
|
config.wecomEnabled = boolOrDefault(wecom.get("enabled"), true);
|
||||||
|
|
||||||
|
Map<String, Object> notify = mapOrEmpty(root.get("notify"));
|
||||||
|
config.onlyOnChange = boolOrDefault(notify.get("only_on_change"), true);
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 安全转为 Map,非 Map 则返回空 Map */
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private static Map<String, Object> mapOrEmpty(Object value) {
|
||||||
|
if (value instanceof Map) {
|
||||||
|
return (Map<String, Object>) value;
|
||||||
|
}
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 安全转为字符串列表 */
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private static List<String> stringList(Object value) {
|
||||||
|
if (value instanceof List) {
|
||||||
|
List<?> list = (List<?>) value;
|
||||||
|
List<String> result = new ArrayList<>();
|
||||||
|
for (Object item : list) {
|
||||||
|
if (item != null) {
|
||||||
|
result.add(item.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return new ArrayList<>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 安全转为 boolean,缺省用 defaultValue */
|
||||||
|
private static boolean boolOrDefault(Object value, boolean defaultValue) {
|
||||||
|
if (value instanceof Boolean) {
|
||||||
|
return (Boolean) value;
|
||||||
|
}
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 安全转为字符串,null 则空串 */
|
||||||
|
private static String stringOrEmpty(Object value) {
|
||||||
|
return value == null ? "" : value.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 类变更检测总开关 */
|
||||||
|
public boolean isEnabled() {
|
||||||
|
return enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dto→Entity 类转换检测开关 */
|
||||||
|
public boolean isDtoEntityConversionEnabled() {
|
||||||
|
return dtoEntityConversionEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 模型类目录(预留,当前扫描仍按类名后缀) */
|
||||||
|
public List<String> getModelDirs() {
|
||||||
|
return modelDirs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Controller 扫描目录 */
|
||||||
|
public List<String> getControllerScanDirs() {
|
||||||
|
return controllerScanDirs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Feign 接口扫描目录 */
|
||||||
|
public List<String> getFeignScanDirs() {
|
||||||
|
return feignScanDirs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** BeanUtils / convert 扫描目录 */
|
||||||
|
public List<String> getConversionScanDirs() {
|
||||||
|
return conversionScanDirs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 企微 Webhook 地址 */
|
||||||
|
public String getWecomWebhookUrl() {
|
||||||
|
return wecomWebhookUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 企微通知开关 */
|
||||||
|
public boolean isWecomEnabled() {
|
||||||
|
return wecomEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 无变更时是否打印提示后退出 */
|
||||||
|
public boolean isOnlyOnChange() {
|
||||||
|
return onlyOnChange;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
package com.aicheck.git;
|
||||||
|
|
||||||
|
import com.aicheck.model.ChangedClassFile;
|
||||||
|
import com.aicheck.model.ClassType;
|
||||||
|
import com.aicheck.parser.ClassDeclParser;
|
||||||
|
|
||||||
|
import java.io.BufferedReader;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行 git diff,识别 Dto/Vo/Entity/Model 的修改、删除、重命名(含 R* 与同目录 D+A 配对)。
|
||||||
|
*/
|
||||||
|
public class GitChangeScanner {
|
||||||
|
private final Path repoRoot;
|
||||||
|
private final ClassDeclParser classDeclParser = new ClassDeclParser();
|
||||||
|
|
||||||
|
public GitChangeScanner(Path repoRoot) {
|
||||||
|
this.repoRoot = repoRoot;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 扫描两次提交间的模型类变更 */
|
||||||
|
public List<ChangedClassFile> scanChangedClasses(String oldSha, String newSha) throws IOException {
|
||||||
|
List<String> lines = runGit("diff", "--name-status", oldSha, newSha);
|
||||||
|
List<ChangedClassFile> deletions = new ArrayList<>();
|
||||||
|
Map<String, PendingAdd> additionsByParent = new LinkedHashMap<>();
|
||||||
|
List<ChangedClassFile> result = new ArrayList<>();
|
||||||
|
|
||||||
|
for (String line : lines) {
|
||||||
|
if (line.isBlank()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String[] parts = line.split("\t");
|
||||||
|
if (parts.length < 2) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String status = parts[0].trim();
|
||||||
|
|
||||||
|
if (status.startsWith("R") && parts.length >= 3) {
|
||||||
|
ChangedClassFile renamed = buildRenamed(parts[1], parts[2], oldSha, newSha);
|
||||||
|
if (renamed != null) {
|
||||||
|
result.add(renamed);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
String path = normalizePath(parts[parts.length - 1]);
|
||||||
|
if (!path.endsWith(".java")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
String fallbackName = ClassDeclParser.classNameFromPath(path);
|
||||||
|
ClassType classType = ClassType.fromClassName(fallbackName);
|
||||||
|
if (classType == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.equals("A")) {
|
||||||
|
String newSource = readFileAtCommit(newSha, path);
|
||||||
|
String className = classDeclParser.resolveClassName(newSource, fallbackName);
|
||||||
|
additionsByParent.computeIfAbsent(parentDir(path), k -> new PendingAdd())
|
||||||
|
.add(path, className, classType, newSource);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.equals("D")) {
|
||||||
|
String oldSource = readFileAtCommit(oldSha, path);
|
||||||
|
String className = classDeclParser.resolveClassName(oldSource, fallbackName);
|
||||||
|
deletions.add(new ChangedClassFile(path, ChangedClassFile.ChangeStatus.DELETED, className, classType));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.startsWith("M")) {
|
||||||
|
ChangedClassFile modified = buildModified(path, oldSha, newSha, fallbackName, classType);
|
||||||
|
if (modified != null) {
|
||||||
|
result.add(modified);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pairDeleteAndAdd(deletions, additionsByParent, oldSha, result);
|
||||||
|
result.addAll(deletions);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 处理 git R 状态:路径重命名 */
|
||||||
|
private ChangedClassFile buildRenamed(String oldPathRaw, String newPathRaw,
|
||||||
|
String oldSha, String newSha) throws IOException {
|
||||||
|
String oldPath = normalizePath(oldPathRaw);
|
||||||
|
String newPath = normalizePath(newPathRaw);
|
||||||
|
if (!oldPath.endsWith(".java") || !newPath.endsWith(".java")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String oldFallback = ClassDeclParser.classNameFromPath(oldPath);
|
||||||
|
String newFallback = ClassDeclParser.classNameFromPath(newPath);
|
||||||
|
ClassType classType = ClassType.fromClassName(newFallback);
|
||||||
|
if (classType == null) {
|
||||||
|
classType = ClassType.fromClassName(oldFallback);
|
||||||
|
}
|
||||||
|
if (classType == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String oldSource = readFileAtCommit(oldSha, oldPath);
|
||||||
|
String newSource = readFileAtCommit(newSha, newPath);
|
||||||
|
String oldClassName = classDeclParser.resolveClassName(oldSource, oldFallback);
|
||||||
|
String newClassName = classDeclParser.resolveClassName(newSource, newFallback);
|
||||||
|
|
||||||
|
return new ChangedClassFile(newPath, oldPath, ChangedClassFile.ChangeStatus.RENAMED,
|
||||||
|
newClassName, oldClassName, classType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 处理 M 状态:同路径下对比 AST 类名判断是否重命名 */
|
||||||
|
private ChangedClassFile buildModified(String path, String oldSha, String newSha,
|
||||||
|
String fallbackName, ClassType classType) throws IOException {
|
||||||
|
String oldSource = readFileAtCommit(oldSha, path);
|
||||||
|
String newSource = readFileAtCommit(newSha, path);
|
||||||
|
if (newSource == null || newSource.isBlank()) {
|
||||||
|
newSource = readFileAtHead(path);
|
||||||
|
}
|
||||||
|
String oldClassName = classDeclParser.resolveClassName(oldSource, fallbackName);
|
||||||
|
String newClassName = classDeclParser.resolveClassName(newSource, fallbackName);
|
||||||
|
|
||||||
|
if (oldClassName.equals(newClassName)) {
|
||||||
|
return new ChangedClassFile(path, ChangedClassFile.ChangeStatus.MODIFIED,
|
||||||
|
newClassName, classType);
|
||||||
|
}
|
||||||
|
return new ChangedClassFile(path, path, ChangedClassFile.ChangeStatus.RENAMED,
|
||||||
|
newClassName, oldClassName, classType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 同目录 D+A 配对为 RENAMED(Git 未显式标记 R 时) */
|
||||||
|
private void pairDeleteAndAdd(List<ChangedClassFile> deletions,
|
||||||
|
Map<String, PendingAdd> additionsByParent,
|
||||||
|
String oldSha,
|
||||||
|
List<ChangedClassFile> result) throws IOException {
|
||||||
|
List<ChangedClassFile> unpaired = new ArrayList<>();
|
||||||
|
|
||||||
|
for (ChangedClassFile deleted : deletions) {
|
||||||
|
String parent = parentDir(deleted.getRelativePath());
|
||||||
|
PendingAdd pending = additionsByParent.get(parent);
|
||||||
|
if (pending == null || pending.isEmpty()) {
|
||||||
|
unpaired.add(deleted);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
PendingAdd.Candidate candidate = pending.poll(deleted.getClassType());
|
||||||
|
if (candidate == null) {
|
||||||
|
unpaired.add(deleted);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
String oldSource = readFileAtCommit(oldSha, deleted.getRelativePath());
|
||||||
|
String oldClassName = classDeclParser.resolveClassName(oldSource, deleted.getClassName());
|
||||||
|
result.add(new ChangedClassFile(candidate.path(), deleted.getRelativePath(),
|
||||||
|
ChangedClassFile.ChangeStatus.RENAMED,
|
||||||
|
candidate.className(), oldClassName, deleted.getClassType()));
|
||||||
|
}
|
||||||
|
|
||||||
|
deletions.clear();
|
||||||
|
deletions.addAll(unpaired);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取路径父目录,用于 D+A 配对 */
|
||||||
|
private static String parentDir(String path) {
|
||||||
|
int idx = path.lastIndexOf('/');
|
||||||
|
return idx >= 0 ? path.substring(0, idx) : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取指定 commit 下的文件内容 */
|
||||||
|
public String readFileAtCommit(String commitSha, String relativePath) throws IOException {
|
||||||
|
List<String> lines = runGit("show", commitSha + ":" + relativePath);
|
||||||
|
if (lines.isEmpty()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (lines.size() == 1 && lines.get(0).startsWith("fatal:")) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return String.join("\n", lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取工作区 HEAD 文件(commit 中缺失时的回退) */
|
||||||
|
public String readFileAtHead(String relativePath) throws IOException {
|
||||||
|
Path file = repoRoot.resolve(relativePath);
|
||||||
|
if (!Files.exists(file)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Files.readString(file, StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 在 repoRoot 下执行 git 命令并返回 stdout 行 */
|
||||||
|
private List<String> runGit(String... args) throws IOException {
|
||||||
|
String[] command = new String[args.length + 3];
|
||||||
|
command[0] = "git";
|
||||||
|
command[1] = "-C";
|
||||||
|
command[2] = repoRoot.toString();
|
||||||
|
System.arraycopy(args, 0, command, 3, args.length);
|
||||||
|
|
||||||
|
ProcessBuilder builder = new ProcessBuilder(command);
|
||||||
|
builder.redirectErrorStream(true);
|
||||||
|
Process process = builder.start();
|
||||||
|
|
||||||
|
List<String> output = new ArrayList<>();
|
||||||
|
try (BufferedReader reader = new BufferedReader(
|
||||||
|
new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) {
|
||||||
|
String line;
|
||||||
|
while ((line = reader.readLine()) != null) {
|
||||||
|
output.add(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
int exitCode = process.waitFor();
|
||||||
|
if (exitCode != 0 && !isBenignGitShowFailure(args, output)) {
|
||||||
|
throw new IOException("git 命令失败: " + String.join(" ", command)
|
||||||
|
+ "\n" + String.join("\n", output));
|
||||||
|
}
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw new IOException("git 命令被中断", e);
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** git show 文件不存在等情况视为可忽略 */
|
||||||
|
private boolean isBenignGitShowFailure(String[] args, List<String> output) {
|
||||||
|
if (args.length > 0 && "show".equals(args[0])) {
|
||||||
|
String joined = String.join("\n", output).toLowerCase(Locale.ROOT);
|
||||||
|
return joined.contains("exists on disk") || joined.contains("bad object")
|
||||||
|
|| joined.contains("path") && joined.contains("does not exist");
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 统一路径分隔符为 / */
|
||||||
|
private String normalizePath(String path) {
|
||||||
|
return path.replace("\\", "/");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 同目录新增文件缓冲,供 D+A 配对 */
|
||||||
|
private static final class PendingAdd {
|
||||||
|
private final Map<ClassType, List<Candidate>> byType = new HashMap<>();
|
||||||
|
|
||||||
|
void add(String path, String className, ClassType classType, String source) {
|
||||||
|
byType.computeIfAbsent(classType, k -> new ArrayList<>())
|
||||||
|
.add(new Candidate(path, className));
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean isEmpty() {
|
||||||
|
return byType.values().stream().allMatch(List::isEmpty);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按类型取出一个候选新增文件 */
|
||||||
|
Candidate poll(ClassType classType) {
|
||||||
|
List<Candidate> list = byType.get(classType);
|
||||||
|
if (list == null || list.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return list.remove(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class Candidate {
|
||||||
|
private final String path;
|
||||||
|
private final String className;
|
||||||
|
|
||||||
|
private Candidate(String path, String className) {
|
||||||
|
this.path = path;
|
||||||
|
this.className = className;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String path() {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String className() {
|
||||||
|
return className;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package com.aicheck.model;
|
||||||
|
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 索引中的 HTTP/Feign 接口:方法、URI、入参/返回类型简单名。
|
||||||
|
*/
|
||||||
|
public class ApiEndpoint {
|
||||||
|
private final String httpMethod;
|
||||||
|
private final String uri;
|
||||||
|
private final String sourceFile;
|
||||||
|
private final Set<String> paramTypes;
|
||||||
|
private final Set<String> returnTypes;
|
||||||
|
|
||||||
|
public ApiEndpoint(String httpMethod, String uri, String sourceFile,
|
||||||
|
Set<String> paramTypes, Set<String> returnTypes) {
|
||||||
|
this.httpMethod = httpMethod;
|
||||||
|
this.uri = uri;
|
||||||
|
this.sourceFile = sourceFile;
|
||||||
|
this.paramTypes = paramTypes == null ? Set.of() : new LinkedHashSet<>(paramTypes);
|
||||||
|
this.returnTypes = returnTypes == null ? Set.of() : new LinkedHashSet<>(returnTypes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getHttpMethod() {
|
||||||
|
return httpMethod;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getUri() {
|
||||||
|
return uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 定义该接口的 Java 源文件相对路径 */
|
||||||
|
public String getSourceFile() {
|
||||||
|
return sourceFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 入参涉及的类型简单名集合 */
|
||||||
|
public Set<String> getParamTypes() {
|
||||||
|
return paramTypes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 返回值涉及的类型简单名集合(已剥离泛型包装) */
|
||||||
|
public Set<String> getReturnTypes() {
|
||||||
|
return returnTypes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 去重用键:METHOD + URI */
|
||||||
|
public String endpointKey() {
|
||||||
|
return httpMethod + " " + uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 通知展示行:GET /api/foo */
|
||||||
|
public String displayLine() {
|
||||||
|
return httpMethod + " " + uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package com.aicheck.model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Git 扫描得到的单个 Java 模型类变更记录。
|
||||||
|
*/
|
||||||
|
public class ChangedClassFile {
|
||||||
|
/** Git diff 状态:修改 / 删除 / 重命名 */
|
||||||
|
public enum ChangeStatus {
|
||||||
|
MODIFIED, DELETED, RENAMED
|
||||||
|
}
|
||||||
|
|
||||||
|
private final String relativePath;
|
||||||
|
private final String oldRelativePath;
|
||||||
|
private final ChangeStatus status;
|
||||||
|
private final String className;
|
||||||
|
private final String oldClassName;
|
||||||
|
private final ClassType classType;
|
||||||
|
|
||||||
|
/** 修改或删除(无路径变化) */
|
||||||
|
public ChangedClassFile(String relativePath, ChangeStatus status, String className, ClassType classType) {
|
||||||
|
this(relativePath, null, status, className, null, classType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重命名或同路径类名变更 */
|
||||||
|
public ChangedClassFile(String relativePath, String oldRelativePath, ChangeStatus status,
|
||||||
|
String className, String oldClassName, ClassType classType) {
|
||||||
|
this.relativePath = relativePath;
|
||||||
|
this.oldRelativePath = oldRelativePath;
|
||||||
|
this.status = status;
|
||||||
|
this.className = className;
|
||||||
|
this.oldClassName = oldClassName;
|
||||||
|
this.classType = classType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新提交中的相对路径 */
|
||||||
|
public String getRelativePath() {
|
||||||
|
return relativePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 旧提交中的相对路径,未变路径则为 null */
|
||||||
|
public String getOldRelativePath() {
|
||||||
|
return oldRelativePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取旧版本源码时使用的路径 */
|
||||||
|
public String pathForOldCommit() {
|
||||||
|
return oldRelativePath != null ? oldRelativePath : relativePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ChangeStatus getStatus() {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前简单类名 */
|
||||||
|
public String getClassName() {
|
||||||
|
return className;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重命名前简单类名 */
|
||||||
|
public String getOldClassName() {
|
||||||
|
return oldClassName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ClassType getClassType() {
|
||||||
|
return classType;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.aicheck.model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单次类变更的类型,决定通知内容与影响分析策略。
|
||||||
|
*/
|
||||||
|
public enum ClassChangeKind {
|
||||||
|
/** 文件已删除 */
|
||||||
|
DELETED,
|
||||||
|
/** 仅字段变更 */
|
||||||
|
FIELDS_ONLY,
|
||||||
|
/** 仅类名变更,字段不变 */
|
||||||
|
RENAME_ONLY,
|
||||||
|
/** 类名与字段同时变更 */
|
||||||
|
RENAME_AND_FIELDS
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package com.aicheck.model;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单次类变更的完整报告:变更类型、字段 diff、接口/转换影响,供通知渲染。
|
||||||
|
*/
|
||||||
|
public class ClassChangeReport {
|
||||||
|
private final String className;
|
||||||
|
private final String oldClassName;
|
||||||
|
private final ClassType classType;
|
||||||
|
private final ClassChangeKind changeKind;
|
||||||
|
private final String sourceFile;
|
||||||
|
private final List<FieldChange> fieldChanges = new ArrayList<>();
|
||||||
|
private final List<ApiEndpoint> inputImpactEndpoints = new ArrayList<>();
|
||||||
|
private final List<String> conversionEntities = new ArrayList<>();
|
||||||
|
private final List<ApiEndpoint> frontendImpactEndpoints = new ArrayList<>();
|
||||||
|
private final boolean conversionCheckEnabled;
|
||||||
|
|
||||||
|
public ClassChangeReport(String className, String oldClassName, ClassType classType,
|
||||||
|
ClassChangeKind changeKind, String sourceFile,
|
||||||
|
boolean conversionCheckEnabled) {
|
||||||
|
this.className = className;
|
||||||
|
this.oldClassName = oldClassName;
|
||||||
|
this.classType = classType;
|
||||||
|
this.changeKind = changeKind;
|
||||||
|
this.sourceFile = sourceFile;
|
||||||
|
this.conversionCheckEnabled = conversionCheckEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前(新)简单类名 */
|
||||||
|
public String getClassName() {
|
||||||
|
return className;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重命名前的简单类名,未重命名则为 null */
|
||||||
|
public String getOldClassName() {
|
||||||
|
return oldClassName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否发生类名变更 */
|
||||||
|
public boolean isRenamed() {
|
||||||
|
return oldClassName != null && !oldClassName.equals(className);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否仅类名变更、字段无变化 */
|
||||||
|
public boolean isRenameOnly() {
|
||||||
|
return changeKind == ClassChangeKind.RENAME_ONLY;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ClassType getClassType() {
|
||||||
|
return classType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ClassChangeKind getChangeKind() {
|
||||||
|
return changeKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Git 相对路径,通知「文件路径」展示用 */
|
||||||
|
public String getSourceFile() {
|
||||||
|
return sourceFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否整文件删除 */
|
||||||
|
public boolean isDeleted() {
|
||||||
|
return changeKind == ClassChangeKind.DELETED;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<FieldChange> getFieldChanges() {
|
||||||
|
return fieldChanges;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 入参引用该类的接口(request 影响) */
|
||||||
|
public List<ApiEndpoint> getInputImpactEndpoints() {
|
||||||
|
return inputImpactEndpoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dto→Entity 转换目标类名列表 */
|
||||||
|
public List<String> getConversionEntities() {
|
||||||
|
return conversionEntities;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 返回值引用该类的接口(response 影响) */
|
||||||
|
public List<ApiEndpoint> getFrontendImpactEndpoints() {
|
||||||
|
return frontendImpactEndpoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否启用类转换检测 */
|
||||||
|
public boolean isConversionCheckEnabled() {
|
||||||
|
return conversionCheckEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 追加一条字段变更 */
|
||||||
|
public void addFieldChange(FieldChange change) {
|
||||||
|
fieldChanges.add(change);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 追加 request 影响接口(按 endpointKey 去重) */
|
||||||
|
public void addInputImpact(ApiEndpoint endpoint) {
|
||||||
|
if (inputImpactEndpoints.stream().noneMatch(e -> e.endpointKey().equals(endpoint.endpointKey()))) {
|
||||||
|
inputImpactEndpoints.add(endpoint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 追加关联 Entity 类名(去重) */
|
||||||
|
public void addConversionEntity(String entityName) {
|
||||||
|
if (!conversionEntities.contains(entityName)) {
|
||||||
|
conversionEntities.add(entityName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 追加 response 影响接口(按 endpointKey 去重) */
|
||||||
|
public void addFrontendImpact(ApiEndpoint endpoint) {
|
||||||
|
if (frontendImpactEndpoints.stream().noneMatch(e -> e.endpointKey().equals(endpoint.endpointKey()))) {
|
||||||
|
frontendImpactEndpoints.add(endpoint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package com.aicheck.model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 目标模型类后缀类型,决定通知模版中展示哪些影响段落。
|
||||||
|
*/
|
||||||
|
public enum ClassType {
|
||||||
|
DTO("Dto"),
|
||||||
|
VO("Vo"),
|
||||||
|
ENTITY("Entity"),
|
||||||
|
MODEL("Model");
|
||||||
|
|
||||||
|
private final String label;
|
||||||
|
|
||||||
|
ClassType(String label) {
|
||||||
|
this.label = label;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 通知中展示的类型标签 */
|
||||||
|
public String getLabel() {
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 根据简单类名后缀识别类型,不匹配则 null */
|
||||||
|
public static ClassType fromClassName(String className) {
|
||||||
|
if (className.endsWith("Dto")) {
|
||||||
|
return DTO;
|
||||||
|
}
|
||||||
|
if (className.endsWith("VO")) {
|
||||||
|
return VO;
|
||||||
|
}
|
||||||
|
if (className.endsWith("Vo")) {
|
||||||
|
return VO;
|
||||||
|
}
|
||||||
|
if (className.endsWith("Entity")) {
|
||||||
|
return ENTITY;
|
||||||
|
}
|
||||||
|
if (className.endsWith("Model")) {
|
||||||
|
return MODEL;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 判断类名是否属于当前类型 */
|
||||||
|
public boolean isTargetSuffix(String className) {
|
||||||
|
return fromClassName(className) == this;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package com.aicheck.model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 字段级 diff 结果,用于通知中的 [新增]/[删除]/[修改] 行。
|
||||||
|
*/
|
||||||
|
public class FieldChange {
|
||||||
|
/** 字段变更种类 */
|
||||||
|
public enum ChangeKind {
|
||||||
|
ADDED, REMOVED, MODIFIED
|
||||||
|
}
|
||||||
|
|
||||||
|
private final ChangeKind kind;
|
||||||
|
private final String fieldName;
|
||||||
|
private final String description;
|
||||||
|
private final String oldType;
|
||||||
|
private final String newType;
|
||||||
|
private final String oldDescription;
|
||||||
|
private final String detail;
|
||||||
|
|
||||||
|
private FieldChange(ChangeKind kind, String fieldName, String description,
|
||||||
|
String oldType, String newType, String oldDescription, String detail) {
|
||||||
|
this.kind = kind;
|
||||||
|
this.fieldName = fieldName;
|
||||||
|
this.description = description;
|
||||||
|
this.oldType = oldType;
|
||||||
|
this.newType = newType;
|
||||||
|
this.oldDescription = oldDescription;
|
||||||
|
this.detail = detail;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 构造新增字段变更 */
|
||||||
|
public static FieldChange added(FieldInfo field) {
|
||||||
|
return new FieldChange(ChangeKind.ADDED, field.getName(), field.getDescription(),
|
||||||
|
null, field.getType(), null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 构造删除字段变更 */
|
||||||
|
public static FieldChange removed(FieldInfo field) {
|
||||||
|
return new FieldChange(ChangeKind.REMOVED, field.getName(), field.getDescription(),
|
||||||
|
field.getType(), null, field.getDescription(), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 构造修改字段变更,detail 通常为类型变化描述 */
|
||||||
|
public static FieldChange modified(FieldInfo oldField, FieldInfo newField, String detail) {
|
||||||
|
return new FieldChange(ChangeKind.MODIFIED, newField.getName(), newField.getDescription(),
|
||||||
|
oldField.getType(), newField.getType(), oldField.getDescription(), detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ChangeKind getKind() {
|
||||||
|
return kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getFieldName() {
|
||||||
|
return fieldName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 变更后的字段说明(通知「说明」段) */
|
||||||
|
public String getDescription() {
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getOldType() {
|
||||||
|
return oldType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getNewType() {
|
||||||
|
return newType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getOldDescription() {
|
||||||
|
return oldDescription;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 结构性变更详情,如 Integer → String */
|
||||||
|
public String getDetail() {
|
||||||
|
return detail;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package com.aicheck.model;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析后的单个字段:名称、类型、业务说明(Schema/注释)。
|
||||||
|
*/
|
||||||
|
public class FieldInfo {
|
||||||
|
private final String name;
|
||||||
|
private final String type;
|
||||||
|
private final String description;
|
||||||
|
|
||||||
|
public FieldInfo(String name, String type, String description) {
|
||||||
|
this.name = name;
|
||||||
|
this.type = type;
|
||||||
|
this.description = description == null ? "" : description;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 字段名 */
|
||||||
|
public String getName() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 字段类型(简单名) */
|
||||||
|
public String getType() {
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 字段说明文案 */
|
||||||
|
public String getDescription() {
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object o) {
|
||||||
|
if (this == o) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!(o instanceof FieldInfo)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
FieldInfo other = (FieldInfo) o;
|
||||||
|
return Objects.equals(name, other.name)
|
||||||
|
&& Objects.equals(type, other.type)
|
||||||
|
&& Objects.equals(description, other.description);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
return Objects.hash(name, type, description);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
package com.aicheck.notify;
|
||||||
|
|
||||||
|
import com.aicheck.model.ApiEndpoint;
|
||||||
|
import com.aicheck.model.ClassChangeReport;
|
||||||
|
import com.aicheck.model.ClassType;
|
||||||
|
import com.aicheck.model.FieldChange;
|
||||||
|
import okhttp3.MediaType;
|
||||||
|
import okhttp3.OkHttpClient;
|
||||||
|
import okhttp3.Request;
|
||||||
|
import okhttp3.RequestBody;
|
||||||
|
import okhttp3.Response;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 ClassChangeReport 渲染为企业微信 Markdown 并发送(或仅日志输出)。
|
||||||
|
* <p>
|
||||||
|
* 使用 webhook {@code markdown}(v1):引用块 + 换行排版,三色 font(info/comment/warning)。
|
||||||
|
* v1 不支持无序列表,各项以 {@code >标签:值} 分行展示。
|
||||||
|
*/
|
||||||
|
public class WeComNotifier {
|
||||||
|
private static final int MAX_LENGTH = 3800;
|
||||||
|
private static final MediaType JSON = MediaType.get("application/json; charset=utf-8");
|
||||||
|
|
||||||
|
private final OkHttpClient client = new OkHttpClient.Builder()
|
||||||
|
.connectTimeout(10, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(10, TimeUnit.SECONDS)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
/** 逐条发送企微通知,返回成功条数 */
|
||||||
|
public int sendAll(String webhookUrl, List<ClassChangeReport> reports, String modifier, String modifyTime) {
|
||||||
|
if (reports == null || reports.isEmpty()) {
|
||||||
|
System.out.println("无类变更,不发送到企业微信");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int sent = 0;
|
||||||
|
for (ClassChangeReport report : reports) {
|
||||||
|
String markdown = buildMarkdown(report, modifier, modifyTime);
|
||||||
|
if (postMarkdown(webhookUrl, markdown)) {
|
||||||
|
sent++;
|
||||||
|
System.out.println("已发送类变更通知: " + report.getClassName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (sent > 0) {
|
||||||
|
System.out.println("总共发送 " + sent + " 条类变更通知到企业微信");
|
||||||
|
}
|
||||||
|
return sent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 企微关闭时打印 Markdown 到控制台 */
|
||||||
|
public void logAll(List<ClassChangeReport> reports, String modifier, String modifyTime) {
|
||||||
|
if (reports == null || reports.isEmpty()) {
|
||||||
|
System.out.println("无类变更,无日志输出");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
System.out.println("企业微信通知已关闭(wecom.enabled=false),以下结果仅输出到日志:");
|
||||||
|
for (int i = 0; i < reports.size(); i++) {
|
||||||
|
ClassChangeReport report = reports.get(i);
|
||||||
|
System.out.println("========== 类变更 [" + (i + 1) + "/" + reports.size()
|
||||||
|
+ "]: " + report.getClassName() + " ==========");
|
||||||
|
System.out.println(buildMarkdown(report, modifier, modifyTime));
|
||||||
|
System.out.println("========== 结束 ==========");
|
||||||
|
}
|
||||||
|
System.out.println("共 " + reports.size() + " 条类变更结果(未发送到企业微信)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 组装完整 Markdown 正文(引用块 + 换行,每项独立一行) */
|
||||||
|
public String buildMarkdown(ClassChangeReport report, String modifier, String modifyTime) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append("# 【类变更通知】").append("\n\n");
|
||||||
|
appendHeader(sb, report, modifier, modifyTime);
|
||||||
|
|
||||||
|
sb.append("\n## 【对象变更细节】").append("\n\n");
|
||||||
|
appendChangeDetails(sb, report);
|
||||||
|
|
||||||
|
sb.append("\n## 【影响范围】").append("\n\n");
|
||||||
|
appendImpactSections(sb, report);
|
||||||
|
return truncate(sb.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 头部元信息,每项一行引用 */
|
||||||
|
private void appendHeader(StringBuilder sb, ClassChangeReport report,
|
||||||
|
String modifier, String modifyTime) {
|
||||||
|
sb.append(quoteKv("变更对象", colorInfo(safe(report.getClassName()))
|
||||||
|
+ "(" + report.getClassType().getLabel() + ")")).append("\n");
|
||||||
|
sb.append(quoteKv("修改人", colorComment(modifier))).append("\n");
|
||||||
|
sb.append(quoteKv("时间", colorComment(modifyTime))).append("\n");
|
||||||
|
sb.append(quoteKv("路径", colorComment(report.getSourceFile()))).append("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 渲染删除 / 重命名 / 字段变更 */
|
||||||
|
private void appendChangeDetails(StringBuilder sb, ClassChangeReport report) {
|
||||||
|
if (report.isDeleted()) {
|
||||||
|
sb.append(quoteLine(colorWarning("[已删除]") + " "
|
||||||
|
+ colorComment("该类文件已被移除"))).append("\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (report.isRenamed()) {
|
||||||
|
sb.append(quoteLine(colorWarning("[类名变更]") + " "
|
||||||
|
+ colorComment(safe(report.getOldClassName())) + " → "
|
||||||
|
+ colorInfo(safe(report.getClassName())))).append("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (report.isRenameOnly()) {
|
||||||
|
sb.append(quoteLine(colorComment("字段无变化"))).append("\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!report.getFieldChanges().isEmpty()) {
|
||||||
|
sb.append(quoteLine(colorComment("共 "
|
||||||
|
+ report.getFieldChanges().size() + " 项字段变更"))).append("\n\n");
|
||||||
|
for (int i = 0; i < report.getFieldChanges().size(); i++) {
|
||||||
|
if (i > 0) {
|
||||||
|
sb.append("\n");
|
||||||
|
}
|
||||||
|
sb.append(formatFieldChange(report.getFieldChanges().get(i)));
|
||||||
|
}
|
||||||
|
sb.append("\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按类类型选择影响段落 */
|
||||||
|
private void appendImpactSections(StringBuilder sb, ClassChangeReport report) {
|
||||||
|
appendImpactByType(sb, report);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dto/Vo/Entity/Model 各展示不同的 request/response/转换段落 */
|
||||||
|
private void appendImpactByType(StringBuilder sb, ClassChangeReport report) {
|
||||||
|
switch (report.getClassType()) {
|
||||||
|
case DTO:
|
||||||
|
appendSectionIfNeeded(sb, report, true, false, true);
|
||||||
|
break;
|
||||||
|
case VO:
|
||||||
|
appendSectionIfNeeded(sb, report, false, true, true);
|
||||||
|
break;
|
||||||
|
case ENTITY:
|
||||||
|
case MODEL:
|
||||||
|
appendSectionIfNeeded(sb, report, false, false, true);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
appendSectionIfNeeded(sb, report, true, true, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按需追加 request / response / 类转换三个小节 */
|
||||||
|
private void appendSectionIfNeeded(StringBuilder sb, ClassChangeReport report,
|
||||||
|
boolean showRequest, boolean showResponse, boolean showConversion) {
|
||||||
|
if (showRequest) {
|
||||||
|
sb.append("### 影响 request 接口").append("\n");
|
||||||
|
appendEndpointList(sb, report.getInputImpactEndpoints());
|
||||||
|
sb.append("\n");
|
||||||
|
}
|
||||||
|
if (showResponse) {
|
||||||
|
sb.append("### 影响 response 接口").append("\n");
|
||||||
|
appendEndpointList(sb, report.getFrontendImpactEndpoints());
|
||||||
|
sb.append("\n");
|
||||||
|
}
|
||||||
|
if (showConversion) {
|
||||||
|
sb.append("### 类转换影响").append("\n");
|
||||||
|
appendConversionList(sb, report);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 渲染关联 Entity,每项一行 */
|
||||||
|
private void appendConversionList(StringBuilder sb, ClassChangeReport report) {
|
||||||
|
if (!report.isConversionCheckEnabled()) {
|
||||||
|
sb.append(quoteLine(colorComment("未开启检测"))).append("\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (report.getConversionEntities().isEmpty()) {
|
||||||
|
sb.append(quoteLine(colorComment("无"))).append("\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (String entity : report.getConversionEntities()) {
|
||||||
|
sb.append(quoteKv("Entity", colorInfo(safe(entity)))).append("\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 渲染接口,每项一行 */
|
||||||
|
private void appendEndpointList(StringBuilder sb, List<ApiEndpoint> endpoints) {
|
||||||
|
if (endpoints == null || endpoints.isEmpty()) {
|
||||||
|
sb.append(quoteLine(colorComment("无"))).append("\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (ApiEndpoint endpoint : endpoints) {
|
||||||
|
sb.append(formatEndpointLine(endpoint)).append("\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 接口行:> POST `/path` */
|
||||||
|
private String formatEndpointLine(ApiEndpoint endpoint) {
|
||||||
|
String line = endpoint.displayLine();
|
||||||
|
int space = line.indexOf(' ');
|
||||||
|
if (space > 0) {
|
||||||
|
String method = line.substring(0, space).trim();
|
||||||
|
String path = line.substring(space).trim();
|
||||||
|
return quoteLine(colorInfo(method) + " " + inlineCode(path));
|
||||||
|
}
|
||||||
|
return quoteLine(inlineCode(safe(line)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单条字段变更:引用块多行,字段间空行分隔。
|
||||||
|
* 避免 font 内嵌 bold。
|
||||||
|
*/
|
||||||
|
private String formatFieldChange(FieldChange change) {
|
||||||
|
String fieldName = inlineCode(safe(change.getFieldName()));
|
||||||
|
String desc = change.getDescription() == null ? "" : change.getDescription();
|
||||||
|
String descPart = desc.isBlank()
|
||||||
|
? colorComment("(无说明)")
|
||||||
|
: colorComment(desc);
|
||||||
|
|
||||||
|
switch (change.getKind()) {
|
||||||
|
case ADDED:
|
||||||
|
return quoteLine(tagAdded() + " " + fieldName) + "\n"
|
||||||
|
+ quoteKv("说明", descPart);
|
||||||
|
case REMOVED:
|
||||||
|
return quoteLine(tagRemoved() + " " + fieldName) + "\n"
|
||||||
|
+ quoteKv("说明", descPart);
|
||||||
|
case MODIFIED:
|
||||||
|
default:
|
||||||
|
StringBuilder block = new StringBuilder();
|
||||||
|
block.append(quoteLine(tagModified() + " " + fieldName)).append("\n");
|
||||||
|
block.append(quoteKv("说明", descPart));
|
||||||
|
String detail = change.getDetail();
|
||||||
|
if (detail != null && !detail.isBlank()) {
|
||||||
|
block.append("\n").append(quoteKv("类型", formatTypeChange(detail)));
|
||||||
|
}
|
||||||
|
return block.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 类型变化:旧 warning → 新 info */
|
||||||
|
private String formatTypeChange(String detail) {
|
||||||
|
int arrow = detail.indexOf(" → ");
|
||||||
|
if (arrow < 0) {
|
||||||
|
return colorWarning(safe(detail));
|
||||||
|
}
|
||||||
|
String oldType = detail.substring(0, arrow).trim();
|
||||||
|
String newType = detail.substring(arrow + 3).trim();
|
||||||
|
return colorWarning(safe(oldType)) + " → " + colorInfo(safe(newType));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String tagAdded() {
|
||||||
|
return colorInfo("[新增]");
|
||||||
|
}
|
||||||
|
|
||||||
|
private String tagRemoved() {
|
||||||
|
return colorWarning("[删除]");
|
||||||
|
}
|
||||||
|
|
||||||
|
private String tagModified() {
|
||||||
|
return colorWarning("[修改]");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 引用行:{@code >标签:值} */
|
||||||
|
private String quoteKv(String key, String value) {
|
||||||
|
return "> " + key + ":" + value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 纯引用行 */
|
||||||
|
private String quoteLine(String content) {
|
||||||
|
return "> " + content;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 行内代码 */
|
||||||
|
private String inlineCode(String text) {
|
||||||
|
return "`" + text.replace("`", "'") + "`";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String colorInfo(String text) {
|
||||||
|
return "<font color=\"info\">" + text + "</font>";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String colorComment(String text) {
|
||||||
|
return "<font color=\"comment\">" + safe(text) + "</font>";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String colorWarning(String text) {
|
||||||
|
return "<font color=\"warning\">" + text + "</font>";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 转义 HTML 特殊字符,避免破坏 font 标签 */
|
||||||
|
private String safe(String text) {
|
||||||
|
if (text == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return text.replace("&", "&").replace("<", "<").replace(">", ">");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST 企微 Webhook(markdown v1) */
|
||||||
|
private boolean postMarkdown(String webhookUrl, String content) {
|
||||||
|
if (webhookUrl == null || webhookUrl.isBlank() || webhookUrl.contains("YOUR_WECOM")) {
|
||||||
|
System.out.println("[警告] 未配置有效的企业微信 Webhook URL");
|
||||||
|
System.out.println("--- 通知预览 ---");
|
||||||
|
System.out.println(content.length() > 1000 ? content.substring(0, 1000) : content);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
String payload = "{\"msgtype\":\"markdown\",\"markdown\":{\"content\":"
|
||||||
|
+ jsonEscape(content) + "}}";
|
||||||
|
Request request = new Request.Builder()
|
||||||
|
.url(webhookUrl)
|
||||||
|
.post(RequestBody.create(payload, JSON))
|
||||||
|
.build();
|
||||||
|
try (Response response = client.newCall(request).execute()) {
|
||||||
|
if (response.isSuccessful() && response.body() != null) {
|
||||||
|
String body = response.body().string();
|
||||||
|
return body.contains("\"errcode\":0");
|
||||||
|
}
|
||||||
|
System.out.println("[错误] 企微返回异常: " + response.code()
|
||||||
|
+ (response.body() != null ? " " + response.body().string() : ""));
|
||||||
|
return false;
|
||||||
|
} catch (IOException e) {
|
||||||
|
System.out.println("[错误] 发送企微消息失败: " + e.getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 超长消息截断(企微上限 4096 字节 UTF-8) */
|
||||||
|
private String truncate(String text) {
|
||||||
|
if (text.length() <= MAX_LENGTH) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
return text.substring(0, MAX_LENGTH) + "\n\n... 消息过长,已截断";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** JSON 字符串转义 */
|
||||||
|
private String jsonEscape(String text) {
|
||||||
|
String escaped = text
|
||||||
|
.replace("\\", "\\\\")
|
||||||
|
.replace("\"", "\\\"")
|
||||||
|
.replace("\n", "\\n")
|
||||||
|
.replace("\r", "");
|
||||||
|
return "\"" + escaped + "\"";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package com.aicheck.parser;
|
||||||
|
|
||||||
|
import com.github.javaparser.StaticJavaParser;
|
||||||
|
import com.github.javaparser.ast.CompilationUnit;
|
||||||
|
import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration;
|
||||||
|
import com.github.javaparser.ast.body.TypeDeclaration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 Java 源文件路径或 AST 解析类名(简单名 / 全限定名)。
|
||||||
|
*/
|
||||||
|
public class ClassDeclParser {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从源码 AST 提取主类名;解析失败或未找到时回退为路径推导的类名。
|
||||||
|
*/
|
||||||
|
public String resolveClassName(String source, String fallbackFromPath) {
|
||||||
|
if (source == null || source.isBlank()) {
|
||||||
|
return fallbackFromPath;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
CompilationUnit cu = StaticJavaParser.parse(source);
|
||||||
|
for (TypeDeclaration<?> type : cu.getTypes()) {
|
||||||
|
if (type instanceof ClassOrInterfaceDeclaration) {
|
||||||
|
return type.getNameAsString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// 回退路径类名
|
||||||
|
}
|
||||||
|
return fallbackFromPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 .java 路径提取文件名(无扩展名)作为类名 */
|
||||||
|
public static String classNameFromPath(String path) {
|
||||||
|
String fileName = path.substring(path.lastIndexOf('/') + 1);
|
||||||
|
if (!fileName.endsWith(".java")) {
|
||||||
|
return fileName;
|
||||||
|
}
|
||||||
|
return fileName.substring(0, fileName.length() - 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全限定类名:package + 类名;源码无 package 时从文件路径推断。
|
||||||
|
*/
|
||||||
|
public String resolveQualifiedClassName(String source, String relativePath, String fallbackClassName) {
|
||||||
|
String simpleName = resolveClassName(source, fallbackClassName);
|
||||||
|
if (source != null && !source.isBlank()) {
|
||||||
|
try {
|
||||||
|
CompilationUnit cu = StaticJavaParser.parse(source);
|
||||||
|
String packageName = cu.getPackageDeclaration()
|
||||||
|
.map(p -> p.getNameAsString())
|
||||||
|
.orElse("");
|
||||||
|
if (!packageName.isBlank()) {
|
||||||
|
return packageName + "." + simpleName;
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// 回退路径推断
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return inferQualifiedFromPath(relativePath, simpleName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 src/main/java/ 后的路径推断 package.className */
|
||||||
|
public static String inferQualifiedFromPath(String relativePath, String className) {
|
||||||
|
if (relativePath == null || relativePath.isBlank()) {
|
||||||
|
return className;
|
||||||
|
}
|
||||||
|
String normalized = relativePath.replace('\\', '/');
|
||||||
|
String marker = "src/main/java/";
|
||||||
|
int idx = normalized.indexOf(marker);
|
||||||
|
if (idx < 0) {
|
||||||
|
return className;
|
||||||
|
}
|
||||||
|
String subPath = normalized.substring(idx + marker.length());
|
||||||
|
int lastSlash = subPath.lastIndexOf('/');
|
||||||
|
if (lastSlash <= 0) {
|
||||||
|
return className;
|
||||||
|
}
|
||||||
|
String packageName = subPath.substring(0, lastSlash).replace('/', '.');
|
||||||
|
return packageName + "." + className;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package com.aicheck.parser;
|
||||||
|
|
||||||
|
import com.aicheck.model.FieldInfo;
|
||||||
|
import com.github.javaparser.StaticJavaParser;
|
||||||
|
import com.github.javaparser.ast.CompilationUnit;
|
||||||
|
import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration;
|
||||||
|
import com.github.javaparser.ast.body.FieldDeclaration;
|
||||||
|
import com.github.javaparser.ast.body.TypeDeclaration;
|
||||||
|
import com.github.javaparser.ast.body.VariableDeclarator;
|
||||||
|
import com.github.javaparser.ast.comments.JavadocComment;
|
||||||
|
import com.github.javaparser.ast.expr.AnnotationExpr;
|
||||||
|
import com.github.javaparser.ast.expr.Expression;
|
||||||
|
import com.github.javaparser.ast.expr.NormalAnnotationExpr;
|
||||||
|
import com.github.javaparser.ast.expr.SingleMemberAnnotationExpr;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析模型类字段:名称、类型、业务说明(注解或 Javadoc)。
|
||||||
|
*/
|
||||||
|
public class ClassFieldParser {
|
||||||
|
|
||||||
|
/** 解析指定类的实例字段列表 */
|
||||||
|
public List<FieldInfo> parseFields(String source, String expectedClassName) {
|
||||||
|
if (source == null || source.isBlank()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
CompilationUnit cu = StaticJavaParser.parse(source);
|
||||||
|
ClassOrInterfaceDeclaration classDecl = findClass(cu, expectedClassName);
|
||||||
|
if (classDecl == null) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
return parseClassFields(classDecl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按类名查找类声明,找不到则取第一个类 */
|
||||||
|
private ClassOrInterfaceDeclaration findClass(CompilationUnit cu, String expectedClassName) {
|
||||||
|
if (expectedClassName != null && !expectedClassName.isBlank()) {
|
||||||
|
for (TypeDeclaration<?> type : cu.getTypes()) {
|
||||||
|
if (type instanceof ClassOrInterfaceDeclaration) {
|
||||||
|
ClassOrInterfaceDeclaration classDecl = (ClassOrInterfaceDeclaration) type;
|
||||||
|
if (classDecl.getNameAsString().equals(expectedClassName)) {
|
||||||
|
return classDecl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (TypeDeclaration<?> type : cu.getTypes()) {
|
||||||
|
if (type instanceof ClassOrInterfaceDeclaration) {
|
||||||
|
return (ClassOrInterfaceDeclaration) type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提取非 static final 字段,跳过常量 */
|
||||||
|
private List<FieldInfo> parseClassFields(ClassOrInterfaceDeclaration classDecl) {
|
||||||
|
Map<String, FieldInfo> fields = new LinkedHashMap<>();
|
||||||
|
for (FieldDeclaration fieldDecl : classDecl.getFields()) {
|
||||||
|
if (fieldDecl.isStatic() && fieldDecl.isFinal()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String type = TypeNameUtils.typeToString(fieldDecl.getElementType());
|
||||||
|
String description = extractFieldLabel(fieldDecl);
|
||||||
|
for (VariableDeclarator variable : fieldDecl.getVariables()) {
|
||||||
|
fields.put(variable.getNameAsString(), new FieldInfo(variable.getNameAsString(), type, description));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new ArrayList<>(fields.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 字段说明:@Schema(description) > @ApiModelProperty > Javadoc,均无则空串。
|
||||||
|
*/
|
||||||
|
String extractFieldLabel(FieldDeclaration fieldDecl) {
|
||||||
|
for (AnnotationExpr annotation : fieldDecl.getAnnotations()) {
|
||||||
|
String annName = annotation.getNameAsString();
|
||||||
|
if ("Schema".equals(annName)) {
|
||||||
|
String description = readAnnotationStringValue(annotation, "description");
|
||||||
|
if (!description.isEmpty()) {
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ("ApiModelProperty".equals(annName)) {
|
||||||
|
String value = readAnnotationStringValue(annotation, "value");
|
||||||
|
if (!value.isEmpty()) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return extractJavadoc(fieldDecl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取注解中的字符串属性值 */
|
||||||
|
private String readAnnotationStringValue(AnnotationExpr annotation, String attributeName) {
|
||||||
|
if (annotation.isNormalAnnotationExpr()) {
|
||||||
|
NormalAnnotationExpr normal = annotation.asNormalAnnotationExpr();
|
||||||
|
for (var pair : normal.getPairs()) {
|
||||||
|
if (pair.getNameAsString().equals(attributeName)) {
|
||||||
|
return literalString(pair.getValue());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (annotation.isSingleMemberAnnotationExpr()) {
|
||||||
|
SingleMemberAnnotationExpr single = annotation.asSingleMemberAnnotationExpr();
|
||||||
|
if ("value".equals(attributeName) || "description".equals(attributeName)) {
|
||||||
|
return literalString(single.getMemberValue());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提取字符串字面量值 */
|
||||||
|
private String literalString(Expression expression) {
|
||||||
|
if (expression.isStringLiteralExpr()) {
|
||||||
|
return expression.asStringLiteralExpr().getValue().trim();
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从字段 Javadoc 提取首段描述 */
|
||||||
|
private String extractJavadoc(FieldDeclaration fieldDecl) {
|
||||||
|
Optional<JavadocComment> javadoc = fieldDecl.getJavadocComment();
|
||||||
|
if (javadoc.isEmpty()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
String text = javadoc.get().parse().getDescription().toText();
|
||||||
|
return text == null ? "" : text.trim().replaceAll("\\s+", " ");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package com.aicheck.parser;
|
||||||
|
|
||||||
|
import com.github.javaparser.StaticJavaParser;
|
||||||
|
import com.github.javaparser.ast.CompilationUnit;
|
||||||
|
import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration;
|
||||||
|
import com.github.javaparser.ast.body.MethodDeclaration;
|
||||||
|
import com.github.javaparser.ast.body.TypeDeclaration;
|
||||||
|
import com.github.javaparser.ast.expr.MethodCallExpr;
|
||||||
|
import com.github.javaparser.ast.visitor.VoidVisitorAdapter;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 扫描 Dto→Entity 转换关系:convert 方法返回值、BeanUtils.copyProperties 调用。
|
||||||
|
*/
|
||||||
|
public class ConversionParser {
|
||||||
|
|
||||||
|
/** 在类内查找 convert 方法,收集返回 Entity 的类型名 */
|
||||||
|
public Set<String> findConvertTargetsInClass(String source, String className) {
|
||||||
|
Set<String> entities = new LinkedHashSet<>();
|
||||||
|
if (source == null || source.isBlank()) {
|
||||||
|
return entities;
|
||||||
|
}
|
||||||
|
CompilationUnit cu = StaticJavaParser.parse(source);
|
||||||
|
for (TypeDeclaration<?> type : cu.getTypes()) {
|
||||||
|
if (type instanceof ClassOrInterfaceDeclaration) {
|
||||||
|
ClassOrInterfaceDeclaration classDecl = (ClassOrInterfaceDeclaration) type;
|
||||||
|
if (!classDecl.getNameAsString().equals(className)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (MethodDeclaration method : classDecl.getMethods()) {
|
||||||
|
if (!"convert".equals(method.getNameAsString())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String returnType = TypeNameUtils.simpleName(TypeNameUtils.typeToString(method.getType()));
|
||||||
|
if (returnType.endsWith("Entity")) {
|
||||||
|
entities.add(returnType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entities;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 递归扫描目录,查找 BeanUtils.copyProperties(sourceClass, *Entity) */
|
||||||
|
public Set<String> findBeanUtilsTargets(Path rootDir, String sourceClassName) throws IOException {
|
||||||
|
Set<String> entities = new LinkedHashSet<>();
|
||||||
|
if (!Files.exists(rootDir)) {
|
||||||
|
return entities;
|
||||||
|
}
|
||||||
|
try (Stream<Path> paths = Files.walk(rootDir)) {
|
||||||
|
paths.filter(path -> path.toString().endsWith(".java")).forEach(path -> {
|
||||||
|
try {
|
||||||
|
String source = Files.readString(path, StandardCharsets.UTF_8);
|
||||||
|
entities.addAll(scanBeanUtilsInSource(source, sourceClassName));
|
||||||
|
} catch (IOException ignored) {
|
||||||
|
// 跳过
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return entities;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 在单文件源码中扫描 BeanUtils.copyProperties 调用 */
|
||||||
|
private Set<String> scanBeanUtilsInSource(String source, String sourceClassName) {
|
||||||
|
Set<String> entities = new LinkedHashSet<>();
|
||||||
|
CompilationUnit cu = StaticJavaParser.parse(source);
|
||||||
|
cu.accept(new VoidVisitorAdapter<Void>() {
|
||||||
|
@Override
|
||||||
|
public void visit(MethodCallExpr call, Void arg) {
|
||||||
|
super.visit(call, arg);
|
||||||
|
if (!call.getNameAsString().equals("copyProperties")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (call.getScope().isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String scope = call.getScope().get().toString();
|
||||||
|
if (!scope.endsWith("BeanUtils")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (call.getArguments().size() < 2) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String firstArg = TypeNameUtils.simpleName(call.getArguments().get(0).toString());
|
||||||
|
String secondArg = TypeNameUtils.simpleName(call.getArguments().get(1).toString());
|
||||||
|
if (sourceClassName.equals(firstArg) && secondArg.endsWith("Entity")) {
|
||||||
|
entities.add(secondArg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, null);
|
||||||
|
return entities;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
package com.aicheck.parser;
|
||||||
|
|
||||||
|
import com.aicheck.model.ApiEndpoint;
|
||||||
|
import com.github.javaparser.StaticJavaParser;
|
||||||
|
import com.github.javaparser.ast.CompilationUnit;
|
||||||
|
import com.github.javaparser.ast.NodeList;
|
||||||
|
import com.github.javaparser.ast.expr.Expression;
|
||||||
|
import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration;
|
||||||
|
import com.github.javaparser.ast.body.MethodDeclaration;
|
||||||
|
import com.github.javaparser.ast.body.Parameter;
|
||||||
|
import com.github.javaparser.ast.body.TypeDeclaration;
|
||||||
|
import com.github.javaparser.ast.expr.AnnotationExpr;
|
||||||
|
import com.github.javaparser.ast.type.Type;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 扫描 Controller / Feign 接口,提取 HTTP 方法、URI、入参/返回类型。
|
||||||
|
*/
|
||||||
|
public class EndpointParser {
|
||||||
|
private static final Set<String> MAPPING_ANNOTATIONS = Set.of(
|
||||||
|
"GetMapping", "PostMapping", "PutMapping", "DeleteMapping", "PatchMapping", "RequestMapping"
|
||||||
|
);
|
||||||
|
private static final Map<String, String> MAPPING_DEFAULT_METHOD = Map.of(
|
||||||
|
"GetMapping", "GET",
|
||||||
|
"PostMapping", "POST",
|
||||||
|
"PutMapping", "PUT",
|
||||||
|
"DeleteMapping", "DELETE",
|
||||||
|
"PatchMapping", "PATCH"
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 扫描 @RestController / @Controller 目录 */
|
||||||
|
public List<ApiEndpoint> scanControllerDirectory(Path rootDir, String relativePrefix) throws IOException {
|
||||||
|
return scanDirectory(rootDir, relativePrefix, ScanMode.CONTROLLER);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 扫描 @FeignClient 接口目录 */
|
||||||
|
public List<ApiEndpoint> scanFeignDirectory(Path rootDir, String relativePrefix) throws IOException {
|
||||||
|
return scanDirectory(rootDir, relativePrefix, ScanMode.FEIGN);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 递归 walk 目录下 .java 并解析 */
|
||||||
|
private List<ApiEndpoint> scanDirectory(Path rootDir, String relativePrefix, ScanMode mode) throws IOException {
|
||||||
|
if (!Files.exists(rootDir)) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
List<ApiEndpoint> endpoints = new ArrayList<>();
|
||||||
|
try (Stream<Path> paths = Files.walk(rootDir)) {
|
||||||
|
paths.filter(path -> path.toString().endsWith(".java")).forEach(path -> {
|
||||||
|
try {
|
||||||
|
String source = Files.readString(path, StandardCharsets.UTF_8);
|
||||||
|
String relativePath = toRelativePath(relativePrefix, rootDir, path);
|
||||||
|
endpoints.addAll(parseCompilationUnit(source, relativePath, mode));
|
||||||
|
} catch (IOException ignored) {
|
||||||
|
// 跳过无法读取的文件
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return endpoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析单个编译单元,过滤 Controller 或 Feign */
|
||||||
|
private List<ApiEndpoint> parseCompilationUnit(String source, String relativePath, ScanMode mode) {
|
||||||
|
CompilationUnit cu = StaticJavaParser.parse(source);
|
||||||
|
List<ApiEndpoint> endpoints = new ArrayList<>();
|
||||||
|
|
||||||
|
for (TypeDeclaration<?> type : cu.getTypes()) {
|
||||||
|
if (!(type instanceof ClassOrInterfaceDeclaration)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ClassOrInterfaceDeclaration declaration = (ClassOrInterfaceDeclaration) type;
|
||||||
|
if (mode == ScanMode.CONTROLLER && !isController(declaration)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (mode == ScanMode.FEIGN && !isFeignClient(declaration)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
String basePath = mode == ScanMode.FEIGN
|
||||||
|
? joinPaths(extractFeignBasePath(declaration), extractTypeLevelPath(declaration))
|
||||||
|
: extractTypeLevelPath(declaration);
|
||||||
|
for (MethodDeclaration method : declaration.getMethods()) {
|
||||||
|
if (mode == ScanMode.FEIGN && declaration.isInterface()) {
|
||||||
|
endpoints.addAll(parseMethod(method, basePath, relativePath));
|
||||||
|
} else if (mode == ScanMode.CONTROLLER && !declaration.isInterface()) {
|
||||||
|
endpoints.addAll(parseMethod(method, basePath, relativePath));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return endpoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析方法上的 Mapping 注解,生成 ApiEndpoint */
|
||||||
|
private List<ApiEndpoint> parseMethod(MethodDeclaration method, String basePath, String sourceFile) {
|
||||||
|
List<ApiEndpoint> endpoints = new ArrayList<>();
|
||||||
|
for (AnnotationExpr annotation : method.getAnnotations()) {
|
||||||
|
String annName = annotation.getNameAsString();
|
||||||
|
if (!MAPPING_ANNOTATIONS.contains(annName)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
List<String> subPaths = extractPaths(annotation);
|
||||||
|
List<String> httpMethods = extractHttpMethods(annotation, annName);
|
||||||
|
for (String httpMethod : httpMethods) {
|
||||||
|
for (String subPath : subPaths) {
|
||||||
|
String uri = joinPaths(basePath, subPath);
|
||||||
|
Set<String> paramTypes = extractParamTypes(method);
|
||||||
|
Set<String> returnTypes = TypeNameUtils.peelDirectTypeNames(method.getType());
|
||||||
|
endpoints.add(new ApiEndpoint(httpMethod, uri, sourceFile, paramTypes, returnTypes));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return endpoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 收集方法入参类型简单名 */
|
||||||
|
private Set<String> extractParamTypes(MethodDeclaration method) {
|
||||||
|
Set<String> paramTypes = new LinkedHashSet<>();
|
||||||
|
for (Parameter parameter : method.getParameters()) {
|
||||||
|
Type type = parameter.getType();
|
||||||
|
paramTypes.add(TypeNameUtils.simpleName(TypeNameUtils.typeToString(type)));
|
||||||
|
paramTypes.addAll(TypeNameUtils.peelDirectTypeNames(type));
|
||||||
|
}
|
||||||
|
return paramTypes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否 Spring Controller */
|
||||||
|
private boolean isController(ClassOrInterfaceDeclaration declaration) {
|
||||||
|
return declaration.getAnnotations().stream()
|
||||||
|
.anyMatch(ann -> {
|
||||||
|
String name = ann.getNameAsString();
|
||||||
|
return "RestController".equals(name) || "Controller".equals(name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否 Feign 客户端接口 */
|
||||||
|
private boolean isFeignClient(ClassOrInterfaceDeclaration declaration) {
|
||||||
|
return declaration.isInterface() && declaration.getAnnotations().stream()
|
||||||
|
.anyMatch(ann -> "FeignClient".equals(ann.getNameAsString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 类级 @RequestMapping 路径 */
|
||||||
|
private String extractTypeLevelPath(ClassOrInterfaceDeclaration declaration) {
|
||||||
|
for (AnnotationExpr annotation : declaration.getAnnotations()) {
|
||||||
|
if ("RequestMapping".equals(annotation.getNameAsString())) {
|
||||||
|
List<String> paths = extractPaths(annotation);
|
||||||
|
if (!paths.isEmpty()) {
|
||||||
|
return paths.get(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @FeignClient(path=...) 基础路径 */
|
||||||
|
private String extractFeignBasePath(ClassOrInterfaceDeclaration declaration) {
|
||||||
|
for (AnnotationExpr annotation : declaration.getAnnotations()) {
|
||||||
|
if ("FeignClient".equals(annotation.getNameAsString())) {
|
||||||
|
List<String> paths = AnnotationValueReader.readStringArray(annotation, "path");
|
||||||
|
if (!paths.isEmpty()) {
|
||||||
|
return paths.get(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 Mapping 注解读取 value/path */
|
||||||
|
private List<String> extractPaths(AnnotationExpr annotation) {
|
||||||
|
return AnnotationValueReader.readStringArray(annotation, "value", "path");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 推断 HTTP 方法;RequestMapping 无 method 时默认 GET */
|
||||||
|
private List<String> extractHttpMethods(AnnotationExpr annotation, String annName) {
|
||||||
|
if (!"RequestMapping".equals(annName)) {
|
||||||
|
return List.of(MAPPING_DEFAULT_METHOD.getOrDefault(annName, "GET"));
|
||||||
|
}
|
||||||
|
List<String> methods = AnnotationValueReader.readEnumArray(annotation, "method");
|
||||||
|
if (methods.isEmpty()) {
|
||||||
|
return List.of("GET");
|
||||||
|
}
|
||||||
|
return methods;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 拼接类级与方法级路径 */
|
||||||
|
private String joinPaths(String base, String sub) {
|
||||||
|
String normalizedBase = normalizePath(base);
|
||||||
|
String normalizedSub = normalizePath(sub);
|
||||||
|
if (normalizedBase.isEmpty()) {
|
||||||
|
return normalizedSub.isEmpty() ? "/" : normalizedSub;
|
||||||
|
}
|
||||||
|
if (normalizedSub.isEmpty()) {
|
||||||
|
return normalizedBase;
|
||||||
|
}
|
||||||
|
String joined = normalizedBase + "/" + normalizedSub.substring(1);
|
||||||
|
return joined.replaceAll("/+", "/");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 规范化 URI 路径 */
|
||||||
|
private String normalizePath(String path) {
|
||||||
|
if (path == null || path.isBlank()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
String trimmed = path.trim();
|
||||||
|
if (!trimmed.startsWith("/")) {
|
||||||
|
trimmed = "/" + trimmed;
|
||||||
|
}
|
||||||
|
return trimmed.replaceAll("/+", "/");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生成相对仓库根的路径 */
|
||||||
|
private String toRelativePath(String relativePrefix, Path rootDir, Path file) {
|
||||||
|
String relative = rootDir.relativize(file).toString().replace("\\", "/");
|
||||||
|
if (relativePrefix == null || relativePrefix.isBlank()) {
|
||||||
|
return relative;
|
||||||
|
}
|
||||||
|
String prefix = relativePrefix.endsWith("/")
|
||||||
|
? relativePrefix.substring(0, relativePrefix.length() - 1)
|
||||||
|
: relativePrefix;
|
||||||
|
return prefix + "/" + relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum ScanMode {
|
||||||
|
CONTROLLER, FEIGN
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从注解 AST 读取字符串或枚举数组 */
|
||||||
|
static final class AnnotationValueReader {
|
||||||
|
private AnnotationValueReader() {
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<String> readStringArray(AnnotationExpr annotation, String... keys) {
|
||||||
|
NodeList<?> values = readArrayValues(annotation, keys);
|
||||||
|
List<String> result = new ArrayList<>();
|
||||||
|
for (Object value : values) {
|
||||||
|
String text = value.toString().replace("\"", "").trim();
|
||||||
|
if (!text.isBlank()) {
|
||||||
|
result.add(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (result.isEmpty()) {
|
||||||
|
result.add("");
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<String> readEnumArray(AnnotationExpr annotation, String key) {
|
||||||
|
NodeList<?> values = readArrayValues(annotation, key);
|
||||||
|
List<String> result = new ArrayList<>();
|
||||||
|
for (Object value : values) {
|
||||||
|
String text = value.toString().trim();
|
||||||
|
if (text.contains(".")) {
|
||||||
|
text = text.substring(text.lastIndexOf('.') + 1);
|
||||||
|
}
|
||||||
|
result.add(text.toUpperCase(Locale.ROOT));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NodeList<?> readArrayValues(AnnotationExpr annotation, String... keys) {
|
||||||
|
if (annotation.isSingleMemberAnnotationExpr()) {
|
||||||
|
Expression value = annotation.asSingleMemberAnnotationExpr().getMemberValue();
|
||||||
|
if (value.isArrayInitializerExpr()) {
|
||||||
|
return value.asArrayInitializerExpr().getValues();
|
||||||
|
}
|
||||||
|
return new NodeList<>(value);
|
||||||
|
}
|
||||||
|
if (annotation.isNormalAnnotationExpr()) {
|
||||||
|
var pairs = annotation.asNormalAnnotationExpr().getPairs();
|
||||||
|
for (var pair : pairs) {
|
||||||
|
for (String key : keys) {
|
||||||
|
if (pair.getNameAsString().equals(key)) {
|
||||||
|
if (pair.getValue().isArrayInitializerExpr()) {
|
||||||
|
return pair.getValue().asArrayInitializerExpr().getValues();
|
||||||
|
}
|
||||||
|
return new NodeList<>(pair.getValue());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (var pair : pairs) {
|
||||||
|
if ("value".equals(pair.getNameAsString())) {
|
||||||
|
if (pair.getValue().isArrayInitializerExpr()) {
|
||||||
|
return pair.getValue().asArrayInitializerExpr().getValues();
|
||||||
|
}
|
||||||
|
return new NodeList<>(pair.getValue());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new NodeList<>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package com.aicheck.parser;
|
||||||
|
|
||||||
|
import com.github.javaparser.ast.type.ClassOrInterfaceType;
|
||||||
|
import com.github.javaparser.ast.type.Type;
|
||||||
|
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Java 类型名工具:转字符串、取简单名、剥离 ActionResult/List 等泛型包装。
|
||||||
|
*/
|
||||||
|
public final class TypeNameUtils {
|
||||||
|
/** 需要向内层继续剥离的包装类型 */
|
||||||
|
private static final Set<String> WRAPPER_TYPES = Set.of(
|
||||||
|
"ActionResult", "List", "PageListVO", "Set", "Collection", "Iterable", "Optional"
|
||||||
|
);
|
||||||
|
|
||||||
|
private TypeNameUtils() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Type 转无空白字符串 */
|
||||||
|
public static String typeToString(Type type) {
|
||||||
|
if (type == null) {
|
||||||
|
return "Object";
|
||||||
|
}
|
||||||
|
return type.toString().replaceAll("\\s+", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取类型简单名,去掉包名与泛型 */
|
||||||
|
public static String simpleName(String typeName) {
|
||||||
|
if (typeName == null || typeName.isBlank()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
String cleaned = typeName.replaceAll("\\s+", "");
|
||||||
|
int genericStart = cleaned.indexOf('<');
|
||||||
|
String base = genericStart >= 0 ? cleaned.substring(0, genericStart) : cleaned;
|
||||||
|
int dot = base.lastIndexOf('.');
|
||||||
|
return dot >= 0 ? base.substring(dot + 1) : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 Type AST 收集实际业务类型简单名(穿透包装泛型) */
|
||||||
|
public static Set<String> peelDirectTypeNames(Type type) {
|
||||||
|
Set<String> result = new LinkedHashSet<>();
|
||||||
|
collectPeelTargets(type, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从类型字符串收集实际业务类型简单名 */
|
||||||
|
public static Set<String> peelDirectTypeNames(String typeName) {
|
||||||
|
Set<String> result = new LinkedHashSet<>();
|
||||||
|
collectPeelTargets(typeName, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 递归收集:包装类型则进入泛型参数,否则记录简单名 */
|
||||||
|
private static void collectPeelTargets(Type type, Set<String> result) {
|
||||||
|
if (type == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type.isClassOrInterfaceType()) {
|
||||||
|
ClassOrInterfaceType classType = type.asClassOrInterfaceType();
|
||||||
|
String name = simpleName(classType.getNameAsString());
|
||||||
|
if (WRAPPER_TYPES.contains(name) && classType.getTypeArguments().isPresent()) {
|
||||||
|
for (Type arg : classType.getTypeArguments().get()) {
|
||||||
|
collectPeelTargets(arg, result);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
result.add(name);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
result.add(simpleName(typeToString(type)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 字符串版递归收集 */
|
||||||
|
private static void collectPeelTargets(String typeName, Set<String> result) {
|
||||||
|
String cleaned = typeName.replaceAll("\\s+", "");
|
||||||
|
int genericStart = cleaned.indexOf('<');
|
||||||
|
if (genericStart < 0) {
|
||||||
|
result.add(simpleName(cleaned));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String outer = simpleName(cleaned.substring(0, genericStart));
|
||||||
|
String inner = cleaned.substring(genericStart + 1, cleaned.lastIndexOf('>'));
|
||||||
|
if (WRAPPER_TYPES.contains(outer)) {
|
||||||
|
for (String part : splitGenericArgs(inner)) {
|
||||||
|
collectPeelTargets(part, result);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
result.add(outer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按逗号分割泛型参数,支持嵌套 <> */
|
||||||
|
private static List<String> splitGenericArgs(String inner) {
|
||||||
|
List<String> parts = new java.util.ArrayList<>();
|
||||||
|
int depth = 0;
|
||||||
|
StringBuilder current = new StringBuilder();
|
||||||
|
for (char ch : inner.toCharArray()) {
|
||||||
|
if (ch == '<') {
|
||||||
|
depth++;
|
||||||
|
} else if (ch == '>') {
|
||||||
|
depth--;
|
||||||
|
} else if (ch == ',' && depth == 0) {
|
||||||
|
parts.add(current.toString().trim());
|
||||||
|
current.setLength(0);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
current.append(ch);
|
||||||
|
}
|
||||||
|
if (current.length() > 0) {
|
||||||
|
parts.add(current.toString().trim());
|
||||||
|
}
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,27 +1,47 @@
|
|||||||
# ============================================================
|
# ============================================================
|
||||||
# 类变更检测配置
|
# 类变更检测配置
|
||||||
|
# 由 CI 流水线加载;jar 位于 .gitea/workflows/class-checker.jar
|
||||||
|
# 修改后 push 即可生效,无需重新打包 jar(除非改动了 Java 源码)
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
|
# 总开关。false 时跳过全部检测,流水线直接成功退出
|
||||||
class_check:
|
class_check:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
|
# Dto → Entity 类转换影响检测开关
|
||||||
|
# true:分析 Dto 变更是否通过 convert() 或 BeanUtils.copyProperties 影响到 Entity
|
||||||
|
# false:通知中「② 类转换影响」段落显示「未开启检测」
|
||||||
dto_entity_conversion:
|
dto_entity_conversion:
|
||||||
enabled: true
|
enabled: false
|
||||||
|
|
||||||
|
# 模型类源码目录(相对仓库根路径,可配置多个)
|
||||||
|
# 用于声明 Vo/Dto/Entity/Model 所在模块;当前版本按 git diff 全仓库扫描,
|
||||||
|
# 类名须以 Dto、Vo、VO、Entity、Model 结尾才会纳入检测
|
||||||
model_dirs:
|
model_dirs:
|
||||||
- jnpf-ftb/jnpf-ftb-entity/src/main/java
|
- jnpf-ftb/jnpf-ftb-entity/src/main/java
|
||||||
|
|
||||||
|
# 接口索引扫描目录,用于分析类变更对 API 的影响范围
|
||||||
endpoint_scan:
|
endpoint_scan:
|
||||||
controllers:
|
controllers:
|
||||||
|
# Spring @RestController / @Controller 所在目录
|
||||||
|
# 解析 @RequestMapping 等注解,建立「HTTP 方法 + 路径 → 入参/返回值类型」索引
|
||||||
- jnpf-ftb/jnpf-ftb-biz/src/main/java
|
- jnpf-ftb/jnpf-ftb-biz/src/main/java
|
||||||
feign_apis:
|
feign_apis:
|
||||||
|
# OpenFeign @FeignClient 接口所在目录
|
||||||
|
# 解析 Feign 接口方法签名,补充远程调用端的影响范围
|
||||||
- jnpf-ftb/jnpf-ftb-api/src/main/java
|
- jnpf-ftb/jnpf-ftb-api/src/main/java
|
||||||
|
|
||||||
conversion_scan:
|
conversion_scan:
|
||||||
|
# Dto → Entity 转换代码扫描目录(相对仓库根路径,可配置多个)
|
||||||
|
# 在这些目录中搜索 BeanUtils.copyProperties(source, target) 等调用,
|
||||||
|
# 判断哪些 Entity 会因 Dto 字段变更而受影响
|
||||||
- jnpf-ftb/jnpf-ftb-biz/src/main/java
|
- jnpf-ftb/jnpf-ftb-biz/src/main/java
|
||||||
|
|
||||||
|
# 企业微信通知开关 # false:不发送企微,完整通知内容仅打印到 CI 日志
|
||||||
wecom:
|
wecom:
|
||||||
|
enabled: true
|
||||||
webhook_url: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=61f08cc9-b734-4dff-a931-7f33654c0a81"
|
webhook_url: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=61f08cc9-b734-4dff-a931-7f33654c0a81"
|
||||||
|
|
||||||
|
# true:无变更时打印「无类变更,静默退出」后正常结束(不发送通知)
|
||||||
notify:
|
notify:
|
||||||
only_on_change: true
|
only_on_change: true
|
||||||
|
|||||||
@@ -6,39 +6,31 @@ on: [push]
|
|||||||
jobs:
|
jobs:
|
||||||
class-change-check:
|
class-change-check:
|
||||||
if: ${{ gitea.ref != 'refs/heads/pre' && gitea.ref != 'refs/heads/dev' && gitea.ref != 'refs/heads/master-2.0' }}
|
if: ${{ gitea.ref != 'refs/heads/pre' && gitea.ref != 'refs/heads/dev' && gitea.ref != 'refs/heads/master-2.0' }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: jdk11
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 检出代码
|
- name: 检出代码
|
||||||
run: |
|
run: |
|
||||||
git config --global http.sslVerify false
|
git clone --depth=2 \
|
||||||
git clone "https://${{ gitea.token }}@git.niujiekeji.com/${{ gitea.repository }}.git" .
|
"http://oauth2:${{ gitea.token }}@host.docker.internal:3000/${{ gitea.repository }}.git" \
|
||||||
|
.
|
||||||
git checkout ${{ gitea.sha }}
|
git checkout ${{ gitea.sha }}
|
||||||
echo "当前提交: $(git rev-parse HEAD)"
|
|
||||||
echo "上一提交: $(git rev-parse HEAD~1 2>/dev/null || echo '无')"
|
|
||||||
|
|
||||||
- name: 检查配置文件
|
- name: 检查配置文件与预编译 jar
|
||||||
run: |
|
run: |
|
||||||
if [ ! -f .gitea/config.yaml ]; then
|
if [ ! -f .gitea/config.yaml ]; then
|
||||||
echo "错误: 缺少 .gitea/config.yaml"
|
echo "错误: 缺少 .gitea/config.yaml"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
if [ ! -f .gitea/workflows/class-checker.jar ]; then
|
||||||
|
echo "错误: 缺少 .gitea/workflows/class-checker.jar"
|
||||||
|
echo "请本地执行: powershell -File scripts/build-class-checker.ps1"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
- name: 安装 JDK 和 Maven
|
- name: 验证 JDK
|
||||||
run: |
|
run: |
|
||||||
if ! command -v java >/dev/null 2>&1; then
|
|
||||||
apt-get update -qq
|
|
||||||
apt-get install -y openjdk-11-jdk
|
|
||||||
fi
|
|
||||||
if ! command -v mvn >/dev/null 2>&1; then
|
|
||||||
apt-get update -qq
|
|
||||||
apt-get install -y maven
|
|
||||||
fi
|
|
||||||
echo "Java: $(java -version 2>&1 | head -1)"
|
echo "Java: $(java -version 2>&1 | head -1)"
|
||||||
echo "Maven: $(mvn -version 2>&1 | head -1)"
|
|
||||||
|
|
||||||
- name: 编译检测工具
|
|
||||||
run: mvn -q -f .gitea/class-checker/pom.xml package -DskipTests
|
|
||||||
|
|
||||||
- name: 执行类变更检测
|
- name: 执行类变更检测
|
||||||
run: |
|
run: |
|
||||||
@@ -48,7 +40,7 @@ jobs:
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
COMMIT_TIME=$(git log -1 --format=%cd --date=format:'%Y-%m-%d %H:%M:%S')
|
COMMIT_TIME=$(git log -1 --format=%cd --date=format:'%Y-%m-%d %H:%M:%S')
|
||||||
java -jar .gitea/class-checker/target/class-checker.jar \
|
java -jar .gitea/workflows/class-checker.jar \
|
||||||
--config .gitea/config.yaml \
|
--config .gitea/config.yaml \
|
||||||
--repo-root . \
|
--repo-root . \
|
||||||
--old-sha "$OLD_SHA" \
|
--old-sha "$OLD_SHA" \
|
||||||
|
|||||||
BIN
.gitea/workflows/class-checker.jar
Normal file
BIN
.gitea/workflows/class-checker.jar
Normal file
Binary file not shown.
8
.gitignore
vendored
8
.gitignore
vendored
@@ -13,6 +13,7 @@
|
|||||||
|
|
||||||
# Package Files #
|
# Package Files #
|
||||||
*.jar
|
*.jar
|
||||||
|
!.gitea/workflows/class-checker.jar
|
||||||
*.war
|
*.war
|
||||||
*.nar
|
*.nar
|
||||||
*.ear
|
*.ear
|
||||||
@@ -24,3 +25,10 @@
|
|||||||
hs_err_pid*
|
hs_err_pid*
|
||||||
replay_pid*
|
replay_pid*
|
||||||
|
|
||||||
|
# local env
|
||||||
|
.env.gitea
|
||||||
|
gitea-runner/data/
|
||||||
|
|
||||||
|
# maven build output(提交 .gitea/workflows/class-checker.jar 即可)
|
||||||
|
.gitea/checker/target/
|
||||||
|
|
||||||
|
|||||||
12
Dockerfile.gitea-job
Normal file
12
Dockerfile.gitea-job
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# Gitea Actions 任务容器:预装 headless JDK 11 + Git
|
||||||
|
ARG UBUNTU_IMAGE=ubuntu:24.04
|
||||||
|
FROM ${UBUNTU_IMAGE}
|
||||||
|
|
||||||
|
RUN sed -i 's|http://archive.ubuntu.com/ubuntu|http://mirrors.aliyun.com/ubuntu|g; \
|
||||||
|
s|http://security.ubuntu.com/ubuntu|http://mirrors.aliyun.com/ubuntu|g' \
|
||||||
|
/etc/apt/sources.list.d/ubuntu.sources \
|
||||||
|
&& apt-get update -qq \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
openjdk-11-jdk-headless git ca-certificates curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& java -version
|
||||||
48
docker-compose.gitea.yml
Normal file
48
docker-compose.gitea.yml
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
services:
|
||||||
|
gitea:
|
||||||
|
image: ${GITEA_IMAGE:-docker.m.daocloud.io/gitea/gitea:1.22-rootless}
|
||||||
|
pull_policy: if_not_present
|
||||||
|
container_name: gitea
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
GITEA__server__DOMAIN: localhost
|
||||||
|
GITEA__server__ROOT_URL: http://localhost:3000/
|
||||||
|
GITEA__server__SSH_DOMAIN: localhost
|
||||||
|
GITEA__server__SSH_PORT: 2222
|
||||||
|
GITEA__actions__ENABLED: "true"
|
||||||
|
GITEA__actions__DEFAULT_ACTIONS_URL: "https://gitea.com/actions"
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
- "2222:2222"
|
||||||
|
volumes:
|
||||||
|
- gitea-data:/data
|
||||||
|
|
||||||
|
gitea-job:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.gitea-job
|
||||||
|
args:
|
||||||
|
UBUNTU_IMAGE: ${UBUNTU_IMAGE:-docker.m.daocloud.io/ubuntu:24.04}
|
||||||
|
image: ai-check-gitea-job:latest
|
||||||
|
profiles: ["build"]
|
||||||
|
|
||||||
|
gitea-runner:
|
||||||
|
image: ${GITEA_RUNNER_IMAGE:-docker.m.daocloud.io/gitea/act_runner:0.2.11}
|
||||||
|
pull_policy: if_not_present
|
||||||
|
container_name: gitea-act-runner
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- gitea
|
||||||
|
environment:
|
||||||
|
CONFIG_FILE: /config.yaml
|
||||||
|
GITEA_INSTANCE_URL: ${GITEA_INSTANCE_URL:-http://gitea:3000}
|
||||||
|
GITEA_RUNNER_REGISTRATION_TOKEN: ${GITEA_RUNNER_REGISTRATION_TOKEN:-}
|
||||||
|
GITEA_RUNNER_NAME: ${GITEA_RUNNER_NAME:-local-gitea-runner}
|
||||||
|
volumes:
|
||||||
|
- ./gitea-runner/config.yaml:/config.yaml:ro
|
||||||
|
- gitea-runner-data:/data
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
gitea-data:
|
||||||
|
gitea-runner-data:
|
||||||
10
gitea-runner/config.yaml
Normal file
10
gitea-runner/config.yaml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
log:
|
||||||
|
level: info
|
||||||
|
|
||||||
|
runner:
|
||||||
|
file: .runner
|
||||||
|
capacity: 2
|
||||||
|
timeout: 3h
|
||||||
|
labels:
|
||||||
|
- "ubuntu-latest:docker://ai-check-gitea-job:latest"
|
||||||
|
- "linux:docker://ai-check-gitea-job:latest"
|
||||||
25
scripts/build-class-checker.ps1
Normal file
25
scripts/build-class-checker.ps1
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# 本地打包 class-checker 并复制到 .gitea/workflows/
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$Root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
|
||||||
|
$CheckerDir = Join-Path $Root ".gitea\class-checker"
|
||||||
|
$WorkflowsDir = Join-Path $Root ".gitea\workflows"
|
||||||
|
$TargetJar = Join-Path $CheckerDir "target\class-checker.jar"
|
||||||
|
$OutputJar = Join-Path $WorkflowsDir "class-checker.jar"
|
||||||
|
|
||||||
|
Write-Host ">> 编译 class-checker..."
|
||||||
|
Push-Location $Root
|
||||||
|
& mvn -q -f .gitea/class-checker/pom.xml package -DskipTests
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Pop-Location
|
||||||
|
Write-Error "Maven 编译失败,exit code: $LASTEXITCODE"
|
||||||
|
}
|
||||||
|
Pop-Location
|
||||||
|
|
||||||
|
if (-not (Test-Path $TargetJar)) {
|
||||||
|
Write-Error "编译失败,未找到 $TargetJar"
|
||||||
|
}
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Force -Path $WorkflowsDir | Out-Null
|
||||||
|
Copy-Item -Force $TargetJar $OutputJar
|
||||||
|
Write-Host ">> 已输出: $OutputJar"
|
||||||
|
Write-Host ">> 请 commit 并 push .gitea/workflows/class-checker.jar"
|
||||||
Reference in New Issue
Block a user