From 92d82b18a040f926736c9dcd5023465a48ea5ec6 Mon Sep 17 00:00:00 2001 From: William Tso Date: Tue, 25 Mar 2025 17:26:04 +0800 Subject: [PATCH] events apis --- api/events.ts | 152 ++ app/api/analytics/device-analysis/route.ts | 28 - app/api/analytics/funnel/route.ts | 36 - app/api/analytics/link-performance/route.ts | 36 - .../link-status-distribution/route.ts | 28 - app/api/analytics/overview-cards/route.ts | 28 - app/api/analytics/overview/route.ts | 36 - .../analytics/platform-distribution/route.ts | 28 - app/api/analytics/popular-links/route.ts | 32 - app/api/analytics/popular-referrers/route.ts | 32 - app/api/analytics/qr-code-analysis/route.ts | 30 - app/api/analytics/track/route.ts | 68 - app/api/analytics/trends/route.ts | 50 - app/api/events/devices/route.ts | 28 + app/api/events/geo/route.ts | 29 + app/api/events/route.ts | 51 + app/api/events/summary/route.ts | 28 + app/api/events/time-series/route.ts | 38 + app/api/links/[linkId]/details/route.ts | 30 - app/api/links/[linkId]/route.ts | 29 - app/api/links/repository.ts | 157 -- app/api/links/route.ts | 32 - app/api/links/service.ts | 42 - app/api/stats/repository.ts | 21 - app/api/stats/route.ts | 15 - app/api/stats/service.ts | 21 - app/api/tags/repository.ts | 19 - app/api/tags/route.ts | 15 - app/api/tags/service.ts | 9 - app/api/types.ts | 221 --- lib/analytics.ts | 1526 +++-------------- lib/clickhouse.ts | 108 +- lib/types.ts | 171 ++ 33 files changed, 859 insertions(+), 2315 deletions(-) create mode 100644 api/events.ts delete mode 100644 app/api/analytics/device-analysis/route.ts delete mode 100644 app/api/analytics/funnel/route.ts delete mode 100644 app/api/analytics/link-performance/route.ts delete mode 100644 app/api/analytics/link-status-distribution/route.ts delete mode 100644 app/api/analytics/overview-cards/route.ts delete mode 100644 app/api/analytics/overview/route.ts delete mode 100644 app/api/analytics/platform-distribution/route.ts delete mode 100644 app/api/analytics/popular-links/route.ts delete mode 100644 app/api/analytics/popular-referrers/route.ts delete mode 100644 app/api/analytics/qr-code-analysis/route.ts delete mode 100644 app/api/analytics/track/route.ts delete mode 100644 app/api/analytics/trends/route.ts create mode 100644 app/api/events/devices/route.ts create mode 100644 app/api/events/geo/route.ts create mode 100644 app/api/events/route.ts create mode 100644 app/api/events/summary/route.ts create mode 100644 app/api/events/time-series/route.ts delete mode 100644 app/api/links/[linkId]/details/route.ts delete mode 100644 app/api/links/[linkId]/route.ts delete mode 100644 app/api/links/repository.ts delete mode 100644 app/api/links/route.ts delete mode 100644 app/api/links/service.ts delete mode 100644 app/api/stats/repository.ts delete mode 100644 app/api/stats/route.ts delete mode 100644 app/api/stats/service.ts delete mode 100644 app/api/tags/repository.ts delete mode 100644 app/api/tags/route.ts delete mode 100644 app/api/tags/service.ts delete mode 100644 app/api/types.ts create mode 100644 lib/types.ts diff --git a/api/events.ts b/api/events.ts new file mode 100644 index 0000000..5f95fbe --- /dev/null +++ b/api/events.ts @@ -0,0 +1,152 @@ +import { Router } from 'express'; +import type { Request, Response } from 'express'; +import type { ApiResponse, EventsQueryParams } from '../lib/types'; +import { + getEvents, + getEventsSummary, + getTimeSeriesData, + getGeoAnalytics, + getDeviceAnalytics +} from '../lib/analytics'; + +const router = Router(); + +// 获取事件列表 +router.get('/', async (req: Request, res: Response) => { + try { + const params: EventsQueryParams = { + startTime: req.query.startTime as string, + endTime: req.query.endTime as string, + eventType: req.query.eventType as string, + linkId: req.query.linkId as string, + linkSlug: req.query.linkSlug as string, + userId: req.query.userId as string, + teamId: req.query.teamId as string, + projectId: req.query.projectId as string, + page: req.query.page ? parseInt(req.query.page as string, 10) : 1, + pageSize: req.query.pageSize ? parseInt(req.query.pageSize as string, 10) : 20, + sortBy: req.query.sortBy as string, + sortOrder: req.query.sortOrder as 'asc' | 'desc' + }; + + const { events, total } = await getEvents(params); + + const response: ApiResponse = { + success: true, + data: events, + meta: { + total, + page: params.page, + pageSize: params.pageSize + } + }; + + res.json(response); + } catch (error) { + const response: ApiResponse = { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred' + }; + res.status(500).json(response); + } +}); + +// 获取事件概览 +router.get('/summary', async (req: Request, res: Response) => { + try { + const summary = await getEventsSummary({ + startTime: req.query.startTime as string, + endTime: req.query.endTime as string, + linkId: req.query.linkId as string + }); + + const response: ApiResponse = { + success: true, + data: summary + }; + + res.json(response); + } catch (error) { + const response: ApiResponse = { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred' + }; + res.status(500).json(response); + } +}); + +// 获取时间序列数据 +router.get('/time-series', async (req: Request, res: Response) => { + try { + const data = await getTimeSeriesData({ + startTime: req.query.startTime as string, + endTime: req.query.endTime as string, + linkId: req.query.linkId as string, + granularity: (req.query.granularity || 'day') as 'hour' | 'day' | 'week' | 'month' + }); + + const response: ApiResponse = { + success: true, + data + }; + + res.json(response); + } catch (error) { + const response: ApiResponse = { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred' + }; + res.status(500).json(response); + } +}); + +// 获取地理位置分析 +router.get('/geo', async (req: Request, res: Response) => { + try { + const data = await getGeoAnalytics({ + startTime: req.query.startTime as string, + endTime: req.query.endTime as string, + linkId: req.query.linkId as string, + groupBy: (req.query.groupBy || 'country') as 'country' | 'city' + }); + + const response: ApiResponse = { + success: true, + data + }; + + res.json(response); + } catch (error) { + const response: ApiResponse = { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred' + }; + res.status(500).json(response); + } +}); + +// 获取设备分析 +router.get('/devices', async (req: Request, res: Response) => { + try { + const data = await getDeviceAnalytics({ + startTime: req.query.startTime as string, + endTime: req.query.endTime as string, + linkId: req.query.linkId as string + }); + + const response: ApiResponse = { + success: true, + data + }; + + res.json(response); + } catch (error) { + const response: ApiResponse = { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred' + }; + res.status(500).json(response); + } +}); + +export default router; \ No newline at end of file diff --git a/app/api/analytics/device-analysis/route.ts b/app/api/analytics/device-analysis/route.ts deleted file mode 100644 index c1643ae..0000000 --- a/app/api/analytics/device-analysis/route.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { getDeviceAnalysis } from '@/lib/analytics'; - -export async function GET(request: NextRequest) { - try { - // 获取请求参数 - const { searchParams } = new URL(request.url); - const linkId = searchParams.get('linkId') || undefined; - const startDate = searchParams.get('startDate') || undefined; - const endDate = searchParams.get('endDate') || undefined; - - // 获取设备分析详情 - const analysisData = await getDeviceAnalysis( - startDate, - endDate, - linkId - ); - - // 返回数据 - return NextResponse.json(analysisData); - } catch (error) { - console.error('Error in device-analysis API:', error); - return NextResponse.json( - { error: 'Failed to fetch device analysis data' }, - { status: 500 } - ); - } -} \ No newline at end of file diff --git a/app/api/analytics/funnel/route.ts b/app/api/analytics/funnel/route.ts deleted file mode 100644 index 9654b2a..0000000 --- a/app/api/analytics/funnel/route.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { getConversionFunnel } from '@/lib/analytics'; - -export async function GET(request: NextRequest) { - try { - // 获取请求参数 - const { searchParams } = new URL(request.url); - const linkId = searchParams.get('linkId'); - const startDate = searchParams.get('startDate') || undefined; - const endDate = searchParams.get('endDate') || undefined; - - // 验证必要参数 - if (!linkId) { - return NextResponse.json( - { error: 'Missing required parameter: linkId' }, - { status: 400 } - ); - } - - // 获取转化漏斗数据 - const funnelData = await getConversionFunnel( - linkId, - startDate || undefined, - endDate || undefined - ); - - // 返回数据 - return NextResponse.json(funnelData); - } catch (error) { - console.error('Error in funnel API:', error); - return NextResponse.json( - { error: 'Failed to fetch funnel data' }, - { status: 500 } - ); - } -} \ No newline at end of file diff --git a/app/api/analytics/link-performance/route.ts b/app/api/analytics/link-performance/route.ts deleted file mode 100644 index d3234b0..0000000 --- a/app/api/analytics/link-performance/route.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { getLinkPerformance } from '@/lib/analytics'; - -export async function GET(request: NextRequest) { - try { - // 获取请求参数 - const { searchParams } = new URL(request.url); - const linkId = searchParams.get('linkId'); - const startDate = searchParams.get('startDate') || undefined; - const endDate = searchParams.get('endDate') || undefined; - - // 验证必要参数 - if (!linkId) { - return NextResponse.json( - { error: 'Missing required parameter: linkId' }, - { status: 400 } - ); - } - - // 获取链接表现数据 - const performanceData = await getLinkPerformance( - linkId, - startDate || undefined, - endDate || undefined - ); - - // 返回数据 - return NextResponse.json(performanceData); - } catch (error) { - console.error('Error in link-performance API:', error); - return NextResponse.json( - { error: 'Failed to fetch link performance data' }, - { status: 500 } - ); - } -} \ No newline at end of file diff --git a/app/api/analytics/link-status-distribution/route.ts b/app/api/analytics/link-status-distribution/route.ts deleted file mode 100644 index ae4961e..0000000 --- a/app/api/analytics/link-status-distribution/route.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { getLinkStatusDistribution } from '@/lib/analytics'; - -export async function GET(request: NextRequest) { - try { - // 获取请求参数 - const { searchParams } = new URL(request.url); - const projectId = searchParams.get('projectId') || undefined; - const startDate = searchParams.get('startDate') || undefined; - const endDate = searchParams.get('endDate') || undefined; - - // 获取链接状态分布数据 - const distributionData = await getLinkStatusDistribution( - startDate, - endDate, - projectId - ); - - // 返回数据 - return NextResponse.json(distributionData); - } catch (error) { - console.error('Error in link-status-distribution API:', error); - return NextResponse.json( - { error: 'Failed to fetch link status distribution data' }, - { status: 500 } - ); - } -} \ No newline at end of file diff --git a/app/api/analytics/overview-cards/route.ts b/app/api/analytics/overview-cards/route.ts deleted file mode 100644 index 89ba2ca..0000000 --- a/app/api/analytics/overview-cards/route.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { getOverviewCards } from '@/lib/analytics'; - -export async function GET(request: NextRequest) { - try { - // 获取请求参数 - const { searchParams } = new URL(request.url); - const projectId = searchParams.get('projectId') || undefined; - const startDate = searchParams.get('startDate') || undefined; - const endDate = searchParams.get('endDate') || undefined; - - // 获取概览卡片数据 - const cardsData = await getOverviewCards( - startDate, - endDate, - projectId - ); - - // 返回数据 - return NextResponse.json(cardsData); - } catch (error) { - console.error('Error in overview-cards API:', error); - return NextResponse.json( - { error: 'Failed to fetch overview cards data' }, - { status: 500 } - ); - } -} \ No newline at end of file diff --git a/app/api/analytics/overview/route.ts b/app/api/analytics/overview/route.ts deleted file mode 100644 index 270f973..0000000 --- a/app/api/analytics/overview/route.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { getLinkOverview } from '@/lib/analytics'; - -export async function GET(request: NextRequest) { - try { - // 获取请求参数 - const { searchParams } = new URL(request.url); - const linkId = searchParams.get('linkId'); - const startDate = searchParams.get('startDate') || undefined; - const endDate = searchParams.get('endDate') || undefined; - - // 验证必要参数 - if (!linkId) { - return NextResponse.json( - { error: 'Missing required parameter: linkId' }, - { status: 400 } - ); - } - - // 获取链接概览数据 - const overviewData = await getLinkOverview( - linkId, - startDate || undefined, - endDate || undefined - ); - - // 返回数据 - return NextResponse.json(overviewData); - } catch (error) { - console.error('Error in overview API:', error); - return NextResponse.json( - { error: 'Failed to fetch overview data' }, - { status: 500 } - ); - } -} \ No newline at end of file diff --git a/app/api/analytics/platform-distribution/route.ts b/app/api/analytics/platform-distribution/route.ts deleted file mode 100644 index eee370c..0000000 --- a/app/api/analytics/platform-distribution/route.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { getPlatformDistribution } from '@/lib/analytics'; - -export async function GET(request: NextRequest) { - try { - // 获取请求参数 - const { searchParams } = new URL(request.url); - const linkId = searchParams.get('linkId') || undefined; - const startDate = searchParams.get('startDate') || undefined; - const endDate = searchParams.get('endDate') || undefined; - - // 获取平台分布数据 - const distributionData = await getPlatformDistribution( - startDate, - endDate, - linkId - ); - - // 返回数据 - return NextResponse.json(distributionData); - } catch (error) { - console.error('Error in platform-distribution API:', error); - return NextResponse.json( - { error: 'Failed to fetch platform distribution data' }, - { status: 500 } - ); - } -} \ No newline at end of file diff --git a/app/api/analytics/popular-links/route.ts b/app/api/analytics/popular-links/route.ts deleted file mode 100644 index d4e630b..0000000 --- a/app/api/analytics/popular-links/route.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { getPopularLinks } from '@/lib/analytics'; - -export async function GET(request: NextRequest) { - try { - // 获取请求参数 - const { searchParams } = new URL(request.url); - const projectId = searchParams.get('projectId') || undefined; - const startDate = searchParams.get('startDate') || undefined; - const endDate = searchParams.get('endDate') || undefined; - const sortBy = searchParams.get('sortBy') as 'visits' | 'uniqueVisitors' | 'conversionRate' || 'visits'; - const limit = searchParams.get('limit') ? parseInt(searchParams.get('limit') as string, 10) : 10; - - // 获取热门链接数据 - const linksData = await getPopularLinks( - startDate, - endDate, - projectId, - sortBy, - limit - ); - - // 返回数据 - return NextResponse.json(linksData); - } catch (error) { - console.error('Error in popular-links API:', error); - return NextResponse.json( - { error: 'Failed to fetch popular links data' }, - { status: 500 } - ); - } -} \ No newline at end of file diff --git a/app/api/analytics/popular-referrers/route.ts b/app/api/analytics/popular-referrers/route.ts deleted file mode 100644 index a337691..0000000 --- a/app/api/analytics/popular-referrers/route.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { getPopularReferrers } from '@/lib/analytics'; - -export async function GET(request: NextRequest) { - try { - // 获取请求参数 - const { searchParams } = new URL(request.url); - const linkId = searchParams.get('linkId') || undefined; - const startDate = searchParams.get('startDate') || undefined; - const endDate = searchParams.get('endDate') || undefined; - const type = searchParams.get('type') as 'domain' | 'full' || 'domain'; - const limit = searchParams.get('limit') ? parseInt(searchParams.get('limit') as string, 10) : 10; - - // 获取热门引荐来源数据 - const referrersData = await getPopularReferrers( - startDate, - endDate, - linkId, - type, - limit - ); - - // 返回数据 - return NextResponse.json(referrersData); - } catch (error) { - console.error('Error in popular-referrers API:', error); - return NextResponse.json( - { error: 'Failed to fetch popular referrers data' }, - { status: 500 } - ); - } -} \ No newline at end of file diff --git a/app/api/analytics/qr-code-analysis/route.ts b/app/api/analytics/qr-code-analysis/route.ts deleted file mode 100644 index fbab81e..0000000 --- a/app/api/analytics/qr-code-analysis/route.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { getQrCodeAnalysis } from '@/lib/analytics'; - -export async function GET(request: NextRequest) { - try { - // 获取请求参数 - const { searchParams } = new URL(request.url); - const linkId = searchParams.get('linkId') || undefined; - const qrCodeId = searchParams.get('qrCodeId') || undefined; - const startDate = searchParams.get('startDate') || undefined; - const endDate = searchParams.get('endDate') || undefined; - - // 获取QR码分析数据 - const analysisData = await getQrCodeAnalysis( - startDate, - endDate, - linkId, - qrCodeId - ); - - // 返回数据 - return NextResponse.json(analysisData); - } catch (error) { - console.error('Error in qr-code-analysis API:', error); - return NextResponse.json( - { error: 'Failed to fetch QR code analysis data' }, - { status: 500 } - ); - } -} \ No newline at end of file diff --git a/app/api/analytics/track/route.ts b/app/api/analytics/track/route.ts deleted file mode 100644 index 8264251..0000000 --- a/app/api/analytics/track/route.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { trackEvent, EventType, ConversionType } from '@/lib/analytics'; - -export async function POST(request: NextRequest) { - try { - // 解析请求体 - const body = await request.json(); - - // 验证必要字段 - if (!body.linkId) { - return NextResponse.json( - { error: 'Missing required field: linkId' }, - { status: 400 } - ); - } - - if (!body.eventType || !Object.values(EventType).includes(body.eventType)) { - return NextResponse.json( - { - error: 'Invalid or missing eventType', - validValues: Object.values(EventType) - }, - { status: 400 } - ); - } - - // 验证转化类型(如果提供) - if ( - body.conversionType && - !Object.values(ConversionType).includes(body.conversionType) - ) { - return NextResponse.json( - { - error: 'Invalid conversionType', - validValues: Object.values(ConversionType) - }, - { status: 400 } - ); - } - - // 添加客户端IP - const clientIp = request.headers.get('x-forwarded-for') || - request.headers.get('x-real-ip') || - '0.0.0.0'; - - // 添加用户代理 - const userAgent = request.headers.get('user-agent') || ''; - - // 合并数据 - const eventData = { - ...body, - ipAddress: body.ipAddress || clientIp, - userAgent: body.userAgent || userAgent, - }; - - // 追踪事件 - const result = await trackEvent(eventData); - - // 返回结果 - return NextResponse.json(result); - } catch (error) { - console.error('Error in track API:', error); - return NextResponse.json( - { error: 'Failed to track event' }, - { status: 500 } - ); - } -} \ No newline at end of file diff --git a/app/api/analytics/trends/route.ts b/app/api/analytics/trends/route.ts deleted file mode 100644 index cc0fbfd..0000000 --- a/app/api/analytics/trends/route.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { getVisitTrends, TimeGranularity } from '@/lib/analytics'; - -export async function GET(request: NextRequest) { - try { - // 获取请求参数 - const { searchParams } = new URL(request.url); - const linkId = searchParams.get('linkId'); - const startDate = searchParams.get('startDate') || undefined; - const endDate = searchParams.get('endDate') || undefined; - const granularity = searchParams.get('granularity') as TimeGranularity || TimeGranularity.DAY; - - // 验证必要参数 - if (!linkId) { - return NextResponse.json( - { error: 'Missing required parameter: linkId' }, - { status: 400 } - ); - } - - // 验证粒度参数 - const validGranularities = Object.values(TimeGranularity); - if (granularity && !validGranularities.includes(granularity)) { - return NextResponse.json( - { - error: 'Invalid granularity value', - validValues: validGranularities - }, - { status: 400 } - ); - } - - // 获取访问趋势数据 - const trendsData = await getVisitTrends( - linkId, - startDate || undefined, - endDate || undefined, - granularity - ); - - // 返回数据 - return NextResponse.json(trendsData); - } catch (error) { - console.error('Error in trends API:', error); - return NextResponse.json( - { error: 'Failed to fetch trends data' }, - { status: 500 } - ); - } -} \ No newline at end of file diff --git a/app/api/events/devices/route.ts b/app/api/events/devices/route.ts new file mode 100644 index 0000000..7c90d89 --- /dev/null +++ b/app/api/events/devices/route.ts @@ -0,0 +1,28 @@ +import { NextRequest, NextResponse } from 'next/server'; +import type { ApiResponse } from '@/lib/types'; +import { getDeviceAnalytics } from '@/lib/analytics'; + +export async function GET(request: NextRequest) { + try { + const searchParams = request.nextUrl.searchParams; + + const data = await getDeviceAnalytics({ + startTime: searchParams.get('startTime') || undefined, + endTime: searchParams.get('endTime') || undefined, + linkId: searchParams.get('linkId') || undefined + }); + + const response: ApiResponse = { + success: true, + data + }; + + return NextResponse.json(response); + } catch (error) { + const response: ApiResponse = { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred' + }; + return NextResponse.json(response, { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/events/geo/route.ts b/app/api/events/geo/route.ts new file mode 100644 index 0000000..4230096 --- /dev/null +++ b/app/api/events/geo/route.ts @@ -0,0 +1,29 @@ +import { NextRequest, NextResponse } from 'next/server'; +import type { ApiResponse } from '@/lib/types'; +import { getGeoAnalytics } from '@/lib/analytics'; + +export async function GET(request: NextRequest) { + try { + const searchParams = request.nextUrl.searchParams; + + const data = await getGeoAnalytics({ + startTime: searchParams.get('startTime') || undefined, + endTime: searchParams.get('endTime') || undefined, + linkId: searchParams.get('linkId') || undefined, + groupBy: (searchParams.get('groupBy') || 'country') as 'country' | 'city' + }); + + const response: ApiResponse = { + success: true, + data + }; + + return NextResponse.json(response); + } catch (error) { + const response: ApiResponse = { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred' + }; + return NextResponse.json(response, { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/events/route.ts b/app/api/events/route.ts new file mode 100644 index 0000000..6a43d03 --- /dev/null +++ b/app/api/events/route.ts @@ -0,0 +1,51 @@ +import { NextRequest, NextResponse } from 'next/server'; +import type { ApiResponse, EventsQueryParams, EventType } from '@/lib/types'; +import { + getEvents, + getEventsSummary, + getTimeSeriesData, + getGeoAnalytics, + getDeviceAnalytics +} from '@/lib/analytics'; + +// 获取事件列表 +export async function GET(request: NextRequest) { + try { + const searchParams = request.nextUrl.searchParams; + + const params: EventsQueryParams = { + startTime: searchParams.get('startTime') || undefined, + endTime: searchParams.get('endTime') || undefined, + eventType: searchParams.get('eventType') as EventType || undefined, + linkId: searchParams.get('linkId') || undefined, + linkSlug: searchParams.get('linkSlug') || undefined, + userId: searchParams.get('userId') || undefined, + teamId: searchParams.get('teamId') || undefined, + projectId: searchParams.get('projectId') || undefined, + page: searchParams.has('page') ? parseInt(searchParams.get('page')!, 10) : 1, + pageSize: searchParams.has('pageSize') ? parseInt(searchParams.get('pageSize')!, 10) : 20, + sortBy: searchParams.get('sortBy') || undefined, + sortOrder: (searchParams.get('sortOrder') as 'asc' | 'desc') || undefined + }; + + const { events, total } = await getEvents(params); + + const response: ApiResponse = { + success: true, + data: events, + meta: { + total, + page: params.page, + pageSize: params.pageSize + } + }; + + return NextResponse.json(response); + } catch (error) { + const response: ApiResponse = { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred' + }; + return NextResponse.json(response, { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/events/summary/route.ts b/app/api/events/summary/route.ts new file mode 100644 index 0000000..66722c7 --- /dev/null +++ b/app/api/events/summary/route.ts @@ -0,0 +1,28 @@ +import { NextRequest, NextResponse } from 'next/server'; +import type { ApiResponse } from '@/lib/types'; +import { getEventsSummary } from '@/lib/analytics'; + +export async function GET(request: NextRequest) { + try { + const searchParams = request.nextUrl.searchParams; + + const summary = await getEventsSummary({ + startTime: searchParams.get('startTime') || undefined, + endTime: searchParams.get('endTime') || undefined, + linkId: searchParams.get('linkId') || undefined + }); + + const response: ApiResponse = { + success: true, + data: summary + }; + + return NextResponse.json(response); + } catch (error) { + const response: ApiResponse = { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred' + }; + return NextResponse.json(response, { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/events/time-series/route.ts b/app/api/events/time-series/route.ts new file mode 100644 index 0000000..0e07dbe --- /dev/null +++ b/app/api/events/time-series/route.ts @@ -0,0 +1,38 @@ +import { NextRequest, NextResponse } from 'next/server'; +import type { ApiResponse } from '@/lib/types'; +import { getTimeSeriesData } from '@/lib/analytics'; + +export async function GET(request: NextRequest) { + try { + const searchParams = request.nextUrl.searchParams; + const startTime = searchParams.get('startTime'); + const endTime = searchParams.get('endTime'); + + if (!startTime || !endTime) { + return NextResponse.json({ + success: false, + error: 'startTime and endTime are required' + }, { status: 400 }); + } + + const data = await getTimeSeriesData({ + startTime, + endTime, + linkId: searchParams.get('linkId') || undefined, + granularity: (searchParams.get('granularity') || 'day') as 'hour' | 'day' | 'week' | 'month' + }); + + const response: ApiResponse = { + success: true, + data + }; + + return NextResponse.json(response); + } catch (error) { + const response: ApiResponse = { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred' + }; + return NextResponse.json(response, { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/links/[linkId]/details/route.ts b/app/api/links/[linkId]/details/route.ts deleted file mode 100644 index fb69c1d..0000000 --- a/app/api/links/[linkId]/details/route.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { getLinkDetailsById } from '@/app/api/links/service'; - -// 正确的Next.js 15 API路由处理函数参数类型定义 -export async function GET( - request: NextRequest, - context: { params: Promise } -) { - try { - // 获取参数,支持异步格式 - const params = await context.params; - const linkId = params.linkId; - const link = await getLinkDetailsById(linkId); - - if (!link) { - return NextResponse.json( - { error: 'Link not found' }, - { status: 404 } - ); - } - - return NextResponse.json(link); - } catch (error) { - console.error('Failed to fetch link details:', error); - return NextResponse.json( - { error: 'Failed to fetch link details', message: (error as Error).message }, - { status: 500 } - ); - } -} \ No newline at end of file diff --git a/app/api/links/[linkId]/route.ts b/app/api/links/[linkId]/route.ts deleted file mode 100644 index 5667632..0000000 --- a/app/api/links/[linkId]/route.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { getLinkById } from '../service'; - -export async function GET( - request: NextRequest, - context: { params: Promise } -) { - try { - // 获取参数,支持异步格式 - const params = await context.params; - const linkId = params.linkId; - const link = await getLinkById(linkId); - - if (!link) { - return NextResponse.json( - { error: 'Link not found' }, - { status: 404 } - ); - } - - return NextResponse.json(link); - } catch (error) { - console.error('Failed to fetch link:', error); - return NextResponse.json( - { error: 'Failed to fetch link', message: (error as Error).message }, - { status: 500 } - ); - } -} \ No newline at end of file diff --git a/app/api/links/repository.ts b/app/api/links/repository.ts deleted file mode 100644 index 3760af6..0000000 --- a/app/api/links/repository.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { executeQuery, executeQuerySingle } from '@/lib/clickhouse'; -import { Link, LinkQueryParams } from '../types'; - -/** - * Find links with filtering options - */ -export async function findLinks({ - limit = 10, - offset = 0, - searchTerm = '', - tagFilter = '', - isActive = null, -}: LinkQueryParams) { - // Build WHERE conditions - const conditions = []; - - if (searchTerm) { - conditions.push(` - (lower(title) LIKE lower('%${searchTerm}%') OR - lower(original_url) LIKE lower('%${searchTerm}%')) - `); - } - - if (tagFilter) { - conditions.push(`hasAny(tags, ['${tagFilter}'])`); - } - - if (isActive !== null) { - conditions.push(`is_active = ${isActive ? 'true' : 'false'}`); - } - - const whereClause = conditions.length > 0 - ? `WHERE ${conditions.join(' AND ')}` - : ''; - - // Get total count - const countQuery = ` - SELECT count() as total - FROM links - ${whereClause} - `; - - const countData = await executeQuery<{ total: number }>(countQuery); - const total = countData.length > 0 ? countData[0].total : 0; - - // 使用左连接获取链接数据和统计信息 - const linksQuery = ` - SELECT - l.link_id, - l.original_url, - l.created_at, - l.created_by, - l.title, - l.description, - l.tags, - l.is_active, - l.expires_at, - l.team_id, - l.project_id, - count(le.event_id) as visits, - count(DISTINCT le.visitor_id) as unique_visits - FROM links l - LEFT JOIN link_events le ON l.link_id = le.link_id - ${whereClause} - GROUP BY - l.link_id, - l.original_url, - l.created_at, - l.created_by, - l.title, - l.description, - l.tags, - l.is_active, - l.expires_at, - l.team_id, - l.project_id - ORDER BY l.created_at DESC - LIMIT ${limit} - OFFSET ${offset} - `; - - const links = await executeQuery(linksQuery); - - return { - links, - total, - limit, - offset, - page: Math.floor(offset / limit) + 1, - totalPages: Math.ceil(total / limit) - }; -} - -/** - * Find a single link by ID - */ -export async function findLinkById(linkId: string): Promise { - const query = ` - SELECT - l.link_id, - l.original_url, - l.created_at, - l.created_by, - l.title, - l.description, - l.tags, - l.is_active, - l.expires_at, - l.team_id, - l.project_id, - count(le.event_id) as visits, - count(DISTINCT le.visitor_id) as unique_visits - FROM links l - LEFT JOIN link_events le ON l.link_id = le.link_id - WHERE l.link_id = '${linkId}' - GROUP BY - l.link_id, - l.original_url, - l.created_at, - l.created_by, - l.title, - l.description, - l.tags, - l.is_active, - l.expires_at, - l.team_id, - l.project_id - LIMIT 1 - `; - - return await executeQuerySingle(query); -} - -/** - * Find a single link by ID - only basic info without statistics - */ -export async function findLinkDetailsById(linkId: string): Promise | null> { - const query = ` - SELECT - link_id, - original_url, - created_at, - created_by, - title, - description, - tags, - is_active, - expires_at, - team_id, - project_id - FROM links - WHERE link_id = '${linkId}' - LIMIT 1 - `; - - return await executeQuerySingle>(query); -} \ No newline at end of file diff --git a/app/api/links/route.ts b/app/api/links/route.ts deleted file mode 100644 index be7e473..0000000 --- a/app/api/links/route.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { LinkQueryParams } from '../types'; -import { getLinks } from './service'; - -export async function GET(request: NextRequest) { - try { - const searchParams = request.nextUrl.searchParams; - - // Parse request parameters - const params: LinkQueryParams = { - limit: searchParams.has('limit') ? Number(searchParams.get('limit')) : 10, - page: searchParams.has('page') ? Number(searchParams.get('page')) : 1, - searchTerm: searchParams.get('search') || '', - tagFilter: searchParams.get('tag') || '', - }; - - // Handle active status filter - const activeFilter = searchParams.get('active'); - if (activeFilter === 'true') params.isActive = true; - if (activeFilter === 'false') params.isActive = false; - - // Get link data - const result = await getLinks(params); - return NextResponse.json(result); - } catch (error) { - console.error('Failed to fetch links:', error); - return NextResponse.json( - { error: 'Failed to fetch links', message: (error as Error).message }, - { status: 500 } - ); - } -} \ No newline at end of file diff --git a/app/api/links/service.ts b/app/api/links/service.ts deleted file mode 100644 index 8e7db77..0000000 --- a/app/api/links/service.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Link, LinkQueryParams, PaginatedResponse } from '../types'; -import { findLinkById, findLinkDetailsById, findLinks } from './repository'; - -/** - * Get links with pagination information - */ -export async function getLinks(params: LinkQueryParams): Promise> { - // Convert page number to offset - const { page, limit = 10, ...otherParams } = params; - const offset = page ? (page - 1) * limit : params.offset || 0; - - const result = await findLinks({ - ...otherParams, - limit, - offset - }); - - return { - data: result.links, - pagination: { - total: result.total, - limit: result.limit, - offset: result.offset, - page: result.page, - totalPages: result.totalPages - } - }; -} - -/** - * Get a single link by ID with full details (including statistics) - */ -export async function getLinkById(linkId: string): Promise { - return await findLinkById(linkId); -} - -/** - * Get a single link by ID - only basic info without statistics - */ -export async function getLinkDetailsById(linkId: string): Promise | null> { - return await findLinkDetailsById(linkId); -} \ No newline at end of file diff --git a/app/api/stats/repository.ts b/app/api/stats/repository.ts deleted file mode 100644 index d8c98ba..0000000 --- a/app/api/stats/repository.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { executeQuerySingle } from '@/lib/clickhouse'; -import { StatsOverview } from '../types'; - -/** - * Get overview statistics for links - */ -export async function findStatsOverview(): Promise { - const query = ` - WITH - toUInt64(count()) as total_links, - toUInt64(countIf(is_active = true)) as active_links - FROM links - SELECT - total_links as totalLinks, - active_links as activeLinks, - (SELECT count() FROM link_events) as totalVisits, - (SELECT count() FROM link_events) / NULLIF(total_links, 0) as conversionRate - `; - - return await executeQuerySingle(query); -} \ No newline at end of file diff --git a/app/api/stats/route.ts b/app/api/stats/route.ts deleted file mode 100644 index ed8dd49..0000000 --- a/app/api/stats/route.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { NextResponse } from 'next/server'; -import { getStatsOverview } from './service'; - -export async function GET() { - try { - const stats = await getStatsOverview(); - return NextResponse.json(stats); - } catch (error) { - console.error('获取统计概览失败:', error); - return NextResponse.json( - { error: '获取统计概览失败', message: (error as Error).message }, - { status: 500 } - ); - } -} \ No newline at end of file diff --git a/app/api/stats/service.ts b/app/api/stats/service.ts deleted file mode 100644 index 2a2d560..0000000 --- a/app/api/stats/service.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { StatsOverview } from '../types'; -import { findStatsOverview } from './repository'; - -/** - * Get link statistics overview - */ -export async function getStatsOverview(): Promise { - const stats = await findStatsOverview(); - - // Return default values if no data - if (!stats) { - return { - totalLinks: 0, - activeLinks: 0, - totalVisits: 0, - conversionRate: 0 - }; - } - - return stats; -} \ No newline at end of file diff --git a/app/api/tags/repository.ts b/app/api/tags/repository.ts deleted file mode 100644 index 0b7a10f..0000000 --- a/app/api/tags/repository.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { executeQuery } from '@/lib/clickhouse'; -import { Tag } from '../types'; - -/** - * Get all tags with usage counts - */ -export async function findAllTags(): Promise { - const query = ` - SELECT - tag, - count() as count - FROM links - ARRAY JOIN tags as tag - GROUP BY tag - ORDER BY count DESC - `; - - return await executeQuery(query); -} \ No newline at end of file diff --git a/app/api/tags/route.ts b/app/api/tags/route.ts deleted file mode 100644 index 7b3f7e2..0000000 --- a/app/api/tags/route.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { NextResponse } from 'next/server'; -import { getAllTags } from './service'; - -export async function GET() { - try { - const tags = await getAllTags(); - return NextResponse.json(tags); - } catch (error) { - console.error('Failed to fetch tags:', error); - return NextResponse.json( - { error: 'Failed to fetch tags', message: (error as Error).message }, - { status: 500 } - ); - } -} \ No newline at end of file diff --git a/app/api/tags/service.ts b/app/api/tags/service.ts deleted file mode 100644 index 29b869f..0000000 --- a/app/api/tags/service.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Tag } from '../types'; -import { findAllTags } from './repository'; - -/** - * Get all available tags - */ -export async function getAllTags(): Promise { - return await findAllTags(); -} \ No newline at end of file diff --git a/app/api/types.ts b/app/api/types.ts deleted file mode 100644 index e2f5ce3..0000000 --- a/app/api/types.ts +++ /dev/null @@ -1,221 +0,0 @@ -// 链接数据类型 -export interface Link { - link_id: string; - original_url: string; - created_at: string; - created_by: string; - title: string; - description: string; - tags: string[]; - is_active: boolean; - expires_at: string | null; - team_id: string; - project_id: string; - visits: number; - unique_visits: number; -} - -// 分页响应类型 -export interface PaginatedResponse { - data: T[]; - pagination: { - total: number; - limit: number; - offset: number; - page: number; - totalPages: number; - } -} - -// 链接查询参数 -export interface LinkQueryParams { - limit?: number; - offset?: number; - page?: number; - searchTerm?: string; - tagFilter?: string; - isActive?: boolean | null; -} - -// 标签类型 -export interface Tag { - tag: string; - count: number; -} - -// 统计概览类型 -export interface StatsOverview { - totalLinks: number; - activeLinks: number; - totalVisits: number; - conversionRate: number; -} - -// Analytics数据类型 -export interface LinkOverviewData { - totalVisits: number; - uniqueVisitors: number; - averageTimeSpent: number; - bounceCount: number; - conversionCount: number; - uniqueReferrers: number; - deviceTypes: { - mobile: number; - tablet: number; - desktop: number; - other: number; - }; - qrScanCount: number; - totalConversionValue: number; -} - -export interface FunnelStep { - name: string; - value: number; - percent: number; -} - -export interface ConversionFunnelData { - steps: FunnelStep[]; - totalConversions: number; - conversionRate: number; -} - -export interface TrendPoint { - timestamp: string; - visits: number; - uniqueVisitors: number; -} - -export interface VisitTrendsData { - trends: TrendPoint[]; - totals: { - visits: number; - uniqueVisitors: number; - }; -} - -export interface TrackEventRequest { - linkId: string; - eventType: string; - visitorId?: string; - sessionId?: string; - referrer?: string; - userAgent?: string; - ipAddress?: string; - timeSpent?: number; - conversionType?: string; - conversionValue?: number; - customData?: Record; - isQrScan?: boolean; - qrCodeId?: string; - utmSource?: string; - utmMedium?: string; - utmCampaign?: string; -} - -export interface TrackEventResponse { - success: boolean; - eventId: string; - timestamp: string; -} - -// 链接表现数据 -export interface LinkPerformanceData { - totalClicks: number; - uniqueVisitors: number; - averageTimeSpent: number; - bounceRate: number; - uniqueReferrers: number; - conversionRate: number; - activeDays: number; - lastClickTime: string | null; - deviceDistribution: { - mobile: number; - desktop: number; - }; -} - -// 平台分布数据 -export interface PlatformItem { - name: string; - count: number; - percent: number; -} - -export interface PlatformDistributionData { - totalVisits: number; - platforms: PlatformItem[]; - browsers: PlatformItem[]; -} - -// 设备分析数据 -export interface DeviceItem { - name: string; - count: number; - percent: number; -} - -export interface DeviceModelItem { - type: string; - brand: string; - model: string; - count: number; - percent: number; -} - -export interface DeviceAnalysisData { - totalVisits: number; - deviceTypes: DeviceItem[]; - deviceBrands: DeviceItem[]; - deviceModels: DeviceModelItem[]; -} - -// 热门引荐来源数据 -export interface ReferrerItem { - source: string; - visitCount: number; - uniqueVisitors: number; - conversionCount: number; - conversionRate: number; - averageTimeSpent: number; - percent: number; -} - -export interface PopularReferrersData { - referrers: ReferrerItem[]; - totalVisits: number; -} - -// QR码分析数据 -export interface LocationItem { - city: string; - country: string; - scanCount: number; - percent: number; -} - -export interface DeviceDistributionItem { - type: string; - count: number; - percent: number; -} - -export interface HourlyDistributionItem { - hour: number; - scanCount: number; - percent: number; -} - -export interface QrCodeAnalysisData { - overview: { - totalScans: number; - uniqueScanners: number; - conversionCount: number; - conversionRate: number; - averageTimeSpent: number; - }; - locations: LocationItem[]; - deviceDistribution: DeviceDistributionItem[]; - hourlyDistribution: HourlyDistributionItem[]; -} \ No newline at end of file diff --git a/lib/analytics.ts b/lib/analytics.ts index 05ed933..b34974e 100644 --- a/lib/analytics.ts +++ b/lib/analytics.ts @@ -1,1266 +1,286 @@ -import { v4 as uuidv4 } from 'uuid'; -import { executeQuery, executeQuerySingle } from './clickhouse'; +import { executeQuery, executeQuerySingle, buildFilter, buildPagination, buildOrderBy } from './clickhouse'; +import type { Event, EventsSummary, TimeSeriesData, GeoData, DeviceAnalytics, DeviceType } from './types'; -// 时间粒度枚举 -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 = ''; +// 获取事件列表 +export async function getEvents(params: { + startTime?: string; + endTime?: string; + eventType?: string; + linkId?: string; + linkSlug?: string; + userId?: string; + teamId?: string; + projectId?: string; + page?: number; + pageSize?: number; + sortBy?: string; + sortOrder?: 'asc' | 'desc'; +}): Promise<{ events: Event[]; total: number }> { + const filter = buildFilter(params); + const pagination = buildPagination(params.page, params.pageSize); + const orderBy = buildOrderBy(params.sortBy, params.sortOrder); - if (startDate && endDate) { - dateFilter = ` AND date >= '${startDate}' AND date <= '${endDate}'`; - } else if (startDate) { - dateFilter = ` AND date >= '${startDate}'`; - } else if (endDate) { - dateFilter = ` AND date <= '${endDate}'`; + // 获取总数 + const countQuery = ` + SELECT count() as total + FROM events + ${filter} + `; + + const totalResult = await executeQuerySingle<{ total: number }>(countQuery); + const total = totalResult?.total || 0; + + // 获取事件列表 + const query = ` + SELECT * + FROM events + ${filter} + ${orderBy} + ${pagination} + `; + + const events = await executeQuery(query); + + return { events, total }; +} + +// 获取事件概览 +export async function getEventsSummary(params: { + startTime?: string; + endTime?: string; + linkId?: string; +}): Promise { + const filter = buildFilter(params); + + // 获取基本统计数据 + const baseQuery = ` + SELECT + count() as totalEvents, + uniq(visitor_id) 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 + `; + + 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'); } - 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; - 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); + // 计算百分比 + const calculatePercentage = (count: number, total: number) => + Number(((count / total) * 100).toFixed(2)); - 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); - } + // 处理浏览器数据 + const browsers = browserResults.map(item => ({ + name: item.name, + count: item.count, + percentage: calculatePercentage(item.count, baseResult.totalEvents) + })); + + // 处理操作系统数据 + const operatingSystems = osResults.map(item => ({ + name: item.name, + count: item.count, + percentage: calculatePercentage(item.count, baseResult.totalEvents) + })); + + return { + totalEvents: baseResult.totalEvents, + uniqueVisitors: baseResult.uniqueVisitors, + totalConversions: baseResult.totalConversions, + averageTimeSpent: Number(baseResult.averageTimeSpent.toFixed(2)), + deviceTypes: { + mobile: baseResult.mobileCount, + desktop: baseResult.desktopCount, + tablet: baseResult.tabletCount, + other: baseResult.otherCount + }, + browsers, + operatingSystems + }; } -/** - * 获取链接表现数据 - */ -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 getTimeSeriesData(params: { + startTime: string; + endTime: string; + linkId?: string; + granularity: 'hour' | 'day' | 'week' | 'month'; +}): Promise { + 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(query); } -/** - * 获取平台分布数据 - */ -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 getGeoAnalytics(params: { + startTime?: string; + endTime?: string; + linkId?: string; + groupBy?: 'country' | 'city'; +}): Promise { + 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(query); } -/** - * 获取链接状态分布数据 - */ -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; +// 获取设备分析 +export async function getDeviceAnalytics(params: { + startTime?: string; + endTime?: string; + linkId?: string; +}): Promise { + 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) => + 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) + })) + }; } \ No newline at end of file diff --git a/lib/clickhouse.ts b/lib/clickhouse.ts index c91e4c0..330f438 100644 --- a/lib/clickhouse.ts +++ b/lib/clickhouse.ts @@ -1,39 +1,109 @@ import { createClient } from '@clickhouse/client'; +import type { EventsQueryParams } from './types'; -// Create configuration object using the URL approach -const config = { +// ClickHouse 客户端配置 +const clickhouse = createClient({ url: process.env.CLICKHOUSE_URL || 'http://localhost:8123', - username: process.env.CLICKHOUSE_USER || 'default', - password: process.env.CLICKHOUSE_PASSWORD || '', - database: process.env.CLICKHOUSE_DATABASE || 'limq' -}; + username: process.env.CLICKHOUSE_USER || 'admin', + password: process.env.CLICKHOUSE_PASSWORD || 'your_secure_password', + database: process.env.CLICKHOUSE_DB || 'shorturl_analytics' +}); -// Create ClickHouse client with proper URL format -export const clickhouse = createClient(config); +// 构建日期过滤条件 +function buildDateFilter(startTime?: string, endTime?: string): string { + const filters = []; + + if (startTime) { + filters.push(`event_time >= parseDateTimeBestEffort('${startTime}')`); + } + + if (endTime) { + filters.push(`event_time <= parseDateTimeBestEffort('${endTime}')`); + } + + return filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : ''; +} +// 构建通用过滤条件 +export function buildFilter(params: Partial): string { + const filters = []; + + // 时间范围过滤 + if (params.startTime || params.endTime) { + const dateFilter = buildDateFilter(params.startTime, params.endTime).replace('WHERE ', ''); + if (dateFilter) { + filters.push(dateFilter); + } + } + + // 事件类型过滤 + if (params.eventType) { + filters.push(`event_type = '${params.eventType}'`); + } + + // 链接ID过滤 + if (params.linkId) { + filters.push(`link_id = '${params.linkId}'`); + } + + // 链接短码过滤 + if (params.linkSlug) { + filters.push(`link_slug = '${params.linkSlug}'`); + } + + // 用户ID过滤 + if (params.userId) { + filters.push(`user_id = '${params.userId}'`); + } + + // 团队ID过滤 + if (params.teamId) { + filters.push(`team_id = '${params.teamId}'`); + } + + // 项目ID过滤 + if (params.projectId) { + filters.push(`project_id = '${params.projectId}'`); + } + + return filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : ''; +} -/** - * Execute ClickHouse query and return results - */ +// 构建分页 +export function buildPagination(page?: number, pageSize?: number): string { + const limit = pageSize || 20; + const offset = ((page || 1) - 1) * limit; + return `LIMIT ${limit} OFFSET ${offset}`; +} + +// 构建排序 +export function buildOrderBy(sortBy?: string, sortOrder?: 'asc' | 'desc'): string { + if (!sortBy) { + return 'ORDER BY event_time DESC'; + } + return `ORDER BY ${sortBy} ${sortOrder || 'desc'}`; +} + +// 执行查询并处理错误 export async function executeQuery(query: string): Promise { try { - const result = await clickhouse.query({ + const resultSet = await clickhouse.query({ query, - format: 'JSONEachRow', + format: 'JSONEachRow' }); - const data = await result.json(); - return data as T[]; + const rows = await resultSet.json(); + return Array.isArray(rows) ? rows : [rows]; } catch (error) { console.error('ClickHouse query error:', error); throw error; } } -/** - * Execute ClickHouse query and return a single result - */ +// 执行查询并返回单个结果 export async function executeQuerySingle(query: string): Promise { const results = await executeQuery(query); return results.length > 0 ? results[0] : null; -} \ No newline at end of file +} + +export default clickhouse; \ No newline at end of file diff --git a/lib/types.ts b/lib/types.ts new file mode 100644 index 0000000..167e6c5 --- /dev/null +++ b/lib/types.ts @@ -0,0 +1,171 @@ +// 事件类型 +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' +} + +// 设备类型 +export enum DeviceType { + MOBILE = 'mobile', + TABLET = 'tablet', + DESKTOP = 'desktop', + OTHER = 'other' +} + +// API 响应基础接口 +export interface ApiResponse { + success: boolean; + data?: T; + error?: string; + meta?: { + total?: number; + page?: number; + pageSize?: number; + }; +} + +// 事件查询参数 +export interface EventsQueryParams { + startTime?: string; // ISO 格式时间 + endTime?: string; // ISO 格式时间 + eventType?: EventType; + linkId?: string; + linkSlug?: string; + userId?: string; + teamId?: string; + projectId?: string; + page?: number; + pageSize?: number; + sortBy?: string; + sortOrder?: 'asc' | 'desc'; +} + +// 事件基础信息 +export interface Event { + event_id: string; + event_time: string; + event_type: EventType; + event_attributes: Record; + + // 链接信息 + link_id: string; + link_slug: string; + link_label: string; + link_title: string; + link_original_url: string; + link_attributes: Record; + link_created_at: string; + link_expires_at: string | null; + link_tags: string[]; + + // 用户信息 + user_id: string; + user_name: string; + user_email: string; + user_attributes: Record; + + // 团队信息 + team_id: string; + team_name: string; + team_attributes: Record; + + // 项目信息 + project_id: string; + project_name: string; + project_attributes: Record; + + // 访问者信息 + visitor_id: string; + session_id: string; + ip_address: string; + country: string; + city: string; + device_type: DeviceType; + browser: string; + os: string; + user_agent: string; + + // 来源信息 + referrer: string; + utm_source: string; + utm_medium: string; + utm_campaign: string; + + // 交互信息 + time_spent_sec: number; + is_bounce: boolean; + is_qr_scan: boolean; + conversion_type: ConversionType; + conversion_value: number; +} + +// 事件概览数据 +export interface EventsSummary { + totalEvents: number; + uniqueVisitors: number; + totalConversions: number; + averageTimeSpent: number; + deviceTypes: { + mobile: number; + desktop: number; + tablet: number; + other: number; + }; + browsers: Array<{ + name: string; + count: number; + percentage: number; + }>; + operatingSystems: Array<{ + name: string; + count: number; + percentage: number; + }>; +} + +// 时间序列数据 +export interface TimeSeriesData { + timestamp: string; + events: number; + visitors: number; + conversions: number; +} + +// 地理位置数据 +export interface GeoData { + location: string; + visits: number; + visitors: number; + percentage: number; +} + +// 设备分析数据 +export interface DeviceAnalytics { + deviceTypes: Array<{ + type: DeviceType; + count: number; + percentage: number; + }>; + browsers: Array<{ + name: string; + count: number; + percentage: number; + }>; + operatingSystems: Array<{ + name: string; + count: number; + percentage: number; + }>; +} \ No newline at end of file