feat(server): 日志模块——任务 / 定时与自动 / 登录 / 审计合并查询与 SSE 流,登录失败留审计
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
1 parent
01c68f9771
commit
744252b969
8 files changed
+823
-2
No files matched your search
@@ -0,0 +1,130 @@
|
||||
import { createRoute } from "@hono/zod-openapi";
|
||||
import { Api, apiResponse, LOG_STREAM_EVENT, type LogFilter, LogStreamQuery } from "@umt/contracts";
|
||||
import type { Context } from "hono";
|
||||
import { streamSSE } from "hono/streaming";
|
||||
import { requireScope } from "../../http/auth.ts";
|
||||
import { ApiException } from "../../http/errors.ts";
|
||||
import type { ServerModule } from "../types.ts";
|
||||
import { type LogQueries, logQueries } from "./query.ts";
|
||||
import { createLogStreamPoller, STREAM_POLL_MS } from "./stream.ts";
|
||||
|
||||
export { decodeCursor, encodeCursor, logQueries } from "./query.ts";
|
||||
export { createLogStreamPoller } from "./stream.ts";
|
||||
|
||||
/**
|
||||
* 日志模块(界面第二轮 V 线,docs/runbook/logs.md):
|
||||
* - GET /logs:任务 / 定时与自动 / 登录 / 审计合并成一份列表(查询时 union,不新建表),仅 admin;
|
||||
* - GET /logs/stream:同一组筛选的 SSE 流,事件名 log.entry(新日志或状态变化,客户端按 id 覆盖)。
|
||||
*/
|
||||
export interface LogsModuleOptions {
|
||||
/** 引擎 schema(测试覆盖);缺省 openworkflow */
|
||||
engineSchema?: string;
|
||||
/** 流的轮询间隔,缺省 2 秒 */
|
||||
pollMs?: number;
|
||||
keepaliveMs?: number;
|
||||
}
|
||||
|
||||
export function createLogsModule(opts: LogsModuleOptions = {}): ServerModule {
|
||||
return {
|
||||
name: "logs",
|
||||
registerRoutes(app, ctx) {
|
||||
let q: LogQueries | null = null;
|
||||
const queries = () => {
|
||||
q ??= logQueries(ctx.db, { engineSchema: opts.engineSchema });
|
||||
return q;
|
||||
};
|
||||
|
||||
const list = Api.logs.list;
|
||||
app.openapi(
|
||||
createRoute({
|
||||
method: list.method,
|
||||
path: list.path,
|
||||
summary: list.summary,
|
||||
tags: ["logs"],
|
||||
middleware: [requireScope(list.scope)] as never,
|
||||
request: { query: list.query as never },
|
||||
responses: {
|
||||
200: { description: list.summary, content: { "application/json": { schema: apiResponse(list.response) } } },
|
||||
},
|
||||
}),
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 入参已由 zod-openapi 校验
|
||||
(async (c: any) => {
|
||||
const query = c.req.valid("query");
|
||||
const data = list.response.parse(await queries().list(query));
|
||||
return c.json({ ok: true, data }, 200);
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 同上
|
||||
}) as any,
|
||||
);
|
||||
|
||||
const stream = Api.logs.stream;
|
||||
app.openapi(
|
||||
createRoute({
|
||||
method: stream.method,
|
||||
path: stream.path,
|
||||
summary: stream.summary,
|
||||
tags: ["logs"],
|
||||
middleware: [requireScope(stream.scope)] as never,
|
||||
responses: {
|
||||
200: { description: stream.summary, content: { "text/event-stream": { schema: { type: "string" } } } },
|
||||
},
|
||||
}),
|
||||
// SSE 响应不走 JSON 信封
|
||||
(async (c: Context) => {
|
||||
const parsed = LogStreamQuery.safeParse(c.req.query());
|
||||
if (!parsed.success) throw new ApiException("bad_request", parsed.error.issues[0]?.message ?? "筛选参数无效");
|
||||
const { since, ...filter } = parsed.data;
|
||||
const poller = createLogStreamPoller(queries(), filter as LogFilter, {
|
||||
...(since ? { since: new Date(since) } : {}),
|
||||
});
|
||||
const log = ctx.logger.child({ component: "logs-stream" });
|
||||
return streamSSE(c, async (s) => {
|
||||
let closed = false;
|
||||
let wake: (() => void) | null = null;
|
||||
s.onAbort(() => {
|
||||
closed = true;
|
||||
wake?.();
|
||||
});
|
||||
const keepalive = setInterval(() => {
|
||||
void s.write(": keepalive\n\n").catch(() => {
|
||||
closed = true;
|
||||
});
|
||||
}, opts.keepaliveMs ?? 15_000);
|
||||
await s.writeSSE({ event: "ready", data: JSON.stringify({ at: new Date().toISOString() }) });
|
||||
try {
|
||||
while (!closed) {
|
||||
let items: Awaited<ReturnType<typeof poller.poll>> = [];
|
||||
try {
|
||||
items = await poller.poll();
|
||||
} catch (err) {
|
||||
// 库暂时不可达:这一轮跳过,下一轮再查
|
||||
log.debug({ err }, "日志流查询失败");
|
||||
}
|
||||
for (const e of items) {
|
||||
if (closed) break;
|
||||
await s.writeSSE({ id: e.id, event: LOG_STREAM_EVENT, data: JSON.stringify(e) }).catch(() => {
|
||||
closed = true;
|
||||
});
|
||||
}
|
||||
if (closed) break;
|
||||
await new Promise<void>((r) => {
|
||||
const t = setTimeout(r, opts.pollMs ?? STREAM_POLL_MS);
|
||||
wake = () => {
|
||||
clearTimeout(t);
|
||||
r();
|
||||
};
|
||||
});
|
||||
wake = null;
|
||||
}
|
||||
} finally {
|
||||
clearInterval(keepalive);
|
||||
}
|
||||
});
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 同上
|
||||
}) as any,
|
||||
);
|
||||
},
|
||||
registerTasks() {},
|
||||
};
|
||||
}
|
||||
|
||||
export const logsModule = createLogsModule();
|
||||
Reference in new issue
Block a user