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