// server/utils/sysConfig.ts - 从数据库读取系统配置,带 60 秒 TTL 内存缓存;支持按用户自定义配置覆盖 // 替代原 env.ts 中的 OpenAI 配置项,配置以 DB 为准,管理员可通过后台修改 import { prisma } from "~~/server/utils/db"; import { normalizeUserConfigValue } from "~~/server/utils/userConfig"; /** 系统配置字段,对应 system_config 表中的各 key */ export interface SysConfig { /** OpenAI 或兼容 API Key,仅服务端使用,不能返回给前端 */ openAiApiKey: string; /** OpenAI 兼容 API base URL,不含结尾斜杠 */ openAiApiBase: string; /** Chat Completions 使用的模型名 */ openAiModel: string; /** 单次回答最大 token 数 */ maxTokens: number; /** 模型采样温度,越低越稳定 */ temperature: number; /** 单行 SSE data 最大字节数,防止异常流无限堆内存 */ maxSseLineBytes: number; /** 是否允许新用户注册;false 时注册接口直接拒绝 */ allowRegistration: boolean; } // system_config 表 key 字段的常量映射 const CONFIG_KEYS = { openAiApiKey: "openai_api_key", openAiApiBase: "openai_api_base", openAiModel: "openai_model", maxTokens: "max_tokens", temperature: "temperature", maxSseLineBytes: "openai_stream_max_sse_line_bytes", allowRegistration: "allow_registration" } as const; // 硬编码默认值,与原 env.ts 的 fallback 保持一致 const DEFAULTS: SysConfig = { openAiApiKey: "", openAiApiBase: "https://api.openai.com/v1", openAiModel: "gpt-5.2", maxTokens: 500, temperature: 0.7, maxSseLineBytes: 1_048_576, // 默认开放注册;管理员可通过系统配置关闭 allowRegistration: true }; // 内存缓存:减少每请求查 DB 的开销 let _cachedConfig: SysConfig | null = null; let _cacheExpiresAt = 0; const CACHE_TTL_MS = 60_000; /** baseURL 不能带结尾斜杠,后续拼 `/chat/completions` 时格式才正确 */ const normalizeBaseUrl = (url: string) => url.replace(/\/+$/u, ""); /** * 读取系统配置 * * 60 秒内命中内存缓存直接返回,过期后重新查 DB; * 管理员更新配置后可调用 invalidateSysConfigCache() 主动失效 */ export const getSysConfig = async (): Promise => { const now = Date.now(); if (_cachedConfig && now < _cacheExpiresAt) { return _cachedConfig; } const rows = await prisma.systemConfig.findMany({ select: { key: true, value: true } }); const map = new Map(rows.map((r) => [r.key, r.value])); const str = (key: string, fallback: string) => map.get(key) ?? fallback; const int = (key: string, fallback: number) => { const v = Number.parseInt(map.get(key) ?? "", 10); return Number.isNaN(v) ? fallback : v; }; const float = (key: string, fallback: number) => { const v = Number.parseFloat(map.get(key) ?? ""); return Number.isNaN(v) ? fallback : v; }; const config: SysConfig = { openAiApiKey: str(CONFIG_KEYS.openAiApiKey, DEFAULTS.openAiApiKey), openAiApiBase: normalizeBaseUrl( str(CONFIG_KEYS.openAiApiBase, DEFAULTS.openAiApiBase) ), openAiModel: str(CONFIG_KEYS.openAiModel, DEFAULTS.openAiModel), maxTokens: int(CONFIG_KEYS.maxTokens, DEFAULTS.maxTokens), temperature: float(CONFIG_KEYS.temperature, DEFAULTS.temperature), maxSseLineBytes: int(CONFIG_KEYS.maxSseLineBytes, DEFAULTS.maxSseLineBytes), // "true" 以外的值均视为关闭;空值(未设置)走默认值 true allowRegistration: (map.get(CONFIG_KEYS.allowRegistration) ?? "true") !== "false" }; _cachedConfig = config; _cacheExpiresAt = now + CACHE_TTL_MS; return config; }; /** 主动使缓存失效,管理员更新系统配置后调用,下次请求将重新查 DB */ export const invalidateSysConfigCache = () => { _cachedConfig = null; _cacheExpiresAt = 0; }; /** * 获取当前用户生效的 API 配置 * * 安全规则(保证系统 key 不流向用户控制的 endpoint): * - useCustomConfig=true 且 customApiBase + customApiKey 都非空 * → base 和 key 用用户自定义值;model/max_tokens/temperature 有值则各自覆盖系统值 * - 上述条件任一不满足 * → base 和 key 完全使用系统配置(系统 key 不进用户 base) * - useCustomConfig=false 或未设置 * → 完全使用系统配置,用户覆盖项全部忽略 */ export const getUserEffectiveApiConfig = async ( userId: string ): Promise => { const sysCfg = await getSysConfig(); const row = await prisma.userConfig.findUnique({ where: { userId }, select: { value: true } }); const userConfig = normalizeUserConfigValue(row?.value); const useCustom = userConfig.useCustomConfig; if (!useCustom) { return sysCfg; } const customBase = userConfig.customApiBase ?? ""; const customKey = userConfig.customApiKey?.trim() ?? ""; // base 和 key 必须同时有值,才允许覆盖;否则继续使用系统的 base+key // 这样可以保证系统 key 绝不发往用户控制的 endpoint const useCustomEndpoint = customBase.length > 0 && customKey.length > 0; const effectiveBase = useCustomEndpoint ? normalizeBaseUrl(customBase) : sysCfg.openAiApiBase; const effectiveKey = useCustomEndpoint ? customKey : sysCfg.openAiApiKey; // model / max_tokens / temperature 在自定义配置开启时可以各自独立覆盖 const customModel = userConfig.customModel ?? ""; return { openAiApiBase: effectiveBase, openAiApiKey: effectiveKey, openAiModel: customModel.length > 0 ? customModel : sysCfg.openAiModel, maxTokens: userConfig.customMaxTokens ?? sysCfg.maxTokens, temperature: userConfig.customTemperature ?? sysCfg.temperature, maxSseLineBytes: sysCfg.maxSseLineBytes, // 用户有效配置不改变注册开关,手带系统值 allowRegistration: sysCfg.allowRegistration }; }; /** * 遮蔽 API Key,只留前 4 后 4 位,用于前端展示 * * 长度 > 8 → 前4 + "..." + 后4(如 `sk-a...xxxx`) * 长度 1~8 → 全部替换为 `****` * 空 → null */ export const maskApiKey = (key: string): string | null => { if (!key) return null; if (key.length > 8) { return `${key.slice(0, 4)}...${key.slice(-4)}`; } return "****"; };