Explorar o código

fix: 用户限额未设置时 Key 限额仍生效 (#531)

Ding hai 3 meses
pai
achega
b7d5bcd3ed

+ 1 - 0
.gitignore

@@ -12,6 +12,7 @@
 
 # testing
 /coverage
+/coverage-quota
 
 # next.js
 /.next/

+ 1 - 0
package.json

@@ -18,6 +18,7 @@
     "test:e2e": "vitest run --config vitest.e2e.config.ts --reporter=verbose",
     "test:integration": "vitest run --config vitest.integration.config.ts --reporter=verbose",
     "test:coverage": "vitest run --coverage",
+    "test:coverage:quota": "vitest run --config vitest.quota.config.ts --coverage",
     "test:ci": "vitest run --reporter=default --reporter=junit --outputFile.junit=reports/vitest-junit.xml",
     "cui": "npx cui-server --host 0.0.0.0 --port 30000 --token a7564bc8882aa9a2d25d8b4ea6ea1e2e",
     "db:generate": "drizzle-kit generate && node scripts/validate-migrations.js",

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

@@ -24,8 +24,8 @@ export class ProxyRateLimitGuard {
    * 检查顺序(基于 Codex 专业分析):
    * 1-2. 永久硬限制:Key 总限额 → User 总限额
    * 3-4. 资源/频率保护:Key 并发 → User RPM
-   * 5-7. 短期周期限额:Key 5h → User 5h → User 每日
-   * 8-11. 中长期周期限额:Key 周 → User 周 → Key 月 → User 月
+   * 5-8. 短期周期限额:Key 5h → User 5h → Key 每日 → User 每日
+   * 9-12. 中长期周期限额:Key 周 → User 周 → Key 月 → User 月
    *
    * 设计原则:
    * - 硬上限优先于周期上限
@@ -239,7 +239,51 @@ export class ProxyRateLimitGuard {
       );
     }
 
-    // 7. User 每日额度(User 独有的常用预算)- null 表示无限制
+    // 7. Key 每日限额(Key 独有的每日预算)- null 表示无限制
+    const keyDailyCheck = await RateLimitService.checkCostLimits(key.id, "key", {
+      limit_5h_usd: null,
+      limit_daily_usd: key.limitDailyUsd,
+      daily_reset_mode: key.dailyResetMode,
+      daily_reset_time: key.dailyResetTime,
+      limit_weekly_usd: null,
+      limit_monthly_usd: null,
+    });
+
+    if (!keyDailyCheck.allowed) {
+      logger.warn(`[RateLimit] Key daily limit exceeded: key=${key.id}, ${keyDailyCheck.reason}`);
+
+      const { currentUsage, limitValue } = parseLimitInfo(keyDailyCheck.reason!);
+
+      const resetInfo = getResetInfoWithMode("daily", key.dailyResetTime, key.dailyResetMode);
+      // rolling 模式没有 resetAt,使用 24 小时后作为 fallback
+      const resetTime =
+        resetInfo.resetAt?.toISOString() ??
+        new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
+
+      const { getLocale } = await import("next-intl/server");
+      const locale = await getLocale();
+      const message = await getErrorMessageServer(
+        locale,
+        ERROR_CODES.RATE_LIMIT_DAILY_QUOTA_EXCEEDED,
+        {
+          current: currentUsage.toFixed(4),
+          limit: limitValue.toFixed(4),
+          resetTime,
+        }
+      );
+
+      throw new RateLimitError(
+        "rate_limit_error",
+        message,
+        "daily_quota",
+        currentUsage,
+        limitValue,
+        resetTime,
+        null
+      );
+    }
+
+    // 8. User 每日额度(User 独有的常用预算)- null 表示无限制
     if (user.dailyQuota !== null) {
       const dailyCheck = await RateLimitService.checkUserDailyCost(
         user.id,
@@ -284,7 +328,7 @@ export class ProxyRateLimitGuard {
 
     // ========== 第四层:中长期周期限额(混合检查)==========
 
-    // 8. Key 周限额
+    // 9. Key 周限额
     const keyWeeklyCheck = await RateLimitService.checkCostLimits(key.id, "key", {
       limit_5h_usd: null,
       limit_daily_usd: null,
@@ -318,7 +362,7 @@ export class ProxyRateLimitGuard {
       );
     }
 
-    // 9. User 周限额
+    // 10. User 周限额
     const userWeeklyCheck = await RateLimitService.checkCostLimits(user.id, "user", {
       limit_5h_usd: null,
       limit_daily_usd: null,
@@ -354,7 +398,7 @@ export class ProxyRateLimitGuard {
       );
     }
 
-    // 10. Key 月限额
+    // 11. Key 月限额
     const keyMonthlyCheck = await RateLimitService.checkCostLimits(key.id, "key", {
       limit_5h_usd: null,
       limit_daily_usd: null,
@@ -390,7 +434,7 @@ export class ProxyRateLimitGuard {
       );
     }
 
-    // 11. User 月限额(最后一道长期预算闸门)
+    // 12. User 月限额(最后一道长期预算闸门)
     const userMonthlyCheck = await RateLimitService.checkCostLimits(user.id, "user", {
       limit_5h_usd: null,
       limit_daily_usd: null,

+ 2 - 1
src/lib/rate-limit/service.ts

@@ -248,9 +248,10 @@ export class RateLimitService {
           }
 
           if (current >= limit.amount) {
+            const typeName = type === "key" ? "Key" : type === "provider" ? "供应商" : "User";
             return {
               allowed: false,
-              reason: `${type === "key" ? "Key" : "供应商"} ${limit.name}消费上限已达到(${current.toFixed(4)}/${limit.amount})`,
+              reason: `${typeName} ${limit.name}消费上限已达到(${current.toFixed(4)}/${limit.amount})`,
             };
           }
         }

+ 247 - 0
tests/unit/lib/rate-limit/cost-limits.test.ts

@@ -0,0 +1,247 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+const pipelineCommands: Array<unknown[]> = [];
+
+const pipeline = {
+  zadd: vi.fn((...args: unknown[]) => {
+    pipelineCommands.push(["zadd", ...args]);
+    return pipeline;
+  }),
+  expire: vi.fn((...args: unknown[]) => {
+    pipelineCommands.push(["expire", ...args]);
+    return pipeline;
+  }),
+  exec: vi.fn(async () => {
+    pipelineCommands.push(["exec"]);
+    return [];
+  }),
+  incrbyfloat: vi.fn(() => pipeline),
+  zremrangebyscore: vi.fn(() => pipeline),
+  zcard: vi.fn(() => pipeline),
+};
+
+const redisClient = {
+  status: "ready",
+  eval: vi.fn(async () => "0"),
+  exists: vi.fn(async () => 1),
+  get: vi.fn(async () => null),
+  set: vi.fn(async () => "OK"),
+  setex: vi.fn(async () => "OK"),
+  pipeline: vi.fn(() => pipeline),
+};
+
+vi.mock("@/lib/redis", () => ({
+  getRedisClient: () => redisClient,
+}));
+
+const statisticsMock = {
+  // total cost
+  sumKeyTotalCost: vi.fn(async () => 0),
+  sumUserTotalCost: vi.fn(async () => 0),
+
+  // fixed-window sums
+  sumKeyCostInTimeRange: vi.fn(async () => 0),
+  sumProviderCostInTimeRange: vi.fn(async () => 0),
+  sumUserCostInTimeRange: vi.fn(async () => 0),
+
+  // rolling-window entries
+  findKeyCostEntriesInTimeRange: vi.fn(async () => []),
+  findProviderCostEntriesInTimeRange: vi.fn(async () => []),
+  findUserCostEntriesInTimeRange: vi.fn(async () => []),
+};
+
+vi.mock("@/repository/statistics", () => statisticsMock);
+
+describe("RateLimitService - cost limits and quota checks", () => {
+  const nowMs = 1_700_000_000_000;
+
+  beforeEach(() => {
+    pipelineCommands.length = 0;
+    vi.resetAllMocks();
+    vi.useFakeTimers();
+    vi.setSystemTime(new Date(nowMs));
+  });
+
+  afterEach(() => {
+    vi.useRealTimers();
+  });
+
+  it("checkCostLimits:未设置任何限额时应直接放行", async () => {
+    const { RateLimitService } = await import("@/lib/rate-limit");
+
+    const result = await RateLimitService.checkCostLimits(1, "key", {
+      limit_5h_usd: null,
+      limit_daily_usd: null,
+      limit_weekly_usd: null,
+      limit_monthly_usd: null,
+    });
+
+    expect(result).toEqual({ allowed: true });
+    expect(redisClient.eval).not.toHaveBeenCalled();
+    expect(redisClient.get).not.toHaveBeenCalled();
+  });
+
+  it("checkCostLimits:Key 每日 fixed 超限时应返回 not allowed", async () => {
+    const { RateLimitService } = await import("@/lib/rate-limit");
+
+    redisClient.get.mockImplementation(async (key: string) => {
+      if (key === "key:1:cost_daily_0000") return "12";
+      return "0";
+    });
+
+    const result = await RateLimitService.checkCostLimits(1, "key", {
+      limit_5h_usd: null,
+      limit_daily_usd: 10,
+      daily_reset_mode: "fixed",
+      daily_reset_time: "00:00",
+      limit_weekly_usd: null,
+      limit_monthly_usd: null,
+    });
+
+    expect(result.allowed).toBe(false);
+    expect(result.reason).toContain("Key 每日消费上限已达到(12.0000/10)");
+  });
+
+  it("checkCostLimits:Provider 每日 rolling 超限时应返回 not allowed", async () => {
+    const { RateLimitService } = await import("@/lib/rate-limit");
+
+    redisClient.eval.mockResolvedValueOnce("11");
+
+    const result = await RateLimitService.checkCostLimits(9, "provider", {
+      limit_5h_usd: null,
+      limit_daily_usd: 10,
+      daily_reset_mode: "rolling",
+      daily_reset_time: "00:00",
+      limit_weekly_usd: null,
+      limit_monthly_usd: null,
+    });
+
+    expect(result.allowed).toBe(false);
+    expect(result.reason).toContain("供应商 每日消费上限已达到(11.0000/10)");
+  });
+
+  it("checkCostLimits:User fast-path 的类型标识应为 User(避免错误标为“供应商”)", async () => {
+    const { RateLimitService } = await import("@/lib/rate-limit");
+
+    redisClient.get.mockImplementation(async (key: string) => {
+      if (key === "user:1:cost_weekly") return "20";
+      return "0";
+    });
+
+    const result = await RateLimitService.checkCostLimits(1, "user", {
+      limit_5h_usd: null,
+      limit_daily_usd: null,
+      limit_weekly_usd: 10,
+      limit_monthly_usd: null,
+    });
+
+    expect(result.allowed).toBe(false);
+    expect(result.reason).toContain("User 周消费上限已达到(20.0000/10)");
+  });
+
+  it("checkCostLimits:Redis cache miss 时应 fallback 到 DB 查询", async () => {
+    const { RateLimitService } = await import("@/lib/rate-limit");
+
+    redisClient.get.mockResolvedValueOnce(null);
+    statisticsMock.sumKeyCostInTimeRange.mockResolvedValueOnce(20);
+
+    const result = await RateLimitService.checkCostLimits(1, "key", {
+      limit_5h_usd: null,
+      limit_daily_usd: 10,
+      daily_reset_mode: "fixed",
+      daily_reset_time: "00:00",
+      limit_weekly_usd: null,
+      limit_monthly_usd: null,
+    });
+
+    expect(result.allowed).toBe(false);
+    expect(statisticsMock.sumKeyCostInTimeRange).toHaveBeenCalledTimes(1);
+    expect(redisClient.set).toHaveBeenCalled();
+  });
+
+  it("checkTotalCostLimit:limitTotalUsd 未设置时应放行", async () => {
+    const { RateLimitService } = await import("@/lib/rate-limit");
+
+    expect(await RateLimitService.checkTotalCostLimit(1, "user", null)).toEqual({ allowed: true });
+    expect(await RateLimitService.checkTotalCostLimit(1, "user", undefined as any)).toEqual({
+      allowed: true,
+    });
+    expect(await RateLimitService.checkTotalCostLimit(1, "user", 0)).toEqual({ allowed: true });
+  });
+
+  it("checkTotalCostLimit:Key 缺失 keyHash 时应跳过 enforcement", async () => {
+    const { RateLimitService } = await import("@/lib/rate-limit");
+
+    const result = await RateLimitService.checkTotalCostLimit(1, "key", 10, undefined);
+    expect(result).toEqual({ allowed: true });
+  });
+
+  it("checkTotalCostLimit:Redis cache hit 且已超限时应返回 not allowed", async () => {
+    const { RateLimitService } = await import("@/lib/rate-limit");
+
+    redisClient.get.mockImplementation(async (key: string) => {
+      if (key === "total_cost:user:7") return "20";
+      return null;
+    });
+
+    const result = await RateLimitService.checkTotalCostLimit(7, "user", 10);
+    expect(result.allowed).toBe(false);
+    expect(result.current).toBe(20);
+  });
+
+  it("checkTotalCostLimit:Redis miss 时应 fallback DB 并写回缓存", async () => {
+    const { RateLimitService } = await import("@/lib/rate-limit");
+
+    redisClient.get.mockResolvedValueOnce(null);
+    statisticsMock.sumUserTotalCost.mockResolvedValueOnce(5);
+
+    const result = await RateLimitService.checkTotalCostLimit(7, "user", 10);
+    expect(result.allowed).toBe(true);
+    expect(result.current).toBe(5);
+    expect(redisClient.setex).toHaveBeenCalledWith("total_cost:user:7", 300, "5");
+  });
+
+  it("checkUserDailyCost:fixed 模式 cache hit 超限时应拦截", async () => {
+    const { RateLimitService } = await import("@/lib/rate-limit");
+
+    redisClient.get.mockImplementation(async (key: string) => {
+      if (key === "user:1:cost_daily_0000") return "20";
+      return null;
+    });
+
+    const result = await RateLimitService.checkUserDailyCost(1, 10, "00:00", "fixed");
+    expect(result.allowed).toBe(false);
+    expect(result.current).toBe(20);
+  });
+
+  it("checkUserDailyCost:fixed 模式 cache miss 时应 fallback DB 并写回缓存", async () => {
+    const { RateLimitService } = await import("@/lib/rate-limit");
+
+    redisClient.get.mockResolvedValueOnce(null);
+    statisticsMock.sumUserCostInTimeRange.mockResolvedValueOnce(12);
+
+    const result = await RateLimitService.checkUserDailyCost(1, 10, "00:00", "fixed");
+    expect(result.allowed).toBe(false);
+    expect(result.current).toBe(12);
+    expect(redisClient.set).toHaveBeenCalled();
+  });
+
+  it("checkUserDailyCost:rolling 模式 cache miss 时应走明细查询并 warm ZSET", async () => {
+    const { RateLimitService } = await import("@/lib/rate-limit");
+
+    redisClient.eval.mockResolvedValueOnce("0");
+    redisClient.exists.mockResolvedValueOnce(0);
+    statisticsMock.findUserCostEntriesInTimeRange.mockResolvedValueOnce([
+      { id: 101, createdAt: new Date(nowMs - 60_000), costUsd: 3 },
+      { id: 102, createdAt: new Date(nowMs - 30_000), costUsd: 8 },
+    ]);
+
+    const result = await RateLimitService.checkUserDailyCost(1, 10, "00:00", "rolling");
+    expect(result.allowed).toBe(false);
+    expect(result.current).toBe(11);
+
+    const zaddCalls = pipelineCommands.filter((c) => c[0] === "zadd");
+    expect(zaddCalls).toHaveLength(2);
+    expect(pipelineCommands.some((c) => c[0] === "expire")).toBe(true);
+  });
+});

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

@@ -0,0 +1,396 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+let redisClientRef: any;
+
+const pipelineCalls: Array<unknown[]> = [];
+const makePipeline = () => {
+  const pipeline = {
+    eval: vi.fn((...args: unknown[]) => {
+      pipelineCalls.push(["eval", ...args]);
+      return pipeline;
+    }),
+    get: vi.fn((...args: unknown[]) => {
+      pipelineCalls.push(["get", ...args]);
+      return pipeline;
+    }),
+    incrbyfloat: vi.fn((...args: unknown[]) => {
+      pipelineCalls.push(["incrbyfloat", ...args]);
+      return pipeline;
+    }),
+    expire: vi.fn((...args: unknown[]) => {
+      pipelineCalls.push(["expire", ...args]);
+      return pipeline;
+    }),
+    zremrangebyscore: vi.fn((...args: unknown[]) => {
+      pipelineCalls.push(["zremrangebyscore", ...args]);
+      return pipeline;
+    }),
+    zcard: vi.fn((...args: unknown[]) => {
+      pipelineCalls.push(["zcard", ...args]);
+      return pipeline;
+    }),
+    zadd: vi.fn((...args: unknown[]) => {
+      pipelineCalls.push(["zadd", ...args]);
+      return pipeline;
+    }),
+    exec: vi.fn(async () => {
+      pipelineCalls.push(["exec"]);
+      return [];
+    }),
+  };
+  return pipeline;
+};
+
+vi.mock("@/lib/logger", () => ({
+  logger: {
+    debug: vi.fn(),
+    info: vi.fn(),
+    warn: vi.fn(),
+    error: vi.fn(),
+  },
+}));
+
+vi.mock("@/lib/redis", () => ({
+  getRedisClient: () => redisClientRef,
+}));
+
+const statisticsMock = {
+  // service.ts 顶层静态导入需要这些 export 存在
+  sumKeyTotalCost: vi.fn(async () => 0),
+  sumUserTotalCost: vi.fn(async () => 0),
+  sumUserCostInTimeRange: vi.fn(async () => 0),
+
+  // getCurrentCost / checkCostLimitsFromDatabase 动态导入会解构这些 export
+  findKeyCostEntriesInTimeRange: vi.fn(async () => []),
+  findProviderCostEntriesInTimeRange: vi.fn(async () => []),
+  findUserCostEntriesInTimeRange: vi.fn(async () => []),
+  sumKeyCostInTimeRange: vi.fn(async () => 0),
+  sumProviderCostInTimeRange: vi.fn(async () => 0),
+};
+
+vi.mock("@/repository/statistics", () => statisticsMock);
+
+const sessionTrackerMock = {
+  getKeySessionCount: vi.fn(async () => 0),
+  getProviderSessionCount: vi.fn(async () => 0),
+};
+
+vi.mock("@/lib/session-tracker", () => ({
+  SessionTracker: sessionTrackerMock,
+}));
+
+describe("RateLimitService - other quota paths", () => {
+  const nowMs = 1_700_000_000_000;
+
+  beforeEach(() => {
+    vi.resetAllMocks();
+    pipelineCalls.length = 0;
+    vi.useFakeTimers();
+    vi.setSystemTime(new Date(nowMs));
+
+    redisClientRef = {
+      status: "ready",
+      eval: vi.fn(async () => "0"),
+      exists: vi.fn(async () => 1),
+      get: vi.fn(async () => null),
+      set: vi.fn(async () => "OK"),
+      setex: vi.fn(async () => "OK"),
+      pipeline: vi.fn(() => makePipeline()),
+    };
+  });
+
+  afterEach(() => {
+    vi.useRealTimers();
+  });
+
+  it("checkSessionLimit:limit<=0 时应放行", async () => {
+    const { RateLimitService } = await import("@/lib/rate-limit");
+
+    await expect(RateLimitService.checkSessionLimit(1, "key", 0)).resolves.toEqual({
+      allowed: true,
+    });
+  });
+
+  it("checkSessionLimit:Key 并发数达到上限时应拦截", async () => {
+    const { RateLimitService } = await import("@/lib/rate-limit");
+
+    sessionTrackerMock.getKeySessionCount.mockResolvedValueOnce(2);
+
+    const result = await RateLimitService.checkSessionLimit(1, "key", 2);
+    expect(result.allowed).toBe(false);
+    expect(result.reason).toContain("Key并发 Session 上限已达到(2/2)");
+  });
+
+  it("checkSessionLimit:Provider 并发数未达上限时应放行", async () => {
+    const { RateLimitService } = await import("@/lib/rate-limit");
+
+    sessionTrackerMock.getProviderSessionCount.mockResolvedValueOnce(1);
+
+    await expect(RateLimitService.checkSessionLimit(9, "provider", 2)).resolves.toEqual({
+      allowed: true,
+    });
+  });
+
+  it("checkAndTrackProviderSession:limit<=0 时应放行且不追踪", async () => {
+    const { RateLimitService } = await import("@/lib/rate-limit");
+
+    const result = await RateLimitService.checkAndTrackProviderSession(9, "sess", 0);
+    expect(result).toEqual({ allowed: true, count: 0, tracked: false });
+  });
+
+  it("checkAndTrackProviderSession:Redis 非 ready 时应 Fail Open", async () => {
+    const { RateLimitService } = await import("@/lib/rate-limit");
+
+    redisClientRef.status = "end";
+    const result = await RateLimitService.checkAndTrackProviderSession(9, "sess", 2);
+    expect(result).toEqual({ allowed: true, count: 0, tracked: false });
+  });
+
+  it("checkAndTrackProviderSession:达到上限时应返回 not allowed", async () => {
+    const { RateLimitService } = await import("@/lib/rate-limit");
+
+    redisClientRef.eval.mockResolvedValueOnce([0, 2, 0]);
+    const result = await RateLimitService.checkAndTrackProviderSession(9, "sess", 2);
+    expect(result.allowed).toBe(false);
+    expect(result.reason).toContain("供应商并发 Session 上限已达到(2/2)");
+  });
+
+  it("checkAndTrackProviderSession:未达到上限时应返回 allowed 且可标记 tracked", async () => {
+    const { RateLimitService } = await import("@/lib/rate-limit");
+
+    redisClientRef.eval.mockResolvedValueOnce([1, 1, 1]);
+    const result = await RateLimitService.checkAndTrackProviderSession(9, "sess", 2);
+    expect(result).toEqual({ allowed: true, count: 1, tracked: true });
+  });
+
+  it("trackUserDailyCost:fixed 模式应使用 STRING + TTL", async () => {
+    const { RateLimitService } = await import("@/lib/rate-limit");
+
+    await RateLimitService.trackUserDailyCost(1, 1.25, "00:00", "fixed");
+
+    expect(pipelineCalls.some((c) => c[0] === "incrbyfloat")).toBe(true);
+    expect(pipelineCalls.some((c) => c[0] === "expire")).toBe(true);
+  });
+
+  it("trackUserDailyCost:rolling 模式应使用 ZSET Lua 脚本", async () => {
+    const { RateLimitService } = await import("@/lib/rate-limit");
+
+    await RateLimitService.trackUserDailyCost(1, 1.25, "00:00", "rolling", { requestId: 123 });
+
+    expect(redisClientRef.eval).toHaveBeenCalled();
+  });
+
+  it("checkUserRPM:达到上限时应拦截", async () => {
+    const { RateLimitService } = await import("@/lib/rate-limit");
+
+    const pipeline = makePipeline();
+    pipeline.exec
+      .mockResolvedValueOnce([
+        [null, 0],
+        [null, 5], // zcard 返回 5
+      ])
+      .mockResolvedValueOnce([]); // 写入 pipeline
+
+    redisClientRef.pipeline.mockReturnValueOnce(pipeline);
+
+    const result = await RateLimitService.checkUserRPM(1, 5);
+    expect(result.allowed).toBe(false);
+    expect(result.current).toBe(5);
+  });
+
+  it("checkUserRPM:未达到上限时应写入本次请求并放行", async () => {
+    const { RateLimitService } = await import("@/lib/rate-limit");
+
+    const readPipeline = makePipeline();
+    readPipeline.exec.mockResolvedValueOnce([
+      [null, 0],
+      [null, 3], // zcard 返回 3
+    ]);
+
+    const writePipeline = makePipeline();
+    writePipeline.exec.mockResolvedValueOnce([]);
+
+    redisClientRef.pipeline.mockReturnValueOnce(readPipeline).mockReturnValueOnce(writePipeline);
+
+    const result = await RateLimitService.checkUserRPM(1, 5);
+    expect(result.allowed).toBe(true);
+    expect(result.current).toBe(4);
+    expect(writePipeline.zadd).toHaveBeenCalledTimes(1);
+  });
+
+  it("getCurrentCostBatch:providerIds 为空时应返回空 Map", async () => {
+    const { RateLimitService } = await import("@/lib/rate-limit");
+
+    const result = await RateLimitService.getCurrentCostBatch([], new Map());
+    expect(result.size).toBe(0);
+  });
+
+  it("getCurrentCostBatch:Redis 非 ready 时应返回默认 0", async () => {
+    const { RateLimitService } = await import("@/lib/rate-limit");
+
+    redisClientRef.status = "end";
+    const result = await RateLimitService.getCurrentCostBatch([1], new Map());
+    expect(result.get(1)).toEqual({ cost5h: 0, costDaily: 0, costWeekly: 0, costMonthly: 0 });
+  });
+
+  it("getCurrentCostBatch:应按 pipeline 返回解析 5h/daily/weekly/monthly", async () => {
+    const { RateLimitService } = await import("@/lib/rate-limit");
+
+    const pipeline = makePipeline();
+    // queryMeta: 5h(eval), daily(get fixed), weekly(get), monthly(get)
+    pipeline.exec.mockResolvedValueOnce([
+      [null, "1.5"],
+      [null, "2.5"],
+      [null, "3.5"],
+      [null, "4.5"],
+    ]);
+    redisClientRef.pipeline.mockReturnValueOnce(pipeline);
+
+    const dailyResetConfigs = new Map<
+      number,
+      { resetTime?: string | null; resetMode?: string | null }
+    >();
+    dailyResetConfigs.set(1, { resetTime: "00:00", resetMode: "fixed" });
+
+    const result = await RateLimitService.getCurrentCostBatch([1], dailyResetConfigs);
+    expect(result.get(1)).toEqual({
+      cost5h: 1.5,
+      costDaily: 2.5,
+      costWeekly: 3.5,
+      costMonthly: 4.5,
+    });
+  });
+
+  it("checkCostLimits:5h 滚动窗口超限时应返回 not allowed", async () => {
+    const { RateLimitService } = await import("@/lib/rate-limit");
+
+    redisClientRef.eval.mockResolvedValueOnce("11");
+    const result = await RateLimitService.checkCostLimits(1, "provider", {
+      limit_5h_usd: 10,
+      limit_daily_usd: null,
+      limit_weekly_usd: null,
+      limit_monthly_usd: null,
+    });
+
+    expect(result.allowed).toBe(false);
+    expect(result.reason).toContain("供应商 5小时消费上限已达到(11.0000/10)");
+  });
+
+  it("checkCostLimits:daily rolling cache miss 时应回退 DB 并 warm ZSET", async () => {
+    const { RateLimitService } = await import("@/lib/rate-limit");
+
+    redisClientRef.eval.mockResolvedValueOnce("0");
+    redisClientRef.exists.mockResolvedValueOnce(0);
+    statisticsMock.findProviderCostEntriesInTimeRange.mockResolvedValueOnce([
+      { id: 101, createdAt: new Date(nowMs - 60_000), costUsd: 3 },
+      { id: 102, createdAt: new Date(nowMs - 30_000), costUsd: 9 },
+    ]);
+
+    const result = await RateLimitService.checkCostLimits(9, "provider", {
+      limit_5h_usd: null,
+      limit_daily_usd: 10,
+      daily_reset_mode: "rolling",
+      daily_reset_time: "00:00",
+      limit_weekly_usd: null,
+      limit_monthly_usd: null,
+    });
+
+    expect(result.allowed).toBe(false);
+    expect(result.reason).toContain("供应商 每日消费上限已达到(12.0000/10)");
+    expect(pipelineCalls.some((c) => c[0] === "zadd")).toBe(true);
+  });
+
+  it("getCurrentCost:daily fixed cache hit 时应直接返回当前值", async () => {
+    const { RateLimitService } = await import("@/lib/rate-limit");
+
+    redisClientRef.get.mockImplementation(async (key: string) => {
+      if (key === "provider:9:cost_daily_0000") return "7.5";
+      return null;
+    });
+
+    const current = await RateLimitService.getCurrentCost(9, "provider", "daily", "00:00", "fixed");
+    expect(current).toBeCloseTo(7.5, 10);
+  });
+
+  it("getCurrentCost:daily rolling cache miss 时应从 DB 重建并返回", async () => {
+    const { RateLimitService } = await import("@/lib/rate-limit");
+
+    redisClientRef.eval.mockResolvedValueOnce("0");
+    redisClientRef.exists.mockResolvedValueOnce(0);
+    statisticsMock.findProviderCostEntriesInTimeRange.mockResolvedValueOnce([
+      { id: 101, createdAt: new Date(nowMs - 60_000), costUsd: 2 },
+      { id: 102, createdAt: new Date(nowMs - 30_000), costUsd: 3 },
+    ]);
+
+    const current = await RateLimitService.getCurrentCost(
+      9,
+      "provider",
+      "daily",
+      "00:00",
+      "rolling"
+    );
+    expect(current).toBeCloseTo(5, 10);
+    expect(pipelineCalls.some((c) => c[0] === "zadd")).toBe(true);
+  });
+
+  it("trackCost:fixed 模式应写入 key/provider 的 daily+weekly+monthly(STRING)", async () => {
+    const { RateLimitService } = await import("@/lib/rate-limit");
+
+    await RateLimitService.trackCost(1, 9, "sess", 1.25, {
+      keyResetMode: "fixed",
+      providerResetMode: "fixed",
+      keyResetTime: "00:00",
+      providerResetTime: "00:00",
+      requestId: 123,
+      createdAtMs: nowMs,
+    });
+
+    // 5h 的 Lua 脚本至少会执行两次(key/provider)
+    expect(redisClientRef.eval).toHaveBeenCalled();
+    expect(pipelineCalls.filter((c) => c[0] === "incrbyfloat").length).toBeGreaterThanOrEqual(4);
+    expect(pipelineCalls.filter((c) => c[0] === "expire").length).toBeGreaterThanOrEqual(4);
+  });
+
+  it("trackCost:rolling 模式应写入 key/provider 的 daily_rolling(ZSET)", async () => {
+    const { RateLimitService } = await import("@/lib/rate-limit");
+
+    await RateLimitService.trackCost(1, 9, "sess", 1.25, {
+      keyResetMode: "rolling",
+      providerResetMode: "rolling",
+      requestId: 123,
+      createdAtMs: nowMs,
+    });
+
+    const evalArgs = redisClientRef.eval.mock.calls.map((c: unknown[]) => String(c[2]));
+    expect(evalArgs.some((k) => k === "key:1:cost_daily_rolling")).toBe(true);
+    expect(evalArgs.some((k) => k === "provider:9:cost_daily_rolling")).toBe(true);
+  });
+
+  it("getCurrentCostBatch:pipeline.exec 返回 null 时应返回默认值", async () => {
+    const { RateLimitService } = await import("@/lib/rate-limit");
+
+    const pipeline = makePipeline();
+    pipeline.exec.mockResolvedValueOnce(null);
+    redisClientRef.pipeline.mockReturnValueOnce(pipeline);
+
+    const result = await RateLimitService.getCurrentCostBatch([1], new Map());
+    expect(result.get(1)).toEqual({ cost5h: 0, costDaily: 0, costWeekly: 0, costMonthly: 0 });
+  });
+
+  it("getCurrentCostBatch:单个 query 出错时应跳过该项", async () => {
+    const { RateLimitService } = await import("@/lib/rate-limit");
+
+    const pipeline = makePipeline();
+    pipeline.exec.mockResolvedValueOnce([
+      [new Error("boom"), null],
+      [null, "2.5"],
+      [null, "3.5"],
+      [null, "4.5"],
+    ]);
+    redisClientRef.pipeline.mockReturnValueOnce(pipeline);
+
+    const result = await RateLimitService.getCurrentCostBatch([1], new Map());
+    // 5h 出错,保持默认 0,其余正常
+    expect(result.get(1)).toEqual({ cost5h: 0, costDaily: 2.5, costWeekly: 3.5, costMonthly: 4.5 });
+  });
+});

+ 59 - 0
tests/unit/lib/rate-limit/time-utils.test.ts

@@ -0,0 +1,59 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import {
+  getDailyResetTime,
+  getResetInfoWithMode,
+  getSecondsUntilMidnight,
+  getTimeRangeForPeriodWithMode,
+  getTTLForPeriod,
+  getTTLForPeriodWithMode,
+  normalizeResetTime,
+} from "@/lib/rate-limit/time-utils";
+
+describe("rate-limit time-utils", () => {
+  const nowMs = 1_700_000_000_000;
+
+  beforeEach(() => {
+    vi.useFakeTimers();
+    vi.setSystemTime(new Date(nowMs));
+  });
+
+  afterEach(() => {
+    vi.useRealTimers();
+  });
+
+  it("normalizeResetTime:非法时间应回退到安全默认值", () => {
+    expect(normalizeResetTime("abc")).toBe("00:00");
+    expect(normalizeResetTime("99:10")).toBe("00:10");
+    expect(normalizeResetTime("12:70")).toBe("12:00");
+  });
+
+  it("getTimeRangeForPeriodWithMode:daily rolling 应返回过去 24 小时窗口", () => {
+    const { startTime, endTime } = getTimeRangeForPeriodWithMode("daily", "00:00", "rolling");
+
+    expect(endTime.getTime()).toBe(nowMs);
+    expect(startTime.getTime()).toBe(nowMs - 24 * 60 * 60 * 1000);
+  });
+
+  it("getResetInfoWithMode:daily rolling 应返回 rolling 语义", () => {
+    const info = getResetInfoWithMode("daily", "00:00", "rolling");
+    expect(info.type).toBe("rolling");
+    expect(info.period).toBe("24 小时");
+  });
+
+  it("getTTLForPeriodWithMode:daily rolling TTL 应为 24 小时", () => {
+    expect(getTTLForPeriodWithMode("daily", "00:00", "rolling")).toBe(24 * 3600);
+  });
+
+  it("getTTLForPeriod:5h TTL 应为 5 小时", () => {
+    expect(getTTLForPeriod("5h")).toBe(5 * 3600);
+  });
+
+  it("getSecondsUntilMidnight/getDailyResetTime:应能计算出合理的每日重置时间", () => {
+    const seconds = getSecondsUntilMidnight();
+    expect(seconds).toBeGreaterThan(0);
+    expect(seconds).toBeLessThanOrEqual(24 * 3600);
+
+    const resetAt = getDailyResetTime();
+    expect(resetAt.getTime()).toBeGreaterThan(nowMs);
+  });
+});

+ 409 - 0
tests/unit/proxy/rate-limit-guard.test.ts

@@ -0,0 +1,409 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const rateLimitServiceMock = {
+  checkTotalCostLimit: vi.fn(),
+  checkSessionLimit: vi.fn(),
+  checkUserRPM: vi.fn(),
+  checkCostLimits: vi.fn(),
+  checkUserDailyCost: vi.fn(),
+};
+
+vi.mock("@/lib/rate-limit", () => ({
+  RateLimitService: rateLimitServiceMock,
+}));
+
+vi.mock("@/lib/logger", () => ({
+  logger: {
+    warn: vi.fn(),
+    info: vi.fn(),
+    error: vi.fn(),
+    debug: vi.fn(),
+  },
+}));
+
+vi.mock("next-intl/server", () => ({
+  getLocale: vi.fn(async () => "zh-CN"),
+}));
+
+const getErrorMessageServerMock = vi.fn(async () => "mock rate limit message");
+
+vi.mock("@/lib/utils/error-messages", () => ({
+  ERROR_CODES: {
+    RATE_LIMIT_TOTAL_EXCEEDED: "RATE_LIMIT_TOTAL_EXCEEDED",
+    RATE_LIMIT_CONCURRENT_SESSIONS_EXCEEDED: "RATE_LIMIT_CONCURRENT_SESSIONS_EXCEEDED",
+    RATE_LIMIT_RPM_EXCEEDED: "RATE_LIMIT_RPM_EXCEEDED",
+    RATE_LIMIT_DAILY_QUOTA_EXCEEDED: "RATE_LIMIT_DAILY_QUOTA_EXCEEDED",
+    RATE_LIMIT_5H_EXCEEDED: "RATE_LIMIT_5H_EXCEEDED",
+    RATE_LIMIT_WEEKLY_EXCEEDED: "RATE_LIMIT_WEEKLY_EXCEEDED",
+    RATE_LIMIT_MONTHLY_EXCEEDED: "RATE_LIMIT_MONTHLY_EXCEEDED",
+  },
+  getErrorMessageServer: getErrorMessageServerMock,
+}));
+
+describe("ProxyRateLimitGuard - key daily limit enforcement", () => {
+  const createSession = (overrides?: {
+    user?: Partial<{
+      id: number;
+      rpm: number | null;
+      dailyQuota: number | null;
+      dailyResetMode: "fixed" | "rolling";
+      dailyResetTime: string;
+      limit5hUsd: number | null;
+      limitWeeklyUsd: number | null;
+      limitMonthlyUsd: number | null;
+      limitTotalUsd: number | null;
+    }>;
+    key?: Partial<{
+      id: number;
+      key: string;
+      limit5hUsd: number | null;
+      limitDailyUsd: number | null;
+      dailyResetMode: "fixed" | "rolling";
+      dailyResetTime: string;
+      limitWeeklyUsd: number | null;
+      limitMonthlyUsd: number | null;
+      limitTotalUsd: number | null;
+      limitConcurrentSessions: number;
+    }>;
+  }) => {
+    return {
+      authState: {
+        user: {
+          id: 1,
+          rpm: null,
+          dailyQuota: null,
+          dailyResetMode: "fixed",
+          dailyResetTime: "00:00",
+          limit5hUsd: null,
+          limitWeeklyUsd: null,
+          limitMonthlyUsd: null,
+          limitTotalUsd: null,
+          ...overrides?.user,
+        },
+        key: {
+          id: 2,
+          key: "k_test",
+          limit5hUsd: null,
+          limitDailyUsd: null,
+          dailyResetMode: "fixed",
+          dailyResetTime: "00:00",
+          limitWeeklyUsd: null,
+          limitMonthlyUsd: null,
+          limitTotalUsd: null,
+          limitConcurrentSessions: 0,
+          ...overrides?.key,
+        },
+      },
+    } as any;
+  };
+
+  beforeEach(() => {
+    vi.clearAllMocks();
+
+    rateLimitServiceMock.checkTotalCostLimit.mockResolvedValue({ allowed: true });
+    rateLimitServiceMock.checkSessionLimit.mockResolvedValue({ allowed: true });
+    rateLimitServiceMock.checkUserRPM.mockResolvedValue({ allowed: true });
+    rateLimitServiceMock.checkUserDailyCost.mockResolvedValue({ allowed: true });
+    rateLimitServiceMock.checkCostLimits.mockResolvedValue({ allowed: true });
+  });
+
+  it("当用户未设置每日额度时,Key 每日额度已超限也必须拦截", async () => {
+    const { ProxyRateLimitGuard } = await import("@/app/v1/_lib/proxy/rate-limit-guard");
+
+    rateLimitServiceMock.checkCostLimits
+      .mockResolvedValueOnce({ allowed: true }) // key 5h
+      .mockResolvedValueOnce({ allowed: true }) // user 5h
+      .mockResolvedValueOnce({ allowed: false, reason: "Key 每日消费上限已达到(20.0000/10)" }); // key daily
+
+    const session = createSession({
+      user: { dailyQuota: null },
+      key: { limitDailyUsd: 10 },
+    });
+
+    await expect(ProxyRateLimitGuard.ensure(session)).rejects.toMatchObject({
+      name: "RateLimitError",
+      limitType: "daily_quota",
+      currentUsage: 20,
+      limitValue: 10,
+    });
+
+    expect(rateLimitServiceMock.checkUserDailyCost).not.toHaveBeenCalled();
+
+    expect(rateLimitServiceMock.checkCostLimits).toHaveBeenCalledWith(2, "key", {
+      limit_5h_usd: null,
+      limit_daily_usd: 10,
+      daily_reset_mode: "fixed",
+      daily_reset_time: "00:00",
+      limit_weekly_usd: null,
+      limit_monthly_usd: null,
+    });
+  });
+
+  it("当 Key 每日额度超限时,应在用户每日检查之前直接拦截(Key 优先)", async () => {
+    const { ProxyRateLimitGuard } = await import("@/app/v1/_lib/proxy/rate-limit-guard");
+
+    rateLimitServiceMock.checkCostLimits
+      .mockResolvedValueOnce({ allowed: true }) // key 5h
+      .mockResolvedValueOnce({ allowed: true }) // user 5h
+      .mockResolvedValueOnce({ allowed: false, reason: "Key 每日消费上限已达到(20.0000/10)" }); // key daily
+
+    const session = createSession({
+      user: { dailyQuota: 999 },
+      key: { limitDailyUsd: 10 },
+    });
+
+    await expect(ProxyRateLimitGuard.ensure(session)).rejects.toMatchObject({
+      name: "RateLimitError",
+      limitType: "daily_quota",
+    });
+
+    expect(rateLimitServiceMock.checkUserDailyCost).not.toHaveBeenCalled();
+  });
+
+  it("当 Key 未设置每日额度且用户每日额度已超限时,仍应拦截用户每日额度", async () => {
+    const { ProxyRateLimitGuard } = await import("@/app/v1/_lib/proxy/rate-limit-guard");
+
+    rateLimitServiceMock.checkUserDailyCost.mockResolvedValue({
+      allowed: false,
+      current: 20,
+      reason: "用户每日消费上限已达到($20.0000/$10)",
+    });
+
+    rateLimitServiceMock.checkCostLimits
+      .mockResolvedValueOnce({ allowed: true }) // key 5h
+      .mockResolvedValueOnce({ allowed: true }) // user 5h
+      .mockResolvedValueOnce({ allowed: true }); // key daily (limit null)
+
+    const session = createSession({
+      user: { dailyQuota: 10 },
+      key: { limitDailyUsd: null },
+    });
+
+    await expect(ProxyRateLimitGuard.ensure(session)).rejects.toMatchObject({
+      name: "RateLimitError",
+      limitType: "daily_quota",
+      currentUsage: 20,
+      limitValue: 10,
+    });
+
+    expect(rateLimitServiceMock.checkUserDailyCost).toHaveBeenCalledTimes(1);
+    expect(getErrorMessageServerMock).toHaveBeenCalledTimes(1);
+  });
+
+  it("Key 总限额超限应拦截(usd_total)", async () => {
+    const { ProxyRateLimitGuard } = await import("@/app/v1/_lib/proxy/rate-limit-guard");
+
+    rateLimitServiceMock.checkTotalCostLimit.mockResolvedValueOnce({
+      allowed: false,
+      current: 20,
+      reason: "Key total limit exceeded",
+    });
+
+    const session = createSession({
+      key: { limitTotalUsd: 10 },
+    });
+
+    await expect(ProxyRateLimitGuard.ensure(session)).rejects.toMatchObject({
+      name: "RateLimitError",
+      limitType: "usd_total",
+      currentUsage: 20,
+      limitValue: 10,
+    });
+  });
+
+  it("User 总限额超限应拦截(usd_total)", async () => {
+    const { ProxyRateLimitGuard } = await import("@/app/v1/_lib/proxy/rate-limit-guard");
+
+    rateLimitServiceMock.checkTotalCostLimit
+      .mockResolvedValueOnce({ allowed: true }) // key total
+      .mockResolvedValueOnce({ allowed: false, current: 20, reason: "User total limit exceeded" }); // user total
+
+    const session = createSession({
+      user: { limitTotalUsd: 10 },
+    });
+
+    await expect(ProxyRateLimitGuard.ensure(session)).rejects.toMatchObject({
+      name: "RateLimitError",
+      limitType: "usd_total",
+      currentUsage: 20,
+      limitValue: 10,
+    });
+  });
+
+  it("Key 并发 Session 超限应拦截(concurrent_sessions)", async () => {
+    const { ProxyRateLimitGuard } = await import("@/app/v1/_lib/proxy/rate-limit-guard");
+
+    rateLimitServiceMock.checkSessionLimit.mockResolvedValueOnce({
+      allowed: false,
+      reason: "Key并发 Session 上限已达到(2/1)",
+    });
+
+    const session = createSession({
+      key: { limitConcurrentSessions: 1 },
+    });
+
+    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({
+      allowed: false,
+      current: 10,
+      reason: "用户每分钟请求数上限已达到(10/5)",
+    });
+
+    const session = createSession({
+      user: { rpm: 5 },
+    });
+
+    await expect(ProxyRateLimitGuard.ensure(session)).rejects.toMatchObject({
+      name: "RateLimitError",
+      limitType: "rpm",
+      currentUsage: 10,
+      limitValue: 5,
+    });
+  });
+
+  it("Key 5h 超限应拦截(usd_5h)", async () => {
+    const { ProxyRateLimitGuard } = await import("@/app/v1/_lib/proxy/rate-limit-guard");
+
+    rateLimitServiceMock.checkCostLimits.mockResolvedValueOnce({
+      allowed: false,
+      reason: "Key 5小时消费上限已达到(20.0000/10)",
+    });
+
+    const session = createSession({
+      key: { limit5hUsd: 10 },
+    });
+
+    await expect(ProxyRateLimitGuard.ensure(session)).rejects.toMatchObject({
+      name: "RateLimitError",
+      limitType: "usd_5h",
+      currentUsage: 20,
+      limitValue: 10,
+    });
+  });
+
+  it("User 5h 超限应拦截(usd_5h)", async () => {
+    const { ProxyRateLimitGuard } = await import("@/app/v1/_lib/proxy/rate-limit-guard");
+
+    rateLimitServiceMock.checkCostLimits
+      .mockResolvedValueOnce({ allowed: true }) // key 5h
+      .mockResolvedValueOnce({ allowed: false, reason: "User 5小时消费上限已达到(20.0000/10)" }); // user 5h
+
+    const session = createSession({
+      user: { limit5hUsd: 10 },
+    });
+
+    await expect(ProxyRateLimitGuard.ensure(session)).rejects.toMatchObject({
+      name: "RateLimitError",
+      limitType: "usd_5h",
+      currentUsage: 20,
+      limitValue: 10,
+    });
+  });
+
+  it("Key 周限额超限应拦截(usd_weekly)", async () => {
+    const { ProxyRateLimitGuard } = await import("@/app/v1/_lib/proxy/rate-limit-guard");
+
+    rateLimitServiceMock.checkCostLimits
+      .mockResolvedValueOnce({ allowed: true }) // key 5h
+      .mockResolvedValueOnce({ allowed: true }) // user 5h
+      .mockResolvedValueOnce({ allowed: true }) // key daily
+      .mockResolvedValueOnce({ allowed: false, reason: "Key 周消费上限已达到(100.0000/10)" }); // key weekly
+
+    const session = createSession({
+      key: { limitWeeklyUsd: 10 },
+    });
+
+    await expect(ProxyRateLimitGuard.ensure(session)).rejects.toMatchObject({
+      name: "RateLimitError",
+      limitType: "usd_weekly",
+      currentUsage: 100,
+      limitValue: 10,
+    });
+  });
+
+  it("User 周限额超限应拦截(usd_weekly)", async () => {
+    const { ProxyRateLimitGuard } = await import("@/app/v1/_lib/proxy/rate-limit-guard");
+
+    rateLimitServiceMock.checkCostLimits
+      .mockResolvedValueOnce({ allowed: true }) // key 5h
+      .mockResolvedValueOnce({ allowed: true }) // user 5h
+      .mockResolvedValueOnce({ allowed: true }) // key daily
+      .mockResolvedValueOnce({ allowed: true }) // key weekly
+      .mockResolvedValueOnce({ allowed: false, reason: "User 周消费上限已达到(100.0000/10)" }); // user weekly
+
+    const session = createSession({
+      user: { limitWeeklyUsd: 10 },
+    });
+
+    await expect(ProxyRateLimitGuard.ensure(session)).rejects.toMatchObject({
+      name: "RateLimitError",
+      limitType: "usd_weekly",
+      currentUsage: 100,
+      limitValue: 10,
+    });
+  });
+
+  it("Key 月限额超限应拦截(usd_monthly)", async () => {
+    const { ProxyRateLimitGuard } = await import("@/app/v1/_lib/proxy/rate-limit-guard");
+
+    rateLimitServiceMock.checkCostLimits
+      .mockResolvedValueOnce({ allowed: true }) // key 5h
+      .mockResolvedValueOnce({ allowed: true }) // user 5h
+      .mockResolvedValueOnce({ allowed: true }) // key daily
+      .mockResolvedValueOnce({ allowed: true }) // key weekly
+      .mockResolvedValueOnce({ allowed: true }) // user weekly
+      .mockResolvedValueOnce({ allowed: false, reason: "Key 月消费上限已达到(200.0000/10)" }); // key monthly
+
+    const session = createSession({
+      key: { limitMonthlyUsd: 10 },
+    });
+
+    await expect(ProxyRateLimitGuard.ensure(session)).rejects.toMatchObject({
+      name: "RateLimitError",
+      limitType: "usd_monthly",
+      currentUsage: 200,
+      limitValue: 10,
+    });
+  });
+
+  it("User 月限额超限应拦截(usd_monthly)", async () => {
+    const { ProxyRateLimitGuard } = await import("@/app/v1/_lib/proxy/rate-limit-guard");
+
+    rateLimitServiceMock.checkCostLimits
+      .mockResolvedValueOnce({ allowed: true }) // key 5h
+      .mockResolvedValueOnce({ allowed: true }) // user 5h
+      .mockResolvedValueOnce({ allowed: true }) // key daily
+      .mockResolvedValueOnce({ allowed: true }) // key weekly
+      .mockResolvedValueOnce({ allowed: true }) // user weekly
+      .mockResolvedValueOnce({ allowed: true }) // key monthly
+      .mockResolvedValueOnce({ allowed: false, reason: "User 月消费上限已达到(200.0000/10)" }); // user monthly
+
+    const session = createSession({
+      user: { limitMonthlyUsd: 10 },
+    });
+
+    await expect(ProxyRateLimitGuard.ensure(session)).rejects.toMatchObject({
+      name: "RateLimitError",
+      limitType: "usd_monthly",
+      currentUsage: 200,
+      limitValue: 10,
+    });
+  });
+
+  it("所有限额均未触发时应放行", async () => {
+    const { ProxyRateLimitGuard } = await import("@/app/v1/_lib/proxy/rate-limit-guard");
+
+    const session = createSession();
+    await expect(ProxyRateLimitGuard.ensure(session)).resolves.toBeUndefined();
+  });
+});

+ 45 - 0
vitest.quota.config.ts

@@ -0,0 +1,45 @@
+import path from "node:path";
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+  test: {
+    globals: true,
+    environment: "node",
+    setupFiles: ["./tests/setup.ts"],
+
+    include: [
+      "tests/unit/lib/rate-limit/**/*.{test,spec}.ts",
+      "tests/unit/proxy/rate-limit-guard.test.ts",
+    ],
+    exclude: ["node_modules", ".next", "dist", "build", "coverage", "tests/integration/**"],
+
+    coverage: {
+      provider: "v8",
+      reporter: ["text", "html", "json"],
+      reportsDirectory: "./coverage-quota",
+
+      include: ["src/lib/rate-limit/**", "src/app/v1/_lib/proxy/rate-limit-guard.ts"],
+      exclude: ["node_modules/", "tests/", "**/*.d.ts", ".next/", "src/lib/rate-limit/index.ts"],
+
+      thresholds: {
+        lines: 80,
+        functions: 80,
+        branches: 70,
+        statements: 80,
+      },
+    },
+
+    reporters: ["verbose"],
+    isolate: true,
+    mockReset: true,
+    restoreMocks: true,
+    clearMocks: true,
+  },
+
+  resolve: {
+    alias: {
+      "@": path.resolve(__dirname, "./src"),
+      "server-only": path.resolve(__dirname, "./tests/server-only.mock.ts"),
+    },
+  },
+});