feat(crm): 完成访客雷达足迹流水实体、埋点服务与免登录控制器 (Task 11)
这个提交包含在:
@@ -0,0 +1,14 @@
|
||||
import { ModuleConfig } from '@cool-midway/core';
|
||||
|
||||
/**
|
||||
* 潜客线索与 CRM 模块配置
|
||||
*/
|
||||
export default () => {
|
||||
return {
|
||||
name: '潜客线索与 CRM 模块',
|
||||
description: '名片互换留资、访客行为意向度评分引擎与线索生命周期流转',
|
||||
middlewares: [],
|
||||
globalMiddlewares: [],
|
||||
order: 20,
|
||||
} as ModuleConfig;
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Body, Inject, Post, Provide } from '@midwayjs/core';
|
||||
import {
|
||||
BaseController,
|
||||
CoolController,
|
||||
CoolTag,
|
||||
TagTypes,
|
||||
} from '@cool-midway/core';
|
||||
import { CrmLeadService, SubmitLeadDTO } from '../../service/lead';
|
||||
|
||||
/**
|
||||
* 访客端公开免登录留资互换控制器
|
||||
* 路由前缀: /open/crm/lead
|
||||
*/
|
||||
@Provide()
|
||||
@CoolController('/open/crm/lead')
|
||||
export class OpenCrmLeadController extends BaseController {
|
||||
@Inject()
|
||||
crmLeadService: CrmLeadService;
|
||||
|
||||
/**
|
||||
* 访客名片互换与留资沉淀
|
||||
* 接口: POST /open/crm/lead/exchange
|
||||
*/
|
||||
@CoolTag(TagTypes.IGNORE_TOKEN)
|
||||
@Post('/exchange', { summary: '公开名片互换留资' })
|
||||
async exchange(@Body() body: SubmitLeadDTO) {
|
||||
try {
|
||||
const data = await this.crmLeadService.submitLead(body);
|
||||
return this.ok(data);
|
||||
} catch (err: any) {
|
||||
return this.fail(
|
||||
err?.message || '提交留资信息失败',
|
||||
err?.status || 1001
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Body, Get, Inject, Post, Provide, Query } from '@midwayjs/core';
|
||||
import {
|
||||
BaseController,
|
||||
CoolController,
|
||||
CoolTag,
|
||||
TagTypes,
|
||||
} from '@cool-midway/core';
|
||||
import { CrmRadarService, RecordTrackDTO } from '../../service/radar';
|
||||
|
||||
/**
|
||||
* 访客端公开免登录雷达埋点控制器
|
||||
* 路由前缀: /open/crm/radar
|
||||
*/
|
||||
@Provide()
|
||||
@CoolController('/open/crm/radar')
|
||||
export class OpenCrmRadarController extends BaseController {
|
||||
@Inject()
|
||||
crmRadarService: CrmRadarService;
|
||||
|
||||
/**
|
||||
* 访客足迹埋点上报
|
||||
* 接口: POST /open/crm/radar/track
|
||||
*/
|
||||
@CoolTag(TagTypes.IGNORE_TOKEN)
|
||||
@Post('/track', { summary: '访客足迹埋点上报' })
|
||||
async track(@Body() body: RecordTrackDTO) {
|
||||
try {
|
||||
const data = await this.crmRadarService.recordTrack(body);
|
||||
return this.ok(data);
|
||||
} catch (err: any) {
|
||||
return this.fail(
|
||||
err?.message || '记录访客足迹失败',
|
||||
err?.statusCode || err?.status || 1001
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询名片最近访客足迹流水
|
||||
* 接口: GET /open/crm/radar/recent
|
||||
*/
|
||||
@CoolTag(TagTypes.IGNORE_TOKEN)
|
||||
@Get('/recent', { summary: '查询名片最近足迹流水' })
|
||||
async recent(
|
||||
@Query('cardId') cardId: number,
|
||||
@Query('limit') limit?: number
|
||||
) {
|
||||
try {
|
||||
const data = await this.crmRadarService.getRecentTracks(cardId, limit);
|
||||
return this.ok(data);
|
||||
} catch (err: any) {
|
||||
return this.fail(
|
||||
err?.message || '获取访客足迹失败',
|
||||
err?.statusCode || err?.status || 1001
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { BaseEntity } from '../../../comm/entity/base';
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
import {
|
||||
LeadEntity as ILeadEntity,
|
||||
LeadSource,
|
||||
LeadStatus,
|
||||
} from '../../../comm/types/omni';
|
||||
|
||||
/**
|
||||
* 潜客线索实体
|
||||
* 表名: crm_lead
|
||||
*/
|
||||
@Entity('crm_lead')
|
||||
export class CrmLeadEntity extends BaseEntity implements ILeadEntity {
|
||||
@Index()
|
||||
@Column({
|
||||
name: 'card_id',
|
||||
comment: '来源名片 ID',
|
||||
})
|
||||
cardId: number;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
name: 'visitor_id',
|
||||
comment: '访客唯一追踪指纹 ID',
|
||||
length: 64,
|
||||
nullable: true,
|
||||
})
|
||||
visitorId: string;
|
||||
|
||||
@Column({
|
||||
name: 'name',
|
||||
comment: '客户姓名',
|
||||
length: 100,
|
||||
})
|
||||
name: string;
|
||||
|
||||
@Column({
|
||||
name: 'company',
|
||||
comment: '客户所属公司',
|
||||
length: 150,
|
||||
nullable: true,
|
||||
})
|
||||
company?: string;
|
||||
|
||||
@Column({
|
||||
name: 'phone',
|
||||
comment: '联系电话',
|
||||
length: 50,
|
||||
nullable: true,
|
||||
})
|
||||
phone?: string;
|
||||
|
||||
@Column({
|
||||
name: 'email',
|
||||
comment: '电子邮箱',
|
||||
length: 100,
|
||||
nullable: true,
|
||||
})
|
||||
email?: string;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
name: 'status',
|
||||
comment: '跟进状态 (new: 新线索, contacted: 已联系, hot: 高意向, won: 赢单, archived: 归档)',
|
||||
length: 20,
|
||||
default: 'new',
|
||||
})
|
||||
status: LeadStatus;
|
||||
|
||||
@Column({
|
||||
name: 'intent_score',
|
||||
comment: '意向度评分 (0 - 100)',
|
||||
type: 'int',
|
||||
default: 0,
|
||||
})
|
||||
intentScore: number;
|
||||
|
||||
@Column({
|
||||
name: 'source',
|
||||
comment: '触达来源渠道 (nfc, qr, link, ai_chat)',
|
||||
length: 20,
|
||||
default: 'link',
|
||||
})
|
||||
source: LeadSource;
|
||||
|
||||
@Column({
|
||||
name: 'notes',
|
||||
comment: '跟进备注与需求摘要',
|
||||
type: 'text',
|
||||
nullable: true,
|
||||
})
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export { CrmLeadEntity as LeadInfoEntity, CrmLeadEntity as LeadEntity };
|
||||
@@ -0,0 +1,99 @@
|
||||
import { BaseEntity, transformerJson } from '../../../comm/entity/base';
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
import { RadarTrack, TouchType } from '../../../comm/types/omni';
|
||||
|
||||
/**
|
||||
* 实时访客足迹实体
|
||||
* 表名: crm_track
|
||||
*/
|
||||
@Entity('crm_track')
|
||||
export class CrmTrackEntity extends BaseEntity implements RadarTrack {
|
||||
@Index()
|
||||
@Column({
|
||||
name: 'card_id',
|
||||
comment: '访问的目标名片 ID',
|
||||
type: 'bigint',
|
||||
})
|
||||
cardId: number;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
name: 'visitor_id',
|
||||
comment: '访客唯一追踪指纹 ID',
|
||||
length: 64,
|
||||
nullable: true,
|
||||
})
|
||||
visitorId: string;
|
||||
|
||||
@Column({
|
||||
name: 'ip',
|
||||
comment: '访客公网 IP 地址',
|
||||
length: 64,
|
||||
nullable: true,
|
||||
})
|
||||
ip: string;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
name: 'country',
|
||||
comment: '访客所在国家/地区',
|
||||
length: 100,
|
||||
nullable: true,
|
||||
})
|
||||
country: string;
|
||||
|
||||
@Column({
|
||||
name: 'city',
|
||||
comment: '访客所在城市',
|
||||
length: 100,
|
||||
nullable: true,
|
||||
})
|
||||
city: string;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
name: 'touch_type',
|
||||
comment: '触碰方式途径 (nfc, qr, link)',
|
||||
length: 20,
|
||||
default: 'link',
|
||||
})
|
||||
touchType: TouchType;
|
||||
|
||||
@Column({
|
||||
name: 'stay_duration_ms',
|
||||
comment: '页面停留时长 (毫秒)',
|
||||
type: 'int',
|
||||
default: 0,
|
||||
})
|
||||
stayDurationMs: number;
|
||||
|
||||
@Column({
|
||||
name: 'scroll_depth',
|
||||
comment: '页面最大滚动深度百分比 (0 - 100)',
|
||||
type: 'int',
|
||||
default: 0,
|
||||
})
|
||||
scrollDepth: number;
|
||||
|
||||
@Column({
|
||||
name: 'viewed_blocks',
|
||||
comment: '浏览过的 Bento 组件块标识列表',
|
||||
type: 'json',
|
||||
nullable: true,
|
||||
transformer: transformerJson,
|
||||
})
|
||||
viewedBlocks: string[];
|
||||
|
||||
/**
|
||||
* 映射至 BaseEntity 的 createTime,实现 RadarTrack 契约
|
||||
*/
|
||||
get createdAt(): Date | string {
|
||||
return this.createTime;
|
||||
}
|
||||
|
||||
set createdAt(val: Date | string) {
|
||||
this.createTime = val as any;
|
||||
}
|
||||
}
|
||||
|
||||
export { CrmTrackEntity as RadarTrackEntity, CrmTrackEntity as TrackEntity };
|
||||
@@ -0,0 +1,176 @@
|
||||
import { Provide } from '@midwayjs/core';
|
||||
import { BaseService, CoolCommException } from '@cool-midway/core';
|
||||
import { InjectEntityModel } from '@midwayjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { CrmLeadEntity } from '../entity/lead';
|
||||
import { LeadSource, LeadStatus } from '../../../comm/types/omni';
|
||||
|
||||
/**
|
||||
* 意向度评分计算结果接口
|
||||
*/
|
||||
export interface IntentScoreResult {
|
||||
score: number;
|
||||
level: 'low' | 'medium' | 'hot';
|
||||
}
|
||||
|
||||
/**
|
||||
* 留资入参 DTO
|
||||
*/
|
||||
export interface SubmitLeadDTO {
|
||||
cardId: number | string;
|
||||
visitorId?: string;
|
||||
name: string;
|
||||
company?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
status?: LeadStatus;
|
||||
intentScore?: number;
|
||||
source?: LeadSource;
|
||||
notes?: string;
|
||||
stayDurationMs?: number;
|
||||
scrollDepth?: number;
|
||||
blockCount?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 潜客线索服务
|
||||
* 负责线索沉淀、多维行为意向度打标与跟进流转
|
||||
*/
|
||||
@Provide()
|
||||
export class CrmLeadService extends BaseService {
|
||||
@InjectEntityModel(CrmLeadEntity)
|
||||
crmLeadEntity: Repository<CrmLeadEntity>;
|
||||
|
||||
/**
|
||||
* 智能意向度评分计算引擎
|
||||
* 根据停留时长、滚动深度、浏览模块数加权综合评分 (0 - 100 分)
|
||||
*
|
||||
* 权重分配:
|
||||
* - 停留时长: 权重 40 分 (满 60 秒拿满分)
|
||||
* - 滚动深度: 权重 30 分 (100% 滚动拿满分)
|
||||
* - 模块浏览: 权重 30 分 (浏览 5 个及以上模块拿满分)
|
||||
*
|
||||
* 等级划分:
|
||||
* - score >= 75: 'hot' (高意向/火热)
|
||||
* - 40 <= score < 75: 'medium' (中意向/温和)
|
||||
* - score < 40: 'low' (低意向/冷淡)
|
||||
*/
|
||||
calculateIntentScore(
|
||||
stayDurationMs = 0,
|
||||
scrollDepth = 0,
|
||||
blockCount = 0
|
||||
): IntentScoreResult {
|
||||
const safeStay = Math.max(0, stayDurationMs);
|
||||
const safeScroll = Math.max(0, scrollDepth);
|
||||
const safeBlocks = Math.max(0, blockCount);
|
||||
|
||||
// 1. 停留时长得分 (0 - 40)
|
||||
const stayScore = Math.min(40, Math.round((safeStay / 60000) * 40));
|
||||
|
||||
// 2. 滚动深度得分 (0 - 30)
|
||||
const scrollScore = Math.min(30, Math.round((safeScroll / 100) * 30));
|
||||
|
||||
// 3. 模块浏览得分 (0 - 30)
|
||||
const blockScore = Math.min(30, safeBlocks * 6);
|
||||
|
||||
// 4. 加权总分
|
||||
const score = Math.min(100, Math.max(0, stayScore + scrollScore + blockScore));
|
||||
|
||||
// 5. 自动判定意向度等级
|
||||
let level: 'low' | 'medium' | 'hot' = 'low';
|
||||
if (score >= 75) {
|
||||
level = 'hot';
|
||||
} else if (score >= 40) {
|
||||
level = 'medium';
|
||||
}
|
||||
|
||||
return { score, level };
|
||||
}
|
||||
|
||||
/**
|
||||
* 潜客互换名片留资沉淀
|
||||
*/
|
||||
async submitLead(data: SubmitLeadDTO): Promise<CrmLeadEntity> {
|
||||
if (!data || !data.cardId) {
|
||||
throw new CoolCommException('缺少名片标识 cardId', 400);
|
||||
}
|
||||
|
||||
const name = data.name ? String(data.name).trim() : '';
|
||||
if (!name) {
|
||||
throw new CoolCommException('缺少客户姓名 name', 400);
|
||||
}
|
||||
|
||||
const phone = data.phone ? String(data.phone).trim() : '';
|
||||
const email = data.email ? String(data.email).trim() : '';
|
||||
if (!phone && !email) {
|
||||
throw new CoolCommException('请至少填写手机号或电子邮箱等联系方式', 400);
|
||||
}
|
||||
|
||||
// 计算或采用意向度评分
|
||||
let intentScore = data.intentScore ?? 0;
|
||||
if (
|
||||
data.intentScore === undefined &&
|
||||
(data.stayDurationMs !== undefined ||
|
||||
data.scrollDepth !== undefined ||
|
||||
data.blockCount !== undefined)
|
||||
) {
|
||||
const calculated = this.calculateIntentScore(
|
||||
data.stayDurationMs,
|
||||
data.scrollDepth,
|
||||
data.blockCount
|
||||
);
|
||||
intentScore = calculated.score;
|
||||
}
|
||||
|
||||
// 判定初始状态
|
||||
let status: LeadStatus = data.status || 'new';
|
||||
if (!data.status && intentScore >= 75) {
|
||||
status = 'hot';
|
||||
}
|
||||
|
||||
const lead = new CrmLeadEntity();
|
||||
lead.cardId = Number(data.cardId);
|
||||
lead.visitorId = data.visitorId || '';
|
||||
lead.name = name;
|
||||
lead.company = data.company ? String(data.company).trim() : undefined;
|
||||
lead.phone = phone || undefined;
|
||||
lead.email = email || undefined;
|
||||
lead.status = status;
|
||||
lead.intentScore = intentScore;
|
||||
lead.source = data.source || 'link';
|
||||
lead.notes = data.notes;
|
||||
|
||||
return await this.crmLeadEntity.save(lead);
|
||||
}
|
||||
|
||||
/**
|
||||
* 线索跟进状态生命周期流转
|
||||
*/
|
||||
async updateStatus(
|
||||
id: number | string,
|
||||
status: LeadStatus
|
||||
): Promise<CrmLeadEntity> {
|
||||
const validStatuses: LeadStatus[] = [
|
||||
'new',
|
||||
'contacted',
|
||||
'hot',
|
||||
'won',
|
||||
'archived',
|
||||
];
|
||||
|
||||
if (!validStatuses.includes(status)) {
|
||||
throw new CoolCommException('非法状态值', 400);
|
||||
}
|
||||
|
||||
const lead = await this.crmLeadEntity.findOne({
|
||||
where: { id: Number(id) },
|
||||
});
|
||||
|
||||
if (!lead) {
|
||||
throw new CoolCommException('潜客线索不存在', 404);
|
||||
}
|
||||
|
||||
lead.status = status;
|
||||
return await this.crmLeadEntity.save(lead);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { Inject, Provide } from '@midwayjs/core';
|
||||
import { BaseService, CoolCommException } from '@cool-midway/core';
|
||||
import { InjectEntityModel } from '@midwayjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { CrmTrackEntity } from '../entity/track';
|
||||
import { TouchType } from '../../../comm/types/omni';
|
||||
|
||||
/**
|
||||
* 访客足迹上报 DTO
|
||||
*/
|
||||
export interface RecordTrackDTO {
|
||||
cardId: number | string;
|
||||
visitorId?: string;
|
||||
ip?: string;
|
||||
country?: string;
|
||||
city?: string;
|
||||
touchType?: TouchType;
|
||||
stayDurationMs?: number;
|
||||
scrollDepth?: number;
|
||||
viewedBlocks?: string[] | string;
|
||||
tenantId?: number | string;
|
||||
}
|
||||
|
||||
/**
|
||||
* IP 地理位置解析结果
|
||||
*/
|
||||
export interface IpLocationResult {
|
||||
country: string;
|
||||
city: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 访客雷达与轨迹流水服务
|
||||
*/
|
||||
@Provide()
|
||||
export class CrmRadarService extends BaseService {
|
||||
@InjectEntityModel(CrmTrackEntity)
|
||||
crmTrackEntity: Repository<CrmTrackEntity>;
|
||||
|
||||
@Inject()
|
||||
ctx: any;
|
||||
|
||||
/**
|
||||
* 轻量高效 IP 地理位置解析机制
|
||||
* 支持 Cloudflare/CDN 请求头直通与内网/本地安全识别
|
||||
*/
|
||||
parseIpLocation(
|
||||
ip?: string,
|
||||
headers?: Record<string, any>
|
||||
): IpLocationResult {
|
||||
const rawHeaders = headers || (this.ctx && this.ctx.headers) || {};
|
||||
|
||||
// 1. 优先提取出海 CDN (如 Cloudflare) 携带的高可靠地理位置信息
|
||||
const cdnCountry =
|
||||
rawHeaders['cf-ipcountry'] ||
|
||||
rawHeaders['x-country'] ||
|
||||
rawHeaders['geoip-country-code'];
|
||||
const cdnCity =
|
||||
rawHeaders['cf-ipcity'] ||
|
||||
rawHeaders['x-city'] ||
|
||||
rawHeaders['geoip-city'];
|
||||
|
||||
if (cdnCountry) {
|
||||
return {
|
||||
country: String(cdnCountry).trim(),
|
||||
city: cdnCity ? String(cdnCity).trim() : 'Unknown',
|
||||
};
|
||||
}
|
||||
|
||||
const cleanIp = (ip || '').trim().replace(/^::ffff:/, '');
|
||||
|
||||
// 2. 本地回环与内网私有地址识别
|
||||
if (
|
||||
!cleanIp ||
|
||||
cleanIp === '127.0.0.1' ||
|
||||
cleanIp === '::1' ||
|
||||
cleanIp.toLowerCase() === 'localhost' ||
|
||||
cleanIp.startsWith('192.168.') ||
|
||||
cleanIp.startsWith('10.') ||
|
||||
/^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(cleanIp)
|
||||
) {
|
||||
return { country: 'Local', city: 'Local' };
|
||||
}
|
||||
|
||||
// 3. 公网未知 IP 兜底返回
|
||||
return { country: 'Unknown', city: 'Unknown' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录访客轨迹并解析地理位置
|
||||
*/
|
||||
async recordTrack(data: RecordTrackDTO): Promise<CrmTrackEntity> {
|
||||
if (!data || !data.cardId) {
|
||||
throw new CoolCommException('缺少名片标识 cardId', 400);
|
||||
}
|
||||
|
||||
// 提取客户端 IP (入参 > 上下文 IP > 默认空)
|
||||
const clientIp =
|
||||
data.ip ||
|
||||
(this.ctx && this.ctx.ip) ||
|
||||
(this.ctx &&
|
||||
this.ctx.headers &&
|
||||
this.ctx.headers['x-forwarded-for']?.split(',')[0]?.trim()) ||
|
||||
'';
|
||||
|
||||
// 解析地理位置 (入参优先,缺失则自动解析)
|
||||
let country = data.country;
|
||||
let city = data.city;
|
||||
if (!country || !city) {
|
||||
const parsed = this.parseIpLocation(clientIp, this.ctx?.headers);
|
||||
country = country || parsed.country;
|
||||
city = city || parsed.city;
|
||||
}
|
||||
|
||||
// 触碰渠道校验与兜底
|
||||
const validTouchTypes: TouchType[] = ['nfc', 'qr', 'link'];
|
||||
const touchType: TouchType =
|
||||
data.touchType && validTouchTypes.includes(data.touchType)
|
||||
? data.touchType
|
||||
: 'link';
|
||||
|
||||
// 行为指标纠偏 (停留时长非负,滚动深度 0-100)
|
||||
const stayDurationMs = Math.max(0, Math.round(data.stayDurationMs || 0));
|
||||
const scrollDepth = Math.min(
|
||||
100,
|
||||
Math.max(0, Math.round(data.scrollDepth || 0))
|
||||
);
|
||||
|
||||
// 浏览组件块容错与解析
|
||||
let viewedBlocks: string[] = [];
|
||||
if (Array.isArray(data.viewedBlocks)) {
|
||||
viewedBlocks = data.viewedBlocks.map(String);
|
||||
} else if (typeof data.viewedBlocks === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(data.viewedBlocks);
|
||||
viewedBlocks = Array.isArray(parsed) ? parsed.map(String) : [];
|
||||
} catch {
|
||||
viewedBlocks = [data.viewedBlocks];
|
||||
}
|
||||
}
|
||||
|
||||
// 访客指纹兜底生成
|
||||
const visitorId =
|
||||
data.visitorId ||
|
||||
`vis_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
const track = new CrmTrackEntity();
|
||||
track.cardId = Number(data.cardId);
|
||||
track.visitorId = visitorId;
|
||||
track.ip = clientIp;
|
||||
track.country = country;
|
||||
track.city = city;
|
||||
track.touchType = touchType;
|
||||
track.stayDurationMs = stayDurationMs;
|
||||
track.scrollDepth = scrollDepth;
|
||||
track.viewedBlocks = viewedBlocks;
|
||||
if (data.tenantId) {
|
||||
track.tenantId = Number(data.tenantId);
|
||||
}
|
||||
|
||||
return await this.crmTrackEntity.save(track);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取某名片的最近足迹流水
|
||||
*/
|
||||
async getRecentTracks(
|
||||
cardId: number | string,
|
||||
limit = 20
|
||||
): Promise<CrmTrackEntity[]> {
|
||||
if (!cardId) {
|
||||
throw new CoolCommException('缺少名片标识 cardId', 400);
|
||||
}
|
||||
|
||||
// limit 范围限制在 1 ~ 100
|
||||
const parsed = Number(limit);
|
||||
const rawLimit = Number.isFinite(parsed) ? parsed : 20;
|
||||
const take = Math.min(100, Math.max(1, Math.round(rawLimit)));
|
||||
|
||||
return await this.crmTrackEntity.find({
|
||||
where: { cardId: Number(cardId) },
|
||||
order: { id: 'DESC' } as any,
|
||||
take,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
import { getMetadataArgsStorage } from 'typeorm';
|
||||
import { BaseEntity } from '../../../src/comm/entity/base';
|
||||
import { CrmLeadEntity } from '../../../src/modules/crm/entity/lead';
|
||||
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';
|
||||
|
||||
describe('Task 12: 后端潜客线索模块 (modules/crm) 单元测试', () => {
|
||||
const metadata = getMetadataArgsStorage();
|
||||
|
||||
// ==========================================
|
||||
// 1. 实体与契约测试 (Lead Entity & BaseEntity)
|
||||
// ==========================================
|
||||
describe('1. 潜客线索实体定义与数据库映射规范 (CrmLeadEntity)', () => {
|
||||
it('实体表名必须映射为 crm_lead', () => {
|
||||
const table = metadata.tables.find(t => t.target === CrmLeadEntity);
|
||||
expect(table).toBeDefined();
|
||||
expect(table?.name).toBe('crm_lead');
|
||||
});
|
||||
|
||||
it('实体必须继承自 BaseEntity,具备底层租户隔离与审计能力', () => {
|
||||
expect(CrmLeadEntity.prototype instanceof BaseEntity).toBe(true);
|
||||
});
|
||||
|
||||
it('所有实体属性与数据库列必须遵循驼峰至下划线映射', () => {
|
||||
const columns = metadata.columns.filter(c => c.target === CrmLeadEntity);
|
||||
const expectedColumnMappings: Record<string, string> = {
|
||||
cardId: 'card_id',
|
||||
visitorId: 'visitor_id',
|
||||
name: 'name',
|
||||
company: 'company',
|
||||
phone: 'phone',
|
||||
email: 'email',
|
||||
status: 'status',
|
||||
intentScore: 'intent_score',
|
||||
source: 'source',
|
||||
notes: 'notes',
|
||||
};
|
||||
|
||||
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, status 应声明索引', () => {
|
||||
const indexes = metadata.indices.filter(i => i.target === CrmLeadEntity);
|
||||
const indexProps = indexes.flatMap(i =>
|
||||
Array.isArray(i.columns) ? i.columns : []
|
||||
);
|
||||
|
||||
expect(indexProps).toContain('cardId');
|
||||
expect(indexProps).toContain('visitorId');
|
||||
expect(indexProps).toContain('status');
|
||||
});
|
||||
|
||||
it('默认值应符合业务规范: status=\'new\', intentScore=0, source=\'link\'', () => {
|
||||
const columns = metadata.columns.filter(c => c.target === CrmLeadEntity);
|
||||
|
||||
const statusCol = columns.find(c => c.propertyName === 'status');
|
||||
expect(statusCol?.options.default).toBe('new');
|
||||
|
||||
const scoreCol = columns.find(c => c.propertyName === 'intentScore');
|
||||
expect(scoreCol?.options.default).toBe(0);
|
||||
|
||||
const sourceCol = columns.find(c => c.propertyName === 'source');
|
||||
expect(sourceCol?.options.default).toBe('link');
|
||||
});
|
||||
|
||||
it('CrmLeadEntity 实例应完全符合 LeadEntity 契约结构', () => {
|
||||
const lead = new CrmLeadEntity();
|
||||
lead.id = 1;
|
||||
lead.tenantId = 10;
|
||||
lead.cardId = 101;
|
||||
lead.visitorId = 'vis_xyz_789';
|
||||
lead.name = 'John Doe';
|
||||
lead.company = 'Global Tech FZE';
|
||||
lead.phone = '+971-50-1234567';
|
||||
lead.email = 'john@globaltech.ae';
|
||||
lead.status = 'new';
|
||||
lead.intentScore = 85;
|
||||
lead.source = 'nfc';
|
||||
lead.notes = '在迪拜 GITEX 展会展台触碰 NFC 卡留存';
|
||||
|
||||
const contract: LeadEntity = lead;
|
||||
expect(contract.name).toBe('John Doe');
|
||||
expect(contract.status).toBe('new');
|
||||
expect(contract.intentScore).toBe(85);
|
||||
expect(contract.source).toBe('nfc');
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 2. 意向度评分计算引擎测试
|
||||
// ==========================================
|
||||
describe('2. 智能意向度评分计算引擎 (calculateIntentScore)', () => {
|
||||
let leadService: CrmLeadService;
|
||||
|
||||
beforeEach(() => {
|
||||
leadService = new CrmLeadService();
|
||||
});
|
||||
|
||||
it('零行为指标时应输出 0 分且判定等级为 low', () => {
|
||||
const result = leadService.calculateIntentScore(0, 0, 0);
|
||||
expect(result.score).toBe(0);
|
||||
expect(result.level).toBe('low');
|
||||
});
|
||||
|
||||
it('达到满额指标(60秒停留、100% 滚动、5 个模块以上)应获得满分 100 且判定等级为 hot', () => {
|
||||
const result = leadService.calculateIntentScore(60000, 100, 5);
|
||||
expect(result.score).toBe(100);
|
||||
expect(result.level).toBe('hot');
|
||||
});
|
||||
|
||||
it('超额指标(如 120 秒、超界滚动深度等)得分上限应严格限制在 100 分', () => {
|
||||
const result = leadService.calculateIntentScore(120000, 150, 10);
|
||||
expect(result.score).toBe(100);
|
||||
expect(result.level).toBe('hot');
|
||||
});
|
||||
|
||||
it('中等行为指标应正确加权计算并在 40~74 区间内判定等级为 medium', () => {
|
||||
// 停留 30 秒 => 20分,滚动 50% => 15分,浏览 3 个块 => 18分,总计 53分
|
||||
const result = leadService.calculateIntentScore(30000, 50, 3);
|
||||
expect(result.score).toBe(53);
|
||||
expect(result.level).toBe('medium');
|
||||
});
|
||||
|
||||
it('负数或异常参数输入应有防御性兜底,最小分为 0', () => {
|
||||
const result = leadService.calculateIntentScore(-1000, -20, -5);
|
||||
expect(result.score).toBe(0);
|
||||
expect(result.level).toBe('low');
|
||||
});
|
||||
|
||||
it('临界阈值判定: 75 分为 hot 分界线,40 分为 medium 分界线', () => {
|
||||
// 刚好 75 分 => hot
|
||||
// 停留 45 秒 (30分) + 滚动 70% (21分) + 浏览 4 块 (24分) = 75分
|
||||
const hotResult = leadService.calculateIntentScore(45000, 70, 4);
|
||||
expect(hotResult.score).toBe(75);
|
||||
expect(hotResult.level).toBe('hot');
|
||||
|
||||
// 停留 39 分 => low
|
||||
// 停留 30 秒 (20分) + 滚动 30% (9分) + 浏览 1 块 (6分) = 35分
|
||||
const lowResult = leadService.calculateIntentScore(30000, 30, 1);
|
||||
expect(lowResult.score).toBe(35);
|
||||
expect(lowResult.level).toBe('low');
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 3. 线索留资沉淀与流转测试 (submitLead & updateStatus)
|
||||
// ==========================================
|
||||
describe('3. 线索留资沉淀与状态流转业务逻辑 (submitLead & updateStatus)', () => {
|
||||
let leadService: CrmLeadService;
|
||||
let savedLeads: CrmLeadEntity[] = [];
|
||||
let idCounter = 1;
|
||||
|
||||
beforeEach(() => {
|
||||
savedLeads = [];
|
||||
idCounter = 1;
|
||||
|
||||
const mockRepo: any = {
|
||||
save: jest.fn().mockImplementation(async (item: any) => {
|
||||
if (!item.id) {
|
||||
item.id = idCounter++;
|
||||
savedLeads.push(item);
|
||||
} else {
|
||||
const idx = savedLeads.findIndex(l => l.id === item.id);
|
||||
if (idx >= 0) {
|
||||
savedLeads[idx] = { ...savedLeads[idx], ...item };
|
||||
} else {
|
||||
savedLeads.push(item);
|
||||
}
|
||||
}
|
||||
return { ...item };
|
||||
}),
|
||||
findOne: jest.fn().mockImplementation(async (options: any) => {
|
||||
const where = options?.where;
|
||||
if (!where) return null;
|
||||
return savedLeads.find(l => l.id === Number(where.id)) || null;
|
||||
}),
|
||||
};
|
||||
|
||||
leadService = new CrmLeadService();
|
||||
leadService.crmLeadEntity = mockRepo;
|
||||
});
|
||||
|
||||
describe('3.1 submitLead 留资校验与沉淀', () => {
|
||||
it('缺少 cardId 时应拒绝并抛出 400 异常', async () => {
|
||||
await expect(
|
||||
leadService.submitLead({ name: 'Alex', phone: '13800138000' } as any)
|
||||
).rejects.toThrow(CoolCommException);
|
||||
});
|
||||
|
||||
it('缺少姓名 name 时应拒绝并抛出 400 异常', async () => {
|
||||
await expect(
|
||||
leadService.submitLead({ cardId: 101, phone: '13800138000' } as any)
|
||||
).rejects.toThrow(CoolCommException);
|
||||
});
|
||||
|
||||
it('phone 与 email 均为空时应拒绝并抛出 400 异常', async () => {
|
||||
await expect(
|
||||
leadService.submitLead({ cardId: 101, name: 'Alex' } as any)
|
||||
).rejects.toThrow(/联系方式/);
|
||||
});
|
||||
|
||||
it('提供有效 phone 时留资应成功,默认来源为 link,状态为 new', async () => {
|
||||
const lead = await leadService.submitLead({
|
||||
cardId: 101,
|
||||
name: 'Sarah Connor',
|
||||
phone: '+1-555-0199',
|
||||
company: 'Cyberdyne Systems',
|
||||
});
|
||||
|
||||
expect(lead.id).toBeDefined();
|
||||
expect(lead.name).toBe('Sarah Connor');
|
||||
expect(lead.phone).toBe('+1-555-0199');
|
||||
expect(lead.status).toBe('new');
|
||||
expect(lead.source).toBe('link');
|
||||
});
|
||||
|
||||
it('提供有效 email 且携带行为数据时,应自动计算意向度评分', async () => {
|
||||
const lead = await leadService.submitLead({
|
||||
cardId: 101,
|
||||
name: 'David Miller',
|
||||
email: 'david@miller.org',
|
||||
stayDurationMs: 60000,
|
||||
scrollDepth: 100,
|
||||
blockCount: 5,
|
||||
});
|
||||
|
||||
expect(lead.intentScore).toBe(100);
|
||||
// 满分高意向自动将初始状态升级为 'hot'
|
||||
expect(lead.status).toBe('hot');
|
||||
});
|
||||
|
||||
it('外部显式指定 status 或 intentScore 时以传入数据为准', async () => {
|
||||
const lead = await leadService.submitLead({
|
||||
cardId: 101,
|
||||
name: 'Emma Watson',
|
||||
email: 'emma@watson.com',
|
||||
status: 'contacted',
|
||||
intentScore: 60,
|
||||
source: 'nfc',
|
||||
});
|
||||
|
||||
expect(lead.status).toBe('contacted');
|
||||
expect(lead.intentScore).toBe(60);
|
||||
expect(lead.source).toBe('nfc');
|
||||
});
|
||||
});
|
||||
|
||||
describe('3.2 updateStatus 线索状态流转', () => {
|
||||
it('更新不存在的线索 ID 时应抛出 404 异常', async () => {
|
||||
await expect(leadService.updateStatus(9999, 'contacted')).rejects.toThrow(
|
||||
CoolCommException
|
||||
);
|
||||
});
|
||||
|
||||
it('更新为非法状态值时应抛出 400 参数异常', async () => {
|
||||
const created = await leadService.submitLead({
|
||||
cardId: 101,
|
||||
name: 'Test Lead',
|
||||
phone: '13800000000',
|
||||
});
|
||||
|
||||
await expect(
|
||||
leadService.updateStatus(created.id, 'invalid_status' as any)
|
||||
).rejects.toThrow(/非法状态/);
|
||||
});
|
||||
|
||||
it('正常流转状态并成功更新保存 (new -> contacted -> won)', async () => {
|
||||
const created = await leadService.submitLead({
|
||||
cardId: 101,
|
||||
name: 'Enterprise Buyer',
|
||||
email: 'buyer@corp.com',
|
||||
});
|
||||
expect(created.status).toBe('new');
|
||||
|
||||
const contacted = await leadService.updateStatus(created.id, 'contacted');
|
||||
expect(contacted.status).toBe('contacted');
|
||||
|
||||
const won = await leadService.updateStatus(created.id, 'won');
|
||||
expect(won.status).toBe('won');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 4. 控制器接口测试 (OpenCrmLeadController)
|
||||
// ==========================================
|
||||
describe('4. 公开免登录留资互换接口 (OpenCrmLeadController)', () => {
|
||||
let controller: OpenCrmLeadController;
|
||||
let leadService: CrmLeadService;
|
||||
|
||||
beforeEach(() => {
|
||||
leadService = new CrmLeadService();
|
||||
leadService.crmLeadEntity = {
|
||||
save: jest.fn().mockImplementation(async item => ({
|
||||
id: 88,
|
||||
...item,
|
||||
})),
|
||||
findOne: jest.fn(),
|
||||
} as any;
|
||||
|
||||
controller = new OpenCrmLeadController();
|
||||
controller.crmLeadService = leadService;
|
||||
});
|
||||
|
||||
it('POST /open/crm/lead/exchange 正常留资返回成功响应 (code: 1000)', async () => {
|
||||
const response = await controller.exchange({
|
||||
cardId: 101,
|
||||
name: 'Michael Scott',
|
||||
phone: '+1-202-555-0143',
|
||||
company: 'Dunder Mifflin',
|
||||
source: 'qr',
|
||||
});
|
||||
|
||||
expect(response.code).toBe(1000);
|
||||
expect(response.message).toBe('success');
|
||||
expect((response as any).data.id).toBe(88);
|
||||
expect((response as any).data.name).toBe('Michael Scott');
|
||||
expect((response as any).data.source).toBe('qr');
|
||||
});
|
||||
|
||||
it('缺少必要留资参数时应返回失败响应 (非 1000)', async () => {
|
||||
const response = await controller.exchange({
|
||||
cardId: 101,
|
||||
// 缺少 name 和 联系方式
|
||||
} as any);
|
||||
|
||||
expect(response.code).not.toBe(1000);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,393 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户