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