From 3d584e5d36884afd8ea2e95862dd5e16ba1bca93 Mon Sep 17 00:00:00 2001 From: ding113 Date: Wed, 11 Feb 2026 13:52:58 +0800 Subject: [PATCH 1/7] fix(circuit-breaker): key errors should not trip endpoint circuit breaker Remove 3 recordEndpointFailure calls from response-handler streaming error paths (fake-200, non-200 HTTP, stream abort). These are key-level errors where the endpoint itself responded successfully. Only forwarder-level failures (timeout, network error) and probe failures should penalize the endpoint circuit breaker. Previously, a single bad API key could trip the endpoint breaker (threshold=3, open=5min), making ALL keys on that endpoint unavailable. --- src/app/v1/_lib/proxy/response-handler.ts | 49 +-- ...handler-endpoint-circuit-isolation.test.ts | 399 ++++++++++++++++++ 2 files changed, 409 insertions(+), 39 deletions(-) create mode 100644 tests/unit/proxy/response-handler-endpoint-circuit-isolation.test.ts diff --git a/src/app/v1/_lib/proxy/response-handler.ts b/src/app/v1/_lib/proxy/response-handler.ts index 7aa4b06f2..fa7f9bfcb 100644 --- a/src/app/v1/_lib/proxy/response-handler.ts +++ b/src/app/v1/_lib/proxy/response-handler.ts @@ -209,19 +209,10 @@ async function finalizeDeferredStreamingFinalizationIfNeeded( }); } - if (meta.endpointId != null) { - try { - const { recordEndpointFailure } = await import("@/lib/endpoint-circuit-breaker"); - await recordEndpointFailure(meta.endpointId, new Error(errorMessage ?? "STREAM_ABORTED")); - } catch (endpointError) { - logger.warn("[ResponseHandler] Failed to record endpoint failure (stream aborted)", { - endpointId: meta.endpointId, - providerId: meta.providerId, - sessionId: session.sessionId ?? null, - error: endpointError, - }); - } - } + // NOTE: Do NOT call recordEndpointFailure here. Stream aborts are key-level + // errors (auth, rate limit, bad key). The endpoint itself delivered HTTP 200 + // successfully. Only forwarder-level failures (timeout, network error) and + // probe failures should penalize the endpoint circuit breaker. } session.addProviderToChain(providerForChain, { @@ -259,19 +250,9 @@ async function finalizeDeferredStreamingFinalizationIfNeeded( }); } - // endpoint 级熔断:与成功路径保持对称,避免“假 200”只影响 provider 而不影响 endpoint 健康度 - if (meta.endpointId != null) { - try { - const { recordEndpointFailure } = await import("@/lib/endpoint-circuit-breaker"); - await recordEndpointFailure(meta.endpointId, new Error(detected.code)); - } catch (endpointError) { - logger.warn("[ResponseHandler] Failed to record endpoint failure (fake 200)", { - endpointId: meta.endpointId, - providerId: meta.providerId, - error: endpointError, - }); - } - } + // NOTE: Do NOT call recordEndpointFailure here. Fake-200 errors are key-level + // issues (invalid key, auth failure). The endpoint returned HTTP 200 successfully; + // the error is in the response content, not endpoint connectivity. // 记录到决策链(用于日志展示与 DB 持久化)。 // 注意:这里用 effectiveStatusCode(502)而不是 upstreamStatusCode(200), @@ -310,19 +291,9 @@ async function finalizeDeferredStreamingFinalizationIfNeeded( }); } - // endpoint 级熔断:与成功路径保持对称 - if (meta.endpointId != null) { - try { - const { recordEndpointFailure } = await import("@/lib/endpoint-circuit-breaker"); - await recordEndpointFailure(meta.endpointId, new Error(errorMessage)); - } catch (endpointError) { - logger.warn("[ResponseHandler] Failed to record endpoint failure (non-200)", { - endpointId: meta.endpointId, - providerId: meta.providerId, - error: endpointError, - }); - } - } + // NOTE: Do NOT call recordEndpointFailure here. Non-200 HTTP errors (401, 429, + // etc.) are typically key/auth-level errors. The endpoint was reachable and + // responded; only forwarder-level failures should penalize the endpoint breaker. // 记录到决策链 session.addProviderToChain(providerForChain, { diff --git a/tests/unit/proxy/response-handler-endpoint-circuit-isolation.test.ts b/tests/unit/proxy/response-handler-endpoint-circuit-isolation.test.ts new file mode 100644 index 000000000..e8cb3ffa5 --- /dev/null +++ b/tests/unit/proxy/response-handler-endpoint-circuit-isolation.test.ts @@ -0,0 +1,399 @@ +/** + * Tests for endpoint circuit breaker isolation in response-handler.ts + * + * Verifies that key-level errors (fake 200, non-200 HTTP, stream abort) do NOT + * call recordEndpointFailure. Only forwarder-level failures (timeout, network + * error) and probe failures should penalize the endpoint circuit breaker. + * + * Streaming success DOES call recordEndpointSuccess (regression guard). + */ + +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ModelPriceData } from "@/types/model-price"; + +// Track async tasks for draining +const asyncTasks: Promise[] = []; + +vi.mock("@/lib/async-task-manager", () => ({ + AsyncTaskManager: { + register: (_taskId: string, promise: Promise) => { + asyncTasks.push(promise); + return new AbortController(); + }, + cleanup: () => {}, + cancel: () => {}, + }, +})); + +vi.mock("@/lib/logger", () => ({ + logger: { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + trace: () => {}, + }, +})); + +vi.mock("@/lib/price-sync/cloud-price-updater", () => ({ + requestCloudPriceTableSync: () => {}, +})); + +vi.mock("@/repository/model-price", () => ({ + findLatestPriceByModel: vi.fn(), +})); + +vi.mock("@/repository/system-config", () => ({ + getSystemSettings: vi.fn(), +})); + +vi.mock("@/repository/message", () => ({ + updateMessageRequestCost: vi.fn(), + updateMessageRequestDetails: vi.fn(), + updateMessageRequestDuration: vi.fn(), +})); + +vi.mock("@/lib/session-manager", () => ({ + SessionManager: { + updateSessionUsage: vi.fn(), + storeSessionResponse: vi.fn(), + extractCodexPromptCacheKey: vi.fn(), + updateSessionWithCodexCacheKey: vi.fn(), + }, +})); + +vi.mock("@/lib/rate-limit", () => ({ + RateLimitService: { + trackCost: vi.fn(), + trackUserDailyCost: vi.fn(), + decrementLeaseBudget: vi.fn(), + }, +})); + +vi.mock("@/lib/session-tracker", () => ({ + SessionTracker: { + refreshSession: vi.fn(), + }, +})); + +vi.mock("@/lib/proxy-status-tracker", () => ({ + ProxyStatusTracker: { + getInstance: () => ({ + endRequest: () => {}, + }), + }, +})); + +// Mock circuit breakers with tracked spies (vi.hoisted to avoid TDZ with vi.mock hoisting) +const { mockRecordFailure, mockRecordEndpointFailure, mockRecordEndpointSuccess } = vi.hoisted( + () => ({ + mockRecordFailure: vi.fn(), + mockRecordEndpointFailure: vi.fn(), + mockRecordEndpointSuccess: vi.fn(), + }), +); + +vi.mock("@/lib/circuit-breaker", () => ({ + recordFailure: mockRecordFailure, +})); + +vi.mock("@/lib/endpoint-circuit-breaker", () => ({ + recordEndpointFailure: mockRecordEndpointFailure, + recordEndpointSuccess: mockRecordEndpointSuccess, + resetEndpointCircuit: vi.fn(), +})); + +import { ProxyResponseHandler } from "@/app/v1/_lib/proxy/response-handler"; +import { ProxySession } from "@/app/v1/_lib/proxy/session"; +import { setDeferredStreamingFinalization } from "@/app/v1/_lib/proxy/stream-finalization"; +import { getSystemSettings } from "@/repository/system-config"; +import { findLatestPriceByModel } from "@/repository/model-price"; +import { + updateMessageRequestDetails, + updateMessageRequestDuration, +} from "@/repository/message"; +import { SessionManager } from "@/lib/session-manager"; +import { RateLimitService } from "@/lib/rate-limit"; +import { SessionTracker } from "@/lib/session-tracker"; + +const testPriceData: ModelPriceData = { + input_cost_per_token: 0.000003, + output_cost_per_token: 0.000015, +}; + +function createSession(opts?: { sessionId?: string | null }): ProxySession { + const session = Object.create(ProxySession.prototype) as ProxySession; + const provider = { + id: 1, + name: "test-provider", + providerType: "claude" as const, + baseUrl: "https://api.test.com", + priority: 10, + weight: 1, + costMultiplier: 1, + groupTag: "default", + isEnabled: true, + models: [], + createdAt: new Date(), + updatedAt: new Date(), + streamingIdleTimeoutMs: 0, + dailyResetTime: "00:00", + dailyResetMode: "fixed", + }; + + const user = { id: 123, name: "test-user", dailyResetTime: "00:00", dailyResetMode: "fixed" }; + const key = { id: 456, name: "test-key", dailyResetTime: "00:00", dailyResetMode: "fixed" }; + + Object.assign(session, { + request: { message: {}, log: "(test)", model: "test-model" }, + startTime: Date.now(), + method: "POST", + requestUrl: new URL("http://localhost/v1/messages"), + headers: new Headers(), + headerLog: "", + userAgent: null, + context: {}, + clientAbortSignal: null, + userName: "test-user", + authState: { user, key, apiKey: "sk-test", success: true }, + provider, + messageContext: { + id: 1, + createdAt: new Date(), + user, + key, + apiKey: "sk-test", + }, + sessionId: opts?.sessionId ?? null, + requestSequence: 1, + originalFormat: "claude", + providerType: null, + originalModelName: null, + originalUrlPathname: null, + providerChain: [], + cacheTtlResolved: null, + context1mApplied: false, + specialSettings: [], + cachedPriceData: undefined, + cachedBillingModelSource: undefined, + isHeaderModified: () => false, + getContext1mApplied: () => false, + getOriginalModel: () => "test-model", + getCurrentModel: () => "test-model", + getProviderChain: () => session.providerChain, + getCachedPriceDataByBillingSource: async () => testPriceData, + recordTtfb: () => 100, + ttfbMs: null, + getRequestSequence: () => 1, + addProviderToChain: function ( + this: ProxySession & { providerChain: unknown[] }, + prov: { id: number; name: string; providerType: string; priority: number; weight: number; costMultiplier: number; groupTag: string; providerVendorId?: string }, + ) { + this.providerChain.push({ + id: prov.id, + name: prov.name, + vendorId: prov.providerVendorId, + providerType: prov.providerType, + priority: prov.priority, + weight: prov.weight, + costMultiplier: prov.costMultiplier, + groupTag: prov.groupTag, + timestamp: Date.now(), + }); + }, + }); + + // Helper setters + (session as { setOriginalModel(m: string | null): void }).setOriginalModel = function ( + m: string | null, + ) { + (this as { originalModelName: string | null }).originalModelName = m; + }; + (session as { setSessionId(s: string): void }).setSessionId = function (s: string) { + (this as { sessionId: string | null }).sessionId = s; + }; + (session as { setProvider(p: unknown): void }).setProvider = function (p: unknown) { + (this as { provider: unknown }).provider = p; + }; + (session as { setAuthState(a: unknown): void }).setAuthState = function (a: unknown) { + (this as { authState: unknown }).authState = a; + }; + (session as { setMessageContext(c: unknown): void }).setMessageContext = function (c: unknown) { + (this as { messageContext: unknown }).messageContext = c; + }; + + session.setOriginalModel("test-model"); + + return session; +} + +function setDeferredMeta(session: ProxySession, endpointId: number | null = 42) { + setDeferredStreamingFinalization(session, { + providerId: 1, + providerName: "test-provider", + providerPriority: 10, + attemptNumber: 1, + totalProvidersAttempted: 1, + isFirstAttempt: true, + isFailoverSuccess: false, + endpointId, + endpointUrl: "https://api.test.com", + upstreamStatusCode: 200, + }); +} + +/** Create an SSE stream that emits a fake-200 error body (valid HTTP 200 but error in content). */ +function createFake200StreamResponse(): Response { + const body = `data: ${JSON.stringify({ error: { message: "invalid api key" } })}\n\n`; + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(body)); + controller.close(); + }, + }); + return new Response(stream, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); +} + +/** Create an SSE stream that returns non-200 HTTP status with error body. */ +function createNon200StreamResponse(statusCode: number): Response { + const body = `data: ${JSON.stringify({ error: "rate limit exceeded" })}\n\n`; + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(body)); + controller.close(); + }, + }); + return new Response(stream, { + status: statusCode, + headers: { "content-type": "text/event-stream" }, + }); +} + +/** Create a successful SSE stream with usage data. */ +function createSuccessStreamResponse(): Response { + const sseText = `event: message_delta\ndata: ${JSON.stringify({ usage: { input_tokens: 100, output_tokens: 50 } })}\n\n`; + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(sseText)); + controller.close(); + }, + }); + return new Response(stream, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); +} + +async function drainAsyncTasks(): Promise { + const tasks = asyncTasks.splice(0, asyncTasks.length); + await Promise.all(tasks); +} + +function setupCommonMocks() { + vi.mocked(getSystemSettings).mockResolvedValue({ + billingModelSource: "original", + streamBufferEnabled: false, + streamBufferMode: "none", + streamBufferSize: 0, + } as ReturnType extends Promise ? T : never); + vi.mocked(findLatestPriceByModel).mockResolvedValue({ + id: 1, + modelName: "test-model", + priceData: testPriceData, + createdAt: new Date(), + updatedAt: new Date(), + }); + vi.mocked(updateMessageRequestDetails).mockResolvedValue(undefined); + vi.mocked(updateMessageRequestDuration).mockResolvedValue(undefined); + vi.mocked(SessionManager.storeSessionResponse).mockResolvedValue(undefined); + vi.mocked(RateLimitService.trackCost).mockResolvedValue(undefined); + vi.mocked(RateLimitService.trackUserDailyCost).mockResolvedValue(undefined); + vi.mocked(RateLimitService.decrementLeaseBudget).mockResolvedValue({ + success: true, + newRemaining: 10, + }); + vi.mocked(SessionTracker.refreshSession).mockResolvedValue(undefined); + mockRecordFailure.mockResolvedValue(undefined); + mockRecordEndpointFailure.mockResolvedValue(undefined); + mockRecordEndpointSuccess.mockResolvedValue(undefined); +} + +beforeEach(() => { + vi.clearAllMocks(); + asyncTasks.splice(0, asyncTasks.length); +}); + +describe("Endpoint circuit breaker isolation", () => { + beforeEach(() => { + setupCommonMocks(); + }); + + it("fake-200 error should call recordFailure but NOT recordEndpointFailure", async () => { + const session = createSession(); + setDeferredMeta(session, 42); + + const response = createFake200StreamResponse(); + await ProxyResponseHandler.dispatch(session, response); + await drainAsyncTasks(); + + expect(mockRecordFailure).toHaveBeenCalledWith( + 1, + expect.objectContaining({ message: expect.stringContaining("FAKE_200") }), + ); + expect(mockRecordEndpointFailure).not.toHaveBeenCalled(); + }); + + it("non-200 HTTP status should call recordFailure but NOT recordEndpointFailure", async () => { + const session = createSession(); + // Set upstream status to 429 in deferred meta + setDeferredStreamingFinalization(session, { + providerId: 1, + providerName: "test-provider", + providerPriority: 10, + attemptNumber: 1, + totalProvidersAttempted: 1, + isFirstAttempt: true, + isFailoverSuccess: false, + endpointId: 42, + endpointUrl: "https://api.test.com", + upstreamStatusCode: 429, + }); + + const response = createNon200StreamResponse(429); + await ProxyResponseHandler.dispatch(session, response); + await drainAsyncTasks(); + + expect(mockRecordFailure).toHaveBeenCalledWith(1, expect.any(Error)); + expect(mockRecordEndpointFailure).not.toHaveBeenCalled(); + }); + + it("streaming success DOES call recordEndpointSuccess (regression guard)", async () => { + const session = createSession(); + setDeferredMeta(session, 42); + + const response = createSuccessStreamResponse(); + await ProxyResponseHandler.dispatch(session, response); + await drainAsyncTasks(); + + expect(mockRecordEndpointSuccess).toHaveBeenCalledWith(42); + expect(mockRecordEndpointFailure).not.toHaveBeenCalled(); + }); + + it("streaming success without endpointId should NOT call any endpoint circuit breaker function", async () => { + const session = createSession(); + setDeferredMeta(session, null); + + const response = createSuccessStreamResponse(); + await ProxyResponseHandler.dispatch(session, response); + await drainAsyncTasks(); + + expect(mockRecordEndpointSuccess).not.toHaveBeenCalled(); + expect(mockRecordEndpointFailure).not.toHaveBeenCalled(); + }); +}); From e21a5f71fa891963e97ff4ce76fee15804fcbeef Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 11 Feb 2026 05:57:56 +0000 Subject: [PATCH 2/7] chore: format code (dev-3d584e5) --- ...handler-endpoint-circuit-isolation.test.ts | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/tests/unit/proxy/response-handler-endpoint-circuit-isolation.test.ts b/tests/unit/proxy/response-handler-endpoint-circuit-isolation.test.ts index e8cb3ffa5..16c531d24 100644 --- a/tests/unit/proxy/response-handler-endpoint-circuit-isolation.test.ts +++ b/tests/unit/proxy/response-handler-endpoint-circuit-isolation.test.ts @@ -90,7 +90,7 @@ const { mockRecordFailure, mockRecordEndpointFailure, mockRecordEndpointSuccess mockRecordFailure: vi.fn(), mockRecordEndpointFailure: vi.fn(), mockRecordEndpointSuccess: vi.fn(), - }), + }) ); vi.mock("@/lib/circuit-breaker", () => ({ @@ -108,10 +108,7 @@ import { ProxySession } from "@/app/v1/_lib/proxy/session"; import { setDeferredStreamingFinalization } from "@/app/v1/_lib/proxy/stream-finalization"; import { getSystemSettings } from "@/repository/system-config"; import { findLatestPriceByModel } from "@/repository/model-price"; -import { - updateMessageRequestDetails, - updateMessageRequestDuration, -} from "@/repository/message"; +import { updateMessageRequestDetails, updateMessageRequestDuration } from "@/repository/message"; import { SessionManager } from "@/lib/session-manager"; import { RateLimitService } from "@/lib/rate-limit"; import { SessionTracker } from "@/lib/session-tracker"; @@ -187,7 +184,16 @@ function createSession(opts?: { sessionId?: string | null }): ProxySession { getRequestSequence: () => 1, addProviderToChain: function ( this: ProxySession & { providerChain: unknown[] }, - prov: { id: number; name: string; providerType: string; priority: number; weight: number; costMultiplier: number; groupTag: string; providerVendorId?: string }, + prov: { + id: number; + name: string; + providerType: string; + priority: number; + weight: number; + costMultiplier: number; + groupTag: string; + providerVendorId?: string; + } ) { this.providerChain.push({ id: prov.id, @@ -205,7 +211,7 @@ function createSession(opts?: { sessionId?: string | null }): ProxySession { // Helper setters (session as { setOriginalModel(m: string | null): void }).setOriginalModel = function ( - m: string | null, + m: string | null ) { (this as { originalModelName: string | null }).originalModelName = m; }; @@ -344,7 +350,7 @@ describe("Endpoint circuit breaker isolation", () => { expect(mockRecordFailure).toHaveBeenCalledWith( 1, - expect.objectContaining({ message: expect.stringContaining("FAKE_200") }), + expect.objectContaining({ message: expect.stringContaining("FAKE_200") }) ); expect(mockRecordEndpointFailure).not.toHaveBeenCalled(); }); From cc1c7ea2e39757932085f6278842fe2a079fd740 Mon Sep 17 00:00:00 2001 From: hank9999 Date: Wed, 11 Feb 2026 23:22:46 +0800 Subject: [PATCH 3/7] Merge pull request #767 from ding113/fix/provider-clone-deep-copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix: 修复供应商克隆时因浅拷贝引用共享导致源供应商数据被意外污染的问题 --- .../provider-form/provider-form-context.tsx | 11 +- .../provider-form-clone-deep-copy.test.ts | 158 ++++++++++++++++++ 2 files changed, 165 insertions(+), 4 deletions(-) create mode 100644 tests/unit/dashboard/provider-form-clone-deep-copy.test.ts diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context.tsx index 7b89a5dbe..9f9c890a2 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context.tsx +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context.tsx @@ -22,7 +22,8 @@ export function createInitialState( } ): ProviderFormState { const isEdit = mode === "edit"; - const sourceProvider = isEdit ? provider : cloneProvider; + const raw = isEdit ? provider : cloneProvider; + const sourceProvider = raw ? structuredClone(raw) : undefined; return { basic: { @@ -322,11 +323,13 @@ export function providerFormReducer( return { ...state, ui: { ...state.ui, showFailureThresholdConfirm: action.payload } }; // Reset - case "RESET_FORM": + case "RESET_FORM": { + const fresh = structuredClone(defaultInitialState); return { - ...defaultInitialState, - ui: { ...defaultInitialState.ui, activeTab: state.ui.activeTab }, + ...fresh, + ui: { ...fresh.ui, activeTab: state.ui.activeTab }, }; + } // Load provider data case "LOAD_PROVIDER": diff --git a/tests/unit/dashboard/provider-form-clone-deep-copy.test.ts b/tests/unit/dashboard/provider-form-clone-deep-copy.test.ts new file mode 100644 index 000000000..d5a0932ba --- /dev/null +++ b/tests/unit/dashboard/provider-form-clone-deep-copy.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from "vitest"; +import { createInitialState } from "@/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context"; +import type { ProviderDisplay } from "@/types/provider"; + +function makeProvider(overrides?: Partial): ProviderDisplay { + return { + id: 1, + name: "TestProvider", + url: "https://api.example.com", + maskedKey: "sk-****1234", + isEnabled: true, + weight: 1, + priority: 0, + groupPriorities: { groupA: 10, groupB: 20 }, + costMultiplier: 1.0, + groupTag: "groupA,groupB", + providerType: "claude", + providerVendorId: null, + preserveClientIp: false, + modelRedirects: { "claude-3": "claude-3.5" }, + allowedModels: ["claude-3", "claude-3.5"], + mcpPassthroughType: "none", + mcpPassthroughUrl: null, + limit5hUsd: null, + limitDailyUsd: null, + dailyResetMode: "fixed", + dailyResetTime: "00:00", + limitWeeklyUsd: null, + limitMonthlyUsd: null, + limitTotalUsd: null, + limitConcurrentSessions: 0, + maxRetryAttempts: null, + circuitBreakerFailureThreshold: 3, + circuitBreakerOpenDuration: 60000, + circuitBreakerHalfOpenSuccessThreshold: 2, + proxyUrl: null, + proxyFallbackToDirect: false, + firstByteTimeoutStreamingMs: 30000, + streamingIdleTimeoutMs: 60000, + requestTimeoutNonStreamingMs: 120000, + websiteUrl: null, + faviconUrl: null, + cacheTtlPreference: null, + context1mPreference: null, + codexReasoningEffortPreference: null, + codexReasoningSummaryPreference: null, + codexTextVerbosityPreference: null, + codexParallelToolCallsPreference: null, + anthropicMaxTokensPreference: null, + anthropicThinkingBudgetPreference: null, + anthropicAdaptiveThinking: { + effort: "high", + modelMatchMode: "specific", + models: ["claude-opus-4-6"], + }, + geminiGoogleSearchPreference: null, + tpm: null, + rpm: null, + rpd: null, + cc: null, + createdAt: "2025-01-01T00:00:00.000Z", + updatedAt: "2025-01-01T00:00:00.000Z", + ...overrides, + } as ProviderDisplay; +} + +describe("createInitialState deep-copy safety", () => { + describe("clone mode", () => { + it("modelRedirects is a distinct object with equal values", () => { + const source = makeProvider(); + const state = createInitialState("create", undefined, source); + expect(state.routing.modelRedirects).toEqual(source.modelRedirects); + expect(state.routing.modelRedirects).not.toBe(source.modelRedirects); + }); + + it("allowedModels is a distinct array with equal values", () => { + const source = makeProvider(); + const state = createInitialState("create", undefined, source); + expect(state.routing.allowedModels).toEqual(source.allowedModels); + expect(state.routing.allowedModels).not.toBe(source.allowedModels); + }); + + it("groupPriorities is a distinct object with equal values", () => { + const source = makeProvider(); + const state = createInitialState("create", undefined, source); + expect(state.routing.groupPriorities).toEqual(source.groupPriorities); + expect(state.routing.groupPriorities).not.toBe(source.groupPriorities); + }); + + it("anthropicAdaptiveThinking is a distinct object with distinct models array", () => { + const source = makeProvider(); + const state = createInitialState("create", undefined, source); + expect(state.routing.anthropicAdaptiveThinking).toEqual(source.anthropicAdaptiveThinking); + expect(state.routing.anthropicAdaptiveThinking).not.toBe(source.anthropicAdaptiveThinking); + expect(state.routing.anthropicAdaptiveThinking!.models).not.toBe( + source.anthropicAdaptiveThinking!.models + ); + }); + + it("null anthropicAdaptiveThinking stays null", () => { + const source = makeProvider({ anthropicAdaptiveThinking: null }); + const state = createInitialState("create", undefined, source); + expect(state.routing.anthropicAdaptiveThinking).toBeNull(); + }); + + it("null modelRedirects falls back to empty object", () => { + const source = makeProvider({ modelRedirects: null }); + const state = createInitialState("create", undefined, source); + expect(state.routing.modelRedirects).toEqual({}); + }); + + it("null allowedModels falls back to empty array", () => { + const source = makeProvider({ allowedModels: null }); + const state = createInitialState("create", undefined, source); + expect(state.routing.allowedModels).toEqual([]); + }); + + it("null groupPriorities falls back to empty object", () => { + const source = makeProvider({ groupPriorities: null }); + const state = createInitialState("create", undefined, source); + expect(state.routing.groupPriorities).toEqual({}); + }); + + it("name gets _Copy suffix", () => { + const source = makeProvider({ name: "MyProvider" }); + const state = createInitialState("create", undefined, source); + expect(state.basic.name).toBe("MyProvider_Copy"); + }); + + it("key is always empty", () => { + const source = makeProvider(); + const state = createInitialState("create", undefined, source); + expect(state.basic.key).toBe(""); + }); + }); + + describe("edit mode", () => { + it("nested objects are isolated from source provider", () => { + const source = makeProvider(); + const state = createInitialState("edit", source); + expect(state.routing.modelRedirects).toEqual(source.modelRedirects); + expect(state.routing.modelRedirects).not.toBe(source.modelRedirects); + expect(state.routing.allowedModels).not.toBe(source.allowedModels); + expect(state.routing.groupPriorities).not.toBe(source.groupPriorities); + expect(state.routing.anthropicAdaptiveThinking).not.toBe(source.anthropicAdaptiveThinking); + }); + }); + + describe("create mode without clone source", () => { + it("nested objects use fresh defaults", () => { + const state = createInitialState("create"); + expect(state.routing.modelRedirects).toEqual({}); + expect(state.routing.allowedModels).toEqual([]); + expect(state.routing.groupPriorities).toEqual({}); + expect(state.routing.anthropicAdaptiveThinking).toBeNull(); + }); + }); +}); From cdb21d9e47b747fbbf6f12e2b20566abad0d3218 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Wed, 11 Feb 2026 23:34:56 +0800 Subject: [PATCH 4/7] =?UTF-8?q?=E5=A2=9E=E5=BC=BA=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E8=A1=A8=E5=8D=95=E8=BE=93=E5=85=A5=E8=AD=A6=E5=91=8A=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=20(#768)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 增强配置表单输入警告提示 * fix: 修复 expiresAt 显示与配额刷新输入边界 * fix: 修复 expiresAt 解析兜底并改善刷新间隔输入体验 * fix: 刷新间隔输入取整并复用 clamp --------- Co-authored-by: tesgth032 --- messages/en/dashboard.json | 3 +- messages/en/settings/config.json | 8 +- messages/en/settings/providers/form/key.json | 9 +- messages/ja/dashboard.json | 3 +- messages/ja/settings/config.json | 8 +- messages/ja/settings/providers/form/key.json | 9 +- messages/ru/dashboard.json | 3 +- messages/ru/settings/config.json | 8 +- messages/ru/settings/providers/form/key.json | 9 +- messages/zh-CN/dashboard.json | 3 +- messages/zh-CN/settings/config.json | 8 +- .../zh-CN/settings/providers/form/key.json | 9 +- messages/zh-TW/dashboard.json | 3 +- messages/zh-TW/settings/config.json | 8 +- .../zh-TW/settings/providers/form/key.json | 9 +- .../_components/user/forms/user-form.tsx | 32 ++++--- .../_components/system-settings-form.tsx | 73 ++++++++++++++-- .../sections/basic-info-section.tsx | 14 ++- src/components/ui/inline-warning.tsx | 26 ++++++ src/lib/utils/date-input.test.ts | 36 ++++++++ src/lib/utils/date-input.ts | 34 ++++++++ .../utils/validation/api-key-warnings.test.ts | 44 ++++++++++ src/lib/utils/validation/api-key-warnings.ts | 85 +++++++++++++++++++ .../validation/quota-lease-warnings.test.ts | 39 +++++++++ .../utils/validation/quota-lease-warnings.ts | 31 +++++++ .../user-form-expiry-clear-ui.test.tsx | 6 +- .../unit/lib/provider-endpoints/probe.test.ts | 69 +++++++-------- 27 files changed, 515 insertions(+), 74 deletions(-) create mode 100644 src/components/ui/inline-warning.tsx create mode 100644 src/lib/utils/date-input.test.ts create mode 100644 src/lib/utils/validation/api-key-warnings.test.ts create mode 100644 src/lib/utils/validation/api-key-warnings.ts create mode 100644 src/lib/utils/validation/quota-lease-warnings.test.ts create mode 100644 src/lib/utils/validation/quota-lease-warnings.ts diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json index f26229c75..6256e867e 100644 --- a/messages/en/dashboard.json +++ b/messages/en/dashboard.json @@ -994,7 +994,8 @@ "expiresAt": { "label": "Expiration Date", "placeholder": "Leave empty for never expires", - "description": "User will be automatically disabled after expiration" + "description": "User will be automatically disabled after expiration", + "pastWarning": "Selected date is in the past. The user will expire and be disabled immediately after saving." }, "allowedClients": { "label": "Allowed Clients", diff --git a/messages/en/settings/config.json b/messages/en/settings/config.json index f7aacfa05..c7bd7ae2b 100644 --- a/messages/en/settings/config.json +++ b/messages/en/settings/config.json @@ -95,7 +95,13 @@ "leasePercentMonthly": "Monthly Window Lease Percentage", "leasePercentMonthlyDesc": "Percentage of monthly limit for each lease slice (0-1)", "leaseCapUsd": "Lease Cap (USD)", - "leaseCapUsdDesc": "Maximum absolute cap per lease slice in USD, leave empty for no limit" + "leaseCapUsdDesc": "Maximum absolute cap per lease slice in USD, leave empty for no limit", + "warnings": { + "dbRefreshIntervalTooLow": "Refresh interval is {value}s. This may increase DB load.", + "dbRefreshIntervalTooHigh": "Refresh interval is {value}s. Quota/limit updates may be delayed.", + "leasePercentZero": "Percentage is 0. This may cause the lease budget to always be 0.", + "leaseCapZero": "Lease cap is 0. This may cause the per-lease budget to be 0." + } } }, "section": { diff --git a/messages/en/settings/providers/form/key.json b/messages/en/settings/providers/form/key.json index 7764faf0b..4bcd75153 100644 --- a/messages/en/settings/providers/form/key.json +++ b/messages/en/settings/providers/form/key.json @@ -4,5 +4,12 @@ "labelEdit": "API Key (Leave empty to keep unchanged)", "leaveEmpty": "(Leave empty to keep unchanged)", "leaveEmptyDesc": "Leave empty to keep existing key", - "placeholder": "Enter API Key" + "placeholder": "Enter API Key", + "warnings": { + "looks_like_auth_header": "Looks like you pasted a request header (e.g., Bearer/Authorization/x-api-key). Please enter the key value only.", + "wrapped_in_quotes": "Wrapped in quotes. Usually the quotes are not needed.", + "contains_non_ascii": "Contains non-ASCII characters. This is uncommon for API keys.", + "contains_whitespace": "Contains whitespace (spaces/newlines). This is uncommon for API keys.", + "contains_uncommon_ascii": "Contains uncommon symbols. This is uncommon for API keys." + } } diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json index fd6a42f10..3a4aae78d 100644 --- a/messages/ja/dashboard.json +++ b/messages/ja/dashboard.json @@ -981,7 +981,8 @@ "expiresAt": { "label": "有効期限", "placeholder": "空白の場合は無期限", - "description": "有効期限切れ後、ユーザーは自動的に無効化されます" + "description": "有効期限切れ後、ユーザーは自動的に無効化されます", + "pastWarning": "選択した日付は過去です。保存するとユーザーは直ちに期限切れとなり無効化されます。" }, "allowedClients": { "label": "許可されたクライアント", diff --git a/messages/ja/settings/config.json b/messages/ja/settings/config.json index 6bf7f8790..7a9a6204e 100644 --- a/messages/ja/settings/config.json +++ b/messages/ja/settings/config.json @@ -95,7 +95,13 @@ "leasePercentMonthly": "月次ウィンドウリース比率", "leasePercentMonthlyDesc": "各リーススライスの月次制限に対する比率(0-1)", "leaseCapUsd": "リース上限(USD)", - "leaseCapUsdDesc": "リーススライスごとの絶対上限(米ドル)、空の場合は無制限" + "leaseCapUsdDesc": "リーススライスごとの絶対上限(米ドル)、空の場合は無制限", + "warnings": { + "dbRefreshIntervalTooLow": "更新間隔が {value}s です。DB 負荷が増える可能性があります。", + "dbRefreshIntervalTooHigh": "更新間隔が {value}s です。クォータ/制限の反映が遅れる可能性があります。", + "leasePercentZero": "比率が 0 です。リース予算が常に 0 になる可能性があります。", + "leaseCapZero": "上限が 0 です。リースごとの予算が 0 になる可能性があります。" + } } }, "section": { diff --git a/messages/ja/settings/providers/form/key.json b/messages/ja/settings/providers/form/key.json index f40d9137a..0d0385683 100644 --- a/messages/ja/settings/providers/form/key.json +++ b/messages/ja/settings/providers/form/key.json @@ -4,5 +4,12 @@ "labelEdit": "API キー(空欄のままにすると変更しません)", "leaveEmpty": "(空欄のままにすると変更しません)", "leaveEmptyDesc": "空欄のままにすると既存のキーを保持します", - "placeholder": "API キーを入力" + "placeholder": "API キーを入力", + "warnings": { + "looks_like_auth_header": "リクエストヘッダー(例: Bearer/Authorization/x-api-key)を貼り付けたようです。キー本体のみを入力してください。", + "wrapped_in_quotes": "前後が引用符で囲まれています。通常、引用符は不要です。", + "contains_non_ascii": "非 ASCII 文字を含んでいます。API キーとしては一般的ではありません。", + "contains_whitespace": "空白文字(スペース/改行)を含んでいます。API キーとしては一般的ではありません。", + "contains_uncommon_ascii": "一般的でない記号を含んでいます。API キーとしては一般的ではありません。" + } } diff --git a/messages/ru/dashboard.json b/messages/ru/dashboard.json index 48337f945..6c46440e6 100644 --- a/messages/ru/dashboard.json +++ b/messages/ru/dashboard.json @@ -983,7 +983,8 @@ "expiresAt": { "label": "Срок действия", "placeholder": "Оставьте пустым для бессрочного", - "description": "Пользователь будет автоматически отключен после истечения срока" + "description": "Пользователь будет автоматически отключен после истечения срока", + "pastWarning": "Выбранная дата в прошлом. После сохранения пользователь сразу станет просроченным и будет отключен." }, "allowedClients": { "label": "Разрешённые клиенты", diff --git a/messages/ru/settings/config.json b/messages/ru/settings/config.json index 44f9883c1..a65535f31 100644 --- a/messages/ru/settings/config.json +++ b/messages/ru/settings/config.json @@ -95,7 +95,13 @@ "leasePercentMonthly": "Процент аренды месячного окна", "leasePercentMonthlyDesc": "Процент месячного лимита для каждого среза аренды (0-1)", "leaseCapUsd": "Предел аренды (USD)", - "leaseCapUsdDesc": "Максимальный абсолютный предел на срез аренды в долларах США, оставьте пустым для отсутствия ограничения" + "leaseCapUsdDesc": "Максимальный абсолютный предел на срез аренды в долларах США, оставьте пустым для отсутствия ограничения", + "warnings": { + "dbRefreshIntervalTooLow": "Интервал {value}s. Это может увеличить нагрузку на БД.", + "dbRefreshIntervalTooHigh": "Интервал {value}s. Обновление квот/лимитов может запаздывать.", + "leasePercentZero": "Процент равен 0. Бюджет аренды может всегда быть 0.", + "leaseCapZero": "Предел аренды равен 0. Бюджет на срез может быть 0." + } } }, "section": { diff --git a/messages/ru/settings/providers/form/key.json b/messages/ru/settings/providers/form/key.json index 7bb499d2e..941b1b451 100644 --- a/messages/ru/settings/providers/form/key.json +++ b/messages/ru/settings/providers/form/key.json @@ -4,5 +4,12 @@ "labelEdit": "API ключ (Оставьте пустым, чтобы не менять)", "leaveEmpty": "(Оставьте пустым, чтобы не менять)", "leaveEmptyDesc": "Пустое значение — без изменений", - "placeholder": "Введите API ключ" + "placeholder": "Введите API ключ", + "warnings": { + "looks_like_auth_header": "Похоже, вы вставили заголовок запроса (например, Bearer/Authorization/x-api-key). Введите только значение ключа.", + "wrapped_in_quotes": "Обрамлено кавычками. Обычно кавычки не нужны.", + "contains_non_ascii": "Содержит не-ASCII символы. Для API ключей это обычно нетипично.", + "contains_whitespace": "Содержит пробелы/переносы строк. Для API ключей это обычно нетипично.", + "contains_uncommon_ascii": "Содержит нетипичные символы. Для API ключей это обычно нетипично." + } } diff --git a/messages/zh-CN/dashboard.json b/messages/zh-CN/dashboard.json index 823caee5a..e08a15acf 100644 --- a/messages/zh-CN/dashboard.json +++ b/messages/zh-CN/dashboard.json @@ -995,7 +995,8 @@ "expiresAt": { "label": "过期时间", "placeholder": "留空表示永不过期", - "description": "用户过期后将自动禁用" + "description": "用户过期后将自动禁用", + "pastWarning": "选择的日期已在过去,保存后用户将立即过期并被禁用。" }, "allowedClients": { "label": "允许的客户端", diff --git a/messages/zh-CN/settings/config.json b/messages/zh-CN/settings/config.json index b553ef9d5..91c876140 100644 --- a/messages/zh-CN/settings/config.json +++ b/messages/zh-CN/settings/config.json @@ -108,7 +108,13 @@ "leasePercentMonthly": "每月窗口租约比例", "leasePercentMonthlyDesc": "每次租约切片占每月限额的比例(0-1)", "leaseCapUsd": "租约上限(USD)", - "leaseCapUsdDesc": "单次租约切片的绝对上限(美元),为空则不限制" + "leaseCapUsdDesc": "单次租约切片的绝对上限(美元),为空则不限制", + "warnings": { + "dbRefreshIntervalTooLow": "当前刷新间隔为 {value}s,可能增加 DB 压力。", + "dbRefreshIntervalTooHigh": "当前刷新间隔为 {value}s,配额/限额状态可能更新不及时。", + "leasePercentZero": "当前比例为 0,可能导致租约预算始终为 0。", + "leaseCapZero": "租约上限为 0 可能导致单次租约预算为 0。" + } } } } diff --git a/messages/zh-CN/settings/providers/form/key.json b/messages/zh-CN/settings/providers/form/key.json index 4658c4ca9..ff5a99e12 100644 --- a/messages/zh-CN/settings/providers/form/key.json +++ b/messages/zh-CN/settings/providers/form/key.json @@ -4,5 +4,12 @@ "leaveEmpty": "(留空不更改)", "placeholder": "输入 API 密钥", "leaveEmptyDesc": "留空则不更改密钥", - "currentKey": "当前密钥: {key}" + "currentKey": "当前密钥: {key}", + "warnings": { + "looks_like_auth_header": "看起来像粘贴了请求头(如 Bearer/Authorization/x-api-key)。请仅填写 Key 本身。", + "wrapped_in_quotes": "检测到首尾引号,通常不需要引号。", + "contains_non_ascii": "包含非 ASCII 字符(如中文),通常不是常见 API Key。", + "contains_whitespace": "包含空白字符(空格/换行),通常不是常见 API Key。", + "contains_uncommon_ascii": "包含不常见符号,通常不是常见 API Key。" + } } diff --git a/messages/zh-TW/dashboard.json b/messages/zh-TW/dashboard.json index ce0739f82..a4d2d8fe7 100644 --- a/messages/zh-TW/dashboard.json +++ b/messages/zh-TW/dashboard.json @@ -980,7 +980,8 @@ "expiresAt": { "label": "到期時間", "placeholder": "留空表示永不過期", - "description": "使用者過期後將自動停用" + "description": "使用者過期後將自動停用", + "pastWarning": "選擇的日期已在過去,儲存後使用者將立即到期並被停用。" }, "allowedClients": { "label": "允許的用戶端", diff --git a/messages/zh-TW/settings/config.json b/messages/zh-TW/settings/config.json index b57c2e355..4a3c7ee01 100644 --- a/messages/zh-TW/settings/config.json +++ b/messages/zh-TW/settings/config.json @@ -95,7 +95,13 @@ "leasePercentMonthly": "每月窗口租約比例", "leasePercentMonthlyDesc": "每次租約切片佔每月限額的比例(0-1)", "leaseCapUsd": "租約上限(USD)", - "leaseCapUsdDesc": "單次租約切片的絕對上限(美元),為空則不限制" + "leaseCapUsdDesc": "單次租約切片的絕對上限(美元),為空則不限制", + "warnings": { + "dbRefreshIntervalTooLow": "目前刷新間隔為 {value}s,可能增加 DB 壓力。", + "dbRefreshIntervalTooHigh": "目前刷新間隔為 {value}s,配額/限額狀態可能更新不及時。", + "leasePercentZero": "目前比例為 0,可能導致租約預算始終為 0。", + "leaseCapZero": "租約上限為 0 可能導致單次租約預算為 0。" + } } }, "section": { diff --git a/messages/zh-TW/settings/providers/form/key.json b/messages/zh-TW/settings/providers/form/key.json index f89c40db5..19f6ccd31 100644 --- a/messages/zh-TW/settings/providers/form/key.json +++ b/messages/zh-TW/settings/providers/form/key.json @@ -4,5 +4,12 @@ "labelEdit": "API 金鑰(留空不變更)", "leaveEmpty": "(留空不變更)", "leaveEmptyDesc": "留空則不變更金鑰", - "placeholder": "輸入 API 金鑰" + "placeholder": "輸入 API 金鑰", + "warnings": { + "looks_like_auth_header": "看起來像貼上了請求標頭(例如 Bearer/Authorization/x-api-key)。請只填入 Key 本身。", + "wrapped_in_quotes": "偵測到首尾引號,通常不需要引號。", + "contains_non_ascii": "包含非 ASCII 字元(例如中文),通常不是常見 API Key。", + "contains_whitespace": "包含空白字元(空格/換行),通常不是常見 API Key。", + "contains_uncommon_ascii": "包含不常見符號,通常不是常見 API Key。" + } } diff --git a/src/app/[locale]/dashboard/_components/user/forms/user-form.tsx b/src/app/[locale]/dashboard/_components/user/forms/user-form.tsx index 010cbceca..e3688e1c1 100644 --- a/src/app/[locale]/dashboard/_components/user/forms/user-form.tsx +++ b/src/app/[locale]/dashboard/_components/user/forms/user-form.tsx @@ -1,7 +1,7 @@ "use client"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; -import { useEffect, useState, useTransition } from "react"; +import { useEffect, useMemo, useState, useTransition } from "react"; import { toast } from "sonner"; import { z } from "zod"; import { getAvailableProviderGroups } from "@/actions/providers"; @@ -10,11 +10,13 @@ import { DatePickerField } from "@/components/form/date-picker-field"; import { ArrayTagInputField, TagInputField, TextField } from "@/components/form/form-field"; import { DialogFormLayout, FormGrid } from "@/components/form/form-layout"; import { Checkbox } from "@/components/ui/checkbox"; +import { InlineWarning } from "@/components/ui/inline-warning"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { PROVIDER_GROUP } from "@/lib/constants/provider.constants"; import { USER_LIMITS } from "@/lib/constants/user.constants"; import { useZodForm } from "@/lib/hooks/use-zod-form"; +import { formatDateToLocalYmd, parseYmdToLocalEndOfDay } from "@/lib/utils/date-input"; import { getErrorMessage } from "@/lib/utils/error-messages"; import { setZodErrorMap } from "@/lib/utils/zod-i18n"; import { CreateUserSchema } from "@/lib/validation/schemas"; @@ -99,20 +101,19 @@ export function UserForm({ user, onSuccess, currentUser }: UserFormProps) { limitTotalUsd: user?.limitTotalUsd ?? null, limitConcurrentSessions: user?.limitConcurrentSessions ?? null, isEnabled: user?.isEnabled ?? true, - expiresAt: user?.expiresAt ? user.expiresAt.toISOString().split("T")[0] : "", + expiresAt: user?.expiresAt ? formatDateToLocalYmd(user.expiresAt) : "", allowedClients: user?.allowedClients || [], allowedModels: user?.allowedModels || [], }, onSubmit: async (data) => { - // 将纯日期转换为当天结束时间(本地时区 23:59:59.999),避免默认 UTC 零点导致提前过期 - const toEndOfDay = (dateStr: string) => { - const d = new Date(dateStr); - d.setHours(23, 59, 59, 999); - return d; - }; - startTransition(async () => { try { + const expiresAt = data.expiresAt ? parseYmdToLocalEndOfDay(data.expiresAt) : null; + if (data.expiresAt && !expiresAt) { + toast.error(tErrors("INVALID_FORMAT", { field: tErrors("EXPIRES_AT_FIELD") })); + return; + } + let res; if (isEdit && user?.id) { res = await editUser(user.id, { @@ -128,7 +129,7 @@ export function UserForm({ user, onSuccess, currentUser }: UserFormProps) { limitTotalUsd: data.limitTotalUsd, limitConcurrentSessions: data.limitConcurrentSessions, isEnabled: data.isEnabled, - expiresAt: data.expiresAt ? toEndOfDay(data.expiresAt) : null, + expiresAt, allowedClients: data.allowedClients, allowedModels: data.allowedModels, }); @@ -146,7 +147,7 @@ export function UserForm({ user, onSuccess, currentUser }: UserFormProps) { limitTotalUsd: data.limitTotalUsd, limitConcurrentSessions: data.limitConcurrentSessions, isEnabled: data.isEnabled, - expiresAt: data.expiresAt ? toEndOfDay(data.expiresAt) : null, + expiresAt, allowedClients: data.allowedClients, allowedModels: data.allowedModels, }); @@ -176,6 +177,14 @@ export function UserForm({ user, onSuccess, currentUser }: UserFormProps) { // Use dashboard translations for form const tForm = useTranslations("dashboard.userForm"); + const expiresAtPastWarning = useMemo(() => { + const expiresAtYmd = form.values.expiresAt ?? ""; + if (!expiresAtYmd) return null; + const date = parseYmdToLocalEndOfDay(expiresAtYmd); + if (!date) return null; + return date.getTime() <= Date.now() ? tForm("expiresAt.pastWarning") : null; + }, [form.values.expiresAt, tForm]); + return ( + {expiresAtPastWarning && {expiresAtPastWarning}} {/* Allowed Clients (CLI/IDE restrictions) */}
diff --git a/src/app/[locale]/settings/config/_components/system-settings-form.tsx b/src/app/[locale]/settings/config/_components/system-settings-form.tsx index da47f4c87..6ecdf50f6 100644 --- a/src/app/[locale]/settings/config/_components/system-settings-form.tsx +++ b/src/app/[locale]/settings/config/_components/system-settings-form.tsx @@ -21,6 +21,7 @@ import { toast } from "sonner"; import { saveSystemSettings } from "@/actions/system-config"; import { Button } from "@/components/ui/button"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { InlineWarning } from "@/components/ui/inline-warning"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { @@ -34,6 +35,12 @@ import { Switch } from "@/components/ui/switch"; import type { CurrencyCode } from "@/lib/utils"; import { CURRENCY_CONFIG } from "@/lib/utils"; import { COMMON_TIMEZONES, getTimezoneLabel } from "@/lib/utils/timezone"; +import { + shouldWarnQuotaDbRefreshIntervalTooHigh, + shouldWarnQuotaDbRefreshIntervalTooLow, + shouldWarnQuotaLeaseCapZero, + shouldWarnQuotaLeasePercentZero, +} from "@/lib/utils/validation/quota-lease-warnings"; import type { BillingModelSource, SystemSettings } from "@/types/system-config"; interface SystemSettingsFormProps { @@ -62,6 +69,13 @@ interface SystemSettingsFormProps { >; } +function clampQuotaDbRefreshIntervalSeconds(raw: string): number { + const parsed = Number(raw); + if (!Number.isFinite(parsed)) return 1; + const rounded = Math.round(parsed); + return Math.min(300, Math.max(1, rounded)); +} + export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps) { const router = useRouter(); const t = useTranslations("settings.config.form"); @@ -102,9 +116,15 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps) const [responseFixerConfig, setResponseFixerConfig] = useState( initialSettings.responseFixerConfig ); - const [quotaDbRefreshIntervalSeconds, setQuotaDbRefreshIntervalSeconds] = useState( - initialSettings.quotaDbRefreshIntervalSeconds ?? 10 + const [quotaDbRefreshIntervalSecondsStr, setQuotaDbRefreshIntervalSecondsStr] = useState( + String(initialSettings.quotaDbRefreshIntervalSeconds ?? 10) ); + const quotaDbRefreshIntervalSeconds = (() => { + const trimmed = quotaDbRefreshIntervalSecondsStr.trim(); + if (!trimmed) return Number.NaN; + const parsed = Number(trimmed); + return Number.isFinite(parsed) ? parsed : Number.NaN; + })(); const [quotaLeasePercent5h, setQuotaLeasePercent5h] = useState( initialSettings.quotaLeasePercent5h ?? 0.05 ); @@ -132,6 +152,10 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps) return; } + const quotaDbRefreshIntervalSecondsToSave = clampQuotaDbRefreshIntervalSeconds( + quotaDbRefreshIntervalSecondsStr + ); + startTransition(async () => { const result = await saveSystemSettings({ siteTitle, @@ -148,7 +172,7 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps) enableClaudeMetadataUserIdInjection, enableResponseFixer, responseFixerConfig, - quotaDbRefreshIntervalSeconds, + quotaDbRefreshIntervalSeconds: quotaDbRefreshIntervalSecondsToSave, quotaLeasePercent5h, quotaLeasePercentDaily, quotaLeasePercentWeekly, @@ -176,7 +200,9 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps) setEnableClaudeMetadataUserIdInjection(result.data.enableClaudeMetadataUserIdInjection); setEnableResponseFixer(result.data.enableResponseFixer); setResponseFixerConfig(result.data.responseFixerConfig); - setQuotaDbRefreshIntervalSeconds(result.data.quotaDbRefreshIntervalSeconds ?? 10); + setQuotaDbRefreshIntervalSecondsStr( + String(result.data.quotaDbRefreshIntervalSeconds ?? 10) + ); setQuotaLeasePercent5h(result.data.quotaLeasePercent5h ?? 0.05); setQuotaLeasePercentDaily(result.data.quotaLeasePercentDaily ?? 0.05); setQuotaLeasePercentWeekly(result.data.quotaLeasePercentWeekly ?? 0.05); @@ -620,14 +646,34 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps) type="number" min={1} max={300} - value={quotaDbRefreshIntervalSeconds} - onChange={(e) => setQuotaDbRefreshIntervalSeconds(Number(e.target.value))} + step={1} + value={quotaDbRefreshIntervalSecondsStr} + onChange={(e) => setQuotaDbRefreshIntervalSecondsStr(e.target.value)} + onBlur={() => { + setQuotaDbRefreshIntervalSecondsStr( + String(clampQuotaDbRefreshIntervalSeconds(quotaDbRefreshIntervalSecondsStr)) + ); + }} disabled={isPending} className={inputClassName} />

{t("quotaLease.dbRefreshIntervalDesc")}

+ {shouldWarnQuotaDbRefreshIntervalTooLow(quotaDbRefreshIntervalSeconds) && ( + + {t("quotaLease.warnings.dbRefreshIntervalTooLow", { + value: quotaDbRefreshIntervalSeconds, + })} + + )} + {shouldWarnQuotaDbRefreshIntervalTooHigh(quotaDbRefreshIntervalSeconds) && ( + + {t("quotaLease.warnings.dbRefreshIntervalTooHigh", { + value: quotaDbRefreshIntervalSeconds, + })} + + )}
{/* Lease Percent 5h */} @@ -652,6 +698,9 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps)

