From e557bd3d12737425a6206e7861501412ec438e92 Mon Sep 17 00:00:00 2001 From: Ding <44717411+ding113@users.noreply.github.com> Date: Fri, 2 Jan 2026 16:35:30 +0800 Subject: [PATCH 01/13] =?UTF-8?q?fix:=20=E4=BC=98=E5=8C=96=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=BA=93=E8=BF=9E=E6=8E=A5=E6=B1=A0=E4=B8=8E=E8=AF=B7?= =?UTF-8?q?=E6=B1=82=E6=97=A5=E5=BF=97=E5=86=99=E5=85=A5=E6=80=A7=E8=83=BD?= =?UTF-8?q?=20(#503)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 优化数据库连接池与请求日志写入性能 * fix: 修复异步写缓冲的停机与重试边界 --- .env.example | 18 + README.md | 7 + src/app/v1/_lib/proxy/response-handler.ts | 23 +- src/drizzle/db.ts | 13 +- src/instrumentation.ts | 12 + src/lib/config/env.schema.ts | 58 +++ src/repository/message-write-buffer.ts | 352 ++++++++++++++++++ src/repository/message.ts | 17 + tests/unit/drizzle/db-pool-config.test.ts | 108 ++++++ .../repository/message-write-buffer.test.ts | 265 +++++++++++++ 10 files changed, 863 insertions(+), 10 deletions(-) create mode 100644 src/repository/message-write-buffer.ts create mode 100644 tests/unit/drizzle/db-pool-config.test.ts create mode 100644 tests/unit/repository/message-write-buffer.test.ts diff --git a/.env.example b/.env.example index 2109f2f80..e03069733 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,24 @@ AUTO_MIGRATE=true # 数据库连接字符串(仅用于本地开发或非 Docker Compose 部署) DSN="postgres://user:password@host:port/db_name" +# PostgreSQL 连接池配置(postgres.js) +# 说明: +# - 这些值是“每个应用进程”的连接池上限;k8s 多副本时需要按副本数分摊 +# - 默认值:生产环境 20,开发环境 10(可按需覆盖) +DB_POOL_MAX=20 +DB_POOL_IDLE_TIMEOUT=20 # 空闲连接回收(秒) +DB_POOL_CONNECT_TIMEOUT=10 # 建立连接超时(秒) + +# message_request 写入模式 +# - async:异步批量写入(默认,降低 DB 写放大与连接占用) +# - sync:同步写入(兼容旧行为,但高并发下会增加请求尾部阻塞) +MESSAGE_REQUEST_WRITE_MODE=async + +# message_request 异步批量参数(可选) +MESSAGE_REQUEST_ASYNC_FLUSH_INTERVAL_MS=250 +MESSAGE_REQUEST_ASYNC_BATCH_SIZE=200 +MESSAGE_REQUEST_ASYNC_MAX_PENDING=5000 + # 数据库配置(Docker Compose 部署时使用) DB_USER=postgres DB_PASSWORD=your-secure-password_change-me diff --git a/README.md b/README.md index e714a415b..e07501708 100644 --- a/README.md +++ b/README.md @@ -275,6 +275,13 @@ Docker Compose 是**首选部署方式**,自动配置数据库、Redis 和应 | ------------------------------------------ | ------------------------ | ---------------------------------------------------------------------------- | | `ADMIN_TOKEN` | `change-me` | 后台登录令牌,部署前必须修改。 | | `DSN` | - | PostgreSQL 连接串,如 `postgres://user:pass@host:5432/db`. | +| `DB_POOL_MAX` | 生产环境 `20` / 开发 `10` | PostgreSQL 连接池上限(每进程);高并发可提高,k8s 多副本需结合 `max_connections` 分摊。 | +| `DB_POOL_IDLE_TIMEOUT` | `20` | 空闲连接回收(秒);避免连接长期占用。 | +| `DB_POOL_CONNECT_TIMEOUT` | `10` | 建立连接超时(秒);避免网络异常时卡住连接获取。 | +| `MESSAGE_REQUEST_WRITE_MODE` | `async` | 请求日志写入模式:`async` 异步批量(默认);`sync` 同步写入(更实时但更慢)。 | +| `MESSAGE_REQUEST_ASYNC_FLUSH_INTERVAL_MS` | `250` | 异步批量写入 flush 间隔(毫秒)。 | +| `MESSAGE_REQUEST_ASYNC_BATCH_SIZE` | `200` | 单次批量写入最大条数(避免单条 SQL 过大)。 | +| `MESSAGE_REQUEST_ASYNC_MAX_PENDING` | `5000` | 内存队列上限(防止 DB 异常时无限增长;超限将丢弃最旧更新并告警)。 | | `AUTO_MIGRATE` | `true` | 启动时自动执行 Drizzle 迁移;生产环境可关闭以人工控制。 | | `REDIS_URL` | `redis://localhost:6379` | Redis 地址,支持 `rediss://` 用于 TLS。 | | `REDIS_TLS_REJECT_UNAUTHORIZED` | `true` | 是否验证 Redis TLS 证书;设为 `false` 可跳过验证(用于自签/共享证书)。 | diff --git a/src/app/v1/_lib/proxy/response-handler.ts b/src/app/v1/_lib/proxy/response-handler.ts index 725515405..8f5e730ab 100644 --- a/src/app/v1/_lib/proxy/response-handler.ts +++ b/src/app/v1/_lib/proxy/response-handler.ts @@ -1,4 +1,5 @@ import { AsyncTaskManager } from "@/lib/async-task-manager"; +import { getEnvConfig } from "@/lib/config/env.schema"; import { logger } from "@/lib/logger"; import { ProxyStatusTracker } from "@/lib/proxy-status-tracker"; import { RateLimitService } from "@/lib/rate-limit"; @@ -1967,14 +1968,20 @@ async function persistRequestFailure(options: { context1mApplied: session.getContext1mApplied(), }); - logger.info("ResponseHandler: Successfully persisted request failure", { - taskId, - phase, - messageId: messageContext.id, - duration, - statusCode, - errorMessage, - }); + const isAsyncWrite = getEnvConfig().MESSAGE_REQUEST_WRITE_MODE !== "sync"; + logger.info( + isAsyncWrite + ? "ResponseHandler: Request failure persistence enqueued" + : "ResponseHandler: Successfully persisted request failure", + { + taskId, + phase, + messageId: messageContext.id, + duration, + statusCode, + errorMessage, + } + ); } catch (dbError) { logger.error("ResponseHandler: Failed to persist request failure", { taskId, diff --git a/src/drizzle/db.ts b/src/drizzle/db.ts index 58377ebed..4e3078a24 100644 --- a/src/drizzle/db.ts +++ b/src/drizzle/db.ts @@ -2,18 +2,27 @@ import 'server-only'; import { drizzle, type PostgresJsDatabase } from 'drizzle-orm/postgres-js'; import postgres from 'postgres'; +import { getEnvConfig } from '@/lib/config/env.schema'; import * as schema from './schema'; let dbInstance: PostgresJsDatabase | null = null; function createDbInstance(): PostgresJsDatabase { - const connectionString = process.env.DSN; + const env = getEnvConfig(); + const connectionString = env.DSN; if (!connectionString) { throw new Error('DSN environment variable is not set'); } - const client = postgres(connectionString); + // postgres.js 默认 max=10,在高并发下容易出现查询排队 + // 这里采用“生产环境默认更大、同时可通过 env 覆盖”的策略,兼容单机与 k8s 多副本 + const defaultMax = env.NODE_ENV === 'production' ? 20 : 10; + const client = postgres(connectionString, { + max: env.DB_POOL_MAX ?? defaultMax, + idle_timeout: env.DB_POOL_IDLE_TIMEOUT ?? 20, + connect_timeout: env.DB_POOL_CONNECT_TIMEOUT ?? 10, + }); return drizzle(client, { schema }); } diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 2914d98f6..479d58c0e 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -84,6 +84,18 @@ export async function register() { error: error instanceof Error ? error.message : String(error), }); } + + // 尽力将 message_request 的异步批量更新刷入数据库(避免终止时丢失尾部日志) + try { + const { stopMessageRequestWriteBuffer } = await import( + "@/repository/message-write-buffer" + ); + await stopMessageRequestWriteBuffer(); + } catch (error) { + logger.warn("[Instrumentation] Failed to stop message request write buffer", { + error: error instanceof Error ? error.message : String(error), + }); + } }; process.once("SIGTERM", () => { diff --git a/src/lib/config/env.schema.ts b/src/lib/config/env.schema.ts index 9b17d5118..ccaee2e33 100644 --- a/src/lib/config/env.schema.ts +++ b/src/lib/config/env.schema.ts @@ -9,6 +9,18 @@ import { z } from "zod"; */ const booleanTransform = (s: string) => s !== "false" && s !== "0"; +/** + * 可选数值解析(支持字符串) + * - undefined/null/空字符串 -> undefined + * - 其他 -> 交给 z.coerce.number 处理 + */ +const optionalNumber = (schema: z.ZodNumber) => + z.preprocess((val) => { + if (val === undefined || val === null || val === "") return undefined; + if (typeof val === "string") return Number(val); + return val; + }, schema.optional()); + /** * 环境变量验证schema */ @@ -20,6 +32,52 @@ export const EnvSchema = z.object({ if (val.includes("user:password@host:port")) return undefined; // 占位符模板 return val; }, z.string().url("数据库URL格式无效").optional()), + // PostgreSQL 连接池配置(postgres.js) + // - 多副本部署(k8s)需要结合数据库 max_connections 分摊配置 + // - 这些值为“每个应用进程”的连接池上限 + DB_POOL_MAX: optionalNumber( + z.number().int().min(1, "DB_POOL_MAX 不能小于 1").max(200, "DB_POOL_MAX 不能大于 200") + ), + // 空闲连接回收(秒) + DB_POOL_IDLE_TIMEOUT: optionalNumber( + z + .number() + .min(0, "DB_POOL_IDLE_TIMEOUT 不能小于 0") + .max(3600, "DB_POOL_IDLE_TIMEOUT 不能大于 3600") + ), + // 建连超时(秒) + DB_POOL_CONNECT_TIMEOUT: optionalNumber( + z + .number() + .min(1, "DB_POOL_CONNECT_TIMEOUT 不能小于 1") + .max(120, "DB_POOL_CONNECT_TIMEOUT 不能大于 120") + ), + // message_request 写入模式 + // - sync:同步写入(兼容旧行为,但高并发下会增加请求尾部阻塞) + // - async:异步批量写入(默认,降低 DB 写放大与连接占用) + MESSAGE_REQUEST_WRITE_MODE: z.enum(["sync", "async"]).default("async"), + // 异步批量写入参数 + MESSAGE_REQUEST_ASYNC_FLUSH_INTERVAL_MS: optionalNumber( + z + .number() + .int() + .min(10, "MESSAGE_REQUEST_ASYNC_FLUSH_INTERVAL_MS 不能小于 10") + .max(60000, "MESSAGE_REQUEST_ASYNC_FLUSH_INTERVAL_MS 不能大于 60000") + ), + MESSAGE_REQUEST_ASYNC_BATCH_SIZE: optionalNumber( + z + .number() + .int() + .min(1, "MESSAGE_REQUEST_ASYNC_BATCH_SIZE 不能小于 1") + .max(2000, "MESSAGE_REQUEST_ASYNC_BATCH_SIZE 不能大于 2000") + ), + MESSAGE_REQUEST_ASYNC_MAX_PENDING: optionalNumber( + z + .number() + .int() + .min(100, "MESSAGE_REQUEST_ASYNC_MAX_PENDING 不能小于 100") + .max(200000, "MESSAGE_REQUEST_ASYNC_MAX_PENDING 不能大于 200000") + ), ADMIN_TOKEN: z.preprocess((val) => { // 空字符串或 "change-me" 占位符转为 undefined if (!val || typeof val !== "string") return undefined; diff --git a/src/repository/message-write-buffer.ts b/src/repository/message-write-buffer.ts new file mode 100644 index 000000000..b1b0a6288 --- /dev/null +++ b/src/repository/message-write-buffer.ts @@ -0,0 +1,352 @@ +import "server-only"; + +import type { SQL } from "drizzle-orm"; +import { sql } from "drizzle-orm"; +import { db } from "@/drizzle/db"; +import { getEnvConfig } from "@/lib/config/env.schema"; +import { logger } from "@/lib/logger"; +import type { CreateMessageRequestData } from "@/types/message"; + +export type MessageRequestUpdatePatch = { + durationMs?: number; + costUsd?: string; + statusCode?: number; + inputTokens?: number; + outputTokens?: number; + ttfbMs?: number | null; + cacheCreationInputTokens?: number; + cacheReadInputTokens?: number; + cacheCreation5mInputTokens?: number; + cacheCreation1hInputTokens?: number; + cacheTtlApplied?: string | null; + providerChain?: CreateMessageRequestData["provider_chain"]; + errorMessage?: string; + errorStack?: string; + errorCause?: string; + model?: string; + providerId?: number; + context1mApplied?: boolean; +}; + +type MessageRequestUpdateRecord = { + id: number; + patch: MessageRequestUpdatePatch; +}; + +type WriterConfig = { + flushIntervalMs: number; + batchSize: number; + maxPending: number; +}; + +const COLUMN_MAP: Record = { + durationMs: "duration_ms", + costUsd: "cost_usd", + statusCode: "status_code", + inputTokens: "input_tokens", + outputTokens: "output_tokens", + ttfbMs: "ttfb_ms", + cacheCreationInputTokens: "cache_creation_input_tokens", + cacheReadInputTokens: "cache_read_input_tokens", + cacheCreation5mInputTokens: "cache_creation_5m_input_tokens", + cacheCreation1hInputTokens: "cache_creation_1h_input_tokens", + cacheTtlApplied: "cache_ttl_applied", + providerChain: "provider_chain", + errorMessage: "error_message", + errorStack: "error_stack", + errorCause: "error_cause", + model: "model", + providerId: "provider_id", + context1mApplied: "context_1m_applied", +}; + +function loadWriterConfig(): WriterConfig { + const env = getEnvConfig(); + return { + flushIntervalMs: env.MESSAGE_REQUEST_ASYNC_FLUSH_INTERVAL_MS ?? 250, + batchSize: env.MESSAGE_REQUEST_ASYNC_BATCH_SIZE ?? 200, + maxPending: env.MESSAGE_REQUEST_ASYNC_MAX_PENDING ?? 5000, + }; +} + +function takeBatch(map: Map, batchSize: number) { + const items: MessageRequestUpdateRecord[] = []; + for (const [id, patch] of map) { + items.push({ id, patch }); + map.delete(id); + if (items.length >= batchSize) { + break; + } + } + return items; +} + +function buildBatchUpdateSql(updates: MessageRequestUpdateRecord[]): SQL | null { + if (updates.length === 0) { + return null; + } + + const ids = updates.map((u) => u.id); + + const setClauses: SQL[] = []; + for (const [key, columnName] of Object.entries(COLUMN_MAP) as Array< + [keyof MessageRequestUpdatePatch, string] + >) { + const cases: SQL[] = []; + for (const update of updates) { + const value = update.patch[key]; + if (value === undefined) { + continue; + } + + if (key === "providerChain") { + if (value === null) { + cases.push(sql`WHEN ${update.id} THEN NULL`); + continue; + } + const json = JSON.stringify(value); + cases.push(sql`WHEN ${update.id} THEN ${json}::jsonb`); + continue; + } + + if (key === "costUsd") { + // numeric 类型,显式 cast 避免隐式类型推断异常 + cases.push(sql`WHEN ${update.id} THEN ${value}::numeric`); + continue; + } + + cases.push(sql`WHEN ${update.id} THEN ${value}`); + } + + if (cases.length === 0) { + continue; + } + + const col = sql.identifier(columnName); + setClauses.push(sql`${col} = CASE id ${sql.join(cases, sql` `)} ELSE ${col} END`); + } + + // 没有任何可更新字段时跳过(避免无意义写入) + if (setClauses.length === 0) { + return null; + } + + // 所有更新统一刷新 updated_at + setClauses.push(sql`${sql.identifier("updated_at")} = NOW()`); + + const idList = sql.join( + ids.map((id) => sql`${id}`), + sql`, ` + ); + + return sql` + UPDATE message_request + SET ${sql.join(setClauses, sql`, `)} + WHERE id IN (${idList}) AND deleted_at IS NULL + `; +} + +class MessageRequestWriteBuffer { + private readonly config: WriterConfig; + private readonly pending = new Map(); + private flushTimer: NodeJS.Timeout | null = null; + private flushAgainAfterCurrent = false; + private flushInFlight: Promise | null = null; + private stopping = false; + + constructor(config: WriterConfig) { + this.config = config; + } + + enqueue(id: number, patch: MessageRequestUpdatePatch): void { + const existing = this.pending.get(id) ?? {}; + const merged: MessageRequestUpdatePatch = { ...existing }; + for (const [k, v] of Object.entries(patch) as Array< + [keyof MessageRequestUpdatePatch, MessageRequestUpdatePatch[keyof MessageRequestUpdatePatch]] + >) { + if (v !== undefined) { + merged[k] = v as never; + } + } + this.pending.set(id, merged); + + // 队列上限保护:DB 异常时避免无限增长导致 OOM + if (this.pending.size > this.config.maxPending) { + // 优先丢弃非“终态”更新(没有 durationMs 的条目),尽量保留请求完成信息 + let droppedId: number | undefined; + let droppedPatch: MessageRequestUpdatePatch | undefined; + + for (const [candidateId, candidatePatch] of this.pending) { + if (candidatePatch.durationMs === undefined) { + droppedId = candidateId; + droppedPatch = candidatePatch; + break; + } + } + + if (droppedId === undefined) { + const first = this.pending.entries().next().value as + | [number, MessageRequestUpdatePatch] + | undefined; + if (first) { + droppedId = first[0]; + droppedPatch = first[1]; + } + } + + if (droppedId !== undefined) { + this.pending.delete(droppedId); + logger.warn("[MessageRequestWriteBuffer] Pending queue overflow, dropping update", { + maxPending: this.config.maxPending, + droppedId, + droppedHasDurationMs: droppedPatch?.durationMs !== undefined, + currentPending: this.pending.size, + }); + } + } + + // flush 过程中有新任务:标记需要再跑一轮(避免刚好 flush 完成时遗漏) + if (this.flushInFlight) { + this.flushAgainAfterCurrent = true; + return; + } + + // 停止阶段不再调度 timer,避免阻止进程退出 + if (!this.stopping) { + this.ensureFlushTimer(); + } + + // 达到批量阈值时尽快 flush,降低 durationMs 为空的“悬挂时间” + if (this.pending.size >= this.config.batchSize) { + void this.flush(); + } + } + + private ensureFlushTimer(): void { + if (this.stopping || this.flushTimer) { + return; + } + + this.flushTimer = setTimeout(() => { + this.flushTimer = null; + void this.flush(); + }, this.config.flushIntervalMs); + } + + private clearFlushTimer(): void { + if (this.flushTimer) { + clearTimeout(this.flushTimer); + this.flushTimer = null; + } + } + + async flush(): Promise { + if (this.flushInFlight) { + this.flushAgainAfterCurrent = true; + return this.flushInFlight; + } + + // 进入 flush:先清理 timer,避免重复调度 + this.clearFlushTimer(); + + this.flushInFlight = (async () => { + do { + this.flushAgainAfterCurrent = false; + + while (this.pending.size > 0) { + const batch = takeBatch(this.pending, this.config.batchSize); + const query = buildBatchUpdateSql(batch); + if (!query) { + continue; + } + + try { + await db.execute(query); + } catch (error) { + // 失败重试:将 batch 放回队列 + // 合并策略:保留“更新更晚”的字段(existing 优先),避免覆盖新数据 + for (const item of batch) { + const existing = this.pending.get(item.id) ?? {}; + this.pending.set(item.id, { ...item.patch, ...existing }); + } + + logger.error("[MessageRequestWriteBuffer] Flush failed, will retry later", { + error: error instanceof Error ? error.message : String(error), + pending: this.pending.size, + batchSize: batch.length, + }); + + // DB 异常时不在当前循环内死磕,留待下一次 timer/手动 flush + break; + } + } + } while (this.flushAgainAfterCurrent); + })().finally(() => { + this.flushInFlight = null; + // 如果还有积压:运行态下继续用 timer 退避重试;停止阶段不再调度 timer + if (this.pending.size > 0 && !this.stopping) { + this.ensureFlushTimer(); + } + }); + + await this.flushInFlight; + } + + async stop(): Promise { + this.stopping = true; + this.clearFlushTimer(); + await this.flush(); + // stop 期间尽量补刷一次,避免极小概率竞态导致的 tail 更新残留 + if (this.pending.size > 0) { + await this.flush(); + } + } +} + +let _buffer: MessageRequestWriteBuffer | null = null; +let _bufferState: "running" | "stopping" | "stopped" = "running"; + +function getBuffer(): MessageRequestWriteBuffer | null { + if (!_buffer) { + if (_bufferState !== "running") { + return null; + } + _buffer = new MessageRequestWriteBuffer(loadWriterConfig()); + } + return _buffer; +} + +export function enqueueMessageRequestUpdate(id: number, patch: MessageRequestUpdatePatch): void { + // 只在 async 模式下启用队列,避免额外内存/定时器开销 + if (getEnvConfig().MESSAGE_REQUEST_WRITE_MODE !== "async") { + return; + } + const buffer = getBuffer(); + if (!buffer) { + return; + } + buffer.enqueue(id, patch); +} + +export async function flushMessageRequestWriteBuffer(): Promise { + if (!_buffer) { + return; + } + await _buffer.flush(); +} + +export async function stopMessageRequestWriteBuffer(): Promise { + if (_bufferState === "stopped") { + return; + } + _bufferState = "stopping"; + + if (!_buffer) { + _bufferState = "stopped"; + return; + } + + await _buffer.stop(); + _buffer = null; + _bufferState = "stopped"; +} diff --git a/src/repository/message.ts b/src/repository/message.ts index f7e30df47..712931bf3 100644 --- a/src/repository/message.ts +++ b/src/repository/message.ts @@ -3,9 +3,11 @@ import { and, asc, desc, eq, gt, inArray, isNull, lt, sql } from "drizzle-orm"; import { db } from "@/drizzle/db"; import { keys as keysTable, messageRequest, providers, users } from "@/drizzle/schema"; +import { getEnvConfig } from "@/lib/config/env.schema"; import { formatCostForStorage } from "@/lib/utils/currency"; import type { CreateMessageRequestData, MessageRequest } from "@/types/message"; import { toMessageRequest } from "./_shared/transformers"; +import { enqueueMessageRequestUpdate } from "./message-write-buffer"; /** * 创建消息请求记录 @@ -67,6 +69,11 @@ export async function createMessageRequest( * 更新消息请求的耗时 */ export async function updateMessageRequestDuration(id: number, durationMs: number): Promise { + if (getEnvConfig().MESSAGE_REQUEST_WRITE_MODE === "async") { + enqueueMessageRequestUpdate(id, { durationMs }); + return; + } + await db .update(messageRequest) .set({ @@ -88,6 +95,11 @@ export async function updateMessageRequestCost( return; } + if (getEnvConfig().MESSAGE_REQUEST_WRITE_MODE === "async") { + enqueueMessageRequestUpdate(id, { costUsd: formattedCost }); + return; + } + await db .update(messageRequest) .set({ @@ -121,6 +133,11 @@ export async function updateMessageRequestDetails( context1mApplied?: boolean; // 是否应用了1M上下文窗口 } ): Promise { + if (getEnvConfig().MESSAGE_REQUEST_WRITE_MODE === "async") { + enqueueMessageRequestUpdate(id, details); + return; + } + const updateData: Record = { updatedAt: new Date(), }; diff --git a/tests/unit/drizzle/db-pool-config.test.ts b/tests/unit/drizzle/db-pool-config.test.ts new file mode 100644 index 000000000..bc53b3d17 --- /dev/null +++ b/tests/unit/drizzle/db-pool-config.test.ts @@ -0,0 +1,108 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +type EnvSnapshot = Partial>; + +function snapshotEnv(keys: string[]): EnvSnapshot { + const snapshot: EnvSnapshot = {}; + for (const key of keys) { + snapshot[key] = process.env[key]; + } + return snapshot; +} + +function restoreEnv(snapshot: EnvSnapshot) { + for (const [key, value] of Object.entries(snapshot)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } +} + +describe("drizzle/db 连接池配置", () => { + const envKeys = [ + "NODE_ENV", + "DSN", + "DB_POOL_MAX", + "DB_POOL_IDLE_TIMEOUT", + "DB_POOL_CONNECT_TIMEOUT", + "MESSAGE_REQUEST_WRITE_MODE", + ]; + + const postgresMock = vi.fn(); + const drizzleMock = vi.fn(() => ({ __db: true })); + + const originalEnv = snapshotEnv(envKeys); + + beforeEach(() => { + vi.resetModules(); + postgresMock.mockReset(); + drizzleMock.mockReset(); + + // 确保每个用例有一致的基础环境 + process.env.DSN = "postgres://postgres:postgres@localhost:5432/claude_code_hub_test"; + process.env.MESSAGE_REQUEST_WRITE_MODE = "async"; + delete process.env.DB_POOL_MAX; + delete process.env.DB_POOL_IDLE_TIMEOUT; + delete process.env.DB_POOL_CONNECT_TIMEOUT; + + vi.doMock("postgres", () => ({ default: postgresMock })); + vi.doMock("drizzle-orm/postgres-js", () => ({ + drizzle: drizzleMock, + })); + }); + + afterEach(() => { + restoreEnv(originalEnv); + }); + + it("生产环境默认 max=20、idle_timeout=20、connect_timeout=10", async () => { + process.env.NODE_ENV = "production"; + + const { getDb } = await import("@/drizzle/db"); + getDb(); + + expect(postgresMock).toHaveBeenCalledWith( + process.env.DSN, + expect.objectContaining({ + max: 20, + idle_timeout: 20, + connect_timeout: 10, + }) + ); + }); + + it("开发环境默认 max=10", async () => { + process.env.NODE_ENV = "development"; + + const { getDb } = await import("@/drizzle/db"); + getDb(); + + expect(postgresMock).toHaveBeenCalledWith( + process.env.DSN, + expect.objectContaining({ + max: 10, + }) + ); + }); + + it("支持通过 env 覆盖连接池参数", async () => { + process.env.NODE_ENV = "production"; + process.env.DB_POOL_MAX = "50"; + process.env.DB_POOL_IDLE_TIMEOUT = "30"; + process.env.DB_POOL_CONNECT_TIMEOUT = "5"; + + const { getDb } = await import("@/drizzle/db"); + getDb(); + + expect(postgresMock).toHaveBeenCalledWith( + process.env.DSN, + expect.objectContaining({ + max: 50, + idle_timeout: 30, + connect_timeout: 5, + }) + ); + }); +}); diff --git a/tests/unit/repository/message-write-buffer.test.ts b/tests/unit/repository/message-write-buffer.test.ts new file mode 100644 index 000000000..17f5ab192 --- /dev/null +++ b/tests/unit/repository/message-write-buffer.test.ts @@ -0,0 +1,265 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +type EnvSnapshot = Partial>; + +function snapshotEnv(keys: string[]): EnvSnapshot { + const snapshot: EnvSnapshot = {}; + for (const key of keys) { + snapshot[key] = process.env[key]; + } + return snapshot; +} + +function restoreEnv(snapshot: EnvSnapshot) { + for (const [key, value] of Object.entries(snapshot)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } +} + +function toSqlText(query: { toQuery: (config: any) => { sql: string; params: unknown[] } }) { + return query.toQuery({ + escapeName: (name: string) => `"${name}"`, + escapeParam: (index: number) => `$${index}`, + escapeString: (value: string) => `'${value}'`, + paramStartIndex: { value: 1 }, + }); +} + +function createDeferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +describe("message_request 异步批量写入", () => { + const envKeys = [ + "NODE_ENV", + "DSN", + "MESSAGE_REQUEST_WRITE_MODE", + "MESSAGE_REQUEST_ASYNC_FLUSH_INTERVAL_MS", + "MESSAGE_REQUEST_ASYNC_BATCH_SIZE", + "MESSAGE_REQUEST_ASYNC_MAX_PENDING", + ]; + const originalEnv = snapshotEnv(envKeys); + + const executeMock = vi.fn(async () => []); + + beforeEach(() => { + vi.resetModules(); + executeMock.mockClear(); + + process.env.NODE_ENV = "test"; + process.env.DSN = "postgres://postgres:postgres@localhost:5432/claude_code_hub_test"; + process.env.MESSAGE_REQUEST_ASYNC_FLUSH_INTERVAL_MS = "60000"; + process.env.MESSAGE_REQUEST_ASYNC_BATCH_SIZE = "1000"; + process.env.MESSAGE_REQUEST_ASYNC_MAX_PENDING = "1000"; + + vi.doMock("@/drizzle/db", () => ({ + db: { + execute: executeMock, + // 避免 tests/setup.ts 的 afterAll 清理逻辑因 mock 缺失 select 而报错 + select: () => ({ + from: () => ({ + where: async () => [], + }), + }), + }, + })); + }); + + afterEach(() => { + restoreEnv(originalEnv); + }); + + it("sync 模式下不应入队/写库", async () => { + process.env.MESSAGE_REQUEST_WRITE_MODE = "sync"; + + const { enqueueMessageRequestUpdate, flushMessageRequestWriteBuffer } = await import( + "@/repository/message-write-buffer" + ); + + enqueueMessageRequestUpdate(1, { durationMs: 123 }); + await flushMessageRequestWriteBuffer(); + + expect(executeMock).not.toHaveBeenCalled(); + }); + + it("async 模式下应合并同一 id 的多次更新并批量写入", async () => { + process.env.MESSAGE_REQUEST_WRITE_MODE = "async"; + + const { + enqueueMessageRequestUpdate, + flushMessageRequestWriteBuffer, + stopMessageRequestWriteBuffer, + } = await import("@/repository/message-write-buffer"); + + enqueueMessageRequestUpdate(42, { durationMs: 100 }); + enqueueMessageRequestUpdate(42, { statusCode: 200, ttfbMs: 10 }); + + await flushMessageRequestWriteBuffer(); + await stopMessageRequestWriteBuffer(); + + expect(executeMock).toHaveBeenCalledTimes(1); + + const query = executeMock.mock.calls[0]?.[0]; + const built = toSqlText(query); + + expect(built.sql).toContain("UPDATE message_request"); + expect(built.sql).toContain("duration_ms"); + expect(built.sql).toContain("status_code"); + expect(built.sql).toContain("ttfb_ms"); + expect(built.sql).toContain("updated_at"); + expect(built.sql).toContain("deleted_at IS NULL"); + }); + + it("应对 costUsd/providerChain 做显式类型转换(numeric/jsonb)", async () => { + process.env.MESSAGE_REQUEST_WRITE_MODE = "async"; + + const { enqueueMessageRequestUpdate, stopMessageRequestWriteBuffer } = await import( + "@/repository/message-write-buffer" + ); + + enqueueMessageRequestUpdate(7, { + costUsd: "0.000123", + providerChain: [{ id: 1, name: "p1" }], + }); + + await stopMessageRequestWriteBuffer(); + + expect(executeMock).toHaveBeenCalledTimes(1); + + const query = executeMock.mock.calls[0]?.[0]; + const built = toSqlText(query); + + expect(built.sql).toContain("::numeric"); + expect(built.sql).toContain("::jsonb"); + }); + + it("stop 应等待 in-flight flush 完成", async () => { + process.env.MESSAGE_REQUEST_WRITE_MODE = "async"; + + const deferred = createDeferred(); + executeMock.mockImplementationOnce(async () => deferred.promise); + + const { enqueueMessageRequestUpdate, stopMessageRequestWriteBuffer } = await import( + "@/repository/message-write-buffer" + ); + + enqueueMessageRequestUpdate(1, { durationMs: 123 }); + + const stopPromise = stopMessageRequestWriteBuffer(); + + expect(executeMock).toHaveBeenCalledTimes(1); + + const raced = await Promise.race([ + stopPromise.then(() => "stopped"), + Promise.resolve("pending"), + ]); + expect(raced).toBe("pending"); + + deferred.resolve([]); + await stopPromise; + }); + + it("flush 进行中 enqueue 的更新应最终落库", async () => { + process.env.MESSAGE_REQUEST_WRITE_MODE = "async"; + + const firstExecute = createDeferred(); + executeMock.mockImplementationOnce(async () => firstExecute.promise); + executeMock.mockImplementationOnce(async () => []); + + const { + enqueueMessageRequestUpdate, + flushMessageRequestWriteBuffer, + stopMessageRequestWriteBuffer, + } = await import("@/repository/message-write-buffer"); + + enqueueMessageRequestUpdate(42, { durationMs: 100 }); + + const flushPromise = flushMessageRequestWriteBuffer(); + expect(executeMock).toHaveBeenCalledTimes(1); + + // 在第一次写入尚未完成时,追加同一请求的后续 patch + enqueueMessageRequestUpdate(42, { statusCode: 200 }); + + firstExecute.resolve([]); + + await flushPromise; + await stopMessageRequestWriteBuffer(); + + expect(executeMock).toHaveBeenCalledTimes(2); + + const secondQuery = executeMock.mock.calls[1]?.[0]; + const built = toSqlText(secondQuery); + expect(built.sql).toContain("status_code"); + }); + + it("DB 写入失败重试时不应覆盖更晚的 patch", async () => { + process.env.MESSAGE_REQUEST_WRITE_MODE = "async"; + + const firstExecute = createDeferred(); + executeMock.mockImplementationOnce(async () => firstExecute.promise); + executeMock.mockImplementationOnce(async () => []); + + const { + enqueueMessageRequestUpdate, + flushMessageRequestWriteBuffer, + stopMessageRequestWriteBuffer, + } = await import("@/repository/message-write-buffer"); + + enqueueMessageRequestUpdate(7, { durationMs: 100 }); + + const flushPromise = flushMessageRequestWriteBuffer(); + expect(executeMock).toHaveBeenCalledTimes(1); + + // 在第一次 flush 的 in-flight 期间写入“更晚”的字段 + enqueueMessageRequestUpdate(7, { statusCode: 500 }); + + firstExecute.reject(new Error("db down")); + await flushPromise; + + // 触发下一次 flush:应同时包含 duration/statusCode + await flushMessageRequestWriteBuffer(); + await stopMessageRequestWriteBuffer(); + + expect(executeMock).toHaveBeenCalledTimes(2); + + const secondQuery = executeMock.mock.calls[1]?.[0]; + const built = toSqlText(secondQuery); + expect(built.sql).toContain("duration_ms"); + expect(built.sql).toContain("status_code"); + }); + + it("队列溢出时应优先丢弃非终态更新(尽量保留 durationMs)", async () => { + process.env.MESSAGE_REQUEST_WRITE_MODE = "async"; + process.env.MESSAGE_REQUEST_ASYNC_MAX_PENDING = "100"; + + const { enqueueMessageRequestUpdate, stopMessageRequestWriteBuffer } = await import( + "@/repository/message-write-buffer" + ); + + enqueueMessageRequestUpdate(1001, { statusCode: 200 }); // 非终态(无 durationMs) + for (let i = 0; i < 100; i++) { + enqueueMessageRequestUpdate(2000 + i, { durationMs: i }); + } + + await stopMessageRequestWriteBuffer(); + + expect(executeMock).toHaveBeenCalledTimes(1); + + const query = executeMock.mock.calls[0]?.[0]; + const built = toSqlText(query); + + expect(built.params).toContain(2000); + expect(built.params).toContain(2099); + expect(built.params).not.toContain(1001); + }); +}); From f017a83f6f921b1cb785fe507dbc4e86742471ec Mon Sep 17 00:00:00 2001 From: miraserver <20286838+miraserver@users.noreply.github.com> Date: Fri, 2 Jan 2026 11:36:38 +0300 Subject: [PATCH 02/13] fix: Request Filters improvements & SOCKS proxy fix (#501) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: exclude deleted providers from group tags dropdown in Request Filters The getDistinctProviderGroupsAction was showing group tags from soft-deleted providers. Added deletedAt IS NULL filter to match the behavior of getDistinctProviderGroups in repository/provider.ts. Co-Authored-By: Claude Opus 4.5 * feat(providers): add circuit breaker filter toggle Add a switch toggle to filter providers by circuit breaker state. The toggle appears only when there are providers with open circuit breaker. When enabled, shows only providers with broken circuits. - Filter works on top of existing filters (type, status, group, search) - Visual feedback: icon and text turn red when filter is active - Added i18n translations for all 5 languages 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * fix(proxy): replace socks-proxy-agent with fetch-socks for undici compatibility SocksProxyAgent from socks-proxy-agent is a Node.js HTTP Agent that doesn't implement undici's Dispatcher interface, causing "this.dispatch is not a function" error when used with undici.request(). Replace with fetch-socks which provides socksDispatcher - a native undici Dispatcher implementation for SOCKS4/SOCKS5 proxies. - Replace socks-proxy-agent with fetch-socks dependency - Update createProxyAgentForProvider to use socksDispatcher - Update next.config.ts outputFileTracingIncludes - Update comments in forwarder.ts 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * refactor: use drizzle-orm operators instead of raw SQL for group tag filtering Replace sql template with isNotNull and ne operators for better type-safety and consistency with drizzle-orm patterns. Changes in both: - src/actions/request-filters.ts - src/repository/provider.ts 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * chore: format code (fix-req-filters-adv-1b8577f) --------- Co-authored-by: John Doe Co-authored-by: Claude Opus 4.5 Co-authored-by: github-actions[bot] --- messages/en/settings.json | 3 +- messages/ja/settings.json | 3 +- messages/ru/settings.json | 3 +- messages/zh-CN/settings.json | 3 +- messages/zh-TW/settings.json | 3 +- next.config.ts | 4 +- package.json | 2 +- src/actions/request-filters.ts | 9 +- .../_components/provider-manager.tsx | 86 +++++++++++++++---- src/app/v1/_lib/proxy/forwarder.ts | 4 +- src/lib/proxy-agent.ts | 36 +++++--- src/repository/provider.ts | 4 +- 12 files changed, 114 insertions(+), 46 deletions(-) diff --git a/messages/en/settings.json b/messages/en/settings.json index 2c427f3a6..90d4b6b63 100644 --- a/messages/en/settings.json +++ b/messages/en/settings.json @@ -1527,7 +1527,8 @@ "label": "Groups:", "all": "All", "default": "default" - } + }, + "circuitBroken": "Circuit Broken" }, "subtitle": "Provider Management", "subtitleDesc": "Configure upstream provider rate limiting and concurrent session limits. Leave empty for unlimited.", diff --git a/messages/ja/settings.json b/messages/ja/settings.json index 8837a878a..f1a9bb6e9 100644 --- a/messages/ja/settings.json +++ b/messages/ja/settings.json @@ -1397,7 +1397,8 @@ "label": "グループ:", "all": "すべて", "default": "default" - } + }, + "circuitBroken": "サーキットブレーカー" }, "subtitle": "プロバイダー管理", "subtitleDesc": "上流プロバイダーの支出制限とセッション並行制限を設定します。空のままにすると無制限です。", diff --git a/messages/ru/settings.json b/messages/ru/settings.json index 1d6fc6075..4c0c93521 100644 --- a/messages/ru/settings.json +++ b/messages/ru/settings.json @@ -1397,7 +1397,8 @@ "label": "Группы:", "all": "Все", "default": "default" - } + }, + "circuitBroken": "Сбой соединения" }, "subtitle": "Поставщики", "subtitleDesc": "Настройка ограничений по расходам и параллельным сеансам.", diff --git a/messages/zh-CN/settings.json b/messages/zh-CN/settings.json index 25eea66bf..a86b7bd91 100644 --- a/messages/zh-CN/settings.json +++ b/messages/zh-CN/settings.json @@ -1108,7 +1108,8 @@ "label": "分组:", "all": "全部", "default": "default" - } + }, + "circuitBroken": "熔断" }, "editProvider": "编辑服务商", "createProvider": "新增服务商", diff --git a/messages/zh-TW/settings.json b/messages/zh-TW/settings.json index 55f61e6f0..87929a04c 100644 --- a/messages/zh-TW/settings.json +++ b/messages/zh-TW/settings.json @@ -1403,7 +1403,8 @@ "label": "分組:", "all": "全部", "default": "default" - } + }, + "circuitBroken": "熔斷" }, "subtitle": "服務商管理", "subtitleDesc": "設定上游服務商的金額限流和並行限制。留空表示無限制。", diff --git a/next.config.ts b/next.config.ts index 93e5b44fc..f3e726bfd 100644 --- a/next.config.ts +++ b/next.config.ts @@ -23,11 +23,11 @@ const nextConfig: NextConfig = { "drizzle-orm", ], - // 强制包含 undici 到 standalone 输出 + // 强制包含 undici 和 fetch-socks 到 standalone 输出 // Next.js 依赖追踪无法正确追踪动态导入和类型导入的传递依赖 // 参考: https://nextjs.org/docs/app/api-reference/config/next-config-js/output outputFileTracingIncludes: { - "/**": ["./node_modules/undici/**/*", "./node_modules/socks-proxy-agent/**/*"], + "/**": ["./node_modules/undici/**/*", "./node_modules/fetch-socks/**/*"], }, // 文件上传大小限制(用于数据库备份导入) diff --git a/package.json b/package.json index a42392413..269bc952a 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "decimal.js-light": "^2", "dotenv": "^17", "drizzle-orm": "^0.44", + "fetch-socks": "^1.3.2", "hono": "^4", "html2canvas": "^1", "ioredis": "^5", @@ -80,7 +81,6 @@ "recharts": "^3", "safe-regex": "^2", "server-only": "^0.0.1", - "socks-proxy-agent": "^8", "sonner": "^2", "tailwind-merge": "^3", "timeago.js": "^4", diff --git a/src/actions/request-filters.ts b/src/actions/request-filters.ts index 63d37a5e9..e801dedea 100644 --- a/src/actions/request-filters.ts +++ b/src/actions/request-filters.ts @@ -281,12 +281,17 @@ export async function getDistinctProviderGroupsAction(): Promise(); diff --git a/src/app/[locale]/settings/providers/_components/provider-manager.tsx b/src/app/[locale]/settings/providers/_components/provider-manager.tsx index 50ceb0c72..2648481d8 100644 --- a/src/app/[locale]/settings/providers/_components/provider-manager.tsx +++ b/src/app/[locale]/settings/providers/_components/provider-manager.tsx @@ -1,9 +1,10 @@ "use client"; -import { Loader2, Search, X } from "lucide-react"; +import { AlertTriangle, Loader2, Search, X } from "lucide-react"; import { useTranslations } from "next-intl"; import { type ReactNode, useMemo, useState } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; import { Select, SelectContent, @@ -12,6 +13,7 @@ import { SelectValue, } from "@/components/ui/select"; import { Skeleton } from "@/components/ui/skeleton"; +import { Switch } from "@/components/ui/switch"; import { useDebounce } from "@/lib/hooks/use-debounce"; import type { CurrencyCode } from "@/lib/utils/currency"; import type { ProviderDisplay, ProviderType } from "@/types/provider"; @@ -61,6 +63,12 @@ export function ProviderManager({ // Status and group filters const [statusFilter, setStatusFilter] = useState<"all" | "active" | "inactive">("all"); const [groupFilter, setGroupFilter] = useState([]); + const [circuitBrokenFilter, setCircuitBrokenFilter] = useState(false); + + // Count providers with circuit breaker open + const circuitBrokenCount = useMemo(() => { + return providers.filter((p) => healthStatus[p.id]?.circuitState === "open").length; + }, [providers, healthStatus]); // Extract unique groups from all providers const allGroups = useMemo(() => { @@ -132,6 +140,11 @@ export function ProviderManager({ }); } + // Filter by circuit breaker state + if (circuitBrokenFilter) { + result = result.filter((p) => healthStatus[p.id]?.circuitState === "open"); + } + // 排序 return [...result].sort((a, b) => { switch (sortBy) { @@ -161,7 +174,16 @@ export function ProviderManager({ return 0; } }); - }, [providers, debouncedSearchTerm, typeFilter, sortBy, statusFilter, groupFilter]); + }, [ + providers, + debouncedSearchTerm, + typeFilter, + sortBy, + statusFilter, + groupFilter, + circuitBrokenFilter, + healthStatus, + ]); return (
@@ -242,22 +264,50 @@ export function ProviderManager({ ))}
)} - {/* 搜索结果提示 */} - {debouncedSearchTerm ? ( -

- {loading - ? tCommon("loading") - : filteredProviders.length > 0 - ? t("found", { count: filteredProviders.length }) - : t("notFound")} -

- ) : ( -
- {loading - ? tCommon("loading") - : t("showing", { filtered: filteredProviders.length, total: providers.length })} -
- )} + {/* 搜索结果提示 + Circuit Breaker filter */} +
+ {debouncedSearchTerm ? ( +

+ {loading + ? tCommon("loading") + : filteredProviders.length > 0 + ? t("found", { count: filteredProviders.length }) + : t("notFound")} +

+ ) : ( +
+ {loading + ? tCommon("loading") + : t("showing", { filtered: filteredProviders.length, total: providers.length })} +
+ )} + + {/* Circuit Breaker toggle - only show if there are broken providers */} + {circuitBrokenCount > 0 && ( +
+ + + + + ({circuitBrokenCount}) + +
+ )} +
{/* 供应商列表 */} diff --git a/src/app/v1/_lib/proxy/forwarder.ts b/src/app/v1/_lib/proxy/forwarder.ts index 94a312572..7a0ec5406 100644 --- a/src/app/v1/_lib/proxy/forwarder.ts +++ b/src/app/v1/_lib/proxy/forwarder.ts @@ -128,7 +128,7 @@ function resolveMaxAttemptsForProvider( /** * undici request 超时配置(毫秒) * - * 背景:undiciRequest() 在使用非 undici 原生 dispatcher(如 SocksProxyAgent)时, + * 背景:undiciRequest() 在使用自定义 dispatcher(如 SOCKS 代理)时, * 不会继承全局 Agent 的超时配置,需要显式传递超时参数。 * * 这里与全局 undici Agent 使用同一套环境变量配置(FETCH_HEADERS_TIMEOUT / FETCH_BODY_TIMEOUT)。 @@ -1873,7 +1873,7 @@ export class ProxyForwarder { } // 使用 undici.request 获取未自动解压的响应 - // ⭐ 显式配置超时:确保使用非 undici 原生 dispatcher(如 SocksProxyAgent)时也能正确应用超时 + // ⭐ 显式配置超时:确保使用自定义 dispatcher(如 SOCKS 代理)时也能正确应用超时 const undiciRes = await undiciRequest(url, { method: init.method as string, headers: headersObj, diff --git a/src/lib/proxy-agent.ts b/src/lib/proxy-agent.ts index 0dcb2f501..42ea57b6c 100644 --- a/src/lib/proxy-agent.ts +++ b/src/lib/proxy-agent.ts @@ -1,5 +1,5 @@ -import { SocksProxyAgent } from "socks-proxy-agent"; -import { Agent, ProxyAgent, setGlobalDispatcher } from "undici"; +import { socksDispatcher } from "fetch-socks"; +import { Agent, type Dispatcher, ProxyAgent, setGlobalDispatcher } from "undici"; import type { Provider } from "@/types/provider"; import { getEnvConfig } from "./config/env.schema"; import { logger } from "./logger"; @@ -43,8 +43,7 @@ logger.info("undici global dispatcher configured", { * 代理配置结果 */ export interface ProxyConfig { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - agent: ProxyAgent | SocksProxyAgent | any; // any to support non-undici agents + agent: ProxyAgent | Dispatcher; fallbackToDirect: boolean; proxyUrl: string; http2Enabled: boolean; // HTTP/2 是否启用(SOCKS 代理不支持 HTTP/2) @@ -99,17 +98,26 @@ export function createProxyAgentForProvider( const parsedProxy = new URL(proxyUrl); // 根据协议选择 Agent - let agent: ProxyAgent | SocksProxyAgent; + let agent: ProxyAgent | Dispatcher; let actualHttp2Enabled = false; // 实际是否启用 HTTP/2 if (parsedProxy.protocol === "socks5:" || parsedProxy.protocol === "socks4:") { - // SOCKS 代理(不支持 HTTP/2) - // ⭐ 超时说明: - // - SocksProxyAgent 仅处理 SOCKS 连接建立阶段(默认 30s 超时,足够) - // - 连接建立后,HTTP 数据传输由全局 undici Agent 控制(headersTimeout/bodyTimeout 可配置) - // - 因此 SOCKS 代理无需额外配置 headersTimeout/bodyTimeout - // @see https://github.com/TooTallNate/node-socks-proxy-agent/issues/26 - agent = new SocksProxyAgent(proxyUrl); + // SOCKS 代理通过 fetch-socks(undici 兼容) + // 使用 socksDispatcher 创建 undici 兼容的 Dispatcher + agent = socksDispatcher( + { + type: parsedProxy.protocol === "socks5:" ? 5 : 4, + host: parsedProxy.hostname, + port: parseInt(parsedProxy.port) || 1080, + userId: parsedProxy.username || undefined, + password: parsedProxy.password || undefined, + }, + { + connect: { + timeout: connectTimeout, + }, + } + ); actualHttp2Enabled = false; // SOCKS 不支持 HTTP/2 // 警告:SOCKS 代理不支持 HTTP/2 @@ -121,14 +129,14 @@ export function createProxyAgentForProvider( }); } - logger.debug("SOCKS ProxyAgent created", { + logger.debug("SOCKS dispatcher created via fetch-socks", { providerId: provider.id, providerName: provider.name ?? "unknown", protocol: parsedProxy.protocol, proxyHost: parsedProxy.hostname, proxyPort: parsedProxy.port, targetUrl: new URL(targetUrl).origin, - http2Enabled: false, // SOCKS 不支持 HTTP/2 + http2Enabled: false, }); } else if (parsedProxy.protocol === "http:" || parsedProxy.protocol === "https:") { // HTTP/HTTPS 代理(使用 undici) diff --git a/src/repository/provider.ts b/src/repository/provider.ts index 44e24f173..3f2d1442b 100644 --- a/src/repository/provider.ts +++ b/src/repository/provider.ts @@ -1,6 +1,6 @@ "use server"; -import { and, desc, eq, isNull, sql } from "drizzle-orm"; +import { and, desc, eq, isNotNull, isNull, ne, sql } from "drizzle-orm"; import { db } from "@/drizzle/db"; import { providers } from "@/drizzle/schema"; import { getEnvConfig } from "@/lib/config"; @@ -453,7 +453,7 @@ export async function getDistinctProviderGroups(): Promise { .where( and( isNull(providers.deletedAt), - sql`${providers.groupTag} IS NOT NULL AND ${providers.groupTag} != ''` + and(isNotNull(providers.groupTag), ne(providers.groupTag, "")) ) ) .orderBy(providers.groupTag); From 75c655e958779cf718bd087f9ca0d60d1985ce3c Mon Sep 17 00:00:00 2001 From: Ding <44717411+ding113@users.noreply.github.com> Date: Fri, 2 Jan 2026 20:54:50 +0800 Subject: [PATCH 03/13] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=20Webhook=20?= =?UTF-8?q?=E9=80=9A=E7=9F=A5=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- drizzle/0043_lonely_rick_jones.sql | 62 + drizzle/meta/0043_snapshot.json | 2273 +++++++++++++++++ drizzle/meta/_journal.json | 7 + messages/en/settings.json | 96 +- messages/ja/settings.json | 94 +- messages/ru/settings.json | 94 +- messages/zh-CN/settings.json | 96 +- messages/zh-TW/settings.json | 96 +- package.json | 1 + src/actions/notification-bindings.ts | 66 + src/actions/webhook-targets.ts | 477 ++++ .../_components/binding-selector.tsx | 333 +++ .../_components/global-settings-card.tsx | 52 + .../_components/notification-type-card.tsx | 293 +++ .../_components/notifications-skeleton.tsx | 23 + .../_components/proxy-config-section.tsx | 67 + .../_components/template-editor.tsx | 118 + .../_components/test-webhook-button.tsx | 80 + .../_components/webhook-target-card.tsx | 153 ++ .../_components/webhook-target-dialog.tsx | 292 +++ .../_components/webhook-targets-section.tsx | 156 ++ .../_components/webhook-type-form.tsx | 121 + .../settings/notifications/_lib/hooks.ts | 345 +++ .../settings/notifications/_lib/schemas.ts | 133 + .../[locale]/settings/notifications/page.tsx | 602 +---- src/app/api/actions/[...route]/route.ts | 255 +- src/drizzle/schema.ts | 81 + src/lib/notification/notification-queue.ts | 249 +- src/lib/notification/notifier.ts | 134 +- src/lib/proxy-agent.ts | 2 +- src/lib/webhook/index.ts | 3 + src/lib/webhook/notifier.ts | 177 +- src/lib/webhook/renderers/custom.ts | 59 + src/lib/webhook/renderers/dingtalk.ts | 99 + src/lib/webhook/renderers/index.ts | 35 +- src/lib/webhook/renderers/telegram.ts | 105 + src/lib/webhook/renderers/wechat.ts | 16 +- src/lib/webhook/templates/defaults.ts | 45 + src/lib/webhook/templates/index.ts | 7 + src/lib/webhook/templates/placeholders.ts | 203 ++ src/lib/webhook/types.ts | 29 +- src/lib/webhook/utils/date.ts | 4 + src/repository/notification-bindings.ts | 242 ++ src/repository/notifications.ts | 80 + src/repository/webhook-targets.ts | 170 ++ src/types/fetch-socks.d.ts | 27 + tests/api/api-actions-integrity.test.ts | 29 + tests/api/api-openapi-spec.test.ts | 6 +- tests/e2e/notification-settings.test.ts | 134 + .../integration/notification-bindings.test.ts | 85 + .../integration/webhook-targets-crud.test.ts | 57 + tests/unit/webhook/notifier.test.ts | 71 + tests/unit/webhook/renderers/custom.test.ts | 58 + tests/unit/webhook/renderers/dingtalk.test.ts | 57 + tests/unit/webhook/renderers/telegram.test.ts | 51 + .../webhook/templates/placeholders.test.ts | 136 + vitest.integration.config.ts | 41 + 57 files changed, 8193 insertions(+), 684 deletions(-) create mode 100644 drizzle/0043_lonely_rick_jones.sql create mode 100644 drizzle/meta/0043_snapshot.json create mode 100644 src/actions/notification-bindings.ts create mode 100644 src/actions/webhook-targets.ts create mode 100644 src/app/[locale]/settings/notifications/_components/binding-selector.tsx create mode 100644 src/app/[locale]/settings/notifications/_components/global-settings-card.tsx create mode 100644 src/app/[locale]/settings/notifications/_components/notification-type-card.tsx create mode 100644 src/app/[locale]/settings/notifications/_components/notifications-skeleton.tsx create mode 100644 src/app/[locale]/settings/notifications/_components/proxy-config-section.tsx create mode 100644 src/app/[locale]/settings/notifications/_components/template-editor.tsx create mode 100644 src/app/[locale]/settings/notifications/_components/test-webhook-button.tsx create mode 100644 src/app/[locale]/settings/notifications/_components/webhook-target-card.tsx create mode 100644 src/app/[locale]/settings/notifications/_components/webhook-target-dialog.tsx create mode 100644 src/app/[locale]/settings/notifications/_components/webhook-targets-section.tsx create mode 100644 src/app/[locale]/settings/notifications/_components/webhook-type-form.tsx create mode 100644 src/app/[locale]/settings/notifications/_lib/hooks.ts create mode 100644 src/app/[locale]/settings/notifications/_lib/schemas.ts create mode 100644 src/lib/webhook/renderers/custom.ts create mode 100644 src/lib/webhook/renderers/dingtalk.ts create mode 100644 src/lib/webhook/renderers/telegram.ts create mode 100644 src/lib/webhook/templates/defaults.ts create mode 100644 src/lib/webhook/templates/placeholders.ts create mode 100644 src/repository/notification-bindings.ts create mode 100644 src/repository/webhook-targets.ts create mode 100644 src/types/fetch-socks.d.ts create mode 100644 tests/e2e/notification-settings.test.ts create mode 100644 tests/integration/notification-bindings.test.ts create mode 100644 tests/integration/webhook-targets-crud.test.ts create mode 100644 tests/unit/webhook/renderers/custom.test.ts create mode 100644 tests/unit/webhook/renderers/dingtalk.test.ts create mode 100644 tests/unit/webhook/renderers/telegram.test.ts create mode 100644 tests/unit/webhook/templates/placeholders.test.ts create mode 100644 vitest.integration.config.ts diff --git a/drizzle/0043_lonely_rick_jones.sql b/drizzle/0043_lonely_rick_jones.sql new file mode 100644 index 000000000..b364d7256 --- /dev/null +++ b/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 diff --git a/drizzle/meta/0043_snapshot.json b/drizzle/meta/0043_snapshot.json new file mode 100644 index 000000000..14551e16f --- /dev/null +++ b/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": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 9656a1d57..ad6494e40 100644 --- a/drizzle/meta/_journal.json +++ b/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 } ] } \ No newline at end of file diff --git a/messages/en/settings.json b/messages/en/settings.json index 90d4b6b63..78d1184e5 100644 --- a/messages/en/settings.json +++ b/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=...", diff --git a/messages/ja/settings.json b/messages/ja/settings.json index f1a9bb6e9..ea1f89815 100644 --- a/messages/ja/settings.json +++ b/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", diff --git a/messages/ru/settings.json b/messages/ru/settings.json index 4c0c93521..82c4d1971 100644 --- a/messages/ru/settings.json +++ b/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", diff --git a/messages/zh-CN/settings.json b/messages/zh-CN/settings.json index a86b7bd91..6ced53a21 100644 --- a/messages/zh-CN/settings.json +++ b/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=...", diff --git a/messages/zh-TW/settings.json b/messages/zh-TW/settings.json index 87929a04c..14a185122 100644 --- a/messages/zh-TW/settings.json +++ b/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=...", diff --git a/package.json b/package.json index 269bc952a..69ac19f48 100644 --- a/package.json +++ b/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", diff --git a/src/actions/notification-bindings.ts b/src/actions/notification-bindings.ts new file mode 100644 index 000000000..c64453b9b --- /dev/null +++ b/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 = 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> { + 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> { + 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 }; + } +} diff --git a/src/actions/webhook-targets.ts b/src/actions/webhook-targets.ts new file mode 100644 index 000000000..d7c6865f4 --- /dev/null +++ b/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 | 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; +} + +function validateProviderConfig(params: { + providerType: WebhookProviderType; + webhookUrl: string | null; + telegramBotToken: string | null; + telegramChatId: string | null; + customTemplate?: Record | 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; + +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): { + name: string; + providerType: WebhookProviderType; + webhookUrl: string | null; + telegramBotToken: string | null; + telegramChatId: string | null; + dingtalkSecret: string | null; + customTemplate: Record | null; + customHeaders: Record | 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 +): { + name: string; + providerType: WebhookProviderType; + webhookUrl: string | null; + telegramBotToken: string | null; + telegramChatId: string | null; + dingtalkSecret: string | null; + customTemplate: Record | null; + customHeaders: Record | 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> { + 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 +): Promise> { + 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 +): Promise> { + 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> { + 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> { + 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 }; + } +} diff --git a/src/app/[locale]/settings/notifications/_components/binding-selector.tsx b/src/app/[locale]/settings/notifications/_components/binding-selector.tsx new file mode 100644 index 000000000..514f3b0cd --- /dev/null +++ b/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 | null; + }> + ) => Promise>; +} + +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; +type BindingsFormValues = z.input; + +function toJsonString(value: unknown): string { + if (!value) return ""; + try { + return JSON.stringify(value, null, 2); + } catch { + return ""; + } +} + +function parseJsonObjectOrNull(value: string | null | undefined): Record | 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; +} + +export function BindingSelector({ type, targets, bindings, onSave }: BindingSelectorProps) { + const t = useTranslations("settings"); + const [expanded, setExpanded] = useState>({}); + const [templateDialogOpen, setTemplateDialogOpen] = useState(false); + const [templateEditingTargetId, setTemplateEditingTargetId] = useState(null); + + const bindingByTargetId = useMemo(() => { + const map = new Map(); + bindings.forEach((b) => map.set(b.targetId, b)); + return map; + }, [bindings]); + + const formValues = useMemo(() => { + 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({ + 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 ( +
+ {!hasTargets ? ( +
{t("notifications.bindings.noTargets")}
+ ) : ( +
+ {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 ( + +
+
+ + setValue(`rows.${index}.isBound`, Boolean(checked), { shouldDirty: true }) + } + aria-label={t("notifications.bindings.bindTarget")} + /> + +
+
{target.name}
+
+ {t(`notifications.targetDialog.types.${target.providerType}` as any)} +
+
+
+ +
+
+ + + setValue(`rows.${index}.isEnabled`, checked, { shouldDirty: true }) + } + /> +
+ + setExpanded((p) => ({ ...p, [target.id]: open }))} + > + + + + + +
+
+ + + setValue(`rows.${index}.scheduleCron`, e.target.value, { + shouldDirty: true, + }) + } + placeholder={t("notifications.bindings.scheduleCronPlaceholder")} + /> +
+
+ + + setValue(`rows.${index}.scheduleTimezone`, e.target.value, { + shouldDirty: true, + }) + } + placeholder="Asia/Shanghai" + /> +
+
+ + {target.providerType === "custom" ? ( +
+ + +
+ ) : null} +
+
+
+
+
+ ); + })} +
+ )} + +
+ +
+ + (open ? setTemplateDialogOpen(true) : closeTemplateDialog())} + > + + + {t("notifications.bindings.templateOverrideTitle")} + + + { + if (templateEditingIndex >= 0) { + setValue(`rows.${templateEditingIndex}.templateOverrideJson`, v, { + shouldDirty: true, + }); + } + }} + notificationType={type} + /> + + + + + + + +
+ ); +} diff --git a/src/app/[locale]/settings/notifications/_components/global-settings-card.tsx b/src/app/[locale]/settings/notifications/_components/global-settings-card.tsx new file mode 100644 index 000000000..541e68a5c --- /dev/null +++ b/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; +} + +export function GlobalSettingsCard({ + enabled, + useLegacyMode, + onEnabledChange, +}: GlobalSettingsCardProps) { + const t = useTranslations("settings"); + + return ( + + + + + {t("notifications.global.title")} + + {t("notifications.global.description")} + + + +
+ + onEnabledChange(checked)} + /> +
+ + {useLegacyMode ? ( + + {t("notifications.global.legacyModeTitle")} + {t("notifications.global.legacyModeDescription")} + + ) : null} +
+
+ ); +} diff --git a/src/app/[locale]/settings/notifications/_components/notification-type-card.tsx b/src/app/[locale]/settings/notifications/_components/notification-type-card.tsx new file mode 100644 index 000000000..90b1b1124 --- /dev/null +++ b/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 + ) => Promise>; + onSaveBindings: BindingSelectorProps["onSave"]; + onTestLegacyWebhook: ( + type: NotificationType, + webhookUrl: string + ) => Promise>; +} + +type BindingSelectorProps = ComponentProps; + +function getIcon(type: NotificationType) { + switch (type) { + case "circuit_breaker": + return ; + case "daily_leaderboard": + return ; + case "cost_alert": + return ; + } +} + +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(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; + 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 ( + + + +
+ {getIcon(type)} + {meta.title} +
+ {!useLegacyMode ? ( +
+ + {t("notifications.bindings.boundCount", { count: bindings.length })} + + 0 ? "default" : "secondary"}> + {t("notifications.bindings.enabledCount", { count: bindingEnabledCount })} + +
+ ) : null} +
+ {meta.description} +
+ + +
+ + onUpdateSettings({ [meta.enabledKey]: checked } as any)} + /> +
+ + {useLegacyMode ? ( +
+ +
+ setLegacyWebhookUrl(e.target.value)} + onBlur={saveLegacyWebhook} + /> + +
+
+ ) : null} + + {type === "daily_leaderboard" ? ( +
+
+ + onUpdateSettings({ dailyLeaderboardTime: e.target.value })} + /> +
+ +
+ + onUpdateSettings({ dailyLeaderboardTopN: Number(e.target.value) })} + /> +
+
+ ) : null} + + {type === "cost_alert" ? ( +
+
+
+ + {Math.round(settings.costAlertThreshold * 100)}% +
+ onUpdateSettings({ costAlertThreshold: v })} + /> +
+ +
+ + + onUpdateSettings({ costAlertCheckInterval: Number(e.target.value) }) + } + /> +
+
+ ) : null} + + {!useLegacyMode ? ( +
+ + +
+ ) : null} +
+
+ ); +} diff --git a/src/app/[locale]/settings/notifications/_components/notifications-skeleton.tsx b/src/app/[locale]/settings/notifications/_components/notifications-skeleton.tsx new file mode 100644 index 000000000..ec0c8e93f --- /dev/null +++ b/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 ( +
+
+ + +
+ + + + +
+ + + +
+
+ ); +} diff --git a/src/app/[locale]/settings/notifications/_components/proxy-config-section.tsx b/src/app/[locale]/settings/notifications/_components/proxy-config-section.tsx new file mode 100644 index 000000000..82c138717 --- /dev/null +++ b/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 ( + +
+
+ + {t("notifications.targetDialog.proxy.title")} +
+ + + +
+ + +
+ + onProxyUrlChange(e.target.value)} + placeholder={t("notifications.targetDialog.proxy.urlPlaceholder")} + /> +
+ +
+ + onProxyFallbackToDirectChange(checked)} + /> +
+
+
+ ); +} diff --git a/src/app/[locale]/settings/notifications/_components/template-editor.tsx b/src/app/[locale]/settings/notifications/_components/template-editor.tsx new file mode 100644 index 000000000..e7035e78f --- /dev/null +++ b/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(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 ( +
+
+ +