feat: 同步管理端、用户端与后端业务,并接通线上 Web 接口
补齐平台订阅、外部登录、CRM 同步与用户端页面;Nginx 转发用户端接口,登录页使用 Google 与 Apple 标志。 Co-authored-by: Cursor <cursoragent@cursor.com>
这个提交包含在:
@@ -308,6 +308,50 @@ describe('Task 11: 后端访客雷达模块 (modules/crm) 单元测试', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 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)
|
||||
// ==========================================
|
||||
@@ -435,3 +479,105 @@ describe('Task 11: 后端访客雷达模块 (modules/crm) 单元测试', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户