feat: 完成全系统30个功能点开发与E2E全链路验收 (8轮调度32个子代理闭环)

- 模块一: 出海案例作品集、博客动态发布、人名发音语音条、RTL阿语多语言切换
- 模块二: AI顾问人设配置、行业术语库润色、RAG切片沙盒调试、流式会话与意向留资卡片
- 模块三: 全球访客雷达大屏、多维加权意向算分、外部CRM对接推送、团队公海池流转
- 模块四: 移动端NFC安全挂失、NFC动态路由规则配置、动态防伪验签、展会制卡Zip批量导出
- 模块五: 组织架构与名片分配、移动端个人中心设置、离职交接与历史线索迁移、安全审计日志
- 模块六: Stripe国际信用卡订阅Webhook、独立二级域名与SSL检测、坐席配额预警、多币种发票
- 模块七: 纸质名片OCR解析入库、国际商务日程RFC 5545 ICS日历、微官网离线SWR预加载、展会实时排行榜大屏
- 模块八: 全球出海SEO OpenGraph元标签注入与社交爬虫探测、30点端到端全链路业务流集成测试套件
这个提交包含在:
wikiboss
2026-09-20 23:47:47 +08:00
父节点 acd10ffb59
当前提交 eec259a3a7
共有 276 个文件被更改,包括 54906 次插入 和 145 次删除

查看文件

@@ -0,0 +1,266 @@
import { CONTROLLER_KEY, getClassMetadata } from '@midwayjs/core';
import { CurdOption } from '@cool-midway/core';
import { AdminCrmLeadController } from '../../../src/modules/crm/controller/admin/lead';
import { AdminCrmRadarController } from '../../../src/modules/crm/controller/admin/radar';
import { CrmLeadEntity } from '../../../src/modules/crm/entity/lead';
import { CrmTrackEntity } from '../../../src/modules/crm/entity/track';
import { CrmLeadService } from '../../../src/modules/crm/service/lead';
import { CrmRadarService } from '../../../src/modules/crm/service/radar';
import { CoolCommException } from '@cool-midway/core';
/**
* CoolController 元数据(脚本路径解析为异步)
*/
interface CoolControllerMeta {
prefix?: string;
curdOption?: CurdOption;
}
/**
* 等待 CoolController 将 CRUD 配置写入类元数据
*/
async function waitCoolMeta(
target: new (...args: never[]) => unknown
): Promise<CoolControllerMeta> {
const started = Date.now();
while (Date.now() - started < 2000) {
const meta = getClassMetadata(CONTROLLER_KEY, target) as
| CoolControllerMeta
| undefined;
if (meta?.curdOption) {
return meta;
}
await new Promise<void>(resolve => setImmediate(resolve));
}
throw new Error(`${target.name} 的 CoolController 元数据未就绪`);
}
function serviceApiMethods(apis: CurdOption['serviceApis'] = []): string[] {
return apis.map(api => (typeof api === 'string' ? api : api.method));
}
describe('CRM 管理后台 CRUD 控制器 (admin/crm)', () => {
describe('AdminCrmLeadController /admin/crm/lead', () => {
let meta: CoolControllerMeta;
let option: CurdOption;
beforeAll(async () => {
meta = await waitCoolMeta(AdminCrmLeadController);
option = meta.curdOption as CurdOption;
});
it('路由前缀应为 /admin/crm/lead', () => {
expect(meta.prefix).toBe('/admin/crm/lead');
});
it('应开放完整 CRUD:add / delete / update / info / list / page', () => {
expect(option.api).toEqual([
'add',
'delete',
'update',
'info',
'list',
'page',
]);
});
it('应绑定线索实体与线索服务', () => {
expect(option.entity).toBe(CrmLeadEntity);
expect(option.service).toBe(CrmLeadService);
});
it('分页查询应支持姓名/公司/邮箱/电话模糊检索', () => {
const pageQueryOp = option.pageQueryOp as {
keyWordLikeFields?: string[];
fieldEq?: string[];
addOrderBy?: Record<string, string>;
};
expect(pageQueryOp.keyWordLikeFields).toEqual([
'a.name',
'a.company',
'a.email',
'a.phone',
]);
});
it('分页查询应支持状态/来源/名片精确筛选,并按创建时间倒序', () => {
const pageQueryOp = option.pageQueryOp as {
keyWordLikeFields?: string[];
fieldEq?: string[];
addOrderBy?: Record<string, string>;
};
expect(pageQueryOp.fieldEq).toEqual(['a.status', 'a.source', 'a.cardId']);
expect(pageQueryOp.addOrderBy).toEqual({ createTime: 'desc' });
});
it('应将 updateStatus 注册为 serviceApi(线索状态流转)', () => {
const methods = serviceApiMethods(option.serviceApis);
expect(methods).toContain('updateStatus');
const named = (option.serviceApis || []).find(
api => typeof api !== 'string' && api.method === 'updateStatus'
) as { method: string; summary: string } | undefined;
expect(named?.summary).toBe('线索状态流转');
});
});
describe('AdminCrmRadarController /admin/crm/radar', () => {
let meta: CoolControllerMeta;
let option: CurdOption;
beforeAll(async () => {
meta = await waitCoolMeta(AdminCrmRadarController);
option = meta.curdOption as CurdOption;
});
it('路由前缀应为 /admin/crm/radar', () => {
expect(meta.prefix).toBe('/admin/crm/radar');
});
it('足迹只读管理:开放 info / list / page / delete,不含 add', () => {
expect(option.api).toEqual(['info', 'list', 'page', 'delete']);
expect(option.api).not.toContain('add');
});
it('应绑定足迹实体与雷达服务', () => {
expect(option.entity).toBe(CrmTrackEntity);
expect(option.service).toBe(CrmRadarService);
});
it('分页查询应支持国家/城市/访客/IP 模糊检索', () => {
const pageQueryOp = option.pageQueryOp as {
keyWordLikeFields?: string[];
fieldEq?: string[];
addOrderBy?: Record<string, string>;
};
expect(pageQueryOp.keyWordLikeFields).toEqual([
'a.country',
'a.city',
'a.visitorId',
'a.ip',
]);
});
it('分页查询应支持名片/触达方式/国家精确筛选,并按 id 倒序', () => {
const pageQueryOp = option.pageQueryOp as {
keyWordLikeFields?: string[];
fieldEq?: string[];
addOrderBy?: Record<string, string>;
};
expect(pageQueryOp.fieldEq).toEqual([
'a.cardId',
'a.touchType',
'a.country',
]);
expect(pageQueryOp.addOrderBy).toEqual({ id: 'desc' });
});
it('应将 getRecentTracks 注册为 serviceApi(最近足迹)', () => {
const methods = serviceApiMethods(option.serviceApis);
expect(methods).toContain('getRecentTracks');
const named = (option.serviceApis || []).find(
api => typeof api !== 'string' && api.method === 'getRecentTracks'
) as { method: string; summary: string } | undefined;
expect(named?.summary).toBe('最近足迹');
});
});
describe('serviceApis POST body 单参数对象转调', () => {
it('updateStatus 接收 { id, status } 时应转调已有双参签名', async () => {
const saved: CrmLeadEntity[] = [];
const leadService = new CrmLeadService();
leadService.crmLeadEntity = {
save: jest.fn(async (item: CrmLeadEntity) => {
if (!item.id) {
item.id = 1;
}
const idx = saved.findIndex(row => row.id === item.id);
if (idx >= 0) {
saved[idx] = item;
} else {
saved.push(item);
}
return item;
}),
findOne: jest.fn(async (options: { where?: { id?: number } }) => {
const id = options?.where?.id;
return saved.find(row => row.id === Number(id)) || null;
}),
} as unknown as CrmLeadService['crmLeadEntity'];
const created = await leadService.submitLead({
cardId: 101,
name: 'Admin Flow',
email: 'admin@example.com',
});
expect(created.status).toBe('new');
const updated = await leadService.updateStatus({
id: Number(created.id),
status: 'contacted',
});
expect(updated.status).toBe('contacted');
});
it('getRecentTracks 接收 { cardId, limit } 时应转调已有双参签名', async () => {
const saved: CrmTrackEntity[] = [];
for (let i = 1; i <= 4; i++) {
const track = new CrmTrackEntity();
track.id = i;
track.cardId = 202;
track.visitorId = `vis_${i}`;
track.touchType = 'link';
saved.push(track);
}
const radarService = new CrmRadarService();
radarService.crmTrackEntity = {
find: jest.fn(
async (options: {
where?: { cardId?: number };
order?: { id?: string };
take?: number;
}) => {
let list = saved.filter(
row => row.cardId === Number(options?.where?.cardId)
);
if (options?.order?.id === 'DESC') {
list = [...list].sort((a, b) => Number(b.id) - Number(a.id));
}
if (options?.take) {
list = list.slice(0, options.take);
}
return list;
}
),
} as unknown as CrmRadarService['crmTrackEntity'];
const list = await radarService.getRecentTracks({
cardId: 202,
limit: 2,
});
expect(list).toHaveLength(2);
expect(list[0].id).toBe(4);
expect(list[1].id).toBe(3);
});
it('updateStatus 旧双参签名仍可用,非法状态仍拒绝', async () => {
const leadService = new CrmLeadService();
leadService.crmLeadEntity = {
save: jest.fn(async (item: CrmLeadEntity) => item),
findOne: jest.fn(async () => {
const lead = new CrmLeadEntity();
lead.id = 7;
lead.status = 'new';
return lead;
}),
} as unknown as CrmLeadService['crmLeadEntity'];
const updated = await leadService.updateStatus(7, 'won');
expect(updated.status).toBe('won');
await expect(
leadService.updateStatus(7, 'invalid' as 'new')
).rejects.toThrow(CoolCommException);
});
});
});

