浏览代码

feat: 添加货币显示配置和日志自动清理功能

主要更新:

1. 货币显示配置
   - 新增多货币支持(USD/CNY/EUR/JPY 等)
   - 前端统一使用货币转换工具
   - Dashboard 和报表全面支持货币切换

2. 日志自动清理
   - 新增定时清理服务(基于 node-cron)
   - 支持配置保留天数、执行计划、批次大小
   - 提供手动清理 API 接口
   - 清理队列和进度追踪

3. 数据库 Schema
   - system_settings 表新增 5 个配置字段
   - 生成迁移文件 0010_unusual_bloodscream.sql

4. UI 优化
   - 系统设置页面新增货币和清理配置表单
   - 数据管理页面新增日志清理面板
   - 改进统计图表的数据转换逻辑
   - 优化多个组件的样式和交互

技术细节:
- 使用 node-cron 进行定时任务调度
- 批量删除优化性能(默认 10000 条/批次)
- 新增货币转换工具函数
- 完善类型定义和验证 schema

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
ding113 3 月之前
父节点
当前提交
d669bbfa30
共有 34 个文件被更改,包括 2929 次插入86 次删除
  1. 5 0
      drizzle/0010_unusual_bloodscream.sql
  2. 1132 0
      drizzle/meta/0010_snapshot.json
  3. 7 0
      drizzle/meta/_journal.json
  4. 5 0
      package.json
  5. 487 0
      pnpm-lock.yaml
  6. 21 10
      src/actions/model-prices.ts
  7. 2 0
      src/actions/system-config.ts
  8. 17 8
      src/app/api/admin/database/export/route.ts
  9. 4 1
      src/app/api/admin/database/import/route.ts
  10. 118 0
      src/app/api/admin/log-cleanup/manual/route.ts
  11. 16 8
      src/app/api/leaderboard/route.ts
  12. 12 6
      src/app/dashboard/_components/statistics/chart.tsx
  13. 10 2
      src/app/dashboard/_components/statistics/wrapper.tsx
  14. 1 2
      src/app/dashboard/leaderboard/_components/leaderboard-table.tsx
  15. 8 3
      src/app/dashboard/page.tsx
  16. 187 0
      src/app/settings/config/_components/auto-cleanup-form.tsx
  17. 46 2
      src/app/settings/config/_components/system-settings-form.tsx
  18. 10 1
      src/app/settings/config/page.tsx
  19. 219 0
      src/app/settings/data/_components/log-cleanup-panel.tsx
  20. 13 1
      src/app/settings/data/page.tsx
  21. 7 2
      src/app/settings/providers/_components/model-multi-select.tsx
  22. 22 5
      src/components/customs/overview-panel.tsx
  23. 10 0
      src/drizzle/schema.ts
  24. 4 0
      src/instrumentation.ts
  25. 35 23
      src/lib/database-backup/docker-executor.ts
  26. 151 0
      src/lib/log-cleanup/cleanup-queue.ts
  27. 245 0
      src/lib/log-cleanup/service.ts
  28. 3 4
      src/lib/price-sync/seed-initializer.ts
  29. 44 3
      src/lib/utils/currency.ts
  30. 2 0
      src/lib/utils/index.ts
  31. 7 0
      src/lib/validation/schemas.ts
  32. 5 0
      src/repository/_shared/transformers.ts
  33. 53 5
      src/repository/system-config.ts
  34. 21 0
      src/types/system-config.ts

+ 5 - 0
drizzle/0010_unusual_bloodscream.sql

@@ -0,0 +1,5 @@
+ALTER TABLE "system_settings" ADD COLUMN "currency_display" varchar(10) DEFAULT 'USD' NOT NULL;--> statement-breakpoint
+ALTER TABLE "system_settings" ADD COLUMN "enable_auto_cleanup" boolean DEFAULT false;--> statement-breakpoint
+ALTER TABLE "system_settings" ADD COLUMN "cleanup_retention_days" integer DEFAULT 30;--> statement-breakpoint
+ALTER TABLE "system_settings" ADD COLUMN "cleanup_schedule" varchar(50) DEFAULT '0 2 * * *';--> statement-breakpoint
+ALTER TABLE "system_settings" ADD COLUMN "cleanup_batch_size" integer DEFAULT 10000;

+ 1132 - 0
drizzle/meta/0010_snapshot.json

