diff --git a/.gitea/checker/api-templates/11.py b/.gitea/checker/api-templates/11.py index 9655f6c..d9d3110 100644 --- a/.gitea/checker/api-templates/11.py +++ b/.gitea/checker/api-templates/11.py @@ -106,12 +106,12 @@ def _format_param_details_section(report: EndpointChangeReport) -> List[str]: def _format_endpoint_block(report: EndpointChangeReport) -> str: """ 格式化单个接口块,按模板匹配格式输出。 - 全路径类名显示为 source_file(相对仓库根的完整 .java 路径)。 + 路径显示为 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}**" + class_line = f"- **路径:** **{file_path}**" header = [ f"- **变更类型:** **{change_type}**", @@ -441,7 +441,7 @@ def build_path_change_markdown( # 变更类型高亮 type_highlight = f"**{change_type}**" - # 全路径类名高亮 + # 路径高亮 class_highlight = f"**{file_name}**" # 根据变更类型优化 URI 展示 @@ -459,7 +459,7 @@ def build_path_change_markdown( "# 【API路径变更通知】", "", f" 变更类型: {type_highlight}", - f" 全路径类名: {class_highlight}", + f" 路径: {class_highlight}", f" 修改人: {push_user}", f" 修改时间: {push_time}", "", @@ -496,7 +496,7 @@ def build_method_change_markdown( "# 【API请求方式变更通知】", "", f" 变更类型: {type_highlight}", - f" 全路径类名: {class_highlight}", + f" 路径: {class_highlight}", f" 修改人: {push_user}", f" 修改时间: {push_time}", "", diff --git a/.gitea/checker/api-templates/README.md b/.gitea/checker/api-templates/README.md new file mode 100644 index 0000000..89b2a3f --- /dev/null +++ b/.gitea/checker/api-templates/README.md @@ -0,0 +1,160 @@ +# API 变更检测 — 分析与设计 + +在现有 **类变更检测**(`class-checker.jar`)基础上,扩展 **API 路径变更** 与 **API 参数变更** 两类能力。通知渲染参考 [`11.py`](./11.py)。 + +--- + +## 一、用 JavaParser 做方便吗? + +**结论:方便,且与现有技术栈一致。** + +| 维度 | 评估 | +|------|------| +| 技术栈 | `class-checker` 已依赖 `javaparser-symbol-solver-core 3.25.10`,`EndpointParser` 已在用 | +| 路径解析 | `@RequestMapping` / `@GetMapping` 等注解 + 类级 path 拼接 — **已实现** | +| HTTP 方法 | Mapping 注解读取 — **已实现** | +| 参数解析 | 方法 `Parameter` + `@RequestBody` / `@PathVariable` / `@RequestParam` 注解 — **需扩展** | +| DTO 字段 diff | `@RequestBody` 的 Dto 一级字段 — 可复用 `ClassFieldParser` + `FieldDiffEngine` | +| 跨提交对比 | `git show oldSha:path` / `newSha:path` + AST 对比 — 与类变更相同模式 | + +JavaParser **擅长** 注解驱动的 Spring MVC 声明式接口;**不擅长** 运行时动态路由、非注解配置(如 `WebMvcConfigurer` 手动注册)。 + +--- + +## 二、与现有代码的关系 + +``` +现有(类变更) 待扩展(API 变更) +───────────────── ───────────────── +GitChangeScanner ────────► Git 扫描 Controller/*.java 变更 +EndpointParser(索引) ────────► EndpointSnapshotParser(含参数明细) +FieldDiffEngine ────────► 复用:@RequestBody Dto 字段 diff +ImpactAnalyzer ────────► (类变更专用,API 变更不直接用) +WeComNotifier ────────► ApiChangeNotifier(新,参考 11.py) +``` + +当前 `ApiEndpoint` 仅含:`httpMethod`、`uri`、`paramTypes`(类型简单名集合)、`returnTypes`,**不足以做参数级 diff**,需扩展为 `ApiEndpointSnapshot`(含每个参数的 name、type、source、required、description 等)。 + +--- + +## 三、检测能力拆分 + +### 3.1 API 路径变更(功能 1) + +| 场景 | 识别方式 | 通知模版 | +|------|----------|----------| +| 新增接口 | 新 commit 有、旧 commit 无(同 Controller 方法指纹) | [`api-path-change.md`](./api-path-change.md) · 新增接口 | +| 删除接口 | 旧有、新无 | 删除接口 | +| 修改路径 | 类级/方法级 `@RequestMapping` path 变化,HTTP 方法不变 | 修改路径 | +| 修改请求方式 | 同 URI(或同方法指纹)HTTP 方法变化 | [`api-method-change.md`](./api-method-change.md) | + +**方法指纹建议**(用于跨 commit 匹配同一接口): + +``` +controller源文件 + 方法名 + 参数类型签名 +``` + +仅 URI 匹配在「改路径」场景会失效,需指纹辅助。 + +### 3.2 API 参数变更(功能 2) + +| 参数来源 | 检测内容 | 展示分组 | +|----------|----------|----------| +| `@RequestBody` Dto | Dto **一级字段** 增删改/重命名/类型 | 类对象变更 | +| `@PathVariable` | 名称、类型、是否新增/删除 | 普通参数变更 | +| `@RequestParam` | 名称、类型、required | 普通参数变更 | +| 无注解简单参数 | 视项目规范决定是否纳入 | 待确认 | + +**排除**(参考 11.py / comparator 惯例):`HttpServletRequest`、`HttpServletResponse`、`BindingResult`、`Principal`、`Model`、`ModelMap` 等框架注入参数。 + +模版见 [`api-param-change.md`](./api-param-change.md)。 + +--- + +## 四、建议架构(Java 实现) + +``` +.gitea/checker/src/main/java/com/aicheck/ +├── api/ +│ ├── ApiCheckMain.java # 可选:独立 CLI 或并入 ClassCheckMain +│ ├── scanner/ +│ │ └── ControllerChangeScanner.java # git diff 筛 Controller 文件 +│ ├── parser/ +│ │ └── EndpointSnapshotParser.java # 扩展 EndpointParser,输出完整快照 +│ ├── analyzer/ +│ │ ├── EndpointDiffEngine.java # 路径/方法/增删对比 +│ │ └── ParameterDiffEngine.java # 参数 + Body Dto 字段对比 +│ ├── model/ +│ │ ├── EndpointSnapshot.java +│ │ ├── EndpointChangeReport.java # 对齐 11.py EndpointChangeReport +│ │ └── ParameterChange.java # 对齐 11.py ParameterChange +│ └── notify/ +│ └── ApiChangeNotifier.java # 对齐 11.py 三类模版 +``` + +**流程**: + +1. `git diff --name-only oldSha newSha` 筛 `endpoint_scan.controllers` 下 `.java` +2. 对每个变更 Controller,分别读取 old/new 源码 +3. `EndpointSnapshotParser` 解析两端快照 +4. `EndpointDiffEngine` 产出路径/方法/增删报告 +5. URI+方法未变的接口 → `ParameterDiffEngine` 产出参数报告 +6. `ApiChangeNotifier` 按类型发企微(路径/方法/参数分条发送,与 11.py 一致) + +--- + +## 五、模版文件 + +| 文件 | 对应 11.py | 场景 | +|------|------------|------| +| [api-path-change.md](./api-path-change.md) | `build_path_change_markdown` | 新增 / 删除 / 修改路径 | +| [api-method-change.md](./api-method-change.md) | `build_method_change_markdown` | 修改 HTTP 方法 | +| [api-param-change.md](./api-param-change.md) | `_format_endpoint_block` | URI+方法不变,仅参数变 | +| [11.py](./11.py) | 参考实现(Python + comparator) | 通知渲染逻辑 | + +--- + +## 六、配置扩展建议(`config.yaml`) + +```yaml +api_check: + enabled: true + endpoint_scan: + controllers: + - jnpf-ftb/jnpf-ftb-biz/src/main/java + feign_apis: [] # 是否纳入 Feign,待确认 + exclude_framework_params: true + body_dto_field_depth: 1 # @RequestBody 只 diff 一级字段 +``` + +可与 `class_check` 共用 `wecom` 配置。 + +--- + +## 七、工作量粗估 + +| 模块 | 难度 | 说明 | +|------|------|------| +| EndpointSnapshotParser | 中 | 扩展参数注解解析 | +| EndpointDiffEngine | 中 | 方法指纹 + 路径/方法对比 | +| ParameterDiffEngine | 中高 | Body Dto 复用 FieldDiffEngine;普通参数 diff | +| ApiChangeNotifier | 低 | largely 翻译 11.py | +| CI / 配置 / 联调 | 低 | 扩展 workflow 或合并 Main | + +整体:**可行,约 3–5 个核心类 + 1 个 Notifier**,与类变更检测复用 Git + JavaParser + 企微基础设施。 + +--- + +## 八、实现状态(已并入 `class-checker.jar`) + +| 模块 | 包路径 | 状态 | +|------|--------|------| +| 配置 | `AppConfig` + `config.yaml` `api_check` | ✅ | +| 扫描 | `api.scanner.ApiFileChangeScanner` | ✅ | +| 解析 | `api.parser.EndpointSnapshotParser`、`NestedDtoFieldParser` | ✅ | +| 分析 | `api.analyzer.ApiChangeAnalyzer`、`EndpointDiffEngine`、`ParameterDiffEngine` | ✅ | +| 通知 | `api.notify.ApiChangeNotifier`(路径/方法/参数分条) | ✅ | +| 编排 | `ClassCheckMain`(类变更与 API 变更独立执行) | ✅ | +| 公共 | `common.MarkdownStyles`、`common.WeComMarkdownSender` | ✅ | + +**已确认策略**:并入同一 jar;含 Feign;含请求方式变更;嵌套 Dto 字段;对齐类变更通知规范;分条发送;路径+参数同改拆两条;不含 LLM。 diff --git a/.gitea/checker/api-templates/api-method-change.md b/.gitea/checker/api-templates/api-method-change.md new file mode 100644 index 0000000..bcbbdb8 --- /dev/null +++ b/.gitea/checker/api-templates/api-method-change.md @@ -0,0 +1,38 @@ +# API 请求方式变更通知模版 + +对应 `11.py` → `build_method_change_markdown()`。 +适用:URI 不变,仅 **HTTP 方法** 变化(如 `GET` → `POST`)。 + +> 第一期是否纳入,待产品确认;模版先备齐。 + +--- + +## 完整示例 + +``` +# 【API请求方式变更通知】 + + 变更类型: **修改请求方式** + 路径: **jnpf-ftb/jnpf-ftb-biz/src/main/java/jnpf/workflow/controller/ApplyClockInController.java** + 修改人: dongzi + 修改时间: 2026-06-08 16:30:00 + +--------------------------------------- + +#### 【请求方式变更详情】 +- **URI:** **`/apply/clockIn/{id}`** +- **原请求方式:** **GET** +- **新请求方式:** **PUT****请求方式已变更** +``` + +--- + +## 检测逻辑 + +- 方法指纹相同 + URI 相同 + `httpMethod` 不同 → `is_method_changed = true` +- 与路径变更、参数变更报告**互斥拆分**(同 11.py `comparator` 约定) + +## 实现 + +- `EndpointDiffEngine` +- `ApiChangeNotifier.buildMethodChangeMarkdown()` diff --git a/.gitea/checker/api-templates/api-param-change.md b/.gitea/checker/api-templates/api-param-change.md new file mode 100644 index 0000000..26600b8 --- /dev/null +++ b/.gitea/checker/api-templates/api-param-change.md @@ -0,0 +1,121 @@ +# API 参数变更通知模版 + +对应 `11.py` → `build_markdown_notification()` 中参数变更分支 + `_format_endpoint_block()`。 +适用:**URI 与 HTTP 方法均未变**,仅入参发生变化。 + +--- + +## 完整示例 + +``` +# 【API参数变更通知】 +- **修改人:** dongzi +- **修改时间:** 2026-06-08 16:30:00 + +- **变更类型:** **修改参数** +- **URI:** **POST** `/apply/clockIn` +- **路径:** **jnpf-ftb/jnpf-ftb-biz/src/main/java/jnpf/workflow/controller/ApplyClockInController.java** + +--------------------------------------- + +#### 【接口参数变动详情】 + +**类对象变更(一级字段)** + +共 **1** 个类对象 · **2** 项字段变更 + +**applyAttendanceChangeDto** · `ApplyAttendanceChangeDto` + +├─ `taskIds` · `List` · 必填 [新增] +> 说明:流程主键集合 +└─ `applyUser1` · `Integer` [删除] +> 说明:申请人员 + +**普通参数变更** + +共 **1** 项变更 + +1. `id` · `String` · 路径参数 [新增] +> 说明:主键 +``` + +--- + +## 示例(仅普通参数,无 RequestBody) + +``` +# 【API参数变更通知】 +- **修改人:** dongzi +- **修改时间:** 2026-06-08 16:30:00 + +- **变更类型:** **修改参数** +- **URI:** **GET** `/apply/clockIn/{id}` +- **路径:** **jnpf-ftb/.../ApplyClockInController.java** + +--------------------------------------- + +#### 【接口参数变动详情】 + +**普通参数变更** + +共 **1** 项变更 + +1. `pageSize` · `Integer` · 查询参数 [新增] +``` + +--- + +## 参数分类与检测 + +| 来源注解 | `source` 字段 | diff 粒度 | +|----------|---------------|-----------| +| `@RequestBody` | `body` | Dto **一级字段**(复用 `FieldDiffEngine`) | +| `@PathVariable` | `path` | 参数名、类型、增删 | +| `@RequestParam` | `query` | 参数名、类型、required、增删 | +| 无注解 | `simple` | 待确认是否纳入 | + +### 排除的框架参数(建议默认开启) + +`HttpServletRequest`、`HttpServletResponse`、`BindingResult`、`Principal`、`Authentication`、`Model`、`ModelMap`、`UriComponentsBuilder` 等。 + +--- + +## ParameterChange 数据结构(对齐 11.py) + +| 字段 | 说明 | +|------|------| +| `param_name` | 当前参数名 / 字段名 | +| `old_name` | 重命名前名称 | +| `param_type` | 类型字符串,如 `List` | +| `description` | 说明(@Schema / 注释) | +| `source` | `body` / `path` / `query` | +| `body_param_name` | `@RequestBody` 形参名 | +| `parent_dto` | Dto 简单类名 | +| `change_type` | `added` / `removed` / `modified` / `renamed` | +| `detail` | 类型变化等详情 | + +--- + +## 与路径变更的拆分规则(11.py 约定) + +| 同一次改动 | 通知策略 | +|------------|----------| +| 仅参数变 | 本模版 | +| 路径变 + 参数变 | **拆两条**:先路径通知,再参数通知 | +| 方法变 + 参数变 | **拆两条**:先方法通知,再参数通知 | +| 新增接口 + 带参数 | 路径通知可**附带**参数详情区块 | + +--- + +## JavaParser 实现要点 + +1. **EndpointSnapshotParser**:遍历 `MethodDeclaration.getParameters()`,读参数注解 +2. **@RequestBody**:取 Dto 类型 → `ClassFieldParser.parseFields()` 得字段列表 +3. **ParameterDiffEngine**:旧/新快照按方法指纹对齐后 diff +4. **ApiChangeNotifier**:渲染本模版;泛型类型展示规则与类变更通知一致(`<>` 不 HTML 转义) + +## 实现 + +- `ParameterDiffEngine` +- `ApiChangeNotifier.formatEndpointBlock()` +- 复用 `FieldDiffEngine` / `WeComNotifier` 中的字段行格式(可选统一) diff --git a/.gitea/checker/api-templates/api-path-change.md b/.gitea/checker/api-templates/api-path-change.md new file mode 100644 index 0000000..626d14e --- /dev/null +++ b/.gitea/checker/api-templates/api-path-change.md @@ -0,0 +1,88 @@ +# API 路径变更通知模版 + +对应 `11.py` → `build_path_change_markdown()`。 +适用:**新增接口**、**删除接口**、**修改路径**。 + +--- + +## 完整示例(修改路径) + +``` +# 【API路径变更通知】 + + 变更类型: **修改路径** + 路径: **jnpf-ftb/jnpf-ftb-biz/src/main/java/jnpf/workflow/controller/ApplyClockInController.java** + 修改人: dongzi + 修改时间: 2026-06-08 16:30:00 + +--------------------------------------- + +#### 【URI变更详情】 +- **原路径:** ~~`/apply/clockIn`~~**旧路径** +- **新路径:** **`/apply/clockIn/v2`****新路径** +``` + +--- + +## 示例(新增接口) + +``` +# 【API路径变更通知】 + + 变更类型: **新增接口** + 路径: **jnpf-ftb/.../ApplyClockInController.java** + 修改人: dongzi + 修改时间: 2026-06-08 16:30:00 + +--------------------------------------- + +#### 【URI变更详情】 +- **原路径:** `-` +- **新路径:** **`/apply/clockIn`****新增** +``` + +> 若新增接口同时有参数变更,可在路径通知后追加【接口参数变动详情】区块(见 `api-param-change.md`)。 + +--- + +## 示例(删除接口) + +``` +# 【API路径变更通知】 + + 变更类型: **删除接口** + 路径: **jnpf-ftb/.../ApplyClockInController.java** + 修改人: dongzi + 修改时间: 2026-06-08 16:30:00 + +--------------------------------------- + +#### 【URI变更详情】 +- **原路径:** **`/apply/clockIn/{id}`****已删除** +- **新路径:** `已删除` +``` + +--- + +## 字段说明 + +| 占位符 | 来源 | +|--------|------| +| 变更类型 | `新增接口` / `删除接口` / `修改路径` | +| 路径 | Controller `.java` 相对仓库根路径(`source_file`) | +| 原路径 / 新路径 | 类级 `@RequestMapping` + 方法级 Mapping 拼接后的 URI | +| HTTP 方法 | 路径变更通知中默认不展示;与请求方式变更模版区分 | + +## 检测逻辑(JavaParser) + +1. 解析旧/新 commit 下同一 Controller 源码 AST +2. 提取每个方法的 `httpMethod` + `uri`(已有 `EndpointParser` 逻辑) +3. 用**方法指纹**(类文件 + 方法名 + 参数类型签名)匹配新旧接口 +4. 指纹相同且 URI 不同 → **修改路径** +5. 仅旧有新无 → **删除**;仅新有旧无 → **新增** + +## 实现 + +- `EndpointSnapshotParser` — 解析快照 +- `EndpointDiffEngine` — 对比产出 `EndpointChangeReport.is_renamed_endpoint` 等标志 +- `ApiChangeNotifier.buildPathChangeMarkdown()` — 渲染本模版 diff --git a/.gitea/checker/prompt.md b/.gitea/checker/prompt.md index 1920970..8578056 100644 --- a/.gitea/checker/prompt.md +++ b/.gitea/checker/prompt.md @@ -77,5 +77,5 @@ JavaParser解析(AST Controller变化\DTO变化\VO变化\ENTITY变化) | Entity | [entity.md](./notify-templates/entity.md) | 类转换 | | Model | [model.md](./notify-templates/model.md) | 类转换 | -详见 [notify-templates/README.md](./notify-templates/README.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 index 1029499..e3786f4 100644 --- a/.gitea/checker/src/main/java/com/autoCheck/ClassCheckMain.java +++ b/.gitea/checker/src/main/java/com/autoCheck/ClassCheckMain.java @@ -2,6 +2,9 @@ package com.aicheck; import com.aicheck.analyzer.ClassChangeAnalyzer; import com.aicheck.analyzer.EndpointIndexBuilder; +import com.aicheck.api.analyzer.ApiChangeAnalyzer; +import com.aicheck.api.model.EndpointChangeReport; +import com.aicheck.api.notify.ApiChangeNotifier; import com.aicheck.config.AppConfig; import com.aicheck.git.GitChangeScanner; import com.aicheck.model.ApiEndpoint; @@ -20,7 +23,7 @@ import java.util.concurrent.Callable; * CLI 入口:加载配置 → 扫描 git 变更 → 分析影响 → 输出/发送企微通知。 */ @Command(name = "class-checker", mixinStandardHelpOptions = true, - description = "检测 Vo/Dto/Entity/Model 类变更并发送企业微信通知") + description = "检测类变更与 API 变更并发送企业微信通知") public class ClassCheckMain implements Callable { @Option(names = "--config", required = true, description = "配置文件路径") private Path config; @@ -46,16 +49,33 @@ public class ClassCheckMain implements Callable { System.exit(exitCode); } - /** 主流程:索引接口 → 分析变更 → 通知 */ + /** 主流程:类变更与 API 变更独立检测、分条通知 */ @Override public Integer call() throws Exception { AppConfig appConfig = AppConfig.load(config.toAbsolutePath()); - if (!appConfig.isEnabled()) { + GitChangeScanner gitScanner = new GitChangeScanner(repoRoot.toAbsolutePath()); + int totalSent = 0; + + if (appConfig.isEnabled()) { + totalSent += runClassChangeCheck(appConfig, gitScanner); + } else { System.out.println("类变更检测已关闭(class_check.enabled=false)"); - return 0; } - GitChangeScanner gitScanner = new GitChangeScanner(repoRoot.toAbsolutePath()); + if (appConfig.isApiCheckEnabled()) { + totalSent += runApiChangeCheck(appConfig, gitScanner); + } else { + System.out.println("API 变更检测已关闭(api_check.enabled=false)"); + } + + if (totalSent == 0 && appConfig.isOnlyOnChange()) { + System.out.println("无变更,静默退出"); + } + return 0; + } + + private int runClassChangeCheck(AppConfig appConfig, GitChangeScanner gitScanner) throws Exception { + System.out.println("=== 类变更检测 ==="); EndpointIndexBuilder indexBuilder = new EndpointIndexBuilder(); Map endpointIndex = indexBuilder.buildIndex(repoRoot.toAbsolutePath(), appConfig); System.out.println("已索引接口数量: " + endpointIndex.size()); @@ -64,20 +84,28 @@ public class ClassCheckMain implements Callable { 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 notifier.sendAll(appConfig.getWecomWebhookUrl(), reports, modifier, modifyTime); } - return 0; + notifier.logAll(reports, modifier, modifyTime); + return reports.size(); + } + + private int runApiChangeCheck(AppConfig appConfig, GitChangeScanner gitScanner) throws Exception { + System.out.println("=== API 变更检测 ==="); + ApiChangeAnalyzer analyzer = new ApiChangeAnalyzer(gitScanner); + List reports = analyzer.analyze( + repoRoot.toAbsolutePath(), appConfig, oldSha, newSha); + System.out.println("检测到需通知的 API 变更数量: " + reports.size()); + if (reports.isEmpty()) { + return 0; + } + ApiChangeNotifier notifier = new ApiChangeNotifier(); + return notifier.sendAll(appConfig.getWecomWebhookUrl(), reports, modifier, modifyTime, + appConfig.isWecomEnabled()); } } diff --git a/.gitea/checker/src/main/java/com/autoCheck/api/analyzer/ApiChangeAnalyzer.java b/.gitea/checker/src/main/java/com/autoCheck/api/analyzer/ApiChangeAnalyzer.java new file mode 100644 index 0000000..09a98a6 --- /dev/null +++ b/.gitea/checker/src/main/java/com/autoCheck/api/analyzer/ApiChangeAnalyzer.java @@ -0,0 +1,74 @@ +package com.aicheck.api.analyzer; + +import com.aicheck.api.model.EndpointChangeReport; +import com.aicheck.api.model.EndpointSnapshot; +import com.aicheck.api.parser.EndpointSnapshotParser; +import com.aicheck.api.scanner.ApiFileChangeScanner; +import com.aicheck.config.AppConfig; +import com.aicheck.git.GitChangeScanner; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +/** + * API 变更分析编排(与 {@link com.aicheck.analyzer.ClassChangeAnalyzer} 平行、互不调用)。 + */ +public class ApiChangeAnalyzer { + private final GitChangeScanner gitScanner; + private final ApiFileChangeScanner fileScanner; + + public ApiChangeAnalyzer(GitChangeScanner gitScanner) { + this.gitScanner = gitScanner; + this.fileScanner = new ApiFileChangeScanner(gitScanner); + } + + public List analyze(Path repoRoot, AppConfig config, + String oldSha, String newSha) throws IOException { + List changedFiles = fileScanner.scanChangedFiles( + repoRoot, config.getAllApiScanDirs(), oldSha, newSha); + if (changedFiles.isEmpty()) { + return List.of(); + } + + EndpointSnapshotParser parser = new EndpointSnapshotParser(config.isApiExcludeFrameworkParams()); + ParameterDiffEngine parameterDiffEngine = new ParameterDiffEngine( + repoRoot, buildSearchDirs(config)); + EndpointDiffEngine endpointDiffEngine = new EndpointDiffEngine(parameterDiffEngine); + + List oldSnapshots = new ArrayList<>(); + List newSnapshots = new ArrayList<>(); + + for (String path : changedFiles) { + boolean feign = isFeignPath(path, config); + String oldSource = gitScanner.readFileAtCommit(oldSha, path); + String newSource = gitScanner.readFileAtCommit(newSha, path); + oldSnapshots.addAll(parser.parseSource(oldSource, path, feign)); + newSnapshots.addAll(parser.parseSource(newSource, path, feign)); + } + + return endpointDiffEngine.diff(oldSnapshots, newSnapshots); + } + + private List buildSearchDirs(AppConfig config) { + List dirs = new ArrayList<>(); + dirs.addAll(config.getModelDirs()); + dirs.addAll(config.getAllApiScanDirs()); + return dirs; + } + + private boolean isFeignPath(String path, AppConfig config) { + String normalized = path.replace('\\', '/'); + for (String dir : config.getApiFeignScanDirs()) { + String prefix = dir.replace('\\', '/'); + if (!prefix.endsWith("/")) { + prefix = prefix + "/"; + } + if (normalized.startsWith(prefix)) { + return true; + } + } + return false; + } +} diff --git a/.gitea/checker/src/main/java/com/autoCheck/api/analyzer/EndpointDiffEngine.java b/.gitea/checker/src/main/java/com/autoCheck/api/analyzer/EndpointDiffEngine.java new file mode 100644 index 0000000..6f22da9 --- /dev/null +++ b/.gitea/checker/src/main/java/com/autoCheck/api/analyzer/EndpointDiffEngine.java @@ -0,0 +1,126 @@ +package com.aicheck.api.analyzer; + +import com.aicheck.api.model.ApiChangeKind; +import com.aicheck.api.model.EndpointChangeReport; +import com.aicheck.api.model.EndpointSnapshot; +import com.aicheck.api.model.ParameterChange; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 接口快照对比:路径 / 方法 / 增删 / 参数(拆分报告,互不混合类型)。 + */ +public class EndpointDiffEngine { + private final ParameterDiffEngine parameterDiffEngine; + + public EndpointDiffEngine(ParameterDiffEngine parameterDiffEngine) { + this.parameterDiffEngine = parameterDiffEngine; + } + + public List diff(List oldSnapshots, + List newSnapshots) throws IOException { + Map oldMap = indexByFingerprint(oldSnapshots); + Map newMap = indexByFingerprint(newSnapshots); + List reports = new ArrayList<>(); + + for (String fp : newMap.keySet()) { + if (!oldMap.containsKey(fp)) { + EndpointSnapshot snap = newMap.get(fp); + reports.add(new EndpointChangeReport( + ApiChangeKind.NEW_ENDPOINT, + snap.getHttpMethod(), null, + snap.getUri(), null, + snap.getSourceFile(), snap.getControllerClass(), + snap.getMethodDescription())); + } + } + for (String fp : oldMap.keySet()) { + if (!newMap.containsKey(fp)) { + EndpointSnapshot snap = oldMap.get(fp); + reports.add(new EndpointChangeReport( + ApiChangeKind.REMOVED_ENDPOINT, + snap.getHttpMethod(), null, + snap.getUri(), null, + snap.getSourceFile(), snap.getControllerClass(), + snap.getMethodDescription())); + } + } + for (String fp : oldMap.keySet()) { + if (!newMap.containsKey(fp)) { + continue; + } + EndpointSnapshot oldSnap = oldMap.get(fp); + EndpointSnapshot newSnap = newMap.get(fp); + reports.addAll(diffMatched(oldSnap, newSnap)); + } + return reports; + } + + private List diffMatched(EndpointSnapshot oldSnap, + EndpointSnapshot newSnap) throws IOException { + List reports = new ArrayList<>(); + boolean pathChanged = !oldSnap.getUri().equals(newSnap.getUri()); + boolean methodChanged = !oldSnap.getHttpMethod().equalsIgnoreCase(newSnap.getHttpMethod()); + + if (pathChanged) { + reports.add(new EndpointChangeReport( + ApiChangeKind.PATH_CHANGED, + newSnap.getHttpMethod(), null, + newSnap.getUri(), oldSnap.getUri(), + newSnap.getSourceFile(), newSnap.getControllerClass(), + preferDescription(newSnap, oldSnap))); + } + if (methodChanged) { + reports.add(new EndpointChangeReport( + ApiChangeKind.METHOD_CHANGED, + newSnap.getHttpMethod(), oldSnap.getHttpMethod(), + newSnap.getUri(), null, + newSnap.getSourceFile(), newSnap.getControllerClass(), + preferDescription(newSnap, oldSnap))); + } + + List paramChanges = parameterDiffEngine.diff(oldSnap, newSnap); + if (!paramChanges.isEmpty()) { + if (pathChanged || methodChanged) { + EndpointChangeReport paramReport = new EndpointChangeReport( + ApiChangeKind.PARAM_CHANGED, + newSnap.getHttpMethod(), methodChanged ? oldSnap.getHttpMethod() : null, + newSnap.getUri(), pathChanged ? oldSnap.getUri() : null, + newSnap.getSourceFile(), newSnap.getControllerClass(), + preferDescription(newSnap, oldSnap)); + paramChanges.forEach(paramReport::addParameterChange); + reports.add(paramReport); + } else { + EndpointChangeReport paramReport = new EndpointChangeReport( + ApiChangeKind.PARAM_CHANGED, + newSnap.getHttpMethod(), null, + newSnap.getUri(), null, + newSnap.getSourceFile(), newSnap.getControllerClass(), + preferDescription(newSnap, oldSnap)); + paramChanges.forEach(paramReport::addParameterChange); + reports.add(paramReport); + } + } + return reports; + } + + private String preferDescription(EndpointSnapshot primary, EndpointSnapshot fallback) { + if (primary != null && primary.getMethodDescription() != null + && !primary.getMethodDescription().isBlank()) { + return primary.getMethodDescription(); + } + return fallback == null ? "" : fallback.getMethodDescription(); + } + + private Map indexByFingerprint(List snapshots) { + Map map = new LinkedHashMap<>(); + for (EndpointSnapshot snap : snapshots) { + map.putIfAbsent(snap.getFingerprint(), snap); + } + return map; + } +} diff --git a/.gitea/checker/src/main/java/com/autoCheck/api/analyzer/ParameterDiffEngine.java b/.gitea/checker/src/main/java/com/autoCheck/api/analyzer/ParameterDiffEngine.java new file mode 100644 index 0000000..6551cb7 --- /dev/null +++ b/.gitea/checker/src/main/java/com/autoCheck/api/analyzer/ParameterDiffEngine.java @@ -0,0 +1,135 @@ +package com.aicheck.api.analyzer; + +import com.aicheck.analyzer.FieldDiffEngine; +import com.aicheck.api.model.EndpointSnapshot; +import com.aicheck.api.model.MethodParameterSnapshot; +import com.aicheck.api.model.ParameterChange; +import com.aicheck.api.parser.NestedDtoFieldParser; +import com.aicheck.api.parser.NestedFieldInfo; +import com.aicheck.model.FieldChange; +import com.aicheck.model.FieldInfo; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 接口入参 diff(普通参数 + RequestBody 嵌套 Dto 字段),与类变更 FieldDiffEngine 解耦。 + */ +public class ParameterDiffEngine { + private final NestedDtoFieldParser nestedDtoFieldParser; + private final FieldDiffEngine fieldDiffEngine = new FieldDiffEngine(); + + public ParameterDiffEngine(Path repoRoot, List searchDirs) { + this.nestedDtoFieldParser = new NestedDtoFieldParser(repoRoot, searchDirs); + } + + public List diff(EndpointSnapshot oldSnap, EndpointSnapshot newSnap) throws IOException { + Map oldParams = toParamMap(oldSnap); + Map newParams = toParamMap(newSnap); + List changes = new ArrayList<>(); + + for (Map.Entry entry : newParams.entrySet()) { + MethodParameterSnapshot oldParam = oldParams.get(entry.getKey()); + MethodParameterSnapshot newParam = entry.getValue(); + if (oldParam == null) { + changes.addAll(addedChanges(newParam)); + } else if ("body".equals(newParam.getSource())) { + changes.addAll(diffBodyDto(oldParam, newParam)); + } else if (!oldParam.getType().equals(newParam.getType())) { + changes.add(ParameterChange.modified( + newParam.getName(), + newParam.getType(), + newParam.getDescription(), + oldParam.getType() + " → " + newParam.getType(), + newParam.getSource(), + null, + null, + null + )); + } + } + for (Map.Entry entry : oldParams.entrySet()) { + if (!newParams.containsKey(entry.getKey())) { + changes.addAll(removedChanges(entry.getValue())); + } + } + return changes; + } + + private List diffBodyDto(MethodParameterSnapshot oldParam, + MethodParameterSnapshot newParam) throws IOException { + List oldFields = nestedDtoFieldParser.parseNestedFields(oldParam.getDtoClassName()); + List newFields = nestedDtoFieldParser.parseNestedFields(newParam.getDtoClassName()); + List fieldChanges = fieldDiffEngine.diff(toFieldInfo(oldFields), toFieldInfo(newFields)); + List result = new ArrayList<>(); + for (FieldChange fc : fieldChanges) { + result.add(mapFieldChange(fc, newParam.getName(), newParam.getDtoClassName())); + } + return result; + } + + private ParameterChange mapFieldChange(FieldChange fc, String bodyParamName, String dtoName) { + String path = fc.getFieldName(); + switch (fc.getKind()) { + case ADDED: + return ParameterChange.added(path, fc.getNewType(), fc.getDescription(), + "body", bodyParamName, dtoName, path); + case REMOVED: + return ParameterChange.removed(path, fc.getOldType(), fc.getDescription(), + "body", bodyParamName, dtoName, path); + case RENAMED: + return ParameterChange.renamed(fc.getOldFieldName(), fc.getFieldName(), + fc.getNewType(), fc.getDescription(), "body", bodyParamName, dtoName, path); + case MODIFIED: + default: + return ParameterChange.modified(path, fc.getNewType(), fc.getDescription(), + fc.getDetail(), "body", bodyParamName, dtoName, path); + } + } + + private List addedChanges(MethodParameterSnapshot param) throws IOException { + if ("body".equals(param.getSource())) { + List list = new ArrayList<>(); + for (NestedFieldInfo field : nestedDtoFieldParser.parseNestedFields(param.getDtoClassName())) { + list.add(ParameterChange.added(field.getPath(), field.getType(), field.getDescription(), + "body", param.getName(), param.getDtoClassName(), field.getPath())); + } + return list; + } + return List.of(ParameterChange.added(param.getName(), param.getType(), param.getDescription(), + param.getSource(), null, null, null)); + } + + private List removedChanges(MethodParameterSnapshot param) throws IOException { + if ("body".equals(param.getSource())) { + List list = new ArrayList<>(); + for (NestedFieldInfo field : nestedDtoFieldParser.parseNestedFields(param.getDtoClassName())) { + list.add(ParameterChange.removed(field.getPath(), field.getType(), field.getDescription(), + "body", param.getName(), param.getDtoClassName(), field.getPath())); + } + return list; + } + return List.of(ParameterChange.removed(param.getName(), param.getType(), param.getDescription(), + param.getSource(), null, null, null)); + } + + private Map toParamMap(EndpointSnapshot snap) { + Map map = new LinkedHashMap<>(); + for (MethodParameterSnapshot p : snap.getParameters()) { + map.put(p.identityKey(), p); + } + return map; + } + + private List toFieldInfo(List nested) { + List list = new ArrayList<>(); + for (NestedFieldInfo info : nested) { + list.add(new FieldInfo(info.getPath(), info.getType(), info.getDescription())); + } + return list; + } +} diff --git a/.gitea/checker/src/main/java/com/autoCheck/api/model/ApiChangeKind.java b/.gitea/checker/src/main/java/com/autoCheck/api/model/ApiChangeKind.java new file mode 100644 index 0000000..b79af71 --- /dev/null +++ b/.gitea/checker/src/main/java/com/autoCheck/api/model/ApiChangeKind.java @@ -0,0 +1,12 @@ +package com.aicheck.api.model; + +/** + * API 变更类型(与类变更 {@link com.aicheck.model.ClassChangeKind} 独立)。 + */ +public enum ApiChangeKind { + NEW_ENDPOINT, + REMOVED_ENDPOINT, + PATH_CHANGED, + METHOD_CHANGED, + PARAM_CHANGED +} diff --git a/.gitea/checker/src/main/java/com/autoCheck/api/model/EndpointChangeReport.java b/.gitea/checker/src/main/java/com/autoCheck/api/model/EndpointChangeReport.java new file mode 100644 index 0000000..1bf19b6 --- /dev/null +++ b/.gitea/checker/src/main/java/com/autoCheck/api/model/EndpointChangeReport.java @@ -0,0 +1,76 @@ +package com.aicheck.api.model; + +import java.util.ArrayList; +import java.util.List; + +/** + * 单条 API 变更报告(路径 / 方法 / 参数各自独立,不与其他类型混合)。 + */ +public class EndpointChangeReport { + private final ApiChangeKind changeKind; + private final String httpMethod; + private final String oldHttpMethod; + private final String uri; + private final String oldUri; + private final String sourceFile; + private final String controllerClass; + private final String endpointDescription; + private final List parameterChanges = new ArrayList<>(); + + public EndpointChangeReport(ApiChangeKind changeKind, String httpMethod, String oldHttpMethod, + String uri, String oldUri, String sourceFile, String controllerClass, + String endpointDescription) { + this.changeKind = changeKind; + this.httpMethod = httpMethod; + this.oldHttpMethod = oldHttpMethod; + this.uri = uri; + this.oldUri = oldUri; + this.sourceFile = sourceFile; + this.controllerClass = controllerClass; + this.endpointDescription = endpointDescription == null ? "" : endpointDescription; + } + + public ApiChangeKind getChangeKind() { + return changeKind; + } + + public String getHttpMethod() { + return httpMethod; + } + + public String getOldHttpMethod() { + return oldHttpMethod; + } + + public String getUri() { + return uri; + } + + public String getOldUri() { + return oldUri; + } + + public String getSourceFile() { + return sourceFile; + } + + public String getControllerClass() { + return controllerClass; + } + + public String getEndpointDescription() { + return endpointDescription; + } + + public List getParameterChanges() { + return parameterChanges; + } + + public void addParameterChange(ParameterChange change) { + parameterChanges.add(change); + } + + public boolean hasParameterChanges() { + return !parameterChanges.isEmpty(); + } +} diff --git a/.gitea/checker/src/main/java/com/autoCheck/api/model/EndpointSnapshot.java b/.gitea/checker/src/main/java/com/autoCheck/api/model/EndpointSnapshot.java new file mode 100644 index 0000000..b6875e0 --- /dev/null +++ b/.gitea/checker/src/main/java/com/autoCheck/api/model/EndpointSnapshot.java @@ -0,0 +1,76 @@ +package com.aicheck.api.model; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 单个 HTTP/Feign 接口快照。 + */ +public class EndpointSnapshot { + private final String fingerprint; + private final String httpMethod; + private final String uri; + private final String sourceFile; + private final String controllerClass; + private final String methodName; + private final String methodDescription; + private final List parameters; + + public EndpointSnapshot(String fingerprint, String httpMethod, String uri, String sourceFile, + String controllerClass, String methodName, String methodDescription, + List parameters) { + this.fingerprint = fingerprint; + this.httpMethod = httpMethod; + this.uri = uri; + this.sourceFile = sourceFile; + this.controllerClass = controllerClass; + this.methodName = methodName; + this.methodDescription = methodDescription == null ? "" : methodDescription; + this.parameters = parameters == null ? List.of() : new ArrayList<>(parameters); + } + + public static String buildFingerprint(String sourceFile, String methodName, + List parameters) { + String sig = parameters.stream() + .map(p -> p.getType()) + .collect(Collectors.joining(",")); + return sourceFile + "#" + methodName + "#" + sig; + } + + public String getFingerprint() { + return fingerprint; + } + + public String getHttpMethod() { + return httpMethod; + } + + public String getUri() { + return uri; + } + + public String getSourceFile() { + return sourceFile; + } + + public String getControllerClass() { + return controllerClass; + } + + public String getMethodName() { + return methodName; + } + + public String getMethodDescription() { + return methodDescription; + } + + public List getParameters() { + return parameters; + } + + public String endpointKey() { + return httpMethod + " " + uri; + } +} diff --git a/.gitea/checker/src/main/java/com/autoCheck/api/model/MethodParameterSnapshot.java b/.gitea/checker/src/main/java/com/autoCheck/api/model/MethodParameterSnapshot.java new file mode 100644 index 0000000..1bd457e --- /dev/null +++ b/.gitea/checker/src/main/java/com/autoCheck/api/model/MethodParameterSnapshot.java @@ -0,0 +1,52 @@ +package com.aicheck.api.model; + +/** + * 接口方法入参快照。 + */ +public class MethodParameterSnapshot { + private final String name; + private final String type; + private final String source; + private final boolean required; + private final String description; + private final String dtoClassName; + + public MethodParameterSnapshot(String name, String type, String source, + boolean required, String description, String dtoClassName) { + this.name = name; + this.type = type; + this.source = source; + this.required = required; + this.description = description; + this.dtoClassName = dtoClassName; + } + + public String getName() { + return name; + } + + public String getType() { + return type; + } + + /** body / path / query / simple */ + public String getSource() { + return source; + } + + public boolean isRequired() { + return required; + } + + public String getDescription() { + return description; + } + + public String getDtoClassName() { + return dtoClassName; + } + + public String identityKey() { + return source + ":" + name; + } +} diff --git a/.gitea/checker/src/main/java/com/autoCheck/api/model/ParameterChange.java b/.gitea/checker/src/main/java/com/autoCheck/api/model/ParameterChange.java new file mode 100644 index 0000000..3853c81 --- /dev/null +++ b/.gitea/checker/src/main/java/com/autoCheck/api/model/ParameterChange.java @@ -0,0 +1,112 @@ +package com.aicheck.api.model; + +/** + * API 参数或 RequestBody 嵌套字段变更。 + */ +public class ParameterChange { + public enum ChangeType { + ADDED, REMOVED, MODIFIED, RENAMED + } + + private final ChangeType changeType; + private final String paramName; + private final String oldName; + private final String paramType; + private final String description; + private final String oldDescription; + private final String source; + private final String bodyParamName; + private final String parentDto; + private final String fieldPath; + private final String detail; + + private ParameterChange(ChangeType changeType, String paramName, String oldName, + String paramType, String description, String oldDescription, + String source, String bodyParamName, String parentDto, + String fieldPath, String detail) { + this.changeType = changeType; + this.paramName = paramName; + this.oldName = oldName; + this.paramType = paramType; + this.description = description; + this.oldDescription = oldDescription; + this.source = source; + this.bodyParamName = bodyParamName; + this.parentDto = parentDto; + this.fieldPath = fieldPath; + this.detail = detail; + } + + public static ParameterChange added(String name, String type, String desc, String source, + String bodyParam, String dto, String fieldPath) { + return new ParameterChange(ChangeType.ADDED, name, null, type, desc, null, + source, bodyParam, dto, fieldPath, null); + } + + public static ParameterChange removed(String name, String type, String desc, String source, + String bodyParam, String dto, String fieldPath) { + return new ParameterChange(ChangeType.REMOVED, name, null, type, desc, null, + source, bodyParam, dto, fieldPath, null); + } + + public static ParameterChange modified(String name, String type, String desc, + String detail, String source, String bodyParam, + String dto, String fieldPath) { + return new ParameterChange(ChangeType.MODIFIED, name, null, type, desc, null, + source, bodyParam, dto, fieldPath, detail); + } + + public static ParameterChange renamed(String oldName, String newName, String type, String desc, + String source, String bodyParam, String dto, String fieldPath) { + return new ParameterChange(ChangeType.RENAMED, newName, oldName, type, desc, null, + source, bodyParam, dto, fieldPath, null); + } + + public ChangeType getChangeType() { + return changeType; + } + + public String getParamName() { + return paramName; + } + + public String getOldName() { + return oldName; + } + + public String getParamType() { + return paramType; + } + + public String getDescription() { + return description; + } + + public String getSource() { + return source; + } + + public String getBodyParamName() { + return bodyParamName; + } + + public String getParentDto() { + return parentDto; + } + + public String getFieldPath() { + return fieldPath; + } + + public String getDetail() { + return detail; + } + + public boolean isBodyField() { + return "body".equals(source); + } + + public String displayName() { + return fieldPath == null || fieldPath.isBlank() ? paramName : fieldPath; + } +} diff --git a/.gitea/checker/src/main/java/com/autoCheck/api/notify/ApiChangeNotifier.java b/.gitea/checker/src/main/java/com/autoCheck/api/notify/ApiChangeNotifier.java new file mode 100644 index 0000000..55fa6f5 --- /dev/null +++ b/.gitea/checker/src/main/java/com/autoCheck/api/notify/ApiChangeNotifier.java @@ -0,0 +1,261 @@ +package com.aicheck.api.notify; + +import com.aicheck.api.model.ApiChangeKind; +import com.aicheck.api.model.EndpointChangeReport; +import com.aicheck.api.model.ParameterChange; +import com.aicheck.common.MarkdownStyles; +import com.aicheck.common.WeComMarkdownSender; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * API 变更通知(路径 / 请求方式 / 参数分类型、分条发送,与类变更通知解耦)。 + */ +public class ApiChangeNotifier { + private final WeComMarkdownSender sender = new WeComMarkdownSender(); + + public int sendAll(String webhookUrl, List reports, + String modifier, String modifyTime, boolean wecomEnabled) { + if (reports == null || reports.isEmpty()) { + System.out.println("无 API 变更,不发送通知"); + return 0; + } + int sent = 0; + for (EndpointChangeReport report : reports) { + String markdown = buildMarkdown(report, modifier, modifyTime); + if (wecomEnabled) { + if (sender.send(webhookUrl, markdown)) { + sent++; + System.out.println("已发送 API 变更通知: " + report.getChangeKind() + + " " + report.getHttpMethod() + " " + report.getUri()); + } + } else { + sender.logPreview("API 变更 [" + report.getChangeKind() + "]", markdown); + sent++; + } + } + if (sent > 0) { + System.out.println("总共发送 " + sent + " 条 API 变更通知"); + } + return sent; + } + + public String buildMarkdown(EndpointChangeReport report, String modifier, String modifyTime) { + ApiChangeKind kind = report.getChangeKind(); + if (kind == ApiChangeKind.PATH_CHANGED + || kind == ApiChangeKind.NEW_ENDPOINT + || kind == ApiChangeKind.REMOVED_ENDPOINT) { + return buildPathMarkdown(report, modifier, modifyTime); + } + if (kind == ApiChangeKind.METHOD_CHANGED) { + return buildMethodMarkdown(report, modifier, modifyTime); + } + return buildParamMarkdown(report, modifier, modifyTime); + } + + private String buildPathMarkdown(EndpointChangeReport report, String modifier, String modifyTime) { + String changeLabel; + switch (report.getChangeKind()) { + case NEW_ENDPOINT: + changeLabel = "新增接口"; + break; + case REMOVED_ENDPOINT: + changeLabel = "删除接口"; + break; + default: + changeLabel = "修改路径"; + break; + } + StringBuilder sb = new StringBuilder(); + sb.append("# 【API路径变更通知】").append("\n\n"); + sb.append(MarkdownStyles.quoteKvBold("变更类型", MarkdownStyles.colorWarning(changeLabel))).append("\n"); + sb.append(MarkdownStyles.quoteKvBold("路径", + MarkdownStyles.colorInfo(MarkdownStyles.safe(report.getSourceFile())))).append("\n"); + sb.append(MarkdownStyles.quoteKvBold("修改人", MarkdownStyles.colorComment(modifier))).append("\n"); + sb.append(MarkdownStyles.quoteKvBold("时间", MarkdownStyles.colorComment(modifyTime))).append("\n"); + sb.append("\n## 【URI变更详情】").append("\n\n"); + sb.append(MarkdownStyles.quoteKvBold("接口说明", formatEndpointDescription(report))).append("\n"); + appendPathUriLines(sb, report, changeLabel); + return sb.toString(); + } + + private void appendPathUriLines(StringBuilder sb, EndpointChangeReport report, String changeLabel) { + if ("新增接口".equals(changeLabel)) { + sb.append(MarkdownStyles.quoteKvBold("原路径", "`-`")).append("\n"); + sb.append(MarkdownStyles.quoteKvBold("新路径", + formatUriWithMethod(report.getHttpMethod(), report.getUri(), true) + + " " + MarkdownStyles.colorInfo("[新增]"))).append("\n"); + } else if ("删除接口".equals(changeLabel)) { + sb.append(MarkdownStyles.quoteKvBold("原路径", + formatUriWithMethod(report.getHttpMethod(), report.getUri(), false) + + " " + MarkdownStyles.colorWarning("[已删除]"))).append("\n"); + sb.append(MarkdownStyles.quoteKvBold("新路径", "`已删除`")).append("\n"); + } else { + sb.append(MarkdownStyles.quoteKvBold("原路径", + MarkdownStyles.colorWarning(report.getOldUri()) + " " + MarkdownStyles.colorWarning("[旧路径]"))).append("\n"); + sb.append(MarkdownStyles.quoteKvBold("新路径", + MarkdownStyles.colorInfo(report.getUri()) + " " + MarkdownStyles.colorInfo("[新路径]"))).append("\n"); + } + } + + private String buildMethodMarkdown(EndpointChangeReport report, String modifier, String modifyTime) { + StringBuilder sb = new StringBuilder(); + sb.append("# 【API请求方式变更通知】").append("\n\n"); + sb.append(MarkdownStyles.quoteKvBold("变更类型", MarkdownStyles.colorWarning("修改请求方式"))).append("\n"); + sb.append(MarkdownStyles.quoteKvBold("路径", + MarkdownStyles.colorInfo(MarkdownStyles.safe(report.getSourceFile())))).append("\n"); + sb.append(MarkdownStyles.quoteKvBold("修改人", MarkdownStyles.colorComment(modifier))).append("\n"); + sb.append(MarkdownStyles.quoteKvBold("时间", MarkdownStyles.colorComment(modifyTime))).append("\n"); + sb.append("\n## 【请求方式变更详情】").append("\n\n"); + sb.append(MarkdownStyles.quoteKvBold("接口说明", formatEndpointDescription(report))).append("\n"); + sb.append(MarkdownStyles.quoteKvBold("URI", MarkdownStyles.colorInfo(report.getUri()))).append("\n"); + sb.append(MarkdownStyles.quoteKvBold("原请求方式", + MarkdownStyles.colorWarning(report.getOldHttpMethod()))).append("\n"); + sb.append(MarkdownStyles.quoteKvBold("新请求方式", + MarkdownStyles.colorInfo(report.getHttpMethod()) + " " + + MarkdownStyles.colorInfo("[请求方式已变更]"))).append("\n"); + return sb.toString(); + } + + private String buildParamMarkdown(EndpointChangeReport report, String modifier, String modifyTime) { + StringBuilder sb = new StringBuilder(); + sb.append("# 【API参数变更通知】").append("\n\n"); + sb.append(MarkdownStyles.quoteKvBold("修改人", MarkdownStyles.colorComment(modifier))).append("\n"); + sb.append(MarkdownStyles.quoteKvBold("时间", MarkdownStyles.colorComment(modifyTime))).append("\n"); + sb.append(MarkdownStyles.quoteKvBold("变更类型", MarkdownStyles.colorWarning("修改参数"))).append("\n"); + sb.append(MarkdownStyles.quoteKvBold("URI", + MarkdownStyles.colorInfo(report.getHttpMethod()) + " " + + MarkdownStyles.inlineCode(report.getUri()))).append("\n"); + sb.append(MarkdownStyles.quoteKvBold("路径", + MarkdownStyles.colorInfo(MarkdownStyles.safe(report.getSourceFile())))).append("\n"); + sb.append("\n## 【接口参数变动详情】").append("\n\n"); + appendParameterDetails(sb, report); + return sb.toString(); + } + + private void appendParameterDetails(StringBuilder sb, EndpointChangeReport report) { + List bodyChanges = new ArrayList<>(); + List regularChanges = new ArrayList<>(); + for (ParameterChange change : report.getParameterChanges()) { + if (change.isBodyField()) { + bodyChanges.add(change); + } else { + regularChanges.add(change); + } + } + if (!bodyChanges.isEmpty()) { + sb.append("**类对象变更(含嵌套字段)**").append("\n\n"); + appendBodyGroups(sb, bodyChanges); + sb.append("\n"); + } + if (!regularChanges.isEmpty()) { + sb.append("**普通参数变更**").append("\n\n"); + sb.append(MarkdownStyles.quoteLine("**共 " + + MarkdownStyles.colorWarning(String.valueOf(regularChanges.size())) + + " 项变更**")).append("\n\n"); + for (ParameterChange change : regularChanges) { + sb.append(formatParameterLine(change)).append("\n\n"); + } + } + if (bodyChanges.isEmpty() && regularChanges.isEmpty()) { + sb.append(MarkdownStyles.quoteLine(MarkdownStyles.colorComment("无"))).append("\n"); + } + } + + private void appendBodyGroups(StringBuilder sb, List bodyChanges) { + Map> groups = new LinkedHashMap<>(); + for (ParameterChange change : bodyChanges) { + String key = (change.getBodyParamName() == null ? "body" : change.getBodyParamName()) + + "|" + (change.getParentDto() == null ? "" : change.getParentDto()); + groups.computeIfAbsent(key, k -> new ArrayList<>()).add(change); + } + int total = bodyChanges.size(); + sb.append(MarkdownStyles.quoteLine("**共 " + + MarkdownStyles.colorWarning(String.valueOf(groups.size())) + + " 个类对象 · " + + MarkdownStyles.colorWarning(String.valueOf(total)) + + " 项变更**")).append("\n\n"); + for (List group : groups.values()) { + ParameterChange first = group.get(0); + sb.append("**").append(first.getBodyParamName()).append("**"); + if (first.getParentDto() != null && !first.getParentDto().isBlank()) { + sb.append(" ").append(MarkdownStyles.inlineCode(first.getParentDto())); + } + sb.append("\n\n"); + for (ParameterChange change : group) { + sb.append(formatParameterLine(change)).append("\n\n"); + } + } + } + + private String formatParameterLine(ParameterChange change) { + String tag; + switch (change.getChangeType()) { + case ADDED: + tag = MarkdownStyles.colorInfo("[新增]"); + break; + case REMOVED: + tag = MarkdownStyles.colorWarning("[删除]"); + break; + case RENAMED: + tag = MarkdownStyles.colorWarning("[重命名]"); + break; + default: + tag = MarkdownStyles.colorWarning("[修改]"); + break; + } + String name = MarkdownStyles.inlineCode(MarkdownStyles.safe(change.displayName())); + String desc = change.getDescription() == null || change.getDescription().isBlank() + ? MarkdownStyles.colorComment("(无说明)") + : MarkdownStyles.colorComment(change.getDescription()); + StringBuilder line = new StringBuilder(); + line.append(MarkdownStyles.quoteLine(tag + " " + name + " 说明: " + desc)); + if (change.getChangeType() == ParameterChange.ChangeType.RENAMED) { + line = new StringBuilder(); + line.append(MarkdownStyles.quoteLine(tag + " " + + MarkdownStyles.colorComment(MarkdownStyles.safe(change.getOldName())) + " → " + + MarkdownStyles.colorInfo(MarkdownStyles.safe(change.getParamName())) + + " 说明: " + desc)); + } + String typePart = resolveTypePart(change); + if (!typePart.isBlank()) { + return line + "\n" + MarkdownStyles.quoteKv("类型", typePart); + } + return line.toString(); + } + + private String formatUriWithMethod(String httpMethod, String uri, boolean isNew) { + String path = MarkdownStyles.inlineCode(MarkdownStyles.safe(uri)); + if (httpMethod == null || httpMethod.isBlank()) { + return path; + } + String methodPart = isNew + ? MarkdownStyles.colorInfo(httpMethod.toUpperCase()) + : MarkdownStyles.colorWarning(httpMethod.toUpperCase()); + return methodPart + " " + path; + } + + private String formatEndpointDescription(EndpointChangeReport report) { + String desc = report.getEndpointDescription(); + if (desc == null || desc.isBlank()) { + return MarkdownStyles.colorComment("(无说明)"); + } + return MarkdownStyles.colorComment(MarkdownStyles.safe(desc)); + } + + private String resolveTypePart(ParameterChange change) { + if (change.getChangeType() == ParameterChange.ChangeType.MODIFIED + && change.getDetail() != null && !change.getDetail().isBlank()) { + return MarkdownStyles.formatTypeChange(change.getDetail()); + } + if (change.getParamType() != null && !change.getParamType().isBlank()) { + boolean isNew = change.getChangeType() == ParameterChange.ChangeType.ADDED + || change.getChangeType() == ParameterChange.ChangeType.RENAMED; + return MarkdownStyles.formatSingleType(change.getParamType(), isNew); + } + return ""; + } +} diff --git a/.gitea/checker/src/main/java/com/autoCheck/api/parser/EndpointSnapshotParser.java b/.gitea/checker/src/main/java/com/autoCheck/api/parser/EndpointSnapshotParser.java new file mode 100644 index 0000000..a65b27e --- /dev/null +++ b/.gitea/checker/src/main/java/com/autoCheck/api/parser/EndpointSnapshotParser.java @@ -0,0 +1,289 @@ +package com.aicheck.api.parser; + +import com.aicheck.api.model.EndpointSnapshot; +import com.aicheck.api.model.MethodParameterSnapshot; +import com.aicheck.parser.TypeNameUtils; +import com.github.javaparser.StaticJavaParser; +import com.github.javaparser.ast.CompilationUnit; +import com.github.javaparser.ast.NodeList; +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.expr.Expression; +import com.github.javaparser.ast.expr.NormalAnnotationExpr; +import com.github.javaparser.ast.type.Type; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +/** + * 解析 Controller / Feign 接口完整快照(含入参明细)。 + */ +public class EndpointSnapshotParser { + 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" + ); + private static final Set FRAMEWORK_PARAM_TYPES = Set.of( + "HttpServletRequest", "HttpServletResponse", "BindingResult", "Principal", + "Authentication", "Model", "ModelMap", "UriComponentsBuilder", "WebRequest", + "NativeWebRequest", "Errors", "Locale" + ); + + private final boolean excludeFrameworkParams; + + public EndpointSnapshotParser(boolean excludeFrameworkParams) { + this.excludeFrameworkParams = excludeFrameworkParams; + } + + public List parseSource(String source, String sourceFile, boolean feignMode) { + if (source == null || source.isBlank()) { + return List.of(); + } + CompilationUnit cu = StaticJavaParser.parse(source); + List snapshots = new ArrayList<>(); + for (TypeDeclaration type : cu.getTypes()) { + if (!(type instanceof ClassOrInterfaceDeclaration)) { + continue; + } + ClassOrInterfaceDeclaration decl = (ClassOrInterfaceDeclaration) type; + if (feignMode && !isFeignClient(decl)) { + continue; + } + if (!feignMode && !isController(decl)) { + continue; + } + String basePath = feignMode + ? joinPaths(extractFeignBasePath(decl), extractTypeLevelPath(decl)) + : extractTypeLevelPath(decl); + String className = decl.getNameAsString(); + for (MethodDeclaration method : decl.getMethods()) { + if (feignMode && !decl.isInterface()) { + continue; + } + if (!feignMode && decl.isInterface()) { + continue; + } + snapshots.addAll(parseMethod(method, basePath, sourceFile, className)); + } + } + return snapshots; + } + + private List parseMethod(MethodDeclaration method, String basePath, + String sourceFile, String className) { + List result = new ArrayList<>(); + for (AnnotationExpr annotation : method.getAnnotations()) { + String annName = annotation.getNameAsString(); + if (!MAPPING_ANNOTATIONS.contains(annName)) { + continue; + } + List subPaths = readStringArray(annotation, "value", "path"); + List httpMethods = extractHttpMethods(annotation, annName); + List params = extractParameters(method); + String methodDescription = MethodDescriptionExtractor.extract(method); + String fingerprint = EndpointSnapshot.buildFingerprint(sourceFile, method.getNameAsString(), params); + for (String httpMethod : httpMethods) { + for (String subPath : subPaths) { + String uri = joinPaths(basePath, subPath); + result.add(new EndpointSnapshot(fingerprint, httpMethod, uri, sourceFile, + className, method.getNameAsString(), methodDescription, params)); + } + } + } + return result; + } + + private List extractParameters(MethodDeclaration method) { + List params = new ArrayList<>(); + for (Parameter parameter : method.getParameters()) { + String typeName = TypeNameUtils.typeToString(parameter.getType()); + String simple = TypeNameUtils.simpleName(typeName); + if (excludeFrameworkParams && FRAMEWORK_PARAM_TYPES.contains(simple)) { + continue; + } + String source = resolveParamSource(parameter); + boolean required = resolveRequired(parameter, source); + String dtoName = "body".equals(source) ? simple : ""; + params.add(new MethodParameterSnapshot( + parameter.getNameAsString(), + typeName, + source, + required, + "", + dtoName + )); + } + return params; + } + + private String resolveParamSource(Parameter parameter) { + for (AnnotationExpr ann : parameter.getAnnotations()) { + String name = ann.getNameAsString(); + if ("RequestBody".equals(name)) { + return "body"; + } + if ("PathVariable".equals(name)) { + return "path"; + } + if ("RequestParam".equals(name)) { + return "query"; + } + } + return "simple"; + } + + private boolean resolveRequired(Parameter parameter, String source) { + if ("query".equals(source)) { + for (AnnotationExpr ann : parameter.getAnnotations()) { + if ("RequestParam".equals(ann.getNameAsString()) && ann.isNormalAnnotationExpr()) { + for (var pair : ann.asNormalAnnotationExpr().getPairs()) { + if ("required".equals(pair.getNameAsString())) { + return !"false".equalsIgnoreCase(pair.getValue().toString().trim()); + } + } + } + } + } + return !"query".equals(source); + } + + private boolean isController(ClassOrInterfaceDeclaration decl) { + return decl.getAnnotations().stream() + .anyMatch(ann -> { + String n = ann.getNameAsString(); + return "RestController".equals(n) || "Controller".equals(n); + }); + } + + private boolean isFeignClient(ClassOrInterfaceDeclaration decl) { + return decl.isInterface() && decl.getAnnotations().stream() + .anyMatch(ann -> "FeignClient".equals(ann.getNameAsString())); + } + + private String extractTypeLevelPath(ClassOrInterfaceDeclaration decl) { + for (AnnotationExpr annotation : decl.getAnnotations()) { + if ("RequestMapping".equals(annotation.getNameAsString())) { + List paths = readStringArray(annotation, "value", "path"); + if (!paths.isEmpty()) { + return paths.get(0); + } + } + } + return ""; + } + + private String extractFeignBasePath(ClassOrInterfaceDeclaration decl) { + for (AnnotationExpr annotation : decl.getAnnotations()) { + if ("FeignClient".equals(annotation.getNameAsString())) { + List paths = readStringArray(annotation, "path"); + if (!paths.isEmpty()) { + return paths.get(0); + } + } + } + return ""; + } + + private List extractHttpMethods(AnnotationExpr annotation, String annName) { + if (!"RequestMapping".equals(annName)) { + return List.of(MAPPING_DEFAULT_METHOD.getOrDefault(annName, "GET")); + } + List methods = readEnumArray(annotation, "method"); + return methods.isEmpty() ? List.of("GET") : 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; + } + return (normalizedBase + "/" + normalizedSub.substring(1)).replaceAll("/+", "/"); + } + + private String normalizePath(String path) { + if (path == null || path.isBlank()) { + return ""; + } + String trimmed = path.trim(); + if (!trimmed.startsWith("/")) { + trimmed = "/" + trimmed; + } + return trimmed.replaceAll("/+", "/"); + } + + private 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; + } + + private 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 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/api/parser/JavaSourceLocator.java b/.gitea/checker/src/main/java/com/autoCheck/api/parser/JavaSourceLocator.java new file mode 100644 index 0000000..6554b93 --- /dev/null +++ b/.gitea/checker/src/main/java/com/autoCheck/api/parser/JavaSourceLocator.java @@ -0,0 +1,48 @@ +package com.aicheck.api.parser; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Optional; +import java.util.stream.Stream; + +/** + * 按简单类名在仓库中定位 .java 源文件。 + */ +public class JavaSourceLocator { + private final Path repoRoot; + private final List searchDirs; + + public JavaSourceLocator(Path repoRoot, List searchDirs) { + this.repoRoot = repoRoot; + this.searchDirs = searchDirs; + } + + public Optional readSourceBySimpleName(String simpleClassName) throws IOException { + Optional path = findFile(simpleClassName); + if (path.isEmpty()) { + return Optional.empty(); + } + return Optional.of(Files.readString(path.get())); + } + + public Optional findFile(String simpleClassName) throws IOException { + String fileName = simpleClassName + ".java"; + for (String dir : searchDirs) { + Path root = repoRoot.resolve(dir.replace('\\', '/')); + if (!Files.exists(root)) { + continue; + } + try (Stream walk = Files.walk(root)) { + Optional found = walk + .filter(p -> p.getFileName().toString().equals(fileName)) + .findFirst(); + if (found.isPresent()) { + return found; + } + } + } + return Optional.empty(); + } +} diff --git a/.gitea/checker/src/main/java/com/autoCheck/api/parser/MethodDescriptionExtractor.java b/.gitea/checker/src/main/java/com/autoCheck/api/parser/MethodDescriptionExtractor.java new file mode 100644 index 0000000..8defa19 --- /dev/null +++ b/.gitea/checker/src/main/java/com/autoCheck/api/parser/MethodDescriptionExtractor.java @@ -0,0 +1,74 @@ +package com.aicheck.api.parser; + +import com.github.javaparser.ast.body.MethodDeclaration; +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.Optional; + +/** + * 提取接口方法中文说明:@Operation(summary) > @Operation(description) > Javadoc 首段。 + */ +public final class MethodDescriptionExtractor { + private MethodDescriptionExtractor() { + } + + public static String extract(MethodDeclaration method) { + if (method == null) { + return ""; + } + for (AnnotationExpr annotation : method.getAnnotations()) { + if (!"Operation".equals(annotation.getNameAsString())) { + continue; + } + String summary = readAnnotationStringValue(annotation, "summary"); + if (!summary.isEmpty()) { + return summary; + } + String description = readAnnotationStringValue(annotation, "description"); + if (!description.isEmpty()) { + return description; + } + } + return extractMethodJavadoc(method); + } + + private static String extractMethodJavadoc(MethodDeclaration method) { + Optional javadoc = method.getJavadocComment(); + if (javadoc.isEmpty()) { + return ""; + } + String text = javadoc.get().parse().getDescription().toText(); + return text == null ? "" : text.trim().replaceAll("\\s+", " "); + } + + private static 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) + || "summary".equals(attributeName)) { + return literalString(single.getMemberValue()); + } + } + return ""; + } + + private static String literalString(Expression expression) { + if (expression.isStringLiteralExpr()) { + return expression.asStringLiteralExpr().getValue().trim(); + } + return ""; + } +} diff --git a/.gitea/checker/src/main/java/com/autoCheck/api/parser/NestedDtoFieldParser.java b/.gitea/checker/src/main/java/com/autoCheck/api/parser/NestedDtoFieldParser.java new file mode 100644 index 0000000..763f1db --- /dev/null +++ b/.gitea/checker/src/main/java/com/autoCheck/api/parser/NestedDtoFieldParser.java @@ -0,0 +1,68 @@ +package com.aicheck.api.parser; + +import com.aicheck.model.FieldInfo; +import com.aicheck.parser.ClassFieldParser; +import com.aicheck.parser.TypeNameUtils; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +/** + * 递归展开 Dto/Vo 嵌套字段(dot path),与类变更字段解析解耦但复用 ClassFieldParser。 + */ +public class NestedDtoFieldParser { + private static final Set LEAF_TYPES = Set.of( + "String", "Integer", "int", "Long", "long", "Boolean", "boolean", "Double", "double", + "Float", "float", "Short", "short", "Byte", "byte", "Character", "char", + "BigDecimal", "BigInteger", "Date", "LocalDate", "LocalDateTime", "LocalTime", + "Instant", "Timestamp", "Object", "Void", "void" + ); + + private final ClassFieldParser classFieldParser = new ClassFieldParser(); + private final JavaSourceLocator sourceLocator; + + public NestedDtoFieldParser(Path repoRoot, List searchDirs) { + this.sourceLocator = new JavaSourceLocator(repoRoot, searchDirs); + } + + public List parseNestedFields(String dtoClassName) throws IOException { + Set visiting = new HashSet<>(); + List result = new ArrayList<>(); + collectFields(dtoClassName, "", visiting, result); + return result; + } + + private void collectFields(String className, String prefix, Set visiting, + List out) throws IOException { + if (className == null || className.isBlank() || visiting.contains(className)) { + return; + } + visiting.add(className); + Optional source = sourceLocator.readSourceBySimpleName(className); + if (source.isEmpty()) { + visiting.remove(className); + return; + } + List fields = classFieldParser.parseFields(source.get(), className); + for (FieldInfo field : fields) { + String path = prefix.isBlank() ? field.getName() : prefix + "." + field.getName(); + String simpleType = TypeNameUtils.simpleName(field.getType()); + if (isLeafType(simpleType)) { + out.add(new NestedFieldInfo(path, field.getType(), field.getDescription())); + } else { + out.add(new NestedFieldInfo(path, field.getType(), field.getDescription())); + collectFields(simpleType, path, visiting, out); + } + } + visiting.remove(className); + } + + private boolean isLeafType(String simpleType) { + return LEAF_TYPES.contains(simpleType) || simpleType.endsWith("[]"); + } +} diff --git a/.gitea/checker/src/main/java/com/autoCheck/api/parser/NestedFieldInfo.java b/.gitea/checker/src/main/java/com/autoCheck/api/parser/NestedFieldInfo.java new file mode 100644 index 0000000..838b7fe --- /dev/null +++ b/.gitea/checker/src/main/java/com/autoCheck/api/parser/NestedFieldInfo.java @@ -0,0 +1,28 @@ +package com.aicheck.api.parser; + +/** + * DTO 嵌套字段扁平化条目(dot path)。 + */ +public class NestedFieldInfo { + private final String path; + private final String type; + private final String description; + + public NestedFieldInfo(String path, String type, String description) { + this.path = path; + this.type = type; + this.description = description; + } + + public String getPath() { + return path; + } + + public String getType() { + return type; + } + + public String getDescription() { + return description; + } +} diff --git a/.gitea/checker/src/main/java/com/autoCheck/api/scanner/ApiFileChangeScanner.java b/.gitea/checker/src/main/java/com/autoCheck/api/scanner/ApiFileChangeScanner.java new file mode 100644 index 0000000..c12081f --- /dev/null +++ b/.gitea/checker/src/main/java/com/autoCheck/api/scanner/ApiFileChangeScanner.java @@ -0,0 +1,56 @@ +package com.aicheck.api.scanner; + +import com.aicheck.git.GitChangeScanner; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** + * 扫描 API 相关 Java 文件变更(Controller / Feign),与类变更扫描解耦。 + */ +public class ApiFileChangeScanner { + private final GitChangeScanner gitScanner; + + public ApiFileChangeScanner(GitChangeScanner gitScanner) { + this.gitScanner = gitScanner; + } + + /** 返回两次提交间变更的 .java 相对路径(位于 scanDirs 下) */ + public List scanChangedFiles(Path repoRoot, List scanDirs, + String oldSha, String newSha) throws IOException { + Set changed = new LinkedHashSet<>(); + List diffLines = gitScanner.diffNameOnly(oldSha, newSha); + for (String line : diffLines) { + String path = normalize(line); + if (!path.endsWith(".java")) { + continue; + } + if (isUnderScanDirs(path, scanDirs)) { + changed.add(path); + } + } + return new ArrayList<>(changed); + } + + private boolean isUnderScanDirs(String relativePath, List scanDirs) { + String normalized = relativePath.replace('\\', '/'); + for (String dir : scanDirs) { + String prefix = dir.replace('\\', '/'); + if (!prefix.endsWith("/")) { + prefix = prefix + "/"; + } + if (normalized.startsWith(prefix)) { + return true; + } + } + return false; + } + + private String normalize(String path) { + return path.replace('\\', '/').trim(); + } +} diff --git a/.gitea/checker/src/main/java/com/autoCheck/common/MarkdownStyles.java b/.gitea/checker/src/main/java/com/autoCheck/common/MarkdownStyles.java new file mode 100644 index 0000000..5cee1b5 --- /dev/null +++ b/.gitea/checker/src/main/java/com/autoCheck/common/MarkdownStyles.java @@ -0,0 +1,62 @@ +package com.aicheck.common; + +/** + * 企微 Markdown v1 公共样式(类变更 / API 变更通知共用)。 + */ +public final class MarkdownStyles { + private MarkdownStyles() { + } + + public static String colorInfo(String text) { + return "" + text + ""; + } + + public static String colorComment(String text) { + return "" + safe(text) + ""; + } + + public static String colorWarning(String text) { + return "" + text + ""; + } + + public static String quoteKvBold(String key, String value) { + return "> **" + key + ": " + value + "**"; + } + + public static String quoteKv(String key, String value) { + return "> " + key + ": " + value; + } + + public static String quoteLine(String content) { + return "> " + content; + } + + public static String inlineCode(String text) { + return "`" + text.replace("`", "'") + "`"; + } + + /** 类型展示:泛型尖括号不转义 */ + public static String formatTypeChange(String detail) { + int arrow = detail.indexOf(" → "); + if (arrow < 0) { + return colorWarning(detail); + } + String oldType = detail.substring(0, arrow).trim(); + String newType = detail.substring(arrow + 3).trim(); + return colorWarning(oldType) + " → " + colorInfo(newType); + } + + public static String formatSingleType(String type, boolean isNew) { + if (type == null || type.isBlank()) { + return ""; + } + return isNew ? colorInfo(type) : colorWarning(type); + } + + public static String safe(String text) { + if (text == null) { + return ""; + } + return text.replace("&", "&").replace("<", "<").replace(">", ">"); + } +} diff --git a/.gitea/checker/src/main/java/com/autoCheck/common/WeComMarkdownSender.java b/.gitea/checker/src/main/java/com/autoCheck/common/WeComMarkdownSender.java new file mode 100644 index 0000000..96a1615 --- /dev/null +++ b/.gitea/checker/src/main/java/com/autoCheck/common/WeComMarkdownSender.java @@ -0,0 +1,75 @@ +package com.aicheck.common; + +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; + +import java.io.IOException; +import java.util.concurrent.TimeUnit; + +/** + * 企微 Markdown 发送(与具体变更类型解耦)。 + */ +public class WeComMarkdownSender { + 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 boolean send(String webhookUrl, String content) { + return postMarkdown(webhookUrl, truncate(content)); + } + + public void logPreview(String title, String content) { + System.out.println("========== " + title + " =========="); + System.out.println(content); + System.out.println("========== 结束 =========="); + } + + 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) { + return response.body().string().contains("\"errcode\":0"); + } + System.out.println("[错误] 企微返回异常: " + response.code()); + return false; + } catch (IOException e) { + System.out.println("[错误] 发送企微消息失败: " + e.getMessage()); + return false; + } + } + + private String truncate(String text) { + if (text.length() <= MAX_LENGTH) { + return text; + } + return text.substring(0, MAX_LENGTH) + "\n\n... 消息过长,已截断"; + } + + 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/config/AppConfig.java b/.gitea/checker/src/main/java/com/autoCheck/config/AppConfig.java index 8a06b0c..d597665 100644 --- a/.gitea/checker/src/main/java/com/autoCheck/config/AppConfig.java +++ b/.gitea/checker/src/main/java/com/autoCheck/config/AppConfig.java @@ -24,6 +24,11 @@ public class AppConfig { private boolean wecomEnabled = true; private boolean onlyOnChange = true; + private boolean apiCheckEnabled = true; + private boolean apiExcludeFrameworkParams = true; + private List apiControllerScanDirs = new ArrayList<>(); + private List apiFeignScanDirs = new ArrayList<>(); + /** 从 YAML 文件加载配置 */ @SuppressWarnings("unchecked") public static AppConfig load(Path configPath) throws IOException { @@ -56,6 +61,19 @@ public class AppConfig { Map notify = mapOrEmpty(root.get("notify")); config.onlyOnChange = boolOrDefault(notify.get("only_on_change"), true); + Map apiCheck = mapOrEmpty(root.get("api_check")); + config.apiCheckEnabled = boolOrDefault(apiCheck.get("enabled"), true); + config.apiExcludeFrameworkParams = boolOrDefault(apiCheck.get("exclude_framework_params"), true); + Map apiEndpointScan = mapOrEmpty(apiCheck.get("endpoint_scan")); + config.apiControllerScanDirs = stringList(apiEndpointScan.get("controllers")); + config.apiFeignScanDirs = stringList(apiEndpointScan.get("feign_apis")); + if (config.apiControllerScanDirs.isEmpty()) { + config.apiControllerScanDirs = new ArrayList<>(config.controllerScanDirs); + } + if (config.apiFeignScanDirs.isEmpty()) { + config.apiFeignScanDirs = new ArrayList<>(config.feignScanDirs); + } + return config; } @@ -141,4 +159,32 @@ public class AppConfig { public boolean isOnlyOnChange() { return onlyOnChange; } + + /** API 变更检测总开关 */ + public boolean isApiCheckEnabled() { + return apiCheckEnabled; + } + + /** 是否排除 Spring 框架注入参数 */ + public boolean isApiExcludeFrameworkParams() { + return apiExcludeFrameworkParams; + } + + /** API 检测 Controller 扫描目录 */ + public List getApiControllerScanDirs() { + return apiControllerScanDirs; + } + + /** API 检测 Feign 扫描目录 */ + public List getApiFeignScanDirs() { + return apiFeignScanDirs; + } + + /** API 检测所有扫描目录(Controller + Feign) */ + public List getAllApiScanDirs() { + List dirs = new ArrayList<>(); + dirs.addAll(apiControllerScanDirs); + dirs.addAll(apiFeignScanDirs); + return dirs; + } } diff --git a/.gitea/checker/src/main/java/com/autoCheck/git/GitChangeScanner.java b/.gitea/checker/src/main/java/com/autoCheck/git/GitChangeScanner.java index dc52c4c..8af68f0 100644 --- a/.gitea/checker/src/main/java/com/autoCheck/git/GitChangeScanner.java +++ b/.gitea/checker/src/main/java/com/autoCheck/git/GitChangeScanner.java @@ -198,6 +198,11 @@ public class GitChangeScanner { return Files.readString(file, StandardCharsets.UTF_8); } + /** 两次提交间变更文件路径列表(--name-only) */ + public List diffNameOnly(String oldSha, String newSha) throws IOException { + return runGit("diff", "--name-only", oldSha, newSha); + } + /** 在 repoRoot 下执行 git 命令并返回 stdout 行 */ private List runGit(String... args) throws IOException { String[] command = new String[args.length + 3]; diff --git a/.gitea/config.yaml b/.gitea/config.yaml index 945eeed..bd4a92f 100644 --- a/.gitea/config.yaml +++ b/.gitea/config.yaml @@ -45,3 +45,13 @@ wecom: # true:无变更时打印「无类变更,静默退出」后正常结束(不发送通知) notify: only_on_change: true + +# API 变更检测(路径 / 请求方式 / 参数),与 class_check 共用同一 jar 与 wecom 配置 +api_check: + enabled: true + exclude_framework_params: true + endpoint_scan: + controllers: + - jnpf-ftb/jnpf-ftb-biz/src/main/java + feign_apis: + - jnpf-ftb/jnpf-ftb-api/src/main/java