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
+42
View File
@@ -0,0 +1,42 @@
// server/utils/accessToken.ts - OCS API 可选访问令牌校验(用于 stats/cache 等管理接口)
import { getQuery, type H3Event } from "h3";
import { serverEnv } from "~~/server/utils/env";
/**
* H3 的 query 值可能是字符串、数组或 undefined
* 访问令牌只接受第一个值,和多数 Web 框架读取 query 的行为保持一致
*/
const firstQueryValue = (value: unknown) => {
if (Array.isArray(value)) return value[0]?.toString() || "";
return value?.toString() || "";
};
/**
* 校验可选访问令牌
*
* - 没配置 `ACCESS_TOKEN` 时,服务保持旧 Python 项目的开放行为
* - 配置后,兼容两种传递方式:`X-Access-Token` 请求头或 `?token=...`
*/
export const verifyAccessToken = (event: H3Event) => {
if (!serverEnv.accessToken) return true;
const headerToken = event.node.req.headers["x-access-token"]?.toString();
const queryToken = firstQueryValue(getQuery(event).token);
return (
headerToken === serverEnv.accessToken ||
queryToken === serverEnv.accessToken
);
};
/**
* OCS 搜索接口使用 `{ code, msg }`,而 stats/cache 使用 `{ success, message }`
* 这里先返回最基础的 OCS 形状,其他接口复用其中的 msg 文案
*/
export const invalidAccessTokenResponse = () => {
return {
code: 0,
msg: "无效的访问令牌"
};
};
+57 -32
View File
@@ -1,42 +1,67 @@
// server/utils/auth.ts - OCS API 可选访问令牌校验
import { getQuery, type H3Event } from "h3";
// server/utils/auth.ts - Better Auth 服务端实例,邮箱密码登录 + 用户 API token
import { randomBytes } from "node:crypto";
import { serverEnv } from "~~/server/utils/env";
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";
/**
* H3 的 query 值可能是字符串、数组或 undefined
* 访问令牌只接受第一个值,和多数 Web 框架读取 query 的行为保持一致
*/
const firstQueryValue = (value: unknown) => {
if (Array.isArray(value)) return value[0]?.toString() || "";
return value?.toString() || "";
};
/**
* 校验可选访问令牌
* Better Auth 全局实例
*
* - 没配置 `ACCESS_TOKEN` 时,服务保持旧 Python 项目的开放行为
* - 配置后,兼容两种传递方式:`X-Access-Token` 请求头或 `?token=...`
* - 使用 Prisma MySQL/MariaDB 适配器
* - 只启用邮箱/密码登录,无邮箱验证和密码重置(保持最简)
* - 注册时通过 databaseHook 自动生成 32 位 hex apiToken
* apiToken 供 OCS 油猴脚本跨域调用 /api/search 时在 body 中携带
* - apiToken 不允许客户端直接设置(input: false),但随 session 返回给登录用户
*/
export const verifyAccessToken = (event: H3Event) => {
if (!serverEnv.accessToken) return true;
export const auth = betterAuth({
database: prismaAdapter(prisma, { provider: "mysql" }),
const headerToken = event.node.req.headers["x-access-token"]?.toString();
const queryToken = firstQueryValue(getQuery(event).token);
session: {
expiresIn: 60 * 60 * 24 * 14, // 14 天过期
updateAge: 60 * 60 * 24 // 每天自动续期
},
return (
headerToken === serverEnv.accessToken ||
queryToken === serverEnv.accessToken
);
};
emailAndPassword: {
enabled: true
},
user: {
additionalFields: {
apiToken: {
type: "string",
required: false,
// 客户端(signup/updateUser)不能设置此字段,只由 databaseHooks 写入
input: false
}
}
},
databaseHooks: {
user: {
create: {
// 注册时自动生成 apiToken,格式与旧 ACCESS_TOKEN 字段一致(32 位 hex
before: async (user) => {
return {
data: {
...user,
apiToken: randomBytes(16).toString("hex")
}
};
}
}
}
}
});
/**
* OCS 搜索接口使用 `{ code, msg }`,而 stats/cache 使用 `{ success, message }`
* 这里先返回最基础的 OCS 形状,其他接口复用其中的 msg 文案
* 从 H3 事件中读取 Better Auth session
*
* 封装 toWebRequest 转换,避免在每个 handler 中重复导入和调用
* 未登录或 session 无效时返回 null
*/
export const invalidAccessTokenResponse = () => {
return {
code: 0,
msg: "无效的访问令牌"
};
};
export const getAuthSession = (event: H3Event) =>
auth.api.getSession({ headers: toWebRequest(event).headers });
+52 -47
View File
@@ -1,35 +1,16 @@
// server/utils/runtimeState.ts - 服务启动时间和最近问答记录的内存状态
// server/utils/runtimeState.ts - 服务启动时间内存状态 + 问答记录 DB 读写
import { answerCache } from "~~/server/utils/cache";
import { prisma } from "~~/server/utils/db";
import { serverEnv } from "~~/server/utils/env";
/** 最近问答记录,沿用旧 Python 服务 dashboard/stats 的内存记录语义 */
export interface QaRecord {
/** 本地可读时间,方便后续如果恢复 dashboard 时直接展示 */
time: string;
/** ISO 时间,方便机器处理和排序 */
timestamp: string;
/** 题目正文 */
question: string;
/** 题型 */
type: string;
/** 选项文本 */
options: string;
/** 最终返回给 OCS 的答案 */
answer: string;
}
/** 只保留最近 100 条,避免长时间运行后内存无限增长 */
const MAX_RECORDS = 100;
/** 进程启动时间,用于 `/api/stats` 返回 uptime */
const startTime = Date.now();
/** 进程内问答记录;服务重启或多实例部署时不会共享 */
const qaRecords: QaRecord[] = [];
/** 对外展示的服务版本,和 README/API 文档保持一致 */
export const SERVICE_VERSION = "1.1.0";
/** 格式化成本地 `YYYY-MM-DD HH:mm:ss`,对齐旧 Python 服务记录格式 */
const formatLocalDateTime = (date: Date) => {
export const formatLocalDateTime = (date: Date) => {
const pad = (value: number) => value.toString().padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(
@@ -40,35 +21,60 @@ const formatLocalDateTime = (date: Date) => {
};
/**
* 追加一条问答记录
*
* 记录只用于统计和未来可能恢复的 dashboard,不作为题库持久化数据
* 向数据库写入一条问答记录
*/
export const addQaRecord = (record: Omit<QaRecord, "time" | "timestamp">) => {
const now = new Date();
qaRecords.push({
time: formatLocalDateTime(now),
timestamp: now.toISOString(),
...record
export const addQaRecord = async (record: {
userId: string;
question: string;
type: string;
options: string;
answer: string;
}) => {
await prisma.qaRecord.create({
data: {
userId: record.userId,
question: record.question,
type: record.type,
options: record.options || null,
answer: record.answer || null
}
});
if (qaRecords.length > MAX_RECORDS) {
qaRecords.shift();
}
};
/**
* 获取最近问答记录
*
* 返回副本并按“最新在前”排序,避免 API handler 或前端展示逻辑误改内存原数组
* 从数据库分页读取指定用户的问答记录,按创建时间倒序
*/
export const getQaRecords = () => {
return [...qaRecords].reverse();
};
/** 清空进程内问答记录,通常和 answerCache.clear() 一起调用 */
export const clearQaRecords = () => {
qaRecords.length = 0;
export const getQaRecords = async (
userId: string,
options: { page: number; size: number }
) => {
const { page, size } = options;
const skip = (page - 1) * size;
const [total, rows] = await Promise.all([
prisma.qaRecord.count({ where: { userId } }),
prisma.qaRecord.findMany({
where: { userId },
orderBy: { createdAt: "desc" },
skip,
take: size,
select: {
question: true,
type: true,
options: true,
answer: true,
createdAt: true
}
})
]);
const records = rows.map((r) => ({
time: formatLocalDateTime(r.createdAt),
timestamp: r.createdAt.toISOString(),
question: r.question,
type: r.type,
options: r.options ?? "",
answer: r.answer ?? ""
}));
return { records, total };
};
/** 生成 `/api/stats` 响应,实时计算 uptime 和有效缓存数量 */
export const getRuntimeStats = () => {
@@ -77,7 +83,6 @@ export const getRuntimeStats = () => {
uptime: (Date.now() - startTime) / 1000,
model: serverEnv.openAiModel,
cache_enabled: serverEnv.enableCache,
cache_size: answerCache?.size() ?? 0,
qa_records_count: qaRecords.length
cache_size: answerCache?.size() ?? 0
};
};