feat: 新增鉴权
This commit is contained in:
+46
-16
@@ -4,7 +4,8 @@ import {
|
||||
type H3Event,
|
||||
readBody,
|
||||
readFormData,
|
||||
setResponseStatus} from "h3";
|
||||
setResponseStatus
|
||||
} from "h3";
|
||||
|
||||
import {
|
||||
ANSWER_SYSTEM_PROMPT,
|
||||
@@ -14,8 +15,9 @@ import {
|
||||
parseQuestionAndOptions,
|
||||
type SearchParams
|
||||
} from "~~/server/utils/answer";
|
||||
import { invalidAccessTokenResponse, verifyAccessToken } from "~~/server/utils/auth";
|
||||
import { getAuthSession } from "~~/server/utils/auth";
|
||||
import { answerCache } from "~~/server/utils/cache";
|
||||
import { prisma } from "~~/server/utils/db";
|
||||
import { createApiLogger, toSafeLogError } from "~~/server/utils/logging";
|
||||
import { askAnswerStream } from "~~/server/utils/openai";
|
||||
import { addQaRecord } from "~~/server/utils/runtimeState";
|
||||
@@ -36,6 +38,9 @@ const isRecord = (value: unknown): value is Record<string, unknown> => {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
};
|
||||
|
||||
/** SearchParams 扩展,包含 OCS 脚本通过 body 传入的 apiToken */
|
||||
type SearchParamsWithToken = SearchParams & { token?: string };
|
||||
|
||||
/**
|
||||
* 兼容旧 Python 服务的三种入参方式
|
||||
*
|
||||
@@ -48,7 +53,7 @@ const isRecord = (value: unknown): value is Record<string, unknown> => {
|
||||
*/
|
||||
const readSearchParams = async (
|
||||
event: H3Event
|
||||
): Promise<SearchParams | "invalid_body"> => {
|
||||
): Promise<SearchParamsWithToken | "invalid_body"> => {
|
||||
const method = event.node.req.method?.toUpperCase() || "GET";
|
||||
|
||||
if (method === "GET") {
|
||||
@@ -57,7 +62,8 @@ const readSearchParams = async (
|
||||
return {
|
||||
title: toStringValue(query.title).trim(),
|
||||
type: toStringValue(query.type).trim(),
|
||||
options: toStringValue(query.options).trim()
|
||||
options: toStringValue(query.options).trim(),
|
||||
token: toStringValue(query.token).trim() || undefined
|
||||
};
|
||||
}
|
||||
|
||||
@@ -89,7 +95,8 @@ const readSearchParams = async (
|
||||
return {
|
||||
title: toStringValue(body.title).trim(),
|
||||
type: toStringValue(body.type).trim(),
|
||||
options: toStringValue(body.options).trim()
|
||||
options: toStringValue(body.options).trim(),
|
||||
token: toStringValue(body.token).trim() || undefined
|
||||
};
|
||||
};
|
||||
|
||||
@@ -97,15 +104,18 @@ const readSearchParams = async (
|
||||
* OCS 答题搜索主接口
|
||||
*
|
||||
* 流程:
|
||||
* - 校验请求方法和可选访问令牌
|
||||
* - 校验请求方法
|
||||
* - 鉴权:优先读 session(浏览器登录),无 session 则读 body.token(OCS 油猴脚本)
|
||||
* - 两种识别方式均无效时返回 401
|
||||
* - 读取题目、题型、选项,兼容 GET/JSON/form
|
||||
* - 先查内存缓存,命中后不再请求 OpenAI
|
||||
* - 未命中时拼提示词,服务端流式请求 Chat Completions
|
||||
* - 清洗答案、写入缓存和最近问答记录,最后返回 OCS 兼容 JSON
|
||||
* - 清洗答案、写入缓存和 DB 记录,最后返回 OCS 兼容 JSON
|
||||
*
|
||||
* 安全边界:
|
||||
* - 不向前端返回 OpenAI 错误、响应体、API Key 或内部堆栈
|
||||
* - 日志只记录长度、阶段、耗时等摘要,不记录完整题目和完整 prompt
|
||||
* - apiToken 不进日志
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const logger = createApiLogger(event, "api.search");
|
||||
@@ -118,13 +128,6 @@ export default defineEventHandler(async (event) => {
|
||||
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);
|
||||
@@ -133,6 +136,27 @@ export default defineEventHandler(async (event) => {
|
||||
return createOcsErrorResponse("请求体必须是 JSON 对象");
|
||||
}
|
||||
|
||||
// 鉴权:优先 session(浏览器登录),无 session 则用 body.token 查 apiToken
|
||||
// OCS 油猴脚本跨域无法携带 cookie,需在 body 中传入用户自己的 apiToken
|
||||
let userId: string | null = null;
|
||||
|
||||
const session = await getAuthSession(event);
|
||||
if (session) {
|
||||
userId = session.user.id;
|
||||
} else if (params.token) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { apiToken: params.token },
|
||||
select: { id: true }
|
||||
});
|
||||
userId = user?.id ?? null;
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
setResponseStatus(event, 401);
|
||||
logger.warn("unauthorized");
|
||||
return createOcsErrorResponse("请先登录或提供有效 token");
|
||||
}
|
||||
|
||||
logger.info("read_question", {
|
||||
questionLength: params.title.length,
|
||||
type: params.type,
|
||||
@@ -168,8 +192,14 @@ export default defineEventHandler(async (event) => {
|
||||
const processedAnswer = extractAnswer(streamResult.answer, params.type);
|
||||
|
||||
// 先缓存再记录;这两步失败风险很低,且都是内存操作,不会阻塞主链路
|
||||
answerCache?.set(params.title, processedAnswer, params.type, params.options);
|
||||
addQaRecord({
|
||||
answerCache?.set(
|
||||
params.title,
|
||||
processedAnswer,
|
||||
params.type,
|
||||
params.options
|
||||
);
|
||||
await addQaRecord({
|
||||
userId,
|
||||
question: params.title,
|
||||
type: params.type,
|
||||
options: params.options,
|
||||
|
||||
Reference in New Issue
Block a user