feat: 前端组件准备
This commit is contained in:
Vendored
+43
@@ -0,0 +1,43 @@
|
||||
// server/api/cache/clear.post.ts - 清空答题缓存接口
|
||||
import { setResponseStatus } from "h3";
|
||||
import { invalidAccessTokenResponse, verifyAccessToken } from "~~/server/utils/auth";
|
||||
import { answerCache } from "~~/server/utils/cache";
|
||||
import { serverEnv } from "~~/server/utils/env";
|
||||
import { createApiLogger } from "~~/server/utils/logging";
|
||||
|
||||
/**
|
||||
* 清空内存缓存
|
||||
*
|
||||
* 这是一个管理类接口,配置 ACCESS_TOKEN 时必须携带正确 token
|
||||
* 清理的是当前 Nuxt 进程内缓存;多实例部署时,每个实例都有自己的内存缓存
|
||||
*/
|
||||
export default defineEventHandler((event) => {
|
||||
const logger = createApiLogger(event, "api.cache.clear");
|
||||
|
||||
// 没配置 token 时保持旧服务的开放行为;配置后用于简单保护管理接口
|
||||
if (!verifyAccessToken(event)) {
|
||||
setResponseStatus(event, 403);
|
||||
logger.warn("invalid_access_token");
|
||||
return {
|
||||
success: false,
|
||||
message: invalidAccessTokenResponse().msg
|
||||
};
|
||||
}
|
||||
|
||||
// 缓存被关闭时不报错,只明确告诉调用方当前没有缓存可清
|
||||
if (!serverEnv.enableCache || !answerCache) {
|
||||
return {
|
||||
success: false,
|
||||
message: "缓存未启用"
|
||||
};
|
||||
}
|
||||
|
||||
// 清空当前进程内缓存,不影响问答记录和运行统计中的 uptime
|
||||
answerCache.clear();
|
||||
logger.info("finish_success");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "缓存已清除"
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
// server/api/health.get.ts - 服务健康检查接口
|
||||
import { serverEnv } from "~~/server/utils/env";
|
||||
import { SERVICE_VERSION } from "~~/server/utils/runtimeState";
|
||||
|
||||
/**
|
||||
* 健康检查不需要鉴权
|
||||
*
|
||||
* 这个接口用于部署平台、Docker healthcheck 或人工确认服务是否启动;
|
||||
* 返回模型名和缓存开关,但不返回 API Key、baseURL 或其他敏感配置
|
||||
*/
|
||||
export default defineEventHandler(() => {
|
||||
return {
|
||||
status: "ok",
|
||||
message: "AI题库服务运行正常",
|
||||
version: SERVICE_VERSION,
|
||||
cache_enabled: serverEnv.enableCache,
|
||||
model: serverEnv.openAiModel
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
// server/api/search.ts - OCS AnswererWrapper 兼容搜索接口
|
||||
import {
|
||||
getQuery,
|
||||
type H3Event,
|
||||
readBody,
|
||||
readFormData,
|
||||
setResponseStatus} from "h3";
|
||||
|
||||
import {
|
||||
ANSWER_SYSTEM_PROMPT,
|
||||
createOcsErrorResponse,
|
||||
extractAnswer,
|
||||
formatAnswerForOcs,
|
||||
parseQuestionAndOptions,
|
||||
type SearchParams
|
||||
} from "~~/server/utils/answer";
|
||||
import { invalidAccessTokenResponse, verifyAccessToken } from "~~/server/utils/auth";
|
||||
import { answerCache } from "~~/server/utils/cache";
|
||||
import { createApiLogger, toSafeLogError } from "~~/server/utils/logging";
|
||||
import { askAnswerStream } from "~~/server/utils/openai";
|
||||
import { addQaRecord } from "~~/server/utils/runtimeState";
|
||||
|
||||
/**
|
||||
* 把 query/body/form 中的值统一转成字符串
|
||||
*
|
||||
* OCS 传参通常是普通字符串,但 query 可能出现同名参数数组;
|
||||
* 为了兼容旧 Flask 服务,这里取第一个值并把缺失值转为空字符串
|
||||
*/
|
||||
const toStringValue = (value: unknown) => {
|
||||
if (Array.isArray(value)) return value[0]?.toString() || "";
|
||||
return value?.toString() || "";
|
||||
};
|
||||
|
||||
/** 判断 JSON body 是否是普通对象;数组和字符串都不是本接口接受的 body */
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> => {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
};
|
||||
|
||||
/**
|
||||
* 兼容旧 Python 服务的三种入参方式
|
||||
*
|
||||
* 1. GET:从 query 读取 `title/type/options`
|
||||
* 2. multipart/form-data:从 FormData 读取
|
||||
* 3. JSON 或 urlencoded POST:通过 `readBody` 读取对象
|
||||
*
|
||||
* 返回 `"invalid_body"` 时,说明客户端传了数组、字符串等不合法 body,
|
||||
* handler 会按安全规范返回 400 和本地错误文案
|
||||
*/
|
||||
const readSearchParams = async (
|
||||
event: H3Event
|
||||
): Promise<SearchParams | "invalid_body"> => {
|
||||
const method = event.node.req.method?.toUpperCase() || "GET";
|
||||
|
||||
if (method === "GET") {
|
||||
const query = getQuery(event);
|
||||
|
||||
return {
|
||||
title: toStringValue(query.title).trim(),
|
||||
type: toStringValue(query.type).trim(),
|
||||
options: toStringValue(query.options).trim()
|
||||
};
|
||||
}
|
||||
|
||||
const contentType = event.node.req.headers["content-type"] || "";
|
||||
|
||||
if (contentType.includes("multipart/form-data")) {
|
||||
const form = await readFormData(event);
|
||||
|
||||
return {
|
||||
title: toStringValue(form.get("title")).trim(),
|
||||
type: toStringValue(form.get("type")).trim(),
|
||||
options: toStringValue(form.get("options")).trim()
|
||||
};
|
||||
}
|
||||
|
||||
const body = await readBody<unknown>(event).catch(() => null);
|
||||
if (body === null || body === undefined) {
|
||||
return {
|
||||
title: "",
|
||||
type: "",
|
||||
options: ""
|
||||
};
|
||||
}
|
||||
|
||||
if (!isRecord(body)) {
|
||||
return "invalid_body";
|
||||
}
|
||||
|
||||
return {
|
||||
title: toStringValue(body.title).trim(),
|
||||
type: toStringValue(body.type).trim(),
|
||||
options: toStringValue(body.options).trim()
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* OCS 答题搜索主接口
|
||||
*
|
||||
* 流程:
|
||||
* - 校验请求方法和可选访问令牌
|
||||
* - 读取题目、题型、选项,兼容 GET/JSON/form
|
||||
* - 先查内存缓存,命中后不再请求 OpenAI
|
||||
* - 未命中时拼提示词,服务端流式请求 Chat Completions
|
||||
* - 清洗答案、写入缓存和最近问答记录,最后返回 OCS 兼容 JSON
|
||||
*
|
||||
* 安全边界:
|
||||
* - 不向前端返回 OpenAI 错误、响应体、API Key 或内部堆栈
|
||||
* - 日志只记录长度、阶段、耗时等摘要,不记录完整题目和完整 prompt
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const logger = createApiLogger(event, "api.search");
|
||||
const method = event.node.req.method?.toUpperCase() || "GET";
|
||||
const startedAt = Date.now();
|
||||
|
||||
// Nitro 文件路由会匹配所有方法;这里显式限制,保持接口行为清楚
|
||||
if (!["GET", "POST"].includes(method)) {
|
||||
setResponseStatus(event, 405);
|
||||
return createOcsErrorResponse("请求方法不支持");
|
||||
}
|
||||
|
||||
// ACCESS_TOKEN 未配置时直接放行;配置后要求 header 或 query token 命中
|
||||
if (!verifyAccessToken(event)) {
|
||||
setResponseStatus(event, 403);
|
||||
logger.warn("invalid_access_token");
|
||||
return invalidAccessTokenResponse();
|
||||
}
|
||||
|
||||
try {
|
||||
// 读取并标准化 OCS 参数,避免后续逻辑关心请求来源
|
||||
const params = await readSearchParams(event);
|
||||
if (params === "invalid_body") {
|
||||
setResponseStatus(event, 400);
|
||||
return createOcsErrorResponse("请求体必须是 JSON 对象");
|
||||
}
|
||||
|
||||
logger.info("read_question", {
|
||||
questionLength: params.title.length,
|
||||
type: params.type,
|
||||
hasOptions: Boolean(params.options)
|
||||
});
|
||||
|
||||
if (!params.title) {
|
||||
return createOcsErrorResponse("未提供问题内容");
|
||||
}
|
||||
|
||||
// 缓存 key 包含题目、题型和选项;同题不同选项不能共用答案
|
||||
const cachedAnswer = answerCache?.get(
|
||||
params.title,
|
||||
params.type,
|
||||
params.options
|
||||
);
|
||||
|
||||
if (cachedAnswer) {
|
||||
logger.info("cache_hit");
|
||||
return formatAnswerForOcs(params.title, cachedAnswer);
|
||||
}
|
||||
|
||||
// 构造和旧 Python 服务一致的提示词,再通过服务端流式请求模型
|
||||
const prompt = parseQuestionAndOptions(
|
||||
params.title,
|
||||
params.options,
|
||||
params.type
|
||||
);
|
||||
const streamResult = await askAnswerStream({
|
||||
prompt,
|
||||
systemPrompt: ANSWER_SYSTEM_PROMPT
|
||||
});
|
||||
const processedAnswer = extractAnswer(streamResult.answer, params.type);
|
||||
|
||||
// 先缓存再记录;这两步失败风险很低,且都是内存操作,不会阻塞主链路
|
||||
answerCache?.set(params.title, processedAnswer, params.type, params.options);
|
||||
addQaRecord({
|
||||
question: params.title,
|
||||
type: params.type,
|
||||
options: params.options,
|
||||
answer: processedAnswer
|
||||
});
|
||||
|
||||
logger.info("finish_success", {
|
||||
durationMs: Date.now() - startedAt,
|
||||
chunkCount: streamResult.upstreamResponse.chunkCount,
|
||||
answerLength: processedAnswer.length
|
||||
});
|
||||
|
||||
return formatAnswerForOcs(params.title, processedAnswer);
|
||||
} catch (error) {
|
||||
// 按安全优先策略:详细错误只进服务端日志,OCS 端只看到本地通用文案
|
||||
logger.error("finish_error", {
|
||||
error: toSafeLogError(error)
|
||||
});
|
||||
|
||||
return createOcsErrorResponse("服务器内部错误");
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
// server/api/stats.get.ts - 服务运行统计接口
|
||||
import { setResponseStatus } from "h3";
|
||||
|
||||
import {
|
||||
invalidAccessTokenResponse,
|
||||
verifyAccessToken
|
||||
} from "~~/server/utils/auth";
|
||||
import { createApiLogger } from "~~/server/utils/logging";
|
||||
import { getRuntimeStats } from "~~/server/utils/runtimeState";
|
||||
|
||||
/**
|
||||
* 运行统计接口
|
||||
*
|
||||
* 统计信息包含 uptime、模型名、缓存数量、最近问答记录数量
|
||||
* 因为它能反映服务使用情况,所以和旧 Python 服务一样走可选 ACCESS_TOKEN 校验
|
||||
*/
|
||||
export default defineEventHandler((event) => {
|
||||
const logger = createApiLogger(event, "api.stats");
|
||||
|
||||
// 未配置 ACCESS_TOKEN 时 verifyAccessToken 会放行;配置后必须携带正确 token
|
||||
if (!verifyAccessToken(event)) {
|
||||
setResponseStatus(event, 403);
|
||||
logger.warn("invalid_access_token");
|
||||
return {
|
||||
success: false,
|
||||
message: invalidAccessTokenResponse().msg
|
||||
};
|
||||
}
|
||||
|
||||
// getRuntimeStats 会实时清理过期缓存,再返回有效缓存数量
|
||||
logger.info("finish_success");
|
||||
return getRuntimeStats();
|
||||
});
|
||||
@@ -1,6 +0,0 @@
|
||||
export default defineEventHandler(async () => {
|
||||
return prisma.user.findMany({
|
||||
include: { posts: true },
|
||||
orderBy: { id: "desc" },
|
||||
});
|
||||
});
|
||||
@@ -1,10 +0,0 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<{ email: string; name?: string | null }>(event);
|
||||
|
||||
return prisma.user.create({
|
||||
data: {
|
||||
email: body.email,
|
||||
name: body.name,
|
||||
},
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user