forked from wangziqi/gongxue-base
feat: add learning leaderboards
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user