feat: add point activities and exchange

This commit is contained in:
Codex
2026-06-30 03:28:11 +08:00
parent e035f5be9a
commit 2fea68fe4d
22 changed files with 2121 additions and 43 deletions

View File

@@ -55,6 +55,11 @@ const ids = {
tenantCheckInBadge: '00000000-0000-0000-0000-000000000873',
tenantScoreBadge: '00000000-0000-0000-0000-000000000874',
tenantFeedbackBadge: '00000000-0000-0000-0000-000000000875',
tenantActivityBadge: '00000000-0000-0000-0000-000000000876',
pointActivityTask: '00000000-0000-0000-0000-000000000877',
pointSystemTask: '00000000-0000-0000-0000-000000000878',
pointExchangeItem: '00000000-0000-0000-0000-000000000879',
pointExpensiveExchangeItem: '00000000-0000-0000-0000-000000000880',
questionBank: '00000000-0000-0000-0000-000000000400',
publicQuestionBankGrant: '00000000-0000-0000-0000-000000000906',
};
@@ -6811,11 +6816,245 @@ async function testTenantAdminOps() {
assert.equal(partnerDenied.code, 'TENANT_ADMIN_REQUIRED', 'tenant admin must not administer another tenant without membership');
}
async function testPointActivitiesAndExchange() {
const activityBadge = await request('/api/tenant-admin/badges', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
id: ids.tenantActivityBadge,
legacyId: 'integration-badge-activity-auto',
name: '集成测试活动任务勋章',
description: '完成积分活动任务后自动发放',
category: 'activity',
iconUrl: 'https://example.test/badges/activity-auto.png',
level: 1,
unlockType: 'activity_reward',
conditionField: 'activityRewardCount',
conditionOperator: 'gte',
conditionValue: 1,
metadata: { source: 'integration-test', trigger: 'activity_reward' },
order: 4,
isActive: true,
},
});
assert.equal(activityBadge.item?.id, ids.tenantActivityBadge, 'tenant admin should create activity reward badge');
const manualTask = await request('/api/tenant-admin/point-activity-tasks', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
id: ids.pointActivityTask,
legacyId: 'integration-point-task-manual',
code: 'integration-manual-task',
title: '集成测试积分任务',
description: '用于验证学生端领取积分任务',
taskType: 'manual',
rewardPoints: 18,
claimLimitPerUser: 1,
periodType: 'once',
metadata: { source: 'integration-test' },
order: 1,
status: 'active',
},
});
assert.equal(manualTask.item?.id, ids.pointActivityTask, 'tenant admin should upsert point activity task');
const systemTask = await request('/api/tenant-admin/point-activity-tasks', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
id: ids.pointSystemTask,
legacyId: 'integration-point-task-feedback-resolved',
code: 'integration-system-feedback-task',
title: '集成测试系统发放任务',
taskType: 'feedback_resolved',
rewardPoints: 6,
periodType: 'once',
status: 'active',
},
});
assert.equal(systemTask.item?.taskType, 'feedback_resolved', 'tenant admin should allow system-granted task config');
const tasks = await request('/api/profile/activity-tasks', { query: { limit: 20 } });
assert.ok(tasks.items?.some(item => item.id === ids.pointActivityTask), 'student should list active point activity task');
const systemClaimDenied = await request('/api/profile/activity-tasks/claim', {
method: 'POST',
body: { taskId: ids.pointSystemTask },
expectStatus: 403,
});
assert.equal(systemClaimDenied.code, 'POINT_TASK_SYSTEM_GRANTED_ONLY', 'student must not self-claim system workflow tasks');
const claimed = await request('/api/profile/activity-tasks/claim', {
method: 'POST',
body: {
taskId: ids.pointActivityTask,
idempotencyKey: 'integration-manual-claim',
metadata: { source: 'integration-test' },
},
});
assert.equal(claimed.item?.pointsAdded, 18, 'claiming activity task should add configured points');
assert.equal(claimed.item?.ledger?.eventType, 'activity_reward', 'activity task claim should write activity_reward ledger');
assert.equal(claimed.item?.claim?.taskId, ids.pointActivityTask, 'activity claim should bind task id');
assert.ok(
claimed.item?.autoBadges?.some(item => item.badgeId === ids.tenantActivityBadge),
'activity claim should auto grant activity badge',
);
const duplicateClaim = await request('/api/profile/activity-tasks/claim', {
method: 'POST',
body: {
taskId: ids.pointActivityTask,
idempotencyKey: 'integration-manual-claim',
},
expectStatus: 409,
});
assert.equal(duplicateClaim.code, 'POINT_TASK_CLAIM_LIMIT_REACHED', 'activity task should enforce claim limit');
const scoreEventsAfterClaim = await request('/api/profile/score-events', { query: { limit: 80 } });
assert.ok(
scoreEventsAfterClaim.items?.some(item => item.eventType === 'activity_reward' && item.sourceId === ids.pointActivityTask),
'score ledger should include activity reward source',
);
const activityClaims = await request('/api/tenant-admin/point-activity-claims', {
userId: TENANT_ADMIN_USER_ID,
query: { taskId: ids.pointActivityTask, limit: 20 },
});
assert.ok(activityClaims.items?.some(item => item.userId === USER_ID), 'tenant admin should list activity claims');
const exchangeCoupon = await request('/api/tenant-admin/coupons', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
code: 'IT-POINT-EXCHANGE-COUPON',
planId: ids.couponOnlyPlan,
discountType: 'fixed',
discountValue: 100,
maxUses: 10,
perUserLimit: 10,
campaignName: 'integration-point-exchange',
metadata: { source: 'integration-test' },
source: 'points-exchange',
},
});
assert.equal(exchangeCoupon.item?.code, 'IT-POINT-EXCHANGE-COUPON', 'tenant admin should create coupon for point exchange');
const exchangeItem = await request('/api/tenant-admin/point-exchange-items', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
id: ids.pointExchangeItem,
legacyId: 'integration-point-exchange-item',
code: 'integration-coupon-exchange',
title: '集成测试积分兑换券',
description: '用积分兑换优惠券',
costPoints: 12,
itemType: 'coupon',
couponId: exchangeCoupon.item.id,
stockTotal: 1,
perUserLimit: 1,
metadata: { source: 'integration-test' },
status: 'active',
},
});
assert.equal(exchangeItem.item?.id, ids.pointExchangeItem, 'tenant admin should upsert point exchange item');
const exchangeList = await request('/api/profile/exchange-items', { query: { limit: 20 } });
assert.ok(exchangeList.items?.some(item => item.id === ids.pointExchangeItem), 'student should list active exchange item');
const redeemed = await request('/api/profile/exchange-items/redeem', {
method: 'POST',
body: {
itemId: ids.pointExchangeItem,
idempotencyKey: 'integration-exchange-redeem',
metadata: { source: 'integration-test' },
},
});
assert.equal(redeemed.item?.pointsSpent, 12, 'exchange should spend configured points');
assert.equal(redeemed.item?.ledger?.eventType, 'redeem_cost', 'exchange should write redeem_cost ledger');
assert.equal(redeemed.item?.order?.itemId, ids.pointExchangeItem, 'exchange order should bind item');
assert.ok(redeemed.item?.couponRedemption?.id, 'coupon exchange should create coupon redemption');
const repeatedRedeem = await request('/api/profile/exchange-items/redeem', {
method: 'POST',
body: {
itemId: ids.pointExchangeItem,
idempotencyKey: 'integration-exchange-redeem',
},
});
assert.equal(repeatedRedeem.item?.idempotent, true, 'same exchange idempotency key should return existing order');
const redeemLimitDenied = await request('/api/profile/exchange-items/redeem', {
method: 'POST',
body: {
itemId: ids.pointExchangeItem,
idempotencyKey: 'integration-exchange-redeem-2',
},
expectStatus: 409,
});
assert.equal(redeemLimitDenied.code, 'POINT_EXCHANGE_USER_LIMIT_REACHED', 'exchange should enforce per-user limit');
const stockDenied = await request('/api/profile/exchange-items/redeem', {
userId: SECOND_STUDENT_USER_ID,
method: 'POST',
body: {
itemId: ids.pointExchangeItem,
idempotencyKey: 'integration-exchange-second-student',
},
expectStatus: 409,
});
assert.equal(stockDenied.code, 'POINT_EXCHANGE_OUT_OF_STOCK', 'exchange should enforce stock before charging another user');
const expensiveItem = await request('/api/tenant-admin/point-exchange-items', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
id: ids.pointExpensiveExchangeItem,
legacyId: 'integration-point-exchange-expensive',
code: 'integration-expensive-exchange',
title: '集成测试高积分兑换',
costPoints: 9999,
itemType: 'manual',
stockTotal: 20,
perUserLimit: 1,
status: 'active',
},
});
assert.equal(expensiveItem.item?.id, ids.pointExpensiveExchangeItem, 'tenant admin should create high-cost exchange item');
const insufficientScoreDenied = await request('/api/profile/exchange-items/redeem', {
userId: SECOND_STUDENT_USER_ID,
method: 'POST',
body: {
itemId: ids.pointExpensiveExchangeItem,
idempotencyKey: 'integration-expensive-second-student',
},
expectStatus: 409,
});
assert.equal(insufficientScoreDenied.code, 'POINT_EXCHANGE_INSUFFICIENT_SCORE', 'exchange should reject insufficient score balance');
const exchangeOrders = await request('/api/tenant-admin/point-exchange-orders', {
userId: TENANT_ADMIN_USER_ID,
query: { itemId: ids.pointExchangeItem, limit: 20 },
});
assert.ok(exchangeOrders.items?.some(item => item.userId === USER_ID), 'tenant admin should list exchange orders');
const partnerExchangeDenied = await request('/api/tenant-admin/point-exchange-items', {
tenantId: PARTNER_TENANT_ID,
userId: TENANT_ADMIN_USER_ID,
expectStatus: 403,
});
assert.equal(partnerExchangeDenied.code, 'TENANT_ADMIN_REQUIRED', 'point exchange admin must be tenant isolated');
}
async function testTenantMemberPermissionsAndAudit() {
const permissionMatrix = await request('/api/tenant-admin/permissions', {
userId: TENANT_ADMIN_USER_ID,
});
assert.ok(permissionMatrix.permissions?.some(item => item.key === 'marketing:write'), 'permission matrix should expose marketing permission');
assert.ok(permissionMatrix.permissions?.some(item => item.key === 'marketing:points:read'), 'permission matrix should expose point marketing read permission');
assert.ok(permissionMatrix.permissions?.some(item => item.key === 'marketing:points:write'), 'permission matrix should expose point marketing write permission');
assert.ok(permissionMatrix.permissions?.some(item => item.key === 'dashboard:read'), 'permission matrix should expose dashboard read permission');
assert.ok(permissionMatrix.permissions?.some(item => item.key === 'roles:write'), 'permission matrix should expose role template permission');
assert.ok(permissionMatrix.permissions?.some(item => item.key === 'badges:grant'), 'permission matrix should expose badge grant permission');
@@ -7927,6 +8166,7 @@ async function main() {
await check('tenant content assets and imports', testTenantContentAssetsAndImports);
await check('public question bank adoption', testPublicQuestionBankAdoption);
await check('tenant admin operations', testTenantAdminOps);
await check('point activities and exchange', testPointActivitiesAndExchange);
await check('tenant member permissions and audit', testTenantMemberPermissionsAndAudit);
await check('tenant class and student scopes', testTenantClassStudentScopes);
await check('tenant student operations', testTenantStudentOperations);