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 次删除

查看文件

@@ -100,6 +100,7 @@ describe('CRM 管理后台 CRUD 控制器 (admin/crm)', () => {
it('应将 updateStatus 注册为 serviceApi(线索状态流转)', () => {
const methods = serviceApiMethods(option.serviceApis);
expect(methods).toContain('updateStatus');
expect(methods).toContain('batchExport');
const named = (option.serviceApis || []).find(
api => typeof api !== 'string' && api.method === 'updateStatus'
) as { method: string; summary: string } | undefined;

查看文件

@@ -1,8 +1,27 @@
import { lookupGeoIp } from '../../../src/comm/geoip/lookup';
import axios from 'axios';
import {
lookupGeoIp,
resolveGeoIp,
resolveLanguageFromCountry,
} from '../../../src/comm/geoip/lookup';
import { CrmRadarService } from '../../../src/modules/crm/service/radar';
describe('T34: 内置 GeoIP 前缀表 (lookupGeoIp)', () => {
describe('lookupGeoIp', () => {
jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;
describe('T34 / P29: GeoIP 可替换解析', () => {
const originalBaseUrl = process.env.GEOIP_BASE_URL;
afterEach(() => {
if (originalBaseUrl === undefined) {
delete process.env.GEOIP_BASE_URL;
} else {
process.env.GEOIP_BASE_URL = originalBaseUrl;
}
jest.clearAllMocks();
});
describe('lookupGeoIp(本地前缀库)', () => {
it('应将 8.8.8.8 解析为 US / Mountain View', () => {
expect(lookupGeoIp('8.8.8.8')).toEqual({
country: 'US',
@@ -40,6 +59,120 @@ describe('T34: 内置 GeoIP 前缀表 (lookupGeoIp)', () => {
city: 'Berkeley',
});
});
it('未知公网 IP 不得瞎填国家/城市(含迪拜)', () => {
expect(lookupGeoIp('203.0.113.50')).toBeNull();
expect(lookupGeoIp('45.33.32.156')).toBeNull();
expect(lookupGeoIp('185.199.108.153')).toBeNull();
});
});
describe('resolveGeoIp(可替换)', () => {
it('无 GEOIP_BASE_URL 时本地库命中返回国家城市与映射语言', async () => {
delete process.env.GEOIP_BASE_URL;
await expect(resolveGeoIp('8.8.8.8')).resolves.toEqual({
country: 'US',
city: 'Mountain View',
language: 'en',
});
expect(mockedAxios.get).not.toHaveBeenCalled();
});
it('无 GEOIP_BASE_URL 且本地未命中时返回空地理 + reason,不假装迪拜,不猜语言', async () => {
delete process.env.GEOIP_BASE_URL;
const result = await resolveGeoIp('45.33.32.156');
expect(result.country).toBe('');
expect(result.city).toBe('');
expect(result.reason).toBe('not_found');
expect(['', 'unknown']).toContain(result.language);
expect(result.language).not.toBe('ar');
expect(result.city.toLowerCase()).not.toContain('dubai');
expect(result.country).not.toBe('AE');
expect(mockedAxios.get).not.toHaveBeenCalled();
});
it('有 GEOIP_BASE_URL 时用 axios 查询,超时 3000ms', async () => {
process.env.GEOIP_BASE_URL = 'https://geo.example.com/lookup';
mockedAxios.get.mockResolvedValueOnce({
data: { country: 'DE', city: 'Berlin' },
});
const result = await resolveGeoIp('1.2.3.4');
expect(result).toEqual({ country: 'DE', city: 'Berlin', language: 'de' });
expect(mockedAxios.get).toHaveBeenCalledWith(
'https://geo.example.com/lookup',
expect.objectContaining({
params: { ip: '1.2.3.4' },
timeout: 3000,
})
);
});
it('远程失败时返回空地理 + reason,不抛错、不填假城市、不猜语言', async () => {
process.env.GEOIP_BASE_URL = 'https://geo.example.com/lookup';
mockedAxios.get.mockRejectedValueOnce(new Error('timeout of 3000ms exceeded'));
const result = await resolveGeoIp('1.2.3.4');
expect(result.country).toBe('');
expect(result.city).toBe('');
expect(result.reason).toContain('timeout');
expect(['', 'unknown']).toContain(result.language);
expect(result.language).not.toBe('ar');
expect(result.city.toLowerCase()).not.toContain('dubai');
});
});
describe('R46: resolveLanguageFromCountry(无国家不猜语言)', () => {
it('无国家时语言为空或 unknown,不得默认 ar', () => {
for (const country of ['', ' ', null, undefined]) {
const language = resolveLanguageFromCountry(country as string | null | undefined);
expect(['', 'unknown']).toContain(language);
expect(language).not.toBe('ar');
expect(language).not.toBe('en');
expect(language).not.toBe('zh');
}
});
it('有国家且映射表命中才返回对应语言', () => {
expect(resolveLanguageFromCountry('US')).toBe('en');
expect(resolveLanguageFromCountry('CN')).toBe('zh');
expect(resolveLanguageFromCountry('AE')).toBe('ar');
expect(resolveLanguageFromCountry('DE')).toBe('de');
expect(resolveLanguageFromCountry('AU')).toBe('en');
});
it('有国家但不在映射表时语言为空或 unknown,不得瞎猜', () => {
const language = resolveLanguageFromCountry('ZZ');
expect(['', 'unknown']).toContain(language);
expect(language).not.toBe('ar');
});
it('本地未命中 / 私网解析结果的 language 不得为 ar', async () => {
delete process.env.GEOIP_BASE_URL;
const unknown = await resolveGeoIp('185.199.108.153');
expect(unknown.country).toBe('');
expect(['', 'unknown']).toContain(unknown.language);
expect(unknown.language).not.toBe('ar');
const privateIp = await resolveGeoIp('192.168.1.100');
expect(privateIp.country).toBe('');
expect(['', 'unknown']).toContain(privateIp.language);
expect(privateIp.language).not.toBe('ar');
});
it('远程仅有城市无国家时 language 为空或 unknown,不得默认 ar', async () => {
process.env.GEOIP_BASE_URL = 'https://geo.example.com/lookup';
mockedAxios.get.mockResolvedValueOnce({
data: { city: 'Somewhere' },
});
const result = await resolveGeoIp('1.2.3.4');
expect(result.country).toBe('');
expect(result.city).toBe('Somewhere');
expect(['', 'unknown']).toContain(result.language);
expect(result.language).not.toBe('ar');
expect(result.city.toLowerCase()).not.toContain('dubai');
});
});
describe('CrmRadarService.parseIpLocation', () => {

查看文件

@@ -0,0 +1,80 @@
import axios from 'axios';
import { CoolCommException } from '@cool-midway/core';
import { CrmHubSpotService } from '../../../src/modules/crm/service/hubspot';
describe('CrmHubSpotService.createContact', () => {
let service: CrmHubSpotService;
beforeEach(() => {
service = new CrmHubSpotService();
jest.clearAllMocks();
});
afterEach(() => {
jest.restoreAllMocks();
});
it('accessToken 为空时应抛出 CoolCommException,说明未配置 HubSpot', async () => {
const postSpy = jest.spyOn(axios, 'post');
await expect(
service.createContact('', {
properties: { email: 'a@b.com' },
})
).rejects.toThrow(CoolCommException);
await expect(
service.createContact(' ', {
properties: { email: 'a@b.com' },
})
).rejects.toThrow(/HubSpot/);
expect(postSpy).not.toHaveBeenCalled();
});
it('有 token 时应向 HubSpot Contacts API 发起 Bearer 请求并返回 id 与原始响应', async () => {
const token = 'pat-test-token-xyz';
const payload = {
properties: {
email: 'john@acme.com',
firstname: 'John',
},
};
const raw = { id: 'hs-contact-99', properties: payload.properties };
const postSpy = jest.spyOn(axios, 'post').mockResolvedValueOnce({
status: 201,
data: raw,
});
const result = await service.createContact(token, payload);
expect(postSpy).toHaveBeenCalledTimes(1);
const [url, body, config] = postSpy.mock.calls[0];
expect(url).toBe('https://api.hubapi.com/crm/v3/objects/contacts');
expect(body).toEqual(payload);
expect(config?.headers?.Authorization).toBe(`Bearer ${token}`);
expect(config?.timeout).toBe(8000);
expect(result.id).toBe('hs-contact-99');
expect(result.raw).toEqual(raw);
});
it('对方返回 4xx 时应抛出包含状态码与错误信息的异常,不吞掉', async () => {
const axiosError = Object.assign(new Error('Request failed with status code 400'), {
isAxiosError: true,
response: {
status: 400,
data: { message: 'Property values were not valid' },
},
});
jest.spyOn(axios, 'post').mockRejectedValueOnce(axiosError);
await expect(
service.createContact('pat-valid', {
properties: { email: 'bad' },
})
).rejects.toThrow(/400|Property values were not valid/);
});
});

查看文件

@@ -0,0 +1,229 @@
import { CoolCommException } from '@cool-midway/core';
import { AdminCrmLeadController } from '../../../src/modules/crm/controller/admin/lead';
import { CrmSyncService } from '../../../src/modules/crm/service/sync';
import { CrmLeadEntity } from '../../../src/modules/crm/entity/lead';
describe('AdminCrmLeadController.syncExternal 外部 CRM 真实同步', () => {
let controller: AdminCrmLeadController;
let syncService: CrmSyncService;
let mockLeadRepo: { findOne: jest.Mock };
let hubspotService: { createContact: jest.Mock };
let salesforceService: { createLead: jest.Mock };
const mockLead = {
id: 101,
name: 'John Doe',
email: 'john.doe@acme.com',
phone: '+1-555-0199',
company: 'Acme Global Corp',
status: 'new',
} as unknown as CrmLeadEntity;
const envKeys = [
'HUBSPOT_ACCESS_TOKEN',
'SALESFORCE_ACCESS_TOKEN',
'SALESFORCE_INSTANCE_URL',
] as const;
const savedEnv: Record<string, string | undefined> = {};
beforeEach(() => {
for (const key of envKeys) {
savedEnv[key] = process.env[key];
delete process.env[key];
}
syncService = new CrmSyncService();
mockLeadRepo = { findOne: jest.fn().mockResolvedValue(mockLead) };
hubspotService = { createContact: jest.fn() };
salesforceService = { createLead: jest.fn() };
controller = new AdminCrmLeadController();
(controller as unknown as { crmLeadEntity: unknown }).crmLeadEntity =
mockLeadRepo;
(controller as unknown as { crmSyncService: unknown }).crmSyncService =
syncService;
(controller as unknown as { crmHubSpotService: unknown }).crmHubSpotService =
hubspotService;
(
controller as unknown as { crmSalesforceService: unknown }
).crmSalesforceService = salesforceService;
});
afterEach(() => {
for (const key of envKeys) {
const prev = savedEnv[key];
if (prev === undefined) {
delete process.env[key];
} else {
process.env[key] = prev;
}
}
jest.restoreAllMocks();
});
describe('target=hubspot', () => {
it('无 HUBSPOT_ACCESS_TOKEN 时不调用客户端,返回 synced:false', async () => {
const res = await controller.syncExternal({
id: 101,
target: 'hubspot',
});
expect(hubspotService.createContact).not.toHaveBeenCalled();
expect(res.code).toBe(1000);
expect(res.data).toMatchObject({
target: 'hubspot',
leadId: 101,
synced: false,
reason: '未配置 HubSpot',
payload: {
properties: {
email: 'john.doe@acme.com',
firstname: 'John',
lastname: 'Doe',
phone: '+1-555-0199',
company: 'Acme Global Corp',
lifecyclestage: 'lead',
},
},
});
});
it('有 token 时应调用 createContact 并返回 synced:true 与 id', async () => {
process.env.HUBSPOT_ACCESS_TOKEN = 'pat-test-token';
hubspotService.createContact.mockResolvedValueOnce({
id: 'hs-42',
raw: { id: 'hs-42' },
});
const res = await controller.syncExternal({
id: 101,
target: 'hubspot',
});
expect(hubspotService.createContact).toHaveBeenCalledTimes(1);
expect(hubspotService.createContact).toHaveBeenCalledWith(
'pat-test-token',
{
properties: {
email: 'john.doe@acme.com',
firstname: 'John',
lastname: 'Doe',
phone: '+1-555-0199',
company: 'Acme Global Corp',
lifecyclestage: 'lead',
},
}
);
expect(res.code).toBe(1000);
expect(res.data).toMatchObject({
target: 'hubspot',
leadId: 101,
synced: true,
id: 'hs-42',
});
});
it('客户端抛错时应向上抛出,不返回 synced:true', async () => {
process.env.HUBSPOT_ACCESS_TOKEN = 'pat-test-token';
hubspotService.createContact.mockRejectedValueOnce(
new CoolCommException('HubSpot API 错误 400: bad', 400)
);
await expect(
controller.syncExternal({ id: 101, target: 'hubspot' })
).rejects.toThrow(CoolCommException);
expect(hubspotService.createContact).toHaveBeenCalledTimes(1);
});
});
describe('target=salesforce', () => {
it('缺凭证时不调用客户端,返回 synced:false', async () => {
const res = await controller.syncExternal({
id: 101,
target: 'salesforce',
});
expect(salesforceService.createLead).not.toHaveBeenCalled();
expect(res.code).toBe(1000);
expect(res.data).toMatchObject({
target: 'salesforce',
leadId: 101,
synced: false,
reason: '未配置 Salesforce',
payload: {
FirstName: 'John',
LastName: 'Doe',
Email: 'john.doe@acme.com',
Company: 'Acme Global Corp',
Phone: '+1-555-0199',
Status: 'Open - Not Contacted',
},
});
});
it('仅有 token 缺 instanceUrl 时也不调用客户端', async () => {
process.env.SALESFORCE_ACCESS_TOKEN = 'sf-token';
const res = await controller.syncExternal({
id: 101,
target: 'salesforce',
});
expect(salesforceService.createLead).not.toHaveBeenCalled();
expect(res.data).toMatchObject({
synced: false,
reason: '未配置 Salesforce',
});
});
it('凭证齐全时应调用 createLead 并返回 synced:true 与 id', async () => {
process.env.SALESFORCE_ACCESS_TOKEN = 'sf-token';
process.env.SALESFORCE_INSTANCE_URL = 'https://example.my.salesforce.com';
salesforceService.createLead.mockResolvedValueOnce({
id: '00QsfLead1',
raw: { id: '00QsfLead1', success: true },
});
const res = await controller.syncExternal({
id: 101,
target: 'salesforce',
});
expect(salesforceService.createLead).toHaveBeenCalledTimes(1);
expect(salesforceService.createLead).toHaveBeenCalledWith(
'sf-token',
'https://example.my.salesforce.com',
{
FirstName: 'John',
LastName: 'Doe',
Email: 'john.doe@acme.com',
Company: 'Acme Global Corp',
Phone: '+1-555-0199',
Status: 'Open - Not Contacted',
}
);
expect(res.code).toBe(1000);
expect(res.data).toMatchObject({
target: 'salesforce',
leadId: 101,
synced: true,
id: '00QsfLead1',
});
});
it('客户端抛错时应向上抛出,不返回 synced:true', async () => {
process.env.SALESFORCE_ACCESS_TOKEN = 'sf-token';
process.env.SALESFORCE_INSTANCE_URL = 'https://example.my.salesforce.com';
salesforceService.createLead.mockRejectedValueOnce(
new CoolCommException('Salesforce API 错误 401: unauthorized', 401)
);
await expect(
controller.syncExternal({ id: 101, target: 'salesforce' })
).rejects.toThrow(CoolCommException);
expect(salesforceService.createLead).toHaveBeenCalledTimes(1);
});
});
});

查看文件

@@ -5,6 +5,10 @@ import { CrmLeadService } from '../../../src/modules/crm/service/lead';
import { OpenCrmLeadController } from '../../../src/modules/crm/controller/open/lead';
import { LeadEntity, LeadStatus } from '../../../src/comm/types/omni';
import { CoolCommException } from '@cool-midway/core';
import {
AUDIT_ACTION_LEAD_BATCH_EXPORT,
BaseAuditLogService,
} from '../../../src/modules/base/service/audit';
describe('Task 12: 后端潜客线索模块 (modules/crm) 单元测试', () => {
const metadata = getMetadataArgsStorage();
@@ -333,4 +337,82 @@ describe('Task 12: 后端潜客线索模块 (modules/crm) 单元测试', () => {
expect(response.code).not.toBe(1000);
});
});
// ==========================================
// 5. 线索批量导出审计 (batchExport)
// ==========================================
describe('5. 线索批量导出成功后写入审计 (batchExport)', () => {
let leadService: CrmLeadService;
let savedLeads: CrmLeadEntity[] = [];
let record: jest.Mock;
beforeEach(() => {
savedLeads = [
Object.assign(new CrmLeadEntity(), {
id: 1,
name: 'Alice',
phone: '+971-50-1111111',
email: 'a@ex.com',
status: 'new',
source: 'nfc',
}),
Object.assign(new CrmLeadEntity(), {
id: 2,
name: 'Bob',
phone: '+971-50-2222222',
email: 'b@ex.com',
status: 'hot',
source: 'qr',
}),
];
record = jest.fn(async dto => dto);
const auditService = new BaseAuditLogService();
auditService.record = record as unknown as BaseAuditLogService['record'];
leadService = new CrmLeadService();
leadService.baseAuditLogService = auditService;
leadService.crmLeadEntity = {
find: jest.fn().mockImplementation(async () => [...savedLeads]),
save: jest.fn(),
findOne: jest.fn(),
} as any;
leadService.baseCtx = {
admin: { userId: 7, username: 'ExportAdmin' },
} as unknown as CrmLeadService['baseCtx'];
});
it('导出成功应记录 lead_batch_export 审计且正文仅含条数不含手机号', async () => {
const result = await leadService.batchExport({});
expect(result.exportCount).toBe(2);
expect(result.list).toHaveLength(2);
expect(record).toHaveBeenCalledTimes(1);
expect(record).toHaveBeenCalledWith(
expect.objectContaining({
module: 'crm',
action: AUDIT_ACTION_LEAD_BATCH_EXPORT,
operatorId: 7,
operatorName: 'ExportAdmin',
status: 1,
})
);
const detailRaw = String(record.mock.calls[0][0].detail);
const detail = JSON.parse(detailRaw);
expect(detail.exportCount).toBe(2);
expect(detailRaw).not.toContain('+971-50-1111111');
expect(detailRaw).not.toContain('+971-50-2222222');
expect(detail).not.toHaveProperty('phone');
expect(detail).not.toHaveProperty('phones');
});
it('导出查询失败时不得记成功审计', async () => {
(leadService.crmLeadEntity.find as jest.Mock).mockRejectedValue(
new Error('db down')
);
await expect(leadService.batchExport({})).rejects.toThrow(/db down/);
expect(record).not.toHaveBeenCalled();
});
});
});

查看文件

@@ -1,3 +1,4 @@
import axios from 'axios';
import {
CrmRadarPushService,
RadarPushPayload,
@@ -5,6 +6,9 @@ import {
} from '../../../src/modules/crm/service/push';
import { RadarTrack } from '../../../src/comm/types/omni';
jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;
function makeTrack(overrides: Partial<RadarTrack> = {}): RadarTrack {
return {
id: 1,
@@ -35,9 +39,20 @@ describe('Task 35: 雷达 Push 通知内存渠道 (CrmRadarPushService)', () =>
];
let service: CrmRadarPushService;
const originalWebhookUrl = process.env.RADAR_PUSH_WEBHOOK_URL;
beforeEach(() => {
service = new CrmRadarPushService();
delete process.env.RADAR_PUSH_WEBHOOK_URL;
mockedAxios.post.mockReset();
});
afterEach(() => {
if (originalWebhookUrl === undefined) {
delete process.env.RADAR_PUSH_WEBHOOK_URL;
} else {
process.env.RADAR_PUSH_WEBHOOK_URL = originalWebhookUrl;
}
});
it('buildPayload 字段齐全且 event 为 radar.visitor', () => {
@@ -116,4 +131,87 @@ describe('Task 35: 雷达 Push 通知内存渠道 (CrmRadarPushService)', () =>
expect(received).toHaveLength(1);
expect(received[0].visitorId).toBe('vis_before');
});
describe('dispatch 外发结果', () => {
it('未配置 RADAR_PUSH_WEBHOOK_URL 时先通知内存监听者,返回 delivered:false channel:memory', async () => {
const received: RadarPushPayload[] = [];
const payload = service.buildPayload(
makeTrack({ cardId: 501, visitorId: 'vis_dispatch_mem' })
);
service.subscribe(501, p => {
received.push(p);
});
const result = await service.dispatch(payload);
expect(received).toHaveLength(1);
expect(received[0].visitorId).toBe('vis_dispatch_mem');
expect(result).toEqual({ delivered: false, channel: 'memory' });
expect(result.delivered).toBe(false);
expect(mockedAxios.post).not.toHaveBeenCalled();
});
it('公网 Webhook HTTP 2xx 才返回 delivered:true channel:webhook', async () => {
process.env.RADAR_PUSH_WEBHOOK_URL = 'https://hooks.example.com/radar';
mockedAxios.post.mockResolvedValue({ status: 204, data: {} } as never);
const payload = service.buildPayload(
makeTrack({ cardId: 502, visitorId: 'vis_ok' })
);
const result = await service.dispatch(payload);
expect(mockedAxios.post).toHaveBeenCalledWith(
'https://hooks.example.com/radar',
payload,
{ timeout: 5000 }
);
expect(result).toEqual({ delivered: true, channel: 'webhook' });
});
it('Webhook 请求失败不抛错,返回 delivered:false 且带 error', async () => {
process.env.RADAR_PUSH_WEBHOOK_URL = 'https://hooks.example.com/radar';
mockedAxios.post.mockRejectedValue(new Error('socket hang up'));
const payload = service.buildPayload(
makeTrack({ cardId: 503, visitorId: 'vis_fail' })
);
await expect(service.dispatch(payload)).resolves.toEqual({
delivered: false,
channel: 'webhook',
error: 'socket hang up',
});
});
it('非 2xx 不伪造 delivered:true', async () => {
process.env.RADAR_PUSH_WEBHOOK_URL = 'https://hooks.example.com/radar';
mockedAxios.post.mockResolvedValue({ status: 500, data: 'err' } as never);
const payload = service.buildPayload(makeTrack({ cardId: 504 }));
const result = await service.dispatch(payload);
expect(result.delivered).toBe(false);
expect(result.channel).toBe('webhook');
expect(result.error).toBeTruthy();
});
it.each([
'http://localhost/hook',
'http://127.0.0.1:9000/hook',
'http://10.1.2.3/hook',
'http://172.16.0.1/hook',
'http://172.31.255.1/hook',
'http://192.168.1.10/hook',
'ftp://hooks.example.com/radar',
'file:///etc/passwd',
])('拒绝内网或非 http(s) URL:%s,不发请求', async url => {
process.env.RADAR_PUSH_WEBHOOK_URL = url;
const payload = service.buildPayload(makeTrack({ cardId: 505 }));
const result = await service.dispatch(payload);
expect(mockedAxios.post).not.toHaveBeenCalled();
expect(result.delivered).toBe(false);
expect(result.error).toMatch(/内网|拒绝|http/i);
});
});
});

查看文件

@@ -43,3 +43,90 @@ describe('性能与防 OOM: 雷达国家分布与时段统计原生 SQL 聚合
expect(result[0].avgStayDurationMs).toBe(45000);
});
});
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('无足迹时语言占比与设备占比均返回空数组,不得填示例语言或零值渠道', async () => {
const languages = await radarService.getLanguageStats();
const devices = await radarService.getDeviceStats();
expect(languages).toEqual([]);
expect(devices).toEqual([]);
expect(JSON.stringify(languages)).not.toMatch(/zh-CN|en-US|ar-SA|English|中文/i);
expect(JSON.stringify(devices)).not.toMatch(/iPhone|Android|iOS|Samsung/i);
});
it('足迹无 language/locale 字段时语言占比仍为空,不得用国家或固定比例冒充', async () => {
trackList = [
{ id: 1, cardId: 10, country: 'United States', touchType: 'nfc' },
{ id: 2, cardId: 10, country: 'Germany', touchType: 'qr' },
];
const languages = await radarService.getLanguageStats(10);
expect(languages).toEqual([]);
expect(languages.map((i) => i.percentage)).not.toEqual([40, 35, 25]);
});
it('设备占比按 touchType 真实计数,仅返回有数据的类型与占比', async () => {
trackList = [
{ id: 1, cardId: 10, touchType: 'nfc' },
{ id: 2, cardId: 10, touchType: 'nfc' },
{ id: 3, cardId: 10, touchType: 'qr' },
{ id: 4, cardId: 10, touchType: 'link' },
];
const devices = await radarService.getDeviceStats(10);
expect(devices).toHaveLength(3);
const byType = Object.fromEntries(devices.map((d) => [d.type, d]));
expect(byType.nfc.count).toBe(2);
expect(byType.nfc.percentage).toBe(50);
expect(byType.qr.count).toBe(1);
expect(byType.qr.percentage).toBe(25);
expect(byType.link.count).toBe(1);
expect(byType.link.percentage).toBe(25);
expect(devices.every((d) => d.count > 0)).toBe(true);
});
it('仅出现部分渠道时不补齐未出现的 nfc/qr/link 零值行', async () => {
trackList = [
{ id: 1, cardId: 10, touchType: 'nfc' },
{ id: 2, cardId: 10, touchType: 'nfc' },
];
const devices = await radarService.getDeviceStats(10);
expect(devices).toEqual([{ type: 'nfc', count: 2, percentage: 100 }]);
});
it('若足迹带有 language 字段则按真实计数聚合语言占比', async () => {
trackList = [
{ id: 1, cardId: 10, language: 'en-US', touchType: 'link' },
{ id: 2, cardId: 10, language: 'en-US', touchType: 'link' },
{ id: 3, cardId: 10, language: 'ar-SA', touchType: 'qr' },
];
const languages = await radarService.getLanguageStats(10);
expect(languages).toHaveLength(2);
const byLang = Object.fromEntries(languages.map((l) => [l.language, l]));
expect(byLang['en-US'].count).toBe(2);
expect(byLang['en-US'].percentage).toBeCloseTo(66.7, 0);
expect(byLang['ar-SA'].count).toBe(1);
expect(byLang['ar-SA'].percentage).toBeCloseTo(33.3, 0);
});
});

查看文件

@@ -180,11 +180,10 @@ describe('Point 09: 全球访客雷达热力分布与大屏服务 (radar-map.tes
});
describe('3. 终端渠道分析 (getDeviceStats)', () => {
it('空数据时应安全返回包含基础渠道为 0 的统计', async () => {
it('空数据时应安全返回空数组 []', 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);
expect(result).toEqual([]);
});
it('应准确按 touchType (nfc, qr, link) 计算数量与占比', async () => {

查看文件

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

查看文件

@@ -0,0 +1,141 @@
import axios from 'axios';
import { CoolCommException } from '@cool-midway/core';
import { CrmSalesforceService } from '../../../src/modules/crm/service/salesforce';
describe('CrmSalesforceService.createLead', () => {
let service: CrmSalesforceService;
const validPayload = {
FirstName: 'Jane',
LastName: 'Doe',
Email: 'jane@acme.com',
Company: 'Acme',
Phone: '+15551234567',
Status: 'Open - Not Contacted',
};
const validInstanceUrl = 'https://myorg.my.salesforce.com';
beforeEach(() => {
service = new CrmSalesforceService();
jest.clearAllMocks();
});
afterEach(() => {
jest.restoreAllMocks();
});
it('accessToken 为空时应抛出 CoolCommException,说明未配置 Salesforce', async () => {
const postSpy = jest.spyOn(axios, 'post');
await expect(
service.createLead('', validInstanceUrl, validPayload)
).rejects.toThrow(CoolCommException);
await expect(
service.createLead(' ', validInstanceUrl, validPayload)
).rejects.toThrow(/Salesforce/);
expect(postSpy).not.toHaveBeenCalled();
});
it('instanceUrl 为空时应抛出 CoolCommException,说明未配置 Salesforce', async () => {
const postSpy = jest.spyOn(axios, 'post');
await expect(
service.createLead('sf-token', '', validPayload)
).rejects.toThrow(CoolCommException);
await expect(
service.createLead('sf-token', ' ', validPayload)
).rejects.toThrow(/Salesforce/);
expect(postSpy).not.toHaveBeenCalled();
});
it('非法 instanceUrl(非 https 或主机名不符)应抛错且不发请求', async () => {
const postSpy = jest.spyOn(axios, 'post');
await expect(
service.createLead('sf-token', 'http://myorg.my.salesforce.com', validPayload)
).rejects.toThrow(CoolCommException);
await expect(
service.createLead('sf-token', 'https://evil.example.com', validPayload)
).rejects.toThrow(CoolCommException);
await expect(
service.createLead('sf-token', 'https://127.0.0.1', validPayload)
).rejects.toThrow(CoolCommException);
expect(postSpy).not.toHaveBeenCalled();
});
it('有效时应向 Salesforce Lead API 发起 Bearer 请求并返回 id 与原始响应', async () => {
const token = 'sf-access-token-xyz';
const raw = { id: '00Qxx000001Lead', success: true, errors: [] };
const postSpy = jest.spyOn(axios, 'post').mockResolvedValueOnce({
status: 201,
data: raw,
});
const result = await service.createLead(token, validInstanceUrl, validPayload);
expect(postSpy).toHaveBeenCalledTimes(1);
const [url, body, config] = postSpy.mock.calls[0];
expect(url).toBe(
`${validInstanceUrl}/services/data/v59.0/sobjects/Lead`
);
expect(body).toEqual(validPayload);
expect(config?.headers?.Authorization).toBe(`Bearer ${token}`);
expect(config?.timeout).toBe(8000);
expect(result.id).toBe('00Qxx000001Lead');
expect(result.raw).toEqual(raw);
});
it('force.com 主机名应被允许', async () => {
const instanceUrl = 'https://na1.salesforce.com';
const forceUrl = 'https://custom.force.com';
const token = 'sf-token';
const raw = { id: '00Qxx000002', success: true };
const postSpy = jest
.spyOn(axios, 'post')
.mockResolvedValueOnce({ status: 201, data: raw })
.mockResolvedValueOnce({ status: 201, data: raw });
await service.createLead(token, instanceUrl, validPayload);
await service.createLead(token, forceUrl, validPayload);
expect(postSpy).toHaveBeenCalledTimes(2);
expect(postSpy.mock.calls[0][0]).toBe(
`${instanceUrl}/services/data/v59.0/sobjects/Lead`
);
expect(postSpy.mock.calls[1][0]).toBe(
`${forceUrl}/services/data/v59.0/sobjects/Lead`
);
});
it('对方返回 4xx 时应抛出包含状态码与错误信息的异常,不返回 success:true', async () => {
const axiosError = Object.assign(new Error('Request failed with status code 400'), {
isAxiosError: true,
response: {
status: 400,
data: [
{
message: 'Required fields are missing: [LastName]',
errorCode: 'REQUIRED_FIELD_MISSING',
},
],
},
});
jest.spyOn(axios, 'post').mockRejectedValueOnce(axiosError);
await expect(
service.createLead('sf-valid', validInstanceUrl, validPayload)
).rejects.toThrow(/400|Required fields are missing/);
});
});

查看文件

@@ -221,12 +221,162 @@ describe('Point 11: 国际主流 CRM (HubSpot / Salesforce / Webhook) 对接服
syncService.sendWebhook('', 'secret', mockLead)
).rejects.toThrow(CoolCommException);
});
it.each([
'http://localhost/hook',
'http://127.0.0.1:9000/hook',
'http://10.1.2.3/hook',
'http://172.16.0.1/hook',
'http://172.31.255.1/hook',
'http://192.168.1.10/hook',
'ftp://hooks.example.com/crm',
'file:///etc/passwd',
])('拒绝内网或非 http(s) URL:%s,不发请求且不记成功', async url => {
const postSpy = jest.spyOn(axios, 'post');
await expect(
syncService.sendWebhook(url, 'wh_secret', mockLead, { delayMs: 1 })
).rejects.toThrow(CoolCommException);
expect(postSpy).not.toHaveBeenCalled();
});
it('公网 https URL 仍应发送并附带 X-OmniCard-Signature', async () => {
const postSpy = jest.spyOn(axios, 'post').mockResolvedValueOnce({
status: 200,
data: { ok: true },
});
const secret = 'wh_secret_public';
const url = 'https://hooks.example.com/crm/lead';
const result = await syncService.sendWebhook(url, secret, mockLead, {
delayMs: 1,
});
expect(result.success).toBe(true);
expect(postSpy).toHaveBeenCalledTimes(1);
const [, payload, config] = postSpy.mock.calls[0];
expect(config?.headers?.['X-OmniCard-Signature']).toBe(
syncService.generateSignature(payload, secret)
);
});
});
// ==========================================
// 5. 控制器接口集成 (/admin/crm/lead/sync-external)
// 5. Slack / 飞书:组包 ≠ 已送达 (delivered)
// ==========================================
describe('5. 控制器接口 /admin/crm/lead/sync-external 集成测试', () => {
describe('5. Slack / 飞书组包与送达标记', () => {
it('formatToSlack 仅组包,不发起 HTTP', () => {
const postSpy = jest.spyOn(axios, 'post');
const payload = syncService.formatToSlack(mockLead);
expect(payload).toMatchObject({
text: expect.stringContaining('John Doe'),
});
expect(payload.text).toContain('Acme Global Corp');
expect(postSpy).not.toHaveBeenCalled();
});
it('formatToFeishu 仅组包,不发起 HTTP', () => {
const postSpy = jest.spyOn(axios, 'post');
const payload = syncService.formatToFeishu(mockLead);
expect(payload.msg_type).toBe('text');
expect(payload.content?.text).toContain('John Doe');
expect(payload.content?.text).toContain('Acme Global Corp');
expect(postSpy).not.toHaveBeenCalled();
});
it('sendSlack 无 Webhook URL 时只组包,delivered 必须为 false', async () => {
const postSpy = jest.spyOn(axios, 'post');
const result = await syncService.sendSlack(undefined, mockLead);
expect(result.delivered).toBe(false);
expect(result.channel).toBe('slack');
expect(result.payload).toBeDefined();
expect(result.reason).toBeTruthy();
expect(postSpy).not.toHaveBeenCalled();
});
it('sendFeishu 空 URL 时只组包,delivered 必须为 false', async () => {
const postSpy = jest.spyOn(axios, 'post');
const result = await syncService.sendFeishu('', mockLead);
expect(result.delivered).toBe(false);
expect(result.channel).toBe('feishu');
expect(result.payload).toBeDefined();
expect(result.reason).toBeTruthy();
expect(postSpy).not.toHaveBeenCalled();
});
it('sendSlack 内网 URL 在发请求前抛 CoolCommException', async () => {
const postSpy = jest.spyOn(axios, 'post');
await expect(
syncService.sendSlack('http://192.168.1.10/slack', mockLead)
).rejects.toThrow(CoolCommException);
expect(postSpy).not.toHaveBeenCalled();
});
it('sendFeishu 内网 URL 在发请求前抛 CoolCommException', async () => {
const postSpy = jest.spyOn(axios, 'post');
await expect(
syncService.sendFeishu('http://10.0.0.8/feishu', mockLead)
).rejects.toThrow(CoolCommException);
expect(postSpy).not.toHaveBeenCalled();
});
it('sendSlack 公网 HTTP 2xx 才 delivered:true', async () => {
const postSpy = jest.spyOn(axios, 'post').mockResolvedValueOnce({
status: 200,
data: { ok: true },
});
const result = await syncService.sendSlack(
'https://hooks.slack.com/services/T00/B00/xxx',
mockLead,
{ delayMs: 1 }
);
expect(result.delivered).toBe(true);
expect(result.channel).toBe('slack');
expect(postSpy).toHaveBeenCalledTimes(1);
const [, body] = postSpy.mock.calls[0];
expect(body).toMatchObject({ text: expect.any(String) });
});
it('sendFeishu 公网 HTTP 非 2xx / 失败时 delivered:false,不伪造成功', async () => {
jest.spyOn(axios, 'post').mockRejectedValue(new Error('502 Bad Gateway'));
const result = await syncService.sendFeishu(
'https://open.feishu.cn/open-apis/bot/v2/hook/public-demo',
mockLead,
{ delayMs: 1, maxRetries: 1 }
);
expect(result.delivered).toBe(false);
expect(result.channel).toBe('feishu');
expect(result.error).toBeTruthy();
});
it('组包成功不得等同于 delivered:true(sendSlack / sendFeishu 无 URL)', async () => {
const slack = await syncService.sendSlack(undefined, mockLead);
const feishu = await syncService.sendFeishu(undefined, mockLead);
expect(slack.delivered).toBe(false);
expect(feishu.delivered).toBe(false);
expect(slack.payload).toBeDefined();
expect(feishu.payload).toBeDefined();
});
});
// ==========================================
// 6. 控制器接口集成 (/admin/crm/lead/sync-external)
// ==========================================
describe('6. 控制器接口 /admin/crm/lead/sync-external 集成测试', () => {
let controller: AdminCrmLeadController;
let mockLeadRepo: { findOne: jest.Mock };
@@ -285,6 +435,8 @@ describe('Point 11: 国际主流 CRM (HubSpot / Salesforce / Webhook) 对接服
data: {
target: 'hubspot',
leadId: 101,
synced: false,
reason: '未配置 HubSpot',
payload: {
properties: {
email: 'john.doe@acme.com',
@@ -313,6 +465,8 @@ describe('Point 11: 国际主流 CRM (HubSpot / Salesforce / Webhook) 对接服
data: {
target: 'salesforce',
leadId: 101,
synced: false,
reason: '未配置 Salesforce',
payload: {
FirstName: 'John',
LastName: 'Doe',

查看文件

@@ -117,7 +117,7 @@ describe('商务概览工作台聚合 (getWorkplaceOverview)', () => {
find: jest.fn().mockResolvedValue(leads),
} as never;
const result = await service.getWorkplaceOverview({ feedLimit: 8 });
const result = await service.getWorkplaceOverview({ feedLimit: 8, now: today });
expect(result.kpis.tapCount).toBe(1);
expect(result.kpis.newLeadCount).toBe(1);
expect(result.radarFeed[0].viewedBlocks).toEqual(['hero', 'cases']);