From dfb383d281bd9e5e6c1000332e2a3f6685460a70 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 28 Jun 2026 23:44:52 +0800 Subject: [PATCH] feat: add learning stats and review plans --- apps/api/src/features/learning/index.ts | 8 + apps/api/src/features/learning/routes.ts | 437 +++++++++++++++++- docs/refactor/backend-capability-status.md | 2 + docs/refactor/legacy-feature-gap-matrix.md | 8 +- docs/refactor/next-development-todo.md | 11 +- docs/refactor/taro-frontend-integration.md | 31 ++ scripts/api-integration-test.js | 63 +++ .../202606210013_learning_stats_history.sql | 18 + 8 files changed, 566 insertions(+), 12 deletions(-) create mode 100644 supabase/migrations/202606210013_learning_stats_history.sql diff --git a/apps/api/src/features/learning/index.ts b/apps/api/src/features/learning/index.ts index c1444e5e..2368010c 100644 --- a/apps/api/src/features/learning/index.ts +++ b/apps/api/src/features/learning/index.ts @@ -3,7 +3,10 @@ import { createPracticeSessionRoute, favoriteQuestionsRoute, favoriteWordsRoute, + learningStatsRoute, + learningTrendRoute, practiceReportsRoute, + practiceHistoryRoute, practiceSessionReportRoute, resolveWrongQuestionRoute, submitAnswerRoute, @@ -13,6 +16,7 @@ import { updateWordProgressRoute, wordProgressRoute, wordStatsRoute, + wrongQuestionReviewPlanRoute, wrongQuestionsRoute, } from './routes.js'; @@ -20,11 +24,15 @@ 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-sessions/history', practiceHistoryRoute], ['GET', '/api/learning/practice-reports', practiceReportsRoute], + ['GET', '/api/learning/stats', learningStatsRoute], + ['GET', '/api/learning/trend', learningTrendRoute], ['POST', '/api/learning/answers', submitAnswerRoute], ['GET', '/api/learning/favorites/questions', favoriteQuestionsRoute], ['POST', '/api/learning/favorites/questions', toggleFavoriteQuestionRoute], ['GET', '/api/learning/wrong-questions', wrongQuestionsRoute], + ['GET', '/api/learning/wrong-questions/review-plan', wrongQuestionReviewPlanRoute], ['POST', '/api/learning/wrong-questions/resolve', resolveWrongQuestionRoute], ['GET', '/api/learning/vocabulary/progress', wordProgressRoute], ['POST', '/api/learning/vocabulary/progress', updateWordProgressRoute], diff --git a/apps/api/src/features/learning/routes.ts b/apps/api/src/features/learning/routes.ts index 1093edc2..50cfc439 100644 --- a/apps/api/src/features/learning/routes.ts +++ b/apps/api/src/features/learning/routes.ts @@ -160,6 +160,18 @@ interface PracticeAssembly { rules: Record; } +type TrendMetric = { + date: string; + answeredCount: number; + correctCount: number; + wrongCount: number; + accuracy: number; + sessionCount: number; + reportCount: number; + score: number; + totalScore: number; +}; + function normalizeStringArray(value: unknown): string[] { if (!Array.isArray(value)) return []; return value.map(item => String(item)).filter(item => item !== ''); @@ -234,6 +246,15 @@ function round4(value: number) { return Math.round(value * 10000) / 10000; } +function dateParam(ctx: RequestContext, name: string) { + const value = stringParam(ctx, name); + if (!value) return ''; + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) { + throw new HttpError(400, `${name} must be YYYY-MM-DD`, 'INVALID_DATE_PARAM'); + } + return value; +} + function idArrayFromJson(value: unknown): string[] { return normalizeStringArray(value); } @@ -465,6 +486,41 @@ async function collectFromLegacyTarget( return rows.map(row => row.id); } +async function collectWrongReviewQuestions(tenantId: string, userId: string, limit: number) { + const rows = await query<{ id: string }>( + ` + select q.id + from public.wrong_questions wq + join public.questions q on q.id = wq.question_id and q.tenant_id = wq.tenant_id + where wq.tenant_id = $1 + and wq.user_id = $2 + and wq.resolved_at is null + and q.status = 'published' + order by wq.wrong_count desc, wq.last_wrong_at asc + limit $3 + `, + [tenantId, userId, limit], + ); + return rows.map(row => row.id); +} + +async function collectFavoriteReviewQuestions(tenantId: string, userId: string, limit: number) { + const rows = await query<{ id: string }>( + ` + select q.id + from public.favorite_questions fq + join public.questions q on q.id = fq.question_id and q.tenant_id = fq.tenant_id + where fq.tenant_id = $1 + and fq.user_id = $2 + and q.status = 'published' + order by fq.created_at desc + limit $3 + `, + [tenantId, userId, limit], + ); + return rows.map(row => row.id); +} + function sectionType(section: unknown) { if (!section || typeof section !== 'object' || Array.isArray(section)) return null; const object = section as Record; @@ -485,7 +541,7 @@ function sectionLimit(section: unknown, fallback: number) { return positiveInt(object.questionCount ?? object.limit, fallback); } -async function assembleQuestionIds(tenantId: string, assembly: PracticeAssembly) { +async function assembleQuestionIds(tenantId: string, userId: string, assembly: PracticeAssembly) { const randomize = assembly.mode === 'random' || assembly.mode === 'mock_exam' || assembly.rules.randomize === true; const questionIds: string[] = []; const pushUnique = (ids: string[]) => { @@ -494,6 +550,13 @@ async function assembleQuestionIds(tenantId: string, assembly: PracticeAssembly) } }; + if (assembly.mode === 'wrong_review') { + return collectWrongReviewQuestions(tenantId, userId, assembly.questionLimit); + } + if (assembly.mode === 'favorite_review') { + return collectFavoriteReviewQuestions(tenantId, userId, assembly.questionLimit); + } + if (assembly.sections.length && (assembly.collectionId || assembly.contentNodeId)) { for (const section of assembly.sections) { const limit = sectionLimit(section, assembly.questionLimit); @@ -521,11 +584,17 @@ export async function createPracticeSessionRoute(ctx: RequestContext) { const tenantId = await tenantIdFrom(ctx); const userId = await userIdFrom(ctx, body); const assembly = await buildPracticeAssembly(tenantId, body); - const questionIds = await assembleQuestionIds(tenantId, assembly); + const questionIds = await assembleQuestionIds(tenantId, userId, assembly); if ((assembly.blueprintId || assembly.collectionId || assembly.contentNodeId) && questionIds.length === 0) { throw new HttpError(409, 'No published questions are available for this practice target', 'NO_PRACTICE_QUESTIONS'); } + if (assembly.mode === 'wrong_review' && questionIds.length === 0) { + throw new HttpError(409, 'No unresolved wrong questions are available for review', 'NO_WRONG_REVIEW_QUESTIONS'); + } + if (assembly.mode === 'favorite_review' && questionIds.length === 0) { + throw new HttpError(409, 'No favorite questions are available for review', 'NO_FAVORITE_REVIEW_QUESTIONS'); + } const item = await transaction(async client => { const access = await authorizePracticeSession(client, { @@ -1110,6 +1179,370 @@ export async function practiceReportsRoute(ctx: RequestContext) { return { items: items.map(formatReport) }; } +export async function practiceHistoryRoute(ctx: RequestContext) { + const tenantId = await tenantIdFrom(ctx); + const userId = await userIdFrom(ctx); + const mode = stringParam(ctx, 'mode'); + const status = stringParam(ctx, 'status'); + const limit = intParam(ctx, 'limit', 50, 200); + + const items = await query( + ` + select ps.id, ps.mode, ps.target_type as "targetType", ps.target_id as "targetId", + ps.blueprint_id as "blueprintId", pb.name as "blueprintName", + ps.collection_id as "collectionId", qc.name as "collectionName", + ps.entry_id as "entryId", ce.name as "entryName", + ps.content_node_id as "contentNodeId", cn.name as "contentNodeName", + ps.question_count as "questionCount", ps.duration_minutes as "durationMinutes", + ps.total_score as "sessionTotalScore", ps.access_mode as "accessMode", + ps.started_at as "startedAt", ps.finished_at as "finishedAt", + ps.expires_at as "expiresAt", + case + when ps.finished_at is not null then 'finished' + when ps.expires_at is not null and ps.expires_at <= now() then 'expired' + else 'active' + end as status, + coalesce(ar.answered_count, 0)::integer as "answeredCount", + coalesce(ar.correct_count, 0)::integer as "correctCount", + coalesce(ar.wrong_count, 0)::integer as "wrongCount", + r.id as "reportId", r.score, r.total_score as "reportTotalScore", + r.accuracy, r.submitted_at as "submittedAt" + from public.practice_sessions ps + left join public.practice_blueprints pb on pb.id = ps.blueprint_id and pb.tenant_id = ps.tenant_id + left join public.question_collections qc on qc.id = ps.collection_id and qc.tenant_id = ps.tenant_id + left join public.content_entries ce on ce.id = ps.entry_id and ce.tenant_id = ps.tenant_id + left join public.content_nodes cn on cn.id = ps.content_node_id and cn.tenant_id = ps.tenant_id + left join public.practice_session_reports r on r.practice_session_id = ps.id and r.tenant_id = ps.tenant_id and r.user_id = ps.user_id + left join lateral ( + select count(distinct question_id)::integer as answered_count, + count(distinct question_id) filter (where is_correct is true)::integer as correct_count, + count(distinct question_id) filter (where is_correct is false)::integer as wrong_count + from public.answer_records + where tenant_id = ps.tenant_id + and user_id = ps.user_id + and practice_session_id = ps.id + ) ar on true + where ps.tenant_id = $1 and ps.user_id = $2 + and ($3::text = '' or ps.mode = $3::text) + and ( + $4::text = '' + or ($4::text = 'finished' and ps.finished_at is not null) + or ($4::text = 'active' and ps.finished_at is null and (ps.expires_at is null or ps.expires_at > now())) + or ($4::text = 'expired' and ps.finished_at is null and ps.expires_at is not null and ps.expires_at <= now()) + ) + order by coalesce(ps.finished_at, ps.started_at) desc + limit $5 + `, + [tenantId, userId, mode, status, limit], + ); + + return { items }; +} + +export async function learningStatsRoute(ctx: RequestContext) { + const tenantId = await tenantIdFrom(ctx); + const userId = await userIdFrom(ctx); + const days = intParam(ctx, 'days', 30, 365); + + const [answerStats, sessionStats, reportStats, wrongStats, favoriteStats, typeStats] = await Promise.all([ + queryOne<{ + totalAnswered: string; + correctCount: string; + wrongCount: string; + todayAnswered: string; + latestAnsweredAt: string | null; + }>( + ` + select count(*)::text as "totalAnswered", + count(*) filter (where is_correct is true)::text as "correctCount", + count(*) filter (where is_correct is false)::text as "wrongCount", + count(*) filter (where answered_at::date = current_date)::text as "todayAnswered", + max(answered_at) as "latestAnsweredAt" + from public.answer_records + where tenant_id = $1 and user_id = $2 + `, + [tenantId, userId], + ), + queryOne<{ + totalSessions: string; + activeSessions: string; + finishedSessions: string; + latestStartedAt: string | null; + }>( + ` + select count(*)::text as "totalSessions", + count(*) filter (where finished_at is null and (expires_at is null or expires_at > now()))::text as "activeSessions", + count(*) filter (where finished_at is not null)::text as "finishedSessions", + max(started_at) as "latestStartedAt" + from public.practice_sessions + where tenant_id = $1 and user_id = $2 + `, + [tenantId, userId], + ), + queryOne<{ + reportCount: string; + avgAccuracy: string | null; + bestScore: string | null; + latestSubmittedAt: string | null; + }>( + ` + select count(*)::text as "reportCount", + avg(accuracy)::text as "avgAccuracy", + max(score)::text as "bestScore", + max(submitted_at) as "latestSubmittedAt" + from public.practice_session_reports + where tenant_id = $1 and user_id = $2 + `, + [tenantId, userId], + ), + queryOne<{ + unresolvedWrong: string; + resolvedWrong: string; + totalWrongBook: string; + }>( + ` + select count(*) filter (where resolved_at is null)::text as "unresolvedWrong", + count(*) filter (where resolved_at is not null)::text as "resolvedWrong", + count(*)::text as "totalWrongBook" + from public.wrong_questions + where tenant_id = $1 and user_id = $2 + `, + [tenantId, userId], + ), + queryOne<{ favoriteQuestions: string }>( + ` + select count(*)::text as "favoriteQuestions" + from public.favorite_questions + where tenant_id = $1 and user_id = $2 + `, + [tenantId, userId], + ), + query<{ + questionType: string; + typeLabel: string | null; + answeredCount: string; + correctCount: string; + wrongCount: string; + }>( + ` + select q.type as "questionType", q.type_label as "typeLabel", + count(*)::text as "answeredCount", + count(*) filter (where ar.is_correct is true)::text as "correctCount", + count(*) filter (where ar.is_correct is false)::text as "wrongCount" + from public.answer_records ar + join public.questions q on q.id = ar.question_id and q.tenant_id = ar.tenant_id + where ar.tenant_id = $1 + and ar.user_id = $2 + and ar.answered_at >= current_date - ($3::integer * interval '1 day') + group by q.type, q.type_label + order by count(*) desc, q.type asc + limit 20 + `, + [tenantId, userId, days], + ), + ]); + + const totalAnswered = Number(answerStats?.totalAnswered || 0); + const correctCount = Number(answerStats?.correctCount || 0); + const wrongCount = Number(answerStats?.wrongCount || 0); + return { + item: { + windowDays: days, + answers: { + totalAnswered, + correctCount, + wrongCount, + todayAnswered: Number(answerStats?.todayAnswered || 0), + accuracy: round4(totalAnswered ? correctCount / totalAnswered : 0), + latestAnsweredAt: answerStats?.latestAnsweredAt || null, + }, + sessions: { + totalSessions: Number(sessionStats?.totalSessions || 0), + activeSessions: Number(sessionStats?.activeSessions || 0), + finishedSessions: Number(sessionStats?.finishedSessions || 0), + latestStartedAt: sessionStats?.latestStartedAt || null, + }, + reports: { + reportCount: Number(reportStats?.reportCount || 0), + avgAccuracy: round4(finiteNumber(reportStats?.avgAccuracy, 0)), + bestScore: round2(finiteNumber(reportStats?.bestScore, 0)), + latestSubmittedAt: reportStats?.latestSubmittedAt || null, + }, + wrongBook: { + unresolvedWrong: Number(wrongStats?.unresolvedWrong || 0), + resolvedWrong: Number(wrongStats?.resolvedWrong || 0), + totalWrongBook: Number(wrongStats?.totalWrongBook || 0), + }, + favorites: { + favoriteQuestions: Number(favoriteStats?.favoriteQuestions || 0), + }, + questionTypes: typeStats.map(item => { + const answered = Number(item.answeredCount || 0); + const correct = Number(item.correctCount || 0); + return { + questionType: item.questionType, + typeLabel: item.typeLabel, + answeredCount: answered, + correctCount: correct, + wrongCount: Number(item.wrongCount || 0), + accuracy: round4(answered ? correct / answered : 0), + }; + }), + }, + }; +} + +export async function learningTrendRoute(ctx: RequestContext) { + const tenantId = await tenantIdFrom(ctx); + const userId = await userIdFrom(ctx); + const days = intParam(ctx, 'days', 14, 365); + const from = dateParam(ctx, 'from'); + const to = dateParam(ctx, 'to'); + + const rows = await query( + ` + with bounds as ( + select + coalesce($3::date, current_date - (($5::integer - 1) * interval '1 day'))::date as start_date, + coalesce($4::date, current_date)::date as end_date + ), + dates as ( + select generate_series(start_date, end_date, interval '1 day')::date as stat_date + from bounds + ), + answers as ( + select answered_at::date as stat_date, + count(*)::integer as answered_count, + count(*) filter (where is_correct is true)::integer as correct_count, + count(*) filter (where is_correct is false)::integer as wrong_count + from public.answer_records, bounds + where tenant_id = $1 and user_id = $2 + and answered_at::date between bounds.start_date and bounds.end_date + group by answered_at::date + ), + sessions as ( + select started_at::date as stat_date, count(*)::integer as session_count + from public.practice_sessions, bounds + where tenant_id = $1 and user_id = $2 + and started_at::date between bounds.start_date and bounds.end_date + group by started_at::date + ), + reports as ( + select submitted_at::date as stat_date, + count(*)::integer as report_count, + coalesce(sum(score), 0)::numeric as score, + coalesce(sum(total_score), 0)::numeric as total_score + from public.practice_session_reports, bounds + where tenant_id = $1 and user_id = $2 + and submitted_at::date between bounds.start_date and bounds.end_date + group by submitted_at::date + ) + select dates.stat_date::text as date, + coalesce(answers.answered_count, 0)::integer as "answeredCount", + coalesce(answers.correct_count, 0)::integer as "correctCount", + coalesce(answers.wrong_count, 0)::integer as "wrongCount", + case when coalesce(answers.answered_count, 0) = 0 then 0 + else round((answers.correct_count::numeric / answers.answered_count::numeric), 4) + end as accuracy, + coalesce(sessions.session_count, 0)::integer as "sessionCount", + coalesce(reports.report_count, 0)::integer as "reportCount", + coalesce(reports.score, 0)::numeric as score, + coalesce(reports.total_score, 0)::numeric as "totalScore" + from dates + left join answers on answers.stat_date = dates.stat_date + left join sessions on sessions.stat_date = dates.stat_date + left join reports on reports.stat_date = dates.stat_date + order by dates.stat_date asc + `, + [tenantId, userId, from || null, to || null, days], + ); + + return { + items: rows.map(row => ({ + ...row, + answeredCount: Number(row.answeredCount || 0), + correctCount: Number(row.correctCount || 0), + wrongCount: Number(row.wrongCount || 0), + accuracy: finiteNumber(row.accuracy, 0), + sessionCount: Number(row.sessionCount || 0), + reportCount: Number(row.reportCount || 0), + score: finiteNumber(row.score, 0), + totalScore: finiteNumber(row.totalScore, 0), + })), + }; +} + +export async function wrongQuestionReviewPlanRoute(ctx: RequestContext) { + const tenantId = await tenantIdFrom(ctx); + const userId = await userIdFrom(ctx); + const limit = intParam(ctx, 'limit', 20, 100); + const includeResolved = stringParam(ctx, 'includeResolved') === 'true'; + + const items = await query( + ` + select wq.question_id as "questionId", wq.wrong_count as "wrongCount", + wq.last_wrong_at as "lastWrongAt", wq.resolved_at as "resolvedAt", + q.type, q.type_label as "typeLabel", q.subject_id as "subjectId", + s.name as "subjectName", q.category_id as "categoryId", + c.name as "categoryName", q.content_node_id as "contentNodeId", + cn.name as "contentNodeName", v.content, + coalesce(ar.answer_attempts, 0)::integer as "answerAttempts", + ar.last_answered_at as "lastAnsweredAt", + case + when wq.resolved_at is not null then 'resolved' + when wq.wrong_count >= 3 then 'high_priority' + when wq.last_wrong_at <= now() - interval '3 days' then 'due' + else 'new' + end as "reviewStatus", + ( + wq.last_wrong_at + + case + when wq.wrong_count >= 3 then interval '1 day' + when wq.wrong_count = 2 then interval '2 days' + else interval '3 days' + end + ) as "suggestedReviewAt" + from public.wrong_questions wq + join public.questions q on q.id = wq.question_id and q.tenant_id = wq.tenant_id + left join public.question_versions v on v.id = q.current_version_id + left join public.subjects s on s.id = q.subject_id and s.tenant_id = q.tenant_id + left join public.categories c on c.id = q.category_id and c.tenant_id = q.tenant_id + left join public.content_nodes cn on cn.id = q.content_node_id and cn.tenant_id = q.tenant_id + left join lateral ( + select count(*)::integer as answer_attempts, max(answered_at) as last_answered_at + from public.answer_records + where tenant_id = wq.tenant_id + and user_id = wq.user_id + and question_id = wq.question_id + ) ar on true + where wq.tenant_id = $1 + and wq.user_id = $2 + and ($3::boolean = true or wq.resolved_at is null) + and q.status = 'published' + order by + case + when wq.resolved_at is not null then 4 + when wq.wrong_count >= 3 then 0 + when wq.last_wrong_at <= now() - interval '3 days' then 1 + else 2 + end, + wq.wrong_count desc, + wq.last_wrong_at asc + limit $4 + `, + [tenantId, userId, includeResolved, limit], + ); + + return { + items, + nextAction: { + mode: 'wrong_review', + endpoint: 'POST /api/learning/practice-sessions', + body: { mode: 'wrong_review', questionLimit: Math.min(limit, 50) }, + }, + }; +} + export async function favoriteQuestionsRoute(ctx: RequestContext) { const tenantId = await tenantIdFrom(ctx); const userId = await userIdFrom(ctx); diff --git a/docs/refactor/backend-capability-status.md b/docs/refactor/backend-capability-status.md index 09316a1b..d10329b8 100644 --- a/docs/refactor/backend-capability-status.md +++ b/docs/refactor/backend-capability-status.md @@ -58,6 +58,8 @@ | 收藏夹 | 可联调 | `/api/learning/favorites/questions` | | 免费用户题量限制 | 可联调 | `practice_daily_usage` + `practice_access_events`;支持内容 accessRules、每日额度、session 截断、SVIP-only 拦截 | | 模考交卷报告 | 可联调 | `POST /api/learning/practice-sessions/submit`、`GET /api/learning/practice-sessions/report`、`GET /api/learning/practice-reports`;后端按 session 快照评分、分段统计、错题解析汇总,重复提交幂等 | +| 学习历史/统计/趋势 | 可联调 | `GET /api/learning/practice-sessions/history`、`GET /api/learning/stats`、`GET /api/learning/trend`;可支撑个人中心、练习历史、正确率趋势和题型分布 | +| 错题复习计划 | 可联调 | `GET /api/learning/wrong-questions/review-plan` + `POST /api/learning/practice-sessions` 的 `mode=wrong_review`,后端从本人错题本安全组卷 | ## 背单词、知识手册、分数线、视频 diff --git a/docs/refactor/legacy-feature-gap-matrix.md b/docs/refactor/legacy-feature-gap-matrix.md index 3e69ccf9..b27f3df3 100644 --- a/docs/refactor/legacy-feature-gap-matrix.md +++ b/docs/refactor/legacy-feature-gap-matrix.md @@ -20,17 +20,17 @@ | 首页/学生看板 | `pages/StudentDashboardNew.tsx` | 部分覆盖 | 品牌、Banner、公告、入口、个人统计有基础;缺完整运营动态/学习任务聚合 | | 题库入口 | `pages/SubjectSelector.tsx`、`RegionArchitectureEditor.tsx` | 已覆盖 | 前端应改接 `content_entries/content_nodes` | | 多级分类树 | 旧 module/subject/category 树 | 已覆盖 | 新后端支持任意深度和 `marker_type`;前端不要写死层级 | -| 顺序刷题 | `pages/Quiz.tsx` | 已覆盖 | 免费额度/SVIP 校验已由后端强制;继续补断点续练、更多题型渲染 | -| 随机刷题 | `pages/Quiz.tsx` | 已覆盖 | 已有 blueprint/session 快照和访问控制,前端需按 mode 调用 | +| 顺序刷题 | `pages/Quiz.tsx` | 已覆盖 | 免费额度/SVIP 校验、session 快照、练习历史和趋势统计已由后端强制;继续补断点续练、更多题型渲染 | +| 随机刷题 | `pages/Quiz.tsx` | 已覆盖 | 已有 blueprint/session 快照、访问控制和历史统计,前端需按 mode 调用 | | 全真模拟 | `components/AdminMockexam`、`MockExamConfigModal.tsx` | 部分覆盖 | blueprint、session 快照、交卷评分、分段统计和错题解析汇总已覆盖;后续补排名、断点续练、复盘体验 | -| 错题本 | 用户 stats/错题逻辑 | 已覆盖 | 后续补错题复习计划 | +| 错题本 | 用户 stats/错题逻辑 | 已覆盖 | 错题列表、移出错题、复习计划和 `wrong_review` 后端组卷已覆盖;后续补更细的间隔复习算法 | | 收藏夹 | `WordFavoritesPage.tsx`、题目收藏 | 已覆盖 | 题目和单词收藏已有 | | 题目视频 | `VideoPlayer.tsx` | 部分覆盖 | 题目视频查询、播放签名、SVIP/次数扣减、播放日志已有;缺深度防盗链、动态水印、播放统计报表 | | 背单词 | `VocabularyPage.tsx`、`VocabularyQuiz.tsx` | 部分覆盖 | 单词列表/进度/收藏/统计已有;缺完整艾宾浩斯算法、每日计划、收藏练习细节 | | 知识手册 | `Handbook*.tsx` | 已覆盖 | 前端需做好 Markdown/公式/图片渲染和搜索体验 | | 分数线 | `ScorelinePage.tsx` | 已覆盖 | 动态字段/趋势已有;缺批量导入和复杂筛选优化 | | 商城/SVIP | `Store.tsx`、`SvipModal.tsx` | 部分覆盖 | 套餐/订单/权益/激活码已有;缺真实支付、优惠券抵扣 | -| 个人中心 | `Profile.tsx` | 部分覆盖 | 基本资料、权益、订单统计有;缺完整勋章、签到、学习报告 | +| 个人中心 | `Profile.tsx` | 部分覆盖 | 基本资料、权益、订单统计、练习历史、学习统计和趋势已有;缺完整勋章、签到、学习报告可视化 | | 资料下载 | `QuestionExporterPublishModal.tsx` 等 | 部分覆盖 | 资源台账/签名下载已有;缺 PDF 预览、下载水印、防盗链 | | AI 择校推荐 | 业务规划新增 | 未覆盖 | 需设计学生输入 schema、地区数据上下文、AI JSON 输出、PDF 报告 | diff --git a/docs/refactor/next-development-todo.md b/docs/refactor/next-development-todo.md index 5ce3fec9..6ba7487d 100644 --- a/docs/refactor/next-development-todo.md +++ b/docs/refactor/next-development-todo.md @@ -8,13 +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_session_reports/practice_session_report_sections` 支持交卷、评分、题型/小节统计、错题解析汇总和历史查询;`/api/learning/stats`、`trend`、`practice-sessions/history`、`wrong-questions/review-plan` 可支撑个人中心和学习报告基础页。 - 练习访问控制:`practice_daily_usage/practice_access_events` 支持免费每日额度、SVIP 范围校验、SVIP-only 内容拦截和答题 session 快照保护。 - 内容导入:题目、单词、知识手册 JSON 预览、校验、导入、幂等、审计。 - 本地验证:`npm run check:refactor` 已通过。 @@ -78,10 +78,9 @@ - 单题视频和通用知识视频混合推荐。 6. 学习统计 - - 已完成免费额度、练习访问事件、模考交卷评分报告基础。 - - 继续补练习历史、正确率趋势、题型分布、错题复习计划。 - - 单词复习算法、每日计划、排行榜。 - - 模考排名、断点续练、复盘体验。 + - 已完成免费额度、练习访问事件、模考交卷评分报告、练习历史、正确率趋势、题型分布、错题复习计划。 + - 继续补单词复习算法、每日计划、排行榜。 + - 继续补模考排名、断点续练、复盘体验。 7. 数据看板 - 收益、注册趋势、答题次数、收入趋势、题型分布、科目数量、题目总量。 diff --git a/docs/refactor/taro-frontend-integration.md b/docs/refactor/taro-frontend-integration.md index d9d9273b..7ac9bd65 100644 --- a/docs/refactor/taro-frontend-integration.md +++ b/docs/refactor/taro-frontend-integration.md @@ -139,7 +139,9 @@ tenant::theme | 提交答案 | `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/review-plan`、`POST /api/learning/practice-sessions` with `mode=wrong_review` | | 收藏夹 | `GET/POST /api/learning/favorites/questions` | +| 练习历史/统计 | `GET /api/learning/practice-sessions/history`、`GET /api/learning/stats`、`GET /api/learning/trend` | | 题目视频 | `GET /api/questions/{questionId}/videos`、`POST /api/questions/videos/batch`、`POST /api/videos/play` | | 背单词 | `/api/catalog/vocabulary-units`、`/api/catalog/vocabulary-words` | | 单词进度 | `/api/learning/vocabulary/progress`、`/api/learning/vocabulary/stats` | @@ -262,6 +264,35 @@ tenant::theme - `score` 是逐题得分合计;`totalScore` 保留后台配置的卷面总分。测试或预发数据题量不足时,两者不一定按百分制等比换算,前端展示时不要自行重算。 - 错题复盘优先使用 `wrongQuestionIds` 和 `questionResults`,题目详情仍可按现有题目接口或 session 快照加载。 +### 练习历史、统计和错题复习 + +个人中心和学习报告页优先使用后端聚合接口,不要让前端遍历全部答题记录自行统计。 + +接口用途: + +| 页面/组件 | 接口 | 说明 | +| --- | --- | --- | +| 练习历史列表 | `GET /api/learning/practice-sessions/history?limit=20` | 返回 session、报告、已答数量、正确数、状态 | +| 学习概览卡片 | `GET /api/learning/stats?days=30` | 返回总答题、正确率、报告数、错题数、收藏数、题型分布 | +| 正确率趋势图 | `GET /api/learning/trend?days=14` | 返回每日答题数、正确数、错题数、session 数、报告数 | +| 错题复习入口 | `GET /api/learning/wrong-questions/review-plan?limit=20` | 返回建议复习题和后端组卷 nextAction | + +错题复习创建 session: + +```json +{ + "mode": "wrong_review", + "questionLimit": 20 +} +``` + +前端处理规则: + +- 不要把错题 ID 列表从前端传回后端组卷;`wrong_review` 会由后端按当前用户错题本安全组卷。 +- `review-plan.nextAction` 可直接用于按钮配置,但仍需使用当前登录 session 调用。 +- 收藏夹复习同理可调用 `POST /api/learning/practice-sessions`,body 为 `{ "mode": "favorite_review", "questionLimit": 20 }`。 +- 趋势图以接口返回日期桶为准,缺失日期后端会补 0,不需要前端补点。 + ## 视频播放契约 题目视频分为 `free`、`svip`、`video_quota` 三种访问模式。列表接口只用于展示标题、封面、时长、访问模式和试看秒数;除免费公开视频外,列表和搜索接口不会返回可播放 URL。 diff --git a/scripts/api-integration-test.js b/scripts/api-integration-test.js index 4ccba637..073bc4aa 100644 --- a/scripts/api-integration-test.js +++ b/scripts/api-integration-test.js @@ -746,6 +746,69 @@ async function testCatalogAndLearning() { query: { status: 'all' }, }); assert.ok(wrong.items?.some(item => item.questionId === ids.question), 'wrong book should include smoke question'); + + const wrongReviewPlan = await request('/api/learning/wrong-questions/review-plan', { + userId: false, + headers: { authorization: `Bearer ${freeLogin.session.token}` }, + query: { limit: 10 }, + }); + assert.ok(wrongReviewPlan.items?.some(item => item.questionId === ids.question), 'wrong review plan should include unresolved wrong question'); + assert.equal(wrongReviewPlan.nextAction?.body?.mode, 'wrong_review', 'wrong review plan should guide backend session creation'); + + const wrongReviewSession = await request('/api/learning/practice-sessions', { + userId: false, + headers: { authorization: `Bearer ${freeLogin.session.token}` }, + method: 'POST', + body: { + mode: 'wrong_review', + questionLimit: 5, + }, + }); + assert.equal(wrongReviewSession.item?.mode, 'wrong_review', 'wrong review should create a dedicated practice session'); + assert.ok(wrongReviewSession.item?.questionIds?.includes(ids.question), 'wrong review session should be assembled by backend wrong book'); + + const history = await request('/api/learning/practice-sessions/history', { + userId: TENANT_ADMIN_USER_ID, + query: { mode: 'mock_exam', limit: 20 }, + }); + assert.ok(history.items?.some(item => item.id === mockSession.item.id && item.reportId === mockReport.item.id), 'practice history should include submitted mock session'); + + const learningStats = await request('/api/learning/stats', { + userId: TENANT_ADMIN_USER_ID, + query: { days: 30 }, + }); + assert.ok(learningStats.item?.answers?.totalAnswered >= 2, 'learning stats should count answer records'); + assert.ok(learningStats.item?.reports?.reportCount >= 1, 'learning stats should count reports'); + assert.ok(learningStats.item?.questionTypes?.some(item => item.questionType === 'choice'), 'learning stats should include question type distribution'); + + const learningTrend = await request('/api/learning/trend', { + userId: TENANT_ADMIN_USER_ID, + query: { days: 7 }, + }); + assert.ok(Array.isArray(learningTrend.items), 'learning trend should return daily buckets'); + assert.ok(learningTrend.items.length >= 1, 'learning trend should include at least one bucket'); + assert.ok(learningTrend.items.some(item => item.answeredCount >= 2), 'learning trend should include answer activity'); + + await request('/api/learning/favorites/questions', { + userId: TENANT_ADMIN_USER_ID, + method: 'POST', + body: { + userId: TENANT_ADMIN_USER_ID, + questionId: ids.questionThree, + favorite: true, + }, + }); + const favoriteReviewSession = await request('/api/learning/practice-sessions', { + userId: TENANT_ADMIN_USER_ID, + method: 'POST', + body: { + userId: TENANT_ADMIN_USER_ID, + mode: 'favorite_review', + questionLimit: 5, + }, + }); + assert.equal(favoriteReviewSession.item?.mode, 'favorite_review', 'favorite review should create a dedicated practice session'); + assert.ok(favoriteReviewSession.item?.questionIds?.includes(ids.questionThree), 'favorite review session should be assembled by backend favorites'); } async function testProfile() { diff --git a/supabase/migrations/202606210013_learning_stats_history.sql b/supabase/migrations/202606210013_learning_stats_history.sql new file mode 100644 index 00000000..ce272ddc --- /dev/null +++ b/supabase/migrations/202606210013_learning_stats_history.sql @@ -0,0 +1,18 @@ +create index if not exists idx_practice_sessions_user_started + on public.practice_sessions(tenant_id, user_id, started_at desc); + +create index if not exists idx_practice_sessions_user_finished + on public.practice_sessions(tenant_id, user_id, finished_at desc) + where finished_at is not null; + +create index if not exists idx_answer_records_user_answered + on public.answer_records(tenant_id, user_id, answered_at desc); + +create index if not exists idx_answer_records_session_latest + on public.answer_records(tenant_id, user_id, practice_session_id, question_id, answered_at desc); + +create index if not exists idx_wrong_questions_user_review + on public.wrong_questions(tenant_id, user_id, resolved_at, last_wrong_at desc, wrong_count desc); + +create index if not exists idx_favorite_questions_user_created + on public.favorite_questions(tenant_id, user_id, created_at desc);