This commit is contained in:
@@ -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}",
|
||||||
"",
|
"",
|
||||||
|
|||||||
160
.gitea/checker/api-templates/README.md
Normal file
160
.gitea/checker/api-templates/README.md
Normal 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 |
|
||||||
|
|
||||||
|
整体:**可行,约 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。
|
||||||
38
.gitea/checker/api-templates/api-method-change.md
Normal file
38
.gitea/checker/api-templates/api-method-change.md
Normal 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()`
|
||||||
121
.gitea/checker/api-templates/api-param-change.md
Normal file
121
.gitea/checker/api-templates/api-param-change.md
Normal 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` 中的字段行格式(可选统一)
|
||||||
88
.gitea/checker/api-templates/api-path-change.md
Normal file
88
.gitea/checker/api-templates/api-path-change.md
Normal 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()` — 渲染本模版
|
||||||
@@ -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)(含企微颜色样式、路径、字段说明规则)。
|
||||||
|
|
||||||
|
|||||||
@@ -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)");
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
GitChangeScanner gitScanner = new GitChangeScanner(repoRoot.toAbsolutePath());
|
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);
|
|
||||||
}
|
}
|
||||||
|
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;
|
return 0;
|
||||||
}
|
}
|
||||||
|
ApiChangeNotifier notifier = new ApiChangeNotifier();
|
||||||
|
return notifier.sendAll(appConfig.getWecomWebhookUrl(), reports, modifier, modifyTime,
|
||||||
|
appConfig.isWecomEnabled());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 "";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<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 "";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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("[]");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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("&", "&").replace("<", "<").replace(">", ">");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 + "\"";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user