hookehuyr

feat(api-generator): 新增 API 变更自动检测功能

新增独立的 API 对比工具,在生成 API 文档时自动检测接口变更并识别破坏性变更。

主要特性:
- 自动对比新旧 OpenAPI 文档,检测接口增删改
- 智能识别破坏性变更(新增必填参数、删除参数、类型变更等)
- 分类展示破坏性/非破坏性变更,便于业务代码审查
- 支持独立调用和集成调用两种模式
- 提供文本和 JSON 两种输出格式
- 双基线机制自动管理版本对比

技术实现:
- 新增 apiDiff.js 核心对比脚本,支持 GET/POST 参数对比
- 集成到 generateApiFromOpenAPI.js,自动触发检测
- 新增 api-diff skill,可独立使用或 CI/CD 集成
- 详细的 API_DIFF_GUIDE.md 使用文档

使用方式:
- 自动检测:node scripts/generateApiFromOpenAPI.js
- 手动对比:node scripts/apiDiff.js <oldPath> <newPath>

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 +# API Diff Skill
2 +
3 +对比两个版本的 OpenAPI 文档或生成的 API 文件,检测接口变更。
4 +
5 +## 使用场景
6 +
7 +1. **更新 API 后自动检查**:运行 `api:generate` 后自动对比新旧接口
8 +2. **手动对比**:对比两个不同的 OpenAPI 文档
9 +3. **CI/CD 集成**:在部署前检查破坏性 API 变更
10 +
11 +## 如何调用
12 +
13 +### 在生成 API 后自动对比
14 +```bash
15 +pnpm run api:generate
16 +```
17 +生成器会自动调用对比逻辑,检查是否有接口变更。
18 +
19 +### 手动对比两个文档
20 +```bash
21 +# 对比两个 OpenAPI markdown 文档
22 +node scripts/apiDiff.js docs/openAPI/user/api1.md docs/openAPI/user/api1-new.md
23 +
24 +# 对比整个模块目录
25 +node scripts/apiDiff.js docs/openAPI/user/ docs/openAPI/user-new/
26 +
27 +# 对比生成的 API 文件
28 +node scripts/apiDiff.js src/api/user.js src/api/user-new.js
29 +```
30 +
31 +## 对比维度
32 +
33 +1. **接口增删**:新增或删除的接口
34 +2. **参数变更**
35 + - 新增必填参数(破坏性变更)
36 + - 删除参数(破坏性变更)
37 + - 参数类型变更(破坏性变更)
38 + - 新增可选参数(非破坏性)
39 +3. **返回值变更**
40 + - 返回结构变更
41 + - 字段类型变更
42 +4. **HTTP 方法变更**:GET ↔ POST(破坏性变更)
43 +
44 +## 输出格式
45 +
46 +对比结果会以以下格式输出:
47 +
48 +```
49 +=== API 变更检测报告 ===
50 +
51 +📦 模块: user
52 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
53 +
54 +✅ 新增接口 (1):
55 + + getUserProfile
56 +
57 +⚠️ 修改接口 (2):
58 + ↪ editUserInfo
59 + ✗ [破坏性] 删除必填参数: sms_code
60 + ✓ [非破坏性] 新增可选参数: avatar
61 +
62 + ↪ getUserInfo
63 + ✓ [非破坏性] 新增可选参数: include_profile
64 +
65 +❌ 删除接口 (1):
66 + - deleteUserAccount
67 +
68 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
69 +总计: 1 新增, 2 修改, 1 删除
70 +⚠️ 检测到 1 个破坏性变更,请仔细检查业务逻辑!
71 +```
72 +
73 +## 退出码
74 +
75 +- `0`: 无变更或仅有非破坏性变更
76 +- `1`: 检测到破坏性变更(可用于 CI/CD 失败)
77 +
78 +## 配置选项
79 +
80 +可以通过环境变量配置:
81 +
82 +```bash
83 +# 严格模式:任何变更都返回失败码
84 +API_DIFF_STRICT=true node scripts/apiDiff.js ...
85 +
86 +# 输出 JSON 格式(用于程序解析)
87 +API_DIFF_FORMAT=json node scripts/apiDiff.js ...
88 +```
89 +
90 +## 注意事项
91 +
92 +1. 对比逻辑基于 OpenAPI 规范,确保文档格式正确
93 +2. 破坏性变更需要在业务代码中做兼容处理
94 +3. 新增接口通常不需要修改现有代码
95 +4. 删除接口前请确认没有地方在使用
...@@ -12,3 +12,4 @@ pnpm-debug.log* ...@@ -12,3 +12,4 @@ pnpm-debug.log*
12 .env.*.local 12 .env.*.local
13 unpackage/ 13 unpackage/
14 .history/ 14 .history/
15 +.tmp/
......
1 +# API 变更检测工具使用指南
2 +
3 +## 概述
4 +
5 +API 变更检测工具是 OpenAPI 转 API 文档生成器的增强功能,可以自动检测接口变更并识别破坏性变更,帮助开发者在更新接口时及时发现潜在问题。
6 +
7 +## 工作原理
8 +
9 +1. **基线建立**:首次运行时自动建立 API 基线
10 +2. **变更检测**:后续运行时对比当前版本与基线版本
11 +3. **分类标记**:自动区分破坏性变更和非破坏性变更
12 +4. **自动更新**:检测完成后自动更新基线
13 +
14 +## 使用方式
15 +
16 +### 自动检测(推荐)
17 +
18 +每次运行 API 生成器时自动检测变更:
19 +
20 +```bash
21 +node scripts/generateApiFromOpenAPI.js
22 +```
23 +
24 +输出示例:
25 +
26 +```
27 +=== OpenAPI 转 API 文档生成器 ===
28 +
29 +输入目录: /Users/huyirui/program/itomix/git/manulife-weapp/docs/openAPI
30 +输出目录: /Users/huyirui/program/itomix/git/manulife-weapp/src/api
31 +
32 +💾 备份当前 OpenAPI 文档...
33 +
34 +找到 2 个模块: order, user
35 +
36 +处理模块: order
37 +找到 2 个 API 文档
38 + ✓ getDetail: 获取订单详情
39 + ✓ getList: 获取订单列表
40 + 📝 生成文件: /Users/huyirui/program/itomix/git/manulife-weapp/src/api/order.js
41 +
42 +处理模块: user
43 +找到 2 个 API 文档
44 + ✓ editUserInfo: 修改我的信息
45 + ✓ getUserInfo: 查询我的信息
46 + 📝 生成文件: /Users/huyirui/program/itomix/git/manulife-weapp/src/api/user.js
47 +
48 +✅ API 文档生成完成!
49 +
50 +🔍 开始检测 API 变更...
51 +
52 +
53 +=== API 变更检测报告 ===
54 +
55 +📦 对比范围: 2 个旧接口 → 2 个新接口
56 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
57 +
58 +⚠️ 修改接口 (1):
59 + ↪ getUserInfo - 查询我的信息
60 + ✓ [非破坏性] 新增可选 query 参数: include_profile
61 +
62 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
63 +总计: 0 新增, 1 修改, 0 删除
64 +✅ 未检测到破坏性变更
65 +
66 +📝 更新 API 基线...
67 +```
68 +
69 +### 手动检测
70 +
71 +可以独立运行对比脚本:
72 +
73 +```bash
74 +# 对比两个 OpenAPI 文档
75 +node scripts/apiDiff.js docs/openAPI/user/getUserInfo.md docs/openAPI/user/getUserInfo-new.md
76 +
77 +# 对比两个模块目录
78 +node scripts/apiDiff.js docs/openAPI/user/ docs/openAPI/user-new/
79 +```
80 +
81 +## 变更类型说明
82 +
83 +### 非破坏性变更(✓)
84 +
85 +这些变更不会影响现有代码:
86 +
87 +- **新增可选参数**:客户端可以不传,不影响现有调用
88 +- **删除可选参数**:服务端不再接收,但客户端不传也没问题
89 +- **必填参数变可选**:限制放宽,兼容性提升
90 +- **返回值新增字段**:客户端可以选择性使用
91 +
92 +### 破坏性变更(✗)
93 +
94 +这些变更需要检查业务逻辑:
95 +
96 +- **新增必填参数**:现有调用会缺少参数
97 +- **删除必填参数**:现有调用可能传了被删除的参数
98 +- **可选参数变必填**:现有调用可能不传该参数
99 +- **参数类型变更**:可能导致类型错误
100 +- **HTTP 方法变更**:GET ↔ POST 会导致调用失败
101 +- **删除接口**:所有调用该接口的地方需要修改
102 +
103 +## 输出格式
104 +
105 +### 文本格式(默认)
106 +
107 +```
108 +=== API 变更检测报告 ===
109 +
110 +📦 对比范围: 2 个旧接口 → 2 个新接口
111 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
112 +
113 +✅ 新增接口 (1):
114 + + getUserProfile - 获取用户详细资料
115 +
116 +⚠️ 修改接口 (2):
117 + ↪ editUserInfo - 修改我的信息
118 + ✗ [破坏性] 删除必填 body 参数: sms_code
119 + ✓ [非破坏性] 新增可选 body 参数: avatar
120 +
121 + ↪ getUserInfo - 查询我的信息
122 + ✓ [非破坏性] 新增可选 query 参数: include_profile
123 +
124 +❌ 删除接口 (1):
125 + - deleteUserAccount - 删除用户账号
126 +
127 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
128 +总计: 1 新增, 2 修改, 1 删除
129 +⚠️ 检测到 1 个破坏性变更,请仔细检查业务逻辑!
130 +```
131 +
132 +### JSON 格式
133 +
134 +设置环境变量输出 JSON 格式(便于程序解析):
135 +
136 +```bash
137 +API_DIFF_FORMAT=json node scripts/apiDiff.js <oldPath> <newPath>
138 +```
139 +
140 +输出示例:
141 +
142 +```json
143 +{
144 + "summary": {
145 + "added": 1,
146 + "modified": 2,
147 + "removed": 1,
148 + "breakingChanges": 1
149 + },
150 + "added": [
151 + {
152 + "name": "getUserProfile",
153 + "summary": "获取用户详细资料",
154 + "method": "GET",
155 + "path": "/srv/"
156 + }
157 + ],
158 + "modified": [
159 + {
160 + "name": "editUserInfo",
161 + "summary": "修改我的信息",
162 + "breakingChanges": [
163 + "删除必填 body 参数: sms_code"
164 + ],
165 + "nonBreakingChanges": [
166 + "新增可选 body 参数: avatar"
167 + ]
168 + }
169 + ],
170 + "removed": [
171 + {
172 + "name": "deleteUserAccount",
173 + "summary": "删除用户账号",
174 + "method": "POST",
175 + "path": "/srv/"
176 + }
177 + ]
178 +}
179 +```
180 +
181 +## CI/CD 集成
182 +
183 +### 严格模式
184 +
185 +任何变更都返回失败码:
186 +
187 +```bash
188 +API_DIFF_STRICT=true node scripts/apiDiff.js <oldPath> <newPath>
189 +```
190 +
191 +### 检查退出码
192 +
193 +```bash
194 +node scripts/apiDiff.js <oldPath> <newPath>
195 +EXIT_CODE=$?
196 +
197 +if [ $EXIT_CODE -eq 1 ]; then
198 + echo "检测到破坏性变更或启用严格模式"
199 + exit 1
200 +fi
201 +```
202 +
203 +退出码:
204 +- `0`: 无破坏性变更
205 +- `1`: 检测到破坏性变更或严格模式下有任何变更
206 +
207 +## 配置选项
208 +
209 +| 环境变量 | 说明 | 默认值 |
210 +|---------|------|--------|
211 +| `API_DIFF_FORMAT` | 输出格式 (text/json) | text |
212 +| `API_DIFF_STRICT` | 严格模式 (true/false) | false |
213 +
214 +## 存储位置
215 +
216 +- **基线文件**`.tmp/openAPI-temp/`
217 +- **临时备份**`.tmp/openAPI-backup/`
218 +
219 +这些目录已添加到 `.gitignore`,不会被提交到 git。
220 +
221 +## 常见问题
222 +
223 +### Q: 首次运行显示"首次运行,已建立基线"?
224 +
225 +A: 这是正常的。首次运行时会建立 API 基线,从第二次开始才会检测变更。
226 +
227 +### Q: 如何重置基线?
228 +
229 +A: 删除 `.tmp` 目录即可:
230 +
231 +```bash
232 +rm -rf .tmp
233 +```
234 +
235 +### Q: 检测到破坏性变更怎么办?
236 +
237 +A:
238 +1. 仔细检查变更内容
239 +2. 确认是否真的需要这个变更
240 +3. 如果需要,搜索所有调用该接口的代码并更新
241 +4. 如果是第三方接口变更,联系后端确认
242 +
243 +### Q: 可以对比生成的 JS 文件吗?
244 +
245 +A: 当前版本不支持对比生成的 JS 文件,请对比 OpenAPI 文档。
246 +
247 +### Q: 为什么有些参数没有检测到变更?
248 +
249 +A: 工具会自动过滤 `a``f` 参数(业务标识符),专注于业务参数。
250 +
251 +## 最佳实践
252 +
253 +1. **每次更新接口后运行检测**:及时发现问题
254 +2. **仔细阅读破坏性变更报告**:不要忽略警告
255 +3. **在 CI/CD 中集成**:自动化检测流程
256 +4. **定期审查非破坏性变更**:确保接口设计合理
257 +5. **保留接口变更历史**:便于追溯问题
258 +
259 +## 技术实现
260 +
261 +### 核心文件
262 +
263 +- `scripts/apiDiff.js` - 核心对比逻辑
264 +- `scripts/generateApiFromOpenAPI.js` - 集成调用
265 +- `.claude/custom_skills/api-diff/skill.md` - Skill 文档
266 +
267 +### 对比维度
268 +
269 +1. **接口级别**:新增、删除、修改
270 +2. **参数级别**:名称、类型、是否必填
271 +3. **HTTP 方法**:GET ↔ POST
272 +4. **返回值**:结构、字段类型
273 +
274 +### 检测逻辑
275 +
276 +- 基于参数名称和类型对比
277 +- 智能识别必填/可选参数变化
278 +- 自动分类破坏性/非破坏性变更
279 +- 支持模块级别和接口级别对比
280 +
281 +## 贡献
282 +
283 +如果发现问题或有改进建议,欢迎提交 Issue 或 Pull Request。
This diff is collapsed. Click to expand it.
...@@ -24,6 +24,7 @@ ...@@ -24,6 +24,7 @@
24 const fs = require('fs'); 24 const fs = require('fs');
25 const path = require('path'); 25 const path = require('path');
26 const yaml = require('js-yaml'); 26 const yaml = require('js-yaml');
27 +const { generateReport, parseOpenAPIPath } = require('./apiDiff');
27 28
28 /** 29 /**
29 * 提取 Markdown 文件中的 YAML 代码块 30 * 提取 Markdown 文件中的 YAML 代码块
...@@ -401,6 +402,164 @@ function scanAndGenerate(openAPIDir, outputDir) { ...@@ -401,6 +402,164 @@ function scanAndGenerate(openAPIDir, outputDir) {
401 }); 402 });
402 403
403 console.log('\n✅ API 文档生成完成!'); 404 console.log('\n✅ API 文档生成完成!');
405 +
406 + // 对比新旧 API
407 + console.log('\n🔍 开始检测 API 变更...\n');
408 + compareAPIChanges(openAPIDir);
409 +}
410 +
411 +/**
412 + * 备份 OpenAPI 文档目录
413 + * @param {string} sourceDir - 源目录
414 + * @returns {string} - 备份目录路径
415 + */
416 +function backupOpenAPIDir(sourceDir) {
417 + const backupBaseDir = path.resolve(__dirname, '../.tmp');
418 + const backupDir = path.join(backupBaseDir, 'openAPI-backup');
419 +
420 + // 创建备份目录
421 + if (!fs.existsSync(backupBaseDir)) {
422 + fs.mkdirSync(backupBaseDir, { recursive: true });
423 + }
424 +
425 + // 删除旧备份
426 + if (fs.existsSync(backupDir)) {
427 + fs.rmSync(backupDir, { recursive: true, force: true });
428 + }
429 +
430 + // 复制目录
431 + copyDirectory(sourceDir, backupDir);
432 +
433 + return backupDir;
434 +}
435 +
436 +/**
437 + * 递归复制目录
438 + * @param {string} src - 源路径
439 + * @param {string} dest - 目标路径
440 + */
441 +function copyDirectory(src, dest) {
442 + if (!fs.existsSync(dest)) {
443 + fs.mkdirSync(dest, { recursive: true });
444 + }
445 +
446 + const entries = fs.readdirSync(src, { withFileTypes: true });
447 +
448 + for (const entry of entries) {
449 + const srcPath = path.join(src, entry.name);
450 + const destPath = path.join(dest, entry.name);
451 +
452 + if (entry.isDirectory()) {
453 + copyDirectory(srcPath, destPath);
454 + } else {
455 + fs.copyFileSync(srcPath, destPath);
456 + }
457 + }
458 +}
459 +
460 +/**
461 + * 对比新旧 API 变更
462 + * @param {string} openAPIDir - OpenAPI 文档目录
463 + */
464 +function compareAPIChanges(openAPIDir) {
465 + const backupDir = path.resolve(__dirname, '../.tmp/openAPI-backup');
466 + const tempDir = path.resolve(__dirname, '../.tmp/openAPI-temp');
467 +
468 + // 检查是否存在临时备份(上一次的版本)
469 + if (!fs.existsSync(tempDir)) {
470 + console.log('ℹ️ 首次运行,已建立基线。下次运行将检测 API 变更。');
471 + // 将当前备份移动到临时目录,作为下次对比的基线
472 + if (fs.existsSync(backupDir)) {
473 + fs.renameSync(backupDir, tempDir);
474 + }
475 + return;
476 + }
477 +
478 + // 扫描模块
479 + const modules = fs.readdirSync(openAPIDir, { withFileTypes: true })
480 + .filter(dirent => dirent.isDirectory())
481 + .map(dirent => dirent.name);
482 +
483 + let hasChanges = false;
484 + const moduleReports = [];
485 +
486 + modules.forEach((moduleName) => {
487 + const moduleDir = path.join(openAPIDir, moduleName);
488 + const tempModuleDir = path.join(tempDir, moduleName);
489 +
490 + // 如果临时备份中不存在该模块,说明是新增模块
491 + if (!fs.existsSync(tempModuleDir)) {
492 + console.log(`📦 新增模块: ${moduleName}`);
493 + hasChanges = true;
494 + return;
495 + }
496 +
497 + // 读取当前和临时备份的文档
498 + const currentFiles = fs.readdirSync(moduleDir).filter(f => f.endsWith('.md'));
499 + const tempFiles = fs.readdirSync(tempModuleDir).filter(f => f.endsWith('.md'));
500 +
501 + // 检查是否有文件变更
502 + const hasNewFiles = currentFiles.some(f => !tempFiles.includes(f));
503 + const hasRemovedFiles = tempFiles.some(f => !currentFiles.includes(f));
504 + const hasModifiedFiles = currentFiles.some(f => {
505 + if (!tempFiles.includes(f)) return false;
506 + const currentContent = fs.readFileSync(path.join(moduleDir, f), 'utf8');
507 + const tempContent = fs.readFileSync(path.join(tempModuleDir, f), 'utf8');
508 + return currentContent !== tempContent;
509 + });
510 +
511 + if (hasNewFiles || hasRemovedFiles || hasModifiedFiles) {
512 + hasChanges = true;
513 + moduleReports.push({ moduleName, moduleDir, tempModuleDir });
514 + }
515 + });
516 +
517 + // 检查删除的模块
518 + const tempModules = fs.existsSync(tempDir)
519 + ? fs.readdirSync(tempDir, { withFileTypes: true })
520 + .filter(dirent => dirent.isDirectory())
521 + .map(dirent => dirent.name)
522 + : [];
523 +
524 + const deletedModules = tempModules.filter(m => !modules.includes(m));
525 + if (deletedModules.length > 0) {
526 + hasChanges = true;
527 + console.log(`\n❌ 删除模块: ${deletedModules.join(', ')}`);
528 + }
529 +
530 + if (!hasChanges) {
531 + console.log('✅ 未检测到 API 变更');
532 + // 更新基线
533 + if (fs.existsSync(backupDir)) {
534 + fs.rmSync(tempDir, { recursive: true, force: true });
535 + fs.renameSync(backupDir, tempDir);
536 + }
537 + return;
538 + }
539 +
540 + // 逐个模块对比
541 + console.log('');
542 + moduleReports.forEach(({ moduleName, moduleDir, tempModuleDir }) => {
543 + try {
544 + const oldDocs = parseOpenAPIPath(tempModuleDir);
545 + const newDocs = parseOpenAPIPath(moduleDir);
546 + const report = generateReport(oldDocs, newDocs, 'text');
547 +
548 + console.log(report);
549 + console.log('');
550 + } catch (error) {
551 + console.error(`⚠️ 模块 ${moduleName} 对比失败: ${error.message}`);
552 + }
553 + });
554 +
555 + // 更新基线:将当前备份作为下次对比的基准
556 + console.log('📝 更新 API 基线...');
557 + if (fs.existsSync(tempDir)) {
558 + fs.rmSync(tempDir, { recursive: true, force: true });
559 + }
560 + if (fs.existsSync(backupDir)) {
561 + fs.renameSync(backupDir, tempDir);
562 + }
404 } 563 }
405 564
406 // 执行生成 565 // 执行生成
...@@ -411,4 +570,11 @@ console.log('=== OpenAPI 转 API 文档生成器 ===\n'); ...@@ -411,4 +570,11 @@ console.log('=== OpenAPI 转 API 文档生成器 ===\n');
411 console.log(`输入目录: ${openAPIDir}`); 570 console.log(`输入目录: ${openAPIDir}`);
412 console.log(`输出目录: ${outputDir}\n`); 571 console.log(`输出目录: ${outputDir}\n`);
413 572
573 +// 备份当前的 OpenAPI 文档(用于下次对比)
574 +if (fs.existsSync(openAPIDir)) {
575 + console.log('💾 备份当前 OpenAPI 文档...');
576 + backupOpenAPIDir(openAPIDir);
577 + console.log('');
578 +}
579 +
414 scanAndGenerate(openAPIDir, outputDir); 580 scanAndGenerate(openAPIDir, outputDir);
......
...@@ -11,7 +11,6 @@ const Api = { ...@@ -11,7 +11,6 @@ const Api = {
11 * @param {string} params.name (可选) 姓名 11 * @param {string} params.name (可选) 姓名
12 * @param {string} params.avatar (可选) 头像 12 * @param {string} params.avatar (可选) 头像
13 * @param {string} params.mobile (可选) 手机号 13 * @param {string} params.mobile (可选) 手机号
14 - * @param {string} params.sms_code (可选) 短信验证码
15 * @param {string} params.idcard (可选) 身份证 14 * @param {string} params.idcard (可选) 身份证
16 * @returns {Promise<{ 15 * @returns {Promise<{
17 * code: number; // 状态码 16 * code: number; // 状态码
...@@ -24,6 +23,7 @@ export const editUserInfoAPI = (params) => fn(fetch.post(Api.EditUserInfo, param ...@@ -24,6 +23,7 @@ export const editUserInfoAPI = (params) => fn(fetch.post(Api.EditUserInfo, param
24 /** 23 /**
25 * @description: 查询我的信息 24 * @description: 查询我的信息
26 * @param {Object} params 请求参数 25 * @param {Object} params 请求参数
26 + * @param {boolean} params.include_profile (可选) 是否包含完整资料信息
27 * @returns {Promise<{ 27 * @returns {Promise<{
28 * code: number; // 状态码 28 * code: number; // 状态码
29 * msg: string; // 消息 29 * msg: string; // 消息
......