api-access-logger.js
4.81 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
/**
* 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 };