feat: 大量优化和bug修复
This commit is contained in:
+24
-3
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
@@ -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 "****";
|
||||
};
|
||||
|
||||
@@ -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
|
||||
});
|
||||
Reference in New Issue
Block a user