feat: add mock exam reports

This commit is contained in:
Codex
2026-06-28 23:34:31 +08:00
parent 6a7294224d
commit e55942dcec
8 changed files with 817 additions and 5 deletions

View File

@@ -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],

View File

@@ -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<string, unknown>;
}
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<string, unknown>;
}
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<string, unknown>;
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<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
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<string, unknown>): 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<PracticeSessionReportRow>(
`
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<PracticeSessionReportSessionRow>(
`
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<PracticeSessionReportQuestionRow>(
`
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<PracticeSessionReportAnswerRow>(
`
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<string, SectionStat>();
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<PracticeSessionReportRow>(
`
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<PracticeSessionReportRow>(
`
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<PracticeSessionReportRow>(
`
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);

View File

@@ -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 快照评分、分段统计、错题解析汇总,重复提交幂等 |
## 背单词、知识手册、分数线、视频

View File

@@ -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/次数扣减、播放日志已有;缺深度防盗链、动态水印、播放统计报表 |

View File

@@ -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. 数据看板
- 收益、注册趋势、答题次数、收入趋势、题型分布、科目数量、题目总量。

View File

@@ -137,6 +137,7 @@ tenant:<tenantId>: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:<tenantId>: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。

View File

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

View File

@@ -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 $$;