feat: 添加分页功能和缓存管理,优化问答记录展示
This commit is contained in:
@@ -58,7 +58,7 @@ defineExpose({ isCopied });
|
|||||||
:variant="variant"
|
:variant="variant"
|
||||||
:size="size"
|
:size="size"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
:class="cn('relative overflow-hidden pl-8', className)"
|
:class="cn('relative overflow-hidden pl-7', className)"
|
||||||
@click.prevent.stop="handleCopy"
|
@click.prevent.stop="handleCopy"
|
||||||
>
|
>
|
||||||
<!-- 复制成功图标:未复制时向上移出,复制后滑入 -->
|
<!-- 复制成功图标:未复制时向上移出,复制后滑入 -->
|
||||||
|
|||||||
@@ -14,8 +14,19 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle
|
CardTitle
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue
|
||||||
|
} from "@/components/ui/select";
|
||||||
import type { IQaRecord } from "@/interfaces";
|
import type { IQaRecord } from "@/interfaces";
|
||||||
import { QUESTION_TYPE_LABELS, useAnswerStore } from "@/stores/answer";
|
import {
|
||||||
|
DASHBOARD_PAGE_SIZE_OPTIONS,
|
||||||
|
QUESTION_TYPE_LABELS,
|
||||||
|
useAnswerStore
|
||||||
|
} from "@/stores/answer";
|
||||||
|
|
||||||
const answerStore = useAnswerStore();
|
const answerStore = useAnswerStore();
|
||||||
const {
|
const {
|
||||||
@@ -78,8 +89,29 @@ const displayOptions = (record: IQaRecord) => {
|
|||||||
<template>
|
<template>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
<CardTitle>最近问答记录</CardTitle>
|
<CardTitle>最近问答记录</CardTitle>
|
||||||
<CardDescription>当前进程最多保留最近 100 条。</CardDescription>
|
<CardDescription>分页展示,按创建时间倒序</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
:model-value="String(recordsPageSize)"
|
||||||
|
@update:model-value="(v) => answerStore.setRecordsPageSize(Number(v))"
|
||||||
|
>
|
||||||
|
<SelectTrigger class="w-28">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem
|
||||||
|
v-for="size in DASHBOARD_PAGE_SIZE_OPTIONS"
|
||||||
|
:key="size"
|
||||||
|
:value="String(size)"
|
||||||
|
>
|
||||||
|
{{ size }} 条/页
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
|||||||
@@ -3,10 +3,7 @@
|
|||||||
import { RefreshCw, Trash2 } from "lucide-vue-next";
|
import { RefreshCw, Trash2 } from "lucide-vue-next";
|
||||||
import { storeToRefs } from "pinia";
|
import { storeToRefs } from "pinia";
|
||||||
|
|
||||||
import {
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
Alert,
|
|
||||||
AlertDescription
|
|
||||||
} from "@/components/ui/alert";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -39,13 +36,7 @@ const overviewItems = computed(() => [
|
|||||||
{
|
{
|
||||||
label: "模型",
|
label: "模型",
|
||||||
value: stats.value?.model || health.value?.model || "-",
|
value: stats.value?.model || health.value?.model || "-",
|
||||||
detail: stats.value?.cache_enabled ? "缓存已启用" : "缓存未启用",
|
detail: "AI 模型",
|
||||||
loading: statsLoading.value && !stats.value
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "缓存数量",
|
|
||||||
value: stats.value?.cache_size?.toString() ?? "0",
|
|
||||||
detail: "当前进程",
|
|
||||||
loading: statsLoading.value && !stats.value
|
loading: statsLoading.value && !stats.value
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -59,7 +50,9 @@ const overviewItems = computed(() => [
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="grid gap-4">
|
<section class="grid gap-4">
|
||||||
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
<div
|
||||||
|
class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between"
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-xl font-semibold text-foreground">Dashboard</h1>
|
<h1 class="text-xl font-semibold text-foreground">Dashboard</h1>
|
||||||
<p class="mt-1 text-sm text-muted-foreground">
|
<p class="mt-1 text-sm text-muted-foreground">
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
<!-- app/components/home/OcsConfigCard.vue - OCS AnswererWrapper 配置片段 -->
|
<!-- app/components/home/OcsConfigCard.vue - OCS AnswererWrapper 配置片段 -->
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import { RefreshCw } from "lucide-vue-next";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardAction,
|
CardAction,
|
||||||
@@ -12,6 +15,14 @@ import { useAuthStore } from "@/stores/auth";
|
|||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
const origin = ref("http://localhost:3000");
|
const origin = ref("http://localhost:3000");
|
||||||
|
|
||||||
|
const refreshing = ref(false);
|
||||||
|
|
||||||
|
const handleRefreshToken = async () => {
|
||||||
|
refreshing.value = true;
|
||||||
|
await authStore.refreshApiToken();
|
||||||
|
refreshing.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
/** 浏览器端读取当前站点地址,避免文档里写死 localhost 或生产域名 */
|
/** 浏览器端读取当前站点地址,避免文档里写死 localhost 或生产域名 */
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
origin.value = window.location.origin;
|
origin.value = window.location.origin;
|
||||||
@@ -22,7 +33,8 @@ const configText = computed(() => {
|
|||||||
const data: Record<string, string> = {
|
const data: Record<string, string> = {
|
||||||
title: "${title}",
|
title: "${title}",
|
||||||
type: "${type}",
|
type: "${type}",
|
||||||
options: "${options}"
|
options: "${options}",
|
||||||
|
token: ""
|
||||||
};
|
};
|
||||||
|
|
||||||
if (authStore.user?.apiToken) {
|
if (authStore.user?.apiToken) {
|
||||||
@@ -53,13 +65,25 @@ const configText = computed(() => {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>OCS 配置</CardTitle>
|
<CardTitle>OCS 配置</CardTitle>
|
||||||
<CardAction>
|
<CardAction>
|
||||||
<CopyCom :text="configText" :timeout="1200" />
|
<div class="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
v-if="authStore.user?.apiToken && authStore.isOnline"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
:disabled="refreshing"
|
||||||
|
@click="handleRefreshToken"
|
||||||
|
>
|
||||||
|
<RefreshCw :class="['size-4', refreshing ? 'animate-spin' : '']" />
|
||||||
|
刷新 Token
|
||||||
|
</Button>
|
||||||
|
<CopyCom :text="configText" :timeout="1200" :disabled="refreshing" />
|
||||||
|
</div>
|
||||||
</CardAction>
|
</CardAction>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<pre
|
<pre
|
||||||
class="max-h-120 overflow-auto rounded-2xl bg-muted p-4 text-xs leading-5 text-muted-foreground"
|
class="h-89.5 overflow-x-auto overflow-y-hidden rounded-2xl bg-muted p-4 text-xs leading-5 text-muted-foreground"
|
||||||
><code>{{ configText }}</code>
|
><code>{{ configText }}</code>
|
||||||
</pre>
|
</pre>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -47,13 +47,11 @@ export interface IHealthResponse {
|
|||||||
message: string;
|
message: string;
|
||||||
/** 服务版本 */
|
/** 服务版本 */
|
||||||
version: string;
|
version: string;
|
||||||
/** 当前是否启用内存缓存 */
|
|
||||||
cache_enabled: boolean;
|
|
||||||
/** 当前模型名,不包含 API Key 或 baseURL */
|
/** 当前模型名,不包含 API Key 或 baseURL */
|
||||||
model: string;
|
model: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 服务统计响应,来自当前 Nuxt 进程的内存状态 */
|
/** 服务统计响应 */
|
||||||
export interface IStatsResponse {
|
export interface IStatsResponse {
|
||||||
/** 服务版本 */
|
/** 服务版本 */
|
||||||
version: string;
|
version: string;
|
||||||
@@ -61,11 +59,7 @@ export interface IStatsResponse {
|
|||||||
uptime: number;
|
uptime: number;
|
||||||
/** 当前模型名 */
|
/** 当前模型名 */
|
||||||
model: string;
|
model: string;
|
||||||
/** 当前是否启用内存缓存 */
|
/** 当前用户的问答记录总数 */
|
||||||
cache_enabled: boolean;
|
|
||||||
/** 当前有效缓存数量 */
|
|
||||||
cache_size: number;
|
|
||||||
/** 当前进程内问答记录数量 */
|
|
||||||
qa_records_count: number;
|
qa_records_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,25 +1,13 @@
|
|||||||
<!-- app/pages/dashboard.vue - 运行状态与最近问答记录页面 -->
|
<!-- app/pages/dashboard.vue - 运行状态与最近问答记录页面 -->
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { RefreshCw } from "lucide-vue-next";
|
|
||||||
|
|
||||||
import QaRecordDetail from "@/components/dashboard/QaRecordDetail.vue";
|
import QaRecordDetail from "@/components/dashboard/QaRecordDetail.vue";
|
||||||
import QaRecordsTable from "@/components/dashboard/QaRecordsTable.vue";
|
import QaRecordsTable from "@/components/dashboard/QaRecordsTable.vue";
|
||||||
import StatsOverview from "@/components/dashboard/StatsOverview.vue";
|
import StatsOverview from "@/components/dashboard/StatsOverview.vue";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { useAnswerStore } from "@/stores/answer";
|
import { useAnswerStore } from "@/stores/answer";
|
||||||
import { useAuthStore } from "@/stores/auth";
|
|
||||||
|
|
||||||
definePageMeta({ middleware: "auth" });
|
definePageMeta({ middleware: "auth" });
|
||||||
|
|
||||||
const answerStore = useAnswerStore();
|
const answerStore = useAnswerStore();
|
||||||
const authStore = useAuthStore();
|
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
title: "Dashboard - OCS AI 答题服务"
|
title: "Dashboard - OCS AI 答题服务"
|
||||||
@@ -36,50 +24,10 @@ if (import.meta.client) {
|
|||||||
void answerStore.loadDashboard();
|
void answerStore.loadDashboard();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const refreshing = ref(false);
|
|
||||||
|
|
||||||
const handleRefreshToken = async () => {
|
|
||||||
refreshing.value = true;
|
|
||||||
await authStore.refreshApiToken();
|
|
||||||
refreshing.value = false;
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="grid gap-6">
|
<div class="grid gap-6">
|
||||||
<!-- OCS 油猴脚本 API token 配置卡片 -->
|
|
||||||
<Card v-if="authStore.user?.apiToken">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>OCS 脚本 Token</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
在 OCS 油猴脚本的服务器配置中填入此
|
|
||||||
token,脚本将以你的账号身份调用答题接口。
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<code
|
|
||||||
class="flex-1 overflow-x-auto bg-muted px-3 py-2 font-mono text-sm rounded-full"
|
|
||||||
>
|
|
||||||
{{ authStore.user.apiToken }}
|
|
||||||
</code>
|
|
||||||
<CopyCom :text="authStore.user.apiToken" :disabled="refreshing" />
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
:disabled="refreshing"
|
|
||||||
title="刷新 Token"
|
|
||||||
class="flex items-center gap-1 w-fit px-2.5"
|
|
||||||
@click="handleRefreshToken"
|
|
||||||
>
|
|
||||||
<RefreshCw :class="['size-4', refreshing ? 'animate-spin' : '']" />
|
|
||||||
<span>刷新 Token</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<StatsOverview />
|
<StatsOverview />
|
||||||
<QaRecordsTable />
|
<QaRecordsTable />
|
||||||
<QaRecordDetail />
|
<QaRecordDetail />
|
||||||
|
|||||||
+12
-1
@@ -13,6 +13,9 @@ import { AnswerService } from "@/services";
|
|||||||
/** Dashboard 最近记录表格每页数量,组件直接复用,避免多个地方写死 */
|
/** Dashboard 最近记录表格每页数量,组件直接复用,避免多个地方写死 */
|
||||||
export const DASHBOARD_RECORD_PAGE_SIZE = 10;
|
export const DASHBOARD_RECORD_PAGE_SIZE = 10;
|
||||||
|
|
||||||
|
/** 分页大小可选列表,与后端白名单保持一致 */
|
||||||
|
export const DASHBOARD_PAGE_SIZE_OPTIONS = [10, 50, 100] as const;
|
||||||
|
|
||||||
/** 题型下拉选项,value 保持 OCS / 旧 Python 服务使用的英文值 */
|
/** 题型下拉选项,value 保持 OCS / 旧 Python 服务使用的英文值 */
|
||||||
export const QUESTION_TYPE_OPTIONS: Array<{
|
export const QUESTION_TYPE_OPTIONS: Array<{
|
||||||
label: string;
|
label: string;
|
||||||
@@ -104,6 +107,13 @@ export const useAnswerStore = defineStore("answer", () => {
|
|||||||
await getRecords(page);
|
await getRecords(page);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 切换分页大小,重置到第 1 页后重新请求 */
|
||||||
|
const setRecordsPageSize = async (size: number) => {
|
||||||
|
if (recordsLoading.value || size === recordsPageSize.value) return;
|
||||||
|
recordsPageSize.value = size;
|
||||||
|
await getRecords(1);
|
||||||
|
};
|
||||||
|
|
||||||
/** 调用答题 API,返回 OCS 兼容结构但只在 store 中维护 UI 状态 */
|
/** 调用答题 API,返回 OCS 兼容结构但只在 store 中维护 UI 状态 */
|
||||||
const searchAnswer = async () => {
|
const searchAnswer = async () => {
|
||||||
const title = question.value.trim();
|
const title = question.value.trim();
|
||||||
@@ -245,6 +255,7 @@ export const useAnswerStore = defineStore("answer", () => {
|
|||||||
loadDashboard,
|
loadDashboard,
|
||||||
clearCache,
|
clearCache,
|
||||||
selectRecord,
|
selectRecord,
|
||||||
setRecordsPage
|
setRecordsPage,
|
||||||
|
setRecordsPageSize
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ model User {
|
|||||||
createdAt DateTime
|
createdAt DateTime
|
||||||
updatedAt DateTime
|
updatedAt DateTime
|
||||||
apiToken String? @unique
|
apiToken String? @unique
|
||||||
|
/// 用户最近一次清缓存的时间;只有在此时间之后写入的记录才算缓存命中
|
||||||
|
cacheClearedAt DateTime?
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
accounts Account[]
|
accounts Account[]
|
||||||
qaRecords QaRecord[]
|
qaRecords QaRecord[]
|
||||||
@@ -85,7 +87,10 @@ model QaRecord {
|
|||||||
type String
|
type String
|
||||||
options String? @db.Text
|
options String? @db.Text
|
||||||
answer String? @db.Text
|
answer String? @db.Text
|
||||||
|
/// 题目+题型+选项的 MD5,用于 DB 缓存查询;老记录为 null
|
||||||
|
hash String? @db.Char(32)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([userId, hash])
|
||||||
@@map("qa_record")
|
@@map("qa_record")
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+11
-16
@@ -1,27 +1,22 @@
|
|||||||
// server/api/cache/clear.post.ts - 清空答题缓存接口
|
// server/api/cache/clear.post.ts - 清空当前用户 DB 缓存接口
|
||||||
import { answerCache } from "~~/server/utils/cache";
|
import { prisma } from "~~/server/utils/db";
|
||||||
import { serverEnv } from "~~/server/utils/env";
|
|
||||||
import { createApiLogger } from "~~/server/utils/logging";
|
import { createApiLogger } from "~~/server/utils/logging";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 清空内存缓存
|
* 清缓存:将当前用户的 cacheClearedAt 更新为当前时间
|
||||||
*
|
*
|
||||||
* 这是一个管理类接口,必须携带有效 session cookie(由 api-auth middleware 统一鉴权)
|
* 之后查 DB 缓存时只命中该时间之后写入的记录,历史记录全部保留。
|
||||||
* 清理的是当前 Nuxt 进程内缓存;多实例部署时,每个实例都有自己的内存缓存
|
* 必须携带有效 session cookie(由 api-auth middleware 统一鉴权)
|
||||||
*/
|
*/
|
||||||
export default defineEventHandler((event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const logger = createApiLogger(event, "api.cache.clear");
|
const logger = createApiLogger(event, "api.cache.clear");
|
||||||
|
const userId = event.context.auth!.user.id;
|
||||||
|
|
||||||
// 缓存被关闭时不报错,只明确告诉调用方当前没有缓存可清
|
await prisma.user.update({
|
||||||
if (!serverEnv.enableCache || !answerCache) {
|
where: { id: userId },
|
||||||
return {
|
data: { cacheClearedAt: new Date() }
|
||||||
success: false,
|
});
|
||||||
message: "缓存未启用"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 清空当前进程内缓存,DB 记录不受影响
|
|
||||||
answerCache.clear();
|
|
||||||
logger.info("finish_success");
|
logger.info("finish_success");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -6,14 +6,13 @@ import { SERVICE_VERSION } from "~~/server/utils/runtimeState";
|
|||||||
* 健康检查不需要鉴权
|
* 健康检查不需要鉴权
|
||||||
*
|
*
|
||||||
* 这个接口用于部署平台、Docker healthcheck 或人工确认服务是否启动;
|
* 这个接口用于部署平台、Docker healthcheck 或人工确认服务是否启动;
|
||||||
* 返回模型名和缓存开关,但不返回 API Key、baseURL 或其他敏感配置
|
* 返回模型名,但不返回 API Key、baseURL 或其他敏感配置
|
||||||
*/
|
*/
|
||||||
export default defineEventHandler(() => {
|
export default defineEventHandler(() => {
|
||||||
return {
|
return {
|
||||||
status: "ok",
|
status: "ok",
|
||||||
message: "AI题库服务运行正常",
|
message: "AI题库服务运行正常",
|
||||||
version: SERVICE_VERSION,
|
version: SERVICE_VERSION,
|
||||||
cache_enabled: serverEnv.enableCache,
|
|
||||||
model: serverEnv.openAiModel
|
model: serverEnv.openAiModel
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ export default defineEventHandler(async (event) => {
|
|||||||
const query = getQuery(event);
|
const query = getQuery(event);
|
||||||
const rawPage = Number.parseInt(query.page?.toString() || "1", 10);
|
const rawPage = Number.parseInt(query.page?.toString() || "1", 10);
|
||||||
const rawSize = Number.parseInt(query.size?.toString() || "10", 10);
|
const rawSize = Number.parseInt(query.size?.toString() || "10", 10);
|
||||||
const size = Number.isFinite(rawSize)
|
// size 只允许 10 / 50 / 100;传入其他值(含超出上限)一律退回 10
|
||||||
? Math.min(Math.max(rawSize, 1), 100)
|
const ALLOWED_SIZES = new Set([10, 50, 100]);
|
||||||
: 10;
|
const size = ALLOWED_SIZES.has(rawSize) ? rawSize : 10;
|
||||||
const page = Number.isFinite(rawPage) ? Math.max(rawPage, 1) : 1;
|
const page = Number.isFinite(rawPage) ? Math.max(rawPage, 1) : 1;
|
||||||
|
|
||||||
const { records, total } = await getQaRecords(event.context.auth!.user.id, {
|
const { records, total } = await getQaRecords(event.context.auth!.user.id, {
|
||||||
|
|||||||
+21
-17
@@ -1,4 +1,6 @@
|
|||||||
// server/api/search.ts - OCS AnswererWrapper 兼容搜索接口
|
// server/api/search.ts - OCS AnswererWrapper 兼容搜索接口
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getQuery,
|
getQuery,
|
||||||
type H3Event,
|
type H3Event,
|
||||||
@@ -16,11 +18,10 @@ import {
|
|||||||
type SearchParams
|
type SearchParams
|
||||||
} from "~~/server/utils/answer";
|
} from "~~/server/utils/answer";
|
||||||
import { getAuthSession } from "~~/server/utils/auth";
|
import { getAuthSession } from "~~/server/utils/auth";
|
||||||
import { answerCache } from "~~/server/utils/cache";
|
|
||||||
import { prisma } from "~~/server/utils/db";
|
import { prisma } from "~~/server/utils/db";
|
||||||
import { createApiLogger, toSafeLogError } from "~~/server/utils/logging";
|
import { createApiLogger, toSafeLogError } from "~~/server/utils/logging";
|
||||||
import { askAnswerStream } from "~~/server/utils/openai";
|
import { askAnswerStream } from "~~/server/utils/openai";
|
||||||
import { addQaRecord } from "~~/server/utils/runtimeState";
|
import { addQaRecord, lookupCachedAnswer } from "~~/server/utils/runtimeState";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 把 query/body/form 中的值统一转成字符串
|
* 把 query/body/form 中的值统一转成字符串
|
||||||
@@ -138,17 +139,25 @@ export default defineEventHandler(async (event) => {
|
|||||||
|
|
||||||
// 鉴权:优先 session(浏览器登录),无 session 则用 body.token 查 apiToken
|
// 鉴权:优先 session(浏览器登录),无 session 则用 body.token 查 apiToken
|
||||||
// OCS 油猴脚本跨域无法携带 cookie,需在 body 中传入用户自己的 apiToken
|
// OCS 油猴脚本跨域无法携带 cookie,需在 body 中传入用户自己的 apiToken
|
||||||
|
// 同时取出 cacheClearedAt,用于 DB 缓存过滤
|
||||||
let userId: string | null = null;
|
let userId: string | null = null;
|
||||||
|
let cacheClearedAt: Date | null = null;
|
||||||
|
|
||||||
const session = await getAuthSession(event);
|
const session = await getAuthSession(event);
|
||||||
if (session) {
|
if (session) {
|
||||||
userId = session.user.id;
|
userId = session.user.id;
|
||||||
|
const userData = await prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { cacheClearedAt: true }
|
||||||
|
});
|
||||||
|
cacheClearedAt = userData?.cacheClearedAt ?? null;
|
||||||
} else if (params.token) {
|
} else if (params.token) {
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { apiToken: params.token },
|
where: { apiToken: params.token },
|
||||||
select: { id: true }
|
select: { id: true, cacheClearedAt: true }
|
||||||
});
|
});
|
||||||
userId = user?.id ?? null;
|
userId = user?.id ?? null;
|
||||||
|
cacheClearedAt = user?.cacheClearedAt ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
@@ -167,12 +176,12 @@ export default defineEventHandler(async (event) => {
|
|||||||
return createOcsErrorResponse("未提供问题内容");
|
return createOcsErrorResponse("未提供问题内容");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 缓存 key 包含题目、题型和选项;同题不同选项不能共用答案
|
// 计算缓存 key:题目+题型+选项的 MD5;同题不同选项不能共用答案
|
||||||
const cachedAnswer = answerCache?.get(
|
const hash = createHash("md5")
|
||||||
params.title,
|
.update(`${params.title}|${params.type}|${params.options}`, "utf8")
|
||||||
params.type,
|
.digest("hex");
|
||||||
params.options
|
|
||||||
);
|
const cachedAnswer = await lookupCachedAnswer(userId, hash, cacheClearedAt);
|
||||||
|
|
||||||
if (cachedAnswer) {
|
if (cachedAnswer) {
|
||||||
logger.info("cache_hit");
|
logger.info("cache_hit");
|
||||||
@@ -191,19 +200,14 @@ export default defineEventHandler(async (event) => {
|
|||||||
});
|
});
|
||||||
const processedAnswer = extractAnswer(streamResult.answer, params.type);
|
const processedAnswer = extractAnswer(streamResult.answer, params.type);
|
||||||
|
|
||||||
// 先缓存再记录;这两步失败风险很低,且都是内存操作,不会阻塞主链路
|
// 写入 DB 记录(带 hash);记录本身即为后续缓存查询的来源
|
||||||
answerCache?.set(
|
|
||||||
params.title,
|
|
||||||
processedAnswer,
|
|
||||||
params.type,
|
|
||||||
params.options
|
|
||||||
);
|
|
||||||
await addQaRecord({
|
await addQaRecord({
|
||||||
userId,
|
userId,
|
||||||
question: params.title,
|
question: params.title,
|
||||||
type: params.type,
|
type: params.type,
|
||||||
options: params.options,
|
options: params.options,
|
||||||
answer: processedAnswer
|
answer: processedAnswer,
|
||||||
|
hash
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info("finish_success", {
|
logger.info("finish_success", {
|
||||||
|
|||||||
+11
-4
@@ -1,16 +1,23 @@
|
|||||||
// server/api/stats.get.ts - 服务运行统计接口
|
// server/api/stats.get.ts - 服务运行统计接口
|
||||||
|
import { prisma } from "~~/server/utils/db";
|
||||||
import { createApiLogger } from "~~/server/utils/logging";
|
import { createApiLogger } from "~~/server/utils/logging";
|
||||||
import { getRuntimeStats } from "~~/server/utils/runtimeState";
|
import { getRuntimeStats } from "~~/server/utils/runtimeState";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 运行统计接口
|
* 运行统计接口
|
||||||
*
|
*
|
||||||
* 统计信息包含 uptime、模型名、缓存数量、最近问答记录数量
|
* 返回进程 uptime、模型名以及当前用户的问答记录总数
|
||||||
* 必须携带有效 session cookie(由 api-auth middleware 统一鉴权)
|
* 必须携带有效 session cookie(由 api-auth middleware 统一鉴权)
|
||||||
*/
|
*/
|
||||||
export default defineEventHandler((event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const logger = createApiLogger(event, "api.stats");
|
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");
|
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),
|
maxTokens: readInteger("MAX_TOKENS", 500),
|
||||||
/** 模型采样温度,越低越稳定 */
|
/** 模型采样温度,越低越稳定 */
|
||||||
temperature: readNumber("TEMPERATURE", 0.7),
|
temperature: readNumber("TEMPERATURE", 0.7),
|
||||||
/** 是否启用内存缓存 */
|
|
||||||
enableCache: readBoolean("ENABLE_CACHE", true),
|
|
||||||
/** 缓存过期时间,单位秒 */
|
|
||||||
cacheExpiration: readInteger("CACHE_EXPIRATION", 86_400),
|
|
||||||
/** 预留日志级别配置,目前日志工具只负责安全输出 */
|
/** 预留日志级别配置,目前日志工具只负责安全输出 */
|
||||||
logLevel: readString("LOG_LEVEL", "INFO"),
|
logLevel: readString("LOG_LEVEL", "INFO"),
|
||||||
/** 单行 SSE data 的最大字节数,用于防止异常流无限堆内存 */
|
/** 单行 SSE data 的最大字节数,用于防止异常流无限堆内存 */
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
// server/utils/runtimeState.ts - 服务启动时间内存状态 + 问答记录 DB 读写
|
// server/utils/runtimeState.ts - 服务启动时间内存状态 + 问答记录 DB 读写
|
||||||
import { answerCache } from "~~/server/utils/cache";
|
|
||||||
import { prisma } from "~~/server/utils/db";
|
import { prisma } from "~~/server/utils/db";
|
||||||
import { serverEnv } from "~~/server/utils/env";
|
import { serverEnv } from "~~/server/utils/env";
|
||||||
|
|
||||||
@@ -29,6 +28,7 @@ export const addQaRecord = async (record: {
|
|||||||
type: string;
|
type: string;
|
||||||
options: string;
|
options: string;
|
||||||
answer: string;
|
answer: string;
|
||||||
|
hash: string;
|
||||||
}) => {
|
}) => {
|
||||||
await prisma.qaRecord.create({
|
await prisma.qaRecord.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -36,11 +36,34 @@ export const addQaRecord = async (record: {
|
|||||||
question: record.question,
|
question: record.question,
|
||||||
type: record.type,
|
type: record.type,
|
||||||
options: record.options || null,
|
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 };
|
return { records, total };
|
||||||
};
|
};
|
||||||
/** 生成 `/api/stats` 响应,实时计算 uptime 和有效缓存数量 */
|
/** 生成 `/api/stats` 的基础运行时信息,不包含用户相关数据 */
|
||||||
export const getRuntimeStats = () => {
|
export const getRuntimeStats = () => {
|
||||||
return {
|
return {
|
||||||
version: SERVICE_VERSION,
|
version: SERVICE_VERSION,
|
||||||
uptime: (Date.now() - startTime) / 1000,
|
uptime: (Date.now() - startTime) / 1000,
|
||||||
model: serverEnv.openAiModel,
|
model: serverEnv.openAiModel
|
||||||
cache_enabled: serverEnv.enableCache,
|
|
||||||
cache_size: answerCache?.size() ?? 0
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user