소스 검색

Fix user rate limit enforcement (#643)

* Fix user rate limit enforcement

* chore: format code (fix-rate-limit-user-limits-d46e1e6)

* Fix i18n errors and user limit parsing

* chore: format code (fix-rate-limit-user-limits-fd82bf6)

* fix: resolve TypeScript type conversion error in transformers test (#645)

Fixed TypeScript error TS2352 by adding intermediate 'unknown' cast
when converting User type to Record<string, unknown> for dynamic
field access in test assertions.

Error: Conversion of type 'User' to type 'Record<string, unknown>'
may be a mistake because neither type sufficiently overlaps with
the other.

Solution: Added intermediate cast to 'unknown' as suggested by
TypeScript compiler.
Ding 3 주 전
부모
커밋
085c86bfee

+ 6 - 0
messages/en/errors.json

@@ -74,6 +74,12 @@
   "UPDATE_KEY_FAILED": "Failed to update key, please try again later",
   "DELETE_KEY_FAILED": "Failed to delete key, please try again later",
   "CANNOT_DISABLE_LAST_KEY": "Cannot disable the last active key. Users must have at least one enabled key",
+  "KEY_LIMIT_5H_EXCEEDS_USER_LIMIT": "Key 5-hour limit ({keyLimit}) cannot exceed user limit ({userLimit})",
+  "KEY_LIMIT_DAILY_EXCEEDS_USER_LIMIT": "Key daily limit ({keyLimit}) cannot exceed user limit ({userLimit})",
+  "KEY_LIMIT_WEEKLY_EXCEEDS_USER_LIMIT": "Key weekly limit ({keyLimit}) cannot exceed user limit ({userLimit})",
+  "KEY_LIMIT_MONTHLY_EXCEEDS_USER_LIMIT": "Key monthly limit ({keyLimit}) cannot exceed user limit ({userLimit})",
+  "KEY_LIMIT_TOTAL_EXCEEDS_USER_LIMIT": "Key total limit ({keyLimit}) cannot exceed user limit ({userLimit})",
+  "KEY_LIMIT_CONCURRENT_EXCEEDS_USER_LIMIT": "Key concurrent session limit ({keyLimit}) cannot exceed user limit ({userLimit})",
   "NO_DEFAULT_GROUP_PERMISSION": "No permission to use default group. You don't have a Key with default group",
   "NO_GROUP_PERMISSION": "No permission to use the following groups: {groups}"
 }

+ 6 - 0
messages/ja/errors.json

@@ -63,6 +63,12 @@
   "UPDATE_KEY_FAILED": "キーの更新に失敗しました。後でもう一度お試しください",
   "DELETE_KEY_FAILED": "キーの削除に失敗しました。後でもう一度お試しください",
   "CANNOT_DISABLE_LAST_KEY": "最後のアクティブなキーを無効にすることはできません。ユーザーには少なくとも1つの有効なキーが必要です",
+  "KEY_LIMIT_5H_EXCEEDS_USER_LIMIT": "キーの5時間上限({keyLimit})はユーザー上限({userLimit})を超えられません",
+  "KEY_LIMIT_DAILY_EXCEEDS_USER_LIMIT": "キーの日次上限({keyLimit})はユーザー上限({userLimit})を超えられません",
+  "KEY_LIMIT_WEEKLY_EXCEEDS_USER_LIMIT": "キーの週次上限({keyLimit})はユーザー上限({userLimit})を超えられません",
+  "KEY_LIMIT_MONTHLY_EXCEEDS_USER_LIMIT": "キーの月次上限({keyLimit})はユーザー上限({userLimit})を超えられません",
+  "KEY_LIMIT_TOTAL_EXCEEDS_USER_LIMIT": "キーの総上限({keyLimit})はユーザー上限({userLimit})を超えられません",
+  "KEY_LIMIT_CONCURRENT_EXCEEDS_USER_LIMIT": "キーの同時セッション上限({keyLimit})はユーザー上限({userLimit})を超えられません",
   "EXPIRES_AT_FIELD": "有効期限",
   "EXPIRES_AT_MUST_BE_FUTURE": "有効期限は将来の日付である必要があります",
   "EXPIRES_AT_TOO_FAR": "有効期限は10年を超えることはできません",

+ 6 - 0
messages/ru/errors.json

@@ -63,6 +63,12 @@
   "UPDATE_KEY_FAILED": "Не удалось обновить ключ, попробуйте позже",
   "DELETE_KEY_FAILED": "Не удалось удалить ключ, попробуйте позже",
   "CANNOT_DISABLE_LAST_KEY": "Невозможно отключить последний активный ключ. У пользователя должен быть хотя бы один включенный ключ",
+  "KEY_LIMIT_5H_EXCEEDS_USER_LIMIT": "Лимит ключа на 5 часов ({keyLimit}) не может превышать лимит пользователя ({userLimit})",
+  "KEY_LIMIT_DAILY_EXCEEDS_USER_LIMIT": "Дневной лимит ключа ({keyLimit}) не может превышать лимит пользователя ({userLimit})",
+  "KEY_LIMIT_WEEKLY_EXCEEDS_USER_LIMIT": "Недельный лимит ключа ({keyLimit}) не может превышать лимит пользователя ({userLimit})",
+  "KEY_LIMIT_MONTHLY_EXCEEDS_USER_LIMIT": "Месячный лимит ключа ({keyLimit}) не может превышать лимит пользователя ({userLimit})",
+  "KEY_LIMIT_TOTAL_EXCEEDS_USER_LIMIT": "Общий лимит ключа ({keyLimit}) не может превышать лимит пользователя ({userLimit})",
+  "KEY_LIMIT_CONCURRENT_EXCEEDS_USER_LIMIT": "Лимит одновременных сессий ключа ({keyLimit}) не может превышать лимит пользователя ({userLimit})",
   "EXPIRES_AT_FIELD": "Дата истечения",
   "EXPIRES_AT_MUST_BE_FUTURE": "Дата истечения должна быть в будущем",
   "EXPIRES_AT_TOO_FAR": "Дата истечения не может превышать 10 лет",

+ 6 - 0
messages/zh-CN/errors.json

@@ -73,6 +73,12 @@
   "UPDATE_KEY_FAILED": "更新密钥失败,请稍后重试",
   "DELETE_KEY_FAILED": "删除密钥失败,请稍后重试",
   "CANNOT_DISABLE_LAST_KEY": "无法禁用最后一个可用密钥,用户至少需要保留一个启用的密钥",
+  "KEY_LIMIT_5H_EXCEEDS_USER_LIMIT": "Key的5小时消费上限({keyLimit})不能超过用户限额({userLimit})",
+  "KEY_LIMIT_DAILY_EXCEEDS_USER_LIMIT": "Key的日消费上限({keyLimit})不能超过用户限额({userLimit})",
+  "KEY_LIMIT_WEEKLY_EXCEEDS_USER_LIMIT": "Key的周消费上限({keyLimit})不能超过用户限额({userLimit})",
+  "KEY_LIMIT_MONTHLY_EXCEEDS_USER_LIMIT": "Key的月消费上限({keyLimit})不能超过用户限额({userLimit})",
+  "KEY_LIMIT_TOTAL_EXCEEDS_USER_LIMIT": "Key的总消费上限({keyLimit})不能超过用户限额({userLimit})",
+  "KEY_LIMIT_CONCURRENT_EXCEEDS_USER_LIMIT": "Key的并发Session上限({keyLimit})不能超过用户限额({userLimit})",
   "NO_DEFAULT_GROUP_PERMISSION": "无权使用 default 分组,您当前没有 default 分组的 Key",
   "NO_GROUP_PERMISSION": "无权使用以下分组: {groups}"
 }

