From 23bcb4cb8b718466648566e160f2901e9d9ab5d3 Mon Sep 17 00:00:00 2001 From: William Tso Date: Thu, 13 Mar 2025 21:54:21 +0800 Subject: [PATCH] =?UTF-8?q?=E8=8E=B7=E5=8F=96=E6=A6=82=E8=A7=88=E5=8D=A1?= =?UTF-8?q?=E7=89=87=E6=95=B0=E6=8D=AE=20(/api/analytics/dashboard-cards)?= =?UTF-8?q?=20=E8=BF=94=E5=9B=9E=E5=8C=85=E5=90=AB=E7=95=99=E8=A8=80?= =?UTF-8?q?=E6=80=BB=E6=95=B0=E3=80=81=E5=B9=B3=E5=9D=87=E4=BA=92=E5=8A=A8?= =?UTF-8?q?=E7=8E=87=E5=92=8C=E6=83=85=E6=84=9F=E5=88=86=E6=9E=90=E4=B8=89?= =?UTF-8?q?=E4=B8=AA=E6=A0=B8=E5=BF=83=E6=8C=87=E6=A0=87=E7=9A=84=E5=8D=A1?= =?UTF-8?q?=E7=89=87=E6=95=B0=E6=8D=AE=E5=8F=8A=E7=8E=AF=E6=AF=94=E5=8F=98?= =?UTF-8?q?=E5=8C=96=20=E6=94=AF=E6=8C=81=E6=97=B6=E9=97=B4=E8=8C=83?= =?UTF-8?q?=E5=9B=B4=E5=92=8C=E9=A1=B9=E7=9B=AEID=E8=BF=87=E6=BB=A4=20?= =?UTF-8?q?=E8=8E=B7=E5=8F=96=E7=95=99=E8=A8=80=E8=B6=8B=E5=8A=BF=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=20(/api/analytics/comment-trend)=20=E8=BF=94=E5=9B=9E?= =?UTF-8?q?=E4=B8=80=E6=AE=B5=E6=97=B6=E9=97=B4=E5=86=85=E7=95=99=E8=A8=80?= =?UTF-8?q?=E6=95=B0=E9=87=8F=E7=9A=84=E5=8F=98=E5=8C=96=E8=B6=8B=E5=8A=BF?= =?UTF-8?q?=EF=BC=8C=E7=94=A8=E4=BA=8E=E7=BB=98=E5=88=B6=E6=9F=B1=E7=8A=B6?= =?UTF-8?q?=E5=9B=BE=20=E6=94=AF=E6=8C=81=E6=97=B6=E9=97=B4=E8=8C=83?= =?UTF-8?q?=E5=9B=B4=E3=80=81=E9=A1=B9=E7=9B=AEID=E5=92=8C=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E8=BF=87=E6=BB=A4=20=E8=8E=B7=E5=8F=96=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E5=88=86=E5=B8=83=E6=95=B0=E6=8D=AE=20(/api/analytics?= =?UTF-8?q?/platform-distribution)=20=E8=BF=94=E5=9B=9E=E4=B8=8D=E5=90=8C?= =?UTF-8?q?=E7=A4=BE=E4=BA=A4=E5=B9=B3=E5=8F=B0=E4=B8=8A=E7=9A=84=E8=AF=84?= =?UTF-8?q?=E8=AE=BA=E6=88=96=E4=BA=92=E5=8A=A8=E5=88=86=E5=B8=83=E6=83=85?= =?UTF-8?q?=E5=86=B5=20=E6=94=AF=E6=8C=81=E6=97=B6=E9=97=B4=E8=8C=83?= =?UTF-8?q?=E5=9B=B4=E3=80=81=E9=A1=B9=E7=9B=AEID=E5=92=8C=E4=BA=8B?= =?UTF-8?q?=E4=BB=B6=E7=B1=BB=E5=9E=8B=E8=BF=87=E6=BB=A4=20=E8=8E=B7?= =?UTF-8?q?=E5=8F=96=E6=83=85=E6=84=9F=E5=88=86=E6=9E=90=E8=AF=A6=E6=83=85?= =?UTF-8?q?=20(/api/analytics/sentiment-analysis)=20=E8=BF=94=E5=9B=9E?= =?UTF-8?q?=E6=AD=A3=E9=9D=A2=E3=80=81=E4=B8=AD=E6=80=A7=E3=80=81=E8=B4=9F?= =?UTF-8?q?=E9=9D=A2=E8=AF=84=E8=AE=BA=E7=9A=84=E6=AF=94=E4=BE=8B=E5=92=8C?= =?UTF-8?q?=E5=B9=B3=E5=9D=87=E6=83=85=E6=84=9F=E5=BE=97=E5=88=86=20?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E6=97=B6=E9=97=B4=E8=8C=83=E5=9B=B4=E3=80=81?= =?UTF-8?q?=E9=A1=B9=E7=9B=AEID=E5=92=8C=E5=B9=B3=E5=8F=B0=E8=BF=87?= =?UTF-8?q?=E6=BB=A4=20=E8=8E=B7=E5=8F=96=E7=83=AD=E9=97=A8=E6=96=87?= =?UTF-8?q?=E7=AB=A0=E6=95=B0=E6=8D=AE=20(/api/analytics/popular-posts)=20?= =?UTF-8?q?=E8=BF=94=E5=9B=9E=E6=8C=89=E4=BA=92=E5=8A=A8=E6=95=B0=E9=87=8F?= =?UTF-8?q?=E6=88=96=E4=BA=92=E5=8A=A8=E7=8E=87=E6=8E=92=E5=BA=8F=E7=9A=84?= =?UTF-8?q?=E7=83=AD=E9=97=A8=E5=B8=96=E6=96=87=E5=88=97=E8=A1=A8=20?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E6=97=B6=E9=97=B4=E8=8C=83=E5=9B=B4=E3=80=81?= =?UTF-8?q?=E9=A1=B9=E7=9B=AEID=E3=80=81=E5=B9=B3=E5=8F=B0=E8=BF=87?= =?UTF-8?q?=E6=BB=A4=EF=BC=8C=E4=BB=A5=E5=8F=8A=E6=8E=92=E5=BA=8F=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=E5=92=8C=E9=99=90=E5=88=B6=E8=BF=94=E5=9B=9E=E6=95=B0?= =?UTF-8?q?=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/analyticsController.ts | 366 ++++++++ backend/src/routes/analytics.ts | 15 + backend/src/services/analyticsService.ts | 839 ++++++++++++++++++ backend/src/swagger/index.ts | 526 +++++++++++ 4 files changed, 1746 insertions(+) diff --git a/backend/src/controllers/analyticsController.ts b/backend/src/controllers/analyticsController.ts index 3eeaafe..ad002ea 100644 --- a/backend/src/controllers/analyticsController.ts +++ b/backend/src/controllers/analyticsController.ts @@ -401,6 +401,372 @@ export class AnalyticsController { }; }); } + + /** + * 获取概览卡片数据 + * 返回包含留言总数、平均互动率和情感分析三个卡片数据 + * + * @param c Hono Context + * @returns Response with dashboard card data + */ + async getDashboardCards(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'); // 可选项目过滤 + + logger.info(`[${requestId}] Dashboard cards request received`, { + timeRange, + projectId, + 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 data = await analyticsService.getDashboardCardData( + parseInt(timeRange, 10), + projectId + ); + + // 返回成功响应 + const duration = Date.now() - startTime; + logger.info(`[${requestId}] Dashboard cards response sent successfully`, { + duration + }); + + return c.json({ + success: true, + data + }); + } catch (error) { + // 记录错误 + const duration = Date.now() - startTime; + logger.error(`[${requestId}] Error fetching dashboard cards (${duration}ms)`, error); + + // 返回错误响应 + return c.json({ + success: false, + error: 'Failed to fetch dashboard card data', + message: error instanceof Error ? error.message : 'Unknown error' + }, 500); + } + } + + /** + * 获取留言趋势数据 + * 返回一段时间内留言数量的变化趋势 + * + * @param c Hono Context + * @returns Response with comment trend data + */ + async getCommentTrend(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'); // 可选平台过滤 + + logger.info(`[${requestId}] Comment trend request received`, { + timeRange, + projectId, + platform, + 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 data = await analyticsService.getCommentTrend( + parseInt(timeRange, 10), + projectId, + platform + ); + + // 返回成功响应 + const duration = Date.now() - startTime; + logger.info(`[${requestId}] Comment trend response sent successfully`, { + duration, + dataPoints: data.data.length, + totalComments: data.total_count + }); + + return c.json({ + success: true, + data: data.data, + metadata: { + max_count: data.max_count, + total_count: data.total_count + } + }); + } catch (error) { + // 记录错误 + const duration = Date.now() - startTime; + logger.error(`[${requestId}] Error fetching comment trend (${duration}ms)`, error); + + // 返回错误响应 + return c.json({ + success: false, + error: 'Failed to fetch comment trend data', + message: error instanceof Error ? error.message : 'Unknown error' + }, 500); + } + } + + /** + * 获取平台分布数据 + * 返回不同社交平台上的事件分布情况 + * + * @param c Hono Context + * @returns Response with platform distribution data + */ + async getPlatformDistribution(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 eventType = c.req.query('eventType') || 'comment'; // 默认分析评论事件 + + logger.info(`[${requestId}] Platform distribution request received`, { + timeRange, + projectId, + eventType, + 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 validEventTypes = ['comment', 'like', 'view', 'share']; + if (!validEventTypes.includes(eventType)) { + logger.warn(`[${requestId}] Invalid eventType: ${eventType}`); + return c.json({ + success: false, + error: `Invalid eventType. Must be one of: ${validEventTypes.join(', ')}` + }, 400); + } + + // 获取平台分布数据 + const data = await analyticsService.getPlatformDistribution( + parseInt(timeRange, 10), + projectId, + eventType + ); + + // 返回成功响应 + const duration = Date.now() - startTime; + logger.info(`[${requestId}] Platform distribution response sent successfully`, { + duration, + platformCount: data.data.length, + total: data.total + }); + + return c.json({ + success: true, + data: data.data, + metadata: { + total: data.total, + event_type: eventType + } + }); + } catch (error) { + // 记录错误 + const duration = Date.now() - startTime; + logger.error(`[${requestId}] Error fetching platform distribution (${duration}ms)`, error); + + // 返回错误响应 + return c.json({ + success: false, + error: 'Failed to fetch platform distribution data', + message: error instanceof Error ? error.message : 'Unknown error' + }, 500); + } + } + + /** + * 获取情感分析详情数据 + * 返回正面、中性、负面评论的比例和分析 + * + * @param c Hono Context + * @returns Response with sentiment analysis data + */ + async getSentimentAnalysis(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'); // 可选平台过滤 + + logger.info(`[${requestId}] Sentiment analysis request received`, { + timeRange, + projectId, + platform, + 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 data = await analyticsService.getSentimentAnalysis( + parseInt(timeRange, 10), + projectId, + platform + ); + + // 返回成功响应 + const duration = Date.now() - startTime; + logger.info(`[${requestId}] Sentiment analysis response sent successfully`, { + duration, + total: data.total + }); + + return c.json({ + success: true, + data + }); + } catch (error) { + // 记录错误 + const duration = Date.now() - startTime; + logger.error(`[${requestId}] Error fetching sentiment analysis (${duration}ms)`, error); + + // 返回错误响应 + return c.json({ + success: false, + error: 'Failed to fetch sentiment analysis data', + message: error instanceof Error ? error.message : 'Unknown error' + }, 500); + } + } + + /** + * 获取热门文章数据 + * 返回按互动数量或互动率排序的热门帖文 + * + * @param c Hono Context + * @returns Response with popular posts data + */ + async getPopularPosts(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 sortBy = c.req.query('sortBy') || 'engagement_count'; // 默认按互动数量排序 + const limit = parseInt(c.req.query('limit') || '10', 10); // 默认返回10个 + + logger.info(`[${requestId}] Popular posts request received`, { + timeRange, + projectId, + platform, + sortBy, + 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); + } + + // 验证排序字段 + if (!['engagement_count', 'engagement_rate'].includes(sortBy)) { + logger.warn(`[${requestId}] Invalid sortBy: ${sortBy}`); + return c.json({ + success: false, + error: 'Invalid sortBy. Must be engagement_count or engagement_rate.' + }, 400); + } + + // 获取热门文章数据 + const data = await analyticsService.getPopularPosts( + parseInt(timeRange, 10), + projectId, + platform, + sortBy, + Math.min(limit, 50) // 最多返回50条 + ); + + // 返回成功响应 + const duration = Date.now() - startTime; + logger.info(`[${requestId}] Popular posts response sent successfully`, { + duration, + resultCount: data.posts.length, + total: data.total + }); + + return c.json({ + success: true, + data: data.posts, + metadata: { + total: data.total, + high_engagement_count: data.posts.filter(post => post.is_high_engagement).length + } + }); + } catch (error) { + // 记录错误 + const duration = Date.now() - startTime; + logger.error(`[${requestId}] Error fetching popular posts (${duration}ms)`, error); + + // 返回错误响应 + return c.json({ + success: false, + error: 'Failed to fetch popular posts 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 c968b0d..261d0e9 100644 --- a/backend/src/routes/analytics.ts +++ b/backend/src/routes/analytics.ts @@ -32,4 +32,19 @@ analyticsRouter.get('/kol-funnel', (c) => analyticsController.getKolFunnel(c)); // Add new post performance route analyticsRouter.get('/post-performance', (c) => analyticsController.getPostPerformance(c)); +// Add dashboard cards route +analyticsRouter.get('/dashboard-cards', (c) => analyticsController.getDashboardCards(c)); + +// Add comment trend route +analyticsRouter.get('/comment-trend', (c) => analyticsController.getCommentTrend(c)); + +// Add platform distribution route +analyticsRouter.get('/platform-distribution', (c) => analyticsController.getPlatformDistribution(c)); + +// Add sentiment analysis route +analyticsRouter.get('/sentiment-analysis', (c) => analyticsController.getSentimentAnalysis(c)); + +// Add popular posts route +analyticsRouter.get('/popular-posts', (c) => analyticsController.getPopularPosts(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 617f04a..93fa374 100644 --- a/backend/src/services/analyticsService.ts +++ b/backend/src/services/analyticsService.ts @@ -83,6 +83,102 @@ export interface PostPerformanceResponse { total: number; // 总数 } +/** + * 概览卡片数据 + */ +export interface DashboardCardData { + comments_count: { + current: number; + change_percentage: number; + }; + engagement_rate: { + current: number; + change_percentage: number; + }; + sentiment_score: { + current: number; + change_percentage: number; + }; +} + +/** + * 留言趋势数据点 + */ +export interface CommentTrendPoint { + date: string; + count: number; +} + +/** + * 留言趋势响应 + */ +export interface CommentTrendResponse { + data: CommentTrendPoint[]; + max_count: number; + total_count: number; +} + +/** + * 平台分布数据项 + */ +export interface PlatformDistributionItem { + platform: string; + count: number; + percentage: number; +} + +/** + * 平台分布响应 + */ +export interface PlatformDistributionResponse { + data: PlatformDistributionItem[]; + total: number; +} + +/** + * 情感分析详情数据 + */ +export interface SentimentAnalysisData { + positive: { + count: number; + percentage: number; + }; + neutral: { + count: number; + percentage: number; + }; + negative: { + count: number; + percentage: number; + }; + total: number; + average_score: number; +} + +/** + * 热门文章数据项 + */ +export interface PopularPostItem { + post_id: string; + title: string; + platform: string; + influencer_id: string; + influencer_name: string; + publish_date: string; + engagement_count: number; + views_count: number; + engagement_rate: number; + is_high_engagement: boolean; +} + +/** + * 热门文章响应 + */ +export interface PopularPostsResponse { + posts: PopularPostItem[]; + total: number; +} + /** * Analytics service for KOL performance data */ @@ -887,6 +983,749 @@ export class AnalyticsService { return mockPosts; } + + /** + * 获取概览卡片数据 + * @param timeRange 时间范围(天数) + * @param projectId 可选项目ID + * @returns 概览卡片数据 + */ + async getDashboardCardData( + timeRange: number, + projectId?: string + ): Promise { + const startTime = Date.now(); + logger.info('Fetching dashboard card data', { timeRange, projectId }); + + try { + // 计算当前时间范围和比较时间范围 + const currentDate = new Date(); + const pastDate = new Date(); + pastDate.setDate(currentDate.getDate() - timeRange); + + const olderPastDate = new Date(pastDate); + olderPastDate.setDate(olderPastDate.getDate() - timeRange); + + // 格式化日期 + const currentDateStr = this.formatDateForClickhouse(currentDate); + const pastDateStr = this.formatDateForClickhouse(pastDate); + const olderPastDateStr = this.formatDateForClickhouse(olderPastDate); + + // 构建项目过滤条件 + const projectFilter = projectId ? `AND project_id = '${projectId}'` : ''; + + // 查询当前时间段的数据 + const currentPeriodQuery = ` + SELECT + countIf(event_type = 'comment') AS comments_count, + countIf(event_type = 'view') AS views_count, + countIf(event_type = 'like' OR event_type = 'comment' OR event_type = 'share') AS interactions_count, + avg(CASE + WHEN sentiment = 'positive' THEN 1 + WHEN sentiment = 'neutral' THEN 0 + WHEN sentiment = 'negative' THEN -1 + ELSE NULL + END) AS avg_sentiment + FROM + events + WHERE + date BETWEEN '${pastDateStr}' AND '${currentDateStr}' + ${projectFilter} + `; + + // 查询上一时间段的数据(用于计算环比) + const previousPeriodQuery = ` + SELECT + countIf(event_type = 'comment') AS comments_count, + countIf(event_type = 'view') AS views_count, + countIf(event_type = 'like' OR event_type = 'comment' OR event_type = 'share') AS interactions_count, + avg(CASE + WHEN sentiment = 'positive' THEN 1 + WHEN sentiment = 'neutral' THEN 0 + WHEN sentiment = 'negative' THEN -1 + ELSE NULL + END) AS avg_sentiment + FROM + events + WHERE + date BETWEEN '${olderPastDateStr}' AND '${pastDateStr}' + ${projectFilter} + `; + + logger.debug('Executing ClickHouse queries for dashboard cards', { + currentPeriodQuery: currentPeriodQuery.replace(/\n\s+/g, ' ').trim(), + previousPeriodQuery: previousPeriodQuery.replace(/\n\s+/g, ' ').trim() + }); + + // 执行查询 + const [currentData, previousData] = await Promise.all([ + this.executeClickhouseQuery(currentPeriodQuery), + this.executeClickhouseQuery(previousPeriodQuery) + ]); + + // 解析结果 + const current = currentData[0] || {}; + const previous = previousData[0] || {}; + + // 计算当前值 + const commentsCount = Number(current.comments_count || 0); + const viewsCount = Number(current.views_count || 0); + const interactionsCount = Number(current.interactions_count || 0); + const avgSentiment = Number(current.avg_sentiment || 0); + + // 计算环比变化 + const prevCommentsCount = Number(previous.comments_count || 0); + const prevViewsCount = Number(previous.views_count || 0); + const prevInteractionsCount = Number(previous.interactions_count || 0); + const prevAvgSentiment = Number(previous.avg_sentiment || 0); + + // 计算环比百分比变化 + const calculatePercentageChange = (current: number, previous: number): number => { + if (previous === 0) return current > 0 ? 100 : 0; + return ((current - previous) / previous) * 100; + }; + + const commentsChange = calculatePercentageChange(commentsCount, prevCommentsCount); + + // 计算互动率 (interactions / views) + const currentEngagementRate = viewsCount > 0 ? (interactionsCount / viewsCount) * 100 : 0; + const prevEngagementRate = prevViewsCount > 0 ? (prevInteractionsCount / prevViewsCount) * 100 : 0; + const engagementRateChange = calculatePercentageChange(currentEngagementRate, prevEngagementRate); + + // 计算情感得分变化 + const sentimentChange = calculatePercentageChange(avgSentiment, prevAvgSentiment); + + const result: DashboardCardData = { + comments_count: { + current: commentsCount, + change_percentage: parseFloat(commentsChange.toFixed(2)) + }, + engagement_rate: { + current: parseFloat(currentEngagementRate.toFixed(2)), + change_percentage: parseFloat(engagementRateChange.toFixed(2)) + }, + sentiment_score: { + current: parseFloat(avgSentiment.toFixed(2)), + change_percentage: parseFloat(sentimentChange.toFixed(2)) + } + }; + + const duration = Date.now() - startTime; + logger.info('Dashboard card data fetched successfully', { + duration, + result + }); + + return result; + } catch (error) { + const duration = Date.now() - startTime; + logger.error(`Error in getDashboardCardData (${duration}ms)`, error); + + // 发生错误时返回模拟数据 + return { + comments_count: { current: 0, change_percentage: 0 }, + engagement_rate: { current: 0, change_percentage: 0 }, + sentiment_score: { current: 0, change_percentage: 0 } + }; + } + } + + /** + * 获取留言趋势数据 + * @param timeRange 时间范围(天数) + * @param projectId 可选项目ID + * @param platform 可选平台 + * @returns 留言趋势数据 + */ + async getCommentTrend( + timeRange: number, + projectId?: string, + platform?: string + ): Promise { + const startTime = Date.now(); + logger.info('Fetching comment trend data', { timeRange, projectId, platform }); + + 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 filters: string[] = []; + filters.push(`date BETWEEN '${pastDateStr}' AND '${currentDateStr}'`); + filters.push(`event_type = 'comment'`); + + if (projectId) { + filters.push(`project_id = '${projectId}'`); + } + + if (platform) { + filters.push(`platform = '${platform}'`); + } + + const filterCondition = filters.join(' AND '); + + // 按日期分组查询留言数量 + const trendQuery = ` + SELECT + toDate(timestamp) AS date, + count() AS count + FROM + events + WHERE + ${filterCondition} + GROUP BY + date + ORDER BY + date ASC + `; + + // 查询总留言数 + const totalQuery = ` + SELECT + count() AS total + FROM + events + WHERE + ${filterCondition} + `; + + logger.debug('Executing ClickHouse queries for comment trend', { + trendQuery: trendQuery.replace(/\n\s+/g, ' ').trim(), + totalQuery: totalQuery.replace(/\n\s+/g, ' ').trim() + }); + + // 执行查询 + const [trendData, totalData] = await Promise.all([ + this.executeClickhouseQuery(trendQuery), + this.executeClickhouseQuery(totalQuery) + ]); + + // 解析结果 + const total = totalData[0]?.total || 0; + + // 确保每天都有数据点 + const trendPoints: CommentTrendPoint[] = []; + let maxCount = 0; + + // 创建日期范围内的所有日期 + const dateMap: Record = {}; + const currentDateObj = new Date(pastDate); + + while (currentDateObj <= currentDate) { + const dateStr = this.formatDateForClickhouse(currentDateObj); + dateMap[dateStr] = 0; + currentDateObj.setDate(currentDateObj.getDate() + 1); + } + + // 填充实际数据 + if (Array.isArray(trendData)) { + trendData.forEach(point => { + if (point.date && point.count !== undefined) { + const dateStr = point.date.split('T')[0]; // 确保日期格式一致 + dateMap[dateStr] = Number(point.count); + maxCount = Math.max(maxCount, Number(point.count)); + } + }); + } + + // 转换为数组 + Object.entries(dateMap).forEach(([date, count]) => { + trendPoints.push({ + date, + count: Number(count) + }); + }); + + // 按日期排序 + trendPoints.sort((a, b) => a.date.localeCompare(b.date)); + + const result: CommentTrendResponse = { + data: trendPoints, + max_count: maxCount, + total_count: Number(total) + }; + + const duration = Date.now() - startTime; + logger.info('Comment trend data fetched successfully', { + duration, + dataPoints: trendPoints.length, + total + }); + + return result; + } catch (error) { + const duration = Date.now() - startTime; + logger.error(`Error in getCommentTrend (${duration}ms)`, error); + + // 发生错误时返回空数据 + return { + data: [], + max_count: 0, + total_count: 0 + }; + } + } + + /** + * 获取平台分布数据 + * @param timeRange 时间范围(天数) + * @param projectId 可选项目ID + * @param eventType 可选事件类型 + * @returns 平台分布数据 + */ + async getPlatformDistribution( + timeRange: number, + projectId?: string, + eventType: string = 'comment' + ): Promise { + const startTime = Date.now(); + logger.info('Fetching platform distribution data', { timeRange, projectId, eventType }); + + 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 filters: string[] = []; + filters.push(`date BETWEEN '${pastDateStr}' AND '${currentDateStr}'`); + filters.push(`event_type = '${eventType}'`); + + if (projectId) { + filters.push(`project_id = '${projectId}'`); + } + + const filterCondition = filters.join(' AND '); + + // 按平台分组查询事件数量 + const query = ` + SELECT + platform, + count() AS count + FROM + events + WHERE + ${filterCondition} + GROUP BY + platform + ORDER BY + count DESC + `; + + logger.debug('Executing ClickHouse query for platform distribution', { + query: query.replace(/\n\s+/g, ' ').trim() + }); + + // 执行查询 + const result = await this.executeClickhouseQuery(query); + + // 处理结果 + const platforms: PlatformDistributionItem[] = []; + let total = 0; + + if (Array.isArray(result)) { + result.forEach(item => { + if (item.platform && item.count !== undefined) { + const count = Number(item.count); + total += count; + platforms.push({ + platform: item.platform, + count, + percentage: 0 // 先初始化为0,稍后计算 + }); + } + }); + } + + // 计算百分比 + if (total > 0) { + platforms.forEach(item => { + item.percentage = parseFloat(((item.count / total) * 100).toFixed(2)); + }); + } + + // 确保结果包含主要平台,即使没有数据 + const mainPlatforms = ['Instagram', 'TikTok', 'Twitter', 'Facebook', 'YouTube']; + mainPlatforms.forEach(platform => { + if (!platforms.some(item => item.platform.toLowerCase() === platform.toLowerCase())) { + platforms.push({ + platform, + count: 0, + percentage: 0 + }); + } + }); + + // 按数量降序排序 + platforms.sort((a, b) => b.count - a.count); + + const result_data: PlatformDistributionResponse = { + data: platforms, + total + }; + + const duration = Date.now() - startTime; + logger.info('Platform distribution data fetched successfully', { + duration, + platformCount: platforms.length, + total + }); + + return result_data; + } catch (error) { + const duration = Date.now() - startTime; + logger.error(`Error in getPlatformDistribution (${duration}ms)`, error); + + // 发生错误时返回空数据 + return { + data: [], + total: 0 + }; + } + } + + /** + * 获取情感分析详情数据 + * @param timeRange 时间范围(天数) + * @param projectId 可选项目ID + * @param platform 可选平台 + * @returns 情感分析详情数据 + */ + async getSentimentAnalysis( + timeRange: number, + projectId?: string, + platform?: string + ): Promise { + const startTime = Date.now(); + logger.info('Fetching sentiment analysis data', { timeRange, projectId, platform }); + + 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 filters: string[] = []; + filters.push(`date BETWEEN '${pastDateStr}' AND '${currentDateStr}'`); + filters.push(`sentiment IN ('positive', 'neutral', 'negative')`); + + if (projectId) { + filters.push(`project_id = '${projectId}'`); + } + + if (platform) { + filters.push(`platform = '${platform}'`); + } + + const filterCondition = filters.join(' AND '); + + // 查询情感分布 + const query = ` + SELECT + sentiment, + count() AS count + FROM + events + WHERE + ${filterCondition} + GROUP BY + sentiment + `; + + // 查询情感得分平均值 + const averageQuery = ` + SELECT + avg(CASE + WHEN sentiment = 'positive' THEN 1 + WHEN sentiment = 'neutral' THEN 0 + WHEN sentiment = 'negative' THEN -1 + ELSE NULL + END) AS avg_score + FROM + events + WHERE + ${filterCondition} + `; + + logger.debug('Executing ClickHouse queries for sentiment analysis', { + query: query.replace(/\n\s+/g, ' ').trim(), + averageQuery: averageQuery.replace(/\n\s+/g, ' ').trim() + }); + + // 执行查询 + const [sentimentData, averageData] = await Promise.all([ + this.executeClickhouseQuery(query), + this.executeClickhouseQuery(averageQuery) + ]); + + // 初始化结果 + let positiveCount = 0; + let neutralCount = 0; + let negativeCount = 0; + + // 处理情感分布数据 + if (Array.isArray(sentimentData)) { + sentimentData.forEach(item => { + if (item.sentiment && item.count !== undefined) { + const count = Number(item.count); + if (item.sentiment === 'positive') { + positiveCount = count; + } else if (item.sentiment === 'neutral') { + neutralCount = count; + } else if (item.sentiment === 'negative') { + negativeCount = count; + } + } + }); + } + + // 计算总数和百分比 + const total = positiveCount + neutralCount + negativeCount; + const positivePercentage = total > 0 ? (positiveCount / total) * 100 : 0; + const neutralPercentage = total > 0 ? (neutralCount / total) * 100 : 0; + const negativePercentage = total > 0 ? (negativeCount / total) * 100 : 0; + + // 获取平均得分 + const avgScore = Array.isArray(averageData) && averageData.length > 0 + ? Number(averageData[0].avg_score || 0) + : 0; + + const result: SentimentAnalysisData = { + positive: { + count: positiveCount, + percentage: parseFloat(positivePercentage.toFixed(2)) + }, + neutral: { + count: neutralCount, + percentage: parseFloat(neutralPercentage.toFixed(2)) + }, + negative: { + count: negativeCount, + percentage: parseFloat(negativePercentage.toFixed(2)) + }, + total, + average_score: parseFloat(avgScore.toFixed(2)) + }; + + const duration = Date.now() - startTime; + logger.info('Sentiment analysis data fetched successfully', { + duration, + total, + avgScore + }); + + return result; + } catch (error) { + const duration = Date.now() - startTime; + logger.error(`Error in getSentimentAnalysis (${duration}ms)`, error); + + // 发生错误时返回空数据 + return { + positive: { count: 0, percentage: 0 }, + neutral: { count: 0, percentage: 0 }, + negative: { count: 0, percentage: 0 }, + total: 0, + average_score: 0 + }; + } + } + + /** + * 获取热门文章数据 + * @param timeRange 时间范围(天数) + * @param projectId 可选项目ID + * @param platform 可选平台 + * @param sortBy 排序字段 + * @param limit 返回数量 + * @returns 热门文章数据 + */ + async getPopularPosts( + timeRange: number, + projectId?: string, + platform?: string, + sortBy: string = 'engagement_count', + limit: number = 10 + ): Promise { + const startTime = Date.now(); + logger.info('Fetching popular posts data', { + timeRange, + projectId, + platform, + sortBy, + limit + }); + + 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 postFilters: string[] = []; + + if (platform) { + postFilters.push(`p.platform = '${platform}'`); + } + + if (projectId) { + postFilters.push(`p.project_id = '${projectId}'`); + } + + postFilters.push(`p.date BETWEEN '${pastDateStr}' AND '${currentDateStr}'`); + + const postFilterCondition = postFilters.length > 0 + ? 'WHERE ' + postFilters.join(' AND ') + : ''; + + // 获取帖文和互动数据 + const query = ` + WITH post_metrics AS ( + SELECT + content_id AS post_id, + countIf(event_type = 'view') AS views_count, + countIf(event_type = 'like' OR event_type = 'comment' OR event_type = 'share') AS engagement_count + FROM + events + WHERE + date BETWEEN '${pastDateStr}' AND '${currentDateStr}' + AND content_id IS NOT NULL + ${projectId ? `AND project_id = '${projectId}'` : ''} + ${platform ? `AND platform = '${platform}'` : ''} + GROUP BY + post_id + ) + SELECT + p.post_id, + p.title, + p.platform, + p.influencer_id, + i.name AS influencer_name, + toString(p.created_at) AS publish_date, + pm.engagement_count, + pm.views_count, + multiIf(pm.views_count > 0, pm.engagement_count / pm.views_count, 0) AS engagement_rate + FROM + posts p + LEFT JOIN + influencers i ON p.influencer_id = i.influencer_id + LEFT JOIN + post_metrics pm ON p.post_id = pm.post_id + ${postFilterCondition} + ORDER BY + ${sortBy === 'engagement_rate' ? 'engagement_rate' : 'engagement_count'} DESC + LIMIT ${limit} + `; + + // 查询符合条件的帖文总数 + const countQuery = ` + SELECT + count() AS total + FROM + posts p + ${postFilterCondition} + `; + + logger.debug('Executing ClickHouse queries for popular posts', { + query: query.replace(/\n\s+/g, ' ').trim(), + countQuery: countQuery.replace(/\n\s+/g, ' ').trim() + }); + + // 执行查询 + const [postsData, countData] = await Promise.all([ + this.executeClickhouseQuery(query), + this.executeClickhouseQuery(countQuery) + ]); + + // 计算总数 + const total = Array.isArray(countData) && countData.length > 0 + ? Number(countData[0].total || 0) + : 0; + + // 计算高互动率阈值(互动率大于帖文平均互动率的150%) + let averageEngagementRate = 0; + let engagementRates: number[] = []; + + if (Array.isArray(postsData)) { + engagementRates = postsData + .map(post => Number(post.engagement_rate || 0)) + .filter(rate => rate > 0); + + if (engagementRates.length > 0) { + averageEngagementRate = engagementRates.reduce((a, b) => a + b, 0) / engagementRates.length; + } + } + + const highEngagementThreshold = averageEngagementRate * 1.5; + + // 转换结果 + const posts: PopularPostItem[] = []; + + if (Array.isArray(postsData)) { + postsData.forEach(post => { + const engagementCount = Number(post.engagement_count || 0); + const viewsCount = Number(post.views_count || 0); + const engagementRate = viewsCount > 0 ? engagementCount / viewsCount : 0; + + posts.push({ + post_id: post.post_id, + title: post.title || '无标题', + platform: post.platform || 'unknown', + influencer_id: post.influencer_id, + influencer_name: post.influencer_name || '未知KOL', + publish_date: post.publish_date || '', + engagement_count: engagementCount, + views_count: viewsCount, + engagement_rate: parseFloat(engagementRate.toFixed(4)), + is_high_engagement: engagementRate > highEngagementThreshold + }); + }); + } + + const result: PopularPostsResponse = { + posts, + total + }; + + const duration = Date.now() - startTime; + logger.info('Popular posts data fetched successfully', { + duration, + resultCount: posts.length, + total + }); + + return result; + } catch (error) { + const duration = Date.now() - startTime; + logger.error(`Error in getPopularPosts (${duration}ms)`, error); + + // 发生错误时返回空数据 + return { + posts: [], + total: 0 + }; + } + } } // Export singleton instance diff --git a/backend/src/swagger/index.ts b/backend/src/swagger/index.ts index c64ec79..aef43cb 100644 --- a/backend/src/swagger/index.ts +++ b/backend/src/swagger/index.ts @@ -2529,6 +2529,532 @@ export const openAPISpec = { } } }, + '/api/analytics/dashboard-cards': { + 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' + } + } + ], + responses: { + '200': { + description: '成功响应', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + success: { type: 'boolean', example: true }, + data: { + type: 'object', + properties: { + comments_count: { + type: 'object', + properties: { + current: { type: 'number', example: 256 }, + change_percentage: { type: 'number', example: 12.5 } + } + }, + engagement_rate: { + type: 'object', + properties: { + current: { type: 'number', example: 5.8 }, + change_percentage: { type: 'number', example: -2.3 } + } + }, + sentiment_score: { + type: 'object', + properties: { + current: { type: 'number', example: 0.75 }, + change_percentage: { type: 'number', example: 8.7 } + } + } + } + } + } + } + } + } + }, + '400': { + description: '参数错误', + 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: '服务器错误', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + success: { type: 'boolean', example: false }, + error: { type: 'string', example: 'Failed to fetch dashboard card data' }, + message: { type: 'string', example: 'Internal server error' } + } + } + } + } + } + } + } + }, + '/api/analytics/comment-trend': { + 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', + enum: ['Twitter', 'Instagram', 'TikTok', 'Facebook', 'YouTube'] + } + } + ], + responses: { + '200': { + description: '成功响应', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + success: { type: 'boolean', example: true }, + data: { + type: 'array', + items: { + type: 'object', + properties: { + date: { type: 'string', format: 'date', example: '2025-03-01' }, + count: { type: 'number', example: 32 } + } + } + }, + metadata: { + type: 'object', + properties: { + max_count: { type: 'number', example: 58 }, + total_count: { type: 'number', example: 256 } + } + } + } + } + } + } + }, + '400': { + description: '参数错误', + 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: '服务器错误', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + success: { type: 'boolean', example: false }, + error: { type: 'string', example: 'Failed to fetch comment trend data' }, + message: { type: 'string', example: 'Internal server error' } + } + } + } + } + } + } + } + }, + '/api/analytics/platform-distribution': { + 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: 'eventType', + in: 'query', + description: '事件类型', + schema: { + type: 'string', + enum: ['comment', 'like', 'view', 'share'], + default: 'comment' + } + } + ], + responses: { + '200': { + description: '成功响应', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + success: { type: 'boolean', example: true }, + data: { + type: 'array', + items: { + type: 'object', + properties: { + platform: { type: 'string', example: 'Instagram' }, + count: { type: 'number', example: 128 }, + percentage: { type: 'number', example: 42.5 } + } + } + }, + metadata: { + type: 'object', + properties: { + total: { type: 'number', example: 312 }, + event_type: { type: 'string', example: 'comment' } + } + } + } + } + } + } + }, + '400': { + description: '参数错误', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + success: { type: 'boolean', example: false }, + error: { type: 'string', example: 'Invalid eventType. Must be one of: comment, like, view, share' } + } + } + } + } + }, + '500': { + description: '服务器错误', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + success: { type: 'boolean', example: false }, + error: { type: 'string', example: 'Failed to fetch platform distribution data' }, + message: { type: 'string', example: 'Internal server error' } + } + } + } + } + } + } + } + }, + '/api/analytics/sentiment-analysis': { + 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', + enum: ['Twitter', 'Instagram', 'TikTok', 'Facebook', 'YouTube'] + } + } + ], + responses: { + '200': { + description: '成功响应', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + success: { type: 'boolean', example: true }, + data: { + type: 'object', + properties: { + positive: { + type: 'object', + properties: { + count: { type: 'number', example: 156 }, + percentage: { type: 'number', example: 65.2 } + } + }, + neutral: { + type: 'object', + properties: { + count: { type: 'number', example: 45 }, + percentage: { type: 'number', example: 20.8 } + } + }, + negative: { + type: 'object', + properties: { + count: { type: 'number', example: 28 }, + percentage: { type: 'number', example: 14.0 } + } + }, + total: { type: 'number', example: 229 }, + average_score: { type: 'number', example: 0.68 } + } + } + } + } + } + } + }, + '400': { + description: '参数错误', + 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: '服务器错误', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + success: { type: 'boolean', example: false }, + error: { type: 'string', example: 'Failed to fetch sentiment analysis data' }, + message: { type: 'string', example: 'Internal server error' } + } + } + } + } + } + } + } + }, + '/api/analytics/popular-posts': { + 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', + enum: ['Twitter', 'Instagram', 'TikTok', 'Facebook', 'YouTube'] + } + }, + { + name: 'sortBy', + in: 'query', + description: '排序字段', + schema: { + type: 'string', + enum: ['engagement_count', 'engagement_rate'], + default: 'engagement_count' + } + }, + { + name: 'limit', + in: 'query', + description: '返回数量', + schema: { + type: 'integer', + default: 10, + maximum: 50 + } + } + ], + responses: { + '200': { + description: '成功响应', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + success: { type: 'boolean', example: true }, + data: { + type: 'array', + items: { + type: 'object', + properties: { + post_id: { type: 'string', example: 'post_123' }, + title: { type: 'string', example: '新产品发布' }, + platform: { type: 'string', example: 'Instagram' }, + influencer_id: { type: 'string', example: 'inf_456' }, + influencer_name: { type: 'string', example: '时尚KOL' }, + publish_date: { type: 'string', example: '2025-03-10 10:30:00' }, + engagement_count: { type: 'number', example: 2350 }, + views_count: { type: 'number', example: 15600 }, + engagement_rate: { type: 'number', example: 0.1506 }, + is_high_engagement: { type: 'boolean', example: true } + } + } + }, + metadata: { + type: 'object', + properties: { + total: { type: 'number', example: 45 }, + high_engagement_count: { type: 'number', example: 8 } + } + } + } + } + } + } + }, + '400': { + description: '参数错误', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + success: { type: 'boolean', example: false }, + error: { type: 'string', example: 'Invalid sortBy. Must be engagement_count or engagement_rate.' } + } + } + } + } + }, + '500': { + description: '服务器错误', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + success: { type: 'boolean', example: false }, + error: { type: 'string', example: 'Failed to fetch popular posts data' }, + message: { type: 'string', example: 'Internal server error' } + } + } + } + } + } + } + } + }, }, components: { schemas: {