获取概览卡片数据 (/api/analytics/dashboard-cards)

返回包含留言总数、平均互动率和情感分析三个核心指标的卡片数据及环比变化
支持时间范围和项目ID过滤
获取留言趋势数据 (/api/analytics/comment-trend)
返回一段时间内留言数量的变化趋势,用于绘制柱状图
支持时间范围、项目ID和平台过滤
获取平台分布数据 (/api/analytics/platform-distribution)
返回不同社交平台上的评论或互动分布情况
支持时间范围、项目ID和事件类型过滤
获取情感分析详情 (/api/analytics/sentiment-analysis)
返回正面、中性、负面评论的比例和平均情感得分
支持时间范围、项目ID和平台过滤
获取热门文章数据 (/api/analytics/popular-posts)
返回按互动数量或互动率排序的热门帖文列表
支持时间范围、项目ID、平台过滤,以及排序字段和限制返回数量
This commit is contained in:
2025-03-13 21:54:21 +08:00
parent f9ba8a73ba
commit 23bcb4cb8b
4 changed files with 1746 additions and 0 deletions

View File

@@ -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 // Export singleton instance

View File

@@ -32,4 +32,19 @@ analyticsRouter.get('/kol-funnel', (c) => analyticsController.getKolFunnel(c));
// Add new post performance route // Add new post performance route
analyticsRouter.get('/post-performance', (c) => analyticsController.getPostPerformance(c)); 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; export default analyticsRouter;

View File

@@ -83,6 +83,102 @@ export interface PostPerformanceResponse {
total: number; // 总数 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 * Analytics service for KOL performance data
*/ */
@@ -887,6 +983,749 @@ export class AnalyticsService {
return mockPosts; return mockPosts;
} }
/**
* 获取概览卡片数据
* @param timeRange 时间范围(天数)
* @param projectId 可选项目ID
* @returns 概览卡片数据
*/
async getDashboardCardData(
timeRange: number,
projectId?: string
): Promise<DashboardCardData> {
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<CommentTrendResponse> {
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<string, number> = {};
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<PlatformDistributionResponse> {
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<SentimentAnalysisData> {
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<PopularPostsResponse> {
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 // Export singleton instance

View File

@@ -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: { components: {
schemas: { schemas: {