查看文件

@@ -0,0 +1,80 @@
import { lookupGeoIp } from '../../../src/comm/geoip/lookup';
import { CrmRadarService } from '../../../src/modules/crm/service/radar';
describe('T34: 内置 GeoIP 前缀表 (lookupGeoIp)', () => {
describe('lookupGeoIp', () => {
it('应将 8.8.8.8 解析为 US / Mountain View', () => {
expect(lookupGeoIp('8.8.8.8')).toEqual({
country: 'US',
city: 'Mountain View',
});
});
it('应将 114.114.114.114 解析为 CN / Nanjing', () => {
expect(lookupGeoIp('114.114.114.114')).toEqual({
country: 'CN',
city: 'Nanjing',
});
});
it('回环与私网 IP 应返回 null,交由雷达归 Local', () => {
expect(lookupGeoIp('127.0.0.1')).toBeNull();
expect(lookupGeoIp('::1')).toBeNull();
expect(lookupGeoIp('localhost')).toBeNull();
expect(lookupGeoIp('192.168.1.100')).toBeNull();
expect(lookupGeoIp('10.0.8.1')).toBeNull();
expect(lookupGeoIp('172.16.0.8')).toBeNull();
});
it('应覆盖其余内置公网短前缀', () => {
expect(lookupGeoIp('8.8.4.4')).toEqual({
country: 'US',
city: 'Mountain View',
});
expect(lookupGeoIp('1.1.1.1')).toEqual({
country: 'AU',
city: 'Sydney',
});
expect(lookupGeoIp('9.9.9.9')).toEqual({
country: 'US',
city: 'Berkeley',
});
});
});
describe('CrmRadarService.parseIpLocation', () => {
let radarService: CrmRadarService;
beforeEach(() => {
radarService = new CrmRadarService();
});
it('公网 8.8.8.8 应命中 GeoIP 为 US,不再 Unknown', () => {
const loc = radarService.parseIpLocation('8.8.8.8');
expect(loc.country).toBe('US');
expect(loc.city).toBe('Mountain View');
});
it('公网 114.114.114.114 应命中 GeoIP 为 CN', () => {
const loc = radarService.parseIpLocation('114.114.114.114');
expect(loc.country).toBe('CN');
expect(loc.city).toBe('Nanjing');
});
it('127.0.0.1 在 lookup 为 null 时 parseIpLocation 仍归 Local', () => {
expect(lookupGeoIp('127.0.0.1')).toBeNull();
expect(radarService.parseIpLocation('127.0.0.1')).toEqual({
country: 'Local',
city: 'Local',
});
});
it('cf-ipcountry=AE 应优先于公网 IP 的 GeoIP 结果', () => {
const loc = radarService.parseIpLocation('8.8.8.8', {
'cf-ipcountry': 'AE',
});
expect(loc.country).toBe('AE');
expect(loc.city).toBe('Unknown');
});
});
});

查看文件

