Files
shorturl-analytics/lib/analytics.ts
2025-03-31 22:51:49 +08:00

350 lines
9.0 KiB
TypeScript

import { executeQuery, executeQuerySingle, buildFilter, buildPagination, buildOrderBy } from './clickhouse';
import type { Event, EventsSummary, TimeSeriesData, GeoData, DeviceAnalytics, DeviceType, EventsQueryParams } from './types';
// 时间粒度枚举
export enum TimeGranularity {
HOUR = 'hour',
DAY = 'day',
WEEK = 'week',
MONTH = 'month'
}
// 获取事件列表
export async function getEvents(params: Partial<EventsQueryParams>): Promise<{ events: Event[]; total: number }> {
const filter = buildFilter(params);
const pagination = buildPagination(params.page, params.pageSize);
const orderBy = buildOrderBy(params.sortBy, params.sortOrder);
// 获取总数
const countQuery = `
SELECT count() as total
FROM events
${filter}
`;
const totalResult = await executeQuerySingle<{ total: number }>(countQuery);
const total = totalResult?.total || 0;
// 获取事件列表
const query = `
SELECT
event_id,
event_time,
event_type,
event_attributes,
link_id,
link_slug,
link_label,
link_title,
link_original_url,
link_attributes,
link_created_at,
link_expires_at,
link_tags,
user_id,
user_name,
user_email,
user_attributes,
team_id,
team_name,
team_attributes,
project_id,
project_name,
project_attributes,
visitor_id,
session_id,
ip_address,
country,
city,
device_type,
browser,
os,
user_agent,
referrer,
utm_source,
utm_medium,
utm_campaign,
time_spent_sec,
is_bounce,
is_qr_scan,
conversion_type,
conversion_value
FROM events
${filter}
${orderBy}
${pagination}
`;
const events = await executeQuery<Event>(query);
// 处理 JSON 字符串字段
return {
events: events.map(event => ({
...event,
event_attributes: JSON.parse(event.event_attributes as unknown as string),
link_attributes: JSON.parse(event.link_attributes as unknown as string),
link_tags: JSON.parse(event.link_tags as unknown as string),
user_attributes: JSON.parse(event.user_attributes as unknown as string),
team_attributes: JSON.parse(event.team_attributes as unknown as string),
project_attributes: JSON.parse(event.project_attributes as unknown as string)
})),
total
};
}
// 获取事件概览
export async function getEventsSummary(params: {
startTime?: string;
endTime?: string;
linkId?: string;
}): Promise<EventsSummary> {
const filter = buildFilter(params);
// 获取基本统计数据
const baseQuery = `
SELECT
count() as totalEvents,
uniq(ip_address) as uniqueVisitors,
countIf(event_type = 'conversion') as totalConversions,
avg(time_spent_sec) as averageTimeSpent,
-- 设备类型统计
countIf(device_type = 'mobile') as mobileCount,
countIf(device_type = 'desktop') as desktopCount,
countIf(device_type = 'tablet') as tabletCount,
countIf(device_type = 'other') as otherCount
FROM events
${filter}
`;
// 获取浏览器统计数据
const browserQuery = `
SELECT
browser as name,
count() as count
FROM events
${filter}
GROUP BY browser
ORDER BY count DESC
`;
// 获取操作系统统计数据
const osQuery = `
SELECT
os as name,
count() as count
FROM events
${filter}
GROUP BY os
ORDER BY count DESC
`;
try {
const [baseResult, browserResults, osResults] = await Promise.all([
executeQuerySingle<{
totalEvents: number;
uniqueVisitors: number;
totalConversions: number;
averageTimeSpent: number;
mobileCount: number;
desktopCount: number;
tabletCount: number;
otherCount: number;
}>(baseQuery),
executeQuery<{ name: string; count: number }>(browserQuery),
executeQuery<{ name: string; count: number }>(osQuery)
]);
if (!baseResult) {
throw new Error('Failed to get events summary');
}
// 安全转换数字类型
const safeNumber = (value: any): number => {
if (value === null || value === undefined) return 0;
const num = Number(value);
return isNaN(num) ? 0 : num;
};
// 计算百分比
const calculatePercentage = (count: number, total: number) => {
if (!total) return 0; // 防止除以零
return Number(((count / total) * 100).toFixed(2));
};
// 处理浏览器数据
const browsers = browserResults.map(item => ({
name: item.name || 'Unknown',
count: safeNumber(item.count),
percentage: calculatePercentage(safeNumber(item.count), safeNumber(baseResult.totalEvents))
}));
// 处理操作系统数据
const operatingSystems = osResults.map(item => ({
name: item.name || 'Unknown',
count: safeNumber(item.count),
percentage: calculatePercentage(safeNumber(item.count), safeNumber(baseResult.totalEvents))
}));
return {
totalEvents: safeNumber(baseResult.totalEvents),
uniqueVisitors: safeNumber(baseResult.uniqueVisitors),
totalConversions: safeNumber(baseResult.totalConversions),
averageTimeSpent: baseResult.averageTimeSpent ? Number(baseResult.averageTimeSpent.toFixed(2)) : 0,
deviceTypes: {
mobile: safeNumber(baseResult.mobileCount),
desktop: safeNumber(baseResult.desktopCount),
tablet: safeNumber(baseResult.tabletCount),
other: safeNumber(baseResult.otherCount)
},
browsers,
operatingSystems
};
} catch (error) {
console.error('Error in getEventsSummary:', error);
throw error;
}
}
// 获取时间序列数据
export async function getTimeSeriesData(params: {
startTime: string;
endTime: string;
linkId?: string;
granularity: 'hour' | 'day' | 'week' | 'month';
}): Promise<TimeSeriesData[]> {
const filter = buildFilter(params);
// 根据粒度选择时间间隔
const interval = {
hour: '1 HOUR',
day: '1 DAY',
week: '1 WEEK',
month: '1 MONTH'
}[params.granularity];
const query = `
SELECT
toStartOfInterval(event_time, INTERVAL ${interval}) as timestamp,
count() as events,
uniq(visitor_id) as visitors,
countIf(event_type = 'conversion') as conversions
FROM events
${filter}
GROUP BY timestamp
ORDER BY timestamp
`;
return executeQuery<TimeSeriesData>(query);
}
// 获取地理位置分析
export async function getGeoAnalytics(params: {
startTime?: string;
endTime?: string;
linkId?: string;
groupBy?: 'country' | 'city';
}): Promise<GeoData[]> {
const filter = buildFilter(params);
const groupByField = 'ip_address'; // 暂时按 IP 地址分组
const query = `
SELECT
${groupByField} as location,
count() as visits,
uniq(visitor_id) as visitors,
count() * 100.0 / sum(count()) OVER () as percentage
FROM events
${filter}
GROUP BY ${groupByField}
HAVING location != ''
ORDER BY visits DESC
LIMIT 10
`;
return executeQuery<GeoData>(query);
}
// 获取设备分析
export async function getDeviceAnalytics(params: {
startTime?: string;
endTime?: string;
linkId?: string;
}): Promise<DeviceAnalytics> {
const filter = buildFilter(params);
// 获取总数
const totalQuery = `
SELECT count() as total
FROM events
${filter}
`;
// 获取设备类型统计
const deviceTypesQuery = `
SELECT
device_type as name,
count() as count
FROM events
${filter}
GROUP BY device_type
ORDER BY count DESC
`;
// 获取浏览器统计
const browsersQuery = `
SELECT
browser as name,
count() as count
FROM events
${filter}
GROUP BY browser
ORDER BY count DESC
`;
// 获取操作系统统计
const osQuery = `
SELECT
os as name,
count() as count
FROM events
${filter}
GROUP BY os
ORDER BY count DESC
`;
const [totalResult, deviceTypes, browsers, operatingSystems] = await Promise.all([
executeQuerySingle<{ total: number }>(totalQuery),
executeQuery<{ name: string; count: number }>(deviceTypesQuery),
executeQuery<{ name: string; count: number }>(browsersQuery),
executeQuery<{ name: string; count: number }>(osQuery)
]);
if (!totalResult) {
throw new Error('Failed to get device analytics');
}
// 计算百分比
const calculatePercentage = (count: number) => {
if (!totalResult || totalResult.total === 0) return 0;
return Number(((count / totalResult.total) * 100).toFixed(2));
};
return {
deviceTypes: deviceTypes.map(item => ({
type: item.name.toLowerCase() as DeviceType,
count: item.count,
percentage: calculatePercentage(item.count)
})),
browsers: browsers.map(item => ({
name: item.name,
count: item.count,
percentage: calculatePercentage(item.count)
})),
operatingSystems: operatingSystems.map(item => ({
name: item.name,
count: item.count,
percentage: calculatePercentage(item.count)
}))
};
}