feat: refactor for multi-tenant HTTP support and global caching

This commit is contained in:
Gemini Agent committed 2026-01-14 18:56:03 +08:00
1 parent 30d4c8a9da
commit 1ea6e0e9f0
5 files changed
+737 -639

No files matched your search

+443 -376
View File
@@ -1,376 +1,443 @@
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";
export class ApifoxService {
constructor(config = {}) {
const { projectId, token, baseUrl, logFilePath } = config;
this.projectId = projectId;
this.branchId = null;
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}`
}
});
// 初始化node-cache,设置默认5分钟过期时间
this.cache = new NodeCache({
stdTTL: 5 * 60, // 5分钟,单位秒
checkperiod: 60, // 每分钟检查一次过期缓存
useClones: false // 创建缓存对象的克隆以避免引用问题
});
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);
}
}
/**
* 初始化服务,获取branchId
* @returns {Promise<void>}
*/
async init(projectId) {
if (projectId) {
this.projectId = projectId;
this.branchId = await this.getBranchId();
}
}
/**
* 发送GET请求
* @param {string} url - 请求的URL路径
* @param {object} config - axios配置对象
* @returns {Promise} 返回响应数据中的data字段
*/
async get(url, config = {}) {
try {
const response = await this.http.get(url, {
...config,
headers: {
...config.headers,
"X-Project-Id": this.projectId,
"X-Branch-Id": this.branchId,
},
});
return response.data;
} catch (error) {
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,
headers: {
...config.headers,
"X-Project-Id": this.projectId,
"X-Branch-Id": this.branchId,
},
});
return response.data;
} catch (error) {
throw error;
}
}
async getBranchId() {
const branches = await this.get(`/projects/${this.projectId}/api-versions`);
return branches.data[0].id;
}
async getApiTreeList() {
const apiTree = await this.get(`/projects/${this.projectId}/api-tree-list`);
this.apiTreeList = apiTree;
return apiTree;
}
async getFolderList() {
// 定义缓存键
const cacheKey = 'folderList';
// 检查缓存是否有效
const cachedData = this.cache.get(cacheKey);
if (cachedData) {
return cachedData;
}
const {data: folderList} = await this.get(`/projects/${this.projectId}/api-detail-folders`);
// 设置缓存
this.cache.set(cacheKey, folderList);
return folderList;
}
async getFolderByName(folderName) {
const folderList = await this.getFolderList();
const folder = folderList.find((item) => item.name === folderName);
if (!folder) {
throw new Error(`Folder with name ${folderName} not found`);
}
return folder;
}
async getDataSchema() {
// 定义缓存键
const cacheKey = 'dataSchema';
// 检查缓存是否有效
const cachedData = this.cache.get(cacheKey);
if (cachedData) {
return cachedData;
}
const {data:schemasTreeList} = await this.get(`/projects/${this.projectId}/data-schemas?locale=zh-CN`);
// 设置缓存
this.cache.set(cacheKey, schemasTreeList);
return schemasTreeList;
}
async getApiDetails() {
// 定义缓存键
const cacheKey = 'apiDetails';
// 检查缓存是否有效
const cachedData = this.cache.get(cacheKey);
if (cachedData) {
return cachedData;
}
const {data:apiDetails} = await this.get(`/api-details?locale=zh-CN`, {});
// 设置缓存
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 {Array} allSchemas - 所有schema数据数组(可选,用于递归)
* @param {Set} visitedIds - 已访问的ID集合,用于防止循环引用
* @returns {Promise<object>} 返回完整解析后的schema对象
*/
async getSchemaById(schemaId, allSchemas = null, visitedIds = new Set()) {
if (!schemaId) {
return null;
}
// 如果没有传入allSchemas,则获取所有schema数据
if (!allSchemas) {
allSchemas = await this.getDataSchema();
}
// 查找对应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());
// 深拷贝schema以避免修改原始数据
const resolvedSchema = JSON.parse(JSON.stringify(schema));
// 递归替换jsonSchema中的所有$ref
resolvedSchema.jsonSchema = await this.resolveRefs(
resolvedSchema.jsonSchema,
allSchemas,
new Set(visitedIds)
);
// 移除不需要的字段
const { description, folderId, projectId, moduleId, ordering, creatorId, editorId, createdAt, updatedAt, visibility, ...cleanSchema } = resolvedSchema;
return cleanSchema;
}
/**
* 递归解析对象中的所有$ref引用
* @param {object} obj - 要解析的对象
* @param {Array} allSchemas - 所有schema数据数组
* @param {Set} visitedIds - 已访问的ID集合
* @returns {Promise<object>} 返回解析后的对象
*/
async resolveRefs(obj, allSchemas, visitedIds) {
if (!obj || typeof obj !== 'object') {
return obj;
}
// 如果是数组,递归处理每个元素
if (Array.isArray(obj)) {
const resolved = [];
for (const item of obj) {
resolved.push(await this.resolveRefs(item, allSchemas, visitedIds));
}
return resolved;
}
// 如果对象包含$ref,替换为实际的schema定义
if (obj.$ref) {
const refId = this.getSchemaRefId(obj);
const referencedSchema = await this.getSchemaById(refId, allSchemas, visitedIds);
// 返回引用的jsonSchema内容
return referencedSchema.jsonSchema;
}
// 递归处理对象的所有属性
const resolved = {};
for (const [key, value] of Object.entries(obj)) {
resolved[key] = await this.resolveRefs(value, allSchemas, visitedIds);
}
return resolved;
}
async getApiDetailResp(apiDetail) {
const dataSchema = await this.getDataSchema();
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);
const responseDataSchema = dataSchema.find((item) => item.id.toString() === responseSchemaId).jsonSchema.properties.data;
let respDataSchemaId;
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),
responseSchema: isArray ? {
type: 'array',
items: respDataSchemaId ? await this.getSchemaById(respDataSchemaId) : null,
} : await this.getSchemaById(respDataSchemaId),
};
}
async getApiDetailByPath(path, projectId) {
if (projectId) {
await this.init(projectId);
}
const apiDetails = await this.getApiDetails();
const apiDetail = apiDetails.find((item) => item.path === path);
if (!apiDetail) {
throw new Error(`API with path ${path} not found`);
}
return this.getApiDetailResp(apiDetail);
}
async getApiDetailsById(apiId) {
const apiDetails = await this.getApiDetails();
const apiDetail = apiDetails.find((item) => item.id === apiId);
if (!apiDetail) {
throw new Error(`API with id ${id} not found`);
}
return this.getApiDetailResp(apiDetail);
}
// TODO:
async getApiListByFolderName(folderName, projectId) {
if (projectId) {
await this.init(projectId);
}
const folder = await this.getFolderByName(folderName);
const apiDetails = await this.getApiDetails();
const apiList = apiDetails.filter((item) => item.folderId === folder.id);
return Promise.all(apiList.map((api) => this.getApiDetailResp(api)));
}
}
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<object>} 返回完整解析后的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<object>} 返回解析后的对象
*/
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)));
}
}