@@ -0,0 +1,400 @@
import { getMetadataArgsStorage } from 'typeorm';
import { CONTROLLER_KEY, getClassMetadata } from '@midwayjs/core';
import { CoolCommException } from '@cool-midway/core';
import { BaseEntity } from '../../../src/comm/entity/base';
import { CrmLeadEntity } from '../../../src/modules/crm/entity/lead';
import { CrmLeadAuditEntity } from '../../../src/modules/crm/entity/audit';
import { CrmLeadPoolService } from '../../../src/modules/crm/service/lead-pool';
import { AdminCrmLeadController } from '../../../src/modules/crm/controller/admin/lead';
describe('Point 12: 团队线索公海池流转与自动分配机制 单元测试', () => {
const metadata = getMetadataArgsStorage();
// ==========================================
// 1. 实体与数据表映射规范测试
// ==========================================
describe('1. 实体定义与数据库字段映射测试', () => {
it('CrmLeadEntity 应包含 user_id、is_pool 与 last_follow_up_time 映射', () => {
const columns = metadata.columns.filter(c => c.target === CrmLeadEntity);
const userCol = columns.find(c => c.propertyName === 'userId');
const isPoolCol = columns.find(c => c.propertyName === 'isPool');
const followUpCol = columns.find(c => c.propertyName === 'lastFollowUpTime');
expect(userCol).toBeDefined();
expect(userCol?.options.name || userCol?.propertyName).toBe('user_id');
expect(isPoolCol).toBeDefined();
expect(isPoolCol?.options.name || isPoolCol?.propertyName).toBe('is_pool');
expect(followUpCol).toBeDefined();
expect(followUpCol?.options.name || followUpCol?.propertyName).toBe('last_follow_up_time');
});
it('CrmLeadAuditEntity 表名映射应为 crm_lead_audit 且继承 BaseEntity', () => {
const table = metadata.tables.find(t => t.target === CrmLeadAuditEntity);
expect(table).toBeDefined();
expect(table?.name).toBe('crm_lead_audit');
expect(CrmLeadAuditEntity.prototype instanceof BaseEntity).toBe(true);
});
it('CrmLeadAuditEntity 应映射 lead_id、action、operator_id、from_user_id、to_user_id、reason 列', () => {
const columns = metadata.columns.filter(c => c.target === CrmLeadAuditEntity);
const expected: Record<string, string> = {
leadId: 'lead_id',
action: 'action',
operatorId: 'operator_id',
fromUserId: 'from_user_id',
toUserId: 'to_user_id',
reason: 'reason',
};
for (const [prop, dbCol] of Object.entries(expected)) {
const col = columns.find(c => c.propertyName === prop);
expect(col).toBeDefined();
expect(col?.options.name || col?.propertyName).toBe(dbCol);
}
});
});
// ==========================================
// 2. 模拟内存数据仓储与并发 CAS
// ==========================================
describe('2. 公海池核心流转业务逻辑测试 (CrmLeadPoolService)', () => {
let service: CrmLeadPoolService;
let leads: CrmLeadEntity[];
let audits: CrmLeadAuditEntity[];
let nextAuditId = 1;
beforeEach(() => {
leads = [];
audits = [];
nextAuditId = 1;
// 预置线索数据
const lead1 = new CrmLeadEntity();
lead1.id = 1;
lead1.cardId = 101;
lead1.name = '公海线索-1';
lead1.status = 'new';
lead1.isPool = true;
lead1.userId = null;
leads.push(lead1);
const lead2 = new CrmLeadEntity();
lead2.id = 2;
lead2.cardId = 101;
lead2.name = '销售私海线索-2';
lead2.status = 'contacted';
lead2.isPool = false;
lead2.userId = 88;
lead2.lastFollowUpTime = new Date(Date.now() - 15 * 24 * 60 * 60 * 1000); // 15天前未跟进
leads.push(lead2);
const lead3 = new CrmLeadEntity();
lead3.id = 3;
lead3.cardId = 101;
lead3.name = '最近跟进私海线索-3';
lead3.status = 'hot';
lead3.isPool = false;
lead3.userId = 88;
lead3.lastFollowUpTime = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000); // 2天前跟进
leads.push(lead3);
const lead4 = new CrmLeadEntity();
lead4.id = 4;
lead4.cardId = 101;
lead4.name = '已赢单线索-4';
lead4.status = 'won';
lead4.isPool = false;
lead4.userId = 88;
lead4.lastFollowUpTime = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); // 30天前,但已赢单不回收
leads.push(lead4);
// Mock CrmLeadEntity Repository
const mockLeadRepo = {
findOne: jest.fn(async (options: { where?: { id?: number } }) => {
const id = options?.where?.id;
const found = leads.find(l => l.id === Number(id));
return found ? Object.assign(new CrmLeadEntity(), found) : null;
}),
find: jest.fn(async (options?: { where?: Partial<CrmLeadEntity> }) => {
if (!options?.where) return [...leads];
return leads.filter(item => {
const itemMap = item as unknown as Record<string, unknown>;
for (const [key, val] of Object.entries(options.where!)) {
if (itemMap[key] !== val) return false;
}
return true;
});
}),
save: jest.fn(async (entity: CrmLeadEntity) => {
const idx = leads.findIndex(l => l.id === entity.id);
if (idx >= 0) {
Object.assign(leads[idx], entity);
return leads[idx];
}
leads.push(entity);
return entity;
}),
// 模拟原子更新 CAS 操作
update: jest.fn(async (criteria: { id: number; isPool?: boolean }, values: Partial<CrmLeadEntity>) => {
const idx = leads.findIndex(l => {
if (l.id !== Number(criteria.id)) return false;
if (criteria.isPool !== undefined && l.isPool !== criteria.isPool) return false;
return true;
});
if (idx === -1) {
return { affected: 0 };
}
Object.assign(leads[idx], values);
return { affected: 1 };
}),
};
// Mock CrmLeadAuditEntity Repository
const mockAuditRepo = {
save: jest.fn(async (entity: CrmLeadAuditEntity) => {
const saved = Object.assign(new CrmLeadAuditEntity(), entity, {
id: nextAuditId++,
createTime: new Date(),
updateTime: new Date(),
});
audits.push(saved);
return saved;
}),
find: jest.fn(async (options?: { where?: { leadId?: number } }) => {
if (options?.where?.leadId) {
return audits.filter(a => a.leadId === options.where!.leadId);
}
return [...audits];
}),
};
service = new CrmLeadPoolService();
service.crmLeadEntity = mockLeadRepo as unknown as CrmLeadPoolService['crmLeadEntity'];
service.crmLeadAuditEntity = mockAuditRepo as unknown as CrmLeadPoolService['crmLeadAuditEntity'];
});
// ----------------------------------------------------
// 2.1 recycleToPool 回收线索至公海
// ----------------------------------------------------
describe('2.1 recycleToPool - 线索手动回收入公海池', () => {
it('线索不存在时应抛出 404 异常', async () => {
await expect(service.recycleToPool(9999, '测试不存在')).rejects.toThrow(
CoolCommException
);
});
it('成功回收私海线索,置 isPool=true, userId=null 并记录审计日志', async () => {
const recycled = await service.recycleToPool(2, '客户暂时无需跟进');
expect(recycled.isPool).toBe(true);
expect(recycled.userId).toBeNull();
const leadLogs = audits.filter(a => a.leadId === 2);
expect(leadLogs.length).toBe(1);
expect(leadLogs[0].action).toBe('recycle');
expect(leadLogs[0].fromUserId).toBe(88);
expect(leadLogs[0].toUserId).toBeNull();
expect(leadLogs[0].reason).toBe('客户暂时无需跟进');
});
});
// ----------------------------------------------------
// 2.2 scanAndAutoRecycle 超时未跟进自动批量回收
// ----------------------------------------------------
describe('2.2 scanAndAutoRecycle - 超时未跟进自动回收机制', () => {
it('应自动将超过 inactivityDays 未跟进且非公海、非 won/archived 线索批量回收', async () => {
// 设置 7 天未跟进为阈值
const result = await service.scanAndAutoRecycle(7);
// lead2 (15天未跟进) 应被回收
// lead3 (2天未跟进) 不应被回收
// lead4 (30天未跟进但为 won) 不应被回收
// lead1 本来就是公海,不重复回收
expect(result.recycledCount).toBe(1);
expect(result.leadIds).toContain(2);
const targetLead = leads.find(l => l.id === 2);
expect(targetLead?.isPool).toBe(true);
expect(targetLead?.userId).toBeNull();
// 验证审计日志
const autoLog = audits.find(a => a.leadId === 2 && a.action === 'auto_recycle');
expect(autoLog).toBeDefined();
expect(autoLog?.fromUserId).toBe(88);
expect(autoLog?.reason).toMatch(/超过 7 天未跟进/);
});
it('天数未达到阈值时不应回收任何线索', async () => {
const result = await service.scanAndAutoRecycle(30);
expect(result.recycledCount).toBe(0);
expect(result.leadIds.length).toBe(0);
});
});
// ----------------------------------------------------
// 2.3 claimLead 公海抢单与原子性防并发重抢
// ----------------------------------------------------
describe('2.3 claimLead - 销售抢单领用与并发保护', () => {
it('线索不存在时应抛出 404 异常', async () => {
await expect(service.claimLead(9999, 100)).rejects.toThrow(
CoolCommException
);
});
it('线索若已被领用(不在公海池),再次抢单应抛出 409 异常被拦截', async () => {
// lead2 是私海线索 (isPool: false, userId: 88)
await expect(service.claimLead(2, 100)).rejects.toThrow(
CoolCommException
);
});
it('正常抢单公海线索应成功,置 isPool=false, userId=领用人,并记录审计日志', async () => {
// lead1 是公海线索
const claimed = await service.claimLead(1, 100);
expect(claimed.isPool).toBe(false);
expect(claimed.userId).toBe(100);
const claimLog = audits.find(a => a.leadId === 1 && a.action === 'claim');
expect(claimLog).toBeDefined();
expect(claimLog?.toUserId).toBe(100);
expect(claimLog?.operatorId).toBe(100);
});
it('高并发场景防重抢:多个销售并发争抢同一公海线索,严格保证只有 1 人成功', async () => {
// 确保 lead1 处于公海
const lead = leads.find(l => l.id === 1);
lead!.isPool = true;
lead!.userId = null;
const salesUsers = [201, 202, 203, 204, 205];
const results = await Promise.allSettled(
salesUsers.map(uid => service.claimLead(1, uid))
);
const fulfilled = results.filter(r => r.status === 'fulfilled');
const rejected = results.filter(r => r.status === 'rejected');
// 仅有 1 个请求能成功抢单
expect(fulfilled.length).toBe(1);
// 其余 4 个并发争抢者全部被拦截拒绝
expect(rejected.length).toBe(4);
// 最终数据库状态必须归属于成功的销售,且不在公海
const finalLead = leads.find(l => l.id === 1);
expect(finalLead?.isPool).toBe(false);
expect(salesUsers).toContain(finalLead?.userId);
});
});
// ----------------------------------------------------
// 2.4 assignLead 管理员指定派发
// ----------------------------------------------------
describe('2.4 assignLead - 管理员指定分配线索', () => {
it('线索不存在时应抛出 404 异常', async () => {
await expect(service.assignLead(9999, 100, 1)).rejects.toThrow(
CoolCommException
);
});
it('缺少目标指派用户 targetUserId 时抛出 400 异常', async () => {
await expect(service.assignLead(1, 0, 1)).rejects.toThrow(
CoolCommException
);
});
it('管理员成功指派线索给目标销售,记录分配前后人及管理员审计日志', async () => {
const assigned = await service.assignLead(1, 105, 1);
expect(assigned.isPool).toBe(false);
expect(assigned.userId).toBe(105);
const assignLog = audits.find(a => a.leadId === 1 && a.action === 'assign');
expect(assignLog).toBeDefined();
expect(assignLog?.fromUserId).toBeNull();
expect(assignLog?.toUserId).toBe(105);
expect(assignLog?.operatorId).toBe(1);
});
});
// ----------------------------------------------------
// 2.5 审计轨迹完整性 (Audit Trail)
// ----------------------------------------------------
describe('2.5 getAuditLogs - 流转审计链路追踪', () => {
it('应能完整检索线索全生命周期的流转审计轨迹', async () => {
// 先分配给销售 101
await service.assignLead(1, 101, 1);
// 销售 101 退回公海
await service.recycleToPool(1, '重新流转');
// 销售 102 抢单
await service.claimLead(1, 102);
const logs = await service.getAuditLogs(1);
expect(logs.length).toBe(3);
expect(logs.map(l => l.action)).toEqual(['assign', 'recycle', 'claim']);
});
});
});
// ==========================================
// 3. 管理后台控制器接口规范与路由调用测试
// ==========================================
describe('3. 管理后台接口暴露 (AdminCrmLeadController)', () => {
let controller: AdminCrmLeadController;
let poolService: CrmLeadPoolService;
beforeEach(() => {
poolService = new CrmLeadPoolService();
poolService.claimLead = jest.fn(async (leadId, userId) => {
const lead = new CrmLeadEntity();
lead.id = Number(leadId);
lead.isPool = false;
lead.userId = Number(userId);
return lead;
});
poolService.assignLead = jest.fn(async (leadId, targetUserId, assignerId) => {
const lead = new CrmLeadEntity();
lead.id = Number(leadId);
lead.isPool = false;
lead.userId = Number(targetUserId);
return lead;
});
controller = new AdminCrmLeadController();
controller.crmLeadPoolService = poolService;
// 模拟 Cool-Admin BaseController 上下文与 ok 方法
controller.ok = (data: unknown) => ({
code: 1000,
message: 'success',
data,
});
(controller as unknown as { ctx: { admin: { userId: number } } }).ctx = {
admin: { userId: 9 },
};
});
it('控制器应包含 poolClaim 与 poolAssign 接口方法', () => {
expect(typeof controller.poolClaim).toBe('function');
expect(typeof controller.poolAssign).toBe('function');
});
it('POST /pool-claim 认领公海线索成功返回标准 1000 响应', async () => {
const res = await controller.poolClaim({ leadId: 1, userId: 101 });
expect(res.code).toBe(1000);
expect(poolService.claimLead).toHaveBeenCalledWith(1, 101);
const resData = (res as unknown as { data: CrmLeadEntity }).data;
expect(resData.userId).toBe(101);
});
it('POST /pool-claim 未提供 userId 时优先采用上下文登录用户 ID', async () => {
const res = await controller.poolClaim({ leadId: 1 });
expect(res.code).toBe(1000);
expect(poolService.claimLead).toHaveBeenCalledWith(1, 9);
});
it('POST /pool-assign 管理员派发成功返回标准 1000 响应', async () => {
const res = await controller.poolAssign({ leadId: 1, targetUserId: 202 });
expect(res.code).toBe(1000);
expect(poolService.assignLead).toHaveBeenCalledWith(1, 202, 9);
const resData = (res as unknown as { data: CrmLeadEntity }).data;
expect(resData.userId).toBe(202);
});
});
});

