feat: 新增鉴权

This commit is contained in:
2026-05-22 22:38:01 +08:00
parent b02f15f735
commit 3857f77f8b
38 changed files with 1697 additions and 195 deletions
+14
View File
@@ -0,0 +1,14 @@
// server/api/auth/[...all].ts - Better Auth catch-all 路由,处理所有 /api/auth/* 请求
import { toWebRequest } from "h3";
import { auth } from "~~/server/utils/auth";
/**
* 将所有 /api/auth/* 请求转发给 Better Auth handler
*
* toWebRequest 把 Nitro H3Event 转为标准 Web API Request
* Better Auth handler 返回标准 Web API ResponseNitro 会自动将其转发给客户端
*/
export default defineEventHandler((event) => {
return auth.handler(toWebRequest(event));
});
+2 -4
View File
@@ -4,11 +4,10 @@ import { setResponseStatus } from "h3";
import {
invalidAccessTokenResponse,
verifyAccessToken
} from "~~/server/utils/auth";
} from "~~/server/utils/accessToken";
import { answerCache } from "~~/server/utils/cache";
import { serverEnv } from "~~/server/utils/env";
import { createApiLogger } from "~~/server/utils/logging";
import { clearQaRecords } from "~~/server/utils/runtimeState";
/**
* 清空内存缓存
@@ -37,9 +36,8 @@ export default defineEventHandler((event) => {
};
}
// 清空当前进程内缓存和问答记录,不影响运行统计中的 uptime
// 清空当前进程内缓存DB 记录不受影响
answerCache.clear();
clearQaRecords();
logger.info("finish_success");
return {
+20 -27
View File
@@ -1,33 +1,28 @@
// server/api/records.get.ts - 最近问答记录接口,供 Nuxt Dashboard 表格展示
// server/api/records.get.ts - 用户问答记录接口,供 Nuxt Dashboard 表格展示
import { getQuery, setResponseStatus } from "h3";
import {
invalidAccessTokenResponse,
verifyAccessToken
} from "~~/server/utils/auth";
import { getAuthSession } from "~~/server/utils/auth";
import { createApiLogger } from "~~/server/utils/logging";
import { getQaRecords } from "~~/server/utils/runtimeState";
/**
* 最近问答记录接口
*
* 旧 Python Dashboard 直接在服务端模板里读取内存 records;迁移到 Nuxt 后,
* 前端表格需要一个 JSON 数据源,所以这里暴露同样的内存记录副本。
* 用户问答记录接口(分页,最新在前)
*
* 安全边界:
* - 配置 ACCESS_TOKEN 时必须携带 `X-Access-Token` 或 query `token`
* - 这里只返回题目、选项和最终答案,不包含 OpenAI 响应体、API Key 或内部错误
* - 记录仅存在当前进程内,重启或多实例部署不会共享
* - 必须携带有效 session cookie(登录用户)
* - 只返回当前登录用户自己的记录,用 userId 严格隔离
* - 不包含 OpenAI 响应体、API Key 或内部错误
*/
export default defineEventHandler((event) => {
export default defineEventHandler(async (event) => {
const logger = createApiLogger(event, "api.records");
if (!verifyAccessToken(event)) {
setResponseStatus(event, 403);
logger.warn("invalid_access_token");
const session = await getAuthSession(event);
if (!session) {
setResponseStatus(event, 401);
logger.warn("unauthorized");
return {
success: false,
message: invalidAccessTokenResponse().msg,
message: "未登录",
records: [],
page: 1,
size: 10,
@@ -41,25 +36,23 @@ export default defineEventHandler((event) => {
const size = Number.isFinite(rawSize)
? Math.min(Math.max(rawSize, 1), 100)
: 10;
const records = getQaRecords();
const total = records.length;
const maxPage = Math.max(1, Math.ceil(total / size));
const page = Number.isFinite(rawPage)
? Math.min(Math.max(rawPage, 1), maxPage)
: 1;
const start = (page - 1) * size;
const pageRecords = records.slice(start, start + size);
const page = Number.isFinite(rawPage) ? Math.max(rawPage, 1) : 1;
const { records, total } = await getQaRecords(session.user.id, {
page,
size
});
logger.info("finish_success", {
page,
size,
total,
count: pageRecords.length
count: records.length
});
return {
success: true,
records: pageRecords,
records,
page,
size,
total
+46 -16
View File
@@ -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.tokenOCS 油猴脚本)
* - 两种识别方式均无效时返回 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,
+1 -1
View File
@@ -4,7 +4,7 @@ import { setResponseStatus } from "h3";
import {
invalidAccessTokenResponse,
verifyAccessToken
} from "~~/server/utils/auth";
} from "~~/server/utils/accessToken";
import { createApiLogger } from "~~/server/utils/logging";
import { getRuntimeStats } from "~~/server/utils/runtimeState";
+37
View File
@@ -0,0 +1,37 @@
// server/api/user/refresh-token.post.ts - 刷新当前登录用户的 apiToken
// 鉴权后生成新 token 写入数据库,不对外暴露旧 token 或内部错误
import { randomBytes } from "node:crypto";
import { setResponseStatus } from "h3";
import { getAuthSession } from "~~/server/utils/auth";
import { prisma } from "~~/server/utils/db";
import { createApiLogger } from "~~/server/utils/logging";
export default defineEventHandler(async (event) => {
const logger = createApiLogger(event, "api.user.refresh-token");
const session = await getAuthSession(event);
if (!session) {
setResponseStatus(event, 401);
logger.warn("unauthorized");
return { success: false, msg: "未登录" };
}
const userId = session.user.id;
const newToken = randomBytes(16).toString("hex");
try {
await prisma.user.update({
where: { id: userId },
data: { apiToken: newToken }
});
} catch (err) {
logger.error("db_error", { userId, err: String(err) });
setResponseStatus(event, 500);
return { success: false, msg: "服务器内部错误" };
}
logger.info("finish_success", { userId });
return { success: true, msg: "刷新成功", apiToken: newToken };
});