/** * 合并运行时 API 日志与静态 api_mapping 数据 * * 匹配逻辑:通过 URL 匹配(运行时日志的 url 对应 api_mapping 中的 fullUrl) * 输出:带入参结构的增强版 api_mapping */ const fs = require('fs'); const path = require('path'); // 读取数据 const configDir = path.join(__dirname, '..', 'config'); const apiMapping = JSON.parse(fs.readFileSync(path.join(configDir, 'api_mapping.json'), 'utf-8')); // 自动扫描所有 api_log_*.json 文件,合并数据 const logFiles = fs.readdirSync(configDir).filter(f => f.startsWith('api_log_') && f.endsWith('.json')); let apiLog = []; for (const file of logFiles) { const data = JSON.parse(fs.readFileSync(path.join(configDir, file), 'utf-8')); console.log(` 📄 ${file}: ${data.length} 条记录`); apiLog = apiLog.concat(data); } console.log(`📊 api_mapping: ${apiMapping.length} 个页面`); console.log(`📊 api_log 总计: ${apiLog.length} 条运行时记录(来自 ${logFiles.length} 个文件)`); // 1. 构建运行时数据索引 // 按文件时间顺序处理,新文件有数据则覆盖旧的,没数据则保留旧的 // 双重索引:pageKey:url → 记录列表(按页面区分) // 全局索引:url → 记录列表(兜底用) const pageIndex = {}; // { "页面名::URL" → [records] } const globalIndex = {}; // { "URL" → [records] } // 按文件名排序(文件名含时间戳,自然排序即为时间顺序) logFiles.sort(); for (const file of logFiles) { const data = JSON.parse(fs.readFileSync(path.join(configDir, file), 'utf-8')); for (const record of data) { const baseUrl = record.url.split('?')[0]; const pageName = record.currentPageName || ''; const hasData = (record.dataStructure && typeof record.dataStructure === 'object' && Object.keys(record.dataStructure).length > 0) || (record.paramsStructure && typeof record.paramsStructure === 'object' && Object.keys(record.paramsStructure).length > 0); // 全局索引:新文件有数据则覆盖,无数据则追加(不覆盖已有的) if (!globalIndex[baseUrl]) { globalIndex[baseUrl] = [record]; } else if (hasData) { // 新记录有入参,覆盖旧的 globalIndex[baseUrl] = [record]; } // 新记录无入参,保留旧的,不做操作 // 页面索引 if (pageName && pageName !== '空白页') { const pageKey = `${pageName}::${baseUrl}`; if (!pageIndex[pageKey]) { pageIndex[pageKey] = [record]; } else if (hasData) { // 新记录有入参,覆盖旧的 pageIndex[pageKey] = [record]; } // 新记录无入参,保留旧的 } } } console.log(`📊 运行时唯一接口数(去参数): ${Object.keys(globalIndex).length}`); console.log(`📊 页面级索引条目数: ${Object.keys(pageIndex).length}`); /** * 合并多条运行时记录的入参结构(取字段并集) * 这样不同调用传了不同的可选参数都能被收集到 */ function mergeStructures(records) { let mergedData = null; let mergedParams = null; let exampleData = null; let exampleParams = null; let method = 'GET'; let requestType = ''; let requestSource = ''; for (const r of records) { method = (r.method || method); requestType = r.requestType || requestType; requestSource = r.requestSource || requestSource; // 合并 dataStructure if (r.dataStructure && typeof r.dataStructure === 'object') { if (!mergedData) mergedData = {}; deepMergeKeys(mergedData, r.dataStructure); } // 合并 paramsStructure if (r.paramsStructure && typeof r.paramsStructure === 'object') { if (!mergedParams) mergedParams = {}; deepMergeKeys(mergedParams, r.paramsStructure); } // 收集示例数据 (dataFull/paramsFull) // 取最后一条有数据的记录作为示例 if (r.dataFull && Object.keys(r.dataFull).length > 0) { exampleData = r.dataFull; } if (r.paramsFull && Object.keys(r.paramsFull).length > 0) { exampleParams = r.paramsFull; } } return { method, requestType, requestSource, mergedData, mergedParams, exampleData, exampleParams }; } /** * 深度合并 key(取并集),保留类型信息 */ function deepMergeKeys(target, source) { for (const key of Object.keys(source)) { if (!(key in target)) { target[key] = source[key]; } else if (typeof target[key] === 'object' && target[key] !== null && typeof source[key] === 'object' && source[key] !== null && !Array.isArray(target[key]) && !Array.isArray(source[key])) { // 两边都是对象,递归合并 deepMergeKeys(target[key], source[key]); } // 如果类型不同或已有值,保留原值(先到先得) } } // 2. 合并数据 let matchedApiCount = 0; let unmatchedApiCount = 0; let matchedPageCount = 0; for (const page of apiMapping) { if (!page.apis || page.apis.length === 0) continue; let pageHasMatch = false; const pageName = page.menuName || ''; for (const api of page.apis) { const baseUrl = api.fullUrl.split('?')[0]; // 优先用页面级索引(精确匹配当前页面的记录) const pageKey = `${pageName}::${baseUrl}`; let records = pageIndex[pageKey]; // 兜底用全局索引 if (!records || records.length === 0) { records = globalIndex[baseUrl]; } if (records && records.length > 0) { // 合并该页面下所有同接口调用的入参(取并集) const merged = mergeStructures(records); api.httpMethod = merged.method; api.dataStructure = merged.mergedData; api.paramsStructure = merged.mergedParams; api.exampleData = merged.exampleData; api.exampleParams = merged.exampleParams; api.requestType = merged.requestType; api.runtimeSource = merged.requestSource; api.runtimePage = pageName; api.hasRuntimeData = true; // 标记是否为当前页面的精确匹配 api.exactPageMatch = !!pageIndex[pageKey]; matchedApiCount++; pageHasMatch = true; } else { api.hasRuntimeData = false; unmatchedApiCount++; } } if (pageHasMatch) matchedPageCount++; } console.log(`\n✅ 合并结果:`); console.log(` 已匹配接口: ${matchedApiCount}`); console.log(` 未匹配接口: ${unmatchedApiCount}`); console.log(` 有运行时数据的页面: ${matchedPageCount}`); // 3. 输出合并后的 JSON const jsonOutput = path.join(__dirname, '..', 'config', 'api_mapping_merged.json'); fs.writeFileSync(jsonOutput, JSON.stringify(apiMapping, null, 2), 'utf-8'); console.log(`\n📁 JSON 已输出: ${jsonOutput}`); // 4. 输出 Markdown 报告 const mdOutput = path.join(__dirname, '..', 'config', 'api_mapping_merged.md'); let md = ''; md += `# API 接口映射报告(含入参)\n\n`; md += `> 生成时间: ${new Date().toLocaleString('zh-CN')}\n\n`; md += `## 统计\n\n`; md += `| 指标 | 数值 |\n`; md += `|------|------|\n`; md += `| 总页面数 | ${apiMapping.length} |\n`; md += `| 有运行时数据的页面 | ${matchedPageCount} |\n`; md += `| 已匹配接口 | ${matchedApiCount} |\n`; md += `| 未匹配接口(待采集) | ${unmatchedApiCount} |\n\n`; // 按父菜单分组 const groups = {}; for (const item of apiMapping) { const parent = item.parentMenu || '未分类'; if (!groups[parent]) groups[parent] = []; groups[parent].push(item); } for (const [groupName, items] of Object.entries(groups)) { md += `## ${groupName}\n\n`; for (const item of items) { if (item.status === 'unmatched' || item.status === 'dir_not_found') continue; md += `### ${item.menuName}\n`; md += `- 路径: \`${item.menuUrl}\`\n`; md += `- 组件: \`${item.componentDir || item.componentPath}\`\n`; md += `- 调用了 **${item.apiCount}** 个接口\n\n`; if (item.apis && item.apis.length > 0) { item.apis.forEach((api, idx) => { const method = api.httpMethod || '-'; const status = api.hasRuntimeData ? '✅ 已采集' : '⏳ 待采集'; md += `#### ${idx + 1}. \`${api.functionName || '-'}\`\n\n`; md += `- **方法**: \`${method}\`\n`; md += `- **接口**: \`${api.path}\`\n`; md += `- **完整URL**: \`${api.fullUrl}\`\n`; md += `- **来源**: \`${api.serviceFile || '-'}\`\n`; md += `- **状态**: ${status}\n`; // 详细入参结构 if (api.dataStructure && Object.keys(api.dataStructure).length > 0) { md += `- **请求体 (data/body)**:\n`; md += '```json\n'; md += JSON.stringify(api.dataStructure, null, 2) + '\n'; md += '```\n'; } if (api.paramsStructure && Object.keys(api.paramsStructure).length > 0) { md += `- **查询参数 (params/query)**:\n`; md += '```json\n'; md += JSON.stringify(api.paramsStructure, null, 2) + '\n'; md += '```\n'; } // 示例请求参数展示 if ((api.exampleData && Object.keys(api.exampleData).length > 0) || (api.exampleParams && Object.keys(api.exampleParams).length > 0)) { md += `- **示例请求参数 (运行时真实数据)**:\n`; md += '```json\n'; const fullExample = {}; if (api.exampleData) fullExample.body = api.exampleData; if (api.exampleParams) fullExample.query = api.exampleParams; md += JSON.stringify(fullExample, null, 2) + '\n'; md += '```\n'; } if (!api.hasRuntimeData) { md += `- **入参**: 暂无运行时数据\n`; } else if ( (!api.dataStructure || Object.keys(api.dataStructure).length === 0) && (!api.paramsStructure || Object.keys(api.paramsStructure).length === 0) ) { md += `- **入参**: 无参数\n`; } md += '\n'; }); } } md += '---\n\n'; } fs.writeFileSync(mdOutput, md, 'utf-8'); console.log(`📝 Markdown 已输出: ${mdOutput}`);