diff --git a/backend/src/modules/crm/config.ts b/backend/src/modules/crm/config.ts new file mode 100644 index 0000000..c76fcf1 --- /dev/null +++ b/backend/src/modules/crm/config.ts @@ -0,0 +1,14 @@ +import { ModuleConfig } from '@cool-midway/core'; + +/** + * 潜客线索与 CRM 模块配置 + */ +export default () => { + return { + name: '潜客线索与 CRM 模块', + description: '名片互换留资、访客行为意向度评分引擎与线索生命周期流转', + middlewares: [], + globalMiddlewares: [], + order: 20, + } as ModuleConfig; +}; diff --git a/backend/src/modules/crm/controller/open/lead.ts b/backend/src/modules/crm/controller/open/lead.ts new file mode 100644 index 0000000..eb15251 --- /dev/null +++ b/backend/src/modules/crm/controller/open/lead.ts @@ -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 + ); + } + } +} diff --git a/backend/src/modules/crm/controller/open/radar.ts b/backend/src/modules/crm/controller/open/radar.ts new file mode 100644 index 0000000..8ad3196 --- /dev/null +++ b/backend/src/modules/crm/controller/open/radar.ts @@ -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 + ); + } + } +} diff --git a/backend/src/modules/crm/entity/lead.ts b/backend/src/modules/crm/entity/lead.ts new file mode 100644 index 0000000..79d85a6 --- /dev/null +++ b/backend/src/modules/crm/entity/lead.ts @@ -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 }; diff --git a/backend/src/modules/crm/entity/track.ts b/backend/src/modules/crm/entity/track.ts new file mode 100644 index 0000000..8d03b9e --- /dev/null +++ b/backend/src/modules/crm/entity/track.ts @@ -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 }; diff --git a/backend/src/modules/crm/service/lead.ts b/backend/src/modules/crm/service/lead.ts new file mode 100644 index 0000000..4f789f0 --- /dev/null +++ b/backend/src/modules/crm/service/lead.ts @@ -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; + + /** + * 智能意向度评分计算引擎 + * 根据停留时长、滚动深度、浏览模块数加权综合评分 (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 { + 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 { + 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); + } +} diff --git a/backend/src/modules/crm/service/radar.ts b/backend/src/modules/crm/service/radar.ts new file mode 100644 index 0000000..884cc69 --- /dev/null +++ b/backend/src/modules/crm/service/radar.ts @@ -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; + + @Inject() + ctx: any; + + /** + * 轻量高效 IP 地理位置解析机制 + * 支持 Cloudflare/CDN 请求头直通与内网/本地安全识别 + */ + parseIpLocation( + ip?: string, + headers?: Record + ): 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 { + 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 { + 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, + }); + } +} diff --git a/backend/test/modules/crm/lead.test.ts b/backend/test/modules/crm/lead.test.ts new file mode 100644 index 0000000..c70efb5 --- /dev/null +++ b/backend/test/modules/crm/lead.test.ts @@ -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 = { + 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); + }); + }); +}); diff --git a/backend/test/modules/crm/radar.test.ts b/backend/test/modules/crm/radar.test.ts new file mode 100644 index 0000000..3f0de80 --- /dev/null +++ b/backend/test/modules/crm/radar.test.ts @@ -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 = { + 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); + }); + }); +});