From 7d68db723b3cb142b12be4898ab50eb07b959278 Mon Sep 17 00:00:00 2001 From: Marcus <1922576605@qq.com> Date: Sat, 23 May 2026 13:45:58 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8E=A5=E5=8F=A3=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- _write_runtime.cjs | 74 --------------- app/components/layout/AppHeader.vue | 4 +- app/interfaces/answer.ts | 128 ++++---------------------- app/services/answer_service.ts | 5 +- app/stores/answer.ts | 65 ++++++------- app/stores/auth.ts | 13 +-- app/utils/fetch.ts | 17 ++++ app/utils/format.ts | 23 +++-- app/utils/index.ts | 1 + server/api/cache/clear.post.ts | 25 +++-- server/api/health.get.ts | 7 +- server/api/records.get.ts | 37 ++++---- server/api/stats.get.ts | 21 +++-- server/api/user/refresh-token.post.ts | 5 +- server/middleware/api-auth.ts | 6 +- server/utils/answer.ts | 22 ++--- server/utils/api-auth-rules.ts | 9 +- server/utils/response.ts | 16 ++++ shared/types/answer.ts | 124 +++++++++++++++++++++++++ shared/types/index.ts | 2 + 20 files changed, 302 insertions(+), 302 deletions(-) delete mode 100644 _write_runtime.cjs create mode 100644 app/utils/fetch.ts create mode 100644 server/utils/response.ts create mode 100644 shared/types/answer.ts create mode 100644 shared/types/index.ts diff --git a/_write_runtime.cjs b/_write_runtime.cjs deleted file mode 100644 index 412a10c..0000000 --- a/_write_runtime.cjs +++ /dev/null @@ -1,74 +0,0 @@ -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'); diff --git a/app/components/layout/AppHeader.vue b/app/components/layout/AppHeader.vue index 3e5421e..deae797 100644 --- a/app/components/layout/AppHeader.vue +++ b/app/components/layout/AppHeader.vue @@ -17,9 +17,9 @@ import { useAuthStore } from "@/stores/auth"; const colorMode = useColorMode(); const isDark = computed(() => colorMode.value === "dark"); -function toggleColorMode() { +const toggleColorMode = () => { colorMode.value = isDark.value ? "light" : "dark"; -} +}; const authStore = useAuthStore(); diff --git a/app/interfaces/answer.ts b/app/interfaces/answer.ts index df2018b..1ed16e8 100644 --- a/app/interfaces/answer.ts +++ b/app/interfaces/answer.ts @@ -1,112 +1,16 @@ -// app/interfaces/answer.ts - 前端和本地 API 之间使用的响应类型 - -/** OCS 支持的题型;空字符串表示不指定题型,交给模型根据题目判断 */ -export type QuestionType = - | "" - | "single" - | "multiple" - | "judgement" - | "completion"; - -/** 答题接口请求体,字段名保持旧 Python 服务和 OCS AnswererWrapper 兼容 */ -export interface ISearchRequest { - /** 题目正文,必填 */ - title: string; - /** 题型,可能为空字符串 */ - type: QuestionType | string; - /** 选项文本,多行或 JSON 字符串均按旧逻辑透传给服务端解析 */ - options: string; -} - -/** 答题成功时返回给 OCS 和前端的结构 */ -export interface ISearchSuccessResponse { - /** OCS 约定:1 表示找到答案 */ - code: 1; - /** 原题目正文 */ - question: string; - /** 最终答案;多选题继续使用 `#` 分隔 */ - answer: string; -} - -/** 答题失败时返回给 OCS 和前端的结构 */ -export interface ISearchErrorResponse { - /** OCS 约定:0 表示失败 */ - code: 0; - /** 本地安全错误文案,不包含上游细节 */ - msg: string; -} - -/** 答题接口联合响应 */ -export type ISearchResponse = ISearchSuccessResponse | ISearchErrorResponse; - -/** 健康检查响应,只暴露非敏感运行信息 */ -export interface IHealthResponse { - /** 服务状态,正常时为 ok */ - status: "ok"; - /** 本地状态文案 */ - message: string; - /** 服务版本 */ - version: string; - /** 当前模型名,不包含 API Key 或 baseURL */ - model: string; -} - -/** 服务统计响应 */ -export interface IStatsResponse { - /** 服务版本 */ - version: string; - /** 进程运行秒数 */ - uptime: number; - /** 当前模型名 */ - model: string; - /** 当前用户的问答记录总数 */ - qa_records_count: number; -} - -/** 最近问答记录,字段来自 server/utils/runtimeState.ts */ -export interface IQaRecord { - /** 本地可读时间 */ - time: string; - /** ISO 时间,方便排序和调试 */ - timestamp: string; - /** 题目正文 */ - question: string; - /** 题型 */ - type: string; - /** 选项文本 */ - options: string; - /** 最终返回给 OCS 的答案 */ - answer: string; -} - -/** 最近问答记录分页请求 */ -export interface IRecordsRequest { - /** 当前页码,从 1 开始 */ - page: number; - /** 每页数量 */ - size: number; -} - -/** 最近问答记录接口响应 */ -export interface IRecordsResponse { - /** 管理接口是否成功 */ - success: boolean; - /** 失败时的本地安全文案 */ - message?: string; - /** 当前页问答记录,最新在前 */ - records: IQaRecord[]; - /** 当前页码,从 1 开始 */ - page: number; - /** 每页数量 */ - size: number; - /** 当前进程内问答记录总数 */ - total: number; -} - -/** 清空缓存接口响应 */ -export interface IClearCacheResponse { - /** 是否清理成功;缓存关闭时为 false */ - success: boolean; - /** 本地状态文案 */ - message: string; -} +// app/interfaces/answer.ts - 从 shared/types/answer.ts 重新导出,前端直接使用 @/interfaces 路径无需改动 +export type { + IApiResponse, + IClearCacheResponse, + IHealthResponse, + IQaRecord, + IRecordsData, + IRecordsRequest, + IRecordsResponse, + ISearchErrorResponse, + ISearchRequest, + ISearchResponse, + ISearchSuccessResponse, + IStatsResponse, + QuestionType +} from "~~/shared/types/answer"; diff --git a/app/services/answer_service.ts b/app/services/answer_service.ts index eb780d0..d652530 100644 --- a/app/services/answer_service.ts +++ b/app/services/answer_service.ts @@ -1,5 +1,6 @@ // app/services/answer_service.ts - OCS AI 答题服务相关 API import type { + IApiResponse, IClearCacheResponse, IHealthResponse, IRecordsRequest, @@ -27,14 +28,14 @@ export class AnswerService { /** 读取健康状态;无需 token */ public static getHealth() { - return BaseClientService.get( + return BaseClientService.get>( `${AnswerService.basePath}/health` ); } /** 读取运行统计(需登录) */ public static getStats() { - return BaseClientService.get( + return BaseClientService.get>( `${AnswerService.basePath}/stats` ); } diff --git a/app/stores/answer.ts b/app/stores/answer.ts index 91eefab..e30d7a1 100644 --- a/app/stores/answer.ts +++ b/app/stores/answer.ts @@ -9,6 +9,7 @@ import type { QuestionType } from "@/interfaces"; import { AnswerService } from "@/services"; +import { formatUptime, getClientErrorMessage } from "~/utils"; /** Dashboard 最近记录表格每页数量,组件直接复用,避免多个地方写死 */ export const DASHBOARD_RECORD_PAGE_SIZE = 10; @@ -37,55 +38,44 @@ export const QUESTION_TYPE_LABELS: Record = { completion: "填空题" }; -/** 从 `$fetch` 错误里提取安全文案;服务端已经保证 message/msg 不含敏感上游细节 */ -const getClientErrorMessage = (error: unknown, fallback: string) => { - if (typeof error !== "object" || error === null) return fallback; - - const data = "data" in error ? (error as { data?: unknown }).data : undefined; - if (typeof data === "object" && data !== null) { - const message = (data as { message?: unknown; msg?: unknown }).message; - const msg = (data as { message?: unknown; msg?: unknown }).msg; - - if (typeof message === "string" && message) return message; - if (typeof msg === "string" && msg) return msg; - } - - return fallback; -}; - -/** 把运行秒数格式化为 dashboard 更容易扫读的时长 */ -const formatUptime = (seconds: number) => { - const safeSeconds = Math.max(0, Math.floor(seconds)); - const days = Math.floor(safeSeconds / 86_400); - const hours = Math.floor((safeSeconds % 86_400) / 3_600); - const minutes = Math.floor((safeSeconds % 3_600) / 60); - - if (days > 0) return `${days} 天 ${hours} 小时`; - if (hours > 0) return `${hours} 小时 ${minutes} 分钟`; - return `${Math.max(1, minutes)} 分钟`; -}; - /** OCS AI 答题服务前端状态管理 */ export const useAnswerStore = defineStore("answer", () => { // ---- 答题表单状态 ---- + /** 当前问题内容 */ const question = ref(""); + /** 当前问题类型 */ const questionType = ref(""); + /** 当前问题选项 */ const options = ref(""); + /** 答题请求加载状态 */ const searchLoading = ref(false); + /** 答题结果 */ const answerResult = ref(); + /** 答题错误文案 */ const searchErrorMessage = ref(""); // ---- Dashboard 状态 ---- + /** 健康检查信息 */ const health = ref(); + /** 运行统计信息 */ const stats = ref(); + /** 最近问答记录 */ const records = ref([]); + /** Dashboard 错误文案 */ const dashboardErrorMessage = ref(""); + /** 统计信息加载状态 */ const statsLoading = ref(false); + /** 最近问答记录加载状态 */ const recordsLoading = ref(false); + /** 缓存清理状态 */ const cacheClearing = ref(false); + /** 最近问答记录当前页码 */ const recordsPage = ref(1); + /** 最近问答记录每页数量 */ const recordsPageSize = ref(DASHBOARD_RECORD_PAGE_SIZE); + /** 最近问答记录总数 */ const recordCount = ref(0); + /** 当前选中的问答记录 */ const selectedRecord = ref(); /** Dashboard 表格当前页数据,页码切换会重新请求服务端分页接口 */ @@ -154,7 +144,7 @@ export const useAnswerStore = defineStore("answer", () => { const getHealth = async () => { try { const res = await AnswerService.getHealth(); - health.value = res.data; + health.value = res.data.data; } catch (error) { dashboardErrorMessage.value = getClientErrorMessage( error, @@ -168,7 +158,7 @@ export const useAnswerStore = defineStore("answer", () => { statsLoading.value = true; try { const res = await AnswerService.getStats(); - stats.value = res.data; + stats.value = res.data.data; } catch (error) { dashboardErrorMessage.value = getClientErrorMessage( error, @@ -187,10 +177,10 @@ export const useAnswerStore = defineStore("answer", () => { page, size: recordsPageSize.value }); - records.value = res.data.records; - recordsPage.value = res.data.page; - recordsPageSize.value = res.data.size; - recordCount.value = res.data.total; + records.value = res.data.data.records; + recordsPage.value = res.data.data.page; + recordsPageSize.value = res.data.data.size; + recordCount.value = res.data.data.total; } catch (error) { dashboardErrorMessage.value = getClientErrorMessage( error, @@ -207,16 +197,13 @@ export const useAnswerStore = defineStore("answer", () => { await Promise.allSettled([getHealth(), getStats(), getRecords()]); }; - /** 清空缓存和问答记录后刷新统计与表格 */ + /** 清空缓存后刷新统计与表格;$fetch 非 2xx 时会抛出,错误由 catch 处理 */ const clearCache = async () => { cacheClearing.value = true; dashboardErrorMessage.value = ""; try { - const res = await AnswerService.clearCache(); - if (!res.data.success) { - dashboardErrorMessage.value = res.data.message; - } + await AnswerService.clearCache(); await Promise.allSettled([getStats(), getRecords(1)]); } catch (error) { dashboardErrorMessage.value = getClientErrorMessage( diff --git a/app/stores/auth.ts b/app/stores/auth.ts index de4b046..34285be 100644 --- a/app/stores/auth.ts +++ b/app/stores/auth.ts @@ -132,12 +132,13 @@ export const useAuthStore = defineStore("auth", () => { 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 }; + const res = await $fetch<{ + code: number; + msg: string; + data: { apiToken: string } | null; + }>("/api/user/refresh-token", { method: "POST" }); + if (res.code === 0 && res.data?.apiToken && user.value) { + user.value = { ...user.value, apiToken: res.data.apiToken }; return true; } error.value = res.msg || "刷新失败"; diff --git a/app/utils/fetch.ts b/app/utils/fetch.ts new file mode 100644 index 0000000..f08cafe --- /dev/null +++ b/app/utils/fetch.ts @@ -0,0 +1,17 @@ +// app/utils/fetch.ts - $fetch 请求工具 + +/** 从 `$fetch` 错误里提取安全文案;服务端已经保证 message/msg 不含敏感上游细节 */ +export const getClientErrorMessage = (error: unknown, fallback: string) => { + if (typeof error !== "object" || error === null) return fallback; + + const data = "data" in error ? (error as { data?: unknown }).data : undefined; + if (typeof data === "object" && data !== null) { + const message = (data as { message?: unknown; msg?: unknown }).message; + const msg = (data as { message?: unknown; msg?: unknown }).msg; + + if (typeof message === "string" && message) return message; + if (typeof msg === "string" && msg) return msg; + } + + return fallback; +}; diff --git a/app/utils/format.ts b/app/utils/format.ts index 39ae075..ec28b16 100644 --- a/app/utils/format.ts +++ b/app/utils/format.ts @@ -6,19 +6,18 @@ * 输入:`A.\n错\nB.\n对` * 输出:`A. 错\nB. 对` */ -export function formatOptions(options: string): string { +export const formatOptions = (options: string): string => // 匹配类似 "A." 开头的选项标记,将其后紧跟的换行内容合并到同一行 - return options.replace(/^([A-Za-z]\.)\s*\n\s*/gm, "$1 "); -} + options.replace(/^([A-Za-z]\.)\s*\n\s*/gm, "$1 "); /** * 将选项字符串拆分为逐行结构,标记哪些行是正确答案。 * answer 按 # 分隔(多选),与选项正文做等值匹配(大小写不敏感)。 */ -export function getOptionLines( +export const getOptionLines = ( options: string, answer: string -): { text: string; isCorrect: boolean }[] { +): { text: string; isCorrect: boolean }[] => { const segments = answer .split("#") .map((s) => s.trim().toUpperCase()) @@ -33,4 +32,16 @@ export function getOptionLines( const isCorrect = content !== "" && segments.includes(content); return { text: line, isCorrect }; }); -} +}; + +/** 把运行秒数格式化为 dashboard 更容易扫读的时长 */ +export const formatUptime = (seconds: number) => { + const safeSeconds = Math.max(0, Math.floor(seconds)); + const days = Math.floor(safeSeconds / 86_400); + const hours = Math.floor((safeSeconds % 86_400) / 3_600); + const minutes = Math.floor((safeSeconds % 3_600) / 60); + + if (days > 0) return `${days} 天 ${hours} 小时`; + if (hours > 0) return `${hours} 小时 ${minutes} 分钟`; + return `${Math.max(1, minutes)} 分钟`; +}; diff --git a/app/utils/index.ts b/app/utils/index.ts index edd6f06..ffa5d70 100644 --- a/app/utils/index.ts +++ b/app/utils/index.ts @@ -1,3 +1,4 @@ export * from "./clipboard"; +export * from "./fetch"; export * from "./format"; export * from "./sortableTable"; diff --git a/server/api/cache/clear.post.ts b/server/api/cache/clear.post.ts index 1ece541..ecab6e9 100644 --- a/server/api/cache/clear.post.ts +++ b/server/api/cache/clear.post.ts @@ -1,6 +1,9 @@ // server/api/cache/clear.post.ts - 清空当前用户 DB 缓存接口 +import { setResponseStatus } from "h3"; + import { prisma } from "~~/server/utils/db"; import { createApiLogger } from "~~/server/utils/logging"; +import { apiErr, apiOk } from "~~/server/utils/response"; /** * 清缓存:将当前用户的 cacheClearedAt 更新为当前时间 @@ -12,15 +15,17 @@ export default defineEventHandler(async (event) => { const logger = createApiLogger(event, "api.cache.clear"); const userId = event.context.auth!.user.id; - await prisma.user.update({ - where: { id: userId }, - data: { cacheClearedAt: new Date() } - }); + try { + await prisma.user.update({ + where: { id: userId }, + data: { cacheClearedAt: new Date() } + }); - logger.info("finish_success"); - - return { - success: true, - message: "缓存已清除" - }; + logger.info("finish_success"); + return apiOk(null); + } catch (err) { + logger.error("db_error", { userId, err: String(err) }); + setResponseStatus(event, 500); + return apiErr(500, "服务器内部错误"); + } }); diff --git a/server/api/health.get.ts b/server/api/health.get.ts index a468456..4d63e64 100644 --- a/server/api/health.get.ts +++ b/server/api/health.get.ts @@ -1,5 +1,6 @@ // server/api/health.get.ts - 服务健康检查接口 import { serverEnv } from "~~/server/utils/env"; +import { apiOk } from "~~/server/utils/response"; import { SERVICE_VERSION } from "~~/server/utils/runtimeState"; /** @@ -9,10 +10,10 @@ import { SERVICE_VERSION } from "~~/server/utils/runtimeState"; * 返回模型名,但不返回 API Key、baseURL 或其他敏感配置 */ export default defineEventHandler(() => { - return { - status: "ok", + return apiOk({ + status: "ok" as const, message: "AI题库服务运行正常", version: SERVICE_VERSION, model: serverEnv.openAiModel - }; + }); }); diff --git a/server/api/records.get.ts b/server/api/records.get.ts index eec392c..42030fb 100644 --- a/server/api/records.get.ts +++ b/server/api/records.get.ts @@ -1,7 +1,8 @@ // server/api/records.get.ts - 用户问答记录接口,供 Nuxt Dashboard 表格展示 -import { getQuery } from "h3"; +import { getQuery, setResponseStatus } from "h3"; import { createApiLogger } from "~~/server/utils/logging"; +import { apiErr, apiOk } from "~~/server/utils/response"; import { getQaRecords } from "~~/server/utils/runtimeState"; /** @@ -23,23 +24,23 @@ export default defineEventHandler(async (event) => { const size = ALLOWED_SIZES.has(rawSize) ? rawSize : 10; const page = Number.isFinite(rawPage) ? Math.max(rawPage, 1) : 1; - const { records, total } = await getQaRecords(event.context.auth!.user.id, { - page, - size - }); + try { + const { records, total } = await getQaRecords(event.context.auth!.user.id, { + page, + size + }); - logger.info("finish_success", { - page, - size, - total, - count: records.length - }); + logger.info("finish_success", { + page, + size, + total, + count: records.length + }); - return { - success: true, - records, - page, - size, - total - }; + return apiOk({ records, page, size, total }); + } catch (err) { + logger.error("db_error", { err: String(err) }); + setResponseStatus(event, 500); + return apiErr(500, "服务器内部错误"); + } }); diff --git a/server/api/stats.get.ts b/server/api/stats.get.ts index b7a379f..8c2abd7 100644 --- a/server/api/stats.get.ts +++ b/server/api/stats.get.ts @@ -1,6 +1,9 @@ // server/api/stats.get.ts - 服务运行统计接口 +import { setResponseStatus } from "h3"; + import { prisma } from "~~/server/utils/db"; import { createApiLogger } from "~~/server/utils/logging"; +import { apiErr, apiOk } from "~~/server/utils/response"; import { getRuntimeStats } from "~~/server/utils/runtimeState"; /** @@ -13,11 +16,17 @@ export default defineEventHandler(async (event) => { const logger = createApiLogger(event, "api.stats"); const userId = event.context.auth!.user.id; - const [runtimeStats, qa_records_count] = await Promise.all([ - Promise.resolve(getRuntimeStats()), - prisma.qaRecord.count({ where: { userId } }) - ]); + try { + const [runtimeStats, qa_records_count] = await Promise.all([ + Promise.resolve(getRuntimeStats()), + prisma.qaRecord.count({ where: { userId } }) + ]); - logger.info("finish_success"); - return { ...runtimeStats, qa_records_count }; + logger.info("finish_success"); + return apiOk({ ...runtimeStats, qa_records_count }); + } catch (err) { + logger.error("db_error", { userId, err: String(err) }); + setResponseStatus(event, 500); + return apiErr(500, "服务器内部错误"); + } }); diff --git a/server/api/user/refresh-token.post.ts b/server/api/user/refresh-token.post.ts index 37ab498..2c56622 100644 --- a/server/api/user/refresh-token.post.ts +++ b/server/api/user/refresh-token.post.ts @@ -6,6 +6,7 @@ import { setResponseStatus } from "h3"; import { prisma } from "~~/server/utils/db"; import { createApiLogger } from "~~/server/utils/logging"; +import { apiErr, apiOk } from "~~/server/utils/response"; export default defineEventHandler(async (event) => { const logger = createApiLogger(event, "api.user.refresh-token"); @@ -21,9 +22,9 @@ export default defineEventHandler(async (event) => { } catch (err) { logger.error("db_error", { userId, err: String(err) }); setResponseStatus(event, 500); - return { success: false, msg: "服务器内部错误" }; + return apiErr(500, "服务器内部错误"); } logger.info("finish_success", { userId }); - return { success: true, msg: "刷新成功", apiToken: newToken }; + return apiOk({ apiToken: newToken }); }); diff --git a/server/middleware/api-auth.ts b/server/middleware/api-auth.ts index 05bfc5b..46cd845 100644 --- a/server/middleware/api-auth.ts +++ b/server/middleware/api-auth.ts @@ -1,8 +1,9 @@ // server/middleware/api-auth.ts - 统一 API 鉴权层,所有非公开 /api/* 请求都必须通过此处 -import { createError, getRequestURL } from "h3"; +import { getRequestURL, setResponseStatus } from "h3"; import { isPublicApiRoute } from "~~/server/utils/api-auth-rules"; import { getAuthSession } from "~~/server/utils/auth"; +import { apiErr } from "~~/server/utils/response"; /** * 统一 API 鉴权 middleware @@ -27,7 +28,8 @@ export default defineEventHandler(async (event) => { const session = await getAuthSession(event); if (!session) { - throw createError({ statusCode: 401, statusMessage: "未登录" }); + setResponseStatus(event, 401); + return apiErr(401, "unauthorized"); } // 挂到 event.context,后续 handler 直接读取,不再重复查询 session diff --git a/server/utils/answer.ts b/server/utils/answer.ts index b5efe24..b9a552d 100644 --- a/server/utils/answer.ts +++ b/server/utils/answer.ts @@ -1,4 +1,8 @@ // server/utils/answer.ts - OCS 答题提示词构建、答案清洗和响应格式化 +import type { + ISearchErrorResponse, + ISearchSuccessResponse +} from "~~/shared/types/answer"; /** `/api/search` 从 OCS 请求中最终抽取出的标准参数 */ export interface SearchParams { @@ -10,21 +14,9 @@ export interface SearchParams { options: string; } -/** OCS AnswererWrapper handler 期望的成功响应结构 */ -export interface OcsSuccessResponse { - code: 1; - /** 返回原题,handler 会将它作为匹配题目展示 */ - question: string; - /** 返回最终答案;多选题必须是 `#` 分隔字符串,而不是数组 */ - answer: string; -} - -/** OCS AnswererWrapper handler 期望的失败响应结构 */ -export interface OcsErrorResponse { - code: 0; - /** 只放本地业务文案,不暴露上游或内部异常详情 */ - msg: string; -} +/** OCS 兼容响应类型别名,与 shared/types/answer.ts 保持一致 */ +export type OcsSuccessResponse = ISearchSuccessResponse; +export type OcsErrorResponse = ISearchErrorResponse; /** * 不同题型对应的补充说明 diff --git a/server/utils/api-auth-rules.ts b/server/utils/api-auth-rules.ts index b58b18f..5ad9a8c 100644 --- a/server/utils/api-auth-rules.ts +++ b/server/utils/api-auth-rules.ts @@ -23,18 +23,18 @@ export const publicApiRoutes: ApiRouteRule[] = [ ]; /** 路径匹配:支持精确匹配、`/**` 前缀通配和 RegExp */ -function matchPath(rulePath: string | RegExp, pathname: string): boolean { +const matchPath = (rulePath: string | RegExp, pathname: string): boolean => { if (rulePath instanceof RegExp) return rulePath.test(pathname); if (rulePath.endsWith("/**")) { const prefix = rulePath.slice(0, -3); return pathname === prefix || pathname.startsWith(`${prefix}/`); } return pathname === rulePath; -} +}; /** 判断当前请求是否命中公开路由规则 */ -export function isPublicApiRoute(pathname: string, method: string): boolean { - return publicApiRoutes.some((rule) => { +export const isPublicApiRoute = (pathname: string, method: string): boolean => + publicApiRoutes.some((rule) => { const methods = Array.isArray(rule.method) ? rule.method : rule.method @@ -43,4 +43,3 @@ export function isPublicApiRoute(pathname: string, method: string): boolean { const methodMatched = !methods || methods.includes(method as HttpMethod); return methodMatched && matchPath(rule.path, pathname); }); -} diff --git a/server/utils/response.ts b/server/utils/response.ts new file mode 100644 index 0000000..0b0de14 --- /dev/null +++ b/server/utils/response.ts @@ -0,0 +1,16 @@ +// server/utils/response.ts - 统一 API 响应工具函数(非 OCS 接口专用) +// OCS 搜题接口 /api/search 有固定的兼容格式,不使用此工具 + +/** 成功响应:code 0,data 为业务数据,msg 固定为"请求成功" */ +export const apiOk = (data: T) => ({ + code: 0 as const, + data, + msg: "请求成功" +}); + +/** 错误响应:data 固定为 null,msg 为本地安全文案,不暴露上游细节 */ +export const apiErr = (code: number, msg: string) => ({ + code, + data: null, + msg +}); diff --git a/shared/types/answer.ts b/shared/types/answer.ts new file mode 100644 index 0000000..2e4ad12 --- /dev/null +++ b/shared/types/answer.ts @@ -0,0 +1,124 @@ +// shared/types/answer.ts - 前后端通用的 API 契约类型 + +// ───────────────────────────────────────────────────────────── +// OCS AnswererWrapper 兼容类型(/api/search 接口格式固定,不可随意改动) +// ───────────────────────────────────────────────────────────── + +/** OCS 支持的题型;空字符串表示不指定题型,交给模型根据题目判断 */ +export type QuestionType = + | "" + | "single" + | "multiple" + | "judgement" + | "completion"; + +/** 答题接口请求体,字段名保持旧 Python 服务和 OCS AnswererWrapper 兼容 */ +export interface ISearchRequest { + /** 题目正文,必填 */ + title: string; + /** 题型,可能为空字符串 */ + type: QuestionType | string; + /** 选项文本,多行或 JSON 字符串均按旧逻辑透传给服务端解析 */ + options: string; +} + +/** OCS AnswererWrapper handler 期望的成功响应结构 */ +export interface ISearchSuccessResponse { + /** OCS 约定:1 表示找到答案 */ + code: 1; + /** 原题目正文 */ + question: string; + /** 最终答案;多选题继续使用 `#` 分隔 */ + answer: string; +} + +/** OCS AnswererWrapper handler 期望的失败响应结构 */ +export interface ISearchErrorResponse { + /** OCS 约定:0 表示失败 */ + code: 0; + /** 本地安全错误文案,不包含上游细节 */ + msg: string; +} + +/** 答题接口联合响应 */ +export type ISearchResponse = ISearchSuccessResponse | ISearchErrorResponse; + +// ───────────────────────────────────────────────────────────── +// 内部管理接口类型(Dashboard、统计、缓存等,与 OCS 格式无关) +// ───────────────────────────────────────────────────────────── + +/** 统一 API 响应包装类型(非 OCS 接口专用) */ +export interface IApiResponse { + /** 状态码:0 成功,其他为错误码 */ + code: number; + /** 返回数据,成功时为业务数据,无数据时为 null */ + data: T; + /** 提示信息,成功时为"请求成功" */ + msg: string; +} + +/** 健康检查响应,只暴露非敏感运行信息 */ +export interface IHealthResponse { + /** 服务状态,正常时为 ok */ + status: "ok"; + /** 本地状态文案 */ + message: string; + /** 服务版本 */ + version: string; + /** 当前模型名,不包含 API Key 或 baseURL */ + model: string; +} + +/** 服务统计响应 */ +export interface IStatsResponse { + /** 服务版本 */ + version: string; + /** 进程运行秒数 */ + uptime: number; + /** 当前模型名 */ + model: string; + /** 当前用户的问答记录总数 */ + qa_records_count: number; +} + +/** 最近问答记录,字段来自 Prisma QaRecord */ +export interface IQaRecord { + /** 本地可读时间 */ + time: string; + /** ISO 时间,方便排序和调试 */ + timestamp: string; + /** 题目正文 */ + question: string; + /** 题型 */ + type: string; + /** 选项文本 */ + options: string; + /** 最终返回给 OCS 的答案 */ + answer: string; +} + +/** 最近问答记录分页请求 */ +export interface IRecordsRequest { + /** 当前页码,从 1 开始 */ + page: number; + /** 每页数量 */ + size: number; +} + +/** 最近问答记录分页数据(IRecordsResponse 的 data 内层) */ +export interface IRecordsData { + /** 当前页问答记录,最新在前 */ + records: IQaRecord[]; + /** 当前页码,从 1 开始 */ + page: number; + /** 每页数量 */ + size: number; + /** 当前进程内问答记录总数 */ + total: number; +} + +/** 最近问答记录接口响应 */ +export type IRecordsResponse = IApiResponse; + +/** 清空缓存接口响应 */ +export type IClearCacheResponse = IApiResponse; diff --git a/shared/types/index.ts b/shared/types/index.ts new file mode 100644 index 0000000..9cce720 --- /dev/null +++ b/shared/types/index.ts @@ -0,0 +1,2 @@ +// shared/types/index.ts - 前后端通用类型统一出口 +export * from "./answer";