Compare commits

112 Commits

Author SHA1 Message Date
a51ab9098a 嵌套对象处理
All checks were successful
CodeChecker 变更检测 / code-check (push) Successful in 27s
2026-06-09 17:42:57 +08:00
8b60a84b56 类对象变更通知:PunishmentsApprovalDto的嵌套类对象UserSelfDto-》删除参数
All checks were successful
CodeChecker 变更检测 / code-check (push) Successful in 26s
2026-06-09 17:40:33 +08:00
3af30b355a 类对象变更通知:PunishmentsApprovalDto的嵌套类对象UserSelfDto-》删除参数
Some checks failed
CodeChecker 变更检测 / code-check (push) Has been cancelled
2026-06-09 17:40:25 +08:00
814095e618 类对象变更通知:PunishmentsApprovalDto的嵌套类对象UserSelfDto-》新增参数
All checks were successful
CodeChecker 变更检测 / code-check (push) Successful in 24s
2026-06-09 17:28:13 +08:00
3b12b1a540 类对象变更通知:PunishmentsApprovalDto的嵌套类对象UserSelfDto-》删除参数
All checks were successful
CodeChecker 变更检测 / code-check (push) Successful in 24s
2026-06-09 17:05:33 +08:00
290f562188 PunishmentsApprovalDto- UserSelfDto类对象参数 - 嵌套对象 - 类型&名称同时修改
All checks were successful
CodeChecker 变更检测 / code-check (push) Successful in 24s
2026-06-09 16:58:05 +08:00
c035e19577 PunishmentsApprovalDto- UserSelfDto类对象参数 - 嵌套对象 - 新增参数
All checks were successful
CodeChecker 变更检测 / code-check (push) Successful in 24s
2026-06-09 16:54:52 +08:00
4c37a961a0 PunishmentsApprovalDto- UserSelfDto类对象参数 - 嵌套对象 - 删除参数
All checks were successful
CodeChecker 变更检测 / code-check (push) Successful in 24s
2026-06-09 16:52:51 +08:00
8aa506a807 PunishmentsApprovalDto- UserSelfDto类对象参数 - 嵌套对象 - 修改参数名称
All checks were successful
CodeChecker 变更检测 / code-check (push) Successful in 25s
2026-06-09 16:51:27 +08:00
89f1b0ceb7 PunishmentsApprovalDto- UserSelfDto类对象参数 - 嵌套对象 - 参数类型变更
All checks were successful
CodeChecker 变更检测 / code-check (push) Successful in 24s
2026-06-09 16:47:40 +08:00
fa8802ada7 jar包更新
All checks were successful
CodeChecker 变更检测 / code-check (push) Successful in 22s
2026-06-09 16:46:22 +08:00
1643660c0c 源码update
All checks were successful
CodeChecker 变更检测 / code-check (push) Successful in 24s
2026-06-09 16:45:31 +08:00
85f0acb4ac PunishmentsApprovalDto- UserSelfDto类对象参数 - 嵌套对象 - 新增参数
All checks were successful
CodeChecker 变更检测 / code-check (push) Successful in 24s
2026-06-09 16:34:06 +08:00
bd7db35db8 源码update
All checks were successful
CodeChecker 变更检测 / code-check (push) Successful in 25s
2026-06-09 16:32:47 +08:00
b97bdea716 PunishmentsApprovalDto-类对象参数 - 嵌套对象 - 删除参数
All checks were successful
CodeChecker 变更检测 / code-check (push) Successful in 27s
2026-06-09 16:23:17 +08:00
cd82403e80 PunishmentsApprovalDto-类对象参数 - 嵌套对象 - 新增参数
All checks were successful
CodeChecker 变更检测 / code-check (push) Successful in 20s
2026-06-09 16:05:01 +08:00
850f000f86 PunishmentsApprovalDto-类对象参数 - 只改形参参数名
All checks were successful
CodeChecker 变更检测 / code-check (push) Successful in 26s
2026-06-09 16:03:00 +08:00
a38245bb9b PunishmentsApprovalDto-参数类型变更
All checks were successful
CodeChecker 变更检测 / code-check (push) Successful in 23s
2026-06-09 15:51:15 +08:00
75c5d5c70b PunishmentsApprovalDto-删除参数测试
All checks were successful
CodeChecker 变更检测 / code-check (push) Successful in 23s
2026-06-09 15:49:06 +08:00
acf3f241cf PunishmentsApprovalDto-修改参数测试
All checks were successful
CodeChecker 变更检测 / code-check (push) Successful in 23s
2026-06-09 15:47:24 +08:00
33a580a040 PunishmentsApprovalDto-修改参数测试
All checks were successful
CodeChecker 变更检测 / code-check (push) Successful in 21s
2026-06-09 15:45:31 +08:00
f94c24a0ab 源码update
All checks were successful
CodeChecker 变更检测 / code-check (push) Successful in 20s
2026-06-09 15:43:04 +08:00
c8840e2af0 PunishmentsApprovalDto-新增参数测试
All checks were successful
CodeChecker 变更检测 / code-check (push) Successful in 23s
2026-06-09 15:41:36 +08:00
ac558bdc89 PunishmentsApprovalDto-删除参数测试
All checks were successful
CodeChecker 变更检测 / code-check (push) Successful in 21s
2026-06-09 15:38:52 +08:00
38e018b8aa PunishmentsApprovalDto-新增参数测试
All checks were successful
CodeChecker 变更检测 / code-check (push) Successful in 21s
2026-06-09 15:26:13 +08:00
36b571df5d 源码update
All checks were successful
CodeChecker 变更检测 / code-check (push) Successful in 20s
2026-06-09 15:20:43 +08:00
d1610bd43c 新增参数 测试
All checks were successful
CodeChecker 变更检测 / code-check (push) Successful in 21s
2026-06-09 15:17:28 +08:00
0f02b883c1 删除参数 测试
All checks were successful
CodeChecker 变更检测 / code-check (push) Successful in 22s
2026-06-09 15:14:15 +08:00
e27b8adf1f 删除参数
All checks were successful
CodeChecker 变更检测 / code-check (push) Successful in 21s
2026-06-09 15:04:43 +08:00
da4611a24c 源码更新
All checks were successful
CodeChecker 变更检测 / code-check (push) Successful in 23s
2026-06-09 15:02:52 +08:00
a9e7072688 修改参数-三者都改
All checks were successful
CodeChecker 变更检测 / code-check (push) Successful in 21s
2026-06-09 15:01:01 +08:00
bf191f6baf 修改参数-只修改参数类型
All checks were successful
CodeChecker 变更检测 / code-check (push) Successful in 21s
2026-06-09 14:57:01 +08:00
b15ffc223b 修改参数-只修改参数类型
All checks were successful
CodeChecker 变更检测 / code-check (push) Successful in 22s
2026-06-09 14:35:28 +08:00
934b7d068f 请求参数 binding名 重命名测试
All checks were successful
CodeChecker 变更检测 / code-check (push) Successful in 21s
2026-06-09 14:31:37 +08:00
01d6952ad9 RequestParam参数新增测试
All checks were successful
CodeChecker 变更检测 / code-check (push) Successful in 20s
2026-06-09 14:07:20 +08:00
ee9583c870 RequestParam参数修改测试
All checks were successful
CodeChecker 变更检测 / code-check (push) Successful in 20s
2026-06-09 14:02:07 +08:00
e765cd64e5 RequestParam参数修改测试
All checks were successful
CodeChecker 变更检测 / code-check (push) Successful in 20s
2026-06-09 13:59:32 +08:00
780ba5fbf8 接口参数变更测试 接收字段名变更
All checks were successful
CodeChecker 变更检测 / code-check (push) Successful in 19s
2026-06-09 13:57:41 +08:00
8f19bb817e 接口参数变更测试 接收字段名变更
All checks were successful
CodeChecker 变更检测 / code-check (push) Successful in 19s
2026-06-09 13:43:49 +08:00
e2b1cb7b72 API开关检测
All checks were successful
CodeChecker 变更检测 / code-check (push) Successful in 19s
2026-06-09 11:39:49 +08:00
17d3cab02d 类开关测试
All checks were successful
CodeChecker 变更检测 / code-check (push) Successful in 11s
2026-06-09 11:34:26 +08:00
3c086d8709 总开关测试
All checks were successful
CodeChecker 变更检测 / code-check (push) Successful in 9s
2026-06-09 11:33:25 +08:00
2de66b89f4 ApplyAttendanceOutsideDto
All checks were successful
CodeChecker 变更检测 / code-check (push) Successful in 25s
2026-06-09 11:31:43 +08:00
ceb1c81f94 ApplyAttendanceOutsideDto 2026-06-09 11:28:52 +08:00
871823b3da 项目结构变更 2026-06-09 11:20:24 +08:00
fb6cd124c8 API变更整合
All checks were successful
类变更检测 / class-change-check (push) Successful in 17s
2026-06-09 11:02:06 +08:00
46988e63fa com
All checks were successful
类变更检测 / class-change-check (push) Successful in 19s
2026-06-09 10:59:14 +08:00
95e031e63d com
All checks were successful
类变更检测 / class-change-check (push) Successful in 17s
2026-06-09 10:58:36 +08:00
c39355815e 同时变更
All checks were successful
类变更检测 / class-change-check (push) Successful in 18s
2026-06-09 10:54:01 +08:00
5ff1f6aa73 请求方式&请求参数 同时变更
All checks were successful
类变更检测 / class-change-check (push) Successful in 18s
2026-06-09 10:48:07 +08:00
1c6c153ea6 删除接口-多个
All checks were successful
类变更检测 / class-change-check (push) Successful in 19s
2026-06-09 10:40:40 +08:00
94e34b6f2e 修改接口路径-多个
All checks were successful
类变更检测 / class-change-check (push) Successful in 19s
2026-06-09 10:39:21 +08:00
dfb24790d0 新增接口-多个
All checks were successful
类变更检测 / class-change-check (push) Successful in 19s
2026-06-09 10:36:35 +08:00
ba5de889de 新增接口
All checks were successful
类变更检测 / class-change-check (push) Successful in 18s
2026-06-09 10:34:53 +08:00
ea01756156 c
All checks were successful
类变更检测 / class-change-check (push) Successful in 17s
2026-06-09 10:31:53 +08:00
fc657dd8c0 删除接口
All checks were successful
类变更检测 / class-change-check (push) Successful in 18s
2026-06-09 10:21:06 +08:00
fcd1f64ddd 新增接口
All checks were successful
类变更检测 / class-change-check (push) Successful in 18s
2026-06-09 10:19:56 +08:00
4937cc6738 API路径变更+类对象变更
All checks were successful
类变更检测 / class-change-check (push) Successful in 18s
2026-06-09 10:10:00 +08:00
2aa7b0c4ef API参数变更检测-类对象参数 - 新增参数
All checks were successful
类变更检测 / class-change-check (push) Successful in 18s
2026-06-09 10:07:38 +08:00
1202dd95d6 API参数变更检测-修改方法入参参数-修改参数类型
All checks were successful
类变更检测 / class-change-check (push) Successful in 18s
2026-06-09 10:03:29 +08:00
7e94f75ebd API参数变更检测-删除方法入参参数
All checks were successful
类变更检测 / class-change-check (push) Successful in 18s
2026-06-09 10:00:11 +08:00
25316a6f61 API参数变更检测-新增方法入参参数
All checks were successful
类变更检测 / class-change-check (push) Successful in 18s
2026-06-09 09:56:35 +08:00
7e03ef872a API参数变更检测-只修改后端接收值
All checks were successful
类变更检测 / class-change-check (push) Successful in 18s
2026-06-09 09:52:18 +08:00
4be719190f API路径变更检测 修改接口
All checks were successful
类变更检测 / class-change-check (push) Successful in 17s
2026-06-09 09:46:40 +08:00
a94c89ba90 API路径变更检测 删除接口
All checks were successful
类变更检测 / class-change-check (push) Successful in 18s
2026-06-09 09:45:05 +08:00
fbd6e6d5ee API路径变更检测 新增接口
All checks were successful
类变更检测 / class-change-check (push) Successful in 17s
2026-06-09 09:43:50 +08:00
ac1873ee96 API路径变更检测 请求方式变更
All checks were successful
类变更检测 / class-change-check (push) Successful in 18s
2026-06-09 09:39:16 +08:00
67fd025f02 API路径变更检测 请求方式变更
Some checks failed
类变更检测 / class-change-check (push) Failing after 6s
2026-06-09 09:37:21 +08:00
1ee3ef9088 API路径变更检测 请求方式变更
All checks were successful
类变更检测 / class-change-check (push) Successful in 17s
2026-06-09 09:29:10 +08:00
9fa59cd2a9 API路径变更检测
All checks were successful
类变更检测 / class-change-check (push) Successful in 15s
2026-06-09 09:28:48 +08:00
eedc1767b3 类型展示的优化
All checks were successful
类变更检测 / class-change-check (push) Successful in 15s
2026-06-08 17:29:11 +08:00
be19389f4c ApplyAttendanceChangeVo、
All checks were successful
类变更检测 / class-change-check (push) Successful in 16s
ApplyAttendanceOutsideVo都修改
2026-06-08 17:18:48 +08:00
2b88112963 Vo测试 字段名&字段类型&字段说明 都修改
All checks were successful
类变更检测 / class-change-check (push) Successful in 16s
类型补充-》测试
2026-06-08 17:16:02 +08:00
466a884651 Vo测试 字段名&字段类型&字段说明 都修改
All checks were successful
类变更检测 / class-change-check (push) Successful in 18s
2026-06-08 17:05:33 +08:00
8c92fecf2c Vo测试 字段名&字段类型 都重构
All checks were successful
类变更检测 / class-change-check (push) Successful in 16s
2026-06-08 17:03:30 +08:00
75d56c24ef Vo测试 字段名&字段类型 都重构
All checks were successful
类变更检测 / class-change-check (push) Successful in 16s
2026-06-08 16:49:56 +08:00
22728b8408 Vo测试 字段名&字段说明 都重构
All checks were successful
类变更检测 / class-change-check (push) Successful in 16s
2026-06-08 16:47:17 +08:00
17a320f0df Vo测试 仅修改字段名测试
All checks were successful
类变更检测 / class-change-check (push) Successful in 16s
2026-06-08 16:45:07 +08:00
afe9cfc70f Vo字段类型修改 & 删除字段
All checks were successful
类变更检测 / class-change-check (push) Successful in 16s
2026-06-08 16:43:09 +08:00
6a3a3a72d7 Vo字段类型新增测试
All checks were successful
类变更检测 / class-change-check (push) Successful in 16s
2026-06-08 16:41:30 +08:00
2c6ebcb737 Vo字段类型修改测试
All checks were successful
类变更检测 / class-change-check (push) Successful in 16s
2026-06-08 16:33:48 +08:00
b9242a9f2b 类中文说明补充优化
All checks were successful
类变更检测 / class-change-check (push) Successful in 14s
2026-06-08 16:31:53 +08:00
90afda6c3c 类中文说明补充
All checks were successful
类变更检测 / class-change-check (push) Successful in 15s
2026-06-08 16:30:50 +08:00
e367d4257f 字段修改匹配规则优化
All checks were successful
类变更检测 / class-change-check (push) Successful in 14s
2026-06-08 16:16:39 +08:00
ba1cc6aa4e ApplyAttendanceChangeDto&ApplyAttendanceChangeDto 都修改
All checks were successful
类变更检测 / class-change-check (push) Successful in 16s
2026-06-08 16:10:39 +08:00
e6c90eccff ApplyAttendanceChangeDto
All checks were successful
类变更检测 / class-change-check (push) Successful in 15s
字段名&字段类型&字段注释说明 都重构
2026-06-08 16:05:26 +08:00
717278de4a ApplyAttendanceChangeDto
All checks were successful
类变更检测 / class-change-check (push) Successful in 20s
字段名&字段类型 都重构
2026-06-08 15:55:08 +08:00
8fa8199012 ApplyAttendanceChangeDto字段taskId重命名 同时改名称和字段说明
All checks were successful
类变更检测 / class-change-check (push) Successful in 16s
2026-06-08 15:45:00 +08:00
e9ed5299b2 ApplyAttendanceChangeDto字段taskId重命名
All checks were successful
类变更检测 / class-change-check (push) Successful in 16s
2026-06-08 15:43:35 +08:00
5533ca5503 ApplyAttendanceChangeDto字段名修改
All checks were successful
类变更检测 / class-change-check (push) Successful in 15s
2026-06-08 15:31:55 +08:00
bbd781fc70 ApplyAttendanceChangeDto
All checks were successful
类变更检测 / class-change-check (push) Successful in 16s
新增
2026-06-08 15:29:39 +08:00
fb575311d5 ApplyAttendanceChangeDto
All checks were successful
类变更检测 / class-change-check (push) Successful in 15s
字段新增&删除
2026-06-08 15:22:18 +08:00
6d8cc0eddc 类名修改适配
All checks were successful
类变更检测 / class-change-check (push) Successful in 16s
2026-06-08 15:21:06 +08:00
17aa214952 类名修改测试:
All checks were successful
类变更检测 / class-change-check (push) Successful in 16s
ApplyAttendanceChangeDto->ApplyAttendanceChange1Dto
2026-06-08 15:15:13 +08:00
2e77bd62ac 类名修改测试:ApplyAttendanceChange1Dto->ApplyAttendanceChangeDto
All checks were successful
类变更检测 / class-change-check (push) Successful in 15s
2026-06-08 15:13:54 +08:00
e86991c66e 类名修改测试:ApplyAttendanceChangeDto1->ApplyAttendanceChange1Dto
All checks were successful
类变更检测 / class-change-check (push) Successful in 16s
2026-06-08 15:07:02 +08:00
01b3e75968 测试:只修改类名
All checks were successful
类变更检测 / class-change-check (push) Successful in 15s
2026-06-08 14:56:03 +08:00
bd78fa5872 测试:只修改类名
All checks were successful
类变更检测 / class-change-check (push) Successful in 16s
2026-06-08 14:52:48 +08:00
3ae49737e2 测试:只修改类名
All checks were successful
类变更检测 / class-change-check (push) Successful in 15s
2026-06-08 14:41:50 +08:00
9141fdbb19 格式优化测试完成
All checks were successful
类变更检测 / class-change-check (push) Successful in 13s
2026-06-08 14:40:06 +08:00
5821b85578 关闭通知测试
All checks were successful
类变更检测 / class-change-check (push) Successful in 14s
2026-06-08 14:30:31 +08:00
7c807bd9e9 格式优化测试
All checks were successful
类变更检测 / class-change-check (push) Successful in 15s
2026-06-08 14:20:40 +08:00
a5749339a1 目录重构
All checks were successful
类变更检测 / class-change-check (push) Successful in 14s
2026-06-08 14:01:05 +08:00
5105a460e0 ApplyAttendanceChangeDto类变更
All checks were successful
类变更检测 / class-change-check (push) Successful in 14s
2026-06-08 13:35:45 +08:00
9010792b6e 脚本修改
All checks were successful
类变更检测 / class-change-check (push) Successful in 13s
2026-06-08 13:17:43 +08:00
84ae68be05 脚本修改
Some checks failed
类变更检测 / class-change-check (push) Failing after 0s
2026-06-08 13:16:12 +08:00
2f8798c38c 脚本修改
Some checks failed
类变更检测 / class-change-check (push) Failing after 1s
2026-06-08 13:08:34 +08:00
9e1d66c81f Dto修改
All checks were successful
类变更检测 / class-change-check (push) Successful in 5m40s
2026-06-05 18:32:27 +08:00
c5bc52911b Vo修改
All checks were successful
类变更检测 / class-change-check (push) Successful in 4m0s
2026-06-05 18:25:31 +08:00
13e067f2e2 Vo修改
Some checks failed
类变更检测 / class-change-check (push) Failing after 4s
2026-06-05 18:23:18 +08:00
ec3bd1d0b2 commit
Some checks failed
类变更检测 / class-change-check (push) Failing after 4s
2026-06-05 18:21:45 +08:00
c3c73b6fb3 目录del 2026-06-05 18:00:43 +08:00
88 changed files with 6514 additions and 2484 deletions

