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>
Showing
6 changed files
with
970 additions
and
1 deletions
.claude/custom_skills/api-diff/skill.md
0 → 100644
| 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. 删除接口前请确认没有地方在使用 |
docs/API_DIFF_GUIDE.md
0 → 100644
| 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。 |
scripts/apiDiff.js
0 → 100644
| 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; // 消息 | ... | ... |
-
Please register or login to post a comment