forked from wangziqi/gongxue-base
feat: add learning leaderboards
This commit is contained in:
@@ -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、公共题库授权、租户采纳、订单状态轮询、激活码预检查、积分活动深化,以及排行榜防刷/预聚合。
|
||||
|
||||
@@ -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],
|
||||
|
||||
338
apps/api/src/features/learning/leaderboard.ts
Normal file
338
apps/api/src/features/learning/leaderboard.ts
Normal file
@@ -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<LeaderboardMetric>(['questions', 'score', 'vocabulary', 'mock_exam']);
|
||||
const PERIODS = new Set<LeaderboardPeriod>(['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<RankedRow>(
|
||||
`
|
||||
with ${rankedSelect(metricSql, `$${params.length + 1}`, `$${params.length + 2}`)}
|
||||
`,
|
||||
listParams,
|
||||
);
|
||||
|
||||
const currentUser = await queryOne<RankedRow>(
|
||||
`
|
||||
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(),
|
||||
};
|
||||
}
|
||||
@@ -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 和其它内容类型应接入同一管线。
|
||||
|
||||
@@ -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`;积分流水幂等、事务加锁,重复签到不重复加分 |
|
||||
|
||||
@@ -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 和小程序双端跑通:登录、首页、题库、练习、错题、收藏、单词、手册、会员、排行榜、个人中心。
|
||||
- 租户隔离、后台角色权限、资源访问权限、订单权益和内容导入至少有集成测试覆盖。
|
||||
|
||||
后端进入“商用生产交付”的最低标准:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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 导出、水印、导出审计和权限控制。
|
||||
|
||||
@@ -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. 个人中心
|
||||
- 会员权益、订单、激活码兑换
|
||||
- 错题本、收藏夹、学习统计
|
||||
- 错题本、收藏夹、学习统计、排行榜
|
||||
|
||||
### 前端接入原则
|
||||
|
||||
|
||||
@@ -159,6 +159,7 @@ tenant:<tenantId>: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:<tenantId>: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:<tenantId>: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`、连续正确、掌握状态和每日复习计划。
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
21
supabase/migrations/202606290005_learning_leaderboards.sql
Normal file
21
supabase/migrations/202606290005_learning_leaderboards.sql
Normal file
@@ -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';
|
||||
Reference in New Issue
Block a user