Add backend log management

This commit is contained in:
inman
2026-06-03 11:49:45 +08:00
parent 13ddc66cfe
commit fb0229ba06
14 changed files with 804 additions and 2 deletions

View File

@@ -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.

View File

@@ -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

40
app/api/logs/route.ts Normal file
View File

@@ -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";
}

View File

@@ -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;

7
app/logs/page.tsx Normal file
View File

@@ -0,0 +1,7 @@
import { LogManager } from "@/components/log-manager";
export const dynamic = "force-dynamic";
export default function LogsPage() {
return <LogManager />;
}

View File

@@ -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 }
];

245
components/log-manager.tsx Normal file
View File

@@ -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<LogPayload | null>(null);
const [level, setLevel] = useState<LogLevel | "all">("all");
const [query, setQuery] = useState("");
const [draftQuery, setDraftQuery] = useState("");
const [loading, setLoading] = useState(true);
const [clearing, setClearing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const logsRef = useRef<HTMLDivElement | null>(null);
const contentRef = useRef<HTMLElement | null>(null);
const feedbackRef = useRef<HTMLDivElement | null>(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<HTMLFormElement>) {
event.preventDefault();
setQuery(draftQuery.trim());
}
return (
<div className="log-manager" ref={logsRef}>
<div className="workspace-head" data-animate>
<div>
<h1 className="workspace-title"></h1>
</div>
<div className="workspace-meta">
<span className="status">{stats.total} </span>
<span className="status failed">{stats.error} </span>
<span className="status running">{stats.warning} </span>
</div>
</div>
<section className="panel log-actions" data-animate>
<div className="segmented log-tabs" aria-label="日志级别">
{LEVEL_TABS.map((tab) => (
<button
className={level === tab.id ? "active" : ""}
aria-pressed={level === tab.id}
type="button"
key={tab.id}
onClick={() => setLevel(tab.id)}
>
{tab.label}
</button>
))}
</div>
<form className="log-search" onSubmit={submitSearch}>
<Search aria-hidden="true" />
<input
value={draftQuery}
placeholder="搜索消息、路径、状态"
onChange={(event) => setDraftQuery(event.target.value)}
/>
</form>
<div className="toolbar">
<button className="button" type="button" onClick={loadLogs} disabled={loading || clearing}>
{loading ? <Loader2 className="spin" size={18} /> : <RefreshCw size={18} />}
</button>
<button className="button danger" type="button" onClick={clearLogs} disabled={loading || clearing}>
{clearing ? <Loader2 className="spin" size={18} /> : <Trash2 size={18} />}
</button>
</div>
</section>
{error || message ? (
<div ref={feedbackRef}>
{error ? <div className="callout" role="alert">{error}</div> : null}
{message ? <div className="callout success-callout" role="status" aria-live="polite">{message}</div> : null}
</div>
) : null}
<section className="panel log-storage" data-animate>
<div>
<span></span>
<strong>{payload?.logPath || ".runtime/logs/server-events.jsonl"}</strong>
</div>
<div>
<span></span>
<strong>{formatBytes(payload?.maxBytes || 0)}</strong>
</div>
<div>
<span></span>
<strong>{levelLabel(level)} / {query || "全部"}</strong>
</div>
</section>
<section className="panel log-list" data-animate ref={contentRef}>
{loading && !payload ? (
<div className="settings-loading">
<Loader2 className="spin" size={18} />
</div>
) : payload?.entries.length ? (
payload.entries.map((entry) => <LogRow entry={entry} key={entry.id} />)
) : (
<div className="log-empty"></div>
)}
</section>
</div>
);
}
function LogRow({ entry }: { entry: LogEntry }) {
const details = detailText(entry);
return (
<article className={`log-row log-row-${entry.level}`}>
<div className="log-row-main">
<span className={`status ${entry.level === "error" ? "failed" : entry.level === "warning" ? "running" : ""}`}>{levelLabel(entry.level)}</span>
<div className="log-message">
<strong>{entry.message}</strong>
<small>{formatTime(entry.createdAt)} · {entry.source}{entry.status ? ` · ${entry.status}` : ""}{entry.method || entry.path ? ` · ${[entry.method, entry.path].filter(Boolean).join(" ")}` : ""}</small>
</div>
</div>
{details ? (
<details className="log-detail">
<summary></summary>
<pre>{details}</pre>
</details>
) : null}
</article>
);
}
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`;
}

View File

@@ -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 密钥按业务需要配置完成

View File

@@ -1,11 +1,30 @@
import { NextResponse } from "next/server";
import { recordAppLog } from "@/lib/server/log-manager";
export function jsonOk<T>(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 });

210
lib/server/log-manager.ts Normal file
View File

@@ -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<unknown> = 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<AppLogEntry> {
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<AppLogEntry[]> {
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<void> {
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<AppLogEntry>;
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<void> {
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<object>()): 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<string, unknown> = {};
for (const [key, item] of Object.entries(value as Record<string, unknown>).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";
}

View File

@@ -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<void> {
await Promise.all([
mkdir(dataDir(), { recursive: true }),
mkdir(uploadDir(), { recursive: true }),
mkdir(resultDir(), { recursive: true })
mkdir(resultDir(), { recursive: true }),
mkdir(logDir(), { recursive: true })
]);
}

View File

@@ -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: {

View File

@@ -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*"
]

72
tests/log-manager.test.ts Normal file
View File

@@ -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;
}