Browse Source

Merge pull request #145 from Silentely/dev

feat 新增每日消费限额功能,完善限额日志显示
Ding 3 months ago
parent
commit
11863a0b31
62 changed files with 1530 additions and 89 deletions
  1. 3 2
      bun.lock
  2. 22 0
      drizzle/0021_daily_cost_limits.sql
  3. 27 1
      drizzle/meta/0018_snapshot.json
  4. 27 1
      drizzle/meta/0019_snapshot.json
  5. 41 1
      drizzle/meta/0020_snapshot.json
  6. 8 1
      drizzle/meta/_journal.json
  7. 12 0
      messages/en/dashboard.json
  8. 24 0
      messages/en/quota.json
  9. 19 0
      messages/en/settings.json
  10. 1 0
      messages/en/usage.json
  11. 1 0
      messages/ja/usage.json
  12. 1 0
      messages/ru/usage.json
  13. 12 0
      messages/zh-CN/dashboard.json
  14. 24 0
      messages/zh-CN/quota.json
  15. 22 3
      messages/zh-CN/settings.json
  16. 1 0
      messages/zh-CN/usage.json
  17. 1 0
      messages/zh-TW/dashboard.json
  18. 1 0
      messages/zh-TW/usage.json
  19. BIN
      public/readme/供应商管理.png
  20. BIN
      public/readme/排行榜.png
  21. BIN
      public/readme/日志.png
  22. BIN
      public/readme/首页.png
  23. 39 2
      src/actions/keys.ts
  24. 31 2
      src/actions/providers.ts
  25. 2 0
      src/actions/users.ts
  26. 1 1
      src/app/[locale]/dashboard/_components/statistics/chart.tsx
  27. 55 0
      src/app/[locale]/dashboard/_components/user/forms/add-key-form.tsx
  28. 24 0
      src/app/[locale]/dashboard/_components/user/forms/edit-key-form.tsx
  29. 7 0
      src/app/[locale]/dashboard/_components/user/key-limit-usage.tsx
  30. 1 0
      src/app/[locale]/dashboard/_components/user/key-list.tsx
  31. 93 5
      src/app/[locale]/dashboard/logs/_components/error-details-dialog.tsx
  32. 52 0
      src/app/[locale]/dashboard/quotas/keys/_components/edit-key-quota-dialog.tsx
  33. 39 0
      src/app/[locale]/dashboard/quotas/keys/_components/keys-quota-client.tsx
  34. 3 0
      src/app/[locale]/dashboard/quotas/keys/_components/keys-quota-manager.tsx
  35. 28 0
      src/app/[locale]/dashboard/quotas/providers/_components/providers-quota-client.tsx
  36. 1 0
      src/app/[locale]/dashboard/quotas/providers/_components/providers-quota-manager.tsx
  37. 1 1
      src/app/[locale]/settings/providers/_components/add-provider-dialog.tsx
  38. 91 0
      src/app/[locale]/settings/providers/_components/forms/provider-form.tsx
  39. 8 4
      src/app/[locale]/settings/providers/_components/provider-rich-list-item.tsx
  40. 12 5
      src/app/[locale]/usage-doc/page.tsx
  41. 19 0
      src/app/v1/_lib/codex/chat-completions-handler.ts
  42. 67 9
      src/app/v1/_lib/proxy/provider-selector.ts
  43. 3 0
      src/app/v1/_lib/proxy/rate-limit-guard.ts
  44. 7 1
      src/app/v1/_lib/proxy/response-handler.ts
  45. 53 3
      src/app/v1/_lib/proxy/responses.ts
  46. 16 0
      src/drizzle/schema.ts
  47. 3 0
      src/lib/auth.ts
  48. 2 1
      src/lib/hooks/use-format-currency.ts
  49. 229 32
      src/lib/rate-limit/service.ts
  50. 159 7
      src/lib/rate-limit/time-utils.ts
  51. 19 3
      src/lib/redis/client.ts
  52. 80 0
      src/lib/redis/lua-scripts.ts
  53. 4 2
      src/lib/utils/error-messages.ts
  54. 5 0
      src/lib/utils/quota-helpers.ts
  55. 12 2
      src/lib/utils/zod-i18n.ts
  56. 35 0
      src/lib/validation/schemas.ts
  57. 4 0
      src/repository/_shared/transformers.ts
  58. 32 0
      src/repository/key.ts
  59. 23 0
      src/repository/provider.ts
  60. 9 0
      src/types/key.ts
  61. 12 0
      src/types/provider.ts
  62. 2 0
      src/types/user.ts

File diff suppressed because it is too large
+ 3 - 2
bun.lock


+ 22 - 0
drizzle/0021_daily_cost_limits.sql

@@ -0,0 +1,22 @@
+-- 每日成本限额功能 - 统一迁移文件
+-- 包含:添加字段、设置约束、添加重置模式
+
+-- Step 1: 添加基础字段
+ALTER TABLE "keys" ADD COLUMN "limit_daily_usd" numeric(10, 2);--> statement-breakpoint
+ALTER TABLE "keys" ADD COLUMN "daily_reset_time" varchar(5) DEFAULT '00:00';--> statement-breakpoint
+ALTER TABLE "keys" ADD COLUMN "daily_reset_mode" varchar(10) DEFAULT 'fixed' NOT NULL;--> statement-breakpoint
+ALTER TABLE "providers" ADD COLUMN "limit_daily_usd" numeric(10, 2);--> statement-breakpoint
+ALTER TABLE "providers" ADD COLUMN "daily_reset_time" varchar(5) DEFAULT '00:00';--> statement-breakpoint
+ALTER TABLE "providers" ADD COLUMN "daily_reset_mode" varchar(10) DEFAULT 'fixed' NOT NULL;--> statement-breakpoint
+
+-- Step 2: 数据清理和约束设置
+UPDATE "keys"
+SET "daily_reset_time" = '00:00'
+WHERE "daily_reset_time" IS NULL OR trim("daily_reset_time") = '';--> statement-breakpoint
+ALTER TABLE "keys" ALTER COLUMN "daily_reset_time" SET DEFAULT '00:00';--> statement-breakpoint
+ALTER TABLE "keys" ALTER COLUMN "daily_reset_time" SET NOT NULL;--> statement-breakpoint
+UPDATE "providers"
+SET "daily_reset_time" = '00:00'
+WHERE "daily_reset_time" IS NULL OR trim("daily_reset_time") = '';--> statement-breakpoint
+ALTER TABLE "providers" ALTER COLUMN "daily_reset_time" SET DEFAULT '00:00';--> statement-breakpoint
+ALTER TABLE "providers" ALTER COLUMN "daily_reset_time" SET NOT NULL;

+ 27 - 1
drizzle/meta/0018_snapshot.json

@@ -204,6 +204,19 @@
           "primaryKey": false,
           "notNull": false
         },
+        "limit_daily_usd": {
+          "name": "limit_daily_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "daily_reset_time": {
+          "name": "daily_reset_time",
+          "type": "varchar(5)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'00:00'"
+        },
         "limit_weekly_usd": {
           "name": "limit_weekly_usd",
           "type": "numeric(10, 2)",
@@ -940,6 +953,19 @@
           "primaryKey": false,
           "notNull": false
         },
+        "limit_daily_usd": {
+          "name": "limit_daily_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "daily_reset_time": {
+          "name": "daily_reset_time",
+          "type": "varchar(5)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'00:00'"
+        },
         "limit_weekly_usd": {
           "name": "limit_weekly_usd",
           "type": "numeric(10, 2)",
@@ -1471,4 +1497,4 @@
     "schemas": {},
     "tables": {}
   }
-}
+}

+ 27 - 1
drizzle/meta/0019_snapshot.json

@@ -204,6 +204,19 @@
           "primaryKey": false,
           "notNull": false
         },
+        "limit_daily_usd": {
+          "name": "limit_daily_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "daily_reset_time": {
+          "name": "daily_reset_time",
+          "type": "varchar(5)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'00:00'"
+        },
         "limit_weekly_usd": {
           "name": "limit_weekly_usd",
           "type": "numeric(10, 2)",
@@ -940,6 +953,19 @@
           "primaryKey": false,
           "notNull": false
         },
+        "limit_daily_usd": {
+          "name": "limit_daily_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "daily_reset_time": {
+          "name": "daily_reset_time",
+          "type": "varchar(5)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'00:00'"
+        },
         "limit_weekly_usd": {
           "name": "limit_weekly_usd",
           "type": "numeric(10, 2)",
@@ -1492,4 +1518,4 @@
     "schemas": {},
     "tables": {}
   }
-}
+}

+ 41 - 1
drizzle/meta/0020_snapshot.json

@@ -204,6 +204,26 @@
           "primaryKey": false,
           "notNull": false
         },
+        "limit_daily_usd": {
+          "name": "limit_daily_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "daily_reset_mode": {
+          "name": "daily_reset_mode",
+          "type": "varchar(10)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'fixed'"
+        },
+        "daily_reset_time": {
+          "name": "daily_reset_time",
+          "type": "varchar(5)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'00:00'"
+        },
         "limit_weekly_usd": {
           "name": "limit_weekly_usd",
           "type": "numeric(10, 2)",
@@ -940,6 +960,26 @@
           "primaryKey": false,
           "notNull": false
         },
+        "limit_daily_usd": {
+          "name": "limit_daily_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "daily_reset_mode": {
+          "name": "daily_reset_mode",
+          "type": "varchar(10)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'fixed'"
+        },
+        "daily_reset_time": {
+          "name": "daily_reset_time",
+          "type": "varchar(5)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'00:00'"
+        },
         "limit_weekly_usd": {
           "name": "limit_weekly_usd",
           "type": "numeric(10, 2)",
@@ -1516,4 +1556,4 @@
     "schemas": {},
     "tables": {}
   }
-}
+}

+ 8 - 1
drizzle/meta/_journal.json

@@ -148,6 +148,13 @@
       "when": 1763465177387,
       "tag": "0020_next_juggernaut",
       "breakpoints": true
+    },
+    {
+      "idx": 21,
+      "version": "7",
+      "when": 1763823720000,
+      "tag": "0021_daily_cost_limits",
+      "breakpoints": true
     }
   ]
-}
+}

+ 12 - 0
messages/en/dashboard.json

@@ -150,6 +150,7 @@
         "billingDescription": "The system prioritizes billing based on the price of the requested model ({original}). If the model is not found in the price list, the price of the actually called model ({current}) is used."
       },
       "errorMessage": "Error Message",
+      "filteredProviders": "Filtered Providers",
       "providerChain": {
         "title": "Provider Decision Chain Timeline",
         "totalDuration": "Total Duration: {duration}ms"
@@ -477,6 +478,7 @@
     "error": "Fetch failed",
     "networkError": "Network error",
     "cost5h": "5-Hour Cost",
+    "costDaily": "Daily Cost",
     "costWeekly": "Weekly Cost",
     "costMonthly": "Monthly Cost",
     "concurrentSessions": "Concurrent Sessions",
@@ -505,6 +507,16 @@
       "placeholder": "Leave blank for unlimited",
       "description": "Maximum cost within 5 hours"
     },