+ 6 - 0
messages/zh-TW/errors.json

@@ -63,6 +63,12 @@
   "UPDATE_KEY_FAILED": "更新金鑰失敗,請稍後重試",
   "DELETE_KEY_FAILED": "刪除金鑰失敗,請稍後重試",
   "CANNOT_DISABLE_LAST_KEY": "無法禁用最後一個可用金鑰,使用者至少需要保留一個啟用的金鑰",
+  "KEY_LIMIT_5H_EXCEEDS_USER_LIMIT": "Key 的 5 小時消費上限({keyLimit})不能超過使用者限額({userLimit})",
+  "KEY_LIMIT_DAILY_EXCEEDS_USER_LIMIT": "Key 的每日消費上限({keyLimit})不能超過使用者限額({userLimit})",
+  "KEY_LIMIT_WEEKLY_EXCEEDS_USER_LIMIT": "Key 的每週消費上限({keyLimit})不能超過使用者限額({userLimit})",
+  "KEY_LIMIT_MONTHLY_EXCEEDS_USER_LIMIT": "Key 的每月消費上限({keyLimit})不能超過使用者限額({userLimit})",
+  "KEY_LIMIT_TOTAL_EXCEEDS_USER_LIMIT": "Key 的總消費上限({keyLimit})不能超過使用者限額({userLimit})",
+  "KEY_LIMIT_CONCURRENT_EXCEEDS_USER_LIMIT": "Key 的並發 Session 上限({keyLimit})不能超過使用者限額({userLimit})",
   "EXPIRES_AT_FIELD": "過期時間",
   "EXPIRES_AT_MUST_BE_FUTURE": "過期時間必須是未來時間",
   "EXPIRES_AT_TOO_FAR": "過期時間不能超過10年",

+ 110 - 34
src/actions/keys.ts

