From 98193a354c3db320c952f1fab57dc3a4f8a470af Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Mon, 13 Apr 2026 14:04:47 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E7=BC=93=E5=AD=98=E5=91=BD=E4=B8=AD=E7=8E=87=E6=8E=92=E8=A1=8C?= =?UTF-8?q?=E6=A6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en/dashboard.json | 1 + messages/ja/dashboard.json | 1 + messages/ru/dashboard.json | 1 + messages/zh-CN/dashboard.json | 1 + messages/zh-TW/dashboard.json | 1 + .../_components/leaderboard-view.tsx | 151 +++++++++-- src/app/api/leaderboard/route.ts | 25 +- src/lib/redis/leaderboard-cache.ts | 51 +++- src/repository/leaderboard.ts | 254 ++++++++++++++++-- tests/unit/api/leaderboard-route.test.ts | 100 +++++++ ...derboard-view-user-cache-hit-rate.test.tsx | 129 +++++++++ tests/unit/redis/leaderboard-cache.test.ts | 125 +++++++++ .../leaderboard-user-model-stats.test.ts | 127 +++++++++ 13 files changed, 920 insertions(+), 47 deletions(-) create mode 100644 tests/unit/dashboard/leaderboard-view-user-cache-hit-rate.test.tsx create mode 100644 tests/unit/redis/leaderboard-cache.test.ts diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json index 4d6de4678..3e6fcd0de 100644 --- a/messages/en/dashboard.json +++ b/messages/en/dashboard.json @@ -425,6 +425,7 @@ "users": "User Rankings", "keys": "Key Rankings", "userRanking": "User Rankings", + "userCacheHitRateRanking": "User Cache Hit Rate", "providerRanking": "Provider Rankings", "providerCacheHitRateRanking": "Provider Cache Hit Rate", "modelRanking": "Model Rankings", diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json index bd59bc42f..7b4d042d5 100644 --- a/messages/ja/dashboard.json +++ b/messages/ja/dashboard.json @@ -425,6 +425,7 @@ "users": "ユーザー ランキング", "keys": "キー ランキング", "userRanking": "ユーザーランキング", + "userCacheHitRateRanking": "ユーザーキャッシュ命中率", "providerRanking": "プロバイダーランキング", "providerCacheHitRateRanking": "プロバイダーキャッシュ命中率", "modelRanking": "モデルランキング", diff --git a/messages/ru/dashboard.json b/messages/ru/dashboard.json index c307023a3..71ccc50dd 100644 --- a/messages/ru/dashboard.json +++ b/messages/ru/dashboard.json @@ -425,6 +425,7 @@ "users": "Рейтинг пользователей", "keys": "Рейтинг ключей", "userRanking": "Рейтинг пользователей", + "userCacheHitRateRanking": "Рейтинг пользователей по попаданиям в кэш", "providerRanking": "Рейтинг поставщиков", "providerCacheHitRateRanking": "Рейтинг по попаданиям в кэш", "modelRanking": "Рейтинг моделей", diff --git a/messages/zh-CN/dashboard.json b/messages/zh-CN/dashboard.json index d250d952c..03fec2eb8 100644 --- a/messages/zh-CN/dashboard.json +++ b/messages/zh-CN/dashboard.json @@ -425,6 +425,7 @@ "users": "用户排行", "keys": "密钥排行", "userRanking": "用户排行", + "userCacheHitRateRanking": "用户缓存命中率排行", "providerRanking": "供应商排行", "providerCacheHitRateRanking": "供应商缓存命中率排行", "modelRanking": "模型排行", diff --git a/messages/zh-TW/dashboard.json b/messages/zh-TW/dashboard.json index a600f5e43..4b693f027 100644 --- a/messages/zh-TW/dashboard.json +++ b/messages/zh-TW/dashboard.json @@ -425,6 +425,7 @@ "users": "使用者排名", "keys": "密鑰排名", "userRanking": "使用者排名", + "userCacheHitRateRanking": "使用者快取命中率排行", "providerRanking": "供應商排名", "providerCacheHitRateRanking": "供應商快取命中率排行", "modelRanking": "模型排名", diff --git a/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx b/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx index 441db5d6b..c7c37e6af 100644 --- a/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx +++ b/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx @@ -20,6 +20,8 @@ import type { ModelProviderStat, ProviderCacheHitRateLeaderboardEntry, ProviderLeaderboardEntry, + UserCacheHitModelStat, + UserCacheHitRateLeaderboardEntry, UserModelStat, } from "@/repository/leaderboard"; import type { ProviderType } from "@/types/provider"; @@ -30,7 +32,7 @@ interface LeaderboardViewProps { isAdmin: boolean; } -type LeaderboardScope = "user" | "provider" | "providerCacheHitRate" | "model"; +type LeaderboardScope = "user" | "userCacheHitRate" | "provider" | "providerCacheHitRate" | "model"; type TotalCostFormattedFields = { totalCostFormatted?: string }; type ProviderCostFormattedFields = { // API 额外返回的展示用字段(格式化后的字符串) @@ -51,9 +53,20 @@ type ProviderEntry = Omit & modelStats?: ModelProviderStatClient[]; }; type ProviderTableRow = ProviderEntry | ModelProviderStatClient; +type UserCacheHitModelStatClient = UserCacheHitModelStat; +type UserCacheHitRateEntry = Omit & + TotalCostFormattedFields & { + modelStats?: UserCacheHitModelStatClient[]; + }; +type UserCacheHitRateTableRow = UserCacheHitRateEntry | UserCacheHitModelStatClient; type ProviderCacheHitRateEntry = ProviderCacheHitRateLeaderboardEntry; type ProviderCacheHitRateTableRow = ProviderCacheHitRateEntry | ModelCacheHitStat; -type AnyEntry = UserEntry | ProviderEntry | ProviderCacheHitRateEntry | ModelEntry; +type AnyEntry = + | UserEntry + | UserCacheHitRateEntry + | ProviderEntry + | ProviderCacheHitRateEntry + | ModelEntry; const VALID_PERIODS: LeaderboardPeriod[] = ["daily", "weekly", "monthly", "allTime", "custom"]; @@ -63,7 +76,10 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) { const urlScope = searchParams.get("scope") as LeaderboardScope | null; const initialScope: LeaderboardScope = - (urlScope === "provider" || urlScope === "providerCacheHitRate" || urlScope === "model") && + (urlScope === "provider" || + urlScope === "providerCacheHitRate" || + urlScope === "userCacheHitRate" || + urlScope === "model") && isAdmin ? urlScope : "user"; @@ -104,6 +120,7 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) { const urlScopeParam = searchParams.get("scope") as LeaderboardScope | null; const normalizedScope: LeaderboardScope = (urlScopeParam === "provider" || + urlScopeParam === "userCacheHitRate" || urlScopeParam === "providerCacheHitRate" || urlScopeParam === "model") && isAdmin @@ -142,10 +159,10 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) { if (scope === "provider") { url += "&includeModelStats=1"; } - if (scope === "user" && isAdmin) { + if ((scope === "user" || scope === "userCacheHitRate") && isAdmin) { url += "&includeUserModelStats=1"; } - if (scope === "user") { + if (scope === "user" || scope === "userCacheHitRate") { if (userTagFilters.length > 0) { url += `&userTags=${encodeURIComponent(userTagFilters.join(","))}`; } @@ -190,13 +207,15 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) { const skeletonColumns = scope === "user" ? 5 - : scope === "provider" - ? 10 - : scope === "providerCacheHitRate" - ? 8 - : scope === "model" - ? 6 - : 5; + : scope === "userCacheHitRate" + ? 6 + : scope === "provider" + ? 10 + : scope === "providerCacheHitRate" + ? 8 + : scope === "model" + ? 6 + : 5; const skeletonGridStyle = { gridTemplateColumns: `repeat(${skeletonColumns}, minmax(0, 1fr))` }; // 列定义(根据 scope 动态切换) @@ -245,9 +264,14 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) { { header: t("columns.consumedAmount"), className: "text-right font-mono", - cell: (row) => row.totalCostFormatted ?? row.totalCost, + cell: (row) => + "userName" in row ? ( + (row.totalCostFormatted ?? row.totalCost) + ) : ( + - + ), sortKey: "totalCost", - getValue: (row) => row.totalCost, + getValue: (row) => ("userName" in row ? row.totalCost : 0), defaultBold: true, }, ]; @@ -382,6 +406,80 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) { }, ]; + const userCacheHitRateColumns: ColumnDef[] = [ + { + header: t("columns.user"), + cell: (row) => { + if ("userName" in row) { + return isAdmin ? ( + + {row.userName} + + ) : ( + row.userName + ); + } + return renderSubModelLabel(row.model ?? t("columns.unknownModel")); + }, + sortKey: "userName", + getValue: (row) => ("userName" in row ? row.userName : (row.model ?? "")), + }, + { + header: t("columns.cacheHitRequests"), + className: "text-right", + cell: (row) => row.totalRequests.toLocaleString(), + sortKey: "totalRequests", + getValue: (row) => row.totalRequests, + }, + { + header: t("columns.cacheHitRate"), + className: "text-right", + cell: (row) => { + const rate = Number(row.cacheHitRate || 0) * 100; + const colorClass = + rate >= 85 + ? "text-green-600 dark:text-green-400" + : rate >= 60 + ? "text-yellow-600 dark:text-yellow-400" + : "text-orange-600 dark:text-orange-400"; + return {rate.toFixed(1)}%; + }, + sortKey: "cacheHitRate", + getValue: (row) => row.cacheHitRate, + }, + { + header: t("columns.cacheReadTokens"), + className: "text-right", + cell: (row) => formatTokenAmount(row.cacheReadTokens), + sortKey: "cacheReadTokens", + getValue: (row) => row.cacheReadTokens, + }, + { + header: t("columns.totalTokens"), + className: "text-right", + cell: (row) => formatTokenAmount(row.totalInputTokens), + sortKey: "totalInputTokens", + getValue: (row) => row.totalInputTokens, + }, + { + header: t("columns.consumedAmount"), + className: "text-right font-mono", + cell: (row) => { + if ("userName" in row) { + return row.totalCostFormatted ?? row.totalCost; + } + return -; + }, + sortKey: "totalCost", + getValue: (row) => ("userName" in row ? row.totalCost : 0), + defaultBold: true, + }, + ]; + const modelColumns: ColumnDef[] = [ { header: t("columns.model"), @@ -457,6 +555,21 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) { /> ); + const renderUserCacheHitRateTable = () => ( + + data={data as UserCacheHitRateEntry[]} + period={period} + columns={userCacheHitRateColumns} + getRowKey={(row) => row.userId} + {...(isAdmin + ? { + getSubRows: (row) => row.modelStats, + getSubRowKey: (subRow) => subRow.model ?? "__null__", + } + : {})} + /> + ); + const renderModelTable = () => ( data={data as ModelEntry[]} @@ -468,6 +581,7 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) { const renderTable = () => { if (scope === "user") return renderUserTable(); + if (scope === "userCacheHitRate") return renderUserCacheHitRateTable(); if (scope === "provider") return renderProviderTable(); if (scope === "providerCacheHitRate") return renderProviderCacheHitRateTable(); return renderModelTable(); @@ -478,8 +592,13 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) { {/* Scope toggle */}
setScope(v as LeaderboardScope)}> - + {t("tabs.userRanking")} + {isAdmin && ( + + {t("tabs.userCacheHitRateRanking")} + + )} {isAdmin && {t("tabs.providerRanking")}} {isAdmin && ( @@ -499,7 +618,7 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) { ) : null}
- {scope === "user" && isAdmin && ( + {(scope === "user" || scope === "userCacheHitRate") && isAdmin && (
{ const stat = ms as { - totalCost: number; model: string | null; } & Record; return { ...stat, - totalCostFormatted: formatCurrency(stat.totalCost, systemSettings.currencyDisplay), + ...("totalCost" in stat && typeof stat.totalCost === "number" + ? { + totalCostFormatted: formatCurrency( + stat.totalCost, + systemSettings.currencyDisplay + ), + } + : {}), }; }) : undefined; diff --git a/src/lib/redis/leaderboard-cache.ts b/src/lib/redis/leaderboard-cache.ts index 2916a409a..214dccb96 100644 --- a/src/lib/redis/leaderboard-cache.ts +++ b/src/lib/redis/leaderboard-cache.ts @@ -7,37 +7,49 @@ import { findAllTimeModelLeaderboard, findAllTimeProviderCacheHitRateLeaderboard, findAllTimeProviderLeaderboard, + findAllTimeUserCacheHitRateLeaderboard, findCustomRangeLeaderboard, findCustomRangeModelLeaderboard, findCustomRangeProviderCacheHitRateLeaderboard, findCustomRangeProviderLeaderboard, + findCustomRangeUserCacheHitRateLeaderboard, findDailyLeaderboard, findDailyModelLeaderboard, findDailyProviderCacheHitRateLeaderboard, findDailyProviderLeaderboard, + findDailyUserCacheHitRateLeaderboard, findMonthlyLeaderboard, findMonthlyModelLeaderboard, findMonthlyProviderCacheHitRateLeaderboard, findMonthlyProviderLeaderboard, + findMonthlyUserCacheHitRateLeaderboard, findWeeklyLeaderboard, findWeeklyModelLeaderboard, findWeeklyProviderCacheHitRateLeaderboard, findWeeklyProviderLeaderboard, + findWeeklyUserCacheHitRateLeaderboard, type LeaderboardEntry, type LeaderboardPeriod, type ModelLeaderboardEntry, type ProviderCacheHitRateLeaderboardEntry, type ProviderLeaderboardEntry, + type UserCacheHitRateLeaderboardEntry, type UserLeaderboardFilters, } from "@/repository/leaderboard"; import type { ProviderType } from "@/types/provider"; import { getRedisClient } from "./client"; export type { DateRangeParams, LeaderboardPeriod }; -export type LeaderboardScope = "user" | "provider" | "providerCacheHitRate" | "model"; +export type LeaderboardScope = + | "user" + | "userCacheHitRate" + | "provider" + | "providerCacheHitRate" + | "model"; type LeaderboardData = | LeaderboardEntry[] + | UserCacheHitRateLeaderboardEntry[] | ProviderLeaderboardEntry[] | ProviderCacheHitRateLeaderboardEntry[] | ModelLeaderboardEntry[]; @@ -46,7 +58,7 @@ export interface LeaderboardFilters { providerType?: ProviderType; userTags?: string[]; userGroups?: string[]; - /** scope=provider 或 scope=user 时生效:是否包含按模型拆分的数据 */ + /** scope=provider / user / userCacheHitRate 时生效:是否包含按模型拆分的数据 */ includeModelStats?: boolean; } @@ -65,12 +77,13 @@ function buildCacheKey( const now = new Date(); const providerTypeSuffix = filters?.providerType ? `:providerType:${filters.providerType}` : ""; const includeModelStatsSuffix = - (scope === "provider" || scope === "user") && filters?.includeModelStats + (scope === "provider" || scope === "user" || scope === "userCacheHitRate") && + filters?.includeModelStats ? ":includeModelStats" : ""; let userFilterSuffix = ""; - if (scope === "user") { + if (scope === "user" || scope === "userCacheHitRate") { const tagsPart = filters?.userTags?.length ? `:tags:${[...filters.userTags].sort().join(",")}` : ""; @@ -111,7 +124,8 @@ async function queryDatabase( filters?: LeaderboardFilters ): Promise { const userFilters: UserLeaderboardFilters | undefined = - scope === "user" && (filters?.userTags?.length || filters?.userGroups?.length) + (scope === "user" || scope === "userCacheHitRate") && + (filters?.userTags?.length || filters?.userGroups?.length) ? { userTags: filters.userTags, userGroups: filters.userGroups } : undefined; @@ -120,6 +134,13 @@ async function queryDatabase( if (scope === "user") { return await findCustomRangeLeaderboard(dateRange, userFilters, filters?.includeModelStats); } + if (scope === "userCacheHitRate") { + return await findCustomRangeUserCacheHitRateLeaderboard( + dateRange, + userFilters, + filters?.includeModelStats + ); + } if (scope === "provider") { return await findCustomRangeProviderLeaderboard( dateRange, @@ -147,6 +168,26 @@ async function queryDatabase( return await findDailyLeaderboard(userFilters, filters?.includeModelStats); } } + if (scope === "userCacheHitRate") { + switch (period) { + case "daily": + return await findDailyUserCacheHitRateLeaderboard(userFilters, filters?.includeModelStats); + case "weekly": + return await findWeeklyUserCacheHitRateLeaderboard(userFilters, filters?.includeModelStats); + case "monthly": + return await findMonthlyUserCacheHitRateLeaderboard( + userFilters, + filters?.includeModelStats + ); + case "allTime": + return await findAllTimeUserCacheHitRateLeaderboard( + userFilters, + filters?.includeModelStats + ); + default: + return await findDailyUserCacheHitRateLeaderboard(userFilters, filters?.includeModelStats); + } + } if (scope === "provider") { switch (period) { case "daily": diff --git a/src/repository/leaderboard.ts b/src/repository/leaderboard.ts index 2b2442a67..b0b733f38 100644 --- a/src/repository/leaderboard.ts +++ b/src/repository/leaderboard.ts @@ -1,6 +1,6 @@ "use server"; -import { and, desc, eq, isNull, sql } from "drizzle-orm"; +import { and, asc, 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"; @@ -108,6 +108,28 @@ export interface ProviderCacheHitRateLeaderboardEntry { /** * 模型排行榜条目类型 */ +export interface UserCacheHitModelStat { + model: string | null; + totalRequests: number; + cacheReadTokens: number; + totalInputTokens: number; + cacheHitRate: number; // 0-1 +} + +export interface UserCacheHitRateLeaderboardEntry { + userId: number; + userName: string; + totalRequests: number; + cacheReadTokens: number; + totalCost: number; + cacheCreationCost: number; + totalInputTokens: number; + /** @deprecated Use totalInputTokens instead */ + totalTokens: number; + cacheHitRate: number; // 0-1 + modelStats?: UserCacheHitModelStat[]; +} + export interface ModelLeaderboardEntry { model: string; totalRequests: number; @@ -250,18 +272,7 @@ function buildDateCondition( /** * 通用排行榜查询函数(使用 SQL AT TIME ZONE 确保时区正确) */ -async function findLeaderboardWithTimezone( - period: LeaderboardPeriod, - timezone: string, - dateRange?: DateRangeParams, - userFilters?: UserLeaderboardFilters, - includeModelStats?: boolean -): Promise { - const whereConditions = [ - LEDGER_BILLING_CONDITION, - buildDateCondition(period, timezone, dateRange), - ]; - +function buildUserFilterCondition(userFilters?: UserLeaderboardFilters) { const normalizedTags = (userFilters?.userTags ?? []).map((t) => t.trim()).filter(Boolean); let tagFilterCondition: ReturnType | undefined; if (normalizedTags.length > 0) { @@ -274,17 +285,33 @@ async function findLeaderboardWithTimezone( if (normalizedGroups.length > 0) { const groupConditions = normalizedGroups.map( (group) => - sql`${group} = ANY(regexp_split_to_array(coalesce(${users.providerGroup}, ''), '\\s*[,,]+\\s*'))` + sql`${group} = ANY(regexp_split_to_array(coalesce(${users.providerGroup}, ''), '\\s*,\\s*'))` ); groupFilterCondition = sql`(${sql.join(groupConditions, sql` OR `)})`; } if (tagFilterCondition && groupFilterCondition) { - whereConditions.push(sql`(${tagFilterCondition} OR ${groupFilterCondition})`); - } else if (tagFilterCondition) { - whereConditions.push(tagFilterCondition); - } else if (groupFilterCondition) { - whereConditions.push(groupFilterCondition); + return sql`(${tagFilterCondition} OR ${groupFilterCondition})`; + } + + return tagFilterCondition ?? groupFilterCondition; +} + +async function findLeaderboardWithTimezone( + period: LeaderboardPeriod, + timezone: string, + dateRange?: DateRangeParams, + userFilters?: UserLeaderboardFilters, + includeModelStats?: boolean +): Promise { + const whereConditions = [ + LEDGER_BILLING_CONDITION, + buildDateCondition(period, timezone, dateRange), + ]; + + const userFilterCondition = buildUserFilterCondition(userFilters); + if (userFilterCondition) { + whereConditions.push(userFilterCondition); } const rankings = await db @@ -513,6 +540,62 @@ export async function findAllTimeProviderCacheHitRateLeaderboard( ); } +export async function findDailyUserCacheHitRateLeaderboard( + userFilters?: UserLeaderboardFilters, + includeModelStats?: boolean +): Promise { + const timezone = await resolveSystemTimezone(); + return findUserCacheHitRateLeaderboardWithTimezone( + "daily", + timezone, + undefined, + userFilters, + includeModelStats + ); +} + +export async function findMonthlyUserCacheHitRateLeaderboard( + userFilters?: UserLeaderboardFilters, + includeModelStats?: boolean +): Promise { + const timezone = await resolveSystemTimezone(); + return findUserCacheHitRateLeaderboardWithTimezone( + "monthly", + timezone, + undefined, + userFilters, + includeModelStats + ); +} + +export async function findWeeklyUserCacheHitRateLeaderboard( + userFilters?: UserLeaderboardFilters, + includeModelStats?: boolean +): Promise { + const timezone = await resolveSystemTimezone(); + return findUserCacheHitRateLeaderboardWithTimezone( + "weekly", + timezone, + undefined, + userFilters, + includeModelStats + ); +} + +export async function findAllTimeUserCacheHitRateLeaderboard( + userFilters?: UserLeaderboardFilters, + includeModelStats?: boolean +): Promise { + const timezone = await resolveSystemTimezone(); + return findUserCacheHitRateLeaderboardWithTimezone( + "allTime", + timezone, + undefined, + userFilters, + includeModelStats + ); +} + /** * 通用供应商排行榜查询函数(使用 SQL AT TIME ZONE 确保时区正确) * includeModelStats=true 时会额外返回按模型拆分的统计数据(modelStats) @@ -798,6 +881,124 @@ async function findProviderCacheHitRateLeaderboardWithTimezone( * 查询自定义日期范围供应商消耗排行榜 * includeModelStats=true 时会额外返回按模型拆分的统计数据(modelStats) */ +async function findUserCacheHitRateLeaderboardWithTimezone( + period: LeaderboardPeriod, + timezone: string, + dateRange?: DateRangeParams, + userFilters?: UserLeaderboardFilters, + includeModelStats?: boolean +): Promise { + const totalInputTokensExpr = sql`( + COALESCE(${usageLedger.inputTokens}, 0)::double precision + + COALESCE(${usageLedger.cacheCreationInputTokens}, 0)::double precision + + COALESCE(${usageLedger.cacheReadInputTokens}, 0)::double precision + )`; + + const cacheRequiredCondition = sql`( + COALESCE(${usageLedger.cacheCreationInputTokens}, 0) > 0 + OR COALESCE(${usageLedger.cacheReadInputTokens}, 0) > 0 + )`; + + const sumTotalInputTokens = sql`COALESCE(sum(${totalInputTokensExpr})::double precision, 0::double precision)`; + const sumCacheReadTokens = sql`COALESCE(sum(COALESCE(${usageLedger.cacheReadInputTokens}, 0))::double precision, 0::double precision)`; + const sumCacheCreationCost = sql`COALESCE(sum(CASE WHEN COALESCE(${usageLedger.cacheCreationInputTokens}, 0) > 0 THEN ${usageLedger.costUsd} ELSE 0 END), 0)`; + + const cacheHitRateExpr = sql`COALESCE( + ${sumCacheReadTokens} / NULLIF(${sumTotalInputTokens}, 0::double precision), + 0::double precision + )`; + + const whereConditions = [ + LEDGER_BILLING_CONDITION, + buildDateCondition(period, timezone, dateRange), + cacheRequiredCondition, + ]; + + const userFilterCondition = buildUserFilterCondition(userFilters); + if (userFilterCondition) { + whereConditions.push(userFilterCondition); + } + + const rankings = await db + .select({ + userId: usageLedger.userId, + userName: users.name, + totalRequests: sql`count(*)::double precision`, + totalCost: sql`COALESCE(sum(${usageLedger.costUsd}), 0)`, + cacheReadTokens: sumCacheReadTokens, + cacheCreationCost: sumCacheCreationCost, + totalInputTokens: sumTotalInputTokens, + cacheHitRate: cacheHitRateExpr, + }) + .from(usageLedger) + .innerJoin(users, and(sql`${usageLedger.userId} = ${users.id}`, isNull(users.deletedAt))) + .where(and(...whereConditions)) + .groupBy(usageLedger.userId, users.name) + .orderBy(desc(cacheHitRateExpr), desc(sql`count(*)`), asc(users.name), asc(usageLedger.userId)); + + const baseEntries: UserCacheHitRateLeaderboardEntry[] = rankings.map((entry) => ({ + userId: entry.userId, + userName: entry.userName, + totalRequests: entry.totalRequests, + totalCost: parseFloat(entry.totalCost), + cacheReadTokens: entry.cacheReadTokens, + cacheCreationCost: parseFloat(entry.cacheCreationCost), + totalInputTokens: entry.totalInputTokens, + totalTokens: entry.totalInputTokens, + cacheHitRate: clampRatio01(entry.cacheHitRate), + })); + + if (!includeModelStats) return baseEntries; + + const systemSettings = await getSystemSettings(); + const billingModelSource = systemSettings.billingModelSource; + const rawModelField = + billingModelSource === "original" + ? sql`COALESCE(${usageLedger.originalModel}, ${usageLedger.model})` + : sql`COALESCE(${usageLedger.model}, ${usageLedger.originalModel})`; + const modelField = sql`NULLIF(TRIM(${rawModelField}), '')`; + + const modelTotalInput = sql`COALESCE(sum(${totalInputTokensExpr})::double precision, 0::double precision)`; + const modelCacheRead = sql`COALESCE(sum(COALESCE(${usageLedger.cacheReadInputTokens}, 0))::double precision, 0::double precision)`; + const modelCacheHitRate = sql`COALESCE( + ${modelCacheRead} / NULLIF(${modelTotalInput}, 0::double precision), + 0::double precision + )`; + + const modelRows = await db + .select({ + userId: usageLedger.userId, + model: modelField, + totalRequests: sql`count(*)::double precision`, + cacheReadTokens: modelCacheRead, + totalInputTokens: modelTotalInput, + cacheHitRate: modelCacheHitRate, + }) + .from(usageLedger) + .innerJoin(users, and(sql`${usageLedger.userId} = ${users.id}`, isNull(users.deletedAt))) + .where(and(...whereConditions)) + .groupBy(usageLedger.userId, modelField) + .orderBy(desc(modelCacheHitRate), desc(sql`count(*)`), asc(modelField)); + + const modelStatsByUser = new Map(); + for (const row of modelRows) { + const stats = modelStatsByUser.get(row.userId) ?? []; + stats.push({ + model: row.model, + totalRequests: row.totalRequests, + cacheReadTokens: row.cacheReadTokens, + totalInputTokens: row.totalInputTokens, + cacheHitRate: clampRatio01(row.cacheHitRate), + }); + modelStatsByUser.set(row.userId, stats); + } + + return baseEntries.map((entry) => ({ + ...entry, + modelStats: modelStatsByUser.get(entry.userId) ?? [], + })); +} + export async function findCustomRangeProviderLeaderboard( dateRange: DateRangeParams, providerType?: ProviderType, @@ -833,6 +1034,21 @@ export async function findCustomRangeProviderCacheHitRateLeaderboard( * 查询今日模型调用排行榜(不限制数量) * 使用 SQL AT TIME ZONE 进行时区转换,确保"今日"基于系统时区 */ +export async function findCustomRangeUserCacheHitRateLeaderboard( + dateRange: DateRangeParams, + userFilters?: UserLeaderboardFilters, + includeModelStats?: boolean +): Promise { + const timezone = await resolveSystemTimezone(); + return findUserCacheHitRateLeaderboardWithTimezone( + "custom", + timezone, + dateRange, + userFilters, + includeModelStats + ); +} + export async function findDailyModelLeaderboard(): Promise { const timezone = await resolveSystemTimezone(); return findModelLeaderboardWithTimezone("daily", timezone); diff --git a/tests/unit/api/leaderboard-route.test.ts b/tests/unit/api/leaderboard-route.test.ts index c358e93bd..f59f79ac0 100644 --- a/tests/unit/api/leaderboard-route.test.ts +++ b/tests/unit/api/leaderboard-route.test.ts @@ -67,6 +67,22 @@ describe("GET /api/leaderboard", () => { expect(options.userGroups).toEqual(["a", "b", "c"]); }); + it("applies userTags/userGroups to userCacheHitRate scope too", async () => { + mocks.getSession.mockResolvedValue({ user: { id: 1, name: "u", role: "admin" } }); + + const { GET } = await import("@/app/api/leaderboard/route"); + const url = + "http://localhost/api/leaderboard?scope=userCacheHitRate&period=daily&userTags=vip, beta &userGroups=g1, g2"; + const response = await GET({ nextUrl: new URL(url) } as any); + + expect(response.status).toBe(200); + + expect(mocks.getLeaderboardWithCache).toHaveBeenCalledTimes(1); + const options = mocks.getLeaderboardWithCache.mock.calls[0][4]; + expect(options.userTags).toEqual(["vip", "beta"]); + expect(options.userGroups).toEqual(["g1", "g2"]); + }); + it("does not apply userTags/userGroups when scope is not user", async () => { mocks.getSession.mockResolvedValue({ user: { id: 1, name: "u", role: "admin" } }); @@ -198,6 +214,51 @@ describe("GET /api/leaderboard", () => { expect(entry.modelStats[0]).toHaveProperty("cacheHitRate", 0.53); }); + it("includes modelStats in userCacheHitRate scope response", async () => { + mocks.getSession.mockResolvedValue({ user: { id: 1, name: "u", role: "admin" } }); + mocks.getLeaderboardWithCache.mockResolvedValue([ + { + userId: 7, + userName: "cache-user", + totalRequests: 50, + cacheReadTokens: 10000, + totalCost: 2.5, + cacheCreationCost: 1.0, + totalInputTokens: 20000, + totalTokens: 20000, + cacheHitRate: 0.5, + modelStats: [ + { + model: "claude-3-opus", + totalRequests: 30, + cacheReadTokens: 8000, + totalInputTokens: 15000, + cacheHitRate: 0.53, + }, + { + model: null, + totalRequests: 20, + cacheReadTokens: 2000, + totalInputTokens: 5000, + cacheHitRate: 0.4, + }, + ], + }, + ]); + + const { GET } = await import("@/app/api/leaderboard/route"); + const url = "http://localhost/api/leaderboard?scope=userCacheHitRate&period=daily"; + const response = await GET({ nextUrl: new URL(url) } as any); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body).toHaveLength(1); + expect(body[0]).toHaveProperty("modelStats"); + expect(body[0].modelStats).toHaveLength(2); + expect(body[0].modelStats[0]).toHaveProperty("model", "claude-3-opus"); + expect(body[0].modelStats[1]).toHaveProperty("model", null); + }); + it("passes includeModelStats to cache and formats provider modelStats entries", async () => { mocks.getSession.mockResolvedValue({ user: { id: 1, name: "u", role: "admin" } }); mocks.getLeaderboardWithCache.mockResolvedValue([ @@ -351,5 +412,44 @@ describe("GET /api/leaderboard", () => { const body = await response.json(); expect(body.error).toBe("INCLUDE_USER_MODEL_STATS_ADMIN_REQUIRED"); }); + + it("admin + userCacheHitRate + includeUserModelStats=1 forwards includeModelStats to cache", async () => { + mocks.getSession.mockResolvedValue({ user: { id: 1, name: "admin", role: "admin" } }); + mocks.getLeaderboardWithCache.mockResolvedValue([ + { + userId: 1, + userName: "cache-user", + totalRequests: 20, + cacheReadTokens: 500, + totalCost: 1.5, + cacheCreationCost: 0.4, + totalInputTokens: 1000, + totalTokens: 1000, + cacheHitRate: 0.5, + modelStats: [ + { + model: "claude-sonnet", + totalRequests: 20, + cacheReadTokens: 500, + totalInputTokens: 1000, + cacheHitRate: 0.5, + }, + ], + }, + ]); + + const { GET } = await import("@/app/api/leaderboard/route"); + const url = + "http://localhost/api/leaderboard?scope=userCacheHitRate&period=daily&includeUserModelStats=1"; + const response = await GET({ nextUrl: new URL(url) } as any); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(response.headers.get("Cache-Control")).toBe("private, no-store"); + expect(mocks.getLeaderboardWithCache).toHaveBeenCalledTimes(1); + expect(mocks.getLeaderboardWithCache.mock.calls[0][4].includeModelStats).toBe(true); + expect(body[0].modelStats).toHaveLength(1); + expect(body[0].modelStats[0]).not.toHaveProperty("totalCostFormatted"); + }); }); }); diff --git a/tests/unit/dashboard/leaderboard-view-user-cache-hit-rate.test.tsx b/tests/unit/dashboard/leaderboard-view-user-cache-hit-rate.test.tsx new file mode 100644 index 000000000..6cd32cdc4 --- /dev/null +++ b/tests/unit/dashboard/leaderboard-view-user-cache-hit-rate.test.tsx @@ -0,0 +1,129 @@ +/** + * @vitest-environment happy-dom + */ +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { LeaderboardView } from "@/app/[locale]/dashboard/leaderboard/_components/leaderboard-view"; + +const fetchMock = vi.fn(); +const { getAllUserTagsMock, getAllUserKeyGroupsMock } = vi.hoisted(() => ({ + getAllUserTagsMock: vi.fn(), + getAllUserKeyGroupsMock: vi.fn(), +})); +const searchParamsState = vi.hoisted(() => ({ + value: new URLSearchParams(), +})); +const tMock = vi.hoisted(() => vi.fn((key: string) => key)); + +vi.mock("next/navigation", () => ({ + useSearchParams: () => searchParamsState.value, +})); + +vi.mock("next-intl", () => ({ + useTranslations: () => tMock, +})); + +vi.mock("@/actions/users", () => ({ + getAllUserTags: getAllUserTagsMock, + getAllUserKeyGroups: getAllUserKeyGroupsMock, +})); + +vi.mock("@/app/[locale]/settings/providers/_components/provider-type-filter", () => ({ + ProviderTypeFilter: ({ value }: { value: string }) => ( +
{value}
+ ), +})); + +vi.mock("@/i18n/routing", () => ({ + Link: ({ children, href, ...props }: any) => ( + + {children} + + ), +})); + +const globalFetch = global.fetch; + +describe("LeaderboardView user cache hit rate scope", () => { + let container: HTMLDivElement | null = null; + let root: ReturnType | null = null; + + beforeEach(() => { + vi.clearAllMocks(); + searchParamsState.value = new URLSearchParams("scope=userCacheHitRate"); + getAllUserTagsMock.mockResolvedValue({ ok: true, data: ["vip"] }); + getAllUserKeyGroupsMock.mockResolvedValue({ ok: true, data: ["default"] }); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + fetchMock.mockImplementation(async (input) => { + const url = String(input); + if (url.includes("scope=userCacheHitRate")) { + return { + ok: true, + json: async () => [ + { + userId: 9, + userName: "cache-user", + totalRequests: 15, + totalCost: 1.5, + totalCostFormatted: "$1.50", + cacheReadTokens: 500, + cacheCreationCost: 0.2, + totalInputTokens: 1000, + totalTokens: 1000, + cacheHitRate: 0.5, + modelStats: [ + { + model: "claude-sonnet-4", + totalRequests: 15, + cacheReadTokens: 500, + totalInputTokens: 1000, + cacheHitRate: 0.5, + }, + ], + }, + ], + } as Response; + } + + return { + ok: true, + json: async () => [], + } as Response; + }); + + global.fetch = fetchMock as typeof fetch; + }); + + afterEach(() => { + if (root) { + act(() => root!.unmount()); + root = null; + } + if (container) { + container.remove(); + container = null; + } + global.fetch = globalFetch; + }); + + it("fetches and renders user cache hit rate leaderboard for admin", async () => { + await act(async () => { + root!.render(); + }); + + expect(fetchMock).toHaveBeenCalled(); + const requestedUrls = fetchMock.mock.calls.map((call) => String(call[0])); + expect(requestedUrls.some((url) => url.includes("scope=userCacheHitRate"))).toBe(true); + expect( + requestedUrls.some( + (url) => url.includes("scope=userCacheHitRate") && url.includes("includeUserModelStats=1") + ) + ).toBe(true); + expect(container!.textContent).toContain("cache-user"); + expect(container!.textContent).toContain("50.0%"); + }); +}); diff --git a/tests/unit/redis/leaderboard-cache.test.ts b/tests/unit/redis/leaderboard-cache.test.ts new file mode 100644 index 000000000..32c575ca9 --- /dev/null +++ b/tests/unit/redis/leaderboard-cache.test.ts @@ -0,0 +1,125 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { getRedisClient } from "@/lib/redis/client"; +import { getLeaderboardWithCache } from "@/lib/redis/leaderboard-cache"; +import { + findDailyUserCacheHitRateLeaderboard, + type UserCacheHitRateLeaderboardEntry, +} from "@/repository/leaderboard"; + +vi.mock("@/lib/logger", () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock("@/lib/redis/client", () => ({ + getRedisClient: vi.fn(), +})); + +vi.mock("@/lib/utils/timezone", () => ({ + resolveSystemTimezone: vi.fn().mockResolvedValue("UTC"), +})); + +vi.mock("@/repository/leaderboard", async () => { + const actual = await vi.importActual( + "@/repository/leaderboard" + ); + + return { + ...actual, + findDailyUserCacheHitRateLeaderboard: vi.fn(), + }; +}); + +type RedisMock = { + get: ReturnType; + set: ReturnType; + setex: ReturnType; + del: ReturnType; +}; + +function createRedisMock(): RedisMock { + return { + get: vi.fn(), + set: vi.fn(), + setex: vi.fn(), + del: vi.fn(), + }; +} + +function createUserCacheHitRateRows(): UserCacheHitRateLeaderboardEntry[] { + return [ + { + userId: 1, + userName: "alice", + totalRequests: 12, + totalCost: 1.23, + cacheReadTokens: 456, + cacheCreationCost: 0.45, + totalInputTokens: 789, + totalTokens: 789, + cacheHitRate: 0.577, + }, + ]; +} + +describe("getLeaderboardWithCache", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useRealTimers(); + }); + + it("passes user filters to userCacheHitRate queries on Redis cache miss", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-04-13T00:00:00Z")); + + const redis = createRedisMock(); + const rows = createUserCacheHitRateRows(); + redis.get.mockResolvedValueOnce(null); + redis.set.mockResolvedValueOnce("OK"); + redis.setex.mockResolvedValueOnce("OK"); + redis.del.mockResolvedValueOnce(1); + + vi.mocked(getRedisClient).mockReturnValue( + redis as unknown as NonNullable> + ); + vi.mocked(findDailyUserCacheHitRateLeaderboard).mockResolvedValueOnce(rows); + + const result = await getLeaderboardWithCache("daily", "USD", "userCacheHitRate", undefined, { + userTags: ["vip", "team-a"], + userGroups: ["group-1"], + includeModelStats: true, + }); + + expect(result).toEqual(rows); + expect(findDailyUserCacheHitRateLeaderboard).toHaveBeenCalledWith( + { userTags: ["vip", "team-a"], userGroups: ["group-1"] }, + true + ); + expect(redis.setex).toHaveBeenCalledWith( + "leaderboard:userCacheHitRate:daily:2026-04-13:USD:includeModelStats:tags:team-a,vip:groups:group-1", + 60, + JSON.stringify(rows) + ); + }); + + it("falls back to direct query when Redis is unavailable and still preserves userCacheHitRate filters", async () => { + const rows = createUserCacheHitRateRows(); + vi.mocked(getRedisClient).mockReturnValue(null); + vi.mocked(findDailyUserCacheHitRateLeaderboard).mockResolvedValueOnce(rows); + + const result = await getLeaderboardWithCache("daily", "USD", "userCacheHitRate", undefined, { + userTags: ["vip"], + userGroups: ["group-1"], + }); + + expect(result).toEqual(rows); + expect(findDailyUserCacheHitRateLeaderboard).toHaveBeenCalledWith( + { userTags: ["vip"], userGroups: ["group-1"] }, + undefined + ); + }); +}); diff --git a/tests/unit/repository/leaderboard-user-model-stats.test.ts b/tests/unit/repository/leaderboard-user-model-stats.test.ts index 37da7ba37..3b9af6a13 100644 --- a/tests/unit/repository/leaderboard-user-model-stats.test.ts +++ b/tests/unit/repository/leaderboard-user-model-stats.test.ts @@ -324,3 +324,130 @@ describe("User Leaderboard Model Stats", () => { expect(stats[1].model).toBe("cheap-model"); }); }); + +describe("User Cache Hit Rate Leaderboard", () => { + beforeEach(() => { + vi.resetModules(); + selectCallIndex = 0; + chainMocks = []; + mockSelect.mockClear(); + mocks.resolveSystemTimezone.mockResolvedValue("UTC"); + mocks.getSystemSettings.mockResolvedValue({ billingModelSource: "redirected" }); + }); + + it("returns user cache hit rankings with stable ordering and base fields", async () => { + chainMocks = [ + createChainMock([ + { + userId: 1, + userName: "alice", + totalRequests: 30, + totalCost: "3.0", + cacheReadTokens: 600, + cacheCreationCost: "1.0", + totalInputTokens: 1000, + cacheHitRate: 0.6, + }, + { + userId: 2, + userName: "bob", + totalRequests: 30, + totalCost: "2.0", + cacheReadTokens: 300, + cacheCreationCost: "0.5", + totalInputTokens: 1000, + cacheHitRate: 0.3, + }, + ]), + ]; + + const { findDailyUserCacheHitRateLeaderboard } = await import("@/repository/leaderboard"); + const result = await findDailyUserCacheHitRateLeaderboard(); + + expect(result).toHaveLength(2); + expect(result[0].cacheHitRate).toBeGreaterThanOrEqual(result[1].cacheHitRate); + expect(result[0]).toMatchObject({ + userId: 1, + userName: "alice", + totalRequests: 30, + totalCost: 3, + cacheReadTokens: 600, + cacheCreationCost: 1, + totalInputTokens: 1000, + totalTokens: 1000, + cacheHitRate: 0.6, + }); + }); + + it("includes modelStats when includeModelStats=true and preserves null model rows", async () => { + chainMocks = [ + createChainMock([ + { + userId: 1, + userName: "alice", + totalRequests: 30, + totalCost: "3.0", + cacheReadTokens: 600, + cacheCreationCost: "1.0", + totalInputTokens: 1000, + cacheHitRate: 0.6, + }, + ]), + createChainMock([ + { + userId: 1, + model: "claude-sonnet-4", + totalRequests: 20, + cacheReadTokens: 500, + totalInputTokens: 700, + cacheHitRate: 0.714, + }, + { + userId: 1, + model: null, + totalRequests: 10, + cacheReadTokens: 100, + totalInputTokens: 300, + cacheHitRate: 0.333, + }, + ]), + ]; + + const { findDailyUserCacheHitRateLeaderboard } = await import("@/repository/leaderboard"); + const result = await findDailyUserCacheHitRateLeaderboard(undefined, true); + + expect(result).toHaveLength(1); + expect(result[0].modelStats).toHaveLength(2); + expect(result[0].modelStats?.[0].model).toBe("claude-sonnet-4"); + expect(result[0].modelStats?.[0].cacheHitRate).toBeCloseTo(0.714, 3); + + const nullModelStat = result[0].modelStats?.find((item) => item.model === null); + expect(nullModelStat).toBeDefined(); + expect(nullModelStat?.totalRequests).toBe(10); + expect(nullModelStat?.cacheReadTokens).toBe(100); + }); + + it("does not query model breakdown when includeModelStats is false", async () => { + chainMocks = [ + createChainMock([ + { + userId: 1, + userName: "alice", + totalRequests: 10, + totalCost: "1.0", + cacheReadTokens: 100, + cacheCreationCost: "0.2", + totalInputTokens: 400, + cacheHitRate: 0.25, + }, + ]), + ]; + + const { findDailyUserCacheHitRateLeaderboard } = await import("@/repository/leaderboard"); + const result = await findDailyUserCacheHitRateLeaderboard(undefined, false); + + expect(result).toHaveLength(1); + expect(result[0].modelStats).toBeUndefined(); + expect(mockSelect).toHaveBeenCalledTimes(1); + }); +}); From 7a52479b6a8f838829e7b1af8e4946cd924a3ae8 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Mon, 13 Apr 2026 14:27:01 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=8E=92=E8=A1=8C?= =?UTF-8?q?=E6=A6=9C=E5=AE=A1=E6=9F=A5=E5=8F=8D=E9=A6=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_components/leaderboard-view.tsx | 9 +-- src/repository/leaderboard.ts | 9 ++- ...derboard-view-user-cache-hit-rate.test.tsx | 53 +++++++++++++++++ .../leaderboard-user-model-stats.test.ts | 59 +++++++++++++++++++ 4 files changed, 121 insertions(+), 9 deletions(-) diff --git a/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx b/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx index c7c37e6af..1f0a142bf 100644 --- a/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx +++ b/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx @@ -264,14 +264,9 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) { { header: t("columns.consumedAmount"), className: "text-right font-mono", - cell: (row) => - "userName" in row ? ( - (row.totalCostFormatted ?? row.totalCost) - ) : ( - - - ), + cell: (row) => row.totalCostFormatted ?? row.totalCost, sortKey: "totalCost", - getValue: (row) => ("userName" in row ? row.totalCost : 0), + getValue: (row) => row.totalCost, defaultBold: true, }, ]; diff --git a/src/repository/leaderboard.ts b/src/repository/leaderboard.ts index b0b733f38..2d7a8c188 100644 --- a/src/repository/leaderboard.ts +++ b/src/repository/leaderboard.ts @@ -124,9 +124,14 @@ export interface UserCacheHitRateLeaderboardEntry { totalCost: number; cacheCreationCost: number; totalInputTokens: number; - /** @deprecated Use totalInputTokens instead */ + /** 为与现有缓存命中率榜单前端保持字段一致而保留;值始终等于 totalInputTokens */ totalTokens: number; cacheHitRate: number; // 0-1 + /** + * 可选:按模型拆分 + * - undefined: 未请求 includeModelStats + * - []: 已请求 includeModelStats,但该用户下无可用模型统计 + */ modelStats?: UserCacheHitModelStat[]; } @@ -285,7 +290,7 @@ function buildUserFilterCondition(userFilters?: UserLeaderboardFilters) { if (normalizedGroups.length > 0) { const groupConditions = normalizedGroups.map( (group) => - sql`${group} = ANY(regexp_split_to_array(coalesce(${users.providerGroup}, ''), '\\s*,\\s*'))` + sql`${group} = ANY(regexp_split_to_array(coalesce(${users.providerGroup}, ''), '\\s*[,,\n\r]+\\s*'))` ); groupFilterCondition = sql`(${sql.join(groupConditions, sql` OR `)})`; } diff --git a/tests/unit/dashboard/leaderboard-view-user-cache-hit-rate.test.tsx b/tests/unit/dashboard/leaderboard-view-user-cache-hit-rate.test.tsx index 6cd32cdc4..2ac1fa7ca 100644 --- a/tests/unit/dashboard/leaderboard-view-user-cache-hit-rate.test.tsx +++ b/tests/unit/dashboard/leaderboard-view-user-cache-hit-rate.test.tsx @@ -126,4 +126,57 @@ describe("LeaderboardView user cache hit rate scope", () => { expect(container!.textContent).toContain("cache-user"); expect(container!.textContent).toContain("50.0%"); }); + + it("keeps model cost visible in the existing user leaderboard drilldown", async () => { + searchParamsState.value = new URLSearchParams("scope=user"); + fetchMock.mockImplementationOnce(async (input) => { + const url = String(input); + if (url.includes("scope=user")) { + return { + ok: true, + json: async () => [ + { + userId: 3, + userName: "cost-user", + totalRequests: 12, + totalCost: 4.2, + totalCostFormatted: "$4.20", + totalTokens: 2400, + modelStats: [ + { + model: "claude-sonnet-4", + totalRequests: 7, + totalCost: 2.1, + totalCostFormatted: "$2.10", + totalTokens: 1400, + }, + ], + }, + ], + } as Response; + } + + return { + ok: true, + json: async () => [], + } as Response; + }); + + await act(async () => { + root!.render(); + }); + + const expandButton = container!.querySelector( + 'button[aria-label="expandModelStats"]' + ) as HTMLButtonElement | null; + expect(expandButton).toBeTruthy(); + + await act(async () => { + expandButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(container!.textContent).toContain("cost-user"); + expect(container!.textContent).toContain("claude-sonnet-4"); + expect(container!.textContent).toContain("$2.10"); + }); }); diff --git a/tests/unit/repository/leaderboard-user-model-stats.test.ts b/tests/unit/repository/leaderboard-user-model-stats.test.ts index 3b9af6a13..15a25b54a 100644 --- a/tests/unit/repository/leaderboard-user-model-stats.test.ts +++ b/tests/unit/repository/leaderboard-user-model-stats.test.ts @@ -29,6 +29,40 @@ const mocks = vi.hoisted(() => ({ getSystemSettings: vi.fn(), })); +function sqlToString(sqlObj: unknown): string { + const visited = new Set(); + + const walk = (node: unknown): string => { + if (!node || visited.has(node)) return ""; + visited.add(node); + + if (typeof node === "string") return node; + if (typeof node === "number") return String(node); + + if (typeof node === "object") { + const anyNode = node as Record; + if (Array.isArray(anyNode)) { + return anyNode.map(walk).join(""); + } + + if (anyNode.value !== undefined) { + if (Array.isArray(anyNode.value)) { + return (anyNode.value as unknown[]).map(walk).join(""); + } + return walk(anyNode.value); + } + + if (anyNode.queryChunks) { + return walk(anyNode.queryChunks); + } + } + + return ""; + }; + + return walk(sqlObj); +} + vi.mock("@/drizzle/db", () => ({ db: { select: (...args: unknown[]) => mockSelect(...args), @@ -450,4 +484,29 @@ describe("User Cache Hit Rate Leaderboard", () => { expect(result[0].modelStats).toBeUndefined(); expect(mockSelect).toHaveBeenCalledTimes(1); }); + + it("keeps chinese comma and newline support in providerGroup filter", async () => { + const whereArgs: unknown[] = []; + chainMocks = [ + { + from: vi.fn().mockReturnThis(), + innerJoin: vi.fn().mockReturnThis(), + where: vi.fn((arg: unknown) => { + whereArgs.push(arg); + return { + groupBy: vi.fn().mockReturnThis(), + orderBy: vi.fn().mockResolvedValue([]), + }; + }), + } as any, + ]; + + const { findDailyUserCacheHitRateLeaderboard } = await import("@/repository/leaderboard"); + await findDailyUserCacheHitRateLeaderboard({ userGroups: ["研发"] }, false); + + expect(whereArgs).toHaveLength(1); + const whereSql = sqlToString(whereArgs[0]).replace(/\r/g, "\\r").replace(/\n/g, "\\n"); + expect(whereSql).toContain("regexp_split_to_array"); + expect(whereSql).toContain("\\s*[,,\\n\\r]+\\s*"); + }); });