feat: 接口统一优化
This commit is contained in:
@@ -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');
|
|
||||||
@@ -17,9 +17,9 @@ import { useAuthStore } from "@/stores/auth";
|
|||||||
const colorMode = useColorMode();
|
const colorMode = useColorMode();
|
||||||
const isDark = computed(() => colorMode.value === "dark");
|
const isDark = computed(() => colorMode.value === "dark");
|
||||||
|
|
||||||
function toggleColorMode() {
|
const toggleColorMode = () => {
|
||||||
colorMode.value = isDark.value ? "light" : "dark";
|
colorMode.value = isDark.value ? "light" : "dark";
|
||||||
}
|
};
|
||||||
|
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
|
|||||||
+16
-112
@@ -1,112 +1,16 @@
|
|||||||
// app/interfaces/answer.ts - 前端和本地 API 之间使用的响应类型
|
// app/interfaces/answer.ts - 从 shared/types/answer.ts 重新导出,前端直接使用 @/interfaces 路径无需改动
|
||||||
|
export type {
|
||||||
/** OCS 支持的题型;空字符串表示不指定题型,交给模型根据题目判断 */
|
IApiResponse,
|
||||||
export type QuestionType =
|
IClearCacheResponse,
|
||||||
| ""
|
IHealthResponse,
|
||||||
| "single"
|
IQaRecord,
|
||||||
| "multiple"
|
IRecordsData,
|
||||||
| "judgement"
|
IRecordsRequest,
|
||||||
| "completion";
|
IRecordsResponse,
|
||||||
|
ISearchErrorResponse,
|
||||||
/** 答题接口请求体,字段名保持旧 Python 服务和 OCS AnswererWrapper 兼容 */
|
ISearchRequest,
|
||||||
export interface ISearchRequest {
|
ISearchResponse,
|
||||||
/** 题目正文,必填 */
|
ISearchSuccessResponse,
|
||||||
title: string;
|
IStatsResponse,
|
||||||
/** 题型,可能为空字符串 */
|
QuestionType
|
||||||
type: QuestionType | string;
|
} from "~~/shared/types/answer";
|
||||||
/** 选项文本,多行或 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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// app/services/answer_service.ts - OCS AI 答题服务相关 API
|
// app/services/answer_service.ts - OCS AI 答题服务相关 API
|
||||||
import type {
|
import type {
|
||||||
|
IApiResponse,
|
||||||
IClearCacheResponse,
|
IClearCacheResponse,
|
||||||
IHealthResponse,
|
IHealthResponse,
|
||||||
IRecordsRequest,
|
IRecordsRequest,
|
||||||
@@ -27,14 +28,14 @@ export class AnswerService {
|
|||||||
|
|
||||||
/** 读取健康状态;无需 token */
|
/** 读取健康状态;无需 token */
|
||||||
public static getHealth() {
|
public static getHealth() {
|
||||||
return BaseClientService.get<IHealthResponse>(
|
return BaseClientService.get<IApiResponse<IHealthResponse>>(
|
||||||
`${AnswerService.basePath}/health`
|
`${AnswerService.basePath}/health`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 读取运行统计(需登录) */
|
/** 读取运行统计(需登录) */
|
||||||
public static getStats() {
|
public static getStats() {
|
||||||
return BaseClientService.get<IStatsResponse>(
|
return BaseClientService.get<IApiResponse<IStatsResponse>>(
|
||||||
`${AnswerService.basePath}/stats`
|
`${AnswerService.basePath}/stats`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-39
@@ -9,6 +9,7 @@ import type {
|
|||||||
QuestionType
|
QuestionType
|
||||||
} from "@/interfaces";
|
} from "@/interfaces";
|
||||||
import { AnswerService } from "@/services";
|
import { AnswerService } from "@/services";
|
||||||
|
import { formatUptime, getClientErrorMessage } from "~/utils";
|
||||||
|
|
||||||
/** Dashboard 最近记录表格每页数量,组件直接复用,避免多个地方写死 */
|
/** Dashboard 最近记录表格每页数量,组件直接复用,避免多个地方写死 */
|
||||||
export const DASHBOARD_RECORD_PAGE_SIZE = 10;
|
export const DASHBOARD_RECORD_PAGE_SIZE = 10;
|
||||||
@@ -37,55 +38,44 @@ export const QUESTION_TYPE_LABELS: Record<string, string> = {
|
|||||||
completion: "填空题"
|
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 答题服务前端状态管理 */
|
/** OCS AI 答题服务前端状态管理 */
|
||||||
export const useAnswerStore = defineStore("answer", () => {
|
export const useAnswerStore = defineStore("answer", () => {
|
||||||
// ---- 答题表单状态 ----
|
// ---- 答题表单状态 ----
|
||||||
|
/** 当前问题内容 */
|
||||||
const question = ref("");
|
const question = ref("");
|
||||||
|
/** 当前问题类型 */
|
||||||
const questionType = ref<QuestionType>("");
|
const questionType = ref<QuestionType>("");
|
||||||
|
/** 当前问题选项 */
|
||||||
const options = ref("");
|
const options = ref("");
|
||||||
|
/** 答题请求加载状态 */
|
||||||
const searchLoading = ref(false);
|
const searchLoading = ref(false);
|
||||||
|
/** 答题结果 */
|
||||||
const answerResult = ref<ISearchSuccessResponse>();
|
const answerResult = ref<ISearchSuccessResponse>();
|
||||||
|
/** 答题错误文案 */
|
||||||
const searchErrorMessage = ref("");
|
const searchErrorMessage = ref("");
|
||||||
|
|
||||||
// ---- Dashboard 状态 ----
|
// ---- Dashboard 状态 ----
|
||||||
|
/** 健康检查信息 */
|
||||||
const health = ref<IHealthResponse>();
|
const health = ref<IHealthResponse>();
|
||||||
|
/** 运行统计信息 */
|
||||||
const stats = ref<IStatsResponse>();
|
const stats = ref<IStatsResponse>();
|
||||||
|
/** 最近问答记录 */
|
||||||
const records = ref<IQaRecord[]>([]);
|
const records = ref<IQaRecord[]>([]);
|
||||||
|
/** Dashboard 错误文案 */
|
||||||
const dashboardErrorMessage = ref("");
|
const dashboardErrorMessage = ref("");
|
||||||
|
/** 统计信息加载状态 */
|
||||||
const statsLoading = ref(false);
|
const statsLoading = ref(false);
|
||||||
|
/** 最近问答记录加载状态 */
|
||||||
const recordsLoading = ref(false);
|
const recordsLoading = ref(false);
|
||||||
|
/** 缓存清理状态 */
|
||||||
const cacheClearing = ref(false);
|
const cacheClearing = ref(false);
|
||||||
|
/** 最近问答记录当前页码 */
|
||||||
const recordsPage = ref(1);
|
const recordsPage = ref(1);
|
||||||
|
/** 最近问答记录每页数量 */
|
||||||
const recordsPageSize = ref(DASHBOARD_RECORD_PAGE_SIZE);
|
const recordsPageSize = ref(DASHBOARD_RECORD_PAGE_SIZE);
|
||||||
|
/** 最近问答记录总数 */
|
||||||
const recordCount = ref(0);
|
const recordCount = ref(0);
|
||||||
|
/** 当前选中的问答记录 */
|
||||||
const selectedRecord = ref<IQaRecord>();
|
const selectedRecord = ref<IQaRecord>();
|
||||||
|
|
||||||
/** Dashboard 表格当前页数据,页码切换会重新请求服务端分页接口 */
|
/** Dashboard 表格当前页数据,页码切换会重新请求服务端分页接口 */
|
||||||
@@ -154,7 +144,7 @@ export const useAnswerStore = defineStore("answer", () => {
|
|||||||
const getHealth = async () => {
|
const getHealth = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await AnswerService.getHealth();
|
const res = await AnswerService.getHealth();
|
||||||
health.value = res.data;
|
health.value = res.data.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
dashboardErrorMessage.value = getClientErrorMessage(
|
dashboardErrorMessage.value = getClientErrorMessage(
|
||||||
error,
|
error,
|
||||||
@@ -168,7 +158,7 @@ export const useAnswerStore = defineStore("answer", () => {
|
|||||||
statsLoading.value = true;
|
statsLoading.value = true;
|
||||||
try {
|
try {
|
||||||
const res = await AnswerService.getStats();
|
const res = await AnswerService.getStats();
|
||||||
stats.value = res.data;
|
stats.value = res.data.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
dashboardErrorMessage.value = getClientErrorMessage(
|
dashboardErrorMessage.value = getClientErrorMessage(
|
||||||
error,
|
error,
|
||||||
@@ -187,10 +177,10 @@ export const useAnswerStore = defineStore("answer", () => {
|
|||||||
page,
|
page,
|
||||||
size: recordsPageSize.value
|
size: recordsPageSize.value
|
||||||
});
|
});
|
||||||
records.value = res.data.records;
|
records.value = res.data.data.records;
|
||||||
recordsPage.value = res.data.page;
|
recordsPage.value = res.data.data.page;
|
||||||
recordsPageSize.value = res.data.size;
|
recordsPageSize.value = res.data.data.size;
|
||||||
recordCount.value = res.data.total;
|
recordCount.value = res.data.data.total;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
dashboardErrorMessage.value = getClientErrorMessage(
|
dashboardErrorMessage.value = getClientErrorMessage(
|
||||||
error,
|
error,
|
||||||
@@ -207,16 +197,13 @@ export const useAnswerStore = defineStore("answer", () => {
|
|||||||
await Promise.allSettled([getHealth(), getStats(), getRecords()]);
|
await Promise.allSettled([getHealth(), getStats(), getRecords()]);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 清空缓存和问答记录后刷新统计与表格 */
|
/** 清空缓存后刷新统计与表格;$fetch 非 2xx 时会抛出,错误由 catch 处理 */
|
||||||
const clearCache = async () => {
|
const clearCache = async () => {
|
||||||
cacheClearing.value = true;
|
cacheClearing.value = true;
|
||||||
dashboardErrorMessage.value = "";
|
dashboardErrorMessage.value = "";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await AnswerService.clearCache();
|
await AnswerService.clearCache();
|
||||||
if (!res.data.success) {
|
|
||||||
dashboardErrorMessage.value = res.data.message;
|
|
||||||
}
|
|
||||||
await Promise.allSettled([getStats(), getRecords(1)]);
|
await Promise.allSettled([getStats(), getRecords(1)]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
dashboardErrorMessage.value = getClientErrorMessage(
|
dashboardErrorMessage.value = getClientErrorMessage(
|
||||||
|
|||||||
+7
-6
@@ -132,12 +132,13 @@ export const useAuthStore = defineStore("auth", () => {
|
|||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = "";
|
error.value = "";
|
||||||
try {
|
try {
|
||||||
const res = await $fetch<{ success: boolean; msg: string; apiToken?: string }>(
|
const res = await $fetch<{
|
||||||
"/api/user/refresh-token",
|
code: number;
|
||||||
{ method: "POST" }
|
msg: string;
|
||||||
);
|
data: { apiToken: string } | null;
|
||||||
if (res.success && res.apiToken && user.value) {
|
}>("/api/user/refresh-token", { method: "POST" });
|
||||||
user.value = { ...user.value, apiToken: res.apiToken };
|
if (res.code === 0 && res.data?.apiToken && user.value) {
|
||||||
|
user.value = { ...user.value, apiToken: res.data.apiToken };
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
error.value = res.msg || "刷新失败";
|
error.value = res.msg || "刷新失败";
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
+17
-6
@@ -6,19 +6,18 @@
|
|||||||
* 输入:`A.\n错\nB.\n对`
|
* 输入:`A.\n错\nB.\n对`
|
||||||
* 输出:`A. 错\nB. 对`
|
* 输出:`A. 错\nB. 对`
|
||||||
*/
|
*/
|
||||||
export function formatOptions(options: string): string {
|
export const formatOptions = (options: string): string =>
|
||||||
// 匹配类似 "A." 开头的选项标记,将其后紧跟的换行内容合并到同一行
|
// 匹配类似 "A." 开头的选项标记,将其后紧跟的换行内容合并到同一行
|
||||||
return options.replace(/^([A-Za-z]\.)\s*\n\s*/gm, "$1 ");
|
options.replace(/^([A-Za-z]\.)\s*\n\s*/gm, "$1 ");
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 将选项字符串拆分为逐行结构,标记哪些行是正确答案。
|
* 将选项字符串拆分为逐行结构,标记哪些行是正确答案。
|
||||||
* answer 按 # 分隔(多选),与选项正文做等值匹配(大小写不敏感)。
|
* answer 按 # 分隔(多选),与选项正文做等值匹配(大小写不敏感)。
|
||||||
*/
|
*/
|
||||||
export function getOptionLines(
|
export const getOptionLines = (
|
||||||
options: string,
|
options: string,
|
||||||
answer: string
|
answer: string
|
||||||
): { text: string; isCorrect: boolean }[] {
|
): { text: string; isCorrect: boolean }[] => {
|
||||||
const segments = answer
|
const segments = answer
|
||||||
.split("#")
|
.split("#")
|
||||||
.map((s) => s.trim().toUpperCase())
|
.map((s) => s.trim().toUpperCase())
|
||||||
@@ -33,4 +32,16 @@ export function getOptionLines(
|
|||||||
const isCorrect = content !== "" && segments.includes(content);
|
const isCorrect = content !== "" && segments.includes(content);
|
||||||
return { text: line, isCorrect };
|
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)} 分钟`;
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from "./clipboard";
|
export * from "./clipboard";
|
||||||
|
export * from "./fetch";
|
||||||
export * from "./format";
|
export * from "./format";
|
||||||
export * from "./sortableTable";
|
export * from "./sortableTable";
|
||||||
|
|||||||
Vendored
+15
-10
@@ -1,6 +1,9 @@
|
|||||||
// server/api/cache/clear.post.ts - 清空当前用户 DB 缓存接口
|
// server/api/cache/clear.post.ts - 清空当前用户 DB 缓存接口
|
||||||
|
import { setResponseStatus } from "h3";
|
||||||
|
|
||||||
import { prisma } from "~~/server/utils/db";
|
import { prisma } from "~~/server/utils/db";
|
||||||
import { createApiLogger } from "~~/server/utils/logging";
|
import { createApiLogger } from "~~/server/utils/logging";
|
||||||
|
import { apiErr, apiOk } from "~~/server/utils/response";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 清缓存:将当前用户的 cacheClearedAt 更新为当前时间
|
* 清缓存:将当前用户的 cacheClearedAt 更新为当前时间
|
||||||
@@ -12,15 +15,17 @@ export default defineEventHandler(async (event) => {
|
|||||||
const logger = createApiLogger(event, "api.cache.clear");
|
const logger = createApiLogger(event, "api.cache.clear");
|
||||||
const userId = event.context.auth!.user.id;
|
const userId = event.context.auth!.user.id;
|
||||||
|
|
||||||
await prisma.user.update({
|
try {
|
||||||
where: { id: userId },
|
await prisma.user.update({
|
||||||
data: { cacheClearedAt: new Date() }
|
where: { id: userId },
|
||||||
});
|
data: { cacheClearedAt: new Date() }
|
||||||
|
});
|
||||||
|
|
||||||
logger.info("finish_success");
|
logger.info("finish_success");
|
||||||
|
return apiOk(null);
|
||||||
return {
|
} catch (err) {
|
||||||
success: true,
|
logger.error("db_error", { userId, err: String(err) });
|
||||||
message: "缓存已清除"
|
setResponseStatus(event, 500);
|
||||||
};
|
return apiErr(500, "服务器内部错误");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// server/api/health.get.ts - 服务健康检查接口
|
// server/api/health.get.ts - 服务健康检查接口
|
||||||
import { serverEnv } from "~~/server/utils/env";
|
import { serverEnv } from "~~/server/utils/env";
|
||||||
|
import { apiOk } from "~~/server/utils/response";
|
||||||
import { SERVICE_VERSION } from "~~/server/utils/runtimeState";
|
import { SERVICE_VERSION } from "~~/server/utils/runtimeState";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -9,10 +10,10 @@ import { SERVICE_VERSION } from "~~/server/utils/runtimeState";
|
|||||||
* 返回模型名,但不返回 API Key、baseURL 或其他敏感配置
|
* 返回模型名,但不返回 API Key、baseURL 或其他敏感配置
|
||||||
*/
|
*/
|
||||||
export default defineEventHandler(() => {
|
export default defineEventHandler(() => {
|
||||||
return {
|
return apiOk({
|
||||||
status: "ok",
|
status: "ok" as const,
|
||||||
message: "AI题库服务运行正常",
|
message: "AI题库服务运行正常",
|
||||||
version: SERVICE_VERSION,
|
version: SERVICE_VERSION,
|
||||||
model: serverEnv.openAiModel
|
model: serverEnv.openAiModel
|
||||||
};
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+19
-18
@@ -1,7 +1,8 @@
|
|||||||
// server/api/records.get.ts - 用户问答记录接口,供 Nuxt Dashboard 表格展示
|
// server/api/records.get.ts - 用户问答记录接口,供 Nuxt Dashboard 表格展示
|
||||||
import { getQuery } from "h3";
|
import { getQuery, setResponseStatus } from "h3";
|
||||||
|
|
||||||
import { createApiLogger } from "~~/server/utils/logging";
|
import { createApiLogger } from "~~/server/utils/logging";
|
||||||
|
import { apiErr, apiOk } from "~~/server/utils/response";
|
||||||
import { getQaRecords } from "~~/server/utils/runtimeState";
|
import { getQaRecords } from "~~/server/utils/runtimeState";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -23,23 +24,23 @@ export default defineEventHandler(async (event) => {
|
|||||||
const size = ALLOWED_SIZES.has(rawSize) ? rawSize : 10;
|
const size = ALLOWED_SIZES.has(rawSize) ? rawSize : 10;
|
||||||
const page = Number.isFinite(rawPage) ? Math.max(rawPage, 1) : 1;
|
const page = Number.isFinite(rawPage) ? Math.max(rawPage, 1) : 1;
|
||||||
|
|
||||||
const { records, total } = await getQaRecords(event.context.auth!.user.id, {
|
try {
|
||||||
page,
|
const { records, total } = await getQaRecords(event.context.auth!.user.id, {
|
||||||
size
|
page,
|
||||||
});
|
size
|
||||||
|
});
|
||||||
|
|
||||||
logger.info("finish_success", {
|
logger.info("finish_success", {
|
||||||
page,
|
page,
|
||||||
size,
|
size,
|
||||||
total,
|
total,
|
||||||
count: records.length
|
count: records.length
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return apiOk({ records, page, size, total });
|
||||||
success: true,
|
} catch (err) {
|
||||||
records,
|
logger.error("db_error", { err: String(err) });
|
||||||
page,
|
setResponseStatus(event, 500);
|
||||||
size,
|
return apiErr(500, "服务器内部错误");
|
||||||
total
|
}
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|||||||
+15
-6
@@ -1,6 +1,9 @@
|
|||||||
// server/api/stats.get.ts - 服务运行统计接口
|
// server/api/stats.get.ts - 服务运行统计接口
|
||||||
|
import { setResponseStatus } from "h3";
|
||||||
|
|
||||||
import { prisma } from "~~/server/utils/db";
|
import { prisma } from "~~/server/utils/db";
|
||||||
import { createApiLogger } from "~~/server/utils/logging";
|
import { createApiLogger } from "~~/server/utils/logging";
|
||||||
|
import { apiErr, apiOk } from "~~/server/utils/response";
|
||||||
import { getRuntimeStats } from "~~/server/utils/runtimeState";
|
import { getRuntimeStats } from "~~/server/utils/runtimeState";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -13,11 +16,17 @@ export default defineEventHandler(async (event) => {
|
|||||||
const logger = createApiLogger(event, "api.stats");
|
const logger = createApiLogger(event, "api.stats");
|
||||||
const userId = event.context.auth!.user.id;
|
const userId = event.context.auth!.user.id;
|
||||||
|
|
||||||
const [runtimeStats, qa_records_count] = await Promise.all([
|
try {
|
||||||
Promise.resolve(getRuntimeStats()),
|
const [runtimeStats, qa_records_count] = await Promise.all([
|
||||||
prisma.qaRecord.count({ where: { userId } })
|
Promise.resolve(getRuntimeStats()),
|
||||||
]);
|
prisma.qaRecord.count({ where: { userId } })
|
||||||
|
]);
|
||||||
|
|
||||||
logger.info("finish_success");
|
logger.info("finish_success");
|
||||||
return { ...runtimeStats, qa_records_count };
|
return apiOk({ ...runtimeStats, qa_records_count });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error("db_error", { userId, err: String(err) });
|
||||||
|
setResponseStatus(event, 500);
|
||||||
|
return apiErr(500, "服务器内部错误");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { setResponseStatus } from "h3";
|
|||||||
|
|
||||||
import { prisma } from "~~/server/utils/db";
|
import { prisma } from "~~/server/utils/db";
|
||||||
import { createApiLogger } from "~~/server/utils/logging";
|
import { createApiLogger } from "~~/server/utils/logging";
|
||||||
|
import { apiErr, apiOk } from "~~/server/utils/response";
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const logger = createApiLogger(event, "api.user.refresh-token");
|
const logger = createApiLogger(event, "api.user.refresh-token");
|
||||||
@@ -21,9 +22,9 @@ export default defineEventHandler(async (event) => {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error("db_error", { userId, err: String(err) });
|
logger.error("db_error", { userId, err: String(err) });
|
||||||
setResponseStatus(event, 500);
|
setResponseStatus(event, 500);
|
||||||
return { success: false, msg: "服务器内部错误" };
|
return apiErr(500, "服务器内部错误");
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("finish_success", { userId });
|
logger.info("finish_success", { userId });
|
||||||
return { success: true, msg: "刷新成功", apiToken: newToken };
|
return apiOk({ apiToken: newToken });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
// server/middleware/api-auth.ts - 统一 API 鉴权层,所有非公开 /api/* 请求都必须通过此处
|
// 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 { isPublicApiRoute } from "~~/server/utils/api-auth-rules";
|
||||||
import { getAuthSession } from "~~/server/utils/auth";
|
import { getAuthSession } from "~~/server/utils/auth";
|
||||||
|
import { apiErr } from "~~/server/utils/response";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 统一 API 鉴权 middleware
|
* 统一 API 鉴权 middleware
|
||||||
@@ -27,7 +28,8 @@ export default defineEventHandler(async (event) => {
|
|||||||
|
|
||||||
const session = await getAuthSession(event);
|
const session = await getAuthSession(event);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
throw createError({ statusCode: 401, statusMessage: "未登录" });
|
setResponseStatus(event, 401);
|
||||||
|
return apiErr(401, "unauthorized");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 挂到 event.context,后续 handler 直接读取,不再重复查询 session
|
// 挂到 event.context,后续 handler 直接读取,不再重复查询 session
|
||||||
|
|||||||
+7
-15
@@ -1,4 +1,8 @@
|
|||||||
// server/utils/answer.ts - OCS 答题提示词构建、答案清洗和响应格式化
|
// server/utils/answer.ts - OCS 答题提示词构建、答案清洗和响应格式化
|
||||||
|
import type {
|
||||||
|
ISearchErrorResponse,
|
||||||
|
ISearchSuccessResponse
|
||||||
|
} from "~~/shared/types/answer";
|
||||||
|
|
||||||
/** `/api/search` 从 OCS 请求中最终抽取出的标准参数 */
|
/** `/api/search` 从 OCS 请求中最终抽取出的标准参数 */
|
||||||
export interface SearchParams {
|
export interface SearchParams {
|
||||||
@@ -10,21 +14,9 @@ export interface SearchParams {
|
|||||||
options: string;
|
options: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** OCS AnswererWrapper handler 期望的成功响应结构 */
|
/** OCS 兼容响应类型别名,与 shared/types/answer.ts 保持一致 */
|
||||||
export interface OcsSuccessResponse {
|
export type OcsSuccessResponse = ISearchSuccessResponse;
|
||||||
code: 1;
|
export type OcsErrorResponse = ISearchErrorResponse;
|
||||||
/** 返回原题,handler 会将它作为匹配题目展示 */
|
|
||||||
question: string;
|
|
||||||
/** 返回最终答案;多选题必须是 `#` 分隔字符串,而不是数组 */
|
|
||||||
answer: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** OCS AnswererWrapper handler 期望的失败响应结构 */
|
|
||||||
export interface OcsErrorResponse {
|
|
||||||
code: 0;
|
|
||||||
/** 只放本地业务文案,不暴露上游或内部异常详情 */
|
|
||||||
msg: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 不同题型对应的补充说明
|
* 不同题型对应的补充说明
|
||||||
|
|||||||
@@ -23,18 +23,18 @@ export const publicApiRoutes: ApiRouteRule[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
/** 路径匹配:支持精确匹配、`/**` 前缀通配和 RegExp */
|
/** 路径匹配:支持精确匹配、`/**` 前缀通配和 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 instanceof RegExp) return rulePath.test(pathname);
|
||||||
if (rulePath.endsWith("/**")) {
|
if (rulePath.endsWith("/**")) {
|
||||||
const prefix = rulePath.slice(0, -3);
|
const prefix = rulePath.slice(0, -3);
|
||||||
return pathname === prefix || pathname.startsWith(`${prefix}/`);
|
return pathname === prefix || pathname.startsWith(`${prefix}/`);
|
||||||
}
|
}
|
||||||
return pathname === rulePath;
|
return pathname === rulePath;
|
||||||
}
|
};
|
||||||
|
|
||||||
/** 判断当前请求是否命中公开路由规则 */
|
/** 判断当前请求是否命中公开路由规则 */
|
||||||
export function isPublicApiRoute(pathname: string, method: string): boolean {
|
export const isPublicApiRoute = (pathname: string, method: string): boolean =>
|
||||||
return publicApiRoutes.some((rule) => {
|
publicApiRoutes.some((rule) => {
|
||||||
const methods = Array.isArray(rule.method)
|
const methods = Array.isArray(rule.method)
|
||||||
? rule.method
|
? 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);
|
const methodMatched = !methods || methods.includes(method as HttpMethod);
|
||||||
return methodMatched && matchPath(rule.path, pathname);
|
return methodMatched && matchPath(rule.path, pathname);
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// server/utils/response.ts - 统一 API 响应工具函数(非 OCS 接口专用)
|
||||||
|
// OCS 搜题接口 /api/search 有固定的兼容格式,不使用此工具
|
||||||
|
|
||||||
|
/** 成功响应:code 0,data 为业务数据,msg 固定为"请求成功" */
|
||||||
|
export const apiOk = <T>(data: T) => ({
|
||||||
|
code: 0 as const,
|
||||||
|
data,
|
||||||
|
msg: "请求成功"
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 错误响应:data 固定为 null,msg 为本地安全文案,不暴露上游细节 */
|
||||||
|
export const apiErr = (code: number, msg: string) => ({
|
||||||
|
code,
|
||||||
|
data: null,
|
||||||
|
msg
|
||||||
|
});
|
||||||
@@ -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<T = null> {
|
||||||
|
/** 状态码: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<IRecordsData>;
|
||||||
|
|
||||||
|
/** 清空缓存接口响应 */
|
||||||
|
export type IClearCacheResponse = IApiResponse<null>;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// shared/types/index.ts - 前后端通用类型统一出口
|
||||||
|
export * from "./answer";
|
||||||
Reference in New Issue
Block a user