Compare commits
112 Commits
main
...
class-chec
| Author | SHA1 | Date | |
|---|---|---|---|
| a51ab9098a | |||
| 8b60a84b56 | |||
| 3af30b355a | |||
| 814095e618 | |||
| 3b12b1a540 | |||
| 290f562188 | |||
| c035e19577 | |||
| 4c37a961a0 | |||
| 8aa506a807 | |||
| 89f1b0ceb7 | |||
| fa8802ada7 | |||
| 1643660c0c | |||
| 85f0acb4ac | |||
| bd7db35db8 | |||
| b97bdea716 | |||
| cd82403e80 | |||
| 850f000f86 | |||
| a38245bb9b | |||
| 75c5d5c70b | |||
| acf3f241cf | |||
| 33a580a040 | |||
| f94c24a0ab | |||
| c8840e2af0 | |||
| ac558bdc89 | |||
| 38e018b8aa | |||
| 36b571df5d | |||
| d1610bd43c | |||
| 0f02b883c1 | |||
| e27b8adf1f | |||
| da4611a24c | |||
| a9e7072688 | |||
| bf191f6baf | |||
| b15ffc223b | |||
| 934b7d068f | |||
| 01d6952ad9 | |||
| ee9583c870 | |||
| e765cd64e5 | |||
| 780ba5fbf8 | |||
| 8f19bb817e | |||
| e2b1cb7b72 | |||
| 17d3cab02d | |||
| 3c086d8709 | |||
| 2de66b89f4 | |||
| ceb1c81f94 | |||
| 871823b3da | |||
| fb6cd124c8 | |||
| 46988e63fa | |||
| 95e031e63d | |||
| c39355815e | |||
| 5ff1f6aa73 | |||
| 1c6c153ea6 | |||
| 94e34b6f2e | |||
| dfb24790d0 | |||
| ba5de889de | |||
| ea01756156 | |||
| fc657dd8c0 | |||
| fcd1f64ddd | |||
| 4937cc6738 | |||
| 2aa7b0c4ef | |||
| 1202dd95d6 | |||
| 7e94f75ebd | |||
| 25316a6f61 | |||
| 7e03ef872a | |||
| 4be719190f | |||
| a94c89ba90 | |||
| fbd6e6d5ee | |||
| ac1873ee96 | |||
| 67fd025f02 | |||
| 1ee3ef9088 | |||
| 9fa59cd2a9 | |||
| eedc1767b3 | |||
| be19389f4c | |||
| 2b88112963 | |||
| 466a884651 | |||
| 8c92fecf2c | |||
| 75d56c24ef | |||
| 22728b8408 | |||
| 17a320f0df | |||
| afe9cfc70f | |||
| 6a3a3a72d7 | |||
| 2c6ebcb737 | |||
| b9242a9f2b | |||
| 90afda6c3c | |||
| e367d4257f | |||
| ba1cc6aa4e | |||
| e6c90eccff | |||
| 717278de4a | |||
| 8fa8199012 | |||
| e9ed5299b2 | |||
| 5533ca5503 | |||
| bbd781fc70 | |||
| fb575311d5 | |||
| 6d8cc0eddc | |||
| 17aa214952 | |||
| 2e77bd62ac | |||
| e86991c66e | |||
| 01b3e75968 | |||
| bd78fa5872 | |||
| 3ae49737e2 | |||
| 9141fdbb19 | |||
| 5821b85578 | |||
| 7c807bd9e9 | |||
| a5749339a1 | |||
| 5105a460e0 | |||
| 9010792b6e | |||
| 84ae68be05 | |||
| 2f8798c38c | |||
| 9e1d66c81f | |||
| c5bc52911b | |||
| 13e067f2e2 | |||
| ec3bd1d0b2 | |||
| c3c73b6fb3 |
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
*.sh text eol=lf
|
||||
scripts/* text eol=lf
|
||||
3
.gitea/.gitignore
vendored
3
.gitea/.gitignore
vendored
@@ -1,3 +0,0 @@
|
||||
# 本地/CI 运行时产生的缓存与日志(可不提交)
|
||||
.cache/
|
||||
logs/
|
||||
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源文件 + 方法名(不含参数信息;增删参/改类型等由 ParameterDiffEngine 检测)
|
||||
```
|
||||
|
||||
仅 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`**
|
||||
|
||||
├─ `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. 用**方法指纹**(类文件 + 方法名,如 `ApplyClockInController.java#getApply`)匹配新旧接口;参数增删改由 ParameterDiffEngine 检测
|
||||
4. 指纹相同且 URI 不同 → **修改路径**
|
||||
5. 仅旧有新无 → **删除**;仅新有旧无 → **新增**
|
||||
|
||||
## 实现
|
||||
|
||||
- `EndpointSnapshotParser` — 解析快照
|
||||
- `EndpointDiffEngine` — 对比产出 `EndpointChangeReport.is_renamed_endpoint` 等标志
|
||||
- `ApiChangeNotifier.buildPathChangeMarkdown()` — 渲染本模版
|
||||
@@ -1,165 +0,0 @@
|
||||
"""
|
||||
变更日志持久化模块。
|
||||
受 log.enabled 开关控制,默认关闭;仅记录接口参数变更及 LLM 审核结果。
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from comparator import EndpointChangeReport
|
||||
from git_utils import CommitInfo
|
||||
|
||||
|
||||
def is_log_enabled(config: Dict[str, Any]) -> bool:
|
||||
"""判断日志总开关是否开启。"""
|
||||
return config.get("log", {}).get("enabled", False)
|
||||
|
||||
|
||||
def _serialize_reports(reports: List[EndpointChangeReport]) -> List[dict]:
|
||||
"""将参数变更报告序列化为 JSON 结构。"""
|
||||
result = []
|
||||
for r in reports:
|
||||
result.append(
|
||||
{
|
||||
"uri": r.uri,
|
||||
"http_method": r.http_method,
|
||||
"controller_class": r.controller_class,
|
||||
"method_name": r.method_name,
|
||||
"is_new_endpoint": r.is_new_endpoint,
|
||||
"is_removed_endpoint": r.is_removed_endpoint,
|
||||
"parameter_changes": [
|
||||
{
|
||||
"change_type": c.change_type.value,
|
||||
"param_name": c.param_name,
|
||||
"param_type": c.param_type,
|
||||
"old_name": c.old_name,
|
||||
"old_type": c.old_type,
|
||||
"required": c.required,
|
||||
"detail": c.detail,
|
||||
}
|
||||
for c in r.parameter_changes
|
||||
],
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def save_to_file(
|
||||
reports: List[EndpointChangeReport],
|
||||
commit_info: CommitInfo,
|
||||
log_dir: str,
|
||||
llm_review: Optional[str] = None,
|
||||
) -> Path:
|
||||
"""写入 JSON 日志文件。"""
|
||||
log_path = Path(log_dir)
|
||||
log_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
short_sha = commit_info.sha[:8]
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
output = log_path / f"{timestamp}_{short_sha}.json"
|
||||
|
||||
record = {
|
||||
"commit_sha": commit_info.sha,
|
||||
"author": commit_info.author,
|
||||
"commit_time": commit_info.commit_time,
|
||||
"message": commit_info.message,
|
||||
"detected_at": datetime.now().isoformat(),
|
||||
"change_count": len(reports),
|
||||
"parameter_changes": _serialize_reports(reports),
|
||||
"llm_review": llm_review,
|
||||
}
|
||||
|
||||
with open(output, "w", encoding="utf-8") as f:
|
||||
json.dump(record, f, ensure_ascii=False, indent=2)
|
||||
|
||||
print(f"[日志] 参数变更记录已写入: {output}")
|
||||
return output
|
||||
|
||||
|
||||
def save_to_mysql(
|
||||
reports: List[EndpointChangeReport],
|
||||
commit_info: CommitInfo,
|
||||
mysql_config: Dict[str, Any],
|
||||
llm_review: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""写入 MySQL。"""
|
||||
try:
|
||||
import pymysql
|
||||
except ImportError:
|
||||
print("[错误] MySQL 模式需要: pip install pymysql")
|
||||
return False
|
||||
|
||||
host = mysql_config.get("host", "")
|
||||
if not host or host == "YOUR_MYSQL_HOST":
|
||||
print("[警告] 未配置 MySQL,跳过写入。")
|
||||
return False
|
||||
|
||||
try:
|
||||
conn = pymysql.connect(
|
||||
host=host,
|
||||
port=int(mysql_config.get("port", 3306)),
|
||||
user=mysql_config.get("user"),
|
||||
password=mysql_config.get("password"),
|
||||
database=mysql_config.get("database"),
|
||||
charset="utf8mb4",
|
||||
)
|
||||
table = mysql_config.get("table", "api_change_logs")
|
||||
payload = json.dumps(_serialize_reports(reports), ensure_ascii=False)
|
||||
|
||||
with conn.cursor() as cursor:
|
||||
sql = f"""
|
||||
INSERT INTO `{table}`
|
||||
(commit_sha, author, commit_time, commit_message, change_count, reports_json, llm_review, created_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, NOW())
|
||||
"""
|
||||
cursor.execute(
|
||||
sql,
|
||||
(
|
||||
commit_info.sha,
|
||||
commit_info.author,
|
||||
commit_info.commit_time,
|
||||
commit_info.message,
|
||||
len(reports),
|
||||
payload,
|
||||
llm_review,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print(f"[日志] 已写入 MySQL: {table}")
|
||||
return True
|
||||
except Exception as exc:
|
||||
print(f"[错误] MySQL 写入失败: {exc}")
|
||||
return False
|
||||
|
||||
|
||||
def persist_change_log(
|
||||
reports: List[EndpointChangeReport],
|
||||
commit_info: CommitInfo,
|
||||
config: Dict[str, Any],
|
||||
llm_review: Optional[str] = None,
|
||||
) -> None:
|
||||
"""
|
||||
根据 log.enabled 决定是否持久化接口参数变更日志。
|
||||
|
||||
:param reports: 参数变更报告
|
||||
:param commit_info: 提交信息
|
||||
:param config: 完整配置
|
||||
:param llm_review: LLM 参数变更审核结论
|
||||
"""
|
||||
if not is_log_enabled(config):
|
||||
print("[日志] 日志开关已关闭(log.enabled=false),跳过写入。")
|
||||
return
|
||||
|
||||
log_cfg = config.get("log", {})
|
||||
if log_cfg.get("storage") == "mysql":
|
||||
save_to_mysql(reports, commit_info, log_cfg.get("mysql", {}), llm_review)
|
||||
else:
|
||||
save_to_file(
|
||||
reports,
|
||||
commit_info,
|
||||
log_cfg.get("file_dir", ".gitea/logs/api-changes"),
|
||||
llm_review,
|
||||
)
|
||||
@@ -1,449 +0,0 @@
|
||||
"""
|
||||
API 参数变更对比模块。
|
||||
对比新旧两个版本的 Controller 端点,识别参数的增、删、改、重命名。
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Dict, List, Optional, Set, Tuple
|
||||
|
||||
from models import ApiEndpoint, ApiParameter
|
||||
|
||||
|
||||
class ChangeType(str, Enum):
|
||||
"""参数变更类型。"""
|
||||
|
||||
ADDED = "added"
|
||||
REMOVED = "removed"
|
||||
MODIFIED = "modified"
|
||||
RENAMED = "renamed"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParameterChange:
|
||||
"""单条参数变更记录。"""
|
||||
|
||||
change_type: ChangeType
|
||||
param_name: str
|
||||
param_type: Optional[str] = None
|
||||
old_name: Optional[str] = None
|
||||
old_type: Optional[str] = None
|
||||
required: Optional[bool] = None
|
||||
old_required: Optional[bool] = None
|
||||
detail: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
old_description: Optional[str] = None
|
||||
source: str = "query"
|
||||
parent_dto: Optional[str] = None
|
||||
body_param_name: Optional[str] = None
|
||||
|
||||
def _change_tag(self) -> str:
|
||||
"""变更类型标签(企微颜色)。"""
|
||||
tags = {
|
||||
ChangeType.ADDED: '<font color="info">**新增**</font>',
|
||||
ChangeType.REMOVED: '<font color="warning">**删除**</font>',
|
||||
ChangeType.RENAMED: '<font color="comment">**重命名**</font>',
|
||||
ChangeType.MODIFIED: '<font color="warning">**修改**</font>',
|
||||
}
|
||||
return tags.get(self.change_type, "")
|
||||
|
||||
def _required_tag(self) -> str:
|
||||
"""必填/可选标签。"""
|
||||
if self.required is True:
|
||||
return '<font color="warning">必填</font>'
|
||||
if self.required is False:
|
||||
return '<font color="comment">可选</font>'
|
||||
return ""
|
||||
|
||||
def to_markdown_block(self, index: int = 1) -> str:
|
||||
"""格式化为企微友好的参数变更卡片(列表式,非表格)。"""
|
||||
lines: List[str] = []
|
||||
desc = self.description or self.old_description
|
||||
|
||||
if self.change_type == ChangeType.RENAMED:
|
||||
lines.append(f"**{index}. `{self.param_name}`** {self._change_tag()}")
|
||||
lines.append(f"> `{self.old_name}` → `{self.param_name}`")
|
||||
if desc:
|
||||
lines.append(f"> 说明:{desc}")
|
||||
return "\n".join(lines)
|
||||
|
||||
if self.change_type == ChangeType.ADDED:
|
||||
type_part = f" · `{self.param_type}`" if self.param_type else ""
|
||||
req_part = f" · {self._required_tag()}" if self._required_tag() else ""
|
||||
lines.append(
|
||||
f"**{index}. `{self.param_name}`**{type_part}{req_part} {self._change_tag()}"
|
||||
)
|
||||
if desc:
|
||||
lines.append(f"> 说明:{desc}")
|
||||
return "\n".join(lines)
|
||||
|
||||
if self.change_type == ChangeType.REMOVED:
|
||||
type_part = f" · `{self.param_type}`" if self.param_type else ""
|
||||
lines.append(
|
||||
f"**{index}. `{self.param_name}`**{type_part} {self._change_tag()}"
|
||||
)
|
||||
if desc:
|
||||
lines.append(f"> 说明:{desc}")
|
||||
return "\n".join(lines)
|
||||
|
||||
# MODIFIED
|
||||
lines.append(f"**{index}. `{self.param_name}`** {self._change_tag()}")
|
||||
if desc:
|
||||
lines.append(f"> 说明:{desc}")
|
||||
if self.detail:
|
||||
lines.append(f"> 变更:{self.detail}")
|
||||
return "\n".join(lines)
|
||||
|
||||
def to_table_row(self) -> str:
|
||||
"""兼容旧调用,委托至卡片块。"""
|
||||
return self.to_markdown_block(1)
|
||||
|
||||
|
||||
@dataclass
|
||||
class EndpointChangeReport:
|
||||
"""单个接口的变更报告。"""
|
||||
|
||||
uri: str
|
||||
http_method: str
|
||||
controller_class: str
|
||||
method_name: str
|
||||
source_file: str = ""
|
||||
parameter_changes: List[ParameterChange] = field(default_factory=list)
|
||||
is_new_endpoint: bool = False
|
||||
is_removed_endpoint: bool = False
|
||||
is_renamed_endpoint: bool = False
|
||||
old_uri: Optional[str] = None
|
||||
is_method_changed: bool = False
|
||||
old_http_method: Optional[str] = None
|
||||
|
||||
@property
|
||||
def has_changes(self) -> bool:
|
||||
"""是否存在任何变更。"""
|
||||
return (
|
||||
self.is_new_endpoint
|
||||
or self.is_removed_endpoint
|
||||
or self.is_renamed_endpoint
|
||||
or self.is_method_changed
|
||||
or len(self.parameter_changes) > 0
|
||||
)
|
||||
|
||||
@property
|
||||
def endpoint_key(self) -> str:
|
||||
return f"{self.http_method} {self.uri}"
|
||||
|
||||
|
||||
def _param_key(p: ApiParameter) -> Tuple[str, str]:
|
||||
"""参数匹配键:(source, name)。"""
|
||||
return (p.source, p.name)
|
||||
|
||||
|
||||
def _format_type_change(old_type: str, new_type: str) -> str:
|
||||
"""类型变更文案。"""
|
||||
return f"类型由{old_type}改为{new_type}"
|
||||
|
||||
|
||||
def compare_parameters(
|
||||
old_params: List[ApiParameter], new_params: List[ApiParameter]
|
||||
) -> List[ParameterChange]:
|
||||
"""
|
||||
对比同一接口新旧版本的参数列表,识别增删改及重命名。
|
||||
|
||||
重命名启发式:若删除与新增参数类型相同且 source 相同,则视为重命名。
|
||||
|
||||
:param old_params: 旧版本参数
|
||||
:param new_params: 新版本参数
|
||||
:return: 变更列表
|
||||
"""
|
||||
changes: List[ParameterChange] = []
|
||||
|
||||
old_map: Dict[Tuple[str, str], ApiParameter] = {_param_key(p): p for p in old_params}
|
||||
new_map: Dict[Tuple[str, str], ApiParameter] = {_param_key(p): p for p in new_params}
|
||||
|
||||
old_keys = set(old_map.keys())
|
||||
new_keys = set(new_map.keys())
|
||||
|
||||
removed_keys = old_keys - new_keys
|
||||
added_keys = new_keys - old_keys
|
||||
common_keys = old_keys & new_keys
|
||||
|
||||
# 1. 共同参数:检查类型、必填等属性变更
|
||||
for key in common_keys:
|
||||
old_p = old_map[key]
|
||||
new_p = new_map[key]
|
||||
detail_parts = []
|
||||
if old_p.type != new_p.type:
|
||||
detail_parts.append(_format_type_change(old_p.type, new_p.type))
|
||||
if old_p.required != new_p.required:
|
||||
req_label = lambda r: "必填" if r else "可选"
|
||||
detail_parts.append(
|
||||
f"必填性由{req_label(old_p.required)}改为{req_label(new_p.required)}"
|
||||
)
|
||||
if old_p.description != new_p.description:
|
||||
detail_parts.append(
|
||||
f"说明由{old_p.description or '-'}改为{new_p.description or '-'}"
|
||||
)
|
||||
if detail_parts:
|
||||
changes.append(
|
||||
ParameterChange(
|
||||
change_type=ChangeType.MODIFIED,
|
||||
param_name=new_p.name,
|
||||
param_type=new_p.type,
|
||||
required=new_p.required,
|
||||
old_required=old_p.required,
|
||||
detail=", ".join(detail_parts),
|
||||
description=new_p.description,
|
||||
old_description=old_p.description,
|
||||
source=new_p.source,
|
||||
parent_dto=new_p.parent_dto,
|
||||
body_param_name=new_p.body_param_name,
|
||||
)
|
||||
)
|
||||
|
||||
# 2. 重命名检测:在 removed + added 中找同 type + source 的配对
|
||||
unmatched_removed: List[Tuple[Tuple[str, str], ApiParameter]] = []
|
||||
unmatched_added: List[Tuple[Tuple[str, str], ApiParameter]] = []
|
||||
|
||||
for key in removed_keys:
|
||||
unmatched_removed.append((key, old_map[key]))
|
||||
for key in added_keys:
|
||||
unmatched_added.append((key, new_map[key]))
|
||||
|
||||
matched_removed: Set[Tuple[str, str]] = set()
|
||||
matched_added: Set[Tuple[str, str]] = set()
|
||||
|
||||
for r_key, r_param in unmatched_removed:
|
||||
for a_key, a_param in unmatched_added:
|
||||
if a_key in matched_added:
|
||||
continue
|
||||
if r_param.type == a_param.type and r_param.source == a_param.source:
|
||||
changes.append(
|
||||
ParameterChange(
|
||||
change_type=ChangeType.RENAMED,
|
||||
param_name=a_param.name,
|
||||
param_type=a_param.type,
|
||||
old_name=r_param.name,
|
||||
old_type=r_param.type,
|
||||
required=a_param.required,
|
||||
description=a_param.description,
|
||||
old_description=r_param.description,
|
||||
source=a_param.source,
|
||||
parent_dto=a_param.parent_dto,
|
||||
body_param_name=a_param.body_param_name,
|
||||
)
|
||||
)
|
||||
matched_removed.add(r_key)
|
||||
matched_added.add(a_key)
|
||||
break
|
||||
|
||||
# 3. 纯删除
|
||||
for key, param in unmatched_removed:
|
||||
if key not in matched_removed:
|
||||
changes.append(
|
||||
ParameterChange(
|
||||
change_type=ChangeType.REMOVED,
|
||||
param_name=param.name,
|
||||
param_type=param.type,
|
||||
description=param.description,
|
||||
source=param.source,
|
||||
parent_dto=param.parent_dto,
|
||||
body_param_name=param.body_param_name,
|
||||
)
|
||||
)
|
||||
|
||||
# 4. 纯新增
|
||||
for key, param in unmatched_added:
|
||||
if key not in matched_added:
|
||||
changes.append(
|
||||
ParameterChange(
|
||||
change_type=ChangeType.ADDED,
|
||||
param_name=param.name,
|
||||
param_type=param.type,
|
||||
required=param.required,
|
||||
description=param.description,
|
||||
source=param.source,
|
||||
parent_dto=param.parent_dto,
|
||||
body_param_name=param.body_param_name,
|
||||
)
|
||||
)
|
||||
|
||||
return changes
|
||||
|
||||
|
||||
def compare_endpoints(
|
||||
old_endpoints: Dict[str, ApiEndpoint],
|
||||
new_endpoints: Dict[str, ApiEndpoint],
|
||||
) -> List[EndpointChangeReport]:
|
||||
"""
|
||||
对比新旧两个版本的全部 Controller 端点,生成变更报告列表。
|
||||
|
||||
支持以下变更类型检测:
|
||||
- HTTP 方法变更(GET → POST 等)
|
||||
- URI 路径变更(路径重命名)
|
||||
- 新增 / 删除接口
|
||||
- 参数变更
|
||||
"""
|
||||
reports: List[EndpointChangeReport] = []
|
||||
|
||||
old_keys = set(old_endpoints.keys())
|
||||
new_keys = set(new_endpoints.keys())
|
||||
|
||||
removed_keys = old_keys - new_keys
|
||||
added_keys = new_keys - old_keys
|
||||
common_keys = old_keys & new_keys
|
||||
|
||||
# 收集未匹配的 removed / added
|
||||
unmatched_removed: List[Tuple[str, ApiEndpoint]] = []
|
||||
unmatched_added: List[Tuple[str, ApiEndpoint]] = []
|
||||
|
||||
for key in removed_keys:
|
||||
unmatched_removed.append((key, old_endpoints[key]))
|
||||
for key in added_keys:
|
||||
unmatched_added.append((key, new_endpoints[key]))
|
||||
|
||||
matched_removed: Set[str] = set()
|
||||
matched_added: Set[str] = set()
|
||||
|
||||
# 1. HTTP 方法变更检测(uri + controller 相同,但 method 不同)
|
||||
# 放宽匹配条件:只要同一个 Controller 的同一个 URI 请求方式改变,就识别为「修改请求方式」
|
||||
# 不再要求 method_name 相同(允许方法重命名场景)
|
||||
# 如果同时有参数变更,生成两条独立报告(方法变更 + 参数变更),互不干扰
|
||||
for r_key, r_ep in unmatched_removed:
|
||||
for a_key, a_ep in unmatched_added:
|
||||
if a_key in matched_added:
|
||||
continue
|
||||
if (
|
||||
r_ep.uri == a_ep.uri
|
||||
and r_ep.controller_class == a_ep.controller_class
|
||||
and r_ep.http_method != a_ep.http_method
|
||||
):
|
||||
# 先生成纯方法变更报告
|
||||
reports.append(
|
||||
EndpointChangeReport(
|
||||
uri=a_ep.uri,
|
||||
http_method=a_ep.http_method,
|
||||
controller_class=a_ep.controller_class,
|
||||
method_name=a_ep.method_name,
|
||||
source_file=a_ep.source_file,
|
||||
is_method_changed=True,
|
||||
old_http_method=r_ep.http_method,
|
||||
)
|
||||
)
|
||||
# 再检测参数变更,如果有则额外生成参数报告
|
||||
param_changes = compare_parameters(r_ep.parameters, a_ep.parameters)
|
||||
if param_changes:
|
||||
reports.append(
|
||||
EndpointChangeReport(
|
||||
uri=a_ep.uri,
|
||||
http_method=a_ep.http_method,
|
||||
controller_class=a_ep.controller_class,
|
||||
method_name=a_ep.method_name,
|
||||
source_file=a_ep.source_file,
|
||||
parameter_changes=param_changes,
|
||||
)
|
||||
)
|
||||
matched_removed.add(r_key)
|
||||
matched_added.add(a_key)
|
||||
break
|
||||
|
||||
# 2. URI 路径变更检测(method + controller + method_name 相同,但 uri 不同)
|
||||
# 如果同时有参数变更,生成两条独立报告(路径变更 + 参数变更),互不干扰
|
||||
for r_key, r_ep in unmatched_removed:
|
||||
if r_key in matched_removed:
|
||||
continue
|
||||
for a_key, a_ep in unmatched_added:
|
||||
if a_key in matched_added:
|
||||
continue
|
||||
if (
|
||||
r_ep.http_method == a_ep.http_method
|
||||
and r_ep.controller_class == a_ep.controller_class
|
||||
and r_ep.method_name == a_ep.method_name
|
||||
and r_ep.uri != a_ep.uri
|
||||
):
|
||||
# 先生成纯路径变更报告
|
||||
reports.append(
|
||||
EndpointChangeReport(
|
||||
uri=a_ep.uri,
|
||||
http_method=a_ep.http_method,
|
||||
controller_class=a_ep.controller_class,
|
||||
method_name=a_ep.method_name,
|
||||
source_file=a_ep.source_file,
|
||||
is_renamed_endpoint=True,
|
||||
old_uri=r_ep.uri,
|
||||
)
|
||||
)
|
||||
# 再检测参数变更,如果有则额外生成参数报告
|
||||
param_changes = compare_parameters(r_ep.parameters, a_ep.parameters)
|
||||
if param_changes:
|
||||
reports.append(
|
||||
EndpointChangeReport(
|
||||
uri=a_ep.uri,
|
||||
http_method=a_ep.http_method,
|
||||
controller_class=a_ep.controller_class,
|
||||
method_name=a_ep.method_name,
|
||||
source_file=a_ep.source_file,
|
||||
parameter_changes=param_changes,
|
||||
)
|
||||
)
|
||||
matched_removed.add(r_key)
|
||||
matched_added.add(a_key)
|
||||
break
|
||||
|
||||
# 3. 剩余未匹配的 removed → 删除接口
|
||||
for key, ep in unmatched_removed:
|
||||
if key not in matched_removed:
|
||||
reports.append(
|
||||
EndpointChangeReport(
|
||||
uri=ep.uri,
|
||||
http_method=ep.http_method,
|
||||
controller_class=ep.controller_class,
|
||||
method_name=ep.method_name,
|
||||
source_file=ep.source_file,
|
||||
is_removed_endpoint=True,
|
||||
)
|
||||
)
|
||||
|
||||
# 4. 剩余未匹配的 added → 新增接口
|
||||
for key, ep in unmatched_added:
|
||||
if key not in matched_added:
|
||||
reports.append(
|
||||
EndpointChangeReport(
|
||||
uri=ep.uri,
|
||||
http_method=ep.http_method,
|
||||
controller_class=ep.controller_class,
|
||||
method_name=ep.method_name,
|
||||
source_file=ep.source_file,
|
||||
is_new_endpoint=True,
|
||||
parameter_changes=[
|
||||
ParameterChange(
|
||||
change_type=ChangeType.ADDED,
|
||||
param_name=p.name,
|
||||
param_type=p.type,
|
||||
required=p.required,
|
||||
description=p.description,
|
||||
source=p.source,
|
||||
parent_dto=p.parent_dto,
|
||||
body_param_name=p.body_param_name,
|
||||
)
|
||||
for p in ep.parameters
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
# 5. 共同 URI:对比参数变更
|
||||
for key in common_keys:
|
||||
old_ep = old_endpoints[key]
|
||||
new_ep = new_endpoints[key]
|
||||
param_changes = compare_parameters(old_ep.parameters, new_ep.parameters)
|
||||
if param_changes:
|
||||
reports.append(
|
||||
EndpointChangeReport(
|
||||
uri=new_ep.uri,
|
||||
http_method=new_ep.http_method,
|
||||
controller_class=new_ep.controller_class,
|
||||
method_name=new_ep.method_name,
|
||||
source_file=new_ep.source_file,
|
||||
parameter_changes=param_changes,
|
||||
)
|
||||
)
|
||||
|
||||
return [r for r in reports if r.has_changes]
|
||||
@@ -1,558 +0,0 @@
|
||||
"""
|
||||
纯 Python Controller AST 解析器(基于 javalang,无需 Java/Maven)。
|
||||
仅解析 Spring @RestController / @Controller 的接口映射与参数。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import javalang
|
||||
from javalang.tree import (
|
||||
Annotation,
|
||||
ClassDeclaration,
|
||||
ElementValuePair,
|
||||
FieldDeclaration,
|
||||
FormalParameter,
|
||||
Literal,
|
||||
MemberReference,
|
||||
MethodDeclaration,
|
||||
)
|
||||
|
||||
from models import ApiEndpoint, ApiParameter
|
||||
|
||||
# javax.validation 必填注解
|
||||
REQUIRED_FIELD_ANNS = {"NotNull", "NotEmpty", "NotBlank"}
|
||||
MAPPING_ANNS = {"GetMapping", "PostMapping", "PutMapping", "DeleteMapping", "PatchMapping", "RequestMapping"}
|
||||
CONTROLLER_ANNS = {"RestController", "Controller"}
|
||||
|
||||
# Spring MVC 框架自动注入参数,不属于 API 调用方入参,解析时忽略
|
||||
FRAMEWORK_PARAM_TYPES = {
|
||||
"HttpServletRequest",
|
||||
"HttpServletResponse",
|
||||
"HttpSession",
|
||||
"ServletRequest",
|
||||
"ServletResponse",
|
||||
"WebRequest",
|
||||
"NativeWebRequest",
|
||||
"Model",
|
||||
"ModelMap",
|
||||
"RedirectAttributes",
|
||||
"BindingResult",
|
||||
"Errors",
|
||||
"Authentication",
|
||||
"Principal",
|
||||
"Locale",
|
||||
"TimeZone",
|
||||
"InputStream",
|
||||
"OutputStream",
|
||||
"Reader",
|
||||
"Writer",
|
||||
"HttpHeaders",
|
||||
"UriComponentsBuilder",
|
||||
}
|
||||
|
||||
|
||||
def _is_framework_param(type_name: str, param_name: str) -> bool:
|
||||
"""判断是否为框架注入参数(非 API 调用方需要传递)。"""
|
||||
simple = type_name.split(".")[-1].replace(">", "").replace("<", "").strip()
|
||||
if simple in FRAMEWORK_PARAM_TYPES:
|
||||
return True
|
||||
if param_name in ("request", "response") and (
|
||||
simple.endswith("Request") or simple.endswith("Response")
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _ann_simple_name(ann: Annotation) -> str:
|
||||
"""获取注解简单类名。"""
|
||||
return ann.name.split(".")[-1]
|
||||
|
||||
|
||||
def _literal_str(node) -> str:
|
||||
"""从 AST 节点提取字符串或布尔值。"""
|
||||
if node is None:
|
||||
return ""
|
||||
if isinstance(node, Literal):
|
||||
v = node.value
|
||||
if isinstance(v, bool):
|
||||
return str(v).lower()
|
||||
return str(v or "").strip('"').strip("'")
|
||||
if isinstance(node, MemberReference):
|
||||
return node.member
|
||||
if isinstance(node, bool):
|
||||
return str(node).lower()
|
||||
return str(node).strip('"').strip("'")
|
||||
|
||||
|
||||
def _collect_ann_members(ann: Annotation) -> Dict[str, str]:
|
||||
"""
|
||||
解析注解成员为字典。
|
||||
支持 @GetMapping("/path") 与 @RequestParam(value="x", required=false)。
|
||||
"""
|
||||
members: Dict[str, str] = {}
|
||||
el = ann.element
|
||||
if el is None:
|
||||
return members
|
||||
if isinstance(el, ElementValuePair):
|
||||
members[el.name] = _literal_str(el.value)
|
||||
elif isinstance(el, list):
|
||||
for item in el:
|
||||
if isinstance(item, ElementValuePair):
|
||||
members[item.name] = _literal_str(item.value)
|
||||
else:
|
||||
members["value"] = _literal_str(el)
|
||||
return members
|
||||
|
||||
|
||||
def _ann_string(ann: Annotation, *keys: str) -> str:
|
||||
"""从注解提取字符串属性。"""
|
||||
members = _collect_ann_members(ann)
|
||||
for k in keys:
|
||||
if k in members and members[k]:
|
||||
return members[k]
|
||||
if "value" in members:
|
||||
return members["value"]
|
||||
return ""
|
||||
|
||||
|
||||
def _type_to_str(type_node) -> str:
|
||||
"""Java 类型节点转字符串。"""
|
||||
if type_node is None:
|
||||
return "Object"
|
||||
if isinstance(type_node, javalang.tree.BasicType):
|
||||
return type_node.name
|
||||
if isinstance(type_node, javalang.tree.ReferenceType):
|
||||
name = type_node.name or "Object"
|
||||
if type_node.arguments:
|
||||
args = ",".join(_type_to_str(a.type) for a in type_node.arguments)
|
||||
return f"{name}<{args}>"
|
||||
if type_node.sub_type:
|
||||
return _type_to_str(type_node.sub_type)
|
||||
return name
|
||||
return str(type_node)
|
||||
|
||||
|
||||
def _normalize_path(path: str) -> str:
|
||||
"""规范化 URI 路径。"""
|
||||
if not path or not path.strip():
|
||||
return ""
|
||||
path = path.strip()
|
||||
if not path.startswith("/"):
|
||||
path = "/" + path
|
||||
return re.sub(r"/+", "/", path)
|
||||
|
||||
|
||||
def _join_paths(base: str, sub: str) -> str:
|
||||
"""拼接类级与方法级路径。"""
|
||||
b, m = _normalize_path(base), _normalize_path(sub)
|
||||
if not b:
|
||||
return m or "/"
|
||||
if not m:
|
||||
return b
|
||||
if b.endswith("/") and m.startswith("/"):
|
||||
return b + m[1:]
|
||||
if not b.endswith("/") and not m.startswith("/"):
|
||||
return b + "/" + m
|
||||
return b + m
|
||||
|
||||
|
||||
def _http_method(ann_name: str, ann: Annotation) -> str:
|
||||
"""从映射注解推断 HTTP 方法。
|
||||
|
||||
支持大小写不敏感匹配,避免 PUTMapping、POSTMapping 等不规范写法导致解析失败。
|
||||
"""
|
||||
mapping = {
|
||||
"GetMapping": "GET",
|
||||
"PostMapping": "POST",
|
||||
"PutMapping": "PUT",
|
||||
"DeleteMapping": "DELETE",
|
||||
"PatchMapping": "PATCH",
|
||||
}
|
||||
# 大小写不敏感匹配
|
||||
for key, value in mapping.items():
|
||||
if key.lower() == ann_name.lower():
|
||||
return value
|
||||
if ann_name.lower() == "requestmapping":
|
||||
m = _ann_string(ann, "method")
|
||||
if m:
|
||||
return m.replace("RequestMethod.", "").upper()
|
||||
return "GET"
|
||||
return "GET"
|
||||
|
||||
|
||||
def _has_ann(node, name: str) -> bool:
|
||||
"""节点是否含指定注解。"""
|
||||
anns = getattr(node, "annotations", None) or []
|
||||
return any(_ann_simple_name(a) == name for a in anns)
|
||||
|
||||
|
||||
def _find_ann(node, name: str) -> Optional[Annotation]:
|
||||
"""查找指定注解。"""
|
||||
for a in getattr(node, "annotations", None) or []:
|
||||
if _ann_simple_name(a) == name:
|
||||
return a
|
||||
return None
|
||||
|
||||
|
||||
def _param_source(param: FormalParameter) -> str:
|
||||
"""参数来源:path / query / header / form / body。"""
|
||||
if _has_ann(param, "PathVariable"):
|
||||
return "path"
|
||||
if _has_ann(param, "RequestHeader"):
|
||||
return "header"
|
||||
if _has_ann(param, "RequestPart") or _has_ann(param, "ModelAttribute"):
|
||||
return "form"
|
||||
if _has_ann(param, "RequestBody"):
|
||||
return "body"
|
||||
return "query"
|
||||
|
||||
|
||||
def _param_name(param: FormalParameter) -> str:
|
||||
"""解析参数名。"""
|
||||
for ann_name in ("RequestParam", "PathVariable", "RequestHeader", "RequestPart"):
|
||||
ann = _find_ann(param, ann_name)
|
||||
if ann:
|
||||
val = _ann_string(ann, "value", "name")
|
||||
if val:
|
||||
return val
|
||||
return param.name
|
||||
|
||||
|
||||
def _param_required(param: FormalParameter) -> bool:
|
||||
"""是否必填。"""
|
||||
ann = _find_ann(param, "RequestParam")
|
||||
if ann:
|
||||
members = _collect_ann_members(ann)
|
||||
if members.get("required", "").lower() == "false":
|
||||
return False
|
||||
type_name = _type_to_str(param.type)
|
||||
if type_name.startswith("Optional"):
|
||||
return False
|
||||
return not _has_ann(param, "Nullable")
|
||||
|
||||
|
||||
JAVADOC_PARAM_RE = re.compile(
|
||||
r"@param\s+(\w+)\s+(.*?)(?=\n\s*\*\s*@|\n\s*\*/|\Z)",
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
|
||||
def _clean_javadoc_text(text: str) -> str:
|
||||
"""清理 JavaDoc 行前缀与多余空白。"""
|
||||
cleaned = re.sub(r"\s*\*\s?", " ", text)
|
||||
return re.sub(r"\s+", " ", cleaned).strip()
|
||||
|
||||
|
||||
def _parse_javadoc_params(javadoc: str) -> Dict[str, str]:
|
||||
"""从 JavaDoc 块解析 @param 名称 -> 说明。"""
|
||||
if not javadoc:
|
||||
return {}
|
||||
result: Dict[str, str] = {}
|
||||
for match in JAVADOC_PARAM_RE.finditer(javadoc):
|
||||
name = match.group(1)
|
||||
desc = _clean_javadoc_text(match.group(2))
|
||||
if desc:
|
||||
result[name] = desc
|
||||
return result
|
||||
|
||||
|
||||
def _extract_javadoc_before_line(source: str, target_line: int) -> str:
|
||||
"""
|
||||
提取目标行之前紧邻的 JavaDoc 块。
|
||||
target_line 为 1-indexed(与方法声明行号一致)。
|
||||
"""
|
||||
if not source or target_line <= 1:
|
||||
return ""
|
||||
lines = source.splitlines()
|
||||
idx = target_line - 2
|
||||
while idx >= 0 and not lines[idx].strip():
|
||||
idx -= 1
|
||||
while idx >= 0 and lines[idx].strip().startswith("@"):
|
||||
idx -= 1
|
||||
if idx < 0 or not lines[idx].strip().endswith("*/"):
|
||||
return ""
|
||||
end_idx = idx
|
||||
while idx >= 0 and not lines[idx].strip().startswith("/**"):
|
||||
idx -= 1
|
||||
if idx < 0:
|
||||
return ""
|
||||
return "\n".join(lines[idx : end_idx + 1])
|
||||
|
||||
|
||||
def _field_required(field: FieldDeclaration) -> bool:
|
||||
"""DTO 字段是否必填(@NotNull / @NotEmpty / @NotBlank)。"""
|
||||
if _has_ann(field, "Nullable"):
|
||||
return False
|
||||
for ann in field.annotations or []:
|
||||
if _ann_simple_name(ann) in REQUIRED_FIELD_ANNS:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _lookup_param_description(
|
||||
javadoc_params: Dict[str, str], param: FormalParameter, resolved_name: str
|
||||
) -> Optional[str]:
|
||||
"""按注解名或形参名匹配 JavaDoc @param 说明。"""
|
||||
for key in (resolved_name, param.name):
|
||||
if key and key in javadoc_params:
|
||||
return javadoc_params[key]
|
||||
return None
|
||||
|
||||
|
||||
class ControllerAstParser:
|
||||
"""
|
||||
基于 javalang 的 Controller 解析器。
|
||||
只解析传入的文件,不扫描整个目录(CI 更快)。
|
||||
"""
|
||||
|
||||
def __init__(self, repo_root: Path, source_dirs: List[Path]):
|
||||
"""
|
||||
:param repo_root: 仓库根目录
|
||||
:param source_dirs: Java 源码根目录列表(用于查找 DTO 等)
|
||||
"""
|
||||
self.repo_root = repo_root
|
||||
self.source_dirs = source_dirs
|
||||
self._dto_cache: Dict[str, List[ApiParameter]] = {}
|
||||
self._current_source = ""
|
||||
|
||||
def parse_file_content(self, source: str, repo_relative_path: str) -> List[ApiEndpoint]:
|
||||
"""
|
||||
解析单个 Java 源文件内容。
|
||||
|
||||
:param source: 源码文本
|
||||
:param repo_relative_path: 相对仓库根目录的路径(与 git diff 一致)
|
||||
:return: 端点列表
|
||||
"""
|
||||
endpoints: List[ApiEndpoint] = []
|
||||
self._current_source = source
|
||||
try:
|
||||
tree = javalang.parse.parse(source)
|
||||
except (javalang.parser.JavaSyntaxError, RecursionError) as exc:
|
||||
print(f"[警告] 解析失败 {repo_relative_path}: {exc}")
|
||||
return endpoints
|
||||
|
||||
for type_decl in tree.types or []:
|
||||
if not isinstance(type_decl, ClassDeclaration):
|
||||
continue
|
||||
if not self._is_controller(type_decl):
|
||||
continue
|
||||
|
||||
class_path = ""
|
||||
for ann in type_decl.annotations or []:
|
||||
if _ann_simple_name(ann) == "RequestMapping":
|
||||
class_path = _ann_string(ann, "value", "path")
|
||||
break
|
||||
|
||||
for method in type_decl.methods or []:
|
||||
ep = self._parse_method(method, type_decl.name, class_path, repo_relative_path)
|
||||
if ep:
|
||||
endpoints.append(ep)
|
||||
|
||||
return endpoints
|
||||
|
||||
def _is_controller(self, cls: ClassDeclaration) -> bool:
|
||||
"""是否为 Controller 类。"""
|
||||
return any(_ann_simple_name(a) in CONTROLLER_ANNS for a in (cls.annotations or []))
|
||||
|
||||
def _parse_method(
|
||||
self,
|
||||
method: MethodDeclaration,
|
||||
class_name: str,
|
||||
class_path: str,
|
||||
source_file: str,
|
||||
) -> Optional[ApiEndpoint]:
|
||||
"""解析带映射注解的方法。"""
|
||||
for ann in method.annotations or []:
|
||||
ann_name = _ann_simple_name(ann)
|
||||
if ann_name not in MAPPING_ANNS:
|
||||
continue
|
||||
|
||||
method_path = _ann_string(ann, "value", "path")
|
||||
javadoc_params: Dict[str, str] = {}
|
||||
if getattr(method, "position", None) and method.position:
|
||||
javadoc = _extract_javadoc_before_line(
|
||||
self._current_source, method.position.line
|
||||
)
|
||||
javadoc_params = _parse_javadoc_params(javadoc)
|
||||
|
||||
params = []
|
||||
for p in method.parameters or []:
|
||||
params.extend(self._extract_param(p, javadoc_params))
|
||||
|
||||
return ApiEndpoint(
|
||||
http_method=_http_method(ann_name, ann),
|
||||
uri=_join_paths(class_path, method_path),
|
||||
controller_class=class_name,
|
||||
method_name=method.name,
|
||||
source_file=source_file.replace("\\", "/"),
|
||||
parameters=params,
|
||||
)
|
||||
return None
|
||||
|
||||
def _extract_param(
|
||||
self, param: FormalParameter, javadoc_params: Optional[Dict[str, str]] = None
|
||||
) -> List[ApiParameter]:
|
||||
"""提取方法参数,@RequestBody 展开 DTO 字段;忽略框架注入参数。"""
|
||||
type_name = _type_to_str(param.type)
|
||||
name = _param_name(param)
|
||||
javadoc_params = javadoc_params or {}
|
||||
|
||||
if _is_framework_param(type_name, name):
|
||||
return []
|
||||
|
||||
if _has_ann(param, "RequestBody"):
|
||||
body_desc = _lookup_param_description(javadoc_params, param, name)
|
||||
return self._expand_dto(
|
||||
type_name,
|
||||
"body",
|
||||
body_param_name=param.name,
|
||||
body_param_desc=body_desc,
|
||||
)
|
||||
|
||||
description = _lookup_param_description(javadoc_params, param, name)
|
||||
return [
|
||||
ApiParameter(
|
||||
name=name,
|
||||
type=type_name,
|
||||
required=_param_required(param),
|
||||
source=_param_source(param),
|
||||
description=description,
|
||||
)
|
||||
]
|
||||
|
||||
def _expand_dto(
|
||||
self,
|
||||
type_name: str,
|
||||
source: str,
|
||||
body_param_name: str = "",
|
||||
body_param_desc: Optional[str] = None,
|
||||
) -> List[ApiParameter]:
|
||||
"""展开 @RequestBody DTO 一级字段。"""
|
||||
simple = type_name.split(".")[-1].replace(">", "").replace("<", "").strip()
|
||||
cache_key = f"{simple}:{body_param_name}"
|
||||
if cache_key in self._dto_cache:
|
||||
return self._dto_cache[cache_key]
|
||||
|
||||
dto_file = self._find_dto_file(simple)
|
||||
if not dto_file:
|
||||
result = [
|
||||
ApiParameter(
|
||||
name=simple,
|
||||
type=type_name,
|
||||
required=True,
|
||||
source=source,
|
||||
description=body_param_desc,
|
||||
parent_dto=simple,
|
||||
body_param_name=body_param_name or None,
|
||||
)
|
||||
]
|
||||
self._dto_cache[cache_key] = result
|
||||
return result
|
||||
|
||||
try:
|
||||
dto_source = dto_file.read_text(encoding="utf-8", errors="ignore")
|
||||
tree = javalang.parse.parse(dto_source)
|
||||
except (javalang.parser.JavaSyntaxError, OSError):
|
||||
result = [
|
||||
ApiParameter(
|
||||
name=simple,
|
||||
type=type_name,
|
||||
required=True,
|
||||
source=source,
|
||||
description=body_param_desc,
|
||||
parent_dto=simple,
|
||||
body_param_name=body_param_name or None,
|
||||
)
|
||||
]
|
||||
self._dto_cache[cache_key] = result
|
||||
return result
|
||||
|
||||
fields: List[ApiParameter] = []
|
||||
for type_decl in tree.types or []:
|
||||
if not isinstance(type_decl, ClassDeclaration):
|
||||
continue
|
||||
for field in type_decl.fields or []:
|
||||
if "static" in (field.modifiers or []):
|
||||
continue
|
||||
field_javadoc = ""
|
||||
if getattr(field, "position", None) and field.position:
|
||||
field_javadoc = _extract_javadoc_before_line(
|
||||
dto_source, field.position.line
|
||||
)
|
||||
field_desc = _clean_javadoc_text(
|
||||
field_javadoc.replace("/**", "").replace("*/", "").strip()
|
||||
) or None
|
||||
for decl in field.declarators:
|
||||
fields.append(
|
||||
ApiParameter(
|
||||
name=decl.name,
|
||||
type=_type_to_str(field.type),
|
||||
required=_field_required(field),
|
||||
source=source,
|
||||
description=field_desc,
|
||||
parent_dto=simple,
|
||||
body_param_name=body_param_name or None,
|
||||
)
|
||||
)
|
||||
|
||||
if not fields:
|
||||
fields = [
|
||||
ApiParameter(
|
||||
name=simple,
|
||||
type=type_name,
|
||||
required=True,
|
||||
source=source,
|
||||
description=body_param_desc,
|
||||
parent_dto=simple,
|
||||
body_param_name=body_param_name or None,
|
||||
)
|
||||
]
|
||||
|
||||
self._dto_cache[cache_key] = fields
|
||||
return fields
|
||||
|
||||
def _find_dto_file(self, simple_name: str) -> Optional[Path]:
|
||||
"""在配置的源码目录及仓库内 src/main/java 中查找 DTO 文件。"""
|
||||
target = f"{simple_name}.java"
|
||||
for source_dir in self.source_dirs:
|
||||
if source_dir.exists():
|
||||
for path in source_dir.rglob(target):
|
||||
return path
|
||||
if self.repo_root.exists():
|
||||
for path in self.repo_root.rglob(target):
|
||||
if "src/main/java" in path.as_posix():
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def parse_controller_files(
|
||||
repo_root: Path,
|
||||
source_subdirs: List[str],
|
||||
file_paths: List[str],
|
||||
file_contents: Dict[str, str],
|
||||
) -> List[ApiEndpoint]:
|
||||
"""
|
||||
批量解析指定 Controller 文件(仅解析传入的文件,不全量扫描)。
|
||||
|
||||
:param repo_root: 仓库根目录
|
||||
:param source_subdirs: 源码子目录列表(相对仓库根)
|
||||
:param file_paths: 要解析的文件路径列表(相对仓库根)
|
||||
:param file_contents: {文件路径: 源码内容}
|
||||
:return: 所有端点
|
||||
"""
|
||||
source_dirs = [(repo_root / sub).resolve() for sub in source_subdirs]
|
||||
parser = ControllerAstParser(repo_root, source_dirs)
|
||||
endpoints: List[ApiEndpoint] = []
|
||||
|
||||
for path in file_paths:
|
||||
norm = path.replace("\\", "/")
|
||||
content = file_contents.get(norm)
|
||||
if not content:
|
||||
continue
|
||||
endpoints.extend(parser.parse_file_content(content, norm))
|
||||
|
||||
return endpoints
|
||||
@@ -1,43 +0,0 @@
|
||||
"""
|
||||
Controller 端点解析模块(纯 Python,无需 Java)。
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
|
||||
from models import ApiEndpoint, ApiParameter
|
||||
|
||||
|
||||
def endpoints_to_map(endpoints: List[ApiEndpoint]) -> Dict[str, ApiEndpoint]:
|
||||
"""端点列表转字典,key 为 endpoint_key。"""
|
||||
return {ep.endpoint_key: ep for ep in endpoints}
|
||||
|
||||
|
||||
def filter_endpoints_by_files(
|
||||
endpoints: List[ApiEndpoint], changed_files: List[str]
|
||||
) -> List[ApiEndpoint]:
|
||||
"""仅保留变更文件中的端点。"""
|
||||
if not changed_files:
|
||||
return endpoints
|
||||
changed_set = {f.replace("\\", "/") for f in changed_files}
|
||||
return [ep for ep in endpoints if ep.source_file in changed_set]
|
||||
|
||||
|
||||
def parse_endpoints_from_files(
|
||||
repo_root: Path,
|
||||
source_subdirs: List[str],
|
||||
file_paths: List[str],
|
||||
file_contents: Dict[str, str],
|
||||
) -> List[ApiEndpoint]:
|
||||
"""
|
||||
解析指定 Controller 文件,提取接口参数(仅解析传入文件,不全量扫描)。
|
||||
|
||||
:param repo_root: 仓库根
|
||||
:param source_subdirs: 源码目录列表(相对仓库根)
|
||||
:param file_paths: 文件路径列表
|
||||
:param file_contents: 路径 -> 源码内容
|
||||
:return: ApiEndpoint 列表
|
||||
"""
|
||||
from controller_ast_parser import parse_controller_files
|
||||
|
||||
return parse_controller_files(repo_root, source_subdirs, file_paths, file_contents)
|
||||
51
.gitea/checker/dependency-reduced-pom.xml
Normal file
51
.gitea/checker/dependency-reduced-pom.xml
Normal file
@@ -0,0 +1,51 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<groupId>com.codechecker</groupId>
|
||||
<artifactId>code-checker</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<build>
|
||||
<finalName>code-checker</finalName>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>3.13.0</version>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<artifactId>maven-shade-plugin</artifactId>
|
||||
<version>3.6.0</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<phase>package</phase>
|
||||
<goals>
|
||||
<goal>shade</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<transformers>
|
||||
<transformer>
|
||||
<mainClass>com.codechecker.CodeCheckMain</mainClass>
|
||||
</transformer>
|
||||
</transformers>
|
||||
<filters>
|
||||
<filter>
|
||||
<artifact>*:*</artifact>
|
||||
<excludes>
|
||||
<exclude>META-INF/*.SF</exclude>
|
||||
<exclude>META-INF/*.DSA</exclude>
|
||||
<exclude>META-INF/*.RSA</exclude>
|
||||
</excludes>
|
||||
</filter>
|
||||
</filters>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
<properties>
|
||||
<maven.compiler.target>11</maven.compiler.target>
|
||||
<javaparser.version>3.25.10</javaparser.version>
|
||||
<maven.compiler.source>11</maven.compiler.source>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
</properties>
|
||||
</project>
|
||||
@@ -1,154 +0,0 @@
|
||||
"""
|
||||
Git 操作工具模块。
|
||||
负责在 CI 环境中检出上一版本代码、获取变更文件列表及提交元信息。
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class CommitInfo:
|
||||
"""单次 Git 提交的元信息。"""
|
||||
|
||||
sha: str
|
||||
author: str
|
||||
commit_time: str
|
||||
message: str
|
||||
|
||||
|
||||
def run_git(args: List[str], cwd: Optional[Path] = None) -> str:
|
||||
"""
|
||||
执行 git 命令并返回标准输出。
|
||||
|
||||
:param args: git 子命令及参数,如 ["log", "-1", "--format=%H"]
|
||||
:param cwd: 工作目录,默认为当前目录
|
||||
:return: 命令 stdout 文本(已 strip)
|
||||
:raises RuntimeError: git 命令执行失败时抛出
|
||||
"""
|
||||
cmd = ["git"] + args
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"Git 命令失败: {' '.join(cmd)}\n{result.stderr}")
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def get_current_commit() -> CommitInfo:
|
||||
"""
|
||||
获取当前 HEAD 提交的元信息(推送人、时间等,用于通知模板)。
|
||||
|
||||
:return: CommitInfo 对象
|
||||
"""
|
||||
sha = run_git(["rev-parse", "HEAD"])
|
||||
author = run_git(["log", "-1", "--format=%an"])
|
||||
commit_time = run_git(["log", "-1", "--format=%ci"])
|
||||
message = run_git(["log", "-1", "--format=%s"])
|
||||
return CommitInfo(sha=sha, author=author, commit_time=commit_time, message=message)
|
||||
|
||||
|
||||
def get_previous_commit_sha() -> Optional[str]:
|
||||
"""
|
||||
获取上一次提交的 SHA(HEAD~1)。
|
||||
若是首次提交则返回 None。
|
||||
|
||||
:return: 上一 commit SHA,或 None
|
||||
"""
|
||||
try:
|
||||
return run_git(["rev-parse", "HEAD~1"])
|
||||
except RuntimeError:
|
||||
return None
|
||||
|
||||
|
||||
def checkout_commit(sha: str, worktree_dir: Path) -> None:
|
||||
"""
|
||||
将指定 commit 的代码检出到独立工作目录(不影响当前工作区)。
|
||||
|
||||
:param sha: 目标 commit SHA
|
||||
:param worktree_dir: git worktree 目录
|
||||
"""
|
||||
worktree_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||
if worktree_dir.exists():
|
||||
# 已存在则先移除旧 worktree
|
||||
run_git(["worktree", "remove", "--force", str(worktree_dir)])
|
||||
run_git(["worktree", "add", str(worktree_dir), sha])
|
||||
|
||||
|
||||
def get_changed_java_controller_files(base_sha: str, head_sha: str) -> List[str]:
|
||||
"""
|
||||
获取两次提交之间变更的 Controller 相关 Java 文件路径。
|
||||
|
||||
:param base_sha: 基准 commit(旧版本)
|
||||
:param head_sha: 目标 commit(新版本)
|
||||
:return: 相对路径列表,如 ["src/main/java/.../UserController.java"]
|
||||
"""
|
||||
diff_output = run_git(["diff", "--name-only", base_sha, head_sha])
|
||||
if not diff_output:
|
||||
return []
|
||||
|
||||
changed = []
|
||||
for line in diff_output.splitlines():
|
||||
line = line.strip()
|
||||
if line.endswith(".java") and "Controller" in line:
|
||||
changed.append(line.replace("\\", "/"))
|
||||
return changed
|
||||
|
||||
|
||||
def get_controller_files_diff(base_sha: str, head_sha: str, changed_files: List[str]) -> str:
|
||||
"""
|
||||
获取变更 Controller 文件的 Git diff,供 LLM 审核接口参数变更时参考。
|
||||
|
||||
:param base_sha: 旧版本 commit SHA
|
||||
:param head_sha: 新版本 commit SHA
|
||||
:param changed_files: 变更文件相对路径列表
|
||||
:return: diff 文本
|
||||
"""
|
||||
if not changed_files:
|
||||
return ""
|
||||
|
||||
try:
|
||||
return run_git(["diff", base_sha, head_sha, "--"] + changed_files)
|
||||
except RuntimeError as exc:
|
||||
print(f"[警告] 获取 Git diff 失败: {exc}")
|
||||
return ""
|
||||
|
||||
|
||||
def get_file_content_at_commit(commit_sha: str, file_path: str) -> Optional[str]:
|
||||
"""
|
||||
读取指定 commit 下某个文件的内容(无需 git worktree,更快)。
|
||||
|
||||
:param commit_sha: commit SHA
|
||||
:param file_path: 相对仓库根目录的文件路径
|
||||
:return: 文件内容;该 commit 中不存在则返回 None
|
||||
"""
|
||||
try:
|
||||
return run_git(["show", f"{commit_sha}:{file_path}"])
|
||||
except RuntimeError:
|
||||
return None
|
||||
|
||||
|
||||
def prepare_worktrees(repo_root: Path) -> tuple:
|
||||
"""
|
||||
准备新旧两个版本的代码工作目录,供 AST 解析器分别扫描。
|
||||
|
||||
:param repo_root: 仓库根目录
|
||||
:return: (新版本目录, 旧版本目录, 旧版本SHA);首次提交时旧版本目录为 None
|
||||
"""
|
||||
prev_sha = get_previous_commit_sha()
|
||||
current_dir = repo_root
|
||||
|
||||
if prev_sha is None:
|
||||
return current_dir, None, None
|
||||
|
||||
prev_dir = repo_root / ".gitea" / ".cache" / "prev-worktree"
|
||||
checkout_commit(prev_sha, prev_dir)
|
||||
return current_dir, prev_dir, prev_sha
|
||||
@@ -1,147 +0,0 @@
|
||||
"""
|
||||
豆包 LLM 接口参数变更审核模块。
|
||||
LLM 仅输出简短的兼容性提示,详细变更由 AST + Markdown 通知展示。
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import requests
|
||||
|
||||
from comparator import EndpointChangeReport
|
||||
|
||||
# 写入 prompt,不在通知中展示
|
||||
FRAMEWORK_IGNORE_HINT = """
|
||||
以下参数类型/名称属于 Spring MVC 框架自动注入,不是 API 调用方入参,审核时必须忽略,不要在结果中提及:
|
||||
HttpServletRequest、HttpServletResponse、HttpSession、ServletRequest、ServletResponse、
|
||||
WebRequest、NativeWebRequest、Model、ModelMap、RedirectAttributes、BindingResult、
|
||||
Authentication、Principal 等。
|
||||
"""
|
||||
|
||||
|
||||
def is_llm_enabled(config: Dict[str, Any]) -> bool:
|
||||
"""判断大模型总开关是否开启。"""
|
||||
return config.get("llm", {}).get("enabled", True)
|
||||
|
||||
|
||||
def call_doubao_api(
|
||||
api_key: str,
|
||||
prompt: str,
|
||||
config: Dict[str, Any],
|
||||
) -> Optional[str]:
|
||||
"""调用豆包 API。"""
|
||||
if not api_key or api_key == "YOUR_DOUBAO_API_KEY":
|
||||
print("[警告] 未配置豆包 API Key,跳过 LLM 审核。")
|
||||
return None
|
||||
|
||||
llm_cfg = config.get("llm", {})
|
||||
model = llm_cfg.get("model") or llm_cfg.get("endpoint_id", "")
|
||||
if not model:
|
||||
print("[警告] 未配置 llm.model,跳过 LLM 审核。")
|
||||
return None
|
||||
|
||||
api_url = llm_cfg.get(
|
||||
"api_url", "https://ark.cn-beijing.volces.com/api/v3/chat/completions"
|
||||
)
|
||||
timeout = llm_cfg.get("timeout")
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
}
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"你是 Java Spring Boot API 变更分析专家。"
|
||||
"你只负责输出简短的兼容性风险提示,不重复罗列接口参数明细。"
|
||||
+ FRAMEWORK_IGNORE_HINT
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
"temperature": 0.1,
|
||||
}
|
||||
|
||||
try:
|
||||
kwargs = {"headers": headers, "json": payload}
|
||||
if timeout is not None:
|
||||
kwargs["timeout"] = timeout
|
||||
resp = requests.post(api_url, **kwargs)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if "choices" in data and data["choices"]:
|
||||
return data["choices"][0]["message"]["content"]
|
||||
return None
|
||||
except requests.RequestException as exc:
|
||||
print(f"[错误] 豆包 API 调用失败: {exc}")
|
||||
return None
|
||||
|
||||
|
||||
def build_parameter_change_prompt(
|
||||
reports: List[EndpointChangeReport],
|
||||
changed_files: List[str],
|
||||
git_diff: str = "",
|
||||
) -> str:
|
||||
"""
|
||||
构造 LLM 提示词:只要求输出兼容性摘要,不要求重复参数列表。
|
||||
"""
|
||||
ast_report = []
|
||||
for r in reports:
|
||||
ast_report.append(
|
||||
{
|
||||
"uri": f"{r.http_method} {r.uri}",
|
||||
"is_new": r.is_new_endpoint,
|
||||
"is_removed": r.is_removed_endpoint,
|
||||
"changes": [
|
||||
{
|
||||
"type": c.change_type.value,
|
||||
"name": c.param_name,
|
||||
"java_type": c.param_type,
|
||||
"required": c.required,
|
||||
}
|
||||
for c in r.parameter_changes
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
diff_block = git_diff.strip()[:6000] if git_diff.strip() else "(无)"
|
||||
|
||||
return f"""请根据以下 Controller 接口参数变更,**仅输出「兼容性提示」**,要求:
|
||||
|
||||
{FRAMEWORK_IGNORE_HINT}
|
||||
|
||||
## 输出格式(严格遵守)
|
||||
- 只输出 3~6 行 Markdown,不要输出「整体说明」「接口变更详情」等标题
|
||||
- 不要逐条重复 URI 和参数列表(通知里已有)
|
||||
- 不要提及「排除框架注入」相关字样
|
||||
- 重点说明:是否有破坏性变更、哪些必填参数调用方必须传入
|
||||
- 全新 Controller 说明「均为新接口,对现有调用方无破坏」即可
|
||||
- 语气简洁,可用 <font color="warning">...</font> 标注风险项
|
||||
|
||||
## 变更文件
|
||||
{json.dumps(changed_files, ensure_ascii=False)}
|
||||
|
||||
## AST 变更摘要
|
||||
{json.dumps(ast_report, ensure_ascii=False, indent=2)}
|
||||
|
||||
## Git Diff
|
||||
{diff_block}
|
||||
"""
|
||||
|
||||
|
||||
def review_parameter_changes(
|
||||
reports: List[EndpointChangeReport],
|
||||
config: Dict[str, Any],
|
||||
changed_files: List[str],
|
||||
git_diff: str = "",
|
||||
) -> Optional[str]:
|
||||
"""LLM 审核,返回简短兼容性提示。"""
|
||||
if not is_llm_enabled(config) or not reports:
|
||||
return None
|
||||
|
||||
llm_cfg = config.get("llm", {})
|
||||
prompt = build_parameter_change_prompt(reports, changed_files, git_diff)
|
||||
return call_doubao_api(llm_cfg.get("api_key", ""), prompt, config)
|
||||
@@ -1,207 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
AI-Check 主入口 — Controller 层接口参数变更检测(纯 Python,无 Java 依赖)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import yaml
|
||||
|
||||
CHECKER_DIR = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(CHECKER_DIR))
|
||||
|
||||
from change_logger import persist_change_log
|
||||
from comparator import compare_endpoints
|
||||
from controller_parser import (
|
||||
endpoints_to_map,
|
||||
filter_endpoints_by_files,
|
||||
parse_endpoints_from_files,
|
||||
)
|
||||
from git_utils import (
|
||||
get_changed_java_controller_files,
|
||||
get_controller_files_diff,
|
||||
get_current_commit,
|
||||
get_file_content_at_commit,
|
||||
get_previous_commit_sha,
|
||||
)
|
||||
from llm_reviewer import review_parameter_changes
|
||||
from notifier import send_parameter_change_notification
|
||||
|
||||
|
||||
def load_config(config_path: Path) -> dict:
|
||||
"""加载 YAML 配置文件。"""
|
||||
if not config_path.exists():
|
||||
print(f"[错误] 配置文件不存在: {config_path}")
|
||||
print("请在 .gitea/config.yaml 中填写配置并提交到仓库。")
|
||||
sys.exit(1)
|
||||
|
||||
with open(config_path, "r", encoding="utf-8") as f:
|
||||
return yaml.safe_load(f) or {}
|
||||
|
||||
|
||||
def resolve_source_subdirs(config: dict) -> list:
|
||||
"""从配置解析 Java 源码目录列表(支持 source_dirs 多模块)。"""
|
||||
dirs = config.get("source_dirs")
|
||||
if dirs:
|
||||
return [str(d) for d in dirs]
|
||||
return [config.get("source_dir", "src/main/java")]
|
||||
|
||||
|
||||
def _read_file_safe(path: Path) -> str:
|
||||
"""读取文件内容。"""
|
||||
try:
|
||||
return path.read_text(encoding="utf-8", errors="ignore")
|
||||
except OSError as exc:
|
||||
print(f"[警告] 无法读取 {path}: {exc}")
|
||||
return ""
|
||||
|
||||
|
||||
def _load_version_contents(
|
||||
repo_root: Path,
|
||||
file_paths: list,
|
||||
commit_sha: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""加载文件内容;commit_sha 为空则读工作区,否则 git show。"""
|
||||
contents = {}
|
||||
for fp in file_paths:
|
||||
norm = fp.replace("\\", "/")
|
||||
if commit_sha:
|
||||
text = get_file_content_at_commit(commit_sha, norm)
|
||||
if text is not None:
|
||||
contents[norm] = text
|
||||
else:
|
||||
text = _read_file_safe(repo_root / norm)
|
||||
if text:
|
||||
contents[norm] = text
|
||||
return contents
|
||||
|
||||
|
||||
def parse_changed_endpoints(
|
||||
repo_root: Path,
|
||||
source_subdirs: list,
|
||||
changed_files: list,
|
||||
old_sha: str,
|
||||
label: str,
|
||||
) -> dict:
|
||||
"""解析变更 Controller 文件在新/旧版本的端点。"""
|
||||
if label == "new":
|
||||
contents = _load_version_contents(repo_root, changed_files)
|
||||
else:
|
||||
contents = _load_version_contents(repo_root, changed_files, commit_sha=old_sha)
|
||||
|
||||
print(f"[AST] 解析 {label} 版本 {len(contents)} 个 Controller 文件")
|
||||
endpoints = parse_endpoints_from_files(
|
||||
repo_root, source_subdirs, changed_files, contents
|
||||
)
|
||||
print(f"[AST] {label} 版本共 {len(endpoints)} 个接口")
|
||||
return endpoints_to_map(endpoints)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""主流程入口。"""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="AI-Check: Controller 接口参数变更检测"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--config", default=".gitea/config.yaml", help="配置文件路径"
|
||||
)
|
||||
parser.add_argument("--repo-root", default=".", help="Git 仓库根目录")
|
||||
parser.add_argument("push_user", nargs="?", default=None, help="推送人")
|
||||
parser.add_argument("push_time", nargs="?", default=None, help="推送时间")
|
||||
args = parser.parse_args()
|
||||
|
||||
repo_root = Path(args.repo_root).resolve()
|
||||
config_path = Path(args.config)
|
||||
if not config_path.is_absolute():
|
||||
config_path = repo_root / config_path
|
||||
config = load_config(config_path)
|
||||
|
||||
if not config.get("check", {}).get("enabled", True):
|
||||
print("[检查] API 变动检查已关闭(check.enabled=false),跳过。")
|
||||
return 0
|
||||
|
||||
source_subdirs = resolve_source_subdirs(config)
|
||||
|
||||
commit_info = get_current_commit()
|
||||
push_user = args.push_user or commit_info.author
|
||||
push_time = args.push_time or commit_info.commit_time
|
||||
|
||||
print("Controller 接口参数变更检测(纯 Python)")
|
||||
print("=" * 40)
|
||||
print(f"推送人: {push_user}")
|
||||
print(f"推送时间: {push_time}")
|
||||
print(f"API 变动检查: {config.get('check', {}).get('enabled', True)}")
|
||||
print(f"源码目录: {', '.join(source_subdirs)}")
|
||||
print(f"LLM 审核: {config.get('llm', {}).get('enabled', True)}")
|
||||
print(f"记录日志: {config.get('log', {}).get('enabled', False)}")
|
||||
print("=" * 40)
|
||||
|
||||
prev_sha = get_previous_commit_sha()
|
||||
if prev_sha is None:
|
||||
print("[Git] 首次提交,无可对比版本,跳过。")
|
||||
return 0
|
||||
|
||||
changed_files = [f.replace("\\", "/") for f in get_changed_java_controller_files(prev_sha, commit_info.sha)]
|
||||
if not changed_files:
|
||||
print("[Git] 本次提交未变更 Controller 文件,跳过。")
|
||||
return 0
|
||||
|
||||
print(f"[Git] 变更 Controller 文件 {len(changed_files)} 个:")
|
||||
for f in changed_files:
|
||||
print(f" - {f}")
|
||||
|
||||
git_diff = get_controller_files_diff(prev_sha, commit_info.sha, changed_files)
|
||||
|
||||
new_map = parse_changed_endpoints(
|
||||
repo_root, source_subdirs, changed_files, prev_sha, "new"
|
||||
)
|
||||
old_map = parse_changed_endpoints(
|
||||
repo_root, source_subdirs, changed_files, prev_sha, "old"
|
||||
)
|
||||
|
||||
new_filtered = endpoints_to_map(
|
||||
filter_endpoints_by_files(list(new_map.values()), changed_files)
|
||||
)
|
||||
old_filtered = endpoints_to_map(
|
||||
filter_endpoints_by_files(list(old_map.values()), changed_files)
|
||||
)
|
||||
|
||||
reports = compare_endpoints(old_filtered, new_filtered)
|
||||
print(f"[对比] 检测到 {len(reports)} 个接口存在参数变更")
|
||||
|
||||
llm_review = None
|
||||
if reports:
|
||||
llm_review = review_parameter_changes(
|
||||
reports, config, changed_files, git_diff
|
||||
)
|
||||
if llm_review:
|
||||
print("[LLM] 参数变更审核完成")
|
||||
|
||||
persist_change_log(reports, commit_info, config, llm_review)
|
||||
|
||||
notify_cfg = config.get("notify", {})
|
||||
if notify_cfg.get("only_on_change", True) and not reports:
|
||||
print("[通知] 无接口参数变更,跳过企微通知。")
|
||||
return 0
|
||||
|
||||
mentioned = notify_cfg.get("mentioned_users", "")
|
||||
mentioned_list = [u.strip() for u in mentioned.split(",") if u.strip()] or None
|
||||
|
||||
send_parameter_change_notification(
|
||||
webhook_url=config.get("wecom", {}).get("webhook_url", ""),
|
||||
reports=reports,
|
||||
push_user=push_user,
|
||||
push_time=push_time,
|
||||
llm_review=llm_review,
|
||||
mentioned_users=mentioned_list,
|
||||
)
|
||||
|
||||
print("\n完成")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,35 +0,0 @@
|
||||
"""
|
||||
Controller 端点数据模型。
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class ApiParameter:
|
||||
"""单个接口参数。"""
|
||||
|
||||
name: str
|
||||
type: str
|
||||
required: bool = True
|
||||
source: str = "query"
|
||||
description: Optional[str] = None
|
||||
parent_dto: Optional[str] = None
|
||||
body_param_name: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ApiEndpoint:
|
||||
"""单个 Controller 接口端点。"""
|
||||
|
||||
http_method: str
|
||||
uri: str
|
||||
controller_class: str
|
||||
method_name: str
|
||||
source_file: str
|
||||
parameters: List[ApiParameter] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def endpoint_key(self) -> str:
|
||||
return f"{self.http_method} {self.uri}"
|
||||
@@ -1,525 +0,0 @@
|
||||
"""
|
||||
企业微信 Markdown 通知模块。
|
||||
支持加粗、颜色(info/comment/warning),新增接口与变更接口使用不同展示模板。
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from collections import OrderedDict
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
import requests
|
||||
|
||||
from comparator import EndpointChangeReport, ParameterChange
|
||||
|
||||
# 企微 Markdown 单条上限 4096 字符,留余量
|
||||
MAX_MD_LENGTH = 3800
|
||||
|
||||
|
||||
def truncate_text(text: str, max_length: int = MAX_MD_LENGTH) -> str:
|
||||
"""截断超长消息。"""
|
||||
if len(text) <= max_length:
|
||||
return text
|
||||
return text[:max_length] + "\n\n<font color=\"comment\">... 消息过长,已截断</font>"
|
||||
|
||||
|
||||
def _format_param_change_list(changes: List[ParameterChange]) -> List[str]:
|
||||
"""生成企微友好的普通参数变更列表(卡片式)。"""
|
||||
if not changes:
|
||||
return ['<font color="comment">无</font>']
|
||||
lines = ["", f"共 **{len(changes)}** 项变更", ""]
|
||||
for i, change in enumerate(changes, 1):
|
||||
lines.append(change.to_markdown_block(i))
|
||||
if i < len(changes):
|
||||
lines.append("")
|
||||
return lines
|
||||
|
||||
|
||||
def _body_dto_group_key(change: ParameterChange) -> Tuple[str, str]:
|
||||
"""类对象变更分组键:(body 参数名, DTO 类名)。"""
|
||||
return (change.body_param_name or "body", change.parent_dto or "")
|
||||
|
||||
|
||||
def _format_body_field_line(change: ParameterChange, *, is_last: bool) -> List[str]:
|
||||
"""格式化 DTO 一级字段变更行。"""
|
||||
branch = "└─" if is_last else "├─"
|
||||
desc = change.description or change.old_description
|
||||
type_part = f" · `{change.param_type}`" if change.param_type else ""
|
||||
req_part = f" · {change._required_tag()}" if change._required_tag() else ""
|
||||
lines = [f"{branch} `{change.param_name}`{type_part}{req_part} {change._change_tag()}"]
|
||||
if desc:
|
||||
lines.append(f"> 说明:{desc}")
|
||||
if change.change_type.value == "modified" and change.detail:
|
||||
lines.append(f"> 变更:{change.detail}")
|
||||
if change.change_type.value == "renamed":
|
||||
lines.append(f"> `{change.old_name}` → `{change.param_name}`")
|
||||
return lines
|
||||
|
||||
|
||||
def _format_body_dto_groups(changes: List[ParameterChange]) -> List[str]:
|
||||
"""按 DTO 分组展示 @RequestBody 一级字段。"""
|
||||
if not changes:
|
||||
return ['<font color="comment">无</font>']
|
||||
|
||||
groups: OrderedDict[Tuple[str, str], List[ParameterChange]] = OrderedDict()
|
||||
for change in changes:
|
||||
key = _body_dto_group_key(change)
|
||||
groups.setdefault(key, []).append(change)
|
||||
|
||||
lines: List[str] = ["", f"共 **{len(groups)}** 个类对象 · **{len(changes)}** 项字段变更", ""]
|
||||
for (param_name, dto_name), group in groups.items():
|
||||
label = param_name or "body"
|
||||
dto_part = f" · `{dto_name}`" if dto_name else ""
|
||||
lines.append(f"**{label}**{dto_part}")
|
||||
lines.append("")
|
||||
for i, change in enumerate(group):
|
||||
lines.extend(_format_body_field_line(change, is_last=(i == len(group) - 1)))
|
||||
lines.append("")
|
||||
|
||||
if lines and lines[-1] == "":
|
||||
lines.pop()
|
||||
return lines
|
||||
|
||||
|
||||
def _format_param_details_section(report: EndpointChangeReport) -> List[str]:
|
||||
"""生成接口参数变动详情区块。"""
|
||||
body_changes = [c for c in report.parameter_changes if c.source == "body"]
|
||||
regular_changes = [c for c in report.parameter_changes if c.source != "body"]
|
||||
lines = ["", "---------------------------------------", "", "#### 【接口参数变动详情】", ""]
|
||||
|
||||
if body_changes:
|
||||
lines.append("**类对象变更(一级字段)**")
|
||||
lines.extend(_format_body_dto_groups(body_changes))
|
||||
lines.append("")
|
||||
|
||||
if regular_changes:
|
||||
lines.append("**普通参数变更**")
|
||||
lines.extend(_format_param_change_list(regular_changes))
|
||||
lines.append("")
|
||||
|
||||
if not body_changes and not regular_changes:
|
||||
lines.append('<font color="comment">无</font>')
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
def _format_endpoint_block(report: EndpointChangeReport) -> str:
|
||||
"""
|
||||
格式化单个接口块,按模板匹配格式输出。
|
||||
全路径类名显示为 source_file(相对仓库根的完整 .java 路径)。
|
||||
"""
|
||||
change_type = "新增接口" if report.is_new_endpoint else ("删除接口" if report.is_removed_endpoint else "修改参数")
|
||||
uri_line = f"**{report.http_method}** `{report.uri}`"
|
||||
file_path = report.source_file or report.controller_class
|
||||
class_line = f"- **全路径类名:** <font color=\"info\">**{file_path}**</font>"
|
||||
|
||||
header = [
|
||||
f"- **变更类型:** <font color=\"warning\">**{change_type}**</font>",
|
||||
f"- **URI:** {uri_line}",
|
||||
class_line,
|
||||
]
|
||||
|
||||
if report.is_removed_endpoint:
|
||||
return "\n".join(header + ["", f"<font color=\"warning\">**该接口已被移除**</font>"])
|
||||
|
||||
return "\n".join(header + _format_param_details_section(report))
|
||||
|
||||
|
||||
def build_markdown_notification(
|
||||
reports: List[EndpointChangeReport],
|
||||
push_user: str,
|
||||
push_time: str,
|
||||
llm_summary: Optional[str] = None,
|
||||
) -> str:
|
||||
"""
|
||||
构建完整 Markdown 通知正文。
|
||||
|
||||
:param reports: AST 变更报告
|
||||
:param push_user: 推送人
|
||||
:param push_time: 推送时间
|
||||
:param llm_summary: LLM 兼容性摘要(可选,简短)
|
||||
:return: Markdown 文本
|
||||
"""
|
||||
parts: List[str] = []
|
||||
|
||||
# 所有 API 级变更(新增、修改路径、修改请求方式、删除、参数变更)统一走 model1.md 路径变更通知
|
||||
method_changed_reports = [r for r in reports if r.is_method_changed]
|
||||
renamed_reports = [r for r in reports if r.is_renamed_endpoint]
|
||||
new_reports = [r for r in reports if r.is_new_endpoint]
|
||||
# 参数变更报告:只包含「URI/方法未变,仅参数变化」的报告
|
||||
# 路径变更 + 参数变更、方法变更 + 参数变更 场景已在上层 comparator 中拆分为独立报告
|
||||
changed_reports = [
|
||||
r for r in reports
|
||||
if not r.is_new_endpoint
|
||||
and not r.is_removed_endpoint
|
||||
and not r.is_renamed_endpoint
|
||||
and not r.is_method_changed
|
||||
]
|
||||
removed_reports = [r for r in reports if r.is_removed_endpoint]
|
||||
|
||||
# 1. 新增接口 → 走 API路径变更通知
|
||||
for report in new_reports:
|
||||
path_md = build_path_change_markdown(
|
||||
old_uri="-",
|
||||
new_uri=report.uri,
|
||||
change_type="新增接口",
|
||||
push_user=push_user,
|
||||
push_time=push_time,
|
||||
file_name=report.source_file or report.controller_class,
|
||||
)
|
||||
parts.append(path_md)
|
||||
parts.append("")
|
||||
|
||||
# 2. 修改请求方式 → 使用独立的新模板 【API请求方式变更通知】
|
||||
for report in method_changed_reports:
|
||||
method_md = build_method_change_markdown(
|
||||
uri=report.uri,
|
||||
old_method=report.old_http_method or "?",
|
||||
new_method=report.http_method,
|
||||
push_user=push_user,
|
||||
push_time=push_time,
|
||||
file_name=report.source_file or report.controller_class,
|
||||
)
|
||||
parts.append(method_md)
|
||||
parts.append("")
|
||||
|
||||
# 3. 修改路径 → 走 API路径变更通知
|
||||
for report in renamed_reports:
|
||||
path_md = build_path_change_markdown(
|
||||
old_uri=report.old_uri or "-",
|
||||
new_uri=report.uri,
|
||||
change_type="修改路径",
|
||||
push_user=push_user,
|
||||
push_time=push_time,
|
||||
file_name=report.source_file or report.controller_class,
|
||||
)
|
||||
parts.append(path_md)
|
||||
parts.append("")
|
||||
|
||||
# 4. 删除接口 → 走 API路径变更通知
|
||||
for report in removed_reports:
|
||||
path_md = build_path_change_markdown(
|
||||
old_uri=report.uri,
|
||||
new_uri="已删除",
|
||||
change_type="删除接口",
|
||||
push_user=push_user,
|
||||
push_time=push_time,
|
||||
file_name=report.source_file or report.controller_class,
|
||||
)
|
||||
parts.append(path_md)
|
||||
parts.append("")
|
||||
|
||||
# 4. 普通参数变更(非路径变更)仍使用 model.md 格式
|
||||
if changed_reports:
|
||||
parts.append("# 【API参数变更通知】")
|
||||
parts.append(f"- **修改人:** {push_user}")
|
||||
parts.append(f"- **修改时间:** {push_time}")
|
||||
parts.append("")
|
||||
for report in changed_reports:
|
||||
parts.append(_format_endpoint_block(report))
|
||||
parts.append("")
|
||||
|
||||
if llm_summary:
|
||||
cleaned = llm_summary.strip()
|
||||
# 去掉 LLM 可能输出的「排除框架注入」类说明
|
||||
cleaned = re.sub(
|
||||
r"(排除Spring MVC框架自动注入的[^)]+)",
|
||||
"",
|
||||
cleaned,
|
||||
)
|
||||
cleaned = re.sub(
|
||||
r"排除Spring MVC框架自动注入的[`\w/]+[`\w/、/]*[。\.]?",
|
||||
"",
|
||||
cleaned,
|
||||
)
|
||||
if cleaned:
|
||||
parts.append("### <font color=\"comment\">【兼容性提示】</font>")
|
||||
parts.append(cleaned)
|
||||
|
||||
return "\n".join(parts).strip()
|
||||
|
||||
|
||||
def _split_markdown(text: str, max_len: int) -> List[str]:
|
||||
"""按 ### 标题块拆分超长 Markdown。"""
|
||||
if len(text) <= max_len:
|
||||
return [text]
|
||||
|
||||
lines = text.split("\n")
|
||||
chunks: List[str] = []
|
||||
current: List[str] = []
|
||||
|
||||
for line in lines:
|
||||
if line.startswith("### ") and current and len("\n".join(current)) > 200:
|
||||
chunks.append("\n".join(current))
|
||||
current = [line]
|
||||
else:
|
||||
current.append(line)
|
||||
if len("\n".join(current)) >= max_len:
|
||||
chunks.append("\n".join(current))
|
||||
current = []
|
||||
|
||||
if current:
|
||||
if chunks and len("\n".join(current)) < 200:
|
||||
chunks[-1] = chunks[-1] + "\n" + "\n".join(current)
|
||||
else:
|
||||
chunks.append("\n".join(current))
|
||||
|
||||
return chunks or [truncate_text(text)]
|
||||
|
||||
|
||||
def _post_wecom_markdown(webhook_url: str, content: str) -> bool:
|
||||
"""发送企微 Markdown 消息。"""
|
||||
if not webhook_url or "YOUR_WECOM_KEY" in webhook_url:
|
||||
print("[警告] 未配置有效的企业微信 Webhook URL。")
|
||||
print("--- 通知预览 ---")
|
||||
print(content[:1000])
|
||||
return False
|
||||
|
||||
payload = {
|
||||
"msgtype": "markdown",
|
||||
"markdown": {"content": truncate_text(content)},
|
||||
}
|
||||
|
||||
try:
|
||||
resp = requests.post(
|
||||
webhook_url,
|
||||
headers={"Content-Type": "application/json"},
|
||||
data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
|
||||
timeout=10,
|
||||
)
|
||||
if resp.status_code == 200 and resp.json().get("errcode", 0) == 0:
|
||||
return True
|
||||
print(f"[错误] 企微返回异常: {resp.status_code} {resp.text}")
|
||||
return False
|
||||
except requests.RequestException as exc:
|
||||
print(f"[错误] 发送企微消息失败: {exc}")
|
||||
return False
|
||||
|
||||
|
||||
def send_parameter_change_notification(
|
||||
webhook_url: str,
|
||||
reports: List[EndpointChangeReport],
|
||||
push_user: str,
|
||||
push_time: str,
|
||||
llm_review: Optional[str] = None,
|
||||
mentioned_users: Optional[List[str]] = None,
|
||||
) -> int:
|
||||
"""
|
||||
发送 Markdown 格式的接口变更通知。
|
||||
|
||||
严格按变更类型拆分,各自独立构建和发送企微通知:
|
||||
- 方法变更 → 独立调用 build_method_change_markdown
|
||||
- 路径变更(新增/修改/删除) → 独立调用 build_path_change_markdown
|
||||
- 参数变更 → 独立调用 _format_endpoint_block
|
||||
|
||||
不同类型之间完全互不干扰,各自走独立分支。
|
||||
"""
|
||||
if not reports and not llm_review:
|
||||
print("无接口参数变更,不发送到企业微信")
|
||||
return 0
|
||||
|
||||
# 按类型严格分组(互不重叠)
|
||||
method_changed_reports = [r for r in reports if r.is_method_changed]
|
||||
renamed_reports = [r for r in reports if r.is_renamed_endpoint]
|
||||
new_reports = [r for r in reports if r.is_new_endpoint]
|
||||
removed_reports = [r for r in reports if r.is_removed_endpoint]
|
||||
changed_reports = [
|
||||
r for r in reports
|
||||
if not r.is_new_endpoint
|
||||
and not r.is_removed_endpoint
|
||||
and not r.is_renamed_endpoint
|
||||
and not r.is_method_changed
|
||||
]
|
||||
|
||||
sent = 0
|
||||
|
||||
# ========== 1. 请求方式变更通知(独立分支) ==========
|
||||
for report in method_changed_reports:
|
||||
md = build_method_change_markdown(
|
||||
uri=report.uri,
|
||||
old_method=report.old_http_method or "?",
|
||||
new_method=report.http_method,
|
||||
push_user=push_user,
|
||||
push_time=push_time,
|
||||
file_name=report.source_file or report.controller_class,
|
||||
)
|
||||
if _post_wecom_markdown(webhook_url, md):
|
||||
sent += 1
|
||||
print(f"第 {sent} 条通知已发送到企业微信(请求方式变更)")
|
||||
|
||||
# ========== 2. 路径变更通知(新增/修改/删除) ==========
|
||||
# 新增接口
|
||||
for report in new_reports:
|
||||
md = build_path_change_markdown(
|
||||
old_uri="-",
|
||||
new_uri=report.uri,
|
||||
change_type="新增接口",
|
||||
push_user=push_user,
|
||||
push_time=push_time,
|
||||
file_name=report.source_file or report.controller_class,
|
||||
)
|
||||
if report.parameter_changes:
|
||||
param_section = "\n".join(_format_param_details_section(report)).strip()
|
||||
md = f"{md}\n\n{param_section}"
|
||||
if _post_wecom_markdown(webhook_url, md):
|
||||
sent += 1
|
||||
print(f"第 {sent} 条通知已发送到企业微信(新增接口)")
|
||||
|
||||
# 修改路径
|
||||
for report in renamed_reports:
|
||||
md = build_path_change_markdown(
|
||||
old_uri=report.old_uri or "-",
|
||||
new_uri=report.uri,
|
||||
change_type="修改路径",
|
||||
push_user=push_user,
|
||||
push_time=push_time,
|
||||
file_name=report.source_file or report.controller_class,
|
||||
)
|
||||
if _post_wecom_markdown(webhook_url, md):
|
||||
sent += 1
|
||||
print(f"第 {sent} 条通知已发送到企业微信(修改路径)")
|
||||
|
||||
# 删除接口
|
||||
for report in removed_reports:
|
||||
md = build_path_change_markdown(
|
||||
old_uri=report.uri,
|
||||
new_uri="已删除",
|
||||
change_type="删除接口",
|
||||
push_user=push_user,
|
||||
push_time=push_time,
|
||||
file_name=report.source_file or report.controller_class,
|
||||
)
|
||||
if _post_wecom_markdown(webhook_url, md):
|
||||
sent += 1
|
||||
print(f"第 {sent} 条通知已发送到企业微信(删除接口)")
|
||||
|
||||
# ========== 3. 参数变更通知(独立分支) ==========
|
||||
if changed_reports:
|
||||
# 构建参数变更通知(只包含参数变更报告,对齐 model.md)
|
||||
parts: List[str] = []
|
||||
parts.append("# 【API参数变更通知】")
|
||||
parts.append(f"- **修改人:** {push_user}")
|
||||
parts.append(f"- **修改时间:** {push_time}")
|
||||
parts.append("")
|
||||
for report in changed_reports:
|
||||
parts.append(_format_endpoint_block(report))
|
||||
parts.append("")
|
||||
if llm_review:
|
||||
parts.append("---")
|
||||
parts.append("### <font color=\"comment\">兼容性提示</font>")
|
||||
parts.append(llm_review.strip())
|
||||
|
||||
md = "\n".join(parts).strip()
|
||||
if _post_wecom_markdown(webhook_url, md):
|
||||
sent += 1
|
||||
print(f"第 {sent} 条通知已发送到企业微信(参数变更)")
|
||||
|
||||
if sent > 0:
|
||||
print(f"总共发送 {sent} 条通知到企业微信")
|
||||
return sent
|
||||
|
||||
|
||||
def build_path_change_markdown(
|
||||
old_uri: str,
|
||||
new_uri: str,
|
||||
change_type: str,
|
||||
push_user: str,
|
||||
push_time: str,
|
||||
file_name: str,
|
||||
) -> str:
|
||||
"""构建 API路径变更通知,完全匹配 model1.md 模板,并加强视觉区分。
|
||||
|
||||
支持的 change_type:
|
||||
- 新增接口 / 删除接口 / 修改路径 / 修改请求方式
|
||||
|
||||
改进点:
|
||||
- 标题使用【】风格
|
||||
- 头部信息缩进 + 颜色高亮
|
||||
- URI 详情使用列表(更直观)
|
||||
- 「修改请求方式」额外展示方法变更
|
||||
"""
|
||||
# 变更类型高亮
|
||||
type_highlight = f"<font color=\"warning\">**{change_type}**</font>"
|
||||
|
||||
# 全路径类名高亮
|
||||
class_highlight = f"<font color=\"info\">**{file_name}**</font>"
|
||||
|
||||
# 根据变更类型优化 URI 展示
|
||||
if change_type == "新增接口":
|
||||
old_display = "`-`"
|
||||
new_display = f"<font color=\"info\">**`{new_uri}`**</font> ← <font color=\"info\">**新增**</font>"
|
||||
elif change_type == "删除接口":
|
||||
old_display = f"<font color=\"warning\">**`{old_uri}`**</font> ← <font color=\"warning\">**已删除**</font>"
|
||||
new_display = "`已删除`"
|
||||
else: # 修改路径
|
||||
old_display = f"<font color=\"warning\">~~`{old_uri}`~~</font> ← <font color=\"warning\">**旧路径**</font>"
|
||||
new_display = f"<font color=\"info\">**`{new_uri}`**</font> ← <font color=\"info\">**新路径**</font>"
|
||||
|
||||
parts = [
|
||||
"# 【API路径变更通知】",
|
||||
"",
|
||||
f" 变更类型: {type_highlight}",
|
||||
f" 全路径类名: {class_highlight}",
|
||||
f" 修改人: {push_user}",
|
||||
f" 修改时间: {push_time}",
|
||||
"",
|
||||
"---------------------------------------",
|
||||
"",
|
||||
"#### 【URI变更详情】",
|
||||
f"- **原路径:** {old_display}",
|
||||
f"- **新路径:** {new_display}",
|
||||
"",
|
||||
]
|
||||
return "\n".join(parts).strip()
|
||||
|
||||
|
||||
def build_method_change_markdown(
|
||||
uri: str,
|
||||
old_method: str,
|
||||
new_method: str,
|
||||
push_user: str,
|
||||
push_time: str,
|
||||
file_name: str,
|
||||
) -> str:
|
||||
"""构建【API请求方式变更通知】独立模板。
|
||||
|
||||
格式参考 model1.md,但专门针对 HTTP 方法变更场景设计,
|
||||
突出「原请求方式 → 新请求方式」的对比。
|
||||
"""
|
||||
type_highlight = '<font color="warning">**修改请求方式**</font>'
|
||||
class_highlight = f'<font color="info">**{file_name}**</font>'
|
||||
uri_highlight = f'<font color="info">**`{uri}`**</font>'
|
||||
old_m = f'<font color="warning">**{old_method}**</font>'
|
||||
new_m = f'<font color="info">**{new_method}**</font>'
|
||||
|
||||
parts = [
|
||||
"# 【API请求方式变更通知】",
|
||||
"",
|
||||
f" 变更类型: {type_highlight}",
|
||||
f" 全路径类名: {class_highlight}",
|
||||
f" 修改人: {push_user}",
|
||||
f" 修改时间: {push_time}",
|
||||
"",
|
||||
"---------------------------------------",
|
||||
"",
|
||||
"#### 【请求方式变更详情】",
|
||||
f"- **URI:** {uri_highlight}",
|
||||
f"- **原请求方式:** {old_m}",
|
||||
f"- **新请求方式:** {new_m} ← <font color=\"info\">**请求方式已变更**</font>",
|
||||
"",
|
||||
]
|
||||
return "\n".join(parts).strip()
|
||||
|
||||
|
||||
def send_path_change_notification(
|
||||
webhook_url: str,
|
||||
old_uri: str,
|
||||
new_uri: str,
|
||||
change_type: str,
|
||||
push_user: str,
|
||||
push_time: str,
|
||||
file_name: str,
|
||||
) -> bool:
|
||||
"""发送路径变更通知。"""
|
||||
md = build_path_change_markdown(old_uri, new_uri, change_type, push_user, push_time, file_name)
|
||||
return _post_wecom_markdown(webhook_url, md)
|
||||
58
.gitea/checker/notify-templates/README.md
Normal file
58
.gitea/checker/notify-templates/README.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# 类变更通知模版
|
||||
|
||||
Push 触发 CI 后,按变更类的后缀(`Dto` / `Vo` / `Entity` / `Model`)选用对应模版生成企业微信 Markdown 通知。
|
||||
|
||||
## 企微语法说明
|
||||
|
||||
使用 webhook **`markdown`**(v1),支持 font 三色;**不支持无序列表**,故各项以**引用块 + 换行**分行展示。
|
||||
|
||||
| 语法 | 说明 |
|
||||
|------|------|
|
||||
| `#` / `##` / `###` | 标题(主区块用【】) |
|
||||
| `` `行内代码` `` | 字段名、URI |
|
||||
| `>` | 引用行(每项一行) |
|
||||
| `**bold**` | 头部四行、统计行加粗 |
|
||||
| `<font color="info">` | 绿:类名、新增、HTTP 方法、新类型 |
|
||||
| `<font color="comment">` | 灰:说明、路径、无影响 |
|
||||
| `<font color="warning">` | 橙:[修改]/[删除]、旧类型、统计数字 |
|
||||
|
||||
## 布局约定
|
||||
|
||||
1. **# 【类变更通知】** — 头部 4 项,每项一行 `>**标签: 值**`(加粗,冒号后两空格);变更对象括号内展示类中文说明(@Schema / Javadoc),无说明则仅类名
|
||||
2. **## 【对象变更细节】** — 统计行 + 每条变更单行(标签/说明/类型合并)
|
||||
3. **## 【影响范围】** — 各 ### 小节内,每项一行引用
|
||||
|
||||
## 公共头部
|
||||
|
||||
```
|
||||
# 【类变更通知】
|
||||
|
||||
> **变更对象: <font color="info">ApplyAttendanceChangeDto</font>(<font color="comment">流程表单 [出勤变更]</font>)**
|
||||
> **修改人: <font color="comment">dongzi</font>**
|
||||
> **时间: <font color="comment">2026-06-07 20:14:35</font>**
|
||||
> **路径: <font color="comment">jnpf-ftb/.../ApplyAttendanceChangeDto.java</font>**
|
||||
```
|
||||
|
||||
## 影响范围
|
||||
|
||||
| 类类型 | request | response | 类转换 |
|
||||
|--------|:-------:|:--------:|:------:|
|
||||
| Dto | ✅ | ✅ | ✅ |
|
||||
| Vo | ✅ | ✅ | ✅ |
|
||||
| Entity / Model | ❌ | ❌ | ✅ |
|
||||
|
||||
Dto/Vo 均固定展示 request、response 两栏;无匹配接口时显示「无」。类转换栏仅在 `dto_entity_conversion.enabled: true` 时展示,关闭时不出现该小节。实际影响由接口索引 + 嵌套关系传播,不假定 Dto 仅 request、Vo 仅 response。
|
||||
|
||||
## 模版文件
|
||||
|
||||
| 文件 | 场景 |
|
||||
|------|------|
|
||||
| [field-description.md](./field-description.md) | 字段说明与行格式 |
|
||||
| [dto.md](./dto.md) | Dto |
|
||||
| [vo.md](./vo.md) | Vo |
|
||||
| [entity.md](./entity.md) | Entity |
|
||||
| [model.md](./model.md) | Model |
|
||||
|
||||
## 实现
|
||||
|
||||
`WeComNotifier.buildMarkdown()` · 消息类型 `markdown` · 路径取自 `ClassChangeReport.sourceFile`
|
||||
106
.gitea/checker/notify-templates/dto.md
Normal file
106
.gitea/checker/notify-templates/dto.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# Dto 类变更通知模版
|
||||
|
||||
**识别规则**:类名以 `Dto` 结尾。
|
||||
**影响范围**:request + response + 类转换(无匹配时对应栏显示「无」)。
|
||||
**嵌套标识**:被其他 Dto/Vo 嵌套时在「变更对象」行追加 `(嵌套对象)`;若同时直接作接口入参/返回值根类型,再追加 `(顶层对象)`。纯顶层不标注。
|
||||
|
||||
---
|
||||
|
||||
## 完整示例(字段修改)
|
||||
|
||||
```
|
||||
# 【类变更通知】
|
||||
|
||||
> **变更对象: <font color="info">ApplyAttendanceChangeDto</font>(<font color="comment">流程表单 [出勤变更]</font>)**
|
||||
> **修改人: <font color="comment">dongzi</font>**
|
||||
> **时间: <font color="comment">2026-06-07 20:14:35</font>**
|
||||
> **路径: <font color="comment">jnpf-ftb/jnpf-ftb-entity/src/main/java/jnpf/model/workflow/dto/ApplyAttendanceChangeDto.java</font>**
|
||||
|
||||
## 【对象变更细节】
|
||||
|
||||
> **共 <font color="warning">4</font> 项变更**
|
||||
|
||||
> <font color="warning">[修改]</font> `taskId` 说明: <font color="comment">流程主键</font> 类型: <font color="warning">Integer</font> → <font color="info">String</font>
|
||||
|
||||
> <font color="warning">[修改]</font> `changeUserId` 说明: <font color="comment">变更人员id</font> 类型: <font color="warning">String</font> → <font color="info">Integer</font>
|
||||
|
||||
> <font color="info">[新增]</font> `storeId` 说明: <font color="comment">门店ID</font> 类型: <font color="info">String</font>
|
||||
|
||||
> <font color="warning">[删除]</font> `oldField` 说明: <font color="comment">已废弃字段</font> 类型: <font color="warning">Integer</font>
|
||||
|
||||
## 【影响范围】
|
||||
|
||||
### 影响 request 接口
|
||||
> <font color="info">POST</font> `/apply/clockIn`
|
||||
> <font color="info">PUT</font> `/apply/clockIn/{id}`
|
||||
|
||||
### 类转换影响
|
||||
> <font color="comment">未开启检测</font>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 示例(嵌套对象)
|
||||
|
||||
```
|
||||
> **变更对象: <font color="info">UserSelfDto</font>(<font color="comment">嵌套对象</font>)**
|
||||
```
|
||||
|
||||
若该类同时直接出现在某接口 `@RequestBody` 或返回值类型中:
|
||||
|
||||
```
|
||||
> **变更对象: <font color="info">SomeDto</font>(<font color="comment">说明</font>)(<font color="comment">嵌套对象</font>)(<font color="comment">顶层对象</font>)**
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 示例(类删除)
|
||||
|
||||
```
|
||||
## 【对象变更细节】
|
||||
|
||||
> <font color="warning">[已删除]</font> <font color="comment">该类文件已被移除</font>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 示例(仅类名变更)
|
||||
|
||||
```
|
||||
## 【对象变更细节】
|
||||
|
||||
> <font color="warning">[类名变更]</font> <font color="comment">ApplyAttendanceChangeDto</font> → <font color="info">ApplyAttendanceChangeNewDto</font>
|
||||
> <font color="comment">字段无变化</font>
|
||||
|
||||
## 【影响范围】
|
||||
|
||||
### 影响 request 接口
|
||||
> <font color="info">POST</font> `/apply/clockIn`
|
||||
> <font color="info">PUT</font> `/apply/clockIn/{id}`
|
||||
|
||||
### 类转换影响
|
||||
> <font color="comment">未开启检测</font>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 示例(类名 + 字段同时变更)
|
||||
|
||||
```
|
||||
## 【对象变更细节】
|
||||
|
||||
> <font color="warning">[类名变更]</font> <font color="comment">ApplyAttendanceChangeDto</font> → <font color="info">ApplyAttendanceChangeNewDto</font>
|
||||
|
||||
> **共 <font color="warning">1</font> 项变更**
|
||||
|
||||
> <font color="warning">[修改]</font> `changeMinute` 说明: <font color="comment">变更分钟数</font> 类型: <font color="warning">Integer</font> → <font color="info">String</font>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 占位符
|
||||
|
||||
| 占位符 | 来源 |
|
||||
|--------|------|
|
||||
| 路径 | Git 相对路径,`ClassChangeReport.sourceFile` |
|
||||
| 说明 | `@Schema` / 注释 |
|
||||
49
.gitea/checker/notify-templates/entity.md
Normal file
49
.gitea/checker/notify-templates/entity.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# Entity 类变更通知模版
|
||||
|
||||
**识别规则**:类名以 `Entity` 结尾。
|
||||
**影响范围**:仅类转换(不展示 request/response 接口)。
|
||||
|
||||
---
|
||||
|
||||
## 完整示例(字段修改)
|
||||
|
||||
```
|
||||
# 【类变更通知】
|
||||
|
||||
> **变更对象: <font color="info">TrainingPositionEntity</font>(<font color="comment">培训岗位</font>)**
|
||||
> **修改人: <font color="comment">张三</font>**
|
||||
> **时间: <font color="comment">2026-06-07 14:30:00</font>**
|
||||
> **路径: <font color="comment">jnpf-ftb/jnpf-ftb-entity/src/main/java/jnpf/entity/training/TrainingPositionEntity.java</font>**
|
||||
|
||||
## 【对象变更细节】
|
||||
|
||||
> **共 <font color="warning">1</font> 项变更**
|
||||
|
||||
> <font color="warning">[修改]</font> `createTime` 说明: <font color="comment">创建时间</font> 类型: <font color="warning">Date</font> → <font color="info">LocalDateTime</font>
|
||||
|
||||
## 【影响范围】
|
||||
|
||||
### 类转换影响
|
||||
> Entity: <font color="info">TrainingPositionEntity</font>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 示例(类删除)
|
||||
|
||||
```
|
||||
## 【对象变更细节】
|
||||
|
||||
> <font color="warning">[已删除]</font> <font color="comment">该类文件已被移除</font>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 示例(仅类名变更)
|
||||
|
||||
```
|
||||
## 【对象变更细节】
|
||||
|
||||
> <font color="warning">[类名变更]</font> <font color="comment">TrainingPositionEntity</font> → <font color="info">TrainingPositionNewEntity</font>
|
||||
> <font color="comment">字段无变化</font>
|
||||
```
|
||||
57
.gitea/checker/notify-templates/field-description.md
Normal file
57
.gitea/checker/notify-templates/field-description.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# 字段说明规则
|
||||
|
||||
字段变更采用 **引用块 + 单行合并 + font 颜色**,遵循企微 `markdown` v1(不支持列表)。
|
||||
|
||||
## 说明提取优先级
|
||||
|
||||
| 优先级 | 来源 |
|
||||
|:------:|------|
|
||||
| 1 | `@Schema(description = "...")` |
|
||||
| 2 | `@ApiModelProperty` |
|
||||
| 3 | `/** ... */` 字段注释 |
|
||||
| 4 | 空串 |
|
||||
|
||||
## 字段变更行格式
|
||||
|
||||
每条变更占**一行**,标签、说明、类型横向排列,冒号后两空格:
|
||||
|
||||
```
|
||||
> **共 <font color="warning">2</font> 项变更**
|
||||
|
||||
> <font color="warning">[重命名]</font> <font color="comment">taskId</font> → <font color="info">taskIds</font> 说明: <font color="comment">流程主键</font>
|
||||
|
||||
> <font color="info">[新增]</font> `applyUserList` 说明: <font color="comment">申请人员集合</font> 类型: <font color="info">List<String></font>
|
||||
|
||||
> <font color="warning">[删除]</font> `applyUser1` 说明: <font color="comment">申请人员</font> 类型: <font color="warning">Integer</font>
|
||||
```
|
||||
|
||||
| 操作 | 标签 | 类型段 |
|
||||
|------|------|--------|
|
||||
| 新增 | info `[新增]` | 始终展示,绿色 `info` |
|
||||
| 删除 | warning `[删除]` | 始终展示,橙色 `warning` |
|
||||
| 修改 | warning `[修改]` | 始终展示,旧类型 warning → 新类型 info |
|
||||
| 重命名 | warning `[重命名]` | 始终展示;仅改名时单色 info,改类型时 old → new |
|
||||
|
||||
### 重命名配对规则
|
||||
|
||||
- 删除+新增且**类型相同、说明相同**(非空)→ `[重命名]`
|
||||
- 删除+新增且**说明相同但类型不同** → `[重命名]` + 类型行
|
||||
- 说明均为空时也可配对
|
||||
- 说明不同则不配对,保持删除+新增
|
||||
|
||||
- 统计行加粗,数字用 warning(橙色),文案为「共 N 项变更」(不含「字段」)
|
||||
- 多条变更之间用**空行**分隔
|
||||
- 说明为空时显示 `<font color="comment">(无说明)</font>`
|
||||
- 头部四行加粗,用 `quoteKvBold`;不要在 `<font>` 内嵌 `**bold**`
|
||||
|
||||
## 接口行格式
|
||||
|
||||
```
|
||||
> <font color="info">POST</font> `/apply/clockIn`
|
||||
```
|
||||
|
||||
## 实现
|
||||
|
||||
- `ClassFieldParser.extractFieldLabel()`
|
||||
- `FieldDiffEngine` — 类型变化产生 `[修改]`,说明匹配的删除+新增合并为 `[重命名]`
|
||||
- `WeComNotifier.formatFieldChange()`
|
||||
49
.gitea/checker/notify-templates/model.md
Normal file
49
.gitea/checker/notify-templates/model.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# Model 类变更通知模版
|
||||
|
||||
**识别规则**:类名以 `Model` 结尾。
|
||||
**影响范围**:仅类转换(不展示 request/response 接口)。
|
||||
|
||||
---
|
||||
|
||||
## 完整示例(字段修改)
|
||||
|
||||
```
|
||||
# 【类变更通知】
|
||||
|
||||
> **变更对象: <font color="info">AttendanceRuleModel</font>(<font color="comment">考勤规则</font>)**
|
||||
> **修改人: <font color="comment">张三</font>**
|
||||
> **时间: <font color="comment">2026-06-07 14:30:00</font>**
|
||||
> **路径: <font color="comment">jnpf-ftb/jnpf-ftb-entity/src/main/java/jnpf/model/attendance/AttendanceRuleModel.java</font>**
|
||||
|
||||
## 【对象变更细节】
|
||||
|
||||
> **共 <font color="warning">1</font> 项变更**
|
||||
|
||||
> <font color="warning">[修改]</font> `ruleType` 说明: <font color="comment">规则类型</font> 类型: <font color="warning">Date</font> → <font color="info">String</font>
|
||||
|
||||
## 【影响范围】
|
||||
|
||||
### 类转换影响
|
||||
> Entity: <font color="info">AttendanceRuleEntity</font>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 示例(类删除)
|
||||
|
||||
```
|
||||
## 【对象变更细节】
|
||||
|
||||
> <font color="warning">[已删除]</font> <font color="comment">该类文件已被移除</font>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 示例(仅类名变更)
|
||||
|
||||
```
|
||||
## 【对象变更细节】
|
||||
|
||||
> <font color="warning">[类名变更]</font> <font color="comment">AttendanceRuleModel</font> → <font color="info">AttendanceRuleNewModel</font>
|
||||
> <font color="comment">字段无变化</font>
|
||||
```
|
||||
63
.gitea/checker/notify-templates/vo.md
Normal file
63
.gitea/checker/notify-templates/vo.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# Vo 类变更通知模版
|
||||
|
||||
**识别规则**:类名以 `Vo` 或 `VO` 结尾。
|
||||
**影响范围**:request + response + 类转换(无匹配时对应栏显示「无」)。
|
||||
**嵌套标识**:规则同 Dto——仅嵌套时标注 `(嵌套对象)`,嵌套且直接作接口根类型时追加 `(顶层对象)`。
|
||||
|
||||
---
|
||||
|
||||
## 完整示例(字段修改)
|
||||
|
||||
```
|
||||
# 【类变更通知】
|
||||
|
||||
> **变更对象: <font color="info">AttendanceDetailVo</font>(<font color="comment">考勤详情</font>)**
|
||||
> **修改人: <font color="comment">张三</font>**
|
||||
> **时间: <font color="comment">2026-06-07 14:30:00</font>**
|
||||
> **路径: <font color="comment">jnpf-ftb/jnpf-ftb-entity/src/main/java/jnpf/model/attendance/vo/AttendanceDetailVo.java</font>**
|
||||
|
||||
## 【对象变更细节】
|
||||
|
||||
> **共 <font color="warning">2</font> 项变更**
|
||||
|
||||
> <font color="info">[新增]</font> `overtimeHours` 说明: <font color="comment">加班时长</font>
|
||||
|
||||
> <font color="warning">[修改]</font> `status` 说明: <font color="comment">考勤状态</font> 类型: <font color="warning">Integer</font> → <font color="info">String</font>
|
||||
|
||||
## 【影响范围】
|
||||
|
||||
### 影响 response 接口
|
||||
> <font color="info">GET</font> `/api/attendance/detail`
|
||||
|
||||
### 类转换影响
|
||||
> Entity: <font color="info">AttendanceDetailEntity</font>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 示例(类删除)
|
||||
|
||||
```
|
||||
## 【对象变更细节】
|
||||
|
||||
> <font color="warning">[已删除]</font> <font color="comment">该类文件已被移除</font>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 示例(仅类名变更)
|
||||
|
||||
```
|
||||
## 【对象变更细节】
|
||||
|
||||
> <font color="warning">[类名变更]</font> <font color="comment">AttendanceDetailVo</font> → <font color="info">AttendanceDetailNewVo</font>
|
||||
> <font color="comment">字段无变化</font>
|
||||
|
||||
## 【影响范围】
|
||||
|
||||
### 影响 response 接口
|
||||
> <font color="info">GET</font> `/api/attendance/detail`
|
||||
|
||||
### 类转换影响
|
||||
> <font color="comment">未开启检测</font>
|
||||
```
|
||||
82
.gitea/checker/pom.xml
Normal file
82
.gitea/checker/pom.xml
Normal file
@@ -0,0 +1,82 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>com.codechecker</groupId>
|
||||
<artifactId>code-checker</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<properties>
|
||||
<maven.compiler.source>11</maven.compiler.source>
|
||||
<maven.compiler.target>11</maven.compiler.target>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<javaparser.version>3.25.10</javaparser.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.github.javaparser</groupId>
|
||||
<artifactId>javaparser-symbol-solver-core</artifactId>
|
||||
<version>${javaparser.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.yaml</groupId>
|
||||
<artifactId>snakeyaml</artifactId>
|
||||
<version>2.2</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.squareup.okhttp3</groupId>
|
||||
<artifactId>okhttp</artifactId>
|
||||
<version>4.12.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>info.picocli</groupId>
|
||||
<artifactId>picocli</artifactId>
|
||||
<version>4.7.6</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<finalName>code-checker</finalName>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>3.13.0</version>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-shade-plugin</artifactId>
|
||||
<version>3.6.0</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<phase>package</phase>
|
||||
<goals>
|
||||
<goal>shade</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<transformers>
|
||||
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
|
||||
<mainClass>com.codechecker.CodeCheckMain</mainClass>
|
||||
</transformer>
|
||||
</transformers>
|
||||
<filters>
|
||||
<filter>
|
||||
<artifact>*:*</artifact>
|
||||
<excludes>
|
||||
<exclude>META-INF/*.SF</exclude>
|
||||
<exclude>META-INF/*.DSA</exclude>
|
||||
<exclude>META-INF/*.RSA</exclude>
|
||||
</excludes>
|
||||
</filter>
|
||||
</filters>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -1,9 +0,0 @@
|
||||
# Python 依赖(版本锁定,避免 CI pip 冲突)
|
||||
PyYAML==6.0.2
|
||||
requests==2.32.3
|
||||
charset-normalizer==3.4.1
|
||||
urllib3==2.2.3
|
||||
certifi==2024.8.30
|
||||
idna==3.10
|
||||
javalang==0.13.0
|
||||
six==1.16.0
|
||||
178
.gitea/checker/src/main/java/com/codechecker/CodeCheckMain.java
Normal file
178
.gitea/checker/src/main/java/com/codechecker/CodeCheckMain.java
Normal file
@@ -0,0 +1,178 @@
|
||||
package com.codechecker;
|
||||
|
||||
import com.codechecker.analyzer.ClassChangeAnalyzer;
|
||||
import com.codechecker.analyzer.DtoNestIndex;
|
||||
import com.codechecker.analyzer.EndpointIndexBuilder;
|
||||
import com.codechecker.api.analyzer.ApiChangeAnalyzer;
|
||||
import com.codechecker.api.analyzer.DtoImpactedApiAnalyzer;
|
||||
import com.codechecker.api.model.ApiChangeKind;
|
||||
import com.codechecker.api.model.EndpointChangeReport;
|
||||
import com.codechecker.api.notify.ApiChangeNotifier;
|
||||
import com.codechecker.api.scanner.ApiFileChangeScanner;
|
||||
import com.codechecker.config.AppConfig;
|
||||
import com.codechecker.git.GitChangeScanner;
|
||||
import com.codechecker.model.ApiEndpoint;
|
||||
import com.codechecker.model.ClassChangeReport;
|
||||
import com.codechecker.notify.OverlapNotificationFilter;
|
||||
import com.codechecker.notify.WeComNotifier;
|
||||
import picocli.CommandLine;
|
||||
import picocli.CommandLine.Command;
|
||||
import picocli.CommandLine.Option;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.Callable;
|
||||
|
||||
/**
|
||||
* CLI 入口:加载配置 → 扫描 git 变更 → 分析影响 → 输出/发送企微通知。
|
||||
*/
|
||||
@Command(name = "code-checker", mixinStandardHelpOptions = true,
|
||||
description = "检测类变更与 API 变更并发送企业微信通知")
|
||||
public class CodeCheckMain implements Callable<Integer> {
|
||||
@Option(names = "--config", required = true, description = "配置文件路径")
|
||||
private Path config;
|
||||
|
||||
@Option(names = "--repo-root", required = true, description = "仓库根目录")
|
||||
private Path repoRoot;
|
||||
|
||||
@Option(names = "--old-sha", required = true, description = "旧提交 SHA")
|
||||
private String oldSha;
|
||||
|
||||
@Option(names = "--new-sha", required = true, description = "新提交 SHA")
|
||||
private String newSha;
|
||||
|
||||
@Option(names = "--modifier", required = true, description = "修改人")
|
||||
private String modifier;
|
||||
|
||||
@Option(names = "--modify-time", required = true, description = "修改时间")
|
||||
private String modifyTime;
|
||||
|
||||
/** 程序入口 */
|
||||
public static void main(String[] args) {
|
||||
int exitCode = new CommandLine(new CodeCheckMain()).execute(args);
|
||||
System.exit(exitCode);
|
||||
}
|
||||
|
||||
/** 主流程:类变更与 API 变更检测,支持 Dto 跟进与重叠通知策略 */
|
||||
@Override
|
||||
public Integer call() throws Exception {
|
||||
AppConfig appConfig = AppConfig.load(config.toAbsolutePath());
|
||||
if (!appConfig.isMasterEnabled()) {
|
||||
System.out.println("变更检测已全部关闭(checker.enabled=false)");
|
||||
return 0;
|
||||
}
|
||||
|
||||
GitChangeScanner gitScanner = new GitChangeScanner(repoRoot.toAbsolutePath());
|
||||
DtoNestIndex nestIndex = DtoNestIndex.build(repoRoot.toAbsolutePath(), appConfig);
|
||||
List<ClassChangeReport> classReports = List.of();
|
||||
List<EndpointChangeReport> apiReports = List.of();
|
||||
|
||||
if (appConfig.isClassCheckEnabled()) {
|
||||
classReports = analyzeClassChanges(appConfig, gitScanner, nestIndex);
|
||||
} else {
|
||||
System.out.println("类变更检测已关闭(class_check.enabled=false)");
|
||||
}
|
||||
|
||||
if (appConfig.isApiCheckEnabled()) {
|
||||
apiReports = analyzeApiChanges(appConfig, gitScanner, classReports, nestIndex);
|
||||
} else {
|
||||
System.out.println("API 变更检测已关闭(api_check.enabled=false)");
|
||||
}
|
||||
|
||||
OverlapNotificationFilter.FilterResult filtered = OverlapNotificationFilter.apply(
|
||||
classReports, apiReports, appConfig.getDtoOverlapMode(), nestIndex);
|
||||
int totalSent = sendClassNotifications(appConfig, filtered.classReports())
|
||||
+ sendApiNotifications(appConfig, filtered.apiReports());
|
||||
|
||||
if (totalSent == 0 && appConfig.isOnlyOnChange()) {
|
||||
System.out.println("无变更,静默退出");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private List<ClassChangeReport> analyzeClassChanges(AppConfig appConfig, GitChangeScanner gitScanner,
|
||||
DtoNestIndex nestIndex) throws Exception {
|
||||
System.out.println("=== 类变更检测 ===");
|
||||
EndpointIndexBuilder indexBuilder = new EndpointIndexBuilder();
|
||||
Map<String, ApiEndpoint> endpointIndex = indexBuilder.buildIndex(repoRoot.toAbsolutePath(), appConfig);
|
||||
System.out.println("已索引接口数量: " + endpointIndex.size());
|
||||
|
||||
ClassChangeAnalyzer analyzer = new ClassChangeAnalyzer(gitScanner);
|
||||
List<ClassChangeReport> reports = analyzer.analyze(
|
||||
repoRoot.toAbsolutePath(), appConfig, oldSha, newSha, endpointIndex, nestIndex);
|
||||
System.out.println("检测到需通知的类变更数量: " + reports.size());
|
||||
return reports;
|
||||
}
|
||||
|
||||
private List<EndpointChangeReport> analyzeApiChanges(AppConfig appConfig, GitChangeScanner gitScanner,
|
||||
List<ClassChangeReport> classReports,
|
||||
DtoNestIndex nestIndex) throws Exception {
|
||||
System.out.println("=== API 变更检测 ===");
|
||||
ApiFileChangeScanner fileScanner = new ApiFileChangeScanner(gitScanner);
|
||||
Set<String> changedApiFiles = new LinkedHashSet<>(fileScanner.scanChangedFiles(
|
||||
repoRoot.toAbsolutePath(), appConfig.getAllApiScanDirs(), oldSha, newSha));
|
||||
|
||||
ApiChangeAnalyzer analyzer = new ApiChangeAnalyzer(gitScanner);
|
||||
List<EndpointChangeReport> reports = new ArrayList<>();
|
||||
if (!changedApiFiles.isEmpty()) {
|
||||
reports.addAll(analyzer.analyze(repoRoot.toAbsolutePath(), appConfig, oldSha, newSha));
|
||||
}
|
||||
|
||||
if (appConfig.isDtoApiFollowUpEnabled() && !classReports.isEmpty()) {
|
||||
DtoImpactedApiAnalyzer dtoAnalyzer = new DtoImpactedApiAnalyzer(gitScanner);
|
||||
List<EndpointChangeReport> followUpReports = dtoAnalyzer.analyze(
|
||||
repoRoot.toAbsolutePath(), appConfig, oldSha, newSha, classReports, changedApiFiles, nestIndex);
|
||||
if (!followUpReports.isEmpty()) {
|
||||
System.out.println("Dto 跟进检测到 API 参数变更数量: " + followUpReports.size());
|
||||
reports.addAll(followUpReports);
|
||||
}
|
||||
}
|
||||
|
||||
reports = dedupeApiReports(reports);
|
||||
System.out.println("检测到需通知的 API 变更数量: " + reports.size());
|
||||
return reports;
|
||||
}
|
||||
|
||||
private List<EndpointChangeReport> dedupeApiReports(List<EndpointChangeReport> reports) {
|
||||
Map<String, EndpointChangeReport> merged = new LinkedHashMap<>();
|
||||
for (EndpointChangeReport report : reports) {
|
||||
String key = report.getChangeKind() + "|" + report.getHttpMethod() + "|" + report.getUri();
|
||||
EndpointChangeReport existing = merged.get(key);
|
||||
if (existing == null) {
|
||||
merged.put(key, report);
|
||||
continue;
|
||||
}
|
||||
if (report.getChangeKind() == ApiChangeKind.PARAM_CHANGED
|
||||
&& existing.getChangeKind() == ApiChangeKind.PARAM_CHANGED) {
|
||||
report.getParameterChanges().forEach(existing::addParameterChange);
|
||||
}
|
||||
}
|
||||
return new ArrayList<>(merged.values());
|
||||
}
|
||||
|
||||
private int sendClassNotifications(AppConfig appConfig, List<ClassChangeReport> reports) {
|
||||
if (reports.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
WeComNotifier notifier = new WeComNotifier();
|
||||
if (appConfig.isWecomEnabled()) {
|
||||
return notifier.sendAll(appConfig.getWecomWebhookUrl(), reports, modifier, modifyTime);
|
||||
}
|
||||
notifier.logAll(reports, modifier, modifyTime);
|
||||
return reports.size();
|
||||
}
|
||||
|
||||
private int sendApiNotifications(AppConfig appConfig, List<EndpointChangeReport> reports) {
|
||||
if (reports.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
ApiChangeNotifier notifier = new ApiChangeNotifier();
|
||||
return notifier.sendAll(appConfig.getWecomWebhookUrl(), reports, modifier, modifyTime,
|
||||
appConfig.isWecomEnabled());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package com.codechecker.analyzer;
|
||||
|
||||
import com.codechecker.config.AppConfig;
|
||||
import com.codechecker.git.GitChangeScanner;
|
||||
import com.codechecker.model.ChangedClassFile;
|
||||
import com.codechecker.model.ClassChangeKind;
|
||||
import com.codechecker.model.ClassChangeReport;
|
||||
import com.codechecker.model.FieldChange;
|
||||
import com.codechecker.model.FieldInfo;
|
||||
import com.codechecker.parser.ClassDeclParser;
|
||||
import com.codechecker.parser.ClassFieldParser;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 编排 git 扫描、字段 diff、影响分析,生成待通知的 ClassChangeReport 列表。
|
||||
*/
|
||||
public class ClassChangeAnalyzer {
|
||||
private final GitChangeScanner gitScanner;
|
||||
private final ClassFieldParser classFieldParser = new ClassFieldParser();
|
||||
private final FieldDiffEngine fieldDiffEngine = new FieldDiffEngine();
|
||||
private final ImpactAnalyzer impactAnalyzer = new ImpactAnalyzer();
|
||||
private final ClassDeclParser classDeclParser = new ClassDeclParser();
|
||||
|
||||
public ClassChangeAnalyzer(GitChangeScanner gitScanner) {
|
||||
this.gitScanner = gitScanner;
|
||||
}
|
||||
|
||||
/** 扫描变更文件并逐条分析,无实质变更的 MODIFIED 会被跳过 */
|
||||
public List<ClassChangeReport> analyze(Path repoRoot, AppConfig config, String oldSha, String newSha,
|
||||
Map<String, com.codechecker.model.ApiEndpoint> endpointIndex,
|
||||
DtoNestIndex nestIndex) throws IOException {
|
||||
List<ChangedClassFile> changedFiles = gitScanner.scanChangedClasses(oldSha, newSha);
|
||||
List<ClassChangeReport> reports = new ArrayList<>();
|
||||
|
||||
for (ChangedClassFile changedFile : changedFiles) {
|
||||
if (changedFile.getStatus() == ChangedClassFile.ChangeStatus.DELETED) {
|
||||
reports.add(analyzeDeleted(changedFile, config, repoRoot, oldSha, endpointIndex, nestIndex));
|
||||
continue;
|
||||
}
|
||||
ClassChangeReport report = analyzeModifiedOrRenamed(changedFile, config, repoRoot, oldSha, newSha,
|
||||
endpointIndex, nestIndex);
|
||||
if (report != null) {
|
||||
reports.add(report);
|
||||
}
|
||||
}
|
||||
return reports;
|
||||
}
|
||||
|
||||
/** 处理删除:标记 DELETED 并分析影响(基于旧源码) */
|
||||
private ClassChangeReport analyzeDeleted(ChangedClassFile changedFile, AppConfig config, Path repoRoot,
|
||||
String oldSha, Map<String, com.codechecker.model.ApiEndpoint> endpointIndex,
|
||||
DtoNestIndex nestIndex)
|
||||
throws IOException {
|
||||
String path = changedFile.getRelativePath();
|
||||
String oldSource = gitScanner.readFileAtCommit(oldSha, path);
|
||||
|
||||
String classDescription = classDeclParser.extractClassDescription(
|
||||
oldSource, changedFile.getClassName());
|
||||
|
||||
ClassChangeReport report = new ClassChangeReport(
|
||||
changedFile.getClassName(),
|
||||
null,
|
||||
changedFile.getClassType(),
|
||||
ClassChangeKind.DELETED,
|
||||
path,
|
||||
config.isDtoEntityConversionEnabled(),
|
||||
classDescription
|
||||
);
|
||||
impactAnalyzer.analyze(report, endpointIndex, config, repoRoot, oldSource, oldSource, nestIndex);
|
||||
return report;
|
||||
}
|
||||
|
||||
/** 处理修改/重命名:字段 diff → 判定 changeKind → 影响分析 */
|
||||
private ClassChangeReport analyzeModifiedOrRenamed(ChangedClassFile changedFile, AppConfig config,
|
||||
Path repoRoot, String oldSha, String newSha,
|
||||
Map<String, com.codechecker.model.ApiEndpoint> endpointIndex,
|
||||
DtoNestIndex nestIndex)
|
||||
throws IOException {
|
||||
String oldPath = changedFile.pathForOldCommit();
|
||||
String newPath = changedFile.getRelativePath();
|
||||
|
||||
String oldSource = gitScanner.readFileAtCommit(oldSha, oldPath);
|
||||
String newSource = gitScanner.readFileAtCommit(newSha, newPath);
|
||||
if (newSource == null || newSource.isBlank()) {
|
||||
newSource = gitScanner.readFileAtHead(newPath);
|
||||
}
|
||||
|
||||
String oldFallback = ClassDeclParser.classNameFromPath(oldPath);
|
||||
String newFallback = ClassDeclParser.classNameFromPath(newPath);
|
||||
String oldClassName = changedFile.getOldClassName() != null
|
||||
? changedFile.getOldClassName()
|
||||
: classDeclParser.resolveClassName(oldSource, oldFallback);
|
||||
String newClassName = classDeclParser.resolveClassName(newSource, newFallback);
|
||||
|
||||
List<FieldInfo> oldFields = classFieldParser.parseFields(oldSource, oldClassName);
|
||||
List<FieldInfo> newFields = classFieldParser.parseFields(newSource, newClassName);
|
||||
List<FieldChange> fieldChanges = fieldDiffEngine.diff(oldFields, newFields);
|
||||
|
||||
boolean renamed = !oldClassName.equals(newClassName);
|
||||
ClassChangeKind changeKind;
|
||||
if (renamed && fieldChanges.isEmpty()) {
|
||||
changeKind = ClassChangeKind.RENAME_ONLY;
|
||||
} else if (renamed) {
|
||||
changeKind = ClassChangeKind.RENAME_AND_FIELDS;
|
||||
} else if (!fieldChanges.isEmpty()) {
|
||||
changeKind = ClassChangeKind.FIELDS_ONLY;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
String classDescription = classDeclParser.extractClassDescription(newSource, newClassName);
|
||||
|
||||
ClassChangeReport report = new ClassChangeReport(
|
||||
newClassName,
|
||||
renamed ? oldClassName : null,
|
||||
changedFile.getClassType(),
|
||||
changeKind,
|
||||
newPath,
|
||||
config.isDtoEntityConversionEnabled(),
|
||||
classDescription
|
||||
);
|
||||
fieldChanges.forEach(report::addFieldChange);
|
||||
impactAnalyzer.analyze(report, endpointIndex, config, repoRoot, newSource, oldSource, nestIndex);
|
||||
return report;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package com.codechecker.analyzer;
|
||||
|
||||
import com.codechecker.config.AppConfig;
|
||||
import com.codechecker.model.ClassType;
|
||||
import com.codechecker.model.FieldInfo;
|
||||
import com.codechecker.parser.ClassFieldParser;
|
||||
import com.codechecker.parser.TypeNameUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* Dto/Vo 嵌套关系索引:反向查找祖先容器(用于影响分析与 API 跟进)。
|
||||
*/
|
||||
public class DtoNestIndex {
|
||||
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 int maxDepth;
|
||||
private final Map<String, Set<String>> ancestorsOf = new LinkedHashMap<>();
|
||||
private final Map<String, String> sourceByClass = new HashMap<>();
|
||||
|
||||
private DtoNestIndex(int maxDepth) {
|
||||
this.maxDepth = maxDepth;
|
||||
}
|
||||
|
||||
public static DtoNestIndex build(Path repoRoot, AppConfig config) throws IOException {
|
||||
DtoNestIndex index = new DtoNestIndex(config.getNestMaxDepth());
|
||||
ClassFieldParser fieldParser = new ClassFieldParser();
|
||||
for (String dir : config.getModelDirs()) {
|
||||
Path root = repoRoot.resolve(dir.replace('\\', '/'));
|
||||
if (!Files.exists(root)) {
|
||||
continue;
|
||||
}
|
||||
try (Stream<Path> paths = Files.walk(root)) {
|
||||
paths.filter(path -> path.toString().endsWith(".java"))
|
||||
.forEach(path -> {
|
||||
String className = path.getFileName().toString().replace(".java", "");
|
||||
if (ClassType.fromClassName(className) != ClassType.DTO
|
||||
&& ClassType.fromClassName(className) != ClassType.VO) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
String source = Files.readString(path, StandardCharsets.UTF_8);
|
||||
index.sourceByClass.put(className, source);
|
||||
} catch (IOException ignored) {
|
||||
// 跳过无法读取的文件
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
for (Map.Entry<String, String> entry : index.sourceByClass.entrySet()) {
|
||||
String rootClass = entry.getKey();
|
||||
List<FieldInfo> fields = fieldParser.parseFields(entry.getValue(), rootClass);
|
||||
Set<String> visiting = new LinkedHashSet<>();
|
||||
index.walkNested(rootClass, fields, rootClass, 1, visiting, fieldParser);
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
/** 自身 + 所有祖先 Dto/Vo 类名(用于接口影响匹配) */
|
||||
public Set<String> expandImpactNames(String className) {
|
||||
Set<String> names = new LinkedHashSet<>();
|
||||
if (className != null && !className.isBlank()) {
|
||||
names.add(className);
|
||||
names.addAll(ancestorsOf.getOrDefault(className, Set.of()));
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/** 是否被其他 Dto/Vo 嵌套引用(存在至少一个祖先容器) */
|
||||
public boolean hasAncestors(String className) {
|
||||
Set<String> ancestors = ancestorsOf.get(className);
|
||||
return ancestors != null && !ancestors.isEmpty();
|
||||
}
|
||||
|
||||
/** 嵌套类型的 @RequestBody 根 Dto 祖先(仅 Dto 后缀) */
|
||||
public Set<String> findRequestBodyRoots(String className) {
|
||||
Set<String> roots = new LinkedHashSet<>();
|
||||
if (className != null && className.endsWith("Dto")) {
|
||||
roots.add(className);
|
||||
}
|
||||
for (String ancestor : ancestorsOf.getOrDefault(className, Set.of())) {
|
||||
if (ancestor.endsWith("Dto")) {
|
||||
roots.add(ancestor);
|
||||
}
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
public int getMaxDepth() {
|
||||
return maxDepth;
|
||||
}
|
||||
|
||||
private void walkNested(String ownerClass, List<FieldInfo> fields, String rootAncestor,
|
||||
int depth, Set<String> visiting, ClassFieldParser fieldParser) {
|
||||
if (depth > maxDepth) {
|
||||
return;
|
||||
}
|
||||
for (FieldInfo field : fields) {
|
||||
for (String nestedType : TypeNameUtils.peelDirectTypeNames(field.getType())) {
|
||||
if (isLeafType(nestedType) || nestedType.equals(ownerClass)) {
|
||||
continue;
|
||||
}
|
||||
ancestorsOf.computeIfAbsent(nestedType, k -> new LinkedHashSet<>()).add(rootAncestor);
|
||||
if (!visiting.add(nestedType)) {
|
||||
continue;
|
||||
}
|
||||
String nestedSource = sourceByClass.get(nestedType);
|
||||
if (nestedSource != null) {
|
||||
List<FieldInfo> nestedFields = fieldParser.parseFields(nestedSource, nestedType);
|
||||
walkNested(nestedType, nestedFields, rootAncestor, depth + 1, visiting, fieldParser);
|
||||
}
|
||||
visiting.remove(nestedType);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isLeafType(String simpleType) {
|
||||
return LEAF_TYPES.contains(simpleType) || simpleType.endsWith("[]");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.codechecker.analyzer;
|
||||
|
||||
import com.codechecker.config.AppConfig;
|
||||
import com.codechecker.model.ApiEndpoint;
|
||||
import com.codechecker.parser.EndpointParser;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 预扫描 Controller/Feign 目录,构建 endpointKey → ApiEndpoint 索引。
|
||||
*/
|
||||
public class EndpointIndexBuilder {
|
||||
private final EndpointParser endpointParser = new EndpointParser();
|
||||
|
||||
/** 合并 Controller 与 Feign 扫描结果 */
|
||||
public Map<String, ApiEndpoint> buildIndex(Path repoRoot, AppConfig config) throws IOException {
|
||||
Map<String, ApiEndpoint> index = new LinkedHashMap<>();
|
||||
for (String dir : config.getControllerScanDirs()) {
|
||||
addEndpoints(index, endpointParser.scanControllerDirectory(repoRoot.resolve(dir), dir));
|
||||
}
|
||||
for (String dir : config.getFeignScanDirs()) {
|
||||
addEndpoints(index, endpointParser.scanFeignDirectory(repoRoot.resolve(dir), dir));
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
/** 按 endpointKey 去重写入索引 */
|
||||
private void addEndpoints(Map<String, ApiEndpoint> index, List<ApiEndpoint> endpoints) {
|
||||
for (ApiEndpoint endpoint : endpoints) {
|
||||
index.putIfAbsent(endpoint.endpointKey(), endpoint);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package com.codechecker.analyzer;
|
||||
|
||||
import com.codechecker.model.FieldChange;
|
||||
import com.codechecker.model.FieldInfo;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 对比新旧字段列表,产出新增/删除/类型修改/重命名(纯注释变更忽略)。
|
||||
*/
|
||||
public class FieldDiffEngine {
|
||||
|
||||
/**
|
||||
* 按字段名对比;删除+新增且说明匹配时合并为重命名。
|
||||
* 输出顺序:按新字段声明顺序,未配对的删除字段置于末尾。
|
||||
*/
|
||||
public List<FieldChange> diff(List<FieldInfo> oldFields, List<FieldInfo> newFields) {
|
||||
Map<String, FieldInfo> oldMap = toMap(oldFields);
|
||||
Map<String, FieldInfo> newMap = toMap(newFields);
|
||||
|
||||
List<FieldChange> modified = new ArrayList<>();
|
||||
List<FieldInfo> added = new ArrayList<>();
|
||||
List<FieldInfo> removed = new ArrayList<>();
|
||||
|
||||
for (FieldInfo newField : newFields) {
|
||||
FieldInfo oldField = oldMap.get(newField.getName());
|
||||
if (oldField == null) {
|
||||
added.add(newField);
|
||||
} else if (!oldField.getType().equals(newField.getType())) {
|
||||
modified.add(FieldChange.modified(oldField, newField, buildTypeDetail(oldField, newField)));
|
||||
}
|
||||
// 仅 @Schema / 注释文案变化:不纳入字段变更
|
||||
}
|
||||
|
||||
for (FieldInfo oldField : oldFields) {
|
||||
if (!newMap.containsKey(oldField.getName())) {
|
||||
removed.add(oldField);
|
||||
}
|
||||
}
|
||||
|
||||
List<FieldChange> renamed = pairRenames(removed, added);
|
||||
return mergeInOrder(newFields, renamed, modified, added, removed);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将删除+新增配对为字段重命名。
|
||||
* 优先:说明相同且类型相同;其次:说明相同但类型不同(重命名+改类型)。
|
||||
*/
|
||||
private List<FieldChange> pairRenames(List<FieldInfo> removed, List<FieldInfo> added) {
|
||||
List<FieldChange> renames = new ArrayList<>();
|
||||
Set<FieldInfo> matchedRemoved = new LinkedHashSet<>();
|
||||
Set<FieldInfo> matchedAdded = new LinkedHashSet<>();
|
||||
|
||||
for (FieldInfo oldField : removed) {
|
||||
FieldInfo pair = findRenamePair(oldField, added, matchedAdded, true);
|
||||
if (pair == null) {
|
||||
pair = findRenamePair(oldField, added, matchedAdded, false);
|
||||
}
|
||||
if (pair != null) {
|
||||
renames.add(FieldChange.renamed(oldField, pair));
|
||||
matchedRemoved.add(oldField);
|
||||
matchedAdded.add(pair);
|
||||
}
|
||||
}
|
||||
|
||||
removed.removeIf(matchedRemoved::contains);
|
||||
added.removeIf(matchedAdded::contains);
|
||||
return renames;
|
||||
}
|
||||
|
||||
private FieldInfo findRenamePair(FieldInfo removed, List<FieldInfo> added,
|
||||
Set<FieldInfo> excluded, boolean requireSameType) {
|
||||
for (FieldInfo candidate : added) {
|
||||
if (excluded.contains(candidate)) {
|
||||
continue;
|
||||
}
|
||||
if (!descriptionsMatch(removed, candidate)) {
|
||||
continue;
|
||||
}
|
||||
if (requireSameType && !removed.getType().equals(candidate.getType())) {
|
||||
continue;
|
||||
}
|
||||
if (!requireSameType && removed.getType().equals(candidate.getType())) {
|
||||
continue;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 说明相同(非空)或双方均为空时视为匹配 */
|
||||
private boolean descriptionsMatch(FieldInfo oldField, FieldInfo newField) {
|
||||
String oldDesc = normalizeDescription(oldField.getDescription());
|
||||
String newDesc = normalizeDescription(newField.getDescription());
|
||||
if (oldDesc.isEmpty() && newDesc.isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
if (oldDesc.isEmpty() || newDesc.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
return oldDesc.equals(newDesc);
|
||||
}
|
||||
|
||||
private String normalizeDescription(String description) {
|
||||
return description == null ? "" : description.trim();
|
||||
}
|
||||
|
||||
/** 按新字段声明顺序合并各变更类型 */
|
||||
private List<FieldChange> mergeInOrder(List<FieldInfo> newFields, List<FieldChange> renamed,
|
||||
List<FieldChange> modified, List<FieldInfo> added,
|
||||
List<FieldInfo> removed) {
|
||||
Map<String, FieldChange> renamedByNewName = new LinkedHashMap<>();
|
||||
for (FieldChange change : renamed) {
|
||||
renamedByNewName.put(change.getFieldName(), change);
|
||||
}
|
||||
|
||||
Map<String, FieldChange> modifiedByName = new LinkedHashMap<>();
|
||||
for (FieldChange change : modified) {
|
||||
modifiedByName.put(change.getFieldName(), change);
|
||||
}
|
||||
|
||||
Set<String> emitted = new LinkedHashSet<>();
|
||||
List<FieldChange> result = new ArrayList<>();
|
||||
|
||||
for (FieldInfo newField : newFields) {
|
||||
String name = newField.getName();
|
||||
if (renamedByNewName.containsKey(name)) {
|
||||
result.add(renamedByNewName.get(name));
|
||||
emitted.add(name);
|
||||
} else if (modifiedByName.containsKey(name)) {
|
||||
result.add(modifiedByName.get(name));
|
||||
emitted.add(name);
|
||||
} else if (added.stream().anyMatch(f -> f.getName().equals(name))) {
|
||||
result.add(FieldChange.added(newField));
|
||||
emitted.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
for (FieldInfo oldField : removed) {
|
||||
result.add(FieldChange.removed(oldField));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 字段列表转 LinkedHashMap,保持声明顺序 */
|
||||
private Map<String, FieldInfo> toMap(List<FieldInfo> fields) {
|
||||
Map<String, FieldInfo> map = new LinkedHashMap<>();
|
||||
for (FieldInfo field : fields) {
|
||||
map.put(field.getName(), field);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/** 构造类型变化描述,如 Integer → String */
|
||||
private String buildTypeDetail(FieldInfo oldField, FieldInfo newField) {
|
||||
if (oldField.getType().equals(newField.getType())) {
|
||||
return "";
|
||||
}
|
||||
return oldField.getType() + " → " + newField.getType();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package com.codechecker.analyzer;
|
||||
|
||||
import com.codechecker.config.AppConfig;
|
||||
import com.codechecker.model.ApiEndpoint;
|
||||
import com.codechecker.model.ClassChangeReport;
|
||||
import com.codechecker.model.ClassType;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import com.codechecker.parser.ConversionParser;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 根据变更报告匹配受影响的 HTTP 接口与 Dto→Entity 转换目标。
|
||||
*/
|
||||
public class ImpactAnalyzer {
|
||||
private final ConversionParser conversionParser = new ConversionParser();
|
||||
|
||||
/**
|
||||
* 填充 report 的影响列表;新旧类名均参与匹配;Entity/Model 不匹配接口。
|
||||
*/
|
||||
public void analyze(ClassChangeReport report, Map<String, ApiEndpoint> endpointIndex,
|
||||
AppConfig config, Path repoRoot, String newSource, String oldSource,
|
||||
DtoNestIndex nestIndex) throws IOException {
|
||||
Set<String> matchNames = namesForMatching(report, nestIndex);
|
||||
|
||||
if (report.getClassType() != ClassType.ENTITY && report.getClassType() != ClassType.MODEL) {
|
||||
matchEndpoints(report, endpointIndex, matchNames);
|
||||
}
|
||||
|
||||
report.setObjectRoleLabels(NestedObjectRoleResolver.resolve(report, nestIndex, endpointIndex));
|
||||
|
||||
if (!config.isDtoEntityConversionEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
analyzeConversion(report, config, repoRoot, newSource, oldSource, matchNames);
|
||||
}
|
||||
|
||||
/** 收集新旧类名及嵌套祖先 Dto/Vo,用于接口/转换匹配 */
|
||||
private Set<String> namesForMatching(ClassChangeReport report, DtoNestIndex nestIndex) {
|
||||
Set<String> names = new LinkedHashSet<>();
|
||||
names.add(report.getClassName());
|
||||
if (report.getOldClassName() != null && !report.getOldClassName().isBlank()) {
|
||||
names.add(report.getOldClassName());
|
||||
}
|
||||
if (nestIndex != null
|
||||
&& (report.getClassType() == ClassType.DTO || report.getClassType() == ClassType.VO)) {
|
||||
for (String name : new ArrayList<>(names)) {
|
||||
names.addAll(nestIndex.expandImpactNames(name));
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/** 在接口索引中匹配入参/返回类型 */
|
||||
private void matchEndpoints(ClassChangeReport report, Map<String, ApiEndpoint> endpointIndex,
|
||||
Set<String> matchNames) {
|
||||
List<ApiEndpoint> inputImpacts = new ArrayList<>();
|
||||
List<ApiEndpoint> frontendImpacts = new ArrayList<>();
|
||||
|
||||
for (ApiEndpoint endpoint : endpointIndex.values()) {
|
||||
if (matchesAnyType(endpoint.getParamTypes(), matchNames)) {
|
||||
inputImpacts.add(endpoint);
|
||||
}
|
||||
if (matchesAnyType(endpoint.getReturnTypes(), matchNames)) {
|
||||
frontendImpacts.add(endpoint);
|
||||
}
|
||||
}
|
||||
|
||||
inputImpacts.forEach(report::addInputImpact);
|
||||
frontendImpacts.forEach(report::addFrontendImpact);
|
||||
}
|
||||
|
||||
/** 扫描 convert 方法与 BeanUtils.copyProperties 关联的 Entity */
|
||||
private void analyzeConversion(ClassChangeReport report, AppConfig config, Path repoRoot,
|
||||
String newSource, String oldSource, Set<String> matchNames) throws IOException {
|
||||
for (String name : matchNames) {
|
||||
if (newSource != null && !newSource.isBlank()) {
|
||||
conversionParser.findConvertTargetsInClass(newSource, name)
|
||||
.forEach(report::addConversionEntity);
|
||||
}
|
||||
if (oldSource != null && !oldSource.isBlank() && !oldSource.equals(newSource)) {
|
||||
conversionParser.findConvertTargetsInClass(oldSource, name)
|
||||
.forEach(report::addConversionEntity);
|
||||
}
|
||||
for (String scanDir : config.getConversionScanDirs()) {
|
||||
conversionParser.findBeanUtilsTargets(repoRoot.resolve(scanDir), name)
|
||||
.forEach(report::addConversionEntity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 类型集合中是否包含任一目标类名 */
|
||||
private boolean matchesAnyType(Collection<String> types, Set<String> classNames) {
|
||||
if (types == null) {
|
||||
return false;
|
||||
}
|
||||
for (String type : types) {
|
||||
if (classNames.contains(type)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.codechecker.analyzer;
|
||||
|
||||
import com.codechecker.model.ApiEndpoint;
|
||||
import com.codechecker.model.ClassChangeReport;
|
||||
import com.codechecker.model.ClassType;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 判定 Dto/Vo 在类变更通知中的对象角色标签(方案 B:嵌套 + 可选顶层)。
|
||||
* <p>
|
||||
* 仅当存在嵌套祖先时标注;纯顶层不标注;既嵌套又直接作接口根类型时同时标注。
|
||||
*/
|
||||
public final class NestedObjectRoleResolver {
|
||||
|
||||
private NestedObjectRoleResolver() {
|
||||
}
|
||||
|
||||
public static List<String> resolve(ClassChangeReport report, DtoNestIndex nestIndex,
|
||||
Map<String, ApiEndpoint> endpointIndex) {
|
||||
if (report.getClassType() != ClassType.DTO && report.getClassType() != ClassType.VO) {
|
||||
return List.of();
|
||||
}
|
||||
if (nestIndex == null) {
|
||||
return List.of();
|
||||
}
|
||||
String className = report.getClassName();
|
||||
if (!nestIndex.hasAncestors(className)) {
|
||||
return List.of();
|
||||
}
|
||||
List<String> labels = new ArrayList<>();
|
||||
labels.add("嵌套对象");
|
||||
if (isDirectEndpointType(className, endpointIndex)) {
|
||||
labels.add("顶层对象");
|
||||
}
|
||||
return List.copyOf(labels);
|
||||
}
|
||||
|
||||
/** 是否直接出现在接口入参或返回值类型(非仅经祖先传播) */
|
||||
private static boolean isDirectEndpointType(String className, Map<String, ApiEndpoint> endpointIndex) {
|
||||
if (className == null || className.isBlank() || endpointIndex == null) {
|
||||
return false;
|
||||
}
|
||||
for (ApiEndpoint endpoint : endpointIndex.values()) {
|
||||
if (endpoint.getParamTypes().contains(className)
|
||||
|| endpoint.getReturnTypes().contains(className)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.codechecker.api.analyzer;
|
||||
|
||||
import com.codechecker.api.model.EndpointChangeReport;
|
||||
import com.codechecker.api.model.EndpointSnapshot;
|
||||
import com.codechecker.api.parser.EndpointSnapshotParser;
|
||||
import com.codechecker.api.scanner.ApiFileChangeScanner;
|
||||
import com.codechecker.config.AppConfig;
|
||||
import com.codechecker.git.GitChangeScanner;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* API 变更分析编排(与 {@link com.codechecker.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), gitScanner, oldSha, newSha, config.getNestMaxDepth());
|
||||
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,150 @@
|
||||
package com.codechecker.api.analyzer;
|
||||
|
||||
import com.codechecker.analyzer.DtoNestIndex;
|
||||
import com.codechecker.api.model.ApiChangeKind;
|
||||
import com.codechecker.api.model.EndpointChangeReport;
|
||||
import com.codechecker.api.model.EndpointSnapshot;
|
||||
import com.codechecker.api.model.ParameterChange;
|
||||
import com.codechecker.api.parser.EndpointSnapshotParser;
|
||||
import com.codechecker.config.AppConfig;
|
||||
import com.codechecker.git.GitChangeScanner;
|
||||
import com.codechecker.model.ApiEndpoint;
|
||||
import com.codechecker.model.ClassChangeReport;
|
||||
import com.codechecker.model.ClassType;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 类变更(Dto/Vo 嵌套字段)后,对受影响的 Controller 继续 API 参数 diff,产出 PARAM_CHANGED 报告。
|
||||
*/
|
||||
public class DtoImpactedApiAnalyzer {
|
||||
private final GitChangeScanner gitScanner;
|
||||
|
||||
public DtoImpactedApiAnalyzer(GitChangeScanner gitScanner) {
|
||||
this.gitScanner = gitScanner;
|
||||
}
|
||||
|
||||
public List<EndpointChangeReport> analyze(Path repoRoot, AppConfig config,
|
||||
String oldSha, String newSha,
|
||||
List<ClassChangeReport> classReports,
|
||||
Set<String> alreadyScannedFiles,
|
||||
DtoNestIndex nestIndex) throws IOException {
|
||||
Map<String, Set<String>> controllerToDtos = collectImpactedControllers(classReports, alreadyScannedFiles,
|
||||
nestIndex);
|
||||
if (controllerToDtos.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
EndpointSnapshotParser parser = new EndpointSnapshotParser(config.isApiExcludeFrameworkParams());
|
||||
ParameterDiffEngine parameterDiffEngine = new ParameterDiffEngine(
|
||||
repoRoot, buildSearchDirs(config), gitScanner, oldSha, newSha, config.getNestMaxDepth());
|
||||
EndpointDiffEngine endpointDiffEngine = new EndpointDiffEngine(parameterDiffEngine);
|
||||
|
||||
List<EndpointSnapshot> oldSnapshots = new ArrayList<>();
|
||||
List<EndpointSnapshot> newSnapshots = new ArrayList<>();
|
||||
for (String path : controllerToDtos.keySet()) {
|
||||
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));
|
||||
}
|
||||
|
||||
List<EndpointChangeReport> reports = new ArrayList<>();
|
||||
for (EndpointChangeReport report : endpointDiffEngine.diff(oldSnapshots, newSnapshots)) {
|
||||
if (report.getChangeKind() != ApiChangeKind.PARAM_CHANGED || !report.hasParameterChanges()) {
|
||||
continue;
|
||||
}
|
||||
String relatedDto = findRelatedDto(report, controllerToDtos);
|
||||
if (relatedDto == null) {
|
||||
continue;
|
||||
}
|
||||
reports.add(EndpointChangeReport.dtoFollowUp(report, relatedDto));
|
||||
}
|
||||
return reports;
|
||||
}
|
||||
|
||||
private Map<String, Set<String>> collectImpactedControllers(List<ClassChangeReport> classReports,
|
||||
Set<String> alreadyScannedFiles,
|
||||
DtoNestIndex nestIndex) {
|
||||
Map<String, Set<String>> controllerToDtos = new LinkedHashMap<>();
|
||||
for (ClassChangeReport report : classReports) {
|
||||
if (report.getFieldChanges().isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
if (report.getClassType() != ClassType.DTO && report.getClassType() != ClassType.VO) {
|
||||
continue;
|
||||
}
|
||||
Set<String> bodyRoots = resolveBodyRoots(report, nestIndex);
|
||||
if (bodyRoots.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
for (ApiEndpoint endpoint : report.getInputImpactEndpoints()) {
|
||||
String controllerFile = endpoint.getSourceFile();
|
||||
if (alreadyScannedFiles.contains(controllerFile)) {
|
||||
continue;
|
||||
}
|
||||
controllerToDtos.computeIfAbsent(controllerFile, k -> new LinkedHashSet<>()).addAll(bodyRoots);
|
||||
}
|
||||
}
|
||||
return controllerToDtos;
|
||||
}
|
||||
|
||||
private Set<String> resolveBodyRoots(ClassChangeReport report, DtoNestIndex nestIndex) {
|
||||
if (nestIndex == null) {
|
||||
Set<String> names = new LinkedHashSet<>();
|
||||
if (report.getClassName().endsWith("Dto")) {
|
||||
names.add(report.getClassName());
|
||||
}
|
||||
return names;
|
||||
}
|
||||
Set<String> roots = new LinkedHashSet<>();
|
||||
roots.addAll(nestIndex.findRequestBodyRoots(report.getClassName()));
|
||||
if (report.getOldClassName() != null && !report.getOldClassName().isBlank()) {
|
||||
roots.addAll(nestIndex.findRequestBodyRoots(report.getOldClassName()));
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
private String findRelatedDto(EndpointChangeReport report, Map<String, Set<String>> controllerToDtos) {
|
||||
Set<String> impactedDtos = controllerToDtos.getOrDefault(report.getSourceFile(), Set.of());
|
||||
for (ParameterChange change : report.getParameterChanges()) {
|
||||
if (!"body".equals(change.getSource())) {
|
||||
continue;
|
||||
}
|
||||
String parentDto = change.getParentDto();
|
||||
if (parentDto != null && impactedDtos.contains(parentDto)) {
|
||||
return parentDto;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
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.codechecker.api.analyzer;
|
||||
|
||||
import com.codechecker.api.model.ApiChangeKind;
|
||||
import com.codechecker.api.model.EndpointChangeReport;
|
||||
import com.codechecker.api.model.EndpointSnapshot;
|
||||
import com.codechecker.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,234 @@
|
||||
package com.codechecker.api.analyzer;
|
||||
|
||||
import com.codechecker.analyzer.FieldDiffEngine;
|
||||
import com.codechecker.api.model.EndpointSnapshot;
|
||||
import com.codechecker.api.model.MethodParameterSnapshot;
|
||||
import com.codechecker.api.model.ParameterChange;
|
||||
import com.codechecker.api.parser.NestedDtoFieldParser;
|
||||
import com.codechecker.api.parser.NestedFieldInfo;
|
||||
import com.codechecker.git.GitChangeScanner;
|
||||
import com.codechecker.model.FieldChange;
|
||||
import com.codechecker.model.FieldInfo;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 接口入参 diff(普通参数 + RequestBody 嵌套 Dto 字段)。
|
||||
*
|
||||
* path/query 规则:
|
||||
* - 形参名+类型相同,仅绑定名变 → 重命名
|
||||
* - 形参名+绑定名相同,仅类型变 → 类型变更
|
||||
* - 仅形参名变(绑定名不变)→ 不通知
|
||||
* - 类型与绑定名同时变,或三者都变 → 先删除后新增
|
||||
*/
|
||||
public class ParameterDiffEngine {
|
||||
private final NestedDtoFieldParser nestedDtoFieldParser;
|
||||
private final FieldDiffEngine fieldDiffEngine = new FieldDiffEngine();
|
||||
|
||||
public ParameterDiffEngine(Path repoRoot, List<String> searchDirs,
|
||||
GitChangeScanner gitScanner, String oldSha, String newSha, int maxDepth) {
|
||||
this.nestedDtoFieldParser = new NestedDtoFieldParser(repoRoot, searchDirs, gitScanner, oldSha, newSha, maxDepth);
|
||||
}
|
||||
|
||||
public List<ParameterChange> diff(EndpointSnapshot oldSnap, EndpointSnapshot newSnap) throws IOException {
|
||||
List<ParameterChange> changes = new ArrayList<>();
|
||||
changes.addAll(diffBodyParams(oldSnap, newSnap));
|
||||
changes.addAll(diffBindingParams(oldSnap, newSnap));
|
||||
return changes;
|
||||
}
|
||||
|
||||
private List<ParameterChange> diffBodyParams(EndpointSnapshot oldSnap, EndpointSnapshot newSnap)
|
||||
throws IOException {
|
||||
Map<String, MethodParameterSnapshot> oldParams = filterBySource(oldSnap, "body");
|
||||
Map<String, MethodParameterSnapshot> newParams = filterBySource(newSnap, "body");
|
||||
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(addedBodyChanges(newParam));
|
||||
} else {
|
||||
changes.addAll(diffBodyDto(oldParam, newParam));
|
||||
}
|
||||
}
|
||||
for (Map.Entry<String, MethodParameterSnapshot> entry : oldParams.entrySet()) {
|
||||
if (!newParams.containsKey(entry.getKey())) {
|
||||
changes.addAll(removedBodyChanges(entry.getValue()));
|
||||
}
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
private List<ParameterChange> diffBindingParams(EndpointSnapshot oldSnap, EndpointSnapshot newSnap) {
|
||||
Map<String, MethodParameterSnapshot> oldParams = filterBindingParams(oldSnap);
|
||||
Map<String, MethodParameterSnapshot> newParams = filterBindingParams(newSnap);
|
||||
List<ParameterChange> changes = new ArrayList<>();
|
||||
|
||||
List<MethodParameterSnapshot> unmatchedOld = new ArrayList<>();
|
||||
List<MethodParameterSnapshot> unmatchedNew = new ArrayList<>();
|
||||
|
||||
for (Map.Entry<String, MethodParameterSnapshot> entry : newParams.entrySet()) {
|
||||
MethodParameterSnapshot oldParam = oldParams.get(entry.getKey());
|
||||
MethodParameterSnapshot newParam = entry.getValue();
|
||||
if (oldParam == null) {
|
||||
unmatchedNew.add(newParam);
|
||||
} else if (!oldParam.getType().equals(newParam.getType())) {
|
||||
changes.add(ParameterChange.modified(
|
||||
newParam.displayName(),
|
||||
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())) {
|
||||
unmatchedOld.add(entry.getValue());
|
||||
}
|
||||
}
|
||||
|
||||
pairRenamedBindingParams(unmatchedOld, unmatchedNew, changes);
|
||||
|
||||
for (MethodParameterSnapshot removed : unmatchedOld) {
|
||||
changes.add(ParameterChange.removed(
|
||||
removed.displayName(), removed.getType(), removed.getDescription(),
|
||||
removed.getSource(), null, null, null));
|
||||
}
|
||||
for (MethodParameterSnapshot added : unmatchedNew) {
|
||||
changes.add(ParameterChange.added(
|
||||
added.displayName(), added.getType(), added.getDescription(),
|
||||
added.getSource(), null, null, null));
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
/** 形参名+类型相同,仅绑定名变化 → 重命名 */
|
||||
private void pairRenamedBindingParams(List<MethodParameterSnapshot> unmatchedOld,
|
||||
List<MethodParameterSnapshot> unmatchedNew,
|
||||
List<ParameterChange> changes) {
|
||||
Set<MethodParameterSnapshot> pairedOld = new HashSet<>();
|
||||
Set<MethodParameterSnapshot> pairedNew = new HashSet<>();
|
||||
|
||||
for (MethodParameterSnapshot oldParam : unmatchedOld) {
|
||||
if (pairedOld.contains(oldParam)) {
|
||||
continue;
|
||||
}
|
||||
for (MethodParameterSnapshot newParam : unmatchedNew) {
|
||||
if (pairedNew.contains(newParam)) {
|
||||
continue;
|
||||
}
|
||||
if (!oldParam.getSource().equals(newParam.getSource())) {
|
||||
continue;
|
||||
}
|
||||
if (!oldParam.getName().equals(newParam.getName())) {
|
||||
continue;
|
||||
}
|
||||
if (!oldParam.getType().equals(newParam.getType())) {
|
||||
continue;
|
||||
}
|
||||
changes.add(ParameterChange.renamed(
|
||||
oldParam.getBindingName(),
|
||||
newParam.getBindingName(),
|
||||
newParam.getType(),
|
||||
newParam.getDescription(),
|
||||
newParam.getSource(),
|
||||
null, null, null));
|
||||
pairedOld.add(oldParam);
|
||||
pairedNew.add(newParam);
|
||||
break;
|
||||
}
|
||||
}
|
||||
unmatchedOld.removeIf(pairedOld::contains);
|
||||
unmatchedNew.removeIf(pairedNew::contains);
|
||||
}
|
||||
|
||||
private Map<String, MethodParameterSnapshot> filterBindingParams(EndpointSnapshot snap) {
|
||||
Map<String, MethodParameterSnapshot> map = new LinkedHashMap<>();
|
||||
for (MethodParameterSnapshot p : snap.getParameters()) {
|
||||
if (isBindingParam(p)) {
|
||||
map.put(p.identityKey(), p);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private Map<String, MethodParameterSnapshot> filterBySource(EndpointSnapshot snap, String source) {
|
||||
Map<String, MethodParameterSnapshot> map = new LinkedHashMap<>();
|
||||
for (MethodParameterSnapshot p : snap.getParameters()) {
|
||||
if (source.equals(p.getSource())) {
|
||||
map.put(p.identityKey(), p);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private boolean isBindingParam(MethodParameterSnapshot param) {
|
||||
return "path".equals(param.getSource()) || "query".equals(param.getSource());
|
||||
}
|
||||
|
||||
private List<ParameterChange> diffBodyDto(MethodParameterSnapshot oldParam,
|
||||
MethodParameterSnapshot newParam) throws IOException {
|
||||
List<NestedFieldInfo> oldFields = nestedDtoFieldParser.parseNestedFieldsAtOldCommit(oldParam.getDtoClassName());
|
||||
List<NestedFieldInfo> newFields = nestedDtoFieldParser.parseNestedFieldsAtNewCommit(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> addedBodyChanges(MethodParameterSnapshot param) throws IOException {
|
||||
List<ParameterChange> list = new ArrayList<>();
|
||||
for (NestedFieldInfo field : nestedDtoFieldParser.parseNestedFieldsAtNewCommit(param.getDtoClassName())) {
|
||||
list.add(ParameterChange.added(field.getPath(), field.getType(), field.getDescription(),
|
||||
"body", param.getName(), param.getDtoClassName(), field.getPath()));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
private List<ParameterChange> removedBodyChanges(MethodParameterSnapshot param) throws IOException {
|
||||
List<ParameterChange> list = new ArrayList<>();
|
||||
for (NestedFieldInfo field : nestedDtoFieldParser.parseNestedFieldsAtOldCommit(param.getDtoClassName())) {
|
||||
list.add(ParameterChange.removed(field.getPath(), field.getType(), field.getDescription(),
|
||||
"body", param.getName(), param.getDtoClassName(), field.getPath()));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
private List<FieldInfo> toFieldInfo(List<NestedFieldInfo> nested) {
|
||||
List<FieldInfo> result = new ArrayList<>();
|
||||
for (NestedFieldInfo info : nested) {
|
||||
result.add(new FieldInfo(info.getPath(), info.getType(), info.getDescription()));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.codechecker.api.model;
|
||||
|
||||
/**
|
||||
* API 变更类型(与类变更 {@link com.codechecker.model.ClassChangeKind} 独立)。
|
||||
*/
|
||||
public enum ApiChangeKind {
|
||||
NEW_ENDPOINT,
|
||||
REMOVED_ENDPOINT,
|
||||
PATH_CHANGED,
|
||||
METHOD_CHANGED,
|
||||
PARAM_CHANGED
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package com.codechecker.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 boolean dtoFollowUp;
|
||||
private final String relatedDtoClassName;
|
||||
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, httpMethod, oldHttpMethod, uri, oldUri, sourceFile, controllerClass,
|
||||
endpointDescription, false, null);
|
||||
}
|
||||
|
||||
public EndpointChangeReport(ApiChangeKind changeKind, String httpMethod, String oldHttpMethod,
|
||||
String uri, String oldUri, String sourceFile, String controllerClass,
|
||||
String endpointDescription, boolean dtoFollowUp, String relatedDtoClassName) {
|
||||
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;
|
||||
this.dtoFollowUp = dtoFollowUp;
|
||||
this.relatedDtoClassName = relatedDtoClassName;
|
||||
}
|
||||
|
||||
/** 基于已有报告创建 Dto 跟进产生的副本 */
|
||||
public static EndpointChangeReport dtoFollowUp(EndpointChangeReport source, String relatedDtoClassName) {
|
||||
EndpointChangeReport copy = new EndpointChangeReport(
|
||||
source.getChangeKind(),
|
||||
source.getHttpMethod(),
|
||||
source.getOldHttpMethod(),
|
||||
source.getUri(),
|
||||
source.getOldUri(),
|
||||
source.getSourceFile(),
|
||||
source.getControllerClass(),
|
||||
source.getEndpointDescription(),
|
||||
true,
|
||||
relatedDtoClassName);
|
||||
source.getParameterChanges().forEach(copy::addParameterChange);
|
||||
return copy;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
public boolean isDtoFollowUp() {
|
||||
return dtoFollowUp;
|
||||
}
|
||||
|
||||
public String getRelatedDtoClassName() {
|
||||
return relatedDtoClassName;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.codechecker.api.model;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 单个 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);
|
||||
}
|
||||
|
||||
/** 跨 commit 配对同一 Java 方法;不含参数信息,参数 diff 由 ParameterDiffEngine 负责 */
|
||||
public static String buildFingerprint(String sourceFile, String methodName) {
|
||||
return sourceFile + "#" + methodName;
|
||||
}
|
||||
|
||||
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,71 @@
|
||||
package com.codechecker.api.model;
|
||||
|
||||
/**
|
||||
* 接口方法入参快照。
|
||||
*/
|
||||
public class MethodParameterSnapshot {
|
||||
private final String name;
|
||||
private final String bindingName;
|
||||
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 bindingName, String type, String source,
|
||||
boolean required, String description, String dtoClassName) {
|
||||
this.name = name;
|
||||
this.bindingName = bindingName == null || bindingName.isBlank() ? name : bindingName;
|
||||
this.type = type;
|
||||
this.source = source;
|
||||
this.required = required;
|
||||
this.description = description;
|
||||
this.dtoClassName = dtoClassName;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
/** 对外绑定名(@PathVariable / @RequestParam 的 value/name,缺省为形参名) */
|
||||
public String getBindingName() {
|
||||
return bindingName;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** path/query 按绑定名匹配,避免仅 Java 形参重命名误报 */
|
||||
public String identityKey() {
|
||||
if ("path".equals(source) || "query".equals(source)) {
|
||||
return source + ":" + bindingName;
|
||||
}
|
||||
return source + ":" + name;
|
||||
}
|
||||
|
||||
/** 通知展示名:path/query 展示绑定名 */
|
||||
public String displayName() {
|
||||
if ("path".equals(source) || "query".equals(source)) {
|
||||
return bindingName;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package com.codechecker.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,272 @@
|
||||
package com.codechecker.api.notify;
|
||||
|
||||
import com.codechecker.api.model.ApiChangeKind;
|
||||
import com.codechecker.api.model.EndpointChangeReport;
|
||||
import com.codechecker.api.model.ParameterChange;
|
||||
import com.codechecker.common.MarkdownStyles;
|
||||
import com.codechecker.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("原路径",
|
||||
formatUriWithMethod(report.getHttpMethod(), report.getOldUri(), false)
|
||||
+ " " + MarkdownStyles.colorWarning("[旧路径]"))).append("\n");
|
||||
sb.append(MarkdownStyles.quoteKvBold("新路径",
|
||||
formatUriWithMethod(report.getHttpMethod(), report.getUri(), true)
|
||||
+ " " + 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.getParentDto() == null || change.getParentDto().isBlank()
|
||||
? (change.getBodyParamName() == null ? "body" : change.getBodyParamName())
|
||||
: 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);
|
||||
if (first.getParentDto() != null && !first.getParentDto().isBlank()) {
|
||||
sb.append("**").append(MarkdownStyles.inlineCode(first.getParentDto())).append("**");
|
||||
} else if (first.getBodyParamName() != null && !first.getBodyParamName().isBlank()) {
|
||||
sb.append("**").append(MarkdownStyles.inlineCode(first.getBodyParamName())).append("**");
|
||||
}
|
||||
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;
|
||||
case MODIFIED:
|
||||
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();
|
||||
if (change.getChangeType() == ParameterChange.ChangeType.RENAMED) {
|
||||
line.append(tag).append(" ")
|
||||
.append(MarkdownStyles.colorComment(MarkdownStyles.safe(change.getOldName()))).append(" → ")
|
||||
.append(MarkdownStyles.colorInfo(MarkdownStyles.safe(change.getParamName())))
|
||||
.append(" 说明: ").append(desc);
|
||||
} else {
|
||||
line.append(tag).append(" ").append(name).append(" 说明: ").append(desc);
|
||||
}
|
||||
appendParameterType(line, change);
|
||||
return MarkdownStyles.quoteLine(line.toString());
|
||||
}
|
||||
|
||||
private void appendParameterType(StringBuilder line, ParameterChange change) {
|
||||
String typePart = resolveTypePart(change);
|
||||
if (!typePart.isBlank()) {
|
||||
line.append(" 类型: ").append(typePart);
|
||||
}
|
||||
}
|
||||
|
||||
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,313 @@
|
||||
package com.codechecker.api.parser;
|
||||
|
||||
import com.codechecker.api.model.EndpointSnapshot;
|
||||
import com.codechecker.api.model.MethodParameterSnapshot;
|
||||
import com.codechecker.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());
|
||||
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) {
|
||||
Map<String, String> paramDescriptions = MethodParamJavadocExtractor.extract(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);
|
||||
String paramName = parameter.getNameAsString();
|
||||
String bindingName = resolveBindingName(parameter, source, paramName);
|
||||
boolean required = resolveRequired(parameter, source);
|
||||
String dtoName = "body".equals(source) ? simple : "";
|
||||
String description = paramDescriptions.getOrDefault(paramName, "");
|
||||
params.add(new MethodParameterSnapshot(
|
||||
paramName,
|
||||
bindingName,
|
||||
typeName,
|
||||
source,
|
||||
required,
|
||||
description,
|
||||
dtoName
|
||||
));
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
private String resolveBindingName(Parameter parameter, String source, String paramName) {
|
||||
if (!"path".equals(source) && !"query".equals(source)) {
|
||||
return paramName;
|
||||
}
|
||||
String annName = "path".equals(source) ? "PathVariable" : "RequestParam";
|
||||
for (AnnotationExpr ann : parameter.getAnnotations()) {
|
||||
if (!annName.equals(ann.getNameAsString())) {
|
||||
continue;
|
||||
}
|
||||
List<String> bindings = readStringArray(ann, "value", "name");
|
||||
for (String binding : bindings) {
|
||||
if (binding != null && !binding.isBlank()) {
|
||||
return binding;
|
||||
}
|
||||
}
|
||||
}
|
||||
return paramName;
|
||||
}
|
||||
|
||||
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,68 @@
|
||||
package com.codechecker.api.parser;
|
||||
|
||||
import com.codechecker.git.GitChangeScanner;
|
||||
|
||||
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<String> readSourceAtCommit(GitChangeScanner gitScanner, String sha,
|
||||
String simpleClassName) throws IOException {
|
||||
Optional<String> relativePath = findRelativePath(simpleClassName);
|
||||
if (relativePath.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String source = gitScanner.readFileAtCommit(sha, relativePath.get());
|
||||
if (source == null || source.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(source);
|
||||
}
|
||||
|
||||
public Optional<String> findRelativePath(String simpleClassName) throws IOException {
|
||||
Optional<Path> path = findFile(simpleClassName);
|
||||
return path.map(p -> repoRoot.relativize(p).toString().replace('\\', '/'));
|
||||
}
|
||||
|
||||
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.codechecker.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,67 @@
|
||||
package com.codechecker.api.parser;
|
||||
|
||||
import com.github.javaparser.ast.body.MethodDeclaration;
|
||||
import com.github.javaparser.ast.comments.JavadocComment;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 从方法 Javadoc 的 @param 标签提取形参说明(按 Java 形参名匹配)。
|
||||
*/
|
||||
public final class MethodParamJavadocExtractor {
|
||||
private static final Pattern PARAM_TAG = Pattern.compile(
|
||||
"@param\\s+(\\w+)\\s+(.+?)(?=\\s*@param\\s+|\\s*@return\\s+|\\s*@throws\\s+|\\s*@see\\s+|\\s*\\*/|$)",
|
||||
Pattern.DOTALL);
|
||||
|
||||
private MethodParamJavadocExtractor() {
|
||||
}
|
||||
|
||||
public static Map<String, String> extract(MethodDeclaration method) {
|
||||
Map<String, String> descriptions = new HashMap<>();
|
||||
if (method == null) {
|
||||
return descriptions;
|
||||
}
|
||||
Optional<JavadocComment> javadoc = method.getJavadocComment();
|
||||
if (javadoc.isEmpty()) {
|
||||
return descriptions;
|
||||
}
|
||||
String raw = javadoc.get().getContent();
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return descriptions;
|
||||
}
|
||||
String normalized = raw.replace('\r', '\n');
|
||||
Matcher matcher = PARAM_TAG.matcher(normalized);
|
||||
while (matcher.find()) {
|
||||
String paramName = matcher.group(1).trim();
|
||||
String desc = cleanDescription(matcher.group(2));
|
||||
if (!paramName.isBlank()) {
|
||||
descriptions.put(paramName, desc);
|
||||
}
|
||||
}
|
||||
return descriptions;
|
||||
}
|
||||
|
||||
private static String cleanDescription(String text) {
|
||||
if (text == null) {
|
||||
return "";
|
||||
}
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (String line : text.split("\n")) {
|
||||
String trimmed = line.trim();
|
||||
if (trimmed.startsWith("*")) {
|
||||
trimmed = trimmed.substring(1).trim();
|
||||
}
|
||||
if (!trimmed.isEmpty()) {
|
||||
if (sb.length() > 0) {
|
||||
sb.append(' ');
|
||||
}
|
||||
sb.append(trimmed);
|
||||
}
|
||||
}
|
||||
return sb.toString().trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.codechecker.api.parser;
|
||||
|
||||
import com.codechecker.git.GitChangeScanner;
|
||||
import com.codechecker.model.FieldInfo;
|
||||
import com.codechecker.parser.ClassFieldParser;
|
||||
import com.codechecker.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;
|
||||
private final GitChangeScanner gitScanner;
|
||||
private final String oldSha;
|
||||
private final String newSha;
|
||||
private final int maxDepth;
|
||||
|
||||
public NestedDtoFieldParser(Path repoRoot, List<String> searchDirs,
|
||||
GitChangeScanner gitScanner, String oldSha, String newSha, int maxDepth) {
|
||||
this.sourceLocator = new JavaSourceLocator(repoRoot, searchDirs);
|
||||
this.gitScanner = gitScanner;
|
||||
this.oldSha = oldSha;
|
||||
this.newSha = newSha;
|
||||
this.maxDepth = maxDepth;
|
||||
}
|
||||
|
||||
public List<NestedFieldInfo> parseNestedFieldsAtOldCommit(String dtoClassName) throws IOException {
|
||||
return parseNestedFields(dtoClassName, oldSha);
|
||||
}
|
||||
|
||||
public List<NestedFieldInfo> parseNestedFieldsAtNewCommit(String dtoClassName) throws IOException {
|
||||
return parseNestedFields(dtoClassName, newSha);
|
||||
}
|
||||
|
||||
private List<NestedFieldInfo> parseNestedFields(String dtoClassName, String sha) throws IOException {
|
||||
Set<String> visiting = new HashSet<>();
|
||||
List<NestedFieldInfo> result = new ArrayList<>();
|
||||
collectFields(dtoClassName, "", visiting, result, sha, 1);
|
||||
return result;
|
||||
}
|
||||
|
||||
private void collectFields(String className, String prefix, Set<String> visiting,
|
||||
List<NestedFieldInfo> out, String sha, int depth) throws IOException {
|
||||
if (className == null || className.isBlank() || visiting.contains(className) || depth > maxDepth) {
|
||||
return;
|
||||
}
|
||||
visiting.add(className);
|
||||
Optional<String> source = readSource(className, sha);
|
||||
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();
|
||||
Set<String> nestedTypes = TypeNameUtils.peelDirectTypeNames(field.getType());
|
||||
boolean expanded = false;
|
||||
for (String nestedType : nestedTypes) {
|
||||
if (isLeafType(nestedType) || nestedType.equals(className)) {
|
||||
continue;
|
||||
}
|
||||
expanded = true;
|
||||
// 嵌套字段路径用类型简单类名(如 UserSelfDto.nickName),不用成员名(userDtos.nickName)
|
||||
collectFields(nestedType, nestedType, visiting, out, sha, depth + 1);
|
||||
}
|
||||
if (!expanded) {
|
||||
out.add(new NestedFieldInfo(path, field.getType(), field.getDescription()));
|
||||
}
|
||||
}
|
||||
visiting.remove(className);
|
||||
}
|
||||
|
||||
private Optional<String> readSource(String className, String sha) throws IOException {
|
||||
if (sha != null && gitScanner != null) {
|
||||
return sourceLocator.readSourceAtCommit(gitScanner, sha, className);
|
||||
}
|
||||
return sourceLocator.readSourceBySimpleName(className);
|
||||
}
|
||||
|
||||
private boolean isLeafType(String simpleType) {
|
||||
return LEAF_TYPES.contains(simpleType) || simpleType.endsWith("[]");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.codechecker.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.codechecker.api.scanner;
|
||||
|
||||
import com.codechecker.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.codechecker.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.codechecker.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 + "\"";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
package com.codechecker.config;
|
||||
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 读取 .gitea/workflows/code-check-config.yaml,提供检测开关、扫描目录、企微配置等。
|
||||
*/
|
||||
public class AppConfig {
|
||||
private boolean masterEnabled = true;
|
||||
private boolean classCheckEnabled = true;
|
||||
private boolean dtoEntityConversionEnabled = true;
|
||||
private List<String> modelDirs = new ArrayList<>();
|
||||
private List<String> controllerScanDirs = new ArrayList<>();
|
||||
private List<String> feignScanDirs = new ArrayList<>();
|
||||
private List<String> conversionScanDirs = new ArrayList<>();
|
||||
private String wecomWebhookUrl = "";
|
||||
private boolean wecomEnabled = true;
|
||||
private boolean onlyOnChange = true;
|
||||
|
||||
private boolean dtoApiFollowUpEnabled = true;
|
||||
private int nestMaxDepth = 3;
|
||||
private boolean apiCheckEnabled = true;
|
||||
private boolean apiExcludeFrameworkParams = true;
|
||||
private List<String> apiControllerScanDirs = new ArrayList<>();
|
||||
private List<String> apiFeignScanDirs = new ArrayList<>();
|
||||
private DtoOverlapMode dtoOverlapMode = DtoOverlapMode.BOTH;
|
||||
|
||||
/** 从 YAML 文件加载配置 */
|
||||
@SuppressWarnings("unchecked")
|
||||
public static AppConfig load(Path configPath) throws IOException {
|
||||
Yaml yaml = new Yaml();
|
||||
Map<String, Object> root;
|
||||
try (InputStream in = Files.newInputStream(configPath)) {
|
||||
root = yaml.load(in);
|
||||
}
|
||||
if (root == null) {
|
||||
root = Map.of();
|
||||
}
|
||||
|
||||
AppConfig config = new AppConfig();
|
||||
Map<String, Object> checker = mapOrEmpty(root.get("checker"));
|
||||
config.masterEnabled = boolOrDefault(checker.get("enabled"), true);
|
||||
|
||||
Map<String, Object> classCheck = mapOrEmpty(root.get("class_check"));
|
||||
config.classCheckEnabled = boolOrDefault(classCheck.get("enabled"), true);
|
||||
|
||||
Map<String, Object> dtoApiFollowUp = mapOrEmpty(classCheck.get("dto_api_follow_up"));
|
||||
config.dtoApiFollowUpEnabled = boolOrDefault(dtoApiFollowUp.get("enabled"), true);
|
||||
|
||||
Map<String, Object> nestIndex = mapOrEmpty(classCheck.get("nest_index"));
|
||||
config.nestMaxDepth = intOrDefault(nestIndex.get("max_depth"), 3);
|
||||
|
||||
Map<String, Object> conversion = mapOrEmpty(classCheck.get("dto_entity_conversion"));
|
||||
config.dtoEntityConversionEnabled = boolOrDefault(conversion.get("enabled"), true);
|
||||
|
||||
config.modelDirs = stringList(classCheck.get("model_dirs"));
|
||||
Map<String, Object> endpointScan = mapOrEmpty(classCheck.get("endpoint_scan"));
|
||||
config.controllerScanDirs = stringList(endpointScan.get("controllers"));
|
||||
config.feignScanDirs = stringList(endpointScan.get("feign_apis"));
|
||||
config.conversionScanDirs = stringList(classCheck.get("conversion_scan"));
|
||||
|
||||
Map<String, Object> wecom = mapOrEmpty(root.get("wecom"));
|
||||
config.wecomWebhookUrl = stringOrEmpty(wecom.get("webhook_url"));
|
||||
config.wecomEnabled = boolOrDefault(wecom.get("enabled"), true);
|
||||
|
||||
Map<String, Object> notify = mapOrEmpty(root.get("notify"));
|
||||
config.onlyOnChange = boolOrDefault(notify.get("only_on_change"), true);
|
||||
config.dtoOverlapMode = DtoOverlapMode.fromString(stringOrEmpty(notify.get("dto_overlap_mode")));
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** 安全转为 Map,非 Map 则返回空 Map */
|
||||
@SuppressWarnings("unchecked")
|
||||
private static Map<String, Object> mapOrEmpty(Object value) {
|
||||
if (value instanceof Map) {
|
||||
return (Map<String, Object>) value;
|
||||
}
|
||||
return Map.of();
|
||||
}
|
||||
|
||||
/** 安全转为字符串列表 */
|
||||
@SuppressWarnings("unchecked")
|
||||
private static List<String> stringList(Object value) {
|
||||
if (value instanceof List) {
|
||||
List<?> list = (List<?>) value;
|
||||
List<String> result = new ArrayList<>();
|
||||
for (Object item : list) {
|
||||
if (item != null) {
|
||||
result.add(item.toString());
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
/** 安全转为 boolean,缺省用 defaultValue */
|
||||
private static boolean boolOrDefault(Object value, boolean defaultValue) {
|
||||
if (value instanceof Boolean) {
|
||||
return (Boolean) value;
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
/** 安全转为 int,缺省用 defaultValue */
|
||||
private static int intOrDefault(Object value, int defaultValue) {
|
||||
if (value instanceof Number) {
|
||||
return ((Number) value).intValue();
|
||||
}
|
||||
if (value != null) {
|
||||
try {
|
||||
return Integer.parseInt(value.toString().trim());
|
||||
} catch (NumberFormatException ignored) {
|
||||
// 使用默认值
|
||||
}
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
/** 安全转为字符串,null 则空串 */
|
||||
private static String stringOrEmpty(Object value) {
|
||||
return value == null ? "" : value.toString();
|
||||
}
|
||||
|
||||
/** 变更检测总开关(checker.enabled,控制 class_check + api_check) */
|
||||
public boolean isMasterEnabled() {
|
||||
return masterEnabled;
|
||||
}
|
||||
|
||||
/** 类变更检测开关(class_check.enabled) */
|
||||
public boolean isClassCheckEnabled() {
|
||||
return classCheckEnabled;
|
||||
}
|
||||
|
||||
/** Dto→Entity 类转换检测开关 */
|
||||
public boolean isDtoEntityConversionEnabled() {
|
||||
return dtoEntityConversionEnabled;
|
||||
}
|
||||
|
||||
/** 模型类目录(预留,当前扫描仍按类名后缀) */
|
||||
public List<String> getModelDirs() {
|
||||
return modelDirs;
|
||||
}
|
||||
|
||||
/** Controller 扫描目录 */
|
||||
public List<String> getControllerScanDirs() {
|
||||
return controllerScanDirs;
|
||||
}
|
||||
|
||||
/** Feign 接口扫描目录 */
|
||||
public List<String> getFeignScanDirs() {
|
||||
return feignScanDirs;
|
||||
}
|
||||
|
||||
/** BeanUtils / convert 扫描目录 */
|
||||
public List<String> getConversionScanDirs() {
|
||||
return conversionScanDirs;
|
||||
}
|
||||
|
||||
/** 企微 Webhook 地址 */
|
||||
public String getWecomWebhookUrl() {
|
||||
return wecomWebhookUrl;
|
||||
}
|
||||
|
||||
/** 企微通知开关 */
|
||||
public boolean isWecomEnabled() {
|
||||
return wecomEnabled;
|
||||
}
|
||||
|
||||
/** 无变更时是否打印提示后退出 */
|
||||
public boolean isOnlyOnChange() {
|
||||
return onlyOnChange;
|
||||
}
|
||||
|
||||
/** Dto 类变更后是否继续检测受影响接口的 API 参数变更 */
|
||||
public boolean isDtoApiFollowUpEnabled() {
|
||||
return dtoApiFollowUpEnabled;
|
||||
}
|
||||
|
||||
/** Dto/Vo 嵌套展开最大深度(默认 3,可按需调至 4、5) */
|
||||
public int getNestMaxDepth() {
|
||||
return nestMaxDepth;
|
||||
}
|
||||
|
||||
/** API 变更检测总开关 */
|
||||
public boolean isApiCheckEnabled() {
|
||||
return apiCheckEnabled;
|
||||
}
|
||||
|
||||
/** Dto 类变更与 API 参数变更重叠时的通知策略 */
|
||||
public DtoOverlapMode getDtoOverlapMode() {
|
||||
return dtoOverlapMode;
|
||||
}
|
||||
|
||||
/** 是否排除 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.codechecker.config;
|
||||
|
||||
/**
|
||||
* Dto 类变更与 API 参数变更重叠时的通知策略。
|
||||
*/
|
||||
public enum DtoOverlapMode {
|
||||
/** 仅发类变更通知,抑制重叠的 API 参数通知 */
|
||||
CLASS_ONLY,
|
||||
/** 仅发 API 参数通知,抑制重叠的类变更通知 */
|
||||
API_ONLY,
|
||||
/** 两类通知均发送 */
|
||||
BOTH;
|
||||
|
||||
public static DtoOverlapMode fromString(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return BOTH;
|
||||
}
|
||||
try {
|
||||
return DtoOverlapMode.valueOf(value.trim().toUpperCase());
|
||||
} catch (IllegalArgumentException ex) {
|
||||
return BOTH;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
package com.codechecker.git;
|
||||
|
||||
import com.codechecker.model.ChangedClassFile;
|
||||
import com.codechecker.model.ClassType;
|
||||
import com.codechecker.parser.ClassDeclParser;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 执行 git diff,识别 Dto/Vo/Entity/Model 的修改、删除、重命名(含 R* 与同目录 D+A 配对)。
|
||||
*/
|
||||
public class GitChangeScanner {
|
||||
private final Path repoRoot;
|
||||
private final ClassDeclParser classDeclParser = new ClassDeclParser();
|
||||
|
||||
public GitChangeScanner(Path repoRoot) {
|
||||
this.repoRoot = repoRoot;
|
||||
}
|
||||
|
||||
/** 扫描两次提交间的模型类变更 */
|
||||
public List<ChangedClassFile> scanChangedClasses(String oldSha, String newSha) throws IOException {
|
||||
List<String> lines = runGit("diff", "--name-status", oldSha, newSha);
|
||||
List<ChangedClassFile> deletions = new ArrayList<>();
|
||||
Map<String, PendingAdd> additionsByParent = new LinkedHashMap<>();
|
||||
List<ChangedClassFile> result = new ArrayList<>();
|
||||
|
||||
for (String line : lines) {
|
||||
if (line.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
String[] parts = line.split("\t");
|
||||
if (parts.length < 2) {
|
||||
continue;
|
||||
}
|
||||
String status = parts[0].trim();
|
||||
|
||||
if (status.startsWith("R") && parts.length >= 3) {
|
||||
ChangedClassFile renamed = buildRenamed(parts[1], parts[2], oldSha, newSha);
|
||||
if (renamed != null) {
|
||||
result.add(renamed);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
String path = normalizePath(parts[parts.length - 1]);
|
||||
if (!path.endsWith(".java")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String fallbackName = ClassDeclParser.classNameFromPath(path);
|
||||
ClassType classType = ClassType.fromClassName(fallbackName);
|
||||
if (classType == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (status.equals("A")) {
|
||||
String newSource = readFileAtCommit(newSha, path);
|
||||
String className = classDeclParser.resolveClassName(newSource, fallbackName);
|
||||
additionsByParent.computeIfAbsent(parentDir(path), k -> new PendingAdd())
|
||||
.add(path, className, classType, newSource);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (status.equals("D")) {
|
||||
String oldSource = readFileAtCommit(oldSha, path);
|
||||
String className = classDeclParser.resolveClassName(oldSource, fallbackName);
|
||||
deletions.add(new ChangedClassFile(path, ChangedClassFile.ChangeStatus.DELETED, className, classType));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (status.startsWith("M")) {
|
||||
ChangedClassFile modified = buildModified(path, oldSha, newSha, fallbackName, classType);
|
||||
if (modified != null) {
|
||||
result.add(modified);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pairDeleteAndAdd(deletions, additionsByParent, oldSha, result);
|
||||
result.addAll(deletions);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 处理 git R 状态:路径重命名 */
|
||||
private ChangedClassFile buildRenamed(String oldPathRaw, String newPathRaw,
|
||||
String oldSha, String newSha) throws IOException {
|
||||
String oldPath = normalizePath(oldPathRaw);
|
||||
String newPath = normalizePath(newPathRaw);
|
||||
if (!oldPath.endsWith(".java") || !newPath.endsWith(".java")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String oldFallback = ClassDeclParser.classNameFromPath(oldPath);
|
||||
String newFallback = ClassDeclParser.classNameFromPath(newPath);
|
||||
ClassType classType = ClassType.fromClassName(newFallback);
|
||||
if (classType == null) {
|
||||
classType = ClassType.fromClassName(oldFallback);
|
||||
}
|
||||
if (classType == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String oldSource = readFileAtCommit(oldSha, oldPath);
|
||||
String newSource = readFileAtCommit(newSha, newPath);
|
||||
String oldClassName = classDeclParser.resolveClassName(oldSource, oldFallback);
|
||||
String newClassName = classDeclParser.resolveClassName(newSource, newFallback);
|
||||
|
||||
return new ChangedClassFile(newPath, oldPath, ChangedClassFile.ChangeStatus.RENAMED,
|
||||
newClassName, oldClassName, classType);
|
||||
}
|
||||
|
||||
/** 处理 M 状态:同路径下对比 AST 类名判断是否重命名 */
|
||||
private ChangedClassFile buildModified(String path, String oldSha, String newSha,
|
||||
String fallbackName, ClassType classType) throws IOException {
|
||||
String oldSource = readFileAtCommit(oldSha, path);
|
||||
String newSource = readFileAtCommit(newSha, path);
|
||||
if (newSource == null || newSource.isBlank()) {
|
||||
newSource = readFileAtHead(path);
|
||||
}
|
||||
String oldClassName = classDeclParser.resolveClassName(oldSource, fallbackName);
|
||||
String newClassName = classDeclParser.resolveClassName(newSource, fallbackName);
|
||||
|
||||
if (oldClassName.equals(newClassName)) {
|
||||
return new ChangedClassFile(path, ChangedClassFile.ChangeStatus.MODIFIED,
|
||||
newClassName, classType);
|
||||
}
|
||||
return new ChangedClassFile(path, path, ChangedClassFile.ChangeStatus.RENAMED,
|
||||
newClassName, oldClassName, classType);
|
||||
}
|
||||
|
||||
/** 同目录 D+A 配对为 RENAMED(Git 未显式标记 R 时) */
|
||||
private void pairDeleteAndAdd(List<ChangedClassFile> deletions,
|
||||
Map<String, PendingAdd> additionsByParent,
|
||||
String oldSha,
|
||||
List<ChangedClassFile> result) throws IOException {
|
||||
List<ChangedClassFile> unpaired = new ArrayList<>();
|
||||
|
||||
for (ChangedClassFile deleted : deletions) {
|
||||
String parent = parentDir(deleted.getRelativePath());
|
||||
PendingAdd pending = additionsByParent.get(parent);
|
||||
if (pending == null || pending.isEmpty()) {
|
||||
unpaired.add(deleted);
|
||||
continue;
|
||||
}
|
||||
|
||||
PendingAdd.Candidate candidate = pending.poll(deleted.getClassType());
|
||||
if (candidate == null) {
|
||||
unpaired.add(deleted);
|
||||
continue;
|
||||
}
|
||||
|
||||
String oldSource = readFileAtCommit(oldSha, deleted.getRelativePath());
|
||||
String oldClassName = classDeclParser.resolveClassName(oldSource, deleted.getClassName());
|
||||
result.add(new ChangedClassFile(candidate.path(), deleted.getRelativePath(),
|
||||
ChangedClassFile.ChangeStatus.RENAMED,
|
||||
candidate.className(), oldClassName, deleted.getClassType()));
|
||||
}
|
||||
|
||||
deletions.clear();
|
||||
deletions.addAll(unpaired);
|
||||
}
|
||||
|
||||
/** 取路径父目录,用于 D+A 配对 */
|
||||
private static String parentDir(String path) {
|
||||
int idx = path.lastIndexOf('/');
|
||||
return idx >= 0 ? path.substring(0, idx) : "";
|
||||
}
|
||||
|
||||
/** 读取指定 commit 下的文件内容 */
|
||||
public String readFileAtCommit(String commitSha, String relativePath) throws IOException {
|
||||
List<String> lines = runGit("show", commitSha + ":" + relativePath);
|
||||
if (lines.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
if (lines.size() == 1 && lines.get(0).startsWith("fatal:")) {
|
||||
return "";
|
||||
}
|
||||
return String.join("\n", lines);
|
||||
}
|
||||
|
||||
/** 读取工作区 HEAD 文件(commit 中缺失时的回退) */
|
||||
public String readFileAtHead(String relativePath) throws IOException {
|
||||
Path file = repoRoot.resolve(relativePath);
|
||||
if (!Files.exists(file)) {
|
||||
return null;
|
||||
}
|
||||
return Files.readString(file, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
/** 两次提交间变更文件路径列表(--name-only) */
|
||||
public List<String> diffNameOnly(String oldSha, String newSha) throws IOException {
|
||||
return runGit("diff", "--name-only", oldSha, newSha);
|
||||
}
|
||||
|
||||
/** 在 repoRoot 下执行 git 命令并返回 stdout 行 */
|
||||
private List<String> runGit(String... args) throws IOException {
|
||||
String[] command = new String[args.length + 3];
|
||||
command[0] = "git";
|
||||
command[1] = "-C";
|
||||
command[2] = repoRoot.toString();
|
||||
System.arraycopy(args, 0, command, 3, args.length);
|
||||
|
||||
ProcessBuilder builder = new ProcessBuilder(command);
|
||||
builder.redirectErrorStream(true);
|
||||
Process process = builder.start();
|
||||
|
||||
List<String> output = new ArrayList<>();
|
||||
try (BufferedReader reader = new BufferedReader(
|
||||
new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
output.add(line);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
int exitCode = process.waitFor();
|
||||
if (exitCode != 0 && !isBenignGitShowFailure(args, output)) {
|
||||
throw new IOException("git 命令失败: " + String.join(" ", command)
|
||||
+ "\n" + String.join("\n", output));
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("git 命令被中断", e);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
/** git show 文件不存在等情况视为可忽略 */
|
||||
private boolean isBenignGitShowFailure(String[] args, List<String> output) {
|
||||
if (args.length > 0 && "show".equals(args[0])) {
|
||||
String joined = String.join("\n", output).toLowerCase(Locale.ROOT);
|
||||
return joined.contains("exists on disk") || joined.contains("bad object")
|
||||
|| joined.contains("path") && joined.contains("does not exist");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 统一路径分隔符为 / */
|
||||
private String normalizePath(String path) {
|
||||
return path.replace("\\", "/");
|
||||
}
|
||||
|
||||
/** 同目录新增文件缓冲,供 D+A 配对 */
|
||||
private static final class PendingAdd {
|
||||
private final Map<ClassType, List<Candidate>> byType = new HashMap<>();
|
||||
|
||||
void add(String path, String className, ClassType classType, String source) {
|
||||
byType.computeIfAbsent(classType, k -> new ArrayList<>())
|
||||
.add(new Candidate(path, className));
|
||||
}
|
||||
|
||||
boolean isEmpty() {
|
||||
return byType.values().stream().allMatch(List::isEmpty);
|
||||
}
|
||||
|
||||
/** 按类型取出一个候选新增文件 */
|
||||
Candidate poll(ClassType classType) {
|
||||
List<Candidate> list = byType.get(classType);
|
||||
if (list == null || list.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return list.remove(0);
|
||||
}
|
||||
|
||||
private static final class Candidate {
|
||||
private final String path;
|
||||
private final String className;
|
||||
|
||||
private Candidate(String path, String className) {
|
||||
this.path = path;
|
||||
this.className = className;
|
||||
}
|
||||
|
||||
private String path() {
|
||||
return path;
|
||||
}
|
||||
|
||||
private String className() {
|
||||
return className;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.codechecker.model;
|
||||
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 索引中的 HTTP/Feign 接口:方法、URI、入参/返回类型简单名。
|
||||
*/
|
||||
public class ApiEndpoint {
|
||||
private final String httpMethod;
|
||||
private final String uri;
|
||||
private final String sourceFile;
|
||||
private final Set<String> paramTypes;
|
||||
private final Set<String> returnTypes;
|
||||
|
||||
public ApiEndpoint(String httpMethod, String uri, String sourceFile,
|
||||
Set<String> paramTypes, Set<String> returnTypes) {
|
||||
this.httpMethod = httpMethod;
|
||||
this.uri = uri;
|
||||
this.sourceFile = sourceFile;
|
||||
this.paramTypes = paramTypes == null ? Set.of() : new LinkedHashSet<>(paramTypes);
|
||||
this.returnTypes = returnTypes == null ? Set.of() : new LinkedHashSet<>(returnTypes);
|
||||
}
|
||||
|
||||
public String getHttpMethod() {
|
||||
return httpMethod;
|
||||
}
|
||||
|
||||
public String getUri() {
|
||||
return uri;
|
||||
}
|
||||
|
||||
/** 定义该接口的 Java 源文件相对路径 */
|
||||
public String getSourceFile() {
|
||||
return sourceFile;
|
||||
}
|
||||
|
||||
/** 入参涉及的类型简单名集合 */
|
||||
public Set<String> getParamTypes() {
|
||||
return paramTypes;
|
||||
}
|
||||
|
||||
/** 返回值涉及的类型简单名集合(已剥离泛型包装) */
|
||||
public Set<String> getReturnTypes() {
|
||||
return returnTypes;
|
||||
}
|
||||
|
||||
/** 去重用键:METHOD + URI */
|
||||
public String endpointKey() {
|
||||
return httpMethod + " " + uri;
|
||||
}
|
||||
|
||||
/** 通知展示行:GET /api/foo */
|
||||
public String displayLine() {
|
||||
return httpMethod + " " + uri;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.codechecker.model;
|
||||
|
||||
/**
|
||||
* Git 扫描得到的单个 Java 模型类变更记录。
|
||||
*/
|
||||
public class ChangedClassFile {
|
||||
/** Git diff 状态:修改 / 删除 / 重命名 */
|
||||
public enum ChangeStatus {
|
||||
MODIFIED, DELETED, RENAMED
|
||||
}
|
||||
|
||||
private final String relativePath;
|
||||
private final String oldRelativePath;
|
||||
private final ChangeStatus status;
|
||||
private final String className;
|
||||
private final String oldClassName;
|
||||
private final ClassType classType;
|
||||
|
||||
/** 修改或删除(无路径变化) */
|
||||
public ChangedClassFile(String relativePath, ChangeStatus status, String className, ClassType classType) {
|
||||
this(relativePath, null, status, className, null, classType);
|
||||
}
|
||||
|
||||
/** 重命名或同路径类名变更 */
|
||||
public ChangedClassFile(String relativePath, String oldRelativePath, ChangeStatus status,
|
||||
String className, String oldClassName, ClassType classType) {
|
||||
this.relativePath = relativePath;
|
||||
this.oldRelativePath = oldRelativePath;
|
||||
this.status = status;
|
||||
this.className = className;
|
||||
this.oldClassName = oldClassName;
|
||||
this.classType = classType;
|
||||
}
|
||||
|
||||
/** 新提交中的相对路径 */
|
||||
public String getRelativePath() {
|
||||
return relativePath;
|
||||
}
|
||||
|
||||
/** 旧提交中的相对路径,未变路径则为 null */
|
||||
public String getOldRelativePath() {
|
||||
return oldRelativePath;
|
||||
}
|
||||
|
||||
/** 读取旧版本源码时使用的路径 */
|
||||
public String pathForOldCommit() {
|
||||
return oldRelativePath != null ? oldRelativePath : relativePath;
|
||||
}
|
||||
|
||||
public ChangeStatus getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
/** 当前简单类名 */
|
||||
public String getClassName() {
|
||||
return className;
|
||||
}
|
||||
|
||||
/** 重命名前简单类名 */
|
||||
public String getOldClassName() {
|
||||
return oldClassName;
|
||||
}
|
||||
|
||||
public ClassType getClassType() {
|
||||
return classType;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.codechecker.model;
|
||||
|
||||
/**
|
||||
* 单次类变更的类型,决定通知内容与影响分析策略。
|
||||
*/
|
||||
public enum ClassChangeKind {
|
||||
/** 文件已删除 */
|
||||
DELETED,
|
||||
/** 仅字段变更 */
|
||||
FIELDS_ONLY,
|
||||
/** 仅类名变更,字段不变 */
|
||||
RENAME_ONLY,
|
||||
/** 类名与字段同时变更 */
|
||||
RENAME_AND_FIELDS
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package com.codechecker.model;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 单次类变更的完整报告:变更类型、字段 diff、接口/转换影响,供通知渲染。
|
||||
*/
|
||||
public class ClassChangeReport {
|
||||
private final String className;
|
||||
private final String oldClassName;
|
||||
private final ClassType classType;
|
||||
private final ClassChangeKind changeKind;
|
||||
private final String sourceFile;
|
||||
private final String classDescription;
|
||||
private final List<FieldChange> fieldChanges = new ArrayList<>();
|
||||
private final List<ApiEndpoint> inputImpactEndpoints = new ArrayList<>();
|
||||
private final List<String> conversionEntities = new ArrayList<>();
|
||||
private final List<ApiEndpoint> frontendImpactEndpoints = new ArrayList<>();
|
||||
private final boolean conversionCheckEnabled;
|
||||
private List<String> objectRoleLabels = List.of();
|
||||
|
||||
public ClassChangeReport(String className, String oldClassName, ClassType classType,
|
||||
ClassChangeKind changeKind, String sourceFile,
|
||||
boolean conversionCheckEnabled, String classDescription) {
|
||||
this.className = className;
|
||||
this.oldClassName = oldClassName;
|
||||
this.classType = classType;
|
||||
this.changeKind = changeKind;
|
||||
this.sourceFile = sourceFile;
|
||||
this.conversionCheckEnabled = conversionCheckEnabled;
|
||||
this.classDescription = classDescription == null ? "" : classDescription.trim();
|
||||
}
|
||||
|
||||
/** 当前(新)简单类名 */
|
||||
public String getClassName() {
|
||||
return className;
|
||||
}
|
||||
|
||||
/** 重命名前的简单类名,未重命名则为 null */
|
||||
public String getOldClassName() {
|
||||
return oldClassName;
|
||||
}
|
||||
|
||||
/** 是否发生类名变更 */
|
||||
public boolean isRenamed() {
|
||||
return oldClassName != null && !oldClassName.equals(className);
|
||||
}
|
||||
|
||||
/** 是否仅类名变更、字段无变化 */
|
||||
public boolean isRenameOnly() {
|
||||
return changeKind == ClassChangeKind.RENAME_ONLY;
|
||||
}
|
||||
|
||||
public ClassType getClassType() {
|
||||
return classType;
|
||||
}
|
||||
|
||||
public ClassChangeKind getChangeKind() {
|
||||
return changeKind;
|
||||
}
|
||||
|
||||
/** Git 相对路径,通知「文件路径」展示用 */
|
||||
public String getSourceFile() {
|
||||
return sourceFile;
|
||||
}
|
||||
|
||||
/** 类级中文说明(@Schema / 类 Javadoc),无则空串 */
|
||||
public String getClassDescription() {
|
||||
return classDescription;
|
||||
}
|
||||
|
||||
/** 是否整文件删除 */
|
||||
public boolean isDeleted() {
|
||||
return changeKind == ClassChangeKind.DELETED;
|
||||
}
|
||||
|
||||
public List<FieldChange> getFieldChanges() {
|
||||
return fieldChanges;
|
||||
}
|
||||
|
||||
/** 入参引用该类的接口(request 影响) */
|
||||
public List<ApiEndpoint> getInputImpactEndpoints() {
|
||||
return inputImpactEndpoints;
|
||||
}
|
||||
|
||||
/** Dto→Entity 转换目标类名列表 */
|
||||
public List<String> getConversionEntities() {
|
||||
return conversionEntities;
|
||||
}
|
||||
|
||||
/** 返回值引用该类的接口(response 影响) */
|
||||
public List<ApiEndpoint> getFrontendImpactEndpoints() {
|
||||
return frontendImpactEndpoints;
|
||||
}
|
||||
|
||||
/** 是否启用类转换检测 */
|
||||
public boolean isConversionCheckEnabled() {
|
||||
return conversionCheckEnabled;
|
||||
}
|
||||
|
||||
/** 对象角色标签(如「嵌套对象」「顶层对象」),仅 Dto/Vo 且存在嵌套时非空 */
|
||||
public List<String> getObjectRoleLabels() {
|
||||
return objectRoleLabels;
|
||||
}
|
||||
|
||||
public void setObjectRoleLabels(List<String> labels) {
|
||||
this.objectRoleLabels = labels == null || labels.isEmpty() ? List.of() : List.copyOf(labels);
|
||||
}
|
||||
|
||||
/** 追加一条字段变更 */
|
||||
public void addFieldChange(FieldChange change) {
|
||||
fieldChanges.add(change);
|
||||
}
|
||||
|
||||
/** 追加 request 影响接口(按 endpointKey 去重) */
|
||||
public void addInputImpact(ApiEndpoint endpoint) {
|
||||
if (inputImpactEndpoints.stream().noneMatch(e -> e.endpointKey().equals(endpoint.endpointKey()))) {
|
||||
inputImpactEndpoints.add(endpoint);
|
||||
}
|
||||
}
|
||||
|
||||
/** 追加关联 Entity 类名(去重) */
|
||||
public void addConversionEntity(String entityName) {
|
||||
if (!conversionEntities.contains(entityName)) {
|
||||
conversionEntities.add(entityName);
|
||||
}
|
||||
}
|
||||
|
||||
/** 追加 response 影响接口(按 endpointKey 去重) */
|
||||
public void addFrontendImpact(ApiEndpoint endpoint) {
|
||||
if (frontendImpactEndpoints.stream().noneMatch(e -> e.endpointKey().equals(endpoint.endpointKey()))) {
|
||||
frontendImpactEndpoints.add(endpoint);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.codechecker.model;
|
||||
|
||||
/**
|
||||
* 目标模型类后缀类型,决定通知模版中展示哪些影响段落。
|
||||
*/
|
||||
public enum ClassType {
|
||||
DTO("Dto"),
|
||||
VO("Vo"),
|
||||
ENTITY("Entity"),
|
||||
MODEL("Model");
|
||||
|
||||
private final String label;
|
||||
|
||||
ClassType(String label) {
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
/** 通知中展示的类型标签 */
|
||||
public String getLabel() {
|
||||
return label;
|
||||
}
|
||||
|
||||
/** 根据简单类名后缀识别类型,不匹配则 null */
|
||||
public static ClassType fromClassName(String className) {
|
||||
if (className.endsWith("Dto")) {
|
||||
return DTO;
|
||||
}
|
||||
if (className.endsWith("VO")) {
|
||||
return VO;
|
||||
}
|
||||
if (className.endsWith("Vo")) {
|
||||
return VO;
|
||||
}
|
||||
if (className.endsWith("Entity")) {
|
||||
return ENTITY;
|
||||
}
|
||||
if (className.endsWith("Model")) {
|
||||
return MODEL;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 判断类名是否属于当前类型 */
|
||||
public boolean isTargetSuffix(String className) {
|
||||
return fromClassName(className) == this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.codechecker.model;
|
||||
|
||||
/**
|
||||
* 字段级 diff 结果,用于通知中的 [新增]/[删除]/[修改]/[重命名] 行。
|
||||
*/
|
||||
public class FieldChange {
|
||||
/** 字段变更种类 */
|
||||
public enum ChangeKind {
|
||||
ADDED, REMOVED, MODIFIED, RENAMED
|
||||
}
|
||||
|
||||
private final ChangeKind kind;
|
||||
private final String fieldName;
|
||||
private final String oldFieldName;
|
||||
private final String description;
|
||||
private final String oldType;
|
||||
private final String newType;
|
||||
private final String oldDescription;
|
||||
private final String detail;
|
||||
|
||||
private FieldChange(ChangeKind kind, String fieldName, String oldFieldName, String description,
|
||||
String oldType, String newType, String oldDescription, String detail) {
|
||||
this.kind = kind;
|
||||
this.fieldName = fieldName;
|
||||
this.oldFieldName = oldFieldName;
|
||||
this.description = description;
|
||||
this.oldType = oldType;
|
||||
this.newType = newType;
|
||||
this.oldDescription = oldDescription;
|
||||
this.detail = detail;
|
||||
}
|
||||
|
||||
/** 构造新增字段变更 */
|
||||
public static FieldChange added(FieldInfo field) {
|
||||
return new FieldChange(ChangeKind.ADDED, field.getName(), null, field.getDescription(),
|
||||
null, field.getType(), null, null);
|
||||
}
|
||||
|
||||
/** 构造删除字段变更 */
|
||||
public static FieldChange removed(FieldInfo field) {
|
||||
return new FieldChange(ChangeKind.REMOVED, field.getName(), null, field.getDescription(),
|
||||
field.getType(), null, field.getDescription(), null);
|
||||
}
|
||||
|
||||
/** 构造修改字段变更,detail 通常为类型变化描述 */
|
||||
public static FieldChange modified(FieldInfo oldField, FieldInfo newField, String detail) {
|
||||
return new FieldChange(ChangeKind.MODIFIED, newField.getName(), null, newField.getDescription(),
|
||||
oldField.getType(), newField.getType(), oldField.getDescription(), detail);
|
||||
}
|
||||
|
||||
/** 构造字段重命名;类型变化时 detail 为 oldType → newType */
|
||||
public static FieldChange renamed(FieldInfo oldField, FieldInfo newField) {
|
||||
String typeDetail = oldField.getType().equals(newField.getType())
|
||||
? null
|
||||
: oldField.getType() + " → " + newField.getType();
|
||||
return new FieldChange(ChangeKind.RENAMED, newField.getName(), oldField.getName(),
|
||||
newField.getDescription(), oldField.getType(), newField.getType(),
|
||||
oldField.getDescription(), typeDetail);
|
||||
}
|
||||
|
||||
public ChangeKind getKind() {
|
||||
return kind;
|
||||
}
|
||||
|
||||
public String getFieldName() {
|
||||
return fieldName;
|
||||
}
|
||||
|
||||
/** 重命名前的字段名,仅 RENAMED 时有值 */
|
||||
public String getOldFieldName() {
|
||||
return oldFieldName;
|
||||
}
|
||||
|
||||
/** 变更后的字段说明(通知「说明」段) */
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
public String getOldType() {
|
||||
return oldType;
|
||||
}
|
||||
|
||||
public String getNewType() {
|
||||
return newType;
|
||||
}
|
||||
|
||||
public String getOldDescription() {
|
||||
return oldDescription;
|
||||
}
|
||||
|
||||
/** 结构性变更详情,重命名时为类型变化描述 */
|
||||
public String getDetail() {
|
||||
return detail;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.codechecker.model;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 解析后的单个字段:名称、类型、业务说明(Schema/注释)。
|
||||
*/
|
||||
public class FieldInfo {
|
||||
private final String name;
|
||||
private final String type;
|
||||
private final String description;
|
||||
|
||||
public FieldInfo(String name, String type, String description) {
|
||||
this.name = name;
|
||||
this.type = type;
|
||||
this.description = description == null ? "" : description;
|
||||
}
|
||||
|
||||
/** 字段名 */
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
/** 字段类型(简单名) */
|
||||
public String getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
/** 字段说明文案 */
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) {
|
||||
return true;
|
||||
}
|
||||
if (!(o instanceof FieldInfo)) {
|
||||
return false;
|
||||
}
|
||||
FieldInfo other = (FieldInfo) o;
|
||||
return Objects.equals(name, other.name)
|
||||
&& Objects.equals(type, other.type)
|
||||
&& Objects.equals(description, other.description);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(name, type, description);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package com.codechecker.notify;
|
||||
|
||||
import com.codechecker.analyzer.DtoNestIndex;
|
||||
import com.codechecker.api.model.ApiChangeKind;
|
||||
import com.codechecker.api.model.EndpointChangeReport;
|
||||
import com.codechecker.api.model.ParameterChange;
|
||||
import com.codechecker.config.DtoOverlapMode;
|
||||
import com.codechecker.model.ApiEndpoint;
|
||||
import com.codechecker.model.ClassChangeReport;
|
||||
import com.codechecker.model.ClassType;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 按配置过滤 Dto 类变更与 API 参数变更的重叠通知。
|
||||
*/
|
||||
public class OverlapNotificationFilter {
|
||||
|
||||
public static final class FilterResult {
|
||||
private final List<ClassChangeReport> classReports;
|
||||
private final List<EndpointChangeReport> apiReports;
|
||||
|
||||
public FilterResult(List<ClassChangeReport> classReports, List<EndpointChangeReport> apiReports) {
|
||||
this.classReports = classReports;
|
||||
this.apiReports = apiReports;
|
||||
}
|
||||
|
||||
public List<ClassChangeReport> classReports() {
|
||||
return classReports;
|
||||
}
|
||||
|
||||
public List<EndpointChangeReport> apiReports() {
|
||||
return apiReports;
|
||||
}
|
||||
}
|
||||
|
||||
public static FilterResult apply(List<ClassChangeReport> classReports,
|
||||
List<EndpointChangeReport> apiReports,
|
||||
DtoOverlapMode mode,
|
||||
DtoNestIndex nestIndex) {
|
||||
if (mode == DtoOverlapMode.BOTH) {
|
||||
return new FilterResult(classReports, apiReports);
|
||||
}
|
||||
Set<OverlapKey> overlapKeys = buildOverlapKeys(classReports, nestIndex);
|
||||
if (overlapKeys.isEmpty()) {
|
||||
return new FilterResult(classReports, apiReports);
|
||||
}
|
||||
if (mode == DtoOverlapMode.CLASS_ONLY) {
|
||||
return new FilterResult(classReports, filterApiReports(apiReports, overlapKeys));
|
||||
}
|
||||
Set<OverlapKey> apiOverlapKeys = buildApiOverlapKeys(apiReports);
|
||||
return new FilterResult(filterClassReportsForApiOnly(classReports, apiOverlapKeys, nestIndex), apiReports);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重叠键使用 @RequestBody 根 Dto(如 PunishmentsApprovalDto),与 API 参数通知 parentDto 对齐;
|
||||
* 嵌套子 Dto(如 UserSelfDto)通过 nestIndex 解析到根 Dto。
|
||||
*/
|
||||
private static Set<OverlapKey> buildOverlapKeys(List<ClassChangeReport> classReports,
|
||||
DtoNestIndex nestIndex) {
|
||||
Set<OverlapKey> keys = new LinkedHashSet<>();
|
||||
for (ClassChangeReport report : classReports) {
|
||||
if (report.getClassType() != ClassType.DTO) {
|
||||
continue;
|
||||
}
|
||||
if (!hasDtoFieldChanges(report)) {
|
||||
continue;
|
||||
}
|
||||
Set<String> bodyRoots = requestBodyRoots(report, nestIndex);
|
||||
for (ApiEndpoint endpoint : report.getInputImpactEndpoints()) {
|
||||
for (String rootDto : bodyRoots) {
|
||||
keys.add(new OverlapKey(rootDto, endpoint.endpointKey()));
|
||||
}
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
private static Set<String> requestBodyRoots(ClassChangeReport report, DtoNestIndex nestIndex) {
|
||||
Set<String> roots = new LinkedHashSet<>();
|
||||
if (nestIndex != null) {
|
||||
roots.addAll(nestIndex.findRequestBodyRoots(report.getClassName()));
|
||||
if (report.getOldClassName() != null && !report.getOldClassName().isBlank()) {
|
||||
roots.addAll(nestIndex.findRequestBodyRoots(report.getOldClassName()));
|
||||
}
|
||||
}
|
||||
if (roots.isEmpty() && report.getClassName().endsWith("Dto")) {
|
||||
roots.add(report.getClassName());
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
private static List<EndpointChangeReport> filterApiReports(List<EndpointChangeReport> apiReports,
|
||||
Set<OverlapKey> overlapKeys) {
|
||||
List<EndpointChangeReport> kept = new ArrayList<>();
|
||||
for (EndpointChangeReport report : apiReports) {
|
||||
if (!matchesOverlap(report, overlapKeys)) {
|
||||
kept.add(report);
|
||||
}
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
|
||||
private static List<ClassChangeReport> filterClassReportsForApiOnly(List<ClassChangeReport> classReports,
|
||||
Set<OverlapKey> apiOverlapKeys,
|
||||
DtoNestIndex nestIndex) {
|
||||
List<ClassChangeReport> kept = new ArrayList<>();
|
||||
for (ClassChangeReport report : classReports) {
|
||||
if (!shouldSuppressClassForApiOnly(report, apiOverlapKeys, nestIndex)) {
|
||||
kept.add(report);
|
||||
}
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
|
||||
private static Set<OverlapKey> buildApiOverlapKeys(List<EndpointChangeReport> apiReports) {
|
||||
Set<OverlapKey> keys = new LinkedHashSet<>();
|
||||
for (EndpointChangeReport report : apiReports) {
|
||||
if (report.getChangeKind() != ApiChangeKind.PARAM_CHANGED) {
|
||||
continue;
|
||||
}
|
||||
String endpointKey = report.getHttpMethod() + " " + report.getUri();
|
||||
for (ParameterChange change : report.getParameterChanges()) {
|
||||
if (!"body".equals(change.getSource())) {
|
||||
continue;
|
||||
}
|
||||
String parentDto = change.getParentDto();
|
||||
if (parentDto != null && !parentDto.isBlank()) {
|
||||
keys.add(new OverlapKey(parentDto, endpointKey));
|
||||
}
|
||||
}
|
||||
if (report.isDtoFollowUp()) {
|
||||
String relatedDto = report.getRelatedDtoClassName();
|
||||
if (relatedDto != null && !relatedDto.isBlank()) {
|
||||
keys.add(new OverlapKey(relatedDto, endpointKey));
|
||||
}
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
private static boolean shouldSuppressClassForApiOnly(ClassChangeReport report,
|
||||
Set<OverlapKey> apiOverlapKeys,
|
||||
DtoNestIndex nestIndex) {
|
||||
if (report.getClassType() != ClassType.DTO || !hasDtoFieldChanges(report)) {
|
||||
return false;
|
||||
}
|
||||
Set<String> bodyRoots = requestBodyRoots(report, nestIndex);
|
||||
for (ApiEndpoint endpoint : report.getInputImpactEndpoints()) {
|
||||
for (String rootDto : bodyRoots) {
|
||||
if (apiOverlapKeys.contains(new OverlapKey(rootDto, endpoint.endpointKey()))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean matchesOverlap(EndpointChangeReport report, Set<OverlapKey> overlapKeys) {
|
||||
if (report.getChangeKind() != ApiChangeKind.PARAM_CHANGED) {
|
||||
return false;
|
||||
}
|
||||
String endpointKey = report.getHttpMethod() + " " + report.getUri();
|
||||
for (ParameterChange change : report.getParameterChanges()) {
|
||||
if (!"body".equals(change.getSource())) {
|
||||
continue;
|
||||
}
|
||||
String parentDto = change.getParentDto();
|
||||
if (parentDto == null || parentDto.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
if (overlapKeys.contains(new OverlapKey(parentDto, endpointKey))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (report.isDtoFollowUp()) {
|
||||
String relatedDto = report.getRelatedDtoClassName();
|
||||
if (relatedDto != null && overlapKeys.contains(new OverlapKey(relatedDto, endpointKey))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean hasDtoFieldChanges(ClassChangeReport report) {
|
||||
return !report.getFieldChanges().isEmpty();
|
||||
}
|
||||
|
||||
private static final class OverlapKey {
|
||||
private final String dtoClassName;
|
||||
private final String endpointKey;
|
||||
|
||||
private OverlapKey(String dtoClassName, String endpointKey) {
|
||||
this.dtoClassName = dtoClassName;
|
||||
this.endpointKey = endpointKey;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object obj) {
|
||||
if (this == obj) {
|
||||
return true;
|
||||
}
|
||||
if (!(obj instanceof OverlapKey)) {
|
||||
return false;
|
||||
}
|
||||
OverlapKey other = (OverlapKey) obj;
|
||||
return dtoClassName.equals(other.dtoClassName) && endpointKey.equals(other.endpointKey);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return dtoClassName.hashCode() * 31 + endpointKey.hashCode();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
package com.codechecker.notify;
|
||||
|
||||
import com.codechecker.model.ApiEndpoint;
|
||||
import com.codechecker.model.ClassChangeReport;
|
||||
import com.codechecker.model.ClassType;
|
||||
import com.codechecker.model.FieldChange;
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 将 ClassChangeReport 渲染为企业微信 Markdown 并发送(或仅日志输出)。
|
||||
* <p>
|
||||
* 使用 webhook {@code markdown}(v1):引用块 + 换行排版,三色 font(info/comment/warning)。
|
||||
* v1 不支持无序列表,各项以 {@code >标签: 值} 分行展示(冒号后两空格)。
|
||||
*/
|
||||
public class WeComNotifier {
|
||||
private static final int MAX_LENGTH = 3800;
|
||||
private static final MediaType JSON = MediaType.get("application/json; charset=utf-8");
|
||||
|
||||
private final OkHttpClient client = new OkHttpClient.Builder()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(10, TimeUnit.SECONDS)
|
||||
.build();
|
||||
|
||||
/** 逐条发送企微通知,返回成功条数 */
|
||||
public int sendAll(String webhookUrl, List<ClassChangeReport> reports, String modifier, String modifyTime) {
|
||||
if (reports == null || reports.isEmpty()) {
|
||||
System.out.println("无类变更,不发送到企业微信");
|
||||
return 0;
|
||||
}
|
||||
|
||||
int sent = 0;
|
||||
for (ClassChangeReport report : reports) {
|
||||
String markdown = buildMarkdown(report, modifier, modifyTime);
|
||||
if (postMarkdown(webhookUrl, markdown)) {
|
||||
sent++;
|
||||
System.out.println("已发送类变更通知: " + report.getClassName());
|
||||
}
|
||||
}
|
||||
if (sent > 0) {
|
||||
System.out.println("总共发送 " + sent + " 条类变更通知到企业微信");
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
|
||||
/** 企微关闭时打印 Markdown 到控制台 */
|
||||
public void logAll(List<ClassChangeReport> reports, String modifier, String modifyTime) {
|
||||
if (reports == null || reports.isEmpty()) {
|
||||
System.out.println("无类变更,无日志输出");
|
||||
return;
|
||||
}
|
||||
|
||||
System.out.println("企业微信通知已关闭(wecom.enabled=false),以下结果仅输出到日志:");
|
||||
for (int i = 0; i < reports.size(); i++) {
|
||||
ClassChangeReport report = reports.get(i);
|
||||
System.out.println("========== 类变更 [" + (i + 1) + "/" + reports.size()
|
||||
+ "]: " + report.getClassName() + " ==========");
|
||||
System.out.println(buildMarkdown(report, modifier, modifyTime));
|
||||
System.out.println("========== 结束 ==========");
|
||||
}
|
||||
System.out.println("共 " + reports.size() + " 条类变更结果(未发送到企业微信)");
|
||||
}
|
||||
|
||||
/** 组装完整 Markdown 正文(引用块 + 换行,每项独立一行) */
|
||||
public String buildMarkdown(ClassChangeReport report, String modifier, String modifyTime) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("# 【类变更通知】").append("\n\n");
|
||||
appendHeader(sb, report, modifier, modifyTime);
|
||||
|
||||
sb.append("\n## 【对象变更细节】").append("\n\n");
|
||||
appendChangeDetails(sb, report);
|
||||
|
||||
sb.append("\n## 【影响范围】").append("\n\n");
|
||||
appendImpactSections(sb, report);
|
||||
return truncate(sb.toString());
|
||||
}
|
||||
|
||||
/** 变更对象行:类名(绿)+ 可选中文说明 + 嵌套角色标签(灰,整行加粗) */
|
||||
private String formatChangeTarget(ClassChangeReport report) {
|
||||
StringBuilder line = new StringBuilder(colorInfo(safe(report.getClassName())));
|
||||
String description = report.getClassDescription();
|
||||
if (description != null && !description.isBlank()) {
|
||||
line.append("(").append(colorComment(description)).append(")");
|
||||
}
|
||||
for (String role : report.getObjectRoleLabels()) {
|
||||
line.append("(").append(colorComment(role)).append(")");
|
||||
}
|
||||
return line.toString();
|
||||
}
|
||||
|
||||
/** 头部元信息,每项一行引用(加粗) */
|
||||
private void appendHeader(StringBuilder sb, ClassChangeReport report,
|
||||
String modifier, String modifyTime) {
|
||||
sb.append(quoteKvBold("变更对象", formatChangeTarget(report))).append("\n");
|
||||
sb.append(quoteKvBold("修改人", colorComment(modifier))).append("\n");
|
||||
sb.append(quoteKvBold("时间", colorComment(modifyTime))).append("\n");
|
||||
sb.append(quoteKvBold("路径", colorComment(report.getSourceFile()))).append("\n");
|
||||
}
|
||||
|
||||
/** 渲染删除 / 重命名 / 字段变更 */
|
||||
private void appendChangeDetails(StringBuilder sb, ClassChangeReport report) {
|
||||
if (report.isDeleted()) {
|
||||
sb.append(quoteLine(colorWarning("[已删除]") + " "
|
||||
+ colorComment("该类文件已被移除"))).append("\n");
|
||||
return;
|
||||
}
|
||||
|
||||
if (report.isRenamed()) {
|
||||
sb.append(quoteLine(colorWarning("[类名变更]") + " "
|
||||
+ colorComment(safe(report.getOldClassName())) + " → "
|
||||
+ colorInfo(safe(report.getClassName())))).append("\n");
|
||||
}
|
||||
|
||||
if (report.isRenameOnly()) {
|
||||
sb.append(quoteLine(colorComment("字段无变化"))).append("\n");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!report.getFieldChanges().isEmpty()) {
|
||||
int count = report.getFieldChanges().size();
|
||||
sb.append(quoteLine("**共 " + colorWarning(String.valueOf(count)) + " 项变更**"))
|
||||
.append("\n\n");
|
||||
for (int i = 0; i < report.getFieldChanges().size(); i++) {
|
||||
if (i > 0) {
|
||||
sb.append("\n");
|
||||
}
|
||||
sb.append(formatFieldChange(report.getFieldChanges().get(i)));
|
||||
}
|
||||
sb.append("\n");
|
||||
}
|
||||
}
|
||||
|
||||
/** 按类类型选择影响段落 */
|
||||
private void appendImpactSections(StringBuilder sb, ClassChangeReport report) {
|
||||
appendImpactByType(sb, report);
|
||||
}
|
||||
|
||||
/** Dto/Vo 均展示 request + response(二者可能交叉);Entity/Model 仅类转换 */
|
||||
private void appendImpactByType(StringBuilder sb, ClassChangeReport report) {
|
||||
switch (report.getClassType()) {
|
||||
case DTO:
|
||||
case VO:
|
||||
appendSectionIfNeeded(sb, report, true, true, true);
|
||||
break;
|
||||
case ENTITY:
|
||||
case MODEL:
|
||||
appendSectionIfNeeded(sb, report, false, false, true);
|
||||
break;
|
||||
default:
|
||||
appendSectionIfNeeded(sb, report, true, true, true);
|
||||
}
|
||||
}
|
||||
|
||||
/** 按需追加 request / response / 类转换三个小节 */
|
||||
private void appendSectionIfNeeded(StringBuilder sb, ClassChangeReport report,
|
||||
boolean showRequest, boolean showResponse, boolean showConversion) {
|
||||
if (showRequest) {
|
||||
sb.append("### 影响 request 接口").append("\n");
|
||||
appendEndpointList(sb, report.getInputImpactEndpoints());
|
||||
sb.append("\n");
|
||||
}
|
||||
if (showResponse) {
|
||||
sb.append("### 影响 response 接口").append("\n");
|
||||
appendEndpointList(sb, report.getFrontendImpactEndpoints());
|
||||
sb.append("\n");
|
||||
}
|
||||
if (showConversion && report.isConversionCheckEnabled()) {
|
||||
sb.append("### 类转换影响").append("\n");
|
||||
appendConversionList(sb, report);
|
||||
}
|
||||
}
|
||||
|
||||
/** 渲染关联 Entity,每项一行 */
|
||||
private void appendConversionList(StringBuilder sb, ClassChangeReport report) {
|
||||
if (report.getConversionEntities().isEmpty()) {
|
||||
sb.append(quoteLine(colorComment("无"))).append("\n");
|
||||
return;
|
||||
}
|
||||
for (String entity : report.getConversionEntities()) {
|
||||
sb.append(quoteKv("Entity", colorInfo(safe(entity)))).append("\n");
|
||||
}
|
||||
}
|
||||
|
||||
/** 渲染接口,每项一行 */
|
||||
private void appendEndpointList(StringBuilder sb, List<ApiEndpoint> endpoints) {
|
||||
if (endpoints == null || endpoints.isEmpty()) {
|
||||
sb.append(quoteLine(colorComment("无"))).append("\n");
|
||||
return;
|
||||
}
|
||||
for (ApiEndpoint endpoint : endpoints) {
|
||||
sb.append(formatEndpointLine(endpoint)).append("\n");
|
||||
}
|
||||
}
|
||||
|
||||
/** 接口行:> POST `/path` */
|
||||
private String formatEndpointLine(ApiEndpoint endpoint) {
|
||||
String line = endpoint.displayLine();
|
||||
int space = line.indexOf(' ');
|
||||
if (space > 0) {
|
||||
String method = line.substring(0, space).trim();
|
||||
String path = line.substring(space).trim();
|
||||
return quoteLine(colorInfo(method) + " " + inlineCode(path));
|
||||
}
|
||||
return quoteLine(inlineCode(safe(line)));
|
||||
}
|
||||
|
||||
/** 单条字段变更:标签、说明、类型合并为一行,字段间空行分隔 */
|
||||
private String formatFieldChange(FieldChange change) {
|
||||
String fieldName = inlineCode(safe(change.getFieldName()));
|
||||
String desc = change.getDescription() == null ? "" : change.getDescription();
|
||||
String descPart = desc.isBlank()
|
||||
? colorComment("(无说明)")
|
||||
: colorComment(desc);
|
||||
|
||||
switch (change.getKind()) {
|
||||
case ADDED: {
|
||||
StringBuilder line = new StringBuilder();
|
||||
line.append(tagAdded()).append(" ").append(fieldName)
|
||||
.append(" 说明: ").append(descPart);
|
||||
appendFieldType(line, change);
|
||||
return quoteLine(line.toString());
|
||||
}
|
||||
case REMOVED: {
|
||||
StringBuilder line = new StringBuilder();
|
||||
line.append(tagRemoved()).append(" ").append(fieldName)
|
||||
.append(" 说明: ").append(descPart);
|
||||
appendFieldType(line, change);
|
||||
return quoteLine(line.toString());
|
||||
}
|
||||
case RENAMED: {
|
||||
StringBuilder renameLine = new StringBuilder();
|
||||
renameLine.append(tagRenamed()).append(" ")
|
||||
.append(colorComment(safe(change.getOldFieldName()))).append(" → ")
|
||||
.append(colorInfo(safe(change.getFieldName())))
|
||||
.append(" 说明: ").append(descPart);
|
||||
appendFieldType(renameLine, change);
|
||||
return quoteLine(renameLine.toString());
|
||||
}
|
||||
case MODIFIED:
|
||||
default: {
|
||||
StringBuilder line = new StringBuilder();
|
||||
line.append(tagModified()).append(" ").append(fieldName)
|
||||
.append(" 说明: ").append(descPart);
|
||||
appendFieldType(line, change);
|
||||
return quoteLine(line.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 追加字段类型:新增/重命名(仅改名)用 info,删除用 warning,修改/重命名(改类型)用 old → new */
|
||||
private void appendFieldType(StringBuilder line, FieldChange change) {
|
||||
if (change.getKind() == FieldChange.ChangeKind.RENAMED
|
||||
|| change.getKind() == FieldChange.ChangeKind.MODIFIED) {
|
||||
String typeDetail = change.getDetail();
|
||||
if (typeDetail != null && !typeDetail.isBlank()) {
|
||||
line.append(" 类型: ").append(formatTypeChange(typeDetail));
|
||||
return;
|
||||
}
|
||||
}
|
||||
String singleType = change.getKind() == FieldChange.ChangeKind.REMOVED
|
||||
? change.getOldType()
|
||||
: change.getNewType();
|
||||
if (singleType == null || singleType.isBlank()) {
|
||||
return;
|
||||
}
|
||||
line.append(" 类型: ");
|
||||
if (change.getKind() == FieldChange.ChangeKind.REMOVED) {
|
||||
line.append(colorWarning(singleType));
|
||||
} else {
|
||||
line.append(colorInfo(singleType));
|
||||
}
|
||||
}
|
||||
|
||||
/** 类型变化:旧 warning → 新 info;泛型尖括号原样展示,不做 HTML 转义 */
|
||||
private 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);
|
||||
}
|
||||
|
||||
private String tagAdded() {
|
||||
return colorInfo("[新增]");
|
||||
}
|
||||
|
||||
private String tagRemoved() {
|
||||
return colorWarning("[删除]");
|
||||
}
|
||||
|
||||
private String tagModified() {
|
||||
return colorWarning("[修改]");
|
||||
}
|
||||
|
||||
private String tagRenamed() {
|
||||
return colorWarning("[重命名]");
|
||||
}
|
||||
|
||||
/** 引用行:{@code >标签: 值}(冒号后两空格) */
|
||||
private String quoteKv(String key, String value) {
|
||||
return "> " + key + ": " + value;
|
||||
}
|
||||
|
||||
/** 加粗引用行:用于类变更通知头部 */
|
||||
private String quoteKvBold(String key, String value) {
|
||||
return "> **" + key + ": " + value + "**";
|
||||
}
|
||||
|
||||
/** 纯引用行 */
|
||||
private String quoteLine(String content) {
|
||||
return "> " + content;
|
||||
}
|
||||
|
||||
/** 行内代码 */
|
||||
private String inlineCode(String text) {
|
||||
return "`" + text.replace("`", "'") + "`";
|
||||
}
|
||||
|
||||
private String colorInfo(String text) {
|
||||
return "<font color=\"info\">" + text + "</font>";
|
||||
}
|
||||
|
||||
private String colorComment(String text) {
|
||||
return "<font color=\"comment\">" + safe(text) + "</font>";
|
||||
}
|
||||
|
||||
private String colorWarning(String text) {
|
||||
return "<font color=\"warning\">" + text + "</font>";
|
||||
}
|
||||
|
||||
/** 转义 HTML 特殊字符,避免破坏 font 标签 */
|
||||
private String safe(String text) {
|
||||
if (text == null) {
|
||||
return "";
|
||||
}
|
||||
return text.replace("&", "&").replace("<", "<").replace(">", ">");
|
||||
}
|
||||
|
||||
/** POST 企微 Webhook(markdown v1) */
|
||||
private boolean postMarkdown(String webhookUrl, String content) {
|
||||
if (webhookUrl == null || webhookUrl.isBlank() || webhookUrl.contains("YOUR_WECOM")) {
|
||||
System.out.println("[警告] 未配置有效的企业微信 Webhook URL");
|
||||
System.out.println("--- 通知预览 ---");
|
||||
System.out.println(content.length() > 1000 ? content.substring(0, 1000) : content);
|
||||
return false;
|
||||
}
|
||||
|
||||
String payload = "{\"msgtype\":\"markdown\",\"markdown\":{\"content\":"
|
||||
+ jsonEscape(content) + "}}";
|
||||
Request request = new Request.Builder()
|
||||
.url(webhookUrl)
|
||||
.post(RequestBody.create(payload, JSON))
|
||||
.build();
|
||||
try (Response response = client.newCall(request).execute()) {
|
||||
if (response.isSuccessful() && response.body() != null) {
|
||||
String body = response.body().string();
|
||||
return body.contains("\"errcode\":0");
|
||||
}
|
||||
System.out.println("[错误] 企微返回异常: " + response.code()
|
||||
+ (response.body() != null ? " " + response.body().string() : ""));
|
||||
return false;
|
||||
} catch (IOException e) {
|
||||
System.out.println("[错误] 发送企微消息失败: " + e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 超长消息截断(企微上限 4096 字节 UTF-8) */
|
||||
private String truncate(String text) {
|
||||
if (text.length() <= MAX_LENGTH) {
|
||||
return text;
|
||||
}
|
||||
return text.substring(0, MAX_LENGTH) + "\n\n... 消息过长,已截断";
|
||||
}
|
||||
|
||||
/** JSON 字符串转义 */
|
||||
private String jsonEscape(String text) {
|
||||
String escaped = text
|
||||
.replace("\\", "\\\\")
|
||||
.replace("\"", "\\\"")
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "");
|
||||
return "\"" + escaped + "\"";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package com.codechecker.parser;
|
||||
|
||||
import com.github.javaparser.StaticJavaParser;
|
||||
import com.github.javaparser.ast.CompilationUnit;
|
||||
import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration;
|
||||
import com.github.javaparser.ast.body.TypeDeclaration;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 从 Java 源文件路径或 AST 解析类名(简单名 / 全限定名)及类级中文说明。
|
||||
*/
|
||||
public class ClassDeclParser {
|
||||
|
||||
/**
|
||||
* 从源码 AST 提取主类名;解析失败或未找到时回退为路径推导的类名。
|
||||
*/
|
||||
public String resolveClassName(String source, String fallbackFromPath) {
|
||||
if (source == null || source.isBlank()) {
|
||||
return fallbackFromPath;
|
||||
}
|
||||
try {
|
||||
CompilationUnit cu = StaticJavaParser.parse(source);
|
||||
for (TypeDeclaration<?> type : cu.getTypes()) {
|
||||
if (type instanceof ClassOrInterfaceDeclaration) {
|
||||
return type.getNameAsString();
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// 回退路径类名
|
||||
}
|
||||
return fallbackFromPath;
|
||||
}
|
||||
|
||||
/** 从 .java 路径提取文件名(无扩展名)作为类名 */
|
||||
public static String classNameFromPath(String path) {
|
||||
String fileName = path.substring(path.lastIndexOf('/') + 1);
|
||||
if (!fileName.endsWith(".java")) {
|
||||
return fileName;
|
||||
}
|
||||
return fileName.substring(0, fileName.length() - 5);
|
||||
}
|
||||
|
||||
/**
|
||||
* 全限定类名:package + 类名;源码无 package 时从文件路径推断。
|
||||
*/
|
||||
public String resolveQualifiedClassName(String source, String relativePath, String fallbackClassName) {
|
||||
String simpleName = resolveClassName(source, fallbackClassName);
|
||||
if (source != null && !source.isBlank()) {
|
||||
try {
|
||||
CompilationUnit cu = StaticJavaParser.parse(source);
|
||||
String packageName = cu.getPackageDeclaration()
|
||||
.map(p -> p.getNameAsString())
|
||||
.orElse("");
|
||||
if (!packageName.isBlank()) {
|
||||
return packageName + "." + simpleName;
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// 回退路径推断
|
||||
}
|
||||
}
|
||||
return inferQualifiedFromPath(relativePath, simpleName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取类级中文说明:@Schema(description/title) > 类 Javadoc 首段。
|
||||
*/
|
||||
public String extractClassDescription(String source, String expectedClassName) {
|
||||
if (source == null || source.isBlank()) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
CompilationUnit cu = StaticJavaParser.parse(source);
|
||||
ClassOrInterfaceDeclaration classDecl = findClass(cu, expectedClassName);
|
||||
if (classDecl == null) {
|
||||
return "";
|
||||
}
|
||||
String fromSchema = readSchemaDescription(classDecl);
|
||||
if (!fromSchema.isEmpty()) {
|
||||
return fromSchema;
|
||||
}
|
||||
return extractClassJavadoc(classDecl);
|
||||
} catch (Exception ignored) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private ClassOrInterfaceDeclaration findClass(CompilationUnit cu, String expectedClassName) {
|
||||
if (expectedClassName != null && !expectedClassName.isBlank()) {
|
||||
for (TypeDeclaration<?> type : cu.getTypes()) {
|
||||
if (type instanceof ClassOrInterfaceDeclaration) {
|
||||
ClassOrInterfaceDeclaration classDecl = (ClassOrInterfaceDeclaration) type;
|
||||
if (classDecl.getNameAsString().equals(expectedClassName)) {
|
||||
return classDecl;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (TypeDeclaration<?> type : cu.getTypes()) {
|
||||
if (type instanceof ClassOrInterfaceDeclaration) {
|
||||
return (ClassOrInterfaceDeclaration) type;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String readSchemaDescription(ClassOrInterfaceDeclaration classDecl) {
|
||||
for (AnnotationExpr annotation : classDecl.getAnnotations()) {
|
||||
if (!"Schema".equals(annotation.getNameAsString())) {
|
||||
continue;
|
||||
}
|
||||
String description = readAnnotationStringValue(annotation, "description");
|
||||
if (!description.isEmpty()) {
|
||||
return description;
|
||||
}
|
||||
String title = readAnnotationStringValue(annotation, "title");
|
||||
if (!title.isEmpty()) {
|
||||
return title;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private String readAnnotationStringValue(AnnotationExpr annotation, String attributeName) {
|
||||
if (annotation.isNormalAnnotationExpr()) {
|
||||
NormalAnnotationExpr normal = annotation.asNormalAnnotationExpr();
|
||||
for (var pair : normal.getPairs()) {
|
||||
if (pair.getNameAsString().equals(attributeName)) {
|
||||
return literalString(pair.getValue());
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
if (annotation.isSingleMemberAnnotationExpr()) {
|
||||
SingleMemberAnnotationExpr single = annotation.asSingleMemberAnnotationExpr();
|
||||
if ("value".equals(attributeName) || "description".equals(attributeName)) {
|
||||
return literalString(single.getMemberValue());
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private String literalString(Expression expression) {
|
||||
if (expression.isStringLiteralExpr()) {
|
||||
return expression.asStringLiteralExpr().getValue().trim();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private String extractClassJavadoc(ClassOrInterfaceDeclaration classDecl) {
|
||||
Optional<JavadocComment> javadoc = classDecl.getJavadocComment();
|
||||
if (javadoc.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
String text = javadoc.get().parse().getDescription().toText();
|
||||
return text == null ? "" : text.trim().replaceAll("\\s+", " ");
|
||||
}
|
||||
|
||||
/** 从 src/main/java/ 后的路径推断 package.className */
|
||||
public static String inferQualifiedFromPath(String relativePath, String className) {
|
||||
if (relativePath == null || relativePath.isBlank()) {
|
||||
return className;
|
||||
}
|
||||
String normalized = relativePath.replace('\\', '/');
|
||||
String marker = "src/main/java/";
|
||||
int idx = normalized.indexOf(marker);
|
||||
if (idx < 0) {
|
||||
return className;
|
||||
}
|
||||
String subPath = normalized.substring(idx + marker.length());
|
||||
int lastSlash = subPath.lastIndexOf('/');
|
||||
if (lastSlash <= 0) {
|
||||
return className;
|
||||
}
|
||||
String packageName = subPath.substring(0, lastSlash).replace('/', '.');
|
||||
return packageName + "." + className;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package com.codechecker.parser;
|
||||
|
||||
import com.codechecker.model.FieldInfo;
|
||||
import com.github.javaparser.StaticJavaParser;
|
||||
import com.github.javaparser.ast.CompilationUnit;
|
||||
import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration;
|
||||
import com.github.javaparser.ast.body.FieldDeclaration;
|
||||
import com.github.javaparser.ast.body.TypeDeclaration;
|
||||
import com.github.javaparser.ast.body.VariableDeclarator;
|
||||
import com.github.javaparser.ast.comments.JavadocComment;
|
||||
import com.github.javaparser.ast.expr.AnnotationExpr;
|
||||
import com.github.javaparser.ast.expr.Expression;
|
||||
import com.github.javaparser.ast.expr.NormalAnnotationExpr;
|
||||
import com.github.javaparser.ast.expr.SingleMemberAnnotationExpr;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 解析模型类字段:名称、类型、业务说明(注解或 Javadoc)。
|
||||
*/
|
||||
public class ClassFieldParser {
|
||||
|
||||
/** 解析指定类的实例字段列表 */
|
||||
public List<FieldInfo> parseFields(String source, String expectedClassName) {
|
||||
if (source == null || source.isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
CompilationUnit cu = StaticJavaParser.parse(source);
|
||||
ClassOrInterfaceDeclaration classDecl = findClass(cu, expectedClassName);
|
||||
if (classDecl == null) {
|
||||
return List.of();
|
||||
}
|
||||
return parseClassFields(classDecl);
|
||||
}
|
||||
|
||||
/** 按类名查找类声明,找不到则取第一个类 */
|
||||
private ClassOrInterfaceDeclaration findClass(CompilationUnit cu, String expectedClassName) {
|
||||
if (expectedClassName != null && !expectedClassName.isBlank()) {
|
||||
for (TypeDeclaration<?> type : cu.getTypes()) {
|
||||
if (type instanceof ClassOrInterfaceDeclaration) {
|
||||
ClassOrInterfaceDeclaration classDecl = (ClassOrInterfaceDeclaration) type;
|
||||
if (classDecl.getNameAsString().equals(expectedClassName)) {
|
||||
return classDecl;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (TypeDeclaration<?> type : cu.getTypes()) {
|
||||
if (type instanceof ClassOrInterfaceDeclaration) {
|
||||
return (ClassOrInterfaceDeclaration) type;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 提取非 static final 字段,跳过常量 */
|
||||
private List<FieldInfo> parseClassFields(ClassOrInterfaceDeclaration classDecl) {
|
||||
Map<String, FieldInfo> fields = new LinkedHashMap<>();
|
||||
for (FieldDeclaration fieldDecl : classDecl.getFields()) {
|
||||
if (fieldDecl.isStatic() && fieldDecl.isFinal()) {
|
||||
continue;
|
||||
}
|
||||
String type = TypeNameUtils.typeToString(fieldDecl.getElementType());
|
||||
String description = extractFieldLabel(fieldDecl);
|
||||
for (VariableDeclarator variable : fieldDecl.getVariables()) {
|
||||
fields.put(variable.getNameAsString(), new FieldInfo(variable.getNameAsString(), type, description));
|
||||
}
|
||||
}
|
||||
return new ArrayList<>(fields.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* 字段说明:@Schema(description) > @ApiModelProperty > Javadoc,均无则空串。
|
||||
*/
|
||||
String extractFieldLabel(FieldDeclaration fieldDecl) {
|
||||
for (AnnotationExpr annotation : fieldDecl.getAnnotations()) {
|
||||
String annName = annotation.getNameAsString();
|
||||
if ("Schema".equals(annName)) {
|
||||
String description = readAnnotationStringValue(annotation, "description");
|
||||
if (!description.isEmpty()) {
|
||||
return description;
|
||||
}
|
||||
}
|
||||
if ("ApiModelProperty".equals(annName)) {
|
||||
String value = readAnnotationStringValue(annotation, "value");
|
||||
if (!value.isEmpty()) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return extractJavadoc(fieldDecl);
|
||||
}
|
||||
|
||||
/** 读取注解中的字符串属性值 */
|
||||
private String readAnnotationStringValue(AnnotationExpr annotation, String attributeName) {
|
||||
if (annotation.isNormalAnnotationExpr()) {
|
||||
NormalAnnotationExpr normal = annotation.asNormalAnnotationExpr();
|
||||
for (var pair : normal.getPairs()) {
|
||||
if (pair.getNameAsString().equals(attributeName)) {
|
||||
return literalString(pair.getValue());
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
if (annotation.isSingleMemberAnnotationExpr()) {
|
||||
SingleMemberAnnotationExpr single = annotation.asSingleMemberAnnotationExpr();
|
||||
if ("value".equals(attributeName) || "description".equals(attributeName)) {
|
||||
return literalString(single.getMemberValue());
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/** 提取字符串字面量值 */
|
||||
private String literalString(Expression expression) {
|
||||
if (expression.isStringLiteralExpr()) {
|
||||
return expression.asStringLiteralExpr().getValue().trim();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/** 从字段 Javadoc 提取首段描述 */
|
||||
private String extractJavadoc(FieldDeclaration fieldDecl) {
|
||||
Optional<JavadocComment> javadoc = fieldDecl.getJavadocComment();
|
||||
if (javadoc.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
String text = javadoc.get().parse().getDescription().toText();
|
||||
return text == null ? "" : text.trim().replaceAll("\\s+", " ");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.codechecker.parser;
|
||||
|
||||
import com.github.javaparser.StaticJavaParser;
|
||||
import com.github.javaparser.ast.CompilationUnit;
|
||||
import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration;
|
||||
import com.github.javaparser.ast.body.MethodDeclaration;
|
||||
import com.github.javaparser.ast.body.TypeDeclaration;
|
||||
import com.github.javaparser.ast.expr.MethodCallExpr;
|
||||
import com.github.javaparser.ast.visitor.VoidVisitorAdapter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* 扫描 Dto→Entity 转换关系:convert 方法返回值、BeanUtils.copyProperties 调用。
|
||||
*/
|
||||
public class ConversionParser {
|
||||
|
||||
/** 在类内查找 convert 方法,收集返回 Entity 的类型名 */
|
||||
public Set<String> findConvertTargetsInClass(String source, String className) {
|
||||
Set<String> entities = new LinkedHashSet<>();
|
||||
if (source == null || source.isBlank()) {
|
||||
return entities;
|
||||
}
|
||||
CompilationUnit cu = StaticJavaParser.parse(source);
|
||||
for (TypeDeclaration<?> type : cu.getTypes()) {
|
||||
if (type instanceof ClassOrInterfaceDeclaration) {
|
||||
ClassOrInterfaceDeclaration classDecl = (ClassOrInterfaceDeclaration) type;
|
||||
if (!classDecl.getNameAsString().equals(className)) {
|
||||
continue;
|
||||
}
|
||||
for (MethodDeclaration method : classDecl.getMethods()) {
|
||||
if (!"convert".equals(method.getNameAsString())) {
|
||||
continue;
|
||||
}
|
||||
String returnType = TypeNameUtils.simpleName(TypeNameUtils.typeToString(method.getType()));
|
||||
if (returnType.endsWith("Entity")) {
|
||||
entities.add(returnType);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return entities;
|
||||
}
|
||||
|
||||
/** 递归扫描目录,查找 BeanUtils.copyProperties(sourceClass, *Entity) */
|
||||
public Set<String> findBeanUtilsTargets(Path rootDir, String sourceClassName) throws IOException {
|
||||
Set<String> entities = new LinkedHashSet<>();
|
||||
if (!Files.exists(rootDir)) {
|
||||
return entities;
|
||||
}
|
||||
try (Stream<Path> paths = Files.walk(rootDir)) {
|
||||
paths.filter(path -> path.toString().endsWith(".java")).forEach(path -> {
|
||||
try {
|
||||
String source = Files.readString(path, StandardCharsets.UTF_8);
|
||||
entities.addAll(scanBeanUtilsInSource(source, sourceClassName));
|
||||
} catch (IOException ignored) {
|
||||
// 跳过
|
||||
}
|
||||
});
|
||||
}
|
||||
return entities;
|
||||
}
|
||||
|
||||
/** 在单文件源码中扫描 BeanUtils.copyProperties 调用 */
|
||||
private Set<String> scanBeanUtilsInSource(String source, String sourceClassName) {
|
||||
Set<String> entities = new LinkedHashSet<>();
|
||||
CompilationUnit cu = StaticJavaParser.parse(source);
|
||||
cu.accept(new VoidVisitorAdapter<Void>() {
|
||||
@Override
|
||||
public void visit(MethodCallExpr call, Void arg) {
|
||||
super.visit(call, arg);
|
||||
if (!call.getNameAsString().equals("copyProperties")) {
|
||||
return;
|
||||
}
|
||||
if (call.getScope().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
String scope = call.getScope().get().toString();
|
||||
if (!scope.endsWith("BeanUtils")) {
|
||||
return;
|
||||
}
|
||||
if (call.getArguments().size() < 2) {
|
||||
return;
|
||||
}
|
||||
String firstArg = TypeNameUtils.simpleName(call.getArguments().get(0).toString());
|
||||
String secondArg = TypeNameUtils.simpleName(call.getArguments().get(1).toString());
|
||||
if (sourceClassName.equals(firstArg) && secondArg.endsWith("Entity")) {
|
||||
entities.add(secondArg);
|
||||
}
|
||||
}
|
||||
}, null);
|
||||
return entities;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
package com.codechecker.parser;
|
||||
|
||||
import com.codechecker.model.ApiEndpoint;
|
||||
import com.github.javaparser.StaticJavaParser;
|
||||
import com.github.javaparser.ast.CompilationUnit;
|
||||
import com.github.javaparser.ast.NodeList;
|
||||
import com.github.javaparser.ast.expr.Expression;
|
||||
import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration;
|
||||
import com.github.javaparser.ast.body.MethodDeclaration;
|
||||
import com.github.javaparser.ast.body.Parameter;
|
||||
import com.github.javaparser.ast.body.TypeDeclaration;
|
||||
import com.github.javaparser.ast.expr.AnnotationExpr;
|
||||
import com.github.javaparser.ast.type.Type;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* 扫描 Controller / Feign 接口,提取 HTTP 方法、URI、入参/返回类型。
|
||||
*/
|
||||
public class EndpointParser {
|
||||
private static final Set<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"
|
||||
);
|
||||
|
||||
/** 扫描 @RestController / @Controller 目录 */
|
||||
public List<ApiEndpoint> scanControllerDirectory(Path rootDir, String relativePrefix) throws IOException {
|
||||
return scanDirectory(rootDir, relativePrefix, ScanMode.CONTROLLER);
|
||||
}
|
||||
|
||||
/** 扫描 @FeignClient 接口目录 */
|
||||
public List<ApiEndpoint> scanFeignDirectory(Path rootDir, String relativePrefix) throws IOException {
|
||||
return scanDirectory(rootDir, relativePrefix, ScanMode.FEIGN);
|
||||
}
|
||||
|
||||
/** 递归 walk 目录下 .java 并解析 */
|
||||
private List<ApiEndpoint> scanDirectory(Path rootDir, String relativePrefix, ScanMode mode) throws IOException {
|
||||
if (!Files.exists(rootDir)) {
|
||||
return List.of();
|
||||
}
|
||||
List<ApiEndpoint> endpoints = new ArrayList<>();
|
||||
try (Stream<Path> paths = Files.walk(rootDir)) {
|
||||
paths.filter(path -> path.toString().endsWith(".java")).forEach(path -> {
|
||||
try {
|
||||
String source = Files.readString(path, StandardCharsets.UTF_8);
|
||||
String relativePath = toRelativePath(relativePrefix, rootDir, path);
|
||||
endpoints.addAll(parseCompilationUnit(source, relativePath, mode));
|
||||
} catch (IOException ignored) {
|
||||
// 跳过无法读取的文件
|
||||
}
|
||||
});
|
||||
}
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
/** 解析单个编译单元,过滤 Controller 或 Feign */
|
||||
private List<ApiEndpoint> parseCompilationUnit(String source, String relativePath, ScanMode mode) {
|
||||
CompilationUnit cu = StaticJavaParser.parse(source);
|
||||
List<ApiEndpoint> endpoints = new ArrayList<>();
|
||||
|
||||
for (TypeDeclaration<?> type : cu.getTypes()) {
|
||||
if (!(type instanceof ClassOrInterfaceDeclaration)) {
|
||||
continue;
|
||||
}
|
||||
ClassOrInterfaceDeclaration declaration = (ClassOrInterfaceDeclaration) type;
|
||||
if (mode == ScanMode.CONTROLLER && !isController(declaration)) {
|
||||
continue;
|
||||
}
|
||||
if (mode == ScanMode.FEIGN && !isFeignClient(declaration)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String basePath = mode == ScanMode.FEIGN
|
||||
? joinPaths(extractFeignBasePath(declaration), extractTypeLevelPath(declaration))
|
||||
: extractTypeLevelPath(declaration);
|
||||
for (MethodDeclaration method : declaration.getMethods()) {
|
||||
if (mode == ScanMode.FEIGN && declaration.isInterface()) {
|
||||
endpoints.addAll(parseMethod(method, basePath, relativePath));
|
||||
} else if (mode == ScanMode.CONTROLLER && !declaration.isInterface()) {
|
||||
endpoints.addAll(parseMethod(method, basePath, relativePath));
|
||||
}
|
||||
}
|
||||
}
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
/** 解析方法上的 Mapping 注解,生成 ApiEndpoint */
|
||||
private List<ApiEndpoint> parseMethod(MethodDeclaration method, String basePath, String sourceFile) {
|
||||
List<ApiEndpoint> endpoints = new ArrayList<>();
|
||||
for (AnnotationExpr annotation : method.getAnnotations()) {
|
||||
String annName = annotation.getNameAsString();
|
||||
if (!MAPPING_ANNOTATIONS.contains(annName)) {
|
||||
continue;
|
||||
}
|
||||
List<String> subPaths = extractPaths(annotation);
|
||||
List<String> httpMethods = extractHttpMethods(annotation, annName);
|
||||
for (String httpMethod : httpMethods) {
|
||||
for (String subPath : subPaths) {
|
||||
String uri = joinPaths(basePath, subPath);
|
||||
Set<String> paramTypes = extractParamTypes(method);
|
||||
Set<String> returnTypes = TypeNameUtils.peelDirectTypeNames(method.getType());
|
||||
endpoints.add(new ApiEndpoint(httpMethod, uri, sourceFile, paramTypes, returnTypes));
|
||||
}
|
||||
}
|
||||
}
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
/** 收集方法入参类型简单名 */
|
||||
private Set<String> extractParamTypes(MethodDeclaration method) {
|
||||
Set<String> paramTypes = new LinkedHashSet<>();
|
||||
for (Parameter parameter : method.getParameters()) {
|
||||
Type type = parameter.getType();
|
||||
paramTypes.add(TypeNameUtils.simpleName(TypeNameUtils.typeToString(type)));
|
||||
paramTypes.addAll(TypeNameUtils.peelDirectTypeNames(type));
|
||||
}
|
||||
return paramTypes;
|
||||
}
|
||||
|
||||
/** 是否 Spring Controller */
|
||||
private boolean isController(ClassOrInterfaceDeclaration declaration) {
|
||||
return declaration.getAnnotations().stream()
|
||||
.anyMatch(ann -> {
|
||||
String name = ann.getNameAsString();
|
||||
return "RestController".equals(name) || "Controller".equals(name);
|
||||
});
|
||||
}
|
||||
|
||||
/** 是否 Feign 客户端接口 */
|
||||
private boolean isFeignClient(ClassOrInterfaceDeclaration declaration) {
|
||||
return declaration.isInterface() && declaration.getAnnotations().stream()
|
||||
.anyMatch(ann -> "FeignClient".equals(ann.getNameAsString()));
|
||||
}
|
||||
|
||||
/** 类级 @RequestMapping 路径 */
|
||||
private String extractTypeLevelPath(ClassOrInterfaceDeclaration declaration) {
|
||||
for (AnnotationExpr annotation : declaration.getAnnotations()) {
|
||||
if ("RequestMapping".equals(annotation.getNameAsString())) {
|
||||
List<String> paths = extractPaths(annotation);
|
||||
if (!paths.isEmpty()) {
|
||||
return paths.get(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/** @FeignClient(path=...) 基础路径 */
|
||||
private String extractFeignBasePath(ClassOrInterfaceDeclaration declaration) {
|
||||
for (AnnotationExpr annotation : declaration.getAnnotations()) {
|
||||
if ("FeignClient".equals(annotation.getNameAsString())) {
|
||||
List<String> paths = AnnotationValueReader.readStringArray(annotation, "path");
|
||||
if (!paths.isEmpty()) {
|
||||
return paths.get(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/** 从 Mapping 注解读取 value/path */
|
||||
private List<String> extractPaths(AnnotationExpr annotation) {
|
||||
return AnnotationValueReader.readStringArray(annotation, "value", "path");
|
||||
}
|
||||
|
||||
/** 推断 HTTP 方法;RequestMapping 无 method 时默认 GET */
|
||||
private List<String> extractHttpMethods(AnnotationExpr annotation, String annName) {
|
||||
if (!"RequestMapping".equals(annName)) {
|
||||
return List.of(MAPPING_DEFAULT_METHOD.getOrDefault(annName, "GET"));
|
||||
}
|
||||
List<String> methods = AnnotationValueReader.readEnumArray(annotation, "method");
|
||||
if (methods.isEmpty()) {
|
||||
return List.of("GET");
|
||||
}
|
||||
return methods;
|
||||
}
|
||||
|
||||
/** 拼接类级与方法级路径 */
|
||||
private String joinPaths(String base, String sub) {
|
||||
String normalizedBase = normalizePath(base);
|
||||
String normalizedSub = normalizePath(sub);
|
||||
if (normalizedBase.isEmpty()) {
|
||||
return normalizedSub.isEmpty() ? "/" : normalizedSub;
|
||||
}
|
||||
if (normalizedSub.isEmpty()) {
|
||||
return normalizedBase;
|
||||
}
|
||||
String joined = normalizedBase + "/" + normalizedSub.substring(1);
|
||||
return joined.replaceAll("/+", "/");
|
||||
}
|
||||
|
||||
/** 规范化 URI 路径 */
|
||||
private String normalizePath(String path) {
|
||||
if (path == null || path.isBlank()) {
|
||||
return "";
|
||||
}
|
||||
String trimmed = path.trim();
|
||||
if (!trimmed.startsWith("/")) {
|
||||
trimmed = "/" + trimmed;
|
||||
}
|
||||
return trimmed.replaceAll("/+", "/");
|
||||
}
|
||||
|
||||
/** 生成相对仓库根的路径 */
|
||||
private String toRelativePath(String relativePrefix, Path rootDir, Path file) {
|
||||
String relative = rootDir.relativize(file).toString().replace("\\", "/");
|
||||
if (relativePrefix == null || relativePrefix.isBlank()) {
|
||||
return relative;
|
||||
}
|
||||
String prefix = relativePrefix.endsWith("/")
|
||||
? relativePrefix.substring(0, relativePrefix.length() - 1)
|
||||
: relativePrefix;
|
||||
return prefix + "/" + relative;
|
||||
}
|
||||
|
||||
private enum ScanMode {
|
||||
CONTROLLER, FEIGN
|
||||
}
|
||||
|
||||
/** 从注解 AST 读取字符串或枚举数组 */
|
||||
static final class AnnotationValueReader {
|
||||
private AnnotationValueReader() {
|
||||
}
|
||||
|
||||
static List<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;
|
||||
}
|
||||
|
||||
static 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 static NodeList<?> readArrayValues(AnnotationExpr annotation, String... keys) {
|
||||
if (annotation.isSingleMemberAnnotationExpr()) {
|
||||
Expression value = annotation.asSingleMemberAnnotationExpr().getMemberValue();
|
||||
if (value.isArrayInitializerExpr()) {
|
||||
return value.asArrayInitializerExpr().getValues();
|
||||
}
|
||||
return new NodeList<>(value);
|
||||
}
|
||||
if (annotation.isNormalAnnotationExpr()) {
|
||||
var pairs = annotation.asNormalAnnotationExpr().getPairs();
|
||||
for (var pair : pairs) {
|
||||
for (String key : keys) {
|
||||
if (pair.getNameAsString().equals(key)) {
|
||||
if (pair.getValue().isArrayInitializerExpr()) {
|
||||
return pair.getValue().asArrayInitializerExpr().getValues();
|
||||
}
|
||||
return new NodeList<>(pair.getValue());
|
||||
}
|
||||
}
|
||||
}
|
||||
for (var pair : pairs) {
|
||||
if ("value".equals(pair.getNameAsString())) {
|
||||
if (pair.getValue().isArrayInitializerExpr()) {
|
||||
return pair.getValue().asArrayInitializerExpr().getValues();
|
||||
}
|
||||
return new NodeList<>(pair.getValue());
|
||||
}
|
||||
}
|
||||
}
|
||||
return new NodeList<>();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package com.codechecker.parser;
|
||||
|
||||
import com.github.javaparser.ast.type.ClassOrInterfaceType;
|
||||
import com.github.javaparser.ast.type.Type;
|
||||
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Java 类型名工具:转字符串、取简单名、剥离 ActionResult/List 等泛型包装。
|
||||
*/
|
||||
public final class TypeNameUtils {
|
||||
/** 需要向内层继续剥离的包装类型 */
|
||||
private static final Set<String> WRAPPER_TYPES = Set.of(
|
||||
"ActionResult", "List", "PageListVO", "Set", "Collection", "Iterable", "Optional"
|
||||
);
|
||||
|
||||
private TypeNameUtils() {
|
||||
}
|
||||
|
||||
/** Type 转无空白字符串 */
|
||||
public static String typeToString(Type type) {
|
||||
if (type == null) {
|
||||
return "Object";
|
||||
}
|
||||
return type.toString().replaceAll("\\s+", "");
|
||||
}
|
||||
|
||||
/** 取类型简单名,去掉包名与泛型 */
|
||||
public static String simpleName(String typeName) {
|
||||
if (typeName == null || typeName.isBlank()) {
|
||||
return "";
|
||||
}
|
||||
String cleaned = typeName.replaceAll("\\s+", "");
|
||||
int genericStart = cleaned.indexOf('<');
|
||||
String base = genericStart >= 0 ? cleaned.substring(0, genericStart) : cleaned;
|
||||
int dot = base.lastIndexOf('.');
|
||||
return dot >= 0 ? base.substring(dot + 1) : base;
|
||||
}
|
||||
|
||||
/** 从 Type AST 收集实际业务类型简单名(穿透包装泛型) */
|
||||
public static Set<String> peelDirectTypeNames(Type type) {
|
||||
Set<String> result = new LinkedHashSet<>();
|
||||
collectPeelTargets(type, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 从类型字符串收集实际业务类型简单名 */
|
||||
public static Set<String> peelDirectTypeNames(String typeName) {
|
||||
Set<String> result = new LinkedHashSet<>();
|
||||
collectPeelTargets(typeName, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 递归收集:包装类型则进入泛型参数,否则记录简单名 */
|
||||
private static void collectPeelTargets(Type type, Set<String> result) {
|
||||
if (type == null) {
|
||||
return;
|
||||
}
|
||||
if (type.isClassOrInterfaceType()) {
|
||||
ClassOrInterfaceType classType = type.asClassOrInterfaceType();
|
||||
String name = simpleName(classType.getNameAsString());
|
||||
if (WRAPPER_TYPES.contains(name) && classType.getTypeArguments().isPresent()) {
|
||||
for (Type arg : classType.getTypeArguments().get()) {
|
||||
collectPeelTargets(arg, result);
|
||||
}
|
||||
return;
|
||||
}
|
||||
result.add(name);
|
||||
return;
|
||||
}
|
||||
result.add(simpleName(typeToString(type)));
|
||||
}
|
||||
|
||||
/** 字符串版递归收集 */
|
||||
private static void collectPeelTargets(String typeName, Set<String> result) {
|
||||
String cleaned = typeName.replaceAll("\\s+", "");
|
||||
int genericStart = cleaned.indexOf('<');
|
||||
if (genericStart < 0) {
|
||||
result.add(simpleName(cleaned));
|
||||
return;
|
||||
}
|
||||
String outer = simpleName(cleaned.substring(0, genericStart));
|
||||
String inner = cleaned.substring(genericStart + 1, cleaned.lastIndexOf('>'));
|
||||
if (WRAPPER_TYPES.contains(outer)) {
|
||||
for (String part : splitGenericArgs(inner)) {
|
||||
collectPeelTargets(part, result);
|
||||
}
|
||||
return;
|
||||
}
|
||||
result.add(outer);
|
||||
}
|
||||
|
||||
/** 按逗号分割泛型参数,支持嵌套 <> */
|
||||
private static List<String> splitGenericArgs(String inner) {
|
||||
List<String> parts = new java.util.ArrayList<>();
|
||||
int depth = 0;
|
||||
StringBuilder current = new StringBuilder();
|
||||
for (char ch : inner.toCharArray()) {
|
||||
if (ch == '<') {
|
||||
depth++;
|
||||
} else if (ch == '>') {
|
||||
depth--;
|
||||
} else if (ch == ',' && depth == 0) {
|
||||
parts.add(current.toString().trim());
|
||||
current.setLength(0);
|
||||
continue;
|
||||
}
|
||||
current.append(ch);
|
||||
}
|
||||
if (current.length() > 0) {
|
||||
parts.add(current.toString().trim());
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
# ============================================================
|
||||
# AI-Check 配置文件(位于 .gitea/ 目录,与业务代码解耦)
|
||||
# ============================================================
|
||||
|
||||
# ---------- API 变动检查 ----------
|
||||
# 总开关:false 时跳过 Controller 接口参数变更检测(不对比、不通知)
|
||||
check:
|
||||
enabled: true
|
||||
|
||||
# 业务 Java 源码目录(相对仓库根目录)
|
||||
# 单模块: source_dir: "src/main/java"
|
||||
# 多模块: 使用 source_dirs(优先于 source_dir)
|
||||
source_dirs:
|
||||
- "jnpf-ftb/jnpf-ftb-biz/src/main/java"
|
||||
- "jnpf-ftb/jnpf-ftb-entity/src/main/java"
|
||||
source_dir: "ftb/src/main/java"
|
||||
|
||||
# ---------- 企业微信机器人 ----------
|
||||
wecom:
|
||||
webhook_url: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=61f08cc9-b734-4dff-a931-7f33654c0a81"
|
||||
|
||||
# ---------- 豆包 LLM(审核接口参数变更)----------
|
||||
llm:
|
||||
enabled: false
|
||||
api_key: "2f3f7ee9-a6f7-46b7-a709-a36743a83a04"
|
||||
model: "doubao-seed-1-8-251228"
|
||||
endpoint_id: ""
|
||||
api_url: "https://ark.cn-beijing.volces.com/api/v3/chat/completions"
|
||||
timeout: null
|
||||
|
||||
# ---------- 变更日志 ----------
|
||||
log:
|
||||
enabled: false
|
||||
storage: "file"
|
||||
file_dir: ".gitea/logs/api-changes"
|
||||
mysql:
|
||||
host: "YOUR_MYSQL_HOST"
|
||||
port: 3306
|
||||
user: "YOUR_MYSQL_USER"
|
||||
password: "YOUR_MYSQL_PASSWORD"
|
||||
database: "YOUR_MYSQL_DATABASE"
|
||||
table: "api_change_logs"
|
||||
|
||||
# ---------- 通知 ----------
|
||||
notify:
|
||||
only_on_change: true
|
||||
mentioned_users: ""
|
||||
@@ -1,34 +0,0 @@
|
||||
# 【API参数变更通知】
|
||||
|
||||
- **变更类型:** {新增接口 / 修改参数 / 删除接口}
|
||||
- **URI:** {Method} {URI}
|
||||
- **修改人:** {Modifier}
|
||||
- **修改时间:** {ModifyTime}
|
||||
- **全路径类名:** {FileName}
|
||||
|
||||
---
|
||||
|
||||
## 接口参数变动详情
|
||||
|
||||
---
|
||||
|
||||
### 类对象变更
|
||||
(如有对象替换或对象属性变更)
|
||||
|
||||
- **对象:** {类名}
|
||||
- **变更方式:** {对象属性变更 / 对象替换(旧类A → 新类B)}
|
||||
- **属性变更明细:**
|
||||
- [新增] 属性: `attr1` 说明: {说明}
|
||||
- [删除] 属性: `attr2` 说明: {说明}
|
||||
- [修改] 属性: `attr3` 说明: {说明}
|
||||
|
||||
### 【参数变更】
|
||||
- **变更列表:**
|
||||
|
||||
| 字段 | 说明 | 变更 |
|
||||
|------|------|------|
|
||||
| `pageSize` | 每页条数 | 新增必填 |
|
||||
| `keyword` | 搜索关键词 | 类型由String改为Long |
|
||||
| `startTime` | 开始时间 | 删除 |
|
||||
|
||||
(如无变更,显示:无)
|
||||
@@ -1,17 +0,0 @@
|
||||
# 【API路径变更通知】
|
||||
|
||||
变更类型: {新增接口 / 修改路径 / 删除接口}
|
||||
全路径类名: {FullClassName}
|
||||
修改人: {Modifier}
|
||||
修改时间: {ModifyTime}
|
||||
|
||||
---
|
||||
|
||||
#### 【URI变更详情】
|
||||
- **原路径:** `{OldURI}` *(新增时显示:-)*
|
||||
- **新路径:** `{NewURI}` *(删除时显示:已删除 / -)*
|
||||
|
||||
**示例:**
|
||||
- 全路径类名:`com.example.controller.UserController`
|
||||
- 原路径:`/api/users/{id}`
|
||||
- 新路径:`/api/users/getall`
|
||||
@@ -1,17 +0,0 @@
|
||||
-- MySQL 变更日志表(storage=mysql 时使用)
|
||||
-- 执行前请先创建数据库并替换 YOUR_MYSQL_DATABASE
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `api_change_logs` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
`commit_sha` VARCHAR(64) NOT NULL COMMENT 'Git 提交 SHA',
|
||||
`author` VARCHAR(128) NOT NULL COMMENT '提交人',
|
||||
`commit_time` VARCHAR(64) NOT NULL COMMENT '提交时间',
|
||||
`commit_message` TEXT NULL COMMENT '提交说明',
|
||||
`change_count` INT NOT NULL DEFAULT 0 COMMENT '变更接口数量',
|
||||
`reports_json` LONGTEXT NOT NULL COMMENT '变更详情 JSON',
|
||||
`llm_review` TEXT NULL COMMENT 'LLM 评审结论',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '记录写入时间',
|
||||
PRIMARY KEY (`id`),
|
||||
INDEX `idx_commit_sha` (`commit_sha`),
|
||||
INDEX `idx_created_at` (`created_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='API 接口参数变更日志';
|
||||
@@ -1,46 +0,0 @@
|
||||
# Gitea Actions:Controller 接口参数变更检测(纯 Python,无 Java 构建)
|
||||
|
||||
name: API接口参数变更检测
|
||||
run-name: ${{ gitea.actor }}的API参数变更检测
|
||||
|
||||
on: [push]
|
||||
|
||||
jobs:
|
||||
api-param-check:
|
||||
if: ${{ gitea.ref != 'refs/heads/pre' && gitea.ref != 'refs/heads/dev' && gitea.ref != 'refs/heads/master-2.0' }}
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: 内置权限检出代码
|
||||
run: |
|
||||
git config --global http.sslVerify false
|
||||
git clone "https://${{ gitea.token }}@git.niujiekeji.com/${{ gitea.repository }}.git" .
|
||||
git checkout ${{ gitea.sha }}
|
||||
echo "当前提交: $(git rev-parse HEAD)"
|
||||
echo "上一提交: $(git rev-parse HEAD~1 2>/dev/null || echo '无')"
|
||||
|
||||
- name: 检查配置文件
|
||||
run: |
|
||||
if [ ! -f .gitea/config.yaml ]; then
|
||||
echo "错误: 缺少 .gitea/config.yaml"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: 安装 Python 依赖
|
||||
run: |
|
||||
if ! python3 -m venv .gitea/.venv 2>/dev/null; then
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y python3-venv
|
||||
python3 -m venv .gitea/.venv
|
||||
fi
|
||||
.gitea/.venv/bin/pip install --upgrade pip
|
||||
.gitea/.venv/bin/pip install -r .gitea/checker/requirements.txt
|
||||
|
||||
- name: 检测 Controller 接口参数变更
|
||||
run: |
|
||||
COMMIT_TIME=$(git log -1 --format=%cd --date=format:'%Y-%m-%d %H:%M:%S')
|
||||
.gitea/.venv/bin/python .gitea/checker/main.py \
|
||||
--config .gitea/config.yaml \
|
||||
--repo-root . \
|
||||
"${{ gitea.actor }}" \
|
||||
"$COMMIT_TIME"
|
||||
56
.gitea/workflows/code-check-config.yaml
Normal file
56
.gitea/workflows/code-check-config.yaml
Normal file
@@ -0,0 +1,56 @@
|
||||
# ============================================================
|
||||
# 变更检测配置
|
||||
# 由 CI 流水线加载;jar 位于 .gitea/workflows/code-checker.jar
|
||||
# 修改后 push 即可生效,无需重新打包 jar(除非改动了 Java 源码)
|
||||
# ============================================================
|
||||
|
||||
|
||||
# 总开关:false 时跳过 class_check + api_check,流水线直接退出
|
||||
checker:
|
||||
enabled: true
|
||||
|
||||
# 类变更检测(Vo/Dto/Entity/Model 字段增删改等)
|
||||
class_check:
|
||||
enabled: true
|
||||
# Dto 类字段变更后,继续检测受影响 Controller 的 API 参数变更
|
||||
dto_api_follow_up:
|
||||
enabled: true
|
||||
# Dto/Vo 嵌套关系索引:影响分析传播 & API 参数字段展开深度
|
||||
nest_index:
|
||||
max_depth: 3
|
||||
dto_entity_conversion:
|
||||
enabled: false
|
||||
|
||||
model_dirs:
|
||||
- jnpf-ftb/jnpf-ftb-entity/src/main/java
|
||||
|
||||
# 接口索引扫描目录,用于分析类变更对 API 的影响范围
|
||||
endpoint_scan:
|
||||
controllers:
|
||||
- jnpf-ftb/jnpf-ftb-biz/src/main/java
|
||||
feign_apis:
|
||||
- jnpf-ftb/jnpf-ftb-api/src/main/java
|
||||
|
||||
conversion_scan:
|
||||
- jnpf-ftb/jnpf-ftb-biz/src/main/java
|
||||
|
||||
|
||||
# API 变更检测(路径 / 请求方式 / 参数),与 class_check 同级
|
||||
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
|
||||
|
||||
# 企业微信通知开关
|
||||
wecom:
|
||||
enabled: true
|
||||
webhook_url: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=61f08cc9-b734-4dff-a931-7f33654c0a81"
|
||||
|
||||
notify:
|
||||
only_on_change: true
|
||||
# Dto 类变更与 API 参数变更重叠时的通知策略:both | class_only | api_only
|
||||
dto_overlap_mode: class_only
|
||||
53
.gitea/workflows/code-check.yml
Normal file
53
.gitea/workflows/code-check.yml
Normal file
@@ -0,0 +1,53 @@
|
||||
name: CodeChecker 变更检测
|
||||
run-name: ${{ gitea.actor }}的CodeChecker变更检测
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- class-check
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
code-check:
|
||||
if: ${{ gitea.ref != 'refs/heads/pre' && gitea.ref != 'refs/heads/dev' && gitea.ref != 'refs/heads/master-2.0' }}
|
||||
runs-on: jdk11
|
||||
steps:
|
||||
- name: 检出代码
|
||||
run: |
|
||||
git config --global http.sslVerify false
|
||||
git clone "https://${{ gitea.token }}@git.niujiekeji.com/${{ gitea.repository }}.git" .
|
||||
git checkout ${{ gitea.sha }}
|
||||
|
||||
- name: 检查配置文件与预编译 jar
|
||||
run: |
|
||||
if [ ! -f .gitea/workflows/code-check-config.yaml ]; then
|
||||
echo "错误: 缺少 .gitea/workflows/code-check-config.yaml"
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f .gitea/workflows/code-checker.jar ]; then
|
||||
echo "错误: 缺少 .gitea/workflows/code-checker.jar"
|
||||
echo "请本地执行: powershell -File scripts/build-code-checker.ps1"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: 验证 JDK
|
||||
run: |
|
||||
echo "Java: $(java -version 2>&1 | head -1)"
|
||||
|
||||
- name: 执行 CodeChecker 变更检测
|
||||
run: |
|
||||
OLD_SHA=$(git rev-parse HEAD~1 2>/dev/null || echo "")
|
||||
if [ -z "$OLD_SHA" ]; then
|
||||
echo "首次提交,跳过变更检测"
|
||||
exit 0
|
||||
fi
|
||||
COMMIT_TIME=$(git log -1 --format=%cd --date=format:'%Y-%m-%d %H:%M:%S')
|
||||
java -jar .gitea/workflows/code-checker.jar \
|
||||
--config .gitea/workflows/code-check-config.yaml \
|
||||
--repo-root . \
|
||||
--old-sha "$OLD_SHA" \
|
||||
--new-sha "$(git rev-parse HEAD)" \
|
||||
--modifier "${{ gitea.actor }}" \
|
||||
--modify-time "$COMMIT_TIME"
|
||||
|
||||
|
||||
BIN
.gitea/workflows/code-checker.jar
Normal file
BIN
.gitea/workflows/code-checker.jar
Normal file
Binary file not shown.
8
.gitignore
vendored
8
.gitignore
vendored
@@ -13,6 +13,7 @@
|
||||
|
||||
# Package Files #
|
||||
*.jar
|
||||
!.gitea/workflows/code-checker.jar
|
||||
*.war
|
||||
*.nar
|
||||
*.ear
|
||||
@@ -24,3 +25,10 @@
|
||||
hs_err_pid*
|
||||
replay_pid*
|
||||
|
||||
# local env
|
||||
.env.gitea
|
||||
gitea-runner/data/
|
||||
|
||||
# maven build output(提交 .gitea/workflows/code-checker.jar 即可)
|
||||
.gitea/checker/target/
|
||||
|
||||
|
||||
12
Dockerfile.gitea-job
Normal file
12
Dockerfile.gitea-job
Normal file
@@ -0,0 +1,12 @@
|
||||
# Gitea Actions 任务容器:预装 headless JDK 11 + Git
|
||||
ARG UBUNTU_IMAGE=ubuntu:24.04
|
||||
FROM ${UBUNTU_IMAGE}
|
||||
|
||||
RUN sed -i 's|http://archive.ubuntu.com/ubuntu|http://mirrors.aliyun.com/ubuntu|g; \
|
||||
s|http://security.ubuntu.com/ubuntu|http://mirrors.aliyun.com/ubuntu|g' \
|
||||
/etc/apt/sources.list.d/ubuntu.sources \
|
||||
&& apt-get update -qq \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
openjdk-11-jdk-headless git ca-certificates curl \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& java -version
|
||||
48
docker-compose.gitea.yml
Normal file
48
docker-compose.gitea.yml
Normal file
@@ -0,0 +1,48 @@
|
||||
services:
|
||||
gitea:
|
||||
image: ${GITEA_IMAGE:-docker.m.daocloud.io/gitea/gitea:1.22-rootless}
|
||||
pull_policy: if_not_present
|
||||
container_name: gitea
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
GITEA__server__DOMAIN: localhost
|
||||
GITEA__server__ROOT_URL: http://localhost:3000/
|
||||
GITEA__server__SSH_DOMAIN: localhost
|
||||
GITEA__server__SSH_PORT: 2222
|
||||
GITEA__actions__ENABLED: "true"
|
||||
GITEA__actions__DEFAULT_ACTIONS_URL: "https://gitea.com/actions"
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- "2222:2222"
|
||||
volumes:
|
||||
- gitea-data:/data
|
||||
|
||||
gitea-job:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.gitea-job
|
||||
args:
|
||||
UBUNTU_IMAGE: ${UBUNTU_IMAGE:-docker.m.daocloud.io/ubuntu:24.04}
|
||||
image: ai-check-gitea-job:latest
|
||||
profiles: ["build"]
|
||||
|
||||
gitea-runner:
|
||||
image: ${GITEA_RUNNER_IMAGE:-docker.m.daocloud.io/gitea/act_runner:0.2.11}
|
||||
pull_policy: if_not_present
|
||||
container_name: gitea-act-runner
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- gitea
|
||||
environment:
|
||||
CONFIG_FILE: /config.yaml
|
||||
GITEA_INSTANCE_URL: ${GITEA_INSTANCE_URL:-http://gitea:3000}
|
||||
GITEA_RUNNER_REGISTRATION_TOKEN: ${GITEA_RUNNER_REGISTRATION_TOKEN:-}
|
||||
GITEA_RUNNER_NAME: ${GITEA_RUNNER_NAME:-local-gitea-runner}
|
||||
volumes:
|
||||
- ./gitea-runner/config.yaml:/config.yaml:ro
|
||||
- gitea-runner-data:/data
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
|
||||
volumes:
|
||||
gitea-data:
|
||||
gitea-runner-data:
|
||||
@@ -131,7 +131,7 @@ public class ApplyClockInController {
|
||||
* @param applyAttendanceRepairDto 表单对象
|
||||
* @return java.lang.Object
|
||||
*/
|
||||
@PostMapping("/repair")
|
||||
@PostMapping("/repair11")
|
||||
public ActionResult<Void> create(@RequestBody @Valid ApplyAttendanceRepairDto applyAttendanceRepairDto) {
|
||||
|
||||
ApplyAttendanceRepair entity = JsonUtil.getJsonToBean(applyAttendanceRepairDto, ApplyAttendanceRepair.class);
|
||||
@@ -155,13 +155,14 @@ public class ApplyClockInController {
|
||||
return ActionResult.success(MsgCode.SU006.get());*/
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 修改补卡审批申请
|
||||
* @param applyAttendanceRepairDto 表单对象
|
||||
* @param id 主键
|
||||
* @return java.lang.Object
|
||||
*/
|
||||
@PutMapping("/repair/{id}")
|
||||
@GetMapping("/repair/{id}")
|
||||
public Object update(@RequestBody @Valid ApplyAttendanceRepairDto applyAttendanceRepairDto, @PathVariable("id") String id) throws Exception {
|
||||
|
||||
ApplyAttendanceRepair entity = JsonUtil.getJsonToBean(applyAttendanceRepairDto, ApplyAttendanceRepair.class);
|
||||
@@ -180,7 +181,7 @@ public class ApplyClockInController {
|
||||
* @return jnpf.base.ActionResult<jnpf.model.workflow.vo.ApplyAttendanceChangeVo>
|
||||
*/
|
||||
@GetMapping(value = "/outside/{id}")
|
||||
public ActionResult<ApplyAttendanceOutsideVo> getApplyAttendanceOutside(@PathVariable(value = "id") String id) {
|
||||
public ActionResult<ApplyAttendanceOutsideVo> getApplyAttendanceOutside(@PathVariable(value = "id") String ids) {
|
||||
|
||||
ApplyAttendanceOutsideVo vo = applyAttendanceOutsideService.getApplyAttendanceOutside(id);
|
||||
return ActionResult.success(vo);
|
||||
@@ -228,6 +229,7 @@ public class ApplyClockInController {
|
||||
return ActionResult.success(MsgCode.SU006.get());
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 新建违规打卡审批申请
|
||||
* @param applyAttendanceViolationDto 表单对象
|
||||
@@ -258,7 +260,7 @@ public class ApplyClockInController {
|
||||
* 获取打卡类型
|
||||
* @return jnpf.base.ActionResult<java.util.List<jnpf.model.workflow.vo.ClockKindVo>>
|
||||
*/
|
||||
@GetMapping(value = "/violation/clock-kind")
|
||||
@GetMapping(value = "/violation/clock-kind1")
|
||||
public ActionResult<List<ClockKindVo>> getClockKindList() {
|
||||
|
||||
List<ClockKindVo> list = List.of(new ClockKindVo(1, "内勤打卡"), new ClockKindVo(2, "外勤打卡"));
|
||||
|
||||
@@ -110,7 +110,7 @@ public class RewardsPunishmentsApproveController {
|
||||
*/
|
||||
@GetMapping("/reward/pass")
|
||||
@NoDataSourceBind
|
||||
public void rewardApprove(@RequestParam(value = "tenantId") String tenantId,@RequestParam(value = "applyId") String applyId,@RequestParam(value = "status") Integer status) {
|
||||
public void rewardApprove(@RequestParam(value = "tenantId") Integer tenantId, @RequestParam(value = "applyId") String applyId, @RequestParam(value = "status") Integer status) {
|
||||
log.info("/reward/pass tenantId:{},applyId:{},status:{}",tenantId,applyId,status);
|
||||
checkOutTenant(tenantId);
|
||||
rewardService.rewardApprove(applyId, status);
|
||||
@@ -156,7 +156,7 @@ public class RewardsPunishmentsApproveController {
|
||||
* @return java.lang.Object
|
||||
*/
|
||||
@PutMapping("/punishments/{id}")
|
||||
public Object updatePunishments(@RequestBody @Valid PunishmentsApprovalDto dto, @PathVariable("id") String id) {
|
||||
public Object updatePunishments(@RequestBody @Valid PunishmentsApprovalDto dto11, @PathVariable("id") String id) {
|
||||
|
||||
PunishmentsApproval entity = JsonUtil.getJsonToBean(dto, PunishmentsApproval.class);
|
||||
entity.setId(id);
|
||||
@@ -174,9 +174,9 @@ public class RewardsPunishmentsApproveController {
|
||||
* @param applyId 审批Id
|
||||
* @param status 是否审核通过 0.待审核 1.通过 2.未通过 3.撤回
|
||||
*/
|
||||
@GetMapping("/punishments/pass")
|
||||
@PutMapping("/punishments/pass")
|
||||
@NoDataSourceBind
|
||||
public void punishmentsApprove(@RequestParam(value = "tenantId") String tenantId,@RequestParam(value = "applyId") String applyId,@RequestParam(value = "status") Integer status) {
|
||||
public void test1(@RequestParam(value = "tenantId") String tenantId,@RequestParam(value = "testId1") String testId1,@RequestParam(value = "applyId") String applyId,@RequestParam(value = "status") Integer status) {
|
||||
log.info("punishments/pass tenantId:{},applyId:{},status:{}",tenantId,applyId,status);
|
||||
checkOutTenant(tenantId);
|
||||
punishmentsApprovalService.punishmentsApprove(applyId, status);
|
||||
|
||||
@@ -20,7 +20,7 @@ public class BookRecordMonthListDto {
|
||||
*/
|
||||
@NotBlank(message = "考勤本ID不能为空")
|
||||
@Schema(description = "考勤本ID", required = true)
|
||||
private String bookId;
|
||||
private Integer bookId;
|
||||
|
||||
/**
|
||||
* 月份(必填,格式:yyyy-MM)
|
||||
|
||||
@@ -19,7 +19,7 @@ public class DayStatisticsDto {
|
||||
* 门店ID集合
|
||||
*/
|
||||
@NotEmpty(message = "门店ID集合不能为空")
|
||||
private List<String> storeIds;
|
||||
private List<Integer> storeIds;
|
||||
/**
|
||||
* 日期(yyyy-mm-dd)
|
||||
*/
|
||||
|
||||
@@ -15,12 +15,10 @@ import java.util.Date;
|
||||
@Setter
|
||||
public class ApplyAttendanceChangeDto {
|
||||
|
||||
/** 流程主键 */
|
||||
private String taskId;
|
||||
/** 流程标题 */
|
||||
private String flowTitle;
|
||||
/** 流程主键集合 */
|
||||
private String taskIds;
|
||||
/** 申请人员 */
|
||||
private String applyUser;
|
||||
private Integer applyUser1;
|
||||
/** 申请日期 */
|
||||
private Date applyDate;
|
||||
/** 变更人员id */
|
||||
@@ -36,9 +34,9 @@ public class ApplyAttendanceChangeDto {
|
||||
/** 下班时间 */
|
||||
private Date offWorkTime;
|
||||
/** 变更类型(-1: 缺卡, 1: 正常, 2: 迟到, 3: 早退) */
|
||||
private Integer changeType;
|
||||
private String changeType;
|
||||
/** 变更分钟数 */
|
||||
private Integer changeMinute;
|
||||
private String changeMinute;
|
||||
/** 变更类型 + 分钟数 */
|
||||
private String[] changeData;
|
||||
/** 变更理由 */
|
||||
|
||||
@@ -18,8 +18,6 @@ public class ApplyAttendanceOutsideDto {
|
||||
|
||||
/** 审批id */
|
||||
private String taskId;
|
||||
/** 流程主键 */
|
||||
private String flowId;
|
||||
/** 流程标题 */
|
||||
private String flowTitle;
|
||||
/** 申请人id */
|
||||
|
||||
@@ -17,7 +17,7 @@ public class PunishmentsApprovalDto {
|
||||
private String id;
|
||||
|
||||
/** 用户Id 暂弃 */
|
||||
private String userId;
|
||||
private Integer userId;
|
||||
|
||||
/** 惩罚核算日 */
|
||||
private Date day;
|
||||
|
||||
@@ -17,12 +17,10 @@ public class ApplyAttendanceChangeVo {
|
||||
|
||||
/** 主键 */
|
||||
private String id;
|
||||
/** 流程主键 */
|
||||
private String flowId;
|
||||
/** 流程标题 */
|
||||
private String flowTitle;
|
||||
/** 流程标题集合 */
|
||||
private String flowTitleList;
|
||||
/** 申请人员 */
|
||||
private String applyUser;
|
||||
private Integer applyUser1;
|
||||
/** 申请日期 */
|
||||
private Date applyDate;
|
||||
/** 变更人员id */
|
||||
|
||||
@@ -16,9 +16,7 @@ import java.util.Date;
|
||||
public class ApplyAttendanceOutsideVo {
|
||||
|
||||
/** 主键 */
|
||||
private String id;
|
||||
/** 流程主键 */
|
||||
private String flowId;
|
||||
private Integer id;
|
||||
/** 流程标题 */
|
||||
private String flowTitle;
|
||||
/** 申请人id */
|
||||
|
||||
25
scripts/build-code-checker.ps1
Normal file
25
scripts/build-code-checker.ps1
Normal file
@@ -0,0 +1,25 @@
|
||||
# 本地打包 CodeChecker 并复制到 .gitea/workflows/
|
||||
$ErrorActionPreference = "Stop"
|
||||
$Root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
|
||||
$CheckerDir = Join-Path $Root ".gitea\checker"
|
||||
$WorkflowsDir = Join-Path $Root ".gitea\workflows"
|
||||
$TargetJar = Join-Path $CheckerDir "target\code-checker.jar"
|
||||
$OutputJar = Join-Path $WorkflowsDir "code-checker.jar"
|
||||
|
||||
Write-Host ">> 编译 CodeChecker..."
|
||||
Push-Location $Root
|
||||
& mvn -q -f .gitea/checker/pom.xml package -DskipTests
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Pop-Location
|
||||
Write-Error "Maven 编译失败,exit code: $LASTEXITCODE"
|
||||
}
|
||||
Pop-Location
|
||||
|
||||
if (-not (Test-Path $TargetJar)) {
|
||||
Write-Error "编译失败,未找到 $TargetJar"
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $WorkflowsDir | Out-Null
|
||||
Copy-Item -Force $TargetJar $OutputJar
|
||||
Write-Host ">> 已输出: $OutputJar"
|
||||
Write-Host ">> 请 commit 并 push .gitea/workflows/code-checker.jar"
|
||||
Reference in New Issue
Block a user