feat(crm): 完成访客雷达足迹流水实体、埋点服务与免登录控制器 (Task 11)
这个提交包含在:
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户