feat: 大量优化和bug修复
This commit is contained in:
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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, "服务器内部错误");
|
||||
}
|
||||
});
|
||||
@@ -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" };
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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, "服务器内部错误");
|
||||
}
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
// server/api/user/settings.post.ts - 兼容 POST 保存个人配置,复用 PUT 实现
|
||||
export { default } from "./settings.put";
|
||||
@@ -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, "服务器内部错误");
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user