From 72c040cf198ed1b14bc209a9ab4e6966b514158b Mon Sep 17 00:00:00 2001 From: William Tso Date: Thu, 13 Mar 2025 20:06:58 +0800 Subject: [PATCH] kunnel --- .../src/controllers/analyticsController.ts | 71 +++++++ backend/src/routes/analytics.ts | 19 +- backend/src/services/analyticsService.ts | 191 +++++++++++++++++- backend/src/swagger/index.ts | 114 +++++++++++ 4 files changed, 381 insertions(+), 14 deletions(-) diff --git a/backend/src/controllers/analyticsController.ts b/backend/src/controllers/analyticsController.ts index 4758ce1..c34535e 100644 --- a/backend/src/controllers/analyticsController.ts +++ b/backend/src/controllers/analyticsController.ts @@ -113,6 +113,77 @@ export class AnalyticsController { }, 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 diff --git a/backend/src/routes/analytics.ts b/backend/src/routes/analytics.ts index d0b83af..e6e3808 100644 --- a/backend/src/routes/analytics.ts +++ b/backend/src/routes/analytics.ts @@ -1,21 +1,21 @@ import { Hono } from 'hono'; -import { analyticsController } from '../controllers/analyticsController'; +import { cors } from 'hono/cors'; import { logger } from '../utils/logger'; +import { analyticsController } from '../controllers/analyticsController'; // Create analytics router const analyticsRouter = new Hono(); -// Log all analytics requests +// Add middleware +analyticsRouter.use('*', cors()); analyticsRouter.use('*', async (c, next) => { const startTime = Date.now(); - const path = c.req.path; 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}`, { - query: c.req.query(), - userAgent: c.req.header('user-agent'), - referer: c.req.header('referer') - }); + logger.info(`Analytics API request: ${method} ${path}`, { query, userAgent }); await next(); @@ -26,4 +26,7 @@ analyticsRouter.use('*', async (c, next) => { // KOL performance overview endpoint analyticsRouter.get('/kol-overview', (c) => analyticsController.getKolOverview(c)); +// Add new funnel analysis route +analyticsRouter.get('/kol-funnel', (c) => analyticsController.getKolFunnel(c)); + export default analyticsRouter; \ No newline at end of file diff --git a/backend/src/services/analyticsService.ts b/backend/src/services/analyticsService.ts index 5221c82..1c7adb7 100644 --- a/backend/src/services/analyticsService.ts +++ b/backend/src/services/analyticsService.ts @@ -12,11 +12,11 @@ export interface KolPerformanceData { profile_url: string; followers_count: number; followers_change: number; - followers_change_percentage: number | null; + followers_change_percentage: number | null | string; likes_change: number; - likes_change_percentage: number | null; + likes_change_percentage: number | null | string; follows_change: number; - follows_change_percentage: number | null; + follows_change_percentage: number | null | string; } /** @@ -27,6 +27,34 @@ export interface KolPerformanceResponse { 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 */ @@ -169,11 +197,11 @@ export class AnalyticsService { profile_url: String(influencer.profile_url || ''), followers_count: Number(influencer.followers_count || 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_percentage: null, + likes_change_percentage: "待计算", 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); } } + + /** + * 获取KOL合作转换漏斗数据 + * @param timeRange 时间范围(天数) + * @param projectId 可选项目ID + * @returns 漏斗数据 + */ + async getKolFunnel( + timeRange: number, + projectId?: string + ): Promise { + 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 = {}; + 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 diff --git a/backend/src/swagger/index.ts b/backend/src/swagger/index.ts index de20b1f..07624cb 100644 --- a/backend/src/swagger/index.ts +++ b/backend/src/swagger/index.ts @@ -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: { schemas: {