Files
UHD_Media_Tool/apps/server/src/modules/logs/index.ts
T

131 lines
4.9 KiB
TypeScript

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();