feat: add learning leaderboards

This commit is contained in:
Codex
2026-06-29 02:11:52 +08:00
parent 8f92d0427e
commit 9122bb0829
15 changed files with 724 additions and 33 deletions

View File

@@ -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、公共题库授权、租户采纳、订单状态轮询、激活码预检查、积分活动深化,以及排行榜防刷/预聚合

View File

@@ -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],

View 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(),
};
}

View File

@@ -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 当前支持题目 JSONExcel/CSV 和其它内容类型应接入同一管线。

View File

@@ -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`;积分流水幂等、事务加锁,重复签到不重复加分 |

View File

@@ -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 和小程序双端跑通:登录、首页、题库、练习、错题、收藏、单词、手册、会员、排行榜、个人中心。
- 租户隔离、后台角色权限、资源访问权限、订单权益和内容导入至少有集成测试覆盖。
后端进入“商用生产交付”的最低标准:

View File

@@ -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

View File

@@ -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。

View File

@@ -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。

View File

@@ -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 导出、水印、导出审计和权限控制。

View File

@@ -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. 个人中心
- 会员权益、订单、激活码兑换
- 错题本、收藏夹、学习统计
- 错题本、收藏夹、学习统计、排行榜
### 前端接入原则

View File

@@ -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&regionId=...&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`、连续正确、掌握状态和每日复习计划。

View File

@@ -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);

View File

@@ -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(

View 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';