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

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