Просмотр исходного кода

feat: 增强 Webhook 通知系统

Ding 1 месяц назад
Родитель
Сommit
75c655e958
57 измененных файлов с 8193 добавлено и 684 удалено
  1. 62 0
      drizzle/0043_lonely_rick_jones.sql
  2. 2273 0
      drizzle/meta/0043_snapshot.json
  3. 7 0
      drizzle/meta/_journal.json
  4. 89 7
      messages/en/settings.json
  5. 88 6
      messages/ja/settings.json
  6. 88 6
      messages/ru/settings.json
  7. 89 7
      messages/zh-CN/settings.json
  8. 89 7
      messages/zh-TW/settings.json
  9. 1 0
      package.json
  10. 66 0
      src/actions/notification-bindings.ts
  11. 477 0
      src/actions/webhook-targets.ts
  12. 333 0
      src/app/[locale]/settings/notifications/_components/binding-selector.tsx
  13. 52 0
      src/app/[locale]/settings/notifications/_components/global-settings-card.tsx
  14. 293 0
      src/app/[locale]/settings/notifications/_components/notification-type-card.tsx
  15. 23 0
      src/app/[locale]/settings/notifications/_components/notifications-skeleton.tsx
  16. 67 0
      src/app/[locale]/settings/notifications/_components/proxy-config-section.tsx
  17. 118 0
      src/app/[locale]/settings/notifications/_components/template-editor.tsx
  18. 80 0
      src/app/[locale]/settings/notifications/_components/test-webhook-button.tsx
  19. 153 0
      src/app/[locale]/settings/notifications/_components/webhook-target-card.tsx
  20. 292 0
      src/app/[locale]/settings/notifications/_components/webhook-target-dialog.tsx
  21. 156 0
      src/app/[locale]/settings/notifications/_components/webhook-targets-section.tsx
  22. 121 0
      src/app/[locale]/settings/notifications/_components/webhook-type-form.tsx
  23. 345 0
      src/app/[locale]/settings/notifications/_lib/hooks.ts
  24. 133 0
      src/app/[locale]/settings/notifications/_lib/schemas.ts
  25. 70 532
      src/app/[locale]/settings/notifications/page.tsx
  26. 248 7
      src/app/api/actions/[...route]/route.ts
  27. 81 0
      src/drizzle/schema.ts
  28. 199 50
      src/lib/notification/notification-queue.ts
  29. 114 20
      src/lib/notification/notifier.ts
  30. 1 1
      src/lib/proxy-agent.ts
  31. 3 0
      src/lib/webhook/index.ts
  32. 157 20
      src/lib/webhook/notifier.ts
  33. 59 0
      src/lib/webhook/renderers/custom.ts
  34. 99 0
      src/lib/webhook/renderers/dingtalk.ts
  35. 32 3
      src/lib/webhook/renderers/index.ts
  36. 105 0
      src/lib/webhook/renderers/telegram.ts
  37. 2 14
      src/lib/webhook/renderers/wechat.ts
  38. 45 0
      src/lib/webhook/templates/defaults.ts
  39. 7 0
      src/lib/webhook/templates/index.ts
  40. 203 0
      src/lib/webhook/templates/placeholders.ts
  41. 28 1
      src/lib/webhook/types.ts
  42. 4 0
      src/lib/webhook/utils/date.ts
  43. 242 0
      src/repository/notification-bindings.ts
  44. 80 0
      src/repository/notifications.ts
  45. 170 0
      src/repository/webhook-targets.ts
  46. 27 0
      src/types/fetch-socks.d.ts
  47. 29 0
      tests/api/api-actions-integrity.test.ts
  48. 3 3
      tests/api/api-openapi-spec.test.ts
  49. 134 0
      tests/e2e/notification-settings.test.ts
  50. 85 0
      tests/integration/notification-bindings.test.ts
  51. 57 0
      tests/integration/webhook-targets-crud.test.ts
  52. 71 0
      tests/unit/webhook/notifier.test.ts
  53. 58 0
      tests/unit/webhook/renderers/custom.test.ts
  54. 57 0
      tests/unit/webhook/renderers/dingtalk.test.ts
  55. 51 0
      tests/unit/webhook/renderers/telegram.test.ts
  56. 136 0
      tests/unit/webhook/templates/placeholders.test.ts
  57. 41 0
      vitest.integration.config.ts

+ 62 - 0
drizzle/0043_lonely_rick_jones.sql

@@ -0,0 +1,62 @@
+-- Step 1: 创建枚举类型(幂等)
+DO $$ BEGIN
+	CREATE TYPE "public"."notification_type" AS ENUM('circuit_breaker', 'daily_leaderboard', 'cost_alert');
+EXCEPTION
+	WHEN duplicate_object THEN null;
+END $$;
+--> statement-breakpoint
+
+DO $$ BEGIN
+	CREATE TYPE "public"."webhook_provider_type" AS ENUM('wechat', 'feishu', 'dingtalk', 'telegram', 'custom');
+EXCEPTION
+	WHEN duplicate_object THEN null;
+END $$;
+--> statement-breakpoint
+
+-- Step 2: 创建表(幂等)
+CREATE TABLE IF NOT EXISTS "notification_target_bindings" (
+	"id" serial PRIMARY KEY NOT NULL,
+	"notification_type" "notification_type" NOT NULL,
+	"target_id" integer NOT NULL,
+	"is_enabled" boolean DEFAULT true NOT NULL,
+	"schedule_cron" varchar(100),
+	"schedule_timezone" varchar(50) DEFAULT 'Asia/Shanghai',
+	"template_override" jsonb,
+	"created_at" timestamp with time zone DEFAULT now()
+);
+--> statement-breakpoint
+CREATE TABLE IF NOT EXISTS "webhook_targets" (
+	"id" serial PRIMARY KEY NOT NULL,
+	"name" varchar(100) NOT NULL,
+	"provider_type" "webhook_provider_type" NOT NULL,
+	"webhook_url" varchar(1024),
+	"telegram_bot_token" varchar(256),
+	"telegram_chat_id" varchar(64),
+	"dingtalk_secret" varchar(256),
+	"custom_template" jsonb,
+	"custom_headers" jsonb,
+	"proxy_url" varchar(512),
+	"proxy_fallback_to_direct" boolean DEFAULT false,
+	"is_enabled" boolean DEFAULT true NOT NULL,
+	"last_test_at" timestamp with time zone,
+	"last_test_result" jsonb,
+	"created_at" timestamp with time zone DEFAULT now(),
+	"updated_at" timestamp with time zone DEFAULT now()
+);
+--> statement-breakpoint
+
+-- Step 3: 兼容旧配置(幂等)
+ALTER TABLE "notification_settings" ADD COLUMN IF NOT EXISTS "use_legacy_mode" boolean DEFAULT true NOT NULL;--> statement-breakpoint
+
+-- Step 4: 外键约束(幂等)
+DO $$ BEGIN
+	ALTER TABLE "notification_target_bindings" ADD CONSTRAINT "notification_target_bindings_target_id_webhook_targets_id_fk" FOREIGN KEY ("target_id") REFERENCES "public"."webhook_targets"("id") ON DELETE cascade ON UPDATE no action;
+EXCEPTION
+	WHEN duplicate_object THEN null;
+END $$;
+--> statement-breakpoint
+
+-- Step 5: 索引(幂等)
+CREATE UNIQUE INDEX IF NOT EXISTS "unique_notification_target_binding" ON "notification_target_bindings" USING btree ("notification_type","target_id");--> statement-breakpoint
+CREATE INDEX IF NOT EXISTS "idx_notification_bindings_type" ON "notification_target_bindings" USING btree ("notification_type","is_enabled");--> statement-breakpoint
+CREATE INDEX IF NOT EXISTS "idx_notification_bindings_target" ON "notification_target_bindings" USING btree ("target_id","is_enabled");--> statement-breakpoint

+ 2273 - 0
drizzle/meta/0043_snapshot.json

