forked from wangziqi/gongxue-base
feat: add mock exam reports
This commit is contained in:
@@ -3,8 +3,11 @@ import {
|
|||||||
createPracticeSessionRoute,
|
createPracticeSessionRoute,
|
||||||
favoriteQuestionsRoute,
|
favoriteQuestionsRoute,
|
||||||
favoriteWordsRoute,
|
favoriteWordsRoute,
|
||||||
|
practiceReportsRoute,
|
||||||
|
practiceSessionReportRoute,
|
||||||
resolveWrongQuestionRoute,
|
resolveWrongQuestionRoute,
|
||||||
submitAnswerRoute,
|
submitAnswerRoute,
|
||||||
|
submitPracticeSessionRoute,
|
||||||
toggleFavoriteQuestionRoute,
|
toggleFavoriteQuestionRoute,
|
||||||
toggleFavoriteWordRoute,
|
toggleFavoriteWordRoute,
|
||||||
updateWordProgressRoute,
|
updateWordProgressRoute,
|
||||||
@@ -15,6 +18,9 @@ import {
|
|||||||
|
|
||||||
export const learningRoutes: RouteDefinition[] = [
|
export const learningRoutes: RouteDefinition[] = [
|
||||||
['POST', '/api/learning/practice-sessions', createPracticeSessionRoute],
|
['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],
|
['POST', '/api/learning/answers', submitAnswerRoute],
|
||||||
['GET', '/api/learning/favorites/questions', favoriteQuestionsRoute],
|
['GET', '/api/learning/favorites/questions', favoriteQuestionsRoute],
|
||||||
['POST', '/api/learning/favorites/questions', toggleFavoriteQuestionRoute],
|
['POST', '/api/learning/favorites/questions', toggleFavoriteQuestionRoute],
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type pg from 'pg';
|
||||||
import { HttpError, type RequestContext } from '../../core/http.js';
|
import { HttpError, type RequestContext } from '../../core/http.js';
|
||||||
import {
|
import {
|
||||||
intParam,
|
intParam,
|
||||||
@@ -35,6 +36,115 @@ interface PracticeBlueprintRow {
|
|||||||
rules: Record<string, unknown>;
|
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 {
|
interface PracticeAssembly {
|
||||||
mode: string;
|
mode: string;
|
||||||
targetType: string | null;
|
targetType: string | null;
|
||||||
@@ -101,12 +211,77 @@ function jsonArray(value: unknown): unknown[] {
|
|||||||
return Array.isArray(value) ? value : [];
|
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) {
|
function positiveInt(value: unknown, fallback: number, max = 500) {
|
||||||
const parsed = Number(value ?? fallback);
|
const parsed = Number(value ?? fallback);
|
||||||
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
|
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
|
||||||
return Math.min(Math.trunc(parsed), max);
|
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) {
|
function modeFrom(value: string) {
|
||||||
if (['sequential', 'random', 'mock_exam', 'paper', 'wrong_review', 'favorite_review', 'chapter'].includes(value)) return value;
|
if (['sequential', 'random', 'mock_exam', 'paper', 'wrong_review', 'favorite_review', 'chapter'].includes(value)) return value;
|
||||||
return 'chapter';
|
return 'chapter';
|
||||||
@@ -524,6 +699,417 @@ export async function submitAnswerRoute(ctx: RequestContext) {
|
|||||||
return { item: result };
|
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) {
|
export async function favoriteQuestionsRoute(ctx: RequestContext) {
|
||||||
const tenantId = await tenantIdFrom(ctx);
|
const tenantId = await tenantIdFrom(ctx);
|
||||||
const userId = await userIdFrom(ctx);
|
const userId = await userIdFrom(ctx);
|
||||||
|
|||||||
@@ -57,7 +57,7 @@
|
|||||||
| 错题本 | 可联调 | `/api/learning/wrong-questions` |
|
| 错题本 | 可联调 | `/api/learning/wrong-questions` |
|
||||||
| 收藏夹 | 可联调 | `/api/learning/favorites/questions` |
|
| 收藏夹 | 可联调 | `/api/learning/favorites/questions` |
|
||||||
| 免费用户题量限制 | 可联调 | `practice_daily_usage` + `practice_access_events`;支持内容 accessRules、每日额度、session 截断、SVIP-only 拦截 |
|
| 免费用户题量限制 | 可联调 | `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`;前端不要写死层级 |
|
| 多级分类树 | 旧 module/subject/category 树 | 已覆盖 | 新后端支持任意深度和 `marker_type`;前端不要写死层级 |
|
||||||
| 顺序刷题 | `pages/Quiz.tsx` | 已覆盖 | 免费额度/SVIP 校验已由后端强制;继续补断点续练、更多题型渲染 |
|
| 顺序刷题 | `pages/Quiz.tsx` | 已覆盖 | 免费额度/SVIP 校验已由后端强制;继续补断点续练、更多题型渲染 |
|
||||||
| 随机刷题 | `pages/Quiz.tsx` | 已覆盖 | 已有 blueprint/session 快照和访问控制,前端需按 mode 调用 |
|
| 随机刷题 | `pages/Quiz.tsx` | 已覆盖 | 已有 blueprint/session 快照和访问控制,前端需按 mode 调用 |
|
||||||
| 全真模拟 | `components/AdminMockexam`、`MockExamConfigModal.tsx` | 部分覆盖 | 后端有 blueprint 基础;缺完整交卷报告、排名、复盘 |
|
| 全真模拟 | `components/AdminMockexam`、`MockExamConfigModal.tsx` | 部分覆盖 | blueprint、session 快照、交卷评分、分段统计和错题解析汇总已覆盖;后续补排名、断点续练、复盘体验 |
|
||||||
| 错题本 | 用户 stats/错题逻辑 | 已覆盖 | 后续补错题复习计划 |
|
| 错题本 | 用户 stats/错题逻辑 | 已覆盖 | 后续补错题复习计划 |
|
||||||
| 收藏夹 | `WordFavoritesPage.tsx`、题目收藏 | 已覆盖 | 题目和单词收藏已有 |
|
| 收藏夹 | `WordFavoritesPage.tsx`、题目收藏 | 已覆盖 | 题目和单词收藏已有 |
|
||||||
| 题目视频 | `VideoPlayer.tsx` | 部分覆盖 | 题目视频查询、播放签名、SVIP/次数扣减、播放日志已有;缺深度防盗链、动态水印、播放统计报表 |
|
| 题目视频 | `VideoPlayer.tsx` | 部分覆盖 | 题目视频查询、播放签名、SVIP/次数扣减、播放日志已有;缺深度防盗链、动态水印、播放统计报表 |
|
||||||
|
|||||||
@@ -8,12 +8,13 @@
|
|||||||
|
|
||||||
- Supabase/PostgreSQL 多租户 schema、RLS、索引、触发器。
|
- Supabase/PostgreSQL 多租户 schema、RLS、索引、触发器。
|
||||||
- Node.js API 分层:`core/features`。
|
- Node.js API 分层:`core/features`。
|
||||||
- 学生端核心 API:题库、练习、答题、错题、收藏、背单词、知识手册、分数线、视频播放签名、资料、订单、权益、个人中心。
|
- 学生端核心 API:题库、练习、答题、模考交卷报告、错题、收藏、背单词、知识手册、分数线、视频播放签名、资料、订单、权益、个人中心。
|
||||||
- 租户后台 API:品牌、域名、设置、支付账户、登录 provider、私密密钥、活动、激活码、优惠券、成员权限、审计、内容管理。
|
- 租户后台 API:品牌、域名、设置、支付账户、登录 provider、私密密钥、活动、激活码、优惠券、成员权限、审计、内容管理。
|
||||||
- 平台后台 API:租户、SaaS 套餐、订阅、账单、服务费收款、用量。
|
- 平台后台 API:租户、SaaS 套餐、订阅、账单、服务费收款、用量。
|
||||||
- 销售/代理/CRM 增长链路:邀请码、扫码事件、首绑保护、团队、统计、CRM 队列。
|
- 销售/代理/CRM 增长链路:邀请码、扫码事件、首绑保护、团队、统计、CRM 队列。
|
||||||
- 内容导航:`content_entries/content_nodes` 支持任意深度入口和分类。
|
- 内容导航:`content_entries/content_nodes` 支持任意深度入口和分类。
|
||||||
- 练习组卷:`question_collections/practice_blueprints` 支持顺序、随机、全真模拟快照。
|
- 练习组卷:`question_collections/practice_blueprints` 支持顺序、随机、全真模拟快照。
|
||||||
|
- 模考报告:`practice_session_reports/practice_session_report_sections` 支持交卷、评分、题型/小节统计、错题解析汇总和历史查询。
|
||||||
- 练习访问控制:`practice_daily_usage/practice_access_events` 支持免费每日额度、SVIP 范围校验、SVIP-only 内容拦截和答题 session 快照保护。
|
- 练习访问控制:`practice_daily_usage/practice_access_events` 支持免费每日额度、SVIP 范围校验、SVIP-only 内容拦截和答题 session 快照保护。
|
||||||
- 内容导入:题目、单词、知识手册 JSON 预览、校验、导入、幂等、审计。
|
- 内容导入:题目、单词、知识手册 JSON 预览、校验、导入、幂等、审计。
|
||||||
- 本地验证:`npm run check:refactor` 已通过。
|
- 本地验证:`npm run check:refactor` 已通过。
|
||||||
@@ -77,10 +78,10 @@
|
|||||||
- 单题视频和通用知识视频混合推荐。
|
- 单题视频和通用知识视频混合推荐。
|
||||||
|
|
||||||
6. 学习统计
|
6. 学习统计
|
||||||
- 已完成免费额度和练习访问事件基础。
|
- 已完成免费额度、练习访问事件、模考交卷评分报告基础。
|
||||||
- 继续补练习历史、正确率趋势、题型分布、错题复习计划。
|
- 继续补练习历史、正确率趋势、题型分布、错题复习计划。
|
||||||
- 单词复习算法、每日计划、排行榜。
|
- 单词复习算法、每日计划、排行榜。
|
||||||
- 模考交卷、评分报告、错题解析汇总。
|
- 模考排名、断点续练、复盘体验。
|
||||||
|
|
||||||
7. 数据看板
|
7. 数据看板
|
||||||
- 收益、注册趋势、答题次数、收入趋势、题型分布、科目数量、题目总量。
|
- 收益、注册趋势、答题次数、收入趋势、题型分布、科目数量、题目总量。
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ tenant:<tenantId>:theme
|
|||||||
| 题目列表 | `/api/catalog/question-collections`、`/api/catalog/question-collections/questions` |
|
| 题目列表 | `/api/catalog/question-collections`、`/api/catalog/question-collections/questions` |
|
||||||
| 开始练习 | `POST /api/learning/practice-sessions` |
|
| 开始练习 | `POST /api/learning/practice-sessions` |
|
||||||
| 提交答案 | `POST /api/learning/answers` |
|
| 提交答案 | `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 /api/learning/wrong-questions`、`POST /api/learning/wrong-questions/resolve` |
|
||||||
| 收藏夹 | `GET/POST /api/learning/favorites/questions` |
|
| 收藏夹 | `GET/POST /api/learning/favorites/questions` |
|
||||||
| 题目视频 | `GET /api/questions/{questionId}/videos`、`POST /api/questions/videos/batch`、`POST /api/videos/play` |
|
| 题目视频 | `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 快照内,应清理本地异常进度并重新开始。
|
- `PRACTICE_SESSION_QUESTION_FORBIDDEN`:说明提交答案的题目不在本次 session 快照内,应清理本地异常进度并重新开始。
|
||||||
- 提交答案必须传 `practiceSessionId`;后端会拒绝不属于本人有效 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。
|
题目视频分为 `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.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');
|
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', {
|
const wrong = await request('/api/learning/wrong-questions', {
|
||||||
userId: false,
|
userId: false,
|
||||||
headers: { authorization: `Bearer ${freeLogin.session.token}` },
|
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