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
+1 -18
View File
@@ -1,21 +1,4 @@
# OpenAI API 配置
OPENAI_API_KEY=your-api-key-here
OPENAI_API_BASE=https://api.openai.com/v1
# 模型设置
OPENAI_MODEL=gpt-3.5-turbo
MAX_TOKENS=500
TEMPERATURE=0.7
OPENAI_STREAM_MAX_SSE_LINE_BYTES=1048576
# 缓存配置
ENABLE_CACHE=true
CACHE_EXPIRATION=86400
# 日志配置
LOG_LEVEL=INFO
# Prisma 配置,当前答题服务不依赖数据库,但项目保留 Prisma
# Prisma 配置
DATABASE_URL="mysql://user:password@localhost:3306/database"
# better-auth 配置
+5 -7
View File
@@ -32,8 +32,6 @@ OPENAI_API_BASE=https://api.openai.com/v1
OPENAI_MODEL=gpt-3.5-turbo
MAX_TOKENS=500
TEMPERATURE=0.7
ENABLE_CACHE=true
CACHE_EXPIRATION=86400
```
如需限制访问:
@@ -82,11 +80,11 @@ pnpm dev
参数:
| 参数 | 必填 | 说明 |
| --- | --- | --- |
| `title` | 是 | 题目内容 |
| `type` | 否 | `single``multiple``judgement``completion` |
| `options` | 否 | 选项文本 |
| 参数 | 必填 | 说明 |
| ----------- | ---- | ------------------------------------------------------- |
| `title` | 是 | 题目内容 |
| `type` | 否 | `single``multiple``judgement``completion` |
| `options` | 否 | 选项文本 |
成功:
+57 -3
View File
@@ -13,19 +13,22 @@ datasource db {
// Better Auth 标准用户表
// apiToken 由服务端注册 hook 生成,供 OCS 油猴脚本跨域身份验证
model User {
id String @id
id String @id
name String
email String @unique
email String @unique
emailVerified Boolean
image String?
createdAt DateTime
updatedAt DateTime
apiToken String? @unique
apiToken String? @unique
/// 用户最近一次清缓存的时间;只有在此时间之后写入的记录才算缓存命中
cacheClearedAt DateTime?
/// 用户角色:user(普通用户)、admin(管理员)、superadmin(超级管理员)
role String @default("user")
sessions Session[]
accounts Account[]
qaRecords QaRecord[]
userConfigs UserConfig[]
@@map("user")
}
@@ -77,6 +80,57 @@ model Verification {
@@map("verification")
}
// 系统级全局配置表(KV 结构),存放 OpenAI 等运行时配置
// 管理员通过后台修改,服务端读取时使用 60 秒 TTL 缓存,不再依赖环境变量
model SystemConfig {
key String @id
value String @db.Text
/// 配置项中文描述
description String?
updatedAt DateTime @updatedAt
@@map("system_config")
}
// 用户自定义配置(与系统配置独立),后续支持用户覆盖 API Key 等
model UserConfig {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
/// 配置键,与 system_config 的 key 语义相同
key String
value String @db.Text
updatedAt DateTime @updatedAt
@@unique([userId, key])
@@map("user_config")
}
// 可分配的权限定义,预留结构,暂无实际权限可配置
model Permission {
id String @id @default(cuid())
/// 权限标识符,如 "manage_users"、"view_all_records"
name String @unique
description String?
createdAt DateTime @default(now())
rolePermissions RolePermission[]
@@map("permission")
}
// 角色-权限映射表,预留;role 字段存角色字符串,方便后续新增角色
model RolePermission {
id String @id @default(cuid())
/// 角色名称,如 "user"、"admin"、"superadmin"
role String
permissionId String
permission Permission @relation(fields: [permissionId], references: [id], onDelete: Cascade)
@@unique([role, permissionId])
@@index([role])
@@map("role_permission")
}
// 用户问答记录,按用户隔离存储
// search 接口在拿到有效 session 或 apiToken 后写入,与当前登录用户绑定
model QaRecord {
+4 -3
View File
@@ -1,7 +1,7 @@
// server/api/health.get.ts - 服务健康检查接口
import { serverEnv } from "~~/server/utils/env";
import { apiOk } from "~~/server/utils/response";
import { SERVICE_VERSION } from "~~/server/utils/runtimeState";
import { getSysConfig } from "~~/server/utils/sysConfig";
/**
* 健康检查不需要鉴权
@@ -9,11 +9,12 @@ import { SERVICE_VERSION } from "~~/server/utils/runtimeState";
* 这个接口用于部署平台、Docker healthcheck 或人工确认服务是否启动;
* 返回模型名,但不返回 API Key、baseURL 或其他敏感配置
*/
export default defineEventHandler(() => {
export default defineEventHandler(async () => {
const cfg = await getSysConfig();
return apiOk({
status: "ok" as const,
message: "AI题库服务运行正常",
version: SERVICE_VERSION,
model: serverEnv.openAiModel
model: cfg.openAiModel
});
});
+1 -1
View File
@@ -18,7 +18,7 @@ export default defineEventHandler(async (event) => {
try {
const [runtimeStats, qa_records_count] = await Promise.all([
Promise.resolve(getRuntimeStats()),
getRuntimeStats(),
prisma.qaRecord.count({ where: { userId } })
]);
+61
View File
@@ -0,0 +1,61 @@
// server/plugins/initConfig.ts - 服务启动时检查 system_config 表,补全缺失的默认配置
// 只在首次部署或新增配置项时写入,不覆盖已有值;不读取环境变量
import { prisma } from "~~/server/utils/db";
// 所有系统配置的默认值,key 对应 system_config.key
// 管理员后续通过后台修改 valueopenai_api_key 默认留空,需手动填入
const DEFAULT_CONFIGS = [
{
key: "openai_api_key",
value: "",
description: "OpenAI 或兼容 API Key,仅服务端使用,不能返回给前端"
},
{
key: "openai_api_base",
value: "https://api.openai.com/v1",
description: "OpenAI 兼容 API base URL,例如 https://api.openai.com/v1"
},
{
key: "openai_model",
value: "gpt-3.5-turbo",
description: "Chat Completions 使用的模型名"
},
{
key: "max_tokens",
value: "500",
description: "单次回答最大 token 数"
},
{
key: "temperature",
value: "0.7",
description: "模型采样温度,越低越稳定,范围 0~2"
},
{
key: "openai_stream_max_sse_line_bytes",
value: "1048576",
description: "单行 SSE data 最大字节数,防止异常流无限堆内存"
}
];
export default defineNitroPlugin(async () => {
try {
const existingRows = await prisma.systemConfig.findMany({
select: { key: true }
});
const existingKeys = new Set(existingRows.map((r) => r.key));
const missing = DEFAULT_CONFIGS.filter((c) => !existingKeys.has(c.key));
if (missing.length === 0) return;
await prisma.systemConfig.createMany({
data: missing,
skipDuplicates: true
});
console.log(
`[initConfig] 已写入 ${missing.length} 项默认系统配置:${missing.map((c) => c.key).join(", ")}`
);
} catch (err) {
// 初始化失败不阻断进程启动,记录警告供排查
console.error("[initConfig] 系统配置初始化失败:", String(err));
}
});
-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;
};