feat: 新增鉴权
This commit is contained in:
@@ -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 Response,Nitro 会自动将其转发给客户端
|
||||
*/
|
||||
export default defineEventHandler((event) => {
|
||||
return auth.handler(toWebRequest(event));
|
||||
});
|
||||
Vendored
+2
-4
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
@@ -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
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user