From 5fe92d1cf948b4404ed0678658dbdc133c80fe89 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Sun, 1 Mar 2026 14:26:59 +0800 Subject: [PATCH 01/16] =?UTF-8?q?perf:=20=E5=B8=B8=E7=94=A8=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E8=AF=BB=E8=B7=AF=E5=BE=84=E9=99=8D=E8=B4=9F=E8=BD=BD?= =?UTF-8?q?=EF=BC=88=E7=B3=BB=E7=BB=9F=E8=AE=BE=E7=BD=AE=E7=BC=93=E5=AD=98?= =?UTF-8?q?=E4=B8=8E=E6=97=A5=E5=BF=97=E8=BD=AE=E8=AF=A2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common-pages-optimization-plan.md | 164 +++++++++++++ src/actions/dashboard-realtime.ts | 4 +- src/actions/key-quota.ts | 4 +- src/actions/keys.ts | 2 +- src/actions/my-usage.ts | 12 +- src/actions/notifications.ts | 2 +- src/actions/overview.ts | 4 +- src/actions/provider-slots.ts | 4 +- src/actions/statistics.ts | 4 +- src/actions/system-config.ts | 6 +- src/actions/users.ts | 2 +- src/actions/webhook-targets.ts | 2 +- .../_components/dashboard-bento-sections.tsx | 4 +- .../_components/dashboard-sections.tsx | 4 +- .../user/forms/limit-rule-picker.tsx | 2 +- .../[locale]/dashboard/leaderboard/page.tsx | 4 +- .../logs/_components/usage-logs-sections.tsx | 6 +- .../virtualized-logs-table.test.tsx | 3 + .../_components/virtualized-logs-table.tsx | 127 +++++++++- .../logs/_hooks/use-lazy-filter-options.ts | 2 +- src/app/[locale]/dashboard/my-quota/page.tsx | 4 +- .../dashboard/quotas/providers/page.tsx | 4 +- .../[locale]/dashboard/quotas/users/page.tsx | 7 +- src/app/[locale]/layout.tsx | 6 +- src/app/[locale]/settings/config/page.tsx | 4 +- src/app/api/leaderboard/route.ts | 4 +- src/app/api/system-settings/route.ts | 4 +- src/app/v1/_lib/proxy/provider-selector.ts | 19 +- src/app/v1/_lib/proxy/response-handler.ts | 4 +- src/app/v1/_lib/proxy/session.ts | 4 +- src/app/v1/_lib/proxy/version-guard.ts | 4 +- src/i18n/request.ts | 25 +- src/lib/auth.ts | 3 +- src/lib/auth/constants.ts | 3 + src/lib/config/index.ts | 1 + src/lib/config/system-settings-cache.ts | 223 +++++++++++++----- src/lib/notification/notification-queue.ts | 2 +- .../tasks/cache-hit-rate-alert.ts | 2 +- .../notification/tasks/daily-leaderboard.ts | 2 +- src/lib/rate-limit/time-utils.ts | 2 +- src/lib/redis/leaderboard-cache.ts | 2 +- src/lib/security/constant-time-compare.ts | 4 +- src/lib/utils/timezone.server.ts | 41 ++++ src/lib/utils/timezone.ts | 33 +-- src/proxy.ts | 2 +- src/repository/leaderboard.ts | 8 +- src/repository/notification-bindings.ts | 2 +- src/repository/overview.ts | 2 +- src/repository/provider.ts | 2 +- src/repository/statistics.ts | 2 +- .../integration/billing-model-source.test.ts | 6 + .../actions/my-usage-date-range-dst.test.ts | 2 +- .../my-usage-token-aggregation.test.ts | 11 +- tests/unit/actions/system-config-save.test.ts | 10 +- tests/unit/lib/rate-limit/cost-limits.test.ts | 2 +- tests/unit/lib/rate-limit/lease.test.ts | 4 +- .../lib/rate-limit/rolling-window-5h.test.ts | 2 +- .../rolling-window-cache-warm.test.ts | 2 +- .../unit/lib/rate-limit/service-extra.test.ts | 2 +- tests/unit/lib/rate-limit/time-utils.test.ts | 4 +- .../lib/timezone/timezone-resolver.test.ts | 14 +- .../notification/cost-alert-window.test.ts | 2 +- tests/unit/proxy/session.test.ts | 41 ++-- .../leaderboard-provider-metrics.test.ts | 12 +- .../leaderboard-timezone-parentheses.test.ts | 7 +- .../overview-timezone-parentheses.test.ts | 2 +- 66 files changed, 674 insertions(+), 236 deletions(-) create mode 100644 docs/performance/common-pages-optimization-plan.md create mode 100644 src/lib/auth/constants.ts create mode 100644 src/lib/utils/timezone.server.ts diff --git a/docs/performance/common-pages-optimization-plan.md b/docs/performance/common-pages-optimization-plan.md new file mode 100644 index 000000000..c79b51e96 --- /dev/null +++ b/docs/performance/common-pages-optimization-plan.md @@ -0,0 +1,164 @@ +# 常用页面与写入链路性能优化路线图(无感升级优先) + +更新时间:2026-03-01 + +## 目标 + +- 让常用界面(仪表盘、使用记录、排行榜、供应商管理)在数据量增长/并发增加时,仍能维持稳定的响应时间。 +- 显著降低服务器负载(DB CPU/IO、应用 CPU、内存占用、连接数、Redis 压力)。 +- 优化需尽量“不改变现有行为”:升级后用户体验应尽可能无感(除了更快、更稳)。 + +## 约束与原则(确保无感升级) + +- 优先做“读路径”优化:缓存/预聚合/索引/分页策略,风险低、收益大。 +- 写路径优化必须“可回滚、可开关”:任何改变写入时序/一致性的优化都应有 feature flag,并保留旧路径。 +- 迁移必须向后兼容:以“新增表/新增索引/新增字段”为主,避免破坏性变更(drop/rename)。 +- 观测先行:每一项优化都要能被指标/日志证明效果,并能在异常时快速降级。 + +## 现状链路速览(按页面/情形) + +### 仪表盘(Dashboard) + +- 页面入口:`src/app/[locale]/dashboard/page.tsx` +- 主要数据: + - Overview:`src/actions/overview.ts` -> `src/lib/redis/overview-cache.ts` -> `src/repository/overview.ts`(读 `usage_ledger`) + - Statistics:`src/actions/statistics.ts` -> `src/lib/redis/statistics-cache.ts` -> `src/repository/statistics.ts`(读 `usage_ledger`,含 buckets + zero-fill) +- 风险点: + - 管理员视角可能一次拉取“全体用户/全体 keys 的 bucket 数据”,CPU 与内存放大明显。 + - 缓存 miss 时聚合查询会产生尖刺(thundering herd 已用锁缓解,但仍会打 DB)。 + +### 使用记录(Usage Logs) + +- 页面入口:`src/app/[locale]/dashboard/logs/page.tsx` +- 查询方式: + - 列表:`src/actions/usage-logs.ts:getUsageLogsBatch` -> `src/repository/usage-logs.ts:findUsageLogsBatch`(keyset pagination,无 COUNT) + - 筛选项:`src/actions/usage-logs.ts:getFilterOptions` 内存缓存 5 分钟(避免 3 次 DISTINCT) + - 活跃会话:`src/actions/active-sessions.ts`(SessionTracker + 聚合查询 + 本地缓存) +- 风险点: + - 前端自动刷新如果全量 refetch 所有 pages,会对 DB 造成持续压力(尤其是多人同时打开 logs 页)。 + +### 排行榜(Leaderboard) + +- 页面入口:`src/app/[locale]/dashboard/leaderboard/page.tsx` +- 数据入口:`src/app/api/leaderboard/route.ts` -> `src/lib/redis/leaderboard-cache.ts` -> `src/repository/leaderboard.ts`(按周期聚合 `usage_ledger`) +- 风险点: + - 自定义区间/全站维度的聚合可能扫描大量 ledger 数据;缓存 miss 时压力集中。 + +### 供应商管理(Providers) + +- 页面入口:`src/app/[locale]/dashboard/providers/page.tsx`(复用 settings/providers 组件) +- 客户端多请求: + - providers:`src/actions/providers.ts:getProviders`(当前使用 `findAllProvidersFresh()` 绕过 provider cache) + - health:`src/actions/providers.ts:getProvidersHealthStatus` + - statistics:`src/actions/providers.ts:getProviderStatisticsAsync`(前端 60s interval) + - system-settings:`/api/system-settings` +- 风险点: + - 多个独立 query 的“瀑布式”刷新容易放大 API/DB QPS。 + - provider 列表绕过缓存会导致频繁全表读取(尤其是多人同时管理)。 + +### 高频写入情形:每次新请求写入数据库 + +- 入口:`src/app/v1/_lib/proxy/message-service.ts:ProxyMessageService.ensureContext` + - 同步写:`src/repository/message.ts:createMessageRequest` -> `message_request INSERT` +- 请求结束/更新: + - `src/repository/message.ts:updateMessageRequest*`(默认 `MESSAGE_REQUEST_WRITE_MODE=async`,走 `src/repository/message-write-buffer.ts` 批量 UPDATE) +- 派生 ledger: + - DB trigger:`src/lib/ledger-backfill/trigger.sql`(`AFTER INSERT OR UPDATE ON message_request`,每次写都 upsert `usage_ledger`) +- 风险点: + - 写放大:每个请求至少 1 次 INSERT + 1 次 UPDATE(甚至更多),且每次都会触发 `usage_ledger` UPSERT。 + - `message_request` 索引较多,写入会额外消耗 CPU/IO;大 JSON 字段会导致 row bloat 与 vacuum 压力。 + +## 已落地的低风险优化(不改变语义) + +- 系统设置缓存跨实例失效通知:在保存系统设置后通过 Redis Pub/Sub 广播失效,让各实例的进程内缓存立即失效(减少重复读 `system_settings`)。 +- 使用记录自动刷新减负:前端仅轮询“最新一页”,并合并到现有无限列表(避免 react-query 在 infiniteQuery 下重拉所有 pages)。 + +### 已落地代码位置(便于继续扩展) + +- 系统设置缓存与失效广播: + - 缓存实现:`src/lib/config/system-settings-cache.ts` + - 保存设置后发布失效:`src/actions/system-config.ts` + - 统一出口(仅 server 侧使用):`src/lib/config/index.ts` + - 失败退避:当 DB 不可用时也会缓存 fallback(避免每次调用都重复打点/重试) +- 使用记录页自动刷新减负: + - 轮询/合并/去重:`src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx` + - 覆盖测试:`src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.test.tsx` + +## 分层优化路线图(按收益/风险分级) + +### P0:低风险、优先落地(默认不改变行为) + +1) 统一系统设置读路径为缓存(并保持可降级) +- 目标:把全站“频繁读 system_settings”压到接近 0(在 TTL 内/命中场景)。 +- 做法: + - 页面/接口/代理热路径统一改用 `src/lib/config/system-settings-cache.ts:getCachedSystemSettings()`。 + - 依赖 Redis 时用 pubsub 即时失效;无 Redis 时仍按 TTL 自动过期(不影响可用性)。 + +2) 轮询策略与请求编排(减少无效 QPS) +- 使用记录:保持 keyset pagination;自动刷新仅更新最新页(已落地)。 +- 活跃会话:在服务端已有缓存的前提下,前端可考虑仅在 tab 可见时轮询,或对并发计数做短 TTL 缓存(不改变展示语义)。 +- 供应商管理:将 providers/health/statistics 的刷新节奏错峰,或合并为单一 endpoints(需要评估 UI 代码改动范围)。 + +3) 缓存 miss 尖刺治理(降低“缓存雪崩”影响) +- Overview/Statistics/Leaderboard 的 Redis 锁机制已存在,可补充: + - 更细粒度的 cache key(例如按 scope/filters 维度拆分),避免一个 key 承载过多场景。 + - 在缓存写入失败时记录最小必要信息(避免静默长期退化)。 + +4) 查询列裁剪与只读路径分离(减少 IO) +- 使用记录列表页:尽量只 select 列表展示所需字段;详情弹窗再拉大字段(如 errorStack、providerChain、specialSettings)。 +- 供应商管理列表:主列表优先“轻字段”;统计/健康信息独立异步加载(已基本实现)。 + +### P1:中风险、需开关/兼容迁移(收益巨大) + +1) 写放大治理:让 `usage_ledger` 写入更“按需” +- 方向 A(推荐):对 trigger 加 `WHEN` 条件,只在关键字段变化时 upsert + - 示例触发字段:`status_code`、`cost_usd`、`duration_ms`、`tokens`、`blocked_by`、`provider_chain`、`model/provider_id` 等。 + - 优点:不改变外部读模型(usage_ledger 仍是源),但能显著减少重复 UPSERT。 + - 风险:需要严谨列出“影响 billing/统计/展示”的字段集合,避免漏更新。 +- 方向 B:仅在“终态”写入 ledger(例如 `duration_ms IS NOT NULL` 或 `status_code IS NOT NULL`) + - 优点:写放大最小。 + - 风险:会改变“进行中请求”的统计可见性,需要产品确认,并必须 feature flag。 + +2) 大表分区(message_request / usage_ledger) +- 当数据量达到千万级后,聚合与范围查询更依赖分区裁剪。 +- 做法:按月/周分区,保留相同 schema 与索引;对读路径保持透明。 +- 风险:迁移复杂、需要演练;必须保证自动迁移与回滚策略。 + +3) 预聚合/rollup 表(从“每次扫明细”到“读汇总”) +- 为 dashboard/leaderboard/statistics 引入 rollup: + - 例如 `usage_ledger_rollup_hourly` / `usage_ledger_rollup_daily`(按 user/provider/model 维度聚合) + - 由后台任务或增量触发更新 +- UI 查询优先读 rollup,必要时再回退到明细聚合。 +- 风险:一致性与延迟(需要定义可接受的统计滞后,例如 30s/1m)。 + +4) 大字段拆表(降低行膨胀与 vacuum 压力) +- `message_request` 的 `error_stack`、`error_cause`、`provider_chain`、`special_settings` 等可迁移到侧表(按 request_id 1:1)。 +- 列表页默认不 join 大字段,只有需要时再 join。 +- 风险:需要迁移脚本与读写双写/回读逻辑,建议分阶段上线。 + +### P2:高风险/架构级(需明确 ROI,通常不作为第一波) + +- 将轮询改为推送:SSE/WebSocket(服务器端维护订阅,减少重复查询)。 +- 将历史明细转入分析型存储(如 ClickHouse): + - Postgres 保留近期热数据;报表/排行榜走分析库。 +- 将 message_request/usage_ledger 写入改为 event sourcing + 异步消费(需大量改动与完善的幂等/重放机制)。 + +## 验收指标(建议纳入监控) + +- 页面: + - 仪表盘/使用记录/排行榜/供应商管理:TTFB、接口 p95/p99、前端渲染耗时(可选)。 +- DB: + - QPS、慢查询数量、CPU、IO、连接数、锁等待、autovacuum 追赶情况、WAL 产出与复制延迟(如有)。 +- Redis: + - 命中率、带宽、锁 key 冲突率、pubsub 消息量。 +- 写路径: + - 每请求写入次数(INSERT/UPDATE/UPSERT)、message_write_buffer flush 频率与失败率、pending queue 大小。 + +## 文件导航(便于继续深入) + +- 系统设置:`src/repository/system-config.ts`、`src/lib/config/system-settings-cache.ts` +- 仪表盘:`src/actions/overview.ts`、`src/actions/statistics.ts`、`src/repository/overview.ts`、`src/repository/statistics.ts` +- 使用记录:`src/actions/usage-logs.ts`、`src/repository/usage-logs.ts`、`src/app/[locale]/dashboard/logs/` +- 排行榜:`src/app/api/leaderboard/route.ts`、`src/lib/redis/leaderboard-cache.ts`、`src/repository/leaderboard.ts` +- 供应商管理:`src/actions/providers.ts`、`src/repository/provider.ts`、`src/app/[locale]/settings/providers/` +- 写入链路:`src/app/v1/_lib/proxy/message-service.ts`、`src/repository/message.ts`、`src/repository/message-write-buffer.ts`、`src/lib/ledger-backfill/trigger.sql` diff --git a/src/actions/dashboard-realtime.ts b/src/actions/dashboard-realtime.ts index 274ab376b..a220afdd1 100644 --- a/src/actions/dashboard-realtime.ts +++ b/src/actions/dashboard-realtime.ts @@ -1,6 +1,7 @@ "use server"; import { getSession } from "@/lib/auth"; +import { getCachedSystemSettings } from "@/lib/config"; import { logger } from "@/lib/logger"; import { findRecentActivityStream } from "@/repository/activity-stream"; import { @@ -11,7 +12,6 @@ import { type ModelLeaderboardEntry, type ProviderLeaderboardEntry, } from "@/repository/leaderboard"; -import { getSystemSettings } from "@/repository/system-config"; // 导入已有的接口和方法 import { getOverviewData, type OverviewData } from "./overview"; import { getProviderSlots, type ProviderSlotInfo } from "./provider-slots"; @@ -96,7 +96,7 @@ export async function getDashboardRealtimeData(): Promise> { const session = await getSession({ allowReadOnlyAccess: true }); if (!session) return { ok: false, error: "Unauthorized" }; - const settings = await getSystemSettings(); + const settings = await getCachedSystemSettings(); const billingModelSource = settings.billingModelSource; const currencyCode = settings.currencyDisplay; @@ -441,7 +441,7 @@ export async function getMyUsageLogs( const session = await getSession({ allowReadOnlyAccess: true }); if (!session) return { ok: false, error: "Unauthorized" }; - const settings = await getSystemSettings(); + const settings = await getCachedSystemSettings(); const rawPageSize = filters.pageSize && filters.pageSize > 0 ? filters.pageSize : 20; const pageSize = Math.min(rawPageSize, 100); @@ -583,7 +583,7 @@ export async function getMyStatsSummary( const session = await getSession({ allowReadOnlyAccess: true }); if (!session) return { ok: false, error: "Unauthorized" }; - const settings = await getSystemSettings(); + const settings = await getCachedSystemSettings(); const currencyCode = settings.currencyDisplay; const timezone = await resolveSystemTimezone(); diff --git a/src/actions/notifications.ts b/src/actions/notifications.ts index 4584ba8af..2f5fc2bbf 100644 --- a/src/actions/notifications.ts +++ b/src/actions/notifications.ts @@ -3,7 +3,7 @@ import { getSession } from "@/lib/auth"; import type { NotificationJobType } from "@/lib/constants/notification.constants"; import { logger } from "@/lib/logger"; -import { resolveSystemTimezone } from "@/lib/utils/timezone"; +import { resolveSystemTimezone } from "@/lib/utils/timezone.server"; import { WebhookNotifier } from "@/lib/webhook"; import { buildTestMessage } from "@/lib/webhook/templates/test-messages"; import { diff --git a/src/actions/overview.ts b/src/actions/overview.ts index a9cb71523..cb0dc7f3a 100644 --- a/src/actions/overview.ts +++ b/src/actions/overview.ts @@ -1,9 +1,9 @@ "use server"; import { getSession } from "@/lib/auth"; +import { getCachedSystemSettings } from "@/lib/config"; import { logger } from "@/lib/logger"; import { getOverviewWithCache } from "@/lib/redis"; -import { getSystemSettings } from "@/repository/system-config"; import { getConcurrentSessions as getConcurrentSessionsCount } from "./concurrent-sessions"; import type { ActionResult } from "./types"; @@ -48,7 +48,7 @@ export async function getOverviewData(): Promise> { }; } - const settings = await getSystemSettings(); + const settings = await getCachedSystemSettings(); const isAdmin = session.user.role === "admin"; const canViewGlobalData = isAdmin || settings.allowGlobalUsageView; diff --git a/src/actions/provider-slots.ts b/src/actions/provider-slots.ts index d7fffb771..800822a0c 100644 --- a/src/actions/provider-slots.ts +++ b/src/actions/provider-slots.ts @@ -4,9 +4,9 @@ import { and, eq, isNull } from "drizzle-orm"; import { db } from "@/drizzle/db"; import { providers } from "@/drizzle/schema"; import { getSession } from "@/lib/auth"; +import { getCachedSystemSettings } from "@/lib/config"; import { logger } from "@/lib/logger"; import { SessionTracker } from "@/lib/session-tracker"; -import { getSystemSettings } from "@/repository/system-config"; import type { ActionResult } from "./types"; /** @@ -42,7 +42,7 @@ export async function getProviderSlots(): Promise { const trimmed = rawValue.trim(); diff --git a/src/app/[locale]/dashboard/leaderboard/page.tsx b/src/app/[locale]/dashboard/leaderboard/page.tsx index 0be901118..438144e7d 100644 --- a/src/app/[locale]/dashboard/leaderboard/page.tsx +++ b/src/app/[locale]/dashboard/leaderboard/page.tsx @@ -5,7 +5,7 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Link } from "@/i18n/routing"; import { getSession } from "@/lib/auth"; -import { getSystemSettings } from "@/repository/system-config"; +import { getCachedSystemSettings } from "@/lib/config"; import { LeaderboardView } from "./_components/leaderboard-view"; export const dynamic = "force-dynamic"; @@ -14,7 +14,7 @@ export default async function LeaderboardPage() { const t = await getTranslations("dashboard"); // 获取用户 session 和系统设置 const session = await getSession(); - const systemSettings = await getSystemSettings(); + const systemSettings = await getCachedSystemSettings(); // 检查权限 const isAdmin = session?.user.role === "admin"; diff --git a/src/app/[locale]/dashboard/logs/_components/usage-logs-sections.tsx b/src/app/[locale]/dashboard/logs/_components/usage-logs-sections.tsx index 0c1ef1d5c..4ac33430f 100644 --- a/src/app/[locale]/dashboard/logs/_components/usage-logs-sections.tsx +++ b/src/app/[locale]/dashboard/logs/_components/usage-logs-sections.tsx @@ -1,10 +1,10 @@ import { cache } from "react"; import { ActiveSessionsList } from "@/components/customs/active-sessions-list"; -import { resolveSystemTimezone } from "@/lib/utils/timezone"; -import { getSystemSettings } from "@/repository/system-config"; +import { getCachedSystemSettings as getCachedSystemSettingsFromConfig } from "@/lib/config"; +import { resolveSystemTimezone } from "@/lib/utils/timezone.server"; import { UsageLogsViewVirtualized } from "./usage-logs-view-virtualized"; -const getCachedSystemSettings = cache(getSystemSettings); +const getCachedSystemSettings = cache(getCachedSystemSettingsFromConfig); interface UsageLogsDataSectionProps { isAdmin: boolean; diff --git a/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.test.tsx b/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.test.tsx index 404501fa6..833b01410 100644 --- a/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.test.tsx +++ b/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.test.tsx @@ -27,6 +27,9 @@ vi.mock("@tanstack/react-query", () => ({ isError: mockIsError, error: mockError, }), + useQueryClient: () => ({ + setQueryData: vi.fn(), + }), })); vi.mock("@/hooks/use-virtualizer", () => ({ diff --git a/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx b/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx index 374a8e32f..a0cee8de9 100644 --- a/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx +++ b/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx @@ -1,6 +1,6 @@ "use client"; -import { useInfiniteQuery } from "@tanstack/react-query"; +import { type InfiniteData, useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; import { ArrowUp, Loader2 } from "lucide-react"; import { useTranslations } from "next-intl"; import { type MouseEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -30,6 +30,39 @@ import { ProviderChainPopover } from "./provider-chain-popover"; const BATCH_SIZE = 50; const ROW_HEIGHT = 52; // Estimated row height in pixels +const MAX_PAGES = 5; +const MAX_TOTAL_LOGS = BATCH_SIZE * MAX_PAGES; + +type Cursor = { createdAt: string; id: number }; +type CursorParam = Cursor | undefined; + +type UsageLogsBatchData = Extract< + Awaited>, + { ok: true } +>["data"]; +type UsageLogWithCursor = UsageLogsBatchData["logs"][number] & { createdAtRaw?: string }; + +function chunkArray(items: T[], size: number): T[][] { + if (size <= 0) return [items]; + + const chunks: T[][] = []; + for (let i = 0; i < items.length; i += size) { + chunks.push(items.slice(i, i + size)); + } + return chunks; +} + +function resolveCursorFromLog(log: UsageLogWithCursor): Cursor | null { + const createdAtRaw = + typeof log.createdAtRaw === "string" && log.createdAtRaw.length > 0 + ? log.createdAtRaw + : log.createdAt + ? log.createdAt.toISOString() + : null; + + if (!createdAtRaw) return null; + return { createdAt: createdAtRaw, id: log.id }; +} export interface VirtualizedLogsTableFilters { userId?: number; @@ -73,6 +106,9 @@ export function VirtualizedLogsTable({ const parentRef = useRef(null); const [showScrollToTop, setShowScrollToTop] = useState(false); const shouldPoll = autoRefreshEnabled && !showScrollToTop; + const queryClient = useQueryClient(); + const queryKey = useMemo(() => ["usage-logs-batch", filters] as const, [filters]); + const refreshInFlightRef = useRef(false); const hideProviderColumn = hiddenColumns?.includes("provider") ?? false; const hideUserColumn = hiddenColumns?.includes("user") ?? false; @@ -106,7 +142,7 @@ export function VirtualizedLogsTable({ // Infinite query with cursor-based pagination const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, isError, error } = useInfiniteQuery({ - queryKey: ["usage-logs-batch", filters], + queryKey, queryFn: async ({ pageParam }) => { const result = await getUsageLogsBatch({ ...filters, @@ -122,10 +158,93 @@ export function VirtualizedLogsTable({ initialPageParam: undefined as { createdAt: string; id: number } | undefined, staleTime: 30000, // 30 seconds refetchOnWindowFocus: false, - refetchInterval: shouldPoll ? autoRefreshIntervalMs : false, - maxPages: 5, + refetchInterval: false, + maxPages: MAX_PAGES, }); + // Poll only the latest page to reduce DB load. + // We merge the refreshed first page into the existing infinite list (dedupe by id), + // and keep a fixed-size rolling window to avoid unbounded growth. + useEffect(() => { + if (!shouldPoll) return; + + let cancelled = false; + + const tick = async () => { + if (refreshInFlightRef.current) return; + refreshInFlightRef.current = true; + + try { + const result = await getUsageLogsBatch({ + ...filters, + cursor: undefined, + limit: BATCH_SIZE, + }); + + if (!result.ok || cancelled) return; + + const latestPage = result.data; + + queryClient.setQueryData>(queryKey, (old) => { + const oldPages = old?.pages ?? []; + const existingLogs = oldPages.flatMap((page) => page.logs); + + const combined = [...latestPage.logs, ...existingLogs]; + const seen = new Set(); + const deduped: UsageLogsBatchData["logs"] = []; + + for (const log of combined) { + if (seen.has(log.id)) continue; + seen.add(log.id); + deduped.push(log); + if (deduped.length >= MAX_TOTAL_LOGS) break; + } + + if (deduped.length === 0) { + return old; + } + + const lastHasMore = + oldPages.length > 0 + ? (oldPages[oldPages.length - 1]?.hasMore ?? true) + : latestPage.hasMore; + + const chunks = chunkArray(deduped, BATCH_SIZE); + const pages = chunks.map((logs, index) => { + const isLast = index === chunks.length - 1; + const hasMore = isLast ? lastHasMore : true; + const lastLog = logs[logs.length - 1] as UsageLogWithCursor | undefined; + const cursor = lastLog ? resolveCursorFromLog(lastLog) : null; + + return { + logs, + hasMore, + nextCursor: hasMore && cursor ? cursor : null, + } satisfies UsageLogsBatchData; + }); + + const pageParams = pages.map((_, index) => + index === 0 ? undefined : (pages[index - 1]?.nextCursor ?? undefined) + ); + + return { pages, pageParams }; + }); + } catch { + // Ignore polling errors (manual refresh still available). + } finally { + refreshInFlightRef.current = false; + } + }; + + void tick(); + const intervalId = setInterval(() => void tick(), autoRefreshIntervalMs); + + return () => { + cancelled = true; + clearInterval(intervalId); + }; + }, [autoRefreshIntervalMs, filters, queryClient, queryKey, shouldPoll]); + // Flatten all pages into a single array const pages = data?.pages; const allLogs = useMemo(() => pages?.flatMap((page) => page.logs) ?? [], [pages]); diff --git a/src/app/[locale]/dashboard/logs/_hooks/use-lazy-filter-options.ts b/src/app/[locale]/dashboard/logs/_hooks/use-lazy-filter-options.ts index c78e125cc..64dfbc811 100644 --- a/src/app/[locale]/dashboard/logs/_hooks/use-lazy-filter-options.ts +++ b/src/app/[locale]/dashboard/logs/_hooks/use-lazy-filter-options.ts @@ -77,7 +77,7 @@ function createLazyFilterHook( inFlightRef.current = promise; return promise; - }, [fetcher, isLoaded]); + }, [isLoaded]); const onOpenChange = useCallback( (open: boolean) => { diff --git a/src/app/[locale]/dashboard/my-quota/page.tsx b/src/app/[locale]/dashboard/my-quota/page.tsx index 19d9d2f0d..32b169a67 100644 --- a/src/app/[locale]/dashboard/my-quota/page.tsx +++ b/src/app/[locale]/dashboard/my-quota/page.tsx @@ -2,7 +2,7 @@ import { AlertCircle } from "lucide-react"; import { getTranslations } from "next-intl/server"; import { getMyQuota } from "@/actions/my-usage"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { getSystemSettings } from "@/repository/system-config"; +import { getCachedSystemSettings } from "@/lib/config"; import { QuotaCards } from "../../my-usage/_components/quota-cards"; export const dynamic = "force-dynamic"; @@ -13,7 +13,7 @@ export default async function MyQuotaPage({ params }: { params: Promise<{ locale const [quotaResult, systemSettings, tNav, tCommon] = await Promise.all([ getMyQuota(), - getSystemSettings(), + getCachedSystemSettings(), getTranslations("dashboard.nav"), getTranslations("common"), ]); diff --git a/src/app/[locale]/dashboard/quotas/providers/page.tsx b/src/app/[locale]/dashboard/quotas/providers/page.tsx index 8086ce29e..c6bbd5cac 100644 --- a/src/app/[locale]/dashboard/quotas/providers/page.tsx +++ b/src/app/[locale]/dashboard/quotas/providers/page.tsx @@ -3,7 +3,7 @@ import { Suspense } from "react"; import { getProviderLimitUsageBatch, getProviders } from "@/actions/providers"; import { redirect } from "@/i18n/routing"; import { getSession } from "@/lib/auth"; -import { getSystemSettings } from "@/repository/system-config"; +import { getCachedSystemSettings } from "@/lib/config"; import { ProvidersQuotaSkeleton } from "../_components/providers-quota-skeleton"; import { ProvidersQuotaManager } from "./_components/providers-quota-manager"; @@ -73,7 +73,7 @@ export default async function ProvidersQuotaPage({ async function ProvidersQuotaContent() { const [providers, systemSettings] = await Promise.all([ getProvidersWithQuotas(), - getSystemSettings(), + getCachedSystemSettings(), ]); const t = await getTranslations("quota.providers"); diff --git a/src/app/[locale]/dashboard/quotas/users/page.tsx b/src/app/[locale]/dashboard/quotas/users/page.tsx index 5a85248b1..5fb3a7129 100644 --- a/src/app/[locale]/dashboard/quotas/users/page.tsx +++ b/src/app/[locale]/dashboard/quotas/users/page.tsx @@ -6,8 +6,8 @@ import { QuotaToolbar } from "@/components/quota/quota-toolbar"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Link, redirect } from "@/i18n/routing"; import { getSession } from "@/lib/auth"; +import { getCachedSystemSettings } from "@/lib/config"; import { sumKeyTotalCostBatchByIds, sumUserTotalCostBatch } from "@/repository/statistics"; -import { getSystemSettings } from "@/repository/system-config"; import { UsersQuotaSkeleton } from "../_components/users-quota-skeleton"; import type { UserKeyWithUsage, UserQuotaWithUsage } from "./_components/types"; import { UsersQuotaClient } from "./_components/users-quota-client"; @@ -116,7 +116,10 @@ export default async function UsersQuotaPage({ params }: { params: Promise<{ loc } async function UsersQuotaContent() { - const [users, systemSettings] = await Promise.all([getUsersWithQuotas(), getSystemSettings()]); + const [users, systemSettings] = await Promise.all([ + getUsersWithQuotas(), + getCachedSystemSettings(), + ]); const t = await getTranslations("quota.users"); return ( diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx index a7c6c18d5..5dc6825aa 100644 --- a/src/app/[locale]/layout.tsx +++ b/src/app/[locale]/layout.tsx @@ -6,9 +6,9 @@ import { getMessages } from "next-intl/server"; import { Footer } from "@/components/customs/footer"; import { Toaster } from "@/components/ui/sonner"; import { type Locale, locales } from "@/i18n/config"; +import { getCachedSystemSettings } from "@/lib/config"; import { logger } from "@/lib/logger"; -import { resolveSystemTimezone } from "@/lib/utils/timezone"; -import { getSystemSettings } from "@/repository/system-config"; +import { resolveSystemTimezone } from "@/lib/utils/timezone.server"; import { AppProviders } from "../providers"; const FALLBACK_TITLE = "Claude Code Hub"; @@ -21,7 +21,7 @@ export async function generateMetadata({ const { locale } = await params; try { - const settings = await getSystemSettings(); + const settings = await getCachedSystemSettings(); const title = settings.siteTitle?.trim() || FALLBACK_TITLE; // Generate alternates for all locales diff --git a/src/app/[locale]/settings/config/page.tsx b/src/app/[locale]/settings/config/page.tsx index 75bef5427..db674c9a0 100644 --- a/src/app/[locale]/settings/config/page.tsx +++ b/src/app/[locale]/settings/config/page.tsx @@ -1,7 +1,7 @@ import { getTranslations } from "next-intl/server"; import { Suspense } from "react"; import { Section } from "@/components/section"; -import { getSystemSettings } from "@/repository/system-config"; +import { getCachedSystemSettings } from "@/lib/config"; import { SettingsPageHeader } from "../_components/settings-page-header"; import { AutoCleanupForm } from "./_components/auto-cleanup-form"; import { SettingsConfigSkeleton } from "./_components/settings-config-skeleton"; @@ -28,7 +28,7 @@ export default async function SettingsConfigPage() { async function SettingsConfigContent() { const t = await getTranslations("settings"); - const settings = await getSystemSettings(); + const settings = await getCachedSystemSettings(); return ( <> diff --git a/src/app/api/leaderboard/route.ts b/src/app/api/leaderboard/route.ts index e703a1e71..f9f50c64d 100644 --- a/src/app/api/leaderboard/route.ts +++ b/src/app/api/leaderboard/route.ts @@ -1,5 +1,6 @@ import { type NextRequest, NextResponse } from "next/server"; import { getSession } from "@/lib/auth"; +import { getCachedSystemSettings } from "@/lib/config"; import { logger } from "@/lib/logger"; import { getLeaderboardWithCache } from "@/lib/redis"; import type { @@ -8,7 +9,6 @@ import type { LeaderboardScope, } from "@/lib/redis/leaderboard-cache"; import { formatCurrency } from "@/lib/utils"; -import { getSystemSettings } from "@/repository/system-config"; import type { ProviderType } from "@/types/provider"; const VALID_PERIODS: LeaderboardPeriod[] = ["daily", "weekly", "monthly", "allTime", "custom"]; @@ -49,7 +49,7 @@ export async function GET(request: NextRequest) { } // 获取系统配置 - const systemSettings = await getSystemSettings(); + const systemSettings = await getCachedSystemSettings(); // 检查权限:管理员或开启了全站使用量查看权限 const isAdmin = session.user.role === "admin"; diff --git a/src/app/api/system-settings/route.ts b/src/app/api/system-settings/route.ts index e69868eef..bd65fbb3f 100644 --- a/src/app/api/system-settings/route.ts +++ b/src/app/api/system-settings/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { getSession } from "@/lib/auth"; -import { getSystemSettings } from "@/repository/system-config"; +import { getCachedSystemSettings } from "@/lib/config"; // 需要数据库连接 export const runtime = "nodejs"; @@ -17,7 +17,7 @@ export async function GET() { return NextResponse.json({ error: "未授权,请先登录" }, { status: 401 }); } - const settings = await getSystemSettings(); + const settings = await getCachedSystemSettings(); return NextResponse.json(settings); } catch (error) { console.error("Failed to fetch system settings:", error); diff --git a/src/app/v1/_lib/proxy/provider-selector.ts b/src/app/v1/_lib/proxy/provider-selector.ts index 788d010b8..6c4b9e1b2 100644 --- a/src/app/v1/_lib/proxy/provider-selector.ts +++ b/src/app/v1/_lib/proxy/provider-selector.ts @@ -1,13 +1,13 @@ import { getCircuitState, isCircuitOpen } from "@/lib/circuit-breaker"; +import { getCachedSystemSettings } from "@/lib/config"; import { PROVIDER_GROUP } from "@/lib/constants/provider.constants"; import { logger } from "@/lib/logger"; import { RateLimitService } from "@/lib/rate-limit"; import { SessionManager } from "@/lib/session-manager"; import { isProviderActiveNow } from "@/lib/utils/provider-schedule"; -import { resolveSystemTimezone } from "@/lib/utils/timezone"; +import { resolveSystemTimezone } from "@/lib/utils/timezone.server"; import { isVendorTypeCircuitOpen } from "@/lib/vendor-type-circuit-breaker"; import { findAllProviders, findProviderById } from "@/repository/provider"; -import { getSystemSettings } from "@/repository/system-config"; import type { ProviderChainItem } from "@/types/message"; import type { Provider } from "@/types/provider"; import { isClientAllowedDetailed } from "./client-detector"; @@ -15,22 +15,9 @@ import type { ClientFormat } from "./format-mapper"; import { ProxyResponses } from "./responses"; import type { ProxySession } from "./session"; -// 系统设置缓存 - 避免每次请求失败都查询数据库 -const SETTINGS_CACHE_TTL_MS = 60_000; // 60 seconds -let cachedVerboseProviderError: { value: boolean; expiresAt: number } | null = null; - async function getVerboseProviderErrorCached(): Promise { - const now = Date.now(); - if (cachedVerboseProviderError && cachedVerboseProviderError.expiresAt > now) { - return cachedVerboseProviderError.value; - } - try { - const systemSettings = await getSystemSettings(); - cachedVerboseProviderError = { - value: systemSettings.verboseProviderError, - expiresAt: now + SETTINGS_CACHE_TTL_MS, - }; + const systemSettings = await getCachedSystemSettings(); return systemSettings.verboseProviderError; } catch (e) { logger.warn( diff --git a/src/app/v1/_lib/proxy/response-handler.ts b/src/app/v1/_lib/proxy/response-handler.ts index b533b240e..0603f1076 100644 --- a/src/app/v1/_lib/proxy/response-handler.ts +++ b/src/app/v1/_lib/proxy/response-handler.ts @@ -1,5 +1,6 @@ import { ResponseFixer } from "@/app/v1/_lib/proxy/response-fixer"; import { AsyncTaskManager } from "@/lib/async-task-manager"; +import { getCachedSystemSettings } from "@/lib/config"; import { getEnvConfig } from "@/lib/config/env.schema"; import { logger } from "@/lib/logger"; import { requestCloudPriceTableSync } from "@/lib/price-sync/cloud-price-updater"; @@ -22,7 +23,6 @@ import { updateMessageRequestDuration, } from "@/repository/message"; import { findLatestPriceByModel } from "@/repository/model-price"; -import { getSystemSettings } from "@/repository/system-config"; import type { SessionUsageUpdate } from "@/types/session"; import { GeminiAdapter } from "../gemini/adapter"; import type { GeminiResponse } from "../gemini/types"; @@ -2743,7 +2743,7 @@ async function updateRequestCostFromUsage( try { // 获取系统设置中的计费模型来源配置 - const systemSettings = await getSystemSettings(); + const systemSettings = await getCachedSystemSettings(); const billingModelSource = systemSettings.billingModelSource; // 根据配置决定计费模型优先级 diff --git a/src/app/v1/_lib/proxy/session.ts b/src/app/v1/_lib/proxy/session.ts index 74afe9400..351542f19 100644 --- a/src/app/v1/_lib/proxy/session.ts +++ b/src/app/v1/_lib/proxy/session.ts @@ -729,8 +729,8 @@ export class ProxySession { if (!this.billingModelSourcePromise) { this.billingModelSourcePromise = (async () => { try { - const { getSystemSettings } = await import("@/repository/system-config"); - const systemSettings = await getSystemSettings(); + const { getCachedSystemSettings } = await import("@/lib/config"); + const systemSettings = await getCachedSystemSettings(); const source = systemSettings.billingModelSource; if (source !== "original" && source !== "redirected") { diff --git a/src/app/v1/_lib/proxy/version-guard.ts b/src/app/v1/_lib/proxy/version-guard.ts index 66fd161f3..f8f752182 100644 --- a/src/app/v1/_lib/proxy/version-guard.ts +++ b/src/app/v1/_lib/proxy/version-guard.ts @@ -1,7 +1,7 @@ import { ClientVersionChecker } from "@/lib/client-version-checker"; +import { getCachedSystemSettings } from "@/lib/config"; import { logger } from "@/lib/logger"; import { getClientTypeDisplayName, parseUserAgent } from "@/lib/ua-parser"; -import { getSystemSettings } from "@/repository/system-config"; import type { ProxySession } from "./session"; /** @@ -29,7 +29,7 @@ export class ProxyVersionGuard { static async ensure(session: ProxySession): Promise { try { // 1. 检查系统配置 - const settings = await getSystemSettings(); + const settings = await getCachedSystemSettings(); if (!settings.enableClientVersionCheck) { logger.debug("[ProxyVersionGuard] 版本检查功能已关闭"); return null; // 功能关闭,放行 diff --git a/src/i18n/request.ts b/src/i18n/request.ts index b03ea7549..c56a51dda 100644 --- a/src/i18n/request.ts +++ b/src/i18n/request.ts @@ -4,10 +4,31 @@ */ import { getRequestConfig } from "next-intl/server"; -import { resolveSystemTimezone } from "@/lib/utils/timezone"; +import { isValidIANATimezone } from "@/lib/utils/timezone"; import type { Locale } from "./config"; import { routing } from "./routing"; +function resolveEnvTimezone(): string { + const tz = process.env.TZ?.trim(); + return tz && isValidIANATimezone(tz) ? tz : "UTC"; +} + +async function resolveRequestTimezone(): Promise { + const fallback = resolveEnvTimezone(); + + // Edge runtime 无法访问数据库/Redis,直接使用 env/UTC + if (process.env.NEXT_RUNTIME === "edge") { + return fallback; + } + + try { + const { resolveSystemTimezone } = await import("@/lib/utils/timezone.server"); + return await resolveSystemTimezone(); + } catch { + return fallback; + } +} + export default getRequestConfig(async ({ requestLocale }) => { // This typically corresponds to the `[locale]` segment in the app directory let locale = await requestLocale; @@ -22,7 +43,7 @@ export default getRequestConfig(async ({ requestLocale }) => { // The `settings` namespace is composed by `messages//settings/index.ts` so key paths stay stable. const messages = await import(`../../messages/${locale}`).then((module) => module.default); - const timeZone = await resolveSystemTimezone(); + const timeZone = await resolveRequestTimezone(); return { locale, diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 4f6749282..f54efe4b3 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -7,6 +7,7 @@ import { constantTimeEqual } from "@/lib/security/constant-time-compare"; import { findKeyList, validateApiKeyAndGetUser } from "@/repository/key"; import type { Key } from "@/types/key"; import type { User } from "@/types/user"; +import { AUTH_COOKIE_NAME } from "./auth/constants"; /** * Apply no-store / cache-busting headers to auth responses that mutate session state. @@ -38,7 +39,7 @@ declare global { var __cchAuthSessionStorage: AuthSessionStorage | undefined; } -export const AUTH_COOKIE_NAME = "auth-token"; +export { AUTH_COOKIE_NAME }; const AUTH_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; // 7 days export interface AuthSession { diff --git a/src/lib/auth/constants.ts b/src/lib/auth/constants.ts new file mode 100644 index 000000000..631a8916a --- /dev/null +++ b/src/lib/auth/constants.ts @@ -0,0 +1,3 @@ +// Edge-safe auth constants (avoid importing server-only auth modules in middleware) + +export const AUTH_COOKIE_NAME = "auth-token"; diff --git a/src/lib/config/index.ts b/src/lib/config/index.ts index fd292b3d1..1bc6fd945 100644 --- a/src/lib/config/index.ts +++ b/src/lib/config/index.ts @@ -8,4 +8,5 @@ export { getCachedSystemSettings, invalidateSystemSettingsCache, isHttp2Enabled, + publishSystemSettingsCacheInvalidation, } from "./system-settings-cache"; diff --git a/src/lib/config/system-settings-cache.ts b/src/lib/config/system-settings-cache.ts index 9382f7ee9..1cc1b35c6 100644 --- a/src/lib/config/system-settings-cache.ts +++ b/src/lib/config/system-settings-cache.ts @@ -12,17 +12,36 @@ * - Fail-open: returns default settings on error */ +import "server-only"; + import { logger } from "@/lib/logger"; +import { publishCacheInvalidation, subscribeCacheInvalidation } from "@/lib/redis/pubsub"; import { getSystemSettings } from "@/repository/system-config"; import type { SystemSettings } from "@/types/system-config"; /** Cache TTL in milliseconds (1 minute) */ const CACHE_TTL_MS = 60 * 1000; +export const CHANNEL_SYSTEM_SETTINGS_UPDATED = "cch:cache:system_settings:updated"; + /** Cached settings and timestamp */ let cachedSettings: SystemSettings | null = null; let cachedAt: number = 0; +/** + * In-flight fetch promise (dedupe concurrent cache misses) + * + * This avoids thundering herd on cold start / TTL boundary. + */ +let inFlightFetch: Promise | null = null; + +/** + * Cache generation id + * + * Incremented on invalidation to prevent stale in-flight fetches from overwriting newer cache. + */ +let cacheGeneration = 0; + /** Default settings used when cache fetch fails */ const DEFAULT_SETTINGS: Pick< SystemSettings, @@ -53,6 +72,49 @@ const DEFAULT_SETTINGS: Pick< }, }; +let subscriptionInitialized = false; +let subscriptionInitPromise: Promise | null = null; + +async function ensureSubscription(): Promise { + if (subscriptionInitialized) return; + if (subscriptionInitPromise) return subscriptionInitPromise; + + subscriptionInitPromise = (async () => { + // CI/build 阶段跳过,避免触发 Redis 连接 + if (process.env.CI === "true" || process.env.NEXT_PHASE === "phase-production-build") { + subscriptionInitialized = true; + return; + } + + const redisUrl = process.env.REDIS_URL?.trim(); + const rateLimitRaw = process.env.ENABLE_RATE_LIMIT?.trim(); + const isRateLimitEnabled = rateLimitRaw !== "false" && rateLimitRaw !== "0"; + + // Redis 不可用或未启用(当前 pubsub 实现依赖 ENABLE_RATE_LIMIT=true) + if (!redisUrl || !isRateLimitEnabled) { + subscriptionInitialized = true; + return; + } + + try { + const cleanup = await subscribeCacheInvalidation(CHANNEL_SYSTEM_SETTINGS_UPDATED, () => { + invalidateSystemSettingsCache(); + logger.debug("[SystemSettingsCache] Cache invalidated via pub/sub"); + }); + + if (!cleanup) return; + + subscriptionInitialized = true; + } catch (error) { + logger.warn("[SystemSettingsCache] Failed to subscribe settings invalidation", { error }); + } + })().finally(() => { + subscriptionInitPromise = null; + }); + + return subscriptionInitPromise; +} + /** * Get cached system settings * @@ -62,6 +124,9 @@ const DEFAULT_SETTINGS: Pick< * @returns System settings (cached or fresh) */ export async function getCachedSystemSettings(): Promise { + // 不阻塞:尽力初始化跨实例失效通知订阅 + void ensureSubscription(); + const now = Date.now(); // Return cached if still valid @@ -69,64 +134,90 @@ export async function getCachedSystemSettings(): Promise { return cachedSettings; } - try { - // Fetch fresh settings from database - const settings = await getSystemSettings(); - - // Update cache - cachedSettings = settings; - cachedAt = now; - - logger.debug("[SystemSettingsCache] Settings cached", { - enableHttp2: settings.enableHttp2, - ttl: CACHE_TTL_MS, - }); - - return settings; - } catch (error) { - // Fail-open: return previous cached value or defaults - logger.warn("[SystemSettingsCache] Failed to fetch settings, using fallback", { - hasCachedValue: !!cachedSettings, - error, - }); - - if (cachedSettings) { - return cachedSettings; + // Dedupe concurrent cache misses + if (inFlightFetch) { + return inFlightFetch; + } + + const generationAtStart = cacheGeneration; + + const fetchPromise = (async (): Promise => { + try { + // Fetch fresh settings from database + const settings = await getSystemSettings(); + + // Update cache only if generation unchanged + if (cacheGeneration === generationAtStart) { + cachedSettings = settings; + cachedAt = Date.now(); + logger.debug("[SystemSettingsCache] Settings cached", { + enableHttp2: settings.enableHttp2, + ttl: CACHE_TTL_MS, + }); + } + + return settings; + } catch (error) { + // Fail-open: return previous cached value or defaults + logger.warn("[SystemSettingsCache] Failed to fetch settings, using fallback", { + hasCachedValue: !!cachedSettings, + error, + }); + + const fallback: SystemSettings = + cachedSettings ?? + ({ + // Return minimal default settings - this should rarely happen + // since getSystemSettings creates default row if not exists + id: 0, + siteTitle: "Claude Code Hub", + allowGlobalUsageView: false, + currencyDisplay: "USD", + billingModelSource: "original", + timezone: null, + verboseProviderError: false, + enableAutoCleanup: false, + cleanupRetentionDays: 30, + cleanupSchedule: "0 2 * * *", + cleanupBatchSize: 10000, + enableClientVersionCheck: false, + enableHttp2: DEFAULT_SETTINGS.enableHttp2, + interceptAnthropicWarmupRequests: DEFAULT_SETTINGS.interceptAnthropicWarmupRequests, + enableThinkingSignatureRectifier: DEFAULT_SETTINGS.enableThinkingSignatureRectifier, + enableThinkingBudgetRectifier: DEFAULT_SETTINGS.enableThinkingBudgetRectifier, + enableBillingHeaderRectifier: DEFAULT_SETTINGS.enableBillingHeaderRectifier, + enableCodexSessionIdCompletion: DEFAULT_SETTINGS.enableCodexSessionIdCompletion, + enableClaudeMetadataUserIdInjection: DEFAULT_SETTINGS.enableClaudeMetadataUserIdInjection, + enableResponseFixer: DEFAULT_SETTINGS.enableResponseFixer, + responseFixerConfig: DEFAULT_SETTINGS.responseFixerConfig, + quotaDbRefreshIntervalSeconds: 10, + quotaLeasePercent5h: 0.05, + quotaLeasePercentDaily: 0.05, + quotaLeasePercentWeekly: 0.05, + quotaLeasePercentMonthly: 0.05, + quotaLeaseCapUsd: null, + createdAt: new Date(), + updatedAt: new Date(), + } satisfies SystemSettings); + + // 将 fallback 也写入缓存,避免在 DB 不可用时每次调用都重复打点/重试 + if (cacheGeneration === generationAtStart) { + cachedSettings = fallback; + cachedAt = Date.now(); + } + + return fallback; } + })(); - // Return minimal default settings - this should rarely happen - // since getSystemSettings creates default row if not exists - return { - id: 0, - siteTitle: "Claude Code Hub", - allowGlobalUsageView: false, - currencyDisplay: "USD", - billingModelSource: "original", - timezone: null, - verboseProviderError: false, - enableAutoCleanup: false, - cleanupRetentionDays: 30, - cleanupSchedule: "0 2 * * *", - cleanupBatchSize: 10000, - enableClientVersionCheck: false, - enableHttp2: DEFAULT_SETTINGS.enableHttp2, - interceptAnthropicWarmupRequests: DEFAULT_SETTINGS.interceptAnthropicWarmupRequests, - enableThinkingSignatureRectifier: DEFAULT_SETTINGS.enableThinkingSignatureRectifier, - enableThinkingBudgetRectifier: DEFAULT_SETTINGS.enableThinkingBudgetRectifier, - enableBillingHeaderRectifier: DEFAULT_SETTINGS.enableBillingHeaderRectifier, - enableCodexSessionIdCompletion: DEFAULT_SETTINGS.enableCodexSessionIdCompletion, - enableClaudeMetadataUserIdInjection: DEFAULT_SETTINGS.enableClaudeMetadataUserIdInjection, - enableResponseFixer: DEFAULT_SETTINGS.enableResponseFixer, - responseFixerConfig: DEFAULT_SETTINGS.responseFixerConfig, - quotaDbRefreshIntervalSeconds: 10, - quotaLeasePercent5h: 0.05, - quotaLeasePercentDaily: 0.05, - quotaLeasePercentWeekly: 0.05, - quotaLeasePercentMonthly: 0.05, - quotaLeaseCapUsd: null, - createdAt: new Date(), - updatedAt: new Date(), - } satisfies SystemSettings; + inFlightFetch = fetchPromise; + + try { + return await fetchPromise; + } finally { + if (inFlightFetch === fetchPromise) { + inFlightFetch = null; + } } } @@ -147,7 +238,27 @@ export async function isHttp2Enabled(): Promise { * the next request gets fresh settings. */ export function invalidateSystemSettingsCache(): void { + cacheGeneration++; cachedSettings = null; cachedAt = 0; + inFlightFetch = null; logger.info("[SystemSettingsCache] Cache invalidated"); } + +/** + * Invalidate settings cache and publish cross-instance invalidation notification. + * + * Use this after system settings are saved. + */ +export async function publishSystemSettingsCacheInvalidation(): Promise { + invalidateSystemSettingsCache(); + + const redisUrl = process.env.REDIS_URL?.trim(); + const rateLimitRaw = process.env.ENABLE_RATE_LIMIT?.trim(); + const isRateLimitEnabled = rateLimitRaw !== "false" && rateLimitRaw !== "0"; + + if (!redisUrl || !isRateLimitEnabled) return; + + await publishCacheInvalidation(CHANNEL_SYSTEM_SETTINGS_UPDATED); + logger.debug("[SystemSettingsCache] Published cache invalidation"); +} diff --git a/src/lib/notification/notification-queue.ts b/src/lib/notification/notification-queue.ts index 7c9d0d23f..f96078d8a 100644 --- a/src/lib/notification/notification-queue.ts +++ b/src/lib/notification/notification-queue.ts @@ -10,7 +10,7 @@ import { } from "@/lib/notification/tasks/cache-hit-rate-alert"; import { generateCostAlerts } from "@/lib/notification/tasks/cost-alert"; import { generateDailyLeaderboard } from "@/lib/notification/tasks/daily-leaderboard"; -import { resolveSystemTimezone } from "@/lib/utils/timezone"; +import { resolveSystemTimezone } from "@/lib/utils/timezone.server"; import { buildCacheHitRateAlertMessage, buildCircuitBreakerMessage, diff --git a/src/lib/notification/tasks/cache-hit-rate-alert.ts b/src/lib/notification/tasks/cache-hit-rate-alert.ts index 0340f04a9..fbedeeb17 100644 --- a/src/lib/notification/tasks/cache-hit-rate-alert.ts +++ b/src/lib/notification/tasks/cache-hit-rate-alert.ts @@ -7,7 +7,7 @@ import { } from "@/lib/cache-hit-rate-alert/decision"; import { logger } from "@/lib/logger"; import { getRedisClient } from "@/lib/redis/client"; -import { resolveSystemTimezone } from "@/lib/utils/timezone"; +import { resolveSystemTimezone } from "@/lib/utils/timezone.server"; import type { CacheHitRateAlertData, CacheHitRateAlertSettingsSnapshot, diff --git a/src/lib/notification/tasks/daily-leaderboard.ts b/src/lib/notification/tasks/daily-leaderboard.ts index a008435c5..b065989d7 100644 --- a/src/lib/notification/tasks/daily-leaderboard.ts +++ b/src/lib/notification/tasks/daily-leaderboard.ts @@ -1,5 +1,5 @@ import { logger } from "@/lib/logger"; -import { resolveSystemTimezone } from "@/lib/utils/timezone"; +import { resolveSystemTimezone } from "@/lib/utils/timezone.server"; import type { DailyLeaderboardData } from "@/lib/webhook"; import { findLast24HoursLeaderboard } from "@/repository/leaderboard"; diff --git a/src/lib/rate-limit/time-utils.ts b/src/lib/rate-limit/time-utils.ts index 1edd48b94..9676823c5 100644 --- a/src/lib/rate-limit/time-utils.ts +++ b/src/lib/rate-limit/time-utils.ts @@ -15,7 +15,7 @@ import { startOfWeek, } from "date-fns"; import { fromZonedTime, toZonedTime } from "date-fns-tz"; -import { resolveSystemTimezone } from "@/lib/utils/timezone"; +import { resolveSystemTimezone } from "@/lib/utils/timezone.server"; export type TimePeriod = "5h" | "daily" | "weekly" | "monthly"; export type DailyResetMode = "fixed" | "rolling"; diff --git a/src/lib/redis/leaderboard-cache.ts b/src/lib/redis/leaderboard-cache.ts index 5bbdc3251..5da19800d 100644 --- a/src/lib/redis/leaderboard-cache.ts +++ b/src/lib/redis/leaderboard-cache.ts @@ -1,6 +1,6 @@ import { formatInTimeZone } from "date-fns-tz"; import { logger } from "@/lib/logger"; -import { resolveSystemTimezone } from "@/lib/utils/timezone"; +import { resolveSystemTimezone } from "@/lib/utils/timezone.server"; import { type DateRangeParams, findAllTimeLeaderboard, diff --git a/src/lib/security/constant-time-compare.ts b/src/lib/security/constant-time-compare.ts index 7452358ea..1ec8a0767 100644 --- a/src/lib/security/constant-time-compare.ts +++ b/src/lib/security/constant-time-compare.ts @@ -23,9 +23,9 @@ export function constantTimeEqual(a: string, b: string): boolean { const padB = new Uint8Array(padLen); padA.set(bufA); padB.set(bufB); - let dummy = 0; + let _dummy = 0; for (let i = 0; i < padLen; i++) { - dummy |= padA[i] ^ padB[i]; + _dummy |= padA[i] ^ padB[i]; } return false; } diff --git a/src/lib/utils/timezone.server.ts b/src/lib/utils/timezone.server.ts new file mode 100644 index 000000000..89a5ea67e --- /dev/null +++ b/src/lib/utils/timezone.server.ts @@ -0,0 +1,41 @@ +import "server-only"; + +import { getEnvConfig } from "@/lib/config/env.schema"; +import { getCachedSystemSettings } from "@/lib/config/system-settings-cache"; +import { logger } from "@/lib/logger"; +import { isValidIANATimezone } from "./timezone"; + +/** + * Resolves the system timezone using the fallback chain: + * 1. DB system_settings.timezone (via cached settings) + * 2. env TZ variable + * 3. "UTC" as final fallback + * + * Each candidate is validated via isValidIANATimezone before being accepted. + * + * @returns Resolved IANA timezone identifier (always valid) + */ +export async function resolveSystemTimezone(): Promise { + // Step 1: Try DB timezone from cached system settings + try { + const settings = await getCachedSystemSettings(); + if (settings.timezone && isValidIANATimezone(settings.timezone)) { + return settings.timezone; + } + } catch (error) { + logger.warn("[TimezoneResolver] Failed to read cached system settings", { error }); + } + + // Step 2: Fallback to env TZ + try { + const { TZ } = getEnvConfig(); + if (TZ && isValidIANATimezone(TZ)) { + return TZ; + } + } catch (error) { + logger.warn("[TimezoneResolver] Failed to read env TZ", { error }); + } + + // Step 3: Ultimate fallback + return "UTC"; +} diff --git a/src/lib/utils/timezone.ts b/src/lib/utils/timezone.ts index 659207fca..ac7ac5c72 100644 --- a/src/lib/utils/timezone.ts +++ b/src/lib/utils/timezone.ts @@ -1,17 +1,12 @@ /** * Timezone Utilities * - * Provides timezone validation and resolution functions. + * Provides timezone validation and formatting helpers (client-safe). * Uses IANA timezone database identifiers (e.g., "Asia/Shanghai", "America/New_York"). * - * resolveSystemTimezone() implements the fallback chain: - * DB timezone -> env TZ -> UTC + * Server-only system timezone resolution lives in `timezone.server.ts`. */ -import { getEnvConfig } from "@/lib/config/env.schema"; -import { getCachedSystemSettings } from "@/lib/config/system-settings-cache"; -import { logger } from "@/lib/logger"; - /** * Common IANA timezone identifiers for dropdown UI. * Organized by region for better UX. @@ -144,27 +139,3 @@ export function getTimezoneOffsetMinutes(timezone: string): number { * * @returns Resolved IANA timezone identifier (always valid) */ -export async function resolveSystemTimezone(): Promise { - // Step 1: Try DB timezone from cached system settings - try { - const settings = await getCachedSystemSettings(); - if (settings.timezone && isValidIANATimezone(settings.timezone)) { - return settings.timezone; - } - } catch (error) { - logger.warn("[TimezoneResolver] Failed to read cached system settings", { error }); - } - - // Step 2: Fallback to env TZ - try { - const { TZ } = getEnvConfig(); - if (TZ && isValidIANATimezone(TZ)) { - return TZ; - } - } catch (error) { - logger.warn("[TimezoneResolver] Failed to read env TZ", { error }); - } - - // Step 3: Ultimate fallback - return "UTC"; -} diff --git a/src/proxy.ts b/src/proxy.ts index 05cae00ac..637fd9d9e 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -2,7 +2,7 @@ import { type NextRequest, NextResponse } from "next/server"; import createMiddleware from "next-intl/middleware"; import type { Locale } from "@/i18n/config"; import { routing } from "@/i18n/routing"; -import { AUTH_COOKIE_NAME } from "@/lib/auth"; +import { AUTH_COOKIE_NAME } from "@/lib/auth/constants"; import { isDevelopment } from "@/lib/config/env.schema"; import { logger } from "@/lib/logger"; diff --git a/src/repository/leaderboard.ts b/src/repository/leaderboard.ts index 451e6c440..8cb931170 100644 --- a/src/repository/leaderboard.ts +++ b/src/repository/leaderboard.ts @@ -3,10 +3,10 @@ import { and, desc, eq, isNull, sql } from "drizzle-orm"; import { db } from "@/drizzle/db"; import { providers, usageLedger, users } from "@/drizzle/schema"; -import { resolveSystemTimezone } from "@/lib/utils/timezone"; +import { getCachedSystemSettings } from "@/lib/config"; +import { resolveSystemTimezone } from "@/lib/utils/timezone.server"; import type { ProviderType } from "@/types/provider"; import { LEDGER_BILLING_CONDITION } from "./_shared/ledger-conditions"; -import { getSystemSettings } from "./system-config"; /** * 排行榜条目类型 @@ -527,7 +527,7 @@ async function findProviderCacheHitRateLeaderboardWithTimezone( .orderBy(desc(cacheHitRateExpr), desc(sql`count(*)`)); // Model-level cache hit breakdown per provider - const systemSettings = await getSystemSettings(); + const systemSettings = await getCachedSystemSettings(); const billingModelSource = systemSettings.billingModelSource; const modelField = billingModelSource === "original" @@ -661,7 +661,7 @@ async function findModelLeaderboardWithTimezone( dateRange?: DateRangeParams ): Promise { // 获取系统设置中的计费模型来源配置 - const systemSettings = await getSystemSettings(); + const systemSettings = await getCachedSystemSettings(); const billingModelSource = systemSettings.billingModelSource; // 根据配置决定模型字段的优先级 diff --git a/src/repository/notification-bindings.ts b/src/repository/notification-bindings.ts index 3445d79b1..a8c1ae1da 100644 --- a/src/repository/notification-bindings.ts +++ b/src/repository/notification-bindings.ts @@ -3,7 +3,7 @@ import { and, desc, eq, notInArray } from "drizzle-orm"; import { db } from "@/drizzle/db"; import { notificationTargetBindings, webhookTargets } from "@/drizzle/schema"; -import { resolveSystemTimezone } from "@/lib/utils/timezone"; +import { resolveSystemTimezone } from "@/lib/utils/timezone.server"; import type { WebhookProviderType, WebhookTarget, WebhookTestResult } from "./webhook-targets"; export type NotificationType = diff --git a/src/repository/overview.ts b/src/repository/overview.ts index 61f337087..f8060650b 100644 --- a/src/repository/overview.ts +++ b/src/repository/overview.ts @@ -4,7 +4,7 @@ import { and, avg, count, eq, gte, lt, sql, sum } from "drizzle-orm"; import { db } from "@/drizzle/db"; import { usageLedger } from "@/drizzle/schema"; import { Decimal, toCostDecimal } from "@/lib/utils/currency"; -import { resolveSystemTimezone } from "@/lib/utils/timezone"; +import { resolveSystemTimezone } from "@/lib/utils/timezone.server"; import { LEDGER_BILLING_CONDITION } from "./_shared/ledger-conditions"; /** diff --git a/src/repository/provider.ts b/src/repository/provider.ts index 00e5c2bb3..c352389f5 100644 --- a/src/repository/provider.ts +++ b/src/repository/provider.ts @@ -6,7 +6,7 @@ import { providerEndpoints, providers } from "@/drizzle/schema"; import { getCachedProviders } from "@/lib/cache/provider-cache"; import { resetEndpointCircuit } from "@/lib/endpoint-circuit-breaker"; import { logger } from "@/lib/logger"; -import { resolveSystemTimezone } from "@/lib/utils/timezone"; +import { resolveSystemTimezone } from "@/lib/utils/timezone.server"; import type { AnthropicAdaptiveThinkingConfig, CreateProviderData, diff --git a/src/repository/statistics.ts b/src/repository/statistics.ts index 7df64e5d0..e638662d1 100644 --- a/src/repository/statistics.ts +++ b/src/repository/statistics.ts @@ -5,7 +5,7 @@ import { and, eq, gte, inArray, isNull, lt, sql } from "drizzle-orm"; import { db } from "@/drizzle/db"; import { keys, messageRequest, usageLedger } from "@/drizzle/schema"; import { TTLMap } from "@/lib/cache/ttl-map"; -import { resolveSystemTimezone } from "@/lib/utils/timezone"; +import { resolveSystemTimezone } from "@/lib/utils/timezone.server"; import type { DatabaseKey, DatabaseKeyStatRow, diff --git a/tests/integration/billing-model-source.test.ts b/tests/integration/billing-model-source.test.ts index 580275668..f3eedc8ea 100644 --- a/tests/integration/billing-model-source.test.ts +++ b/tests/integration/billing-model-source.test.ts @@ -78,6 +78,7 @@ vi.mock("@/lib/proxy-status-tracker", () => ({ import { ProxyResponseHandler } from "@/app/v1/_lib/proxy/response-handler"; import { ProxySession } from "@/app/v1/_lib/proxy/session"; +import { invalidateSystemSettingsCache } from "@/lib/config"; import { SessionManager } from "@/lib/session-manager"; import { RateLimitService } from "@/lib/rate-limit"; import { SessionTracker } from "@/lib/session-tracker"; @@ -91,6 +92,7 @@ import { getSystemSettings } from "@/repository/system-config"; beforeEach(() => { cloudPriceSyncRequests.splice(0, cloudPriceSyncRequests.length); + invalidateSystemSettingsCache(); }); function makeSystemSettings( @@ -257,6 +259,8 @@ async function runScenario({ billingModelSource: SystemSettings["billingModelSource"]; isStream: boolean; }): Promise<{ dbCostUsd: string; sessionCostUsd: string; rateLimitCost: number }> { + invalidateSystemSettingsCache(); + const usage = { input_tokens: 2, output_tokens: 3 }; const originalModel = "original-model"; const redirectedModel = "redirected-model"; @@ -377,6 +381,8 @@ describe("价格表缺失/查询失败:不计费放行", () => { isStream: boolean; priceLookup: "none" | "throws"; }): Promise<{ dbCostCalls: number; rateLimitCalls: number }> { + invalidateSystemSettingsCache(); + const usage = { input_tokens: 2, output_tokens: 3 }; const originalModel = "original-model"; const redirectedModel = "redirected-model"; diff --git a/tests/unit/actions/my-usage-date-range-dst.test.ts b/tests/unit/actions/my-usage-date-range-dst.test.ts index ee1f8fea4..15b714610 100644 --- a/tests/unit/actions/my-usage-date-range-dst.test.ts +++ b/tests/unit/actions/my-usage-date-range-dst.test.ts @@ -24,7 +24,7 @@ vi.mock("@/repository/usage-logs", async (importOriginal) => { }; }); -vi.mock("@/lib/utils/timezone", () => ({ +vi.mock("@/lib/utils/timezone.server", () => ({ resolveSystemTimezone: mocks.resolveSystemTimezone, })); diff --git a/tests/unit/actions/my-usage-token-aggregation.test.ts b/tests/unit/actions/my-usage-token-aggregation.test.ts index 6b0c750ea..7a82e86ef 100644 --- a/tests/unit/actions/my-usage-token-aggregation.test.ts +++ b/tests/unit/actions/my-usage-token-aggregation.test.ts @@ -70,9 +70,14 @@ vi.mock("@/repository/system-config", () => ({ getSystemSettings: mocks.getSystemSettings, })); -vi.mock("@/lib/config", () => ({ - getEnvConfig: mocks.getEnvConfig, -})); +vi.mock("@/lib/config", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getEnvConfig: mocks.getEnvConfig, + getCachedSystemSettings: () => mocks.getSystemSettings(), + }; +}); vi.mock("@/lib/rate-limit/time-utils", () => ({ getTimeRangeForPeriodWithMode: mocks.getTimeRangeForPeriodWithMode, diff --git a/tests/unit/actions/system-config-save.test.ts b/tests/unit/actions/system-config-save.test.ts index b90f06c82..595f408ff 100644 --- a/tests/unit/actions/system-config-save.test.ts +++ b/tests/unit/actions/system-config-save.test.ts @@ -4,7 +4,7 @@ import { locales } from "@/i18n/config"; // Mock dependencies const getSessionMock = vi.fn(); const revalidatePathMock = vi.fn(); -const invalidateSystemSettingsCacheMock = vi.fn(); +const publishSystemSettingsCacheInvalidationMock = vi.fn(); const updateSystemSettingsMock = vi.fn(); const getSystemSettingsMock = vi.fn(); @@ -17,7 +17,7 @@ vi.mock("next/cache", () => ({ })); vi.mock("@/lib/config", () => ({ - invalidateSystemSettingsCache: () => invalidateSystemSettingsCacheMock(), + publishSystemSettingsCacheInvalidation: () => publishSystemSettingsCacheInvalidationMock(), })); vi.mock("@/lib/logger", () => ({ @@ -30,7 +30,7 @@ vi.mock("@/lib/logger", () => ({ }, })); -vi.mock("@/lib/utils/timezone", () => ({ +vi.mock("@/lib/utils/timezone.server", () => ({ resolveSystemTimezone: vi.fn(async () => "UTC"), isValidIANATimezone: vi.fn(() => true), })); @@ -124,10 +124,10 @@ describe("saveSystemSettings", () => { ); }); - it("should invalidate system settings cache after successful save", async () => { + it("should publish system settings cache invalidation after successful save", async () => { await saveSystemSettings({ siteTitle: "New Title" }); - expect(invalidateSystemSettingsCacheMock).toHaveBeenCalled(); + expect(publishSystemSettingsCacheInvalidationMock).toHaveBeenCalled(); }); describe("revalidatePath locale coverage", () => { diff --git a/tests/unit/lib/rate-limit/cost-limits.test.ts b/tests/unit/lib/rate-limit/cost-limits.test.ts index b8299251e..f317fd56a 100644 --- a/tests/unit/lib/rate-limit/cost-limits.test.ts +++ b/tests/unit/lib/rate-limit/cost-limits.test.ts @@ -36,7 +36,7 @@ vi.mock("@/lib/redis", () => ({ const resolveSystemTimezoneMock = vi.hoisted(() => vi.fn(async () => "Asia/Shanghai")); -vi.mock("@/lib/utils/timezone", () => ({ +vi.mock("@/lib/utils/timezone.server", () => ({ resolveSystemTimezone: resolveSystemTimezoneMock, })); diff --git a/tests/unit/lib/rate-limit/lease.test.ts b/tests/unit/lib/rate-limit/lease.test.ts index 6816cbb0b..f21d965b6 100644 --- a/tests/unit/lib/rate-limit/lease.test.ts +++ b/tests/unit/lib/rate-limit/lease.test.ts @@ -7,11 +7,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; // Mock resolveSystemTimezone before importing lease module -vi.mock("@/lib/utils/timezone", () => ({ +vi.mock("@/lib/utils/timezone.server", () => ({ resolveSystemTimezone: vi.fn(async () => "Asia/Shanghai"), })); -import { resolveSystemTimezone } from "@/lib/utils/timezone"; +import { resolveSystemTimezone } from "@/lib/utils/timezone.server"; describe("lease module", () => { const nowMs = 1706400000000; // 2024-01-28 00:00:00 UTC diff --git a/tests/unit/lib/rate-limit/rolling-window-5h.test.ts b/tests/unit/lib/rate-limit/rolling-window-5h.test.ts index 1fe0cd1d2..ef687bba1 100644 --- a/tests/unit/lib/rate-limit/rolling-window-5h.test.ts +++ b/tests/unit/lib/rate-limit/rolling-window-5h.test.ts @@ -13,7 +13,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; // Mock resolveSystemTimezone before importing modules -vi.mock("@/lib/utils/timezone", () => ({ +vi.mock("@/lib/utils/timezone.server", () => ({ resolveSystemTimezone: vi.fn(async () => "Asia/Shanghai"), })); diff --git a/tests/unit/lib/rate-limit/rolling-window-cache-warm.test.ts b/tests/unit/lib/rate-limit/rolling-window-cache-warm.test.ts index cee080f43..6f267da3d 100644 --- a/tests/unit/lib/rate-limit/rolling-window-cache-warm.test.ts +++ b/tests/unit/lib/rate-limit/rolling-window-cache-warm.test.ts @@ -31,7 +31,7 @@ vi.mock("@/lib/redis", () => ({ getRedisClient: () => redisClient, })); -vi.mock("@/lib/utils/timezone", () => ({ +vi.mock("@/lib/utils/timezone.server", () => ({ resolveSystemTimezone: vi.fn(async () => "Asia/Shanghai"), })); diff --git a/tests/unit/lib/rate-limit/service-extra.test.ts b/tests/unit/lib/rate-limit/service-extra.test.ts index 2de1b381b..6e728b4c3 100644 --- a/tests/unit/lib/rate-limit/service-extra.test.ts +++ b/tests/unit/lib/rate-limit/service-extra.test.ts @@ -56,7 +56,7 @@ vi.mock("@/lib/redis", () => ({ const resolveSystemTimezoneMock = vi.hoisted(() => vi.fn(async () => "Asia/Shanghai")); -vi.mock("@/lib/utils/timezone", () => ({ +vi.mock("@/lib/utils/timezone.server", () => ({ resolveSystemTimezone: resolveSystemTimezoneMock, })); diff --git a/tests/unit/lib/rate-limit/time-utils.test.ts b/tests/unit/lib/rate-limit/time-utils.test.ts index e44d4f02f..c0d7ddb3a 100644 --- a/tests/unit/lib/rate-limit/time-utils.test.ts +++ b/tests/unit/lib/rate-limit/time-utils.test.ts @@ -1,11 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; // Mock resolveSystemTimezone before importing time-utils -vi.mock("@/lib/utils/timezone", () => ({ +vi.mock("@/lib/utils/timezone.server", () => ({ resolveSystemTimezone: vi.fn(async () => "Asia/Shanghai"), })); -import { resolveSystemTimezone } from "@/lib/utils/timezone"; +import { resolveSystemTimezone } from "@/lib/utils/timezone.server"; import { getDailyResetTime, getResetInfo, diff --git a/tests/unit/lib/timezone/timezone-resolver.test.ts b/tests/unit/lib/timezone/timezone-resolver.test.ts index 4650b86b7..b821a3d0b 100644 --- a/tests/unit/lib/timezone/timezone-resolver.test.ts +++ b/tests/unit/lib/timezone/timezone-resolver.test.ts @@ -103,7 +103,7 @@ beforeEach(() => { describe("resolveSystemTimezone", () => { it("should return DB timezone when set and valid", async () => { - const { resolveSystemTimezone } = await import("@/lib/utils/timezone"); + const { resolveSystemTimezone } = await import("@/lib/utils/timezone.server"); getCachedSystemSettingsMock.mockResolvedValue(createSettings({ timezone: "America/New_York" })); mockEnvConfig("Asia/Shanghai"); @@ -113,7 +113,7 @@ describe("resolveSystemTimezone", () => { }); it("should fallback to env TZ when DB timezone is null", async () => { - const { resolveSystemTimezone } = await import("@/lib/utils/timezone"); + const { resolveSystemTimezone } = await import("@/lib/utils/timezone.server"); getCachedSystemSettingsMock.mockResolvedValue(createSettings({ timezone: null })); mockEnvConfig("Europe/London"); @@ -123,7 +123,7 @@ describe("resolveSystemTimezone", () => { }); it("should fallback to env TZ when DB timezone is invalid", async () => { - const { resolveSystemTimezone } = await import("@/lib/utils/timezone"); + const { resolveSystemTimezone } = await import("@/lib/utils/timezone.server"); getCachedSystemSettingsMock.mockResolvedValue( createSettings({ timezone: "Invalid/Timezone_Zone" }) @@ -135,7 +135,7 @@ describe("resolveSystemTimezone", () => { }); it("should fallback to UTC when both DB timezone and env TZ are invalid", async () => { - const { resolveSystemTimezone } = await import("@/lib/utils/timezone"); + const { resolveSystemTimezone } = await import("@/lib/utils/timezone.server"); getCachedSystemSettingsMock.mockResolvedValue(createSettings({ timezone: "Invalid/Zone" })); // Empty string TZ won't pass isValidIANATimezone @@ -146,7 +146,7 @@ describe("resolveSystemTimezone", () => { }); it("should fallback to UTC when getCachedSystemSettings throws", async () => { - const { resolveSystemTimezone } = await import("@/lib/utils/timezone"); + const { resolveSystemTimezone } = await import("@/lib/utils/timezone.server"); getCachedSystemSettingsMock.mockRejectedValue(new Error("DB connection failed")); mockEnvConfig("Asia/Shanghai"); @@ -157,7 +157,7 @@ describe("resolveSystemTimezone", () => { }); it("should fallback to UTC when getCachedSystemSettings throws and env TZ is empty", async () => { - const { resolveSystemTimezone } = await import("@/lib/utils/timezone"); + const { resolveSystemTimezone } = await import("@/lib/utils/timezone.server"); getCachedSystemSettingsMock.mockRejectedValue(new Error("DB connection failed")); mockEnvConfig(""); @@ -167,7 +167,7 @@ describe("resolveSystemTimezone", () => { }); it("should handle empty string DB timezone as null", async () => { - const { resolveSystemTimezone } = await import("@/lib/utils/timezone"); + const { resolveSystemTimezone } = await import("@/lib/utils/timezone.server"); getCachedSystemSettingsMock.mockResolvedValue( createSettings({ timezone: "" as unknown as null }) diff --git a/tests/unit/notification/cost-alert-window.test.ts b/tests/unit/notification/cost-alert-window.test.ts index 6fcdcb441..b8870343a 100644 --- a/tests/unit/notification/cost-alert-window.test.ts +++ b/tests/unit/notification/cost-alert-window.test.ts @@ -52,7 +52,7 @@ vi.mock("@/lib/logger", () => ({ }, })); -vi.mock("@/lib/utils/timezone", () => ({ +vi.mock("@/lib/utils/timezone.server", () => ({ resolveSystemTimezone: vi.fn(async () => "Asia/Shanghai"), })); diff --git a/tests/unit/proxy/session.test.ts b/tests/unit/proxy/session.test.ts index 9771ea4df..1adbdb367 100644 --- a/tests/unit/proxy/session.test.ts +++ b/tests/unit/proxy/session.test.ts @@ -9,13 +9,14 @@ vi.mock("@/repository/model-price", () => ({ findLatestPriceByModel: vi.fn(), })); -vi.mock("@/repository/system-config", () => ({ - getSystemSettings: vi.fn(), -})); +vi.mock("@/lib/config", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, getCachedSystemSettings: vi.fn() }; +}); import { ProxySession } from "@/app/v1/_lib/proxy/session"; +import { getCachedSystemSettings } from "@/lib/config"; import { findLatestPriceByModel } from "@/repository/model-price"; -import { getSystemSettings } from "@/repository/system-config"; function makeSystemSettings( billingModelSource: SystemSettings["billingModelSource"] @@ -158,7 +159,7 @@ describe("ProxySession.getCachedPriceDataByBillingSource", () => { output_cost_per_token: 4, }; - vi.mocked(getSystemSettings).mockResolvedValue(makeSystemSettings("original")); + vi.mocked(getCachedSystemSettings).mockResolvedValue(makeSystemSettings("original")); vi.mocked(findLatestPriceByModel).mockImplementation(async (modelName: string) => { if (modelName === "original-model") { return makePriceRecord(modelName, originalPriceData); @@ -187,7 +188,7 @@ describe("ProxySession.getCachedPriceDataByBillingSource", () => { output_cost_per_token: 4, }; - vi.mocked(getSystemSettings).mockResolvedValue(makeSystemSettings("redirected")); + vi.mocked(getCachedSystemSettings).mockResolvedValue(makeSystemSettings("redirected")); vi.mocked(findLatestPriceByModel).mockImplementation(async (modelName: string) => { if (modelName === "original-model") { return makePriceRecord(modelName, originalPriceData); @@ -215,7 +216,7 @@ describe("ProxySession.getCachedPriceDataByBillingSource", () => { output_cost_per_token: 4, }; - vi.mocked(getSystemSettings).mockResolvedValue(makeSystemSettings("original")); + vi.mocked(getCachedSystemSettings).mockResolvedValue(makeSystemSettings("original")); vi.mocked(findLatestPriceByModel) .mockResolvedValueOnce(makePriceRecord("original-model", {})) .mockResolvedValueOnce(makePriceRecord("redirected-model", redirectedPriceData)); @@ -238,7 +239,7 @@ describe("ProxySession.getCachedPriceDataByBillingSource", () => { output_cost_per_token: 4, }; - vi.mocked(getSystemSettings).mockResolvedValue(makeSystemSettings("original")); + vi.mocked(getCachedSystemSettings).mockResolvedValue(makeSystemSettings("original")); vi.mocked(findLatestPriceByModel) .mockResolvedValueOnce(null) .mockResolvedValueOnce(makePriceRecord("redirected-model", redirectedPriceData)); @@ -255,13 +256,13 @@ describe("ProxySession.getCachedPriceDataByBillingSource", () => { expect(findLatestPriceByModel).toHaveBeenNthCalledWith(2, "redirected-model"); }); - it("应在 getSystemSettings 失败时回退到 redirected", async () => { + it("应在 getCachedSystemSettings 失败时回退到 redirected", async () => { const redirectedPriceData: ModelPriceData = { input_cost_per_token: 3, output_cost_per_token: 4, }; - vi.mocked(getSystemSettings).mockRejectedValue(new Error("DB error")); + vi.mocked(getCachedSystemSettings).mockRejectedValue(new Error("DB error")); vi.mocked(findLatestPriceByModel).mockResolvedValue( makePriceRecord("redirected-model", redirectedPriceData) ); @@ -273,7 +274,7 @@ describe("ProxySession.getCachedPriceDataByBillingSource", () => { const result = await session.getCachedPriceDataByBillingSource(); expect(result).toEqual(redirectedPriceData); - expect(getSystemSettings).toHaveBeenCalledTimes(1); + expect(getCachedSystemSettings).toHaveBeenCalledTimes(1); expect(findLatestPriceByModel).toHaveBeenCalledTimes(1); expect(findLatestPriceByModel).toHaveBeenCalledWith("redirected-model"); @@ -287,7 +288,7 @@ describe("ProxySession.getCachedPriceDataByBillingSource", () => { output_cost_per_token: 4, }; - vi.mocked(getSystemSettings).mockResolvedValue({ + vi.mocked(getCachedSystemSettings).mockResolvedValue({ ...makeSystemSettings("redirected"), billingModelSource: "invalid" as any, } as any); @@ -310,7 +311,7 @@ describe("ProxySession.getCachedPriceDataByBillingSource", () => { }); it("当原始模型等于重定向模型时应避免重复查询", async () => { - vi.mocked(getSystemSettings).mockResolvedValue(makeSystemSettings("original")); + vi.mocked(getCachedSystemSettings).mockResolvedValue(makeSystemSettings("original")); vi.mocked(findLatestPriceByModel).mockResolvedValue(null); const session = createSession({ @@ -327,7 +328,7 @@ describe("ProxySession.getCachedPriceDataByBillingSource", () => { it("并发调用时应只读取一次配置", async () => { const priceData: ModelPriceData = { input_cost_per_token: 1, output_cost_per_token: 2 }; - vi.mocked(getSystemSettings).mockImplementation(async () => { + vi.mocked(getCachedSystemSettings).mockImplementation(async () => { await new Promise((resolve) => setTimeout(resolve, 10)); return makeSystemSettings("redirected"); }); @@ -344,13 +345,13 @@ describe("ProxySession.getCachedPriceDataByBillingSource", () => { const p2 = session.getCachedPriceDataByBillingSource(); await Promise.all([p1, p2]); - expect(getSystemSettings).toHaveBeenCalledTimes(1); + expect(getCachedSystemSettings).toHaveBeenCalledTimes(1); }); it("应缓存配置避免重复读取", async () => { const priceData: ModelPriceData = { input_cost_per_token: 1, output_cost_per_token: 2 }; - vi.mocked(getSystemSettings).mockResolvedValue(makeSystemSettings("redirected")); + vi.mocked(getCachedSystemSettings).mockResolvedValue(makeSystemSettings("redirected")); vi.mocked(findLatestPriceByModel).mockResolvedValue( makePriceRecord("redirected-model", priceData) ); @@ -363,13 +364,13 @@ describe("ProxySession.getCachedPriceDataByBillingSource", () => { await session.getCachedPriceDataByBillingSource(); await session.getCachedPriceDataByBillingSource(); - expect(getSystemSettings).toHaveBeenCalledTimes(1); + expect(getCachedSystemSettings).toHaveBeenCalledTimes(1); }); it("应缓存价格数据避免重复查询", async () => { const priceData: ModelPriceData = { input_cost_per_token: 1, output_cost_per_token: 2 }; - vi.mocked(getSystemSettings).mockResolvedValue(makeSystemSettings("redirected")); + vi.mocked(getCachedSystemSettings).mockResolvedValue(makeSystemSettings("redirected")); vi.mocked(findLatestPriceByModel).mockResolvedValue( makePriceRecord("redirected-model", priceData) ); @@ -386,13 +387,13 @@ describe("ProxySession.getCachedPriceDataByBillingSource", () => { }); it("应在无模型时返回 null", async () => { - vi.mocked(getSystemSettings).mockResolvedValue(makeSystemSettings("redirected")); + vi.mocked(getCachedSystemSettings).mockResolvedValue(makeSystemSettings("redirected")); const session = createSession({ redirectedModel: null }); const result = await session.getCachedPriceDataByBillingSource(); expect(result).toBeNull(); - expect(getSystemSettings).not.toHaveBeenCalled(); + expect(getCachedSystemSettings).not.toHaveBeenCalled(); expect(findLatestPriceByModel).not.toHaveBeenCalled(); }); }); diff --git a/tests/unit/repository/leaderboard-provider-metrics.test.ts b/tests/unit/repository/leaderboard-provider-metrics.test.ts index a39bb489f..49c09e24a 100644 --- a/tests/unit/repository/leaderboard-provider-metrics.test.ts +++ b/tests/unit/repository/leaderboard-provider-metrics.test.ts @@ -29,7 +29,7 @@ const mockSelect = vi.fn(() => { const mocks = vi.hoisted(() => ({ resolveSystemTimezone: vi.fn(), - getSystemSettings: vi.fn(), + getCachedSystemSettings: vi.fn(), })); vi.mock("@/drizzle/db", () => ({ @@ -82,12 +82,12 @@ vi.mock("@/drizzle/schema", () => ({ users: {}, })); -vi.mock("@/lib/utils/timezone", () => ({ +vi.mock("@/lib/utils/timezone.server", () => ({ resolveSystemTimezone: mocks.resolveSystemTimezone, })); -vi.mock("@/repository/system-config", () => ({ - getSystemSettings: mocks.getSystemSettings, +vi.mock("@/lib/config", () => ({ + getCachedSystemSettings: mocks.getCachedSystemSettings, })); describe("Provider Leaderboard Average Cost Metrics", () => { @@ -97,7 +97,7 @@ describe("Provider Leaderboard Average Cost Metrics", () => { chainMocks = []; mockSelect.mockClear(); mocks.resolveSystemTimezone.mockResolvedValue("UTC"); - mocks.getSystemSettings.mockResolvedValue({ billingModelSource: "redirected" }); + mocks.getCachedSystemSettings.mockResolvedValue({ billingModelSource: "redirected" }); }); it("computes avgCostPerRequest = totalCost / totalRequests for valid denominators", async () => { @@ -243,7 +243,7 @@ describe("Provider Cache Hit Rate Model Breakdown", () => { chainMocks = []; mockSelect.mockClear(); mocks.resolveSystemTimezone.mockResolvedValue("UTC"); - mocks.getSystemSettings.mockResolvedValue({ billingModelSource: "redirected" }); + mocks.getCachedSystemSettings.mockResolvedValue({ billingModelSource: "redirected" }); }); it("includes modelStats field on cache-hit leaderboard entries", async () => { diff --git a/tests/unit/repository/leaderboard-timezone-parentheses.test.ts b/tests/unit/repository/leaderboard-timezone-parentheses.test.ts index 906d0cc1a..b61e2ed65 100644 --- a/tests/unit/repository/leaderboard-timezone-parentheses.test.ts +++ b/tests/unit/repository/leaderboard-timezone-parentheses.test.ts @@ -47,6 +47,7 @@ function sqlToString(sqlObj: unknown): string { const mocks = vi.hoisted(() => ({ resolveSystemTimezone: vi.fn(), + getCachedSystemSettings: vi.fn().mockResolvedValue({ billingModelSource: "redirected" }), })); function createThenableQuery(result: T, whereArgs?: unknown[]) { @@ -127,12 +128,12 @@ vi.mock("@/drizzle/schema", () => ({ }, })); -vi.mock("@/lib/utils/timezone", () => ({ +vi.mock("@/lib/utils/timezone.server", () => ({ resolveSystemTimezone: mocks.resolveSystemTimezone, })); -vi.mock("@/repository/system-config", () => ({ - getSystemSettings: vi.fn().mockResolvedValue({ billingModelSource: "redirected" }), +vi.mock("@/lib/config", () => ({ + getCachedSystemSettings: mocks.getCachedSystemSettings, })); describe("buildDateCondition - timezone parentheses regression", () => { diff --git a/tests/unit/repository/overview-timezone-parentheses.test.ts b/tests/unit/repository/overview-timezone-parentheses.test.ts index dd7a99fe8..edb3c8809 100644 --- a/tests/unit/repository/overview-timezone-parentheses.test.ts +++ b/tests/unit/repository/overview-timezone-parentheses.test.ts @@ -108,7 +108,7 @@ vi.mock("@/drizzle/schema", () => ({ }, })); -vi.mock("@/lib/utils/timezone", () => ({ +vi.mock("@/lib/utils/timezone.server", () => ({ resolveSystemTimezone: mocks.resolveSystemTimezone, })); From 814c7404475c9f22e2ca19f42a9f83786d9f58a7 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Sun, 1 Mar 2026 15:28:59 +0800 Subject: [PATCH 02/16] =?UTF-8?q?perf(db):=20usage=5Fledger=20=E8=A7=A6?= =?UTF-8?q?=E5=8F=91=E5=99=A8=E8=B7=B3=E8=BF=87=E6=97=A0=E5=85=B3=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=EF=BC=8C=E9=99=8D=E4=BD=8E=E5=86=99=E6=94=BE=E5=A4=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ledger_trigger_skip_irrelevant_updates.sql | 132 + drizzle/meta/0078_snapshot.json | 3908 +++++++++++++++++ drizzle/meta/_journal.json | 7 + src/lib/ledger-backfill/trigger.sql | 52 + tests/unit/usage-ledger/trigger.test.ts | 8 + 5 files changed, 4107 insertions(+) create mode 100644 drizzle/0078_perf_usage_ledger_trigger_skip_irrelevant_updates.sql create mode 100644 drizzle/meta/0078_snapshot.json diff --git a/drizzle/0078_perf_usage_ledger_trigger_skip_irrelevant_updates.sql b/drizzle/0078_perf_usage_ledger_trigger_skip_irrelevant_updates.sql new file mode 100644 index 000000000..c5873a7a0 --- /dev/null +++ b/drizzle/0078_perf_usage_ledger_trigger_skip_irrelevant_updates.sql @@ -0,0 +1,132 @@ +-- perf: avoid redundant usage_ledger UPSERTs on irrelevant message_request updates + +CREATE OR REPLACE FUNCTION fn_upsert_usage_ledger() +RETURNS TRIGGER AS $$ +DECLARE + v_final_provider_id integer; + v_old_final_provider_id integer; + v_is_success boolean; + v_old_is_success boolean; +BEGIN + IF NEW.blocked_by = 'warmup' THEN + -- If a ledger row already exists (row was originally non-warmup), mark it as warmup + UPDATE usage_ledger SET blocked_by = 'warmup' WHERE request_id = NEW.id; + RETURN NEW; + END IF; + + IF NEW.provider_chain IS NOT NULL + AND jsonb_typeof(NEW.provider_chain) = 'array' + AND jsonb_array_length(NEW.provider_chain) > 0 + AND jsonb_typeof(NEW.provider_chain -> -1) = 'object' + AND (NEW.provider_chain -> -1 ? 'id') + AND (NEW.provider_chain -> -1 ->> 'id') ~ '^[0-9]+$' THEN + v_final_provider_id := (NEW.provider_chain -> -1 ->> 'id')::integer; + ELSE + v_final_provider_id := NEW.provider_id; + END IF; + + v_is_success := (NEW.error_message IS NULL OR NEW.error_message = ''); + + -- Performance: skip UPSERT when UPDATE doesn't affect usage_ledger fields. + -- usage_ledger does NOT persist provider_chain / error_message, so compare derived values instead. + IF TG_OP = 'UPDATE' THEN + IF OLD.provider_chain IS NOT NULL + AND jsonb_typeof(OLD.provider_chain) = 'array' + AND jsonb_array_length(OLD.provider_chain) > 0 + AND jsonb_typeof(OLD.provider_chain -> -1) = 'object' + AND (OLD.provider_chain -> -1 ? 'id') + AND (OLD.provider_chain -> -1 ->> 'id') ~ '^[0-9]+$' THEN + v_old_final_provider_id := (OLD.provider_chain -> -1 ->> 'id')::integer; + ELSE + v_old_final_provider_id := OLD.provider_id; + END IF; + + v_old_is_success := (OLD.error_message IS NULL OR OLD.error_message = ''); + + IF + NEW.user_id IS NOT DISTINCT FROM OLD.user_id + AND NEW.key IS NOT DISTINCT FROM OLD.key + AND NEW.provider_id IS NOT DISTINCT FROM OLD.provider_id + AND v_final_provider_id IS NOT DISTINCT FROM v_old_final_provider_id + AND NEW.model IS NOT DISTINCT FROM OLD.model + AND NEW.original_model IS NOT DISTINCT FROM OLD.original_model + AND NEW.endpoint IS NOT DISTINCT FROM OLD.endpoint + AND NEW.api_type IS NOT DISTINCT FROM OLD.api_type + AND NEW.session_id IS NOT DISTINCT FROM OLD.session_id + AND NEW.status_code IS NOT DISTINCT FROM OLD.status_code + AND v_is_success IS NOT DISTINCT FROM v_old_is_success + AND NEW.blocked_by IS NOT DISTINCT FROM OLD.blocked_by + AND NEW.cost_usd IS NOT DISTINCT FROM OLD.cost_usd + AND NEW.cost_multiplier IS NOT DISTINCT FROM OLD.cost_multiplier + AND NEW.input_tokens IS NOT DISTINCT FROM OLD.input_tokens + AND NEW.output_tokens IS NOT DISTINCT FROM OLD.output_tokens + AND NEW.cache_creation_input_tokens IS NOT DISTINCT FROM OLD.cache_creation_input_tokens + AND NEW.cache_read_input_tokens IS NOT DISTINCT FROM OLD.cache_read_input_tokens + AND NEW.cache_creation_5m_input_tokens IS NOT DISTINCT FROM OLD.cache_creation_5m_input_tokens + AND NEW.cache_creation_1h_input_tokens IS NOT DISTINCT FROM OLD.cache_creation_1h_input_tokens + AND NEW.cache_ttl_applied IS NOT DISTINCT FROM OLD.cache_ttl_applied + AND NEW.context_1m_applied IS NOT DISTINCT FROM OLD.context_1m_applied + AND NEW.swap_cache_ttl_applied IS NOT DISTINCT FROM OLD.swap_cache_ttl_applied + AND NEW.duration_ms IS NOT DISTINCT FROM OLD.duration_ms + AND NEW.ttfb_ms IS NOT DISTINCT FROM OLD.ttfb_ms + AND NEW.created_at IS NOT DISTINCT FROM OLD.created_at + THEN + RETURN NEW; + END IF; + END IF; + + INSERT INTO usage_ledger ( + request_id, user_id, key, provider_id, final_provider_id, + model, original_model, endpoint, api_type, session_id, + status_code, is_success, blocked_by, + cost_usd, cost_multiplier, + input_tokens, output_tokens, + cache_creation_input_tokens, cache_read_input_tokens, + cache_creation_5m_input_tokens, cache_creation_1h_input_tokens, + cache_ttl_applied, context_1m_applied, swap_cache_ttl_applied, + duration_ms, ttfb_ms, created_at + ) VALUES ( + NEW.id, NEW.user_id, NEW.key, NEW.provider_id, v_final_provider_id, + NEW.model, NEW.original_model, NEW.endpoint, NEW.api_type, NEW.session_id, + NEW.status_code, v_is_success, NEW.blocked_by, + NEW.cost_usd, NEW.cost_multiplier, + NEW.input_tokens, NEW.output_tokens, + NEW.cache_creation_input_tokens, NEW.cache_read_input_tokens, + NEW.cache_creation_5m_input_tokens, NEW.cache_creation_1h_input_tokens, + NEW.cache_ttl_applied, NEW.context_1m_applied, NEW.swap_cache_ttl_applied, + NEW.duration_ms, NEW.ttfb_ms, NEW.created_at + ) + ON CONFLICT (request_id) DO UPDATE SET + user_id = EXCLUDED.user_id, + key = EXCLUDED.key, + provider_id = EXCLUDED.provider_id, + final_provider_id = EXCLUDED.final_provider_id, + model = EXCLUDED.model, + original_model = EXCLUDED.original_model, + endpoint = EXCLUDED.endpoint, + api_type = EXCLUDED.api_type, + session_id = EXCLUDED.session_id, + status_code = EXCLUDED.status_code, + is_success = EXCLUDED.is_success, + blocked_by = EXCLUDED.blocked_by, + cost_usd = EXCLUDED.cost_usd, + cost_multiplier = EXCLUDED.cost_multiplier, + input_tokens = EXCLUDED.input_tokens, + output_tokens = EXCLUDED.output_tokens, + cache_creation_input_tokens = EXCLUDED.cache_creation_input_tokens, + cache_read_input_tokens = EXCLUDED.cache_read_input_tokens, + cache_creation_5m_input_tokens = EXCLUDED.cache_creation_5m_input_tokens, + cache_creation_1h_input_tokens = EXCLUDED.cache_creation_1h_input_tokens, + cache_ttl_applied = EXCLUDED.cache_ttl_applied, + context_1m_applied = EXCLUDED.context_1m_applied, + swap_cache_ttl_applied = EXCLUDED.swap_cache_ttl_applied, + duration_ms = EXCLUDED.duration_ms, + ttfb_ms = EXCLUDED.ttfb_ms, + created_at = EXCLUDED.created_at; + + RETURN NEW; +EXCEPTION WHEN OTHERS THEN + RAISE WARNING 'fn_upsert_usage_ledger failed for request_id=%: %', NEW.id, SQLERRM; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; diff --git a/drizzle/meta/0078_snapshot.json b/drizzle/meta/0078_snapshot.json new file mode 100644 index 000000000..79481990c --- /dev/null +++ b/drizzle/meta/0078_snapshot.json @@ -0,0 +1,3908 @@ +{ + "id": "ae9c3a85-7321-43d4-a211-d56ce6725c0e", + "prevId": "22eb3652-56d7-4a04-9845-5fa18210ef90", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.error_rules": { + "name": "error_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'regex'" + }, + "category": { + "name": "category", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "override_response": { + "name": "override_response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "override_status_code": { + "name": "override_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_error_rules_enabled": { + "name": "idx_error_rules_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "unique_pattern": { + "name": "unique_pattern", + "columns": [ + { + "expression": "pattern", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_category": { + "name": "idx_category", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_match_type": { + "name": "idx_match_type", + "columns": [ + { + "expression": "match_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.keys": { + "name": "keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "can_login_web_ui": { + "name": "can_login_web_ui", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_daily_usd": { + "name": "limit_daily_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "provider_group": { + "name": "provider_group", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "default": "'default'" + }, + "cache_ttl_preference": { + "name": "cache_ttl_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_keys_user_id": { + "name": "idx_keys_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_keys_key": { + "name": "idx_keys_key", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_keys_created_at": { + "name": "idx_keys_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_keys_deleted_at": { + "name": "idx_keys_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message_request": { + "name": "message_request", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "numeric(21, 15)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "request_sequence": { + "name": "request_sequence", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "provider_chain": { + "name": "provider_chain", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "api_type": { + "name": "api_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "endpoint": { + "name": "endpoint", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "original_model": { + "name": "original_model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "ttfb_ms": { + "name": "ttfb_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_creation_input_tokens": { + "name": "cache_creation_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_read_input_tokens": { + "name": "cache_read_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_5m_input_tokens": { + "name": "cache_creation_5m_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_1h_input_tokens": { + "name": "cache_creation_1h_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_applied": { + "name": "cache_ttl_applied", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "context_1m_applied": { + "name": "context_1m_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "swap_cache_ttl_applied": { + "name": "swap_cache_ttl_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "special_settings": { + "name": "special_settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_stack": { + "name": "error_stack", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_cause": { + "name": "error_cause", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "blocked_by": { + "name": "blocked_by", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "blocked_reason": { + "name": "blocked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "messages_count": { + "name": "messages_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_message_request_user_date_cost": { + "name": "idx_message_request_user_date_cost", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false + }, + "idx_message_request_user_created_at_cost_stats": { + "name": "idx_message_request_user_created_at_cost_stats", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false + }, + "idx_message_request_user_query": { + "name": "idx_message_request_user_query", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false + }, + "idx_message_request_provider_created_at_active": { + "name": "idx_message_request_provider_created_at_active", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false + }, + "idx_message_request_session_id": { + "name": "idx_message_request_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false + }, + "idx_message_request_session_id_prefix": { + "name": "idx_message_request_session_id_prefix", + "columns": [ + { + "expression": "\"session_id\" varchar_pattern_ops", + "isExpression": true, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false + }, + "idx_message_request_session_seq": { + "name": "idx_message_request_session_seq", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_sequence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false + }, + "idx_message_request_endpoint": { + "name": "idx_message_request_endpoint", + "columns": [ + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false + }, + "idx_message_request_blocked_by": { + "name": "idx_message_request_blocked_by", + "columns": [ + { + "expression": "blocked_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false + }, + "idx_message_request_provider_id": { + "name": "idx_message_request_provider_id", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_message_request_user_id": { + "name": "idx_message_request_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_message_request_key": { + "name": "idx_message_request_key", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_message_request_key_created_at_id": { + "name": "idx_message_request_key_created_at_id", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false + }, + "idx_message_request_key_model_active": { + "name": "idx_message_request_key_model_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"model\" IS NOT NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false + }, + "idx_message_request_key_endpoint_active": { + "name": "idx_message_request_key_endpoint_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"endpoint\" IS NOT NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false + }, + "idx_message_request_created_at_id_active": { + "name": "idx_message_request_created_at_id_active", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false + }, + "idx_message_request_model_active": { + "name": "idx_message_request_model_active", + "columns": [ + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"model\" IS NOT NULL", + "concurrently": false + }, + "idx_message_request_status_code_active": { + "name": "idx_message_request_status_code_active", + "columns": [ + { + "expression": "status_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"status_code\" IS NOT NULL", + "concurrently": false + }, + "idx_message_request_created_at": { + "name": "idx_message_request_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_message_request_deleted_at": { + "name": "idx_message_request_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_message_request_key_last_active": { + "name": "idx_message_request_key_last_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false + }, + "idx_message_request_key_cost_active": { + "name": "idx_message_request_key_cost_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false + }, + "idx_message_request_session_user_info": { + "name": "idx_message_request_session_user_info", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model_prices": { + "name": "model_prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "model_name": { + "name": "model_name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "price_data": { + "name": "price_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'litellm'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_model_prices_latest": { + "name": "idx_model_prices_latest", + "columns": [ + { + "expression": "model_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_model_prices_model_name": { + "name": "idx_model_prices_model_name", + "columns": [ + { + "expression": "model_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_model_prices_created_at": { + "name": "idx_model_prices_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_model_prices_source": { + "name": "idx_model_prices_source", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_settings": { + "name": "notification_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "use_legacy_mode": { + "name": "use_legacy_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "circuit_breaker_enabled": { + "name": "circuit_breaker_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "circuit_breaker_webhook": { + "name": "circuit_breaker_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "daily_leaderboard_enabled": { + "name": "daily_leaderboard_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "daily_leaderboard_webhook": { + "name": "daily_leaderboard_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "daily_leaderboard_time": { + "name": "daily_leaderboard_time", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false, + "default": "'09:00'" + }, + "daily_leaderboard_top_n": { + "name": "daily_leaderboard_top_n", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "cost_alert_enabled": { + "name": "cost_alert_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cost_alert_webhook": { + "name": "cost_alert_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "cost_alert_threshold": { + "name": "cost_alert_threshold", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": false, + "default": "'0.80'" + }, + "cost_alert_check_interval": { + "name": "cost_alert_check_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 60 + }, + "cache_hit_rate_alert_enabled": { + "name": "cache_hit_rate_alert_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cache_hit_rate_alert_webhook": { + "name": "cache_hit_rate_alert_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "cache_hit_rate_alert_window_mode": { + "name": "cache_hit_rate_alert_window_mode", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false, + "default": "'auto'" + }, + "cache_hit_rate_alert_check_interval": { + "name": "cache_hit_rate_alert_check_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "cache_hit_rate_alert_historical_lookback_days": { + "name": "cache_hit_rate_alert_historical_lookback_days", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 7 + }, + "cache_hit_rate_alert_min_eligible_requests": { + "name": "cache_hit_rate_alert_min_eligible_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 20 + }, + "cache_hit_rate_alert_min_eligible_tokens": { + "name": "cache_hit_rate_alert_min_eligible_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "cache_hit_rate_alert_abs_min": { + "name": "cache_hit_rate_alert_abs_min", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "cache_hit_rate_alert_drop_rel": { + "name": "cache_hit_rate_alert_drop_rel", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.3'" + }, + "cache_hit_rate_alert_drop_abs": { + "name": "cache_hit_rate_alert_drop_abs", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.1'" + }, + "cache_hit_rate_alert_cooldown_minutes": { + "name": "cache_hit_rate_alert_cooldown_minutes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30 + }, + "cache_hit_rate_alert_top_n": { + "name": "cache_hit_rate_alert_top_n", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_target_bindings": { + "name": "notification_target_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "notification_type": { + "name": "notification_type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "schedule_cron": { + "name": "schedule_cron", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "schedule_timezone": { + "name": "schedule_timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "template_override": { + "name": "template_override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "unique_notification_target_binding": { + "name": "unique_notification_target_binding", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_notification_bindings_type": { + "name": "idx_notification_bindings_type", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_notification_bindings_target": { + "name": "idx_notification_bindings_target", + "columns": [ + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "notification_target_bindings_target_id_webhook_targets_id_fk": { + "name": "notification_target_bindings_target_id_webhook_targets_id_fk", + "tableFrom": "notification_target_bindings", + "columnsFrom": [ + "target_id" + ], + "tableTo": "webhook_targets", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_endpoint_probe_logs": { + "name": "provider_endpoint_probe_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "endpoint_id": { + "name": "endpoint_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'scheduled'" + }, + "ok": { + "name": "ok", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error_type": { + "name": "error_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_provider_endpoint_probe_logs_endpoint_created_at": { + "name": "idx_provider_endpoint_probe_logs_endpoint_created_at", + "columns": [ + { + "expression": "endpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_provider_endpoint_probe_logs_created_at": { + "name": "idx_provider_endpoint_probe_logs_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "provider_endpoint_probe_logs_endpoint_id_provider_endpoints_id_fk": { + "name": "provider_endpoint_probe_logs_endpoint_id_provider_endpoints_id_fk", + "tableFrom": "provider_endpoint_probe_logs", + "columnsFrom": [ + "endpoint_id" + ], + "tableTo": "provider_endpoints", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_endpoints": { + "name": "provider_endpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "vendor_id": { + "name": "vendor_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'claude'" + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_probed_at": { + "name": "last_probed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_probe_ok": { + "name": "last_probe_ok", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "last_probe_status_code": { + "name": "last_probe_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_probe_latency_ms": { + "name": "last_probe_latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_probe_error_type": { + "name": "last_probe_error_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "last_probe_error_message": { + "name": "last_probe_error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uniq_provider_endpoints_vendor_type_url": { + "name": "uniq_provider_endpoints_vendor_type_url", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false + }, + "idx_provider_endpoints_vendor_type": { + "name": "idx_provider_endpoints_vendor_type", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false + }, + "idx_provider_endpoints_enabled": { + "name": "idx_provider_endpoints_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false + }, + "idx_provider_endpoints_pick_enabled": { + "name": "idx_provider_endpoints_pick_enabled", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false + }, + "idx_provider_endpoints_created_at": { + "name": "idx_provider_endpoints_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_provider_endpoints_deleted_at": { + "name": "idx_provider_endpoints_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "provider_endpoints_vendor_id_provider_vendors_id_fk": { + "name": "provider_endpoints_vendor_id_provider_vendors_id_fk", + "tableFrom": "provider_endpoints", + "columnsFrom": [ + "vendor_id" + ], + "tableTo": "provider_vendors", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_vendors": { + "name": "provider_vendors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "website_domain": { + "name": "website_domain", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "uniq_provider_vendors_website_domain": { + "name": "uniq_provider_vendors_website_domain", + "columns": [ + { + "expression": "website_domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_provider_vendors_created_at": { + "name": "idx_provider_vendors_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.providers": { + "name": "providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "provider_vendor_id": { + "name": "provider_vendor_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "weight": { + "name": "weight", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "group_priorities": { + "name": "group_priorities", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false, + "default": "'1.0'" + }, + "group_tag": { + "name": "group_tag", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "provider_type": { + "name": "provider_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'claude'" + }, + "preserve_client_ip": { + "name": "preserve_client_ip", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "model_redirects": { + "name": "model_redirects", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "allowed_models": { + "name": "allowed_models", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "allowed_clients": { + "name": "allowed_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "blocked_clients": { + "name": "blocked_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "active_time_start": { + "name": "active_time_start", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "active_time_end": { + "name": "active_time_end", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "codex_instructions_strategy": { + "name": "codex_instructions_strategy", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'auto'" + }, + "mcp_passthrough_type": { + "name": "mcp_passthrough_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "mcp_passthrough_url": { + "name": "mcp_passthrough_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_daily_usd": { + "name": "limit_daily_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "total_cost_reset_at": { + "name": "total_cost_reset_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "max_retry_attempts": { + "name": "max_retry_attempts", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "circuit_breaker_failure_threshold": { + "name": "circuit_breaker_failure_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "circuit_breaker_open_duration": { + "name": "circuit_breaker_open_duration", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1800000 + }, + "circuit_breaker_half_open_success_threshold": { + "name": "circuit_breaker_half_open_success_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 2 + }, + "proxy_url": { + "name": "proxy_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "proxy_fallback_to_direct": { + "name": "proxy_fallback_to_direct", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "first_byte_timeout_streaming_ms": { + "name": "first_byte_timeout_streaming_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "streaming_idle_timeout_ms": { + "name": "streaming_idle_timeout_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "request_timeout_non_streaming_ms": { + "name": "request_timeout_non_streaming_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_preference": { + "name": "cache_ttl_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "swap_cache_ttl_billing": { + "name": "swap_cache_ttl_billing", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "context_1m_preference": { + "name": "context_1m_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_reasoning_effort_preference": { + "name": "codex_reasoning_effort_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_reasoning_summary_preference": { + "name": "codex_reasoning_summary_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_text_verbosity_preference": { + "name": "codex_text_verbosity_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "codex_parallel_tool_calls_preference": { + "name": "codex_parallel_tool_calls_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "anthropic_max_tokens_preference": { + "name": "anthropic_max_tokens_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "anthropic_thinking_budget_preference": { + "name": "anthropic_thinking_budget_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "anthropic_adaptive_thinking": { + "name": "anthropic_adaptive_thinking", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "gemini_google_search_preference": { + "name": "gemini_google_search_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "tpm": { + "name": "tpm", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "rpm": { + "name": "rpm", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "rpd": { + "name": "rpd", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "cc": { + "name": "cc", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_providers_enabled_priority": { + "name": "idx_providers_enabled_priority", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "weight", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false + }, + "idx_providers_group": { + "name": "idx_providers_group", + "columns": [ + { + "expression": "group_tag", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false + }, + "idx_providers_vendor_type_url_active": { + "name": "idx_providers_vendor_type_url_active", + "columns": [ + { + "expression": "provider_vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false + }, + "idx_providers_created_at": { + "name": "idx_providers_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_providers_deleted_at": { + "name": "idx_providers_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_providers_vendor_type": { + "name": "idx_providers_vendor_type", + "columns": [ + { + "expression": "provider_vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false + }, + "idx_providers_enabled_vendor_type": { + "name": "idx_providers_enabled_vendor_type", + "columns": [ + { + "expression": "provider_vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"providers\".\"deleted_at\" IS NULL AND \"providers\".\"is_enabled\" = true AND \"providers\".\"provider_vendor_id\" IS NOT NULL AND \"providers\".\"provider_vendor_id\" > 0", + "concurrently": false + } + }, + "foreignKeys": { + "providers_provider_vendor_id_provider_vendors_id_fk": { + "name": "providers_provider_vendor_id_provider_vendors_id_fk", + "tableFrom": "providers", + "columnsFrom": [ + "provider_vendor_id" + ], + "tableTo": "provider_vendors", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "restrict" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.request_filters": { + "name": "request_filters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "replacement": { + "name": "replacement", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "binding_type": { + "name": "binding_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'global'" + }, + "provider_ids": { + "name": "provider_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "group_tags": { + "name": "group_tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_request_filters_enabled": { + "name": "idx_request_filters_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_request_filters_scope": { + "name": "idx_request_filters_scope", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_request_filters_action": { + "name": "idx_request_filters_action", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_request_filters_binding": { + "name": "idx_request_filters_binding", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "binding_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sensitive_words": { + "name": "sensitive_words", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "word": { + "name": "word", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'contains'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_sensitive_words_enabled": { + "name": "idx_sensitive_words_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "match_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_sensitive_words_created_at": { + "name": "idx_sensitive_words_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_settings": { + "name": "system_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "site_title": { + "name": "site_title", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "default": "'Claude Code Hub'" + }, + "allow_global_usage_view": { + "name": "allow_global_usage_view", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "currency_display": { + "name": "currency_display", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "billing_model_source": { + "name": "billing_model_source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'original'" + }, + "timezone": { + "name": "timezone", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "enable_auto_cleanup": { + "name": "enable_auto_cleanup", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "cleanup_retention_days": { + "name": "cleanup_retention_days", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30 + }, + "cleanup_schedule": { + "name": "cleanup_schedule", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'0 2 * * *'" + }, + "cleanup_batch_size": { + "name": "cleanup_batch_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10000 + }, + "enable_client_version_check": { + "name": "enable_client_version_check", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verbose_provider_error": { + "name": "verbose_provider_error", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_http2": { + "name": "enable_http2", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "intercept_anthropic_warmup_requests": { + "name": "intercept_anthropic_warmup_requests", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_thinking_signature_rectifier": { + "name": "enable_thinking_signature_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_thinking_budget_rectifier": { + "name": "enable_thinking_budget_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_billing_header_rectifier": { + "name": "enable_billing_header_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_codex_session_id_completion": { + "name": "enable_codex_session_id_completion", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_claude_metadata_user_id_injection": { + "name": "enable_claude_metadata_user_id_injection", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_response_fixer": { + "name": "enable_response_fixer", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "response_fixer_config": { + "name": "response_fixer_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{\"fixTruncatedJson\":true,\"fixSseFormat\":true,\"fixEncoding\":true,\"maxJsonDepth\":200,\"maxFixSize\":1048576}'::jsonb" + }, + "quota_db_refresh_interval_seconds": { + "name": "quota_db_refresh_interval_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "quota_lease_percent_5h": { + "name": "quota_lease_percent_5h", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_percent_daily": { + "name": "quota_lease_percent_daily", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_percent_weekly": { + "name": "quota_lease_percent_weekly", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_percent_monthly": { + "name": "quota_lease_percent_monthly", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_cap_usd": { + "name": "quota_lease_cap_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_ledger": { + "name": "usage_ledger", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "final_provider_id": { + "name": "final_provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "original_model": { + "name": "original_model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "endpoint": { + "name": "endpoint", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "api_type": { + "name": "api_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_success": { + "name": "is_success", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "blocked_by": { + "name": "blocked_by", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "numeric(21, 15)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_input_tokens": { + "name": "cache_creation_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_read_input_tokens": { + "name": "cache_read_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_5m_input_tokens": { + "name": "cache_creation_5m_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_1h_input_tokens": { + "name": "cache_creation_1h_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_applied": { + "name": "cache_ttl_applied", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "context_1m_applied": { + "name": "context_1m_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "swap_cache_ttl_applied": { + "name": "swap_cache_ttl_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ttfb_ms": { + "name": "ttfb_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_usage_ledger_request_id": { + "name": "idx_usage_ledger_request_id", + "columns": [ + { + "expression": "request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_usage_ledger_user_created_at": { + "name": "idx_usage_ledger_user_created_at", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false + }, + "idx_usage_ledger_key_created_at": { + "name": "idx_usage_ledger_key_created_at", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false + }, + "idx_usage_ledger_provider_created_at": { + "name": "idx_usage_ledger_provider_created_at", + "columns": [ + { + "expression": "final_provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false + }, + "idx_usage_ledger_created_at_minute": { + "name": "idx_usage_ledger_created_at_minute", + "columns": [ + { + "expression": "date_trunc('minute', \"created_at\" AT TIME ZONE 'UTC')", + "isExpression": true, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_usage_ledger_created_at_desc_id": { + "name": "idx_usage_ledger_created_at_desc_id", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_usage_ledger_session_id": { + "name": "idx_usage_ledger_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"usage_ledger\".\"session_id\" IS NOT NULL", + "concurrently": false + }, + "idx_usage_ledger_model": { + "name": "idx_usage_ledger_model", + "columns": [ + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"usage_ledger\".\"model\" IS NOT NULL", + "concurrently": false + }, + "idx_usage_ledger_key_cost": { + "name": "idx_usage_ledger_key_cost", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false + }, + "idx_usage_ledger_user_cost_cover": { + "name": "idx_usage_ledger_user_cost_cover", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false + }, + "idx_usage_ledger_provider_cost_cover": { + "name": "idx_usage_ledger_provider_cost_cover", + "columns": [ + { + "expression": "final_provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "rpm_limit": { + "name": "rpm_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "daily_limit_usd": { + "name": "daily_limit_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "provider_group": { + "name": "provider_group", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "default": "'default'" + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "allowed_clients": { + "name": "allowed_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "allowed_models": { + "name": "allowed_models", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "blocked_clients": { + "name": "blocked_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_users_active_role_sort": { + "name": "idx_users_active_role_sort", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false + }, + "idx_users_enabled_expires_at": { + "name": "idx_users_enabled_expires_at", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false + }, + "idx_users_tags_gin": { + "name": "idx_users_tags_gin", + "columns": [ + { + "expression": "tags", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "gin", + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false + }, + "idx_users_created_at": { + "name": "idx_users_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_users_deleted_at": { + "name": "idx_users_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_targets": { + "name": "webhook_targets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "webhook_provider_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "webhook_url": { + "name": "webhook_url", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false + }, + "telegram_bot_token": { + "name": "telegram_bot_token", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "dingtalk_secret": { + "name": "dingtalk_secret", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "custom_template": { + "name": "custom_template", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "custom_headers": { + "name": "custom_headers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "proxy_url": { + "name": "proxy_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "proxy_fallback_to_direct": { + "name": "proxy_fallback_to_direct", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_test_at": { + "name": "last_test_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_test_result": { + "name": "last_test_result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.daily_reset_mode": { + "name": "daily_reset_mode", + "schema": "public", + "values": [ + "fixed", + "rolling" + ] + }, + "public.notification_type": { + "name": "notification_type", + "schema": "public", + "values": [ + "circuit_breaker", + "daily_leaderboard", + "cost_alert", + "cache_hit_rate_alert" + ] + }, + "public.webhook_provider_type": { + "name": "webhook_provider_type", + "schema": "public", + "values": [ + "wechat", + "feishu", + "dingtalk", + "telegram", + "custom" + ] + } + }, + "schemas": {}, + "views": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index fb7b5a646..34f90a3e7 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -547,6 +547,13 @@ "when": 1772219877045, "tag": "0077_nappy_giant_man", "breakpoints": true + }, + { + "idx": 78, + "version": "7", + "when": 1772348135114, + "tag": "0078_perf_usage_ledger_trigger_skip_irrelevant_updates", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/lib/ledger-backfill/trigger.sql b/src/lib/ledger-backfill/trigger.sql index a04a313ba..5a910381b 100644 --- a/src/lib/ledger-backfill/trigger.sql +++ b/src/lib/ledger-backfill/trigger.sql @@ -2,7 +2,9 @@ CREATE OR REPLACE FUNCTION fn_upsert_usage_ledger() RETURNS TRIGGER AS $$ DECLARE v_final_provider_id integer; + v_old_final_provider_id integer; v_is_success boolean; + v_old_is_success boolean; BEGIN IF NEW.blocked_by = 'warmup' THEN -- If a ledger row already exists (row was originally non-warmup), mark it as warmup @@ -23,6 +25,56 @@ BEGIN v_is_success := (NEW.error_message IS NULL OR NEW.error_message = ''); + -- 性能优化:避免“无关字段更新”触发 usage_ledger 的重复 UPSERT(写放大) + -- 典型无关字段:special_settings、error_stack、error_cause、updated_at 等。 + -- 仅当会影响 usage_ledger 的字段发生变化时才继续执行。 + -- 注意:usage_ledger 不存 provider_chain / error_message,因此这里比较其派生值(final_provider_id / is_success)即可。 + IF TG_OP = 'UPDATE' THEN + IF OLD.provider_chain IS NOT NULL + AND jsonb_typeof(OLD.provider_chain) = 'array' + AND jsonb_array_length(OLD.provider_chain) > 0 + AND jsonb_typeof(OLD.provider_chain -> -1) = 'object' + AND (OLD.provider_chain -> -1 ? 'id') + AND (OLD.provider_chain -> -1 ->> 'id') ~ '^[0-9]+$' THEN + v_old_final_provider_id := (OLD.provider_chain -> -1 ->> 'id')::integer; + ELSE + v_old_final_provider_id := OLD.provider_id; + END IF; + + v_old_is_success := (OLD.error_message IS NULL OR OLD.error_message = ''); + + IF + NEW.user_id IS NOT DISTINCT FROM OLD.user_id + AND NEW.key IS NOT DISTINCT FROM OLD.key + AND NEW.provider_id IS NOT DISTINCT FROM OLD.provider_id + AND v_final_provider_id IS NOT DISTINCT FROM v_old_final_provider_id + AND NEW.model IS NOT DISTINCT FROM OLD.model + AND NEW.original_model IS NOT DISTINCT FROM OLD.original_model + AND NEW.endpoint IS NOT DISTINCT FROM OLD.endpoint + AND NEW.api_type IS NOT DISTINCT FROM OLD.api_type + AND NEW.session_id IS NOT DISTINCT FROM OLD.session_id + AND NEW.status_code IS NOT DISTINCT FROM OLD.status_code + AND v_is_success IS NOT DISTINCT FROM v_old_is_success + AND NEW.blocked_by IS NOT DISTINCT FROM OLD.blocked_by + AND NEW.cost_usd IS NOT DISTINCT FROM OLD.cost_usd + AND NEW.cost_multiplier IS NOT DISTINCT FROM OLD.cost_multiplier + AND NEW.input_tokens IS NOT DISTINCT FROM OLD.input_tokens + AND NEW.output_tokens IS NOT DISTINCT FROM OLD.output_tokens + AND NEW.cache_creation_input_tokens IS NOT DISTINCT FROM OLD.cache_creation_input_tokens + AND NEW.cache_read_input_tokens IS NOT DISTINCT FROM OLD.cache_read_input_tokens + AND NEW.cache_creation_5m_input_tokens IS NOT DISTINCT FROM OLD.cache_creation_5m_input_tokens + AND NEW.cache_creation_1h_input_tokens IS NOT DISTINCT FROM OLD.cache_creation_1h_input_tokens + AND NEW.cache_ttl_applied IS NOT DISTINCT FROM OLD.cache_ttl_applied + AND NEW.context_1m_applied IS NOT DISTINCT FROM OLD.context_1m_applied + AND NEW.swap_cache_ttl_applied IS NOT DISTINCT FROM OLD.swap_cache_ttl_applied + AND NEW.duration_ms IS NOT DISTINCT FROM OLD.duration_ms + AND NEW.ttfb_ms IS NOT DISTINCT FROM OLD.ttfb_ms + AND NEW.created_at IS NOT DISTINCT FROM OLD.created_at + THEN + RETURN NEW; + END IF; + END IF; + INSERT INTO usage_ledger ( request_id, user_id, key, provider_id, final_provider_id, model, original_model, endpoint, api_type, session_id, diff --git a/tests/unit/usage-ledger/trigger.test.ts b/tests/unit/usage-ledger/trigger.test.ts index 6890dedcb..2df1da929 100644 --- a/tests/unit/usage-ledger/trigger.test.ts +++ b/tests/unit/usage-ledger/trigger.test.ts @@ -25,6 +25,14 @@ describe("fn_upsert_usage_ledger trigger SQL", () => { expect(sql).toContain("error_message IS NULL"); }); + it("skips irrelevant updates to reduce write amplification", () => { + expect(sql).toContain("TG_OP = 'UPDATE'"); + expect(sql).toContain("IS NOT DISTINCT FROM"); + // Ensure the skip logic compares derived values (usage_ledger doesn't persist provider_chain / error_message) + expect(sql).toContain("v_final_provider_id IS NOT DISTINCT FROM v_old_final_provider_id"); + expect(sql).toContain("v_is_success IS NOT DISTINCT FROM v_old_is_success"); + }); + it("creates trigger binding", () => { expect(sql).toContain("CREATE TRIGGER trg_upsert_usage_ledger"); }); From 02aad0fac4473a3453c43a2b5aaa9c8d30d973af Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Sun, 1 Mar 2026 15:29:15 +0800 Subject: [PATCH 03/16] =?UTF-8?q?perf(providers):=20=E5=90=88=E5=B9=B6?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E9=A1=B5=E8=AF=BB=E8=AF=B7=E6=B1=82=EF=BC=8C?= =?UTF-8?q?=E9=99=8D=E4=BD=8E=E7=80=91=E5=B8=83=E4=B8=8E=20DB=20=E5=8E=8B?= =?UTF-8?q?=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/actions/providers.ts | 69 +++++++++++++++++-- .../_components/provider-manager-loader.tsx | 65 ++++------------- tests/unit/actions/providers.test.ts | 4 +- 3 files changed, 81 insertions(+), 57 deletions(-) diff --git a/src/actions/providers.ts b/src/actions/providers.ts index 712f6928d..f91d28395 100644 --- a/src/actions/providers.ts +++ b/src/actions/providers.ts @@ -17,6 +17,7 @@ import { publishCircuitBreakerConfigInvalidation, resetCircuit, } from "@/lib/circuit-breaker"; +import { getCachedSystemSettings } from "@/lib/config"; import { PROVIDER_GROUP, PROVIDER_TIMEOUT_DEFAULTS } from "@/lib/constants/provider.constants"; import { logger } from "@/lib/logger"; import { PROVIDER_BATCH_PATCH_ERROR_CODES } from "@/lib/provider-batch-patch-error-codes"; @@ -44,6 +45,7 @@ import { } from "@/lib/redis/circuit-breaker-config"; import { RedisKVStore } from "@/lib/redis/redis-kv-store"; import type { Context1mPreference } from "@/lib/special-attributes"; +import type { CurrencyCode } from "@/lib/utils/currency"; import { maskKey } from "@/lib/utils/validation"; import { extractZodErrorCode, formatZodError } from "@/lib/utils/zod-i18n"; import { validateProviderUrlForConnectivity } from "@/lib/validation/provider-url"; @@ -201,7 +203,8 @@ export async function getProviders(): Promise { } // 仅获取供应商列表,统计数据由前端异步获取 - const providers = await findAllProvidersFresh(); + // 使用进程级缓存(30s TTL + pub/sub 失效)降低后台高频读取对 DB 的压力 + const providers = await findAllProviders(); // 空统计数组,保持后续合并逻辑兼容 const statistics: Awaited> = []; @@ -337,6 +340,65 @@ export async function getProviders(): Promise { } } +export type ProviderHealthStatusMap = Record< + number, + { + circuitState: "closed" | "open" | "half-open"; + failureCount: number; + lastFailureTime: number | null; + circuitOpenUntil: number | null; + recoveryMinutes: number | null; + } +>; + +export type ProviderManagerBootstrapData = { + providers: ProviderDisplay[]; + healthStatus: ProviderHealthStatusMap; + systemSettings: { currencyDisplay: CurrencyCode }; +}; + +/** + * Providers 管理页:合并 providers / health / system-settings 的读取,减少瀑布式请求 + */ +export async function getProviderManagerBootstrapData(): Promise { + const session = await getSession(); + if (!session || session.user.role !== "admin") { + return { + providers: [], + healthStatus: {}, + systemSettings: { currencyDisplay: "USD" }, + }; + } + + const [providers, systemSettings] = await Promise.all([ + getProviders(), + getCachedSystemSettings(), + ]); + + const providerIds = providers.map((provider) => provider.id); + const healthStatusRaw = await getAllHealthStatusAsync(providerIds, { forceRefresh: true }); + + const now = Date.now(); + const healthStatus: ProviderHealthStatusMap = {}; + Object.entries(healthStatusRaw).forEach(([providerId, health]) => { + healthStatus[Number(providerId)] = { + circuitState: health.circuitState, + failureCount: health.failureCount, + lastFailureTime: health.lastFailureTime, + circuitOpenUntil: health.circuitOpenUntil, + recoveryMinutes: health.circuitOpenUntil + ? Math.ceil((health.circuitOpenUntil - now) / 60000) + : null, + }; + }); + + return { + providers, + healthStatus, + systemSettings: { currencyDisplay: systemSettings.currencyDisplay }, + }; +} + /** * Async get provider statistics data (today cost, call count, last call info) * Called independently by frontend, does not block main list loading @@ -1035,9 +1097,8 @@ export async function getProvidersHealthStatus() { return {}; } - const providerIds = await findAllProvidersFresh().then((providers) => - providers.map((p) => p.id) - ); + // 使用进程级缓存(30s TTL + pub/sub 失效)降低后台高频读取对 DB 的压力 + const providerIds = await findAllProviders().then((providers) => providers.map((p) => p.id)); const healthStatus = await getAllHealthStatusAsync(providerIds, { forceRefresh: true, }); diff --git a/src/app/[locale]/settings/providers/_components/provider-manager-loader.tsx b/src/app/[locale]/settings/providers/_components/provider-manager-loader.tsx index 848fb8fc2..433251814 100644 --- a/src/app/[locale]/settings/providers/_components/provider-manager-loader.tsx +++ b/src/app/[locale]/settings/providers/_components/provider-manager-loader.tsx @@ -2,35 +2,15 @@ import { useQuery } from "@tanstack/react-query"; import { + getProviderManagerBootstrapData, getProviderStatisticsAsync, - getProviders, - getProvidersHealthStatus, + type ProviderManagerBootstrapData, } from "@/actions/providers"; -import type { CurrencyCode } from "@/lib/utils/currency"; import type { ProviderDisplay, ProviderStatisticsMap } from "@/types/provider"; import type { User } from "@/types/user"; import { AddProviderDialog } from "./add-provider-dialog"; import { ProviderManager } from "./provider-manager"; -type ProviderHealthStatus = Record< - number, - { - circuitState: "closed" | "open" | "half-open"; - failureCount: number; - lastFailureTime: number | null; - circuitOpenUntil: number | null; - recoveryMinutes: number | null; - } ->; - -async function fetchSystemSettings(): Promise<{ currencyDisplay: CurrencyCode }> { - const response = await fetch("/api/system-settings"); - if (!response.ok) { - throw new Error("FETCH_SETTINGS_FAILED"); - } - return response.json() as Promise<{ currencyDisplay: CurrencyCode }>; -} - interface ProviderManagerLoaderProps { currentUser?: User; enableMultiProviderTypes?: boolean; @@ -41,26 +21,19 @@ function ProviderManagerLoaderContent({ enableMultiProviderTypes = true, }: ProviderManagerLoaderProps) { const { - data: providers = [], - isLoading: isProvidersLoading, - isFetching: isProvidersFetching, - } = useQuery({ - queryKey: ["providers"], - queryFn: getProviders, + data: bootstrap, + isLoading: isBootstrapLoading, + isFetching: isBootstrapFetching, + } = useQuery({ + queryKey: ["providers-bootstrap"], + queryFn: getProviderManagerBootstrapData, refetchOnWindowFocus: false, staleTime: 30_000, }); - const { - data: healthStatus = {} as ProviderHealthStatus, - isLoading: isHealthLoading, - isFetching: isHealthFetching, - } = useQuery({ - queryKey: ["providers-health"], - queryFn: getProvidersHealthStatus, - refetchOnWindowFocus: false, - staleTime: 30_000, - }); + const providers: ProviderDisplay[] = bootstrap?.providers ?? []; + const healthStatus = bootstrap?.healthStatus ?? {}; + const currencyCode = bootstrap?.systemSettings.currencyDisplay ?? "USD"; // Statistics loaded independently with longer cache const { data: statistics = {} as ProviderStatisticsMap, isLoading: isStatisticsLoading } = @@ -72,20 +45,8 @@ function ProviderManagerLoaderContent({ refetchInterval: 60_000, }); - const { - data: systemSettings, - isLoading: isSettingsLoading, - isFetching: isSettingsFetching, - } = useQuery<{ currencyDisplay: CurrencyCode }>({ - queryKey: ["system-settings"], - queryFn: fetchSystemSettings, - refetchOnWindowFocus: false, - staleTime: 30_000, - }); - - const loading = isProvidersLoading || isHealthLoading || isSettingsLoading; - const refreshing = !loading && (isProvidersFetching || isHealthFetching || isSettingsFetching); - const currencyCode = systemSettings?.currencyDisplay ?? "USD"; + const loading = isBootstrapLoading; + const refreshing = !loading && isBootstrapFetching; return ( ({ vi.mock("@/repository/provider", () => ({ createProvider: createProviderMock, deleteProvider: deleteProviderMock, - findAllProviders: vi.fn(async () => []), + // getProviders() now uses the cached providers entrypoint; keep tests aligned by delegating + // to the fresh mock (cache behavior is covered elsewhere). + findAllProviders: vi.fn(async () => await findAllProvidersFreshMock()), findAllProvidersFresh: findAllProvidersFreshMock, findProviderById: findProviderByIdMock, getProviderStatistics: getProviderStatisticsMock, From 8f08e56a40cd3c85c2b6b8c6cbc6ba0199052a20 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Sun, 1 Mar 2026 15:50:37 +0800 Subject: [PATCH 04/16] =?UTF-8?q?fix(providers):=20=E7=BB=9F=E4=B8=80=20in?= =?UTF-8?q?validate=20=E5=88=B0=20providers-bootstrap=20=E6=9F=A5=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_components/auto-sort-priority-dialog.tsx | 2 +- .../batch-edit/provider-batch-dialog.tsx | 8 +++---- .../_components/forms/provider-form/index.tsx | 15 +++++-------- .../_components/provider-rich-list-item.tsx | 21 +++++++------------ .../_components/recluster-vendors-dialog.tsx | 2 +- .../_components/vendor-keys-compact-list.tsx | 11 ++++------ 6 files changed, 23 insertions(+), 36 deletions(-) diff --git a/src/app/[locale]/settings/providers/_components/auto-sort-priority-dialog.tsx b/src/app/[locale]/settings/providers/_components/auto-sort-priority-dialog.tsx index 13b2d9db6..256d36536 100644 --- a/src/app/[locale]/settings/providers/_components/auto-sort-priority-dialog.tsx +++ b/src/app/[locale]/settings/providers/_components/auto-sort-priority-dialog.tsx @@ -113,7 +113,7 @@ export function AutoSortPriorityDialog() { const result = await autoSortProviderPriority({ confirm: true }); if (result.ok) { toast.success(t("success", { count: result.data.summary.changedCount })); - queryClient.invalidateQueries({ queryKey: ["providers"] }); + queryClient.invalidateQueries({ queryKey: ["providers-bootstrap"] }); setOpen(false); } else { toast.error(getActionErrorMessage(result)); diff --git a/src/app/[locale]/settings/providers/_components/batch-edit/provider-batch-dialog.tsx b/src/app/[locale]/settings/providers/_components/batch-edit/provider-batch-dialog.tsx index 6920c1888..2ded957bf 100644 --- a/src/app/[locale]/settings/providers/_components/batch-edit/provider-batch-dialog.tsx +++ b/src/app/[locale]/settings/providers/_components/batch-edit/provider-batch-dialog.tsx @@ -224,7 +224,7 @@ function BatchEditDialogContent({ }); if (result.ok) { - await queryClient.invalidateQueries({ queryKey: ["providers"] }); + await queryClient.invalidateQueries({ queryKey: ["providers-bootstrap"] }); onOpenChange(false); onSuccess?.(); @@ -239,7 +239,7 @@ function BatchEditDialogContent({ const undoResult = await undoProviderPatch({ undoToken, operationId }); if (undoResult.ok) { toast.success(t("toast.undoSuccess", { count: undoResult.data.revertedCount })); - queryClient.invalidateQueries({ queryKey: ["providers"] }); + queryClient.invalidateQueries({ queryKey: ["providers-bootstrap"] }); } else { toast.error(t("toast.undoFailed", { error: undoResult.error })); } @@ -417,7 +417,7 @@ function BatchConfirmDialog({ toast.success( t("undo.batchDeleteUndone", { count: undoResult.data.restoredCount }) ); - await queryClient.invalidateQueries({ queryKey: ["providers"] }); + await queryClient.invalidateQueries({ queryKey: ["providers-bootstrap"] }); } else if ( undoResult.errorCode === PROVIDER_BATCH_PATCH_ERROR_CODES.UNDO_EXPIRED ) { @@ -447,7 +447,7 @@ function BatchConfirmDialog({ } } - await queryClient.invalidateQueries({ queryKey: ["providers"] }); + await queryClient.invalidateQueries({ queryKey: ["providers-bootstrap"] }); onOpenChange(false); onSuccess?.(); } catch (error) { diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/index.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form/index.tsx index 4d381451c..a6aa26507 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form/index.tsx +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/index.tsx @@ -388,8 +388,7 @@ function ProviderFormContent({ const undoResult = await undoProviderPatch({ undoToken, operationId }); if (undoResult.ok) { toast.success(tBatchEdit("undo.singleEditUndone")); - await queryClient.invalidateQueries({ queryKey: ["providers"] }); - await queryClient.invalidateQueries({ queryKey: ["providers-health"] }); + await queryClient.invalidateQueries({ queryKey: ["providers-bootstrap"] }); await queryClient.invalidateQueries({ queryKey: ["providers-statistics"] }); await queryClient.invalidateQueries({ queryKey: ["provider-vendors"] }); } else if ( @@ -406,8 +405,7 @@ function ProviderFormContent({ }, }); - void queryClient.invalidateQueries({ queryKey: ["providers"] }); - void queryClient.invalidateQueries({ queryKey: ["providers-health"] }); + void queryClient.invalidateQueries({ queryKey: ["providers-bootstrap"] }); void queryClient.invalidateQueries({ queryKey: ["providers-statistics"] }); void queryClient.invalidateQueries({ queryKey: ["provider-vendors"] }); } else { @@ -419,8 +417,7 @@ function ProviderFormContent({ return; } - void queryClient.invalidateQueries({ queryKey: ["providers"] }); - void queryClient.invalidateQueries({ queryKey: ["providers-health"] }); + void queryClient.invalidateQueries({ queryKey: ["providers-bootstrap"] }); void queryClient.invalidateQueries({ queryKey: ["providers-statistics"] }); void queryClient.invalidateQueries({ queryKey: ["provider-vendors"] }); @@ -480,8 +477,7 @@ function ProviderFormContent({ const undoResult = await undoProviderDelete({ undoToken, operationId }); if (undoResult.ok) { toast.success(tBatchEdit("undo.singleDeleteUndone")); - await queryClient.invalidateQueries({ queryKey: ["providers"] }); - await queryClient.invalidateQueries({ queryKey: ["providers-health"] }); + await queryClient.invalidateQueries({ queryKey: ["providers-bootstrap"] }); await queryClient.invalidateQueries({ queryKey: ["providers-statistics"] }); await queryClient.invalidateQueries({ queryKey: ["provider-vendors"] }); } else if (undoResult.errorCode === PROVIDER_BATCH_PATCH_ERROR_CODES.UNDO_EXPIRED) { @@ -496,8 +492,7 @@ function ProviderFormContent({ }, }); - void queryClient.invalidateQueries({ queryKey: ["providers"] }); - void queryClient.invalidateQueries({ queryKey: ["providers-health"] }); + void queryClient.invalidateQueries({ queryKey: ["providers-bootstrap"] }); void queryClient.invalidateQueries({ queryKey: ["providers-statistics"] }); void queryClient.invalidateQueries({ queryKey: ["provider-vendors"] }); onSuccess?.(); diff --git a/src/app/[locale]/settings/providers/_components/provider-rich-list-item.tsx b/src/app/[locale]/settings/providers/_components/provider-rich-list-item.tsx index 5681eb8de..1347d7387 100644 --- a/src/app/[locale]/settings/providers/_components/provider-rich-list-item.tsx +++ b/src/app/[locale]/settings/providers/_components/provider-rich-list-item.tsx @@ -225,8 +225,7 @@ export function ProviderRichListItem({ const undoResult = await undoProviderDelete({ undoToken, operationId }); if (undoResult.ok) { toast.success(tBatchEdit("undo.singleDeleteUndone")); - await queryClient.invalidateQueries({ queryKey: ["providers"] }); - await queryClient.invalidateQueries({ queryKey: ["providers-health"] }); + await queryClient.invalidateQueries({ queryKey: ["providers-bootstrap"] }); await queryClient.invalidateQueries({ queryKey: ["provider-vendors"] }); } else if ( undoResult.errorCode === PROVIDER_BATCH_PATCH_ERROR_CODES.UNDO_EXPIRED @@ -242,8 +241,7 @@ export function ProviderRichListItem({ }, }); - queryClient.invalidateQueries({ queryKey: ["providers"] }); - queryClient.invalidateQueries({ queryKey: ["providers-health"] }); + queryClient.invalidateQueries({ queryKey: ["providers-bootstrap"] }); queryClient.invalidateQueries({ queryKey: ["provider-vendors"] }); } else { toast.error(tList("deleteFailed"), { @@ -313,8 +311,7 @@ export function ProviderRichListItem({ toast.success(tList("resetCircuitSuccess"), { description: tList("resetCircuitSuccessDesc", { name: provider.name }), }); - queryClient.invalidateQueries({ queryKey: ["providers"] }); - queryClient.invalidateQueries({ queryKey: ["providers-health"] }); + queryClient.invalidateQueries({ queryKey: ["providers-bootstrap"] }); } else { toast.error(tList("resetCircuitFailed"), { description: res.error || tList("unknownError"), @@ -338,8 +335,7 @@ export function ProviderRichListItem({ toast.success(tList("resetUsageSuccess"), { description: tList("resetUsageSuccessDesc", { name: provider.name }), }); - queryClient.invalidateQueries({ queryKey: ["providers"] }); - queryClient.invalidateQueries({ queryKey: ["providers-health"] }); + queryClient.invalidateQueries({ queryKey: ["providers-bootstrap"] }); } else { toast.error(tList("resetUsageFailed"), { description: res.error || tList("unknownError"), @@ -366,8 +362,7 @@ export function ProviderRichListItem({ toast.success(tList("toggleSuccess", { status }), { description: tList("toggleSuccessDesc", { name: provider.name }), }); - queryClient.invalidateQueries({ queryKey: ["providers"] }); - queryClient.invalidateQueries({ queryKey: ["providers-health"] }); + queryClient.invalidateQueries({ queryKey: ["providers-bootstrap"] }); } else { toast.error(tList("toggleFailed"), { description: res.error || tList("unknownError"), @@ -390,7 +385,7 @@ export function ProviderRichListItem({ >[1]); if (res.ok) { toast.success(tInline("saveSuccess")); - queryClient.invalidateQueries({ queryKey: ["providers"] }); + queryClient.invalidateQueries({ queryKey: ["providers-bootstrap"] }); return true; } toast.error(tInline("saveFailed"), { description: res.error || tList("unknownError") }); @@ -419,7 +414,7 @@ export function ProviderRichListItem({ const res = await editProvider(provider.id, { group_tag: groupTag }); if (res.ok) { toast.success(tInline("saveSuccess")); - queryClient.invalidateQueries({ queryKey: ["providers"] }); + queryClient.invalidateQueries({ queryKey: ["providers-bootstrap"] }); return true; } toast.error(tInline("groupSaveError"), { @@ -444,7 +439,7 @@ export function ProviderRichListItem({ }); if (res.ok) { toast.success(tInline("saveSuccess")); - queryClient.invalidateQueries({ queryKey: ["providers"] }); + queryClient.invalidateQueries({ queryKey: ["providers-bootstrap"] }); return true; } toast.error(tInline("saveFailed"), { description: res.error || tList("unknownError") }); diff --git a/src/app/[locale]/settings/providers/_components/recluster-vendors-dialog.tsx b/src/app/[locale]/settings/providers/_components/recluster-vendors-dialog.tsx index d22b1f3a4..8f488ca3c 100644 --- a/src/app/[locale]/settings/providers/_components/recluster-vendors-dialog.tsx +++ b/src/app/[locale]/settings/providers/_components/recluster-vendors-dialog.tsx @@ -113,7 +113,7 @@ export function ReclusterVendorsDialog() { const result = await reclusterProviderVendors({ confirm: true }); if (result.ok) { toast.success(t("success", { count: result.data.preview.providersMoved })); - queryClient.invalidateQueries({ queryKey: ["providers"] }); + queryClient.invalidateQueries({ queryKey: ["providers-bootstrap"] }); queryClient.invalidateQueries({ queryKey: ["provider-vendors"] }); queryClient.invalidateQueries({ queryKey: ["provider-endpoints"] }); setOpen(false); diff --git a/src/app/[locale]/settings/providers/_components/vendor-keys-compact-list.tsx b/src/app/[locale]/settings/providers/_components/vendor-keys-compact-list.tsx index 15b268f7f..0ce57a99c 100644 --- a/src/app/[locale]/settings/providers/_components/vendor-keys-compact-list.tsx +++ b/src/app/[locale]/settings/providers/_components/vendor-keys-compact-list.tsx @@ -260,7 +260,7 @@ function VendorKeyRow(props: { const res = await editProvider(props.provider.id, { [fieldName]: value }); if (res.ok) { toast.success(tInline("saveSuccess")); - queryClient.invalidateQueries({ queryKey: ["providers"] }); + queryClient.invalidateQueries({ queryKey: ["providers-bootstrap"] }); queryClient.invalidateQueries({ queryKey: ["provider-vendors"] }); return true; } @@ -299,8 +299,7 @@ function VendorKeyRow(props: { if (!res.ok) throw new Error(res.error); }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["providers"] }); - queryClient.invalidateQueries({ queryKey: ["providers-health"] }); + queryClient.invalidateQueries({ queryKey: ["providers-bootstrap"] }); queryClient.invalidateQueries({ queryKey: ["provider-vendors"] }); }, onError: () => { @@ -315,8 +314,7 @@ function VendorKeyRow(props: { return res.data; }, onSuccess: (data) => { - queryClient.invalidateQueries({ queryKey: ["providers"] }); - queryClient.invalidateQueries({ queryKey: ["providers-health"] }); + queryClient.invalidateQueries({ queryKey: ["providers-bootstrap"] }); queryClient.invalidateQueries({ queryKey: ["providers-statistics"] }); queryClient.invalidateQueries({ queryKey: ["provider-vendors"] }); setDeleteDialogOpen(false); @@ -333,8 +331,7 @@ function VendorKeyRow(props: { }); if (undoResult.ok) { toast.success(tBatchEdit("undo.singleDeleteUndone")); - await queryClient.invalidateQueries({ queryKey: ["providers"] }); - await queryClient.invalidateQueries({ queryKey: ["providers-health"] }); + await queryClient.invalidateQueries({ queryKey: ["providers-bootstrap"] }); await queryClient.invalidateQueries({ queryKey: ["providers-statistics"] }); await queryClient.invalidateQueries({ queryKey: ["provider-vendors"] }); } else if (undoResult.errorCode === PROVIDER_BATCH_PATCH_ERROR_CODES.UNDO_EXPIRED) { From b480660161ff0be35e25fcfbf0d179d591ca39f9 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Sun, 1 Mar 2026 16:03:44 +0800 Subject: [PATCH 05/16] =?UTF-8?q?perf(config):=20=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E8=AF=BB=E5=8F=96=E6=94=B9=E7=94=A8=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E8=AE=BE=E7=BD=AE=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/log-cleanup/cleanup-queue.ts | 4 ++-- src/repository/cache-hit-rate-alert.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/lib/log-cleanup/cleanup-queue.ts b/src/lib/log-cleanup/cleanup-queue.ts index 8e0c7cf89..20bed591c 100644 --- a/src/lib/log-cleanup/cleanup-queue.ts +++ b/src/lib/log-cleanup/cleanup-queue.ts @@ -3,8 +3,8 @@ import { BullAdapter } from "@bull-board/api/bullAdapter"; import { ExpressAdapter } from "@bull-board/express"; import type { Job } from "bull"; import Queue from "bull"; +import { getCachedSystemSettings } from "@/lib/config"; import { logger } from "@/lib/logger"; -import { getSystemSettings } from "@/repository/system-config"; import { cleanupLogs } from "./service"; /** @@ -147,7 +147,7 @@ function setupQueueProcessor(queue: Queue.Queue): void { */ export async function scheduleAutoCleanup() { try { - const settings = await getSystemSettings(); + const settings = await getCachedSystemSettings(); const queue = getCleanupQueue(); if (!settings.enableAutoCleanup) { diff --git a/src/repository/cache-hit-rate-alert.ts b/src/repository/cache-hit-rate-alert.ts index 2d151ac84..2fcc26262 100644 --- a/src/repository/cache-hit-rate-alert.ts +++ b/src/repository/cache-hit-rate-alert.ts @@ -4,8 +4,8 @@ import { and, desc, eq, gte, isNull, lt, sql } from "drizzle-orm"; import { alias } from "drizzle-orm/pg-core"; import { db } from "@/drizzle/db"; import { messageRequest, providers } from "@/drizzle/schema"; +import { getCachedSystemSettings } from "@/lib/config"; import { EXCLUDE_WARMUP_CONDITION } from "@/repository/_shared/message-request-conditions"; -import { getSystemSettings } from "@/repository/system-config"; import type { ProviderType } from "@/types/provider"; export interface TimeRange { @@ -125,7 +125,7 @@ export async function findProviderModelCacheHitRateMetricsForAlert( const windowMode = normalizeWindowMode(config); const ttlFallback = normalizeTtlFallbackSeconds(config); - const systemSettings = await getSystemSettings(); + const systemSettings = await getCachedSystemSettings(); const billingModelSource = systemSettings.billingModelSource; const modelField = From 9ab60a81d89651ada8a2eae426bda992dba3339f Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Sun, 1 Mar 2026 16:11:21 +0800 Subject: [PATCH 06/16] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0=E5=B8=B8?= =?UTF-8?q?=E7=94=A8=E9=A1=B5=E9=9D=A2=E6=80=A7=E8=83=BD=E8=B7=AF=E7=BA=BF?= =?UTF-8?q?=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/performance/common-pages-optimization-plan.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/performance/common-pages-optimization-plan.md b/docs/performance/common-pages-optimization-plan.md index c79b51e96..e15159c20 100644 --- a/docs/performance/common-pages-optimization-plan.md +++ b/docs/performance/common-pages-optimization-plan.md @@ -72,6 +72,7 @@ - 系统设置缓存跨实例失效通知:在保存系统设置后通过 Redis Pub/Sub 广播失效,让各实例的进程内缓存立即失效(减少重复读 `system_settings`)。 - 使用记录自动刷新减负:前端仅轮询“最新一页”,并合并到现有无限列表(避免 react-query 在 infiniteQuery 下重拉所有 pages)。 +- 供应商管理请求瀑布减负:将 providers/health/system-settings 合并为单一 bootstrap 请求(约 4 个请求 -> 2 个请求);providers 列表改为走 30s TTL + pub/sub 失效的进程缓存(降低 DB 读放大)。 ### 已落地代码位置(便于继续扩展) @@ -115,6 +116,7 @@ - 示例触发字段:`status_code`、`cost_usd`、`duration_ms`、`tokens`、`blocked_by`、`provider_chain`、`model/provider_id` 等。 - 优点:不改变外部读模型(usage_ledger 仍是源),但能显著减少重复 UPSERT。 - 风险:需要严谨列出“影响 billing/统计/展示”的字段集合,避免漏更新。 + - 已落地:在 trigger 函数内对 `usage_ledger` 相关字段与派生值(`final_provider_id` / `is_success`)做对比,无变化直接 `RETURN NEW`,减少无效 UPSERT(迁移:`0078_perf_usage_ledger_trigger_skip_irrelevant_updates.sql`)。 - 方向 B:仅在“终态”写入 ledger(例如 `duration_ms IS NOT NULL` 或 `status_code IS NOT NULL`) - 优点:写放大最小。 - 风险:会改变“进行中请求”的统计可见性,需要产品确认,并必须 feature flag。 From 85fbaa6c256dcda9e665cda395997f5c7dc6a577 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Sun, 1 Mar 2026 17:51:34 +0800 Subject: [PATCH 07/16] =?UTF-8?q?fix(cleanup):=20=E8=B0=83=E5=BA=A6?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E6=B8=85=E7=90=86=E6=97=B6=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E4=B8=A5=E6=A0=BC=E7=B3=BB=E7=BB=9F=E8=AE=BE=E7=BD=AE=E8=AF=BB?= =?UTF-8?q?=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/log-cleanup/cleanup-queue.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/log-cleanup/cleanup-queue.ts b/src/lib/log-cleanup/cleanup-queue.ts index 20bed591c..8e0c7cf89 100644 --- a/src/lib/log-cleanup/cleanup-queue.ts +++ b/src/lib/log-cleanup/cleanup-queue.ts @@ -3,8 +3,8 @@ import { BullAdapter } from "@bull-board/api/bullAdapter"; import { ExpressAdapter } from "@bull-board/express"; import type { Job } from "bull"; import Queue from "bull"; -import { getCachedSystemSettings } from "@/lib/config"; import { logger } from "@/lib/logger"; +import { getSystemSettings } from "@/repository/system-config"; import { cleanupLogs } from "./service"; /** @@ -147,7 +147,7 @@ function setupQueueProcessor(queue: Queue.Queue): void { */ export async function scheduleAutoCleanup() { try { - const settings = await getCachedSystemSettings(); + const settings = await getSystemSettings(); const queue = getCleanupQueue(); if (!settings.enableAutoCleanup) { From 868d29ac335f09e19c63f5ac1cdd8b0553b4d940 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Sun, 1 Mar 2026 18:07:04 +0800 Subject: [PATCH 08/16] =?UTF-8?q?fix(providers):=20=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E9=A1=B5=E8=AF=BB=E5=8F=96=E7=BB=95=E8=BF=87=E8=BF=9B=E7=A8=8B?= =?UTF-8?q?=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/actions/providers.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/actions/providers.ts b/src/actions/providers.ts index f91d28395..4e873f295 100644 --- a/src/actions/providers.ts +++ b/src/actions/providers.ts @@ -203,8 +203,9 @@ export async function getProviders(): Promise { } // 仅获取供应商列表,统计数据由前端异步获取 - // 使用进程级缓存(30s TTL + pub/sub 失效)降低后台高频读取对 DB 的压力 - const providers = await findAllProviders(); + // 管理/配置类页面:优先保证强一致,避免跨实例缓存带来的短暂陈旧数据(用户可感知) + // 热路径(proxy/session)仍可使用 findAllProviders() 的进程缓存以降低 DB 压力 + const providers = await findAllProvidersFresh(); // 空统计数组,保持后续合并逻辑兼容 const statistics: Awaited> = []; @@ -1097,8 +1098,10 @@ export async function getProvidersHealthStatus() { return {}; } - // 使用进程级缓存(30s TTL + pub/sub 失效)降低后台高频读取对 DB 的压力 - const providerIds = await findAllProviders().then((providers) => providers.map((p) => p.id)); + // 管理/监控展示:优先保证强一致,避免跨实例缓存导致新 provider/删除 provider 短暂不一致 + const providerIds = await findAllProvidersFresh().then((providers) => + providers.map((p) => p.id) + ); const healthStatus = await getAllHealthStatusAsync(providerIds, { forceRefresh: true, }); From 85469f7bc0f2b19505c93c63e4ce2ea5bb25ae1b Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Sun, 1 Mar 2026 18:07:16 +0800 Subject: [PATCH 09/16] =?UTF-8?q?fix(usage-ledger):=20=E8=B7=B3=E8=BF=87?= =?UTF-8?q?=E6=97=A0=E5=85=B3=E6=9B=B4=E6=96=B0=E5=89=8D=E7=A1=AE=E8=AE=A4?= =?UTF-8?q?=20ledger=20=E8=A1=8C=E5=AD=98=E5=9C=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...78_perf_usage_ledger_trigger_skip_irrelevant_updates.sql | 6 +++++- src/lib/ledger-backfill/trigger.sql | 6 +++++- tests/unit/usage-ledger/trigger.test.ts | 2 ++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/drizzle/0078_perf_usage_ledger_trigger_skip_irrelevant_updates.sql b/drizzle/0078_perf_usage_ledger_trigger_skip_irrelevant_updates.sql index c5873a7a0..c4103c102 100644 --- a/drizzle/0078_perf_usage_ledger_trigger_skip_irrelevant_updates.sql +++ b/drizzle/0078_perf_usage_ledger_trigger_skip_irrelevant_updates.sql @@ -71,7 +71,11 @@ BEGIN AND NEW.ttfb_ms IS NOT DISTINCT FROM OLD.ttfb_ms AND NEW.created_at IS NOT DISTINCT FROM OLD.created_at THEN - RETURN NEW; + -- Self-heal: if prior UPSERT failed and ledger row is missing, allow a later UPDATE to fill it. + -- Uses cheap indexed read (request_id UNIQUE) to avoid reintroducing write amplification. + IF EXISTS (SELECT 1 FROM usage_ledger WHERE request_id = NEW.id) THEN + RETURN NEW; + END IF; END IF; END IF; diff --git a/src/lib/ledger-backfill/trigger.sql b/src/lib/ledger-backfill/trigger.sql index 5a910381b..bae7dcfe1 100644 --- a/src/lib/ledger-backfill/trigger.sql +++ b/src/lib/ledger-backfill/trigger.sql @@ -71,7 +71,11 @@ BEGIN AND NEW.ttfb_ms IS NOT DISTINCT FROM OLD.ttfb_ms AND NEW.created_at IS NOT DISTINCT FROM OLD.created_at THEN - RETURN NEW; + -- 自愈:如果上一次 UPSERT 因异常失败导致 ledger 行缺失,允许在后续 UPDATE 中补齐。 + -- 这里用索引读(request_id UNIQUE)替代重复写入,兼顾“写放大治理”和“最终一致”。 + IF EXISTS (SELECT 1 FROM usage_ledger WHERE request_id = NEW.id) THEN + RETURN NEW; + END IF; END IF; END IF; diff --git a/tests/unit/usage-ledger/trigger.test.ts b/tests/unit/usage-ledger/trigger.test.ts index 2df1da929..6cd77b3cb 100644 --- a/tests/unit/usage-ledger/trigger.test.ts +++ b/tests/unit/usage-ledger/trigger.test.ts @@ -31,6 +31,8 @@ describe("fn_upsert_usage_ledger trigger SQL", () => { // Ensure the skip logic compares derived values (usage_ledger doesn't persist provider_chain / error_message) expect(sql).toContain("v_final_provider_id IS NOT DISTINCT FROM v_old_final_provider_id"); expect(sql).toContain("v_is_success IS NOT DISTINCT FROM v_old_is_success"); + // Self-heal: if ledger row is missing, later UPDATE can fill it (avoids permanent gaps) + expect(sql).toContain("EXISTS (SELECT 1 FROM usage_ledger WHERE request_id = NEW.id)"); }); it("creates trigger binding", () => { From 080581e50e01251c63f1f2193221efc94a904501 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Sun, 1 Mar 2026 18:54:41 +0800 Subject: [PATCH 10/16] =?UTF-8?q?perf:=20=E5=BB=B6=E8=BF=9F=20special=5Fse?= =?UTF-8?q?ttings=20=E5=8D=95=E5=AD=97=E6=AE=B5=20flush=20=E9=99=8D?= =?UTF-8?q?=E4=BD=8E=E5=86=99=E6=94=BE=E5=A4=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/repository/message-write-buffer.ts | 61 ++++++++++++++++++++++---- 1 file changed, 53 insertions(+), 8 deletions(-) diff --git a/src/repository/message-write-buffer.ts b/src/repository/message-write-buffer.ts index d2f690189..a1e33e873 100644 --- a/src/repository/message-write-buffer.ts +++ b/src/repository/message-write-buffer.ts @@ -39,6 +39,7 @@ type WriterConfig = { flushIntervalMs: number; batchSize: number; maxPending: number; + specialSettingsOnlyFlushIntervalMs: number; }; const COLUMN_MAP: Record = { @@ -66,10 +67,15 @@ const COLUMN_MAP: Record = { function loadWriterConfig(): WriterConfig { const env = getEnvConfig(); + const flushIntervalMs = env.MESSAGE_REQUEST_ASYNC_FLUSH_INTERVAL_MS ?? 250; return { - flushIntervalMs: env.MESSAGE_REQUEST_ASYNC_FLUSH_INTERVAL_MS ?? 250, + flushIntervalMs, batchSize: env.MESSAGE_REQUEST_ASYNC_BATCH_SIZE ?? 200, maxPending: env.MESSAGE_REQUEST_ASYNC_MAX_PENDING ?? 5000, + // special_settings 通常在请求前置阶段写入:若立即 flush,长请求会产生额外 UPDATE。 + // 这里默认对“仅 special_settings 的 patch”使用更长的 flush 间隔,尽量与最终统计合并落库, + // 以降低写放大与 WAL 压力;同时仍保留兜底 flush,避免极端情况下永不落库。 + specialSettingsOnlyFlushIntervalMs: Math.max(flushIntervalMs, 5000), }; } @@ -154,6 +160,7 @@ class MessageRequestWriteBuffer { private readonly config: WriterConfig; private readonly pending = new Map(); private flushTimer: NodeJS.Timeout | null = null; + private flushDueAt = 0; private flushAgainAfterCurrent = false; private flushInFlight: Promise | null = null; private stopping = false; @@ -162,6 +169,25 @@ class MessageRequestWriteBuffer { this.config = config; } + private static isSpecialSettingsOnlyPatch(patch: MessageRequestUpdatePatch): boolean { + return patch.specialSettings !== undefined && Object.keys(patch).length === 1; + } + + private getDesiredFlushIntervalMsForPatch(patch: MessageRequestUpdatePatch): number { + return MessageRequestWriteBuffer.isSpecialSettingsOnlyPatch(patch) + ? this.config.specialSettingsOnlyFlushIntervalMs + : this.config.flushIntervalMs; + } + + private getDesiredFlushIntervalMsForPending(): number { + for (const patch of this.pending.values()) { + if (!MessageRequestWriteBuffer.isSpecialSettingsOnlyPatch(patch)) { + return this.config.flushIntervalMs; + } + } + return this.config.specialSettingsOnlyFlushIntervalMs; + } + enqueue(id: number, patch: MessageRequestUpdatePatch): void { const existing = this.pending.get(id) ?? {}; const merged: MessageRequestUpdatePatch = { ...existing }; @@ -217,7 +243,7 @@ class MessageRequestWriteBuffer { // 停止阶段不再调度 timer,避免阻止进程退出 if (!this.stopping) { - this.ensureFlushTimer(); + this.ensureFlushTimer(this.getDesiredFlushIntervalMsForPatch(merged)); } // 达到批量阈值时尽快 flush,降低 durationMs 为空的“悬挂时间” @@ -226,21 +252,40 @@ class MessageRequestWriteBuffer { } } - private ensureFlushTimer(): void { - if (this.stopping || this.flushTimer) { + private ensureFlushTimer(intervalMs: number): void { + if (this.stopping) { return; } - this.flushTimer = setTimeout(() => { + const now = Date.now(); + const dueAt = now + Math.max(0, intervalMs); + + // 若已存在 timer:仅在需要“更早” flush 时重置,避免延长已有更紧急的 flush + if (this.flushTimer) { + if (this.flushDueAt > 0 && dueAt >= this.flushDueAt) { + return; + } + clearTimeout(this.flushTimer); this.flushTimer = null; - void this.flush(); - }, this.config.flushIntervalMs); + this.flushDueAt = 0; + } + + this.flushDueAt = dueAt; + this.flushTimer = setTimeout( + () => { + this.flushTimer = null; + this.flushDueAt = 0; + void this.flush(); + }, + Math.max(0, dueAt - now) + ); } private clearFlushTimer(): void { if (this.flushTimer) { clearTimeout(this.flushTimer); this.flushTimer = null; + this.flushDueAt = 0; } } @@ -289,7 +334,7 @@ class MessageRequestWriteBuffer { this.flushInFlight = null; // 如果还有积压:运行态下继续用 timer 退避重试;停止阶段不再调度 timer if (this.pending.size > 0 && !this.stopping) { - this.ensureFlushTimer(); + this.ensureFlushTimer(this.getDesiredFlushIntervalMsForPending()); } }); From 67d82fbbb5295a48ba53af94907d65e5afca028a Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Sun, 1 Mar 2026 21:00:15 +0800 Subject: [PATCH 11/16] =?UTF-8?q?perf(db):=20usage=5Fledger=20=E8=A7=A6?= =?UTF-8?q?=E5=8F=91=E5=99=A8=E8=B7=B3=E8=BF=87=20blocked=20=E5=86=99?= =?UTF-8?q?=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...usage_ledger_trigger_skip_blocked_rows.sql | 143 ++++++++++++++++++ drizzle/meta/_journal.json | 9 +- src/lib/ledger-backfill/trigger.sql | 7 + src/repository/_shared/ledger-conditions.ts | 6 +- tests/unit/usage-ledger/trigger.test.ts | 5 + 5 files changed, 167 insertions(+), 3 deletions(-) create mode 100644 drizzle/0079_perf_usage_ledger_trigger_skip_blocked_rows.sql diff --git a/drizzle/0079_perf_usage_ledger_trigger_skip_blocked_rows.sql b/drizzle/0079_perf_usage_ledger_trigger_skip_blocked_rows.sql new file mode 100644 index 000000000..1ffb05251 --- /dev/null +++ b/drizzle/0079_perf_usage_ledger_trigger_skip_blocked_rows.sql @@ -0,0 +1,143 @@ +-- perf: skip usage_ledger UPSERTs for blocked (non-billable) requests + +CREATE OR REPLACE FUNCTION fn_upsert_usage_ledger() +RETURNS TRIGGER AS $$ +DECLARE + v_final_provider_id integer; + v_old_final_provider_id integer; + v_is_success boolean; + v_old_is_success boolean; +BEGIN + IF NEW.blocked_by = 'warmup' THEN + -- If a ledger row already exists (row was originally non-warmup), mark it as warmup + UPDATE usage_ledger SET blocked_by = 'warmup' WHERE request_id = NEW.id; + RETURN NEW; + END IF; + + IF NEW.blocked_by IS NOT NULL THEN + -- Blocked requests are excluded from billing stats; avoid creating usage_ledger rows. + -- If a ledger row already exists (row was originally unblocked), mark it as blocked. + UPDATE usage_ledger SET blocked_by = NEW.blocked_by WHERE request_id = NEW.id; + RETURN NEW; + END IF; + + IF NEW.provider_chain IS NOT NULL + AND jsonb_typeof(NEW.provider_chain) = 'array' + AND jsonb_array_length(NEW.provider_chain) > 0 + AND jsonb_typeof(NEW.provider_chain -> -1) = 'object' + AND (NEW.provider_chain -> -1 ? 'id') + AND (NEW.provider_chain -> -1 ->> 'id') ~ '^[0-9]+$' THEN + v_final_provider_id := (NEW.provider_chain -> -1 ->> 'id')::integer; + ELSE + v_final_provider_id := NEW.provider_id; + END IF; + + v_is_success := (NEW.error_message IS NULL OR NEW.error_message = ''); + + -- Performance: skip UPSERT when UPDATE doesn't affect usage_ledger fields. + -- usage_ledger does NOT persist provider_chain / error_message, so compare derived values instead. + IF TG_OP = 'UPDATE' THEN + IF OLD.provider_chain IS NOT NULL + AND jsonb_typeof(OLD.provider_chain) = 'array' + AND jsonb_array_length(OLD.provider_chain) > 0 + AND jsonb_typeof(OLD.provider_chain -> -1) = 'object' + AND (OLD.provider_chain -> -1 ? 'id') + AND (OLD.provider_chain -> -1 ->> 'id') ~ '^[0-9]+$' THEN + v_old_final_provider_id := (OLD.provider_chain -> -1 ->> 'id')::integer; + ELSE + v_old_final_provider_id := OLD.provider_id; + END IF; + + v_old_is_success := (OLD.error_message IS NULL OR OLD.error_message = ''); + + IF + NEW.user_id IS NOT DISTINCT FROM OLD.user_id + AND NEW.key IS NOT DISTINCT FROM OLD.key + AND NEW.provider_id IS NOT DISTINCT FROM OLD.provider_id + AND v_final_provider_id IS NOT DISTINCT FROM v_old_final_provider_id + AND NEW.model IS NOT DISTINCT FROM OLD.model + AND NEW.original_model IS NOT DISTINCT FROM OLD.original_model + AND NEW.endpoint IS NOT DISTINCT FROM OLD.endpoint + AND NEW.api_type IS NOT DISTINCT FROM OLD.api_type + AND NEW.session_id IS NOT DISTINCT FROM OLD.session_id + AND NEW.status_code IS NOT DISTINCT FROM OLD.status_code + AND v_is_success IS NOT DISTINCT FROM v_old_is_success + AND NEW.blocked_by IS NOT DISTINCT FROM OLD.blocked_by + AND NEW.cost_usd IS NOT DISTINCT FROM OLD.cost_usd + AND NEW.cost_multiplier IS NOT DISTINCT FROM OLD.cost_multiplier + AND NEW.input_tokens IS NOT DISTINCT FROM OLD.input_tokens + AND NEW.output_tokens IS NOT DISTINCT FROM OLD.output_tokens + AND NEW.cache_creation_input_tokens IS NOT DISTINCT FROM OLD.cache_creation_input_tokens + AND NEW.cache_read_input_tokens IS NOT DISTINCT FROM OLD.cache_read_input_tokens + AND NEW.cache_creation_5m_input_tokens IS NOT DISTINCT FROM OLD.cache_creation_5m_input_tokens + AND NEW.cache_creation_1h_input_tokens IS NOT DISTINCT FROM OLD.cache_creation_1h_input_tokens + AND NEW.cache_ttl_applied IS NOT DISTINCT FROM OLD.cache_ttl_applied + AND NEW.context_1m_applied IS NOT DISTINCT FROM OLD.context_1m_applied + AND NEW.swap_cache_ttl_applied IS NOT DISTINCT FROM OLD.swap_cache_ttl_applied + AND NEW.duration_ms IS NOT DISTINCT FROM OLD.duration_ms + AND NEW.ttfb_ms IS NOT DISTINCT FROM OLD.ttfb_ms + AND NEW.created_at IS NOT DISTINCT FROM OLD.created_at + THEN + -- Self-heal: if prior UPSERT failed and ledger row is missing, allow a later UPDATE to fill it. + -- Uses cheap indexed read (request_id UNIQUE) to avoid reintroducing write amplification. + IF EXISTS (SELECT 1 FROM usage_ledger WHERE request_id = NEW.id) THEN + RETURN NEW; + END IF; + END IF; + END IF; + + INSERT INTO usage_ledger ( + request_id, user_id, key, provider_id, final_provider_id, + model, original_model, endpoint, api_type, session_id, + status_code, is_success, blocked_by, + cost_usd, cost_multiplier, + input_tokens, output_tokens, + cache_creation_input_tokens, cache_read_input_tokens, + cache_creation_5m_input_tokens, cache_creation_1h_input_tokens, + cache_ttl_applied, context_1m_applied, swap_cache_ttl_applied, + duration_ms, ttfb_ms, created_at + ) VALUES ( + NEW.id, NEW.user_id, NEW.key, NEW.provider_id, v_final_provider_id, + NEW.model, NEW.original_model, NEW.endpoint, NEW.api_type, NEW.session_id, + NEW.status_code, v_is_success, NEW.blocked_by, + NEW.cost_usd, NEW.cost_multiplier, + NEW.input_tokens, NEW.output_tokens, + NEW.cache_creation_input_tokens, NEW.cache_read_input_tokens, + NEW.cache_creation_5m_input_tokens, NEW.cache_creation_1h_input_tokens, + NEW.cache_ttl_applied, NEW.context_1m_applied, NEW.swap_cache_ttl_applied, + NEW.duration_ms, NEW.ttfb_ms, NEW.created_at + ) + ON CONFLICT (request_id) DO UPDATE SET + user_id = EXCLUDED.user_id, + key = EXCLUDED.key, + provider_id = EXCLUDED.provider_id, + final_provider_id = EXCLUDED.final_provider_id, + model = EXCLUDED.model, + original_model = EXCLUDED.original_model, + endpoint = EXCLUDED.endpoint, + api_type = EXCLUDED.api_type, + session_id = EXCLUDED.session_id, + status_code = EXCLUDED.status_code, + is_success = EXCLUDED.is_success, + blocked_by = EXCLUDED.blocked_by, + cost_usd = EXCLUDED.cost_usd, + cost_multiplier = EXCLUDED.cost_multiplier, + input_tokens = EXCLUDED.input_tokens, + output_tokens = EXCLUDED.output_tokens, + cache_creation_input_tokens = EXCLUDED.cache_creation_input_tokens, + cache_read_input_tokens = EXCLUDED.cache_read_input_tokens, + cache_creation_5m_input_tokens = EXCLUDED.cache_creation_5m_input_tokens, + cache_creation_1h_input_tokens = EXCLUDED.cache_creation_1h_input_tokens, + cache_ttl_applied = EXCLUDED.cache_ttl_applied, + context_1m_applied = EXCLUDED.context_1m_applied, + swap_cache_ttl_applied = EXCLUDED.swap_cache_ttl_applied, + duration_ms = EXCLUDED.duration_ms, + ttfb_ms = EXCLUDED.ttfb_ms, + created_at = EXCLUDED.created_at; + + RETURN NEW; +EXCEPTION WHEN OTHERS THEN + RAISE WARNING 'fn_upsert_usage_ledger failed for request_id=%: %', NEW.id, SQLERRM; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 34f90a3e7..6c73416e8 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -554,6 +554,13 @@ "when": 1772348135114, "tag": "0078_perf_usage_ledger_trigger_skip_irrelevant_updates", "breakpoints": true + }, + { + "idx": 79, + "version": "7", + "when": 1772369661910, + "tag": "0079_perf_usage_ledger_trigger_skip_blocked_rows", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/src/lib/ledger-backfill/trigger.sql b/src/lib/ledger-backfill/trigger.sql index bae7dcfe1..9925357e1 100644 --- a/src/lib/ledger-backfill/trigger.sql +++ b/src/lib/ledger-backfill/trigger.sql @@ -12,6 +12,13 @@ BEGIN RETURN NEW; END IF; + IF NEW.blocked_by IS NOT NULL THEN + -- Blocked requests are excluded from billing stats; avoid creating usage_ledger rows. + -- If a ledger row already exists (row was originally unblocked), mark it as blocked. + UPDATE usage_ledger SET blocked_by = NEW.blocked_by WHERE request_id = NEW.id; + RETURN NEW; + END IF; + IF NEW.provider_chain IS NOT NULL AND jsonb_typeof(NEW.provider_chain) = 'array' AND jsonb_array_length(NEW.provider_chain) > 0 diff --git a/src/repository/_shared/ledger-conditions.ts b/src/repository/_shared/ledger-conditions.ts index 1a192da70..089ef2364 100644 --- a/src/repository/_shared/ledger-conditions.ts +++ b/src/repository/_shared/ledger-conditions.ts @@ -3,8 +3,10 @@ import { usageLedger } from "@/drizzle/schema"; /** * 只统计未被阻断的请求。 - * Warmup 行在触发器层面已过滤,不会进入 usage_ledger, - * 因此此处只需排除 blocked_by IS NOT NULL 的记录。 + * + * 说明: + * - Warmup 行在触发器层面已过滤,不会进入 usage_ledger。 + * - 其他 blocked 请求默认也不会创建 usage_ledger 行;若请求后置被标记为 blocked,会将已有 ledger 行标记为 blocked。 */ export const LEDGER_BILLING_CONDITION = sql`(${usageLedger.blockedBy} IS NULL)`; diff --git a/tests/unit/usage-ledger/trigger.test.ts b/tests/unit/usage-ledger/trigger.test.ts index 6cd77b3cb..b50a5d8f3 100644 --- a/tests/unit/usage-ledger/trigger.test.ts +++ b/tests/unit/usage-ledger/trigger.test.ts @@ -9,6 +9,11 @@ describe("fn_upsert_usage_ledger trigger SQL", () => { expect(sql).toContain("blocked_by = 'warmup'"); }); + it("skips blocked requests to avoid wasted ledger writes", () => { + expect(sql).toContain("NEW.blocked_by IS NOT NULL"); + expect(sql).toContain("UPDATE usage_ledger SET blocked_by = NEW.blocked_by"); + }); + it("contains ON CONFLICT UPSERT", () => { expect(sql).toContain("ON CONFLICT (request_id) DO UPDATE"); }); From dc4f60d11c0054592e1b0571186f76d5c94cc568 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Sun, 1 Mar 2026 22:56:28 +0800 Subject: [PATCH 12/16] =?UTF-8?q?fix:=20=E6=94=B6=E6=95=9B=20allowGlobalUs?= =?UTF-8?q?ageView=20=E9=89=B4=E6=9D=83=E4=B8=8E=20review=20=E7=BB=86?= =?UTF-8?q?=E8=8A=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - allowGlobalUsageView 权限判定改为强一致读取(fail-closed)\n- Providers bootstrap/health 读取做 partial success\n- Logs 轮询改为 setTimeout 链式调度,避免 interval 堆积\n- 文档路径/术语对齐;usage_ledger 迁移 SQL 测试对齐 --- .../common-pages-optimization-plan.md | 6 +- src/actions/dashboard-realtime.ts | 420 +++++++++++------- src/actions/my-usage.ts | 63 ++- src/actions/overview.ts | 6 +- src/actions/provider-slots.ts | 34 +- src/actions/providers.ts | 65 ++- src/actions/statistics.ts | 59 ++- .../[locale]/dashboard/leaderboard/page.tsx | 6 +- .../_components/virtualized-logs-table.tsx | 23 +- .../logs/_hooks/use-lazy-filter-options.ts | 1 + src/app/api/leaderboard/route.ts | 12 +- src/lib/utils/timezone.ts | 11 - src/repository/system-config.ts | 37 ++ tests/unit/usage-ledger/trigger.test.ts | 12 +- 14 files changed, 499 insertions(+), 256 deletions(-) diff --git a/docs/performance/common-pages-optimization-plan.md b/docs/performance/common-pages-optimization-plan.md index e15159c20..0c4fa4def 100644 --- a/docs/performance/common-pages-optimization-plan.md +++ b/docs/performance/common-pages-optimization-plan.md @@ -46,7 +46,9 @@ ### 供应商管理(Providers) -- 页面入口:`src/app/[locale]/dashboard/providers/page.tsx`(复用 settings/providers 组件) +- 页面入口(Settings):`src/app/[locale]/settings/providers/page.tsx` +- 快捷入口(Dashboard):`src/app/[locale]/dashboard/providers/page.tsx`(复用同一套 Providers 管理组件) +- 复用组件目录:`src/app/[locale]/settings/providers/_components/` - 客户端多请求: - providers:`src/actions/providers.ts:getProviders`(当前使用 `findAllProvidersFresh()` 绕过 provider cache) - health:`src/actions/providers.ts:getProvidersHealthStatus` @@ -98,7 +100,7 @@ 2) 轮询策略与请求编排(减少无效 QPS) - 使用记录:保持 keyset pagination;自动刷新仅更新最新页(已落地)。 - 活跃会话:在服务端已有缓存的前提下,前端可考虑仅在 tab 可见时轮询,或对并发计数做短 TTL 缓存(不改变展示语义)。 -- 供应商管理:将 providers/health/statistics 的刷新节奏错峰,或合并为单一 endpoints(需要评估 UI 代码改动范围)。 +- 供应商管理:将 providers/health/statistics 的刷新节奏错峰,或合并为单一 endpoint(需要评估 UI 代码改动范围)。 3) 缓存 miss 尖刺治理(降低“缓存雪崩”影响) - Overview/Statistics/Leaderboard 的 Redis 锁机制已存在,可补充: diff --git a/src/actions/dashboard-realtime.ts b/src/actions/dashboard-realtime.ts index a220afdd1..cfba6eeff 100644 --- a/src/actions/dashboard-realtime.ts +++ b/src/actions/dashboard-realtime.ts @@ -3,15 +3,14 @@ import { getSession } from "@/lib/auth"; import { getCachedSystemSettings } from "@/lib/config"; import { logger } from "@/lib/logger"; +import { getLeaderboardWithCache, getRedisClient } from "@/lib/redis"; import { findRecentActivityStream } from "@/repository/activity-stream"; -import { - findDailyLeaderboard, - findDailyModelLeaderboard, - findDailyProviderLeaderboard, - type LeaderboardEntry, - type ModelLeaderboardEntry, - type ProviderLeaderboardEntry, +import type { + LeaderboardEntry, + ModelLeaderboardEntry, + ProviderLeaderboardEntry, } from "@/repository/leaderboard"; +import { getAllowGlobalUsageViewFromDB } from "@/repository/system-config"; // 导入已有的接口和方法 import { getOverviewData, type OverviewData } from "./overview"; import { getProviderSlots, type ProviderSlotInfo } from "./provider-slots"; @@ -96,9 +95,9 @@ export async function getDashboardRealtimeData(): Promise setTimeout(resolve, lockWaitMs)); + const retried = await redis.get(cacheKey); + if (retried) { + return { ok: true, data: JSON.parse(retried) as DashboardRealtimeData }; + } + } + } + } catch (error) { + logger.warn("[DashboardRealtime] Cache lock failed, fallback to compute", { + lockKey, + error, + }); + lockAcquired = false; + } } - // 处理实时活动流数据(已包含 Redis 活跃 + 数据库最新的混合数据) - const now = Date.now(); - const activityStream: ActivityStreamEntry[] = activityStreamItems.map((item) => { - // 计算耗时: - // - 如果有 durationMs(已完成的请求),使用实际值 - // - 如果没有(进行中的请求),计算从开始到现在的耗时 - const latency = item.durationMs ?? now - item.startTime; - - return { - id: item.sessionId ?? `req-${item.id}`, // 使用 sessionId,如果没有则用请求ID - user: item.userName, - model: item.originalModel ?? item.model ?? "Unknown", // 优先使用计费模型 - provider: item.providerName ?? "Unknown", - latency, - status: item.statusCode ?? 200, - cost: parseFloat(item.costUsd ?? "0"), - startTime: item.startTime, - }; - }); - - // 处理供应商插槽数据(合并流量数据 + 过滤未设置限额 + 按占用率排序 + 限制最多3个) - const providerSlotsWithVolume: ProviderSlotInfo[] = providerSlots - .filter((slot) => slot.totalSlots > 0) // 过滤未设置并发限额的供应商 - .map((slot) => { - const rankingData = providerRankings.find((p) => p.providerId === slot.providerId); - - if (!rankingData) { - logger.debug("Provider has slots but no traffic", { - providerId: slot.providerId, - providerName: slot.name, - }); - } + try { + // 并行查询所有数据源(使用 allSettled 以实现部分失败容错) + const [ + overviewResult, + activityStreamResult, + userRankingsResult, + providerRankingsResult, + providerSlotsResult, + modelRankingsResult, + statisticsResult, + ] = await Promise.allSettled([ + getOverviewData(), + findRecentActivityStream(ACTIVITY_STREAM_LIMIT), // 使用新的混合数据源 + getLeaderboardWithCache("daily", settings.currencyDisplay, "user") as Promise< + LeaderboardEntry[] + >, + getLeaderboardWithCache("daily", settings.currencyDisplay, "provider") as Promise< + ProviderLeaderboardEntry[] + >, + getProviderSlots(), + getLeaderboardWithCache("daily", settings.currencyDisplay, "model") as Promise< + ModelLeaderboardEntry[] + >, + getUserStatistics("today"), + ]); + + // 提取数据并处理错误 + const overviewData = + overviewResult.status === "fulfilled" && overviewResult.value.ok + ? overviewResult.value.data + : null; + + if (!overviewData) { + const errorReason = + overviewResult.status === "rejected" ? overviewResult.reason : "Unknown error"; + logger.error("Failed to get overview data", { reason: errorReason }); + return { + ok: false, + error: "获取概览数据失败", + }; + } + + // 提取其他数据,失败时使用空数组作为 fallback + const activityStreamItems = + activityStreamResult.status === "fulfilled" ? activityStreamResult.value : []; + + const userRankings = + userRankingsResult.status === "fulfilled" ? userRankingsResult.value : []; + + const providerRankings = + providerRankingsResult.status === "fulfilled" ? providerRankingsResult.value : []; + + const providerSlots = + providerSlotsResult.status === "fulfilled" && providerSlotsResult.value.ok + ? providerSlotsResult.value.data + : []; + + const modelRankings = + modelRankingsResult.status === "fulfilled" ? modelRankingsResult.value : []; + + const statisticsData = + statisticsResult.status === "fulfilled" && statisticsResult.value.ok + ? statisticsResult.value.data + : null; + + // 记录部分失败的数据源 + if (activityStreamResult.status === "rejected" || !activityStreamItems.length) { + logger.warn("Failed to get activity stream", { + reason: + activityStreamResult.status === "rejected" ? activityStreamResult.reason : "empty data", + }); + } + if (userRankingsResult.status === "rejected") { + logger.warn("Failed to get user rankings", { reason: userRankingsResult.reason }); + } + if (providerRankingsResult.status === "rejected") { + logger.warn("Failed to get provider rankings", { reason: providerRankingsResult.reason }); + } + if (providerSlotsResult.status === "rejected" || !providerSlots.length) { + logger.warn("Failed to get provider slots", { + reason: + providerSlotsResult.status === "rejected" + ? providerSlotsResult.reason + : "empty data or action failed", + }); + } + if (modelRankingsResult.status === "rejected") { + logger.warn("Failed to get model rankings", { reason: modelRankingsResult.reason }); + } + if (statisticsResult.status === "rejected" || !statisticsData) { + logger.warn("Failed to get statistics", { + reason: + statisticsResult.status === "rejected" + ? statisticsResult.reason + : "action failed or empty data", + }); + } + + const providerRankingByProviderId = new Map( + providerRankings.map((entry) => [entry.providerId, entry]) + ); + + // 处理实时活动流数据(已包含 Redis 活跃 + 数据库最新的混合数据) + const now = Date.now(); + const activityStream: ActivityStreamEntry[] = activityStreamItems.map((item) => { + // 计算耗时: + // - 如果有 durationMs(已完成的请求),使用实际值 + // - 如果没有(进行中的请求),计算从开始到现在的耗时 + const latency = item.durationMs ?? now - item.startTime; return { - ...slot, - totalVolume: rankingData?.totalTokens ?? 0, + id: item.sessionId ?? `req-${item.id}`, // 使用 sessionId,如果没有则用请求ID + user: item.userName, + model: item.originalModel ?? item.model ?? "Unknown", // 优先使用计费模型 + provider: item.providerName ?? "Unknown", + latency, + status: item.statusCode ?? 200, + cost: parseFloat(item.costUsd ?? "0"), + startTime: item.startTime, }; - }) - .sort((a, b) => { - // 按占用率降序排序(占用率 = usedSlots / totalSlots) - const usageA = a.totalSlots > 0 ? a.usedSlots / a.totalSlots : 0; - const usageB = b.totalSlots > 0 ? b.usedSlots / b.totalSlots : 0; - return usageB - usageA; - }) - .slice(0, 3); // 只取前3个 - - // 处理趋势数据(24小时)- 从 ChartDataItem 正确提取数据 - const trendData = statisticsData?.chartData - ? statisticsData.chartData.map((item) => { - const hour = new Date(item.date).getUTCHours(); - // 聚合所有 *_calls 字段(如 user-1_calls, user-2_calls) - const value = Object.keys(item) - .filter((key) => key.endsWith("_calls")) - .reduce((sum, key) => sum + (Number(item[key]) || 0), 0); - return { hour, value }; + }); + + // 处理供应商插槽数据(合并流量数据 + 过滤未设置限额 + 按占用率排序 + 限制最多3个) + const providerSlotsWithVolume: ProviderSlotInfo[] = providerSlots + .filter((slot) => slot.totalSlots > 0) // 过滤未设置并发限额的供应商 + .map((slot) => { + const rankingData = providerRankingByProviderId.get(slot.providerId); + + if (!rankingData) { + logger.debug("Provider has slots but no traffic", { + providerId: slot.providerId, + providerName: slot.name, + }); + } + + return { + ...slot, + totalVolume: rankingData?.totalTokens ?? 0, + }; + }) + .sort((a, b) => { + // 按占用率降序排序(占用率 = usedSlots / totalSlots) + const usageA = a.totalSlots > 0 ? a.usedSlots / a.totalSlots : 0; + const usageB = b.totalSlots > 0 ? b.usedSlots / b.totalSlots : 0; + return usageB - usageA; }) - : Array.from({ length: 24 }, (_, i) => ({ hour: i, value: 0 })); - - logger.debug("DashboardRealtime: Retrieved dashboard data", { - userId: session.user.id, - concurrentSessions: overviewData.concurrentSessions, - activityStreamCount: activityStream.length, - userRankingCount: userRankings.length, - providerRankingCount: providerRankings.length, - providerSlotsCount: providerSlotsWithVolume.length, - modelCount: modelRankings.length, - }); - - // 供应商排行按金额降序排序 - const sortedProviderRankings = [...providerRankings] - .sort((a, b) => b.totalCost - a.totalCost) - .slice(0, 5); + .slice(0, 3); // 只取前3个 + + // 处理趋势数据(24小时)- 从 ChartDataItem 正确提取数据 + const trendData = statisticsData?.chartData + ? statisticsData.chartData.map((item) => { + const hour = new Date(item.date).getUTCHours(); + // 聚合所有 *_calls 字段(如 user-1_calls, user-2_calls) + const value = Object.keys(item) + .filter((key) => key.endsWith("_calls")) + .reduce((sum, key) => sum + (Number(item[key]) || 0), 0); + return { hour, value }; + }) + : Array.from({ length: 24 }, (_, i) => ({ hour: i, value: 0 })); + + logger.debug("DashboardRealtime: Retrieved dashboard data", { + userId: session.user.id, + concurrentSessions: overviewData.concurrentSessions, + activityStreamCount: activityStream.length, + userRankingCount: userRankings.length, + providerRankingCount: providerRankings.length, + providerSlotsCount: providerSlotsWithVolume.length, + modelCount: modelRankings.length, + }); - return { - ok: true, - data: { + // 供应商排行按金额降序排序 + const sortedProviderRankings = [...providerRankings] + .sort((a, b) => b.totalCost - a.totalCost) + .slice(0, 5); + + const data: DashboardRealtimeData = { metrics: overviewData, activityStream, userRankings: userRankings.slice(0, 5), @@ -283,8 +343,30 @@ export async function getDashboardRealtimeData(): Promise {}); + } + } catch (error) { + logger.warn("[DashboardRealtime] Cache write failed", { cacheKey, error }); + } + } + + return { + ok: true, + data, + }; + } finally { + if (redis && lockAcquired) { + await redis.del(lockKey).catch(() => {}); + } + } } catch (error) { logger.error("Failed to get dashboard realtime data:", error); return { diff --git a/src/actions/my-usage.ts b/src/actions/my-usage.ts index d173323ec..2bd88173d 100644 --- a/src/actions/my-usage.ts +++ b/src/actions/my-usage.ts @@ -9,6 +9,7 @@ import { getCachedSystemSettings } from "@/lib/config"; import { logger } from "@/lib/logger"; import { resolveKeyConcurrentSessionLimit } from "@/lib/rate-limit/concurrent-session-limit"; import type { DailyResetMode } from "@/lib/rate-limit/time-utils"; +import { getRedisClient } from "@/lib/redis"; import { SessionTracker } from "@/lib/session-tracker"; import type { CurrencyCode } from "@/lib/utils"; import { resolveSystemTimezone } from "@/lib/utils/timezone.server"; @@ -213,6 +214,34 @@ export async function getMyQuota(): Promise> { const session = await getSession({ allowReadOnlyAccess: true }); if (!session) return { ok: false, error: "Unauthorized" }; + const redisClient = getRedisClient(); + const redis = redisClient && redisClient.status === "ready" ? redisClient : null; + const cacheKey = `my-usage:quota:user:${session.user.id}:key:${session.key.id}`; + if (redis) { + try { + const cached = await redis.get(cacheKey); + if (cached) { + // 注意:该 action 会被 Client Component 直接调用,返回值中的 Date 需要保持为 Date。 + // JSON 序列化会把 Date 变成字符串,因此这里需要 revive。 + const parsed = JSON.parse(cached) as Omit & { + userExpiresAt: string | null; + expiresAt: string | null; + }; + + return { + ok: true, + data: { + ...parsed, + userExpiresAt: parsed.userExpiresAt ? new Date(parsed.userExpiresAt) : null, + expiresAt: parsed.expiresAt ? new Date(parsed.expiresAt) : null, + }, + }; + } + } catch (error) { + logger.warn("[my-usage] getMyQuota cache read failed, fallback to compute", { error }); + } + } + const key = session.key; const user = session.user; @@ -334,6 +363,16 @@ export async function getMyQuota(): Promise> { dailyResetTime: key.dailyResetTime ?? "00:00", }; + if (redis) { + // 短 TTL:页面高频刷新时减少 DB/Redis 压力,同时保持“几乎实时”的观感 + const cachePayload = { + ...quota, + userExpiresAt: quota.userExpiresAt ? quota.userExpiresAt.toISOString() : null, + expiresAt: quota.expiresAt ? quota.expiresAt.toISOString() : null, + }; + await redis.setex(cacheKey, 2, JSON.stringify(cachePayload)).catch(() => {}); + } + return { ok: true, data: quota }; } catch (error) { logger.error("[my-usage] getMyQuota failed", error); @@ -346,6 +385,20 @@ export async function getMyTodayStats(): Promise> { const session = await getSession({ allowReadOnlyAccess: true }); if (!session) return { ok: false, error: "Unauthorized" }; + const redisClient = getRedisClient(); + const redis = redisClient && redisClient.status === "ready" ? redisClient : null; + const cacheKey = `my-usage:today-stats:key:${session.key.id}`; + if (redis) { + try { + const cached = await redis.get(cacheKey); + if (cached) { + return { ok: true, data: JSON.parse(cached) as MyTodayStats }; + } + } catch (error) { + logger.warn("[my-usage] getMyTodayStats cache read failed, fallback to compute", { error }); + } + } + const settings = await getCachedSystemSettings(); const billingModelSource = settings.billingModelSource; const currencyCode = settings.currencyDisplay; @@ -413,6 +466,10 @@ export async function getMyTodayStats(): Promise> { billingModelSource, }; + if (redis) { + await redis.setex(cacheKey, 5, JSON.stringify(stats)).catch(() => {}); + } + return { ok: true, data: stats }; } catch (error) { logger.error("[my-usage] getMyTodayStats failed", error); @@ -447,7 +504,8 @@ export async function getMyUsageLogs( const pageSize = Math.min(rawPageSize, 100); const page = filters.page && filters.page > 0 ? filters.page : 1; - const timezone = await resolveSystemTimezone(); + const hasDateFilter = Boolean(filters.startDate?.trim() || filters.endDate?.trim()); + const timezone = hasDateFilter ? await resolveSystemTimezone() : undefined; const { startTime, endTime } = parseDateRangeInServerTimezone( filters.startDate, filters.endDate, @@ -586,7 +644,8 @@ export async function getMyStatsSummary( const settings = await getCachedSystemSettings(); const currencyCode = settings.currencyDisplay; - const timezone = await resolveSystemTimezone(); + const hasDateFilter = Boolean(filters.startDate?.trim() || filters.endDate?.trim()); + const timezone = hasDateFilter ? await resolveSystemTimezone() : undefined; const { startTime, endTime } = parseDateRangeInServerTimezone( filters.startDate, filters.endDate, diff --git a/src/actions/overview.ts b/src/actions/overview.ts index cb0dc7f3a..fa37a6783 100644 --- a/src/actions/overview.ts +++ b/src/actions/overview.ts @@ -1,9 +1,9 @@ "use server"; import { getSession } from "@/lib/auth"; -import { getCachedSystemSettings } from "@/lib/config"; import { logger } from "@/lib/logger"; import { getOverviewWithCache } from "@/lib/redis"; +import { getAllowGlobalUsageViewFromDB } from "@/repository/system-config"; import { getConcurrentSessions as getConcurrentSessionsCount } from "./concurrent-sessions"; import type { ActionResult } from "./types"; @@ -48,9 +48,9 @@ export async function getOverviewData(): Promise> { }; } - const settings = await getCachedSystemSettings(); const isAdmin = session.user.role === "admin"; - const canViewGlobalData = isAdmin || settings.allowGlobalUsageView; + const allowGlobalUsageView = !isAdmin ? await getAllowGlobalUsageViewFromDB() : false; + const canViewGlobalData = isAdmin || allowGlobalUsageView; // 根据权限决定查询范围 const userId = canViewGlobalData ? undefined : session.user.id; diff --git a/src/actions/provider-slots.ts b/src/actions/provider-slots.ts index 800822a0c..8568e11c9 100644 --- a/src/actions/provider-slots.ts +++ b/src/actions/provider-slots.ts @@ -4,9 +4,9 @@ import { and, eq, isNull } from "drizzle-orm"; import { db } from "@/drizzle/db"; import { providers } from "@/drizzle/schema"; import { getSession } from "@/lib/auth"; -import { getCachedSystemSettings } from "@/lib/config"; import { logger } from "@/lib/logger"; import { SessionTracker } from "@/lib/session-tracker"; +import { getAllowGlobalUsageViewFromDB } from "@/repository/system-config"; import type { ActionResult } from "./types"; /** @@ -42,9 +42,9 @@ export async function getProviderSlots(): Promise { - const usedSlots = await SessionTracker.getProviderSessionCount(provider.id); + // 批量获取并发数(避免 Redis N+1) + const providerIds = providerList.map((p) => p.id); + const usedSlotsByProviderId = await SessionTracker.getProviderSessionCountBatch(providerIds); - return { - providerId: provider.id, - name: provider.name, - usedSlots, - totalSlots: provider.limitConcurrentSessions ?? 0, - totalVolume: 0, // This will be populated by the calling action from leaderboard data. - }; - } - ) + const slotInfoList = providerList.map( + (provider: { id: number; name: string; limitConcurrentSessions: number | null }) => { + const usedSlots = usedSlotsByProviderId.get(provider.id) ?? 0; + return { + providerId: provider.id, + name: provider.name, + usedSlots, + totalSlots: provider.limitConcurrentSessions ?? 0, + totalVolume: 0, // This will be populated by the calling action from leaderboard data. + }; + } ); logger.debug("ProviderSlots: Retrieved provider slots", { diff --git a/src/actions/providers.ts b/src/actions/providers.ts index 4e873f295..cd3d34c63 100644 --- a/src/actions/providers.ts +++ b/src/actions/providers.ts @@ -371,32 +371,67 @@ export async function getProviderManagerBootstrapData(): Promise provider.id); - const healthStatusRaw = await getAllHealthStatusAsync(providerIds, { forceRefresh: true }); + const healthStatusRaw = await getAllHealthStatusAsync(providerIds, { forceRefresh: true }).catch( + (error) => { + logger.warn("[ProvidersBootstrap] Failed to load health status, fallback to empty", { + error: error instanceof Error ? error.message : String(error), + }); + return {}; + } + ); const now = Date.now(); - const healthStatus: ProviderHealthStatusMap = {}; - Object.entries(healthStatusRaw).forEach(([providerId, health]) => { - healthStatus[Number(providerId)] = { - circuitState: health.circuitState, - failureCount: health.failureCount, - lastFailureTime: health.lastFailureTime, - circuitOpenUntil: health.circuitOpenUntil, - recoveryMinutes: health.circuitOpenUntil - ? Math.ceil((health.circuitOpenUntil - now) / 60000) - : null, - }; - }); + const healthStatus: ProviderHealthStatusMap = Object.fromEntries( + Object.entries(healthStatusRaw).map(([providerIdRaw, health]) => { + const providerId = Number(providerIdRaw); + const circuitOpenUntil = health.circuitOpenUntil ?? null; + return [ + providerId, + { + circuitState: health.circuitState, + failureCount: health.failureCount, + lastFailureTime: health.lastFailureTime, + circuitOpenUntil, + recoveryMinutes: circuitOpenUntil ? Math.ceil((circuitOpenUntil - now) / 60000) : null, + }, + ]; + }) + ); return { providers, healthStatus, - systemSettings: { currencyDisplay: systemSettings.currencyDisplay }, + systemSettings: { currencyDisplay }, }; } diff --git a/src/actions/statistics.ts b/src/actions/statistics.ts index da8026059..dc0e60edf 100644 --- a/src/actions/statistics.ts +++ b/src/actions/statistics.ts @@ -1,11 +1,10 @@ "use server"; import { getSession } from "@/lib/auth"; -import { getCachedSystemSettings } from "@/lib/config"; import { logger } from "@/lib/logger"; import { getStatisticsWithCache } from "@/lib/redis"; import { formatCostForStorage } from "@/lib/utils/currency"; -import { getActiveKeysForUserFromDB, getActiveUsersFromDB } from "@/repository/statistics"; +import { getAllowGlobalUsageViewFromDB } from "@/repository/system-config"; import type { ChartDataItem, DatabaseKey, @@ -24,6 +23,28 @@ import type { ActionResult } from "./types"; */ const createDataKey = (prefix: string, id: number): string => `${prefix}-${id}`; +function extractUsersFromStatsData(statsData: DatabaseStatRow[]): DatabaseUser[] { + const unique = new Map(); + for (const row of statsData) { + unique.set(row.user_id, row.user_name); + } + + return Array.from(unique.entries()) + .map(([id, name]) => ({ id, name })) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +function extractKeysFromStatsData(statsData: DatabaseKeyStatRow[]): DatabaseKey[] { + const unique = new Map(); + for (const row of statsData) { + unique.set(row.key_id, row.key_name); + } + + return Array.from(unique.entries()) + .map(([id, name]) => ({ id, name })) + .sort((a, b) => a.name.localeCompare(b.name)); +} + /** * 获取用户统计数据,用于图表展示 */ @@ -45,13 +66,13 @@ export async function getUserStatistics( throw new Error(`Invalid time range: ${timeRange}`); } - const settings = await getCachedSystemSettings(); const isAdmin = session.user.role === "admin"; + const allowGlobalUsageView = isAdmin ? true : await getAllowGlobalUsageViewFromDB(); // 确定显示模式 const mode: "users" | "keys" | "mixed" = isAdmin ? "users" - : settings.allowGlobalUsageView + : allowGlobalUsageView ? "mixed" : "keys"; @@ -62,19 +83,12 @@ export async function getUserStatistics( if (mode === "users") { // Admin: 显示所有用户 - const [cachedData, userList] = await Promise.all([ - getStatisticsWithCache(timeRange, "users"), - getActiveUsersFromDB(), - ]); - statsData = cachedData as DatabaseStatRow[]; - entities = userList; + const cachedData = (await getStatisticsWithCache(timeRange, "users")) as DatabaseStatRow[]; + statsData = cachedData; + entities = extractUsersFromStatsData(cachedData); } else if (mode === "mixed") { // 非 Admin + allowGlobalUsageView: 自己的密钥明细 + 其他用户汇总 - const [ownKeysList, cachedData] = await Promise.all([ - getActiveKeysForUserFromDB(session.user.id), - getStatisticsWithCache(timeRange, "mixed", session.user.id), - ]); - + const cachedData = await getStatisticsWithCache(timeRange, "mixed", session.user.id); const mixedData = cachedData as { ownKeys: DatabaseKeyStatRow[]; othersAggregate: DatabaseStatRow[]; @@ -84,15 +98,16 @@ export async function getUserStatistics( statsData = [...mixedData.ownKeys, ...mixedData.othersAggregate]; // 合并实体列表:自己的密钥 + 其他用户虚拟实体 - entities = [...ownKeysList, { id: -1, name: "__others__" }]; + entities = [...extractKeysFromStatsData(mixedData.ownKeys), { id: -1, name: "__others__" }]; } else { // 非 Admin + !allowGlobalUsageView: 仅显示自己的密钥 - const [cachedData, keyList] = await Promise.all([ - getStatisticsWithCache(timeRange, "keys", session.user.id), - getActiveKeysForUserFromDB(session.user.id), - ]); - statsData = cachedData as DatabaseKeyStatRow[]; - entities = keyList; + const cachedData = (await getStatisticsWithCache( + timeRange, + "keys", + session.user.id + )) as DatabaseKeyStatRow[]; + statsData = cachedData; + entities = extractKeysFromStatsData(cachedData); } // 将数据转换为适合图表的格式 diff --git a/src/app/[locale]/dashboard/leaderboard/page.tsx b/src/app/[locale]/dashboard/leaderboard/page.tsx index 438144e7d..2d3a0161a 100644 --- a/src/app/[locale]/dashboard/leaderboard/page.tsx +++ b/src/app/[locale]/dashboard/leaderboard/page.tsx @@ -5,7 +5,7 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Link } from "@/i18n/routing"; import { getSession } from "@/lib/auth"; -import { getCachedSystemSettings } from "@/lib/config"; +import { getAllowGlobalUsageViewFromDB } from "@/repository/system-config"; import { LeaderboardView } from "./_components/leaderboard-view"; export const dynamic = "force-dynamic"; @@ -14,11 +14,11 @@ export default async function LeaderboardPage() { const t = await getTranslations("dashboard"); // 获取用户 session 和系统设置 const session = await getSession(); - const systemSettings = await getCachedSystemSettings(); // 检查权限 const isAdmin = session?.user.role === "admin"; - const hasPermission = isAdmin || systemSettings.allowGlobalUsageView; + const allowGlobalUsageView = session && !isAdmin ? await getAllowGlobalUsageViewFromDB() : false; + const hasPermission = Boolean(isAdmin) || allowGlobalUsageView; // 无权限时显示友好提示 if (!hasPermission) { diff --git a/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx b/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx index a0cee8de9..158d64cf5 100644 --- a/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx +++ b/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx @@ -169,9 +169,22 @@ export function VirtualizedLogsTable({ if (!shouldPoll) return; let cancelled = false; + let timeoutId: ReturnType | null = null; + + const scheduleNext = () => { + if (cancelled) return; + if (timeoutId) { + clearTimeout(timeoutId); + } + timeoutId = setTimeout(() => void tick(), autoRefreshIntervalMs); + }; const tick = async () => { - if (refreshInFlightRef.current) return; + if (cancelled) return; + if (refreshInFlightRef.current) { + scheduleNext(); + return; + } refreshInFlightRef.current = true; try { @@ -204,6 +217,8 @@ export function VirtualizedLogsTable({ return old; } + // hasMore 表示“是否还能继续向更旧方向翻页”,因此应以旧的最后一页为准; + // 没有旧数据时使用最新页的 hasMore(对应“是否存在更旧数据”)。 const lastHasMore = oldPages.length > 0 ? (oldPages[oldPages.length - 1]?.hasMore ?? true) @@ -233,15 +248,17 @@ export function VirtualizedLogsTable({ // Ignore polling errors (manual refresh still available). } finally { refreshInFlightRef.current = false; + scheduleNext(); } }; void tick(); - const intervalId = setInterval(() => void tick(), autoRefreshIntervalMs); return () => { cancelled = true; - clearInterval(intervalId); + if (timeoutId) { + clearTimeout(timeoutId); + } }; }, [autoRefreshIntervalMs, filters, queryClient, queryKey, shouldPoll]); diff --git a/src/app/[locale]/dashboard/logs/_hooks/use-lazy-filter-options.ts b/src/app/[locale]/dashboard/logs/_hooks/use-lazy-filter-options.ts index 64dfbc811..3faea9665 100644 --- a/src/app/[locale]/dashboard/logs/_hooks/use-lazy-filter-options.ts +++ b/src/app/[locale]/dashboard/logs/_hooks/use-lazy-filter-options.ts @@ -47,6 +47,7 @@ function createLazyFilterHook( }; }, []); + // fetcher 来自 createLazyFilterHook(...) 的闭包参数,在 hook 生命周期内保持稳定,因此不需要放入 deps。 const load = useCallback(async () => { // 如果已加载或有进行中的请求,跳过 if (isLoaded || inFlightRef.current) return; diff --git a/src/app/api/leaderboard/route.ts b/src/app/api/leaderboard/route.ts index f9f50c64d..59ec3001a 100644 --- a/src/app/api/leaderboard/route.ts +++ b/src/app/api/leaderboard/route.ts @@ -9,6 +9,7 @@ import type { LeaderboardScope, } from "@/lib/redis/leaderboard-cache"; import { formatCurrency } from "@/lib/utils"; +import { getAllowGlobalUsageViewFromDB } from "@/repository/system-config"; import type { ProviderType } from "@/types/provider"; const VALID_PERIODS: LeaderboardPeriod[] = ["daily", "weekly", "monthly", "allTime", "custom"]; @@ -48,19 +49,17 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: "未登录" }, { status: 401 }); } - // 获取系统配置 - const systemSettings = await getCachedSystemSettings(); - // 检查权限:管理员或开启了全站使用量查看权限 const isAdmin = session.user.role === "admin"; - const hasPermission = isAdmin || systemSettings.allowGlobalUsageView; + const allowGlobalUsageView = !isAdmin ? await getAllowGlobalUsageViewFromDB() : false; + const hasPermission = isAdmin || allowGlobalUsageView; if (!hasPermission) { logger.warn("Leaderboard API: Access denied", { userId: session.user.id, userName: session.user.name, isAdmin, - allowGlobalUsageView: systemSettings.allowGlobalUsageView, + allowGlobalUsageView, }); return NextResponse.json( { error: "无权限访问排行榜,请联系管理员开启全站使用量查看权限" }, @@ -68,6 +67,9 @@ export async function GET(request: NextRequest) { ); } + // 获取系统配置(仅权限通过后再读取) + const systemSettings = await getCachedSystemSettings(); + // 验证参数 const searchParams = request.nextUrl.searchParams; const period = (searchParams.get("period") || "daily") as LeaderboardPeriod; diff --git a/src/lib/utils/timezone.ts b/src/lib/utils/timezone.ts index ac7ac5c72..1b53730e7 100644 --- a/src/lib/utils/timezone.ts +++ b/src/lib/utils/timezone.ts @@ -128,14 +128,3 @@ export function getTimezoneOffsetMinutes(timezone: string): number { return (tzDate.getTime() - utcDate.getTime()) / (1000 * 60); } - -/** - * Resolves the system timezone using the fallback chain: - * 1. DB system_settings.timezone (via cached settings) - * 2. env TZ variable - * 3. "UTC" as final fallback - * - * Each candidate is validated via isValidIANATimezone before being accepted. - * - * @returns Resolved IANA timezone identifier (always valid) - */ diff --git a/src/repository/system-config.ts b/src/repository/system-config.ts index 67e063492..06595421d 100644 --- a/src/repository/system-config.ts +++ b/src/repository/system-config.ts @@ -270,6 +270,43 @@ export async function getSystemSettings(): Promise { } } +let lastAllowGlobalUsageViewReadErrorAt = 0; +const ALLOW_GLOBAL_USAGE_VIEW_READ_ERROR_LOG_INTERVAL_MS = 60_000; + +/** + * 强一致读取 allowGlobalUsageView(权限相关字段不应依赖可过期缓存) + * + * - 失败时 fail-closed 返回 false(避免短时越权) + * - 不会创建默认记录(与 getSystemSettings() 的“自愈创建”行为区分) + */ +export async function getAllowGlobalUsageViewFromDB(): Promise { + try { + const [row] = await db + .select({ allowGlobalUsageView: systemSettings.allowGlobalUsageView }) + .from(systemSettings) + .limit(1); + + return row?.allowGlobalUsageView ?? false; + } catch (error) { + if (isTableMissingError(error) || isUndefinedColumnError(error)) { + return false; + } + + const now = Date.now(); + if ( + now - lastAllowGlobalUsageViewReadErrorAt >= + ALLOW_GLOBAL_USAGE_VIEW_READ_ERROR_LOG_INTERVAL_MS + ) { + lastAllowGlobalUsageViewReadErrorAt = now; + logger.warn("[SystemSettings] Failed to read allowGlobalUsageView, fallback to false", { + error: error instanceof Error ? error.message : String(error), + }); + } + + return false; + } +} + /** * 更新系统设置 */ diff --git a/tests/unit/usage-ledger/trigger.test.ts b/tests/unit/usage-ledger/trigger.test.ts index b50a5d8f3..9c302f254 100644 --- a/tests/unit/usage-ledger/trigger.test.ts +++ b/tests/unit/usage-ledger/trigger.test.ts @@ -2,9 +2,13 @@ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; -const sql = readFileSync(resolve(process.cwd(), "src/lib/ledger-backfill/trigger.sql"), "utf-8"); +// 以实际部署的迁移 SQL 为准(trigger.sql 主要用于回填/参考,不一定与最终迁移完全一致)。 +const sql = readFileSync( + resolve(process.cwd(), "drizzle/0079_perf_usage_ledger_trigger_skip_blocked_rows.sql"), + "utf-8" +); -describe("fn_upsert_usage_ledger trigger SQL", () => { +describe("fn_upsert_usage_ledger migration SQL", () => { it("contains warmup exclusion check", () => { expect(sql).toContain("blocked_by = 'warmup'"); }); @@ -40,7 +44,7 @@ describe("fn_upsert_usage_ledger trigger SQL", () => { expect(sql).toContain("EXISTS (SELECT 1 FROM usage_ledger WHERE request_id = NEW.id)"); }); - it("creates trigger binding", () => { - expect(sql).toContain("CREATE TRIGGER trg_upsert_usage_ledger"); + it("creates function definition", () => { + expect(sql).toContain("CREATE OR REPLACE FUNCTION fn_upsert_usage_ledger"); }); }); From 0aa582e4732c2c29945cdcea12ee6c0cfecfb25c Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Mon, 2 Mar 2026 11:17:45 +0800 Subject: [PATCH 13/16] =?UTF-8?q?perf:=20=E4=BF=AE=E5=A4=8D=E5=B8=B8?= =?UTF-8?q?=E7=94=A8=E9=A1=B5=E4=BC=98=E5=8C=96=E7=9A=84=E8=BE=B9=E7=95=8C?= =?UTF-8?q?=E4=B8=8E=E5=B9=B6=E5=8F=91=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Providers healthStatus:recoveryMinutes 负值钳制为 0 - System settings cache:订阅失败增加退避,避免 cache miss 反复重试 - Dashboard realtime:Redis 锁改为 token + compare-and-del 安全释放 - usage_ledger trigger:provider_chain.id 加 int32 越界保护,并补回填 SQL 与测试断言 --- ...ledger_trigger_skip_irrelevant_updates.sql | 14 ++++++- ...usage_ledger_trigger_skip_blocked_rows.sql | 14 ++++++- src/actions/dashboard-realtime.ts | 10 +++-- src/actions/providers.ts | 4 +- src/lib/config/system-settings-cache.ts | 39 ++++++++++++++++++- src/lib/ledger-backfill/trigger.sql | 14 ++++++- tests/unit/usage-ledger/trigger.test.ts | 5 +++ 7 files changed, 88 insertions(+), 12 deletions(-) diff --git a/drizzle/0078_perf_usage_ledger_trigger_skip_irrelevant_updates.sql b/drizzle/0078_perf_usage_ledger_trigger_skip_irrelevant_updates.sql index c4103c102..4bc2ec4f1 100644 --- a/drizzle/0078_perf_usage_ledger_trigger_skip_irrelevant_updates.sql +++ b/drizzle/0078_perf_usage_ledger_trigger_skip_irrelevant_updates.sql @@ -19,7 +19,12 @@ BEGIN AND jsonb_array_length(NEW.provider_chain) > 0 AND jsonb_typeof(NEW.provider_chain -> -1) = 'object' AND (NEW.provider_chain -> -1 ? 'id') - AND (NEW.provider_chain -> -1 ->> 'id') ~ '^[0-9]+$' THEN + AND (NEW.provider_chain -> -1 ->> 'id') ~ '^[0-9]+$' + AND length(NEW.provider_chain -> -1 ->> 'id') <= 10 + AND ( + length(NEW.provider_chain -> -1 ->> 'id') < 10 + OR (NEW.provider_chain -> -1 ->> 'id') <= '2147483647' + ) THEN v_final_provider_id := (NEW.provider_chain -> -1 ->> 'id')::integer; ELSE v_final_provider_id := NEW.provider_id; @@ -35,7 +40,12 @@ BEGIN AND jsonb_array_length(OLD.provider_chain) > 0 AND jsonb_typeof(OLD.provider_chain -> -1) = 'object' AND (OLD.provider_chain -> -1 ? 'id') - AND (OLD.provider_chain -> -1 ->> 'id') ~ '^[0-9]+$' THEN + AND (OLD.provider_chain -> -1 ->> 'id') ~ '^[0-9]+$' + AND length(OLD.provider_chain -> -1 ->> 'id') <= 10 + AND ( + length(OLD.provider_chain -> -1 ->> 'id') < 10 + OR (OLD.provider_chain -> -1 ->> 'id') <= '2147483647' + ) THEN v_old_final_provider_id := (OLD.provider_chain -> -1 ->> 'id')::integer; ELSE v_old_final_provider_id := OLD.provider_id; diff --git a/drizzle/0079_perf_usage_ledger_trigger_skip_blocked_rows.sql b/drizzle/0079_perf_usage_ledger_trigger_skip_blocked_rows.sql index 1ffb05251..ecfe45e69 100644 --- a/drizzle/0079_perf_usage_ledger_trigger_skip_blocked_rows.sql +++ b/drizzle/0079_perf_usage_ledger_trigger_skip_blocked_rows.sql @@ -26,7 +26,12 @@ BEGIN AND jsonb_array_length(NEW.provider_chain) > 0 AND jsonb_typeof(NEW.provider_chain -> -1) = 'object' AND (NEW.provider_chain -> -1 ? 'id') - AND (NEW.provider_chain -> -1 ->> 'id') ~ '^[0-9]+$' THEN + AND (NEW.provider_chain -> -1 ->> 'id') ~ '^[0-9]+$' + AND length(NEW.provider_chain -> -1 ->> 'id') <= 10 + AND ( + length(NEW.provider_chain -> -1 ->> 'id') < 10 + OR (NEW.provider_chain -> -1 ->> 'id') <= '2147483647' + ) THEN v_final_provider_id := (NEW.provider_chain -> -1 ->> 'id')::integer; ELSE v_final_provider_id := NEW.provider_id; @@ -42,7 +47,12 @@ BEGIN AND jsonb_array_length(OLD.provider_chain) > 0 AND jsonb_typeof(OLD.provider_chain -> -1) = 'object' AND (OLD.provider_chain -> -1 ? 'id') - AND (OLD.provider_chain -> -1 ->> 'id') ~ '^[0-9]+$' THEN + AND (OLD.provider_chain -> -1 ->> 'id') ~ '^[0-9]+$' + AND length(OLD.provider_chain -> -1 ->> 'id') <= 10 + AND ( + length(OLD.provider_chain -> -1 ->> 'id') < 10 + OR (OLD.provider_chain -> -1 ->> 'id') <= '2147483647' + ) THEN v_old_final_provider_id := (OLD.provider_chain -> -1 ->> 'id')::integer; ELSE v_old_final_provider_id := OLD.provider_id; diff --git a/src/actions/dashboard-realtime.ts b/src/actions/dashboard-realtime.ts index cfba6eeff..d376e365a 100644 --- a/src/actions/dashboard-realtime.ts +++ b/src/actions/dashboard-realtime.ts @@ -71,6 +71,8 @@ export interface DashboardRealtimeData { // Constants for data limits const ACTIVITY_STREAM_LIMIT = 20; const MODEL_DISTRIBUTION_LIMIT = 10; +const REDIS_UNLOCK_LUA = + "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) end return 0"; /** * 获取数据大屏的所有实时数据 @@ -125,6 +127,7 @@ export async function getDashboardRealtimeData(): Promise {}); + if (redis && lockAcquired && lockValue) { + await redis.eval(REDIS_UNLOCK_LUA, 1, lockKey, lockValue).catch(() => {}); } } } catch (error) { diff --git a/src/actions/providers.ts b/src/actions/providers.ts index cd3d34c63..e5e26561d 100644 --- a/src/actions/providers.ts +++ b/src/actions/providers.ts @@ -422,7 +422,9 @@ export async function getProviderManagerBootstrapData(): Promise | null = null; +let subscriptionRetryFailures = 0; +let subscriptionNextRetryAt = 0; async function ensureSubscription(): Promise { if (subscriptionInitialized) return; if (subscriptionInitPromise) return subscriptionInitPromise; + const now = Date.now(); + if (subscriptionNextRetryAt > now) return; + subscriptionInitPromise = (async () => { // CI/build 阶段跳过,避免触发 Redis 连接 if (process.env.CI === "true" || process.env.NEXT_PHASE === "phase-production-build") { subscriptionInitialized = true; + subscriptionRetryFailures = 0; + subscriptionNextRetryAt = 0; return; } @@ -93,6 +108,8 @@ async function ensureSubscription(): Promise { // Redis 不可用或未启用(当前 pubsub 实现依赖 ENABLE_RATE_LIMIT=true) if (!redisUrl || !isRateLimitEnabled) { subscriptionInitialized = true; + subscriptionRetryFailures = 0; + subscriptionNextRetryAt = 0; return; } @@ -102,11 +119,29 @@ async function ensureSubscription(): Promise { logger.debug("[SystemSettingsCache] Cache invalidated via pub/sub"); }); - if (!cleanup) return; + if (!cleanup) { + subscriptionRetryFailures++; + subscriptionNextRetryAt = + Date.now() + computeSubscriptionRetryBackoffMs(subscriptionRetryFailures); + logger.debug("[SystemSettingsCache] Pub/sub subscribe not ready, will retry later", { + consecutiveFailures: subscriptionRetryFailures, + nextRetryAt: new Date(subscriptionNextRetryAt).toISOString(), + }); + return; + } subscriptionInitialized = true; + subscriptionRetryFailures = 0; + subscriptionNextRetryAt = 0; } catch (error) { - logger.warn("[SystemSettingsCache] Failed to subscribe settings invalidation", { error }); + subscriptionRetryFailures++; + subscriptionNextRetryAt = + Date.now() + computeSubscriptionRetryBackoffMs(subscriptionRetryFailures); + logger.warn("[SystemSettingsCache] Failed to subscribe settings invalidation", { + error, + consecutiveFailures: subscriptionRetryFailures, + nextRetryAt: new Date(subscriptionNextRetryAt).toISOString(), + }); } })().finally(() => { subscriptionInitPromise = null; diff --git a/src/lib/ledger-backfill/trigger.sql b/src/lib/ledger-backfill/trigger.sql index 9925357e1..1bba9a449 100644 --- a/src/lib/ledger-backfill/trigger.sql +++ b/src/lib/ledger-backfill/trigger.sql @@ -24,7 +24,12 @@ BEGIN AND jsonb_array_length(NEW.provider_chain) > 0 AND jsonb_typeof(NEW.provider_chain -> -1) = 'object' AND (NEW.provider_chain -> -1 ? 'id') - AND (NEW.provider_chain -> -1 ->> 'id') ~ '^[0-9]+$' THEN + AND (NEW.provider_chain -> -1 ->> 'id') ~ '^[0-9]+$' + AND length(NEW.provider_chain -> -1 ->> 'id') <= 10 + AND ( + length(NEW.provider_chain -> -1 ->> 'id') < 10 + OR (NEW.provider_chain -> -1 ->> 'id') <= '2147483647' + ) THEN v_final_provider_id := (NEW.provider_chain -> -1 ->> 'id')::integer; ELSE v_final_provider_id := NEW.provider_id; @@ -42,7 +47,12 @@ BEGIN AND jsonb_array_length(OLD.provider_chain) > 0 AND jsonb_typeof(OLD.provider_chain -> -1) = 'object' AND (OLD.provider_chain -> -1 ? 'id') - AND (OLD.provider_chain -> -1 ->> 'id') ~ '^[0-9]+$' THEN + AND (OLD.provider_chain -> -1 ->> 'id') ~ '^[0-9]+$' + AND length(OLD.provider_chain -> -1 ->> 'id') <= 10 + AND ( + length(OLD.provider_chain -> -1 ->> 'id') < 10 + OR (OLD.provider_chain -> -1 ->> 'id') <= '2147483647' + ) THEN v_old_final_provider_id := (OLD.provider_chain -> -1 ->> 'id')::integer; ELSE v_old_final_provider_id := OLD.provider_id; diff --git a/tests/unit/usage-ledger/trigger.test.ts b/tests/unit/usage-ledger/trigger.test.ts index 9c302f254..43ebb153d 100644 --- a/tests/unit/usage-ledger/trigger.test.ts +++ b/tests/unit/usage-ledger/trigger.test.ts @@ -30,6 +30,11 @@ describe("fn_upsert_usage_ledger migration SQL", () => { expect(sql).toContain("jsonb_typeof"); }); + it("guards provider_chain id extraction cast range", () => { + expect(sql).toContain("2147483647"); + expect(sql).toContain("length(NEW.provider_chain -> -1 ->> 'id') <= 10"); + }); + it("computes is_success from error_message", () => { expect(sql).toContain("error_message IS NULL"); }); From 0dfa3e8952ca11f234fa3979dbaacbb59f63439e Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Mon, 2 Mar 2026 11:31:30 +0800 Subject: [PATCH 14/16] =?UTF-8?q?perf:=20=E5=87=8F=E5=B0=91=20blocked=5Fby?= =?UTF-8?q?=20=E9=87=8D=E5=A4=8D=E5=86=99=E5=85=A5=E5=B9=B6=E8=A1=A5?= =?UTF-8?q?=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - usage_ledger trigger:blocked_by/warmup 分支 UPDATE 增加 IS DISTINCT FROM,避免重复写入 - trigger SQL 测试:补充 OLD.provider_chain 的长度保护断言 --- ...78_perf_usage_ledger_trigger_skip_irrelevant_updates.sql | 3 ++- .../0079_perf_usage_ledger_trigger_skip_blocked_rows.sql | 6 ++++-- src/lib/ledger-backfill/trigger.sql | 6 ++++-- tests/unit/usage-ledger/trigger.test.ts | 1 + 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/drizzle/0078_perf_usage_ledger_trigger_skip_irrelevant_updates.sql b/drizzle/0078_perf_usage_ledger_trigger_skip_irrelevant_updates.sql index 4bc2ec4f1..22b5cb61b 100644 --- a/drizzle/0078_perf_usage_ledger_trigger_skip_irrelevant_updates.sql +++ b/drizzle/0078_perf_usage_ledger_trigger_skip_irrelevant_updates.sql @@ -10,7 +10,8 @@ DECLARE BEGIN IF NEW.blocked_by = 'warmup' THEN -- If a ledger row already exists (row was originally non-warmup), mark it as warmup - UPDATE usage_ledger SET blocked_by = 'warmup' WHERE request_id = NEW.id; + UPDATE usage_ledger SET blocked_by = 'warmup' + WHERE request_id = NEW.id AND blocked_by IS DISTINCT FROM 'warmup'; RETURN NEW; END IF; diff --git a/drizzle/0079_perf_usage_ledger_trigger_skip_blocked_rows.sql b/drizzle/0079_perf_usage_ledger_trigger_skip_blocked_rows.sql index ecfe45e69..7ef006ba1 100644 --- a/drizzle/0079_perf_usage_ledger_trigger_skip_blocked_rows.sql +++ b/drizzle/0079_perf_usage_ledger_trigger_skip_blocked_rows.sql @@ -10,14 +10,16 @@ DECLARE BEGIN IF NEW.blocked_by = 'warmup' THEN -- If a ledger row already exists (row was originally non-warmup), mark it as warmup - UPDATE usage_ledger SET blocked_by = 'warmup' WHERE request_id = NEW.id; + UPDATE usage_ledger SET blocked_by = 'warmup' + WHERE request_id = NEW.id AND blocked_by IS DISTINCT FROM 'warmup'; RETURN NEW; END IF; IF NEW.blocked_by IS NOT NULL THEN -- Blocked requests are excluded from billing stats; avoid creating usage_ledger rows. -- If a ledger row already exists (row was originally unblocked), mark it as blocked. - UPDATE usage_ledger SET blocked_by = NEW.blocked_by WHERE request_id = NEW.id; + UPDATE usage_ledger SET blocked_by = NEW.blocked_by + WHERE request_id = NEW.id AND blocked_by IS DISTINCT FROM NEW.blocked_by; RETURN NEW; END IF; diff --git a/src/lib/ledger-backfill/trigger.sql b/src/lib/ledger-backfill/trigger.sql index 1bba9a449..0724ed46d 100644 --- a/src/lib/ledger-backfill/trigger.sql +++ b/src/lib/ledger-backfill/trigger.sql @@ -8,14 +8,16 @@ DECLARE BEGIN IF NEW.blocked_by = 'warmup' THEN -- If a ledger row already exists (row was originally non-warmup), mark it as warmup - UPDATE usage_ledger SET blocked_by = 'warmup' WHERE request_id = NEW.id; + UPDATE usage_ledger SET blocked_by = 'warmup' + WHERE request_id = NEW.id AND blocked_by IS DISTINCT FROM 'warmup'; RETURN NEW; END IF; IF NEW.blocked_by IS NOT NULL THEN -- Blocked requests are excluded from billing stats; avoid creating usage_ledger rows. -- If a ledger row already exists (row was originally unblocked), mark it as blocked. - UPDATE usage_ledger SET blocked_by = NEW.blocked_by WHERE request_id = NEW.id; + UPDATE usage_ledger SET blocked_by = NEW.blocked_by + WHERE request_id = NEW.id AND blocked_by IS DISTINCT FROM NEW.blocked_by; RETURN NEW; END IF; diff --git a/tests/unit/usage-ledger/trigger.test.ts b/tests/unit/usage-ledger/trigger.test.ts index 43ebb153d..3fd20891c 100644 --- a/tests/unit/usage-ledger/trigger.test.ts +++ b/tests/unit/usage-ledger/trigger.test.ts @@ -33,6 +33,7 @@ describe("fn_upsert_usage_ledger migration SQL", () => { it("guards provider_chain id extraction cast range", () => { expect(sql).toContain("2147483647"); expect(sql).toContain("length(NEW.provider_chain -> -1 ->> 'id') <= 10"); + expect(sql).toContain("length(OLD.provider_chain -> -1 ->> 'id') <= 10"); }); it("computes is_success from error_message", () => { From bab5f55f233268f103a5773ba969fa9afdc9024a Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Mon, 2 Mar 2026 11:50:09 +0800 Subject: [PATCH 15/16] =?UTF-8?q?perf:=20=E6=94=B6=E6=95=9B=E5=A4=A7?= =?UTF-8?q?=E5=B1=8F=E5=91=8A=E8=AD=A6=E6=97=A5=E5=BF=97=E5=B9=B6=E5=8A=A0?= =?UTF-8?q?=E5=9B=BA=20trigger=20=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DashboardRealtime:空数据降级为 debug;model/provider 缺省值改为 '-'(避免硬编码英文文案) - usage_ledger trigger 测试:校验使用最新迁移文件,并锁定 blocked_by 去重写条件 --- src/actions/dashboard-realtime.ts | 44 ++++++++++++++----------- tests/unit/usage-ledger/trigger.test.ts | 19 ++++++++--- 2 files changed, 39 insertions(+), 24 deletions(-) diff --git a/src/actions/dashboard-realtime.ts b/src/actions/dashboard-realtime.ts index d376e365a..f78211764 100644 --- a/src/actions/dashboard-realtime.ts +++ b/src/actions/dashboard-realtime.ts @@ -224,17 +224,21 @@ export async function getDashboardRealtimeData(): Promise { + it("uses latest relevant migration file", () => { + const latest = readdirSync(resolve(process.cwd(), "drizzle")) + .filter((name) => /^\d+_perf_usage_ledger_trigger_.*\.sql$/.test(name)) + .sort() + .slice(-1)[0]; + + expect(latest).toBe(MIGRATION_FILENAME); + }); + it("contains warmup exclusion check", () => { expect(sql).toContain("blocked_by = 'warmup'"); }); @@ -16,6 +23,8 @@ describe("fn_upsert_usage_ledger migration SQL", () => { it("skips blocked requests to avoid wasted ledger writes", () => { expect(sql).toContain("NEW.blocked_by IS NOT NULL"); expect(sql).toContain("UPDATE usage_ledger SET blocked_by = NEW.blocked_by"); + expect(sql).toContain("blocked_by IS DISTINCT FROM 'warmup'"); + expect(sql).toContain("blocked_by IS DISTINCT FROM NEW.blocked_by"); }); it("contains ON CONFLICT UPSERT", () => { From 160930ee272bc7556238e39b61c955f0274faa41 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Mon, 2 Mar 2026 12:09:39 +0800 Subject: [PATCH 16/16] =?UTF-8?q?perf:=20DashboardRealtime=20=E6=8F=90?= =?UTF-8?q?=E5=8D=87=E9=94=99=E8=AF=AF=E5=8F=AF=E8=A7=82=E6=B5=8B=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 记录 overview action 的业务错误信息(避免 Unknown error 掩盖原因) - costUsd 解析增加 finite guard,避免 NaN - 合并缓存写回分支并在失败日志附带 lockAcquired --- src/actions/dashboard-realtime.ts | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/src/actions/dashboard-realtime.ts b/src/actions/dashboard-realtime.ts index f78211764..144c705ac 100644 --- a/src/actions/dashboard-realtime.ts +++ b/src/actions/dashboard-realtime.ts @@ -198,7 +198,11 @@ export async function getDashboardRealtimeData(): Promise {}); - } + // 未拿到锁时仍允许 best-effort 写入兜底(防止锁持有者计算失败)。 + await redis.setex(cacheKey, cacheTtlSeconds, JSON.stringify(data)); } catch (error) { - logger.warn("[DashboardRealtime] Cache write failed", { cacheKey, error }); + logger.warn("[DashboardRealtime] Cache write failed", { + cacheKey, + cacheTtlSeconds, + lockAcquired, + error, + }); } }