feat: 重构配置管理,使用数据库替代环境变量,优化系统配置读取

This commit is contained in:
2026-05-23 15:37:58 +08:00
parent ea95886cfc
commit 92b063c4c4
10 changed files with 249 additions and 129 deletions
-80
View File
@@ -1,80 +0,0 @@
// server/utils/env.ts - 服务端环境变量读取与规范化
/**
* 允许 `.env` 里保留类似 Python 旧项目的写法:
* `OPENAI_API_BASE=https://example.com/v1 # 注释`
* Nuxt/Node 读到的是整行值,所以这里主动裁掉行尾注释
*/
const stripInlineComment = (value: string) => {
return value.replace(/\s+#.*$/u, "").trim();
};
/**
* 读取字符串环境变量
*
* - 空字符串视为未配置,回退到 fallback
* - 返回值统一去掉行尾注释,避免 baseURL、model 之类配置带上注释文本
*/
const readString = (key: string, fallback = "") => {
const value = process.env[key];
if (value === undefined || value === null || value.trim() === "") {
return fallback;
}
return stripInlineComment(value);
};
/** 读取可选字符串;空值统一转成 undefined,方便后续判断是否启用某能力 */
const readOptionalString = (key: string) => {
const value = readString(key);
return value || undefined;
};
/** 兼容常见布尔写法,便于 Docker、面板和手写 `.env` 配置 */
const readBoolean = (key: string, fallback: boolean) => {
const value = readString(key);
if (!value) return fallback;
return ["1", "true", "yes", "on"].includes(value.toLowerCase());
};
/** 读取整数配置;非法值不抛错,使用默认值保证服务能启动 */
const readInteger = (key: string, fallback: number) => {
const value = Number.parseInt(readString(key), 10);
return Number.isFinite(value) ? value : fallback;
};
/** 读取小数配置,主要用于 OpenAI temperature */
const readNumber = (key: string, fallback: number) => {
const value = Number.parseFloat(readString(key));
return Number.isFinite(value) ? value : fallback;
};
/** OpenAI 兼容接口 baseURL 统一不带结尾斜杠,后续再拼 `/chat/completions` */
const normalizeBaseUrl = (url: string) => {
return url.replace(/\/+$/u, "");
};
/**
* 服务端环境配置的唯一出口
*
* 业务代码不要直接读 `process.env`,这样可以把默认值、格式修正和安全边界
* 都收敛在这里,后续迁移部署平台时也只需要检查这个文件
*/
export const serverEnv = {
/** OpenAI 或兼容中转平台 API Key,仅服务端使用,不能返回给前端 */
openAiApiKey: readString("OPENAI_API_KEY"),
/** OpenAI 兼容 API base,例如 `https://api.openai.com/v1` */
openAiApiBase: normalizeBaseUrl(
readString("OPENAI_API_BASE", "https://api.openai.com/v1")
),
/** Chat Completions 使用的模型名 */
openAiModel: readString("OPENAI_MODEL", "gpt-3.5-turbo"),
/** 单次回答最大 token 数,沿用旧 Python 服务的配置语义 */
maxTokens: readInteger("MAX_TOKENS", 500),
/** 模型采样温度,越低越稳定 */
temperature: readNumber("TEMPERATURE", 0.7),
/** 预留日志级别配置,目前日志工具只负责安全输出 */
logLevel: readString("LOG_LEVEL", "INFO"),
/** 单行 SSE data 的最大字节数,用于防止异常流无限堆内存 */
maxSseLineBytes: readInteger("OPENAI_STREAM_MAX_SSE_LINE_BYTES", 1_048_576)
};
+20 -14
View File
@@ -1,8 +1,6 @@
// server/utils/openai.ts - OpenAI Chat Completions 流式调用工具
import { serverEnv } from "~~/server/utils/env";
import { getSysConfig } from "~~/server/utils/sysConfig";
/** 旧项目的 `OPENAI_API_BASE` 语义是完整 base,例如 `https://api.openai.com/v1` */
const CHAT_COMPLETIONS_URL = `${serverEnv.openAiApiBase}/chat/completions`;
/** 复用编码器计算 SSE data 字节长度,避免循环里频繁创建对象 */
const textEncoder = new TextEncoder();
@@ -56,21 +54,25 @@ export const askAnswerStream = async ({
prompt: string;
systemPrompt: string;
}): Promise<AskAnswerStreamResult> => {
if (!serverEnv.openAiApiKey) {
const cfg = await getSysConfig();
if (!cfg.openAiApiKey) {
throw new Error("OPENAI_API_KEY is not set.");
}
const chatCompletionsUrl = `${cfg.openAiApiBase}/chat/completions`;
// 这里不要记录 request body:其中包含完整题目和可能的选项文本
const response = await fetch(CHAT_COMPLETIONS_URL, {
const response = await fetch(chatCompletionsUrl, {
method: "POST",
headers: {
Authorization: `Bearer ${serverEnv.openAiApiKey}`,
Authorization: `Bearer ${cfg.openAiApiKey}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
model: serverEnv.openAiModel,
temperature: serverEnv.temperature,
max_tokens: serverEnv.maxTokens,
model: cfg.openAiModel,
temperature: cfg.temperature,
max_tokens: cfg.maxTokens,
stream: true,
messages: [
{
@@ -94,7 +96,10 @@ export const askAnswerStream = async ({
}
// 将 SSE delta 读完后再返回给 API handler,由 handler 统一做答案清洗和缓存
const upstreamResponse = await readChatCompletionStream(response.body);
const upstreamResponse = await readChatCompletionStream(
response.body,
cfg.maxSseLineBytes
);
return {
answer: upstreamResponse.content.trim(),
@@ -112,7 +117,8 @@ export const askAnswerStream = async ({
* 网络 chunk 不一定按行对齐,所以用 buffer 保存尚未拼完整的一行
*/
export const readChatCompletionStream = async (
stream: ReadableStream<Uint8Array>
stream: ReadableStream<Uint8Array>,
maxSseLineBytes: number
): Promise<ChatCompletionStreamResult> => {
const reader = stream.getReader();
const decoder = new TextDecoder();
@@ -146,14 +152,14 @@ export const readChatCompletionStream = async (
// stream: true 可以正确处理跨 chunk 的多字节字符,例如中文
buffer += decoder.decode(value, { stream: true });
assertSseBufferSize(buffer, serverEnv.maxSseLineBytes);
assertSseBufferSize(buffer, maxSseLineBytes);
// 只处理已经遇到换行的完整 SSE 行,最后一段留到下次 chunk 再拼
const lines = buffer.split(/\r?\n/u);
buffer = lines.pop() ?? "";
for (const line of lines) {
const chunk = parseSseDataLine(line, serverEnv.maxSseLineBytes);
const chunk = parseSseDataLine(line, maxSseLineBytes);
if (chunk) collectChunk(chunk);
}
}
@@ -161,7 +167,7 @@ export const readChatCompletionStream = async (
// 处理流结束时仍留在 buffer 中的最后一行
const finalText = buffer + decoder.decode();
for (const line of finalText.split(/\r?\n/u)) {
const chunk = parseSseDataLine(line, serverEnv.maxSseLineBytes);
const chunk = parseSseDataLine(line, maxSseLineBytes);
if (chunk) collectChunk(chunk);
}
+4 -3
View File
@@ -1,6 +1,6 @@
// server/utils/runtimeState.ts - 服务启动时间内存状态 + 问答记录 DB 读写
import { prisma } from "~~/server/utils/db";
import { serverEnv } from "~~/server/utils/env";
import { getSysConfig } from "~~/server/utils/sysConfig";
/** 进程启动时间,用于 `/api/stats` 返回 uptime */
const startTime = Date.now();
@@ -100,10 +100,11 @@ export const getQaRecords = async (
return { records, total };
};
/** 生成 `/api/stats` 的基础运行时信息,不包含用户相关数据 */
export const getRuntimeStats = () => {
export const getRuntimeStats = async () => {
const cfg = await getSysConfig();
return {
version: SERVICE_VERSION,
uptime: (Date.now() - startTime) / 1000,
model: serverEnv.openAiModel
model: cfg.openAiModel
};
};
+96
View File
@@ -0,0 +1,96 @@
// server/utils/sysConfig.ts - 从数据库读取系统配置,带 60 秒 TTL 内存缓存
// 替代原 env.ts 中的 OpenAI 配置项,配置以 DB 为准,管理员可通过后台修改
import { prisma } from "~~/server/utils/db";
/** 系统配置字段,对应 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;
}
// 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"
} as const;
// 硬编码默认值,与原 env.ts 的 fallback 保持一致
const DEFAULTS: SysConfig = {
openAiApiKey: "",
openAiApiBase: "https://api.openai.com/v1",
openAiModel: "gpt-3.5-turbo",
maxTokens: 500,
temperature: 0.7,
maxSseLineBytes: 1_048_576
};
// 内存缓存:减少每请求查 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)
};
_cachedConfig = config;
_cacheExpiresAt = now + CACHE_TTL_MS;
return config;
};
/** 主动使缓存失效,管理员更新系统配置后调用,下次请求将重新查 DB */
export const invalidateSysConfigCache = () => {
_cachedConfig = null;
_cacheExpiresAt = 0;
};