Compare commits
17 Commits
18276652f3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4056bac3ab | |||
| d0f2ab0620 | |||
| bd1a5ce384 | |||
| 942fb592b5 | |||
| 5a03323c69 | |||
| 853ce79e16 | |||
| e0bacfac1a | |||
| 55ac825d08 | |||
| 364a9dfc41 | |||
| ebd35b7576 | |||
| 9d0d3ddf4f | |||
| a345bf9cac | |||
| a2f5261523 | |||
| 2033adfc67 | |||
| b3d3c7cb3b | |||
| 2a3ca09862 | |||
| 6964eb75cc |
@@ -56,7 +56,7 @@
|
|||||||
- 条形长度直观反映各平台占比
|
- 条形长度直观反映各平台占比
|
||||||
- 帮助团队了解哪些平台效果更好
|
- 帮助团队了解哪些平台效果更好
|
||||||
|
|
||||||
# 审核状态分布
|
# 审核状态分布 [已实现]
|
||||||
|
|
||||||
- 环形图展示内容审核状态的分布情况
|
- 环形图展示内容审核状态的分布情况
|
||||||
- 包括三种状态:已核准、待审核、已拒绝
|
- 包括三种状态:已核准、待审核、已拒绝
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ CREATE TABLE IF NOT EXISTS posts (
|
|||||||
PRIMARY KEY (post_id)
|
PRIMARY KEY (post_id)
|
||||||
) ENGINE = MergeTree();
|
) ENGINE = MergeTree();
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS promote.sync_logs (
|
CREATE TABLE IF NOT EXISTS sync_logs (
|
||||||
timestamp DateTime DEFAULT now(),
|
timestamp DateTime DEFAULT now(),
|
||||||
duration_ms UInt64,
|
duration_ms UInt64,
|
||||||
posts_synced UInt32,
|
posts_synced UInt32,
|
||||||
@@ -145,4 +145,64 @@ CREATE TABLE IF NOT EXISTS promote.sync_logs (
|
|||||||
error_messages String
|
error_messages String
|
||||||
) ENGINE = MergeTree()
|
) ENGINE = MergeTree()
|
||||||
ORDER BY
|
ORDER BY
|
||||||
(timestamp)
|
(timestamp);
|
||||||
|
|
||||||
|
-- 创建专门的comments表存储评论数据
|
||||||
|
CREATE TABLE IF NOT EXISTS comments (
|
||||||
|
-- 基本标识信息
|
||||||
|
comment_id String,
|
||||||
|
-- 评论唯一ID
|
||||||
|
post_id String,
|
||||||
|
-- 关联的帖子ID
|
||||||
|
user_id String,
|
||||||
|
-- 发表评论的用户ID
|
||||||
|
-- 时间信息
|
||||||
|
created_at DateTime DEFAULT now(),
|
||||||
|
-- 评论创建时间
|
||||||
|
date Date DEFAULT toDate(created_at),
|
||||||
|
-- 日期(用于分区)
|
||||||
|
-- 评论内容信息
|
||||||
|
content String,
|
||||||
|
-- 评论内容
|
||||||
|
sentiment_score Float64,
|
||||||
|
-- 情感分数(-1到1)
|
||||||
|
sentiment Enum8(
|
||||||
|
-- 情感分类
|
||||||
|
'positive' = 1,
|
||||||
|
'neutral' = 2,
|
||||||
|
'negative' = 3
|
||||||
|
),
|
||||||
|
-- 关联信息
|
||||||
|
project_id String,
|
||||||
|
-- 项目ID
|
||||||
|
influencer_id String,
|
||||||
|
-- 网红ID
|
||||||
|
platform String,
|
||||||
|
-- 评论所在平台
|
||||||
|
-- 互动信息
|
||||||
|
likes_count UInt32 DEFAULT 0,
|
||||||
|
-- 点赞数量
|
||||||
|
replies_count UInt32 DEFAULT 0,
|
||||||
|
-- 回复数量
|
||||||
|
-- 元数据
|
||||||
|
parent_comment_id String DEFAULT '',
|
||||||
|
-- 父评论ID(用于回复)
|
||||||
|
is_reply UInt8 DEFAULT 0,
|
||||||
|
-- 是否为回复(0=否,1=是)
|
||||||
|
-- 同步信息
|
||||||
|
is_synced UInt8 DEFAULT 1,
|
||||||
|
-- 是否已同步(0=否,1=是)
|
||||||
|
last_updated DateTime DEFAULT now(),
|
||||||
|
-- 最后更新时间
|
||||||
|
-- 分析信息
|
||||||
|
keywords Array(String),
|
||||||
|
-- 提取的关键词
|
||||||
|
topics Array(String),
|
||||||
|
-- 关联的话题
|
||||||
|
-- 内部处理信息
|
||||||
|
is_active UInt8 DEFAULT 1,
|
||||||
|
-- 是否活跃(0=已删除/隐藏,1=活跃)
|
||||||
|
is_spam UInt8 DEFAULT 0 -- 是否为垃圾评论(0=否,1=是)
|
||||||
|
) ENGINE = MergeTree() PARTITION BY toYYYYMM(date)
|
||||||
|
ORDER BY
|
||||||
|
(post_id, created_at, comment_id) SETTINGS index_granularity = 8192;
|
||||||
@@ -192,159 +192,111 @@ export class AnalyticsController {
|
|||||||
* @param c Hono Context
|
* @param c Hono Context
|
||||||
* @returns Response with post performance data
|
* @returns Response with post performance data
|
||||||
*/
|
*/
|
||||||
async getPostPerformance(c: Context) {
|
async getPostPerformance(c: Context): Promise<Response> {
|
||||||
const requestId = `req_${Date.now()}_${Math.random().toString(36).substring(2, 10)}`;
|
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
// 解析查询参数
|
||||||
|
const kolId = c.req.query('kolId');
|
||||||
|
const platform = c.req.query('platform');
|
||||||
|
const startDate = c.req.query('startDate');
|
||||||
|
const endDate = c.req.query('endDate');
|
||||||
|
const sortBy = c.req.query('sortBy');
|
||||||
|
const sortOrder = c.req.query('sortOrder');
|
||||||
|
const limit = c.req.query('limit');
|
||||||
|
const offset = c.req.query('offset');
|
||||||
|
|
||||||
|
// 记录请求参数
|
||||||
|
logger.info('Post performance data requested', {
|
||||||
|
kolId, platform, startDate, endDate,
|
||||||
|
sortBy, sortOrder, limit, offset
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get query parameters
|
// 验证时间范围
|
||||||
const kolId = c.req.query('kolId'); // Optional KOL filter
|
|
||||||
const platform = c.req.query('platform'); // Optional platform filter
|
|
||||||
const startDate = c.req.query('startDate'); // Optional start date
|
|
||||||
const endDate = c.req.query('endDate'); // Optional end date
|
|
||||||
const sortBy = c.req.query('sortBy') || 'publish_date'; // Default sort by publish date
|
|
||||||
const sortOrder = c.req.query('sortOrder') || 'desc'; // Default to descending order
|
|
||||||
const limit = parseInt(c.req.query('limit') || '20', 10); // Default limit to 20 posts
|
|
||||||
const offset = parseInt(c.req.query('offset') || '0', 10); // Default offset to 0
|
|
||||||
const useMockData = c.req.query('useMockData') === 'true'; // 允许用户强制使用模拟数据
|
|
||||||
|
|
||||||
logger.info(`[${requestId}] Post performance request received`, {
|
|
||||||
kolId,
|
|
||||||
platform,
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
sortBy,
|
|
||||||
sortOrder,
|
|
||||||
limit,
|
|
||||||
offset,
|
|
||||||
useMockData,
|
|
||||||
userAgent: c.req.header('user-agent'),
|
|
||||||
ip: c.req.header('x-forwarded-for') || 'unknown'
|
|
||||||
});
|
|
||||||
|
|
||||||
// 如果强制使用模拟数据,直接生成并返回
|
|
||||||
if (useMockData) {
|
|
||||||
logger.info(`[${requestId}] Using mock data as requested`);
|
|
||||||
const mockPosts = this.generateMockPostData(limit, platform, kolId);
|
|
||||||
|
|
||||||
return c.json({
|
|
||||||
success: true,
|
|
||||||
data: mockPosts,
|
|
||||||
pagination: {
|
|
||||||
limit,
|
|
||||||
offset,
|
|
||||||
total: 100 // 模拟总数
|
|
||||||
},
|
|
||||||
is_mock_data: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate sort order
|
|
||||||
if (!['asc', 'desc'].includes(sortOrder)) {
|
|
||||||
logger.warn(`[${requestId}] Invalid sortOrder: ${sortOrder}`);
|
|
||||||
return c.json({
|
|
||||||
success: false,
|
|
||||||
error: 'Invalid sortOrder. Must be asc or desc.'
|
|
||||||
}, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate sort field
|
|
||||||
const validSortFields = ['publish_date', 'views', 'likes', 'comments', 'shares', 'sentiment_score'];
|
|
||||||
if (!validSortFields.includes(sortBy)) {
|
|
||||||
logger.warn(`[${requestId}] Invalid sortBy: ${sortBy}`);
|
|
||||||
return c.json({
|
|
||||||
success: false,
|
|
||||||
error: `Invalid sortBy. Must be one of: ${validSortFields.join(', ')}`
|
|
||||||
}, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate date formats if provided
|
|
||||||
if (startDate && !this.isValidDateFormat(startDate)) {
|
if (startDate && !this.isValidDateFormat(startDate)) {
|
||||||
logger.warn(`[${requestId}] Invalid startDate format: ${startDate}`);
|
return c.json({
|
||||||
return c.json({
|
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Invalid startDate format. Use YYYY-MM-DD.'
|
error: 'Invalid startDate format. Expected YYYY-MM-DD'
|
||||||
}, 400);
|
}, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (endDate && !this.isValidDateFormat(endDate)) {
|
if (endDate && !this.isValidDateFormat(endDate)) {
|
||||||
logger.warn(`[${requestId}] Invalid endDate format: ${endDate}`);
|
return c.json({
|
||||||
return c.json({
|
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Invalid endDate format. Use YYYY-MM-DD.'
|
error: 'Invalid endDate format. Expected YYYY-MM-DD'
|
||||||
}, 400);
|
}, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get post performance data from service
|
// 验证排序字段
|
||||||
const data = await analyticsService.getPostPerformance(
|
const validSortFields = ['publish_date', 'views', 'likes', 'comments', 'shares', 'sentiment_score'];
|
||||||
kolId,
|
if (sortBy && !validSortFields.includes(sortBy)) {
|
||||||
platform,
|
return c.json({
|
||||||
startDate,
|
success: false,
|
||||||
endDate,
|
error: `Invalid sortBy field. Expected one of: ${validSortFields.join(', ')}`
|
||||||
sortBy,
|
}, 400);
|
||||||
sortOrder,
|
}
|
||||||
limit,
|
|
||||||
offset
|
// 验证排序顺序
|
||||||
|
if (sortOrder && !['asc', 'desc', 'ASC', 'DESC'].includes(sortOrder)) {
|
||||||
|
return c.json({
|
||||||
|
success: false,
|
||||||
|
error: 'Invalid sortOrder. Expected: asc or desc'
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证限制数量和偏移量
|
||||||
|
const parsedLimit = limit ? parseInt(limit, 10) : 10;
|
||||||
|
if (isNaN(parsedLimit) || parsedLimit < 1 || parsedLimit > 100) {
|
||||||
|
return c.json({
|
||||||
|
success: false,
|
||||||
|
error: 'Invalid limit. Expected a number between 1 and 100'
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedOffset = offset ? parseInt(offset, 10) : 0;
|
||||||
|
if (isNaN(parsedOffset) || parsedOffset < 0) {
|
||||||
|
return c.json({
|
||||||
|
success: false,
|
||||||
|
error: 'Invalid offset. Expected a non-negative number'
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取帖文表现数据
|
||||||
|
const { posts, total } = await analyticsService.getPostPerformance(
|
||||||
|
kolId || undefined,
|
||||||
|
platform || undefined,
|
||||||
|
startDate || undefined,
|
||||||
|
endDate || undefined,
|
||||||
|
sortBy || 'publish_date',
|
||||||
|
sortOrder || 'desc',
|
||||||
|
parsedLimit,
|
||||||
|
parsedOffset
|
||||||
);
|
);
|
||||||
|
|
||||||
// 检查返回的数据是否包含真实数据(通过检查post_id的格式)
|
// 返回结果
|
||||||
const realDataCount = data.posts.filter(post =>
|
|
||||||
!post.post_id.startsWith('mock-')
|
|
||||||
).length;
|
|
||||||
|
|
||||||
const isMockData = realDataCount === 0 && data.posts.length > 0;
|
|
||||||
|
|
||||||
// Log successful response
|
|
||||||
const duration = Date.now() - startTime;
|
const duration = Date.now() - startTime;
|
||||||
logger.info(`[${requestId}] Post performance response sent successfully`, {
|
logger.info(`Post performance data returned (${duration}ms)`, {
|
||||||
duration,
|
totalPosts: total,
|
||||||
resultCount: data.posts.length,
|
returnedPosts: posts.length
|
||||||
totalPosts: data.total,
|
|
||||||
realDataCount,
|
|
||||||
mockDataCount: data.posts.length - realDataCount,
|
|
||||||
isMockData
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Return the data
|
|
||||||
return c.json({
|
return c.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: data.posts,
|
data: posts,
|
||||||
pagination: {
|
meta: {
|
||||||
limit,
|
total,
|
||||||
offset,
|
returned: posts.length,
|
||||||
total: data.total
|
limit: parsedLimit,
|
||||||
},
|
offset: parsedOffset
|
||||||
is_mock_data: isMockData
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Log error
|
|
||||||
const duration = Date.now() - startTime;
|
const duration = Date.now() - startTime;
|
||||||
logger.error(`[${requestId}] Error fetching post performance data (${duration}ms)`, error);
|
logger.error(`Error getting post performance data (${duration}ms)`, error);
|
||||||
|
|
||||||
try {
|
return c.json({
|
||||||
// 发生错误时尝试返回模拟数据
|
success: false,
|
||||||
const mockPosts = this.generateMockPostData(20);
|
error: 'Failed to retrieve post performance data'
|
||||||
logger.info(`[${requestId}] Returning mock data due to error`);
|
}, 500);
|
||||||
|
|
||||||
return c.json({
|
|
||||||
success: true,
|
|
||||||
data: mockPosts,
|
|
||||||
pagination: {
|
|
||||||
limit: 20,
|
|
||||||
offset: 0,
|
|
||||||
total: 100
|
|
||||||
},
|
|
||||||
is_mock_data: true,
|
|
||||||
original_error: error instanceof Error ? error.message : 'Unknown error'
|
|
||||||
});
|
|
||||||
} catch (mockError) {
|
|
||||||
// 如果连模拟数据生成都失败,返回错误响应
|
|
||||||
return c.json({
|
|
||||||
success: false,
|
|
||||||
error: 'Failed to fetch post performance data',
|
|
||||||
message: error instanceof Error ? error.message : 'Unknown error'
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,47 +313,6 @@ export class AnalyticsController {
|
|||||||
return date instanceof Date && !isNaN(date.getTime());
|
return date instanceof Date && !isNaN(date.getTime());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 生成模拟贴文数据
|
|
||||||
*/
|
|
||||||
private generateMockPostData(count: number, platform?: string, kolId?: string): any[] {
|
|
||||||
const platforms = platform ? [platform] : ['instagram', 'youtube', 'tiktok', 'facebook', 'twitter'];
|
|
||||||
const kolIds = kolId ? [kolId] : Array.from({length: 10}, (_, i) => `mock-kol-${i+1}`);
|
|
||||||
const kolNames = Array.from({length: 10}, (_, i) => `模拟KOL ${i+1}`);
|
|
||||||
|
|
||||||
return Array.from({length: count}, (_, i) => {
|
|
||||||
const selectedPlatform = platforms[Math.floor(Math.random() * platforms.length)];
|
|
||||||
const kolIndex = Math.floor(Math.random() * kolIds.length);
|
|
||||||
const selectedKolId = kolIds[kolIndex];
|
|
||||||
const selectedKolName = kolId ? `指定KOL` : kolNames[kolIndex % kolNames.length];
|
|
||||||
|
|
||||||
const publishDate = new Date();
|
|
||||||
publishDate.setDate(publishDate.getDate() - Math.floor(Math.random() * 90));
|
|
||||||
|
|
||||||
const views = Math.floor(Math.random() * 10000) + 1000;
|
|
||||||
const likes = Math.floor(views * (Math.random() * 0.2 + 0.05));
|
|
||||||
const comments = Math.floor(likes * (Math.random() * 0.2 + 0.02));
|
|
||||||
const shares = Math.floor(likes * (Math.random() * 0.1 + 0.01));
|
|
||||||
|
|
||||||
return {
|
|
||||||
post_id: `mock-post-${i+1}`,
|
|
||||||
title: `模拟贴文 ${i+1} (${selectedPlatform})`,
|
|
||||||
kol_id: selectedKolId,
|
|
||||||
kol_name: selectedKolName,
|
|
||||||
platform: selectedPlatform,
|
|
||||||
publish_date: publishDate.toISOString(),
|
|
||||||
metrics: {
|
|
||||||
views,
|
|
||||||
likes,
|
|
||||||
comments,
|
|
||||||
shares
|
|
||||||
},
|
|
||||||
sentiment_score: parseFloat((Math.random() * 1.6 - 0.6).toFixed(2)),
|
|
||||||
post_url: `https://${selectedPlatform}.com/post/mock-${i+1}`
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取概览卡片数据
|
* 获取概览卡片数据
|
||||||
* 返回包含留言总数、平均互动率和情感分析三个卡片数据
|
* 返回包含留言总数、平均互动率和情感分析三个卡片数据
|
||||||
@@ -767,6 +678,313 @@ export class AnalyticsController {
|
|||||||
}, 500);
|
}, 500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取内容审核状态分布数据
|
||||||
|
* 返回已批准、待审核和已拒绝内容的数量和比例
|
||||||
|
*
|
||||||
|
* @param c Hono Context
|
||||||
|
* @returns Response with moderation status distribution data
|
||||||
|
*/
|
||||||
|
async getModerationStatus(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 contentType = c.req.query('contentType') || 'all'; // 内容类型:post, comment, all
|
||||||
|
|
||||||
|
logger.info(`[${requestId}] Moderation status distribution request received`, {
|
||||||
|
timeRange,
|
||||||
|
projectId,
|
||||||
|
contentType,
|
||||||
|
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 (!['post', 'comment', 'all'].includes(contentType)) {
|
||||||
|
logger.warn(`[${requestId}] Invalid contentType: ${contentType}`);
|
||||||
|
return c.json({
|
||||||
|
success: false,
|
||||||
|
error: 'Invalid contentType. Must be post, comment, or all.'
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取审核状态分布数据
|
||||||
|
const data = await analyticsService.getModerationStatusDistribution(
|
||||||
|
parseInt(timeRange, 10),
|
||||||
|
projectId,
|
||||||
|
contentType
|
||||||
|
);
|
||||||
|
|
||||||
|
// 返回成功响应
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
logger.info(`[${requestId}] Moderation status distribution response sent successfully`, {
|
||||||
|
duration,
|
||||||
|
statuses: Object.keys(data.statuses),
|
||||||
|
total: data.total
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
success: true,
|
||||||
|
data: data
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// 记录错误
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
logger.error(`[${requestId}] Error fetching moderation status distribution (${duration}ms)`, error);
|
||||||
|
|
||||||
|
// 返回错误响应
|
||||||
|
return c.json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to fetch moderation status distribution data',
|
||||||
|
message: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取热门关键字数据
|
||||||
|
* 返回按出现频率排序的热门关键字列表
|
||||||
|
*
|
||||||
|
* @param c Hono Context
|
||||||
|
* @returns Response with hot keywords data
|
||||||
|
*/
|
||||||
|
async getHotKeywords(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 limit = parseInt(c.req.query('limit') || '20', 10); // 默认返回20个关键字
|
||||||
|
|
||||||
|
logger.info(`[${requestId}] Hot keywords request received`, {
|
||||||
|
timeRange,
|
||||||
|
projectId,
|
||||||
|
platform,
|
||||||
|
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 hotKeywordsData = await analyticsService.getHotKeywords(
|
||||||
|
parseInt(timeRange, 10),
|
||||||
|
projectId,
|
||||||
|
platform,
|
||||||
|
limit
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
success: true,
|
||||||
|
data: hotKeywordsData.data,
|
||||||
|
metadata: {
|
||||||
|
total: hotKeywordsData.total,
|
||||||
|
is_mock_data: false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
logger.info(`[${requestId}] Hot keywords request completed in ${duration}ms`);
|
||||||
|
|
||||||
|
return c.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
logger.error(`[${requestId}] Error getting hot keywords: ${error}`, {
|
||||||
|
error: String(error),
|
||||||
|
duration
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to fetch hot keywords data'
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户互动时间分析数据
|
||||||
|
* 返回按小时统计的用户互动数量和分布
|
||||||
|
*
|
||||||
|
* @param c Hono Context
|
||||||
|
* @returns Response with interaction time analysis data
|
||||||
|
*/
|
||||||
|
async getInteractionTimeAnalysis(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 eventType = c.req.query('eventType') || 'all'; // 可选事件类型过滤
|
||||||
|
|
||||||
|
logger.info(`[${requestId}] Interaction time analysis request received`, {
|
||||||
|
timeRange,
|
||||||
|
projectId,
|
||||||
|
platform,
|
||||||
|
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 = ['all', 'comment', 'like', 'view', 'share', 'follow'];
|
||||||
|
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.getInteractionTimeAnalysis(
|
||||||
|
parseInt(timeRange, 10),
|
||||||
|
projectId,
|
||||||
|
platform,
|
||||||
|
eventType
|
||||||
|
);
|
||||||
|
|
||||||
|
// 返回成功响应
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
logger.info(`[${requestId}] Interaction time analysis response sent successfully`, {
|
||||||
|
duration,
|
||||||
|
total: data.total,
|
||||||
|
peak_hour: data.peak_hour
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
success: true,
|
||||||
|
data: data.data,
|
||||||
|
metadata: {
|
||||||
|
total: data.total,
|
||||||
|
peak_hour: data.peak_hour,
|
||||||
|
lowest_hour: data.lowest_hour
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// 记录错误
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
logger.error(`[${requestId}] Error fetching interaction time analysis (${duration}ms)`, error);
|
||||||
|
|
||||||
|
// 返回错误响应
|
||||||
|
return c.json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to fetch interaction time analysis data',
|
||||||
|
message: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
}, 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
|
// Export singleton instance
|
||||||
|
|||||||
@@ -47,4 +47,16 @@ analyticsRouter.get('/sentiment-analysis', (c) => analyticsController.getSentime
|
|||||||
// Add popular posts route
|
// Add popular posts route
|
||||||
analyticsRouter.get('/popular-posts', (c) => analyticsController.getPopularPosts(c));
|
analyticsRouter.get('/popular-posts', (c) => analyticsController.getPopularPosts(c));
|
||||||
|
|
||||||
|
// Add moderation status distribution route
|
||||||
|
analyticsRouter.get('/moderation-status', (c) => analyticsController.getModerationStatus(c));
|
||||||
|
|
||||||
|
// Add hot keywords route
|
||||||
|
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;
|
export default analyticsRouter;
|
||||||
@@ -179,6 +179,108 @@ export interface PopularPostsResponse {
|
|||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sentiment analysis response
|
||||||
|
*/
|
||||||
|
export interface SentimentAnalysisResponse {
|
||||||
|
positive_percentage: number;
|
||||||
|
neutral_percentage: number;
|
||||||
|
negative_percentage: number;
|
||||||
|
average_score: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moderation status response
|
||||||
|
*/
|
||||||
|
export interface ModerationStatusResponse {
|
||||||
|
statuses: {
|
||||||
|
approved: number;
|
||||||
|
pending: number;
|
||||||
|
rejected: number;
|
||||||
|
};
|
||||||
|
percentages: {
|
||||||
|
approved: number;
|
||||||
|
pending: number;
|
||||||
|
rejected: number;
|
||||||
|
};
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a hot keyword item with count, percentage and sentiment score
|
||||||
|
*/
|
||||||
|
export interface HotKeywordItem {
|
||||||
|
keyword: string; // 关键词
|
||||||
|
count: number; // 出现次数
|
||||||
|
percentage: number; // 占比(%)
|
||||||
|
sentiment_score: number; // 情感分数
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response structure for hot keywords
|
||||||
|
*/
|
||||||
|
export interface HotKeywordsResponse {
|
||||||
|
data: HotKeywordItem[]; // 热门关键词数据
|
||||||
|
total: number; // 总数
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户互动时间分布数据项
|
||||||
|
*/
|
||||||
|
export interface InteractionTimeItem {
|
||||||
|
hour: number; // 小时 (0-23)
|
||||||
|
count: number; // 互动数量
|
||||||
|
percentage: number; // 占总数百分比
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户互动时间分析响应
|
||||||
|
*/
|
||||||
|
export interface InteractionTimeResponse {
|
||||||
|
data: InteractionTimeItem[]; // 按小时统计的互动数据
|
||||||
|
peak_hour: number; // 峰值时段
|
||||||
|
lowest_hour: number; // 最低时段
|
||||||
|
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
|
* Analytics service for KOL performance data
|
||||||
*/
|
*/
|
||||||
@@ -838,7 +940,7 @@ export class AnalyticsService {
|
|||||||
|
|
||||||
// 合并数据,生成最终结果
|
// 合并数据,生成最终结果
|
||||||
const transformedPosts: PostPerformanceData[] = postsData.map(post => {
|
const transformedPosts: PostPerformanceData[] = postsData.map(post => {
|
||||||
// 获取帖文的指标数据,如果没有则使用空值或模拟数据
|
// 获取帖文的指标数据
|
||||||
const metrics = metricsMap[post.post_id] || {};
|
const metrics = metricsMap[post.post_id] || {};
|
||||||
const postMetrics = {
|
const postMetrics = {
|
||||||
views: Number(metrics.views || 0),
|
views: Number(metrics.views || 0),
|
||||||
@@ -847,16 +949,10 @@ export class AnalyticsService {
|
|||||||
shares: Number(metrics.shares || 0)
|
shares: Number(metrics.shares || 0)
|
||||||
};
|
};
|
||||||
|
|
||||||
// 有真实数据则使用真实数据,否则生成模拟数据
|
// 获取情感分数
|
||||||
const hasRealMetrics = postMetrics.views > 0 || postMetrics.likes > 0 ||
|
|
||||||
postMetrics.comments > 0 || postMetrics.shares > 0;
|
|
||||||
|
|
||||||
const finalMetrics = hasRealMetrics ? postMetrics : this.generateMockMetrics();
|
|
||||||
|
|
||||||
// 同样,有真实情感分数则使用真实数据,否则生成模拟数据
|
|
||||||
const sentimentScore = metrics.sentiment_score !== undefined
|
const sentimentScore = metrics.sentiment_score !== undefined
|
||||||
? Number(metrics.sentiment_score)
|
? Number(metrics.sentiment_score)
|
||||||
: this.generateMockSentimentScore();
|
: 0; // 默认为0(中性)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
post_id: post.post_id,
|
post_id: post.post_id,
|
||||||
@@ -865,7 +961,7 @@ export class AnalyticsService {
|
|||||||
kol_name: post.kol_name || '未知KOL',
|
kol_name: post.kol_name || '未知KOL',
|
||||||
platform: post.platform || 'unknown',
|
platform: post.platform || 'unknown',
|
||||||
publish_date: post.publish_date,
|
publish_date: post.publish_date,
|
||||||
metrics: finalMetrics,
|
metrics: postMetrics,
|
||||||
sentiment_score: sentimentScore,
|
sentiment_score: sentimentScore,
|
||||||
post_url: post.post_url || `https://${post.platform || 'example'}.com/post/${post.post_id}`
|
post_url: post.post_url || `https://${post.platform || 'example'}.com/post/${post.post_id}`
|
||||||
};
|
};
|
||||||
@@ -893,19 +989,11 @@ export class AnalyticsService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 统计真实数据vs模拟数据的比例
|
|
||||||
const realDataCount = transformedPosts.filter(post =>
|
|
||||||
post.metrics.views > 0 || post.metrics.likes > 0 ||
|
|
||||||
post.metrics.comments > 0 || post.metrics.shares > 0
|
|
||||||
).length;
|
|
||||||
|
|
||||||
const duration = Date.now() - startTime;
|
const duration = Date.now() - startTime;
|
||||||
logger.info('KOL post performance data fetched successfully', {
|
logger.info('KOL post performance data fetched successfully', {
|
||||||
duration,
|
duration,
|
||||||
resultCount: transformedPosts.length,
|
resultCount: transformedPosts.length,
|
||||||
totalPosts: total,
|
totalPosts: total
|
||||||
realDataCount,
|
|
||||||
mockDataCount: transformedPosts.length - realDataCount
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -916,73 +1004,13 @@ export class AnalyticsService {
|
|||||||
const duration = Date.now() - startTime;
|
const duration = Date.now() - startTime;
|
||||||
logger.error(`Error in getPostPerformance (${duration}ms)`, error);
|
logger.error(`Error in getPostPerformance (${duration}ms)`, error);
|
||||||
|
|
||||||
// 发生错误时,尝试返回模拟数据
|
// 发生错误时返回空数据
|
||||||
try {
|
return {
|
||||||
const mockPosts = this.generateMockPostPerformanceData(limit);
|
posts: [],
|
||||||
logger.info('Returning mock data due to error', {
|
total: 0
|
||||||
mockDataCount: mockPosts.length,
|
};
|
||||||
error: error instanceof Error ? error.message : 'Unknown error'
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
posts: mockPosts,
|
|
||||||
total: 100 // 模拟总数
|
|
||||||
};
|
|
||||||
} catch (mockError) {
|
|
||||||
// 如果连模拟数据都无法生成,则抛出原始错误
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 生成模拟贴文互动指标
|
|
||||||
*/
|
|
||||||
private generateMockMetrics(): {views: number, likes: number, comments: number, shares: number} {
|
|
||||||
// 生成在合理范围内的随机数
|
|
||||||
const views = Math.floor(Math.random() * 10000) + 1000;
|
|
||||||
const likes = Math.floor(views * (Math.random() * 0.2 + 0.05)); // 5-25% 的观看转化为点赞
|
|
||||||
const comments = Math.floor(likes * (Math.random() * 0.2 + 0.02)); // 2-22% 的点赞转化为评论
|
|
||||||
const shares = Math.floor(likes * (Math.random() * 0.1 + 0.01)); // 1-11% 的点赞转化为分享
|
|
||||||
|
|
||||||
return { views, likes, comments, shares };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 生成模拟情感分数 (-1 到 1 之间)
|
|
||||||
*/
|
|
||||||
private generateMockSentimentScore(): number {
|
|
||||||
// 生成-1到1之间的随机数,倾向于正面情绪
|
|
||||||
return parseFloat((Math.random() * 1.6 - 0.6).toFixed(2));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 生成完整的模拟贴文表现数据
|
|
||||||
*/
|
|
||||||
private generateMockPostPerformanceData(count: number): PostPerformanceData[] {
|
|
||||||
const platforms = ['instagram', 'youtube', 'tiktok', 'facebook', 'twitter'];
|
|
||||||
const mockPosts: PostPerformanceData[] = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
const platform = platforms[Math.floor(Math.random() * platforms.length)];
|
|
||||||
const publishDate = new Date();
|
|
||||||
publishDate.setDate(publishDate.getDate() - Math.floor(Math.random() * 90));
|
|
||||||
|
|
||||||
mockPosts.push({
|
|
||||||
post_id: `mock-post-${i+1}`,
|
|
||||||
title: `模拟贴文 ${i+1}`,
|
|
||||||
kol_id: `mock-kol-${Math.floor(Math.random() * 10) + 1}`,
|
|
||||||
kol_name: `模拟KOL ${Math.floor(Math.random() * 10) + 1}`,
|
|
||||||
platform,
|
|
||||||
publish_date: publishDate.toISOString(),
|
|
||||||
metrics: this.generateMockMetrics(),
|
|
||||||
sentiment_score: this.generateMockSentimentScore(),
|
|
||||||
post_url: `https://${platform}.com/post/mock-${i+1}`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return mockPosts;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取概览卡片数据
|
* 获取概览卡片数据
|
||||||
@@ -1726,6 +1754,706 @@ export class AnalyticsService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取内容审核状态分布数据
|
||||||
|
* @param timeRange 时间范围(天数)
|
||||||
|
* @param projectId 可选项目ID过滤
|
||||||
|
* @param contentType 内容类型过滤(post, comment, all)
|
||||||
|
* @returns 审核状态分布数据
|
||||||
|
*/
|
||||||
|
async getModerationStatusDistribution(
|
||||||
|
timeRange: number,
|
||||||
|
projectId?: string,
|
||||||
|
contentType: string = 'all'
|
||||||
|
): Promise<ModerationStatusResponse> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
logger.info('Fetching moderation status distribution', { timeRange, projectId, contentType });
|
||||||
|
|
||||||
|
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}'`);
|
||||||
|
|
||||||
|
if (projectId) {
|
||||||
|
filters.push(`project_id = '${projectId}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 基于内容类型的过滤
|
||||||
|
let typeFilter = '';
|
||||||
|
if (contentType === 'post') {
|
||||||
|
typeFilter = "AND content_type IN ('video', 'image', 'text', 'story', 'reel', 'live')";
|
||||||
|
} else if (contentType === 'comment') {
|
||||||
|
typeFilter = "AND event_type = 'comment'";
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterCondition = filters.join(' AND ');
|
||||||
|
|
||||||
|
// 查询审核状态分布
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
content_status,
|
||||||
|
count() as status_count
|
||||||
|
FROM
|
||||||
|
events
|
||||||
|
WHERE
|
||||||
|
${filterCondition}
|
||||||
|
AND content_status IN ('approved', 'pending', 'rejected')
|
||||||
|
${typeFilter}
|
||||||
|
GROUP BY
|
||||||
|
content_status
|
||||||
|
`;
|
||||||
|
|
||||||
|
logger.debug('Executing ClickHouse query for moderation status distribution', {
|
||||||
|
query: query.replace(/\n\s+/g, ' ').trim()
|
||||||
|
});
|
||||||
|
|
||||||
|
// 执行查询
|
||||||
|
const results = await this.executeClickhouseQuery(query);
|
||||||
|
|
||||||
|
// 初始化结果对象
|
||||||
|
const statusCounts = {
|
||||||
|
approved: 0,
|
||||||
|
pending: 0,
|
||||||
|
rejected: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
// 解析结果
|
||||||
|
results.forEach(row => {
|
||||||
|
const status = row.content_status.toLowerCase();
|
||||||
|
if (status in statusCounts) {
|
||||||
|
statusCounts[status as keyof typeof statusCounts] = Number(row.status_count);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 计算总数和百分比
|
||||||
|
const total = statusCounts.approved + statusCounts.pending + statusCounts.rejected;
|
||||||
|
|
||||||
|
const calculatePercentage = (count: number): number => {
|
||||||
|
if (total === 0) return 0;
|
||||||
|
return parseFloat(((count / total) * 100).toFixed(1));
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusPercentages = {
|
||||||
|
approved: calculatePercentage(statusCounts.approved),
|
||||||
|
pending: calculatePercentage(statusCounts.pending),
|
||||||
|
rejected: calculatePercentage(statusCounts.rejected)
|
||||||
|
};
|
||||||
|
|
||||||
|
const result: ModerationStatusResponse = {
|
||||||
|
statuses: statusCounts,
|
||||||
|
percentages: statusPercentages,
|
||||||
|
total
|
||||||
|
};
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
logger.info('Moderation status distribution fetched successfully', {
|
||||||
|
duration,
|
||||||
|
total,
|
||||||
|
statusDistribution: statusCounts
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
logger.error(`Error in getModerationStatusDistribution (${duration}ms)`, error);
|
||||||
|
|
||||||
|
// 发生错误时返回默认响应
|
||||||
|
return {
|
||||||
|
statuses: { approved: 0, pending: 0, rejected: 0 },
|
||||||
|
percentages: { approved: 0, pending: 0, rejected: 0 },
|
||||||
|
total: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get hot keywords from comments with their frequency and sentiment scores
|
||||||
|
* @param timeRange Time range in days (7, 30, 90)
|
||||||
|
* @param projectId Optional project ID to filter by
|
||||||
|
* @param platform Optional platform to filter by
|
||||||
|
* @param limit Maximum number of keywords to return
|
||||||
|
* @returns Hot keywords with their counts, percentages and sentiment scores
|
||||||
|
*/
|
||||||
|
async getHotKeywords(
|
||||||
|
timeRange: number,
|
||||||
|
projectId?: string,
|
||||||
|
platform?: string,
|
||||||
|
limit: number = 20
|
||||||
|
): Promise<HotKeywordsResponse> {
|
||||||
|
try {
|
||||||
|
const startDate = new Date();
|
||||||
|
startDate.setDate(startDate.getDate() - timeRange);
|
||||||
|
const formattedStartDate = this.formatDateForClickhouse(startDate);
|
||||||
|
|
||||||
|
// Build the WHERE clause with filters
|
||||||
|
const filters = [`event_time >= '${formattedStartDate}'`];
|
||||||
|
if (projectId) {
|
||||||
|
filters.push(`project_id = '${projectId}'`);
|
||||||
|
}
|
||||||
|
if (platform) {
|
||||||
|
filters.push(`platform = '${platform}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add filter for comment events only
|
||||||
|
filters.push(`event_type = 'comment'`);
|
||||||
|
|
||||||
|
const whereClause = filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '';
|
||||||
|
|
||||||
|
// Query to extract keywords from comments and count their occurrences
|
||||||
|
const keywordsQuery = `
|
||||||
|
WITH extracted_keywords AS (
|
||||||
|
SELECT
|
||||||
|
arrayJoin(extractAll(comment_text, '[\\\\p{L}\\\\p{N}]{2,}')) AS keyword,
|
||||||
|
sentiment_score
|
||||||
|
FROM events
|
||||||
|
${whereClause}
|
||||||
|
WHERE comment_text != ''
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
keyword,
|
||||||
|
count() AS count,
|
||||||
|
avg(sentiment_score) AS avg_sentiment_score
|
||||||
|
FROM extracted_keywords
|
||||||
|
GROUP BY keyword
|
||||||
|
ORDER BY count DESC
|
||||||
|
LIMIT ${limit}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Execute the query
|
||||||
|
const keywordsData = await this.executeClickhouseQuery(keywordsQuery);
|
||||||
|
|
||||||
|
// Calculate total count for percentages
|
||||||
|
const totalCount = keywordsData.reduce((sum, item) => sum + item.count, 0);
|
||||||
|
|
||||||
|
// Format the response
|
||||||
|
const hotKeywords: HotKeywordItem[] = keywordsData.map(item => ({
|
||||||
|
keyword: item.keyword,
|
||||||
|
count: item.count,
|
||||||
|
percentage: parseFloat(((item.count / totalCount) * 100).toFixed(1)),
|
||||||
|
sentiment_score: parseFloat(item.avg_sentiment_score.toFixed(1))
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: hotKeywords,
|
||||||
|
total: totalCount
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Error fetching hot keywords: ${error}`);
|
||||||
|
|
||||||
|
// Return mock data in case of error
|
||||||
|
const mockKeywords: HotKeywordItem[] = [
|
||||||
|
{ keyword: '价格', count: 45, percentage: 22.5, sentiment_score: 0.2 },
|
||||||
|
{ keyword: '质量', count: 38, percentage: 19.0, sentiment_score: 0.7 },
|
||||||
|
{ keyword: '服务', count: 32, percentage: 16.0, sentiment_score: -0.3 },
|
||||||
|
{ keyword: '快递', count: 28, percentage: 14.0, sentiment_score: 0.1 },
|
||||||
|
{ keyword: '推荐', count: 24, percentage: 12.0, sentiment_score: 0.8 },
|
||||||
|
{ keyword: '问题', count: 18, percentage: 9.0, sentiment_score: -0.6 },
|
||||||
|
{ keyword: '体验', count: 15, percentage: 7.5, sentiment_score: 0.4 }
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: mockKeywords,
|
||||||
|
total: 100
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户互动时间分析数据
|
||||||
|
* @param timeRange 时间范围(天数)
|
||||||
|
* @param projectId 可选项目ID
|
||||||
|
* @param platform 可选平台
|
||||||
|
* @param eventType 互动事件类型
|
||||||
|
* @returns 互动时间分析数据
|
||||||
|
*/
|
||||||
|
async getInteractionTimeAnalysis(
|
||||||
|
timeRange: number,
|
||||||
|
projectId?: string,
|
||||||
|
platform?: string,
|
||||||
|
eventType: string = 'all'
|
||||||
|
): Promise<InteractionTimeResponse> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
logger.info('Fetching interaction time analysis data', { timeRange, projectId, platform, eventType });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 计算时间范围
|
||||||
|
const startDate = new Date();
|
||||||
|
startDate.setDate(startDate.getDate() - timeRange);
|
||||||
|
const formattedStartDate = this.formatDateForClickhouse(startDate);
|
||||||
|
|
||||||
|
// 构建查询条件
|
||||||
|
const filters = [`event_time >= '${formattedStartDate}'`];
|
||||||
|
|
||||||
|
if (projectId) {
|
||||||
|
filters.push(`project_id = '${projectId}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (platform) {
|
||||||
|
filters.push(`platform = '${platform}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据事件类型过滤
|
||||||
|
if (eventType !== 'all') {
|
||||||
|
filters.push(`event_type = '${eventType}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '';
|
||||||
|
|
||||||
|
// 按小时分组查询互动数量
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
toHour(event_time) AS hour,
|
||||||
|
count() AS count
|
||||||
|
FROM
|
||||||
|
events
|
||||||
|
${whereClause}
|
||||||
|
GROUP BY
|
||||||
|
hour
|
||||||
|
ORDER BY
|
||||||
|
hour ASC
|
||||||
|
`;
|
||||||
|
|
||||||
|
logger.debug('Executing ClickHouse query for interaction time analysis', {
|
||||||
|
query: query.replace(/\n\s+/g, ' ').trim()
|
||||||
|
});
|
||||||
|
|
||||||
|
// 执行查询
|
||||||
|
const result = await this.executeClickhouseQuery(query);
|
||||||
|
|
||||||
|
// 处理结果,确保所有24小时都有数据
|
||||||
|
const hourlyData: { [hour: number]: number } = {};
|
||||||
|
|
||||||
|
// 初始化所有小时为0
|
||||||
|
for (let i = 0; i < 24; i++) {
|
||||||
|
hourlyData[i] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 填充查询结果
|
||||||
|
result.forEach(item => {
|
||||||
|
hourlyData[item.hour] = item.count;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 计算总数和百分比
|
||||||
|
const total = Object.values(hourlyData).reduce((sum, count) => sum + count, 0);
|
||||||
|
|
||||||
|
// 找出峰值和最低时段
|
||||||
|
let peakHour = 0;
|
||||||
|
let lowestHour = 0;
|
||||||
|
let maxCount = 0;
|
||||||
|
let minCount = Number.MAX_SAFE_INTEGER;
|
||||||
|
|
||||||
|
Object.entries(hourlyData).forEach(([hour, count]) => {
|
||||||
|
const hourNum = parseInt(hour, 10);
|
||||||
|
if (count > maxCount) {
|
||||||
|
maxCount = count;
|
||||||
|
peakHour = hourNum;
|
||||||
|
}
|
||||||
|
if (count < minCount) {
|
||||||
|
minCount = count;
|
||||||
|
lowestHour = hourNum;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 格式化响应数据
|
||||||
|
const data: InteractionTimeItem[] = Object.entries(hourlyData).map(([hour, count]) => ({
|
||||||
|
hour: parseInt(hour, 10),
|
||||||
|
count,
|
||||||
|
percentage: total > 0 ? parseFloat(((count / total) * 100).toFixed(1)) : 0
|
||||||
|
}));
|
||||||
|
|
||||||
|
const response: InteractionTimeResponse = {
|
||||||
|
data,
|
||||||
|
peak_hour: peakHour,
|
||||||
|
lowest_hour: lowestHour,
|
||||||
|
total
|
||||||
|
};
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
logger.info('Interaction time analysis data fetched successfully', {
|
||||||
|
duration,
|
||||||
|
total,
|
||||||
|
peak_hour: peakHour
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
logger.error(`Error in getInteractionTimeAnalysis (${duration}ms)`, error);
|
||||||
|
|
||||||
|
// 发生错误时返回模拟数据
|
||||||
|
const mockData: InteractionTimeItem[] = Array.from({ length: 24 }, (_, i) => {
|
||||||
|
// 创建合理的模拟数据分布,早晨和晚上的互动较多
|
||||||
|
let count = 0;
|
||||||
|
if (i >= 7 && i <= 10) { // 早晨
|
||||||
|
count = Math.floor(Math.random() * 100) + 150;
|
||||||
|
} else if (i >= 17 && i <= 22) { // 晚上
|
||||||
|
count = Math.floor(Math.random() * 100) + 200;
|
||||||
|
} else { // 其他时间
|
||||||
|
count = Math.floor(Math.random() * 100) + 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
hour: i,
|
||||||
|
count,
|
||||||
|
percentage: 0 // 暂时设为0,稍后计算
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 计算总数和百分比
|
||||||
|
const total = mockData.reduce((sum, item) => sum + item.count, 0);
|
||||||
|
mockData.forEach(item => {
|
||||||
|
item.percentage = parseFloat(((item.count / total) * 100).toFixed(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 找出峰值和最低时段
|
||||||
|
let peakHour = 0;
|
||||||
|
let lowestHour = 0;
|
||||||
|
let maxCount = 0;
|
||||||
|
let minCount = Number.MAX_SAFE_INTEGER;
|
||||||
|
|
||||||
|
mockData.forEach(item => {
|
||||||
|
if (item.count > maxCount) {
|
||||||
|
maxCount = item.count;
|
||||||
|
peakHour = item.hour;
|
||||||
|
}
|
||||||
|
if (item.count < minCount) {
|
||||||
|
minCount = item.count;
|
||||||
|
lowestHour = item.hour;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: mockData,
|
||||||
|
peak_hour: peakHour,
|
||||||
|
lowest_hour: lowestHour,
|
||||||
|
total
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取内容表现分析数据
|
||||||
|
*
|
||||||
|
* @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<ContentPerformanceResponse> {
|
||||||
|
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
|
// Export singleton instance
|
||||||
|
|||||||
@@ -2431,15 +2431,6 @@ export const openAPISpec = {
|
|||||||
type: 'integer',
|
type: 'integer',
|
||||||
default: 0
|
default: 0
|
||||||
}
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'useMockData',
|
|
||||||
in: 'query',
|
|
||||||
description: 'Force use of mock data (useful for testing and UI development)',
|
|
||||||
schema: {
|
|
||||||
type: 'boolean',
|
|
||||||
default: false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
responses: {
|
responses: {
|
||||||
@@ -2932,14 +2923,14 @@ export const openAPISpec = {
|
|||||||
},
|
},
|
||||||
'/api/analytics/popular-posts': {
|
'/api/analytics/popular-posts': {
|
||||||
get: {
|
get: {
|
||||||
summary: '获取热门文章数据',
|
summary: '获取热门帖文数据',
|
||||||
description: '返回按互动数量或互动率排序的热门帖文列表',
|
description: '返回按互动数量或互动率排序的热门帖文',
|
||||||
tags: ['Analytics'],
|
tags: ['Analytics'],
|
||||||
parameters: [
|
parameters: [
|
||||||
{
|
{
|
||||||
name: 'timeRange',
|
name: 'timeRange',
|
||||||
in: 'query',
|
in: 'query',
|
||||||
description: '时间范围(天)',
|
description: '时间范围(天)',
|
||||||
schema: {
|
schema: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
enum: ['7', '30', '90'],
|
enum: ['7', '30', '90'],
|
||||||
@@ -2949,7 +2940,7 @@ export const openAPISpec = {
|
|||||||
{
|
{
|
||||||
name: 'projectId',
|
name: 'projectId',
|
||||||
in: 'query',
|
in: 'query',
|
||||||
description: '项目ID过滤',
|
description: '项目ID',
|
||||||
schema: {
|
schema: {
|
||||||
type: 'string'
|
type: 'string'
|
||||||
}
|
}
|
||||||
@@ -2957,10 +2948,9 @@ export const openAPISpec = {
|
|||||||
{
|
{
|
||||||
name: 'platform',
|
name: 'platform',
|
||||||
in: 'query',
|
in: 'query',
|
||||||
description: '平台过滤',
|
description: '平台',
|
||||||
schema: {
|
schema: {
|
||||||
type: 'string',
|
type: 'string'
|
||||||
enum: ['Twitter', 'Instagram', 'TikTok', 'Facebook', 'YouTube']
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -2976,7 +2966,7 @@ export const openAPISpec = {
|
|||||||
{
|
{
|
||||||
name: 'limit',
|
name: 'limit',
|
||||||
in: 'query',
|
in: 'query',
|
||||||
description: '返回数量',
|
description: '返回数量限制',
|
||||||
schema: {
|
schema: {
|
||||||
type: 'integer',
|
type: 'integer',
|
||||||
default: 10,
|
default: 10,
|
||||||
@@ -2986,36 +2976,58 @@ export const openAPISpec = {
|
|||||||
],
|
],
|
||||||
responses: {
|
responses: {
|
||||||
'200': {
|
'200': {
|
||||||
description: '成功响应',
|
description: '热门帖文数据',
|
||||||
content: {
|
content: {
|
||||||
'application/json': {
|
'application/json': {
|
||||||
schema: {
|
schema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
success: { type: 'boolean', example: true },
|
success: {
|
||||||
|
type: 'boolean'
|
||||||
|
},
|
||||||
data: {
|
data: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
items: {
|
items: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
post_id: { type: 'string', example: 'post_123' },
|
title: {
|
||||||
title: { type: 'string', example: '新产品发布' },
|
type: 'string'
|
||||||
platform: { type: 'string', example: 'Instagram' },
|
},
|
||||||
influencer_id: { type: 'string', example: 'inf_456' },
|
platform: {
|
||||||
influencer_name: { type: 'string', example: '时尚KOL' },
|
type: 'string'
|
||||||
publish_date: { type: 'string', example: '2025-03-10 10:30:00' },
|
},
|
||||||
engagement_count: { type: 'number', example: 2350 },
|
influencer_name: {
|
||||||
views_count: { type: 'number', example: 15600 },
|
type: 'string'
|
||||||
engagement_rate: { type: 'number', example: 0.1506 },
|
},
|
||||||
is_high_engagement: { type: 'boolean', example: true }
|
publish_date: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'date-time'
|
||||||
|
},
|
||||||
|
engagement_count: {
|
||||||
|
type: 'integer'
|
||||||
|
},
|
||||||
|
views_count: {
|
||||||
|
type: 'integer'
|
||||||
|
},
|
||||||
|
engagement_rate: {
|
||||||
|
type: 'number',
|
||||||
|
format: 'float'
|
||||||
|
},
|
||||||
|
is_high_engagement: {
|
||||||
|
type: 'boolean'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
metadata: {
|
metadata: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
total: { type: 'number', example: 45 },
|
total: {
|
||||||
high_engagement_count: { type: 'number', example: 8 }
|
type: 'integer'
|
||||||
|
},
|
||||||
|
high_engagement_count: {
|
||||||
|
type: 'integer'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3024,14 +3036,262 @@ export const openAPISpec = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
'400': {
|
'400': {
|
||||||
description: '参数错误',
|
description: '请求参数错误',
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: {
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'500': {
|
||||||
|
description: '服务器错误',
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: {
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'/api/analytics/moderation-status': {
|
||||||
|
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: 'contentType',
|
||||||
|
in: 'query',
|
||||||
|
description: '内容类型',
|
||||||
|
schema: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['post', 'comment', 'all'],
|
||||||
|
default: 'all'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
responses: {
|
||||||
|
'200': {
|
||||||
|
description: '审核状态分布数据',
|
||||||
content: {
|
content: {
|
||||||
'application/json': {
|
'application/json': {
|
||||||
schema: {
|
schema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
success: { type: 'boolean', example: false },
|
success: {
|
||||||
error: { type: 'string', example: 'Invalid sortBy. Must be engagement_count or engagement_rate.' }
|
type: 'boolean'
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
statuses: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
approved: {
|
||||||
|
type: 'integer',
|
||||||
|
description: '已批准内容数量'
|
||||||
|
},
|
||||||
|
pending: {
|
||||||
|
type: 'integer',
|
||||||
|
description: '待审核内容数量'
|
||||||
|
},
|
||||||
|
rejected: {
|
||||||
|
type: 'integer',
|
||||||
|
description: '已拒绝内容数量'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
percentages: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
approved: {
|
||||||
|
type: 'number',
|
||||||
|
format: 'float',
|
||||||
|
description: '已批准内容百分比'
|
||||||
|
},
|
||||||
|
pending: {
|
||||||
|
type: 'number',
|
||||||
|
format: 'float',
|
||||||
|
description: '待审核内容百分比'
|
||||||
|
},
|
||||||
|
rejected: {
|
||||||
|
type: 'number',
|
||||||
|
format: 'float',
|
||||||
|
description: '已拒绝内容百分比'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
total: {
|
||||||
|
type: 'integer',
|
||||||
|
description: '内容总数'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'400': {
|
||||||
|
description: '请求参数错误',
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: {
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'500': {
|
||||||
|
description: '服务器错误',
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: {
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'/api/analytics/hot-keywords': {
|
||||||
|
get: {
|
||||||
|
summary: '获取热门关键词',
|
||||||
|
description: '返回按出现频率排序的热门关键词列表,包含关键词、出现次数、占比和情感分数',
|
||||||
|
tags: ['Analytics'],
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: 'timeRange',
|
||||||
|
in: 'query',
|
||||||
|
description: '时间范围(天)',
|
||||||
|
schema: {
|
||||||
|
type: 'integer',
|
||||||
|
enum: [7, 30, 90],
|
||||||
|
default: 30
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'projectId',
|
||||||
|
in: 'query',
|
||||||
|
description: '项目ID(可选)',
|
||||||
|
schema: {
|
||||||
|
type: 'string'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'platform',
|
||||||
|
in: 'query',
|
||||||
|
description: '平台(可选)',
|
||||||
|
schema: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['weibo', 'xiaohongshu', 'douyin', 'bilibili']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'limit',
|
||||||
|
in: 'query',
|
||||||
|
description: '返回关键词数量上限',
|
||||||
|
schema: {
|
||||||
|
type: 'integer',
|
||||||
|
default: 20
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
responses: {
|
||||||
|
'200': {
|
||||||
|
description: '成功获取热门关键词',
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
success: {
|
||||||
|
type: 'boolean',
|
||||||
|
example: true
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
keyword: {
|
||||||
|
type: 'string',
|
||||||
|
example: '质量'
|
||||||
|
},
|
||||||
|
count: {
|
||||||
|
type: 'integer',
|
||||||
|
example: 38
|
||||||
|
},
|
||||||
|
percentage: {
|
||||||
|
type: 'number',
|
||||||
|
format: 'float',
|
||||||
|
example: 19.0
|
||||||
|
},
|
||||||
|
sentiment_score: {
|
||||||
|
type: 'number',
|
||||||
|
format: 'float',
|
||||||
|
example: 0.7
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
total: {
|
||||||
|
type: 'integer',
|
||||||
|
example: 100
|
||||||
|
},
|
||||||
|
is_mock_data: {
|
||||||
|
type: 'boolean',
|
||||||
|
example: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'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.'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3044,9 +3304,14 @@ export const openAPISpec = {
|
|||||||
schema: {
|
schema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
success: { type: 'boolean', example: false },
|
success: {
|
||||||
error: { type: 'string', example: 'Failed to fetch popular posts data' },
|
type: 'boolean',
|
||||||
message: { type: 'string', example: 'Internal server error' }
|
example: false
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
type: 'string',
|
||||||
|
example: 'Failed to fetch hot keywords data'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3055,6 +3320,349 @@ export const openAPISpec = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
'/api/analytics/interaction-time': {
|
||||||
|
get: {
|
||||||
|
summary: '获取用户互动时间分析',
|
||||||
|
description: '返回按小时统计的用户互动数量和分布,帮助了解用户最活跃的时间段',
|
||||||
|
tags: ['Analytics'],
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: 'timeRange',
|
||||||
|
in: 'query',
|
||||||
|
description: '时间范围(天)',
|
||||||
|
schema: {
|
||||||
|
type: 'integer',
|
||||||
|
enum: [7, 30, 90],
|
||||||
|
default: 30
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'projectId',
|
||||||
|
in: 'query',
|
||||||
|
description: '项目ID(可选)',
|
||||||
|
schema: {
|
||||||
|
type: 'string'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'platform',
|
||||||
|
in: 'query',
|
||||||
|
description: '平台(可选)',
|
||||||
|
schema: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['weibo', 'xiaohongshu', 'douyin', 'bilibili']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'eventType',
|
||||||
|
in: 'query',
|
||||||
|
description: '互动事件类型',
|
||||||
|
schema: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['all', 'comment', 'like', 'view', 'share', 'follow'],
|
||||||
|
default: 'all'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
responses: {
|
||||||
|
'200': {
|
||||||
|
description: '成功获取互动时间分析数据',
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
success: {
|
||||||
|
type: 'boolean',
|
||||||
|
example: true
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
hour: {
|
||||||
|
type: 'integer',
|
||||||
|
example: 20
|
||||||
|
},
|
||||||
|
count: {
|
||||||
|
type: 'integer',
|
||||||
|
example: 256
|
||||||
|
},
|
||||||
|
percentage: {
|
||||||
|
type: 'number',
|
||||||
|
format: 'float',
|
||||||
|
example: 15.2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
total: {
|
||||||
|
type: 'integer',
|
||||||
|
example: 1680
|
||||||
|
},
|
||||||
|
peak_hour: {
|
||||||
|
type: 'integer',
|
||||||
|
example: 20
|
||||||
|
},
|
||||||
|
lowest_hour: {
|
||||||
|
type: 'integer',
|
||||||
|
example: 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'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 interaction time analysis data'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'/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: {
|
components: {
|
||||||
schemas: {
|
schemas: {
|
||||||
|
|||||||
@@ -3,497 +3,6 @@ import clickhouse from './clickhouse';
|
|||||||
import fs from 'fs/promises';
|
import fs from 'fs/promises';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
/**
|
|
||||||
* 初始化 Supabase (PostgreSQL) 数据库表
|
|
||||||
*/
|
|
||||||
export const initSupabaseTables = async () => {
|
|
||||||
try {
|
|
||||||
console.log('开始初始化 Supabase 数据表...');
|
|
||||||
|
|
||||||
// 创建用户扩展表
|
|
||||||
await supabase.rpc('create_user_profiles_if_not_exists');
|
|
||||||
|
|
||||||
// 创建项目表
|
|
||||||
await supabase.rpc('create_projects_table_if_not_exists');
|
|
||||||
|
|
||||||
// 创建网红(影响者)表
|
|
||||||
await supabase.rpc('create_influencers_table_if_not_exists');
|
|
||||||
|
|
||||||
// 创建项目-网红关联表
|
|
||||||
await supabase.rpc('create_project_influencers_table_if_not_exists');
|
|
||||||
|
|
||||||
// 创建帖子表
|
|
||||||
await supabase.rpc('create_posts_table_if_not_exists');
|
|
||||||
|
|
||||||
// 创建评论表
|
|
||||||
await supabase.rpc('create_comments_table_if_not_exists');
|
|
||||||
|
|
||||||
// 创建项目评论表
|
|
||||||
await supabase.rpc('create_project_comments_table_if_not_exists');
|
|
||||||
|
|
||||||
console.log('Supabase 数据表初始化完成');
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('初始化 Supabase 数据表失败:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 初始化 ClickHouse 数据库表
|
|
||||||
*/
|
|
||||||
export const initClickHouseTables = async () => {
|
|
||||||
try {
|
|
||||||
console.log('开始初始化 ClickHouse 数据表...');
|
|
||||||
|
|
||||||
// Create events table for general analytics tracking
|
|
||||||
await clickhouse.query({
|
|
||||||
query: `
|
|
||||||
CREATE TABLE IF NOT EXISTS promote.events (
|
|
||||||
event_id UUID DEFAULT generateUUIDv4(),
|
|
||||||
event_type String,
|
|
||||||
influencer_id String DEFAULT '',
|
|
||||||
post_id String DEFAULT '',
|
|
||||||
project_id String DEFAULT '',
|
|
||||||
timestamp DateTime64(3) DEFAULT now64(3),
|
|
||||||
metric_name String DEFAULT '',
|
|
||||||
metric_value Int64 DEFAULT 0,
|
|
||||||
metric_total Int64 DEFAULT 0,
|
|
||||||
recorded_by String DEFAULT '',
|
|
||||||
extra_data String DEFAULT ''
|
|
||||||
)
|
|
||||||
ENGINE = MergeTree()
|
|
||||||
ORDER BY (event_type, influencer_id, post_id, timestamp)
|
|
||||||
`
|
|
||||||
});
|
|
||||||
console.log(' - Created events table');
|
|
||||||
|
|
||||||
// 创建事件表
|
|
||||||
await clickhouse.query({
|
|
||||||
query: `
|
|
||||||
CREATE TABLE IF NOT EXISTS events (
|
|
||||||
event_id UUID DEFAULT generateUUIDv4(),
|
|
||||||
project_id UUID,
|
|
||||||
influencer_id UUID,
|
|
||||||
post_id UUID NULL,
|
|
||||||
platform String,
|
|
||||||
event_type Enum(
|
|
||||||
'follower_change' = 1,
|
|
||||||
'post_like_change' = 2,
|
|
||||||
'post_view_change' = 3,
|
|
||||||
'click' = 4,
|
|
||||||
'comment' = 5,
|
|
||||||
'share' = 6,
|
|
||||||
'project_comment' = 7
|
|
||||||
),
|
|
||||||
metric_value Int64,
|
|
||||||
event_metadata String,
|
|
||||||
timestamp DateTime DEFAULT now()
|
|
||||||
) ENGINE = MergeTree()
|
|
||||||
PARTITION BY toYYYYMM(timestamp)
|
|
||||||
ORDER BY (platform, influencer_id, post_id, event_type, timestamp)
|
|
||||||
`
|
|
||||||
});
|
|
||||||
|
|
||||||
// 创建统计视图 - 按天统计
|
|
||||||
await clickhouse.query({
|
|
||||||
query: `
|
|
||||||
CREATE MATERIALIZED VIEW IF NOT EXISTS daily_stats
|
|
||||||
ENGINE = SummingMergeTree()
|
|
||||||
PARTITION BY toYYYYMM(date)
|
|
||||||
ORDER BY (date, platform, influencer_id, event_type)
|
|
||||||
AS SELECT
|
|
||||||
toDate(timestamp) AS date,
|
|
||||||
platform,
|
|
||||||
influencer_id,
|
|
||||||
event_type,
|
|
||||||
SUM(metric_value) AS total_value,
|
|
||||||
COUNT(*) AS event_count
|
|
||||||
FROM events
|
|
||||||
GROUP BY date, platform, influencer_id, event_type
|
|
||||||
`
|
|
||||||
});
|
|
||||||
|
|
||||||
// 创建统计视图 - 按月统计
|
|
||||||
await clickhouse.query({
|
|
||||||
query: `
|
|
||||||
CREATE MATERIALIZED VIEW IF NOT EXISTS monthly_stats
|
|
||||||
ENGINE = SummingMergeTree()
|
|
||||||
ORDER BY (month, platform, influencer_id, event_type)
|
|
||||||
AS SELECT
|
|
||||||
toStartOfMonth(timestamp) AS month,
|
|
||||||
platform,
|
|
||||||
influencer_id,
|
|
||||||
event_type,
|
|
||||||
SUM(metric_value) AS total_value,
|
|
||||||
COUNT(*) AS event_count
|
|
||||||
FROM events
|
|
||||||
GROUP BY month, platform, influencer_id, event_type
|
|
||||||
`
|
|
||||||
});
|
|
||||||
|
|
||||||
// 创建帖子互动统计视图
|
|
||||||
await clickhouse.query({
|
|
||||||
query: `
|
|
||||||
CREATE MATERIALIZED VIEW IF NOT EXISTS post_interaction_stats
|
|
||||||
ENGINE = SummingMergeTree()
|
|
||||||
ORDER BY (post_id, event_type, date)
|
|
||||||
AS SELECT
|
|
||||||
post_id,
|
|
||||||
event_type,
|
|
||||||
toDate(timestamp) AS date,
|
|
||||||
SUM(metric_value) AS value,
|
|
||||||
COUNT(*) AS count
|
|
||||||
FROM events
|
|
||||||
WHERE post_id IS NOT NULL
|
|
||||||
GROUP BY post_id, event_type, date
|
|
||||||
`
|
|
||||||
});
|
|
||||||
|
|
||||||
// 创建项目互动统计视图
|
|
||||||
await clickhouse.query({
|
|
||||||
query: `
|
|
||||||
CREATE MATERIALIZED VIEW IF NOT EXISTS project_interaction_stats
|
|
||||||
ENGINE = SummingMergeTree()
|
|
||||||
ORDER BY (project_id, event_type, date)
|
|
||||||
AS SELECT
|
|
||||||
project_id,
|
|
||||||
event_type,
|
|
||||||
toDate(timestamp) AS date,
|
|
||||||
SUM(metric_value) AS value,
|
|
||||||
COUNT(*) AS count
|
|
||||||
FROM events
|
|
||||||
WHERE project_id IS NOT NULL AND event_type = 'project_comment'
|
|
||||||
GROUP BY project_id, event_type, date
|
|
||||||
`
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('ClickHouse 数据表初始化完成');
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('初始化 ClickHouse 数据表失败:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 初始化 Supabase 存储函数
|
|
||||||
*/
|
|
||||||
export const initSupabaseFunctions = async () => {
|
|
||||||
try {
|
|
||||||
console.log('开始初始化 Supabase 存储过程...');
|
|
||||||
|
|
||||||
// 创建用户简档表的存储过程
|
|
||||||
await supabase.rpc('create_function_create_user_profiles_if_not_exists');
|
|
||||||
|
|
||||||
// 创建项目表的存储过程
|
|
||||||
await supabase.rpc('create_function_create_projects_table_if_not_exists');
|
|
||||||
|
|
||||||
// 创建网红表的存储过程
|
|
||||||
await supabase.rpc('create_function_create_influencers_table_if_not_exists');
|
|
||||||
|
|
||||||
// 创建项目-网红关联表的存储过程
|
|
||||||
await supabase.rpc('create_function_create_project_influencers_table_if_not_exists');
|
|
||||||
|
|
||||||
// 创建帖子表的存储过程
|
|
||||||
await supabase.rpc('create_function_create_posts_table_if_not_exists');
|
|
||||||
|
|
||||||
// 创建评论表的存储过程
|
|
||||||
await supabase.rpc('create_function_create_comments_table_if_not_exists');
|
|
||||||
|
|
||||||
// 创建项目评论表的存储过程
|
|
||||||
await supabase.rpc('create_function_create_project_comments_table_if_not_exists');
|
|
||||||
|
|
||||||
// 创建评论相关的SQL函数
|
|
||||||
console.log('创建评论相关的SQL函数...');
|
|
||||||
const commentsSQL = await fs.readFile(
|
|
||||||
path.join(__dirname, 'supabase-comments-functions.sql'),
|
|
||||||
'utf8'
|
|
||||||
);
|
|
||||||
|
|
||||||
// 使用Supabase执行SQL
|
|
||||||
const { error: commentsFunctionsError } = await supabase.rpc(
|
|
||||||
'pgclient_execute',
|
|
||||||
{ query: commentsSQL }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (commentsFunctionsError) {
|
|
||||||
console.error('创建评论SQL函数失败:', commentsFunctionsError);
|
|
||||||
} else {
|
|
||||||
console.log('评论SQL函数创建成功');
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Supabase 存储过程初始化完成');
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('初始化 Supabase 存储过程失败:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建测试数据
|
|
||||||
*/
|
|
||||||
export const createSampleData = async () => {
|
|
||||||
try {
|
|
||||||
console.log('开始创建测试数据...');
|
|
||||||
|
|
||||||
// 创建测试用户
|
|
||||||
const { data: user, error: userError } = await supabase.auth.admin.createUser({
|
|
||||||
email: 'test@example.com',
|
|
||||||
password: 'password123',
|
|
||||||
user_metadata: {
|
|
||||||
full_name: '测试用户'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (userError) {
|
|
||||||
console.error('创建测试用户失败:', userError);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建测试项目
|
|
||||||
const { data: project, error: projectError } = await supabase
|
|
||||||
.from('projects')
|
|
||||||
.insert({
|
|
||||||
name: '测试营销活动',
|
|
||||||
description: '这是一个测试营销活动',
|
|
||||||
created_by: user.user.id
|
|
||||||
})
|
|
||||||
.select()
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (projectError) {
|
|
||||||
console.error('创建测试项目失败:', projectError);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建项目评论
|
|
||||||
await supabase
|
|
||||||
.from('project_comments')
|
|
||||||
.insert([
|
|
||||||
{
|
|
||||||
project_id: project.id,
|
|
||||||
user_id: user.user.id,
|
|
||||||
content: '这是对项目的一条测试评论',
|
|
||||||
sentiment_score: 0.8
|
|
||||||
},
|
|
||||||
{
|
|
||||||
project_id: project.id,
|
|
||||||
user_id: user.user.id,
|
|
||||||
content: '这个项目很有前景',
|
|
||||||
sentiment_score: 0.9
|
|
||||||
},
|
|
||||||
{
|
|
||||||
project_id: project.id,
|
|
||||||
user_id: user.user.id,
|
|
||||||
content: '需要关注这个项目的进展',
|
|
||||||
sentiment_score: 0.7
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 创建测试网红
|
|
||||||
const platforms = ['youtube', 'instagram', 'tiktok'];
|
|
||||||
const influencers = [];
|
|
||||||
|
|
||||||
for (let i = 1; i <= 10; i++) {
|
|
||||||
const platform = platforms[Math.floor(Math.random() * platforms.length)];
|
|
||||||
|
|
||||||
const { data: influencer, error: influencerError } = await supabase
|
|
||||||
.from('influencers')
|
|
||||||
.insert({
|
|
||||||
name: `测试网红 ${i}`,
|
|
||||||
platform,
|
|
||||||
profile_url: `https://${platform}.com/user${i}`,
|
|
||||||
external_id: `user_${platform}_${i}`,
|
|
||||||
followers_count: Math.floor(Math.random() * 1000000) + 1000,
|
|
||||||
video_count: Math.floor(Math.random() * 500) + 10
|
|
||||||
})
|
|
||||||
.select()
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (influencerError) {
|
|
||||||
console.error(`创建测试网红 ${i} 失败:`, influencerError);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
influencers.push(influencer);
|
|
||||||
|
|
||||||
// 将网红添加到项目
|
|
||||||
await supabase
|
|
||||||
.from('project_influencers')
|
|
||||||
.insert({
|
|
||||||
project_id: project.id,
|
|
||||||
influencer_id: influencer.influencer_id
|
|
||||||
});
|
|
||||||
|
|
||||||
// 为每个网红创建 3-5 个帖子
|
|
||||||
const postCount = Math.floor(Math.random() * 3) + 3;
|
|
||||||
|
|
||||||
for (let j = 1; j <= postCount; j++) {
|
|
||||||
const { data: post, error: postError } = await supabase
|
|
||||||
.from('posts')
|
|
||||||
.insert({
|
|
||||||
influencer_id: influencer.influencer_id,
|
|
||||||
platform,
|
|
||||||
post_url: `https://${platform}.com/user${i}/post${j}`,
|
|
||||||
title: `测试帖子 ${j} - 由 ${influencer.name} 发布`,
|
|
||||||
description: `这是一个测试帖子的描述 ${j}`,
|
|
||||||
published_at: new Date(Date.now() - Math.floor(Math.random() * 30) * 24 * 60 * 60 * 1000).toISOString()
|
|
||||||
})
|
|
||||||
.select()
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (postError) {
|
|
||||||
console.error(`创建测试帖子 ${j} 失败:`, postError);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 为每个帖子创建 2-10 个评论
|
|
||||||
const commentCount = Math.floor(Math.random() * 9) + 2;
|
|
||||||
|
|
||||||
for (let k = 1; k <= commentCount; k++) {
|
|
||||||
await supabase
|
|
||||||
.from('comments')
|
|
||||||
.insert({
|
|
||||||
post_id: post.post_id,
|
|
||||||
user_id: user.user.id,
|
|
||||||
content: `这是对帖子 ${post.title} 的测试评论 ${k}`,
|
|
||||||
sentiment_score: (Math.random() * 2 - 1) // -1 到 1 之间的随机数
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建 ClickHouse 事件数据
|
|
||||||
// 粉丝变化事件
|
|
||||||
await clickhouse.query({
|
|
||||||
query: `
|
|
||||||
INSERT INTO events (
|
|
||||||
project_id,
|
|
||||||
influencer_id,
|
|
||||||
platform,
|
|
||||||
event_type,
|
|
||||||
metric_value,
|
|
||||||
event_metadata
|
|
||||||
) VALUES (?, ?, ?, 'follower_change', ?, ?)
|
|
||||||
`,
|
|
||||||
values: [
|
|
||||||
project.id,
|
|
||||||
influencer.influencer_id,
|
|
||||||
platform,
|
|
||||||
Math.floor(Math.random() * 1000) - 200, // -200 到 800 之间的随机数
|
|
||||||
JSON.stringify({ source: 'api_crawler' })
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
// 帖子点赞变化事件
|
|
||||||
await clickhouse.query({
|
|
||||||
query: `
|
|
||||||
INSERT INTO events (
|
|
||||||
project_id,
|
|
||||||
influencer_id,
|
|
||||||
post_id,
|
|
||||||
platform,
|
|
||||||
event_type,
|
|
||||||
metric_value,
|
|
||||||
event_metadata
|
|
||||||
) VALUES (?, ?, ?, ?, 'post_like_change', ?, ?)
|
|
||||||
`,
|
|
||||||
values: [
|
|
||||||
project.id,
|
|
||||||
influencer.influencer_id,
|
|
||||||
post.post_id,
|
|
||||||
platform,
|
|
||||||
Math.floor(Math.random() * 500) + 10, // 10 到 510 之间的随机数
|
|
||||||
JSON.stringify({ source: 'api_crawler' })
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
// 帖子观看数变化事件
|
|
||||||
await clickhouse.query({
|
|
||||||
query: `
|
|
||||||
INSERT INTO events (
|
|
||||||
project_id,
|
|
||||||
influencer_id,
|
|
||||||
post_id,
|
|
||||||
platform,
|
|
||||||
event_type,
|
|
||||||
metric_value,
|
|
||||||
event_metadata
|
|
||||||
) VALUES (?, ?, ?, ?, 'post_view_change', ?, ?)
|
|
||||||
`,
|
|
||||||
values: [
|
|
||||||
project.id,
|
|
||||||
influencer.influencer_id,
|
|
||||||
post.post_id,
|
|
||||||
platform,
|
|
||||||
Math.floor(Math.random() * 5000) + 100, // 100 到 5100 之间的随机数
|
|
||||||
JSON.stringify({ source: 'api_crawler' })
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
// 互动事件
|
|
||||||
const interactionTypes = ['click', 'comment', 'share'];
|
|
||||||
const interactionType = interactionTypes[Math.floor(Math.random() * interactionTypes.length)];
|
|
||||||
|
|
||||||
await clickhouse.query({
|
|
||||||
query: `
|
|
||||||
INSERT INTO events (
|
|
||||||
project_id,
|
|
||||||
influencer_id,
|
|
||||||
post_id,
|
|
||||||
platform,
|
|
||||||
event_type,
|
|
||||||
metric_value,
|
|
||||||
event_metadata
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
||||||
`,
|
|
||||||
values: [
|
|
||||||
project.id,
|
|
||||||
influencer.influencer_id,
|
|
||||||
post.post_id,
|
|
||||||
platform,
|
|
||||||
interactionType,
|
|
||||||
1,
|
|
||||||
JSON.stringify({
|
|
||||||
ip: '192.168.1.' + Math.floor(Math.random() * 255),
|
|
||||||
user_agent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
|
|
||||||
})
|
|
||||||
]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建项目评论事件
|
|
||||||
for (let i = 1; i <= 5; i++) {
|
|
||||||
await clickhouse.query({
|
|
||||||
query: `
|
|
||||||
INSERT INTO events (
|
|
||||||
project_id,
|
|
||||||
event_type,
|
|
||||||
metric_value,
|
|
||||||
event_metadata
|
|
||||||
) VALUES (?, 'project_comment', ?, ?)
|
|
||||||
`,
|
|
||||||
values: [
|
|
||||||
project.id,
|
|
||||||
1,
|
|
||||||
JSON.stringify({
|
|
||||||
user_id: user.user.id,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
comment: `项目评论事件 ${i}`
|
|
||||||
})
|
|
||||||
]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('测试数据创建完成');
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('创建测试数据失败:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查数据库连接
|
* 检查数据库连接
|
||||||
*/
|
*/
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user