@@ -0,0 +1,1132 @@
+{
+  "id": "84458c64-5549-40c5-a785-9dfef840b2a2",
+  "prevId": "eb5fc628-9ee3-43dd-9b4f-7c9eaeee1b82",
+  "version": "7",
+  "dialect": "postgresql",
+  "tables": {
+    "public.keys": {
+      "name": "keys",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "user_id": {
+          "name": "user_id",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "key": {
+          "name": "key",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "name": {
+          "name": "name",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "is_enabled": {
+          "name": "is_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": false,
+          "default": true
+        },
+        "expires_at": {
+          "name": "expires_at",
+          "type": "timestamp",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "can_login_web_ui": {
+          "name": "can_login_web_ui",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": false,
+          "default": true
+        },
+        "limit_5h_usd": {
+          "name": "limit_5h_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_weekly_usd": {
+          "name": "limit_weekly_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_monthly_usd": {
+          "name": "limit_monthly_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_concurrent_sessions": {
+          "name": "limit_concurrent_sessions",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 0
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "deleted_at": {
+          "name": "deleted_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false
+        }
+      },
+      "indexes": {
+        "idx_keys_user_id": {
+          "name": "idx_keys_user_id",
+          "columns": [
+            {
+              "expression": "user_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_keys_created_at": {
+          "name": "idx_keys_created_at",
+          "columns": [
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_keys_deleted_at": {
+          "name": "idx_keys_deleted_at",
+          "columns": [
+            {
+              "expression": "deleted_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.message_request": {
+      "name": "message_request",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "provider_id": {
+          "name": "provider_id",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "user_id": {
+          "name": "user_id",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "key": {
+          "name": "key",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "model": {
+          "name": "model",
+          "type": "varchar(128)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "duration_ms": {
+          "name": "duration_ms",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "cost_usd": {
+          "name": "cost_usd",
+          "type": "numeric(21, 15)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'0'"
+        },
+        "cost_multiplier": {
+          "name": "cost_multiplier",
+          "type": "numeric(10, 4)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "varchar(64)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "provider_chain": {
+          "name": "provider_chain",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "status_code": {
+          "name": "status_code",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "api_type": {
+          "name": "api_type",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "original_model": {
+          "name": "original_model",
+          "type": "varchar(128)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "input_tokens": {
+          "name": "input_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "output_tokens": {
+          "name": "output_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "cache_creation_input_tokens": {
+          "name": "cache_creation_input_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "cache_read_input_tokens": {
+          "name": "cache_read_input_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "error_message": {
+          "name": "error_message",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "blocked_by": {
+          "name": "blocked_by",
+          "type": "varchar(50)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "blocked_reason": {
+          "name": "blocked_reason",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "user_agent": {
+          "name": "user_agent",
+          "type": "varchar(512)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "messages_count": {
+          "name": "messages_count",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "deleted_at": {
+          "name": "deleted_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false
+        }
+      },
+      "indexes": {
+        "idx_message_request_user_date_cost": {
+          "name": "idx_message_request_user_date_cost",
+          "columns": [
+            {
+              "expression": "user_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "cost_usd",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"message_request\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_user_query": {
+          "name": "idx_message_request_user_query",
+          "columns": [
+            {
+              "expression": "user_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"message_request\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_session_id": {
+          "name": "idx_message_request_session_id",
+          "columns": [
+            {
+              "expression": "session_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"message_request\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_provider_id": {
+          "name": "idx_message_request_provider_id",
+          "columns": [
+            {
+              "expression": "provider_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_user_id": {
+          "name": "idx_message_request_user_id",
+          "columns": [
+            {
+              "expression": "user_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_key": {
+          "name": "idx_message_request_key",
+          "columns": [
+            {
+              "expression": "key",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_created_at": {
+          "name": "idx_message_request_created_at",
+          "columns": [
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_deleted_at": {
+          "name": "idx_message_request_deleted_at",
+          "columns": [
+            {
+              "expression": "deleted_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.model_prices": {
+      "name": "model_prices",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "model_name": {
+          "name": "model_name",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "price_data": {
+          "name": "price_data",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        }
+      },
+      "indexes": {
+        "idx_model_prices_latest": {
+          "name": "idx_model_prices_latest",
+          "columns": [
+            {
+              "expression": "model_name",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": false,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_model_prices_model_name": {
+          "name": "idx_model_prices_model_name",
+          "columns": [
+            {
+              "expression": "model_name",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_model_prices_created_at": {
+          "name": "idx_model_prices_created_at",
+          "columns": [
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": false,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.providers": {
+      "name": "providers",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "name": {
+          "name": "name",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "url": {
+          "name": "url",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "key": {
+          "name": "key",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "is_enabled": {
+          "name": "is_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": true
+        },
+        "weight": {
+          "name": "weight",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "default": 1
+        },
+        "priority": {
+          "name": "priority",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "default": 0
+        },
+        "cost_multiplier": {
+          "name": "cost_multiplier",
+          "type": "numeric(10, 4)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'1.0'"
+        },
+        "group_tag": {
+          "name": "group_tag",
+          "type": "varchar(50)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "provider_type": {
+          "name": "provider_type",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'claude'"
+        },
+        "model_redirects": {
+          "name": "model_redirects",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "allowed_models": {
+          "name": "allowed_models",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'null'::jsonb"
+        },
+        "limit_5h_usd": {
+          "name": "limit_5h_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_weekly_usd": {
+          "name": "limit_weekly_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_monthly_usd": {
+          "name": "limit_monthly_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_concurrent_sessions": {
+          "name": "limit_concurrent_sessions",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 0
+        },
+        "tpm": {
+          "name": "tpm",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 0
+        },
+        "rpm": {
+          "name": "rpm",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 0
+        },
+        "rpd": {
+          "name": "rpd",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 0
+        },
+        "cc": {
+          "name": "cc",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 0
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "deleted_at": {
+          "name": "deleted_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false
+        }
+      },
+      "indexes": {
+        "idx_providers_enabled_priority": {
+          "name": "idx_providers_enabled_priority",
+          "columns": [
+            {
+              "expression": "is_enabled",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "priority",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "weight",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"providers\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_providers_group": {
+          "name": "idx_providers_group",
+          "columns": [
+            {
+              "expression": "group_tag",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"providers\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_providers_created_at": {
+          "name": "idx_providers_created_at",
+          "columns": [
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_providers_deleted_at": {
+          "name": "idx_providers_deleted_at",
+          "columns": [
+            {
+              "expression": "deleted_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.sensitive_words": {
+      "name": "sensitive_words",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "word": {
+          "name": "word",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "match_type": {
+          "name": "match_type",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'contains'"
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "is_enabled": {
+          "name": "is_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": true
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        }
+      },
+      "indexes": {
+        "idx_sensitive_words_enabled": {
+          "name": "idx_sensitive_words_enabled",
+          "columns": [
+            {
+              "expression": "is_enabled",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "match_type",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_sensitive_words_created_at": {
+          "name": "idx_sensitive_words_created_at",
+          "columns": [
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.system_settings": {
+      "name": "system_settings",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "site_title": {
+          "name": "site_title",
+          "type": "varchar(128)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'Claude Code Hub'"
+        },
+        "allow_global_usage_view": {
+          "name": "allow_global_usage_view",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "currency_display": {
+          "name": "currency_display",
+          "type": "varchar(10)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'USD'"
+        },
+        "enable_auto_cleanup": {
+          "name": "enable_auto_cleanup",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": false,
+          "default": false
+        },
+        "cleanup_retention_days": {
+          "name": "cleanup_retention_days",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 30
+        },
+        "cleanup_schedule": {
+          "name": "cleanup_schedule",
+          "type": "varchar(50)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'0 2 * * *'"
+        },
+        "cleanup_batch_size": {
+          "name": "cleanup_batch_size",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 10000
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.users": {
+      "name": "users",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "name": {
+          "name": "name",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "role": {
+          "name": "role",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'user'"
+        },
+        "rpm_limit": {
+          "name": "rpm_limit",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 60
+        },
+        "daily_limit_usd": {
+          "name": "daily_limit_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'100.00'"
+        },
+        "provider_group": {
+          "name": "provider_group",
+          "type": "varchar(50)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "deleted_at": {
+          "name": "deleted_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false
+        }
+      },
+      "indexes": {
+        "idx_users_active_role_sort": {
+          "name": "idx_users_active_role_sort",
+          "columns": [
+            {
+              "expression": "deleted_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "role",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"users\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_users_created_at": {
+          "name": "idx_users_created_at",
+          "columns": [
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_users_deleted_at": {
+          "name": "idx_users_deleted_at",
+          "columns": [
+            {
+              "expression": "deleted_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    }
+  },
+  "enums": {},
+  "schemas": {},
+  "sequences": {},
+  "roles": {},
+  "policies": {},
+  "views": {},
+  "_meta": {
+    "columns": {},
+    "schemas": {},
+    "tables": {}
+  }
+}

+ 7 - 0
drizzle/meta/_journal.json

@@ -71,6 +71,13 @@
       "when": 1761404190737,
       "tag": "0009_many_amazoness",
       "breakpoints": true
+    },
+    {
+      "idx": 10,
+      "version": "7",
+      "when": 1761455319449,
+      "tag": "0010_unusual_bloodscream",
+      "breakpoints": true
     }
   ]
 }

+ 5 - 0
package.json

@@ -17,6 +17,9 @@
     "db:studio": "drizzle-kit studio"
   },
   "dependencies": {
+    "@bull-board/api": "^6.14.0",
+    "@bull-board/express": "^6.14.0",
+    "@hookform/resolvers": "^5.2.2",
     "@radix-ui/react-alert-dialog": "^1.1.15",
     "@radix-ui/react-avatar": "^1.1.10",
     "@radix-ui/react-checkbox": "^1.3.3",
@@ -32,6 +35,7 @@
     "@radix-ui/react-tabs": "^1.1.13",
     "@radix-ui/react-tooltip": "^1.2.8",
     "@tanstack/react-query": "^5.90.5",
+    "bull": "^4.16.5",
     "class-variance-authority": "^0.7.1",
     "clsx": "^2.1.1",
     "cmdk": "^1.1.1",
@@ -51,6 +55,7 @@
     "postgres": "^3.4.7",
     "react": "19.2.0",
     "react-dom": "19.2.0",
+    "react-hook-form": "^7.65.0",
     "recharts": "2.15.4",
     "sonner": "^2.0.7",
     "tailwind-merge": "^3.3.1",

文件差异内容过多而无法显示
+ 487 - 0
pnpm-lock.yaml


+ 21 - 10
src/actions/model-prices.ts

@@ -27,18 +27,13 @@ function isPriceDataEqual(data1: ModelPriceData, data2: ModelPriceData): boolean
 }
 
 /**
- * 上传并更新模型价格表
+ * 价格表处理核心逻辑(内部函数,无权限检查)
+ * 用于系统初始化和 Web UI 上传
  */
-export async function uploadPriceTable(
+export async function processPriceTableInternal(
   jsonContent: string
 ): Promise<ActionResult<PriceUpdateResult>> {
   try {
-    // 权限检查:只有管理员可以上传价格表
-    const session = await getSession();
-    if (!session || session.user.role !== "admin") {
-      return { ok: false, error: "无权限执行此操作" };
-    }
-
     // 解析JSON内容
     let priceTable: PriceTableJson;
     try {
@@ -107,12 +102,28 @@ export async function uploadPriceTable(
 
     return { ok: true, data: result };
   } catch (error) {
-    logger.error("上传价格表失败:", error);
-    const message = error instanceof Error ? error.message : "上传失败,请稍后重试";
+    logger.error("处理价格表失败:", error);
+    const message = error instanceof Error ? error.message : "处理失败,请稍后重试";
     return { ok: false, error: message };
   }
 }
 
+/**
+ * 上传并更新模型价格表(Web UI 入口,包含权限检查)
+ */
+export async function uploadPriceTable(
+  jsonContent: string
+): Promise<ActionResult<PriceUpdateResult>> {
+  // 权限检查:只有管理员可以上传价格表
+  const session = await getSession();
+  if (!session || session.user.role !== "admin") {
+    return { ok: false, error: "无权限执行此操作" };
+  }
+
+  // 调用核心逻辑
+  return processPriceTableInternal(jsonContent);
+}
+
 /**
  * 获取所有模型的最新价格(包含 Claude 和 OpenAI 等所有模型)
  */

+ 2 - 0
src/actions/system-config.ts

@@ -26,6 +26,7 @@ export async function fetchSystemSettings(): Promise<ActionResult<SystemSettings
 export async function saveSystemSettings(formData: {
   siteTitle: string;
   allowGlobalUsageView: boolean;
+  currencyDisplay?: string;
 }): Promise<ActionResult<SystemSettings>> {
   try {
     const session = await getSession();
@@ -37,6 +38,7 @@ export async function saveSystemSettings(formData: {
     const updated = await updateSystemSettings({
       siteTitle: validated.siteTitle.trim(),
       allowGlobalUsageView: validated.allowGlobalUsageView,
+      currencyDisplay: validated.currencyDisplay,
     });
 
     revalidatePath("/settings/config");

+ 17 - 8
src/app/api/admin/database/export/route.ts

@@ -6,7 +6,10 @@ import { getSession } from "@/lib/auth";
 /**
  * 导出数据库备份
  *
- * GET /api/admin/database/export
+ * GET /api/admin/database/export?excludeLogs=true
+ *
+ * Query Parameters:
+ *   - excludeLogs: 'true' | 'false' (是否排除日志数据,默认 false)
  *
  * 响应: application/octet-stream (pg_dump custom format)
  */
@@ -46,20 +49,26 @@ export async function GET(request: Request) {
       return Response.json({ error: "数据库连接不可用,请检查数据库服务状态" }, { status: 503 });
     }
 
-    // 4. 执行 pg_dump
-    const stream = executePgDump();
+    // 4. 解析查询参数
+    const url = new URL(request.url);
+    const excludeLogs = url.searchParams.get("excludeLogs") === "true";
+
+    // 5. 执行 pg_dump
+    const stream = executePgDump(excludeLogs);
 
-    // 5. 生成文件名(带时间戳)
+    // 6. 生成文件名(带时间戳和标记
     const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, -5);
-    const filename = `backup_${timestamp}.dump`;
+    const suffix = excludeLogs ? "_no-logs" : "";
+    const filename = `backup_${timestamp}${suffix}.dump`;
 
     logger.info({
       action: "database_export_initiated",
       filename,
+      excludeLogs,
       user: session.user.name,
     });
 
-    // 6. 监听请求取消(用户关闭浏览器)
+    // 7. 监听请求取消(用户关闭浏览器)
     request.signal.addEventListener("abort", () => {
       if (lockId) {
         releaseBackupLock(lockId, "export").catch((err) => {
@@ -73,7 +82,7 @@ export async function GET(request: Request) {
       }
     });
 
-    // 7. 包装流以确保锁的释放
+    // 8. 包装流以确保锁的释放
     const cleanupStream = new TransformStream({
       transform(chunk, controller) {
         controller.enqueue(chunk);
@@ -93,7 +102,7 @@ export async function GET(request: Request) {
       },
     });
 
-    // 8. 返回流式响应
+    // 9. 返回流式响应
     return new Response(stream.pipeThrough(cleanupStream), {
       status: 200,
       headers: {

+ 4 - 1
src/app/api/admin/database/import/route.ts

@@ -21,6 +21,7 @@ const MAX_FILE_SIZE = 500 * 1024 * 1024;
  * Body: multipart/form-data
  *   - file: 备份文件 (.dump)
  *   - cleanFirst: 'true' | 'false' (是否清除现有数据)
+ *   - skipLogs: 'true' | 'false' (是否跳过日志数据导入)
  *
  * 响应: text/event-stream (SSE 格式的进度流)
  */
@@ -65,6 +66,7 @@ export async function POST(request: Request) {
     const formData = await request.formData();
     const file = formData.get("file") as File | null;
     const cleanFirst = formData.get("cleanFirst") === "true";
+    const skipLogs = formData.get("skipLogs") === "true";
 
     if (!file) {
       return Response.json({ error: "缺少备份文件" }, { status: 400 });
@@ -97,6 +99,7 @@ export async function POST(request: Request) {
       filename: file.name,
       fileSize: file.size,
       cleanFirst,
+      skipLogs,
       user: session.user.name,
     });
 
@@ -118,7 +121,7 @@ export async function POST(request: Request) {
     request.signal.addEventListener("abort", abortCleanup);
 
     // 9. 执行 pg_restore,返回 SSE 流
-    const stream = executePgRestore(tempFilePath, cleanFirst);
+    const stream = executePgRestore(tempFilePath, cleanFirst, skipLogs);
 
     // 10. 包装流以确保临时文件和锁的清理
     const cleanupStream = new TransformStream({

+ 118 - 0
src/app/api/admin/log-cleanup/manual/route.ts

@@ -0,0 +1,118 @@
+import { NextRequest } from 'next/server';
+import { getSession } from '@/lib/auth';
+import { cleanupLogs, CleanupConditions } from '@/lib/log-cleanup/service';
+import { logger } from '@/lib/logger';
+import { z } from 'zod';
+
+/**
+ * 清理请求参数校验 schema
+ */
+const cleanupRequestSchema = z.object({
+  beforeDate: z.string().optional(),
+  afterDate: z.string().optional(),
+  userIds: z.array(z.number()).optional(),
+  providerIds: z.array(z.number()).optional(),
+  statusCodes: z.array(z.number()).optional(),
+  statusCodeRange: z.object({
+    min: z.number(),
+    max: z.number(),
+  }).optional(),
+  onlyBlocked: z.boolean().optional(),
+  dryRun: z.boolean().optional(),
+});
+
+/**
+ * 手动清理日志 API
+ *
+ * POST /api/admin/log-cleanup/manual
+ *
+ * Body: {
+ *   beforeDate?: string;          // ISO 8601 日期字符串
+ *   afterDate?: string;           // ISO 8601 日期字符串
+ *   userIds?: number[];           // 用户 ID 列表
+ *   providerIds?: number[];       // 供应商 ID 列表
+ *   statusCodes?: number[];       // 状态码列表
+ *   statusCodeRange?: { min: number; max: number };  // 状态码范围
+ *   onlyBlocked?: boolean;        // 仅清理被拦截的请求
+ *   dryRun?: boolean;             // 仅预览,不实际删除
+ * }
+ *
+ * Response: {
+ *   success: boolean;
+ *   totalDeleted: number;
+ *   batchCount: number;
+ *   durationMs: number;
+ *   error?: string;
+ * }
+ */
+export async function POST(request: NextRequest) {
+  try {
+    // 1. 验证管理员权限
+    const session = await getSession();
+    if (!session || session.user.role !== 'admin') {
+      logger.warn({ action: 'log_cleanup_unauthorized' });
+      return Response.json({ error: 'Unauthorized' }, { status: 401 });
+    }
+
+    // 2. 解析请求参数
+    const body = await request.json();
+    const validated = cleanupRequestSchema.parse(body);
+
+    // 3. 构建清理条件
+    const conditions: CleanupConditions = {
+      beforeDate: validated.beforeDate ? new Date(validated.beforeDate) : undefined,
+      afterDate: validated.afterDate ? new Date(validated.afterDate) : undefined,
+      userIds: validated.userIds,
+      providerIds: validated.providerIds,
+      statusCodes: validated.statusCodes,
+      statusCodeRange: validated.statusCodeRange,
+      onlyBlocked: validated.onlyBlocked,
+    };
+
+    logger.info({
+      action: 'manual_log_cleanup_initiated',
+      user: session.user.name,
+      conditions,
+      dryRun: validated.dryRun,
+    });
+
+    // 4. 执行清理
+    const result = await cleanupLogs(
+      conditions,
+      { dryRun: validated.dryRun },
+      { type: 'manual', user: session.user.name }
+    );
+
+    return Response.json({
+      success: !result.error,
+      totalDeleted: result.totalDeleted,
+      batchCount: result.batchCount,
+      durationMs: result.durationMs,
+      error: result.error,
+    });
+
+  } catch (error) {
+    logger.error({
+      action: 'manual_log_cleanup_error',
+      error: error instanceof Error ? error.message : String(error),
+    });
+
+    if (error instanceof z.ZodError) {
+      return Response.json(
+        {
+          error: '请求参数格式错误',
+          details: error.issues,
+        },
+        { status: 400 }
+      );
+    }
+
+    return Response.json(
+      {
+        error: '清理日志失败',
+        details: error instanceof Error ? error.message : String(error),
+      },
+      { status: 500 }
+    );
+  }
+}

+ 16 - 8
src/app/api/leaderboard/route.ts

@@ -1,6 +1,8 @@
 import { NextRequest, NextResponse } from "next/server";
 import { logger } from "@/lib/logger";
 import { findDailyLeaderboard, findMonthlyLeaderboard } from "@/repository/leaderboard";
+import { getSystemSettings } from "@/repository/system-config";
+import { formatCurrency } from "@/lib/utils";
 import { unstable_cache } from "next/cache";
 
 /**
@@ -23,21 +25,27 @@ export async function GET(request: NextRequest) {
       );
     }
 
-    // 生成缓存 key(包含日期以确保每天/每月自动刷新)
+    // 获取系统配置(货币显示单位)
+    const systemSettings = await getSystemSettings();
+
+    // 生成缓存 key(包含日期和货币配置以确保每天/每月/货币变化时自动刷新)
     const now = new Date();
     const cacheKey =
       period === "daily"
-        ? `leaderboard:daily:${now.toISOString().split("T")[0]}`
-        : `leaderboard:monthly:${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
+        ? `leaderboard:daily:${now.toISOString().split("T")[0]}:${systemSettings.currencyDisplay}`
+        : `leaderboard:monthly:${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}:${systemSettings.currencyDisplay}`;
 
     // 使用 Next.js unstable_cache 进行缓存
     const getCachedLeaderboard = unstable_cache(
       async () => {
-        if (period === "daily") {
-          return await findDailyLeaderboard();
-        } else {
-          return await findMonthlyLeaderboard();
-        }
+        const rawData =
+          period === "daily" ? await findDailyLeaderboard() : await findMonthlyLeaderboard();
+
+        // 格式化金额字段
+        return rawData.map((entry) => ({
+          ...entry,
+          totalCostFormatted: formatCurrency(entry.totalCost, systemSettings.currencyDisplay),
+        }));
       },
       [cacheKey],
       {

+ 12 - 6
src/app/dashboard/_components/statistics/chart.tsx

@@ -5,6 +5,7 @@ import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
 
 import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
 import { cn, Decimal, formatCurrency, toDecimal } from "@/lib/utils";
+import type { CurrencyCode } from "@/lib/utils";
 import { ChartConfig, ChartContainer, ChartLegend, ChartTooltip } from "@/components/ui/chart";
 
 import type { UserStatisticsData, TimeRange } from "@/types/statistics";
@@ -44,13 +45,18 @@ const getUserColor = (index: number) => USER_COLOR_PALETTE[index % USER_COLOR_PA
 export interface UserStatisticsChartProps {
   data: UserStatisticsData;
   onTimeRangeChange?: (timeRange: TimeRange) => void;
+  currencyCode?: CurrencyCode;
 }
 
 /**
  * 用户统计图表组件
  * 展示用户的消费金额和API调用次数
  */
-export function UserStatisticsChart({ data, onTimeRangeChange }: UserStatisticsChartProps) {
+export function UserStatisticsChart({
+  data,
+  onTimeRangeChange,
+  currencyCode = 'USD',
+}: UserStatisticsChartProps) {
   const [activeChart, setActiveChart] = React.useState<"cost" | "calls">("cost");
 
   // 用户选择状态(仅 Admin 用 users 模式时启用)
@@ -309,7 +315,7 @@ export function UserStatisticsChart({ data, onTimeRangeChange }: UserStatisticsC
             >
               <span className="text-muted-foreground text-xs">总消费金额</span>
               <span className="text-lg leading-none font-bold sm:text-3xl">
-                {formatCurrency(visibleTotals.cost)}
+                {formatCurrency(visibleTotals.cost, currencyCode)}
               </span>
             </button>
             <button
@@ -335,7 +341,7 @@ export function UserStatisticsChart({ data, onTimeRangeChange }: UserStatisticsC
           >
             <span className="text-muted-foreground text-xs">总消费金额</span>
             <span className="text-lg leading-none font-bold sm:text-xl">
-              {formatCurrency(visibleTotals.cost)}
+              {formatCurrency(visibleTotals.cost, currencyCode)}
             </span>
           </button>
           <button
@@ -391,7 +397,7 @@ export function UserStatisticsChart({ data, onTimeRangeChange }: UserStatisticsC
               tickMargin={8}
               tickFormatter={(value) => {
                 if (activeChart === "cost") {
-                  return formatCurrency(value);
+                  return formatCurrency(value, currencyCode);
                 }
                 return Number(value).toLocaleString();
               }}
@@ -448,7 +454,7 @@ export function UserStatisticsChart({ data, onTimeRangeChange }: UserStatisticsC
                                 </div>
                                 <span className="ml-auto font-mono flex-shrink-0">
                                   {activeChart === "cost"
-                                    ? formatCurrency(value)
+                                    ? formatCurrency(value, currencyCode)
                                     : value.toLocaleString()}
                                 </span>
                               </div>
@@ -535,7 +541,7 @@ export function UserStatisticsChart({ data, onTimeRangeChange }: UserStatisticsC
                           {/* 下方:数据值 */}
                           <div className="text-xs font-bold text-foreground">
                             {activeChart === "cost"
-                              ? formatCurrency(userTotal.cost)
+                              ? formatCurrency(userTotal.cost, currencyCode)
                               : userTotal.calls.toLocaleString()}
                           </div>
                         </div>

+ 10 - 2
src/app/dashboard/_components/statistics/wrapper.tsx

@@ -5,11 +5,13 @@ import { useQuery } from "@tanstack/react-query";
 import { UserStatisticsChart } from "./chart";
 import { getUserStatistics } from "@/actions/statistics";
 import type { TimeRange, UserStatisticsData } from "@/types/statistics";
+import type { CurrencyCode } from "@/lib/utils";
 import { DEFAULT_TIME_RANGE } from "@/types/statistics";
 import { toast } from "sonner";
 
 interface StatisticsWrapperProps {
   initialData?: UserStatisticsData;
+  currencyCode?: CurrencyCode;
 }
 
 const STATISTICS_REFRESH_INTERVAL = 5000; // 5秒刷新一次
@@ -26,7 +28,7 @@ async function fetchStatistics(timeRange: TimeRange): Promise<UserStatisticsData
  * 统计组件包装器
  * 处理时间范围状态管理和数据获取
  */
-export function StatisticsWrapper({ initialData }: StatisticsWrapperProps) {
+export function StatisticsWrapper({ initialData, currencyCode = 'USD' }: StatisticsWrapperProps) {
   const [timeRange, setTimeRange] = React.useState<TimeRange>(
     initialData?.timeRange ?? DEFAULT_TIME_RANGE
   );
@@ -55,5 +57,11 @@ export function StatisticsWrapper({ initialData }: StatisticsWrapperProps) {
     return <div className="text-center py-8 text-muted-foreground">暂无统计数据</div>;
   }
 
-  return <UserStatisticsChart data={data} onTimeRangeChange={handleTimeRangeChange} />;
+  return (
+    <UserStatisticsChart
+      data={data}
+      onTimeRangeChange={handleTimeRangeChange}
+      currencyCode={currencyCode}
+    />
+  );
 }

+ 1 - 2
src/app/dashboard/leaderboard/_components/leaderboard-table.tsx

@@ -12,7 +12,6 @@ import { Card, CardContent } from "@/components/ui/card";
 import { Badge } from "@/components/ui/badge";
 import { Trophy, Medal, Award } from "lucide-react";
 import type { LeaderboardEntry } from "@/repository/leaderboard";
-import { formatCurrency } from "@/lib/utils/currency";
 
 interface LeaderboardTableProps {
   data: LeaderboardEntry[];
@@ -97,7 +96,7 @@ export function LeaderboardTable({ data, period }: LeaderboardTableProps) {
                       {entry.totalTokens.toLocaleString()}
                     </TableCell>
                     <TableCell className="text-right font-mono font-semibold">
-                      {formatCurrency(entry.totalCost)}
+                      {(entry as any).totalCostFormatted || entry.totalCost}
                     </TableCell>
                   </TableRow>
                 );

+ 8 - 3
src/app/dashboard/page.tsx

@@ -5,6 +5,7 @@ import { UserKeyManager } from "./_components/user/user-key-manager";
 import { getUsers } from "@/actions/users";
 import { getUserStatistics } from "@/actions/statistics";
 import { hasPriceTable } from "@/actions/model-prices";
+import { getSystemSettings } from "@/repository/system-config";
 import { ListErrorBoundary } from "@/components/error-boundary";
 import { StatisticsWrapper } from "./_components/statistics";
 import { OverviewPanel } from "@/components/customs/overview-panel";
@@ -19,18 +20,22 @@ export default async function DashboardPage() {
     redirect("/settings/prices?required=true");
   }
 
-  const [users, session, statistics] = await Promise.all([
+  const [users, session, statistics, systemSettings] = await Promise.all([
     getUsers(),
     getSession(),
     getUserStatistics(DEFAULT_TIME_RANGE),
+    getSystemSettings(),
   ]);
 
   return (
     <div className="space-y-6">
-      <OverviewPanel />
+      <OverviewPanel currencyCode={systemSettings.currencyDisplay} />
 
       <div>
-        <StatisticsWrapper initialData={statistics.ok ? statistics.data : undefined} />
+        <StatisticsWrapper
+          initialData={statistics.ok ? statistics.data : undefined}
+          currencyCode={systemSettings.currencyDisplay}
+        />
       </div>
 
       <Section title="客户端" description="用户和密钥管理">

+ 187 - 0
src/app/settings/config/_components/auto-cleanup-form.tsx

@@ -0,0 +1,187 @@
+"use client";
+
+import { useState } from "react";
+import { useForm } from "react-hook-form";
+import { z } from "zod";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { Loader2 } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Switch } from "@/components/ui/switch";
+import { toast } from "sonner";
+import type { SystemSettings } from "@/types/system-config";
+
+/**
+ * 自动清理配置表单 Schema
+ */
+const autoCleanupSchema = z.object({
+  enableAutoCleanup: z.boolean(),
+  cleanupRetentionDays: z.number().int().min(1).max(365),
+  cleanupSchedule: z.string().min(1),
+  cleanupBatchSize: z.number().int().min(1000).max(100000),
+});
+
+type AutoCleanupFormData = z.infer<typeof autoCleanupSchema>;
+
+interface AutoCleanupFormProps {
+  settings: SystemSettings;
+  onSuccess?: () => void;
+}
+
+export function AutoCleanupForm({ settings, onSuccess }: AutoCleanupFormProps) {
+  const [isSubmitting, setIsSubmitting] = useState(false);
+
+  const {
+    register,
+    handleSubmit,
+    watch,
+    setValue,
+    formState: { errors },
+  } = useForm<AutoCleanupFormData>({
+    resolver: zodResolver(autoCleanupSchema),
+    defaultValues: {
+      enableAutoCleanup: settings.enableAutoCleanup ?? false,
+      cleanupRetentionDays: settings.cleanupRetentionDays ?? 30,
+      cleanupSchedule: settings.cleanupSchedule ?? '0 2 * * *',
+      cleanupBatchSize: settings.cleanupBatchSize ?? 10000,
+    },
+  });
+
+  const enableAutoCleanup = watch('enableAutoCleanup');
+
+  const onSubmit = async (data: AutoCleanupFormData) => {
+    setIsSubmitting(true);
+
+    try {
+      const response = await fetch('/api/admin/system-config', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        credentials: 'include',
+        body: JSON.stringify({
+          siteTitle: settings.siteTitle,
+          allowGlobalUsageView: settings.allowGlobalUsageView,
+          ...data,
+        }),
+      });
+
+      if (!response.ok) {
+        const error = await response.json();
+        throw new Error(error.error || '保存失败');
+      }
+
+      toast.success('自动清理配置已保存');
+      onSuccess?.();
+    } catch (error) {
+      console.error('Save error:', error);
+      toast.error(error instanceof Error ? error.message : '保存配置失败');
+    } finally {
+      setIsSubmitting(false);
+    }
+  };
+
+  return (
+    <form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
+      {/* 启用开关 */}
+      <div className="flex items-center justify-between">
+        <div className="space-y-0.5">
+          <Label htmlFor="enableAutoCleanup">启用自动清理</Label>
+          <p className="text-sm text-muted-foreground">
+            定时自动清理历史日志数据
+          </p>
+        </div>
+        <Switch
+          id="enableAutoCleanup"
+          checked={enableAutoCleanup}
+          onCheckedChange={(checked) => setValue('enableAutoCleanup', checked)}
+        />
+      </div>
+
+      {/* 仅在启用时显示配置项 */}
+      {enableAutoCleanup && (
+        <>
+          {/* 保留天数 */}
+          <div className="space-y-2">
+            <Label htmlFor="cleanupRetentionDays">
+              保留天数 <span className="text-destructive">*</span>
+            </Label>
+            <Input
+              id="cleanupRetentionDays"
+              type="number"
+              min={1}
+              max={365}
+              {...register('cleanupRetentionDays', { valueAsNumber: true })}
+              placeholder="30"
+            />
+            {errors.cleanupRetentionDays && (
+              <p className="text-sm text-destructive">
+                {errors.cleanupRetentionDays.message}
+              </p>
+            )}
+            <p className="text-xs text-muted-foreground">
+              超过此天数的日志将被自动清理(范围:1-365 天)
+            </p>
+          </div>
+
+          {/* Cron 表达式 */}
+          <div className="space-y-2">
+            <Label htmlFor="cleanupSchedule">
+              执行时间 (Cron) <span className="text-destructive">*</span>
+            </Label>
+            <Input
+              id="cleanupSchedule"
+              type="text"
+              {...register('cleanupSchedule')}
+              placeholder="0 2 * * *"
+            />
+            {errors.cleanupSchedule && (
+              <p className="text-sm text-destructive">
+                {errors.cleanupSchedule.message}
+              </p>
+            )}
+            <p className="text-xs text-muted-foreground">
+              Cron 表达式,默认:0 2 * * *(每天凌晨 2 点)
+              <br />
+              示例:0 3 * * 0(每周日凌晨 3 点)
+            </p>
+          </div>
+
+          {/* 批量大小 */}
+          <div className="space-y-2">
+            <Label htmlFor="cleanupBatchSize">
+              批量大小 <span className="text-destructive">*</span>
+            </Label>
+            <Input
+              id="cleanupBatchSize"
+              type="number"
+              min={1000}
+              max={100000}
+              {...register('cleanupBatchSize', { valueAsNumber: true })}
+              placeholder="10000"
+            />
+            {errors.cleanupBatchSize && (
+              <p className="text-sm text-destructive">
+                {errors.cleanupBatchSize.message}
+              </p>
+            )}
+            <p className="text-xs text-muted-foreground">
+              每批删除的记录数(范围:1000-100000,推荐 10000)
+            </p>
+          </div>
+        </>
+      )}
+
+      {/* 提交按钮 */}
+      <Button type="submit" disabled={isSubmitting} className="w-full sm:w-auto">
+        {isSubmitting ? (
+          <>
+            <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+            保存中...
+          </>
+        ) : (
+          '保存配置'
+        )}
+      </Button>
+    </form>
+  );
+}

+ 46 - 2
src/app/settings/config/_components/system-settings-form.tsx

@@ -5,12 +5,21 @@ import { Input } from "@/components/ui/input";
 import { Label } from "@/components/ui/label";
 import { Switch } from "@/components/ui/switch";
 import { Button } from "@/components/ui/button";
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from "@/components/ui/select";
 import { saveSystemSettings } from "@/actions/system-config";
 import { toast } from "sonner";
+import { CURRENCY_CONFIG } from "@/lib/utils";
 import type { SystemSettings } from "@/types/system-config";
+import type { CurrencyCode } from "@/lib/utils";
 
 interface SystemSettingsFormProps {
-  initialSettings: Pick<SystemSettings, "siteTitle" | "allowGlobalUsageView">;
+  initialSettings: Pick<SystemSettings, "siteTitle" | "allowGlobalUsageView" | "currencyDisplay">;
 }
 
 export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps) {
@@ -18,6 +27,9 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps)
   const [allowGlobalUsageView, setAllowGlobalUsageView] = useState(
     initialSettings.allowGlobalUsageView
   );
+  const [currencyDisplay, setCurrencyDisplay] = useState<CurrencyCode>(
+    initialSettings.currencyDisplay
+  );
   const [isPending, startTransition] = useTransition();
 
   const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
@@ -32,6 +44,7 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps)
       const result = await saveSystemSettings({
         siteTitle,
         allowGlobalUsageView,
+        currencyDisplay,
       });
 
       if (!result.ok) {
@@ -42,9 +55,14 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps)
       if (result.data) {
         setSiteTitle(result.data.siteTitle);
         setAllowGlobalUsageView(result.data.allowGlobalUsageView);
+        setCurrencyDisplay(result.data.currencyDisplay);
       }
 
-      toast.success("系统设置已更新");
+      toast.success("系统设置已更新,页面将刷新以应用货币显示变更");
+      // 刷新页面以应用货币显示变更
+      setTimeout(() => {
+        window.location.reload();
+      }, 1000);
     });
   };
 
@@ -66,6 +84,32 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps)
         </p>
       </div>
 
+      <div className="space-y-2">
+        <Label htmlFor="currency-display">货币显示单位</Label>
+        <Select
+          value={currencyDisplay}
+          onValueChange={(value) => setCurrencyDisplay(value as CurrencyCode)}
+          disabled={isPending}
+        >
+          <SelectTrigger id="currency-display">
+            <SelectValue placeholder="选择货币单位" />
+          </SelectTrigger>
+          <SelectContent>
+            {(Object.keys(CURRENCY_CONFIG) as CurrencyCode[]).map((code) => {
+              const config = CURRENCY_CONFIG[code];
+              return (
+                <SelectItem key={code} value={code}>
+                  {config.symbol} {config.name} ({code})
+                </SelectItem>
+              );
+            })}
+          </SelectContent>
+        </Select>
+        <p className="text-xs text-muted-foreground">
+          修改后,系统所有页面和 API 接口的金额显示将使用对应的货币符号(仅修改符号,不进行汇率转换)。
+        </p>
+      </div>
+
       <div className="flex items-start justify-between gap-4 rounded-lg border border-dashed border-border px-4 py-3">
         <div>
           <Label htmlFor="allow-global-usage" className="text-sm font-medium">

+ 10 - 1
src/app/settings/config/page.tsx

@@ -2,6 +2,7 @@ import { Section } from "@/components/section";
 import { SettingsPageHeader } from "../_components/settings-page-header";
 import { getSystemSettings } from "@/repository/system-config";
 import { SystemSettingsForm } from "./_components/system-settings-form";
+import { AutoCleanupForm } from "./_components/auto-cleanup-form";
 
 export const dynamic = "force-dynamic";
 
@@ -15,14 +16,22 @@ export default async function SettingsConfigPage() {
         description="管理系统的基础参数,影响站点显示和统计行为。"
       />
 
-      <Section title="站点参数" description="配置站点标题与仪表盘统计展示策略。">
+      <Section title="站点参数" description="配置站点标题、货币显示单位与仪表盘统计展示策略。">
         <SystemSettingsForm
           initialSettings={{
             siteTitle: settings.siteTitle,
             allowGlobalUsageView: settings.allowGlobalUsageView,
+            currencyDisplay: settings.currencyDisplay,
           }}
         />
       </Section>
+
+      <Section
+        title="自动日志清理"
+        description="定时自动清理历史日志数据,释放数据库存储空间。"
+      >
+        <AutoCleanupForm settings={settings} />
+      </Section>
     </>
   );
 }

+ 219 - 0
src/app/settings/data/_components/log-cleanup-panel.tsx

@@ -0,0 +1,219 @@
+"use client";
+
+import { useState, useEffect, useCallback } from "react";
+import { Trash2, AlertTriangle, Loader2 } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Label } from "@/components/ui/label";
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from "@/components/ui/select";
+import {
+  AlertDialog,
+  AlertDialogAction,
+  AlertDialogCancel,
+  AlertDialogContent,
+  AlertDialogDescription,
+  AlertDialogFooter,
+  AlertDialogHeader,
+  AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { toast } from "sonner";
+
+export function LogCleanupPanel() {
+  const [isOpen, setIsOpen] = useState(false);
+  const [isLoading, setIsLoading] = useState(false);
+  const [isPreviewLoading, setIsPreviewLoading] = useState(false);
+  const [timeRange, setTimeRange] = useState<string>("30");
+  const [previewCount, setPreviewCount] = useState<number | null>(null);
+
+  const fetchPreview = useCallback(async () => {
+    setIsPreviewLoading(true);
+
+    try {
+      const beforeDate = new Date();
+      beforeDate.setDate(beforeDate.getDate() - parseInt(timeRange));
+
+      const response = await fetch('/api/admin/log-cleanup/manual', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        credentials: 'include',
+        body: JSON.stringify({
+          beforeDate: beforeDate.toISOString(),
+          dryRun: true,
+        }),
+      });
+
+      const result = await response.json();
+
+      if (response.ok && result.success) {
+        setPreviewCount(result.totalDeleted);
+      } else {
+        console.error('Preview error:', result.error);
+        setPreviewCount(null);
+      }
+    } catch (error) {
+      console.error('Preview error:', error);
+      setPreviewCount(null);
+    } finally {
+      setIsPreviewLoading(false);
+    }
+  }, [timeRange]);
+
+  // 当对话框打开时,自动预览
+  useEffect(() => {
+    if (isOpen) {
+      fetchPreview();
+    } else {
+      setPreviewCount(null);
+    }
+  }, [isOpen, fetchPreview]);
+
+  const handleCleanup = async () => {
+    setIsLoading(true);
+
+    try {
+      const beforeDate = new Date();
+      beforeDate.setDate(beforeDate.getDate() - parseInt(timeRange));
+
+      const response = await fetch('/api/admin/log-cleanup/manual', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        credentials: 'include',
+        body: JSON.stringify({
+          beforeDate: beforeDate.toISOString(),
+        }),
+      });
+
+      const result = await response.json();
+
+      if (!response.ok) {
+        throw new Error(result.error || '清理失败');
+      }
+
+      if (result.success) {
+        toast.success(`成功清理 ${result.totalDeleted.toLocaleString()} 条日志记录(${result.batchCount} 批次,耗时 ${(result.durationMs / 1000).toFixed(2)}s)`);
+        setIsOpen(false);
+      } else {
+        toast.error(result.error || '清理失败');
+      }
+    } catch (error) {
+      console.error('Cleanup error:', error);
+      toast.error(error instanceof Error ? error.message : '清理日志失败');
+    } finally {
+      setIsLoading(false);
+    }
+  };
+
+  const getTimeRangeDescription = () => {
+    const days = parseInt(timeRange);
+    if (days === 7) return '一周前';
+    if (days === 30) return '一个月前';
+    if (days === 90) return '三个月前';
+    if (days === 180) return '六个月前';
+    return `${days} 天前`;
+  };
+
+  return (
+    <div className="flex flex-col gap-4">
+      <p className="text-sm text-muted-foreground">
+        清理历史日志数据以释放数据库存储空间。
+        <strong>注意:统计数据将被保留,但日志详情将被永久删除。</strong>
+      </p>
+
+      <div className="flex flex-col gap-3">
+        <Label htmlFor="time-range">清理范围</Label>
+        <Select value={timeRange} onValueChange={setTimeRange}>
+          <SelectTrigger id="time-range" className="w-full sm:w-[300px]">
+            <SelectValue />
+          </SelectTrigger>
+          <SelectContent>
+            <SelectItem value="7">一周前的日志 (7 天)</SelectItem>
+            <SelectItem value="30">一个月前的日志 (30 天)</SelectItem>
+            <SelectItem value="90">三个月前的日志 (90 天)</SelectItem>
+            <SelectItem value="180">六个月前的日志 (180 天)</SelectItem>
+          </SelectContent>
+        </Select>
+        <p className="text-xs text-muted-foreground">
+          将清理 {getTimeRangeDescription()} 的所有日志记录
+        </p>
+      </div>
+
+      <Button
+        onClick={() => setIsOpen(true)}
+        variant="destructive"
+        className="w-full sm:w-auto"
+      >
+        <Trash2 className="mr-2 h-4 w-4" />
+        清理日志
+      </Button>
+
+      <AlertDialog open={isOpen} onOpenChange={setIsOpen}>
+        <AlertDialogContent>
+          <AlertDialogHeader>
+            <AlertDialogTitle className="flex items-center gap-2">
+              <AlertTriangle className="h-5 w-5 text-destructive" />
+              确认清理日志
+            </AlertDialogTitle>
+            <AlertDialogDescription className="space-y-3">
+              <p>
+                此操作将<strong className="text-destructive">永久删除</strong>{" "}
+                {getTimeRangeDescription()}的所有日志记录,
+                且<strong className="text-destructive">无法恢复</strong>。
+              </p>
+
+              {/* 预览信息 */}
+              <div className="bg-muted p-3 rounded-md">
+                {isPreviewLoading ? (
+                  <div className="flex items-center gap-2 text-sm">
+                    <Loader2 className="h-4 w-4 animate-spin" />
+                    <span>正在统计...</span>
+                  </div>
+                ) : previewCount !== null ? (
+                  <p className="text-sm font-medium">
+                    将删除 <span className="text-destructive text-lg">{previewCount.toLocaleString()}</span> 条日志记录
+                  </p>
+                ) : (
+                  <p className="text-sm text-muted-foreground">
+                    无法获取预览信息
+                  </p>
+                )}
+              </div>
+
+              <p className="text-sm">
+                ✓ 统计数据将被保留(用于趋势分析)<br />
+                ✗ 日志详情将被删除(请求/响应内容、错误信息等)
+              </p>
+              <p className="text-sm text-muted-foreground">
+                建议:在清理前先<strong>导出数据库备份</strong>,以防需要恢复数据。
+              </p>
+            </AlertDialogDescription>
+          </AlertDialogHeader>
+          <AlertDialogFooter>
+            <AlertDialogCancel disabled={isLoading}>取消</AlertDialogCancel>
+            <AlertDialogAction
+              onClick={(e) => {
+                e.preventDefault();
+                handleCleanup();
+              }}
+              disabled={isLoading || isPreviewLoading}
+              className="bg-destructive hover:bg-destructive/90"
+            >
+              {isLoading ? (
+                <>
+                  <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+                  正在清理...
+                </>
+              ) : (
+                '确认清理'
+              )}
+            </AlertDialogAction>
+          </AlertDialogFooter>
+        </AlertDialogContent>
+      </AlertDialog>
+    </div>
+  );
+}

+ 13 - 1
src/app/settings/data/page.tsx

@@ -7,6 +7,7 @@ import { SettingsPageHeader } from "../_components/settings-page-header";
 import { DatabaseStatusDisplay } from "./_components/database-status";
 import { DatabaseExport } from "./_components/database-export";
 import { DatabaseImport } from "./_components/database-import";
+import { LogCleanupPanel } from "./_components/log-cleanup-panel";
 import {
   Collapsible,
   CollapsibleContent,
@@ -21,7 +22,7 @@ export default function SettingsDataPage() {
     <>
       <SettingsPageHeader
         title="数据管理"
-        description="管理数据库的备份与恢复,支持完整数据导入导出。"
+        description="管理数据库的备份与恢复,支持完整数据导入导出和日志清理。"
       />
 
       <Section
@@ -31,6 +32,13 @@ export default function SettingsDataPage() {
         <DatabaseStatusDisplay />
       </Section>
 
+      <Section
+        title="日志清理"
+        description="清理历史日志数据以释放数据库存储空间,统计数据将被保留。"
+      >
+        <LogCleanupPanel />
+      </Section>
+
       <Section
         title="数据导出"
         description="将数据库导出为备份文件,用于数据迁移或灾难恢复。"
@@ -66,6 +74,10 @@ export default function SettingsDataPage() {
           <CollapsibleContent className="pt-4">
             <div className="prose prose-sm dark:prose-invert max-w-none">
               <ul className="text-sm text-muted-foreground space-y-2">
+                <li>
+                  <strong>日志清理</strong>: 物理删除历史日志数据,不可恢复。
+                  统计数据(statistics 表)将被保留。建议清理前先导出数据库备份。
+                </li>
                 <li>
                   <strong>备份格式</strong>: 使用 PostgreSQL custom format (.dump),
                   自动压缩且能够兼容不同版本的数据库结构。

+ 7 - 2
src/app/settings/providers/_components/model-multi-select.tsx

@@ -83,10 +83,15 @@ export function ModelMultiSelect({
           )}
         </Button>
       </PopoverTrigger>
-      <PopoverContent className="w-[400px] h-[400px] p-0" align="start">
+      <PopoverContent
+        className="w-[400px] h-[400px] p-0"
+        align="start"
+        onWheel={(e) => e.stopPropagation()}
+        onTouchMove={(e) => e.stopPropagation()}
+      >
         <Command shouldFilter={true} className="flex flex-col h-full">
           <CommandInput placeholder="搜索模型名称..." />
-          <CommandList className="flex-1 !max-h-none overflow-y-auto">
+          <CommandList className="flex-1 max-h-[300px] overflow-y-auto">
             <CommandEmpty>{loading ? "加载中..." : "未找到模型"}</CommandEmpty>
 
             {!loading && (

+ 22 - 5
src/components/customs/overview-panel.tsx

@@ -21,6 +21,7 @@ import { formatCurrency } from "@/lib/utils/currency";
 import { cn } from "@/lib/utils";
 import type { OverviewData } from "@/actions/overview";
 import type { ActiveSessionInfo } from "@/types/session";
+import type { CurrencyCode } from "@/lib/utils";
 import Link from "next/link";
 
 const REFRESH_INTERVAL = 5000; // 5秒刷新一次
@@ -66,7 +67,13 @@ function getStatusIcon(status: "in_progress" | "completed" | "error", statusCode
 /**
  * 简洁的 Session 列表项
  */
-function SessionListItem({ session }: { session: ActiveSessionInfo }) {
+function SessionListItem({
+  session,
+  currencyCode = 'USD',
+}: {
+  session: ActiveSessionInfo;
+  currencyCode?: CurrencyCode;
+}) {
   const statusInfo = getStatusIcon(session.status, session.statusCode);
   const StatusIcon = statusInfo.icon;
 
@@ -130,7 +137,9 @@ function SessionListItem({ session }: { session: ActiveSessionInfo }) {
             </span>
           )}
           {session.costUsd && (
-            <span className="font-medium">${parseFloat(session.costUsd).toFixed(4)}</span>
+            <span className="font-medium">
+              {formatCurrency(session.costUsd, currencyCode, 4)}
+            </span>
           )}
         </div>
       </div>
@@ -138,12 +147,16 @@ function SessionListItem({ session }: { session: ActiveSessionInfo }) {
   );
 }
 
+interface OverviewPanelProps {
+  currencyCode?: CurrencyCode;
+}
+
 /**
  * 概览面板
  * 左侧:4个指标卡片
  * 右侧:简洁的活跃 Session 列表
  */
-export function OverviewPanel() {
+export function OverviewPanel({ currencyCode = 'USD' }: OverviewPanelProps) {
   const router = useRouter();
 
   const { data, isLoading } = useQuery<OverviewData, Error>({
@@ -185,7 +198,7 @@ export function OverviewPanel() {
           />
           <MetricCard
             title="今日消耗"
-            value={formatCurrency(metrics.todayCost)}
+            value={formatCurrency(metrics.todayCost, currencyCode)}
             description="总费用"
             icon={DollarSign}
           />
@@ -239,7 +252,11 @@ export function OverviewPanel() {
             ) : (
               <div className="divide-y">
                 {metrics.recentSessions.map((session) => (
-                  <SessionListItem key={session.sessionId} session={session} />
+                  <SessionListItem
+                    key={session.sessionId}
+                    session={session}
+                    currencyCode={currencyCode}
+                  />
                 ))}
               </div>
             )}

+ 10 - 0
src/drizzle/schema.ts

@@ -209,6 +209,16 @@ export const systemSettings = pgTable('system_settings', {
   id: serial('id').primaryKey(),
   siteTitle: varchar('site_title', { length: 128 }).notNull().default('Claude Code Hub'),
   allowGlobalUsageView: boolean('allow_global_usage_view').notNull().default(false),
+
+  // 货币显示配置
+  currencyDisplay: varchar('currency_display', { length: 10 }).notNull().default('USD'),
+
+  // 日志清理配置
+  enableAutoCleanup: boolean('enable_auto_cleanup').default(false),
+  cleanupRetentionDays: integer('cleanup_retention_days').default(30),
+  cleanupSchedule: varchar('cleanup_schedule', { length: 50 }).default('0 2 * * *'),
+  cleanupBatchSize: integer('cleanup_batch_size').default(10000),
+
   createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
   updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
 });

+ 4 - 0
src/instrumentation.ts

@@ -29,6 +29,10 @@ export async function register() {
       const { ensurePriceTable } = await import("@/lib/price-sync/seed-initializer");
       await ensurePriceTable();
 
+      // 初始化日志清理任务队列(如果启用)
+      const { scheduleAutoCleanup } = await import("@/lib/log-cleanup/cleanup-queue");
+      await scheduleAutoCleanup();
+
       logger.info("Application ready");
     }
   }

+ 35 - 23
src/lib/database-backup/docker-executor.ts

@@ -5,38 +5,43 @@ import { getDatabaseConfig } from "./db-config";
 /**
  * 执行 pg_dump 导出数据库
  *
+ * @param excludeLogs 是否排除日志数据(保留表结构但不导出 message_request 数据)
  * @returns ReadableStream 数据流
  */
-export function executePgDump(): ReadableStream<Uint8Array> {
+export function executePgDump(excludeLogs = false): ReadableStream<Uint8Array> {
   const dbConfig = getDatabaseConfig();
 
-  const pgProcess = spawn(
-    "pg_dump",
-    [
-      "-h",
-      dbConfig.host,
-      "-p",
-      dbConfig.port.toString(),
-      "-U",
-      dbConfig.user,
-      "-d",
-      dbConfig.database,
-      "-Fc", // Custom format (compressed)
-      "-v", // Verbose
-    ],
-    {
-      env: {
-        ...process.env,
-        PGPASSWORD: dbConfig.password,
-      },
-    }
-  );
+  const args = [
+    "-h",
+    dbConfig.host,
+    "-p",
+    dbConfig.port.toString(),
+    "-U",
+    dbConfig.user,
+    "-d",
+    dbConfig.database,
+    "-Fc", // Custom format (compressed)
+    "-v", // Verbose
+  ];
+
+  // 排除日志数据(保留表结构但不导出数据)
+  if (excludeLogs) {
+    args.push("--exclude-table-data=message_request");
+  }
+
+  const pgProcess = spawn("pg_dump", args, {
+    env: {
+      ...process.env,
+      PGPASSWORD: dbConfig.password,
+    },
+  });
 
   logger.info({
     action: "pg_dump_start",
     host: dbConfig.host,
     port: dbConfig.port,
     database: dbConfig.database,
+    excludeLogs,
   });
 
   return new ReadableStream({
@@ -167,7 +172,8 @@ function analyzeRestoreErrors(errors: string[]): {
 
 export function executePgRestore(
   filePath: string,
-  cleanFirst: boolean
+  cleanFirst: boolean,
+  skipLogs = false
 ): ReadableStream<Uint8Array> {
   const dbConfig = getDatabaseConfig();
 
@@ -188,6 +194,11 @@ export function executePgRestore(
     args.push("--clean", "--if-exists", "--no-owner");
   }
 
+  // 跳过日志数据导入(保留表结构但不导入 message_request 数据)
+  if (skipLogs) {
+    args.push("--exclude-table-data=message_request");
+  }
+
   // 直接指定文件路径(比 stdin 更高效,避免额外的流处理)
   args.push(filePath);
 
@@ -204,6 +215,7 @@ export function executePgRestore(
     port: dbConfig.port,
     database: dbConfig.database,
     cleanFirst,
+    skipLogs,
     filePath,
   });
 

+ 151 - 0
src/lib/log-cleanup/cleanup-queue.ts

@@ -0,0 +1,151 @@
+import Queue from 'bull';
+import { createBullBoard } from '@bull-board/api';
+import { BullAdapter } from '@bull-board/api/bullAdapter';
+import { ExpressAdapter } from '@bull-board/express';
+import { logger } from '@/lib/logger';
+import { cleanupLogs } from './service';
+import { getSystemSettings } from '@/repository/system-config';
+
+/**
+ * 日志清理任务队列
+ */
+export const cleanupQueue = new Queue('log-cleanup', {
+  redis: {
+    host: process.env.REDIS_HOST || 'localhost',
+    port: parseInt(process.env.REDIS_PORT || '6379'),
+  },
+  defaultJobOptions: {
+    attempts: 3,                    // 失败重试 3 次
+    backoff: {
+      type: 'exponential',
+      delay: 60000,                 // 首次重试延迟 1 分钟
+    },
+    removeOnComplete: 100,          // 保留最近 100 个完成任务
+    removeOnFail: 50,               // 保留最近 50 个失败任务
+  },
+});
+
+/**
+ * 处理清理任务
+ */
+cleanupQueue.process(async (job) => {
+  logger.info({
+    action: 'cleanup_job_start',
+    jobId: job.id,
+    conditions: job.data.conditions,
+  });
+
+  const result = await cleanupLogs(
+    job.data.conditions,
+    { batchSize: job.data.batchSize },
+    { type: 'scheduled' }
+  );
+
+  if (result.error) {
+    throw new Error(result.error);
+  }
+
+  logger.info({
+    action: 'cleanup_job_complete',
+    jobId: job.id,
+    totalDeleted: result.totalDeleted,
+    durationMs: result.durationMs,
+  });
+
+  return result;
+});
+
+/**
+ * 错误处理
+ */
+cleanupQueue.on('failed', (job, err) => {
+  logger.error({
+    action: 'cleanup_job_failed',
+    jobId: job.id,
+    error: err.message,
+    attempts: job.attemptsMade,
+  });
+});
+
+/**
+ * 添加或更新定时清理任务
+ */
+export async function scheduleAutoCleanup() {
+  try {
+    const settings = await getSystemSettings();
+
+    if (!settings.enableAutoCleanup) {
+      logger.info({ action: 'auto_cleanup_disabled' });
+
+      // 移除所有已存在的定时任务
+      const repeatableJobs = await cleanupQueue.getRepeatableJobs();
+      for (const job of repeatableJobs) {
+        await cleanupQueue.removeRepeatableByKey(job.key);
+      }
+
+      return;
+    }
+
+    // 移除旧的定时任务
+    const repeatableJobs = await cleanupQueue.getRepeatableJobs();
+    for (const job of repeatableJobs) {
+      await cleanupQueue.removeRepeatableByKey(job.key);
+    }
+
+    // 构建清理条件(使用默认值)
+    const retentionDays = settings.cleanupRetentionDays ?? 30;
+    const beforeDate = new Date();
+    beforeDate.setDate(beforeDate.getDate() - retentionDays);
+
+    // 添加新的定时任务
+    await cleanupQueue.add(
+      'auto-cleanup',
+      {
+        conditions: { beforeDate },
+        batchSize: settings.cleanupBatchSize ?? 10000,
+      },
+      {
+        repeat: {
+          cron: settings.cleanupSchedule ?? '0 2 * * *', // 默认每天凌晨 2 点
+        },
+      }
+    );
+
+    logger.info({
+      action: 'auto_cleanup_scheduled',
+      schedule: settings.cleanupSchedule ?? '0 2 * * *',
+      retentionDays,
+      batchSize: settings.cleanupBatchSize ?? 10000,
+    });
+  } catch (error) {
+    logger.error({
+      action: 'schedule_auto_cleanup_error',
+      error: error instanceof Error ? error.message : String(error),
+    });
+
+    // Fail Open: 调度失败不影响应用启动
+  }
+}
+
+/**
+ * Bull Board 监控面板
+ */
+export function createCleanupMonitor() {
+  const serverAdapter = new ExpressAdapter();
+  serverAdapter.setBasePath('/admin/queues');
+
+  createBullBoard({
+    queues: [new BullAdapter(cleanupQueue)],
+    serverAdapter,
+  });
+
+  return serverAdapter.getRouter();
+}
+
+/**
+ * 停止清理队列(优雅关闭)
+ */
+export async function stopCleanupQueue() {
+  await cleanupQueue.close();
+  logger.info({ action: 'cleanup_queue_closed' });
+}

+ 245 - 0
src/lib/log-cleanup/service.ts

@@ -0,0 +1,245 @@
+import { logger } from '@/lib/logger';
+import { db } from '@/drizzle/db';
+import { messageRequest } from '@/drizzle/schema';
+import { and, lte, gte, inArray, isNotNull, between, sql, SQL } from 'drizzle-orm';
+
+/**
+ * 日志清理条件
+ */
+export interface CleanupConditions {
+  // 时间范围
+  beforeDate?: Date;
+  afterDate?: Date;
+
+  // 用户维度
+  userIds?: number[];
+
+  // 供应商维度
+  providerIds?: number[];
+
+  // 状态维度
+  statusCodes?: number[];      // 精确匹配状态码
+  statusCodeRange?: {          // 状态码范围 (如 400-499)
+    min: number;
+    max: number;
+  };
+  onlyBlocked?: boolean;       // 仅被拦截的请求
+}
+
+/**
+ * 清理选项
+ */
+export interface CleanupOptions {
+  batchSize?: number;          // 批量删除大小(默认 10000)
+  dryRun?: boolean;            // 仅预览,不实际删除
+}
+
+/**
+ * 清理结果
+ */
+export interface CleanupResult {
+  totalDeleted: number;
+  batchCount: number;
+  durationMs: number;
+  error?: string;
+}
+
+/**
+ * 触发信息
+ */
+export interface TriggerInfo {
+  type: 'manual' | 'scheduled';
+  user?: string;
+}
+
+/**
+ * 执行日志清理
+ *
+ * @param conditions 清理条件
+ * @param options 清理选项
+ * @param triggerInfo 触发信息
+ * @returns 清理结果
+ */
+export async function cleanupLogs(
+  conditions: CleanupConditions,
+  options: CleanupOptions = {},
+  triggerInfo: TriggerInfo
+): Promise<CleanupResult> {
+  const startTime = Date.now();
+  const batchSize = options.batchSize || 10000;
+  let totalDeleted = 0;
+  let batchCount = 0;
+
+  try {
+    // 1. 构建 WHERE 条件
+    const whereConditions = buildWhereConditions(conditions);
+
+    if (whereConditions.length === 0) {
+      logger.warn({
+        action: 'log_cleanup_no_conditions',
+        triggerType: triggerInfo.type,
+      });
+      return {
+        totalDeleted: 0,
+        batchCount: 0,
+        durationMs: Date.now() - startTime,
+        error: '未指定任何清理条件',
+      };
+    }
+
+    if (options.dryRun) {
+      // 仅统计数量
+      const result = await db
+        .select({ count: sql<number>`count(*)::int` })
+        .from(messageRequest)
+        .where(and(...whereConditions));
+
+      logger.info({
+        action: 'log_cleanup_dry_run',
+        estimatedCount: result[0]?.count || 0,
+        conditions,
+      });
+
+      return {
+        totalDeleted: result[0]?.count || 0,
+        batchCount: 0,
+        durationMs: Date.now() - startTime,
+      };
+    }
+
+    // 2. 分批删除
+    while (true) {
+      const deleted = await deleteBatch(whereConditions, batchSize);
+
+      if (deleted === 0) break;
+
+      totalDeleted += deleted;
+      batchCount++;
+
+      logger.info({
+        action: 'log_cleanup_batch',
+        batchNumber: batchCount,
+        deletedInBatch: deleted,
+        totalDeleted,
+      });
+
+      // 避免长时间锁表,短暂休息
+      if (deleted === batchSize) {
+        await sleep(100);
+      }
+    }
+
+    const durationMs = Date.now() - startTime;
+
+    logger.info({
+      action: 'log_cleanup_complete',
+      totalDeleted,
+      batchCount,
+      durationMs,
+      triggerType: triggerInfo.type,
+      user: triggerInfo.user,
+    });
+
+    return { totalDeleted, batchCount, durationMs };
+
+  } catch (error) {
+    const errorMessage = error instanceof Error ? error.message : String(error);
+
+    logger.error({
+      action: 'log_cleanup_error',
+      error: errorMessage,
+      conditions,
+      totalDeleted,
+      triggerType: triggerInfo.type,
+    });
+
+    return {
+      totalDeleted,
+      batchCount,
+      durationMs: Date.now() - startTime,
+      error: errorMessage,
+    };
+  }
+}
+
+/**
+ * 构建 WHERE 条件
+ */
+function buildWhereConditions(conditions: CleanupConditions): SQL[] {
+  const where: SQL[] = [];
+
+  // 排除软删除的记录(已经被软删除的不再处理)
+  where.push(sql`${messageRequest.deletedAt} IS NULL`);
+
+  // 时间范围
+  if (conditions.beforeDate) {
+    where.push(lte(messageRequest.createdAt, conditions.beforeDate));
+  }
+  if (conditions.afterDate) {
+    where.push(gte(messageRequest.createdAt, conditions.afterDate));
+  }
+
+  // 用户维度
+  if (conditions.userIds && conditions.userIds.length > 0) {
+    where.push(inArray(messageRequest.userId, conditions.userIds));
+  }
+
+  // 供应商维度
+  if (conditions.providerIds && conditions.providerIds.length > 0) {
+    where.push(inArray(messageRequest.providerId, conditions.providerIds));
+  }
+
+  // 状态维度
+  if (conditions.statusCodes && conditions.statusCodes.length > 0) {
+    where.push(inArray(messageRequest.statusCode, conditions.statusCodes));
+  }
+  if (conditions.statusCodeRange) {
+    where.push(
+      between(
+        messageRequest.statusCode,
+        conditions.statusCodeRange.min,
+        conditions.statusCodeRange.max
+      )
+    );
+  }
+  if (conditions.onlyBlocked) {
+    where.push(isNotNull(messageRequest.blockedBy));
+  }
+
+  return where;
+}
+
+/**
+ * 批量删除
+ *
+ * 使用 CTE (Common Table Expression) + DELETE 实现原子删除
+ * 避免两步操作的竞态条件,性能更好
+ */
+async function deleteBatch(
+  whereConditions: SQL[],
+  batchSize: number
+): Promise<number> {
+  // 使用 CTE 实现原子批量删除
+  const result = await db.execute(sql`
+    WITH ids_to_delete AS (
+      SELECT id FROM message_request
+      WHERE ${and(...whereConditions)}
+      ORDER BY created_at ASC
+      LIMIT ${batchSize}
+      FOR UPDATE SKIP LOCKED
+    )
+    DELETE FROM message_request
+    WHERE id IN (SELECT id FROM ids_to_delete)
+  `);
+
+  // Drizzle execute 返回的 result 包含 rowCount 属性
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  return (result as any).rowCount || 0;
+}
+
+/**
+ * 休眠函数
+ */
+function sleep(ms: number): Promise<void> {
+  return new Promise(resolve => setTimeout(resolve, ms));
+}

+ 3 - 4
src/lib/price-sync/seed-initializer.ts

@@ -54,11 +54,10 @@ export async function initializePriceTableFromSeed(): Promise<boolean> {
     }
 
     // 动态导入以避免循环依赖
-    const { uploadPriceTable } = await import("@/actions/model-prices");
+    // 直接调用内部函数,无需权限检查(系统启动时的自动初始化)
+    const { processPriceTableInternal } = await import("@/actions/model-prices");
 
-    // 使用现有的上传逻辑导入种子数据
-    // 注意:这里跳过权限检查,因为是系统启动时的自动初始化
-    const result = await uploadPriceTable(seedJson);
+    const result = await processPriceTableInternal(seedJson);
 
     if (!result.ok) {
       logger.error("❌ Failed to initialize price table from seed:", { error: result.error });

+ 44 - 3
src/lib/utils/currency.ts

@@ -11,6 +11,33 @@ Decimal.set({
 
 export const COST_SCALE = 15;
 
+/**
+ * 支持的货币代码
+ */
+export type CurrencyCode = 'USD' | 'CNY' | 'EUR' | 'JPY' | 'GBP' | 'HKD' | 'TWD' | 'KRW' | 'SGD';
+
+/**
+ * 货币配置
+ * - symbol: 货币符号
+ * - name: 货币名称
+ * - locale: 地区代码(用于数字格式化)
+ */
+export const CURRENCY_CONFIG: Record<CurrencyCode, {
+  symbol: string;
+  name: string;
+  locale: string;
+}> = {
+  USD: { symbol: '$', name: '美元', locale: 'en-US' },
+  CNY: { symbol: '¥', name: '人民币', locale: 'zh-CN' },
+  EUR: { symbol: '€', name: '欧元', locale: 'de-DE' },
+  JPY: { symbol: '¥', name: '日元', locale: 'ja-JP' },
+  GBP: { symbol: '£', name: '英镑', locale: 'en-GB' },
+  HKD: { symbol: 'HK$', name: '港币', locale: 'zh-HK' },
+  TWD: { symbol: 'NT$', name: '新台币', locale: 'zh-TW' },
+  KRW: { symbol: '₩', name: '韩元', locale: 'ko-KR' },
+  SGD: { symbol: 'S$', name: '新加坡元', locale: 'en-SG' },
+} as const;
+
 export type DecimalInput = Numeric | null | undefined;
 
 export function toDecimal(value: DecimalInput): Decimal | null {
@@ -56,13 +83,27 @@ export function sumCosts(values: DecimalInput[]): Decimal {
   }, new Decimal(0));
 }
 
-export function formatCurrency(value: DecimalInput, fractionDigits = 2): string {
+/**
+ * 格式化货币显示
+ * @param value - 金额数值
+ * @param currencyCode - 货币代码(默认 USD)
+ * @param fractionDigits - 小数位数(默认 2)
+ * @returns 格式化后的货币字符串(如 "$100.00" 或 "¥100.00")
+ */
+export function formatCurrency(
+  value: DecimalInput,
+  currencyCode: CurrencyCode = 'USD',
+  fractionDigits = 2
+): string {
   const decimal = toDecimal(value) ?? new Decimal(0);
-  const formatted = decimal.toDecimalPlaces(fractionDigits).toNumber().toLocaleString("en-US", {
+  const config = CURRENCY_CONFIG[currencyCode];
+
+  const formatted = decimal.toDecimalPlaces(fractionDigits).toNumber().toLocaleString(config.locale, {
     minimumFractionDigits: fractionDigits,
     maximumFractionDigits: fractionDigits,
   });
-  return `$${formatted}`;
+
+  return `${config.symbol}${formatted}`;
 }
 
 export { Decimal };

+ 2 - 0
src/lib/utils/index.ts

@@ -15,7 +15,9 @@ export {
   toDecimal,
   costToNumber,
   sumCosts,
+  CURRENCY_CONFIG,
 } from "./currency";
+export type { CurrencyCode } from "./currency";
 
 // 成本计算
 export { calculateRequestCost } from "./cost-calculation";

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

@@ -1,6 +1,7 @@
 import { z } from "zod";
 import { PROVIDER_LIMITS, PROVIDER_DEFAULTS } from "@/lib/constants/provider.constants";
 import { USER_LIMITS, USER_DEFAULTS } from "@/lib/constants/user.constants";
+import { CURRENCY_CONFIG } from "@/lib/utils/currency";
 
 /**
  * 用户创建数据验证schema
@@ -203,6 +204,12 @@ export const UpdateProviderSchema = z
 export const UpdateSystemSettingsSchema = z.object({
   siteTitle: z.string().min(1, "站点标题不能为空").max(128, "站点标题不能超过128个字符"),
   allowGlobalUsageView: z.boolean(),
+  currencyDisplay: z
+    .enum(
+      Object.keys(CURRENCY_CONFIG) as [keyof typeof CURRENCY_CONFIG, ...Array<keyof typeof CURRENCY_CONFIG>],
+      { message: "不支持的货币类型" }
+    )
+    .optional(),
 });
 
 // 导出类型推断

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

@@ -88,6 +88,11 @@ export function toSystemSettings(dbSettings: any): SystemSettings {
     id: dbSettings?.id ?? 0,
     siteTitle: dbSettings?.siteTitle ?? "Claude Code Hub",
     allowGlobalUsageView: dbSettings?.allowGlobalUsageView ?? true,
+    currencyDisplay: dbSettings?.currencyDisplay ?? 'USD',
+    enableAutoCleanup: dbSettings?.enableAutoCleanup ?? false,
+    cleanupRetentionDays: dbSettings?.cleanupRetentionDays ?? 30,
+    cleanupSchedule: dbSettings?.cleanupSchedule ?? '0 2 * * *',
+    cleanupBatchSize: dbSettings?.cleanupBatchSize ?? 10000,
     createdAt: dbSettings?.createdAt ? new Date(dbSettings.createdAt) : new Date(),
     updatedAt: dbSettings?.updatedAt ? new Date(dbSettings.updatedAt) : new Date(),
   };

+ 53 - 5
src/repository/system-config.ts

@@ -77,6 +77,11 @@ function createFallbackSettings(): SystemSettings {
     id: 0,
     siteTitle: DEFAULT_SITE_TITLE,
     allowGlobalUsageView: false,
+    currencyDisplay: 'USD',
+    enableAutoCleanup: false,
+    cleanupRetentionDays: 30,
+    cleanupSchedule: '0 2 * * *',
+    cleanupBatchSize: 10000,
     createdAt: now,
     updatedAt: now,
   };
@@ -92,6 +97,11 @@ export async function getSystemSettings(): Promise<SystemSettings> {
         id: systemSettings.id,
         siteTitle: systemSettings.siteTitle,
         allowGlobalUsageView: systemSettings.allowGlobalUsageView,
+        currencyDisplay: systemSettings.currencyDisplay,
+        enableAutoCleanup: systemSettings.enableAutoCleanup,
+        cleanupRetentionDays: systemSettings.cleanupRetentionDays,
+        cleanupSchedule: systemSettings.cleanupSchedule,
+        cleanupBatchSize: systemSettings.cleanupBatchSize,
         createdAt: systemSettings.createdAt,
         updatedAt: systemSettings.updatedAt,
       })
@@ -107,12 +117,18 @@ export async function getSystemSettings(): Promise<SystemSettings> {
       .values({
         siteTitle: DEFAULT_SITE_TITLE,
         allowGlobalUsageView: false,
+        currencyDisplay: 'USD',
       })
       .onConflictDoNothing()
       .returning({
         id: systemSettings.id,
         siteTitle: systemSettings.siteTitle,
         allowGlobalUsageView: systemSettings.allowGlobalUsageView,
+        currencyDisplay: systemSettings.currencyDisplay,
+        enableAutoCleanup: systemSettings.enableAutoCleanup,
+        cleanupRetentionDays: systemSettings.cleanupRetentionDays,
+        cleanupSchedule: systemSettings.cleanupSchedule,
+        cleanupBatchSize: systemSettings.cleanupBatchSize,
         createdAt: systemSettings.createdAt,
         updatedAt: systemSettings.updatedAt,
       });
@@ -127,6 +143,11 @@ export async function getSystemSettings(): Promise<SystemSettings> {
         id: systemSettings.id,
         siteTitle: systemSettings.siteTitle,
         allowGlobalUsageView: systemSettings.allowGlobalUsageView,
+        currencyDisplay: systemSettings.currencyDisplay,
+        enableAutoCleanup: systemSettings.enableAutoCleanup,
+        cleanupRetentionDays: systemSettings.cleanupRetentionDays,
+        cleanupSchedule: systemSettings.cleanupSchedule,
+        cleanupBatchSize: systemSettings.cleanupBatchSize,
         createdAt: systemSettings.createdAt,
         updatedAt: systemSettings.updatedAt,
       })
@@ -156,18 +177,45 @@ export async function updateSystemSettings(
   const current = await getSystemSettings();
 
   try {
+    // 构建更新对象,只更新提供的字段
+    const updates: Partial<typeof systemSettings.$inferInsert> = {
+      siteTitle: payload.siteTitle,
+      allowGlobalUsageView: payload.allowGlobalUsageView,
+      updatedAt: new Date(),
+    };
+
+    // 添加货币显示配置字段(如果提供)
+    if (payload.currencyDisplay !== undefined) {
+      updates.currencyDisplay = payload.currencyDisplay;
+    }
+
+    // 添加日志清理配置字段(如果提供)
+    if (payload.enableAutoCleanup !== undefined) {
+      updates.enableAutoCleanup = payload.enableAutoCleanup;
+    }
+    if (payload.cleanupRetentionDays !== undefined) {
+      updates.cleanupRetentionDays = payload.cleanupRetentionDays;
+    }
+    if (payload.cleanupSchedule !== undefined) {
+      updates.cleanupSchedule = payload.cleanupSchedule;
+    }
+    if (payload.cleanupBatchSize !== undefined) {
+      updates.cleanupBatchSize = payload.cleanupBatchSize;
+    }
+
     const [updated] = await db
       .update(systemSettings)
-      .set({
-        siteTitle: payload.siteTitle,
-        allowGlobalUsageView: payload.allowGlobalUsageView,
-        updatedAt: new Date(),
-      })
+      .set(updates)
       .where(eq(systemSettings.id, current.id))
       .returning({
         id: systemSettings.id,
         siteTitle: systemSettings.siteTitle,
         allowGlobalUsageView: systemSettings.allowGlobalUsageView,
+        currencyDisplay: systemSettings.currencyDisplay,
+        enableAutoCleanup: systemSettings.enableAutoCleanup,
+        cleanupRetentionDays: systemSettings.cleanupRetentionDays,
+        cleanupSchedule: systemSettings.cleanupSchedule,
+        cleanupBatchSize: systemSettings.cleanupBatchSize,
         createdAt: systemSettings.createdAt,
         updatedAt: systemSettings.updatedAt,
       });

+ 21 - 0
src/types/system-config.ts

@@ -1,7 +1,19 @@
+import type { CurrencyCode } from "@/lib/utils";
+
 export interface SystemSettings {
   id: number;
   siteTitle: string;
   allowGlobalUsageView: boolean;
+
+  // 货币显示配置
+  currencyDisplay: CurrencyCode;
+
+  // 日志清理配置
+  enableAutoCleanup?: boolean;
+  cleanupRetentionDays?: number;
+  cleanupSchedule?: string;
+  cleanupBatchSize?: number;
+
   createdAt: Date;
   updatedAt: Date;
 }
@@ -9,4 +21,13 @@ export interface SystemSettings {
 export interface UpdateSystemSettingsInput {
   siteTitle: string;
   allowGlobalUsageView: boolean;
+
+  // 货币显示配置(可选)
+  currencyDisplay?: CurrencyCode;
+
+  // 日志清理配置(可选)
+  enableAutoCleanup?: boolean;
+  cleanupRetentionDays?: number;
+  cleanupSchedule?: string;
+  cleanupBatchSize?: number;
 }

部分文件因为文件数量过多而无法显示