175 lines
6.1 KiB
TypeScript
175 lines
6.1 KiB
TypeScript
// 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<SysConfig> => {
|
||
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<SysConfig> => {
|
||
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 "****";
|
||
};
|