Browse Source

feat(quota): 添加倒计时 Hook 和限额标签组件扩展

- 新增 `useCountdown` 和 `useCountdownProgress` Hook,用于动态计算和显示倒计时
- 添加 `QuotaWindowType` 组件,支持不同限额窗口类型的显示和解释
- 扩展限额倒计时功能,支持状态样式和进度条展示
- 优化 Redis 的缓存策略,支持滚动窗口消费记录和数据恢复
- 批量聚合 Session 统计数据,解决 N+1 查询问题,提升性能
chenhongzhi 4 months ago
parent
commit
5522d0d710

+ 220 - 40
src/actions/active-sessions.ts

@@ -1,17 +1,56 @@
 "use server";
 
-import { SessionManager } from "@/lib/session-manager";
 import { logger } from "@/lib/logger";
 import type { ActionResult } from "./types";
 import type { ActiveSessionInfo } from "@/types/session";
+import {
+  getActiveSessionsCache,
+  setActiveSessionsCache,
+  getSessionDetailsCache,
+  setSessionDetailsCache,
+} from "@/lib/cache/session-cache";
 
 /**
- * 获取所有活跃 session 的详细信息(使用聚合数据)
+ * 获取所有活跃 session 的详细信息(使用聚合数据 + 批量查询 + 缓存
  * 用于实时监控页面
  */
 export async function getActiveSessions(): Promise<ActionResult<ActiveSessionInfo[]>> {
   try {
-    // 1. 从 SessionTracker 获取活跃 session ID 列表
+    // 1. 尝试从缓存获取
+    const cached = getActiveSessionsCache();
+    if (cached) {
+      logger.debug("[SessionCache] Active sessions cache hit");
+      return {
+        ok: true,
+        data: cached.map((s) => ({
+          sessionId: s.sessionId,
+          userName: s.userName,
+          userId: s.userId,
+          keyId: s.keyId,
+          keyName: s.keyName,
+          providerId: s.providers[0]?.id || null,
+          providerName: s.providers.map((p) => p.name).join(", ") || null,
+          model: s.models.join(", ") || null,
+          apiType: (s.apiType as "chat" | "codex") || "chat",
+          startTime: s.firstRequestAt ? new Date(s.firstRequestAt).getTime() : Date.now(),
+          inputTokens: s.totalInputTokens,
+          outputTokens: s.totalOutputTokens,
+          cacheCreationInputTokens: s.totalCacheCreationTokens,
+          cacheReadInputTokens: s.totalCacheReadTokens,
+          totalTokens:
+            s.totalInputTokens +
+            s.totalOutputTokens +
+            s.totalCacheCreationTokens +
+            s.totalCacheReadTokens,
+          costUsd: s.totalCostUsd,
+          status: "completed",
+          durationMs: s.totalDurationMs,
+          requestCount: s.requestCount,
+        })),
+      };
+    }
+
+    // 2. 从 SessionTracker 获取活跃 session ID 列表
     const { SessionTracker } = await import("@/lib/session-tracker");
     const sessionIds = await SessionTracker.getActiveSessions();
 
@@ -19,14 +58,143 @@ export async function getActiveSessions(): Promise<ActionResult<ActiveSessionInf
       return { ok: true, data: [] };
     }
 
-    // 2. 并行查询每个 session 的聚合数据
-    const { aggregateSessionStats } = await import("@/repository/message");
-    const sessionsData = await Promise.all(sessionIds.map((id) => aggregateSessionStats(id)));
+    // 3. 使用批量聚合查询(性能优化)
+    const { aggregateMultipleSessionStats } = await import("@/repository/message");
+    const sessionsData = await aggregateMultipleSessionStats(sessionIds);
+
+    // 4. 写入缓存
+    setActiveSessionsCache(sessionsData);
+
+    // 5. 转换格式
+    const sessions: ActiveSessionInfo[] = sessionsData.map((s) => ({
+      sessionId: s.sessionId,
+      userName: s.userName,
+      userId: s.userId,
+      keyId: s.keyId,
+      keyName: s.keyName,
+      providerId: s.providers[0]?.id || null,
+      providerName: s.providers.map((p) => p.name).join(", ") || null,
+      model: s.models.join(", ") || null,
+      apiType: (s.apiType as "chat" | "codex") || "chat",
+      startTime: s.firstRequestAt ? new Date(s.firstRequestAt).getTime() : Date.now(),
+      inputTokens: s.totalInputTokens,
+      outputTokens: s.totalOutputTokens,
+      cacheCreationInputTokens: s.totalCacheCreationTokens,
+      cacheReadInputTokens: s.totalCacheReadTokens,
+      totalTokens:
+        s.totalInputTokens +
+        s.totalOutputTokens +
+        s.totalCacheCreationTokens +
+        s.totalCacheReadTokens,
+      costUsd: s.totalCostUsd,
+      status: "completed",
+      durationMs: s.totalDurationMs,
+      requestCount: s.requestCount,
+    }));
+
+    logger.debug(
+      `[SessionCache] Active sessions fetched and cached, count: ${sessions.length}`
+    );
+
+    return { ok: true, data: sessions };
+  } catch (error) {
+    logger.error("Failed to get active sessions:", error);
+    return {
+      ok: false,
+      error: "获取活跃 session 失败",
+    };
+  }
+}
+
+/**
+ * 获取所有 session(包括活跃和非活跃的)
+ * 用于实时监控页面的完整视图
+ *
+ * ✅ 修复:统一使用数据库聚合查询,确保与其他页面数据一致
+ */
+export async function getAllSessions(): Promise<
+  ActionResult<{
+    active: ActiveSessionInfo[];
+    inactive: ActiveSessionInfo[];
+  }>
+> {
+  try {
+    // 1. 尝试从缓存获取(使用不同的 key)
+    const cacheKey = "all_sessions";
+    const cached = getActiveSessionsCache(cacheKey);
+    if (cached) {
+      logger.debug("[SessionCache] All sessions cache hit");
+
+      // 分离活跃和非活跃(5 分钟内有请求为活跃)
+      const now = Date.now();
+      const fiveMinutesAgo = now - 5 * 60 * 1000;
+
+      const active: ActiveSessionInfo[] = [];
+      const inactive: ActiveSessionInfo[] = [];
+
+      for (const s of cached) {
+        const lastRequestTime = s.lastRequestAt ? new Date(s.lastRequestAt).getTime() : 0;
+        const sessionInfo: ActiveSessionInfo = {
+          sessionId: s.sessionId,
+          userName: s.userName,
+          userId: s.userId,
+          keyId: s.keyId,
+          keyName: s.keyName,
+          providerId: s.providers[0]?.id || null,
+          providerName: s.providers.map((p) => p.name).join(", ") || null,
+          model: s.models.join(", ") || null,
+          apiType: (s.apiType as "chat" | "codex") || "chat",
+          startTime: s.firstRequestAt ? new Date(s.firstRequestAt).getTime() : Date.now(),
+          inputTokens: s.totalInputTokens,
+          outputTokens: s.totalOutputTokens,
+          cacheCreationInputTokens: s.totalCacheCreationTokens,
+          cacheReadInputTokens: s.totalCacheReadTokens,
+          totalTokens:
+            s.totalInputTokens +
+            s.totalOutputTokens +
+            s.totalCacheCreationTokens +
+            s.totalCacheReadTokens,
+          costUsd: s.totalCostUsd,
+          status: "completed",
+          durationMs: s.totalDurationMs,
+          requestCount: s.requestCount,
+        };
+
+        if (lastRequestTime >= fiveMinutesAgo) {
+          active.push(sessionInfo);
+        } else {
+          inactive.push(sessionInfo);
+        }
+      }
+
+      return { ok: true, data: { active, inactive } };
+    }
+
+    // 2. 从 Redis 获取所有 session ID(包括活跃和非活跃)
+    const { SessionManager } = await import("@/lib/session-manager");
+    const allSessionIds = await SessionManager.getAllSessionIds();
+
+    if (allSessionIds.length === 0) {
+      return { ok: true, data: { active: [], inactive: [] } };
+    }
+
+    // 3. 使用批量聚合查询(性能优化)
+    const { aggregateMultipleSessionStats } = await import("@/repository/message");
+    const sessionsData = await aggregateMultipleSessionStats(allSessionIds);
+
+    // 4. 写入缓存
+    setActiveSessionsCache(sessionsData, cacheKey);
+
+    // 5. 分离活跃和非活跃(5 分钟内有请求为活跃)
+    const now = Date.now();
+    const fiveMinutesAgo = now - 5 * 60 * 1000;
 
-    // 3. 过滤掉查询失败的 session,并转换格式
-    const sessions: ActiveSessionInfo[] = sessionsData
-      .filter((s): s is NonNullable<typeof s> => s !== null)
-      .map((s) => ({
+    const active: ActiveSessionInfo[] = [];
+    const inactive: ActiveSessionInfo[] = [];
+
+    for (const s of sessionsData) {
+      const lastRequestTime = s.lastRequestAt ? new Date(s.lastRequestAt).getTime() : 0;
+      const sessionInfo: ActiveSessionInfo = {
         sessionId: s.sessionId,
         userName: s.userName,
         userId: s.userId,
@@ -50,34 +218,20 @@ export async function getActiveSessions(): Promise<ActionResult<ActiveSessionInf
         status: "completed",
         durationMs: s.totalDurationMs,
         requestCount: s.requestCount,
-      }));
+      };
 
-    return { ok: true, data: sessions };
-  } catch (error) {
-    logger.error("Failed to get active sessions:", error);
-    return {
-      ok: false,
-      error: "获取活跃 session 失败",
-    };
-  }
-}
+      if (lastRequestTime >= fiveMinutesAgo) {
+        active.push(sessionInfo);
+      } else {
+        inactive.push(sessionInfo);
+      }
+    }
 
