From e49b3a21722cd3469a58f20c2e0c0ceb8fd69997 Mon Sep 17 00:00:00 2001 From: William Tso Date: Mon, 10 Mar 2025 18:03:47 +0800 Subject: [PATCH] an --- backend/dist/config/index.js | 51 - .../dist/controllers/commentsController.js | 111 - .../dist/controllers/influencersController.js | 116 - backend/dist/index.js | 163 -- backend/dist/middlewares/auth.js | 85 - backend/dist/routes/analytics.js | 453 ---- backend/dist/routes/auth.js | 140 -- backend/dist/routes/comments.js | 12 - backend/dist/routes/community.js | 649 ------ backend/dist/routes/influencers.js | 10 - backend/dist/routes/posts.js | 584 ------ backend/dist/routes/projectComments.js | 395 ---- backend/dist/swagger/index.js | 1863 ----------------- backend/dist/utils/clickhouse.js | 87 - backend/dist/utils/initDatabase.js | 492 ----- backend/dist/utils/queue.js | 158 -- backend/dist/utils/redis.js | 80 - backend/dist/utils/supabase.js | 18 - backend/src/index.ts | 29 +- backend/src/routes/analytics.ts | 359 ++++ backend/src/utils/clickhouseHelper.ts | 0 backend/src/utils/initDatabase.ts | 22 + backend/src/utils/scheduledTasks.ts | 406 ++++ 23 files changed, 802 insertions(+), 5481 deletions(-) delete mode 100644 backend/dist/config/index.js delete mode 100644 backend/dist/controllers/commentsController.js delete mode 100644 backend/dist/controllers/influencersController.js delete mode 100644 backend/dist/index.js delete mode 100644 backend/dist/middlewares/auth.js delete mode 100644 backend/dist/routes/analytics.js delete mode 100644 backend/dist/routes/auth.js delete mode 100644 backend/dist/routes/comments.js delete mode 100644 backend/dist/routes/community.js delete mode 100644 backend/dist/routes/influencers.js delete mode 100644 backend/dist/routes/posts.js delete mode 100644 backend/dist/routes/projectComments.js delete mode 100644 backend/dist/swagger/index.js delete mode 100644 backend/dist/utils/clickhouse.js delete mode 100644 backend/dist/utils/initDatabase.js delete mode 100644 backend/dist/utils/queue.js delete mode 100644 backend/dist/utils/redis.js delete mode 100644 backend/dist/utils/supabase.js create mode 100644 backend/src/utils/clickhouseHelper.ts create mode 100644 backend/src/utils/scheduledTasks.ts diff --git a/backend/dist/config/index.js b/backend/dist/config/index.js deleted file mode 100644 index db1de1e..0000000 --- a/backend/dist/config/index.js +++ /dev/null @@ -1,51 +0,0 @@ -"use strict"; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.config = void 0; -const dotenv_1 = __importDefault(require("dotenv")); -const path_1 = require("path"); -// Load environment variables from .env file -dotenv_1.default.config({ path: (0, path_1.join)(__dirname, '../../.env') }); -exports.config = { - port: process.env.PORT || 4000, - // Supabase configuration - supabase: { - url: process.env.SUPABASE_URL || '', - key: process.env.SUPABASE_KEY || '', - anonKey: process.env.SUPABASE_ANON_KEY || '', - }, - // Redis configuration - redis: { - host: process.env.REDIS_HOST || 'localhost', - port: parseInt(process.env.REDIS_PORT || '6379', 10), - password: process.env.REDIS_PASSWORD || '', - }, - // ClickHouse configuration - clickhouse: { - host: process.env.CLICKHOUSE_HOST || 'localhost', - port: process.env.CLICKHOUSE_PORT || '8123', - user: process.env.CLICKHOUSE_USER || 'admin', - password: process.env.CLICKHOUSE_PASSWORD || 'your_secure_password', - database: process.env.CLICKHOUSE_DATABASE || 'promote', - }, - // BullMQ configuration - bull: { - redis: { - host: process.env.BULL_REDIS_HOST || 'localhost', - port: parseInt(process.env.BULL_REDIS_PORT || '6379', 10), - password: process.env.BULL_REDIS_PASSWORD || '', - }, - }, - // JWT configuration - jwt: { - secret: process.env.JWT_SECRET || 'your-secret-key', - expiresIn: process.env.JWT_EXPIRES_IN || '7d', - }, - // Domain configuration - domain: process.env.DOMAIN || 'upj.to', - // Enabled routes - enabledRoutes: process.env.ENABLED_ROUTES || 'all', -}; -exports.default = exports.config; diff --git a/backend/dist/controllers/commentsController.js b/backend/dist/controllers/commentsController.js deleted file mode 100644 index dd7ab7c..0000000 --- a/backend/dist/controllers/commentsController.js +++ /dev/null @@ -1,111 +0,0 @@ -"use strict"; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.deleteComment = exports.createComment = exports.getComments = void 0; -const supabase_1 = __importDefault(require("../utils/supabase")); -const getComments = async (c) => { - try { - const { post_id, limit = '10', offset = '0' } = c.req.query(); - let query; - if (post_id) { - // 获取特定帖子的评论 - query = supabase_1.default.rpc('get_comments_for_post', { post_id_param: post_id }); - } - else { - // 获取所有评论 - query = supabase_1.default.rpc('get_comments_with_posts'); - } - // 应用分页 - query = query.range(Number(offset), Number(offset) + Number(limit) - 1); - const { data: comments, error, count } = await query; - if (error) { - return c.json({ error: error.message }, 500); - } - return c.json({ - comments, - count, - limit: Number(limit), - offset: Number(offset) - }); - } - catch (error) { - return c.json({ error: 'Internal server error' }, 500); - } -}; -exports.getComments = getComments; -const createComment = async (c) => { - try { - const { post_id, content } = await c.req.json(); - const user_id = c.get('user')?.id; - if (!user_id) { - return c.json({ error: 'Unauthorized' }, 401); - } - const { data: comment, error } = await supabase_1.default - .from('comments') - .insert({ - post_id, - content, - user_id - }) - .select(` - comment_id, - content, - sentiment_score, - created_at, - updated_at, - post_id, - user_id - `) - .single(); - if (error) { - return c.json({ error: error.message }, 500); - } - // 获取用户信息 - const { data: userProfile, error: userError } = await supabase_1.default - .from('user_profiles') - .select('id, full_name, avatar_url') - .eq('id', user_id) - .single(); - if (!userError && userProfile) { - comment.user_profile = userProfile; - } - return c.json(comment, 201); - } - catch (error) { - return c.json({ error: 'Internal server error' }, 500); - } -}; -exports.createComment = createComment; -const deleteComment = async (c) => { - try { - const { comment_id } = c.req.param(); - const user_id = c.get('user')?.id; - if (!user_id) { - return c.json({ error: 'Unauthorized' }, 401); - } - // Check if the comment belongs to the user - const { data: comment, error: fetchError } = await supabase_1.default - .from('comments') - .select() - .eq('comment_id', comment_id) - .eq('user_id', user_id) - .single(); - if (fetchError || !comment) { - return c.json({ error: 'Comment not found or unauthorized' }, 404); - } - const { error: deleteError } = await supabase_1.default - .from('comments') - .delete() - .eq('comment_id', comment_id); - if (deleteError) { - return c.json({ error: deleteError.message }, 500); - } - return c.body(null, 204); - } - catch (error) { - return c.json({ error: 'Internal server error' }, 500); - } -}; -exports.deleteComment = deleteComment; diff --git a/backend/dist/controllers/influencersController.js b/backend/dist/controllers/influencersController.js deleted file mode 100644 index 704a4ff..0000000 --- a/backend/dist/controllers/influencersController.js +++ /dev/null @@ -1,116 +0,0 @@ -"use strict"; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.getInfluencerStats = exports.getInfluencerById = exports.getInfluencers = void 0; -const supabase_1 = __importDefault(require("../utils/supabase")); -const getInfluencers = async (c) => { - try { - const { platform, limit = '10', offset = '0', min_followers, max_followers, sort_by = 'followers_count', sort_order = 'desc' } = c.req.query(); - let query = supabase_1.default - .from('influencers') - .select(` - influencer_id, - name, - platform, - profile_url, - followers_count, - video_count, - platform_count, - created_at, - updated_at - `); - // Apply filters - if (platform) { - query = query.eq('platform', platform); - } - if (min_followers) { - query = query.gte('followers_count', Number(min_followers)); - } - if (max_followers) { - query = query.lte('followers_count', Number(max_followers)); - } - // Apply sorting - if (sort_by && ['followers_count', 'video_count', 'created_at'].includes(sort_by)) { - query = query.order(sort_by, { ascending: sort_order === 'asc' }); - } - // Apply pagination - query = query.range(Number(offset), Number(offset) + Number(limit) - 1); - const { data: influencers, error, count } = await query; - if (error) { - return c.json({ error: error.message }, 500); - } - return c.json({ - influencers, - count, - limit: Number(limit), - offset: Number(offset) - }); - } - catch (error) { - return c.json({ error: 'Internal server error' }, 500); - } -}; -exports.getInfluencers = getInfluencers; -const getInfluencerById = async (c) => { - try { - const { influencer_id } = c.req.param(); - const { data: influencer, error } = await supabase_1.default - .from('influencers') - .select(` - influencer_id, - name, - platform, - profile_url, - followers_count, - video_count, - platform_count, - created_at, - updated_at, - posts ( - post_id, - title, - description, - published_at - ) - `) - .eq('influencer_id', influencer_id) - .single(); - if (error) { - return c.json({ error: 'Influencer not found' }, 404); - } - return c.json(influencer); - } - catch (error) { - return c.json({ error: 'Internal server error' }, 500); - } -}; -exports.getInfluencerById = getInfluencerById; -const getInfluencerStats = async (c) => { - try { - const { platform } = c.req.query(); - let query = supabase_1.default - .from('influencers') - .select('platform, followers_count, video_count'); - if (platform) { - query = query.eq('platform', platform); - } - const { data: stats, error } = await query; - if (error) { - return c.json({ error: error.message }, 500); - } - const aggregatedStats = { - total_influencers: stats.length, - total_followers: stats.reduce((sum, item) => sum + (item.followers_count || 0), 0), - total_videos: stats.reduce((sum, item) => sum + (item.video_count || 0), 0), - average_followers: Math.round(stats.reduce((sum, item) => sum + (item.followers_count || 0), 0) / (stats.length || 1)), - average_videos: Math.round(stats.reduce((sum, item) => sum + (item.video_count || 0), 0) / (stats.length || 1)) - }; - return c.json(aggregatedStats); - } - catch (error) { - return c.json({ error: 'Internal server error' }, 500); - } -}; -exports.getInfluencerStats = getInfluencerStats; diff --git a/backend/dist/index.js b/backend/dist/index.js deleted file mode 100644 index 4510096..0000000 --- a/backend/dist/index.js +++ /dev/null @@ -1,163 +0,0 @@ -"use strict"; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -const node_server_1 = require("@hono/node-server"); -const hono_1 = require("hono"); -const cors_1 = require("hono/cors"); -const logger_1 = require("hono/logger"); -const config_1 = __importDefault(require("./config")); -const auth_1 = __importDefault(require("./routes/auth")); -const analytics_1 = __importDefault(require("./routes/analytics")); -const community_1 = __importDefault(require("./routes/community")); -const posts_1 = __importDefault(require("./routes/posts")); -const projectComments_1 = __importDefault(require("./routes/projectComments")); -const comments_1 = __importDefault(require("./routes/comments")); -const influencers_1 = __importDefault(require("./routes/influencers")); -const redis_1 = require("./utils/redis"); -const clickhouse_1 = require("./utils/clickhouse"); -const queue_1 = require("./utils/queue"); -const initDatabase_1 = require("./utils/initDatabase"); -const swagger_1 = require("./swagger"); -// Create Hono app -const app = new hono_1.Hono(); -// Middleware -app.use('*', (0, logger_1.logger)()); -app.use('*', (0, cors_1.cors)({ - origin: '*', - allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], - allowHeaders: ['Content-Type', 'Authorization'], - exposeHeaders: ['Content-Length'], - maxAge: 86400, -})); -// Health check route -app.get('/', (c) => { - return c.json({ - status: 'ok', - message: 'Promote API is running', - version: '1.0.0', - }); -}); -// 数据库初始化路由 -app.post('/api/admin/init-db', async (c) => { - try { - const result = await (0, initDatabase_1.initDatabase)(); - return c.json({ - success: result, - message: result ? 'Database initialized successfully' : 'Database initialization failed' - }); - } - catch (error) { - console.error('Error initializing database:', error); - return c.json({ - success: false, - message: 'Error initializing database', - error: error instanceof Error ? error.message : String(error) - }, 500); - } -}); -// 创建测试数据路由 -app.post('/api/admin/create-sample-data', async (c) => { - try { - const result = await (0, initDatabase_1.createSampleData)(); - return c.json({ - success: result, - message: result ? 'Sample data created successfully' : 'Sample data creation failed' - }); - } - catch (error) { - console.error('Error creating sample data:', error); - return c.json({ - success: false, - message: 'Error creating sample data', - error: error instanceof Error ? error.message : String(error) - }, 500); - } -}); -// Routes -app.route('/api/auth', auth_1.default); -app.route('/api/analytics', analytics_1.default); -app.route('/api/community', community_1.default); -app.route('/api/posts', posts_1.default); -app.route('/api/project-comments', projectComments_1.default); -app.route('/api/comments', comments_1.default); -app.route('/api/influencers', influencers_1.default); -// Swagger UI -const swaggerApp = (0, swagger_1.createSwaggerUI)(); -app.route('', swaggerApp); -// Initialize services and start server -const startServer = async () => { - try { - // Connect to Redis - try { - await (0, redis_1.connectRedis)(); - console.log('Connected to Redis'); - } - catch (error) { - console.error('Failed to connect to Redis:', error); - console.log('Continuing with mock Redis client...'); - } - // Initialize ClickHouse - try { - await (0, clickhouse_1.initClickHouse)(); - console.log('ClickHouse initialized'); - } - catch (error) { - console.error('Failed to initialize ClickHouse:', error); - console.log('Continuing with limited analytics functionality...'); - } - // 检查数据库连接,但不自动初始化或修改数据库 - try { - await (0, initDatabase_1.checkDatabaseConnection)(); - } - catch (error) { - console.error('Database connection check failed:', error); - console.log('Some features may not work correctly if database is not properly set up'); - } - console.log('NOTICE: Database will NOT be automatically initialized on startup'); - console.log('Use /api/admin/init-db endpoint to manually initialize the database if needed'); - // Initialize BullMQ workers - let workers; - try { - workers = (0, queue_1.initWorkers)(); - console.log('BullMQ workers initialized'); - } - catch (error) { - console.error('Failed to initialize BullMQ workers:', error); - console.log('Background processing will not be available...'); - workers = { analyticsWorker: null, notificationsWorker: null }; - } - // Start server - const port = Number(config_1.default.port); - console.log(`Server starting on port ${port}...`); - (0, node_server_1.serve)({ - fetch: app.fetch, - port, - }); - console.log(`Server running at http://localhost:${port}`); - console.log(`Swagger UI available at http://localhost:${port}/swagger`); - console.log(`Initialize database at http://localhost:${port}/api/admin/init-db (POST)`); - console.log(`Create sample data at http://localhost:${port}/api/admin/create-sample-data (POST)`); - // Handle graceful shutdown - const shutdown = async () => { - console.log('Shutting down server...'); - // Close workers if they exist - if (workers.analyticsWorker) { - await workers.analyticsWorker.close(); - } - if (workers.notificationsWorker) { - await workers.notificationsWorker.close(); - } - process.exit(0); - }; - process.on('SIGINT', shutdown); - process.on('SIGTERM', shutdown); - } - catch (error) { - console.error('Failed to start server:', error); - process.exit(1); - } -}; -// Start the server -startServer(); diff --git a/backend/dist/middlewares/auth.js b/backend/dist/middlewares/auth.js deleted file mode 100644 index dc2997c..0000000 --- a/backend/dist/middlewares/auth.js +++ /dev/null @@ -1,85 +0,0 @@ -"use strict"; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.verifySupabaseToken = exports.generateToken = exports.authMiddleware = void 0; -const jsonwebtoken_1 = __importDefault(require("jsonwebtoken")); -const config_1 = __importDefault(require("../config")); -const supabase_1 = __importDefault(require("../utils/supabase")); -// Middleware to verify JWT token -const authMiddleware = async (c, next) => { - try { - // Get authorization header - const authHeader = c.req.header('Authorization'); - if (!authHeader || !authHeader.startsWith('Bearer ')) { - return c.json({ error: 'Unauthorized: No token provided' }, 401); - } - // Extract token - const token = authHeader.split(' ')[1]; - try { - // 验证 JWT token - const decoded = jsonwebtoken_1.default.verify(token, config_1.default.jwt.secret); - // 特殊处理 Swagger 测试 token - if (decoded.sub === 'swagger-test-user' && decoded.email === 'swagger@test.com') { - // 为 Swagger 测试设置一个模拟用户 - c.set('user', { - id: 'swagger-test-user', - email: 'swagger@test.com', - name: 'Swagger Test User' - }); - // 继续到下一个中间件或路由处理器 - await next(); - return; - } - // 设置用户信息到上下文 - c.set('user', { - id: decoded.sub, - email: decoded.email - }); - // 继续到下一个中间件或路由处理器 - await next(); - } - catch (jwtError) { - if (jwtError instanceof jsonwebtoken_1.default.JsonWebTokenError) { - return c.json({ error: 'Unauthorized: Invalid token' }, 401); - } - if (jwtError instanceof jsonwebtoken_1.default.TokenExpiredError) { - return c.json({ error: 'Unauthorized: Token expired' }, 401); - } - throw jwtError; - } - } - catch (error) { - console.error('Auth middleware error:', error); - return c.json({ error: 'Internal server error' }, 500); - } -}; -exports.authMiddleware = authMiddleware; -// Generate JWT token -const generateToken = (userId, email) => { - const secret = config_1.default.jwt.secret; - const expiresIn = config_1.default.jwt.expiresIn; - return jsonwebtoken_1.default.sign({ - sub: userId, - email, - }, secret, { - expiresIn, - }); -}; -exports.generateToken = generateToken; -// Verify Supabase token -const verifySupabaseToken = async (token) => { - try { - const { data, error } = await supabase_1.default.auth.getUser(token); - if (error || !data.user) { - return null; - } - return data.user; - } - catch (error) { - console.error('Supabase token verification error:', error); - return null; - } -}; -exports.verifySupabaseToken = verifySupabaseToken; diff --git a/backend/dist/routes/analytics.js b/backend/dist/routes/analytics.js deleted file mode 100644 index 4bcc780..0000000 --- a/backend/dist/routes/analytics.js +++ /dev/null @@ -1,453 +0,0 @@ -"use strict"; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -const hono_1 = require("hono"); -const auth_1 = require("../middlewares/auth"); -const clickhouse_1 = __importDefault(require("../utils/clickhouse")); -const queue_1 = require("../utils/queue"); -const redis_1 = require("../utils/redis"); -const supabase_1 = __importDefault(require("../utils/supabase")); -const analyticsRouter = new hono_1.Hono(); -// Apply auth middleware to all routes -analyticsRouter.use('*', auth_1.authMiddleware); -// Track a view event -analyticsRouter.post('/view', async (c) => { - try { - const { content_id } = await c.req.json(); - const user = c.get('user'); - if (!content_id) { - return c.json({ error: 'Content ID is required' }, 400); - } - // Get IP and user agent - const ip = c.req.header('x-forwarded-for') || c.req.header('x-real-ip') || '0.0.0.0'; - const userAgent = c.req.header('user-agent') || 'unknown'; - // Insert view event into ClickHouse - await clickhouse_1.default.query({ - query: ` - INSERT INTO promote.view_events (user_id, content_id, ip, user_agent) - VALUES (?, ?, ?, ?) - `, - values: [ - user.id, - content_id, - ip, - userAgent - ] - }); - // Queue analytics processing job - await (0, queue_1.addAnalyticsJob)('process_views', { - user_id: user.id, - content_id, - timestamp: new Date().toISOString() - }); - // Increment view count in Redis cache - const redis = await (0, redis_1.getRedisClient)(); - await redis.incr(`views:${content_id}`); - return c.json({ message: 'View tracked successfully' }); - } - catch (error) { - console.error('View tracking error:', error); - return c.json({ error: 'Internal server error' }, 500); - } -}); -// Track a like event -analyticsRouter.post('/like', async (c) => { - try { - const { content_id, action } = await c.req.json(); - const user = c.get('user'); - if (!content_id || !action) { - return c.json({ error: 'Content ID and action are required' }, 400); - } - if (action !== 'like' && action !== 'unlike') { - return c.json({ error: 'Action must be either "like" or "unlike"' }, 400); - } - // Insert like event into ClickHouse - await clickhouse_1.default.query({ - query: ` - INSERT INTO promote.like_events (user_id, content_id, action) - VALUES (?, ?, ?) - `, - values: [ - user.id, - content_id, - action === 'like' ? 1 : 2 - ] - }); - // Queue analytics processing job - await (0, queue_1.addAnalyticsJob)('process_likes', { - user_id: user.id, - content_id, - action, - timestamp: new Date().toISOString() - }); - // Update like count in Redis cache - const redis = await (0, redis_1.getRedisClient)(); - const likeKey = `likes:${content_id}`; - if (action === 'like') { - await redis.incr(likeKey); - } - else { - await redis.decr(likeKey); - } - return c.json({ message: `${action} tracked successfully` }); - } - catch (error) { - console.error('Like tracking error:', error); - return c.json({ error: 'Internal server error' }, 500); - } -}); -// Track a follow event -analyticsRouter.post('/follow', async (c) => { - try { - const { followed_id, action } = await c.req.json(); - const user = c.get('user'); - if (!followed_id || !action) { - return c.json({ error: 'Followed ID and action are required' }, 400); - } - if (action !== 'follow' && action !== 'unfollow') { - return c.json({ error: 'Action must be either "follow" or "unfollow"' }, 400); - } - // Insert follower event into ClickHouse - await clickhouse_1.default.query({ - query: ` - INSERT INTO promote.follower_events (follower_id, followed_id, action) - VALUES (?, ?, ?) - `, - values: [ - user.id, - followed_id, - action === 'follow' ? 1 : 2 - ] - }); - // Queue analytics processing job - await (0, queue_1.addAnalyticsJob)('process_followers', { - follower_id: user.id, - followed_id, - action, - timestamp: new Date().toISOString() - }); - // Update follower count in Redis cache - const redis = await (0, redis_1.getRedisClient)(); - const followerKey = `followers:${followed_id}`; - if (action === 'follow') { - await redis.incr(followerKey); - } - else { - await redis.decr(followerKey); - } - return c.json({ message: `${action} tracked successfully` }); - } - catch (error) { - console.error('Follow tracking error:', error); - return c.json({ error: 'Internal server error' }, 500); - } -}); -// Get analytics for a content -analyticsRouter.get('/content/:id', async (c) => { - try { - const contentId = c.req.param('id'); - // Get counts from Redis cache - const redis = await (0, redis_1.getRedisClient)(); - const [views, likes] = await Promise.all([ - redis.get(`views:${contentId}`), - redis.get(`likes:${contentId}`) - ]); - return c.json({ - content_id: contentId, - views: parseInt(views || '0'), - likes: parseInt(likes || '0') - }); - } - catch (error) { - console.error('Content analytics error:', error); - return c.json({ error: 'Internal server error' }, 500); - } -}); -// Get analytics for a user -analyticsRouter.get('/user/:id', async (c) => { - try { - const userId = c.req.param('id'); - // Get follower count from Redis cache - const redis = await (0, redis_1.getRedisClient)(); - const followers = await redis.get(`followers:${userId}`); - // Get content view and like counts from ClickHouse - const viewsResult = await clickhouse_1.default.query({ - query: ` - SELECT content_id, COUNT(*) as view_count - FROM promote.view_events - WHERE user_id = ? - GROUP BY content_id - `, - values: [userId] - }); - const likesResult = await clickhouse_1.default.query({ - query: ` - SELECT content_id, SUM(CASE WHEN action = 1 THEN 1 ELSE -1 END) as like_count - FROM promote.like_events - WHERE user_id = ? - GROUP BY content_id - `, - values: [userId] - }); - // Extract data from results - const viewsData = 'rows' in viewsResult ? viewsResult.rows : []; - const likesData = 'rows' in likesResult ? likesResult.rows : []; - return c.json({ - user_id: userId, - followers: parseInt(followers || '0'), - content_analytics: { - views: viewsData, - likes: likesData - } - }); - } - catch (error) { - console.error('User analytics error:', error); - return c.json({ error: 'Internal server error' }, 500); - } -}); -// 社群分析相关路由 -// 获取项目的顶级影响者 -analyticsRouter.get('/project/:id/top-influencers', async (c) => { - try { - const projectId = c.req.param('id'); - // 从ClickHouse查询项目的顶级影响者 - const result = await clickhouse_1.default.query({ - query: ` - SELECT - influencer_id, - SUM(metric_value) AS total_views - FROM events - WHERE - project_id = ? AND - event_type = 'post_view_change' - GROUP BY influencer_id - ORDER BY total_views DESC - LIMIT 10 - `, - values: [projectId] - }); - // 提取数据 - const influencerData = 'rows' in result ? result.rows : []; - // 如果有数据,从Supabase获取影响者详细信息 - if (influencerData.length > 0) { - const influencerIds = influencerData.map((item) => item.influencer_id); - const { data: influencerDetails, error } = await supabase_1.default - .from('influencers') - .select('influencer_id, name, platform, followers_count, video_count') - .in('influencer_id', influencerIds); - if (error) { - console.error('Error fetching influencer details:', error); - return c.json({ error: 'Error fetching influencer details' }, 500); - } - // 合并数据 - const enrichedData = influencerData.map((item) => { - const details = influencerDetails?.find((detail) => detail.influencer_id === item.influencer_id) || {}; - return { - ...item, - ...details - }; - }); - return c.json(enrichedData); - } - return c.json(influencerData); - } - catch (error) { - console.error('Error fetching top influencers:', error); - return c.json({ error: 'Internal server error' }, 500); - } -}); -// 获取影响者的粉丝变化趋势(过去6个月) -analyticsRouter.get('/influencer/:id/follower-trend', async (c) => { - try { - const influencerId = c.req.param('id'); - // 从ClickHouse查询影响者的粉丝变化趋势 - const result = await clickhouse_1.default.query({ - query: ` - SELECT - toStartOfMonth(timestamp) AS month, - SUM(metric_value) AS follower_change - FROM events - WHERE - influencer_id = ? AND - event_type = 'follower_change' AND - timestamp >= subtractMonths(now(), 6) - GROUP BY month - ORDER BY month ASC - `, - values: [influencerId] - }); - // 提取数据 - const trendData = 'rows' in result ? result.rows : []; - return c.json({ - influencer_id: influencerId, - follower_trend: trendData - }); - } - catch (error) { - console.error('Error fetching follower trend:', error); - return c.json({ error: 'Internal server error' }, 500); - } -}); -// 获取帖子的点赞变化(过去30天) -analyticsRouter.get('/post/:id/like-trend', async (c) => { - try { - const postId = c.req.param('id'); - // 从ClickHouse查询帖子的点赞变化 - const result = await clickhouse_1.default.query({ - query: ` - SELECT - toDate(timestamp) AS day, - SUM(metric_value) AS like_change - FROM events - WHERE - post_id = ? AND - event_type = 'post_like_change' AND - timestamp >= subtractDays(now(), 30) - GROUP BY day - ORDER BY day ASC - `, - values: [postId] - }); - // 提取数据 - const trendData = 'rows' in result ? result.rows : []; - return c.json({ - post_id: postId, - like_trend: trendData - }); - } - catch (error) { - console.error('Error fetching like trend:', error); - return c.json({ error: 'Internal server error' }, 500); - } -}); -// 获取影响者详细信息 -analyticsRouter.get('/influencer/:id/details', async (c) => { - try { - const influencerId = c.req.param('id'); - // 从Supabase获取影响者详细信息 - const { data, error } = await supabase_1.default - .from('influencers') - .select('influencer_id, name, platform, profile_url, external_id, followers_count, video_count, platform_count, created_at') - .eq('influencer_id', influencerId) - .single(); - if (error) { - console.error('Error fetching influencer details:', error); - return c.json({ error: 'Error fetching influencer details' }, 500); - } - if (!data) { - return c.json({ error: 'Influencer not found' }, 404); - } - return c.json(data); - } - catch (error) { - console.error('Error fetching influencer details:', error); - return c.json({ error: 'Internal server error' }, 500); - } -}); -// 获取影响者的帖子列表 -analyticsRouter.get('/influencer/:id/posts', async (c) => { - try { - const influencerId = c.req.param('id'); - // 从Supabase获取影响者的帖子列表 - const { data, error } = await supabase_1.default - .from('posts') - .select('post_id, influencer_id, platform, post_url, title, description, published_at, created_at') - .eq('influencer_id', influencerId) - .order('published_at', { ascending: false }); - if (error) { - console.error('Error fetching influencer posts:', error); - return c.json({ error: 'Error fetching influencer posts' }, 500); - } - return c.json(data || []); - } - catch (error) { - console.error('Error fetching influencer posts:', error); - return c.json({ error: 'Internal server error' }, 500); - } -}); -// 获取帖子的评论列表 -analyticsRouter.get('/post/:id/comments', async (c) => { - try { - const postId = c.req.param('id'); - // 从Supabase获取帖子的评论列表 - const { data, error } = await supabase_1.default - .from('comments') - .select('comment_id, post_id, user_id, content, sentiment_score, created_at') - .eq('post_id', postId) - .order('created_at', { ascending: false }); - if (error) { - console.error('Error fetching post comments:', error); - return c.json({ error: 'Error fetching post comments' }, 500); - } - return c.json(data || []); - } - catch (error) { - console.error('Error fetching post comments:', error); - return c.json({ error: 'Internal server error' }, 500); - } -}); -// 获取项目的平台分布 -analyticsRouter.get('/project/:id/platform-distribution', async (c) => { - try { - const projectId = c.req.param('id'); - // 从ClickHouse查询项目的平台分布 - const result = await clickhouse_1.default.query({ - query: ` - SELECT - platform, - COUNT(DISTINCT influencer_id) AS influencer_count - FROM events - WHERE project_id = ? - GROUP BY platform - ORDER BY influencer_count DESC - `, - values: [projectId] - }); - // 提取数据 - const distributionData = 'rows' in result ? result.rows : []; - return c.json({ - project_id: projectId, - platform_distribution: distributionData - }); - } - catch (error) { - console.error('Error fetching platform distribution:', error); - return c.json({ error: 'Internal server error' }, 500); - } -}); -// 获取项目的互动类型分布 -analyticsRouter.get('/project/:id/interaction-types', async (c) => { - try { - const projectId = c.req.param('id'); - // 从ClickHouse查询项目的互动类型分布 - const result = await clickhouse_1.default.query({ - query: ` - SELECT - event_type, - COUNT(*) AS event_count, - SUM(metric_value) AS total_value - FROM events - WHERE - project_id = ? AND - event_type IN ('click', 'comment', 'share') - GROUP BY event_type - ORDER BY event_count DESC - `, - values: [projectId] - }); - // 提取数据 - const interactionData = 'rows' in result ? result.rows : []; - return c.json({ - project_id: projectId, - interaction_types: interactionData - }); - } - catch (error) { - console.error('Error fetching interaction types:', error); - return c.json({ error: 'Internal server error' }, 500); - } -}); -exports.default = analyticsRouter; diff --git a/backend/dist/routes/auth.js b/backend/dist/routes/auth.js deleted file mode 100644 index 1f15d83..0000000 --- a/backend/dist/routes/auth.js +++ /dev/null @@ -1,140 +0,0 @@ -"use strict"; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -const hono_1 = require("hono"); -const auth_1 = require("../middlewares/auth"); -const supabase_1 = __importDefault(require("../utils/supabase")); -const authRouter = new hono_1.Hono(); -// Register a new user -authRouter.post('/register', async (c) => { - try { - const { email, password, name } = await c.req.json(); - // Validate input - if (!email || !password || !name) { - return c.json({ error: 'Email, password, and name are required' }, 400); - } - // Register user with Supabase - const { data: authData, error: authError } = await supabase_1.default.auth.signUp({ - email, - password, - }); - if (authError) { - return c.json({ error: authError.message }, 400); - } - if (!authData.user) { - return c.json({ error: 'Failed to create user' }, 500); - } - // Create user profile in the database - const { error: profileError } = await supabase_1.default - .from('users') - .insert({ - id: authData.user.id, - email: authData.user.email, - name, - created_at: new Date().toISOString(), - }); - if (profileError) { - // Attempt to clean up the auth user if profile creation fails - await supabase_1.default.auth.admin.deleteUser(authData.user.id); - return c.json({ error: profileError.message }, 500); - } - // Generate JWT token - const token = (0, auth_1.generateToken)(authData.user.id, authData.user.email); - return c.json({ - message: 'User registered successfully', - user: { - id: authData.user.id, - email: authData.user.email, - name, - }, - token, - }, 201); - } - catch (error) { - console.error('Registration error:', error); - return c.json({ error: 'Internal server error' }, 500); - } -}); -// Login user -authRouter.post('/login', async (c) => { - try { - const { email, password } = await c.req.json(); - const { data, error } = await supabase_1.default.auth.signInWithPassword({ - email, - password - }); - if (error) { - return c.json({ error: error.message }, 400); - } - // 使用与 authMiddleware 一致的方式创建 JWT - const token = (0, auth_1.generateToken)(data.user.id, data.user.email || ''); - // 只返回必要的用户信息和令牌 - return c.json({ - success: true, - token, - user: { - id: data.user.id, - email: data.user.email - } - }); - } - catch (error) { - console.error(error); - return c.json({ error: 'Server error' }, 500); - } -}); -// Verify token -authRouter.get('/verify', async (c) => { - try { - const token = c.req.header('Authorization')?.split(' ')[1]; - if (!token) { - return c.json({ error: 'No token provided' }, 401); - } - const user = await (0, auth_1.verifySupabaseToken)(token); - if (!user) { - return c.json({ error: 'Invalid token' }, 401); - } - return c.json({ - message: 'Token is valid', - user: { - id: user.id, - email: user.email, - }, - }); - } - catch (error) { - console.error('Token verification error:', error); - return c.json({ error: 'Internal server error' }, 500); - } -}); -// Refresh token -authRouter.post('/refresh-token', async (c) => { - try { - const token = c.req.header('Authorization')?.split(' ')[1]; - if (!token) { - return c.json({ error: 'No token provided' }, 401); - } - // 验证当前token - const user = await (0, auth_1.verifySupabaseToken)(token); - if (!user) { - return c.json({ error: 'Invalid token' }, 401); - } - // 生成新token - const newToken = (0, auth_1.generateToken)(user.id, user.email || ''); - return c.json({ - message: 'Token refreshed successfully', - token: newToken, - user: { - id: user.id, - email: user.email, - }, - }); - } - catch (error) { - console.error('Token refresh error:', error); - return c.json({ error: 'Internal server error' }, 500); - } -}); -exports.default = authRouter; diff --git a/backend/dist/routes/comments.js b/backend/dist/routes/comments.js deleted file mode 100644 index 049e3af..0000000 --- a/backend/dist/routes/comments.js +++ /dev/null @@ -1,12 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -const hono_1 = require("hono"); -const commentsController_1 = require("../controllers/commentsController"); -const auth_1 = require("../middlewares/auth"); -const commentsRouter = new hono_1.Hono(); -// Public routes -commentsRouter.get('/', commentsController_1.getComments); -// Protected routes -commentsRouter.post('/', auth_1.authMiddleware, commentsController_1.createComment); -commentsRouter.delete('/:comment_id', auth_1.authMiddleware, commentsController_1.deleteComment); -exports.default = commentsRouter; diff --git a/backend/dist/routes/community.js b/backend/dist/routes/community.js deleted file mode 100644 index 11e4aab..0000000 --- a/backend/dist/routes/community.js +++ /dev/null @@ -1,649 +0,0 @@ -"use strict"; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -const hono_1 = require("hono"); -const auth_1 = require("../middlewares/auth"); -const clickhouse_1 = __importDefault(require("../utils/clickhouse")); -const supabase_1 = __importDefault(require("../utils/supabase")); -const communityRouter = new hono_1.Hono(); -// Apply auth middleware to all routes -communityRouter.use('*', auth_1.authMiddleware); -// 创建新项目 -communityRouter.post('/projects', async (c) => { - try { - const { name, description, start_date, end_date } = await c.req.json(); - const user = c.get('user'); - if (!name) { - return c.json({ error: 'Project name is required' }, 400); - } - // 在Supabase中创建项目 - const { data, error } = await supabase_1.default - .from('projects') - .insert({ - name, - description, - start_date, - end_date, - created_by: user.id - }) - .select() - .single(); - if (error) { - console.error('Error creating project:', error); - return c.json({ error: 'Failed to create project' }, 500); - } - return c.json({ - message: 'Project created successfully', - project: data - }, 201); - } - catch (error) { - console.error('Error creating project:', error); - return c.json({ error: 'Internal server error' }, 500); - } -}); -// 获取项目列表 -communityRouter.get('/projects', async (c) => { - try { - const user = c.get('user'); - // 从Supabase获取项目列表 - const { data, error } = await supabase_1.default - .from('projects') - .select('*') - .eq('created_by', user.id) - .order('created_at', { ascending: false }); - if (error) { - console.error('Error fetching projects:', error); - return c.json({ error: 'Failed to fetch projects' }, 500); - } - return c.json(data || []); - } - catch (error) { - console.error('Error fetching projects:', error); - return c.json({ error: 'Internal server error' }, 500); - } -}); -// 获取项目详情 -communityRouter.get('/projects/:id', async (c) => { - try { - const projectId = c.req.param('id'); - // 从Supabase获取项目详情 - const { data, error } = await supabase_1.default - .from('projects') - .select('*') - .eq('id', projectId) - .single(); - if (error) { - console.error('Error fetching project:', error); - return c.json({ error: 'Failed to fetch project' }, 500); - } - if (!data) { - return c.json({ error: 'Project not found' }, 404); - } - return c.json(data); - } - catch (error) { - console.error('Error fetching project:', error); - return c.json({ error: 'Internal server error' }, 500); - } -}); -// 更新项目 -communityRouter.put('/projects/:id', async (c) => { - try { - const projectId = c.req.param('id'); - const { name, description, start_date, end_date, status } = await c.req.json(); - const user = c.get('user'); - // 检查项目是否存在并属于当前用户 - const { data: existingProject, error: fetchError } = await supabase_1.default - .from('projects') - .select('*') - .eq('id', projectId) - .eq('created_by', user.id) - .single(); - if (fetchError || !existingProject) { - return c.json({ error: 'Project not found or you do not have permission to update it' }, 404); - } - // 更新项目 - const { data, error } = await supabase_1.default - .from('projects') - .update({ - name, - description, - start_date, - end_date, - status, - updated_at: new Date().toISOString() - }) - .eq('id', projectId) - .select() - .single(); - if (error) { - console.error('Error updating project:', error); - return c.json({ error: 'Failed to update project' }, 500); - } - return c.json({ - message: 'Project updated successfully', - project: data - }); - } - catch (error) { - console.error('Error updating project:', error); - return c.json({ error: 'Internal server error' }, 500); - } -}); -// 删除项目 -communityRouter.delete('/projects/:id', async (c) => { - try { - const projectId = c.req.param('id'); - const user = c.get('user'); - // 检查项目是否存在并属于当前用户 - const { data: existingProject, error: fetchError } = await supabase_1.default - .from('projects') - .select('*') - .eq('id', projectId) - .eq('created_by', user.id) - .single(); - if (fetchError || !existingProject) { - return c.json({ error: 'Project not found or you do not have permission to delete it' }, 404); - } - // 删除项目 - const { error } = await supabase_1.default - .from('projects') - .delete() - .eq('id', projectId); - if (error) { - console.error('Error deleting project:', error); - return c.json({ error: 'Failed to delete project' }, 500); - } - return c.json({ - message: 'Project deleted successfully' - }); - } - catch (error) { - console.error('Error deleting project:', error); - return c.json({ error: 'Internal server error' }, 500); - } -}); -// 添加影响者到项目 -communityRouter.post('/projects/:id/influencers', async (c) => { - try { - const projectId = c.req.param('id'); - const { influencer_id, platform, external_id, name, profile_url } = await c.req.json(); - const user = c.get('user'); - // 检查项目是否存在并属于当前用户 - const { data: existingProject, error: fetchError } = await supabase_1.default - .from('projects') - .select('*') - .eq('id', projectId) - .eq('created_by', user.id) - .single(); - if (fetchError || !existingProject) { - return c.json({ error: 'Project not found or you do not have permission to update it' }, 404); - } - // 检查影响者是否已存在 - let influencerData; - if (influencer_id) { - // 如果提供了影响者ID,检查是否存在 - const { data, error } = await supabase_1.default - .from('influencers') - .select('*') - .eq('influencer_id', influencer_id) - .single(); - if (!error && data) { - influencerData = data; - } - } - else if (external_id && platform) { - // 如果提供了外部ID和平台,检查是否存在 - const { data, error } = await supabase_1.default - .from('influencers') - .select('*') - .eq('external_id', external_id) - .eq('platform', platform) - .single(); - if (!error && data) { - influencerData = data; - } - } - // 如果影响者不存在,创建新的影响者 - if (!influencerData) { - if (!name || !platform) { - return c.json({ error: 'Name and platform are required for new influencers' }, 400); - } - const { data, error } = await supabase_1.default - .from('influencers') - .insert({ - name, - platform, - external_id, - profile_url - }) - .select() - .single(); - if (error) { - console.error('Error creating influencer:', error); - return c.json({ error: 'Failed to create influencer' }, 500); - } - influencerData = data; - } - // 将影响者添加到项目 - const { data: projectInfluencer, error } = await supabase_1.default - .from('project_influencers') - .insert({ - project_id: projectId, - influencer_id: influencerData.influencer_id - }) - .select() - .single(); - if (error) { - console.error('Error adding influencer to project:', error); - return c.json({ error: 'Failed to add influencer to project' }, 500); - } - return c.json({ - message: 'Influencer added to project successfully', - project_influencer: projectInfluencer, - influencer: influencerData - }, 201); - } - catch (error) { - console.error('Error adding influencer to project:', error); - return c.json({ error: 'Internal server error' }, 500); - } -}); -// 获取项目的影响者列表 -communityRouter.get('/projects/:id/influencers', async (c) => { - try { - const projectId = c.req.param('id'); - // 从Supabase获取项目的影响者列表 - const { data, error } = await supabase_1.default - .from('project_influencers') - .select(` - project_id, - influencers ( - influencer_id, - name, - platform, - profile_url, - external_id, - followers_count, - video_count - ) - `) - .eq('project_id', projectId); - if (error) { - console.error('Error fetching project influencers:', error); - return c.json({ error: 'Failed to fetch project influencers' }, 500); - } - // 格式化数据 - const influencers = data?.map(item => item.influencers) || []; - return c.json(influencers); - } - catch (error) { - console.error('Error fetching project influencers:', error); - return c.json({ error: 'Internal server error' }, 500); - } -}); -// 从项目中移除影响者 -communityRouter.delete('/projects/:projectId/influencers/:influencerId', async (c) => { - try { - const projectId = c.req.param('projectId'); - const influencerId = c.req.param('influencerId'); - const user = c.get('user'); - // 检查项目是否存在并属于当前用户 - const { data: existingProject, error: fetchError } = await supabase_1.default - .from('projects') - .select('*') - .eq('id', projectId) - .eq('created_by', user.id) - .single(); - if (fetchError || !existingProject) { - return c.json({ error: 'Project not found or you do not have permission to update it' }, 404); - } - // 从项目中移除影响者 - const { error } = await supabase_1.default - .from('project_influencers') - .delete() - .eq('project_id', projectId) - .eq('influencer_id', influencerId); - if (error) { - console.error('Error removing influencer from project:', error); - return c.json({ error: 'Failed to remove influencer from project' }, 500); - } - return c.json({ - message: 'Influencer removed from project successfully' - }); - } - catch (error) { - console.error('Error removing influencer from project:', error); - return c.json({ error: 'Internal server error' }, 500); - } -}); -// 添加事件数据 -communityRouter.post('/events', async (c) => { - try { - const { project_id, influencer_id, post_id, platform, event_type, metric_value, event_metadata } = await c.req.json(); - if (!project_id || !influencer_id || !platform || !event_type || metric_value === undefined) { - return c.json({ - error: 'Project ID, influencer ID, platform, event type, and metric value are required' - }, 400); - } - // 验证事件类型 - const validEventTypes = [ - 'follower_change', - 'post_like_change', - 'post_view_change', - 'click', - 'comment', - 'share' - ]; - if (!validEventTypes.includes(event_type)) { - return c.json({ - error: `Invalid event type. Must be one of: ${validEventTypes.join(', ')}` - }, 400); - } - // 验证平台 - const validPlatforms = ['youtube', 'instagram', 'tiktok', 'twitter', 'facebook']; - if (!validPlatforms.includes(platform)) { - return c.json({ - error: `Invalid platform. Must be one of: ${validPlatforms.join(', ')}` - }, 400); - } - // 将事件数据插入ClickHouse - await clickhouse_1.default.query({ - query: ` - INSERT INTO events ( - project_id, - influencer_id, - post_id, - platform, - event_type, - metric_value, - event_metadata - ) VALUES (?, ?, ?, ?, ?, ?, ?) - `, - values: [ - project_id, - influencer_id, - post_id || null, - platform, - event_type, - metric_value, - event_metadata ? JSON.stringify(event_metadata) : '{}' - ] - }); - return c.json({ - message: 'Event data added successfully' - }, 201); - } - catch (error) { - console.error('Error adding event data:', error); - return c.json({ error: 'Internal server error' }, 500); - } -}); -// 批量添加事件数据 -communityRouter.post('/events/batch', async (c) => { - try { - const { events } = await c.req.json(); - if (!Array.isArray(events) || events.length === 0) { - return c.json({ error: 'Events array is required and must not be empty' }, 400); - } - // 验证事件类型和平台 - const validEventTypes = [ - 'follower_change', - 'post_like_change', - 'post_view_change', - 'click', - 'comment', - 'share' - ]; - const validPlatforms = ['youtube', 'instagram', 'tiktok', 'twitter', 'facebook']; - // 验证每个事件 - for (const event of events) { - const { project_id, influencer_id, platform, event_type, metric_value } = event; - if (!project_id || !influencer_id || !platform || !event_type || metric_value === undefined) { - return c.json({ - error: 'Project ID, influencer ID, platform, event type, and metric value are required for all events' - }, 400); - } - if (!validEventTypes.includes(event_type)) { - return c.json({ - error: `Invalid event type: ${event_type}. Must be one of: ${validEventTypes.join(', ')}` - }, 400); - } - if (!validPlatforms.includes(platform)) { - return c.json({ - error: `Invalid platform: ${platform}. Must be one of: ${validPlatforms.join(', ')}` - }, 400); - } - } - // 准备批量插入数据 - const values = events.map(event => `( - '${event.project_id}', - '${event.influencer_id}', - ${event.post_id ? `'${event.post_id}'` : 'NULL'}, - '${event.platform}', - '${event.event_type}', - ${event.metric_value}, - '${event.event_metadata ? JSON.stringify(event.event_metadata) : '{}'}' - )`).join(','); - // 批量插入事件数据 - await clickhouse_1.default.query({ - query: ` - INSERT INTO events ( - project_id, - influencer_id, - post_id, - platform, - event_type, - metric_value, - event_metadata - ) VALUES ${values} - ` - }); - return c.json({ - message: `${events.length} events added successfully` - }, 201); - } - catch (error) { - console.error('Error adding batch event data:', error); - return c.json({ error: 'Internal server error' }, 500); - } -}); -// 添加帖子 -communityRouter.post('/posts', async (c) => { - try { - const { influencer_id, platform, post_url, title, description, published_at } = await c.req.json(); - if (!influencer_id || !platform || !post_url) { - return c.json({ - error: 'Influencer ID, platform, and post URL are required' - }, 400); - } - // 验证平台 - const validPlatforms = ['youtube', 'instagram', 'tiktok', 'twitter', 'facebook']; - if (!validPlatforms.includes(platform)) { - return c.json({ - error: `Invalid platform. Must be one of: ${validPlatforms.join(', ')}` - }, 400); - } - // 检查帖子是否已存在 - const { data: existingPost, error: checkError } = await supabase_1.default - .from('posts') - .select('*') - .eq('post_url', post_url) - .single(); - if (!checkError && existingPost) { - return c.json({ - error: 'Post with this URL already exists', - post: existingPost - }, 409); - } - // 创建新帖子 - const { data, error } = await supabase_1.default - .from('posts') - .insert({ - influencer_id, - platform, - post_url, - title, - description, - published_at: published_at || new Date().toISOString() - }) - .select() - .single(); - if (error) { - console.error('Error creating post:', error); - return c.json({ error: 'Failed to create post' }, 500); - } - return c.json({ - message: 'Post created successfully', - post: data - }, 201); - } - catch (error) { - console.error('Error creating post:', error); - return c.json({ error: 'Internal server error' }, 500); - } -}); -// 添加评论 -communityRouter.post('/comments', async (c) => { - try { - const { post_id, user_id, content, sentiment_score } = await c.req.json(); - if (!post_id || !content) { - return c.json({ - error: 'Post ID and content are required' - }, 400); - } - // 创建新评论 - const { data, error } = await supabase_1.default - .from('comments') - .insert({ - post_id, - user_id: user_id || c.get('user').id, - content, - sentiment_score: sentiment_score || 0 - }) - .select() - .single(); - if (error) { - console.error('Error creating comment:', error); - return c.json({ error: 'Failed to create comment' }, 500); - } - return c.json({ - message: 'Comment created successfully', - comment: data - }, 201); - } - catch (error) { - console.error('Error creating comment:', error); - return c.json({ error: 'Internal server error' }, 500); - } -}); -// 获取项目的事件统计 -communityRouter.get('/projects/:id/event-stats', async (c) => { - try { - const projectId = c.req.param('id'); - // 从ClickHouse查询项目的事件统计 - const result = await clickhouse_1.default.query({ - query: ` - SELECT - event_type, - COUNT(*) AS event_count, - SUM(metric_value) AS total_value - FROM events - WHERE project_id = ? - GROUP BY event_type - ORDER BY event_count DESC - `, - values: [projectId] - }); - // 提取数据 - const statsData = 'rows' in result ? result.rows : []; - return c.json({ - project_id: projectId, - event_stats: statsData - }); - } - catch (error) { - console.error('Error fetching event stats:', error); - return c.json({ error: 'Internal server error' }, 500); - } -}); -// 获取项目的时间趋势 -communityRouter.get('/projects/:id/time-trend', async (c) => { - try { - const projectId = c.req.param('id'); - const { event_type, interval = 'day', days = '30' } = c.req.query(); - if (!event_type) { - return c.json({ error: 'Event type is required' }, 400); - } - // 验证事件类型 - const validEventTypes = [ - 'follower_change', - 'post_like_change', - 'post_view_change', - 'click', - 'comment', - 'share' - ]; - if (!validEventTypes.includes(event_type)) { - return c.json({ - error: `Invalid event type. Must be one of: ${validEventTypes.join(', ')}` - }, 400); - } - // 验证时间间隔 - const validIntervals = ['hour', 'day', 'week', 'month']; - if (!validIntervals.includes(interval)) { - return c.json({ - error: `Invalid interval. Must be one of: ${validIntervals.join(', ')}` - }, 400); - } - // 构建时间间隔函数 - let timeFunction; - switch (interval) { - case 'hour': - timeFunction = 'toStartOfHour'; - break; - case 'day': - timeFunction = 'toDate'; - break; - case 'week': - timeFunction = 'toStartOfWeek'; - break; - case 'month': - timeFunction = 'toStartOfMonth'; - break; - } - // 从ClickHouse查询项目的时间趋势 - const result = await clickhouse_1.default.query({ - query: ` - SELECT - ${timeFunction}(timestamp) AS time_period, - SUM(metric_value) AS value - FROM events - WHERE - project_id = ? AND - event_type = ? AND - timestamp >= subtractDays(now(), ?) - GROUP BY time_period - ORDER BY time_period ASC - `, - values: [projectId, event_type, parseInt(days)] - }); - // 提取数据 - const trendData = 'rows' in result ? result.rows : []; - return c.json({ - project_id: projectId, - event_type, - interval, - days: parseInt(days), - trend: trendData - }); - } - catch (error) { - console.error('Error fetching time trend:', error); - return c.json({ error: 'Internal server error' }, 500); - } -}); -exports.default = communityRouter; diff --git a/backend/dist/routes/influencers.js b/backend/dist/routes/influencers.js deleted file mode 100644 index 3e1c696..0000000 --- a/backend/dist/routes/influencers.js +++ /dev/null @@ -1,10 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -const hono_1 = require("hono"); -const influencersController_1 = require("../controllers/influencersController"); -const influencersRouter = new hono_1.Hono(); -// Public routes -influencersRouter.get('/', influencersController_1.getInfluencers); -influencersRouter.get('/stats', influencersController_1.getInfluencerStats); -influencersRouter.get('/:influencer_id', influencersController_1.getInfluencerById); -exports.default = influencersRouter; diff --git a/backend/dist/routes/posts.js b/backend/dist/routes/posts.js deleted file mode 100644 index ee77019..0000000 --- a/backend/dist/routes/posts.js +++ /dev/null @@ -1,584 +0,0 @@ -"use strict"; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -const hono_1 = require("hono"); -const auth_1 = require("../middlewares/auth"); -const supabase_1 = __importDefault(require("../utils/supabase")); -const clickhouse_1 = __importDefault(require("../utils/clickhouse")); -const redis_1 = require("../utils/redis"); -const postsRouter = new hono_1.Hono(); -// Apply auth middleware to most routes -postsRouter.use('*', auth_1.authMiddleware); -// 创建新帖子 -postsRouter.post('/', async (c) => { - try { - const { influencer_id, platform, post_url, title, description, published_at } = await c.req.json(); - if (!influencer_id || !platform || !post_url) { - return c.json({ - error: 'influencer_id, platform, and post_url are required' - }, 400); - } - // 验证平台 - const validPlatforms = ['youtube', 'instagram', 'tiktok', 'twitter', 'facebook']; - if (!validPlatforms.includes(platform)) { - return c.json({ - error: `Invalid platform. Must be one of: ${validPlatforms.join(', ')}` - }, 400); - } - // 检查帖子URL是否已存在 - const { data: existingPost, error: checkError } = await supabase_1.default - .from('posts') - .select('*') - .eq('post_url', post_url) - .single(); - if (!checkError && existingPost) { - return c.json({ - error: 'Post with this URL already exists', - post: existingPost - }, 409); - } - // 创建新帖子 - const { data: post, error } = await supabase_1.default - .from('posts') - .insert({ - influencer_id, - platform, - post_url, - title, - description, - published_at: published_at || new Date().toISOString() - }) - .select() - .single(); - if (error) { - console.error('Error creating post:', error); - return c.json({ error: 'Failed to create post' }, 500); - } - return c.json({ - message: 'Post created successfully', - post - }, 201); - } - catch (error) { - console.error('Error creating post:', error); - return c.json({ error: 'Internal server error' }, 500); - } -}); -// 获取帖子列表 -postsRouter.get('/', async (c) => { - try { - const { influencer_id, platform, limit = '20', offset = '0', sort = 'published_at', order = 'desc' } = c.req.query(); - // 构建查询 - let query = supabase_1.default.from('posts').select(` - *, - influencer:influencers(name, platform, profile_url, followers_count) - `); - // 添加过滤条件 - if (influencer_id) { - query = query.eq('influencer_id', influencer_id); - } - if (platform) { - query = query.eq('platform', platform); - } - // 添加排序和分页 - query = query.order(sort, { ascending: order === 'asc' }); - query = query.range(parseInt(offset), parseInt(offset) + parseInt(limit) - 1); - // 执行查询 - const { data, error, count } = await query; - if (error) { - console.error('Error fetching posts:', error); - return c.json({ error: 'Failed to fetch posts' }, 500); - } - // 获取帖子的统计数据 - if (data && data.length > 0) { - const postIds = data.map(post => post.post_id); - // 尝试从缓存获取数据 - const redis = await (0, redis_1.getRedisClient)(); - const cachedStats = await Promise.all(postIds.map(async (postId) => { - const [views, likes] = await Promise.all([ - redis.get(`post:views:${postId}`), - redis.get(`post:likes:${postId}`) - ]); - return { - post_id: postId, - views: views ? parseInt(views) : null, - likes: likes ? parseInt(likes) : null - }; - })); - // 找出缓存中没有的帖子ID - const missingIds = postIds.filter(id => { - const stat = cachedStats.find(s => s.post_id === id); - return stat?.views === null || stat?.likes === null; - }); - // 如果有缺失的统计数据,从ClickHouse获取 - if (missingIds.length > 0) { - try { - // 查询帖子的观看数 - const viewsResult = await clickhouse_1.default.query({ - query: ` - SELECT - post_id, - SUM(metric_value) AS views - FROM events - WHERE - post_id IN (${missingIds.map(id => `'${id}'`).join(',')}) AND - event_type = 'post_view_change' - GROUP BY post_id - ` - }); - // 查询帖子的点赞数 - const likesResult = await clickhouse_1.default.query({ - query: ` - SELECT - post_id, - SUM(metric_value) AS likes - FROM events - WHERE - post_id IN (${missingIds.map(id => `'${id}'`).join(',')}) AND - event_type = 'post_like_change' - GROUP BY post_id - ` - }); - // 处理结果 - const viewsData = 'rows' in viewsResult ? viewsResult.rows : []; - const likesData = 'rows' in likesResult ? likesResult.rows : []; - // 更新缓存并填充统计数据 - for (const viewStat of viewsData) { - if (viewStat && typeof viewStat === 'object' && 'post_id' in viewStat && 'views' in viewStat) { - // 更新缓存 - await redis.set(`post:views:${viewStat.post_id}`, String(viewStat.views)); - // 更新缓存统计数据 - const cacheStat = cachedStats.find(s => s.post_id === viewStat.post_id); - if (cacheStat) { - cacheStat.views = Number(viewStat.views); - } - } - } - for (const likeStat of likesData) { - if (likeStat && typeof likeStat === 'object' && 'post_id' in likeStat && 'likes' in likeStat) { - // 更新缓存 - await redis.set(`post:likes:${likeStat.post_id}`, String(likeStat.likes)); - // 更新缓存统计数据 - const cacheStat = cachedStats.find(s => s.post_id === likeStat.post_id); - if (cacheStat) { - cacheStat.likes = Number(likeStat.likes); - } - } - } - } - catch (chError) { - console.error('Error fetching stats from ClickHouse:', chError); - } - } - // 合并统计数据到帖子数据 - data.forEach(post => { - const stats = cachedStats.find(s => s.post_id === post.post_id); - post.stats = { - views: stats?.views || 0, - likes: stats?.likes || 0 - }; - }); - } - return c.json({ - posts: data || [], - total: count || 0, - limit: parseInt(limit), - offset: parseInt(offset) - }); - } - catch (error) { - console.error('Error fetching posts:', error); - return c.json({ error: 'Internal server error' }, 500); - } -}); -// 获取单个帖子详情 -postsRouter.get('/:id', async (c) => { - try { - const postId = c.req.param('id'); - // 获取帖子详情 - const { data: post, error } = await supabase_1.default - .from('posts') - .select(` - *, - influencer:influencers(name, platform, profile_url, followers_count) - `) - .eq('post_id', postId) - .single(); - if (error) { - console.error('Error fetching post:', error); - return c.json({ error: 'Failed to fetch post' }, 500); - } - if (!post) { - return c.json({ error: 'Post not found' }, 404); - } - // 获取帖子统计数据 - try { - // 先尝试从Redis缓存获取 - const redis = await (0, redis_1.getRedisClient)(); - const [cachedViews, cachedLikes] = await Promise.all([ - redis.get(`post:views:${postId}`), - redis.get(`post:likes:${postId}`) - ]); - // 如果缓存中有数据,直接使用 - if (cachedViews !== null && cachedLikes !== null) { - post.stats = { - views: parseInt(cachedViews), - likes: parseInt(cachedLikes) - }; - } - else { - // 如果缓存中没有,从ClickHouse获取 - // 查询帖子的观看数 - const viewsResult = await clickhouse_1.default.query({ - query: ` - SELECT SUM(metric_value) AS views - FROM events - WHERE - post_id = ? AND - event_type = 'post_view_change' - `, - values: [postId] - }); - // 查询帖子的点赞数 - const likesResult = await clickhouse_1.default.query({ - query: ` - SELECT SUM(metric_value) AS likes - FROM events - WHERE - post_id = ? AND - event_type = 'post_like_change' - `, - values: [postId] - }); - // 处理结果 - let viewsData = 0; - if ('rows' in viewsResult && viewsResult.rows.length > 0 && viewsResult.rows[0] && typeof viewsResult.rows[0] === 'object' && 'views' in viewsResult.rows[0]) { - viewsData = Number(viewsResult.rows[0].views) || 0; - } - let likesData = 0; - if ('rows' in likesResult && likesResult.rows.length > 0 && likesResult.rows[0] && typeof likesResult.rows[0] === 'object' && 'likes' in likesResult.rows[0]) { - likesData = Number(likesResult.rows[0].likes) || 0; - } - // 更新缓存 - await redis.set(`post:views:${postId}`, String(viewsData)); - await redis.set(`post:likes:${postId}`, String(likesData)); - // 添加统计数据 - post.stats = { - views: viewsData, - likes: likesData - }; - } - // 获取互动时间线 - const timelineResult = await clickhouse_1.default.query({ - query: ` - SELECT - toDate(timestamp) as date, - event_type, - SUM(metric_value) as value - FROM events - WHERE - post_id = ? AND - event_type IN ('post_view_change', 'post_like_change') - GROUP BY date, event_type - ORDER BY date ASC - `, - values: [postId] - }); - const timelineData = 'rows' in timelineResult ? timelineResult.rows : []; - // 添加时间线数据 - post.timeline = timelineData; - // 获取评论数量 - const { count } = await supabase_1.default - .from('comments') - .select('*', { count: 'exact', head: true }) - .eq('post_id', postId); - post.comment_count = count || 0; - } - catch (statsError) { - console.error('Error fetching post stats:', statsError); - // 继续返回帖子数据,但没有统计信息 - post.stats = { views: 0, likes: 0 }; - post.timeline = []; - post.comment_count = 0; - } - return c.json(post); - } - catch (error) { - console.error('Error fetching post:', error); - return c.json({ error: 'Internal server error' }, 500); - } -}); -// 更新帖子 -postsRouter.put('/:id', async (c) => { - try { - const postId = c.req.param('id'); - const user = c.get('user'); - const { title, description } = await c.req.json(); - // 先检查帖子是否存在 - const { data: existingPost, error: fetchError } = await supabase_1.default - .from('posts') - .select('*') - .eq('post_id', postId) - .single(); - if (fetchError || !existingPost) { - return c.json({ error: 'Post not found' }, 404); - } - // 更新帖子 - const { data: updatedPost, error } = await supabase_1.default - .from('posts') - .update({ - title, - description, - updated_at: new Date().toISOString() - }) - .eq('post_id', postId) - .select() - .single(); - if (error) { - console.error('Error updating post:', error); - return c.json({ error: 'Failed to update post' }, 500); - } - return c.json({ - message: 'Post updated successfully', - post: updatedPost - }); - } - catch (error) { - console.error('Error updating post:', error); - return c.json({ error: 'Internal server error' }, 500); - } -}); -// 删除帖子 -postsRouter.delete('/:id', async (c) => { - try { - const postId = c.req.param('id'); - const user = c.get('user'); - // 删除帖子 - const { error } = await supabase_1.default - .from('posts') - .delete() - .eq('post_id', postId); - if (error) { - console.error('Error deleting post:', error); - return c.json({ error: 'Failed to delete post' }, 500); - } - // 清除缓存 - try { - const redis = await (0, redis_1.getRedisClient)(); - await Promise.all([ - redis.del(`post:views:${postId}`), - redis.del(`post:likes:${postId}`) - ]); - } - catch (cacheError) { - console.error('Error clearing cache:', cacheError); - } - return c.json({ - message: 'Post deleted successfully' - }); - } - catch (error) { - console.error('Error deleting post:', error); - return c.json({ error: 'Internal server error' }, 500); - } -}); -// 获取帖子的评论 -postsRouter.get('/:id/comments', async (c) => { - try { - const postId = c.req.param('id'); - const { limit = '20', offset = '0' } = c.req.query(); - // 获取评论 - const { data: comments, error, count } = await supabase_1.default - .from('comments') - .select('*', { count: 'exact' }) - .eq('post_id', postId) - .order('created_at', { ascending: false }) - .range(parseInt(offset), parseInt(offset) + parseInt(limit) - 1); - if (error) { - console.error('Error fetching comments:', error); - return c.json({ error: 'Failed to fetch comments' }, 500); - } - // 如果有评论,获取用户信息 - if (comments && comments.length > 0) { - const userIds = [...new Set(comments.map(comment => comment.user_id))]; - // 获取用户信息 - const { data: userProfiles, error: userError } = await supabase_1.default - .from('user_profiles') - .select('id, full_name, avatar_url') - .in('id', userIds); - if (!userError && userProfiles) { - // 将用户信息添加到评论中 - comments.forEach(comment => { - const userProfile = userProfiles.find(profile => profile.id === comment.user_id); - comment.user_profile = userProfile || null; - }); - } - else { - console.error('Error fetching user profiles:', userError); - } - } - return c.json({ - comments: comments || [], - total: count || 0, - limit: parseInt(limit), - offset: parseInt(offset) - }); - } - catch (error) { - console.error('Error fetching comments:', error); - return c.json({ error: 'Internal server error' }, 500); - } -}); -// 添加评论到帖子 -postsRouter.post('/:id/comments', async (c) => { - try { - const postId = c.req.param('id'); - const user = c.get('user'); - const { content, sentiment_score } = await c.req.json(); - if (!content) { - return c.json({ error: 'Comment content is required' }, 400); - } - // 创建评论 - const { data: comment, error } = await supabase_1.default - .from('comments') - .insert({ - post_id: postId, - user_id: user.id, - content, - sentiment_score: sentiment_score || 0 - }) - .select() - .single(); - if (error) { - console.error('Error creating comment:', error); - return c.json({ error: 'Failed to create comment' }, 500); - } - // 尝试记录评论事件到ClickHouse - try { - // 获取帖子信息 - const { data: post } = await supabase_1.default - .from('posts') - .select('influencer_id, platform') - .eq('post_id', postId) - .single(); - if (post) { - await clickhouse_1.default.query({ - query: ` - INSERT INTO events ( - influencer_id, - post_id, - platform, - event_type, - metric_value, - event_metadata - ) VALUES (?, ?, ?, 'comment', ?, ?) - `, - values: [ - post.influencer_id, - postId, - post.platform, - 1, - JSON.stringify({ - comment_id: comment.comment_id, - user_id: user.id, - sentiment_score: sentiment_score || 0 - }) - ] - }); - } - } - catch (eventError) { - console.error('Error recording comment event:', eventError); - // 不影响主流程,继续返回评论数据 - } - return c.json({ - message: 'Comment added successfully', - comment - }, 201); - } - catch (error) { - console.error('Error adding comment:', error); - return c.json({ error: 'Internal server error' }, 500); - } -}); -// 更新评论 -postsRouter.put('/comments/:id', async (c) => { - try { - const commentId = c.req.param('id'); - const user = c.get('user'); - const { content, sentiment_score } = await c.req.json(); - // 先检查评论是否存在且属于当前用户 - const { data: existingComment, error: fetchError } = await supabase_1.default - .from('comments') - .select('*') - .eq('comment_id', commentId) - .eq('user_id', user.id) - .single(); - if (fetchError || !existingComment) { - return c.json({ - error: 'Comment not found or you do not have permission to update it' - }, 404); - } - // 更新评论 - const { data: updatedComment, error } = await supabase_1.default - .from('comments') - .update({ - content, - sentiment_score: sentiment_score !== undefined ? sentiment_score : existingComment.sentiment_score, - updated_at: new Date().toISOString() - }) - .eq('comment_id', commentId) - .select() - .single(); - if (error) { - console.error('Error updating comment:', error); - return c.json({ error: 'Failed to update comment' }, 500); - } - return c.json({ - message: 'Comment updated successfully', - comment: updatedComment - }); - } - catch (error) { - console.error('Error updating comment:', error); - return c.json({ error: 'Internal server error' }, 500); - } -}); -// 删除评论 -postsRouter.delete('/comments/:id', async (c) => { - try { - const commentId = c.req.param('id'); - const user = c.get('user'); - // 先检查评论是否存在且属于当前用户 - const { data: existingComment, error: fetchError } = await supabase_1.default - .from('comments') - .select('*') - .eq('comment_id', commentId) - .eq('user_id', user.id) - .single(); - if (fetchError || !existingComment) { - return c.json({ - error: 'Comment not found or you do not have permission to delete it' - }, 404); - } - // 删除评论 - const { error } = await supabase_1.default - .from('comments') - .delete() - .eq('comment_id', commentId); - if (error) { - console.error('Error deleting comment:', error); - return c.json({ error: 'Failed to delete comment' }, 500); - } - return c.json({ - message: 'Comment deleted successfully' - }); - } - catch (error) { - console.error('Error deleting comment:', error); - return c.json({ error: 'Internal server error' }, 500); - } -}); -exports.default = postsRouter; diff --git a/backend/dist/routes/projectComments.js b/backend/dist/routes/projectComments.js deleted file mode 100644 index 3e217ee..0000000 --- a/backend/dist/routes/projectComments.js +++ /dev/null @@ -1,395 +0,0 @@ -"use strict"; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -const hono_1 = require("hono"); -const auth_1 = require("../middlewares/auth"); -const supabase_1 = __importDefault(require("../utils/supabase")); -const clickhouse_1 = __importDefault(require("../utils/clickhouse")); -const projectCommentsRouter = new hono_1.Hono(); -// Apply auth middleware to all routes -projectCommentsRouter.use('*', auth_1.authMiddleware); -// 获取项目的评论列表 -projectCommentsRouter.get('/projects/:id/comments', async (c) => { - try { - const projectId = c.req.param('id'); - const { limit = '20', offset = '0', parent_id = null } = c.req.query(); - // 检查项目是否存在 - const { data: project, error: projectError } = await supabase_1.default - .from('projects') - .select('id, name') - .eq('id', projectId) - .single(); - if (projectError) { - console.error('Error fetching project:', projectError); - return c.json({ error: 'Project not found' }, 404); - } - // 构建评论查询 - let commentsQuery = supabase_1.default - .from('project_comments') - .select(` - comment_id, - project_id, - user_id, - content, - sentiment_score, - status, - is_pinned, - parent_id, - created_at, - updated_at, - user:user_id(id, email) - `, { count: 'exact' }); - // 过滤条件 - commentsQuery = commentsQuery.eq('project_id', projectId); - // 如果指定了父评论ID,则获取子评论 - if (parent_id) { - commentsQuery = commentsQuery.eq('parent_id', parent_id); - } - else { - // 否则获取顶级评论(没有父评论的评论) - commentsQuery = commentsQuery.is('parent_id', null); - } - // 排序和分页 - const isPinned = parent_id ? false : true; // 只有顶级评论才考虑置顶 - if (isPinned) { - commentsQuery = commentsQuery.order('is_pinned', { ascending: false }); - } - commentsQuery = commentsQuery.order('created_at', { ascending: false }); - commentsQuery = commentsQuery.range(parseInt(offset), parseInt(offset) + parseInt(limit) - 1); - // 执行查询 - const { data: comments, error: commentsError, count } = await commentsQuery; - if (commentsError) { - console.error('Error fetching project comments:', commentsError); - return c.json({ error: 'Failed to fetch project comments' }, 500); - } - // 获取每个顶级评论的回复数量 - if (comments && !parent_id) { - const commentIds = comments.map(comment => comment.comment_id); - if (commentIds.length > 0) { - // 手动构建SQL查询来计算每个父评论的回复数量 - const { data: replyCounts, error: replyCountError } = await supabase_1.default - .rpc('get_reply_counts_for_comments', { parent_ids: commentIds }); - if (!replyCountError && replyCounts) { - // 将回复数量添加到评论中 - for (const comment of comments) { - const replyCountItem = replyCounts.find((r) => r.parent_id === comment.comment_id); - comment.reply_count = replyCountItem ? replyCountItem.count : 0; - } - } - } - } - return c.json({ - project, - comments: comments || [], - total: count || 0, - limit: parseInt(limit), - offset: parseInt(offset) - }); - } - catch (error) { - console.error('Error fetching project comments:', error); - return c.json({ error: 'Internal server error' }, 500); - } -}); -// 添加评论到项目 -projectCommentsRouter.post('/projects/:id/comments', async (c) => { - try { - const projectId = c.req.param('id'); - const user = c.get('user'); - const { content, sentiment_score = 0, parent_id = null } = await c.req.json(); - if (!content) { - return c.json({ error: 'Comment content is required' }, 400); - } - // 检查项目是否存在 - const { data: project, error: projectError } = await supabase_1.default - .from('projects') - .select('id') - .eq('id', projectId) - .single(); - if (projectError) { - console.error('Error fetching project:', projectError); - return c.json({ error: 'Project not found' }, 404); - } - // 如果指定了父评论ID,检查父评论是否存在 - if (parent_id) { - const { data: parentComment, error: parentError } = await supabase_1.default - .from('project_comments') - .select('comment_id') - .eq('comment_id', parent_id) - .eq('project_id', projectId) - .single(); - if (parentError || !parentComment) { - return c.json({ error: 'Parent comment not found' }, 404); - } - } - // 创建评论 - const { data: comment, error: commentError } = await supabase_1.default - .from('project_comments') - .insert({ - project_id: projectId, - user_id: user.id, - content, - sentiment_score, - parent_id - }) - .select() - .single(); - if (commentError) { - console.error('Error creating project comment:', commentError); - return c.json({ error: 'Failed to create comment' }, 500); - } - // 记录评论事件到ClickHouse - try { - await clickhouse_1.default.query({ - query: ` - INSERT INTO events ( - project_id, - event_type, - metric_value, - event_metadata - ) VALUES (?, 'project_comment', ?, ?) - `, - values: [ - projectId, - 1, - JSON.stringify({ - comment_id: comment.comment_id, - user_id: user.id, - parent_id: parent_id || null, - content: content.substring(0, 100), // 只存储部分内容以减小数据量 - sentiment_score: sentiment_score - }) - ] - }); - } - catch (chError) { - console.error('Error recording project comment event:', chError); - // 继续执行,不中断主流程 - } - return c.json({ - message: 'Comment added successfully', - comment - }, 201); - } - catch (error) { - console.error('Error adding project comment:', error); - return c.json({ error: 'Internal server error' }, 500); - } -}); -// 更新项目评论 -projectCommentsRouter.put('/comments/:id', async (c) => { - try { - const commentId = c.req.param('id'); - const user = c.get('user'); - const { content, sentiment_score, is_pinned } = await c.req.json(); - // 检查评论是否存在且属于当前用户或用户是项目拥有者 - const { data: comment, error: fetchError } = await supabase_1.default - .from('project_comments') - .select(` - comment_id, - project_id, - user_id, - projects!inner(created_by) - `) - .eq('comment_id', commentId) - .single(); - if (fetchError || !comment) { - return c.json({ error: 'Comment not found' }, 404); - } - // 确保我们能够安全地访问projects中的created_by字段 - const projectOwner = comment.projects && - Array.isArray(comment.projects) && - comment.projects.length > 0 ? - comment.projects[0].created_by : null; - // 检查用户是否有权限更新评论 - const isCommentOwner = comment.user_id === user.id; - const isProjectOwner = projectOwner === user.id; - if (!isCommentOwner && !isProjectOwner) { - return c.json({ - error: 'You do not have permission to update this comment' - }, 403); - } - // 准备更新数据 - const updateData = {}; - // 评论创建者可以更新内容和情感分数 - if (isCommentOwner) { - if (content !== undefined) { - updateData.content = content; - } - if (sentiment_score !== undefined) { - updateData.sentiment_score = sentiment_score; - } - } - // 项目所有者可以更新状态和置顶 - if (isProjectOwner) { - if (is_pinned !== undefined) { - updateData.is_pinned = is_pinned; - } - } - // 更新时间 - updateData.updated_at = new Date().toISOString(); - // 如果没有内容要更新,返回错误 - if (Object.keys(updateData).length === 1) { // 只有updated_at - return c.json({ error: 'No valid fields to update' }, 400); - } - // 更新评论 - const { data: updatedComment, error } = await supabase_1.default - .from('project_comments') - .update(updateData) - .eq('comment_id', commentId) - .select() - .single(); - if (error) { - console.error('Error updating project comment:', error); - return c.json({ error: 'Failed to update comment' }, 500); - } - return c.json({ - message: 'Comment updated successfully', - comment: updatedComment - }); - } - catch (error) { - console.error('Error updating project comment:', error); - return c.json({ error: 'Internal server error' }, 500); - } -}); -// 删除项目评论 -projectCommentsRouter.delete('/comments/:id', async (c) => { - try { - const commentId = c.req.param('id'); - const user = c.get('user'); - // 检查评论是否存在且属于当前用户或用户是项目拥有者 - const { data: comment, error: fetchError } = await supabase_1.default - .from('project_comments') - .select(` - comment_id, - project_id, - user_id, - projects!inner(created_by) - `) - .eq('comment_id', commentId) - .single(); - if (fetchError || !comment) { - return c.json({ error: 'Comment not found' }, 404); - } - // 确保我们能够安全地访问projects中的created_by字段 - const projectOwner = comment.projects && - Array.isArray(comment.projects) && - comment.projects.length > 0 ? - comment.projects[0].created_by : null; - // 检查用户是否有权限删除评论 - const isCommentOwner = comment.user_id === user.id; - const isProjectOwner = projectOwner === user.id; - if (!isCommentOwner && !isProjectOwner) { - return c.json({ - error: 'You do not have permission to delete this comment' - }, 403); - } - // 删除评论 - const { error } = await supabase_1.default - .from('project_comments') - .delete() - .eq('comment_id', commentId); - if (error) { - console.error('Error deleting project comment:', error); - return c.json({ error: 'Failed to delete comment' }, 500); - } - return c.json({ - message: 'Comment deleted successfully' - }); - } - catch (error) { - console.error('Error deleting project comment:', error); - return c.json({ error: 'Internal server error' }, 500); - } -}); -// 获取项目评论的统计信息 -projectCommentsRouter.get('/projects/:id/comments/stats', async (c) => { - try { - const projectId = c.req.param('id'); - // 检查项目是否存在 - const { data: project, error: projectError } = await supabase_1.default - .from('projects') - .select('id, name') - .eq('id', projectId) - .single(); - if (projectError) { - console.error('Error fetching project:', projectError); - return c.json({ error: 'Project not found' }, 404); - } - // 从Supabase获取评论总数 - const { count } = await supabase_1.default - .from('project_comments') - .select('*', { count: 'exact', head: true }) - .eq('project_id', projectId); - // 从Supabase获取情感分析统计 - const { data: sentimentStats } = await supabase_1.default - .from('project_comments') - .select('sentiment_score') - .eq('project_id', projectId); - let averageSentiment = 0; - let positiveCount = 0; - let neutralCount = 0; - let negativeCount = 0; - if (sentimentStats && sentimentStats.length > 0) { - // 计算平均情感分数 - const totalSentiment = sentimentStats.reduce((acc, curr) => acc + (curr.sentiment_score || 0), 0); - averageSentiment = totalSentiment / sentimentStats.length; - // 分类情感分数 - sentimentStats.forEach(stat => { - const score = stat.sentiment_score || 0; - if (score > 0.3) { - positiveCount++; - } - else if (score < -0.3) { - negativeCount++; - } - else { - neutralCount++; - } - }); - } - let timeTrend = []; - try { - const result = await clickhouse_1.default.query({ - query: ` - SELECT - toDate(timestamp) as date, - count() as comment_count - FROM events - WHERE - project_id = ? AND - event_type = 'project_comment' AND - timestamp >= subtractDays(now(), 30) - GROUP BY date - ORDER BY date ASC - `, - values: [projectId] - }); - timeTrend = 'rows' in result ? result.rows : []; - } - catch (chError) { - console.error('Error fetching comment time trend:', chError); - // 继续执行,返回空趋势数据 - } - return c.json({ - project_id: projectId, - project_name: project.name, - total_comments: count || 0, - sentiment: { - average: averageSentiment, - positive: positiveCount, - neutral: neutralCount, - negative: negativeCount - }, - time_trend: timeTrend - }); - } - catch (error) { - console.error('Error fetching project comment stats:', error); - return c.json({ error: 'Internal server error' }, 500); - } -}); -exports.default = projectCommentsRouter; diff --git a/backend/dist/swagger/index.js b/backend/dist/swagger/index.js deleted file mode 100644 index 45df67d..0000000 --- a/backend/dist/swagger/index.js +++ /dev/null @@ -1,1863 +0,0 @@ -"use strict"; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.createSwaggerUI = exports.openAPISpec = void 0; -const swagger_ui_1 = require("@hono/swagger-ui"); -const hono_1 = require("hono"); -const jsonwebtoken_1 = __importDefault(require("jsonwebtoken")); -const config_1 = __importDefault(require("../config")); -// 创建 OpenAPI 规范 -exports.openAPISpec = { - openapi: '3.0.0', - info: { - title: 'Promote API', - version: '1.0.0', - description: 'API documentation for the Promote platform', - }, - servers: [ - { - url: 'http://localhost:4000', - description: 'Local development server', - }, - ], - paths: { - '/': { - get: { - summary: 'Health check', - description: 'Returns the API status', - responses: { - '200': { - description: 'API is running', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - status: { type: 'string', example: 'ok' }, - message: { type: 'string', example: 'Promote API is running' }, - version: { type: 'string', example: '1.0.0' }, - }, - }, - }, - }, - }, - }, - }, - }, - '/api/auth/register': { - post: { - summary: 'Register a new user', - description: 'Creates a new user account', - requestBody: { - required: true, - content: { - 'application/json': { - schema: { - type: 'object', - required: ['email', 'password', 'name'], - properties: { - email: { type: 'string', format: 'email', example: 'user@example.com' }, - password: { type: 'string', format: 'password', example: 'securepassword' }, - name: { type: 'string', example: 'John Doe' }, - }, - }, - }, - }, - }, - responses: { - '201': { - description: 'User registered successfully', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - message: { type: 'string', example: 'User registered successfully' }, - user: { - type: 'object', - properties: { - id: { type: 'string', example: '123e4567-e89b-12d3-a456-426614174000' }, - email: { type: 'string', example: 'user@example.com' }, - name: { type: 'string', example: 'John Doe' }, - }, - }, - token: { type: 'string', example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...' }, - }, - }, - }, - }, - }, - '400': { - description: 'Bad request', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - error: { type: 'string', example: 'Email, password, and name are required' }, - }, - }, - }, - }, - }, - '500': { - description: 'Internal server error', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - error: { type: 'string', example: 'Internal server error' }, - }, - }, - }, - }, - }, - }, - }, - }, - '/api/auth/login': { - post: { - summary: 'Login user', - description: 'Authenticates a user and returns a JWT token', - requestBody: { - required: true, - content: { - 'application/json': { - schema: { - type: 'object', - required: ['email', 'password'], - properties: { - email: { type: 'string', format: 'email', example: 'vitalitymailg@gmail.com' }, - password: { type: 'string', format: 'password', example: 'password123' }, - }, - }, - examples: { - demoUser: { - summary: '示例用户', - value: { - email: 'vitalitymailg@gmail.com', - password: 'password123' - } - } - } - }, - }, - }, - responses: { - '200': { - description: 'Login successful', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - message: { type: 'string', example: 'Login successful' }, - user: { - type: 'object', - properties: { - id: { type: 'string', example: '123e4567-e89b-12d3-a456-426614174000' }, - email: { type: 'string', example: 'vitalitymailg@gmail.com' }, - name: { type: 'string', example: 'Vitality User' }, - }, - }, - token: { type: 'string', example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...' }, - }, - }, - }, - }, - }, - '401': { - description: 'Unauthorized', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - error: { type: 'string', example: 'Invalid credentials' }, - }, - }, - }, - }, - }, - '500': { - description: 'Internal server error', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - error: { type: 'string', example: 'Internal server error' }, - }, - }, - }, - }, - }, - }, - }, - }, - '/api/auth/verify': { - get: { - summary: 'Verify token', - description: 'Verifies a JWT token', - security: [ - { - bearerAuth: [], - }, - ], - responses: { - '200': { - description: 'Token is valid', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - message: { type: 'string', example: 'Token is valid' }, - user: { - type: 'object', - properties: { - id: { type: 'string', example: '123e4567-e89b-12d3-a456-426614174000' }, - email: { type: 'string', example: 'vitalitymailg@gmail.com' }, - }, - }, - }, - }, - }, - }, - }, - '401': { - description: 'Unauthorized', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - error: { type: 'string', example: 'Invalid token' }, - }, - }, - }, - }, - }, - '500': { - description: 'Internal server error', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - error: { type: 'string', example: 'Internal server error' }, - }, - }, - }, - }, - }, - }, - }, - }, - '/api/analytics/view': { - post: { - summary: 'Track view event', - description: 'Records a view event for content', - security: [ - { - bearerAuth: [], - }, - ], - requestBody: { - required: true, - content: { - 'application/json': { - schema: { - type: 'object', - required: ['content_id'], - properties: { - content_id: { type: 'string', example: 'content-123' }, - }, - }, - }, - }, - }, - responses: { - '200': { - description: 'View tracked successfully', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - message: { type: 'string', example: 'View tracked successfully' }, - }, - }, - }, - }, - }, - '400': { - description: 'Bad request', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - error: { type: 'string', example: 'Content ID is required' }, - }, - }, - }, - }, - }, - '401': { - description: 'Unauthorized', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - error: { type: 'string', example: 'Unauthorized: No token provided' }, - }, - }, - }, - }, - }, - '500': { - description: 'Internal server error', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - error: { type: 'string', example: 'Internal server error' }, - }, - }, - }, - }, - }, - }, - }, - }, - '/api/analytics/like': { - post: { - summary: 'Track like event', - description: 'Records a like or unlike event for content', - security: [ - { - bearerAuth: [], - }, - ], - requestBody: { - required: true, - content: { - 'application/json': { - schema: { - type: 'object', - required: ['content_id', 'action'], - properties: { - content_id: { type: 'string', example: 'content-123' }, - action: { type: 'string', enum: ['like', 'unlike'], example: 'like' }, - }, - }, - }, - }, - }, - responses: { - '200': { - description: 'Like/unlike tracked successfully', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - message: { type: 'string', example: 'like tracked successfully' }, - }, - }, - }, - }, - }, - '400': { - description: 'Bad request', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - error: { type: 'string', example: 'Content ID and action are required' }, - }, - }, - }, - }, - }, - '401': { - description: 'Unauthorized', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - error: { type: 'string', example: 'Unauthorized: No token provided' }, - }, - }, - }, - }, - }, - '500': { - description: 'Internal server error', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - error: { type: 'string', example: 'Internal server error' }, - }, - }, - }, - }, - }, - }, - }, - }, - '/api/analytics/follow': { - post: { - summary: 'Track follow event', - description: 'Records a follow or unfollow event for a user', - security: [ - { - bearerAuth: [], - }, - ], - requestBody: { - required: true, - content: { - 'application/json': { - schema: { - type: 'object', - required: ['followed_id', 'action'], - properties: { - followed_id: { type: 'string', example: 'user-123' }, - action: { type: 'string', enum: ['follow', 'unfollow'], example: 'follow' }, - }, - }, - }, - }, - }, - responses: { - '200': { - description: 'Follow/unfollow tracked successfully', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - message: { type: 'string', example: 'follow tracked successfully' }, - }, - }, - }, - }, - }, - '400': { - description: 'Bad request', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - error: { type: 'string', example: 'Followed ID and action are required' }, - }, - }, - }, - }, - }, - '401': { - description: 'Unauthorized', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - error: { type: 'string', example: 'Unauthorized: No token provided' }, - }, - }, - }, - }, - }, - '500': { - description: 'Internal server error', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - error: { type: 'string', example: 'Internal server error' }, - }, - }, - }, - }, - }, - }, - }, - }, - '/api/analytics/content/{id}': { - get: { - summary: 'Get content analytics', - description: 'Returns analytics data for a specific content', - security: [ - { - bearerAuth: [], - }, - ], - parameters: [ - { - name: 'id', - in: 'path', - required: true, - schema: { - type: 'string', - }, - description: 'Content ID', - example: 'content-123', - }, - ], - responses: { - '200': { - description: 'Content analytics data', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - content_id: { type: 'string', example: 'content-123' }, - views: { type: 'integer', example: 1250 }, - likes: { type: 'integer', example: 87 }, - }, - }, - }, - }, - }, - '401': { - description: 'Unauthorized', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - error: { type: 'string', example: 'Unauthorized: No token provided' }, - }, - }, - }, - }, - }, - '500': { - description: 'Internal server error', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - error: { type: 'string', example: 'Internal server error' }, - }, - }, - }, - }, - }, - }, - }, - }, - '/api/analytics/user/{id}': { - get: { - summary: 'Get user analytics', - description: 'Returns analytics data for a specific user', - security: [ - { - bearerAuth: [], - }, - ], - parameters: [ - { - name: 'id', - in: 'path', - required: true, - schema: { - type: 'string', - }, - description: 'User ID', - example: 'user-123', - }, - ], - responses: { - '200': { - description: 'User analytics data', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - user_id: { type: 'string', example: 'user-123' }, - followers: { type: 'integer', example: 542 }, - content_analytics: { - type: 'object', - properties: { - views: { - type: 'array', - items: { - type: 'object', - properties: { - content_id: { type: 'string', example: 'content-123' }, - view_count: { type: 'integer', example: 1250 }, - }, - }, - }, - likes: { - type: 'array', - items: { - type: 'object', - properties: { - content_id: { type: 'string', example: 'content-123' }, - like_count: { type: 'integer', example: 87 }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - '401': { - description: 'Unauthorized', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - error: { type: 'string', example: 'Unauthorized: No token provided' }, - }, - }, - }, - }, - }, - '500': { - description: 'Internal server error', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - error: { type: 'string', example: 'Internal server error' }, - }, - }, - }, - }, - }, - }, - }, - }, - '/api/posts': { - get: { - summary: '获取帖子列表', - description: '返回分页的帖子列表,支持过滤和排序', - security: [{ bearerAuth: [] }], - parameters: [ - { - name: 'influencer_id', - in: 'query', - description: '按影响者ID过滤', - schema: { type: 'string', format: 'uuid' }, - required: false - }, - { - name: 'platform', - in: 'query', - description: '按平台过滤', - schema: { - type: 'string', - enum: ['youtube', 'instagram', 'tiktok', 'twitter', 'facebook'] - }, - required: false - }, - { - name: 'limit', - in: 'query', - description: '每页返回的记录数', - schema: { type: 'integer', default: 20 }, - required: false - }, - { - name: 'offset', - in: 'query', - description: '分页偏移量', - schema: { type: 'integer', default: 0 }, - required: false - }, - { - name: 'sort', - in: 'query', - description: '排序字段', - schema: { type: 'string', default: 'published_at' }, - required: false - }, - { - name: 'order', - in: 'query', - description: '排序方向', - schema: { - type: 'string', - enum: ['asc', 'desc'], - default: 'desc' - }, - required: false - } - ], - responses: { - '200': { - description: '成功获取帖子列表', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - posts: { - type: 'array', - items: { - $ref: '#/components/schemas/Post' - } - }, - total: { type: 'integer' }, - limit: { type: 'integer' }, - offset: { type: 'integer' } - } - } - } - } - }, - '500': { - description: '服务器错误', - content: { - 'application/json': { - schema: { - $ref: '#/components/schemas/Error' - } - } - } - } - } - }, - post: { - summary: '创建新帖子', - description: '创建一个新的帖子', - security: [{ bearerAuth: [] }], - requestBody: { - required: true, - content: { - 'application/json': { - schema: { - type: 'object', - required: ['influencer_id', 'platform', 'post_url'], - properties: { - influencer_id: { type: 'string', format: 'uuid' }, - platform: { - type: 'string', - enum: ['youtube', 'instagram', 'tiktok', 'twitter', 'facebook'] - }, - post_url: { type: 'string', format: 'uri' }, - title: { type: 'string' }, - description: { type: 'string' }, - published_at: { type: 'string', format: 'date-time' } - } - } - } - } - }, - responses: { - '201': { - description: '帖子创建成功', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - message: { type: 'string' }, - post: { - $ref: '#/components/schemas/Post' - } - } - } - } - } - }, - '400': { - description: '请求参数错误', - content: { - 'application/json': { - schema: { - $ref: '#/components/schemas/Error' - } - } - } - }, - '409': { - description: '帖子URL已存在', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - error: { type: 'string' }, - post: { - $ref: '#/components/schemas/Post' - } - } - } - } - } - }, - '500': { - description: '服务器错误', - content: { - 'application/json': { - schema: { - $ref: '#/components/schemas/Error' - } - } - } - } - } - } - }, - '/api/posts/{id}': { - get: { - summary: '获取单个帖子详情', - description: '返回指定ID的帖子详情,包括统计数据和时间线', - security: [{ bearerAuth: [] }], - parameters: [ - { - name: 'id', - in: 'path', - description: '帖子ID', - required: true, - schema: { type: 'string', format: 'uuid' } - } - ], - responses: { - '200': { - description: '成功获取帖子详情', - content: { - 'application/json': { - schema: { - $ref: '#/components/schemas/PostDetail' - } - } - } - }, - '404': { - description: '帖子不存在', - content: { - 'application/json': { - schema: { - $ref: '#/components/schemas/Error' - } - } - } - }, - '500': { - description: '服务器错误', - content: { - 'application/json': { - schema: { - $ref: '#/components/schemas/Error' - } - } - } - } - } - }, - put: { - summary: '更新帖子', - description: '更新指定ID的帖子信息', - security: [{ bearerAuth: [] }], - parameters: [ - { - name: 'id', - in: 'path', - description: '帖子ID', - required: true, - schema: { type: 'string', format: 'uuid' } - } - ], - requestBody: { - required: true, - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - title: { type: 'string' }, - description: { type: 'string' } - } - } - } - } - }, - responses: { - '200': { - description: '帖子更新成功', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - message: { type: 'string' }, - post: { - $ref: '#/components/schemas/Post' - } - } - } - } - } - }, - '404': { - description: '帖子不存在', - content: { - 'application/json': { - schema: { - $ref: '#/components/schemas/Error' - } - } - } - }, - '500': { - description: '服务器错误', - content: { - 'application/json': { - schema: { - $ref: '#/components/schemas/Error' - } - } - } - } - } - }, - delete: { - summary: '删除帖子', - description: '删除指定ID的帖子', - security: [{ bearerAuth: [] }], - parameters: [ - { - name: 'id', - in: 'path', - description: '帖子ID', - required: true, - schema: { type: 'string', format: 'uuid' } - } - ], - responses: { - '200': { - description: '帖子删除成功', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - message: { type: 'string' } - } - } - } - } - }, - '500': { - description: '服务器错误', - content: { - 'application/json': { - schema: { - $ref: '#/components/schemas/Error' - } - } - } - } - } - } - }, - '/api/posts/{id}/comments': { - get: { - summary: '获取帖子评论', - description: '返回指定帖子的评论列表', - security: [{ bearerAuth: [] }], - parameters: [ - { - name: 'id', - in: 'path', - description: '帖子ID', - required: true, - schema: { type: 'string', format: 'uuid' } - }, - { - name: 'limit', - in: 'query', - description: '每页返回的记录数', - schema: { type: 'integer', default: 20 }, - required: false - }, - { - name: 'offset', - in: 'query', - description: '分页偏移量', - schema: { type: 'integer', default: 0 }, - required: false - } - ], - responses: { - '200': { - description: '成功获取评论列表', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - comments: { - type: 'array', - items: { - $ref: '#/components/schemas/Comment' - } - }, - total: { type: 'integer' }, - limit: { type: 'integer' }, - offset: { type: 'integer' } - } - } - } - } - }, - '500': { - description: '服务器错误', - content: { - 'application/json': { - schema: { - $ref: '#/components/schemas/Error' - } - } - } - } - } - }, - post: { - summary: '添加评论', - description: '为指定帖子添加新评论', - security: [{ bearerAuth: [] }], - parameters: [ - { - name: 'id', - in: 'path', - description: '帖子ID', - required: true, - schema: { type: 'string', format: 'uuid' } - } - ], - requestBody: { - required: true, - content: { - 'application/json': { - schema: { - type: 'object', - required: ['content'], - properties: { - content: { type: 'string' }, - sentiment_score: { type: 'number' } - } - } - } - } - }, - responses: { - '201': { - description: '评论添加成功', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - message: { type: 'string' }, - comment: { - $ref: '#/components/schemas/Comment' - } - } - } - } - } - }, - '400': { - description: '请求参数错误', - content: { - 'application/json': { - schema: { - $ref: '#/components/schemas/Error' - } - } - } - }, - '500': { - description: '服务器错误', - content: { - 'application/json': { - schema: { - $ref: '#/components/schemas/Error' - } - } - } - } - } - } - }, - '/api/posts/comments/{id}': { - put: { - summary: '更新评论', - description: '更新指定ID的评论', - security: [{ bearerAuth: [] }], - parameters: [ - { - name: 'id', - in: 'path', - description: '评论ID', - required: true, - schema: { type: 'string', format: 'uuid' } - } - ], - requestBody: { - required: true, - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - content: { type: 'string' }, - sentiment_score: { type: 'number' } - } - } - } - } - }, - responses: { - '200': { - description: '评论更新成功', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - message: { type: 'string' }, - comment: { - $ref: '#/components/schemas/Comment' - } - } - } - } - } - }, - '404': { - description: '评论不存在或无权限', - content: { - 'application/json': { - schema: { - $ref: '#/components/schemas/Error' - } - } - } - }, - '500': { - description: '服务器错误', - content: { - 'application/json': { - schema: { - $ref: '#/components/schemas/Error' - } - } - } - } - } - }, - delete: { - summary: '删除评论', - description: '删除指定ID的评论', - security: [{ bearerAuth: [] }], - parameters: [ - { - name: 'id', - in: 'path', - description: '评论ID', - required: true, - schema: { type: 'string', format: 'uuid' } - } - ], - responses: { - '200': { - description: '评论删除成功', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - message: { type: 'string' } - } - } - } - } - }, - '404': { - description: '评论不存在或无权限', - content: { - 'application/json': { - schema: { - $ref: '#/components/schemas/Error' - } - } - } - }, - '500': { - description: '服务器错误', - content: { - 'application/json': { - schema: { - $ref: '#/components/schemas/Error' - } - } - } - } - } - } - }, - '/api/comments': { - get: { - tags: ['Comments'], - summary: 'Get comments', - description: 'Retrieve a list of comments with optional filtering by post_id', - parameters: [ - { - name: 'post_id', - in: 'query', - description: 'Filter comments by post ID', - required: false, - schema: { - type: 'string', - format: 'uuid' - } - }, - { - name: 'limit', - in: 'query', - description: 'Number of comments to return', - required: false, - schema: { - type: 'integer', - default: 10 - } - }, - { - name: 'offset', - in: 'query', - description: 'Number of comments to skip', - required: false, - schema: { - type: 'integer', - default: 0 - } - } - ], - responses: { - '200': { - description: 'List of comments', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - comments: { - type: 'array', - items: { - $ref: '#/components/schemas/Comment' - } - }, - count: { - type: 'integer' - }, - limit: { - type: 'integer' - }, - offset: { - type: 'integer' - } - } - } - } - } - } - } - }, - post: { - tags: ['Comments'], - summary: 'Create a comment', - description: 'Create a new comment on a post', - security: [ - { - bearerAuth: [] - } - ], - requestBody: { - required: true, - content: { - 'application/json': { - schema: { - type: 'object', - required: ['post_id', 'content'], - properties: { - post_id: { - type: 'string', - format: 'uuid' - }, - content: { - type: 'string' - } - } - } - } - } - }, - responses: { - '201': { - description: 'Comment created successfully', - content: { - 'application/json': { - schema: { - $ref: '#/components/schemas/Comment' - } - } - } - }, - '401': { - description: 'Unauthorized' - } - } - } - }, - '/api/comments/{comment_id}': { - delete: { - tags: ['Comments'], - summary: 'Delete a comment', - description: 'Delete a comment by ID (only for comment owner)', - security: [ - { - bearerAuth: [] - } - ], - parameters: [ - { - name: 'comment_id', - in: 'path', - description: 'Comment ID to delete', - required: true, - schema: { - type: 'string', - format: 'uuid' - } - } - ], - responses: { - '204': { - description: 'Comment deleted successfully' - }, - '401': { - description: 'Unauthorized' - }, - '404': { - description: 'Comment not found or unauthorized' - } - } - } - }, - '/api/influencers': { - get: { - tags: ['Influencers'], - summary: 'Get influencers', - description: 'Retrieve a list of influencers with optional filtering and sorting', - parameters: [ - { - name: 'platform', - in: 'query', - description: 'Filter by platform', - required: false, - schema: { - type: 'string', - enum: ['youtube', 'instagram', 'tiktok', 'twitter', 'facebook'] - } - }, - { - name: 'min_followers', - in: 'query', - description: 'Minimum number of followers', - required: false, - schema: { - type: 'integer' - } - }, - { - name: 'max_followers', - in: 'query', - description: 'Maximum number of followers', - required: false, - schema: { - type: 'integer' - } - }, - { - name: 'sort_by', - in: 'query', - description: 'Field to sort by', - required: false, - schema: { - type: 'string', - enum: ['followers_count', 'video_count', 'created_at'], - default: 'followers_count' - } - }, - { - name: 'sort_order', - in: 'query', - description: 'Sort order', - required: false, - schema: { - type: 'string', - enum: ['asc', 'desc'], - default: 'desc' - } - }, - { - name: 'limit', - in: 'query', - description: 'Number of influencers to return', - required: false, - schema: { - type: 'integer', - default: 10 - } - }, - { - name: 'offset', - in: 'query', - description: 'Number of influencers to skip', - required: false, - schema: { - type: 'integer', - default: 0 - } - } - ], - responses: { - '200': { - description: 'List of influencers', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - influencers: { - type: 'array', - items: { - $ref: '#/components/schemas/Influencer' - } - }, - count: { - type: 'integer' - }, - limit: { - type: 'integer' - }, - offset: { - type: 'integer' - } - } - } - } - } - } - } - } - }, - '/api/influencers/stats': { - get: { - tags: ['Influencers'], - summary: 'Get influencer statistics', - description: 'Retrieve aggregated statistics about influencers', - parameters: [ - { - name: 'platform', - in: 'query', - description: 'Filter statistics by platform', - required: false, - schema: { - type: 'string', - enum: ['youtube', 'instagram', 'tiktok', 'twitter', 'facebook'] - } - } - ], - responses: { - '200': { - description: 'Influencer statistics', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - total_influencers: { - type: 'integer' - }, - total_followers: { - type: 'integer' - }, - total_videos: { - type: 'integer' - }, - average_followers: { - type: 'integer' - }, - average_videos: { - type: 'integer' - } - } - } - } - } - } - } - } - }, - '/api/influencers/{influencer_id}': { - get: { - tags: ['Influencers'], - summary: 'Get influencer by ID', - description: 'Retrieve detailed information about a specific influencer', - parameters: [ - { - name: 'influencer_id', - in: 'path', - description: 'Influencer ID', - required: true, - schema: { - type: 'string', - format: 'uuid' - } - } - ], - responses: { - '200': { - description: 'Influencer details', - content: { - 'application/json': { - schema: { - $ref: '#/components/schemas/InfluencerWithPosts' - } - } - } - }, - '404': { - description: 'Influencer not found' - } - } - } - } - }, - components: { - schemas: { - Post: { - type: 'object', - properties: { - post_id: { type: 'string', format: 'uuid' }, - influencer_id: { type: 'string', format: 'uuid' }, - platform: { - type: 'string', - enum: ['youtube', 'instagram', 'tiktok', 'twitter', 'facebook'] - }, - post_url: { type: 'string', format: 'uri' }, - title: { type: 'string' }, - description: { type: 'string' }, - published_at: { type: 'string', format: 'date-time' }, - created_at: { type: 'string', format: 'date-time' }, - updated_at: { type: 'string', format: 'date-time' }, - influencer: { - type: 'object', - properties: { - name: { type: 'string' }, - platform: { type: 'string' }, - profile_url: { type: 'string' }, - followers_count: { type: 'integer' } - } - }, - stats: { - type: 'object', - properties: { - views: { type: 'integer' }, - likes: { type: 'integer' } - } - } - } - }, - PostDetail: { - type: 'object', - properties: { - post_id: { type: 'string', format: 'uuid' }, - influencer_id: { type: 'string', format: 'uuid' }, - platform: { - type: 'string', - enum: ['youtube', 'instagram', 'tiktok', 'twitter', 'facebook'] - }, - post_url: { type: 'string', format: 'uri' }, - title: { type: 'string' }, - description: { type: 'string' }, - published_at: { type: 'string', format: 'date-time' }, - created_at: { type: 'string', format: 'date-time' }, - updated_at: { type: 'string', format: 'date-time' }, - influencer: { - type: 'object', - properties: { - name: { type: 'string' }, - platform: { type: 'string' }, - profile_url: { type: 'string' }, - followers_count: { type: 'integer' } - } - }, - stats: { - type: 'object', - properties: { - views: { type: 'integer' }, - likes: { type: 'integer' } - } - }, - timeline: { - type: 'array', - items: { - type: 'object', - properties: { - date: { type: 'string', format: 'date' }, - event_type: { type: 'string' }, - value: { type: 'integer' } - } - } - }, - comment_count: { type: 'integer' } - } - }, - Comment: { - type: 'object', - properties: { - comment_id: { - type: 'string', - format: 'uuid' - }, - content: { - type: 'string' - }, - sentiment_score: { - type: 'number', - description: '情感分析分数,范围从 -1 到 1' - }, - created_at: { - type: 'string', - format: 'date-time' - }, - updated_at: { - type: 'string', - format: 'date-time' - }, - post_id: { - type: 'string', - format: 'uuid' - }, - user_id: { - type: 'string', - format: 'uuid' - }, - user_profile: { - type: 'object', - properties: { - id: { - type: 'string', - format: 'uuid' - }, - full_name: { - type: 'string' - }, - avatar_url: { - type: 'string', - format: 'uri' - } - } - }, - post: { - type: 'object', - properties: { - id: { - type: 'string', - format: 'uuid' - }, - title: { - type: 'string' - }, - platform: { - type: 'string', - enum: ['facebook', 'threads', 'instagram', 'linkedin', 'xiaohongshu', 'youtube'] - }, - content_type: { - type: 'string', - enum: ['post', 'reel', 'video', 'short'] - }, - influencer: { - type: 'object', - properties: { - id: { - type: 'string', - format: 'uuid' - }, - name: { - type: 'string' - }, - type: { - type: 'string', - enum: ['user', 'kol', 'official'] - } - } - } - } - }, - status: { - type: 'string', - enum: ['pending', 'approved', 'rejected'] - }, - reply_status: { - type: 'string', - enum: ['none', 'draft', 'sent'] - }, - language: { - type: 'string', - enum: ['zh-TW', 'zh-CN', 'en'] - } - } - }, - Influencer: { - type: 'object', - properties: { - influencer_id: { - type: 'string', - format: 'uuid' - }, - name: { - type: 'string' - }, - platform: { - type: 'string', - enum: ['youtube', 'instagram', 'tiktok', 'twitter', 'facebook'] - }, - profile_url: { - type: 'string' - }, - followers_count: { - type: 'integer' - }, - video_count: { - type: 'integer' - }, - platform_count: { - type: 'integer' - }, - created_at: { - type: 'string', - format: 'date-time' - }, - updated_at: { - type: 'string', - format: 'date-time' - } - } - }, - InfluencerWithPosts: { - allOf: [ - { - $ref: '#/components/schemas/Influencer' - }, - { - type: 'object', - properties: { - posts: { - type: 'array', - items: { - type: 'object', - properties: { - post_id: { - type: 'string', - format: 'uuid' - }, - title: { - type: 'string' - }, - description: { - type: 'string' - }, - published_at: { - type: 'string', - format: 'date-time' - } - } - } - } - } - } - ] - }, - Error: { - type: 'object', - properties: { - error: { type: 'string' } - } - } - }, - securitySchemes: { - bearerAuth: { - type: 'http', - scheme: 'bearer', - bearerFormat: 'JWT', - description: '登录后获取的 token 可直接在此处使用。在 Authorize 按钮中输入 "Bearer your-token" 或直接输入 token(不带 Bearer 前缀)。' - }, - }, - }, -}; -// 创建 Swagger UI 路由 -const createSwaggerUI = () => { - const app = new hono_1.Hono(); - // 设置 Swagger UI 路由 - app.get('/swagger', (0, swagger_ui_1.swaggerUI)({ - url: '/api/swagger.json', - })); - // 提供 OpenAPI 规范的 JSON 端点 - app.get('/api/swagger.json', (c) => { - return c.json(exports.openAPISpec); - }); - // 添加临时的 token 生成端点,仅用于 Swagger 测试 - app.get('/api/swagger/token', async (c) => { - try { - // 创建一个临时 token,与 authMiddleware 中的验证方式一致 - const token = jsonwebtoken_1.default.sign({ - sub: 'swagger-test-user', - email: 'swagger@test.com', - }, config_1.default.jwt.secret, { - expiresIn: '1h', - }); - return c.json({ - message: '此 token 仅用于 Swagger UI 测试', - token, - usage: '在 Authorize 对话框中输入: Bearer [token]' - }); - } - catch (error) { - console.error('Error generating swagger token:', error); - return c.json({ error: 'Failed to generate token' }, 500); - } - }); - return app; -}; -exports.createSwaggerUI = createSwaggerUI; diff --git a/backend/dist/utils/clickhouse.js b/backend/dist/utils/clickhouse.js deleted file mode 100644 index b2d504f..0000000 --- a/backend/dist/utils/clickhouse.js +++ /dev/null @@ -1,87 +0,0 @@ -"use strict"; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.initClickHouse = void 0; -const client_1 = require("@clickhouse/client"); -const config_1 = __importDefault(require("../config")); -// Create ClickHouse client with error handling -const createClickHouseClient = () => { - try { - return (0, client_1.createClient)({ - host: `http://${config_1.default.clickhouse.host}:${config_1.default.clickhouse.port}`, - username: config_1.default.clickhouse.user, - password: config_1.default.clickhouse.password, - database: config_1.default.clickhouse.database, - }); - } - catch (error) { - console.error('Error creating ClickHouse client:', error); - // Return a mock client for development that logs operations instead of executing them - return { - query: async ({ query, values }) => { - console.log('ClickHouse query (mock):', query, values); - return { rows: [] }; - }, - close: async () => { - console.log('ClickHouse connection closed (mock)'); - } - }; - } -}; -const clickhouse = createClickHouseClient(); -// Initialize ClickHouse database and tables -const initClickHouse = async () => { - try { - // Create database if not exists - await clickhouse.query({ - query: `CREATE DATABASE IF NOT EXISTS ${config_1.default.clickhouse.database}`, - }); - // Create tables for tracking events - await clickhouse.query({ - query: ` - CREATE TABLE IF NOT EXISTS ${config_1.default.clickhouse.database}.view_events ( - user_id String, - content_id String, - timestamp DateTime DEFAULT now(), - ip String, - user_agent String - ) ENGINE = MergeTree() - PARTITION BY toYYYYMM(timestamp) - ORDER BY (user_id, content_id, timestamp) - `, - }); - await clickhouse.query({ - query: ` - CREATE TABLE IF NOT EXISTS ${config_1.default.clickhouse.database}.like_events ( - user_id String, - content_id String, - timestamp DateTime DEFAULT now(), - action Enum('like' = 1, 'unlike' = 2) - ) ENGINE = MergeTree() - PARTITION BY toYYYYMM(timestamp) - ORDER BY (user_id, content_id, timestamp) - `, - }); - await clickhouse.query({ - query: ` - CREATE TABLE IF NOT EXISTS ${config_1.default.clickhouse.database}.follower_events ( - follower_id String, - followed_id String, - timestamp DateTime DEFAULT now(), - action Enum('follow' = 1, 'unfollow' = 2) - ) ENGINE = MergeTree() - PARTITION BY toYYYYMM(timestamp) - ORDER BY (follower_id, followed_id, timestamp) - `, - }); - console.log('ClickHouse database and tables initialized'); - } - catch (error) { - console.error('Error initializing ClickHouse:', error); - console.log('Continuing with limited functionality...'); - } -}; -exports.initClickHouse = initClickHouse; -exports.default = clickhouse; diff --git a/backend/dist/utils/initDatabase.js b/backend/dist/utils/initDatabase.js deleted file mode 100644 index c960dde..0000000 --- a/backend/dist/utils/initDatabase.js +++ /dev/null @@ -1,492 +0,0 @@ -"use strict"; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.initDatabase = exports.checkDatabaseConnection = exports.createSampleData = exports.initSupabaseFunctions = exports.initClickHouseTables = exports.initSupabaseTables = void 0; -const supabase_1 = __importDefault(require("./supabase")); -const clickhouse_1 = __importDefault(require("./clickhouse")); -const promises_1 = __importDefault(require("fs/promises")); -const path_1 = __importDefault(require("path")); -/** - * 初始化 Supabase (PostgreSQL) 数据库表 - */ -const initSupabaseTables = async () => { - try { - console.log('开始初始化 Supabase 数据表...'); - // 创建用户扩展表 - await supabase_1.default.rpc('create_user_profiles_if_not_exists'); - // 创建项目表 - await supabase_1.default.rpc('create_projects_table_if_not_exists'); - // 创建网红(影响者)表 - await supabase_1.default.rpc('create_influencers_table_if_not_exists'); - // 创建项目-网红关联表 - await supabase_1.default.rpc('create_project_influencers_table_if_not_exists'); - // 创建帖子表 - await supabase_1.default.rpc('create_posts_table_if_not_exists'); - // 创建评论表 - await supabase_1.default.rpc('create_comments_table_if_not_exists'); - // 创建项目评论表 - await supabase_1.default.rpc('create_project_comments_table_if_not_exists'); - console.log('Supabase 数据表初始化完成'); - return true; - } - catch (error) { - console.error('初始化 Supabase 数据表失败:', error); - return false; - } -}; -exports.initSupabaseTables = initSupabaseTables; -/** - * 初始化 ClickHouse 数据库表 - */ -const initClickHouseTables = async () => { - try { - console.log('开始初始化 ClickHouse 数据表...'); - // 创建事件表 - await clickhouse_1.default.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_1.default.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_1.default.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_1.default.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_1.default.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; - } -}; -exports.initClickHouseTables = initClickHouseTables; -/** - * 初始化 Supabase 存储函数 - */ -const initSupabaseFunctions = async () => { - try { - console.log('开始初始化 Supabase 存储过程...'); - // 创建用户简档表的存储过程 - await supabase_1.default.rpc('create_function_create_user_profiles_if_not_exists'); - // 创建项目表的存储过程 - await supabase_1.default.rpc('create_function_create_projects_table_if_not_exists'); - // 创建网红表的存储过程 - await supabase_1.default.rpc('create_function_create_influencers_table_if_not_exists'); - // 创建项目-网红关联表的存储过程 - await supabase_1.default.rpc('create_function_create_project_influencers_table_if_not_exists'); - // 创建帖子表的存储过程 - await supabase_1.default.rpc('create_function_create_posts_table_if_not_exists'); - // 创建评论表的存储过程 - await supabase_1.default.rpc('create_function_create_comments_table_if_not_exists'); - // 创建项目评论表的存储过程 - await supabase_1.default.rpc('create_function_create_project_comments_table_if_not_exists'); - // 创建评论相关的SQL函数 - console.log('创建评论相关的SQL函数...'); - const commentsSQL = await promises_1.default.readFile(path_1.default.join(__dirname, 'supabase-comments-functions.sql'), 'utf8'); - // 使用Supabase执行SQL - const { error: commentsFunctionsError } = await supabase_1.default.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; - } -}; -exports.initSupabaseFunctions = initSupabaseFunctions; -/** - * 创建测试数据 - */ -const createSampleData = async () => { - try { - console.log('开始创建测试数据...'); - // 创建测试用户 - const { data: user, error: userError } = await supabase_1.default.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_1.default - .from('projects') - .insert({ - name: '测试营销活动', - description: '这是一个测试营销活动', - created_by: user.user.id - }) - .select() - .single(); - if (projectError) { - console.error('创建测试项目失败:', projectError); - return false; - } - // 创建项目评论 - await supabase_1.default - .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_1.default - .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_1.default - .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_1.default - .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_1.default - .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_1.default.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_1.default.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_1.default.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_1.default.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_1.default.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; - } -}; -exports.createSampleData = createSampleData; -/** - * 检查数据库连接 - */ -const checkDatabaseConnection = async () => { - try { - console.log('检查数据库连接...'); - // 检查 Supabase 连接 - try { - // 仅检查连接是否正常,不执行实际查询 - const { data, error } = await supabase_1.default.auth.getSession(); - if (error) { - console.error('Supabase 连接测试失败:', error); - return false; - } - console.log('Supabase 连接正常'); - } - catch (supabaseError) { - console.error('Supabase 连接测试失败:', supabaseError); - return false; - } - // 检查 ClickHouse 连接 - try { - // 使用简单查询代替ping方法 - const result = await clickhouse_1.default.query({ query: 'SELECT 1' }); - console.log('ClickHouse 连接正常'); - } - catch (error) { - console.error('ClickHouse 连接测试失败:', error); - return false; - } - console.log('数据库连接检查完成,所有连接均正常'); - return true; - } - catch (error) { - console.error('数据库连接检查失败:', error); - return false; - } -}; -exports.checkDatabaseConnection = checkDatabaseConnection; -/** - * 初始化数据库 - 此函数现在仅作为手动初始化的入口点 - * 只有通过管理API明确调用时才会执行实际的初始化 - */ -const initDatabase = async () => { - try { - console.log('开始数据库初始化...'); - console.log('警告: 此操作将修改数据库结构,请确保您知道自己在做什么'); - // 初始化 Supabase 函数 - await (0, exports.initSupabaseFunctions)(); - // 初始化 Supabase 表 - await (0, exports.initSupabaseTables)(); - // 初始化 ClickHouse 表 - await (0, exports.initClickHouseTables)(); - console.log('数据库初始化完成'); - return true; - } - catch (error) { - console.error('数据库初始化失败:', error); - return false; - } -}; -exports.initDatabase = initDatabase; diff --git a/backend/dist/utils/queue.js b/backend/dist/utils/queue.js deleted file mode 100644 index 848f61f..0000000 --- a/backend/dist/utils/queue.js +++ /dev/null @@ -1,158 +0,0 @@ -"use strict"; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.addNotificationJob = exports.addAnalyticsJob = exports.initWorkers = exports.QUEUE_NAMES = void 0; -const bullmq_1 = require("bullmq"); -const config_1 = __importDefault(require("../config")); -// Define queue names -exports.QUEUE_NAMES = { - ANALYTICS: 'analytics', - NOTIFICATIONS: 'notifications', -}; -// Create Redis connection options -const redisOptions = { - host: config_1.default.bull.redis.host, - port: config_1.default.bull.redis.port, - password: config_1.default.bull.redis.password, -}; -// Create queues with error handling -let analyticsQueue; -let notificationsQueue; -try { - analyticsQueue = new bullmq_1.Queue(exports.QUEUE_NAMES.ANALYTICS, { - connection: redisOptions, - defaultJobOptions: { - attempts: 3, - backoff: { - type: 'exponential', - delay: 1000, - }, - }, - }); - notificationsQueue = new bullmq_1.Queue(exports.QUEUE_NAMES.NOTIFICATIONS, { - connection: redisOptions, - defaultJobOptions: { - attempts: 3, - backoff: { - type: 'exponential', - delay: 1000, - }, - }, - }); -} -catch (error) { - console.error('Error initializing BullMQ queues:', error); - // Create mock queues for development - analyticsQueue = { - add: async (name, data) => { - console.log(`Mock analytics job added: ${name}`, data); - return { id: 'mock-job-id' }; - }, - close: async () => console.log('Mock analytics queue closed'), - }; - notificationsQueue = { - add: async (name, data) => { - console.log(`Mock notification job added: ${name}`, data); - return { id: 'mock-job-id' }; - }, - close: async () => console.log('Mock notifications queue closed'), - }; -} -// Initialize workers -const initWorkers = () => { - try { - // Analytics worker - const analyticsWorker = new bullmq_1.Worker(exports.QUEUE_NAMES.ANALYTICS, async (job) => { - console.log(`Processing analytics job ${job.id}`); - const { type, data } = job.data; - switch (type) { - case 'process_views': - // Process view analytics - console.log('Processing view analytics', data); - break; - case 'process_likes': - // Process like analytics - console.log('Processing like analytics', data); - break; - case 'process_followers': - // Process follower analytics - console.log('Processing follower analytics', data); - break; - default: - console.log(`Unknown analytics job type: ${type}`); - } - }, { connection: redisOptions }); - // Notifications worker - const notificationsWorker = new bullmq_1.Worker(exports.QUEUE_NAMES.NOTIFICATIONS, async (job) => { - console.log(`Processing notification job ${job.id}`); - const { type, data } = job.data; - switch (type) { - case 'new_follower': - // Send new follower notification - console.log('Sending new follower notification', data); - break; - case 'new_like': - // Send new like notification - console.log('Sending new like notification', data); - break; - default: - console.log(`Unknown notification job type: ${type}`); - } - }, { connection: redisOptions }); - // Handle worker events - analyticsWorker.on('completed', (job) => { - console.log(`Analytics job ${job.id} completed`); - }); - analyticsWorker.on('failed', (job, err) => { - console.error(`Analytics job ${job?.id} failed with error ${err.message}`); - }); - notificationsWorker.on('completed', (job) => { - console.log(`Notification job ${job.id} completed`); - }); - notificationsWorker.on('failed', (job, err) => { - console.error(`Notification job ${job?.id} failed with error ${err.message}`); - }); - return { - analyticsWorker, - notificationsWorker, - }; - } - catch (error) { - console.error('Error initializing BullMQ workers:', error); - // Return mock workers - return { - analyticsWorker: { - close: async () => console.log('Mock analytics worker closed'), - }, - notificationsWorker: { - close: async () => console.log('Mock notifications worker closed'), - }, - }; - } -}; -exports.initWorkers = initWorkers; -// Helper function to add jobs to queues -const addAnalyticsJob = async (type, data, options = {}) => { - try { - return await analyticsQueue.add(type, { type, data }, options); - } - catch (error) { - console.error('Error adding analytics job:', error); - console.log('Job details:', { type, data }); - return null; - } -}; -exports.addAnalyticsJob = addAnalyticsJob; -const addNotificationJob = async (type, data, options = {}) => { - try { - return await notificationsQueue.add(type, { type, data }, options); - } - catch (error) { - console.error('Error adding notification job:', error); - console.log('Job details:', { type, data }); - return null; - } -}; -exports.addNotificationJob = addNotificationJob; diff --git a/backend/dist/utils/redis.js b/backend/dist/utils/redis.js deleted file mode 100644 index 67a2c55..0000000 --- a/backend/dist/utils/redis.js +++ /dev/null @@ -1,80 +0,0 @@ -"use strict"; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.getRedisClient = exports.connectRedis = exports.redisClient = void 0; -const redis_1 = require("redis"); -const config_1 = __importDefault(require("../config")); -// Create Redis client -const redisClient = (0, redis_1.createClient)({ - url: `redis://${config_1.default.redis.password ? `${config_1.default.redis.password}@` : ''}${config_1.default.redis.host}:${config_1.default.redis.port}`, -}); -exports.redisClient = redisClient; -// Handle Redis connection errors -redisClient.on('error', (err) => { - console.error('Redis Client Error:', err); -}); -// Create a mock Redis client for development when real connection fails -const createMockRedisClient = () => { - const store = new Map(); - return { - isOpen: true, - connect: async () => console.log('Mock Redis client connected'), - get: async (key) => store.get(key) || null, - set: async (key, value) => { - store.set(key, value); - return 'OK'; - }, - incr: async (key) => { - const current = parseInt(store.get(key) || '0', 10); - const newValue = current + 1; - store.set(key, newValue.toString()); - return newValue; - }, - decr: async (key) => { - const current = parseInt(store.get(key) || '0', 10); - const newValue = Math.max(0, current - 1); - store.set(key, newValue.toString()); - return newValue; - }, - quit: async () => console.log('Mock Redis client disconnected'), - }; -}; -// Connect to Redis -let mockRedisClient = null; -const connectRedis = async () => { - try { - if (!redisClient.isOpen) { - await redisClient.connect(); - console.log('Redis client connected'); - } - return redisClient; - } - catch (error) { - console.error('Failed to connect to Redis:', error); - console.log('Using mock Redis client for development...'); - if (!mockRedisClient) { - mockRedisClient = createMockRedisClient(); - } - return mockRedisClient; - } -}; -exports.connectRedis = connectRedis; -// Export the appropriate client -const getRedisClient = async () => { - try { - if (redisClient.isOpen) { - return redisClient; - } - return await connectRedis(); - } - catch (error) { - if (!mockRedisClient) { - mockRedisClient = createMockRedisClient(); - } - return mockRedisClient; - } -}; -exports.getRedisClient = getRedisClient; -exports.default = redisClient; diff --git a/backend/dist/utils/supabase.js b/backend/dist/utils/supabase.js deleted file mode 100644 index fc6f9fb..0000000 --- a/backend/dist/utils/supabase.js +++ /dev/null @@ -1,18 +0,0 @@ -"use strict"; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -const supabase_js_1 = require("@supabase/supabase-js"); -const config_1 = __importDefault(require("../config")); -// Validate Supabase URL -const validateSupabaseUrl = (url) => { - if (!url || !url.startsWith('http')) { - console.warn('Invalid Supabase URL provided. Using a placeholder for development.'); - return 'https://example.supabase.co'; - } - return url; -}; -// Create a single supabase client for interacting with your database -const supabase = (0, supabase_js_1.createClient)(validateSupabaseUrl(config_1.default.supabase.url), config_1.default.supabase.key || 'dummy-key'); -exports.default = supabase; diff --git a/backend/src/index.ts b/backend/src/index.ts index 811fc6c..6dfae1e 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -15,6 +15,7 @@ import { initClickHouse } from './utils/clickhouse'; import { initWorkers } from './utils/queue'; import { initDatabase, createSampleData, checkDatabaseConnection } from './utils/initDatabase'; import { createSwaggerUI } from './swagger'; +import { initScheduledTaskWorkers } from './utils/scheduledTasks'; // Create Hono app const app = new Hono(); @@ -119,16 +120,12 @@ const startServer = async () => { console.log('NOTICE: Database will NOT be automatically initialized on startup'); console.log('Use /api/admin/init-db endpoint to manually initialize the database if needed'); - // Initialize BullMQ workers - let workers; - try { - workers = initWorkers(); - console.log('BullMQ workers initialized'); - } catch (error) { - console.error('Failed to initialize BullMQ workers:', error); - console.log('Background processing will not be available...'); - workers = { analyticsWorker: null, notificationsWorker: null }; - } + // Initialize workers for background processing + console.log('🏗️ Initializing workers...'); + const workers = { + backgroundWorkers: initWorkers(), + scheduledTaskWorker: initScheduledTaskWorkers() + }; // Start server const port = Number(config.port); @@ -149,12 +146,16 @@ const startServer = async () => { console.log('Shutting down server...'); // Close workers if they exist - if (workers.analyticsWorker) { - await workers.analyticsWorker.close(); + if (workers.backgroundWorkers.analyticsWorker) { + await workers.backgroundWorkers.analyticsWorker.close(); } - if (workers.notificationsWorker) { - await workers.notificationsWorker.close(); + if (workers.backgroundWorkers.notificationsWorker) { + await workers.backgroundWorkers.notificationsWorker.close(); + } + + if (workers.scheduledTaskWorker) { + await workers.scheduledTaskWorker.close(); } process.exit(0); diff --git a/backend/src/routes/analytics.ts b/backend/src/routes/analytics.ts index 67509a7..d207c5a 100644 --- a/backend/src/routes/analytics.ts +++ b/backend/src/routes/analytics.ts @@ -4,6 +4,12 @@ import clickhouse from '../utils/clickhouse'; import { addAnalyticsJob } from '../utils/queue'; import { getRedisClient } from '../utils/redis'; import supabase from '../utils/supabase'; +import { + scheduleInfluencerCollection, + schedulePostCollection, + removeScheduledJob, + getScheduledJobs +} from '../utils/scheduledTasks'; // Define user type interface User { @@ -519,4 +525,357 @@ analyticsRouter.get('/project/:id/interaction-types', async (c) => { } }); +// ===== Scheduled Collection Endpoints ===== + +// Schedule automated data collection for an influencer +analyticsRouter.post('/schedule/influencer', async (c) => { + try { + const { influencer_id, cron_expression } = await c.req.json(); + + if (!influencer_id) { + return c.json({ error: 'Influencer ID is required' }, 400); + } + + // Validate that the influencer exists + const { data, error } = await supabase + .from('influencers') + .select('influencer_id') + .eq('influencer_id', influencer_id) + .single(); + + if (error || !data) { + return c.json({ error: 'Influencer not found' }, 404); + } + + // Schedule the collection job + await scheduleInfluencerCollection( + influencer_id, + cron_expression || '0 0 * * *' // Default: Every day at midnight + ); + + return c.json({ + message: 'Influencer metrics collection scheduled successfully', + influencer_id, + cron_expression: cron_expression || '0 0 * * *' + }); + } catch (error) { + console.error('Error scheduling influencer collection:', error); + return c.json({ error: 'Internal server error' }, 500); + } +}); + +// Schedule automated data collection for a post +analyticsRouter.post('/schedule/post', async (c) => { + try { + const { post_id, cron_expression } = await c.req.json(); + + if (!post_id) { + return c.json({ error: 'Post ID is required' }, 400); + } + + // Validate that the post exists + const { data, error } = await supabase + .from('posts') + .select('post_id') + .eq('post_id', post_id) + .single(); + + if (error || !data) { + return c.json({ error: 'Post not found' }, 404); + } + + // Schedule the collection job + await schedulePostCollection( + post_id, + cron_expression || '0 0 * * *' // Default: Every day at midnight + ); + + return c.json({ + message: 'Post metrics collection scheduled successfully', + post_id, + cron_expression: cron_expression || '0 0 * * *' + }); + } catch (error) { + console.error('Error scheduling post collection:', error); + return c.json({ error: 'Internal server error' }, 500); + } +}); + +// Get all scheduled collection jobs +analyticsRouter.get('/schedule', async (c) => { + try { + const scheduledJobs = await getScheduledJobs(); + + return c.json({ + scheduled_jobs: scheduledJobs + }); + } catch (error) { + console.error('Error fetching scheduled jobs:', error); + return c.json({ error: 'Internal server error' }, 500); + } +}); + +// Delete a scheduled collection job +analyticsRouter.delete('/schedule/:job_id', async (c) => { + try { + const jobId = c.req.param('job_id'); + + await removeScheduledJob(jobId); + + return c.json({ + message: 'Scheduled job removed successfully', + job_id: jobId + }); + } catch (error) { + console.error('Error removing scheduled job:', error); + return c.json({ error: 'Internal server error' }, 500); + } +}); + +// ===== Data Export Endpoints ===== + +// Export influencer growth data (CSV format) +analyticsRouter.get('/export/influencer/:id/growth', async (c) => { + try { + const influencerId = c.req.param('id'); + const { + metric = 'followers_count', + timeframe = '6months', + interval = 'month' + } = c.req.query(); + + // The same logic as the influencer growth endpoint, but return CSV format + + // Validate parameters + const validMetrics = ['followers_count', 'video_count', 'views_count', 'likes_count']; + if (!validMetrics.includes(metric)) { + return c.json({ error: 'Invalid metric specified' }, 400); + } + + // Determine time range and interval function + let timeRangeSql: string; + let intervalFunction: string; + + switch (timeframe) { + case '30days': + timeRangeSql = 'timestamp >= subtractDays(now(), 30)'; + break; + case '90days': + timeRangeSql = 'timestamp >= subtractDays(now(), 90)'; + break; + case '6months': + default: + timeRangeSql = 'timestamp >= subtractMonths(now(), 6)'; + break; + case '1year': + timeRangeSql = 'timestamp >= subtractYears(now(), 1)'; + break; + } + + switch (interval) { + case 'day': + intervalFunction = 'toDate(timestamp)'; + break; + case 'week': + intervalFunction = 'toStartOfWeek(timestamp)'; + break; + case 'month': + default: + intervalFunction = 'toStartOfMonth(timestamp)'; + break; + } + + // Query ClickHouse for data + const result = await clickhouse.query({ + query: ` + SELECT + ${intervalFunction} AS time_period, + sumIf(metric_value, metric_name = ?) AS change, + maxIf(metric_total, metric_name = ?) AS total_value + FROM promote.events + WHERE + influencer_id = ? AND + event_type = ? AND + ${timeRangeSql} + GROUP BY time_period + ORDER BY time_period ASC + `, + values: [ + metric, + metric, + influencerId, + `${metric}_change` + ] + }); + + // Get influencer info + const { data: influencer } = await supabase + .from('influencers') + .select('name, platform') + .eq('influencer_id', influencerId) + .single(); + + // Extract trend data + const trendData = 'rows' in result ? result.rows : []; + + // Format as CSV + const csvHeader = `Time Period,Change,Total Value\n`; + const csvRows = trendData.map((row: any) => + `${row.time_period},${row.change},${row.total_value}` + ).join('\n'); + + const influencerInfo = influencer + ? `Influencer: ${influencer.name} (${influencer.platform})\nMetric: ${metric}\nTimeframe: ${timeframe}\nInterval: ${interval}\n\n` + : ''; + + const csvContent = influencerInfo + csvHeader + csvRows; + + return c.body(csvContent, { + headers: { + 'Content-Type': 'text/csv', + 'Content-Disposition': `attachment; filename="influencer_growth_${influencerId}.csv"` + } + }); + } catch (error) { + console.error('Error exporting influencer growth data:', error); + return c.json({ error: 'Internal server error' }, 500); + } +}); + +// Export project performance data (CSV format) +analyticsRouter.get('/export/project/:id/performance', async (c) => { + try { + const projectId = c.req.param('id'); + const { timeframe = '30days' } = c.req.query(); + + // Get project information + const { data: project, error: projectError } = await supabase + .from('projects') + .select('id, name, description') + .eq('id', projectId) + .single(); + + if (projectError) { + return c.json({ error: 'Project not found' }, 404); + } + + // Get project influencers + const { data: projectInfluencers, error: influencersError } = await supabase + .from('project_influencers') + .select('influencer_id') + .eq('project_id', projectId); + + if (influencersError) { + console.error('Error fetching project influencers:', influencersError); + return c.json({ error: 'Failed to fetch project data' }, 500); + } + + const influencerIds = projectInfluencers.map(pi => pi.influencer_id); + + if (influencerIds.length === 0) { + const emptyCSV = `Project: ${project.name}\nNo influencers found in this project.`; + return c.body(emptyCSV, { + headers: { + 'Content-Type': 'text/csv', + 'Content-Disposition': `attachment; filename="project_performance_${projectId}.csv"` + } + }); + } + + // Determine time range + let startDate: Date; + const endDate = new Date(); + + switch (timeframe) { + case '7days': + startDate = new Date(endDate); + startDate.setDate(endDate.getDate() - 7); + break; + case '30days': + default: + startDate = new Date(endDate); + startDate.setDate(endDate.getDate() - 30); + break; + case '90days': + startDate = new Date(endDate); + startDate.setDate(endDate.getDate() - 90); + break; + case '6months': + startDate = new Date(endDate); + startDate.setMonth(endDate.getMonth() - 6); + break; + } + + // Get influencer details + const { data: influencersData } = await supabase + .from('influencers') + .select('influencer_id, name, platform, followers_count') + .in('influencer_id', influencerIds); + + // Get metrics from ClickHouse + const metricsResult = await clickhouse.query({ + query: ` + SELECT + influencer_id, + sumIf(metric_value, event_type = 'followers_count_change') AS followers_change, + sumIf(metric_value, event_type = 'post_views_count_change') AS views_change, + sumIf(metric_value, event_type = 'post_likes_count_change') AS likes_change + FROM promote.events + WHERE + influencer_id IN (?) AND + timestamp >= ? AND + timestamp <= ? + GROUP BY influencer_id + `, + values: [ + influencerIds, + startDate.toISOString(), + endDate.toISOString() + ] + }); + + // Extract metrics data + const metricsData = 'rows' in metricsResult ? metricsResult.rows : []; + + // Combine data + const reportData = (influencersData || []).map(influencer => { + const metrics = metricsData.find((m: any) => m.influencer_id === influencer.influencer_id) || { + followers_change: 0, + views_change: 0, + likes_change: 0 + }; + + return { + influencer_id: influencer.influencer_id, + name: influencer.name, + platform: influencer.platform, + followers_count: influencer.followers_count, + followers_change: metrics.followers_change || 0, + views_change: metrics.views_change || 0, + likes_change: metrics.likes_change || 0 + }; + }); + + // Format as CSV + const csvHeader = `Influencer Name,Platform,Followers Count,Followers Change,Views Change,Likes Change\n`; + const csvRows = reportData.map(row => + `${row.name},${row.platform},${row.followers_count},${row.followers_change},${row.views_change},${row.likes_change}` + ).join('\n'); + + const projectInfo = `Project: ${project.name}\nDescription: ${project.description || 'N/A'}\nTimeframe: ${timeframe}\nExport Date: ${new Date().toISOString()}\n\n`; + + const csvContent = projectInfo + csvHeader + csvRows; + + return c.body(csvContent, { + headers: { + 'Content-Type': 'text/csv', + 'Content-Disposition': `attachment; filename="project_performance_${projectId}.csv"` + } + }); + } catch (error) { + console.error('Error exporting project performance data:', error); + return c.json({ error: 'Internal server error' }, 500); + } +}); + export default analyticsRouter; \ No newline at end of file diff --git a/backend/src/utils/clickhouseHelper.ts b/backend/src/utils/clickhouseHelper.ts new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/utils/initDatabase.ts b/backend/src/utils/initDatabase.ts index 68a59f6..c85bd9e 100644 --- a/backend/src/utils/initDatabase.ts +++ b/backend/src/utils/initDatabase.ts @@ -46,6 +46,28 @@ 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: ` diff --git a/backend/src/utils/scheduledTasks.ts b/backend/src/utils/scheduledTasks.ts new file mode 100644 index 0000000..40fdedd --- /dev/null +++ b/backend/src/utils/scheduledTasks.ts @@ -0,0 +1,406 @@ +import { Queue, Worker } from 'bullmq'; +import supabase from './supabase'; +import clickhouse from './clickhouse'; +import { getRedisClient } from './redis'; + +interface ScheduledCollectionData { + type: 'influencer_metrics' | 'post_metrics'; + influencer_id?: string; + post_id?: string; + project_id?: string; +} + +// Create a mock scheduler if BullMQ doesn't export QueueScheduler +class MockQueueScheduler { + constructor(queueName: string, options: any) { + console.log(`Creating mock scheduler for queue: ${queueName}`); + } +} + +// Create scheduled collection queue +const createScheduledTaskQueue = async () => { + const connection = { + host: process.env.BULL_REDIS_HOST || 'localhost', + port: parseInt(process.env.BULL_REDIS_PORT || '6379'), + password: process.env.BULL_REDIS_PASSWORD || '', + }; + + const queueOptions = { + connection, + defaultJobOptions: { + attempts: 3, + backoff: { + type: 'exponential', + delay: 5000, + }, + }, + }; + + // Create queue + const scheduledCollectionQueue = new Queue('scheduled-data-collection', queueOptions); + + // Note about scheduler: + // In a production environment, a QueueScheduler should be initialized + // for handling delayed/repeatable jobs properly. + // The QueueScheduler can be initialized separately if needed. + + return scheduledCollectionQueue; +}; + +// Initialize scheduled collection workers +export const initScheduledTaskWorkers = () => { + const worker = new Worker( + 'scheduled-data-collection', + async (job) => { + console.log(`Processing scheduled task: ${job.id}`, job.data); + const { type, influencer_id, post_id, project_id } = job.data as ScheduledCollectionData; + + try { + if (type === 'influencer_metrics') { + await collectInfluencerMetrics(influencer_id); + } else if (type === 'post_metrics') { + await collectPostMetrics(post_id); + } + + console.log(`Successfully completed scheduled task: ${job.id}`); + return { success: true, timestamp: new Date().toISOString() }; + } catch (error) { + console.error(`Error processing scheduled task ${job.id}:`, error); + throw error; + } + }, + { + connection: { + host: process.env.BULL_REDIS_HOST || 'localhost', + port: parseInt(process.env.BULL_REDIS_PORT || '6379'), + password: process.env.BULL_REDIS_PASSWORD || '', + }, + concurrency: 5, + } + ); + + worker.on('completed', job => { + console.log(`Scheduled task completed: ${job.id}`); + }); + + worker.on('failed', (job, err) => { + console.error(`Scheduled task failed: ${job?.id}`, err); + }); + + return worker; +}; + +// Schedule data collection jobs +export const scheduleInfluencerCollection = async ( + influencerId: string, + cronExpression: string = '0 0 * * *' // Default: Every day at midnight +) => { + const queue = await createScheduledTaskQueue(); + + await queue.add( + `influencer-collection-${influencerId}`, + { + type: 'influencer_metrics', + influencer_id: influencerId, + scheduled_at: new Date().toISOString() + }, + { + jobId: `influencer-${influencerId}-${Date.now()}`, + repeat: { + pattern: cronExpression + } + } + ); + + return true; +}; + +export const schedulePostCollection = async ( + postId: string, + cronExpression: string = '0 0 * * *' // Default: Every day at midnight +) => { + const queue = await createScheduledTaskQueue(); + + await queue.add( + `post-collection-${postId}`, + { + type: 'post_metrics', + post_id: postId, + scheduled_at: new Date().toISOString() + }, + { + jobId: `post-${postId}-${Date.now()}`, + repeat: { + pattern: cronExpression + } + } + ); + + return true; +}; + +// Remove scheduled jobs +export const removeScheduledJob = async (jobId: string) => { + const queue = await createScheduledTaskQueue(); + await queue.removeRepeatableByKey(jobId); + return true; +}; + +// Get all scheduled jobs +export const getScheduledJobs = async () => { + const queue = await createScheduledTaskQueue(); + const repeatableJobs = await queue.getRepeatableJobs(); + return repeatableJobs; +}; + +// Implementation of collection functions +// These functions would typically call APIs or scrape data from platforms + +async function collectInfluencerMetrics(influencerId?: string) { + if (!influencerId) { + throw new Error('Influencer ID is required'); + } + + // Get influencer data from Supabase + const { data: influencer, error } = await supabase + .from('influencers') + .select('influencer_id, name, platform, external_id') + .eq('influencer_id', influencerId) + .single(); + + if (error || !influencer) { + throw new Error(`Failed to find influencer: ${error?.message}`); + } + + // Here you would integrate with platform APIs to get updated metrics + // This is a placeholder that would be replaced with actual API calls + + // Simulate collecting metrics (in a real scenario, this would come from APIs) + const simulatedMetrics = { + followers_count: Math.floor(50000 + Math.random() * 1000), + video_count: Math.floor(100 + Math.random() * 5), + views_count: Math.floor(1000000 + Math.random() * 50000), + likes_count: Math.floor(500000 + Math.random() * 20000) + }; + + // Record the metrics in both Supabase and ClickHouse + + // Get the current metrics to calculate changes + const { data: currentMetrics, error: metricsError } = await supabase + .from('influencers') + .select('followers_count, video_count') + .eq('influencer_id', influencerId) + .single(); + + if (metricsError) { + throw new Error(`Failed to get current metrics: ${metricsError.message}`); + } + + // Calculate changes + const followerChange = (simulatedMetrics.followers_count || 0) - (currentMetrics?.followers_count || 0); + const videoChange = (simulatedMetrics.video_count || 0) - (currentMetrics?.video_count || 0); + + // Update Supabase + const { error: updateError } = await supabase + .from('influencers') + .update(simulatedMetrics) + .eq('influencer_id', influencerId); + + if (updateError) { + throw new Error(`Failed to update influencer metrics: ${updateError.message}`); + } + + // Record events in ClickHouse + const timestamp = new Date().toISOString(); + const eventPromises = []; + + if (followerChange !== 0) { + eventPromises.push( + clickhouse.query({ + query: ` + INSERT INTO promote.events ( + event_type, + influencer_id, + timestamp, + metric_name, + metric_value, + metric_total, + recorded_by + ) VALUES (?, ?, ?, ?, ?, ?, ?) + `, + values: [ + 'followers_count_change', + influencerId, + timestamp, + 'followers_count', + followerChange, + simulatedMetrics.followers_count, + 'system' // Recorded by the system scheduler + ] + }) + ); + } + + if (videoChange !== 0) { + eventPromises.push( + clickhouse.query({ + query: ` + INSERT INTO promote.events ( + event_type, + influencer_id, + timestamp, + metric_name, + metric_value, + metric_total, + recorded_by + ) VALUES (?, ?, ?, ?, ?, ?, ?) + `, + values: [ + 'video_count_change', + influencerId, + timestamp, + 'video_count', + videoChange, + simulatedMetrics.video_count, + 'system' // Recorded by the system scheduler + ] + }) + ); + } + + await Promise.all(eventPromises); + + return { + influencer_id: influencerId, + timestamp, + metrics: simulatedMetrics, + changes: { + followers: followerChange, + videos: videoChange + } + }; +} + +async function collectPostMetrics(postId?: string) { + if (!postId) { + throw new Error('Post ID is required'); + } + + // Get post data from Supabase + const { data: post, error } = await supabase + .from('posts') + .select('post_id, influencer_id, platform, post_url, title') + .eq('post_id', postId) + .single(); + + if (error || !post) { + throw new Error(`Failed to find post: ${error?.message}`); + } + + // Here you would integrate with platform APIs to get updated metrics + // This is a placeholder that would be replaced with actual API calls + + // Simulate collecting metrics (in a real scenario, this would come from APIs) + const simulatedMetrics = { + views_count: Math.floor(10000 + Math.random() * 5000), + likes_count: Math.floor(5000 + Math.random() * 1000), + comments_count: Math.floor(200 + Math.random() * 50), + shares_count: Math.floor(100 + Math.random() * 20) + }; + + // Get the current metrics to calculate changes + const { data: currentMetrics, error: metricsError } = await supabase + .from('posts') + .select('views_count, likes_count, comments_count, shares_count') + .eq('post_id', postId) + .single(); + + if (metricsError) { + throw new Error(`Failed to get current metrics: ${metricsError.message}`); + } + + // Calculate changes + const viewsChange = (simulatedMetrics.views_count || 0) - (currentMetrics?.views_count || 0); + const likesChange = (simulatedMetrics.likes_count || 0) - (currentMetrics?.likes_count || 0); + const commentsChange = (simulatedMetrics.comments_count || 0) - (currentMetrics?.comments_count || 0); + const sharesChange = (simulatedMetrics.shares_count || 0) - (currentMetrics?.shares_count || 0); + + // Update Supabase + const { error: updateError } = await supabase + .from('posts') + .update(simulatedMetrics) + .eq('post_id', postId); + + if (updateError) { + throw new Error(`Failed to update post metrics: ${updateError.message}`); + } + + // Record events in ClickHouse + const timestamp = new Date().toISOString(); + const eventPromises = []; + + // Only record changes if they are non-zero + interface MetricChanges { + views: number; + likes: number; + comments: number; + shares: number; + } + + const changes: MetricChanges = { + views: viewsChange, + likes: likesChange, + comments: commentsChange, + shares: sharesChange + }; + + const metricsMap = { + views: simulatedMetrics.views_count, + likes: simulatedMetrics.likes_count, + comments: simulatedMetrics.comments_count, + shares: simulatedMetrics.shares_count + }; + + for (const [key, value] of Object.entries(changes)) { + if (value !== 0) { + const metricName = `${key}_count`; + const metricTotal = metricsMap[key as keyof typeof metricsMap]; + + eventPromises.push( + clickhouse.query({ + query: ` + INSERT INTO promote.events ( + event_type, + post_id, + influencer_id, + timestamp, + metric_name, + metric_value, + metric_total, + recorded_by + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `, + values: [ + `post_${metricName}_change`, + postId, + post.influencer_id, + timestamp, + metricName, + value, + metricTotal, + 'system' // Recorded by the system scheduler + ] + }) + ); + } + } + + await Promise.all(eventPromises); + + return { + post_id: postId, + timestamp, + metrics: simulatedMetrics, + changes + }; +} \ No newline at end of file