feat: add practice session resume

This commit is contained in:
Codex
2026-06-29 15:19:35 +08:00
parent b96897b2be
commit 07a6edeea2
12 changed files with 291 additions and 12 deletions

View File

@@ -6,6 +6,7 @@ import {
favoriteWordsRoute,
learningStatsRoute,
learningTrendRoute,
practiceSessionDetailRoute,
practiceReportsRoute,
practiceHistoryRoute,
practiceSessionReportRoute,
@@ -26,6 +27,7 @@ import {
export const learningRoutes: RouteDefinition[] = [
['GET', '/api/learning/leaderboard', learningLeaderboardRoute],
['POST', '/api/learning/practice-sessions', createPracticeSessionRoute],
['GET', '/api/learning/practice-sessions/detail', practiceSessionDetailRoute],
['POST', '/api/learning/practice-sessions/submit', submitPracticeSessionRoute],
['GET', '/api/learning/practice-sessions/report', practiceSessionReportRoute],
['GET', '/api/learning/practice-sessions/history', practiceHistoryRoute],

View File

@@ -54,6 +54,16 @@ interface PracticeSessionReportSessionRow {
metadata: Record<string, unknown>;
}
interface PracticeSessionDetailRow extends PracticeSessionReportSessionRow {
targetType: string | null;
targetId: string | null;
accessMode: string | null;
accessEntitlementId: string | null;
consumedFreeQuota: number;
accessSnapshot: unknown;
expiresAt: string | null;
}
interface PracticeSessionReportRow {
id: string;
tenantId: string;
@@ -829,6 +839,27 @@ function formatReport(row: PracticeSessionReportRow) {
};
}
function practiceSessionStatus(row: { finishedAt?: string | null; expiresAt?: string | null }) {
if (row.finishedAt) return 'finished';
if (row.expiresAt && new Date(row.expiresAt).getTime() <= Date.now()) return 'expired';
return 'active';
}
function formatPracticeSession(row: PracticeSessionDetailRow & Record<string, unknown>) {
const questionIds = idArrayFromJson(row.questionIds);
return {
...row,
questionIds,
questionCount: Number(row.questionCount || questionIds.length),
durationMinutes: row.durationMinutes === null || row.durationMinutes === undefined ? null : Number(row.durationMinutes),
totalScore: row.totalScore === null || row.totalScore === undefined ? null : finiteNumber(row.totalScore, 0),
consumedFreeQuota: row.consumedFreeQuota === undefined ? undefined : Number(row.consumedFreeQuota || 0),
accessSnapshot: jsonObject(row.accessSnapshot),
metadata: jsonObject(row.metadata),
status: practiceSessionStatus(row),
};
}
async function fetchPracticeReportBySession(
client: { query: pg.PoolClient['query'] },
tenantId: string,
@@ -1187,6 +1218,93 @@ export async function practiceSessionReportRoute(ctx: RequestContext) {
return { item: formatReport(report) };
}
export async function practiceSessionDetailRoute(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 session = await queryOne<PracticeSessionDetailRow & Record<string, unknown>>(
`
select ps.id, ps.tenant_id as "tenantId", ps.user_id as "userId", ps.mode,
ps.target_type as "targetType", ps.target_id as "targetId",
ps.blueprint_id as "blueprintId", ps.collection_id as "collectionId",
ps.entry_id as "entryId", ps.content_node_id as "contentNodeId",
ps.question_ids as "questionIds", ps.question_count as "questionCount",
ps.duration_minutes as "durationMinutes", ps.total_score as "totalScore",
ps.access_mode as "accessMode", ps.access_entitlement_id as "accessEntitlementId",
ps.consumed_free_quota as "consumedFreeQuota", ps.access_snapshot as "accessSnapshot",
ps.started_at as "startedAt", ps.finished_at as "finishedAt",
ps.expires_at as "expiresAt", ps.metadata
from public.practice_sessions ps
where ps.tenant_id = $1 and ps.user_id = $2 and ps.id = $3
limit 1
`,
[tenantId, userId, practiceSessionId],
);
if (!session) {
throw new HttpError(404, 'Practice session not found', 'PRACTICE_SESSION_NOT_FOUND');
}
const questionIds = idArrayFromJson(session.questionIds);
const [questions, answers] = await Promise.all([
questionIds.length ? query(
`
select q.id, q.legacy_id as "legacyId", q.entry_id as "entryId",
q.content_node_id as "contentNodeId",
q.primary_collection_id as "primaryCollectionId",
q.subject_id as "subjectId", q.category_id as "categoryId", q.node_id as "nodeId",
q.type, q.type_label as "typeLabel", q.difficulty, q.tags,
q.exam_markers as "examMarkers",
q.media_url as "mediaUrl", q.has_video_explanation as "hasVideoExplanation",
v.id as "versionId", v.content, v.options,
v.correct_option_index as "correctOptionIndex",
v.correct_option_indices as "correctOptionIndices",
v.answer_text as "answerText", v.explanation, v.sub_questions as "subQuestions",
v.code_lang as "codeLang", v.code_template as "codeTemplate",
q.created_at as "createdAt", q.updated_at as "updatedAt"
from public.questions q
left join public.question_versions v on v.id = q.current_version_id
where q.tenant_id = $1 and q.id = any($2::uuid[])
`,
[tenantId, questionIds],
) : Promise.resolve([]),
questionIds.length ? query(
`
select distinct on (question_id)
id, question_id as "questionId", question_version_id as "questionVersionId",
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],
) : Promise.resolve([]),
]);
const questionById = new Map((questions as Array<Record<string, unknown> & { id: string }>).map(item => [item.id, item]));
const orderedQuestions = questionIds.map(id => questionById.get(id)).filter(Boolean);
const answersByQuestion = Object.fromEntries(
(answers as Array<Record<string, unknown> & { questionId: string }>).map(answer => [answer.questionId, {
...answer,
selectedOptions: normalizeStringArray(answer.selectedOptions),
}]),
);
return {
item: {
...formatPracticeSession(session),
questions: orderedQuestions,
answersByQuestion,
},
};
}
export async function practiceReportsRoute(ctx: RequestContext) {
const tenantId = await tenantIdFrom(ctx);
const userId = await userIdFrom(ctx);