From ed95b48ac08e28d415b1ecbcef40f8011d6d1459 Mon Sep 17 00:00:00 2001 From: Ding <44717411+ding113@users.noreply.github.com> Date: Fri, 29 May 2026 21:19:01 +0800 Subject: [PATCH 01/13] fix(anthropic): normalize created_at to second precision per API spec (#1227) Normalize the created_at field in Anthropic /v1/models responses to second-precision UTC, stripping any millisecond fraction from upstream timestamps. Unparseable values are returned unchanged to avoid errors. Fixes #1226 --- src/app/v1/_lib/models/available-models.ts | 19 +++++++++++-- tests/unit/proxy/available-models.test.ts | 33 ++++++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/app/v1/_lib/models/available-models.ts b/src/app/v1/_lib/models/available-models.ts index 2c62196ea..d1ea2aeba 100644 --- a/src/app/v1/_lib/models/available-models.ts +++ b/src/app/v1/_lib/models/available-models.ts @@ -370,16 +370,31 @@ export function formatOpenAIResponse(models: FetchedModel[]): OpenAIModelsRespon return { object: "list" as const, data }; } +/** + * 将时间戳归一化为 Anthropic API 规范格式(秒级精度,不含毫秒)。 + * + * 官方 Anthropic /v1/models 的 created_at 形如 `2026-05-29T09:22:44Z`,不含毫秒; + * 而 Date.toISOString() 始终输出 `.SSSZ`,部分上游也会带毫秒,因此统一去除。 + * 无法解析的上游时间戳原样返回,避免抛错。 + */ +function normalizeAnthropicTimestamp(value: string): string { + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + return value; + } + return parsed.toISOString().replace(/\.\d{3}Z$/, "Z"); +} + /** * 格式化为 Anthropic 响应 */ export function formatAnthropicResponse(models: FetchedModel[]): AnthropicModelsResponse { - const now = new Date().toISOString(); + const now = normalizeAnthropicTimestamp(new Date().toISOString()); const data = models.map((m) => ({ id: m.id, type: "model" as const, display_name: m.displayName || m.id, - created_at: m.createdAt || now, + created_at: m.createdAt ? normalizeAnthropicTimestamp(m.createdAt) : now, })); return { data, has_more: false }; diff --git a/tests/unit/proxy/available-models.test.ts b/tests/unit/proxy/available-models.test.ts index 86ce62ab2..d6d6fc627 100644 --- a/tests/unit/proxy/available-models.test.ts +++ b/tests/unit/proxy/available-models.test.ts @@ -204,6 +204,39 @@ describe("formatAnthropicResponse - Anthropic 格式响应", () => { const result: AnthropicModelsResponse = formatAnthropicResponse([{ id: "test-model" }]); expect(result.data[0].display_name).toBe("test-model"); }); + + test("fallback created_at 不应包含毫秒(符合官方 API 规范)", () => { + const result: AnthropicModelsResponse = formatAnthropicResponse([{ id: "test-model" }]); + expect(result.data[0].created_at).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/); + }); + + test("上游带毫秒的 created_at 应被归一化为秒级精度", () => { + const result: AnthropicModelsResponse = formatAnthropicResponse([ + { id: "claude-3-opus", createdAt: "2026-05-29T09:22:44.350Z" }, + ]); + expect(result.data[0].created_at).toBe("2026-05-29T09:22:44Z"); + }); + + test("上游已是秒级精度的 created_at 应保持不变", () => { + const result: AnthropicModelsResponse = formatAnthropicResponse([ + { id: "claude-3-opus", createdAt: "2024-02-29T00:00:00Z" }, + ]); + expect(result.data[0].created_at).toBe("2024-02-29T00:00:00Z"); + }); + + test("非 Z 时区偏移的 created_at 应被归一化为 UTC 秒级精度", () => { + const result: AnthropicModelsResponse = formatAnthropicResponse([ + { id: "claude-3-opus", createdAt: "2026-05-29T17:22:44.350+08:00" }, + ]); + expect(result.data[0].created_at).toBe("2026-05-29T09:22:44Z"); + }); + + test("无法解析的 created_at 应原样返回,不抛错", () => { + const result: AnthropicModelsResponse = formatAnthropicResponse([ + { id: "claude-3-opus", createdAt: "not-a-valid-date" }, + ]); + expect(result.data[0].created_at).toBe("not-a-valid-date"); + }); }); describe("formatGeminiResponse - Gemini 格式响应", () => { From 5bb7ad5122ebb56e6e328558f6553f7977e844d1 Mon Sep 17 00:00:00 2001 From: Ding <44717411+ding113@users.noreply.github.com> Date: Tue, 2 Jun 2026 11:25:32 +0800 Subject: [PATCH 02/13] fix(api-client): wrap provider group-count and model-suggestion wrappers in toActionResult (#1235) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getProviderGroupsWithCount and getModelSuggestionsByProviderGroup returned raw HTTP bodies, but dashboard consumers check res.ok, so the user-edit panel showed 获取供应商分组统计失败: undefined on every open. Wrap both in toActionResult; add real-wrapper contract tests and align stale model-suggestion mocks. --- src/lib/api-client/v1/actions/providers.ts | 15 ++++-- tests/unit/api/v1/api-client-actions.test.ts | 52 +++++++++++++++++++ .../provider-form-endpoint-pool.test.tsx | 2 +- .../provider-form-total-limit-ui.test.tsx | 2 +- 4 files changed, 65 insertions(+), 6 deletions(-) diff --git a/src/lib/api-client/v1/actions/providers.ts b/src/lib/api-client/v1/actions/providers.ts index 44bfab515..7ceee8c4c 100644 --- a/src/lib/api-client/v1/actions/providers.ts +++ b/src/lib/api-client/v1/actions/providers.ts @@ -61,7 +61,12 @@ export function getAvailableProviderGroups(userId?: number): Promise { } export function getProviderGroupsWithCount() { - return apiGet(`/api/v1/providers/groups?include=count`, dashboardCompatOptions); + return toActionResult( + apiGet>( + `/api/v1/providers/groups?include=count`, + dashboardCompatOptions + ) + ); } export function addProvider(data: unknown) { @@ -219,9 +224,11 @@ export function fetchUpstreamModels(data: unknown) { } export function getModelSuggestionsByProviderGroup(providerGroup?: string | null) { - return apiGet( - `/api/v1/providers/model-suggestions${searchParams({ providerGroup })}`, - dashboardCompatOptions + return toActionResult( + apiGet( + `/api/v1/providers/model-suggestions${searchParams({ providerGroup })}`, + dashboardCompatOptions + ) ); } diff --git a/tests/unit/api/v1/api-client-actions.test.ts b/tests/unit/api/v1/api-client-actions.test.ts index 5b8f29564..64ef6184d 100644 --- a/tests/unit/api/v1/api-client-actions.test.ts +++ b/tests/unit/api/v1/api-client-actions.test.ts @@ -373,4 +373,56 @@ describe("v1 action compatibility client", () => { expect(result).not.toHaveProperty("data"); expect(result[1]?.circuitState).toBe("open"); }); + + test("wraps provider group counts in ActionResult for the dashboard consumer", async () => { + // 后端 listProviderGroups 经 actionJson() 解包,直接返回裸数组; + // provider-group-select.tsx 却按 `{ ok, data }` 消费。若 api-client 不再包一层, + // `res.ok` 永远 undefined,每次展开用户编辑面板都会误报 "获取供应商分组统计失败"。 + const groupCounts = [ + { group: "default", providerCount: 2 }, + { group: "prod", providerCount: 1 }, + ]; + getMock.mockResolvedValue(groupCounts); + + const result = await providers.getProviderGroupsWithCount(); + + expect(getMock).toHaveBeenCalledWith("/api/v1/providers/groups?include=count", { + headers: { [DASHBOARD_COMPAT_HEADER]: "1" }, + }); + expect(result).toEqual({ ok: true, data: groupCounts }); + }); + + test("maps a failed provider group counts request to a failed ActionResult", async () => { + getMock.mockRejectedValue( + new ApiError({ + status: 403, + errorCode: "auth.forbidden", + detail: "Admin access is required.", + }) + ); + + const result = await providers.getProviderGroupsWithCount(); + + expect(result).toEqual({ + ok: false, + error: "Admin access is required.", + errorCode: "auth.forbidden", + errorParams: undefined, + }); + }); + + test("wraps model suggestions in ActionResult for the autocomplete consumer", async () => { + // 与分组统计同源:use-model-suggestions.ts 检查 `res.ok && res.data`, + // 裸数组会让自动补全静默失效(不报错但永远拿不到建议模型)。 + const suggestions = ["claude-3-opus", "claude-3-sonnet"]; + getMock.mockResolvedValue(suggestions); + + const result = await providers.getModelSuggestionsByProviderGroup("default"); + + expect(getMock).toHaveBeenCalledWith( + "/api/v1/providers/model-suggestions?providerGroup=default", + { headers: { [DASHBOARD_COMPAT_HEADER]: "1" } } + ); + expect(result).toEqual({ ok: true, data: suggestions }); + }); }); diff --git a/tests/unit/settings/providers/provider-form-endpoint-pool.test.tsx b/tests/unit/settings/providers/provider-form-endpoint-pool.test.tsx index ac6ea6b6e..1a1602b0f 100644 --- a/tests/unit/settings/providers/provider-form-endpoint-pool.test.tsx +++ b/tests/unit/settings/providers/provider-form-endpoint-pool.test.tsx @@ -31,7 +31,7 @@ const providersActionMocks = vi.hoisted(() => ({ removeProvider: vi.fn(async () => ({ ok: true })), getUnmaskedProviderKey: vi.fn(async () => ({ ok: true, data: { key: "test-key" } })), getProviderTestPresets: vi.fn(async () => ({ ok: true, data: [] })), - getModelSuggestionsByProviderGroup: vi.fn(async () => []), + getModelSuggestionsByProviderGroup: vi.fn(async () => ({ ok: true, data: [] })), fetchUpstreamModels: vi.fn(async () => ({ ok: true, data: { models: [] } })), })); vi.mock("@/actions/providers", () => providersActionMocks); diff --git a/tests/unit/settings/providers/provider-form-total-limit-ui.test.tsx b/tests/unit/settings/providers/provider-form-total-limit-ui.test.tsx index 8fd565a2b..571affb1c 100644 --- a/tests/unit/settings/providers/provider-form-total-limit-ui.test.tsx +++ b/tests/unit/settings/providers/provider-form-total-limit-ui.test.tsx @@ -42,7 +42,7 @@ const providersActionMocks = vi.hoisted(() => ({ removeProvider: vi.fn(async (_providerId: number) => ({ ok: true })), getUnmaskedProviderKey: vi.fn(async () => ({ ok: true, data: { key: "test-key" } })), getProviderTestPresets: vi.fn(async () => ({ ok: true, data: [] })), - getModelSuggestionsByProviderGroup: vi.fn(async () => []), + getModelSuggestionsByProviderGroup: vi.fn(async () => ({ ok: true, data: [] })), fetchUpstreamModels: vi.fn(async () => ({ ok: true, data: { models: [] } })), })); vi.mock("@/actions/providers", () => providersActionMocks); From c5d02b98654e0ad89f5ff5c53910b353a390134b Mon Sep 17 00:00:00 2001 From: Ding <44717411+ding113@users.noreply.github.com> Date: Tue, 2 Jun 2026 20:36:07 +0800 Subject: [PATCH 03/13] fix(ui): auto-open TagInput suggestions when async data arrives while focused (#1237) Fixes the create-user dialog where the "provider group" dropdown would not open to select existing groups. TagInput now auto-opens its suggestions the first time they load asynchronously while the input is focused (fire-once, disabled-guarded), so focusing the field before the data arrives no longer leaves the dropdown stuck closed. The data-fetch half of #1212 was fixed separately in #1235; this completes the interaction-layer fix. Includes regression tests (incl. the dialog/portal path) and corrects the test mock path. Fixes #1212 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../provider-group-tag-input.test.tsx | 192 ++++++++++++++++-- src/components/ui/tag-input.tsx | 12 ++ 2 files changed, 182 insertions(+), 22 deletions(-) diff --git a/src/components/ui/__tests__/provider-group-tag-input.test.tsx b/src/components/ui/__tests__/provider-group-tag-input.test.tsx index a2a73a2e7..d5de942a5 100644 --- a/src/components/ui/__tests__/provider-group-tag-input.test.tsx +++ b/src/components/ui/__tests__/provider-group-tag-input.test.tsx @@ -4,13 +4,15 @@ import type { ReactNode } from "react"; import { act } from "react"; -import { createRoot } from "react-dom/client"; +import { type Root, createRoot } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { ProviderGroupSelect } from "@/app/[locale]/dashboard/_components/user/forms/provider-group-select"; import { TagInput } from "@/components/ui/tag-input"; -const providerActionsMocks = vi.hoisted(() => ({ - getProviderGroupsWithCount: vi.fn(async () => ({ ok: true, data: [] })), +// 该组件从 v1 api-client 导入 getProviderGroupsWithCount,必须 mock 这个路径, +// 否则测试会命中真实的 REST 客户端(历史上 mock 错路径掩盖了真实数据流)。 +const providerApiMocks = vi.hoisted(() => ({ + getProviderGroupsWithCount: vi.fn(async () => ({ ok: true, data: [] as unknown[] })), })); const sonnerMocks = vi.hoisted(() => ({ @@ -19,27 +21,47 @@ const sonnerMocks = vi.hoisted(() => ({ }, })); -vi.mock("@/actions/providers", () => providerActionsMocks); +vi.mock("@/lib/api-client/v1/actions/providers", () => providerApiMocks); vi.mock("sonner", () => sonnerMocks); +// 追踪所有挂载的 React root,确保即使某个用例断言失败提前抛出,afterEach 也能卸载, +// 避免残留已挂载的 root 污染后续用例的 document.activeElement / DOM。 +const mountedRoots: Array<{ container: HTMLElement; root: Root; unmounted: boolean }> = []; + function render(node: ReactNode) { const container = document.createElement("div"); document.body.appendChild(container); const root = createRoot(container); - act(() => { root.render(node); }); - + const entry = { container, root, unmounted: false }; + mountedRoots.push(entry); return { container, unmount: () => { + if (entry.unmounted) return; + entry.unmounted = true; act(() => root.unmount()); container.remove(); }, }; } +async function flush() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +function createDeferred() { + let resolve!: (value: T) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + async function typeAndSubmit(input: HTMLInputElement, value: string) { await act(async () => { input.focus(); @@ -56,7 +78,37 @@ async function typeAndSubmit(input: HTMLInputElement, value: string) { }); } +/** 下拉建议项通过 Portal 渲染(无 Dialog 祖先时落到 document.body),按分组名匹配建议按钮。 */ +function suggestionButtonsFor(group: string) { + return Array.from(document.querySelectorAll("button")).filter((btn) => + (btn.textContent || "").includes(group) + ); +} + +const PROVIDER_GROUP_TRANSLATIONS = { + label: "Provider group", + placeholder: "Enter group", + description: "desc", + providersSuffix: "providers", + errors: { + loadFailed: "Load failed", + }, + tagInputErrors: { + empty: "empty", + duplicate: "duplicate", + too_long: "too long", + invalid_format: "invalid format", + max_tags: "max tags", + }, +}; + afterEach(() => { + for (const entry of mountedRoots.splice(0)) { + if (entry.unmounted) continue; + entry.unmounted = true; + act(() => entry.root.unmount()); + entry.container.remove(); + } while (document.body.firstChild) { document.body.removeChild(document.body.firstChild); } @@ -64,6 +116,7 @@ afterEach(() => { beforeEach(() => { vi.clearAllMocks(); + providerApiMocks.getProviderGroupsWithCount.mockResolvedValue({ ok: true, data: [] }); }); describe("provider-group tag inputs", () => { @@ -87,23 +140,12 @@ describe("provider-group tag inputs", () => { test("ProviderGroupSelect 应允许输入中文分组", async () => { const onChange = vi.fn(); - const translations = { - label: "Provider group", - placeholder: "Enter group", - description: "desc", - errors: { - loadFailed: "Load failed", - }, - tagInputErrors: { - empty: "empty", - duplicate: "duplicate", - too_long: "too long", - invalid_format: "invalid format", - max_tags: "max tags", - }, - }; const { container, unmount } = render( - + ); const input = container.querySelector("input"); @@ -116,4 +158,110 @@ describe("provider-group tag inputs", () => { unmount(); }); + + // 数据流回归:覆盖 mock 路径、ActionResult 解包与建议渲染链路(经 focus -> handleFocus 展开)。 + test("数据加载后点击输入框应展开下拉并列出已有的供应商分组", async () => { + providerApiMocks.getProviderGroupsWithCount.mockResolvedValue({ + ok: true, + data: [ + { group: "team-alpha", providerCount: 3 }, + { group: "team-beta", providerCount: 1 }, + ], + }); + const onChange = vi.fn(); + const { container, unmount } = render( + + ); + + // 等待异步加载完成 + await flush(); + + const input = container.querySelector("input") as HTMLInputElement; + await act(async () => { + // 点击容器会聚焦输入框,进而触发 onFocus -> handleFocus 展开下拉 + input.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(suggestionButtonsFor("team-alpha").length).toBeGreaterThan(0); + expect(suggestionButtonsFor("team-beta").length).toBeGreaterThan(0); + + unmount(); + }); + + // #1212 核心回归:建议数据在用户聚焦「之后」才异步返回时,下拉应自动展开。 + // 这是 tag-input.tsx 中「首次加载自动展开」effect 的专属守护用例(移除该 effect 则失败)。 + test("回归 #1212:建议数据在聚焦之后异步返回时下拉应自动展开", async () => { + const deferred = createDeferred<{ + ok: true; + data: Array<{ group: string; providerCount: number }>; + }>(); + providerApiMocks.getProviderGroupsWithCount.mockReturnValue(deferred.promise); + + const onChange = vi.fn(); + const { container, unmount } = render( + + ); + + const input = container.querySelector("input") as HTMLInputElement; + + // 用户在数据返回前就聚焦了输入框:此时建议为空,下拉不应展开 + await act(async () => { + // happy-dom 下 input.focus() 即可触发 React 的 onFocus 并设置 document.activeElement + input.focus(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + expect(suggestionButtonsFor("team-alpha").length).toBe(0); + + // 数据异步返回,输入框仍处于聚焦状态:下拉应自动展开 + await act(async () => { + deferred.resolve({ ok: true, data: [{ group: "team-alpha", providerCount: 2 }] }); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(suggestionButtonsFor("team-alpha").length).toBeGreaterThan(0); + + unmount(); + }); + + // #1212 实际场景:字段位于创建用户的 Dialog 内,下拉通过 Portal 渲染进 dialog-content。 + test("在 Dialog 中点击输入框时下拉应渲染到 dialog-content 容器内", async () => { + providerApiMocks.getProviderGroupsWithCount.mockResolvedValue({ + ok: true, + data: [{ group: "team-alpha", providerCount: 5 }], + }); + const onChange = vi.fn(); + const { container, unmount } = render( +
+ +
+ ); + await flush(); + + const input = container.querySelector("input") as HTMLInputElement; + await act(async () => { + input.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + const dialogContent = container.querySelector('[data-slot="dialog-content"]') as HTMLElement; + const buttonsInDialog = Array.from(dialogContent.querySelectorAll("button")).filter((btn) => + (btn.textContent || "").includes("team-alpha") + ); + expect(buttonsInDialog.length).toBeGreaterThan(0); + + unmount(); + }); }); diff --git a/src/components/ui/tag-input.tsx b/src/components/ui/tag-input.tsx index f5c656a77..b013cf4f4 100644 --- a/src/components/ui/tag-input.tsx +++ b/src/components/ui/tag-input.tsx @@ -175,6 +175,18 @@ export function TagInput({ return () => document.removeEventListener("mousedown", handleClickOutside, true); }, [showSuggestions]); + // 建议列表「首次」异步加载完成时自动展开下拉。建议数据经网络请求获取时, + // 用户可能在数据返回前就已聚焦输入框;此时 focus 事件不会再次触发,下拉无法展开。 + // 只处理首次由空变为非空,避免后续刷新(清空再填充)覆盖用户已手动关闭(Escape/点击外部)的下拉。 + const didAutoOpenRef = React.useRef(false); + React.useEffect(() => { + if (didAutoOpenRef.current || suggestions.length === 0) return; + didAutoOpenRef.current = true; + if (!disabled && inputRef.current === document.activeElement) { + setShowSuggestions(true); + } + }, [disabled, suggestions.length]); + const inputMinWidthClass = normalizedMaxVisible === undefined ? "min-w-[120px]" : "min-w-[60px]"; // Normalize suggestions so callers can provide either strings or { value, label } objects. From 8ceb31d2f10b73dc409866eefae40f7aa5fe3110 Mon Sep 17 00:00:00 2001 From: Ding <44717411+ding113@users.noreply.github.com> Date: Tue, 2 Jun 2026 21:19:00 +0800 Subject: [PATCH 04/13] fix(lifecycle): don't crash process on benign EPIPE from downstream stream disconnect (#1239) Narrow process-level uncaughtException/unhandledRejection suppression to EPIPE (write-side, unambiguous downstream disconnect) only; ECONNRESET/ERR_STREAM_PREMATURE_CLOSE stay fatal to preserve fail-fast. Adds getBenignBrokenPipeCode for accurate nested-code logging. Closes #1234 --- src/instrumentation.ts | 31 +++- src/lib/lifecycle/benign-errors.ts | 64 +++++++ tests/unit/benign-broken-pipe-error.test.ts | 124 +++++++++++++ .../instrumentation-crash-handler.test.ts | 163 ++++++++++++++++++ 4 files changed, 381 insertions(+), 1 deletion(-) create mode 100644 src/lib/lifecycle/benign-errors.ts create mode 100644 tests/unit/benign-broken-pipe-error.test.ts create mode 100644 tests/unit/instrumentation-crash-handler.test.ts diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 6f3fb4cf4..1767b1bfb 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -4,6 +4,7 @@ */ import { startCacheCleanup } from "@/lib/cache/session-cache"; +import { getBenignBrokenPipeCode } from "@/lib/lifecycle/benign-errors"; import { logger } from "@/lib/logger"; import { CHANNEL_API_KEYS_UPDATED, subscribeCacheInvalidation } from "@/lib/redis/pubsub"; import { apiKeyVacuumFilter } from "@/lib/security/api-key-vacuum-filter"; @@ -36,7 +37,7 @@ const instrumentationState = globalThis as unknown as { * * 这两个 process.on(...) 不会与现有的 SIGTERM / SIGINT 处理器冲突。 */ -function registerCrashDiagnostics(): void { +export function registerCrashDiagnostics(): void { if (instrumentationState.__CCH_CRASH_HANDLERS_REGISTERED__) { return; } @@ -80,6 +81,20 @@ function registerCrashDiagnostics(): void { }; process.on("uncaughtException", (err: Error) => { + // 良性断管(EPIPE,issue #1234):流式响应写入由 Next.js 持有,下游断开会让 socket + // write 在本地 try/catch 之外抛 EPIPE 并逃逸到此。这是请求级传输关闭,不应放大为整个 + // 进程退出/容器重启。仅抑制写侧、来源明确的 EPIPE;ECONNRESET 等来源不明的码仍 fail-fast。 + const benignCode = getBenignBrokenPipeCode(err); + if (benignCode) { + logger.warn("[Lifecycle] ignored uncaught client disconnect", { + error: err.message, + errorName: err.name, + errorCode: benignCode, + stack: err.stack, + }); + return; + } + const reportPath = writeReport("uncaughtException", err); writeFatalStderr("uncaughtException", err, reportPath); logger.fatal("[Lifecycle] uncaughtException", { @@ -97,6 +112,20 @@ function registerCrashDiagnostics(): void { // 否则一个未捕获的 promise 会让进程留在未定义状态、绕过 supervisor 重启。 // 因此:写诊断报告 + 同步落盘日志 + 主动 exit(1) 复现默认语义。 process.on("unhandledRejection", (reason: unknown) => { + // 同 uncaughtException:良性断管(EPIPE)以 rejection 形式逃逸时同样不应使进程退出。 + // 对原始 reason 判定(而非 wrap 后的 err),否则 { code: "EPIPE" } 之类非 Error 拒因在 + // new Error(String(reason)) 后会丢失 code,且 message 会变成 "[object Object]"。 + const benignCode = getBenignBrokenPipeCode(reason); + if (benignCode) { + logger.warn("[Lifecycle] ignored unhandled client disconnect", { + error: reason instanceof Error ? reason.message : `non-Error rejection (${benignCode})`, + errorName: reason instanceof Error ? reason.name : typeof reason, + errorCode: benignCode, + stack: reason instanceof Error ? reason.stack : undefined, + }); + return; + } + const err = reason instanceof Error ? reason : new Error(String(reason)); const reportPath = writeReport("unhandledRejection", err); writeFatalStderr("unhandledRejection", err, reportPath); diff --git a/src/lib/lifecycle/benign-errors.ts b/src/lib/lifecycle/benign-errors.ts new file mode 100644 index 000000000..d059839c5 --- /dev/null +++ b/src/lib/lifecycle/benign-errors.ts @@ -0,0 +1,64 @@ +/** + * 进程级崩溃处理器使用的“良性断管/客户端断连”判定。 + * + * 背景(issue #1234):CCH 代理流式响应(如 Codex `/v1/responses`)时,下游客户端在 + * Next.js 仍向 socket 写入时断开,底层 socket write 抛出 `write EPIPE`。该错误发生在 + * 本地 try/catch 之外(最终 Response 写入由 Next.js 持有),逃逸到进程级 uncaughtException + * 处理器;若按致命错误 process.exit(1),会把单个请求的断管放大为整个容器重启。 + * + * 判定范围刻意仅限 EPIPE: + * - EPIPE 是“写侧”错误——只在“我们向已关闭的 socket 写入”时出现。代理中唯一未被本地 + * handler 兜住、且会逃逸到进程级的大写入路径,就是 Next.js 向客户端写流式响应;因此 + * 进程级的 EPIPE 几乎必然来自下游断连,抑制它是安全的。 + * - 不包含 ECONNRESET / ERR_STREAM_PREMATURE_CLOSE:这两者方向不确定,可能源自上游 + * provider / DB / Redis 等连接(读侧重置 / 提前关闭),而进程级处理器没有请求或连接 + * 上下文来区分“客户端断连”与“自身基础设施故障”。全局吞掉它们会把真正的故障降级为 + * warn 并让进程带病运行,破坏 #1147 引入的 fail-fast 语义。这类码在有上下文的代理层 + * (forwarder 的 stream error / isTransportError)单独处理。 + * + * 设计约束:保持零依赖,避免在崩溃处理路径引入副作用导入。 + */ + +/** 进程级可安全抑制的断管错误码(仅写侧、来源明确的 EPIPE)。 */ +const BENIGN_BROKEN_PIPE_CODES = new Set(["EPIPE"]); + +/** cause 链最大遍历深度,避免循环引用导致的死循环。 */ +const MAX_CAUSE_DEPTH = 5; + +/** + * 返回触发“良性断管”判定的错误码(沿 `cause` 链向下查找),未命中返回 undefined。 + * + * 供崩溃处理器记录“实际命中的 code”——即便它嵌套在 cause 链深处(undici/fetch 常把底层 + * socket 错误包在 cause 上),也能拿到准确的 code 而非顶层的 undefined。 + * + * 仅基于错误码(`code`)判定,刻意不做 message 模糊匹配,避免把携带 "EPIPE" 字样的上游 + * 错误文案误判为良性,从而错误地抑制真正应当退出的崩溃。 + * + * @param err - 待检查的错误(任意类型) + * @returns 命中的良性错误码;未命中返回 undefined + */ +export function getBenignBrokenPipeCode(err: unknown): string | undefined { + let current: unknown = err; + for (let depth = 0; depth <= MAX_CAUSE_DEPTH && current != null; depth++) { + if (typeof current === "object") { + const code = (current as { code?: unknown }).code; + if (typeof code === "string" && BENIGN_BROKEN_PIPE_CODES.has(code)) { + return code; + } + current = (current as { cause?: unknown }).cause; + continue; + } + break; + } + return undefined; +} + +/** + * 是否为“良性的断管/客户端断连”错误(仅 EPIPE,含 cause 链)。 + * + * @param err - 待检查的错误(任意类型) + * @returns 命中良性断管错误码时返回 true + */ +export function isBenignBrokenPipeError(err: unknown): boolean { + return getBenignBrokenPipeCode(err) !== undefined; +} diff --git a/tests/unit/benign-broken-pipe-error.test.ts b/tests/unit/benign-broken-pipe-error.test.ts new file mode 100644 index 000000000..6fb4a754f --- /dev/null +++ b/tests/unit/benign-broken-pipe-error.test.ts @@ -0,0 +1,124 @@ +/** + * Benign broken-pipe error detection tests (issue #1234) + * + * 验证进程级崩溃处理器使用的判定:仅把写侧、来源明确的 EPIPE 视为良性断管, + * 而 ECONNRESET / ERR_STREAM_PREMATURE_CLOSE 等来源不明的码必须保持 fail-fast, + * 避免在进程级(无请求上下文)误吞上游基础设施故障。 + */ +import { describe, expect, it } from "vitest"; +import { getBenignBrokenPipeCode, isBenignBrokenPipeError } from "@/lib/lifecycle/benign-errors"; + +describe("isBenignBrokenPipeError", () => { + describe("benign (EPIPE only — write-side, unambiguous downstream disconnect)", () => { + it("detects EPIPE (the issue #1234 write EPIPE case)", () => { + const err = new Error("write EPIPE"); + (err as NodeJS.ErrnoException).code = "EPIPE"; + expect(isBenignBrokenPipeError(err)).toBe(true); + }); + }); + + describe("cause chain", () => { + it("detects EPIPE wrapped on cause", () => { + const cause = new Error("write EPIPE"); + (cause as NodeJS.ErrnoException).code = "EPIPE"; + const err = new Error("request failed"); + (err as Error & { cause: Error }).cause = cause; + expect(isBenignBrokenPipeError(err)).toBe(true); + }); + + it("detects EPIPE nested two levels deep", () => { + const root = new Error("write EPIPE"); + (root as NodeJS.ErrnoException).code = "EPIPE"; + const mid = new Error("socket write failed"); + (mid as Error & { cause: Error }).cause = root; + const top = new Error("stream error"); + (top as Error & { cause: Error }).cause = mid; + expect(isBenignBrokenPipeError(top)).toBe(true); + }); + + it("does not loop forever on a cyclic cause chain", () => { + const a = new Error("a"); + const b = new Error("b"); + (a as Error & { cause: Error }).cause = b; + (b as Error & { cause: Error }).cause = a; + expect(isBenignBrokenPipeError(a)).toBe(false); + }); + }); + + describe("ambiguous codes are deliberately NOT benign (preserve fail-fast)", () => { + it("does NOT treat ECONNRESET as benign (may originate upstream: DB/Redis/provider)", () => { + const err = new Error("read ECONNRESET"); + (err as NodeJS.ErrnoException).code = "ECONNRESET"; + expect(isBenignBrokenPipeError(err)).toBe(false); + }); + + it("does NOT treat ERR_STREAM_PREMATURE_CLOSE as benign", () => { + const err = new Error("Premature close"); + (err as NodeJS.ErrnoException).code = "ERR_STREAM_PREMATURE_CLOSE"; + expect(isBenignBrokenPipeError(err)).toBe(false); + }); + + it("does NOT treat an upstream-nested ECONNRESET as benign", () => { + const root = new Error("read ECONNRESET"); + (root as NodeJS.ErrnoException).code = "ECONNRESET"; + const top = new Error("provider request failed"); + (top as Error & { cause: Error }).cause = root; + expect(isBenignBrokenPipeError(top)).toBe(false); + }); + }); + + describe("non-benign errors (must still fail-fast)", () => { + it("does not match a generic error without a code", () => { + expect(isBenignBrokenPipeError(new Error("Something went wrong"))).toBe(false); + }); + + it("does not match unrelated transport codes", () => { + const err = new Error("connect ECONNREFUSED"); + (err as NodeJS.ErrnoException).code = "ECONNREFUSED"; + expect(isBenignBrokenPipeError(err)).toBe(false); + }); + + it("does not match on message text alone (avoids false-benign suppression)", () => { + // 上游错误文案可能包含 "EPIPE" 字样,但没有真实的 code, + // 必须按非良性处理,确保真正的崩溃仍会退出。 + expect(isBenignBrokenPipeError(new Error("upstream said: write EPIPE"))).toBe(false); + }); + + it("handles non-Error values safely", () => { + expect(isBenignBrokenPipeError(null)).toBe(false); + expect(isBenignBrokenPipeError(undefined)).toBe(false); + expect(isBenignBrokenPipeError("EPIPE")).toBe(false); + expect(isBenignBrokenPipeError(42)).toBe(false); + }); + + it("matches a plain object carrying EPIPE but not other codes", () => { + // 非 Error 但带 code 的对象(某些 stream 错误事件 / 非 Error 拒因)也应识别。 + expect(isBenignBrokenPipeError({ code: "EPIPE" })).toBe(true); + expect(isBenignBrokenPipeError({ code: "ECONNRESET" })).toBe(false); + }); + }); +}); + +describe("getBenignBrokenPipeCode", () => { + it("returns the matched code for a top-level EPIPE", () => { + const err = new Error("write EPIPE"); + (err as NodeJS.ErrnoException).code = "EPIPE"; + expect(getBenignBrokenPipeCode(err)).toBe("EPIPE"); + }); + + it("returns the nested code so logging is accurate even when wrapped", () => { + const cause = new Error("write EPIPE"); + (cause as NodeJS.ErrnoException).code = "EPIPE"; + const err = new Error("request failed"); + (err as Error & { cause: Error }).cause = cause; + expect(getBenignBrokenPipeCode(err)).toBe("EPIPE"); + }); + + it("returns undefined for ambiguous and unrelated codes", () => { + const econn = new Error("read ECONNRESET"); + (econn as NodeJS.ErrnoException).code = "ECONNRESET"; + expect(getBenignBrokenPipeCode(econn)).toBeUndefined(); + expect(getBenignBrokenPipeCode(new Error("no code"))).toBeUndefined(); + expect(getBenignBrokenPipeCode(null)).toBeUndefined(); + }); +}); diff --git a/tests/unit/instrumentation-crash-handler.test.ts b/tests/unit/instrumentation-crash-handler.test.ts new file mode 100644 index 000000000..bda70367c --- /dev/null +++ b/tests/unit/instrumentation-crash-handler.test.ts @@ -0,0 +1,163 @@ +/** + * Instrumentation crash-handler behavior tests (issue #1234) + * + * 锁定核心回归:进程级 uncaughtException / unhandledRejection 处理器在遇到良性断管 + * (仅 EPIPE,写侧断连)时必须仅记录 warn 而不调用 process.exit(1);遇到真正的错误 + * (含来源不明的 ECONNRESET / ERR_STREAM_PREMATURE_CLOSE)时仍必须 fail-fast 退出。 + * + * 谓词 isBenignBrokenPipeError 已单独单测,这里验证 registerCrashDiagnostics 的实际接线, + * 防止未来重构(删掉早返回、反转判断、移动谓词调用)在谓词测试全绿的情况下重新引入崩溃。 + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/logger", () => ({ + logger: { + fatal: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + trace: vi.fn(), + }, +})); + +import { logger } from "@/lib/logger"; +import { registerCrashDiagnostics } from "@/instrumentation"; + +type CrashHandler = (arg: unknown) => void; + +/** + * 通过 spy process.on 捕获 registerCrashDiagnostics 实际注册的处理器, + * 避免用 process.emit 触发真实进程事件(会干扰 vitest 自身的监听器)。 + */ +function captureHandlers(): { uncaughtException: CrashHandler; unhandledRejection: CrashHandler } { + const handlers: Record = {}; + const onSpy = vi.spyOn(process, "on").mockImplementation((( + event: string, + handler: CrashHandler + ) => { + if (event === "uncaughtException" || event === "unhandledRejection") { + handlers[event] = handler; + } + return process; + }) as never); + + // 重置去重标志,确保本次调用真正执行 process.on 注册 + ( + globalThis as { __CCH_CRASH_HANDLERS_REGISTERED__?: boolean } + ).__CCH_CRASH_HANDLERS_REGISTERED__ = false; + registerCrashDiagnostics(); + onSpy.mockRestore(); + + if (!handlers.uncaughtException || !handlers.unhandledRejection) { + throw new Error("crash handlers were not registered"); + } + return { + uncaughtException: handlers.uncaughtException, + unhandledRejection: handlers.unhandledRejection, + }; +} + +function makeError(code: string, message = code): NodeJS.ErrnoException { + const err = new Error(message) as NodeJS.ErrnoException; + err.code = code; + return err; +} + +describe("registerCrashDiagnostics", () => { + let exitSpy: ReturnType; + let stderrSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + exitSpy = vi + .spyOn(process, "exit") + .mockImplementation(((_code?: number) => undefined) as never); + stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + // 避免在 fatal 路径写出 Node 诊断报告文件 + if (process.report && typeof process.report.writeReport === "function") { + vi.spyOn(process.report, "writeReport").mockReturnValue(""); + } + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("benign broken-pipe errors (must NOT exit)", () => { + it("uncaughtException: EPIPE is logged at warn and does not exit", () => { + const { uncaughtException } = captureHandlers(); + uncaughtException(makeError("EPIPE", "write EPIPE")); + + expect(exitSpy).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(logger.fatal).not.toHaveBeenCalled(); + expect(stderrSpy).not.toHaveBeenCalled(); + }); + + it("unhandledRejection: rejected Error with EPIPE does not exit", () => { + const { unhandledRejection } = captureHandlers(); + unhandledRejection(makeError("EPIPE", "write EPIPE")); + + expect(exitSpy).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(logger.fatal).not.toHaveBeenCalled(); + }); + + it("unhandledRejection: non-Error rejection { code: EPIPE } does not exit and logs a clean message", () => { + // 回归:reason 在 wrap 成 Error 之前判定,否则 code 会丢失,且 error 字段会变成 + // "[object Object]"(gemini / greptile 评审发现)。 + const { unhandledRejection } = captureHandlers(); + unhandledRejection({ code: "EPIPE" }); + + expect(exitSpy).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledTimes(1); + const [, meta] = (logger.warn as unknown as ReturnType).mock.calls[0]; + expect(meta.errorCode).toBe("EPIPE"); + expect(meta.error).not.toBe("[object Object]"); + }); + }); + + describe("genuine errors (must still fail-fast)", () => { + it("uncaughtException: a generic Error exits with code 1 and writes diagnostics", () => { + const { uncaughtException } = captureHandlers(); + uncaughtException(new Error("real bug")); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(logger.fatal).toHaveBeenCalledTimes(1); + expect(logger.warn).not.toHaveBeenCalled(); + // fatal 路径必须写出同步 stderr 兜底诊断,防止回归静默吞掉致命错误 + expect(stderrSpy).toHaveBeenCalled(); + }); + + it("uncaughtException: a non-benign transport code (ECONNREFUSED) exits with code 1", () => { + const { uncaughtException } = captureHandlers(); + uncaughtException(makeError("ECONNREFUSED", "connect ECONNREFUSED")); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(logger.fatal).toHaveBeenCalledTimes(1); + }); + + it.each([ + "ECONNRESET", + "ERR_STREAM_PREMATURE_CLOSE", + ])("uncaughtException: ambiguous code %s is NOT suppressed and still exits with code 1", (code) => { + // 这些码方向不明(可能来自上游 DB/Redis/provider),进程级无上下文区分, + // 必须保持 fail-fast,避免误吞真正的基础设施故障。 + const { uncaughtException } = captureHandlers(); + uncaughtException(makeError(code)); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(logger.fatal).toHaveBeenCalledTimes(1); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it("unhandledRejection: a generic rejection exits with code 1", () => { + const { unhandledRejection } = captureHandlers(); + unhandledRejection(new Error("real rejection")); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(logger.fatal).toHaveBeenCalledTimes(1); + }); + }); +}); From 549defa68acb8e97a3b5bbb3557154aa42c7b96b Mon Sep 17 00:00:00 2001 From: Ding <44717411+ding113@users.noreply.github.com> Date: Tue, 2 Jun 2026 21:31:03 +0800 Subject: [PATCH 05/13] =?UTF-8?q?fix(notification):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E6=80=BB=E5=BC=80=E5=85=B3=E5=85=B3=E9=97=AD=E5=90=8E=E6=8E=92?= =?UTF-8?q?=E8=A1=8C=E6=A6=9C/=E6=88=90=E6=9C=AC=E9=A2=84=E8=AD=A6?= =?UTF-8?q?=E4=BB=8D=E6=8E=A8=E9=80=81=20&=20=E6=88=90=E6=9C=AC=E9=A2=84?= =?UTF-8?q?=E8=AD=A6=E9=97=B4=E9=9A=94=E2=89=A560=E5=88=86=E9=92=9F?= =?UTF-8?q?=E5=A1=8C=E7=BC=A9=E4=B8=BA=E6=AF=8F=E5=B0=8F=E6=97=B6=20(#1236?= =?UTF-8?q?)=20(#1240)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(notification): prevent stale repeatable jobs from firing after switch off and fix cost alert interval collapse When the master switch or sub-switch is turned off, previously enqueued repeatable jobs continued to fire because the processor did not re-check settings at execution time. Additionally, the scheduleNotifications function was guarded by NODE_ENV="production", so changes in development were silently ignored. The cost alert interval >=60 minutes collapsed to an hourly cron because Bull's cron only supports minutes 0-59. - Remove NODE_ENV guard so scheduleNotifications always runs on settings save. - Add enabled/sub-switch checks in daily-leaderboard and cost-alert job processors to skip sending when disabled. - Extract removeAllRepeatableJobs and make individual key removal resilient to transient Redis errors. - For cost alert intervals >=60 minutes, use {every} instead of */N cron to schedule every N minutes correctly. - Add unit tests covering processor skip behavior, removal resilience, and cost alert interval mapping. Fixes #1236 Co-Authored-By: Claude Opus 4.8 (1M context) * fix(notification-queue): prevent double-firing and stale alerts (#1236) The scheduler previously added new repeatable jobs even when old ones could not be removed, causing duplicate notifications. Circuit-breaker jobs also lacked a run-time switch check, so alerts could fire after the feature was disabled. - Extract clampIntervalMinutes and intervalToRepeat helpers; use cron only when the interval divides 60 evenly, falling back to the every option otherwise. This avoids uneven cron patterns and simplifies scheduling across legacy and targets modes. - Return a success boolean from removeAllRepeatableJobs and abort the reschedule if any removal fails, preventing old and new jobs from running simultaneously. - Add an execution-time guard in the circuit-breaker queue processor that re-verifies the master and circuit-breaker switches before sending, skipping with success: true, skipped: true when off. - Add unit tests for the circuit-breaker switch logic, the cost-alert positive path, targets-mode scheduling with binding jobId and timezone, and the abort-on-removal-failure scenario. Co-Authored-By: Claude Opus 4.8 (1M context) * test(notification): add circuit-breaker sub-switch-off skip test Add a test case verifying that the notification queue processor skips sending when circuitBreakerEnabled is false. Covers a scenario flagged as missing during code review. Refs #1236 Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- src/actions/notifications.ts | 17 +- src/lib/notification/notification-queue.ts | 161 +++++-- .../notification/notification-queue.test.ts | 456 ++++++++++++++++++ 3 files changed, 579 insertions(+), 55 deletions(-) create mode 100644 tests/unit/notification/notification-queue.test.ts diff --git a/src/actions/notifications.ts b/src/actions/notifications.ts index b066d2997..4cf314d10 100644 --- a/src/actions/notifications.ts +++ b/src/actions/notifications.ts @@ -3,7 +3,6 @@ import { emitActionAudit } from "@/lib/audit/emit"; import { getSession } from "@/lib/auth"; import type { NotificationJobType } from "@/lib/constants/notification.constants"; -import { logger } from "@/lib/logger"; import { resolveSystemTimezone } from "@/lib/utils/timezone"; import { WebhookNotifier } from "@/lib/webhook"; import { buildTestMessage } from "@/lib/webhook/templates/test-messages"; @@ -41,18 +40,10 @@ export async function updateNotificationSettingsAction( const before = await getNotificationSettings(); const updated = await updateNotificationSettings(payload); - // 重新调度通知任务(仅生产环境) - if (process.env.NODE_ENV === "production") { - // 动态导入避免 Turbopack 编译 Bull 模块 - const { scheduleNotifications } = await import("@/lib/notification/notification-queue"); - await scheduleNotifications(); - } else { - logger.warn({ - action: "schedule_notifications_skipped", - reason: "development_mode", - message: "Notification scheduling is disabled in development mode", - }); - } + // 重新调度通知任务,使总开关、子开关、时间/间隔等变更立即生效(添加/移除 repeatable 作业)。 + // 动态导入避免静态加载 Bull;scheduleNotifications 内部已 fail-open,缺少 REDIS_URL 时不会影响设置保存。 + const { scheduleNotifications } = await import("@/lib/notification/notification-queue"); + await scheduleNotifications(); emitActionAudit({ category: "notification", diff --git a/src/lib/notification/notification-queue.ts b/src/lib/notification/notification-queue.ts index c4ce3db5b..54257beff 100644 --- a/src/lib/notification/notification-queue.ts +++ b/src/lib/notification/notification-queue.ts @@ -424,13 +424,30 @@ function setupQueueProcessor(queue: Queue.Queue): void { | undefined = data; let cooldownCommit: { keys: string[]; cooldownMinutes: number } | undefined; switch (type) { - case "circuit-breaker": + case "circuit-breaker": { + // 执行期再次校验开关:入队后若开关被关闭,遗留作业不应继续发送 + const { getNotificationSettings } = await import("@/repository/notifications"); + const settings = await getNotificationSettings(); + + if (!settings.enabled || !settings.circuitBreakerEnabled) { + logger.info({ action: "circuit_breaker_disabled", jobId: job.id }); + return { success: true, skipped: true }; + } + message = buildCircuitBreakerMessage(data as CircuitBreakerAlertData, timezone); break; + } case "daily-leaderboard": { // 动态生成排行榜数据 const { getNotificationSettings } = await import("@/repository/notifications"); const settings = await getNotificationSettings(); + + // 执行期再次校验开关:总开关或子开关关闭后,遗留的 repeatable 作业不应继续发送 + if (!settings.enabled || !settings.dailyLeaderboardEnabled) { + logger.info({ action: "daily_leaderboard_disabled", jobId: job.id }); + return { success: true, skipped: true }; + } + const leaderboardData = await generateDailyLeaderboard( settings.dailyLeaderboardTopN || 5 ); @@ -451,6 +468,13 @@ function setupQueueProcessor(queue: Queue.Queue): void { // 动态生成成本预警数据 const { getNotificationSettings } = await import("@/repository/notifications"); const settings = await getNotificationSettings(); + + // 执行期再次校验开关:总开关或子开关关闭后,遗留的 repeatable 作业不应继续发送 + if (!settings.enabled || !settings.costAlertEnabled) { + logger.info({ action: "cost_alert_disabled", jobId: job.id }); + return { success: true, skipped: true }; + } + const alerts = await generateCostAlerts( parseFloat(settings.costAlertThreshold || "0.80") ); @@ -705,6 +729,68 @@ export async function addNotificationJobForTarget( } } +/** + * 移除队列中所有 repeatable 定时任务。 + * 逐个尝试移除(单个 key 失败不影响其余 key),返回是否全部成功。 + * 调用方应在“仍要新增任务”的场景下检查返回值:若有旧任务未能移除, + * 继续新增会导致旧任务与新任务同时触发(重复发送),应中止本次重调度等待重试。 + */ +async function removeAllRepeatableJobs(queue: Queue.Queue): Promise { + let repeatableJobs: Awaited>; + try { + repeatableJobs = await queue.getRepeatableJobs(); + } catch (error) { + logger.warn({ + action: "notification_repeatable_list_failed", + error: error instanceof Error ? error.message : String(error), + }); + return false; + } + + let allRemoved = true; + for (const job of repeatableJobs) { + try { + await queue.removeRepeatableByKey(job.key); + } catch (error) { + allRemoved = false; + logger.warn({ + action: "notification_repeatable_remove_failed", + key: job.key, + error: error instanceof Error ? error.message : String(error), + }); + } + } + return allRemoved; +} + +/** 将“每 N 分钟”归一化到 [1, 1440] 分钟。 */ +function clampIntervalMinutes(rawMinutes: number): number { + return Math.min(Math.max(1, Math.trunc(rawMinutes)), 24 * 60); +} + +/** + * 将“每 N 分钟”间隔映射为 Bull 的 repeat 选项。 + * Bull cron 的分钟字段仅 0-59,且分钟步进表达式在 N 不整除 60 时(如 45)会在整点边界产生不均匀间隔, + * 因此仅当 N<=59 且能整除 60 时使用 cron(可携带时区);否则退化为固定毫秒间隔 + * (every 按固定节奏触发,不对齐整点、不支持时区,这是 Bull 的限制)。 + */ +function intervalToRepeat( + intervalMinutes: number, + tz?: string +): { cron: string; tz?: string } | { every: number } { + if (intervalMinutes <= 59 && 60 % intervalMinutes === 0) { + return tz + ? { cron: `*/${intervalMinutes} * * * *`, tz } + : { cron: `*/${intervalMinutes} * * * *` }; + } + return { every: intervalMinutes * 60 * 1000 }; +} + +/** 生成 repeat 选项的可读日志标签。 */ +function describeRepeat(repeat: { cron: string } | { every: number }): string { + return "cron" in repeat ? repeat.cron : `every:${Math.round(repeat.every / 60000)}m`; +} + /** * 调度定时通知任务 */ @@ -719,19 +805,21 @@ export async function scheduleNotifications() { if (!settings.enabled) { logger.info({ action: "notifications_disabled" }); - // 移除所有已存在的定时任务 - const repeatableJobs = await queue.getRepeatableJobs(); - for (const job of repeatableJobs) { - await queue.removeRepeatableByKey(job.key); - } + // 总开关关闭:移除所有已存在的定时任务(此处无需新增任务,移除失败不阻断) + await removeAllRepeatableJobs(queue); return; } - // 移除旧的定时任务 - const repeatableJobs = await queue.getRepeatableJobs(); - for (const job of repeatableJobs) { - await queue.removeRepeatableByKey(job.key); + // 移除旧的定时任务,避免改时间/改配置后旧任务残留导致重复或错误时间触发。 + // 若移除未全部成功,则不再新增任务——否则旧任务会与新任务同时触发(重复发送),等待下次重调度重试。 + const removedAll = await removeAllRepeatableJobs(queue); + if (!removedAll) { + logger.error({ + action: "schedule_notifications_aborted", + reason: "stale_repeatable_remove_failed", + }); + return; } if (settings.useLegacyMode) { @@ -764,8 +852,8 @@ export async function scheduleNotifications() { } if (settings.costAlertEnabled && settings.costAlertWebhook) { - const interval = settings.costAlertCheckInterval ?? 60; // 分钟 - const cron = `*/${interval} * * * *`; // 每 N 分钟 + const interval = clampIntervalMinutes(settings.costAlertCheckInterval ?? 60); + const repeat = intervalToRepeat(interval); await queue.add( { @@ -774,27 +862,22 @@ export async function scheduleNotifications() { // data 字段省略,任务执行时动态生成 }, { - repeat: { cron }, + repeat, jobId: "cost-alert-scheduled", } ); logger.info({ action: "cost_alert_scheduled", - schedule: cron, + schedule: describeRepeat(repeat), intervalMinutes: interval, mode: "legacy", }); } if (settings.cacheHitRateAlertEnabled && settings.cacheHitRateAlertWebhook) { - const intervalMinutesRaw = settings.cacheHitRateAlertCheckInterval ?? 5; - const intervalMinutes = Math.max(1, Math.trunc(intervalMinutesRaw)); - const clampedIntervalMinutes = Math.min(intervalMinutes, 24 * 60); - const repeat = - clampedIntervalMinutes <= 59 - ? { cron: `*/${clampedIntervalMinutes} * * * *` } - : { every: clampedIntervalMinutes * 60 * 1000 }; + const interval = clampIntervalMinutes(settings.cacheHitRateAlertCheckInterval ?? 5); + const repeat = intervalToRepeat(interval); await queue.add( { @@ -806,8 +889,8 @@ export async function scheduleNotifications() { logger.info({ action: "cache_hit_rate_alert_scheduled", - schedule: "cron" in repeat ? repeat.cron : `every:${clampedIntervalMinutes}m`, - intervalMinutes: clampedIntervalMinutes, + schedule: describeRepeat(repeat), + intervalMinutes: interval, mode: "legacy", }); } @@ -848,12 +931,14 @@ export async function scheduleNotifications() { if (settings.costAlertEnabled) { const bindings = await getEnabledBindingsByType("cost_alert"); - const interval = settings.costAlertCheckInterval ?? 60; - const defaultCron = `*/${interval} * * * *`; + const interval = clampIntervalMinutes(settings.costAlertCheckInterval ?? 60); for (const binding of bindings) { - const cron = binding.scheduleCron ?? defaultCron; const tz = binding.scheduleTimezone ?? systemTimezone; + // 优先级:绑定自定义 cron(支持 tz)> 默认间隔(按 N 是否整除 60 选择 cron 或固定 every)。 + const repeat = binding.scheduleCron + ? { cron: binding.scheduleCron, tz } + : intervalToRepeat(interval, tz); await queue.add( { @@ -862,7 +947,7 @@ export async function scheduleNotifications() { bindingId: binding.id, }, { - repeat: { cron, tz }, + repeat, jobId: `cost-alert:${binding.id}`, } ); @@ -870,7 +955,7 @@ export async function scheduleNotifications() { logger.info({ action: "cost_alert_scheduled", - schedule: defaultCron, + schedule: describeRepeat(intervalToRepeat(interval)), intervalMinutes: interval, targets: bindings.length, mode: "targets", @@ -879,19 +964,12 @@ export async function scheduleNotifications() { if (settings.cacheHitRateAlertEnabled) { const bindings = await getEnabledBindingsByType("cache_hit_rate_alert"); - const intervalMinutesRaw = settings.cacheHitRateAlertCheckInterval ?? 5; - const intervalMinutes = Math.max(1, Math.trunc(intervalMinutesRaw)); - const clampedIntervalMinutes = Math.min(intervalMinutes, 24 * 60); - const defaultCron = `*/${clampedIntervalMinutes} * * * *`; - const repeat = - clampedIntervalMinutes <= 59 - ? { cron: defaultCron, tz: systemTimezone } - : { every: clampedIntervalMinutes * 60 * 1000 }; + const interval = clampIntervalMinutes(settings.cacheHitRateAlertCheckInterval ?? 5); + const repeat = intervalToRepeat(interval, systemTimezone); if (bindings.length > 0) { // 注意:这里刻意只调度一个共享的 repeat 作业,然后在处理器内 fan-out 到所有 bindings。 // 这样可以避免对每个 binding 重复计算同一份 payload;代价是 binding 的 scheduleCron/scheduleTimezone 将被忽略。 - // 另外:interval > 59 分钟会使用 repeat.every(固定间隔,不对齐整点,也不支持 tz),这是 Bull cron 分钟字段的限制。 // 若未来需要支持 per-binding 的 cron/timezone,需要改为“每个 binding 一个 repeat 作业”或引入更细粒度的调度层。 await queue.add( { @@ -904,17 +982,16 @@ export async function scheduleNotifications() { ); logger.info({ action: "cache_hit_rate_alert_scheduled", - schedule: "cron" in repeat ? repeat.cron : `every:${clampedIntervalMinutes}m`, - intervalMinutes: clampedIntervalMinutes, + schedule: describeRepeat(repeat), + intervalMinutes: interval, targets: bindings.length, mode: "targets", }); } else { logger.info({ action: "cache_hit_rate_alert_schedule_skipped", - schedule: - clampedIntervalMinutes <= 59 ? defaultCron : `every:${clampedIntervalMinutes}m`, - intervalMinutes: clampedIntervalMinutes, + schedule: describeRepeat(repeat), + intervalMinutes: interval, reason: "no_bindings", mode: "targets", }); diff --git a/tests/unit/notification/notification-queue.test.ts b/tests/unit/notification/notification-queue.test.ts new file mode 100644 index 000000000..2a8ccb03a --- /dev/null +++ b/tests/unit/notification/notification-queue.test.ts @@ -0,0 +1,456 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// 受控的依赖 mock(在 beforeEach 中通过 vi.doMock 装配,跨 resetModules 复用同一组 spy) +const mockGetNotificationSettings = vi.fn(); +const mockGenerateDailyLeaderboard = vi.fn(); +const mockGenerateCostAlerts = vi.fn(); +const mockSendWebhookMessage = vi.fn(); +const mockGetEnabledBindingsByType = vi.fn(async () => []); + +const queueAdd = vi.fn(async () => ({})); +const queueGetRepeatableJobs = vi.fn(async () => [] as Array<{ key: string }>); +const queueRemoveRepeatableByKey = vi.fn(async () => {}); + +type MockJob = { + id: string; + timestamp: number; + data: Record; + update: (data: unknown) => Promise; +}; + +class MockQueue { + processHandler: ((job: MockJob) => Promise) | null = null; + add = queueAdd; + getRepeatableJobs = queueGetRepeatableJobs; + removeRepeatableByKey = queueRemoveRepeatableByKey; + process = vi.fn((fn: (job: MockJob) => Promise) => { + this.processHandler = fn; + }); + on = vi.fn(); + close = vi.fn(async () => {}); + + constructor() { + capturedQueue = this; + } +} + +let capturedQueue: MockQueue | null = null; + +function makeSettings(overrides: Record = {}) { + return { + id: 1, + enabled: false, + useLegacyMode: true, + circuitBreakerEnabled: false, + circuitBreakerWebhook: null, + dailyLeaderboardEnabled: false, + dailyLeaderboardWebhook: null, + dailyLeaderboardTime: "18:00", + dailyLeaderboardTopN: 5, + costAlertEnabled: false, + costAlertWebhook: null, + costAlertThreshold: "0.80", + costAlertCheckInterval: 60, + cacheHitRateAlertEnabled: false, + cacheHitRateAlertWebhook: null, + cacheHitRateAlertWindowMode: "auto", + cacheHitRateAlertCheckInterval: 5, + cacheHitRateAlertHistoricalLookbackDays: 7, + cacheHitRateAlertMinEligibleRequests: 20, + cacheHitRateAlertMinEligibleTokens: 0, + cacheHitRateAlertAbsMin: "0.05", + cacheHitRateAlertDropRel: "0.3", + cacheHitRateAlertDropAbs: "0.1", + cacheHitRateAlertCooldownMinutes: 30, + cacheHitRateAlertTopN: 10, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +beforeEach(() => { + vi.resetModules(); + capturedQueue = null; + process.env.REDIS_URL = "redis://localhost:6379"; + + vi.doMock("bull", () => ({ default: MockQueue })); + + vi.doMock("@/repository/notifications", () => ({ + getNotificationSettings: mockGetNotificationSettings, + })); + + vi.doMock("@/repository/notification-bindings", () => ({ + getEnabledBindingsByType: mockGetEnabledBindingsByType, + getBindingById: vi.fn(async () => null), + })); + + vi.doMock("@/repository/webhook-targets", () => ({ + getWebhookTargetById: vi.fn(async () => ({ isEnabled: true })), + })); + + vi.doMock("@/lib/notification/tasks/daily-leaderboard", () => ({ + generateDailyLeaderboard: mockGenerateDailyLeaderboard, + })); + + vi.doMock("@/lib/notification/tasks/cost-alert", () => ({ + generateCostAlerts: mockGenerateCostAlerts, + })); + + vi.doMock("@/lib/notification/tasks/cache-hit-rate-alert", () => ({ + applyCacheHitRateAlertCooldownToPayload: vi.fn(), + buildCacheHitRateAlertCooldownKey: vi.fn(), + commitCacheHitRateAlertCooldown: vi.fn(), + generateCacheHitRateAlertPayload: vi.fn(), + })); + + vi.doMock("@/lib/webhook", () => ({ + buildCacheHitRateAlertMessage: vi.fn(() => ({})), + buildCircuitBreakerMessage: vi.fn(() => ({})), + buildCostAlertMessage: vi.fn(() => ({})), + buildDailyLeaderboardMessage: vi.fn(() => ({})), + sendWebhookMessage: mockSendWebhookMessage, + })); + + vi.doMock("@/lib/utils/timezone", () => ({ + resolveSystemTimezone: vi.fn(async () => "UTC"), + })); + + vi.doMock("@/lib/logger", () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + trace: vi.fn(), + fatal: vi.fn(), + }, + })); + + mockSendWebhookMessage.mockResolvedValue({ success: true }); + mockGenerateDailyLeaderboard.mockResolvedValue({ date: "2026-06-02", entries: [] }); + mockGenerateCostAlerts.mockResolvedValue([{ providerName: "p" }]); + mockGetEnabledBindingsByType.mockResolvedValue([]); + queueGetRepeatableJobs.mockResolvedValue([]); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +/** 初始化队列并返回捕获的 process 处理器 */ +async function loadProcessor() { + const mod = await import("@/lib/notification/notification-queue"); + // addNotificationJob 内部触发 getNotificationQueue(),注册并捕获 process 处理器 + await mod.addNotificationJob("daily-leaderboard", "https://example.com/hook", { + date: "2026-06-02", + entries: [], + } as never); + if (!capturedQueue?.processHandler) { + throw new Error("process handler not captured"); + } + return capturedQueue.processHandler; +} + +function makeJob(data: Record): MockJob { + return { id: "job-1", timestamp: 1000, data, update: vi.fn(async () => {}) }; +} + +describe("notification queue processor - daily-leaderboard", () => { + it("skips sending when the master switch is off (issue #1236)", async () => { + mockGetNotificationSettings.mockResolvedValue( + makeSettings({ enabled: false, dailyLeaderboardEnabled: true }) + ); + + const handler = await loadProcessor(); + const result = await handler( + makeJob({ type: "daily-leaderboard", webhookUrl: "https://example.com/hook" }) + ); + + expect(result).toEqual({ success: true, skipped: true }); + expect(mockGenerateDailyLeaderboard).not.toHaveBeenCalled(); + expect(mockSendWebhookMessage).not.toHaveBeenCalled(); + }); + + it("skips sending when the daily-leaderboard sub-switch is off", async () => { + mockGetNotificationSettings.mockResolvedValue( + makeSettings({ enabled: true, dailyLeaderboardEnabled: false }) + ); + + const handler = await loadProcessor(); + const result = await handler( + makeJob({ type: "daily-leaderboard", webhookUrl: "https://example.com/hook" }) + ); + + expect(result).toEqual({ success: true, skipped: true }); + expect(mockSendWebhookMessage).not.toHaveBeenCalled(); + }); + + it("sends when both master and sub-switch are enabled", async () => { + mockGetNotificationSettings.mockResolvedValue( + makeSettings({ enabled: true, dailyLeaderboardEnabled: true }) + ); + + const handler = await loadProcessor(); + const result = await handler( + makeJob({ type: "daily-leaderboard", webhookUrl: "https://example.com/hook" }) + ); + + expect(result).toEqual({ success: true }); + expect(mockGenerateDailyLeaderboard).toHaveBeenCalledTimes(1); + expect(mockSendWebhookMessage).toHaveBeenCalledTimes(1); + }); +}); + +describe("notification queue processor - cost-alert", () => { + it("skips sending when the master switch is off", async () => { + mockGetNotificationSettings.mockResolvedValue( + makeSettings({ enabled: false, costAlertEnabled: true }) + ); + + const handler = await loadProcessor(); + const result = await handler( + makeJob({ type: "cost-alert", webhookUrl: "https://example.com/hook" }) + ); + + expect(result).toEqual({ success: true, skipped: true }); + expect(mockGenerateCostAlerts).not.toHaveBeenCalled(); + expect(mockSendWebhookMessage).not.toHaveBeenCalled(); + }); + + it("skips sending when the cost-alert sub-switch is off", async () => { + mockGetNotificationSettings.mockResolvedValue( + makeSettings({ enabled: true, costAlertEnabled: false }) + ); + + const handler = await loadProcessor(); + const result = await handler( + makeJob({ type: "cost-alert", webhookUrl: "https://example.com/hook" }) + ); + + expect(result).toEqual({ success: true, skipped: true }); + expect(mockSendWebhookMessage).not.toHaveBeenCalled(); + }); + + it("sends when both master and sub-switch are enabled", async () => { + mockGetNotificationSettings.mockResolvedValue( + makeSettings({ enabled: true, costAlertEnabled: true }) + ); + + const handler = await loadProcessor(); + const result = await handler( + makeJob({ type: "cost-alert", webhookUrl: "https://example.com/hook" }) + ); + + expect(result).toEqual({ success: true }); + expect(mockGenerateCostAlerts).toHaveBeenCalledTimes(1); + expect(mockSendWebhookMessage).toHaveBeenCalledTimes(1); + }); +}); + +describe("notification queue processor - circuit-breaker", () => { + const data = { + providerName: "OpenAI", + providerId: 1, + failureCount: 5, + retryAt: "2026-06-02T12:30:00Z", + }; + + it("skips sending when the master switch is off", async () => { + mockGetNotificationSettings.mockResolvedValue( + makeSettings({ enabled: false, circuitBreakerEnabled: true }) + ); + + const handler = await loadProcessor(); + const result = await handler( + makeJob({ type: "circuit-breaker", webhookUrl: "https://example.com/hook", data }) + ); + + expect(result).toEqual({ success: true, skipped: true }); + expect(mockSendWebhookMessage).not.toHaveBeenCalled(); + }); + + it("skips sending when the circuit-breaker sub-switch is off", async () => { + mockGetNotificationSettings.mockResolvedValue( + makeSettings({ enabled: true, circuitBreakerEnabled: false }) + ); + + const handler = await loadProcessor(); + const result = await handler( + makeJob({ type: "circuit-breaker", webhookUrl: "https://example.com/hook", data }) + ); + + expect(result).toEqual({ success: true, skipped: true }); + expect(mockSendWebhookMessage).not.toHaveBeenCalled(); + }); + + it("sends when both master and sub-switch are enabled", async () => { + mockGetNotificationSettings.mockResolvedValue( + makeSettings({ enabled: true, circuitBreakerEnabled: true }) + ); + + const handler = await loadProcessor(); + const result = await handler( + makeJob({ type: "circuit-breaker", webhookUrl: "https://example.com/hook", data }) + ); + + expect(result).toEqual({ success: true }); + expect(mockSendWebhookMessage).toHaveBeenCalledTimes(1); + }); +}); + +describe("scheduleNotifications", () => { + it("removes all repeatable jobs when the master switch is off", async () => { + mockGetNotificationSettings.mockResolvedValue(makeSettings({ enabled: false })); + queueGetRepeatableJobs.mockResolvedValue([{ key: "k1" }, { key: "k2" }]); + + const { scheduleNotifications } = await import("@/lib/notification/notification-queue"); + await scheduleNotifications(); + + expect(queueRemoveRepeatableByKey).toHaveBeenCalledWith("k1"); + expect(queueRemoveRepeatableByKey).toHaveBeenCalledWith("k2"); + expect(queueAdd).not.toHaveBeenCalled(); + }); + + it("attempts to remove every repeatable job even when one removal fails (master off)", async () => { + mockGetNotificationSettings.mockResolvedValue(makeSettings({ enabled: false })); + queueGetRepeatableJobs.mockResolvedValue([{ key: "k1" }, { key: "k2" }]); + queueRemoveRepeatableByKey.mockRejectedValueOnce(new Error("redis down")); + + const { scheduleNotifications } = await import("@/lib/notification/notification-queue"); + await expect(scheduleNotifications()).resolves.toBeUndefined(); + + expect(queueRemoveRepeatableByKey).toHaveBeenCalledTimes(2); + }); + + it("aborts adding new jobs when an old repeatable cannot be removed (avoids double-firing)", async () => { + mockGetNotificationSettings.mockResolvedValue( + makeSettings({ + enabled: true, + useLegacyMode: true, + costAlertEnabled: true, + costAlertWebhook: "https://example.com/hook", + }) + ); + queueGetRepeatableJobs.mockResolvedValue([{ key: "stale" }]); + queueRemoveRepeatableByKey.mockRejectedValueOnce(new Error("redis down")); + + const { scheduleNotifications } = await import("@/lib/notification/notification-queue"); + await scheduleNotifications(); + + // 旧任务未能移除时不得新增任务,否则新旧任务会同时触发 + expect(queueAdd).not.toHaveBeenCalled(); + }); + + it("uses {every} for an interval that does not divide 60 (legacy)", async () => { + mockGetNotificationSettings.mockResolvedValue( + makeSettings({ + enabled: true, + useLegacyMode: true, + costAlertEnabled: true, + costAlertWebhook: "https://example.com/hook", + costAlertCheckInterval: 45, + }) + ); + + const { scheduleNotifications } = await import("@/lib/notification/notification-queue"); + await scheduleNotifications(); + + const costCall = queueAdd.mock.calls.find( + (c) => (c[0] as { type?: string })?.type === "cost-alert" + ); + expect((costCall?.[1] as { repeat?: unknown })?.repeat).toEqual({ every: 45 * 60 * 1000 }); + }); + + it("schedules targets-mode cost-alert with binding jobId and tz (cron path)", async () => { + mockGetNotificationSettings.mockResolvedValue( + makeSettings({ + enabled: true, + useLegacyMode: false, + costAlertEnabled: true, + costAlertCheckInterval: 30, + }) + ); + mockGetEnabledBindingsByType.mockImplementation(async (type: string) => + type === "cost_alert" + ? [{ id: 7, targetId: 3, scheduleCron: null, scheduleTimezone: "Asia/Tokyo" }] + : [] + ); + + const { scheduleNotifications } = await import("@/lib/notification/notification-queue"); + await scheduleNotifications(); + + const costCall = queueAdd.mock.calls.find( + (c) => (c[0] as { type?: string })?.type === "cost-alert" + ); + expect(costCall?.[0]).toMatchObject({ type: "cost-alert", targetId: 3, bindingId: 7 }); + expect(costCall?.[1]).toMatchObject({ + repeat: { cron: "*/30 * * * *", tz: "Asia/Tokyo" }, + jobId: "cost-alert:7", + }); + }); + + it("schedules targets-mode cost-alert with {every} for interval >= 60 (drops tz)", async () => { + mockGetNotificationSettings.mockResolvedValue( + makeSettings({ + enabled: true, + useLegacyMode: false, + costAlertEnabled: true, + costAlertCheckInterval: 120, + }) + ); + mockGetEnabledBindingsByType.mockImplementation(async (type: string) => + type === "cost_alert" + ? [{ id: 9, targetId: 4, scheduleCron: null, scheduleTimezone: "Asia/Tokyo" }] + : [] + ); + + const { scheduleNotifications } = await import("@/lib/notification/notification-queue"); + await scheduleNotifications(); + + const costCall = queueAdd.mock.calls.find( + (c) => (c[0] as { type?: string })?.type === "cost-alert" + ); + expect((costCall?.[1] as { repeat?: unknown })?.repeat).toEqual({ every: 120 * 60 * 1000 }); + }); + + it("uses {every} instead of */60 cron for cost-alert interval >= 60 (legacy)", async () => { + mockGetNotificationSettings.mockResolvedValue( + makeSettings({ + enabled: true, + useLegacyMode: true, + costAlertEnabled: true, + costAlertWebhook: "https://example.com/hook", + costAlertCheckInterval: 60, + }) + ); + + const { scheduleNotifications } = await import("@/lib/notification/notification-queue"); + await scheduleNotifications(); + + const costCall = queueAdd.mock.calls.find( + (c) => (c[0] as { type?: string })?.type === "cost-alert" + ); + expect((costCall?.[1] as { repeat?: unknown })?.repeat).toEqual({ every: 60 * 60 * 1000 }); + }); + + it("uses a step cron for cost-alert interval < 60 (legacy)", async () => { + mockGetNotificationSettings.mockResolvedValue( + makeSettings({ + enabled: true, + useLegacyMode: true, + costAlertEnabled: true, + costAlertWebhook: "https://example.com/hook", + costAlertCheckInterval: 30, + }) + ); + + const { scheduleNotifications } = await import("@/lib/notification/notification-queue"); + await scheduleNotifications(); + + const costCall = queueAdd.mock.calls.find( + (c) => (c[0] as { type?: string })?.type === "cost-alert" + ); + expect((costCall?.[1] as { repeat?: unknown })?.repeat).toEqual({ cron: "*/30 * * * *" }); + }); +}); From 214aebaf92dc1d0ff3da187e250ab5b4918b4a42 Mon Sep 17 00:00:00 2001 From: Ding <44717411+ding113@users.noreply.github.com> Date: Tue, 2 Jun 2026 21:37:21 +0800 Subject: [PATCH 06/13] feat(proxy): support compressed request bodies (zstd, gzip, deflate, br) (#1238) Decode inbound content-encoding (zstd/gzip/deflate/br) so codex and other clients can send compressed request bodies. Single-layer cap + decompression-bomb guard; strips content-encoding after decode. Requires Node >=22.15 (native node:zlib zstd). Closes #1193. --- .github/workflows/pr-check.yml | 2 +- Dockerfile | 2 +- README.en.md | 2 +- README.md | 2 +- deploy/Dockerfile | 3 + package.json | 3 + src/app/v1/_lib/proxy/request-body-codec.ts | 220 ++++++++++++++++++ src/app/v1/_lib/proxy/session.ts | 47 +++- tests/unit/proxy/request-body-codec.test.ts | 170 ++++++++++++++ .../unit/proxy/session-request-decode.test.ts | 152 ++++++++++++ 10 files changed, 588 insertions(+), 15 deletions(-) create mode 100644 src/app/v1/_lib/proxy/request-body-codec.ts create mode 100644 tests/unit/proxy/request-body-codec.test.ts create mode 100644 tests/unit/proxy/session-request-decode.test.ts diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index c68e73ac3..c06a6d2b3 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -31,7 +31,7 @@ jobs: - name: 🟢 Setup Node.js uses: actions/setup-node@v6 with: - node-version: '20' + node-version: '22' - name: Cache Bun package cache uses: actions/cache@v5 diff --git a/Dockerfile b/Dockerfile index 06dc8b4b1..5518ae51e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,7 +12,7 @@ ENV NEXT_TELEMETRY_DISABLED=1 ENV CI=true RUN --mount=type=cache,target=/app/.next/cache bun run build -FROM node:20-slim AS runner +FROM node:22-slim AS runner WORKDIR /app ENV NODE_ENV=production ENV PORT=3000 diff --git a/README.en.md b/README.en.md index 9b867709f..0257fdd1f 100644 --- a/README.en.md +++ b/README.en.md @@ -124,7 +124,7 @@ Register now via this link ### Requirements - Docker and Docker Compose (latest version recommended) -- Optional (for local development): Node.js ≥ 20, Bun ≥ 1.3 +- Optional (for local development): Node.js ≥ 22.15 (inbound zstd request-body decompression uses native `node:zlib` zstd), Bun ≥ 1.3 ### 🚀 One-Click Deployment Script (✨ Recommended - Fully Automated) diff --git a/README.md b/README.md index e72b82e46..1dd018c66 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,7 @@ Claude Code / Codex / Gemini 官方渠道价格低至原价的 38% / 6% / 9%, ### 环境要求 - Docker 与 Docker Compose(推荐使用最新版本) -- 可选(本地开发):Node.js ≥ 20,Bun ≥ 1.3 +- 可选(本地开发):Node.js ≥ 22.15(入站请求体 zstd 解压依赖原生 `node:zlib` zstd),Bun ≥ 1.3 ### 🚀 一键部署脚本(✨ 推荐方式,全自动安装) diff --git a/deploy/Dockerfile b/deploy/Dockerfile index 2c6596280..27d5c62bb 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -28,6 +28,9 @@ ENV CI=true RUN bun run build # 运行阶段:使用 Node.js(避免 Bun 流式响应内存泄漏 Issue #18488) +# 要求 Node >= 22.15(入站请求体 zstd 解压依赖 node:zlib 原生 zstd,见 +# src/app/v1/_lib/proxy/request-body-codec.ts 与 package.json engines)。 +# node:trixie-slim 基于 Debian Trixie,提供 Node 24+,满足该要求。 FROM node:trixie-slim AS runner ENV NODE_ENV=production ENV PORT=3000 diff --git a/package.json b/package.json index 05cf18c45..52f3bfbec 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,9 @@ "name": "claude-code-hub", "version": "0.8.0", "private": true, + "engines": { + "node": ">=22.15.0" + }, "scripts": { "dev": "tsgo -p tsconfig.json --noEmit && next dev --port 13500", "dev:server": "NODE_ENV=development node server.js", diff --git a/src/app/v1/_lib/proxy/request-body-codec.ts b/src/app/v1/_lib/proxy/request-body-codec.ts new file mode 100644 index 000000000..86cb1f532 --- /dev/null +++ b/src/app/v1/_lib/proxy/request-body-codec.ts @@ -0,0 +1,220 @@ +/** + * 入站请求体解压(content-encoding)。 + * + * 背景:Codex 等客户端会用 `content-encoding: zstd`(也可能是 gzip/deflate/br) + * 压缩请求体后发往代理。代理需要解压才能解析出 model、做敏感词/过滤、计费与日志。 + * 解压后由上层剥离 `content-encoding` 头,并以明文转发给上游(content-length 会被 + * 出站黑名单重算)。 + * + * 运行时为 Node.js(route.ts: `runtime = "nodejs"`)。`node:zlib` 自 Node 22.15 起 + * 原生提供 zstd 同步解压(gzip/deflate/br 更早即有),无需第三方依赖。该最低版本要求 + * 由 package.json 的 `engines` 字段声明;生产镜像(deploy/Dockerfile 的 node:trixie-slim) + * 运行 Node 24+,满足要求。 + * + * 注意:解压在 `ProxySession.fromContext` 内、鉴权 guard 之前同步执行(与既有的请求体 + * `JSON.parse` 一致)。单层编码 + maxOutputBytes 输出上限将其最坏开销限定为单次有界解压。 + */ +import { + brotliDecompressSync, + gunzipSync, + inflateRawSync, + inflateSync, + zstdDecompressSync, +} from "node:zlib"; +import { logger } from "@/lib/logger"; +import { ProxyError } from "./errors"; + +/** + * 解压输出硬上限,防御解压炸弹(decompression bomb):很小的压缩体可能展开成数 GB + * 导致 OOM。这是一个独立的内存兜底阈值——注意 /v1、/v1beta 代理路径并不受 + * next.config.ts 的 proxyClientMaxBodySize 钳制(见 proxy.matcher.ts),故入站压缩体 + * 体积本身不另设限。逐层解压按此上限增量限制,超过即按 413 拒绝。 + */ +export const MAX_DECOMPRESSED_REQUEST_BYTES = 100 * 1024 * 1024; + +/** + * content-encoding 编码链最大层数。真实客户端(含 Codex)只发单层编码;允许多层会让一个 + * 很小的压缩体经多次同步解压放大 CPU 开销与峰值内存(每层最多解到 maxOutputBytes,层间 + * 缓冲还会短暂共存),无正当用途。限制为单层后,峰值解压内存即由 maxOutputBytes 一档兜底; + * 超出按 400 拒绝。 + */ +export const MAX_CONTENT_ENCODING_LAYERS = 1; + +const SUPPORTED_ENCODINGS = new Set(["zstd", "gzip", "x-gzip", "deflate", "br"]); + +export interface DecodeRequestBodyOptions { + /** 解压输出字节上限,默认 {@link MAX_DECOMPRESSED_REQUEST_BYTES}。主要用于测试。 */ + maxOutputBytes?: number; +} + +export interface DecodedRequestBody { + /** + * 请求体明文字节。解压时为新分配的解压结果;未解压时即原始字节,可能与入参共享 + * 底层内存(仅供只读消费,调用方不得改写)。 + */ + buffer: ArrayBuffer; + /** 是否实际执行了解压。 */ + decoded: boolean; + /** 实际应用的编码链(按解码顺序,如 "zstd" 或 "br, gzip");未解压时为 null。 */ + encoding: string | null; + originalByteLength: number; + decodedByteLength: number; +} + +/** + * 将 `content-encoding` 头解析为编码 token 列表(小写、去空白、去除 identity)。 + * HTTP 语义为「按列出顺序逐层应用」,因此解码需反向进行。 + */ +export function parseContentEncoding(header: string | null | undefined): string[] { + if (!header) return []; + return header + .split(",") + .map((token) => token.trim().toLowerCase()) + .filter((token) => token.length > 0 && token !== "identity"); +} + +function isOutputTooLargeError(err: unknown): boolean { + return ( + err instanceof RangeError || + (err as NodeJS.ErrnoException | undefined)?.code === "ERR_BUFFER_TOO_LARGE" + ); +} + +function decodeOne(buffer: Buffer, encoding: string, maxOutputLength: number): Buffer { + switch (encoding) { + case "zstd": + return zstdDecompressSync(buffer, { maxOutputLength }); + case "gzip": + case "x-gzip": + return gunzipSync(buffer, { maxOutputLength }); + case "br": + return brotliDecompressSync(buffer, { maxOutputLength }); + case "deflate": + // HTTP `deflate` 名义上是 zlib 包装,但不少实现发送的是裸 deflate 流, + // 因此先按 zlib 解,失败再回退裸 deflate。解压炸弹错误不回退,直接抛出。 + try { + return inflateSync(buffer, { maxOutputLength }); + } catch (err) { + if (isOutputTooLargeError(err)) throw err; + return inflateRawSync(buffer, { maxOutputLength }); + } + default: + // parseContentEncoding + 支持集校验已保证不会走到这里。 + throw new Error(`Unsupported content-encoding: ${encoding}`); + } +} + +// 返回的 buffer 仅被下游只读消费(TextDecoder / JSON.parse / 透传转发),不会被改写, +// 故视图正好完整覆盖底层 ArrayBuffer 时直接复用、避免对最大 100MB 数据做无谓拷贝。 +function toArrayBuffer(input: ArrayBuffer | Uint8Array): ArrayBuffer { + if (input instanceof ArrayBuffer) return input; + if (input.byteOffset === 0 && input.byteLength === input.buffer.byteLength) { + return input.buffer as ArrayBuffer; + } + return input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength) as ArrayBuffer; +} + +function bufferToArrayBuffer(buf: Buffer): ArrayBuffer { + if (buf.byteOffset === 0 && buf.byteLength === buf.buffer.byteLength) { + return buf.buffer as ArrayBuffer; + } + return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer; +} + +/** + * 按 `content-encoding` 解压入站请求体。 + * + * - 无编码 / identity / 空体:原样返回(decoded=false)。 + * - 含不支持的编码:不解压、原样返回(decoded=false)并告警,由上层透传给上游。 + * - 支持的编码:逐层反向解压;超过上限抛 413,损坏流抛 400。 + */ +export function decodeRequestBody( + input: ArrayBuffer | Uint8Array, + contentEncoding: string | null | undefined, + options?: DecodeRequestBodyOptions +): DecodedRequestBody { + const maxOutputBytes = options?.maxOutputBytes ?? MAX_DECOMPRESSED_REQUEST_BYTES; + const originalByteLength = input.byteLength; + + const encodings = parseContentEncoding(contentEncoding); + if (encodings.length === 0) { + const buffer = toArrayBuffer(input); + return { + buffer, + decoded: false, + encoding: null, + originalByteLength, + decodedByteLength: buffer.byteLength, + }; + } + + // 空体:不可能是有效压缩流,在层数/支持集校验之前直接透传,避免对安全的空请求误报 400。 + if (originalByteLength === 0) { + const buffer = toArrayBuffer(input); + return { + buffer, + decoded: false, + encoding: null, + originalByteLength, + decodedByteLength: buffer.byteLength, + }; + } + + if (encodings.length > MAX_CONTENT_ENCODING_LAYERS) { + // 防御多层编码放大:每层都是一次同步解压,过多层数纯属攻击/异常。 + throw new ProxyError( + `Too many content-encoding layers (${encodings.length}); at most ${MAX_CONTENT_ENCODING_LAYERS} are allowed.`, + 400 + ); + } + + const unsupported = encodings.filter((enc) => !SUPPORTED_ENCODINGS.has(enc)); + if (unsupported.length > 0) { + // 透传:不解压、保留原始字节与 content-encoding 头,交给上游处理。 + logger.warn("[decodeRequestBody] Unsupported content-encoding, passing through untouched", { + contentEncoding, + unsupported, + }); + const buffer = toArrayBuffer(input); + return { + buffer, + decoded: false, + encoding: null, + originalByteLength, + decodedByteLength: buffer.byteLength, + }; + } + + // 按 HTTP 语义反向逐层解码。 + const decodeOrder = [...encodings].reverse(); + let current: Buffer = Buffer.from(input instanceof Uint8Array ? input : new Uint8Array(input)); + for (const enc of decodeOrder) { + try { + current = decodeOne(current, enc, maxOutputBytes); + } catch (err) { + if (isOutputTooLargeError(err)) { + throw new ProxyError( + `Request body exceeds the maximum decompressed size (${maxOutputBytes} bytes).`, + 413 + ); + } + const message = err instanceof Error ? err.message : String(err); + throw new ProxyError(`Failed to decode '${enc}' request body: ${message}`, 400); + } + } + + const buffer = bufferToArrayBuffer(current); + logger.debug("[decodeRequestBody] Decompressed request body", { + encoding: decodeOrder.join(", "), + originalByteLength, + decodedByteLength: buffer.byteLength, + }); + + return { + buffer, + decoded: true, + encoding: decodeOrder.join(", "), + originalByteLength, + decodedByteLength: buffer.byteLength, + }; +} diff --git a/src/app/v1/_lib/proxy/session.ts b/src/app/v1/_lib/proxy/session.ts index b6dfda24e..6c2c1f36d 100644 --- a/src/app/v1/_lib/proxy/session.ts +++ b/src/app/v1/_lib/proxy/session.ts @@ -29,6 +29,7 @@ import { type OpenAIImageRequestMetadata, parseOpenAIImageMultipartMetadata, } from "./openai-image-compat"; +import { decodeRequestBody } from "./request-body-codec"; /** * Classification of an auth failure, used to decide whether to record the @@ -82,6 +83,12 @@ interface RequestBodyResult { contentLength?: number | null; actualBodyBytes?: number; imageRequestMetadata?: OpenAIImageRequestMetadata | null; + /** + * 入站请求体实际解压所用的 content-encoding(链)。 + * 非空表示代理已解压请求体,调用方需剥离出站 `content-encoding` 头, + * 避免上游对明文再次解码。未解压时为 undefined。 + */ + decodedContentEncoding?: string; } export class ProxySession { @@ -237,6 +244,12 @@ export class ProxySession { const headerLog = formatHeadersForLog(headers); const bodyResult = await parseRequestBody(c); + // 已在代理内解压请求体:剥离 content-encoding,避免上游对明文再次解码 + // (raw passthrough 也会转发解压后的字节;content-length 由出站黑名单重算)。 + if (bodyResult.decodedContentEncoding) { + headers.delete("content-encoding"); + } + // 提取 User-Agent const userAgent = headers.get("user-agent") || null; @@ -1157,27 +1170,29 @@ async function parseRequestBody(c: Context): Promise { const contentLength = parseContentLengthHeader(c.req.header("content-length")); const contentType = c.req.header("content-type") ?? null; + const contentEncoding = c.req.header("content-encoding") ?? null; const pathname = new URL(c.req.url).pathname; - const requestBodyBuffer = await c.req.raw.clone().arrayBuffer(); - const actualBodyBytes = requestBodyBuffer.byteLength; - const requestBodyText = new TextDecoder().decode(requestBodyBuffer); + // 原始(可能被压缩的)入站字节:用于截断检测与 multipart 透传。 + const rawBodyBuffer = await c.req.raw.clone().arrayBuffer(); + const receivedBodyBytes = rawBodyBuffer.byteLength; // Truncation detection: warn only when both conditions are met // 1. Absolute difference > 1MB (avoid false positives from minor discrepancies) // 2. Actual body < 80% of expected (significant truncation) + // 注意:基于「接收到的原始字节」与 content-length 比较(同为压缩域),不受解压影响。 const MIN_TRUNCATION_DIFF_BYTES = 1024 * 1024; // 1MB const TRUNCATION_RATIO_THRESHOLD = 0.8; if ( contentLength !== null && - contentLength - actualBodyBytes > MIN_TRUNCATION_DIFF_BYTES && - actualBodyBytes < contentLength * TRUNCATION_RATIO_THRESHOLD + contentLength - receivedBodyBytes > MIN_TRUNCATION_DIFF_BYTES && + receivedBodyBytes < contentLength * TRUNCATION_RATIO_THRESHOLD ) { logger.warn("[parseRequestBody] Possible body truncation detected", { - pathname: new URL(c.req.url).pathname, + pathname, method, contentLength, - actualBodyBytes, - ratio: (actualBodyBytes / contentLength).toFixed(2), + actualBodyBytes: receivedBodyBytes, + ratio: (receivedBodyBytes / contentLength).toFixed(2), }); } @@ -1188,6 +1203,7 @@ async function parseRequestBody(c: Context): Promise { if (getOpenAIImageEndpoint(pathname) && isOpenAIImageMultipartContentType(contentType)) { // 图片 multipart 请求保留 sidecar metadata,并为过滤/敏感词提供文本字段视图。 + // multipart 请求体不会被 content-encoding 压缩,按原始字节透传。 imageRequestMetadata = await parseOpenAIImageMultipartMetadata( c.req.raw, pathname, @@ -1203,13 +1219,19 @@ async function parseRequestBody(c: Context): Promise { requestMessage, requestBodyLog, requestBodyLogNote, - requestBodyBuffer, + requestBodyBuffer: rawBodyBuffer, contentLength, - actualBodyBytes, + actualBodyBytes: receivedBodyBytes, imageRequestMetadata, }; } + // 非 multipart:按 content-encoding(zstd/gzip/deflate/br)解压请求体, + // 使下游模型解析、过滤、计费、日志与转发都基于明文。 + const decodedBody = decodeRequestBody(rawBodyBuffer, contentEncoding); + const requestBodyBuffer = decodedBody.buffer; + const requestBodyText = new TextDecoder().decode(requestBodyBuffer); + try { const parsedMessage = JSON.parse(requestBodyText) as Record; requestMessage = parsedMessage; // 保留原始数据用于业务逻辑 @@ -1226,7 +1248,10 @@ async function parseRequestBody(c: Context): Promise { requestBodyLogNote, requestBodyBuffer, contentLength, - actualBodyBytes, + // 维持原语义:actualBodyBytes 表示「接收到的原始(线上)字节」,供 + // isLargeRequestBody 的截断提示判断使用,不受解压后体积影响。 + actualBodyBytes: receivedBodyBytes, imageRequestMetadata, + decodedContentEncoding: decodedBody.encoding ?? undefined, }; } diff --git a/tests/unit/proxy/request-body-codec.test.ts b/tests/unit/proxy/request-body-codec.test.ts new file mode 100644 index 000000000..68dac6df2 --- /dev/null +++ b/tests/unit/proxy/request-body-codec.test.ts @@ -0,0 +1,170 @@ +import { + brotliCompressSync, + deflateRawSync, + deflateSync, + gzipSync, + zstdCompressSync, +} from "node:zlib"; +import { describe, expect, it } from "vitest"; +import { ProxyError } from "@/app/v1/_lib/proxy/errors"; +import { + decodeRequestBody, + MAX_CONTENT_ENCODING_LAYERS, + MAX_DECOMPRESSED_REQUEST_BYTES, + parseContentEncoding, +} from "@/app/v1/_lib/proxy/request-body-codec"; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +const SAMPLE = JSON.stringify({ + model: "gpt-5-codex", + stream: true, + input: [{ role: "user", content: "hello zstd" }], +}); + +function raw(text = SAMPLE): Uint8Array { + return encoder.encode(text); +} + +function decodedText(result: { buffer: ArrayBuffer }): string { + return decoder.decode(result.buffer); +} + +describe("parseContentEncoding", () => { + it("returns empty for null/undefined/empty", () => { + expect(parseContentEncoding(null)).toEqual([]); + expect(parseContentEncoding(undefined)).toEqual([]); + expect(parseContentEncoding("")).toEqual([]); + }); + + it("lowercases, trims, and drops identity", () => { + expect(parseContentEncoding(" ZSTD ")).toEqual(["zstd"]); + expect(parseContentEncoding("identity")).toEqual([]); + expect(parseContentEncoding("gzip, identity, BR")).toEqual(["gzip", "br"]); + }); +}); + +describe("decodeRequestBody", () => { + it("round-trips zstd", () => { + const result = decodeRequestBody(zstdCompressSync(raw()), "zstd"); + expect(result.decoded).toBe(true); + expect(result.encoding).toBe("zstd"); + expect(decodedText(result)).toBe(SAMPLE); + }); + + it("round-trips gzip and x-gzip", () => { + const gz = decodeRequestBody(gzipSync(raw()), "gzip"); + expect(gz.decoded).toBe(true); + expect(decodedText(gz)).toBe(SAMPLE); + + const xgz = decodeRequestBody(gzipSync(raw()), "x-gzip"); + expect(xgz.decoded).toBe(true); + expect(decodedText(xgz)).toBe(SAMPLE); + }); + + it("round-trips brotli", () => { + const result = decodeRequestBody(brotliCompressSync(raw()), "br"); + expect(result.decoded).toBe(true); + expect(decodedText(result)).toBe(SAMPLE); + }); + + it("round-trips zlib-wrapped deflate", () => { + const result = decodeRequestBody(deflateSync(raw()), "deflate"); + expect(result.decoded).toBe(true); + expect(decodedText(result)).toBe(SAMPLE); + }); + + it("round-trips raw (headerless) deflate via fallback", () => { + const result = decodeRequestBody(deflateRawSync(raw()), "deflate"); + expect(result.decoded).toBe(true); + expect(decodedText(result)).toBe(SAMPLE); + }); + + it("is case-insensitive", () => { + const result = decodeRequestBody(gzipSync(raw()), "GZip"); + expect(result.decoded).toBe(true); + expect(decodedText(result)).toBe(SAMPLE); + }); + + it("caps content-encoding to a single layer", () => { + expect(MAX_CONTENT_ENCODING_LAYERS).toBe(1); + }); + + it("rejects multi-layer content-encoding chains with ProxyError(400)", () => { + // Even all-supported multi-layer chains are rejected: real clients never send them + // and they amplify synchronous decompression cost. + const layered = gzipSync(gzipSync(raw())); + try { + decodeRequestBody(layered, "gzip, gzip"); + throw new Error("expected decodeRequestBody to throw"); + } catch (err) { + expect(err).toBeInstanceOf(ProxyError); + expect((err as ProxyError).statusCode).toBe(400); + } + }); + + it("passes through when no content-encoding", () => { + const result = decodeRequestBody(raw(), null); + expect(result.decoded).toBe(false); + expect(result.encoding).toBeNull(); + expect(decodedText(result)).toBe(SAMPLE); + }); + + it("passes through identity", () => { + const result = decodeRequestBody(raw(), "identity"); + expect(result.decoded).toBe(false); + expect(decodedText(result)).toBe(SAMPLE); + }); + + it("passes through an empty body even with an encoding header", () => { + const result = decodeRequestBody(new Uint8Array(0), "zstd"); + expect(result.decoded).toBe(false); + expect(result.decodedByteLength).toBe(0); + }); + + it("passes through unsupported encodings untouched", () => { + const result = decodeRequestBody(raw(), "snappy"); + expect(result.decoded).toBe(false); + expect(result.encoding).toBeNull(); + expect(decodedText(result)).toBe(SAMPLE); + }); + + it("accepts ArrayBuffer input and returns an independent ArrayBuffer", () => { + const gz = gzipSync(raw()); + const ab = gz.buffer.slice(gz.byteOffset, gz.byteOffset + gz.byteLength); + const result = decodeRequestBody(ab, "gzip"); + expect(result.decoded).toBe(true); + expect(result.buffer).toBeInstanceOf(ArrayBuffer); + // Decoded output is freshly allocated, never the input compressed buffer. + expect(result.buffer).not.toBe(ab); + expect(decodedText(result)).toBe(SAMPLE); + }); + + it("throws ProxyError(413) when decompressed output exceeds the cap (bomb guard)", () => { + const bomb = gzipSync(Buffer.alloc(1024 * 1024, 0)); // 1MB of zeros -> tiny gzip + expect(bomb.byteLength).toBeLessThan(1024 * 1024); + try { + decodeRequestBody(bomb, "gzip", { maxOutputBytes: 1024 }); + throw new Error("expected decodeRequestBody to throw"); + } catch (err) { + expect(err).toBeInstanceOf(ProxyError); + expect((err as ProxyError).statusCode).toBe(413); + } + }); + + it("throws ProxyError(400) on a corrupt compressed stream", () => { + const garbage = encoder.encode("this is definitely not a gzip stream"); + try { + decodeRequestBody(garbage, "gzip"); + throw new Error("expected decodeRequestBody to throw"); + } catch (err) { + expect(err).toBeInstanceOf(ProxyError); + expect((err as ProxyError).statusCode).toBe(400); + } + }); + + it("exposes a sane default decompression cap", () => { + expect(MAX_DECOMPRESSED_REQUEST_BYTES).toBe(100 * 1024 * 1024); + }); +}); diff --git a/tests/unit/proxy/session-request-decode.test.ts b/tests/unit/proxy/session-request-decode.test.ts new file mode 100644 index 000000000..9523dd3eb --- /dev/null +++ b/tests/unit/proxy/session-request-decode.test.ts @@ -0,0 +1,152 @@ +import type { Context } from "hono"; +import { gzipSync, zstdCompressSync } from "node:zlib"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/repository/model-price", () => ({ + findLatestPriceByModel: vi.fn(), +})); + +vi.mock("@/repository/system-config", () => ({ + getSystemSettings: vi.fn(), +})); + +import { ProxySession } from "@/app/v1/_lib/proxy/session"; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +/** + * Minimal Hono Context stub covering the surface `ProxySession.fromContext` + * touches: method, url, header() (all + by-name), and raw Request. + */ +function makeContext( + url: string, + headers: Record, + body: Uint8Array | string +): Context { + const req = new Request(url, { method: "POST", headers, body }); + return { + req: { + method: "POST", + url, + raw: req, + header: (name?: string) => { + if (name === undefined) { + const all: Record = {}; + req.headers.forEach((value, key) => { + all[key] = value; + }); + return all; + } + return req.headers.get(name) ?? undefined; + }, + }, + } as unknown as Context; +} + +describe("ProxySession.fromContext request body decompression", () => { + it("decompresses a zstd codex /v1/responses body and strips content-encoding", async () => { + const payload = JSON.stringify({ + model: "gpt-5-codex", + stream: true, + input: [{ role: "user", content: "ping" }], + }); + const ctx = makeContext( + "https://hub.test/v1/responses", + { "content-type": "application/json", "content-encoding": "zstd" }, + zstdCompressSync(encoder.encode(payload)) + ); + + const session = await ProxySession.fromContext(ctx); + + expect(session.request.message.model).toBe("gpt-5-codex"); + expect(session.request.message.stream).toBe(true); + expect(session.request.buffer).toBeDefined(); + expect(decoder.decode(session.request.buffer)).toBe(payload); + // Upstream must not be told the (now plaintext) body is still zstd-encoded. + expect(session.headers.get("content-encoding")).toBeNull(); + }); + + it("decompresses a gzip /v1/messages body", async () => { + const payload = JSON.stringify({ + model: "claude-sonnet-4-5", + messages: [{ role: "user", content: "hi" }], + }); + const ctx = makeContext( + "https://hub.test/v1/messages", + { "content-type": "application/json", "content-encoding": "gzip" }, + gzipSync(encoder.encode(payload)) + ); + + const session = await ProxySession.fromContext(ctx); + + expect(session.request.message.model).toBe("claude-sonnet-4-5"); + expect(decoder.decode(session.request.buffer)).toBe(payload); + expect(session.headers.get("content-encoding")).toBeNull(); + }); + + it("decompresses for the raw-passthrough /v1/responses/compact endpoint", async () => { + const payload = JSON.stringify({ model: "gpt-5-codex", input: "compact me" }); + const ctx = makeContext( + "https://hub.test/v1/responses/compact", + { "content-type": "application/json", "content-encoding": "zstd" }, + zstdCompressSync(encoder.encode(payload)) + ); + + const session = await ProxySession.fromContext(ctx); + + // Raw passthrough forwards session.request.buffer verbatim -> must be plaintext. + expect(decoder.decode(session.request.buffer)).toBe(payload); + expect(session.headers.get("content-encoding")).toBeNull(); + }); + + it("leaves uncompressed requests untouched", async () => { + const payload = JSON.stringify({ model: "gpt-5-codex", input: "plain" }); + const ctx = makeContext( + "https://hub.test/v1/responses", + { "content-type": "application/json" }, + encoder.encode(payload) + ); + + const session = await ProxySession.fromContext(ctx); + + expect(session.request.message.model).toBe("gpt-5-codex"); + expect(decoder.decode(session.request.buffer)).toBe(payload); + expect(session.headers.get("content-encoding")).toBeNull(); + }); + + it("preserves content-encoding for unsupported encodings (transparent passthrough)", async () => { + const payload = JSON.stringify({ model: "gpt-5-codex", input: "exotic" }); + const ctx = makeContext( + "https://hub.test/v1/responses", + { "content-type": "application/json", "content-encoding": "snappy" }, + encoder.encode(payload) + ); + + const session = await ProxySession.fromContext(ctx); + + // We could not decode it, so we must not strip the header: forward as-is. + expect(session.headers.get("content-encoding")).toBe("snappy"); + }); + + it("surfaces a ProxyError(400) when a declared-compressed body is corrupt", async () => { + const ctx = makeContext( + "https://hub.test/v1/responses", + { "content-type": "application/json", "content-encoding": "gzip" }, + encoder.encode("this is not a valid gzip stream") + ); + + await expect(ProxySession.fromContext(ctx)).rejects.toMatchObject({ statusCode: 400 }); + }); + + it("surfaces a ProxyError(400) when the content-encoding chain has too many layers", async () => { + const payload = JSON.stringify({ model: "gpt-5-codex", input: "x" }); + const ctx = makeContext( + "https://hub.test/v1/responses", + { "content-type": "application/json", "content-encoding": "gzip, gzip, gzip, gzip" }, + gzipSync(encoder.encode(payload)) + ); + + await expect(ProxySession.fromContext(ctx)).rejects.toMatchObject({ statusCode: 400 }); + }); +}); From 08599ecc4c1a625d3e8e6d026c868c78d7928463 Mon Sep 17 00:00:00 2001 From: Ding <44717411+ding113@users.noreply.github.com> Date: Wed, 3 Jun 2026 01:09:09 +0800 Subject: [PATCH 07/13] fix(model-prices): protect locally-uploaded prices from cloud auto-sync overwrite (#1241) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 模型价格表「本地优先」修复: - 用户显式上传的价格表(uploadPriceTable)标记为 source='manual',不再被云端自动同步静默覆盖。 - 跳过保护仅在 source='litellm'(云端/自动同步)时生效;手动上传为权威导入,可正常覆盖与重新上传更新。 - update 路径改用事务型 upsertModelPrice 原子替换(delete+insert 同事务),消除崩溃丢数据窗口并清理孤儿行。 - 云端模型名 trim 归一化后再做保护比对。 - 新增针对上传/重新上传/转换/归一化的单元测试。 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/actions/model-prices.ts | 40 ++++-- src/repository/model-price.ts | 8 +- tests/unit/actions/model-prices.test.ts | 178 +++++++++++++++++++++++- 3 files changed, 204 insertions(+), 22 deletions(-) diff --git a/src/actions/model-prices.ts b/src/actions/model-prices.ts index e7d4d3e9f..30d43c7b0 100644 --- a/src/actions/model-prices.ts +++ b/src/actions/model-prices.ts @@ -25,6 +25,7 @@ import { import type { ModelPrice, ModelPriceData, + ModelPriceSource, PriceTableJson, PriceUpdateResult, SyncConflict, @@ -98,13 +99,17 @@ function buildManualPriceDataFromProviderPricing( /** * 价格表处理核心逻辑(内部函数,无权限检查) - * 用于系统初始化和 Web UI 上传 + * 用于系统初始化、云端自动同步和 Web UI 上传 * @param jsonContent - 价格表 JSON 内容 * @param overwriteManual - 可选,要覆盖的手动添加模型名称列表 + * @param source - 写入记录的来源。云端/自动同步为 'litellm'(默认); + * 用户在本地显式上传的价格表为 'manual',使其遵循“本地优先”原则、 + * 不被后续云端自动同步覆盖。 */ export async function processPriceTableInternal( jsonContent: string, - overwriteManual?: string[] + overwriteManual?: string[], + source: ModelPriceSource = "litellm" ): Promise> { try { // 解析JSON内容 @@ -156,7 +161,10 @@ export async function processPriceTableInternal( }; // 处理每个模型的价格 - for (const [modelName, priceData] of entries) { + for (const [rawModelName, priceData] of entries) { + // 与 manual 记录入库时(upsertModelPrice 使用 trim 后的名称)保持一致地归一化, + // 避免云端表里带空白的同名键绕过本地手动模型的保护检查。 + const modelName = typeof rawModelName === "string" ? rawModelName.trim() : rawModelName; try { // 验证价格数据基本类型 if (typeof priceData !== "object" || priceData === null) { @@ -172,9 +180,11 @@ export async function processPriceTableInternal( continue; } - // 检查是否存在手动添加的价格且不在覆盖列表中 + // 本地优先:仅当本次写入来自云端/自动同步(source='litellm')时, + // 才跳过用户手动维护的模型,除非显式列入覆盖列表。 + // 用户显式上传(source='manual')属于权威导入,不受此保护跳过,可正常覆盖。 const isManualPrice = manualPrices.has(modelName); - if (isManualPrice && !overwriteSet.has(modelName)) { + if (source === "litellm" && isManualPrice && !overwriteSet.has(modelName)) { // 跳过手动添加的模型,记录到 skippedConflicts result.skippedConflicts?.push(modelName); result.unchanged.push(modelName); @@ -186,15 +196,15 @@ export async function processPriceTableInternal( if (!existingPrice) { // 模型不存在,新增记录 - await createModelPrice(modelName, priceData, "litellm"); + await createModelPrice(modelName, priceData, source); result.added.push(modelName); - } else if (!isPriceDataEqual(existingPrice.priceData, priceData)) { - // 模型存在但价格发生变化 - // 如果是手动模型且在覆盖列表中,先删除旧记录 - if (isManualPrice && overwriteSet.has(modelName)) { - await deleteModelPriceByName(modelName); - } - await createModelPrice(modelName, priceData, "litellm"); + } else if ( + existingPrice.source !== source || + !isPriceDataEqual(existingPrice.priceData, priceData) + ) { + // 价格或来源发生变化:用事务原子地“删旧 + 插新”替换该模型的所有记录, + // 既保证不会在崩溃时丢失价格,又避免同名记录堆积(litellm 孤儿行,或 manual + litellm 并存)。 + await upsertModelPrice(modelName, priceData, source); result.updated.push(modelName); } else { // 价格未发生变化,不需要更新 @@ -261,7 +271,9 @@ export async function uploadPriceTable( jsonContent = JSON.stringify(parseResult.data.models); } - const result = await processPriceTableInternal(jsonContent, overwriteManual); + // 用户显式上传的价格表视为“本地优先”的权威来源,标记为 manual, + // 使其不会被后续云端自动同步静默覆盖。 + const result = await processPriceTableInternal(jsonContent, overwriteManual, "manual"); if (result.ok) { emitActionAudit({ diff --git a/src/repository/model-price.ts b/src/repository/model-price.ts index 0dfbb8ed9..c21c0d918 100644 --- a/src/repository/model-price.ts +++ b/src/repository/model-price.ts @@ -267,24 +267,24 @@ export async function createModelPrice( /** * 更新或插入模型价格(先删除旧记录,再插入新记录) - * 用于手动维护单个模型价格,source 固定为 'manual' + * 用于手动维护单个模型价格或批量替换;source 默认为 'manual'。 */ export async function upsertModelPrice( modelName: string, - priceData: ModelPriceData + priceData: ModelPriceData, + source: ModelPriceSource = "manual" ): Promise { // 使用事务确保删除和插入的原子性 return await db.transaction(async (tx) => { // 先删除该模型的所有旧记录 await tx.delete(modelPrices).where(eq(modelPrices.modelName, modelName)); - // 插入新记录,source 固定为 'manual' const [price] = await tx .insert(modelPrices) .values({ modelName: modelName, priceData: priceData, - source: "manual", + source: source, }) .returning(); return toModelPrice(price); diff --git a/tests/unit/actions/model-prices.test.ts b/tests/unit/actions/model-prices.test.ts index c99973e63..f64a86eb0 100644 --- a/tests/unit/actions/model-prices.test.ts +++ b/tests/unit/actions/model-prices.test.ts @@ -488,8 +488,7 @@ describe("Model Price Actions", () => { findAllManualPricesMock.mockResolvedValue(new Map([["custom-model", manualPrice]])); findAllLatestPricesMock.mockResolvedValue([manualPrice]); - deleteModelPriceByNameMock.mockResolvedValue(undefined); - createModelPriceMock.mockResolvedValue( + upsertModelPriceMock.mockResolvedValue( makeMockPrice( "custom-model", { @@ -513,8 +512,12 @@ describe("Model Price Actions", () => { expect(result.ok).toBe(true); expect(result.data?.updated).toContain("custom-model"); - expect(deleteModelPriceByNameMock).toHaveBeenCalledWith("custom-model"); - expect(createModelPriceMock).toHaveBeenCalled(); + // The overwrite is an atomic replace (delete + insert in one transaction). + expect(upsertModelPriceMock).toHaveBeenCalledWith( + "custom-model", + expect.any(Object), + "litellm" + ); }); it("should add new models with litellm source", async () => { @@ -608,6 +611,173 @@ describe("Model Price Actions", () => { expect(result.data?.unchanged).toContain("safe-model"); expect(createModelPriceMock).not.toHaveBeenCalled(); }); + + it("should persist models with the manual source when source='manual' (local upload)", async () => { + findAllManualPricesMock.mockResolvedValue(new Map()); + findAllLatestPricesMock.mockResolvedValue([]); + createModelPriceMock.mockResolvedValue( + makeMockPrice("new-model", { mode: "chat" }, "manual") + ); + + const { processPriceTableInternal } = await import("@/actions/model-prices"); + const result = await processPriceTableInternal( + JSON.stringify({ "new-model": { mode: "chat", input_cost_per_token: 0.000001 } }), + undefined, + "manual" + ); + + expect(result.ok).toBe(true); + expect(result.data?.added).toContain("new-model"); + expect(createModelPriceMock).toHaveBeenCalledWith("new-model", expect.any(Object), "manual"); + }); + + it("should protect a locally-uploaded (manual) model from later auto-sync overwrite", async () => { + // Regression: a user-created local model must survive an unattended cloud sync. + const manualPrice = makeMockPrice("my-custom-model", { + mode: "chat", + input_cost_per_token: 0.123, + }); + findAllManualPricesMock.mockResolvedValue(new Map([["my-custom-model", manualPrice]])); + findAllLatestPricesMock.mockResolvedValue([manualPrice]); + + const { processPriceTableInternal } = await import("@/actions/model-prices"); + // Auto-sync writes 'litellm' (default) with a different cloud price and no overwrite list. + const result = await processPriceTableInternal( + JSON.stringify({ "my-custom-model": { mode: "chat", input_cost_per_token: 0.999 } }) + ); + + expect(result.ok).toBe(true); + expect(result.data?.skippedConflicts).toContain("my-custom-model"); + expect(createModelPriceMock).not.toHaveBeenCalled(); + expect(deleteModelPriceByNameMock).not.toHaveBeenCalled(); + }); + + it("should atomically replace a changed cloud price via upsert (no orphan rows)", async () => { + const existing = makeMockPrice( + "cloud-model", + { mode: "chat", input_cost_per_token: 0.001 }, + "litellm" + ); + findAllManualPricesMock.mockResolvedValue(new Map()); + findAllLatestPricesMock.mockResolvedValue([existing]); + upsertModelPriceMock.mockResolvedValue( + makeMockPrice("cloud-model", { mode: "chat", input_cost_per_token: 0.002 }, "litellm") + ); + + const { processPriceTableInternal } = await import("@/actions/model-prices"); + const result = await processPriceTableInternal( + JSON.stringify({ "cloud-model": { mode: "chat", input_cost_per_token: 0.002 } }) + ); + + expect(result.ok).toBe(true); + expect(result.data?.updated).toContain("cloud-model"); + // Transactional replace, not a separate delete + insert. + expect(upsertModelPriceMock).toHaveBeenCalledWith( + "cloud-model", + expect.any(Object), + "litellm" + ); + expect(createModelPriceMock).not.toHaveBeenCalled(); + }); + + it("should convert an existing cloud (litellm) model to manual on local upload", async () => { + const existing = makeMockPrice( + "shared-model", + { mode: "chat", input_cost_per_token: 0.001 }, + "litellm" + ); + findAllManualPricesMock.mockResolvedValue(new Map()); + findAllLatestPricesMock.mockResolvedValue([existing]); + upsertModelPriceMock.mockResolvedValue( + makeMockPrice("shared-model", { mode: "chat", input_cost_per_token: 0.001 }, "manual") + ); + + const { processPriceTableInternal } = await import("@/actions/model-prices"); + const result = await processPriceTableInternal( + JSON.stringify({ "shared-model": { mode: "chat", input_cost_per_token: 0.001 } }), + undefined, + "manual" + ); + + expect(result.ok).toBe(true); + expect(result.data?.updated).toContain("shared-model"); + expect(upsertModelPriceMock).toHaveBeenCalledWith( + "shared-model", + expect.any(Object), + "manual" + ); + }); + + it("should update an existing manual model on local re-upload (manual source bypasses skip)", async () => { + // Regression: re-uploading a price file to revise a user's own model must apply, + // not be silently skipped as a conflict. + const manualPrice = makeMockPrice("my-model", { mode: "chat", input_cost_per_token: 0.1 }); + findAllManualPricesMock.mockResolvedValue(new Map([["my-model", manualPrice]])); + findAllLatestPricesMock.mockResolvedValue([manualPrice]); + upsertModelPriceMock.mockResolvedValue( + makeMockPrice("my-model", { mode: "chat", input_cost_per_token: 0.2 }, "manual") + ); + + const { processPriceTableInternal } = await import("@/actions/model-prices"); + const result = await processPriceTableInternal( + JSON.stringify({ "my-model": { mode: "chat", input_cost_per_token: 0.2 } }), + undefined, + "manual" + ); + + expect(result.ok).toBe(true); + expect(result.data?.updated).toContain("my-model"); + expect(result.data?.skippedConflicts).not.toContain("my-model"); + expect(upsertModelPriceMock).toHaveBeenCalledWith("my-model", expect.any(Object), "manual"); + }); + + it("should normalize whitespace in cloud model names before manual protection", async () => { + const manualPrice = makeMockPrice("claude-3", { mode: "chat", input_cost_per_token: 0.123 }); + findAllManualPricesMock.mockResolvedValue(new Map([["claude-3", manualPrice]])); + findAllLatestPricesMock.mockResolvedValue([manualPrice]); + + const { processPriceTableInternal } = await import("@/actions/model-prices"); + const result = await processPriceTableInternal( + JSON.stringify({ " claude-3 ": { mode: "chat", input_cost_per_token: 0.999 } }) + ); + + expect(result.ok).toBe(true); + expect(result.data?.skippedConflicts).toContain("claude-3"); + expect(createModelPriceMock).not.toHaveBeenCalled(); + }); + }); + + describe("uploadPriceTable - local-first", () => { + it("should store uploaded models with manual source so auto-sync cannot overwrite them", async () => { + findAllManualPricesMock.mockResolvedValue(new Map()); + findAllLatestPricesMock.mockResolvedValue([]); + createModelPriceMock.mockResolvedValue( + makeMockPrice("my-custom-model", { mode: "chat", input_cost_per_token: 0.001 }, "manual") + ); + + const { uploadPriceTable } = await import("@/actions/model-prices"); + const result = await uploadPriceTable( + JSON.stringify({ "my-custom-model": { mode: "chat", input_cost_per_token: 0.001 } }) + ); + + expect(result.ok).toBe(true); + expect(createModelPriceMock).toHaveBeenCalledWith( + "my-custom-model", + expect.any(Object), + "manual" + ); + }); + + it("should reject non-admin users", async () => { + getSessionMock.mockResolvedValue({ user: { id: 2, role: "user" } }); + + const { uploadPriceTable } = await import("@/actions/model-prices"); + const result = await uploadPriceTable(JSON.stringify({ x: { mode: "chat" } })); + + expect(result.ok).toBe(false); + expect(result.error).toContain("无权限"); + expect(createModelPriceMock).not.toHaveBeenCalled(); + }); }); describe("pinModelPricingProviderAsManual", () => { From 09d549ca54a229e4ee2bf025e1de61475d6b5822 Mon Sep 17 00:00:00 2001 From: Ding <44717411+ding113@users.noreply.github.com> Date: Wed, 3 Jun 2026 21:55:36 +0800 Subject: [PATCH 08/13] =?UTF-8?q?fix:=20=E8=A7=84=E5=88=99=E4=BF=9D?= =?UTF-8?q?=E5=AD=98/=E4=BF=AE=E6=94=B9=E5=90=8E=E5=8D=B3=E6=97=B6?= =?UTF-8?q?=E7=94=9F=E6=95=88=EF=BC=88=E5=90=8C=E6=AD=A5=E5=86=85=E5=AD=98?= =?UTF-8?q?=E7=BC=93=E5=AD=98=20+=20=E5=88=B7=E6=96=B0=E5=88=97=E8=A1=A8?= =?UTF-8?q?=EF=BC=89=EF=BC=8C=E6=97=A0=E9=9C=80=E6=89=8B=E5=8A=A8=E7=82=B9?= =?UTF-8?q?=E5=88=B7=E6=96=B0=20(#1244)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 请求过滤器与错误规则在增删改/启停后立即同步代理内存缓存并刷新 UI 列表,无需手动点刷新缓存。RequestFilterEngine.reload() 增加并发补跑队列;mutation action 写库后 await reload(best-effort,失败不影响写入成功语义,并复用 emit 触发的在途 reload 避免双读);error-rules UI 各操作补 router.refresh()。处理了 Gemini/CodeRabbit 的 review 意见。 --- src/actions/error-rules.ts | 26 +- src/actions/request-filters.ts | 24 ++ .../_components/add-rule-dialog.tsx | 3 + .../_components/edit-rule-dialog.tsx | 3 + .../_components/refresh-cache-button.tsx | 3 + .../_components/rule-list-table.tsx | 4 + src/lib/error-rule-detector.ts | 7 +- src/lib/request-filter-engine.ts | 63 +++- .../actions/error-rules-cache-reload.test.ts | 135 ++++++++ .../request-filters-cache-reload.test.ts | 144 +++++++++ .../unit/error-rules-list-refresh-ui.test.tsx | 304 ++++++++++++++++++ ...request-filter-engine-reload-queue.test.ts | 216 +++++++++++++ 12 files changed, 915 insertions(+), 17 deletions(-) create mode 100644 tests/unit/actions/error-rules-cache-reload.test.ts create mode 100644 tests/unit/actions/request-filters-cache-reload.test.ts create mode 100644 tests/unit/error-rules-list-refresh-ui.test.tsx create mode 100644 tests/unit/lib/request-filter-engine-reload-queue.test.ts diff --git a/src/actions/error-rules.ts b/src/actions/error-rules.ts index 978e2e316..9c77a2b5b 100644 --- a/src/actions/error-rules.ts +++ b/src/actions/error-rules.ts @@ -178,6 +178,13 @@ export async function createErrorRuleAction(data: { // 刷新缓存(事件广播,支持多 worker 同步) await emitErrorRulesUpdated(); + // 上面的 emit 已在本进程触发一次携带最新数据的 reload,这里复用它即可(无需补跑第二轮)。 + // reload 仅为本进程缓存同步(跨 worker 由 emit 覆盖),失败不应把已成功的写入误报为失败。 + try { + await errorRuleDetector.reload(); + } catch (reloadError) { + logger.warn("[ErrorRulesAction] Failed to reload detector after mutation", { reloadError }); + } revalidatePath("/settings/error-rules"); @@ -311,6 +318,13 @@ export async function updateErrorRuleAction( // 刷新缓存(事件广播,支持多 worker 同步) await emitErrorRulesUpdated(); + // 上面的 emit 已在本进程触发一次携带最新数据的 reload,这里复用它即可(无需补跑第二轮)。 + // reload 仅为本进程缓存同步(跨 worker 由 emit 覆盖),失败不应把已成功的写入误报为失败。 + try { + await errorRuleDetector.reload(); + } catch (reloadError) { + logger.warn("[ErrorRulesAction] Failed to reload detector after mutation", { reloadError }); + } revalidatePath("/settings/error-rules"); @@ -365,6 +379,13 @@ export async function deleteErrorRuleAction(id: number): Promise { // 刷新缓存(事件广播,支持多 worker 同步) await emitErrorRulesUpdated(); + // 上面的 emit 已在本进程触发一次携带最新数据的 reload,这里复用它即可(无需补跑第二轮)。 + // reload 仅为本进程缓存同步(跨 worker 由 emit 覆盖),失败不应把已成功的写入误报为失败。 + try { + await errorRuleDetector.reload(); + } catch (reloadError) { + logger.warn("[ErrorRulesAction] Failed to reload detector after mutation", { reloadError }); + } revalidatePath("/settings/error-rules"); @@ -412,8 +433,9 @@ export async function refreshCacheAction(): Promise< // 1. 同步默认规则到数据库 const syncResult = await repo.syncDefaultErrorRules(); - // 2. 重新加载缓存 - await errorRuleDetector.reload(); + // 2. 重新加载缓存:手动刷新必须读到刚同步的默认规则, + // 若已有在途 reload 则排队补跑一轮(queueIfRunning),确保拿到同步后的最新快照。 + await errorRuleDetector.reload({ queueIfRunning: true }); const stats = errorRuleDetector.getStats(); diff --git a/src/actions/request-filters.ts b/src/actions/request-filters.ts index 8f5ce7d71..4f8f62242 100644 --- a/src/actions/request-filters.ts +++ b/src/actions/request-filters.ts @@ -253,6 +253,14 @@ export async function createRequestFilterAction(data: { operations: data.operations ?? null, }); + // 立即同步内存缓存,确保新规则对代理请求即时生效,无需用户手动点"刷新缓存"。 + // 仓储层已在写入后触发一次本进程 reload,这里 reload(false) 复用它(不补跑第二轮); + // reload 仅为缓存同步,失败不应把已成功的写入误报为失败。 + try { + await requestFilterEngine.reload(false); + } catch (reloadError) { + logger.warn("[RequestFiltersAction] Failed to reload engine after create", { reloadError }); + } revalidatePath(SETTINGS_PATH); return { ok: true, data: created }; } catch (error) { @@ -378,6 +386,14 @@ export async function updateRequestFilterAction( return { ok: false, error: "记录不存在" }; } + // 立即同步内存缓存,确保规则改动对代理请求即时生效,无需用户手动点"刷新缓存"。 + // 仓储层已在写入后触发一次本进程 reload,这里 reload(false) 复用它(不补跑第二轮); + // reload 仅为缓存同步,失败不应把已成功的写入误报为失败。 + try { + await requestFilterEngine.reload(false); + } catch (reloadError) { + logger.warn("[RequestFiltersAction] Failed to reload engine after update", { reloadError }); + } revalidatePath(SETTINGS_PATH); return { ok: true, data: updated }; } catch (error) { @@ -393,6 +409,14 @@ export async function deleteRequestFilterAction(id: number): Promise { @@ -32,6 +34,7 @@ export function RefreshCacheButton({ stats }: RefreshCacheButtonProps) { if (result.ok) { const count = result.data.stats.totalCount; toast.success(t("errorRules.refreshCacheSuccess", { count })); + router.refresh(); } else { toast.error(result.error); } diff --git a/src/app/[locale]/settings/error-rules/_components/rule-list-table.tsx b/src/app/[locale]/settings/error-rules/_components/rule-list-table.tsx index 05976713b..a3581c3e0 100644 --- a/src/app/[locale]/settings/error-rules/_components/rule-list-table.tsx +++ b/src/app/[locale]/settings/error-rules/_components/rule-list-table.tsx @@ -2,6 +2,7 @@ import { formatInTimeZone } from "date-fns-tz"; import { AlertTriangle, Pencil, Trash2 } from "lucide-react"; +import { useRouter } from "next/navigation"; import { useTimeZone, useTranslations } from "next-intl"; import { useState } from "react"; import { toast } from "sonner"; @@ -33,6 +34,7 @@ const categoryColors: Record = { export function RuleListTable({ rules }: RuleListTableProps) { const t = useTranslations("settings"); + const router = useRouter(); const timeZone = useTimeZone() ?? "UTC"; const [selectedRule, setSelectedRule] = useState(null); const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); @@ -42,6 +44,7 @@ export function RuleListTable({ rules }: RuleListTableProps) { if (result.ok) { toast.success(isEnabled ? t("errorRules.enable") : t("errorRules.disable")); + router.refresh(); } else { toast.error(result.error); } @@ -61,6 +64,7 @@ export function RuleListTable({ rules }: RuleListTableProps) { if (result.ok) { toast.success(t("errorRules.deleteSuccess")); + router.refresh(); } else { toast.error(result.error); } diff --git a/src/lib/error-rule-detector.ts b/src/lib/error-rule-detector.ts index 30fc10c4c..8caa9d9e3 100644 --- a/src/lib/error-rule-detector.ts +++ b/src/lib/error-rule-detector.ts @@ -171,10 +171,11 @@ class ErrorRuleDetector { */ async reload(options: { queueIfRunning?: boolean } = {}): Promise { if (this.activeReloadPromise) { - // 无论是事件驱动还是显式调用,只要有 in-flight reload 就排队补跑一轮, - // 确保调用方写库后的显式 reload 不会拿到旧快照。 - this.reloadRequestedWhileLoading = true; + // queueIfRunning=true(事件驱动 / 手动刷新)排队补跑一轮,确保读到最新写入; + // 默认(action 在 emit 已触发 reload 之后调用)直接复用这次在途 reload, + // 避免对同一次写入做两次冗余 DB 读、并让保存响应少等一轮。 if (options.queueIfRunning) { + this.reloadRequestedWhileLoading = true; logger.info("[ErrorRuleDetector] Reload already in progress, queueing another pass"); } return this.activeReloadPromise; diff --git a/src/lib/request-filter-engine.ts b/src/lib/request-filter-engine.ts index c3974ace8..e9a9efcd7 100644 --- a/src/lib/request-filter-engine.ts +++ b/src/lib/request-filter-engine.ts @@ -278,6 +278,8 @@ export class RequestFilterEngine { private isLoading = false; private isInitialized = false; private initializationPromise: Promise | null = null; + private activeReloadPromise: Promise | null = null; // 合并并发 reload + private reloadRequestedWhileLoading = false; // reload 期间收到的补跑请求 private eventEmitterCleanup: (() => void) | null = null; private redisPubSubCleanup: (() => void) | null = null; @@ -336,19 +338,48 @@ export class RequestFilterEngine { } } - async reload(): Promise { - if (this.isLoading) return; - this.isLoading = true; - - try { - const { getActiveRequestFilters } = await import("@/repository/request-filters"); - const filters = await getActiveRequestFilters(); - this.loadFilters(filters); - } catch (error) { - logger.error("[RequestFilterEngine] Failed to reload filters", { error }); - } finally { - this.isLoading = false; + async reload(queue = true): Promise { + if (this.activeReloadPromise) { + // 已有 reload 在途: + // - queue=true(默认 / 事件驱动 / 手动刷新)排队补跑一轮,确保写库后的显式 reload + // 不被丢弃,否则代理仍命中旧快照、必须手动点"刷新缓存"才生效。 + // - queue=false(action 在 emit 已触发 reload 之后调用)直接复用这次在途 reload, + // 避免对同一次写入做两次冗余的 DB 读、并让保存响应少等一轮。 + if (queue) { + this.reloadRequestedWhileLoading = true; + } + return this.activeReloadPromise; } + + const reloadLoop = (async () => { + do { + // reload 期间若又收到补跑请求,本轮结束后立刻再跑一轮,避免新规则落库却没进缓存。 + this.reloadRequestedWhileLoading = false; + this.isLoading = true; + + try { + const { getActiveRequestFilters } = await import("@/repository/request-filters"); + const filters = await getActiveRequestFilters(); + this.loadFilters(filters); + } catch (error) { + logger.error("[RequestFilterEngine] Failed to reload filters", { error }); + } finally { + this.isLoading = false; + } + } while (this.reloadRequestedWhileLoading); + })(); + + this.activeReloadPromise = reloadLoop.finally(() => { + // 极窄窗口:do/while 已判定无需继续,但 finally 微任务执行前又来了新请求, + // 此处再检查一次,避免晚到的补跑被静默吞掉。 + const shouldRestart = this.reloadRequestedWhileLoading; + this.activeReloadPromise = null; + if (shouldRestart) { + return this.reload(); + } + }); + + return this.activeReloadPromise; } /** Shared filter loading logic (used by reload and setFiltersForTest) */ @@ -423,7 +454,15 @@ export class RequestFilterEngine { } private async ensureInitialized(): Promise { + // 已初始化后立即返回,绝不让代理热路径阻塞等待在途 reload: + // loadFilters() 对各 bucket 做同步整体替换,请求读到的恒为某个一致快照; + // 用户保存后的"即时生效"由 action 侧 await reload() 保证,无需并发代理请求陪跑一次 DB 读。 if (this.isInitialized) return; + // 仅在「尚未初始化」且已有在途 reload 时复用它,避免冷启动期间重复打库。 + if (this.activeReloadPromise) { + await this.activeReloadPromise; + return; + } if (!this.initializationPromise) { this.initializationPromise = this.reload().finally(() => { this.initializationPromise = null; diff --git a/tests/unit/actions/error-rules-cache-reload.test.ts b/tests/unit/actions/error-rules-cache-reload.test.ts new file mode 100644 index 000000000..2084230c7 --- /dev/null +++ b/tests/unit/actions/error-rules-cache-reload.test.ts @@ -0,0 +1,135 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const getSessionMock = vi.fn(); +const reloadMock = vi.fn(async () => {}); +const emitErrorRulesUpdatedMock = vi.fn(async () => {}); +const createErrorRuleMock = vi.fn(); +const updateErrorRuleMock = vi.fn(); +const deleteErrorRuleMock = vi.fn(); +const getErrorRuleByIdMock = vi.fn(); + +vi.mock("@/lib/auth", () => ({ + getSession: getSessionMock, +})); + +vi.mock("@/lib/logger", () => ({ + logger: { + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + }, +})); + +vi.mock("@/lib/emit-event", () => ({ + emitErrorRulesUpdated: emitErrorRulesUpdatedMock, +})); + +vi.mock("@/lib/error-override-validator", () => ({ + validateErrorOverrideResponse: vi.fn(() => null), +})); + +vi.mock("@/lib/error-rule-detector", () => ({ + errorRuleDetector: { + reload: reloadMock, + getStats: vi.fn(() => ({ totalCount: 0 })), + ensureInitialized: vi.fn(async () => {}), + detectAsync: vi.fn(async () => ({ matched: false })), + }, +})); + +vi.mock("@/repository/error-rules", () => ({ + createErrorRule: createErrorRuleMock, + updateErrorRule: updateErrorRuleMock, + deleteErrorRule: deleteErrorRuleMock, + getErrorRuleById: getErrorRuleByIdMock, + getAllErrorRules: vi.fn(async () => []), + syncDefaultErrorRules: vi.fn(async () => ({ inserted: 0, updated: 0, skipped: 0, deleted: 0 })), +})); + +vi.mock("next/cache", () => ({ + revalidatePath: vi.fn(), +})); + +const baseRule = { + id: 1, + pattern: "boom", + category: "prompt_limit" as const, + matchType: "contains" as const, + description: null, + overrideResponse: null, + overrideStatusCode: null, + isEnabled: true, + isDefault: false, + priority: 0, + createdAt: new Date("2026-06-01T00:00:00.000Z"), + updatedAt: new Date("2026-06-01T00:00:00.000Z"), +}; + +describe("error-rules actions reload the detector on mutation", () => { + beforeEach(() => { + vi.clearAllMocks(); + getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); + }); + + it("createErrorRuleAction reloads the detector after a successful create", async () => { + createErrorRuleMock.mockResolvedValue(baseRule); + + const { createErrorRuleAction } = await import("@/actions/error-rules"); + const res = await createErrorRuleAction({ + pattern: "boom", + category: "prompt_limit", + matchType: "contains", + }); + + expect(res.ok).toBe(true); + expect(reloadMock).toHaveBeenCalledTimes(1); + }); + + it("updateErrorRuleAction reloads the detector after a successful update", async () => { + getErrorRuleByIdMock.mockResolvedValue(baseRule); + updateErrorRuleMock.mockResolvedValue({ ...baseRule, isEnabled: false }); + + const { updateErrorRuleAction } = await import("@/actions/error-rules"); + const res = await updateErrorRuleAction(1, { isEnabled: false }); + + expect(res.ok).toBe(true); + expect(reloadMock).toHaveBeenCalledTimes(1); + }); + + it("deleteErrorRuleAction reloads the detector after a successful delete", async () => { + deleteErrorRuleMock.mockResolvedValue(true); + + const { deleteErrorRuleAction } = await import("@/actions/error-rules"); + const res = await deleteErrorRuleAction(1); + + expect(res.ok).toBe(true); + expect(reloadMock).toHaveBeenCalledTimes(1); + }); + + it("does not reload the detector when the delete target does not exist", async () => { + deleteErrorRuleMock.mockResolvedValue(false); + + const { deleteErrorRuleAction } = await import("@/actions/error-rules"); + const res = await deleteErrorRuleAction(999); + + expect(res.ok).toBe(false); + expect(reloadMock).not.toHaveBeenCalled(); + }); + + it("still returns ok:true when the detector reload throws (cache sync is best-effort)", async () => { + createErrorRuleMock.mockResolvedValue(baseRule); + reloadMock.mockRejectedValueOnce(new Error("boom")); + + const { createErrorRuleAction } = await import("@/actions/error-rules"); + const res = await createErrorRuleAction({ + pattern: "boom", + category: "prompt_limit", + matchType: "contains", + }); + + // The DB write succeeded; a failed best-effort cache reload must NOT flip the + // action to failed (which would prompt the user to retry and double-create). + expect(res.ok).toBe(true); + expect(reloadMock).toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/actions/request-filters-cache-reload.test.ts b/tests/unit/actions/request-filters-cache-reload.test.ts new file mode 100644 index 000000000..b7a3e4bb8 --- /dev/null +++ b/tests/unit/actions/request-filters-cache-reload.test.ts @@ -0,0 +1,144 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const getSessionMock = vi.fn(); +const reloadMock = vi.fn(async () => {}); +const createRequestFilterMock = vi.fn(); +const updateRequestFilterMock = vi.fn(); +const deleteRequestFilterMock = vi.fn(); + +vi.mock("@/lib/auth", () => ({ + getSession: getSessionMock, +})); + +vi.mock("@/lib/logger", () => ({ + logger: { + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + }, +})); + +vi.mock("@/lib/request-filter-engine", () => ({ + requestFilterEngine: { + reload: reloadMock, + getStats: vi.fn(() => ({ count: 0 })), + }, +})); + +vi.mock("@/repository/request-filters", () => ({ + createRequestFilter: createRequestFilterMock, + deleteRequestFilter: deleteRequestFilterMock, + getAllRequestFilters: vi.fn(async () => []), + getRequestFilterById: vi.fn(async () => null), + updateRequestFilter: updateRequestFilterMock, +})); + +vi.mock("next/cache", () => ({ + revalidatePath: vi.fn(), +})); + +const baseFilter = { + id: 1, + name: "f", + description: null, + scope: "header" as const, + action: "remove" as const, + matchType: null, + target: "x-test", + replacement: null, + priority: 0, + isEnabled: true, + bindingType: "global" as const, + providerIds: null, + groupTags: null, + ruleMode: "simple" as const, + executionPhase: "guard" as const, + operations: null, + createdAt: new Date("2026-06-01T00:00:00.000Z"), + updatedAt: new Date("2026-06-01T00:00:00.000Z"), +}; + +describe("request-filters actions reload the engine on mutation", () => { + beforeEach(() => { + vi.clearAllMocks(); + getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); + }); + + it("createRequestFilterAction reloads the engine after a successful create", async () => { + createRequestFilterMock.mockResolvedValue(baseFilter); + + const { createRequestFilterAction } = await import("@/actions/request-filters"); + const res = await createRequestFilterAction({ + name: "f", + scope: "header", + action: "remove", + target: "x-test", + bindingType: "global", + }); + + expect(res.ok).toBe(true); + expect(reloadMock).toHaveBeenCalledTimes(1); + // reload(false): the repository emit already kicked off a fresh reload; the + // action reuses it instead of forcing a redundant second DB read. + expect(reloadMock).toHaveBeenCalledWith(false); + }); + + it("still returns ok:true when the engine reload throws (cache sync is best-effort)", async () => { + createRequestFilterMock.mockResolvedValue(baseFilter); + reloadMock.mockRejectedValueOnce(new Error("boom")); + + const { createRequestFilterAction } = await import("@/actions/request-filters"); + const res = await createRequestFilterAction({ + name: "f", + scope: "header", + action: "remove", + target: "x-test", + bindingType: "global", + }); + + // The DB write succeeded; a failed best-effort cache reload must NOT flip the + // action to failed (which would prompt the user to retry and double-create). + expect(res.ok).toBe(true); + expect(reloadMock).toHaveBeenCalled(); + }); + + it("updateRequestFilterAction reloads the engine after a successful update", async () => { + updateRequestFilterMock.mockResolvedValue(baseFilter); + + const { updateRequestFilterAction } = await import("@/actions/request-filters"); + const res = await updateRequestFilterAction(1, { isEnabled: false }); + + expect(res.ok).toBe(true); + expect(reloadMock).toHaveBeenCalledTimes(1); + }); + + it("deleteRequestFilterAction reloads the engine after a successful delete", async () => { + deleteRequestFilterMock.mockResolvedValue(true); + + const { deleteRequestFilterAction } = await import("@/actions/request-filters"); + const res = await deleteRequestFilterAction(1); + + expect(res.ok).toBe(true); + expect(reloadMock).toHaveBeenCalledTimes(1); + }); + + it("does not reload the engine when the update target does not exist", async () => { + updateRequestFilterMock.mockResolvedValue(null); + + const { updateRequestFilterAction } = await import("@/actions/request-filters"); + const res = await updateRequestFilterAction(999, { isEnabled: false }); + + expect(res.ok).toBe(false); + expect(reloadMock).not.toHaveBeenCalled(); + }); + + it("does not reload the engine when the delete target does not exist", async () => { + deleteRequestFilterMock.mockResolvedValue(false); + + const { deleteRequestFilterAction } = await import("@/actions/request-filters"); + const res = await deleteRequestFilterAction(999); + + expect(res.ok).toBe(false); + expect(reloadMock).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/error-rules-list-refresh-ui.test.tsx b/tests/unit/error-rules-list-refresh-ui.test.tsx new file mode 100644 index 000000000..5a1d41a8b --- /dev/null +++ b/tests/unit/error-rules-list-refresh-ui.test.tsx @@ -0,0 +1,304 @@ +/** + * @vitest-environment happy-dom + * + * Regression: editing/toggling/deleting an error rule must refresh the list + * immediately (router.refresh) instead of leaving stale data until a manual + * page/cache refresh. Mirrors the behavior request-filters already has. + */ + +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import type { ErrorRule } from "@/repository/error-rules"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const refreshMock = vi.fn(); +const updateErrorRuleActionMock = vi.fn(async () => ({ ok: true })); +const deleteErrorRuleActionMock = vi.fn(async () => ({ ok: true })); +const createErrorRuleActionMock = vi.fn(async () => ({ ok: true })); +const refreshCacheActionMock = vi.fn(async () => ({ + ok: true, + data: { stats: { totalCount: 3 } }, +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ refresh: refreshMock, push: vi.fn(), replace: vi.fn() }), +})); + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, + useTimeZone: () => "UTC", +})); + +vi.mock("sonner", () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})); + +vi.mock("@/lib/api-client/v1/actions/error-rules", () => ({ + updateErrorRuleAction: updateErrorRuleActionMock, + deleteErrorRuleAction: deleteErrorRuleActionMock, + createErrorRuleAction: createErrorRuleActionMock, + refreshCacheAction: refreshCacheActionMock, +})); + +// --- UI primitive stubs (Radix portals/providers are noise for this test) --- +vi.mock("@/components/ui/switch", () => ({ + Switch: ({ checked, onCheckedChange, "aria-label": ariaLabel }: any) => ( + + ), +})); + +vi.mock("@/components/ui/badge", () => ({ + Badge: ({ children }: any) => {children}, +})); + +vi.mock("@/components/ui/tooltip", () => ({ + Tooltip: ({ children }: any) => <>{children}, + TooltipTrigger: ({ children }: any) => <>{children}, + TooltipContent: () => null, + TooltipProvider: ({ children }: any) => <>{children}, +})); + +vi.mock("@/components/ui/dialog", () => ({ + Dialog: ({ children }: any) =>
{children}
, + DialogContent: ({ children }: any) =>
{children}
, + DialogHeader: ({ children }: any) =>
{children}
, + DialogFooter: ({ children }: any) =>
{children}
, + DialogTitle: ({ children }: any) =>
{children}
, + DialogDescription: ({ children }: any) =>
{children}
, + DialogTrigger: ({ children }: any) =>
{children}
, +})); + +vi.mock("@/components/ui/select", () => ({ + // Drivable native onValueChange?.(e.target.value)} + > + + ))} + + ), + SelectContent: () => null, + SelectItem: () => null, + SelectTrigger: () => null, + SelectValue: () => null, +})); + +vi.mock("@/components/ui/label", () => ({ + Label: ({ children }: any) => , +})); + +vi.mock("@/app/[locale]/settings/error-rules/_components/override-section", () => ({ + OverrideSection: () => null, +})); + +vi.mock("@/app/[locale]/settings/error-rules/_components/regex-tester", () => ({ + RegexTester: () => null, +})); + +const rule: ErrorRule = { + id: 42, + pattern: "boom", + category: "prompt_limit", + matchType: "contains", + description: "test rule", + overrideResponse: null, + overrideStatusCode: null, + isEnabled: true, + isDefault: false, + priority: 0, + createdAt: new Date("2026-06-01T00:00:00.000Z"), + updatedAt: new Date("2026-06-01T00:00:00.000Z"), +}; + +let container: HTMLDivElement; +let root: Root; + +async function mount(element: React.ReactNode) { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + await act(async () => { + root.render(element); + }); +} + +async function flush() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +// Sets a React-controlled element's value via the native setter, then fires the +// event React listens to (input for text, change for select) so state updates. +function setControlledValue( + el: HTMLInputElement | HTMLSelectElement, + proto: { prototype: object }, + value: string, + eventName: "input" | "change" +) { + const setter = Object.getOwnPropertyDescriptor(proto.prototype, "value")?.set; + setter?.call(el, value); + el.dispatchEvent(new Event(eventName, { bubbles: true })); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal( + "confirm", + vi.fn(() => true) + ); +}); + +afterEach(async () => { + await act(async () => { + root?.unmount(); + }); + container?.remove(); + vi.unstubAllGlobals(); +}); + +describe("error rules list refresh after mutation", () => { + test("toggling a rule refreshes the list", async () => { + const { RuleListTable } = await import( + "@/app/[locale]/settings/error-rules/_components/rule-list-table" + ); + + await mount(); + + const toggle = container.querySelector('button[role="switch"]') as HTMLButtonElement; + expect(toggle).toBeTruthy(); + + await act(async () => { + toggle.click(); + }); + await flush(); + + expect(updateErrorRuleActionMock).toHaveBeenCalledWith(42, { isEnabled: false }); + expect(refreshMock).toHaveBeenCalled(); + }); + + test("deleting a rule refreshes the list", async () => { + const { RuleListTable } = await import( + "@/app/[locale]/settings/error-rules/_components/rule-list-table" + ); + + await mount(); + + const deleteButton = container + .querySelector(".lucide-trash-2") + ?.closest("button") as HTMLButtonElement; + expect(deleteButton).toBeTruthy(); + + await act(async () => { + deleteButton.click(); + }); + await flush(); + + expect(deleteErrorRuleActionMock).toHaveBeenCalledWith(42); + expect(refreshMock).toHaveBeenCalled(); + }); + + test("saving an edited rule refreshes the list", async () => { + const { EditRuleDialog } = await import( + "@/app/[locale]/settings/error-rules/_components/edit-rule-dialog" + ); + + await mount(); + + const form = container.querySelector("form") as HTMLFormElement; + expect(form).toBeTruthy(); + + await act(async () => { + form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); + }); + await flush(); + + expect(updateErrorRuleActionMock).toHaveBeenCalled(); + expect(refreshMock).toHaveBeenCalled(); + }); + + test("creating a rule refreshes the list", async () => { + const { AddRuleDialog } = await import( + "@/app/[locale]/settings/error-rules/_components/add-rule-dialog" + ); + + await mount(); + + // Drive the controlled pattern input and category select, then submit. + await act(async () => { + setControlledValue( + container.querySelector("#pattern") as HTMLInputElement, + window.HTMLInputElement, + "boom", + "input" + ); + setControlledValue( + container.querySelector('[data-testid="category-select"]') as HTMLSelectElement, + window.HTMLSelectElement, + "prompt_limit", + "change" + ); + }); + + const form = container.querySelector("form") as HTMLFormElement; + expect(form).toBeTruthy(); + + await act(async () => { + form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); + }); + await flush(); + + expect(createErrorRuleActionMock).toHaveBeenCalled(); + expect(refreshMock).toHaveBeenCalled(); + }); + + test("refreshing the cache refreshes the list", async () => { + const { RefreshCacheButton } = await import( + "@/app/[locale]/settings/error-rules/_components/refresh-cache-button" + ); + + await mount(); + + const button = container.querySelector("button") as HTMLButtonElement; + expect(button).toBeTruthy(); + + await act(async () => { + button.click(); + }); + await flush(); + + expect(refreshCacheActionMock).toHaveBeenCalled(); + expect(refreshMock).toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/lib/request-filter-engine-reload-queue.test.ts b/tests/unit/lib/request-filter-engine-reload-queue.test.ts new file mode 100644 index 000000000..408ac3cf7 --- /dev/null +++ b/tests/unit/lib/request-filter-engine-reload-queue.test.ts @@ -0,0 +1,216 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import type { RequestFilter } from "@/repository/request-filters"; + +const mocks = vi.hoisted(() => { + const listeners = new Map void>>(); + + return { + getActiveRequestFilters: vi.fn(), + subscribeCacheInvalidation: vi.fn(async () => undefined), + eventEmitter: { + on(event: string, handler: (...args: unknown[]) => void) { + const current = listeners.get(event) ?? new Set<(...args: unknown[]) => void>(); + current.add(handler); + listeners.set(event, current); + }, + off(event: string, handler: (...args: unknown[]) => void) { + listeners.get(event)?.delete(handler); + }, + emit(event: string, ...args: unknown[]) { + for (const handler of listeners.get(event) ?? []) { + handler(...args); + } + }, + removeAllListeners() { + listeners.clear(); + }, + }, + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + trace: vi.fn(), + error: vi.fn(), + fatal: vi.fn(), + }, + }; +}); + +vi.mock("@/repository/request-filters", () => ({ + getActiveRequestFilters: mocks.getActiveRequestFilters, +})); + +vi.mock("@/lib/event-emitter", () => ({ + eventEmitter: mocks.eventEmitter, +})); + +vi.mock("@/lib/redis/pubsub", () => ({ + CHANNEL_REQUEST_FILTERS_UPDATED: "requestFiltersUpdated", + subscribeCacheInvalidation: mocks.subscribeCacheInvalidation, +})); + +vi.mock("@/lib/logger", () => ({ + logger: mocks.logger, +})); + +let nextId = 1; +function buildFilter(overrides?: Partial): RequestFilter { + return { + id: nextId++, + name: "filter", + description: null, + scope: "header", + action: "remove", + matchType: null, + target: "x-test-header", + replacement: null, + priority: 0, + isEnabled: true, + bindingType: "global", + providerIds: null, + groupTags: null, + ruleMode: "simple", + executionPhase: "guard", + operations: null, + createdAt: new Date("2026-06-01T00:00:00.000Z"), + updatedAt: new Date("2026-06-01T00:00:00.000Z"), + ...overrides, + }; +} + +/** Returns an array of N distinct global-guard filters. */ +function filters(n: number): RequestFilter[] { + return Array.from({ length: n }, () => buildFilter()); +} + +describe("RequestFilterEngine reload queue", () => { + afterEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + mocks.eventEmitter.removeAllListeners(); + // The engine is a globalThis singleton that survives resetModules, so its + // event listener only registers on first construction. Drop it so the next + // test re-imports a fresh engine that re-subscribes to mocks.eventEmitter. + delete (globalThis as Record).__CCH_REQUEST_FILTER_ENGINE__; + nextId = 1; + }); + + test("applies a reload requested while another reload is in-flight (not dropped)", async () => { + let resolveFirstLoad: ((value: RequestFilter[]) => void) | undefined; + + // First load is slow and returns 1 filter (the "old" snapshot). + // Second load returns 2 filters (the "new" snapshot saved by the user). + mocks.getActiveRequestFilters + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirstLoad = resolve; + }) + ) + .mockResolvedValueOnce(filters(2)); + + const { requestFilterEngine } = await import("@/lib/request-filter-engine"); + // Allow the constructor's async event-listener wiring to settle. + await new Promise((resolve) => setTimeout(resolve, 0)); + + const firstReload = requestFilterEngine.reload(); // starts load #1 (pending) + const secondReload = requestFilterEngine.reload(); // requested mid-flight -> must queue + + // Let the dynamic import inside reload() settle so load #1 actually calls + // getActiveRequestFilters (assigning resolveFirstLoad) before we resolve it. + await new Promise((resolve) => setTimeout(resolve, 0)); + resolveFirstLoad?.(filters(1)); + await Promise.all([firstReload, secondReload]); + + // The concurrent reload must NOT be silently dropped: a second DB read runs + // and the engine ends up reflecting the newest snapshot (2 filters), not the + // stale one (1 filter). + expect(mocks.getActiveRequestFilters).toHaveBeenCalledTimes(2); + expect(requestFilterEngine.getStats().count).toBe(2); + }); + + test("a requestFiltersUpdated event during a reload triggers a queued rerun", async () => { + let resolveFirstLoad: ((value: RequestFilter[]) => void) | undefined; + + mocks.getActiveRequestFilters + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirstLoad = resolve; + }) + ) + .mockResolvedValueOnce(filters(3)); + + const { requestFilterEngine } = await import("@/lib/request-filter-engine"); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const firstReload = requestFilterEngine.reload(); + mocks.eventEmitter.emit("requestFiltersUpdated"); + + await new Promise((resolve) => setTimeout(resolve, 0)); + resolveFirstLoad?.(filters(1)); + await firstReload; + // Let the queued rerun (kicked by the event handler) settle. + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(mocks.getActiveRequestFilters).toHaveBeenCalledTimes(2); + expect(requestFilterEngine.getStats().count).toBe(3); + }); + + test("an awaited reload after an in-flight reload observes the freshest snapshot", async () => { + // Models the save path: the repository emits an event (fire-and-forget reload), + // then the action awaits its own reload. The awaited reload must resolve only + // after a pass that reflects the just-written rows. + let resolveFirstLoad: ((value: RequestFilter[]) => void) | undefined; + + mocks.getActiveRequestFilters + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirstLoad = resolve; + }) + ) + .mockResolvedValueOnce(filters(5)); + + const { requestFilterEngine } = await import("@/lib/request-filter-engine"); + await new Promise((resolve) => setTimeout(resolve, 0)); + + // Event-driven (fire-and-forget) reload starts first. + void requestFilterEngine.reload(); + // Action's awaited reload races in while the first is still loading. + const awaitedReload = requestFilterEngine.reload(); + + await new Promise((resolve) => setTimeout(resolve, 0)); + resolveFirstLoad?.(filters(1)); + await awaitedReload; + + expect(mocks.getActiveRequestFilters).toHaveBeenCalledTimes(2); + expect(requestFilterEngine.getStats().count).toBe(5); + }); + + test("reload(false) reuses an in-flight reload without forcing a redundant rerun", async () => { + let resolveLoad: ((value: RequestFilter[]) => void) | undefined; + + // Only ONE DB read should happen: the second reload(false) must reuse the + // in-flight load instead of queueing a second pass. + mocks.getActiveRequestFilters.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveLoad = resolve; + }) + ); + + const { requestFilterEngine } = await import("@/lib/request-filter-engine"); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const first = requestFilterEngine.reload(false); // starts load #1 + const second = requestFilterEngine.reload(false); // in-flight + queue=false -> reuse + + await new Promise((resolve) => setTimeout(resolve, 0)); + resolveLoad?.(filters(2)); + await Promise.all([first, second]); + + expect(mocks.getActiveRequestFilters).toHaveBeenCalledTimes(1); + expect(requestFilterEngine.getStats().count).toBe(2); + }); +}); From e3070dd1319642dbd7a5f054586cd00698762bd3 Mon Sep 17 00:00:00 2001 From: Ding <44717411+ding113@users.noreply.github.com> Date: Thu, 4 Jun 2026 01:08:29 +0800 Subject: [PATCH 09/13] feat(usage-logs): XLSX export with summary sheet + Excel-safe numeric & timezone-aware timestamps (#1245) Add XLSX usage-logs export (detail sheet + daily/hourly summary sheet), Excel-safe numeric normalization (<=15 sig digits) so SUM() works, and timezone-aware timestamps rendered in the system timezone. Streams batches (no full row retention), async zip, hand-rolled OOXML via fflate. Addressed automated review: OOXML gray125 fill, strict XML 1.0 char filter, invalid-Date guards, sync-xlsx rejection, typed download cast, test cleanup. --- messages/en/dashboard.json | 2 + messages/ja/dashboard.json | 2 + messages/ru/dashboard.json | 2 + messages/zh-CN/dashboard.json | 2 + messages/zh-TW/dashboard.json | 2 + package.json | 1 + src/actions/usage-logs.ts | 199 ++++++------ .../logs/_components/usage-logs-filters.tsx | 41 ++- .../api/v1/resources/usage-logs/handlers.ts | 26 +- src/lib/api-client/v1/actions/usage-logs.ts | 14 +- src/lib/api-client/v1/openapi-types.gen.ts | 6 + src/lib/api/v1/schemas/usage-logs.ts | 9 +- src/lib/usage-logs/export/columns.ts | 118 ++++++++ src/lib/usage-logs/export/csv.ts | 66 ++++ src/lib/usage-logs/export/format.ts | 38 +++ src/lib/usage-logs/export/numeric.ts | 51 ++++ src/lib/usage-logs/export/summary.ts | 163 ++++++++++ src/lib/usage-logs/export/xlsx.ts | 282 ++++++++++++++++++ tests/api/v1/usage-logs/usage-logs.test.ts | 54 +++- .../usage-logs-export-retry-count.test.ts | 12 +- .../actions/usage-logs-export-xlsx.test.ts | 181 +++++++++++ tests/unit/api/v1/api-client-actions.test.ts | 42 ++- ...dashboard-logs-export-progress-ui.test.tsx | 88 +++++- tests/unit/usage-logs/export-csv.test.ts | 132 ++++++++ tests/unit/usage-logs/export-numeric.test.ts | 64 ++++ tests/unit/usage-logs/export-summary.test.ts | 116 +++++++ tests/unit/usage-logs/export-xlsx.test.ts | 199 ++++++++++++ 27 files changed, 1782 insertions(+), 130 deletions(-) create mode 100644 src/lib/usage-logs/export/columns.ts create mode 100644 src/lib/usage-logs/export/csv.ts create mode 100644 src/lib/usage-logs/export/format.ts create mode 100644 src/lib/usage-logs/export/numeric.ts create mode 100644 src/lib/usage-logs/export/summary.ts create mode 100644 src/lib/usage-logs/export/xlsx.ts create mode 100644 tests/unit/actions/usage-logs-export-xlsx.test.ts create mode 100644 tests/unit/usage-logs/export-csv.test.ts create mode 100644 tests/unit/usage-logs/export-numeric.test.ts create mode 100644 tests/unit/usage-logs/export-summary.test.ts create mode 100644 tests/unit/usage-logs/export-xlsx.test.ts diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json index 48fee963a..1bf141484 100644 --- a/messages/en/dashboard.json +++ b/messages/en/dashboard.json @@ -97,6 +97,8 @@ "exportError": "Export failed", "exportPreparing": "Preparing export...", "exportProgress": "Exported {current} / {total}", + "exportAsCsv": "Export as CSV", + "exportAsXlsx": "Export as XLSX (with summary)", "quickFilters": { "today": "Today", "thisWeek": "This Week", diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json index 74a9ba3c8..437d49e7b 100644 --- a/messages/ja/dashboard.json +++ b/messages/ja/dashboard.json @@ -97,6 +97,8 @@ "exportError": "エクスポートに失敗しました", "exportPreparing": "エクスポートを準備中...", "exportProgress": "{current} / {total} 件をエクスポート済み", + "exportAsCsv": "CSV としてエクスポート", + "exportAsXlsx": "XLSX としてエクスポート (集計付き)", "quickFilters": { "today": "今日", "thisWeek": "今週", diff --git a/messages/ru/dashboard.json b/messages/ru/dashboard.json index 680a6d01e..933a0f209 100644 --- a/messages/ru/dashboard.json +++ b/messages/ru/dashboard.json @@ -97,6 +97,8 @@ "exportError": "Ошибка экспорта", "exportPreparing": "Подготовка экспорта...", "exportProgress": "Экспортировано {current} / {total}", + "exportAsCsv": "Экспорт в CSV", + "exportAsXlsx": "Экспорт в XLSX (со сводкой)", "quickFilters": { "today": "Сегодня", "thisWeek": "Эта неделя", diff --git a/messages/zh-CN/dashboard.json b/messages/zh-CN/dashboard.json index 7a173ce43..c8e3b8b11 100644 --- a/messages/zh-CN/dashboard.json +++ b/messages/zh-CN/dashboard.json @@ -97,6 +97,8 @@ "exportError": "导出失败", "exportPreparing": "正在准备导出...", "exportProgress": "已导出 {current} / {total} 条", + "exportAsCsv": "导出为 CSV", + "exportAsXlsx": "导出为 XLSX(含汇总表)", "quickFilters": { "today": "今天", "thisWeek": "本周", diff --git a/messages/zh-TW/dashboard.json b/messages/zh-TW/dashboard.json index bafb784d9..c74a45547 100644 --- a/messages/zh-TW/dashboard.json +++ b/messages/zh-TW/dashboard.json @@ -97,6 +97,8 @@ "exportError": "匯出失敗", "exportPreparing": "正在準備匯出...", "exportProgress": "已匯出 {current} / {total} 筆", + "exportAsCsv": "匯出為 CSV", + "exportAsXlsx": "匯出為 XLSX(含彙總表)", "quickFilters": { "today": "今天", "thisWeek": "本週", diff --git a/package.json b/package.json index 52f3bfbec..506c51648 100644 --- a/package.json +++ b/package.json @@ -92,6 +92,7 @@ "dotenv": "^17", "drizzle-orm": "^0.45", "fetch-socks": "^1", + "fflate": "^0.8.2", "framer-motion": "^12", "hono": "^4", "html2canvas": "^1", diff --git a/src/actions/usage-logs.ts b/src/actions/usage-logs.ts index 25a96fc3f..e75f71fe3 100644 --- a/src/actions/usage-logs.ts +++ b/src/actions/usage-logs.ts @@ -9,8 +9,11 @@ import { import { logger } from "@/lib/logger"; import { readLiveChainBatch } from "@/lib/redis/live-chain-store"; import { RedisKVStore } from "@/lib/redis/redis-kv-store"; -import { getRetryCount } from "@/lib/utils/provider-chain-formatter"; +import { buildCsvHeaderLine, buildCsvRows, CSV_BOM } from "@/lib/usage-logs/export/csv"; +import { createSummaryAccumulator } from "@/lib/usage-logs/export/summary"; +import { assembleUsageLogsXlsx, buildDetailRowXml } from "@/lib/usage-logs/export/xlsx"; import { isProviderFinalized } from "@/lib/utils/provider-display"; +import { resolveSystemTimezone } from "@/lib/utils/timezone"; import { findUsageLogSessionIdSuggestions, findUsageLogsBatch, @@ -21,7 +24,6 @@ import { getUsedStatusCodes, type UsageLogBatchFilters, type UsageLogFilters, - type UsageLogRow, type UsageLogSummary, type UsageLogsBatchResult, type UsageLogsResult, @@ -44,26 +46,7 @@ const USAGE_LOGS_EXPORT_BATCH_SIZE = 500; const USAGE_LOGS_EXPORT_JOB_TTL_MS = 15 * 60 * 1000; const USAGE_LOGS_EXPORT_JOB_TTL_SECONDS = Math.floor(USAGE_LOGS_EXPORT_JOB_TTL_MS / 1000); const USAGE_LOGS_EXPORT_PROGRESS_UPDATE_INTERVAL_MS = 800; -const CSV_HEADERS = [ - "Time", - "User", - "Key", - "Provider", - "Model", - "Original Model", - "Endpoint", - "Status Code", - "Input Tokens", - "Output Tokens", - "Cache Write 5m", - "Cache Write 1h", - "Cache Read", - "Total Tokens", - "Cost (USD)", - "Duration (ms)", - "Session ID", - "Retry Count", -] as const; +export type UsageLogsExportFormat = "csv" | "xlsx"; type UsageLogsSession = NonNullable>>; @@ -73,9 +56,18 @@ export interface UsageLogsExportStatus { processedRows: number; totalRows: number; progressPercent: number; + format: UsageLogsExportFormat; error?: string; } +export interface UsageLogsExportDownload { + /** CSV text (encoding "utf8") or base64-encoded XLSX bytes (encoding "base64"). */ + content: string; + encoding: "utf8" | "base64"; + format: UsageLogsExportFormat; + filename: string; +} + interface UsageLogsExportJobRecord extends UsageLogsExportStatus { ownerUserId: number; } @@ -85,13 +77,21 @@ const usageLogsExportStatusStore = new RedisKVStore({ defaultTtlSeconds: USAGE_LOGS_EXPORT_JOB_TTL_SECONDS, }); -const usageLogsExportCsvStore = new RedisKVStore({ - prefix: "cch:usage-logs:export:csv:", +const usageLogsExportResultStore = new RedisKVStore({ + prefix: "cch:usage-logs:export:result:", defaultTtlSeconds: USAGE_LOGS_EXPORT_JOB_TTL_SECONDS, }); -function usageLogsExportCsvKey(jobId: string): string { - return `${jobId}:csv`; +function usageLogsExportResultKey(jobId: string): string { + return `${jobId}:result`; +} + +function fileExtensionForFormat(format: UsageLogsExportFormat): string { + return format === "xlsx" ? "xlsx" : "csv"; +} + +function encodingForFormat(format: UsageLogsExportFormat): "utf8" | "base64" { + return format === "xlsx" ? "base64" : "utf8"; } function resolveUsageLogFiltersForSession( @@ -108,6 +108,7 @@ function toUsageLogsExportStatus(job: UsageLogsExportJobRecord): UsageLogsExport processedRows: job.processedRows, totalRows: job.totalRows, progressPercent: job.progressPercent, + format: job.format, error: job.error, }; } @@ -123,32 +124,6 @@ function getUsageLogsExportJob( return job; } -function buildCsvRows(logs: UsageLogRow[]): string[] { - return logs.map((log) => { - const retryCount = log.providerChain ? getRetryCount(log.providerChain) : 0; - return [ - log.createdAt ? new Date(log.createdAt).toISOString() : "", - escapeCsvField(log.userName), - escapeCsvField(log.keyName), - escapeCsvField(log.providerName ?? ""), - escapeCsvField(log.model ?? ""), - escapeCsvField(log.originalModel ?? ""), - escapeCsvField(log.endpoint ?? ""), - log.statusCode?.toString() ?? "", - log.inputTokens?.toString() ?? "0", - log.outputTokens?.toString() ?? "0", - log.cacheCreation5mInputTokens?.toString() ?? "0", - log.cacheCreation1hInputTokens?.toString() ?? "0", - log.cacheReadInputTokens?.toString() ?? "0", - log.totalTokens.toString(), - log.costUsd ?? "0", - log.durationMs?.toString() ?? "", - escapeCsvField(log.sessionId ?? ""), - retryCount.toString(), - ].join(","); - }); -} - function buildUsageLogsExportProgress( processedRows: number, totalRows: number, @@ -169,8 +144,15 @@ function buildUsageLogsExportProgress( }; } -async function buildUsageLogsExportCsv( +/** + * Stream every matching usage log (in batches) and assemble the requested + * export format. Timestamps are rendered in `timezone` and numeric columns are + * normalized so Excel parses them as numbers (see @/lib/usage-logs/export). + */ +async function buildUsageLogsExport( filters: Omit, + format: UsageLogsExportFormat, + timezone: string, onProgress?: ( progress: Pick ) => Promise | void @@ -183,7 +165,11 @@ async function buildUsageLogsExportCsv( estimatedTotalRows = stats.totalRequests; } - const csvLines = [CSV_HEADERS.join(",")]; + // Both formats stream batch-by-batch into string buffers (CSV lines / XLSX row + // XML + an incremental summary) so the full UsageLogRow[] is never retained. + const csvLines = format === "csv" ? [buildCsvHeaderLine(timezone)] : []; + const xlsxDetailRows: string[] = []; + const xlsxSummary = format === "xlsx" ? createSummaryAccumulator(timezone) : null; let cursor: UsageLogBatchFilters["cursor"] | undefined; let processedRows = 0; @@ -195,7 +181,14 @@ async function buildUsageLogsExportCsv( }); if (batch.logs.length > 0) { - csvLines.push(...buildCsvRows(batch.logs)); + if (format === "xlsx" && xlsxSummary) { + for (const log of batch.logs) { + xlsxDetailRows.push(buildDetailRowXml(log, xlsxDetailRows.length + 2, timezone)); + xlsxSummary.add(log); + } + } else { + csvLines.push(...buildCsvRows(batch.logs, timezone)); + } processedRows += batch.logs.length; } @@ -210,12 +203,22 @@ async function buildUsageLogsExportCsv( cursor = batch.nextCursor; } - return `\uFEFF${csvLines.join("\n")}`; + if (format === "xlsx" && xlsxSummary) { + const bytes = await assembleUsageLogsXlsx({ + detailRowsXml: xlsxDetailRows, + summary: xlsxSummary.finalize(), + timezone, + }); + return Buffer.from(bytes).toString("base64"); + } + + return `${CSV_BOM}${csvLines.join("\n")}`; } async function runUsageLogsExportJob( jobId: string, - filters: Omit + filters: Omit, + format: UsageLogsExportFormat ): Promise { const existingJob = await usageLogsExportStatusStore.get(jobId); if (!existingJob) { @@ -229,8 +232,9 @@ async function runUsageLogsExportJob( }); try { + const timezone = await resolveSystemTimezone(); let lastProgressUpdateAt = 0; - const csv = await buildUsageLogsExportCsv(filters, async (progress) => { + const content = await buildUsageLogsExport(filters, format, timezone, async (progress) => { const now = Date.now(); if ( progress.progressPercent < 100 && @@ -257,13 +261,16 @@ async function runUsageLogsExportJob( return; } - const csvStored = await usageLogsExportCsvStore.set(usageLogsExportCsvKey(jobId), csv); - if (!csvStored) { + const resultStored = await usageLogsExportResultStore.set( + usageLogsExportResultKey(jobId), + content + ); + if (!resultStored) { await usageLogsExportStatusStore.set(jobId, { ...currentJob, status: "failed", progressPercent: 0, - error: "Failed to persist CSV to Redis", + error: "Failed to persist export to Redis", }); return; } @@ -316,22 +323,34 @@ export async function getUsageLogs( } } +export type UsageLogsExportInput = Omit & { + format?: UsageLogsExportFormat; +}; + /** - * 导出使用日志为 CSV 格式 + * 同步导出使用日志为 CSV 格式(XLSX 仅支持异步任务) */ -export async function exportUsageLogs( - filters: Omit -): Promise> { +export async function exportUsageLogs(input: UsageLogsExportInput): Promise> { try { const session = await getSession(); if (!session) { return { ok: false, error: "未登录" }; } + const { format = "csv", ...filters } = input; + if (format !== "csv") { + // XLSX is assembled from every matching row in memory, so it is only + // offered via the async job flow. + return { + ok: false, + error: "Synchronous export only supports CSV; use the async job for XLSX.", + }; + } const finalFilters = resolveUsageLogFiltersForSession(session, filters); - const csv = await buildUsageLogsExportCsv(finalFilters); + const timezone = await resolveSystemTimezone(); + const content = await buildUsageLogsExport(finalFilters, "csv", timezone); - return { ok: true, data: csv }; + return { ok: true, data: content }; } catch (error) { logger.error("导出使用日志失败:", error); const message = error instanceof Error ? error.message : "导出使用日志失败"; @@ -340,7 +359,7 @@ export async function exportUsageLogs( } export async function startUsageLogsExport( - filters: Omit + input: UsageLogsExportInput ): Promise> { try { const session = await getSession(); @@ -348,6 +367,7 @@ export async function startUsageLogsExport( return { ok: false, error: "未登录" }; } + const { format = "csv", ...filters } = input; const jobId = crypto.randomUUID(); const finalFilters = resolveUsageLogFiltersForSession(session, filters); @@ -358,6 +378,7 @@ export async function startUsageLogsExport( processedRows: 0, totalRows: 0, progressPercent: 0, + format, }); if (!stored) { @@ -367,7 +388,7 @@ export async function startUsageLogsExport( // Defer to next tick so the action returns the jobId immediately. // Safe for self-hosted Bun server (long-lived process); NOT suitable for serverless. setTimeout(() => { - void runUsageLogsExportJob(jobId, finalFilters); + void runUsageLogsExportJob(jobId, finalFilters, format); }, 0); return { ok: true, data: { jobId } }; @@ -400,7 +421,9 @@ export async function getUsageLogsExportStatus( } } -export async function downloadUsageLogsExport(jobId: string): Promise> { +export async function downloadUsageLogsExport( + jobId: string +): Promise> { try { const session = await getSession(); if (!session) { @@ -420,12 +443,20 @@ export async function downloadUsageLogsExport(jobId: string): Promise trimmedField.startsWith(char))) { - safeField = `'${field}`; - } - - if ( - safeField.includes(",") || - safeField.includes('"') || - safeField.includes("\n") || - safeField.includes("\r") - ) { - return `"${safeField.replace(/"/g, '""')}"`; - } - return safeField; -} - /** * 获取模型列表(用于筛选器) */ diff --git a/src/app/[locale]/dashboard/logs/_components/usage-logs-filters.tsx b/src/app/[locale]/dashboard/logs/_components/usage-logs-filters.tsx index dd515b41a..26a9b1a6f 100644 --- a/src/app/[locale]/dashboard/logs/_components/usage-logs-filters.tsx +++ b/src/app/[locale]/dashboard/logs/_components/usage-logs-filters.tsx @@ -1,11 +1,17 @@ "use client"; import { format, startOfDay, startOfWeek } from "date-fns"; -import { Clock, Download, Network, Server, User } from "lucide-react"; +import { ChevronDown, Clock, Download, Network, Server, User } from "lucide-react"; import { useTranslations } from "next-intl"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { Progress } from "@/components/ui/progress"; import { downloadUsageLogsExport, @@ -167,19 +173,18 @@ export function UsageLogsFilters({ onReset(); }, [onReset]); - const downloadCsv = useCallback((csv: string) => { - const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }); + const downloadBlob = useCallback((blob: Blob, extension: string) => { const url = window.URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; - a.download = `usage-logs-${format(new Date(), "yyyy-MM-dd-HHmmss")}.csv`; + a.download = `usage-logs-${format(new Date(), "yyyy-MM-dd-HHmmss")}.${extension}`; document.body.appendChild(a); a.click(); document.body.removeChild(a); window.URL.revokeObjectURL(url); }, []); - const handleExport = async () => { + const handleExport = async (exportFormat: "csv" | "xlsx") => { const runId = exportRunIdRef.current + 1; exportRunIdRef.current = runId; setIsExporting(true); @@ -189,11 +194,12 @@ export function UsageLogsFilters({ processedRows: 0, totalRows: 0, progressPercent: 0, + format: exportFormat, }); try { const exportFilters = sanitizeFilters(localFilters); - const startResult = await startUsageLogsExport(exportFilters); + const startResult = await startUsageLogsExport({ ...exportFilters, format: exportFormat }); if (exportRunIdRef.current !== runId) { return; } @@ -259,7 +265,7 @@ export function UsageLogsFilters({ return; } - downloadCsv(downloadResult.data); + downloadBlob(downloadResult.data.blob, exportFormat === "xlsx" ? "xlsx" : "csv"); toast.success(t("logs.filters.exportSuccess")); } catch (error) { @@ -423,10 +429,23 @@ export function UsageLogsFilters({ - + + + + + + handleExport("csv")} disabled={isExporting}> + {t("logs.filters.exportAsCsv")} + + handleExport("xlsx")} disabled={isExporting}> + {t("logs.filters.exportAsXlsx")} + + + {isExporting && exportStatus ? (
diff --git a/src/app/api/v1/resources/usage-logs/handlers.ts b/src/app/api/v1/resources/usage-logs/handlers.ts index f2dddc81c..2328342cc 100644 --- a/src/app/api/v1/resources/usage-logs/handlers.ts +++ b/src/app/api/v1/resources/usage-logs/handlers.ts @@ -1,5 +1,6 @@ import type { Context } from "hono"; import type { ActionResult } from "@/actions/types"; +import type { UsageLogsExportDownload } from "@/actions/usage-logs"; import { callAction } from "@/lib/api/v1/_shared/action-bridge"; import { createProblemResponse, @@ -86,6 +87,18 @@ export async function createUsageLogsExport(c: Context): Promise { if (!body.ok) return body.response; const actions = await import("@/actions/usage-logs"); const preferAsync = (c.req.header("prefer") ?? "").toLowerCase().includes("respond-async"); + + // XLSX is assembled in-memory from every matching row, so it is only offered + // via the async job flow (sync exports always return CSV). + if (!preferAsync && body.data.format === "xlsx") { + return createProblemResponse({ + status: 400, + instance: new URL(c.req.url).pathname, + errorCode: "usage_logs.xlsx_requires_async", + detail: "xlsx export requires asynchronous processing (set 'Prefer: respond-async').", + }); + } + const result = preferAsync ? await callAction(c, actions.startUsageLogsExport, [body.data] as never[], c.get("auth")) : await callAction(c, actions.exportUsageLogs, [body.data] as never[], c.get("auth")); @@ -123,10 +136,17 @@ export async function downloadUsageLogsExport(c: Context): Promise { c.get("auth") ); if (!result.ok) return actionError(c, result); - return new Response(String(result.data), { + const download = result.data as UsageLogsExportDownload; + const isXlsx = download.format === "xlsx"; + const body = + download.encoding === "base64" ? Buffer.from(download.content, "base64") : download.content; + const contentType = isXlsx + ? "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + : "text/csv; charset=utf-8"; + return new Response(body, { headers: { - "Content-Type": "text/csv; charset=utf-8", - "Content-Disposition": `attachment; filename="usage-logs-${params.jobId}.csv"`, + "Content-Type": contentType, + "Content-Disposition": `attachment; filename="${download.filename}"`, }, }); } diff --git a/src/lib/api-client/v1/actions/usage-logs.ts b/src/lib/api-client/v1/actions/usage-logs.ts index ce9d10d22..483d5a02e 100644 --- a/src/lib/api-client/v1/actions/usage-logs.ts +++ b/src/lib/api-client/v1/actions/usage-logs.ts @@ -61,7 +61,11 @@ export function getUsageLogSessionIdSuggestions(params: object) { } export function exportUsageLogs(params?: object) { - return toActionResult(apiPost("/api/v1/usage-logs/exports", params)); + // Unwrap the REST `{ csv }` envelope so the result matches the server action + // contract (ActionResult). + return toActionResult( + apiPost<{ csv: string }>("/api/v1/usage-logs/exports", params).then((body) => body.csv) + ); } export function startUsageLogsExport(params?: object) { @@ -78,13 +82,17 @@ export function getUsageLogsExportStatus(jobId: string) { ); } +export interface UsageLogsExportDownloadFile { + blob: Blob; +} + export function downloadUsageLogsExport(jobId: string) { - return toActionResult( + return toActionResult( fetch(`/api/v1/usage-logs/exports/${encodeURIComponent(jobId)}/download`, { credentials: "include", }).then(async (response) => { if (!response.ok) throw new Error(response.statusText || "Export download failed"); - return response.text(); + return { blob: await response.blob() }; }) ); } diff --git a/src/lib/api-client/v1/openapi-types.gen.ts b/src/lib/api-client/v1/openapi-types.gen.ts index 81102bb70..0c521716a 100644 --- a/src/lib/api-client/v1/openapi-types.gen.ts +++ b/src/lib/api-client/v1/openapi-types.gen.ts @@ -36126,6 +36126,12 @@ export interface operations { startTime?: number | null; /** @description End timestamp in milliseconds. */ endTime?: number | null; + /** + * @description Export format. xlsx is only available asynchronously (Prefer: respond-async). + * @default csv + * @enum {string} + */ + format?: "csv" | "xlsx"; }; }; }; diff --git a/src/lib/api/v1/schemas/usage-logs.ts b/src/lib/api/v1/schemas/usage-logs.ts index f77712a97..be787ddef 100644 --- a/src/lib/api/v1/schemas/usage-logs.ts +++ b/src/lib/api/v1/schemas/usage-logs.ts @@ -36,7 +36,14 @@ export const UsageLogsExportCreateSchema = UsageLogsQuerySchema.omit({ limit: true, page: true, pageSize: true, -}).strict(); +}) + .extend({ + format: z + .enum(["csv", "xlsx"]) + .default("csv") + .describe("Export format. xlsx is only available asynchronously (Prefer: respond-async)."), + }) + .strict(); export const UsageLogExportJobParamSchema = z.object({ jobId: z.string().min(1).describe("Export job id."), diff --git a/src/lib/usage-logs/export/columns.ts b/src/lib/usage-logs/export/columns.ts new file mode 100644 index 000000000..f632ed2d6 --- /dev/null +++ b/src/lib/usage-logs/export/columns.ts @@ -0,0 +1,118 @@ +/** + * Single source of truth for the usage-logs export detail columns, shared by + * the CSV and XLSX renderers so they can never drift apart. + */ + +import { getRetryCount } from "@/lib/utils/provider-chain-formatter"; +import type { UsageLogRow } from "@/repository/usage-logs"; + +export type DetailColumnKind = "text" | "number" | "datetime"; + +export interface DetailColumn { + /** Stable English header (datetime columns get the timezone appended). */ + header: string; + kind: DetailColumnKind; + /** + * Raw extracted value: string for text columns, number|null for number + * columns, Date|null for datetime columns. + */ + get: (log: UsageLogRow) => string | number | Date | null; + /** number columns only: emit 0 (instead of blank) when the value is null. */ + zeroWhenNull?: boolean; + /** ExcelJS number/date format string. */ + numFmt?: string; +} + +export const COST_NUM_FMT = "0.00######"; +export const INT_NUM_FMT = "0"; +export const DATETIME_NUM_FMT = "yyyy-mm-dd hh:mm:ss"; + +function retryCountOf(log: UsageLogRow): number { + return log.providerChain ? getRetryCount(log.providerChain) : 0; +} + +export const DETAIL_COLUMNS: DetailColumn[] = [ + { header: "Time", kind: "datetime", numFmt: DATETIME_NUM_FMT, get: (log) => log.createdAt }, + { header: "User", kind: "text", get: (log) => log.userName }, + { header: "Key", kind: "text", get: (log) => log.keyName }, + { header: "Provider", kind: "text", get: (log) => log.providerName ?? "" }, + { header: "Model", kind: "text", get: (log) => log.model ?? "" }, + { header: "Original Model", kind: "text", get: (log) => log.originalModel ?? "" }, + { header: "Endpoint", kind: "text", get: (log) => log.endpoint ?? "" }, + { header: "Status Code", kind: "number", numFmt: INT_NUM_FMT, get: (log) => log.statusCode }, + { + header: "Input Tokens", + kind: "number", + numFmt: INT_NUM_FMT, + zeroWhenNull: true, + get: (log) => log.inputTokens, + }, + { + header: "Output Tokens", + kind: "number", + numFmt: INT_NUM_FMT, + zeroWhenNull: true, + get: (log) => log.outputTokens, + }, + { + header: "Cache Write 5m", + kind: "number", + numFmt: INT_NUM_FMT, + zeroWhenNull: true, + get: (log) => log.cacheCreation5mInputTokens, + }, + { + header: "Cache Write 1h", + kind: "number", + numFmt: INT_NUM_FMT, + zeroWhenNull: true, + get: (log) => log.cacheCreation1hInputTokens, + }, + { + header: "Cache Read", + kind: "number", + numFmt: INT_NUM_FMT, + zeroWhenNull: true, + get: (log) => log.cacheReadInputTokens, + }, + { + header: "Total Tokens", + kind: "number", + numFmt: INT_NUM_FMT, + zeroWhenNull: true, + get: (log) => log.totalTokens, + }, + { + header: "Cost (USD)", + kind: "number", + numFmt: COST_NUM_FMT, + zeroWhenNull: true, + get: (log) => log.costUsd, + }, + { header: "Duration (ms)", kind: "number", numFmt: INT_NUM_FMT, get: (log) => log.durationMs }, + { header: "Session ID", kind: "text", get: (log) => log.sessionId ?? "" }, + { + header: "Retry Count", + kind: "number", + numFmt: INT_NUM_FMT, + zeroWhenNull: true, + get: retryCountOf, + }, +]; + +/** + * Detail-sheet headers, with the timezone appended to datetime columns so the + * cells stay clean datetimes (e.g. "Time (Asia/Shanghai)"). + */ +export function buildDetailHeaders(timezone: string): string[] { + return DETAIL_COLUMNS.map((column) => + column.kind === "datetime" ? `${column.header} (${timezone})` : column.header + ); +} + +/** A cell value that should render blank (null, undefined, or whitespace-only). */ +export function isBlankValue(value: string | number | Date | null | undefined): boolean { + return ( + value === null || value === undefined || (typeof value === "string" && value.trim() === "") + ); +} diff --git a/src/lib/usage-logs/export/csv.ts b/src/lib/usage-logs/export/csv.ts new file mode 100644 index 000000000..64dd5735b --- /dev/null +++ b/src/lib/usage-logs/export/csv.ts @@ -0,0 +1,66 @@ +/** + * CSV rendering for usage-logs exports. Numeric columns are normalized so Excel + * parses them as numbers (see ./numeric), and timestamps are rendered in the + * resolved system timezone (see ./format). + */ + +import type { UsageLogRow } from "@/repository/usage-logs"; +import { buildDetailHeaders, DETAIL_COLUMNS, isBlankValue } from "./columns"; +import { formatExportTimestamp, isValidDate } from "./format"; +import { normalizeDecimalForSpreadsheet } from "./numeric"; + +export const CSV_BOM = ""; + +/** + * Escape a CSV field, neutralizing spreadsheet formula injection. Mirrors the + * historical behaviour: fields whose first non-whitespace char is one of + * = + - @ are prefixed with a single quote. + */ +export function escapeCsvField(field: string): string { + const dangerousChars = ["=", "+", "-", "@"]; + const trimmedField = field.trimStart(); + let safeField = field; + if (trimmedField && dangerousChars.some((char) => trimmedField.startsWith(char))) { + safeField = `'${field}`; + } + + if ( + safeField.includes(",") || + safeField.includes('"') || + safeField.includes("\n") || + safeField.includes("\r") + ) { + return `"${safeField.replace(/"/g, '""')}"`; + } + return safeField; +} + +function renderCsvCell( + value: string | number | Date | null, + column: (typeof DETAIL_COLUMNS)[number], + timezone: string +): string { + switch (column.kind) { + case "datetime": + return isValidDate(value) ? formatExportTimestamp(value, timezone) : ""; + case "number": + if (isBlankValue(value) && !column.zeroWhenNull) { + return ""; + } + return normalizeDecimalForSpreadsheet(value as string | number | null); + default: + return escapeCsvField(typeof value === "string" ? value : String(value ?? "")); + } +} + +/** The CSV header row (comma-joined), with the timezone annotation. */ +export function buildCsvHeaderLine(timezone: string): string { + return buildDetailHeaders(timezone).map(escapeCsvField).join(","); +} + +/** Render usage log rows as CSV data lines (no header, no BOM). */ +export function buildCsvRows(logs: UsageLogRow[], timezone: string): string[] { + return logs.map((log) => + DETAIL_COLUMNS.map((column) => renderCsvCell(column.get(log), column, timezone)).join(",") + ); +} diff --git a/src/lib/usage-logs/export/format.ts b/src/lib/usage-logs/export/format.ts new file mode 100644 index 000000000..dbe4d27db --- /dev/null +++ b/src/lib/usage-logs/export/format.ts @@ -0,0 +1,38 @@ +/** + * Timezone-aware formatting for spreadsheet exports. + * + * Timestamps are rendered in the system timezone (resolved by the caller via + * resolveSystemTimezone) instead of UTC, and the timezone is surfaced once in + * the column header so each cell stays a clean, Excel-parseable datetime. + */ + +import { formatInTimeZone } from "date-fns-tz"; + +/** Excel-friendly local datetime, e.g. "2026-06-03 20:34:56". */ +export const EXPORT_DATETIME_FORMAT = "yyyy-MM-dd HH:mm:ss"; + +/** + * Narrow to a usable Date. `new Date(NaN)` is still `instanceof Date`, so an + * `instanceof` check alone would let an invalid date reach `formatInTimeZone` + * and throw a RangeError mid-export. + */ +export function isValidDate(value: unknown): value is Date { + return value instanceof Date && !Number.isNaN(value.getTime()); +} + +/** Render an instant as a wall-clock string in the given IANA timezone. */ +export function formatExportTimestamp(date: Date, timezone: string): string { + return formatInTimeZone(date, timezone, EXPORT_DATETIME_FORMAT); +} + +/** + * Convert a UTC instant into a Date whose UTC fields equal the wall-clock time + * in `timezone`. The XLSX writer derives the Excel serial from this Date's UTC + * epoch value (see ./xlsx excelSerial), so the cell displays the intended local + * time while remaining a real (sortable, computable) Excel date. + */ +export function toExcelZonedDate(date: Date, timezone: string): Date { + const parts = formatInTimeZone(date, timezone, "yyyy-MM-dd-HH-mm-ss").split("-").map(Number); + const [year, month, day, hour, minute, second] = parts; + return new Date(Date.UTC(year, month - 1, day, hour, minute, second)); +} diff --git a/src/lib/usage-logs/export/numeric.ts b/src/lib/usage-logs/export/numeric.ts new file mode 100644 index 000000000..6047e9769 --- /dev/null +++ b/src/lib/usage-logs/export/numeric.ts @@ -0,0 +1,51 @@ +/** + * Numeric normalization for spreadsheet exports. + * + * Excel only keeps 15 significant digits. A `numeric(21, 15)` cost such as + * `1.234567890123456` (16 significant digits) is therefore imported as *text*, + * which breaks SUM() and other math. Values < 1 (e.g. `0.000123...`) have fewer + * significant digits and slip under the ceiling, which is why only some rows + * misbehaved. Normalizing every numeric value to <=15 significant digits, plain + * decimal notation, with trailing zeros trimmed keeps Excel treating them as + * numbers. + */ + +const SPREADSHEET_NUMBER_FORMATTER = new Intl.NumberFormat("en-US", { + maximumSignificantDigits: 15, + useGrouping: false, +}); + +/** + * Coerce a DB numeric string (or number) into a finite number, or null when the + * input is empty, nullish, or not a finite number. + */ +export function toFiniteNumber(value: string | number | null | undefined): number | null { + if (value === null || value === undefined) { + return null; + } + if (typeof value === "number") { + return Number.isFinite(value) ? value : null; + } + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + if (trimmed === "") { + return null; + } + const parsed = Number(trimmed); + return Number.isFinite(parsed) ? parsed : null; +} + +/** + * Render a decimal value as an Excel-safe numeric literal: at most 15 + * significant digits, plain decimal notation (never scientific), trailing zeros + * stripped. Non-finite / empty / nullish inputs collapse to "0". + */ +export function normalizeDecimalForSpreadsheet(value: string | number | null | undefined): string { + const parsed = toFiniteNumber(value); + if (parsed === null) { + return "0"; + } + return SPREADSHEET_NUMBER_FORMATTER.format(parsed); +} diff --git a/src/lib/usage-logs/export/summary.ts b/src/lib/usage-logs/export/summary.ts new file mode 100644 index 000000000..66ca5ba97 --- /dev/null +++ b/src/lib/usage-logs/export/summary.ts @@ -0,0 +1,163 @@ +/** + * Aggregated summary for the XLSX export's second worksheet. + * + * - Multi-day exports are summarized per calendar day. + * - Single-day exports are summarized per hour. + * + * Calendar boundaries are evaluated in the resolved system timezone so the + * buckets line up with what the user sees in the dashboard. + */ + +import { formatInTimeZone } from "date-fns-tz"; +import type { UsageLogRow } from "@/repository/usage-logs"; +import { isValidDate } from "./format"; +import { toFiniteNumber } from "./numeric"; + +export type SummaryGranularity = "daily" | "hourly"; + +export interface SummaryRow { + period: string; + requests: number; + inputTokens: number; + outputTokens: number; + cacheWrite5m: number; + cacheWrite1h: number; + cacheRead: number; + totalTokens: number; + cost: number; +} + +export interface UsageLogsSummary { + granularity: SummaryGranularity; + rows: SummaryRow[]; + total: SummaryRow; +} + +export const SUMMARY_HEADERS = [ + "Period", + "Requests", + "Input Tokens", + "Output Tokens", + "Cache Write 5m", + "Cache Write 1h", + "Cache Read", + "Total Tokens", + "Cost (USD)", +] as const; + +const UNKNOWN_PERIOD = "Unknown"; + +function emptyRow(period: string): SummaryRow { + return { + period, + requests: 0, + inputTokens: 0, + outputTokens: 0, + cacheWrite5m: 0, + cacheWrite1h: 0, + cacheRead: 0, + totalTokens: 0, + cost: 0, + }; +} + +function accumulate(row: SummaryRow, log: UsageLogRow): void { + row.requests += 1; + row.inputTokens += log.inputTokens ?? 0; + row.outputTokens += log.outputTokens ?? 0; + row.cacheWrite5m += log.cacheCreation5mInputTokens ?? 0; + row.cacheWrite1h += log.cacheCreation1hInputTokens ?? 0; + row.cacheRead += log.cacheReadInputTokens ?? 0; + row.totalTokens += log.totalTokens ?? 0; + row.cost += toFiniteNumber(log.costUsd) ?? 0; +} + +function merge(target: SummaryRow, source: SummaryRow): void { + target.requests += source.requests; + target.inputTokens += source.inputTokens; + target.outputTokens += source.outputTokens; + target.cacheWrite5m += source.cacheWrite5m; + target.cacheWrite1h += source.cacheWrite1h; + target.cacheRead += source.cacheRead; + target.totalTokens += source.totalTokens; + target.cost += source.cost; +} + +function byPeriod(a: SummaryRow, b: SummaryRow): number { + return a.period < b.period ? -1 : a.period > b.period ? 1 : 0; +} + +/** + * Incremental summary builder. Logs are folded in one at a time (each timestamp + * formatted exactly once) so callers can stream batches without retaining the + * rows. Buckets are kept at hour granularity; `finalize` chooses per-hour vs + * per-day output from the distinct-day count and rolls hours up when needed. + * Period labels are zero-padded ISO, so the later lexicographic sort is also + * chronological. + */ +export interface SummaryAccumulator { + add(log: UsageLogRow): void; + finalize(): UsageLogsSummary; +} + +export function createSummaryAccumulator(timezone: string): SummaryAccumulator { + const hourBuckets = new Map(); + const days = new Set(); + const total = emptyRow("Total"); + let unknown: SummaryRow | null = null; + + return { + add(log) { + accumulate(total, log); + if (!isValidDate(log.createdAt)) { + unknown ??= emptyRow(UNKNOWN_PERIOD); + accumulate(unknown, log); + return; + } + const hourKey = `${formatInTimeZone(log.createdAt, timezone, "yyyy-MM-dd HH")}:00`; + days.add(hourKey.slice(0, 10)); + let row = hourBuckets.get(hourKey); + if (!row) { + row = emptyRow(hourKey); + hourBuckets.set(hourKey, row); + } + accumulate(row, log); + }, + finalize() { + const granularity: SummaryGranularity = days.size <= 1 ? "hourly" : "daily"; + let rows: SummaryRow[]; + if (granularity === "hourly") { + rows = [...hourBuckets.values()]; + } else { + const dayBuckets = new Map(); + for (const hour of hourBuckets.values()) { + const dayKey = hour.period.slice(0, 10); + let day = dayBuckets.get(dayKey); + if (!day) { + day = emptyRow(dayKey); + dayBuckets.set(dayKey, day); + } + merge(day, hour); + } + rows = [...dayBuckets.values()]; + } + if (unknown) { + rows.push(unknown); + } + rows.sort(byPeriod); + return { granularity, rows, total }; + }, + }; +} + +/** + * Build the per-day or per-hour summary for the given logs (convenience wrapper + * around {@link createSummaryAccumulator} for callers that already hold all rows). + */ +export function buildUsageLogsSummary(logs: UsageLogRow[], timezone: string): UsageLogsSummary { + const accumulator = createSummaryAccumulator(timezone); + for (const log of logs) { + accumulator.add(log); + } + return accumulator.finalize(); +} diff --git a/src/lib/usage-logs/export/xlsx.ts b/src/lib/usage-logs/export/xlsx.ts new file mode 100644 index 000000000..398ecc2c8 --- /dev/null +++ b/src/lib/usage-logs/export/xlsx.ts @@ -0,0 +1,282 @@ +/** + * Minimal, dependency-light XLSX writer for usage-logs exports. + * + * Why hand-rolled: we only need to emit two simple worksheets with correctly + * typed numeric / date cells. A purpose-built writer (on top of the already + * present `fflate` zip codec) keeps the cells genuinely numeric so Excel SUM() + * works, renders timestamps as real Excel dates in the system timezone, and + * avoids pulling in a heavy spreadsheet dependency tree. + * + * Workbook layout: + * Sheet 1 "Usage Logs" - one row per request (mirrors the CSV) + * Sheet 2 "Daily/Hourly Summary" - aggregates (see ./summary) + */ + +import { strToU8, zip } from "fflate"; +import type { UsageLogRow } from "@/repository/usage-logs"; +import { + buildDetailHeaders, + COST_NUM_FMT, + DETAIL_COLUMNS, + type DetailColumn, + isBlankValue, +} from "./columns"; +import { isValidDate, toExcelZonedDate } from "./format"; +import { normalizeDecimalForSpreadsheet } from "./numeric"; +import { + createSummaryAccumulator, + SUMMARY_HEADERS, + type SummaryRow, + type UsageLogsSummary, +} from "./summary"; + +// Cell style indices, matched 1:1 to the entries in STYLES_XML below. +const STYLE = { text: 0, header: 1, datetime: 2, integer: 3, cost: 4 } as const; + +// Spreadsheet column letters are invariant per column index, so precompute them +// once instead of recomputing inside every row. +const DETAIL_COLUMN_REFS = DETAIL_COLUMNS.map((_column, index) => columnRef(index)); +const SUMMARY_COLUMN_REFS = SUMMARY_HEADERS.map((_header, index) => columnRef(index)); + +// Days between the Unix epoch (1970-01-01) and the Excel epoch (1899-12-30). +const EXCEL_EPOCH_OFFSET_DAYS = 25569; +const MS_PER_DAY = 86_400_000; +const SECONDS_PER_DAY = 86_400; + +function escapeXml(value: string): string { + return value.replace(/[&<>"']/g, (char) => { + switch (char) { + case "&": + return "&"; + case "<": + return "<"; + case ">": + return ">"; + case '"': + return """; + default: + return "'"; + } + }); +} + +// Keep only characters allowed by the XML 1.0 Char production, so a stray byte +// in a model/endpoint string (control bytes, unpaired surrogates, the U+FFFE / +// U+FFFF non-characters) cannot corrupt the whole workbook. +function stripIllegalXmlChars(value: string): string { + let out = ""; + for (const char of value) { + const code = char.codePointAt(0) ?? 0; + if ( + code === 0x09 || + code === 0x0a || + code === 0x0d || + (code >= 0x20 && code <= 0xd7ff) || + (code >= 0xe000 && code <= 0xfffd) || + (code >= 0x10000 && code <= 0x10ffff) + ) { + out += char; + } + } + return out; +} + +function sanitizeXmlText(value: string): string { + return escapeXml(stripIllegalXmlChars(value)); +} + +/** Zero-based column index -> spreadsheet column letters (0 -> A, 26 -> AA). */ +export function columnRef(index: number): string { + let remaining = index + 1; + let ref = ""; + while (remaining > 0) { + const mod = (remaining - 1) % 26; + ref = String.fromCharCode(65 + mod) + ref; + remaining = Math.floor((remaining - 1) / 26); + } + return ref; +} + +function excelSerial(date: Date): number { + const serial = date.getTime() / MS_PER_DAY + EXCEL_EPOCH_OFFSET_DAYS; + // Timestamps are whole seconds; snap to the second grid so binary-float + // artifacts in the division cannot make Excel display the wrong second. + return Math.round(serial * SECONDS_PER_DAY) / SECONDS_PER_DAY; +} + +function textCell(ref: string, value: string, style: number): string { + if (value === "") { + return ``; + } + return `${sanitizeXmlText(value)}`; +} + +function numberCell(ref: string, value: string | null, style: number): string { + if (value === null) { + return ``; + } + return `${value}`; +} + +function dateCell(ref: string, date: Date): string { + return `${excelSerial(date)}`; +} + +// Only reached for number columns; cost gets the decimal format, the rest are integers. +function detailNumberStyle(column: DetailColumn): number { + return column.numFmt === COST_NUM_FMT ? STYLE.cost : STYLE.integer; +} + +function detailCell(column: DetailColumn, log: UsageLogRow, ref: string, timezone: string): string { + const raw = column.get(log); + if (column.kind === "datetime") { + return isValidDate(raw) ? dateCell(ref, toExcelZonedDate(raw, timezone)) : ``; + } + if (column.kind === "number") { + if (isBlankValue(raw) && !column.zeroWhenNull) { + return numberCell(ref, null, STYLE.integer); + } + return numberCell( + ref, + normalizeDecimalForSpreadsheet(raw as string | number | null), + detailNumberStyle(column) + ); + } + return textCell(ref, typeof raw === "string" ? raw : String(raw ?? ""), STYLE.text); +} + +function rowXml(rowNumber: number, cells: string[]): string { + return `${cells.join("")}`; +} + +function headerRowXml(headers: string[], rowNumber: number, refs: string[]): string { + const cells = headers.map((header, index) => + textCell(`${refs[index]}${rowNumber}`, header, STYLE.header) + ); + return rowXml(rowNumber, cells); +} + +function worksheetXml(rows: string[], columnCount: number): string { + const lastCol = columnRef(columnCount - 1); + const lastRow = Math.max(rows.length, 1); + return ` +${rows.join("")}`; +} + +/** + * Render a single detail row's XML. `rowNumber` is 1-based (the header occupies + * row 1). Exposed so callers can stream batches into the sheet without retaining + * the whole result set in memory. + */ +export function buildDetailRowXml(log: UsageLogRow, rowNumber: number, timezone: string): string { + const cells = DETAIL_COLUMNS.map((column, columnIndex) => + detailCell(column, log, `${DETAIL_COLUMN_REFS[columnIndex]}${rowNumber}`, timezone) + ); + return rowXml(rowNumber, cells); +} + +function detailSheetXml(detailRowsXml: string[], timezone: string): string { + const rows = [ + headerRowXml(buildDetailHeaders(timezone), 1, DETAIL_COLUMN_REFS), + ...detailRowsXml, + ]; + return worksheetXml(rows, DETAIL_COLUMNS.length); +} + +function summaryRowCells(row: SummaryRow, rowNumber: number, periodStyle: number): string[] { + const integers = [ + row.requests, + row.inputTokens, + row.outputTokens, + row.cacheWrite5m, + row.cacheWrite1h, + row.cacheRead, + row.totalTokens, + ]; + const cells = [textCell(`${SUMMARY_COLUMN_REFS[0]}${rowNumber}`, row.period, periodStyle)]; + integers.forEach((value, index) => { + cells.push( + numberCell(`${SUMMARY_COLUMN_REFS[index + 1]}${rowNumber}`, String(value), STYLE.integer) + ); + }); + cells.push( + numberCell( + `${SUMMARY_COLUMN_REFS[8]}${rowNumber}`, + normalizeDecimalForSpreadsheet(row.cost), + STYLE.cost + ) + ); + return cells; +} + +function buildSummarySheet(summary: UsageLogsSummary): string { + const rows = [headerRowXml([...SUMMARY_HEADERS], 1, SUMMARY_COLUMN_REFS)]; + summary.rows.forEach((row, index) => { + rows.push(rowXml(index + 2, summaryRowCells(row, index + 2, STYLE.text))); + }); + const totalRowNumber = summary.rows.length + 2; + rows.push(rowXml(totalRowNumber, summaryRowCells(summary.total, totalRowNumber, STYLE.header))); + return worksheetXml(rows, SUMMARY_HEADERS.length); +} + +function summarySheetName(summary: UsageLogsSummary): string { + return summary.granularity === "daily" ? "Daily Summary" : "Hourly Summary"; +} + +const STYLES_XML = ` +`; + +const CONTENT_TYPES_XML = ` +`; + +const ROOT_RELS_XML = ` +`; + +const WORKBOOK_RELS_XML = ` +`; + +function workbookXml(summaryName: string): string { + return ` +`; +} + +export interface XlsxParts { + /** Pre-rendered detail row XML (one entry per data row, row numbers from 2). */ + detailRowsXml: string[]; + summary: UsageLogsSummary; + timezone: string; +} + +/** + * Assemble an XLSX workbook (detail sheet + daily/hourly summary sheet) from + * pre-rendered detail rows and an aggregated summary. Compression runs via + * fflate's async zip so a large export does not block the event loop. + */ +export function assembleUsageLogsXlsx(parts: XlsxParts): Promise { + const files: Record = { + "[Content_Types].xml": strToU8(CONTENT_TYPES_XML), + "_rels/.rels": strToU8(ROOT_RELS_XML), + "xl/workbook.xml": strToU8(workbookXml(summarySheetName(parts.summary))), + "xl/_rels/workbook.xml.rels": strToU8(WORKBOOK_RELS_XML), + "xl/styles.xml": strToU8(STYLES_XML), + "xl/worksheets/sheet1.xml": strToU8(detailSheetXml(parts.detailRowsXml, parts.timezone)), + "xl/worksheets/sheet2.xml": strToU8(buildSummarySheet(parts.summary)), + }; + return new Promise((resolve, reject) => { + zip(files, { level: 6 }, (error, data) => (error ? reject(error) : resolve(data))); + }); +} + +/** + * Build an XLSX workbook for the given logs (convenience wrapper that holds all + * rows in memory; the streaming export path uses buildDetailRowXml + + * createSummaryAccumulator + assembleUsageLogsXlsx instead). + */ +export function buildUsageLogsXlsx(logs: UsageLogRow[], timezone: string): Promise { + const accumulator = createSummaryAccumulator(timezone); + const detailRowsXml = logs.map((log, index) => { + accumulator.add(log); + return buildDetailRowXml(log, index + 2, timezone); + }); + return assembleUsageLogsXlsx({ detailRowsXml, summary: accumulator.finalize(), timezone }); +} diff --git a/tests/api/v1/usage-logs/usage-logs.test.ts b/tests/api/v1/usage-logs/usage-logs.test.ts index 65ca4fa30..6245aa868 100644 --- a/tests/api/v1/usage-logs/usage-logs.test.ts +++ b/tests/api/v1/usage-logs/usage-logs.test.ts @@ -85,7 +85,15 @@ describe("v1 usage log endpoints", () => { progressPercent: 100, }, }); - downloadUsageLogsExportMock.mockResolvedValue({ ok: true, data: "Time,Model\nnow,claude" }); + downloadUsageLogsExportMock.mockResolvedValue({ + ok: true, + data: { + content: "Time,Model\nnow,claude", + encoding: "utf8", + format: "csv", + filename: "usage-logs-job-1.csv", + }, + }); }); test("lists usage logs with offset and cursor filters", async () => { @@ -247,7 +255,7 @@ describe("v1 usage log endpoints", () => { }); expect(sync.response.status).toBe(200); expect(sync.json).toEqual({ csv: "Time,Model\nnow,claude" }); - expect(exportUsageLogsMock).toHaveBeenCalledWith({ model: "claude" }); + expect(exportUsageLogsMock).toHaveBeenCalledWith({ model: "claude", format: "csv" }); const asyncJob = await callV1Route({ method: "POST", @@ -257,7 +265,7 @@ describe("v1 usage log endpoints", () => { }); expect(asyncJob.response.status).toBe(202); expect(asyncJob.response.headers.get("Location")).toBe("/api/v1/usage-logs/exports/job-1"); - expect(startUsageLogsExportMock).toHaveBeenCalledWith({ model: "claude" }); + expect(startUsageLogsExportMock).toHaveBeenCalledWith({ model: "claude", format: "csv" }); const status = await callV1Route({ method: "GET", @@ -277,6 +285,46 @@ describe("v1 usage log endpoints", () => { expect(download.text).toContain("Time,Model"); }); + test("xlsx export requires async and downloads as a spreadsheet", async () => { + const syncXlsx = await callV1Route({ + method: "POST", + pathname: "/api/v1/usage-logs/exports", + headers, + body: { model: "claude", format: "xlsx" }, + }); + expect(syncXlsx.response.status).toBe(400); + expect(startUsageLogsExportMock).not.toHaveBeenCalledWith( + expect.objectContaining({ format: "xlsx" }) + ); + + const asyncXlsx = await callV1Route({ + method: "POST", + pathname: "/api/v1/usage-logs/exports", + headers: { ...headers, Prefer: "respond-async" }, + body: { model: "claude", format: "xlsx" }, + }); + expect(asyncXlsx.response.status).toBe(202); + expect(startUsageLogsExportMock).toHaveBeenCalledWith({ model: "claude", format: "xlsx" }); + + downloadUsageLogsExportMock.mockResolvedValueOnce({ + ok: true, + data: { + content: Buffer.from("PK-xlsx-bytes").toString("base64"), + encoding: "base64", + format: "xlsx", + filename: "usage-logs-job-1.xlsx", + }, + }); + const download = await callV1Route({ + method: "GET", + pathname: "/api/v1/usage-logs/exports/job-1/download", + headers, + }); + expect(download.response.status).toBe(200); + expect(download.response.headers.get("content-type")).toContain("spreadsheetml.sheet"); + expect(download.response.headers.get("content-disposition")).toContain(".xlsx"); + }); + test("returns problem+json for action failures and documents paths", async () => { getUsageLogsStatsMock.mockResolvedValueOnce({ ok: false, diff --git a/tests/unit/actions/usage-logs-export-retry-count.test.ts b/tests/unit/actions/usage-logs-export-retry-count.test.ts index 69dd3f06c..0c97c2587 100644 --- a/tests/unit/actions/usage-logs-export-retry-count.test.ts +++ b/tests/unit/actions/usage-logs-export-retry-count.test.ts @@ -13,6 +13,10 @@ vi.mock("@/lib/auth", () => { }; }); +vi.mock("@/lib/utils/timezone", () => ({ + resolveSystemTimezone: vi.fn(async () => "UTC"), +})); + vi.mock("@/lib/redis/redis-kv-store", () => ({ RedisKVStore: class MockRedisKVStore { private readonly prefix: string; @@ -361,7 +365,11 @@ describe("Usage logs CSV export retryCount", () => { const downloadResult = await downloadUsageLogsExport(jobId); expect(downloadResult.ok).toBe(true); - expect(downloadResult.data).toContain("Session ID"); - expect(downloadResult.data).toContain("job-session"); + if (!downloadResult.ok) throw new Error("expected ok download"); + expect(downloadResult.data.format).toBe("csv"); + expect(downloadResult.data.encoding).toBe("utf8"); + expect(downloadResult.data.filename).toMatch(/\.csv$/); + expect(downloadResult.data.content).toContain("Session ID"); + expect(downloadResult.data.content).toContain("job-session"); }); }); diff --git a/tests/unit/actions/usage-logs-export-xlsx.test.ts b/tests/unit/actions/usage-logs-export-xlsx.test.ts new file mode 100644 index 000000000..9c9e89f17 --- /dev/null +++ b/tests/unit/actions/usage-logs-export-xlsx.test.ts @@ -0,0 +1,181 @@ +import { strFromU8, unzipSync } from "fflate"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +const getSessionMock = vi.fn(); +const findUsageLogsWithDetailsMock = vi.fn(); +const findUsageLogsBatchMock = vi.fn(); +const findUsageLogsStatsMock = vi.fn(); +const exportStatusStore = new Map(); +const exportResultStore = new Map(); + +vi.mock("@/lib/auth", () => ({ getSession: getSessionMock })); + +vi.mock("@/lib/utils/timezone", () => ({ + resolveSystemTimezone: vi.fn(async () => "Asia/Shanghai"), +})); + +vi.mock("@/lib/redis/redis-kv-store", () => ({ + RedisKVStore: class MockRedisKVStore { + private readonly prefix: string; + constructor(options: { prefix: string }) { + this.prefix = options.prefix; + } + async set(key: string, value: T) { + if (this.prefix.includes(":status:")) { + exportStatusStore.set(key, value); + } else { + exportResultStore.set(key, value as string); + } + return true; + } + async get(key: string) { + if (this.prefix.includes(":status:")) { + return (exportStatusStore.get(key) as T | undefined) ?? null; + } + return ((exportResultStore.get(key) as T | undefined) ?? null) as T | null; + } + async delete(key: string) { + if (this.prefix.includes(":status:")) { + return exportStatusStore.delete(key); + } + return exportResultStore.delete(key); + } + }, +})); + +vi.mock("@/repository/usage-logs", () => ({ + findUsageLogSessionIdSuggestions: vi.fn(async () => []), + findUsageLogsBatch: findUsageLogsBatchMock, + findUsageLogsStats: findUsageLogsStatsMock, + findUsageLogsWithDetails: findUsageLogsWithDetailsMock, + getUsedEndpoints: vi.fn(async () => []), + getUsedModels: vi.fn(async () => []), + getUsedStatusCodes: vi.fn(async () => []), +})); + +function summary(totalRequests = 0) { + return { + totalRequests, + totalCost: 0, + totalTokens: 0, + totalInputTokens: 0, + totalOutputTokens: 0, + totalCacheCreationTokens: 0, + totalCacheReadTokens: 0, + totalCacheCreation5mTokens: 0, + totalCacheCreation1hTokens: 0, + }; +} + +function log(overrides: Record = {}) { + return { + createdAt: new Date("2026-03-16T01:00:00.000Z"), + userName: "u", + keyName: "k", + providerName: "p", + model: "m", + originalModel: "om", + endpoint: "/v1/messages", + statusCode: 200, + inputTokens: 1, + outputTokens: 2, + cacheCreation5mInputTokens: 0, + cacheCreation1hInputTokens: 0, + cacheReadInputTokens: 0, + totalTokens: 3, + costUsd: "1.500000000000000", + durationMs: 10, + sessionId: "s1", + providerChain: null, + ...overrides, + }; +} + +describe("Usage logs XLSX export", () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + vi.useRealTimers(); + exportStatusStore.clear(); + exportResultStore.clear(); + getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); + findUsageLogsWithDetailsMock.mockResolvedValue({ logs: [], total: 1, summary: summary(1) }); + findUsageLogsBatchMock.mockResolvedValue({ logs: [], nextCursor: null, hasMore: false }); + findUsageLogsStatsMock.mockResolvedValue(summary()); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test("async xlsx job completes and downloads a base64 workbook with two sheets", async () => { + vi.useFakeTimers(); + findUsageLogsBatchMock.mockResolvedValueOnce({ + logs: [log({ sessionId: "job-session" })], + nextCursor: null, + hasMore: false, + }); + + const { downloadUsageLogsExport, getUsageLogsExportStatus, startUsageLogsExport } = + await import("@/actions/usage-logs"); + + const startResult = await startUsageLogsExport({ format: "xlsx" }); + expect(startResult.ok).toBe(true); + if (!startResult.ok) throw new Error("start failed"); + const jobId = startResult.data.jobId; + + const queued = await getUsageLogsExportStatus(jobId); + expect(queued.ok).toBe(true); + if (!queued.ok) throw new Error("status failed"); + expect(queued.data.format).toBe("xlsx"); + + await vi.runAllTimersAsync(); + + const completed = await getUsageLogsExportStatus(jobId); + expect(completed.ok && completed.data.status).toBe("completed"); + + const download = await downloadUsageLogsExport(jobId); + expect(download.ok).toBe(true); + if (!download.ok) throw new Error("download failed"); + expect(download.data.format).toBe("xlsx"); + expect(download.data.encoding).toBe("base64"); + expect(download.data.filename).toMatch(/\.xlsx$/); + + const bytes = Buffer.from(download.data.content, "base64"); + // PK zip signature + expect(bytes[0]).toBe(0x50); + expect(bytes[1]).toBe(0x4b); + + const files = unzipSync(new Uint8Array(bytes)); + expect(Object.keys(files)).toEqual( + expect.arrayContaining(["xl/worksheets/sheet1.xml", "xl/worksheets/sheet2.xml"]) + ); + const sheet1 = strFromU8(files["xl/worksheets/sheet1.xml"]); + // 01:00 UTC -> 09:00 Asia/Shanghai, header carries the timezone + expect(sheet1).toContain("Time (Asia/Shanghai)"); + // cost rendered as a numeric cell, normalized + expect(sheet1).toContain("1.5"); + }); + + test("sync export rejects xlsx (async job only)", async () => { + const { exportUsageLogs } = await import("@/actions/usage-logs"); + const result = await exportUsageLogs({ format: "xlsx" }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected rejection"); + expect(result.error).toMatch(/XLSX/); + }); + + test("sync csv export returns CSV text", async () => { + findUsageLogsBatchMock.mockResolvedValueOnce({ + logs: [log()], + nextCursor: null, + hasMore: false, + }); + const { exportUsageLogs } = await import("@/actions/usage-logs"); + const result = await exportUsageLogs({ format: "csv" }); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("export failed"); + expect(result.data).toContain("Session ID"); + expect(result.data.startsWith("")).toBe(true); + }); +}); diff --git a/tests/unit/api/v1/api-client-actions.test.ts b/tests/unit/api/v1/api-client-actions.test.ts index 64ef6184d..74c285d9f 100644 --- a/tests/unit/api/v1/api-client-actions.test.ts +++ b/tests/unit/api/v1/api-client-actions.test.ts @@ -1,5 +1,5 @@ import { Buffer } from "node:buffer"; -import { beforeEach, describe, expect, test, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { DASHBOARD_COMPAT_HEADER } from "@/lib/api/v1/_shared/constants"; import { ApiError } from "@/lib/api-client/v1/errors"; @@ -39,6 +39,11 @@ describe("v1 action compatibility client", () => { vi.clearAllMocks(); }); + // Always restore globals, even if a stubbed-fetch test throws mid-assertion. + afterEach(() => { + vi.unstubAllGlobals(); + }); + test("preserves provider edit undo metadata from response headers", async () => { patchMock.mockImplementation( async ( @@ -425,4 +430,39 @@ describe("v1 action compatibility client", () => { ); expect(result).toEqual({ ok: true, data: suggestions }); }); + + test("downloadUsageLogsExport returns the response body as a Blob", async () => { + const fetchMock = vi.fn( + async () => + new Response(new Blob(["PKxlsx-bytes"]), { + status: 200, + headers: { + "Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "Content-Disposition": 'attachment; filename="usage-logs-job-9.xlsx"', + }, + }) + ); + vi.stubGlobal("fetch", fetchMock); + + const result = await usageLogs.downloadUsageLogsExport("job-9"); + + expect(fetchMock).toHaveBeenCalledWith("/api/v1/usage-logs/exports/job-9/download", { + credentials: "include", + }); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("expected ok"); + expect(result.data.blob).toBeInstanceOf(Blob); + expect(await result.data.blob.text()).toBe("PKxlsx-bytes"); + }); + + test("downloadUsageLogsExport surfaces a non-2xx download as an error result", async () => { + const fetchMock = vi.fn( + async () => new Response("nope", { status: 404, statusText: "Not Found" }) + ); + vi.stubGlobal("fetch", fetchMock); + + const result = await usageLogs.downloadUsageLogsExport("missing"); + + expect(result.ok).toBe(false); + }); }); diff --git a/tests/unit/dashboard-logs-export-progress-ui.test.tsx b/tests/unit/dashboard-logs-export-progress-ui.test.tsx index 016071b68..143440c5c 100644 --- a/tests/unit/dashboard-logs-export-progress-ui.test.tsx +++ b/tests/unit/dashboard-logs-export-progress-ui.test.tsx @@ -41,6 +41,39 @@ vi.mock("sonner", () => ({ }, })); +// Render the dropdown menu inline so its items are directly clickable in happy-dom. +vi.mock("@/components/ui/dropdown-menu", () => ({ + DropdownMenu: ({ children }: { children: ReactNode }) =>
{children}
, + DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <>{children}, + DropdownMenuContent: ({ children }: { children: ReactNode }) =>
{children}
, + DropdownMenuItem: ({ + children, + onSelect, + disabled, + }: { + children: ReactNode; + onSelect?: () => void; + disabled?: boolean; + }) => ( + + ), +})); + +function csvBlobResult() { + return { + ok: true as const, + data: { blob: new Blob(["Time,User\n"], { type: "text/csv" }), filename: "usage-logs.csv" }, + }; +} + +function findButtonByText(container: Element, text: string): Element | undefined { + return Array.from(container.querySelectorAll("button")).find( + (button) => (button.textContent || "").trim() === text + ); +} + vi.mock("@/app/[locale]/dashboard/logs/_components/filters/active-filters-display", () => ({ ActiveFiltersDisplay: () =>
, })); @@ -162,7 +195,7 @@ describe("UsageLogsFilters export progress UI", () => { progressPercent: 100, }, }); - downloadUsageLogsExportMock.mockResolvedValue({ ok: true, data: "\uFEFFTime,User\n" }); + downloadUsageLogsExportMock.mockResolvedValue(csvBlobResult()); const { container, unmount } = renderWithIntl( { /> ); - const exportButton = Array.from(container.querySelectorAll("button")).find( - (button) => (button.textContent || "").trim() === "Export" - ); - - await actClick(exportButton ?? null); + await actClick(findButtonByText(container, "Export as CSV") ?? null); await flushPromises(); expect(container.textContent).toContain("Exported 50 / 200"); @@ -190,6 +219,7 @@ describe("UsageLogsFilters export progress UI", () => { }); await flushPromises(); + expect(startUsageLogsExportMock).toHaveBeenCalledWith({ format: "csv" }); expect(downloadUsageLogsExportMock).toHaveBeenCalledWith("job-1"); expect(toastSuccessMock).toHaveBeenCalledWith("Export completed successfully"); expect(toastErrorMock).not.toHaveBeenCalled(); @@ -209,7 +239,7 @@ describe("UsageLogsFilters export progress UI", () => { progressPercent: 100, }, }); - downloadUsageLogsExportMock.mockResolvedValue({ ok: true, data: "\uFEFFTime,User\n" }); + downloadUsageLogsExportMock.mockResolvedValue(csvBlobResult()); const { container, unmount } = renderWithIntl( { await actClick(container.querySelector("[data-testid='request-filters']")); - const exportButton = Array.from(container.querySelectorAll("button")).find( - (button) => (button.textContent || "").trim() === "Export" + await actClick(findButtonByText(container, "Export as CSV") ?? null); + await flushPromises(); + + expect(startUsageLogsExportMock).toHaveBeenCalledWith({ + sessionId: "draft-session", + format: "csv", + }); + + unmount(); + }); + + test("exports as XLSX when the XLSX option is selected", async () => { + startUsageLogsExportMock.mockResolvedValue({ ok: true, data: { jobId: "job-3" } }); + getUsageLogsExportStatusMock.mockResolvedValueOnce({ + ok: true, + data: { + jobId: "job-3", + status: "completed", + processedRows: 1, + totalRows: 1, + progressPercent: 100, + format: "xlsx", + }, + }); + downloadUsageLogsExportMock.mockResolvedValue({ + ok: true, + data: { blob: new Blob(["PK"], { type: "application/octet-stream" }), filename: "u.xlsx" }, + }); + + const { container, unmount } = renderWithIntl( + {}} + onReset={() => {}} + /> ); - await actClick(exportButton ?? null); + await actClick(findButtonByText(container, "Export as XLSX (with summary)") ?? null); await flushPromises(); - expect(startUsageLogsExportMock).toHaveBeenCalledWith({ sessionId: "draft-session" }); + expect(startUsageLogsExportMock).toHaveBeenCalledWith({ format: "xlsx" }); unmount(); }); diff --git a/tests/unit/usage-logs/export-csv.test.ts b/tests/unit/usage-logs/export-csv.test.ts new file mode 100644 index 000000000..6c5dccfb3 --- /dev/null +++ b/tests/unit/usage-logs/export-csv.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, test } from "vitest"; +import type { UsageLogRow } from "@/repository/usage-logs"; +import { buildCsvHeaderLine, buildCsvRows, escapeCsvField } from "@/lib/usage-logs/export/csv"; +import { buildDetailHeaders } from "@/lib/usage-logs/export/columns"; + +function makeLog(overrides: Partial = {}): UsageLogRow { + return { + id: 1, + createdAt: new Date("2026-06-03T12:34:56.000Z"), + sessionId: "s1", + requestSequence: 1, + userName: "alice", + keyName: "key-1", + providerName: "anthropic", + model: "claude", + originalModel: "claude-orig", + actualResponseModel: null, + endpoint: "/v1/messages", + statusCode: 200, + inputTokens: 10, + outputTokens: 20, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 5, + cacheCreation5mInputTokens: 1, + cacheCreation1hInputTokens: 2, + cacheTtlApplied: null, + totalTokens: 38, + costUsd: "1.500000000000000", + costMultiplier: null, + groupCostMultiplier: null, + costBreakdown: null, + durationMs: 123, + ttfbMs: null, + errorMessage: null, + providerChain: null, + blockedBy: null, + blockedReason: null, + userAgent: null, + clientIp: null, + messagesCount: null, + context1mApplied: null, + swapCacheTtlApplied: null, + specialSettings: null, + ...overrides, + }; +} + +const HEADER = buildDetailHeaders("UTC"); +const TIME_IDX = 0; +const STATUS_IDX = HEADER.indexOf("Status Code"); +const COST_IDX = HEADER.indexOf("Cost (USD)"); +const DURATION_IDX = HEADER.indexOf("Duration (ms)"); + +describe("buildCsvHeaderLine", () => { + test("annotates the time column with the timezone", () => { + expect(buildCsvHeaderLine("Asia/Shanghai").split(",")[TIME_IDX]).toBe("Time (Asia/Shanghai)"); + expect(buildCsvHeaderLine("UTC").split(",")[TIME_IDX]).toBe("Time (UTC)"); + }); +}); + +describe("buildCsvRows", () => { + test("renders the timestamp in the requested timezone (no UTC Z suffix)", () => { + const [row] = buildCsvRows([makeLog()], "Asia/Shanghai"); + const cells = row.split(","); + // 12:34:56 UTC -> 20:34:56 in Asia/Shanghai (+08:00) + expect(cells[TIME_IDX]).toBe("2026-06-03 20:34:56"); + expect(cells[TIME_IDX]).not.toContain("Z"); + }); + + test("normalizes the cost so Excel reads it as a number (trailing zeros gone)", () => { + const [row] = buildCsvRows([makeLog({ costUsd: "1.500000000000000" })], "UTC"); + expect(row.split(",")[COST_IDX]).toBe("1.5"); + }); + + test("caps 16-significant-digit costs to Excel's 15-digit ceiling", () => { + const [row] = buildCsvRows([makeLog({ costUsd: "1.234567890123456" })], "UTC"); + expect(row.split(",")[COST_IDX]).toBe("1.23456789012346"); + }); + + test("blank status code / duration stay blank; null cost becomes 0", () => { + const [row] = buildCsvRows( + [makeLog({ statusCode: null, durationMs: null, costUsd: null })], + "UTC" + ); + const cells = row.split(","); + expect(cells[STATUS_IDX]).toBe(""); + expect(cells[DURATION_IDX]).toBe(""); + expect(cells[COST_IDX]).toBe("0"); + }); + + test("null timestamp renders as an empty cell", () => { + const [row] = buildCsvRows([makeLog({ createdAt: null })], "UTC"); + expect(row.split(",")[TIME_IDX]).toBe(""); + }); + + test("invalid Date timestamp renders empty (no RangeError crash)", () => { + const [row] = buildCsvRows([makeLog({ createdAt: new Date(Number.NaN) })], "UTC"); + expect(row.split(",")[TIME_IDX]).toBe(""); + }); + + test("retry count is derived from the provider chain", () => { + const retryIdx = HEADER.indexOf("Retry Count"); + const [row] = buildCsvRows( + [ + makeLog({ + providerChain: [ + { reason: "initial_selection" }, + { reason: "retry_failed", attemptNumber: 1 }, + { reason: "retry_success", statusCode: 200, attemptNumber: 1 }, + ] as UsageLogRow["providerChain"], + }), + ], + "UTC" + ); + expect(row.split(",")[retryIdx]).toBe("1"); + }); +}); + +describe("escapeCsvField", () => { + test("neutralizes formula injection regardless of leading whitespace", () => { + expect(escapeCsvField("=1+1")).toBe("'=1+1"); + // a tab does not trigger CSV quoting, so only the leading-quote guard applies + expect(escapeCsvField(" \t@SUM(A1:A2)")).toBe("' \t@SUM(A1:A2)"); + expect(escapeCsvField("+2+2")).toBe("'+2+2"); + }); + + test("quotes fields containing commas or quotes", () => { + expect(escapeCsvField("a,b")).toBe('"a,b"'); + expect(escapeCsvField('a"b')).toBe('"a""b"'); + expect(escapeCsvField("plain")).toBe("plain"); + }); +}); diff --git a/tests/unit/usage-logs/export-numeric.test.ts b/tests/unit/usage-logs/export-numeric.test.ts new file mode 100644 index 000000000..82725218a --- /dev/null +++ b/tests/unit/usage-logs/export-numeric.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from "vitest"; +import { normalizeDecimalForSpreadsheet, toFiniteNumber } from "@/lib/usage-logs/export/numeric"; + +describe("toFiniteNumber", () => { + test("parses numeric strings", () => { + expect(toFiniteNumber("1.5")).toBe(1.5); + expect(toFiniteNumber("0")).toBe(0); + expect(toFiniteNumber(42)).toBe(42); + }); + + test("returns null for empty / nullish / non-numeric", () => { + expect(toFiniteNumber("")).toBeNull(); + expect(toFiniteNumber(" ")).toBeNull(); + expect(toFiniteNumber(null)).toBeNull(); + expect(toFiniteNumber(undefined)).toBeNull(); + expect(toFiniteNumber("abc")).toBeNull(); + expect(toFiniteNumber(Number.NaN)).toBeNull(); + expect(toFiniteNumber(Number.POSITIVE_INFINITY)).toBeNull(); + }); + + test("returns null for unexpected non-string/number types (no .trim() crash)", () => { + expect(toFiniteNumber(true as unknown as string)).toBeNull(); + expect(toFiniteNumber({} as unknown as string)).toBeNull(); + expect(toFiniteNumber([] as unknown as string)).toBeNull(); + }); +}); + +describe("normalizeDecimalForSpreadsheet", () => { + test("strips trailing zeros so Excel parses the value as a number", () => { + // numeric(21,15) always pads to 15 decimals -> Excel sees 16 significant + // digits and falls back to text. Trimming makes it a clean number again. + expect(normalizeDecimalForSpreadsheet("1.500000000000000")).toBe("1.5"); + expect(normalizeDecimalForSpreadsheet("0.001000000000000")).toBe("0.001"); + }); + + test("caps to 15 significant digits (Excel's precision ceiling)", () => { + expect(normalizeDecimalForSpreadsheet("1.234567890123456")).toBe("1.23456789012346"); + expect(normalizeDecimalForSpreadsheet("12.3456789012345678")).toBe("12.3456789012346"); + }); + + test("preserves small values whose leading digit is 0", () => { + expect(normalizeDecimalForSpreadsheet("0.000123456789012345")).toBe("0.000123456789012345"); + }); + + test("never emits scientific notation", () => { + expect(normalizeDecimalForSpreadsheet(1e-12)).toBe("0.000000000001"); + expect(normalizeDecimalForSpreadsheet("0.000000000123456")).toBe("0.000000000123456"); + expect(normalizeDecimalForSpreadsheet(1e-12)).not.toContain("e"); + }); + + test("nullish / empty / non-finite collapse to 0", () => { + expect(normalizeDecimalForSpreadsheet(null)).toBe("0"); + expect(normalizeDecimalForSpreadsheet(undefined)).toBe("0"); + expect(normalizeDecimalForSpreadsheet("")).toBe("0"); + expect(normalizeDecimalForSpreadsheet("not-a-number")).toBe("0"); + expect(normalizeDecimalForSpreadsheet("0")).toBe("0"); + expect(normalizeDecimalForSpreadsheet(0)).toBe("0"); + }); + + test("passes through plain integers and decimals unchanged", () => { + expect(normalizeDecimalForSpreadsheet("123456.789")).toBe("123456.789"); + expect(normalizeDecimalForSpreadsheet("42")).toBe("42"); + }); +}); diff --git a/tests/unit/usage-logs/export-summary.test.ts b/tests/unit/usage-logs/export-summary.test.ts new file mode 100644 index 000000000..b0ac2309a --- /dev/null +++ b/tests/unit/usage-logs/export-summary.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, test } from "vitest"; +import type { UsageLogRow } from "@/repository/usage-logs"; +import { buildUsageLogsSummary } from "@/lib/usage-logs/export/summary"; + +function makeLog(overrides: Partial = {}): UsageLogRow { + return { + id: 1, + createdAt: new Date("2026-06-03T12:00:00.000Z"), + sessionId: "s1", + requestSequence: 1, + userName: "alice", + keyName: "key-1", + providerName: "anthropic", + model: "claude", + originalModel: null, + actualResponseModel: null, + endpoint: "/v1/messages", + statusCode: 200, + inputTokens: 10, + outputTokens: 20, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 5, + cacheCreation5mInputTokens: 1, + cacheCreation1hInputTokens: 2, + cacheTtlApplied: null, + totalTokens: 38, + costUsd: "0.5", + costMultiplier: null, + groupCostMultiplier: null, + costBreakdown: null, + durationMs: 100, + ttfbMs: null, + errorMessage: null, + providerChain: null, + blockedBy: null, + blockedReason: null, + userAgent: null, + clientIp: null, + messagesCount: null, + context1mApplied: null, + swapCacheTtlApplied: null, + specialSettings: null, + ...overrides, + }; +} + +describe("buildUsageLogsSummary", () => { + test("single-day data is bucketed by hour (in the system timezone)", () => { + const logs = [ + // 12:00 UTC -> 20:00 Asia/Shanghai + makeLog({ createdAt: new Date("2026-06-03T12:00:00.000Z"), costUsd: "0.5" }), + makeLog({ createdAt: new Date("2026-06-03T12:30:00.000Z"), costUsd: "0.5" }), + // 13:10 UTC -> 21:10 Asia/Shanghai + makeLog({ createdAt: new Date("2026-06-03T13:10:00.000Z"), costUsd: "1" }), + ]; + const summary = buildUsageLogsSummary(logs, "Asia/Shanghai"); + + expect(summary.granularity).toBe("hourly"); + expect(summary.rows.map((r) => r.period)).toEqual(["2026-06-03 20:00", "2026-06-03 21:00"]); + expect(summary.rows[0].requests).toBe(2); + expect(summary.rows[0].cost).toBeCloseTo(1, 10); + expect(summary.rows[1].requests).toBe(1); + expect(summary.total.requests).toBe(3); + expect(summary.total.cost).toBeCloseTo(2, 10); + expect(summary.total.inputTokens).toBe(30); + expect(summary.total.totalTokens).toBe(114); + }); + + test("multi-day data is bucketed by day", () => { + const logs = [ + makeLog({ createdAt: new Date("2026-06-03T12:00:00.000Z") }), + makeLog({ createdAt: new Date("2026-06-04T12:00:00.000Z") }), + makeLog({ createdAt: new Date("2026-06-04T18:00:00.000Z") }), + ]; + const summary = buildUsageLogsSummary(logs, "UTC"); + + expect(summary.granularity).toBe("daily"); + expect(summary.rows.map((r) => r.period)).toEqual(["2026-06-03", "2026-06-04"]); + expect(summary.rows[1].requests).toBe(2); + expect(summary.total.requests).toBe(3); + }); + + test("day boundaries follow the timezone, not UTC", () => { + // 23:30 UTC on 06-03 is 07:30 on 06-04 in Asia/Shanghai -> two distinct days + const logs = [ + makeLog({ createdAt: new Date("2026-06-03T12:00:00.000Z") }), + makeLog({ createdAt: new Date("2026-06-03T23:30:00.000Z") }), + ]; + const summary = buildUsageLogsSummary(logs, "Asia/Shanghai"); + expect(summary.granularity).toBe("daily"); + expect(summary.rows.map((r) => r.period)).toEqual(["2026-06-03", "2026-06-04"]); + }); + + test("empty input yields a zeroed total and no rows", () => { + const summary = buildUsageLogsSummary([], "UTC"); + expect(summary.granularity).toBe("hourly"); + expect(summary.rows).toEqual([]); + expect(summary.total.requests).toBe(0); + expect(summary.total.cost).toBe(0); + }); + + test("invalid Date rows fall into the Unknown bucket without crashing", () => { + const summary = buildUsageLogsSummary( + [ + makeLog({ createdAt: new Date("2026-06-03T12:00:00.000Z") }), + makeLog({ createdAt: new Date(Number.NaN) }), + makeLog({ createdAt: null }), + ], + "UTC" + ); + expect(summary.total.requests).toBe(3); + expect(summary.rows.some((r) => r.period === "Unknown")).toBe(true); + const unknown = summary.rows.find((r) => r.period === "Unknown"); + expect(unknown?.requests).toBe(2); + }); +}); diff --git a/tests/unit/usage-logs/export-xlsx.test.ts b/tests/unit/usage-logs/export-xlsx.test.ts new file mode 100644 index 000000000..a2178a69e --- /dev/null +++ b/tests/unit/usage-logs/export-xlsx.test.ts @@ -0,0 +1,199 @@ +import { strFromU8, unzipSync } from "fflate"; +import { describe, expect, test } from "vitest"; +import type { UsageLogRow } from "@/repository/usage-logs"; +import { buildUsageLogsXlsx, columnRef } from "@/lib/usage-logs/export/xlsx"; + +function makeLog(overrides: Partial = {}): UsageLogRow { + return { + id: 1, + createdAt: new Date("2026-06-03T12:34:56.000Z"), + sessionId: "s1", + requestSequence: 1, + userName: "alice", + keyName: "key-1", + providerName: "anthropic", + model: "claude", + originalModel: "claude-orig", + actualResponseModel: null, + endpoint: "/v1/messages", + statusCode: 200, + inputTokens: 10, + outputTokens: 20, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 5, + cacheCreation5mInputTokens: 1, + cacheCreation1hInputTokens: 2, + cacheTtlApplied: null, + totalTokens: 38, + costUsd: "1.500000000000000", + costMultiplier: null, + groupCostMultiplier: null, + costBreakdown: null, + durationMs: 123, + ttfbMs: null, + errorMessage: null, + providerChain: null, + blockedBy: null, + blockedReason: null, + userAgent: null, + clientIp: null, + messagesCount: null, + context1mApplied: null, + swapCacheTtlApplied: null, + specialSettings: null, + ...overrides, + }; +} + +function unzip(bytes: Uint8Array): Record { + const files = unzipSync(bytes); + const out: Record = {}; + for (const [name, content] of Object.entries(files)) { + out[name] = strFromU8(content); + } + return out; +} + +/** Extract the inner XML of a cell by its A1 reference. */ +function cell(sheetXml: string, ref: string): string | null { + const match = sheetXml.match(new RegExp(`]*?(?:/>|>(.*?))`)); + if (!match) return null; + return match[0]; +} + +const COST_COL = columnRef(14); // O +const TIME_COL = columnRef(0); // A +const MODEL_COL = columnRef(4); // E +const STATUS_COL = columnRef(7); // H + +describe("buildUsageLogsXlsx", () => { + test("produces a valid two-sheet workbook package", async () => { + const files = unzip(await buildUsageLogsXlsx([makeLog()], "UTC")); + expect(Object.keys(files)).toEqual( + expect.arrayContaining([ + "[Content_Types].xml", + "_rels/.rels", + "xl/workbook.xml", + "xl/_rels/workbook.xml.rels", + "xl/styles.xml", + "xl/worksheets/sheet1.xml", + "xl/worksheets/sheet2.xml", + ]) + ); + expect(files["xl/workbook.xml"]).toContain('name="Usage Logs"'); + }); + + test("cost is a numeric cell (not text) and normalized for Excel", async () => { + const files = unzip( + await buildUsageLogsXlsx([makeLog({ costUsd: "1.500000000000000" })], "UTC") + ); + const costCell = cell(files["xl/worksheets/sheet1.xml"], `${COST_COL}2`) ?? ""; + expect(costCell).toContain("1.5"); + expect(costCell).not.toContain("inlineStr"); + }); + + test("16-significant-digit cost is capped to 15 digits", async () => { + const files = unzip( + await buildUsageLogsXlsx([makeLog({ costUsd: "1.234567890123456" })], "UTC") + ); + const costCell = cell(files["xl/worksheets/sheet1.xml"], `${COST_COL}2`) ?? ""; + expect(costCell).toContain("1.23456789012346"); + }); + + test("model name is a text (inlineStr) cell, not interpreted as a formula", async () => { + const files = unzip(await buildUsageLogsXlsx([makeLog({ model: "=1+1" })], "UTC")); + const modelCell = cell(files["xl/worksheets/sheet1.xml"], `${MODEL_COL}2`) ?? ""; + expect(modelCell).toContain("inlineStr"); + expect(modelCell).toContain("=1+1"); + }); + + test("status code is an integer numeric cell", async () => { + const files = unzip(await buildUsageLogsXlsx([makeLog({ statusCode: 200 })], "UTC")); + const statusCell = cell(files["xl/worksheets/sheet1.xml"], `${STATUS_COL}2`) ?? ""; + expect(statusCell).toContain("200"); + expect(statusCell).not.toContain("inlineStr"); + }); + + test("timestamp is a real Excel date serial reflecting the system timezone", async () => { + const files = unzip(await buildUsageLogsXlsx([makeLog()], "Asia/Shanghai")); + const sheet1 = files["xl/worksheets/sheet1.xml"]; + // header carries the timezone + expect(sheet1).toContain("Time (Asia/Shanghai)"); + + const timeCell = cell(sheet1, `${TIME_COL}2`) ?? ""; + const serial = Number(timeCell.match(/([^<]+)<\/v>/)?.[1]); + expect(Number.isFinite(serial)).toBe(true); + + // serial -> wall clock; 12:34:56 UTC is 20:34:56 in Asia/Shanghai (+08:00) + const ms = Math.round(((serial - 25569) * 86_400_000) / 1000) * 1000; + const wall = new Date(ms); + expect(wall.getUTCFullYear()).toBe(2026); + expect(wall.getUTCMonth()).toBe(5); // June + expect(wall.getUTCDate()).toBe(3); + expect(wall.getUTCHours()).toBe(20); + expect(wall.getUTCMinutes()).toBe(34); + expect(wall.getUTCSeconds()).toBe(56); + }); + + test("single-day data yields an hourly summary sheet", async () => { + const files = unzip( + await buildUsageLogsXlsx( + [ + makeLog({ createdAt: new Date("2026-06-03T12:00:00.000Z"), costUsd: "0.5" }), + makeLog({ createdAt: new Date("2026-06-03T12:30:00.000Z"), costUsd: "0.5" }), + ], + "UTC" + ) + ); + expect(files["xl/workbook.xml"]).toContain('name="Hourly Summary"'); + const summary = files["xl/worksheets/sheet2.xml"]; + expect(summary).toContain("Period"); + expect(summary).toContain("2026-06-03 12:00"); + expect(summary).toContain("Total"); + // total cost cell at column I (index 8), last data row + total row + }); + + test("multi-day data yields a daily summary sheet", async () => { + const files = unzip( + await buildUsageLogsXlsx( + [ + makeLog({ createdAt: new Date("2026-06-03T12:00:00.000Z") }), + makeLog({ createdAt: new Date("2026-06-04T12:00:00.000Z") }), + ], + "UTC" + ) + ); + expect(files["xl/workbook.xml"]).toContain('name="Daily Summary"'); + const summary = files["xl/worksheets/sheet2.xml"]; + expect(summary).toContain("2026-06-03"); + expect(summary).toContain("2026-06-04"); + }); + + test("does not crash on empty input", async () => { + const files = unzip(await buildUsageLogsXlsx([], "UTC")); + expect(files["xl/worksheets/sheet1.xml"]).toContain("Time (UTC)"); + expect(files["xl/worksheets/sheet2.xml"]).toContain("Total"); + }); + + test("invalid Date timestamp yields an empty cell (no crash)", async () => { + const files = unzip( + await buildUsageLogsXlsx([makeLog({ createdAt: new Date(Number.NaN) })], "UTC") + ); + const timeCell = cell(files["xl/worksheets/sheet1.xml"], `${TIME_COL}2`) ?? ""; + expect(timeCell).toBe(``); + }); + + test("strips illegal XML characters from text cells", async () => { + const files = unzip(await buildUsageLogsXlsx([makeLog({ model: "gpt\uFFFE\uFFFF-x" })], "UTC")); + const modelCell = cell(files["xl/worksheets/sheet1.xml"], `${MODEL_COL}2`) ?? ""; + expect(modelCell).toContain("gpt-x"); + expect(modelCell).not.toContain("\uFFFE"); + expect(modelCell).not.toContain("\uFFFF"); + }); + + test("styles.xml declares the two OOXML-reserved fills", async () => { + const files = unzip(await buildUsageLogsXlsx([makeLog()], "UTC")); + expect(files["xl/styles.xml"]).toContain(''); + expect(files["xl/styles.xml"]).toContain('patternType="gray125"'); + }); +}); From 3ade94c7261aa2a88ac35d4d7dd3df675ca3137f Mon Sep 17 00:00:00 2001 From: ding113 Date: Thu, 4 Jun 2026 14:37:40 +0800 Subject: [PATCH 10/13] fix(proxy): harden decompression with pre-auth compressed size limit Make the decompression output cap configurable via MAX_DECOMPRESSED_REQUEST_BYTES env var (default 100MB), and introduce a new MAX_COMPRESSED_REQUEST_BYTES env var (default 10MB) that rejects oversized compressed requests before decompression. This closes a DoS amplification vector in the /v1 and /v1beta proxy paths, which are not governed by proxyClientMaxBodySize. The compressed size check runs pre-auth, limiting CPU/memory usage from unauthenticated clients and bounding the decompression amplification ratio. Refs #1243 --- .env.example | 9 +++ src/app/v1/_lib/proxy/request-body-codec.ts | 43 +++++++++++- tests/unit/proxy/request-body-codec.test.ts | 73 ++++++++++++++++++++- 3 files changed, 123 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 522c007f5..8f778c015 100644 --- a/.env.example +++ b/.env.example @@ -149,6 +149,15 @@ FETCH_HEADERS_TIMEOUT=600000 FETCH_BODY_TIMEOUT=600000 MAX_RETRY_ATTEMPTS_DEFAULT=2 # 单供应商最大尝试次数(含首次调用),范围 1-10,留空使用默认值 2 +# 入站压缩请求体(content-encoding: zstd/gzip/deflate/br)解压上限(字节) +# 功能说明:/v1、/v1beta 代理路径不受 proxyClientMaxBodySize 钳制,这两项是入站解压的内存/CPU 兜底。 +# - MAX_COMPRESSED_REQUEST_BYTES:压缩输入(线上字节)上限,解压前即校验,超过按 413 拒绝。默认 10MB。 +# 作用:限制鉴权前需读取+解压的输入量,并收敛最大放大比。真实压缩请求体通常仅数 MB,一般无需调整。 +# - MAX_DECOMPRESSED_REQUEST_BYTES:解压输出上限,防御解压炸弹,超过按 413 拒绝。默认 100MB。 +# 作用:内存受限部署可下调;代理刻意支持大请求体,默认值已较宽松,留空使用默认值。 +# MAX_COMPRESSED_REQUEST_BYTES=10485760 +# MAX_DECOMPRESSED_REQUEST_BYTES=104857600 + # Langfuse Observability (optional, auto-enabled when keys are set) # 功能说明:企业级 LLM 可观测性集成,自动追踪所有代理请求的完整生命周期 # - 配置 PUBLIC_KEY 和 SECRET_KEY 后自动启用 diff --git a/src/app/v1/_lib/proxy/request-body-codec.ts b/src/app/v1/_lib/proxy/request-body-codec.ts index 86cb1f532..edfc41973 100644 --- a/src/app/v1/_lib/proxy/request-body-codec.ts +++ b/src/app/v1/_lib/proxy/request-body-codec.ts @@ -24,13 +24,42 @@ import { import { logger } from "@/lib/logger"; import { ProxyError } from "./errors"; +/** 解析「字节数」环境变量;非法/缺省时回退到 fallback。 */ +function parseByteLimitEnv(name: string, fallback: number): number { + const raw = process.env[name]; + if (!raw) return fallback; + const n = Number(raw); + return Number.isFinite(n) && n > 0 ? Math.trunc(n) : fallback; +} + /** * 解压输出硬上限,防御解压炸弹(decompression bomb):很小的压缩体可能展开成数 GB * 导致 OOM。这是一个独立的内存兜底阈值——注意 /v1、/v1beta 代理路径并不受 * next.config.ts 的 proxyClientMaxBodySize 钳制(见 proxy.matcher.ts),故入站压缩体 * 体积本身不另设限。逐层解压按此上限增量限制,超过即按 413 拒绝。 + * + * 默认 100MB(代理刻意支持大请求体,见 next.config.ts proxyClientMaxBodySize)。 + * 可经环境变量 MAX_DECOMPRESSED_REQUEST_BYTES 覆盖(字节数),供内存受限部署下调上限。 */ -export const MAX_DECOMPRESSED_REQUEST_BYTES = 100 * 1024 * 1024; +export const MAX_DECOMPRESSED_REQUEST_BYTES = parseByteLimitEnv( + "MAX_DECOMPRESSED_REQUEST_BYTES", + 100 * 1024 * 1024 +); + +/** + * 压缩输入(线上字节)硬上限。解压在 `ProxySession.fromContext` 内、鉴权 guard 之前同步执行, + * 且 /v1、/v1beta 路径不受 proxyClientMaxBodySize 钳制(见上);若仅靠 maxOutputBytes 输出兜底, + * 未鉴权客户端仍可反复发送「小压缩体 → 解到 100MB」来放大解压 CPU/事件循环开销。 + * 因此在解压前先按压缩体本身的字节数拒绝过大输入:既限制鉴权前需读取+解压的输入量, + * 也把最大放大比收敛(10MB 压缩体最多解到 100MB 输出)。超过按 413 拒绝。 + * + * 默认 10MB(远高于任何真实压缩请求体:2M token 上下文 zstd 压缩通常仅数 MB)。 + * 可经环境变量 MAX_COMPRESSED_REQUEST_BYTES 覆盖(字节数)。 + */ +export const MAX_COMPRESSED_REQUEST_BYTES = parseByteLimitEnv( + "MAX_COMPRESSED_REQUEST_BYTES", + 10 * 1024 * 1024 +); /** * content-encoding 编码链最大层数。真实客户端(含 Codex)只发单层编码;允许多层会让一个 @@ -45,6 +74,8 @@ const SUPPORTED_ENCODINGS = new Set(["zstd", "gzip", "x-gzip", "deflate", "br"]) export interface DecodeRequestBodyOptions { /** 解压输出字节上限,默认 {@link MAX_DECOMPRESSED_REQUEST_BYTES}。主要用于测试。 */ maxOutputBytes?: number; + /** 压缩输入字节上限,默认 {@link MAX_COMPRESSED_REQUEST_BYTES}。主要用于测试。 */ + maxCompressedBytes?: number; } export interface DecodedRequestBody { @@ -134,6 +165,7 @@ export function decodeRequestBody( options?: DecodeRequestBodyOptions ): DecodedRequestBody { const maxOutputBytes = options?.maxOutputBytes ?? MAX_DECOMPRESSED_REQUEST_BYTES; + const maxCompressedBytes = options?.maxCompressedBytes ?? MAX_COMPRESSED_REQUEST_BYTES; const originalByteLength = input.byteLength; const encodings = parseContentEncoding(contentEncoding); @@ -185,6 +217,15 @@ export function decodeRequestBody( }; } + // 解压前先按压缩体本身字节数拒绝过大输入(鉴权前防放大,见 MAX_COMPRESSED_REQUEST_BYTES)。 + // 仅对「支持的单层编码」生效:上面已确保 encodings 非空、层数合法且全部受支持。 + if (originalByteLength > maxCompressedBytes) { + throw new ProxyError( + `Compressed request body exceeds the maximum allowed size (${maxCompressedBytes} bytes).`, + 413 + ); + } + // 按 HTTP 语义反向逐层解码。 const decodeOrder = [...encodings].reverse(); let current: Buffer = Buffer.from(input instanceof Uint8Array ? input : new Uint8Array(input)); diff --git a/tests/unit/proxy/request-body-codec.test.ts b/tests/unit/proxy/request-body-codec.test.ts index 68dac6df2..17b1e753c 100644 --- a/tests/unit/proxy/request-body-codec.test.ts +++ b/tests/unit/proxy/request-body-codec.test.ts @@ -5,10 +5,11 @@ import { gzipSync, zstdCompressSync, } from "node:zlib"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { ProxyError } from "@/app/v1/_lib/proxy/errors"; import { decodeRequestBody, + MAX_COMPRESSED_REQUEST_BYTES, MAX_CONTENT_ENCODING_LAYERS, MAX_DECOMPRESSED_REQUEST_BYTES, parseContentEncoding, @@ -167,4 +168,74 @@ describe("decodeRequestBody", () => { it("exposes a sane default decompression cap", () => { expect(MAX_DECOMPRESSED_REQUEST_BYTES).toBe(100 * 1024 * 1024); }); + + it("exposes a sane default compressed-input cap", () => { + expect(MAX_COMPRESSED_REQUEST_BYTES).toBe(10 * 1024 * 1024); + }); + + it("throws ProxyError(413) when the compressed input exceeds the cap, before decompressing", () => { + // A valid gzip body that decodes fine, but whose compressed size exceeds the input cap. + const body = gzipSync(raw()); + try { + decodeRequestBody(body, "gzip", { maxCompressedBytes: 1 }); + throw new Error("expected decodeRequestBody to throw"); + } catch (err) { + expect(err).toBeInstanceOf(ProxyError); + expect((err as ProxyError).statusCode).toBe(413); + expect((err as ProxyError).message).toContain("Compressed request body"); + } + }); + + it("does not apply the compressed cap to unsupported encodings (still passes through)", () => { + // Unsupported encodings are passed through untouched even if larger than the compressed cap. + const result = decodeRequestBody(raw(), "snappy", { maxCompressedBytes: 1 }); + expect(result.decoded).toBe(false); + expect(result.encoding).toBeNull(); + expect(decodedText(result)).toBe(SAMPLE); + }); + + it("does not apply the compressed cap to an empty body", () => { + const result = decodeRequestBody(new Uint8Array(0), "zstd", { maxCompressedBytes: 1 }); + expect(result.decoded).toBe(false); + expect(result.decodedByteLength).toBe(0); + }); + + it("allows a supported body at or under the compressed cap", () => { + const body = gzipSync(raw()); + const result = decodeRequestBody(body, "gzip", { maxCompressedBytes: body.byteLength }); + expect(result.decoded).toBe(true); + expect(decodedText(result)).toBe(SAMPLE); + }); +}); + +describe("decodeRequestBody env-configurable limits", () => { + const ORIGINAL_ENV = { ...process.env }; + + afterEach(() => { + process.env = { ...ORIGINAL_ENV }; + vi.resetModules(); + }); + + it("honors MAX_DECOMPRESSED_REQUEST_BYTES override", async () => { + process.env.MAX_DECOMPRESSED_REQUEST_BYTES = String(4 * 1024 * 1024); + vi.resetModules(); + const mod = await import("@/app/v1/_lib/proxy/request-body-codec"); + expect(mod.MAX_DECOMPRESSED_REQUEST_BYTES).toBe(4 * 1024 * 1024); + }); + + it("honors MAX_COMPRESSED_REQUEST_BYTES override", async () => { + process.env.MAX_COMPRESSED_REQUEST_BYTES = String(2 * 1024 * 1024); + vi.resetModules(); + const mod = await import("@/app/v1/_lib/proxy/request-body-codec"); + expect(mod.MAX_COMPRESSED_REQUEST_BYTES).toBe(2 * 1024 * 1024); + }); + + it("falls back to defaults for invalid/non-positive env values", async () => { + process.env.MAX_DECOMPRESSED_REQUEST_BYTES = "not-a-number"; + process.env.MAX_COMPRESSED_REQUEST_BYTES = "-5"; + vi.resetModules(); + const mod = await import("@/app/v1/_lib/proxy/request-body-codec"); + expect(mod.MAX_DECOMPRESSED_REQUEST_BYTES).toBe(100 * 1024 * 1024); + expect(mod.MAX_COMPRESSED_REQUEST_BYTES).toBe(10 * 1024 * 1024); + }); }); From d4b8cee203b18a31109834358e258234843ea722 Mon Sep 17 00:00:00 2001 From: ding113 Date: Thu, 4 Jun 2026 14:38:21 +0800 Subject: [PATCH 11/13] feat(dashboard): add tooltip to effort badge and missing xhigh style Add a tooltip around the Anthropic effort badge in the request details dialog, explaining that the badge shows the verbatim output_config.effort value sent by the client; the proxy does not rename or convert levels. Also add the missing xhigh badge style in anthropic-effort-badge so xhigh is visually distinct between high and max instead of falling back to the gray default. --- messages/en/dashboard.json | 3 ++- messages/ja/dashboard.json | 3 ++- messages/ru/dashboard.json | 3 ++- messages/zh-CN/dashboard.json | 3 ++- messages/zh-TW/dashboard.json | 3 ++- .../components/SummaryTab.tsx | 19 +++++++++++++++---- .../customs/anthropic-effort-badge.tsx | 3 +++ 7 files changed, 28 insertions(+), 9 deletions(-) diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json index 1bf141484..6c5c5cbac 100644 --- a/messages/en/dashboard.json +++ b/messages/en/dashboard.json @@ -361,7 +361,8 @@ }, "effort": { "label": "Effort", - "overridden": "Overridden by provider" + "overridden": "Overridden by provider", + "tooltip": "Thinking effort requested by the client (output_config.effort), shown verbatim. The proxy does not rename or convert levels." }, "logicTrace": { "title": "Decision Chain", diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json index 437d49e7b..c74b91c9a 100644 --- a/messages/ja/dashboard.json +++ b/messages/ja/dashboard.json @@ -361,7 +361,8 @@ }, "effort": { "label": "Effort", - "overridden": "プロバイダーにより上書き" + "overridden": "プロバイダーにより上書き", + "tooltip": "クライアントがリクエストボディで指定した思考強度 (output_config.effort)。プロキシは値をそのまま表示し、レベル名を変換しません。" }, "logicTrace": { "title": "決定チェーン", diff --git a/messages/ru/dashboard.json b/messages/ru/dashboard.json index 933a0f209..95b204f5c 100644 --- a/messages/ru/dashboard.json +++ b/messages/ru/dashboard.json @@ -361,7 +361,8 @@ }, "effort": { "label": "Effort", - "overridden": "Переопределено провайдером" + "overridden": "Переопределено провайдером", + "tooltip": "Уровень усилий на размышления, запрошенный клиентом (output_config.effort); показан как есть — прокси не переименовывает и не преобразует уровни." }, "logicTrace": { "title": "Цепочка решений", diff --git a/messages/zh-CN/dashboard.json b/messages/zh-CN/dashboard.json index c8e3b8b11..14c6e0a76 100644 --- a/messages/zh-CN/dashboard.json +++ b/messages/zh-CN/dashboard.json @@ -361,7 +361,8 @@ }, "effort": { "label": "Effort", - "overridden": "已被供应商覆写" + "overridden": "已被供应商覆写", + "tooltip": "客户端在请求体中声明的思考强度(output_config.effort),按原值显示,代理不会重命名或转换等级。" }, "logicTrace": { "title": "决策链", diff --git a/messages/zh-TW/dashboard.json b/messages/zh-TW/dashboard.json index c74a45547..6b6f96f4c 100644 --- a/messages/zh-TW/dashboard.json +++ b/messages/zh-TW/dashboard.json @@ -361,7 +361,8 @@ }, "effort": { "label": "Effort", - "overridden": "已被供應商覆寫" + "overridden": "已被供應商覆寫", + "tooltip": "用戶端在請求體中宣告的思考強度(output_config.effort),按原值顯示,代理不會重新命名或轉換等級。" }, "logicTrace": { "title": "決策鏈", diff --git a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/SummaryTab.tsx b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/SummaryTab.tsx index dd2f826db..44e21f887 100644 --- a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/SummaryTab.tsx +++ b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/SummaryTab.tsx @@ -313,10 +313,21 @@ export function SummaryTab({
{t("effort.label")}: - + + + + + + + + +

{t("effort.tooltip")}

+
+
+
{effortInfo.isOverridden && effortInfo.overriddenEffort && ( <> diff --git a/src/components/customs/anthropic-effort-badge.tsx b/src/components/customs/anthropic-effort-badge.tsx index a93ea4c61..91577d22e 100644 --- a/src/components/customs/anthropic-effort-badge.tsx +++ b/src/components/customs/anthropic-effort-badge.tsx @@ -7,6 +7,9 @@ const ANTHROPIC_EFFORT_BADGE_STYLES: Record = { medium: "border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-300", high: "border-rose-200 bg-rose-50 text-rose-700 dark:border-rose-800 dark:bg-rose-950/30 dark:text-rose-300", + // xhigh 介于 high 与 max 之间,需有独立样式,否则会落入灰色 DEFAULT 看起来比 high 还弱。 + xhigh: + "border-red-200 bg-red-50 text-red-700 dark:border-red-800 dark:bg-red-950/30 dark:text-red-300", max: "border-red-300 bg-red-100 text-red-800 dark:border-red-700 dark:bg-red-950/40 dark:text-red-200", }; From 492c508b8296403e99370c93ef6dc9c07a8c5e48 Mon Sep 17 00:00:00 2001 From: ding113 Date: Thu, 4 Jun 2026 14:39:14 +0800 Subject: [PATCH 12/13] feat(usage-logs): refresh stats panel on manual refresh --- .../usage-logs-stats-panel.test.tsx | 74 +++++++++++++++++++ .../_components/usage-logs-stats-panel.tsx | 19 +++-- .../usage-logs-view-virtualized.tsx | 10 ++- 3 files changed, 97 insertions(+), 6 deletions(-) create mode 100644 src/app/[locale]/dashboard/logs/_components/usage-logs-stats-panel.test.tsx diff --git a/src/app/[locale]/dashboard/logs/_components/usage-logs-stats-panel.test.tsx b/src/app/[locale]/dashboard/logs/_components/usage-logs-stats-panel.test.tsx new file mode 100644 index 000000000..70d17a6fc --- /dev/null +++ b/src/app/[locale]/dashboard/logs/_components/usage-logs-stats-panel.test.tsx @@ -0,0 +1,74 @@ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// Stable translation function so loadStats identity stays stable across rerenders +// (mirrors real next-intl, where useTranslations returns a memoized fn). This ensures +// the only refetch triggers are filtersKey and refreshKey, matching production. +const i18nMock = vi.hoisted(() => ({ t: (key: string) => key })); + +const getUsageLogsStatsMock = vi.hoisted(() => vi.fn()); + +vi.mock("next-intl", () => ({ + useTranslations: () => i18nMock.t, +})); + +vi.mock("@/lib/api-client/v1/actions/usage-logs", () => ({ + getUsageLogsStats: getUsageLogsStatsMock, +})); + +import { UsageLogsStatsPanel } from "./usage-logs-stats-panel"; + +// Same object reference across rerenders so only refreshKey drives the refetch. +const FILTERS = { userId: 1 }; + +async function renderPanel(root: Root, refreshKey: number) { + await act(async () => { + root.render(); + }); +} + +describe("UsageLogsStatsPanel manual refresh", () => { + let container: HTMLElement; + let root: Root; + + beforeEach(() => { + getUsageLogsStatsMock.mockReset(); + // Error branch avoids rendering the full stats shape while still exercising the fetch path. + getUsageLogsStatsMock.mockResolvedValue({ ok: false, error: "stub" }); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(async () => { + await act(async () => { + root.unmount(); + }); + container.remove(); + }); + + it("fetches stats once on mount", async () => { + await renderPanel(root, 0); + expect(getUsageLogsStatsMock).toHaveBeenCalledTimes(1); + }); + + it("refetches when refreshKey is bumped (manual refresh)", async () => { + await renderPanel(root, 0); + expect(getUsageLogsStatsMock).toHaveBeenCalledTimes(1); + + await renderPanel(root, 1); + expect(getUsageLogsStatsMock).toHaveBeenCalledTimes(2); + + await renderPanel(root, 2); + expect(getUsageLogsStatsMock).toHaveBeenCalledTimes(3); + }); + + it("does not refetch when refreshKey is unchanged on rerender", async () => { + await renderPanel(root, 5); + expect(getUsageLogsStatsMock).toHaveBeenCalledTimes(1); + + await renderPanel(root, 5); + expect(getUsageLogsStatsMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/[locale]/dashboard/logs/_components/usage-logs-stats-panel.tsx b/src/app/[locale]/dashboard/logs/_components/usage-logs-stats-panel.tsx index d03b7a3a1..df9a39782 100644 --- a/src/app/[locale]/dashboard/logs/_components/usage-logs-stats-panel.tsx +++ b/src/app/[locale]/dashboard/logs/_components/usage-logs-stats-panel.tsx @@ -25,14 +25,23 @@ interface UsageLogsStatsPanelProps { minRetryCount?: number; }; currencyCode?: CurrencyCode; + /** + * 手动刷新计数器:每次递增都会触发一次统计汇总重新拉取(与列表的手动刷新联动)。 + * 仅在值变化时生效,不影响按 filters 变化的常规重拉。 + */ + refreshKey?: number; } /** * Stats panel component with glass morphism UI * Always expanded (not collapsible), loads data asynchronously - * Re-fetches when filters change + * Re-fetches when filters change or when refreshKey is bumped (manual refresh) */ -export function UsageLogsStatsPanel({ filters, currencyCode = "USD" }: UsageLogsStatsPanelProps) { +export function UsageLogsStatsPanel({ + filters, + currencyCode = "USD", + refreshKey = 0, +}: UsageLogsStatsPanelProps) { const t = useTranslations("dashboard"); const [stats, setStats] = useState(null); const [isLoading, setIsLoading] = useState(true); @@ -61,11 +70,11 @@ export function UsageLogsStatsPanel({ filters, currencyCode = "USD" }: UsageLogs } }, [filters, t]); - // Load data on mount and when filters change - // biome-ignore lint/correctness/useExhaustiveDependencies: filtersKey is used to detect filter changes + // Load data on mount, when filters change, and on manual refresh (refreshKey bump) + // biome-ignore lint/correctness/useExhaustiveDependencies: filtersKey/refreshKey are the refetch triggers useEffect(() => { loadStats(); - }, [filtersKey, loadStats]); + }, [filtersKey, refreshKey, loadStats]); return (
| null>(null); const fullscreen = useFullscreen(); @@ -182,6 +184,8 @@ function UsageLogsViewContent({ const handleManualRefresh = useCallback(async () => { setIsManualRefreshing(true); + // 同时刷新底部调用历史(react-query)与顶部统计汇总(refreshKey 触发)。 + setStatsRefreshKey((k) => k + 1); await queryClientInstance.invalidateQueries({ queryKey: ["usage-logs-batch"] }); if (refreshTimeoutRef.current) { clearTimeout(refreshTimeoutRef.current); @@ -272,7 +276,11 @@ function UsageLogsViewContent({
{/* Stats Summary */} {hasStatsFilters && ( - + )} {/* Toolbar + Filter */} From 58346ce633eea8729c1e1afd22608b481cae5240 Mon Sep 17 00:00:00 2001 From: ding113 Date: Thu, 4 Jun 2026 14:56:26 +0800 Subject: [PATCH 13/13] fix(proxy): raise compressed body limit to match decompressed ceiling The hardcoded 10MB default was rejecting large compressed requests (e.g. 60MB prose, image-heavy payloads) that the plaintext path accepts up to 100MB. Real compression never grows the body, so the limit now tracks the decompressed ceiling, eliminating the asymmetry. The .env.example and code comments document the rationale and the ability to override via MAX_COMPRESSED_REQUEST_BYTES. --- .env.example | 9 +++++---- src/app/v1/_lib/proxy/request-body-codec.ts | 14 +++++++------- tests/unit/proxy/request-body-codec.test.ts | 11 ++++++++--- 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/.env.example b/.env.example index 8f778c015..102f5c5fa 100644 --- a/.env.example +++ b/.env.example @@ -151,12 +151,13 @@ MAX_RETRY_ATTEMPTS_DEFAULT=2 # 单供应商最大尝试次数( # 入站压缩请求体(content-encoding: zstd/gzip/deflate/br)解压上限(字节) # 功能说明:/v1、/v1beta 代理路径不受 proxyClientMaxBodySize 钳制,这两项是入站解压的内存/CPU 兜底。 -# - MAX_COMPRESSED_REQUEST_BYTES:压缩输入(线上字节)上限,解压前即校验,超过按 413 拒绝。默认 10MB。 -# 作用:限制鉴权前需读取+解压的输入量,并收敛最大放大比。真实压缩请求体通常仅数 MB,一般无需调整。 # - MAX_DECOMPRESSED_REQUEST_BYTES:解压输出上限,防御解压炸弹,超过按 413 拒绝。默认 100MB。 -# 作用:内存受限部署可下调;代理刻意支持大请求体,默认值已较宽松,留空使用默认值。 -# MAX_COMPRESSED_REQUEST_BYTES=10485760 +# 作用:内存受限部署可下调来收紧鉴权前的解压开销;代理刻意支持大请求体,默认值已较宽松。 +# - MAX_COMPRESSED_REQUEST_BYTES:压缩输入(线上字节)上限,解压前即校验,超过按 413 拒绝。 +# 默认与 MAX_DECOMPRESSED_REQUEST_BYTES 一致(真实压缩比下合法请求的压缩体一定不超过其解压体, +# 故默认不会误拒大上下文/图片压缩请求);下调解压上限时本上限随之收紧,也可单独覆盖。 # MAX_DECOMPRESSED_REQUEST_BYTES=104857600 +# MAX_COMPRESSED_REQUEST_BYTES=104857600 # Langfuse Observability (optional, auto-enabled when keys are set) # 功能说明:企业级 LLM 可观测性集成,自动追踪所有代理请求的完整生命周期 diff --git a/src/app/v1/_lib/proxy/request-body-codec.ts b/src/app/v1/_lib/proxy/request-body-codec.ts index edfc41973..e22093afd 100644 --- a/src/app/v1/_lib/proxy/request-body-codec.ts +++ b/src/app/v1/_lib/proxy/request-body-codec.ts @@ -48,17 +48,17 @@ export const MAX_DECOMPRESSED_REQUEST_BYTES = parseByteLimitEnv( /** * 压缩输入(线上字节)硬上限。解压在 `ProxySession.fromContext` 内、鉴权 guard 之前同步执行, - * 且 /v1、/v1beta 路径不受 proxyClientMaxBodySize 钳制(见上);若仅靠 maxOutputBytes 输出兜底, - * 未鉴权客户端仍可反复发送「小压缩体 → 解到 100MB」来放大解压 CPU/事件循环开销。 - * 因此在解压前先按压缩体本身的字节数拒绝过大输入:既限制鉴权前需读取+解压的输入量, - * 也把最大放大比收敛(10MB 压缩体最多解到 100MB 输出)。超过按 413 拒绝。 + * 且 /v1、/v1beta 路径不受 proxyClientMaxBodySize 钳制(见上)。该上限在解压前先按压缩体本身 + * 的字节数拒绝过大输入,作为鉴权前的结构性天花板(限制需读取+解压的输入量)。 * - * 默认 10MB(远高于任何真实压缩请求体:2M token 上下文 zstd 压缩通常仅数 MB)。 - * 可经环境变量 MAX_COMPRESSED_REQUEST_BYTES 覆盖(字节数)。 + * 默认与 {@link MAX_DECOMPRESSED_REQUEST_BYTES} 一致:真实压缩比下合法请求的压缩体一定不超过其 + * 解压体,故该默认不会误拒既有的大上下文/图片压缩请求(避免「明文 100MB 放行、压缩体却被拒」的 + * 不对称)。内存受限部署下调 MAX_DECOMPRESSED_REQUEST_BYTES 时本上限随之收紧;也可经 + * MAX_COMPRESSED_REQUEST_BYTES 单独覆盖。超过按 413 拒绝。 */ export const MAX_COMPRESSED_REQUEST_BYTES = parseByteLimitEnv( "MAX_COMPRESSED_REQUEST_BYTES", - 10 * 1024 * 1024 + MAX_DECOMPRESSED_REQUEST_BYTES ); /** diff --git a/tests/unit/proxy/request-body-codec.test.ts b/tests/unit/proxy/request-body-codec.test.ts index 17b1e753c..e25683d61 100644 --- a/tests/unit/proxy/request-body-codec.test.ts +++ b/tests/unit/proxy/request-body-codec.test.ts @@ -169,8 +169,12 @@ describe("decodeRequestBody", () => { expect(MAX_DECOMPRESSED_REQUEST_BYTES).toBe(100 * 1024 * 1024); }); - it("exposes a sane default compressed-input cap", () => { - expect(MAX_COMPRESSED_REQUEST_BYTES).toBe(10 * 1024 * 1024); + it("defaults the compressed-input cap to the decompressed ceiling (regression-free)", () => { + // Real compression never grows the body, so a legitimate compressed request is always + // <= its decompressed size; matching the output ceiling avoids rejecting large compressed + // requests that the plaintext/output path would accept. + expect(MAX_COMPRESSED_REQUEST_BYTES).toBe(MAX_DECOMPRESSED_REQUEST_BYTES); + expect(MAX_COMPRESSED_REQUEST_BYTES).toBe(100 * 1024 * 1024); }); it("throws ProxyError(413) when the compressed input exceeds the cap, before decompressing", () => { @@ -236,6 +240,7 @@ describe("decodeRequestBody env-configurable limits", () => { vi.resetModules(); const mod = await import("@/app/v1/_lib/proxy/request-body-codec"); expect(mod.MAX_DECOMPRESSED_REQUEST_BYTES).toBe(100 * 1024 * 1024); - expect(mod.MAX_COMPRESSED_REQUEST_BYTES).toBe(10 * 1024 * 1024); + // Falls back to the decompressed default (100MB) since the compressed default tracks it. + expect(mod.MAX_COMPRESSED_REQUEST_BYTES).toBe(100 * 1024 * 1024); }); });