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>
# API Diff Skill
对比两个版本的 OpenAPI 文档或生成的 API 文件,检测接口变更。
## 使用场景
1. **更新 API 后自动检查**:运行 `api:generate` 后自动对比新旧接口
2. **手动对比**:对比两个不同的 OpenAPI 文档
3. **CI/CD 集成**:在部署前检查破坏性 API 变更
## 如何调用
### 在生成 API 后自动对比
```bash
pnpm run api:generate
```
生成器会自动调用对比逻辑,检查是否有接口变更。
### 手动对比两个文档
```bash
# 对比两个 OpenAPI markdown 文档
node scripts/apiDiff.js docs/openAPI/user/api1.md docs/openAPI/user/api1-new.md
# 对比整个模块目录
node scripts/apiDiff.js docs/openAPI/user/ docs/openAPI/user-new/
# 对比生成的 API 文件
node scripts/apiDiff.js src/api/user.js src/api/user-new.js
```
## 对比维度
1. **接口增删**:新增或删除的接口
2. **参数变更**
- 新增必填参数(破坏性变更)
- 删除参数(破坏性变更)
- 参数类型变更(破坏性变更)
- 新增可选参数(非破坏性)
3. **返回值变更**
- 返回结构变更
- 字段类型变更
4. **HTTP 方法变更**:GET ↔ POST(破坏性变更)
## 输出格式
对比结果会以以下格式输出:
```
=== API 变更检测报告 ===
📦 模块: user
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
✅ 新增接口 (1):
+ getUserProfile
⚠️ 修改接口 (2):
↪ editUserInfo
✗ [破坏性] 删除必填参数: sms_code
✓ [非破坏性] 新增可选参数: avatar
↪ getUserInfo
✓ [非破坏性] 新增可选参数: include_profile
❌ 删除接口 (1):
- deleteUserAccount
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
总计: 1 新增, 2 修改, 1 删除
⚠️ 检测到 1 个破坏性变更,请仔细检查业务逻辑!
```
## 退出码
- `0`: 无变更或仅有非破坏性变更
- `1`: 检测到破坏性变更(可用于 CI/CD 失败)
## 配置选项
可以通过环境变量配置:
```bash
# 严格模式:任何变更都返回失败码
API_DIFF_STRICT=true node scripts/apiDiff.js ...
# 输出 JSON 格式(用于程序解析)
API_DIFF_FORMAT=json node scripts/apiDiff.js ...
```
## 注意事项
1. 对比逻辑基于 OpenAPI 规范,确保文档格式正确
2. 破坏性变更需要在业务代码中做兼容处理
3. 新增接口通常不需要修改现有代码
4. 删除接口前请确认没有地方在使用
......@@ -12,3 +12,4 @@ pnpm-debug.log*
.env.*.local
unpackage/
.history/
.tmp/
......
# API 变更检测工具使用指南
## 概述
API 变更检测工具是 OpenAPI 转 API 文档生成器的增强功能,可以自动检测接口变更并识别破坏性变更,帮助开发者在更新接口时及时发现潜在问题。
## 工作原理
1. **基线建立**:首次运行时自动建立 API 基线
2. **变更检测**:后续运行时对比当前版本与基线版本
3. **分类标记**:自动区分破坏性变更和非破坏性变更
4. **自动更新**:检测完成后自动更新基线
## 使用方式
### 自动检测(推荐)
每次运行 API 生成器时自动检测变更:
```bash
node scripts/generateApiFromOpenAPI.js
```
输出示例:
```
=== OpenAPI 转 API 文档生成器 ===
输入目录: /Users/huyirui/program/itomix/git/manulife-weapp/docs/openAPI
输出目录: /Users/huyirui/program/itomix/git/manulife-weapp/src/api
💾 备份当前 OpenAPI 文档...
找到 2 个模块: order, user
处理模块: order
找到 2 个 API 文档
✓ getDetail: 获取订单详情
✓ getList: 获取订单列表
📝 生成文件: /Users/huyirui/program/itomix/git/manulife-weapp/src/api/order.js
处理模块: user
找到 2 个 API 文档
✓ editUserInfo: 修改我的信息
✓ getUserInfo: 查询我的信息
📝 生成文件: /Users/huyirui/program/itomix/git/manulife-weapp/src/api/user.js
✅ API 文档生成完成!
🔍 开始检测 API 变更...
=== API 变更检测报告 ===
📦 对比范围: 2 个旧接口 → 2 个新接口
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
⚠️ 修改接口 (1):
↪ getUserInfo - 查询我的信息
✓ [非破坏性] 新增可选 query 参数: include_profile
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
总计: 0 新增, 1 修改, 0 删除
✅ 未检测到破坏性变更
📝 更新 API 基线...
```
### 手动检测
可以独立运行对比脚本:
```bash
# 对比两个 OpenAPI 文档
node scripts/apiDiff.js docs/openAPI/user/getUserInfo.md docs/openAPI/user/getUserInfo-new.md
# 对比两个模块目录
node scripts/apiDiff.js docs/openAPI/user/ docs/openAPI/user-new/
```
## 变更类型说明
### 非破坏性变更(✓)
这些变更不会影响现有代码:
- **新增可选参数**:客户端可以不传,不影响现有调用
- **删除可选参数**:服务端不再接收,但客户端不传也没问题
- **必填参数变可选**:限制放宽,兼容性提升
- **返回值新增字段**:客户端可以选择性使用
### 破坏性变更(✗)
这些变更需要检查业务逻辑:
- **新增必填参数**:现有调用会缺少参数
- **删除必填参数**:现有调用可能传了被删除的参数
- **可选参数变必填**:现有调用可能不传该参数
- **参数类型变更**:可能导致类型错误
- **HTTP 方法变更**:GET ↔ POST 会导致调用失败
- **删除接口**:所有调用该接口的地方需要修改
## 输出格式
### 文本格式(默认)
```
=== API 变更检测报告 ===
📦 对比范围: 2 个旧接口 → 2 个新接口
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
✅ 新增接口 (1):
+ getUserProfile - 获取用户详细资料
⚠️ 修改接口 (2):
↪ editUserInfo - 修改我的信息
✗ [破坏性] 删除必填 body 参数: sms_code
✓ [非破坏性] 新增可选 body 参数: avatar
↪ getUserInfo - 查询我的信息
✓ [非破坏性] 新增可选 query 参数: include_profile
❌ 删除接口 (1):
- deleteUserAccount - 删除用户账号
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
总计: 1 新增, 2 修改, 1 删除
⚠️ 检测到 1 个破坏性变更,请仔细检查业务逻辑!
```
### JSON 格式
设置环境变量输出 JSON 格式(便于程序解析):
```bash
API_DIFF_FORMAT=json node scripts/apiDiff.js <oldPath> <newPath>
```
输出示例:
```json
{
"summary": {
"added": 1,
"modified": 2,
"removed": 1,
"breakingChanges": 1
},
"added": [
{
"name": "getUserProfile",
"summary": "获取用户详细资料",
"method": "GET",
"path": "/srv/"
}
],
"modified": [
{
"name": "editUserInfo",
"summary": "修改我的信息",
"breakingChanges": [
"删除必填 body 参数: sms_code"
],
"nonBreakingChanges": [
"新增可选 body 参数: avatar"
]
}
],
"removed": [
{
"name": "deleteUserAccount",
"summary": "删除用户账号",
"method": "POST",
"path": "/srv/"
}
]
}
```
## CI/CD 集成
### 严格模式
任何变更都返回失败码:
```bash
API_DIFF_STRICT=true node scripts/apiDiff.js <oldPath> <newPath>
```
### 检查退出码
```bash
node scripts/apiDiff.js <oldPath> <newPath>
EXIT_CODE=$?
if [ $EXIT_CODE -eq 1 ]; then
echo "检测到破坏性变更或启用严格模式"
exit 1
fi
```
退出码:
- `0`: 无破坏性变更
- `1`: 检测到破坏性变更或严格模式下有任何变更
## 配置选项
| 环境变量 | 说明 | 默认值 |
|---------|------|--------|
| `API_DIFF_FORMAT` | 输出格式 (text/json) | text |
| `API_DIFF_STRICT` | 严格模式 (true/false) | false |
## 存储位置
- **基线文件**`.tmp/openAPI-temp/`
- **临时备份**`.tmp/openAPI-backup/`
这些目录已添加到 `.gitignore`,不会被提交到 git。
## 常见问题
### Q: 首次运行显示"首次运行,已建立基线"?
A: 这是正常的。首次运行时会建立 API 基线,从第二次开始才会检测变更。
### Q: 如何重置基线?
A: 删除 `.tmp` 目录即可:
```bash
rm -rf .tmp
```
### Q: 检测到破坏性变更怎么办?
A:
1. 仔细检查变更内容
2. 确认是否真的需要这个变更
3. 如果需要,搜索所有调用该接口的代码并更新
4. 如果是第三方接口变更,联系后端确认
### Q: 可以对比生成的 JS 文件吗?
A: 当前版本不支持对比生成的 JS 文件,请对比 OpenAPI 文档。
### Q: 为什么有些参数没有检测到变更?
A: 工具会自动过滤 `a``f` 参数(业务标识符),专注于业务参数。
## 最佳实践
1. **每次更新接口后运行检测**:及时发现问题
2. **仔细阅读破坏性变更报告**:不要忽略警告
3. **在 CI/CD 中集成**:自动化检测流程
4. **定期审查非破坏性变更**:确保接口设计合理
5. **保留接口变更历史**:便于追溯问题
## 技术实现
### 核心文件
- `scripts/apiDiff.js` - 核心对比逻辑
- `scripts/generateApiFromOpenAPI.js` - 集成调用
- `.claude/custom_skills/api-diff/skill.md` - Skill 文档
### 对比维度
1. **接口级别**:新增、删除、修改
2. **参数级别**:名称、类型、是否必填
3. **HTTP 方法**:GET ↔ POST
4. **返回值**:结构、字段类型
### 检测逻辑
- 基于参数名称和类型对比
- 智能识别必填/可选参数变化
- 自动分类破坏性/非破坏性变更
- 支持模块级别和接口级别对比
## 贡献
如果发现问题或有改进建议,欢迎提交 Issue 或 Pull Request。
This diff is collapsed. Click to expand it.
......@@ -24,6 +24,7 @@
const fs = require('fs');
const path = require('path');
const yaml = require('js-yaml');
const { generateReport, parseOpenAPIPath } = require('./apiDiff');
/**
* 提取 Markdown 文件中的 YAML 代码块
......@@ -401,6 +402,164 @@ function scanAndGenerate(openAPIDir, outputDir) {
});
console.log('\n✅ API 文档生成完成!');
// 对比新旧 API
console.log('\n🔍 开始检测 API 变更...\n');
compareAPIChanges(openAPIDir);
}
/**
* 备份 OpenAPI 文档目录
* @param {string} sourceDir - 源目录
* @returns {string} - 备份目录路径
*/
function backupOpenAPIDir(sourceDir) {
const backupBaseDir = path.resolve(__dirname, '../.tmp');
const backupDir = path.join(backupBaseDir, 'openAPI-backup');
// 创建备份目录
if (!fs.existsSync(backupBaseDir)) {
fs.mkdirSync(backupBaseDir, { recursive: true });
}
// 删除旧备份
if (fs.existsSync(backupDir)) {
fs.rmSync(backupDir, { recursive: true, force: true });
}
// 复制目录
copyDirectory(sourceDir, backupDir);
return backupDir;
}
/**
* 递归复制目录
* @param {string} src - 源路径
* @param {string} dest - 目标路径
*/
function copyDirectory(src, dest) {
if (!fs.existsSync(dest)) {
fs.mkdirSync(dest, { recursive: true });
}
const entries = fs.readdirSync(src, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
copyDirectory(srcPath, destPath);
} else {
fs.copyFileSync(srcPath, destPath);
}
}
}
/**
* 对比新旧 API 变更
* @param {string} openAPIDir - OpenAPI 文档目录
*/
function compareAPIChanges(openAPIDir) {
const backupDir = path.resolve(__dirname, '../.tmp/openAPI-backup');
const tempDir = path.resolve(__dirname, '../.tmp/openAPI-temp');
// 检查是否存在临时备份(上一次的版本)
if (!fs.existsSync(tempDir)) {
console.log('ℹ️ 首次运行,已建立基线。下次运行将检测 API 变更。');
// 将当前备份移动到临时目录,作为下次对比的基线
if (fs.existsSync(backupDir)) {
fs.renameSync(backupDir, tempDir);
}
return;
}
// 扫描模块
const modules = fs.readdirSync(openAPIDir, { withFileTypes: true })
.filter(dirent => dirent.isDirectory())
.map(dirent => dirent.name);
let hasChanges = false;
const moduleReports = [];
modules.forEach((moduleName) => {
const moduleDir = path.join(openAPIDir, moduleName);
const tempModuleDir = path.join(tempDir, moduleName);
// 如果临时备份中不存在该模块,说明是新增模块
if (!fs.existsSync(tempModuleDir)) {
console.log(`📦 新增模块: ${moduleName}`);
hasChanges = true;
return;
}
// 读取当前和临时备份的文档
const currentFiles = fs.readdirSync(moduleDir).filter(f => f.endsWith('.md'));
const tempFiles = fs.readdirSync(tempModuleDir).filter(f => f.endsWith('.md'));
// 检查是否有文件变更
const hasNewFiles = currentFiles.some(f => !tempFiles.includes(f));
const hasRemovedFiles = tempFiles.some(f => !currentFiles.includes(f));
const hasModifiedFiles = currentFiles.some(f => {
if (!tempFiles.includes(f)) return false;
const currentContent = fs.readFileSync(path.join(moduleDir, f), 'utf8');
const tempContent = fs.readFileSync(path.join(tempModuleDir, f), 'utf8');
return currentContent !== tempContent;
});
if (hasNewFiles || hasRemovedFiles || hasModifiedFiles) {
hasChanges = true;
moduleReports.push({ moduleName, moduleDir, tempModuleDir });
}
});
// 检查删除的模块
const tempModules = fs.existsSync(tempDir)
? fs.readdirSync(tempDir, { withFileTypes: true })
.filter(dirent => dirent.isDirectory())
.map(dirent => dirent.name)
: [];
const deletedModules = tempModules.filter(m => !modules.includes(m));
if (deletedModules.length > 0) {
hasChanges = true;
console.log(`\n❌ 删除模块: ${deletedModules.join(', ')}`);
}
if (!hasChanges) {
console.log('✅ 未检测到 API 变更');
// 更新基线
if (fs.existsSync(backupDir)) {
fs.rmSync(tempDir, { recursive: true, force: true });
fs.renameSync(backupDir, tempDir);
}
return;
}
// 逐个模块对比
console.log('');
moduleReports.forEach(({ moduleName, moduleDir, tempModuleDir }) => {
try {
const oldDocs = parseOpenAPIPath(tempModuleDir);
const newDocs = parseOpenAPIPath(moduleDir);
const report = generateReport(oldDocs, newDocs, 'text');
console.log(report);
console.log('');
} catch (error) {
console.error(`⚠️ 模块 ${moduleName} 对比失败: ${error.message}`);
}
});
// 更新基线:将当前备份作为下次对比的基准
console.log('📝 更新 API 基线...');
if (fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
if (fs.existsSync(backupDir)) {
fs.renameSync(backupDir, tempDir);
}
}
// 执行生成
......@@ -411,4 +570,11 @@ console.log('=== OpenAPI 转 API 文档生成器 ===\n');
console.log(`输入目录: ${openAPIDir}`);
console.log(`输出目录: ${outputDir}\n`);
// 备份当前的 OpenAPI 文档(用于下次对比)
if (fs.existsSync(openAPIDir)) {
console.log('💾 备份当前 OpenAPI 文档...');
backupOpenAPIDir(openAPIDir);
console.log('');
}
scanAndGenerate(openAPIDir, outputDir);
......
......@@ -11,7 +11,6 @@ const Api = {
* @param {string} params.name (可选) 姓名
* @param {string} params.avatar (可选) 头像
* @param {string} params.mobile (可选) 手机号
* @param {string} params.sms_code (可选) 短信验证码
* @param {string} params.idcard (可选) 身份证
* @returns {Promise<{
* code: number; // 状态码
......@@ -24,6 +23,7 @@ export const editUserInfoAPI = (params) => fn(fetch.post(Api.EditUserInfo, param
/**
* @description: 查询我的信息
* @param {Object} params 请求参数
* @param {boolean} params.include_profile (可选) 是否包含完整资料信息
* @returns {Promise<{
* code: number; // 状态码
* msg: string; // 消息
......