events apis

This commit is contained in:
2025-03-25 17:26:04 +08:00
parent 1e9e5928d7
commit 92d82b18a0
33 changed files with 859 additions and 2315 deletions

152
api/events.ts Normal file
View 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;

View File

@@ -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 }
);
}
}

View File

@@ -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 }
);
}
}

View File

@@ -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 }
);
}
}

View File

@@ -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 }
);
}
}

View File

@@ -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 }
);
}
}

View File

@@ -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 }
);
}
}

View File

@@ -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 }
);
}
}

View File

@@ -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 }
);
}
}

View File

@@ -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 }
);
}
}

View File

@@ -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 }
);
}
}

View File

@@ -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 }
);
}
}

View File

@@ -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 }
);
}
}

View 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 });
}
}

View 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
View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View File

@@ -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 }
);
}
}

View File

@@ -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 }
);
}
}

View File

@@ -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);
}

View File

@@ -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 }
);
}
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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 }
);
}
}

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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 }
);
}
}

View File

@@ -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();
}

View File

@@ -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[];
}

File diff suppressed because it is too large Load Diff

View File

@@ -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}')`);
}
/**
* Execute ClickHouse query and return results
*/
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 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<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
View 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;
}>;
}