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。
/**
* API 对比工具
*
* 功能:
* 1. 对比两个 OpenAPI 文档的差异
* 2. 检测破坏性变更
* 3. 生成详细的变更报告
*
* 使用方式:
* node scripts/apiDiff.js <oldPath> <newPath>
*/
const fs = require('fs');
const path = require('path');
const yaml = require('js-yaml');
/**
* 从 Markdown 文件中提取 YAML
*/
function extractYAMLFromMarkdown(content) {
const yamlRegex = /```yaml\s*\n([\s\S]*?)\n```/;
const match = content.match(yamlRegex);
return match ? match[1] : null;
}
/**
* 解析 OpenAPI 文档(支持 .md 和目录)
*/
function parseOpenAPIPath(filePath) {
const stat = fs.statSync(filePath);
if (stat.isFile()) {
// 单个文件
if (filePath.endsWith('.md')) {
const content = fs.readFileSync(filePath, 'utf8');
const yamlContent = extractYAMLFromMarkdown(content);
if (!yamlContent) {
throw new Error(`文件 ${filePath} 中未找到 YAML 代码块`);
}
return [yaml.load(yamlContent)];
} else if (filePath.endsWith('.js')) {
// TODO: 支持对比生成的 JS 文件(需要解析 AST)
throw new Error('暂不支持对比生成的 JS 文件,请对比 OpenAPI 文档');
} else {
throw new Error(`不支持的文件类型: ${filePath}`);
}
} else if (stat.isDirectory()) {
// 目录,读取所有 .md 文件
const files = fs.readdirSync(filePath).filter(f => f.endsWith('.md'));
const docs = [];
files.forEach(file => {
const fullPath = path.join(filePath, file);
const content = fs.readFileSync(fullPath, 'utf8');
const yamlContent = extractYAMLFromMarkdown(content);
if (yamlContent) {
const doc = yaml.load(yamlContent);
// 保存文件名用于标识
doc._fileName = path.basename(file, '.md');
docs.push(doc);
}
});
return docs;
}
}
/**
* 从 OpenAPI 文档提取 API 信息
*/
function extractAPIInfo(openapiDoc) {
const path = Object.keys(openapiDoc.paths)[0];
const method = Object.keys(openapiDoc.paths[path])[0];
const apiInfo = openapiDoc.paths[path][method];
// 提取参数
const queryParams = (apiInfo.parameters || [])
.filter(p => p.in === 'query' && p.name !== 'a' && p.name !== 'f')
.map(p => ({
name: p.name,
type: p.schema?.type || 'any',
required: p.required || false,
description: p.description || '',
}));
// 提取 body 参数
const bodyParams = [];
if (apiInfo.requestBody && apiInfo.requestBody.content) {
const content = apiInfo.requestBody.content['application/x-www-form-urlencoded'] ||
apiInfo.requestBody.content['application/json'];
if (content && content.schema && content.schema.properties) {
Object.entries(content.schema.properties).forEach(([key, value]) => {
if (key !== 'a' && key !== 'f') {
bodyParams.push({
name: key,
type: value.type || 'any',
required: content.schema.required?.includes(key) || false,
description: value.description || '',
});
}
});
}
}
// 提取响应结构
const responseSchema = apiInfo.responses?.['200']?.content?.['application/json']?.schema;
return {
name: openapiDoc._fileName || 'unknown',
path,
method: method.toUpperCase(),
queryParams: new Set(queryParams.map(p => p.name)),
bodyParams: new Set(bodyParams.map(p => p.name)),
requiredQueryParams: new Set(queryParams.filter(p => p.required).map(p => p.name)),
requiredBodyParams: new Set(bodyParams.filter(p => p.required).map(p => p.name)),
allQueryParams: queryParams,
allBodyParams: bodyParams,
responseSchema,
summary: apiInfo.summary || '',
};
}
/**
* 对比两个 API 信息
*/
function compareAPI(oldAPI, newAPI) {
const changes = {
breaking: [],
nonBreaking: [],
};
// 检查 HTTP 方法变更
if (oldAPI.method !== newAPI.method) {
changes.breaking.push(`HTTP 方法变更: ${oldAPI.method}${newAPI.method}`);
}
// 检查 GET 参数变更
oldAPI.allQueryParams.forEach(oldParam => {
const newParam = newAPI.allQueryParams.find(p => p.name === oldParam.name);
if (!newParam) {
// 参数被删除
if (oldAPI.requiredQueryParams.has(oldParam.name)) {
changes.breaking.push(`删除必填 query 参数: ${oldParam.name}`);
} else {
changes.nonBreaking.push(`删除可选 query 参数: ${oldParam.name}`);
}
} else {
// 参数类型变更
if (oldParam.type !== newParam.type) {
changes.breaking.push(`query 参数类型变更: ${oldParam.name} (${oldParam.type}${newParam.type})`);
}
// 可选 → 必填
if (!oldParam.required && newParam.required) {
changes.breaking.push(`query 参数变为必填: ${newParam.name}`);
}
// 必填 → 可选
if (oldParam.required && !newParam.required) {
changes.nonBreaking.push(`query 参数变为可选: ${newParam.name}`);
}
}
});
// 检查新增 GET 参数
newAPI.allQueryParams.forEach(newParam => {
const oldParam = oldAPI.allQueryParams.find(p => p.name === newParam.name);
if (!oldParam) {
if (newParam.required) {
changes.breaking.push(`新增必填 query 参数: ${newParam.name}`);
} else {
changes.nonBreaking.push(`新增可选 query 参数: ${newParam.name}`);
}
}
});
// 检查 POST body 参数变更
oldAPI.allBodyParams.forEach(oldParam => {
const newParam = newAPI.allBodyParams.find(p => p.name === oldParam.name);
if (!newParam) {
// 参数被删除
if (oldAPI.requiredBodyParams.has(oldParam.name)) {
changes.breaking.push(`删除必填 body 参数: ${oldParam.name}`);
} else {
changes.nonBreaking.push(`删除可选 body 参数: ${oldParam.name}`);
}
} else {
// 参数类型变更
if (oldParam.type !== newParam.type) {
changes.breaking.push(`body 参数类型变更: ${oldParam.name} (${oldParam.type}${newParam.type})`);
}
// 可选 → 必填
if (!oldParam.required && newParam.required) {
changes.breaking.push(`body 参数变为必填: ${newParam.name}`);
}
// 必填 → 可选
if (oldParam.required && !newParam.required) {
changes.nonBreaking.push(`body 参数变为可选: ${newParam.name}`);
}
}
});
// 检查新增 body 参数
newAPI.allBodyParams.forEach(newParam => {
const oldParam = oldAPI.allBodyParams.find(p => p.name === newParam.name);
if (!oldParam) {
if (newParam.required) {
changes.breaking.push(`新增必填 body 参数: ${newParam.name}`);
} else {
changes.nonBreaking.push(`新增可选 body 参数: ${newParam.name}`);
}
}
});
return changes;
}
/**
* 生成变更报告
*/
function generateReport(oldDocs, newDocs, format = 'text') {
const oldAPIs = oldDocs.map(extractAPIInfo);
const newAPIs = newDocs.map(extractAPIInfo);
const oldAPIsMap = new Map(oldAPIs.map(api => [api.name, api]));
const newAPIsMap = new Map(newAPIs.map(api => [api.name, api]));
const addedAPIs = [];
const removedAPIs = [];
const modifiedAPIs = [];
// 检测新增接口
newAPIs.forEach(api => {
if (!oldAPIsMap.has(api.name)) {
addedAPIs.push(api);
}
});
// 检测删除接口
oldAPIs.forEach(api => {
if (!newAPIsMap.has(api.name)) {
removedAPIs.push(api);
}
});
// 检测修改接口
newAPIs.forEach(api => {
const oldAPI = oldAPIsMap.get(api.name);
if (oldAPI) {
const changes = compareAPI(oldAPI, api);
if (changes.breaking.length > 0 || changes.nonBreaking.length > 0) {
modifiedAPIs.push({
name: api.name,
summary: api.summary,
changes,
});
}
}
});
// 统计
const totalBreaking = modifiedAPIs.reduce(
(sum, api) => sum + api.changes.breaking.length,
0
);
// 生成文本报告
if (format === 'text') {
const lines = [];
lines.push('=== API 变更检测报告 ===\n');
lines.push(`📦 对比范围: ${oldAPIs.length} 个旧接口 → ${newAPIs.length} 个新接口`);
lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
if (addedAPIs.length > 0) {
lines.push(`✅ 新增接口 (${addedAPIs.length}):`);
addedAPIs.forEach(api => {
lines.push(` + ${api.name} - ${api.summary}`);
});
lines.push('');
}
if (modifiedAPIs.length > 0) {
lines.push(`⚠️ 修改接口 (${modifiedAPIs.length}):`);
modifiedAPIs.forEach(api => {
lines.push(` ↪ ${api.name} - ${api.summary}`);
api.changes.breaking.forEach(change => {
lines.push(` ✗ [破坏性] ${change}`);
});
api.changes.nonBreaking.forEach(change => {
lines.push(` ✓ [非破坏性] ${change}`);
});
});
lines.push('');
}
if (removedAPIs.length > 0) {
lines.push(`❌ 删除接口 (${removedAPIs.length}):`);
removedAPIs.forEach(api => {
lines.push(` - ${api.name} - ${api.summary}`);
});
lines.push('');
}
lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
lines.push(`总计: ${addedAPIs.length} 新增, ${modifiedAPIs.length} 修改, ${removedAPIs.length} 删除`);
if (totalBreaking > 0) {
lines.push(`⚠️ 检测到 ${totalBreaking} 个破坏性变更,请仔细检查业务逻辑!`);
} else if (addedAPIs.length > 0 || modifiedAPIs.length > 0 || removedAPIs.length > 0) {
lines.push('✅ 未检测到破坏性变更');
} else {
lines.push('✅ 无接口变更');
}
return lines.join('\n');
}
// 生成 JSON 报告
if (format === 'json') {
return JSON.stringify({
summary: {
added: addedAPIs.length,
modified: modifiedAPIs.length,
removed: removedAPIs.length,
breakingChanges: totalBreaking,
},
added: addedAPIs.map(api => ({
name: api.name,
summary: api.summary,
method: api.method,
path: api.path,
})),
modified: modifiedAPIs.map(api => ({
name: api.name,
summary: api.summary,
breakingChanges: api.changes.breaking,
nonBreakingChanges: api.changes.nonBreaking,
})),
removed: removedAPIs.map(api => ({
name: api.name,
summary: api.summary,
method: api.method,
path: api.path,
})),
}, null, 2);
}
}
/**
* 主函数
*/
function main() {
const args = process.argv.slice(2);
if (args.length < 2) {
console.error('用法: node scripts/apiDiff.js <oldPath> <newPath>');
console.error('示例:');
console.error(' node scripts/apiDiff.js docs/openAPI/user/ docs/openAPI/user-new/');
console.error(' node scripts/apiDiff.js docs/openAPI/user/api1.md docs/openAPI/user/api1-new.md');
process.exit(1);
}
const [oldPath, newPath] = args;
if (!fs.existsSync(oldPath)) {
console.error(`❌ 旧路径不存在: ${oldPath}`);
process.exit(1);
}
if (!fs.existsSync(newPath)) {
console.error(`❌ 新路径不存在: ${newPath}`);
process.exit(1);
}
try {
const oldDocs = parseOpenAPIPath(oldPath);
const newDocs = parseOpenAPIPath(newPath);
const format = process.env.API_DIFF_FORMAT || 'text';
const report = generateReport(oldDocs, newDocs, format);
console.log(report);
// 如果有破坏性变更,返回退出码 1
const oldAPIs = oldDocs.map(extractAPIInfo);
const newAPIs = newDocs.map(extractAPIInfo);
const oldAPIsMap = new Map(oldAPIs.map(api => [api.name, api]));
const newAPIsMap = new Map(newAPIs.map(api => [api.name, api]));
let totalBreaking = 0;
newAPIs.forEach(api => {
const oldAPI = oldAPIsMap.get(api.name);
if (oldAPI) {
const changes = compareAPI(oldAPI, api);
totalBreaking += changes.breaking.length;
}
});
// 严格模式:任何变更都返回 1
const strictMode = process.env.API_DIFF_STRICT === 'true';
const hasChanges = oldAPIs.length !== newAPIs.length ||
newAPIs.some(api => !oldAPIsMap.has(api.name)) ||
oldAPIs.some(api => !newAPIsMap.has(api.name));
if (totalBreaking > 0 || (strictMode && hasChanges)) {
process.exit(1);
} else {
process.exit(0);
}
} catch (error) {
console.error(`❌ 对比失败: ${error.message}`);
process.exit(1);
}
}
// 如果直接运行此脚本
if (require.main === module) {
main();
}
module.exports = {
compareAPI,
generateReport,
parseOpenAPIPath,
extractAPIInfo,
};
......@@ -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; // 消息
......