This commit is contained in:
2025-03-13 20:06:58 +08:00
parent b8ea4e7097
commit 72c040cf19
4 changed files with 381 additions and 14 deletions

View File

@@ -113,6 +113,77 @@ export class AnalyticsController {
}, 500); }, 500);
} }
} }
/**
* Get KOL conversion funnel data
* Returns user counts and conversion rates for each funnel stage
*
* @param c Hono Context
* @returns Response with funnel data
*/
async getKolFunnel(c: Context) {
const requestId = `req_${Date.now()}_${Math.random().toString(36).substring(2, 10)}`;
const startTime = Date.now();
try {
// Get query parameters
const timeRange = c.req.query('timeRange') || '30'; // Default to 30 days
const projectId = c.req.query('projectId'); // Optional project filter
const debug = c.req.query('debug') || 'false'; // Debug mode
logger.info(`[${requestId}] KOL funnel request received`, {
timeRange,
projectId,
debug,
userAgent: c.req.header('user-agent'),
ip: c.req.header('x-forwarded-for') || 'unknown'
});
// Validate time range
if (!['7', '30', '90'].includes(timeRange)) {
logger.warn(`[${requestId}] Invalid timeRange: ${timeRange}`);
return c.json({
success: false,
error: 'Invalid timeRange. Must be 7, 30, or 90.'
}, 400);
}
// Get funnel data from service
const data = await analyticsService.getKolFunnel(
parseInt(timeRange, 10),
projectId
);
// Debug mode - log additional data
if (debug.toLowerCase() === 'true' && process.env.NODE_ENV !== 'production') {
await analyticsService.debugEventData();
}
// Log successful response
const duration = Date.now() - startTime;
logger.info(`[${requestId}] KOL funnel response sent successfully`, {
duration,
stageCount: data.stages.length
});
// Return the data
return c.json({
success: true,
data
});
} catch (error) {
// Log error
const duration = Date.now() - startTime;
logger.error(`[${requestId}] Error fetching KOL funnel (${duration}ms)`, error);
// Return error response
return c.json({
success: false,
error: 'Failed to fetch KOL funnel data',
message: error instanceof Error ? error.message : 'Unknown error'
}, 500);
}
}
} }
// Export singleton instance // Export singleton instance

View File