2
.gitattributes vendored Normal file
View File

@@ -0,0 +1,2 @@
*.sh text eol=lf
scripts/* text eol=lf

3
.gitea/.gitignore vendored
View File

@@ -1,3 +0,0 @@
# 本地/CI 运行时产生的缓存与日志(可不提交)
.cache/
logs/

View File

@@ -0,0 +1,160 @@
# API 变更检测 — 分析与设计
在现有 **类变更检测**`class-checker.jar`)基础上,扩展 **API 路径变更****API 参数变更** 两类能力。通知渲染参考 [`11.py`](./11.py)。
---
## 一、用 JavaParser 做方便吗?
**结论:方便,且与现有技术栈一致。**
| 维度 | 评估 |
|------|------|
| 技术栈 | `class-checker` 已依赖 `javaparser-symbol-solver-core 3.25.10``EndpointParser` 已在用 |
| 路径解析 | `@RequestMapping` / `@GetMapping` 等注解 + 类级 path 拼接 — **已实现** |
| HTTP 方法 | Mapping 注解读取 — **已实现** |
| 参数解析 | 方法 `Parameter` + `@RequestBody` / `@PathVariable` / `@RequestParam` 注解 — **需扩展** |
| DTO 字段 diff | `@RequestBody` 的 Dto 一级字段 — 可复用 `ClassFieldParser` + `FieldDiffEngine` |
| 跨提交对比 | `git show oldSha:path` / `newSha:path` + AST 对比 — 与类变更相同模式 |
JavaParser **擅长** 注解驱动的 Spring MVC 声明式接口;**不擅长** 运行时动态路由、非注解配置(如 `WebMvcConfigurer` 手动注册)。
---
## 二、与现有代码的关系
```
现有(类变更) 待扩展API 变更)
───────────────── ─────────────────
GitChangeScanner ────────► Git 扫描 Controller/*.java 变更
EndpointParser索引 ────────► EndpointSnapshotParser含参数明细
FieldDiffEngine ────────► 复用:@RequestBody Dto 字段 diff
ImpactAnalyzer ────────► 类变更专用API 变更不直接用)
WeComNotifier ────────► ApiChangeNotifier参考 11.py
```
当前 `ApiEndpoint` 仅含:`httpMethod``uri``paramTypes`(类型简单名集合)、`returnTypes`**不足以做参数级 diff**,需扩展为 `ApiEndpointSnapshot`(含每个参数的 name、type、source、required、description 等)。
---
## 三、检测能力拆分
### 3.1 API 路径变更(功能 1
| 场景 | 识别方式 | 通知模版 |
|------|----------|----------|
| 新增接口 | 新 commit 有、旧 commit 无(同 Controller 方法指纹) | [`api-path-change.md`](./api-path-change.md) · 新增接口 |
| 删除接口 | 旧有、新无 | 删除接口 |
| 修改路径 | 类级/方法级 `@RequestMapping` path 变化HTTP 方法不变 | 修改路径 |
| 修改请求方式 | 同 URI或同方法指纹HTTP 方法变化 | [`api-method-change.md`](./api-method-change.md) |
**方法指纹建议**(用于跨 commit 匹配同一接口):
```
controller源文件 + 方法名(不含参数信息;增删参/改类型等由 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 |
整体:**可行,约 35 个核心类 + 1 个 Notifier**,与类变更检测复用 Git + JavaParser + 企微基础设施。
---
## 八、实现状态(已并入 `class-checker.jar`
| 模块 | 包路径 | 状态 |
|------|--------|------|
| 配置 | `AppConfig` + `config.yaml` `api_check` | ✅ |
| 扫描 | `api.scanner.ApiFileChangeScanner` | ✅ |
| 解析 | `api.parser.EndpointSnapshotParser``NestedDtoFieldParser` | ✅ |
| 分析 | `api.analyzer.ApiChangeAnalyzer``EndpointDiffEngine``ParameterDiffEngine` | ✅ |
| 通知 | `api.notify.ApiChangeNotifier`(路径/方法/参数分条) | ✅ |
| 编排 | `ClassCheckMain`(类变更与 API 变更独立执行) | ✅ |
| 公共 | `common.MarkdownStyles``common.WeComMarkdownSender` | ✅ |
**已确认策略**:并入同一 jar含 Feign含请求方式变更嵌套 Dto 字段;对齐类变更通知规范;分条发送;路径+参数同改拆两条;不含 LLM。

View File

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

View File

@@ -0,0 +1,121 @@
# API 参数变更通知模版
对应 `11.py``build_markdown_notification()` 中参数变更分支 + `_format_endpoint_block()`
适用:**URI 与 HTTP 方法均未变**,仅入参发生变化。
---
## 完整示例
```
# 【API参数变更通知】
- **修改人:** dongzi
- **修改时间:** 2026-06-08 16:30:00
- **变更类型:** <font color="warning">**修改参数**</font>
- **URI** **POST** `/apply/clockIn`
- **路径:** <font color="info">**jnpf-ftb/jnpf-ftb-biz/src/main/java/jnpf/workflow/controller/ApplyClockInController.java**</font>
---------------------------------------
#### 【接口参数变动详情】
**类对象变更(一级字段)**
共 **1** 个类对象 · **2** 项字段变更
**`ApplyAttendanceChangeDto`**
├─ `taskIds` · `List<String>` · 必填 [新增]
> 说明:流程主键集合
└─ `applyUser1` · `Integer` [删除]
> 说明:申请人员
**普通参数变更**
共 **1** 项变更
1. `id` · `String` · 路径参数 [新增]
> 说明:主键
```
---
## 示例(仅普通参数,无 RequestBody
```
# 【API参数变更通知】
- **修改人:** dongzi
- **修改时间:** 2026-06-08 16:30:00
- **变更类型:** <font color="warning">**修改参数**</font>
- **URI** **GET** `/apply/clockIn/{id}`
- **路径:** <font color="info">**jnpf-ftb/.../ApplyClockInController.java**</font>
---------------------------------------
#### 【接口参数变动详情】
**普通参数变更**
共 **1** 项变更
1. `pageSize` · `Integer` · 查询参数 [新增]
```
---
## 参数分类与检测
| 来源注解 | `source` 字段 | diff 粒度 |
|----------|---------------|-----------|
| `@RequestBody` | `body` | Dto **一级字段**(复用 `FieldDiffEngine` |
| `@PathVariable` | `path` | 参数名、类型、增删 |
| `@RequestParam` | `query` | 参数名、类型、required、增删 |
| 无注解 | `simple` | 待确认是否纳入 |
### 排除的框架参数(建议默认开启)
`HttpServletRequest``HttpServletResponse``BindingResult``Principal``Authentication``Model``ModelMap``UriComponentsBuilder` 等。
---
## ParameterChange 数据结构(对齐 11.py
| 字段 | 说明 |
|------|------|
| `param_name` | 当前参数名 / 字段名 |
| `old_name` | 重命名前名称 |
| `param_type` | 类型字符串,如 `List<String>` |
| `description` | 说明(@Schema / 注释) |
| `source` | `body` / `path` / `query` |
| `body_param_name` | `@RequestBody` 形参名 |
| `parent_dto` | Dto 简单类名 |
| `change_type` | `added` / `removed` / `modified` / `renamed` |
| `detail` | 类型变化等详情 |
---
## 与路径变更的拆分规则11.py 约定)
| 同一次改动 | 通知策略 |
|------------|----------|
| 仅参数变 | 本模版 |
| 路径变 + 参数变 | **拆两条**:先路径通知,再参数通知 |
| 方法变 + 参数变 | **拆两条**:先方法通知,再参数通知 |
| 新增接口 + 带参数 | 路径通知可**附带**参数详情区块 |
---
## JavaParser 实现要点
1. **EndpointSnapshotParser**:遍历 `MethodDeclaration.getParameters()`,读参数注解
2. **@RequestBody**:取 Dto 类型 → `ClassFieldParser.parseFields()` 得字段列表
3. **ParameterDiffEngine**:旧/新快照按方法指纹对齐后 diff
4. **ApiChangeNotifier**:渲染本模版;泛型类型展示规则与类变更通知一致(`<>` 不 HTML 转义)
## 实现
- `ParameterDiffEngine`
- `ApiChangeNotifier.formatEndpointBlock()`
- 复用 `FieldDiffEngine` / `WeComNotifier` 中的字段行格式(可选统一)

View File

@@ -0,0 +1,88 @@
# API 路径变更通知模版
对应 `11.py``build_path_change_markdown()`
适用:**新增接口**、**删除接口**、**修改路径**。
---
## 完整示例(修改路径)
```
# 【API路径变更通知】
变更类型: <font color="warning">**修改路径**</font>
路径: <font color="info">**jnpf-ftb/jnpf-ftb-biz/src/main/java/jnpf/workflow/controller/ApplyClockInController.java**</font>
修改人: dongzi
修改时间: 2026-06-08 16:30:00
---------------------------------------
#### 【URI变更详情】
- **原路径:** <font color="warning">~~`/apply/clockIn`~~</font> ← <font color="warning">**旧路径**</font>
- **新路径:** <font color="info">**`/apply/clockIn/v2`**</font> ← <font color="info">**新路径**</font>
```
---
## 示例(新增接口)
```
# 【API路径变更通知】
变更类型: <font color="warning">**新增接口**</font>
路径: <font color="info">**jnpf-ftb/.../ApplyClockInController.java**</font>
修改人: dongzi
修改时间: 2026-06-08 16:30:00
---------------------------------------
#### 【URI变更详情】
- **原路径:** `-`
- **新路径:** <font color="info">**`/apply/clockIn`**</font> ← <font color="info">**新增**</font>
```
> 若新增接口同时有参数变更,可在路径通知后追加【接口参数变动详情】区块(见 `api-param-change.md`)。
---
## 示例(删除接口)
```
# 【API路径变更通知】
变更类型: <font color="warning">**删除接口**</font>
路径: <font color="info">**jnpf-ftb/.../ApplyClockInController.java**</font>
修改人: dongzi
修改时间: 2026-06-08 16:30:00
---------------------------------------
#### 【URI变更详情】
- **原路径:** <font color="warning">**`/apply/clockIn/{id}`**</font> ← <font color="warning">**已删除**</font>
- **新路径:** `已删除`
```
---
## 字段说明
| 占位符 | 来源 |
|--------|------|
| 变更类型 | `新增接口` / `删除接口` / `修改路径` |
| 路径 | Controller `.java` 相对仓库根路径(`source_file` |
| 原路径 / 新路径 | 类级 `@RequestMapping` + 方法级 Mapping 拼接后的 URI |
| HTTP 方法 | 路径变更通知中默认不展示;与请求方式变更模版区分 |
## 检测逻辑JavaParser
1. 解析旧/新 commit 下同一 Controller 源码 AST
2. 提取每个方法的 `httpMethod` + `uri`(已有 `EndpointParser` 逻辑)
3. 用**方法指纹**(类文件 + 方法名,如 `ApplyClockInController.java#getApply`)匹配新旧接口;参数增删改由 ParameterDiffEngine 检测
4. 指纹相同且 URI 不同 → **修改路径**
5. 仅旧有新无 → **删除**;仅新有旧无 → **新增**
## 实现
- `EndpointSnapshotParser` — 解析快照
- `EndpointDiffEngine` — 对比产出 `EndpointChangeReport.is_renamed_endpoint` 等标志
- `ApiChangeNotifier.buildPathChangeMarkdown()` — 渲染本模版

View File

@@ -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,
)

View File

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

View File

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

View File

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

View 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>

View File

@@ -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]:
"""
获取上一次提交的 SHAHEAD~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

View File

@@ -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}
## 输出格式(严格遵守)
- 只输出 36 行 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)

View File

@@ -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())

View File

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

View File

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

View 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`

View 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` / 注释 |

View 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>
```

View 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()`

View 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>
```

View 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
View 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>

View File

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

View 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());
}
}

View File

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

View File

@@ -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("[]");
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) &gt; @Operation(description) &gt; Javadoc 首段。
*/
public final class MethodDescriptionExtractor {
private MethodDescriptionExtractor() {
}
public static String extract(MethodDeclaration method) {
if (method == null) {
return "";
}
for (AnnotationExpr annotation : method.getAnnotations()) {
if (!"Operation".equals(annotation.getNameAsString())) {
continue;
}
String summary = readAnnotationStringValue(annotation, "summary");
if (!summary.isEmpty()) {
return summary;
}
String description = readAnnotationStringValue(annotation, "description");
if (!description.isEmpty()) {
return description;
}
}
return extractMethodJavadoc(method);
}
private static String extractMethodJavadoc(MethodDeclaration method) {
Optional<JavadocComment> javadoc = method.getJavadocComment();
if (javadoc.isEmpty()) {
return "";
}
String text = javadoc.get().parse().getDescription().toText();
return text == null ? "" : text.trim().replaceAll("\\s+", " ");
}
private static String readAnnotationStringValue(AnnotationExpr annotation, String attributeName) {
if (annotation.isNormalAnnotationExpr()) {
NormalAnnotationExpr normal = annotation.asNormalAnnotationExpr();
for (var pair : normal.getPairs()) {
if (pair.getNameAsString().equals(attributeName)) {
return literalString(pair.getValue());
}
}
return "";
}
if (annotation.isSingleMemberAnnotationExpr()) {
SingleMemberAnnotationExpr single = annotation.asSingleMemberAnnotationExpr();
if ("value".equals(attributeName) || "description".equals(attributeName)
|| "summary".equals(attributeName)) {
return literalString(single.getMemberValue());
}
}
return "";
}
private static String literalString(Expression expression) {
if (expression.isStringLiteralExpr()) {
return expression.asStringLiteralExpr().getValue().trim();
}
return "";
}
}

