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
+33
View File
@@ -0,0 +1,33 @@
// server/api/admin/system-config.get.ts - 读取系统配置(仅 superadmin 可访问)
//
// 流程:鉴权 → 二次校验 superadmin → 读 system_config 表 → 遮蔽 key → 返回
// 安全边界:
// - handler 内独立校验 role === 'superadmin',不依赖 middleware
// - API Key 只返回遮蔽预览(前4...后4),不返回明文
// - 此接口不返回任何用户数据
import { setResponseStatus } from "h3";
import { getSysConfig, maskApiKey } from "~~/server/utils/sysConfig";
import { apiErr, apiOk } from "~~/server/utils/response";
import type { ISystemConfigResponse } from "~~/shared/types/settings";
export default defineEventHandler(async (event): Promise<ISystemConfigResponse> => {
// 二次校验:必须是 superadmin 才能访问系统配置
const role = (event.context.auth?.user as { role?: string } | undefined)
?.role;
if (role !== "superadmin") {
setResponseStatus(event, 403);
return apiErr(403, "权限不足") as ISystemConfigResponse;
}
const cfg = await getSysConfig();
return apiOk({
openAiApiBase: cfg.openAiApiBase,
openAiApiKeyPreview: maskApiKey(cfg.openAiApiKey),
openAiModel: cfg.openAiModel,
maxTokens: cfg.maxTokens,
temperature: cfg.temperature,
maxSseLineBytes: cfg.maxSseLineBytes
});
});
+122
View File
@@ -0,0 +1,122 @@
// server/api/admin/system-config.put.ts - 更新系统配置(仅 superadmin 可访问)
//
// 流程:鉴权 → 二次校验 superadmin → 白名单校验 → upsert system_config → 失效缓存 → 返回
// 安全边界:
// - handler 内独立校验 role === 'superadmin'
// - openAiApiKey 为空字符串时跳过,不清空已有 key
// - 更新成功后立即失效系统配置缓存,下次请求即生效
import { readBody, setResponseStatus } from "h3";
import { prisma } from "~~/server/utils/db";
import { createApiLogger, toSafeLogError } from "~~/server/utils/logging";
import { apiErr, apiOk } from "~~/server/utils/response";
import {
invalidateSysConfigCache
} from "~~/server/utils/sysConfig";
import type { ISystemConfigUpdateRequest } from "~~/shared/types/settings";
const isRecord = (v: unknown): v is Record<string, unknown> =>
typeof v === "object" && v !== null && !Array.isArray(v);
/** system_config 表 key 的常量映射 */
const SYS_KEY = {
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;
export default defineEventHandler(async (event) => {
const logger = createApiLogger(event, "admin.system-config.put");
const role = (event.context.auth?.user as { role?: string } | undefined)
?.role;
if (role !== "superadmin") {
setResponseStatus(event, 403);
return apiErr(403, "权限不足");
}
const body = await readBody<unknown>(event).catch(() => null);
if (!isRecord(body)) {
setResponseStatus(event, 400);
return apiErr(400, "请求体必须是 JSON 对象");
}
const req: ISystemConfigUpdateRequest = {};
if (typeof body.openAiApiBase === "string") {
req.openAiApiBase = body.openAiApiBase.trim();
}
if (typeof body.openAiApiKey === "string") {
req.openAiApiKey = body.openAiApiKey; // 空字符串 = 跳过,见下方逻辑
}
if (typeof body.openAiModel === "string") {
req.openAiModel = body.openAiModel.trim();
}
if (typeof body.maxTokens === "number") {
req.maxTokens = body.maxTokens;
}
if (typeof body.temperature === "number") {
req.temperature = body.temperature;
}
if (typeof body.maxSseLineBytes === "number") {
req.maxSseLineBytes = body.maxSseLineBytes;
}
// 构建需要写入的 KV 对(key 为空时跳过)
type SysKv = { key: string; value: string };
const upserts: SysKv[] = [];
if (req.openAiApiBase) {
upserts.push({ key: SYS_KEY.openAiApiBase, value: req.openAiApiBase });
}
// key 为空字符串时跳过,不清空已有 key
if (req.openAiApiKey) {
upserts.push({ key: SYS_KEY.openAiApiKey, value: req.openAiApiKey });
}
if (req.openAiModel) {
upserts.push({ key: SYS_KEY.openAiModel, value: req.openAiModel });
}
if (req.maxTokens !== undefined) {
upserts.push({ key: SYS_KEY.maxTokens, value: String(req.maxTokens) });
}
if (req.temperature !== undefined) {
upserts.push({ key: SYS_KEY.temperature, value: String(req.temperature) });
}
if (req.maxSseLineBytes !== undefined) {
upserts.push({
key: SYS_KEY.maxSseLineBytes,
value: String(req.maxSseLineBytes)
});
}
if (upserts.length === 0) {
return apiOk(null);
}
try {
await prisma.$transaction(
upserts.map((item) =>
prisma.systemConfig.upsert({
where: { key: item.key },
create: { key: item.key, value: item.value },
update: { value: item.value }
})
)
);
// 立即失效缓存,确保下次请求读到最新配置
invalidateSysConfigCache();
logger.info("system_config_updated", {
updatedKeys: upserts.map((u) => u.key)
});
return apiOk(null);
} catch (error) {
logger.error("system_config_update_failed", { error: toSafeLogError(error) });
setResponseStatus(event, 500);
return apiErr(500, "服务器内部错误");
}
});
+3 -19
View File
@@ -1,20 +1,4 @@
// server/api/health.get.ts - 服务健康检查接口
import { apiOk } from "~~/server/utils/response";
import { SERVICE_VERSION } from "~~/server/utils/runtimeState";
import { getSysConfig } from "~~/server/utils/sysConfig";
/**
* 健康检查不需要鉴权
*
* 这个接口用于部署平台、Docker healthcheck 或人工确认服务是否启动;
* 返回模型名,但不返回 API Key、baseURL 或其他敏感配置
*/
export default defineEventHandler(async () => {
const cfg = await getSysConfig();
return apiOk({
status: "ok" as const,
message: "AI题库服务运行正常",
version: SERVICE_VERSION,
model: cfg.openAiModel
});
// server/api/health.get.ts - 服务健康检查接口,无需鉴权
export default defineEventHandler(() => {
return { status: "ok" };
});
+7 -1
View File
@@ -22,6 +22,7 @@ import { prisma } from "~~/server/utils/db";
import { createApiLogger, toSafeLogError } from "~~/server/utils/logging";
import { askAnswerStream } from "~~/server/utils/openai";
import { addQaRecord, lookupCachedAnswer } from "~~/server/utils/runtimeState";
import { getUserEffectiveApiConfig } from "~~/server/utils/sysConfig";
/**
* 把 query/body/form 中的值统一转成字符串
@@ -166,6 +167,10 @@ export default defineEventHandler(async (event) => {
return createOcsErrorResponse("请先登录或提供有效 token");
}
// 读取用户生效配置:如果用户开启了自定义 API 且完整填写,则使用自定义 base+key
// 否则完全回退到系统配置,保证系统 key 不流向用户自定义的 endpoint
const effectiveCfg = await getUserEffectiveApiConfig(userId);
logger.info("read_question", {
questionLength: params.title.length,
type: params.type,
@@ -196,7 +201,8 @@ export default defineEventHandler(async (event) => {
);
const streamResult = await askAnswerStream({
prompt,
systemPrompt: ANSWER_SYSTEM_PROMPT
systemPrompt: ANSWER_SYSTEM_PROMPT,
cfg: effectiveCfg
});
const processedAnswer = extractAnswer(streamResult.answer, params.type);
-32
View File
@@ -1,32 +0,0 @@
// server/api/stats.get.ts - 服务运行统计接口
import { setResponseStatus } from "h3";
import { prisma } from "~~/server/utils/db";
import { createApiLogger } from "~~/server/utils/logging";
import { apiErr, apiOk } from "~~/server/utils/response";
import { getRuntimeStats } from "~~/server/utils/runtimeState";
/**
* 运行统计接口
*
* 返回进程 uptime、模型名以及当前用户的问答记录总数
* 必须携带有效 session cookie(由 api-auth middleware 统一鉴权)
*/
export default defineEventHandler(async (event) => {
const logger = createApiLogger(event, "api.stats");
const userId = event.context.auth!.user.id;
try {
const [runtimeStats, qa_records_count] = await Promise.all([
getRuntimeStats(),
prisma.qaRecord.count({ where: { userId } })
]);
logger.info("finish_success");
return apiOk({ ...runtimeStats, qa_records_count });
} catch (err) {
logger.error("db_error", { userId, err: String(err) });
setResponseStatus(event, 500);
return apiErr(500, "服务器内部错误");
}
});
+30
View File
@@ -0,0 +1,30 @@
// server/api/user/settings.get.ts - 读取当前用户的自定义 API 配置
//
// 流程:鉴权 → 读 user_config 单行 JSON 配置 → 遮蔽 key → 返回
// 安全边界:key 只返回遮蔽预览(前4...后4),不返回明文;不涉及系统配置
import { prisma } from "~~/server/utils/db";
import { apiOk } from "~~/server/utils/response";
import { maskApiKey } from "~~/server/utils/sysConfig";
import { normalizeUserConfigValue } from "~~/server/utils/userConfig";
import type { IUserSettingsResponse } from "~~/shared/types/settings";
export default defineEventHandler(async (event): Promise<IUserSettingsResponse> => {
// middleware 已确保此处 auth 不为空
const userId = event.context.auth!.user.id;
const row = await prisma.userConfig.findUnique({
where: { userId },
select: { value: true }
});
const userConfig = normalizeUserConfigValue(row?.value);
return apiOk({
useCustomConfig: userConfig.useCustomConfig,
customApiBase: userConfig.customApiBase,
customApiKeyPreview: maskApiKey(userConfig.customApiKey ?? ""),
customModel: userConfig.customModel,
customMaxTokens: userConfig.customMaxTokens,
customTemperature: userConfig.customTemperature
});
});
+2
View File
@@ -0,0 +1,2 @@
// server/api/user/settings.post.ts - 兼容 POST 保存个人配置,复用 PUT 实现
export { default } from "./settings.put";
+138
View File
@@ -0,0 +1,138 @@
// server/api/user/settings.put.ts - 更新当前用户的自定义 API 配置
//
// 流程:鉴权 → 白名单校验 → 合并并写入 user_config 单行 JSON value → 返回
// 安全边界:
// - customApiKey 为空字符串时跳过,不清空已有 key
// - 只接受声明字段,拒绝未知字段
// - 不读取或返回系统配置任何内容
// 注意:保存时使用单条 SQL upsert,避免多次写入造成连接占用
import { randomUUID } from "node:crypto";
import { readBody, setResponseStatus } from "h3";
import { Prisma } from "~~/prisma/generated/client";
import { prisma } from "~~/server/utils/db";
import { createApiLogger, toSafeLogError } from "~~/server/utils/logging";
import { apiErr, apiOk } from "~~/server/utils/response";
import {
hasUserConfigMutation,
mergeUserConfigUpdate,
normalizeUserConfigValue,
toUserConfigJson
} from "~~/server/utils/userConfig";
import type { IUserSettingsUpdateRequest } from "~~/shared/types/settings";
/** 是否是纯 JSON 对象 */
const isRecord = (v: unknown): v is Record<string, unknown> =>
typeof v === "object" && v !== null && !Array.isArray(v);
const ALLOWED_KEYS = new Set([
"useCustomConfig",
"customApiBase",
"customApiKey",
"customModel",
"customMaxTokens",
"customTemperature"
]);
/** 写入用户配置单行 JSON value */
const writeUserConfigValue = async (
userId: string,
value: Prisma.InputJsonObject
) => {
const updatedAt = new Date();
const jsonValue = JSON.stringify(value);
await prisma.$executeRaw(Prisma.sql`
INSERT INTO user_config (id, userId, value, updatedAt)
VALUES (${randomUUID()}, ${userId}, ${jsonValue}, ${updatedAt})
ON DUPLICATE KEY UPDATE value = VALUES(value), updatedAt = VALUES(updatedAt)
`);
};
export default defineEventHandler(async (event) => {
const logger = createApiLogger(event, "user.settings.put");
const userId = event.context.auth!.user.id;
const body = await readBody<unknown>(event).catch(() => null);
if (!isRecord(body)) {
setResponseStatus(event, 400);
return apiErr(400, "请求体必须是 JSON 对象");
}
const unknownKeys = Object.keys(body).filter((key) => !ALLOWED_KEYS.has(key));
if (unknownKeys.length > 0) {
setResponseStatus(event, 400);
return apiErr(400, "请求体包含不支持的字段");
}
// 白名单字段提取
const req: IUserSettingsUpdateRequest = {};
if (typeof body.useCustomConfig === "boolean") {
req.useCustomConfig = body.useCustomConfig;
}
if (typeof body.customApiBase === "string") {
req.customApiBase = body.customApiBase.trim();
}
if (typeof body.customApiKey === "string") {
req.customApiKey = body.customApiKey; // 空字符串 = 不更新,见下方逻辑
}
if (typeof body.customModel === "string") {
req.customModel = body.customModel.trim();
}
if (
typeof body.customMaxTokens === "number" &&
Number.isFinite(body.customMaxTokens)
) {
req.customMaxTokens = body.customMaxTokens;
} else if (body.customMaxTokens === null) {
req.customMaxTokens = null;
}
if (
typeof body.customTemperature === "number" &&
Number.isFinite(body.customTemperature)
) {
req.customTemperature = body.customTemperature;
} else if (body.customTemperature === null) {
req.customTemperature = null;
}
// 没有任何变更时直接返回
if (!hasUserConfigMutation(req)) {
return apiOk(null);
}
try {
const currentRow = await prisma.userConfig.findUnique({
where: { userId },
select: { value: true }
});
const current = normalizeUserConfigValue(currentRow?.value);
const next = mergeUserConfigUpdate(current, req);
const jsonValue = toUserConfigJson(next);
logger.info("user_settings_write_start", {
hasExistingRow: Boolean(currentRow),
useCustomConfig: next.useCustomConfig
});
await writeUserConfigValue(userId, jsonValue);
logger.info("user_settings_updated", {
useCustomConfig: next.useCustomConfig,
hasCustomApiBase: Boolean(next.customApiBase),
hasCustomApiKey: Boolean(next.customApiKey),
hasCustomModel: Boolean(next.customModel),
hasCustomMaxTokens: next.customMaxTokens !== null,
hasCustomTemperature: next.customTemperature !== null
});
return apiOk(null);
} catch (error) {
logger.error("user_settings_update_failed", {
error: toSafeLogError(error)
});
setResponseStatus(event, 500);
return apiErr(500, "服务器内部错误");
}
});
+5 -2
View File
@@ -20,8 +20,11 @@ export default defineEventHandler(async (event) => {
// 只处理 /api/ 路径
if (!pathname.startsWith("/api/")) return;
// OPTIONS 预检请求不需要鉴权CORS headers 由 nuxt.config routeRules 统一设置
if (method === "OPTIONS") return;
// OPTIONS 预检请求不需要鉴权;直接返回 204,避免没有匹配 API 路由时落到 404。
if (method === "OPTIONS") {
setResponseStatus(event, 204);
return "";
}
// 公开路由直接放行
if (isPublicApiRoute(pathname, method)) return;
+1 -1
View File
@@ -17,7 +17,7 @@ const DEFAULT_CONFIGS = [
},
{
key: "openai_model",
value: "gpt-3.5-turbo",
value: "gpt-5.2",
description: "Chat Completions 使用的模型名"
},
{
+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
});