feat: 完成全系统30个功能点开发与E2E全链路验收 (8轮调度32个子代理闭环)
- 模块一: 出海案例作品集、博客动态发布、人名发音语音条、RTL阿语多语言切换 - 模块二: AI顾问人设配置、行业术语库润色、RAG切片沙盒调试、流式会话与意向留资卡片 - 模块三: 全球访客雷达大屏、多维加权意向算分、外部CRM对接推送、团队公海池流转 - 模块四: 移动端NFC安全挂失、NFC动态路由规则配置、动态防伪验签、展会制卡Zip批量导出 - 模块五: 组织架构与名片分配、移动端个人中心设置、离职交接与历史线索迁移、安全审计日志 - 模块六: Stripe国际信用卡订阅Webhook、独立二级域名与SSL检测、坐席配额预警、多币种发票 - 模块七: 纸质名片OCR解析入库、国际商务日程RFC 5545 ICS日历、微官网离线SWR预加载、展会实时排行榜大屏 - 模块八: 全球出海SEO OpenGraph元标签注入与社交爬虫探测、30点端到端全链路业务流集成测试套件
这个提交包含在:
@@ -0,0 +1,151 @@
|
||||
import { Body, Inject, Post, Provide } from '@midwayjs/core';
|
||||
import { Context } from '@midwayjs/koa';
|
||||
import { CoolController, BaseController, CoolCommException } from '@cool-midway/core';
|
||||
import { InjectEntityModel } from '@midwayjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { CrmLeadEntity } from '../../entity/lead';
|
||||
import { CrmLeadService } from '../../service/lead';
|
||||
import { CrmLeadPoolService } from '../../service/lead-pool';
|
||||
import {
|
||||
CrmSyncService,
|
||||
HubSpotContactPayload,
|
||||
SalesforceLeadPayload,
|
||||
WebhookResult,
|
||||
} from '../../service/sync';
|
||||
|
||||
/**
|
||||
* 外部 CRM 同步请求体
|
||||
*/
|
||||
export interface SyncExternalDTO {
|
||||
id: number;
|
||||
target: 'hubspot' | 'salesforce' | 'webhook';
|
||||
webhookUrl?: string;
|
||||
webhookSecret?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理后台-潜客线索与公海池流转
|
||||
* 路由前缀: /admin/crm/lead
|
||||
*/
|
||||
@Provide()
|
||||
@CoolController({
|
||||
api: ['add', 'delete', 'update', 'info', 'list', 'page'],
|
||||
entity: CrmLeadEntity,
|
||||
service: CrmLeadService,
|
||||
pageQueryOp: {
|
||||
keyWordLikeFields: ['a.name', 'a.company', 'a.email', 'a.phone'],
|
||||
fieldEq: ['a.status', 'a.source', 'a.cardId'],
|
||||
addOrderBy: { createTime: 'desc' },
|
||||
},
|
||||
serviceApis: [
|
||||
{ method: 'updateStatus', summary: '线索状态流转' },
|
||||
],
|
||||
})
|
||||
export class AdminCrmLeadController extends BaseController {
|
||||
@Inject()
|
||||
ctx: Context;
|
||||
|
||||
@Inject()
|
||||
crmLeadPoolService: CrmLeadPoolService;
|
||||
|
||||
@Inject()
|
||||
crmSyncService: CrmSyncService;
|
||||
|
||||
@InjectEntityModel(CrmLeadEntity)
|
||||
crmLeadEntity: Repository<CrmLeadEntity>;
|
||||
|
||||
/**
|
||||
* 销售认领/抢单公海线索
|
||||
* 路由: POST /admin/crm/lead/pool-claim
|
||||
*/
|
||||
@Post('/pool-claim', { summary: '公海线索认领抢单' })
|
||||
async poolClaim(@Body() body: { leadId: number; userId?: number }) {
|
||||
const userId = body?.userId ?? (this.ctx?.admin?.userId || 0);
|
||||
const result = await this.crmLeadPoolService.claimLead(body?.leadId, userId);
|
||||
return this.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员指定分配/派发线索
|
||||
* 路由: POST /admin/crm/lead/pool-assign
|
||||
*/
|
||||
@Post('/pool-assign', { summary: '管理员指定分配派发线索' })
|
||||
async poolAssign(
|
||||
@Body() body: { leadId: number; targetUserId: number; assignerId?: number }
|
||||
) {
|
||||
const assignerId = body?.assignerId ?? (this.ctx?.admin?.userId || 0);
|
||||
const result = await this.crmLeadPoolService.assignLead(
|
||||
body?.leadId,
|
||||
body?.targetUserId,
|
||||
assignerId
|
||||
);
|
||||
return this.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步线索至外部主流 CRM (HubSpot / Salesforce / Webhook)
|
||||
* 路由: POST /admin/crm/lead/sync-external
|
||||
*/
|
||||
@Post('/sync-external', { summary: '同步线索至外部 CRM (HubSpot / Salesforce / Webhook)' })
|
||||
async syncExternal(
|
||||
@Body() body: SyncExternalDTO
|
||||
): Promise<{
|
||||
code: number;
|
||||
message: string;
|
||||
data: {
|
||||
target: 'hubspot' | 'salesforce' | 'webhook';
|
||||
leadId: number;
|
||||
payload?: HubSpotContactPayload | SalesforceLeadPayload;
|
||||
result?: WebhookResult;
|
||||
};
|
||||
}> {
|
||||
const { id, target, webhookUrl, webhookSecret } = body || {};
|
||||
|
||||
if (!id) {
|
||||
throw new CoolCommException('缺少线索 ID', 400);
|
||||
}
|
||||
|
||||
const validTargets = ['hubspot', 'salesforce', 'webhook'];
|
||||
if (!target || !validTargets.includes(target)) {
|
||||
throw new CoolCommException('不支持的同步目标 CRM 类型', 400);
|
||||
}
|
||||
|
||||
const lead = await this.crmLeadEntity.findOne({ where: { id } });
|
||||
if (!lead) {
|
||||
throw new CoolCommException('潜客线索不存在', 404);
|
||||
}
|
||||
|
||||
if (target === 'hubspot') {
|
||||
const payload = this.crmSyncService.formatToHubSpot(lead);
|
||||
return this.ok({ target, leadId: id, payload }) as unknown as {
|
||||
code: number;
|
||||
message: string;
|
||||
data: { target: 'hubspot'; leadId: number; payload: typeof payload };
|
||||
};
|
||||
}
|
||||
|
||||
if (target === 'salesforce') {
|
||||
const payload = this.crmSyncService.formatToSalesforce(lead);
|
||||
return this.ok({ target, leadId: id, payload }) as unknown as {
|
||||
code: number;
|
||||
message: string;
|
||||
data: { target: 'salesforce'; leadId: number; payload: typeof payload };
|
||||
};
|
||||
}
|
||||
|
||||
// target === 'webhook'
|
||||
if (!webhookUrl) {
|
||||
throw new CoolCommException('缺少目标 Webhook URL', 400);
|
||||
}
|
||||
const result = await this.crmSyncService.sendWebhook(
|
||||
webhookUrl,
|
||||
webhookSecret || '',
|
||||
lead
|
||||
);
|
||||
return this.ok({ target, leadId: id, result }) as unknown as {
|
||||
code: number;
|
||||
message: string;
|
||||
data: { target: 'webhook'; leadId: number; result: typeof result };
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Body, Get, Inject, Post, Provide, Query } from '@midwayjs/core';
|
||||
import { CoolController, BaseController } from '@cool-midway/core';
|
||||
import { CrmTrackEntity } from '../../entity/track';
|
||||
import { CrmRadarService } from '../../service/radar';
|
||||
|
||||
/**
|
||||
* 管理后台-访客雷达足迹
|
||||
* 路由前缀: /admin/crm/radar
|
||||
* 足迹由公开埋点写入,后台不提供手工 add
|
||||
*/
|
||||
@Provide()
|
||||
@CoolController({
|
||||
api: ['info', 'list', 'page', 'delete'],
|
||||
entity: CrmTrackEntity,
|
||||
service: CrmRadarService,
|
||||
pageQueryOp: {
|
||||
keyWordLikeFields: ['a.country', 'a.city', 'a.visitorId', 'a.ip'],
|
||||
fieldEq: ['a.cardId', 'a.touchType', 'a.country'],
|
||||
addOrderBy: { id: 'desc' },
|
||||
},
|
||||
serviceApis: [
|
||||
{ method: 'getRecentTracks', summary: '最近足迹' },
|
||||
{ method: 'distribution', summary: '国家分布' },
|
||||
{ method: 'analytics', summary: '大屏综合指标' },
|
||||
],
|
||||
})
|
||||
export class AdminCrmRadarController extends BaseController {
|
||||
@Inject()
|
||||
crmRadarService: CrmRadarService;
|
||||
|
||||
/**
|
||||
* 全球访客国家分布统计接口
|
||||
* 路由: GET/POST /admin/crm/radar/distribution
|
||||
*/
|
||||
@Get('/distribution', { summary: '全球访客国家分布' })
|
||||
@Post('/distribution', { summary: '全球访客国家分布' })
|
||||
async distribution(
|
||||
@Body() body?: { cardId?: number },
|
||||
@Query('cardId') queryCardId?: number
|
||||
) {
|
||||
const cardId = body?.cardId ?? queryCardId;
|
||||
const data = await this.crmRadarService.getCountryDistribution(
|
||||
cardId !== undefined && cardId !== null && !isNaN(Number(cardId))
|
||||
? Number(cardId)
|
||||
: undefined
|
||||
);
|
||||
return this.ok(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 全球访客雷达大屏综合指标接口
|
||||
* 路由: GET/POST /admin/crm/radar/analytics
|
||||
*/
|
||||
@Get('/analytics', { summary: '雷达大屏综合指标' })
|
||||
@Post('/analytics', { summary: '雷达大屏综合指标' })
|
||||
async analytics(
|
||||
@Body() body?: { cardId?: number; days?: number },
|
||||
@Query('cardId') queryCardId?: number,
|
||||
@Query('days') queryDays?: number
|
||||
) {
|
||||
const cardId = body?.cardId ?? queryCardId;
|
||||
const days = body?.days ?? queryDays;
|
||||
const data = await this.crmRadarService.getAnalytics({
|
||||
cardId:
|
||||
cardId !== undefined && cardId !== null && !isNaN(Number(cardId))
|
||||
? Number(cardId)
|
||||
: undefined,
|
||||
days:
|
||||
days !== undefined && days !== null && !isNaN(Number(days))
|
||||
? Number(days)
|
||||
: undefined,
|
||||
});
|
||||
return this.ok(data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { BaseEntity } from '../../../comm/entity/base';
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
import { LeadPoolAction } from '../../../comm/types/omni';
|
||||
|
||||
/**
|
||||
* 潜客线索流转与公海变更审计日志实体
|
||||
* 表名: crm_lead_audit
|
||||
*/
|
||||
@Entity('crm_lead_audit')
|
||||
export class CrmLeadAuditEntity extends BaseEntity {
|
||||
@Index()
|
||||
@Column({
|
||||
name: 'lead_id',
|
||||
comment: '关联线索 ID',
|
||||
})
|
||||
leadId: number;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
name: 'action',
|
||||
comment: '操作行为类型 (recycle: 回收, auto_recycle: 自动超时回收, claim: 认领, assign: 管理员分配)',
|
||||
length: 30,
|
||||
})
|
||||
action: LeadPoolAction;
|
||||
|
||||
@Column({
|
||||
name: 'operator_id',
|
||||
comment: '操作人 ID (系统自动流转为 null 或 0)',
|
||||
nullable: true,
|
||||
})
|
||||
operatorId?: number | null;
|
||||
|
||||
@Column({
|
||||
name: 'from_user_id',
|
||||
comment: '原负责人销售 ID',
|
||||
nullable: true,
|
||||
})
|
||||
fromUserId?: number | null;
|
||||
|
||||
@Column({
|
||||
name: 'to_user_id',
|
||||
comment: '新负责人销售 ID',
|
||||
nullable: true,
|
||||
})
|
||||
toUserId?: number | null;
|
||||
|
||||
@Column({
|
||||
name: 'reason',
|
||||
comment: '流转原因或说明',
|
||||
length: 255,
|
||||
nullable: true,
|
||||
})
|
||||
reason?: string;
|
||||
|
||||
@Column({
|
||||
name: 'notes',
|
||||
comment: '扩展备忘或快照说明',
|
||||
type: 'text',
|
||||
nullable: true,
|
||||
})
|
||||
notes?: string;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BaseEntity } from '../../../comm/entity/base';
|
||||
import { BaseEntity, transformerTime } from '../../../comm/entity/base';
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
import {
|
||||
LeadEntity as ILeadEntity,
|
||||
@@ -91,6 +91,31 @@ export class CrmLeadEntity extends BaseEntity implements ILeadEntity {
|
||||
nullable: true,
|
||||
})
|
||||
notes?: string;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
name: 'user_id',
|
||||
comment: '所属销售/负责人 ID (为空表示公海)',
|
||||
nullable: true,
|
||||
})
|
||||
userId?: number | null;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
name: 'is_pool',
|
||||
comment: '是否处于公海池 (true: 公海, false: 私海)',
|
||||
default: false,
|
||||
})
|
||||
isPool: boolean;
|
||||
|
||||
@Column({
|
||||
name: 'last_follow_up_time',
|
||||
comment: '最后跟进时间',
|
||||
type: 'varchar',
|
||||
transformer: transformerTime,
|
||||
nullable: true,
|
||||
})
|
||||
lastFollowUpTime?: Date | string | null;
|
||||
}
|
||||
|
||||
export { CrmLeadEntity as LeadInfoEntity, CrmLeadEntity as LeadEntity };
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
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 { CrmLeadAuditEntity } from '../entity/audit';
|
||||
|
||||
/**
|
||||
* 自动回收执行结果接口
|
||||
*/
|
||||
export interface AutoRecycleResult {
|
||||
recycledCount: number;
|
||||
leadIds: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 团队线索公海池流转与自动分配服务
|
||||
* 负责线索公海回收、超时自动扫描回收、销售原子防并发抢单、管理员指派及全链路审计追踪
|
||||
*/
|
||||
@Provide()
|
||||
export class CrmLeadPoolService extends BaseService {
|
||||
@InjectEntityModel(CrmLeadEntity)
|
||||
crmLeadEntity: Repository<CrmLeadEntity>;
|
||||
|
||||
@InjectEntityModel(CrmLeadAuditEntity)
|
||||
crmLeadAuditEntity: Repository<CrmLeadAuditEntity>;
|
||||
|
||||
/**
|
||||
* 将线索退回/手动置入公海池
|
||||
* @param leadId 线索主键 ID
|
||||
* @param reason 退回公海原因说明
|
||||
*/
|
||||
async recycleToPool(
|
||||
leadId: number,
|
||||
reason?: string
|
||||
): Promise<CrmLeadEntity> {
|
||||
const id = Number(leadId);
|
||||
if (!id) {
|
||||
throw new CoolCommException('缺少线索 ID', 400);
|
||||
}
|
||||
|
||||
const lead = await this.crmLeadEntity.findOne({ where: { id } });
|
||||
if (!lead) {
|
||||
throw new CoolCommException('潜客线索不存在', 404);
|
||||
}
|
||||
|
||||
const oldUserId = lead.userId ?? null;
|
||||
lead.isPool = true;
|
||||
lead.userId = null;
|
||||
const saved = await this.crmLeadEntity.save(lead);
|
||||
|
||||
const audit = new CrmLeadAuditEntity();
|
||||
audit.leadId = id;
|
||||
audit.action = 'recycle';
|
||||
audit.fromUserId = oldUserId;
|
||||
audit.toUserId = null;
|
||||
audit.reason = reason || '手动退回公海池';
|
||||
await this.crmLeadAuditEntity.save(audit);
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
/**
|
||||
* 定时扫描超过指定天数未跟进的线索,自动批量归入公海池
|
||||
* @param inactivityDays 未跟进超期天数阈值
|
||||
*/
|
||||
async scanAndAutoRecycle(inactivityDays: number): Promise<AutoRecycleResult> {
|
||||
if (!inactivityDays || inactivityDays <= 0) {
|
||||
return { recycledCount: 0, leadIds: [] };
|
||||
}
|
||||
|
||||
const thresholdMs = inactivityDays * 24 * 60 * 60 * 1000;
|
||||
const now = Date.now();
|
||||
|
||||
const allLeads = await this.crmLeadEntity.find();
|
||||
const toRecycle: CrmLeadEntity[] = [];
|
||||
|
||||
for (const lead of allLeads) {
|
||||
// 仅扫描私海线索,且排除已赢单 (won) 和已归档 (archived)
|
||||
if (lead.isPool) continue;
|
||||
if (lead.status === 'won' || lead.status === 'archived') continue;
|
||||
|
||||
const followUp = lead.lastFollowUpTime || lead.updateTime || lead.createTime;
|
||||
if (!followUp) continue;
|
||||
|
||||
const diff = now - new Date(followUp).getTime();
|
||||
if (diff >= thresholdMs) {
|
||||
toRecycle.push(lead);
|
||||
}
|
||||
}
|
||||
|
||||
const recycledIds: number[] = [];
|
||||
for (const lead of toRecycle) {
|
||||
const oldUserId = lead.userId ?? null;
|
||||
lead.isPool = true;
|
||||
lead.userId = null;
|
||||
await this.crmLeadEntity.save(lead);
|
||||
recycledIds.push(Number(lead.id));
|
||||
|
||||
const audit = new CrmLeadAuditEntity();
|
||||
audit.leadId = Number(lead.id);
|
||||
audit.action = 'auto_recycle';
|
||||
audit.fromUserId = oldUserId;
|
||||
audit.toUserId = null;
|
||||
audit.reason = `超过 ${inactivityDays} 天未跟进自动回收至公海池`;
|
||||
await this.crmLeadAuditEntity.save(audit);
|
||||
}
|
||||
|
||||
return { recycledCount: recycledIds.length, leadIds: recycledIds };
|
||||
}
|
||||
|
||||
/**
|
||||
* 销售成员从公海池抢单/领用 (CAS 原子性防并发保护)
|
||||
* @param leadId 线索 ID
|
||||
* @param userId 认领销售人员 ID
|
||||
*/
|
||||
async claimLead(leadId: number, userId: number): Promise<CrmLeadEntity> {
|
||||
const id = Number(leadId);
|
||||
const targetUid = Number(userId);
|
||||
|
||||
if (!id) {
|
||||
throw new CoolCommException('缺少线索 ID', 400);
|
||||
}
|
||||
if (!targetUid) {
|
||||
throw new CoolCommException('缺少领用销售人员 ID', 400);
|
||||
}
|
||||
|
||||
// 检查线索是否存在
|
||||
const existing = await this.crmLeadEntity.findOne({ where: { id } });
|
||||
if (!existing) {
|
||||
throw new CoolCommException('潜客线索不存在', 404);
|
||||
}
|
||||
|
||||
if (!existing.isPool || existing.userId) {
|
||||
throw new CoolCommException('该线索已被其他销售认领或不在公海池', 409);
|
||||
}
|
||||
|
||||
// 原子性条件更新:仅在线索仍在公海时方可更新
|
||||
const updateResult = await this.crmLeadEntity.update(
|
||||
{ id, isPool: true },
|
||||
{ isPool: false, userId: targetUid }
|
||||
);
|
||||
|
||||
if (!updateResult || updateResult.affected === 0) {
|
||||
throw new CoolCommException('该线索已被其他销售认领或不在公海池', 409);
|
||||
}
|
||||
|
||||
const updated = await this.crmLeadEntity.findOne({ where: { id } });
|
||||
const resultLead = updated || existing;
|
||||
resultLead.isPool = false;
|
||||
resultLead.userId = targetUid;
|
||||
|
||||
// 记录领用审计日志
|
||||
const audit = new CrmLeadAuditEntity();
|
||||
audit.leadId = id;
|
||||
audit.action = 'claim';
|
||||
audit.operatorId = targetUid;
|
||||
audit.fromUserId = null;
|
||||
audit.toUserId = targetUid;
|
||||
audit.reason = '销售公海抢单领用';
|
||||
await this.crmLeadAuditEntity.save(audit);
|
||||
|
||||
return resultLead;
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员指定派发分配线索
|
||||
* @param leadId 线索 ID
|
||||
* @param targetUserId 目标销售成员 ID
|
||||
* @param assignerId 管理员/操作人 ID
|
||||
*/
|
||||
async assignLead(
|
||||
leadId: number,
|
||||
targetUserId: number,
|
||||
assignerId?: number
|
||||
): Promise<CrmLeadEntity> {
|
||||
const id = Number(leadId);
|
||||
const targetUid = Number(targetUserId);
|
||||
|
||||
if (!id) {
|
||||
throw new CoolCommException('缺少线索 ID', 400);
|
||||
}
|
||||
if (!targetUid) {
|
||||
throw new CoolCommException('缺少目标销售人员 ID', 400);
|
||||
}
|
||||
|
||||
const lead = await this.crmLeadEntity.findOne({ where: { id } });
|
||||
if (!lead) {
|
||||
throw new CoolCommException('潜客线索不存在', 404);
|
||||
}
|
||||
|
||||
const oldUserId = lead.userId ?? null;
|
||||
lead.userId = targetUid;
|
||||
lead.isPool = false;
|
||||
const saved = await this.crmLeadEntity.save(lead);
|
||||
|
||||
const audit = new CrmLeadAuditEntity();
|
||||
audit.leadId = id;
|
||||
audit.action = 'assign';
|
||||
audit.operatorId = assignerId ? Number(assignerId) : null;
|
||||
audit.fromUserId = oldUserId;
|
||||
audit.toUserId = targetUid;
|
||||
audit.reason = '管理员指定派发线索';
|
||||
await this.crmLeadAuditEntity.save(audit);
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询指定线索的全生命周期流转审计追踪日志
|
||||
* @param leadId 线索 ID
|
||||
*/
|
||||
async getAuditLogs(leadId: number): Promise<CrmLeadAuditEntity[]> {
|
||||
const id = Number(leadId);
|
||||
if (!id) return [];
|
||||
|
||||
return await this.crmLeadAuditEntity.find({
|
||||
where: { leadId: id },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import { Provide } from '@midwayjs/core';
|
||||
import { BaseService } from '@cool-midway/core';
|
||||
import { RadarTrack, LeadEntity } from '../../../comm/types/omni';
|
||||
|
||||
/**
|
||||
* 意向度分级判定等级
|
||||
* - Hot: 高意向 (>= 75 分)
|
||||
* - Warm: 中意向 (40 ~ 74 分)
|
||||
* - Cold: 低意向 (< 40 分)
|
||||
*/
|
||||
export type LeadIntentGrade = 'Hot' | 'Warm' | 'Cold';
|
||||
|
||||
/**
|
||||
* 行为打分入参接口
|
||||
*/
|
||||
export interface LeadScoreBehaviorInput {
|
||||
/** 页面停留时长 (秒) */
|
||||
stayDurationSeconds?: number;
|
||||
/** 页面停留时长 (毫秒,若提供自动换算为秒) */
|
||||
stayDurationMs?: number;
|
||||
/** 浏览过的模块标识列表 (自动去重计算) */
|
||||
viewedBlocks?: string[];
|
||||
/** 浏览过的模块数 (若直接指定) */
|
||||
viewedBlockCount?: number;
|
||||
/** 是否点击了 WhatsApp 沟通 */
|
||||
clickedWhatsApp?: boolean;
|
||||
/** 是否下载保存了 vCard 电子名片 */
|
||||
downloadedVCard?: boolean;
|
||||
/** 是否主动提交表单留资 */
|
||||
submittedLead?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分项得分明细
|
||||
*/
|
||||
export interface ScoreBreakdown {
|
||||
stayScore: number;
|
||||
moduleScore: number;
|
||||
interactionScore: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 评分计算结果
|
||||
*/
|
||||
export interface LeadScoreResult {
|
||||
score: number;
|
||||
level: LeadIntentGrade;
|
||||
breakdown: ScoreBreakdown;
|
||||
}
|
||||
|
||||
/**
|
||||
* 意向诊断报告
|
||||
*/
|
||||
export interface LeadDiagnosticReport {
|
||||
score: number;
|
||||
level: LeadIntentGrade;
|
||||
suggestion: string;
|
||||
icebreaker: string;
|
||||
highlights: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 线索智能意向度评分与商机诊断引擎服务
|
||||
*/
|
||||
@Provide()
|
||||
export class CrmLeadScoreService extends BaseService {
|
||||
/**
|
||||
* 计算停留时长得分
|
||||
* 规则: >30s +15, >120s +30, >300s +40, <=30s +0
|
||||
*/
|
||||
calculateStayScore(seconds: number): number {
|
||||
if (!Number.isFinite(seconds) || seconds <= 30) {
|
||||
return 0;
|
||||
}
|
||||
if (seconds > 300) {
|
||||
return 40;
|
||||
}
|
||||
if (seconds > 120) {
|
||||
return 30;
|
||||
}
|
||||
return 15;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算浏览模块深度得分
|
||||
* 规则: 每个查看模块 +5, 上限 20 分
|
||||
*/
|
||||
calculateModuleScore(blockCount: number): number {
|
||||
if (!Number.isFinite(blockCount) || blockCount <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return Math.min(20, Math.floor(blockCount) * 5);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算高价值互动得分
|
||||
* 规则: 点击 WhatsApp +25, 下载 vCard +20, 主动留资 +35
|
||||
*/
|
||||
calculateInteractionScore(interactions: {
|
||||
clickedWhatsApp?: boolean;
|
||||
downloadedVCard?: boolean;
|
||||
submittedLead?: boolean;
|
||||
}): number {
|
||||
let score = 0;
|
||||
if (interactions.clickedWhatsApp) {
|
||||
score += 25;
|
||||
}
|
||||
if (interactions.downloadedVCard) {
|
||||
score += 20;
|
||||
}
|
||||
if (interactions.submittedLead) {
|
||||
score += 35;
|
||||
}
|
||||
return score;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据总分判定意向度等级
|
||||
* 规则: >=75 Hot, 40~74 Warm, <40 Cold
|
||||
*/
|
||||
resolveIntentLevel(score: number): LeadIntentGrade {
|
||||
if (score >= 75) {
|
||||
return 'Hot';
|
||||
}
|
||||
if (score >= 40) {
|
||||
return 'Warm';
|
||||
}
|
||||
return 'Cold';
|
||||
}
|
||||
|
||||
/**
|
||||
* 综合评分计算引擎
|
||||
* 加权计算停留时长、模块深度与关键互动,总分截断于 0~100
|
||||
*/
|
||||
calculateScore(input: LeadScoreBehaviorInput = {}): LeadScoreResult {
|
||||
// 1. 停留时长解析 (优先秒,其次毫秒换算,负数/NaN 防御)
|
||||
let seconds = 0;
|
||||
if (input.stayDurationSeconds !== undefined) {
|
||||
seconds = Number(input.stayDurationSeconds);
|
||||
} else if (input.stayDurationMs !== undefined) {
|
||||
seconds = Math.floor(Number(input.stayDurationMs) / 1000);
|
||||
}
|
||||
const safeSeconds = Number.isFinite(seconds) ? Math.max(0, seconds) : 0;
|
||||
const stayScore = this.calculateStayScore(safeSeconds);
|
||||
|
||||
// 2. 浏览模块数解析 (去重或直接取数量,负数/NaN 防御)
|
||||
let blockCount = 0;
|
||||
if (Array.isArray(input.viewedBlocks)) {
|
||||
blockCount = new Set(input.viewedBlocks.filter(Boolean)).size;
|
||||
} else if (input.viewedBlockCount !== undefined) {
|
||||
blockCount = Number(input.viewedBlockCount);
|
||||
}
|
||||
const safeBlockCount = Number.isFinite(blockCount)
|
||||
? Math.max(0, blockCount)
|
||||
: 0;
|
||||
const moduleScore = this.calculateModuleScore(safeBlockCount);
|
||||
|
||||
// 3. 高价值互动加权
|
||||
const interactionScore = this.calculateInteractionScore({
|
||||
clickedWhatsApp: Boolean(input.clickedWhatsApp),
|
||||
downloadedVCard: Boolean(input.downloadedVCard),
|
||||
submittedLead: Boolean(input.submittedLead),
|
||||
});
|
||||
|
||||
// 4. 总分计算与 0~100 截断
|
||||
const rawScore = stayScore + moduleScore + interactionScore;
|
||||
const score = Math.min(100, Math.max(0, rawScore));
|
||||
|
||||
// 5. 等级判定
|
||||
const level = this.resolveIntentLevel(score);
|
||||
|
||||
return {
|
||||
score,
|
||||
level,
|
||||
breakdown: {
|
||||
stayScore,
|
||||
moduleScore,
|
||||
interactionScore,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成意向诊断建议与商务破冰首语
|
||||
*/
|
||||
generateDiagnosticReport(
|
||||
scoreInput: number | LeadScoreResult,
|
||||
track?: Partial<RadarTrack> | null,
|
||||
lead?: Partial<LeadEntity> | null
|
||||
): LeadDiagnosticReport {
|
||||
const score =
|
||||
typeof scoreInput === 'number'
|
||||
? Math.min(100, Math.max(0, scoreInput))
|
||||
: scoreInput.score;
|
||||
|
||||
const level =
|
||||
typeof scoreInput === 'number'
|
||||
? this.resolveIntentLevel(score)
|
||||
: scoreInput.level;
|
||||
|
||||
// 提取潜客与足迹核心上下文
|
||||
const name = (lead?.name || '').trim();
|
||||
const company = (lead?.company || '').trim();
|
||||
const country = (track?.country || '').trim();
|
||||
const city = (track?.city || '').trim();
|
||||
|
||||
// 1. 智能诊断建议
|
||||
let suggestion = '';
|
||||
if (level === 'Hot') {
|
||||
suggestion = `该潜客(综合意向得分 ${score})属于高价值高意向决策客商,产生了深度浏览与关键交互。强烈建议在 2 小时内由商务团队通过 WhatsApp 或电话直连跟进,优先提供专属方案报价与样品预约。`;
|
||||
} else if (level === 'Warm') {
|
||||
suggestion = `该潜客(综合意向得分 ${score})对品牌与产品具有明确兴趣。建议在 24 小时内通过邮件或即时通讯推送针对性解决方案白皮书与客户案例,加深意向转化。`;
|
||||
} else {
|
||||
suggestion = `该潜客(综合意向得分 ${score})目前处于初次轻度探索阶段。建议纳入全球出海自动化培育邮件流 (Nurture Sequence),定期推送行业洞察,避免过早强行推销。`;
|
||||
}
|
||||
|
||||
// 2. 出海商务破冰首语 (Icebreaker)
|
||||
let icebreaker = '';
|
||||
const nameGreeting = name ? name : 'there';
|
||||
const companyAffix = company ? ` from ${company}` : '';
|
||||
const locationAffix =
|
||||
country && country !== 'Unknown' && country !== 'Local'
|
||||
? ` in ${country}`
|
||||
: '';
|
||||
|
||||
if (level === 'Hot') {
|
||||
icebreaker = `Hi ${nameGreeting}${companyAffix}, great to connect with you${locationAffix}! I noticed your high interest in our digital profile and solutions. Would you be open to a quick 10-minute discovery call this week?`;
|
||||
} else if (level === 'Warm') {
|
||||
icebreaker = `Hi ${nameGreeting}${companyAffix}, thanks for exploring our profile! I'd love to share our latest product catalog and industry case studies tailored to your market. Let me know if you'd like me to send them over.`;
|
||||
} else {
|
||||
icebreaker = `Hello ${nameGreeting}, thanks for visiting our digital card! Feel free to reach out anytime if you need any resources or information regarding our solutions.`;
|
||||
}
|
||||
|
||||
// 3. 提炼画像与行为高亮
|
||||
const highlights: string[] = [];
|
||||
if (track?.stayDurationMs && track.stayDurationMs > 300000) {
|
||||
highlights.push('深度停留超过 5 分钟');
|
||||
}
|
||||
if (track?.viewedBlocks && track.viewedBlocks.length >= 4) {
|
||||
highlights.push('浏览了 4 个及以上核心业务展示模块');
|
||||
}
|
||||
if (lead?.source === 'nfc' || track?.touchType === 'nfc') {
|
||||
highlights.push('通过 NFC 实体卡近场触碰连接');
|
||||
}
|
||||
if (lead?.phone || lead?.email) {
|
||||
highlights.push('主动留存了真实联系渠道');
|
||||
}
|
||||
if (country && country !== 'Unknown' && country !== 'Local') {
|
||||
highlights.push(`来自 ${country}${city ? ' · ' + city : ''} 的出海潜在客商`);
|
||||
}
|
||||
if (highlights.length === 0) {
|
||||
highlights.push('初次访问数字名片微官网');
|
||||
}
|
||||
|
||||
return {
|
||||
score,
|
||||
level,
|
||||
suggestion,
|
||||
icebreaker,
|
||||
highlights,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,20 @@
|
||||
import { Provide } from '@midwayjs/core';
|
||||
import { Inject, 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';
|
||||
import {
|
||||
LeadSource,
|
||||
LeadStatus,
|
||||
RadarTrack,
|
||||
LeadEntity,
|
||||
} from '../../../comm/types/omni';
|
||||
import {
|
||||
CrmLeadScoreService,
|
||||
LeadScoreBehaviorInput,
|
||||
LeadScoreResult,
|
||||
LeadDiagnosticReport,
|
||||
} from './lead-score';
|
||||
|
||||
/**
|
||||
* 意向度评分计算结果接口
|
||||
@@ -30,6 +41,10 @@ export interface SubmitLeadDTO {
|
||||
stayDurationMs?: number;
|
||||
scrollDepth?: number;
|
||||
blockCount?: number;
|
||||
viewedBlocks?: string[];
|
||||
clickedWhatsApp?: boolean;
|
||||
downloadedVCard?: boolean;
|
||||
submittedLead?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,6 +56,9 @@ export class CrmLeadService extends BaseService {
|
||||
@InjectEntityModel(CrmLeadEntity)
|
||||
crmLeadEntity: Repository<CrmLeadEntity>;
|
||||
|
||||
@Inject()
|
||||
leadScoreService: CrmLeadScoreService = new CrmLeadScoreService();
|
||||
|
||||
/**
|
||||
* 智能意向度评分计算引擎
|
||||
* 根据停留时长、滚动深度、浏览模块数加权综合评分 (0 - 100 分)
|
||||
@@ -108,18 +126,33 @@ export class CrmLeadService extends BaseService {
|
||||
|
||||
// 计算或采用意向度评分
|
||||
let intentScore = data.intentScore ?? 0;
|
||||
if (
|
||||
data.intentScore === undefined &&
|
||||
(data.stayDurationMs !== undefined ||
|
||||
if (data.intentScore === undefined) {
|
||||
if (
|
||||
data.clickedWhatsApp !== undefined ||
|
||||
data.downloadedVCard !== undefined ||
|
||||
data.submittedLead !== undefined
|
||||
) {
|
||||
const calculated = this.calculateLeadScore({
|
||||
stayDurationMs: data.stayDurationMs,
|
||||
viewedBlocks: data.viewedBlocks,
|
||||
viewedBlockCount: data.blockCount,
|
||||
clickedWhatsApp: data.clickedWhatsApp,
|
||||
downloadedVCard: data.downloadedVCard,
|
||||
submittedLead: data.submittedLead !== undefined ? data.submittedLead : true,
|
||||
});
|
||||
intentScore = calculated.score;
|
||||
} else if (
|
||||
data.stayDurationMs !== undefined ||
|
||||
data.scrollDepth !== undefined ||
|
||||
data.blockCount !== undefined)
|
||||
) {
|
||||
const calculated = this.calculateIntentScore(
|
||||
data.stayDurationMs,
|
||||
data.scrollDepth,
|
||||
data.blockCount
|
||||
);
|
||||
intentScore = calculated.score;
|
||||
data.blockCount !== undefined
|
||||
) {
|
||||
const calculated = this.calculateIntentScore(
|
||||
data.stayDurationMs,
|
||||
data.scrollDepth,
|
||||
data.blockCount
|
||||
);
|
||||
intentScore = calculated.score;
|
||||
}
|
||||
}
|
||||
|
||||
// 判定初始状态
|
||||
@@ -143,13 +176,45 @@ export class CrmLeadService extends BaseService {
|
||||
return await this.crmLeadEntity.save(lead);
|
||||
}
|
||||
|
||||
/**
|
||||
* 采用新一代智能引擎计算线索意向度评分
|
||||
*/
|
||||
calculateLeadScore(input: LeadScoreBehaviorInput): LeadScoreResult {
|
||||
return this.leadScoreService.calculateScore(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成潜客意向诊断建议与商务破冰首语
|
||||
*/
|
||||
generateDiagnosticReport(
|
||||
score: number | LeadScoreResult,
|
||||
track?: Partial<RadarTrack> | null,
|
||||
lead?: Partial<LeadEntity> | null
|
||||
): LeadDiagnosticReport {
|
||||
return this.leadScoreService.generateDiagnosticReport(score, track, lead);
|
||||
}
|
||||
|
||||
/**
|
||||
* 线索跟进状态生命周期流转
|
||||
* 兼容管理后台 serviceApis:POST body 为单参数对象 { id, status }
|
||||
*/
|
||||
async updateStatus(
|
||||
id: number | string,
|
||||
status: LeadStatus
|
||||
): Promise<CrmLeadEntity>;
|
||||
async updateStatus(params: {
|
||||
id: number;
|
||||
status: LeadStatus;
|
||||
}): Promise<CrmLeadEntity>;
|
||||
async updateStatus(
|
||||
idOrParams: number | string | { id: number; status: LeadStatus },
|
||||
status?: LeadStatus
|
||||
): Promise<CrmLeadEntity> {
|
||||
if (typeof idOrParams === 'object' && idOrParams !== null) {
|
||||
return this.updateStatus(idOrParams.id, idOrParams.status);
|
||||
}
|
||||
|
||||
const id = idOrParams;
|
||||
const validStatuses: LeadStatus[] = [
|
||||
'new',
|
||||
'contacted',
|
||||
@@ -158,7 +223,7 @@ export class CrmLeadService extends BaseService {
|
||||
'archived',
|
||||
];
|
||||
|
||||
if (!validStatuses.includes(status)) {
|
||||
if (!status || !validStatuses.includes(status)) {
|
||||
throw new CoolCommException('非法状态值', 400);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Provide, Scope, ScopeEnum } from '@midwayjs/core';
|
||||
import { RadarTrack, TouchType } from '../../../comm/types/omni';
|
||||
|
||||
/** 雷达访客实时事件名,与管理端 / 客户端约定一致 */
|
||||
export const RADAR_VISITOR_EVENT = 'radar.visitor' as const;
|
||||
|
||||
/**
|
||||
* 内存渠道投递载荷。
|
||||
* 仅描述访客足迹摘要,不表示已推送到真实设备。
|
||||
*/
|
||||
export interface RadarPushPayload {
|
||||
event: typeof RADAR_VISITOR_EVENT;
|
||||
cardId: string | number;
|
||||
visitorId: string;
|
||||
country: string;
|
||||
city: string;
|
||||
touchType: TouchType;
|
||||
stayDurationMs: number;
|
||||
}
|
||||
|
||||
export type RadarPushListener = (payload: RadarPushPayload) => void;
|
||||
|
||||
/**
|
||||
* 雷达 Push 通知服务(内存渠道骨架)
|
||||
*
|
||||
* 不依赖 Redis / Socket.io 进程适配器。
|
||||
* publish 只通知本进程内 subscribe 的监听器,不伪造设备投递成功。
|
||||
*/
|
||||
@Provide()
|
||||
@Scope(ScopeEnum.Singleton)
|
||||
export class CrmRadarPushService {
|
||||
private readonly channels = new Map<string, Set<RadarPushListener>>();
|
||||
|
||||
buildPayload(track: RadarTrack): RadarPushPayload {
|
||||
return {
|
||||
event: RADAR_VISITOR_EVENT,
|
||||
cardId: track.cardId,
|
||||
visitorId: track.visitorId,
|
||||
country: track.country,
|
||||
city: track.city,
|
||||
touchType: track.touchType,
|
||||
stayDurationMs: track.stayDurationMs,
|
||||
};
|
||||
}
|
||||
|
||||
subscribe(cardId: string | number, listener: RadarPushListener): void {
|
||||
const key = this.channelKey(cardId);
|
||||
let listeners = this.channels.get(key);
|
||||
if (!listeners) {
|
||||
listeners = new Set<RadarPushListener>();
|
||||
this.channels.set(key, listeners);
|
||||
}
|
||||
listeners.add(listener);
|
||||
}
|
||||
|
||||
unsubscribe(cardId: string | number, listener: RadarPushListener): void {
|
||||
const key = this.channelKey(cardId);
|
||||
const listeners = this.channels.get(key);
|
||||
if (!listeners) {
|
||||
return;
|
||||
}
|
||||
listeners.delete(listener);
|
||||
if (listeners.size === 0) {
|
||||
this.channels.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 向该 cardId 的内存订阅者投递载荷。
|
||||
* 无订阅者时静默返回,不声称已送达设备。
|
||||
*/
|
||||
publish(track: RadarTrack): RadarPushPayload {
|
||||
const payload = this.buildPayload(track);
|
||||
const listeners = this.channels.get(this.channelKey(track.cardId));
|
||||
if (!listeners) {
|
||||
return payload;
|
||||
}
|
||||
for (const listener of listeners) {
|
||||
listener(payload);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
private channelKey(cardId: string | number): string {
|
||||
return String(cardId);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { InjectEntityModel } from '@midwayjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { CrmTrackEntity } from '../entity/track';
|
||||
import { TouchType } from '../../../comm/types/omni';
|
||||
import { lookupGeoIp } from '../../../comm/geoip/lookup';
|
||||
|
||||
/**
|
||||
* 访客足迹上报 DTO
|
||||
@@ -29,6 +30,49 @@ export interface IpLocationResult {
|
||||
city: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 国家分布聚合结果项
|
||||
*/
|
||||
export interface CountryDistributionItem {
|
||||
country: string;
|
||||
visitorCount: number;
|
||||
percentage: number;
|
||||
avgStayDurationMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 停留时长时段分布项 (0 - 23 小时)
|
||||
*/
|
||||
export interface HourlyStayStatItem {
|
||||
hour: number;
|
||||
visitorCount: number;
|
||||
avgStayDurationMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 终端渠道分析项
|
||||
*/
|
||||
export interface DeviceStatItem {
|
||||
type: string;
|
||||
count: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 雷达大屏综合指标聚合结果
|
||||
*/
|
||||
export interface RadarAnalyticsResult {
|
||||
countryDistribution: CountryDistributionItem[];
|
||||
hourlyStats: HourlyStayStatItem[];
|
||||
deviceStats: DeviceStatItem[];
|
||||
summary: {
|
||||
totalVisitors: number;
|
||||
avgStayDurationMs: number;
|
||||
topCountry: string;
|
||||
countriesCount: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 访客雷达与轨迹流水服务
|
||||
*/
|
||||
@@ -82,7 +126,11 @@ export class CrmRadarService extends BaseService {
|
||||
return { country: 'Local', city: 'Local' };
|
||||
}
|
||||
|
||||
// 3. 公网未知 IP 兜底返回
|
||||
// 3. 公网:内置 GeoIP 前缀表命中则用其结果,否则 Unknown
|
||||
const geo = lookupGeoIp(cleanIp);
|
||||
if (geo) {
|
||||
return { country: geo.country, city: geo.city };
|
||||
}
|
||||
return { country: 'Unknown', city: 'Unknown' };
|
||||
}
|
||||
|
||||
@@ -163,11 +211,28 @@ export class CrmRadarService extends BaseService {
|
||||
|
||||
/**
|
||||
* 获取某名片的最近足迹流水
|
||||
* 兼容管理后台 serviceApis:POST body 为单参数对象 { cardId, limit }
|
||||
*/
|
||||
async getRecentTracks(
|
||||
cardId: number | string,
|
||||
limit?: number
|
||||
): Promise<CrmTrackEntity[]>;
|
||||
async getRecentTracks(params: {
|
||||
cardId: number | string;
|
||||
limit?: number;
|
||||
}): Promise<CrmTrackEntity[]>;
|
||||
async getRecentTracks(
|
||||
cardIdOrParams:
|
||||
| number
|
||||
| string
|
||||
| { cardId: number | string; limit?: number },
|
||||
limit = 20
|
||||
): Promise<CrmTrackEntity[]> {
|
||||
if (typeof cardIdOrParams === 'object' && cardIdOrParams !== null) {
|
||||
return this.getRecentTracks(cardIdOrParams.cardId, cardIdOrParams.limit);
|
||||
}
|
||||
|
||||
const cardId = cardIdOrParams;
|
||||
if (!cardId) {
|
||||
throw new CoolCommException('缺少名片标识 cardId', 400);
|
||||
}
|
||||
@@ -183,4 +248,159 @@ export class CrmRadarService extends BaseService {
|
||||
take,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 按国家聚合访客数、占比与平均停留时长
|
||||
*/
|
||||
async getCountryDistribution(cardId?: number): Promise<CountryDistributionItem[]> {
|
||||
const where: any = {};
|
||||
if (cardId !== undefined && cardId !== null && !isNaN(Number(cardId))) {
|
||||
where.cardId = Number(cardId);
|
||||
}
|
||||
|
||||
const tracks = await this.crmTrackEntity.find({ where });
|
||||
if (!tracks || tracks.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const map = new Map<string, { count: number; totalDuration: number }>();
|
||||
let totalVisitors = 0;
|
||||
|
||||
for (const track of tracks) {
|
||||
const country = (track.country && String(track.country).trim()) || 'Unknown';
|
||||
const duration = Number(track.stayDurationMs) || 0;
|
||||
const current = map.get(country) || { count: 0, totalDuration: 0 };
|
||||
current.count += 1;
|
||||
current.totalDuration += duration;
|
||||
map.set(country, current);
|
||||
totalVisitors += 1;
|
||||
}
|
||||
|
||||
const result: CountryDistributionItem[] = [];
|
||||
for (const [country, stat] of map.entries()) {
|
||||
const percentage = totalVisitors > 0 ? Number(((stat.count / totalVisitors) * 100).toFixed(1)) : 0;
|
||||
const avgStayDurationMs = stat.count > 0 ? Math.round(stat.totalDuration / stat.count) : 0;
|
||||
result.push({
|
||||
country,
|
||||
visitorCount: stat.count,
|
||||
percentage,
|
||||
avgStayDurationMs,
|
||||
});
|
||||
}
|
||||
|
||||
result.sort((a, b) => b.visitorCount - a.visitorCount);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取停留时长与访客时段分布 (0 - 23 小时,支持近 N 天统计)
|
||||
*/
|
||||
async getHourlyStayStats(days = 7, cardId?: number): Promise<HourlyStayStatItem[]> {
|
||||
const validDays = Number.isFinite(Number(days)) && Number(days) > 0 ? Number(days) : 7;
|
||||
const sinceDate = new Date(Date.now() - validDays * 24 * 60 * 60 * 1000);
|
||||
|
||||
const where: any = {};
|
||||
if (cardId !== undefined && cardId !== null && !isNaN(Number(cardId))) {
|
||||
where.cardId = Number(cardId);
|
||||
}
|
||||
|
||||
const tracks = await this.crmTrackEntity.find({ where });
|
||||
|
||||
// 初始化 0~23 小时数组结构
|
||||
const hourlyList: Array<{ count: number; totalDuration: number }> = Array.from(
|
||||
{ length: 24 },
|
||||
() => ({ count: 0, totalDuration: 0 })
|
||||
);
|
||||
|
||||
for (const track of tracks) {
|
||||
const createTime = track.createTime ? new Date(track.createTime) : null;
|
||||
if (!createTime || isNaN(createTime.getTime()) || createTime < sinceDate) {
|
||||
continue;
|
||||
}
|
||||
const hour = createTime.getHours();
|
||||
if (hour >= 0 && hour <= 23) {
|
||||
hourlyList[hour].count += 1;
|
||||
hourlyList[hour].totalDuration += Number(track.stayDurationMs) || 0;
|
||||
}
|
||||
}
|
||||
|
||||
return hourlyList.map((item, hour) => ({
|
||||
hour,
|
||||
visitorCount: item.count,
|
||||
avgStayDurationMs: item.count > 0 ? Math.round(item.totalDuration / item.count) : 0,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取触碰渠道/终端分析统计
|
||||
*/
|
||||
async getDeviceStats(cardId?: number): Promise<DeviceStatItem[]> {
|
||||
const where: any = {};
|
||||
if (cardId !== undefined && cardId !== null && !isNaN(Number(cardId))) {
|
||||
where.cardId = Number(cardId);
|
||||
}
|
||||
|
||||
const tracks = await this.crmTrackEntity.find({ where });
|
||||
const countMap: Record<string, number> = {
|
||||
nfc: 0,
|
||||
qr: 0,
|
||||
link: 0,
|
||||
};
|
||||
let total = 0;
|
||||
|
||||
for (const track of tracks || []) {
|
||||
const type = (track.touchType && String(track.touchType).trim()) || 'link';
|
||||
countMap[type] = (countMap[type] || 0) + 1;
|
||||
total += 1;
|
||||
}
|
||||
|
||||
const standardTypes = ['nfc', 'qr', 'link'];
|
||||
const otherTypes = Object.keys(countMap).filter((t) => !standardTypes.includes(t));
|
||||
const allTypes = [...standardTypes, ...otherTypes];
|
||||
|
||||
return allTypes.map((type) => {
|
||||
const count = countMap[type] || 0;
|
||||
const percentage = total > 0 ? Number(((count / total) * 100).toFixed(1)) : 0;
|
||||
return {
|
||||
type,
|
||||
count,
|
||||
percentage,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 雷达大屏全量综合数据聚合
|
||||
*/
|
||||
async getAnalytics(params?: { cardId?: number; days?: number }): Promise<RadarAnalyticsResult> {
|
||||
const cardId = params?.cardId !== undefined ? Number(params.cardId) : undefined;
|
||||
const days = params?.days !== undefined ? Number(params.days) : 7;
|
||||
|
||||
const [countryDistribution, hourlyStats, deviceStats] = await Promise.all([
|
||||
this.getCountryDistribution(cardId),
|
||||
this.getHourlyStayStats(days, cardId),
|
||||
this.getDeviceStats(cardId),
|
||||
]);
|
||||
|
||||
const totalVisitors = countryDistribution.reduce((acc, cur) => acc + cur.visitorCount, 0);
|
||||
const totalDuration = countryDistribution.reduce(
|
||||
(acc, cur) => acc + cur.visitorCount * cur.avgStayDurationMs,
|
||||
0
|
||||
);
|
||||
const avgStayDurationMs = totalVisitors > 0 ? Math.round(totalDuration / totalVisitors) : 0;
|
||||
const topCountry = countryDistribution.length > 0 ? countryDistribution[0].country : '-';
|
||||
const countriesCount = countryDistribution.length;
|
||||
|
||||
return {
|
||||
countryDistribution,
|
||||
hourlyStats,
|
||||
deviceStats,
|
||||
summary: {
|
||||
totalVisitors,
|
||||
avgStayDurationMs,
|
||||
topCountry,
|
||||
countriesCount,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import * as crypto from 'crypto';
|
||||
import axios from 'axios';
|
||||
import { Provide } from '@midwayjs/core';
|
||||
import { BaseService, CoolCommException } from '@cool-midway/core';
|
||||
import { CrmLeadEntity } from '../entity/lead';
|
||||
|
||||
/**
|
||||
* HubSpot Contacts v3 API 格式
|
||||
*/
|
||||
export interface HubSpotContactPayload {
|
||||
properties: {
|
||||
email: string;
|
||||
firstname: string;
|
||||
lastname: string;
|
||||
phone: string;
|
||||
company: string;
|
||||
lifecyclestage: 'lead';
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Salesforce Lead sObject 格式
|
||||
*/
|
||||
export interface SalesforceLeadPayload {
|
||||
FirstName: string;
|
||||
LastName: string;
|
||||
Email: string;
|
||||
Company: string;
|
||||
Phone: string;
|
||||
Status: 'Open - Not Contacted';
|
||||
}
|
||||
|
||||
/**
|
||||
* Webhook 发送配置选项
|
||||
*/
|
||||
export interface WebhookSendOptions {
|
||||
maxRetries?: number;
|
||||
delayMs?: number;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Webhook 发送响应结果
|
||||
*/
|
||||
export interface WebhookResult {
|
||||
success: boolean;
|
||||
attempts: number;
|
||||
data?: unknown;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 国际主流 CRM (HubSpot / Salesforce / Webhook) 对接服务
|
||||
*/
|
||||
@Provide()
|
||||
export class CrmSyncService extends BaseService {
|
||||
/**
|
||||
* 智能拆分姓名
|
||||
*/
|
||||
private splitName(name?: string): { firstName: string; lastName: string } {
|
||||
const parts = (name || '').trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 0) {
|
||||
return { firstName: '', lastName: '' };
|
||||
}
|
||||
if (parts.length === 1) {
|
||||
return { firstName: parts[0], lastName: '' };
|
||||
}
|
||||
return {
|
||||
firstName: parts[0],
|
||||
lastName: parts.slice(1).join(' '),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步延时工具
|
||||
*/
|
||||
private sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换成 HubSpot Contacts v3 API 格式
|
||||
*/
|
||||
formatToHubSpot(lead: Partial<CrmLeadEntity>): HubSpotContactPayload {
|
||||
const { firstName, lastName } = this.splitName(lead?.name);
|
||||
return {
|
||||
properties: {
|
||||
email: lead?.email || '',
|
||||
firstname: firstName,
|
||||
lastname: lastName,
|
||||
phone: lead?.phone || '',
|
||||
company: lead?.company || '',
|
||||
lifecyclestage: 'lead',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换成 Salesforce Lead sObject 格式
|
||||
*/
|
||||
formatToSalesforce(lead: Partial<CrmLeadEntity>): SalesforceLeadPayload {
|
||||
const { firstName, lastName } = this.splitName(lead?.name);
|
||||
let finalFirstName = firstName;
|
||||
let finalLastName = lastName;
|
||||
|
||||
// Salesforce 规范:LastName 必填;若仅单名,LastName 取该名,FirstName 为空;若全无,LastName 为 'Unknown'
|
||||
if (!lastName) {
|
||||
finalFirstName = '';
|
||||
finalLastName = firstName || 'Unknown';
|
||||
}
|
||||
|
||||
return {
|
||||
FirstName: finalFirstName,
|
||||
LastName: finalLastName,
|
||||
Email: lead?.email || '',
|
||||
Company: lead?.company || 'Unknown',
|
||||
Phone: lead?.phone || '',
|
||||
Status: 'Open - Not Contacted',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算 HMAC-SHA256 签名
|
||||
*/
|
||||
generateSignature(payload: unknown, secret: string): string {
|
||||
const content =
|
||||
typeof payload === 'string' ? payload : JSON.stringify(payload);
|
||||
return crypto.createHmac('sha256', secret).update(content).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送带 HMAC-SHA256 签名的 Webhook,具备模拟退避重试(最多 3 次)
|
||||
*/
|
||||
async sendWebhook(
|
||||
url: string,
|
||||
secret: string,
|
||||
lead: Partial<CrmLeadEntity>,
|
||||
options?: WebhookSendOptions
|
||||
): Promise<WebhookResult> {
|
||||
if (!url || !url.trim()) {
|
||||
throw new CoolCommException('缺少目标 Webhook URL', 400);
|
||||
}
|
||||
|
||||
const payload = {
|
||||
event: 'crm.lead.sync',
|
||||
timestamp: Date.now(),
|
||||
data: lead,
|
||||
};
|
||||
|
||||
const signature = this.generateSignature(payload, secret || '');
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-OmniCard-Signature': signature,
|
||||
};
|
||||
|
||||
const maxRetries = options?.maxRetries ?? 3;
|
||||
const baseDelay = options?.delayMs ?? 100;
|
||||
const timeout = options?.timeoutMs ?? 5000;
|
||||
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
const response = await axios.post(url, payload, { headers, timeout });
|
||||
return {
|
||||
success: true,
|
||||
attempts: attempt,
|
||||
data: response.data,
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
lastError = err instanceof Error ? err : new Error(String(err));
|
||||
if (attempt < maxRetries) {
|
||||
const delay = baseDelay * Math.pow(2, attempt - 1);
|
||||
await this.sleep(delay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
attempts: maxRetries,
|
||||
error: lastError?.message || 'Webhook request failed',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Provide } from '@midwayjs/core';
|
||||
import { RADAR_VISITOR_EVENT } from '../service/push';
|
||||
|
||||
/**
|
||||
* 雷达访客实时 Socket 控制器骨架。
|
||||
*
|
||||
* 当前依赖不齐:
|
||||
* - @midwayjs/core 未导出 WSController / OnWSConnection / OnWSMessage
|
||||
* - configuration.ts 尚未引入 @midwayjs/socketio(本任务禁止改动该文件)
|
||||
*
|
||||
* 待 configuration 挂载 socketio 后,将本类补齐为:
|
||||
* @WSController('/')
|
||||
* @OnWSMessage('radar.visitor') 接收订阅,并 ctx.join(roomOf(cardId))
|
||||
*
|
||||
* 业务投递以 CrmRadarPushService 内存渠道为准,本骨架不伪造设备推送成功。
|
||||
*/
|
||||
@Provide()
|
||||
export class CrmRadarSocketController {
|
||||
readonly event = RADAR_VISITOR_EVENT;
|
||||
|
||||
/**
|
||||
* 名片房间名。接入 socketio 后对应 ctx.join(room)。
|
||||
*/
|
||||
roomOf(cardId: string | number): string {
|
||||
return `card:${String(cardId)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理客户端 radar.visitor 订阅意图,返回将要加入的房间。
|
||||
* 真实 WS 装饰器待 socketio 接入后挂上。
|
||||
*/
|
||||
onRadarVisitor(data: { cardId: string | number }): { event: string; room: string } {
|
||||
return {
|
||||
event: this.event,
|
||||
room: this.roomOf(data.cardId),
|
||||
};
|
||||
}
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户