From fbf134ccc64bb3198d4881f9d1c60e529c65b308 Mon Sep 17 00:00:00 2001 From: liyy <18435186204@163.com> Date: Wed, 2 Sep 2026 13:57:39 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E8=81=94=E8=B0=83=E5=BC=82=E5=B8=B8?= =?UTF-8?q?=E6=94=B6=E5=8F=A3=EF=BC=88=E5=BC=80=E5=A7=8B=E5=88=B7=E9=A2=98?= =?UTF-8?q?=E6=8F=90=E7=A4=BA/=E9=9D=9E=E6=B3=95JSON/=E5=AF=BC=E5=85=A5?= =?UTF-8?q?=E9=80=90=E6=9D=A1=E6=A0=A1=E9=AA=8C/=E9=87=8D=E5=A4=8Dfinish?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/views/practice/PracticeView.vue | 4 + docs/checks/task-12.md | 91 ++++++++++++++++++++++ server/src/errors.ts | 6 +- server/src/handlers/news.ts | 48 ++++++------ server/src/handlers/practice.ts | 7 +- server/src/handlers/questions.ts | 57 ++++++++------ server/src/schemas/api.ts | 81 ++++++++++++++++--- server/src/server.ts | 6 +- 8 files changed, 236 insertions(+), 64 deletions(-) create mode 100644 docs/checks/task-12.md diff --git a/client/src/views/practice/PracticeView.vue b/client/src/views/practice/PracticeView.vue index 510f6ba..5d3917a 100644 --- a/client/src/views/practice/PracticeView.vue +++ b/client/src/views/practice/PracticeView.vue @@ -5,6 +5,7 @@ import { dashboardApi, practiceApi, reviewApi, type ReviewListItem } from '../.. import { useRequest } from '../../composables/useRequest' import { useResponsive } from '../../composables/useResponsive' import { usePracticeStore } from '../../stores/practice' +import { useAppStore } from '../../stores/app' import AppPageHeader from '../../components/base/AppPageHeader.vue' import AppCard from '../../components/base/AppCard.vue' import AppButton from '../../components/base/AppButton.vue' @@ -21,6 +22,7 @@ const route = useRoute() const router = useRouter() const { isMobile } = useResponsive() const practice = usePracticeStore() +const app = useAppStore() /** 当前子 tab:刷题 / 错题本 / 申论 */ const tab = computed(() => { @@ -132,6 +134,8 @@ async function startPractice() { total: session.total }) router.push({ name: 'practice-session', params: { id: session.sessionId } }) + } catch (err) { + app.toast(err instanceof Error ? err.message : '组卷失败,请稍后重试', 'error') } finally { starting.value = false } diff --git a/docs/checks/task-12.md b/docs/checks/task-12.md new file mode 100644 index 0000000..b802687 --- /dev/null +++ b/docs/checks/task-12.md @@ -0,0 +1,91 @@ +# 任务 12 检查记录:联调与异常收口 + +日期:2026-09-02 + +## 本轮修复(查漏补缺) + +1. `client/src/views/practice/PracticeView.vue`:**「开始刷题」缺失 catch**——组卷失败(如模块无题目 409、网络异常)时用户无任何反馈(未处理 Promise rejection)。补 catch + toast,复用统一错误文案。 +2. `server/src/errors.ts` + `server/src/server.ts`:**非法 JSON 请求体返回 500**——Fastify 解析失败此前落入「未处理异常 → 500 INTERNAL_ERROR」。现解析器显式构造 400 错误并透出可理解文案「请求体不是合法 JSON,请检查格式」;AJV 校验分支保持原「请求参数不合法 + details」。 +3. `server/src/handlers/questions.ts` + `news.ts` + `schemas/api.ts`:**导入「单条错误阻塞整批」与文档承诺不符**——此前请求级 schema 对题目/要闻逐条强制必填+枚举,任何一条无效整批 400,导致「错误题目不影响有效题目导入」从未真正生效(任务07 检查记录也仅验证了去重)。现改为:请求层只声明字段类型结构,handler 内用严格 per-item schema(`QuestionInputSchema` / `NewsImportItemSchema`)逐条 `safeParse`,收集可读中文错误(`importItemMessage`),有效项照常入库;重复项仍按指纹跳过。 +4. `server/src/handlers/practice.ts`:**finish 对已结束会话缺少防护**(与 answer/currentQuestion 不一致)——补 `status==='finished' → 409 会话已结束`;清理过期注释(错题沉淀实际在单题作答时完成)。 + +## 接口检查清单(40 个接口) + +### 数据中枢 +| 接口 | 正常 | 空数据 | 校验/边界 | +|---|---|---|---| +| GET /api/dashboard/overview | 200 指标齐全 | 200 全 0 / pendingReview 联动 | — | +| GET /api/dashboard/trends | 200 | 200 空数组 | days 越界(0/91) → 400 | +| GET /api/dashboard/mastery | 200 五模块 | 200 全部 answered 0 | — | +| GET /api/dashboard/weak-points | 200 | 200 空 | — | + +### 刷题 +| 接口 | 正常 | 空数据 | 校验/边界 | +|---|---|---|---| +| GET /api/practice/modules | 200 五模块题量 | 200 全 0 | — | +| POST /api/practice/start | 200 会话 | 模块无题 → 409「该模块暂无题目,请先导入题目」 | 非法模块/时长 → 400;custom 空 → 409;custom 含不存在题目 → 409;>30 题 → 409 | +| GET /api/practice/:sessionId/question | 200 当前题 | 答完 → question:null | 不存在会话 → 404;已结束 → 409 | +| POST /api/practice/:sessionId/answer | 200 记分 | — | 重复提交 → duplicate 不重复计;乱序/跨题 → 409;已结束 → 409;题目被删 → 404 | +| POST /api/practice/:sessionId/finish | 200 结果 | — | 已结束再次 finish → 409(本轮补) | +| GET /api/practice/wrong-reasons | 200 | 200 空 | — | + +### 错题复习 +| 接口 | 正常 | 空数据 | 校验/边界 | +|---|---|---|---| +| GET /api/review/list | 200 分页 | 200 total 0 | 非法 status/module → 400 | +| GET /api/review/:id/detail | 200 | — | 不存在 → 404;题目已删 → 404 | +| POST /api/review/:id/submit | 200 推进 +1/+3/+7 | — | 非法 answer → 400;不存在 → 404 | +| POST /api/review/:id/self-assess | 200 | — | 缺 correct → 400;不存在 → 404 | +| POST /api/review/:id/mark-mastered | 200 | — | 不存在 → 404 | + +### 备考计划 / 模考 / 要闻 / 题库 / AI / 档案 +| 接口 | 正常 | 空数据 | 校验/边界 | +|---|---|---|---| +| GET/POST /api/plans/* | 200 | 200 空任务 | toggle 不存在 → 404;创建缺标题 → 400 | +| GET /api/plans/review | 200 | 200 全 0 | — | +| GET/POST/DELETE /api/mock-exams/* | 200 | 200 latest/gap 为 null | 分数越界 → 400;不存在记录 update/delete → 404 | +| GET /api/news/list + /:id | 200 | 200 | 分类筛选;不存在 → 404 | +| POST /api/news/import/json | 200 报告 | — | 逐条校验(本轮):有效导入、重复跳过、单条错误不再阻塞整批 | +| POST /api/news/import/{rss,api,url} | 200 占位反馈 | — | url 非法 → 400 | +| GET/POST /api/questions/list、import、export | 200 | 200 | 逐条校验(本轮):单条错误报告 + 有效项照常入库 | +| DELETE /api/questions/:id | 200 | — | 被引用 → 409 带明细;不存在 → 404 | +| GET /api/questions/:id/refs | 200 | — | 不存在 → 404 | +| POST /api/ai/explain-question | 200 | — | 未配置/超时/失败回退 fallback;题目不存在 → 404;非法 requestType → 400 | +| GET/PATCH /api/profile、/api/settings | 200 | 档案缺失时 fallback 默认值 | 非法日期/分数 → 400;aiConfigured 由环境变量计算 | + +## 边界场景记录 + +| 场景 | 结果 | 处理 | +|---|---|---| +| 全数据文件为空(合法 []) | 全部 GET 200、空态正确;模块无题 start → 409 | 已有空态 + 本轮补前端 toast | +| 0 字节/损坏 JSON 文件(启动期) | 启动报「无法读取 xxx:SyntaxError…」,可定位 | 任务02 约定:缺失文件 fallback、非法 JSON 可定位错误 | +| 运行中损坏 questions.json | 该域 500(用户见通用文案),服务不崩;mock/news 等其它域 200;恢复文件后**无需重启**即恢复 | readData 每次读盘;日志含「无法读取 questions.json:…」 | +| 非法 JSON 请求体 | 400「请求体不是合法 JSON,请检查格式」(原 500) | 本轮修复 | +| 题目/要闻导入含无效条目 | 有效项入库 + 逐条中文错误报告(不再整批 400) | 本轮修复 | +| 重复导入 | 指纹去重:success 0 / skipped N | 既有 | +| 重复作答 | accepted:false + duplicate:true,不重复计入 | 既有 | +| 乱序作答 | 409「题目与会话作答顺序不符」 | 既有 | +| 重复 finish | 409「会话已结束」(原 200 重复写状态) | 本轮修复 | +| 刷题中心「开始刷题」失败 | toast 显示后端中文错误(原无反馈) | 本轮修复 | +| 网络断开(后端未启动) | 页面 AppError「无法连接服务,请确认后端已启动」+ 重试 | 既有 unwrap + 页面四态 | +| AI 未配置/超时/失败 | fallback 可识别提示,不阻塞流程;Token 不进日志 | 任务11 | +| 删除被引用题目 | 409 + 引用明细,前端弹层禁用确认 | 既有 | +| 前端提交类操作 | 保存/导入/删除/自评/标记等均 try/catch + toast / 错误态;进行中按钮 disabled/loading | 审计通过(仅开始刷题缺 catch,已修) | +| 敏感配置 | server/.env gitignored;AI Key 不进入日志、OpenAPI、前端 | 任务11 + 88c479f | + +## 完成标准核对(开发计划任务12) + +- ✅ 逐接口联调(40 个接口按域清单核对:正常 / 空数据 / 非法输入 / 业务边界)。 +- ✅ 空数据处理(全空 JSON 文件下各页空态 + CTA)。 +- ✅ 非法导入(题目/要闻单条无效不再阻塞整批,逐条中文错误报告)。 +- ✅ 重复提交(作答幂等、重复 finish 409、重复导入去重)。 +- ✅ 文件写入失败 / 损坏文件(可定位日志、服务不崩、恢复无需重启)。 +- ✅ AI 失败(未配置/超时/解析失败回退题库解析,不阻塞流程)。 +- ✅ 网络断开(统一「无法连接服务」+ 重试)。 +- ✅ 统一按钮禁用与提示文案(缺失的「开始刷题」catch 已补,toast 文案与后端错误对齐)。 +- ✅ 服务端日志可定位问题(文件路径 + 错误原因)且不泄露敏感配置(无请求体、无 Token)。 +- ✅ 类型检查(vue-tsc / tsc)与生产构建(139 模块)通过;OpenAPI / 生成 API / 实现一致(api:generate 无 diff)。 + +## 当前结论 + +任务 12 完成:本轮审计发现并修复 4 类真实缺口(开始刷题无错误反馈、非法 JSON body 误报 500、导入单条错误阻塞整批、finish 重复调用无防护);40 个接口边界、损坏文件、网络断开、AI 失败等场景全部实测通过;接口检查清单与边界场景记录如上。测试数据均在临时目录,仓库演示数据未受影响。 diff --git a/server/src/errors.ts b/server/src/errors.ts index 9c83e87..417b6d2 100644 --- a/server/src/errors.ts +++ b/server/src/errors.ts @@ -96,8 +96,12 @@ export function errorHandler(error: FastifyError | ApiError | Error, request: Fa const fastifyError = error as FastifyError if (fastifyError.validation || fastifyError.statusCode === 400) { + if (!fastifyError.validation) { + // 非 AJV 的 400(如 JSON body 解析失败),直接透出可理解的原因 + return sendError(reply, 400, 'VALIDATION_ERROR', fastifyError.message || '请求参数不合法', []) + } const details = consolidateDetails( - (fastifyError.validation ?? []).map((item) => ({ + fastifyError.validation.map((item) => ({ path: item.instancePath.replace(/^\//, '') || (item.params as { missingProperty?: string })?.missingProperty || '', message: item.message ?? '参数不合法' })) diff --git a/server/src/handlers/news.ts b/server/src/handlers/news.ts index efd3e5b..f80925d 100644 --- a/server/src/handlers/news.ts +++ b/server/src/handlers/news.ts @@ -7,10 +7,12 @@ import type { NewsItem } from '../schemas/entities.js' import type { z } from 'zod' import { NewsImportApiBodySchema, + NewsImportItemSchema, NewsImportJsonBodySchema, NewsImportRssBodySchema, NewsImportUrlBodySchema, - NewsListQuerySchema + NewsListQuerySchema, + importItemMessage } from '../schemas/api.js' /** 排序:按发布时间倒序(用于列表) */ @@ -55,28 +57,30 @@ export async function importJson(request: FastifyRequest) { const added: NewsItem[] = [] let skipped = 0 - body.news.forEach((item, index) => { - try { - const fingerprint = `${item.title}\u0000${item.category}\u0000${item.publishedAt}` - if (seen.has(fingerprint)) { - skipped += 1 - return - } - seen.add(fingerprint) - added.push({ - id: newId('news'), - title: item.title, - category: item.category, - summary: item.summary, - content: item.content, - source: item.source, - publishedAt: item.publishedAt, - tags: item.tags, - importSource: item.importSource - }) - } catch (error) { - errors.push({ index, message: error instanceof Error ? error.message : '导入失败' }) + body.news.forEach((raw, index) => { + const parsed = NewsImportItemSchema.safeParse(raw) + if (!parsed.success) { + errors.push({ index, message: importItemMessage(parsed.error) }) + return } + const item = parsed.data + const fingerprint = `${item.title}\u0000${item.category}\u0000${item.publishedAt}` + if (seen.has(fingerprint)) { + skipped += 1 + return + } + seen.add(fingerprint) + added.push({ + id: newId('news'), + title: item.title, + category: item.category, + summary: item.summary, + content: item.content, + source: item.source, + publishedAt: item.publishedAt, + tags: item.tags, + importSource: item.importSource + }) }) if (added.length > 0) { diff --git a/server/src/handlers/practice.ts b/server/src/handlers/practice.ts index 71c1c92..132c963 100644 --- a/server/src/handlers/practice.ts +++ b/server/src/handlers/practice.ts @@ -261,13 +261,14 @@ export async function wrongReasons() { * 结束会话并返回结果: * - 只对已作答的题计分(未作答不计入正确率); * - 计算得分、正确率、总耗时; - * - 标记会话 finished;若仍有未答题,不允许结束(需先答完)。 - * 说明:错题沉淀(写入 wrong-questions.json)属于任务 09,本接口暂不落库, - * 但返回错题列表供前端展示与后续移交。 + * - 标记会话 finished;已结束会话重复调用返回 409; + * - 错题沉淀在单题作答时(practice.answer 答错)已写入 wrong-questions.json(任务 09), + * 本接口仅汇总返回本卷错题列表,供结果页展示。 */ export async function finish(request: FastifyRequest) { const { sessionId } = request.params as { sessionId: string } const session = await findSession(sessionId) + if (session.status === 'finished') throw ApiError.conflict('会话已结束') const [records, questions] = await Promise.all([ readData(dataFiles.practiceRecords, []), diff --git a/server/src/handlers/questions.ts b/server/src/handlers/questions.ts index a460d0d..8be772d 100644 --- a/server/src/handlers/questions.ts +++ b/server/src/handlers/questions.ts @@ -10,7 +10,12 @@ import type { WrongQuestion } from '../schemas/entities.js' import type { z } from 'zod' -import { QuestionsImportBodySchema, QuestionsListQuerySchema } from '../schemas/api.js' +import { + importItemMessage, + QuestionImportItemSchema, + QuestionsImportBodySchema, + QuestionsListQuerySchema +} from '../schemas/api.js' /** 内容指纹:题干 + 模块 + 答案 + 选项文本(排序后),用于导入去重 */ function contentFingerprint(q: Pick) { @@ -52,31 +57,33 @@ export async function importQuestions(request: FastifyRequest) { const added: Question[] = [] let skipped = 0 - body.questions.forEach((item, index) => { - try { - const fingerprint = contentFingerprint(item) - if (seen.has(fingerprint)) { - skipped += 1 - return - } - seen.add(fingerprint) - added.push({ - id: newId('q'), - type: item.type, - module: item.module, - subModule: item.subModule, - difficulty: item.difficulty, - stem: item.stem, - options: item.options, - answer: item.answer, - analysis: item.analysis, - tags: item.tags, - source: item.source, - createdAt: new Date().toISOString() - }) - } catch (error) { - errors.push({ index, message: error instanceof Error ? error.message : '导入失败' }) + body.questions.forEach((raw, index) => { + const parsed = QuestionImportItemSchema.safeParse(raw) + if (!parsed.success) { + errors.push({ index, message: importItemMessage(parsed.error) }) + return } + const item = parsed.data + const fingerprint = contentFingerprint(item) + if (seen.has(fingerprint)) { + skipped += 1 + return + } + seen.add(fingerprint) + added.push({ + id: newId('q'), + type: item.type, + module: item.module, + subModule: item.subModule, + difficulty: item.difficulty, + stem: item.stem, + options: item.options, + answer: item.answer, + analysis: item.analysis, + tags: item.tags, + source: item.source, + createdAt: new Date().toISOString() + }) }) if (added.length > 0) { diff --git a/server/src/schemas/api.ts b/server/src/schemas/api.ts index 1b04bd7..8dbd4a2 100644 --- a/server/src/schemas/api.ts +++ b/server/src/schemas/api.ts @@ -5,11 +5,43 @@ import { DIFFICULTIES, NewsItemSchema, ProfileSchema, + QuestionInputSchema, QuestionSchema, SettingsSchema, StudyPlanSchema } from './entities.js' +/** 导入报告逐条错误的中文可读信息(Zod 校验失败时调用) */ +export function importItemMessage(error: z.ZodError): string { + const first = error.issues[0] + if (!first) return '字段不合法' + const field = first.path.join('.') || '条目' + const extra = first as { received?: string; expected?: string; type?: string; minimum?: number; keys?: string[] } + switch (first.code) { + case 'invalid_type': + if (extra.received === 'undefined') return `缺少必填字段 ${field}` + return `${field} 类型应为${extra.expected ?? '正确类型'}` + case 'invalid_value': + return `${field} 取值必须是允许的枚举值之一` + case 'too_small': + if (extra.type === 'array') return `${field} 至少需要 ${extra.minimum ?? 1} 项` + if (extra.type === 'string') return `${field} 不能为空` + return `${field} 取值不符合要求` + case 'too_big': + return `${field} 取值超出上限` + case 'invalid_format': + return `${field} 格式不正确` + case 'invalid_union': + return `${field} 取值不符合预设选项` + case 'invalid_element': + return `${field} 内部存在不合法项` + case 'unrecognized_keys': + return `${field} 包含不支持字段:${(extra.keys ?? []).join('、')}` + default: + return `${field} 不合法` + } +} + /** 统一错误响应(需求文档第 6 节格式) */ export const ErrorDetailSchema = z.object({ path: z.string(), @@ -385,18 +417,27 @@ export const NewsIdParamsSchema = z.object({ id: z.string().min(1) }) export const NewsImportJsonBodySchema = z.object({ version: z.string().optional(), news: z.array( - NewsItemSchema.omit({ id: true }).extend({ - title: z.string().min(1), - category: z.string().min(1), - summary: z.string(), - content: z.string(), - source: z.string(), - publishedAt: z.string(), - tags: z.array(z.string()).default([]), - importSource: z.string().default('json') - }) + // 请求层只约束字段类型;逐条「必填/非空/枚举」语义校验在 handler 内完成, + // 保证单条错误不阻塞其它有效条目(返回逐条错误报告)。 + z + .object({ + title: z.string().optional(), + category: z.string().optional(), + summary: z.string().optional(), + content: z.string().optional(), + source: z.string().optional(), + publishedAt: z.string().optional(), + tags: z.array(z.string()).optional(), + importSource: z.string().optional() + }) + .passthrough() ) }) +/** 要闻逐条严格校验(用于 handler 内 safeParse,默认值在此落地) */ +export const NewsImportItemSchema = NewsItemSchema.omit({ id: true }).extend({ + tags: z.array(z.string()).default([]), + importSource: z.string().default('json') +}) export const NewsImportRssBodySchema = z.object({ url: z.string().url() }) export const NewsImportApiBodySchema = z.object({ url: z.string().url(), @@ -417,9 +458,27 @@ export const QuestionIdParamsSchema = z.object({ id: z.string().min(1) }) export const QuestionsImportBodySchema = z.object({ version: z.string().optional(), questions: z.array( - QuestionSchema.omit({ id: true, createdAt: true }) + // 同要闻导入:请求层宽松,逐条严格校验(QuestionInputSchema)在 handler 内完成 + z + .object({ + type: z.string().optional(), + module: z.string().optional(), + subModule: z.string().optional(), + difficulty: z.string().optional(), + stem: z.string().optional(), + options: z + .array(z.object({ key: z.string().optional(), text: z.string().optional() })) + .optional(), + answer: z.string().optional(), + analysis: z.string().optional(), + tags: z.array(z.string()).optional(), + source: z.string().optional() + }) + .passthrough() ) }) +/** 题目逐条严格校验(与导出格式一致,供 handler 内 safeParse) */ +export const QuestionImportItemSchema = QuestionInputSchema export const QuestionsExportResponseSchema = z.object({ version: z.string(), exportedAt: z.string(), diff --git a/server/src/server.ts b/server/src/server.ts index 8080dd9..cb8dc2b 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -2,7 +2,7 @@ import './env.js' import { pathToFileURL } from 'node:url' -import Fastify from 'fastify' +import Fastify, { type FastifyError } from 'fastify' import cors from '@fastify/cors' import swagger from '@fastify/swagger' import swaggerUi from '@fastify/swagger-ui' @@ -21,7 +21,9 @@ export async function buildServer() { try { done(null, JSON.parse(body as string)) } catch (err) { - done(err as Error, undefined) + const parseError = new Error('请求体不是合法 JSON,请检查格式') as FastifyError + parseError.statusCode = 400 + done(parseError, undefined) } })