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
+4
View File
@@ -20,3 +20,7 @@ LOG_LEVEL=INFO
# Prisma 配置,当前答题服务不依赖数据库,但项目保留 Prisma
DATABASE_URL="mysql://user:password@localhost:3306/database"
# better-auth 配置
BETTER_AUTH_SECRET=your-better-auth-secret-here
BETTER_AUTH_URL=http://localhost:3000
+2
View File
@@ -24,3 +24,5 @@ logs
!.env.example
/prisma/generated
.agents
+74
View File
@@ -0,0 +1,74 @@
const fs = require('fs');
const path = require('path');
const content = `// server/utils/runtimeState.ts - 服务启动时间内存状态 + 问答记录 DB 读写
import { answerCache } from "~~/server/utils/cache";
import { prisma } from "~~/server/utils/db";
import { serverEnv } from "~~/server/utils/env";
const startTime = Date.now();
export const SERVICE_VERSION = "1.1.0";
export const formatLocalDateTime = (date: Date) => {
const pad = (value: number) => value.toString().padStart(2, "0");
return \`\${date.getFullYear()}-\${pad(date.getMonth() + 1)}-\${pad(date.getDate())} \${pad(date.getHours())}:\${pad(date.getMinutes())}:\${pad(date.getSeconds())}\`;
};
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
}
});
};
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 };
};
export const getRuntimeStats = () => {
return {
version: SERVICE_VERSION,
uptime: (Date.now() - startTime) / 1000,
model: serverEnv.openAiModel,
cache_enabled: serverEnv.enableCache,
cache_size: answerCache?.size() ?? 0
};
};
`;
fs.writeFileSync(path.join(__dirname, 'server', 'utils', 'runtimeState.ts'), content, 'utf8');
console.log('Written successfully');
+72
View File
@@ -0,0 +1,72 @@
<!-- app/components/CopyCom.vue - 通用复制按钮带图标切换动画 -->
<script lang="ts" setup>
import { Check, Copy } from "lucide-vue-next";
import type { HTMLAttributes } from "vue";
import type { ButtonVariants } from "@/components/ui/button";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { copy } from "@/utils";
const {
text = "",
class: className,
variant = "outline",
size = "sm",
timeout = 2000,
disableDefaultCopy = false
} = defineProps<{
/** 要复制的文本 */
text?: string;
class?: HTMLAttributes["class"] | undefined;
variant?: ButtonVariants["variant"];
size?: ButtonVariants["size"];
/** 复制成功后恢复的延迟毫秒数,默认 2000 */
timeout?: number;
/** 禁用内置复制行为,只触发状态变化(外部自行处理复制) */
disableDefaultCopy?: boolean;
}>();
const emit = defineEmits<{
copy: [text: string];
}>();
const isCopied = ref(false);
const handleCopy = () => {
if (!disableDefaultCopy) {
copy(text);
}
emit("copy", text);
isCopied.value = true;
setTimeout(() => {
isCopied.value = false;
}, timeout);
};
defineExpose({ isCopied });
</script>
<template>
<Button
type="button"
:variant="variant"
:size="size"
:class="cn('relative overflow-hidden pl-8', className)"
@click.prevent.stop="handleCopy"
>
<!-- 复制成功图标未复制时向上移出复制后滑入 -->
<Check
class="absolute size-3.5 transition-transform duration-200 left-2.5"
:class="isCopied ? 'translate-y-0' : '-translate-y-6'"
/>
<!-- 默认复制图标复制后向下移出 -->
<Copy
class="absolute size-3.5 transition-transform duration-200 left-2.5"
:class="isCopied ? 'translate-y-6' : 'translate-y-0'"
/>
<slot :is-copied="isCopied">
<span>复制</span>
</slot>
</Button>
</template>
+100 -27
View File
@@ -1,7 +1,10 @@
<!-- app/components/home/AnswerForm.vue - 答题提交表单状态全部来自 Pinia store -->
<script lang="ts" setup>
import { toTypedSchema } from "@vee-validate/zod";
import { Search } from "lucide-vue-next";
import { storeToRefs } from "pinia";
import { useForm } from "vee-validate";
import * as z from "zod";
import { Button } from "@/components/ui/button";
import {
@@ -11,7 +14,13 @@ import {
CardHeader,
CardTitle
} from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@/components/ui/form";
import {
Select,
SelectContent,
@@ -26,6 +35,10 @@ import { QUESTION_TYPE_OPTIONS, useAnswerStore } from "@/stores/answer";
const answerStore = useAnswerStore();
const { question, questionType, options, searchLoading } =
storeToRefs(answerStore);
const authStore = useAuthStore();
/** session 尚未初始化完成(客户端 plugin 还在请求中) */
const sessionChecking = computed(() => !authStore.initialized);
const AUTO_TYPE_VALUE = "__auto__";
/** shadcn Select 不使用空字符串作为 item value,这里只在组件边界做一次映射 */
@@ -37,10 +50,47 @@ const selectedQuestionType = computed({
}
});
/** 表单提交只触发 store action,组件不直接接触请求服务 */
const onSubmit = () => {
void answerStore.searchAnswer();
};
const formSchema = toTypedSchema(
z.object({
question: z
.string({ required_error: "题目不能为空" })
.min(1, "题目不能为空"),
options: z.string().default("")
})
);
const { handleSubmit, setFieldError } = useForm({
validationSchema: formSchema,
initialValues: { question: question.value, options: options.value }
});
let clearErrorTimer: ReturnType<typeof setTimeout> | null = null;
/** 表单提交先更新 store 再触发 action,组件不直接接触请求服务 */
const onSubmit = handleSubmit(
(values) => {
if (clearErrorTimer) {
clearTimeout(clearErrorTimer);
clearErrorTimer = null;
}
question.value = values.question;
options.value = values.options ?? "";
void answerStore.searchAnswer();
},
() => {
// 校验失败 5s 后自动清除提示
if (clearErrorTimer) clearTimeout(clearErrorTimer);
clearErrorTimer = setTimeout(() => {
setFieldError("question", undefined);
clearErrorTimer = null;
}, 5000);
}
);
onUnmounted(() => {
// 清除定时器
if (clearErrorTimer) clearTimeout(clearErrorTimer);
});
</script>
<template>
@@ -53,24 +103,34 @@ const onSubmit = () => {
</CardHeader>
<CardContent>
<form class="grid gap-4" @submit.prevent="onSubmit">
<div class="grid gap-2">
<Label for="answer-question">题目</Label>
<Textarea
id="answer-question"
v-model="question"
class="min-h-32 resize-y"
placeholder="输入题目内容"
/>
</div>
<form class="grid gap-4" @submit="onSubmit">
<FormField
v-slot="{ componentField }"
name="question"
:validate-on-blur="false"
:validate-on-change="false"
:validate-on-model-update="false"
>
<FormItem>
<FormLabel>题目</FormLabel>
<FormControl>
<Textarea
class="min-h-32 resize-y"
placeholder="输入题目内容"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<div
class="grid gap-4 md:grid-cols-[220px_minmax(0,1fr)] md:items-start"
>
<div class="grid gap-2">
<Label for="answer-type">题型</Label>
<label class="text-sm font-medium leading-none">题型</label>
<Select v-model="selectedQuestionType">
<SelectTrigger id="answer-type" class="w-full">
<SelectTrigger class="w-full">
<SelectValue placeholder="自动判断" />
</SelectTrigger>
<SelectContent>
@@ -85,22 +145,35 @@ const onSubmit = () => {
</Select>
</div>
<div class="grid gap-2">
<Label for="answer-options">选项</Label>
<Textarea
id="answer-options"
v-model="options"
class="min-h-24 resize-y"
placeholder="A. 选项一&#10;B. 选项二"
/>
</div>
<FormField v-slot="{ componentField }" name="options">
<FormItem>
<FormLabel>选项</FormLabel>
<FormControl>
<Textarea
class="min-h-24 resize-y"
placeholder="A. 选项一&#10;B. 选项二"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</div>
<div class="flex items-center justify-end">
<Button type="submit" :disabled="searchLoading">
<!-- 已登录或 session 尚未确认乐观显示正常按钮SSR/客户端 DOM 一致避免水合不一致-->
<!-- 确认未登录后才切换为"去登录"此切换只发生在客户端 plugin 完成后 -->
<Button
v-if="authStore.isOnline || sessionChecking"
type="submit"
:disabled="searchLoading"
>
<Search class="size-4" />
<span>{{ searchLoading ? "查询中" : "获取答案" }}</span>
</Button>
<Button v-else as-child variant="outline">
<NuxtLink to="/login">去登录</NuxtLink>
</Button>
</div>
</form>
</CardContent>
+15 -24
View File
@@ -1,8 +1,5 @@
<!-- app/components/home/OcsConfigCard.vue - OCS AnswererWrapper 配置片段 -->
<script lang="ts" setup>
import { Check, Copy } from "lucide-vue-next";
import { Button } from "@/components/ui/button";
import {
Card,
CardAction,
@@ -10,17 +7,28 @@ import {
CardHeader,
CardTitle
} from "@/components/ui/card";
import { useAuthStore } from "@/stores/auth";
const authStore = useAuthStore();
const origin = ref("http://localhost:3000");
const copied = ref(false);
/** 浏览器端读取当前站点地址,避免文档里写死 localhost 或生产域名 */
onMounted(() => {
origin.value = window.location.origin;
});
/** 与旧 Python 项目的 ocs_config_example.json 保持同样字段 */
/** 与旧 Python 项目的 ocs_config_example.json 保持同样字段;登录后自动填入 apiToken */
const configText = computed(() => {
const data: Record<string, string> = {
title: "${title}",
type: "${type}",
options: "${options}"
};
if (authStore.user?.apiToken) {
data.token = authStore.user.apiToken;
}
return JSON.stringify(
[
{
@@ -29,11 +37,7 @@ const configText = computed(() => {
url: `${origin.value}/api/search`,
method: "get",
contentType: "json",
data: {
title: "${title}",
type: "${type}",
options: "${options}"
},
data,
handler:
"return (res)=> res.code === 1 ? [res.question, res.answer] : [res.msg, undefined]"
}
@@ -42,15 +46,6 @@ const configText = computed(() => {
2
);
});
/** 复制配置片段,失败时不打断主流程 */
const copyConfig = async () => {
await navigator.clipboard.writeText(configText.value).catch(() => undefined);
copied.value = true;
window.setTimeout(() => {
copied.value = false;
}, 1200);
};
</script>
<template>
@@ -58,11 +53,7 @@ const copyConfig = async () => {
<CardHeader>
<CardTitle>OCS 配置</CardTitle>
<CardAction>
<Button type="button" variant="outline" size="sm" @click="copyConfig">
<Check v-if="copied" class="size-3.5" />
<Copy v-else class="size-3.5" />
<span>{{ copied ? "已复制" : "复制" }}</span>
</Button>
<CopyCom :text="configText" :timeout="1200" />
</CardAction>
</CardHeader>
+40 -1
View File
@@ -1,8 +1,17 @@
<!-- app/components/layout/AppHeader.vue - 顶部导航和页面切换入口 -->
<script lang="ts" setup>
import { Activity, LayoutDashboard, Moon, Search, Sun } from "lucide-vue-next";
import {
Activity,
LayoutDashboard,
LogIn,
LogOut,
Moon,
Search,
Sun
} from "lucide-vue-next";
import { Button } from "@/components/ui/button";
import { useAuthStore } from "@/stores/auth";
/** 使用 VueUse 的 useColorMode 切换 dark/lightclass 会挂到 <html> 上 */
const colorMode = useColorMode();
@@ -12,6 +21,8 @@ function toggleColorMode() {
colorMode.value = isDark.value ? "light" : "dark";
}
const authStore = useAuthStore();
/** 顶部导航项,icon 使用 lucide,和项目里 shadcn 的图标体系保持一致 */
const navItems = [
{
@@ -33,6 +44,11 @@ const isActive = (to: string) => {
if (to === "/") return route.path === "/";
return route.path.startsWith(to);
};
const handleSignOut = async () => {
await authStore.signOut();
await navigateTo("/login");
};
</script>
<template>
@@ -82,6 +98,29 @@ const isActive = (to: string) => {
<Sun v-if="isDark" class="size-4" />
<Moon v-else class="size-4" />
</Button>
<!-- 已登录显示邮箱和退出按钮未登录显示登录链接 -->
<template v-if="authStore.isOnline">
<span class="max-sm:hidden text-xs text-muted-foreground">
{{ authStore.user!.email }}
</span>
<Button
variant="ghost"
size="icon"
aria-label="退出登录"
@click="handleSignOut"
>
<LogOut class="size-4" />
</Button>
</template>
<template v-else>
<Button as-child variant="ghost" size="sm">
<NuxtLink to="/login" class="gap-2">
<LogIn class="size-4" />
<span>登录</span>
</NuxtLink>
</Button>
</template>
</nav>
</div>
</header>
+17
View File
@@ -0,0 +1,17 @@
<script lang="ts" setup>
import { Slot } from 'reka-ui'
import { useFormField } from './useFormField'
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
</script>
<template>
<Slot
:id="formItemId"
data-slot="form-control"
:aria-describedby="!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`"
:aria-invalid="!!error"
>
<slot />
</Slot>
</template>
@@ -0,0 +1,21 @@
<script lang="ts" setup>
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import { useFormField } from './useFormField'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
const { formDescriptionId } = useFormField()
</script>
<template>
<p
:id="formDescriptionId"
data-slot="form-description"
:class="cn('text-muted-foreground text-sm', props.class)"
>
<slot />
</p>
</template>
+23
View File
@@ -0,0 +1,23 @@
<script lang="ts" setup>
import type { HTMLAttributes } from 'vue'
import { useId } from 'reka-ui'
import { provide } from 'vue'
import { cn } from '@/lib/utils'
import { FORM_ITEM_INJECTION_KEY } from './injectionKeys'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
const id = useId()
provide(FORM_ITEM_INJECTION_KEY, id)
</script>
<template>
<div
data-slot="form-item"
:class="cn('grid gap-2', props.class)"
>
<slot />
</div>
</template>
+25
View File
@@ -0,0 +1,25 @@
<script lang="ts" setup>
import type { LabelProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import { Label } from '@/components/ui/label'
import { useFormField } from './useFormField'
const props = defineProps<LabelProps & { class?: HTMLAttributes['class'] }>()
const { error, formItemId } = useFormField()
</script>
<template>
<Label
data-slot="form-label"
:data-error="!!error"
:class="cn(
'data-[error=true]:text-destructive',
props.class,
)"
:for="formItemId"
>
<slot />
</Label>
</template>
+23
View File
@@ -0,0 +1,23 @@
<script lang="ts" setup>
import type { HTMLAttributes } from 'vue'
import { ErrorMessage } from 'vee-validate'
import { toValue } from 'vue'
import { cn } from '@/lib/utils'
import { useFormField } from './useFormField'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
const { name, formMessageId } = useFormField()
</script>
<template>
<ErrorMessage
:id="formMessageId"
data-slot="form-message"
as="p"
:name="toValue(name)"
:class="cn('text-destructive text-sm', props.class)"
/>
</template>
+7
View File
@@ -0,0 +1,7 @@
export { default as FormControl } from './FormControl.vue'
export { default as FormDescription } from './FormDescription.vue'
export { default as FormItem } from './FormItem.vue'
export { default as FormLabel } from './FormLabel.vue'
export { default as FormMessage } from './FormMessage.vue'
export { FORM_ITEM_INJECTION_KEY } from './injectionKeys'
export { Form, Field as FormField, FieldArray as FormFieldArray } from 'vee-validate'
+4
View File
@@ -0,0 +1,4 @@
import type { InjectionKey } from 'vue'
export const FORM_ITEM_INJECTION_KEY
= Symbol() as InjectionKey<string>
+30
View File
@@ -0,0 +1,30 @@
import { FieldContextKey } from 'vee-validate'
import { computed, inject } from 'vue'
import { FORM_ITEM_INJECTION_KEY } from './injectionKeys'
export function useFormField() {
const fieldContext = inject(FieldContextKey)
const fieldItemContext = inject(FORM_ITEM_INJECTION_KEY)
if (!fieldContext)
throw new Error('useFormField should be used within <FormField>')
const { name, errorMessage: error, meta } = fieldContext
const id = fieldItemContext
const fieldState = {
valid: computed(() => meta.valid),
isDirty: computed(() => meta.dirty),
isTouched: computed(() => meta.touched),
error,
}
return {
id,
name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
+22
View File
@@ -0,0 +1,22 @@
// app/middleware/auth.ts - 路由守卫,未登录时重定向到登录页
import { useAuthStore } from "@/stores/auth";
export default defineNuxtRouteMiddleware(async (to) => {
// 只保护 /dashboard 路径
if (!to.path.startsWith("/dashboard")) return;
// SSR 阶段无浏览器 cookiesession 由 auth.client.ts 插件在客户端初始化
// 服务端直接放行,客户端插件执行完毕后 middleware 会带着正确状态再次运行
if (import.meta.server) return;
const authStore = useAuthStore();
// 等待 session 初始化完成(插件已发起,此处复用同一 Promise 去重)
if (!authStore.initialized) {
await authStore.fetchSession();
}
if (!authStore.isOnline) {
return navigateTo("/login");
}
});
+54
View File
@@ -1,11 +1,25 @@
<!-- app/pages/dashboard.vue - 运行状态与最近问答记录页面 -->
<script lang="ts" setup>
import { ArrowPathIcon } from "@heroicons/vue/24/outline";
import QaRecordDetail from "@/components/dashboard/QaRecordDetail.vue";
import QaRecordsTable from "@/components/dashboard/QaRecordsTable.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 { useAuthStore } from "@/stores/auth";
definePageMeta({ middleware: "auth" });
const answerStore = useAnswerStore();
const authStore = useAuthStore();
useHead({
title: "Dashboard - OCS AI 答题服务"
@@ -22,10 +36,50 @@ if (import.meta.client) {
void answerStore.loadDashboard();
});
}
const refreshing = ref(false);
const handleRefreshToken = async () => {
refreshing.value = true;
await authStore.refreshApiToken();
refreshing.value = false;
};
</script>
<template>
<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" />
<Button
variant="outline"
size="icon"
:disabled="refreshing"
title="刷新 Token"
@click="handleRefreshToken"
>
<ArrowPathIcon
:class="['size-4', refreshing ? 'animate-spin' : '']"
/>
</Button>
</div>
</CardContent>
</Card>
<StatsOverview />
<QaRecordsTable />
<QaRecordDetail />
+112
View File
@@ -0,0 +1,112 @@
<!-- app/pages/login.vue - 邮箱密码登录页 -->
<script lang="ts" setup>
import { toTypedSchema } from "@vee-validate/zod";
import { useForm } from "vee-validate";
import * as z from "zod";
import { Button } from "@/components/ui/button";
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { useAuthStore } from "@/stores/auth";
useHead({ title: "登录 - OCS AI 答题服务" });
const authStore = useAuthStore();
// 已登录用户访问登录页,直接跳首页
watchEffect(() => {
if (authStore.isOnline) navigateTo("/");
});
const formSchema = toTypedSchema(
z.object({
email: z
.string({ required_error: "邮箱不能为空" })
.min(1, "邮箱不能为空")
.email("请输入有效的邮箱地址"),
password: z.string({ required_error: "密码不能为空" }).min(1, "密码不能为空")
})
);
const { handleSubmit, isSubmitting } = useForm({
validationSchema: formSchema
});
const onSubmit = handleSubmit(async (values) => {
const ok = await authStore.signIn(values.email, values.password);
if (ok) {
await navigateTo("/");
}
});
</script>
<template>
<div class="flex min-h-[60vh] items-center justify-center">
<div class="w-full max-w-sm space-y-6">
<div class="space-y-1 text-center">
<h1 class="text-2xl font-semibold">登录</h1>
<p class="text-sm text-muted-foreground">使用邮箱和密码登录账号</p>
</div>
<form class="space-y-4" @submit="onSubmit">
<FormField v-slot="{ componentField }" name="email">
<FormItem>
<FormLabel>邮箱</FormLabel>
<FormControl>
<Input
type="email"
autocomplete="email"
placeholder="your@email.com"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="password">
<FormItem>
<FormLabel>密码</FormLabel>
<FormControl>
<Input
type="password"
autocomplete="current-password"
placeholder="••••••••"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<p v-if="authStore.error" class="text-sm text-destructive">
{{ authStore.error }}
</p>
<Button
type="submit"
:disabled="isSubmitting || authStore.loading || !authStore.initialized"
class="w-full"
>
{{ authStore.loading ? "登录中..." : "登录" }}
</Button>
</form>
<p class="text-center text-sm text-muted-foreground">
还没有账号
<NuxtLink
to="/register"
class="underline underline-offset-4 hover:text-primary"
>
注册
</NuxtLink>
</p>
</div>
</div>
</template>
+136
View File
@@ -0,0 +1,136 @@
<!-- app/pages/register.vue - 新用户注册页 -->
<script lang="ts" setup>
import { toTypedSchema } from "@vee-validate/zod";
import { useForm } from "vee-validate";
import * as z from "zod";
import { Button } from "@/components/ui/button";
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { useAuthStore } from "@/stores/auth";
useHead({ title: "注册 - OCS AI 答题服务" });
const authStore = useAuthStore();
// 已登录用户访问注册页,直接跳首页
watchEffect(() => {
if (authStore.isOnline) navigateTo("/");
});
const formSchema = toTypedSchema(
z.object({
name: z
.string({ required_error: "昵称不能为空" })
.min(1, "昵称不能为空")
.max(20, "昵称不能超过 20 个字符"),
email: z
.string({ required_error: "邮箱不能为空" })
.min(1, "邮箱不能为空")
.email("请输入有效的邮箱地址"),
password: z
.string({ required_error: "密码不能为空" })
.min(8, "密码至少 8 位")
.max(100, "密码不能超过 100 位")
.regex(/[a-zA-Z]/, "密码必须包含英文字母")
.regex(/[0-9]/, "密码必须包含数字")
})
);
const { handleSubmit, isSubmitting } = useForm({
validationSchema: formSchema
});
const onSubmit = handleSubmit(async (values) => {
const ok = await authStore.signUp(values.name, values.email, values.password);
if (ok) {
await navigateTo("/");
}
});
</script>
<template>
<div class="flex min-h-[60vh] items-center justify-center">
<div class="w-full max-w-sm space-y-6">
<div class="space-y-1 text-center">
<h1 class="text-2xl font-semibold">注册</h1>
<p class="text-sm text-muted-foreground">创建新账号</p>
</div>
<form class="space-y-4" @submit="onSubmit">
<FormField v-slot="{ componentField }" name="name">
<FormItem>
<FormLabel>昵称</FormLabel>
<FormControl>
<Input
type="text"
autocomplete="name"
placeholder="你的昵称"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="email">
<FormItem>
<FormLabel>邮箱</FormLabel>
<FormControl>
<Input
type="email"
autocomplete="email"
placeholder="your@email.com"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="password">
<FormItem>
<FormLabel>密码</FormLabel>
<FormControl>
<Input
type="password"
autocomplete="new-password"
placeholder="至少 8 位"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<p v-if="authStore.error" class="text-sm text-destructive">
{{ authStore.error }}
</p>
<Button
type="submit"
:disabled="isSubmitting || authStore.loading || !authStore.initialized"
class="w-full"
>
{{ authStore.loading ? "注册中..." : "注册" }}
</Button>
</form>
<p class="text-center text-sm text-muted-foreground">
已有账号
<NuxtLink
to="/login"
class="underline underline-offset-4 hover:text-primary"
>
登录
</NuxtLink>
</p>
</div>
</div>
</template>
+6
View File
@@ -0,0 +1,6 @@
// app/plugins/auth.client.ts - 客户端启动时拉取一次 session,供全局复用
// .client.ts 后缀确保只在浏览器端执行,避免 SSR 阶段因无 cookie 而误判未登录
export default defineNuxtPlugin(async () => {
const authStore = useAuthStore();
await authStore.fetchSession();
});
+165
View File
@@ -0,0 +1,165 @@
// app/stores/auth.ts - 用户登录状态管理
import { defineStore } from "pinia";
import { authClient } from "@/utils/auth";
/** 当前登录用户信息,apiToken 用于 OCS 油猴脚本配置 */
interface AuthUser {
id: string;
name: string;
email: string;
/** 油猴脚本使用此 token 在 body 中传递身份 */
apiToken?: string | null;
}
export const useAuthStore = defineStore("auth", () => {
const user = ref<AuthUser | null>(null);
const loading = ref(false);
const error = ref("");
/** session 是否已完成首次加载,用于区分"未登录"和"尚未检查" */
const initialized = ref(false);
/** 已登录且 session 已初始化完成 */
const isOnline = computed(() => initialized.value && user.value !== null);
// 用于对并发调用去重,避免同一时刻发起多次 get-session 请求
let _fetchPromise: Promise<void> | null = null;
/** 从服务端拉取当前 session;已在请求中时复用同一 Promise,已初始化时直接跳过 */
const fetchSession = async () => {
if (initialized.value) return;
if (_fetchPromise) return _fetchPromise;
_fetchPromise = (async () => {
loading.value = true;
error.value = "";
try {
const session = await authClient.getSession();
user.value = session.data?.user
? {
id: session.data.user.id,
name: session.data.user.name,
email: session.data.user.email,
apiToken: (session.data.user as { apiToken?: string | null })
.apiToken
}
: null;
} catch {
user.value = null;
} finally {
loading.value = false;
initialized.value = true;
_fetchPromise = null;
}
})();
return _fetchPromise;
};
/** 从 better-auth 返回的 user 对象中提取并写入 store */
const _setUserFromResult = (u: {
id: string;
name: string;
email: string;
apiToken?: string | null;
}) => {
user.value = {
id: u.id,
name: u.name,
email: u.email,
apiToken: u.apiToken ?? null
};
initialized.value = true;
};
/** 邮箱密码登录 */
const signIn = async (email: string, password: string) => {
loading.value = true;
error.value = "";
try {
const result = await authClient.signIn.email({ email, password });
if (result.error) {
error.value = "邮箱或密码错误";
return false;
}
// 直接使用登录响应中的用户数据,无需重复请求 get-session
if (result.data?.user) {
_setUserFromResult(
result.data.user as Parameters<typeof _setUserFromResult>[0]
);
}
return true;
} catch {
error.value = "登录失败,请重试";
return false;
} finally {
loading.value = false;
}
};
/** 注册新账号 */
const signUp = async (name: string, email: string, password: string) => {
loading.value = true;
error.value = "";
try {
const result = await authClient.signUp.email({ name, email, password });
if (result.error) {
error.value = "注册失败,邮箱可能已被使用";
return false;
}
if (result.data?.user) {
_setUserFromResult(
result.data.user as Parameters<typeof _setUserFromResult>[0]
);
}
return true;
} catch {
error.value = "注册失败,请重试";
return false;
} finally {
loading.value = false;
}
};
/** 退出登录 */
const signOut = async () => {
await authClient.signOut();
user.value = null;
initialized.value = false;
};
/** 刷新当前用户的 apiToken;成功后同步更新 store */
const refreshApiToken = async (): Promise<boolean> => {
loading.value = true;
error.value = "";
try {
const res = await $fetch<{ success: boolean; msg: string; apiToken?: string }>(
"/api/user/refresh-token",
{ method: "POST" }
);
if (res.success && res.apiToken && user.value) {
user.value = { ...user.value, apiToken: res.apiToken };
return true;
}
error.value = res.msg || "刷新失败";
return false;
} catch {
error.value = "刷新失败,请重试";
return false;
} finally {
loading.value = false;
}
};
return {
user,
loading,
error,
initialized,
isOnline,
fetchSession,
signIn,
signUp,
signOut,
refreshApiToken
};
});
+4
View File
@@ -0,0 +1,4 @@
// app/utils/auth.ts - Better Auth 前端 Vue 客户端
import { createAuthClient } from "better-auth/vue";
export const authClient = createAuthClient();
+19
View File
@@ -0,0 +1,19 @@
// app/utils/clipboard.ts - 剪贴板操作工具
const legacyCopy = (text: string) => {
const input = document.createElement("input");
input.value = text;
document.body.appendChild(input);
input.select();
document.execCommand("copy");
document.body.removeChild(input);
};
/** 复制文本,优先使用 Clipboard API,降级使用 execCommand */
export const copy = (text: string): void => {
try {
void navigator.clipboard.writeText(text);
} catch {
legacyCopy(text);
}
};
+1
View File
@@ -1 +1,2 @@
export * from "./clipboard";
export * from "./sortableTable";
+4
View File
@@ -2,6 +2,7 @@
import tsParser from '@typescript-eslint/parser'
import simpleImportSort from 'eslint-plugin-simple-import-sort'
import vueParser from 'vue-eslint-parser'
import withNuxt from './.nuxt/eslint.config.mjs'
export default withNuxt(
@@ -51,6 +52,9 @@ export default withNuxt(
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'vue/html-self-closing': ['error', { html: { void: 'any' } }],
'no-unused-vars': 'warn',
'@typescript-eslint/no-unused-vars': 'warn',
},
},
)
+5 -1
View File
@@ -17,8 +17,10 @@
"@prisma/client": "7.8.0",
"@tailwindcss/vite": "^4.3.0",
"@tanstack/vue-table": "^8.21.3",
"@vee-validate/zod": "^4.15.1",
"@vueuse/core": "^14.3.0",
"@vueuse/nuxt": "14.3.0",
"better-auth": "^1.6.11",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.20",
@@ -33,9 +35,11 @@
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.0",
"tw-animate-css": "^1.4.0",
"vee-validate": "^4.15.1",
"vite-svg-loader": "^5.1.1",
"vue": "^3.5.34",
"vue-router": "^5.0.7"
"vue-router": "^5.0.7",
"zod": "3.25.76"
},
"devDependencies": {
"@typescript-eslint/parser": "^8.59.4",
+323
View File
@@ -29,12 +29,18 @@ importers:
'@tanstack/vue-table':
specifier: ^8.21.3
version: 8.21.3(vue@3.5.34(typescript@6.0.3))
'@vee-validate/zod':
specifier: ^4.15.1
version: 4.15.1(vue@3.5.34(typescript@6.0.3))(zod@3.25.76)
'@vueuse/core':
specifier: ^14.3.0
version: 14.3.0(vue@3.5.34(typescript@6.0.3))
'@vueuse/nuxt':
specifier: 14.3.0
version: 14.3.0(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0))(@electric-sql/pglite@0.4.1)(@parcel/watcher@2.5.6)(@types/node@24.12.4)(@vue/compiler-sfc@3.5.34)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(eslint@10.4.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(mysql2@3.15.3)(optionator@0.9.4)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(srvx@0.11.15)(stylus@0.57.0)(terser@5.47.1)(typescript@6.0.3)(vite@7.3.3(@types/node@24.12.4)(jiti@2.7.0)(lightningcss@1.32.0)(stylus@0.57.0)(terser@5.47.1)(yaml@2.9.0))(yaml@2.9.0))(vue@3.5.34(typescript@6.0.3))
better-auth:
specifier: ^1.6.11
version: 1.6.11(@prisma/client@7.8.0(prisma@7.8.0(@types/react@19.2.15)(magicast@0.5.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3))(typescript@6.0.3))(mysql2@3.15.3)(prisma@7.8.0(@types/react@19.2.15)(magicast@0.5.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vue@3.5.34(typescript@6.0.3))
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -77,6 +83,9 @@ importers:
tw-animate-css:
specifier: ^1.4.0
version: 1.4.0
vee-validate:
specifier: ^4.15.1
version: 4.15.1(vue@3.5.34(typescript@6.0.3))
vite-svg-loader:
specifier: ^5.1.1
version: 5.1.1(vue@3.5.34(typescript@6.0.3))
@@ -86,6 +95,9 @@ importers:
vue-router:
specifier: ^5.0.7
version: 5.0.7(@vue/compiler-sfc@3.5.34)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)))(vue@3.5.34(typescript@6.0.3))
zod:
specifier: 3.25.76
version: 3.25.76
devDependencies:
'@typescript-eslint/parser':
specifier: ^8.59.4
@@ -272,6 +284,85 @@ packages:
resolution: {integrity: sha512-JeSVu/m8x/zpp4CLjYHVNXuhEyOkhPXuxM8YOXjh6L4LlvQNKuUNOTo5KdBuKAcTDHw8DquToTaEkhsBqPXOaA==}
engines: {node: ^22.18.0 || >=24.11.0}
'@better-auth/core@1.6.11':
resolution: {integrity: sha512-LrwidLCV8azdMGjvtwp30nj9tIv1BwI3VhtC0UaGSjQkAVWw4bN42I8qwbxRziPeSQoj+zUVkOpxZzAWBDARtQ==}
peerDependencies:
'@better-auth/utils': 0.4.0
'@better-fetch/fetch': 1.1.21
'@cloudflare/workers-types': '>=4'
'@opentelemetry/api': ^1.9.0
better-call: 1.3.5
jose: ^6.1.0
kysely: ^0.28.5
nanostores: ^1.0.1
peerDependenciesMeta:
'@cloudflare/workers-types':
optional: true
'@opentelemetry/api':
optional: true
'@better-auth/drizzle-adapter@1.6.11':
resolution: {integrity: sha512-4jpkETIGZOHCf7BK4jnu22fdN6jjomH0/HhEzkaWy3+Eppi5PYlHTF/460jrTmA3Xc+Vqwp9t282ymHiEPypGw==}
peerDependencies:
'@better-auth/core': ^1.6.11
'@better-auth/utils': 0.4.0
drizzle-orm: ^0.45.2
peerDependenciesMeta:
drizzle-orm:
optional: true
'@better-auth/kysely-adapter@1.6.11':
resolution: {integrity: sha512-/g8M9RfIjdcZDnbstSUvQiINkvdNlCeZr248zwqx2/PVksQI1MhQofbzUn3RnQnbPKp0EPwpX/dR3oudRFenUg==}
peerDependencies:
'@better-auth/core': ^1.6.11
'@better-auth/utils': 0.4.0
kysely: ^0.28.17
peerDependenciesMeta:
kysely:
optional: true
'@better-auth/memory-adapter@1.6.11':
resolution: {integrity: sha512-hpdfw0BBf8MuzLkIdmbcUZICbY9r/bhLO2RxSnkzT5+/O+0I0u2I8+m0YUP7vNllP/ZCKASHOYgXPLO75Z0f9Q==}
peerDependencies:
'@better-auth/core': ^1.6.11
'@better-auth/utils': 0.4.0
'@better-auth/mongo-adapter@1.6.11':
resolution: {integrity: sha512-3Tor8rSv8vSEIMEaV2PFpPEuVhqc1gNoZ6eGvoh3LwExXXuj8madew6ob+H1pH7Aphn3Ar5PQ08AguT8TbwFAA==}
peerDependencies:
'@better-auth/core': ^1.6.11
'@better-auth/utils': 0.4.0
mongodb: ^6.0.0 || ^7.0.0
peerDependenciesMeta:
mongodb:
optional: true
'@better-auth/prisma-adapter@1.6.11':
resolution: {integrity: sha512-Pw+7q7zTp+VSci1V+CYMvuxIbAeVMZLe4lRo46LJoAKMHfjFl5T/ycsyFvWs/DkWC7n9gZZzRDEbHp0I5FiKKw==}
peerDependencies:
'@better-auth/core': ^1.6.11
'@better-auth/utils': 0.4.0
'@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0
prisma: ^5.0.0 || ^6.0.0 || ^7.0.0
peerDependenciesMeta:
'@prisma/client':
optional: true
prisma:
optional: true
'@better-auth/telemetry@1.6.11':
resolution: {integrity: sha512-hsjDHc8MZbm6/AHeNdtywrWedXevnBjmdvnHTcZub+rTVjOv+Td0roI8USKuC6uUibmrl//2rJfVCsGbopihNA==}
peerDependencies:
'@better-auth/core': ^1.6.11
'@better-auth/utils': 0.4.0
'@better-fetch/fetch': 1.1.21
'@better-auth/utils@0.4.0':
resolution: {integrity: sha512-RpMtLUIQAEWMgdPLNVbIF5ON2mm+CH0U3rCdUCU1VyeAUui4m38DyK7/aXMLZov2YDjG684pS1D0MBllrmgjQA==}
'@better-fetch/fetch@1.1.21':
resolution: {integrity: sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==}
'@bomb.sh/tab@0.0.15':
resolution: {integrity: sha512-Y90ub44TAvbdO9P8mcD/XPyQjFhiR5xmd4Fk7JErmWmEWEUimNnjWiBrVZ16Tj3GA1rLZ+uvCN2V/pzLawv31g==}
hasBin: true
@@ -836,6 +927,10 @@ packages:
resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==}
engines: {node: ^14.21.3 || >=16}
'@noble/ciphers@2.2.0':
resolution: {integrity: sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA==}
engines: {node: '>= 20.19.0'}
'@noble/curves@1.9.7':
resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==}
engines: {node: ^14.21.3 || >=16}
@@ -844,6 +939,10 @@ packages:
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
engines: {node: ^14.21.3 || >=16}
'@noble/hashes@2.2.0':
resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==}
engines: {node: '>= 20.19.0'}
'@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
@@ -969,6 +1068,10 @@ packages:
rollup-plugin-visualizer:
optional: true
'@opentelemetry/semantic-conventions@1.41.1':
resolution: {integrity: sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==}
engines: {node: '>=14'}
'@oxc-minify/binding-android-arm-eabi@0.131.0':
resolution: {integrity: sha512-yLa7y9jjJgUeUUMm6AtjmBIQzK1YU5sYcNJnVVtr6WtoWu5SpuNDZ8u6cl/dhn0g/oQgVlf+E+8WJfsExt8R+Q==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -2289,6 +2392,11 @@ packages:
cpu: [x64]
os: [win32]
'@vee-validate/zod@4.15.1':
resolution: {integrity: sha512-329Z4TDBE5Vx0FdbA8S4eR9iGCFFUNGbxjpQ20ff5b5wGueScjocUIx9JHPa79LTG06RnlUR4XogQsjN4tecKA==}
peerDependencies:
zod: ^3.24.0
'@vercel/nft@1.5.0':
resolution: {integrity: sha512-IWTDeIoWhQ7ZtRO/JRKH+jhmeQvZYhtGPmzw/QGDY+wDCQqfm25P9yIdoAFagu4fWsK4IwZXDFIjrmp5rRm/sA==}
engines: {node: '>=20'}
@@ -2597,6 +2705,76 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
better-auth@1.6.11:
resolution: {integrity: sha512-Wwt6+q07dwIhsp6XiM7L1qSXVUWBEtNl+eZvwM778CguFqDZFBN9Pt6LtFaHl55t8Z+Zc//5kxcbgDY8/79vFQ==}
peerDependencies:
'@lynx-js/react': '*'
'@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0
'@sveltejs/kit': ^2.0.0
'@tanstack/react-start': ^1.0.0
'@tanstack/solid-start': ^1.0.0
better-sqlite3: ^12.0.0
drizzle-kit: '>=0.31.4'
drizzle-orm: ^0.45.2
mongodb: ^6.0.0 || ^7.0.0
mysql2: ^3.0.0
next: ^14.0.0 || ^15.0.0 || ^16.0.0
pg: ^8.0.0
prisma: ^5.0.0 || ^6.0.0 || ^7.0.0
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
solid-js: ^1.0.0
svelte: ^4.0.0 || ^5.0.0
vitest: ^2.0.0 || ^3.0.0 || ^4.0.0
vue: ^3.0.0
peerDependenciesMeta:
'@lynx-js/react':
optional: true
'@prisma/client':
optional: true
'@sveltejs/kit':
optional: true
'@tanstack/react-start':
optional: true
'@tanstack/solid-start':
optional: true
better-sqlite3:
optional: true
drizzle-kit:
optional: true
drizzle-orm:
optional: true
mongodb:
optional: true
mysql2:
optional: true
next:
optional: true
pg:
optional: true
prisma:
optional: true
react:
optional: true
react-dom:
optional: true
solid-js:
optional: true
svelte:
optional: true
vitest:
optional: true
vue:
optional: true
better-call@1.3.5:
resolution: {integrity: sha512-kOFJkBP7utAQLEYrobZm3vkTH8mXq5GNgvjc5/XEST1ilVHaxXUXfeDeFlqoETMtyqS4+3/h4ONX2i++ebZrvA==}
peerDependencies:
zod: ^4.0.0
peerDependenciesMeta:
zod:
optional: true
better-result@2.9.2:
resolution: {integrity: sha512-WIFoBPCdnTOdk9inkE1ZRvCZ4P0CpSkAiLlchC65N7n9DcjZ3NhqkBOlafzpOVnO8ixyi37kicmSJ3ENhPZl7Q==}
@@ -3896,6 +4074,10 @@ packages:
knitwork@1.3.0:
resolution: {integrity: sha512-4LqMNoONzR43B1W0ek0fhXMsDNW/zxa1NdFAVMY+k28pgZLovR4G3PB5MrpTxCy1QaZCqNoiaKPr5w5qZHfSNw==}
kysely@0.28.17:
resolution: {integrity: sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q==}
engines: {node: '>=20.0.0'}
launch-editor@2.13.2:
resolution: {integrity: sha512-4VVDnbOpLXy/s8rdRCSXb+zfMeFR0WlJWpET1iA9CQdlZDfwyLjUuGQzXU4VeOoey6AicSAluWan7Etga6Kcmg==}
@@ -4191,6 +4373,10 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
nanostores@1.3.0:
resolution: {integrity: sha512-XPUa/jz+P1oJvN9VBxw4L9MtdFfaH3DAryqPssqhb2kXjmb9npz0dly6rCsgFWOPr4Yg9mTfM3MDZgZZ+7A3lA==}
engines: {node: ^20.0.0 || >=22.0.0}
nanotar@0.3.0:
resolution: {integrity: sha512-Kv2JYYiCzt16Kt5QwAc9BFG89xfPNBx+oQL4GQXD9nLqPkZBiNaqaCWtwnbk/q7UVsTYevvM1b0UF8zmEI4pCg==}
@@ -4895,6 +5081,9 @@ packages:
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
rou3@0.7.12:
resolution: {integrity: sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==}
rou3@0.8.1:
resolution: {integrity: sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA==}
@@ -4966,6 +5155,9 @@ packages:
resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
engines: {node: '>= 18'}
set-cookie-parser@3.1.0:
resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==}
setprototypeof@1.2.0:
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
@@ -5483,6 +5675,11 @@ packages:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'}
vee-validate@4.15.1:
resolution: {integrity: sha512-DkFsiTwEKau8VIxyZBGdO6tOudD+QoUBPuHj3e6QFqmbfCRj1ArmYWue9lEp6jLSWBIw4XPlDLjFIZNLdRAMSg==}
peerDependencies:
vue: ^3.4.26
vite-dev-rpc@1.1.0:
resolution: {integrity: sha512-pKXZlgoXGoE8sEKiKJSng4hI1sQ4wi5YT24FCrwrLt6opmkjlqPPVmiPWWJn8M8byMxRGzp1CrFuqQs4M/Z39A==}
peerDependencies:
@@ -5780,6 +5977,9 @@ packages:
zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
zod@4.4.3:
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
snapshots:
'@antfu/install-pkg@1.1.0':
@@ -6002,6 +6202,60 @@ snapshots:
'@babel/helper-string-parser': 8.0.0-rc.5
'@babel/helper-validator-identifier': 8.0.0-rc.5
'@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0)':
dependencies:
'@better-auth/utils': 0.4.0
'@better-fetch/fetch': 1.1.21
'@opentelemetry/semantic-conventions': 1.41.1
'@standard-schema/spec': 1.1.0
better-call: 1.3.5(zod@4.4.3)
jose: 6.2.3
kysely: 0.28.17
nanostores: 1.3.0
zod: 4.4.3
'@better-auth/drizzle-adapter@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)':
dependencies:
'@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0)
'@better-auth/utils': 0.4.0
'@better-auth/kysely-adapter@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(kysely@0.28.17)':
dependencies:
'@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0)
'@better-auth/utils': 0.4.0
optionalDependencies:
kysely: 0.28.17
'@better-auth/memory-adapter@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)':
dependencies:
'@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0)
'@better-auth/utils': 0.4.0
'@better-auth/mongo-adapter@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)':
dependencies:
'@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0)
'@better-auth/utils': 0.4.0
'@better-auth/prisma-adapter@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@prisma/client@7.8.0(prisma@7.8.0(@types/react@19.2.15)(magicast@0.5.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3))(typescript@6.0.3))(prisma@7.8.0(@types/react@19.2.15)(magicast@0.5.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3))':
dependencies:
'@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0)
'@better-auth/utils': 0.4.0
optionalDependencies:
'@prisma/client': 7.8.0(prisma@7.8.0(@types/react@19.2.15)(magicast@0.5.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3))(typescript@6.0.3)
prisma: 7.8.0(@types/react@19.2.15)(magicast@0.5.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3)
'@better-auth/telemetry@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)':
dependencies:
'@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0)
'@better-auth/utils': 0.4.0
'@better-fetch/fetch': 1.1.21
'@better-auth/utils@0.4.0':
dependencies:
'@noble/hashes': 2.2.0
'@better-fetch/fetch@1.1.21': {}
'@bomb.sh/tab@0.0.15(cac@6.7.14)(citty@0.2.2)':
optionalDependencies:
cac: 6.7.14
@@ -6450,12 +6704,16 @@ snapshots:
'@noble/ciphers@1.3.0': {}
'@noble/ciphers@2.2.0': {}
'@noble/curves@1.9.7':
dependencies:
'@noble/hashes': 1.8.0
'@noble/hashes@1.8.0': {}
'@noble/hashes@2.2.0': {}
'@nodelib/fs.scandir@2.1.5':
dependencies:
'@nodelib/fs.stat': 2.0.5
@@ -6834,6 +7092,8 @@ snapshots:
- vue-tsc
- yaml
'@opentelemetry/semantic-conventions@1.41.1': {}
'@oxc-minify/binding-android-arm-eabi@0.131.0':
optional: true
@@ -7787,6 +8047,14 @@ snapshots:
'@unrs/resolver-binding-win32-x64-msvc@1.12.2':
optional: true
'@vee-validate/zod@4.15.1(vue@3.5.34(typescript@6.0.3))(zod@3.25.76)':
dependencies:
type-fest: 4.41.0
vee-validate: 4.15.1(vue@3.5.34(typescript@6.0.3))
zod: 3.25.76
transitivePeerDependencies:
- vue
'@vercel/nft@1.5.0(rollup@4.60.4)':
dependencies:
'@mapbox/node-pre-gyp': 2.0.3
@@ -8147,6 +8415,45 @@ snapshots:
baseline-browser-mapping@2.10.31: {}
better-auth@1.6.11(@prisma/client@7.8.0(prisma@7.8.0(@types/react@19.2.15)(magicast@0.5.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3))(typescript@6.0.3))(mysql2@3.15.3)(prisma@7.8.0(@types/react@19.2.15)(magicast@0.5.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vue@3.5.34(typescript@6.0.3)):
dependencies:
'@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0)
'@better-auth/drizzle-adapter': 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)
'@better-auth/kysely-adapter': 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(kysely@0.28.17)
'@better-auth/memory-adapter': 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)
'@better-auth/mongo-adapter': 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)
'@better-auth/prisma-adapter': 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@prisma/client@7.8.0(prisma@7.8.0(@types/react@19.2.15)(magicast@0.5.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3))(typescript@6.0.3))(prisma@7.8.0(@types/react@19.2.15)(magicast@0.5.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3))
'@better-auth/telemetry': 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)
'@better-auth/utils': 0.4.0
'@better-fetch/fetch': 1.1.21
'@noble/ciphers': 2.2.0
'@noble/hashes': 2.2.0
better-call: 1.3.5(zod@4.4.3)
defu: 6.1.7
jose: 6.2.3
kysely: 0.28.17
nanostores: 1.3.0
zod: 4.4.3
optionalDependencies:
'@prisma/client': 7.8.0(prisma@7.8.0(@types/react@19.2.15)(magicast@0.5.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3))(typescript@6.0.3)
mysql2: 3.15.3
prisma: 7.8.0(@types/react@19.2.15)(magicast@0.5.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3)
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
vue: 3.5.34(typescript@6.0.3)
transitivePeerDependencies:
- '@cloudflare/workers-types'
- '@opentelemetry/api'
better-call@1.3.5(zod@4.4.3):
dependencies:
'@better-auth/utils': 0.4.0
'@better-fetch/fetch': 1.1.21
rou3: 0.7.12
set-cookie-parser: 3.1.0
optionalDependencies:
zod: 4.4.3
better-result@2.9.2: {}
bindings@1.5.0:
@@ -9464,6 +9771,8 @@ snapshots:
knitwork@1.3.0: {}
kysely@0.28.17: {}
launch-editor@2.13.2:
dependencies:
picocolors: 1.1.1
@@ -9734,6 +10043,8 @@ snapshots:
nanoid@3.3.12: {}
nanostores@1.3.0: {}
nanotar@0.3.0: {}
napi-postinstall@0.3.4: {}
@@ -10707,6 +11018,8 @@ snapshots:
'@rollup/rollup-win32-x64-msvc': 4.60.4
fsevents: 2.3.3
rou3@0.7.12: {}
rou3@0.8.1: {}
router@2.2.0:
@@ -10784,6 +11097,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
set-cookie-parser@3.1.0: {}
setprototypeof@1.2.0: {}
shadcn-nuxt@2.7.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(magicast@0.5.3):
@@ -11368,6 +11683,12 @@ snapshots:
vary@1.1.2: {}
vee-validate@4.15.1(vue@3.5.34(typescript@6.0.3)):
dependencies:
'@vue/devtools-api': 7.7.9
type-fest: 4.41.0
vue: 3.5.34(typescript@6.0.3)
vite-dev-rpc@1.1.0(vite@7.3.3(@types/node@24.12.4)(jiti@2.7.0)(lightningcss@1.32.0)(stylus@0.57.0)(terser@5.47.1)(yaml@2.9.0)):
dependencies:
birpc: 2.9.0
@@ -11667,3 +11988,5 @@ snapshots:
zod: 3.25.76
zod@3.25.76: {}
zod@4.4.3: {}
+77 -15
View File
@@ -1,7 +1,5 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
// Get a free hosted Postgres database in seconds: `npx create-db`
// prisma/schema.prisma - OCS 答题服务数据库 schema
// 包含 Better Auth 认证表(User, Session, Account, Verification)和业务表(QaRecord
generator client {
provider = "prisma-client"
@@ -12,18 +10,82 @@ datasource db {
provider = "mysql"
}
// Better Auth 标准用户表
// apiToken 由服务端注册 hook 生成,供 OCS 油猴脚本跨域身份验证
model User {
id Int @id @default(autoincrement())
email String @unique
name String?
posts Post[]
id String @id
name String
email String @unique
emailVerified Boolean
image String?
createdAt DateTime
updatedAt DateTime
apiToken String? @unique
sessions Session[]
accounts Account[]
qaRecords QaRecord[]
@@map("user")
}
model Post {
id Int @id @default(autoincrement())
title String
content String?
published Boolean @default(false)
author User @relation(fields: [authorId], references: [id])
authorId Int
// Better Auth 会话表
model Session {
id String @id
expiresAt DateTime
token String @unique
createdAt DateTime
updatedAt DateTime
ipAddress String?
userAgent String?
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("session")
}
// Better Auth 账号表(用于邮密和 OAuth Provider 关联)
model Account {
id String @id
accountId String
providerId String
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
accessToken String?
refreshToken String?
idToken String? @db.Text
accessTokenExpiresAt DateTime?
refreshTokenExpiresAt DateTime?
scope String?
password String?
createdAt DateTime
updatedAt DateTime
@@map("account")
}
// Better Auth 邮箱验证令牌表
model Verification {
id String @id
identifier String
value String
expiresAt DateTime
createdAt DateTime?
updatedAt DateTime?
@@map("verification")
}
// 用户问答记录,按用户隔离存储
// search 接口在拿到有效 session 或 apiToken 后写入,与当前登录用户绑定
model QaRecord {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
question String @db.Text
type String
options String? @db.Text
answer String? @db.Text
createdAt DateTime @default(now())
@@map("qa_record")
}
+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 };
});
+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
};
};
+41
View File
@@ -0,0 +1,41 @@
{
"version": 1,
"skills": {
"better-auth-best-practices": {
"source": "better-auth/skills",
"sourceType": "github",
"skillPath": "better-auth/best-practices/SKILL.md",
"computedHash": "9ab075b5061be2a5f299c10505667345cc1ec76e8de4120901cfd586643e776f"
},
"better-auth-security-best-practices": {
"source": "better-auth/skills",
"sourceType": "github",
"skillPath": "security/SKILL.md",
"computedHash": "ed38caef4b297cc399d717258d6ea4e6ecb8894c453c83450064c7e7c6e5cc02"
},
"create-auth-skill": {
"source": "better-auth/skills",
"sourceType": "github",
"skillPath": "better-auth/create-auth/SKILL.md",
"computedHash": "393e8d2d795fa5797c9a4e0665f29183ea2e140233db59746d510340a10456e6"
},
"email-and-password-best-practices": {
"source": "better-auth/skills",
"sourceType": "github",
"skillPath": "better-auth/emailAndPassword/SKILL.md",
"computedHash": "7786d722fa682b3d6a99793e73a9d51b59becefa595676134a993020633e068f"
},
"organization-best-practices": {
"source": "better-auth/skills",
"sourceType": "github",
"skillPath": "better-auth/organization/SKILL.md",
"computedHash": "fa7a76e45a1f9632e6d63e92b476cf566e64f6e93a2c8ee00bc77b4a74008fbd"
},
"two-factor-authentication-best-practices": {
"source": "better-auth/skills",
"sourceType": "github",
"skillPath": "better-auth/twoFactor/SKILL.md",
"computedHash": "c4f3a299c62c1985b774b7c28e53b7e55c565dbf973fd52b08899e4bcfffa3f9"
}
}
}