API变更整合
All checks were successful
类变更检测 / class-change-check (push) Successful in 17s

This commit is contained in:
2026-06-09 11:02:06 +08:00
parent 46988e63fa
commit fb6cd124c8
27 changed files with 2140 additions and 20 deletions

View File

@@ -106,12 +106,12 @@ def _format_param_details_section(report: EndpointChangeReport) -> List[str]:
def _format_endpoint_block(report: EndpointChangeReport) -> 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 "修改参数") change_type = "新增接口" if report.is_new_endpoint else ("删除接口" if report.is_removed_endpoint else "修改参数")
uri_line = f"**{report.http_method}** `{report.uri}`" uri_line = f"**{report.http_method}** `{report.uri}`"
file_path = report.source_file or report.controller_class file_path = report.source_file or report.controller_class
class_line = f"- **路径类名** <font color=\"info\">**{file_path}**</font>" class_line = f"- **路径:** <font color=\"info\">**{file_path}**</font>"
header = [ header = [
f"- **变更类型:** <font color=\"warning\">**{change_type}**</font>", f"- **变更类型:** <font color=\"warning\">**{change_type}**</font>",
@@ -441,7 +441,7 @@ def build_path_change_markdown(
# 变更类型高亮 # 变更类型高亮
type_highlight = f"<font color=\"warning\">**{change_type}**</font>" type_highlight = f"<font color=\"warning\">**{change_type}**</font>"
# 路径类名高亮 # 路径高亮
class_highlight = f"<font color=\"info\">**{file_name}**</font>" class_highlight = f"<font color=\"info\">**{file_name}**</font>"
# 根据变更类型优化 URI 展示 # 根据变更类型优化 URI 展示
@@ -459,7 +459,7 @@ def build_path_change_markdown(
"# 【API路径变更通知】", "# 【API路径变更通知】",
"", "",
f" 变更类型: {type_highlight}", f" 变更类型: {type_highlight}",
f" 路径类名 {class_highlight}", f" 路径: {class_highlight}",
f" 修改人: {push_user}", f" 修改人: {push_user}",
f" 修改时间: {push_time}", f" 修改时间: {push_time}",
"", "",
@@ -496,7 +496,7 @@ def build_method_change_markdown(
"# 【API请求方式变更通知】", "# 【API请求方式变更通知】",
"", "",
f" 变更类型: {type_highlight}", f" 变更类型: {type_highlight}",
f" 路径类名 {class_highlight}", f" 路径: {class_highlight}",
f" 修改人: {push_user}", f" 修改人: {push_user}",
f" 修改时间: {push_time}", f" 修改时间: {push_time}",
"", "",

View File

@@ -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 |
整体:**可行,约 35 个核心类 + 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。

View File

@@ -0,0 +1,38 @@
# API 请求方式变更通知模版
对应 `11.py``build_method_change_markdown()`
适用URI 不变,仅 **HTTP 方法** 变化(如 `GET``POST`)。
> 第一期是否纳入,待产品确认;模版先备齐。
---
## 完整示例
```
# 【API请求方式变更通知】
变更类型: <font color="warning">**修改请求方式**</font>
路径: <font color="info">**jnpf-ftb/jnpf-ftb-biz/src/main/java/jnpf/workflow/controller/ApplyClockInController.java**</font>
修改人: dongzi
修改时间: 2026-06-08 16:30:00
---------------------------------------
#### 【请求方式变更详情】
- **URI** <font color="info">**`/apply/clockIn/{id}`**</font>
- **原请求方式:** <font color="warning">**GET**</font>
- **新请求方式:** <font color="info">**PUT**</font> ← <font color="info">**请求方式已变更**</font>
```
---
## 检测逻辑
- 方法指纹相同 + URI 相同 + `httpMethod` 不同 → `is_method_changed = true`
- 与路径变更、参数变更报告**互斥拆分**(同 11.py `comparator` 约定)
## 实现
- `EndpointDiffEngine`
- `ApiChangeNotifier.buildMethodChangeMarkdown()`

View File

@@ -0,0 +1,121 @@
# API 参数变更通知模版
对应 `11.py``build_markdown_notification()` 中参数变更分支 + `_format_endpoint_block()`
适用:**URI 与 HTTP 方法均未变**,仅入参发生变化。
---
## 完整示例
```
# 【API参数变更通知】
- **修改人:** dongzi
- **修改时间:** 2026-06-08 16:30:00
- **变更类型:** <font color="warning">**修改参数**</font>
- **URI** **POST** `/apply/clockIn`
- **路径:** <font color="info">**jnpf-ftb/jnpf-ftb-biz/src/main/java/jnpf/workflow/controller/ApplyClockInController.java**</font>
---------------------------------------
#### 【接口参数变动详情】
**类对象变更(一级字段)**
共 **1** 个类对象 · **2** 项字段变更
**applyAttendanceChangeDto** · `ApplyAttendanceChangeDto`
├─ `taskIds` · `List<String>` · 必填 [新增]
> 说明:流程主键集合
└─ `applyUser1` · `Integer` [删除]
> 说明:申请人员
**普通参数变更**
共 **1** 项变更
1. `id` · `String` · 路径参数 [新增]
> 说明:主键
```
---
## 示例(仅普通参数,无 RequestBody
```
# 【API参数变更通知】
- **修改人:** dongzi
- **修改时间:** 2026-06-08 16:30:00
- **变更类型:** <font color="warning">**修改参数**</font>
- **URI** **GET** `/apply/clockIn/{id}`
- **路径:** <font color="info">**jnpf-ftb/.../ApplyClockInController.java**</font>
---------------------------------------
#### 【接口参数变动详情】
**普通参数变更**
共 **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<String>` |
| `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` 中的字段行格式(可选统一)

View File

@@ -0,0 +1,88 @@
# API 路径变更通知模版
对应 `11.py``build_path_change_markdown()`
适用:**新增接口**、**删除接口**、**修改路径**。
---
## 完整示例(修改路径)
```
# 【API路径变更通知】
变更类型: <font color="warning">**修改路径**</font>
路径: <font color="info">**jnpf-ftb/jnpf-ftb-biz/src/main/java/jnpf/workflow/controller/ApplyClockInController.java**</font>
修改人: dongzi
修改时间: 2026-06-08 16:30:00
---------------------------------------
#### 【URI变更详情】
- **原路径:** <font color="warning">~~`/apply/clockIn`~~</font> ← <font color="warning">**旧路径**</font>
- **新路径:** <font color="info">**`/apply/clockIn/v2`**</font> ← <font color="info">**新路径**</font>
```
---
## 示例(新增接口)
```
# 【API路径变更通知】
变更类型: <font color="warning">**新增接口**</font>
路径: <font color="info">**jnpf-ftb/.../ApplyClockInController.java**</font>
修改人: dongzi
修改时间: 2026-06-08 16:30:00
---------------------------------------
#### 【URI变更详情】
- **原路径:** `-`
- **新路径:** <font color="info">**`/apply/clockIn`**</font> ← <font color="info">**新增**</font>
```
> 若新增接口同时有参数变更,可在路径通知后追加【接口参数变动详情】区块(见 `api-param-change.md`)。
---
## 示例(删除接口)
```
# 【API路径变更通知】
变更类型: <font color="warning">**删除接口**</font>
路径: <font color="info">**jnpf-ftb/.../ApplyClockInController.java**</font>
修改人: dongzi
修改时间: 2026-06-08 16:30:00
---------------------------------------
#### 【URI变更详情】
- **原路径:** <font color="warning">**`/apply/clockIn/{id}`**</font> ← <font color="warning">**已删除**</font>
- **新路径:** `已删除`
```
---
## 字段说明
| 占位符 | 来源 |
|--------|------|
| 变更类型 | `新增接口` / `删除接口` / `修改路径` |
| 路径 | 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()` — 渲染本模版

View File

@@ -77,5 +77,5 @@ JavaParser解析AST Controller变化\DTO变化\VO变化\ENTITY变化
| Entity | [entity.md](./notify-templates/entity.md) | 类转换 | | Entity | [entity.md](./notify-templates/entity.md) | 类转换 |
| Model | [model.md](./notify-templates/model.md) | 类转换 | | Model | [model.md](./notify-templates/model.md) | 类转换 |
详见 [notify-templates/README.md](./notify-templates/README.md)(含企微颜色样式、路径类名、字段说明规则)。 详见 [notify-templates/README.md](./notify-templates/README.md)(含企微颜色样式、路径、字段说明规则)。

View File

@@ -2,6 +2,9 @@ package com.aicheck;
import com.aicheck.analyzer.ClassChangeAnalyzer; import com.aicheck.analyzer.ClassChangeAnalyzer;
import com.aicheck.analyzer.EndpointIndexBuilder; 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.config.AppConfig;
import com.aicheck.git.GitChangeScanner; import com.aicheck.git.GitChangeScanner;
import com.aicheck.model.ApiEndpoint; import com.aicheck.model.ApiEndpoint;
@@ -20,7 +23,7 @@ import java.util.concurrent.Callable;
* CLI 入口:加载配置 → 扫描 git 变更 → 分析影响 → 输出/发送企微通知。 * CLI 入口:加载配置 → 扫描 git 变更 → 分析影响 → 输出/发送企微通知。
*/ */
@Command(name = "class-checker", mixinStandardHelpOptions = true, @Command(name = "class-checker", mixinStandardHelpOptions = true,
description = "检测 Vo/Dto/Entity/Model 类变更并发送企业微信通知") description = "检测类变更与 API 变更并发送企业微信通知")
public class ClassCheckMain implements Callable<Integer> { public class ClassCheckMain implements Callable<Integer> {
@Option(names = "--config", required = true, description = "配置文件路径") @Option(names = "--config", required = true, description = "配置文件路径")
private Path config; private Path config;
@@ -46,16 +49,33 @@ public class ClassCheckMain implements Callable<Integer> {
System.exit(exitCode); System.exit(exitCode);
} }
/** 主流程:索引接口 → 分析变更 → 通知 */ /** 主流程:类变更与 API 变更独立检测、分条通知 */
@Override @Override
public Integer call() throws Exception { public Integer call() throws Exception {
AppConfig appConfig = AppConfig.load(config.toAbsolutePath()); 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"); 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(); EndpointIndexBuilder indexBuilder = new EndpointIndexBuilder();
Map<String, ApiEndpoint> endpointIndex = indexBuilder.buildIndex(repoRoot.toAbsolutePath(), appConfig); Map<String, ApiEndpoint> endpointIndex = indexBuilder.buildIndex(repoRoot.toAbsolutePath(), appConfig);
System.out.println("已索引接口数量: " + endpointIndex.size()); System.out.println("已索引接口数量: " + endpointIndex.size());
@@ -64,20 +84,28 @@ public class ClassCheckMain implements Callable<Integer> {
List<ClassChangeReport> reports = analyzer.analyze( List<ClassChangeReport> reports = analyzer.analyze(
repoRoot.toAbsolutePath(), appConfig, oldSha, newSha, endpointIndex); repoRoot.toAbsolutePath(), appConfig, oldSha, newSha, endpointIndex);
System.out.println("检测到需通知的类变更数量: " + reports.size()); System.out.println("检测到需通知的类变更数量: " + reports.size());
if (reports.isEmpty()) { if (reports.isEmpty()) {
if (appConfig.isOnlyOnChange()) {
System.out.println("无类变更,静默退出");
}
return 0; return 0;
} }
WeComNotifier notifier = new WeComNotifier(); WeComNotifier notifier = new WeComNotifier();
if (appConfig.isWecomEnabled()) { if (appConfig.isWecomEnabled()) {
notifier.sendAll(appConfig.getWecomWebhookUrl(), reports, modifier, modifyTime); return notifier.sendAll(appConfig.getWecomWebhookUrl(), reports, modifier, modifyTime);
} else {
notifier.logAll(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<EndpointChangeReport> 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());
} }
} }

View File

@@ -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<EndpointChangeReport> analyze(Path repoRoot, AppConfig config,
String oldSha, String newSha) throws IOException {
List<String> 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<EndpointSnapshot> oldSnapshots = new ArrayList<>();
List<EndpointSnapshot> 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<String> buildSearchDirs(AppConfig config) {
List<String> 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;
}
}

View File

@@ -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<EndpointChangeReport> diff(List<EndpointSnapshot> oldSnapshots,
List<EndpointSnapshot> newSnapshots) throws IOException {
Map<String, EndpointSnapshot> oldMap = indexByFingerprint(oldSnapshots);
Map<String, EndpointSnapshot> newMap = indexByFingerprint(newSnapshots);
List<EndpointChangeReport> 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<EndpointChangeReport> diffMatched(EndpointSnapshot oldSnap,
EndpointSnapshot newSnap) throws IOException {
List<EndpointChangeReport> 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<ParameterChange> 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<String, EndpointSnapshot> indexByFingerprint(List<EndpointSnapshot> snapshots) {
Map<String, EndpointSnapshot> map = new LinkedHashMap<>();
for (EndpointSnapshot snap : snapshots) {
map.putIfAbsent(snap.getFingerprint(), snap);
}
return map;
}
}

View File

@@ -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<String> searchDirs) {
this.nestedDtoFieldParser = new NestedDtoFieldParser(repoRoot, searchDirs);
}
public List<ParameterChange> diff(EndpointSnapshot oldSnap, EndpointSnapshot newSnap) throws IOException {
Map<String, MethodParameterSnapshot> oldParams = toParamMap(oldSnap);
Map<String, MethodParameterSnapshot> newParams = toParamMap(newSnap);
List<ParameterChange> changes = new ArrayList<>();
for (Map.Entry<String, MethodParameterSnapshot> 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<String, MethodParameterSnapshot> entry : oldParams.entrySet()) {
if (!newParams.containsKey(entry.getKey())) {
changes.addAll(removedChanges(entry.getValue()));
}
}
return changes;
}
private List<ParameterChange> diffBodyDto(MethodParameterSnapshot oldParam,
MethodParameterSnapshot newParam) throws IOException {
List<NestedFieldInfo> oldFields = nestedDtoFieldParser.parseNestedFields(oldParam.getDtoClassName());
List<NestedFieldInfo> newFields = nestedDtoFieldParser.parseNestedFields(newParam.getDtoClassName());
List<FieldChange> fieldChanges = fieldDiffEngine.diff(toFieldInfo(oldFields), toFieldInfo(newFields));
List<ParameterChange> 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<ParameterChange> addedChanges(MethodParameterSnapshot param) throws IOException {
if ("body".equals(param.getSource())) {
List<ParameterChange> 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<ParameterChange> removedChanges(MethodParameterSnapshot param) throws IOException {
if ("body".equals(param.getSource())) {
List<ParameterChange> 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<String, MethodParameterSnapshot> toParamMap(EndpointSnapshot snap) {
Map<String, MethodParameterSnapshot> map = new LinkedHashMap<>();
for (MethodParameterSnapshot p : snap.getParameters()) {
map.put(p.identityKey(), p);
}
return map;
}
private List<FieldInfo> toFieldInfo(List<NestedFieldInfo> nested) {
List<FieldInfo> list = new ArrayList<>();
for (NestedFieldInfo info : nested) {
list.add(new FieldInfo(info.getPath(), info.getType(), info.getDescription()));
}
return list;
}
}

View File

@@ -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
}

View File

@@ -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<ParameterChange> 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<ParameterChange> getParameterChanges() {
return parameterChanges;
}
public void addParameterChange(ParameterChange change) {
parameterChanges.add(change);
}
public boolean hasParameterChanges() {
return !parameterChanges.isEmpty();
}
}

View File

@@ -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<MethodParameterSnapshot> parameters;
public EndpointSnapshot(String fingerprint, String httpMethod, String uri, String sourceFile,
String controllerClass, String methodName, String methodDescription,
List<MethodParameterSnapshot> 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<MethodParameterSnapshot> 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<MethodParameterSnapshot> getParameters() {
return parameters;
}
public String endpointKey() {
return httpMethod + " " + uri;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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<EndpointChangeReport> 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<ParameterChange> bodyChanges = new ArrayList<>();
List<ParameterChange> 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<ParameterChange> bodyChanges) {
Map<String, List<ParameterChange>> 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<ParameterChange> 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 "";
}
}

View File

@@ -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<String> MAPPING_ANNOTATIONS = Set.of(
"GetMapping", "PostMapping", "PutMapping", "DeleteMapping", "PatchMapping", "RequestMapping"
);
private static final Map<String, String> MAPPING_DEFAULT_METHOD = Map.of(
"GetMapping", "GET",
"PostMapping", "POST",
"PutMapping", "PUT",
"DeleteMapping", "DELETE",
"PatchMapping", "PATCH"
);
private static final Set<String> 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<EndpointSnapshot> parseSource(String source, String sourceFile, boolean feignMode) {
if (source == null || source.isBlank()) {
return List.of();
}
CompilationUnit cu = StaticJavaParser.parse(source);
List<EndpointSnapshot> 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<EndpointSnapshot> parseMethod(MethodDeclaration method, String basePath,
String sourceFile, String className) {
List<EndpointSnapshot> result = new ArrayList<>();
for (AnnotationExpr annotation : method.getAnnotations()) {
String annName = annotation.getNameAsString();
if (!MAPPING_ANNOTATIONS.contains(annName)) {
continue;
}
List<String> subPaths = readStringArray(annotation, "value", "path");
List<String> httpMethods = extractHttpMethods(annotation, annName);
List<MethodParameterSnapshot> 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<MethodParameterSnapshot> extractParameters(MethodDeclaration method) {
List<MethodParameterSnapshot> 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<String> 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<String> paths = readStringArray(annotation, "path");
if (!paths.isEmpty()) {
return paths.get(0);
}
}
}
return "";
}
private List<String> extractHttpMethods(AnnotationExpr annotation, String annName) {
if (!"RequestMapping".equals(annName)) {
return List.of(MAPPING_DEFAULT_METHOD.getOrDefault(annName, "GET"));
}
List<String> 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<String> readStringArray(AnnotationExpr annotation, String... keys) {
NodeList<?> values = readArrayValues(annotation, keys);
List<String> result = new ArrayList<>();
for (Object value : values) {
String text = value.toString().replace("\"", "").trim();
if (!text.isBlank()) {
result.add(text);
}
}
if (result.isEmpty()) {
result.add("");
}
return result;
}
private List<String> readEnumArray(AnnotationExpr annotation, String key) {
NodeList<?> values = readArrayValues(annotation, key);
List<String> result = new ArrayList<>();
for (Object value : values) {
String text = value.toString().trim();
if (text.contains(".")) {
text = text.substring(text.lastIndexOf('.') + 1);
}
result.add(text.toUpperCase(Locale.ROOT));
}
return result;
}
private 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<>();
}
}

View File

@@ -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<String> searchDirs;
public JavaSourceLocator(Path repoRoot, List<String> searchDirs) {
this.repoRoot = repoRoot;
this.searchDirs = searchDirs;
}
public Optional<String> readSourceBySimpleName(String simpleClassName) throws IOException {
Optional<Path> path = findFile(simpleClassName);
if (path.isEmpty()) {
return Optional.empty();
}
return Optional.of(Files.readString(path.get()));
}
public Optional<Path> 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<Path> walk = Files.walk(root)) {
Optional<Path> found = walk
.filter(p -> p.getFileName().toString().equals(fileName))
.findFirst();
if (found.isPresent()) {
return found;
}
}
}
return Optional.empty();
}
}

View File

@@ -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) &gt; @Operation(description) &gt; 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<JavadocComment> 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 "";
}
}

View File

@@ -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<String> 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<String> searchDirs) {
this.sourceLocator = new JavaSourceLocator(repoRoot, searchDirs);
}
public List<NestedFieldInfo> parseNestedFields(String dtoClassName) throws IOException {
Set<String> visiting = new HashSet<>();
List<NestedFieldInfo> result = new ArrayList<>();
collectFields(dtoClassName, "", visiting, result);
return result;
}
private void collectFields(String className, String prefix, Set<String> visiting,
List<NestedFieldInfo> out) throws IOException {
if (className == null || className.isBlank() || visiting.contains(className)) {
return;
}
visiting.add(className);
Optional<String> source = sourceLocator.readSourceBySimpleName(className);
if (source.isEmpty()) {
visiting.remove(className);
return;
}
List<FieldInfo> 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("[]");
}
}

View File

@@ -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;
}
}

View File

@@ -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<String> scanChangedFiles(Path repoRoot, List<String> scanDirs,
String oldSha, String newSha) throws IOException {
Set<String> changed = new LinkedHashSet<>();
List<String> 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<String> 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();
}
}

View File

@@ -0,0 +1,62 @@
package com.aicheck.common;
/**
* 企微 Markdown v1 公共样式(类变更 / API 变更通知共用)。
*/
public final class MarkdownStyles {
private MarkdownStyles() {
}
public static String colorInfo(String text) {
return "<font color=\"info\">" + text + "</font>";
}
public static String colorComment(String text) {
return "<font color=\"comment\">" + safe(text) + "</font>";
}
public static String colorWarning(String text) {
return "<font color=\"warning\">" + text + "</font>";
}
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;");
}
}

View File

@@ -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 + "\"";
}
}

View File

@@ -24,6 +24,11 @@ public class AppConfig {
private boolean wecomEnabled = true; private boolean wecomEnabled = true;
private boolean onlyOnChange = true; private boolean onlyOnChange = true;
private boolean apiCheckEnabled = true;
private boolean apiExcludeFrameworkParams = true;
private List<String> apiControllerScanDirs = new ArrayList<>();
private List<String> apiFeignScanDirs = new ArrayList<>();
/** 从 YAML 文件加载配置 */ /** 从 YAML 文件加载配置 */
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
public static AppConfig load(Path configPath) throws IOException { public static AppConfig load(Path configPath) throws IOException {
@@ -56,6 +61,19 @@ public class AppConfig {
Map<String, Object> notify = mapOrEmpty(root.get("notify")); Map<String, Object> notify = mapOrEmpty(root.get("notify"));
config.onlyOnChange = boolOrDefault(notify.get("only_on_change"), true); config.onlyOnChange = boolOrDefault(notify.get("only_on_change"), true);
Map<String, Object> apiCheck = mapOrEmpty(root.get("api_check"));
config.apiCheckEnabled = boolOrDefault(apiCheck.get("enabled"), true);
config.apiExcludeFrameworkParams = boolOrDefault(apiCheck.get("exclude_framework_params"), true);
Map<String, Object> 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; return config;
} }
@@ -141,4 +159,32 @@ public class AppConfig {
public boolean isOnlyOnChange() { public boolean isOnlyOnChange() {
return onlyOnChange; return onlyOnChange;
} }
/** API 变更检测总开关 */
public boolean isApiCheckEnabled() {
return apiCheckEnabled;
}
/** 是否排除 Spring 框架注入参数 */
public boolean isApiExcludeFrameworkParams() {
return apiExcludeFrameworkParams;
}
/** API 检测 Controller 扫描目录 */
public List<String> getApiControllerScanDirs() {
return apiControllerScanDirs;
}
/** API 检测 Feign 扫描目录 */
public List<String> getApiFeignScanDirs() {
return apiFeignScanDirs;
}
/** API 检测所有扫描目录Controller + Feign */
public List<String> getAllApiScanDirs() {
List<String> dirs = new ArrayList<>();
dirs.addAll(apiControllerScanDirs);
dirs.addAll(apiFeignScanDirs);
return dirs;
}
} }

View File

@@ -198,6 +198,11 @@ public class GitChangeScanner {
return Files.readString(file, StandardCharsets.UTF_8); return Files.readString(file, StandardCharsets.UTF_8);
} }
/** 两次提交间变更文件路径列表(--name-only */
public List<String> diffNameOnly(String oldSha, String newSha) throws IOException {
return runGit("diff", "--name-only", oldSha, newSha);
}
/** 在 repoRoot 下执行 git 命令并返回 stdout 行 */ /** 在 repoRoot 下执行 git 命令并返回 stdout 行 */
private List<String> runGit(String... args) throws IOException { private List<String> runGit(String... args) throws IOException {
String[] command = new String[args.length + 3]; String[] command = new String[args.length + 3];

View File

@@ -45,3 +45,13 @@ wecom:
# true无变更时打印「无类变更静默退出」后正常结束不发送通知 # true无变更时打印「无类变更静默退出」后正常结束不发送通知
notify: notify:
only_on_change: true 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