feat: add student crm push

This commit is contained in:
Codex
2026-06-30 16:21:17 +08:00
parent d94b20b8ed
commit 7f97b52165
15 changed files with 774 additions and 27 deletions

View File

@@ -9463,6 +9463,102 @@ async function testReferralAndCrmGrowth() {
assert.deepEqual(crmConfig.item?.assignmentPool, [TENANT_SALES_USER_ID, TENANT_AGENT_USER_ID], 'CRM config should persist assignment pool');
assert.ok(!JSON.stringify(crmConfig).includes('crm-secret-smoke'), 'CRM config response should not leak secret');
const studentCrmPush = await request('/api/tenant-admin/students/crm-push', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
studentUserIds: [USER_ID, SECOND_STUDENT_USER_ID],
source: 'integration_batch',
title: '集成测试批量跟进',
message: '请销售今日联系并记录转化情况',
followupType: 'sales',
priority: 'high',
assignedToUserId: TENANT_SALES_USER_ID,
dueAt: '2026-07-02T10:00:00.000Z',
idempotencyKey: 'integration-student-crm-push',
metadata: { source: 'integration-test' },
},
});
assert.equal(studentCrmPush.total, 2, 'student CRM push should report total');
assert.equal(studentCrmPush.successCount, 2, 'student CRM push should enqueue valid students');
assert.equal(studentCrmPush.errorCount, 0, 'student CRM push should not fail valid students');
assert.ok(studentCrmPush.items?.every(item => item.queueId && item.followupId), 'student CRM push should return queue and follow-up ids');
const repeatedStudentCrmPush = await request('/api/tenant-admin/students/crm-push', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
studentUserIds: [USER_ID, SECOND_STUDENT_USER_ID],
title: '集成测试批量跟进',
message: '重复提交不应重复创建队列',
followupType: 'sales',
priority: 'high',
assignedToUserId: TENANT_SALES_USER_ID,
idempotencyKey: 'integration-student-crm-push',
},
});
assert.deepEqual(
repeatedStudentCrmPush.items?.map(item => item.queueId).sort(),
studentCrmPush.items?.map(item => item.queueId).sort(),
'student CRM push idempotency should reuse queue ids',
);
assert.deepEqual(
repeatedStudentCrmPush.items?.map(item => item.followupId).sort(),
studentCrmPush.items?.map(item => item.followupId).sort(),
'student CRM push idempotency should reuse follow-up ids',
);
const studentCrmPushClassError = await request('/api/tenant-admin/students/crm-push', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
studentUserIds: [USER_ID, SECOND_STUDENT_USER_ID],
title: '集成测试班级范围跟进',
classId: ids.tenantClass,
idempotencyKey: 'integration-student-crm-push-class-scope',
},
});
assert.equal(studentCrmPushClassError.total, 2, 'student CRM class scoped push should report total');
assert.equal(studentCrmPushClassError.successCount, 1, 'student CRM class scoped push should enqueue in-class student');
assert.ok(
studentCrmPushClassError.errors?.some(item => item.studentUserId === SECOND_STUDENT_USER_ID && item.code === 'STUDENT_CLASS_REQUIRED'),
'student CRM class scoped push should reject students outside the selected class',
);
const operatorCrmPushDenied = await request('/api/tenant-admin/students/crm-push', {
userId: TENANT_OPERATOR_USER_ID,
method: 'POST',
body: {
studentUserIds: [USER_ID],
title: '不应允许普通运营推送',
},
expectStatus: 403,
});
assert.equal(operatorCrmPushDenied.code, 'TENANT_PERMISSION_REQUIRED', 'student CRM push should require crm:write permission');
const partnerStudentCrmPushDenied = await request('/api/tenant-admin/students/crm-push', {
tenantId: PARTNER_TENANT_ID,
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
studentUserIds: [USER_ID],
title: '不应跨租户推送',
},
expectStatus: 403,
});
assert.equal(partnerStudentCrmPushDenied.code, 'TENANT_ADMIN_REQUIRED', 'student CRM push must be tenant isolated');
const studentCrmPushDenied = await request('/api/tenant-admin/students/crm-push', {
userId: USER_ID,
method: 'POST',
body: {
studentUserIds: [USER_ID],
title: '学生端不应推送 CRM',
},
expectStatus: 403,
});
assert.equal(studentCrmPushDenied.code, 'TENANT_ADMIN_REQUIRED', 'student CRM push should reject student users');
const invalidCrmAssignment = await request('/api/crm/config', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
@@ -9820,6 +9916,14 @@ async function testReferralAndCrmGrowth() {
});
assert.ok(crmQueue.items?.some(item => item.leadId === manual.lead.item.id || item.leadId === tracked.lead.item.id), 'CRM queue should include referral lead task');
assert.ok(crmQueue.items?.some(item => item.payload?.assignee?.id === TENANT_AGENT_USER_ID), 'CRM queue payload should include assigned follow-up user');
assert.ok(
crmQueue.items?.some(item => item.payload?.eventType === 'student.crm_push' && item.payload?.assignee?.userId === TENANT_SALES_USER_ID),
'CRM queue should include student CRM push task',
);
assert.ok(
crmQueue.items?.some(item => item.payload?.eventType === 'student.crm_push' && item.payload?.student?.avatarPreset && !item.payload?.student?.avatarUrl),
'student CRM push payload should use preset avatars only',
);
assert.ok(!JSON.stringify(crmQueue).includes('crm-secret-smoke'), 'CRM queue should not leak secret');
const partnerReferralDenied = await request('/api/referral/sales-stats', {