import axios from "axios"; import NodeCache from 'node-cache'; import fs from 'fs'; import path from 'path'; const APIFOX_API_BASE = "https://apifox.gzmiyuan.com/api/v1"; // 初始化node-cache,设置默认5分钟过期时间 // 全局共享缓存,避免每次实例化清空缓存 const globalCache = new NodeCache({ stdTTL: 5 * 60, // 5分钟,单位秒 checkperiod: 60, // 每分钟检查一次过期缓存 useClones: false // 创建缓存对象的克隆以避免引用问题 }); export class ApifoxService { constructor(config = {}) { const { projectId, token, baseUrl, logFilePath } = config; this.defaultProjectId = projectId; this.projectBranchIds = new Map(); // Cache branchIds: projectId -> branchId this.apiTreeList = null; this.logFilePath = logFilePath || path.join(process.cwd(), 'apifox-service.log'); this.http = axios.create({ baseURL: baseUrl || APIFOX_API_BASE, headers: { "X-Client-Mode": "web", "X-Client-Version": "2.7.19", "X-Device-Id": "bd94aea0-7b65-48ba-9274-4b1177897954", "Authorization": `Bearer ${token}` } }); this.cache = globalCache; this.init(); } /** * 清除特定缓存 * @param {string} cacheKey - 缓存键名 */ clearCache(cacheKey) { if (cacheKey) { this.cache.del(cacheKey); } else { // 清除所有缓存 this.cache.flushAll(); } } /** * 写入日志到文件 * @param {string} level - 日志级别 (INFO, ERROR, WARN, DEBUG) * @param {string} message - 日志消息 * @param {object} data - 可选的额外数据 */ logToFile(level, message, data = null) { try { const timestamp = new Date().toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' }); let logLine = `[${timestamp}] ${level.toUpperCase()}: ${message}`; if (data) { if (typeof data === 'object') { logLine += `\n 数据: ${JSON.stringify(data, null, 2)}`; } else { logLine += `\n 数据: ${data}`; } } logLine += '\n'; // 确保日志目录存在 const logDir = path.dirname(this.logFilePath); if (!fs.existsSync(logDir)) { fs.mkdirSync(logDir, { recursive: true }); } // 追加写入日志文件 fs.appendFileSync(this.logFilePath, logLine); // 同时输出到控制台 } catch (error) { console.error('写入日志失败:', error); } } async init() { // Pre-fetch branchId for default project if set if (this.defaultProjectId) { await this.getBranchId(this.defaultProjectId); } } /** * 发送GET请求 * @param {string} url - 请求的URL路径 * @param {object} config - axios配置对象 (contains headers with projectId/branchId) * @returns {Promise} 返回响应数据中的data字段 */ async get(url, config = {}) { try { const response = await this.http.get(url, config); return response.data; } catch (error) { // Enhance error logging if needed throw error; } } /** * 发送POST请求 * @param {string} url - 请求的URL路径 * @param {object} data - 请求体数据 * @param {object} config - axios配置对象 * @returns {Promise} 返回响应数据中的data字段 */ async post(url, data = {}, config = {}) { try { const response = await this.http.post(url, data, config); return response.data; } catch (error) { throw error; } } async getRequestConfig(projectId) { const pid = projectId || this.defaultProjectId; if (!pid) throw new Error("No Project ID provided"); const branchId = await this.getBranchId(pid); return { headers: { "X-Project-Id": pid, "X-Branch-Id": branchId, } }; } async getBranchId(projectId) { if (this.projectBranchIds.has(projectId)) { return this.projectBranchIds.get(projectId); } // We need to fetch branch ID. // BUT fetching branch ID requires Project ID header usually? // Let's check the original implementation: // const branches = await this.get(`/projects/${this.projectId}/api-versions`); // It used `this.http` which had `this.projectId` in headers? // WAIT, original `get` implementation injected headers dynamically based on `this.projectId`. // So here we must manually construct headers for this specific request to avoid circular dependency // or just pass X-Project-Id. try { const response = await this.http.get(`/projects/${projectId}/api-versions`, { headers: { "X-Project-Id": projectId } }); const branchId = response.data.data[0].id; this.projectBranchIds.set(projectId, branchId); return branchId; } catch (error) { console.error(`Failed to get branch ID for project ${projectId}`, error); throw error; } } async getApiTreeList(projectId) { const pid = projectId || this.defaultProjectId; const cacheKey = `${pid}:apiTreeList`; const cachedData = this.cache.get(cacheKey); if (cachedData) { return cachedData; } const config = await this.getRequestConfig(pid); const apiTree = await this.get(`/projects/${pid}/api-tree-list`, config); this.cache.set(cacheKey, apiTree); return apiTree; } async getFolderList(projectId) { const pid = projectId || this.defaultProjectId; // 定义缓存键 const cacheKey = `${pid}:folderList`; // 检查缓存是否有效 const cachedData = this.cache.get(cacheKey); if (cachedData) { return cachedData; } const config = await this.getRequestConfig(pid); const {data: folderList} = await this.get(`/projects/${pid}/api-detail-folders`, config); // 设置缓存 this.cache.set(cacheKey, folderList); return folderList; } async getFolderByName(folderName, projectId) { const folderList = await this.getFolderList(projectId); const folder = folderList.find((item) => item.name === folderName); if (!folder) { throw new Error(`Folder with name ${folderName} not found`); } return folder; } async getDataSchema(projectId) { const pid = projectId || this.defaultProjectId; // 定义缓存键 const cacheKey = `${pid}:dataSchema`; // 检查缓存是否有效 const cachedData = this.cache.get(cacheKey); if (cachedData) { return cachedData; } const config = await this.getRequestConfig(pid); const {data:schemasTreeList} = await this.get(`/projects/${pid}/data-schemas?locale=zh-CN`, config); // 设置缓存 this.cache.set(cacheKey, schemasTreeList); return schemasTreeList; } async getApiDetails(projectId) { const pid = projectId || this.defaultProjectId; // 定义缓存键 const cacheKey = `${pid}:apiDetails`; // 检查缓存是否有效 const cachedData = this.cache.get(cacheKey); if (cachedData) { return cachedData; } const config = await this.getRequestConfig(pid); const {data:apiDetails} = await this.get(`/api-details?locale=zh-CN`, config); // 设置缓存 this.cache.set(cacheKey, apiDetails); return apiDetails; } getSchemaRefId(obj) { if (!obj || !obj.$ref) { return; } const schemaName = obj.$ref.split('/').pop(); return schemaName; } /** * 根据schema ID获取完整的schema定义,递归替换所有$ref引用 * @param {number|string} schemaId - schema的ID * @param {object} context - 上下文对象 { projectId, allSchemas, visitedIds } * @returns {Promise} 返回完整解析后的schema对象 */ async getSchemaById(schemaId, context = {}) { if (!schemaId) { return null; } let { projectId, allSchemas, visitedIds } = context; visitedIds = visitedIds || new Set(); // 如果没有传入allSchemas,则获取所有schema数据 if (!allSchemas) { allSchemas = await this.getDataSchema(projectId); context.allSchemas = allSchemas; // Update context for recursion } // 查找对应ID的schema const schema = allSchemas.find((item) => item.id.toString() === schemaId.toString()); if (!schema) { throw new Error(`Schema with id ${schemaId} not found`); } // 防止循环引用 if (visitedIds.has(schemaId.toString())) { return { type: 'object', description: `循环引用: ${schema.name}` }; } visitedIds.add(schemaId.toString()); // Update context with new visitedIds for recursion (create new Set for branch safety if needed, but sharing Set prevents cycles across branches which is usually desired for DAGs) // Actually, usually visitedIds should be cloned for separate branches if we want to allow same ref in different parts, but for circular detection, shared set is stricter. // However, existing code passed `new Set(visitedIds)` to resolveRefs. // 深拷贝schema以避免修改原始数据 const resolvedSchema = JSON.parse(JSON.stringify(schema)); // 递归替换jsonSchema中的所有$ref resolvedSchema.jsonSchema = await this.resolveRefs( resolvedSchema.jsonSchema, { ...context, visitedIds: new Set(visitedIds) } ); // 移除不需要的字段 const { description, folderId, projectId: pid, moduleId, ordering, creatorId, editorId, createdAt, updatedAt, visibility, ...cleanSchema } = resolvedSchema; return cleanSchema; } /** * 递归解析对象中的所有$ref引用 * @param {object} obj - 要解析的对象 * @param {object} context - 上下文对象 { projectId, allSchemas, visitedIds } * @returns {Promise} 返回解析后的对象 */ async resolveRefs(obj, context) { if (!obj || typeof obj !== 'object') { return obj; } // 如果是数组,递归处理每个元素 if (Array.isArray(obj)) { const resolved = []; for (const item of obj) { resolved.push(await this.resolveRefs(item, context)); } return resolved; } // 如果对象包含$ref,替换为实际的schema定义 if (obj.$ref) { const refId = this.getSchemaRefId(obj); // Calls getSchemaById with context const referencedSchema = await this.getSchemaById(refId, context); // 返回引用的jsonSchema内容 return referencedSchema.jsonSchema; } // 递归处理对象的所有属性 const resolved = {}; for (const [key, value] of Object.entries(obj)) { resolved[key] = await this.resolveRefs(value, context); } return resolved; } async getApiDetailResp(apiDetail, projectId) { const pid = projectId || this.defaultProjectId; const dataSchema = await this.getDataSchema(pid); const context = { projectId: pid, allSchemas: dataSchema }; const requestSchemaId = this.getSchemaRefId(apiDetail.requestBody.jsonSchema); const responseSchema = Array.isArray(apiDetail.responses) ? apiDetail.responses[0].jsonSchema : apiDetail.responses.jsonSchema; const responseSchemaId = this.getSchemaRefId(responseSchema); // Find the response data schema property let responseDataSchema; try { const schema = dataSchema.find((item) => item.id.toString() === responseSchemaId); if (schema && schema.jsonSchema && schema.jsonSchema.properties) { responseDataSchema = schema.jsonSchema.properties.data; } } catch (e) { // Fallback or ignore if structure not found } if (!responseDataSchema) { // handle case where structure is different or missing // For now, let's assume if we can't find it, we might just try to return null for response schema or log warning this.logToFile('WARN', `无法找到 responseDataSchema`, { responseSchemaId }); // Proceeding with null might be safer than crashing below } let respDataSchemaId; if (responseDataSchema) { const isArray = responseDataSchema.type === 'array'; // 处理数组和对象两种情况 if (isArray && responseDataSchema.items && responseDataSchema.items.$ref) { // 数组情况:从 items.$ref 中提取 schema ID respDataSchemaId = this.getSchemaRefId(responseDataSchema.items); } else if (responseDataSchema.$ref) { // 对象情况:直接从 $ref 中提取 schema ID respDataSchemaId = this.getSchemaRefId(responseDataSchema); } else { // 如果都没有 $ref,记录警告并返回 null this.logToFile('WARN', `无法从 responseDataSchema 中提取 schema ID`, responseDataSchema); respDataSchemaId = null; } } return { path: apiDetail.path, name: apiDetail.name, method: apiDetail.method, url: apiDetail.url, requestSchema: await this.getSchemaById(requestSchemaId, context), responseSchema: (responseDataSchema && responseDataSchema.type === 'array') ? { type: 'array', items: respDataSchemaId ? await this.getSchemaById(respDataSchemaId, context) : null, } : await this.getSchemaById(respDataSchemaId, context), }; } async getApiDetailByPath(path, projectId) { const pid = projectId || this.defaultProjectId; const apiDetails = await this.getApiDetails(pid); const apiDetail = apiDetails.find((item) => item.path === path); if (!apiDetail) { throw new Error(`API with path ${path} not found`); } return this.getApiDetailResp(apiDetail, pid); } async getApiDetailsById(apiId, projectId) { const pid = projectId || this.defaultProjectId; const apiDetails = await this.getApiDetails(pid); const apiDetail = apiDetails.find((item) => item.id === apiId); if (!apiDetail) { throw new Error(`API with id ${apiId} not found`); } return this.getApiDetailResp(apiDetail, pid); } // TODO: async getApiListByFolderName(folderName, projectId) { const pid = projectId || this.defaultProjectId; const folder = await this.getFolderByName(folderName, pid); const apiDetails = await this.getApiDetails(pid); const apiList = apiDetails.filter((item) => item.folderId === folder.id); return Promise.all(apiList.map((api) => this.getApiDetailResp(api, pid))); } }