@@ -182,57 +182,99 @@ export async function addKey(data: {
     }
 
     // 验证各个限额字段
-    if (data.limit5hUsd && user.limit5hUsd && data.limit5hUsd > user.limit5hUsd) {
+    if (
+      validatedData.limit5hUsd != null &&
+      validatedData.limit5hUsd > 0 &&
+      user.limit5hUsd != null &&
+      user.limit5hUsd > 0 &&
+      validatedData.limit5hUsd > user.limit5hUsd
+    ) {
       return {
         ok: false,
-        error: `Key的5小时消费上限(${data.limit5hUsd})不能超过用户限额(${user.limit5hUsd})`,
+        error: tError("KEY_LIMIT_5H_EXCEEDS_USER_LIMIT", {
+          keyLimit: String(validatedData.limit5hUsd),
+          userLimit: String(user.limit5hUsd),
+        }),
       };
     }
 
-    if (data.limitDailyUsd && user.dailyQuota && data.limitDailyUsd > user.dailyQuota) {
+    if (
+      validatedData.limitDailyUsd != null &&
+      validatedData.limitDailyUsd > 0 &&
+      user.dailyQuota != null &&
+      user.dailyQuota > 0 &&
+      validatedData.limitDailyUsd > user.dailyQuota
+    ) {
       return {
         ok: false,
-        error: `Key的日消费上限(${data.limitDailyUsd})不能超过用户限额(${user.dailyQuota})`,
+        error: tError("KEY_LIMIT_DAILY_EXCEEDS_USER_LIMIT", {
+          keyLimit: String(validatedData.limitDailyUsd),
+          userLimit: String(user.dailyQuota),
+        }),
       };
     }
 
-    if (data.limitWeeklyUsd && user.limitWeeklyUsd && data.limitWeeklyUsd > user.limitWeeklyUsd) {
+    if (
+      validatedData.limitWeeklyUsd != null &&
+      validatedData.limitWeeklyUsd > 0 &&
+      user.limitWeeklyUsd != null &&
+      user.limitWeeklyUsd > 0 &&
+      validatedData.limitWeeklyUsd > user.limitWeeklyUsd
+    ) {
       return {
         ok: false,
-        error: `Key的周消费上限(${data.limitWeeklyUsd})不能超过用户限额(${user.limitWeeklyUsd})`,
+        error: tError("KEY_LIMIT_WEEKLY_EXCEEDS_USER_LIMIT", {
+          keyLimit: String(validatedData.limitWeeklyUsd),
+          userLimit: String(user.limitWeeklyUsd),
+        }),
       };
     }
 
     if (
-      data.limitMonthlyUsd &&
-      user.limitMonthlyUsd &&
-      data.limitMonthlyUsd > user.limitMonthlyUsd
+      validatedData.limitMonthlyUsd != null &&
+      validatedData.limitMonthlyUsd > 0 &&
+      user.limitMonthlyUsd != null &&
+      user.limitMonthlyUsd > 0 &&
+      validatedData.limitMonthlyUsd > user.limitMonthlyUsd
     ) {
       return {
         ok: false,
-        error: `Key的月消费上限(${data.limitMonthlyUsd})不能超过用户限额(${user.limitMonthlyUsd})`,
+        error: tError("KEY_LIMIT_MONTHLY_EXCEEDS_USER_LIMIT", {
+          keyLimit: String(validatedData.limitMonthlyUsd),
+          userLimit: String(user.limitMonthlyUsd),
+        }),
       };
     }
 
     if (
-      validatedData.limitTotalUsd &&
-      user.limitTotalUsd &&
+      validatedData.limitTotalUsd != null &&
+      validatedData.limitTotalUsd > 0 &&
+      user.limitTotalUsd != null &&
+      user.limitTotalUsd > 0 &&
       validatedData.limitTotalUsd > user.limitTotalUsd
     ) {
       return {
         ok: false,
-        error: `Key的总消费上限(${validatedData.limitTotalUsd})不能超过用户限额(${user.limitTotalUsd})`,
+        error: tError("KEY_LIMIT_TOTAL_EXCEEDS_USER_LIMIT", {
+          keyLimit: String(validatedData.limitTotalUsd),
+          userLimit: String(user.limitTotalUsd),
+        }),
       };
     }
 
     if (
-      data.limitConcurrentSessions &&
-      user.limitConcurrentSessions &&
-      data.limitConcurrentSessions > user.limitConcurrentSessions
+      validatedData.limitConcurrentSessions != null &&
+      validatedData.limitConcurrentSessions > 0 &&
+      user.limitConcurrentSessions != null &&
+      user.limitConcurrentSessions > 0 &&
+      validatedData.limitConcurrentSessions > user.limitConcurrentSessions
     ) {
       return {
         ok: false,
-        error: `Key的并发Session上限(${data.limitConcurrentSessions})不能超过用户限额(${user.limitConcurrentSessions})`,
+        error: tError("KEY_LIMIT_CONCURRENT_EXCEEDS_USER_LIMIT", {
+          keyLimit: String(validatedData.limitConcurrentSessions),
+          userLimit: String(user.limitConcurrentSessions),
+        }),
       };
     }
 
@@ -357,65 +399,99 @@ export async function editKey(
     }
 
     // 验证各个限额字段
-    if (validatedData.limit5hUsd && user.limit5hUsd && validatedData.limit5hUsd > user.limit5hUsd) {
+    if (
+      validatedData.limit5hUsd != null &&
+      validatedData.limit5hUsd > 0 &&
+      user.limit5hUsd != null &&
+      user.limit5hUsd > 0 &&
+      validatedData.limit5hUsd > user.limit5hUsd
+    ) {
       return {
         ok: false,
-        error: `Key的5小时消费上限(${validatedData.limit5hUsd})不能超过用户限额(${user.limit5hUsd})`,
+        error: tError("KEY_LIMIT_5H_EXCEEDS_USER_LIMIT", {
+          keyLimit: String(validatedData.limit5hUsd),
+          userLimit: String(user.limit5hUsd),
+        }),
       };
     }
 
     if (
-      validatedData.limitDailyUsd &&
-      user.dailyQuota &&
+      validatedData.limitDailyUsd != null &&
+      validatedData.limitDailyUsd > 0 &&
+      user.dailyQuota != null &&
+      user.dailyQuota > 0 &&
       validatedData.limitDailyUsd > user.dailyQuota
     ) {
       return {
         ok: false,
-        error: `Key的日消费上限(${validatedData.limitDailyUsd})不能超过用户限额(${user.dailyQuota})`,
+        error: tError("KEY_LIMIT_DAILY_EXCEEDS_USER_LIMIT", {
+          keyLimit: String(validatedData.limitDailyUsd),
+          userLimit: String(user.dailyQuota),
+        }),
       };
     }
 
     if (
-      validatedData.limitWeeklyUsd &&
-      user.limitWeeklyUsd &&
+      validatedData.limitWeeklyUsd != null &&
+      validatedData.limitWeeklyUsd > 0 &&
+      user.limitWeeklyUsd != null &&
+      user.limitWeeklyUsd > 0 &&
       validatedData.limitWeeklyUsd > user.limitWeeklyUsd
     ) {
       return {
         ok: false,
-        error: `Key的周消费上限(${validatedData.limitWeeklyUsd})不能超过用户限额(${user.limitWeeklyUsd})`,
+        error: tError("KEY_LIMIT_WEEKLY_EXCEEDS_USER_LIMIT", {
+          keyLimit: String(validatedData.limitWeeklyUsd),
+          userLimit: String(user.limitWeeklyUsd),
+        }),
       };
     }
 
     if (
-      validatedData.limitMonthlyUsd &&
-      user.limitMonthlyUsd &&
+      validatedData.limitMonthlyUsd != null &&
+      validatedData.limitMonthlyUsd > 0 &&
+      user.limitMonthlyUsd != null &&
+      user.limitMonthlyUsd > 0 &&
       validatedData.limitMonthlyUsd > user.limitMonthlyUsd
     ) {
       return {
         ok: false,
-        error: `Key的月消费上限(${validatedData.limitMonthlyUsd})不能超过用户限额(${user.limitMonthlyUsd})`,
+        error: tError("KEY_LIMIT_MONTHLY_EXCEEDS_USER_LIMIT", {
+          keyLimit: String(validatedData.limitMonthlyUsd),
+          userLimit: String(user.limitMonthlyUsd),
+        }),
       };
     }
 
     if (
-      validatedData.limitTotalUsd &&
-      user.limitTotalUsd &&
+      validatedData.limitTotalUsd != null &&
+      validatedData.limitTotalUsd > 0 &&
+      user.limitTotalUsd != null &&
+      user.limitTotalUsd > 0 &&
       validatedData.limitTotalUsd > user.limitTotalUsd
     ) {
       return {
         ok: false,
-        error: `Key的总消费上限(${validatedData.limitTotalUsd})不能超过用户限额(${user.limitTotalUsd})`,
+        error: tError("KEY_LIMIT_TOTAL_EXCEEDS_USER_LIMIT", {
+          keyLimit: String(validatedData.limitTotalUsd),
+          userLimit: String(user.limitTotalUsd),
+        }),
       };
     }
 
     if (
-      validatedData.limitConcurrentSessions &&
-      user.limitConcurrentSessions &&
+      validatedData.limitConcurrentSessions != null &&
+      validatedData.limitConcurrentSessions > 0 &&
+      user.limitConcurrentSessions != null &&
+      user.limitConcurrentSessions > 0 &&
       validatedData.limitConcurrentSessions > user.limitConcurrentSessions
     ) {
       return {
         ok: false,
-        error: `Key的并发Session上限(${validatedData.limitConcurrentSessions})不能超过用户限额(${user.limitConcurrentSessions})`,
+        error: tError("KEY_LIMIT_CONCURRENT_EXCEEDS_USER_LIMIT", {
+          keyLimit: String(validatedData.limitConcurrentSessions),
+          userLimit: String(user.limitConcurrentSessions),
+        }),
       };
     }
 

+ 47 - 7
src/app/v1/_lib/proxy/rate-limit-guard.ts

@@ -23,9 +23,9 @@ export class ProxyRateLimitGuard {
    *
    * 检查顺序(基于 Codex 专业分析):
    * 1-2. 永久硬限制:Key 总限额 → User 总限额
-   * 3-4. 资源/频率保护:Key 并发 → User RPM
-   * 5-8. 短期周期限额:Key 5h → User 5h → Key 每日 → User 每日
-   * 9-12. 中长期周期限额:Key 周 → User 周 → Key 月 → User 月
+   * 3-5. 资源/频率保护:Key 并发 → User 并发 → User RPM
+   * 6-9. 短期周期限额:Key 5h → User 5h → Key 每日 → User 每日
+   * 10-13. 中长期周期限额:Key 周 → User 周 → Key 月 → User 月
    *
    * 设计原则:
    * - 硬上限优先于周期上限
@@ -110,7 +110,7 @@ export class ProxyRateLimitGuard {
     const sessionCheck = await RateLimitService.checkSessionLimit(
       key.id,
       "key",
-      key.limitConcurrentSessions || 0
+      key.limitConcurrentSessions ?? 0
     );
 
     if (!sessionCheck.allowed) {
@@ -142,9 +142,49 @@ export class ProxyRateLimitGuard {
       );
     }
 
-    // 4. User RPM(频率闸门,挡住高频噪声)- null 表示无限制
-    if (user.rpm !== null) {
-      const rpmCheck = await RateLimitService.checkUserRPM(user.id, user.rpm);
+    // 4. User 并发 Session(账号级并发保护)
+    if (user.limitConcurrentSessions != null && user.limitConcurrentSessions > 0) {
+      const userSessionCheck = await RateLimitService.checkSessionLimit(
+        user.id,
+        "user",
+        user.limitConcurrentSessions
+      );
+
+      if (!userSessionCheck.allowed) {
+        logger.warn(
+          `[RateLimit] User session limit exceeded: user=${user.id}, ${userSessionCheck.reason}`
+        );
+
+        const { currentUsage, limitValue } = parseLimitInfo(userSessionCheck.reason!);
+
+        const resetTime = new Date().toISOString();
+
+        const { getLocale } = await import("next-intl/server");
+        const locale = await getLocale();
+        const message = await getErrorMessageServer(
+          locale,
+          ERROR_CODES.RATE_LIMIT_CONCURRENT_SESSIONS_EXCEEDED,
+          {
+            current: String(currentUsage),
+            limit: String(limitValue),
+          }
+        );
+
+        throw new RateLimitError(
+          "rate_limit_error",
+          message,
+          "concurrent_sessions",
+          currentUsage,
+          limitValue,
+          resetTime,
+          null
+        );
+      }
+    }
+
+    // 5. User RPM(频率闸门,挡住高频噪声)- null/0 表示无限制
+    if (user.rpm != null && user.rpm > 0) {
+      const rpmCheck = await RateLimitService.checkRpmLimit(user.id, "user", user.rpm);
       if (!rpmCheck.allowed) {
         logger.warn(`[RateLimit] User RPM exceeded: user=${user.id}, ${rpmCheck.reason}`);
 

+ 5 - 3
src/app/v1/_lib/proxy/response-handler.ts

@@ -1951,9 +1951,11 @@ async function trackCostToRedis(session: ProxySession, usage: UsageMetrics | nul
     );
 
     // 刷新 session 时间戳(滑动窗口)
-    void SessionTracker.refreshSession(session.sessionId, key.id, provider.id).catch((error) => {
-      logger.error("[ResponseHandler] Failed to refresh session tracker:", error);
-    });
+    void SessionTracker.refreshSession(session.sessionId, key.id, provider.id, user.id).catch(
+      (error) => {
+        logger.error("[ResponseHandler] Failed to refresh session tracker:", error);
+      }
+    );
   } catch (error) {
     logger.error("[ResponseHandler] Failed to track cost to Redis, skipping", {
       error: error instanceof Error ? error.message : String(error),

+ 5 - 3
src/app/v1/_lib/proxy/session-guard.ts

@@ -138,9 +138,11 @@ export class ProxySessionGuard {
       // 5. 追踪 session(添加到活跃集合)
       // Warmup 拦截请求不应计入并发会话(避免影响后续真实请求的限额判断)
       if (!warmupMaybeIntercepted) {
-        void SessionTracker.trackSession(sessionId, keyId).catch((err) => {
-          logger.error("[ProxySessionGuard] Failed to track session:", err);
-        });
+        void SessionTracker.trackSession(sessionId, keyId, session.authState?.user?.id).catch(
+          (err) => {
+            logger.error("[ProxySessionGuard] Failed to track session:", err);
+          }
+        );
       }
 
       // 6. 存储 session 详细信息到 Redis(用于实时监控,带重试机制)

+ 23 - 4
src/lib/rate-limit/service.ts

@@ -500,12 +500,12 @@ export class RateLimitService {
   /**
    * 检查并发 Session 限制(仅检查,不追踪)
    *
-   * 注意:此方法仅用于非供应商级别的限流检查(如 key 级)
+   * 注意:此方法仅用于非供应商级别的限流检查(如 key / user 级)
    * 供应商级别请使用 checkAndTrackProviderSession 保证原子性
    */
   static async checkSessionLimit(
     id: number,
-    type: "key" | "provider",
+    type: "key" | "provider" | "user",
     limit: number
   ): Promise<{ allowed: boolean; reason?: string }> {
     if (limit <= 0) {
@@ -517,12 +517,15 @@ export class RateLimitService {
       const count =
         type === "key"
           ? await SessionTracker.getKeySessionCount(id)
-          : await SessionTracker.getProviderSessionCount(id);
+          : type === "provider"
+            ? await SessionTracker.getProviderSessionCount(id)
+            : await SessionTracker.getUserSessionCount(id);
 
       if (count >= limit) {
+        const typeLabel = type === "key" ? "Key" : type === "provider" ? "供应商" : "User";
         return {
           allowed: false,
-          reason: `${type === "key" ? "Key" : "供应商"}并发 Session 上限已达到(${count}/${limit})`,
+          reason: `${typeLabel}并发 Session 上限已达到(${count}/${limit})`,
         };
       }
 
@@ -965,6 +968,22 @@ export class RateLimitService {
     }
   }
 
+  /**
+   * 检查 RPM(每分钟请求数)限制
+   * 目前仅支持 user 级别
+   */
+  static async checkRpmLimit(
+    id: number,
+    type: "user",
+    limit: number
+  ): Promise<{ allowed: boolean; reason?: string; current?: number }> {
+    if (type === "user") {
+      return RateLimitService.checkUserRPM(id, limit);
+    }
+
+    return { allowed: true };
+  }
+
   /**
    * 检查用户每日消费额度限制
    * 优先使用 Redis,失败时降级到数据库查询

+ 52 - 3
src/lib/session-tracker.ts

@@ -14,6 +14,7 @@ import { getRedisClient } from "./redis";
  * - global:active_sessions (ZSET): score = timestamp, member = sessionId
  * - key:${keyId}:active_sessions (ZSET): 同上
  * - provider:${providerId}:active_sessions (ZSET): 同上
+ * - user:${userId}:active_sessions (ZSET): 同上
  */
 export class SessionTracker {
   private static readonly SESSION_TTL = 300000; // 5 分钟(毫秒)
@@ -54,14 +55,15 @@ export class SessionTracker {
   }
 
   /**
-   * 追踪 session(添加到全局和 key 级集合)
+   * 追踪 session(添加到全局、key 级集合,可选 user 级集合)
    *
    * 调用时机:SessionGuard 分配 sessionId 后
    *
    * @param sessionId - Session ID
    * @param keyId - API Key ID
+   * @param userId - User ID(可选)
    */
-  static async trackSession(sessionId: string, keyId: number): Promise<void> {
+  static async trackSession(sessionId: string, keyId: number, userId?: number): Promise<void> {
     const redis = getRedisClient();
     if (!redis || redis.status !== "ready") return;
 
@@ -77,6 +79,11 @@ export class SessionTracker {
       pipeline.zadd(`key:${keyId}:active_sessions`, now, sessionId);
       pipeline.expire(`key:${keyId}:active_sessions`, 3600);
 
+      if (userId !== undefined) {
+        pipeline.zadd(`user:${userId}:active_sessions`, now, sessionId);
+        pipeline.expire(`user:${userId}:active_sessions`, 3600);
+      }
+
       const results = await pipeline.exec();
 
       // 检查执行结果,捕获类型冲突错误
@@ -153,8 +160,14 @@ export class SessionTracker {
    * @param sessionId - Session ID
    * @param keyId - API Key ID
    * @param providerId - Provider ID
+   * @param userId - User ID(可选)
    */
-  static async refreshSession(sessionId: string, keyId: number, providerId: number): Promise<void> {
+  static async refreshSession(
+    sessionId: string,
+    keyId: number,
+    providerId: number,
+    userId?: number
+  ): Promise<void> {
     const redis = getRedisClient();
     if (!redis || redis.status !== "ready") return;
 
@@ -166,6 +179,9 @@ export class SessionTracker {
       pipeline.zadd("global:active_sessions", now, sessionId);
       pipeline.zadd(`key:${keyId}:active_sessions`, now, sessionId);
       pipeline.zadd(`provider:${providerId}:active_sessions`, now, sessionId);
+      if (userId !== undefined) {
+        pipeline.zadd(`user:${userId}:active_sessions`, now, sessionId);
+      }
 
       // 修复 Bug:同步刷新 session 绑定信息的 TTL
       //
@@ -299,6 +315,39 @@ export class SessionTracker {
     }
   }
 
+  /**
+   * 获取 User 级活跃 session 计数
+   *
+   * @param userId - User ID
+   * @returns 活跃 session 数量
+   */
+  static async getUserSessionCount(userId: number): Promise<number> {
+    const redis = getRedisClient();
+    if (!redis || redis.status !== "ready") return 0;
+
+    try {
+      const key = `user:${userId}:active_sessions`;
+      const exists = await redis.exists(key);
+
+      if (exists === 1) {
+        const type = await redis.type(key);
+
+        if (type !== "zset") {
+          logger.warn("SessionTracker: Key is not ZSET, deleting", { key, type });
+          await redis.del(key);
+          return 0;
+        }
+
+        return await SessionTracker.countFromZSet(key);
+      }
+
+      return 0;
+    } catch (error) {
+      logger.error("SessionTracker: Failed to get user session count", { error, userId });
+      return 0;
+    }
+  }
+
   /**
    * 批量获取多个 Provider 的活跃 session 计数
    * 用于避免 N+1 查询问题

+ 53 - 0
src/repository/_shared/transformers.test.ts

@@ -72,6 +72,59 @@ describe("src/repository/_shared/transformers.ts", () => {
       });
     });
 
+    describe("limit 字段处理", () => {
+      it.each([
+        { title: "limit5hUsd = null -> null", field: "limit5hUsd", value: null, expected: null },
+        {
+          title: "limit5hUsd = undefined -> undefined",
+          field: "limit5hUsd",
+          value: undefined,
+          expected: undefined,
+        },
+        { title: 'limit5hUsd = "0" -> 0', field: "limit5hUsd", value: "0", expected: 0 },
+        {
+          title: 'limit5hUsd = "1.25" -> 1.25',
+          field: "limit5hUsd",
+          value: "1.25",
+          expected: 1.25,
+        },
+        { title: "limitWeeklyUsd = 0 -> 0", field: "limitWeeklyUsd", value: 0, expected: 0 },
+        {
+          title: "limitMonthlyUsd = 2.5 -> 2.5",
+          field: "limitMonthlyUsd",
+          value: 2.5,
+          expected: 2.5,
+        },
+        {
+          title: "limitConcurrentSessions = null -> null",
+          field: "limitConcurrentSessions",
+          value: null,
+          expected: null,
+        },
+        {
+          title: "limitConcurrentSessions = undefined -> undefined",
+          field: "limitConcurrentSessions",
+          value: undefined,
+          expected: undefined,
+        },
+        {
+          title: "limitConcurrentSessions = 0 -> 0",
+          field: "limitConcurrentSessions",
+          value: 0,
+          expected: 0,
+        },
+        {
+          title: "limitConcurrentSessions = 3 -> 3",
+          field: "limitConcurrentSessions",
+          value: 3,
+          expected: 3,
+        },
+      ])("$title", ({ field, value, expected }) => {
+        const result = toUser({ ...baseDbUser, [field]: value });
+        expect((result as unknown as Record<string, unknown>)[field]).toBe(expected);
+      });
+    });
+
     it("createdAt/updatedAt 缺失时默认使用当前时间", () => {
       const result = toUser({
         ...baseDbUser,

+ 19 - 4
src/repository/_shared/transformers.ts

@@ -8,6 +8,20 @@ import type { User } from "@/types/user";
 
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
 export function toUser(dbUser: any): User {
+  const parseOptionalNumber = (value: unknown): number | null | undefined => {
+    if (value === undefined) return undefined;
+    if (value === null) return null;
+    const parsed = Number.parseFloat(String(value));
+    return Number.isNaN(parsed) ? null : parsed;
+  };
+
+  const parseOptionalInteger = (value: unknown): number | null | undefined => {
+    if (value === undefined) return undefined;
+    if (value === null) return null;
+    const parsed = Number.parseInt(String(value), 10);
+    return Number.isNaN(parsed) ? null : parsed;
+  };
+
   return {
     ...dbUser,
     description: dbUser?.description || "",
@@ -24,10 +38,11 @@ export function toUser(dbUser: any): User {
     })(),
     providerGroup: dbUser?.providerGroup ?? null,
     tags: dbUser?.tags ?? [],
-    limitTotalUsd:
-      dbUser?.limitTotalUsd !== null && dbUser?.limitTotalUsd !== undefined
-        ? parseFloat(dbUser.limitTotalUsd)
-        : null,
+    limit5hUsd: parseOptionalNumber(dbUser?.limit5hUsd),
+    limitWeeklyUsd: parseOptionalNumber(dbUser?.limitWeeklyUsd),
+    limitMonthlyUsd: parseOptionalNumber(dbUser?.limitMonthlyUsd),
+    limitTotalUsd: parseOptionalNumber(dbUser?.limitTotalUsd),
+    limitConcurrentSessions: parseOptionalInteger(dbUser?.limitConcurrentSessions),
     dailyResetMode: dbUser?.dailyResetMode ?? "fixed",
     dailyResetTime: dbUser?.dailyResetTime ?? "00:00",
     isEnabled: dbUser?.isEnabled ?? true,

+ 12 - 0
src/repository/key.ts

@@ -471,7 +471,13 @@ export async function validateApiKeyAndGetUser(
       userRpm: users.rpmLimit,
       userDailyQuota: users.dailyLimitUsd,
       userProviderGroup: users.providerGroup,
+      userLimit5hUsd: users.limit5hUsd,
+      userLimitWeeklyUsd: users.limitWeeklyUsd,
+      userLimitMonthlyUsd: users.limitMonthlyUsd,
       userLimitTotalUsd: users.limitTotalUsd,
+      userLimitConcurrentSessions: users.limitConcurrentSessions,
+      userDailyResetMode: users.dailyResetMode,
+      userDailyResetTime: users.dailyResetTime,
       userIsEnabled: users.isEnabled,
       userExpiresAt: users.expiresAt,
       userAllowedClients: users.allowedClients,
@@ -506,7 +512,13 @@ export async function validateApiKeyAndGetUser(
     rpm: row.userRpm,
     dailyQuota: row.userDailyQuota,
     providerGroup: row.userProviderGroup,
+    limit5hUsd: row.userLimit5hUsd,
+    limitWeeklyUsd: row.userLimitWeeklyUsd,
+    limitMonthlyUsd: row.userLimitMonthlyUsd,
     limitTotalUsd: row.userLimitTotalUsd,
+    limitConcurrentSessions: row.userLimitConcurrentSessions,
+    dailyResetMode: row.userDailyResetMode,
+    dailyResetTime: row.userDailyResetTime,
     isEnabled: row.userIsEnabled,
     expiresAt: row.userExpiresAt,
     allowedClients: row.userAllowedClients,

+ 153 - 0
tests/unit/actions/keys-limit-validation.test.ts

@@ -0,0 +1,153 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const getSessionMock = vi.fn();
+vi.mock("@/lib/auth", () => ({
+  getSession: getSessionMock,
+}));
+
+vi.mock("next/cache", () => ({
+  revalidatePath: vi.fn(),
+}));
+
+const getTranslationsMock = vi.fn(async () => (key: string) => key);
+vi.mock("next-intl/server", () => ({
+  getTranslations: getTranslationsMock,
+}));
+
+const createKeyMock = vi.fn(async () => ({}));
+const findActiveKeyByUserIdAndNameMock = vi.fn(async () => null);
+const findKeyByIdMock = vi.fn();
+const findKeyListMock = vi.fn(async () => []);
+const updateKeyMock = vi.fn(async () => ({}));
+
+vi.mock("@/repository/key", () => ({
+  countActiveKeysByUser: vi.fn(async () => 1),
+  createKey: createKeyMock,
+  deleteKey: vi.fn(async () => true),
+  findActiveKeyByUserIdAndName: findActiveKeyByUserIdAndNameMock,
+  findKeyById: findKeyByIdMock,
+  findKeyList: findKeyListMock,
+  findKeysWithStatistics: vi.fn(async () => []),
+  updateKey: updateKeyMock,
+}));
+
+const findUserByIdMock = vi.fn();
+vi.mock("@/repository/user", async (importOriginal) => {
+  const actual = await importOriginal<typeof import("@/repository/user")>();
+  return {
+    ...actual,
+    findUserById: findUserByIdMock,
+  };
+});
+
+const syncUserProviderGroupFromKeysMock = vi.fn(async () => undefined);
+vi.mock("@/actions/users", () => ({
+  syncUserProviderGroupFromKeys: syncUserProviderGroupFromKeysMock,
+}));
+
+describe("keys limit validation", () => {
+  let baseUser: Record<string, unknown>;
+
+  beforeEach(() => {
+    vi.clearAllMocks();
+    getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } });
+    baseUser = {
+      id: 10,
+      name: "u",
+      description: "",
+      role: "user",
+      rpm: null,
+      dailyQuota: null,
+      providerGroup: "default",
+      tags: [],
+      limit5hUsd: null,
+      dailyResetMode: "fixed",
+      dailyResetTime: "00:00",
+      limitWeeklyUsd: null,
+      limitMonthlyUsd: null,
+      limitTotalUsd: null,
+      limitConcurrentSessions: 2,
+      isEnabled: true,
+      expiresAt: null,
+      allowedClients: [],
+      allowedModels: [],
+      createdAt: new Date(),
+      updatedAt: new Date(),
+      deletedAt: null,
+    };
+
+    findUserByIdMock.mockResolvedValue(baseUser);
+
+    findKeyByIdMock.mockResolvedValue({
+      id: 1,
+      userId: 10,
+      key: "sk-test",
+      name: "k",
+      isEnabled: true,
+      expiresAt: null,
+      canLoginWebUi: true,
+      limit5hUsd: null,
+      limitDailyUsd: null,
+      dailyResetMode: "fixed",
+      dailyResetTime: "00:00",
+      limitWeeklyUsd: null,
+      limitMonthlyUsd: null,
+      limitTotalUsd: null,
+      limitConcurrentSessions: 0,
+      providerGroup: "default",
+      cacheTtlPreference: null,
+      createdAt: new Date(),
+      updatedAt: new Date(),
+      deletedAt: null,
+    });
+  });
+
+  it("addKey:key 并发超过用户并发时应拦截", async () => {
+    const { addKey } = await import("@/actions/keys");
+
+    const result = await addKey({
+      userId: 10,
+      name: "k1",
+      limitConcurrentSessions: 3,
+      providerGroup: "default",
+    });
+
+    expect(result.ok).toBe(false);
+    if (!result.ok) {
+      expect(result.error).toBe("KEY_LIMIT_CONCURRENT_EXCEEDS_USER_LIMIT");
+    }
+    expect(createKeyMock).not.toHaveBeenCalled();
+  });
+
+  it("addKey:用户并发为 0 时不应限制 key 并发", async () => {
+    const { addKey } = await import("@/actions/keys");
+
+    findUserByIdMock.mockResolvedValueOnce({ ...baseUser, limitConcurrentSessions: 0 });
+
+    const result = await addKey({
+      userId: 10,
+      name: "k1",
+      limitConcurrentSessions: 3,
+      providerGroup: "default",
+    });
+
+    expect(result.ok).toBe(true);
+    expect(createKeyMock).toHaveBeenCalledTimes(1);
+  });
+
+  it("editKey:key 并发超过用户并发时应拦截", async () => {
+    const { editKey } = await import("@/actions/keys");
+
+    const result = await editKey(1, {
+      name: "k1",
+      providerGroup: "default",
+      limitConcurrentSessions: 3,
+    });
+
+    expect(result.ok).toBe(false);
+    if (!result.ok) {
+      expect(result.error).toBe("KEY_LIMIT_CONCURRENT_EXCEEDS_USER_LIMIT");
+    }
+    expect(updateKeyMock).not.toHaveBeenCalled();
+  });
+});

+ 20 - 0
tests/unit/lib/rate-limit/service-extra.test.ts

@@ -73,6 +73,7 @@ vi.mock("@/repository/statistics", () => statisticsMock);
 const sessionTrackerMock = {
   getKeySessionCount: vi.fn(async () => 0),
   getProviderSessionCount: vi.fn(async () => 0),
+  getUserSessionCount: vi.fn(async () => 0),
 };
 
 vi.mock("@/lib/session-tracker", () => ({
@@ -218,6 +219,25 @@ describe("RateLimitService - other quota paths", () => {
     expect(writePipeline.zadd).toHaveBeenCalledTimes(1);
   });
 
+  it("checkRpmLimit:user 类型应复用 checkUserRPM 逻辑", async () => {
+    const { RateLimitService } = await import("@/lib/rate-limit");
+
+    const readPipeline = makePipeline();
+    readPipeline.exec.mockResolvedValueOnce([
+      [null, 0],
+      [null, 1],
+    ]);
+
+    const writePipeline = makePipeline();
+    writePipeline.exec.mockResolvedValueOnce([]);
+
+    redisClientRef.pipeline.mockReturnValueOnce(readPipeline).mockReturnValueOnce(writePipeline);
+
+    const result = await RateLimitService.checkRpmLimit(1, "user", 2);
+    expect(result.allowed).toBe(true);
+    expect(result.current).toBe(2);
+  });
+
   it("getCurrentCostBatch:providerIds 为空时应返回空 Map", async () => {
     const { RateLimitService } = await import("@/lib/rate-limit");
 

+ 25 - 3
tests/unit/proxy/rate-limit-guard.test.ts

@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
 const rateLimitServiceMock = {
   checkTotalCostLimit: vi.fn(),
   checkSessionLimit: vi.fn(),
-  checkUserRPM: vi.fn(),
+  checkRpmLimit: vi.fn(),
   checkCostLimits: vi.fn(),
   checkUserDailyCost: vi.fn(),
 };
@@ -52,6 +52,7 @@ describe("ProxyRateLimitGuard - key daily limit enforcement", () => {
       limitWeeklyUsd: number | null;
       limitMonthlyUsd: number | null;
       limitTotalUsd: number | null;
+      limitConcurrentSessions: number | null;
     }>;
     key?: Partial<{
       id: number;
@@ -78,6 +79,7 @@ describe("ProxyRateLimitGuard - key daily limit enforcement", () => {
           limitWeeklyUsd: null,
           limitMonthlyUsd: null,
           limitTotalUsd: null,
+          limitConcurrentSessions: null,
           ...overrides?.user,
         },
         key: {
@@ -102,7 +104,7 @@ describe("ProxyRateLimitGuard - key daily limit enforcement", () => {
 
     rateLimitServiceMock.checkTotalCostLimit.mockResolvedValue({ allowed: true });
     rateLimitServiceMock.checkSessionLimit.mockResolvedValue({ allowed: true });
-    rateLimitServiceMock.checkUserRPM.mockResolvedValue({ allowed: true });
+    rateLimitServiceMock.checkRpmLimit.mockResolvedValue({ allowed: true });
     rateLimitServiceMock.checkUserDailyCost.mockResolvedValue({ allowed: true });
     rateLimitServiceMock.checkCostLimits.mockResolvedValue({ allowed: true });
   });
@@ -250,10 +252,30 @@ describe("ProxyRateLimitGuard - key daily limit enforcement", () => {
     });
   });
 
+  it("User 并发 Session 超限应拦截(concurrent_sessions)", async () => {
+    const { ProxyRateLimitGuard } = await import("@/app/v1/_lib/proxy/rate-limit-guard");
+
+    rateLimitServiceMock.checkSessionLimit
+      .mockResolvedValueOnce({ allowed: true }) // key session
+      .mockResolvedValueOnce({ allowed: false, reason: "User并发 Session 上限已达到(2/1)" });
+
+    const session = createSession({
+      user: { limitConcurrentSessions: 1 },
+      key: { limitConcurrentSessions: 10 },
+    });
+
+    await expect(ProxyRateLimitGuard.ensure(session)).rejects.toMatchObject({
+      name: "RateLimitError",
+      limitType: "concurrent_sessions",
+      currentUsage: 2,
+      limitValue: 1,
+    });
+  });
+
   it("User RPM 超限应拦截(rpm)", async () => {
     const { ProxyRateLimitGuard } = await import("@/app/v1/_lib/proxy/rate-limit-guard");
 
-    rateLimitServiceMock.checkUserRPM.mockResolvedValueOnce({
+    rateLimitServiceMock.checkRpmLimit.mockResolvedValueOnce({
       allowed: false,
       current: 10,
       reason: "用户每分钟请求数上限已达到(10/5)",

+ 138 - 0
tests/unit/repository/key.test.ts

@@ -0,0 +1,138 @@
+import { describe, expect, it, vi } from "vitest";
+
+const row = {
+  keyId: 1,
+  keyUserId: 2,
+  keyString: "sk-test",
+  keyName: "k1",
+  keyIsEnabled: true,
+  keyExpiresAt: null,
+  keyCanLoginWebUi: true,
+  keyLimit5hUsd: "1.00",
+  keyLimitDailyUsd: "2.00",
+  keyDailyResetMode: "fixed",
+  keyDailyResetTime: "00:00",
+  keyLimitWeeklyUsd: "3.00",
+  keyLimitMonthlyUsd: "4.00",
+  keyLimitTotalUsd: "5.00",
+  keyLimitConcurrentSessions: 6,
+  keyProviderGroup: "default",
+  keyCacheTtlPreference: null,
+  keyCreatedAt: new Date("2024-01-01T00:00:00.000Z"),
+  keyUpdatedAt: new Date("2024-01-01T00:00:00.000Z"),
+  keyDeletedAt: null,
+  userId: 2,
+  userName: "u1",
+  userDescription: "",
+  userRole: "user",
+  userRpm: 100,
+  userDailyQuota: "10.00",
+  userProviderGroup: "default",
+  userLimit5hUsd: "1.25",
+  userLimitWeeklyUsd: "2.5",
+  userLimitMonthlyUsd: "3.75",
+  userLimitTotalUsd: "20.00",
+  userLimitConcurrentSessions: 7,
+  userDailyResetMode: "rolling",
+  userDailyResetTime: "01:00",
+  userIsEnabled: true,
+  userExpiresAt: null,
+  userAllowedClients: [],
+  userAllowedModels: [],
+  userCreatedAt: new Date("2024-01-01T00:00:00.000Z"),
+  userUpdatedAt: new Date("2024-01-01T00:00:00.000Z"),
+  userDeletedAt: null,
+};
+
+const selectMock = vi.fn(() => ({
+  from: vi.fn(() => ({
+    innerJoin: vi.fn(() => ({
+      where: vi.fn(async () => [row]),
+    })),
+  })),
+}));
+
+vi.mock("@/drizzle/db", () => ({
+  db: {
+    select: selectMock,
+  },
+}));
+
+vi.mock("@/drizzle/schema", () => ({
+  keys: {
+    id: "keys.id",
+    userId: "keys.userId",
+    key: "keys.key",
+    name: "keys.name",
+    isEnabled: "keys.isEnabled",
+    expiresAt: "keys.expiresAt",
+    canLoginWebUi: "keys.canLoginWebUi",
+    limit5hUsd: "keys.limit5hUsd",
+    limitDailyUsd: "keys.limitDailyUsd",
+    dailyResetMode: "keys.dailyResetMode",
+    dailyResetTime: "keys.dailyResetTime",
+    limitWeeklyUsd: "keys.limitWeeklyUsd",
+    limitMonthlyUsd: "keys.limitMonthlyUsd",
+    limitTotalUsd: "keys.limitTotalUsd",
+    limitConcurrentSessions: "keys.limitConcurrentSessions",
+    providerGroup: "keys.providerGroup",
+    cacheTtlPreference: "keys.cacheTtlPreference",
+    createdAt: "keys.createdAt",
+    updatedAt: "keys.updatedAt",
+    deletedAt: "keys.deletedAt",
+  },
+  users: {
+    id: "users.id",
+    name: "users.name",
+    description: "users.description",
+    role: "users.role",
+    rpmLimit: "users.rpmLimit",
+    dailyLimitUsd: "users.dailyLimitUsd",
+    providerGroup: "users.providerGroup",
+    limit5hUsd: "users.limit5hUsd",
+    limitWeeklyUsd: "users.limitWeeklyUsd",
+    limitMonthlyUsd: "users.limitMonthlyUsd",
+    limitTotalUsd: "users.limitTotalUsd",
+    limitConcurrentSessions: "users.limitConcurrentSessions",
+    dailyResetMode: "users.dailyResetMode",
+    dailyResetTime: "users.dailyResetTime",
+    isEnabled: "users.isEnabled",
+    expiresAt: "users.expiresAt",
+    allowedClients: "users.allowedClients",
+    allowedModels: "users.allowedModels",
+    createdAt: "users.createdAt",
+    updatedAt: "users.updatedAt",
+    deletedAt: "users.deletedAt",
+  },
+}));
+
+vi.mock("drizzle-orm", () => ({
+  and: (...args: unknown[]) => args,
+  or: (...args: unknown[]) => args,
+  eq: (...args: unknown[]) => args,
+  gt: (...args: unknown[]) => args,
+  isNull: (...args: unknown[]) => args,
+  count: (...args: unknown[]) => args,
+  desc: (...args: unknown[]) => args,
+  gte: (...args: unknown[]) => args,
+  inArray: (...args: unknown[]) => args,
+  lt: (...args: unknown[]) => args,
+  sql: (...args: unknown[]) => args,
+  sum: (...args: unknown[]) => args,
+}));
+
+describe("repository/key validateApiKeyAndGetUser", () => {
+  it("should return user with limit fields populated", async () => {
+    const { validateApiKeyAndGetUser } = await import("@/repository/key");
+
+    const result = await validateApiKeyAndGetUser("sk-test");
+
+    expect(result?.user.limit5hUsd).toBe(1.25);
+    expect(result?.user.limitWeeklyUsd).toBe(2.5);
+    expect(result?.user.limitMonthlyUsd).toBe(3.75);
+    expect(result?.user.limitTotalUsd).toBe(20);
+    expect(result?.user.limitConcurrentSessions).toBe(7);
+    expect(result?.user.dailyResetMode).toBe("rolling");
+    expect(result?.user.dailyResetTime).toBe("01:00");
+  });
+});