forked from wangziqi/gongxue-base
feat: add mock exam reports
This commit is contained in:
@@ -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],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 快照评分、分段统计、错题解析汇总,重复提交幂等 |
|
||||
|
||||
## 背单词、知识手册、分数线、视频
|
||||
|
||||
|
||||
@@ -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/次数扣减、播放日志已有;缺深度防盗链、动态水印、播放统计报表 |
|
||||
|
||||
@@ -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. 数据看板
|
||||
- 收益、注册趋势、答题次数、收入趋势、题型分布、科目数量、题目总量。
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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}` },
|
||||
|
||||
77
supabase/migrations/202606210012_mock_exam_reports.sql
Normal file
77
supabase/migrations/202606210012_mock_exam_reports.sql
Normal 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 $$;
|
||||
Reference in New Issue
Block a user