kunnel
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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<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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user