{t("quotaLease.leasePercent5hDesc")}

+ {shouldWarnQuotaLeasePercentZero(quotaLeasePercent5h) && ( + {t("quotaLease.warnings.leasePercentZero")} + )} {/* Lease Percent Daily */} @@ -676,6 +725,9 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps)

{t("quotaLease.leasePercentDailyDesc")}

+ {shouldWarnQuotaLeasePercentZero(quotaLeasePercentDaily) && ( + {t("quotaLease.warnings.leasePercentZero")} + )} {/* Lease Percent Weekly */} @@ -700,6 +752,9 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps)

{t("quotaLease.leasePercentWeeklyDesc")}

+ {shouldWarnQuotaLeasePercentZero(quotaLeasePercentWeekly) && ( + {t("quotaLease.warnings.leasePercentZero")} + )} {/* Lease Percent Monthly */} @@ -724,6 +779,9 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps)

{t("quotaLease.leasePercentMonthlyDesc")}

+ {shouldWarnQuotaLeasePercentZero(quotaLeasePercentMonthly) && ( + {t("quotaLease.warnings.leasePercentZero")} + )} {/* Lease Cap USD */} @@ -746,6 +804,9 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps) className={inputClassName} />

{t("quotaLease.leaseCapUsdDesc")}