@@ -1,21 +1,21 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { analyticsController } from '../controllers/analyticsController'; import { cors } from 'hono/cors';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import { analyticsController } from '../controllers/analyticsController';
// Create analytics router // Create analytics router
const analyticsRouter = new Hono(); const analyticsRouter = new Hono();
// Log all analytics requests // Add middleware
analyticsRouter.use('*', cors());
analyticsRouter.use('*', async (c, next) => { analyticsRouter.use('*', async (c, next) => {
const startTime = Date.now(); const startTime = Date.now();
const path = c.req.path;
const method = c.req.method; const method = c.req.method;
const path = c.req.path;
const query = Object.fromEntries(new URL(c.req.url).searchParams.entries());
const userAgent = c.req.header('user-agent');
logger.info(`Analytics API request: ${method} ${path}`, { logger.info(`Analytics API request: ${method} ${path}`, { query, userAgent });
query: c.req.query(),
userAgent: c.req.header('user-agent'),
referer: c.req.header('referer')
});
await next(); await next();
@@ -26,4 +26,7 @@ analyticsRouter.use('*', async (c, next) => {
// KOL performance overview endpoint // KOL performance overview endpoint
analyticsRouter.get('/kol-overview', (c) => analyticsController.getKolOverview(c)); analyticsRouter.get('/kol-overview', (c) => analyticsController.getKolOverview(c));
// Add new funnel analysis route
analyticsRouter.get('/kol-funnel', (c) => analyticsController.getKolFunnel(c));
export default analyticsRouter; export default analyticsRouter;

View File

@@ -12,11 +12,11 @@ export interface KolPerformanceData {
profile_url: string; profile_url: string;
followers_count: number; followers_count: number;
followers_change: number; followers_change: number;
followers_change_percentage: number | null; followers_change_percentage: number | null | string;
likes_change: number; likes_change: number;
likes_change_percentage: number | null; likes_change_percentage: number | null | string;
follows_change: number; follows_change: number;
follows_change_percentage: number | null; follows_change_percentage: number | null | string;
} }
/** /**
@@ -27,6 +27,34 @@ export interface KolPerformanceResponse {
total: number; total: number;
} }
/**
* 漏斗阶段数据
*/
export interface FunnelStageData {
stage: string; // 阶段名称
stage_display: string; // 阶段显示名称
count: number; // 用户数量
percentage: number; // 总体占比(%)
conversion_rate: number | null; // 与上一阶段的转化率(%)
}
/**
* 漏斗分析总览数据
*/
export interface FunnelOverview {
average_conversion_rate: number; // 平均转化率(%)
highest_conversion_stage: string; // 转化率最高的阶段
lowest_conversion_stage: string; // 转化率最低的阶段
}
/**
* 漏斗分析响应
*/
export interface FunnelResponse {
stages: FunnelStageData[]; // 各阶段数据
overview: FunnelOverview; // 总览数据
}
/** /**
* Analytics service for KOL performance data * Analytics service for KOL performance data
*/ */
@@ -169,11 +197,11 @@ export class AnalyticsService {
profile_url: String(influencer.profile_url || ''), profile_url: String(influencer.profile_url || ''),
followers_count: Number(influencer.followers_count || 0), followers_count: Number(influencer.followers_count || 0),
followers_change: Number(events.followers_change || 0), followers_change: Number(events.followers_change || 0),
followers_change_percentage: null, // We'll calculate this in a separate query if needed followers_change_percentage: "待计算",
likes_change: Number(events.likes_change || 0), likes_change: Number(events.likes_change || 0),
likes_change_percentage: null, likes_change_percentage: "待计算",
follows_change: Number(events.follows_change || 0), follows_change: Number(events.follows_change || 0),
follows_change_percentage: null follows_change_percentage: "待计算"
}; };
}); });
@@ -368,6 +396,157 @@ export class AnalyticsService {
logger.warn('Error in debugEventData', error); logger.warn('Error in debugEventData', error);
} }
} }
/**
* 获取KOL合作转换漏斗数据
* @param timeRange 时间范围(天数)
* @param projectId 可选项目ID
* @returns 漏斗数据
*/
async getKolFunnel(
timeRange: number,
projectId?: string
): Promise<FunnelResponse> {
const startTime = Date.now();
logger.info('Fetching KOL funnel data', { timeRange, projectId });
try {
// 计算时间范围
const currentDate = new Date();
const pastDate = new Date();
pastDate.setDate(currentDate.getDate() - timeRange);
// 格式化日期
const currentDateStr = this.formatDateForClickhouse(currentDate);
const pastDateStr = this.formatDateForClickhouse(pastDate);
// 构建项目过滤条件
const projectFilter = projectId ? `AND project_id = '${projectId}'` : '';
// 漏斗阶段及其显示名称
const stages = [
{ id: 'exposure', display: '曝光' },
{ id: 'interest', display: '兴趣' },
{ id: 'consideration', display: '考虑' },
{ id: 'intent', display: '意向' },
{ id: 'evaluation', display: '评估' },
{ id: 'purchase', display: '购买' }
];
// 查询每个阶段的用户数量
const funnelQuery = `
SELECT
funnel_stage,
COUNT(DISTINCT user_id) as user_count
FROM
events
WHERE
date BETWEEN '${pastDateStr}' AND '${currentDateStr}'
${projectFilter}
GROUP BY
funnel_stage
ORDER BY
CASE funnel_stage
WHEN 'exposure' THEN 1
WHEN 'interest' THEN 2
WHEN 'consideration' THEN 3
WHEN 'intent' THEN 4
WHEN 'evaluation' THEN 5
WHEN 'purchase' THEN 6
ELSE 7
END
`;
logger.debug('Executing ClickHouse query for funnel data', {
funnelQuery: funnelQuery.replace(/\n\s+/g, ' ').trim()
});
// 执行查询
const funnelData = await this.executeClickhouseQuery(funnelQuery);
// 将结果转换为Map便于查找
const stageCounts: Record<string, number> = {};
if (Array.isArray(funnelData)) {
funnelData.forEach(item => {
if (item.funnel_stage && item.user_count) {
stageCounts[item.funnel_stage] = Number(item.user_count);
}
});
}
// 计算总用户数(以最上层漏斗的用户数为准)
const totalUsers = stageCounts['exposure'] || 0;
// 构建漏斗阶段数据
const stagesData: FunnelStageData[] = [];
let prevCount = 0;
stages.forEach((stage, index) => {
const count = stageCounts[stage.id] || 0;
const percentage = totalUsers > 0 ? (count / totalUsers * 100) : 0;
let conversionRate = null;
if (index > 0 && prevCount > 0) {
conversionRate = (count / prevCount) * 100;
}
stagesData.push({
stage: stage.id,
stage_display: stage.display,
count,
percentage: parseFloat(percentage.toFixed(2)),
conversion_rate: conversionRate !== null ? parseFloat(conversionRate.toFixed(2)) : null
});
prevCount = count;
});
// 计算总览数据
const conversionRates = stagesData
.filter(stage => stage.conversion_rate !== null)
.map(stage => stage.conversion_rate as number);
const averageConversionRate = conversionRates.length > 0
? parseFloat((conversionRates.reduce((sum, rate) => sum + rate, 0) / conversionRates.length).toFixed(2))
: 0;
// 找出转化率最高和最低的阶段
let highestStage = { stage_display: '无数据', rate: 0 };
let lowestStage = { stage_display: '无数据', rate: 100 };
stagesData.forEach(stage => {
if (stage.conversion_rate !== null) {
if (stage.conversion_rate > highestStage.rate) {
highestStage = { stage_display: stage.stage_display, rate: stage.conversion_rate };
}
if (stage.conversion_rate < lowestStage.rate) {
lowestStage = { stage_display: stage.stage_display, rate: stage.conversion_rate };
}
}
});
const overview: FunnelOverview = {
average_conversion_rate: averageConversionRate,
highest_conversion_stage: highestStage.stage_display,
lowest_conversion_stage: lowestStage.stage_display
};
const duration = Date.now() - startTime;
logger.info('KOL funnel data fetched successfully', {
duration,
totalUsers
});
return {
stages: stagesData,
overview
};
} catch (error) {
const duration = Date.now() - startTime;
logger.error(`Error in getKolFunnel (${duration}ms)`, error);
throw error;
}
}
} }
// Export singleton instance // Export singleton instance

