Răsfoiți Sursa

feat(leaderboard): add user tag and group filters for user ranking (#607)

* feat(leaderboard): add user tag and group filters for user ranking (#606)

Add filtering capability to the leaderboard user ranking by:
- userTags: filter users by their tags (OR logic)
- userGroups: filter users by their providerGroup (OR logic)

Changes:
- Repository: Add UserLeaderboardFilters interface and SQL filtering
- Cache: Extend LeaderboardFilters and include filters in cache key
- API: Parse userTags/userGroups query params (CSV format, max 20)
- Frontend: Add TagInput filters (admin-only, user scope only)
- i18n: Add translation keys for 5 languages

Closes #606

* refactor: apply reviewer suggestions for leaderboard filters

- Use JSONB ? operator instead of @> for better performance
- Extract parseListParam helper to reduce code duplication
Ding 1 lună în urmă
părinte
comite
84c8ce6f03

+ 4 - 0
messages/en/dashboard.json

@@ -322,6 +322,10 @@
       "adminAction": "Enable this permission.",
       "userAction": "Please contact an administrator to enable this permission.",
       "systemSettings": "System Settings"
+    },
+    "filters": {
+      "userTagsPlaceholder": "Filter by user tags...",
+      "userGroupsPlaceholder": "Filter by user groups..."
     }
   },
   "sessions": {

+ 4 - 0
messages/ja/dashboard.json

@@ -321,6 +321,10 @@
       "adminAction": "この権限を有効にします。",
       "userAction": "この権限を有効にするには、管理者に連絡してください。",
       "systemSettings": "システム設定"
+    },
+    "filters": {
+      "userTagsPlaceholder": "ユーザータグでフィルタ...",
+      "userGroupsPlaceholder": "ユーザーグループでフィルタ..."
     }
   },
   "sessions": {

+ 4 - 0
messages/ru/dashboard.json

@@ -321,6 +321,10 @@
       "adminAction": "Включить это разрешение.",
       "userAction": "Пожалуйста, свяжитесь с администратором, чтобы включить это разрешение.",
       "systemSettings": "Настройки системы"
+    },
+    "filters": {
+      "userTagsPlaceholder": "Фильтр по тегам пользователей...",
+      "userGroupsPlaceholder": "Фильтр по группам пользователей..."
     }
   },
   "sessions": {

+ 4 - 0
messages/zh-CN/dashboard.json

@@ -322,6 +322,10 @@
       "adminAction": "开启此权限。",
       "userAction": "请联系管理员开启此权限。",
       "systemSettings": "系统设置"
+    },
+    "filters": {
+      "userTagsPlaceholder": "按用户标签筛选...",
+      "userGroupsPlaceholder": "按用户分组筛选..."
     }
   },
   "sessions": {

+ 4 - 0
messages/zh-TW/dashboard.json

@@ -322,6 +322,10 @@
       "adminAction": "開啟此權限。",
       "userAction": "請聯繫管理員開啟此權限。",
       "systemSettings": "系統設定"
+    },
+    "filters": {
+      "userTagsPlaceholder": "按使用者標籤篩選...",
+      "userGroupsPlaceholder": "按使用者群組篩選..."
     }
   },
   "sessions": {

+ 37 - 1
src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx

@@ -7,6 +7,7 @@ import { ProviderTypeFilter } from "@/app/[locale]/settings/providers/_component
 import { Card, CardContent } from "@/components/ui/card";
 import { Skeleton } from "@/components/ui/skeleton";
 import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { TagInput } from "@/components/ui/tag-input";
 import { formatTokenAmount } from "@/lib/utils";
 import type {
   DateRangeParams,
@@ -51,6 +52,8 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) {
   const [period, setPeriod] = useState<LeaderboardPeriod>(initialPeriod);
   const [dateRange, setDateRange] = useState<DateRangeParams | undefined>(undefined);
   const [providerTypeFilter, setProviderTypeFilter] = useState<ProviderType | "all">("all");
+  const [userTagFilters, setUserTagFilters] = useState<string[]>([]);
+  const [userGroupFilters, setUserGroupFilters] = useState<string[]>([]);
   const [data, setData] = useState<AnyEntry[]>([]);
   const [loading, setLoading] = useState(true);
   const [error, setError] = useState<string | null>(null);
@@ -96,6 +99,14 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) {
         ) {
           url += `&providerType=${encodeURIComponent(providerTypeFilter)}`;
         }
+        if (scope === "user") {
+          if (userTagFilters.length > 0) {
+            url += `&userTags=${encodeURIComponent(userTagFilters.join(","))}`;
+          }
+          if (userGroupFilters.length > 0) {
+            url += `&userGroups=${encodeURIComponent(userGroupFilters.join(","))}`;
+          }
+        }
         const res = await fetch(url);
 
         if (!res.ok) {
@@ -120,7 +131,7 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) {
     return () => {
       cancelled = true;
     };
-  }, [scope, period, dateRange, providerTypeFilter, t]);
+  }, [scope, period, dateRange, providerTypeFilter, userTagFilters, userGroupFilters, t]);
 
   const handlePeriodChange = useCallback(
     (newPeriod: LeaderboardPeriod, newDateRange?: DateRangeParams) => {
@@ -369,6 +380,31 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) {
         ) : null}
       </div>
 
