forked from wangziqi/gongxue-base
feat: auto grant badges for learning milestones
This commit is contained in:
@@ -58,6 +58,9 @@ const ids = {
|
||||
tenantScoreBadge: '00000000-0000-0000-0000-000000000874',
|
||||
tenantFeedbackBadge: '00000000-0000-0000-0000-000000000875',
|
||||
tenantActivityBadge: '00000000-0000-0000-0000-000000000876',
|
||||
tenantPracticeBadge: '00000000-0000-0000-0000-000000000881',
|
||||
tenantVocabularyBadge: '00000000-0000-0000-0000-000000000882',
|
||||
tenantMockExamBadge: '00000000-0000-0000-0000-000000000883',
|
||||
pointActivityTask: '00000000-0000-0000-0000-000000000877',
|
||||
pointSystemTask: '00000000-0000-0000-0000-000000000878',
|
||||
pointExchangeItem: '00000000-0000-0000-0000-000000000879',
|
||||
@@ -2386,6 +2389,50 @@ async function testCatalogAndLearning() {
|
||||
'node session should include descendant questions',
|
||||
);
|
||||
|
||||
const practiceBadge = await request('/api/tenant-admin/badges', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
method: 'PUT',
|
||||
body: {
|
||||
id: ids.tenantPracticeBadge,
|
||||
legacyId: 'integration-badge-practice-count-auto',
|
||||
name: '集成测试练习完成勋章',
|
||||
description: '完成一次练习报告后自动发放',
|
||||
category: 'practice',
|
||||
iconUrl: 'https://example.test/badges/practice-count-auto.png',
|
||||
level: 1,
|
||||
unlockType: 'practice_count',
|
||||
conditionField: 'practiceCount',
|
||||
conditionOperator: 'gte',
|
||||
conditionValue: 1,
|
||||
metadata: { source: 'integration-test', trigger: 'practice_count' },
|
||||
order: 3,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
assert.equal(practiceBadge.item?.id, ids.tenantPracticeBadge, 'tenant admin should create automatic practice count badge');
|
||||
|
||||
const mockExamBadge = await request('/api/tenant-admin/badges', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
method: 'PUT',
|
||||
body: {
|
||||
id: ids.tenantMockExamBadge,
|
||||
legacyId: 'integration-badge-mock-exam-score-auto',
|
||||
name: '集成测试模考成绩勋章',
|
||||
description: '模考分数达到规则后自动发放',
|
||||
category: 'mock_exam',
|
||||
iconUrl: 'https://example.test/badges/mock-exam-score-auto.png',
|
||||
level: 1,
|
||||
unlockType: 'mock_exam_score',
|
||||
conditionField: 'mockExamScore',
|
||||
conditionOperator: 'gte',
|
||||
conditionValue: 4,
|
||||
metadata: { source: 'integration-test', trigger: 'mock_exam_score' },
|
||||
order: 4,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
assert.equal(mockExamBadge.item?.id, ids.tenantMockExamBadge, 'tenant admin should create automatic mock exam score badge');
|
||||
|
||||
const mockSession = await request('/api/learning/practice-sessions', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
method: 'POST',
|
||||
@@ -2456,6 +2503,14 @@ async function testCatalogAndLearning() {
|
||||
mockReport.item?.questionResults?.some(item => item.questionId === ids.questionThree && item.isCorrect === true && item.answerText === '已经对照参考答案完成自评'),
|
||||
'mock report should include backend-persisted subjective self judgment',
|
||||
);
|
||||
assert.ok(
|
||||
mockReport.item?.autoBadges?.some(item => item.badgeId === ids.tenantPracticeBadge),
|
||||
'mock report submit should auto grant practice count badge',
|
||||
);
|
||||
assert.ok(
|
||||
mockReport.item?.autoBadges?.some(item => item.badgeId === ids.tenantMockExamBadge),
|
||||
'mock report submit should auto grant mock exam score badge',
|
||||
);
|
||||
|
||||
const idempotentReport = await request('/api/learning/practice-sessions/submit', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
@@ -2466,6 +2521,11 @@ async function testCatalogAndLearning() {
|
||||
},
|
||||
});
|
||||
assert.equal(idempotentReport.item?.id, mockReport.item.id, 'mock submit should be idempotent');
|
||||
assert.equal(
|
||||
idempotentReport.item?.autoBadges?.filter(item => item.badgeId === ids.tenantMockExamBadge).length || 0,
|
||||
0,
|
||||
'idempotent mock submit should not duplicate automatic mock exam badges',
|
||||
);
|
||||
|
||||
const fetchedReport = await request('/api/learning/practice-sessions/report', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
@@ -3144,6 +3204,28 @@ async function testVocabulary() {
|
||||
const stats = await request('/api/learning/vocabulary/stats', { query: { unitId: ids.vocabularyUnit } });
|
||||
assert.ok(stats.item?.totalWords >= 1, 'word stats should count smoke word');
|
||||
|
||||
const vocabularyBadge = await request('/api/tenant-admin/badges', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
method: 'PUT',
|
||||
body: {
|
||||
id: ids.tenantVocabularyBadge,
|
||||
legacyId: 'integration-badge-vocabulary-mastered-auto',
|
||||
name: '集成测试单词掌握勋章',
|
||||
description: '掌握单词达到规则后自动发放',
|
||||
category: 'vocabulary',
|
||||
iconUrl: 'https://example.test/badges/vocabulary-mastered-auto.png',
|
||||
level: 1,
|
||||
unlockType: 'vocabulary_mastered',
|
||||
conditionField: 'vocabularyMasteredCount',
|
||||
conditionOperator: 'gte',
|
||||
conditionValue: 1,
|
||||
metadata: { source: 'integration-test', trigger: 'vocabulary_mastered' },
|
||||
order: 5,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
assert.equal(vocabularyBadge.item?.id, ids.tenantVocabularyBadge, 'tenant admin should create automatic vocabulary badge');
|
||||
|
||||
const reviewUnknown = await request('/api/learning/vocabulary/review', {
|
||||
method: 'POST',
|
||||
body: { userId: USER_ID, wordId: ids.vocabularyWord, result: 'unknown' },
|
||||
@@ -3162,6 +3244,10 @@ async function testVocabulary() {
|
||||
body: { userId: USER_ID, wordId: ids.vocabularyWord, status: 'mastered', correctDelta: 1 },
|
||||
});
|
||||
assert.equal(progress.item?.status, 'mastered', 'word progress should update to mastered');
|
||||
assert.ok(
|
||||
progress.item?.autoBadges?.some(item => item.badgeId === ids.tenantVocabularyBadge),
|
||||
'mastered word progress should auto grant vocabulary badge',
|
||||
);
|
||||
|
||||
const reviewKnown = await request('/api/learning/vocabulary/review', {
|
||||
method: 'POST',
|
||||
@@ -3170,6 +3256,11 @@ async function testVocabulary() {
|
||||
assert.equal(reviewKnown.item?.lastResult, 'known', 'word review should persist known result');
|
||||
assert.ok(reviewKnown.item?.reviewCount >= 1, 'word review should increment review count');
|
||||
assert.ok(reviewKnown.item?.nextReviewDate, 'word review should compute next review date');
|
||||
assert.equal(
|
||||
reviewKnown.item?.autoBadges?.filter(item => item.badgeId === ids.tenantVocabularyBadge).length || 0,
|
||||
0,
|
||||
'repeated mastered word review should not duplicate vocabulary badge',
|
||||
);
|
||||
|
||||
const favorite = await request('/api/learning/vocabulary/favorites', {
|
||||
method: 'POST',
|
||||
|
||||
@@ -75,6 +75,9 @@ const ids = {
|
||||
tenantScoreBadge: '00000000-0000-0000-0000-000000000874',
|
||||
tenantFeedbackBadge: '00000000-0000-0000-0000-000000000875',
|
||||
tenantActivityBadge: '00000000-0000-0000-0000-000000000876',
|
||||
tenantPracticeBadge: '00000000-0000-0000-0000-000000000881',
|
||||
tenantVocabularyBadge: '00000000-0000-0000-0000-000000000882',
|
||||
tenantMockExamBadge: '00000000-0000-0000-0000-000000000883',
|
||||
pointActivityTask: '00000000-0000-0000-0000-000000000877',
|
||||
pointSystemTask: '00000000-0000-0000-0000-000000000878',
|
||||
pointExchangeItem: '00000000-0000-0000-0000-000000000879',
|
||||
@@ -357,13 +360,27 @@ async function main() {
|
||||
tenantId,
|
||||
ids.user,
|
||||
ids.secondStudentUser,
|
||||
[ids.tenantBadge, ids.tenantCheckInBadge, ids.tenantScoreBadge, ids.tenantFeedbackBadge, ids.tenantActivityBadge],
|
||||
[
|
||||
ids.tenantBadge,
|
||||
ids.tenantCheckInBadge,
|
||||
ids.tenantScoreBadge,
|
||||
ids.tenantFeedbackBadge,
|
||||
ids.tenantActivityBadge,
|
||||
ids.tenantPracticeBadge,
|
||||
ids.tenantVocabularyBadge,
|
||||
ids.tenantMockExamBadge,
|
||||
],
|
||||
[
|
||||
`badge:${ids.tenantBadge}:user:${ids.user}`,
|
||||
`auto:${ids.tenantCheckInBadge}:user:${ids.user}`,
|
||||
`auto:${ids.tenantScoreBadge}:user:${ids.user}`,
|
||||
`auto:${ids.tenantFeedbackBadge}:user:${ids.user}`,
|
||||
`auto:${ids.tenantActivityBadge}:user:${ids.user}`,
|
||||
`auto:${ids.tenantPracticeBadge}:user:${ids.user}`,
|
||||
`auto:${ids.tenantPracticeBadge}:user:${ids.tenantAdminUser}`,
|
||||
`auto:${ids.tenantVocabularyBadge}:user:${ids.user}`,
|
||||
`auto:${ids.tenantMockExamBadge}:user:${ids.user}`,
|
||||
`auto:${ids.tenantMockExamBadge}:user:${ids.tenantAdminUser}`,
|
||||
],
|
||||
],
|
||||
);
|
||||
@@ -377,7 +394,19 @@ async function main() {
|
||||
or coalesce(legacy_id, '') like 'integration-badge-%'
|
||||
)
|
||||
`,
|
||||
[tenantId, [ids.tenantBadge, ids.tenantCheckInBadge, ids.tenantScoreBadge, ids.tenantFeedbackBadge, ids.tenantActivityBadge]],
|
||||
[
|
||||
tenantId,
|
||||
[
|
||||
ids.tenantBadge,
|
||||
ids.tenantCheckInBadge,
|
||||
ids.tenantScoreBadge,
|
||||
ids.tenantFeedbackBadge,
|
||||
ids.tenantActivityBadge,
|
||||
ids.tenantPracticeBadge,
|
||||
ids.tenantVocabularyBadge,
|
||||
ids.tenantMockExamBadge,
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
@@ -969,6 +998,7 @@ async function main() {
|
||||
`
|
||||
update public.student_profiles
|
||||
set region_id = $3,
|
||||
avatar_preset = 'male',
|
||||
last_check_in_date = null,
|
||||
stats = stats - 'checkInStreak' - 'lastCheckInPoints',
|
||||
updated_at = now()
|
||||
|
||||
Reference in New Issue
Block a user