View File

@@ -2239,6 +2239,120 @@ export const openAPISpec = {
} }
} }
}, },
'/api/analytics/kol-funnel': {
get: {
summary: 'Get KOL conversion funnel data',
description: 'Returns user counts and conversion rates for each funnel stage in the KOL collaboration process',
tags: ['Analytics'],
parameters: [
{
name: 'timeRange',
in: 'query',
description: 'Number of days to look back',
schema: {
type: 'string',
enum: ['7', '30', '90'],
default: '30'
}
},
{
name: 'projectId',
in: 'query',
description: 'Filter by project ID',
schema: {
type: 'string'
}
},
{
name: 'debug',
in: 'query',
description: 'Enable debug mode (non-production environments only)',
schema: {
type: 'string',
enum: ['true', 'false'],
default: 'false'
}
}
],
responses: {
'200': {
description: 'Successful response with funnel data',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
success: {
type: 'boolean',
example: true
},
data: {
type: 'object',
properties: {
stages: {
type: 'array',
items: {
type: 'object',
properties: {
stage: { type: 'string', example: 'exposure' },
stage_display: { type: 'string', example: '曝光' },
count: { type: 'integer', example: 10000 },
percentage: { type: 'number', example: 100 },
conversion_rate: {
type: 'number',
nullable: true,
example: 75.5
}
}
}
},
overview: {
type: 'object',
properties: {
average_conversion_rate: { type: 'number', example: 45.2 },
highest_conversion_stage: { type: 'string', example: '兴趣' },
lowest_conversion_stage: { type: 'string', example: '购买' }
}
}
}
}
}
}
}
}
},
'400': {
description: 'Bad request - invalid parameters',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
success: { type: 'boolean', example: false },
error: { type: 'string', example: 'Invalid timeRange. Must be 7, 30, or 90.' }
}
}
}
}
},
'500': {
description: 'Server error',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
success: { type: 'boolean', example: false },
error: { type: 'string', example: 'Failed to fetch KOL funnel data' },
message: { type: 'string', example: 'ClickHouse query error: Connection refused' }
}
}
}
}
}
}
}
},
}, },
components: { components: {
schemas: { schemas: {