350 lines
9.0 KiB
TypeScript
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)
|
|
}))
|
|
};
|
|
}
|