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。
1 +/**
2 + * API 对比工具
3 + *
4 + * 功能:
5 + * 1. 对比两个 OpenAPI 文档的差异
6 + * 2. 检测破坏性变更
7 + * 3. 生成详细的变更报告
8 + *
9 + * 使用方式:
10 + * node scripts/apiDiff.js <oldPath> <newPath>
11 + */
12 +
13 +const fs = require('fs');
14 +const path = require('path');
15 +const yaml = require('js-yaml');
16 +
17 +/**
18 + * 从 Markdown 文件中提取 YAML
19 + */
20 +function extractYAMLFromMarkdown(content) {
21 + const yamlRegex = /```yaml\s*\n([\s\S]*?)\n```/;
22 + const match = content.match(yamlRegex);
23 + return match ? match[1] : null;
24 +}
25 +
26 +/**
27 + * 解析 OpenAPI 文档(支持 .md 和目录)
28 + */
29 +function parseOpenAPIPath(filePath) {
30 + const stat = fs.statSync(filePath);
31 +
32 + if (stat.isFile()) {
33 + // 单个文件
34 + if (filePath.endsWith('.md')) {
35 + const content = fs.readFileSync(filePath, 'utf8');
36 + const yamlContent = extractYAMLFromMarkdown(content);
37 + if (!yamlContent) {
38 + throw new Error(`文件 ${filePath} 中未找到 YAML 代码块`);
39 + }
40 + return [yaml.load(yamlContent)];
41 + } else if (filePath.endsWith('.js')) {
42 + // TODO: 支持对比生成的 JS 文件(需要解析 AST)
43 + throw new Error('暂不支持对比生成的 JS 文件,请对比 OpenAPI 文档');
44 + } else {
45 + throw new Error(`不支持的文件类型: ${filePath}`);
46 + }
47 + } else if (stat.isDirectory()) {
48 + // 目录,读取所有 .md 文件
49 + const files = fs.readdirSync(filePath).filter(f => f.endsWith('.md'));
50 + const docs = [];
51 + files.forEach(file => {
52 + const fullPath = path.join(filePath, file);
53 + const content = fs.readFileSync(fullPath, 'utf8');
54 + const yamlContent = extractYAMLFromMarkdown(content);
55 + if (yamlContent) {
56 + const doc = yaml.load(yamlContent);
57 + // 保存文件名用于标识
58 + doc._fileName = path.basename(file, '.md');
59 + docs.push(doc);
60 + }
61 + });
62 + return docs;
63 + }
64 +}
65 +
66 +/**
67 + * 从 OpenAPI 文档提取 API 信息
68 + */
69 +function extractAPIInfo(openapiDoc) {
70 + const path = Object.keys(openapiDoc.paths)[0];
71 + const method = Object.keys(openapiDoc.paths[path])[0];
72 + const apiInfo = openapiDoc.paths[path][method];
73 +
74 + // 提取参数
75 + const queryParams = (apiInfo.parameters || [])
76 + .filter(p => p.in === 'query' && p.name !== 'a' && p.name !== 'f')
77 + .map(p => ({
78 + name: p.name,
79 + type: p.schema?.type || 'any',
80 + required: p.required || false,
81 + description: p.description || '',
82 + }));
83 +
84 + // 提取 body 参数
85 + const bodyParams = [];
86 + if (apiInfo.requestBody && apiInfo.requestBody.content) {
87 + const content = apiInfo.requestBody.content['application/x-www-form-urlencoded'] ||
88 + apiInfo.requestBody.content['application/json'];
89 + if (content && content.schema && content.schema.properties) {
90 + Object.entries(content.schema.properties).forEach(([key, value]) => {
91 + if (key !== 'a' && key !== 'f') {
92 + bodyParams.push({
93 + name: key,
94 + type: value.type || 'any',
95 + required: content.schema.required?.includes(key) || false,
96 + description: value.description || '',
97 + });
98 + }
99 + });
100 + }
101 + }
102 +
103 + // 提取响应结构
104 + const responseSchema = apiInfo.responses?.['200']?.content?.['application/json']?.schema;
105 +
106 + return {
107 + name: openapiDoc._fileName || 'unknown',
108 + path,
109 + method: method.toUpperCase(),
110 + queryParams: new Set(queryParams.map(p => p.name)),
111 + bodyParams: new Set(bodyParams.map(p => p.name)),
112 + requiredQueryParams: new Set(queryParams.filter(p => p.required).map(p => p.name)),
113 + requiredBodyParams: new Set(bodyParams.filter(p => p.required).map(p => p.name)),
114 + allQueryParams: queryParams,
115 + allBodyParams: bodyParams,
116 + responseSchema,
117 + summary: apiInfo.summary || '',
118 + };
119 +}
120 +
121 +/**
122 + * 对比两个 API 信息
123 + */
124 +function compareAPI(oldAPI, newAPI) {
125 + const changes = {
126 + breaking: [],
127 + nonBreaking: [],
128 + };
129 +
130 + // 检查 HTTP 方法变更
131 + if (oldAPI.method !== newAPI.method) {
132 + changes.breaking.push(`HTTP 方法变更: ${oldAPI.method}${newAPI.method}`);
133 + }
134 +
135 + // 检查 GET 参数变更
136 + oldAPI.allQueryParams.forEach(oldParam => {
137 + const newParam = newAPI.allQueryParams.find(p => p.name === oldParam.name);
138 +
139 + if (!newParam) {
140 + // 参数被删除
141 + if (oldAPI.requiredQueryParams.has(oldParam.name)) {
142 + changes.breaking.push(`删除必填 query 参数: ${oldParam.name}`);
143 + } else {
144 + changes.nonBreaking.push(`删除可选 query 参数: ${oldParam.name}`);
145 + }
146 + } else {
147 + // 参数类型变更
148 + if (oldParam.type !== newParam.type) {
149 + changes.breaking.push(`query 参数类型变更: ${oldParam.name} (${oldParam.type}${newParam.type})`);
150 + }
151 + // 可选 → 必填
152 + if (!oldParam.required && newParam.required) {
153 + changes.breaking.push(`query 参数变为必填: ${newParam.name}`);
154 + }
155 + // 必填 → 可选
156 + if (oldParam.required && !newParam.required) {
157 + changes.nonBreaking.push(`query 参数变为可选: ${newParam.name}`);
158 + }
159 + }
160 + });
161 +
162 + // 检查新增 GET 参数
163 + newAPI.allQueryParams.forEach(newParam => {
164 + const oldParam = oldAPI.allQueryParams.find(p => p.name === newParam.name);
165 + if (!oldParam) {
166 + if (newParam.required) {
167 + changes.breaking.push(`新增必填 query 参数: ${newParam.name}`);
168 + } else {
169 + changes.nonBreaking.push(`新增可选 query 参数: ${newParam.name}`);
170 + }
171 + }
172 + });
173 +
174 + // 检查 POST body 参数变更
175 + oldAPI.allBodyParams.forEach(oldParam => {
176 + const newParam = newAPI.allBodyParams.find(p => p.name === oldParam.name);
177 +
178 + if (!newParam) {
179 + // 参数被删除
180 + if (oldAPI.requiredBodyParams.has(oldParam.name)) {
181 + changes.breaking.push(`删除必填 body 参数: ${oldParam.name}`);
182 + } else {
183 + changes.nonBreaking.push(`删除可选 body 参数: ${oldParam.name}`);
184 + }
185 + } else {
186 + // 参数类型变更
187 + if (oldParam.type !== newParam.type) {
188 + changes.breaking.push(`body 参数类型变更: ${oldParam.name} (${oldParam.type}${newParam.type})`);
189 + }
190 + // 可选 → 必填
191 + if (!oldParam.required && newParam.required) {
192 + changes.breaking.push(`body 参数变为必填: ${newParam.name}`);
193 + }
194 + // 必填 → 可选
195 + if (oldParam.required && !newParam.required) {
196 + changes.nonBreaking.push(`body 参数变为可选: ${newParam.name}`);
197 + }
198 + }
199 + });
200 +
201 + // 检查新增 body 参数
202 + newAPI.allBodyParams.forEach(newParam => {
203 + const oldParam = oldAPI.allBodyParams.find(p => p.name === newParam.name);
204 + if (!oldParam) {
205 + if (newParam.required) {
206 + changes.breaking.push(`新增必填 body 参数: ${newParam.name}`);
207 + } else {
208 + changes.nonBreaking.push(`新增可选 body 参数: ${newParam.name}`);
209 + }
210 + }
211 + });
212 +
213 + return changes;
214 +}
215 +
216 +/**
217 + * 生成变更报告
218 + */
219 +function generateReport(oldDocs, newDocs, format = 'text') {
220 + const oldAPIs = oldDocs.map(extractAPIInfo);
221 + const newAPIs = newDocs.map(extractAPIInfo);
222 +
223 + const oldAPIsMap = new Map(oldAPIs.map(api => [api.name, api]));
224 + const newAPIsMap = new Map(newAPIs.map(api => [api.name, api]));
225 +
226 + const addedAPIs = [];
227 + const removedAPIs = [];
228 + const modifiedAPIs = [];
229 +
230 + // 检测新增接口
231 + newAPIs.forEach(api => {
232 + if (!oldAPIsMap.has(api.name)) {
233 + addedAPIs.push(api);
234 + }
235 + });
236 +
237 + // 检测删除接口
238 + oldAPIs.forEach(api => {
239 + if (!newAPIsMap.has(api.name)) {
240 + removedAPIs.push(api);
241 + }
242 + });
243 +
244 + // 检测修改接口
245 + newAPIs.forEach(api => {
246 + const oldAPI = oldAPIsMap.get(api.name);
247 + if (oldAPI) {
248 + const changes = compareAPI(oldAPI, api);
249 + if (changes.breaking.length > 0 || changes.nonBreaking.length > 0) {
250 + modifiedAPIs.push({
251 + name: api.name,
252 + summary: api.summary,
253 + changes,
254 + });
255 + }
256 + }
257 + });
258 +
259 + // 统计
260 + const totalBreaking = modifiedAPIs.reduce(
261 + (sum, api) => sum + api.changes.breaking.length,
262 + 0
263 + );
264 +
265 + // 生成文本报告
266 + if (format === 'text') {
267 + const lines = [];
268 + lines.push('=== API 变更检测报告 ===\n');
269 + lines.push(`📦 对比范围: ${oldAPIs.length} 个旧接口 → ${newAPIs.length} 个新接口`);
270 + lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
271 +
272 + if (addedAPIs.length > 0) {
273 + lines.push(`✅ 新增接口 (${addedAPIs.length}):`);
274 + addedAPIs.forEach(api => {
275 + lines.push(` + ${api.name} - ${api.summary}`);
276 + });
277 + lines.push('');
278 + }
279 +
280 + if (modifiedAPIs.length > 0) {
281 + lines.push(`⚠️ 修改接口 (${modifiedAPIs.length}):`);
282 + modifiedAPIs.forEach(api => {
283 + lines.push(` ↪ ${api.name} - ${api.summary}`);
284 + api.changes.breaking.forEach(change => {
285 + lines.push(` ✗ [破坏性] ${change}`);
286 + });
287 + api.changes.nonBreaking.forEach(change => {
288 + lines.push(` ✓ [非破坏性] ${change}`);
289 + });
290 + });
291 + lines.push('');
292 + }
293 +
294 + if (removedAPIs.length > 0) {
295 + lines.push(`❌ 删除接口 (${removedAPIs.length}):`);
296 + removedAPIs.forEach(api => {
297 + lines.push(` - ${api.name} - ${api.summary}`);
298 + });
299 + lines.push('');
300 + }
301 +
302 + lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
303 + lines.push(`总计: ${addedAPIs.length} 新增, ${modifiedAPIs.length} 修改, ${removedAPIs.length} 删除`);
304 +
305 + if (totalBreaking > 0) {
306 + lines.push(`⚠️ 检测到 ${totalBreaking} 个破坏性变更,请仔细检查业务逻辑!`);
307 + } else if (addedAPIs.length > 0 || modifiedAPIs.length > 0 || removedAPIs.length > 0) {
308 + lines.push('✅ 未检测到破坏性变更');
309 + } else {
310 + lines.push('✅ 无接口变更');
311 + }
312 +
313 + return lines.join('\n');
314 + }
315 +
316 + // 生成 JSON 报告
317 + if (format === 'json') {
318 + return JSON.stringify({
319 + summary: {
320 + added: addedAPIs.length,
321 + modified: modifiedAPIs.length,
322 + removed: removedAPIs.length,
323 + breakingChanges: totalBreaking,
324 + },
325 + added: addedAPIs.map(api => ({
326 + name: api.name,
327 + summary: api.summary,
328 + method: api.method,
329 + path: api.path,
330 + })),
331 + modified: modifiedAPIs.map(api => ({
332 + name: api.name,
333 + summary: api.summary,
334 + breakingChanges: api.changes.breaking,
335 + nonBreakingChanges: api.changes.nonBreaking,
336 + })),
337 + removed: removedAPIs.map(api => ({
338 + name: api.name,
339 + summary: api.summary,
340 + method: api.method,
341 + path: api.path,
342 + })),
343 + }, null, 2);
344 + }
345 +}
346 +
347 +/**
348 + * 主函数
349 + */
350 +function main() {
351 + const args = process.argv.slice(2);
352 +
353 + if (args.length < 2) {
354 + console.error('用法: node scripts/apiDiff.js <oldPath> <newPath>');
355 + console.error('示例:');
356 + console.error(' node scripts/apiDiff.js docs/openAPI/user/ docs/openAPI/user-new/');
357 + console.error(' node scripts/apiDiff.js docs/openAPI/user/api1.md docs/openAPI/user/api1-new.md');
358 + process.exit(1);
359 + }
360 +
361 + const [oldPath, newPath] = args;
362 +
363 + if (!fs.existsSync(oldPath)) {
364 + console.error(`❌ 旧路径不存在: ${oldPath}`);
365 + process.exit(1);
366 + }
367 +
368 + if (!fs.existsSync(newPath)) {
369 + console.error(`❌ 新路径不存在: ${newPath}`);
370 + process.exit(1);
371 + }
372 +
373 + try {
374 + const oldDocs = parseOpenAPIPath(oldPath);
375 + const newDocs = parseOpenAPIPath(newPath);
376 +
377 + const format = process.env.API_DIFF_FORMAT || 'text';
378 + const report = generateReport(oldDocs, newDocs, format);
379 +
380 + console.log(report);
381 +
382 + // 如果有破坏性变更,返回退出码 1
383 + const oldAPIs = oldDocs.map(extractAPIInfo);
384 + const newAPIs = newDocs.map(extractAPIInfo);
385 + const oldAPIsMap = new Map(oldAPIs.map(api => [api.name, api]));
386 + const newAPIsMap = new Map(newAPIs.map(api => [api.name, api]));
387 +
388 + let totalBreaking = 0;
389 + newAPIs.forEach(api => {
390 + const oldAPI = oldAPIsMap.get(api.name);
391 + if (oldAPI) {
392 + const changes = compareAPI(oldAPI, api);
393 + totalBreaking += changes.breaking.length;
394 + }
395 + });
396 +
397 + // 严格模式:任何变更都返回 1
398 + const strictMode = process.env.API_DIFF_STRICT === 'true';
399 + const hasChanges = oldAPIs.length !== newAPIs.length ||
400 + newAPIs.some(api => !oldAPIsMap.has(api.name)) ||
401 + oldAPIs.some(api => !newAPIsMap.has(api.name));
402 +
403 + if (totalBreaking > 0 || (strictMode && hasChanges)) {
404 + process.exit(1);
405 + } else {
406 + process.exit(0);
407 + }
408 + } catch (error) {
409 + console.error(`❌ 对比失败: ${error.message}`);
410 + process.exit(1);
411 + }
412 +}
413 +
414 +// 如果直接运行此脚本
415 +if (require.main === module) {
416 + main();
417 +}
418 +
419 +module.exports = {
420 + compareAPI,
421 + generateReport,
422 + parseOpenAPIPath,
423 + extractAPIInfo,
424 +};
...@@ -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; // 消息
......