+      {scope === "user" && isAdmin && (
+        <div className="flex flex-wrap gap-4 mb-4">
+          <div className="flex-1 min-w-[200px] max-w-[300px]">
+            <TagInput
+              value={userTagFilters}
+              onChange={setUserTagFilters}
+              placeholder={t("filters.userTagsPlaceholder")}
+              disabled={loading}
+              maxTags={20}
+              clearable
+            />
+          </div>
+          <div className="flex-1 min-w-[200px] max-w-[300px]">
+            <TagInput
+              value={userGroupFilters}
+              onChange={setUserGroupFilters}
+              placeholder={t("filters.userGroupsPlaceholder")}
+              disabled={loading}
+              maxTags={20}
+              clearable
+            />
+          </div>
+        </div>
+      )}
+
       {/* Date range picker with quick period buttons */}
       <div className="mb-6">
         <DateRangePicker

+ 22 - 1
src/app/api/leaderboard/route.ts

@@ -75,6 +75,8 @@ export async function GET(request: NextRequest) {
     const startDate = searchParams.get("startDate");
     const endDate = searchParams.get("endDate");
     const providerTypeParam = searchParams.get("providerType");
+    const userTagsParam = searchParams.get("userTags");
+    const userGroupsParam = searchParams.get("userGroups");
 
     if (!VALID_PERIODS.includes(period)) {
       return NextResponse.json(
@@ -125,13 +127,30 @@ export async function GET(request: NextRequest) {
       providerType = providerTypeParam;
     }
 
+    const parseListParam = (param: string | null): string[] | undefined => {
+      if (!param) return undefined;
+      const items = param
+        .split(",")
+        .map((s) => s.trim())
+        .filter((s) => s.length > 0)
+        .slice(0, 20);
+      return items.length > 0 ? items : undefined;
+    };
+
+    let userTags: string[] | undefined;
+    let userGroups: string[] | undefined;
+    if (scope === "user") {
+      userTags = parseListParam(userTagsParam);
+      userGroups = parseListParam(userGroupsParam);
+    }
+
     // 使用 Redis 乐观缓存获取数据
     const rawData = await getLeaderboardWithCache(
       period,
       systemSettings.currencyDisplay,
       scope,
       dateRange,
-      providerType ? { providerType } : undefined
+      { providerType, userTags, userGroups }
     );
 
     // 格式化金额字段
@@ -162,6 +181,8 @@ export async function GET(request: NextRequest) {
       scope,
       dateRange,
       providerType,
+      userTags,
+      userGroups,
       entriesCount: data.length,
     });
 

+ 30 - 11
src/lib/redis/leaderboard-cache.ts

@@ -28,6 +28,7 @@ import {
   type ModelLeaderboardEntry,
   type ProviderCacheHitRateLeaderboardEntry,
   type ProviderLeaderboardEntry,
+  type UserLeaderboardFilters,
 } from "@/repository/leaderboard";
 import type { ProviderType } from "@/types/provider";
 import { getRedisClient } from "./client";
@@ -43,6 +44,8 @@ type LeaderboardData =
 
 export interface LeaderboardFilters {
   providerType?: ProviderType;
+  userTags?: string[];
+  userGroups?: string[];
 }
 
 /**
@@ -59,24 +62,35 @@ function buildCacheKey(
   const tz = getEnvConfig().TZ; // ensure date formatting aligns with configured timezone
   const providerTypeSuffix = filters?.providerType ? `:providerType:${filters.providerType}` : "";
 
+  let userFilterSuffix = "";
+  if (scope === "user") {
+    const tagsPart = filters?.userTags?.length
+      ? `:tags:${[...filters.userTags].sort().join(",")}`
+      : "";
+    const groupsPart = filters?.userGroups?.length
+      ? `:groups:${[...filters.userGroups].sort().join(",")}`
+      : "";
+    userFilterSuffix = tagsPart + groupsPart;
+  }
+
   if (period === "custom" && dateRange) {
     // leaderboard:{scope}:custom:2025-01-01_2025-01-15:USD
-    return `leaderboard:${scope}:custom:${dateRange.startDate}_${dateRange.endDate}:${currencyDisplay}${providerTypeSuffix}`;
+    return `leaderboard:${scope}:custom:${dateRange.startDate}_${dateRange.endDate}:${currencyDisplay}${providerTypeSuffix}${userFilterSuffix}`;
   } else if (period === "daily") {
     // leaderboard:{scope}:daily:2025-01-15:USD
     const dateStr = formatInTimeZone(now, tz, "yyyy-MM-dd");
-    return `leaderboard:${scope}:daily:${dateStr}:${currencyDisplay}${providerTypeSuffix}`;
+    return `leaderboard:${scope}:daily:${dateStr}:${currencyDisplay}${providerTypeSuffix}${userFilterSuffix}`;
   } else if (period === "weekly") {
     // leaderboard:{scope}:weekly:2025-W03:USD (ISO week)
     const weekStr = formatInTimeZone(now, tz, "yyyy-'W'ww");
-    return `leaderboard:${scope}:weekly:${weekStr}:${currencyDisplay}${providerTypeSuffix}`;
+    return `leaderboard:${scope}:weekly:${weekStr}:${currencyDisplay}${providerTypeSuffix}${userFilterSuffix}`;
   } else if (period === "monthly") {
     // leaderboard:{scope}:monthly:2025-01:USD
     const monthStr = formatInTimeZone(now, tz, "yyyy-MM");
-    return `leaderboard:${scope}:monthly:${monthStr}:${currencyDisplay}${providerTypeSuffix}`;
+    return `leaderboard:${scope}:monthly:${monthStr}:${currencyDisplay}${providerTypeSuffix}${userFilterSuffix}`;
   } else {
     // allTime: leaderboard:{scope}:allTime:USD (no date component)
-    return `leaderboard:${scope}:allTime:${currencyDisplay}${providerTypeSuffix}`;
+    return `leaderboard:${scope}:allTime:${currencyDisplay}${providerTypeSuffix}${userFilterSuffix}`;
   }
 }
 
@@ -89,10 +103,15 @@ async function queryDatabase(
   dateRange?: DateRangeParams,
   filters?: LeaderboardFilters
 ): Promise<LeaderboardData> {
+  const userFilters: UserLeaderboardFilters | undefined =
+    scope === "user" && (filters?.userTags?.length || filters?.userGroups?.length)
+      ? { userTags: filters.userTags, userGroups: filters.userGroups }
+      : undefined;
+
   // 处理自定义日期范围
   if (period === "custom" && dateRange) {
     if (scope === "user") {
-      return await findCustomRangeLeaderboard(dateRange);
+      return await findCustomRangeLeaderboard(dateRange, userFilters);
     }
     if (scope === "provider") {
       return await findCustomRangeProviderLeaderboard(dateRange, filters?.providerType);
@@ -106,15 +125,15 @@ async function queryDatabase(
   if (scope === "user") {
     switch (period) {
       case "daily":
-        return await findDailyLeaderboard();
+        return await findDailyLeaderboard(userFilters);
       case "weekly":
-        return await findWeeklyLeaderboard();
+        return await findWeeklyLeaderboard(userFilters);
       case "monthly":
-        return await findMonthlyLeaderboard();
+        return await findMonthlyLeaderboard(userFilters);
       case "allTime":
-        return await findAllTimeLeaderboard();
+        return await findAllTimeLeaderboard(userFilters);
       default:
-        return await findDailyLeaderboard();
+        return await findDailyLeaderboard(userFilters);
     }
   }
   if (scope === "provider") {

+ 63 - 18
src/repository/leaderboard.ts

@@ -19,6 +19,16 @@ export interface LeaderboardEntry {
   totalTokens: number;
 }
 
+/**
+ * 用户排行榜筛选参数
+ */
+export interface UserLeaderboardFilters {
+  /** 按用户标签筛选(OR 逻辑:匹配任一标签) */
+  userTags?: string[];
+  /** 按用户分组筛选(OR 逻辑:匹配任一分组) */
+  userGroups?: string[];
+}
+
 /**
  * 供应商排行榜条目类型
  */
@@ -62,35 +72,43 @@ export interface ModelLeaderboardEntry {
  * 查询今日消耗排行榜(不限制数量)
  * 使用 SQL AT TIME ZONE 进行时区转换,确保"今日"基于配置时区(Asia/Shanghai)
  */
-export async function findDailyLeaderboard(): Promise<LeaderboardEntry[]> {
+export async function findDailyLeaderboard(
+  userFilters?: UserLeaderboardFilters
+): Promise<LeaderboardEntry[]> {
   const timezone = getEnvConfig().TZ;
-  return findLeaderboardWithTimezone("daily", timezone);
+  return findLeaderboardWithTimezone("daily", timezone, undefined, userFilters);
 }
 
 /**
  * 查询本月消耗排行榜(不限制数量)
  * 使用 SQL AT TIME ZONE 进行时区转换,确保"本月"基于配置时区(Asia/Shanghai)
  */
-export async function findMonthlyLeaderboard(): Promise<LeaderboardEntry[]> {
+export async function findMonthlyLeaderboard(
+  userFilters?: UserLeaderboardFilters
+): Promise<LeaderboardEntry[]> {
   const timezone = getEnvConfig().TZ;
-  return findLeaderboardWithTimezone("monthly", timezone);
+  return findLeaderboardWithTimezone("monthly", timezone, undefined, userFilters);
 }
 
 /**
  * 查询本周消耗排行榜(不限制数量)
  * 使用 SQL AT TIME ZONE 进行时区转换,确保"本周"基于配置时区
  */
-export async function findWeeklyLeaderboard(): Promise<LeaderboardEntry[]> {
+export async function findWeeklyLeaderboard(
+  userFilters?: UserLeaderboardFilters
+): Promise<LeaderboardEntry[]> {
   const timezone = getEnvConfig().TZ;
-  return findLeaderboardWithTimezone("weekly", timezone);
+  return findLeaderboardWithTimezone("weekly", timezone, undefined, userFilters);
 }
 
 /**
  * 查询全部时间消耗排行榜(不限制数量)
  */
-export async function findAllTimeLeaderboard(): Promise<LeaderboardEntry[]> {
+export async function findAllTimeLeaderboard(
+  userFilters?: UserLeaderboardFilters
+): Promise<LeaderboardEntry[]> {
   const timezone = getEnvConfig().TZ;
-  return findLeaderboardWithTimezone("allTime", timezone);
+  return findLeaderboardWithTimezone("allTime", timezone, undefined, userFilters);
 }
 
 /**
@@ -151,8 +169,40 @@ function buildDateCondition(
 async function findLeaderboardWithTimezone(
   period: LeaderboardPeriod,
   timezone: string,
-  dateRange?: DateRangeParams
+  dateRange?: DateRangeParams,
+  userFilters?: UserLeaderboardFilters
 ): Promise<LeaderboardEntry[]> {
+  const whereConditions = [
+    isNull(messageRequest.deletedAt),
+    EXCLUDE_WARMUP_CONDITION,
+    buildDateCondition(period, timezone, dateRange),
+  ];
+
+  const normalizedTags = (userFilters?.userTags ?? []).map((t) => t.trim()).filter(Boolean);
+  let tagFilterCondition: ReturnType<typeof sql> | undefined;
+  if (normalizedTags.length > 0) {
+    const tagConditions = normalizedTags.map((tag) => sql`${users.tags} ? ${tag}`);
+    tagFilterCondition = sql`(${sql.join(tagConditions, sql` OR `)})`;
+  }
+
+  const normalizedGroups = (userFilters?.userGroups ?? []).map((g) => g.trim()).filter(Boolean);
+  let groupFilterCondition: ReturnType<typeof sql> | undefined;
+  if (normalizedGroups.length > 0) {
+    const groupConditions = normalizedGroups.map(
+      (group) =>
+        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);
+  }
+
   const rankings = await db
     .select({
       userId: messageRequest.userId,
@@ -171,13 +221,7 @@ async function findLeaderboardWithTimezone(
     })
     .from(messageRequest)
     .innerJoin(users, and(sql`${messageRequest.userId} = ${users.id}`, isNull(users.deletedAt)))
-    .where(
-      and(
-        isNull(messageRequest.deletedAt),
-        EXCLUDE_WARMUP_CONDITION,
-        buildDateCondition(period, timezone, dateRange)
-      )
-    )
+    .where(and(...whereConditions))
     .groupBy(messageRequest.userId, users.name)
     .orderBy(desc(sql`sum(${messageRequest.costUsd})`));
 
@@ -194,10 +238,11 @@ async function findLeaderboardWithTimezone(
  * 查询自定义日期范围消耗排行榜
  */
 export async function findCustomRangeLeaderboard(
-  dateRange: DateRangeParams
+  dateRange: DateRangeParams,
+  userFilters?: UserLeaderboardFilters
 ): Promise<LeaderboardEntry[]> {
   const timezone = getEnvConfig().TZ;
-  return findLeaderboardWithTimezone("custom", timezone, dateRange);
+  return findLeaderboardWithTimezone("custom", timezone, dateRange, userFilters);
 }
 
 /**