查看文件

@@ -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();
});
});
});

查看文件

@@ -0,0 +1,108 @@
import {
CrmRadarPushService,
RadarPushPayload,
} from '../../../src/modules/crm/service/push';
import { RadarTrack } from '../../../src/comm/types/omni';
function makeTrack(overrides: Partial<RadarTrack> = {}): RadarTrack {
return {
id: 1,
tenantId: 10,
cardId: 101,
visitorId: 'vis_abc_123',
ip: '8.8.8.8',
country: 'US',
city: 'Mountain View',
touchType: 'nfc',
stayDurationMs: 12000,
scrollDepth: 40,
viewedBlocks: ['hero'],
createdAt: '2026-09-20T10:00:00.000Z',
...overrides,
};
}
describe('Task 35: 雷达 Push 通知内存渠道 (CrmRadarPushService)', () => {
const payloadKeys: Array<keyof RadarPushPayload> = [
'event',
'cardId',
'visitorId',
'country',
'city',
'touchType',
'stayDurationMs',
];
let service: CrmRadarPushService;
beforeEach(() => {
service = new CrmRadarPushService();
});
it('buildPayload 字段齐全且 event 为 radar.visitor', () => {
const track = makeTrack();
const payload = service.buildPayload(track);
for (const key of payloadKeys) {
expect(payload).toHaveProperty(key);
}
expect(Object.keys(payload).sort()).toEqual([...payloadKeys].sort());
expect(payload.event).toBe('radar.visitor');
expect(payload.cardId).toBe(track.cardId);
expect(payload.visitorId).toBe(track.visitorId);
expect(payload.country).toBe(track.country);
expect(payload.city).toBe(track.city);
expect(payload.touchType).toBe(track.touchType);
expect(payload.stayDurationMs).toBe(track.stayDurationMs);
});
it('subscribe 后 publish 能收到对应名片的载荷', () => {
const received: RadarPushPayload[] = [];
const track = makeTrack({ cardId: 202, visitorId: 'vis_sub' });
service.subscribe(202, payload => {
received.push(payload);
});
service.publish(track);
expect(received).toHaveLength(1);
expect(received[0].event).toBe('radar.visitor');
expect(received[0].cardId).toBe(202);
expect(received[0].visitorId).toBe('vis_sub');
});
it('不同 cardId 的订阅互不串扰', () => {
const cardA: RadarPushPayload[] = [];
const cardB: RadarPushPayload[] = [];
service.subscribe(301, payload => {
cardA.push(payload);
});
service.subscribe('302', payload => {
cardB.push(payload);
});
service.publish(makeTrack({ cardId: 301, visitorId: 'vis_a' }));
service.publish(makeTrack({ cardId: 302, visitorId: 'vis_b' }));
expect(cardA).toHaveLength(1);
expect(cardA[0].visitorId).toBe('vis_a');
expect(cardB).toHaveLength(1);
expect(cardB[0].visitorId).toBe('vis_b');
});
it('unsubscribe 后不再收到后续 publish', () => {
const received: RadarPushPayload[] = [];
const listener = (payload: RadarPushPayload) => {
received.push(payload);
};
service.subscribe(401, listener);
service.publish(makeTrack({ cardId: 401, visitorId: 'vis_before' }));
service.unsubscribe(401, listener);
service.publish(makeTrack({ cardId: 401, visitorId: 'vis_after' }));
expect(received).toHaveLength(1);
expect(received[0].visitorId).toBe('vis_before');
});
});

