From 4056bac3ab2b061da3f9f34d243afe2dd45021bc Mon Sep 17 00:00:00 2001 From: William Tso Date: Fri, 14 Mar 2025 23:23:47 +0800 Subject: [PATCH] content performance --- .../src/controllers/analyticsController.ts | 75 ++++ backend/src/routes/analytics.ts | 3 + backend/src/services/analyticsService.ts | 351 ++++++++++++++++++ backend/src/swagger/index.ts | 201 ++++++++++ 4 files changed, 630 insertions(+) diff --git a/backend/src/controllers/analyticsController.ts b/backend/src/controllers/analyticsController.ts index f39dc05..3fe01e7 100644 --- a/backend/src/controllers/analyticsController.ts +++ b/backend/src/controllers/analyticsController.ts @@ -910,6 +910,81 @@ export class AnalyticsController { }, 500); } } + + /** + * 获取内容表现分析数据 + * 提供内容覆盖量、互动率、互动量等散点图数据,用于四象限分析 + * + * @param c Hono Context + * @returns Response with content performance data + */ + async getContentPerformance(c: Context) { + const requestId = `req_${Date.now()}_${Math.random().toString(36).substring(2, 10)}`; + const startTime = Date.now(); + + try { + // 获取查询参数 + const timeRange = c.req.query('timeRange') || '30'; // 默认30天 + const projectId = c.req.query('projectId'); // 可选项目过滤 + const platform = c.req.query('platform'); // 可选平台过滤 + const kolId = c.req.query('kolId'); // 可选KOL过滤 + const contentType = c.req.query('contentType') || 'all'; // 内容类型过滤(post、video、article或all) + const limit = parseInt(c.req.query('limit') || '100', 10); // 默认返回100条内容 + + logger.info(`[${requestId}] Content performance analysis request received`, { + timeRange, + projectId, + platform, + kolId, + contentType, + limit, + userAgent: c.req.header('user-agent'), + ip: c.req.header('x-forwarded-for') || 'unknown' + }); + + // 验证时间范围 + 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); + } + + // 调用服务获取内容表现分析数据 + const contentPerformanceData = await analyticsService.getContentPerformance( + parseInt(timeRange, 10), + projectId, + platform, + kolId, + contentType, + limit + ); + + // 记录请求完成 + const duration = Date.now() - startTime; + logger.info(`[${requestId}] Content performance analysis request completed in ${duration}ms`, { + itemCount: contentPerformanceData.data.length, + metadata: contentPerformanceData.metadata + }); + + return c.json({ + success: true, + data: contentPerformanceData.data, + metadata: contentPerformanceData.metadata + }); + } catch (error) { + // 记录错误 + const duration = Date.now() - startTime; + logger.error(`[${requestId}] Error fetching content performance data (${duration}ms)`, error); + + return c.json({ + success: false, + error: 'Failed to fetch content performance 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 7454db5..c5c0fbb 100644 --- a/backend/src/routes/analytics.ts +++ b/backend/src/routes/analytics.ts @@ -56,4 +56,7 @@ analyticsRouter.get('/hot-keywords', (c) => analyticsController.getHotKeywords(c // Add interaction time analysis route analyticsRouter.get('/interaction-time', (c) => analyticsController.getInteractionTimeAnalysis(c)); +// Add content performance analysis route +analyticsRouter.get('/content-performance', (c) => analyticsController.getContentPerformance(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 7ad4737..3248bf5 100644 --- a/backend/src/services/analyticsService.ts +++ b/backend/src/services/analyticsService.ts @@ -244,6 +244,43 @@ export interface InteractionTimeResponse { total: number; // 总互动数 } +/** + * 内容表现分析项目数据 + */ +export interface ContentPerformanceItem { + content_id: string; + title: string; + platform: string; + content_type: string; + influencer_name: string; + publish_date: string; + coverage: number; // 内容覆盖量(如阅读量/浏览量) + interaction_rate: number; // 互动率(互动总数/覆盖量) + interaction_count: number; // 互动总量(点赞+评论+分享) + likes: number; + comments: number; + shares: number; + quadrant: 'high_value' | 'high_coverage' | 'high_engagement' | 'low_performance'; // 四象限分类 +} + +/** + * 内容表现分析响应数据 + */ +export interface ContentPerformanceResponse { + data: ContentPerformanceItem[]; + metadata: { + total: number; + average_coverage: number; + average_interaction_rate: number; + quadrant_counts: { + high_value: number; + high_coverage: number; + high_engagement: number; + low_performance: number; + } + }; +} + /** * Analytics service for KOL performance data */ @@ -2103,6 +2140,320 @@ export class AnalyticsService { }; } } + + /** + * 获取内容表现分析数据 + * + * @param timeRange 时间范围(天数) + * @param projectId 可选项目ID + * @param platform 可选平台 + * @param kolId 可选KOL ID + * @param contentType 内容类型过滤 + * @param limit 最大返回条数 + * @returns 内容表现分析数据 + */ + async getContentPerformance( + timeRange: number, + projectId?: string, + platform?: string, + kolId?: string, + contentType: string = 'all', + limit: number = 100 + ): Promise { + const startTime = Date.now(); + logger.info('Fetching content performance data', { timeRange, projectId, platform, kolId, contentType, limit }); + + try { + // 计算日期范围 + const currentDate = new Date(); + const pastDate = new Date(); + pastDate.setDate(pastDate.getDate() - timeRange); + + const currentDateStr = this.formatDateForClickhouse(currentDate); + const pastDateStr = this.formatDateForClickhouse(pastDate); + + // 构建基础过滤条件 + const filters = [ + `created_at BETWEEN '${pastDateStr}' AND '${currentDateStr}'` + ]; + + if (projectId) { + filters.push(`project_id = '${projectId}'`); + } + + if (platform) { + filters.push(`platform = '${platform}'`); + } + + if (kolId) { + filters.push(`influencer_id = '${kolId}'`); + } + + if (contentType !== 'all') { + filters.push(`content_type = '${contentType}'`); + } + + const whereClause = filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : ''; + + // 查询内容数据及性能指标 + const contentQuery = ` + WITH content_metrics AS ( + SELECT + c.content_id, + c.title, + c.platform, + c.content_type, + c.influencer_id, + i.name AS influencer_name, + toString(c.created_at) AS publish_date, + c.views AS coverage, + countIf(e.event_type = 'like') AS likes, + countIf(e.event_type = 'comment') AS comments, + countIf(e.event_type = 'share') AS shares, + count() AS total_interactions + FROM + content c + LEFT JOIN + influencers i ON c.influencer_id = i.influencer_id + LEFT JOIN + events e ON c.content_id = e.content_id AND e.event_time BETWEEN '${pastDateStr}' AND '${currentDateStr}' + ${whereClause} + GROUP BY + c.content_id, c.title, c.platform, c.content_type, c.influencer_id, i.name, c.created_at, c.views + ) + SELECT + content_id, + title, + platform, + content_type, + influencer_id, + influencer_name, + publish_date, + coverage, + likes, + comments, + shares, + total_interactions, + coverage > 0 ? total_interactions / coverage : 0 AS interaction_rate + FROM + content_metrics + WHERE + coverage > 0 + ORDER BY + total_interactions DESC + LIMIT ${limit} + `; + + // 执行查询 + logger.debug('Executing ClickHouse query for content performance', { + query: contentQuery.replace(/\n\s+/g, ' ').trim() + }); + + const contentData = await this.executeClickhouseQuery(contentQuery); + + // 如果没有数据,返回模拟数据 + if (!Array.isArray(contentData) || contentData.length === 0) { + return this.generateMockContentPerformance(limit); + } + + // 处理结果,计算平均值和四象限分类 + const processedData: ContentPerformanceItem[] = []; + let totalCoverage = 0; + let totalInteractionRate = 0; + + // 处理每个内容项 + contentData.forEach(item => { + const coverage = Number(item.coverage || 0); + const likes = Number(item.likes || 0); + const comments = Number(item.comments || 0); + const shares = Number(item.shares || 0); + const interactionCount = likes + comments + shares; + const interactionRate = coverage > 0 ? interactionCount / coverage : 0; + + totalCoverage += coverage; + totalInteractionRate += interactionRate; + + processedData.push({ + content_id: item.content_id, + title: item.title || '无标题', + platform: item.platform || 'unknown', + content_type: item.content_type || 'post', + influencer_name: item.influencer_name || '未知KOL', + publish_date: item.publish_date || '', + coverage, + interaction_rate: interactionRate, + interaction_count: interactionCount, + likes, + comments, + shares, + quadrant: 'low_performance' // 临时值,稍后更新 + }); + }); + + // 计算平均值 + const averageCoverage = processedData.length > 0 ? totalCoverage / processedData.length : 0; + const averageInteractionRate = processedData.length > 0 ? totalInteractionRate / processedData.length : 0; + + // 根据平均值确定每个内容的四象限分类 + const quadrantCounts = { + high_value: 0, + high_coverage: 0, + high_engagement: 0, + low_performance: 0 + }; + + processedData.forEach(item => { + if (item.coverage > averageCoverage && item.interaction_rate > averageInteractionRate) { + // 高覆盖 + 高互动率 = 高价值 + item.quadrant = 'high_value'; + quadrantCounts.high_value++; + } else if (item.coverage > averageCoverage) { + // 高覆盖 + 低互动率 = 高覆盖 + item.quadrant = 'high_coverage'; + quadrantCounts.high_coverage++; + } else if (item.interaction_rate > averageInteractionRate) { + // 低覆盖 + 高互动率 = 高互动 + item.quadrant = 'high_engagement'; + quadrantCounts.high_engagement++; + } else { + // 低覆盖 + 低互动率 = 低表现 + item.quadrant = 'low_performance'; + quadrantCounts.low_performance++; + } + }); + + // 构建返回结果 + const result: ContentPerformanceResponse = { + data: processedData, + metadata: { + total: processedData.length, + average_coverage: parseFloat(averageCoverage.toFixed(2)), + average_interaction_rate: parseFloat(averageInteractionRate.toFixed(4)), + quadrant_counts: quadrantCounts + } + }; + + const duration = Date.now() - startTime; + logger.info(`Content performance data fetched successfully in ${duration}ms`, { + itemCount: processedData.length, + averages: { coverage: averageCoverage, interactionRate: averageInteractionRate }, + quadrantCounts + }); + + return result; + } catch (error) { + const duration = Date.now() - startTime; + logger.error(`Error in getContentPerformance (${duration}ms)`, error); + + // 发生错误时返回模拟数据 + return this.generateMockContentPerformance(limit); + } + } + + /** + * 生成模拟内容表现分析数据 + * 当实际数据获取失败时使用 + */ + private generateMockContentPerformance(limit: number = 100): ContentPerformanceResponse { + const platforms = ['微博', '微信', '抖音', '小红书', '知乎']; + const contentTypes = ['post', 'video', 'article']; + const titles = [ + '为什么越来越多的年轻人选择使用我们的产品', + '产品新功能发布,颠覆你的使用体验', + '用户分享:我是如何通过这款产品提升效率的', + '揭秘:产品背后的设计理念', + '五分钟了解产品如何解决你的痛点问题', + '实用指南:产品高级功能详解', + '权威评测:产品vs竞品全方位对比', + '独家:产品未来发展方向预测', + '创始人专访:产品诞生的故事', + '用户案例:产品如何改变工作方式' + ]; + + const mockData: ContentPerformanceItem[] = []; + let totalCoverage = 0; + let totalInteractionRate = 0; + + const randomDate = (start: Date, end: Date) => { + return new Date(start.getTime() + Math.random() * (end.getTime() - start.getTime())); + }; + + const endDate = new Date(); + const startDate = new Date(); + startDate.setDate(startDate.getDate() - 90); + + for (let i = 0; i < limit; i++) { + // 生成随机数据 + const coverage = Math.floor(Math.random() * 50000) + 1000; + const likes = Math.floor(Math.random() * coverage * 0.2); + const comments = Math.floor(Math.random() * coverage * 0.05); + const shares = Math.floor(Math.random() * coverage * 0.03); + const interactionCount = likes + comments + shares; + const interactionRate = interactionCount / coverage; + + totalCoverage += coverage; + totalInteractionRate += interactionRate; + + mockData.push({ + content_id: `content_${i + 1}`, + title: titles[i % titles.length], + platform: platforms[Math.floor(Math.random() * platforms.length)], + content_type: contentTypes[Math.floor(Math.random() * contentTypes.length)], + influencer_name: `KOL ${i % 10 + 1}`, + publish_date: randomDate(startDate, endDate).toISOString(), + coverage, + interaction_rate: interactionRate, + interaction_count: interactionCount, + likes, + comments, + shares, + quadrant: 'low_performance' // 临时值,稍后更新 + }); + } + + // 计算平均值 + const averageCoverage = mockData.length > 0 ? totalCoverage / mockData.length : 0; + const averageInteractionRate = mockData.length > 0 ? totalInteractionRate / mockData.length : 0; + + // 根据平均值确定每个内容的四象限分类 + const quadrantCounts = { + high_value: 0, + high_coverage: 0, + high_engagement: 0, + low_performance: 0 + }; + + mockData.forEach(item => { + if (item.coverage > averageCoverage && item.interaction_rate > averageInteractionRate) { + // 高覆盖 + 高互动率 = 高价值 + item.quadrant = 'high_value'; + quadrantCounts.high_value++; + } else if (item.coverage > averageCoverage) { + // 高覆盖 + 低互动率 = 高覆盖 + item.quadrant = 'high_coverage'; + quadrantCounts.high_coverage++; + } else if (item.interaction_rate > averageInteractionRate) { + // 低覆盖 + 高互动率 = 高互动 + item.quadrant = 'high_engagement'; + quadrantCounts.high_engagement++; + } else { + // 低覆盖 + 低互动率 = 低表现 + item.quadrant = 'low_performance'; + quadrantCounts.low_performance++; + } + }); + + // 构建返回结果 + return { + data: mockData, + metadata: { + total: mockData.length, + average_coverage: parseFloat(averageCoverage.toFixed(2)), + average_interaction_rate: parseFloat(averageInteractionRate.toFixed(4)), + quadrant_counts: quadrantCounts + } + }; + } } // Export singleton instance diff --git a/backend/src/swagger/index.ts b/backend/src/swagger/index.ts index 14f3e51..0340571 100644 --- a/backend/src/swagger/index.ts +++ b/backend/src/swagger/index.ts @@ -3462,6 +3462,207 @@ export const openAPISpec = { } } }, + '/api/analytics/content-performance': { + get: { + summary: '获取内容表现分析数据', + description: '提供内容覆盖量、互动率、互动量等散点图数据,用于四象限分析', + tags: ['Analytics'], + parameters: [ + { + name: 'timeRange', + in: 'query', + description: '时间范围(天)', + schema: { + type: 'string', + enum: ['7', '30', '90'], + default: '30' + } + }, + { + name: 'projectId', + in: 'query', + description: '项目ID', + schema: { + type: 'string' + } + }, + { + name: 'platform', + in: 'query', + description: '平台', + schema: { + type: 'string' + } + }, + { + name: 'kolId', + in: 'query', + description: 'KOL ID', + schema: { + type: 'string' + } + }, + { + name: 'contentType', + in: 'query', + description: '内容类型', + schema: { + type: 'string', + enum: ['post', 'video', 'article', 'all'], + default: 'all' + } + }, + { + name: 'limit', + in: 'query', + description: '最大返回条数', + schema: { + type: 'integer', + default: 100 + } + } + ], + responses: { + '200': { + description: '内容表现分析数据', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + success: { + type: 'boolean' + }, + data: { + type: 'array', + items: { + type: 'object', + properties: { + content_id: { + type: 'string', + description: '内容ID' + }, + title: { + type: 'string', + description: '内容标题' + }, + platform: { + type: 'string', + description: '平台' + }, + content_type: { + type: 'string', + description: '内容类型' + }, + influencer_name: { + type: 'string', + description: 'KOL名称' + }, + publish_date: { + type: 'string', + description: '发布日期' + }, + coverage: { + type: 'integer', + description: '内容覆盖量(阅读量/浏览量)' + }, + interaction_rate: { + type: 'number', + format: 'float', + description: '互动率(互动总数/覆盖量)' + }, + interaction_count: { + type: 'integer', + description: '互动总量(点赞+评论+分享)' + }, + likes: { + type: 'integer', + description: '点赞数' + }, + comments: { + type: 'integer', + description: '评论数' + }, + shares: { + type: 'integer', + description: '分享数' + }, + quadrant: { + type: 'string', + enum: ['high_value', 'high_coverage', 'high_engagement', 'low_performance'], + description: '四象限分类' + } + } + } + }, + metadata: { + type: 'object', + properties: { + total: { + type: 'integer', + description: '内容总数' + }, + average_coverage: { + type: 'number', + format: 'float', + description: '平均覆盖量' + }, + average_interaction_rate: { + type: 'number', + format: 'float', + description: '平均互动率' + }, + quadrant_counts: { + type: 'object', + properties: { + high_value: { + type: 'integer', + description: '高价值内容数' + }, + high_coverage: { + type: 'integer', + description: '高覆盖内容数' + }, + high_engagement: { + type: 'integer', + description: '高互动内容数' + }, + low_performance: { + type: 'integer', + description: '低表现内容数' + } + } + } + } + } + } + } + } + } + }, + '400': { + description: '无效请求', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error' + } + } + } + }, + '500': { + description: '服务器错误', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error' + } + } + } + } + } + } + }, }, components: { schemas: {