@@ -0,0 +1,2273 @@
+{
+  "id": "1f7c9b64-5c00-4a95-993c-802a3be11622",
+  "prevId": "21302171-827d-483a-aa6c-1e9c4084bebc",
+  "version": "7",
+  "dialect": "postgresql",
+  "tables": {
+    "public.error_rules": {
+      "name": "error_rules",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "pattern": {
+          "name": "pattern",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "match_type": {
+          "name": "match_type",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'regex'"
+        },
+        "category": {
+          "name": "category",
+          "type": "varchar(50)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "override_response": {
+          "name": "override_response",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "override_status_code": {
+          "name": "override_status_code",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "is_enabled": {
+          "name": "is_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": true
+        },
+        "is_default": {
+          "name": "is_default",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "priority": {
+          "name": "priority",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "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()"
+        }
+      },
+      "indexes": {
+        "idx_error_rules_enabled": {
+          "name": "idx_error_rules_enabled",
+          "columns": [
+            {
+              "expression": "is_enabled",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "priority",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "unique_pattern": {
+          "name": "unique_pattern",
+          "columns": [
+            {
+              "expression": "pattern",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": true,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_category": {
+          "name": "idx_category",
+          "columns": [
+            {
+              "expression": "category",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_match_type": {
+          "name": "idx_match_type",
+          "columns": [
+            {
+              "expression": "match_type",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "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": false
+        },
+        "limit_5h_usd": {
+          "name": "limit_5h_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_daily_usd": {
+          "name": "limit_daily_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "daily_reset_mode": {
+          "name": "daily_reset_mode",
+          "type": "daily_reset_mode",
+          "typeSchema": "public",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'fixed'"
+        },
+        "daily_reset_time": {
+          "name": "daily_reset_time",
+          "type": "varchar(5)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'00:00'"
+        },
+        "limit_weekly_usd": {
+          "name": "limit_weekly_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_monthly_usd": {
+          "name": "limit_monthly_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_total_usd": {
+          "name": "limit_total_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_concurrent_sessions": {
+          "name": "limit_concurrent_sessions",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 0
+        },
+        "provider_group": {
+          "name": "provider_group",
+          "type": "varchar(50)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'default'"
+        },
+        "cache_ttl_preference": {
+          "name": "cache_ttl_preference",
+          "type": "varchar(10)",
+          "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_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
+        },
+        "request_sequence": {
+          "name": "request_sequence",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 1
+        },
+        "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
+        },
+        "endpoint": {
+          "name": "endpoint",
+          "type": "varchar(256)",
+          "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
+        },
+        "ttfb_ms": {
+          "name": "ttfb_ms",
+          "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
+        },
+        "cache_creation_5m_input_tokens": {
+          "name": "cache_creation_5m_input_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "cache_creation_1h_input_tokens": {
+          "name": "cache_creation_1h_input_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "cache_ttl_applied": {
+          "name": "cache_ttl_applied",
+          "type": "varchar(10)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "context_1m_applied": {
+          "name": "context_1m_applied",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": false,
+          "default": false
+        },
+        "error_message": {
+          "name": "error_message",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "error_stack": {
+          "name": "error_stack",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "error_cause": {
+          "name": "error_cause",
+          "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_session_seq": {
+          "name": "idx_message_request_session_seq",
+          "columns": [
+            {
+              "expression": "session_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "request_sequence",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"message_request\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_endpoint": {
+          "name": "idx_message_request_endpoint",
+          "columns": [
+            {
+              "expression": "endpoint",
+              "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.notification_settings": {
+      "name": "notification_settings",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "use_legacy_mode": {
+          "name": "use_legacy_mode",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": true
+        },
+        "circuit_breaker_enabled": {
+          "name": "circuit_breaker_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "circuit_breaker_webhook": {
+          "name": "circuit_breaker_webhook",
+          "type": "varchar(512)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "daily_leaderboard_enabled": {
+          "name": "daily_leaderboard_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "daily_leaderboard_webhook": {
+          "name": "daily_leaderboard_webhook",
+          "type": "varchar(512)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "daily_leaderboard_time": {
+          "name": "daily_leaderboard_time",
+          "type": "varchar(10)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'09:00'"
+        },
+        "daily_leaderboard_top_n": {
+          "name": "daily_leaderboard_top_n",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 5
+        },
+        "cost_alert_enabled": {
+          "name": "cost_alert_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "cost_alert_webhook": {
+          "name": "cost_alert_webhook",
+          "type": "varchar(512)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "cost_alert_threshold": {
+          "name": "cost_alert_threshold",
+          "type": "numeric(5, 2)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'0.80'"
+        },
+        "cost_alert_check_interval": {
+          "name": "cost_alert_check_interval",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 60
+        },
+        "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.notification_target_bindings": {
+      "name": "notification_target_bindings",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "notification_type": {
+          "name": "notification_type",
+          "type": "notification_type",
+          "typeSchema": "public",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "target_id": {
+          "name": "target_id",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "is_enabled": {
+          "name": "is_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": true
+        },
+        "schedule_cron": {
+          "name": "schedule_cron",
+          "type": "varchar(100)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "schedule_timezone": {
+          "name": "schedule_timezone",
+          "type": "varchar(50)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'Asia/Shanghai'"
+        },
+        "template_override": {
+          "name": "template_override",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        }
+      },
+      "indexes": {
+        "unique_notification_target_binding": {
+          "name": "unique_notification_target_binding",
+          "columns": [
+            {
+              "expression": "notification_type",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "target_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": true,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_notification_bindings_type": {
+          "name": "idx_notification_bindings_type",
+          "columns": [
+            {
+              "expression": "notification_type",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "is_enabled",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_notification_bindings_target": {
+          "name": "idx_notification_bindings_target",
+          "columns": [
+            {
+              "expression": "target_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "is_enabled",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {
+        "notification_target_bindings_target_id_webhook_targets_id_fk": {
+          "name": "notification_target_bindings_target_id_webhook_targets_id_fk",
+          "tableFrom": "notification_target_bindings",
+          "tableTo": "webhook_targets",
+          "columnsFrom": [
+            "target_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        }
+      },
+      "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'"
+        },
+        "preserve_client_ip": {
+          "name": "preserve_client_ip",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "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"
+        },
+        "join_claude_pool": {
+          "name": "join_claude_pool",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": false,
+          "default": false
+        },
+        "codex_instructions_strategy": {
+          "name": "codex_instructions_strategy",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'auto'"
+        },
+        "mcp_passthrough_type": {
+          "name": "mcp_passthrough_type",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'none'"
+        },
+        "mcp_passthrough_url": {
+          "name": "mcp_passthrough_url",
+          "type": "varchar(512)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_5h_usd": {
+          "name": "limit_5h_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_daily_usd": {
+          "name": "limit_daily_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "daily_reset_mode": {
+          "name": "daily_reset_mode",
+          "type": "daily_reset_mode",
+          "typeSchema": "public",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'fixed'"
+        },
+        "daily_reset_time": {
+          "name": "daily_reset_time",
+          "type": "varchar(5)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'00:00'"
+        },
+        "limit_weekly_usd": {
+          "name": "limit_weekly_usd",
+          "type": "numeric(10, 2)",
+          "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
+        },
+        "max_retry_attempts": {
+          "name": "max_retry_attempts",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "circuit_breaker_failure_threshold": {
+          "name": "circuit_breaker_failure_threshold",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 5
+        },
+        "circuit_breaker_open_duration": {
+          "name": "circuit_breaker_open_duration",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 1800000
+        },
+        "circuit_breaker_half_open_success_threshold": {
+          "name": "circuit_breaker_half_open_success_threshold",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 2
+        },
+        "proxy_url": {
+          "name": "proxy_url",
+          "type": "varchar(512)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "proxy_fallback_to_direct": {
+          "name": "proxy_fallback_to_direct",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": false,
+          "default": false
+        },
+        "first_byte_timeout_streaming_ms": {
+          "name": "first_byte_timeout_streaming_ms",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "default": 0
+        },
+        "streaming_idle_timeout_ms": {
+          "name": "streaming_idle_timeout_ms",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "default": 0
+        },
+        "request_timeout_non_streaming_ms": {
+          "name": "request_timeout_non_streaming_ms",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "default": 0
+        },
+        "website_url": {
+          "name": "website_url",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "favicon_url": {
+          "name": "favicon_url",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "cache_ttl_preference": {
+          "name": "cache_ttl_preference",
+          "type": "varchar(10)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "context_1m_preference": {
+          "name": "context_1m_preference",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "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.request_filters": {
+      "name": "request_filters",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "name": {
+          "name": "name",
+          "type": "varchar(100)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "scope": {
+          "name": "scope",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "action": {
+          "name": "action",
+          "type": "varchar(30)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "match_type": {
+          "name": "match_type",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "target": {
+          "name": "target",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "replacement": {
+          "name": "replacement",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "priority": {
+          "name": "priority",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "default": 0
+        },
+        "is_enabled": {
+          "name": "is_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": true
+        },
+        "binding_type": {
+          "name": "binding_type",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'global'"
+        },
+        "provider_ids": {
+          "name": "provider_ids",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "group_tags": {
+          "name": "group_tags",
+          "type": "jsonb",
+          "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()"
+        }
+      },
+      "indexes": {
+        "idx_request_filters_enabled": {
+          "name": "idx_request_filters_enabled",
+          "columns": [
+            {
+              "expression": "is_enabled",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "priority",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_request_filters_scope": {
+          "name": "idx_request_filters_scope",
+          "columns": [
+            {
+              "expression": "scope",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_request_filters_action": {
+          "name": "idx_request_filters_action",
+          "columns": [
+            {
+              "expression": "action",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_request_filters_binding": {
+          "name": "idx_request_filters_binding",
+          "columns": [
+            {
+              "expression": "is_enabled",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "binding_type",
+              "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'"
+        },
+        "billing_model_source": {
+          "name": "billing_model_source",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'original'"
+        },
+        "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
+        },
+        "enable_client_version_check": {
+          "name": "enable_client_version_check",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "verbose_provider_error": {
+          "name": "verbose_provider_error",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "enable_http2": {
+          "name": "enable_http2",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": 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()"
+        }
+      },
+      "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
+        },
+        "daily_limit_usd": {
+          "name": "daily_limit_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "provider_group": {
+          "name": "provider_group",
+          "type": "varchar(50)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'default'"
+        },
+        "tags": {
+          "name": "tags",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'[]'::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_total_usd": {
+          "name": "limit_total_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_concurrent_sessions": {
+          "name": "limit_concurrent_sessions",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "daily_reset_mode": {
+          "name": "daily_reset_mode",
+          "type": "daily_reset_mode",
+          "typeSchema": "public",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'fixed'"
+        },
+        "daily_reset_time": {
+          "name": "daily_reset_time",
+          "type": "varchar(5)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'00:00'"
+        },
+        "is_enabled": {
+          "name": "is_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": true
+        },
+        "expires_at": {
+          "name": "expires_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "allowed_clients": {
+          "name": "allowed_clients",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'[]'::jsonb"
+        },
+        "allowed_models": {
+          "name": "allowed_models",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'[]'::jsonb"
+        },
+        "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_enabled_expires_at": {
+          "name": "idx_users_enabled_expires_at",
+          "columns": [
+            {
+              "expression": "is_enabled",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "expires_at",
+              "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
+    },
+    "public.webhook_targets": {
+      "name": "webhook_targets",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "name": {
+          "name": "name",
+          "type": "varchar(100)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "provider_type": {
+          "name": "provider_type",
+          "type": "webhook_provider_type",
+          "typeSchema": "public",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "webhook_url": {
+          "name": "webhook_url",
+          "type": "varchar(1024)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "telegram_bot_token": {
+          "name": "telegram_bot_token",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "telegram_chat_id": {
+          "name": "telegram_chat_id",
+          "type": "varchar(64)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "dingtalk_secret": {
+          "name": "dingtalk_secret",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "custom_template": {
+          "name": "custom_template",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "custom_headers": {
+          "name": "custom_headers",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "proxy_url": {
+          "name": "proxy_url",
+          "type": "varchar(512)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "proxy_fallback_to_direct": {
+          "name": "proxy_fallback_to_direct",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": false,
+          "default": false
+        },
+        "is_enabled": {
+          "name": "is_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": true
+        },
+        "last_test_at": {
+          "name": "last_test_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "last_test_result": {
+          "name": "last_test_result",
+          "type": "jsonb",
+          "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()"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    }
+  },
+  "enums": {
+    "public.daily_reset_mode": {
+      "name": "daily_reset_mode",
+      "schema": "public",
+      "values": [
+        "fixed",
+        "rolling"
+      ]
+    },
+    "public.notification_type": {
+      "name": "notification_type",
+      "schema": "public",
+      "values": [
+        "circuit_breaker",
+        "daily_leaderboard",
+        "cost_alert"
+      ]
+    },
+    "public.webhook_provider_type": {
+      "name": "webhook_provider_type",
+      "schema": "public",
+      "values": [
+        "wechat",
+        "feishu",
+        "dingtalk",
+        "telegram",
+        "custom"
+      ]
+    }
+  },
+  "schemas": {},
+  "sequences": {},
+  "roles": {},
+  "policies": {},
+  "views": {},
+  "_meta": {
+    "columns": {},
+    "schemas": {},
+    "tables": {}
+  }
+}

+ 7 - 0
drizzle/meta/_journal.json

@@ -302,6 +302,13 @@
       "when": 1767279598143,
       "tag": "0042_legal_harrier",
       "breakpoints": true
+    },
+    {
+      "idx": 43,
+      "version": "7",
+      "when": 1767349351775,
+      "tag": "0043_lonely_rick_jones",
+      "breakpoints": true
     }
   ]
 }

+ 89 - 7
messages/en/settings.json

@@ -380,14 +380,96 @@
   "notifications": {
     "title": "Push Notifications",
     "description": "Configure Webhook push notifications",
-    "global": {
-      "title": "Notification Master Switch",
-      "description": "Enable or disable all push notification features",
-      "enable": "Enable Push Notifications"
+  "global": {
+    "title": "Notification Master Switch",
+    "description": "Enable or disable all push notification features",
+    "enable": "Enable Push Notifications",
+    "legacyModeTitle": "Legacy Mode",
+    "legacyModeDescription": "You are using legacy single-URL notifications. Create a push target to switch to multi-target mode."
+  },
+  "targets": {
+    "title": "Push Targets",
+    "description": "Manage push targets. Supports WeCom, Feishu, DingTalk, Telegram and custom Webhook.",
+    "add": "Add Target",
+    "update": "Save Target",
+    "edit": "Edit",
+    "delete": "Delete",
+    "deleteConfirmTitle": "Delete Push Target",
+    "deleteConfirm": "Are you sure you want to delete this target? Related bindings will also be removed.",
+    "enable": "Enable Target",
+    "statusEnabled": "Enabled",
+    "statusDisabled": "Disabled",
+    "lastTestAt": "Last Test",
+    "lastTestNever": "Never tested",
+    "lastTestSuccess": "Test OK",
+    "lastTestFailed": "Test Failed",
+    "test": "Test",
+    "testSelectType": "Select test type",
+    "emptyHint": "No push targets yet. Click \"Add Target\" to create one.",
+    "created": "Target created",
+    "updated": "Target updated",
+    "deleted": "Target deleted",
+    "bindingsSaved": "Bindings saved"
+  },
+  "targetDialog": {
+    "createTitle": "Add Push Target",
+    "editTitle": "Edit Push Target",
+    "name": "Target Name",
+    "namePlaceholder": "e.g. Ops Group",
+    "type": "Platform Type",
+    "selectType": "Select platform type",
+    "enable": "Enable",
+    "webhookUrl": "Webhook URL",
+    "webhookUrlPlaceholder": "https://example.com/webhook",
+    "telegramBotToken": "Telegram Bot Token",
+    "telegramBotTokenPlaceholder": "e.g. 123456:ABCDEF...",
+    "telegramChatId": "Telegram Chat ID",
+    "telegramChatIdPlaceholder": "e.g. -1001234567890",
+    "dingtalkSecret": "DingTalk Secret",
+    "dingtalkSecretPlaceholder": "Optional, used for signing",
+    "customHeaders": "Custom Headers (JSON)",
+    "customHeadersPlaceholder": "{\"X-Token\":\"...\"}",
+    "types": {
+      "wechat": "WeCom",
+      "feishu": "Feishu",
+      "dingtalk": "DingTalk",
+      "telegram": "Telegram",
+      "custom": "Custom Webhook"
     },
-    "circuitBreaker": {
-      "title": "Circuit Breaker Alert",
-      "description": "Send alert immediately when provider is fully circuit broken",
+    "proxy": {
+      "title": "Proxy",
+      "toggle": "Toggle proxy settings",
+      "url": "Proxy URL",
+      "urlPlaceholder": "http://127.0.0.1:7890",
+      "fallbackToDirect": "Fallback to direct on proxy failure"
+    }
+  },
+  "bindings": {
+    "title": "Bindings",
+    "noTargets": "No push targets available.",
+    "bindTarget": "Bind target",
+    "enable": "Enable",
+    "enableType": "Enable this notification",
+    "advanced": "Advanced",
+    "scheduleCron": "Cron",
+    "scheduleCronPlaceholder": "e.g. 0 9 * * *",
+    "scheduleTimezone": "Timezone",
+    "templateOverride": "Template Override",
+    "editTemplateOverride": "Edit Override",
+    "templateOverrideTitle": "Edit Template Override",
+    "boundCount": "Bound: {count}",
+    "enabledCount": "Enabled: {count}"
+  },
+  "templateEditor": {
+    "title": "Template (JSON)",
+    "placeholder": "Enter JSON template...",
+    "jsonInvalid": "Invalid JSON",
+    "placeholders": "Placeholders",
+    "insert": "Insert"
+  },
+  "circuitBreaker": {
+    "title": "Circuit Breaker Alert",
+    "description": "Send alert immediately when provider is fully circuit broken",
       "enable": "Enable Circuit Breaker Alert",
       "webhook": "Webhook URL",
       "webhookPlaceholder": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=...",

+ 88 - 6
messages/ja/settings.json

@@ -371,13 +371,95 @@
   "notifications": {
     "title": "プッシュ通知",
     "description": "Webhook プッシュ通知を設定",
-    "global": {
-      "title": "通知マスタースイッチ",
-      "description": "すべてのプッシュ通知機能を有効または無効にする",
-      "enable": "プッシュ通知を有効にする"
+  "global": {
+    "title": "通知マスタースイッチ",
+    "description": "すべてのプッシュ通知機能を有効または無効にする",
+    "enable": "プッシュ通知を有効にする",
+    "legacyModeTitle": "互換モード",
+    "legacyModeDescription": "現在は旧来の単一URL通知設定を使用しています。プッシュ先を作成するとマルチターゲットモードに切り替わります。"
+  },
+  "targets": {
+    "title": "プッシュ先",
+    "description": "プッシュ先を管理します。WeCom、Feishu、DingTalk、Telegram、カスタムWebhookに対応。",
+    "add": "追加",
+    "update": "保存",
+    "edit": "編集",
+    "delete": "削除",
+    "deleteConfirmTitle": "プッシュ先を削除",
+    "deleteConfirm": "このプッシュ先を削除しますか?関連するバインドも削除されます。",
+    "enable": "有効化",
+    "statusEnabled": "有効",
+    "statusDisabled": "無効",
+    "lastTestAt": "最終テスト",
+    "lastTestNever": "未テスト",
+    "lastTestSuccess": "テスト成功",
+    "lastTestFailed": "テスト失敗",
+    "test": "テスト",
+    "testSelectType": "テスト種類を選択",
+    "emptyHint": "プッシュ先がありません。「追加」で作成してください。",
+    "created": "プッシュ先を作成しました",
+    "updated": "プッシュ先を更新しました",
+    "deleted": "プッシュ先を削除しました",
+    "bindingsSaved": "バインドを保存しました"
+  },
+  "targetDialog": {
+    "createTitle": "プッシュ先を追加",
+    "editTitle": "プッシュ先を編集",
+    "name": "名前",
+    "namePlaceholder": "例: Ops グループ",
+    "type": "プラットフォーム",
+    "selectType": "プラットフォームを選択",
+    "enable": "有効化",
+    "webhookUrl": "Webhook URL",
+    "webhookUrlPlaceholder": "https://example.com/webhook",
+    "telegramBotToken": "Telegram Bot Token",
+    "telegramBotTokenPlaceholder": "例: 123456:ABCDEF...",
+    "telegramChatId": "Telegram Chat ID",
+    "telegramChatIdPlaceholder": "例: -1001234567890",
+    "dingtalkSecret": "DingTalk シークレット",
+    "dingtalkSecretPlaceholder": "任意(署名用)",
+    "customHeaders": "カスタムヘッダー(JSON)",
+    "customHeadersPlaceholder": "{\"X-Token\":\"...\"}",
+    "types": {
+      "wechat": "WeCom",
+      "feishu": "Feishu",
+      "dingtalk": "DingTalk",
+      "telegram": "Telegram",
+      "custom": "カスタムWebhook"
     },
-    "circuitBreaker": {
-      "title": "サーキットブレーカーアラート",
+    "proxy": {
+      "title": "プロキシ",
+      "toggle": "プロキシ設定を切り替え",
+      "url": "プロキシURL",
+      "urlPlaceholder": "http://127.0.0.1:7890",
+      "fallbackToDirect": "プロキシ失敗時に直結へフォールバック"
+    }
+  },
+  "bindings": {
+    "title": "バインド",
+    "noTargets": "プッシュ先がありません",
+    "bindTarget": "プッシュ先をバインド",
+    "enable": "有効",
+    "enableType": "この通知を有効化",
+    "advanced": "詳細",
+    "scheduleCron": "Cron",
+    "scheduleCronPlaceholder": "例: 0 9 * * *",
+    "scheduleTimezone": "タイムゾーン",
+    "templateOverride": "テンプレート上書き",
+    "editTemplateOverride": "上書きを編集",
+    "templateOverrideTitle": "テンプレート上書きを編集",
+    "boundCount": "バインド: {count}",
+    "enabledCount": "有効: {count}"
+  },
+  "templateEditor": {
+    "title": "テンプレート(JSON)",
+    "placeholder": "JSON テンプレートを入力...",
+    "jsonInvalid": "JSON が不正です",
+    "placeholders": "プレースホルダー",
+    "insert": "挿入"
+  },
+  "circuitBreaker": {
+    "title": "サーキットブレーカーアラート",
       "description": "プロバイダーが完全に遮断された時に即座にアラートを送信",
       "enable": "サーキットブレーカーアラートを有効にする",
       "webhook": "Webhook URL",

+ 88 - 6
messages/ru/settings.json

@@ -371,13 +371,95 @@
   "notifications": {
     "title": "Push-уведомления",
     "description": "Настройка push-уведомлений Webhook",
-    "global": {
-      "title": "Главный переключатель уведомлений",
-      "description": "Включить или отключить все функции push-уведомлений",
-      "enable": "Включить push-уведомления"
+  "global": {
+    "title": "Главный переключатель уведомлений",
+    "description": "Включить или отключить все функции push-уведомлений",
+    "enable": "Включить push-уведомления",
+    "legacyModeTitle": "Режим совместимости",
+    "legacyModeDescription": "Сейчас используется устаревшая схема уведомлений с одним URL. Создайте цель отправки, чтобы перейти на режим с несколькими целями."
+  },
+  "targets": {
+    "title": "Цели отправки",
+    "description": "Управление целями отправки. Поддерживает WeCom, Feishu, DingTalk, Telegram и пользовательский Webhook.",
+    "add": "Добавить цель",
+    "update": "Сохранить цель",
+    "edit": "Редактировать",
+    "delete": "Удалить",
+    "deleteConfirmTitle": "Удалить цель",
+    "deleteConfirm": "Удалить эту цель? Связанные привязки также будут удалены.",
+    "enable": "Включить цель",
+    "statusEnabled": "Включено",
+    "statusDisabled": "Отключено",
+    "lastTestAt": "Последний тест",
+    "lastTestNever": "Тестов не было",
+    "lastTestSuccess": "Тест OK",
+    "lastTestFailed": "Тест не пройден",
+    "test": "Тест",
+    "testSelectType": "Выберите тип теста",
+    "emptyHint": "Целей нет. Нажмите «Добавить цель», чтобы создать.",
+    "created": "Цель создана",
+    "updated": "Цель обновлена",
+    "deleted": "Цель удалена",
+    "bindingsSaved": "Привязки сохранены"
+  },
+  "targetDialog": {
+    "createTitle": "Добавить цель",
+    "editTitle": "Редактировать цель",
+    "name": "Название",
+    "namePlaceholder": "например, Ops Group",
+    "type": "Платформа",
+    "selectType": "Выберите платформу",
+    "enable": "Включить",
+    "webhookUrl": "Webhook URL",
+    "webhookUrlPlaceholder": "https://example.com/webhook",
+    "telegramBotToken": "Telegram Bot Token",
+    "telegramBotTokenPlaceholder": "например, 123456:ABCDEF...",
+    "telegramChatId": "Telegram Chat ID",
+    "telegramChatIdPlaceholder": "например, -1001234567890",
+    "dingtalkSecret": "Секрет DingTalk",
+    "dingtalkSecretPlaceholder": "Необязательно, для подписи",
+    "customHeaders": "Пользовательские заголовки (JSON)",
+    "customHeadersPlaceholder": "{\"X-Token\":\"...\"}",
+    "types": {
+      "wechat": "WeCom",
+      "feishu": "Feishu",
+      "dingtalk": "DingTalk",
+      "telegram": "Telegram",
+      "custom": "Custom Webhook"
     },
-    "circuitBreaker": {
-      "title": "Оповещение о размыкателе цепи",
+    "proxy": {
+      "title": "Прокси",
+      "toggle": "Показать/скрыть настройки прокси",
+      "url": "URL прокси",
+      "urlPlaceholder": "http://127.0.0.1:7890",
+      "fallbackToDirect": "При ошибке прокси — прямое подключение"
+    }
+  },
+  "bindings": {
+    "title": "Привязки",
+    "noTargets": "Нет доступных целей отправки.",
+    "bindTarget": "Привязать цель",
+    "enable": "Включить",
+    "enableType": "Включить это уведомление",
+    "advanced": "Дополнительно",
+    "scheduleCron": "Cron",
+    "scheduleCronPlaceholder": "например, 0 9 * * *",
+    "scheduleTimezone": "Часовой пояс",
+    "templateOverride": "Переопределение шаблона",
+    "editTemplateOverride": "Редактировать",
+    "templateOverrideTitle": "Редактировать переопределение шаблона",
+    "boundCount": "Привязано: {count}",
+    "enabledCount": "Включено: {count}"
+  },
+  "templateEditor": {
+    "title": "Шаблон (JSON)",
+    "placeholder": "Введите JSON-шаблон...",
+    "jsonInvalid": "Некорректный JSON",
+    "placeholders": "Плейсхолдеры",
+    "insert": "Вставить"
+  },
+  "circuitBreaker": {
+    "title": "Оповещение о размыкателе цепи",
       "description": "Отправить оповещение немедленно при полном размыкании провайдера",
       "enable": "Включить оповещение о размыкателе цепи",
       "webhook": "Webhook URL",

+ 89 - 7
messages/zh-CN/settings.json

@@ -1611,14 +1611,96 @@
   "notifications": {
     "title": "消息推送",
     "description": "配置 Webhook 消息推送",
-    "global": {
-      "title": "通知总开关",
-      "description": "启用或禁用所有消息推送功能",
-      "enable": "启用消息推送"
+  "global": {
+    "title": "通知总开关",
+    "description": "启用或禁用所有消息推送功能",
+    "enable": "启用消息推送",
+    "legacyModeTitle": "兼容模式",
+    "legacyModeDescription": "当前使用旧版单 URL 推送配置。创建推送目标后将自动切换到多目标模式。"
+  },
+  "targets": {
+    "title": "推送目标",
+    "description": "管理推送目标,支持企业微信、飞书、钉钉、Telegram、自定义 Webhook",
+    "add": "添加目标",
+    "update": "保存目标",
+    "edit": "编辑",
+    "delete": "删除",
+    "deleteConfirmTitle": "删除推送目标",
+    "deleteConfirm": "确定要删除该目标吗?相关绑定也会被移除。",
+    "enable": "启用目标",
+    "statusEnabled": "已启用",
+    "statusDisabled": "已禁用",
+    "lastTestAt": "上次测试",
+    "lastTestNever": "从未测试",
+    "lastTestSuccess": "测试成功",
+    "lastTestFailed": "测试失败",
+    "test": "测试",
+    "testSelectType": "选择测试类型",
+    "emptyHint": "暂无推送目标,点击“添加目标”创建。",
+    "created": "目标已创建",
+    "updated": "目标已更新",
+    "deleted": "目标已删除",
+    "bindingsSaved": "绑定已保存"
+  },
+  "targetDialog": {
+    "createTitle": "添加推送目标",
+    "editTitle": "编辑推送目标",
+    "name": "目标名称",
+    "namePlaceholder": "例如:运维群",
+    "type": "平台类型",
+    "selectType": "请选择平台类型",
+    "enable": "启用",
+    "webhookUrl": "Webhook URL",
+    "webhookUrlPlaceholder": "https://example.com/webhook",
+    "telegramBotToken": "Telegram Bot Token",
+    "telegramBotTokenPlaceholder": "例如:123456:ABCDEF...",
+    "telegramChatId": "Telegram Chat ID",
+    "telegramChatIdPlaceholder": "例如:-1001234567890",
+    "dingtalkSecret": "钉钉签名密钥",
+    "dingtalkSecretPlaceholder": "可选,用于签名",
+    "customHeaders": "自定义 Headers(JSON)",
+    "customHeadersPlaceholder": "{\"X-Token\":\"...\"}",
+    "types": {
+      "wechat": "企业微信",
+      "feishu": "飞书",
+      "dingtalk": "钉钉",
+      "telegram": "Telegram",
+      "custom": "自定义 Webhook"
     },
-    "circuitBreaker": {
-      "title": "熔断器告警",
-      "description": "供应商完全熔断时立即推送告警消息",
+    "proxy": {
+      "title": "代理设置",
+      "toggle": "展开/收起代理设置",
+      "url": "代理地址",
+      "urlPlaceholder": "http://127.0.0.1:7890",
+      "fallbackToDirect": "代理失败时回退直连"
+    }
+  },
+  "bindings": {
+    "title": "绑定",
+    "noTargets": "暂无可用推送目标",
+    "bindTarget": "绑定目标",
+    "enable": "启用",
+    "enableType": "启用该通知",
+    "advanced": "高级",
+    "scheduleCron": "Cron 表达式",
+    "scheduleCronPlaceholder": "例如:0 9 * * *",
+    "scheduleTimezone": "时区",
+    "templateOverride": "模板覆盖",
+    "editTemplateOverride": "编辑覆盖",
+    "templateOverrideTitle": "编辑模板覆盖",
+    "boundCount": "已绑定:{count}",
+    "enabledCount": "已启用:{count}"
+  },
+  "templateEditor": {
+    "title": "模板(JSON)",
+    "placeholder": "请输入 JSON 模板...",
+    "jsonInvalid": "JSON 格式不正确",
+    "placeholders": "占位符",
+    "insert": "插入"
+  },
+  "circuitBreaker": {
+    "title": "熔断器告警",
+    "description": "供应商完全熔断时立即推送告警消息",
       "enable": "启用熔断器告警",
       "webhook": "Webhook URL",
       "webhookPlaceholder": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=...",

+ 89 - 7
messages/zh-TW/settings.json

@@ -371,14 +371,96 @@
   "notifications": {
     "title": "訊息推送",
     "description": "設定 Webhook 訊息推送",
-    "global": {
-      "title": "通知總開關",
-      "description": "啟用或停用所有訊息推送功能",
-      "enable": "啟用訊息推送"
+  "global": {
+    "title": "通知總開關",
+    "description": "啟用或停用所有訊息推送功能",
+    "enable": "啟用訊息推送",
+    "legacyModeTitle": "相容模式",
+    "legacyModeDescription": "目前使用舊版單一 URL 推送設定。建立推送目標後將自動切換為多目標模式。"
+  },
+  "targets": {
+    "title": "推送目標",
+    "description": "管理推送目標,支援企業微信、飛書、釘釘、Telegram、自訂 Webhook",
+    "add": "新增目標",
+    "update": "儲存目標",
+    "edit": "編輯",
+    "delete": "刪除",
+    "deleteConfirmTitle": "刪除推送目標",
+    "deleteConfirm": "確定要刪除此目標嗎?相關綁定也會被移除。",
+    "enable": "啟用目標",
+    "statusEnabled": "已啟用",
+    "statusDisabled": "已停用",
+    "lastTestAt": "上次測試",
+    "lastTestNever": "從未測試",
+    "lastTestSuccess": "測試成功",
+    "lastTestFailed": "測試失敗",
+    "test": "測試",
+    "testSelectType": "選擇測試類型",
+    "emptyHint": "尚無推送目標,點擊「新增目標」建立。",
+    "created": "目標已新增",
+    "updated": "目標已更新",
+    "deleted": "目標已刪除",
+    "bindingsSaved": "綁定已儲存"
+  },
+  "targetDialog": {
+    "createTitle": "新增推送目標",
+    "editTitle": "編輯推送目標",
+    "name": "目標名稱",
+    "namePlaceholder": "例如:運維群",
+    "type": "平台類型",
+    "selectType": "請選擇平台類型",
+    "enable": "啟用",
+    "webhookUrl": "Webhook URL",
+    "webhookUrlPlaceholder": "https://example.com/webhook",
+    "telegramBotToken": "Telegram Bot Token",
+    "telegramBotTokenPlaceholder": "例如:123456:ABCDEF...",
+    "telegramChatId": "Telegram Chat ID",
+    "telegramChatIdPlaceholder": "例如:-1001234567890",
+    "dingtalkSecret": "釘釘簽名密鑰",
+    "dingtalkSecretPlaceholder": "可選,用於簽名",
+    "customHeaders": "自訂 Headers(JSON)",
+    "customHeadersPlaceholder": "{\"X-Token\":\"...\"}",
+    "types": {
+      "wechat": "企業微信",
+      "feishu": "飛書",
+      "dingtalk": "釘釘",
+      "telegram": "Telegram",
+      "custom": "自訂 Webhook"
     },
-    "circuitBreaker": {
-      "title": "熔斷器告警",
-      "description": "供應商完全熔斷時立即推送告警訊息",
+    "proxy": {
+      "title": "代理設定",
+      "toggle": "展開/收起代理設定",
+      "url": "代理位址",
+      "urlPlaceholder": "http://127.0.0.1:7890",
+      "fallbackToDirect": "代理失敗時回退直連"
+    }
+  },
+  "bindings": {
+    "title": "綁定",
+    "noTargets": "尚無可用推送目標",
+    "bindTarget": "綁定目標",
+    "enable": "啟用",
+    "enableType": "啟用此通知",
+    "advanced": "進階",
+    "scheduleCron": "Cron 表達式",
+    "scheduleCronPlaceholder": "例如:0 9 * * *",
+    "scheduleTimezone": "時區",
+    "templateOverride": "模板覆蓋",
+    "editTemplateOverride": "編輯覆蓋",
+    "templateOverrideTitle": "編輯模板覆蓋",
+    "boundCount": "已綁定:{count}",
+    "enabledCount": "已啟用:{count}"
+  },
+  "templateEditor": {
+    "title": "模板(JSON)",
+    "placeholder": "請輸入 JSON 模板...",
+    "jsonInvalid": "JSON 格式不正確",
+    "placeholders": "佔位符",
+    "insert": "插入"
+  },
+  "circuitBreaker": {
+    "title": "熔斷器告警",
+    "description": "供應商完全熔斷時立即推送告警訊息",
       "enable": "啟用熔斷器告警",
       "webhook": "Webhook URL",
       "webhookPlaceholder": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=...",

+ 1 - 0
package.json

@@ -16,6 +16,7 @@
     "test": "vitest run",
     "test:ui": "vitest --ui --watch",
     "test:e2e": "vitest run --config vitest.e2e.config.ts --reporter=verbose",
+    "test:integration": "vitest run --config vitest.integration.config.ts --reporter=verbose",
     "test:coverage": "vitest run --coverage",
     "test:ci": "vitest run --reporter=default --reporter=junit --outputFile.junit=reports/vitest-junit.xml",
     "cui": "npx cui-server --host 0.0.0.0 --port 30000 --token a7564bc8882aa9a2d25d8b4ea6ea1e2e",

+ 66 - 0
src/actions/notification-bindings.ts

@@ -0,0 +1,66 @@
+"use server";
+
+import { z } from "zod";
+import { getSession } from "@/lib/auth";
+import { logger } from "@/lib/logger";
+import { scheduleNotifications } from "@/lib/notification/notification-queue";
+import {
+  type BindingInput,
+  getBindingsByType,
+  type NotificationBindingWithTarget,
+  type NotificationType,
+  upsertBindings,
+} from "@/repository/notification-bindings";
+import type { ActionResult } from "./types";
+
+const NotificationTypeSchema = z.enum(["circuit_breaker", "daily_leaderboard", "cost_alert"]);
+
+const BindingInputSchema: z.ZodType<BindingInput> = z.object({
+  targetId: z.number().int().positive(),
+  isEnabled: z.boolean().optional(),
+  scheduleCron: z.string().trim().max(100).optional().nullable(),
+  scheduleTimezone: z.string().trim().max(50).optional().nullable(),
+  templateOverride: z.record(z.string(), z.unknown()).optional().nullable(),
+});
+
+export async function getBindingsForTypeAction(
+  type: NotificationType
+): Promise<ActionResult<NotificationBindingWithTarget[]>> {
+  try {
+    const session = await getSession();
+    if (!session || session.user.role !== "admin") {
+      return { ok: false, error: "无权限访问通知绑定" };
+    }
+
+    const validatedType = NotificationTypeSchema.parse(type) as NotificationType;
+    const bindings = await getBindingsByType(validatedType);
+    return { ok: true, data: bindings };
+  } catch (error) {
+    logger.error("获取通知绑定失败:", error);
+    const message = error instanceof Error ? error.message : "获取通知绑定失败";
+    return { ok: false, error: message };
+  }
+}
+
+export async function updateBindingsAction(
+  type: NotificationType,
+  bindings: BindingInput[]
+): Promise<ActionResult<void>> {
+  try {
+    const session = await getSession();
+    if (!session || session.user.role !== "admin") {
+      return { ok: false, error: "无权限执行此操作" };
+    }
+
+    const validatedType = NotificationTypeSchema.parse(type) as NotificationType;
+    const validatedBindings = z.array(BindingInputSchema).parse(bindings);
+
+    await upsertBindings(validatedType, validatedBindings);
+    await scheduleNotifications();
+    return { ok: true, data: undefined };
+  } catch (error) {
+    logger.error("更新通知绑定失败:", error);
+    const message = error instanceof Error ? error.message : "更新通知绑定失败";
+    return { ok: false, error: message };
+  }
+}

+ 477 - 0
src/actions/webhook-targets.ts

@@ -0,0 +1,477 @@
+"use server";
+
+import { z } from "zod";
+import { getSession } from "@/lib/auth";
+import type { NotificationJobType } from "@/lib/constants/notification.constants";
+import { logger } from "@/lib/logger";
+import { isValidProxyUrl } from "@/lib/proxy-agent";
+import { WebhookNotifier } from "@/lib/webhook";
+import { buildTestMessage } from "@/lib/webhook/templates/test-messages";
+import { getNotificationSettings, updateNotificationSettings } from "@/repository/notifications";
+import {
+  createWebhookTarget,
+  deleteWebhookTarget,
+  getAllWebhookTargets,
+  getWebhookTargetById,
+  updateTestResult,
+  updateWebhookTarget,
+  type WebhookProviderType,
+  type WebhookTarget,
+} from "@/repository/webhook-targets";
+import type { ActionResult } from "./types";
+
+/**
+ * SSRF 防护:阻止访问内部/私有网络地址
+ *
+ * 说明:代理地址不做此限制(Telegram 在部分环境需要本地代理)。
+ */
+function isInternalUrl(urlString: string): boolean {
+  try {
+    const url = new URL(urlString);
+    const hostname = url.hostname.toLowerCase().replace(/\.$/, "");
+
+    if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") {
+      return true;
+    }
+
+    // 云厂商元数据服务(SSRF 高危)
+    if (
+      hostname === "metadata.google.internal" ||
+      hostname === "169.254.169.254" ||
+      hostname === "100.100.100.200"
+    ) {
+      return true;
+    }
+
+    const ipv4Match = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
+    if (ipv4Match) {
+      const [, a, b] = ipv4Match.map(Number);
+      if (a === 127) return true;
+      if (a === 10) return true;
+      if (a === 172 && b >= 16 && b <= 31) return true;
+      if (a === 192 && b === 168) return true;
+      if (a === 169 && b === 254) return true;
+      if (a === 0) return true;
+    }
+
+    const ipv6Hostname = hostname.replace(/^\[|\]$/g, "");
+    if (ipv6Hostname === "fd00:ec2::254") {
+      return true;
+    }
+    if (
+      ipv6Hostname.startsWith("::ffff:127.") ||
+      ipv6Hostname.startsWith("::ffff:10.") ||
+      ipv6Hostname.startsWith("::ffff:192.168.") ||
+      ipv6Hostname.startsWith("::ffff:0.")
+    ) {
+      return true;
+    }
+    const ipv6MappedMatch = ipv6Hostname.match(/^::ffff:172\.(\d+)\./);
+    if (ipv6MappedMatch) {
+      const secondOctet = parseInt(ipv6MappedMatch[1], 10);
+      if (secondOctet >= 16 && secondOctet <= 31) return true;
+    }
+    if (ipv6Hostname.startsWith("fc") || ipv6Hostname.startsWith("fd")) {
+      return true;
+    }
+    if (ipv6Hostname.startsWith("fe80:")) {
+      return true;
+    }
+
+    const dangerousPorts = [22, 23, 3306, 5432, 27017, 6379, 11211];
+    if (url.port && dangerousPorts.includes(parseInt(url.port, 10))) {
+      return true;
+    }
+
+    return false;
+  } catch {
+    return true;
+  }
+}
+
+function trimToNull(value: string | null | undefined): string | null {
+  const trimmed = value?.trim();
+  return trimmed ? trimmed : null;
+}
+
+function parseCustomTemplate(value: string | null | undefined): Record<string, unknown> | null {
+  const trimmed = trimToNull(value);
+  if (!trimmed) return null;
+
+  const parsed = JSON.parse(trimmed) as unknown;
+  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
+    throw new Error("自定义模板必须是 JSON 对象");
+  }
+  return parsed as Record<string, unknown>;
+}
+
+function validateProviderConfig(params: {
+  providerType: WebhookProviderType;
+  webhookUrl: string | null;
+  telegramBotToken: string | null;
+  telegramChatId: string | null;
+  customTemplate?: Record<string, unknown> | null;
+}): void {
+  const { providerType, webhookUrl, telegramBotToken, telegramChatId, customTemplate } = params;
+
+  if (providerType === "telegram") {
+    if (!telegramBotToken || !telegramChatId) {
+      throw new Error("Telegram 需要 Bot Token 和 Chat ID");
+    }
+    return;
+  }
+
+  if (!webhookUrl) {
+    throw new Error("Webhook URL 不能为空");
+  }
+  if (isInternalUrl(webhookUrl)) {
+    throw new Error("不允许访问内部网络地址");
+  }
+
+  if (providerType === "custom" && customTemplate !== undefined && !customTemplate) {
+    throw new Error("自定义 Webhook 需要配置模板");
+  }
+}
+
+const ProviderTypeSchema = z.enum(["wechat", "feishu", "dingtalk", "telegram", "custom"]);
+const NotificationTypeSchema = z.enum(["circuit_breaker", "daily_leaderboard", "cost_alert"]);
+
+export type NotificationType = z.infer<typeof NotificationTypeSchema>;
+
+const BaseTargetSchema = z.object({
+  name: z.string().trim().min(1, "目标名称不能为空").max(100, "目标名称不能超过100个字符"),
+  providerType: ProviderTypeSchema,
+
+  webhookUrl: z.string().trim().url("Webhook URL 格式不正确").optional().nullable(),
+
+  telegramBotToken: z.string().trim().min(1, "Telegram Bot Token 不能为空").optional().nullable(),
+  telegramChatId: z.string().trim().min(1, "Telegram Chat ID 不能为空").optional().nullable(),
+
+  dingtalkSecret: z.string().trim().optional().nullable(),
+
+  customTemplate: z.string().trim().optional().nullable(),
+  customHeaders: z.record(z.string(), z.string()).optional().nullable(),
+
+  proxyUrl: z.string().trim().optional().nullable(),
+  proxyFallbackToDirect: z.boolean().optional(),
+
+  isEnabled: z.boolean().optional(),
+});
+
+const UpdateTargetSchema = BaseTargetSchema.partial();
+
+function normalizeTargetInput(input: z.infer<typeof BaseTargetSchema>): {
+  name: string;
+  providerType: WebhookProviderType;
+  webhookUrl: string | null;
+  telegramBotToken: string | null;
+  telegramChatId: string | null;
+  dingtalkSecret: string | null;
+  customTemplate: Record<string, unknown> | null;
+  customHeaders: Record<string, string> | null;
+  proxyUrl: string | null;
+  proxyFallbackToDirect: boolean;
+  isEnabled: boolean;
+} {
+  const providerType = input.providerType as WebhookProviderType;
+
+  const webhookUrl = trimToNull(input.webhookUrl);
+  const telegramBotToken = trimToNull(input.telegramBotToken);
+  const telegramChatId = trimToNull(input.telegramChatId);
+  const dingtalkSecret = trimToNull(input.dingtalkSecret);
+  const proxyUrl = trimToNull(input.proxyUrl);
+
+  if (proxyUrl && !isValidProxyUrl(proxyUrl)) {
+    throw new Error("代理地址格式不正确(支持 http:// https:// socks5:// socks4://)");
+  }
+
+  validateProviderConfig({ providerType, webhookUrl, telegramBotToken, telegramChatId });
+
+  const customTemplate =
+    providerType === "custom" ? parseCustomTemplate(input.customTemplate) : null;
+  if (providerType === "custom") {
+    validateProviderConfig({
+      providerType,
+      webhookUrl,
+      telegramBotToken,
+      telegramChatId,
+      customTemplate,
+    });
+  }
+
+  return {
+    name: input.name.trim(),
+    providerType,
+    webhookUrl: providerType === "telegram" ? null : webhookUrl,
+    telegramBotToken: providerType === "telegram" ? telegramBotToken : null,
+    telegramChatId: providerType === "telegram" ? telegramChatId : null,
+    dingtalkSecret: providerType === "dingtalk" ? dingtalkSecret : null,
+    customTemplate: providerType === "custom" ? customTemplate : null,
+    customHeaders: providerType === "custom" ? (input.customHeaders ?? null) : null,
+    proxyUrl,
+    proxyFallbackToDirect: input.proxyFallbackToDirect ?? false,
+    isEnabled: input.isEnabled ?? true,
+  };
+}
+
+function normalizeTargetUpdateInput(
+  existing: WebhookTarget,
+  input: z.infer<typeof UpdateTargetSchema>
+): {
+  name: string;
+  providerType: WebhookProviderType;
+  webhookUrl: string | null;
+  telegramBotToken: string | null;
+  telegramChatId: string | null;
+  dingtalkSecret: string | null;
+  customTemplate: Record<string, unknown> | null;
+  customHeaders: Record<string, string> | null;
+  proxyUrl: string | null;
+  proxyFallbackToDirect: boolean;
+  isEnabled: boolean;
+} {
+  const providerType = (input.providerType ?? existing.providerType) as WebhookProviderType;
+
+  const webhookUrl =
+    input.webhookUrl !== undefined ? trimToNull(input.webhookUrl) : existing.webhookUrl;
+  const telegramBotToken =
+    input.telegramBotToken !== undefined
+      ? trimToNull(input.telegramBotToken)
+      : existing.telegramBotToken;
+  const telegramChatId =
+    input.telegramChatId !== undefined ? trimToNull(input.telegramChatId) : existing.telegramChatId;
+  const dingtalkSecret =
+    input.dingtalkSecret !== undefined ? trimToNull(input.dingtalkSecret) : existing.dingtalkSecret;
+  const proxyUrl = input.proxyUrl !== undefined ? trimToNull(input.proxyUrl) : existing.proxyUrl;
+
+  const customTemplate =
+    providerType === "custom"
+      ? input.customTemplate !== undefined
+        ? parseCustomTemplate(input.customTemplate)
+        : existing.customTemplate
+      : null;
+  const customHeaders =
+    providerType === "custom"
+      ? input.customHeaders !== undefined
+        ? input.customHeaders
+        : existing.customHeaders
+      : null;
+
+  if (proxyUrl && !isValidProxyUrl(proxyUrl)) {
+    throw new Error("代理地址格式不正确(支持 http:// https:// socks5:// socks4://)");
+  }
+
+  validateProviderConfig({ providerType, webhookUrl, telegramBotToken, telegramChatId });
+  if (providerType === "custom") {
+    validateProviderConfig({
+      providerType,
+      webhookUrl,
+      telegramBotToken,
+      telegramChatId,
+      customTemplate,
+    });
+  }
+
+  return {
+    name: input.name !== undefined ? input.name.trim() : existing.name,
+    providerType,
+    webhookUrl: providerType === "telegram" ? null : webhookUrl,
+    telegramBotToken: providerType === "telegram" ? telegramBotToken : null,
+    telegramChatId: providerType === "telegram" ? telegramChatId : null,
+    dingtalkSecret: providerType === "dingtalk" ? dingtalkSecret : null,
+    customTemplate: providerType === "custom" ? customTemplate : null,
+    customHeaders: providerType === "custom" ? customHeaders : null,
+    proxyUrl,
+    proxyFallbackToDirect:
+      input.proxyFallbackToDirect !== undefined
+        ? input.proxyFallbackToDirect
+        : existing.proxyFallbackToDirect,
+    isEnabled: input.isEnabled !== undefined ? input.isEnabled : existing.isEnabled,
+  };
+}
+
+function toJobType(type: NotificationType): NotificationJobType {
+  switch (type) {
+    case "circuit_breaker":
+      return "circuit-breaker";
+    case "daily_leaderboard":
+      return "daily-leaderboard";
+    case "cost_alert":
+      return "cost-alert";
+  }
+}
+
+function buildTestData(type: NotificationType): unknown {
+  switch (type) {
+    case "circuit_breaker":
+      return {
+        providerName: "测试供应商",
+        providerId: 0,
+        failureCount: 3,
+        retryAt: new Date(Date.now() + 30 * 60 * 1000).toISOString(),
+        lastError: "Connection timeout (示例错误)",
+      };
+    case "daily_leaderboard":
+      return {
+        date: new Date().toISOString().split("T")[0],
+        entries: [
+          { userId: 1, userName: "用户A", totalRequests: 150, totalCost: 12.5, totalTokens: 50000 },
+          { userId: 2, userName: "用户B", totalRequests: 120, totalCost: 10.2, totalTokens: 40000 },
+        ],
+        totalRequests: 270,
+        totalCost: 22.7,
+      };
+    case "cost_alert":
+      return {
+        targetType: "user",
+        targetName: "测试用户",
+        targetId: 0,
+        currentCost: 80,
+        quotaLimit: 100,
+        threshold: 0.8,
+        period: "本月",
+      };
+  }
+}
+
+export async function getWebhookTargetsAction(): Promise<ActionResult<WebhookTarget[]>> {
+  try {
+    const session = await getSession();
+    if (!session || session.user.role !== "admin") {
+      return { ok: false, error: "无权限访问推送目标" };
+    }
+
+    const targets = await getAllWebhookTargets();
+    return { ok: true, data: targets };
+  } catch (error) {
+    logger.error("获取推送目标失败:", error);
+    return { ok: false, error: "获取推送目标失败" };
+  }
+}
+
+export async function createWebhookTargetAction(
+  input: z.infer<typeof BaseTargetSchema>
+): Promise<ActionResult<WebhookTarget>> {
+  try {
+    const session = await getSession();
+    if (!session || session.user.role !== "admin") {
+      return { ok: false, error: "无权限执行此操作" };
+    }
+
+    const validated = BaseTargetSchema.parse(input);
+    const normalized = normalizeTargetInput(validated);
+
+    const created = await createWebhookTarget(normalized);
+
+    // 数据迁移策略:当创建第一个 webhook_target 时,自动切换到新模式
+    const settings = await getNotificationSettings();
+    if (settings.useLegacyMode) {
+      await updateNotificationSettings({ useLegacyMode: false });
+    }
+
+    return { ok: true, data: created };
+  } catch (error) {
+    logger.error("创建推送目标失败:", error);
+    const message = error instanceof Error ? error.message : "创建推送目标失败";
+    return { ok: false, error: message };
+  }
+}
+
+export async function updateWebhookTargetAction(
+  id: number,
+  input: z.infer<typeof UpdateTargetSchema>
+): Promise<ActionResult<WebhookTarget>> {
+  try {
+    const session = await getSession();
+    if (!session || session.user.role !== "admin") {
+      return { ok: false, error: "无权限执行此操作" };
+    }
+
+    const existing = await getWebhookTargetById(id);
+    if (!existing) {
+      return { ok: false, error: "推送目标不存在" };
+    }
+
+    const validated = UpdateTargetSchema.parse(input);
+    const normalized = normalizeTargetUpdateInput(existing, validated);
+
+    const updated = await updateWebhookTarget(id, normalized);
+    return { ok: true, data: updated };
+  } catch (error) {
+    logger.error("更新推送目标失败:", error);
+    const message = error instanceof Error ? error.message : "更新推送目标失败";
+    return { ok: false, error: message };
+  }
+}
+
+export async function deleteWebhookTargetAction(id: number): Promise<ActionResult<void>> {
+  try {
+    const session = await getSession();
+    if (!session || session.user.role !== "admin") {
+      return { ok: false, error: "无权限执行此操作" };
+    }
+
+    await deleteWebhookTarget(id);
+    return { ok: true, data: undefined };
+  } catch (error) {
+    logger.error("删除推送目标失败:", error);
+    const message = error instanceof Error ? error.message : "删除推送目标失败";
+    return { ok: false, error: message };
+  }
+}
+
+export async function testWebhookTargetAction(
+  id: number,
+  notificationType: NotificationType
+): Promise<ActionResult<{ latencyMs: number }>> {
+  const start = Date.now();
+
+  try {
+    const session = await getSession();
+    if (!session || session.user.role !== "admin") {
+      return { ok: false, error: "无权限执行此操作" };
+    }
+
+    const target = await getWebhookTargetById(id);
+    if (!target) {
+      return { ok: false, error: "推送目标不存在" };
+    }
+
+    const validatedType = NotificationTypeSchema.parse(notificationType);
+    const testMessage = buildTestMessage(toJobType(validatedType));
+
+    const notifier = new WebhookNotifier(target);
+    const result = await notifier.send(testMessage, {
+      notificationType: validatedType,
+      data: buildTestData(validatedType),
+    });
+
+    const latencyMs = Date.now() - start;
+    await updateTestResult(id, {
+      success: result.success,
+      error: result.error,
+      latencyMs,
+    });
+
+    if (!result.success) {
+      return { ok: false, error: result.error || "测试失败" };
+    }
+
+    return { ok: true, data: { latencyMs } };
+  } catch (error) {
+    const latencyMs = Date.now() - start;
+    try {
+      await updateTestResult(id, {
+        success: false,
+        error: error instanceof Error ? error.message : "测试失败",
+        latencyMs,
+      });
+    } catch (_e) {
+      // 忽略写回失败
+    }
+
+    logger.error("测试推送目标失败:", error);
+    const message = error instanceof Error ? error.message : "测试推送目标失败";
+    return { ok: false, error: message };
+  }
+}

+ 333 - 0
src/app/[locale]/settings/notifications/_components/binding-selector.tsx

@@ -0,0 +1,333 @@
+"use client";
+
+import { zodResolver } from "@hookform/resolvers/zod";
+import { ChevronDown, ChevronRight, Save, Settings2 } from "lucide-react";
+import { useTranslations } from "next-intl";
+import { useEffect, useMemo, useState } from "react";
+import { useForm } from "react-hook-form";
+import { toast } from "sonner";
+import { z } from "zod";
+import { Button } from "@/components/ui/button";
+import { Card } from "@/components/ui/card";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
+import {
+  Dialog,
+  DialogContent,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Switch } from "@/components/ui/switch";
+import type {
+  ClientActionResult,
+  NotificationBindingState,
+  WebhookTargetState,
+} from "../_lib/hooks";
+import type { NotificationType } from "../_lib/schemas";
+import { TemplateEditor } from "./template-editor";
+
+interface BindingSelectorProps {
+  type: NotificationType;
+  targets: WebhookTargetState[];
+  bindings: NotificationBindingState[];
+  onSave: (
+    type: NotificationType,
+    bindings: Array<{
+      targetId: number;
+      isEnabled?: boolean;
+      scheduleCron?: string | null;
+      scheduleTimezone?: string | null;
+      templateOverride?: Record<string, unknown> | null;
+    }>
+  ) => Promise<ClientActionResult<void>>;
+}
+
+const BindingFormSchema = z.object({
+  targetId: z.number().int().positive(),
+  isBound: z.boolean().default(false),
+  isEnabled: z.boolean().default(true),
+  scheduleCron: z.string().trim().optional().nullable(),
+  scheduleTimezone: z.string().trim().optional().nullable(),
+  templateOverrideJson: z.string().trim().optional().nullable(),
+});
+
+const BindingsFormSchema = z.object({ rows: z.array(BindingFormSchema) });
+
+type BindingFormValues = z.input<typeof BindingFormSchema>;
+type BindingsFormValues = z.input<typeof BindingsFormSchema>;
+
+function toJsonString(value: unknown): string {
+  if (!value) return "";
+  try {
+    return JSON.stringify(value, null, 2);
+  } catch {
+    return "";
+  }
+}
+
+function parseJsonObjectOrNull(value: string | null | undefined): Record<string, unknown> | null {
+  const trimmed = value?.trim();
+  if (!trimmed) return null;
+  const parsed = JSON.parse(trimmed) as unknown;
+  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
+    throw new Error("模板覆盖必须是 JSON 对象");
+  }
+  return parsed as Record<string, unknown>;
+}
+
+export function BindingSelector({ type, targets, bindings, onSave }: BindingSelectorProps) {
+  const t = useTranslations("settings");
+  const [expanded, setExpanded] = useState<Record<number, boolean>>({});
+  const [templateDialogOpen, setTemplateDialogOpen] = useState(false);
+  const [templateEditingTargetId, setTemplateEditingTargetId] = useState<number | null>(null);
+
+  const bindingByTargetId = useMemo(() => {
+    const map = new Map<number, NotificationBindingState>();
+    bindings.forEach((b) => map.set(b.targetId, b));
+    return map;
+  }, [bindings]);
+
+  const formValues = useMemo<BindingFormValues[]>(() => {
+    return targets.map((target) => {
+      const binding = bindingByTargetId.get(target.id);
+      return {
+        targetId: target.id,
+        isBound: Boolean(binding),
+        isEnabled: binding?.isEnabled ?? true,
+        scheduleCron: binding?.scheduleCron ?? null,
+        scheduleTimezone: binding?.scheduleTimezone ?? null,
+        templateOverrideJson: toJsonString(binding?.templateOverride),
+      };
+    });
+  }, [bindingByTargetId, targets]);
+
+  const {
+    handleSubmit,
+    reset,
+    watch,
+    setValue,
+    formState: { isDirty },
+  } = useForm<BindingsFormValues>({
+    resolver: zodResolver(BindingsFormSchema),
+    defaultValues: { rows: formValues },
+  });
+
+  useEffect(() => {
+    reset({ rows: formValues });
+  }, [formValues, reset]);
+
+  const rows = watch("rows");
+
+  const openTemplateDialog = (targetId: number) => {
+    setTemplateEditingTargetId(targetId);
+    setTemplateDialogOpen(true);
+  };
+
+  const closeTemplateDialog = () => {
+    setTemplateDialogOpen(false);
+    setTemplateEditingTargetId(null);
+  };
+
+  const templateEditingIndex = useMemo(() => {
+    if (templateEditingTargetId === null) return -1;
+    return rows.findIndex((r) => r.targetId === templateEditingTargetId);
+  }, [rows, templateEditingTargetId]);
+
+  const templateValue =
+    templateEditingIndex >= 0 ? (rows[templateEditingIndex]?.templateOverrideJson ?? "") : "";
+
+  const save = async (values: BindingsFormValues) => {
+    try {
+      const payload = values.rows
+        .filter((r) => r.isBound)
+        .map((r) => ({
+          targetId: r.targetId,
+          isEnabled: r.isEnabled,
+          scheduleCron: r.scheduleCron?.trim() ? r.scheduleCron.trim() : null,
+          scheduleTimezone: r.scheduleTimezone?.trim() ? r.scheduleTimezone.trim() : null,
+          templateOverride: parseJsonObjectOrNull(r.templateOverrideJson),
+        }));
+
+      const result = await onSave(type, payload);
+      if (!result.ok) {
+        toast.error(result.error || t("notifications.form.saveFailed"));
+        return;
+      }
+
+      toast.success(t("notifications.targets.bindingsSaved"));
+    } catch (error) {
+      toast.error(error instanceof Error ? error.message : t("notifications.form.saveFailed"));
+    }
+  };
+
+  const hasTargets = targets.length > 0;
+
+  return (
+    <div className="space-y-3">
+      {!hasTargets ? (
+        <div className="text-muted-foreground text-sm">{t("notifications.bindings.noTargets")}</div>
+      ) : (
+        <div className="grid gap-3">
+          {targets.map((target, index) => {
+            const row = rows[index];
+            const isBound = row?.isBound ?? false;
+            const canEditTemplate = target.providerType === "custom" && isBound;
+            const isRowExpanded = expanded[target.id] ?? false;
+
+            return (
+              <Card key={target.id} className="p-4">
+                <div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
+                  <div className="flex items-start gap-3">
+                    <Checkbox
+                      checked={isBound}
+                      onCheckedChange={(checked) =>
+                        setValue(`rows.${index}.isBound`, Boolean(checked), { shouldDirty: true })
+                      }
+                      aria-label={t("notifications.bindings.bindTarget")}
+                    />
+
+                    <div className="min-w-0">
+                      <div className="truncate font-medium">{target.name}</div>
+                      <div className="text-muted-foreground text-xs">
+                        {t(`notifications.targetDialog.types.${target.providerType}` as any)}
+                      </div>
+                    </div>
+                  </div>
+
+                  <div className="flex items-center gap-2 md:justify-end">
+                    <div className="flex items-center gap-2">
+                      <Label htmlFor={`binding-enabled-${type}-${target.id}`} className="text-sm">
+                        {t("notifications.bindings.enable")}
+                      </Label>
+                      <Switch
+                        id={`binding-enabled-${type}-${target.id}`}
+                        checked={row?.isEnabled ?? true}
+                        disabled={!isBound}
+                        onCheckedChange={(checked) =>
+                          setValue(`rows.${index}.isEnabled`, checked, { shouldDirty: true })
+                        }
+                      />
+                    </div>
+
+                    <Collapsible
+                      open={isRowExpanded}
+                      onOpenChange={(open) => setExpanded((p) => ({ ...p, [target.id]: open }))}
+                    >
+                      <CollapsibleTrigger asChild>
+                        <Button type="button" variant="outline" size="sm" disabled={!isBound}>
+                          <Settings2 className="mr-2 h-4 w-4" />
+                          {t("notifications.bindings.advanced")}
+                          {isRowExpanded ? (
+                            <ChevronDown className="ml-2 h-4 w-4" />
+                          ) : (
+                            <ChevronRight className="ml-2 h-4 w-4" />
+                          )}
+                        </Button>
+                      </CollapsibleTrigger>
+
+                      <CollapsibleContent className="mt-4 space-y-4">
+                        <div className="grid gap-4 md:grid-cols-2">
+                          <div className="space-y-2">
+                            <Label htmlFor={`scheduleCron-${type}-${target.id}`}>
+                              {t("notifications.bindings.scheduleCron")}
+                            </Label>
+                            <Input
+                              id={`scheduleCron-${type}-${target.id}`}
+                              value={row?.scheduleCron ?? ""}
+                              onChange={(e) =>
+                                setValue(`rows.${index}.scheduleCron`, e.target.value, {
+                                  shouldDirty: true,
+                                })
+                              }
+                              placeholder={t("notifications.bindings.scheduleCronPlaceholder")}
+                            />
+                          </div>
+                          <div className="space-y-2">
+                            <Label htmlFor={`scheduleTimezone-${type}-${target.id}`}>
+                              {t("notifications.bindings.scheduleTimezone")}
+                            </Label>
+                            <Input
+                              id={`scheduleTimezone-${type}-${target.id}`}
+                              value={row?.scheduleTimezone ?? ""}
+                              onChange={(e) =>
+                                setValue(`rows.${index}.scheduleTimezone`, e.target.value, {
+                                  shouldDirty: true,
+                                })
+                              }
+                              placeholder="Asia/Shanghai"
+                            />
+                          </div>
+                        </div>
+
+                        {target.providerType === "custom" ? (
+                          <div className="space-y-2">
+                            <Label>{t("notifications.bindings.templateOverride")}</Label>
+                            <Button
+                              type="button"
+                              variant="secondary"
+                              disabled={!canEditTemplate}
+                              onClick={() => openTemplateDialog(target.id)}
+                            >
+                              {t("notifications.bindings.editTemplateOverride")}
+                            </Button>
+                          </div>
+                        ) : null}
+                      </CollapsibleContent>
+                    </Collapsible>
+                  </div>
+                </div>
+              </Card>
+            );
+          })}
+        </div>
+      )}
+
+      <div className="flex justify-end">
+        <Button
+          type="button"
+          variant="default"
+          disabled={!hasTargets || !isDirty}
+          onClick={handleSubmit(save)}
+        >
+          <Save className="mr-2 h-4 w-4" />
+          {t("notifications.form.save")}
+        </Button>
+      </div>
+
+      <Dialog
+        open={templateDialogOpen}
+        onOpenChange={(open) => (open ? setTemplateDialogOpen(true) : closeTemplateDialog())}
+      >
+        <DialogContent className="w-full max-w-3xl max-h-[90vh] overflow-y-auto">
+          <DialogHeader>
+            <DialogTitle>{t("notifications.bindings.templateOverrideTitle")}</DialogTitle>
+          </DialogHeader>
+
+          <TemplateEditor
+            value={templateValue}
+            onChange={(v) => {
+              if (templateEditingIndex >= 0) {
+                setValue(`rows.${templateEditingIndex}.templateOverrideJson`, v, {
+                  shouldDirty: true,
+                });
+              }
+            }}
+            notificationType={type}
+          />
+
+          <DialogFooter>
+            <Button type="button" variant="secondary" onClick={closeTemplateDialog}>
+              {t("common.cancel")}
+            </Button>
+            <Button type="button" onClick={closeTemplateDialog}>
+              {t("common.confirm")}
+            </Button>
+          </DialogFooter>
+        </DialogContent>
+      </Dialog>
+    </div>
+  );
+}

+ 52 - 0
src/app/[locale]/settings/notifications/_components/global-settings-card.tsx

@@ -0,0 +1,52 @@
+"use client";
+
+import { Bell } from "lucide-react";
+import { useTranslations } from "next-intl";
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Label } from "@/components/ui/label";
+import { Switch } from "@/components/ui/switch";
+
+interface GlobalSettingsCardProps {
+  enabled: boolean;
+  useLegacyMode: boolean;
+  onEnabledChange: (enabled: boolean) => void | Promise<void>;
+}
+
+export function GlobalSettingsCard({
+  enabled,
+  useLegacyMode,
+  onEnabledChange,
+}: GlobalSettingsCardProps) {
+  const t = useTranslations("settings");
+
+  return (
+    <Card>
+      <CardHeader>
+        <CardTitle className="flex items-center gap-2">
+          <Bell className="h-5 w-5" />
+          {t("notifications.global.title")}
+        </CardTitle>
+        <CardDescription>{t("notifications.global.description")}</CardDescription>
+      </CardHeader>
+
+      <CardContent className="space-y-4">
+        <div className="flex items-center justify-between gap-4">
+          <Label htmlFor="notifications-enabled">{t("notifications.global.enable")}</Label>
+          <Switch
+            id="notifications-enabled"
+            checked={enabled}
+            onCheckedChange={(checked) => onEnabledChange(checked)}
+          />
+        </div>
+
+        {useLegacyMode ? (
+          <Alert>
+            <AlertTitle>{t("notifications.global.legacyModeTitle")}</AlertTitle>
+            <AlertDescription>{t("notifications.global.legacyModeDescription")}</AlertDescription>
+          </Alert>
+        ) : null}
+      </CardContent>
+    </Card>
+  );
+}

+ 293 - 0
src/app/[locale]/settings/notifications/_components/notification-type-card.tsx

@@ -0,0 +1,293 @@
+"use client";
+
+import { AlertTriangle, DollarSign, Loader2, TestTube, TrendingUp } from "lucide-react";
+import { useTranslations } from "next-intl";
+import type { ComponentProps } from "react";
+import { useEffect, useMemo, useRef, useState } from "react";
+import { toast } from "sonner";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Slider } from "@/components/ui/slider";
+import { Switch } from "@/components/ui/switch";
+import type {
+  ClientActionResult,
+  NotificationBindingState,
+  NotificationSettingsState,
+  WebhookTargetState,
+} from "../_lib/hooks";
+import type { NotificationType } from "../_lib/schemas";
+import { BindingSelector } from "./binding-selector";
+
+interface NotificationTypeCardProps {
+  type: NotificationType;
+  settings: NotificationSettingsState;
+  targets: WebhookTargetState[];
+  bindings: NotificationBindingState[];
+  onUpdateSettings: (
+    patch: Partial<NotificationSettingsState>
+  ) => Promise<ClientActionResult<void>>;
+  onSaveBindings: BindingSelectorProps["onSave"];
+  onTestLegacyWebhook: (
+    type: NotificationType,
+    webhookUrl: string
+  ) => Promise<ClientActionResult<void>>;
+}
+
+type BindingSelectorProps = ComponentProps<typeof BindingSelector>;
+
+function getIcon(type: NotificationType) {
+  switch (type) {
+    case "circuit_breaker":
+      return <AlertTriangle className="h-5 w-5 text-destructive" />;
+    case "daily_leaderboard":
+      return <TrendingUp className="h-5 w-5" />;
+    case "cost_alert":
+      return <DollarSign className="h-5 w-5" />;
+  }
+}
+
+export function NotificationTypeCard({
+  type,
+  settings,
+  targets,
+  bindings,
+  onUpdateSettings,
+  onSaveBindings,
+  onTestLegacyWebhook,
+}: NotificationTypeCardProps) {
+  const t = useTranslations("settings");
+
+  const meta = useMemo(() => {
+    switch (type) {
+      case "circuit_breaker":
+        return {
+          title: t("notifications.circuitBreaker.title"),
+          description: t("notifications.circuitBreaker.description"),
+          enabled: settings.circuitBreakerEnabled,
+          enabledKey: "circuitBreakerEnabled" as const,
+          enableLabel: t("notifications.circuitBreaker.enable"),
+          webhookKey: "circuitBreakerWebhook" as const,
+          webhookValue: settings.circuitBreakerWebhook,
+          webhookLabel: t("notifications.circuitBreaker.webhook"),
+          webhookPlaceholder: t("notifications.circuitBreaker.webhookPlaceholder"),
+          webhookTestLabel: t("notifications.circuitBreaker.test"),
+        };
+      case "daily_leaderboard":
+        return {
+          title: t("notifications.dailyLeaderboard.title"),
+          description: t("notifications.dailyLeaderboard.description"),
+          enabled: settings.dailyLeaderboardEnabled,
+          enabledKey: "dailyLeaderboardEnabled" as const,
+          enableLabel: t("notifications.dailyLeaderboard.enable"),
+          webhookKey: "dailyLeaderboardWebhook" as const,
+          webhookValue: settings.dailyLeaderboardWebhook,
+          webhookLabel: t("notifications.dailyLeaderboard.webhook"),
+          webhookPlaceholder: t("notifications.dailyLeaderboard.webhookPlaceholder"),
+          webhookTestLabel: t("notifications.dailyLeaderboard.test"),
+        };
+      case "cost_alert":
+        return {
+          title: t("notifications.costAlert.title"),
+          description: t("notifications.costAlert.description"),
+          enabled: settings.costAlertEnabled,
+          enabledKey: "costAlertEnabled" as const,
+          enableLabel: t("notifications.costAlert.enable"),
+          webhookKey: "costAlertWebhook" as const,
+          webhookValue: settings.costAlertWebhook,
+          webhookLabel: t("notifications.costAlert.webhook"),
+          webhookPlaceholder: t("notifications.costAlert.webhookPlaceholder"),
+          webhookTestLabel: t("notifications.costAlert.test"),
+        };
+    }
+  }, [settings, t, type]);
+
+  const enabled = meta.enabled;
+  const useLegacyMode = settings.useLegacyMode;
+
+  const bindingEnabledCount = useMemo(() => {
+    return bindings.filter((b) => b.isEnabled && b.target.isEnabled).length;
+  }, [bindings]);
+
+  const legacyWebhookInputRef = useRef<HTMLInputElement>(null);
+  const [legacyWebhookUrl, setLegacyWebhookUrl] = useState(meta.webhookValue ?? "");
+  const [isTestingLegacy, setIsTestingLegacy] = useState(false);
+
+  useEffect(() => {
+    if (
+      typeof document !== "undefined" &&
+      document.activeElement === legacyWebhookInputRef.current
+    ) {
+      return;
+    }
+    setLegacyWebhookUrl(meta.webhookValue ?? "");
+  }, [meta.webhookValue]);
+
+  const saveLegacyWebhook = async () => {
+    const patch = { [meta.webhookKey]: legacyWebhookUrl } as Partial<NotificationSettingsState>;
+    await onUpdateSettings(patch);
+  };
+
+  const testLegacyWebhook = async () => {
+    setIsTestingLegacy(true);
+    try {
+      const result = await onTestLegacyWebhook(type, legacyWebhookUrl);
+      if (result.ok) {
+        toast.success(t("notifications.form.testSuccess"));
+      } else {
+        toast.error(result.error || t("notifications.form.testFailed"));
+      }
+    } finally {
+      setIsTestingLegacy(false);
+    }
+  };
+
+  return (
+    <Card>
+      <CardHeader>
+        <CardTitle className="flex items-start justify-between gap-4">
+          <div className="flex items-center gap-2">
+            {getIcon(type)}
+            <span>{meta.title}</span>
+          </div>
+          {!useLegacyMode ? (
+            <div className="flex items-center gap-2">
+              <Badge variant="secondary">
+                {t("notifications.bindings.boundCount", { count: bindings.length })}
+              </Badge>
+              <Badge variant={bindingEnabledCount > 0 ? "default" : "secondary"}>
+                {t("notifications.bindings.enabledCount", { count: bindingEnabledCount })}
+              </Badge>
+            </div>
+          ) : null}
+        </CardTitle>
+        <CardDescription>{meta.description}</CardDescription>
+      </CardHeader>
+
+      <CardContent className="space-y-6">
+        <div className="flex items-center justify-between gap-4">
+          <Label htmlFor={`${type}-enabled`}>{meta.enableLabel}</Label>
+          <Switch
+            id={`${type}-enabled`}
+            checked={enabled}
+            disabled={!settings.enabled}
+            onCheckedChange={(checked) => onUpdateSettings({ [meta.enabledKey]: checked } as any)}
+          />
+        </div>
+
+        {useLegacyMode ? (
+          <div className="space-y-2">
+            <Label htmlFor={`${type}-legacy-webhook`}>{meta.webhookLabel}</Label>
+            <div className="flex flex-col gap-2 sm:flex-row sm:items-center">
+              <Input
+                ref={legacyWebhookInputRef}
+                id={`${type}-legacy-webhook`}
+                value={legacyWebhookUrl}
+                placeholder={meta.webhookPlaceholder}
+                disabled={!settings.enabled || !enabled}
+                onChange={(e) => setLegacyWebhookUrl(e.target.value)}
+                onBlur={saveLegacyWebhook}
+              />
+              <Button
+                type="button"
+                variant="secondary"
+                className="w-full sm:w-auto"
+                disabled={!settings.enabled || !enabled || isTestingLegacy}
+                onClick={testLegacyWebhook}
+              >
+                {isTestingLegacy ? (
+                  <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+                ) : (
+                  <TestTube className="mr-2 h-4 w-4" />
+                )}
+                {meta.webhookTestLabel}
+              </Button>
+            </div>
+          </div>
+        ) : null}
+
+        {type === "daily_leaderboard" ? (
+          <div className="grid gap-4 md:grid-cols-2">
+            <div className="space-y-2">
+              <Label htmlFor="dailyLeaderboardTime">
+                {t("notifications.dailyLeaderboard.time")}
+              </Label>
+              <Input
+                id="dailyLeaderboardTime"
+                type="time"
+                value={settings.dailyLeaderboardTime}
+                disabled={!settings.enabled || !enabled}
+                onChange={(e) => onUpdateSettings({ dailyLeaderboardTime: e.target.value })}
+              />
+            </div>
+
+            <div className="space-y-2">
+              <Label htmlFor="dailyLeaderboardTopN">
+                {t("notifications.dailyLeaderboard.topN")}
+              </Label>
+              <Input
+                id="dailyLeaderboardTopN"
+                type="number"
+                min={1}
+                max={20}
+                value={settings.dailyLeaderboardTopN}
+                disabled={!settings.enabled || !enabled}
+                onChange={(e) => onUpdateSettings({ dailyLeaderboardTopN: Number(e.target.value) })}
+              />
+            </div>
+          </div>
+        ) : null}
+
+        {type === "cost_alert" ? (
+          <div className="space-y-4">
+            <div className="space-y-2">
+              <div className="flex items-center justify-between gap-4">
+                <Label>{t("notifications.costAlert.threshold")}</Label>
+                <Badge variant="secondary">{Math.round(settings.costAlertThreshold * 100)}%</Badge>
+              </div>
+              <Slider
+                value={[settings.costAlertThreshold]}
+                min={0.5}
+                max={1.0}
+                step={0.05}
+                disabled={!settings.enabled || !enabled}
+                onValueChange={([v]) => onUpdateSettings({ costAlertThreshold: v })}
+              />
+            </div>
+
+            <div className="space-y-2">
+              <Label htmlFor="costAlertCheckInterval">
+                {t("notifications.costAlert.interval")}
+              </Label>
+              <Input
+                id="costAlertCheckInterval"
+                type="number"
+                min={10}
+                max={1440}
+                value={settings.costAlertCheckInterval}
+                disabled={!settings.enabled || !enabled}
+                onChange={(e) =>
+                  onUpdateSettings({ costAlertCheckInterval: Number(e.target.value) })
+                }
+              />
+            </div>
+          </div>
+        ) : null}
+
+        {!useLegacyMode ? (
+          <div className="space-y-2">
+            <Label>{t("notifications.bindings.title")}</Label>
+            <BindingSelector
+              type={type}
+              targets={targets}
+              bindings={bindings}
+              onSave={onSaveBindings}
+            />
+          </div>
+        ) : null}
+      </CardContent>
+    </Card>
+  );
+}

+ 23 - 0
src/app/[locale]/settings/notifications/_components/notifications-skeleton.tsx

@@ -0,0 +1,23 @@
+"use client";
+
+import { Skeleton } from "@/components/ui/skeleton";
+
+export function NotificationsSkeleton() {
+  return (
+    <div className="space-y-6">
+      <div className="space-y-2">
+        <Skeleton className="h-8 w-64" />
+        <Skeleton className="h-4 w-96" />
+      </div>
+
+      <Skeleton className="h-36 w-full" />
+      <Skeleton className="h-64 w-full" />
+
+      <div className="grid gap-6">
+        <Skeleton className="h-56 w-full" />
+        <Skeleton className="h-56 w-full" />
+        <Skeleton className="h-56 w-full" />
+      </div>
+    </div>
+  );
+}

+ 67 - 0
src/app/[locale]/settings/notifications/_components/proxy-config-section.tsx

@@ -0,0 +1,67 @@
+"use client";
+
+import { ChevronDown, ChevronRight, Globe } from "lucide-react";
+import { useTranslations } from "next-intl";
+import { useState } from "react";
+import { Button } from "@/components/ui/button";
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Switch } from "@/components/ui/switch";
+
+interface ProxyConfigSectionProps {
+  proxyUrl: string;
+  proxyFallbackToDirect: boolean;
+  onProxyUrlChange: (value: string) => void;
+  onProxyFallbackToDirectChange: (value: boolean) => void;
+}
+
+export function ProxyConfigSection({
+  proxyUrl,
+  proxyFallbackToDirect,
+  onProxyUrlChange,
+  onProxyFallbackToDirectChange,
+}: ProxyConfigSectionProps) {
+  const t = useTranslations("settings");
+  const [open, setOpen] = useState(Boolean(proxyUrl));
+
+  return (
+    <Collapsible open={open} onOpenChange={setOpen}>
+      <div className="flex items-center justify-between gap-2">
+        <div className="flex items-center gap-2 text-sm font-medium">
+          <Globe className="h-4 w-4" />
+          {t("notifications.targetDialog.proxy.title")}
+        </div>
+        <CollapsibleTrigger asChild>
+          <Button type="button" variant="ghost" size="sm">
+            {open ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
+            <span className="sr-only">{t("notifications.targetDialog.proxy.toggle")}</span>
+          </Button>
+        </CollapsibleTrigger>
+      </div>
+
+      <CollapsibleContent className="mt-4 space-y-4">
+        <div className="space-y-2">
+          <Label htmlFor="proxyUrl">{t("notifications.targetDialog.proxy.url")}</Label>
+          <Input
+            id="proxyUrl"
+            value={proxyUrl}
+            onChange={(e) => onProxyUrlChange(e.target.value)}
+            placeholder={t("notifications.targetDialog.proxy.urlPlaceholder")}
+          />
+        </div>
+
+        <div className="flex items-center justify-between gap-4">
+          <Label htmlFor="proxyFallbackToDirect">
+            {t("notifications.targetDialog.proxy.fallbackToDirect")}
+          </Label>
+          <Switch
+            id="proxyFallbackToDirect"
+            checked={proxyFallbackToDirect}
+            onCheckedChange={(checked) => onProxyFallbackToDirectChange(checked)}
+          />
+        </div>
+      </CollapsibleContent>
+    </Collapsible>
+  );
+}

+ 118 - 0
src/app/[locale]/settings/notifications/_components/template-editor.tsx

@@ -0,0 +1,118 @@
+"use client";
+
+import { Braces, Info } from "lucide-react";
+import { useTranslations } from "next-intl";
+import { useMemo, useRef } from "react";
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
+import { Button } from "@/components/ui/button";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import { cn } from "@/lib/utils";
+import { getTemplatePlaceholders } from "@/lib/webhook/templates/placeholders";
+import type { NotificationType } from "../_lib/schemas";
+
+interface TemplateEditorProps {
+  value: string;
+  onChange: (value: string) => void;
+  notificationType?: NotificationType;
+  className?: string;
+}
+
+export function TemplateEditor({
+  value,
+  onChange,
+  notificationType,
+  className,
+}: TemplateEditorProps) {
+  const t = useTranslations("settings");
+  const textareaRef = useRef<HTMLTextAreaElement | null>(null);
+
+  const placeholders = useMemo(() => {
+    return getTemplatePlaceholders(notificationType);
+  }, [notificationType]);
+
+  const jsonError = useMemo(() => {
+    const trimmed = value.trim();
+    if (!trimmed) return null;
+    try {
+      JSON.parse(trimmed);
+      return null;
+    } catch (e) {
+      return e instanceof Error ? e.message : "JSON 格式错误";
+    }
+  }, [value]);
+
+  const insertAtCursor = (text: string) => {
+    const el = textareaRef.current;
+    if (!el) {
+      onChange(value + text);
+      return;
+    }
+
+    const start = el.selectionStart ?? value.length;
+    const end = el.selectionEnd ?? value.length;
+    const next = value.slice(0, start) + text + value.slice(end);
+    onChange(next);
+
+    // 恢复光标位置
+    requestAnimationFrame(() => {
+      el.focus();
+      const pos = start + text.length;
+      el.setSelectionRange(pos, pos);
+    });
+  };
+
+  return (
+    <div className={cn("grid gap-4 md:grid-cols-2", className)}>
+      <div className="space-y-2">
+        <Label className="flex items-center gap-2">
+          <Braces className="h-4 w-4" />
+          {t("notifications.templateEditor.title")}
+        </Label>
+        <Textarea
+          ref={textareaRef}
+          value={value}
+          onChange={(e) => onChange(e.target.value)}
+          placeholder={t("notifications.templateEditor.placeholder")}
+          className={cn("min-h-[200px] max-h-[400px] font-mono text-sm")}
+        />
+        {jsonError ? (
+          <Alert variant="destructive">
+            <AlertTitle>{t("notifications.templateEditor.jsonInvalid")}</AlertTitle>
+            <AlertDescription>{jsonError}</AlertDescription>
+          </Alert>
+        ) : null}
+      </div>
+
+      <div className="space-y-2">
+        <Label className="flex items-center gap-2">
+          <Info className="h-4 w-4" />
+          {t("notifications.templateEditor.placeholders")}
+        </Label>
+
+        <div className="max-h-[400px] overflow-auto rounded-md border p-3">
+          <div className="grid gap-2">
+            {placeholders.map((p) => (
+              <div key={p.key} className="flex items-start justify-between gap-3">
+                <div className="min-w-0">
+                  <div className="font-mono text-sm">{p.key}</div>
+                  <div className="text-muted-foreground text-xs">
+                    {p.label} · {p.description}
+                  </div>
+                </div>
+                <Button
+                  type="button"
+                  variant="secondary"
+                  size="sm"
+                  onClick={() => insertAtCursor(p.key)}
+                >
+                  {t("notifications.templateEditor.insert")}
+                </Button>
+              </div>
+            ))}
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+}

+ 80 - 0
src/app/[locale]/settings/notifications/_components/test-webhook-button.tsx

@@ -0,0 +1,80 @@
+"use client";
+
+import { Loader2, TestTube } from "lucide-react";
+import { useTranslations } from "next-intl";
+import { useMemo, useState } from "react";
+import { Button } from "@/components/ui/button";
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from "@/components/ui/select";
+import type { NotificationType } from "../_lib/schemas";
+
+interface TestWebhookButtonProps {
+  targetId: number;
+  disabled?: boolean;
+  onTest: (targetId: number, type: NotificationType) => Promise<void> | void;
+}
+
+export function TestWebhookButton({ targetId, disabled, onTest }: TestWebhookButtonProps) {
+  const t = useTranslations("settings");
+  const [type, setType] = useState<NotificationType>("circuit_breaker");
+  const [isTesting, setIsTesting] = useState(false);
+
+  const options = useMemo(
+    () => [
+      { value: "circuit_breaker" as const, label: t("notifications.circuitBreaker.title") },
+      { value: "daily_leaderboard" as const, label: t("notifications.dailyLeaderboard.title") },
+      { value: "cost_alert" as const, label: t("notifications.costAlert.title") },
+    ],
+    [t]
+  );
+
+  const handleTest = async () => {
+    setIsTesting(true);
+    try {
+      await onTest(targetId, type);
+    } finally {
+      setIsTesting(false);
+    }
+  };
+
+  return (
+    <div className="flex w-full flex-col gap-2 sm:flex-row sm:items-center sm:justify-end">
+      <Select
+        value={type}
+        onValueChange={(v) => setType(v as NotificationType)}
+        disabled={disabled || isTesting}
+      >
+        <SelectTrigger className="w-full sm:w-56">
+          <SelectValue placeholder={t("notifications.targets.testSelectType")} />
+        </SelectTrigger>
+        <SelectContent>
+          {options.map((o) => (
+            <SelectItem key={o.value} value={o.value}>
+              {o.label}
+            </SelectItem>
+          ))}
+        </SelectContent>
+      </Select>
+
+      <Button
+        type="button"
+        variant="secondary"
+        className="w-full sm:w-auto"
+        disabled={disabled || isTesting}
+        onClick={handleTest}
+      >
+        {isTesting ? (
+          <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+        ) : (
+          <TestTube className="mr-2 h-4 w-4" />
+        )}
+        {t("notifications.targets.test")}
+      </Button>
+    </div>
+  );
+}

+ 153 - 0
src/app/[locale]/settings/notifications/_components/webhook-target-card.tsx

@@ -0,0 +1,153 @@
+"use client";
+
+import { Pencil, Trash2 } from "lucide-react";
+import { useTranslations } from "next-intl";
+import { useMemo, useState } from "react";
+import {
+  AlertDialog,
+  AlertDialogAction,
+  AlertDialogCancel,
+  AlertDialogContent,
+  AlertDialogDescription,
+  AlertDialogFooter,
+  AlertDialogHeader,
+  AlertDialogTitle,
+  AlertDialogTrigger,
+} from "@/components/ui/alert-dialog";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Label } from "@/components/ui/label";
+import { Switch } from "@/components/ui/switch";
+import type { WebhookTargetState } from "../_lib/hooks";
+import { TestWebhookButton } from "./test-webhook-button";
+
+interface WebhookTargetCardProps {
+  target: WebhookTargetState;
+  onEdit: (target: WebhookTargetState) => void;
+  onDelete: (id: number) => Promise<void> | void;
+  onToggleEnabled: (id: number, enabled: boolean) => Promise<void> | void;
+  onTest: (id: number, type: any) => Promise<void> | void;
+}
+
+function formatLastTest(target: WebhookTargetState): string | null {
+  if (!target.lastTestAt) return null;
+  try {
+    const date =
+      typeof target.lastTestAt === "string" ? new Date(target.lastTestAt) : target.lastTestAt;
+    return date.toLocaleString("zh-CN", { hour12: false });
+  } catch {
+    return null;
+  }
+}
+
+export function WebhookTargetCard({
+  target,
+  onEdit,
+  onDelete,
+  onToggleEnabled,
+  onTest,
+}: WebhookTargetCardProps) {
+  const t = useTranslations("settings");
+  const [isDeleting, setIsDeleting] = useState(false);
+
+  const typeLabel = useMemo(() => {
+    return t(`notifications.targetDialog.types.${target.providerType}` as any);
+  }, [t, target.providerType]);
+
+  const lastTestText = useMemo(() => formatLastTest(target), [target]);
+  const lastTestOk = target.lastTestResult?.success;
+  const lastTestLatency = target.lastTestResult?.latencyMs;
+
+  const handleDelete = async () => {
+    setIsDeleting(true);
+    try {
+      await onDelete(target.id);
+    } finally {
+      setIsDeleting(false);
+    }
+  };
+
+  return (
+    <Card>
+      <CardHeader className="space-y-2">
+        <div className="flex flex-col justify-between gap-3 sm:flex-row sm:items-start">
+          <div className="min-w-0">
+            <CardTitle className="truncate">{target.name}</CardTitle>
+            <div className="mt-2 flex flex-wrap items-center gap-2">
+              <Badge variant="secondary">{typeLabel}</Badge>
+              <Badge variant={target.isEnabled ? "default" : "secondary"}>
+                {target.isEnabled
+                  ? t("notifications.targets.statusEnabled")
+                  : t("notifications.targets.statusDisabled")}
+              </Badge>
+              {lastTestOk !== undefined ? (
+                <Badge variant={lastTestOk ? "default" : "destructive"}>
+                  {lastTestOk
+                    ? t("notifications.targets.lastTestSuccess")
+                    : t("notifications.targets.lastTestFailed")}
+                  {lastTestLatency ? ` ${lastTestLatency}ms` : ""}
+                </Badge>
+              ) : null}
+            </div>
+          </div>
+
+          <div className="flex items-center gap-2">
+            <Button type="button" variant="outline" size="sm" onClick={() => onEdit(target)}>
+              <Pencil className="mr-2 h-4 w-4" />
+              {t("notifications.targets.edit")}
+            </Button>
+
+            <AlertDialog>
+              <AlertDialogTrigger asChild>
+                <Button type="button" variant="destructive" size="sm">
+                  <Trash2 className="mr-2 h-4 w-4" />
+                  {t("notifications.targets.delete")}
+                </Button>
+              </AlertDialogTrigger>
+              <AlertDialogContent>
+                <AlertDialogHeader>
+                  <AlertDialogTitle>
+                    {t("notifications.targets.deleteConfirmTitle")}
+                  </AlertDialogTitle>
+                  <AlertDialogDescription>
+                    {t("notifications.targets.deleteConfirm")}
+                  </AlertDialogDescription>
+                </AlertDialogHeader>
+                <AlertDialogFooter>
+                  <AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
+                  <AlertDialogAction onClick={handleDelete} disabled={isDeleting}>
+                    {t("common.confirm")}
+                  </AlertDialogAction>
+                </AlertDialogFooter>
+              </AlertDialogContent>
+            </AlertDialog>
+          </div>
+        </div>
+      </CardHeader>
+
+      <CardContent className="space-y-4">
+        <div className="flex items-center justify-between gap-4">
+          <Label htmlFor={`target-enabled-${target.id}`}>{t("notifications.targets.enable")}</Label>
+          <Switch
+            id={`target-enabled-${target.id}`}
+            checked={target.isEnabled}
+            onCheckedChange={(checked) => onToggleEnabled(target.id, checked)}
+          />
+        </div>
+
+        <div className="text-muted-foreground text-sm">
+          {lastTestText ? (
+            <span>
+              {t("notifications.targets.lastTestAt")}: {lastTestText}
+            </span>
+          ) : (
+            <span>{t("notifications.targets.lastTestNever")}</span>
+          )}
+        </div>
+
+        <TestWebhookButton targetId={target.id} disabled={!target.isEnabled} onTest={onTest} />
+      </CardContent>
+    </Card>
+  );
+}

+ 292 - 0
src/app/[locale]/settings/notifications/_components/webhook-target-dialog.tsx

@@ -0,0 +1,292 @@
+"use client";
+
+import { zodResolver } from "@hookform/resolvers/zod";
+import { Loader2, Plus, Save } from "lucide-react";
+import { useTranslations } from "next-intl";
+import { useEffect, useMemo, useState } from "react";
+import { useForm } from "react-hook-form";
+import { toast } from "sonner";
+import { Button } from "@/components/ui/button";
+import {
+  Dialog,
+  DialogContent,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from "@/components/ui/select";
+import { Separator } from "@/components/ui/separator";
+import { Switch } from "@/components/ui/switch";
+import type { WebhookTargetState } from "../_lib/hooks";
+import {
+  type WebhookProviderType,
+  WebhookProviderTypeSchema,
+  WebhookTargetFormSchema,
+  type WebhookTargetFormValues,
+} from "../_lib/schemas";
+import { ProxyConfigSection } from "./proxy-config-section";
+import { TestWebhookButton } from "./test-webhook-button";
+import { WebhookTypeForm } from "./webhook-type-form";
+
+interface WebhookTargetDialogProps {
+  mode: "create" | "edit";
+  target?: WebhookTargetState;
+  open: boolean;
+  onOpenChange: (open: boolean) => void;
+  onCreate: (input: any) => Promise<{ ok: boolean; error?: string }>;
+  onUpdate: (id: number, input: any) => Promise<{ ok: boolean; error?: string }>;
+  onTest: (
+    id: number,
+    type: any
+  ) => Promise<{ ok: boolean; error?: string; data?: { latencyMs: number } }>;
+}
+
+function toJsonString(value: unknown): string {
+  if (!value) return "";
+  try {
+    return JSON.stringify(value, null, 2);
+  } catch {
+    return "";
+  }
+}
+
+function parseHeadersJson(value: string | null | undefined): Record<string, string> | null {
+  const trimmed = value?.trim();
+  if (!trimmed) return null;
+
+  const parsed = JSON.parse(trimmed) as unknown;
+  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
+    throw new Error("Headers 必须是 JSON 对象");
+  }
+
+  const record = parsed as Record<string, unknown>;
+  const out: Record<string, string> = {};
+  for (const [k, v] of Object.entries(record)) {
+    if (typeof v !== "string") {
+      throw new Error("Headers 的值必须为字符串");
+    }
+    out[k] = v;
+  }
+
+  return out;
+}
+
+export function WebhookTargetDialog({
+  mode,
+  target,
+  open,
+  onOpenChange,
+  onCreate,
+  onUpdate,
+  onTest,
+}: WebhookTargetDialogProps) {
+  const t = useTranslations("settings");
+  const [isSubmitting, setIsSubmitting] = useState(false);
+
+  const defaultValues = useMemo<WebhookTargetFormValues>(
+    () => ({
+      name: target?.name ?? "",
+      providerType: (target?.providerType ?? "wechat") as WebhookProviderType,
+      webhookUrl: target?.webhookUrl ?? "",
+      telegramBotToken: target?.telegramBotToken ?? "",
+      telegramChatId: target?.telegramChatId ?? "",
+      dingtalkSecret: target?.dingtalkSecret ?? "",
+      customTemplate: toJsonString(target?.customTemplate),
+      customHeaders: toJsonString(target?.customHeaders),
+      proxyUrl: target?.proxyUrl ?? "",
+      proxyFallbackToDirect: target?.proxyFallbackToDirect ?? false,
+      isEnabled: target?.isEnabled ?? true,
+    }),
+    [target]
+  );
+
+  const {
+    register,
+    handleSubmit,
+    watch,
+    setValue,
+    reset,
+    formState: { errors },
+  } = useForm<WebhookTargetFormValues>({
+    resolver: zodResolver(WebhookTargetFormSchema),
+    defaultValues,
+  });
+
+  useEffect(() => {
+    if (open) {
+      reset(defaultValues);
+    }
+  }, [defaultValues, open, reset]);
+
+  const providerType = watch("providerType");
+
+  const providerTypeOptions = useMemo(
+    () => [
+      { value: "wechat" as const, label: t("notifications.targetDialog.types.wechat") },
+      { value: "feishu" as const, label: t("notifications.targetDialog.types.feishu") },
+      { value: "dingtalk" as const, label: t("notifications.targetDialog.types.dingtalk") },
+      { value: "telegram" as const, label: t("notifications.targetDialog.types.telegram") },
+      { value: "custom" as const, label: t("notifications.targetDialog.types.custom") },
+    ],
+    [t]
+  );
+
+  const submit = async (values: WebhookTargetFormValues) => {
+    setIsSubmitting(true);
+    try {
+      const normalizedType = WebhookProviderTypeSchema.parse(values.providerType);
+
+      const payload = {
+        name: values.name,
+        providerType: normalizedType,
+        webhookUrl: values.webhookUrl || null,
+        telegramBotToken: values.telegramBotToken || null,
+        telegramChatId: values.telegramChatId || null,
+        dingtalkSecret: values.dingtalkSecret || null,
+        customTemplate: values.customTemplate || null,
+        customHeaders: parseHeadersJson(values.customHeaders),
+        proxyUrl: values.proxyUrl || null,
+        proxyFallbackToDirect: values.proxyFallbackToDirect,
+        isEnabled: values.isEnabled,
+      };
+
+      const result =
+        mode === "create" ? await onCreate(payload) : await onUpdate(target!.id, payload);
+
+      if (!result.ok) {
+        toast.error(result.error || t("notifications.form.saveFailed"));
+        return;
+      }
+
+      onOpenChange(false);
+      reset();
+    } catch (error) {
+      toast.error(error instanceof Error ? error.message : t("notifications.form.saveFailed"));
+    } finally {
+      setIsSubmitting(false);
+    }
+  };
+
+  const handleTest = async (id: number, type: any) => {
+    const result = await onTest(id, type);
+    if (result.ok) {
+      toast.success(t("notifications.form.testSuccess"));
+    } else {
+      toast.error(result.error || t("notifications.form.testFailed"));
+    }
+  };
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange}>
+      <DialogContent className="w-full max-w-2xl max-h-[90vh] overflow-y-auto">
+        <DialogHeader>
+          <DialogTitle>
+            {mode === "create"
+              ? t("notifications.targetDialog.createTitle")
+              : t("notifications.targetDialog.editTitle")}
+          </DialogTitle>
+        </DialogHeader>
+
+        <form onSubmit={handleSubmit(submit)} className="space-y-6">
+          <div className="grid gap-4 md:grid-cols-2">
+            <div className="space-y-2">
+              <Label htmlFor="name">{t("notifications.targetDialog.name")}</Label>
+              <Input
+                id="name"
+                placeholder={t("notifications.targetDialog.namePlaceholder")}
+                {...register("name")}
+              />
+              {errors.name ? (
+                <p className="text-sm text-destructive">{errors.name.message as string}</p>
+              ) : null}
+            </div>
+
+            <div className="space-y-2">
+              <Label htmlFor="providerType">{t("notifications.targetDialog.type")}</Label>
+              <Select
+                value={providerType}
+                onValueChange={(v) =>
+                  setValue("providerType", v as WebhookProviderType, { shouldValidate: true })
+                }
+              >
+                <SelectTrigger id="providerType">
+                  <SelectValue placeholder={t("notifications.targetDialog.selectType")} />
+                </SelectTrigger>
+                <SelectContent>
+                  {providerTypeOptions.map((o) => (
+                    <SelectItem key={o.value} value={o.value}>
+                      {o.label}
+                    </SelectItem>
+                  ))}
+                </SelectContent>
+              </Select>
+              {errors.providerType ? (
+                <p className="text-sm text-destructive">{errors.providerType.message as string}</p>
+              ) : null}
+            </div>
+          </div>
+
+          <div className="flex items-center justify-between gap-4">
+            <Label htmlFor="isEnabled">{t("notifications.targetDialog.enable")}</Label>
+            <Switch
+              id="isEnabled"
+              checked={watch("isEnabled")}
+              onCheckedChange={(checked) => setValue("isEnabled", checked, { shouldDirty: true })}
+            />
+          </div>
+
+          <WebhookTypeForm
+            providerType={providerType}
+            register={register}
+            setValue={setValue}
+            watch={watch}
+            errors={errors}
+          />
+
+          <Separator />
+
+          <ProxyConfigSection
+            proxyUrl={watch("proxyUrl") || ""}
+            proxyFallbackToDirect={watch("proxyFallbackToDirect") ?? false}
+            onProxyUrlChange={(v) => setValue("proxyUrl", v, { shouldDirty: true })}
+            onProxyFallbackToDirectChange={(v) =>
+              setValue("proxyFallbackToDirect", v, { shouldDirty: true })
+            }
+          />
+
+          <DialogFooter className="flex flex-col gap-3 sm:flex-row sm:justify-between">
+            {mode === "edit" && target ? (
+              <TestWebhookButton
+                targetId={target.id}
+                onTest={(targetId, type) => handleTest(targetId, type)}
+              />
+            ) : (
+              <div />
+            )}
+
+            <Button type="submit" disabled={isSubmitting} className="w-full sm:w-auto">
+              {isSubmitting ? (
+                <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+              ) : mode === "create" ? (
+                <Plus className="mr-2 h-4 w-4" />
+              ) : (
+                <Save className="mr-2 h-4 w-4" />
+              )}
+              {mode === "create"
+                ? t("notifications.targets.add")
+                : t("notifications.targets.update")}
+            </Button>
+          </DialogFooter>
+        </form>
+      </DialogContent>
+    </Dialog>
+  );
+}

+ 156 - 0
src/app/[locale]/settings/notifications/_components/webhook-targets-section.tsx

@@ -0,0 +1,156 @@
+"use client";
+
+import { Plus } from "lucide-react";
+import { useTranslations } from "next-intl";
+import { useCallback, useMemo, useState } from "react";
+import { toast } from "sonner";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import type { ClientActionResult, WebhookTargetState } from "../_lib/hooks";
+import type { NotificationType } from "../_lib/schemas";
+import { WebhookTargetCard } from "./webhook-target-card";
+import { WebhookTargetDialog } from "./webhook-target-dialog";
+
+interface WebhookTargetsSectionProps {
+  targets: WebhookTargetState[];
+  onCreate: (input: any) => Promise<ClientActionResult<WebhookTargetState>>;
+  onUpdate: (id: number, input: any) => Promise<ClientActionResult<WebhookTargetState>>;
+  onDelete: (id: number) => Promise<ClientActionResult<void>>;
+  onTest: (
+    id: number,
+    type: NotificationType
+  ) => Promise<ClientActionResult<{ latencyMs: number }>>;
+}
+
+export function WebhookTargetsSection({
+  targets,
+  onCreate,
+  onUpdate,
+  onDelete,
+  onTest,
+}: WebhookTargetsSectionProps) {
+  const t = useTranslations("settings");
+  const [dialogOpen, setDialogOpen] = useState(false);
+  const [dialogMode, setDialogMode] = useState<"create" | "edit">("create");
+  const [editingTarget, setEditingTarget] = useState<WebhookTargetState | undefined>(undefined);
+
+  const openCreate = () => {
+    setDialogMode("create");
+    setEditingTarget(undefined);
+    setDialogOpen(true);
+  };
+
+  const openEdit = (target: WebhookTargetState) => {
+    setDialogMode("edit");
+    setEditingTarget(target);
+    setDialogOpen(true);
+  };
+
+  const handleCreate = useCallback(
+    async (input: any) => {
+      const result = await onCreate(input);
+      if (!result.ok) {
+        return result;
+      }
+      toast.success(t("notifications.targets.created"));
+      return result;
+    },
+    [onCreate, t]
+  );
+
+  const handleUpdate = useCallback(
+    async (id: number, input: any) => {
+      const result = await onUpdate(id, input);
+      if (!result.ok) {
+        return result;
+      }
+      toast.success(t("notifications.targets.updated"));
+      return result;
+    },
+    [onUpdate, t]
+  );
+
+  const handleDelete = useCallback(
+    async (id: number) => {
+      const result = await onDelete(id);
+      if (!result.ok) {
+        toast.error(result.error || t("notifications.form.saveFailed"));
+        return;
+      }
+      toast.success(t("notifications.targets.deleted"));
+    },
+    [onDelete, t]
+  );
+
+  const handleToggleEnabled = useCallback(
+    async (id: number, enabled: boolean) => {
+      const result = await onUpdate(id, { isEnabled: enabled });
+      if (!result.ok) {
+        toast.error(result.error || t("notifications.form.saveFailed"));
+      }
+    },
+    [onUpdate, t]
+  );
+
+  const handleTest = useCallback(
+    async (id: number, type: NotificationType) => {
+      const result = await onTest(id, type);
+      if (!result.ok) {
+        toast.error(result.error || t("notifications.form.testFailed"));
+      } else {
+        toast.success(t("notifications.form.testSuccess"));
+      }
+    },
+    [onTest, t]
+  );
+
+  const sortedTargets = useMemo(() => {
+    return [...targets].sort((a, b) => b.id - a.id);
+  }, [targets]);
+
+  return (
+    <Card>
+      <CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
+        <div>
+          <CardTitle>{t("notifications.targets.title")}</CardTitle>
+          <CardDescription>{t("notifications.targets.description")}</CardDescription>
+        </div>
+        <Button type="button" onClick={openCreate} className="w-full sm:w-auto">
+          <Plus className="mr-2 h-4 w-4" />
+          {t("notifications.targets.add")}
+        </Button>
+      </CardHeader>
+
+      <CardContent className="space-y-4">
+        {sortedTargets.length === 0 ? (
+          <div className="text-muted-foreground text-sm">
+            {t("notifications.targets.emptyHint")}
+          </div>
+        ) : (
+          <div className="grid gap-4">
+            {sortedTargets.map((target) => (
+              <WebhookTargetCard
+                key={target.id}
+                target={target}
+                onEdit={openEdit}
+                onDelete={handleDelete}
+                onToggleEnabled={handleToggleEnabled}
+                onTest={(id, type) => handleTest(id, type)}
+              />
+            ))}
+          </div>
+        )}
+      </CardContent>
+
+      <WebhookTargetDialog
+        mode={dialogMode}
+        target={editingTarget}
+        open={dialogOpen}
+        onOpenChange={setDialogOpen}
+        onCreate={handleCreate}
+        onUpdate={handleUpdate}
+        onTest={onTest as any}
+      />
+    </Card>
+  );
+}

+ 121 - 0
src/app/[locale]/settings/notifications/_components/webhook-type-form.tsx

@@ -0,0 +1,121 @@
+"use client";
+
+import { useTranslations } from "next-intl";
+import type { FieldErrors, UseFormRegister, UseFormSetValue, UseFormWatch } from "react-hook-form";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import type { WebhookProviderType, WebhookTargetFormValues } from "../_lib/schemas";
+import { TemplateEditor } from "./template-editor";
+
+interface WebhookTypeFormProps {
+  providerType: WebhookProviderType;
+  register: UseFormRegister<WebhookTargetFormValues>;
+  setValue: UseFormSetValue<WebhookTargetFormValues>;
+  watch: UseFormWatch<WebhookTargetFormValues>;
+  errors: FieldErrors<WebhookTargetFormValues>;
+}
+
+export function WebhookTypeForm({
+  providerType,
+  register,
+  setValue,
+  watch,
+  errors,
+}: WebhookTypeFormProps) {
+  const t = useTranslations("settings");
+
+  if (providerType === "telegram") {
+    return (
+      <div className="grid gap-4 md:grid-cols-2">
+        <div className="space-y-2">
+          <Label htmlFor="telegramBotToken">
+            {t("notifications.targetDialog.telegramBotToken")}
+          </Label>
+          <Input
+            id="telegramBotToken"
+            type="password"
+            placeholder={t("notifications.targetDialog.telegramBotTokenPlaceholder")}
+            {...register("telegramBotToken")}
+          />
+          {errors.telegramBotToken ? (
+            <p className="text-sm text-destructive">{errors.telegramBotToken.message as string}</p>
+          ) : null}
+        </div>
+
+        <div className="space-y-2">
+          <Label htmlFor="telegramChatId">{t("notifications.targetDialog.telegramChatId")}</Label>
+          <Input
+            id="telegramChatId"
+            placeholder={t("notifications.targetDialog.telegramChatIdPlaceholder")}
+            {...register("telegramChatId")}
+          />
+          {errors.telegramChatId ? (
+            <p className="text-sm text-destructive">{errors.telegramChatId.message as string}</p>
+          ) : null}
+        </div>
+      </div>
+    );
+  }
+
+  return (
+    <div className="space-y-4">
+      <div className="space-y-2">
+        <Label htmlFor="webhookUrl">{t("notifications.targetDialog.webhookUrl")}</Label>
+        <Input
+          id="webhookUrl"
+          placeholder={t("notifications.targetDialog.webhookUrlPlaceholder")}
+          {...register("webhookUrl")}
+        />
+        {errors.webhookUrl ? (
+          <p className="text-sm text-destructive">{errors.webhookUrl.message as string}</p>
+        ) : null}
+      </div>
+
+      {providerType === "dingtalk" ? (
+        <div className="space-y-2">
+          <Label htmlFor="dingtalkSecret">{t("notifications.targetDialog.dingtalkSecret")}</Label>
+          <Input
+            id="dingtalkSecret"
+            type="password"
+            placeholder={t("notifications.targetDialog.dingtalkSecretPlaceholder")}
+            {...register("dingtalkSecret")}
+          />
+          {errors.dingtalkSecret ? (
+            <p className="text-sm text-destructive">{errors.dingtalkSecret.message as string}</p>
+          ) : null}
+        </div>
+      ) : null}
+
+      {providerType === "custom" ? (
+        <div className="space-y-4">
+          <TemplateEditor
+            value={watch("customTemplate") || ""}
+            onChange={(v) =>
+              setValue("customTemplate", v, { shouldValidate: true, shouldDirty: true })
+            }
+          />
+
+          <div className="space-y-2">
+            <Label htmlFor="customHeaders">{t("notifications.targetDialog.customHeaders")}</Label>
+            <Textarea
+              id="customHeaders"
+              placeholder={t("notifications.targetDialog.customHeadersPlaceholder")}
+              value={watch("customHeaders") || ""}
+              onChange={(e) =>
+                setValue("customHeaders", e.target.value, {
+                  shouldValidate: true,
+                  shouldDirty: true,
+                })
+              }
+              className="min-h-[120px] font-mono text-sm"
+            />
+            {errors.customHeaders ? (
+              <p className="text-sm text-destructive">{errors.customHeaders.message as string}</p>
+            ) : null}
+          </div>
+        </div>
+      ) : null}
+    </div>
+  );
+}

+ 345 - 0
src/app/[locale]/settings/notifications/_lib/hooks.ts

@@ -0,0 +1,345 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { getBindingsForTypeAction, updateBindingsAction } from "@/actions/notification-bindings";
+import {
+  getNotificationSettingsAction,
+  testWebhookAction,
+  updateNotificationSettingsAction,
+} from "@/actions/notifications";
+import {
+  createWebhookTargetAction,
+  deleteWebhookTargetAction,
+  getWebhookTargetsAction,
+  testWebhookTargetAction,
+  updateWebhookTargetAction,
+} from "@/actions/webhook-targets";
+import type { NotificationType, WebhookProviderType } from "./schemas";
+
+export interface ClientActionResult<T> {
+  ok: boolean;
+  data?: T;
+  error?: string;
+}
+
+export interface NotificationSettingsState {
+  enabled: boolean;
+  useLegacyMode: boolean;
+
+  circuitBreakerEnabled: boolean;
+  circuitBreakerWebhook: string;
+  dailyLeaderboardEnabled: boolean;
+  dailyLeaderboardWebhook: string;
+  dailyLeaderboardTime: string;
+  dailyLeaderboardTopN: number;
+
+  costAlertEnabled: boolean;
+  costAlertWebhook: string;
+  costAlertThreshold: number;
+  costAlertCheckInterval: number;
+}
+
+export interface WebhookTestResult {
+  success: boolean;
+  error?: string;
+  latencyMs?: number;
+}
+
+export interface WebhookTargetState {
+  id: number;
+  name: string;
+  providerType: WebhookProviderType;
+
+  webhookUrl: string | null;
+  telegramBotToken: string | null;
+  telegramChatId: string | null;
+  dingtalkSecret: string | null;
+  customTemplate: Record<string, unknown> | null;
+  customHeaders: Record<string, string> | null;
+  proxyUrl: string | null;
+  proxyFallbackToDirect: boolean;
+
+  isEnabled: boolean;
+  lastTestAt: string | Date | null;
+  lastTestResult: WebhookTestResult | null;
+}
+
+export interface NotificationBindingState {
+  id: number;
+  notificationType: NotificationType;
+  targetId: number;
+  isEnabled: boolean;
+  scheduleCron: string | null;
+  scheduleTimezone: string | null;
+  templateOverride: Record<string, unknown> | null;
+  createdAt: string | Date | null;
+  target: WebhookTargetState;
+}
+
+export const NOTIFICATION_TYPES: NotificationType[] = [
+  "circuit_breaker",
+  "daily_leaderboard",
+  "cost_alert",
+];
+
+function toClientSettings(raw: any): NotificationSettingsState {
+  return {
+    enabled: Boolean(raw?.enabled),
+    useLegacyMode: Boolean(raw?.useLegacyMode),
+    circuitBreakerEnabled: Boolean(raw?.circuitBreakerEnabled),
+    circuitBreakerWebhook: raw?.circuitBreakerWebhook || "",
+    dailyLeaderboardEnabled: Boolean(raw?.dailyLeaderboardEnabled),
+    dailyLeaderboardWebhook: raw?.dailyLeaderboardWebhook || "",
+    dailyLeaderboardTime: raw?.dailyLeaderboardTime || "09:00",
+    dailyLeaderboardTopN: Number(raw?.dailyLeaderboardTopN || 5),
+    costAlertEnabled: Boolean(raw?.costAlertEnabled),
+    costAlertWebhook: raw?.costAlertWebhook || "",
+    costAlertThreshold: parseFloat(raw?.costAlertThreshold || "0.80"),
+    costAlertCheckInterval: Number(raw?.costAlertCheckInterval || 60),
+  };
+}
+
+export function useNotificationsPageData() {
+  const [settings, setSettings] = useState<NotificationSettingsState | null>(null);
+  const [targets, setTargets] = useState<WebhookTargetState[]>([]);
+  const [bindingsByType, setBindingsByType] = useState<
+    Record<NotificationType, NotificationBindingState[]>
+  >(() => ({
+    circuit_breaker: [],
+    daily_leaderboard: [],
+    cost_alert: [],
+  }));
+
+  const [isLoading, setIsLoading] = useState(true);
+  const [loadError, setLoadError] = useState<string | null>(null);
+
+  const refreshSettings = useCallback(async () => {
+    const raw = await getNotificationSettingsAction();
+    setSettings(toClientSettings(raw));
+  }, []);
+
+  const refreshTargets = useCallback(async () => {
+    const result = await getWebhookTargetsAction();
+    if (!result.ok) {
+      throw new Error(result.error || "加载推送目标失败");
+    }
+    setTargets(result.data as WebhookTargetState[]);
+  }, []);
+
+  const refreshBindingsForType = useCallback(async (type: NotificationType) => {
+    const result = await getBindingsForTypeAction(type);
+    if (!result.ok) {
+      throw new Error(result.error || "加载通知绑定失败");
+    }
+    setBindingsByType((prev) => ({ ...prev, [type]: result.data as NotificationBindingState[] }));
+  }, []);
+
+  const refreshAll = useCallback(async () => {
+    setIsLoading(true);
+    setLoadError(null);
+
+    try {
+      await Promise.all([
+        refreshSettings(),
+        refreshTargets(),
+        ...NOTIFICATION_TYPES.map((type) => refreshBindingsForType(type)),
+      ]);
+    } catch (error) {
+      setLoadError(error instanceof Error ? error.message : "加载失败");
+    } finally {
+      setIsLoading(false);
+    }
+  }, [refreshBindingsForType, refreshSettings, refreshTargets]);
+
+  useEffect(() => {
+    refreshAll();
+  }, [refreshAll]);
+
+  const updateSettings = useCallback(
+    async (patch: Partial<NotificationSettingsState>) => {
+      const result = await updateNotificationSettingsAction({
+        ...(patch.enabled !== undefined ? { enabled: patch.enabled } : {}),
+        ...(patch.useLegacyMode !== undefined ? { useLegacyMode: patch.useLegacyMode } : {}),
+        ...(patch.circuitBreakerEnabled !== undefined
+          ? { circuitBreakerEnabled: patch.circuitBreakerEnabled }
+          : {}),
+        ...(patch.circuitBreakerWebhook !== undefined
+          ? {
+              circuitBreakerWebhook: patch.circuitBreakerWebhook?.trim()
+                ? patch.circuitBreakerWebhook.trim()
+                : null,
+            }
+          : {}),
+        ...(patch.dailyLeaderboardEnabled !== undefined
+          ? { dailyLeaderboardEnabled: patch.dailyLeaderboardEnabled }
+          : {}),
+        ...(patch.dailyLeaderboardWebhook !== undefined
+          ? {
+              dailyLeaderboardWebhook: patch.dailyLeaderboardWebhook?.trim()
+                ? patch.dailyLeaderboardWebhook.trim()
+                : null,
+            }
+          : {}),
+        ...(patch.dailyLeaderboardTime !== undefined
+          ? { dailyLeaderboardTime: patch.dailyLeaderboardTime }
+          : {}),
+        ...(patch.dailyLeaderboardTopN !== undefined
+          ? { dailyLeaderboardTopN: patch.dailyLeaderboardTopN }
+          : {}),
+        ...(patch.costAlertEnabled !== undefined
+          ? { costAlertEnabled: patch.costAlertEnabled }
+          : {}),
+        ...(patch.costAlertWebhook !== undefined
+          ? {
+              costAlertWebhook: patch.costAlertWebhook?.trim()
+                ? patch.costAlertWebhook.trim()
+                : null,
+            }
+          : {}),
+        ...(patch.costAlertThreshold !== undefined
+          ? { costAlertThreshold: patch.costAlertThreshold.toString() }
+          : {}),
+        ...(patch.costAlertCheckInterval !== undefined
+          ? { costAlertCheckInterval: patch.costAlertCheckInterval }
+          : {}),
+      } as any);
+
+      if (!result.success) {
+        return { ok: false, error: result.error || "保存失败" } as ClientActionResult<void>;
+      }
+
+      if (result.data) {
+        setSettings(toClientSettings(result.data));
+      } else {
+        await refreshSettings();
+      }
+
+      return { ok: true } as ClientActionResult<void>;
+    },
+    [refreshSettings]
+  );
+
+  const testLegacyWebhook = useCallback(async (type: NotificationType, webhookUrl: string) => {
+    const trimmed = webhookUrl.trim();
+    if (!trimmed) {
+      return { ok: false, error: "请先填写 Webhook URL" } as ClientActionResult<void>;
+    }
+
+    const jobType = (() => {
+      switch (type) {
+        case "circuit_breaker":
+          return "circuit-breaker" as const;
+        case "daily_leaderboard":
+          return "daily-leaderboard" as const;
+        case "cost_alert":
+          return "cost-alert" as const;
+      }
+    })();
+
+    const result = await testWebhookAction(trimmed, jobType);
+    return result.success
+      ? ({ ok: true } as ClientActionResult<void>)
+      : ({ ok: false, error: result.error || "测试失败" } as ClientActionResult<void>);
+  }, []);
+
+  const saveBindings = useCallback(
+    async (
+      type: NotificationType,
+      bindings: Array<{
+        targetId: number;
+        isEnabled?: boolean;
+        scheduleCron?: string | null;
+        scheduleTimezone?: string | null;
+        templateOverride?: Record<string, unknown> | null;
+      }>
+    ) => {
+      const result = await updateBindingsAction(type, bindings);
+      if (result.ok) {
+        await refreshBindingsForType(type);
+      }
+      return result as ClientActionResult<void>;
+    },
+    [refreshBindingsForType]
+  );
+
+  const createTarget = useCallback(
+    async (input: any) => {
+      const result = await createWebhookTargetAction(input);
+      if (result.ok) {
+        await Promise.all([
+          refreshTargets(),
+          ...NOTIFICATION_TYPES.map((type) => refreshBindingsForType(type)),
+          refreshSettings(),
+        ]);
+      }
+      return result as ClientActionResult<WebhookTargetState>;
+    },
+    [refreshBindingsForType, refreshSettings, refreshTargets]
+  );
+
+  const updateTarget = useCallback(
+    async (id: number, input: any) => {
+      const result = await updateWebhookTargetAction(id, input);
+      if (result.ok) {
+        await Promise.all([
+          refreshTargets(),
+          ...NOTIFICATION_TYPES.map((type) => refreshBindingsForType(type)),
+        ]);
+      }
+      return result as ClientActionResult<WebhookTargetState>;
+    },
+    [refreshBindingsForType, refreshTargets]
+  );
+
+  const deleteTarget = useCallback(
+    async (id: number) => {
+      const result = await deleteWebhookTargetAction(id);
+      if (result.ok) {
+        await Promise.all([
+          refreshTargets(),
+          ...NOTIFICATION_TYPES.map((type) => refreshBindingsForType(type)),
+        ]);
+      }
+      return result as ClientActionResult<void>;
+    },
+    [refreshBindingsForType, refreshTargets]
+  );
+
+  const testTarget = useCallback(
+    async (id: number, type: NotificationType) => {
+      const result = await testWebhookTargetAction(id, type);
+      if (result.ok) {
+        await refreshTargets();
+      }
+      return result as ClientActionResult<{ latencyMs: number }>;
+    },
+    [refreshTargets]
+  );
+
+  const bindingsCount = useMemo(() => {
+    return NOTIFICATION_TYPES.reduce((acc, type) => acc + (bindingsByType[type]?.length ?? 0), 0);
+  }, [bindingsByType]);
+
+  return {
+    settings,
+    targets,
+    bindingsByType,
+    bindingsCount,
+    isLoading,
+    loadError,
+
+    refreshAll,
+    refreshSettings,
+    refreshTargets,
+    refreshBindingsForType,
+
+    updateSettings,
+    saveBindings,
+
+    createTarget,
+    updateTarget,
+    deleteTarget,
+    testTarget,
+    testLegacyWebhook,
+  };
+}

+ 133 - 0
src/app/[locale]/settings/notifications/_lib/schemas.ts

@@ -0,0 +1,133 @@
+"use client";
+
+import { z } from "zod";
+
+export const NotificationTypeSchema = z.enum([
+  "circuit_breaker",
+  "daily_leaderboard",
+  "cost_alert",
+]);
+export type NotificationType = z.infer<typeof NotificationTypeSchema>;
+
+export const WebhookProviderTypeSchema = z.enum([
+  "wechat",
+  "feishu",
+  "dingtalk",
+  "telegram",
+  "custom",
+]);
+export type WebhookProviderType = z.infer<typeof WebhookProviderTypeSchema>;
+
+export const WebhookTargetFormSchema = z
+  .object({
+    name: z.string().trim().min(1).max(100),
+    providerType: WebhookProviderTypeSchema,
+
+    webhookUrl: z.string().trim().optional().nullable(),
+
+    telegramBotToken: z.string().trim().optional().nullable(),
+    telegramChatId: z.string().trim().optional().nullable(),
+
+    dingtalkSecret: z.string().trim().optional().nullable(),
+
+    customTemplate: z.string().trim().optional().nullable(),
+    customHeaders: z.string().trim().optional().nullable(),
+
+    proxyUrl: z.string().trim().optional().nullable(),
+    proxyFallbackToDirect: z.boolean().default(false),
+
+    isEnabled: z.boolean().default(true),
+  })
+  .superRefine((value, ctx) => {
+    const webhookUrl = value.webhookUrl?.trim();
+    const telegramBotToken = value.telegramBotToken?.trim();
+    const telegramChatId = value.telegramChatId?.trim();
+
+    if (value.providerType === "telegram") {
+      if (!telegramBotToken) {
+        ctx.addIssue({
+          code: z.ZodIssueCode.custom,
+          message: "Telegram Bot Token 不能为空",
+          path: ["telegramBotToken"],
+        });
+      }
+      if (!telegramChatId) {
+        ctx.addIssue({
+          code: z.ZodIssueCode.custom,
+          message: "Telegram Chat ID 不能为空",
+          path: ["telegramChatId"],
+        });
+      }
+      return;
+    }
+
+    if (!webhookUrl) {
+      ctx.addIssue({
+        code: z.ZodIssueCode.custom,
+        message: "Webhook URL 不能为空",
+        path: ["webhookUrl"],
+      });
+      return;
+    }
+
+    try {
+      // eslint-disable-next-line no-new
+      new URL(webhookUrl);
+    } catch {
+      ctx.addIssue({
+        code: z.ZodIssueCode.custom,
+        message: "Webhook URL 格式不正确",
+        path: ["webhookUrl"],
+      });
+    }
+
+    if (value.providerType === "custom") {
+      const template = value.customTemplate?.trim();
+      if (!template) {
+        ctx.addIssue({
+          code: z.ZodIssueCode.custom,
+          message: "自定义模板不能为空",
+          path: ["customTemplate"],
+        });
+        return;
+      }
+      try {
+        const parsed = JSON.parse(template) as unknown;
+        if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
+          ctx.addIssue({
+            code: z.ZodIssueCode.custom,
+            message: "自定义模板必须是 JSON 对象",
+            path: ["customTemplate"],
+          });
+        }
+      } catch {
+        ctx.addIssue({
+          code: z.ZodIssueCode.custom,
+          message: "自定义模板不是有效 JSON",
+          path: ["customTemplate"],
+        });
+      }
+    }
+
+    const headers = value.customHeaders?.trim();
+    if (headers) {
+      try {
+        const parsed = JSON.parse(headers) as unknown;
+        if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
+          ctx.addIssue({
+            code: z.ZodIssueCode.custom,
+            message: "Headers 必须是 JSON 对象",
+            path: ["customHeaders"],
+          });
+        }
+      } catch {
+        ctx.addIssue({
+          code: z.ZodIssueCode.custom,
+          message: "Headers 不是有效 JSON",
+          path: ["customHeaders"],
+        });
+      }
+    }
+  });
+
+export type WebhookTargetFormValues = z.input<typeof WebhookTargetFormSchema>;

+ 70 - 532
src/app/[locale]/settings/notifications/page.tsx

@@ -1,549 +1,87 @@
 "use client";
 
-import { zodResolver } from "@hookform/resolvers/zod";
-import { AlertTriangle, Bell, Loader2, TestTube, TrendingUp } from "lucide-react";
 import { useTranslations } from "next-intl";
-import { useCallback, useEffect, useMemo, useState } from "react";
-import { useForm } from "react-hook-form";
 import { toast } from "sonner";
-import { z } from "zod";
-import {
-  getNotificationSettingsAction,
-  testWebhookAction,
-  updateNotificationSettingsAction,
-} from "@/actions/notifications";
-import { Badge } from "@/components/ui/badge";
-import { Button } from "@/components/ui/button";
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
-import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
-import { Separator } from "@/components/ui/separator";
-import { Skeleton } from "@/components/ui/skeleton";
-import { Slider } from "@/components/ui/slider";
-import { Switch } from "@/components/ui/switch";
-import type { NotificationJobType } from "@/lib/constants/notification.constants";
-
-/**
- * 通知设置表单 Schema
- */
-const notificationSchema = z.object({
-  enabled: z.boolean(),
-
-  // 熔断器告警
-  circuitBreakerEnabled: z.boolean(),
-  circuitBreakerWebhook: z.string().optional(),
-
-  // 每日排行榜
-  dailyLeaderboardEnabled: z.boolean(),
-  dailyLeaderboardWebhook: z.string().optional(),
-  dailyLeaderboardTime: z.string().regex(/^\d{2}:\d{2}$/, "时间格式错误,应为 HH:mm"),
-  dailyLeaderboardTopN: z.number().int().min(1).max(20),
-
-  // 成本预警
-  costAlertEnabled: z.boolean(),
-  costAlertWebhook: z.string().optional(),
-  costAlertThreshold: z.number().min(0.5).max(1.0),
-  costAlertCheckInterval: z.number().int().min(10).max(1440),
-});
-
-type NotificationFormData = z.infer<typeof notificationSchema>;
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
+import { SettingsPageHeader } from "../_components/settings-page-header";
+import { GlobalSettingsCard } from "./_components/global-settings-card";
+import { NotificationTypeCard } from "./_components/notification-type-card";
+import { NotificationsSkeleton } from "./_components/notifications-skeleton";
+import { WebhookTargetsSection } from "./_components/webhook-targets-section";
+import { NOTIFICATION_TYPES, useNotificationsPageData } from "./_lib/hooks";
 
 export default function NotificationsPage() {
   const t = useTranslations("settings");
-  const [isLoading, setIsLoading] = useState(true);
-  const [isSubmitting, setIsSubmitting] = useState(false);
-  const [testingWebhook, setTestingWebhook] = useState<NotificationJobType | null>(null);
-
   const {
-    register,
-    handleSubmit,
-    watch,
-    setValue,
-    formState: { errors },
-  } = useForm<NotificationFormData>({
-    resolver: zodResolver(notificationSchema),
-  });
-
-  const enabled = watch("enabled");
-  const circuitBreakerEnabled = watch("circuitBreakerEnabled");
-  const dailyLeaderboardEnabled = watch("dailyLeaderboardEnabled");
-  const costAlertEnabled = watch("costAlertEnabled");
-  const costAlertThreshold = watch("costAlertThreshold");
-  const costAlertWebhook = watch("costAlertWebhook");
-  const circuitBreakerWebhook = watch("circuitBreakerWebhook");
-  const dailyLeaderboardWebhook = watch("dailyLeaderboardWebhook");
-
-  // Detect webhook platform type from URL
-  const detectWebhookType = useCallback((url: string | undefined): "wechat" | "feishu" | null => {
-    if (!url) return null;
-    try {
-      const parsed = new URL(url);
-      if (parsed.hostname === "qyapi.weixin.qq.com") return "wechat";
-      if (parsed.hostname === "open.feishu.cn") return "feishu";
-      return null;
-    } catch {
-      return null;
-    }
-  }, []);
-
-  const costAlertWebhookType = useMemo(
-    () => detectWebhookType(costAlertWebhook),
-    [costAlertWebhook, detectWebhookType]
-  );
-  const circuitBreakerWebhookType = useMemo(
-    () => detectWebhookType(circuitBreakerWebhook),
-    [circuitBreakerWebhook, detectWebhookType]
-  );
-  const dailyLeaderboardWebhookType = useMemo(
-    () => detectWebhookType(dailyLeaderboardWebhook),
-    [dailyLeaderboardWebhook, detectWebhookType]
-  );
-
-  const loadSettings = useCallback(async () => {
-    try {
-      const data = await getNotificationSettingsAction();
-
-      // 设置表单默认值
-      setValue("enabled", data.enabled);
-      setValue("circuitBreakerEnabled", data.circuitBreakerEnabled);
-      setValue("circuitBreakerWebhook", data.circuitBreakerWebhook || "");
-      setValue("dailyLeaderboardEnabled", data.dailyLeaderboardEnabled);
-      setValue("dailyLeaderboardWebhook", data.dailyLeaderboardWebhook || "");
-      setValue("dailyLeaderboardTime", data.dailyLeaderboardTime || "09:00");
-      setValue("dailyLeaderboardTopN", data.dailyLeaderboardTopN || 5);
-      setValue("costAlertEnabled", data.costAlertEnabled);
-      setValue("costAlertWebhook", data.costAlertWebhook || "");
-      setValue("costAlertThreshold", parseFloat(data.costAlertThreshold || "0.80"));
-      setValue("costAlertCheckInterval", data.costAlertCheckInterval || 60);
-    } catch (error) {
-      toast.error(t("notifications.form.loadError"));
-      console.error(error);
-    } finally {
-      setIsLoading(false);
-    }
-  }, [setValue, t]);
-
-  // 加载设置
-  useEffect(() => {
-    loadSettings();
-  }, [loadSettings]);
-
-  const onSubmit = async (data: NotificationFormData) => {
-    setIsSubmitting(true);
-
-    try {
-      const result = await updateNotificationSettingsAction({
-        enabled: data.enabled,
-        circuitBreakerEnabled: data.circuitBreakerEnabled,
-        circuitBreakerWebhook: data.circuitBreakerWebhook || null,
-        dailyLeaderboardEnabled: data.dailyLeaderboardEnabled,
-        dailyLeaderboardWebhook: data.dailyLeaderboardWebhook || null,
-        dailyLeaderboardTime: data.dailyLeaderboardTime,
-        dailyLeaderboardTopN: data.dailyLeaderboardTopN,
-        costAlertEnabled: data.costAlertEnabled,
-        costAlertWebhook: data.costAlertWebhook || null,
-        costAlertThreshold: data.costAlertThreshold.toString(),
-        costAlertCheckInterval: data.costAlertCheckInterval,
-      });
-
-      if (result.success) {
-        toast.success(t("notifications.form.success"));
-        loadSettings();
-      } else {
-        toast.error(result.error || t("notifications.form.saveFailed"));
-      }
-    } catch (error) {
-      console.error("Save error:", error);
-      toast.error(t("notifications.form.saveFailed"));
-    } finally {
-      setIsSubmitting(false);
+    settings,
+    targets,
+    bindingsByType,
+    isLoading,
+    loadError,
+    updateSettings,
+    saveBindings,
+    createTarget,
+    updateTarget,
+    deleteTarget,
+    testTarget,
+    testLegacyWebhook,
+  } = useNotificationsPageData();
+
+  const handleUpdateSettings = async (patch: any) => {
+    const result = await updateSettings(patch);
+    if (!result.ok) {
+      toast.error(result.error || t("notifications.form.saveFailed"));
     }
+    return result;
   };
 
-  const handleTestWebhook = async (webhookUrl: string, type: NotificationJobType) => {
-    if (!webhookUrl || !webhookUrl.trim()) {
-      toast.error(t("notifications.form.webhookRequired"));
-      return;
-    }
-
-    setTestingWebhook(type);
-
-    try {
-      const result = await testWebhookAction(webhookUrl, type);
-
-      if (result.success) {
-        toast.success(t("notifications.form.testSuccess"));
-      } else {
-        toast.error(result.error || t("notifications.form.testFailed"));
-      }
-    } catch (error) {
-      console.error("Test error:", error);
-      toast.error(t("notifications.form.testError"));
-    } finally {
-      setTestingWebhook(null);
-    }
-  };
+  if (isLoading || !settings) {
+    return <NotificationsSkeleton />;
+  }
 
   return (
     <div className="space-y-6">
-      <div>
-        <h1 className="text-3xl font-bold">{t("notifications.title")}</h1>
-        <p className="text-muted-foreground mt-2">{t("notifications.description")}</p>
-      </div>
-
-      {isLoading ? (
-        <NotificationsSkeleton label={t("common.loading")} />
-      ) : (
-        <form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
-          {/* 全局开关 */}
-          <Card>
-            <CardHeader>
-              <CardTitle className="flex items-center gap-2">
-                <Bell className="w-5 h-5" />
-                {t("notifications.global.title")}
-              </CardTitle>
-              <CardDescription>{t("notifications.global.description")}</CardDescription>
-            </CardHeader>
-            <CardContent>
-              <div className="flex items-center justify-between">
-                <Label htmlFor="enabled">{t("notifications.global.enable")}</Label>
-                <Switch
-                  id="enabled"
-                  checked={enabled}
-                  onCheckedChange={(checked) => setValue("enabled", checked)}
-                />
-              </div>
-            </CardContent>
-          </Card>
-
-          {/* 熔断器告警配置 */}
-          <Card>
-            <CardHeader>
-              <CardTitle className="flex items-center gap-2">
-                <AlertTriangle className="w-5 h-5 text-red-500" />
-                {t("notifications.circuitBreaker.title")}
-              </CardTitle>
-              <CardDescription>{t("notifications.circuitBreaker.description")}</CardDescription>
-            </CardHeader>
-            <CardContent className="space-y-4">
-              <div className="flex items-center justify-between">
-                <Label htmlFor="circuitBreakerEnabled">
-                  {t("notifications.circuitBreaker.enable")}
-                </Label>
-                <Switch
-                  id="circuitBreakerEnabled"
-                  checked={circuitBreakerEnabled}
-                  disabled={!enabled}
-                  onCheckedChange={(checked) => setValue("circuitBreakerEnabled", checked)}
-                />
-              </div>
-
-              {circuitBreakerEnabled && (
-                <div className="space-y-4 pt-4">
-                  <Separator />
-                  <div className="space-y-2">
-                    <div className="flex items-center justify-between">
-                      <Label htmlFor="circuitBreakerWebhook">
-                        {t("notifications.circuitBreaker.webhook")}
-                      </Label>
-                      {circuitBreakerWebhookType && (
-                        <Badge variant="secondary">
-                          {circuitBreakerWebhookType === "wechat"
-                            ? t("notifications.costAlert.webhookTypeWeCom")
-                            : t("notifications.costAlert.webhookTypeFeishu")}
-                        </Badge>
-                      )}
-                    </div>
-                    <Input
-                      id="circuitBreakerWebhook"
-                      {...register("circuitBreakerWebhook")}
-                      placeholder={t("notifications.circuitBreaker.webhookPlaceholder")}
-                      disabled={!enabled}
-                    />
-                    {errors.circuitBreakerWebhook && (
-                      <p className="text-sm text-red-500">{errors.circuitBreakerWebhook.message}</p>
-                    )}
-                    {circuitBreakerWebhook && !circuitBreakerWebhookType && (
-                      <p className="text-sm text-amber-500">
-                        {t("notifications.costAlert.webhookTypeUnknown")}
-                      </p>
-                    )}
-                  </div>
-
-                  <Button
-                    type="button"
-                    variant="outline"
-                    size="sm"
-                    disabled={!enabled || testingWebhook === "circuit-breaker"}
-                    onClick={() =>
-                      handleTestWebhook(watch("circuitBreakerWebhook") || "", "circuit-breaker")
-                    }
-                  >
-                    {testingWebhook === "circuit-breaker" ? (
-                      <>
-                        <Loader2 className="w-4 h-4 mr-2 animate-spin" />
-                        {t("common.testing")}
-                      </>
-                    ) : (
-                      <>
-                        <TestTube className="w-4 h-4 mr-2" />
-                        {t("notifications.circuitBreaker.test")}
-                      </>
-                    )}
-                  </Button>
-                </div>
-              )}
-            </CardContent>
-          </Card>
-
-          {/* 每日排行榜配置 */}
-          <Card>
-            <CardHeader>
-              <CardTitle className="flex items-center gap-2">
-                <TrendingUp className="w-5 h-5 text-green-600" />
-                {t("notifications.dailyLeaderboard.title")}
-              </CardTitle>
-              <CardDescription>{t("notifications.dailyLeaderboard.description")}</CardDescription>
-            </CardHeader>
-            <CardContent className="space-y-4">
-              <div className="flex items-center justify-between">
-                <Label htmlFor="dailyLeaderboardEnabled">
-                  {t("notifications.dailyLeaderboard.enable")}
-                </Label>
-                <Switch
-                  id="dailyLeaderboardEnabled"
-                  checked={dailyLeaderboardEnabled}
-                  disabled={!enabled}
-                  onCheckedChange={(checked) => setValue("dailyLeaderboardEnabled", checked)}
-                />
-              </div>
-
-              {dailyLeaderboardEnabled && (
-                <div className="space-y-4 pt-4">
-                  <Separator />
-                  <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
-                    <div className="space-y-2">
-                      <div className="flex items-center justify-between">
-                        <Label htmlFor="dailyLeaderboardWebhook">
-                          {t("notifications.dailyLeaderboard.webhook")}
-                        </Label>
-                        {dailyLeaderboardWebhookType && (
-                          <Badge variant="secondary">
-                            {dailyLeaderboardWebhookType === "wechat"
-                              ? t("notifications.costAlert.webhookTypeWeCom")
-                              : t("notifications.costAlert.webhookTypeFeishu")}
-                          </Badge>
-                        )}
-                      </div>
-                      <Input
-                        id="dailyLeaderboardWebhook"
-                        {...register("dailyLeaderboardWebhook")}
-                        placeholder={t("notifications.dailyLeaderboard.webhookPlaceholder")}
-                        disabled={!enabled}
-                      />
-                      {errors.dailyLeaderboardWebhook && (
-                        <p className="text-sm text-red-500">
-                          {errors.dailyLeaderboardWebhook.message}
-                        </p>
-                      )}
-                      {dailyLeaderboardWebhook && !dailyLeaderboardWebhookType && (
-                        <p className="text-sm text-amber-500">
-                          {t("notifications.costAlert.webhookTypeUnknown")}
-                        </p>
-                      )}
-                    </div>
-
-                    <div className="space-y-2">
-                      <Label htmlFor="dailyLeaderboardTime">
-                        {t("notifications.dailyLeaderboard.time")}
-                      </Label>
-                      <Input
-                        id="dailyLeaderboardTime"
-                        type="time"
-                        {...register("dailyLeaderboardTime")}
-                        disabled={!enabled}
-                      />
-                    </div>
-
-                    <div className="space-y-2">
-                      <Label htmlFor="dailyLeaderboardTopN">
-                        {t("notifications.dailyLeaderboard.topN")}
-                      </Label>
-                      <Input
-                        id="dailyLeaderboardTopN"
-                        type="number"
-                        min={1}
-                        max={20}
-                        {...register("dailyLeaderboardTopN", { valueAsNumber: true })}
-                        disabled={!enabled}
-                      />
-                    </div>
-                  </div>
-
-                  <Button
-                    type="button"
-                    variant="outline"
-                    size="sm"
-                    disabled={!enabled || testingWebhook === "daily-leaderboard"}
-                    onClick={() =>
-                      handleTestWebhook(watch("dailyLeaderboardWebhook") || "", "daily-leaderboard")
-                    }
-                  >
-                    {testingWebhook === "daily-leaderboard" ? (
-                      <>
-                        <Loader2 className="w-4 h-4 mr-2 animate-spin" />
-                        {t("common.testing")}
-                      </>
-                    ) : (
-                      <>
-                        <TestTube className="w-4 h-4 mr-2" />
-                        {t("notifications.dailyLeaderboard.test")}
-                      </>
-                    )}
-                  </Button>
-                </div>
-              )}
-            </CardContent>
-          </Card>
-
-          {/* 成本预警配置 */}
-          <Card>
-            <CardHeader>
-              <CardTitle className="flex items-center gap-2">
-                <TrendingUp className="w-5 h-5 text-orange-500" />
-                {t("notifications.costAlert.title")}
-              </CardTitle>
-              <CardDescription>{t("notifications.costAlert.description")}</CardDescription>
-            </CardHeader>
-            <CardContent className="space-y-4">
-              <div className="flex items-center justify-between">
-                <Label htmlFor="costAlertEnabled">{t("notifications.costAlert.enable")}</Label>
-                <Switch
-                  id="costAlertEnabled"
-                  checked={costAlertEnabled}
-                  disabled={!enabled}
-                  onCheckedChange={(checked) => setValue("costAlertEnabled", checked)}
-                />
-              </div>
-
-              {costAlertEnabled && (
-                <div className="space-y-4 pt-4">
-                  <Separator />
-                  <div className="space-y-2">
-                    <div className="flex items-center justify-between">
-                      <Label htmlFor="costAlertWebhook">
-                        {t("notifications.costAlert.webhook")}
-                      </Label>
-                      {costAlertWebhookType && (
-                        <Badge variant="secondary">
-                          {costAlertWebhookType === "wechat"
-                            ? t("notifications.costAlert.webhookTypeWeCom")
-                            : t("notifications.costAlert.webhookTypeFeishu")}
-                        </Badge>
-                      )}
-                    </div>
-                    <Input
-                      id="costAlertWebhook"
-                      {...register("costAlertWebhook")}
-                      placeholder={t("notifications.costAlert.webhookPlaceholder")}
-                      disabled={!enabled}
-                    />
-                    {errors.costAlertWebhook && (
-                      <p className="text-sm text-red-500">{errors.costAlertWebhook.message}</p>
-                    )}
-                    {costAlertWebhook && !costAlertWebhookType && (
-                      <p className="text-sm text-amber-500">
-                        {t("notifications.costAlert.webhookTypeUnknown")}
-                      </p>
-                    )}
-                  </div>
-
-                  <div className="space-y-2">
-                    <Label>{t("notifications.costAlert.threshold")}</Label>
-                    <div className="flex items-center gap-4">
-                      <Slider
-                        value={[costAlertThreshold]}
-                        min={0.5}
-                        max={1}
-                        step={0.05}
-                        onValueChange={([value]) => setValue("costAlertThreshold", value)}
-                        disabled={!enabled}
-                        className="flex-1"
-                      />
-                      <span className="w-12 text-right text-sm font-medium">
-                        {(costAlertThreshold * 100).toFixed(0)}%
-                      </span>
-                    </div>
-                  </div>
-
-                  <div className="space-y-2">
-                    <Label htmlFor="costAlertCheckInterval">
-                      {t("notifications.costAlert.interval")}
-                    </Label>
-                    <Input
-                      id="costAlertCheckInterval"
-                      type="number"
-                      min={10}
-                      max={1440}
-                      {...register("costAlertCheckInterval", { valueAsNumber: true })}
-                      disabled={!enabled}
-                    />
-                  </div>
-
-                  <Button
-                    type="button"
-                    variant="outline"
-                    size="sm"
-                    disabled={!enabled || testingWebhook === "cost-alert"}
-                    onClick={() => handleTestWebhook(watch("costAlertWebhook") || "", "cost-alert")}
-                  >
-                    {testingWebhook === "cost-alert" ? (
-                      <>
-                        <Loader2 className="w-4 h-4 mr-2 animate-spin" />
-                        {t("common.testing")}
-                      </>
-                    ) : (
-                      <>
-                        <TestTube className="w-4 h-4 mr-2" />
-                        {t("notifications.costAlert.test")}
-                      </>
-                    )}
-                  </Button>
-                </div>
-              )}
-            </CardContent>
-          </Card>
-
-          <div className="flex justify-end">
-            <Button type="submit" disabled={isSubmitting}>
-              {isSubmitting ? t("notifications.form.saving") : t("notifications.form.save")}
-            </Button>
-          </div>
-        </form>
-      )}
-    </div>
-  );
-}
-
-function NotificationsSkeleton({ label }: { label: string }) {
-  return (
-    <div className="space-y-6" aria-busy="true">
-      {Array.from({ length: 3 }).map((_, index) => (
-        <Card key={index}>
-          <CardHeader>
-            <Skeleton className="h-5 w-48" />
-            <Skeleton className="h-4 w-64" />
-          </CardHeader>
-          <CardContent className="space-y-3">
-            <Skeleton className="h-4 w-full" />
-            <Skeleton className="h-9 w-full" />
-            <Skeleton className="h-9 w-40" />
-          </CardContent>
-        </Card>
-      ))}
-      <div className="flex items-center gap-2 text-xs text-muted-foreground">
-        <Loader2 className="h-3 w-3 animate-spin" />
-        <span>{label}</span>
+      <SettingsPageHeader
+        title={t("notifications.title")}
+        description={t("notifications.description")}
+      />
+
+      {loadError ? (
+        <Alert variant="destructive">
+          <AlertTitle>{t("notifications.form.loadError")}</AlertTitle>
+          <AlertDescription>{loadError}</AlertDescription>
+        </Alert>
+      ) : null}
+
+      <GlobalSettingsCard
+        enabled={settings.enabled}
+        useLegacyMode={settings.useLegacyMode}
+        onEnabledChange={async (enabled) => {
+          await handleUpdateSettings({ enabled });
+        }}
+      />
+
+      <WebhookTargetsSection
+        targets={targets}
+        onCreate={createTarget}
+        onUpdate={updateTarget}
+        onDelete={deleteTarget}
+        onTest={testTarget as any}
+      />
+
+      <div className="grid gap-6">
+        {NOTIFICATION_TYPES.map((type) => (
+          <NotificationTypeCard
+            key={type}
+            type={type}
+            settings={settings}
+            targets={targets}
+            bindings={bindingsByType[type]}
+            onUpdateSettings={handleUpdateSettings}
+            onSaveBindings={saveBindings}
+            onTestLegacyWebhook={testLegacyWebhook}
+          />
+        ))}
       </div>
     </div>
   );

+ 248 - 7
src/app/api/actions/[...route]/route.ts

@@ -20,6 +20,7 @@ import { z } from "zod";
 import * as activeSessionActions from "@/actions/active-sessions";
 import * as keyActions from "@/actions/keys";
 import * as modelPriceActions from "@/actions/model-prices";
+import * as notificationBindingActions from "@/actions/notification-bindings";
 import * as notificationActions from "@/actions/notifications";
 import * as overviewActions from "@/actions/overview";
 import * as providerActions from "@/actions/providers";
@@ -28,6 +29,7 @@ import * as statisticsActions from "@/actions/statistics";
 import * as usageLogActions from "@/actions/usage-logs";
 // 导入 actions
 import * as userActions from "@/actions/users";
+import * as webhookTargetActions from "@/actions/webhook-targets";
 import { createActionRoute } from "@/lib/api/action-adapter-openapi";
 import { NOTIFICATION_JOB_TYPES } from "@/lib/constants/notification.constants";
 // 导入 validation schemas
@@ -777,8 +779,8 @@ const { route: getNotificationSettingsRoute, handler: getNotificationSettingsHan
     notificationActions.getNotificationSettingsAction,
     {
       requestSchema: z.object({}).describe("无需请求参数"),
-      description: "获取通知设置",
       summary: "获取通知设置",
+      description: "获取通知系统的全局开关与各类型通知配置(含 legacy 模式字段)",
       tags: ["通知管理"],
       requiredRole: "admin",
     }
@@ -789,14 +791,55 @@ const { route: updateNotificationSettingsRoute, handler: updateNotificationSetti
   createActionRoute(
     "notifications",
     "updateNotificationSettingsAction",
-    notificationActions.updateNotificationSettingsAction,
+    async (payload) => {
+      const result = await notificationActions.updateNotificationSettingsAction(payload);
+      return result.success
+        ? { ok: true, data: result.data }
+        : { ok: false, error: result.error || "更新通知设置失败" };
+    },
     {
       requestSchema: z.object({
-        webhookUrl: z.string().url().optional(),
-        enabledEvents: z.array(z.string()).optional(),
+        enabled: z.boolean().optional().describe("通知总开关"),
+        useLegacyMode: z.boolean().optional().describe("是否启用旧版单 Webhook 模式"),
+
+        circuitBreakerEnabled: z.boolean().optional().describe("是否启用熔断告警"),
+        circuitBreakerWebhook: z
+          .string()
+          .url()
+          .nullable()
+          .optional()
+          .describe("熔断告警 Webhook URL"),
+
+        dailyLeaderboardEnabled: z.boolean().optional().describe("是否启用每日排行榜"),
+        dailyLeaderboardWebhook: z
+          .string()
+          .url()
+          .nullable()
+          .optional()
+          .describe("每日排行榜 Webhook URL(旧版模式)"),
+        dailyLeaderboardTime: z.string().optional().describe("每日排行榜发送时间(HH:mm)"),
+        dailyLeaderboardTopN: z.number().int().positive().optional().describe("每日排行榜 TopN"),
+
+        costAlertEnabled: z.boolean().optional().describe("是否启用成本预警"),
+        costAlertWebhook: z
+          .string()
+          .url()
+          .nullable()
+          .optional()
+          .describe("成本预警 Webhook URL(旧版模式)"),
+        costAlertThreshold: z
+          .string()
+          .optional()
+          .describe("成本预警阈值(numeric 字段以 string 表示)"),
+        costAlertCheckInterval: z
+          .number()
+          .int()
+          .positive()
+          .optional()
+          .describe("成本预警检查间隔(分钟)"),
       }),
-      description: "更新通知设置",
       summary: "更新通知设置",
+      description: "更新通知开关与各类型通知配置(生产环境会触发重新调度定时任务)",
       tags: ["通知管理"],
       requiredRole: "admin",
     }
@@ -806,20 +849,218 @@ app.openapi(updateNotificationSettingsRoute, updateNotificationSettingsHandler);
 const { route: testWebhookRoute, handler: testWebhookHandler } = createActionRoute(
   "notifications",
   "testWebhookAction",
-  notificationActions.testWebhookAction,
+  async (webhookUrl, type) => {
+    const result = await notificationActions.testWebhookAction(webhookUrl, type);
+    return result.success ? { ok: true } : { ok: false, error: result.error || "测试失败" };
+  },
   {
     requestSchema: z.object({
       webhookUrl: z.string().url(),
       type: z.enum(NOTIFICATION_JOB_TYPES),
     }),
-    description: "测试 Webhook 配置",
     summary: "测试 Webhook 配置",
+    description: "向指定 Webhook URL 发送测试消息,用于验证连通性与格式",
     tags: ["通知管理"],
     requiredRole: "admin",
+    argsMapper: (body) => [body.webhookUrl, body.type],
   }
 );
 app.openapi(testWebhookRoute, testWebhookHandler);
 
+// ==================== Webhook 目标管理 ====================
+
+const WebhookProviderTypeSchema = z.enum(["wechat", "feishu", "dingtalk", "telegram", "custom"]);
+const WebhookNotificationTypeSchema = z.enum([
+  "circuit_breaker",
+  "daily_leaderboard",
+  "cost_alert",
+]);
+
+const WebhookTargetSchema = z.object({
+  id: z.number().int().positive().describe("目标 ID"),
+  name: z.string().describe("目标名称"),
+  providerType: WebhookProviderTypeSchema.describe("推送平台类型"),
+  webhookUrl: z.string().nullable().describe("Webhook URL(Telegram 为空)"),
+  telegramBotToken: z.string().nullable().describe("Telegram Bot Token"),
+  telegramChatId: z.string().nullable().describe("Telegram Chat ID"),
+  dingtalkSecret: z.string().nullable().describe("钉钉签名密钥"),
+  customTemplate: z.record(z.string(), z.unknown()).nullable().describe("自定义模板(JSON 对象)"),
+  customHeaders: z.record(z.string(), z.string()).nullable().describe("自定义请求头"),
+  proxyUrl: z.string().nullable().describe("代理地址"),
+  proxyFallbackToDirect: z.boolean().describe("代理失败是否降级直连"),
+  isEnabled: z.boolean().describe("是否启用"),
+  lastTestAt: z.string().nullable().describe("最后测试时间"),
+  lastTestResult: z
+    .object({
+      success: z.boolean(),
+      error: z.string().optional(),
+      latencyMs: z.number().optional(),
+    })
+    .nullable()
+    .describe("最后测试结果"),
+  createdAt: z.string().describe("创建时间"),
+  updatedAt: z.string().describe("更新时间"),
+});
+
+const WebhookTargetCreateSchema = z.object({
+  name: z.string().trim().min(1).max(100),
+  providerType: WebhookProviderTypeSchema,
+  webhookUrl: z.string().trim().url().optional().nullable(),
+  telegramBotToken: z.string().trim().optional().nullable(),
+  telegramChatId: z.string().trim().optional().nullable(),
+  dingtalkSecret: z.string().trim().optional().nullable(),
+  customTemplate: z.string().trim().optional().nullable(),
+  customHeaders: z.record(z.string(), z.string()).optional().nullable(),
+  proxyUrl: z.string().trim().optional().nullable(),
+  proxyFallbackToDirect: z.boolean().optional(),
+  isEnabled: z.boolean().optional(),
+});
+
+const WebhookTargetUpdateSchema = WebhookTargetCreateSchema.partial();
+
+const { route: getWebhookTargetsRoute, handler: getWebhookTargetsHandler } = createActionRoute(
+  "webhook-targets",
+  "getWebhookTargetsAction",
+  webhookTargetActions.getWebhookTargetsAction,
+  {
+    requestSchema: z.object({}).describe("无需请求参数"),
+    responseSchema: z.array(WebhookTargetSchema),
+    summary: "获取推送目标列表",
+    description: "获取所有 Webhook 推送目标(用于通知类型绑定)",
+    tags: ["通知管理"],
+    requiredRole: "admin",
+  }
+);
+app.openapi(getWebhookTargetsRoute, getWebhookTargetsHandler);
+
+const { route: createWebhookTargetRoute, handler: createWebhookTargetHandler } = createActionRoute(
+  "webhook-targets",
+  "createWebhookTargetAction",
+  webhookTargetActions.createWebhookTargetAction,
+  {
+    requestSchema: WebhookTargetCreateSchema,
+    responseSchema: WebhookTargetSchema,
+    summary: "创建推送目标",
+    description: "创建一个新的 Webhook 推送目标(创建后可绑定到通知类型)",
+    tags: ["通知管理"],
+    requiredRole: "admin",
+  }
+);
+app.openapi(createWebhookTargetRoute, createWebhookTargetHandler);
+
+const { route: updateWebhookTargetRoute, handler: updateWebhookTargetHandler } = createActionRoute(
+  "webhook-targets",
+  "updateWebhookTargetAction",
+  webhookTargetActions.updateWebhookTargetAction,
+  {
+    requestSchema: z.object({
+      id: z.number().int().positive(),
+      input: WebhookTargetUpdateSchema,
+    }),
+    responseSchema: WebhookTargetSchema,
+    summary: "更新推送目标(支持局部更新)",
+    description: "更新指定推送目标的配置(支持仅提交变更字段)",
+    tags: ["通知管理"],
+    requiredRole: "admin",
+    argsMapper: (body) => [body.id, body.input],
+  }
+);
+app.openapi(updateWebhookTargetRoute, updateWebhookTargetHandler);
+
+const { route: deleteWebhookTargetRoute, handler: deleteWebhookTargetHandler } = createActionRoute(
+  "webhook-targets",
+  "deleteWebhookTargetAction",
+  webhookTargetActions.deleteWebhookTargetAction,
+  {
+    requestSchema: z.object({
+      id: z.number().int().positive(),
+    }),
+    summary: "删除推送目标",
+    description: "删除指定推送目标(会级联删除与该目标关联的通知绑定)",
+    tags: ["通知管理"],
+    requiredRole: "admin",
+  }
+);
+app.openapi(deleteWebhookTargetRoute, deleteWebhookTargetHandler);
+
+const { route: testWebhookTargetRoute, handler: testWebhookTargetHandler } = createActionRoute(
+  "webhook-targets",
+  "testWebhookTargetAction",
+  webhookTargetActions.testWebhookTargetAction,
+  {
+    requestSchema: z.object({
+      id: z.number().int().positive(),
+      notificationType: WebhookNotificationTypeSchema,
+    }),
+    responseSchema: z.object({
+      latencyMs: z.number().describe("耗时(毫秒)"),
+    }),
+    summary: "测试推送目标配置",
+    description: "向目标发送测试消息并记录 lastTestResult(用于 UI 展示与排查)",
+    tags: ["通知管理"],
+    requiredRole: "admin",
+    argsMapper: (body) => [body.id, body.notificationType],
+  }
+);
+app.openapi(testWebhookTargetRoute, testWebhookTargetHandler);
+
+// ==================== 通知目标绑定 ====================
+
+const NotificationBindingSchema = z.object({
+  id: z.number().int().positive().describe("绑定 ID"),
+  notificationType: WebhookNotificationTypeSchema.describe("通知类型"),
+  targetId: z.number().int().positive().describe("目标 ID"),
+  isEnabled: z.boolean().describe("是否启用"),
+  scheduleCron: z.string().nullable().describe("Cron 表达式覆盖"),
+  scheduleTimezone: z.string().nullable().describe("时区覆盖"),
+  templateOverride: z.record(z.string(), z.unknown()).nullable().describe("模板覆盖"),
+  createdAt: z.string().describe("创建时间"),
+  target: WebhookTargetSchema.describe("目标详情"),
+});
+
+const NotificationBindingInputSchema = z.object({
+  targetId: z.number().int().positive(),
+  isEnabled: z.boolean().optional(),
+  scheduleCron: z.string().trim().max(100).optional().nullable(),
+  scheduleTimezone: z.string().trim().max(50).optional().nullable(),
+  templateOverride: z.record(z.string(), z.unknown()).optional().nullable(),
+});
+
+const { route: getBindingsRoute, handler: getBindingsHandler } = createActionRoute(
+  "notification-bindings",
+  "getBindingsForTypeAction",
+  notificationBindingActions.getBindingsForTypeAction,
+  {
+    requestSchema: z.object({
+      type: WebhookNotificationTypeSchema,
+    }),
+    responseSchema: z.array(NotificationBindingSchema),
+    summary: "获取通知绑定列表",
+    description: "获取指定通知类型的目标绑定(返回包含 target 详情的列表)",
+    tags: ["通知管理"],
+    requiredRole: "admin",
+  }
+);
+app.openapi(getBindingsRoute, getBindingsHandler);
+
+const { route: updateBindingsRoute, handler: updateBindingsHandler } = createActionRoute(
+  "notification-bindings",
+  "updateBindingsAction",
+  notificationBindingActions.updateBindingsAction,
+  {
+    requestSchema: z.object({
+      type: WebhookNotificationTypeSchema,
+      bindings: z.array(NotificationBindingInputSchema),
+    }),
+    summary: "更新通知绑定",
+    description: "按通知类型批量写入绑定(缺失的绑定会被删除,已有绑定会被更新)",
+    tags: ["通知管理"],
+    requiredRole: "admin",
+    argsMapper: (body) => [body.type, body.bindings],
+  }
+);
+app.openapi(updateBindingsRoute, updateBindingsHandler);
+
 // ==================== OpenAPI 文档 ====================
 
 /**

+ 81 - 0
src/drizzle/schema.ts

@@ -16,6 +16,18 @@ import { relations, sql } from 'drizzle-orm';
 
 // Enums
 export const dailyResetModeEnum = pgEnum('daily_reset_mode', ['fixed', 'rolling']);
+export const webhookProviderTypeEnum = pgEnum('webhook_provider_type', [
+  'wechat',
+  'feishu',
+  'dingtalk',
+  'telegram',
+  'custom',
+]);
+export const notificationTypeEnum = pgEnum('notification_type', [
+  'circuit_breaker',
+  'daily_leaderboard',
+  'cost_alert',
+]);
 
 // Users table
 export const users = pgTable('users', {
@@ -460,6 +472,8 @@ export const notificationSettings = pgTable('notification_settings', {
 
   // 全局开关
   enabled: boolean('enabled').notNull().default(false),
+  // 兼容旧配置:默认使用 legacy 字段(单 URL / 自动识别),创建新目标后会切到新模式
+  useLegacyMode: boolean('use_legacy_mode').notNull().default(true),
 
   // 熔断器告警配置
   circuitBreakerEnabled: boolean('circuit_breaker_enabled').notNull().default(false),
@@ -481,6 +495,73 @@ export const notificationSettings = pgTable('notification_settings', {
   updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
 });
 
+// Webhook Targets table - 推送目标(多平台配置)
+export const webhookTargets = pgTable('webhook_targets', {
+  id: serial('id').primaryKey(),
+  name: varchar('name', { length: 100 }).notNull(),
+  providerType: webhookProviderTypeEnum('provider_type').notNull(),
+
+  // 通用配置
+  webhookUrl: varchar('webhook_url', { length: 1024 }),
+
+  // Telegram 特有配置
+  telegramBotToken: varchar('telegram_bot_token', { length: 256 }),
+  telegramChatId: varchar('telegram_chat_id', { length: 64 }),
+
+  // 钉钉签名配置
+  dingtalkSecret: varchar('dingtalk_secret', { length: 256 }),
+
+  // 自定义 Webhook 配置
+  customTemplate: jsonb('custom_template'),
+  customHeaders: jsonb('custom_headers'),
+
+  // 代理配置
+  proxyUrl: varchar('proxy_url', { length: 512 }),
+  proxyFallbackToDirect: boolean('proxy_fallback_to_direct').default(false),
+
+  // 元数据
+  isEnabled: boolean('is_enabled').notNull().default(true),
+  lastTestAt: timestamp('last_test_at', { withTimezone: true }),
+  lastTestResult: jsonb('last_test_result'),
+
+  createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
+  updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
+});
+
+// Notification Target Bindings table - 通知类型与目标绑定
+export const notificationTargetBindings = pgTable(
+  'notification_target_bindings',
+  {
+    id: serial('id').primaryKey(),
+    notificationType: notificationTypeEnum('notification_type').notNull(),
+    targetId: integer('target_id')
+      .notNull()
+      .references(() => webhookTargets.id, { onDelete: 'cascade' }),
+
+    isEnabled: boolean('is_enabled').notNull().default(true),
+
+    // 定时配置覆盖(可选,仅用于定时类通知)
+    scheduleCron: varchar('schedule_cron', { length: 100 }),
+    scheduleTimezone: varchar('schedule_timezone', { length: 50 }).default('Asia/Shanghai'),
+
+    // 模板覆盖(可选,主要用于 custom webhook)
+    templateOverride: jsonb('template_override'),
+
+    createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
+  },
+  (table) => ({
+    uniqueBinding: uniqueIndex('unique_notification_target_binding').on(
+      table.notificationType,
+      table.targetId
+    ),
+    bindingsTypeIdx: index('idx_notification_bindings_type').on(
+      table.notificationType,
+      table.isEnabled
+    ),
+    bindingsTargetIdx: index('idx_notification_bindings_target').on(table.targetId, table.isEnabled),
+  })
+);
+
 // Relations
 export const usersRelations = relations(users, ({ many }) => ({
   keys: many(keys),

+ 199 - 50
src/lib/notification/notification-queue.ts

@@ -11,6 +11,7 @@ import {
   type DailyLeaderboardData,
   type StructuredMessage,
   sendWebhookMessage,
+  type WebhookNotificationType,
 } from "@/lib/webhook";
 import { generateCostAlerts } from "./tasks/cost-alert";
 import { generateDailyLeaderboard } from "./tasks/daily-leaderboard";
@@ -20,10 +21,25 @@ import { generateDailyLeaderboard } from "./tasks/daily-leaderboard";
  */
 export interface NotificationJobData {
   type: NotificationJobType;
-  webhookUrl: string;
+  // legacy 模式使用(单 URL)
+  webhookUrl?: string;
+  // 新模式使用(多目标)
+  targetId?: number;
+  bindingId?: number;
   data?: CircuitBreakerAlertData | DailyLeaderboardData | CostAlertData; // 可选:定时任务会在执行时动态生成
 }
 
+function toWebhookNotificationType(type: NotificationJobType): WebhookNotificationType {
+  switch (type) {
+    case "circuit-breaker":
+      return "circuit_breaker";
+    case "daily-leaderboard":
+      return "daily_leaderboard";
+    case "cost-alert":
+      return "cost_alert";
+  }
+}
+
 /**
  * 队列实例(延迟初始化,避免 Turbopack 编译时加载)
  */
@@ -112,7 +128,7 @@ function setupQueueProcessor(queue: Queue.Queue<NotificationJobData>): void {
    * 处理通知任务
    */
   queue.process(async (job: Job<NotificationJobData>) => {
-    const { type, webhookUrl, data } = job.data;
+    const { type, webhookUrl, targetId, bindingId, data } = job.data;
 
     logger.info({
       action: "notification_job_start",
@@ -123,6 +139,8 @@ function setupQueueProcessor(queue: Queue.Queue<NotificationJobData>): void {
     try {
       // 构建结构化消息
       let message: StructuredMessage;
+      let templateData: CircuitBreakerAlertData | DailyLeaderboardData | CostAlertData | undefined =
+        data;
       switch (type) {
         case "circuit-breaker":
           message = buildCircuitBreakerMessage(data as CircuitBreakerAlertData);
@@ -143,6 +161,7 @@ function setupQueueProcessor(queue: Queue.Queue<NotificationJobData>): void {
             return { success: true, skipped: true };
           }
 
+          templateData = leaderboardData;
           message = buildDailyLeaderboardMessage(leaderboardData);
           break;
         }
@@ -163,6 +182,7 @@ function setupQueueProcessor(queue: Queue.Queue<NotificationJobData>): void {
           }
 
           // 发送第一个告警(后续可扩展为批量发送)
+          templateData = alerts[0];
           message = buildCostAlertMessage(alerts[0]);
           break;
         }
@@ -171,7 +191,40 @@ function setupQueueProcessor(queue: Queue.Queue<NotificationJobData>): void {
       }
 
       // 发送通知
-      const result = await sendWebhookMessage(webhookUrl, message);
+      let result;
+      if (webhookUrl) {
+        result = await sendWebhookMessage(webhookUrl, message);
+      } else if (targetId) {
+        const { getWebhookTargetById } = await import("@/repository/webhook-targets");
+        const target = await getWebhookTargetById(targetId);
+
+        if (!target || !target.isEnabled) {
+          logger.warn({
+            action: "notification_target_missing_or_disabled",
+            jobId: job.id,
+            type,
+            targetId,
+          });
+          return { success: true, skipped: true };
+        }
+
+        const notificationType = toWebhookNotificationType(type);
+
+        let templateOverride: Record<string, unknown> | null = null;
+        if (bindingId) {
+          const { getBindingById } = await import("@/repository/notification-bindings");
+          const binding = await getBindingById(bindingId);
+          templateOverride = binding?.templateOverride ?? null;
+        }
+
+        result = await sendWebhookMessage(target, message, {
+          notificationType,
+          data: templateData,
+          templateOverride,
+        });
+      } else {
+        throw new Error("Missing notification destination (webhookUrl/targetId)");
+      }
 
       if (!result.success) {
         throw new Error(result.error || "Failed to send notification");
@@ -241,6 +294,39 @@ export async function addNotificationJob(
   }
 }
 
+/**
+ * 新模式:为指定目标添加通知任务
+ */
+export async function addNotificationJobForTarget(
+  type: NotificationJobType,
+  targetId: number,
+  bindingId: number | null,
+  data: CircuitBreakerAlertData | DailyLeaderboardData | CostAlertData
+): Promise<void> {
+  try {
+    const queue = getNotificationQueue();
+    await queue.add({
+      type,
+      targetId,
+      ...(bindingId ? { bindingId } : {}),
+      data,
+    });
+
+    logger.info({
+      action: "notification_job_added",
+      type,
+      targetId,
+    });
+  } catch (error) {
+    logger.error({
+      action: "notification_job_add_error",
+      type,
+      targetId,
+      error: error instanceof Error ? error.message : String(error),
+    });
+  }
+}
+
 /**
  * 调度定时通知任务
  */
@@ -270,59 +356,122 @@ export async function scheduleNotifications() {
       await queue.removeRepeatableByKey(job.key);
     }
 
-    // 调度每日排行榜任务
-    if (
-      settings.dailyLeaderboardEnabled &&
-      settings.dailyLeaderboardWebhook &&
-      settings.dailyLeaderboardTime
-    ) {
-      const [hour, minute] = settings.dailyLeaderboardTime.split(":").map(Number);
-      const cron = `${minute} ${hour} * * *`; // 每天指定时间
-
-      await queue.add(
-        {
-          type: "daily-leaderboard",
-          webhookUrl: settings.dailyLeaderboardWebhook,
-          // data 字段省略,任务执行时动态生成
-        },
-        {
-          repeat: {
-            cron,
+    if (settings.useLegacyMode) {
+      // legacy 模式:单 URL
+      if (
+        settings.dailyLeaderboardEnabled &&
+        settings.dailyLeaderboardWebhook &&
+        settings.dailyLeaderboardTime
+      ) {
+        const [hour, minute] = settings.dailyLeaderboardTime.split(":").map(Number);
+        const cron = `${minute} ${hour} * * *`; // 每天指定时间
+
+        await queue.add(
+          {
+            type: "daily-leaderboard",
+            webhookUrl: settings.dailyLeaderboardWebhook,
+            // data 字段省略,任务执行时动态生成
           },
-          jobId: "daily-leaderboard-scheduled", // 使用 jobId 标识,便于管理
-        }
-      );
+          {
+            repeat: { cron },
+            jobId: "daily-leaderboard-scheduled",
+          }
+        );
 
-      logger.info({
-        action: "daily_leaderboard_scheduled",
-        schedule: cron,
-      });
-    }
+        logger.info({
+          action: "daily_leaderboard_scheduled",
+          schedule: cron,
+          mode: "legacy",
+        });
+      }
+
+      if (settings.costAlertEnabled && settings.costAlertWebhook) {
+        const interval = settings.costAlertCheckInterval; // 分钟
+        const cron = `*/${interval} * * * *`; // 每 N 分钟
 
-    // 调度成本预警任务
-    if (settings.costAlertEnabled && settings.costAlertWebhook) {
-      const interval = settings.costAlertCheckInterval; // 分钟
-      const cron = `*/${interval} * * * *`; // 每 N 分钟
-
-      await queue.add(
-        {
-          type: "cost-alert",
-          webhookUrl: settings.costAlertWebhook,
-          // data 字段省略,任务执行时动态生成
-        },
-        {
-          repeat: {
-            cron,
+        await queue.add(
+          {
+            type: "cost-alert",
+            webhookUrl: settings.costAlertWebhook,
+            // data 字段省略,任务执行时动态生成
           },
-          jobId: "cost-alert-scheduled", // 使用 jobId 标识,便于管理
+          {
+            repeat: { cron },
+            jobId: "cost-alert-scheduled",
+          }
+        );
+
+        logger.info({
+          action: "cost_alert_scheduled",
+          schedule: cron,
+          intervalMinutes: interval,
+          mode: "legacy",
+        });
+      }
+    } else {
+      // 新模式:按绑定调度(支持 cron 覆盖)
+      const { getEnabledBindingsByType } = await import("@/repository/notification-bindings");
+
+      if (settings.dailyLeaderboardEnabled) {
+        const bindings = await getEnabledBindingsByType("daily_leaderboard");
+        const [hour, minute] = (settings.dailyLeaderboardTime ?? "09:00").split(":").map(Number);
+        const defaultCron = `${minute} ${hour} * * *`;
+
+        for (const binding of bindings) {
+          const cron = binding.scheduleCron ?? defaultCron;
+          const tz = binding.scheduleTimezone ?? "Asia/Shanghai";
+
+          await queue.add(
+            {
+              type: "daily-leaderboard",
+              targetId: binding.targetId,
+              bindingId: binding.id,
+            },
+            {
+              repeat: { cron, tz },
+              jobId: `daily-leaderboard:${binding.id}`,
+            }
+          );
         }
-      );
 
-      logger.info({
-        action: "cost_alert_scheduled",
-        schedule: cron,
-        intervalMinutes: interval,
-      });
+        logger.info({
+          action: "daily_leaderboard_scheduled",
+          schedule: defaultCron,
+          targets: bindings.length,
+          mode: "targets",
+        });
+      }
+
+      if (settings.costAlertEnabled) {
+        const bindings = await getEnabledBindingsByType("cost_alert");
+        const interval = settings.costAlertCheckInterval ?? 60;
+        const defaultCron = `*/${interval} * * * *`;
+
+        for (const binding of bindings) {
+          const cron = binding.scheduleCron ?? defaultCron;
+          const tz = binding.scheduleTimezone ?? "Asia/Shanghai";
+
+          await queue.add(
+            {
+              type: "cost-alert",
+              targetId: binding.targetId,
+              bindingId: binding.id,
+            },
+            {
+              repeat: { cron, tz },
+              jobId: `cost-alert:${binding.id}`,
+            }
+          );
+        }
+
+        logger.info({
+          action: "cost_alert_scheduled",
+          schedule: defaultCron,
+          intervalMinutes: interval,
+          targets: bindings.length,
+          mode: "targets",
+        });
+      }
     }
 
     logger.info({ action: "notifications_scheduled" });

+ 114 - 20
src/lib/notification/notifier.ts

@@ -14,7 +14,7 @@ export async function sendCircuitBreakerAlert(data: CircuitBreakerAlertData): Pr
     const { getNotificationSettings } = await import("@/repository/notifications");
     const settings = await getNotificationSettings();
 
-    if (!settings.enabled || !settings.circuitBreakerEnabled || !settings.circuitBreakerWebhook) {
+    if (!settings.enabled || !settings.circuitBreakerEnabled) {
       logger.info({
         action: "circuit_breaker_alert_disabled",
         providerId: data.providerId,
@@ -38,17 +38,75 @@ export async function sendCircuitBreakerAlert(data: CircuitBreakerAlertData): Pr
       }
 
       // 动态导入通知队列(避免 Turbopack 编译 Bull)
-      const { addNotificationJob } = await import("./notification-queue");
-
-      // 发送告警
-      await addNotificationJob("circuit-breaker", settings.circuitBreakerWebhook, data);
+      const { addNotificationJob, addNotificationJobForTarget } = await import(
+        "./notification-queue"
+      );
+
+      if (settings.useLegacyMode) {
+        if (!settings.circuitBreakerWebhook) {
+          logger.info({
+            action: "circuit_breaker_alert_disabled",
+            providerId: data.providerId,
+            reason: "legacy_webhook_missing",
+          });
+          return;
+        }
+
+        await addNotificationJob("circuit-breaker", settings.circuitBreakerWebhook, data);
+      } else {
+        const { getEnabledBindingsByType } = await import("@/repository/notification-bindings");
+        const bindings = await getEnabledBindingsByType("circuit_breaker");
+
+        if (bindings.length === 0) {
+          logger.info({
+            action: "circuit_breaker_alert_skipped",
+            providerId: data.providerId,
+            reason: "no_bindings",
+          });
+          return;
+        }
+
+        for (const binding of bindings) {
+          await addNotificationJobForTarget("circuit-breaker", binding.targetId, binding.id, data);
+        }
+      }
 
       // 设置缓存,5 分钟过期
       await redisClient.set(cacheKey, "1", "EX", 300);
     } else {
       // Redis 不可用,直接发送告警
-      const { addNotificationJob } = await import("./notification-queue");
-      await addNotificationJob("circuit-breaker", settings.circuitBreakerWebhook, data);
+      const { addNotificationJob, addNotificationJobForTarget } = await import(
+        "./notification-queue"
+      );
+
+      if (settings.useLegacyMode) {
+        if (!settings.circuitBreakerWebhook) {
+          logger.info({
+            action: "circuit_breaker_alert_disabled",
+            providerId: data.providerId,
+            reason: "legacy_webhook_missing",
+          });
+          return;
+        }
+
+        await addNotificationJob("circuit-breaker", settings.circuitBreakerWebhook, data);
+      } else {
+        const { getEnabledBindingsByType } = await import("@/repository/notification-bindings");
+        const bindings = await getEnabledBindingsByType("circuit_breaker");
+
+        if (bindings.length === 0) {
+          logger.info({
+            action: "circuit_breaker_alert_skipped",
+            providerId: data.providerId,
+            reason: "no_bindings",
+          });
+          return;
+        }
+
+        for (const binding of bindings) {
+          await addNotificationJobForTarget("circuit-breaker", binding.targetId, binding.id, data);
+        }
+      }
     }
 
     logger.info({
@@ -72,11 +130,7 @@ export async function sendDailyLeaderboard(): Promise<void> {
     const { getNotificationSettings } = await import("@/repository/notifications");
     const settings = await getNotificationSettings();
 
-    if (
-      !settings.enabled ||
-      !settings.dailyLeaderboardEnabled ||
-      !settings.dailyLeaderboardWebhook
-    ) {
+    if (!settings.enabled || !settings.dailyLeaderboardEnabled) {
       logger.info({ action: "daily_leaderboard_disabled" });
       return;
     }
@@ -90,10 +144,29 @@ export async function sendDailyLeaderboard(): Promise<void> {
     }
 
     // 动态导入通知队列
-    const { addNotificationJob } = await import("./notification-queue");
+    const { addNotificationJob, addNotificationJobForTarget } = await import(
+      "./notification-queue"
+    );
 
-    // 发送通知
-    await addNotificationJob("daily-leaderboard", settings.dailyLeaderboardWebhook, data);
+    if (settings.useLegacyMode) {
+      if (!settings.dailyLeaderboardWebhook) {
+        logger.info({ action: "daily_leaderboard_disabled", reason: "legacy_webhook_missing" });
+        return;
+      }
+      await addNotificationJob("daily-leaderboard", settings.dailyLeaderboardWebhook, data);
+    } else {
+      const { getEnabledBindingsByType } = await import("@/repository/notification-bindings");
+      const bindings = await getEnabledBindingsByType("daily_leaderboard");
+
+      if (bindings.length === 0) {
+        logger.info({ action: "daily_leaderboard_skipped", reason: "no_bindings" });
+        return;
+      }
+
+      for (const binding of bindings) {
+        await addNotificationJobForTarget("daily-leaderboard", binding.targetId, binding.id, data);
+      }
+    }
 
     logger.info({
       action: "daily_leaderboard_sent",
@@ -115,7 +188,7 @@ export async function sendCostAlerts(): Promise<void> {
     const { getNotificationSettings } = await import("@/repository/notifications");
     const settings = await getNotificationSettings();
 
-    if (!settings.enabled || !settings.costAlertEnabled || !settings.costAlertWebhook) {
+    if (!settings.enabled || !settings.costAlertEnabled) {
       logger.info({ action: "cost_alert_disabled" });
       return;
     }
@@ -129,11 +202,32 @@ export async function sendCostAlerts(): Promise<void> {
     }
 
     // 动态导入通知队列
-    const { addNotificationJob } = await import("./notification-queue");
+    const { addNotificationJob, addNotificationJobForTarget } = await import(
+      "./notification-queue"
+    );
+
+    if (settings.useLegacyMode) {
+      if (!settings.costAlertWebhook) {
+        logger.info({ action: "cost_alert_disabled", reason: "legacy_webhook_missing" });
+        return;
+      }
+      for (const alert of alerts) {
+        await addNotificationJob("cost-alert", settings.costAlertWebhook, alert);
+      }
+    } else {
+      const { getEnabledBindingsByType } = await import("@/repository/notification-bindings");
+      const bindings = await getEnabledBindingsByType("cost_alert");
 
-    // 逐个发送告警
-    for (const alert of alerts) {
-      await addNotificationJob("cost-alert", settings.costAlertWebhook, alert);
+      if (bindings.length === 0) {
+        logger.info({ action: "cost_alerts_skipped", reason: "no_bindings" });
+        return;
+      }
+
+      for (const alert of alerts) {
+        for (const binding of bindings) {
+          await addNotificationJobForTarget("cost-alert", binding.targetId, binding.id, alert);
+        }
+      }
     }
 
     logger.info({

+ 1 - 1
src/lib/proxy-agent.ts

@@ -108,7 +108,7 @@ export function createProxyAgentForProvider(
         {
           type: parsedProxy.protocol === "socks5:" ? 5 : 4,
           host: parsedProxy.hostname,
-          port: parseInt(parsedProxy.port) || 1080,
+          port: parseInt(parsedProxy.port, 10) || 1080,
           userId: parsedProxy.username || undefined,
           password: parsedProxy.password || undefined,
         },

+ 3 - 0
src/lib/webhook/index.ts

@@ -20,6 +20,9 @@ export type {
   Section,
   SectionContent,
   StructuredMessage,
+  WebhookNotificationType,
   WebhookPayload,
   WebhookResult,
+  WebhookSendOptions,
+  WebhookTargetConfig,
 } from "./types";

+ 157 - 20
src/lib/webhook/notifier.ts

@@ -1,6 +1,16 @@
+import { createHmac } from "node:crypto";
+import type { Dispatcher } from "undici";
 import { logger } from "@/lib/logger";
+import { createProxyAgentForProvider, type ProxyConfig } from "@/lib/proxy-agent";
 import { createRenderer, type Renderer } from "./renderers";
-import type { ProviderType, StructuredMessage, WebhookPayload, WebhookResult } from "./types";
+import type {
+  ProviderType,
+  StructuredMessage,
+  WebhookPayload,
+  WebhookResult,
+  WebhookSendOptions,
+  WebhookTargetConfig,
+} from "./types";
 import { withRetry } from "./utils/retry";
 
 export interface WebhookNotifierOptions {
@@ -8,23 +18,33 @@ export interface WebhookNotifierOptions {
 }
 
 export class WebhookNotifier {
-  private readonly webhookUrl: string;
   private readonly maxRetries: number;
   private readonly renderer: Renderer;
   private readonly providerType: ProviderType;
+  private readonly config: WebhookTargetConfig;
+  private readonly proxyConfig: ProxyConfig | null;
 
-  constructor(webhookUrl: string, options?: WebhookNotifierOptions) {
-    this.webhookUrl = webhookUrl;
+  constructor(target: string | WebhookTargetConfig, options?: WebhookNotifierOptions) {
     this.maxRetries = options?.maxRetries ?? 3;
-    this.providerType = this.detectProvider();
-    this.renderer = createRenderer(this.providerType);
+    this.config =
+      typeof target === "string"
+        ? {
+            providerType: WebhookNotifier.detectProvider(target),
+            webhookUrl: target,
+          }
+        : target;
+
+    this.providerType = this.config.providerType;
+    this.renderer = createRenderer(this.providerType, this.config);
+    this.proxyConfig = this.createProxyConfig();
   }
 
-  async send(message: StructuredMessage): Promise<WebhookResult> {
-    const payload = this.renderer.render(message);
+  async send(message: StructuredMessage, options?: WebhookSendOptions): Promise<WebhookResult> {
+    const payload = this.renderer.render(message, options);
+    const url = this.getEndpointUrl();
 
     try {
-      return await withRetry(() => this.doSend(payload), {
+      return await withRetry(() => this.doSend(url, payload), {
         maxRetries: this.maxRetries,
         baseDelay: 1000,
       });
@@ -39,34 +59,135 @@ export class WebhookNotifier {
     }
   }
 
-  private detectProvider(): ProviderType {
-    const url = new URL(this.webhookUrl);
+  private static detectProvider(webhookUrl: string): ProviderType {
+    const url = new URL(webhookUrl);
     if (url.hostname === "qyapi.weixin.qq.com") return "wechat";
     if (url.hostname === "open.feishu.cn") return "feishu";
     throw new Error(`Unsupported webhook hostname: ${url.hostname}`);
   }
 
-  private async doSend(payload: WebhookPayload): Promise<WebhookResult> {
+  private getEndpointUrl(): string {
+    switch (this.providerType) {
+      case "telegram": {
+        const botToken = this.config.telegramBotToken?.trim();
+        if (!botToken) {
+          throw new Error("Telegram Bot Token 不能为空");
+        }
+        return `https://api.telegram.org/bot${botToken}/sendMessage`;
+      }
+
+      case "dingtalk": {
+        const webhookUrl = this.getWebhookUrlOrThrow();
+        return this.withDingtalkSignature(webhookUrl);
+      }
+
+      case "wechat":
+      case "feishu":
+      case "custom":
+        return this.getWebhookUrlOrThrow();
+    }
+  }
+
+  private getWebhookUrlOrThrow(): string {
+    const url = this.config.webhookUrl?.trim();
+    if (!url) {
+      throw new Error("Webhook URL 不能为空");
+    }
+    return url;
+  }
+
+  private withDingtalkSignature(webhookUrl: string): string {
+    const secret = this.config.dingtalkSecret?.trim();
+    if (!secret) {
+      return webhookUrl;
+    }
+
+    const timestamp = Date.now();
+    const stringToSign = `${timestamp}\n${secret}`;
+    const sign = createHmac("sha256", secret).update(stringToSign).digest("base64");
+
+    const url = new URL(webhookUrl);
+    url.searchParams.set("timestamp", String(timestamp));
+    url.searchParams.set("sign", sign);
+    return url.toString();
+  }
+
+  private createProxyConfig(): ProxyConfig | null {
+    const proxyUrl = this.config.proxyUrl?.trim();
+    if (!proxyUrl) {
+      return null;
+    }
+
+    const targetUrl = this.getProxyTargetUrl();
+    return createProxyAgentForProvider(
+      {
+        id: this.config.id ?? 0,
+        name: this.config.name ?? "webhook",
+        proxyUrl,
+        proxyFallbackToDirect: this.config.proxyFallbackToDirect ?? false,
+      },
+      targetUrl,
+      false
+    );
+  }
+
+  private getProxyTargetUrl(): string {
+    if (this.providerType === "telegram") {
+      return "https://api.telegram.org";
+    }
+    return new URL(this.getWebhookUrlOrThrow()).origin;
+  }
+
+  private async doSend(url: string, payload: WebhookPayload): Promise<WebhookResult> {
     logger.info({
       action: "webhook_send",
       provider: this.providerType,
       bodyLength: payload.body.length,
     });
 
-    const response = await fetch(this.webhookUrl, {
+    const init: RequestInit & { dispatcher?: Dispatcher } = {
       method: "POST",
       headers: {
         "Content-Type": "application/json",
         ...payload.headers,
       },
       body: payload.body,
-    });
+      ...(this.proxyConfig ? { dispatcher: this.proxyConfig.agent as Dispatcher } : {}),
+    };
+
+    try {
+      return await this.sendOnce(url, init);
+    } catch (error) {
+      if (this.proxyConfig?.fallbackToDirect) {
+        logger.warn("Webhook 代理发送失败,尝试直连降级", {
+          provider: this.providerType,
+          targetUrl: new URL(url).origin,
+        });
+
+        return await this.sendOnce(url, {
+          ...init,
+          dispatcher: undefined,
+        });
+      }
+      throw error;
+    }
+  }
+
+  private async sendOnce(
+    url: string,
+    init: RequestInit & { dispatcher?: Dispatcher }
+  ): Promise<WebhookResult> {
+    const response = await fetch(url, init);
 
     if (!response.ok) {
       throw new Error(`HTTP ${response.status}: ${response.statusText}`);
     }
 
-    const result = await response.json();
+    if (this.providerType === "custom") {
+      return { success: true };
+    }
+
+    const result = (await response.json()) as Record<string, unknown>;
     return this.checkResponse(result);
   }
 
@@ -83,6 +204,21 @@ export class WebhookNotifier {
           return { success: true };
         }
         throw new Error(`Feishu API Error ${response.code}: ${response.msg}`);
+
+      case "dingtalk":
+        if (response.errcode === 0) {
+          return { success: true };
+        }
+        throw new Error(`DingTalk API Error ${response.errcode}: ${response.errmsg}`);
+
+      case "telegram":
+        if (response.ok === true) {
+          return { success: true };
+        }
+        throw new Error(`Telegram API Error: ${response.description ?? "unknown"}`);
+
+      case "custom":
+        return { success: true };
     }
   }
 }
@@ -91,13 +227,14 @@ export class WebhookNotifier {
  * 便捷函数:发送结构化消息到 webhook
  */
 export async function sendWebhookMessage(
-  webhookUrl: string,
-  message: StructuredMessage
+  target: string | WebhookTargetConfig,
+  message: StructuredMessage,
+  options?: WebhookSendOptions
 ): Promise<WebhookResult> {
-  if (!webhookUrl) {
+  if (!target) {
     return { success: false, error: "Webhook URL is empty" };
   }
 
-  const notifier = new WebhookNotifier(webhookUrl);
-  return notifier.send(message);
+  const notifier = new WebhookNotifier(target);
+  return notifier.send(message, options);
 }

+ 59 - 0
src/lib/webhook/renderers/custom.ts

@@ -0,0 +1,59 @@
+import { buildTemplateVariables } from "../templates/placeholders";
+import type { StructuredMessage, WebhookPayload, WebhookSendOptions } from "../types";
+import type { Renderer } from "./index";
+
+export class CustomRenderer implements Renderer {
+  constructor(
+    private readonly template: Record<string, unknown>,
+    private readonly headers: Record<string, string> | null
+  ) {}
+
+  render(message: StructuredMessage, options?: WebhookSendOptions): WebhookPayload {
+    const template = options?.templateOverride ?? this.template;
+    if (!template || typeof template !== "object" || Array.isArray(template)) {
+      throw new Error("自定义 Webhook 模板必须是 JSON 对象");
+    }
+
+    const variables = buildTemplateVariables({
+      message,
+      notificationType: options?.notificationType,
+      data: options?.data,
+    });
+
+    const bodyObject = this.interpolate(template, variables);
+
+    return {
+      body: JSON.stringify(bodyObject),
+      ...(this.headers ? { headers: this.headers } : {}),
+    };
+  }
+
+  private interpolate(value: unknown, variables: Record<string, string>): unknown {
+    if (typeof value === "string") {
+      return this.interpolateString(value, variables);
+    }
+
+    if (Array.isArray(value)) {
+      return value.map((item) => this.interpolate(item, variables));
+    }
+
+    if (value && typeof value === "object") {
+      const record = value as Record<string, unknown>;
+      const result: Record<string, unknown> = {};
+      for (const [key, item] of Object.entries(record)) {
+        result[key] = this.interpolate(item, variables);
+      }
+      return result;
+    }
+
+    return value;
+  }
+
+  private interpolateString(template: string, variables: Record<string, string>): string {
+    let result = template;
+    for (const [key, value] of Object.entries(variables)) {
+      result = result.replaceAll(key, value);
+    }
+    return result;
+  }
+}

+ 99 - 0
src/lib/webhook/renderers/dingtalk.ts

@@ -0,0 +1,99 @@
+import type {
+  ListItem,
+  Section,
+  SectionContent,
+  StructuredMessage,
+  WebhookPayload,
+} from "../types";
+import { formatTimestamp } from "../utils/date";
+import type { Renderer } from "./index";
+
+export class DingTalkRenderer implements Renderer {
+  render(message: StructuredMessage): WebhookPayload {
+    const markdown = {
+      msgtype: "markdown",
+      markdown: {
+        title: this.escapeText(message.header.title),
+        text: this.buildMarkdown(message),
+      },
+    };
+
+    return { body: JSON.stringify(markdown) };
+  }
+
+  private buildMarkdown(message: StructuredMessage): string {
+    const lines: string[] = [];
+
+    lines.push(`### ${this.escapeText(message.header.title)}`);
+    lines.push("");
+
+    for (const section of message.sections) {
+      lines.push(...this.renderSection(section));
+      lines.push("");
+    }
+
+    if (message.footer) {
+      lines.push("---");
+      for (const section of message.footer) {
+        lines.push(...this.renderSection(section));
+      }
+      lines.push("");
+    }
+
+    lines.push(formatTimestamp(message.timestamp));
+    return lines.join("\n").trim();
+  }
+
+  private renderSection(section: Section): string[] {
+    const lines: string[] = [];
+
+    if (section.title) {
+      lines.push(`**${this.escapeText(section.title)}**`);
+    }
+
+    for (const content of section.content) {
+      lines.push(...this.renderContent(content));
+    }
+
+    return lines;
+  }
+
+  private renderContent(content: SectionContent): string[] {
+    switch (content.type) {
+      case "text":
+        return [this.escapeText(content.value)];
+
+      case "quote":
+        return [`> ${this.escapeText(content.value)}`];
+
+      case "fields":
+        return content.items.map(
+          (item) => `- ${this.escapeText(item.label)}: ${this.escapeText(item.value)}`
+        );
+
+      case "list":
+        return this.renderList(content.items, content.style);
+
+      case "divider":
+        return ["---"];
+    }
+  }
+
+  private renderList(items: ListItem[], style: "ordered" | "bullet"): string[] {
+    const lines: string[] = [];
+
+    items.forEach((item, index) => {
+      const prefix = style === "ordered" ? `${index + 1}.` : "-";
+      lines.push(`${prefix} **${this.escapeText(item.primary)}**`);
+      if (item.secondary) {
+        lines.push(`  ${this.escapeText(item.secondary)}`);
+      }
+    });
+
+    return lines;
+  }
+
+  private escapeText(value: string): string {
+    return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
+  }
+}

+ 32 - 3
src/lib/webhook/renderers/index.ts

@@ -1,16 +1,45 @@
-import type { ProviderType, StructuredMessage, WebhookPayload } from "../types";
+import type {
+  ProviderType,
+  StructuredMessage,
+  WebhookPayload,
+  WebhookSendOptions,
+  WebhookTargetConfig,
+} from "../types";
+import { CustomRenderer } from "./custom";
+import { DingTalkRenderer } from "./dingtalk";
 import { FeishuCardRenderer } from "./feishu";
+import { TelegramRenderer } from "./telegram";
 import { WeChatRenderer } from "./wechat";
 
 export interface Renderer {
-  render(message: StructuredMessage): WebhookPayload;
+  render(message: StructuredMessage, options?: WebhookSendOptions): WebhookPayload;
 }
 
-export function createRenderer(provider: ProviderType): Renderer {
+export function createRenderer(provider: ProviderType, config?: WebhookTargetConfig): Renderer {
   switch (provider) {
     case "wechat":
       return new WeChatRenderer();
     case "feishu":
       return new FeishuCardRenderer();
+    case "dingtalk":
+      return new DingTalkRenderer();
+    case "telegram": {
+      const chatId = config?.telegramChatId?.trim();
+      if (!chatId) {
+        throw new Error("Telegram Chat ID 不能为空");
+      }
+      return new TelegramRenderer(chatId);
+    }
+    case "custom": {
+      const template = config?.customTemplate ?? null;
+      if (!template || typeof template !== "object" || Array.isArray(template)) {
+        throw new Error("自定义 Webhook 模板不能为空");
+      }
+      return new CustomRenderer(template, config?.customHeaders ?? null);
+    }
+    default: {
+      const _exhaustiveCheck: never = provider;
+      throw new Error(`不支持的推送渠道: ${provider}`);
+    }
   }
 }

+ 105 - 0
src/lib/webhook/renderers/telegram.ts

@@ -0,0 +1,105 @@
+import type {
+  ListItem,
+  Section,
+  SectionContent,
+  StructuredMessage,
+  WebhookPayload,
+} from "../types";
+import { formatTimestamp } from "../utils/date";
+import type { Renderer } from "./index";
+
+export class TelegramRenderer implements Renderer {
+  constructor(private readonly chatId: string) {}
+
+  render(message: StructuredMessage): WebhookPayload {
+    const html = this.buildHtml(message);
+    return {
+      body: JSON.stringify({
+        chat_id: this.chatId,
+        text: html,
+        parse_mode: "HTML",
+        disable_web_page_preview: true,
+      }),
+    };
+  }
+
+  private buildHtml(message: StructuredMessage): string {
+    const lines: string[] = [];
+
+    lines.push(`<b>${this.escapeHtml(message.header.title)}</b>`);
+    lines.push("");
+
+    for (const section of message.sections) {
+      lines.push(...this.renderSection(section));
+      lines.push("");
+    }
+
+    if (message.footer) {
+      lines.push("---");
+      for (const section of message.footer) {
+        lines.push(...this.renderSection(section));
+      }
+      lines.push("");
+    }
+
+    lines.push(this.escapeHtml(formatTimestamp(message.timestamp)));
+    return lines.join("\n").trim();
+  }
+
+  private renderSection(section: Section): string[] {
+    const lines: string[] = [];
+
+    if (section.title) {
+      lines.push(`<b>${this.escapeHtml(section.title)}</b>`);
+    }
+
+    for (const content of section.content) {
+      lines.push(...this.renderContent(content));
+    }
+
+    return lines;
+  }
+
+  private renderContent(content: SectionContent): string[] {
+    switch (content.type) {
+      case "text":
+        return [this.escapeHtml(content.value)];
+
+      case "quote":
+        return [`&gt; ${this.escapeHtml(content.value)}`];
+
+      case "fields":
+        return content.items.map(
+          (item) => `<b>${this.escapeHtml(item.label)}</b>: ${this.escapeHtml(item.value)}`
+        );
+
+      case "list":
+        return this.renderList(content.items, content.style);
+
+      case "divider":
+        return ["---"];
+    }
+  }
+
+  private renderList(items: ListItem[], style: "ordered" | "bullet"): string[] {
+    const lines: string[] = [];
+
+    items.forEach((item, index) => {
+      const prefix = style === "ordered" ? `${index + 1}.` : "-";
+      lines.push(`${prefix} <b>${this.escapeHtml(item.primary)}</b>`);
+      if (item.secondary) {
+        lines.push(`  ${this.escapeHtml(item.secondary)}`);
+      }
+    });
+
+    return lines;
+  }
+
+  private escapeHtml(value: string): string {
+    return value
+      .replaceAll("&", "&amp;")
+      .replaceAll("<", "&lt;")
+      .replaceAll(">", "&gt;")
+      .replaceAll('"', "&quot;");
+  }
+}

+ 2 - 14
src/lib/webhook/renderers/wechat.ts

@@ -5,6 +5,7 @@ import type {
   StructuredMessage,
   WebhookPayload,
 } from "../types";
+import { formatTimestamp } from "../utils/date";
 import type { Renderer } from "./index";
 
 export class WeChatRenderer implements Renderer {
@@ -31,7 +32,7 @@ export class WeChatRenderer implements Renderer {
     }
 
     // Timestamp
-    lines.push(this.formatTimestamp(message.timestamp));
+    lines.push(formatTimestamp(message.timestamp));
 
     const content = lines.join("\n");
 
@@ -109,17 +110,4 @@ export class WeChatRenderer implements Renderer {
     }
     return lines;
   }
-
-  private formatTimestamp(date: Date): string {
-    return date.toLocaleString("zh-CN", {
-      timeZone: "Asia/Shanghai",
-      year: "numeric",
-      month: "2-digit",
-      day: "2-digit",
-      hour: "2-digit",
-      minute: "2-digit",
-      second: "2-digit",
-      hour12: false,
-    });
-  }
 }

+ 45 - 0
src/lib/webhook/templates/defaults.ts

@@ -0,0 +1,45 @@
+import type { WebhookNotificationType } from "../types";
+
+export const DEFAULT_TEMPLATES = {
+  custom_generic: {
+    title: "{{title}}",
+    level: "{{level}}",
+    timestamp: "{{timestamp}}",
+    content: "{{sections}}",
+  },
+
+  circuit_breaker: {
+    title: "{{title}}",
+    provider: "{{provider_name}}",
+    providerId: "{{provider_id}}",
+    failureCount: "{{failure_count}}",
+    retryAt: "{{retry_at}}",
+    error: "{{last_error}}",
+  },
+
+  daily_leaderboard: {
+    title: "{{title}}",
+    date: "{{date}}",
+    totalRequests: "{{total_requests}}",
+    totalCost: "{{total_cost}}",
+    entries: "{{entries_json}}",
+  },
+
+  cost_alert: {
+    title: "{{title}}",
+    targetType: "{{target_type}}",
+    targetName: "{{target_name}}",
+    currentCost: "{{current_cost}}",
+    quotaLimit: "{{quota_limit}}",
+    usagePercent: "{{usage_percent}}",
+  },
+} as const;
+
+export const DEFAULT_TEMPLATE_BY_NOTIFICATION_TYPE: Record<
+  WebhookNotificationType,
+  Record<string, unknown>
+> = {
+  circuit_breaker: DEFAULT_TEMPLATES.circuit_breaker,
+  daily_leaderboard: DEFAULT_TEMPLATES.daily_leaderboard,
+  cost_alert: DEFAULT_TEMPLATES.cost_alert,
+};

+ 7 - 0
src/lib/webhook/templates/index.ts

@@ -1,4 +1,11 @@
 export { buildCircuitBreakerMessage } from "./circuit-breaker";
 export { buildCostAlertMessage } from "./cost-alert";
 export { buildDailyLeaderboardMessage } from "./daily-leaderboard";
+export { DEFAULT_TEMPLATE_BY_NOTIFICATION_TYPE, DEFAULT_TEMPLATES } from "./defaults";
+export {
+  buildTemplateVariables,
+  getTemplatePlaceholders,
+  TEMPLATE_PLACEHOLDERS,
+  WEBHOOK_NOTIFICATION_TYPES,
+} from "./placeholders";
 export { buildTestMessage } from "./test-messages";

+ 203 - 0
src/lib/webhook/templates/placeholders.ts

@@ -0,0 +1,203 @@
+import type {
+  CircuitBreakerAlertData,
+  CostAlertData,
+  DailyLeaderboardData,
+  Section,
+  SectionContent,
+  StructuredMessage,
+  WebhookNotificationType,
+} from "../types";
+
+export interface TemplatePlaceholder {
+  key: string;
+  label: string;
+  description: string;
+}
+
+export const WEBHOOK_NOTIFICATION_TYPES = [
+  "circuit_breaker",
+  "daily_leaderboard",
+  "cost_alert",
+] as const satisfies readonly WebhookNotificationType[];
+
+export const TEMPLATE_PLACEHOLDERS = {
+  common: [
+    { key: "{{timestamp}}", label: "发送时间", description: "ISO 8601 格式" },
+    {
+      key: "{{timestamp_local}}",
+      label: "本地时间",
+      description: "本地格式化时间(Asia/Shanghai)",
+    },
+    { key: "{{title}}", label: "消息标题", description: "通知标题" },
+    { key: "{{level}}", label: "消息级别", description: "info / warning / error" },
+    { key: "{{sections}}", label: "正文内容", description: "结构化消息内容(纯文本)" },
+  ],
+  circuit_breaker: [
+    { key: "{{provider_name}}", label: "供应商名称", description: "触发熔断的供应商" },
+    { key: "{{provider_id}}", label: "供应商ID", description: "供应商数字ID" },
+    { key: "{{failure_count}}", label: "失败次数", description: "连续失败计数" },
+    { key: "{{retry_at}}", label: "恢复时间", description: "预计恢复时间" },
+    { key: "{{last_error}}", label: "错误信息", description: "最后一次错误详情" },
+  ],
+  daily_leaderboard: [
+    { key: "{{date}}", label: "统计日期", description: "YYYY-MM-DD 格式" },
+    { key: "{{entries_json}}", label: "排行榜数据", description: "JSON 格式排行榜" },
+    { key: "{{total_requests}}", label: "总请求数", description: "当日总请求量" },
+    { key: "{{total_cost}}", label: "总消费", description: "当日总消费金额" },
+  ],
+  cost_alert: [
+    { key: "{{target_type}}", label: "目标类型", description: "user 或 provider" },
+    { key: "{{target_name}}", label: "目标名称", description: "用户名或供应商名" },
+    { key: "{{current_cost}}", label: "当前消费", description: "当前已消费金额" },
+    { key: "{{quota_limit}}", label: "配额上限", description: "配额限制金额" },
+    { key: "{{usage_percent}}", label: "使用比例", description: "百分比(0-100)" },
+  ],
+} as const satisfies Record<string, readonly TemplatePlaceholder[]>;
+
+export function getTemplatePlaceholders(
+  notificationType?: WebhookNotificationType
+): TemplatePlaceholder[] {
+  const common = TEMPLATE_PLACEHOLDERS.common;
+  if (!notificationType) {
+    return [...common];
+  }
+
+  const specific = TEMPLATE_PLACEHOLDERS[notificationType];
+  return specific ? [...common, ...specific] : [...common];
+}
+
+export function buildTemplateVariables(params: {
+  message: StructuredMessage;
+  notificationType?: WebhookNotificationType;
+  data?: unknown;
+}): Record<string, string> {
+  const { message, notificationType, data } = params;
+
+  const values: Record<string, string> = {};
+
+  // 通用字段
+  values["{{timestamp}}"] = message.timestamp.toISOString();
+  values["{{timestamp_local}}"] = formatLocalTimestamp(message.timestamp);
+  values["{{title}}"] = message.header.title;
+  values["{{level}}"] = message.header.level;
+  values["{{sections}}"] = renderMessageSections(message);
+
+  // 类型字段(尽量容错,避免模板渲染阻塞发送)
+  if (notificationType === "circuit_breaker") {
+    const cb = data as Partial<CircuitBreakerAlertData> | undefined;
+    values["{{provider_name}}"] = cb?.providerName ?? "";
+    values["{{provider_id}}"] = cb?.providerId !== undefined ? String(cb.providerId) : "";
+    values["{{failure_count}}"] = cb?.failureCount !== undefined ? String(cb.failureCount) : "";
+    values["{{retry_at}}"] = cb?.retryAt ?? "";
+    values["{{last_error}}"] = cb?.lastError ?? "";
+  }
+
+  if (notificationType === "daily_leaderboard") {
+    const dl = data as Partial<DailyLeaderboardData> | undefined;
+    values["{{date}}"] = dl?.date ?? "";
+    values["{{entries_json}}"] = dl?.entries !== undefined ? safeJsonStringify(dl.entries) : "[]";
+    values["{{total_requests}}"] = dl?.totalRequests !== undefined ? String(dl.totalRequests) : "";
+    values["{{total_cost}}"] = dl?.totalCost !== undefined ? String(dl.totalCost) : "";
+  }
+
+  if (notificationType === "cost_alert") {
+    const ca = data as Partial<CostAlertData> | undefined;
+    values["{{target_type}}"] = ca?.targetType ?? "";
+    values["{{target_name}}"] = ca?.targetName ?? "";
+    values["{{current_cost}}"] = ca?.currentCost !== undefined ? String(ca.currentCost) : "";
+    values["{{quota_limit}}"] = ca?.quotaLimit !== undefined ? String(ca.quotaLimit) : "";
+    values["{{usage_percent}}"] = buildUsagePercent(ca);
+  }
+
+  return values;
+}
+
+function buildUsagePercent(data: Partial<CostAlertData> | undefined): string {
+  if (!data) return "";
+  if (data.currentCost === undefined || data.quotaLimit === undefined || data.quotaLimit === 0) {
+    return "";
+  }
+  const percent = (data.currentCost / data.quotaLimit) * 100;
+  return Number.isFinite(percent) ? percent.toFixed(1) : "";
+}
+
+function safeJsonStringify(value: unknown): string {
+  try {
+    return JSON.stringify(value);
+  } catch {
+    return "[]";
+  }
+}
+
+function formatLocalTimestamp(date: Date): string {
+  return date.toLocaleString("zh-CN", {
+    timeZone: "Asia/Shanghai",
+    year: "numeric",
+    month: "2-digit",
+    day: "2-digit",
+    hour: "2-digit",
+    minute: "2-digit",
+    second: "2-digit",
+    hour12: false,
+  });
+}
+
+function renderMessageSections(message: StructuredMessage): string {
+  const lines: string[] = [];
+
+  for (const section of message.sections) {
+    lines.push(...renderSection(section));
+    lines.push("");
+  }
+
+  if (message.footer) {
+    lines.push("---");
+    for (const section of message.footer) {
+      lines.push(...renderSection(section));
+    }
+    lines.push("");
+  }
+
+  return lines.join("\n").trim();
+}
+
+function renderSection(section: Section): string[] {
+  const lines: string[] = [];
+
+  if (section.title) {
+    lines.push(section.title);
+  }
+
+  for (const content of section.content) {
+    lines.push(...renderContent(content));
+  }
+
+  return lines;
+}
+
+function renderContent(content: SectionContent): string[] {
+  switch (content.type) {
+    case "text":
+      return [content.value];
+
+    case "quote":
+      return [`> ${content.value}`];
+
+    case "fields":
+      return content.items.map((item) => `${item.label}: ${item.value}`);
+
+    case "list":
+      return content.items.flatMap((item, index) => {
+        const prefix = content.style === "ordered" ? `${index + 1}.` : "-";
+        const lines: string[] = [];
+        lines.push(`${prefix} ${item.primary}`);
+        if (item.secondary) {
+          lines.push(`  ${item.secondary}`);
+        }
+        return lines;
+      });
+
+    case "divider":
+      return ["---"];
+  }
+}

+ 28 - 1
src/lib/webhook/types.ts

@@ -76,7 +76,34 @@ export interface CostAlertData {
  * Webhook 相关类型
  */
 
-export type ProviderType = "wechat" | "feishu";
+export type ProviderType = "wechat" | "feishu" | "dingtalk" | "telegram" | "custom";
+
+export type WebhookNotificationType = "circuit_breaker" | "daily_leaderboard" | "cost_alert";
+
+export interface WebhookTargetConfig {
+  id?: number;
+  name?: string;
+  providerType: ProviderType;
+
+  webhookUrl?: string | null;
+
+  telegramBotToken?: string | null;
+  telegramChatId?: string | null;
+
+  dingtalkSecret?: string | null;
+
+  customTemplate?: Record<string, unknown> | null;
+  customHeaders?: Record<string, string> | null;
+
+  proxyUrl?: string | null;
+  proxyFallbackToDirect?: boolean;
+}
+
+export interface WebhookSendOptions {
+  notificationType?: WebhookNotificationType;
+  data?: unknown;
+  templateOverride?: Record<string, unknown> | null;
+}
 
 export interface WebhookPayload {
   body: string;

+ 4 - 0
src/lib/webhook/utils/date.ts

@@ -14,3 +14,7 @@ export function formatDateTime(date: Date | string): string {
     hour12: false,
   });
 }
+
+export function formatTimestamp(date: Date): string {
+  return formatDateTime(date);
+}

+ 242 - 0
src/repository/notification-bindings.ts

@@ -0,0 +1,242 @@
+"use server";
+
+import { and, desc, eq, notInArray } from "drizzle-orm";
+import { db } from "@/drizzle/db";
+import { notificationTargetBindings, webhookTargets } from "@/drizzle/schema";
+import type { WebhookProviderType, WebhookTarget, WebhookTestResult } from "./webhook-targets";
+
+export type NotificationType = "circuit_breaker" | "daily_leaderboard" | "cost_alert";
+
+const DEFAULT_TIMEZONE = "Asia/Shanghai";
+
+export interface NotificationBinding {
+  id: number;
+  notificationType: NotificationType;
+  targetId: number;
+  isEnabled: boolean;
+  scheduleCron: string | null;
+  scheduleTimezone: string | null;
+  templateOverride: Record<string, unknown> | null;
+  createdAt: Date | null;
+}
+
+export interface NotificationBindingWithTarget extends NotificationBinding {
+  target: WebhookTarget;
+}
+
+export interface BindingInput {
+  targetId: number;
+  isEnabled?: boolean;
+  scheduleCron?: string | null;
+  scheduleTimezone?: string | null;
+  templateOverride?: Record<string, unknown> | null;
+}
+
+const BINDING_WITH_TARGET_SELECT = {
+  bindingId: notificationTargetBindings.id,
+  bindingNotificationType: notificationTargetBindings.notificationType,
+  bindingTargetId: notificationTargetBindings.targetId,
+  bindingIsEnabled: notificationTargetBindings.isEnabled,
+  bindingScheduleCron: notificationTargetBindings.scheduleCron,
+  bindingScheduleTimezone: notificationTargetBindings.scheduleTimezone,
+  bindingTemplateOverride: notificationTargetBindings.templateOverride,
+  bindingCreatedAt: notificationTargetBindings.createdAt,
+
+  targetId: webhookTargets.id,
+  targetName: webhookTargets.name,
+  targetProviderType: webhookTargets.providerType,
+  targetWebhookUrl: webhookTargets.webhookUrl,
+  targetTelegramBotToken: webhookTargets.telegramBotToken,
+  targetTelegramChatId: webhookTargets.telegramChatId,
+  targetDingtalkSecret: webhookTargets.dingtalkSecret,
+  targetCustomTemplate: webhookTargets.customTemplate,
+  targetCustomHeaders: webhookTargets.customHeaders,
+  targetProxyUrl: webhookTargets.proxyUrl,
+  targetProxyFallbackToDirect: webhookTargets.proxyFallbackToDirect,
+  targetIsEnabled: webhookTargets.isEnabled,
+  targetLastTestAt: webhookTargets.lastTestAt,
+  targetLastTestResult: webhookTargets.lastTestResult,
+  targetCreatedAt: webhookTargets.createdAt,
+  targetUpdatedAt: webhookTargets.updatedAt,
+};
+
+type BindingWithTargetRow = {
+  bindingId: number;
+  bindingNotificationType: unknown;
+  bindingTargetId: number;
+  bindingIsEnabled: boolean | null;
+  bindingScheduleCron: string | null;
+  bindingScheduleTimezone: string | null;
+  bindingTemplateOverride: unknown;
+  bindingCreatedAt: Date | null;
+
+  targetId: number;
+  targetName: string;
+  targetProviderType: unknown;
+  targetWebhookUrl: string | null;
+  targetTelegramBotToken: string | null;
+  targetTelegramChatId: string | null;
+  targetDingtalkSecret: string | null;
+  targetCustomTemplate: unknown;
+  targetCustomHeaders: unknown;
+  targetProxyUrl: string | null;
+  targetProxyFallbackToDirect: boolean | null;
+  targetIsEnabled: boolean | null;
+  targetLastTestAt: Date | null;
+  targetLastTestResult: unknown;
+  targetCreatedAt: Date | null;
+  targetUpdatedAt: Date | null;
+};
+
+function mapBindingRow(row: BindingWithTargetRow): NotificationBindingWithTarget {
+  return {
+    id: row.bindingId,
+    notificationType: row.bindingNotificationType as NotificationType,
+    targetId: row.bindingTargetId,
+    isEnabled: row.bindingIsEnabled ?? true,
+    scheduleCron: row.bindingScheduleCron ?? null,
+    scheduleTimezone: row.bindingScheduleTimezone ?? null,
+    templateOverride: (row.bindingTemplateOverride as Record<string, unknown> | null) ?? null,
+    createdAt: row.bindingCreatedAt ?? null,
+    target: {
+      id: row.targetId,
+      name: row.targetName,
+      providerType: row.targetProviderType as WebhookProviderType,
+      webhookUrl: row.targetWebhookUrl ?? null,
+      telegramBotToken: row.targetTelegramBotToken ?? null,
+      telegramChatId: row.targetTelegramChatId ?? null,
+      dingtalkSecret: row.targetDingtalkSecret ?? null,
+      customTemplate: (row.targetCustomTemplate as Record<string, unknown> | null) ?? null,
+      customHeaders: (row.targetCustomHeaders as Record<string, string> | null) ?? null,
+      proxyUrl: row.targetProxyUrl ?? null,
+      proxyFallbackToDirect: row.targetProxyFallbackToDirect ?? false,
+      isEnabled: row.targetIsEnabled ?? true,
+      lastTestAt: row.targetLastTestAt ?? null,
+      lastTestResult: (row.targetLastTestResult as WebhookTestResult | null) ?? null,
+      createdAt: row.targetCreatedAt ?? null,
+      updatedAt: row.targetUpdatedAt ?? null,
+    },
+  };
+}
+
+export async function getBindingById(id: number): Promise<NotificationBinding | null> {
+  const [row] = await db
+    .select()
+    .from(notificationTargetBindings)
+    .where(eq(notificationTargetBindings.id, id))
+    .limit(1);
+
+  if (!row) {
+    return null;
+  }
+
+  return {
+    id: row.id,
+    notificationType: row.notificationType as NotificationType,
+    targetId: row.targetId,
+    isEnabled: row.isEnabled ?? true,
+    scheduleCron: row.scheduleCron ?? null,
+    scheduleTimezone: row.scheduleTimezone ?? null,
+    templateOverride: (row.templateOverride as Record<string, unknown> | null) ?? null,
+    createdAt: row.createdAt ?? null,
+  };
+}
+
+export async function getBindingsByType(
+  type: NotificationType
+): Promise<NotificationBindingWithTarget[]> {
+  const rows = await db
+    .select(BINDING_WITH_TARGET_SELECT)
+    .from(notificationTargetBindings)
+    .innerJoin(webhookTargets, eq(notificationTargetBindings.targetId, webhookTargets.id))
+    .where(eq(notificationTargetBindings.notificationType, type))
+    .orderBy(desc(notificationTargetBindings.id));
+
+  return rows.map((row) => mapBindingRow(row as BindingWithTargetRow));
+}
+
+export async function upsertBindings(
+  type: NotificationType,
+  bindings: BindingInput[]
+): Promise<void> {
+  const normalized = bindings
+    .map((b) => ({
+      targetId: b.targetId,
+      isEnabled: b.isEnabled ?? true,
+      scheduleCron: b.scheduleCron ?? null,
+      scheduleTimezone: b.scheduleTimezone ?? DEFAULT_TIMEZONE,
+      templateOverride: b.templateOverride ?? null,
+    }))
+    .filter((b) => Number.isFinite(b.targetId) && b.targetId > 0);
+
+  const targetIds = Array.from(new Set(normalized.map((b) => b.targetId)));
+
+  await db.transaction(async (tx) => {
+    // 删除不存在的绑定(按类型维度)
+    if (targetIds.length === 0) {
+      await tx
+        .delete(notificationTargetBindings)
+        .where(eq(notificationTargetBindings.notificationType, type));
+    } else {
+      await tx
+        .delete(notificationTargetBindings)
+        .where(
+          and(
+            eq(notificationTargetBindings.notificationType, type),
+            notInArray(notificationTargetBindings.targetId, targetIds)
+          )
+        );
+    }
+
+    // Upsert 目标绑定
+    for (const binding of normalized) {
+      await tx
+        .insert(notificationTargetBindings)
+        .values({
+          notificationType: type,
+          targetId: binding.targetId,
+          isEnabled: binding.isEnabled,
+          scheduleCron: binding.scheduleCron,
+          scheduleTimezone: binding.scheduleTimezone,
+          templateOverride: binding.templateOverride,
+        })
+        .onConflictDoUpdate({
+          target: [
+            notificationTargetBindings.notificationType,
+            notificationTargetBindings.targetId,
+          ],
+          set: {
+            isEnabled: binding.isEnabled,
+            scheduleCron: binding.scheduleCron,
+            scheduleTimezone: binding.scheduleTimezone,
+            templateOverride: binding.templateOverride,
+          },
+        });
+    }
+  });
+}
+
+export async function deleteBindingsForTarget(targetId: number): Promise<void> {
+  await db
+    .delete(notificationTargetBindings)
+    .where(eq(notificationTargetBindings.targetId, targetId));
+}
+
+export async function getEnabledBindingsByType(
+  type: NotificationType
+): Promise<NotificationBindingWithTarget[]> {
+  const rows = await db
+    .select(BINDING_WITH_TARGET_SELECT)
+    .from(notificationTargetBindings)
+    .innerJoin(webhookTargets, eq(notificationTargetBindings.targetId, webhookTargets.id))
+    .where(
+      and(
+        eq(notificationTargetBindings.notificationType, type),
+        eq(notificationTargetBindings.isEnabled, true),
+        eq(webhookTargets.isEnabled, true)
+      )
+    )
+    .orderBy(desc(notificationTargetBindings.id));
+
+  return rows.map((row) => mapBindingRow(row as BindingWithTargetRow));
+}

+ 80 - 0
src/repository/notifications.ts

@@ -11,6 +11,7 @@ import { logger } from "@/lib/logger";
 export interface NotificationSettings {
   id: number;
   enabled: boolean;
+  useLegacyMode: boolean;
 
   // 熔断器告警配置
   circuitBreakerEnabled: boolean;
@@ -37,6 +38,7 @@ export interface NotificationSettings {
  */
 export interface UpdateNotificationSettingsInput {
   enabled?: boolean;
+  useLegacyMode?: boolean;
 
   circuitBreakerEnabled?: boolean;
   circuitBreakerWebhook?: string | null;
@@ -116,6 +118,70 @@ function isTableMissingError(error: unknown, depth = 0): boolean {
   return false;
 }
 
+/**
+ * 检查是否是字段缺失错误(用于灰度上线时,代码先于迁移发布的场景)
+ */
+function isColumnMissingError(error: unknown, depth = 0): boolean {
+  if (!error || depth > 5) {
+    return false;
+  }
+
+  if (typeof error === "string") {
+    const normalized = error.toLowerCase();
+    return (
+      normalized.includes("42703") ||
+      (normalized.includes("use_legacy_mode") &&
+        (normalized.includes("does not exist") ||
+          normalized.includes("doesn't exist") ||
+          normalized.includes("找不到")))
+    );
+  }
+
+  if (typeof error === "object") {
+    const err = error as {
+      code?: unknown;
+      message?: unknown;
+      cause?: unknown;
+      errors?: unknown;
+      originalError?: unknown;
+    };
+
+    if (typeof err.code === "string" && err.code.toUpperCase() === "42703") {
+      return true;
+    }
+
+    if (typeof err.message === "string" && isColumnMissingError(err.message, depth + 1)) {
+      return true;
+    }
+
+    if ("cause" in err && err.cause && isColumnMissingError(err.cause, depth + 1)) {
+      return true;
+    }
+
+    if (Array.isArray(err.errors)) {
+      return err.errors.some((item) => isColumnMissingError(item, depth + 1));
+    }
+
+    if (err.originalError && isColumnMissingError(err.originalError, depth + 1)) {
+      return true;
+    }
+
+    const stringified = (() => {
+      try {
+        return String(error);
+      } catch {
+        return undefined;
+      }
+    })();
+
+    if (stringified) {
+      return isColumnMissingError(stringified, depth + 1);
+    }
+  }
+
+  return false;
+}
+
 /**
  * 创建默认通知设置
  */
@@ -124,6 +190,7 @@ function createFallbackSettings(): NotificationSettings {
   return {
     id: 0,
     enabled: false,
+    useLegacyMode: true,
     circuitBreakerEnabled: false,
     circuitBreakerWebhook: null,
     dailyLeaderboardEnabled: false,
@@ -149,6 +216,7 @@ export async function getNotificationSettings(): Promise<NotificationSettings> {
     if (settings) {
       return {
         ...settings,
+        useLegacyMode: settings.useLegacyMode ?? true,
         createdAt: settings.createdAt ?? new Date(),
         updatedAt: settings.updatedAt ?? new Date(),
       };
@@ -173,6 +241,7 @@ export async function getNotificationSettings(): Promise<NotificationSettings> {
     if (created) {
       return {
         ...created,
+        useLegacyMode: created.useLegacyMode ?? true,
         createdAt: created.createdAt ?? new Date(),
         updatedAt: created.updatedAt ?? new Date(),
       };
@@ -187,6 +256,7 @@ export async function getNotificationSettings(): Promise<NotificationSettings> {
 
     return {
       ...fallback,
+      useLegacyMode: fallback.useLegacyMode ?? true,
       createdAt: fallback.createdAt ?? new Date(),
       updatedAt: fallback.updatedAt ?? new Date(),
     };
@@ -195,6 +265,10 @@ export async function getNotificationSettings(): Promise<NotificationSettings> {
       logger.warn("notification_settings 表不存在,返回默认配置。请运行数据库迁移。", { error });
       return createFallbackSettings();
     }
+    if (isColumnMissingError(error)) {
+      logger.warn("notification_settings 缺少字段,返回默认配置。请运行数据库迁移。", { error });
+      return createFallbackSettings();
+    }
     throw error;
   }
 }
@@ -217,6 +291,9 @@ export async function updateNotificationSettings(
     if (payload.enabled !== undefined) {
       updates.enabled = payload.enabled;
     }
+    if (payload.useLegacyMode !== undefined) {
+      updates.useLegacyMode = payload.useLegacyMode;
+    }
 
     // 熔断器告警配置
     if (payload.circuitBreakerEnabled !== undefined) {
@@ -273,6 +350,9 @@ export async function updateNotificationSettings(
     if (isTableMissingError(error)) {
       throw new Error("通知设置数据表不存在,请先执行数据库迁移。");
     }
+    if (isColumnMissingError(error)) {
+      throw new Error("通知设置字段缺失,请先执行数据库迁移。");
+    }
     throw error;
   }
 }

+ 170 - 0
src/repository/webhook-targets.ts

@@ -0,0 +1,170 @@
+"use server";
+
+import { desc, eq } from "drizzle-orm";
+import { db } from "@/drizzle/db";
+import { webhookTargets } from "@/drizzle/schema";
+
+export type WebhookProviderType = "wechat" | "feishu" | "dingtalk" | "telegram" | "custom";
+
+export interface WebhookTestResult {
+  success: boolean;
+  error?: string;
+  latencyMs?: number;
+}
+
+export interface WebhookTarget {
+  id: number;
+  name: string;
+  providerType: WebhookProviderType;
+
+  webhookUrl: string | null;
+
+  telegramBotToken: string | null;
+  telegramChatId: string | null;
+
+  dingtalkSecret: string | null;
+
+  customTemplate: Record<string, unknown> | null;
+  customHeaders: Record<string, string> | null;
+
+  proxyUrl: string | null;
+  proxyFallbackToDirect: boolean;
+
+  isEnabled: boolean;
+  lastTestAt: Date | null;
+  lastTestResult: WebhookTestResult | null;
+
+  createdAt: Date | null;
+  updatedAt: Date | null;
+}
+
+export interface CreateWebhookTargetData {
+  name: string;
+  providerType: WebhookProviderType;
+
+  webhookUrl?: string | null;
+
+  telegramBotToken?: string | null;
+  telegramChatId?: string | null;
+
+  dingtalkSecret?: string | null;
+
+  customTemplate?: Record<string, unknown> | null;
+  customHeaders?: Record<string, string> | null;
+
+  proxyUrl?: string | null;
+  proxyFallbackToDirect?: boolean;
+
+  isEnabled?: boolean;
+}
+
+export type UpdateWebhookTargetData = Partial<CreateWebhookTargetData>;
+
+function toWebhookTarget(row: typeof webhookTargets.$inferSelect): WebhookTarget {
+  return {
+    id: row.id,
+    name: row.name,
+    providerType: row.providerType as WebhookProviderType,
+    webhookUrl: row.webhookUrl ?? null,
+    telegramBotToken: row.telegramBotToken ?? null,
+    telegramChatId: row.telegramChatId ?? null,
+    dingtalkSecret: row.dingtalkSecret ?? null,
+    customTemplate: (row.customTemplate as Record<string, unknown> | null) ?? null,
+    customHeaders: (row.customHeaders as Record<string, string> | null) ?? null,
+    proxyUrl: row.proxyUrl ?? null,
+    proxyFallbackToDirect: row.proxyFallbackToDirect ?? false,
+    isEnabled: row.isEnabled ?? true,
+    lastTestAt: row.lastTestAt ?? null,
+    lastTestResult: (row.lastTestResult as WebhookTestResult | null) ?? null,
+    createdAt: row.createdAt ?? null,
+    updatedAt: row.updatedAt ?? null,
+  };
+}
+
+export async function getAllWebhookTargets(): Promise<WebhookTarget[]> {
+  const rows = await db.select().from(webhookTargets).orderBy(desc(webhookTargets.id));
+  return rows.map(toWebhookTarget);
+}
+
+export async function getWebhookTargetById(id: number): Promise<WebhookTarget | null> {
+  const [row] = await db.select().from(webhookTargets).where(eq(webhookTargets.id, id)).limit(1);
+  return row ? toWebhookTarget(row) : null;
+}
+
+export async function createWebhookTarget(data: CreateWebhookTargetData): Promise<WebhookTarget> {
+  const now = new Date();
+
+  const [created] = await db
+    .insert(webhookTargets)
+    .values({
+      name: data.name,
+      providerType: data.providerType,
+      webhookUrl: data.webhookUrl ?? null,
+      telegramBotToken: data.telegramBotToken ?? null,
+      telegramChatId: data.telegramChatId ?? null,
+      dingtalkSecret: data.dingtalkSecret ?? null,
+      customTemplate: data.customTemplate ?? null,
+      customHeaders: data.customHeaders ?? null,
+      proxyUrl: data.proxyUrl ?? null,
+      proxyFallbackToDirect: data.proxyFallbackToDirect ?? false,
+      isEnabled: data.isEnabled ?? true,
+      updatedAt: now,
+    })
+    .returning();
+
+  if (!created) {
+    throw new Error("创建 Webhook 目标失败");
+  }
+
+  return toWebhookTarget(created);
+}
+
+export async function updateWebhookTarget(
+  id: number,
+  data: UpdateWebhookTargetData
+): Promise<WebhookTarget> {
+  const now = new Date();
+
+  const [updated] = await db
+    .update(webhookTargets)
+    .set({
+      ...(data.name !== undefined ? { name: data.name } : {}),
+      ...(data.providerType !== undefined ? { providerType: data.providerType } : {}),
+      ...(data.webhookUrl !== undefined ? { webhookUrl: data.webhookUrl } : {}),
+      ...(data.telegramBotToken !== undefined ? { telegramBotToken: data.telegramBotToken } : {}),
+      ...(data.telegramChatId !== undefined ? { telegramChatId: data.telegramChatId } : {}),
+      ...(data.dingtalkSecret !== undefined ? { dingtalkSecret: data.dingtalkSecret } : {}),
+      ...(data.customTemplate !== undefined ? { customTemplate: data.customTemplate } : {}),
+      ...(data.customHeaders !== undefined ? { customHeaders: data.customHeaders } : {}),
+      ...(data.proxyUrl !== undefined ? { proxyUrl: data.proxyUrl } : {}),
+      ...(data.proxyFallbackToDirect !== undefined
+        ? { proxyFallbackToDirect: data.proxyFallbackToDirect }
+        : {}),
+      ...(data.isEnabled !== undefined ? { isEnabled: data.isEnabled } : {}),
+      updatedAt: now,
+    })
+    .where(eq(webhookTargets.id, id))
+    .returning();
+
+  if (!updated) {
+    throw new Error("更新 Webhook 目标失败");
+  }
+
+  return toWebhookTarget(updated);
+}
+
+export async function deleteWebhookTarget(id: number): Promise<void> {
+  await db.delete(webhookTargets).where(eq(webhookTargets.id, id));
+}
+
+export async function updateTestResult(id: number, result: WebhookTestResult): Promise<void> {
+  const now = new Date();
+  await db
+    .update(webhookTargets)
+    .set({
+      lastTestAt: now,
+      lastTestResult: result,
+      updatedAt: now,
+    })
+    .where(eq(webhookTargets.id, id));
+}

+ 27 - 0
src/types/fetch-socks.d.ts

@@ -0,0 +1,27 @@
+/**
+ * fetch-socks 类型声明(项目内最小可用版本)
+ *
+ * 背景:fetch-socks 目前未提供完整的 TypeScript 类型声明,但本项目仅使用 socksDispatcher
+ * 来创建 undici 兼容的 Dispatcher(用于 SOCKS4/SOCKS5 代理)。
+ *
+ * 说明:这里的类型只覆盖本项目的使用场景,避免 typecheck 失败。
+ */
+declare module "fetch-socks" {
+  import type { Dispatcher } from "undici";
+
+  export type SocksDispatcherOptions = {
+    type: 4 | 5;
+    host: string;
+    port: number;
+    userId?: string;
+    password?: string;
+  };
+
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  export type SocksDispatcherConnectOptions = any;
+
+  export function socksDispatcher(
+    options: SocksDispatcherOptions,
+    connectOptions?: SocksDispatcherConnectOptions
+  ): Dispatcher;
+}

+ 29 - 0
tests/api/api-actions-integrity.test.ts

@@ -164,6 +164,33 @@ describe("OpenAPI 端点完整性检查", () => {
     }
   });
 
+  test("Webhook 目标管理模块的所有端点应该被注册", () => {
+    const expectedPaths = [
+      "/api/actions/webhook-targets/getWebhookTargetsAction",
+      "/api/actions/webhook-targets/createWebhookTargetAction",
+      "/api/actions/webhook-targets/updateWebhookTargetAction",
+      "/api/actions/webhook-targets/deleteWebhookTargetAction",
+      "/api/actions/webhook-targets/testWebhookTargetAction",
+    ];
+
+    for (const path of expectedPaths) {
+      expect(openApiDoc.paths[path]).toBeDefined();
+      expect(openApiDoc.paths[path].post).toBeDefined();
+    }
+  });
+
+  test("通知绑定模块的所有端点应该被注册", () => {
+    const expectedPaths = [
+      "/api/actions/notification-bindings/getBindingsForTypeAction",
+      "/api/actions/notification-bindings/updateBindingsAction",
+    ];
+
+    for (const path of expectedPaths) {
+      expect(openApiDoc.paths[path]).toBeDefined();
+      expect(openApiDoc.paths[path].post).toBeDefined();
+    }
+  });
+
   test("所有端点的 summary 应该非空", () => {
     const pathsWithoutSummary: string[] = [];
 
@@ -192,6 +219,8 @@ describe("OpenAPI 端点完整性检查", () => {
       "/api/actions/sensitive-words/": "敏感词管理",
       "/api/actions/active-sessions/": "Session 管理",
       "/api/actions/notifications/": "通知管理",
+      "/api/actions/webhook-targets/": "通知管理",
+      "/api/actions/notification-bindings/": "通知管理",
     };
 
     for (const [path, methods] of Object.entries(openApiDoc.paths)) {

+ 3 - 3
tests/api/api-openapi-spec.test.ts

@@ -192,9 +192,9 @@ describe("OpenAPI 规范验证", () => {
   test("端点数量应该符合预期", () => {
     const totalPaths = Object.keys(openApiDoc.paths).length;
 
-    // 根据代码分析,应该有 39 个端点
-    expect(totalPaths).toBeGreaterThanOrEqual(35);
-    expect(totalPaths).toBeLessThanOrEqual(45);
+    // 端点数量会随着功能模块增长而变化:这里只做“合理范围”约束
+    expect(totalPaths).toBeGreaterThanOrEqual(40);
+    expect(totalPaths).toBeLessThanOrEqual(60);
   });
 
   test("summary 和 description 应该不同", () => {

+ 134 - 0
tests/e2e/notification-settings.test.ts

@@ -0,0 +1,134 @@
+/**
+ * 通知设置(Webhook Targets / Bindings)E2E 测试
+ *
+ * 覆盖范围:
+ * - 创建/更新/删除推送目标
+ * - 绑定通知类型与目标
+ * - 验证创建目标后自动退出 legacy 模式(useLegacyMode=false)
+ *
+ * 前提:
+ * - 开发服务器运行在 http://localhost:13500
+ * - 已配置 ADMIN_TOKEN(或 TEST_ADMIN_TOKEN)
+ */
+
+import { afterAll, describe, expect, test } from "vitest";
+
+const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:13500/api/actions";
+const ADMIN_TOKEN = process.env.TEST_ADMIN_TOKEN || process.env.ADMIN_TOKEN;
+
+async function callApi(module: string, action: string, body: Record<string, unknown> = {}) {
+  const response = await fetch(`${API_BASE_URL}/${module}/${action}`, {
+    method: "POST",
+    headers: {
+      "Content-Type": "application/json",
+      Cookie: `auth-token=${ADMIN_TOKEN}`,
+    },
+    body: JSON.stringify(body),
+  });
+
+  const contentType = response.headers.get("content-type");
+  if (contentType?.includes("application/json")) {
+    const data = await response.json();
+    return { response, data };
+  }
+
+  const text = await response.text();
+  return { response, data: { ok: false, error: `非JSON响应: ${text}` } };
+}
+
+async function expectOk(module: string, action: string, body: Record<string, unknown> = {}) {
+  const { response, data } = await callApi(module, action, body);
+  expect(response.ok).toBe(true);
+  expect(data.ok).toBe(true);
+  return data.data;
+}
+
+const testState = {
+  targetIds: [] as number[],
+};
+
+afterAll(async () => {
+  // 尽量清理测试数据(忽略失败)
+  for (const id of testState.targetIds) {
+    try {
+      await callApi("webhook-targets", "deleteWebhookTargetAction", { id });
+    } catch (_e) {
+      // ignore
+    }
+  }
+});
+
+const run = ADMIN_TOKEN ? describe : describe.skip;
+
+run("通知设置 - Webhook 目标与绑定(E2E)", () => {
+  let targetId: number;
+
+  test("1) 获取通知设置", async () => {
+    const settings = await expectOk("notifications", "getNotificationSettingsAction");
+    expect(settings).toBeDefined();
+    expect(typeof settings.enabled).toBe("boolean");
+  });
+
+  test("2) 创建推送目标(custom)", async () => {
+    const result = await expectOk("webhook-targets", "createWebhookTargetAction", {
+      name: `E2E Webhook Target ${Date.now()}`,
+      providerType: "custom",
+      webhookUrl: "https://example.com/webhook",
+      customTemplate: JSON.stringify({ text: "title={{title}}" }),
+      customHeaders: { "X-Test": "1" },
+      proxyUrl: null,
+      proxyFallbackToDirect: false,
+      isEnabled: true,
+    });
+
+    expect(result).toBeDefined();
+    expect(result.id).toBeTypeOf("number");
+    expect(result.providerType).toBe("custom");
+    targetId = result.id;
+    testState.targetIds.push(targetId);
+  });
+
+  test("3) 创建目标后应处于新模式(useLegacyMode=false)", async () => {
+    const settings = await expectOk("notifications", "getNotificationSettingsAction");
+    expect(settings.useLegacyMode).toBe(false);
+  });
+
+  test("4) 支持局部更新目标配置", async () => {
+    const updated = await expectOk("webhook-targets", "updateWebhookTargetAction", {
+      id: targetId,
+      input: { isEnabled: false },
+    });
+
+    expect(updated.id).toBe(targetId);
+    expect(updated.isEnabled).toBe(false);
+  });
+
+  test("5) 绑定 daily_leaderboard -> target", async () => {
+    await expectOk("notification-bindings", "updateBindingsAction", {
+      type: "daily_leaderboard",
+      bindings: [{ targetId, isEnabled: true }],
+    });
+
+    const bindings = await expectOk("notification-bindings", "getBindingsForTypeAction", {
+      type: "daily_leaderboard",
+    });
+
+    expect(Array.isArray(bindings)).toBe(true);
+    expect(bindings.length).toBe(1);
+    expect(bindings[0].targetId).toBe(targetId);
+    expect(bindings[0].target.id).toBe(targetId);
+  });
+
+  test("6) 删除目标应使绑定不可见", async () => {
+    await expectOk("webhook-targets", "deleteWebhookTargetAction", { id: targetId });
+
+    // 从清理列表移除,避免重复删除
+    testState.targetIds = testState.targetIds.filter((id) => id !== targetId);
+
+    const bindings = await expectOk("notification-bindings", "getBindingsForTypeAction", {
+      type: "daily_leaderboard",
+    });
+    expect(Array.isArray(bindings)).toBe(true);
+    expect(bindings.length).toBe(0);
+  });
+});

+ 85 - 0
tests/integration/notification-bindings.test.ts

@@ -0,0 +1,85 @@
+/**
+ * Notification Bindings Repository 集成测试
+ *
+ * 覆盖范围:
+ * - upsertBindings:新增/更新/删除缺失绑定
+ * - getBindingsByType:带 target join
+ * - deleteWebhookTarget 后绑定应被级联删除
+ */
+
+import { and, eq } from "drizzle-orm";
+import { describe, expect, test } from "vitest";
+import { db } from "@/drizzle/db";
+import { notificationTargetBindings } from "@/drizzle/schema";
+import { deleteWebhookTarget, createWebhookTarget } from "@/repository/webhook-targets";
+import { getBindingsByType, upsertBindings } from "@/repository/notification-bindings";
+
+const run = process.env.DSN ? describe : describe.skip;
+
+run("Notification Bindings Repository(集成测试)", () => {
+  test("should upsert bindings and cascade delete on target removal", async () => {
+    const targetA = await createWebhookTarget({
+      name: `绑定目标A_${Date.now()}`,
+      providerType: "custom",
+      webhookUrl: "https://example.com/webhook",
+      customTemplate: { text: "title={{title}}" },
+      isEnabled: true,
+    });
+
+    const targetB = await createWebhookTarget({
+      name: `绑定目标B_${Date.now()}`,
+      providerType: "wechat",
+      webhookUrl: "https://example.com/webhook2",
+      isEnabled: true,
+    });
+
+    try {
+      // 1) 插入两条绑定
+      await upsertBindings("daily_leaderboard", [
+        {
+          targetId: targetA.id,
+          isEnabled: true,
+          scheduleCron: null,
+          scheduleTimezone: "Asia/Shanghai",
+        },
+        { targetId: targetB.id, isEnabled: false },
+      ]);
+
+      let bindings = await getBindingsByType("daily_leaderboard");
+      expect(bindings.length).toBeGreaterThanOrEqual(2);
+
+      const bindingA = bindings.find((b) => b.targetId === targetA.id);
+      expect(bindingA).toBeDefined();
+      expect(bindingA?.isEnabled).toBe(true);
+      expect(bindingA?.target.providerType).toBe("custom");
+
+      // 2) 仅保留 A:应删除 B 的绑定
+      await upsertBindings("daily_leaderboard", [{ targetId: targetA.id, isEnabled: true }]);
+      bindings = await getBindingsByType("daily_leaderboard");
+      expect(bindings.some((b) => b.targetId === targetB.id)).toBe(false);
+      expect(bindings.some((b) => b.targetId === targetA.id)).toBe(true);
+
+      // 3) 删除 targetA:绑定应被级联删除(表级别校验)
+      await deleteWebhookTarget(targetA.id);
+
+      const rows = await db
+        .select()
+        .from(notificationTargetBindings)
+        .where(
+          and(
+            eq(notificationTargetBindings.notificationType, "daily_leaderboard"),
+            eq(notificationTargetBindings.targetId, targetA.id)
+          )
+        );
+      expect(rows.length).toBe(0);
+    } finally {
+      // targetA 可能已删除,忽略错误
+      try {
+        await deleteWebhookTarget(targetA.id);
+      } catch (_e) {
+        // ignore
+      }
+      await deleteWebhookTarget(targetB.id);
+    }
+  });
+});

+ 57 - 0
tests/integration/webhook-targets-crud.test.ts

@@ -0,0 +1,57 @@
+/**
+ * Webhook Targets Repository 集成测试
+ *
+ * 覆盖范围:
+ * - create / get / list / update / delete
+ * - updateTestResult 写回
+ */
+
+import { describe, expect, test } from "vitest";
+import {
+  createWebhookTarget,
+  deleteWebhookTarget,
+  getAllWebhookTargets,
+  getWebhookTargetById,
+  updateTestResult,
+  updateWebhookTarget,
+} from "@/repository/webhook-targets";
+
+const run = process.env.DSN ? describe : describe.skip;
+
+run("Webhook Targets Repository(集成测试)", () => {
+  test("should create, update and delete webhook target", async () => {
+    const created = await createWebhookTarget({
+      name: `测试目标_${Date.now()}`,
+      providerType: "wechat",
+      webhookUrl: "https://example.com/webhook",
+      isEnabled: true,
+    });
+
+    try {
+      expect(created.id).toBeTypeOf("number");
+      expect(created.name).toContain("测试目标_");
+      expect(created.providerType).toBe("wechat");
+
+      const found = await getWebhookTargetById(created.id);
+      expect(found).not.toBeNull();
+      expect(found?.id).toBe(created.id);
+
+      const updated = await updateWebhookTarget(created.id, {
+        name: `${created.name}_updated`,
+        isEnabled: false,
+      });
+      expect(updated.name).toContain("_updated");
+      expect(updated.isEnabled).toBe(false);
+
+      await updateTestResult(created.id, { success: true, latencyMs: 12 });
+      const afterTest = await getWebhookTargetById(created.id);
+      expect(afterTest?.lastTestResult?.success).toBe(true);
+      expect(afterTest?.lastTestResult?.latencyMs).toBe(12);
+
+      const list = await getAllWebhookTargets();
+      expect(list.some((t) => t.id === created.id)).toBe(true);
+    } finally {
+      await deleteWebhookTarget(created.id);
+    }
+  });
+});

+ 71 - 0
tests/unit/webhook/notifier.test.ts

@@ -103,6 +103,77 @@ describe("WebhookNotifier", () => {
       expect(result.success).toBe(true);
       expect(mockFetch).toHaveBeenCalledTimes(2);
     });
+
+    it("should send dingtalk message with signature params", async () => {
+      vi.spyOn(Date, "now").mockReturnValue(1700000000000);
+
+      mockFetch.mockResolvedValue({
+        ok: true,
+        json: () => Promise.resolve({ errcode: 0, errmsg: "ok" }),
+      });
+
+      const notifier = new WebhookNotifier({
+        providerType: "dingtalk",
+        webhookUrl: "https://oapi.dingtalk.com/robot/send?access_token=token",
+        dingtalkSecret: "secret",
+      });
+
+      const result = await notifier.send(createMessage());
+
+      expect(result.success).toBe(true);
+      const calledUrl = String(mockFetch.mock.calls[0]?.[0]);
+      const url = new URL(calledUrl);
+      expect(url.searchParams.get("access_token")).toBe("token");
+      expect(url.searchParams.get("timestamp")).toBe("1700000000000");
+      expect(url.searchParams.get("sign")).toBeTruthy();
+    });
+
+    it("should send telegram message to bot endpoint", async () => {
+      mockFetch.mockResolvedValue({
+        ok: true,
+        json: () => Promise.resolve({ ok: true, result: {} }),
+      });
+
+      const notifier = new WebhookNotifier({
+        providerType: "telegram",
+        telegramBotToken: "token",
+        telegramChatId: "123",
+      });
+
+      const result = await notifier.send(createMessage());
+
+      expect(result.success).toBe(true);
+      expect(String(mockFetch.mock.calls[0]?.[0])).toBe(
+        "https://api.telegram.org/bottoken/sendMessage"
+      );
+
+      const init = mockFetch.mock.calls[0]?.[1] as any;
+      const body = JSON.parse(init.body) as any;
+      expect(body.chat_id).toBe("123");
+      expect(body.parse_mode).toBe("HTML");
+    });
+
+    it("should treat custom webhook as success without parsing json", async () => {
+      mockFetch.mockResolvedValue({
+        ok: true,
+      });
+
+      const notifier = new WebhookNotifier({
+        providerType: "custom",
+        webhookUrl: "https://example.com/hook",
+        customTemplate: { text: "title={{title}}" },
+        customHeaders: { "X-Test": "1" },
+      });
+
+      const result = await notifier.send(createMessage(), {
+        notificationType: "circuit_breaker",
+        data: { providerName: "OpenAI" },
+      });
+
+      expect(result.success).toBe(true);
+      const init = mockFetch.mock.calls[0]?.[1] as any;
+      expect(init.headers["X-Test"]).toBe("1");
+    });
   });
 
   describe("feishu response handling", () => {

+ 58 - 0
tests/unit/webhook/renderers/custom.test.ts

@@ -0,0 +1,58 @@
+import { describe, expect, it } from "vitest";
+import { CustomRenderer } from "@/lib/webhook/renderers/custom";
+import type { StructuredMessage } from "@/lib/webhook/types";
+
+describe("CustomRenderer", () => {
+  const message: StructuredMessage = {
+    header: { title: "测试标题", level: "info" },
+    sections: [{ content: [{ type: "text", value: "正文内容" }] }],
+    timestamp: new Date("2025-01-02T12:00:00Z"),
+  };
+
+  it("should interpolate placeholders and include custom headers", () => {
+    const renderer = new CustomRenderer(
+      {
+        text: "title={{title}} provider={{provider_name}}",
+        meta: {
+          level: "{{level}}",
+          when: "{{timestamp}}",
+        },
+      },
+      { "X-Test": "1" }
+    );
+
+    const result = renderer.render(message, {
+      notificationType: "circuit_breaker",
+      data: { providerName: "OpenAI" },
+    });
+
+    const body = JSON.parse(result.body) as any;
+    expect(result.headers).toEqual({ "X-Test": "1" });
+    expect(body.text).toContain("title=测试标题");
+    expect(body.text).toContain("provider=OpenAI");
+    expect(body.meta.level).toBe("info");
+    expect(body.meta.when).toContain("2025-01-02T12:00:00.000Z");
+  });
+
+  it("should use templateOverride when provided", () => {
+    const renderer = new CustomRenderer({ foo: "{{title}}" }, null);
+
+    const result = renderer.render(message, {
+      templateOverride: { bar: "override={{title}}" },
+    });
+
+    const body = JSON.parse(result.body) as any;
+    expect(body.foo).toBeUndefined();
+    expect(body.bar).toBe("override=测试标题");
+  });
+
+  it("should throw when templateOverride is invalid", () => {
+    const renderer = new CustomRenderer({ foo: "bar" }, null);
+    expect(() =>
+      renderer.render(message, {
+        // eslint-disable-next-line @typescript-eslint/no-explicit-any
+        templateOverride: [] as any,
+      })
+    ).toThrow("自定义 Webhook 模板必须是 JSON 对象");
+  });
+});

+ 57 - 0
tests/unit/webhook/renderers/dingtalk.test.ts

@@ -0,0 +1,57 @@
+import { describe, expect, it } from "vitest";
+import { DingTalkRenderer } from "@/lib/webhook/renderers/dingtalk";
+import type { StructuredMessage } from "@/lib/webhook/types";
+
+describe("DingTalkRenderer", () => {
+  const renderer = new DingTalkRenderer();
+
+  it("should render markdown payload with escaped content", () => {
+    const message: StructuredMessage = {
+      header: { title: "测试 <标题>&", level: "info" },
+      sections: [
+        {
+          title: "概览",
+          content: [{ type: "text", value: "Hello & <world>" }],
+        },
+        {
+          content: [{ type: "quote", value: "引用 <tag>" }],
+        },
+        {
+          title: "字段",
+          content: [
+            {
+              type: "fields",
+              items: [{ label: "状态", value: "<OK>&" }],
+            },
+          ],
+        },
+        {
+          content: [
+            {
+              type: "list",
+              style: "ordered",
+              items: [{ primary: "用户A", secondary: "消费 <10>&" }],
+            },
+          ],
+        },
+      ],
+      footer: [{ content: [{ type: "text", value: "footer" }] }],
+      timestamp: new Date("2025-01-02T12:00:00Z"),
+    };
+
+    const result = renderer.render(message);
+    const body = JSON.parse(result.body) as any;
+
+    expect(body.msgtype).toBe("markdown");
+    expect(body.markdown.title).toBe("测试 &lt;标题&gt;&amp;");
+    expect(body.markdown.text).toContain("### 测试 &lt;标题&gt;&amp;");
+    expect(body.markdown.text).toContain("**概览**");
+    expect(body.markdown.text).toContain("Hello &amp; &lt;world&gt;");
+    expect(body.markdown.text).toContain("> 引用 &lt;tag&gt;");
+    expect(body.markdown.text).toContain("- 状态: &lt;OK&gt;&amp;");
+    expect(body.markdown.text).toContain("1. **用户A**");
+    expect(body.markdown.text).toContain("消费 &lt;10&gt;&amp;");
+    expect(body.markdown.text).toContain("footer");
+    expect(body.markdown.text).toContain("2025");
+  });
+});

+ 51 - 0
tests/unit/webhook/renderers/telegram.test.ts

@@ -0,0 +1,51 @@
+import { describe, expect, it } from "vitest";
+import { TelegramRenderer } from "@/lib/webhook/renderers/telegram";
+import type { StructuredMessage } from "@/lib/webhook/types";
+
+describe("TelegramRenderer", () => {
+  it("should render HTML payload with chat_id and parse_mode", () => {
+    const renderer = new TelegramRenderer("123");
+
+    const message: StructuredMessage = {
+      header: { title: '测试 <标题>&"', level: "info" },
+      sections: [
+        { content: [{ type: "text", value: 'Hello & <world> "' }] },
+        { content: [{ type: "quote", value: "引用 <tag>" }] },
+        {
+          title: "字段",
+          content: [
+            {
+              type: "fields",
+              items: [{ label: "状态", value: "<OK>&" }],
+            },
+          ],
+        },
+        {
+          content: [
+            {
+              type: "list",
+              style: "bullet",
+              items: [{ primary: "用户A", secondary: '消费 <10>& "' }],
+            },
+          ],
+        },
+      ],
+      timestamp: new Date("2025-01-02T12:00:00Z"),
+    };
+
+    const result = renderer.render(message);
+    const body = JSON.parse(result.body) as any;
+
+    expect(body.chat_id).toBe("123");
+    expect(body.parse_mode).toBe("HTML");
+    expect(body.disable_web_page_preview).toBe(true);
+    expect(body.text).toContain("<b>测试 &lt;标题&gt;&amp;&quot;</b>");
+    expect(body.text).toContain("Hello &amp; &lt;world&gt; &quot;");
+    expect(body.text).toContain("&gt; 引用 &lt;tag&gt;");
+    expect(body.text).toContain("<b>字段</b>");
+    expect(body.text).toContain("<b>状态</b>: &lt;OK&gt;&amp;");
+    expect(body.text).toContain("- <b>用户A</b>");
+    expect(body.text).toContain("消费 &lt;10&gt;&amp; &quot;");
+    expect(body.text).toContain("2025");
+  });
+});

+ 136 - 0
tests/unit/webhook/templates/placeholders.test.ts

@@ -0,0 +1,136 @@
+import { describe, expect, it } from "vitest";
+import {
+  buildTemplateVariables,
+  getTemplatePlaceholders,
+} from "@/lib/webhook/templates/placeholders";
+import type { StructuredMessage } from "@/lib/webhook/types";
+
+describe("Webhook Template Placeholders", () => {
+  it("getTemplatePlaceholders should return common placeholders by default", () => {
+    const placeholders = getTemplatePlaceholders();
+    const keys = placeholders.map((p) => p.key);
+    expect(keys).toContain("{{timestamp}}");
+    expect(keys).toContain("{{sections}}");
+    // 仅 common:至少 5 个
+    expect(placeholders.length).toBeGreaterThanOrEqual(5);
+  });
+
+  it("getTemplatePlaceholders should include type-specific placeholders", () => {
+    const placeholders = getTemplatePlaceholders("cost_alert");
+    const keys = placeholders.map((p) => p.key);
+    expect(keys).toContain("{{timestamp}}");
+    expect(keys).toContain("{{usage_percent}}");
+  });
+
+  it("buildTemplateVariables should build common and circuit_breaker variables", () => {
+    const message: StructuredMessage = {
+      header: { title: "标题", level: "error" },
+      sections: [
+        {
+          title: "信息",
+          content: [
+            { type: "text", value: "普通文本" },
+            { type: "quote", value: "引用内容" },
+            {
+              type: "list",
+              style: "ordered",
+              items: [{ primary: "用户A", secondary: "消费 10" }],
+            },
+          ],
+        },
+      ],
+      footer: [{ content: [{ type: "text", value: "footer" }] }],
+      timestamp: new Date("2025-01-02T12:00:00Z"),
+    };
+
+    const vars = buildTemplateVariables({
+      message,
+      notificationType: "circuit_breaker",
+      data: {
+        providerName: "OpenAI",
+        providerId: 1,
+        failureCount: 3,
+        retryAt: "2025-01-02T13:00:00Z",
+        lastError: "timeout",
+      },
+    });
+
+    expect(vars["{{title}}"]).toBe("标题");
+    expect(vars["{{level}}"]).toBe("error");
+    expect(vars["{{timestamp}}"]).toBe("2025-01-02T12:00:00.000Z");
+    expect(vars["{{provider_name}}"]).toBe("OpenAI");
+    expect(vars["{{provider_id}}"]).toBe("1");
+    expect(vars["{{failure_count}}"]).toBe("3");
+    expect(vars["{{retry_at}}"]).toBe("2025-01-02T13:00:00Z");
+    expect(vars["{{last_error}}"]).toBe("timeout");
+    expect(vars["{{sections}}"]).toContain("信息");
+    expect(vars["{{sections}}"]).toContain("普通文本");
+    expect(vars["{{sections}}"]).toContain("> 引用内容");
+    expect(vars["{{sections}}"]).toContain("1. 用户A");
+    expect(vars["{{sections}}"]).toContain("消费 10");
+    expect(vars["{{sections}}"]).toContain("footer");
+  });
+
+  it("buildTemplateVariables should handle daily_leaderboard entries JSON stringify errors", () => {
+    // 构造循环引用,验证 safeJsonStringify 降级
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    const circular: any[] = [];
+    circular.push(circular);
+
+    const message: StructuredMessage = {
+      header: { title: "排行榜", level: "info" },
+      sections: [],
+      timestamp: new Date("2025-01-02T12:00:00Z"),
+    };
+
+    const vars = buildTemplateVariables({
+      message,
+      notificationType: "daily_leaderboard",
+      data: {
+        date: "2025-01-02",
+        entries: circular,
+        totalRequests: 10,
+        totalCost: 1.23,
+      },
+    });
+
+    expect(vars["{{date}}"]).toBe("2025-01-02");
+    expect(vars["{{entries_json}}"]).toBe("[]");
+    expect(vars["{{total_requests}}"]).toBe("10");
+    expect(vars["{{total_cost}}"]).toBe("1.23");
+  });
+
+  it("buildTemplateVariables should compute usage percent for cost_alert", () => {
+    const message: StructuredMessage = {
+      header: { title: "成本预警", level: "warning" },
+      sections: [],
+      timestamp: new Date("2025-01-02T12:00:00Z"),
+    };
+
+    const vars = buildTemplateVariables({
+      message,
+      notificationType: "cost_alert",
+      data: { targetType: "user", targetName: "张三", currentCost: 80, quotaLimit: 100 },
+    });
+
+    expect(vars["{{target_type}}"]).toBe("user");
+    expect(vars["{{target_name}}"]).toBe("张三");
+    expect(vars["{{usage_percent}}"]).toBe("80.0");
+  });
+
+  it("buildTemplateVariables should return empty usage percent when quota is invalid", () => {
+    const message: StructuredMessage = {
+      header: { title: "成本预警", level: "warning" },
+      sections: [],
+      timestamp: new Date("2025-01-02T12:00:00Z"),
+    };
+
+    const vars = buildTemplateVariables({
+      message,
+      notificationType: "cost_alert",
+      data: { targetType: "user", targetName: "张三", currentCost: 80, quotaLimit: 0 },
+    });
+
+    expect(vars["{{usage_percent}}"]).toBe("");
+  });
+});

+ 41 - 0
vitest.integration.config.ts

@@ -0,0 +1,41 @@
+import path from "node:path";
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+  test: {
+    globals: true,
+    environment: "node",
+    setupFiles: ["./tests/setup.ts"],
+    api: {
+      host: process.env.VITEST_API_HOST || "127.0.0.1",
+      port: Number(process.env.VITEST_API_PORT || 51204),
+      strictPort: false,
+    },
+    open: false,
+    testTimeout: 20000,
+    hookTimeout: 20000,
+    maxConcurrency: 5,
+    pool: "threads",
+    // 仅运行与 Issue #485 相关的数据库集成测试
+    // 说明:仓库中存在其它“需要完整运行时/外部依赖”的集成测试,默认仍由主配置排除。
+    include: [
+      "tests/integration/webhook-targets-crud.test.ts",
+      "tests/integration/notification-bindings.test.ts",
+    ],
+    exclude: ["node_modules", ".next", "dist", "build", "coverage", "**/*.d.ts"],
+    reporters: ["verbose"],
+    isolate: true,
+    mockReset: true,
+    restoreMocks: true,
+    clearMocks: true,
+    resolveSnapshotPath: (testPath, snapExtension) => {
+      return testPath.replace(/\.test\.([tj]sx?)$/, `${snapExtension}.$1`);
+    },
+  },
+  resolve: {
+    alias: {
+      "@": path.resolve(__dirname, "./src"),
+      "server-only": path.resolve(__dirname, "./tests/server-only.mock.ts"),
+    },
+  },
+});