Files
OCS_service/server/utils/sysConfig.ts
T
2026-05-23 23:48:30 +08:00

175 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 "****";
};