From fb0229ba06dc7888d39d4ebeb212bb0999b57f46 Mon Sep 17 00:00:00 2001 From: inman Date: Wed, 3 Jun 2026 11:49:45 +0800 Subject: [PATCH] Add backend log management --- .env.example | 2 + README.zh-CN.md | 4 + app/api/logs/route.ts | 40 ++++++ app/globals.css | 169 +++++++++++++++++++++++++ app/logs/page.tsx | 7 ++ components/app-shell.tsx | 2 + components/log-manager.tsx | 245 +++++++++++++++++++++++++++++++++++++ docs/DEPLOYMENT.md | 11 ++ lib/server/api.ts | 21 +++- lib/server/log-manager.ts | 210 +++++++++++++++++++++++++++++++ lib/server/runtime.ts | 7 +- lib/server/task-manager.ts | 14 +++ middleware.ts | 2 + tests/log-manager.test.ts | 72 +++++++++++ 14 files changed, 804 insertions(+), 2 deletions(-) create mode 100644 app/api/logs/route.ts create mode 100644 app/logs/page.tsx create mode 100644 components/log-manager.tsx create mode 100644 lib/server/log-manager.ts create mode 100644 tests/log-manager.test.ts diff --git a/.env.example b/.env.example index d3531c5..ae9443e 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,8 @@ PORT=3000 HOSTNAME=127.0.0.1 NEXT_PUBLIC_APP_URL=http://127.0.0.1:3000 ZHINIAN_RUNTIME_DIR=.runtime +ZHINIAN_LOG_DIR= +ZHINIAN_LOG_MAX_BYTES=5242880 ZHINIAN_PUBLIC_BASE_URL=http://127.0.0.1:3000 # Account login / Web SSO. diff --git a/README.zh-CN.md b/README.zh-CN.md index 57e5bca..43660b0 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -13,6 +13,7 @@ - 统一创作入口:`/create` - 结果资产管理:`/assets` - 服务与引擎配置:`/settings` +- 后台日志管理:`/logs` - 图片生成:即梦图片生成 4.6 或 EvoLink GPT Image 2 - 图片编辑:局部重绘、智能超清 - 视频生成:Seedance 2.0 @@ -67,6 +68,7 @@ bash scripts/deploy.sh - 创建 `.env.local` - 创建 `.runtime/data`、`.runtime/uploads`、`.runtime/generated-results` +- 创建 `.runtime/logs` - 构建 Docker 镜像 - 使用 `docker compose up -d --build` 后台启动 Web 服务和 Worker 服务 @@ -114,6 +116,7 @@ npm run worker ``` 生产环境建议把 `NEXT_PUBLIC_APP_URL` 设置成真实域名或公网地址,配置 `ZHINIAN_INTERNAL_WORKER_TOKEN`,并把 `.runtime/` 做定期备份。 +部署后如果页面或接口报错,登录后台访问 `/logs` 查看最近错误、请求路径、状态码和错误栈。日志文件默认在 `.runtime/logs/server-events.jsonl`。 ## 常用命令 @@ -136,6 +139,7 @@ npm run info | `/create?mode=inpaint` | 局部重绘模式 | | `/create?mode=upscale` | 智能超清模式 | | `/assets` | 历史任务与资产 | +| `/logs` | 后台日志管理 | | `/settings` | 接口、引擎和服务配置 | ## 账户登录 / SSO diff --git a/app/api/logs/route.ts b/app/api/logs/route.ts new file mode 100644 index 0000000..e4484e2 --- /dev/null +++ b/app/api/logs/route.ts @@ -0,0 +1,40 @@ +import { jsonError, jsonOk } from "@/lib/server/api"; +import { appLogFilePath, appLogMaxBytes, clearAppLogs, listAppLogs, type AppLogLevel } from "@/lib/server/log-manager"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET(request: Request) { + try { + const url = new URL(request.url); + const level = parseLevel(url.searchParams.get("level")); + const q = url.searchParams.get("q") || undefined; + const limit = Number(url.searchParams.get("limit") || 100); + const entries = await listAppLogs({ + level, + q, + limit: Number.isFinite(limit) ? limit : 100 + }); + return jsonOk({ + entries, + logPath: appLogFilePath(), + maxBytes: appLogMaxBytes() + }); + } catch (error) { + return jsonError(error, 500, { request, source: "api.logs" }); + } +} + +export async function DELETE(request: Request) { + try { + await clearAppLogs(); + return jsonOk({ ok: true }); + } catch (error) { + return jsonError(error, 500, { request, source: "api.logs" }); + } +} + +function parseLevel(value: string | null): AppLogLevel | "all" { + if (value === "info" || value === "warning" || value === "error") return value; + return "all"; +} diff --git a/app/globals.css b/app/globals.css index 5ae54f5..a088d04 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1207,6 +1207,163 @@ h3 { gap: 8px; } +.log-manager { + display: grid; + gap: 14px; +} + +.log-actions { + display: grid; + grid-template-columns: minmax(240px, 0.8fr) minmax(260px, 1fr) auto; + align-items: center; + gap: 12px; +} + +.log-tabs { + width: 100%; +} + +.log-search { + min-width: 0; + height: 42px; + display: flex; + align-items: center; + gap: 8px; + border: 1px solid var(--line); + border-radius: 8px; + background: #ffffff; + padding: 0 12px; +} + +.log-search svg { + width: 17px; + height: 17px; + color: var(--muted); + flex: 0 0 auto; +} + +.log-search input { + min-width: 0; + width: 100%; + border: 0; + outline: 0; + background: transparent; + color: var(--ink); +} + +.log-storage { + display: grid; + grid-template-columns: minmax(0, 1.5fr) repeat(2, minmax(150px, 0.6fr)); + gap: 10px; +} + +.log-storage div { + min-width: 0; + display: grid; + gap: 4px; + padding: 10px 12px; + border: 1px solid var(--line); + border-radius: 8px; + background: #f8faf8; +} + +.log-storage span { + color: var(--muted); + font-size: 12px; + font-weight: 700; +} + +.log-storage strong { + min-width: 0; + overflow-wrap: anywhere; + font-size: 13px; +} + +.log-list { + display: grid; + gap: 10px; +} + +.log-row { + min-width: 0; + display: grid; + gap: 10px; + padding: 12px; + border: 1px solid var(--line); + border-radius: 8px; + background: #ffffff; +} + +.log-row-error { + border-color: #e6b8ae; + background: #fffaf8; +} + +.log-row-warning { + border-color: #ead2a4; + background: #fffaf0; +} + +.log-row-main { + min-width: 0; + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: start; + gap: 10px; +} + +.log-message { + min-width: 0; + display: grid; + gap: 4px; +} + +.log-message strong { + overflow-wrap: anywhere; + font-size: 14px; +} + +.log-message small { + min-width: 0; + color: var(--muted); + font-size: 12px; + overflow-wrap: anywhere; +} + +.log-detail { + min-width: 0; +} + +.log-detail summary { + cursor: pointer; + color: var(--green-dark); + font-size: 12px; + font-weight: 800; +} + +.log-detail pre { + max-height: 360px; + overflow: auto; + margin: 8px 0 0; + padding: 10px; + border: 1px solid var(--line); + border-radius: 8px; + background: #f8fbfb; + color: #27312d; + font-size: 12px; + line-height: 1.55; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.log-empty { + display: grid; + place-items: center; + min-height: 160px; + color: var(--muted); + font-weight: 800; +} + .image-upload-button { flex: 0 0 auto; } @@ -2221,6 +2378,11 @@ button:active:not(:disabled), grid-template-columns: repeat(2, minmax(0, 1fr)); } + .log-actions, + .log-storage { + grid-template-columns: 1fr; + } + .settings-engine-row { display: grid; grid-template-columns: 1fr; @@ -2278,6 +2440,7 @@ button:active:not(:disabled), .create-mode-bar, .asset-manager-toolbar, + .log-actions, .settings-actions { padding: 8px; } @@ -2450,6 +2613,11 @@ button:active:not(:disabled), display: grid; } + .log-tabs { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + .asset-view-switch { grid-template-columns: repeat(2, minmax(0, 1fr)); } @@ -2459,6 +2627,7 @@ button:active:not(:disabled), } .asset-view-switch button, + .log-tabs button, .settings-tabs button { min-width: 0; min-height: 38px; diff --git a/app/logs/page.tsx b/app/logs/page.tsx new file mode 100644 index 0000000..7f581e2 --- /dev/null +++ b/app/logs/page.tsx @@ -0,0 +1,7 @@ +import { LogManager } from "@/components/log-manager"; + +export const dynamic = "force-dynamic"; + +export default function LogsPage() { + return ; +} diff --git a/components/app-shell.tsx b/components/app-shell.tsx index 4a9f95a..42b372c 100644 --- a/components/app-shell.tsx +++ b/components/app-shell.tsx @@ -8,6 +8,7 @@ import { Archive, LogIn, LogOut, + ScrollText, Settings, Sparkles, UserCircle @@ -19,6 +20,7 @@ import type { AuthUser } from "@/lib/auth/session"; const nav = [ { href: "/create", label: "创作", icon: Sparkles }, { href: "/assets", label: "结果", icon: Archive }, + { href: "/logs", label: "日志", icon: ScrollText }, { href: "/settings", label: "设置", icon: Settings } ]; diff --git a/components/log-manager.tsx b/components/log-manager.tsx new file mode 100644 index 0000000..b02d939 --- /dev/null +++ b/components/log-manager.tsx @@ -0,0 +1,245 @@ +"use client"; + +import { FormEvent, useEffect, useMemo, useRef, useState } from "react"; +import { Loader2, RefreshCw, Search, Trash2 } from "lucide-react"; +import { crossfadeIn, pulseFeedback, revealChildren, runScopedMotion } from "@/lib/ui/motion"; + +type LogLevel = "info" | "warning" | "error"; + +type LogEntry = { + id: string; + createdAt: string; + level: LogLevel; + source: string; + message: string; + status?: number; + method?: string; + path?: string; + stack?: string; + details?: unknown; +}; + +type LogPayload = { + entries: LogEntry[]; + logPath: string; + maxBytes: number; +}; + +const LEVEL_TABS: Array<{ id: LogLevel | "all"; label: string }> = [ + { id: "all", label: "全部" }, + { id: "error", label: "错误" }, + { id: "warning", label: "警告" }, + { id: "info", label: "信息" } +]; + +export function LogManager() { + const [payload, setPayload] = useState(null); + const [level, setLevel] = useState("all"); + const [query, setQuery] = useState(""); + const [draftQuery, setDraftQuery] = useState(""); + const [loading, setLoading] = useState(true); + const [clearing, setClearing] = useState(false); + const [error, setError] = useState(null); + const [message, setMessage] = useState(null); + const logsRef = useRef(null); + const contentRef = useRef(null); + const feedbackRef = useRef(null); + + useEffect(() => { + void loadLogs(); + }, [level, query]); + + useEffect(() => { + return runScopedMotion(logsRef, (scope) => revealChildren(scope)); + }, []); + + useEffect(() => { + crossfadeIn(contentRef.current); + }, [payload?.entries]); + + useEffect(() => { + pulseFeedback(feedbackRef.current); + }, [error, message]); + + const stats = useMemo(() => { + const entries = payload?.entries || []; + return { + total: entries.length, + error: entries.filter((entry) => entry.level === "error").length, + warning: entries.filter((entry) => entry.level === "warning").length, + info: entries.filter((entry) => entry.level === "info").length + }; + }, [payload]); + + async function loadLogs() { + setLoading(true); + setError(null); + try { + const params = new URLSearchParams({ level, limit: "200" }); + if (query.trim()) params.set("q", query.trim()); + const response = await fetch(`/api/logs?${params.toString()}`, { cache: "no-store" }); + const nextPayload = await response.json(); + if (!response.ok) throw new Error(nextPayload.error || "读取日志失败"); + setPayload(nextPayload); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + } + + async function clearLogs() { + if (!window.confirm("确定清空后台日志?")) return; + setClearing(true); + setError(null); + setMessage(null); + try { + const response = await fetch("/api/logs", { method: "DELETE" }); + const result = await response.json(); + if (!response.ok) throw new Error(result.error || "清空日志失败"); + setMessage("日志已清空。"); + await loadLogs(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setClearing(false); + } + } + + function submitSearch(event: FormEvent) { + event.preventDefault(); + setQuery(draftQuery.trim()); + } + + return ( +
+
+
+