-/**
- * 获取所有 session(包括活跃和非活跃的)
- * 用于实时监控页面的完整视图
- */
-export async function getAllSessions(): Promise<
-  ActionResult<{
-    active: ActiveSessionInfo[];
-    inactive: ActiveSessionInfo[];
-  }>
-> {
-  try {
-    const sessions = await SessionManager.getAllSessionsWithExpiry();
-    return {
-      ok: true,
-      data: sessions,
-    };
+    logger.debug(
+      `[SessionCache] All sessions fetched and cached, active: ${active.length}, inactive: ${inactive.length}`
+    );
+
+    return { ok: true, data: { active, inactive } };
   } catch (error) {
     logger.error("Failed to get all sessions:", error);
     return {
@@ -93,6 +247,7 @@ export async function getAllSessions(): Promise<
  */
 export async function getSessionMessages(sessionId: string): Promise<ActionResult<unknown>> {
   try {
+    const { SessionManager } = await import("@/lib/session-manager");
     const messages = await SessionManager.getSessionMessages(sessionId);
     if (messages === null) {
       return {
@@ -119,6 +274,7 @@ export async function getSessionMessages(sessionId: string): Promise<ActionResul
  */
 export async function hasSessionMessages(sessionId: string): Promise<ActionResult<boolean>> {
   try {
+    const { SessionManager } = await import("@/lib/session-manager");
     const messages = await SessionManager.getSessionMessages(sessionId);
     return {
       ok: true,
@@ -136,6 +292,8 @@ export async function hasSessionMessages(sessionId: string): Promise<ActionResul
 /**
  * 获取 session 的完整详情(messages + response + 聚合统计)
  * 用于 session messages 详情页面
+ *
+ * ✅ 优化:添加缓存支持
  */
 export async function getSessionDetails(sessionId: string): Promise<
   ActionResult<{
@@ -147,12 +305,34 @@ export async function getSessionDetails(sessionId: string): Promise<
   }>
 > {
   try {
-    // 并行获取三项数据:messages, response, 聚合统计
-    const { aggregateSessionStats } = await import("@/repository/message");
-    const [messages, response, sessionStats] = await Promise.all([
+    // 1. 尝试从缓存获取统计数据
+    const cachedStats = getSessionDetailsCache(sessionId);
+
+    let sessionStats: Awaited<
+      ReturnType<typeof import("@/repository/message").aggregateSessionStats>
+    > | null;
+
+    if (cachedStats) {
+      logger.debug(`[SessionCache] Session details cache hit: ${sessionId}`);
+      sessionStats = cachedStats;
+    } else {
+      // 2. 从数据库查询
+      const { aggregateSessionStats } = await import("@/repository/message");
+      sessionStats = await aggregateSessionStats(sessionId);
+
+      // 3. 写入缓存
+      if (sessionStats) {
+        setSessionDetailsCache(sessionId, sessionStats);
+      }
+
+      logger.debug(`[SessionCache] Session details fetched and cached: ${sessionId}`);
+    }
+
+    // 4. 并行获取 messages 和 response(不缓存,因为这些数据较大)
+    const { SessionManager } = await import("@/lib/session-manager");
+    const [messages, response] = await Promise.all([
       SessionManager.getSessionMessages(sessionId),
       SessionManager.getSessionResponse(sessionId),
-      aggregateSessionStats(sessionId),
     ]);
 
     return {

+ 24 - 6
src/actions/keys.ts

@@ -213,9 +213,9 @@ export async function getKeysWithStatistics(
  */
 export async function getKeyLimitUsage(keyId: number): Promise<
   ActionResult<{
-    cost5h: { current: number; limit: number | null };
-    costWeekly: { current: number; limit: number | null };
-    costMonthly: { current: number; limit: number | null };
+    cost5h: { current: number; limit: number | null; resetAt?: Date };
+    costWeekly: { current: number; limit: number | null; resetAt?: Date };
+    costMonthly: { current: number; limit: number | null; resetAt?: Date };
     concurrentSessions: { current: number; limit: number };
   }>
 > {
@@ -238,6 +238,7 @@ export async function getKeyLimitUsage(keyId: number): Promise<
     // 动态导入 RateLimitService 避免循环依赖
     const { RateLimitService } = await import("@/lib/rate-limit");
     const { SessionTracker } = await import("@/lib/session-tracker");
+    const { getResetInfo } = await import("@/lib/rate-limit/time-utils");
 
     // 获取金额消费(优先 Redis,降级数据库)
     const [cost5h, costWeekly, costMonthly, concurrentSessions] = await Promise.all([
@@ -247,12 +248,29 @@ export async function getKeyLimitUsage(keyId: number): Promise<
       SessionTracker.getKeySessionCount(keyId),
     ]);
 
+    // 获取重置时间
+    const resetInfo5h = getResetInfo("5h");
+    const resetInfoWeekly = getResetInfo("weekly");
+    const resetInfoMonthly = getResetInfo("monthly");
+
     return {
       ok: true,
       data: {
-        cost5h: { current: cost5h, limit: key.limit5hUsd },
-        costWeekly: { current: costWeekly, limit: key.limitWeeklyUsd },
-        costMonthly: { current: costMonthly, limit: key.limitMonthlyUsd },
+        cost5h: {
+          current: cost5h,
+          limit: key.limit5hUsd,
+          resetAt: resetInfo5h.resetAt, // 滚动窗口无 resetAt
+        },
+        costWeekly: {
+          current: costWeekly,
+          limit: key.limitWeeklyUsd,
+          resetAt: resetInfoWeekly.resetAt,
+        },
+        costMonthly: {
+          current: costMonthly,
+          limit: key.limitMonthlyUsd,
+          resetAt: resetInfoMonthly.resetAt,
+        },
         concurrentSessions: {
           current: concurrentSessions,
           limit: key.limitConcurrentSessions || 0,

+ 23 - 3
src/app/dashboard/quotas/keys/_components/keys-quota-client.tsx

@@ -18,13 +18,15 @@ import type { CurrencyCode } from "@/lib/utils/currency";
 import { formatCurrency } from "@/lib/utils/currency";
 import { UserQuotaHeader } from "@/components/quota/user-quota-header";
 import { QuotaProgress } from "@/components/quota/quota-progress";
+import { QuotaWindowType } from "@/components/quota/quota-window-type";
+import { QuotaCountdownCompact } from "@/components/quota/quota-countdown";
 import { hasKeyQuotaSet, isUserExceeded, getUsageRate } from "@/lib/utils/quota-helpers";
 import { EditKeyQuotaDialog } from "./edit-key-quota-dialog";
 
 interface KeyQuota {
-  cost5h: { current: number; limit: number | null };
-  costWeekly: { current: number; limit: number | null };
-  costMonthly: { current: number; limit: number | null };
+  cost5h: { current: number; limit: number | null; resetAt?: Date };
+  costWeekly: { current: number; limit: number | null; resetAt?: Date };
+  costMonthly: { current: number; limit: number | null; resetAt?: Date };
   concurrentSessions: { current: number; limit: number };
 }
 
@@ -146,6 +148,10 @@ export function KeysQuotaClient({ users, currencyCode = "USD" }: KeysQuotaClient
                           <TableCell>
                             {hasKeyQuota && key.quota && key.quota.cost5h.limit !== null ? (
                               <div className="space-y-1">
+                                {/* 窗口类型标签 */}
+                                <div className="flex items-center justify-between mb-1">
+                                  <QuotaWindowType type="5h" size="sm" />
+                                </div>
                                 <div className="flex items-center gap-2">
                                   <span className="text-xs font-mono">
                                     {formatCurrency(key.quota.cost5h.current, currencyCode)}/
@@ -174,6 +180,13 @@ export function KeysQuotaClient({ users, currencyCode = "USD" }: KeysQuotaClient
                           <TableCell>
                             {hasKeyQuota && key.quota && key.quota.costWeekly.limit !== null ? (
                               <div className="space-y-1">
+                                {/* 窗口类型 + 倒计时 */}
+                                <div className="flex items-center justify-between mb-1">
+                                  <QuotaWindowType type="weekly" size="sm" />
+                                  {key.quota.costWeekly.resetAt && (
+                                    <QuotaCountdownCompact resetAt={key.quota.costWeekly.resetAt} />
+                                  )}
+                                </div>
                                 <div className="flex items-center gap-2">
                                   <span className="text-xs font-mono">
                                     {formatCurrency(key.quota.costWeekly.current, currencyCode)}/
@@ -202,6 +215,13 @@ export function KeysQuotaClient({ users, currencyCode = "USD" }: KeysQuotaClient
                           <TableCell>
                             {hasKeyQuota && key.quota && key.quota.costMonthly.limit !== null ? (
                               <div className="space-y-1">
+                                {/* 窗口类型 + 倒计时 */}
+                                <div className="flex items-center justify-between mb-1">
+                                  <QuotaWindowType type="monthly" size="sm" />
+                                  {key.quota.costMonthly.resetAt && (
+                                    <QuotaCountdownCompact resetAt={key.quota.costMonthly.resetAt} />
+                                  )}
+                                </div>
                                 <div className="flex items-center gap-2">
                                   <span className="text-xs font-mono">
                                     {formatCurrency(key.quota.costMonthly.current, currencyCode)}/

+ 184 - 0
src/components/quota/quota-countdown.tsx

@@ -0,0 +1,184 @@
+"use client";
+
+import { useCountdown } from "@/hooks/useCountdown";
+import { Clock, AlertTriangle } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+interface QuotaCountdownProps {
+  resetAt: Date | null;
+  label?: string;
+  className?: string;
+  showIcon?: boolean;
+  size?: "sm" | "md" | "lg";
+}
+
+/**
+ * 限额倒计时组件
+ *
+ * 根据剩余时间显示不同颜色:
+ * - 绿色(正常):> 24 小时
+ * - 黄色(预警):> 1 小时 && <= 24 小时
+ * - 橙色(警告):> 10 分钟 && <= 1 小时
+ * - 红色(紧急):<= 10 分钟
+ *
+ * @example
+ * ```tsx
+ * <QuotaCountdown
+ *   resetAt={resetTime}
+ *   label="重置倒计时"
+ *   showIcon
+ * />
+ * ```
+ */
+export function QuotaCountdown({
+  resetAt,
+  label = "重置",
+  className,
+  showIcon = true,
+  size = "md",
+}: QuotaCountdownProps) {
+  const countdown = useCountdown(resetAt);
+
+  // 根据剩余时间判断状态
+  const getStatus = () => {
+    if (countdown.isExpired) return "expired";
+    if (countdown.totalSeconds > 86400) return "normal"; // > 24h
+    if (countdown.totalSeconds > 3600) return "warning"; // > 1h
+    if (countdown.totalSeconds > 600) return "danger"; // > 10min
+    return "critical"; // <= 10min
+  };
+
+  const status = getStatus();
+
+  // 状态样式映射
+  const statusStyles = {
+    expired: "text-muted-foreground",
+    normal: "text-green-600 dark:text-green-400",
+    warning: "text-yellow-600 dark:text-yellow-400",
+    danger: "text-orange-600 dark:text-orange-400",
+    critical: "text-red-600 dark:text-red-400 animate-pulse",
+  };
+
+  // 尺寸样式
+  const sizeStyles = {
+    sm: "text-xs",
+    md: "text-sm",
+    lg: "text-base",
+  };
+
+  const iconSizes = {
+    sm: "h-3 w-3",
+    md: "h-4 w-4",
+    lg: "h-5 w-5",
+  };
+
+  // 选择图标
+  const Icon = status === "critical" ? AlertTriangle : Clock;
+
+  return (
+    <div className={cn("flex items-center gap-1.5", className)}>
+      {showIcon && <Icon className={cn(iconSizes[size], statusStyles[status])} />}
+      <div className={cn("flex flex-col", sizeStyles[size])}>
+        {label && <span className="text-muted-foreground">{label}:</span>}
+        <span className={cn("font-mono font-medium tabular-nums", statusStyles[status])}>
+          {countdown.formatted}
+        </span>
+      </div>
+    </div>
+  );
+}
+
+/**
+ * 简短倒计时组件(仅显示时间,无标签)
+ */
+export function QuotaCountdownCompact({
+  resetAt,
+  className,
+}: {
+  resetAt: Date | null;
+  className?: string;
+}) {
+  const countdown = useCountdown(resetAt);
+
+  const getStatus = () => {
+    if (countdown.isExpired) return "expired";
+    if (countdown.totalSeconds > 86400) return "normal";
+    if (countdown.totalSeconds > 3600) return "warning";
+    if (countdown.totalSeconds > 600) return "danger";
+    return "critical";
+  };
+
+  const status = getStatus();
+
+  const statusStyles = {
+    expired: "text-muted-foreground",
+    normal: "text-green-600 dark:text-green-400",
+    warning: "text-yellow-600 dark:text-yellow-400",
+    danger: "text-orange-600 dark:text-orange-400",
+    critical: "text-red-600 dark:text-red-400 animate-pulse",
+  };
+
+  return (
+    <span className={cn("font-mono text-xs font-medium tabular-nums", statusStyles[status], className)}>
+      {countdown.shortFormatted}
+    </span>
+  );
+}
+
+/**
+ * 带百分比进度条的倒计时
+ */
+export function QuotaCountdownWithProgress({
+  resetAt,
+  startAt,
+  label = "重置",
+  className,
+}: {
+  resetAt: Date | null;
+  startAt?: Date | null;
+  label?: string;
+  className?: string;
+}) {
+  const countdown = useCountdown(resetAt);
+
+  // 计算进度百分比
+  const getProgress = () => {
+    if (!resetAt || countdown.isExpired) return 100;
+
+    const now = new Date().getTime();
+    const target = new Date(resetAt).getTime();
+    const start = startAt ? new Date(startAt).getTime() : now;
+
+    const total = target - start;
+    const elapsed = now - start;
+
+    if (total <= 0) return 100;
+
+    return Math.min(100, Math.max(0, (elapsed / total) * 100));
+  };
+
+  const progress = getProgress();
+
+  // 根据进度判断颜色
+  const getProgressColor = () => {
+    if (progress >= 90) return "bg-red-500";
+    if (progress >= 75) return "bg-orange-500";
+    if (progress >= 50) return "bg-yellow-500";
+    return "bg-green-500";
+  };
+
+  return (
+    <div className={cn("space-y-1", className)}>
+      <div className="flex items-center justify-between">
+        <span className="text-xs text-muted-foreground">{label}</span>
+        <QuotaCountdownCompact resetAt={resetAt} />
+      </div>
+      <div className="h-1 w-full bg-muted rounded-full overflow-hidden">
+        <div
+          className={cn("h-full transition-all duration-300", getProgressColor())}
+          style={{ width: `${progress}%` }}
+        />
+      </div>
+    </div>
+  );
+}

+ 148 - 0
src/components/quota/quota-window-type.tsx

@@ -0,0 +1,148 @@
+"use client";
+
+import { Badge } from "@/components/ui/badge";
+import { RefreshCw, Calendar, CalendarDays, Clock } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+type WindowType = "5h" | "weekly" | "monthly" | "daily";
+
+interface WindowTypeConfig {
+  label: string;
+  description: string;
+  icon: typeof RefreshCw;
+  variant: "default" | "secondary" | "outline";
+  color: string;
+}
+
+const WINDOW_CONFIG: Record<WindowType, WindowTypeConfig> = {
+  "5h": {
+    label: "滚动窗口",
+    description: "统计过去5小时内的消费",
+    icon: RefreshCw,
+    variant: "default",
+    color: "text-blue-600 dark:text-blue-400",
+  },
+  weekly: {
+    label: "自然周",
+    description: "周一 00:00 重置",
+    icon: CalendarDays,
+    variant: "secondary",
+    color: "text-purple-600 dark:text-purple-400",
+  },
+  monthly: {
+    label: "自然月",
+    description: "每月1日 00:00 重置",
+    icon: Calendar,
+    variant: "secondary",
+    color: "text-green-600 dark:text-green-400",
+  },
+  daily: {
+    label: "自然日",
+    description: "每日 00:00 重置",
+    icon: Clock,
+    variant: "secondary",
+    color: "text-orange-600 dark:text-orange-400",
+  },
+};
+
+interface QuotaWindowTypeProps {
+  type: WindowType;
+  className?: string;
+  showIcon?: boolean;
+  showDescription?: boolean;
+  size?: "sm" | "md";
+}
+
+/**
+ * 限额窗口类型标签组件
+ *
+ * 显示不同时间窗口的类型和说明:
+ * - 5h: 滚动窗口(过去5小时)
+ * - weekly: 自然周(周一重置)
+ * - monthly: 自然月(每月1日重置)
+ * - daily: 自然日(每日重置)
+ *
+ * @example
+ * ```tsx
+ * <QuotaWindowType type="5h" showIcon showDescription />
+ * <QuotaWindowType type="weekly" showIcon />
+ * ```
+ */
+export function QuotaWindowType({
+  type,
+  className,
+  showIcon = true,
+  showDescription = false,
+  size = "sm",
+}: QuotaWindowTypeProps) {
+  const config = WINDOW_CONFIG[type];
+  const Icon = config.icon;
+
+  if (showDescription) {
+    return (
+      <div className={cn("flex items-center gap-2", className)}>
+        {showIcon && <Icon className={cn("h-4 w-4", config.color)} />}
+        <div className="flex flex-col">
+          <span className={cn("font-medium", size === "sm" ? "text-xs" : "text-sm")}>
+            {config.label}
+          </span>
+          <span className="text-xs text-muted-foreground">{config.description}</span>
+        </div>
+      </div>
+    );
+  }
+
+  return (
+    <Badge variant={config.variant} className={cn("gap-1", className)}>
+      {showIcon && <Icon className="h-3 w-3" />}
+      <span>{config.label}</span>
+    </Badge>
+  );
+}
+
+/**
+ * 简洁的窗口类型标签(仅文字)
+ */
+export function QuotaWindowTypeCompact({
+  type,
+  className,
+}: {
+  type: WindowType;
+  className?: string;
+}) {
+  const config = WINDOW_CONFIG[type];
+
+  return (
+    <span className={cn("text-xs text-muted-foreground", className)}>{config.label}</span>
+  );
+}
+
+/**
+ * 带工具提示的窗口类型标签
+ */
+export function QuotaWindowTypeWithTooltip({
+  type,
+  className,
+}: {
+  type: WindowType;
+  className?: string;
+}) {
+  const config = WINDOW_CONFIG[type];
+  const Icon = config.icon;
+
+  return (
+    <div
+      className={cn("group relative inline-flex items-center gap-1.5 cursor-help", className)}
+      title={config.description}
+    >
+      <Icon className={cn("h-3.5 w-3.5", config.color)} />
+      <span className="text-xs font-medium">{config.label}</span>
+
+      {/* Tooltip */}
+      <div className="invisible group-hover:visible absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1 bg-popover text-popover-foreground text-xs rounded shadow-md border whitespace-nowrap z-10">
+        {config.description}
+        <div className="absolute top-full left-1/2 -translate-x-1/2 -mt-px border-4 border-transparent border-t-popover" />
+      </div>
+    </div>
+  );
+}

+ 180 - 0
src/hooks/useCountdown.ts

@@ -0,0 +1,180 @@
+"use client";
+
+import { useState, useEffect, useMemo } from "react";
+
+interface CountdownResult {
+  days: number;
+  hours: number;
+  minutes: number;
+  seconds: number;
+  totalSeconds: number;
+  isExpired: boolean;
+  formatted: string; // 格式化输出:如 "1天 2小时 3分钟" 或 "2h 3m 45s"
+  shortFormatted: string; // 简短格式:如 "1d 2h 3m" 或 "2h 3m"
+}
+
+/**
+ * 计算倒计时
+ */
+function calculateCountdown(targetDate: Date | null): CountdownResult {
+  if (!targetDate) {
+    return {
+      days: 0,
+      hours: 0,
+      minutes: 0,
+      seconds: 0,
+      totalSeconds: 0,
+      isExpired: true,
+      formatted: "已过期",
+      shortFormatted: "0s",
+    };
+  }
+
+  const now = new Date().getTime();
+  const target = new Date(targetDate).getTime();
+  const diff = target - now;
+
+  // 已过期
+  if (diff <= 0) {
+    return {
+      days: 0,
+      hours: 0,
+      minutes: 0,
+      seconds: 0,
+      totalSeconds: 0,
+      isExpired: true,
+      formatted: "已过期",
+      shortFormatted: "0s",
+    };
+  }
+
+  const totalSeconds = Math.floor(diff / 1000);
+  const days = Math.floor(totalSeconds / 86400);
+  const hours = Math.floor((totalSeconds % 86400) / 3600);
+  const minutes = Math.floor((totalSeconds % 3600) / 60);
+  const seconds = totalSeconds % 60;
+
+  // 格式化输出
+  let formatted = "";
+  let shortFormatted = "";
+
+  if (days > 0) {
+    formatted = `${days}天 ${hours}小时 ${minutes}分钟`;
+    shortFormatted = `${days}d ${hours}h ${minutes}m`;
+  } else if (hours > 0) {
+    formatted = `${hours}小时 ${minutes}分钟 ${seconds}秒`;
+    shortFormatted = `${hours}h ${minutes}m ${seconds}s`;
+  } else if (minutes > 0) {
+    formatted = `${minutes}分钟 ${seconds}秒`;
+    shortFormatted = `${minutes}m ${seconds}s`;
+  } else {
+    formatted = `${seconds}秒`;
+    shortFormatted = `${seconds}s`;
+  }
+
+  return {
+    days,
+    hours,
+    minutes,
+    seconds,
+    totalSeconds,
+    isExpired: false,
+    formatted,
+    shortFormatted,
+  };
+}
+
+/**
+ * 倒计时 Hook
+ *
+ * @param targetDate - 目标时间
+ * @param enabled - 是否启用倒计时(默认 true)
+ * @returns 倒计时对象
+ *
+ * @example
+ * ```tsx
+ * const countdown = useCountdown(resetTime);
+ * return <div>{countdown.formatted}</div>;
+ * ```
+ */
+export function useCountdown(
+  targetDate: Date | null,
+  enabled: boolean = true
+): CountdownResult {
+  const [mounted, setMounted] = useState(false);
+  const [countdown, setCountdown] = useState<CountdownResult>(() =>
+    calculateCountdown(targetDate)
+  );
+
+  // 挂载状态(避免 SSR 不一致)
+  useEffect(() => {
+    setMounted(true);
+  }, []);
+
+  // 倒计时更新
+  useEffect(() => {
+    if (!enabled || !mounted) return;
+
+    // 初始计算
+    setCountdown(calculateCountdown(targetDate));
+
+    // 每秒更新
+    const interval = setInterval(() => {
+      setCountdown(calculateCountdown(targetDate));
+    }, 1000);
+
+    return () => clearInterval(interval);
+  }, [targetDate, enabled, mounted]);
+
+  // 未挂载时返回占位数据(避免 hydration 不一致)
+  if (!mounted) {
+    return {
+      days: 0,
+      hours: 0,
+      minutes: 0,
+      seconds: 0,
+      totalSeconds: 0,
+      isExpired: false,
+      formatted: "—",
+      shortFormatted: "—",
+    };
+  }
+
+  return countdown;
+}
+
+/**
+ * 倒计时百分比 Hook(用于进度条)
+ *
+ * @param targetDate - 目标时间
+ * @param startDate - 起始时间(默认为当前时间)
+ * @returns 进度百分比(0-100)
+ *
+ * @example
+ * ```tsx
+ * const progress = useCountdownProgress(resetTime, windowStartTime);
+ * return <ProgressBar value={progress} />;
+ * ```
+ */
+export function useCountdownProgress(
+  targetDate: Date | null,
+  startDate?: Date | null
+): number {
+  const countdown = useCountdown(targetDate);
+
+  return useMemo(() => {
+    if (!targetDate || countdown.isExpired) return 100;
+
+    const now = new Date().getTime();
+    const target = new Date(targetDate).getTime();
+    const start = startDate ? new Date(startDate).getTime() : now;
+
+    const total = target - start;
+    const elapsed = now - start;
+
+    if (total <= 0) return 100;
+
+    const progress = Math.min(100, Math.max(0, (elapsed / total) * 100));
+    return Math.round(progress);
+  }, [targetDate, startDate, countdown]);
+}

+ 195 - 0
src/lib/cache/session-cache.ts

@@ -0,0 +1,195 @@
+/**
+ * Session 数据缓存层
+ *
+ * 使用内存缓存减少数据库查询频率,适用于高频读取场景
+ */
+
+interface CacheEntry<T> {
+  data: T;
+  timestamp: number;
+}
+
+class SessionCache<T> {
+  private cache = new Map<string, CacheEntry<T>>();
+  private ttl: number; // TTL in milliseconds
+
+  constructor(ttlSeconds: number = 2) {
+    this.ttl = ttlSeconds * 1000;
+  }
+
+  /**
+   * 获取缓存数据
+   */
+  get(key: string): T | null {
+    const entry = this.cache.get(key);
+    if (!entry) {
+      return null;
+    }
+
+    const now = Date.now();
+    const age = now - entry.timestamp;
+
+    // 检查是否过期
+    if (age > this.ttl) {
+      this.cache.delete(key);
+      return null;
+    }
+
+    return entry.data;
+  }
+
+  /**
+   * 设置缓存数据
+   */
+  set(key: string, data: T): void {
+    this.cache.set(key, {
+      data,
+      timestamp: Date.now(),
+    });
+  }
+
+  /**
+   * 删除缓存数据
+   */
+  delete(key: string): void {
+    this.cache.delete(key);
+  }
+
+  /**
+   * 清空所有缓存
+   */
+  clear(): void {
+    this.cache.clear();
+  }
+
+  /**
+   * 清理过期的缓存条目
+   */
+  cleanup(): void {
+    const now = Date.now();
+    for (const [key, entry] of this.cache.entries()) {
+      const age = now - entry.timestamp;
+      if (age > this.ttl) {
+        this.cache.delete(key);
+      }
+    }
+  }
+
+  /**
+   * 获取缓存统计信息
+   */
+  getStats(): { size: number; ttl: number } {
+    return {
+      size: this.cache.size,
+      ttl: this.ttl / 1000,
+    };
+  }
+}
+
+// 活跃 Session 列表缓存(2 秒 TTL)
+const activeSessionsCache = new SessionCache<
+  Array<{
+    sessionId: string;
+    requestCount: number;
+    totalCostUsd: string;
+    totalInputTokens: number;
+    totalOutputTokens: number;
+    totalCacheCreationTokens: number;
+    totalCacheReadTokens: number;
+    totalDurationMs: number;
+    firstRequestAt: Date | null;
+    lastRequestAt: Date | null;
+    providers: Array<{ id: number; name: string }>;
+    models: string[];
+    userName: string;
+    userId: number;
+    keyName: string;
+    keyId: number;
+    userAgent: string | null;
+    apiType: string | null;
+  }>
+>(2);
+
+// Session 详情缓存(1 秒 TTL,更短因为单个 session 的数据变化更频繁)
+const sessionDetailsCache = new SessionCache<{
+  sessionId: string;
+  requestCount: number;
+  totalCostUsd: string;
+  totalInputTokens: number;
+  totalOutputTokens: number;
+  totalCacheCreationTokens: number;
+  totalCacheReadTokens: number;
+  totalDurationMs: number;
+  firstRequestAt: Date | null;
+  lastRequestAt: Date | null;
+  providers: Array<{ id: number; name: string }>;
+  models: string[];
+  userName: string;
+  userId: number;
+  keyName: string;
+  keyId: number;
+  userAgent: string | null;
+  apiType: string | null;
+}>(1);
+
+/**
+ * 获取活跃 Sessions 的缓存
+ */
+export function getActiveSessionsCache(key: string = "active_sessions") {
+  return activeSessionsCache.get(key);
+}
+
+/**
+ * 设置活跃 Sessions 的缓存
+ */
+export function setActiveSessionsCache(
+  data: Parameters<typeof activeSessionsCache.set>[1],
+  key: string = "active_sessions"
+) {
+  activeSessionsCache.set(key, data);
+}
+
+/**
+ * 获取 Session 详情的缓存
+ */
+export function getSessionDetailsCache(sessionId: string) {
+  return sessionDetailsCache.get(sessionId);
+}
+
+/**
+ * 设置 Session 详情的缓存
+ */
+export function setSessionDetailsCache(
+  sessionId: string,
+  data: Parameters<typeof sessionDetailsCache.set>[1]
+) {
+  sessionDetailsCache.set(sessionId, data);
+}
+
+/**
+ * 清空所有 Session 缓存
+ */
+export function clearAllSessionCache() {
+  activeSessionsCache.clear();
+  sessionDetailsCache.clear();
+}
+
+/**
+ * 定期清理过期缓存(可选,用于内存优化)
+ */
+export function startCacheCleanup(intervalSeconds: number = 60) {
+  setInterval(() => {
+    activeSessionsCache.cleanup();
+    sessionDetailsCache.cleanup();
+  }, intervalSeconds * 1000);
+}
+
+/**
+ * 获取缓存统计信息
+ */
+export function getCacheStats() {
+  return {
+    activeSessions: activeSessionsCache.getStats(),
+    sessionDetails: sessionDetailsCache.getStats(),
+  };
+}

+ 183 - 53
src/lib/rate-limit/service.ts

@@ -1,7 +1,11 @@
 import { getRedisClient } from "@/lib/redis";
 import { logger } from "@/lib/logger";
 import { SessionTracker } from "@/lib/session-tracker";
-import { CHECK_AND_TRACK_SESSION } from "@/lib/redis/lua-scripts";
+import {
+  CHECK_AND_TRACK_SESSION,
+  TRACK_COST_5H_ROLLING_WINDOW,
+  GET_COST_5H_ROLLING_WINDOW,
+} from "@/lib/redis/lua-scripts";
 import { sumUserCostToday } from "@/repository/statistics";
 import { getTimeRangeForPeriod, getTTLForPeriod, getSecondsUntilMidnight } from "./time-utils";
 
@@ -36,28 +40,47 @@ export class RateLimitService {
     try {
       // Fast Path: Redis 查询
       if (this.redis && this.redis.status === "ready") {
-        const pipeline = this.redis.pipeline();
+        const now = Date.now();
+        const window5h = 5 * 60 * 60 * 1000; // 5 hours in ms
+
         for (const limit of costLimits) {
           if (!limit.amount || limit.amount <= 0) continue;
-          pipeline.get(`${type}:${id}:cost_${limit.period}`);
-        }
-
-        const results = await pipeline.exec();
-        if (results) {
-          let index = 0;
-          for (const limit of costLimits) {
-            if (!limit.amount || limit.amount <= 0) continue;
 
-            const [err, value] = results[index] || [];
-            if (err) {
-              logger.error("[RateLimit] Redis error, fallback to database:", err);
-              // 出错时降级到数据库
+          let current = 0;
+
+          // 5h 使用滚动窗口 Lua 脚本
+          if (limit.period === "5h") {
+            try {
+              const key = `${type}:${id}:cost_5h_rolling`;
+              const result = (await this.redis.eval(
+                GET_COST_5H_ROLLING_WINDOW,
+                1, // KEYS count
+                key, // KEYS[1]
+                now.toString(), // ARGV[1]: now
+                window5h.toString() // ARGV[2]: window
+              )) as string;
+
+              current = parseFloat(result || "0");
+
+              // Cache Miss 检测:如果返回 0 但 Redis 中没有 key,从数据库恢复
+              if (current === 0) {
+                const exists = await this.redis.exists(key);
+                if (!exists) {
+                  logger.info(
+                    `[RateLimit] Cache miss for ${type}:${id}:cost_5h, querying database`
+                  );
+                  return await this.checkCostLimitsFromDatabase(id, type, costLimits);
+                }
+              }
+            } catch (error) {
+              logger.error("[RateLimit] 5h rolling window query failed, fallback to database:", error);
               return await this.checkCostLimitsFromDatabase(id, type, costLimits);
             }
+          } else {
+            // 周/月使用普通 GET
+            const value = await this.redis.get(`${type}:${id}:cost_${limit.period}`);
 
-            const current = parseFloat((value as string) || "0");
-
-            // Cache Miss 检测:如果 Redis 返回 null,从数据库恢复
+            // Cache Miss 检测
             if (value === null && limit.amount > 0) {
               logger.info(
                 `[RateLimit] Cache miss for ${type}:${id}:cost_${limit.period}, querying database`
@@ -65,18 +88,18 @@ export class RateLimitService {
               return await this.checkCostLimitsFromDatabase(id, type, costLimits);
             }
 
-            if (current >= limit.amount) {
-              return {
-                allowed: false,
-                reason: `${type === "key" ? "Key" : "供应商"} ${limit.name}消费上限已达到(${current.toFixed(4)}/${limit.amount})`,
-              };
-            }
-
-            index++;
+            current = parseFloat((value as string) || "0");
           }
 
-          return { allowed: true };
+          if (current >= limit.amount) {
+            return {
+              allowed: false,
+              reason: `${type === "key" ? "Key" : "供应商"} ${limit.name}消费上限已达到(${current.toFixed(4)}/${limit.amount})`,
+            };
+          }
         }
+
+        return { allowed: true };
       }
 
       // Slow Path: Redis 不可用,降级到数据库
@@ -112,14 +135,37 @@ export class RateLimitService {
           ? await sumKeyCostInTimeRange(id, startTime, endTime)
           : await sumProviderCostInTimeRange(id, startTime, endTime);
 
-      // Cache Warming: 写回 Redis(使用新的 TTL 计算)
+      // Cache Warming: 写回 Redis
       if (this.redis && this.redis.status === "ready") {
         try {
-          const ttl = getTTLForPeriod(limit.period);
-          await this.redis.set(`${type}:${id}:cost_${limit.period}`, current.toString(), "EX", ttl);
-          logger.info(
-            `[RateLimit] Cache warmed for ${type}:${id}:cost_${limit.period}, value=${current}, ttl=${ttl}s`
-          );
+          if (limit.period === "5h") {
+            // 5h 滚动窗口:使用 ZSET + Lua 脚本
+            if (current > 0) {
+              const now = Date.now();
+              const window5h = 5 * 60 * 60 * 1000;
+              const key = `${type}:${id}:cost_5h_rolling`;
+
+              await this.redis.eval(
+                TRACK_COST_5H_ROLLING_WINDOW,
+                1,
+                key,
+                current.toString(),
+                now.toString(),
+                window5h.toString()
+              );
+
+              logger.info(
+                `[RateLimit] Cache warmed for ${key}, value=${current} (rolling window)`
+              );
+            }
+          } else {
+            // 周/月固定窗口:使用 STRING + 动态 TTL
+            const ttl = getTTLForPeriod(limit.period);
+            await this.redis.set(`${type}:${id}:cost_${limit.period}`, current.toString(), "EX", ttl);
+            logger.info(
+              `[RateLimit] Cache warmed for ${type}:${id}:cost_${limit.period}, value=${current}, ttl=${ttl}s`
+            );
+          }
         } catch (error) {
           logger.error("[RateLimit] Failed to warm cache:", error);
         }
@@ -234,7 +280,7 @@ export class RateLimitService {
 
   /**
    * 累加消费(请求结束后调用)
-   * 使用动态 TTL 以支持自然时间窗口(周一/月初
+   * 5h 使用滚动窗口(ZSET),周/月使用固定窗口(STRING
    */
   static async trackCost(
     keyId: number,
@@ -245,27 +291,45 @@ export class RateLimitService {
     if (!this.redis || cost <= 0) return;
 
     try {
-      // 计算动态 TTL
-      const ttl5h = getTTLForPeriod("5h");
+      const now = Date.now();
+      const window5h = 5 * 60 * 60 * 1000; // 5 hours in ms
+
+      // 计算动态 TTL(周/月)
       const ttlWeekly = getTTLForPeriod("weekly");
       const ttlMonthly = getTTLForPeriod("monthly");
 
-      const pipeline = this.redis.pipeline();
+      // 1. 5h 滚动窗口:使用 Lua 脚本(ZSET)
+      // Key 的 5h 滚动窗口
+      await this.redis.eval(
+        TRACK_COST_5H_ROLLING_WINDOW,
+        1, // KEYS count
+        `key:${keyId}:cost_5h_rolling`, // KEYS[1]
+        cost.toString(), // ARGV[1]: cost
+        now.toString(), // ARGV[2]: now
+        window5h.toString() // ARGV[3]: window
+      );
 
-      // 1. 累加 Key 消费
-      pipeline.incrbyfloat(`key:${keyId}:cost_5h`, cost);
-      pipeline.expire(`key:${keyId}:cost_5h`, ttl5h);
+      // Provider 的 5h 滚动窗口
+      await this.redis.eval(
+        TRACK_COST_5H_ROLLING_WINDOW,
+        1,
+        `provider:${providerId}:cost_5h_rolling`,
+        cost.toString(),
+        now.toString(),
+        window5h.toString()
+      );
 
+      // 2. 周/月固定窗口:使用 STRING + 动态 TTL
+      const pipeline = this.redis.pipeline();
+
+      // Key 的周/月消费
       pipeline.incrbyfloat(`key:${keyId}:cost_weekly`, cost);
       pipeline.expire(`key:${keyId}:cost_weekly`, ttlWeekly);
 
       pipeline.incrbyfloat(`key:${keyId}:cost_monthly`, cost);
       pipeline.expire(`key:${keyId}:cost_monthly`, ttlMonthly);
 
-      // 2. 累加 Provider 消费
-      pipeline.incrbyfloat(`provider:${providerId}:cost_5h`, cost);
-      pipeline.expire(`provider:${providerId}:cost_5h`, ttl5h);
-
+      // Provider 的周/月消费
       pipeline.incrbyfloat(`provider:${providerId}:cost_weekly`, cost);
       pipeline.expire(`provider:${providerId}:cost_weekly`, ttlWeekly);
 
@@ -273,6 +337,10 @@ export class RateLimitService {
       pipeline.expire(`provider:${providerId}:cost_monthly`, ttlMonthly);
 
       await pipeline.exec();
+
+      logger.debug(
+        `[RateLimit] Tracked cost: key=${keyId}, provider=${providerId}, cost=${cost}`
+      );
     } catch (error) {
       logger.error("[RateLimit] Track cost failed:", error);
       // 不抛出错误,静默失败
@@ -291,15 +359,49 @@ export class RateLimitService {
     try {
       // Fast Path: Redis 查询
       if (this.redis && this.redis.status === "ready") {
-        const value = await this.redis.get(`${type}:${id}:cost_${period}`);
+        let current = 0;
+
+        // 5h 使用滚动窗口 Lua 脚本
+        if (period === "5h") {
+          const now = Date.now();
+          const window5h = 5 * 60 * 60 * 1000;
+          const key = `${type}:${id}:cost_5h_rolling`;
+
+          const result = (await this.redis.eval(
+            GET_COST_5H_ROLLING_WINDOW,
+            1,
+            key,
+            now.toString(),
+            window5h.toString()
+          )) as string;
+
+          current = parseFloat(result || "0");
+
+          // Cache Hit
+          if (current > 0) {
+            return current;
+          }
 
-        // Cache Hit
-        if (value !== null) {
-          return parseFloat(value || "0");
-        }
+          // Cache Miss 检测:如果返回 0 但 Redis 中没有 key,从数据库恢复
+          const exists = await this.redis.exists(key);
+          if (!exists) {
+            logger.info(`[RateLimit] Cache miss for ${type}:${id}:cost_5h, querying database`);
+          } else {
+            // Key 存在但值为 0,说明真的是 0
+            return 0;
+          }
+        } else {
+          // 周/月使用普通 GET
+          const value = await this.redis.get(`${type}:${id}:cost_${period}`);
 
-        // Cache Miss: 从数据库恢复
-        logger.info(`[RateLimit] Cache miss for ${type}:${id}:cost_${period}, querying database`);
+          // Cache Hit
+          if (value !== null) {
+            return parseFloat(value || "0");
+          }
+
+          // Cache Miss: 从数据库恢复
+          logger.info(`[RateLimit] Cache miss for ${type}:${id}:cost_${period}, querying database`);
+        }
       } else {
         logger.warn(`[RateLimit] Redis unavailable, querying database for ${type} cost`);
       }
@@ -315,11 +417,39 @@ export class RateLimitService {
           ? await sumKeyCostInTimeRange(id, startTime, endTime)
           : await sumProviderCostInTimeRange(id, startTime, endTime);
 
-      // Cache Warming: 写回 Redis(使用新的 TTL 计算)
+      // Cache Warming: 写回 Redis
       if (this.redis && this.redis.status === "ready") {
         try {
-          const ttl = getTTLForPeriod(period);
-          await this.redis.set(`${type}:${id}:cost_${period}`, current.toString(), "EX", ttl);
+          if (period === "5h") {
+            // 5h 滚动窗口:需要将历史数据转换为 ZSET 格式
+            // 由于无法精确知道每次消费的时间戳,使用当前时间作为近似
+            if (current > 0) {
+              const now = Date.now();
+              const window5h = 5 * 60 * 60 * 1000;
+              const key = `${type}:${id}:cost_5h_rolling`;
+
+              // 将数据库查询到的总额作为单条记录写入
+              await this.redis.eval(
+                TRACK_COST_5H_ROLLING_WINDOW,
+                1,
+                key,
+                current.toString(),
+                now.toString(),
+                window5h.toString()
+              );
+
+              logger.info(
+                `[RateLimit] Cache warmed for ${key}, value=${current} (rolling window)`
+              );
+            }
+          } else {
+            // 周/月固定窗口:使用 STRING + 动态 TTL
+            const ttl = getTTLForPeriod(period);
+            await this.redis.set(`${type}:${id}:cost_${period}`, current.toString(), "EX", ttl);
+            logger.info(
+              `[RateLimit] Cache warmed for ${type}:${id}:cost_${period}, value=${current}, ttl=${ttl}s`
+            );
+          }
         } catch (error) {
           logger.error("[RateLimit] Failed to warm cache:", error);
         }

+ 80 - 0
src/lib/redis/lua-scripts.ts

@@ -100,3 +100,83 @@ end
 
 return results
 `;
+
+/**
+ * 追踪 5小时滚动窗口消费(使用 ZSET)
+ *
+ * 功能:
+ * 1. 清理 5 小时前的消费记录
+ * 2. 添加当前消费记录(带时间戳)
+ * 3. 计算当前窗口内的总消费
+ * 4. 设置兜底 TTL(6 小时)
+ *
+ * KEYS[1]: key:${id}:cost_5h_rolling 或 provider:${id}:cost_5h_rolling
+ * ARGV[1]: cost(本次消费金额)
+ * ARGV[2]: now(当前时间戳,毫秒)
+ * ARGV[3]: window(窗口时长,毫秒,默认 18000000 = 5小时)
+ *
+ * 返回值:string - 当前窗口内的总消费
+ */
+export const TRACK_COST_5H_ROLLING_WINDOW = `
+local key = KEYS[1]
+local cost = tonumber(ARGV[1])
+local now_ms = tonumber(ARGV[2])
+local window_ms = tonumber(ARGV[3])  -- 5 hours = 18000000 ms
+
+-- 1. 清理过期记录(5 小时前的数据)
+redis.call('ZREMRANGEBYSCORE', key, '-inf', now_ms - window_ms)
+
+-- 2. 添加当前消费记录(member = timestamp:cost,便于调试和追踪)
+local member = now_ms .. ':' .. cost
+redis.call('ZADD', key, now_ms, member)
+
+-- 3. 计算窗口内总消费
+local records = redis.call('ZRANGE', key, 0, -1)
+local total = 0
+for _, record in ipairs(records) do
+  -- 解析 member 格式:"timestamp:cost"
+  local cost_str = string.match(record, ':(.+)')
+  if cost_str then
+    total = total + tonumber(cost_str)
+  end
+end
+
+-- 4. 设置兜底 TTL(6 小时,防止数据永久堆积)
+redis.call('EXPIRE', key, 21600)
+
+return tostring(total)
+`;
+
+/**
+ * 查询 5小时滚动窗口当前消费
+ *
+ * 功能:
+ * 1. 清理 5 小时前的消费记录
+ * 2. 计算当前窗口内的总消费
+ *
+ * KEYS[1]: key:${id}:cost_5h_rolling 或 provider:${id}:cost_5h_rolling
+ * ARGV[1]: now(当前时间戳,毫秒)
+ * ARGV[2]: window(窗口时长,毫秒,默认 18000000 = 5小时)
+ *
+ * 返回值:string - 当前窗口内的总消费
+ */
+export const GET_COST_5H_ROLLING_WINDOW = `
+local key = KEYS[1]
+local now_ms = tonumber(ARGV[1])
+local window_ms = tonumber(ARGV[2])  -- 5 hours = 18000000 ms
+
+-- 1. 清理过期记录
+redis.call('ZREMRANGEBYSCORE', key, '-inf', now_ms - window_ms)
+
+-- 2. 计算窗口内总消费
+local records = redis.call('ZRANGE', key, 0, -1)
+local total = 0
+for _, record in ipairs(records) do
+  local cost_str = string.match(record, ':(.+)')
+  if cost_str then
+    total = total + tonumber(cost_str)
+  end
+end
+
+return tostring(total)
+`;

+ 46 - 0
src/lib/session-manager.ts

@@ -966,6 +966,52 @@ export class SessionManager {
     }
   }
 
+  /**
+   * 获取所有 session ID 列表(轻量级版本)
+   * 仅返回 session ID,不返回详细信息
+   *
+   * @returns session ID 数组
+   */
+  static async getAllSessionIds(): Promise<string[]> {
+    const redis = getRedisClient();
+    if (!redis || redis.status !== "ready") {
+      logger.warn("SessionManager: Redis not ready, returning empty list");
+      return [];
+    }
+
+    try {
+      const sessionIds: string[] = [];
+      let cursor = "0";
+
+      do {
+        const [nextCursor, keys] = (await redis.scan(
+          cursor,
+          "MATCH",
+          "session:*:info",
+          "COUNT",
+          100
+        )) as [string, string[]];
+
+        cursor = nextCursor;
+
+        if (keys.length > 0) {
+          // 提取 sessionId
+          for (const key of keys) {
+            const sessionId = key.replace("session:", "").replace(":info", "");
+            sessionIds.push(sessionId);
+          }
+        }
+      } while (cursor !== "0");
+
+      logger.trace(`SessionManager: Found ${sessionIds.length} session IDs`);
+
+      return sessionIds;
+    } catch (error) {
+      logger.error("SessionManager: Failed to get session IDs", { error });
+      return [];
+    }
+  }
+
   /**
    * 获取 session 的 messages 内容
    */

+ 183 - 0
src/repository/message.ts

@@ -314,6 +314,189 @@ export async function aggregateSessionStats(sessionId: string): Promise<{
   };
 }
 
+/**
+ * 批量聚合多个 session 的统计数据(性能优化版本)
+ *
+ * 使用单次 SQL 查询获取所有 session 的聚合数据,避免 N+1 查询问题
+ *
+ * @param sessionIds - Session ID 列表
+ * @returns 聚合统计数据数组
+ */
+export async function aggregateMultipleSessionStats(
+  sessionIds: string[]
+): Promise<
+  Array<{
+    sessionId: string;
+    requestCount: number;
+    totalCostUsd: string;
+    totalInputTokens: number;
+    totalOutputTokens: number;
+    totalCacheCreationTokens: number;
+    totalCacheReadTokens: number;
+    totalDurationMs: number;
+    firstRequestAt: Date | null;
+    lastRequestAt: Date | null;
+    providers: Array<{ id: number; name: string }>;
+    models: string[];
+    userName: string;
+    userId: number;
+    keyName: string;
+    keyId: number;
+    userAgent: string | null;
+    apiType: string | null;
+  }>
+> {
+  if (sessionIds.length === 0) {
+    return [];
+  }
+
+  // 1. 批量聚合统计(单次查询)
+  const statsResults = await db
+    .select({
+      sessionId: messageRequest.sessionId,
+      requestCount: sql<number>`count(*)::int`,
+      totalCostUsd: sql<string>`COALESCE(sum(${messageRequest.costUsd}), 0)`,
+      totalInputTokens: sql<number>`COALESCE(sum(${messageRequest.inputTokens}), 0)::int`,
+      totalOutputTokens: sql<number>`COALESCE(sum(${messageRequest.outputTokens}), 0)::int`,
+      totalCacheCreationTokens: sql<number>`COALESCE(sum(${messageRequest.cacheCreationInputTokens}), 0)::int`,
+      totalCacheReadTokens: sql<number>`COALESCE(sum(${messageRequest.cacheReadInputTokens}), 0)::int`,
+      totalDurationMs: sql<number>`COALESCE(sum(${messageRequest.durationMs}), 0)::int`,
+      firstRequestAt: sql<Date>`min(${messageRequest.createdAt})`,
+      lastRequestAt: sql<Date>`max(${messageRequest.createdAt})`,
+    })
+    .from(messageRequest)
+    .where(and(sql`${messageRequest.sessionId} = ANY(${sessionIds})`, isNull(messageRequest.deletedAt)))
+    .groupBy(messageRequest.sessionId);
+
+  // 创建 sessionId → stats 的 Map
+  const statsMap = new Map(statsResults.map((s) => [s.sessionId, s]));
+
+  // 2. 批量查询供应商列表(按 session 分组)
+  const providerResults = await db
+    .selectDistinct({
+      sessionId: messageRequest.sessionId,
+      providerId: messageRequest.providerId,
+      providerName: providers.name,
+    })
+    .from(messageRequest)
+    .leftJoin(providers, eq(messageRequest.providerId, providers.id))
+    .where(
+      and(
+        sql`${messageRequest.sessionId} = ANY(${sessionIds})`,
+        isNull(messageRequest.deletedAt),
+        sql`${messageRequest.providerId} IS NOT NULL`
+      )
+    );
+
+  // 创建 sessionId → providers 的 Map
+  const providersMap = new Map<string, Array<{ id: number; name: string }>>();
+  for (const p of providerResults) {
+    // 跳过 null sessionId(虽然 WHERE 条件已过滤,但需要满足 TypeScript 类型检查)
+    if (!p.sessionId) continue;
+
+    if (!providersMap.has(p.sessionId)) {
+      providersMap.set(p.sessionId, []);
+    }
+    providersMap.get(p.sessionId)!.push({
+      id: p.providerId!,
+      name: p.providerName || "未知",
+    });
+  }
+
+  // 3. 批量查询模型列表(按 session 分组)
+  const modelResults = await db
+    .selectDistinct({
+      sessionId: messageRequest.sessionId,
+      model: messageRequest.model,
+    })
+    .from(messageRequest)
+    .where(
+      and(
+        sql`${messageRequest.sessionId} = ANY(${sessionIds})`,
+        isNull(messageRequest.deletedAt),
+        sql`${messageRequest.model} IS NOT NULL`
+      )
+    );
+
+  // 创建 sessionId → models 的 Map
+  const modelsMap = new Map<string, string[]>();
+  for (const m of modelResults) {
+    // 跳过 null sessionId(虽然 WHERE 条件已过滤,但需要满足 TypeScript 类型检查)
+    if (!m.sessionId) continue;
+
+    if (!modelsMap.has(m.sessionId)) {
+      modelsMap.set(m.sessionId, []);
+    }
+    modelsMap.get(m.sessionId)!.push(m.model!);
+  }
+
+  // 4. 批量获取用户信息(每个 session 的第一条请求)
+  // 使用 DISTINCT ON + ORDER BY 优化
+  const userInfoResults = await db
+    .select({
+      sessionId: messageRequest.sessionId,
+      userName: users.name,
+      userId: users.id,
+      keyName: keysTable.name,
+      keyId: keysTable.id,
+      userAgent: messageRequest.userAgent,
+      apiType: messageRequest.apiType,
+      createdAt: messageRequest.createdAt,
+    })
+    .from(messageRequest)
+    .innerJoin(users, eq(messageRequest.userId, users.id))
+    .innerJoin(keysTable, eq(messageRequest.key, keysTable.key))
+    .where(and(sql`${messageRequest.sessionId} = ANY(${sessionIds})`, isNull(messageRequest.deletedAt)))
+    .orderBy(messageRequest.sessionId, messageRequest.createdAt);
+
+  // 创建 sessionId → userInfo 的 Map(取每个 session 最早的记录)
+  const userInfoMap = new Map<string, (typeof userInfoResults)[0]>();
+  for (const info of userInfoResults) {
+    // 跳过 null sessionId(虽然 WHERE 条件已过滤,但需要满足 TypeScript 类型检查)
+    if (!info.sessionId) continue;
+
+    if (!userInfoMap.has(info.sessionId)) {
+      userInfoMap.set(info.sessionId, info);
+    }
+  }
+
+  // 5. 组装最终结果
+  const results: Awaited<ReturnType<typeof aggregateMultipleSessionStats>> = [];
+
+  for (const sessionId of sessionIds) {
+    const stats = statsMap.get(sessionId);
+    const userInfo = userInfoMap.get(sessionId);
+
+    // 跳过没有数据的 session
+    if (!stats || !userInfo || stats.requestCount === 0) {
+      continue;
+    }
+
+    results.push({
+      sessionId,
+      requestCount: stats.requestCount,
+      totalCostUsd: stats.totalCostUsd,
+      totalInputTokens: stats.totalInputTokens,
+      totalOutputTokens: stats.totalOutputTokens,
+      totalCacheCreationTokens: stats.totalCacheCreationTokens,
+      totalCacheReadTokens: stats.totalCacheReadTokens,
+      totalDurationMs: stats.totalDurationMs,
+      firstRequestAt: stats.firstRequestAt,
+      lastRequestAt: stats.lastRequestAt,
+      providers: providersMap.get(sessionId) || [],
+      models: modelsMap.get(sessionId) || [],
+      userName: userInfo.userName,
+      userId: userInfo.userId,
+      keyName: userInfo.keyName,
+      keyId: userInfo.keyId,
+      userAgent: userInfo.userAgent,
+      apiType: userInfo.apiType,
+    });
+  }
+
+  return results;
+}
+
 /**
  * 查询使用日志(支持分页、时间筛选、模型筛选)
  */