补齐平台订阅、外部登录、CRM 同步与用户端页面;Nginx 转发用户端接口,登录页使用 Google 与 Apple 标志。 Co-authored-by: Cursor <cursoragent@cursor.com>
584 行
20 KiB
TypeScript
584 行
20 KiB
TypeScript
import { getMetadataArgsStorage } from 'typeorm';
|
|
import { BaseEntity } from '../../../src/comm/entity/base';
|
|
import { CrmTrackEntity } from '../../../src/modules/crm/entity/track';
|
|
import { CrmRadarService } from '../../../src/modules/crm/service/radar';
|
|
import { OpenCrmRadarController } from '../../../src/modules/crm/controller/open/radar';
|
|
import { RadarTrack, TouchType } from '../../../src/comm/types/omni';
|
|
import { CoolCommException } from '@cool-midway/core';
|
|
|
|
describe('Task 11: 后端访客雷达模块 (modules/crm) 单元测试', () => {
|
|
const metadata = getMetadataArgsStorage();
|
|
|
|
// ==========================================
|
|
// 1. 实体与契约测试 (CrmTrackEntity & BaseEntity)
|
|
// ==========================================
|
|
describe('1. 访客足迹实体定义与数据库映射规范 (CrmTrackEntity)', () => {
|
|
it('实体表名必须映射为 crm_track', () => {
|
|
const table = metadata.tables.find(t => t.target === CrmTrackEntity);
|
|
expect(table).toBeDefined();
|
|
expect(table?.name).toBe('crm_track');
|
|
});
|
|
|
|
it('实体必须继承自 BaseEntity,具备底层租户隔离与审计时间戳能力', () => {
|
|
expect(CrmTrackEntity.prototype instanceof BaseEntity).toBe(true);
|
|
});
|
|
|
|
it('所有实体属性与数据库列必须遵循驼峰至下划线映射规范', () => {
|
|
const columns = metadata.columns.filter(c => c.target === CrmTrackEntity);
|
|
const expectedColumnMappings: Record<string, string> = {
|
|
cardId: 'card_id',
|
|
visitorId: 'visitor_id',
|
|
ip: 'ip',
|
|
country: 'country',
|
|
city: 'city',
|
|
touchType: 'touch_type',
|
|
stayDurationMs: 'stay_duration_ms',
|
|
scrollDepth: 'scroll_depth',
|
|
viewedBlocks: 'viewed_blocks',
|
|
};
|
|
|
|
for (const [prop, dbCol] of Object.entries(expectedColumnMappings)) {
|
|
const col = columns.find(c => c.propertyName === prop);
|
|
expect(col).toBeDefined();
|
|
expect(col?.options.name || col?.propertyName).toBe(dbCol);
|
|
}
|
|
});
|
|
|
|
it('关键查询字段 cardId, visitorId, country, touchType 应声明索引', () => {
|
|
const indexes = metadata.indices.filter(i => i.target === CrmTrackEntity);
|
|
const indexProps = indexes.flatMap(i =>
|
|
Array.isArray(i.columns) ? i.columns : []
|
|
);
|
|
|
|
expect(indexProps).toContain('cardId');
|
|
expect(indexProps).toContain('visitorId');
|
|
expect(indexProps).toContain('country');
|
|
expect(indexProps).toContain('touchType');
|
|
});
|
|
|
|
it('默认值应符合业务规范: touchType=\'link\', stayDurationMs=0, scrollDepth=0', () => {
|
|
const columns = metadata.columns.filter(c => c.target === CrmTrackEntity);
|
|
|
|
const touchTypeCol = columns.find(c => c.propertyName === 'touchType');
|
|
expect(touchTypeCol?.options.default).toBe('link');
|
|
|
|
const stayCol = columns.find(c => c.propertyName === 'stayDurationMs');
|
|
expect(stayCol?.options.default).toBe(0);
|
|
|
|
const scrollCol = columns.find(c => c.propertyName === 'scrollDepth');
|
|
expect(scrollCol?.options.default).toBe(0);
|
|
});
|
|
|
|
it('CrmTrackEntity 实例应完全符合 RadarTrack 契约结构 (包含 createdAt 属性)', () => {
|
|
const track = new CrmTrackEntity();
|
|
track.id = 1;
|
|
track.tenantId = 10;
|
|
track.cardId = 101;
|
|
track.visitorId = 'vis_abc_123';
|
|
track.ip = '8.8.8.8';
|
|
track.country = 'US';
|
|
track.city = 'Mountain View';
|
|
track.touchType = 'nfc';
|
|
track.stayDurationMs = 15000;
|
|
track.scrollDepth = 80;
|
|
track.viewedBlocks = ['hero', 'case_1', 'form'];
|
|
track.createTime = new Date('2026-09-20T12:00:00Z');
|
|
|
|
const contract: RadarTrack = track;
|
|
expect(contract.id).toBe(1);
|
|
expect(contract.cardId).toBe(101);
|
|
expect(contract.visitorId).toBe('vis_abc_123');
|
|
expect(contract.touchType).toBe('nfc');
|
|
expect(contract.viewedBlocks).toEqual(['hero', 'case_1', 'form']);
|
|
expect(contract.createdAt).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// ==========================================
|
|
// 2. IP 解析与地理位置推断 (parseIpLocation)
|
|
// ==========================================
|
|
describe('2. IP 地理位置轻量解析机制 (parseIpLocation)', () => {
|
|
let radarService: CrmRadarService;
|
|
|
|
beforeEach(() => {
|
|
radarService = new CrmRadarService();
|
|
});
|
|
|
|
it('本地回环 IP (127.0.0.1 / ::1 / localhost) 应解析为 Local', () => {
|
|
expect(radarService.parseIpLocation('127.0.0.1')).toEqual({
|
|
country: 'Local',
|
|
city: 'Local',
|
|
});
|
|
expect(radarService.parseIpLocation('::1')).toEqual({
|
|
country: 'Local',
|
|
city: 'Local',
|
|
});
|
|
expect(radarService.parseIpLocation('localhost')).toEqual({
|
|
country: 'Local',
|
|
city: 'Local',
|
|
});
|
|
});
|
|
|
|
it('局域网私有 IP (192.168.x / 10.x / 172.16.x) 应解析为 Local', () => {
|
|
expect(radarService.parseIpLocation('192.168.1.100')).toEqual({
|
|
country: 'Local',
|
|
city: 'Local',
|
|
});
|
|
expect(radarService.parseIpLocation('10.0.8.1')).toEqual({
|
|
country: 'Local',
|
|
city: 'Local',
|
|
});
|
|
});
|
|
|
|
it('请求头携带 CDN 地理位置时 (cf-ipcountry / x-country) 应优先提取', () => {
|
|
const headers = { 'cf-ipcountry': 'AE', 'cf-ipcity': 'Dubai' };
|
|
const loc = radarService.parseIpLocation('185.220.101.5', headers);
|
|
expect(loc.country).toBe('AE');
|
|
expect(loc.city).toBe('Dubai');
|
|
});
|
|
|
|
it('未知公网 IP 且无地理请求头时应安全兜底为 Unknown', () => {
|
|
const loc = radarService.parseIpLocation('203.0.113.195');
|
|
expect(loc.country).toBe('Unknown');
|
|
expect(loc.city).toBe('Unknown');
|
|
});
|
|
});
|
|
|
|
// ==========================================
|
|
// 3. 访客轨迹埋点记录测试 (recordTrack)
|
|
// ==========================================
|
|
describe('3. 访客轨迹埋点记录服务 (recordTrack)', () => {
|
|
let radarService: CrmRadarService;
|
|
let savedTracks: CrmTrackEntity[] = [];
|
|
let idCounter = 1;
|
|
|
|
beforeEach(() => {
|
|
savedTracks = [];
|
|
idCounter = 1;
|
|
|
|
const mockRepo: any = {
|
|
save: jest.fn().mockImplementation(async (item: any) => {
|
|
if (!item.id) {
|
|
item.id = idCounter++;
|
|
item.createTime = new Date();
|
|
savedTracks.push(item);
|
|
} else {
|
|
const idx = savedTracks.findIndex(t => t.id === item.id);
|
|
if (idx >= 0) {
|
|
savedTracks[idx] = { ...savedTracks[idx], ...item };
|
|
} else {
|
|
savedTracks.push(item);
|
|
}
|
|
}
|
|
return { ...item };
|
|
}),
|
|
find: jest.fn().mockImplementation(async (options: any) => {
|
|
let list = [...savedTracks];
|
|
if (options?.where?.cardId) {
|
|
list = list.filter(t => t.cardId === Number(options.where.cardId));
|
|
}
|
|
if (options?.order?.id === 'DESC' || options?.order?.createTime === 'DESC') {
|
|
list.sort((a, b) => Number(b.id) - Number(a.id));
|
|
}
|
|
if (options?.take) {
|
|
list = list.slice(0, options.take);
|
|
}
|
|
return list;
|
|
}),
|
|
};
|
|
|
|
radarService = new CrmRadarService();
|
|
radarService.crmTrackEntity = mockRepo;
|
|
});
|
|
|
|
it('缺少 cardId 时应抛出 400 异常', async () => {
|
|
await expect(
|
|
radarService.recordTrack({ touchType: 'link' } as any)
|
|
).rejects.toThrow(CoolCommException);
|
|
});
|
|
|
|
it('正常埋点记录应成功保存并补全默认值与指纹', async () => {
|
|
const result = await radarService.recordTrack({
|
|
cardId: 101,
|
|
touchType: 'qr',
|
|
stayDurationMs: 45000,
|
|
scrollDepth: 75,
|
|
viewedBlocks: ['hero', 'cases', 'social'],
|
|
ip: '127.0.0.1',
|
|
});
|
|
|
|
expect(result.id).toBeDefined();
|
|
expect(result.cardId).toBe(101);
|
|
expect(result.touchType).toBe('qr');
|
|
expect(result.stayDurationMs).toBe(45000);
|
|
expect(result.scrollDepth).toBe(75);
|
|
expect(result.viewedBlocks).toEqual(['hero', 'cases', 'social']);
|
|
expect(result.country).toBe('Local');
|
|
expect(result.city).toBe('Local');
|
|
expect(result.visitorId).toBeTruthy();
|
|
});
|
|
|
|
it('未指定 touchType 或非法触达渠道应兜底为 link', async () => {
|
|
const track1 = await radarService.recordTrack({
|
|
cardId: 101,
|
|
touchType: 'invalid_touch' as any,
|
|
});
|
|
expect(track1.touchType).toBe('link');
|
|
|
|
const track2 = await radarService.recordTrack({
|
|
cardId: 101,
|
|
});
|
|
expect(track2.touchType).toBe('link');
|
|
});
|
|
|
|
it('数值边界纠偏: stayDurationMs 负数纠偏为 0, scrollDepth 限制在 0~100', async () => {
|
|
const result = await radarService.recordTrack({
|
|
cardId: 101,
|
|
stayDurationMs: -500,
|
|
scrollDepth: 150,
|
|
});
|
|
|
|
expect(result.stayDurationMs).toBe(0);
|
|
expect(result.scrollDepth).toBe(100);
|
|
});
|
|
|
|
it('viewedBlocks 支持 JSON 字符串自动解析', async () => {
|
|
const result = await radarService.recordTrack({
|
|
cardId: 101,
|
|
viewedBlocks: JSON.stringify(['hero', 'contact_btn']) as any,
|
|
});
|
|
|
|
expect(result.viewedBlocks).toEqual(['hero', 'contact_btn']);
|
|
});
|
|
|
|
it('外部显式传入地理位置时应优先尊重', async () => {
|
|
const result = await radarService.recordTrack({
|
|
cardId: 101,
|
|
country: 'Singapore',
|
|
city: 'Singapore',
|
|
ip: '10.0.0.1',
|
|
});
|
|
|
|
expect(result.country).toBe('Singapore');
|
|
expect(result.city).toBe('Singapore');
|
|
});
|
|
|
|
it('高停留足迹会走雷达 Push,普通足迹不推', async () => {
|
|
const published: any[] = [];
|
|
radarService.crmRadarPushService = {
|
|
publish: (track: any) => {
|
|
published.push(track);
|
|
return track;
|
|
},
|
|
} as any;
|
|
|
|
await radarService.recordTrack({
|
|
cardId: 101,
|
|
stayDurationMs: 5000,
|
|
viewedBlocks: ['hero'],
|
|
});
|
|
await radarService.recordTrack({
|
|
cardId: 101,
|
|
stayDurationMs: 180000,
|
|
viewedBlocks: ['hero'],
|
|
});
|
|
|
|
expect(published).toHaveLength(1);
|
|
expect(published[0].stayDurationMs).toBe(180000);
|
|
});
|
|
});
|
|
|
|
describe('P2 高意向雷达收件箱', () => {
|
|
it('hotInbox 只统计当前用户名片的高意向足迹', async () => {
|
|
const radarService = new CrmRadarService();
|
|
radarService.cardInfoEntity = {
|
|
find: jest.fn(async () => [{ id: 101, userId: 11 }]),
|
|
} as any;
|
|
radarService.crmTrackEntity = {
|
|
find: jest.fn(async () => [
|
|
{ id: 1, cardId: 101, stayDurationMs: 180000, viewedBlocks: [] },
|
|
{ id: 2, cardId: 101, stayDurationMs: 1000, viewedBlocks: ['hero'] },
|
|
{ id: 3, cardId: 202, stayDurationMs: 200000, viewedBlocks: [] },
|
|
]),
|
|
} as any;
|
|
|
|
const inbox = await radarService.hotInbox(11);
|
|
expect(inbox.unread).toBe(1);
|
|
expect(inbox.list[0].cardId).toBe(101);
|
|
});
|
|
});
|
|
|
|
// ==========================================
|
|
// R25. 语言占比 / 设备占比诚实聚合
|
|
// ==========================================
|
|
describe('R25. 语言占比与设备占比真实计数', () => {
|
|
let radarService: CrmRadarService;
|
|
let trackList: any[];
|
|
|
|
beforeEach(() => {
|
|
trackList = [];
|
|
radarService = new CrmRadarService();
|
|
radarService.crmTrackEntity = {
|
|
find: jest.fn(async (options?: any) => {
|
|
let list = [...trackList];
|
|
if (options?.where?.cardId !== undefined) {
|
|
list = list.filter((t) => Number(t.cardId) === Number(options.where.cardId));
|
|
}
|
|
return list;
|
|
}),
|
|
} as any;
|
|
});
|
|
|
|
it('无数据时 getLanguageStats / getDeviceStats 返回 [],analytics 同步为空', async () => {
|
|
const analytics = await radarService.getAnalytics();
|
|
expect(await radarService.getLanguageStats()).toEqual([]);
|
|
expect(await radarService.getDeviceStats()).toEqual([]);
|
|
expect(analytics.languageStats).toEqual([]);
|
|
expect(analytics.deviceStats).toEqual([]);
|
|
});
|
|
|
|
it('设备占比来自 touchType 计数,空足迹不返回固定 0% 渠道行', async () => {
|
|
expect(await radarService.getDeviceStats()).toEqual([]);
|
|
|
|
trackList = [
|
|
{ id: 1, touchType: 'qr' },
|
|
{ id: 2, touchType: 'qr' },
|
|
{ id: 3, touchType: 'link' },
|
|
];
|
|
const devices = await radarService.getDeviceStats();
|
|
expect(devices.find((d) => d.type === 'qr')?.percentage).toBeCloseTo(66.7, 0);
|
|
expect(devices.find((d) => d.type === 'link')?.percentage).toBeCloseTo(33.3, 0);
|
|
expect(devices.find((d) => d.type === 'nfc')).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ==========================================
|
|
// 4. 最近足迹流水查询测试 (getRecentTracks)
|
|
// ==========================================
|
|
describe('4. 最近足迹流水查询 (getRecentTracks)', () => {
|
|
let radarService: CrmRadarService;
|
|
let savedTracks: CrmTrackEntity[] = [];
|
|
|
|
beforeEach(async () => {
|
|
savedTracks = [];
|
|
const mockRepo: any = {
|
|
save: jest.fn().mockImplementation(async (item: any) => {
|
|
savedTracks.push(item);
|
|
return item;
|
|
}),
|
|
find: jest.fn().mockImplementation(async (options: any) => {
|
|
let list = [...savedTracks];
|
|
if (options?.where?.cardId) {
|
|
list = list.filter(t => t.cardId === Number(options.where.cardId));
|
|
}
|
|
if (options?.order?.id === 'DESC' || options?.order?.createTime === 'DESC') {
|
|
list.sort((a, b) => Number(b.id) - Number(a.id));
|
|
}
|
|
if (options?.take) {
|
|
list = list.slice(0, options.take);
|
|
}
|
|
return list;
|
|
}),
|
|
};
|
|
|
|
radarService = new CrmRadarService();
|
|
radarService.crmTrackEntity = mockRepo;
|
|
|
|
// 预先存入 5 条足迹
|
|
for (let i = 1; i <= 5; i++) {
|
|
const t = new CrmTrackEntity();
|
|
t.id = i;
|
|
t.cardId = 101;
|
|
t.visitorId = `vis_${i}`;
|
|
t.touchType = 'nfc';
|
|
t.createTime = new Date(`2026-09-20T10:0${i}:00Z`);
|
|
savedTracks.push(t);
|
|
}
|
|
});
|
|
|
|
it('缺少 cardId 时应抛出 400 异常', async () => {
|
|
await expect(radarService.getRecentTracks(null as any)).rejects.toThrow(
|
|
CoolCommException
|
|
);
|
|
});
|
|
|
|
it('应按倒序返回指定名片的最近足迹列表', async () => {
|
|
const list = await radarService.getRecentTracks(101, 3);
|
|
expect(list.length).toBe(3);
|
|
expect(list[0].id).toBe(5);
|
|
expect(list[1].id).toBe(4);
|
|
expect(list[2].id).toBe(3);
|
|
});
|
|
|
|
it('limit 超限或非正整数时应自动修正约束在 1~100 区间', async () => {
|
|
const list1 = await radarService.getRecentTracks(101, 0);
|
|
expect(list1.length).toBe(1);
|
|
|
|
const list2 = await radarService.getRecentTracks(101, 500);
|
|
expect(list2.length).toBe(5);
|
|
});
|
|
});
|
|
|
|
// ==========================================
|
|
// 5. 公开免登录埋点控制器测试 (OpenCrmRadarController)
|
|
// ==========================================
|
|
describe('5. 公开免登录埋点控制器 (OpenCrmRadarController)', () => {
|
|
let controller: OpenCrmRadarController;
|
|
let mockRadarService: any;
|
|
|
|
beforeEach(() => {
|
|
mockRadarService = {
|
|
recordTrack: jest.fn().mockResolvedValue({
|
|
id: 999,
|
|
cardId: 101,
|
|
visitorId: 'vis_test',
|
|
country: 'Local',
|
|
city: 'Local',
|
|
}),
|
|
getRecentTracks: jest.fn().mockResolvedValue([
|
|
{ id: 999, cardId: 101 },
|
|
]),
|
|
};
|
|
|
|
controller = new OpenCrmRadarController();
|
|
controller.crmRadarService = mockRadarService;
|
|
(controller as any).ctx = {
|
|
ip: '127.0.0.1',
|
|
headers: {},
|
|
};
|
|
(controller as any).ok = (data: any) => ({ code: 1000, message: 'success', data });
|
|
(controller as any).fail = (msg: string, code = 1001) => ({ code, message: msg });
|
|
});
|
|
|
|
it('POST /open/crm/radar/track 正常埋点返回成功响应 (code: 1000)', async () => {
|
|
const res = await controller.track({
|
|
cardId: 101,
|
|
touchType: 'nfc',
|
|
});
|
|
|
|
expect(res.code).toBe(1000);
|
|
expect((res as any).data.id).toBe(999);
|
|
expect(mockRadarService.recordTrack).toHaveBeenCalled();
|
|
});
|
|
|
|
it('埋点服务抛出异常时应捕获并返回 fail 响应', async () => {
|
|
mockRadarService.recordTrack.mockRejectedValueOnce(
|
|
new CoolCommException('缺少名片标识 cardId', 400)
|
|
);
|
|
|
|
const res = await controller.track({} as any);
|
|
expect(res.code).toBe(400);
|
|
expect(res.message).toContain('缺少名片标识 cardId');
|
|
});
|
|
|
|
it('GET /open/crm/radar/recent 正常查询最近足迹', async () => {
|
|
const res = await controller.recent(101, 10);
|
|
expect(res.code).toBe(1000);
|
|
expect((res as any).data.length).toBe(1);
|
|
expect(mockRadarService.getRecentTracks).toHaveBeenCalledWith(101, 10);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('雷达监控行补全', () => {
|
|
it('用线索姓名、内部用户头像和模块标题补全足迹,对不上则留空', () => {
|
|
const { decorateRadarTracks } = require('../../../src/modules/crm/service/radar');
|
|
const now = Date.parse('2026-09-23T10:00:00.000Z');
|
|
const rows = decorateRadarTracks(
|
|
[
|
|
{
|
|
id: 8,
|
|
cardId: 12,
|
|
visitorId: 'vis_alex',
|
|
country: 'CN',
|
|
city: 'Shanghai',
|
|
ip: '8.8.8.8',
|
|
touchType: 'link',
|
|
stayDurationMs: 12000,
|
|
scrollDepth: 40,
|
|
viewedBlocks: ['2', 'hero'],
|
|
createTime: new Date(now - 30 * 1000).toISOString(),
|
|
},
|
|
{
|
|
id: 9,
|
|
cardId: 12,
|
|
visitorId: 'vis_anon',
|
|
country: 'Local',
|
|
city: 'Local',
|
|
touchType: 'qr',
|
|
stayDurationMs: 0,
|
|
scrollDepth: 0,
|
|
viewedBlocks: [],
|
|
createTime: new Date(now - 60 * 60 * 1000).toISOString(),
|
|
},
|
|
],
|
|
[
|
|
{
|
|
id: 3,
|
|
visitorId: 'vis_alex',
|
|
name: 'Alex',
|
|
company: 'Nexus',
|
|
phone: '13800000000',
|
|
},
|
|
],
|
|
[
|
|
{
|
|
phone: '13800000000',
|
|
avatarUrl: 'https://cdn.example.com/alex.png',
|
|
nickName: 'Alex Nick',
|
|
},
|
|
],
|
|
[
|
|
{ id: 2, cardId: 12, type: 'case', title: 'Middle East Hub' },
|
|
{ id: 1, cardId: 12, type: 'hero', title: 'Profile' },
|
|
],
|
|
now
|
|
);
|
|
|
|
expect(rows[0].visitorName).toBe('Alex');
|
|
expect(rows[0].visitorCompany).toBe('Nexus');
|
|
expect(rows[0].avatarUrl).toBe('https://cdn.example.com/alex.png');
|
|
expect(rows[0].online).toBe(true);
|
|
expect(rows[0].viewedContents).toEqual([
|
|
{ key: '2', title: 'Middle East Hub' },
|
|
{ key: 'hero', title: 'Profile' },
|
|
]);
|
|
expect(rows[1].visitorName).toBeUndefined();
|
|
expect(rows[1].avatarUrl).toBeUndefined();
|
|
expect(rows[1].online).toBe(false);
|
|
});
|
|
|
|
it('数据库蛇形字段也会归一成名片主可读的驼峰监控行', () => {
|
|
const { decorateRadarTracks } = require('../../../src/modules/crm/service/radar');
|
|
const now = Date.parse('2026-09-23T10:00:00.000Z');
|
|
const rows = decorateRadarTracks(
|
|
[
|
|
{
|
|
id: 4,
|
|
card_id: '12',
|
|
visitor_id: 'vis_snake',
|
|
touch_type: 'nfc',
|
|
stay_duration_ms: 6400,
|
|
scroll_depth: 80,
|
|
viewed_blocks: ['hero'],
|
|
country: 'CN',
|
|
city: 'Shanghai',
|
|
ip: '::1',
|
|
create_time: '2026-09-23 17:59:00',
|
|
},
|
|
],
|
|
[],
|
|
[],
|
|
[],
|
|
now
|
|
);
|
|
expect(rows[0].visitorId).toBe('vis_snake');
|
|
expect(rows[0].cardId).toBe('12');
|
|
expect(rows[0].touchType).toBe('nfc');
|
|
expect(rows[0].stayDurationMs).toBe(6400);
|
|
expect(rows[0].scrollDepth).toBe(80);
|
|
expect(rows[0].viewedBlocks).toEqual(['hero']);
|
|
expect(String(rows[0].createdAt)).toContain('2026-09-23');
|
|
});
|
|
});
|