feat: add automatic badge awards

This commit is contained in:
Codex
2026-06-30 02:50:13 +08:00
parent 9a8a85a8c0
commit e035f5be9a
14 changed files with 523 additions and 26 deletions

View File

@@ -52,6 +52,9 @@ const ids = {
examDate: '00000000-0000-0000-0000-000000000861',
tenantExamDate: '00000000-0000-0000-0000-000000000862',
tenantBadge: '00000000-0000-0000-0000-000000000871',
tenantCheckInBadge: '00000000-0000-0000-0000-000000000873',
tenantScoreBadge: '00000000-0000-0000-0000-000000000874',
tenantFeedbackBadge: '00000000-0000-0000-0000-000000000875',
questionBank: '00000000-0000-0000-0000-000000000400',
publicQuestionBankGrant: '00000000-0000-0000-0000-000000000906',
};
@@ -1685,20 +1688,87 @@ async function testProfile() {
assert.equal(typeof profileExamDate?.daysLeft, 'number', 'profile countdown should include daysLeft');
assert.equal(countdowns.target?.regionId, ids.region, 'profile countdown should use student target region');
const checkInBadge = await request('/api/tenant-admin/badges', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
id: ids.tenantCheckInBadge,
legacyId: 'integration-badge-check-in-auto',
name: '集成测试签到勋章',
description: '连续签到达到规则后自动发放',
category: 'activity',
iconUrl: 'https://example.test/badges/check-in-auto.png',
level: 1,
unlockType: 'check_in',
conditionField: 'checkInStreak',
conditionOperator: 'gte',
conditionValue: 1,
metadata: { source: 'integration-test', trigger: 'check_in' },
order: 1,
isActive: true,
},
});
assert.equal(checkInBadge.item?.id, ids.tenantCheckInBadge, 'tenant admin should create automatic check-in badge');
const scoreBadge = await request('/api/tenant-admin/badges', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
id: ids.tenantScoreBadge,
legacyId: 'integration-badge-score-auto',
name: '集成测试积分勋章',
description: '积分达到规则后自动发放',
category: 'activity',
iconUrl: 'https://example.test/badges/score-auto.png',
level: 1,
unlockType: 'score',
conditionField: 'score',
conditionOperator: 'gte',
conditionValue: 10,
metadata: { source: 'integration-test', trigger: 'score' },
order: 2,
isActive: true,
},
});
assert.equal(scoreBadge.item?.id, ids.tenantScoreBadge, 'tenant admin should create automatic score badge');
const checkIn = await request('/api/profile/check-in', { method: 'POST' });
assert.equal(checkIn.item?.checkedIn, true, 'student should be able to check in');
assert.ok(checkIn.item?.pointsAdded >= 10, 'check-in should add points');
assert.ok(checkIn.item?.score >= checkIn.item?.pointsAdded, 'check-in should return updated score');
assert.equal(checkIn.item?.ledger?.eventType, 'check_in', 'check-in should write score ledger');
assert.ok(
checkIn.item?.autoBadges?.some(item => item.badgeId === ids.tenantCheckInBadge),
'check-in should auto grant check-in badge',
);
assert.ok(
checkIn.item?.autoBadges?.some(item => item.badgeId === ids.tenantScoreBadge),
'check-in should auto grant score threshold badge',
);
const duplicateCheckIn = await request('/api/profile/check-in', { method: 'POST' });
assert.equal(duplicateCheckIn.item?.checkedIn, false, 'duplicate daily check-in should be idempotent');
assert.equal(duplicateCheckIn.item?.alreadyCheckedIn, true, 'duplicate daily check-in should report already checked in');
assert.equal(duplicateCheckIn.item?.pointsAdded, 0, 'duplicate daily check-in should not add points');
assert.equal(duplicateCheckIn.item?.autoBadges, undefined, 'duplicate check-in should not auto grant badges');
const scoreEvents = await request('/api/profile/score-events', { query: { limit: 20 } });
assert.ok(scoreEvents.items?.some(item => item.eventType === 'check_in'), 'score ledger should include check-in event');
const autoProfileBadges = await request('/api/profile/badges', {
query: { includeLocked: 'true', category: 'activity', limit: 20 },
});
assert.equal(
autoProfileBadges.items?.find(item => item.badgeId === ids.tenantCheckInBadge)?.unlocked,
true,
'profile badges should expose auto check-in badge as unlocked',
);
assert.equal(
autoProfileBadges.items?.find(item => item.badgeId === ids.tenantScoreBadge)?.unlocked,
true,
'profile badges should expose auto score badge as unlocked',
);
const feedback = await request('/api/profile/feedbacks', {
method: 'POST',
body: {
@@ -1815,6 +1885,7 @@ async function testAiSchoolRecommendationAfterSvip() {
method: 'POST',
body: {
regionId: ids.region,
targetSchoolId: ids.scorelineSchool,
estimatedScore: 210,
riskPreference: 'balanced',
constraints: '优先考虑计算机相关专业',
@@ -6566,6 +6637,28 @@ async function testTenantAdminOps() {
});
assert.equal(studentFeedbackDenied.code, 'TENANT_ADMIN_REQUIRED', 'student should not access tenant feedback admin');
const feedbackBadge = await request('/api/tenant-admin/badges', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
id: ids.tenantFeedbackBadge,
legacyId: 'integration-badge-feedback-auto',
name: '集成测试纠错勋章',
description: '反馈被解决达到规则后自动发放',
category: 'feedback',
iconUrl: 'https://example.test/badges/feedback-auto.png',
level: 1,
unlockType: 'feedback_resolved',
conditionField: 'feedbackResolvedCount',
conditionOperator: 'gte',
conditionValue: 1,
metadata: { source: 'integration-test', trigger: 'feedback_resolved' },
order: 1,
isActive: true,
},
});
assert.equal(feedbackBadge.item?.id, ids.tenantFeedbackBadge, 'tenant admin should create automatic feedback badge');
const resolvedFeedback = await request('/api/tenant-admin/feedbacks/status', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
@@ -6581,6 +6674,10 @@ async function testTenantAdminOps() {
});
assert.equal(resolvedFeedback.item?.status, 'resolved', 'tenant admin should resolve feedback');
assert.equal(resolvedFeedback.item?.reward?.eventType, 'feedback_reward', 'feedback reward should write score ledger');
assert.ok(
resolvedFeedback.item?.autoBadges?.some(item => item.badgeId === ids.tenantFeedbackBadge),
'resolved feedback should auto grant feedback badge',
);
const rewardEventsAfterResolve = await request('/api/profile/score-events', { query: { limit: 50 } });
const rewardCountAfterResolve = rewardEventsAfterResolve.items?.filter(
@@ -6600,6 +6697,11 @@ async function testTenantAdminOps() {
},
});
assert.equal(repeatedReward.item?.reward, null, 'repeated feedback reward should be idempotent');
assert.equal(
repeatedReward.item?.autoBadges?.filter(item => item.badgeId === ids.tenantFeedbackBadge).length || 0,
0,
'repeated feedback resolution should not duplicate auto feedback badge',
);
const rewardEventsAfterRepeat = await request('/api/profile/score-events', { query: { limit: 50 } });
const rewardCountAfterRepeat = rewardEventsAfterRepeat.items?.filter(
@@ -6607,6 +6709,15 @@ async function testTenantAdminOps() {
).length || 0;
assert.equal(rewardCountAfterRepeat, 1, 'feedback reward should not duplicate on repeated status updates');
const feedbackProfileBadges = await request('/api/profile/badges', {
query: { includeLocked: 'true', category: 'feedback', limit: 20 },
});
assert.equal(
feedbackProfileBadges.items?.find(item => item.badgeId === ids.tenantFeedbackBadge)?.unlocked,
true,
'profile badges should expose auto feedback badge as unlocked',
);
const feedbackEvents = await request('/api/tenant-admin/feedbacks/events', {
userId: TENANT_ADMIN_USER_ID,
query: { reportId: pendingFeedback.id },