feat: auto grant badges for learning milestones

This commit is contained in:
Codex
2026-06-30 14:24:07 +08:00
parent 9299552451
commit 7d2b8fd610
11 changed files with 343 additions and 19 deletions

View File

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

View File

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