api-access-logger.js 4.81 KB
/**
 * Open API Access Logger — JSONL 日志中间件
 *
 * 将每次 /api/open/* 请求记录为一条 JSON 行。
 *
 * 目录结构:
 *   apiconversation/
 *     2026-03-11/
 *       access.jsonl        ← 普通访问日志 (search, download, list)
 *       job-xxx-aaa.jsonl   ← 单个 Job 的完整生命周期日志
 *     2026-03-12/
 *       ...
 */

const fs = require('fs');
const path = require('path');

const LOG_DIR = path.resolve(__dirname, '../../apiconversation');

/**
 * Express 中间件:记录 Open API 请求
 */
function apiAccessLogger(req, res, next) {
  const start = Date.now();

  // 拦截 res.end 以获取状态码
  const originalEnd = res.end;
  res.end = function (...args) {
    const latency = Date.now() - start;
    const apiKey = req.apiKey || extractKeyHint(req);
    const fullPath = '/api/open' + req.path;

    const entry = {
      ts: new Date().toISOString(),
      key: maskKey(apiKey),
      method: req.method,
      path: fullPath,
      action: describeAction(req.method, req.path, req.query),
      query: Object.keys(req.query).length > 0 ? req.query : undefined,
      body: summarizeBody(req),
      status: res.statusCode,
      statusText: describeStatus(res.statusCode),
      latency,
      ip: req.ip || req.connection?.remoteAddress,
      userAgent: req.headers['user-agent']?.substring(0, 100),
    };

    // 异步写入,不阻塞响应
    writeLog(entry, req.path);

    originalEnd.apply(res, args);
  };

  next();
}

/**
 * 从请求头提取 Key 提示(用于 401 场景下 req.apiKey 不存在时)
 */
function extractKeyHint(req) {
  const auth = req.headers['authorization'];
  if (auth && auth.startsWith('Bearer ')) return auth.slice(7).trim();
  return req.headers['x-api-key'] || null;
}

/**
 * 遮蔽 API Key(只显示前 6 位和后 3 位)
 */
function maskKey(key) {
  if (!key) return '<none>';
  if (key.length <= 10) return key.substring(0, 3) + '***';
  return key.substring(0, 6) + '***' + key.substring(key.length - 3);
}

/**
 * 提取请求 body 的摘要(仅对 POST 请求,截断到 200 字)
 */
function summarizeBody(req) {
  if (req.method !== 'POST' || !req.body) return undefined;
  const s = typeof req.body === 'string' ? req.body : JSON.stringify(req.body);
  return s.length > 200 ? s.substring(0, 200) + '...' : s;
}

/**
 * 判断请求路径对应的 Job ID(如果有的话)
 */
function extractJobId(reqPath) {
  // /jobs/:jobId 或 /jobs/:jobId/result
  const jobMatch = reqPath.match(/^\/jobs\/(job-[^/]+)/);
  if (jobMatch) return jobMatch[1];
  return null;
}

/**
 * 写一条日志:普通请求 → access.jsonl,Job 相关 → job-{id}.jsonl
 */
function writeLog(entry, reqPath) {
  try {
    const date = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
    const dateDir = path.join(LOG_DIR, date);
    fs.mkdirSync(dateDir, { recursive: true });

    const jobId = extractJobId(reqPath);
    const logFile = jobId
      ? path.join(dateDir, `${jobId}.jsonl`)
      : path.join(dateDir, 'access.jsonl');

    const line = JSON.stringify(entry) + '\n';
    fs.appendFileSync(logFile, line, 'utf-8');
  } catch (err) {
    console.warn('[OpenAPI Logger] Failed to write log:', err.message);
  }
}

/**
 * 根据请求路径生成可读的操作描述
 */
function describeAction(method, reqPath, query) {
  if (reqPath.startsWith('/skills/search')) {
    return `搜索 Skill: "${query?.q || ''}"`;
  }
  if (reqPath.match(/^\/skills\/[^/]+\/download$/)) {
    const name = reqPath.split('/')[2];
    return `下载 Skill: ${name}`;
  }
  if (reqPath.match(/^\/skills\/[^/]+\/feedback$/) && method === 'POST') {
    const name = reqPath.split('/')[2];
    return `提交反馈: ${name}`;
  }
  if (reqPath.match(/^\/skills\/[^/]+\/updates$/) && method === 'GET') {
    const name = reqPath.split('/')[2];
    return `检查更新: ${name}`;
  }
  if (reqPath.match(/^\/skills\/[^/]+$/) && method === 'GET') {
    const name = reqPath.split('/')[2];
    return `查看 Skill 详情: ${name}`;
  }
  if (reqPath === '/skills/create' && method === 'POST') {
    return '请求创建 Skill';
  }
  if (reqPath.match(/^\/jobs\/[^/]+\/result$/)) {
    return '下载 Job 结果';
  }
  if (reqPath.match(/^\/jobs\/[^/]+$/) && method === 'GET') {
    const jobId = reqPath.split('/')[2];
    return `查询 Job 状态: ${jobId.substring(0, 16)}`;
  }
  if (reqPath === '/jobs' && method === 'GET') {
    return '列出 Jobs';
  }
  return `${method} ${reqPath}`;
}

/**
 * 状态码可读描述
 */
function describeStatus(code) {
  const map = {
    200: '成功', 201: '已创建', 202: '已接受', 204: '无内容',
    400: '请求错误', 401: '未授权', 403: '禁止访问', 404: '未找到',
    500: '服务器错误', 502: '网关错误', 503: '服务不可用',
  };
  return map[code] || (code < 400 ? '成功' : '错误');
}

module.exports = { apiAccessLogger };