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> = []; 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((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();