diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..4c88346 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +*.sh text eol=lf +scripts/* text eol=lf diff --git a/.gitea/checker/api-templates/11.py b/.gitea/checker/api-templates/11.py new file mode 100644 index 0000000..9655f6c --- /dev/null +++ b/.gitea/checker/api-templates/11.py @@ -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... 消息过长,已截断" + + +def _format_param_change_list(changes: List[ParameterChange]) -> List[str]: + """生成企微友好的普通参数变更列表(卡片式)。""" + if not changes: + return [''] + 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 [''] + + 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('') + + 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"- **全路径类名:** **{file_path}**" + + header = [ + f"- **变更类型:** **{change_type}**", + f"- **URI:** {uri_line}", + class_line, + ] + + if report.is_removed_endpoint: + return "\n".join(header + ["", f"**该接口已被移除**"]) + + 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("### 【兼容性提示】") + 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("### 兼容性提示") + 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"**{change_type}**" + + # 全路径类名高亮 + class_highlight = f"**{file_name}**" + + # 根据变更类型优化 URI 展示 + if change_type == "新增接口": + old_display = "`-`" + new_display = f"**`{new_uri}`****新增**" + elif change_type == "删除接口": + old_display = f"**`{old_uri}`****已删除**" + new_display = "`已删除`" + else: # 修改路径 + old_display = f"~~`{old_uri}`~~**旧路径**" + new_display = f"**`{new_uri}`****新路径**" + + 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 = '**修改请求方式**' + class_highlight = f'**{file_name}**' + uri_highlight = f'**`{uri}`**' + old_m = f'**{old_method}**' + new_m = f'**{new_method}**' + + parts = [ + "# 【API请求方式变更通知】", + "", + f" 变更类型: {type_highlight}", + f" 全路径类名: {class_highlight}", + f" 修改人: {push_user}", + f" 修改时间: {push_time}", + "", + "---------------------------------------", + "", + "#### 【请求方式变更详情】", + f"- **URI:** {uri_highlight}", + f"- **原请求方式:** {old_m}", + f"- **新请求方式:** {new_m} ← **请求方式已变更**", + "", + ] + 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) diff --git a/.gitea/checker/dependency-reduced-pom.xml b/.gitea/checker/dependency-reduced-pom.xml new file mode 100644 index 0000000..cd6d6a9 --- /dev/null +++ b/.gitea/checker/dependency-reduced-pom.xml @@ -0,0 +1,51 @@ + + + 4.0.0 + com.aicheck + class-checker + 1.0.0 + + class-checker + + + maven-compiler-plugin + 3.13.0 + + + maven-shade-plugin + 3.6.0 + + + package + + shade + + + + + com.aicheck.ClassCheckMain + + + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + + + + + + + + + + + 11 + 3.25.10 + 11 + UTF-8 + + diff --git a/.gitea/checker/notify-templates/README.md b/.gitea/checker/notify-templates/README.md new file mode 100644 index 0000000..f7570ff --- /dev/null +++ b/.gitea/checker/notify-templates/README.md @@ -0,0 +1,55 @@ +# 类变更通知模版 + +Push 触发 CI 后,按变更类的后缀(`Dto` / `Vo` / `Entity` / `Model`)选用对应模版生成企业微信 Markdown 通知。 + +## 企微语法说明 + +使用 webhook **`markdown`**(v1),支持 font 三色;**不支持无序列表**,故各项以**引用块 + 换行**分行展示。 + +| 语法 | 说明 | +|------|------| +| `#` / `##` / `###` | 标题 | +| `` `行内代码` `` | 字段名、URI | +| `>` | 引用行(每项一行) | +| `` | 绿:类名、新增、HTTP 方法、新类型 | +| `` | 灰:说明、路径、无影响 | +| `` | 橙:[修改]/[删除]、旧类型 | + +## 布局约定 + +1. **# 类变更通知** — 头部 4 项,每项一行 `>标签:值` +2. **## 对象变更细节** — 每条变更独立引用块,字段间空行分隔 +3. **## 影响范围** — 各 ### 小节内,每项一行引用 + +## 公共头部 + +``` +# 类变更通知 + +> 变更对象:ApplyAttendanceChangeDto(Dto) +> 修改人:dongzi +> 时间:2026-06-07 20:14:35 +> 路径:jnpf-ftb/.../ApplyAttendanceChangeDto.java +``` + +## 影响范围 + +| 类类型 | 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` diff --git a/.gitea/checker/notify-templates/dto.md b/.gitea/checker/notify-templates/dto.md new file mode 100644 index 0000000..1c5ed6d --- /dev/null +++ b/.gitea/checker/notify-templates/dto.md @@ -0,0 +1,90 @@ +# Dto 类变更通知模版 + +**识别规则**:类名以 `Dto` 结尾。 +**影响范围**:request + 类转换。 + +--- + +## 完整示例(字段修改) + +``` +# 类变更通知 + +> 变更对象:ApplyAttendanceChangeDto(Dto) +> 修改人:dongzi +> 时间:2026-06-07 20:14:35 +> 路径:jnpf-ftb/jnpf-ftb-entity/src/main/java/jnpf/model/workflow/dto/ApplyAttendanceChangeDto.java + +## 对象变更细节 + +> 共 4 项字段变更 + +> [修改] `taskId` +> 说明:流程主键 +> 类型:IntegerString + +> [修改] `changeUserId` +> 说明:变更人员id +> 类型:StringInteger + +> [新增] `storeId` +> 说明:门店ID + +> [删除] `oldField` +> 说明:已废弃字段 + +## 影响范围 + +### 影响 request 接口 +> POST `/apply/clockIn` +> PUT `/apply/clockIn/{id}` + +### 类转换影响 +> 未开启检测 +``` + +--- + +## 示例(类删除) + +``` +## 对象变更细节 + +> [已删除] 该类文件已被移除 +``` + +--- + +## 示例(仅类名变更) + +``` +## 对象变更细节 + +> [类名变更] ApplyAttendanceChangeDtoApplyAttendanceChangeNewDto +> 字段无变化 +``` + +--- + +## 示例(类名 + 字段同时变更) + +``` +## 对象变更细节 + +> [类名变更] ApplyAttendanceChangeDtoApplyAttendanceChangeNewDto + +> 共 1 项字段变更 + +> [修改] `changeMinute` +> 说明:变更分钟数 +> 类型:IntegerString +``` + +--- + +## 占位符 + +| 占位符 | 来源 | +|--------|------| +| 路径 | Git 相对路径,`ClassChangeReport.sourceFile` | +| 说明 | `@Schema` / 注释 | diff --git a/.gitea/checker/notify-templates/entity.md b/.gitea/checker/notify-templates/entity.md new file mode 100644 index 0000000..d0d476e --- /dev/null +++ b/.gitea/checker/notify-templates/entity.md @@ -0,0 +1,51 @@ +# Entity 类变更通知模版 + +**识别规则**:类名以 `Entity` 结尾。 +**影响范围**:仅类转换(不展示 request/response 接口)。 + +--- + +## 完整示例(字段修改) + +``` +# 类变更通知 + +> 变更对象:TrainingPositionEntity(Entity) +> 修改人:张三 +> 时间:2026-06-07 14:30:00 +> 路径:jnpf-ftb/jnpf-ftb-entity/src/main/java/jnpf/entity/training/TrainingPositionEntity.java + +## 对象变更细节 + +> 共 1 项字段变更 + +> [修改] `createTime` +> 说明:创建时间 +> 类型:DateLocalDateTime + +## 影响范围 + +### 类转换影响 +> Entity:TrainingPositionEntity +``` + +--- + +## 示例(类删除) + +``` +## 对象变更细节 + +> [已删除] 该类文件已被移除 +``` + +--- + +## 示例(仅类名变更) + +``` +## 对象变更细节 + +> [类名变更] TrainingPositionEntityTrainingPositionNewEntity +> 字段无变化 +``` diff --git a/.gitea/checker/notify-templates/field-description.md b/.gitea/checker/notify-templates/field-description.md new file mode 100644 index 0000000..10ac63b --- /dev/null +++ b/.gitea/checker/notify-templates/field-description.md @@ -0,0 +1,47 @@ +# 字段说明规则 + +字段变更采用 **引用块 + 换行 + font 颜色**,遵循企微 `markdown` v1(不支持列表)。 + +## 说明提取优先级 + +| 优先级 | 来源 | +|:------:|------| +| 1 | `@Schema(description = "...")` | +| 2 | `@ApiModelProperty` | +| 3 | `/** ... */` 字段注释 | +| 4 | 空串 | + +## 字段变更行格式 + +``` +> 共 4 项字段变更 + +> [修改] `taskId` +> 说明:流程主键 +> 类型:IntegerString + +> [新增] `storeId` +> 说明:门店ID +``` + +| 操作 | 标签 | 类型行 | +|------|------|--------| +| 新增 | info `[新增]` | 无 | +| 删除 | warning `[删除]` | 无 | +| 修改 | warning `[修改]` | 仅类型变化时出现 | + +- 字段间用**空行**分隔,便于对照 +- 说明为空时显示 `(无说明)` +- 不要在 `` 内嵌 `**bold**` + +## 接口行格式 + +``` +> POST `/apply/clockIn` +``` + +## 实现 + +- `ClassFieldParser.extractFieldLabel()` +- `FieldDiffEngine` — 仅类型变化产生 `[修改]` +- `WeComNotifier.formatFieldChange()` diff --git a/.gitea/checker/notify-templates/model.md b/.gitea/checker/notify-templates/model.md new file mode 100644 index 0000000..4fcc841 --- /dev/null +++ b/.gitea/checker/notify-templates/model.md @@ -0,0 +1,51 @@ +# Model 类变更通知模版 + +**识别规则**:类名以 `Model` 结尾。 +**影响范围**:仅类转换(不展示 request/response 接口)。 + +--- + +## 完整示例(字段修改) + +``` +# 类变更通知 + +> 变更对象:AttendanceRuleModel(Model) +> 修改人:张三 +> 时间:2026-06-07 14:30:00 +> 路径:jnpf-ftb/jnpf-ftb-entity/src/main/java/jnpf/model/attendance/AttendanceRuleModel.java + +## 对象变更细节 + +> 共 1 项字段变更 + +> [修改] `ruleType` +> 说明:规则类型 +> 类型:DateString + +## 影响范围 + +### 类转换影响 +> Entity:AttendanceRuleEntity +``` + +--- + +## 示例(类删除) + +``` +## 对象变更细节 + +> [已删除] 该类文件已被移除 +``` + +--- + +## 示例(仅类名变更) + +``` +## 对象变更细节 + +> [类名变更] AttendanceRuleModelAttendanceRuleNewModel +> 字段无变化 +``` diff --git a/.gitea/checker/notify-templates/vo.md b/.gitea/checker/notify-templates/vo.md new file mode 100644 index 0000000..7e79279 --- /dev/null +++ b/.gitea/checker/notify-templates/vo.md @@ -0,0 +1,57 @@ +# Vo 类变更通知模版 + +**识别规则**:类名以 `Vo` 或 `VO` 结尾。 +**影响范围**:response + 类转换。 + +--- + +## 完整示例(字段修改) + +``` +# 类变更通知 + +> 变更对象:AttendanceDetailVo(Vo) +> 修改人:张三 +> 时间:2026-06-07 14:30:00 +> 路径:jnpf-ftb/jnpf-ftb-entity/src/main/java/jnpf/model/attendance/vo/AttendanceDetailVo.java + +## 对象变更细节 + +> 共 2 项字段变更 + +> [新增] `overtimeHours` +> 说明:加班时长 + +> [修改] `status` +> 说明:考勤状态 +> 类型:IntegerString + +## 影响范围 + +### 影响 response 接口 +> GET `/api/attendance/detail` + +### 类转换影响 +> Entity:AttendanceDetailEntity +``` + +--- + +## 示例(类删除) + +``` +## 对象变更细节 + +> [已删除] 该类文件已被移除 +``` + +--- + +## 示例(仅类名变更) + +``` +## 对象变更细节 + +> [类名变更] AttendanceDetailVoAttendanceDetailNewVo +> 字段无变化 +``` diff --git a/.gitea/checker/pom.xml b/.gitea/checker/pom.xml new file mode 100644 index 0000000..f4200ec --- /dev/null +++ b/.gitea/checker/pom.xml @@ -0,0 +1,82 @@ + + + 4.0.0 + + com.aicheck + class-checker + 1.0.0 + jar + + + 11 + 11 + UTF-8 + 3.25.10 + + + + + com.github.javaparser + javaparser-symbol-solver-core + ${javaparser.version} + + + org.yaml + snakeyaml + 2.2 + + + com.squareup.okhttp3 + okhttp + 4.12.0 + + + info.picocli + picocli + 4.7.6 + + + + + class-checker + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + + org.apache.maven.plugins + maven-shade-plugin + 3.6.0 + + + package + + shade + + + + + com.aicheck.ClassCheckMain + + + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + + + + + + + + + + diff --git a/.gitea/checker/prompt.md b/.gitea/checker/prompt.md new file mode 100644 index 0000000..1920970 --- /dev/null +++ b/.gitea/checker/prompt.md @@ -0,0 +1,81 @@ +--- + +#### 需求拆解: +**[类变更类通知]** Vo、Dto、Model、Entity **目前只针对****修改 ****删除也需要** + +**需要展示的内容:** + +修改人、修改时间 (方便后续前端对接) + +对象变更细节:变更了(增删改查)哪些字段 + 字段说明 + +影响范围:类的变更影响了哪些接口的使用(展示出影响的接口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 | + + + + +#### 三、分层说明 +| **层级** | **组件** | **职责** | +| :--- | :--- | :--- | +| **触发层** | Git Push | 开发者提交代码,触发 CI 流程 | +| **CI/CD 层** | 本地 Gitea + act_runner | 监听 Push 事件,编排流水线任务 | +| **解析层** | GitDiff + JavaParser | 获取 diff,按 AST 解析 Controller/DTO/VO/Entity 变更 | +| **通知层** | 企业微信 | 将分析结果推送给相关开发人员 | + + + + +#### 四、通知模版 + +模版已按类类型拆分至 [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)(含企微颜色样式、全路径类名、字段说明规则)。 + diff --git a/.gitea/checker/src/main/java/com/autoCheck/ClassCheckMain.java b/.gitea/checker/src/main/java/com/autoCheck/ClassCheckMain.java new file mode 100644 index 0000000..1029499 --- /dev/null +++ b/.gitea/checker/src/main/java/com/autoCheck/ClassCheckMain.java @@ -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 { + @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 endpointIndex = indexBuilder.buildIndex(repoRoot.toAbsolutePath(), appConfig); + System.out.println("已索引接口数量: " + endpointIndex.size()); + + ClassChangeAnalyzer analyzer = new ClassChangeAnalyzer(gitScanner); + List 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; + } +} diff --git a/.gitea/checker/src/main/java/com/autoCheck/analyzer/ClassChangeAnalyzer.java b/.gitea/checker/src/main/java/com/autoCheck/analyzer/ClassChangeAnalyzer.java new file mode 100644 index 0000000..b22ae06 --- /dev/null +++ b/.gitea/checker/src/main/java/com/autoCheck/analyzer/ClassChangeAnalyzer.java @@ -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 analyze(Path repoRoot, AppConfig config, String oldSha, String newSha, + Map endpointIndex) throws IOException { + List changedFiles = gitScanner.scanChangedClasses(oldSha, newSha); + List 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 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 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 oldFields = classFieldParser.parseFields(oldSource, oldClassName); + List newFields = classFieldParser.parseFields(newSource, newClassName); + List 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; + } +} diff --git a/.gitea/checker/src/main/java/com/autoCheck/analyzer/EndpointIndexBuilder.java b/.gitea/checker/src/main/java/com/autoCheck/analyzer/EndpointIndexBuilder.java new file mode 100644 index 0000000..ada2195 --- /dev/null +++ b/.gitea/checker/src/main/java/com/autoCheck/analyzer/EndpointIndexBuilder.java @@ -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 buildIndex(Path repoRoot, AppConfig config) throws IOException { + Map 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 index, List endpoints) { + for (ApiEndpoint endpoint : endpoints) { + index.putIfAbsent(endpoint.endpointKey(), endpoint); + } + } +} diff --git a/.gitea/checker/src/main/java/com/autoCheck/analyzer/FieldDiffEngine.java b/.gitea/checker/src/main/java/com/autoCheck/analyzer/FieldDiffEngine.java new file mode 100644 index 0000000..07cb541 --- /dev/null +++ b/.gitea/checker/src/main/java/com/autoCheck/analyzer/FieldDiffEngine.java @@ -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 diff(List oldFields, List newFields) { + Map oldMap = toMap(oldFields); + Map newMap = toMap(newFields); + List changes = new ArrayList<>(); + + for (Map.Entry 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 entry : oldMap.entrySet()) { + if (!newMap.containsKey(entry.getKey())) { + changes.add(FieldChange.removed(entry.getValue())); + } + } + return changes; + } + + /** 字段列表转 LinkedHashMap,保持声明顺序 */ + private Map toMap(List fields) { + Map 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(); + } +} diff --git a/.gitea/checker/src/main/java/com/autoCheck/analyzer/ImpactAnalyzer.java b/.gitea/checker/src/main/java/com/autoCheck/analyzer/ImpactAnalyzer.java new file mode 100644 index 0000000..92d52a3 --- /dev/null +++ b/.gitea/checker/src/main/java/com/autoCheck/analyzer/ImpactAnalyzer.java @@ -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 endpointIndex, + AppConfig config, Path repoRoot, String newSource, String oldSource) throws IOException { + if (report.isRenameOnly()) { + return; + } + + Set 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 namesForMatching(ClassChangeReport report) { + Set 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 endpointIndex, + Set matchNames) { + List inputImpacts = new ArrayList<>(); + List 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 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 types, Set classNames) { + if (types == null) { + return false; + } + for (String type : types) { + if (classNames.contains(type)) { + return true; + } + } + return false; + } +} diff --git a/.gitea/checker/src/main/java/com/autoCheck/config/AppConfig.java b/.gitea/checker/src/main/java/com/autoCheck/config/AppConfig.java new file mode 100644 index 0000000..8a06b0c --- /dev/null +++ b/.gitea/checker/src/main/java/com/autoCheck/config/AppConfig.java @@ -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 modelDirs = new ArrayList<>(); + private List controllerScanDirs = new ArrayList<>(); + private List feignScanDirs = new ArrayList<>(); + private List 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 root; + try (InputStream in = Files.newInputStream(configPath)) { + root = yaml.load(in); + } + if (root == null) { + root = Map.of(); + } + + AppConfig config = new AppConfig(); + Map classCheck = mapOrEmpty(root.get("class_check")); + config.enabled = boolOrDefault(classCheck.get("enabled"), true); + + Map conversion = mapOrEmpty(classCheck.get("dto_entity_conversion")); + config.dtoEntityConversionEnabled = boolOrDefault(conversion.get("enabled"), true); + + config.modelDirs = stringList(classCheck.get("model_dirs")); + Map 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 wecom = mapOrEmpty(root.get("wecom")); + config.wecomWebhookUrl = stringOrEmpty(wecom.get("webhook_url")); + config.wecomEnabled = boolOrDefault(wecom.get("enabled"), true); + + Map notify = mapOrEmpty(root.get("notify")); + config.onlyOnChange = boolOrDefault(notify.get("only_on_change"), true); + + return config; + } + + /** 安全转为 Map,非 Map 则返回空 Map */ + @SuppressWarnings("unchecked") + private static Map mapOrEmpty(Object value) { + if (value instanceof Map) { + return (Map) value; + } + return Map.of(); + } + + /** 安全转为字符串列表 */ + @SuppressWarnings("unchecked") + private static List stringList(Object value) { + if (value instanceof List) { + List list = (List) value; + List 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 getModelDirs() { + return modelDirs; + } + + /** Controller 扫描目录 */ + public List getControllerScanDirs() { + return controllerScanDirs; + } + + /** Feign 接口扫描目录 */ + public List getFeignScanDirs() { + return feignScanDirs; + } + + /** BeanUtils / convert 扫描目录 */ + public List getConversionScanDirs() { + return conversionScanDirs; + } + + /** 企微 Webhook 地址 */ + public String getWecomWebhookUrl() { + return wecomWebhookUrl; + } + + /** 企微通知开关 */ + public boolean isWecomEnabled() { + return wecomEnabled; + } + + /** 无变更时是否打印提示后退出 */ + public boolean isOnlyOnChange() { + return onlyOnChange; + } +} diff --git a/.gitea/checker/src/main/java/com/autoCheck/git/GitChangeScanner.java b/.gitea/checker/src/main/java/com/autoCheck/git/GitChangeScanner.java new file mode 100644 index 0000000..dc52c4c --- /dev/null +++ b/.gitea/checker/src/main/java/com/autoCheck/git/GitChangeScanner.java @@ -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 scanChangedClasses(String oldSha, String newSha) throws IOException { + List lines = runGit("diff", "--name-status", oldSha, newSha); + List deletions = new ArrayList<>(); + Map additionsByParent = new LinkedHashMap<>(); + List 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 deletions, + Map additionsByParent, + String oldSha, + List result) throws IOException { + List 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 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 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 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 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> 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 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; + } + } + } +} diff --git a/.gitea/checker/src/main/java/com/autoCheck/model/ApiEndpoint.java b/.gitea/checker/src/main/java/com/autoCheck/model/ApiEndpoint.java new file mode 100644 index 0000000..13cc8de --- /dev/null +++ b/.gitea/checker/src/main/java/com/autoCheck/model/ApiEndpoint.java @@ -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 paramTypes; + private final Set returnTypes; + + public ApiEndpoint(String httpMethod, String uri, String sourceFile, + Set paramTypes, Set 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 getParamTypes() { + return paramTypes; + } + + /** 返回值涉及的类型简单名集合(已剥离泛型包装) */ + public Set getReturnTypes() { + return returnTypes; + } + + /** 去重用键:METHOD + URI */ + public String endpointKey() { + return httpMethod + " " + uri; + } + + /** 通知展示行:GET /api/foo */ + public String displayLine() { + return httpMethod + " " + uri; + } +} diff --git a/.gitea/checker/src/main/java/com/autoCheck/model/ChangedClassFile.java b/.gitea/checker/src/main/java/com/autoCheck/model/ChangedClassFile.java new file mode 100644 index 0000000..445143f --- /dev/null +++ b/.gitea/checker/src/main/java/com/autoCheck/model/ChangedClassFile.java @@ -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; + } +} diff --git a/.gitea/checker/src/main/java/com/autoCheck/model/ClassChangeKind.java b/.gitea/checker/src/main/java/com/autoCheck/model/ClassChangeKind.java new file mode 100644 index 0000000..3765961 --- /dev/null +++ b/.gitea/checker/src/main/java/com/autoCheck/model/ClassChangeKind.java @@ -0,0 +1,15 @@ +package com.aicheck.model; + +/** + * 单次类变更的类型,决定通知内容与影响分析策略。 + */ +public enum ClassChangeKind { + /** 文件已删除 */ + DELETED, + /** 仅字段变更 */ + FIELDS_ONLY, + /** 仅类名变更,字段不变 */ + RENAME_ONLY, + /** 类名与字段同时变更 */ + RENAME_AND_FIELDS +} diff --git a/.gitea/checker/src/main/java/com/autoCheck/model/ClassChangeReport.java b/.gitea/checker/src/main/java/com/autoCheck/model/ClassChangeReport.java new file mode 100644 index 0000000..70e1fc8 --- /dev/null +++ b/.gitea/checker/src/main/java/com/autoCheck/model/ClassChangeReport.java @@ -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 fieldChanges = new ArrayList<>(); + private final List inputImpactEndpoints = new ArrayList<>(); + private final List conversionEntities = new ArrayList<>(); + private final List 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 getFieldChanges() { + return fieldChanges; + } + + /** 入参引用该类的接口(request 影响) */ + public List getInputImpactEndpoints() { + return inputImpactEndpoints; + } + + /** Dto→Entity 转换目标类名列表 */ + public List getConversionEntities() { + return conversionEntities; + } + + /** 返回值引用该类的接口(response 影响) */ + public List 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); + } + } +} diff --git a/.gitea/checker/src/main/java/com/autoCheck/model/ClassType.java b/.gitea/checker/src/main/java/com/autoCheck/model/ClassType.java new file mode 100644 index 0000000..ee02e9a --- /dev/null +++ b/.gitea/checker/src/main/java/com/autoCheck/model/ClassType.java @@ -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; + } +} diff --git a/.gitea/checker/src/main/java/com/autoCheck/model/FieldChange.java b/.gitea/checker/src/main/java/com/autoCheck/model/FieldChange.java new file mode 100644 index 0000000..929102d --- /dev/null +++ b/.gitea/checker/src/main/java/com/autoCheck/model/FieldChange.java @@ -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; + } +} diff --git a/.gitea/checker/src/main/java/com/autoCheck/model/FieldInfo.java b/.gitea/checker/src/main/java/com/autoCheck/model/FieldInfo.java new file mode 100644 index 0000000..1ee6176 --- /dev/null +++ b/.gitea/checker/src/main/java/com/autoCheck/model/FieldInfo.java @@ -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); + } +} diff --git a/.gitea/checker/src/main/java/com/autoCheck/notify/WeComNotifier.java b/.gitea/checker/src/main/java/com/autoCheck/notify/WeComNotifier.java new file mode 100644 index 0000000..770e39f --- /dev/null +++ b/.gitea/checker/src/main/java/com/autoCheck/notify/WeComNotifier.java @@ -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 并发送(或仅日志输出)。 + *

