feat: 大量优化和bug修复

This commit is contained in:
2026-05-23 22:23:29 +08:00
parent 92b063c4c4
commit fc71e924e7
39 changed files with 1381 additions and 302 deletions
+24 -3
View File
@@ -4,7 +4,6 @@ import { randomBytes } from "node:crypto";
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import type { H3Event } from "h3";
import { toWebRequest } from "h3";
import { prisma } from "~~/server/utils/db";
@@ -36,6 +35,12 @@ export const auth = betterAuth({
required: false,
// 客户端(signup/updateUser)不能设置此字段,只由 databaseHooks 写入
input: false
},
role: {
type: "string",
required: false,
// 角色只由服务端管理员设置,不允许客户端修改
input: false
}
}
},
@@ -60,8 +65,24 @@ export const auth = betterAuth({
/**
* 从 H3 事件中读取 Better Auth session
*
* 封装 toWebRequest 转换,避免在每个 handler 中重复导入和调用
* 只复制 headers,不调用 toWebRequest(event)。
* 对 POST/PUT 请求,toWebRequest 会包装 body stream;全局鉴权中调用后,
* 后续 handler 再 readBody(event) 可能长时间挂起。
* 未登录或 session 无效时返回 null
*/
const getHeaderSnapshot = (event: H3Event) => {
const headers = new Headers();
for (const [key, value] of Object.entries(event.node.req.headers)) {
if (Array.isArray(value)) {
for (const item of value) headers.append(key, item);
} else if (value !== undefined) {
headers.set(key, value);
}
}
return headers;
};
export const getAuthSession = (event: H3Event) =>
auth.api.getSession({ headers: toWebRequest(event).headers });
auth.api.getSession({ headers: getHeaderSnapshot(event) });
+19 -5
View File
@@ -26,18 +26,21 @@ const createMariaDbConfig = () => {
database: decodeURIComponent(url.pathname.slice(1)),
connectionLimit: 5,
connectTimeout: 15_000,
acquireTimeout: 20_000
// 正常情况下连接应立即可用;5s 内拿不到连接说明池已耗尽,快速报错优于无限挂起
acquireTimeout: 5_000
};
};
type MariaDbConfig = ReturnType<typeof createMariaDbConfig>;
/**
* 创建 Prisma Client
*
* 连接池超时设置比默认值更宽松,是因为当前数据库是远程 MySQL,
* 默认超时时间过短时 Node driver 可能还没建好 socket 就失败
*/
const prismaClientSingleton = () => {
const adapter = new PrismaMariaDb(createMariaDbConfig());
const prismaClientSingleton = (config: MariaDbConfig) => {
const adapter = new PrismaMariaDb(config);
return new PrismaClient({ adapter });
};
@@ -50,10 +53,21 @@ type PrismaClientSingleton = ReturnType<typeof prismaClientSingleton>;
*/
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClientSingleton | undefined;
prismaConfigSignature: string | undefined;
};
const mariaDbConfig = createMariaDbConfig();
const prismaConfigSignature = JSON.stringify(mariaDbConfig);
/** 全项目唯一 Prisma Client 实例 */
export const prisma = globalForPrisma.prisma ?? prismaClientSingleton();
export const prisma =
globalForPrisma.prisma &&
globalForPrisma.prismaConfigSignature === prismaConfigSignature
? globalForPrisma.prisma
: prismaClientSingleton(mariaDbConfig);
// 生产环境由进程生命周期管理;开发环境缓存到全局,减少 HMR 连接泄漏
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
globalForPrisma.prismaConfigSignature = prismaConfigSignature;
}
+8 -4
View File
@@ -1,5 +1,5 @@
// server/utils/openai.ts - OpenAI Chat Completions 流式调用工具
import { getSysConfig } from "~~/server/utils/sysConfig";
import type { SysConfig } from "~~/server/utils/sysConfig";
/** 复用编码器计算 SSE data 字节长度,避免循环里频繁创建对象 */
const textEncoder = new TextEncoder();
@@ -46,16 +46,20 @@ export interface AskAnswerStreamResult {
*
* 对 OCS 客户端仍返回普通 JSON;流式只发生在服务端到 OpenAI 之间
* 这样既能更早读取上游内容,又不破坏 AnswererWrapper 的 handler 契约
*
* cfg 由调用方传入(通过 getUserEffectiveApiConfig 或 getSysConfig 获取),
* 不在此处直接读取系统配置,确保用户自定义配置可正确生效
*/
export const askAnswerStream = async ({
prompt,
systemPrompt
systemPrompt,
cfg
}: {
prompt: string;
systemPrompt: string;
/** 生效配置,由调用方传入;含 key 等敏感信息,不做日志 */
cfg: SysConfig;
}): Promise<AskAnswerStreamResult> => {
const cfg = await getSysConfig();
if (!cfg.openAiApiKey) {
throw new Error("OPENAI_API_KEY is not set.");
}
+70 -2
View File
@@ -1,6 +1,7 @@
// server/utils/sysConfig.ts - 从数据库读取系统配置,带 60 秒 TTL 内存缓存
// 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 {
@@ -32,7 +33,7 @@ const CONFIG_KEYS = {
const DEFAULTS: SysConfig = {
openAiApiKey: "",
openAiApiBase: "https://api.openai.com/v1",
openAiModel: "gpt-3.5-turbo",
openAiModel: "gpt-5.2",
maxTokens: 500,
temperature: 0.7,
maxSseLineBytes: 1_048_576
@@ -94,3 +95,70 @@ 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
};
};
/**
* 遮蔽 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 "****";
};
+103
View File
@@ -0,0 +1,103 @@
// server/utils/userConfig.ts - 用户自定义配置 JSON value 的归一化与合并
import type { Prisma } from "~~/prisma/generated/client";
import type { IUserSettingsUpdateRequest } from "~~/shared/types/settings";
export interface UserConfigValue {
useCustomConfig: boolean;
customApiBase: string | null;
customApiKey: string | null;
customModel: string | null;
customMaxTokens: number | null;
customTemperature: number | null;
}
export const DEFAULT_USER_CONFIG_VALUE: UserConfigValue = {
useCustomConfig: false,
customApiBase: null,
customApiKey: null,
customModel: null,
customMaxTokens: null,
customTemperature: null
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const cleanString = (value: unknown): string | null => {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
};
const cleanApiKey = (value: unknown): string | null => {
if (typeof value !== "string") return null;
return value.length > 0 ? value : null;
};
const cleanNumber = (value: unknown): number | null =>
typeof value === "number" && Number.isFinite(value) ? value : null;
export const normalizeUserConfigValue = (
value: unknown
): UserConfigValue => {
if (!isRecord(value)) {
return { ...DEFAULT_USER_CONFIG_VALUE };
}
return {
useCustomConfig: value.useCustomConfig === true,
customApiBase: cleanString(value.customApiBase),
customApiKey: cleanApiKey(value.customApiKey),
customModel: cleanString(value.customModel),
customMaxTokens: cleanNumber(value.customMaxTokens),
customTemperature: cleanNumber(value.customTemperature)
};
};
export const mergeUserConfigUpdate = (
current: UserConfigValue,
req: IUserSettingsUpdateRequest
): UserConfigValue => {
const next: UserConfigValue = { ...current };
if (req.useCustomConfig !== undefined) {
next.useCustomConfig = req.useCustomConfig;
}
if (req.customApiBase !== undefined) {
next.customApiBase = cleanString(req.customApiBase);
}
// 空字符串表示不修改已保存的 key,避免用户保存表单时误清空密钥
if (req.customApiKey !== undefined && req.customApiKey !== "") {
next.customApiKey = cleanApiKey(req.customApiKey);
}
if (req.customModel !== undefined) {
next.customModel = cleanString(req.customModel);
}
if (req.customMaxTokens !== undefined) {
next.customMaxTokens = cleanNumber(req.customMaxTokens);
}
if (req.customTemperature !== undefined) {
next.customTemperature = cleanNumber(req.customTemperature);
}
return next;
};
export const hasUserConfigMutation = (req: IUserSettingsUpdateRequest) =>
req.useCustomConfig !== undefined ||
req.customApiBase !== undefined ||
(req.customApiKey !== undefined && req.customApiKey !== "") ||
req.customModel !== undefined ||
req.customMaxTokens !== undefined ||
req.customTemperature !== undefined;
export const toUserConfigJson = (
value: UserConfigValue
): Prisma.InputJsonObject => ({
useCustomConfig: value.useCustomConfig,
customApiBase: value.customApiBase,
customApiKey: value.customApiKey,
customModel: value.customModel,
customMaxTokens: value.customMaxTokens,
customTemperature: value.customTemperature
});