feat: 添加分页功能和缓存管理,优化问答记录展示
This commit is contained in:
Vendored
+11
-16
@@ -1,27 +1,22 @@
|
||||
// server/api/cache/clear.post.ts - 清空答题缓存接口
|
||||
import { answerCache } from "~~/server/utils/cache";
|
||||
import { serverEnv } from "~~/server/utils/env";
|
||||
// server/api/cache/clear.post.ts - 清空当前用户 DB 缓存接口
|
||||
import { prisma } from "~~/server/utils/db";
|
||||
import { createApiLogger } from "~~/server/utils/logging";
|
||||
|
||||
/**
|
||||
* 清空内存缓存
|
||||
* 清缓存:将当前用户的 cacheClearedAt 更新为当前时间
|
||||
*
|
||||
* 这是一个管理类接口,必须携带有效 session cookie(由 api-auth middleware 统一鉴权)
|
||||
* 清理的是当前 Nuxt 进程内缓存;多实例部署时,每个实例都有自己的内存缓存
|
||||
* 之后查 DB 缓存时只命中该时间之后写入的记录,历史记录全部保留。
|
||||
* 必须携带有效 session cookie(由 api-auth middleware 统一鉴权)
|
||||
*/
|
||||
export default defineEventHandler((event) => {
|
||||
export default defineEventHandler(async (event) => {
|
||||
const logger = createApiLogger(event, "api.cache.clear");
|
||||
const userId = event.context.auth!.user.id;
|
||||
|
||||
// 缓存被关闭时不报错,只明确告诉调用方当前没有缓存可清
|
||||
if (!serverEnv.enableCache || !answerCache) {
|
||||
return {
|
||||
success: false,
|
||||
message: "缓存未启用"
|
||||
};
|
||||
}
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { cacheClearedAt: new Date() }
|
||||
});
|
||||
|
||||
// 清空当前进程内缓存,DB 记录不受影响
|
||||
answerCache.clear();
|
||||
logger.info("finish_success");
|
||||
|
||||
return {
|
||||
|
||||
@@ -6,14 +6,13 @@ import { SERVICE_VERSION } from "~~/server/utils/runtimeState";
|
||||
* 健康检查不需要鉴权
|
||||
*
|
||||
* 这个接口用于部署平台、Docker healthcheck 或人工确认服务是否启动;
|
||||
* 返回模型名和缓存开关,但不返回 API Key、baseURL 或其他敏感配置
|
||||
* 返回模型名,但不返回 API Key、baseURL 或其他敏感配置
|
||||
*/
|
||||
export default defineEventHandler(() => {
|
||||
return {
|
||||
status: "ok",
|
||||
message: "AI题库服务运行正常",
|
||||
version: SERVICE_VERSION,
|
||||
cache_enabled: serverEnv.enableCache,
|
||||
model: serverEnv.openAiModel
|
||||
};
|
||||
});
|
||||
|
||||
@@ -18,9 +18,9 @@ export default defineEventHandler(async (event) => {
|
||||
const query = getQuery(event);
|
||||
const rawPage = Number.parseInt(query.page?.toString() || "1", 10);
|
||||
const rawSize = Number.parseInt(query.size?.toString() || "10", 10);
|
||||
const size = Number.isFinite(rawSize)
|
||||
? Math.min(Math.max(rawSize, 1), 100)
|
||||
: 10;
|
||||
// size 只允许 10 / 50 / 100;传入其他值(含超出上限)一律退回 10
|
||||
const ALLOWED_SIZES = new Set([10, 50, 100]);
|
||||
const size = ALLOWED_SIZES.has(rawSize) ? rawSize : 10;
|
||||
const page = Number.isFinite(rawPage) ? Math.max(rawPage, 1) : 1;
|
||||
|
||||
const { records, total } = await getQaRecords(event.context.auth!.user.id, {
|
||||
|
||||
+21
-17
@@ -1,4 +1,6 @@
|
||||
// server/api/search.ts - OCS AnswererWrapper 兼容搜索接口
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import {
|
||||
getQuery,
|
||||
type H3Event,
|
||||
@@ -16,11 +18,10 @@ import {
|
||||
type SearchParams
|
||||
} from "~~/server/utils/answer";
|
||||
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";
|
||||
import { addQaRecord, lookupCachedAnswer } from "~~/server/utils/runtimeState";
|
||||
|
||||
/**
|
||||
* 把 query/body/form 中的值统一转成字符串
|
||||
@@ -138,17 +139,25 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
// 鉴权:优先 session(浏览器登录),无 session 则用 body.token 查 apiToken
|
||||
// OCS 油猴脚本跨域无法携带 cookie,需在 body 中传入用户自己的 apiToken
|
||||
// 同时取出 cacheClearedAt,用于 DB 缓存过滤
|
||||
let userId: string | null = null;
|
||||
let cacheClearedAt: Date | null = null;
|
||||
|
||||
const session = await getAuthSession(event);
|
||||
if (session) {
|
||||
userId = session.user.id;
|
||||
const userData = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { cacheClearedAt: true }
|
||||
});
|
||||
cacheClearedAt = userData?.cacheClearedAt ?? null;
|
||||
} else if (params.token) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { apiToken: params.token },
|
||||
select: { id: true }
|
||||
select: { id: true, cacheClearedAt: true }
|
||||
});
|
||||
userId = user?.id ?? null;
|
||||
cacheClearedAt = user?.cacheClearedAt ?? null;
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
@@ -167,12 +176,12 @@ export default defineEventHandler(async (event) => {
|
||||
return createOcsErrorResponse("未提供问题内容");
|
||||
}
|
||||
|
||||
// 缓存 key 包含题目、题型和选项;同题不同选项不能共用答案
|
||||
const cachedAnswer = answerCache?.get(
|
||||
params.title,
|
||||
params.type,
|
||||
params.options
|
||||
);
|
||||
// 计算缓存 key:题目+题型+选项的 MD5;同题不同选项不能共用答案
|
||||
const hash = createHash("md5")
|
||||
.update(`${params.title}|${params.type}|${params.options}`, "utf8")
|
||||
.digest("hex");
|
||||
|
||||
const cachedAnswer = await lookupCachedAnswer(userId, hash, cacheClearedAt);
|
||||
|
||||
if (cachedAnswer) {
|
||||
logger.info("cache_hit");
|
||||
@@ -191,19 +200,14 @@ export default defineEventHandler(async (event) => {
|
||||
});
|
||||
const processedAnswer = extractAnswer(streamResult.answer, params.type);
|
||||
|
||||
// 先缓存再记录;这两步失败风险很低,且都是内存操作,不会阻塞主链路
|
||||
answerCache?.set(
|
||||
params.title,
|
||||
processedAnswer,
|
||||
params.type,
|
||||
params.options
|
||||
);
|
||||
// 写入 DB 记录(带 hash);记录本身即为后续缓存查询的来源
|
||||
await addQaRecord({
|
||||
userId,
|
||||
question: params.title,
|
||||
type: params.type,
|
||||
options: params.options,
|
||||
answer: processedAnswer
|
||||
answer: processedAnswer,
|
||||
hash
|
||||
});
|
||||
|
||||
logger.info("finish_success", {
|
||||
|
||||
+11
-4
@@ -1,16 +1,23 @@
|
||||
// server/api/stats.get.ts - 服务运行统计接口
|
||||
import { prisma } from "~~/server/utils/db";
|
||||
import { createApiLogger } from "~~/server/utils/logging";
|
||||
import { getRuntimeStats } from "~~/server/utils/runtimeState";
|
||||
|
||||
/**
|
||||
* 运行统计接口
|
||||
*
|
||||
* 统计信息包含 uptime、模型名、缓存数量、最近问答记录数量
|
||||
* 返回进程 uptime、模型名以及当前用户的问答记录总数
|
||||
* 必须携带有效 session cookie(由 api-auth middleware 统一鉴权)
|
||||
*/
|
||||
export default defineEventHandler((event) => {
|
||||
export default defineEventHandler(async (event) => {
|
||||
const logger = createApiLogger(event, "api.stats");
|
||||
// getRuntimeStats 会实时清理过期缓存,再返回有效缓存数量
|
||||
const userId = event.context.auth!.user.id;
|
||||
|
||||
const [runtimeStats, qa_records_count] = await Promise.all([
|
||||
Promise.resolve(getRuntimeStats()),
|
||||
prisma.qaRecord.count({ where: { userId } })
|
||||
]);
|
||||
|
||||
logger.info("finish_success");
|
||||
return getRuntimeStats();
|
||||
return { ...runtimeStats, qa_records_count };
|
||||
});
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
// server/utils/cache.ts - 简单内存缓存,按题目、题型和选项生成缓存键
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { serverEnv } from "~~/server/utils/env";
|
||||
|
||||
/**
|
||||
* 旧 Python 服务使用进程内字典缓存答案
|
||||
*
|
||||
* Nuxt 版保持同样的“内存缓存”语义:服务重启后缓存清空,不跨实例共享;
|
||||
* 这对个人题库服务足够简单,也避免为了缓存引入数据库或 Redis
|
||||
*/
|
||||
export class SimpleCache {
|
||||
/** Map key 是题目、题型和选项计算出的 md5;value 保存写入时间和答案 */
|
||||
private readonly cache = new Map<
|
||||
string,
|
||||
{
|
||||
timestamp: number;
|
||||
value: string;
|
||||
}
|
||||
>();
|
||||
|
||||
constructor(private readonly expirationSeconds: number) {}
|
||||
|
||||
/**
|
||||
* 根据题目、题型和选项生成缓存键
|
||||
*
|
||||
* 同一道题如果选项不同,不能复用旧答案,所以三个字段都参与计算
|
||||
*/
|
||||
private generateKey(question: string, questionType = "", options = "") {
|
||||
return createHash("md5")
|
||||
.update(`${question}|${questionType}|${options}`, "utf8")
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取缓存答案
|
||||
*
|
||||
* 命中过期项时会顺手删除,避免长时间运行后堆积无效缓存
|
||||
*/
|
||||
get(question: string, questionType = "", options = "") {
|
||||
const key = this.generateKey(question, questionType, options);
|
||||
const item = this.cache.get(key);
|
||||
if (!item) return undefined;
|
||||
|
||||
if (Date.now() - item.timestamp < this.expirationSeconds * 1000) {
|
||||
return item.value;
|
||||
}
|
||||
|
||||
this.cache.delete(key);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** 写入答案缓存,时间戳使用当前进程时间即可 */
|
||||
set(question: string, answer: string, questionType = "", options = "") {
|
||||
const key = this.generateKey(question, questionType, options);
|
||||
this.cache.set(key, {
|
||||
timestamp: Date.now(),
|
||||
value: answer
|
||||
});
|
||||
}
|
||||
|
||||
/** 清空全部缓存,对应 `/api/cache/clear` */
|
||||
clear() {
|
||||
this.cache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量移除过期项
|
||||
*
|
||||
* 目前在统计缓存大小时调用,避免 stats 返回已经过期的数量
|
||||
*/
|
||||
removeExpired() {
|
||||
const now = Date.now();
|
||||
let removedCount = 0;
|
||||
|
||||
for (const [key, item] of this.cache.entries()) {
|
||||
if (now - item.timestamp >= this.expirationSeconds * 1000) {
|
||||
this.cache.delete(key);
|
||||
removedCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return removedCount;
|
||||
}
|
||||
|
||||
/** 获取当前有效缓存数量 */
|
||||
size() {
|
||||
this.removeExpired();
|
||||
return this.cache.size;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局答案缓存实例
|
||||
*
|
||||
* 如果 `ENABLE_CACHE=false`,导出 undefined,调用处用可选链即可保持逻辑简洁
|
||||
*/
|
||||
export const answerCache = serverEnv.enableCache
|
||||
? new SimpleCache(serverEnv.cacheExpiration)
|
||||
: undefined;
|
||||
@@ -73,10 +73,6 @@ export const serverEnv = {
|
||||
maxTokens: readInteger("MAX_TOKENS", 500),
|
||||
/** 模型采样温度,越低越稳定 */
|
||||
temperature: readNumber("TEMPERATURE", 0.7),
|
||||
/** 是否启用内存缓存 */
|
||||
enableCache: readBoolean("ENABLE_CACHE", true),
|
||||
/** 缓存过期时间,单位秒 */
|
||||
cacheExpiration: readInteger("CACHE_EXPIRATION", 86_400),
|
||||
/** 预留日志级别配置,目前日志工具只负责安全输出 */
|
||||
logLevel: readString("LOG_LEVEL", "INFO"),
|
||||
/** 单行 SSE data 的最大字节数,用于防止异常流无限堆内存 */
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// server/utils/runtimeState.ts - 服务启动时间内存状态 + 问答记录 DB 读写
|
||||
import { answerCache } from "~~/server/utils/cache";
|
||||
import { prisma } from "~~/server/utils/db";
|
||||
import { serverEnv } from "~~/server/utils/env";
|
||||
|
||||
@@ -29,6 +28,7 @@ export const addQaRecord = async (record: {
|
||||
type: string;
|
||||
options: string;
|
||||
answer: string;
|
||||
hash: string;
|
||||
}) => {
|
||||
await prisma.qaRecord.create({
|
||||
data: {
|
||||
@@ -36,11 +36,34 @@ export const addQaRecord = async (record: {
|
||||
question: record.question,
|
||||
type: record.type,
|
||||
options: record.options || null,
|
||||
answer: record.answer || null
|
||||
answer: record.answer || null,
|
||||
hash: record.hash
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 按用户 + hash 查找 DB 缓存答案
|
||||
*
|
||||
* 只返回 cacheClearedAt 之后写入的记录;未存入时间或 hash 为 null 的老记录不会命中
|
||||
*/
|
||||
export const lookupCachedAnswer = async (
|
||||
userId: string,
|
||||
hash: string,
|
||||
cacheClearedAt: Date | null
|
||||
): Promise<string | null> => {
|
||||
const record = await prisma.qaRecord.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
hash,
|
||||
...(cacheClearedAt ? { createdAt: { gt: cacheClearedAt } } : {})
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { answer: true }
|
||||
});
|
||||
return record?.answer ?? null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 从数据库分页读取指定用户的问答记录,按创建时间倒序
|
||||
*/
|
||||
@@ -76,13 +99,11 @@ export const getQaRecords = async (
|
||||
}));
|
||||
return { records, total };
|
||||
};
|
||||
/** 生成 `/api/stats` 响应,实时计算 uptime 和有效缓存数量 */
|
||||
/** 生成 `/api/stats` 的基础运行时信息,不包含用户相关数据 */
|
||||
export const getRuntimeStats = () => {
|
||||
return {
|
||||
version: SERVICE_VERSION,
|
||||
uptime: (Date.now() - startTime) / 1000,
|
||||
model: serverEnv.openAiModel,
|
||||
cache_enabled: serverEnv.enableCache,
|
||||
cache_size: answerCache?.size() ?? 0
|
||||
model: serverEnv.openAiModel
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user