forked from wangziqi/gongxue-base
feat: add point activities and exchange
This commit is contained in:
@@ -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);
|
||||
|
||||
+111
-3
@@ -74,6 +74,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',
|
||||
partnerTenant: '00000000-0000-0000-0000-000000000901',
|
||||
partnerSubscription: '00000000-0000-0000-0000-000000000902',
|
||||
partnerInvoice: '00000000-0000-0000-0000-000000000903',
|
||||
@@ -222,6 +227,42 @@ async function main() {
|
||||
[tenantId, ids.user, ids.tenantAdminUser, ids.question, ids.questionTwo],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
delete from public.user_point_exchange_orders
|
||||
where tenant_id = $1
|
||||
and (
|
||||
user_id in ($2::uuid, $3::uuid)
|
||||
or item_id = any($4::uuid[])
|
||||
or idempotency_key like 'integration-%'
|
||||
)
|
||||
`,
|
||||
[
|
||||
tenantId,
|
||||
ids.user,
|
||||
ids.secondStudentUser,
|
||||
[ids.pointExchangeItem, ids.pointExpensiveExchangeItem],
|
||||
],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
delete from public.user_point_activity_claims
|
||||
where tenant_id = $1
|
||||
and (
|
||||
user_id in ($2::uuid, $3::uuid)
|
||||
or task_id = any($4::uuid[])
|
||||
or coalesce(metadata->>'source', '') = 'integration-test'
|
||||
)
|
||||
`,
|
||||
[
|
||||
tenantId,
|
||||
ids.user,
|
||||
ids.secondStudentUser,
|
||||
[ids.pointActivityTask, ids.pointSystemTask],
|
||||
],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
delete from public.user_score_events
|
||||
@@ -234,6 +275,32 @@ async function main() {
|
||||
[tenantId, ids.user, ids.tenantAdminUser, ids.secondStudentUser, ids.leaderboardScoreEventSecond],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
delete from public.point_exchange_items
|
||||
where tenant_id = $1
|
||||
and (
|
||||
id = any($2::uuid[])
|
||||
or coalesce(legacy_id, '') like 'integration-point-exchange-%'
|
||||
or code::text like 'integration-%'
|
||||
)
|
||||
`,
|
||||
[tenantId, [ids.pointExchangeItem, ids.pointExpensiveExchangeItem]],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
delete from public.point_activity_tasks
|
||||
where tenant_id = $1
|
||||
and (
|
||||
id = any($2::uuid[])
|
||||
or coalesce(legacy_id, '') like 'integration-point-task-%'
|
||||
or code::text like 'integration-%'
|
||||
)
|
||||
`,
|
||||
[tenantId, [ids.pointActivityTask, ids.pointSystemTask]],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
delete from public.audit_logs
|
||||
@@ -336,7 +403,12 @@ async function main() {
|
||||
[tenantId, ids.leaderboardSessionUser, ids.leaderboardSessionSecond],
|
||||
);
|
||||
|
||||
const checkoutCouponCodes = ['SMOKE50', 'SMOKEFREE'];
|
||||
const checkoutCouponCodes = [
|
||||
'SMOKE50',
|
||||
'SMOKEFREE',
|
||||
'IT-COUPON-001',
|
||||
'IT-POINT-EXCHANGE-COUPON',
|
||||
];
|
||||
|
||||
await client.query(
|
||||
`
|
||||
@@ -345,14 +417,31 @@ async function main() {
|
||||
and (
|
||||
coupon_id = any($2::uuid[])
|
||||
or coupon_code = any($3::text[])
|
||||
or coupon_code like any($4::text[])
|
||||
or coupon_id in (
|
||||
select id from public.coupons
|
||||
where tenant_id = $1
|
||||
and (
|
||||
campaign_name in ('integration-coupon-rules', 'integration-point-exchange', 'tenant-admin-coupon-smoke')
|
||||
or code::text like any($4::text[])
|
||||
)
|
||||
)
|
||||
or order_id in (
|
||||
select id from public.orders
|
||||
where tenant_id = $1
|
||||
and (raw_payload->'pricing'->>'couponCode') = any($3::text[])
|
||||
and (
|
||||
(raw_payload->'pricing'->>'couponCode') = any($3::text[])
|
||||
or (raw_payload->'pricing'->>'couponCode') like any($4::text[])
|
||||
)
|
||||
)
|
||||
)
|
||||
`,
|
||||
[tenantId, [ids.checkoutCoupon, ids.freeCheckoutCoupon], checkoutCouponCodes],
|
||||
[
|
||||
tenantId,
|
||||
[ids.checkoutCoupon, ids.freeCheckoutCoupon],
|
||||
checkoutCouponCodes,
|
||||
['ITMIN%', 'ITDIS%', 'ITREG%', 'ITMULTI%', 'ITFIRST%', 'IT-POINT-%'],
|
||||
],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
@@ -423,6 +512,25 @@ async function main() {
|
||||
[tenantId, checkoutCouponCodes],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
delete from public.coupons
|
||||
where tenant_id = $1
|
||||
and (
|
||||
id = any($2::uuid[])
|
||||
or code::text = any($3::text[])
|
||||
or code::text like any($4::text[])
|
||||
or campaign_name in ('integration-coupon-rules', 'integration-point-exchange', 'tenant-admin-coupon-smoke')
|
||||
)
|
||||
`,
|
||||
[
|
||||
tenantId,
|
||||
[ids.checkoutCoupon, ids.freeCheckoutCoupon],
|
||||
checkoutCouponCodes,
|
||||
['ITMIN%', 'ITDIS%', 'ITREG%', 'ITMULTI%', 'ITFIRST%', 'IT-POINT-%'],
|
||||
],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
delete from public.commission_settlement_items
|
||||
|
||||
Reference in New Issue
Block a user