+    "limitDailyUsd": {
+      "label": "Daily Cost Limit (USD)",
+      "placeholder": "Leave blank for unlimited",
+      "description": "Maximum cost per day"
+    },
+    "dailyResetTime": {
+      "label": "Daily Reset Time",
+      "placeholder": "HH:mm",
+      "description": "When the daily limit resets (uses system timezone)"
+    },
     "limitWeeklyUsd": {
       "label": "Weekly Cost Limit (USD)",
       "placeholder": "Leave blank for unlimited",

+ 24 - 0
messages/en/quota.json

@@ -103,6 +103,10 @@
     "cost5h": {
       "label": "5-Hour Cost"
     },
+    "costDaily": {
+      "label": "Daily Cost",
+      "resetAt": "Resets at"
+    },
     "costWeekly": {
       "label": "Weekly Cost",
       "resetAt": "Resets at"
@@ -136,6 +140,7 @@
       "keyName": "Key Name",
       "quotaType": "Quota Type",
       "cost5h": "5-Hour Quota",
+      "costDaily": "Daily Quota",
       "costWeekly": "Weekly Quota",
       "costMonthly": "Monthly Quota",
       "concurrentSessions": "Concurrent Limit",
@@ -160,6 +165,15 @@
         "placeholder": "Unlimited",
         "current": "Current usage: {currency}{current} / {currency}{limit}"
       },
+      "costDaily": {
+        "label": "Daily Quota (USD)",
+        "placeholder": "Unlimited",
+        "current": "Current usage: {currency}{current} / {currency}{limit}"
+      },
+      "dailyResetTime": {
+        "label": "Daily Reset Time",
+        "placeholder": "HH:mm"
+      },
       "costWeekly": {
         "label": "Weekly Quota (USD)",
         "placeholder": "Unlimited",
@@ -225,6 +239,16 @@
         "placeholder": "Leave blank for unlimited",
         "description": "Maximum cost within 5 hours"
       },
+      "limitDailyUsd": {
+        "label": "Daily Cost Limit (USD)",
+        "placeholder": "Leave blank for unlimited",
+        "description": "Maximum cost per day"
+      },
+      "dailyResetTime": {
+        "label": "Daily Reset Time",
+        "placeholder": "HH:mm",
+        "description": "When the daily limit resets (uses system timezone)"
+      },
       "limitWeeklyUsd": {
         "label": "Weekly Cost Limit (USD)",
         "placeholder": "Leave blank for unlimited",

+ 19 - 0
messages/en/settings.json

@@ -968,6 +968,7 @@
           "title": "Rate Limit",
           "summary": {
             "fiveHour": "5h: ${amount}",
+            "daily": "Day: ${amount} (reset ${resetTime})",
             "weekly": "Week: ${amount}",
             "monthly": "Month: ${amount}",
             "concurrent": "Concurrent: {count}",
@@ -977,6 +978,24 @@
             "label": "5h Spend Limit (USD)",
             "placeholder": "Leave empty for unlimited"
           },
+          "limitDaily": {
+            "label": "Daily Spend Limit (USD)",
+            "placeholder": "Leave empty for unlimited"
+          },
+          "dailyResetMode": {
+            "label": "Daily Reset Mode",
+            "options": {
+              "fixed": "Fixed Time Reset",
+              "rolling": "Rolling Window (24h)"
+            },
+            "desc": {
+              "fixed": "Reset quota at a fixed time each day",
+              "rolling": "Reset 24 hours after first API call"
+            }
+          },
+          "dailyResetTime": {
+            "label": "Daily Reset Time (HH:mm)"
+          },
           "limitWeekly": {
             "label": "Weekly Spend Limit (USD)",
             "placeholder": "Leave empty for unlimited"

+ 1 - 0
messages/en/usage.json

@@ -231,6 +231,7 @@
         "Create a config.json file in the ~/.claude directory (if it doesn't exist)",
         "Add the following content:"
       ],
+      "configPath": "Configuration file location: {path}",
 
       "note": "Note",
       "notePoints": [

+ 1 - 0
messages/ja/usage.json

@@ -231,6 +231,7 @@
         "~/.claude ディレクトリに config.json ファイルを作成 (存在しない場合)",
         "以下の内容を追加:"
       ],
+      "configPath": "設定ファイルの場所:{path}",
 
       "note": "注記",
       "notePoints": [

+ 1 - 0
messages/ru/usage.json

@@ -231,6 +231,7 @@
         "Создайте файл config.json в директории ~/.claude (если его нет)",
         "Добавьте следующее содержимое:"
       ],
+      "configPath": "Путь к файлу конфигурации: {path}",
 
       "note": "Примечание",
       "notePoints": [

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

@@ -151,6 +151,7 @@
         "billingDescription": "系统优先使用请求模型({original})的价格计费。如果价格表中不存在该模型,则使用实际调用模型({current})的价格。"
       },
       "errorMessage": "错误信息",
+      "filteredProviders": "被过滤的供应商",
       "providerChain": {
         "title": "供应商决策链时间线",
         "totalDuration": "总耗时: {duration}ms"
@@ -478,6 +479,7 @@
     "error": "获取失败",
     "networkError": "网络错误",
     "cost5h": "5小时消费",
+    "costDaily": "每日消费",
     "costWeekly": "周消费",
     "costMonthly": "月消费",
     "concurrentSessions": "并发 Session",
@@ -507,6 +509,16 @@
       "description": "5小时内最大消费金额",
       "descriptionWithUserLimit": "5小时内最大消费金额(用户限额: ${limit})"
     },
+    "limitDailyUsd": {
+      "label": "每日消费上限 (USD)",
+      "placeholder": "留空表示无限制",
+      "description": "每日最大消费金额"
+    },
+    "dailyResetTime": {
+      "label": "每日重置时间",
+      "placeholder": "HH:mm",
+      "description": "每日限额的重置时间(使用系统时区)"
+    },
     "limitWeeklyUsd": {
       "label": "周消费上限 (USD)",
       "placeholder": "留空表示无限制",

+ 24 - 0
messages/zh-CN/quota.json

@@ -103,6 +103,10 @@
     "cost5h": {
       "label": "5小时消费"
     },
+    "costDaily": {
+      "label": "每日消费",
+      "resetAt": "重置于"
+    },
     "costWeekly": {
       "label": "周消费",
       "resetAt": "重置于"
@@ -136,6 +140,7 @@
       "keyName": "密钥名称",
       "quotaType": "限额类型",
       "cost5h": "5小时限额",
+      "costDaily": "每日限额",
       "costWeekly": "周限额",
       "costMonthly": "月限额",
       "concurrentSessions": "并发限制",
@@ -160,6 +165,15 @@
         "placeholder": "不限制",
         "current": "当前已用: {currency}{current} / {currency}{limit}"
       },
+      "costDaily": {
+        "label": "每日限额(USD)",
+        "placeholder": "不限制",
+        "current": "当前已用: {currency}{current} / {currency}{limit}"
+      },
+      "dailyResetTime": {
+        "label": "每日重置时间",
+        "placeholder": "HH:mm"
+      },
       "costWeekly": {
         "label": "周限额(USD)",
         "placeholder": "不限制",
@@ -226,6 +240,16 @@
         "description": "5小时内最大消费金额",
         "descriptionWithUserLimit": "5小时内最大消费金额(用户限额: ${limit})"
       },
+      "limitDailyUsd": {
+        "label": "每日消费上限 (USD)",
+        "placeholder": "留空表示无限制",
+        "description": "每日最大消费金额"
+      },
+      "dailyResetTime": {
+        "label": "每日重置时间",
+        "placeholder": "HH:mm",
+        "description": "每日限额的重置时间(使用系统时区)"
+      },
       "limitWeeklyUsd": {
         "label": "周消费上限 (USD)",
         "placeholder": "留空表示无限制",

+ 22 - 3
messages/zh-CN/settings.json

@@ -580,9 +580,10 @@
         "rateLimit": {
           "title": "限流配置",
           "summary": {
-            "fiveHour": "5h: ${amount}",
-            "weekly": "周: ${amount}",
-            "monthly": "月: ${amount}",
+            "fiveHour": "5h: {amount}",
+            "daily": "日: {amount} (重置 {resetTime})",
+            "weekly": "周: {amount}",
+            "monthly": "月: {amount}",
             "concurrent": "并发: {count}",
             "none": "无限制"
           },
@@ -590,6 +591,24 @@
             "label": "5小时消费上限 (USD)",
             "placeholder": "留空表示无限制"
           },
+          "limitDaily": {
+            "label": "每日消费上限 (USD)",
+            "placeholder": "留空表示无限制"
+          },
+          "dailyResetMode": {
+            "label": "每日重置模式",
+            "options": {
+              "fixed": "固定时间重置",
+              "rolling": "滚动窗口(24小时)"
+            },
+            "desc": {
+              "fixed": "每天固定时间点重置配额",
+              "rolling": "从首次调用开始计算,24小时后重置"
+            }
+          },
+          "dailyResetTime": {
+            "label": "每日重置时间 (HH:mm)"
+          },
           "limitWeekly": {
             "label": "周消费上限 (USD)",
             "placeholder": "留空表示无限制"

+ 1 - 0
messages/zh-CN/usage.json

@@ -227,6 +227,7 @@
         "在 ~/.claude 目录下创建 config.json 文件(如果没有)",
         "添加以下内容:"
       ],
+      "configPath": "配置文件路径:{path}",
 
       "note": "注意",
       "notePoints": [

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

@@ -151,6 +151,7 @@
         "billingDescription": "系統優先使用請求模型({original})的價格計費。如果價格表中不存在該模型,則使用實際呼叫模型({current})的價格。"
       },
       "errorMessage": "錯誤訊息",
+      "filteredProviders": "被過濾的供應商",
       "providerChain": {
         "title": "供應商決策鏈時間軸",
         "totalDuration": "總耗時: {duration} 毫秒"

+ 1 - 0
messages/zh-TW/usage.json

@@ -227,6 +227,7 @@
         "在 ~/.claude 目錄下創建 config.json 文件(如果沒有)",
         "添加以下內容:"
       ],
+      "configPath": "設定檔路徑:{path}",
 
       "note": "注意",
       "notePoints": [

BIN
public/readme/供应商管理.png


BIN
public/readme/排行榜.png


BIN
public/readme/日志.png


BIN
public/readme/首页.png


+ 39 - 2
src/actions/keys.ts

@@ -27,6 +27,9 @@ export async function addKey(data: {
   expiresAt?: string;
   canLoginWebUi?: boolean;
   limit5hUsd?: number | null;
+  limitDailyUsd?: number | null;
+  dailyResetMode?: "fixed" | "rolling";
+  dailyResetTime?: string;
   limitWeeklyUsd?: number | null;
   limitMonthlyUsd?: number | null;
   limitConcurrentSessions?: number;
@@ -44,6 +47,14 @@ export async function addKey(data: {
     const validatedData = KeyFormSchema.parse({
       name: data.name,
       expiresAt: data.expiresAt,
+      canLoginWebUi: data.canLoginWebUi,
+      limit5hUsd: data.limit5hUsd,
+      limitDailyUsd: data.limitDailyUsd,
+      dailyResetMode: data.dailyResetMode,
+      dailyResetTime: data.dailyResetTime,
+      limitWeeklyUsd: data.limitWeeklyUsd,
+      limitMonthlyUsd: data.limitMonthlyUsd,
+      limitConcurrentSessions: data.limitConcurrentSessions,
     });
 
     // 检查是否存在同名的生效key
@@ -113,6 +124,9 @@ export async function addKey(data: {
       expires_at: expiresAt,
       can_login_web_ui: validatedData.canLoginWebUi,
       limit_5h_usd: validatedData.limit5hUsd,
+      limit_daily_usd: validatedData.limitDailyUsd,
+      daily_reset_mode: validatedData.dailyResetMode,
+      daily_reset_time: validatedData.dailyResetTime,
       limit_weekly_usd: validatedData.limitWeeklyUsd,
       limit_monthly_usd: validatedData.limitMonthlyUsd,
       limit_concurrent_sessions: validatedData.limitConcurrentSessions,
@@ -137,6 +151,8 @@ export async function editKey(
     expiresAt?: string;
     canLoginWebUi?: boolean;
     limit5hUsd?: number | null;
+    limitDailyUsd?: number | null;
+    dailyResetTime?: string;
     limitWeeklyUsd?: number | null;
     limitMonthlyUsd?: number | null;
     limitConcurrentSessions?: number;
@@ -217,6 +233,9 @@ export async function editKey(
       expires_at: expiresAt,
       can_login_web_ui: validatedData.canLoginWebUi,
       limit_5h_usd: validatedData.limit5hUsd,
+      limit_daily_usd: validatedData.limitDailyUsd,
+      daily_reset_mode: validatedData.dailyResetMode,
+      daily_reset_time: validatedData.dailyResetTime,
       limit_weekly_usd: validatedData.limitWeeklyUsd,
       limit_monthly_usd: validatedData.limitMonthlyUsd,
       limit_concurrent_sessions: validatedData.limitConcurrentSessions,
@@ -314,6 +333,7 @@ export async function getKeysWithStatistics(
 export async function getKeyLimitUsage(keyId: number): Promise<
   ActionResult<{
     cost5h: { current: number; limit: number | null; resetAt?: Date };
+    costDaily: { 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 };
@@ -338,11 +358,18 @@ 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");
+    const { getResetInfo, getResetInfoWithMode } = await import("@/lib/rate-limit/time-utils");
 
     // 获取金额消费(优先 Redis,降级数据库)
-    const [cost5h, costWeekly, costMonthly, concurrentSessions] = await Promise.all([
+    const [cost5h, costDaily, costWeekly, costMonthly, concurrentSessions] = await Promise.all([
       RateLimitService.getCurrentCost(keyId, "key", "5h"),
+      RateLimitService.getCurrentCost(
+        keyId,
+        "key",
+        "daily",
+        key.dailyResetTime,
+        key.dailyResetMode ?? "fixed"
+      ),
       RateLimitService.getCurrentCost(keyId, "key", "weekly"),
       RateLimitService.getCurrentCost(keyId, "key", "monthly"),
       SessionTracker.getKeySessionCount(keyId),
@@ -350,6 +377,11 @@ export async function getKeyLimitUsage(keyId: number): Promise<
 
     // 获取重置时间
     const resetInfo5h = getResetInfo("5h");
+    const resetInfoDaily = getResetInfoWithMode(
+      "daily",
+      key.dailyResetTime,
+      key.dailyResetMode ?? "fixed"
+    );
     const resetInfoWeekly = getResetInfo("weekly");
     const resetInfoMonthly = getResetInfo("monthly");
 
@@ -361,6 +393,11 @@ export async function getKeyLimitUsage(keyId: number): Promise<
           limit: key.limit5hUsd,
           resetAt: resetInfo5h.resetAt, // 滚动窗口无 resetAt
         },
+        costDaily: {
+          current: costDaily,
+          limit: key.limitDailyUsd,
+          resetAt: resetInfoDaily.resetAt,
+        },
         costWeekly: {
           current: costWeekly,
           limit: key.limitWeeklyUsd,

+ 31 - 2
src/actions/providers.ts

@@ -136,6 +136,9 @@ export async function getProviders(): Promise<ProviderDisplay[]> {
         joinClaudePool: provider.joinClaudePool,
         codexInstructionsStrategy: provider.codexInstructionsStrategy,
         limit5hUsd: provider.limit5hUsd,
+        limitDailyUsd: provider.limitDailyUsd,
+        dailyResetMode: provider.dailyResetMode,
+        dailyResetTime: provider.dailyResetTime,
         limitWeeklyUsd: provider.limitWeeklyUsd,
         limitMonthlyUsd: provider.limitMonthlyUsd,
         limitConcurrentSessions: provider.limitConcurrentSessions,
@@ -190,6 +193,9 @@ export async function addProvider(data: {
   allowed_models?: string[] | null;
   join_claude_pool?: boolean;
   limit_5h_usd?: number | null;
+  limit_daily_usd?: number | null;
+  daily_reset_mode?: "fixed" | "rolling";
+  daily_reset_time?: string;
   limit_weekly_usd?: number | null;
   limit_monthly_usd?: number | null;
   limit_concurrent_sessions?: number | null;
@@ -252,6 +258,9 @@ export async function addProvider(data: {
     const payload = {
       ...validated,
       limit_5h_usd: validated.limit_5h_usd ?? null,
+      limit_daily_usd: validated.limit_daily_usd ?? null,
+      daily_reset_mode: validated.daily_reset_mode ?? "fixed",
+      daily_reset_time: validated.daily_reset_time ?? "00:00",
       limit_weekly_usd: validated.limit_weekly_usd ?? null,
       limit_monthly_usd: validated.limit_monthly_usd ?? null,
       limit_concurrent_sessions: validated.limit_concurrent_sessions ?? 0,
@@ -328,6 +337,8 @@ export async function editProvider(
     allowed_models?: string[] | null;
     join_claude_pool?: boolean;
     limit_5h_usd?: number | null;
+    limit_daily_usd?: number | null;
+    daily_reset_time?: string;
     limit_weekly_usd?: number | null;
     limit_monthly_usd?: number | null;
     limit_concurrent_sessions?: number | null;
@@ -544,6 +555,7 @@ export async function resetProviderCircuit(providerId: number): Promise<ActionRe
 export async function getProviderLimitUsage(providerId: number): Promise<
   ActionResult<{
     cost5h: { current: number; limit: number | null; resetInfo: string };
+    costDaily: { 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 };
@@ -563,11 +575,18 @@ export async function getProviderLimitUsage(providerId: number): Promise<
     // 动态导入避免循环依赖
     const { RateLimitService } = await import("@/lib/rate-limit");
     const { SessionTracker } = await import("@/lib/session-tracker");
-    const { getResetInfo } = await import("@/lib/rate-limit/time-utils");
+    const { getResetInfo, getResetInfoWithMode } = await import("@/lib/rate-limit/time-utils");
 
     // 获取金额消费(优先 Redis,降级数据库)
-    const [cost5h, costWeekly, costMonthly, concurrentSessions] = await Promise.all([
+    const [cost5h, costDaily, costWeekly, costMonthly, concurrentSessions] = await Promise.all([
       RateLimitService.getCurrentCost(providerId, "provider", "5h"),
+      RateLimitService.getCurrentCost(
+        providerId,
+        "provider",
+        "daily",
+        provider.dailyResetTime,
+        provider.dailyResetMode ?? "fixed"
+      ),
       RateLimitService.getCurrentCost(providerId, "provider", "weekly"),
       RateLimitService.getCurrentCost(providerId, "provider", "monthly"),
       SessionTracker.getProviderSessionCount(providerId),
@@ -575,6 +594,11 @@ export async function getProviderLimitUsage(providerId: number): Promise<
 
     // 获取重置时间信息
     const reset5h = getResetInfo("5h");
+    const resetDaily = getResetInfoWithMode(
+      "daily",
+      provider.dailyResetTime,
+      provider.dailyResetMode ?? "fixed"
+    );
     const resetWeekly = getResetInfo("weekly");
     const resetMonthly = getResetInfo("monthly");
 
@@ -586,6 +610,11 @@ export async function getProviderLimitUsage(providerId: number): Promise<
           limit: provider.limit5hUsd,
           resetInfo: reset5h.type === "rolling" ? `滚动窗口(${reset5h.period})` : "自然时间窗口",
         },
+        costDaily: {
+          current: costDaily,
+          limit: provider.limitDailyUsd,
+          resetAt: resetDaily.type === "rolling" ? undefined : resetDaily.resetAt!,
+        },
         costWeekly: {
           current: costWeekly,
           limit: provider.limitWeeklyUsd,

+ 2 - 0
src/actions/users.ts

@@ -101,6 +101,8 @@ export async function getUsers(): Promise<UserDisplay[]> {
                 canLoginWebUi: key.canLoginWebUi,
                 // 限额配置
                 limit5hUsd: key.limit5hUsd,
+                limitDailyUsd: key.limitDailyUsd,
+                dailyResetTime: key.dailyResetTime,
                 limitWeeklyUsd: key.limitWeeklyUsd,
                 limitMonthlyUsd: key.limitMonthlyUsd,
                 limitConcurrentSessions: key.limitConcurrentSessions || 0,

+ 1 - 1
src/app/[locale]/dashboard/_components/statistics/chart.tsx

@@ -69,7 +69,7 @@ export function UserStatisticsChart({
   // 重置选择状态(当 data.users 变化时)
   React.useEffect(() => {
     setSelectedUserIds(new Set(data.users.map((u) => u.id)));
-  }, [data.users]);
+  }, [data.users, t]);
 
   const isAdminMode = data.mode === "users";
   const enableUserFilter = isAdminMode && data.users.length > 1;

+ 55 - 0
src/app/[locale]/dashboard/_components/user/forms/add-key-form.tsx

@@ -8,6 +8,13 @@ import { DialogFormLayout } from "@/components/form/form-layout";
 import { TextField, DateField, NumberField } from "@/components/form/form-field";
 import { Label } from "@/components/ui/label";
 import { Switch } from "@/components/ui/switch";
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from "@/components/ui/select";
 import { useZodForm } from "@/lib/hooks/use-zod-form";
 import { KeyFormSchema } from "@/lib/validation/schemas";
 import type { User } from "@/types/user";
@@ -30,6 +37,9 @@ export function AddKeyForm({ userId, user, onSuccess }: AddKeyFormProps) {
       expiresAt: "",
       canLoginWebUi: true,
       limit5hUsd: null,
+      limitDailyUsd: null,
+      dailyResetMode: "fixed" as const,
+      dailyResetTime: "00:00",
       limitWeeklyUsd: null,
       limitMonthlyUsd: null,
       limitConcurrentSessions: 0,
@@ -46,6 +56,9 @@ export function AddKeyForm({ userId, user, onSuccess }: AddKeyFormProps) {
           expiresAt: data.expiresAt || undefined,
           canLoginWebUi: data.canLoginWebUi,
           limit5hUsd: data.limit5hUsd,
+          limitDailyUsd: data.limitDailyUsd,
+          dailyResetMode: data.dailyResetMode,
+          dailyResetTime: data.dailyResetTime,
           limitWeeklyUsd: data.limitWeeklyUsd,
           limitMonthlyUsd: data.limitMonthlyUsd,
           limitConcurrentSessions: data.limitConcurrentSessions,
@@ -131,6 +144,48 @@ export function AddKeyForm({ userId, user, onSuccess }: AddKeyFormProps) {
         {...form.getFieldProps("limit5hUsd")}
       />
 
+      <NumberField
+        label={t("limitDailyUsd.label")}
+        placeholder={t("limitDailyUsd.placeholder")}
+        description={t("limitDailyUsd.description")}
+        min={0}
+        step={0.01}
+        {...form.getFieldProps("limitDailyUsd")}
+      />
+
+      <div className="space-y-2">
+        <Label htmlFor="daily-reset-mode">{t("dailyResetMode.label")}</Label>
+        <Select
+          value={form.values.dailyResetMode}
+          onValueChange={(value: "fixed" | "rolling") => form.setValue("dailyResetMode", value)}
+          disabled={isPending}
+        >
+          <SelectTrigger id="daily-reset-mode">
+            <SelectValue />
+          </SelectTrigger>
+          <SelectContent>
+            <SelectItem value="fixed">{t("dailyResetMode.options.fixed")}</SelectItem>
+            <SelectItem value="rolling">{t("dailyResetMode.options.rolling")}</SelectItem>
+          </SelectContent>
+        </Select>
+        <p className="text-xs text-muted-foreground">
+          {form.values.dailyResetMode === "fixed"
+            ? t("dailyResetMode.desc.fixed")
+            : t("dailyResetMode.desc.rolling")}
+        </p>
+      </div>
+
+      {form.values.dailyResetMode === "fixed" && (
+        <TextField
+          label={t("dailyResetTime.label")}
+          placeholder={t("dailyResetTime.placeholder")}
+          description={t("dailyResetTime.description")}
+          type="time"
+          step={60}
+          {...form.getFieldProps("dailyResetTime")}
+        />
+      )}
+
       <NumberField
         label={t("limitWeeklyUsd.label")}
         placeholder={t("limitWeeklyUsd.placeholder")}

+ 24 - 0
src/app/[locale]/dashboard/_components/user/forms/edit-key-form.tsx

@@ -19,6 +19,8 @@ interface EditKeyFormProps {
     expiresAt: string;
     canLoginWebUi?: boolean;
     limit5hUsd?: number | null;
+    limitDailyUsd?: number | null;
+    dailyResetTime?: string;
     limitWeeklyUsd?: number | null;
     limitMonthlyUsd?: number | null;
     limitConcurrentSessions?: number;
@@ -48,6 +50,8 @@ export function EditKeyForm({ keyData, user, onSuccess }: EditKeyFormProps) {
       expiresAt: formatExpiresAt(keyData?.expiresAt || ""),
       canLoginWebUi: keyData?.canLoginWebUi ?? true,
       limit5hUsd: keyData?.limit5hUsd ?? null,
+      limitDailyUsd: keyData?.limitDailyUsd ?? null,
+      dailyResetTime: keyData?.dailyResetTime ?? "00:00",
       limitWeeklyUsd: keyData?.limitWeeklyUsd ?? null,
       limitMonthlyUsd: keyData?.limitMonthlyUsd ?? null,
       limitConcurrentSessions: keyData?.limitConcurrentSessions ?? 0,
@@ -64,6 +68,8 @@ export function EditKeyForm({ keyData, user, onSuccess }: EditKeyFormProps) {
             expiresAt: data.expiresAt || undefined,
             canLoginWebUi: data.canLoginWebUi,
             limit5hUsd: data.limit5hUsd,
+            limitDailyUsd: data.limitDailyUsd,
+            dailyResetTime: data.dailyResetTime,
             limitWeeklyUsd: data.limitWeeklyUsd,
             limitMonthlyUsd: data.limitMonthlyUsd,
             limitConcurrentSessions: data.limitConcurrentSessions,
@@ -139,6 +145,24 @@ export function EditKeyForm({ keyData, user, onSuccess }: EditKeyFormProps) {
         {...form.getFieldProps("limit5hUsd")}
       />
 
+      <NumberField
+        label={t("limitDailyUsd.label")}
+        placeholder={t("limitDailyUsd.placeholder")}
+        description={t("limitDailyUsd.description")}
+        min={0}
+        step={0.01}
+        {...form.getFieldProps("limitDailyUsd")}
+      />
+
+      <TextField
+        label={t("dailyResetTime.label")}
+        placeholder={t("dailyResetTime.placeholder")}
+        description={t("dailyResetTime.description")}
+        type="time"
+        step={60}
+        {...form.getFieldProps("dailyResetTime")}
+      />
+
       <NumberField
         label={t("limitWeeklyUsd.label")}
         placeholder={t("limitWeeklyUsd.placeholder")}

+ 7 - 0
src/app/[locale]/dashboard/_components/user/key-limit-usage.tsx

@@ -15,6 +15,7 @@ interface KeyLimitUsageProps {
 
 interface LimitUsageData {
   cost5h: { current: number; limit: number | null };
+  costDaily: { current: number; limit: number | null };
   costWeekly: { current: number; limit: number | null };
   costMonthly: { current: number; limit: number | null };
   concurrentSessions: { current: number; limit: number };
@@ -76,6 +77,12 @@ export function KeyLimitUsage({ keyId, currencyCode = "USD" }: KeyLimitUsageProp
       limit: data.cost5h.limit,
       isCost: true,
     },
+    {
+      label: t("costDaily"),
+      current: data.costDaily.current,
+      limit: data.costDaily.limit,
+      isCost: true,
+    },
     {
       label: t("costWeekly"),
       current: data.costWeekly.current,

+ 1 - 0
src/app/[locale]/dashboard/_components/user/key-list.tsx

@@ -89,6 +89,7 @@ export function KeyList({
         // 检查是否有限额配置
         const hasLimitConfig =
           (record.limit5hUsd && record.limit5hUsd > 0) ||
+          (record.limitDailyUsd && record.limitDailyUsd > 0) ||
           (record.limitWeeklyUsd && record.limitWeeklyUsd > 0) ||
           (record.limitMonthlyUsd && record.limitMonthlyUsd > 0) ||
           (record.limitConcurrentSessions && record.limitConcurrentSessions > 0);

+ 93 - 5
src/app/[locale]/dashboard/logs/_components/error-details-dialog.tsx

@@ -295,14 +295,102 @@ export function ErrorDetailsDialog({
                 <AlertCircle className="h-4 w-4" />
                 {t("logs.details.errorMessage")}
               </h4>
-              <div className="rounded-md border bg-destructive/10 p-4">
-                <pre className="text-xs text-destructive whitespace-pre-wrap break-words font-mono">
-                  {errorMessage}
-                </pre>
-              </div>
+
+              {/* 尝试解析 JSON 错误 */}
+              {(() => {
+                try {
+                  const error = JSON.parse(errorMessage);
+
+                  // 检查是否是限流错误
+                  if (error.code === 'rate_limit_exceeded' || error.code === 'circuit_breaker_open' || error.code === 'mixed_unavailable') {
+                    return (
+                      <div className="rounded-md border bg-orange-50 dark:bg-orange-950/20 p-4 space-y-3">
+                        <div className="font-semibold text-orange-900 dark:text-orange-100">
+                          💰 {error.message}
+                        </div>
+                        {error.details?.filteredProviders && error.details.filteredProviders.length > 0 && (
+                          <div className="space-y-2">
+                            <div className="text-sm font-medium text-orange-900 dark:text-orange-100">
+                              {t("logs.details.filteredProviders")}:
+                            </div>
+                            <ul className="text-sm space-y-1">
+                              {error.details.filteredProviders
+                                .filter((p: { reason: string }) => p.reason === 'rate_limited' || p.reason === 'circuit_open')
+                                .map((p: { id: number; name: string; details: string }) => (
+                                  <li key={p.id} className="text-orange-800 dark:text-orange-200 flex items-center gap-2">
+                                    <span className="text-orange-600">•</span>
+                                    <span className="font-medium">{p.name}</span>
+                                    <span className="text-xs">({p.details})</span>
+                                  </li>
+                                ))}
+                            </ul>
+                          </div>
+                        )}
+                      </div>
+                    );
+                  }
+
+                  // 其他 JSON 错误,格式化显示
+                  return (
+                    <div className="rounded-md border bg-destructive/10 p-4">
+                      <pre className="text-xs text-destructive whitespace-pre-wrap break-words font-mono">
+                        {JSON.stringify(error, null, 2)}
+                      </pre>
+                    </div>
+                  );
+                } catch {
+                  // 解析失败,显示原始消息
+                  return (
+                    <div className="rounded-md border bg-destructive/10 p-4">
+                      <pre className="text-xs text-destructive whitespace-pre-wrap break-words font-mono">
+                        {errorMessage}
+                      </pre>
+                    </div>
+                  );
+                }
+              })()}
             </div>
           )}
 
+          {/* 被过滤的供应商(仅在成功请求时显示) */}
+          {isSuccess && providerChain && providerChain.length > 0 && (() => {
+            // 从决策链中提取被过滤的供应商
+            const filteredProviders = providerChain
+              .flatMap(item => item.decisionContext?.filteredProviders || [])
+              .filter(p => p.reason === 'rate_limited' || p.reason === 'circuit_open');
+
+            if (filteredProviders.length === 0) return null;
+
+            return (
+              <div className="space-y-2">
+                <h4 className="font-semibold text-sm flex items-center gap-2">
+                  <AlertCircle className="h-4 w-4 text-orange-600" />
+                  {t("logs.details.filteredProviders")}
+                </h4>
+                <div className="rounded-md border bg-orange-50 dark:bg-orange-950/20 p-4">
+                  <ul className="text-sm space-y-2">
+                    {filteredProviders.map((p, index) => (
+                      <li key={`${p.id}-${index}`} className="text-orange-800 dark:text-orange-200 flex items-start gap-2">
+                        <span className="text-orange-600 mt-0.5">💰</span>
+                        <div className="flex-1">
+                          <span className="font-medium">{p.name}</span>
+                          <span className="text-xs ml-2">
+                            ({p.reason === 'rate_limited' ? '供应商费用限制' : '熔断器打开'})
+                          </span>
+                          {p.details && (
+                            <div className="text-xs text-orange-700 dark:text-orange-300 mt-0.5">
+                              {p.details}
+                            </div>
+                          )}
+                        </div>
+                      </li>
+                    ))}
+                  </ul>
+                </div>
+              </div>
+            );
+          })()}
+
           {/* 供应商决策链时间线 */}
           {providerChain && providerChain.length > 0 && (
             <div className="space-y-2">

+ 52 - 0
src/app/[locale]/dashboard/quotas/keys/_components/edit-key-quota-dialog.tsx

@@ -22,6 +22,7 @@ import { useTranslations } from "next-intl";
 
 interface KeyQuota {
   cost5h: { current: number; limit: number | null };
+  costDaily: { current: number; limit: number | null; resetAt?: Date };
   costWeekly: { current: number; limit: number | null };
   costMonthly: { current: number; limit: number | null };
   concurrentSessions: { current: number; limit: number };
@@ -34,6 +35,7 @@ interface EditKeyQuotaDialogProps {
   currentQuota: KeyQuota | null;
   currencyCode?: CurrencyCode;
   trigger?: React.ReactNode;
+  dailyResetTime?: string;
 }
 
 export function EditKeyQuotaDialog({
@@ -43,6 +45,7 @@ export function EditKeyQuotaDialog({
   currentQuota,
   currencyCode = "USD",
   trigger,
+  dailyResetTime = "00:00",
 }: EditKeyQuotaDialogProps) {
   const router = useRouter();
   const [isPending, startTransition] = useTransition();
@@ -53,6 +56,10 @@ export function EditKeyQuotaDialog({
 
   // 表单状态
   const [limit5h, setLimit5h] = useState<string>(currentQuota?.cost5h.limit?.toString() ?? "");
+  const [limitDaily, setLimitDaily] = useState<string>(
+    currentQuota?.costDaily.limit?.toString() ?? ""
+  );
+  const [resetTime, setResetTime] = useState<string>(dailyResetTime);
   const [limitWeekly, setLimitWeekly] = useState<string>(
     currentQuota?.costWeekly.limit?.toString() ?? ""
   );
@@ -72,6 +79,8 @@ export function EditKeyQuotaDialog({
         const result = await editKey(keyId, {
           name: keyName, // 保持名称不变
           limit5hUsd: limit5h ? parseFloat(limit5h) : null,
+          limitDailyUsd: limitDaily ? parseFloat(limitDaily) : null,
+          dailyResetTime: resetTime,
           limitWeeklyUsd: limitWeekly ? parseFloat(limitWeekly) : null,
           limitMonthlyUsd: limitMonthly ? parseFloat(limitMonthly) : null,
           limitConcurrentSessions: limitConcurrent ? parseInt(limitConcurrent, 10) : 0,
@@ -97,6 +106,8 @@ export function EditKeyQuotaDialog({
         const result = await editKey(keyId, {
           name: keyName,
           limit5hUsd: null,
+          limitDailyUsd: null,
+          dailyResetTime: resetTime,
           limitWeeklyUsd: null,
           limitMonthlyUsd: null,
           limitConcurrentSessions: 0,
@@ -162,6 +173,47 @@ export function EditKeyQuotaDialog({
                 )}
               </div>
 
+              {/* 每日限额 */}
+              <div className="grid gap-1.5">
+                <Label htmlFor="limitDaily" className="text-xs">
+                  {t("costDaily.label")}
+                </Label>
+                <Input
+                  id="limitDaily"
+                  type="number"
+                  step="0.01"
+                  min="0"
+                  placeholder={t("costDaily.placeholder")}
+                  value={limitDaily}
+                  onChange={(e) => setLimitDaily(e.target.value)}
+                  className="h-9"
+                />
+                {currentQuota?.costDaily.limit && (
+                  <p className="text-xs text-muted-foreground">
+                    {t("costDaily.current", {
+                      currency: currencySymbol,
+                      current: currentQuota.costDaily.current.toFixed(4),
+                      limit: currentQuota.costDaily.limit.toFixed(2),
+                    })}
+                  </p>
+                )}
+              </div>
+
+              {/* 每日重置时间 */}
+              <div className="grid gap-1.5">
+                <Label htmlFor="dailyResetTime" className="text-xs">
+                  {t("dailyResetTime.label")}
+                </Label>
+                <Input
+                  id="dailyResetTime"
+                  type="time"
+                  step={60}
+                  value={resetTime}
+                  onChange={(e) => setResetTime(e.target.value || "00:00")}
+                  className="h-9"
+                />
+              </div>
+
               {/* 周限额 */}
               <div className="grid gap-1.5">
                 <Label htmlFor="limitWeekly" className="text-xs">

+ 39 - 0
src/app/[locale]/dashboard/quotas/keys/_components/keys-quota-client.tsx

@@ -26,6 +26,7 @@ import { useTranslations } from "next-intl";
 
 interface KeyQuota {
   cost5h: { current: number; limit: number | null; resetAt?: Date };
+  costDaily: { 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 };
@@ -42,6 +43,8 @@ interface KeyWithQuota {
   isEnabled: boolean;
   expiresAt: string | null;
   quota: KeyQuota | null;
+  limitDailyUsd: number | null;
+  dailyResetTime: string;
 }
 
 interface UserWithKeys {
@@ -120,6 +123,7 @@ export function KeysQuotaClient({ users, currencyCode = "USD" }: KeysQuotaClient
                       <TableHead className="w-[200px]">{t("table.keyName")}</TableHead>
                       <TableHead className="w-[120px]">{t("table.quotaType")}</TableHead>
                       <TableHead className="w-[150px]">{t("table.cost5h")}</TableHead>
+                      <TableHead className="w-[150px]">{t("table.costDaily")}</TableHead>
                       <TableHead className="w-[150px]">{t("table.costWeekly")}</TableHead>
                       <TableHead className="w-[150px]">{t("table.costMonthly")}</TableHead>
                       <TableHead className="w-[120px]">{t("table.concurrentSessions")}</TableHead>
@@ -178,6 +182,40 @@ export function KeysQuotaClient({ users, currencyCode = "USD" }: KeysQuotaClient
                             )}
                           </TableCell>
 
+                          {/* 每日限额 */}
+                          <TableCell>
+                            {hasKeyQuota && key.quota && key.quota.costDaily.limit !== null ? (
+                              <div className="space-y-1">
+                                <div className="flex items-center justify-between mb-1">
+                                  <QuotaWindowType type="daily" size="sm" />
+                                  {key.quota.costDaily.resetAt && (
+                                    <QuotaCountdownCompact resetAt={key.quota.costDaily.resetAt} />
+                                  )}
+                                </div>
+                                <div className="flex items-center gap-2">
+                                  <span className="text-xs font-mono">
+                                    {formatCurrency(key.quota.costDaily.current, currencyCode)}/
+                                    {formatCurrency(key.quota.costDaily.limit, currencyCode)}
+                                  </span>
+                                </div>
+                                <QuotaProgress
+                                  current={key.quota.costDaily.current}
+                                  limit={key.quota.costDaily.limit}
+                                  className="h-1"
+                                />
+                                <div className="text-xs text-muted-foreground text-right">
+                                  {getUsageRate(
+                                    key.quota.costDaily.current,
+                                    key.quota.costDaily.limit
+                                  ).toFixed(1)}
+                                  %
+                                </div>
+                              </div>
+                            ) : (
+                              <span className="text-sm text-muted-foreground">-</span>
+                            )}
+                          </TableCell>
+
                           {/* 周限额 */}
                           <TableCell>
                             {hasKeyQuota && key.quota && key.quota.costWeekly.limit !== null ? (
@@ -300,6 +338,7 @@ export function KeysQuotaClient({ users, currencyCode = "USD" }: KeysQuotaClient
                               userName={user.name}
                               currentQuota={key.quota}
                               currencyCode={currencyCode}
+                              dailyResetTime={key.dailyResetTime}
                               trigger={
                                 <Button variant="ghost" size="sm">
                                   <Settings className="h-4 w-4" />

+ 3 - 0
src/app/[locale]/dashboard/quotas/keys/_components/keys-quota-manager.tsx

@@ -17,6 +17,7 @@ import { useTranslations } from "next-intl";
 
 interface KeyQuota {
   cost5h: { current: number; limit: number | null };
+  costDaily: { current: number; limit: number | null };
   costWeekly: { current: number; limit: number | null };
   costMonthly: { current: number; limit: number | null };
   concurrentSessions: { current: number; limit: number };
@@ -33,6 +34,8 @@ interface KeyWithQuota {
   isEnabled: boolean;
   expiresAt: string | null;
   quota: KeyQuota | null;
+  limitDailyUsd: number | null;
+  dailyResetTime: string;
 }
 
 interface UserWithKeys {

+ 28 - 0
src/app/[locale]/dashboard/quotas/providers/_components/providers-quota-client.tsx

@@ -13,6 +13,7 @@ import type { ProviderType } from "@/types/provider";
 
 interface ProviderQuota {
   cost5h: { current: number; limit: number | null; resetInfo: string };
+  costDaily: { 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 };
@@ -39,6 +40,7 @@ function hasQuotaLimit(quota: ProviderQuota | null): boolean {
   if (!quota) return false;
   return (
     (quota.cost5h.limit !== null && quota.cost5h.limit > 0) ||
+    (quota.costDaily.limit !== null && quota.costDaily.limit > 0) ||
     (quota.costWeekly.limit !== null && quota.costWeekly.limit > 0) ||
     (quota.costMonthly.limit !== null && quota.costMonthly.limit > 0) ||
     quota.concurrentSessions.limit > 0
@@ -130,6 +132,32 @@ export function ProvidersQuotaClient({
               </div>
             )}
 
+            {provider.quota.costDaily.limit && provider.quota.costDaily.limit > 0 && (
+              <div className="space-y-2">
+                <div className="flex items-center justify-between text-sm">
+                  <span className="text-muted-foreground">{t("costDaily.label")}</span>
+                  {provider.quota.costDaily.resetAt && (
+                    <span className="text-xs text-muted-foreground">
+                      {t("costDaily.resetAt")}{" "}
+                      {formatDateDistance(provider.quota.costDaily.resetAt, new Date(), locale)}
+                    </span>
+                  )}
+                </div>
+                <div className="flex items-center justify-between text-sm font-mono">
+                  <span>
+                    {formatCurrency(provider.quota.costDaily.current, currencyCode)} /{" "}
+                    {formatCurrency(provider.quota.costDaily.limit, currencyCode)}
+                  </span>
+                </div>
+                <Progress
+                  value={
+                    (provider.quota.costDaily.current / (provider.quota.costDaily.limit || 1)) * 100
+                  }
+                  className="h-2"
+                />
+              </div>
+            )}
+
             {/* 周消费 */}
             {provider.quota.costWeekly.limit && provider.quota.costWeekly.limit > 0 && (
               <div className="space-y-2">

+ 1 - 0
src/app/[locale]/dashboard/quotas/providers/_components/providers-quota-manager.tsx

@@ -9,6 +9,7 @@ import { useTranslations } from "next-intl";
 
 interface ProviderQuota {
   cost5h: { current: number; limit: number | null; resetInfo: string };
+  costDaily: { 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 };

+ 1 - 1
src/app/[locale]/settings/providers/_components/add-provider-dialog.tsx

@@ -21,7 +21,7 @@ export function AddProviderDialog({ enableMultiProviderTypes }: AddProviderDialo
           <ServerCog className="h-4 w-4" /> 新增服务商
         </Button>
       </DialogTrigger>
-      <DialogContent className="sm:max-w-3xl max-h-[85vh] overflow-y-auto">
+      <DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
         <FormErrorBoundary>
           <ProviderForm
             mode="create"

+ 91 - 0
src/app/[locale]/settings/providers/_components/forms/provider-form.tsx

@@ -91,6 +91,15 @@ export function ProviderForm({
       : []
   );
   const [limit5hUsd, setLimit5hUsd] = useState<number | null>(sourceProvider?.limit5hUsd ?? null);
+  const [limitDailyUsd, setLimitDailyUsd] = useState<number | null>(
+    sourceProvider?.limitDailyUsd ?? null
+  );
+  const [dailyResetMode, setDailyResetMode] = useState<"fixed" | "rolling">(
+    sourceProvider?.dailyResetMode ?? "fixed"
+  );
+  const [dailyResetTime, setDailyResetTime] = useState<string>(
+    sourceProvider?.dailyResetTime ?? "00:00"
+  );
   const [limitWeeklyUsd, setLimitWeeklyUsd] = useState<number | null>(
     sourceProvider?.limitWeeklyUsd ?? null
   );
@@ -267,6 +276,9 @@ export function ProviderForm({
             cost_multiplier?: number;
             group_tag?: string | null;
             limit_5h_usd?: number | null;
+            limit_daily_usd?: number | null;
+            daily_reset_mode?: "fixed" | "rolling";
+            daily_reset_time?: string;
             limit_weekly_usd?: number | null;
             limit_monthly_usd?: number | null;
             limit_concurrent_sessions?: number | null;
@@ -296,6 +308,9 @@ export function ProviderForm({
             cost_multiplier: costMultiplier,
             group_tag: groupTag.length > 0 ? groupTag.join(",") : null,
             limit_5h_usd: limit5hUsd,
+            limit_daily_usd: limitDailyUsd,
+            daily_reset_mode: dailyResetMode,
+            daily_reset_time: dailyResetTime,
             limit_weekly_usd: limitWeeklyUsd,
             limit_monthly_usd: limitMonthlyUsd,
             limit_concurrent_sessions: limitConcurrentSessions,
@@ -348,6 +363,9 @@ export function ProviderForm({
             cost_multiplier: costMultiplier,
             group_tag: groupTag.length > 0 ? groupTag.join(",") : null,
             limit_5h_usd: limit5hUsd,
+            limit_daily_usd: limitDailyUsd,
+            daily_reset_mode: dailyResetMode,
+            daily_reset_time: dailyResetTime,
             limit_weekly_usd: limitWeeklyUsd,
             limit_monthly_usd: limitMonthlyUsd,
             limit_concurrent_sessions: limitConcurrentSessions ?? 0,
@@ -398,6 +416,8 @@ export function ProviderForm({
           setCostMultiplier(1.0);
           setGroupTag([]);
           setLimit5hUsd(null);
+          setLimitDailyUsd(null);
+          setDailyResetTime("00:00");
           setLimitWeeklyUsd(null);
           setLimitMonthlyUsd(null);
           setLimitConcurrentSessions(null);
@@ -822,6 +842,13 @@ export function ProviderForm({
                   const limits: string[] = [];
                   if (limit5hUsd)
                     limits.push(t("sections.rateLimit.summary.fiveHour", { amount: limit5hUsd }));
+                  if (limitDailyUsd)
+                    limits.push(
+                      t("sections.rateLimit.summary.daily", {
+                        amount: limitDailyUsd,
+                        resetTime: dailyResetTime,
+                      })
+                    );
                   if (limitWeeklyUsd)
                     limits.push(t("sections.rateLimit.summary.weekly", { amount: limitWeeklyUsd }));
                   if (limitMonthlyUsd)
@@ -857,6 +884,70 @@ export function ProviderForm({
                     step="0.01"
                   />
                 </div>
+                <div className="space-y-2">
+                  <Label htmlFor={isEdit ? "edit-limit-daily" : "limit-daily"}>
+                    {t("sections.rateLimit.limitDaily.label")}
+                  </Label>
+                  <Input
+                    id={isEdit ? "edit-limit-daily" : "limit-daily"}
+                    type="number"
+                    value={limitDailyUsd?.toString() ?? ""}
+                    onChange={(e) => setLimitDailyUsd(validateNumericField(e.target.value))}
+                    placeholder={t("sections.rateLimit.limitDaily.placeholder")}
+                    disabled={isPending}
+                    min="0"
+                    step="0.01"
+                  />
+                </div>
+              </div>
+
+              <div className="grid grid-cols-2 gap-4">
+                <div className="space-y-2">
+                  <Label htmlFor={isEdit ? "edit-daily-reset-mode" : "daily-reset-mode"}>
+                    {t("sections.rateLimit.dailyResetMode.label")}
+                  </Label>
+                  <Select
+                    value={dailyResetMode}
+                    onValueChange={(value: "fixed" | "rolling") => setDailyResetMode(value)}
+                    disabled={isPending}
+                  >
+                    <SelectTrigger id={isEdit ? "edit-daily-reset-mode" : "daily-reset-mode"}>
+                      <SelectValue />
+                    </SelectTrigger>
+                    <SelectContent>
+                      <SelectItem value="fixed">
+                        {t("sections.rateLimit.dailyResetMode.options.fixed")}
+                      </SelectItem>
+                      <SelectItem value="rolling">
+                        {t("sections.rateLimit.dailyResetMode.options.rolling")}
+                      </SelectItem>
+                    </SelectContent>
+                  </Select>
+                  <p className="text-xs text-muted-foreground">
+                    {dailyResetMode === "fixed"
+                      ? t("sections.rateLimit.dailyResetMode.desc.fixed")
+                      : t("sections.rateLimit.dailyResetMode.desc.rolling")}
+                  </p>
+                </div>
+                {dailyResetMode === "fixed" && (
+                  <div className="space-y-2">
+                    <Label htmlFor={isEdit ? "edit-daily-reset" : "daily-reset"}>
+                      {t("sections.rateLimit.dailyResetTime.label")}
+                    </Label>
+                    <Input
+                      id={isEdit ? "edit-daily-reset" : "daily-reset"}
+                      type="time"
+                      value={dailyResetTime}
+                      onChange={(e) => setDailyResetTime(e.target.value || "00:00")}
+                      placeholder="00:00"
+                      disabled={isPending}
+                      step="60"
+                    />
+                  </div>
+                )}
+              </div>
+
+              <div className="grid grid-cols-2 gap-4">
                 <div className="space-y-2">
                   <Label htmlFor={isEdit ? "edit-limit-weekly" : "limit-weekly"}>
                     {t("sections.rateLimit.limitWeekly.label")}

+ 8 - 4
src/app/[locale]/settings/providers/_components/provider-rich-list-item.tsx

@@ -16,7 +16,7 @@ import {
 } from "lucide-react";
 import type { ProviderDisplay } from "@/types/provider";
 import type { User } from "@/types/user";
-import { getProviderTypeConfig } from "@/lib/provider-type-utils";
+import { getProviderTypeConfig, getProviderTypeTranslationKey } from "@/lib/provider-type-utils";
 import {
   Dialog,
   DialogContent,
@@ -82,13 +82,16 @@ export function ProviderRichListItem({
   const [togglePending, startToggleTransition] = useTransition();
 
   const canEdit = currentUser?.role === "admin";
+  const tTypes = useTranslations("settings.providers.types");
   const tList = useTranslations("settings.providers.list");
-  const tCommon = useTranslations("settings.common");
   const tTimeout = useTranslations("settings.providers.form.sections.timeout");
 
   // 获取供应商类型配置
   const typeConfig = getProviderTypeConfig(provider.providerType);
   const TypeIcon = typeConfig.icon;
+  const typeKey = getProviderTypeTranslationKey(provider.providerType);
+  const typeLabel = tTypes(`${typeKey}.label`);
+  const typeDescription = tTypes(`${typeKey}.description`);
 
   // 处理编辑
   const handleEdit = () => {
@@ -238,8 +241,10 @@ export function ProviderRichListItem({
           {/* 类型图标 */}
           <div
             className={`flex items-center justify-center w-6 h-6 rounded ${typeConfig.bgColor} flex-shrink-0`}
+            title={`${typeLabel} · ${typeDescription}`}
+            aria-label={typeLabel}
           >
-            <TypeIcon className="h-3.5 w-3.5" />
+            <TypeIcon className="h-3.5 w-3.5" aria-hidden />
           </div>
         </div>
 
@@ -254,7 +259,6 @@ export function ProviderRichListItem({
                 alt=""
                 className="h-4 w-4 flex-shrink-0"
                 onError={(e) => {
-                  // 隐藏加载失败的图标
                   (e.target as HTMLImageElement).style.display = "none";
                 }}
               />

+ 12 - 5
src/app/[locale]/usage-doc/page.tsx

@@ -55,7 +55,7 @@ interface CLIConfig {
   vsCodeExtension?: {
     name: string;
     configFile: string;
-    configPath: { macos: string; windows: string };
+    configPath: Record<OS, string>;
   };
 }
 
@@ -78,6 +78,7 @@ function getCLIConfigs(t: (key: string) => string): Record<string, CLIConfig> {
         configPath: {
           macos: "~/.claude",
           windows: "C:\\Users\\你的用户名\\.claude",
+          linux: "~/.claude",
         },
       },
     },
@@ -92,6 +93,7 @@ function getCLIConfigs(t: (key: string) => string): Record<string, CLIConfig> {
         configPath: {
           macos: "~/.codex",
           windows: "C:\\Users\\你的用户名\\.codex",
+          linux: "~/.codex",
         },
       },
     },
@@ -1008,14 +1010,18 @@ gemini`}
   /**
    * 渲染 VS Code 扩展配置
    */
-  const renderVSCodeExtension = (cli: CLIConfig) => {
+  const renderVSCodeExtension = (cli: CLIConfig, os: OS) => {
     const config = cli.vsCodeExtension;
     if (!config) return null;
 
+    const resolvedConfigPath = config.configPath[os];
     if (cli.id === "claude-code") {
       return (
         <div className="space-y-3">
           <h4 className={headingClasses.h4}>{t("claudeCode.vsCodeExtension.title")}</h4>
+          <p className="text-sm text-muted-foreground">
+            {t("claudeCode.vsCodeExtension.configPath", { path: resolvedConfigPath })}
+          </p>
           <ol className="list-decimal space-y-2 pl-6">
             {(t.raw("claudeCode.vsCodeExtension.steps") as string[]).map(
               (step: string, i: number) => (
@@ -1024,8 +1030,9 @@ gemini`}
             )}
           </ol>
           <CodeBlock
-            language="json"
-            code={`{
+            language="jsonc"
+            code={`// Path: ${resolvedConfigPath}
+{
   "primaryApiKey": "any-value"
 }`}
           />
@@ -1294,7 +1301,7 @@ curl -I ${resolvedOrigin}`}
         </div>
 
         {/* VS Code 扩展配置 */}
-        {(cli.id === "claude-code" || cli.id === "codex") && renderVSCodeExtension(cli)}
+        {(cli.id === "claude-code" || cli.id === "codex") && renderVSCodeExtension(cli, os)}
 
         {/* 启动与验证 */}
         {renderStartupVerification(cli, os)}

+ 19 - 0
src/app/v1/_lib/codex/chat-completions-handler.ts

@@ -181,6 +181,25 @@ export async function handleChatCompletions(c: Context): Promise<Response> {
     // 5. 供应商选择(根据模型自动匹配)
     const providerUnavailable = await ProxyProviderResolver.ensure(session);
     if (providerUnavailable) {
+      // 创建失败记录(供应商不可用)
+      await ProxyMessageService.ensureContext(session);
+
+      // 解析错误响应
+      const errorBody = await providerUnavailable
+        .clone()
+        .json()
+        .catch(() => null);
+      const errorMessage = errorBody?.error?.message || "供应商不可用";
+
+      // 记录失败消息
+      if (session.messageContext) {
+        const { updateMessageRequestDetails } = await import("@/repository/message");
+        await updateMessageRequestDetails(session.messageContext.id, {
+          statusCode: providerUnavailable.status,
+          errorMessage: JSON.stringify(errorBody?.error || { message: errorMessage }),
+        });
+      }
+
       return providerUnavailable;
     }
 

+ 67 - 9
src/app/v1/_lib/proxy/provider-selector.ts

@@ -327,15 +327,71 @@ export class ProxyProviderResolver {
 
     // 循环结束:所有可用供应商都已尝试或无可用供应商
     const status = 503;
-    const message =
-      excludedProviders.length > 0
-        ? `所有供应商不可用(尝试了 ${excludedProviders.length} 个供应商)`
-        : "暂无可用的上游服务";
+
+    // 构建详细的错误消息
+    let message = "暂无可用的上游服务";
+    let errorType = "no_available_providers";
+
+    if (excludedProviders.length > 0) {
+      message = `所有供应商不可用(尝试了 ${excludedProviders.length} 个供应商)`;
+      errorType = "all_providers_failed";
+    } else {
+      const selectionContext = session.getLastSelectionContext();
+      const filteredProviders = selectionContext?.filteredProviders;
+
+      if (filteredProviders && filteredProviders.length > 0) {
+        // 统计各种原因
+        const rateLimited = filteredProviders.filter((p) => p.reason === "rate_limited");
+        const circuitOpen = filteredProviders.filter((p) => p.reason === "circuit_open");
+        const disabled = filteredProviders.filter((p) => p.reason === "disabled");
+        const modelNotAllowed = filteredProviders.filter((p) => p.reason === "model_not_allowed");
+
+        // 计算可用供应商数量(排除禁用和模型不支持的)
+        const unavailableCount = rateLimited.length + circuitOpen.length;
+        const totalEnabled = filteredProviders.length - disabled.length - modelNotAllowed.length;
+
+        if (
+          rateLimited.length > 0 &&
+          circuitOpen.length === 0 &&
+          unavailableCount === totalEnabled
+        ) {
+          // 全部因为限流
+          message = `所有可用供应商已达消费限额(${rateLimited.length} 个供应商)`;
+          errorType = "rate_limit_exceeded";
+        } else if (
+          circuitOpen.length > 0 &&
+          rateLimited.length === 0 &&
+          unavailableCount === totalEnabled
+        ) {
+          // 全部因为熔断
+          message = `所有可用供应商熔断器已打开(${circuitOpen.length} 个供应商)`;
+          errorType = "circuit_breaker_open";
+        } else if (rateLimited.length > 0 && circuitOpen.length > 0) {
+          // 混合原因
+          message = `所有可用供应商不可用(${rateLimited.length} 个达限额,${circuitOpen.length} 个熔断)`;
+          errorType = "mixed_unavailable";
+        }
+      }
+    }
+
     logger.error("ProviderSelector: No available providers after trying all candidates", {
       excludedProviders,
       totalAttempts: attemptCount,
+      errorType,
+      filteredProviders: session.getLastSelectionContext()?.filteredProviders,
     });
-    return ProxyResponses.buildError(status, message);
+
+    // 构建详细的错误响应
+    const details: Record<string, unknown> = {
+      totalAttempts: attemptCount,
+      excludedCount: excludedProviders.length,
+    };
+
+    if (session.getLastSelectionContext()?.filteredProviders) {
+      details.filteredProviders = session.getLastSelectionContext()!.filteredProviders;
+    }
+
+    return ProxyResponses.buildError(status, message, errorType, details);
   }
 
   /**
@@ -633,10 +689,9 @@ export class ProxyProviderResolver {
     }
 
     if (healthyProviders.length === 0) {
-      logger.warn("ProviderSelector: All providers rate limited, falling back to random");
-      // Fail Open:降级到随机选择(让上游拒绝)
-      const fallback = this.weightedRandom(candidateProviders);
-      return { provider: fallback, context };
+      logger.warn("ProviderSelector: All providers rate limited or unavailable");
+      // 所有供应商都被限流或不可用,返回 null 触发 503 错误
+      return { provider: null, context };
     }
 
     // Step 4: 优先级分层(只选择最高优先级的供应商)
@@ -703,6 +758,9 @@ export class ProxyProviderResolver {
         // 1. 检查金额限制
         const costCheck = await RateLimitService.checkCostLimits(p.id, "provider", {
           limit_5h_usd: p.limit5hUsd,
+          limit_daily_usd: p.limitDailyUsd,
+          daily_reset_mode: p.dailyResetMode,
+          daily_reset_time: p.dailyResetTime,
           limit_weekly_usd: p.limitWeeklyUsd,
           limit_monthly_usd: p.limitMonthlyUsd,
         });

+ 3 - 0
src/app/v1/_lib/proxy/rate-limit-guard.ts

@@ -85,6 +85,9 @@ export class ProxyRateLimitGuard {
     // 3. 检查 Key 金额限制
     const costCheck = await RateLimitService.checkCostLimits(key.id, "key", {
       limit_5h_usd: key.limit5hUsd,
+      limit_daily_usd: key.limitDailyUsd,
+      daily_reset_mode: key.dailyResetMode,
+      daily_reset_time: key.dailyResetTime,
       limit_weekly_usd: key.limitWeeklyUsd,
       limit_monthly_usd: key.limitMonthlyUsd,
     });

+ 7 - 1
src/app/v1/_lib/proxy/response-handler.ts

@@ -1283,7 +1283,13 @@ async function trackCostToRedis(session: ProxySession, usage: UsageMetrics | nul
     key.id,
     provider.id,
     session.sessionId, // 直接使用 session.sessionId
-    costFloat
+    costFloat,
+    {
+      keyResetTime: key.dailyResetTime,
+      keyResetMode: key.dailyResetMode,
+      providerResetTime: provider.dailyResetTime,
+      providerResetMode: provider.dailyResetMode,
+    }
   );
 
   // 新增:追踪用户层每日消费

+ 53 - 3
src/app/v1/_lib/proxy/responses.ts

@@ -1,12 +1,34 @@
 export class ProxyResponses {
-  static buildError(status: number, message: string): Response {
-    const payload = {
+  static buildError(
+    status: number,
+    message: string,
+    errorType?: string,
+    details?: Record<string, unknown>
+  ): Response {
+    const payload: {
+      error: {
+        message: string;
+        type: string;
+        code?: string;
+        details?: Record<string, unknown>;
+      };
+    } = {
       error: {
         message,
-        type: String(status),
+        type: errorType || this.getErrorType(status),
       },
     };
 
+    // 添加错误代码(用于前端识别)
+    if (errorType) {
+      payload.error.code = errorType;
+    }
+
+    // 添加详细信息(可选)
+    if (details) {
+      payload.error.details = details;
+    }
+
     return new Response(JSON.stringify(payload), {
       status,
       headers: {
@@ -14,4 +36,32 @@ export class ProxyResponses {
       },
     });
   }
+
+  /**
+   * 根据 HTTP 状态码获取默认错误类型
+   */
+  private static getErrorType(status: number): string {
+    switch (status) {
+      case 400:
+        return "invalid_request_error";
+      case 401:
+        return "authentication_error";
+      case 403:
+        return "permission_error";
+      case 404:
+        return "not_found_error";
+      case 429:
+        return "rate_limit_error";
+      case 500:
+        return "internal_server_error";
+      case 502:
+        return "bad_gateway_error";
+      case 503:
+        return "service_unavailable_error";
+      case 504:
+        return "gateway_timeout_error";
+      default:
+        return "api_error";
+    }
+  }
 }

+ 16 - 0
src/drizzle/schema.ts

@@ -53,6 +53,14 @@ export const keys = pgTable('keys', {
 
   // 金额限流配置
   limit5hUsd: numeric('limit_5h_usd', { precision: 10, scale: 2 }),
+  limitDailyUsd: numeric('limit_daily_usd', { precision: 10, scale: 2 }),
+  dailyResetMode: varchar('daily_reset_mode', { length: 10 })
+    .default('fixed')
+    .notNull()
+    .$type<'fixed' | 'rolling'>(), // fixed: 固定时间重置, rolling: 滚动窗口(24小时)
+  dailyResetTime: varchar('daily_reset_time', { length: 5 })
+    .default('00:00')
+    .notNull(), // HH:mm 格式,如 "18:00"(仅 fixed 模式使用)
   limitWeeklyUsd: numeric('limit_weekly_usd', { precision: 10, scale: 2 }),
   limitMonthlyUsd: numeric('limit_monthly_usd', { precision: 10, scale: 2 }),
   limitConcurrentSessions: integer('limit_concurrent_sessions').default(0),
@@ -117,6 +125,14 @@ export const providers = pgTable('providers', {
 
   // 金额限流配置
   limit5hUsd: numeric('limit_5h_usd', { precision: 10, scale: 2 }),
+  limitDailyUsd: numeric('limit_daily_usd', { precision: 10, scale: 2 }),
+  dailyResetMode: varchar('daily_reset_mode', { length: 10 })
+    .default('fixed')
+    .notNull()
+    .$type<'fixed' | 'rolling'>(), // fixed: 固定时间重置, rolling: 滚动窗口(24小时)
+  dailyResetTime: varchar('daily_reset_time', { length: 5 })
+    .default('00:00')
+    .notNull(), // HH:mm 格式,如 "18:00"(仅 fixed 模式使用)
   limitWeeklyUsd: numeric('limit_weekly_usd', { precision: 10, scale: 2 }),
   limitMonthlyUsd: numeric('limit_monthly_usd', { precision: 10, scale: 2 }),
   limitConcurrentSessions: integer('limit_concurrent_sessions').default(0),

+ 3 - 0
src/lib/auth.ts

@@ -38,6 +38,9 @@ export async function validateKey(keyString: string): Promise<AuthSession | null
       isEnabled: true,
       canLoginWebUi: true, // Admin Token 始终可以登录 Web UI
       limit5hUsd: null,
+      limitDailyUsd: null,
+      dailyResetMode: "fixed",
+      dailyResetTime: "00:00",
       limitWeeklyUsd: null,
       limitMonthlyUsd: null,
       limitConcurrentSessions: 0,

+ 2 - 1
src/lib/hooks/use-format-currency.ts

@@ -42,7 +42,8 @@ export function useFormatCurrency() {
         minimumFractionDigits: fractionDigits,
         maximumFractionDigits: fractionDigits,
       });
-    } catch {
+    } catch (error) {
+      console.warn("Currency formatting failed, falling back to manual formatting", error);
       // Fallback to manual formatting if currency is not supported
       const formatted = amount.toLocaleString(config.locale, {
         minimumFractionDigits: fractionDigits,

+ 229 - 32
src/lib/rate-limit/service.ts

@@ -5,14 +5,26 @@ import {
   CHECK_AND_TRACK_SESSION,
   TRACK_COST_5H_ROLLING_WINDOW,
   GET_COST_5H_ROLLING_WINDOW,
+  TRACK_COST_DAILY_ROLLING_WINDOW,
+  GET_COST_DAILY_ROLLING_WINDOW,
 } from "@/lib/redis/lua-scripts";
 import { sumUserCostToday } from "@/repository/statistics";
-import { getTimeRangeForPeriod, getTTLForPeriod, getSecondsUntilMidnight } from "./time-utils";
+import {
+  getTimeRangeForPeriod,
+  getTimeRangeForPeriodWithMode,
+  getTTLForPeriod,
+  getTTLForPeriodWithMode,
+  getSecondsUntilMidnight,
+  normalizeResetTime,
+  type DailyResetMode,
+} from "./time-utils";
 
 interface CostLimit {
   amount: number | null;
-  period: "5h" | "weekly" | "monthly";
+  period: "5h" | "daily" | "weekly" | "monthly";
   name: string;
+  resetTime?: string; // 自定义重置时间(仅 daily + fixed 模式使用,格式 "HH:mm")
+  resetMode?: DailyResetMode; // 日限额重置模式(仅 daily 使用)
 }
 
 export class RateLimitService {
@@ -21,6 +33,11 @@ export class RateLimitService {
     return getRedisClient();
   }
 
+  private static resolveDailyReset(resetTime?: string): { normalized: string; suffix: string } {
+    const normalized = normalizeResetTime(resetTime);
+    return { normalized, suffix: normalized.replace(":", "") };
+  }
+
   /**
    * 检查金额限制(Key 或 Provider)
    * 优先使用 Redis,失败时降级到数据库查询(防止 Redis 清空后超支)
@@ -30,12 +47,24 @@ export class RateLimitService {
     type: "key" | "provider",
     limits: {
       limit_5h_usd: number | null;
+      limit_daily_usd: number | null;
+      daily_reset_time?: string;
+      daily_reset_mode?: DailyResetMode;
       limit_weekly_usd: number | null;
       limit_monthly_usd: number | null;
     }
   ): Promise<{ allowed: boolean; reason?: string }> {
+    const normalizedDailyReset = normalizeResetTime(limits.daily_reset_time);
+    const dailyResetMode = limits.daily_reset_mode ?? "fixed";
     const costLimits: CostLimit[] = [
       { amount: limits.limit_5h_usd, period: "5h", name: "5小时" },
+      {
+        amount: limits.limit_daily_usd,
+        period: "daily",
+        name: "每日",
+        resetTime: normalizedDailyReset,
+        resetMode: dailyResetMode,
+      },
       { amount: limits.limit_weekly_usd, period: "weekly", name: "周" },
       { amount: limits.limit_monthly_usd, period: "monthly", name: "月" },
     ];
@@ -82,14 +111,48 @@ export class RateLimitService {
               );
               return await this.checkCostLimitsFromDatabase(id, type, costLimits);
             }
+          } else if (limit.period === "daily" && limit.resetMode === "rolling") {
+            // daily 滚动窗口:使用 ZSET + Lua 脚本
+            try {
+              const key = `${type}:${id}:cost_daily_rolling`;
+              const window24h = 24 * 60 * 60 * 1000;
+              const result = (await this.redis.eval(
+                GET_COST_DAILY_ROLLING_WINDOW,
+                1,
+                key,
+                now.toString(),
+                window24h.toString()
+              )) as string;
+
+              current = parseFloat(result || "0");
+
+              // Cache Miss 检测
+              if (current === 0) {
+                const exists = await this.redis.exists(key);
+                if (!exists) {
+                  logger.info(
+                    `[RateLimit] Cache miss for ${type}:${id}:cost_daily_rolling, querying database`
+                  );
+                  return await this.checkCostLimitsFromDatabase(id, type, costLimits);
+                }
+              }
+            } catch (error) {
+              logger.error(
+                "[RateLimit] Daily 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}`);
+            // daily fixed/周/月使用普通 GET
+            const { suffix } = this.resolveDailyReset(limit.resetTime);
+            const periodKey = limit.period === "daily" ? `${limit.period}_${suffix}` : limit.period;
+            const value = await this.redis.get(`${type}:${id}:cost_${periodKey}`);
 
             // Cache Miss 检测
             if (value === null && limit.amount > 0) {
               logger.info(
-                `[RateLimit] Cache miss for ${type}:${id}:cost_${limit.period}, querying database`
+                `[RateLimit] Cache miss for ${type}:${id}:cost_${periodKey}, querying database`
               );
               return await this.checkCostLimitsFromDatabase(id, type, costLimits);
             }
@@ -132,8 +195,12 @@ export class RateLimitService {
     for (const limit of costLimits) {
       if (!limit.amount || limit.amount <= 0) continue;
 
-      // 计算时间范围(使用新的时间工具函数)
-      const { startTime, endTime } = getTimeRangeForPeriod(limit.period);
+      // 计算时间范围(使用支持模式的时间工具函数)
+      const { startTime, endTime } = getTimeRangeForPeriodWithMode(
+        limit.period,
+        limit.resetTime,
+        limit.resetMode
+      );
 
       // 查询数据库
       const current =
@@ -162,17 +229,34 @@ export class RateLimitService {
 
               logger.info(`[RateLimit] Cache warmed for ${key}, value=${current} (rolling window)`);
             }
+          } else if (limit.period === "daily" && limit.resetMode === "rolling") {
+            // daily 滚动窗口:使用 ZSET + Lua 脚本
+            if (current > 0) {
+              const now = Date.now();
+              const window24h = 24 * 60 * 60 * 1000;
+              const key = `${type}:${id}:cost_daily_rolling`;
+
+              await this.redis.eval(
+                TRACK_COST_DAILY_ROLLING_WINDOW,
+                1,
+                key,
+                current.toString(),
+                now.toString(),
+                window24h.toString()
+              );
+
+              logger.info(
+                `[RateLimit] Cache warmed for ${key}, value=${current} (daily rolling window)`
+              );
+            }
           } else {
-            // 周/月固定窗口:使用 STRING + 动态 TTL
-            const ttl = getTTLForPeriod(limit.period);
-            await this.redis.set(
-              `${type}:${id}:cost_${limit.period}`,
-              current.toString(),
-              "EX",
-              ttl
-            );
+            // daily fixed/周/月固定窗口:使用 STRING + 动态 TTL
+            const { normalized, suffix } = this.resolveDailyReset(limit.resetTime);
+            const ttl = getTTLForPeriodWithMode(limit.period, normalized, limit.resetMode);
+            const periodKey = limit.period === "daily" ? `${limit.period}_${suffix}` : limit.period;
+            await this.redis.set(`${type}:${id}:cost_${periodKey}`, current.toString(), "EX", ttl);
             logger.info(
-              `[RateLimit] Cache warmed for ${type}:${id}:cost_${limit.period}, value=${current}, ttl=${ttl}s`
+              `[RateLimit] Cache warmed for ${type}:${id}:cost_${periodKey}, value=${current}, ttl=${ttl}s`
             );
           }
         } catch (error) {
@@ -289,21 +373,38 @@ export class RateLimitService {
 
   /**
    * 累加消费(请求结束后调用)
-   * 5h 使用滚动窗口(ZSET),周/月使用固定窗口(STRING)
+   * 5h 使用滚动窗口(ZSET),daily 根据模式选择滚动/固定窗口,周/月使用固定窗口(STRING)
    */
   static async trackCost(
     keyId: number,
     providerId: number,
     sessionId: string,
-    cost: number
+    cost: number,
+    options?: {
+      keyResetTime?: string;
+      keyResetMode?: DailyResetMode;
+      providerResetTime?: string;
+      providerResetMode?: DailyResetMode;
+    }
   ): Promise<void> {
     if (!this.redis || cost <= 0) return;
 
     try {
+      const keyDailyReset = this.resolveDailyReset(options?.keyResetTime);
+      const providerDailyReset = this.resolveDailyReset(options?.providerResetTime);
+      const keyDailyMode = options?.keyResetMode ?? "fixed";
+      const providerDailyMode = options?.providerResetMode ?? "fixed";
       const now = Date.now();
       const window5h = 5 * 60 * 60 * 1000; // 5 hours in ms
-
-      // 计算动态 TTL(周/月)
+      const window24h = 24 * 60 * 60 * 1000; // 24 hours in ms
+
+      // 计算动态 TTL(daily/周/月)
+      const ttlDailyKey = getTTLForPeriodWithMode("daily", keyDailyReset.normalized, keyDailyMode);
+      const ttlDailyProvider =
+        keyDailyReset.normalized === providerDailyReset.normalized &&
+        keyDailyMode === providerDailyMode
+          ? ttlDailyKey
+          : getTTLForPeriodWithMode("daily", providerDailyReset.normalized, providerDailyMode);
       const ttlWeekly = getTTLForPeriod("weekly");
       const ttlMonthly = getTTLForPeriod("monthly");
 
@@ -328,17 +429,52 @@ export class RateLimitService {
         window5h.toString()
       );
 
-      // 2. 周/月固定窗口:使用 STRING + 动态 TTL
+      // 2. daily 滚动窗口:使用 Lua 脚本(ZSET)
+      if (keyDailyMode === "rolling") {
+        await this.redis.eval(
+          TRACK_COST_DAILY_ROLLING_WINDOW,
+          1,
+          `key:${keyId}:cost_daily_rolling`,
+          cost.toString(),
+          now.toString(),
+          window24h.toString()
+        );
+      }
+
+      if (providerDailyMode === "rolling") {
+        await this.redis.eval(
+          TRACK_COST_DAILY_ROLLING_WINDOW,
+          1,
+          `provider:${providerId}:cost_daily_rolling`,
+          cost.toString(),
+          now.toString(),
+          window24h.toString()
+        );
+      }
+
+      // 3. daily fixed/周/月固定窗口:使用 STRING + 动态 TTL
       const pipeline = this.redis.pipeline();
 
-      // Key 的周/月消费
+      // Key 的 daily fixed/周/月消费
+      if (keyDailyMode === "fixed") {
+        const keyDailyKey = `key:${keyId}:cost_daily_${keyDailyReset.suffix}`;
+        pipeline.incrbyfloat(keyDailyKey, cost);
+        pipeline.expire(keyDailyKey, ttlDailyKey);
+      }
+
       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);
 
-      // Provider 的周/月消费
+      // Provider 的 daily fixed/周/月消费
+      if (providerDailyMode === "fixed") {
+        const providerDailyKey = `provider:${providerId}:cost_daily_${providerDailyReset.suffix}`;
+        pipeline.incrbyfloat(providerDailyKey, cost);
+        pipeline.expire(providerDailyKey, ttlDailyProvider);
+      }
+
       pipeline.incrbyfloat(`provider:${providerId}:cost_weekly`, cost);
       pipeline.expire(`provider:${providerId}:cost_weekly`, ttlWeekly);
 
@@ -361,9 +497,12 @@ export class RateLimitService {
   static async getCurrentCost(
     id: number,
     type: "key" | "provider",
-    period: "5h" | "weekly" | "monthly"
+    period: "5h" | "daily" | "weekly" | "monthly",
+    resetTime = "00:00",
+    resetMode: DailyResetMode = "fixed"
   ): Promise<number> {
     try {
+      const dailyResetInfo = this.resolveDailyReset(resetTime);
       // Fast Path: Redis 查询
       if (this.redis && this.redis.status === "ready") {
         let current = 0;
@@ -397,9 +536,40 @@ export class RateLimitService {
             // Key 存在但值为 0,说明真的是 0
             return 0;
           }
+        } else if (period === "daily" && resetMode === "rolling") {
+          // daily 滚动窗口:使用 ZSET + Lua 脚本
+          const now = Date.now();
+          const window24h = 24 * 60 * 60 * 1000;
+          const key = `${type}:${id}:cost_daily_rolling`;
+
+          const result = (await this.redis.eval(
+            GET_COST_DAILY_ROLLING_WINDOW,
+            1,
+            key,
+            now.toString(),
+            window24h.toString()
+          )) as string;
+
+          current = parseFloat(result || "0");
+
+          // Cache Hit
+          if (current > 0) {
+            return current;
+          }
+
+          // Cache Miss 检测
+          const exists = await this.redis.exists(key);
+          if (!exists) {
+            logger.info(
+              `[RateLimit] Cache miss for ${type}:${id}:cost_daily_rolling, querying database`
+            );
+          } else {
+            return 0;
+          }
         } else {
-          // 周/月使用普通 GET
-          const value = await this.redis.get(`${type}:${id}:cost_${period}`);
+          // daily fixed/周/月使用普通 GET
+          const redisKey = period === "daily" ? `${period}_${dailyResetInfo.suffix}` : period;
+          const value = await this.redis.get(`${type}:${id}:cost_${redisKey}`);
 
           // Cache Hit
           if (value !== null) {
@@ -407,7 +577,9 @@ export class RateLimitService {
           }
 
           // Cache Miss: 从数据库恢复
-          logger.info(`[RateLimit] Cache miss for ${type}:${id}:cost_${period}, querying database`);
+          logger.info(
+            `[RateLimit] Cache miss for ${type}:${id}:cost_${redisKey}, querying database`
+          );
         }
       } else {
         logger.warn(`[RateLimit] Redis unavailable, querying database for ${type} cost`);
@@ -418,7 +590,11 @@ export class RateLimitService {
         "@/repository/statistics"
       );
 
-      const { startTime, endTime } = getTimeRangeForPeriod(period);
+      const { startTime, endTime } = getTimeRangeForPeriodWithMode(
+        period,
+        dailyResetInfo.normalized,
+        resetMode
+      );
       const current =
         type === "key"
           ? await sumKeyCostInTimeRange(id, startTime, endTime)
@@ -447,12 +623,33 @@ export class RateLimitService {
 
               logger.info(`[RateLimit] Cache warmed for ${key}, value=${current} (rolling window)`);
             }
+          } else if (period === "daily" && resetMode === "rolling") {
+            // daily 滚动窗口:使用 ZSET + Lua 脚本
+            if (current > 0) {
+              const now = Date.now();
+              const window24h = 24 * 60 * 60 * 1000;
+              const key = `${type}:${id}:cost_daily_rolling`;
+
+              await this.redis.eval(
+                TRACK_COST_DAILY_ROLLING_WINDOW,
+                1,
+                key,
+                current.toString(),
+                now.toString(),
+                window24h.toString()
+              );
+
+              logger.info(
+                `[RateLimit] Cache warmed for ${key}, value=${current} (daily rolling window)`
+              );
+            }
           } else {
-            // 周/月固定窗口:使用 STRING + 动态 TTL
-            const ttl = getTTLForPeriod(period);
-            await this.redis.set(`${type}:${id}:cost_${period}`, current.toString(), "EX", ttl);
+            // daily fixed/周/月固定窗口:使用 STRING + 动态 TTL
+            const redisKey = period === "daily" ? `${period}_${dailyResetInfo.suffix}` : period;
+            const ttl = getTTLForPeriodWithMode(period, dailyResetInfo.normalized, resetMode);
+            await this.redis.set(`${type}:${id}:cost_${redisKey}`, current.toString(), "EX", ttl);
             logger.info(
-              `[RateLimit] Cache warmed for ${type}:${id}:cost_${period}, value=${current}, ttl=${ttl}s`
+              `[RateLimit] Cache warmed for ${type}:${id}:cost_${redisKey}, value=${current}, ttl=${ttl}s`
             );
           }
         } catch (error) {

+ 159 - 7
src/lib/rate-limit/time-utils.ts

@@ -3,11 +3,22 @@
  * 用于计算自然时间窗口(周一/月初)和对应的 TTL
  */
 
-import { startOfMonth, startOfWeek, addMonths, addWeeks, addDays } from "date-fns";
+import {
+  startOfMonth,
+  startOfWeek,
+  addMonths,
+  addWeeks,
+  addDays,
+  setHours,
+  setMinutes,
+  setSeconds,
+  setMilliseconds,
+} from "date-fns";
 import { toZonedTime, fromZonedTime } from "date-fns-tz";
 import { getEnvConfig } from "@/lib/config";
 
-export type TimePeriod = "5h" | "weekly" | "monthly";
+export type TimePeriod = "5h" | "daily" | "weekly" | "monthly";
+export type DailyResetMode = "fixed" | "rolling";
 
 export interface TimeRange {
   startTime: Date;
@@ -15,21 +26,23 @@ export interface TimeRange {
 }
 
 export interface ResetInfo {
-  type: "rolling" | "natural";
-  resetAt?: Date; // 自然时间窗口的重置时间
+  type: "rolling" | "natural" | "custom";
+  resetAt?: Date; // 自然/自定义时间窗口的重置时间
   period?: string; // 滚动窗口的周期描述
 }
 
 /**
  * 根据周期计算时间范围
  * - 5h: 滚动窗口(过去 5 小时)
+ * - daily: 自定义每日重置时间到现在(需要额外的 resetTime 参数)
  * - weekly: 自然周(本周一 00:00 到现在)
  * - monthly: 自然月(本月 1 号 00:00 到现在)
  *
  * 所有自然时间窗口使用配置的时区(Asia/Shanghai)
  */
-export function getTimeRangeForPeriod(period: TimePeriod): TimeRange {
+export function getTimeRangeForPeriod(period: TimePeriod, resetTime = "00:00"): TimeRange {
   const timezone = getEnvConfig().TZ; // 'Asia/Shanghai'
+  const normalizedResetTime = normalizeResetTime(resetTime);
   const now = new Date();
   const endTime = now;
   let startTime: Date;
@@ -40,6 +53,12 @@ export function getTimeRangeForPeriod(period: TimePeriod): TimeRange {
       startTime = new Date(now.getTime() - 5 * 60 * 60 * 1000);
       break;
 
+    case "daily": {
+      // 自定义每日重置时间(例如:18:00)
+      startTime = getCustomDailyResetTime(now, normalizedResetTime, timezone);
+      break;
+    }
+
     case "weekly": {
       // 自然周:本周一 00:00 (Asia/Shanghai)
       const zonedNow = toZonedTime(now, timezone);
@@ -60,20 +79,51 @@ export function getTimeRangeForPeriod(period: TimePeriod): TimeRange {
   return { startTime, endTime };
 }
 
+/**
+ * 根据周期和模式计算时间范围(支持滚动窗口模式)
+ * - daily + rolling: 滚动窗口(过去 24 小时)
+ * - daily + fixed: 固定时间重置(使用 resetTime)
+ * - 其他周期:使用原有逻辑
+ */
+export function getTimeRangeForPeriodWithMode(
+  period: TimePeriod,
+  resetTime = "00:00",
+  mode: DailyResetMode = "fixed"
+): TimeRange {
+  if (period === "daily" && mode === "rolling") {
+    // 滚动窗口:过去 24 小时
+    const now = new Date();
+    return {
+      startTime: new Date(now.getTime() - 24 * 60 * 60 * 1000),
+      endTime: now,
+    };
+  }
+
+  // 其他情况使用原有逻辑
+  return getTimeRangeForPeriod(period, resetTime);
+}
+
 /**
  * 根据周期计算 Redis Key 的 TTL(秒)
  * - 5h: 5 小时(固定)
+ * - daily: 到下一个自定义重置时间的秒数
  * - weekly: 到下周一 00:00 的秒数
  * - monthly: 到下月 1 号 00:00 的秒数
  */
-export function getTTLForPeriod(period: TimePeriod): number {
+export function getTTLForPeriod(period: TimePeriod, resetTime = "00:00"): number {
   const timezone = getEnvConfig().TZ;
   const now = new Date();
+  const normalizedResetTime = normalizeResetTime(resetTime);
 
   switch (period) {
     case "5h":
       return 5 * 3600; // 5 小时
 
+    case "daily": {
+      const nextReset = getNextDailyResetTime(now, normalizedResetTime, timezone);
+      return Math.max(1, Math.ceil((nextReset.getTime() - now.getTime()) / 1000));
+    }
+
     case "weekly": {
       // 计算到下周一 00:00 的秒数
       const zonedNow = toZonedTime(now, timezone);
@@ -96,12 +146,31 @@ export function getTTLForPeriod(period: TimePeriod): number {
   }
 }
 
+/**
+ * 根据周期和模式计算 Redis Key 的 TTL(秒)
+ * - daily + rolling: 24 小时(固定)
+ * - daily + fixed: 到下一个自定义重置时间的秒数
+ * - 其他周期:使用原有逻辑
+ */
+export function getTTLForPeriodWithMode(
+  period: TimePeriod,
+  resetTime = "00:00",
+  mode: DailyResetMode = "fixed"
+): number {
+  if (period === "daily" && mode === "rolling") {
+    return 24 * 3600; // 24 小时
+  }
+
+  return getTTLForPeriod(period, resetTime);
+}
+
 /**
  * 获取重置信息(用于前端展示)
  */
-export function getResetInfo(period: TimePeriod): ResetInfo {
+export function getResetInfo(period: TimePeriod, resetTime = "00:00"): ResetInfo {
   const timezone = getEnvConfig().TZ;
   const now = new Date();
+  const normalizedResetTime = normalizeResetTime(resetTime);
 
   switch (period) {
     case "5h":
@@ -110,6 +179,14 @@ export function getResetInfo(period: TimePeriod): ResetInfo {
         period: "5 小时",
       };
 
+    case "daily": {
+      const nextReset = getNextDailyResetTime(now, normalizedResetTime, timezone);
+      return {
+        type: "custom",
+        resetAt: nextReset,
+      };
+    }
+
     case "weekly": {
       const zonedNow = toZonedTime(now, timezone);
       const zonedStartOfWeek = startOfWeek(zonedNow, { weekStartsOn: 1 });
@@ -136,6 +213,81 @@ export function getResetInfo(period: TimePeriod): ResetInfo {
   }
 }
 
+/**
+ * 获取重置信息(支持滚动窗口模式)
+ */
+export function getResetInfoWithMode(
+  period: TimePeriod,
+  resetTime = "00:00",
+  mode: DailyResetMode = "fixed"
+): ResetInfo {
+  if (period === "daily" && mode === "rolling") {
+    return {
+      type: "rolling",
+      period: "24 小时",
+    };
+  }
+
+  return getResetInfo(period, resetTime);
+}
+
+function getCustomDailyResetTime(now: Date, resetTime: string, timezone: string): Date {
+  const { hours, minutes } = parseResetTime(resetTime);
+  const zonedNow = toZonedTime(now, timezone);
+  const zonedResetToday = buildZonedDate(zonedNow, hours, minutes);
+  const resetToday = fromZonedTime(zonedResetToday, timezone);
+
+  if (now >= resetToday) {
+    return resetToday;
+  }
+
+  return addDays(resetToday, -1);
+}
+
+function getNextDailyResetTime(now: Date, resetTime: string, timezone: string): Date {
+  const { hours, minutes } = parseResetTime(resetTime);
+  const zonedNow = toZonedTime(now, timezone);
+  const zonedResetToday = buildZonedDate(zonedNow, hours, minutes);
+  const resetToday = fromZonedTime(zonedResetToday, timezone);
+
+  if (now < resetToday) {
+    return resetToday;
+  }
+
+  const zonedNextDay = addDays(zonedResetToday, 1);
+  return fromZonedTime(zonedNextDay, timezone);
+}
+
+function buildZonedDate(base: Date, hours: number, minutes: number): Date {
+  const withHours = setHours(base, hours);
+  const withMinutes = setMinutes(withHours, minutes);
+  const withSeconds = setSeconds(withMinutes, 0);
+  return setMilliseconds(withSeconds, 0);
+}
+
+function parseResetTime(resetTime: string): { hours: number; minutes: number } {
+  const matches = /^([0-9]{1,2}):([0-9]{2})$/.exec(resetTime.trim());
+  if (!matches) {
+    return { hours: 0, minutes: 0 };
+  }
+  let hours = Number(matches[1]);
+  let minutes = Number(matches[2]);
+
+  if (Number.isNaN(hours) || hours < 0 || hours > 23) {
+    hours = 0;
+  }
+  if (Number.isNaN(minutes) || minutes < 0 || minutes > 59) {
+    minutes = 0;
+  }
+
+  return { hours, minutes };
+}
+
+export function normalizeResetTime(resetTime?: string): string {
+  const { hours, minutes } = parseResetTime(resetTime ?? "00:00");
+  return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`;
+}
+
 /**
  * 计算距离午夜的秒数(用于每日限额)
  * 使用配置的时区(Asia/Shanghai)而非服务器本地时区

+ 19 - 3
src/lib/redis/client.ts

@@ -3,6 +3,18 @@ import { logger } from "@/lib/logger";
 
 let redisClient: Redis | null = null;
 
+function maskRedisUrl(redisUrl: string) {
+  try {
+    const parsed = new URL(redisUrl);
+    if (parsed.password) {
+      parsed.password = "****";
+    }
+    return parsed.toString();
+  } catch {
+    return redisUrl.replace(/:\w+@/, ":****@");
+  }
+}
+
 /**
  * Build ioredis connection options with protocol-based TLS detection.
  * - When `rediss://` is used, explicitly enable TLS via `tls: {}`
@@ -53,6 +65,8 @@ export function getRedisClient(): Redis | null {
     return null;
   }
 
+  const safeRedisUrl = maskRedisUrl(redisUrl);
+
   if (redisClient) {
     return redisClient;
   }
@@ -77,7 +91,7 @@ export function getRedisClient(): Redis | null {
 
     // 2. 如果使用 rediss://,则添加显式的 TLS 和 SNI (host) 配置
     if (useTls) {
-      logger.info("[Redis] Using TLS connection (rediss://)");
+      logger.info("[Redis] Using TLS connection (rediss://)", { redisUrl: safeRedisUrl });
       try {
         // 从 URL 中解析 hostname,用于 SNI
         const url = new URL(redisUrl);
@@ -99,6 +113,7 @@ export function getRedisClient(): Redis | null {
       logger.info("[Redis] Connected successfully", {
         protocol: useTls ? "rediss" : "redis",
         tlsEnabled: useTls,
+        redisUrl: safeRedisUrl,
       });
     });
 
@@ -107,17 +122,18 @@ export function getRedisClient(): Redis | null {
         error: error instanceof Error ? error.message : String(error),
         protocol: useTls ? "rediss" : "redis",
         tlsEnabled: useTls,
+        redisUrl: safeRedisUrl,
       });
     });
 
     redisClient.on("close", () => {
-      logger.warn("[Redis] Connection closed");
+      logger.warn("[Redis] Connection closed", { redisUrl: safeRedisUrl });
     });
 
     // 5. 返回客户端实例
     return redisClient;
   } catch (error) {
-    logger.error("[Redis] Failed to initialize:", error);
+    logger.error("[Redis] Failed to initialize:", error, { redisUrl: safeRedisUrl });
     return null;
   }
 }

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

@@ -180,3 +180,83 @@ end
 
 return tostring(total)
 `;
+
+/**
+ * 追踪 24小时滚动窗口消费(使用 ZSET)
+ *
+ * 功能:
+ * 1. 清理 24 小时前的消费记录
+ * 2. 添加当前消费记录(带时间戳)
+ * 3. 计算当前窗口内的总消费
+ * 4. 设置兜底 TTL(25 小时)
+ *
+ * KEYS[1]: key:${id}:cost_daily_rolling 或 provider:${id}:cost_daily_rolling
+ * ARGV[1]: cost(本次消费金额)
+ * ARGV[2]: now(当前时间戳,毫秒)
+ * ARGV[3]: window(窗口时长,毫秒,默认 86400000 = 24小时)
+ *
+ * 返回值:string - 当前窗口内的总消费
+ */
+export const TRACK_COST_DAILY_ROLLING_WINDOW = `
+local key = KEYS[1]
+local cost = tonumber(ARGV[1])
+local now_ms = tonumber(ARGV[2])
+local window_ms = tonumber(ARGV[3])  -- 24 hours = 86400000 ms
+
+-- 1. 清理过期记录(24 小时前的数据)
+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(25 小时,防止数据永久堆积)
+redis.call('EXPIRE', key, 90000)
+
+return tostring(total)
+`;
+
+/**
+ * 查询 24小时滚动窗口当前消费
+ *
+ * 功能:
+ * 1. 清理 24 小时前的消费记录
+ * 2. 计算当前窗口内的总消费
+ *
+ * KEYS[1]: key:${id}:cost_daily_rolling 或 provider:${id}:cost_daily_rolling
+ * ARGV[1]: now(当前时间戳,毫秒)
+ * ARGV[2]: window(窗口时长,毫秒,默认 86400000 = 24小时)
+ *
+ * 返回值:string - 当前窗口内的总消费
+ */
+export const GET_COST_DAILY_ROLLING_WINDOW = `
+local key = KEYS[1]
+local now_ms = tonumber(ARGV[1])
+local window_ms = tonumber(ARGV[2])  -- 24 hours = 86400000 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)
+`;

+ 4 - 2
src/lib/utils/error-messages.ts

@@ -160,7 +160,8 @@ export function getErrorMessage(
 ): string {
   try {
     return t(code, params);
-  } catch {
+  } catch (error) {
+    console.warn("Translation missing for error code", code, error);
     // Fallback to generic error message if translation key not found
     return t("INTERNAL_ERROR");
   }
@@ -189,7 +190,8 @@ export async function getErrorMessageServer(
     const { getTranslations } = await import("next-intl/server");
     const t = await getTranslations({ locale, namespace: "errors" });
     return t(code, params);
-  } catch {
+  } catch (error) {
+    console.error("getErrorMessageServer failed", { locale, code, error });
     // Fallback to generic error message
     return "An error occurred";
   }

+ 5 - 0
src/lib/utils/quota-helpers.ts

@@ -7,6 +7,7 @@
 // 类型定义
 export type KeyQuota = {
   cost5h: { current: number; limit: number | null };
+  costDaily: { current: number; limit: number | null };
   costWeekly: { current: number; limit: number | null };
   costMonthly: { current: number; limit: number | null };
   concurrentSessions: { current: number; limit: number };
@@ -28,6 +29,7 @@ export function hasKeyQuotaSet(quota: KeyQuota): boolean {
 
   return !!(
     quota.cost5h.limit ||
+    quota.costDaily.limit ||
     quota.costWeekly.limit ||
     quota.costMonthly.limit ||
     (quota.concurrentSessions.limit && quota.concurrentSessions.limit > 0)
@@ -75,6 +77,9 @@ export function getMaxUsageRate(quota: KeyQuota): number {
   if (quota.cost5h.limit) {
     rates.push(getUsageRate(quota.cost5h.current, quota.cost5h.limit));
   }
+  if (quota.costDaily.limit) {
+    rates.push(getUsageRate(quota.costDaily.current, quota.costDaily.limit));
+  }
   if (quota.costWeekly.limit) {
     rates.push(getUsageRate(quota.costWeekly.current, quota.costWeekly.limit));
   }

+ 12 - 2
src/lib/utils/zod-i18n.ts

@@ -55,7 +55,12 @@ export function setZodErrorMap(
 
     try {
       return { message: t(code, params) };
-    } catch {
+    } catch (error) {
+      // Only log in development to avoid sensitive data exposure
+      if (process.env.NODE_ENV === "development") {
+        console.warn("setZodErrorMap fallback", { code, error });
+        // Avoid logging the full issue object which may contain user input
+      }
       // Fallback to Zod default message
       return { message: _ctx.defaultError };
     }
@@ -91,7 +96,12 @@ export async function getZodErrorMapServer(locale: string) {
 
     try {
       return { message: t(code, params) };
-    } catch {
+    } catch (error) {
+      // Only log in development to avoid sensitive data exposure
+      if (process.env.NODE_ENV === "development") {
+        console.warn("getZodErrorMapServer fallback", { locale, code, error });
+        // Avoid logging the full issue object which may contain user input
+      }
       return { message: _ctx.defaultError };
     }
   };

+ 35 - 0
src/lib/validation/schemas.ts

@@ -118,6 +118,18 @@ export const KeyFormSchema = z.object({
     .max(10000, "5小时消费上限不能超过10000美元")
     .nullable()
     .optional(),
+  limitDailyUsd: z.coerce
+    .number()
+    .min(0, "每日消费上限不能为负数")
+    .max(10000, "每日消费上限不能超过10000美元")
+    .nullable()
+    .optional(),
+  dailyResetMode: z.enum(["fixed", "rolling"]).optional().default("fixed"),
+  dailyResetTime: z
+    .string()
+    .regex(/^([01]?[0-9]|2[0-3]):[0-5][0-9]$/, "重置时间格式必须为 HH:mm")
+    .optional()
+    .default("00:00"),
   limitWeeklyUsd: z.coerce
     .number()
     .min(0, "周消费上限不能为负数")
@@ -184,6 +196,18 @@ export const CreateProviderSchema = z.object({
     .max(10000, "5小时消费上限不能超过10000美元")
     .nullable()
     .optional(),
+  limit_daily_usd: z.coerce
+    .number()
+    .min(0, "每日消费上限不能为负数")
+    .max(10000, "每日消费上限不能超过10000美元")
+    .nullable()
+    .optional(),
+  daily_reset_mode: z.enum(["fixed", "rolling"]).optional().default("fixed"),
+  daily_reset_time: z
+    .string()
+    .regex(/^([01]?[0-9]|2[0-3]):[0-5][0-9]$/, "重置时间格式必须为 HH:mm")
+    .optional()
+    .default("00:00"),
   limit_weekly_usd: z.coerce
     .number()
     .min(0, "周消费上限不能为负数")
@@ -322,6 +346,17 @@ export const UpdateProviderSchema = z
       .max(10000, "5小时消费上限不能超过10000美元")
       .nullable()
       .optional(),
+    limit_daily_usd: z.coerce
+      .number()
+      .min(0, "每日消费上限不能为负数")
+      .max(10000, "每日消费上限不能超过10000美元")
+      .nullable()
+      .optional(),
+    daily_reset_mode: z.enum(["fixed", "rolling"]).optional(),
+    daily_reset_time: z
+      .string()
+      .regex(/^([01]?[0-9]|2[0-3]):[0-5][0-9]$/, "重置时间格式必须为 HH:mm")
+      .optional(),
     limit_weekly_usd: z.coerce
       .number()
       .min(0, "周消费上限不能为负数")

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

@@ -27,6 +27,8 @@ export function toKey(dbKey: any): Key {
     isEnabled: dbKey?.isEnabled ?? true,
     canLoginWebUi: dbKey?.canLoginWebUi ?? true,
     limit5hUsd: dbKey?.limit5hUsd ? parseFloat(dbKey.limit5hUsd) : null,
+    limitDailyUsd: dbKey?.limitDailyUsd ? parseFloat(dbKey.limitDailyUsd) : null,
+    dailyResetTime: dbKey?.dailyResetTime ?? "00:00",
     limitWeeklyUsd: dbKey?.limitWeeklyUsd ? parseFloat(dbKey.limitWeeklyUsd) : null,
     limitMonthlyUsd: dbKey?.limitMonthlyUsd ? parseFloat(dbKey.limitMonthlyUsd) : null,
     limitConcurrentSessions: dbKey?.limitConcurrentSessions ?? 0,
@@ -48,6 +50,8 @@ export function toProvider(dbProvider: any): Provider {
     modelRedirects: dbProvider?.modelRedirects ?? null,
     codexInstructionsStrategy: dbProvider?.codexInstructionsStrategy ?? "auto",
     limit5hUsd: dbProvider?.limit5hUsd ? parseFloat(dbProvider.limit5hUsd) : null,
+    limitDailyUsd: dbProvider?.limitDailyUsd ? parseFloat(dbProvider.limitDailyUsd) : null,
+    dailyResetTime: dbProvider?.dailyResetTime ?? "00:00",
     limitWeeklyUsd: dbProvider?.limitWeeklyUsd ? parseFloat(dbProvider.limitWeeklyUsd) : null,
     limitMonthlyUsd: dbProvider?.limitMonthlyUsd ? parseFloat(dbProvider.limitMonthlyUsd) : null,
     limitConcurrentSessions: dbProvider?.limitConcurrentSessions ?? 0,

+ 32 - 0
src/repository/key.ts

@@ -19,6 +19,9 @@ export async function findKeyById(id: number): Promise<Key | null> {
       expiresAt: keys.expiresAt,
       canLoginWebUi: keys.canLoginWebUi,
       limit5hUsd: keys.limit5hUsd,
+      limitDailyUsd: keys.limitDailyUsd,
+      dailyResetMode: keys.dailyResetMode,
+      dailyResetTime: keys.dailyResetTime,
       limitWeeklyUsd: keys.limitWeeklyUsd,
       limitMonthlyUsd: keys.limitMonthlyUsd,
       limitConcurrentSessions: keys.limitConcurrentSessions,
@@ -44,6 +47,9 @@ export async function findKeyList(userId: number): Promise<Key[]> {
       expiresAt: keys.expiresAt,
       canLoginWebUi: keys.canLoginWebUi,
       limit5hUsd: keys.limit5hUsd,
+      limitDailyUsd: keys.limitDailyUsd,
+      dailyResetMode: keys.dailyResetMode,
+      dailyResetTime: keys.dailyResetTime,
       limitWeeklyUsd: keys.limitWeeklyUsd,
       limitMonthlyUsd: keys.limitMonthlyUsd,
       limitConcurrentSessions: keys.limitConcurrentSessions,
@@ -67,6 +73,9 @@ export async function createKey(keyData: CreateKeyData): Promise<Key> {
     expiresAt: keyData.expires_at,
     canLoginWebUi: keyData.can_login_web_ui ?? true,
     limit5hUsd: keyData.limit_5h_usd != null ? keyData.limit_5h_usd.toString() : null,
+    limitDailyUsd: keyData.limit_daily_usd != null ? keyData.limit_daily_usd.toString() : null,
+    dailyResetMode: keyData.daily_reset_mode ?? "fixed",
+    dailyResetTime: keyData.daily_reset_time ?? "00:00",
     limitWeeklyUsd: keyData.limit_weekly_usd != null ? keyData.limit_weekly_usd.toString() : null,
     limitMonthlyUsd:
       keyData.limit_monthly_usd != null ? keyData.limit_monthly_usd.toString() : null,
@@ -82,6 +91,9 @@ export async function createKey(keyData: CreateKeyData): Promise<Key> {
     expiresAt: keys.expiresAt,
     canLoginWebUi: keys.canLoginWebUi,
     limit5hUsd: keys.limit5hUsd,
+    limitDailyUsd: keys.limitDailyUsd,
+    dailyResetMode: keys.dailyResetMode,
+    dailyResetTime: keys.dailyResetTime,
     limitWeeklyUsd: keys.limitWeeklyUsd,
     limitMonthlyUsd: keys.limitMonthlyUsd,
     limitConcurrentSessions: keys.limitConcurrentSessions,
@@ -108,6 +120,11 @@ export async function updateKey(id: number, keyData: UpdateKeyData): Promise<Key
   if (keyData.can_login_web_ui !== undefined) dbData.canLoginWebUi = keyData.can_login_web_ui;
   if (keyData.limit_5h_usd !== undefined)
     dbData.limit5hUsd = keyData.limit_5h_usd != null ? keyData.limit_5h_usd.toString() : null;
+  if (keyData.limit_daily_usd !== undefined)
+    dbData.limitDailyUsd =
+      keyData.limit_daily_usd != null ? keyData.limit_daily_usd.toString() : null;
+  if (keyData.daily_reset_mode !== undefined) dbData.dailyResetMode = keyData.daily_reset_mode;
+  if (keyData.daily_reset_time !== undefined) dbData.dailyResetTime = keyData.daily_reset_time;
   if (keyData.limit_weekly_usd !== undefined)
     dbData.limitWeeklyUsd =
       keyData.limit_weekly_usd != null ? keyData.limit_weekly_usd.toString() : null;
@@ -130,6 +147,9 @@ export async function updateKey(id: number, keyData: UpdateKeyData): Promise<Key
       expiresAt: keys.expiresAt,
       canLoginWebUi: keys.canLoginWebUi,
       limit5hUsd: keys.limit5hUsd,
+      limitDailyUsd: keys.limitDailyUsd,
+      dailyResetMode: keys.dailyResetMode,
+      dailyResetTime: keys.dailyResetTime,
       limitWeeklyUsd: keys.limitWeeklyUsd,
       limitMonthlyUsd: keys.limitMonthlyUsd,
       limitConcurrentSessions: keys.limitConcurrentSessions,
@@ -156,6 +176,9 @@ export async function findActiveKeyByUserIdAndName(
       expiresAt: keys.expiresAt,
       canLoginWebUi: keys.canLoginWebUi,
       limit5hUsd: keys.limit5hUsd,
+      limitDailyUsd: keys.limitDailyUsd,
+      dailyResetMode: keys.dailyResetMode,
+      dailyResetTime: keys.dailyResetTime,
       limitWeeklyUsd: keys.limitWeeklyUsd,
       limitMonthlyUsd: keys.limitMonthlyUsd,
       limitConcurrentSessions: keys.limitConcurrentSessions,
@@ -243,6 +266,9 @@ export async function findActiveKeyByKeyString(keyString: string): Promise<Key |
       expiresAt: keys.expiresAt,
       canLoginWebUi: keys.canLoginWebUi,
       limit5hUsd: keys.limit5hUsd,
+      limitDailyUsd: keys.limitDailyUsd,
+      dailyResetMode: keys.dailyResetMode,
+      dailyResetTime: keys.dailyResetTime,
       limitWeeklyUsd: keys.limitWeeklyUsd,
       limitMonthlyUsd: keys.limitMonthlyUsd,
       limitConcurrentSessions: keys.limitConcurrentSessions,
@@ -279,6 +305,9 @@ export async function validateApiKeyAndGetUser(
       keyExpiresAt: keys.expiresAt,
       keyCanLoginWebUi: keys.canLoginWebUi,
       keyLimit5hUsd: keys.limit5hUsd,
+      keyLimitDailyUsd: keys.limitDailyUsd,
+      keyDailyResetMode: keys.dailyResetMode,
+      keyDailyResetTime: keys.dailyResetTime,
       keyLimitWeeklyUsd: keys.limitWeeklyUsd,
       keyLimitMonthlyUsd: keys.limitMonthlyUsd,
       keyLimitConcurrentSessions: keys.limitConcurrentSessions,
@@ -337,6 +366,9 @@ export async function validateApiKeyAndGetUser(
     expiresAt: row.keyExpiresAt,
     canLoginWebUi: row.keyCanLoginWebUi,
     limit5hUsd: row.keyLimit5hUsd,
+    limitDailyUsd: row.keyLimitDailyUsd,
+    dailyResetMode: row.keyDailyResetMode,
+    dailyResetTime: row.keyDailyResetTime,
     limitWeeklyUsd: row.keyLimitWeeklyUsd,
     limitMonthlyUsd: row.keyLimitMonthlyUsd,
     limitConcurrentSessions: row.keyLimitConcurrentSessions,

+ 23 - 0
src/repository/provider.ts

@@ -25,6 +25,10 @@ export async function createProvider(providerData: CreateProviderData): Promise<
     joinClaudePool: providerData.join_claude_pool ?? false,
     codexInstructionsStrategy: providerData.codex_instructions_strategy ?? "auto",
     limit5hUsd: providerData.limit_5h_usd != null ? providerData.limit_5h_usd.toString() : null,
+    limitDailyUsd:
+      providerData.limit_daily_usd != null ? providerData.limit_daily_usd.toString() : null,
+    dailyResetMode: providerData.daily_reset_mode ?? "fixed",
+    dailyResetTime: providerData.daily_reset_time ?? "00:00",
     limitWeeklyUsd:
       providerData.limit_weekly_usd != null ? providerData.limit_weekly_usd.toString() : null,
     limitMonthlyUsd:
@@ -63,6 +67,9 @@ export async function createProvider(providerData: CreateProviderData): Promise<
     joinClaudePool: providers.joinClaudePool,
     codexInstructionsStrategy: providers.codexInstructionsStrategy,
     limit5hUsd: providers.limit5hUsd,
+    limitDailyUsd: providers.limitDailyUsd,
+    dailyResetMode: providers.dailyResetMode,
+    dailyResetTime: providers.dailyResetTime,
     limitWeeklyUsd: providers.limitWeeklyUsd,
     limitMonthlyUsd: providers.limitMonthlyUsd,
     limitConcurrentSessions: providers.limitConcurrentSessions,
@@ -109,6 +116,9 @@ export async function findProviderList(
       joinClaudePool: providers.joinClaudePool,
       codexInstructionsStrategy: providers.codexInstructionsStrategy,
       limit5hUsd: providers.limit5hUsd,
+      limitDailyUsd: providers.limitDailyUsd,
+      dailyResetMode: providers.dailyResetMode,
+      dailyResetTime: providers.dailyResetTime,
       limitWeeklyUsd: providers.limitWeeklyUsd,
       limitMonthlyUsd: providers.limitMonthlyUsd,
       limitConcurrentSessions: providers.limitConcurrentSessions,
@@ -162,6 +172,9 @@ export async function findProviderById(id: number): Promise<Provider | null> {
       joinClaudePool: providers.joinClaudePool,
       codexInstructionsStrategy: providers.codexInstructionsStrategy,
       limit5hUsd: providers.limit5hUsd,
+      limitDailyUsd: providers.limitDailyUsd,
+      dailyResetMode: providers.dailyResetMode,
+      dailyResetTime: providers.dailyResetTime,
       limitWeeklyUsd: providers.limitWeeklyUsd,
       limitMonthlyUsd: providers.limitMonthlyUsd,
       limitConcurrentSessions: providers.limitConcurrentSessions,
@@ -223,6 +236,13 @@ export async function updateProvider(
   if (providerData.limit_5h_usd !== undefined)
     dbData.limit5hUsd =
       providerData.limit_5h_usd != null ? providerData.limit_5h_usd.toString() : null;
+  if (providerData.limit_daily_usd !== undefined)
+    dbData.limitDailyUsd =
+      providerData.limit_daily_usd != null ? providerData.limit_daily_usd.toString() : null;
+  if (providerData.daily_reset_mode !== undefined)
+    dbData.dailyResetMode = providerData.daily_reset_mode;
+  if (providerData.daily_reset_time !== undefined)
+    dbData.dailyResetTime = providerData.daily_reset_time;
   if (providerData.limit_weekly_usd !== undefined)
     dbData.limitWeeklyUsd =
       providerData.limit_weekly_usd != null ? providerData.limit_weekly_usd.toString() : null;
@@ -274,6 +294,9 @@ export async function updateProvider(
       joinClaudePool: providers.joinClaudePool,
       codexInstructionsStrategy: providers.codexInstructionsStrategy,
       limit5hUsd: providers.limit5hUsd,
+      limitDailyUsd: providers.limitDailyUsd,
+      dailyResetMode: providers.dailyResetMode,
+      dailyResetTime: providers.dailyResetTime,
       limitWeeklyUsd: providers.limitWeeklyUsd,
       limitMonthlyUsd: providers.limitMonthlyUsd,
       limitConcurrentSessions: providers.limitConcurrentSessions,

+ 9 - 0
src/types/key.ts

@@ -14,6 +14,9 @@ export interface Key {
 
   // 金额限流配置
   limit5hUsd: number | null;
+  limitDailyUsd: number | null;
+  dailyResetMode: "fixed" | "rolling";
+  dailyResetTime: string; // HH:mm 格式
   limitWeeklyUsd: number | null;
   limitMonthlyUsd: number | null;
   limitConcurrentSessions: number;
@@ -36,6 +39,9 @@ export interface CreateKeyData {
   can_login_web_ui?: boolean;
   // 金额限流配置
   limit_5h_usd?: number | null;
+  limit_daily_usd?: number | null;
+  daily_reset_mode?: "fixed" | "rolling";
+  daily_reset_time?: string;
   limit_weekly_usd?: number | null;
   limit_monthly_usd?: number | null;
   limit_concurrent_sessions?: number;
@@ -52,6 +58,9 @@ export interface UpdateKeyData {
   can_login_web_ui?: boolean;
   // 金额限流配置
   limit_5h_usd?: number | null;
+  limit_daily_usd?: number | null;
+  daily_reset_mode?: "fixed" | "rolling";
+  daily_reset_time?: string;
   limit_weekly_usd?: number | null;
   limit_monthly_usd?: number | null;
   limit_concurrent_sessions?: number;

+ 12 - 0
src/types/provider.ts

@@ -44,6 +44,9 @@ export interface Provider {
 
   // 金额限流配置
   limit5hUsd: number | null;
+  limitDailyUsd: number | null;
+  dailyResetMode: "fixed" | "rolling";
+  dailyResetTime: string;
   limitWeeklyUsd: number | null;
   limitMonthlyUsd: number | null;
   limitConcurrentSessions: number;
@@ -104,6 +107,9 @@ export interface ProviderDisplay {
   codexInstructionsStrategy: CodexInstructionsStrategy;
   // 金额限流配置
   limit5hUsd: number | null;
+  limitDailyUsd: number | null;
+  dailyResetMode: "fixed" | "rolling";
+  dailyResetTime: string;
   limitWeeklyUsd: number | null;
   limitMonthlyUsd: number | null;
   limitConcurrentSessions: number;
@@ -158,6 +164,9 @@ export interface CreateProviderData {
 
   // 金额限流配置
   limit_5h_usd?: number | null;
+  limit_daily_usd?: number | null;
+  daily_reset_mode?: "fixed" | "rolling";
+  daily_reset_time?: string;
   limit_weekly_usd?: number | null;
   limit_monthly_usd?: number | null;
   limit_concurrent_sessions?: number;
@@ -214,6 +223,9 @@ export interface UpdateProviderData {
 
   // 金额限流配置
   limit_5h_usd?: number | null;
+  limit_daily_usd?: number | null;
+  daily_reset_mode?: "fixed" | "rolling";
+  daily_reset_time?: string;
   limit_weekly_usd?: number | null;
   limit_monthly_usd?: number | null;
   limit_concurrent_sessions?: number;

+ 2 - 0
src/types/user.ts

@@ -77,6 +77,8 @@ export interface UserKeyDisplay {
   canLoginWebUi: boolean; // 是否允许使用该 Key 登录 Web UI
   // 限额配置
   limit5hUsd: number | null; // 5小时消费上限(美元)
+  limitDailyUsd: number | null; // 每日消费上限
+  dailyResetTime: string; // 每日重置时间
   limitWeeklyUsd: number | null; // 周消费上限(美元)
   limitMonthlyUsd: number | null; // 月消费上限(美元)
   limitConcurrentSessions: number; // 并发 Session 上限

Some files were not shown because too many files changed in this diff