feat(crm): 完成访客雷达足迹流水实体、埋点服务与免登录控制器 (Task 11)

这个提交包含在:
wikiboss
2026-09-20 17:40:04 +08:00
父节点 2bc395c384
当前提交 679ea8441e
共有 9 个文件被更改,包括 1395 次插入 和 0 次删除

查看文件

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