@zhin.js/http 1.0.45 → 1.0.46
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/package.json +9 -5
- package/src/index.ts +534 -0
- package/src/router.ts +66 -0
package/CHANGELOG.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhin.js/http",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.46",
|
|
4
4
|
"description": "HTTP server service for Zhin.js with routing and WebSocket support",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -38,22 +38,26 @@
|
|
|
38
38
|
"koa": "^3.1.1",
|
|
39
39
|
"ws": "^8.18.3",
|
|
40
40
|
"koa-body": "latest",
|
|
41
|
-
"@zhin.js/schema": "1.0.
|
|
41
|
+
"@zhin.js/schema": "1.0.36"
|
|
42
42
|
},
|
|
43
43
|
"peerDependencies": {
|
|
44
|
-
"zhin.js": "1.0.
|
|
44
|
+
"zhin.js": "1.0.52"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
47
47
|
"@types/koa": "latest",
|
|
48
48
|
"@types/koa-bodyparser": "latest",
|
|
49
49
|
"@types/koa__router": "latest",
|
|
50
50
|
"@types/ws": "latest",
|
|
51
|
-
"zhin.js": "1.0.
|
|
51
|
+
"zhin.js": "1.0.52"
|
|
52
52
|
},
|
|
53
53
|
"files": [
|
|
54
|
+
"src",
|
|
54
55
|
"lib",
|
|
55
|
-
"
|
|
56
|
+
"client",
|
|
57
|
+
"dist",
|
|
58
|
+
"skills",
|
|
56
59
|
"README.md",
|
|
60
|
+
"node",
|
|
57
61
|
"CHANGELOG.md"
|
|
58
62
|
],
|
|
59
63
|
"publishConfig": {
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
import { usePlugin, DatabaseFeature, Models, Adapter, SystemLog, Plugin } from "zhin.js";
|
|
2
|
+
import { Schema } from "@zhin.js/schema";
|
|
3
|
+
import { createServer, Server } from "http";
|
|
4
|
+
import crypto from "node:crypto";
|
|
5
|
+
import Koa from "koa";
|
|
6
|
+
import body, { KoaBodyMiddlewareOptionsSchema } from "koa-body";
|
|
7
|
+
import { Router, RouterContext} from "./router.js";
|
|
8
|
+
|
|
9
|
+
export * from "./router.js";
|
|
10
|
+
|
|
11
|
+
declare module "zhin.js" {
|
|
12
|
+
namespace Plugin {
|
|
13
|
+
interface Contexts {
|
|
14
|
+
koa: Koa;
|
|
15
|
+
router: Router;
|
|
16
|
+
server: Server;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Schema 定义
|
|
22
|
+
export const httpSchema = Schema.object({
|
|
23
|
+
port: Schema.number().default(8086).description("HTTP 服务端口"),
|
|
24
|
+
token: Schema.string().description(
|
|
25
|
+
"API 访问令牌,不填则自动生成。通过 Authorization: Bearer <token> 或 ?token=<token> 传递"
|
|
26
|
+
),
|
|
27
|
+
base: Schema.string()
|
|
28
|
+
.default("/api")
|
|
29
|
+
.description("HTTP 路由前缀, 默认为 /api"),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
export interface HttpConfig {
|
|
33
|
+
port?: number;
|
|
34
|
+
host?: string;
|
|
35
|
+
token?: string;
|
|
36
|
+
base?: string;
|
|
37
|
+
/** 是否信任反向代理(Cloudflare、Nginx 等)的 X-Forwarded-* 头,部署在代理后时建议设为 true */
|
|
38
|
+
trustProxy?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const generateToken = () => crypto.randomBytes(16).toString('hex');
|
|
42
|
+
|
|
43
|
+
const plugin = usePlugin();
|
|
44
|
+
const { provide, root, useContext, logger, declareConfig } = plugin;
|
|
45
|
+
|
|
46
|
+
declareConfig("http", httpSchema, { reloadable: false });
|
|
47
|
+
|
|
48
|
+
// 创建实例
|
|
49
|
+
const koa = new Koa();
|
|
50
|
+
const server = createServer(koa.callback());
|
|
51
|
+
const router = new Router(server, { prefix: process.env.routerPrefix || "" });
|
|
52
|
+
|
|
53
|
+
// 注册 server 上下文
|
|
54
|
+
provide({
|
|
55
|
+
name: "server",
|
|
56
|
+
description: "http server",
|
|
57
|
+
value: server,
|
|
58
|
+
dispose(s) {
|
|
59
|
+
s.close();
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// 使用配置服务
|
|
64
|
+
useContext("config", (configService) => {
|
|
65
|
+
const appConfig = configService.getPrimary<{ http?: HttpConfig }>();
|
|
66
|
+
const httpConfig = appConfig.http || {};
|
|
67
|
+
const {
|
|
68
|
+
port = 8086,
|
|
69
|
+
host = "127.0.0.1",
|
|
70
|
+
token = generateToken(),
|
|
71
|
+
base = "/api",
|
|
72
|
+
trustProxy = false,
|
|
73
|
+
} = httpConfig;
|
|
74
|
+
|
|
75
|
+
// 反向代理场景下信任 X-Forwarded-Host / X-Forwarded-Proto 等
|
|
76
|
+
koa.proxy = trustProxy;
|
|
77
|
+
|
|
78
|
+
// Token 认证中间件:仅对 API 路径要求认证
|
|
79
|
+
koa.use(async (ctx, next) => {
|
|
80
|
+
if (!ctx.path.startsWith(base + '/') && ctx.path !== base) return next();
|
|
81
|
+
// /pub 为公开前缀(webhook、OAuth、health 等),不校验 token
|
|
82
|
+
if (ctx.path.startsWith('/pub/') || ctx.path === '/pub') return next();
|
|
83
|
+
// 跳过 router 注册的非 API 路由(如 /mcp),避免误拦截
|
|
84
|
+
const whiteList: (string | RegExp)[] = router.whiteList || [];
|
|
85
|
+
const isWhitelisted = whiteList.some(p =>
|
|
86
|
+
typeof p === 'string' && !p.startsWith(base) && ctx.path.startsWith(p)
|
|
87
|
+
);
|
|
88
|
+
if (isWhitelisted) return next();
|
|
89
|
+
|
|
90
|
+
// 从 Bearer token 或 query 参数中提取 token
|
|
91
|
+
const authHeader = ctx.get('Authorization');
|
|
92
|
+
const reqToken = authHeader?.startsWith('Bearer ')
|
|
93
|
+
? authHeader.slice(7)
|
|
94
|
+
: (ctx.query.token as string);
|
|
95
|
+
|
|
96
|
+
if (reqToken !== token) {
|
|
97
|
+
ctx.status = 401;
|
|
98
|
+
ctx.body = { success: false, error: 'Invalid or missing token' };
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
await next();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// ============================================================================
|
|
105
|
+
// API 路由
|
|
106
|
+
// ============================================================================
|
|
107
|
+
|
|
108
|
+
// 系统状态 API
|
|
109
|
+
router.get(`${base}/system/status`, async (ctx) => {
|
|
110
|
+
ctx.body = {
|
|
111
|
+
success: true,
|
|
112
|
+
data: {
|
|
113
|
+
uptime: process.uptime(),
|
|
114
|
+
memory: process.memoryUsage(),
|
|
115
|
+
cpu: process.cpuUsage(),
|
|
116
|
+
platform: process.platform,
|
|
117
|
+
nodeVersion: process.version,
|
|
118
|
+
pid: process.pid,
|
|
119
|
+
timestamp: new Date().toISOString(),
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// 健康检查 API
|
|
125
|
+
router.get('/pub/health', async (ctx) => {
|
|
126
|
+
ctx.body = {
|
|
127
|
+
success: true,
|
|
128
|
+
status: "ok",
|
|
129
|
+
timestamp: new Date().toISOString(),
|
|
130
|
+
};
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// 统计信息 API
|
|
134
|
+
router.get(`${base}/stats`, async (ctx) => {
|
|
135
|
+
const allPlugins = root.children;
|
|
136
|
+
|
|
137
|
+
// 统计机器人数量
|
|
138
|
+
let botCount = 0;
|
|
139
|
+
let onlineBotCount = 0;
|
|
140
|
+
for (const adapterName of root.adapters) {
|
|
141
|
+
const adapter=root.inject(adapterName)
|
|
142
|
+
if (adapter instanceof Adapter) {
|
|
143
|
+
botCount += adapter.bots.size;
|
|
144
|
+
for (const bot of adapter.bots.values()) {
|
|
145
|
+
if (bot.$connected) onlineBotCount++;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// 统计命令和组件
|
|
151
|
+
const commandService = root.inject("command");
|
|
152
|
+
const componentService = root.inject("component");
|
|
153
|
+
const commandCount = commandService?.items.length || 0;
|
|
154
|
+
const componentCount = componentService?.byName.size || 0;
|
|
155
|
+
|
|
156
|
+
ctx.body = {
|
|
157
|
+
success: true,
|
|
158
|
+
data: {
|
|
159
|
+
plugins: { total: allPlugins.length, active: allPlugins.length },
|
|
160
|
+
bots: { total: botCount, online: onlineBotCount },
|
|
161
|
+
commands: commandCount,
|
|
162
|
+
components: componentCount,
|
|
163
|
+
uptime: process.uptime(),
|
|
164
|
+
memory: process.memoryUsage().heapUsed / 1024 / 1024,
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// 插件列表 API
|
|
170
|
+
router.get(`${base}/plugins`, async (ctx) => {
|
|
171
|
+
const plugins = root.children.map((p) => ({
|
|
172
|
+
name: p.name,
|
|
173
|
+
status: "active",
|
|
174
|
+
description: p.name,
|
|
175
|
+
features: p.getFeatures(),
|
|
176
|
+
}));
|
|
177
|
+
ctx.body = { success: true, data: plugins, total: plugins.length };
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// 插件详情 API
|
|
181
|
+
router.get(`${base}/plugins/:name`, async (ctx) => {
|
|
182
|
+
const pluginName = ctx.params.name;
|
|
183
|
+
const plugin = root.children.find((p) => p.name === pluginName);
|
|
184
|
+
|
|
185
|
+
if (!plugin) {
|
|
186
|
+
ctx.status = 404;
|
|
187
|
+
ctx.body = { success: false, error: "插件不存在" };
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const features = plugin.getFeatures();
|
|
192
|
+
|
|
193
|
+
// 收集 features 中已展示的上下文名(adapter/service items 的 name)
|
|
194
|
+
const shownContextNames = new Set<string>();
|
|
195
|
+
for (const f of features) {
|
|
196
|
+
if (f.name === 'adapter' || f.name === 'service') {
|
|
197
|
+
for (const item of f.items) {
|
|
198
|
+
if (item.name) shownContextNames.add(item.name);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
// 过滤掉已在 features 中展示的上下文
|
|
203
|
+
const contexts = Array.from(plugin.contexts.entries())
|
|
204
|
+
.filter(([name]) => !shownContextNames.has(name))
|
|
205
|
+
.map(([name]) => ({ name }));
|
|
206
|
+
|
|
207
|
+
ctx.body = {
|
|
208
|
+
success: true,
|
|
209
|
+
data: {
|
|
210
|
+
name: plugin.name,
|
|
211
|
+
filename: plugin.filePath,
|
|
212
|
+
filePath: plugin.filePath,
|
|
213
|
+
status: "active",
|
|
214
|
+
description: plugin.name,
|
|
215
|
+
features,
|
|
216
|
+
contexts,
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// 机器人列表 API
|
|
222
|
+
router.get(`${base}/bots`, async (ctx) => {
|
|
223
|
+
interface BotInfo {
|
|
224
|
+
name: string;
|
|
225
|
+
adapter: string;
|
|
226
|
+
connected: boolean;
|
|
227
|
+
status: "online" | "offline";
|
|
228
|
+
}
|
|
229
|
+
const bots: BotInfo[] = [];
|
|
230
|
+
|
|
231
|
+
for (const name of root.adapters) {
|
|
232
|
+
const adapter = root.inject(name);
|
|
233
|
+
if (adapter instanceof Adapter) {
|
|
234
|
+
for (const [botName, bot] of adapter.bots.entries()) {
|
|
235
|
+
bots.push({
|
|
236
|
+
name: botName,
|
|
237
|
+
adapter: name,
|
|
238
|
+
connected: bot.$connected || false,
|
|
239
|
+
status: bot.$connected ? "online" : "offline",
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
ctx.body = { success: true, data: bots, total: bots.length };
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// 配置 API
|
|
249
|
+
router.get(`${base}/config`, async (ctx) => {
|
|
250
|
+
ctx.body = { success: true, data: configService.get("zhin.config.yml") };
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
router.get(`${base}/config/:name`, async (ctx) => {
|
|
254
|
+
const { name } = ctx.params;
|
|
255
|
+
|
|
256
|
+
if (name === "app") {
|
|
257
|
+
ctx.body = { success: true, data: configService.get("zhin.config.yml") };
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const plugin = root.children.find((p) => p.name === name);
|
|
262
|
+
if (!plugin) {
|
|
263
|
+
ctx.status = 404;
|
|
264
|
+
ctx.body = { success: false, error: `Plugin ${name} not found` };
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
ctx.body = {
|
|
269
|
+
success: true,
|
|
270
|
+
data: { name: plugin.name, filePath: plugin.filePath },
|
|
271
|
+
};
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
router.post(`${base}/config/:name`, async (ctx) => {
|
|
275
|
+
const { name } = ctx.params;
|
|
276
|
+
|
|
277
|
+
if (name === "app") {
|
|
278
|
+
ctx.body = {
|
|
279
|
+
success: true,
|
|
280
|
+
message: "App configuration update not implemented yet",
|
|
281
|
+
};
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const plugin = root.children.find((p) => p.name === name);
|
|
286
|
+
if (!plugin) {
|
|
287
|
+
ctx.status = 404;
|
|
288
|
+
ctx.body = { success: false, error: `Plugin ${name} not found` };
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
ctx.body = {
|
|
293
|
+
success: true,
|
|
294
|
+
message: "Plugin configuration update not implemented yet",
|
|
295
|
+
};
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// Schema API - 获取所有插件 Schema
|
|
299
|
+
router.get(`${base}/schemas`, async (ctx) => {
|
|
300
|
+
const schemaService = root.inject('schema' as any);
|
|
301
|
+
const schemas: Record<string, any> = {};
|
|
302
|
+
if (schemaService) {
|
|
303
|
+
for (const [name, schema] of (schemaService as any).items.entries()) {
|
|
304
|
+
schemas[name] = schema.toJSON();
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
ctx.body = { success: true, data: schemas };
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// Schema API - 获取单个插件 Schema
|
|
311
|
+
router.get(`${base}/schema/:name`, async (ctx:RouterContext) => {
|
|
312
|
+
const schemaService = root.inject('schema' as any);
|
|
313
|
+
const { name } = ctx.params;
|
|
314
|
+
const schema = (schemaService as any)?.get(name);
|
|
315
|
+
|
|
316
|
+
if (!schema) {
|
|
317
|
+
ctx.body = { success: true, data: null };
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
ctx.body = { success: true, data: schema.toJSON() };
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
// 消息发送 API(由 Token 认证保护,供 zhin send 等调用)
|
|
325
|
+
router.post(`${base}/message/send`, async (ctx: RouterContext) => {
|
|
326
|
+
interface SendMessageBody {
|
|
327
|
+
context: string;
|
|
328
|
+
bot: string;
|
|
329
|
+
id: string;
|
|
330
|
+
type: string;
|
|
331
|
+
content: unknown;
|
|
332
|
+
}
|
|
333
|
+
const body = ctx.request.body as SendMessageBody;
|
|
334
|
+
const { context, bot, id, type, content } = body;
|
|
335
|
+
|
|
336
|
+
if (!context || !bot || !id || !type || content === undefined || content === null) {
|
|
337
|
+
ctx.status = 400;
|
|
338
|
+
ctx.body = {
|
|
339
|
+
success: false,
|
|
340
|
+
error: "Missing required fields: context, bot, id, type, content",
|
|
341
|
+
};
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
try {
|
|
346
|
+
const adapter = root.inject(context as keyof Plugin.Contexts);
|
|
347
|
+
if (!adapter || !(adapter instanceof Adapter)) {
|
|
348
|
+
ctx.status = 404;
|
|
349
|
+
ctx.body = { success: false, error: `Adapter not found or not sendable: ${context}` };
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
const normalizedContent =
|
|
353
|
+
typeof content === "string" ? content : Array.isArray(content) ? content : String(content);
|
|
354
|
+
const msgId = await adapter.sendMessage({
|
|
355
|
+
context,
|
|
356
|
+
bot,
|
|
357
|
+
id,
|
|
358
|
+
type: type as "private" | "group" | "channel",
|
|
359
|
+
content: normalizedContent,
|
|
360
|
+
});
|
|
361
|
+
ctx.body = {
|
|
362
|
+
success: true,
|
|
363
|
+
message: "Message sent successfully",
|
|
364
|
+
data: { context, bot, id, type, messageId: msgId, timestamp: new Date().toISOString() },
|
|
365
|
+
};
|
|
366
|
+
} catch (err: any) {
|
|
367
|
+
logger.error("message/send failed: " + (err?.message || String(err)));
|
|
368
|
+
ctx.status = 500;
|
|
369
|
+
ctx.body = { success: false, error: err?.message || String(err) };
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
server.listen({ host, port }, () => {
|
|
373
|
+
const address = server.address();
|
|
374
|
+
if (!address) return;
|
|
375
|
+
const visitAddress =
|
|
376
|
+
typeof address === "string"
|
|
377
|
+
? address
|
|
378
|
+
: `${host}:${address.port}`;
|
|
379
|
+
const apiUrl = `http://${visitAddress}${base}`;
|
|
380
|
+
|
|
381
|
+
logger.info(`HTTP 服务已启动 (port=${port}, api=${apiUrl}, token=${token.slice(0, 6)}...)`);
|
|
382
|
+
});
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
// 使用数据库服务(可选)
|
|
386
|
+
useContext("database", (database: DatabaseFeature) => {
|
|
387
|
+
const configService = root.inject("config")!;
|
|
388
|
+
const appConfig = configService.getPrimary<{ http?: HttpConfig }>();
|
|
389
|
+
const base = appConfig.http?.base || "/api";
|
|
390
|
+
|
|
391
|
+
// 日志 API - 获取日志
|
|
392
|
+
router.get(`${base}/logs`, async (ctx) => {
|
|
393
|
+
const limit = parseInt(ctx.query.limit as string) || 100;
|
|
394
|
+
const level = ctx.query.level as string;
|
|
395
|
+
|
|
396
|
+
const LogModel = database.models.get("SystemLog");
|
|
397
|
+
if (!LogModel) {
|
|
398
|
+
ctx.status = 500;
|
|
399
|
+
ctx.body = { success: false, error: "SystemLog model not found" };
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
let selection = LogModel.select();
|
|
404
|
+
if (level && level !== "all") {
|
|
405
|
+
selection = selection.where({ level });
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const logs = await selection.orderBy("timestamp", "DESC").limit(limit);
|
|
409
|
+
|
|
410
|
+
ctx.body = {
|
|
411
|
+
success: true,
|
|
412
|
+
data: logs.map((log: SystemLog) => ({
|
|
413
|
+
level: log.level,
|
|
414
|
+
name: log.name,
|
|
415
|
+
message: log.message,
|
|
416
|
+
source: log.source,
|
|
417
|
+
timestamp:
|
|
418
|
+
log.timestamp instanceof Date
|
|
419
|
+
? log.timestamp.toISOString()
|
|
420
|
+
: log.timestamp,
|
|
421
|
+
})),
|
|
422
|
+
total: logs.length,
|
|
423
|
+
};
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
// 日志 API - 清空日志
|
|
427
|
+
router.delete(`${base}/logs`, async (ctx) => {
|
|
428
|
+
const LogModel = database.models.get("SystemLog");
|
|
429
|
+
if (!LogModel) {
|
|
430
|
+
ctx.status = 500;
|
|
431
|
+
ctx.body = { success: false, error: "SystemLog model not found" };
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
await LogModel.delete({});
|
|
436
|
+
ctx.body = { success: true, message: "日志已清空" };
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
// 日志统计 API
|
|
440
|
+
router.get(`${base}/logs/stats`, async (ctx) => {
|
|
441
|
+
const LogModel = database.models.get("SystemLog");
|
|
442
|
+
if (!LogModel) {
|
|
443
|
+
ctx.status = 500;
|
|
444
|
+
ctx.body = { success: false, error: "SystemLog model not found" };
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const total = await LogModel.select();
|
|
449
|
+
const levels = ["info", "warn", "error"];
|
|
450
|
+
const levelCounts: Record<string, number> = {};
|
|
451
|
+
|
|
452
|
+
for (const level of levels) {
|
|
453
|
+
const count = await LogModel.select().where({ level });
|
|
454
|
+
levelCounts[level] = count.length;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const oldestLog = await LogModel.select("timestamp")
|
|
458
|
+
.orderBy("timestamp", "ASC")
|
|
459
|
+
.limit(1);
|
|
460
|
+
const oldestTimestamp =
|
|
461
|
+
oldestLog.length > 0
|
|
462
|
+
? oldestLog[0].timestamp instanceof Date
|
|
463
|
+
? oldestLog[0].timestamp.toISOString()
|
|
464
|
+
: oldestLog[0].timestamp
|
|
465
|
+
: null;
|
|
466
|
+
|
|
467
|
+
ctx.body = {
|
|
468
|
+
success: true,
|
|
469
|
+
data: { total: total.length, byLevel: levelCounts, oldestTimestamp },
|
|
470
|
+
};
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
// 日志清理 API
|
|
474
|
+
router.post(`${base}/logs/cleanup`, async (ctx:RouterContext) => {
|
|
475
|
+
const LogModel = database.models.get("SystemLog");
|
|
476
|
+
if (!LogModel) {
|
|
477
|
+
ctx.status = 500;
|
|
478
|
+
ctx.body = { success: false, error: "SystemLog model not found" };
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const { days, maxRecords } =
|
|
483
|
+
(ctx.request.body as { days?: number; maxRecords?: number }) || {};
|
|
484
|
+
let deletedCount = 0;
|
|
485
|
+
|
|
486
|
+
if (days && typeof days === "number" && days > 0) {
|
|
487
|
+
const cutoffDate = new Date();
|
|
488
|
+
cutoffDate.setDate(cutoffDate.getDate() - days);
|
|
489
|
+
const deleted = await LogModel.delete({ timestamp: { $lt: cutoffDate } });
|
|
490
|
+
deletedCount +=
|
|
491
|
+
typeof deleted === "number" ? deleted : deleted?.length || 0;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
if (maxRecords && typeof maxRecords === "number" && maxRecords > 0) {
|
|
495
|
+
const totalLogs = await LogModel.select();
|
|
496
|
+
if (totalLogs.length > maxRecords) {
|
|
497
|
+
const excessCount = totalLogs.length - maxRecords;
|
|
498
|
+
const oldestLogs = await LogModel.select("id", "timestamp")
|
|
499
|
+
.orderBy("timestamp", "ASC")
|
|
500
|
+
.limit(excessCount);
|
|
501
|
+
const idsToDelete = oldestLogs.map(
|
|
502
|
+
(log: Pick<SystemLog, "id" | "timestamp">) => log.id
|
|
503
|
+
);
|
|
504
|
+
|
|
505
|
+
if (idsToDelete.length > 0) {
|
|
506
|
+
const deleted = await LogModel.delete({ id: { $in: idsToDelete } });
|
|
507
|
+
deletedCount +=
|
|
508
|
+
typeof deleted === "number" ? deleted : deleted?.length || 0;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
ctx.body = {
|
|
514
|
+
success: true,
|
|
515
|
+
message: `已清理 ${deletedCount} 条日志`,
|
|
516
|
+
deletedCount,
|
|
517
|
+
};
|
|
518
|
+
});
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
// 注册 koa 和 router 上下文
|
|
522
|
+
provide({
|
|
523
|
+
name: "koa",
|
|
524
|
+
description: "koa instance",
|
|
525
|
+
value: koa,
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
provide({
|
|
529
|
+
name: "router",
|
|
530
|
+
description: "koa router",
|
|
531
|
+
value: router,
|
|
532
|
+
});
|
|
533
|
+
// 应用中间件
|
|
534
|
+
koa.use(body()).use(router.routes()).use(router.allowedMethods());
|
package/src/router.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Layer, RouterOptions } from "@koa/router";
|
|
2
|
+
import KoaRouter, { RouterContext as KoaRouterContext } from "@koa/router";
|
|
3
|
+
import * as http from "http";
|
|
4
|
+
import { ServerOptions, WebSocketServer } from "ws";
|
|
5
|
+
import { parse } from "url";
|
|
6
|
+
|
|
7
|
+
type Path = string | RegExp;
|
|
8
|
+
|
|
9
|
+
// 工具函数:从数组中移除元素
|
|
10
|
+
const remove = <T>(arr: T[], item: T): boolean => {
|
|
11
|
+
const index = arr.indexOf(item);
|
|
12
|
+
if (index !== -1) {
|
|
13
|
+
arr.splice(index, 1);
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
return false;
|
|
17
|
+
};
|
|
18
|
+
export type RouterContext = KoaRouterContext & {
|
|
19
|
+
request: Request & {
|
|
20
|
+
body: any;
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
export class Router extends KoaRouter {
|
|
24
|
+
wsStack: WebSocketServer[] = [];
|
|
25
|
+
whiteList: Path[] = [];
|
|
26
|
+
|
|
27
|
+
constructor(public server: http.Server, options?: RouterOptions) {
|
|
28
|
+
super(options);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
register(...args: Parameters<KoaRouter["register"]>): ReturnType<KoaRouter["register"]> {
|
|
32
|
+
const path: Path = args[0] as any;
|
|
33
|
+
this.whiteList.push(path);
|
|
34
|
+
return super.register(...args);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
destroy(layer: Layer) {
|
|
38
|
+
remove(this.stack, layer);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
destroyWs(wsServer: WebSocketServer) {
|
|
42
|
+
wsServer.close();
|
|
43
|
+
remove(this.wsStack, wsServer);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
ws(path: string, options: Omit<ServerOptions, "noServer" | "path"> = {}): WebSocketServer {
|
|
47
|
+
const wsServer = new WebSocketServer({
|
|
48
|
+
noServer: true,
|
|
49
|
+
path,
|
|
50
|
+
...options,
|
|
51
|
+
});
|
|
52
|
+
this.wsStack.push(wsServer);
|
|
53
|
+
|
|
54
|
+
this.server.on("upgrade", (request, socket, head) => {
|
|
55
|
+
const { pathname } = parse(request.url!);
|
|
56
|
+
if (this.wsStack.findIndex((wss) => wss.options.path === path) === -1) {
|
|
57
|
+
socket.destroy();
|
|
58
|
+
} else if (pathname === path) {
|
|
59
|
+
wsServer.handleUpgrade(request, socket, head, (ws) => {
|
|
60
|
+
wsServer.emit("connection", ws, request);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
return wsServer;
|
|
65
|
+
}
|
|
66
|
+
}
|