1266 lines
35 KiB
TypeScript
1266 lines
35 KiB
TypeScript
import { v4 as uuidv4 } from 'uuid';
|
|
import { executeQuery, executeQuerySingle } from './clickhouse';
|
|
|
|
// 时间粒度枚举
|
|
export enum TimeGranularity {
|
|
HOUR = 'hour',
|
|
DAY = 'day',
|
|
WEEK = 'week',
|
|
MONTH = 'month',
|
|
}
|
|
|
|
// 事件类型枚举
|
|
export enum EventType {
|
|
CLICK = 'click',
|
|
REDIRECT = 'redirect',
|
|
CONVERSION = 'conversion',
|
|
ERROR = 'error',
|
|
}
|
|
|
|
// 转化类型枚举
|
|
export enum ConversionType {
|
|
VISIT = 'visit',
|
|
STAY = 'stay',
|
|
INTERACT = 'interact',
|
|
SIGNUP = 'signup',
|
|
SUBSCRIPTION = 'subscription',
|
|
PURCHASE = 'purchase',
|
|
}
|
|
|
|
// 构建日期过滤条件
|
|
function buildDateFilter(startDate?: string, endDate?: string): string {
|
|
let dateFilter = '';
|
|
|
|
if (startDate && endDate) {
|
|
dateFilter = ` AND date >= '${startDate}' AND date <= '${endDate}'`;
|
|
} else if (startDate) {
|
|
dateFilter = ` AND date >= '${startDate}'`;
|
|
} else if (endDate) {
|
|
dateFilter = ` AND date <= '${endDate}'`;
|
|
}
|
|
|
|
return dateFilter;
|
|
}
|
|
|
|
/**
|
|
* 获取链接概览数据
|
|
*/
|
|
export async function getLinkOverview(
|
|
linkId: string,
|
|
startDate?: string,
|
|
endDate?: string,
|
|
) {
|
|
try {
|
|
const dateFilter = buildDateFilter(startDate, endDate);
|
|
const query = `
|
|
SELECT
|
|
count() as total_visits,
|
|
uniq(visitor_id) as unique_visitors,
|
|
avg(time_spent_sec) as average_time_spent,
|
|
countIf(time_spent_sec < 10) as bounce_count,
|
|
countIf(event_type = 'conversion') as conversion_count,
|
|
uniq(referrer) as unique_referrers,
|
|
countIf(device_type = 'mobile') as mobile_count,
|
|
countIf(device_type = 'tablet') as tablet_count,
|
|
countIf(device_type = 'desktop') as desktop_count,
|
|
countIf(device_type = 'other') as other_count,
|
|
countIf(is_qr_scan = true) as qr_scan_count,
|
|
sum(conversion_value) as total_conversion_value
|
|
FROM link_events
|
|
WHERE link_id = '${linkId}'
|
|
${dateFilter}
|
|
`;
|
|
|
|
const result = await executeQuerySingle<{
|
|
total_visits: number;
|
|
unique_visitors: number;
|
|
average_time_spent: number;
|
|
bounce_count: number;
|
|
conversion_count: number;
|
|
unique_referrers: number;
|
|
mobile_count: number;
|
|
tablet_count: number;
|
|
desktop_count: number;
|
|
other_count: number;
|
|
qr_scan_count: number;
|
|
total_conversion_value: number;
|
|
}>(query);
|
|
|
|
if (!result) {
|
|
return {
|
|
totalVisits: 0,
|
|
uniqueVisitors: 0,
|
|
averageTimeSpent: 0,
|
|
bounceCount: 0,
|
|
conversionCount: 0,
|
|
uniqueReferrers: 0,
|
|
deviceTypes: {
|
|
mobile: 0,
|
|
tablet: 0,
|
|
desktop: 0,
|
|
other: 0,
|
|
},
|
|
qrScanCount: 0,
|
|
totalConversionValue: 0,
|
|
};
|
|
}
|
|
|
|
// 将设备类型计数转换为字典
|
|
const deviceTypes = {
|
|
mobile: Number(result.mobile_count),
|
|
tablet: Number(result.tablet_count),
|
|
desktop: Number(result.desktop_count),
|
|
other: Number(result.other_count),
|
|
};
|
|
|
|
return {
|
|
totalVisits: Number(result.total_visits),
|
|
uniqueVisitors: Number(result.unique_visitors),
|
|
averageTimeSpent: Number(result.average_time_spent),
|
|
bounceCount: Number(result.bounce_count),
|
|
conversionCount: Number(result.conversion_count),
|
|
uniqueReferrers: Number(result.unique_referrers),
|
|
deviceTypes,
|
|
qrScanCount: Number(result.qr_scan_count),
|
|
totalConversionValue: Number(result.total_conversion_value),
|
|
};
|
|
} catch (error) {
|
|
console.error('获取链接概览数据失败', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 获取转化漏斗数据
|
|
*/
|
|
export async function getConversionFunnel(
|
|
linkId: string,
|
|
startDate?: string,
|
|
endDate?: string,
|
|
) {
|
|
try {
|
|
const dateFilter = buildDateFilter(startDate, endDate);
|
|
const query = `
|
|
SELECT
|
|
countIf(conversion_type = 'visit') as visit_count,
|
|
countIf(conversion_type = 'stay') as stay_count,
|
|
countIf(conversion_type = 'interact') as interact_count,
|
|
countIf(conversion_type = 'signup') as signup_count,
|
|
countIf(conversion_type = 'subscription') as subscription_count,
|
|
countIf(conversion_type = 'purchase') as purchase_count
|
|
FROM link_events
|
|
WHERE link_id = '${linkId}' AND event_type = 'conversion'
|
|
${dateFilter}
|
|
`;
|
|
|
|
const result = await executeQuerySingle<{
|
|
visit_count: number;
|
|
stay_count: number;
|
|
interact_count: number;
|
|
signup_count: number;
|
|
subscription_count: number;
|
|
purchase_count: number;
|
|
}>(query);
|
|
|
|
if (!result) {
|
|
return {
|
|
steps: [
|
|
{ name: 'Visit', value: 0, percent: 0 },
|
|
{ name: 'Stay', value: 0, percent: 0 },
|
|
{ name: 'Interact', value: 0, percent: 0 },
|
|
{ name: 'Signup', value: 0, percent: 0 },
|
|
{ name: 'Subscription', value: 0, percent: 0 },
|
|
{ name: 'Purchase', value: 0, percent: 0 },
|
|
],
|
|
totalConversions: 0,
|
|
conversionRate: 0,
|
|
};
|
|
}
|
|
|
|
// 计算总转化数
|
|
const totalConversions =
|
|
Number(result.visit_count) +
|
|
Number(result.stay_count) +
|
|
Number(result.interact_count) +
|
|
Number(result.signup_count) +
|
|
Number(result.subscription_count) +
|
|
Number(result.purchase_count);
|
|
|
|
// 计算转化率
|
|
const conversionRate = totalConversions > 0 ?
|
|
Number(result.purchase_count) / Number(result.visit_count) * 100 : 0;
|
|
|
|
// 构建步骤数据
|
|
const steps = [
|
|
{
|
|
name: 'Visit',
|
|
value: Number(result.visit_count),
|
|
percent: 100,
|
|
},
|
|
{
|
|
name: 'Stay',
|
|
value: Number(result.stay_count),
|
|
percent: result.visit_count > 0
|
|
? (Number(result.stay_count) / Number(result.visit_count)) * 100
|
|
: 0,
|
|
},
|
|
{
|
|
name: 'Interact',
|
|
value: Number(result.interact_count),
|
|
percent: result.visit_count > 0
|
|
? (Number(result.interact_count) / Number(result.visit_count)) * 100
|
|
: 0,
|
|
},
|
|
{
|
|
name: 'Signup',
|
|
value: Number(result.signup_count),
|
|
percent: result.visit_count > 0
|
|
? (Number(result.signup_count) / Number(result.visit_count)) * 100
|
|
: 0,
|
|
},
|
|
{
|
|
name: 'Subscription',
|
|
value: Number(result.subscription_count),
|
|
percent: result.visit_count > 0
|
|
? (Number(result.subscription_count) / Number(result.visit_count)) * 100
|
|
: 0,
|
|
},
|
|
{
|
|
name: 'Purchase',
|
|
value: Number(result.purchase_count),
|
|
percent: result.visit_count > 0
|
|
? (Number(result.purchase_count) / Number(result.visit_count)) * 100
|
|
: 0,
|
|
},
|
|
];
|
|
|
|
return {
|
|
steps,
|
|
totalConversions,
|
|
conversionRate,
|
|
};
|
|
} catch (error) {
|
|
console.error('获取转化漏斗数据失败', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 获取访问趋势数据
|
|
*/
|
|
export async function getVisitTrends(
|
|
linkId: string,
|
|
startDate?: string,
|
|
endDate?: string,
|
|
granularity: TimeGranularity = TimeGranularity.DAY,
|
|
) {
|
|
try {
|
|
const dateFilter = buildDateFilter(startDate, endDate);
|
|
|
|
const queryString = `
|
|
SELECT
|
|
toStartOfInterval(event_time, INTERVAL 1 ${granularity}) as timestamp,
|
|
count() as visits,
|
|
uniq(visitor_id) as unique_visitors
|
|
FROM link_events
|
|
WHERE link_id = '${linkId}'
|
|
${dateFilter}
|
|
GROUP BY timestamp
|
|
ORDER BY timestamp
|
|
`;
|
|
|
|
const results = await executeQuery<{
|
|
timestamp: string;
|
|
visits: number;
|
|
unique_visitors: number;
|
|
}>(queryString);
|
|
|
|
// 计算总计
|
|
const totals = {
|
|
visits: results.reduce((sum, item) => sum + Number(item.visits), 0),
|
|
uniqueVisitors: results.reduce((sum, item) => sum + Number(item.unique_visitors), 0),
|
|
};
|
|
|
|
// 格式化时间戳
|
|
const trends = results.map(item => ({
|
|
timestamp: formatTimestamp(item.timestamp, granularity),
|
|
visits: Number(item.visits),
|
|
uniqueVisitors: Number(item.unique_visitors),
|
|
}));
|
|
|
|
return {
|
|
trends,
|
|
totals,
|
|
};
|
|
} catch (error) {
|
|
console.error('获取访问趋势数据失败', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 追踪事件
|
|
*/
|
|
export async function trackEvent(eventData: {
|
|
linkId: string;
|
|
eventType: EventType;
|
|
visitorId?: string;
|
|
sessionId?: string;
|
|
referrer?: string;
|
|
userAgent?: string;
|
|
ipAddress?: string;
|
|
timeSpent?: number;
|
|
conversionType?: ConversionType;
|
|
conversionValue?: number;
|
|
customData?: Record<string, unknown>;
|
|
isQrScan?: boolean;
|
|
qrCodeId?: string;
|
|
utmSource?: string;
|
|
utmMedium?: string;
|
|
utmCampaign?: string;
|
|
}) {
|
|
try {
|
|
// 检查必要字段
|
|
if (!eventData.linkId) {
|
|
throw new Error('Missing required field: linkId');
|
|
}
|
|
|
|
// 生成缺失的ID和时间戳
|
|
const eventId = uuidv4();
|
|
const timestamp = new Date().toISOString();
|
|
const visitorId = eventData.visitorId || uuidv4();
|
|
const sessionId = eventData.sessionId || uuidv4();
|
|
|
|
// 设置默认值
|
|
const isQrScan = !!eventData.isQrScan;
|
|
const qrCodeId = eventData.qrCodeId || '';
|
|
const conversionValue = eventData.conversionValue || 0;
|
|
const conversionType = eventData.conversionType || ConversionType.VISIT;
|
|
const timeSpentSec = eventData.timeSpent || 0;
|
|
|
|
// 准备插入数据
|
|
const insertQuery = `
|
|
INSERT INTO link_events (
|
|
event_id, event_time, link_id, visitor_id, session_id,
|
|
event_type, ip_address, referrer, utm_source, utm_medium,
|
|
utm_campaign, user_agent, time_spent_sec, is_qr_scan,
|
|
qr_code_id, conversion_type, conversion_value, custom_data
|
|
) VALUES (
|
|
'${eventId}', '${timestamp}', '${eventData.linkId}', '${visitorId}', '${sessionId}',
|
|
'${eventData.eventType}', '${eventData.ipAddress || ''}', '${eventData.referrer || ''}',
|
|
'${eventData.utmSource || ''}', '${eventData.utmMedium || ''}',
|
|
'${eventData.utmCampaign || ''}', '${eventData.userAgent || ''}', ${timeSpentSec}, ${isQrScan},
|
|
'${qrCodeId}', '${conversionType}', ${conversionValue}, '${JSON.stringify(eventData.customData || {})}'
|
|
)
|
|
`;
|
|
|
|
await executeQuery(insertQuery);
|
|
|
|
return {
|
|
success: true,
|
|
eventId,
|
|
timestamp,
|
|
};
|
|
} catch (error) {
|
|
console.error('事件追踪失败', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 格式化时间戳
|
|
*/
|
|
function formatTimestamp(timestamp: string, granularity: TimeGranularity): string {
|
|
const date = new Date(timestamp);
|
|
|
|
switch (granularity) {
|
|
case TimeGranularity.HOUR:
|
|
return `${date.toISOString().substring(0, 13)}:00`;
|
|
case TimeGranularity.DAY:
|
|
return date.toISOString().substring(0, 10);
|
|
case TimeGranularity.WEEK: {
|
|
const firstDayOfYear = new Date(date.getFullYear(), 0, 1);
|
|
const pastDaysOfYear = (date.getTime() - firstDayOfYear.getTime()) / 86400000;
|
|
const weekNum = Math.ceil((pastDaysOfYear + firstDayOfYear.getDay() + 1) / 7);
|
|
return `${date.getFullYear()}-W${weekNum.toString().padStart(2, '0')}`;
|
|
}
|
|
case TimeGranularity.MONTH:
|
|
return date.toISOString().substring(0, 7);
|
|
default:
|
|
return date.toISOString().substring(0, 10);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 获取链接表现数据
|
|
*/
|
|
export async function getLinkPerformance(
|
|
linkId: string,
|
|
startDate?: string,
|
|
endDate?: string,
|
|
) {
|
|
try {
|
|
const dateFilter = buildDateFilter(startDate, endDate);
|
|
const query = `
|
|
SELECT
|
|
count() as total_clicks,
|
|
uniq(visitor_id) as unique_visitors,
|
|
avg(time_spent_sec) as average_time_spent,
|
|
countIf(time_spent_sec < 10) as bounce_count,
|
|
uniq(referrer) as unique_referrers,
|
|
countIf(event_type = 'conversion' AND conversion_type = 'purchase') as conversion_count,
|
|
count(DISTINCT DATE(event_time)) as active_days,
|
|
max(event_time) as last_click_time,
|
|
countIf(device_type = 'mobile') as mobile_clicks,
|
|
countIf(device_type = 'desktop') as desktop_clicks
|
|
FROM link_events
|
|
WHERE link_id = '${linkId}'
|
|
${dateFilter}
|
|
`;
|
|
|
|
const result = await executeQuerySingle<{
|
|
total_clicks: number;
|
|
unique_visitors: number;
|
|
average_time_spent: number;
|
|
bounce_count: number;
|
|
unique_referrers: number;
|
|
conversion_count: number;
|
|
active_days: number;
|
|
last_click_time: string;
|
|
mobile_clicks: number;
|
|
desktop_clicks: number;
|
|
}>(query);
|
|
|
|
if (!result) {
|
|
return {
|
|
totalClicks: 0,
|
|
uniqueVisitors: 0,
|
|
averageTimeSpent: 0,
|
|
bounceRate: 0,
|
|
uniqueReferrers: 0,
|
|
conversionRate: 0,
|
|
activeDays: 0,
|
|
lastClickTime: null,
|
|
deviceDistribution: {
|
|
mobile: 0,
|
|
desktop: 0,
|
|
},
|
|
};
|
|
}
|
|
|
|
// 计算跳出率
|
|
const bounceRate = result.total_clicks > 0 ?
|
|
(result.bounce_count / result.total_clicks) * 100 : 0;
|
|
|
|
// 计算转化率
|
|
const conversionRate = result.unique_visitors > 0 ?
|
|
(result.conversion_count / result.unique_visitors) * 100 : 0;
|
|
|
|
return {
|
|
totalClicks: Number(result.total_clicks),
|
|
uniqueVisitors: Number(result.unique_visitors),
|
|
averageTimeSpent: Number(result.average_time_spent),
|
|
bounceRate: Number(bounceRate.toFixed(2)),
|
|
uniqueReferrers: Number(result.unique_referrers),
|
|
conversionRate: Number(conversionRate.toFixed(2)),
|
|
activeDays: Number(result.active_days),
|
|
lastClickTime: result.last_click_time,
|
|
deviceDistribution: {
|
|
mobile: Number(result.mobile_clicks),
|
|
desktop: Number(result.desktop_clicks),
|
|
},
|
|
};
|
|
} catch (error) {
|
|
console.error('获取链接表现数据失败', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 获取平台分布数据
|
|
*/
|
|
export async function getPlatformDistribution(
|
|
startDate?: string,
|
|
endDate?: string,
|
|
linkId?: string,
|
|
) {
|
|
try {
|
|
const dateFilter = buildDateFilter(startDate, endDate);
|
|
|
|
// 构建链接过滤条件
|
|
let linkFilter = '';
|
|
if (linkId) {
|
|
linkFilter = ` AND link_id = '${linkId}'`;
|
|
}
|
|
|
|
const query = `
|
|
SELECT
|
|
os,
|
|
browser,
|
|
count() as visit_count
|
|
FROM link_events
|
|
WHERE 1=1
|
|
${dateFilter}
|
|
${linkFilter}
|
|
GROUP BY os, browser
|
|
ORDER BY visit_count DESC
|
|
`;
|
|
|
|
const results = await executeQuery<{
|
|
os: string;
|
|
browser: string;
|
|
visit_count: number;
|
|
}>(query);
|
|
|
|
// 平台统计
|
|
const platforms: { [key: string]: number } = {};
|
|
// 浏览器统计
|
|
const browsers: { [key: string]: number } = {};
|
|
|
|
// 计算总访问量
|
|
const totalVisits = results.reduce((sum, item) => sum + Number(item.visit_count), 0);
|
|
|
|
// 处理平台和浏览器数据
|
|
for (const item of results) {
|
|
const platform = item.os || 'unknown';
|
|
const browser = item.browser || 'unknown';
|
|
const count = Number(item.visit_count);
|
|
|
|
// 累加平台数据
|
|
platforms[platform] = (platforms[platform] || 0) + count;
|
|
|
|
// 累加浏览器数据
|
|
browsers[browser] = (browsers[browser] || 0) + count;
|
|
}
|
|
|
|
// 计算百分比并格式化结果
|
|
const platformData = Object.entries(platforms).map(([name, count]) => ({
|
|
name,
|
|
count: Number(count),
|
|
percent: totalVisits > 0 ? Number(((count / totalVisits) * 100).toFixed(1)) : 0,
|
|
}));
|
|
|
|
const browserData = Object.entries(browsers).map(([name, count]) => ({
|
|
name,
|
|
count: Number(count),
|
|
percent: totalVisits > 0 ? Number(((count / totalVisits) * 100).toFixed(1)) : 0,
|
|
}));
|
|
|
|
// 按访问量排序
|
|
platformData.sort((a, b) => b.count - a.count);
|
|
browserData.sort((a, b) => b.count - a.count);
|
|
|
|
return {
|
|
totalVisits: totalVisits,
|
|
platforms: platformData,
|
|
browsers: browserData,
|
|
};
|
|
} catch (error) {
|
|
console.error('获取平台分布数据失败', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 获取链接状态分布数据
|
|
*/
|
|
export async function getLinkStatusDistribution(
|
|
startDate?: string,
|
|
endDate?: string,
|
|
projectId?: string,
|
|
) {
|
|
try {
|
|
const dateFilter = buildDateFilter(startDate, endDate);
|
|
|
|
// 构建项目过滤条件
|
|
let projectFilter = '';
|
|
if (projectId) {
|
|
projectFilter = ` AND project_id = '${projectId}'`;
|
|
}
|
|
|
|
const query = `
|
|
SELECT
|
|
is_active,
|
|
count() as link_count
|
|
FROM links
|
|
WHERE 1=1
|
|
${dateFilter}
|
|
${projectFilter}
|
|
GROUP BY is_active
|
|
`;
|
|
|
|
const results = await executeQuery<{
|
|
is_active: boolean;
|
|
link_count: number;
|
|
}>(query);
|
|
|
|
// 初始化数据
|
|
let activeCount = 0;
|
|
let inactiveCount = 0;
|
|
|
|
// 处理查询结果
|
|
for (const item of results) {
|
|
if (item.is_active) {
|
|
activeCount = Number(item.link_count);
|
|
} else {
|
|
inactiveCount = Number(item.link_count);
|
|
}
|
|
}
|
|
|
|
// 计算总数
|
|
const totalLinks = activeCount + inactiveCount;
|
|
|
|
// 计算百分比
|
|
const activePercent = totalLinks > 0 ? (activeCount / totalLinks) * 100 : 0;
|
|
const inactivePercent = totalLinks > 0 ? (inactiveCount / totalLinks) * 100 : 0;
|
|
|
|
// 构建状态分布数据
|
|
const statusDistribution = [
|
|
{
|
|
status: 'active',
|
|
count: activeCount,
|
|
percent: Number(activePercent.toFixed(1)),
|
|
},
|
|
{
|
|
status: 'inactive',
|
|
count: inactiveCount,
|
|
percent: Number(inactivePercent.toFixed(1)),
|
|
},
|
|
];
|
|
|
|
return {
|
|
totalLinks,
|
|
statusDistribution,
|
|
};
|
|
} catch (error) {
|
|
console.error('获取链接状态分布数据失败', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 获取设备分析详情
|
|
*/
|
|
export async function getDeviceAnalysis(
|
|
startDate?: string,
|
|
endDate?: string,
|
|
linkId?: string,
|
|
) {
|
|
try {
|
|
const dateFilter = buildDateFilter(startDate, endDate);
|
|
|
|
// 构建链接过滤条件
|
|
let linkFilter = '';
|
|
if (linkId) {
|
|
linkFilter = ` AND link_id = '${linkId}'`;
|
|
}
|
|
|
|
const query = `
|
|
SELECT
|
|
device_type,
|
|
count() as visit_count
|
|
FROM link_events
|
|
WHERE 1=1
|
|
${dateFilter}
|
|
${linkFilter}
|
|
GROUP BY device_type
|
|
ORDER BY visit_count DESC
|
|
`;
|
|
|
|
const results = await executeQuery<{
|
|
device_type: string;
|
|
visit_count: number;
|
|
}>(query);
|
|
|
|
// 设备类型统计
|
|
const deviceTypes: { [key: string]: number } = {};
|
|
|
|
// 计算总访问量
|
|
const totalVisits = results.reduce((sum, item) => sum + Number(item.visit_count), 0);
|
|
|
|
// 处理设备数据
|
|
for (const item of results) {
|
|
const type = item.device_type || 'unknown';
|
|
const count = Number(item.visit_count);
|
|
|
|
// 累加类型数据
|
|
deviceTypes[type] = (deviceTypes[type] || 0) + count;
|
|
}
|
|
|
|
// 计算百分比并格式化类型结果
|
|
const typeData = Object.entries(deviceTypes).map(([name, count]) => ({
|
|
name,
|
|
count: Number(count),
|
|
percent: totalVisits > 0 ? Number(((count / totalVisits) * 100).toFixed(1)) : 0,
|
|
}));
|
|
|
|
// 排序类型数据
|
|
typeData.sort((a, b) => b.count - a.count);
|
|
|
|
return {
|
|
totalVisits,
|
|
deviceTypes: typeData,
|
|
deviceBrands: [], // 返回空数组,因为数据库中没有设备品牌信息
|
|
deviceModels: [], // 返回空数组,因为数据库中没有设备型号信息
|
|
};
|
|
} catch (error) {
|
|
console.error('获取设备分析详情失败', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 获取热门链接数据
|
|
*/
|
|
export async function getPopularLinks(
|
|
startDate?: string,
|
|
endDate?: string,
|
|
projectId?: string,
|
|
sortBy: 'visits' | 'uniqueVisitors' | 'conversionRate' = 'visits',
|
|
limit: number = 10,
|
|
) {
|
|
try {
|
|
const dateFilter = buildDateFilter(startDate, endDate);
|
|
|
|
// 构建项目过滤条件
|
|
let projectFilter = '';
|
|
if (projectId) {
|
|
projectFilter = ` AND l.project_id = '${projectId}'`;
|
|
}
|
|
|
|
// 根据排序字段构建ORDER BY子句
|
|
let orderBy = '';
|
|
switch (sortBy) {
|
|
case 'visits':
|
|
orderBy = 'ORDER BY total_visits DESC';
|
|
break;
|
|
case 'uniqueVisitors':
|
|
orderBy = 'ORDER BY unique_visitors DESC';
|
|
break;
|
|
case 'conversionRate':
|
|
orderBy = 'ORDER BY conversion_rate DESC';
|
|
break;
|
|
default:
|
|
orderBy = 'ORDER BY total_visits DESC';
|
|
}
|
|
|
|
const query = `
|
|
SELECT
|
|
l.link_id,
|
|
l.original_url,
|
|
l.title,
|
|
l.is_active,
|
|
count() as total_visits,
|
|
uniq(e.visitor_id) as unique_visitors,
|
|
countIf(e.event_type = 'conversion' AND e.conversion_type = 'purchase') as conversion_count,
|
|
countIf(e.time_spent_sec < 10) as bounce_count
|
|
FROM links l
|
|
JOIN link_events e ON l.link_id = e.link_id
|
|
WHERE 1=1
|
|
${dateFilter}
|
|
${projectFilter}
|
|
GROUP BY l.link_id, l.original_url, l.title, l.is_active
|
|
${orderBy}
|
|
LIMIT ${limit}
|
|
`;
|
|
|
|
const results = await executeQuery<{
|
|
link_id: string;
|
|
original_url: string;
|
|
title: string;
|
|
is_active: boolean;
|
|
total_visits: number;
|
|
unique_visitors: number;
|
|
conversion_count: number;
|
|
bounce_count: number;
|
|
}>(query);
|
|
|
|
// 处理查询结果
|
|
const links = results.map(link => {
|
|
const totalVisits = Number(link.total_visits);
|
|
const uniqueVisitors = Number(link.unique_visitors);
|
|
const conversionCount = Number(link.conversion_count);
|
|
const bounceCount = Number(link.bounce_count);
|
|
|
|
// 计算转化率
|
|
const conversionRate = uniqueVisitors > 0
|
|
? (conversionCount / uniqueVisitors) * 100
|
|
: 0;
|
|
|
|
// 计算跳出率
|
|
const bounceRate = totalVisits > 0
|
|
? (bounceCount / totalVisits) * 100
|
|
: 0;
|
|
|
|
return {
|
|
id: link.link_id,
|
|
url: link.original_url,
|
|
title: link.title || '无标题',
|
|
isActive: link.is_active,
|
|
totalVisits,
|
|
uniqueVisitors,
|
|
conversionCount,
|
|
conversionRate: Number(conversionRate.toFixed(2)),
|
|
bounceCount,
|
|
bounceRate: Number(bounceRate.toFixed(2)),
|
|
};
|
|
});
|
|
|
|
return {
|
|
links,
|
|
totalCount: links.length,
|
|
};
|
|
} catch (error) {
|
|
console.error('获取热门链接数据失败', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 获取热门引荐来源数据
|
|
*/
|
|
export async function getPopularReferrers(
|
|
startDate?: string,
|
|
endDate?: string,
|
|
linkId?: string,
|
|
type: 'domain' | 'full' = 'domain',
|
|
limit: number = 10,
|
|
) {
|
|
try {
|
|
const dateFilter = buildDateFilter(startDate, endDate);
|
|
|
|
// 构建链接过滤条件
|
|
let linkFilter = '';
|
|
if (linkId) {
|
|
linkFilter = ` AND link_id = '${linkId}'`;
|
|
}
|
|
|
|
// 决定是按域名还是完整URL分组
|
|
const groupByField = type === 'domain'
|
|
? 'domain(referrer)'
|
|
: 'referrer';
|
|
|
|
const query = `
|
|
SELECT
|
|
${groupByField} as source,
|
|
count() as visit_count,
|
|
uniq(visitor_id) as unique_visitors,
|
|
countIf(event_type = 'conversion' AND conversion_type = 'purchase') as conversion_count,
|
|
avg(time_spent_sec) as average_time_spent
|
|
FROM link_events
|
|
WHERE referrer != ''
|
|
${dateFilter}
|
|
${linkFilter}
|
|
GROUP BY ${groupByField}
|
|
ORDER BY visit_count DESC
|
|
LIMIT ${limit}
|
|
`;
|
|
|
|
const results = await executeQuery<{
|
|
source: string;
|
|
visit_count: number;
|
|
unique_visitors: number;
|
|
conversion_count: number;
|
|
average_time_spent: number;
|
|
}>(query);
|
|
|
|
// 计算总访问量
|
|
const totalVisits = results.reduce((sum, item) => sum + Number(item.visit_count), 0);
|
|
|
|
// 处理查询结果
|
|
const referrers = results.map(referrer => {
|
|
const visitCount = Number(referrer.visit_count);
|
|
const uniqueVisitors = Number(referrer.unique_visitors);
|
|
const conversionCount = Number(referrer.conversion_count);
|
|
|
|
// 计算转化率
|
|
const conversionRate = uniqueVisitors > 0
|
|
? (conversionCount / uniqueVisitors) * 100
|
|
: 0;
|
|
|
|
// 计算百分比
|
|
const percent = totalVisits > 0
|
|
? (visitCount / totalVisits) * 100
|
|
: 0;
|
|
|
|
return {
|
|
source: referrer.source || '(direct)',
|
|
visitCount,
|
|
uniqueVisitors,
|
|
conversionCount,
|
|
conversionRate: Number(conversionRate.toFixed(2)),
|
|
averageTimeSpent: Number(referrer.average_time_spent),
|
|
percent: Number(percent.toFixed(1)),
|
|
};
|
|
});
|
|
|
|
return {
|
|
referrers,
|
|
totalVisits,
|
|
};
|
|
} catch (error) {
|
|
console.error('获取热门引荐来源数据失败', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 获取QR码分析数据
|
|
*/
|
|
export async function getQrCodeAnalysis(
|
|
startDate?: string,
|
|
endDate?: string,
|
|
linkId?: string,
|
|
qrCodeId?: string,
|
|
) {
|
|
try {
|
|
const dateFilter = buildDateFilter(startDate, endDate);
|
|
|
|
// 构建过滤条件
|
|
let filters = ' AND is_qr_scan = true';
|
|
if (linkId) {
|
|
filters += ` AND link_id = '${linkId}'`;
|
|
}
|
|
if (qrCodeId) {
|
|
filters += ` AND qr_code_id = '${qrCodeId}'`;
|
|
}
|
|
|
|
// 查询QR码扫描基本指标
|
|
const basicQuery = `
|
|
SELECT
|
|
count() as total_scans,
|
|
uniq(visitor_id) as unique_scanners,
|
|
countIf(event_type = 'conversion' AND conversion_type = 'purchase') as conversion_count,
|
|
avg(time_spent_sec) as average_time_spent
|
|
FROM link_events
|
|
WHERE 1=1
|
|
${dateFilter}
|
|
${filters}
|
|
`;
|
|
|
|
const basicResult = await executeQuerySingle<{
|
|
total_scans: number;
|
|
unique_scanners: number;
|
|
conversion_count: number;
|
|
average_time_spent: number;
|
|
}>(basicQuery);
|
|
|
|
// 查询QR码扫描的位置分布
|
|
const locationQuery = `
|
|
SELECT
|
|
city,
|
|
country,
|
|
count() as scan_count
|
|
FROM link_events
|
|
WHERE 1=1
|
|
${dateFilter}
|
|
${filters}
|
|
GROUP BY city, country
|
|
ORDER BY scan_count DESC
|
|
LIMIT 10
|
|
`;
|
|
|
|
const locationResults = await executeQuery<{
|
|
city: string;
|
|
country: string;
|
|
scan_count: number;
|
|
}>(locationQuery);
|
|
|
|
// 查询QR码扫描设备分布
|
|
const deviceQuery = `
|
|
SELECT
|
|
device_type,
|
|
count() as scan_count
|
|
FROM link_events
|
|
WHERE 1=1
|
|
${dateFilter}
|
|
${filters}
|
|
GROUP BY device_type
|
|
ORDER BY scan_count DESC
|
|
`;
|
|
|
|
const deviceResults = await executeQuery<{
|
|
device_type: string;
|
|
scan_count: number;
|
|
}>(deviceQuery);
|
|
|
|
// 查询QR码扫描时间分布
|
|
const timeQuery = `
|
|
SELECT
|
|
toHour(event_time) as hour,
|
|
count() as scan_count
|
|
FROM link_events
|
|
WHERE 1=1
|
|
${dateFilter}
|
|
${filters}
|
|
GROUP BY hour
|
|
ORDER BY hour
|
|
`;
|
|
|
|
const timeResults = await executeQuery<{
|
|
hour: number;
|
|
scan_count: number;
|
|
}>(timeQuery);
|
|
|
|
// 计算基本指标
|
|
const totalScans = Number(basicResult?.total_scans || 0);
|
|
const uniqueScanners = Number(basicResult?.unique_scanners || 0);
|
|
const conversionCount = Number(basicResult?.conversion_count || 0);
|
|
|
|
// 计算转化率
|
|
const conversionRate = uniqueScanners > 0
|
|
? (conversionCount / uniqueScanners) * 100
|
|
: 0;
|
|
|
|
// 处理位置数据
|
|
const locations = locationResults.map(loc => ({
|
|
city: loc.city || 'Unknown',
|
|
country: loc.country || 'Unknown',
|
|
scanCount: Number(loc.scan_count),
|
|
percent: totalScans > 0 ? Number(((Number(loc.scan_count) / totalScans) * 100).toFixed(1)) : 0,
|
|
}));
|
|
|
|
// 处理设备类型数据
|
|
const deviceCounts: { [key: string]: number } = {};
|
|
for (const device of deviceResults) {
|
|
const type = device.device_type || 'unknown';
|
|
deviceCounts[type] = Number(device.scan_count);
|
|
}
|
|
|
|
// 计算设备分布的百分比
|
|
const totalDeviceCount = Object.values(deviceCounts).reduce((sum, count) => sum + count, 0);
|
|
const deviceDistribution = Object.entries(deviceCounts).map(([type, count]) => ({
|
|
type,
|
|
count,
|
|
percent: totalDeviceCount > 0 ? Number(((count / totalDeviceCount) * 100).toFixed(1)) : 0,
|
|
}));
|
|
|
|
// 排序设备分布
|
|
deviceDistribution.sort((a, b) => b.count - a.count);
|
|
|
|
// 处理时间分布数据
|
|
const hourlyDistribution = Array.from({ length: 24 }, (_, i) => ({
|
|
hour: i,
|
|
scanCount: 0,
|
|
percent: 0
|
|
}));
|
|
|
|
for (const time of timeResults) {
|
|
const hour = Number(time.hour);
|
|
const count = Number(time.scan_count);
|
|
|
|
if (hour >= 0 && hour < 24) {
|
|
hourlyDistribution[hour].scanCount = count;
|
|
hourlyDistribution[hour].percent = totalScans > 0 ? (count / totalScans) * 100 : 0;
|
|
}
|
|
}
|
|
|
|
return {
|
|
overview: {
|
|
totalScans,
|
|
uniqueScanners,
|
|
conversionCount,
|
|
conversionRate: Number(conversionRate.toFixed(2)),
|
|
averageTimeSpent: Number(basicResult?.average_time_spent || 0),
|
|
},
|
|
locations,
|
|
deviceDistribution,
|
|
hourlyDistribution,
|
|
};
|
|
} catch (error) {
|
|
console.error('获取QR码分析数据失败', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 获取概览卡片数据
|
|
*/
|
|
export async function getOverviewCards(
|
|
startDate?: string,
|
|
endDate?: string,
|
|
projectId?: string,
|
|
) {
|
|
try {
|
|
const dateFilter = buildDateFilter(startDate, endDate);
|
|
|
|
// 构建项目过滤条件
|
|
let projectFilter = '';
|
|
if (projectId) {
|
|
projectFilter = ` AND l.project_id = '${projectId}'`;
|
|
}
|
|
|
|
// 获取当前周期的数据
|
|
const currentQuery = `
|
|
SELECT
|
|
count(DISTINCT e.link_id) as total_links,
|
|
count() as total_visits,
|
|
uniq(e.visitor_id) as unique_visitors,
|
|
countIf(e.event_type = 'conversion' AND e.conversion_type = 'purchase') as total_conversions,
|
|
sum(e.conversion_value) as total_revenue,
|
|
countIf(l.is_active = true) as active_links
|
|
FROM link_events e
|
|
JOIN links l ON e.link_id = l.link_id
|
|
WHERE 1=1
|
|
${dateFilter}
|
|
${projectFilter}
|
|
`;
|
|
|
|
const currentResult = await executeQuerySingle<{
|
|
total_links: number;
|
|
total_visits: number;
|
|
unique_visitors: number;
|
|
total_conversions: number;
|
|
total_revenue: number;
|
|
active_links: number;
|
|
}>(currentQuery);
|
|
|
|
// 计算前一时期的日期范围
|
|
let previousStartDate = '';
|
|
let previousEndDate = '';
|
|
|
|
if (startDate && endDate) {
|
|
const start = new Date(startDate);
|
|
const end = new Date(endDate);
|
|
const duration = end.getTime() - start.getTime();
|
|
|
|
const prevStart = new Date(start.getTime() - duration);
|
|
const prevEnd = new Date(end.getTime() - duration);
|
|
|
|
previousStartDate = prevStart.toISOString().split('T')[0];
|
|
previousEndDate = prevEnd.toISOString().split('T')[0];
|
|
}
|
|
|
|
// 获取前一时期的数据
|
|
let previousResult = null;
|
|
|
|
if (previousStartDate && previousEndDate) {
|
|
const previousDateFilter = buildDateFilter(previousStartDate, previousEndDate);
|
|
|
|
const previousQuery = `
|
|
SELECT
|
|
count(DISTINCT e.link_id) as total_links,
|
|
count() as total_visits,
|
|
uniq(e.visitor_id) as unique_visitors,
|
|
countIf(e.event_type = 'conversion' AND e.conversion_type = 'purchase') as total_conversions,
|
|
sum(e.conversion_value) as total_revenue,
|
|
countIf(l.is_active = true) as active_links
|
|
FROM link_events e
|
|
JOIN links l ON e.link_id = l.link_id
|
|
WHERE 1=1
|
|
${previousDateFilter}
|
|
${projectFilter}
|
|
`;
|
|
|
|
previousResult = await executeQuerySingle<{
|
|
total_links: number;
|
|
total_visits: number;
|
|
unique_visitors: number;
|
|
total_conversions: number;
|
|
total_revenue: number;
|
|
active_links: number;
|
|
}>(previousQuery);
|
|
}
|
|
|
|
// 计算同比变化
|
|
function calculateChange(current: number, previous: number): number {
|
|
if (previous === 0) return 0;
|
|
return Number(((current - previous) / previous * 100).toFixed(1));
|
|
}
|
|
|
|
// 获取当前值,并设置默认值
|
|
const currentTotalLinks = Number(currentResult?.total_links || 0);
|
|
const currentTotalVisits = Number(currentResult?.total_visits || 0);
|
|
const currentUniqueVisitors = Number(currentResult?.unique_visitors || 0);
|
|
const currentTotalConversions = Number(currentResult?.total_conversions || 0);
|
|
const currentTotalRevenue = Number(currentResult?.total_revenue || 0);
|
|
const currentActiveLinks = Number(currentResult?.active_links || 0);
|
|
|
|
// 获取前一时期的值,并设置默认值
|
|
const previousTotalLinks = Number(previousResult?.total_links || 0);
|
|
const previousTotalVisits = Number(previousResult?.total_visits || 0);
|
|
const previousUniqueVisitors = Number(previousResult?.unique_visitors || 0);
|
|
const previousTotalConversions = Number(previousResult?.total_conversions || 0);
|
|
const previousTotalRevenue = Number(previousResult?.total_revenue || 0);
|
|
const previousActiveLinks = Number(previousResult?.active_links || 0);
|
|
|
|
// 计算转化率
|
|
const currentConversionRate = currentUniqueVisitors > 0
|
|
? (currentTotalConversions / currentUniqueVisitors) * 100
|
|
: 0;
|
|
|
|
const previousConversionRate = previousUniqueVisitors > 0
|
|
? (previousTotalConversions / previousUniqueVisitors) * 100
|
|
: 0;
|
|
|
|
// 计算活跃链接百分比
|
|
const currentActivePercentage = currentTotalLinks > 0
|
|
? (currentActiveLinks / currentTotalLinks) * 100
|
|
: 0;
|
|
|
|
const previousActivePercentage = previousTotalLinks > 0
|
|
? (previousActiveLinks / previousTotalLinks) * 100
|
|
: 0;
|
|
|
|
// 构建结果
|
|
return {
|
|
cards: [
|
|
{
|
|
title: '总访问量',
|
|
currentValue: currentTotalVisits,
|
|
previousValue: previousTotalVisits,
|
|
change: calculateChange(currentTotalVisits, previousTotalVisits),
|
|
format: 'number',
|
|
},
|
|
{
|
|
title: '独立访客',
|
|
currentValue: currentUniqueVisitors,
|
|
previousValue: previousUniqueVisitors,
|
|
change: calculateChange(currentUniqueVisitors, previousUniqueVisitors),
|
|
format: 'number',
|
|
},
|
|
{
|
|
title: '转化次数',
|
|
currentValue: currentTotalConversions,
|
|
previousValue: previousTotalConversions,
|
|
change: calculateChange(currentTotalConversions, previousTotalConversions),
|
|
format: 'number',
|
|
},
|
|
{
|
|
title: '总收入',
|
|
currentValue: currentTotalRevenue,
|
|
previousValue: previousTotalRevenue,
|
|
change: calculateChange(currentTotalRevenue, previousTotalRevenue),
|
|
format: 'currency',
|
|
},
|
|
{
|
|
title: '转化率',
|
|
currentValue: Number(currentConversionRate.toFixed(1)),
|
|
previousValue: Number(previousConversionRate.toFixed(1)),
|
|
change: calculateChange(currentConversionRate, previousConversionRate),
|
|
format: 'percent',
|
|
},
|
|
{
|
|
title: '活跃链接率',
|
|
currentValue: Number(currentActivePercentage.toFixed(1)),
|
|
previousValue: Number(previousActivePercentage.toFixed(1)),
|
|
change: calculateChange(currentActivePercentage, previousActivePercentage),
|
|
format: 'percent',
|
|
},
|
|
],
|
|
timeRange: {
|
|
current: {
|
|
startDate: startDate || '',
|
|
endDate: endDate || '',
|
|
},
|
|
previous: {
|
|
startDate: previousStartDate,
|
|
endDate: previousEndDate,
|
|
},
|
|
},
|
|
};
|
|
} catch (error) {
|
|
console.error('获取概览卡片数据失败', error);
|
|
throw error;
|
|
}
|
|
}
|