feat: 同步管理端、用户端与后端业务,并接通线上 Web 接口

补齐平台订阅、外部登录、CRM 同步与用户端页面;Nginx 转发用户端接口,登录页使用 Google 与 Apple 标志。

Co-authored-by: Cursor <cursoragent@cursor.com>
这个提交包含在:
wikiboss
2026-09-23 18:01:47 +08:00
父节点 8ea9c25386
当前提交 62e0ae4435
共有 227 个文件被更改,包括 21855 次插入 和 5264 次删除

查看文件

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