查看文件

@@ -0,0 +1,262 @@
import { CrmTrackEntity } from '../../../src/modules/crm/entity/track';
import { CrmRadarService } from '../../../src/modules/crm/service/radar';
import { AdminCrmRadarController } from '../../../src/modules/crm/controller/admin/radar';
describe('Point 09: 全球访客雷达热力分布与大屏服务 (radar-map.test.ts)', () => {
let radarService: CrmRadarService;
let trackList: CrmTrackEntity[];
beforeEach(() => {
trackList = [];
// Mock TypeORM Repository
const mockRepo: any = {
find: jest.fn().mockImplementation(async (options?: any) => {
let list = [...trackList];
if (options?.where) {
const where = options.where;
if (where.cardId !== undefined && where.cardId !== null) {
list = list.filter((item) => Number(item.cardId) === Number(where.cardId));
}
if (where.createTime && typeof where.createTime === 'object') {
// 支持 TypeORM MoreThanOrEqual / Between 等操作符过滤
if (where.createTime._type === 'moreThanOrEqual' || where.createTime._value) {
const minTime = where.createTime._value || where.createTime;
list = list.filter((item) => new Date(item.createTime) >= new Date(minTime));
}
}
}
return list;
}),
createQueryBuilder: jest.fn().mockReturnValue(null),
};
radarService = new CrmRadarService();
radarService.crmTrackEntity = mockRepo;
});
describe('1. 国家分布统计 (getCountryDistribution)', () => {
it('空数据时应安全兜底返回空数组 []', async () => {
const result = await radarService.getCountryDistribution();
expect(result).toEqual([]);
});
it('应准确按国家聚合访客数、计算占比与平均停留时长,并按人次降序排列', async () => {
trackList = [
{
id: 1,
cardId: 10,
country: 'United States',
stayDurationMs: 30000,
createTime: new Date(),
} as CrmTrackEntity,
{
id: 2,
cardId: 10,
country: 'United States',
stayDurationMs: 10000,
createTime: new Date(),
} as CrmTrackEntity,
{
id: 3,
cardId: 10,
country: 'Singapore',
stayDurationMs: 20000,
createTime: new Date(),
} as CrmTrackEntity,
{
id: 4,
cardId: 10,
country: 'Germany',
stayDurationMs: 40000,
createTime: new Date(),
} as CrmTrackEntity,
];
const result = await radarService.getCountryDistribution();
expect(result.length).toBe(3);
// US 有 2 次访问 (50%), Singapore 1 次 (25%), Germany 1 次 (25%)
expect(result[0].country).toBe('United States');
expect(result[0].visitorCount).toBe(2);
expect(result[0].percentage).toBe(50);
expect(result[0].avgStayDurationMs).toBe(20000);
expect(result[1].visitorCount).toBe(1);
expect(result[1].percentage).toBe(25);
});
it('当国家字段为空或缺失时,应统一兜底为 Unknown', async () => {
trackList = [
{
id: 1,
cardId: 10,
country: '',
stayDurationMs: 5000,
createTime: new Date(),
} as CrmTrackEntity,
{
id: 2,
cardId: 10,
country: undefined,
stayDurationMs: 15000,
createTime: new Date(),
} as CrmTrackEntity,
];
const result = await radarService.getCountryDistribution();
expect(result.length).toBe(1);
expect(result[0].country).toBe('Unknown');
expect(result[0].visitorCount).toBe(2);
expect(result[0].percentage).toBe(100);
expect(result[0].avgStayDurationMs).toBe(10000);
});
it('应支持根据指定 cardId 过滤数据', async () => {
trackList = [
{
id: 1,
cardId: 10,
country: 'Japan',
stayDurationMs: 10000,
createTime: new Date(),
} as CrmTrackEntity,
{
id: 2,
cardId: 20,
country: 'Japan',
stayDurationMs: 20000,
createTime: new Date(),
} as CrmTrackEntity,
];
const result = await radarService.getCountryDistribution(10);
expect(result.length).toBe(1);
expect(result[0].visitorCount).toBe(1);
expect(result[0].country).toBe('Japan');
});
});
describe('2. 停留时长时段分布 (getHourlyStayStats)', () => {
it('返回结果必须严格包含 0 至 23 共 24 个时段,空时段以 0 填充兜底', async () => {
const stats = await radarService.getHourlyStayStats(7);
expect(stats.length).toBe(24);
for (let h = 0; h < 24; h++) {
expect(stats[h].hour).toBe(h);
expect(stats[h].visitorCount).toBe(0);
expect(stats[h].avgStayDurationMs).toBe(0);
}
});
it('应能正确将足迹按创建时间的小时进行归类与停留时长均值统计', async () => {
const now = new Date();
const dateHour14 = new Date(now);
dateHour14.setHours(14, 30, 0, 0);
const dateHour14_2 = new Date(now);
dateHour14_2.setHours(14, 45, 0, 0);
trackList = [
{
id: 1,
cardId: 10,
stayDurationMs: 10000,
createTime: dateHour14,
} as CrmTrackEntity,
{
id: 2,
cardId: 10,
stayDurationMs: 30000,
createTime: dateHour14_2,
} as CrmTrackEntity,
];
const stats = await radarService.getHourlyStayStats(7);
expect(stats.length).toBe(24);
expect(stats[14].visitorCount).toBe(2);
expect(stats[14].avgStayDurationMs).toBe(20000);
expect(stats[0].visitorCount).toBe(0);
});
});
describe('3. 终端渠道分析 (getDeviceStats)', () => {
it('空数据时应安全返回包含基础渠道为 0 的统计', async () => {
const result = await radarService.getDeviceStats();
expect(Array.isArray(result)).toBe(true);
expect(result.length).toBeGreaterThanOrEqual(3);
expect(result.every((item) => item.count === 0 && item.percentage === 0)).toBe(true);
});
it('应准确按 touchType (nfc, qr, link) 计算数量与占比', async () => {
trackList = [
{ id: 1, touchType: 'nfc', createTime: new Date() } as CrmTrackEntity,
{ id: 2, touchType: 'nfc', createTime: new Date() } as CrmTrackEntity,
{ id: 3, touchType: 'qr', createTime: new Date() } as CrmTrackEntity,
{ id: 4, touchType: 'link', createTime: new Date() } as CrmTrackEntity,
];
const result = await radarService.getDeviceStats();
const nfcItem = result.find((item) => item.type === 'nfc');
const qrItem = result.find((item) => item.type === 'qr');
const linkItem = result.find((item) => item.type === 'link');
expect(nfcItem?.count).toBe(2);
expect(nfcItem?.percentage).toBe(50);
expect(qrItem?.count).toBe(1);
expect(qrItem?.percentage).toBe(25);
expect(linkItem?.count).toBe(1);
expect(linkItem?.percentage).toBe(25);
});
});
describe('4. 控制器接口契约 (AdminCrmRadarController)', () => {
let controller: AdminCrmRadarController;
let mockService: any;
beforeEach(() => {
mockService = {
getCountryDistribution: jest.fn().mockResolvedValue([
{ country: 'Singapore', visitorCount: 10, percentage: 100, avgStayDurationMs: 15000 },
]),
getHourlyStayStats: jest.fn().mockResolvedValue([]),
getDeviceStats: jest.fn().mockResolvedValue([]),
getAnalytics: jest.fn().mockResolvedValue({
countryDistribution: [
{ country: 'Singapore', visitorCount: 10, percentage: 100, avgStayDurationMs: 15000 },
],
hourlyStats: [],
deviceStats: [],
summary: {
totalVisitors: 10,
avgStayDurationMs: 15000,
topCountry: 'Singapore',
countriesCount: 1,
},
}),
};
controller = new AdminCrmRadarController();
(controller as any).crmRadarService = mockService;
(controller as any).ok = (data: any) => ({ code: 1000, message: 'success', data });
(controller as any).fail = (msg: string, code = 1001) => ({ code, message: msg });
});
it('GET /admin/crm/radar/distribution 应返回标准 1000 成功响应与国家分布列表', async () => {
const res: any = await controller.distribution({ cardId: 10 });
expect(res.code).toBe(1000);
expect(res.data[0].country).toBe('Singapore');
expect(mockService.getCountryDistribution).toHaveBeenCalledWith(10);
});
it('GET/POST /admin/crm/radar/analytics 应聚合返回大屏综合数据与 KPI 汇总', async () => {
const res: any = await controller.analytics({ cardId: 10, days: 7 });
expect(res.code).toBe(1000);
expect(res.data.countryDistribution).toBeDefined();
expect(res.data.hourlyStats).toBeDefined();
expect(res.data.deviceStats).toBeDefined();
expect(res.data.summary).toBeDefined();
expect(res.data.summary.totalVisitors).toBe(10);
expect(res.data.summary.topCountry).toBe('Singapore');
});
});
});