日志管理

+
+
+ {stats.total} 条 + {stats.error} 错误 + {stats.warning} 警告 +
+
+ +
+
+ {LEVEL_TABS.map((tab) => ( + + ))} +
+
+
+ + {error || message ? ( +
+ {error ?
{error}
: null} + {message ?
{message}
: null} +
+ ) : null} + +
+
+ 日志文件 + {payload?.logPath || ".runtime/logs/server-events.jsonl"} +
+
+ 单文件上限 + {formatBytes(payload?.maxBytes || 0)} +
+
+ 当前筛选 + {levelLabel(level)} / {query || "全部"} +
+
+ +
+ {loading && !payload ? ( +
+ + 正在读取日志 +
+ ) : payload?.entries.length ? ( + payload.entries.map((entry) => ) + ) : ( +
暂无日志
+ )} +
+
+ ); +} + +function LogRow({ entry }: { entry: LogEntry }) { + const details = detailText(entry); + return ( +
+
+ {levelLabel(entry.level)} +
+ {entry.message} + {formatTime(entry.createdAt)} · {entry.source}{entry.status ? ` · ${entry.status}` : ""}{entry.method || entry.path ? ` · ${[entry.method, entry.path].filter(Boolean).join(" ")}` : ""} +
+
+ {details ? ( +
+ 详情 +
{details}
+
+ ) : null} +
+ ); +} + +function detailText(entry: LogEntry): string { + const parts: string[] = []; + if (entry.stack) parts.push(entry.stack); + if (entry.details !== undefined) parts.push(JSON.stringify(entry.details, null, 2)); + return parts.join("\n\n"); +} + +function levelLabel(level: LogLevel | "all"): string { + if (level === "error") return "错误"; + if (level === "warning") return "警告"; + if (level === "info") return "信息"; + return "全部"; +} + +function formatTime(value: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return date.toLocaleString("zh-CN", { hour12: false }); +} + +function formatBytes(value: number): string { + if (!value) return "-"; + if (value >= 1024 * 1024) return `${(value / 1024 / 1024).toFixed(1)} MB`; + if (value >= 1024) return `${(value / 1024).toFixed(1)} KB`; + return `${value} B`; +} diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index f0d3e3e..cf7d562 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -21,6 +21,7 @@ bash scripts/deploy.sh - 从 `.env.example` 创建 `.env.local`(如果不存在) - 创建 `.runtime/data`、`.runtime/uploads`、`.runtime/generated-results` +- 创建 `.runtime/logs` - 构建镜像 - 启动 `zhinian-aigc` Web 服务 - 启动 `zhinian-worker` 任务 Worker @@ -95,6 +96,14 @@ docker compose restart docker compose down ``` +Web 后台可在登录后访问: + +```text +https://你的域名/logs +``` + +日志默认写入 `.runtime/logs/server-events.jsonl`,用于查看 API 500 错误、Worker 任务异常、错误栈和请求路径。可通过环境变量 `ZHINIAN_LOG_DIR` 调整目录,通过 `ZHINIAN_LOG_MAX_BYTES` 调整单文件轮转大小。 + 更新部署: ```bash @@ -123,6 +132,7 @@ Docker Compose 会挂载: ``` 本地 JSON 数据层、上传文件和生成结果都会放在 `.runtime/` 下。生产环境如果未启用 Supabase/Postgres,请定期备份该目录。 +服务端日志也会放在 `.runtime/logs/` 下,建议和运行时数据一起备份或接入服务器日志采集。 建议备份: @@ -174,6 +184,7 @@ curl https://你的域名/api/v1/openapi.json - 未登录访问 Web 页面会跳转到 `/auth/login` - 登录后 Web 页面可访问 - `/api/health` 返回 `ok: true` +- `/logs` 可查看后台错误日志 - `/api/v1/capabilities` 使用 API Key 可访问 - `zhinian-worker` 日志持续输出 `claimed=...` - OSS、EvoLink、火山、Seedance 密钥按业务需要配置完成 diff --git a/lib/server/api.ts b/lib/server/api.ts index e4dd464..dc9c90c 100644 --- a/lib/server/api.ts +++ b/lib/server/api.ts @@ -1,11 +1,30 @@ import { NextResponse } from "next/server"; +import { recordAppLog } from "@/lib/server/log-manager"; export function jsonOk(payload: T, init?: ResponseInit) { return NextResponse.json(payload, init); } -export function jsonError(error: unknown, status = 400) { +export type JsonErrorOptions = { + request?: Request; + source?: string; + details?: unknown; + logClientErrors?: boolean; +}; + +export async function jsonError(error: unknown, status = 400, options: JsonErrorOptions = {}) { const resolvedStatus = statusFromError(error) || status; + if (resolvedStatus >= 500 || options.logClientErrors) { + await recordAppLog({ + level: resolvedStatus >= 500 ? "error" : "warning", + source: options.source || "api", + message: error instanceof Error ? error.message : String(error), + error, + status: resolvedStatus, + request: options.request, + details: options.details + }).catch(() => undefined); + } return NextResponse.json({ error: error instanceof Error ? error.message : String(error) }, { status: resolvedStatus }); diff --git a/lib/server/log-manager.ts b/lib/server/log-manager.ts new file mode 100644 index 0000000..268bd6a --- /dev/null +++ b/lib/server/log-manager.ts @@ -0,0 +1,210 @@ +import { randomUUID } from "node:crypto"; +import { appendFile, mkdir, readFile, rename, stat, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { logDir } from "@/lib/server/runtime"; + +export type AppLogLevel = "info" | "warning" | "error"; + +export type AppLogEntry = { + id: string; + createdAt: string; + level: AppLogLevel; + source: string; + message: string; + status?: number; + method?: string; + path?: string; + stack?: string; + details?: unknown; +}; + +export type AppLogInput = { + level?: AppLogLevel; + source: string; + message?: string; + error?: unknown; + status?: number; + request?: Request; + details?: unknown; +}; + +export type AppLogListFilters = { + level?: AppLogLevel | "all"; + q?: string; + source?: string; + limit?: number; +}; + +const LOG_FILE_NAME = "server-events.jsonl"; +const DEFAULT_MAX_BYTES = 5 * 1024 * 1024; +const MAX_ENTRY_TEXT_LENGTH = 20_000; +let writeQueue: Promise = Promise.resolve(); + +export function appLogFilePath(): string { + return join(logDir(), LOG_FILE_NAME); +} + +export function appLogMaxBytes(): number { + const parsed = Number(process.env.ZHINIAN_LOG_MAX_BYTES); + return Number.isFinite(parsed) && parsed > 1024 ? Math.floor(parsed) : DEFAULT_MAX_BYTES; +} + +export async function recordAppLog(input: AppLogInput): Promise { + const entry = normalizeLogEntry(input); + writeQueue = writeQueue + .catch(() => undefined) + .then(async () => { + await mkdir(logDir(), { recursive: true }); + await rotateLogIfNeeded(); + await appendFile(appLogFilePath(), `${JSON.stringify(entry)}\n`, "utf8"); + }); + await writeQueue; + return entry; +} + +export async function listAppLogs(filters: AppLogListFilters = {}): Promise { + const limit = Math.max(1, Math.min(filters.limit || 100, 500)); + const level = filters.level || "all"; + const q = (filters.q || "").trim().toLowerCase(); + const source = (filters.source || "").trim().toLowerCase(); + const filePath = appLogFilePath(); + let text = ""; + try { + text = await readFile(filePath, "utf8"); + } catch (error) { + if (isNotFound(error)) return []; + throw error; + } + + const entries: AppLogEntry[] = []; + const lines = text.split("\n").filter(Boolean).reverse(); + for (const line of lines) { + const entry = parseLogLine(line); + if (!entry) continue; + if (level !== "all" && entry.level !== level) continue; + if (source && !entry.source.toLowerCase().includes(source)) continue; + if (q && !logEntryMatches(entry, q)) continue; + entries.push(entry); + if (entries.length >= limit) break; + } + return entries; +} + +export async function clearAppLogs(): Promise { + await mkdir(logDir(), { recursive: true }); + await writeFile(appLogFilePath(), "", "utf8"); +} + +export function normalizeLogEntry(input: AppLogInput): AppLogEntry { + const error = input.error; + const requestInfo = input.request ? requestLogInfo(input.request) : {}; + const message = sanitizeText(input.message || errorMessage(error) || "Unknown log event"); + return { + id: `log_${randomUUID().replace(/-/g, "").slice(0, 18)}`, + createdAt: new Date().toISOString(), + level: input.level || (input.status && input.status >= 500 ? "error" : "info"), + source: sanitizeText(input.source || "server"), + message, + status: input.status, + method: requestInfo.method, + path: requestInfo.path, + stack: errorStack(error), + details: sanitizeForLog(input.details) + }; +} + +function parseLogLine(line: string): AppLogEntry | null { + try { + const parsed = JSON.parse(line) as Partial; + if (!parsed.id || !parsed.createdAt || !parsed.level || !parsed.source || !parsed.message) return null; + if (!["info", "warning", "error"].includes(parsed.level)) return null; + return parsed as AppLogEntry; + } catch { + return null; + } +} + +async function rotateLogIfNeeded(): Promise { + const filePath = appLogFilePath(); + try { + const current = await stat(filePath); + if (current.size < appLogMaxBytes()) return; + await rename(filePath, `${filePath}.1`); + } catch (error) { + if (!isNotFound(error)) throw error; + } +} + +function requestLogInfo(request: Request): { method?: string; path?: string } { + try { + const url = new URL(request.url); + return { + method: request.method, + path: `${url.pathname}${url.search}` + }; + } catch { + return { method: request.method }; + } +} + +function logEntryMatches(entry: AppLogEntry, q: string): boolean { + const haystack = [ + entry.message, + entry.source, + entry.path, + entry.method, + entry.status?.toString(), + entry.stack, + typeof entry.details === "string" ? entry.details : JSON.stringify(entry.details || "") + ].join(" ").toLowerCase(); + return haystack.includes(q); +} + +function errorMessage(error: unknown): string | undefined { + if (!error) return undefined; + if (error instanceof Error) return error.message; + return String(error); +} + +function errorStack(error: unknown): string | undefined { + if (!(error instanceof Error) || !error.stack) return undefined; + return truncateText(sanitizeText(error.stack)); +} + +function sanitizeForLog(value: unknown, depth = 0, seen = new WeakSet()): unknown { + if (value === null || value === undefined) return value; + if (typeof value === "string") return truncateText(sanitizeText(value)); + if (typeof value === "number" || typeof value === "boolean") return value; + if (typeof value === "bigint") return value.toString(); + if (typeof value !== "object") return sanitizeText(String(value)); + if (depth >= 5) return "[truncated]"; + if (seen.has(value)) return "[circular]"; + seen.add(value); + if (Array.isArray(value)) return value.slice(0, 50).map((item) => sanitizeForLog(item, depth + 1, seen)); + + const output: Record = {}; + for (const [key, item] of Object.entries(value as Record).slice(0, 80)) { + output[key] = isSensitiveKey(key) ? "[redacted]" : sanitizeForLog(item, depth + 1, seen); + } + return output; +} + +function sanitizeText(text: string): string { + return truncateText(text) + .replace(/(authorization\s*[:=]\s*)[^\s,;}]+/gi, "$1[redacted]") + .replace(/(bearer\s+)[A-Za-z0-9._~+/=-]+/gi, "$1[redacted]") + .replace(/((?:api[_-]?key|access[_-]?key|secret|token|password|client[_-]?secret)\s*[:=]\s*)[^\s,;}]+/gi, "$1[redacted]") + .replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, "[jwt-redacted]"); +} + +function truncateText(text: string): string { + return text.length > MAX_ENTRY_TEXT_LENGTH ? `${text.slice(0, MAX_ENTRY_TEXT_LENGTH)}...[truncated]` : text; +} + +function isSensitiveKey(key: string): boolean { + return /api[_-]?key|access[_-]?key|secret|token|password|authorization|credential/i.test(key); +} + +function isNotFound(error: unknown): boolean { + return typeof error === "object" && error !== null && "code" in error && (error as { code?: unknown }).code === "ENOENT"; +} diff --git a/lib/server/runtime.ts b/lib/server/runtime.ts index 987b342..00e22e5 100644 --- a/lib/server/runtime.ts +++ b/lib/server/runtime.ts @@ -23,11 +23,16 @@ export function resultDir(): string { return process.env.ZHINIAN_RESULT_DIR || join(runtimeDir(), "generated-results"); } +export function logDir(): string { + return process.env.ZHINIAN_LOG_DIR || join(runtimeDir(), "logs"); +} + export async function ensureRuntimeDirs(): Promise { await Promise.all([ mkdir(dataDir(), { recursive: true }), mkdir(uploadDir(), { recursive: true }), - mkdir(resultDir(), { recursive: true }) + mkdir(resultDir(), { recursive: true }), + mkdir(logDir(), { recursive: true }) ]); } diff --git a/lib/server/task-manager.ts b/lib/server/task-manager.ts index 50cd365..5d93fc0 100644 --- a/lib/server/task-manager.ts +++ b/lib/server/task-manager.ts @@ -7,6 +7,7 @@ import { } from "@/lib/server/data-store"; import { advanceImageJob } from "@/lib/server/generation-service"; import { advanceVideoJob } from "@/lib/server/video-generation-service"; +import { recordAppLog } from "@/lib/server/log-manager"; import { requestOrigin } from "@/lib/server/runtime"; import { deliverJobWebhook } from "@/lib/server/webhook"; import type { GenerationJob, GenerationStatus } from "@/lib/types"; @@ -53,6 +54,19 @@ export async function runWorkerTick(input: { action: settled.action }); } catch (error) { + await recordAppLog({ + level: "error", + source: "worker.job", + message: `任务处理失败:${job.id}`, + error, + details: { + jobId: job.id, + capability: job.capability, + provider: job.provider, + attempts: job.attempts, + workerId + } + }).catch(() => undefined); const failed = await updateGenerationJob(job.id, { status: "failed", error: { diff --git a/middleware.ts b/middleware.ts index 133f7a5..3b718ed 100644 --- a/middleware.ts +++ b/middleware.ts @@ -32,12 +32,14 @@ export const config = { "/", "/create/:path*", "/assets/:path*", + "/logs/:path*", "/settings/:path*", "/image-edit/:path*", "/uploads/:path*", "/generated-results/:path*", "/api/assets/:path*", "/api/generations/:path*", + "/api/logs/:path*", "/api/prompt/:path*", "/api/settings/:path*" ] diff --git a/tests/log-manager.test.ts b/tests/log-manager.test.ts new file mode 100644 index 0000000..ae9aaeb --- /dev/null +++ b/tests/log-manager.test.ts @@ -0,0 +1,72 @@ +import { readFile, rm, mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { appLogFilePath, clearAppLogs, listAppLogs, recordAppLog } from "@/lib/server/log-manager"; + +let runtimeDir = ""; +let previousRuntimeDir: string | undefined; +let previousLogDir: string | undefined; + +describe("server log manager", () => { + beforeEach(async () => { + runtimeDir = await mkdtemp(join(tmpdir(), "zhinian-logs-")); + previousRuntimeDir = process.env.ZHINIAN_RUNTIME_DIR; + previousLogDir = process.env.ZHINIAN_LOG_DIR; + process.env.ZHINIAN_RUNTIME_DIR = runtimeDir; + delete process.env.ZHINIAN_LOG_DIR; + }); + + afterEach(async () => { + restoreEnv("ZHINIAN_RUNTIME_DIR", previousRuntimeDir); + restoreEnv("ZHINIAN_LOG_DIR", previousLogDir); + await rm(runtimeDir, { force: true, recursive: true }); + }); + + it("records, filters, and clears server logs", async () => { + await recordAppLog({ + level: "error", + source: "api.test", + message: "EvoLink API_KEY=super-secret failed", + error: new Error("token=abc123 failed"), + status: 500, + request: new Request("http://local.test/api/test?x=1", { method: "POST" }), + details: { + Authorization: "Bearer secret-token", + nested: { password: "123456", prompt: "商品海报" } + } + }); + await recordAppLog({ + level: "warning", + source: "worker.test", + message: "retry scheduled" + }); + + const errors = await listAppLogs({ level: "error", q: "evolink" }); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + level: "error", + source: "api.test", + status: 500, + method: "POST", + path: "/api/test?x=1" + }); + expect(JSON.stringify(errors[0])).not.toContain("super-secret"); + expect(JSON.stringify(errors[0])).not.toContain("secret-token"); + expect(JSON.stringify(errors[0])).not.toContain("123456"); + + const raw = await readFile(appLogFilePath(), "utf8"); + expect(raw).toContain("retry scheduled"); + + await clearAppLogs(); + expect(await listAppLogs()).toHaveLength(0); + }); +}); + +function restoreEnv(name: string, value: string | undefined) { + if (value === undefined) { + delete process.env[name]; + return; + } + process.env[name] = value; +}