View File

@@ -0,0 +1,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();
}
}

View File

@@ -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("[]");
}
}

View File

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

View File

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

View File

@@ -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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;");
}
}

View File

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

View File

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

View File

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

View File

@@ -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 配对为 RENAMEDGit 未显式标记 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;
}
}
}
}

View File

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

View File

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

View File

@@ -0,0 +1,15 @@
package com.codechecker.model;
/**
* 单次类变更的类型,决定通知内容与影响分析策略。
*/
public enum ClassChangeKind {
/** 文件已删除 */
DELETED,
/** 仅字段变更 */
FIELDS_ONLY,
/** 仅类名变更,字段不变 */
RENAME_ONLY,
/** 类名与字段同时变更 */
RENAME_AND_FIELDS
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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引用块 + 换行排版,三色 fontinfo/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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;");
}
/** POST 企微 Webhookmarkdown 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 + "\"";
}
}

View File

@@ -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) &gt; 类 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;
}
}

View File

@@ -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) &gt; @ApiModelProperty &gt; 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+", " ");
}
}

View File

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

View File

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

View File

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

View File

@@ -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: ""

View File

@@ -1,34 +0,0 @@
# 【API参数变更通知】
- **变更类型:** {新增接口 / 修改参数 / 删除接口}
- **URI** {Method} {URI}
- **修改人:** {Modifier}
- **修改时间:** {ModifyTime}
- **全路径类名:** {FileName}
---
## 接口参数变动详情
---
### 类对象变更
(如有对象替换或对象属性变更)
- **对象:** {类名}
- **变更方式:** {对象属性变更 / 对象替换旧类A → 新类B}
- **属性变更明细:**
- [新增] 属性: `attr1` 说明: {说明}
- [删除] 属性: `attr2` 说明: {说明}
- [修改] 属性: `attr3` 说明: {说明}
### 【参数变更】
- **变更列表:**
| 字段 | 说明 | 变更 |
|------|------|------|
| `pageSize` | 每页条数 | 新增必填 |
| `keyword` | 搜索关键词 | 类型由String改为Long |
| `startTime` | 开始时间 | 删除 |
(如无变更,显示:无)

View File

@@ -1,17 +0,0 @@
# 【API路径变更通知】
变更类型: {新增接口 / 修改路径 / 删除接口}
全路径类名: {FullClassName}
修改人: {Modifier}
修改时间: {ModifyTime}
---
#### 【URI变更详情】
- **原路径:** `{OldURI}` *(新增时显示:-*
- **新路径:** `{NewURI}` *(删除时显示:已删除 / -*
**示例:**
- 全路径类名:`com.example.controller.UserController`
- 原路径:`/api/users/{id}`
- 新路径:`/api/users/getall`

View File

@@ -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 接口参数变更日志';

View File

@@ -1,46 +0,0 @@
# Gitea ActionsController 接口参数变更检测(纯 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"

View 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

View 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"

Binary file not shown.

8
.gitignore vendored
View File

@@ -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
View 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
View 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:

View File

@@ -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, "外勤打卡"));

View File

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

View File

@@ -20,7 +20,7 @@ public class BookRecordMonthListDto {
*/
@NotBlank(message = "考勤本ID不能为空")
@Schema(description = "考勤本ID", required = true)
private String bookId;
private Integer bookId;
/**
* 月份必填格式yyyy-MM

View File

@@ -19,7 +19,7 @@ public class DayStatisticsDto {
* 门店ID集合
*/
@NotEmpty(message = "门店ID集合不能为空")
private List<String> storeIds;
private List<Integer> storeIds;
/**
* 日期(yyyy-mm-dd)
*/

View File

@@ -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;
/** 变更理由 */

View File

@@ -18,8 +18,6 @@ public class ApplyAttendanceOutsideDto {
/** 审批id */
private String taskId;
/** 流程主键 */
private String flowId;
/** 流程标题 */
private String flowTitle;
/** 申请人id */

View File

@@ -17,7 +17,7 @@ public class PunishmentsApprovalDto {
private String id;
/** 用户Id 暂弃 */
private String userId;
private Integer userId;
/** 惩罚核算日 */
private Date day;

View File

@@ -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 */

View File

@@ -16,9 +16,7 @@ import java.util.Date;
public class ApplyAttendanceOutsideVo {
/** 主键 */
private String id;
/** 流程主键 */
private String flowId;
private Integer id;
/** 流程标题 */
private String flowTitle;
/** 申请人id */

View 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"