events apis
This commit is contained in:
152
api/events.ts
Normal file
152
api/events.ts
Normal file
@@ -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<typeof events> = {
|
||||
success: true,
|
||||
data: events,
|
||||
meta: {
|
||||
total,
|
||||
page: params.page,
|
||||
pageSize: params.pageSize
|
||||
}
|
||||
};
|
||||
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
const response: ApiResponse<null> = {
|
||||
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<typeof summary> = {
|
||||
success: true,
|
||||
data: summary
|
||||
};
|
||||
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
const response: ApiResponse<null> = {
|
||||
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<typeof data> = {
|
||||
success: true,
|
||||
data
|
||||
};
|
||||
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
const response: ApiResponse<null> = {
|
||||
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<typeof data> = {
|
||||
success: true,
|
||||
data
|
||||
};
|
||||
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
const response: ApiResponse<null> = {
|
||||
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<typeof data> = {
|
||||
success: true,
|
||||
data
|
||||
};
|
||||
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
const response: ApiResponse<null> = {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error occurred'
|
||||
};
|
||||
res.status(500).json(response);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
28
app/api/events/devices/route.ts
Normal file
28
app/api/events/devices/route.ts
Normal file
@@ -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<typeof data> = {
|
||||
success: true,
|
||||
data
|
||||
};
|
||||
|
||||
return NextResponse.json(response);
|
||||
} catch (error) {
|
||||
const response: ApiResponse<null> = {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error occurred'
|
||||
};
|
||||
return NextResponse.json(response, { status: 500 });
|
||||
}
|
||||
}
|
||||
29
app/api/events/geo/route.ts
Normal file
29
app/api/events/geo/route.ts
Normal file
@@ -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<typeof data> = {
|
||||
success: true,
|
||||
data
|
||||
};
|
||||
|
||||
return NextResponse.json(response);
|
||||
} catch (error) {
|
||||
const response: ApiResponse<null> = {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error occurred'
|
||||
};
|
||||
return NextResponse.json(response, { status: 500 });
|
||||
}
|
||||
}
|
||||
51
app/api/events/route.ts
Normal file
51
app/api/events/route.ts
Normal file
@@ -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<typeof events> = {
|
||||
success: true,
|
||||
data: events,
|
||||
meta: {
|
||||
total,
|
||||
page: params.page,
|
||||
pageSize: params.pageSize
|
||||
}
|
||||
};
|
||||
|
||||
return NextResponse.json(response);
|
||||
} catch (error) {
|
||||
const response: ApiResponse<null> = {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error occurred'
|
||||
};
|
||||
return NextResponse.json(response, { status: 500 });
|
||||
}
|
||||
}
|
||||
28
app/api/events/summary/route.ts
Normal file
28
app/api/events/summary/route.ts
Normal file
@@ -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<typeof summary> = {
|
||||
success: true,
|
||||
data: summary
|
||||
};
|
||||
|
||||
return NextResponse.json(response);
|
||||
} catch (error) {
|
||||
const response: ApiResponse<null> = {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error occurred'
|
||||
};
|
||||
return NextResponse.json(response, { status: 500 });
|
||||
}
|
||||
}
|
||||
38
app/api/events/time-series/route.ts
Normal file
38
app/api/events/time-series/route.ts
Normal file
@@ -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<typeof data> = {
|
||||
success: true,
|
||||
data
|
||||
};
|
||||
|
||||
return NextResponse.json(response);
|
||||
} catch (error) {
|
||||
const response: ApiResponse<null> = {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error occurred'
|
||||
};
|
||||
return NextResponse.json(response, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -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<any> }
|
||||
) {
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getLinkById } from '../service';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<any> }
|
||||
) {
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<Link>(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<Link | null> {
|
||||
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<Link>(query);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a single link by ID - only basic info without statistics
|
||||
*/
|
||||
export async function findLinkDetailsById(linkId: string): Promise<Omit<Link, 'visits' | 'unique_visits'> | 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<Omit<Link, 'visits' | 'unique_visits'>>(query);
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<PaginatedResponse<Link>> {
|
||||
// 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<Link | null> {
|
||||
return await findLinkById(linkId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single link by ID - only basic info without statistics
|
||||
*/
|
||||
export async function getLinkDetailsById(linkId: string): Promise<Omit<Link, 'visits' | 'unique_visits'> | null> {
|
||||
return await findLinkDetailsById(linkId);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { executeQuerySingle } from '@/lib/clickhouse';
|
||||
import { StatsOverview } from '../types';
|
||||
|
||||
/**
|
||||
* Get overview statistics for links
|
||||
*/
|
||||
export async function findStatsOverview(): Promise<StatsOverview | null> {
|
||||
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<StatsOverview>(query);
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { StatsOverview } from '../types';
|
||||
import { findStatsOverview } from './repository';
|
||||
|
||||
/**
|
||||
* Get link statistics overview
|
||||
*/
|
||||
export async function getStatsOverview(): Promise<StatsOverview> {
|
||||
const stats = await findStatsOverview();
|
||||
|
||||
// Return default values if no data
|
||||
if (!stats) {
|
||||
return {
|
||||
totalLinks: 0,
|
||||
activeLinks: 0,
|
||||
totalVisits: 0,
|
||||
conversionRate: 0
|
||||
};
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { executeQuery } from '@/lib/clickhouse';
|
||||
import { Tag } from '../types';
|
||||
|
||||
/**
|
||||
* Get all tags with usage counts
|
||||
*/
|
||||
export async function findAllTags(): Promise<Tag[]> {
|
||||
const query = `
|
||||
SELECT
|
||||
tag,
|
||||
count() as count
|
||||
FROM links
|
||||
ARRAY JOIN tags as tag
|
||||
GROUP BY tag
|
||||
ORDER BY count DESC
|
||||
`;
|
||||
|
||||
return await executeQuery<Tag>(query);
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Tag } from '../types';
|
||||
import { findAllTags } from './repository';
|
||||
|
||||
/**
|
||||
* Get all available tags
|
||||
*/
|
||||
export async function getAllTags(): Promise<Tag[]> {
|
||||
return await findAllTags();
|
||||
}
|
||||
221
app/api/types.ts
221
app/api/types.ts
@@ -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<T> {
|
||||
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<string, unknown>;
|
||||
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[];
|
||||
}
|
||||
1464
lib/analytics.ts
1464
lib/analytics.ts
File diff suppressed because it is too large
Load Diff
@@ -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'
|
||||
};
|
||||
|
||||
// Create ClickHouse client with proper URL format
|
||||
export const clickhouse = createClient(config);
|
||||
|
||||
|
||||
/**
|
||||
* Execute ClickHouse query and return results
|
||||
*/
|
||||
export async function executeQuery<T>(query: string): Promise<T[]> {
|
||||
try {
|
||||
const result = await clickhouse.query({
|
||||
query,
|
||||
format: 'JSONEachRow',
|
||||
username: process.env.CLICKHOUSE_USER || 'admin',
|
||||
password: process.env.CLICKHOUSE_PASSWORD || 'your_secure_password',
|
||||
database: process.env.CLICKHOUSE_DB || 'shorturl_analytics'
|
||||
});
|
||||
|
||||
const data = await result.json();
|
||||
return data as T[];
|
||||
// 构建日期过滤条件
|
||||
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<EventsQueryParams>): 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 ')}` : '';
|
||||
}
|
||||
|
||||
// 构建分页
|
||||
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<T>(query: string): Promise<T[]> {
|
||||
try {
|
||||
const resultSet = await clickhouse.query({
|
||||
query,
|
||||
format: 'JSONEachRow'
|
||||
});
|
||||
|
||||
const rows = await resultSet.json<T>();
|
||||
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<T>(query: string): Promise<T | null> {
|
||||
const results = await executeQuery<T>(query);
|
||||
return results.length > 0 ? results[0] : null;
|
||||
}
|
||||
|
||||
export default clickhouse;
|
||||
171
lib/types.ts
Normal file
171
lib/types.ts
Normal file
@@ -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<T> {
|
||||
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<string, any>;
|
||||
|
||||
// 链接信息
|
||||
link_id: string;
|
||||
link_slug: string;
|
||||
link_label: string;
|
||||
link_title: string;
|
||||
link_original_url: string;
|
||||
link_attributes: Record<string, any>;
|
||||
link_created_at: string;
|
||||
link_expires_at: string | null;
|
||||
link_tags: string[];
|
||||
|
||||
// 用户信息
|
||||
user_id: string;
|
||||
user_name: string;
|
||||
user_email: string;
|
||||
user_attributes: Record<string, any>;
|
||||
|
||||
// 团队信息
|
||||
team_id: string;
|
||||
team_name: string;
|
||||
team_attributes: Record<string, any>;
|
||||
|
||||
// 项目信息
|
||||
project_id: string;
|
||||
project_name: string;
|
||||
project_attributes: Record<string, any>;
|
||||
|
||||
// 访问者信息
|
||||
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;
|
||||
}>;
|
||||
}
|
||||
Reference in New Issue
Block a user