feat: 完成全系统30个功能点开发与E2E全链路验收 (8轮调度32个子代理闭环)
- 模块一: 出海案例作品集、博客动态发布、人名发音语音条、RTL阿语多语言切换 - 模块二: AI顾问人设配置、行业术语库润色、RAG切片沙盒调试、流式会话与意向留资卡片 - 模块三: 全球访客雷达大屏、多维加权意向算分、外部CRM对接推送、团队公海池流转 - 模块四: 移动端NFC安全挂失、NFC动态路由规则配置、动态防伪验签、展会制卡Zip批量导出 - 模块五: 组织架构与名片分配、移动端个人中心设置、离职交接与历史线索迁移、安全审计日志 - 模块六: Stripe国际信用卡订阅Webhook、独立二级域名与SSL检测、坐席配额预警、多币种发票 - 模块七: 纸质名片OCR解析入库、国际商务日程RFC 5545 ICS日历、微官网离线SWR预加载、展会实时排行榜大屏 - 模块八: 全球出海SEO OpenGraph元标签注入与社交爬虫探测、30点端到端全链路业务流集成测试套件
这个提交包含在:
@@ -0,0 +1,346 @@
|
||||
import {
|
||||
CrmLeadScoreService,
|
||||
LeadIntentGrade,
|
||||
LeadScoreBehaviorInput,
|
||||
} from '../../../src/modules/crm/service/lead-score';
|
||||
import { CrmLeadService } from '../../../src/modules/crm/service/lead';
|
||||
import { CrmLeadEntity } from '../../../src/modules/crm/entity/lead';
|
||||
import { RadarTrack, LeadEntity } from '../../../src/comm/types/omni';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
describe('Point 10: 线索智能意向度评分引擎 (lead-score.ts) 单元测试', () => {
|
||||
let scoreService: CrmLeadScoreService;
|
||||
|
||||
const mockTrack: Partial<RadarTrack> = {
|
||||
country: 'United Arab Emirates',
|
||||
city: 'Dubai',
|
||||
touchType: 'nfc',
|
||||
stayDurationMs: 320000,
|
||||
viewedBlocks: ['hero', 'case_study', 'product_demo', 'contact'],
|
||||
};
|
||||
|
||||
const mockLead: Partial<LeadEntity> = {
|
||||
name: 'Tariq Al-Mansoor',
|
||||
company: 'Gulf Horizon Trading',
|
||||
phone: '+971-50-9876543',
|
||||
email: 'tariq@gulfhorizon.ae',
|
||||
source: 'nfc',
|
||||
notes: '对中东区域独家代理感兴趣',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
scoreService = new CrmLeadScoreService();
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 1. 停留时长加权规则测试 (>30s +15, >120s +30, >300s +40)
|
||||
// ==========================================
|
||||
describe('1. 停留时长加权规则', () => {
|
||||
it('停留时长 <= 30秒时,得分应为 0', () => {
|
||||
expect(scoreService.calculateScore({ stayDurationSeconds: 0 }).score).toBe(0);
|
||||
expect(scoreService.calculateScore({ stayDurationSeconds: 15 }).score).toBe(0);
|
||||
expect(scoreService.calculateScore({ stayDurationSeconds: 30 }).score).toBe(0);
|
||||
});
|
||||
|
||||
it('停留时长 > 30秒且 <= 120秒时,得分应加 15', () => {
|
||||
expect(scoreService.calculateScore({ stayDurationSeconds: 31 }).score).toBe(15);
|
||||
expect(scoreService.calculateScore({ stayDurationSeconds: 60 }).score).toBe(15);
|
||||
expect(scoreService.calculateScore({ stayDurationSeconds: 120 }).score).toBe(15);
|
||||
});
|
||||
|
||||
it('停留时长 > 120秒且 <= 300秒时,得分应加 30', () => {
|
||||
expect(scoreService.calculateScore({ stayDurationSeconds: 121 }).score).toBe(30);
|
||||
expect(scoreService.calculateScore({ stayDurationSeconds: 200 }).score).toBe(30);
|
||||
expect(scoreService.calculateScore({ stayDurationSeconds: 300 }).score).toBe(30);
|
||||
});
|
||||
|
||||
it('停留时长 > 300秒时,得分应加 40', () => {
|
||||
expect(scoreService.calculateScore({ stayDurationSeconds: 301 }).score).toBe(40);
|
||||
expect(scoreService.calculateScore({ stayDurationSeconds: 600 }).score).toBe(40);
|
||||
});
|
||||
|
||||
it('支持毫秒输入 (stayDurationMs) 并正确转换为秒级加权', () => {
|
||||
// 25000ms = 25s <= 30s -> 0
|
||||
expect(scoreService.calculateScore({ stayDurationMs: 25000 }).score).toBe(0);
|
||||
// 45000ms = 45s > 30s -> 15
|
||||
expect(scoreService.calculateScore({ stayDurationMs: 45000 }).score).toBe(15);
|
||||
// 150000ms = 150s > 120s -> 30
|
||||
expect(scoreService.calculateScore({ stayDurationMs: 150000 }).score).toBe(30);
|
||||
// 360000ms = 360s > 300s -> 40
|
||||
expect(scoreService.calculateScore({ stayDurationMs: 360000 }).score).toBe(40);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 2. 浏览模块深度测试 (每个查看模块 +5,上限 20)
|
||||
// ==========================================
|
||||
describe('2. 浏览模块深度加权规则', () => {
|
||||
it('查看 0 个模块时,得分应为 0', () => {
|
||||
expect(scoreService.calculateScore({ viewedBlockCount: 0 }).score).toBe(0);
|
||||
expect(scoreService.calculateScore({ viewedBlocks: [] }).score).toBe(0);
|
||||
});
|
||||
|
||||
it('查看 1 至 3 个模块时,按每个模块 +5 累加', () => {
|
||||
expect(scoreService.calculateScore({ viewedBlockCount: 1 }).score).toBe(5);
|
||||
expect(scoreService.calculateScore({ viewedBlockCount: 2 }).score).toBe(10);
|
||||
expect(scoreService.calculateScore({ viewedBlockCount: 3 }).score).toBe(15);
|
||||
});
|
||||
|
||||
it('查看 4 个及以上模块时,得分上限截断为 20', () => {
|
||||
expect(scoreService.calculateScore({ viewedBlockCount: 4 }).score).toBe(20);
|
||||
expect(scoreService.calculateScore({ viewedBlockCount: 5 }).score).toBe(20);
|
||||
expect(scoreService.calculateScore({ viewedBlockCount: 10 }).score).toBe(20);
|
||||
});
|
||||
|
||||
it('传入 viewedBlocks 数组时应自动去重并计算', () => {
|
||||
const blocksWithDuplicates = ['hero', 'case', 'hero', 'social', 'case'];
|
||||
// 去重后为 3 个不同模块: hero, case, social -> 15分
|
||||
const res = scoreService.calculateScore({ viewedBlocks: blocksWithDuplicates });
|
||||
expect(res.score).toBe(15);
|
||||
expect(res.breakdown.moduleScore).toBe(15);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 3. 高价值互动加权测试 (WhatsApp +25, vCard +20, 留资 +35)
|
||||
// ==========================================
|
||||
describe('3. 高价值互动加权规则', () => {
|
||||
it('点击 WhatsApp 加 25 分', () => {
|
||||
const res = scoreService.calculateScore({ clickedWhatsApp: true });
|
||||
expect(res.score).toBe(25);
|
||||
expect(res.breakdown.interactionScore).toBe(25);
|
||||
});
|
||||
|
||||
it('下载 vCard 加 20 分', () => {
|
||||
const res = scoreService.calculateScore({ downloadedVCard: true });
|
||||
expect(res.score).toBe(20);
|
||||
expect(res.breakdown.interactionScore).toBe(20);
|
||||
});
|
||||
|
||||
it('主动留资加 35 分', () => {
|
||||
const res = scoreService.calculateScore({ submittedLead: true });
|
||||
expect(res.score).toBe(35);
|
||||
expect(res.breakdown.interactionScore).toBe(35);
|
||||
});
|
||||
|
||||
it('多个高价值互动可叠加加分', () => {
|
||||
// WhatsApp (25) + vCard (20) = 45
|
||||
const res = scoreService.calculateScore({
|
||||
clickedWhatsApp: true,
|
||||
downloadedVCard: true,
|
||||
});
|
||||
expect(res.score).toBe(45);
|
||||
expect(res.breakdown.interactionScore).toBe(45);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 4. 综合加权、边界与异常防御测试
|
||||
// ==========================================
|
||||
describe('4. 综合加权与边界防御保护 (0~100 截断、负数与异常)', () => {
|
||||
it('无任何行为时,应得 0 分并判定为 Cold', () => {
|
||||
const res = scoreService.calculateScore({});
|
||||
expect(res.score).toBe(0);
|
||||
expect(res.level).toBe('Cold');
|
||||
expect(res.breakdown).toEqual({
|
||||
stayScore: 0,
|
||||
moduleScore: 0,
|
||||
interactionScore: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('各维度组合加权正确', () => {
|
||||
// 停留 150s (+30) + 模块 2个 (+10) + WhatsApp (+25) = 65
|
||||
const res = scoreService.calculateScore({
|
||||
stayDurationSeconds: 150,
|
||||
viewedBlockCount: 2,
|
||||
clickedWhatsApp: true,
|
||||
});
|
||||
expect(res.score).toBe(65);
|
||||
expect(res.level).toBe('Warm');
|
||||
expect(res.breakdown).toEqual({
|
||||
stayScore: 30,
|
||||
moduleScore: 10,
|
||||
interactionScore: 25,
|
||||
});
|
||||
});
|
||||
|
||||
it('高维度满额且叠加所有互动时,总分严格截断在 100 分上限', () => {
|
||||
// 停留 >300s (40) + 模块 >=4 (20) + WhatsApp (25) + vCard (20) + 留资 (35) = 140
|
||||
const res = scoreService.calculateScore({
|
||||
stayDurationSeconds: 350,
|
||||
viewedBlockCount: 5,
|
||||
clickedWhatsApp: true,
|
||||
downloadedVCard: true,
|
||||
submittedLead: true,
|
||||
});
|
||||
expect(res.score).toBe(100);
|
||||
expect(res.level).toBe('Hot');
|
||||
});
|
||||
|
||||
it('负数与非法值输入时具备防御性保护,最小分为 0', () => {
|
||||
const res = scoreService.calculateScore({
|
||||
stayDurationSeconds: -50,
|
||||
stayDurationMs: -10000,
|
||||
viewedBlockCount: -5,
|
||||
});
|
||||
expect(res.score).toBe(0);
|
||||
expect(res.level).toBe('Cold');
|
||||
expect(res.breakdown.stayScore).toBe(0);
|
||||
expect(res.breakdown.moduleScore).toBe(0);
|
||||
});
|
||||
|
||||
it('NaN 或异常参数输入时优雅兜底', () => {
|
||||
const res = scoreService.calculateScore({
|
||||
stayDurationSeconds: NaN,
|
||||
viewedBlockCount: NaN,
|
||||
});
|
||||
expect(res.score).toBe(0);
|
||||
expect(res.level).toBe('Cold');
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 5. 分级判定测试 (>=75 Hot, 40~74 Warm, <40 Cold)
|
||||
// ==========================================
|
||||
describe('5. 意向度分级判定规范 (Hot / Warm / Cold)', () => {
|
||||
it('分值 < 40 分级为 Cold', () => {
|
||||
expect(scoreService.resolveIntentLevel(0)).toBe('Cold');
|
||||
expect(scoreService.resolveIntentLevel(15)).toBe('Cold');
|
||||
expect(scoreService.resolveIntentLevel(35)).toBe('Cold');
|
||||
expect(scoreService.resolveIntentLevel(39)).toBe('Cold');
|
||||
});
|
||||
|
||||
it('分值 40 ~ 74 分级为 Warm', () => {
|
||||
expect(scoreService.resolveIntentLevel(40)).toBe('Warm');
|
||||
expect(scoreService.resolveIntentLevel(50)).toBe('Warm');
|
||||
expect(scoreService.resolveIntentLevel(65)).toBe('Warm');
|
||||
expect(scoreService.resolveIntentLevel(74)).toBe('Warm');
|
||||
});
|
||||
|
||||
it('分值 >= 75 分级为 Hot', () => {
|
||||
expect(scoreService.resolveIntentLevel(75)).toBe('Hot');
|
||||
expect(scoreService.resolveIntentLevel(80)).toBe('Hot');
|
||||
expect(scoreService.resolveIntentLevel(100)).toBe('Hot');
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 6. 诊断报告与破冰首语生成测试 (generateDiagnosticReport)
|
||||
// ==========================================
|
||||
describe('6. 意向诊断报告与破冰首语生成引擎 (generateDiagnosticReport)', () => {
|
||||
it('为 Hot 级别线索生成高意向跟进建议与破冰首语', () => {
|
||||
const report = scoreService.generateDiagnosticReport(85, mockTrack, mockLead);
|
||||
|
||||
expect(report.score).toBe(85);
|
||||
expect(report.level).toBe('Hot');
|
||||
expect(report.suggestion).toBeDefined();
|
||||
expect(report.suggestion.length).toBeGreaterThan(10);
|
||||
expect(report.icebreaker).toBeDefined();
|
||||
expect(report.icebreaker).toContain('Tariq');
|
||||
expect(report.icebreaker).toContain('Gulf Horizon Trading');
|
||||
expect(report.highlights.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('为 Warm 级别线索生成培育建议与破冰首语', () => {
|
||||
const warmLead: Partial<LeadEntity> = {
|
||||
name: 'Sarah Chen',
|
||||
company: 'Apex Solutions',
|
||||
};
|
||||
const report = scoreService.generateDiagnosticReport(55, mockTrack, warmLead);
|
||||
|
||||
expect(report.score).toBe(55);
|
||||
expect(report.level).toBe('Warm');
|
||||
expect(report.suggestion).toBeDefined();
|
||||
expect(report.icebreaker).toContain('Sarah');
|
||||
expect(report.icebreaker).toContain('Apex Solutions');
|
||||
});
|
||||
|
||||
it('为 Cold 级别线索生成自动化触达建议与通用破冰首语', () => {
|
||||
const report = scoreService.generateDiagnosticReport(20, null, null);
|
||||
|
||||
expect(report.score).toBe(20);
|
||||
expect(report.level).toBe('Cold');
|
||||
expect(report.suggestion).toBeDefined();
|
||||
expect(report.icebreaker).toBeDefined();
|
||||
});
|
||||
|
||||
it('支持直接传入 LeadScoreResult 对象并生成诊断报告', () => {
|
||||
const scoreResult = scoreService.calculateScore({
|
||||
stayDurationSeconds: 320,
|
||||
viewedBlockCount: 4,
|
||||
clickedWhatsApp: true,
|
||||
}); // 40 + 20 + 25 = 85 -> Hot
|
||||
const report = scoreService.generateDiagnosticReport(
|
||||
scoreResult,
|
||||
mockTrack,
|
||||
mockLead
|
||||
);
|
||||
|
||||
expect(report.score).toBe(85);
|
||||
expect(report.level).toBe('Hot');
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 7. CrmLeadService 集成智能评分引擎验证
|
||||
// ==========================================
|
||||
describe('7. CrmLeadService 评分引擎集成验证', () => {
|
||||
it('CrmLeadService 拥有 leadScoreService 属性或集成方法', () => {
|
||||
const leadService = new CrmLeadService();
|
||||
expect(leadService).toBeDefined();
|
||||
const scoreServiceInstance =
|
||||
leadService.leadScoreService || new CrmLeadScoreService();
|
||||
expect(scoreServiceInstance).toBeInstanceOf(CrmLeadScoreService);
|
||||
});
|
||||
|
||||
it('CrmLeadService.calculateLeadScore 能够正确代理评分', () => {
|
||||
const leadService = new CrmLeadService();
|
||||
const result = leadService.calculateLeadScore({
|
||||
stayDurationSeconds: 150,
|
||||
viewedBlockCount: 2,
|
||||
clickedWhatsApp: true,
|
||||
});
|
||||
expect(result.score).toBe(65);
|
||||
expect(result.level).toBe('Warm');
|
||||
expect(result.breakdown).toEqual({
|
||||
stayScore: 30,
|
||||
moduleScore: 10,
|
||||
interactionScore: 25,
|
||||
});
|
||||
});
|
||||
|
||||
it('CrmLeadService.generateDiagnosticReport 能够正确生成报告', () => {
|
||||
const leadService = new CrmLeadService();
|
||||
const report = leadService.generateDiagnosticReport(80, mockTrack, mockLead);
|
||||
expect(report.score).toBe(80);
|
||||
expect(report.level).toBe('Hot');
|
||||
expect(report.icebreaker).toContain('Tariq');
|
||||
});
|
||||
|
||||
it('CrmLeadService.submitLead 携带互动特征时自动调用新引擎算分并判定 hot 状态', async () => {
|
||||
const leadService = new CrmLeadService();
|
||||
const mockSavedEntity: Partial<CrmLeadEntity> = {};
|
||||
leadService.crmLeadEntity = {
|
||||
save: jest.fn().mockImplementation(async (entity: CrmLeadEntity) => {
|
||||
Object.assign(mockSavedEntity, entity);
|
||||
return entity;
|
||||
}),
|
||||
} as unknown as Repository<CrmLeadEntity>;
|
||||
|
||||
const saved = await leadService.submitLead({
|
||||
cardId: 202,
|
||||
name: 'Alexander Weber',
|
||||
email: 'alex@weber-machinery.de',
|
||||
stayDurationMs: 310000, // >300s -> 40分
|
||||
viewedBlocks: ['hero', 'case_1', 'video', 'contact'], // 4模块 -> 20分
|
||||
clickedWhatsApp: true, // +25分
|
||||
// 自动叠加留资 +35分 -> 40 + 20 + 25 + 35 = 120 -> 截断 100分
|
||||
});
|
||||
|
||||
expect(saved.intentScore).toBe(100);
|
||||
expect(saved.status).toBe('hot');
|
||||
expect(leadService.crmLeadEntity.save).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户