diff --git a/apps/api/src/features/learning/index.ts b/apps/api/src/features/learning/index.ts index de9faf4b..c1444e5e 100644 --- a/apps/api/src/features/learning/index.ts +++ b/apps/api/src/features/learning/index.ts @@ -3,8 +3,11 @@ import { createPracticeSessionRoute, favoriteQuestionsRoute, favoriteWordsRoute, + practiceReportsRoute, + practiceSessionReportRoute, resolveWrongQuestionRoute, submitAnswerRoute, + submitPracticeSessionRoute, toggleFavoriteQuestionRoute, toggleFavoriteWordRoute, updateWordProgressRoute, @@ -15,6 +18,9 @@ import { export const learningRoutes: RouteDefinition[] = [ ['POST', '/api/learning/practice-sessions', createPracticeSessionRoute], + ['POST', '/api/learning/practice-sessions/submit', submitPracticeSessionRoute], + ['GET', '/api/learning/practice-sessions/report', practiceSessionReportRoute], + ['GET', '/api/learning/practice-reports', practiceReportsRoute], ['POST', '/api/learning/answers', submitAnswerRoute], ['GET', '/api/learning/favorites/questions', favoriteQuestionsRoute], ['POST', '/api/learning/favorites/questions', toggleFavoriteQuestionRoute], diff --git a/apps/api/src/features/learning/routes.ts b/apps/api/src/features/learning/routes.ts index e155f616..1093edc2 100644 --- a/apps/api/src/features/learning/routes.ts +++ b/apps/api/src/features/learning/routes.ts @@ -1,3 +1,4 @@ +import type pg from 'pg'; import { HttpError, type RequestContext } from '../../core/http.js'; import { intParam, @@ -35,6 +36,115 @@ interface PracticeBlueprintRow { rules: Record; } +interface PracticeSessionReportSessionRow { + id: string; + tenantId: string; + userId: string; + mode: string; + blueprintId: string | null; + collectionId: string | null; + entryId: string | null; + contentNodeId: string | null; + questionIds: unknown; + questionCount: number; + durationMinutes: number | null; + totalScore: string | number | null; + startedAt: string; + finishedAt: string | null; + metadata: Record; +} + +interface PracticeSessionReportRow { + id: string; + tenantId: string; + userId: string; + practiceSessionId: string; + blueprintId: string | null; + collectionId: string | null; + mode: string; + totalQuestions: number; + answeredCount: number; + correctCount: number; + wrongCount: number; + unansweredCount: number; + score: string | number; + totalScore: string | number; + accuracy: string | number; + durationSeconds: number; + startedAt: string | null; + submittedAt: string; + sectionStats: unknown; + questionResults: unknown; + wrongQuestionIds: unknown; + metadata: Record; + createdAt: string; + updatedAt: string; +} + +interface PracticeSessionReportAnswerRow { + questionId: string; + selectedOptions: unknown; + answerText: string | null; + isCorrect: boolean | null; + answeredAt: string; +} + +interface PracticeSessionReportQuestionRow { + questionId: string; + type: string; + typeLabel: string | null; + collectionId: string | null; + sectionKey: string | null; + sortOrder: number | null; + itemScore: string | number | null; + content: string | null; + explanation: string | null; + correctOptionIndex: number | null; + correctOptionIndices: unknown; + answerText: string | null; +} + +interface PracticeReportQuestionResult { + questionId: string; + questionType: string; + sectionKey: string; + score: number; + totalScore: number; + answered: boolean; + isCorrect: boolean | null; + selectedOptions: string[]; + answerText: string | null; + answeredAt: string | null; + correctOptionIndex: number | null; + correctOptionIndices: number[]; + correctAnswerText: string | null; + explanation: string | null; + content: string | null; +} + +interface SectionDefinition { + key: string; + title: string | null; + questionType: string | null; + scoreEach: number | null; + sortOrder: number; +} + +interface SectionStat { + key: string; + title: string | null; + questionType: string | null; + questionCount: number; + answeredCount: number; + correctCount: number; + wrongCount: number; + unansweredCount: number; + score: number; + totalScore: number; + accuracy: number; + sortOrder: number; +} + interface PracticeAssembly { mode: string; targetType: string | null; @@ -101,12 +211,77 @@ function jsonArray(value: unknown): unknown[] { return Array.isArray(value) ? value : []; } +function jsonObject(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; +} + function positiveInt(value: unknown, fallback: number, max = 500) { const parsed = Number(value ?? fallback); if (!Number.isFinite(parsed) || parsed <= 0) return fallback; return Math.min(Math.trunc(parsed), max); } +function finiteNumber(value: unknown, fallback = 0) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function round2(value: number) { + return Math.round(value * 100) / 100; +} + +function round4(value: number) { + return Math.round(value * 10000) / 10000; +} + +function idArrayFromJson(value: unknown): string[] { + return normalizeStringArray(value); +} + +function sectionDefinitionsFrom(metadata: Record): SectionDefinition[] { + const assembly = jsonObject(metadata.assembly); + return jsonArray(assembly.sections).map((section, index) => { + const object = jsonObject(section); + const key = String(object.key || object.sectionKey || object.questionType || object.type || `section_${index + 1}`); + const title = typeof object.title === 'string' + ? object.title + : typeof object.name === 'string' + ? object.name + : null; + const questionType = typeof object.questionType === 'string' + ? object.questionType + : typeof object.type === 'string' + ? object.type + : null; + const scoreEach = object.scoreEach === undefined && object.score === undefined ? null : finiteNumber(object.scoreEach ?? object.score, 0); + return { key, title, questionType, scoreEach, sortOrder: index }; + }); +} + +function sectionForQuestion(row: PracticeSessionReportQuestionRow, sections: SectionDefinition[]) { + const direct = row.sectionKey ? sections.find(section => section.key === row.sectionKey) : undefined; + if (direct) return direct; + const byType = sections.find(section => section.questionType && section.questionType === row.type); + if (byType) return byType; + return { + key: row.sectionKey || row.type || 'default', + title: row.typeLabel, + questionType: row.type || null, + scoreEach: null, + sortOrder: sections.length, + }; +} + +function resolveQuestionScore(input: { + row: PracticeSessionReportQuestionRow; + section: SectionDefinition; + fallbackScore: number; +}) { + if (input.row.itemScore !== null && input.row.itemScore !== undefined) return finiteNumber(input.row.itemScore, input.fallbackScore); + if (input.section.scoreEach !== null && Number.isFinite(input.section.scoreEach)) return input.section.scoreEach; + return input.fallbackScore; +} + function modeFrom(value: string) { if (['sequential', 'random', 'mock_exam', 'paper', 'wrong_review', 'favorite_review', 'chapter'].includes(value)) return value; return 'chapter'; @@ -524,6 +699,417 @@ export async function submitAnswerRoute(ctx: RequestContext) { return { item: result }; } +function formatReport(row: PracticeSessionReportRow) { + return { + ...row, + totalQuestions: Number(row.totalQuestions), + answeredCount: Number(row.answeredCount), + correctCount: Number(row.correctCount), + wrongCount: Number(row.wrongCount), + unansweredCount: Number(row.unansweredCount), + score: finiteNumber(row.score), + totalScore: finiteNumber(row.totalScore), + accuracy: finiteNumber(row.accuracy), + durationSeconds: Number(row.durationSeconds || 0), + sectionStats: jsonArray(row.sectionStats), + questionResults: jsonArray(row.questionResults), + wrongQuestionIds: normalizeStringArray(row.wrongQuestionIds), + metadata: jsonObject(row.metadata), + }; +} + +async function fetchPracticeReportBySession( + client: { query: pg.PoolClient['query'] }, + tenantId: string, + userId: string, + practiceSessionId: string, +) { + const result = await client.query( + ` + select id, tenant_id as "tenantId", user_id as "userId", + practice_session_id as "practiceSessionId", + blueprint_id as "blueprintId", collection_id as "collectionId", mode, + total_questions as "totalQuestions", answered_count as "answeredCount", + correct_count as "correctCount", wrong_count as "wrongCount", + unanswered_count as "unansweredCount", + score, total_score as "totalScore", accuracy, + duration_seconds as "durationSeconds", + started_at as "startedAt", submitted_at as "submittedAt", + section_stats as "sectionStats", question_results as "questionResults", + wrong_question_ids as "wrongQuestionIds", metadata, + created_at as "createdAt", updated_at as "updatedAt" + from public.practice_session_reports + where tenant_id = $1 and user_id = $2 and practice_session_id = $3 + limit 1 + `, + [tenantId, userId, practiceSessionId], + ); + return result.rows[0] || null; +} + +async function buildPracticeSessionReport( + client: pg.PoolClient, + tenantId: string, + userId: string, + practiceSessionId: string, +) { + const existing = await fetchPracticeReportBySession(client, tenantId, userId, practiceSessionId); + if (existing) return existing; + + const sessionResult = await client.query( + ` + select id, tenant_id as "tenantId", user_id as "userId", mode, + blueprint_id as "blueprintId", collection_id as "collectionId", + entry_id as "entryId", content_node_id as "contentNodeId", + question_ids as "questionIds", question_count as "questionCount", + duration_minutes as "durationMinutes", total_score as "totalScore", + started_at as "startedAt", finished_at as "finishedAt", metadata + from public.practice_sessions + where tenant_id = $1 and user_id = $2 and id = $3 + for update + `, + [tenantId, userId, practiceSessionId], + ); + const session = sessionResult.rows[0]; + if (!session) { + throw new HttpError(404, 'Practice session not found', 'PRACTICE_SESSION_NOT_FOUND'); + } + + const existingAfterLock = await fetchPracticeReportBySession(client, tenantId, userId, practiceSessionId); + if (existingAfterLock) return existingAfterLock; + + const questionIds = idArrayFromJson(session.questionIds); + if (questionIds.length === 0) { + throw new HttpError(409, 'Practice session has no question snapshot', 'PRACTICE_SESSION_EMPTY'); + } + + const sectionDefinitions = sectionDefinitionsFrom(session.metadata); + const fallbackQuestionScore = session.totalScore + ? finiteNumber(session.totalScore, questionIds.length) / Math.max(1, questionIds.length) + : 1; + + const questionRows = await client.query( + ` + select q.id as "questionId", q.type, q.type_label as "typeLabel", + ci.collection_id as "collectionId", ci.section_key as "sectionKey", + ci.sort_order as "sortOrder", ci.score as "itemScore", + v.content, v.explanation, + v.correct_option_index as "correctOptionIndex", + v.correct_option_indices as "correctOptionIndices", + v.answer_text as "answerText" + from public.questions q + left join public.question_versions v on v.id = q.current_version_id + left join public.question_collection_items ci + on ci.tenant_id = q.tenant_id + and ci.question_id = q.id + and ci.collection_id = $3::uuid + where q.tenant_id = $1 and q.id = any($2::uuid[]) + `, + [tenantId, questionIds, session.collectionId], + ); + const questionById = new Map(questionRows.rows.map(row => [row.questionId, row])); + + const answerRows = await client.query( + ` + select distinct on (question_id) + question_id as "questionId", selected_options as "selectedOptions", + answer_text as "answerText", is_correct as "isCorrect", + answered_at as "answeredAt" + from public.answer_records + where tenant_id = $1 + and user_id = $2 + and practice_session_id = $3 + and question_id = any($4::uuid[]) + order by question_id, answered_at desc + `, + [tenantId, userId, practiceSessionId, questionIds], + ); + const answerByQuestionId = new Map(answerRows.rows.map(row => [row.questionId, row])); + + const sectionStatsByKey = new Map(); + const questionResults: PracticeReportQuestionResult[] = []; + const wrongQuestionIds: string[] = []; + + for (const questionId of questionIds) { + const question = questionById.get(questionId); + if (!question) continue; + const answer = answerByQuestionId.get(questionId) || null; + const section = sectionForQuestion(question, sectionDefinitions); + const questionScore = round2(resolveQuestionScore({ row: question, section, fallbackScore: fallbackQuestionScore })); + const answered = !!answer; + const isCorrect = answer?.isCorrect ?? null; + const earnedScore = isCorrect === true ? questionScore : 0; + const sectionStat = sectionStatsByKey.get(section.key) || { + key: section.key, + title: section.title, + questionType: section.questionType, + questionCount: 0, + answeredCount: 0, + correctCount: 0, + wrongCount: 0, + unansweredCount: 0, + score: 0, + totalScore: 0, + accuracy: 0, + sortOrder: section.sortOrder, + }; + + sectionStat.questionCount += 1; + sectionStat.totalScore = round2(sectionStat.totalScore + questionScore); + if (answered) sectionStat.answeredCount += 1; + if (isCorrect === true) { + sectionStat.correctCount += 1; + sectionStat.score = round2(sectionStat.score + earnedScore); + } else if (answered) { + sectionStat.wrongCount += 1; + wrongQuestionIds.push(questionId); + } else { + sectionStat.unansweredCount += 1; + } + sectionStatsByKey.set(section.key, sectionStat); + + questionResults.push({ + questionId, + questionType: question.type, + sectionKey: section.key, + score: earnedScore, + totalScore: questionScore, + answered, + isCorrect, + selectedOptions: normalizeStringArray(answer?.selectedOptions), + answerText: answer?.answerText || null, + answeredAt: answer?.answeredAt || null, + correctOptionIndex: question.correctOptionIndex, + correctOptionIndices: normalizeNumberArray(question.correctOptionIndices), + correctAnswerText: question.answerText, + explanation: question.explanation, + content: question.content, + }); + } + + const sectionStats = [...sectionStatsByKey.values()] + .sort((left, right) => left.sortOrder - right.sortOrder || left.key.localeCompare(right.key)) + .map(section => ({ + ...section, + unansweredCount: Math.max(0, section.questionCount - section.answeredCount), + accuracy: round4(section.questionCount ? section.correctCount / section.questionCount : 0), + score: round2(section.score), + totalScore: round2(section.totalScore), + })); + + const totalQuestions = questionResults.length; + const answeredCount = questionResults.filter(item => item.answered).length; + const correctCount = questionResults.filter(item => item.isCorrect === true).length; + const wrongCount = questionResults.filter(item => item.answered && item.isCorrect !== true).length; + const unansweredCount = Math.max(0, totalQuestions - answeredCount); + const score = round2(questionResults.reduce((sum, item) => sum + item.score, 0)); + const computedTotalScore = round2(questionResults.reduce((sum, item) => sum + item.totalScore, 0)); + const configuredTotalScore = session.totalScore ? round2(finiteNumber(session.totalScore, computedTotalScore)) : computedTotalScore; + const submittedAt = new Date(); + const startedAt = new Date(session.startedAt); + const durationSeconds = Number.isFinite(startedAt.getTime()) + ? Math.max(0, Math.trunc((submittedAt.getTime() - startedAt.getTime()) / 1000)) + : 0; + + const reportResult = await client.query( + ` + insert into public.practice_session_reports ( + tenant_id, user_id, practice_session_id, blueprint_id, collection_id, mode, + total_questions, answered_count, correct_count, wrong_count, unanswered_count, + score, total_score, accuracy, duration_seconds, started_at, submitted_at, + section_stats, question_results, wrong_question_ids, metadata + ) + values ( + $1, $2, $3, $4::uuid, $5::uuid, $6, + $7, $8, $9, $10, $11, + $12, $13, $14, $15, $16, $17, + $18::jsonb, $19::jsonb, $20::jsonb, $21::jsonb + ) + returning id, tenant_id as "tenantId", user_id as "userId", + practice_session_id as "practiceSessionId", + blueprint_id as "blueprintId", collection_id as "collectionId", mode, + total_questions as "totalQuestions", answered_count as "answeredCount", + correct_count as "correctCount", wrong_count as "wrongCount", + unanswered_count as "unansweredCount", + score, total_score as "totalScore", accuracy, + duration_seconds as "durationSeconds", + started_at as "startedAt", submitted_at as "submittedAt", + section_stats as "sectionStats", question_results as "questionResults", + wrong_question_ids as "wrongQuestionIds", metadata, + created_at as "createdAt", updated_at as "updatedAt" + `, + [ + tenantId, + userId, + practiceSessionId, + session.blueprintId, + session.collectionId, + session.mode, + totalQuestions, + answeredCount, + correctCount, + wrongCount, + unansweredCount, + score, + configuredTotalScore, + round4(totalQuestions ? correctCount / totalQuestions : 0), + durationSeconds, + session.startedAt, + submittedAt.toISOString(), + JSON.stringify(sectionStats), + JSON.stringify(questionResults), + JSON.stringify(wrongQuestionIds), + JSON.stringify({ + scoringVersion: 1, + computedTotalScore, + configuredTotalScore, + entryId: session.entryId, + contentNodeId: session.contentNodeId, + }), + ], + ); + const report = reportResult.rows[0]; + + for (const section of sectionStats) { + await client.query( + ` + insert into public.practice_session_report_sections ( + tenant_id, report_id, practice_session_id, section_key, section_name, question_type, + question_count, answered_count, correct_count, wrong_count, unanswered_count, + score, total_score, accuracy, sort_order, metadata + ) + values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, '{}'::jsonb) + on conflict (tenant_id, report_id, section_key) + do update set section_name = excluded.section_name, + question_type = excluded.question_type, + question_count = excluded.question_count, + answered_count = excluded.answered_count, + correct_count = excluded.correct_count, + wrong_count = excluded.wrong_count, + unanswered_count = excluded.unanswered_count, + score = excluded.score, + total_score = excluded.total_score, + accuracy = excluded.accuracy, + sort_order = excluded.sort_order, + updated_at = now() + `, + [ + tenantId, + report.id, + practiceSessionId, + section.key, + section.title, + section.questionType, + section.questionCount, + section.answeredCount, + section.correctCount, + section.wrongCount, + section.unansweredCount, + section.score, + section.totalScore, + section.accuracy, + section.sortOrder, + ], + ); + } + + await client.query( + ` + update public.practice_sessions + set finished_at = coalesce(finished_at, $4::timestamptz), + metadata = metadata || jsonb_build_object( + 'submittedReportId', $5::uuid, + 'score', $6::numeric, + 'accuracy', $7::numeric, + 'submittedAt', $4::timestamptz + ) + where tenant_id = $1 and user_id = $2 and id = $3 + `, + [tenantId, userId, practiceSessionId, submittedAt.toISOString(), report.id, score, round4(totalQuestions ? correctCount / totalQuestions : 0)], + ); + + return report; +} + +export async function submitPracticeSessionRoute(ctx: RequestContext) { + const body = await readJsonBody(ctx); + const tenantId = await tenantIdFrom(ctx); + const userId = await userIdFrom(ctx, body); + const practiceSessionId = requiredString(body, 'practiceSessionId'); + + const report = await transaction(async client => buildPracticeSessionReport(client, tenantId, userId, practiceSessionId)); + return { item: formatReport(report) }; +} + +export async function practiceSessionReportRoute(ctx: RequestContext) { + const tenantId = await tenantIdFrom(ctx); + const userId = await userIdFrom(ctx); + const practiceSessionId = stringParam(ctx, 'practiceSessionId'); + if (!practiceSessionId) { + throw new HttpError(400, 'practiceSessionId is required', 'PRACTICE_SESSION_ID_REQUIRED'); + } + + const report = await queryOne( + ` + select id, tenant_id as "tenantId", user_id as "userId", + practice_session_id as "practiceSessionId", + blueprint_id as "blueprintId", collection_id as "collectionId", mode, + total_questions as "totalQuestions", answered_count as "answeredCount", + correct_count as "correctCount", wrong_count as "wrongCount", + unanswered_count as "unansweredCount", + score, total_score as "totalScore", accuracy, + duration_seconds as "durationSeconds", + started_at as "startedAt", submitted_at as "submittedAt", + section_stats as "sectionStats", question_results as "questionResults", + wrong_question_ids as "wrongQuestionIds", metadata, + created_at as "createdAt", updated_at as "updatedAt" + from public.practice_session_reports + where tenant_id = $1 and user_id = $2 and practice_session_id = $3 + limit 1 + `, + [tenantId, userId, practiceSessionId], + ); + if (!report) { + throw new HttpError(404, 'Practice session report not found', 'PRACTICE_REPORT_NOT_FOUND'); + } + + return { item: formatReport(report) }; +} + +export async function practiceReportsRoute(ctx: RequestContext) { + const tenantId = await tenantIdFrom(ctx); + const userId = await userIdFrom(ctx); + const blueprintId = stringParam(ctx, 'blueprintId'); + const mode = stringParam(ctx, 'mode'); + const limit = intParam(ctx, 'limit', 50, 200); + + const items = await query( + ` + select id, tenant_id as "tenantId", user_id as "userId", + practice_session_id as "practiceSessionId", + blueprint_id as "blueprintId", collection_id as "collectionId", mode, + total_questions as "totalQuestions", answered_count as "answeredCount", + correct_count as "correctCount", wrong_count as "wrongCount", + unanswered_count as "unansweredCount", + score, total_score as "totalScore", accuracy, + duration_seconds as "durationSeconds", + started_at as "startedAt", submitted_at as "submittedAt", + section_stats as "sectionStats", question_results as "questionResults", + wrong_question_ids as "wrongQuestionIds", metadata, + created_at as "createdAt", updated_at as "updatedAt" + from public.practice_session_reports + where tenant_id = $1 and user_id = $2 + and ($3::uuid is null or blueprint_id = $3::uuid) + and ($4::text = '' or mode = $4::text) + order by submitted_at desc + limit $5 + `, + [tenantId, userId, blueprintId || null, mode, limit], + ); + + return { items: items.map(formatReport) }; +} + export async function favoriteQuestionsRoute(ctx: RequestContext) { const tenantId = await tenantIdFrom(ctx); const userId = await userIdFrom(ctx); diff --git a/docs/refactor/backend-capability-status.md b/docs/refactor/backend-capability-status.md index f1a4e051..09316a1b 100644 --- a/docs/refactor/backend-capability-status.md +++ b/docs/refactor/backend-capability-status.md @@ -57,7 +57,7 @@ | 错题本 | 可联调 | `/api/learning/wrong-questions` | | 收藏夹 | 可联调 | `/api/learning/favorites/questions` | | 免费用户题量限制 | 可联调 | `practice_daily_usage` + `practice_access_events`;支持内容 accessRules、每日额度、session 截断、SVIP-only 拦截 | -| 模考交卷报告 | 待补齐 | 已有 session/answer 基础,缺完整交卷、评分报告、错题解析汇总 | +| 模考交卷报告 | 可联调 | `POST /api/learning/practice-sessions/submit`、`GET /api/learning/practice-sessions/report`、`GET /api/learning/practice-reports`;后端按 session 快照评分、分段统计、错题解析汇总,重复提交幂等 | ## 背单词、知识手册、分数线、视频 diff --git a/docs/refactor/legacy-feature-gap-matrix.md b/docs/refactor/legacy-feature-gap-matrix.md index 63034519..3e69ccf9 100644 --- a/docs/refactor/legacy-feature-gap-matrix.md +++ b/docs/refactor/legacy-feature-gap-matrix.md @@ -22,7 +22,7 @@ | 多级分类树 | 旧 module/subject/category 树 | 已覆盖 | 新后端支持任意深度和 `marker_type`;前端不要写死层级 | | 顺序刷题 | `pages/Quiz.tsx` | 已覆盖 | 免费额度/SVIP 校验已由后端强制;继续补断点续练、更多题型渲染 | | 随机刷题 | `pages/Quiz.tsx` | 已覆盖 | 已有 blueprint/session 快照和访问控制,前端需按 mode 调用 | -| 全真模拟 | `components/AdminMockexam`、`MockExamConfigModal.tsx` | 部分覆盖 | 后端有 blueprint 基础;缺完整交卷报告、排名、复盘 | +| 全真模拟 | `components/AdminMockexam`、`MockExamConfigModal.tsx` | 部分覆盖 | blueprint、session 快照、交卷评分、分段统计和错题解析汇总已覆盖;后续补排名、断点续练、复盘体验 | | 错题本 | 用户 stats/错题逻辑 | 已覆盖 | 后续补错题复习计划 | | 收藏夹 | `WordFavoritesPage.tsx`、题目收藏 | 已覆盖 | 题目和单词收藏已有 | | 题目视频 | `VideoPlayer.tsx` | 部分覆盖 | 题目视频查询、播放签名、SVIP/次数扣减、播放日志已有;缺深度防盗链、动态水印、播放统计报表 | diff --git a/docs/refactor/next-development-todo.md b/docs/refactor/next-development-todo.md index 22e4a6ff..5ce3fec9 100644 --- a/docs/refactor/next-development-todo.md +++ b/docs/refactor/next-development-todo.md @@ -8,12 +8,13 @@ - Supabase/PostgreSQL 多租户 schema、RLS、索引、触发器。 - Node.js API 分层:`core/features`。 -- 学生端核心 API:题库、练习、答题、错题、收藏、背单词、知识手册、分数线、视频播放签名、资料、订单、权益、个人中心。 +- 学生端核心 API:题库、练习、答题、模考交卷报告、错题、收藏、背单词、知识手册、分数线、视频播放签名、资料、订单、权益、个人中心。 - 租户后台 API:品牌、域名、设置、支付账户、登录 provider、私密密钥、活动、激活码、优惠券、成员权限、审计、内容管理。 - 平台后台 API:租户、SaaS 套餐、订阅、账单、服务费收款、用量。 - 销售/代理/CRM 增长链路:邀请码、扫码事件、首绑保护、团队、统计、CRM 队列。 - 内容导航:`content_entries/content_nodes` 支持任意深度入口和分类。 - 练习组卷:`question_collections/practice_blueprints` 支持顺序、随机、全真模拟快照。 +- 模考报告:`practice_session_reports/practice_session_report_sections` 支持交卷、评分、题型/小节统计、错题解析汇总和历史查询。 - 练习访问控制:`practice_daily_usage/practice_access_events` 支持免费每日额度、SVIP 范围校验、SVIP-only 内容拦截和答题 session 快照保护。 - 内容导入:题目、单词、知识手册 JSON 预览、校验、导入、幂等、审计。 - 本地验证:`npm run check:refactor` 已通过。 @@ -77,10 +78,10 @@ - 单题视频和通用知识视频混合推荐。 6. 学习统计 - - 已完成免费额度和练习访问事件基础。 + - 已完成免费额度、练习访问事件、模考交卷评分报告基础。 - 继续补练习历史、正确率趋势、题型分布、错题复习计划。 - 单词复习算法、每日计划、排行榜。 - - 模考交卷、评分报告、错题解析汇总。 + - 模考排名、断点续练、复盘体验。 7. 数据看板 - 收益、注册趋势、答题次数、收入趋势、题型分布、科目数量、题目总量。 diff --git a/docs/refactor/taro-frontend-integration.md b/docs/refactor/taro-frontend-integration.md index e317c5bf..d9d9273b 100644 --- a/docs/refactor/taro-frontend-integration.md +++ b/docs/refactor/taro-frontend-integration.md @@ -137,6 +137,7 @@ tenant::theme | 题目列表 | `/api/catalog/question-collections`、`/api/catalog/question-collections/questions` | | 开始练习 | `POST /api/learning/practice-sessions` | | 提交答案 | `POST /api/learning/answers` | +| 交卷/报告 | `POST /api/learning/practice-sessions/submit`、`GET /api/learning/practice-sessions/report`、`GET /api/learning/practice-reports` | | 错题本 | `GET /api/learning/wrong-questions`、`POST /api/learning/wrong-questions/resolve` | | 收藏夹 | `GET/POST /api/learning/favorites/questions` | | 题目视频 | `GET /api/questions/{questionId}/videos`、`POST /api/questions/videos/batch`、`POST /api/videos/play` | @@ -197,6 +198,70 @@ tenant::theme - `PRACTICE_SESSION_QUESTION_FORBIDDEN`:说明提交答案的题目不在本次 session 快照内,应清理本地异常进度并重新开始。 - 提交答案必须传 `practiceSessionId`;后端会拒绝不属于本人有效 session 的题目。 +### 模考交卷与报告 + +全真模拟、试卷模式、顺序练习的最终报告都走后端交卷接口。前端不得传分数、正确数或题目范围;后端只信任 `practice_sessions.question_ids` 快照和 `answer_records` 最新答题记录。 + +交卷请求: + +```json +{ + "practiceSessionId": "00000000-0000-0000-0000-000000000000" +} +``` + +响应关键字段: + +```json +{ + "item": { + "id": "...", + "practiceSessionId": "...", + "mode": "mock_exam", + "totalQuestions": 3, + "answeredCount": 2, + "correctCount": 1, + "wrongCount": 1, + "unansweredCount": 1, + "score": 2, + "totalScore": 100, + "accuracy": 0.3333, + "sectionStats": [ + { + "key": "choice", + "title": "单选题", + "questionCount": 3, + "correctCount": 1, + "score": 2, + "totalScore": 6 + } + ], + "wrongQuestionIds": ["..."], + "questionResults": [ + { + "questionId": "...", + "sectionKey": "choice", + "answered": true, + "isCorrect": false, + "score": 0, + "totalScore": 2, + "selectedOptions": ["0"], + "correctOptionIndices": [1], + "explanation": "..." + } + ] + } +} +``` + +前端处理规则: + +- 重复交卷是幂等的,后端会返回同一份报告。 +- 报告页刷新时调用 `GET /api/learning/practice-sessions/report?practiceSessionId=...`。 +- 个人中心/模考历史调用 `GET /api/learning/practice-reports?mode=mock_exam&limit=20`,也可以传 `blueprintId` 筛选某套模拟卷。 +- `score` 是逐题得分合计;`totalScore` 保留后台配置的卷面总分。测试或预发数据题量不足时,两者不一定按百分制等比换算,前端展示时不要自行重算。 +- 错题复盘优先使用 `wrongQuestionIds` 和 `questionResults`,题目详情仍可按现有题目接口或 session 快照加载。 + ## 视频播放契约 题目视频分为 `free`、`svip`、`video_quota` 三种访问模式。列表接口只用于展示标题、封面、时长、访问模式和试看秒数;除免费公开视频外,列表和搜索接口不会返回可播放 URL。 diff --git a/scripts/api-integration-test.js b/scripts/api-integration-test.js index 92a9fc47..4ccba637 100644 --- a/scripts/api-integration-test.js +++ b/scripts/api-integration-test.js @@ -663,6 +663,83 @@ async function testCatalogAndLearning() { assert.equal(Number(mockSession.item?.totalScore), 100, 'mock session should inherit total score'); assert.ok(mockSession.item?.questionIds?.includes(ids.question), 'mock session should snapshot assembled questions'); + await request('/api/learning/answers', { + userId: TENANT_ADMIN_USER_ID, + method: 'POST', + body: { + userId: TENANT_ADMIN_USER_ID, + questionId: ids.question, + selectedOptions: ['1'], + practiceSessionId: mockSession.item.id, + }, + }); + await request('/api/learning/answers', { + userId: TENANT_ADMIN_USER_ID, + method: 'POST', + body: { + userId: TENANT_ADMIN_USER_ID, + questionId: ids.questionTwo, + selectedOptions: ['0'], + practiceSessionId: mockSession.item.id, + }, + }); + + const mockReport = await request('/api/learning/practice-sessions/submit', { + userId: TENANT_ADMIN_USER_ID, + method: 'POST', + body: { + userId: TENANT_ADMIN_USER_ID, + practiceSessionId: mockSession.item.id, + }, + }); + assert.equal(mockReport.item?.practiceSessionId, mockSession.item.id, 'mock submit should create a report for the session'); + assert.equal(mockReport.item?.totalQuestions, 3, 'mock report should count session snapshot questions'); + assert.equal(mockReport.item?.answeredCount, 2, 'mock report should count latest submitted answers'); + assert.equal(mockReport.item?.correctCount, 1, 'mock report should count correct answers'); + assert.equal(mockReport.item?.wrongCount, 1, 'mock report should count wrong answers'); + assert.equal(mockReport.item?.unansweredCount, 1, 'mock report should count unanswered questions'); + assert.equal(mockReport.item?.score, 2, 'mock report should use backend scoring only'); + assert.equal(mockReport.item?.totalScore, 100, 'mock report should keep configured paper total score'); + assert.ok(mockReport.item?.wrongQuestionIds?.includes(ids.questionTwo), 'mock report should include wrong question ids'); + assert.ok(mockReport.item?.questionResults?.some(item => item.questionId === ids.question && item.isCorrect === true), 'mock report should include per-question result'); + + const idempotentReport = await request('/api/learning/practice-sessions/submit', { + userId: TENANT_ADMIN_USER_ID, + method: 'POST', + body: { + userId: TENANT_ADMIN_USER_ID, + practiceSessionId: mockSession.item.id, + }, + }); + assert.equal(idempotentReport.item?.id, mockReport.item.id, 'mock submit should be idempotent'); + + const fetchedReport = await request('/api/learning/practice-sessions/report', { + userId: TENANT_ADMIN_USER_ID, + query: { practiceSessionId: mockSession.item.id }, + }); + assert.equal(fetchedReport.item?.id, mockReport.item.id, 'mock report should be queryable'); + + const reportList = await request('/api/learning/practice-reports', { + userId: TENANT_ADMIN_USER_ID, + query: { blueprintId: ids.practiceBlueprintMock }, + }); + assert.ok(reportList.items?.some(item => item.id === mockReport.item.id), 'mock report list should include submitted report'); + + const crossUserReport = await request('/api/learning/practice-sessions/report', { + query: { practiceSessionId: mockSession.item.id }, + expectStatus: 404, + }); + assert.equal(crossUserReport.code, 'PRACTICE_REPORT_NOT_FOUND', 'student should not query another user report'); + + const crossUserSubmit = await request('/api/learning/practice-sessions/submit', { + method: 'POST', + body: { + practiceSessionId: mockSession.item.id, + }, + expectStatus: 404, + }); + assert.equal(crossUserSubmit.code, 'PRACTICE_SESSION_NOT_FOUND', 'student should not submit another user session'); + const wrong = await request('/api/learning/wrong-questions', { userId: false, headers: { authorization: `Bearer ${freeLogin.session.token}` }, diff --git a/supabase/migrations/202606210012_mock_exam_reports.sql b/supabase/migrations/202606210012_mock_exam_reports.sql new file mode 100644 index 00000000..05a18d5e --- /dev/null +++ b/supabase/migrations/202606210012_mock_exam_reports.sql @@ -0,0 +1,77 @@ +create table if not exists public.practice_session_reports ( + id uuid primary key default gen_random_uuid(), + tenant_id uuid not null references public.tenants(id) on delete cascade, + user_id uuid not null references public.platform_users(id) on delete cascade, + practice_session_id uuid not null references public.practice_sessions(id) on delete cascade, + blueprint_id uuid references public.practice_blueprints(id) on delete set null, + collection_id uuid references public.question_collections(id) on delete set null, + mode text not null default 'mock_exam', + total_questions integer not null default 0 check (total_questions >= 0), + answered_count integer not null default 0 check (answered_count >= 0), + correct_count integer not null default 0 check (correct_count >= 0), + wrong_count integer not null default 0 check (wrong_count >= 0), + unanswered_count integer not null default 0 check (unanswered_count >= 0), + score numeric(10,2) not null default 0 check (score >= 0), + total_score numeric(10,2) not null default 0 check (total_score >= 0), + accuracy numeric(6,4) not null default 0 check (accuracy >= 0 and accuracy <= 1), + duration_seconds integer not null default 0 check (duration_seconds >= 0), + started_at timestamptz, + submitted_at timestamptz not null default now(), + section_stats jsonb not null default '[]'::jsonb, + question_results jsonb not null default '[]'::jsonb, + wrong_question_ids jsonb not null default '[]'::jsonb, + metadata jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (tenant_id, practice_session_id) +); + +create table if not exists public.practice_session_report_sections ( + id uuid primary key default gen_random_uuid(), + tenant_id uuid not null references public.tenants(id) on delete cascade, + report_id uuid not null references public.practice_session_reports(id) on delete cascade, + practice_session_id uuid not null references public.practice_sessions(id) on delete cascade, + section_key text not null, + section_name text, + question_type text, + question_count integer not null default 0 check (question_count >= 0), + answered_count integer not null default 0 check (answered_count >= 0), + correct_count integer not null default 0 check (correct_count >= 0), + wrong_count integer not null default 0 check (wrong_count >= 0), + unanswered_count integer not null default 0 check (unanswered_count >= 0), + score numeric(10,2) not null default 0 check (score >= 0), + total_score numeric(10,2) not null default 0 check (total_score >= 0), + accuracy numeric(6,4) not null default 0 check (accuracy >= 0 and accuracy <= 1), + sort_order integer not null default 0, + metadata jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (tenant_id, report_id, section_key) +); + +create index if not exists idx_practice_session_reports_user + on public.practice_session_reports(tenant_id, user_id, submitted_at desc); + +create index if not exists idx_practice_session_reports_blueprint + on public.practice_session_reports(tenant_id, blueprint_id, submitted_at desc) + where blueprint_id is not null; + +create index if not exists idx_practice_session_report_sections_report + on public.practice_session_report_sections(tenant_id, report_id, sort_order); + +do $$ +declare + table_name text; +begin + foreach table_name in array array['practice_session_reports', 'practice_session_report_sections'] + loop + execute format('alter table public.%I enable row level security', table_name); + execute format('drop policy if exists tenant_isolation on public.%I', table_name); + execute format( + 'create policy tenant_isolation on public.%I for all using (tenant_id = app.current_tenant_id() or app.is_platform_admin()) with check (tenant_id = app.current_tenant_id() or app.is_platform_admin())', + table_name + ); + execute format('drop trigger if exists set_updated_at on public.%I', table_name); + execute format('create trigger set_updated_at before update on public.%I for each row execute function app.touch_updated_at()', table_name); + end loop; +end $$;