查看文件

@@ -0,0 +1,360 @@
import * as crypto from 'crypto';
import axios from 'axios';
import { CrmSyncService } from '../../../src/modules/crm/service/sync';
import { AdminCrmLeadController } from '../../../src/modules/crm/controller/admin/lead';
import { CrmLeadEntity } from '../../../src/modules/crm/entity/lead';
import { CoolCommException } from '@cool-midway/core';
describe('Point 11: 国际主流 CRM (HubSpot / Salesforce / Webhook) 对接服务', () => {
let syncService: CrmSyncService;
let mockLead: CrmLeadEntity;
beforeEach(() => {
syncService = new CrmSyncService();
mockLead = new CrmLeadEntity();
mockLead.id = 101;
mockLead.name = 'John Doe';
mockLead.company = 'Acme Global Corp';
mockLead.email = 'john.doe@acme.com';
mockLead.phone = '+1-555-0199';
mockLead.status = 'new';
mockLead.intentScore = 88;
mockLead.source = 'nfc';
jest.clearAllMocks();
});
afterEach(() => {
jest.restoreAllMocks();
});
// ==========================================
// 1. HubSpot Contacts v3 转换测试
// ==========================================
describe('1. HubSpot Contacts v3 格式转换 (formatToHubSpot)', () => {
it('应正确转换完整线索字段,并固定 lifecyclestage 为 lead', () => {
const hubspotData = syncService.formatToHubSpot(mockLead);
expect(hubspotData).toEqual({
properties: {
email: 'john.doe@acme.com',
firstname: 'John',
lastname: 'Doe',
phone: '+1-555-0199',
company: 'Acme Global Corp',
lifecyclestage: 'lead',
},
});
});
it('单名应正确填入 firstname,lastname 为空字符串', () => {
mockLead.name = 'Madonna';
const hubspotData = syncService.formatToHubSpot(mockLead);
expect(hubspotData.properties.firstname).toBe('Madonna');
expect(hubspotData.properties.lastname).toBe('');
});
it('包含中间名/多个词的姓名应正确拆分首词为 firstname,其余为 lastname', () => {
mockLead.name = 'John Fitzgerald Kennedy';
const hubspotData = syncService.formatToHubSpot(mockLead);
expect(hubspotData.properties.firstname).toBe('John');
expect(hubspotData.properties.lastname).toBe('Fitzgerald Kennedy');
});
it('缺少可选字段时应优雅兜底为空字符串,不崩溃', () => {
const partialLead: Partial<CrmLeadEntity> = {
name: '',
};
const hubspotData = syncService.formatToHubSpot(partialLead);
expect(hubspotData.properties.email).toBe('');
expect(hubspotData.properties.firstname).toBe('');
expect(hubspotData.properties.lastname).toBe('');
expect(hubspotData.properties.phone).toBe('');
expect(hubspotData.properties.company).toBe('');
expect(hubspotData.properties.lifecyclestage).toBe('lead');
});
});
// ==========================================
// 2. Salesforce Lead sObject 格式转换测试
// ==========================================
describe('2. Salesforce Lead sObject 格式转换 (formatToSalesforce)', () => {
it('应正确转换标准字段,且状态固定为 Open - Not Contacted', () => {
const sfData = syncService.formatToSalesforce(mockLead);
expect(sfData).toEqual({
FirstName: 'John',
LastName: 'Doe',
Email: 'john.doe@acme.com',
Company: 'Acme Global Corp',
Phone: '+1-555-0199',
Status: 'Open - Not Contacted',
});
});
it('单名时应确保必填字段 LastName 有值,FirstName 为空', () => {
mockLead.name = 'Zendaya';
const sfData = syncService.formatToSalesforce(mockLead);
expect(sfData.FirstName).toBe('');
expect(sfData.LastName).toBe('Zendaya');
});
it('姓名为空时应兜底 LastName 为 Unknown,FirstName 为空', () => {
const emptyLead: Partial<CrmLeadEntity> = {
name: '',
company: '',
};
const sfData = syncService.formatToSalesforce(emptyLead);
expect(sfData.FirstName).toBe('');
expect(sfData.LastName).toBe('Unknown');
expect(sfData.Company).toBe('Unknown');
expect(sfData.Status).toBe('Open - Not Contacted');
});
});
// ==========================================
// 3. HMAC-SHA256 签名计算与验证
// ==========================================
describe('3. HMAC-SHA256 签名计算 (generateSignature)', () => {
it('应基于 secret 对 payload 计算出正确的 64 位十六进制 HMAC-SHA256 签名', () => {
const secret = 'omnicard_super_secret_key_2026';
const payload = {
event: 'crm.lead.sync',
data: { id: 101, name: 'John Doe' },
};
const signature = syncService.generateSignature(payload, secret);
const expected = crypto
.createHmac('sha256', secret)
.update(JSON.stringify(payload))
.digest('hex');
expect(signature).toHaveLength(64);
expect(signature).toBe(expected);
});
it('相同 payload 与 secret 计算出的签名必须严格幂等一致', () => {
const secret = 'test_secret';
const payload = { test: 123 };
const sig1 = syncService.generateSignature(payload, secret);
const sig2 = syncService.generateSignature(payload, secret);
expect(sig1).toBe(sig2);
});
});
// ==========================================
// 4. Webhook 发送与模拟退避重试 (sendWebhook)
// ==========================================
describe('4. Webhook 发送与模拟退避重试 (sendWebhook)', () => {
it('首次请求 200 成功时,应附加 X-OmniCard-Signature 请求头并返回 attempts: 1', async () => {
const postSpy = jest.spyOn(axios, 'post').mockResolvedValueOnce({
status: 200,
data: { received: true },
});
const url = 'https://api.partner.com/webhook';
const secret = 'wh_secret_888';
const result = await syncService.sendWebhook(url, secret, mockLead, {
delayMs: 1,
});
expect(result.success).toBe(true);
expect(result.attempts).toBe(1);
expect(postSpy).toHaveBeenCalledTimes(1);
const [calledUrl, calledPayload, config] = postSpy.mock.calls[0];
expect(calledUrl).toBe(url);
expect(calledPayload).toHaveProperty('event', 'crm.lead.sync');
expect(calledPayload).toHaveProperty('data');
expect(config?.headers?.['Content-Type']).toBe('application/json');
expect(config?.headers?.['X-OmniCard-Signature']).toBeDefined();
const verifiedSig = syncService.generateSignature(calledPayload, secret);
expect(config?.headers?.['X-OmniCard-Signature']).toBe(verifiedSig);
});
it('若前两次网络失败第三次成功,应自动重试并在第 3 次成功返回', async () => {
const postSpy = jest
.spyOn(axios, 'post')
.mockRejectedValueOnce(new Error('Network Timeout'))
.mockRejectedValueOnce(new Error('502 Bad Gateway'))
.mockResolvedValueOnce({ status: 200, data: { ok: true } });
const result = await syncService.sendWebhook(
'https://api.partner.com/webhook',
'wh_secret',
mockLead,
{ delayMs: 1 }
);
expect(result.success).toBe(true);
expect(result.attempts).toBe(3);
expect(postSpy).toHaveBeenCalledTimes(3);
});
it('连续失败超过 3 次时,应停止重试,捕获错误并返回 success: false, attempts: 3', async () => {
const postSpy = jest
.spyOn(axios, 'post')
.mockRejectedValue(new Error('Connection Refused'));
const result = await syncService.sendWebhook(
'https://api.partner.com/webhook',
'wh_secret',
mockLead,
{ delayMs: 1 }
);
expect(result.success).toBe(false);
expect(result.attempts).toBe(3);
expect(result.error).toBe('Connection Refused');
expect(postSpy).toHaveBeenCalledTimes(3);
});
it('缺少目标 Webhook URL 时应抛出 400 校验异常', async () => {
await expect(
syncService.sendWebhook('', 'secret', mockLead)
).rejects.toThrow(CoolCommException);
});
});
// ==========================================
// 5. 控制器接口集成 (/admin/crm/lead/sync-external)
// ==========================================
describe('5. 控制器接口 /admin/crm/lead/sync-external 集成测试', () => {
let controller: AdminCrmLeadController;
let mockLeadRepo: { findOne: jest.Mock };
beforeEach(() => {
controller = new AdminCrmLeadController();
mockLeadRepo = {
findOne: jest.fn(),
};
// 依赖注入模拟
(controller as unknown as { crmLeadEntity: unknown }).crmLeadEntity =
mockLeadRepo;
(controller as unknown as { crmSyncService: unknown }).crmSyncService =
syncService;
});
it('缺少线索 id 时应抛出 400 异常', async () => {
await expect(
controller.syncExternal({
id: 0,
target: 'hubspot',
})
).rejects.toThrow(CoolCommException);
});
it('不支持的 target 类型时应抛出 400 异常', async () => {
await expect(
controller.syncExternal({
id: 101,
target: 'unknown_crm' as unknown as 'hubspot',
})
).rejects.toThrow(CoolCommException);
});
it('线索记录不存在时应抛出 404 异常', async () => {
mockLeadRepo.findOne.mockResolvedValueOnce(null);
await expect(
controller.syncExternal({
id: 999,
target: 'hubspot',
})
).rejects.toThrow(CoolCommException);
});
it('正确同步至 hubspot 并返回格式化数据', async () => {
mockLeadRepo.findOne.mockResolvedValueOnce(mockLead);
const res = await controller.syncExternal({
id: 101,
target: 'hubspot',
});
expect(res).toEqual({
code: 1000,
message: 'success',
data: {
target: 'hubspot',
leadId: 101,
payload: {
properties: {
email: 'john.doe@acme.com',
firstname: 'John',
lastname: 'Doe',
phone: '+1-555-0199',
company: 'Acme Global Corp',
lifecyclestage: 'lead',
},
},
},
});
});
it('正确同步至 salesforce 并返回格式化数据', async () => {
mockLeadRepo.findOne.mockResolvedValueOnce(mockLead);
const res = await controller.syncExternal({
id: 101,
target: 'salesforce',
});
expect(res).toEqual({
code: 1000,
message: 'success',
data: {
target: 'salesforce',
leadId: 101,
payload: {
FirstName: 'John',
LastName: 'Doe',
Email: 'john.doe@acme.com',
Company: 'Acme Global Corp',
Phone: '+1-555-0199',
Status: 'Open - Not Contacted',
},
},
});
});
it('正确通过 webhook 发送签名数据并返回结果', async () => {
mockLeadRepo.findOne.mockResolvedValueOnce(mockLead);
jest.spyOn(axios, 'post').mockResolvedValueOnce({
status: 200,
data: { ok: true },
});
const res = await controller.syncExternal({
id: 101,
target: 'webhook',
webhookUrl: 'https://webhook.site/test',
webhookSecret: 'secret_123',
});
expect(res.code).toBe(1000);
expect(res.data.target).toBe('webhook');
expect(res.data.leadId).toBe(101);
expect(res.data.result.success).toBe(true);
expect(res.data.result.attempts).toBe(1);
});
it('webhook 缺少 webhookUrl 时应抛出 400 异常', async () => {
mockLeadRepo.findOne.mockResolvedValueOnce(mockLead);
await expect(
controller.syncExternal({
id: 101,
target: 'webhook',
})
).rejects.toThrow(CoolCommException);
});
});
});