+ * 使用 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 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 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 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 "" + text + ""; + } + + private String colorComment(String text) { + return "" + safe(text) + ""; + } + + private String colorWarning(String text) { + return "" + text + ""; + } + + /** 转义 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 + "\""; + } +} diff --git a/.gitea/checker/src/main/java/com/autoCheck/parser/ClassDeclParser.java b/.gitea/checker/src/main/java/com/autoCheck/parser/ClassDeclParser.java new file mode 100644 index 0000000..d01df8a --- /dev/null +++ b/.gitea/checker/src/main/java/com/autoCheck/parser/ClassDeclParser.java @@ -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; + } +} diff --git a/.gitea/checker/src/main/java/com/autoCheck/parser/ClassFieldParser.java b/.gitea/checker/src/main/java/com/autoCheck/parser/ClassFieldParser.java new file mode 100644 index 0000000..7de0f1b --- /dev/null +++ b/.gitea/checker/src/main/java/com/autoCheck/parser/ClassFieldParser.java @@ -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 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 parseClassFields(ClassOrInterfaceDeclaration classDecl) { + Map 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 javadoc = fieldDecl.getJavadocComment(); + if (javadoc.isEmpty()) { + return ""; + } + String text = javadoc.get().parse().getDescription().toText(); + return text == null ? "" : text.trim().replaceAll("\\s+", " "); + } +} diff --git a/.gitea/checker/src/main/java/com/autoCheck/parser/ConversionParser.java b/.gitea/checker/src/main/java/com/autoCheck/parser/ConversionParser.java new file mode 100644 index 0000000..28a783f --- /dev/null +++ b/.gitea/checker/src/main/java/com/autoCheck/parser/ConversionParser.java @@ -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 findConvertTargetsInClass(String source, String className) { + Set 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 findBeanUtilsTargets(Path rootDir, String sourceClassName) throws IOException { + Set entities = new LinkedHashSet<>(); + if (!Files.exists(rootDir)) { + return entities; + } + try (Stream 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 scanBeanUtilsInSource(String source, String sourceClassName) { + Set entities = new LinkedHashSet<>(); + CompilationUnit cu = StaticJavaParser.parse(source); + cu.accept(new VoidVisitorAdapter() { + @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; + } +} diff --git a/.gitea/checker/src/main/java/com/autoCheck/parser/EndpointParser.java b/.gitea/checker/src/main/java/com/autoCheck/parser/EndpointParser.java new file mode 100644 index 0000000..5cb3010 --- /dev/null +++ b/.gitea/checker/src/main/java/com/autoCheck/parser/EndpointParser.java @@ -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 MAPPING_ANNOTATIONS = Set.of( + "GetMapping", "PostMapping", "PutMapping", "DeleteMapping", "PatchMapping", "RequestMapping" + ); + private static final Map MAPPING_DEFAULT_METHOD = Map.of( + "GetMapping", "GET", + "PostMapping", "POST", + "PutMapping", "PUT", + "DeleteMapping", "DELETE", + "PatchMapping", "PATCH" + ); + + /** 扫描 @RestController / @Controller 目录 */ + public List scanControllerDirectory(Path rootDir, String relativePrefix) throws IOException { + return scanDirectory(rootDir, relativePrefix, ScanMode.CONTROLLER); + } + + /** 扫描 @FeignClient 接口目录 */ + public List scanFeignDirectory(Path rootDir, String relativePrefix) throws IOException { + return scanDirectory(rootDir, relativePrefix, ScanMode.FEIGN); + } + + /** 递归 walk 目录下 .java 并解析 */ + private List scanDirectory(Path rootDir, String relativePrefix, ScanMode mode) throws IOException { + if (!Files.exists(rootDir)) { + return List.of(); + } + List endpoints = new ArrayList<>(); + try (Stream 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 parseCompilationUnit(String source, String relativePath, ScanMode mode) { + CompilationUnit cu = StaticJavaParser.parse(source); + List 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 parseMethod(MethodDeclaration method, String basePath, String sourceFile) { + List endpoints = new ArrayList<>(); + for (AnnotationExpr annotation : method.getAnnotations()) { + String annName = annotation.getNameAsString(); + if (!MAPPING_ANNOTATIONS.contains(annName)) { + continue; + } + List subPaths = extractPaths(annotation); + List httpMethods = extractHttpMethods(annotation, annName); + for (String httpMethod : httpMethods) { + for (String subPath : subPaths) { + String uri = joinPaths(basePath, subPath); + Set paramTypes = extractParamTypes(method); + Set returnTypes = TypeNameUtils.peelDirectTypeNames(method.getType()); + endpoints.add(new ApiEndpoint(httpMethod, uri, sourceFile, paramTypes, returnTypes)); + } + } + } + return endpoints; + } + + /** 收集方法入参类型简单名 */ + private Set extractParamTypes(MethodDeclaration method) { + Set 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 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 paths = AnnotationValueReader.readStringArray(annotation, "path"); + if (!paths.isEmpty()) { + return paths.get(0); + } + } + } + return ""; + } + + /** 从 Mapping 注解读取 value/path */ + private List extractPaths(AnnotationExpr annotation) { + return AnnotationValueReader.readStringArray(annotation, "value", "path"); + } + + /** 推断 HTTP 方法;RequestMapping 无 method 时默认 GET */ + private List extractHttpMethods(AnnotationExpr annotation, String annName) { + if (!"RequestMapping".equals(annName)) { + return List.of(MAPPING_DEFAULT_METHOD.getOrDefault(annName, "GET")); + } + List 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 readStringArray(AnnotationExpr annotation, String... keys) { + NodeList values = readArrayValues(annotation, keys); + List 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 readEnumArray(AnnotationExpr annotation, String key) { + NodeList values = readArrayValues(annotation, key); + List 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<>(); + } + } +} diff --git a/.gitea/checker/src/main/java/com/autoCheck/parser/TypeNameUtils.java b/.gitea/checker/src/main/java/com/autoCheck/parser/TypeNameUtils.java new file mode 100644 index 0000000..fb0c946 --- /dev/null +++ b/.gitea/checker/src/main/java/com/autoCheck/parser/TypeNameUtils.java @@ -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 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 peelDirectTypeNames(Type type) { + Set result = new LinkedHashSet<>(); + collectPeelTargets(type, result); + return result; + } + + /** 从类型字符串收集实际业务类型简单名 */ + public static Set peelDirectTypeNames(String typeName) { + Set result = new LinkedHashSet<>(); + collectPeelTargets(typeName, result); + return result; + } + + /** 递归收集:包装类型则进入泛型参数,否则记录简单名 */ + private static void collectPeelTargets(Type type, Set 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 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 splitGenericArgs(String inner) { + List 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; + } +} diff --git a/.gitea/config.yaml b/.gitea/config.yaml index 9312346..945eeed 100644 --- a/.gitea/config.yaml +++ b/.gitea/config.yaml @@ -1,27 +1,47 @@ # ============================================================ # 类变更检测配置 +# 由 CI 流水线加载;jar 位于 .gitea/workflows/class-checker.jar +# 修改后 push 即可生效,无需重新打包 jar(除非改动了 Java 源码) # ============================================================ +# 总开关。false 时跳过全部检测,流水线直接成功退出 class_check: enabled: true + # Dto → Entity 类转换影响检测开关 + # true:分析 Dto 变更是否通过 convert() 或 BeanUtils.copyProperties 影响到 Entity + # false:通知中「② 类转换影响」段落显示「未开启检测」 dto_entity_conversion: - enabled: true + enabled: false + # 模型类源码目录(相对仓库根路径,可配置多个) + # 用于声明 Vo/Dto/Entity/Model 所在模块;当前版本按 git diff 全仓库扫描, + # 类名须以 Dto、Vo、VO、Entity、Model 结尾才会纳入检测 model_dirs: - jnpf-ftb/jnpf-ftb-entity/src/main/java + # 接口索引扫描目录,用于分析类变更对 API 的影响范围 endpoint_scan: controllers: + # Spring @RestController / @Controller 所在目录 + # 解析 @RequestMapping 等注解,建立「HTTP 方法 + 路径 → 入参/返回值类型」索引 - jnpf-ftb/jnpf-ftb-biz/src/main/java feign_apis: + # OpenFeign @FeignClient 接口所在目录 + # 解析 Feign 接口方法签名,补充远程调用端的影响范围 - jnpf-ftb/jnpf-ftb-api/src/main/java conversion_scan: + # Dto → Entity 转换代码扫描目录(相对仓库根路径,可配置多个) + # 在这些目录中搜索 BeanUtils.copyProperties(source, target) 等调用, + # 判断哪些 Entity 会因 Dto 字段变更而受影响 - jnpf-ftb/jnpf-ftb-biz/src/main/java +# 企业微信通知开关 # false:不发送企微,完整通知内容仅打印到 CI 日志 wecom: + enabled: true webhook_url: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=61f08cc9-b734-4dff-a931-7f33654c0a81" +# true:无变更时打印「无类变更,静默退出」后正常结束(不发送通知) notify: only_on_change: true diff --git a/.gitea/workflows/class-change-check.yml b/.gitea/workflows/class-change-check.yml index f4148e7..e6e4017 100644 --- a/.gitea/workflows/class-change-check.yml +++ b/.gitea/workflows/class-change-check.yml @@ -6,39 +6,31 @@ on: [push] jobs: class-change-check: 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: - name: 检出代码 run: | - git config --global http.sslVerify false - git clone "https://${{ gitea.token }}@git.niujiekeji.com/${{ gitea.repository }}.git" . + git clone --depth=2 \ + "http://oauth2:${{ gitea.token }}@host.docker.internal:3000/${{ gitea.repository }}.git" \ + . git checkout ${{ gitea.sha }} - echo "当前提交: $(git rev-parse HEAD)" - echo "上一提交: $(git rev-parse HEAD~1 2>/dev/null || echo '无')" - - name: 检查配置文件 + - name: 检查配置文件与预编译 jar run: | if [ ! -f .gitea/config.yaml ]; then echo "错误: 缺少 .gitea/config.yaml" exit 1 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: | - 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 "Maven: $(mvn -version 2>&1 | head -1)" - - - name: 编译检测工具 - run: mvn -q -f .gitea/class-checker/pom.xml package -DskipTests - name: 执行类变更检测 run: | @@ -48,7 +40,7 @@ jobs: exit 0 fi 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 \ --repo-root . \ --old-sha "$OLD_SHA" \ diff --git a/.gitea/workflows/class-checker.jar b/.gitea/workflows/class-checker.jar new file mode 100644 index 0000000..060ba80 Binary files /dev/null and b/.gitea/workflows/class-checker.jar differ diff --git a/.gitignore b/.gitignore index 9154f4c..b1ada3d 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ # Package Files # *.jar +!.gitea/workflows/class-checker.jar *.war *.nar *.ear @@ -24,3 +25,10 @@ hs_err_pid* replay_pid* +# local env +.env.gitea +gitea-runner/data/ + +# maven build output(提交 .gitea/workflows/class-checker.jar 即可) +.gitea/checker/target/ + diff --git a/Dockerfile.gitea-job b/Dockerfile.gitea-job new file mode 100644 index 0000000..09d9698 --- /dev/null +++ b/Dockerfile.gitea-job @@ -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 diff --git a/docker-compose.gitea.yml b/docker-compose.gitea.yml new file mode 100644 index 0000000..14f0590 --- /dev/null +++ b/docker-compose.gitea.yml @@ -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: diff --git a/gitea-runner/config.yaml b/gitea-runner/config.yaml new file mode 100644 index 0000000..7c2c796 --- /dev/null +++ b/gitea-runner/config.yaml @@ -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" diff --git a/scripts/build-class-checker.ps1 b/scripts/build-class-checker.ps1 new file mode 100644 index 0000000..cf9d389 --- /dev/null +++ b/scripts/build-class-checker.ps1 @@ -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"