+ {shouldWarnQuotaLeaseCapZero(quotaLeaseCapUsd) && ( + {t("quotaLease.warnings.leaseCapZero")} + )} diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/basic-info-section.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/basic-info-section.tsx index 0b4866d88..eb7258fd8 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/basic-info-section.tsx +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/basic-info-section.tsx @@ -3,9 +3,11 @@ import { motion } from "framer-motion"; import { ExternalLink, Eye, EyeOff, Globe, Key, Link2, User } from "lucide-react"; import { useTranslations } from "next-intl"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { ProviderEndpointsSection } from "@/app/[locale]/settings/providers/_components/provider-endpoints-table"; +import { InlineWarning } from "@/components/ui/inline-warning"; import { Input } from "@/components/ui/input"; +import { detectApiKeyWarnings } from "@/lib/utils/validation/api-key-warnings"; import type { ProviderType } from "@/types/provider"; import { UrlPreview } from "../../url-preview"; import { QuickPasteDialog } from "../components/quick-paste-dialog"; @@ -29,6 +31,8 @@ export function BasicInfoSection({ autoUrlPending, endpointPool }: BasicInfoSect const nameInputRef = useRef(null); const [showKey, setShowKey] = useState(false); + const apiKeyWarnings = useMemo(() => detectApiKeyWarnings(state.basic.key), [state.basic.key]); + // Auto-focus name input useEffect(() => { const timer = setTimeout(() => { @@ -199,6 +203,14 @@ export function BasicInfoSection({ autoUrlPending, endpointPool }: BasicInfoSect {showKey ? : } + + {apiKeyWarnings.length > 0 && ( +
+ {apiKeyWarnings.map((warningId) => ( + {t(`key.warnings.${warningId}`)} + ))} +
+ )} diff --git a/src/components/ui/inline-warning.tsx b/src/components/ui/inline-warning.tsx new file mode 100644 index 000000000..d4f1a2a39 --- /dev/null +++ b/src/components/ui/inline-warning.tsx @@ -0,0 +1,26 @@ +import { AlertTriangle } from "lucide-react"; +import type { ReactNode } from "react"; + +import { cn } from "@/lib/utils"; + +interface InlineWarningProps { + children: ReactNode; + className?: string; +} + +/** + * 表单字段的内联警告提示组件(仅提示,不阻止提交)。 + */ +export function InlineWarning({ children, className }: InlineWarningProps) { + return ( +
+
+ ); +} diff --git a/src/lib/utils/date-input.test.ts b/src/lib/utils/date-input.test.ts new file mode 100644 index 000000000..98f1b242f --- /dev/null +++ b/src/lib/utils/date-input.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "vitest"; + +import { formatDateToLocalYmd, parseYmdToLocalEndOfDay } from "./date-input"; + +describe("parseYmdToLocalEndOfDay", () => { + test("empty/invalid input returns null", () => { + expect(parseYmdToLocalEndOfDay("")).toBeNull(); + expect(parseYmdToLocalEndOfDay("not-a-date")).toBeNull(); + expect(parseYmdToLocalEndOfDay("2026-13-40")).toBeNull(); + }); + + test("parses YYYY-MM-DD as local end-of-day", () => { + const d = parseYmdToLocalEndOfDay("2026-02-11"); + expect(d).not.toBeNull(); + if (!d) return; + + expect(d.getFullYear()).toBe(2026); + expect(d.getMonth()).toBe(1); + expect(d.getDate()).toBe(11); + expect(d.getHours()).toBe(23); + expect(d.getMinutes()).toBe(59); + expect(d.getSeconds()).toBe(59); + expect(d.getMilliseconds()).toBe(999); + }); +}); + +describe("formatDateToLocalYmd", () => { + test("formats Date as local YYYY-MM-DD", () => { + const d = new Date(2026, 1, 11, 12, 0, 0); + expect(formatDateToLocalYmd(d)).toBe("2026-02-11"); + }); + + test("invalid date returns empty string", () => { + expect(formatDateToLocalYmd(new Date("invalid"))).toBe(""); + }); +}); diff --git a/src/lib/utils/date-input.ts b/src/lib/utils/date-input.ts index b872e26c0..77e5e957c 100644 --- a/src/lib/utils/date-input.ts +++ b/src/lib/utils/date-input.ts @@ -61,3 +61,37 @@ export function parseDateInputAsTimezone(input: string, timezone: string): Date // Convert from timezone local time to UTC return fromZonedTime(localDate, timezone); } + +/** + * 将 Date 格式化为本地时区的 YYYY-MM-DD(用于 date-only 输入控件)。 + */ +export function formatDateToLocalYmd(value: Date): string { + if (Number.isNaN(value.getTime())) return ""; + const year = value.getFullYear(); + const month = String(value.getMonth() + 1).padStart(2, "0"); + const day = String(value.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +/** + * 将 YYYY-MM-DD 的纯日期字符串解析为“本地时区当天结束时间”(23:59:59.999)。 + * + * 注意:刻意避免 `new Date("YYYY-MM-DD")`,因为该形式在 JS 中按 UTC 解析, + * 后续再转换为本地时间时可能出现日期偏差(提前/延后一日)。 + */ +export function parseYmdToLocalEndOfDay(input: string): Date | null { + if (!input) return null; + const [year, month, day] = input.split("-").map((v) => Number(v)); + if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) return null; + if (month < 1 || month > 12) return null; + if (day < 1 || day > 31) return null; + + const date = new Date(year, month - 1, day); + if (Number.isNaN(date.getTime())) return null; + if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) { + return null; + } + + date.setHours(23, 59, 59, 999); + return date; +} diff --git a/src/lib/utils/validation/api-key-warnings.test.ts b/src/lib/utils/validation/api-key-warnings.test.ts new file mode 100644 index 000000000..e7eb48a7c --- /dev/null +++ b/src/lib/utils/validation/api-key-warnings.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from "vitest"; + +import { detectApiKeyWarnings } from "./api-key-warnings"; + +describe("detectApiKeyWarnings", () => { + test("空值/空白:应返回空数组", () => { + expect(detectApiKeyWarnings("")).toEqual([]); + expect(detectApiKeyWarnings(" ")).toEqual([]); + }); + + test("包含中文:应提示 contains_non_ascii", () => { + expect(detectApiKeyWarnings("sk-中文")).toContain("contains_non_ascii"); + }); + + test("看起来像 Authorization/Bearer header:应提示 looks_like_auth_header", () => { + expect(detectApiKeyWarnings("Bearer sk-123")).toContain("looks_like_auth_header"); + expect(detectApiKeyWarnings("Authorization: Bearer sk-123")).toContain( + "looks_like_auth_header" + ); + expect(detectApiKeyWarnings("x-api-key: sk-123")).toContain("looks_like_auth_header"); + expect(detectApiKeyWarnings("x-goog-api-key: sk-123")).toContain("looks_like_auth_header"); + }); + + test("被引号包裹:应提示 wrapped_in_quotes", () => { + expect(detectApiKeyWarnings('"sk-123"')).toContain("wrapped_in_quotes"); + expect(detectApiKeyWarnings("'sk-123'")).toContain("wrapped_in_quotes"); + }); + + test("包含空白:非 JSON 时应提示 contains_whitespace", () => { + expect(detectApiKeyWarnings("sk-12 3")).toContain("contains_whitespace"); + expect(detectApiKeyWarnings(" sk-123 ")).toContain("contains_whitespace"); + expect(detectApiKeyWarnings("sk-123\n456")).toContain("contains_whitespace"); + }); + + test("包含不常见 ASCII 符号:非 JSON 时应提示 contains_uncommon_ascii", () => { + expect(detectApiKeyWarnings("sk-123@456")).toContain("contains_uncommon_ascii"); + expect(detectApiKeyWarnings("sk-123;456")).toContain("contains_uncommon_ascii"); + }); + + test("JSON 凭据:不应提示 contains_whitespace(避免误报)", () => { + const json = `{\n "access_token": "ya29.abc"\n}`; + expect(detectApiKeyWarnings(json)).not.toContain("contains_whitespace"); + }); +}); diff --git a/src/lib/utils/validation/api-key-warnings.ts b/src/lib/utils/validation/api-key-warnings.ts new file mode 100644 index 000000000..ff32c5821 --- /dev/null +++ b/src/lib/utils/validation/api-key-warnings.ts @@ -0,0 +1,85 @@ +export type ApiKeyWarningId = + | "looks_like_auth_header" + | "wrapped_in_quotes" + | "contains_non_ascii" + | "contains_whitespace" + | "contains_uncommon_ascii"; + +function isWrappedInQuotes(value: string): boolean { + return ( + (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")) + ); +} + +function looksLikeAuthHeader(value: string): boolean { + const lower = value.toLowerCase(); + return ( + lower.startsWith("bearer ") || + lower.startsWith("authorization:") || + lower.startsWith("x-api-key:") || + lower.startsWith("api-key:") || + lower.startsWith("x-goog-api-key:") + ); +} + +function containsNonAscii(value: string): boolean { + for (const ch of value) { + const code = ch.codePointAt(0); + if (code != null && code > 0x7f) return true; + } + return false; +} + +function containsUncommonAscii(value: string): boolean { + // 常见 token 格式:base64/base64url/jwt 等通常仅由如下字符组成 + // - 字母数字 + // - _ - . + // - base64 的 + / = + // 其它 ASCII 标点大多来自误粘贴(如引号、逗号、分号、@ 等),因此仅作提醒。 + for (const ch of value) { + const code = ch.codePointAt(0); + if (code == null) continue; + if (code > 0x7f) continue; // 非 ASCII 在别处提示 + if (code <= 0x20 || code === 0x7f) continue; // 空白/控制字符在别处提示 + if (/[a-zA-Z0-9._\-+/=]/.test(ch)) continue; + return true; + } + + return false; +} + +/** + * 检测“很可能不是常见 API Key”的输入特征,仅用于 UI 警告(不阻止保存)。 + * + * 注意:某些上游可能允许非 ASCII / 含空白的 key,但一般情况下不常见,因此仅作提醒。 + */ +export function detectApiKeyWarnings(rawKey: string): ApiKeyWarningId[] { + const trimmed = rawKey.trim(); + if (!trimmed) return []; + + const warnings: ApiKeyWarningId[] = []; + + const isLikelyJsonCredentials = trimmed.startsWith("{"); + + if (looksLikeAuthHeader(trimmed)) { + warnings.push("looks_like_auth_header"); + } + + if (isWrappedInQuotes(trimmed)) { + warnings.push("wrapped_in_quotes"); + } + + if (containsNonAscii(trimmed)) { + warnings.push("contains_non_ascii"); + } + + if (!isLikelyJsonCredentials && /\s/.test(rawKey)) { + warnings.push("contains_whitespace"); + } + + if (!isLikelyJsonCredentials && containsUncommonAscii(trimmed)) { + warnings.push("contains_uncommon_ascii"); + } + + return warnings; +} diff --git a/src/lib/utils/validation/quota-lease-warnings.test.ts b/src/lib/utils/validation/quota-lease-warnings.test.ts new file mode 100644 index 000000000..ff5e1598a --- /dev/null +++ b/src/lib/utils/validation/quota-lease-warnings.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "vitest"; + +import { + shouldWarnQuotaDbRefreshIntervalTooHigh, + shouldWarnQuotaDbRefreshIntervalTooLow, + shouldWarnQuotaLeaseCapZero, + shouldWarnQuotaLeasePercentZero, +} from "./quota-lease-warnings"; + +describe("quota-lease-warnings", () => { + test("shouldWarnQuotaDbRefreshIntervalTooLow", () => { + expect(shouldWarnQuotaDbRefreshIntervalTooLow(0)).toBe(false); + expect(shouldWarnQuotaDbRefreshIntervalTooLow(1)).toBe(true); + expect(shouldWarnQuotaDbRefreshIntervalTooLow(2)).toBe(true); + expect(shouldWarnQuotaDbRefreshIntervalTooLow(3)).toBe(false); + expect(shouldWarnQuotaDbRefreshIntervalTooLow(10)).toBe(false); + }); + + test("shouldWarnQuotaDbRefreshIntervalTooHigh", () => { + expect(shouldWarnQuotaDbRefreshIntervalTooHigh(59)).toBe(false); + expect(shouldWarnQuotaDbRefreshIntervalTooHigh(60)).toBe(true); + expect(shouldWarnQuotaDbRefreshIntervalTooHigh(300)).toBe(true); + }); + + test("shouldWarnQuotaLeasePercentZero", () => { + expect(shouldWarnQuotaLeasePercentZero(0)).toBe(true); + expect(shouldWarnQuotaLeasePercentZero(0.01)).toBe(false); + expect(shouldWarnQuotaLeasePercentZero(1)).toBe(false); + }); + + test("shouldWarnQuotaLeaseCapZero", () => { + expect(shouldWarnQuotaLeaseCapZero("")).toBe(false); + expect(shouldWarnQuotaLeaseCapZero(" ")).toBe(false); + expect(shouldWarnQuotaLeaseCapZero("0")).toBe(true); + expect(shouldWarnQuotaLeaseCapZero("0.0")).toBe(true); + expect(shouldWarnQuotaLeaseCapZero("0.01")).toBe(false); + expect(shouldWarnQuotaLeaseCapZero("abc")).toBe(false); + }); +}); diff --git a/src/lib/utils/validation/quota-lease-warnings.ts b/src/lib/utils/validation/quota-lease-warnings.ts new file mode 100644 index 000000000..0eb77c874 --- /dev/null +++ b/src/lib/utils/validation/quota-lease-warnings.ts @@ -0,0 +1,31 @@ +/** + * 仅用于 UI 警告:DB 刷新频率过低可能带来较高 DB 负载(不阻止保存)。 + */ +export function shouldWarnQuotaDbRefreshIntervalTooLow(value: number): boolean { + return value > 0 && value <= 2; +} + +/** + * 仅用于 UI 警告:DB 刷新频率过高可能导致配额/限额更新延迟(不阻止保存)。 + */ +export function shouldWarnQuotaDbRefreshIntervalTooHigh(value: number): boolean { + return value >= 60; +} + +/** + * 仅用于 UI 警告:租约比例为 0 可能导致租约预算始终为 0(不阻止保存)。 + */ +export function shouldWarnQuotaLeasePercentZero(value: number): boolean { + return value === 0; +} + +/** + * 仅用于 UI 警告:租约 cap 为 0 可能导致每次租约预算为 0(不阻止保存)。 + */ +export function shouldWarnQuotaLeaseCapZero(rawValue: string): boolean { + const trimmed = rawValue.trim(); + if (!trimmed) return false; + const parsed = Number.parseFloat(trimmed); + if (!Number.isFinite(parsed)) return false; + return parsed === 0; +} diff --git a/tests/unit/dashboard/user-form-expiry-clear-ui.test.tsx b/tests/unit/dashboard/user-form-expiry-clear-ui.test.tsx index 9e50f7b77..e9f260ffb 100644 --- a/tests/unit/dashboard/user-form-expiry-clear-ui.test.tsx +++ b/tests/unit/dashboard/user-form-expiry-clear-ui.test.tsx @@ -11,6 +11,7 @@ import { NextIntlClientProvider } from "next-intl"; import { beforeEach, describe, expect, test, vi } from "vitest"; import { Dialog } from "@/components/ui/dialog"; import { UserForm } from "@/app/[locale]/dashboard/_components/user/forms/user-form"; +import { formatDateToLocalYmd } from "@/lib/utils/date-input"; vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh: vi.fn() }), @@ -71,7 +72,7 @@ function clickButtonByText(text: string) { const buttons = Array.from(document.body.querySelectorAll("button")); const btn = buttons.find((b) => (b.textContent || "").includes(text)); if (!btn) { - throw new Error(`未找到按钮: ${text}`); + throw new Error(`Button not found: ${text}`); } btn.dispatchEvent(new MouseEvent("click", { bubbles: true })); } @@ -84,6 +85,7 @@ describe("UserForm: 清除 expiresAt 后应提交 null", () => { test("编辑模式:点击 Clear Date 后提交应调用 editUser(..., { expiresAt: null })", async () => { const messages = loadMessages(); const expiresAt = new Date("2026-01-04T23:59:59.999Z"); + const expectedYmd = formatDateToLocalYmd(expiresAt); const { unmount } = render( @@ -97,7 +99,7 @@ describe("UserForm: 清除 expiresAt 后应提交 null", () => { ); await act(async () => { - clickButtonByText("2026-01-04"); + clickButtonByText(expectedYmd); }); await act(async () => { diff --git a/tests/unit/lib/provider-endpoints/probe.test.ts b/tests/unit/lib/provider-endpoints/probe.test.ts index c77b04845..b4842d128 100644 --- a/tests/unit/lib/provider-endpoints/probe.test.ts +++ b/tests/unit/lib/provider-endpoints/probe.test.ts @@ -23,6 +23,15 @@ function makeEndpoint(overrides: Partial): ProviderEndpoint { }; } +function createCircuitBreakerMock(overrides: Partial> = {}) { + return { + getEndpointCircuitStateSync: vi.fn(() => "closed"), + resetEndpointCircuit: vi.fn(async () => {}), + recordEndpointFailure: vi.fn(async () => {}), + ...overrides, + }; +} + afterEach(() => { vi.unstubAllGlobals(); vi.useRealTimers(); @@ -49,9 +58,7 @@ describe("provider-endpoints: probe", () => { recordProviderEndpointProbeResult: vi.fn(), updateProviderEndpointProbeSnapshot: vi.fn(), })); - vi.doMock("@/lib/endpoint-circuit-breaker", () => ({ - recordEndpointFailure: vi.fn(async () => {}), - })); + vi.doMock("@/lib/endpoint-circuit-breaker", () => createCircuitBreakerMock()); const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => { if (init?.method === "HEAD") { @@ -89,9 +96,7 @@ describe("provider-endpoints: probe", () => { recordProviderEndpointProbeResult: vi.fn(), updateProviderEndpointProbeSnapshot: vi.fn(), })); - vi.doMock("@/lib/endpoint-circuit-breaker", () => ({ - recordEndpointFailure: vi.fn(async () => {}), - })); + vi.doMock("@/lib/endpoint-circuit-breaker", () => createCircuitBreakerMock()); const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => { if (init?.method === "HEAD") { @@ -132,9 +137,7 @@ describe("provider-endpoints: probe", () => { recordProviderEndpointProbeResult: vi.fn(), updateProviderEndpointProbeSnapshot: vi.fn(), })); - vi.doMock("@/lib/endpoint-circuit-breaker", () => ({ - recordEndpointFailure: vi.fn(async () => {}), - })); + vi.doMock("@/lib/endpoint-circuit-breaker", () => createCircuitBreakerMock()); vi.stubGlobal( "fetch", @@ -170,9 +173,7 @@ describe("provider-endpoints: probe", () => { recordProviderEndpointProbeResult: vi.fn(), updateProviderEndpointProbeSnapshot: vi.fn(), })); - vi.doMock("@/lib/endpoint-circuit-breaker", () => ({ - recordEndpointFailure: vi.fn(async () => {}), - })); + vi.doMock("@/lib/endpoint-circuit-breaker", () => createCircuitBreakerMock()); vi.stubGlobal( "fetch", @@ -206,9 +207,7 @@ describe("provider-endpoints: probe", () => { recordProviderEndpointProbeResult: vi.fn(), updateProviderEndpointProbeSnapshot: vi.fn(), })); - vi.doMock("@/lib/endpoint-circuit-breaker", () => ({ - recordEndpointFailure: vi.fn(async () => {}), - })); + vi.doMock("@/lib/endpoint-circuit-breaker", () => createCircuitBreakerMock()); const fetchMock = vi.fn(async () => { const err = new Error(""); @@ -251,9 +250,9 @@ describe("provider-endpoints: probe", () => { recordProviderEndpointProbeResult: recordMock, updateProviderEndpointProbeSnapshot: snapshotMock, })); - vi.doMock("@/lib/endpoint-circuit-breaker", () => ({ - recordEndpointFailure: recordFailureMock, - })); + vi.doMock("@/lib/endpoint-circuit-breaker", () => + createCircuitBreakerMock({ recordEndpointFailure: recordFailureMock }) + ); vi.stubGlobal( "fetch", @@ -297,9 +296,9 @@ describe("provider-endpoints: probe", () => { recordProviderEndpointProbeResult: recordMock, updateProviderEndpointProbeSnapshot: snapshotMock, })); - vi.doMock("@/lib/endpoint-circuit-breaker", () => ({ - recordEndpointFailure: recordFailureMock, - })); + vi.doMock("@/lib/endpoint-circuit-breaker", () => + createCircuitBreakerMock({ recordEndpointFailure: recordFailureMock }) + ); vi.stubGlobal( "fetch", @@ -367,9 +366,9 @@ describe("provider-endpoints: probe", () => { findProviderEndpointById: vi.fn(async () => endpoint), recordProviderEndpointProbeResult: recordMock, })); - vi.doMock("@/lib/endpoint-circuit-breaker", () => ({ - recordEndpointFailure: recordFailureMock, - })); + vi.doMock("@/lib/endpoint-circuit-breaker", () => + createCircuitBreakerMock({ recordEndpointFailure: recordFailureMock }) + ); vi.stubGlobal( "fetch", @@ -407,9 +406,9 @@ describe("provider-endpoints: probe", () => { ), recordProviderEndpointProbeResult: recordMock, })); - vi.doMock("@/lib/endpoint-circuit-breaker", () => ({ - recordEndpointFailure: recordFailureMock, - })); + vi.doMock("@/lib/endpoint-circuit-breaker", () => + createCircuitBreakerMock({ recordEndpointFailure: recordFailureMock }) + ); vi.stubGlobal( "fetch", @@ -443,9 +442,7 @@ describe("provider-endpoints: probe", () => { findProviderEndpointById: vi.fn(), recordProviderEndpointProbeResult: vi.fn(), })); - vi.doMock("@/lib/endpoint-circuit-breaker", () => ({ - recordEndpointFailure: vi.fn(async () => {}), - })); + vi.doMock("@/lib/endpoint-circuit-breaker", () => createCircuitBreakerMock()); // Mock net.createConnection to simulate successful TCP connection const mockSocket = { @@ -496,9 +493,7 @@ describe("provider-endpoints: probe", () => { findProviderEndpointById: vi.fn(), recordProviderEndpointProbeResult: vi.fn(), })); - vi.doMock("@/lib/endpoint-circuit-breaker", () => ({ - recordEndpointFailure: vi.fn(async () => {}), - })); + vi.doMock("@/lib/endpoint-circuit-breaker", () => createCircuitBreakerMock()); const mockSocket = { destroy: vi.fn(), @@ -541,9 +536,7 @@ describe("provider-endpoints: probe", () => { findProviderEndpointById: vi.fn(), recordProviderEndpointProbeResult: vi.fn(), })); - vi.doMock("@/lib/endpoint-circuit-breaker", () => ({ - recordEndpointFailure: vi.fn(async () => {}), - })); + vi.doMock("@/lib/endpoint-circuit-breaker", () => createCircuitBreakerMock()); const { probeEndpointUrl } = await import("@/lib/provider-endpoints/probe"); const result = await probeEndpointUrl("not-a-valid-url", 5000); @@ -571,9 +564,7 @@ describe("provider-endpoints: probe", () => { findProviderEndpointById: vi.fn(), recordProviderEndpointProbeResult: vi.fn(), })); - vi.doMock("@/lib/endpoint-circuit-breaker", () => ({ - recordEndpointFailure: vi.fn(async () => {}), - })); + vi.doMock("@/lib/endpoint-circuit-breaker", () => createCircuitBreakerMock()); const mockSocket = { destroy: vi.fn(), From aebb72270635f3581948d4542b0c7d9a7c097714 Mon Sep 17 00:00:00 2001 From: Ding <44717411+ding113@users.noreply.github.com> Date: Thu, 12 Feb 2026 14:19:12 +0800 Subject: [PATCH 5/7] feat(circuit-breaker): endpoint CB default-off + 524 decision chain audit (#773) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(circuit-breaker): endpoint circuit breaker default-off + 524 decision chain audit - Add ENABLE_ENDPOINT_CIRCUIT_BREAKER env var (default: false) to gate endpoint-level circuit breaker - Gate isEndpointCircuitOpen, recordEndpointFailure, recordEndpointSuccess, triggerEndpointCircuitBreakerAlert behind env switch - Add initEndpointCircuitBreaker() startup cleanup: clear stale Redis keys when feature disabled - Gate endpoint filtering in endpoint-selector (getPreferredProviderEndpoints, getEndpointFilterStats) - Fix 524 vendor-type timeout missing from decision chain: add chain entry with reason=vendor_type_all_timeout in forwarder - Add vendor_type_all_timeout to ProviderChainItem reason union type (both backend session.ts and frontend message.ts) - Add timeline rendering for vendor_type_all_timeout in provider-chain-formatter - Replace hardcoded Chinese strings in provider-selector circuit_open details with i18n keys - Add i18n translations for vendor_type_all_timeout and filterDetails (5 languages: zh-CN, zh-TW, en, ja, ru) - Enhance LogicTraceTab to render filterDetails via i18n lookup with fallback - Add endpoint_pool_exhausted and vendor_type_all_timeout to provider-chain-popover isActualRequest/getItemStatus - Add comprehensive unit tests for all changes (endpoint-circuit-breaker, endpoint-selector, provider-chain-formatter) * fix(i18n): fix Russian grammar errors and rate_limited translations - Fix Russian: "конечная точкаов" -> "конечных точек" (11 occurrences) - Fix Russian: "Ограничение стоимости" -> "Ограничение скорости" (rate_limited) - Fix zh-CN: "费用限制" -> "速率限制" (filterDetails.rate_limited) - Fix zh-TW: "費用限制" -> "速率限制" (filterDetails.rate_limited) - Add initEndpointCircuitBreaker() to dev environment in instrumentation.ts --- .env.example | 6 + messages/en/provider-chain.json | 16 +- messages/ja/provider-chain.json | 16 +- messages/ru/provider-chain.json | 30 ++- messages/zh-CN/provider-chain.json | 16 +- messages/zh-TW/provider-chain.json | 16 +- scripts/deploy.ps1 | 1 + scripts/deploy.sh | 1 + .../components/LogicTraceTab.tsx | 8 +- .../_components/provider-chain-popover.tsx | 10 + src/app/v1/_lib/proxy/forwarder.ts | 20 ++ src/app/v1/_lib/proxy/provider-selector.ts | 6 +- src/app/v1/_lib/proxy/session.ts | 3 +- src/instrumentation.ts | 20 ++ src/lib/config/env.schema.ts | 4 + src/lib/endpoint-circuit-breaker.ts | 65 ++++++ .../provider-endpoints/endpoint-selector.ts | 12 + .../utils/provider-chain-formatter.test.ts | 104 +++++++++ src/lib/utils/provider-chain-formatter.ts | 56 ++++- src/types/message.ts | 3 +- .../unit/lib/endpoint-circuit-breaker.test.ts | 212 +++++++++++++++++- .../endpoint-selector.test.ts | 90 ++++++++ 22 files changed, 674 insertions(+), 41 deletions(-) diff --git a/.env.example b/.env.example index 3fc6e5611..5e2c5031e 100644 --- a/.env.example +++ b/.env.example @@ -88,6 +88,12 @@ STORE_SESSION_RESPONSE_BODY=true # 是否在 Redis 中存储会话响应 # - 启用:适用于网络稳定环境,连续网络错误也应触发熔断保护,避免持续请求不可达的供应商 ENABLE_CIRCUIT_BREAKER_ON_NETWORK_ERRORS=false +# 端点级别熔断器 +# 功能说明:控制是否启用端点级别的熔断器 +# - false (默认):禁用端点熔断器,所有启用的端点均可使用 +# - true:启用端点熔断器,连续失败的端点会被临时屏蔽(默认 3 次失败后熔断 5 分钟) +ENABLE_ENDPOINT_CIRCUIT_BREAKER=false + # 供应商缓存配置 # 功能说明:控制是否启用供应商进程级缓存 # - true (默认):启用缓存,30s TTL + Redis Pub/Sub 跨实例即时失效,提升供应商查询性能 diff --git a/messages/en/provider-chain.json b/messages/en/provider-chain.json index a347e8dc8..e8f6678a5 100644 --- a/messages/en/provider-chain.json +++ b/messages/en/provider-chain.json @@ -38,7 +38,8 @@ "concurrentLimit": "Concurrent Limit", "http2Fallback": "HTTP/2 Fallback", "clientError": "Client Error", - "endpointPoolExhausted": "Endpoint Pool Exhausted" + "endpointPoolExhausted": "Endpoint Pool Exhausted", + "vendorTypeAllTimeout": "Vendor-Type All Endpoints Timeout" }, "reasons": { "request_success": "Success", @@ -50,7 +51,8 @@ "http2_fallback": "HTTP/2 Fallback", "session_reuse": "Session Reuse", "initial_selection": "Initial Selection", - "endpoint_pool_exhausted": "Endpoint Pool Exhausted" + "endpoint_pool_exhausted": "Endpoint Pool Exhausted", + "vendor_type_all_timeout": "Vendor-Type All Endpoints Timeout" }, "filterReasons": { "rate_limited": "Rate Limited", @@ -67,6 +69,12 @@ "endpoint_circuit_open": "Endpoint Circuit Open", "endpoint_disabled": "Endpoint Disabled" }, + "filterDetails": { + "vendor_type_circuit_open": "Vendor-type temporarily circuit-broken", + "circuit_open": "Circuit breaker open", + "circuit_half_open": "Circuit breaker half-open", + "rate_limited": "Rate limited" + }, "details": { "selectionMethod": "Selection", "attemptNumber": "Attempt", @@ -197,6 +205,8 @@ "endpointStatsCircuitOpen": "Circuit-Open Endpoints: {count}", "endpointStatsAvailable": "Available Endpoints: {count}", "strictBlockNoEndpoints": "Strict mode: no endpoint candidates available, provider skipped without fallback", - "strictBlockSelectorError": "Strict mode: endpoint selector encountered an error, provider skipped without fallback" + "strictBlockSelectorError": "Strict mode: endpoint selector encountered an error, provider skipped without fallback", + "vendorTypeAllTimeout": "Vendor-Type All Endpoints Timeout (524)", + "vendorTypeAllTimeoutNote": "All endpoints for this vendor-type timed out. Vendor-type circuit breaker triggered." } } diff --git a/messages/ja/provider-chain.json b/messages/ja/provider-chain.json index 37adb84f9..cf9ebdb78 100644 --- a/messages/ja/provider-chain.json +++ b/messages/ja/provider-chain.json @@ -38,7 +38,8 @@ "concurrentLimit": "同時実行制限", "http2Fallback": "HTTP/2 フォールバック", "clientError": "クライアントエラー", - "endpointPoolExhausted": "エンドポイントプール枯渇" + "endpointPoolExhausted": "エンドポイントプール枯渇", + "vendorTypeAllTimeout": "ベンダータイプ全エンドポイントタイムアウト" }, "reasons": { "request_success": "成功", @@ -50,7 +51,8 @@ "http2_fallback": "HTTP/2 フォールバック", "session_reuse": "セッション再利用", "initial_selection": "初期選択", - "endpoint_pool_exhausted": "エンドポイントプール枯渇" + "endpoint_pool_exhausted": "エンドポイントプール枯渇", + "vendor_type_all_timeout": "ベンダータイプ全エンドポイントタイムアウト" }, "filterReasons": { "rate_limited": "レート制限", @@ -67,6 +69,12 @@ "endpoint_circuit_open": "エンドポイントサーキットオープン", "endpoint_disabled": "エンドポイント無効" }, + "filterDetails": { + "vendor_type_circuit_open": "ベンダータイプ一時サーキットブレイク", + "circuit_open": "サーキットブレーカーオープン", + "circuit_half_open": "サーキットブレーカーハーフオープン", + "rate_limited": "レート制限" + }, "details": { "selectionMethod": "選択方法", "attemptNumber": "試行回数", @@ -197,6 +205,8 @@ "endpointStatsCircuitOpen": "サーキットオープンのエンドポイント: {count}", "endpointStatsAvailable": "利用可能なエンドポイント: {count}", "strictBlockNoEndpoints": "厳格モード:利用可能なエンドポイント候補がないため、フォールバックなしでプロバイダーをスキップ", - "strictBlockSelectorError": "厳格モード:エンドポイントセレクターでエラーが発生したため、フォールバックなしでプロバイダーをスキップ" + "strictBlockSelectorError": "厳格モード:エンドポイントセレクターでエラーが発生したため、フォールバックなしでプロバイダーをスキップ", + "vendorTypeAllTimeout": "ベンダータイプ全エンドポイントタイムアウト(524)", + "vendorTypeAllTimeoutNote": "このベンダータイプの全エンドポイントがタイムアウトしました。ベンダータイプサーキットブレーカーが発動しました。" } } diff --git a/messages/ru/provider-chain.json b/messages/ru/provider-chain.json index e37650b04..c123208b8 100644 --- a/messages/ru/provider-chain.json +++ b/messages/ru/provider-chain.json @@ -38,7 +38,8 @@ "concurrentLimit": "Лимит параллельных запросов", "http2Fallback": "Откат HTTP/2", "clientError": "Ошибка клиента", - "endpointPoolExhausted": "Пул конечная точкаов исчерпан" + "endpointPoolExhausted": "Пул конечных точек исчерпан", + "vendorTypeAllTimeout": "Тайм-аут всех конечных точек" }, "reasons": { "request_success": "Успешно", @@ -50,7 +51,8 @@ "http2_fallback": "Откат HTTP/2", "session_reuse": "Повторное использование сессии", "initial_selection": "Первоначальный выбор", - "endpoint_pool_exhausted": "Пул конечная точкаов исчерпан" + "endpoint_pool_exhausted": "Пул конечных точек исчерпан", + "vendor_type_all_timeout": "Тайм-аут всех конечных точек типа поставщика" }, "filterReasons": { "rate_limited": "Ограничение скорости", @@ -64,9 +66,15 @@ "model_not_supported": "Модель не поддерживается", "group_mismatch": "Несоответствие группы", "health_check_failed": "Проверка состояния не пройдена", - "endpoint_circuit_open": "Автомат конечная точкаа открыт", + "endpoint_circuit_open": "Автомат конечной точки открыт", "endpoint_disabled": "Эндпоинт отключен" }, + "filterDetails": { + "vendor_type_circuit_open": "Временное размыкание типа поставщика", + "circuit_open": "Размыкатель открыт", + "circuit_half_open": "Размыкатель полуоткрыт", + "rate_limited": "Ограничение скорости" + }, "details": { "selectionMethod": "Метод выбора", "attemptNumber": "Номер попытки", @@ -190,13 +198,15 @@ "ruleDescription": "Описание: {description}", "ruleHasOverride": "Переопределения: response={response}, statusCode={statusCode}", "clientErrorNote": "Эта ошибка вызвана вводом клиента, не повторяется и не учитывается в автомате защиты.", - "endpointPoolExhausted": "Пул конечная точкаов исчерпан (все конечная точкаы недоступны)", - "endpointStats": "Статистика фильтрации конечная точкаов", - "endpointStatsTotal": "Всего конечная точкаов: {count}", - "endpointStatsEnabled": "Включено конечная точкаов: {count}", + "endpointPoolExhausted": "Пул конечных точек исчерпан (все конечные точки недоступны)", + "endpointStats": "Статистика фильтрации конечных точек", + "endpointStatsTotal": "Всего конечных точек: {count}", + "endpointStatsEnabled": "Включено конечных точек: {count}", "endpointStatsCircuitOpen": "Эндпоинтов с открытым автоматом: {count}", - "endpointStatsAvailable": "Доступных конечная точкаов: {count}", - "strictBlockNoEndpoints": "Строгий режим: нет доступных кандидатов конечная точкаов, провайдер пропущен без отката", - "strictBlockSelectorError": "Строгий режим: ошибка селектора конечная точкаов, провайдер пропущен без отката" + "endpointStatsAvailable": "Доступных конечных точек: {count}", + "strictBlockNoEndpoints": "Строгий режим: нет доступных кандидатов конечных точек, провайдер пропущен без отката", + "strictBlockSelectorError": "Строгий режим: ошибка селектора конечных точек, провайдер пропущен без отката", + "vendorTypeAllTimeout": "Тайм-аут всех конечных точек типа поставщика (524)", + "vendorTypeAllTimeoutNote": "Все конечные точки этого типа поставщика превысили тайм-аут. Активирован размыкатель типа поставщика." } } diff --git a/messages/zh-CN/provider-chain.json b/messages/zh-CN/provider-chain.json index fe75d85a1..8691d9aa1 100644 --- a/messages/zh-CN/provider-chain.json +++ b/messages/zh-CN/provider-chain.json @@ -38,7 +38,8 @@ "concurrentLimit": "并发限制", "http2Fallback": "HTTP/2 回退", "clientError": "客户端错误", - "endpointPoolExhausted": "端点池耗尽" + "endpointPoolExhausted": "端点池耗尽", + "vendorTypeAllTimeout": "供应商类型全端点超时" }, "reasons": { "request_success": "成功", @@ -50,7 +51,8 @@ "http2_fallback": "HTTP/2 回退", "session_reuse": "会话复用", "initial_selection": "首次选择", - "endpoint_pool_exhausted": "端点池耗尽" + "endpoint_pool_exhausted": "端点池耗尽", + "vendor_type_all_timeout": "供应商类型全端点超时" }, "filterReasons": { "rate_limited": "速率限制", @@ -67,6 +69,12 @@ "endpoint_circuit_open": "端点已熔断", "endpoint_disabled": "端点已禁用" }, + "filterDetails": { + "vendor_type_circuit_open": "供应商类型临时熔断", + "circuit_open": "熔断器打开", + "circuit_half_open": "熔断器半开", + "rate_limited": "速率限制" + }, "details": { "selectionMethod": "选择方式", "attemptNumber": "尝试次数", @@ -197,6 +205,8 @@ "endpointStatsCircuitOpen": "已熔断端点: {count}", "endpointStatsAvailable": "可用端点: {count}", "strictBlockNoEndpoints": "严格模式:无可用端点候选,跳过该供应商且不降级", - "strictBlockSelectorError": "严格模式:端点选择器发生错误,跳过该供应商且不降级" + "strictBlockSelectorError": "严格模式:端点选择器发生错误,跳过该供应商且不降级", + "vendorTypeAllTimeout": "供应商类型全端点超时(524)", + "vendorTypeAllTimeoutNote": "该供应商类型的所有端点均超时,已触发供应商类型临时熔断。" } } diff --git a/messages/zh-TW/provider-chain.json b/messages/zh-TW/provider-chain.json index 04aa28488..699b37bc6 100644 --- a/messages/zh-TW/provider-chain.json +++ b/messages/zh-TW/provider-chain.json @@ -38,7 +38,8 @@ "concurrentLimit": "並發限制", "http2Fallback": "HTTP/2 回退", "clientError": "客戶端錯誤", - "endpointPoolExhausted": "端點池耗盡" + "endpointPoolExhausted": "端點池耗盡", + "vendorTypeAllTimeout": "供應商類型全端點逾時" }, "reasons": { "request_success": "成功", @@ -50,7 +51,8 @@ "http2_fallback": "HTTP/2 回退", "session_reuse": "會話複用", "initial_selection": "首次選擇", - "endpoint_pool_exhausted": "端點池耗盡" + "endpoint_pool_exhausted": "端點池耗盡", + "vendor_type_all_timeout": "供應商類型全端點逾時" }, "filterReasons": { "rate_limited": "速率限制", @@ -67,6 +69,12 @@ "endpoint_circuit_open": "端點已熔斷", "endpoint_disabled": "端點已停用" }, + "filterDetails": { + "vendor_type_circuit_open": "供應商類型臨時熔斷", + "circuit_open": "熔斷器打開", + "circuit_half_open": "熔斷器半開", + "rate_limited": "速率限制" + }, "details": { "selectionMethod": "選擇方式", "attemptNumber": "嘗試次數", @@ -197,6 +205,8 @@ "endpointStatsCircuitOpen": "已熔斷端點: {count}", "endpointStatsAvailable": "可用端點: {count}", "strictBlockNoEndpoints": "嚴格模式:無可用端點候選,跳過該供應商且不降級", - "strictBlockSelectorError": "嚴格模式:端點選擇器發生錯誤,跳過該供應商且不降級" + "strictBlockSelectorError": "嚴格模式:端點選擇器發生錯誤,跳過該供應商且不降級", + "vendorTypeAllTimeout": "供應商類型全端點逾時(524)", + "vendorTypeAllTimeoutNote": "該供應商類型的所有端點均逾時,已觸發供應商類型臨時熔斷。" } } diff --git a/scripts/deploy.ps1 b/scripts/deploy.ps1 index d7f1e41de..d8a353200 100644 --- a/scripts/deploy.ps1 +++ b/scripts/deploy.ps1 @@ -503,6 +503,7 @@ ENABLE_SECURE_COOKIES=$secureCookies # Circuit Breaker Configuration ENABLE_CIRCUIT_BREAKER_ON_NETWORK_ERRORS=false +ENABLE_ENDPOINT_CIRCUIT_BREAKER=false # Environment NODE_ENV=production diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 51e457a8e..b777a333c 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -585,6 +585,7 @@ ENABLE_SECURE_COOKIES=${secure_cookies} # Circuit Breaker Configuration ENABLE_CIRCUIT_BREAKER_ON_NETWORK_ERRORS=false +ENABLE_ENDPOINT_CIRCUIT_BREAKER=false # Environment NODE_ENV=production diff --git a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/LogicTraceTab.tsx b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/LogicTraceTab.tsx index d732f846b..2a10408ec 100644 --- a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/LogicTraceTab.tsx +++ b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/LogicTraceTab.tsx @@ -353,7 +353,13 @@ export function LogicTraceTab({ {tChain(`filterReasons.${p.reason}`)} {p.details && ( - ({p.details}) + + ( + {tChain.has(`filterDetails.${p.details}`) + ? tChain(`filterDetails.${p.details}`) + : p.details} + ) + )} ))} diff --git a/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.tsx b/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.tsx index 7a2c99a76..cdc061212 100644 --- a/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.tsx +++ b/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.tsx @@ -34,6 +34,9 @@ interface ProviderChainPopoverProps { function isActualRequest(item: ProviderChainItem): boolean { if (item.reason === "concurrent_limit_failed") return true; if (item.reason === "retry_failed" || item.reason === "system_error") return true; + if (item.reason === "endpoint_pool_exhausted") return true; + if (item.reason === "vendor_type_all_timeout") return true; + if (item.reason === "client_error_non_retryable") return true; if ((item.reason === "request_success" || item.reason === "retry_success") && item.statusCode) { return true; } @@ -89,6 +92,13 @@ function getItemStatus(item: ProviderChainItem): { bgColor: "bg-orange-50 dark:bg-orange-950/30", }; } + if (item.reason === "endpoint_pool_exhausted" || item.reason === "vendor_type_all_timeout") { + return { + icon: XCircle, + color: "text-rose-600", + bgColor: "bg-rose-50 dark:bg-rose-950/30", + }; + } return { icon: RefreshCw, color: "text-slate-500", diff --git a/src/app/v1/_lib/proxy/forwarder.ts b/src/app/v1/_lib/proxy/forwarder.ts index c33082343..18a430d19 100644 --- a/src/app/v1/_lib/proxy/forwarder.ts +++ b/src/app/v1/_lib/proxy/forwarder.ts @@ -1407,6 +1407,26 @@ export class ProxyForwarder { allEndpointAttemptsTimedOut && currentProvider.providerVendorId ) { + // Record to decision chain BEFORE triggering vendor-type circuit breaker + session.addProviderToChain(currentProvider, { + ...endpointAudit, + reason: "vendor_type_all_timeout", + attemptNumber: attemptCount, + statusCode: 524, + errorMessage: errorMessage, + errorDetails: { + provider: { + id: currentProvider.id, + name: currentProvider.name, + statusCode: 524, + statusText: proxyError.message, + upstreamBody: proxyError.upstreamError?.body, + upstreamParsed: proxyError.upstreamError?.parsed, + }, + request: buildRequestDetails(session), + }, + }); + await recordVendorTypeAllEndpointsTimeout( currentProvider.providerVendorId, currentProvider.providerType diff --git a/src/app/v1/_lib/proxy/provider-selector.ts b/src/app/v1/_lib/proxy/provider-selector.ts index 1f6c62a12..9f108cd80 100644 --- a/src/app/v1/_lib/proxy/provider-selector.ts +++ b/src/app/v1/_lib/proxy/provider-selector.ts @@ -885,7 +885,7 @@ export class ProxyProviderResolver { id: p.id, name: p.name, reason: "circuit_open", - details: "供应商类型临时熔断", + details: "vendor_type_circuit_open", }); continue; } @@ -896,14 +896,14 @@ export class ProxyProviderResolver { id: p.id, name: p.name, reason: "circuit_open", - details: `熔断器${state === "open" ? "打开" : "半开"}`, + details: state === "open" ? "circuit_open" : "circuit_half_open", }); } else { context.filteredProviders?.push({ id: p.id, name: p.name, reason: "rate_limited", - details: "费用限制", + details: "rate_limited", }); } } diff --git a/src/app/v1/_lib/proxy/session.ts b/src/app/v1/_lib/proxy/session.ts index 5e93b76f3..22cf12dca 100644 --- a/src/app/v1/_lib/proxy/session.ts +++ b/src/app/v1/_lib/proxy/session.ts @@ -460,7 +460,8 @@ export class ProxySession { | "retry_with_cached_instructions" // Codex instructions 智能重试(缓存) | "client_error_non_retryable" // 不可重试的客户端错误(Prompt 超限、内容过滤、PDF 限制、Thinking 格式) | "http2_fallback" // HTTP/2 协议错误,回退到 HTTP/1.1(不切换供应商、不计入熔断器) - | "endpoint_pool_exhausted"; // 端点池耗尽(strict endpoint policy 阻止了 fallback) + | "endpoint_pool_exhausted" // 端点池耗尽(strict endpoint policy 阻止了 fallback) + | "vendor_type_all_timeout"; // 供应商类型全端点超时(524),触发 vendor-type 临时熔断 selectionMethod?: | "session_reuse" | "weighted_random" diff --git a/src/instrumentation.ts b/src/instrumentation.ts index d81b33ae9..a3303e51d 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -349,6 +349,16 @@ export async function register() { }); } + // 初始化端点熔断器(禁用时清理残留状态) + try { + const { initEndpointCircuitBreaker } = await import("@/lib/endpoint-circuit-breaker"); + await initEndpointCircuitBreaker(); + } catch (error) { + logger.warn("[Instrumentation] Failed to initialize endpoint circuit breaker", { + error: error instanceof Error ? error.message : String(error), + }); + } + try { const { startEndpointProbeLogCleanup } = await import( "@/lib/provider-endpoints/probe-log-cleanup" @@ -456,6 +466,16 @@ export async function register() { }); } + // 初始化端点熔断器(禁用时清理残留状态) + try { + const { initEndpointCircuitBreaker } = await import("@/lib/endpoint-circuit-breaker"); + await initEndpointCircuitBreaker(); + } catch (error) { + logger.warn("[Instrumentation] Failed to initialize endpoint circuit breaker", { + error: error instanceof Error ? error.message : String(error), + }); + } + try { const { startEndpointProbeLogCleanup } = await import( "@/lib/provider-endpoints/probe-log-cleanup" diff --git a/src/lib/config/env.schema.ts b/src/lib/config/env.schema.ts index b120fd8c8..a845a0db5 100644 --- a/src/lib/config/env.schema.ts +++ b/src/lib/config/env.schema.ts @@ -110,6 +110,10 @@ export const EnvSchema = z.object({ LOG_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace"]).default("info"), TZ: z.string().default("Asia/Shanghai"), ENABLE_CIRCUIT_BREAKER_ON_NETWORK_ERRORS: z.string().default("false").transform(booleanTransform), + // 端点级别熔断器开关 + // - false (默认):禁用端点熔断器,所有端点均可使用 + // - true:启用端点熔断器,连续失败的端点会被临时屏蔽 + ENABLE_ENDPOINT_CIRCUIT_BREAKER: z.string().default("false").transform(booleanTransform), // 供应商缓存开关 // - true (默认):启用进程级缓存,30s TTL,提升供应商查询性能 // - false:禁用缓存,每次请求直接查询数据库 diff --git a/src/lib/endpoint-circuit-breaker.ts b/src/lib/endpoint-circuit-breaker.ts index e65696fa6..b3560421b 100644 --- a/src/lib/endpoint-circuit-breaker.ts +++ b/src/lib/endpoint-circuit-breaker.ts @@ -114,6 +114,11 @@ export async function getEndpointHealthInfo( } export async function isEndpointCircuitOpen(endpointId: number): Promise { + const { getEnvConfig } = await import("@/lib/config/env.schema"); + if (!getEnvConfig().ENABLE_ENDPOINT_CIRCUIT_BREAKER) { + return false; + } + const health = await getOrCreateHealth(endpointId); if (health.circuitState === "closed") { @@ -135,6 +140,11 @@ export async function isEndpointCircuitOpen(endpointId: number): Promise { + const { getEnvConfig } = await import("@/lib/config/env.schema"); + if (!getEnvConfig().ENABLE_ENDPOINT_CIRCUIT_BREAKER) { + return; + } + const health = await getOrCreateHealth(endpointId); const config = DEFAULT_ENDPOINT_CIRCUIT_BREAKER_CONFIG; @@ -178,6 +188,11 @@ export async function recordEndpointFailure(endpointId: number, error: Error): P } export async function recordEndpointSuccess(endpointId: number): Promise { + const { getEnvConfig } = await import("@/lib/config/env.schema"); + if (!getEnvConfig().ENABLE_ENDPOINT_CIRCUIT_BREAKER) { + return; + } + const health = await getOrCreateHealth(endpointId); const config = DEFAULT_ENDPOINT_CIRCUIT_BREAKER_CONFIG; @@ -240,6 +255,11 @@ export async function triggerEndpointCircuitBreakerAlert( retryAt: string, lastError: string ): Promise { + const { getEnvConfig } = await import("@/lib/config/env.schema"); + if (!getEnvConfig().ENABLE_ENDPOINT_CIRCUIT_BREAKER) { + return; + } + try { const { sendCircuitBreakerAlert } = await import("@/lib/notification/notifier"); @@ -280,3 +300,48 @@ export async function triggerEndpointCircuitBreakerAlert( }); } } + +/** + * Startup initialization: when ENABLE_ENDPOINT_CIRCUIT_BREAKER is disabled, + * clear all endpoint circuit breaker states from both in-memory map and Redis + * to ensure no stale open states block endpoints. + * + * Called once at application startup. + */ +export async function initEndpointCircuitBreaker(): Promise { + const { getEnvConfig } = await import("@/lib/config/env.schema"); + if (getEnvConfig().ENABLE_ENDPOINT_CIRCUIT_BREAKER) { + return; + } + + healthMap.clear(); + loadedFromRedis.clear(); + + try { + const { getRedisClient } = await import("@/lib/redis/client"); + const redis = getRedisClient(); + if (!redis) return; + + const pattern = "endpoint_circuit_breaker:state:*"; + let cursor = "0"; + let deletedCount = 0; + do { + const [nextCursor, keys] = await redis.scan(cursor, "MATCH", pattern, "COUNT", 100); + cursor = nextCursor; + if (keys.length > 0) { + await redis.del(...keys); + deletedCount += keys.length; + } + } while (cursor !== "0"); + + if (deletedCount > 0) { + logger.info("[EndpointCircuitBreaker] Cleared stale states on startup (feature disabled)", { + deletedCount, + }); + } + } catch (error) { + logger.warn("[EndpointCircuitBreaker] Failed to clear stale states on startup", { + error: error instanceof Error ? error.message : String(error), + }); + } +} diff --git a/src/lib/provider-endpoints/endpoint-selector.ts b/src/lib/provider-endpoints/endpoint-selector.ts index cda4ddd3a..dda76af15 100644 --- a/src/lib/provider-endpoints/endpoint-selector.ts +++ b/src/lib/provider-endpoints/endpoint-selector.ts @@ -41,6 +41,12 @@ export async function getPreferredProviderEndpoints(input: { return []; } + // When endpoint circuit breaker is disabled, skip circuit check entirely + const { getEnvConfig } = await import("@/lib/config/env.schema"); + if (!getEnvConfig().ENABLE_ENDPOINT_CIRCUIT_BREAKER) { + return rankProviderEndpoints(filtered); + } + const circuitResults = await Promise.all( filtered.map(async (endpoint) => ({ endpoint, @@ -74,6 +80,12 @@ export async function getEndpointFilterStats(input: { const total = endpoints.length; const enabled = endpoints.filter((e) => e.isEnabled && !e.deletedAt).length; + // When endpoint circuit breaker is disabled, no endpoints can be circuit-open + const { getEnvConfig } = await import("@/lib/config/env.schema"); + if (!getEnvConfig().ENABLE_ENDPOINT_CIRCUIT_BREAKER) { + return { total, enabled, circuitOpen: 0, available: enabled }; + } + const circuitResults = await Promise.all( endpoints .filter((e) => e.isEnabled && !e.deletedAt) diff --git a/src/lib/utils/provider-chain-formatter.test.ts b/src/lib/utils/provider-chain-formatter.test.ts index 8caf5ed97..ace105ca8 100644 --- a/src/lib/utils/provider-chain-formatter.test.ts +++ b/src/lib/utils/provider-chain-formatter.test.ts @@ -271,6 +271,110 @@ describe("endpoint_pool_exhausted", () => { }); }); +// ============================================================================= +// vendor_type_all_timeout reason tests +// ============================================================================= + +describe("vendor_type_all_timeout", () => { + // --------------------------------------------------------------------------- + // Shared fixtures + // --------------------------------------------------------------------------- + const vendorTypeTimeoutItem: ProviderChainItem = { + id: 1, + name: "provider-timeout", + reason: "vendor_type_all_timeout", + timestamp: 1000, + statusCode: 524, + attemptNumber: 1, + errorMessage: "All endpoints timed out", + errorDetails: { + provider: { + id: 1, + name: "provider-timeout", + statusCode: 524, + statusText: "Origin Time-out", + }, + request: { + method: "POST", + url: "https://api.example.com/v1/messages", + headers: "content-type: application/json", + }, + }, + }; + + const vendorTypeTimeoutNoDetails: ProviderChainItem = { + id: 1, + name: "provider-timeout", + reason: "vendor_type_all_timeout", + timestamp: 1000, + statusCode: 524, + errorMessage: "All endpoints timed out", + }; + + // --------------------------------------------------------------------------- + // formatProviderSummary + // --------------------------------------------------------------------------- + + describe("formatProviderSummary", () => { + test("renders vendor_type_all_timeout with failure mark", () => { + const chain: ProviderChainItem[] = [vendorTypeTimeoutItem]; + const result = formatProviderSummary(chain, mockT); + + expect(result).toContain("provider-timeout"); + expect(result).toContain("\u2717"); + }); + }); + + // --------------------------------------------------------------------------- + // formatProviderDescription + // --------------------------------------------------------------------------- + + describe("formatProviderDescription", () => { + test("shows vendor type all timeout label", () => { + const chain: ProviderChainItem[] = [vendorTypeTimeoutItem]; + const result = formatProviderDescription(chain, mockT); + + expect(result).toContain("description.vendorTypeAllTimeout"); + }); + }); + + // --------------------------------------------------------------------------- + // formatProviderTimeline + // --------------------------------------------------------------------------- + + describe("formatProviderTimeline", () => { + test("renders vendor_type_all_timeout with provider, statusCode, error, and note", () => { + const chain: ProviderChainItem[] = [vendorTypeTimeoutItem]; + const { timeline } = formatProviderTimeline(chain, mockT); + + // Title + expect(timeline).toContain("timeline.vendorTypeAllTimeout"); + // Provider + expect(timeline).toContain("timeline.provider [provider=provider-timeout]"); + // Status code + expect(timeline).toContain("timeline.statusCode [code=524]"); + // Error from statusText + expect(timeline).toContain("timeline.error [error=Origin Time-out]"); + // Note + expect(timeline).toContain("timeline.vendorTypeAllTimeoutNote"); + }); + + test("renders vendor_type_all_timeout without error details", () => { + const chain: ProviderChainItem[] = [vendorTypeTimeoutNoDetails]; + const { timeline } = formatProviderTimeline(chain, mockT); + + // Should still render without crashing + expect(timeline).toContain("timeline.vendorTypeAllTimeout"); + // Falls back to item-level fields + expect(timeline).toContain("timeline.provider [provider=provider-timeout]"); + expect(timeline).toContain("timeline.statusCode [code=524]"); + expect(timeline).toContain("timeline.error [error=All endpoints timed out]"); + // Note is always present + expect(timeline).toContain("timeline.vendorTypeAllTimeoutNote"); + }); + }); +}); + // ============================================================================= // Unknown reason graceful degradation // ============================================================================= diff --git a/src/lib/utils/provider-chain-formatter.ts b/src/lib/utils/provider-chain-formatter.ts index 5369bf1b0..46d2f4e24 100644 --- a/src/lib/utils/provider-chain-formatter.ts +++ b/src/lib/utils/provider-chain-formatter.ts @@ -64,7 +64,8 @@ function getProviderStatus(item: ProviderChainItem): "✓" | "✗" | "⚡" | " item.reason === "retry_failed" || item.reason === "system_error" || item.reason === "client_error_non_retryable" || - item.reason === "endpoint_pool_exhausted" + item.reason === "endpoint_pool_exhausted" || + item.reason === "vendor_type_all_timeout" ) { return "✗"; } @@ -92,7 +93,8 @@ function isActualRequest(item: ProviderChainItem): boolean { item.reason === "retry_failed" || item.reason === "system_error" || item.reason === "client_error_non_retryable" || - item.reason === "endpoint_pool_exhausted" + item.reason === "endpoint_pool_exhausted" || + item.reason === "vendor_type_all_timeout" ) { return true; } @@ -313,6 +315,8 @@ export function formatProviderDescription( desc += ` ${t("description.clientError")}`; } else if (item.reason === "endpoint_pool_exhausted") { desc += ` ${t("description.endpointPoolExhausted")}`; + } else if (item.reason === "vendor_type_all_timeout") { + desc += ` ${t("description.vendorTypeAllTimeout")}`; } desc += "\n"; @@ -408,7 +412,12 @@ export function formatProviderTimeline( timeline += `\n${t("timeline.filtered")}:\n`; for (const f of ctx.filteredProviders) { const icon = f.reason === "circuit_open" ? "⚡" : "💰"; - timeline += ` ${icon} ${f.name} (${f.details || f.reason})\n`; + const detailsText = f.details + ? t(`filterDetails.${f.details}`) !== `filterDetails.${f.details}` + ? t(`filterDetails.${f.details}`) + : f.details + : f.reason; + timeline += ` ${icon} ${f.name} (${detailsText})\n`; } } @@ -742,6 +751,47 @@ export function formatProviderTimeline( continue; } + // === 供应商类型全端点超时(524) === + if (item.reason === "vendor_type_all_timeout") { + timeline += `${t("timeline.vendorTypeAllTimeout")}\n\n`; + + if (item.errorDetails?.provider) { + const p = item.errorDetails.provider; + timeline += `${t("timeline.provider", { provider: p.name })}\n`; + timeline += `${t("timeline.statusCode", { code: p.statusCode })}\n`; + timeline += `${t("timeline.error", { error: p.statusText })}\n`; + + if (i > 0 && item.timestamp && chain[i - 1]?.timestamp) { + const duration = item.timestamp - (chain[i - 1]?.timestamp || 0); + timeline += `${t("timeline.requestDuration", { duration })}\n`; + } + + if (p.upstreamParsed) { + timeline += `\n${t("timeline.errorDetails")}:\n`; + timeline += JSON.stringify(p.upstreamParsed, null, 2); + } else if (p.upstreamBody) { + timeline += `\n${t("timeline.errorDetails")}:\n${p.upstreamBody}`; + } + + if (item.errorDetails?.request) { + timeline += formatRequestDetails(item.errorDetails.request, t); + } + } else { + timeline += `${t("timeline.provider", { provider: item.name })}\n`; + if (item.statusCode) { + timeline += `${t("timeline.statusCode", { code: item.statusCode })}\n`; + } + timeline += `${t("timeline.error", { error: item.errorMessage || t("timeline.unknown") })}\n`; + + if (item.errorDetails?.request) { + timeline += formatRequestDetails(item.errorDetails.request, t); + } + } + + timeline += `\n${t("timeline.vendorTypeAllTimeoutNote")}`; + continue; + } + // 并发限制失败 if (item.reason === "concurrent_limit_failed") { timeline += `${t("timeline.attemptFailed", { attempt: actualAttemptNumber ?? 0 })}\n\n`; diff --git a/src/types/message.ts b/src/types/message.ts index ee3784ed8..56fab4abd 100644 --- a/src/types/message.ts +++ b/src/types/message.ts @@ -33,7 +33,8 @@ export interface ProviderChainItem { | "retry_with_cached_instructions" // Codex instructions 智能重试(缓存) | "client_error_non_retryable" // 不可重试的客户端错误(Prompt 超限、内容过滤、PDF 限制、Thinking 格式) | "http2_fallback" // HTTP/2 协议错误,回退到 HTTP/1.1(不切换供应商、不计入熔断器) - | "endpoint_pool_exhausted"; // 端点池耗尽(所有端点熔断或不可用,严格模式阻止降级) + | "endpoint_pool_exhausted" // 端点池耗尽(所有端点熔断或不可用,严格模式阻止降级) + | "vendor_type_all_timeout"; // 供应商类型全端点超时(524),触发 vendor-type 临时熔断 // === 选择方法(细化) === selectionMethod?: diff --git a/tests/unit/lib/endpoint-circuit-breaker.test.ts b/tests/unit/lib/endpoint-circuit-breaker.test.ts index 107e35536..c38adabf2 100644 --- a/tests/unit/lib/endpoint-circuit-breaker.test.ts +++ b/tests/unit/lib/endpoint-circuit-breaker.test.ts @@ -31,9 +31,6 @@ afterEach(() => { describe("endpoint-circuit-breaker", () => { test("达到阈值后应打开熔断;到期后进入 half-open;成功后关闭并清零", async () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); - vi.resetModules(); let redisState: SavedEndpointCircuitState | null = null; @@ -45,6 +42,9 @@ describe("endpoint-circuit-breaker", () => { redisState = null; }); + vi.doMock("@/lib/config/env.schema", () => ({ + getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: true }), + })); vi.doMock("@/lib/logger", () => ({ logger: createLoggerMock() })); const sendAlertMock = vi.fn(async () => {}); vi.doMock("@/lib/notification/notifier", () => ({ @@ -56,6 +56,9 @@ describe("endpoint-circuit-breaker", () => { deleteEndpointCircuitState: deleteMock, })); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + const { isEndpointCircuitOpen, recordEndpointFailure, @@ -74,6 +77,10 @@ describe("endpoint-circuit-breaker", () => { expect(openState.failureCount).toBe(3); expect(openState.circuitOpenUntil).toBe(Date.now() + 300000); + // Prime env module cache: under fake timers, dynamic import() inside isEndpointCircuitOpen + // may fail to resolve the vi.doMock unless the module is already in the import cache. + await import("@/lib/config/env.schema"); + expect(await isEndpointCircuitOpen(1)).toBe(true); vi.advanceTimersByTime(300000 + 1); @@ -110,14 +117,17 @@ describe("endpoint-circuit-breaker", () => { }); test("recordEndpointSuccess: closed 且 failureCount>0 时应清零", async () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); - vi.resetModules(); const saveMock = vi.fn(async () => {}); + vi.doMock("@/lib/config/env.schema", () => ({ + getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: true }), + })); vi.doMock("@/lib/logger", () => ({ logger: createLoggerMock() })); + + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); vi.doMock("@/lib/redis/endpoint-circuit-breaker-state", () => ({ loadEndpointCircuitState: vi.fn(async () => null), saveEndpointCircuitState: saveMock, @@ -145,6 +155,9 @@ describe("endpoint-circuit-breaker", () => { vi.resetModules(); const sendAlertMock = vi.fn(async () => {}); + vi.doMock("@/lib/config/env.schema", () => ({ + getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: true }), + })); vi.doMock("@/lib/logger", () => ({ logger: createLoggerMock() })); vi.doMock("@/lib/notification/notifier", () => ({ sendCircuitBreakerAlert: sendAlertMock, @@ -183,6 +196,9 @@ describe("endpoint-circuit-breaker", () => { vi.resetModules(); const sendAlertMock = vi.fn(async () => {}); + vi.doMock("@/lib/config/env.schema", () => ({ + getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: true }), + })); vi.doMock("@/lib/notification/notifier", () => ({ sendCircuitBreakerAlert: sendAlertMock, })); @@ -229,9 +245,6 @@ describe("endpoint-circuit-breaker", () => { }); test("recordEndpointFailure should NOT reset circuitOpenUntil when already open", async () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); - vi.resetModules(); let redisState: SavedEndpointCircuitState | null = null; @@ -239,6 +252,9 @@ describe("endpoint-circuit-breaker", () => { redisState = state; }); + vi.doMock("@/lib/config/env.schema", () => ({ + getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: true }), + })); vi.doMock("@/lib/logger", () => ({ logger: createLoggerMock() })); vi.doMock("@/lib/notification/notifier", () => ({ sendCircuitBreakerAlert: vi.fn(async () => {}), @@ -249,7 +265,10 @@ describe("endpoint-circuit-breaker", () => { deleteEndpointCircuitState: vi.fn(async () => {}), })); - const { recordEndpointFailure, isEndpointCircuitOpen } = await import( + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + + const { recordEndpointFailure, isEndpointCircuitOpen, getEndpointHealthInfo } = await import( "@/lib/endpoint-circuit-breaker" ); @@ -258,6 +277,15 @@ describe("endpoint-circuit-breaker", () => { await recordEndpointFailure(100, new Error("fail")); await recordEndpointFailure(100, new Error("fail")); + // Verify circuit was opened (also serves as async flush before isEndpointCircuitOpen) + const { health: healthSnap } = await getEndpointHealthInfo(100); + expect(healthSnap.circuitState).toBe("open"); + + // Prime the env module cache: under fake timers, the dynamic import("@/lib/config/env.schema") + // inside isEndpointCircuitOpen may fail to resolve the mock unless the module is already cached. + const envMod = await import("@/lib/config/env.schema"); + expect(envMod.getEnvConfig().ENABLE_ENDPOINT_CIRCUIT_BREAKER).toBe(true); + expect(await isEndpointCircuitOpen(100)).toBe(true); const originalOpenUntil = redisState!.circuitOpenUntil; expect(originalOpenUntil).toBe(Date.now() + 300000); @@ -274,6 +302,9 @@ describe("endpoint-circuit-breaker", () => { test("getEndpointCircuitStateSync returns correct state for known and unknown endpoints", async () => { vi.resetModules(); + vi.doMock("@/lib/config/env.schema", () => ({ + getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: true }), + })); vi.doMock("@/lib/logger", () => ({ logger: createLoggerMock() })); vi.doMock("@/lib/notification/notifier", () => ({ sendCircuitBreakerAlert: vi.fn(async () => {}), @@ -297,4 +328,165 @@ describe("endpoint-circuit-breaker", () => { await recordEndpointFailure(200, new Error("c")); expect(getEndpointCircuitStateSync(200)).toBe("open"); }); + + describe("ENABLE_ENDPOINT_CIRCUIT_BREAKER disabled", () => { + test("isEndpointCircuitOpen returns false when ENABLE_ENDPOINT_CIRCUIT_BREAKER=false", async () => { + vi.resetModules(); + + vi.doMock("@/lib/config/env.schema", () => ({ + getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: false }), + })); + vi.doMock("@/lib/logger", () => ({ logger: createLoggerMock() })); + vi.doMock("@/lib/redis/endpoint-circuit-breaker-state", () => ({ + loadEndpointCircuitState: vi.fn(async () => null), + saveEndpointCircuitState: vi.fn(async () => {}), + deleteEndpointCircuitState: vi.fn(async () => {}), + })); + + const { isEndpointCircuitOpen } = await import("@/lib/endpoint-circuit-breaker"); + + expect(await isEndpointCircuitOpen(1)).toBe(false); + expect(await isEndpointCircuitOpen(999)).toBe(false); + }); + + test("recordEndpointFailure is no-op when disabled", async () => { + vi.resetModules(); + + const saveMock = vi.fn(async () => {}); + + vi.doMock("@/lib/config/env.schema", () => ({ + getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: false }), + })); + vi.doMock("@/lib/logger", () => ({ logger: createLoggerMock() })); + vi.doMock("@/lib/redis/endpoint-circuit-breaker-state", () => ({ + loadEndpointCircuitState: vi.fn(async () => null), + saveEndpointCircuitState: saveMock, + deleteEndpointCircuitState: vi.fn(async () => {}), + })); + + const { recordEndpointFailure } = await import("@/lib/endpoint-circuit-breaker"); + + await recordEndpointFailure(1, new Error("boom")); + await recordEndpointFailure(1, new Error("boom")); + await recordEndpointFailure(1, new Error("boom")); + + expect(saveMock).not.toHaveBeenCalled(); + }); + + test("recordEndpointSuccess is no-op when disabled", async () => { + vi.resetModules(); + + const saveMock = vi.fn(async () => {}); + + vi.doMock("@/lib/config/env.schema", () => ({ + getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: false }), + })); + vi.doMock("@/lib/logger", () => ({ logger: createLoggerMock() })); + vi.doMock("@/lib/redis/endpoint-circuit-breaker-state", () => ({ + loadEndpointCircuitState: vi.fn(async () => null), + saveEndpointCircuitState: saveMock, + deleteEndpointCircuitState: vi.fn(async () => {}), + })); + + const { recordEndpointSuccess } = await import("@/lib/endpoint-circuit-breaker"); + + await recordEndpointSuccess(1); + + expect(saveMock).not.toHaveBeenCalled(); + }); + + test("triggerEndpointCircuitBreakerAlert is no-op when disabled", async () => { + vi.resetModules(); + + const sendAlertMock = vi.fn(async () => {}); + + vi.doMock("@/lib/config/env.schema", () => ({ + getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: false }), + })); + vi.doMock("@/lib/logger", () => ({ logger: createLoggerMock() })); + vi.doMock("@/lib/notification/notifier", () => ({ + sendCircuitBreakerAlert: sendAlertMock, + })); + vi.doMock("@/lib/redis/endpoint-circuit-breaker-state", () => ({ + loadEndpointCircuitState: vi.fn(async () => null), + saveEndpointCircuitState: vi.fn(async () => {}), + deleteEndpointCircuitState: vi.fn(async () => {}), + })); + + const { triggerEndpointCircuitBreakerAlert } = await import("@/lib/endpoint-circuit-breaker"); + + await triggerEndpointCircuitBreakerAlert( + 5, + 3, + "2026-01-01T00:05:00.000Z", + "connection refused" + ); + + expect(sendAlertMock).not.toHaveBeenCalled(); + }); + + test("initEndpointCircuitBreaker clears in-memory state and Redis keys when disabled", async () => { + vi.resetModules(); + + const redisMock = { + scan: vi + .fn() + .mockResolvedValueOnce([ + "0", + ["endpoint_circuit_breaker:state:1", "endpoint_circuit_breaker:state:2"], + ]), + del: vi.fn(async () => {}), + }; + + vi.doMock("@/lib/config/env.schema", () => ({ + getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: false }), + })); + vi.doMock("@/lib/logger", () => ({ logger: createLoggerMock() })); + vi.doMock("@/lib/redis/client", () => ({ + getRedisClient: () => redisMock, + })); + vi.doMock("@/lib/redis/endpoint-circuit-breaker-state", () => ({ + loadEndpointCircuitState: vi.fn(async () => null), + saveEndpointCircuitState: vi.fn(async () => {}), + deleteEndpointCircuitState: vi.fn(async () => {}), + })); + + const { initEndpointCircuitBreaker } = await import("@/lib/endpoint-circuit-breaker"); + await initEndpointCircuitBreaker(); + + expect(redisMock.scan).toHaveBeenCalled(); + expect(redisMock.del).toHaveBeenCalledWith( + "endpoint_circuit_breaker:state:1", + "endpoint_circuit_breaker:state:2" + ); + }); + + test("initEndpointCircuitBreaker is no-op when enabled", async () => { + vi.resetModules(); + + const redisMock = { + scan: vi.fn(), + del: vi.fn(), + }; + + vi.doMock("@/lib/config/env.schema", () => ({ + getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: true }), + })); + vi.doMock("@/lib/logger", () => ({ logger: createLoggerMock() })); + vi.doMock("@/lib/redis/client", () => ({ + getRedisClient: () => redisMock, + })); + vi.doMock("@/lib/redis/endpoint-circuit-breaker-state", () => ({ + loadEndpointCircuitState: vi.fn(async () => null), + saveEndpointCircuitState: vi.fn(async () => {}), + deleteEndpointCircuitState: vi.fn(async () => {}), + })); + + const { initEndpointCircuitBreaker } = await import("@/lib/endpoint-circuit-breaker"); + await initEndpointCircuitBreaker(); + + expect(redisMock.scan).not.toHaveBeenCalled(); + expect(redisMock.del).not.toHaveBeenCalled(); + }); + }); }); diff --git a/tests/unit/lib/provider-endpoints/endpoint-selector.test.ts b/tests/unit/lib/provider-endpoints/endpoint-selector.test.ts index 8aa1291c0..59ac0312d 100644 --- a/tests/unit/lib/provider-endpoints/endpoint-selector.test.ts +++ b/tests/unit/lib/provider-endpoints/endpoint-selector.test.ts @@ -109,6 +109,9 @@ describe("provider-endpoints: endpoint-selector", () => { vi.doMock("@/lib/endpoint-circuit-breaker", () => ({ isEndpointCircuitOpen: isOpenMock, })); + vi.doMock("@/lib/config/env.schema", () => ({ + getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: true }), + })); const { getPreferredProviderEndpoints, pickBestProviderEndpoint } = await import( "@/lib/provider-endpoints/endpoint-selector" @@ -140,6 +143,9 @@ describe("provider-endpoints: endpoint-selector", () => { vi.doMock("@/lib/endpoint-circuit-breaker", () => ({ isEndpointCircuitOpen: isOpenMock, })); + vi.doMock("@/lib/config/env.schema", () => ({ + getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: true }), + })); const { getPreferredProviderEndpoints, pickBestProviderEndpoint } = await import( "@/lib/provider-endpoints/endpoint-selector" @@ -177,6 +183,9 @@ describe("getEndpointFilterStats", () => { vi.doMock("@/lib/endpoint-circuit-breaker", () => ({ isEndpointCircuitOpen: isOpenMock, })); + vi.doMock("@/lib/config/env.schema", () => ({ + getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: true }), + })); const { getEndpointFilterStats } = await import("@/lib/provider-endpoints/endpoint-selector"); const stats = await getEndpointFilterStats({ vendorId: 10, providerType: "claude" }); @@ -202,6 +211,9 @@ describe("getEndpointFilterStats", () => { vi.doMock("@/lib/endpoint-circuit-breaker", () => ({ isEndpointCircuitOpen: isOpenMock, })); + vi.doMock("@/lib/config/env.schema", () => ({ + getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: true }), + })); const { getEndpointFilterStats } = await import("@/lib/provider-endpoints/endpoint-selector"); const stats = await getEndpointFilterStats({ vendorId: 99, providerType: "codex" }); @@ -232,6 +244,9 @@ describe("getEndpointFilterStats", () => { vi.doMock("@/lib/endpoint-circuit-breaker", () => ({ isEndpointCircuitOpen: isOpenMock, })); + vi.doMock("@/lib/config/env.schema", () => ({ + getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: true }), + })); const { getEndpointFilterStats } = await import("@/lib/provider-endpoints/endpoint-selector"); const stats = await getEndpointFilterStats({ vendorId: 1, providerType: "openai-compatible" }); @@ -244,3 +259,78 @@ describe("getEndpointFilterStats", () => { }); }); }); + +describe("ENABLE_ENDPOINT_CIRCUIT_BREAKER disabled", () => { + test("getPreferredProviderEndpoints skips circuit check when disabled", async () => { + vi.resetModules(); + + const endpoints: ProviderEndpoint[] = [ + makeEndpoint({ id: 1, lastProbeOk: true, sortOrder: 0, lastProbeLatencyMs: 100 }), + makeEndpoint({ id: 2, lastProbeOk: true, sortOrder: 1, lastProbeLatencyMs: 50 }), + makeEndpoint({ id: 3, lastProbeOk: false, sortOrder: 0, lastProbeLatencyMs: 10 }), + makeEndpoint({ id: 4, isEnabled: false }), + makeEndpoint({ id: 5, deletedAt: new Date(1) }), + ]; + + const findMock = vi.fn(async () => endpoints); + const isOpenMock = vi.fn(async () => true); + + vi.doMock("@/repository", () => ({ + findProviderEndpointsByVendorAndType: findMock, + })); + vi.doMock("@/lib/endpoint-circuit-breaker", () => ({ + isEndpointCircuitOpen: isOpenMock, + })); + vi.doMock("@/lib/config/env.schema", () => ({ + getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: false }), + })); + + const { getPreferredProviderEndpoints } = await import( + "@/lib/provider-endpoints/endpoint-selector" + ); + + const result = await getPreferredProviderEndpoints({ + vendorId: 1, + providerType: "claude", + }); + + expect(isOpenMock).not.toHaveBeenCalled(); + // All enabled, non-deleted endpoints returned (id=1,2,3), ranked by sortOrder/health + expect(result.map((e) => e.id)).toEqual([1, 2, 3]); + }); + + test("getEndpointFilterStats returns circuitOpen=0 when disabled", async () => { + vi.resetModules(); + + const endpoints: ProviderEndpoint[] = [ + makeEndpoint({ id: 1, isEnabled: true, lastProbeOk: true }), + makeEndpoint({ id: 2, isEnabled: true, lastProbeOk: false }), + makeEndpoint({ id: 3, isEnabled: false }), + makeEndpoint({ id: 4, deletedAt: new Date(1) }), + ]; + + const findMock = vi.fn(async () => endpoints); + const isOpenMock = vi.fn(async () => true); + + vi.doMock("@/repository", () => ({ + findProviderEndpointsByVendorAndType: findMock, + })); + vi.doMock("@/lib/endpoint-circuit-breaker", () => ({ + isEndpointCircuitOpen: isOpenMock, + })); + vi.doMock("@/lib/config/env.schema", () => ({ + getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: false }), + })); + + const { getEndpointFilterStats } = await import("@/lib/provider-endpoints/endpoint-selector"); + const stats = await getEndpointFilterStats({ vendorId: 10, providerType: "claude" }); + + expect(isOpenMock).not.toHaveBeenCalled(); + expect(stats).toEqual({ + total: 4, + enabled: 2, // id=1,2 (isEnabled && !deletedAt) + circuitOpen: 0, // always 0 when disabled + available: 2, // equals enabled when disabled + }); + }); +}); From eca95ba067785a5f4e297dd6ec65c90883be6201 Mon Sep 17 00:00:00 2001 From: ding113 Date: Thu, 12 Feb 2026 15:54:27 +0800 Subject: [PATCH 6/7] fix(circuit-breaker): vendor type CB respects ENABLE_ENDPOINT_CIRCUIT_BREAKER Make vendor type circuit breaker controlled by the same ENABLE_ENDPOINT_CIRCUIT_BREAKER switch as endpoint circuit breaker. When disabled (default), vendor type CB will never trip or block providers, resolving user confusion about "vendor type temporary circuit breaker" skip reasons in decision chain. Changes: - Add ENABLE_ENDPOINT_CIRCUIT_BREAKER check in isVendorTypeCircuitOpen() - Add switch check in recordVendorTypeAllEndpointsTimeout() - Add tests for switch on/off behavior Co-Authored-By: Claude Sonnet 4.5 --- src/lib/vendor-type-circuit-breaker.ts | 11 +++ .../lib/vendor-type-circuit-breaker.test.ts | 82 +++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/src/lib/vendor-type-circuit-breaker.ts b/src/lib/vendor-type-circuit-breaker.ts index 618b9d680..6fed23b88 100644 --- a/src/lib/vendor-type-circuit-breaker.ts +++ b/src/lib/vendor-type-circuit-breaker.ts @@ -1,5 +1,6 @@ import "server-only"; +import { getEnvConfig } from "@/lib/config/env.schema"; import { logger } from "@/lib/logger"; import { deleteVendorTypeCircuitState, @@ -116,6 +117,11 @@ export async function isVendorTypeCircuitOpen( vendorId: number, providerType: ProviderType ): Promise { + // 检查端点熔断器开关,供应商类型熔断复用此开关 + if (!getEnvConfig().ENABLE_ENDPOINT_CIRCUIT_BREAKER) { + return false; + } + const state = await getOrCreateState(vendorId, providerType); if (state.manualOpen) { @@ -141,6 +147,11 @@ export async function recordVendorTypeAllEndpointsTimeout( providerType: ProviderType, openDurationMs: number = AUTO_OPEN_DURATION_MS ): Promise { + // 检查端点熔断器开关,供应商类型熔断复用此开关 + if (!getEnvConfig().ENABLE_ENDPOINT_CIRCUIT_BREAKER) { + return; + } + const state = await getOrCreateState(vendorId, providerType); if (state.manualOpen) { diff --git a/tests/unit/lib/vendor-type-circuit-breaker.test.ts b/tests/unit/lib/vendor-type-circuit-breaker.test.ts index 8875926be..f1eabdb6b 100644 --- a/tests/unit/lib/vendor-type-circuit-breaker.test.ts +++ b/tests/unit/lib/vendor-type-circuit-breaker.test.ts @@ -24,6 +24,88 @@ afterEach(() => { }); describe("vendor-type-circuit-breaker", () => { + test("ENABLE_ENDPOINT_CIRCUIT_BREAKER=false 时,isVendorTypeCircuitOpen 始终返回 false", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + + vi.resetModules(); + + const loadMock = vi.fn(async () => null); + const saveMock = vi.fn(async () => {}); + + vi.doMock("@/lib/logger", () => ({ logger: createLoggerMock() })); + vi.doMock("@/lib/redis/vendor-type-circuit-breaker-state", () => ({ + loadVendorTypeCircuitState: loadMock, + saveVendorTypeCircuitState: saveMock, + deleteVendorTypeCircuitState: vi.fn(async () => {}), + })); + vi.doMock("@/lib/config/env.schema", () => ({ + getEnvConfig: () => ({ + ENABLE_ENDPOINT_CIRCUIT_BREAKER: false, + NODE_ENV: "test", + }), + })); + + const { isVendorTypeCircuitOpen, recordVendorTypeAllEndpointsTimeout } = await import( + "@/lib/vendor-type-circuit-breaker" + ); + + // 尝试记录熔断 + await recordVendorTypeAllEndpointsTimeout(100, "claude", 60000); + // 不应调用 save + expect(saveMock).not.toHaveBeenCalled(); + + // 应始终返回 false + expect(await isVendorTypeCircuitOpen(100, "claude")).toBe(false); + }); + + test("ENABLE_ENDPOINT_CIRCUIT_BREAKER=true 时,熔断功能正常工作", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + + vi.resetModules(); + + let redisState: SavedVendorTypeCircuitState | null = null; + const loadMock = vi.fn(async () => redisState); + const saveMock = vi.fn( + async ( + _vendorId: number, + _providerType: ProviderType, + state: SavedVendorTypeCircuitState + ) => { + redisState = state; + } + ); + + vi.doMock("@/lib/logger", () => ({ logger: createLoggerMock() })); + vi.doMock("@/lib/redis/vendor-type-circuit-breaker-state", () => ({ + loadVendorTypeCircuitState: loadMock, + saveVendorTypeCircuitState: saveMock, + deleteVendorTypeCircuitState: vi.fn(async () => {}), + })); + vi.doMock("@/lib/config/env.schema", () => ({ + getEnvConfig: () => ({ + ENABLE_ENDPOINT_CIRCUIT_BREAKER: true, + NODE_ENV: "test", + }), + })); + + const { isVendorTypeCircuitOpen, recordVendorTypeAllEndpointsTimeout } = await import( + "@/lib/vendor-type-circuit-breaker" + ); + + // 记录熔断 + await recordVendorTypeAllEndpointsTimeout(101, "claude", 60000); + expect(saveMock).toHaveBeenCalled(); + + // 应返回 true + expect(await isVendorTypeCircuitOpen(101, "claude")).toBe(true); + + // 等待熔断过期 + vi.advanceTimersByTime(60000 + 1); + expect(await isVendorTypeCircuitOpen(101, "claude")).toBe(false); + }); + test("manual open 时 isVendorTypeCircuitOpen 始终为 true,且自动 open 不应覆盖", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); From e1946e74bf8d293701f930f3efc477964c6f7ab9 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Thu, 12 Feb 2026 16:11:17 +0800 Subject: [PATCH 7/7] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20Key=20=E5=B9=B6?= =?UTF-8?q?=E5=8F=91=E9=99=90=E5=88=B6=E7=BB=A7=E6=89=BF=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E5=B9=B6=E5=8F=91=E4=B8=8A=E9=99=90=20(#772)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: Key 并发上限默认继承用户限制 - RateLimitGuard:Key limitConcurrentSessions=0 时回退到 User limitConcurrentSessions\n- Key 配额/使用量接口:并发上限按有效值展示\n- 单测覆盖并发继承逻辑;补齐 probe 测试的 endpoint-circuit-breaker mock 导出\n- 同步更新 biome.json schema 版本以匹配当前 Biome CLI * docs: 补齐并发上限解析工具注释 * refactor: 合并 Key 限额查询并补充并发单测 - getKeyQuotaUsage/getKeyLimitUsage:通过 leftJoin 一次取回 User 并发上限,避免额外查询\n- 新增 resolveKeyConcurrentSessionLimit 单测,覆盖关键分支\n- 修复 vacuum-filter bench 中的 Biome 报错 * fix: my-usage 并发上限继承用户限制 - getMyQuota:Key 并发为 0/null 时回退到 User 并发上限,保持与 Guard/Key 配额一致\n- 新增单测覆盖 Key->User 并发继承 * test: 补齐 my-usage 并发继承场景 - MyUsageQuota.keyLimitConcurrentSessions 收敛为 number(0 表示无限制)\n- OpenAPI 响应 schema 同步为非 nullable\n- my-usage 并发继承测试补充 Key>0 与 User=0 场景 --------- Co-authored-by: tesgth032 --- biome.json | 2 +- src/actions/key-quota.ts | 22 ++- src/actions/keys.ts | 25 +++- src/actions/my-usage.ts | 10 +- src/app/api/actions/[...route]/route.ts | 2 +- src/app/v1/_lib/proxy/rate-limit-guard.ts | 8 +- .../rate-limit/concurrent-session-limit.ts | 33 +++++ .../key-quota-concurrent-inherit.test.ts | 104 +++++++++++++ .../my-usage-concurrent-inherit.test.ts | 140 ++++++++++++++++++ .../concurrent-session-limit.test.ts | 52 +++++++ tests/unit/proxy/rate-limit-guard.test.ts | 14 ++ .../vacuum-filter-has.bench.test.ts | 2 +- 12 files changed, 399 insertions(+), 15 deletions(-) create mode 100644 src/lib/rate-limit/concurrent-session-limit.ts create mode 100644 tests/unit/actions/key-quota-concurrent-inherit.test.ts create mode 100644 tests/unit/actions/my-usage-concurrent-inherit.test.ts create mode 100644 tests/unit/lib/rate-limit/concurrent-session-limit.test.ts diff --git a/biome.json b/biome.json index 87362d2ac..4e430dd39 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.3.10/schema.json", + "$schema": "https://biomejs.dev/schemas/2.3.14/schema.json", "vcs": { "enabled": true, "clientKind": "git", diff --git a/src/actions/key-quota.ts b/src/actions/key-quota.ts index a484b3700..8089a6c60 100644 --- a/src/actions/key-quota.ts +++ b/src/actions/key-quota.ts @@ -3,9 +3,10 @@ import { and, eq, isNull } from "drizzle-orm"; import { getTranslations } from "next-intl/server"; import { db } from "@/drizzle/db"; -import { keys as keysTable } from "@/drizzle/schema"; +import { keys as keysTable, users as usersTable } from "@/drizzle/schema"; import { getSession } from "@/lib/auth"; import { logger } from "@/lib/logger"; +import { resolveKeyConcurrentSessionLimit } from "@/lib/rate-limit/concurrent-session-limit"; import type { DailyResetMode } from "@/lib/rate-limit/time-utils"; import { SessionTracker } from "@/lib/session-tracker"; import type { CurrencyCode } from "@/lib/utils"; @@ -48,13 +49,17 @@ export async function getKeyQuotaUsage(keyId: number): Promise 0 ? effectiveConcurrentLimit : null, }, ]; diff --git a/src/actions/keys.ts b/src/actions/keys.ts index 711d38526..d329c138f 100644 --- a/src/actions/keys.ts +++ b/src/actions/keys.ts @@ -5,15 +5,17 @@ import { and, count, eq, inArray, isNull } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { getTranslations } from "next-intl/server"; import { db } from "@/drizzle/db"; -import { keys as keysTable } from "@/drizzle/schema"; +import { keys as keysTable, users as usersTable } from "@/drizzle/schema"; import { getSession } from "@/lib/auth"; import { PROVIDER_GROUP } from "@/lib/constants/provider.constants"; import { logger } from "@/lib/logger"; +import { resolveKeyConcurrentSessionLimit } from "@/lib/rate-limit/concurrent-session-limit"; import { parseDateInputAsTimezone } from "@/lib/utils/date-input"; import { ERROR_CODES } from "@/lib/utils/error-messages"; import { normalizeProviderGroup, parseProviderGroups } from "@/lib/utils/provider-group"; import { resolveSystemTimezone } from "@/lib/utils/timezone"; import { KeyFormSchema } from "@/lib/validation/schemas"; +import { toKey } from "@/repository/_shared/transformers"; import type { KeyStatistics } from "@/repository/key"; import { countActiveKeysByUser, @@ -696,11 +698,22 @@ export async function getKeyLimitUsage(keyId: number): Promise< return { ok: false, error: "未登录" }; } - const key = await findKeyById(keyId); - if (!key) { + const [result] = await db + .select({ + key: keysTable, + userLimitConcurrentSessions: usersTable.limitConcurrentSessions, + }) + .from(keysTable) + .leftJoin(usersTable, and(eq(keysTable.userId, usersTable.id), isNull(usersTable.deletedAt))) + .where(and(eq(keysTable.id, keyId), isNull(keysTable.deletedAt))) + .limit(1); + + if (!result) { return { ok: false, error: "密钥不存在" }; } + const key = toKey(result.key); + // 权限检查 if (session.user.role !== "admin" && session.user.id !== key.userId) { return { ok: false, error: "无权限执行此操作" }; @@ -715,6 +728,10 @@ export async function getKeyLimitUsage(keyId: number): Promise< getTimeRangeForPeriodWithMode, } = await import("@/lib/rate-limit/time-utils"); const { sumKeyTotalCost, sumKeyCostInTimeRange } = await import("@/repository/statistics"); + const effectiveConcurrentLimit = resolveKeyConcurrentSessionLimit( + key.limitConcurrentSessions, + result.userLimitConcurrentSessions ?? null + ); // Calculate time ranges using Key's dailyResetTime/dailyResetMode configuration const keyDailyTimeRange = await getTimeRangeForPeriodWithMode( @@ -778,7 +795,7 @@ export async function getKeyLimitUsage(keyId: number): Promise< }, concurrentSessions: { current: concurrentSessions, - limit: key.limitConcurrentSessions || 0, + limit: effectiveConcurrentLimit, }, }, }; diff --git a/src/actions/my-usage.ts b/src/actions/my-usage.ts index a59d427d6..1ae320d43 100644 --- a/src/actions/my-usage.ts +++ b/src/actions/my-usage.ts @@ -6,6 +6,7 @@ import { db } from "@/drizzle/db"; import { keys as keysTable, messageRequest } from "@/drizzle/schema"; import { getSession } from "@/lib/auth"; import { logger } from "@/lib/logger"; +import { resolveKeyConcurrentSessionLimit } from "@/lib/rate-limit/concurrent-session-limit"; import type { DailyResetMode } from "@/lib/rate-limit/time-utils"; import { SessionTracker } from "@/lib/session-tracker"; import type { CurrencyCode } from "@/lib/utils"; @@ -91,7 +92,7 @@ export interface MyUsageQuota { keyLimitWeeklyUsd: number | null; keyLimitMonthlyUsd: number | null; keyLimitTotalUsd: number | null; - keyLimitConcurrentSessions: number | null; + keyLimitConcurrentSessions: number; keyCurrent5hUsd: number; keyCurrentDailyUsd: number; keyCurrentWeeklyUsd: number; @@ -266,6 +267,11 @@ export async function getMyQuota(): Promise> { const rangeWeekly = await getTimeRangeForPeriod("weekly"); const rangeMonthly = await getTimeRangeForPeriod("monthly"); + const effectiveKeyConcurrentLimit = resolveKeyConcurrentSessionLimit( + key.limitConcurrentSessions ?? 0, + user.limitConcurrentSessions ?? null + ); + const [ keyCost5h, keyCostDaily, @@ -302,7 +308,7 @@ export async function getMyQuota(): Promise> { keyLimitWeeklyUsd: key.limitWeeklyUsd ?? null, keyLimitMonthlyUsd: key.limitMonthlyUsd ?? null, keyLimitTotalUsd: key.limitTotalUsd ?? null, - keyLimitConcurrentSessions: key.limitConcurrentSessions ?? null, + keyLimitConcurrentSessions: effectiveKeyConcurrentLimit, keyCurrent5hUsd: keyCost5h, keyCurrentDailyUsd: keyCostDaily, keyCurrentWeeklyUsd: keyCostWeekly, diff --git a/src/app/api/actions/[...route]/route.ts b/src/app/api/actions/[...route]/route.ts index 9bbace4e1..08a1f52ca 100644 --- a/src/app/api/actions/[...route]/route.ts +++ b/src/app/api/actions/[...route]/route.ts @@ -891,7 +891,7 @@ const { route: getMyQuotaRoute, handler: getMyQuotaHandler } = createActionRoute keyLimitWeeklyUsd: z.number().nullable(), keyLimitMonthlyUsd: z.number().nullable(), keyLimitTotalUsd: z.number().nullable(), - keyLimitConcurrentSessions: z.number().nullable(), + keyLimitConcurrentSessions: z.number(), keyCurrent5hUsd: z.number(), keyCurrentDailyUsd: z.number(), keyCurrentWeeklyUsd: z.number(), diff --git a/src/app/v1/_lib/proxy/rate-limit-guard.ts b/src/app/v1/_lib/proxy/rate-limit-guard.ts index 750eb951b..99592a5b7 100644 --- a/src/app/v1/_lib/proxy/rate-limit-guard.ts +++ b/src/app/v1/_lib/proxy/rate-limit-guard.ts @@ -1,5 +1,6 @@ import { logger } from "@/lib/logger"; import { RateLimitService } from "@/lib/rate-limit"; +import { resolveKeyConcurrentSessionLimit } from "@/lib/rate-limit/concurrent-session-limit"; import { getResetInfo, getResetInfoWithMode } from "@/lib/rate-limit/time-utils"; import { ERROR_CODES, getErrorMessageServer } from "@/lib/utils/error-messages"; import { RateLimitError } from "./errors"; @@ -118,10 +119,15 @@ export class ProxyRateLimitGuard { // ========== 第二层:资源/频率保护 ========== // 3. Key 并发 Session(避免创建上游连接) + // Key 未设置时,继承 User 并发上限(避免 UI/心智模型不一致:User 设置了并发,但 Key 仍显示“无限制”) + const effectiveKeyConcurrentLimit = resolveKeyConcurrentSessionLimit( + key.limitConcurrentSessions ?? 0, + user.limitConcurrentSessions + ); const sessionCheck = await RateLimitService.checkSessionLimit( key.id, "key", - key.limitConcurrentSessions ?? 0 + effectiveKeyConcurrentLimit ); if (!sessionCheck.allowed) { diff --git a/src/lib/rate-limit/concurrent-session-limit.ts b/src/lib/rate-limit/concurrent-session-limit.ts new file mode 100644 index 000000000..24c9a205c --- /dev/null +++ b/src/lib/rate-limit/concurrent-session-limit.ts @@ -0,0 +1,33 @@ +/** + * 将输入归一化为正整数限额。 + * + * - 非数字 / 非有限值 / <= 0 视为 0(无限制) + * - > 0 时向下取整 + */ +function normalizePositiveLimit(value: unknown): number { + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { + return 0; + } + + return Math.floor(value); +} + +/** + * 解析 Key 的“有效并发 Session 上限”。 + * + * 规则: + * - Key 自身设置(>0)优先生效 + * - Key 未设置/为 0 时,回退到 User 并发上限(>0) + * - 都未设置/为 0 时,返回 0(表示无限制) + */ +export function resolveKeyConcurrentSessionLimit( + keyLimit: number | null | undefined, + userLimit: number | null | undefined +): number { + const normalizedKeyLimit = normalizePositiveLimit(keyLimit); + if (normalizedKeyLimit > 0) { + return normalizedKeyLimit; + } + + return normalizePositiveLimit(userLimit); +} diff --git a/tests/unit/actions/key-quota-concurrent-inherit.test.ts b/tests/unit/actions/key-quota-concurrent-inherit.test.ts new file mode 100644 index 000000000..f07d9f834 --- /dev/null +++ b/tests/unit/actions/key-quota-concurrent-inherit.test.ts @@ -0,0 +1,104 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const getSessionMock = vi.fn(); +vi.mock("@/lib/auth", () => ({ + getSession: getSessionMock, +})); + +const getTranslationsMock = vi.fn(async () => (key: string) => key); +vi.mock("next-intl/server", () => ({ + getTranslations: getTranslationsMock, +})); + +const getSystemSettingsMock = vi.fn(async () => ({ currencyDisplay: "USD" })); +vi.mock("@/repository/system-config", () => ({ + getSystemSettings: getSystemSettingsMock, +})); + +const getTotalUsageForKeyMock = vi.fn(async () => 0); +vi.mock("@/repository/usage-logs", () => ({ + getTotalUsageForKey: getTotalUsageForKeyMock, +})); + +const getKeySessionCountMock = vi.fn(async () => 2); +vi.mock("@/lib/session-tracker", () => ({ + SessionTracker: { + getKeySessionCount: getKeySessionCountMock, + }, +})); + +const getTimeRangeForPeriodWithModeMock = vi.fn(async () => ({ + startTime: new Date("2026-02-11T00:00:00.000Z"), + endTime: new Date("2026-02-12T00:00:00.000Z"), +})); +const getTimeRangeForPeriodMock = vi.fn(async () => ({ + startTime: new Date("2026-02-11T00:00:00.000Z"), + endTime: new Date("2026-02-12T00:00:00.000Z"), +})); +vi.mock("@/lib/rate-limit/time-utils", () => ({ + getTimeRangeForPeriodWithMode: getTimeRangeForPeriodWithModeMock, + getTimeRangeForPeriod: getTimeRangeForPeriodMock, +})); + +const sumKeyCostInTimeRangeMock = vi.fn(async () => 0); +vi.mock("@/repository/statistics", () => ({ + sumKeyCostInTimeRange: sumKeyCostInTimeRangeMock, +})); + +const limitMock = vi.fn(); +const whereMock = vi.fn(() => ({ limit: limitMock })); +const leftJoinMock = vi.fn(() => ({ where: whereMock })); +const fromMock = vi.fn(() => ({ leftJoin: leftJoinMock })); +const selectMock = vi.fn(() => ({ from: fromMock })); + +vi.mock("@/drizzle/db", () => ({ + db: { + select: selectMock, + }, +})); + +vi.mock("@/lib/logger", () => ({ + logger: { + warn: vi.fn(), + error: vi.fn(), + }, +})); + +describe("getKeyQuotaUsage - concurrent limit inheritance", () => { + beforeEach(() => { + vi.clearAllMocks(); + getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); + }); + + it("Key 并发为 0 时应回退到 User 并发上限", async () => { + limitMock.mockResolvedValueOnce([ + { + key: { + id: 1, + userId: 10, + key: "sk-test", + name: "k", + deletedAt: null, + limit5hUsd: null, + limitDailyUsd: null, + limitWeeklyUsd: null, + limitMonthlyUsd: null, + limitTotalUsd: null, + dailyResetTime: "00:00", + dailyResetMode: "fixed", + limitConcurrentSessions: 0, + }, + userLimitConcurrentSessions: 15, + }, + ]); + + const { getKeyQuotaUsage } = await import("@/actions/key-quota"); + const result = await getKeyQuotaUsage(1); + + expect(result.ok).toBe(true); + if (result.ok) { + const item = result.data.items.find((i) => i.type === "limitSessions"); + expect(item).toMatchObject({ current: 2, limit: 15 }); + } + }); +}); diff --git a/tests/unit/actions/my-usage-concurrent-inherit.test.ts b/tests/unit/actions/my-usage-concurrent-inherit.test.ts new file mode 100644 index 000000000..1f5146519 --- /dev/null +++ b/tests/unit/actions/my-usage-concurrent-inherit.test.ts @@ -0,0 +1,140 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const getSessionMock = vi.fn(); +vi.mock("@/lib/auth", () => ({ + getSession: getSessionMock, +})); + +const getKeySessionCountMock = vi.fn(async () => 2); +vi.mock("@/lib/session-tracker", () => ({ + SessionTracker: { + getKeySessionCount: getKeySessionCountMock, + }, +})); + +const getTimeRangeForPeriodWithModeMock = vi.fn(async () => ({ + startTime: new Date("2026-02-11T00:00:00.000Z"), + endTime: new Date("2026-02-12T00:00:00.000Z"), +})); +const getTimeRangeForPeriodMock = vi.fn(async () => ({ + startTime: new Date("2026-02-11T00:00:00.000Z"), + endTime: new Date("2026-02-12T00:00:00.000Z"), +})); +vi.mock("@/lib/rate-limit/time-utils", () => ({ + getTimeRangeForPeriodWithMode: getTimeRangeForPeriodWithModeMock, + getTimeRangeForPeriod: getTimeRangeForPeriodMock, +})); + +const statisticsMock = { + sumUserCostInTimeRange: vi.fn(async () => 0), + sumUserTotalCost: vi.fn(async () => 0), + sumKeyCostInTimeRange: vi.fn(async () => 0), + sumKeyTotalCostById: vi.fn(async () => 0), +}; +vi.mock("@/repository/statistics", () => statisticsMock); + +const whereMock = vi.fn(async () => [{ id: 1 }]); +const fromMock = vi.fn(() => ({ where: whereMock })); +const selectMock = vi.fn(() => ({ from: fromMock })); +vi.mock("@/drizzle/db", () => ({ + db: { + select: selectMock, + }, +})); + +vi.mock("@/lib/logger", () => ({ + logger: { + warn: vi.fn(), + error: vi.fn(), + }, +})); + +function createSession(params: { + keyLimitConcurrentSessions: number | null; + userLimitConcurrentSessions: number | null; +}) { + return { + key: { + id: 1, + key: "sk-test", + name: "k", + dailyResetTime: "00:00", + dailyResetMode: "fixed", + limit5hUsd: null, + limitDailyUsd: null, + limitWeeklyUsd: null, + limitMonthlyUsd: null, + limitTotalUsd: null, + limitConcurrentSessions: params.keyLimitConcurrentSessions, + providerGroup: null, + isEnabled: true, + expiresAt: null, + }, + user: { + id: 10, + name: "u", + dailyResetTime: "00:00", + dailyResetMode: "fixed", + limit5hUsd: null, + dailyQuota: null, + limitWeeklyUsd: null, + limitMonthlyUsd: null, + limitTotalUsd: null, + limitConcurrentSessions: params.userLimitConcurrentSessions, + rpm: null, + providerGroup: null, + isEnabled: true, + expiresAt: null, + allowedModels: [], + allowedClients: [], + }, + }; +} + +describe("getMyQuota - concurrent limit inheritance", () => { + beforeEach(() => { + vi.clearAllMocks(); + + getSessionMock.mockResolvedValue( + createSession({ keyLimitConcurrentSessions: 0, userLimitConcurrentSessions: 15 }) + ); + }); + + it("Key 并发为 0 时应回退到 User 并发上限", async () => { + const { getMyQuota } = await import("@/actions/my-usage"); + const result = await getMyQuota(); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.keyLimitConcurrentSessions).toBe(15); + } + }); + + it("Key 并发为正数时应优先使用 Key 自身上限", async () => { + getSessionMock.mockResolvedValue( + createSession({ keyLimitConcurrentSessions: 5, userLimitConcurrentSessions: 15 }) + ); + + const { getMyQuota } = await import("@/actions/my-usage"); + const result = await getMyQuota(); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.keyLimitConcurrentSessions).toBe(5); + } + }); + + it("Key=0 且 User=0 时应返回 0(无限制)", async () => { + getSessionMock.mockResolvedValue( + createSession({ keyLimitConcurrentSessions: 0, userLimitConcurrentSessions: 0 }) + ); + + const { getMyQuota } = await import("@/actions/my-usage"); + const result = await getMyQuota(); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.keyLimitConcurrentSessions).toBe(0); + } + }); +}); diff --git a/tests/unit/lib/rate-limit/concurrent-session-limit.test.ts b/tests/unit/lib/rate-limit/concurrent-session-limit.test.ts new file mode 100644 index 000000000..706989644 --- /dev/null +++ b/tests/unit/lib/rate-limit/concurrent-session-limit.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { resolveKeyConcurrentSessionLimit } from "@/lib/rate-limit/concurrent-session-limit"; + +describe("resolveKeyConcurrentSessionLimit", () => { + const cases: Array<{ + title: string; + keyLimit: number | null | undefined; + userLimit: number | null | undefined; + expected: number; + }> = [ + { title: "Key > 0 时应优先使用 Key", keyLimit: 10, userLimit: 15, expected: 10 }, + { title: "Key 为 0 时应回退到 User", keyLimit: 0, userLimit: 15, expected: 15 }, + { title: "Key 为 null 时应回退到 User", keyLimit: null, userLimit: 15, expected: 15 }, + { title: "Key 为 undefined 时应回退到 User", keyLimit: undefined, userLimit: 15, expected: 15 }, + { + title: "Key 为 NaN 时应回退到 User", + keyLimit: Number.NaN, + userLimit: 15, + expected: 15, + }, + { + title: "Key 为 Infinity 时应回退到 User", + keyLimit: Number.POSITIVE_INFINITY, + userLimit: 15, + expected: 15, + }, + { title: "Key < 0 时应回退到 User", keyLimit: -1, userLimit: 15, expected: 15 }, + { title: "Key 为小数时应向下取整", keyLimit: 5.9, userLimit: 15, expected: 5 }, + { title: "Key 小数 < 1 时应回退到 User", keyLimit: 0.9, userLimit: 15, expected: 15 }, + { title: "User 为小数时应向下取整", keyLimit: 0, userLimit: 7.8, expected: 7 }, + { + title: "Key 与 User 均未设置/无效时应返回 0(无限制)", + keyLimit: undefined, + userLimit: null, + expected: 0, + }, + { + title: "Key 为 0 且 User 为 Infinity 时应返回 0(无限制)", + keyLimit: 0, + userLimit: Number.POSITIVE_INFINITY, + expected: 0, + }, + ]; + + for (const testCase of cases) { + it(testCase.title, () => { + expect(resolveKeyConcurrentSessionLimit(testCase.keyLimit, testCase.userLimit)).toBe( + testCase.expected + ); + }); + } +}); diff --git a/tests/unit/proxy/rate-limit-guard.test.ts b/tests/unit/proxy/rate-limit-guard.test.ts index 55c761683..c8aec8665 100644 --- a/tests/unit/proxy/rate-limit-guard.test.ts +++ b/tests/unit/proxy/rate-limit-guard.test.ts @@ -284,6 +284,20 @@ describe("ProxyRateLimitGuard - key daily limit enforcement", () => { }); }); + it("当 Key 并发未设置(0)且 User 并发已设置时,Key 并发检查应继承 User 并发上限", async () => { + const { ProxyRateLimitGuard } = await import("@/app/v1/_lib/proxy/rate-limit-guard"); + + const session = createSession({ + user: { limitConcurrentSessions: 15 }, + key: { limitConcurrentSessions: 0 }, + }); + + await expect(ProxyRateLimitGuard.ensure(session)).resolves.toBeUndefined(); + + expect(rateLimitServiceMock.checkSessionLimit).toHaveBeenNthCalledWith(1, 2, "key", 15); + expect(rateLimitServiceMock.checkSessionLimit).toHaveBeenNthCalledWith(2, 1, "user", 15); + }); + it("User RPM 超限应拦截(rpm)", async () => { const { ProxyRateLimitGuard } = await import("@/app/v1/_lib/proxy/rate-limit-guard"); diff --git a/tests/unit/vacuum-filter/vacuum-filter-has.bench.test.ts b/tests/unit/vacuum-filter/vacuum-filter-has.bench.test.ts index 2c5aec50a..1ea732238 100644 --- a/tests/unit/vacuum-filter/vacuum-filter-has.bench.test.ts +++ b/tests/unit/vacuum-filter/vacuum-filter-has.bench.test.ts @@ -31,7 +31,7 @@ function makeAsciiKey(rng: () => number, len: number): string { function freshSameContent(s: string): string { // 让 V8 很难复用同一个 string 实例(模拟“请求头解析后每次都是新字符串对象”) - return (" " + s).slice(1); + return ` ${s}`.slice(1); } function median(values: number[]): number {