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[];
|
|
||||||
}
|
|
||||||
1526
lib/analytics.ts
1526
lib/analytics.ts
File diff suppressed because it is too large
Load Diff
@@ -1,39 +1,109 @@
|
|||||||
import { createClient } from '@clickhouse/client';
|
import { createClient } from '@clickhouse/client';
|
||||||
|
import type { EventsQueryParams } from './types';
|
||||||
|
|
||||||
// Create configuration object using the URL approach
|
// ClickHouse 客户端配置
|
||||||
const config = {
|
const clickhouse = createClient({
|
||||||
url: process.env.CLICKHOUSE_URL || 'http://localhost:8123',
|
url: process.env.CLICKHOUSE_URL || 'http://localhost:8123',
|
||||||
username: process.env.CLICKHOUSE_USER || 'default',
|
username: process.env.CLICKHOUSE_USER || 'admin',
|
||||||
password: process.env.CLICKHOUSE_PASSWORD || '',
|
password: process.env.CLICKHOUSE_PASSWORD || 'your_secure_password',
|
||||||
database: process.env.CLICKHOUSE_DATABASE || 'limq'
|
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<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 ')}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
// 构建分页
|
||||||
* 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<T>(query: string): Promise<T[]> {
|
export async function executeQuery<T>(query: string): Promise<T[]> {
|
||||||
try {
|
try {
|
||||||
const result = await clickhouse.query({
|
const resultSet = await clickhouse.query({
|
||||||
query,
|
query,
|
||||||
format: 'JSONEachRow',
|
format: 'JSONEachRow'
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await result.json();
|
const rows = await resultSet.json<T>();
|
||||||
return data as T[];
|
return Array.isArray(rows) ? rows : [rows];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('ClickHouse query error:', error);
|
console.error('ClickHouse query error:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// 执行查询并返回单个结果
|
||||||
* Execute ClickHouse query and return a single result
|
|
||||||
*/
|
|
||||||
export async function executeQuerySingle<T>(query: string): Promise<T | null> {
|
export async function executeQuerySingle<T>(query: string): Promise<T | null> {
|
||||||
const results = await executeQuery<T>(query);
|
const results = await executeQuery<T>(query);
|
||||||
return results.length > 0 ? results[0] : null;
|
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