170 行
4.5 KiB
TypeScript
170 行
4.5 KiB
TypeScript
import { Provide } from '@midwayjs/core';
|
|
import { BaseService } from '@cool-midway/core';
|
|
|
|
export type TrafficChannelType =
|
|
| 'nfc_tap'
|
|
| 'booth_qr'
|
|
| 'whatsapp_chat'
|
|
| 'linkedin_share'
|
|
| 'email_signature'
|
|
| 'direct_or_organic';
|
|
|
|
export type FunnelStageType =
|
|
| 'impression'
|
|
| 'interactive'
|
|
| 'ai_consult'
|
|
| 'lead_submitted'
|
|
| 'meeting_booked';
|
|
|
|
export interface ChannelAttributionEvent {
|
|
visitorId: string;
|
|
channel: TrafficChannelType | string;
|
|
stage: FunnelStageType;
|
|
timestamp?: number;
|
|
}
|
|
|
|
export interface AttributionFunnelReport {
|
|
totalVisitors: number;
|
|
stages: Record<FunnelStageType, number>;
|
|
stageRates: Record<FunnelStageType, number>;
|
|
overallConversionRate: number;
|
|
}
|
|
|
|
export interface ChannelPerformanceItem {
|
|
channel: string;
|
|
visitors: number;
|
|
leads: number;
|
|
leadRate: number;
|
|
meetings: number;
|
|
}
|
|
|
|
/**
|
|
* 流量来源与渠道归一化解析
|
|
*/
|
|
export function normalizeTrafficChannel(
|
|
source?: string,
|
|
medium?: string
|
|
): TrafficChannelType {
|
|
const s = (source || '').toLowerCase().trim();
|
|
const m = (medium || '').toLowerCase().trim();
|
|
|
|
if (s.includes('nfc') || m.includes('nfc') || m.includes('tap')) {
|
|
return 'nfc_tap';
|
|
}
|
|
if (s.includes('booth') || m.includes('booth') || m.includes('qrcode')) {
|
|
return 'booth_qr';
|
|
}
|
|
if (s.includes('whatsapp') || m.includes('whatsapp')) {
|
|
return 'whatsapp_chat';
|
|
}
|
|
if (s.includes('linkedin') || m.includes('linkedin')) {
|
|
return 'linkedin_share';
|
|
}
|
|
if (s.includes('email') || m.includes('sig')) {
|
|
return 'email_signature';
|
|
}
|
|
|
|
return 'direct_or_organic';
|
|
}
|
|
|
|
/**
|
|
* 计算全链路出海留资转化漏斗指标
|
|
*/
|
|
export function calculateAttributionFunnel(
|
|
events: ChannelAttributionEvent[]
|
|
): AttributionFunnelReport {
|
|
const visitorSet = new Set<string>();
|
|
const stageSets: Record<FunnelStageType, Set<string>> = {
|
|
impression: new Set(),
|
|
interactive: new Set(),
|
|
ai_consult: new Set(),
|
|
lead_submitted: new Set(),
|
|
meeting_booked: new Set(),
|
|
};
|
|
|
|
for (const e of events) {
|
|
visitorSet.add(e.visitorId);
|
|
if (stageSets[e.stage]) {
|
|
stageSets[e.stage].add(e.visitorId);
|
|
}
|
|
}
|
|
|
|
const total = visitorSet.size;
|
|
const stages: Record<FunnelStageType, number> = {
|
|
impression: stageSets.impression.size,
|
|
interactive: stageSets.interactive.size,
|
|
ai_consult: stageSets.ai_consult.size,
|
|
lead_submitted: stageSets.lead_submitted.size,
|
|
meeting_booked: stageSets.meeting_booked.size,
|
|
};
|
|
|
|
const stageRates: Record<FunnelStageType, number> = {
|
|
impression: total > 0 ? Number((stages.impression / total).toFixed(2)) : 0,
|
|
interactive: total > 0 ? Number((stages.interactive / total).toFixed(2)) : 0,
|
|
ai_consult: total > 0 ? Number((stages.ai_consult / total).toFixed(2)) : 0,
|
|
lead_submitted: total > 0 ? Number((stages.lead_submitted / total).toFixed(2)) : 0,
|
|
meeting_booked: total > 0 ? Number((stages.meeting_booked / total).toFixed(2)) : 0,
|
|
};
|
|
|
|
const overallConversionRate =
|
|
total > 0 ? Number((stages.lead_submitted / total).toFixed(2)) : 0;
|
|
|
|
return {
|
|
totalVisitors: total,
|
|
stages,
|
|
stageRates,
|
|
overallConversionRate,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 访客雷达多渠道归因与出海转化分析服务
|
|
*/
|
|
@Provide()
|
|
export class RadarAttributionService extends BaseService {
|
|
/**
|
|
* 渠道转化表现分析
|
|
*/
|
|
analyzeChannelPerformance(
|
|
events: ChannelAttributionEvent[]
|
|
): Record<string, ChannelPerformanceItem> {
|
|
const channelMap: Record<string, { visitors: Set<string>; leads: Set<string>; meetings: Set<string> }> = {};
|
|
|
|
for (const e of events) {
|
|
const ch = e.channel;
|
|
if (!channelMap[ch]) {
|
|
channelMap[ch] = {
|
|
visitors: new Set(),
|
|
leads: new Set(),
|
|
meetings: new Set(),
|
|
};
|
|
}
|
|
channelMap[ch].visitors.add(e.visitorId);
|
|
if (e.stage === 'lead_submitted') {
|
|
channelMap[ch].leads.add(e.visitorId);
|
|
}
|
|
if (e.stage === 'meeting_booked') {
|
|
channelMap[ch].meetings.add(e.visitorId);
|
|
}
|
|
}
|
|
|
|
const report: Record<string, ChannelPerformanceItem> = {};
|
|
for (const [channel, data] of Object.entries(channelMap)) {
|
|
const vCount = data.visitors.size;
|
|
const lCount = data.leads.size;
|
|
const mCount = data.meetings.size;
|
|
const rate = vCount > 0 ? Number((lCount / vCount).toFixed(2)) : 0;
|
|
|
|
report[channel] = {
|
|
channel,
|
|
visitors: vCount,
|
|
leads: lCount,
|
|
leadRate: rate,
|
|
meetings: mCount,
|
|
};
|
|
}
|
|
|
|
return report;
|
|
}
|
|
}
|