feat: 添加 API 生成脚本并更新打卡接口
- 新增 OpenAPI 转 API 生成脚本 (scripts/) - 更新打卡 API 端点 (map → map_activity) - 添加 .gitignore 规则忽略 CLAUDE.md 和 .claude/ - 新增 npm run api:generate 命令 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Showing
6 changed files
with
1284 additions
and
4 deletions
| ... | @@ -28,7 +28,8 @@ | ... | @@ -28,7 +28,8 @@ |
| 28 | "dev_upload": "npm run build_tar && npm run scp-dev && npm run dec-dev && npm run remove_tar && npm run remove_dist", | 28 | "dev_upload": "npm run build_tar && npm run scp-dev && npm run dec-dev && npm run remove_tar && npm run remove_dist", |
| 29 | "oa_upload": "npm run build_tar && npm run scp-oa && npm run dec-oa && npm run remove_tar", | 29 | "oa_upload": "npm run build_tar && npm run scp-oa && npm run dec-oa && npm run remove_tar", |
| 30 | "xys_upload": "npm run build_tar && npm run scp-xys && npm run dec-xys && npm run remove_tar", | 30 | "xys_upload": "npm run build_tar && npm run scp-xys && npm run dec-xys && npm run remove_tar", |
| 31 | - "walk_upload": "npm run build_tar && npm run scp-walk && npm run dec-walk && npm run remove_tar" | 31 | + "walk_upload": "npm run build_tar && npm run scp-walk && npm run dec-walk && npm run remove_tar", |
| 32 | + "api:generate": "node scripts/generateApiFromOpenAPI.js" | ||
| 32 | }, | 33 | }, |
| 33 | "dependencies": { | 34 | "dependencies": { |
| 34 | "@amap/amap-jsapi-loader": "^1.0.1", | 35 | "@amap/amap-jsapi-loader": "^1.0.1", | ... | ... |
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/api-specs/user/ docs/api-specs/user-new/'); | ||
| 357 | + console.error(' node scripts/apiDiff.js docs/api-specs/user/api1.md docs/api-specs/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 | +}; |
scripts/generateApiFromOpenAPI.js
0 → 100644
| 1 | +/** | ||
| 2 | + * 从 OpenAPI 文档自动生成 API 接口文件 | ||
| 3 | + * | ||
| 4 | + * 功能: | ||
| 5 | + * 1. 扫描 docs/api-specs 目录 | ||
| 6 | + * 2. 解析每个 .md 文件中的 OpenAPI YAML 规范 | ||
| 7 | + * 3. 提取 API 信息并生成对应的 JavaScript API 文件 | ||
| 8 | + * 4. 保存到 src/api/ 目录 | ||
| 9 | + * | ||
| 10 | + * 目录结构: | ||
| 11 | + * docs/api-specs/ | ||
| 12 | + * ├── module1/ | ||
| 13 | + * │ ├── api1.md | ||
| 14 | + * │ └── api2.md | ||
| 15 | + * └── module2/ | ||
| 16 | + * └── api3.md | ||
| 17 | + * | ||
| 18 | + * 生成到: | ||
| 19 | + * src/api/ | ||
| 20 | + * ├── module1.js | ||
| 21 | + * └── module2.js | ||
| 22 | + */ | ||
| 23 | + | ||
| 24 | +const fs = require('fs'); | ||
| 25 | +const path = require('path'); | ||
| 26 | +const yaml = require('js-yaml'); | ||
| 27 | +const { generateReport, parseOpenAPIPath } = require('./apiDiff'); | ||
| 28 | + | ||
| 29 | +/** | ||
| 30 | + * 提取 Markdown 文件中的 YAML 代码块 | ||
| 31 | + * @param {string} content - Markdown 文件内容 | ||
| 32 | + * @returns {string|null} - YAML 字符串或 null | ||
| 33 | + */ | ||
| 34 | +function extractYAMLFromMarkdown(content) { | ||
| 35 | + const yamlRegex = /```yaml\s*\n([\s\S]*?)\n```/; | ||
| 36 | + const match = content.match(yamlRegex); | ||
| 37 | + return match ? match[1] : null; | ||
| 38 | +} | ||
| 39 | + | ||
| 40 | +/** | ||
| 41 | + * 将字符串转换为驼峰命名 | ||
| 42 | + * @param {string} str - 输入字符串 | ||
| 43 | + * @returns {string} - 驼峰命名字符串 | ||
| 44 | + */ | ||
| 45 | +function toCamelCase(str) { | ||
| 46 | + return str | ||
| 47 | + .replace(/[-_\s]+(.)?/g, (_, c) => (c ? c.toUpperCase() : '')) | ||
| 48 | + .replace(/^(.)/, (c) => c.toLowerCase()); | ||
| 49 | +} | ||
| 50 | + | ||
| 51 | +/** | ||
| 52 | + * 将字符串转换为帕斯卡命名(首字母大写) | ||
| 53 | + * @param {string} str - 输入字符串 | ||
| 54 | + * @returns {string} - 帕斯卡命名字符串 | ||
| 55 | + */ | ||
| 56 | +function toPascalCase(str) { | ||
| 57 | + const camelCase = toCamelCase(str); | ||
| 58 | + return camelCase.charAt(0).toUpperCase() + camelCase.slice(1); | ||
| 59 | +} | ||
| 60 | + | ||
| 61 | +/** | ||
| 62 | + * 简单的英文到中文翻译 | ||
| 63 | + * @param {string} text - 英文文本 | ||
| 64 | + * @returns {string} - 中文文本 | ||
| 65 | + */ | ||
| 66 | +function translateToChinese(text) { | ||
| 67 | + if (!text) return ''; | ||
| 68 | + | ||
| 69 | + // 常见技术术语翻译字典(优先级从高到低) | ||
| 70 | + const dictionary = { | ||
| 71 | + // 短语和完整句子 | ||
| 72 | + 'mini program authorization': '小程序授权', | ||
| 73 | + 'login flow design': '登录流程设计', | ||
| 74 | + | ||
| 75 | + // 短语 | ||
| 76 | + 'mini program': '小程序', | ||
| 77 | + 'mini-program': '小程序', | ||
| 78 | + 'wechat': '微信', | ||
| 79 | + 'weixin': '微信', | ||
| 80 | + 'openid': 'OpenID', | ||
| 81 | + | ||
| 82 | + // 动词和常用词 | ||
| 83 | + 'authorize': '授权', | ||
| 84 | + 'authorization': '授权', | ||
| 85 | + 'login': '登录', | ||
| 86 | + 'logout': '登出', | ||
| 87 | + 'register': '注册', | ||
| 88 | + 'call': '调用', | ||
| 89 | + 'return': '返回', | ||
| 90 | + 'using': '使用', | ||
| 91 | + 'bound to': '绑定到', | ||
| 92 | + 'according to': '根据', | ||
| 93 | + | ||
| 94 | + // 名词 | ||
| 95 | + 'user': '用户', | ||
| 96 | + 'code': '授权码', | ||
| 97 | + 'token': '令牌', | ||
| 98 | + 'session': '会话', | ||
| 99 | + 'request': '请求', | ||
| 100 | + 'response': '响应', | ||
| 101 | + 'interface': '接口', | ||
| 102 | + 'api': '接口', | ||
| 103 | + 'account': '账号', | ||
| 104 | + 'openid': 'OpenID', | ||
| 105 | + | ||
| 106 | + // 描述性词汇 | ||
| 107 | + 'first': '首先', | ||
| 108 | + 'then': '然后', | ||
| 109 | + 'if': '如果', | ||
| 110 | + 'else': '否则', | ||
| 111 | + 'need': '需要', | ||
| 112 | + 'should': '应该', | ||
| 113 | + 'must': '必须', | ||
| 114 | + | ||
| 115 | + // 状态 | ||
| 116 | + 'success': '成功', | ||
| 117 | + 'fail': '失败', | ||
| 118 | + 'error': '错误', | ||
| 119 | + 'empty': '空', | ||
| 120 | + 'non-empty': '非空', | ||
| 121 | + | ||
| 122 | + // 属性 | ||
| 123 | + 'avatar': '头像', | ||
| 124 | + 'name': '姓名', | ||
| 125 | + 'id': 'ID', | ||
| 126 | + 'info': '信息', | ||
| 127 | + 'data': '数据', | ||
| 128 | + 'flow': '流程', | ||
| 129 | + 'design': '设计', | ||
| 130 | + | ||
| 131 | + // 其他 | ||
| 132 | + 'internal': '内部', | ||
| 133 | + 'automatically': '自动', | ||
| 134 | + 'specify': '指定', | ||
| 135 | + 'testing': '测试', | ||
| 136 | + 'used for': '用于', | ||
| 137 | + 'bound with': '绑定', | ||
| 138 | + }; | ||
| 139 | + | ||
| 140 | + let translated = text; | ||
| 141 | + | ||
| 142 | + // 按照字典进行替换(优先匹配长词) | ||
| 143 | + const sortedKeys = Object.keys(dictionary).sort((a, b) => b.length - a.length); | ||
| 144 | + sortedKeys.forEach(key => { | ||
| 145 | + const regex = new RegExp(key, 'gi'); | ||
| 146 | + translated = translated.replace(regex, dictionary[key]); | ||
| 147 | + }); | ||
| 148 | + | ||
| 149 | + return translated; | ||
| 150 | +} | ||
| 151 | + | ||
| 152 | +/** | ||
| 153 | + * 格式化描述文本(翻译+格式化) | ||
| 154 | + * @param {string} description - 原始描述 | ||
| 155 | + * @returns {string} - 格式化后的描述 | ||
| 156 | + */ | ||
| 157 | +function formatDescription(description) { | ||
| 158 | + if (!description) return ''; | ||
| 159 | + | ||
| 160 | + // 移除 markdown 格式符号(如 # 标题) | ||
| 161 | + let formatted = description | ||
| 162 | + .replace(/^#+\s*/gm, '') // 移除标题符号 | ||
| 163 | + .replace(/\*\*(.*?)\*\*/g, '$1') // 移除加粗 | ||
| 164 | + .replace(/\*(.*?)\*/g, '$1') // 移除斜体 | ||
| 165 | + .replace(/`([^`]+)`/g, '$1') // 移除行内代码 | ||
| 166 | + .trim(); | ||
| 167 | + | ||
| 168 | + // 先进行整句翻译(常见句式) | ||
| 169 | + formatted = translateSentences(formatted); | ||
| 170 | + | ||
| 171 | + return formatted; | ||
| 172 | +} | ||
| 173 | + | ||
| 174 | +/** | ||
| 175 | + * 翻译常见句式 | ||
| 176 | + * @param {string} text - 文本 | ||
| 177 | + * @returns {string} - 翻译后的文本 | ||
| 178 | + */ | ||
| 179 | +function translateSentences(text) { | ||
| 180 | + if (!text) return ''; | ||
| 181 | + | ||
| 182 | + // 常见句式翻译(按优先级排序,长的先匹配) | ||
| 183 | + const sentences = { | ||
| 184 | + // 完整句子 | ||
| 185 | + '# 登录流程设计': '# 登录流程设计', | ||
| 186 | + '# Login Flow Design': '# 登录流程设计', | ||
| 187 | + | ||
| 188 | + // 常见句式 | ||
| 189 | + 'Authorize mini program first': '先进行小程序授权', | ||
| 190 | + 'If user is empty, call login API': '如果返回 user 为空,则需要调用登录接口', | ||
| 191 | + 'If user is not empty, no need to call login API': '如果返回 user 非空,则不需要调用登录接口', | ||
| 192 | + 'the authorization API will automatically login using the account bound to openid': '授权接口内部按照 openid 绑定的账号,自动登录', | ||
| 193 | + 'Specify an openid for testing': '指定一个 openid 用来测试', | ||
| 194 | + 'User information bound to openid': 'openid 绑定的用户信息', | ||
| 195 | + '0=fail, 1=success': '0=失败,1=成功', | ||
| 196 | + }; | ||
| 197 | + | ||
| 198 | + let translated = text; | ||
| 199 | + | ||
| 200 | + // 按长度排序(长句优先) | ||
| 201 | + const sortedKeys = Object.keys(sentences).sort((a, b) => b.length - a.length); | ||
| 202 | + sortedKeys.forEach(key => { | ||
| 203 | + const regex = new RegExp(escapeRegExp(key), 'gi'); | ||
| 204 | + translated = translated.replace(regex, sentences[key]); | ||
| 205 | + }); | ||
| 206 | + | ||
| 207 | + // 最后进行单词级别的补充翻译 | ||
| 208 | + translated = translateWords(translated); | ||
| 209 | + | ||
| 210 | + return translated; | ||
| 211 | +} | ||
| 212 | + | ||
| 213 | +/** | ||
| 214 | + * 转义正则表达式特殊字符 | ||
| 215 | + * @param {string} string - 字符串 | ||
| 216 | + * @returns {string} - 转义后的字符串 | ||
| 217 | + */ | ||
| 218 | +function escapeRegExp(string) { | ||
| 219 | + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); | ||
| 220 | +} | ||
| 221 | + | ||
| 222 | +/** | ||
| 223 | + * 单词级别的翻译(补充) | ||
| 224 | + * @param {string} text - 文本 | ||
| 225 | + * @returns {string} - 翻译后的文本 | ||
| 226 | + */ | ||
| 227 | +function translateWords(text) { | ||
| 228 | + const dictionary = { | ||
| 229 | + 'mini program': '小程序', | ||
| 230 | + 'wechat': '微信', | ||
| 231 | + 'openid': 'OpenID', | ||
| 232 | + 'user': '用户', | ||
| 233 | + 'authorization': '授权', | ||
| 234 | + 'login': '登录', | ||
| 235 | + 'avatar': '头像', | ||
| 236 | + 'name': '姓名', | ||
| 237 | + }; | ||
| 238 | + | ||
| 239 | + let translated = text; | ||
| 240 | + Object.entries(dictionary).forEach(([key, value]) => { | ||
| 241 | + const regex = new RegExp(key, 'gi'); | ||
| 242 | + translated = translated.replace(regex, value); | ||
| 243 | + }); | ||
| 244 | + | ||
| 245 | + return translated; | ||
| 246 | +} | ||
| 247 | + | ||
| 248 | +/** | ||
| 249 | + * 解析对象属性,生成字段描述 | ||
| 250 | + * @param {object} properties - 属性对象 | ||
| 251 | + * @param {number} indent - 缩进级别 | ||
| 252 | + * @returns {string} - 字段描述字符串 | ||
| 253 | + */ | ||
| 254 | +function parseProperties(properties, indent = 0) { | ||
| 255 | + if (!properties) return ''; | ||
| 256 | + | ||
| 257 | + const lines = []; | ||
| 258 | + const prefix = ' '.repeat(indent); | ||
| 259 | + | ||
| 260 | + Object.entries(properties).forEach(([key, value]) => { | ||
| 261 | + const type = value.type || 'any'; | ||
| 262 | + const desc = value.description || value.title || ''; | ||
| 263 | + const required = value.required ? '' : ' (可选)'; | ||
| 264 | + | ||
| 265 | + // 基本类型 | ||
| 266 | + if (type !== 'object' && type !== 'array') { | ||
| 267 | + lines.push(`${prefix}${key}: ${type}${required} - ${desc}`); | ||
| 268 | + } | ||
| 269 | + // 对象类型 | ||
| 270 | + else if (type === 'object' && value.properties) { | ||
| 271 | + lines.push(`${prefix}${key}: {`); | ||
| 272 | + lines.push(`${prefix} // ${desc}`); | ||
| 273 | + lines.push(parseProperties(value.properties, indent + 2)); | ||
| 274 | + lines.push(prefix + '}'); | ||
| 275 | + } | ||
| 276 | + // 数组类型 | ||
| 277 | + else if (type === 'array' && value.items) { | ||
| 278 | + const itemType = value.items.type || 'any'; | ||
| 279 | + if (itemType === 'object' && value.items.properties) { | ||
| 280 | + lines.push(`${prefix}${key}: Array<{`); | ||
| 281 | + lines.push(`${prefix} // ${desc}`); | ||
| 282 | + lines.push(parseProperties(value.items.properties, indent + 2)); | ||
| 283 | + lines.push(prefix + '}>'); | ||
| 284 | + } else { | ||
| 285 | + lines.push(`${prefix}${key}: Array<${itemType}>${required} - ${desc}`); | ||
| 286 | + } | ||
| 287 | + } | ||
| 288 | + }); | ||
| 289 | + | ||
| 290 | + return lines.join('\n'); | ||
| 291 | +} | ||
| 292 | + | ||
| 293 | +/** | ||
| 294 | + * 从 requestBody 中提取参数 | ||
| 295 | + * @param {object} requestBody - requestBody 对象 | ||
| 296 | + * @returns {Array} - 参数数组 | ||
| 297 | + */ | ||
| 298 | +function extractRequestParams(requestBody) { | ||
| 299 | + if (!requestBody || !requestBody.content) { | ||
| 300 | + return []; | ||
| 301 | + } | ||
| 302 | + | ||
| 303 | + // 获取内容类型(可能是 application/x-www-form-urlencoded 或 application/json) | ||
| 304 | + const content = requestBody.content['application/x-www-form-urlencoded'] || | ||
| 305 | + requestBody.content['application/json']; | ||
| 306 | + | ||
| 307 | + if (!content || !content.schema || !content.schema.properties) { | ||
| 308 | + return []; | ||
| 309 | + } | ||
| 310 | + | ||
| 311 | + const params = []; | ||
| 312 | + Object.entries(content.schema.properties).forEach(([key, value]) => { | ||
| 313 | + params.push({ | ||
| 314 | + name: key, | ||
| 315 | + type: value.type || 'any', | ||
| 316 | + description: value.description || '', | ||
| 317 | + example: value.example || '', | ||
| 318 | + required: content.schema.required?.includes(key) || false, | ||
| 319 | + }); | ||
| 320 | + }); | ||
| 321 | + | ||
| 322 | + return params; | ||
| 323 | +} | ||
| 324 | + | ||
| 325 | +/** | ||
| 326 | + * 生成 JSDoc 参数注释 | ||
| 327 | + * @param {Array} parameters - parameters 数组(GET 请求) | ||
| 328 | + * @param {Array} bodyParams - requestBody 参数数组(POST 请求) | ||
| 329 | + * @param {string} method - HTTP 方法 | ||
| 330 | + * @returns {string} - JSDoc 参数注释 | ||
| 331 | + */ | ||
| 332 | +function generateParamJSDoc(parameters, bodyParams, method) { | ||
| 333 | + const lines = [' * @param {Object} params 请求参数']; | ||
| 334 | + | ||
| 335 | + // POST 请求使用 body 参数 | ||
| 336 | + if (method === 'POST' && bodyParams && bodyParams.length > 0) { | ||
| 337 | + // 过滤掉 a、f 和 t 参数(这些参数已硬编码到 URL 中) | ||
| 338 | + const filteredParams = bodyParams.filter(p => p.name !== 'a' && p.name !== 'f' && p.name !== 't'); | ||
| 339 | + | ||
| 340 | + filteredParams.forEach((param) => { | ||
| 341 | + const type = param.type || 'any'; | ||
| 342 | + const desc = param.description || ''; | ||
| 343 | + const required = param.required ? '' : ' (可选)'; | ||
| 344 | + lines.push(` * @param {${type}} params.${param.name}${required} ${desc}`); | ||
| 345 | + }); | ||
| 346 | + } | ||
| 347 | + // GET 请求使用 query 参数 | ||
| 348 | + else if (method === 'GET' && parameters && parameters.length > 0) { | ||
| 349 | + // 只保留 query 参数,过滤 header 参数和 a、f、t 参数 | ||
| 350 | + const queryParams = parameters.filter(p => | ||
| 351 | + p.in === 'query' && | ||
| 352 | + p.name !== 'a' && | ||
| 353 | + p.name !== 'f' && | ||
| 354 | + p.name !== 't' | ||
| 355 | + ); | ||
| 356 | + | ||
| 357 | + queryParams.forEach((param) => { | ||
| 358 | + const type = param.schema?.type || 'any'; | ||
| 359 | + const desc = param.description || ''; | ||
| 360 | + const required = param.required ? '' : ' (可选)'; | ||
| 361 | + lines.push(` * @param {${type}} params.${param.name}${required} ${desc}`); | ||
| 362 | + }); | ||
| 363 | + } | ||
| 364 | + | ||
| 365 | + return lines.join('\n'); | ||
| 366 | +} | ||
| 367 | + | ||
| 368 | +/** | ||
| 369 | + * 递归生成属性字段的 JSDoc 注释 | ||
| 370 | + * @param {object} properties - 属性对象 | ||
| 371 | + * @param {number} indent - 缩进级别(空格数) | ||
| 372 | + * @returns {string} - JSDoc 注释 | ||
| 373 | + */ | ||
| 374 | +function generatePropertiesJSDoc(properties, indent = 0) { | ||
| 375 | + const lines = []; | ||
| 376 | + const prefix = ' '.repeat(indent); | ||
| 377 | + | ||
| 378 | + Object.entries(properties).forEach(([key, value]) => { | ||
| 379 | + const type = value.type || 'any'; | ||
| 380 | + const desc = value.description || value.title || ''; | ||
| 381 | + | ||
| 382 | + // 处理嵌套对象 | ||
| 383 | + if (type === 'object' && value.properties) { | ||
| 384 | + lines.push(`${prefix}${key}: {\n`); | ||
| 385 | + // 递归处理嵌套对象的属性 | ||
| 386 | + lines.push(generatePropertiesJSDoc(value.properties, indent + 2)); | ||
| 387 | + lines.push(`${prefix}};\n`); | ||
| 388 | + } | ||
| 389 | + // 处理数组(元素是对象) | ||
| 390 | + else if (type === 'array' && value.items && value.items.properties) { | ||
| 391 | + lines.push(`${prefix}${key}: Array<{\n`); | ||
| 392 | + // 递归处理数组元素的属性 | ||
| 393 | + lines.push(generatePropertiesJSDoc(value.items.properties, indent + 2)); | ||
| 394 | + lines.push(`${prefix}}>;\n`); | ||
| 395 | + } | ||
| 396 | + // 处理简单数组 | ||
| 397 | + else if (type === 'array' && value.items) { | ||
| 398 | + const itemType = value.items.type || 'any'; | ||
| 399 | + lines.push(`${prefix}${key}: Array<${itemType}>; // ${desc}\n`); | ||
| 400 | + } | ||
| 401 | + // 处理基本类型 | ||
| 402 | + else { | ||
| 403 | + lines.push(`${prefix}${key}: ${type}; // ${desc}\n`); | ||
| 404 | + } | ||
| 405 | + }); | ||
| 406 | + | ||
| 407 | + return lines.join(''); | ||
| 408 | +} | ||
| 409 | + | ||
| 410 | +/** | ||
| 411 | + * 生成 JSDoc 返回值注释 | ||
| 412 | + * @param {object} responseSchema - 响应 schema | ||
| 413 | + * @returns {string} - JSDoc 返回值注释 | ||
| 414 | + */ | ||
| 415 | +function generateReturnJSDoc(responseSchema) { | ||
| 416 | + if (!responseSchema || !responseSchema.properties) { | ||
| 417 | + return ' * @returns {Promise<{code:number,data:any,msg:string}>} 标准返回'; | ||
| 418 | + } | ||
| 419 | + | ||
| 420 | + const { code, msg, data } = responseSchema.properties; | ||
| 421 | + | ||
| 422 | + let returnDesc = ' * @returns {Promise<{\n'; | ||
| 423 | + returnDesc += ' * code: number; // 状态码\n'; | ||
| 424 | + returnDesc += ' * msg: string; // 消息\n'; | ||
| 425 | + | ||
| 426 | + if (data) { | ||
| 427 | + const dataType = data.type || 'any'; | ||
| 428 | + const dataDesc = data.description || data.title || ''; | ||
| 429 | + | ||
| 430 | + // 处理对象类型的 data | ||
| 431 | + if (dataType === 'object' && data.properties) { | ||
| 432 | + returnDesc += ' * data: {\n'; | ||
| 433 | + // 使用递归函数处理 data 的所有属性 | ||
| 434 | + returnDesc += generatePropertiesJSDoc(data.properties, 4); | ||
| 435 | + returnDesc += ' * };\n'; | ||
| 436 | + } | ||
| 437 | + // 处理数组类型的 data(元素是对象) | ||
| 438 | + else if (dataType === 'array' && data.items && data.items.properties) { | ||
| 439 | + returnDesc += ' * data: Array<{\n'; | ||
| 440 | + returnDesc += generatePropertiesJSDoc(data.items.properties, 4); | ||
| 441 | + returnDesc += ' * }>;\n'; | ||
| 442 | + } | ||
| 443 | + // 处理简单数组类型 | ||
| 444 | + else if (dataType === 'array' && data.items) { | ||
| 445 | + const itemType = data.items.type || 'any'; | ||
| 446 | + returnDesc += ` * data: Array<${itemType}>;\n`; | ||
| 447 | + } | ||
| 448 | + // 其他类型 | ||
| 449 | + else { | ||
| 450 | + returnDesc += ` * data: ${dataType};\n`; | ||
| 451 | + } | ||
| 452 | + } else { | ||
| 453 | + returnDesc += ' * data: any;\n'; | ||
| 454 | + } | ||
| 455 | + | ||
| 456 | + returnDesc += ' * }>}'; | ||
| 457 | + | ||
| 458 | + return returnDesc; | ||
| 459 | +} | ||
| 460 | + | ||
| 461 | +/** | ||
| 462 | + * 解析 OpenAPI 文档并提取 API 信息 | ||
| 463 | + * @param {object} openapiDoc - 解析后的 OpenAPI 对象 | ||
| 464 | + * @param {string} fileName - 文件名(用作 API 名称) | ||
| 465 | + * @returns {object} - 提取的 API 信息 | ||
| 466 | + */ | ||
| 467 | +function parseOpenAPIDocument(openapiDoc, fileName) { | ||
| 468 | + try { | ||
| 469 | + const path = Object.keys(openapiDoc.paths)[0]; | ||
| 470 | + const method = Object.keys(openapiDoc.paths[path])[0]; | ||
| 471 | + const apiInfo = openapiDoc.paths[path][method]; | ||
| 472 | + | ||
| 473 | + // 提取 query 参数 | ||
| 474 | + const parameters = apiInfo.parameters || []; | ||
| 475 | + const queryParams = {}; | ||
| 476 | + let actionValue = ''; | ||
| 477 | + let typeValue = ''; // t 参数 | ||
| 478 | + | ||
| 479 | + // 提取 body 参数(用于 POST 请求) | ||
| 480 | + const requestBody = apiInfo.requestBody; | ||
| 481 | + const bodyParams = extractRequestParams(requestBody); | ||
| 482 | + | ||
| 483 | + // 对于 POST 请求,从 requestBody 中提取 action 和 type | ||
| 484 | + if (requestBody && bodyParams.length > 0) { | ||
| 485 | + const actionParam = bodyParams.find(p => p.name === 'a'); | ||
| 486 | + if (actionParam) { | ||
| 487 | + actionValue = actionParam.example || ''; | ||
| 488 | + } | ||
| 489 | + const typeParam = bodyParams.find(p => p.name === 't'); | ||
| 490 | + if (typeParam) { | ||
| 491 | + typeValue = typeParam.example || ''; | ||
| 492 | + } | ||
| 493 | + } | ||
| 494 | + | ||
| 495 | + // 对于 GET 请求,从 query 参数中提取 action 和 type | ||
| 496 | + if (parameters.length > 0) { | ||
| 497 | + parameters.forEach((param) => { | ||
| 498 | + if (param.in === 'query') { | ||
| 499 | + queryParams[param.name] = param.example || param.schema?.default || ''; | ||
| 500 | + | ||
| 501 | + // 提取 action 参数(通常是 'a' 参数) | ||
| 502 | + if (param.name === 'a' && !actionValue) { | ||
| 503 | + actionValue = param.example || ''; | ||
| 504 | + } | ||
| 505 | + // 提取 type 参数('t' 参数) | ||
| 506 | + if (param.name === 't' && !typeValue) { | ||
| 507 | + typeValue = param.example || ''; | ||
| 508 | + } | ||
| 509 | + } | ||
| 510 | + }); | ||
| 511 | + } | ||
| 512 | + | ||
| 513 | + // 提取响应结构 | ||
| 514 | + const responseSchema = apiInfo.responses?.['200']?.content?.['application/json']?.schema; | ||
| 515 | + | ||
| 516 | + return { | ||
| 517 | + summary: apiInfo.summary || fileName, | ||
| 518 | + description: apiInfo.description || '', | ||
| 519 | + method: method.toUpperCase(), | ||
| 520 | + action: actionValue, | ||
| 521 | + type: typeValue, // 保存 t 参数 | ||
| 522 | + queryParams, | ||
| 523 | + parameters, // 保存完整的参数信息用于生成 JSDoc(GET 请求) | ||
| 524 | + bodyParams, // 保存 requestBody 参数用于生成 JSDoc(POST 请求) | ||
| 525 | + responseSchema, // 保存响应结构用于生成 JSDoc | ||
| 526 | + fileName, | ||
| 527 | + }; | ||
| 528 | + } catch (error) { | ||
| 529 | + console.error(`解析 OpenAPI 文档失败: ${error.message}`); | ||
| 530 | + return null; | ||
| 531 | + } | ||
| 532 | +} | ||
| 533 | + | ||
| 534 | +/** | ||
| 535 | + * 生成 API 文件内容 | ||
| 536 | + * @param {string} moduleName - 模块名称 | ||
| 537 | + * @param {Array} apis - API 信息数组 | ||
| 538 | + * @returns {string} - 生成的文件内容 | ||
| 539 | + */ | ||
| 540 | +function generateApiFileContent(moduleName, apis) { | ||
| 541 | + const imports = `import { fn, fetch } from '@/api/fn';\n\n`; | ||
| 542 | + const apiConstants = []; | ||
| 543 | + const apiFunctions = []; | ||
| 544 | + | ||
| 545 | + apis.forEach((api) => { | ||
| 546 | + // 生成常量名(帕斯卡命名) | ||
| 547 | + const constantName = toPascalCase(api.fileName); | ||
| 548 | + // 生成函数名(驼峰命名 + API 后缀) | ||
| 549 | + const functionName = toCamelCase(api.fileName) + 'API'; | ||
| 550 | + | ||
| 551 | + // 构建 URL,包含 a 和 t 参数 | ||
| 552 | + let url = '/srv/?'; | ||
| 553 | + const params = []; | ||
| 554 | + if (api.action) params.push(`a=${api.action}`); | ||
| 555 | + if (api.type) params.push(`t=${api.type}`); | ||
| 556 | + url += params.join('&'); | ||
| 557 | + | ||
| 558 | + // 添加常量定义 | ||
| 559 | + apiConstants.push( | ||
| 560 | + ` ${constantName}: '${url}',` | ||
| 561 | + ); | ||
| 562 | + | ||
| 563 | + // 生成详细的 JSDoc 注释 | ||
| 564 | + const paramJSDoc = generateParamJSDoc(api.parameters, api.bodyParams, api.method); | ||
| 565 | + const returnJSDoc = generateReturnJSDoc(api.responseSchema); | ||
| 566 | + | ||
| 567 | + // 添加函数定义 | ||
| 568 | + const fetchMethod = api.method === 'GET' ? 'fetch.get' : 'fetch.post'; | ||
| 569 | + | ||
| 570 | + // 格式化描述 | ||
| 571 | + const formattedDesc = formatDescription(api.description); | ||
| 572 | + | ||
| 573 | + // 生成 JSDoc 注释(包含描述) | ||
| 574 | + const comment = `/** | ||
| 575 | + * @description ${api.summary} | ||
| 576 | + * @remark ${formattedDesc} | ||
| 577 | +${paramJSDoc} | ||
| 578 | +${returnJSDoc} | ||
| 579 | + */`; | ||
| 580 | + | ||
| 581 | + apiFunctions.push(`${comment}\nexport const ${functionName} = (params) => fn(${fetchMethod}(Api.${constantName}, params));`); | ||
| 582 | + }); | ||
| 583 | + | ||
| 584 | + return `${imports}const Api = {\n${apiConstants.join('\n')}\n}\n\n${apiFunctions.join('\n\n')}\n`; | ||
| 585 | +} | ||
| 586 | + | ||
| 587 | +/** | ||
| 588 | + * 扫描目录并处理所有 OpenAPI 文档 | ||
| 589 | + * @param {string} openAPIDir - OpenAPI 文档目录 | ||
| 590 | + * @param {string} outputDir - 输出目录 | ||
| 591 | + */ | ||
| 592 | +function scanAndGenerate(openAPIDir, outputDir) { | ||
| 593 | + if (!fs.existsSync(openAPIDir)) { | ||
| 594 | + console.error(`OpenAPI 目录不存在: ${openAPIDir}`); | ||
| 595 | + return; | ||
| 596 | + } | ||
| 597 | + | ||
| 598 | + // 确保输出目录存在 | ||
| 599 | + if (!fs.existsSync(outputDir)) { | ||
| 600 | + fs.mkdirSync(outputDir, { recursive: true }); | ||
| 601 | + } | ||
| 602 | + | ||
| 603 | + // 扫描第一级目录(模块) | ||
| 604 | + const modules = fs.readdirSync(openAPIDir, { withFileTypes: true }) | ||
| 605 | + .filter(dirent => dirent.isDirectory()) | ||
| 606 | + .map(dirent => dirent.name); | ||
| 607 | + | ||
| 608 | + console.log(`找到 ${modules.length} 个模块: ${modules.join(', ')}`); | ||
| 609 | + | ||
| 610 | + modules.forEach((moduleName) => { | ||
| 611 | + const moduleDir = path.join(openAPIDir, moduleName); | ||
| 612 | + const apiFiles = fs.readdirSync(moduleDir) | ||
| 613 | + .filter(file => file.endsWith('.md') && file !== 'CLAUDE.md'); | ||
| 614 | + | ||
| 615 | + if (apiFiles.length === 0) { | ||
| 616 | + console.log(`模块 ${moduleName} 中没有找到 .md 文件`); | ||
| 617 | + return; | ||
| 618 | + } | ||
| 619 | + | ||
| 620 | + console.log(`\n处理模块: ${moduleName}`); | ||
| 621 | + console.log(`找到 ${apiFiles.length} 个 API 文档`); | ||
| 622 | + | ||
| 623 | + const apis = []; | ||
| 624 | + | ||
| 625 | + apiFiles.forEach((fileName) => { | ||
| 626 | + const filePath = path.join(moduleDir, fileName); | ||
| 627 | + const content = fs.readFileSync(filePath, 'utf8'); | ||
| 628 | + const yamlContent = extractYAMLFromMarkdown(content); | ||
| 629 | + | ||
| 630 | + if (!yamlContent) { | ||
| 631 | + console.warn(` ⚠️ ${fileName}: 未找到 YAML 代码块`); | ||
| 632 | + return; | ||
| 633 | + } | ||
| 634 | + | ||
| 635 | + try { | ||
| 636 | + const openapiDoc = yaml.load(yamlContent); | ||
| 637 | + const apiName = path.basename(fileName, '.md'); | ||
| 638 | + const apiInfo = parseOpenAPIDocument(openapiDoc, apiName); | ||
| 639 | + | ||
| 640 | + if (apiInfo) { | ||
| 641 | + apis.push(apiInfo); | ||
| 642 | + console.log(` ✓ ${apiName}: ${apiInfo.summary}`); | ||
| 643 | + } | ||
| 644 | + } catch (error) { | ||
| 645 | + console.error(` ✗ ${fileName}: 解析失败 - ${error.message}`); | ||
| 646 | + } | ||
| 647 | + }); | ||
| 648 | + | ||
| 649 | + // 生成并保存 API 文件 | ||
| 650 | + if (apis.length > 0) { | ||
| 651 | + const fileContent = generateApiFileContent(moduleName, apis); | ||
| 652 | + const outputPath = path.join(outputDir, `${moduleName}.js`); | ||
| 653 | + fs.writeFileSync(outputPath, fileContent, 'utf8'); | ||
| 654 | + console.log(` 📝 生成文件: ${outputPath}`); | ||
| 655 | + } | ||
| 656 | + }); | ||
| 657 | + | ||
| 658 | + console.log('\n✅ API 文档生成完成!'); | ||
| 659 | + | ||
| 660 | + // 对比新旧 API | ||
| 661 | + console.log('\n🔍 开始检测 API 变更...\n'); | ||
| 662 | + compareAPIChanges(openAPIDir); | ||
| 663 | +} | ||
| 664 | + | ||
| 665 | +/** | ||
| 666 | + * 备份 OpenAPI 文档目录 | ||
| 667 | + * @param {string} sourceDir - 源目录 | ||
| 668 | + * @returns {string} - 备份目录路径 | ||
| 669 | + */ | ||
| 670 | +function backupOpenAPIDir(sourceDir) { | ||
| 671 | + const backupBaseDir = path.resolve(__dirname, '../.tmp'); | ||
| 672 | + const backupDir = path.join(backupBaseDir, 'api-specs-backup'); | ||
| 673 | + | ||
| 674 | + // 创建备份目录 | ||
| 675 | + if (!fs.existsSync(backupBaseDir)) { | ||
| 676 | + fs.mkdirSync(backupBaseDir, { recursive: true }); | ||
| 677 | + } | ||
| 678 | + | ||
| 679 | + // 删除旧备份 | ||
| 680 | + if (fs.existsSync(backupDir)) { | ||
| 681 | + fs.rmSync(backupDir, { recursive: true, force: true }); | ||
| 682 | + } | ||
| 683 | + | ||
| 684 | + // 复制目录 | ||
| 685 | + copyDirectory(sourceDir, backupDir); | ||
| 686 | + | ||
| 687 | + return backupDir; | ||
| 688 | +} | ||
| 689 | + | ||
| 690 | +/** | ||
| 691 | + * 递归复制目录 | ||
| 692 | + * @param {string} src - 源路径 | ||
| 693 | + * @param {string} dest - 目标路径 | ||
| 694 | + */ | ||
| 695 | +function copyDirectory(src, dest) { | ||
| 696 | + if (!fs.existsSync(dest)) { | ||
| 697 | + fs.mkdirSync(dest, { recursive: true }); | ||
| 698 | + } | ||
| 699 | + | ||
| 700 | + const entries = fs.readdirSync(src, { withFileTypes: true }); | ||
| 701 | + | ||
| 702 | + for (const entry of entries) { | ||
| 703 | + const srcPath = path.join(src, entry.name); | ||
| 704 | + const destPath = path.join(dest, entry.name); | ||
| 705 | + | ||
| 706 | + if (entry.isDirectory()) { | ||
| 707 | + copyDirectory(srcPath, destPath); | ||
| 708 | + } else { | ||
| 709 | + fs.copyFileSync(srcPath, destPath); | ||
| 710 | + } | ||
| 711 | + } | ||
| 712 | +} | ||
| 713 | + | ||
| 714 | +/** | ||
| 715 | + * 对比新旧 API 变更 | ||
| 716 | + * @param {string} openAPIDir - OpenAPI 文档目录 | ||
| 717 | + */ | ||
| 718 | +function compareAPIChanges(openAPIDir) { | ||
| 719 | + const backupDir = path.resolve(__dirname, '../.tmp/api-specs-backup'); | ||
| 720 | + const tempDir = path.resolve(__dirname, '../.tmp/api-specs-temp'); | ||
| 721 | + | ||
| 722 | + // 检查是否存在临时备份(上一次的版本) | ||
| 723 | + if (!fs.existsSync(tempDir)) { | ||
| 724 | + console.log('ℹ️ 首次运行,已建立基线。下次运行将检测 API 变更。'); | ||
| 725 | + // 将当前备份移动到临时目录,作为下次对比的基线 | ||
| 726 | + if (fs.existsSync(backupDir)) { | ||
| 727 | + fs.renameSync(backupDir, tempDir); | ||
| 728 | + } | ||
| 729 | + return; | ||
| 730 | + } | ||
| 731 | + | ||
| 732 | + // 扫描模块 | ||
| 733 | + const modules = fs.readdirSync(openAPIDir, { withFileTypes: true }) | ||
| 734 | + .filter(dirent => dirent.isDirectory()) | ||
| 735 | + .map(dirent => dirent.name); | ||
| 736 | + | ||
| 737 | + let hasChanges = false; | ||
| 738 | + const moduleReports = []; | ||
| 739 | + | ||
| 740 | + modules.forEach((moduleName) => { | ||
| 741 | + const moduleDir = path.join(openAPIDir, moduleName); | ||
| 742 | + const tempModuleDir = path.join(tempDir, moduleName); | ||
| 743 | + | ||
| 744 | + // 如果临时备份中不存在该模块,说明是新增模块 | ||
| 745 | + if (!fs.existsSync(tempModuleDir)) { | ||
| 746 | + console.log(`\n📦 新增模块: ${moduleName}`); | ||
| 747 | + // 解析新增模块的接口 | ||
| 748 | + try { | ||
| 749 | + const newDocs = parseOpenAPIPath(moduleDir); | ||
| 750 | + if (newDocs && newDocs.length > 0) { | ||
| 751 | + // 显示新增接口信息 | ||
| 752 | + console.log(` 包含 ${newDocs.length} 个新增接口:`); | ||
| 753 | + newDocs.forEach(doc => { | ||
| 754 | + const path = Object.keys(doc.paths || {})[0] || ''; | ||
| 755 | + const method = Object.keys(doc.paths?.[path] || {})[0] || ''; | ||
| 756 | + const apiInfo = doc.paths?.[path]?.[method]; | ||
| 757 | + const summary = apiInfo?.summary || doc.info?.title || '未命名接口'; | ||
| 758 | + console.log(` • ${method?.toUpperCase()} ${path} - ${summary}`); | ||
| 759 | + }); | ||
| 760 | + } | ||
| 761 | + } catch (error) { | ||
| 762 | + console.error(` ⚠️ 解析模块失败: ${error.message}`); | ||
| 763 | + } | ||
| 764 | + hasChanges = true; | ||
| 765 | + return; | ||
| 766 | + } | ||
| 767 | + | ||
| 768 | + // 读取当前和临时备份的文档 | ||
| 769 | + const currentFiles = fs.readdirSync(moduleDir).filter(f => f.endsWith('.md') && f !== 'CLAUDE.md'); | ||
| 770 | + const tempFiles = fs.readdirSync(tempModuleDir).filter(f => f.endsWith('.md') && f !== 'CLAUDE.md'); | ||
| 771 | + | ||
| 772 | + // 检查是否有文件变更 | ||
| 773 | + const hasNewFiles = currentFiles.some(f => !tempFiles.includes(f)); | ||
| 774 | + const hasRemovedFiles = tempFiles.some(f => !currentFiles.includes(f)); | ||
| 775 | + const hasModifiedFiles = currentFiles.some(f => { | ||
| 776 | + if (!tempFiles.includes(f)) return false; | ||
| 777 | + const currentContent = fs.readFileSync(path.join(moduleDir, f), 'utf8'); | ||
| 778 | + const tempContent = fs.readFileSync(path.join(tempModuleDir, f), 'utf8'); | ||
| 779 | + return currentContent !== tempContent; | ||
| 780 | + }); | ||
| 781 | + | ||
| 782 | + if (hasNewFiles || hasRemovedFiles || hasModifiedFiles) { | ||
| 783 | + hasChanges = true; | ||
| 784 | + moduleReports.push({ moduleName, moduleDir, tempModuleDir }); | ||
| 785 | + } | ||
| 786 | + }); | ||
| 787 | + | ||
| 788 | + // 检查删除的模块 | ||
| 789 | + const tempModules = fs.existsSync(tempDir) | ||
| 790 | + ? fs.readdirSync(tempDir, { withFileTypes: true }) | ||
| 791 | + .filter(dirent => dirent.isDirectory()) | ||
| 792 | + .map(dirent => dirent.name) | ||
| 793 | + : []; | ||
| 794 | + | ||
| 795 | + const deletedModules = tempModules.filter(m => !modules.includes(m)); | ||
| 796 | + if (deletedModules.length > 0) { | ||
| 797 | + hasChanges = true; | ||
| 798 | + console.log(`\n❌ 删除模块: ${deletedModules.join(', ')}`); | ||
| 799 | + } | ||
| 800 | + | ||
| 801 | + if (!hasChanges) { | ||
| 802 | + console.log('✅ 未检测到 API 变更'); | ||
| 803 | + // 更新基线 | ||
| 804 | + if (fs.existsSync(backupDir)) { | ||
| 805 | + fs.rmSync(tempDir, { recursive: true, force: true }); | ||
| 806 | + fs.renameSync(backupDir, tempDir); | ||
| 807 | + } | ||
| 808 | + return; | ||
| 809 | + } | ||
| 810 | + | ||
| 811 | + // 逐个模块对比 | ||
| 812 | + console.log(''); | ||
| 813 | + moduleReports.forEach(({ moduleName, moduleDir, tempModuleDir }) => { | ||
| 814 | + try { | ||
| 815 | + const oldDocs = parseOpenAPIPath(tempModuleDir); | ||
| 816 | + const newDocs = parseOpenAPIPath(moduleDir); | ||
| 817 | + const report = generateReport(oldDocs, newDocs, 'text'); | ||
| 818 | + | ||
| 819 | + console.log(report); | ||
| 820 | + console.log(''); | ||
| 821 | + } catch (error) { | ||
| 822 | + console.error(`⚠️ 模块 ${moduleName} 对比失败: ${error.message}`); | ||
| 823 | + } | ||
| 824 | + }); | ||
| 825 | + | ||
| 826 | + // 更新基线:将当前备份作为下次对比的基准 | ||
| 827 | + console.log('📝 更新 API 基线...'); | ||
| 828 | + if (fs.existsSync(tempDir)) { | ||
| 829 | + fs.rmSync(tempDir, { recursive: true, force: true }); | ||
| 830 | + } | ||
| 831 | + if (fs.existsSync(backupDir)) { | ||
| 832 | + fs.renameSync(backupDir, tempDir); | ||
| 833 | + } | ||
| 834 | +} | ||
| 835 | + | ||
| 836 | +// 执行生成 | ||
| 837 | +const openAPIDir = path.resolve(__dirname, '../docs/api-specs'); | ||
| 838 | +const outputDir = path.resolve(__dirname, '../src/api'); | ||
| 839 | + | ||
| 840 | +console.log('=== OpenAPI 转 API 文档生成器 ===\n'); | ||
| 841 | +console.log(`输入目录: ${openAPIDir}`); | ||
| 842 | +console.log(`输出目录: ${outputDir}\n`); | ||
| 843 | + | ||
| 844 | +// 备份当前的 OpenAPI 文档(用于下次对比) | ||
| 845 | +if (fs.existsSync(openAPIDir)) { | ||
| 846 | + console.log('💾 备份当前 OpenAPI 文档...'); | ||
| 847 | + backupOpenAPIDir(openAPIDir); | ||
| 848 | + console.log(''); | ||
| 849 | +} | ||
| 850 | + | ||
| 851 | +scanAndGenerate(openAPIDir, outputDir); |
| 1 | /* | 1 | /* |
| 2 | * @Date: 2025-09-04 16:44:18 | 2 | * @Date: 2025-09-04 16:44:18 |
| 3 | * @LastEditors: hookehuyr hookehuyr@gmail.com | 3 | * @LastEditors: hookehuyr hookehuyr@gmail.com |
| 4 | - * @LastEditTime: 2025-09-04 16:53:10 | 4 | + * @LastEditTime: 2026-02-09 16:33:11 |
| 5 | * @FilePath: /map-demo/src/api/checkin.js | 5 | * @FilePath: /map-demo/src/api/checkin.js |
| 6 | * @Description: 文件描述 | 6 | * @Description: 文件描述 |
| 7 | */ | 7 | */ |
| 8 | import { fn, fetch } from '@/api/fn'; | 8 | import { fn, fetch } from '@/api/fn'; |
| 9 | 9 | ||
| 10 | const Api = { | 10 | const Api = { |
| 11 | - IS_CHECKED: '/srv/?f=walk&a=map&t=is_checked', | 11 | + IS_CHECKED: '/srv/?f=walk&a=map_activity&t=is_checked', |
| 12 | - CHECKIN: '/srv/?f=walk&a=map&t=checkin', | 12 | + CHECKIN: '/srv/?f=walk&a=map_activity&t=checkin', |
| 13 | }; | 13 | }; |
| 14 | 14 | ||
| 15 | /** | 15 | /** | ... | ... |
| ... | @@ -7,5 +7,6 @@ | ... | @@ -7,5 +7,6 @@ |
| 7 | 7 | ||
| 8 | | ID | Time | T | Title | Read | | 8 | | ID | Time | T | Title | Read | |
| 9 | |----|------|---|-------|------| | 9 | |----|------|---|-------|------| |
| 10 | +| #4119 | 2:13 PM | 🔵 | page_details data source identified in checkin info component | ~328 | | ||
| 10 | | #3978 | 11:52 AM | 🔵 | Code duplication identified in src/views directory structure | ~320 | | 11 | | #3978 | 11:52 AM | 🔵 | Code duplication identified in src/views directory structure | ~320 | |
| 11 | </claude-mem-context> | 12 | </claude-mem-context> |
| ... | \ No newline at end of file | ... | \ No newline at end of file | ... | ... |
-
Please register or login to post a comment