From 9122bb0829a3a03ff02b428ef9712cef1643170d Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 29 Jun 2026 02:11:52 +0800 Subject: [PATCH] feat: add learning leaderboards --- README.md | 6 +- apps/api/src/features/learning/index.ts | 2 + apps/api/src/features/learning/leaderboard.ts | 338 ++++++++++++++++++ docs/refactor/api-structure.md | 5 +- docs/refactor/backend-capability-status.md | 3 +- docs/refactor/backend-handoff-roadmap.md | 10 +- docs/refactor/backend-progress.md | 4 +- docs/refactor/blueprint-coverage.md | 6 +- docs/refactor/implementation-status.md | 7 +- docs/refactor/legacy-feature-gap-matrix.md | 4 +- docs/refactor/next-development-todo.md | 10 +- docs/refactor/taro-frontend-integration.md | 18 + scripts/api-integration-test.js | 50 +++ scripts/smoke-seed.js | 273 +++++++++++++- .../202606290005_learning_leaderboards.sql | 21 ++ 15 files changed, 724 insertions(+), 33 deletions(-) create mode 100644 apps/api/src/features/learning/leaderboard.ts create mode 100644 supabase/migrations/202606290005_learning_leaderboards.sql diff --git a/README.md b/README.md index ed53679a..9b95676c 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ - `apps/api` 独立业务 API,后续供 H5、Taro 小程序、管理后台统一调用;已支持 Supabase Auth JWT 和迁移期 `tk_` session 双入口。 - 租户后台能力:品牌、域名、公开设置、支付账户、登录配置、私密密钥掩码、活动内容、考试日期、题目反馈处理、激活码、优惠券、成员权限、自定义角色模板、班级/教师/学生范围权限、学生批量导入、批量分班、学生备注、跟进任务、审计日志。 - 租户内容能力:可配置题库入口、任意深度分类树、考试意向标记、题目集合、顺序/随机/全真模拟蓝图、题目录入/更新、视频绑定、分数线、单词、知识手册、资料资源台账、题目/单词/知识手册 JSON 批量导入。 -- 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词进度、个人中心、考试倒计时、签到积分、题目反馈、分数线、题目视频、订单、权益、激活码兑换、资料下载。 +- 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词进度、个人中心、考试倒计时、签到积分、题目反馈、排行榜、分数线、题目视频、订单、权益、激活码兑换、资料下载。 - 平台后台能力:租户管理、SaaS 套餐、订阅、账单、服务费收款、用量记录。 - 销售/代理/CRM 增长链路:邀请码、扫码/分享事件、首绑客资保护、销售统计、团队关系、CRM 配置和队列。 - PocketBase schema/数据导入器雏形和导入后校验脚本。 @@ -132,7 +132,7 @@ apps/api/src/features/ catalog/ 学生端目录、内容入口、分类树、题目集合、资料、商城只读接口 commerce/ 订单、支付确认、激活码、权益 health/ 健康检查 - learning/ 练习 session 组卷、答题、错题、收藏、学习进度 + learning/ 练习 session 组卷、答题、错题、收藏、学习进度、排行榜 platform-admin/ 平台方租户、SaaS 套餐、订阅、账单、用量 profile/ 学生个人中心 referral/ 销售/代理客资追踪、CRM 队列 @@ -178,4 +178,4 @@ npm run check:refactor 2. Taro 前端 scaffold,让 H5 和小程序共用同一套 API。 3. 对象存储上传后校验、PDF 预览、防盗链和视频水印。 4. Excel/CSV 以及分数线、视频批量导入;把现有 JSON 导入升级为可排队异步执行。 -5. 微信网页/QQ 登录、退款对账、CRM worker、公共题库授权、租户采纳、订单状态轮询、激活码预检查、积分活动深化和排行榜。 +5. 微信网页/QQ 登录、退款对账、CRM worker、公共题库授权、租户采纳、订单状态轮询、激活码预检查、积分活动深化,以及排行榜防刷/预聚合。 diff --git a/apps/api/src/features/learning/index.ts b/apps/api/src/features/learning/index.ts index a427d9b2..5224adae 100644 --- a/apps/api/src/features/learning/index.ts +++ b/apps/api/src/features/learning/index.ts @@ -1,4 +1,5 @@ import type { RouteDefinition } from '../../core/router.js'; +import { learningLeaderboardRoute } from './leaderboard.js'; import { createPracticeSessionRoute, favoriteQuestionsRoute, @@ -23,6 +24,7 @@ import { } from './routes.js'; export const learningRoutes: RouteDefinition[] = [ + ['GET', '/api/learning/leaderboard', learningLeaderboardRoute], ['POST', '/api/learning/practice-sessions', createPracticeSessionRoute], ['POST', '/api/learning/practice-sessions/submit', submitPracticeSessionRoute], ['GET', '/api/learning/practice-sessions/report', practiceSessionReportRoute], diff --git a/apps/api/src/features/learning/leaderboard.ts b/apps/api/src/features/learning/leaderboard.ts new file mode 100644 index 00000000..3b6683c5 --- /dev/null +++ b/apps/api/src/features/learning/leaderboard.ts @@ -0,0 +1,338 @@ +import { HttpError, type RequestContext } from '../../core/http.js'; +import { intParam, stringParam, tenantIdFrom, userIdFrom } from '../../core/request.js'; +import { query, queryOne } from '../../core/db.js'; + +type LeaderboardMetric = 'questions' | 'score' | 'vocabulary' | 'mock_exam'; +type LeaderboardPeriod = 'all' | '7d' | '30d'; + +interface LeaderboardRow { + userId: string; + username: string | null; + name: string | null; + avatarUrl: string | null; + primaryRole: string; + regionId: string | null; + regionName: string | null; + classIds: string[]; + classNames: string[]; + value: string | number; + secondaryValue: string | number | null; + latestAt: string | null; +} + +interface RankedRow extends LeaderboardRow { + rank: string | number; +} + +const METRICS = new Set(['questions', 'score', 'vocabulary', 'mock_exam']); +const PERIODS = new Set(['all', '7d', '30d']); + +function metricFrom(value: string): LeaderboardMetric { + const candidate = value || 'questions'; + if (!METRICS.has(candidate as LeaderboardMetric)) { + throw new HttpError(400, `Unsupported leaderboard metric: ${candidate}`, 'INVALID_LEADERBOARD_METRIC'); + } + return candidate as LeaderboardMetric; +} + +function periodFrom(value: string): LeaderboardPeriod { + const candidate = value || 'all'; + if (!PERIODS.has(candidate as LeaderboardPeriod)) { + throw new HttpError(400, `Unsupported leaderboard period: ${candidate}`, 'INVALID_LEADERBOARD_PERIOD'); + } + return candidate as LeaderboardPeriod; +} + +function normalizeUuidParam(ctx: RequestContext, name: string) { + const value = stringParam(ctx, name); + if (!value) return ''; + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value)) { + throw new HttpError(400, `${name} must be a UUID`, 'INVALID_UUID_PARAM'); + } + return value; +} + +function windowCondition(period: LeaderboardPeriod, column: string) { + if (period === '7d') return `${column} >= now() - interval '7 days'`; + if (period === '30d') return `${column} >= now() - interval '30 days'`; + return 'true'; +} + +function metricLabel(metric: LeaderboardMetric) { + return { + questions: '累计答题', + score: '积分', + vocabulary: '掌握单词', + mock_exam: '模考最高分', + }[metric]; +} + +function metricUnit(metric: LeaderboardMetric) { + return { + questions: '题', + score: '分', + vocabulary: '词', + mock_exam: '分', + }[metric]; +} + +function rowToItem(row: RankedRow, currentUserId: string) { + return { + rank: Number(row.rank), + userId: row.userId, + displayName: row.name || row.username || '学员', + avatarUrl: row.avatarUrl, + primaryRole: row.primaryRole, + regionId: row.regionId, + regionName: row.regionName, + classIds: row.classIds || [], + classNames: row.classNames || [], + value: Number(row.value || 0), + secondaryValue: row.secondaryValue === null || row.secondaryValue === undefined ? null : Number(row.secondaryValue), + latestAt: row.latestAt, + isCurrentUser: row.userId === currentUserId, + }; +} + +function baseUsersCte(filters: string[]) { + return ` + base_users as ( + select u.id as user_id, u.username, u.name, u.avatar_url, u.primary_role, + sp.region_id, r.name as region_name, + coalesce(array_remove(array_agg(distinct tcm.class_id) filter (where tcm.class_id is not null), null), array[]::uuid[]) as class_ids, + coalesce(array_remove(array_agg(distinct tc.name) filter (where tc.name is not null), null), array[]::text[]) as class_names + from public.tenant_memberships tm + join public.platform_users u on u.id = tm.user_id + left join public.student_profiles sp on sp.tenant_id = tm.tenant_id and sp.user_id = tm.user_id + left join public.regions r on r.id = sp.region_id and r.tenant_id = tm.tenant_id + left join public.tenant_class_members tcm + on tcm.tenant_id = tm.tenant_id + and tcm.user_id = tm.user_id + and tcm.member_type = 'student' + and tcm.status = 'active' + left join public.tenant_classes tc + on tc.tenant_id = tcm.tenant_id + and tc.id = tcm.class_id + and tc.status = 'active' + where ${filters.join(' and ')} + group by u.id, u.username, u.name, u.avatar_url, u.primary_role, sp.region_id, r.name + ) + `; +} + +function rankedSelect(metricSql: string, limitPlaceholder: string, offsetPlaceholder: string) { + return ` + ${metricSql}, + ranked as ( + select bu.user_id as "userId", bu.username, bu.name, bu.avatar_url as "avatarUrl", + bu.primary_role as "primaryRole", bu.region_id as "regionId", bu.region_name as "regionName", + bu.class_ids as "classIds", bu.class_names as "classNames", + coalesce(m.value, 0) as value, + m.secondary_value as "secondaryValue", + m.latest_at as "latestAt", + dense_rank() over ( + order by coalesce(m.value, 0) desc, + coalesce(m.secondary_value, 0) desc, + coalesce(m.latest_at, '1970-01-01'::timestamptz) asc, + bu.user_id asc + ) as rank + from base_users bu + left join metric_values m on m.user_id = bu.user_id + ) + select * + from ranked + where value > 0 + order by rank asc, value desc, "userId" asc + limit ${limitPlaceholder} + offset ${offsetPlaceholder} + `; +} + +function currentRankSelect(metricSql: string, userPlaceholder: string) { + return ` + ${metricSql}, + ranked as ( + select bu.user_id as "userId", bu.username, bu.name, bu.avatar_url as "avatarUrl", + bu.primary_role as "primaryRole", bu.region_id as "regionId", bu.region_name as "regionName", + bu.class_ids as "classIds", bu.class_names as "classNames", + coalesce(m.value, 0) as value, + m.secondary_value as "secondaryValue", + m.latest_at as "latestAt", + dense_rank() over ( + order by coalesce(m.value, 0) desc, + coalesce(m.secondary_value, 0) desc, + coalesce(m.latest_at, '1970-01-01'::timestamptz) asc, + bu.user_id asc + ) as rank + from base_users bu + left join metric_values m on m.user_id = bu.user_id + ) + select * + from ranked + where "userId" = ${userPlaceholder}::uuid + limit 1 + `; +} + +function buildMetricCte(metric: LeaderboardMetric, period: LeaderboardPeriod, tenantParam = '$1') { + if (metric === 'questions') { + return ` + metric_values as ( + select ar.user_id, + count(*)::numeric as value, + count(*) filter (where ar.is_correct is true)::numeric as secondary_value, + max(ar.answered_at) as latest_at + from public.answer_records ar + where ar.tenant_id = ${tenantParam} + and ${windowCondition(period, 'ar.answered_at')} + group by ar.user_id + ) + `; + } + + if (metric === 'score') { + if (period === 'all') { + return ` + metric_values as ( + select u.id as user_id, + u.score::numeric as value, + null::numeric as secondary_value, + u.updated_at as latest_at + from public.platform_users u + ) + `; + } + return ` + metric_values as ( + select e.user_id, + coalesce(sum(e.points), 0)::numeric as value, + count(*)::numeric as secondary_value, + max(e.created_at) as latest_at + from public.user_score_events e + where e.tenant_id = ${tenantParam} + and e.points > 0 + and ${windowCondition(period, 'e.created_at')} + group by e.user_id + ) + `; + } + + if (metric === 'vocabulary') { + if (period === 'all') { + return ` + metric_values as ( + select sp.user_id, + greatest( + sp.mastered_words_count, + coalesce(count(p.word_id) filter (where p.status = 'mastered'), 0) + )::numeric as value, + coalesce(count(p.word_id) filter (where p.status in ('learning', 'reviewing')), 0)::numeric as secondary_value, + max(p.last_review_date) as latest_at + from public.student_profiles sp + left join public.user_word_progress p + on p.tenant_id = sp.tenant_id + and p.user_id = sp.user_id + where sp.tenant_id = ${tenantParam} + group by sp.user_id, sp.mastered_words_count + ) + `; + } + return ` + metric_values as ( + select p.user_id, + count(distinct p.word_id)::numeric as value, + count(*) filter (where p.status = 'mastered')::numeric as secondary_value, + max(p.last_review_date) as latest_at + from public.user_word_progress p + where p.tenant_id = ${tenantParam} + and p.last_review_date is not null + and ${windowCondition(period, 'p.last_review_date')} + group by p.user_id + ) + `; + } + + return ` + metric_values as ( + select r.user_id, + max(r.score)::numeric as value, + max(r.accuracy)::numeric as secondary_value, + max(r.submitted_at) as latest_at + from public.practice_session_reports r + where r.tenant_id = ${tenantParam} + and r.mode = 'mock_exam' + and ${windowCondition(period, 'r.submitted_at')} + group by r.user_id + ) + `; +} + +export async function learningLeaderboardRoute(ctx: RequestContext) { + const tenantId = await tenantIdFrom(ctx); + const userId = await userIdFrom(ctx); + const metric = metricFrom(stringParam(ctx, 'metric')); + const period = periodFrom(stringParam(ctx, 'period')); + const limit = intParam(ctx, 'limit', 25, 100); + const page = intParam(ctx, 'page', 1, 10000); + const offset = (page - 1) * limit; + const regionId = normalizeUuidParam(ctx, 'regionId'); + const classId = normalizeUuidParam(ctx, 'classId'); + + const params: unknown[] = [tenantId]; + const filters = [ + 'tm.tenant_id = $1', + `tm.status = 'active'`, + `tm.role = 'student'`, + `u.primary_role <> 'platform_admin'`, + ]; + if (regionId) { + params.push(regionId); + filters.push(`sp.region_id = $${params.length}::uuid`); + } + if (classId) { + params.push(classId); + filters.push(`exists ( + select 1 + from public.tenant_class_members class_filter + where class_filter.tenant_id = tm.tenant_id + and class_filter.class_id = $${params.length}::uuid + and class_filter.user_id = tm.user_id + and class_filter.member_type = 'student' + and class_filter.status = 'active' + )`); + } + + const baseSql = baseUsersCte(filters); + const metricSql = `${baseSql},${buildMetricCte(metric, period)}`; + const listParams = [...params, limit, offset]; + const items = await query( + ` + with ${rankedSelect(metricSql, `$${params.length + 1}`, `$${params.length + 2}`)} + `, + listParams, + ); + + const currentUser = await queryOne( + ` + with ${currentRankSelect(metricSql, `$${params.length + 1}`)} + `, + [...params, userId], + ); + + return { + metric, + label: metricLabel(metric), + unit: metricUnit(metric), + period, + scope: { + tenantId, + regionId: regionId || null, + classId: classId || null, + }, + page, + pageSize: limit, + items: items.map(item => rowToItem(item, userId)), + currentUser: currentUser ? rowToItem(currentUser, userId) : null, + generatedAt: new Date().toISOString(), + }; +} diff --git a/docs/refactor/api-structure.md b/docs/refactor/api-structure.md index ed2ee3ca..bd8b5d7a 100644 --- a/docs/refactor/api-structure.md +++ b/docs/refactor/api-structure.md @@ -17,7 +17,7 @@ apps/api/src/ health/ 健康检查 tenant/ 租户解析、品牌配置、域名识别 catalog/ 公开题库、内容入口、分类树、题目集合、练习蓝图、手册、商城、资料资源只读接口 - learning/ 组卷 session、答题、错题、收藏、练习进度 + learning/ 组卷 session、答题、错题、收藏、练习进度、排行榜 commerce/ 订单、支付确认、激活码、权益 referral/ 销售/代理客资追踪、首绑保护、团队关系、CRM 队列 storage/ 对象存储签名 provider @@ -34,7 +34,7 @@ apps/api/src/ ```text features/ auth/ 登录、绑定手机、OAuth 回调、会话换取 - learning/ 顺序/随机/模考组卷、答题记录、错题、收藏、学习进度 + learning/ 顺序/随机/模考组卷、答题记录、错题、收藏、学习进度、排行榜 commerce/ 商品、订单、支付、退款、权益开通 referral/ 销售/代理增长链路、客资归属、分佣依据、CRM 入队 platform-admin/ 平台租户管理、年费、服务费、账务审计 @@ -71,5 +71,6 @@ types.ts 仅本领域使用的类型 - `referral` 是增长/客资业务域,负责邀请码、扫码事件、首绑保护、销售/代理团队归属和 CRM 入队;真实 CRM webhook 发送应由 worker 处理,API 只负责幂等入队。 - 题库前端入口不再只依赖旧 `module_nodes/subjects/categories`;新业务主模型是 `content_entries/content_nodes/question_collections/practice_blueprints`,用于表达可视化入口、多级分类、考试意向标记、题目列表和顺序/随机/全真模拟规则。 - `learning` 创建练习 session 时必须保存 `question_ids` 快照,避免随机刷题和模考过程中题目集合变化导致答题记录无法复盘。 +- 排行榜必须由后端按租户、地区、班级和可信用户上下文聚合,前端不能自行扫描答题记录、积分流水或单词进度后排名;后续高流量场景再通过 worker/materialized view 做日榜、周榜和防刷。 - 资料、PDF、视频等对象存储资源必须先进入 `content_assets` 台账,再通过 API/Edge Function 做权限校验和签名 URL 下发;前端不能直接拼 OSS/COS/Supabase Storage 私有地址。 - 批量导入必须先写 `content_import_jobs/items/issues`,保留原始 payload、规范化 payload、逐行问题和审计记录;同步 API 当前支持题目 JSON,Excel/CSV 和其它内容类型应接入同一管线。 diff --git a/docs/refactor/backend-capability-status.md b/docs/refactor/backend-capability-status.md index a7d91d31..dc5391b5 100644 --- a/docs/refactor/backend-capability-status.md +++ b/docs/refactor/backend-capability-status.md @@ -18,7 +18,7 @@ | RLS/租户隔离 | 可联调 | 表层普遍有 `tenant_id` 和 RLS 策略;API 已支持 `tk_` 迁移 session 与 Supabase Auth JWT 双入口,并覆盖跨租户/伪造身份集成测试;生产前继续补真实云端 JWT/RLS 回归 | | API 分层 | 可联调 | `apps/api/src/core` + `apps/api/src/features/*` | | Docker API | 可联调 | `docker-compose.api.yml` 和 `apps/api/Dockerfile` 可用 | -| 测试 | 可联调 | `npm run check:refactor` 覆盖 TS 检查、导入校验、seed、API 集成测试 | +| 测试 | 可联调 | `npm run check:refactor` 覆盖 TS 检查、导入校验、seed、API 集成测试;排行榜已覆盖四类指标、班级范围和跨租户拒绝 | | 根 workspace | 可联调 | 根目录已清理为新技术栈 monorepo 编排层 | ## 租户与品牌 @@ -63,6 +63,7 @@ | 模考交卷报告 | 可联调 | `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`,后端从本人错题本安全组卷 | +| 学习排行榜 | 可联调 | `GET /api/learning/leaderboard`;支持 `questions`、`score`、`vocabulary`、`mock_exam` 四类指标,支持 `all`、`7d`、`30d` 周期和租户/地区/班级范围,返回当前用户排名并拒绝跨租户 session | | 考试倒计时 | 可联调 | `GET /api/catalog/exam-dates`、`GET /api/profile/exam-countdowns`;返回租户/地区匹配考试日期和 `daysLeft` | | 题目反馈/纠错 | 可联调 | `GET/POST /api/profile/feedbacks`,题目必须属于当前租户;租户后台可处理状态流转 | | 签到积分 | 可联调 | `POST /api/profile/check-in`、`GET /api/profile/score-events`;积分流水幂等、事务加锁,重复签到不重复加分 | diff --git a/docs/refactor/backend-handoff-roadmap.md b/docs/refactor/backend-handoff-roadmap.md index 4cc145ec..cae5e17f 100644 --- a/docs/refactor/backend-handoff-roadmap.md +++ b/docs/refactor/backend-handoff-roadmap.md @@ -10,7 +10,7 @@ - 平台侧可以管理租户、SaaS 套餐、订阅、账单、服务费和用量。 - 租户侧可以管理品牌、域名、支付账户、登录配置、私密密钥、活动、兑换码、优惠券、成员权限、审计日志、内容入口、分类树、题目集合、练习蓝图、题目、视频、分数线、单词、知识手册和资料资源。 -- 学生侧已经有题库入口、分类树、题目集合、顺序/随机/全真模拟组卷、答题、错题、收藏、背单词进度、个人中心、分数线、视频、订单、权益、激活码兑换和资料下载的基础 API。 +- 学生侧已经有题库入口、分类树、题目集合、顺序/随机/全真模拟组卷、答题、错题、收藏、背单词进度、个人中心、排行榜、分数线、视频、订单、权益、激活码兑换和资料下载的基础 API。 - 销售/代理/CRM 已经有邀请码、扫码/分享事件、首绑客资保护、团队关系、统计、CRM 配置和入队能力。 - 旧题库 JSON、单词模板、知识手册嵌套模板已经进入后端 preview/import 管线,由后端负责规范化、校验、幂等、审计和租户隔离。 @@ -23,8 +23,8 @@ | 多租户底座 | 可联调 | 租户、域名、品牌、设置、RLS 基础、审计、Supabase JWT/API 身份映射 | 真实云端 Auth/JWKS 回归、生产 RLS 深测 | | 平台后台 | 基础完成 | 租户、套餐、订阅、账单、服务费、用量 | 自动计费、平台审计、公共题库披露策略 | | 租户后台 | 可联调 | 品牌、域名、支付账户、登录配置、密钥掩码、活动、兑换码、优惠券、成员权限、角色模板、菜单/模块/字段权限配置 API | 前端权限 UI、班级/教师/学生范围权限 | -| 题库与练习 | 可联调 | 内容入口、任意深度分类、题目集合、顺序/随机/全真模拟蓝图、组卷快照、答题、错题、收藏 | 完整模考交卷报告、专项策略、公题库采纳/授权、Excel 导入 | -| 背单词 | 可联调 | 单元、单词、进度、收藏、统计、JSON 导入 | 复习算法、每日计划、排行榜、Excel 导入 | +| 题库与练习 | 可联调 | 内容入口、任意深度分类、题目集合、顺序/随机/全真模拟蓝图、组卷快照、答题、错题、收藏、模考报告、排行榜 | 专项策略、公题库采纳/授权、Excel 导入、排行榜防刷/预聚合 | +| 背单词 | 可联调 | 单元、单词、进度、收藏、统计、每日计划、JSON 导入、排行榜 | Excel 导入、更细复习参数 | | 知识手册 | 可联调 | 科目、章节、条目、Markdown 内容、嵌套 JSON 导入 | 富文本资源、版本管理、附件/PDF 关联 | | 分数线 | 可联调 | 院校、专业、动态字段、记录、年份、趋势、后台维护 | 批量导入、复杂筛选、AI 择校上下文 | | 视频解析 | 部分完成 | 单题视频、批量查询、后台视频绑定 | 会员播放权限、播放次数扣减、签名 URL、防盗链、水印 | @@ -65,7 +65,7 @@ 5. 个人中心 - 调 `GET /api/profile/me`。 - - 接会员权益、订单、激活码兑换、错题本、收藏夹、学习统计。 + - 接会员权益、订单、激活码兑换、错题本、收藏夹、学习统计和排行榜。 6. 资料、视频和支付 - 资料下载、PDF 预览、视频播放必须先请求后端签名或权限检查。 @@ -106,7 +106,7 @@ - `npm run check:refactor` 在本地通过。 - 使用真实 PocketBase 导出数据完成一次 dry-run,产出问题清单和修复后的二次导入报告。 -- 核心学生链路 API 可以在 Taro H5 和小程序双端跑通:登录、首页、题库、练习、错题、收藏、单词、手册、会员、个人中心。 +- 核心学生链路 API 可以在 Taro H5 和小程序双端跑通:登录、首页、题库、练习、错题、收藏、单词、手册、会员、排行榜、个人中心。 - 租户隔离、后台角色权限、资源访问权限、订单权益和内容导入至少有集成测试覆盖。 后端进入“商用生产交付”的最低标准: diff --git a/docs/refactor/backend-progress.md b/docs/refactor/backend-progress.md index 9b6c0c03..471d3a14 100644 --- a/docs/refactor/backend-progress.md +++ b/docs/refactor/backend-progress.md @@ -7,7 +7,7 @@ - API 已按 `core/features` 分层: - `auth`:短信验证码登录、迁移期 session、OAuth provider 预留。 - `catalog`:公开题库、地区、内容入口、分类树、题目集合、练习蓝图、考试日期、手册、商品、SVIP 套餐、资料资源只读/下载接口。 - - `learning`:顺序/随机/全真模拟组卷 session、答题记录、错题、收藏、背单词进度/收藏/统计。 + - `learning`:顺序/随机/全真模拟组卷 session、答题记录、错题、收藏、背单词进度/收藏/统计、排行榜。 - `profile`:学生个人中心、目标院校/专业、会员状态、统计聚合、最近练习、考试倒计时、签到积分、题目反馈。 - `scoreline`:分数线字段、院校、专业、记录、趋势、年份。 - `video`:题目视频讲解、批量预加载、通用视频搜索。 @@ -22,6 +22,7 @@ - 班级与学生范围权限已落库:`tenant_classes`、`tenant_class_members` 支持教师/班主任/助教/学生分组,教师按负责班级查看学生,字段权限可脱敏学生手机号。 - 学生运营管理已落库:`tenant_student_notes`、`tenant_student_followups` 支持学生备注、家校/班主任/销售跟进任务、可见性、指派、完成状态和审计;批量学生 upsert、批量分班、禁用/恢复也已接入权限校验。 - 旧题库常用运营功能已补齐一批:`exam_dates` 支持学生端考试倒计时和租户后台维护;`reports/report_status_events` 支持学生题目反馈、租户后台状态流转;`user_score_events` 支持每日签到积分流水和反馈奖励幂等。 +- 旧题库排行榜主链路已补齐:`GET /api/learning/leaderboard` 支持刷题、积分、背单词、模考四类指标,支持全量/7 天/30 天周期,以及租户/地区/班级范围。 - `learning` 已接入商用访问控制:免费用户每日题量、SVIP 范围、SVIP-only 内容、答题 session 快照保护由后端强制执行。 - `src/services/supabaseApi.ts` 已加入新 API 客户端方法,供旧 Web 逐步替换和后续 Taro 复用。 - 已新增 `npm run db:smoke-seed`,用于 `supabase:reset` 后恢复最小烟测数据。 @@ -64,6 +65,7 @@ GET /api/catalog/assets GET /api/catalog/assets/download GET /api/catalog/exam-dates POST /api/learning/answers +GET /api/learning/leaderboard GET /api/learning/favorites/questions POST /api/learning/favorites/questions GET /api/learning/wrong-questions diff --git a/docs/refactor/blueprint-coverage.md b/docs/refactor/blueprint-coverage.md index dd6ab496..b3ca98d6 100644 --- a/docs/refactor/blueprint-coverage.md +++ b/docs/refactor/blueprint-coverage.md @@ -19,8 +19,8 @@ | 租户品牌和域名 | 基础完成 | 品牌、Logo、主题 JSON、公开资源、域名、租户公开配置 | 三套默认主题、主题可视化编辑、图标/图片上传 | | 租户成员权限 | 基础完成 | owner/admin/operator/teacher/sales/agent/student,权限矩阵,成员启停,审计查询 | 前端权限 UI、自定义角色模板、菜单级可见配置 | | 题库内容维护 | 基础完成 | 内容入口、任意深度分类树、院校/专业/学科/销售意向标记、题目集合、顺序/随机/全真模拟练习蓝图、题目录入/更新、题目 JSON 预览/导入、视频绑定、分数线、单词、知识手册后台 API | Excel/CSV 批量导入、分数线/视频导入、公题库采纳/复制/授权、可视化拖拽排序前端 | -| 学生刷题 | 基础完成 | 内容入口、分类树、题目集合、顺序刷题、随机刷题、全真模拟 session 题目快照、答题、错题本、收藏夹 | 完整模考交卷评分报告、专项练习策略、错题复习计划、题型统计深度分析 | -| 背单词 | 基础完成 | 单词单元、单词、进度、收藏、统计、旧模板/新模板 JSON 预览导入、内容导航绑定 | 复习算法、每日计划、排行榜、Excel 导入 | +| 学生刷题 | 基础完成 | 内容入口、分类树、题目集合、顺序刷题、随机刷题、全真模拟 session 题目快照、答题、错题本、收藏夹、模考交卷评分报告、错题复习计划、排行榜 | 专项练习策略、题型统计深度分析、排行榜防刷/预聚合 | +| 背单词 | 基础完成 | 单词单元、单词、进度、收藏、统计、每日复习计划、旧模板/新模板 JSON 预览导入、内容导航绑定、排行榜 | Excel 导入、更细复习参数 | | 知识手册 | 基础完成 | 科目、章节、条目只读与后台维护、书籍/章节/小节/知识点嵌套 JSON 预览导入、内容导航绑定 | 富文本资源、版本管理、附件/PDF 关联、Excel/Markdown 批量解析 | | 分数线 | 基础完成 | 字段、院校、专业、记录、趋势、年份 | 复杂动态筛选、批量导入、AI 择校数据上下文 | | 视频解析会员 | 部分完成 | 题目视频、批量查询、后台绑定 | SVIP 权限、播放次数扣减、签名 URL、防盗链、水印、播放统计 | @@ -38,7 +38,7 @@ 1. 完善内容导入和对象存储:Excel/CSV、分数线/视频导入,真实 OSS/COS/Supabase Storage 签名,JSON 导入异步化。 2. 公共题库/地区题库授权:平台题库向租户披露、租户采纳、按 SaaS 套餐限制地区。 -3. 完整模考与学习统计:交卷、评分报告、练习历史、正确率趋势、错题复习计划。 +3. 学习统计增强:排行榜防刷/预聚合、断点续练、专项练习策略和更细题型分析。 4. 视频会员控制:视频资源签名 URL、防盗链、水印、播放次数和会员权益。 5. 数据看板 API:把旧 dashboard/revenue 统计迁到新 API。 6. 真实 provider:短信、微信/QQ 登录、微信支付/支付宝、CRM worker。 diff --git a/docs/refactor/implementation-status.md b/docs/refactor/implementation-status.md index 31f8e7c2..affd352e 100644 --- a/docs/refactor/implementation-status.md +++ b/docs/refactor/implementation-status.md @@ -28,7 +28,7 @@ | 错题本 | 已建 `wrong_questions` | 已支持旧错题归一化 | 错题列表、答题自动入错题、移出错题已实现 | 仅烟测 | 基础功能已实现,复习计划和统计未完成 | | 收藏夹 | 已建 `favorite_questions` | 已支持旧收藏归一化 | 收藏/取消收藏、收藏列表已实现 | 仅烟测 | 基础功能已实现 | | 用户订阅/题库会员/SVIP | 已建 `orders`、`payments`、`entitlements`、`svip_plans`、激活码 | 已映射旧 SVIP/会员权益 | 下单、手动支付确认、激活码兑换、权益查询已实现 | 仅烟测 | 业务骨架可跑,真实微信/支付宝支付和 webhook 未完成 | -| 背单词 | 已建单词单元、单词、进度、收藏表,并可绑定 `content_entries/content_nodes` | 已支持内容和部分用户状态映射 | 单元/单词只读、进度、收藏、统计、租户后台单词维护 API、旧模板/新模板 JSON 预览导入已实现 | 核心 API 集成测试含导入断言 | 学生端基础学习状态、后台维护和批量 JSON 导入已实现,复习算法和后台统计待完善 | +| 背单词 | 已建单词单元、单词、进度、收藏表,并可绑定 `content_entries/content_nodes` | 已支持内容和部分用户状态映射 | 单元/单词只读、进度、收藏、统计、每日复习计划、租户后台单词维护 API、旧模板/新模板 JSON 预览导入、排行榜已实现 | 核心 API 集成测试含导入和排行榜断言 | 学生端学习状态、后台维护、批量 JSON 导入和基础排行榜已实现,更细复习参数和后台统计待完善 | | 知识手册 | 已建手册科目、章节、条目,并可绑定 `content_entries/content_nodes` | 已支持内容导入 | 只读 API、租户后台手册科目/章节/条目维护 API、嵌套 JSON 预览导入已实现 | 核心 API 集成测试含导入断言 | 学生端阅读、后台维护和批量 JSON 导入基础可用,富文本资源/版本管理待补 | | 分数线 | 已建院校、专业、字段、记录表 | 已支持导入映射 | 字段、院校、专业、记录、趋势、年份、租户后台维护 API 已实现 | 核心 API 集成测试 | 查询和后台维护基础闭环已实现,复杂动态筛选/批量导入待补 | | 题目视频讲解 | 已建 `video_explanations`、`question_videos` | 已支持导入映射 | 单题视频、批量预加载、通用视频搜索、租户后台视频创建绑定 API 已实现 | 核心 API 集成测试 | 播放数据和后台绑定链路已实现,会员权限、签名 URL、播放统计待补 | @@ -86,6 +86,7 @@ catalog: GET /api/catalog/svip-plans learning: + GET /api/learning/leaderboard POST /api/learning/practice-sessions POST /api/learning/answers GET /api/learning/favorites/questions @@ -252,7 +253,7 @@ platform-admin: 1. 完善内容导入和文件上传:Excel/CSV、分数线、视频导入,接真实 OSS/COS/Supabase Storage 签名,并把 JSON 导入扩展为异步 worker。 2. 补地区/公共题库披露策略、租户套餐地区限制、主题模板系统。 -3. 补学习统计:练习历史、正确率趋势、错题复习计划、单词复习算法。 +3. 补学习统计增强:排行榜防刷/预聚合、断点续练、专项练习策略和更细题型分析。 4. 补视频商用控制:SVIP 权限、签名 URL、防盗链、水印、播放次数扣减。 -5. 补 AI 择校推荐报告、排行榜、勋章自动发放。 +5. 补 AI 择校推荐报告、排行榜防刷/预聚合、勋章自动发放。 6. 接真实支付、短信、微信/QQ 登录 provider adapter,并开始 Taro scaffold。 diff --git a/docs/refactor/legacy-feature-gap-matrix.md b/docs/refactor/legacy-feature-gap-matrix.md index d13c4369..e7f16331 100644 --- a/docs/refactor/legacy-feature-gap-matrix.md +++ b/docs/refactor/legacy-feature-gap-matrix.md @@ -36,7 +36,7 @@ | AI 择校推荐 | 业务规划新增 | 未覆盖 | 需设计学生输入 schema、地区数据上下文、AI JSON 输出、PDF 报告 | | 题目反馈 | `02-API接口.md` 用户反馈 | 部分覆盖 | 学生提交、本人列表、租户后台处理、状态事件、反馈奖励积分已覆盖;缺处理通知、前端消息提醒和批量统计 | | 签到积分 | `Profile.tsx`、`02-API接口.md` | 部分覆盖 | 每日签到、连续签到基础、积分流水、重复签到幂等已覆盖;缺积分兑换、活动任务和更完整的运营规则 | -| 排行榜 | `leaderboard.pb.js`、`02-API接口.md` | 未覆盖 | 需新增题库/模考/背单词排行榜聚合接口和防刷策略 | +| 排行榜 | `leaderboard.pb.js`、`02-API接口.md` | 部分覆盖 | 已有刷题数、积分、背单词、模考最高分排行榜,支持租户/地区/班级范围和当前用户排名;后续补防刷、日/周榜预聚合、运营后台排名看板 | ## 租户后台功能 @@ -95,7 +95,7 @@ 这些是旧项目中已经出现过、但新后端还没有完整业务闭环的功能: -1. 排行榜:刷题、模考、背单词排行榜,以及防刷、租户/地区/班级维度。 +1. 排行榜增强:刷题、模考、背单词、积分排行榜主接口已有;还需防刷、日/周榜预聚合、运营后台排名看板。 2. 订单状态轮询和激活码预检查:订单列表和兑换已有,但旧商城体验需要更细的状态查询/预检接口。 3. 账号设置完整流:头像上传、绑定/更换手机号、微信/QQ 账号合并、密码/邮箱能力。 4. 题库导出:PDF/Word/JSON 导出、水印、导出审计和权限控制。 diff --git a/docs/refactor/next-development-todo.md b/docs/refactor/next-development-todo.md index 447f1e46..7b2b0f10 100644 --- a/docs/refactor/next-development-todo.md +++ b/docs/refactor/next-development-todo.md @@ -8,7 +8,7 @@ - Supabase/PostgreSQL 多租户 schema、RLS、索引、触发器。 - Node.js API 分层:`core/features`。 -- 学生端核心 API:题库、练习、答题、模考交卷报告、练习历史、学习统计、错题复习计划、错题、收藏、背单词、知识手册、分数线、视频播放签名、资料、订单、权益、个人中心、考试倒计时、签到积分、题目反馈。 +- 学生端核心 API:题库、练习、答题、模考交卷报告、练习历史、学习统计、排行榜、错题复习计划、错题、收藏、背单词、知识手册、分数线、视频播放签名、资料、订单、权益、个人中心、考试倒计时、签到积分、题目反馈。 - 租户后台 API:品牌、域名、设置、支付账户、登录 provider、私密密钥、活动、考试日期、题目反馈处理、激活码、优惠券、成员权限、审计、内容管理、班级/教师/学生、学生批量导入、批量分班、学生备注、跟进任务。 - 平台后台 API:租户、SaaS 套餐、订阅、账单、服务费收款、用量。 - 销售/代理/CRM 增长链路:邀请码、扫码事件、首绑保护、团队、统计、CRM 队列。 @@ -19,7 +19,7 @@ - 内容导入:题目、单词、知识手册 JSON 预览、校验、导入、幂等、审计。 - 租户组织范围:班级、班级成员、教师/班主任/助教/学生分组,教师按负责班级查看学生,字段权限可脱敏学生手机号。 - 学生运营管理:学生批量 upsert、禁用/恢复、批量分班、备注、跟进任务已完成接口和集成测试;后续补批量 CRM 推送和自动学习督导。 -- 旧题库运营缺口已补一批:考试日期/倒计时、题目反馈/纠错处理、每日签到积分和积分流水已完成接口和集成测试。 +- 旧题库运营缺口已补一批:考试日期/倒计时、题目反馈/纠错处理、每日签到积分和积分流水、学习排行榜已完成接口和集成测试。 - 本地验证:`npm run check:refactor` 已通过。 当前更适合进入前端联调前阅读的总览文档: @@ -83,8 +83,8 @@ 6. 学习统计 - 已完成免费额度、练习访问事件、模考交卷评分报告、练习历史、正确率趋势、题型分布、错题复习计划。 - 已完成单词复习算法、每日计划和复习上报。 - - 继续补排行榜。 - - 继续补模考排名、断点续练、复盘体验。 + - 已完成排行榜主接口;继续补防刷、日/周榜预聚合和运营后台排名看板。 + - 继续补断点续练和复盘体验。 7. 订单和激活码体验 - 补订单详情、订单状态轮询、支付状态刷新。 @@ -177,7 +177,7 @@ 8. 个人中心 - 会员权益、订单、激活码兑换 - - 错题本、收藏夹、学习统计 + - 错题本、收藏夹、学习统计、排行榜 ### 前端接入原则 diff --git a/docs/refactor/taro-frontend-integration.md b/docs/refactor/taro-frontend-integration.md index 03d4c91e..6528e832 100644 --- a/docs/refactor/taro-frontend-integration.md +++ b/docs/refactor/taro-frontend-integration.md @@ -159,6 +159,7 @@ tenant::theme | 错题复习 | `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/learning/leaderboard?metric=questions&period=all` | | 题目视频 | `GET /api/questions/{questionId}/videos`、`POST /api/questions/videos/batch`、`POST /api/videos/play` | | 题目反馈 | `POST /api/profile/feedbacks`、`GET /api/profile/feedbacks` | | 背单词 | `/api/catalog/vocabulary-units`、`/api/catalog/vocabulary-words` | @@ -302,6 +303,7 @@ tenant::theme | 学习概览卡片 | `GET /api/learning/stats?days=30` | 返回总答题、正确率、报告数、错题数、收藏数、题型分布 | | 正确率趋势图 | `GET /api/learning/trend?days=14` | 返回每日答题数、正确数、错题数、session 数、报告数 | | 错题复习入口 | `GET /api/learning/wrong-questions/review-plan?limit=20` | 返回建议复习题和后端组卷 nextAction | +| 排行榜 | `GET /api/learning/leaderboard?metric=questions&period=7d®ionId=...&classId=...` | 返回排名、用户展示信息、当前用户排名和范围信息 | 错题复习创建 session: @@ -319,6 +321,22 @@ tenant::theme - 收藏夹复习同理可调用 `POST /api/learning/practice-sessions`,body 为 `{ "mode": "favorite_review", "questionLimit": 20 }`。 - 趋势图以接口返回日期桶为准,缺失日期后端会补 0,不需要前端补点。 +### 排行榜 + +排行榜由后端统一聚合,前端不要读取答题记录、单词进度或模考报告后自行排名,避免越权、口径漂移和跨租户数据泄露。 + +可选参数: + +| 参数 | 可选值 | 说明 | +| --- | --- | --- | +| `metric` | `questions`、`score`、`vocabulary`、`mock_exam` | 分别表示累计答题、积分、掌握单词、模考最高分 | +| `period` | `all`、`7d`、`30d` | 统计周期 | +| `regionId` | UUID | 地区范围,可选 | +| `classId` | UUID | 班级范围,可选,后端按当前租户校验 | +| `limit` / `page` | 正整数 | 分页 | + +响应会包含 `items` 和 `currentUser`。即使当前用户未进入前 N 名,也应优先展示 `currentUser` 作为“我的排名”。后台后续会补日/周榜预聚合和防刷策略,前端只消费接口返回口径。 + ### 背单词计划与复习上报 背单词页面分三类数据:单元列表、每日计划、单词进度。前端不需要计算下次复习日期,只提交“认识/不认识”,由后端统一更新 `nextReviewDate`、连续正确、掌握状态和每日复习计划。 diff --git a/scripts/api-integration-test.js b/scripts/api-integration-test.js index 5779d1e3..9e40631f 100644 --- a/scripts/api-integration-test.js +++ b/scripts/api-integration-test.js @@ -994,6 +994,55 @@ async function testProfile() { assert.equal(crossTenantFeedback.code, 'AUTH_TENANT_MISMATCH', 'feedback submission must use authenticated tenant context'); } +async function testLearningLeaderboard() { + const questions = await request('/api/learning/leaderboard', { + query: { metric: 'questions', period: 'all', limit: 10 }, + }); + assert.equal(questions.metric, 'questions', 'question leaderboard should echo metric'); + assert.ok(questions.items?.some(item => item.userId === SECOND_STUDENT_USER_ID), 'question leaderboard should include second student'); + assert.ok(questions.items?.some(item => item.userId === USER_ID), 'question leaderboard should include current student'); + const secondQuestions = questions.items?.find(item => item.userId === SECOND_STUDENT_USER_ID); + const currentQuestions = questions.currentUser; + assert.ok(secondQuestions?.value >= currentQuestions?.value, 'second student should rank at least as high as smoke user by questions'); + assert.equal(currentQuestions?.userId, USER_ID, 'question leaderboard should include current user rank'); + assert.equal(currentQuestions?.isCurrentUser, true, 'current user rank should be flagged'); + + const score = await request('/api/learning/leaderboard', { + query: { metric: 'score', period: 'all', limit: 10 }, + }); + const scoreLeader = score.items?.find(item => item.userId === SECOND_STUDENT_USER_ID); + assert.ok(scoreLeader?.value >= 30, 'score leaderboard should use platform user score'); + assert.ok(score.currentUser?.value >= 10, 'score leaderboard should include current user score'); + + const vocabulary = await request('/api/learning/leaderboard', { + query: { metric: 'vocabulary', period: 'all', limit: 10 }, + }); + const vocabularyLeader = vocabulary.items?.find(item => item.userId === SECOND_STUDENT_USER_ID); + assert.equal(vocabularyLeader?.value, 2, 'vocabulary leaderboard should count mastered words'); + + const mockExam = await request('/api/learning/leaderboard', { + query: { metric: 'mock_exam', period: '30d', limit: 10 }, + }); + const mockLeader = mockExam.items?.find(item => item.userId === SECOND_STUDENT_USER_ID); + assert.equal(mockLeader?.value, 95, 'mock exam leaderboard should use best report score'); + assert.ok(mockExam.currentUser?.value >= 70, 'mock exam leaderboard should include current user best score'); + + const classScoped = await request('/api/learning/leaderboard', { + query: { metric: 'questions', classId: ids.tenantClass, limit: 10 }, + }); + assert.ok(classScoped.items?.some(item => item.userId === USER_ID), 'class scoped leaderboard should include class student'); + assert.ok(!classScoped.items?.some(item => item.userId === SECOND_STUDENT_USER_ID), 'class scoped leaderboard should exclude other class student'); + + const trustedLogin = await loginBySms('13800000000'); + const crossTenantDenied = await request('/api/learning/leaderboard', { + tenantId: PARTNER_TENANT_ID, + userId: false, + headers: { authorization: `Bearer ${trustedLogin.session.token}` }, + expectStatus: 403, + }); + assert.equal(crossTenantDenied.code, 'AUTH_TENANT_MISMATCH', 'leaderboard must reject trusted session cross-tenant access'); +} + async function testScoreline() { const fields = await request('/api/scoreline/fields', { query: { regionId: ids.region } }); assert.ok(fields.items?.some(item => item.fieldKey === 'minScore'), 'scoreline fields should include minScore'); @@ -3326,6 +3375,7 @@ async function main() { await check('legacy auth headers disabled', testLegacyAuthHeadersDisabled); await check('catalog and learning', testCatalogAndLearning); await check('profile', testProfile); + await check('learning leaderboard', testLearningLeaderboard); await check('scoreline', testScoreline); await check('question videos', testVideos); await check('vocabulary', testVocabulary); diff --git a/scripts/smoke-seed.js b/scripts/smoke-seed.js index 112a56ff..42744b60 100644 --- a/scripts/smoke-seed.js +++ b/scripts/smoke-seed.js @@ -28,6 +28,15 @@ const ids = { practiceBlueprintSequential: '00000000-0000-0000-0000-000000000616', practiceBlueprintRandom: '00000000-0000-0000-0000-000000000617', practiceBlueprintMock: '00000000-0000-0000-0000-000000000618', + leaderboardSessionUser: '00000000-0000-0000-0000-000000000621', + leaderboardSessionSecond: '00000000-0000-0000-0000-000000000622', + leaderboardReportUser: '00000000-0000-0000-0000-000000000623', + leaderboardReportSecond: '00000000-0000-0000-0000-000000000624', + leaderboardAnswerUserOne: '00000000-0000-0000-0000-000000000625', + leaderboardAnswerUserTwo: '00000000-0000-0000-0000-000000000626', + leaderboardAnswerSecondOne: '00000000-0000-0000-0000-000000000627', + leaderboardAnswerSecondTwo: '00000000-0000-0000-0000-000000000628', + leaderboardScoreEventSecond: '00000000-0000-0000-0000-000000000629', questionBank: '00000000-0000-0000-0000-000000000400', question: '00000000-0000-0000-0000-000000000401', questionVersion: '00000000-0000-0000-0000-000000000402', @@ -41,6 +50,7 @@ const ids = { activationCode: '00000000-0000-0000-0000-000000000801', vocabularyUnit: '00000000-0000-0000-0000-000000000811', vocabularyWord: '00000000-0000-0000-0000-000000000812', + vocabularyWordTwo: '00000000-0000-0000-0000-000000000813', video: '00000000-0000-0000-0000-000000000821', questionVideo: '00000000-0000-0000-0000-000000000822', videoAsset: '00000000-0000-0000-0000-000000000823', @@ -103,9 +113,54 @@ async function main() { ` delete from public.user_score_events where tenant_id = $1 - and user_id in ($2::uuid, $3::uuid) + and ( + user_id in ($2::uuid, $3::uuid, $4::uuid) + or id = $5::uuid + ) `, - [tenantId, ids.user, ids.tenantAdminUser], + [tenantId, ids.user, ids.tenantAdminUser, ids.secondStudentUser, ids.leaderboardScoreEventSecond], + ); + + await client.query( + ` + delete from public.practice_session_report_sections + where tenant_id = $1 + and report_id in ($2::uuid, $3::uuid) + `, + [tenantId, ids.leaderboardReportUser, ids.leaderboardReportSecond], + ); + + await client.query( + ` + delete from public.practice_session_reports + where tenant_id = $1 + and id in ($2::uuid, $3::uuid) + `, + [tenantId, ids.leaderboardReportUser, ids.leaderboardReportSecond], + ); + + await client.query( + ` + delete from public.answer_records + where tenant_id = $1 + and id in ($2::uuid, $3::uuid, $4::uuid, $5::uuid) + `, + [ + tenantId, + ids.leaderboardAnswerUserOne, + ids.leaderboardAnswerUserTwo, + ids.leaderboardAnswerSecondOne, + ids.leaderboardAnswerSecondTwo, + ], + ); + + await client.query( + ` + delete from public.practice_sessions + where tenant_id = $1 + and id in ($2::uuid, $3::uuid) + `, + [tenantId, ids.leaderboardSessionUser, ids.leaderboardSessionSecond], ); await client.query( @@ -296,9 +351,9 @@ async function main() { update public.platform_users set score = 0, updated_at = now() - where id in ($1::uuid, $2::uuid) + where id in ($1::uuid, $2::uuid, $3::uuid) `, - [ids.user, ids.tenantAdminUser], + [ids.user, ids.tenantAdminUser, ids.secondStudentUser], ); await client.query( @@ -819,6 +874,195 @@ async function main() { ], ); + await client.query( + ` + insert into public.practice_sessions ( + id, tenant_id, user_id, mode, target_type, target_id, + blueprint_id, collection_id, entry_id, content_node_id, + question_ids, question_count, duration_minutes, total_score, + started_at, finished_at, metadata + ) + values + ( + $1, $3, $4, 'mock_exam', 'blueprint', $6, + $6, $7, $8, $9, + jsonb_build_array($10::uuid, $11::uuid), 2, 120, 100, + now() - interval '2 days', now() - interval '2 days' + interval '30 minutes', + '{"source":"smoke-leaderboard"}'::jsonb + ), + ( + $2, $3, $5, 'mock_exam', 'blueprint', $6, + $6, $7, $8, $9, + jsonb_build_array($10::uuid, $11::uuid, $12::uuid), 3, 120, 100, + now() - interval '1 day', now() - interval '1 day' + interval '35 minutes', + '{"source":"smoke-leaderboard"}'::jsonb + ) + on conflict (id) + do update set question_ids = excluded.question_ids, + question_count = excluded.question_count, + finished_at = excluded.finished_at, + metadata = excluded.metadata + `, + [ + ids.leaderboardSessionUser, + ids.leaderboardSessionSecond, + tenantId, + ids.user, + ids.secondStudentUser, + ids.practiceBlueprintMock, + ids.questionCollection, + ids.contentEntry, + ids.contentNodeSchoolTarget, + ids.question, + ids.questionTwo, + ids.questionThree, + ], + ); + + await client.query( + ` + insert into public.answer_records ( + id, tenant_id, user_id, question_id, question_version_id, practice_session_id, + selected_options, answer_text, is_correct, answered_at + ) + values + ($1, $5, $6, $8, $9, $12, '["1"]'::jsonb, null, true, now() - interval '2 days'), + ($2, $5, $6, $10, $11, $12, '["0"]'::jsonb, null, false, now() - interval '2 days' + interval '1 minute'), + ($3, $5, $7, $8, $9, $13, '["1"]'::jsonb, null, true, now() - interval '1 day'), + ($4, $5, $7, $10, $11, $13, '["1"]'::jsonb, null, true, now() - interval '1 day' + interval '1 minute') + on conflict (id) + do update set selected_options = excluded.selected_options, + is_correct = excluded.is_correct, + answered_at = excluded.answered_at + `, + [ + ids.leaderboardAnswerUserOne, + ids.leaderboardAnswerUserTwo, + ids.leaderboardAnswerSecondOne, + ids.leaderboardAnswerSecondTwo, + tenantId, + ids.user, + ids.secondStudentUser, + ids.question, + ids.questionVersion, + ids.questionTwo, + ids.questionTwoVersion, + ids.leaderboardSessionUser, + ids.leaderboardSessionSecond, + ], + ); + + await client.query( + ` + insert into public.practice_session_reports ( + id, 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, $3, $4, $6, $8, $9, 'mock_exam', + 2, 2, 1, 1, 0, 70, 100, 0.5, 1800, + now() - interval '2 days', now() - interval '2 days' + interval '30 minutes', + '[{"key":"choice","title":"单选题","questionCount":2,"correctCount":1,"score":70,"totalScore":100}]'::jsonb, + '[]'::jsonb, jsonb_build_array($10::uuid), '{"source":"smoke-leaderboard"}'::jsonb + ), + ( + $2, $3, $5, $7, $8, $9, 'mock_exam', + 3, 2, 2, 0, 1, 95, 100, 0.6667, 2100, + now() - interval '1 day', now() - interval '1 day' + interval '35 minutes', + '[{"key":"choice","title":"单选题","questionCount":3,"correctCount":2,"score":95,"totalScore":100}]'::jsonb, + '[]'::jsonb, '[]'::jsonb, '{"source":"smoke-leaderboard"}'::jsonb + ) + on conflict (tenant_id, practice_session_id) + do update set score = excluded.score, + total_score = excluded.total_score, + accuracy = excluded.accuracy, + submitted_at = excluded.submitted_at, + section_stats = excluded.section_stats, + question_results = excluded.question_results, + wrong_question_ids = excluded.wrong_question_ids, + metadata = excluded.metadata + `, + [ + ids.leaderboardReportUser, + ids.leaderboardReportSecond, + tenantId, + ids.user, + ids.secondStudentUser, + ids.leaderboardSessionUser, + ids.leaderboardSessionSecond, + ids.practiceBlueprintMock, + ids.questionCollection, + ids.questionTwo, + ], + ); + + await client.query( + ` + insert into public.user_score_events ( + id, tenant_id, user_id, event_type, points, balance_after, + source_type, idempotency_key, metadata, created_at + ) + values ( + $1, $2, $3, 'activity_reward', 30, 30, + 'smoke-leaderboard', 'smoke-leaderboard-score-second', '{"source":"smoke-seed"}'::jsonb, + now() - interval '1 day' + ) + on conflict (id) + do update set points = excluded.points, + balance_after = excluded.balance_after, + metadata = excluded.metadata, + created_at = excluded.created_at + `, + [ids.leaderboardScoreEventSecond, tenantId, ids.secondStudentUser], + ); + + await client.query( + ` + update public.platform_users + set score = case id + when $1::uuid then 10 + when $2::uuid then 30 + else score + end, + updated_at = now() + where id in ($1::uuid, $2::uuid) + `, + [ids.user, ids.secondStudentUser], + ); + + await client.query( + ` + update public.student_profiles sp + set mastered_words_count = coalesce(word_stats.mastered_count, 0), + stats = coalesce(sp.stats, '{}'::jsonb) || jsonb_build_object( + 'totalAnswered', coalesce(answer_stats.answer_count, 0), + 'correctCount', coalesce(answer_stats.correct_count, 0), + 'wrongCount', coalesce(answer_stats.wrong_count, 0) + ), + updated_at = now() + from ( + select user_id, + count(*)::integer as answer_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 + where tenant_id = $1 and user_id in ($2::uuid, $3::uuid) + group by user_id + ) answer_stats + left join ( + select user_id, count(*)::integer as mastered_count + from public.user_word_progress + where tenant_id = $1 and user_id in ($2::uuid, $3::uuid) and status = 'mastered' + group by user_id + ) word_stats on word_stats.user_id = answer_stats.user_id + where sp.tenant_id = $1 and sp.user_id = answer_stats.user_id + `, + [tenantId, ids.user, ids.secondStudentUser], + ); + await client.query( ` insert into public.content_assets ( @@ -999,7 +1243,7 @@ async function main() { insert into public.vocabulary_units ( id, tenant_id, region_id, legacy_id, name, description, word_count, sort_order, is_active ) - values ($1, $2, $3, 'smoke-vocab-unit', '烟测单词单元', '本地 smoke 背单词单元', 1, 1, true) + values ($1, $2, $3, 'smoke-vocab-unit', '烟测单词单元', '本地 smoke 背单词单元', 2, 1, true) on conflict (id) do update set name = excluded.name, region_id = excluded.region_id, @@ -1019,6 +1263,11 @@ async function main() { $1, $2, $3, 'smoke-word', 'abandon', '/əˈbændən/', '放弃', 'Do not abandon your plan.', '不要放弃你的计划。', 1, '["smoke","basic"]'::jsonb, 1, true + ), + ( + $4, $2, $3, 'smoke-word-2', 'benefit', '/ˈbenɪfɪt/', '好处', + 'Practice brings benefit.', '练习会带来好处。', 1, + '["smoke","basic"]'::jsonb, 2, true ) on conflict (id) do update set word = excluded.word, @@ -1026,25 +1275,33 @@ async function main() { meaning = excluded.meaning, updated_at = now() `, - [ids.vocabularyWord, tenantId, ids.vocabularyUnit], + [ids.vocabularyWord, tenantId, ids.vocabularyUnit, ids.vocabularyWordTwo], ); await client.query( ` insert into public.user_word_progress ( tenant_id, user_id, word_id, status, correct_count, wrong_count, + review_count, correct_streak, last_result, due_level, last_review_date, next_review_date ) - values ($1, $2, $3, 'learning', 1, 0, now(), now() + interval '1 day') + values + ($1, $2, $3, 'learning', 1, 0, 1, 1, 'known', 'soon', now(), now() + interval '1 day'), + ($1, $4, $3, 'mastered', 3, 0, 3, 3, 'known', 'mastered', now(), now() + interval '7 days'), + ($1, $4, $5, 'mastered', 3, 0, 3, 3, 'known', 'mastered', now(), now() + interval '7 days') on conflict (tenant_id, user_id, word_id) do update set status = excluded.status, correct_count = excluded.correct_count, wrong_count = excluded.wrong_count, + review_count = excluded.review_count, + correct_streak = excluded.correct_streak, + last_result = excluded.last_result, + due_level = excluded.due_level, last_review_date = excluded.last_review_date, next_review_date = excluded.next_review_date, updated_at = now() `, - [tenantId, ids.user, ids.vocabularyWord], + [tenantId, ids.user, ids.vocabularyWord, ids.secondStudentUser, ids.vocabularyWordTwo], ); await client.query( diff --git a/supabase/migrations/202606290005_learning_leaderboards.sql b/supabase/migrations/202606290005_learning_leaderboards.sql new file mode 100644 index 00000000..56a9a6ca --- /dev/null +++ b/supabase/migrations/202606290005_learning_leaderboards.sql @@ -0,0 +1,21 @@ +create index if not exists idx_tenant_memberships_student_active + on public.tenant_memberships(tenant_id, role, status, user_id) + where role = 'student' and status = 'active'; + +create index if not exists idx_student_profiles_tenant_region_user + on public.student_profiles(tenant_id, region_id, user_id); + +create index if not exists idx_answer_records_tenant_answered_user + on public.answer_records(tenant_id, answered_at desc, user_id); + +create index if not exists idx_score_events_tenant_created_user_positive + on public.user_score_events(tenant_id, created_at desc, user_id) + where points > 0; + +create index if not exists idx_user_word_progress_tenant_review_user + on public.user_word_progress(tenant_id, last_review_date desc, user_id) + where last_review_date is not null; + +create index if not exists idx_practice_reports_tenant_mode_score + on public.practice_session_reports(tenant_id, mode, score desc, submitted_at desc, user_id) + where mode = 'mock_exam';