fix: 联调异常收口(开始刷题提示/非法JSON/导入逐条校验/重复finish)

This commit is contained in:
liyy 2026-09-02 13:57:39 +08:00
parent 88c479f730
commit fbf134ccc6
8 changed files with 236 additions and 64 deletions

View File

@ -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
}

91
docs/checks/task-12.md Normal file
View File

@ -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「该模块暂无题目请先导入题目」 | 非法模块/时长 → 400custom 空 → 409custom 含不存在题目 → 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 默认值 | 非法日期/分数 → 400aiConfigured 由环境变量计算 |
## 边界场景记录
| 场景 | 结果 | 处理 |
|---|---|---|
| 全数据文件为空(合法 [] | 全部 GET 200、空态正确模块无题 start → 409 | 已有空态 + 本轮补前端 toast |
| 0 字节/损坏 JSON 文件(启动期) | 启动报「无法读取 xxxSyntaxError…」可定位 | 任务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 gitignoredAI 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 失败等场景全部实测通过;接口检查清单与边界场景记录如上。测试数据均在临时目录,仓库演示数据未受影响。

View File

@ -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 ?? '参数不合法'
}))

View File

@ -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) {

View File

@ -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<PracticeRecord[]>(dataFiles.practiceRecords, []),

View File

@ -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<Question, 'stem' | 'module' | 'answer' | 'options'>) {
@ -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) {

View File

@ -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(),

View File

@ -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)
}
})