init
This commit is contained in:
28
backend/.env
Normal file
28
backend/.env
Normal file
@@ -0,0 +1,28 @@
|
||||
PORT=4000
|
||||
|
||||
SUPABASE_URL="https://xtqhluzornazlmkonucr.supabase.co"
|
||||
SUPABASE_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inh0cWhsdXpvcm5hemxta29udWNyIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc0MTI0NDcxMywiZXhwIjoyMDU2ODIwNzEzfQ.E9lHlmdoqCZ9zYg0ammuW6ua-__tEEATCrwYv3-Th3I"
|
||||
SUPABASE_ANON_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inh0cWhsdXpvcm5hemxta29udWNyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDEyNDQ3MTMsImV4cCI6MjA1NjgyMDcxM30.PUXbyHOgkvFE6T5fvCFTV7LJq-MbMkRtNdw2VoKJOAg"
|
||||
DATABASE_URL="postgresql://postgres:KR$kH9fdwZAd@tdS@db.xtqhluzornazlmkonucr.supabase.co:5432/postgres"
|
||||
|
||||
REDIS_HOST="localhost"
|
||||
REDIS_PORT="6379"
|
||||
REDIS_PASSWORD=""
|
||||
DOMAIN="upj.to"
|
||||
ENABLED_ROUTES=all
|
||||
|
||||
# ClickHouse Configuration
|
||||
CLICKHOUSE_HOST=localhost
|
||||
CLICKHOUSE_PORT=8123
|
||||
CLICKHOUSE_USER=admin
|
||||
CLICKHOUSE_PASSWORD=your_secure_password
|
||||
CLICKHOUSE_DATABASE=promote
|
||||
|
||||
# BullMQ Configuration
|
||||
BULL_REDIS_HOST="localhost"
|
||||
BULL_REDIS_PORT="6379"
|
||||
BULL_REDIS_PASSWORD=""
|
||||
|
||||
# JWT Configuration
|
||||
JWT_SECRET="your-jwt-secret-key"
|
||||
JWT_EXPIRES_IN="7d"
|
||||
28
backend/.env.example
Normal file
28
backend/.env.example
Normal file
@@ -0,0 +1,28 @@
|
||||
PORT=4000
|
||||
|
||||
SUPABASE_URL="your-supabase-url"
|
||||
SUPABASE_KEY="your-supabase-key"
|
||||
SUPABASE_ANON_KEY="your-supabase-anon-key"
|
||||
DATABASE_URL="your-database-url"
|
||||
|
||||
REDIS_HOST="localhost"
|
||||
REDIS_PORT="6379"
|
||||
REDIS_PASSWORD=""
|
||||
DOMAIN="upj.to"
|
||||
ENABLED_ROUTES=all
|
||||
|
||||
# ClickHouse Configuration
|
||||
CLICKHOUSE_HOST="localhost"
|
||||
CLICKHOUSE_PORT="8123"
|
||||
CLICKHOUSE_USER="admin"
|
||||
CLICKHOUSE_PASSWORD="your_secure_password"
|
||||
CLICKHOUSE_DATABASE="promote"
|
||||
|
||||
# BullMQ Configuration
|
||||
BULL_REDIS_HOST="localhost"
|
||||
BULL_REDIS_PORT="6379"
|
||||
BULL_REDIS_PASSWORD=""
|
||||
|
||||
# JWT Configuration
|
||||
JWT_SECRET="your-jwt-secret-key"
|
||||
JWT_EXPIRES_IN="7d"
|
||||
47
backend/.gitignore
vendored
Normal file
47
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
# 依赖
|
||||
node_modules/
|
||||
pnpm-lock.yaml
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
|
||||
# 构建输出
|
||||
dist/
|
||||
build/
|
||||
|
||||
# 环境配置
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# 日志
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# 系统文件
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# IDE 和编辑器
|
||||
.idea/
|
||||
.vscode/
|
||||
*.sublime-project
|
||||
*.sublime-workspace
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# 临时文件
|
||||
.tmp/
|
||||
.temp/
|
||||
.cache/
|
||||
|
||||
# 覆盖率报告
|
||||
coverage/
|
||||
111
backend/README.md
Normal file
111
backend/README.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# Promote Backend API
|
||||
|
||||
Backend API for the Promote platform, built with Hono.js, Supabase, ClickHouse, Redis, and BullMQ.
|
||||
|
||||
## Features
|
||||
|
||||
- **Authentication**: JWT-based authentication with Supabase Auth
|
||||
- **Analytics Tracking**: Track views, likes, and followers using ClickHouse
|
||||
- **Caching**: Redis for fast API responses
|
||||
- **Task Scheduling**: BullMQ for background processing
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework**: [Hono.js](https://honojs.dev/)
|
||||
- **Authentication**: [Supabase Auth](https://supabase.com/docs/guides/auth) + JWT
|
||||
- **Database**:
|
||||
- [Supabase (PostgreSQL)](https://supabase.com/) for main data
|
||||
- [ClickHouse](https://clickhouse.com/) for analytics events
|
||||
- **Caching**: [Redis](https://redis.io/)
|
||||
- **Task Queue**: [BullMQ](https://docs.bullmq.io/)
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 18+
|
||||
- pnpm
|
||||
- Redis
|
||||
- ClickHouse
|
||||
- Supabase account
|
||||
|
||||
### Installation
|
||||
|
||||
1. Clone the repository
|
||||
2. Install dependencies:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
pnpm install
|
||||
```
|
||||
|
||||
3. Create a `.env` file based on the `.env.example` file
|
||||
4. Start the development server:
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Authentication
|
||||
|
||||
- `POST /api/auth/register` - Register a new user
|
||||
- `POST /api/auth/login` - Login a user
|
||||
- `GET /api/auth/verify` - Verify a token
|
||||
|
||||
### Analytics
|
||||
|
||||
- `POST /api/analytics/view` - Track a view event
|
||||
- `POST /api/analytics/like` - Track a like/unlike event
|
||||
- `POST /api/analytics/follow` - Track a follow/unfollow event
|
||||
- `GET /api/analytics/content/:id` - Get analytics for a content
|
||||
- `GET /api/analytics/user/:id` - Get analytics for a user
|
||||
|
||||
## Development
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
|
||||
### Start Production Server
|
||||
|
||||
```bash
|
||||
pnpm start
|
||||
```
|
||||
|
||||
### Linting
|
||||
|
||||
```bash
|
||||
pnpm lint
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
pnpm test
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
backend/
|
||||
├── src/
|
||||
│ ├── config/ # Configuration files
|
||||
│ ├── controllers/ # Route controllers
|
||||
│ ├── middlewares/ # Middleware functions
|
||||
│ ├── models/ # Data models
|
||||
│ ├── routes/ # API routes
|
||||
│ ├── services/ # Business logic
|
||||
│ ├── utils/ # Utility functions
|
||||
│ └── index.ts # Entry point
|
||||
├── .env # Environment variables
|
||||
├── package.json # Dependencies and scripts
|
||||
└── tsconfig.json # TypeScript configuration
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the ISC License.
|
||||
51
backend/dist/config/index.js
vendored
Normal file
51
backend/dist/config/index.js
vendored
Normal file
@@ -0,0 +1,51 @@
|
||||
"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;
|
||||
111
backend/dist/controllers/commentsController.js
vendored
Normal file
111
backend/dist/controllers/commentsController.js
vendored
Normal file
@@ -0,0 +1,111 @@
|
||||
"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;
|
||||
116
backend/dist/controllers/influencersController.js
vendored
Normal file
116
backend/dist/controllers/influencersController.js
vendored
Normal file
@@ -0,0 +1,116 @@
|
||||
"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;
|
||||
163
backend/dist/index.js
vendored
Normal file
163
backend/dist/index.js
vendored
Normal file
@@ -0,0 +1,163 @@
|
||||
"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();
|
||||
85
backend/dist/middlewares/auth.js
vendored
Normal file
85
backend/dist/middlewares/auth.js
vendored
Normal file
@@ -0,0 +1,85 @@
|
||||
"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;
|
||||
453
backend/dist/routes/analytics.js
vendored
Normal file
453
backend/dist/routes/analytics.js
vendored
Normal file
@@ -0,0 +1,453 @@
|
||||
"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;
|
||||
140
backend/dist/routes/auth.js
vendored
Normal file
140
backend/dist/routes/auth.js
vendored
Normal file
@@ -0,0 +1,140 @@
|
||||
"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;
|
||||
12
backend/dist/routes/comments.js
vendored
Normal file
12
backend/dist/routes/comments.js
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
"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;
|
||||
649
backend/dist/routes/community.js
vendored
Normal file
649
backend/dist/routes/community.js
vendored
Normal file
@@ -0,0 +1,649 @@
|
||||
"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;
|
||||
10
backend/dist/routes/influencers.js
vendored
Normal file
10
backend/dist/routes/influencers.js
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
"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;
|
||||
584
backend/dist/routes/posts.js
vendored
Normal file
584
backend/dist/routes/posts.js
vendored
Normal file
@@ -0,0 +1,584 @@
|
||||
"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;
|
||||
395
backend/dist/routes/projectComments.js
vendored
Normal file
395
backend/dist/routes/projectComments.js
vendored
Normal file
@@ -0,0 +1,395 @@
|
||||
"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;
|
||||
1863
backend/dist/swagger/index.js
vendored
Normal file
1863
backend/dist/swagger/index.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
87
backend/dist/utils/clickhouse.js
vendored
Normal file
87
backend/dist/utils/clickhouse.js
vendored
Normal file
@@ -0,0 +1,87 @@
|
||||
"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;
|
||||
492
backend/dist/utils/initDatabase.js
vendored
Normal file
492
backend/dist/utils/initDatabase.js
vendored
Normal file
@@ -0,0 +1,492 @@
|
||||
"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;
|
||||
158
backend/dist/utils/queue.js
vendored
Normal file
158
backend/dist/utils/queue.js
vendored
Normal file
@@ -0,0 +1,158 @@
|
||||
"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;
|
||||
80
backend/dist/utils/redis.js
vendored
Normal file
80
backend/dist/utils/redis.js
vendored
Normal file
@@ -0,0 +1,80 @@
|
||||
"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;
|
||||
18
backend/dist/utils/supabase.js
vendored
Normal file
18
backend/dist/utils/supabase.js
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
"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;
|
||||
38
backend/package.json
Normal file
38
backend/package.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.0.0",
|
||||
"description": "Backend API for promote platform",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"lint": "eslint src --ext .ts",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"packageManager": "pnpm@9.15.4+sha512.b2dc20e2fc72b3e18848459b37359a32064663e5627a51e4c74b2c29dd8e8e0491483c3abb40789cfd578bf362fb6ba8261b05f0387d76792ed6e23ea3b1b6a0",
|
||||
"dependencies": {
|
||||
"@clickhouse/client": "^0.2.10",
|
||||
"@hono/node-server": "^1.13.8",
|
||||
"@hono/swagger-ui": "^0.5.1",
|
||||
"@supabase/supabase-js": "^2.49.1",
|
||||
"bullmq": "^5.4.1",
|
||||
"dotenv": "^16.4.7",
|
||||
"hono": "^4.7.4",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"redis": "^4.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jsonwebtoken": "^9.0.6",
|
||||
"@types/node": "^20.11.30",
|
||||
"@typescript-eslint/eslint-plugin": "^7.4.0",
|
||||
"@typescript-eslint/parser": "^7.4.0",
|
||||
"eslint": "^8.57.0",
|
||||
"tsx": "^4.7.1",
|
||||
"typescript": "^5.4.3",
|
||||
"vitest": "^1.4.0"
|
||||
}
|
||||
}
|
||||
2878
backend/pnpm-lock.yaml
generated
Normal file
2878
backend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
55
backend/src/config/index.ts
Normal file
55
backend/src/config/index.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import dotenv from 'dotenv';
|
||||
import { join } from 'path';
|
||||
|
||||
// Load environment variables from .env file
|
||||
dotenv.config({ path: join(__dirname, '../../.env') });
|
||||
|
||||
export const 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',
|
||||
};
|
||||
|
||||
export default config;
|
||||
120
backend/src/controllers/commentsController.ts
Normal file
120
backend/src/controllers/commentsController.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { Context } from 'hono';
|
||||
import supabase from '../utils/supabase';
|
||||
|
||||
export const getComments = async (c: Context) => {
|
||||
try {
|
||||
const { post_id, limit = '10', offset = '0' } = c.req.query();
|
||||
|
||||
let query;
|
||||
|
||||
if (post_id) {
|
||||
// 获取特定帖子的评论
|
||||
query = supabase.rpc('get_comments_for_post', { post_id_param: post_id });
|
||||
} else {
|
||||
// 获取所有评论
|
||||
query = supabase.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);
|
||||
}
|
||||
};
|
||||
|
||||
export const createComment = async (c: Context) => {
|
||||
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
|
||||
.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
|
||||
.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);
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteComment = async (c: Context) => {
|
||||
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
|
||||
.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
|
||||
.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);
|
||||
}
|
||||
};
|
||||
136
backend/src/controllers/influencersController.ts
Normal file
136
backend/src/controllers/influencersController.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { Context } from 'hono';
|
||||
import supabase from '../utils/supabase';
|
||||
|
||||
export const getInfluencers = async (c: Context) => {
|
||||
try {
|
||||
const {
|
||||
platform,
|
||||
limit = '10',
|
||||
offset = '0',
|
||||
min_followers,
|
||||
max_followers,
|
||||
sort_by = 'followers_count',
|
||||
sort_order = 'desc'
|
||||
} = c.req.query();
|
||||
|
||||
let query = supabase
|
||||
.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);
|
||||
}
|
||||
};
|
||||
|
||||
export const getInfluencerById = async (c: Context) => {
|
||||
try {
|
||||
const { influencer_id } = c.req.param();
|
||||
|
||||
const { data: influencer, error } = await supabase
|
||||
.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);
|
||||
}
|
||||
};
|
||||
|
||||
export const getInfluencerStats = async (c: Context) => {
|
||||
try {
|
||||
const { platform } = c.req.query();
|
||||
|
||||
let query = supabase
|
||||
.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: number, item: any) => sum + (item.followers_count || 0), 0),
|
||||
total_videos: stats.reduce((sum: number, item: any) => sum + (item.video_count || 0), 0),
|
||||
average_followers: Math.round(
|
||||
stats.reduce((sum: number, item: any) => sum + (item.followers_count || 0), 0) / (stats.length || 1)
|
||||
),
|
||||
average_videos: Math.round(
|
||||
stats.reduce((sum: number, item: any) => sum + (item.video_count || 0), 0) / (stats.length || 1)
|
||||
)
|
||||
};
|
||||
|
||||
return c.json(aggregatedStats);
|
||||
} catch (error) {
|
||||
return c.json({ error: 'Internal server error' }, 500);
|
||||
}
|
||||
};
|
||||
172
backend/src/index.ts
Normal file
172
backend/src/index.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { serve } from '@hono/node-server';
|
||||
import { Hono } from 'hono';
|
||||
import { cors } from 'hono/cors';
|
||||
import { logger } from 'hono/logger';
|
||||
import config from './config';
|
||||
import authRouter from './routes/auth';
|
||||
import analyticsRouter from './routes/analytics';
|
||||
import communityRouter from './routes/community';
|
||||
import postsRouter from './routes/posts';
|
||||
import projectCommentsRouter from './routes/projectComments';
|
||||
import commentsRouter from './routes/comments';
|
||||
import influencersRouter from './routes/influencers';
|
||||
import { connectRedis } from './utils/redis';
|
||||
import { initClickHouse } from './utils/clickhouse';
|
||||
import { initWorkers } from './utils/queue';
|
||||
import { initDatabase, createSampleData, checkDatabaseConnection } from './utils/initDatabase';
|
||||
import { createSwaggerUI } from './swagger';
|
||||
|
||||
// Create Hono app
|
||||
const app = new Hono();
|
||||
|
||||
// Middleware
|
||||
app.use('*', logger());
|
||||
app.use('*', 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 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 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', authRouter);
|
||||
app.route('/api/analytics', analyticsRouter);
|
||||
app.route('/api/community', communityRouter);
|
||||
app.route('/api/posts', postsRouter);
|
||||
app.route('/api/project-comments', projectCommentsRouter);
|
||||
app.route('/api/comments', commentsRouter);
|
||||
app.route('/api/influencers', influencersRouter);
|
||||
|
||||
// Swagger UI
|
||||
const swaggerApp = createSwaggerUI();
|
||||
app.route('', swaggerApp);
|
||||
|
||||
// Initialize services and start server
|
||||
const startServer = async () => {
|
||||
try {
|
||||
// Connect to Redis
|
||||
try {
|
||||
await 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 initClickHouse();
|
||||
console.log('ClickHouse initialized');
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize ClickHouse:', error);
|
||||
console.log('Continuing with limited analytics functionality...');
|
||||
}
|
||||
|
||||
// 检查数据库连接,但不自动初始化或修改数据库
|
||||
try {
|
||||
await 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 = 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.port);
|
||||
console.log(`Server starting on port ${port}...`);
|
||||
|
||||
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();
|
||||
101
backend/src/middlewares/auth.ts
Normal file
101
backend/src/middlewares/auth.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { Context, Next } from 'hono';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import config from '../config';
|
||||
import supabase from '../utils/supabase';
|
||||
|
||||
// Interface for JWT payload
|
||||
interface JwtPayload {
|
||||
sub: string;
|
||||
email: string;
|
||||
iat: number;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
// Middleware to verify JWT token
|
||||
export const authMiddleware = async (c: Context, next: 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 = jwt.verify(token, config.jwt.secret) as JwtPayload;
|
||||
|
||||
// 特殊处理 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 jwt.JsonWebTokenError) {
|
||||
return c.json({ error: 'Unauthorized: Invalid token' }, 401);
|
||||
}
|
||||
|
||||
if (jwtError instanceof jwt.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);
|
||||
}
|
||||
};
|
||||
|
||||
// Generate JWT token
|
||||
export const generateToken = (userId: string, email: string): string => {
|
||||
const secret = config.jwt.secret;
|
||||
const expiresIn = config.jwt.expiresIn;
|
||||
|
||||
return jwt.sign(
|
||||
{
|
||||
sub: userId,
|
||||
email,
|
||||
},
|
||||
secret,
|
||||
{
|
||||
expiresIn,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// Verify Supabase token
|
||||
export const verifySupabaseToken = async (token: string) => {
|
||||
try {
|
||||
const { data, error } = await supabase.auth.getUser(token);
|
||||
|
||||
if (error || !data.user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return data.user;
|
||||
} catch (error) {
|
||||
console.error('Supabase token verification error:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
522
backend/src/routes/analytics.ts
Normal file
522
backend/src/routes/analytics.ts
Normal file
@@ -0,0 +1,522 @@
|
||||
import { Hono } from 'hono';
|
||||
import { authMiddleware } from '../middlewares/auth';
|
||||
import clickhouse from '../utils/clickhouse';
|
||||
import { addAnalyticsJob } from '../utils/queue';
|
||||
import { getRedisClient } from '../utils/redis';
|
||||
import supabase from '../utils/supabase';
|
||||
|
||||
// Define user type
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
// Extend Hono's Context type
|
||||
declare module 'hono' {
|
||||
interface ContextVariableMap {
|
||||
user: User;
|
||||
}
|
||||
}
|
||||
|
||||
const analyticsRouter = new Hono();
|
||||
|
||||
// Apply auth middleware to all routes
|
||||
analyticsRouter.use('*', 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.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 addAnalyticsJob('process_views', {
|
||||
user_id: user.id,
|
||||
content_id,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// Increment view count in Redis cache
|
||||
const redis = await 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.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 addAnalyticsJob('process_likes', {
|
||||
user_id: user.id,
|
||||
content_id,
|
||||
action,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// Update like count in Redis cache
|
||||
const redis = await 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.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 addAnalyticsJob('process_followers', {
|
||||
follower_id: user.id,
|
||||
followed_id,
|
||||
action,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// Update follower count in Redis cache
|
||||
const redis = await 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 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 getRedisClient();
|
||||
const followers = await redis.get(`followers:${userId}`);
|
||||
|
||||
// Get content view and like counts from ClickHouse
|
||||
const viewsResult = await clickhouse.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.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.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: any) => item.influencer_id);
|
||||
|
||||
const { data: influencerDetails, error } = await supabase
|
||||
.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: any) => {
|
||||
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.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.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
|
||||
.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
|
||||
.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
|
||||
.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.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.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);
|
||||
}
|
||||
});
|
||||
|
||||
export default analyticsRouter;
|
||||
159
backend/src/routes/auth.ts
Normal file
159
backend/src/routes/auth.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { Hono } from 'hono';
|
||||
import { generateToken, verifySupabaseToken } from '../middlewares/auth';
|
||||
import supabase from '../utils/supabase';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
const authRouter = new 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.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
|
||||
.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.auth.admin.deleteUser(authData.user.id);
|
||||
return c.json({ error: profileError.message }, 500);
|
||||
}
|
||||
|
||||
// Generate JWT token
|
||||
const token = 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.auth.signInWithPassword({
|
||||
email,
|
||||
password
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return c.json({ error: error.message }, 400);
|
||||
}
|
||||
|
||||
// 使用与 authMiddleware 一致的方式创建 JWT
|
||||
const token = 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 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 verifySupabaseToken(token);
|
||||
|
||||
if (!user) {
|
||||
return c.json({ error: 'Invalid token' }, 401);
|
||||
}
|
||||
|
||||
// 生成新token
|
||||
const newToken = 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);
|
||||
}
|
||||
});
|
||||
|
||||
export default authRouter;
|
||||
14
backend/src/routes/comments.ts
Normal file
14
backend/src/routes/comments.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Hono } from 'hono';
|
||||
import { getComments, createComment, deleteComment } from '../controllers/commentsController';
|
||||
import { authMiddleware } from '../middlewares/auth';
|
||||
|
||||
const commentsRouter = new Hono();
|
||||
|
||||
// Public routes
|
||||
commentsRouter.get('/', getComments);
|
||||
|
||||
// Protected routes
|
||||
commentsRouter.post('/', authMiddleware, createComment);
|
||||
commentsRouter.delete('/:comment_id', authMiddleware, deleteComment);
|
||||
|
||||
export default commentsRouter;
|
||||
770
backend/src/routes/community.ts
Normal file
770
backend/src/routes/community.ts
Normal file
@@ -0,0 +1,770 @@
|
||||
import { Hono } from 'hono';
|
||||
import { authMiddleware } from '../middlewares/auth';
|
||||
import clickhouse from '../utils/clickhouse';
|
||||
import supabase from '../utils/supabase';
|
||||
|
||||
// Define user type
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
// Extend Hono's Context type
|
||||
declare module 'hono' {
|
||||
interface ContextVariableMap {
|
||||
user: User;
|
||||
}
|
||||
}
|
||||
|
||||
const communityRouter = new Hono();
|
||||
|
||||
// Apply auth middleware to all routes
|
||||
communityRouter.use('*', 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
|
||||
.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
|
||||
.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
|
||||
.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
|
||||
.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
|
||||
.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
|
||||
.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
|
||||
.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
|
||||
.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
|
||||
.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
|
||||
.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
|
||||
.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
|
||||
.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
|
||||
.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
|
||||
.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
|
||||
.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.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.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
|
||||
.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
|
||||
.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
|
||||
.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.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.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);
|
||||
}
|
||||
});
|
||||
|
||||
export default communityRouter;
|
||||
11
backend/src/routes/influencers.ts
Normal file
11
backend/src/routes/influencers.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Hono } from 'hono';
|
||||
import { getInfluencers, getInfluencerById, getInfluencerStats } from '../controllers/influencersController';
|
||||
|
||||
const influencersRouter = new Hono();
|
||||
|
||||
// Public routes
|
||||
influencersRouter.get('/', getInfluencers);
|
||||
influencersRouter.get('/stats', getInfluencerStats);
|
||||
influencersRouter.get('/:influencer_id', getInfluencerById);
|
||||
|
||||
export default influencersRouter;
|
||||
686
backend/src/routes/posts.ts
Normal file
686
backend/src/routes/posts.ts
Normal file
@@ -0,0 +1,686 @@
|
||||
import { Hono } from 'hono';
|
||||
import { authMiddleware } from '../middlewares/auth';
|
||||
import supabase from '../utils/supabase';
|
||||
import clickhouse from '../utils/clickhouse';
|
||||
import { getRedisClient } from '../utils/redis';
|
||||
|
||||
// Define user type
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
// Define stats type
|
||||
interface PostStats {
|
||||
post_id: string;
|
||||
views: number | null;
|
||||
likes: number | null;
|
||||
}
|
||||
|
||||
// Extend Hono's Context type
|
||||
declare module 'hono' {
|
||||
interface ContextVariableMap {
|
||||
user: User;
|
||||
}
|
||||
}
|
||||
|
||||
const postsRouter = new Hono();
|
||||
|
||||
// Apply auth middleware to most routes
|
||||
postsRouter.use('*', 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
|
||||
.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
|
||||
.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.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 getRedisClient();
|
||||
const cachedStats: PostStats[] = 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.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.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
|
||||
.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 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.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.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.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
|
||||
.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
|
||||
.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
|
||||
.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
|
||||
.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 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
|
||||
.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
|
||||
.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
|
||||
.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
|
||||
.from('posts')
|
||||
.select('influencer_id, platform')
|
||||
.eq('post_id', postId)
|
||||
.single();
|
||||
|
||||
if (post) {
|
||||
await clickhouse.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
|
||||
.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
|
||||
.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
|
||||
.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
|
||||
.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);
|
||||
}
|
||||
});
|
||||
|
||||
export default postsRouter;
|
||||
489
backend/src/routes/projectComments.ts
Normal file
489
backend/src/routes/projectComments.ts
Normal file
@@ -0,0 +1,489 @@
|
||||
import { Hono } from 'hono';
|
||||
import { authMiddleware } from '../middlewares/auth';
|
||||
import supabase from '../utils/supabase';
|
||||
import clickhouse from '../utils/clickhouse';
|
||||
|
||||
// Define user type
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
// Define comment type
|
||||
interface ProjectComment {
|
||||
comment_id: string;
|
||||
project_id: string;
|
||||
user_id: string;
|
||||
content: string;
|
||||
sentiment_score: number;
|
||||
status: string;
|
||||
is_pinned: boolean;
|
||||
parent_id: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
user: { id: string; email: string }[];
|
||||
reply_count?: number;
|
||||
}
|
||||
|
||||
// Define project type
|
||||
interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
created_by?: string;
|
||||
}
|
||||
|
||||
// Define reply count type
|
||||
interface ReplyCount {
|
||||
parent_id: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
// Extend Hono's Context type
|
||||
declare module 'hono' {
|
||||
interface ContextVariableMap {
|
||||
user: User;
|
||||
}
|
||||
}
|
||||
|
||||
const projectCommentsRouter = new Hono();
|
||||
|
||||
// Apply auth middleware to all routes
|
||||
projectCommentsRouter.use('*', 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
|
||||
.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
|
||||
.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
|
||||
.rpc('get_reply_counts_for_comments', { parent_ids: commentIds });
|
||||
|
||||
if (!replyCountError && replyCounts) {
|
||||
// 将回复数量添加到评论中
|
||||
for (const comment of comments) {
|
||||
const replyCountItem = replyCounts.find((r: ReplyCount) => 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
|
||||
.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
|
||||
.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
|
||||
.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.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
|
||||
.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: Record<string, any> = {};
|
||||
|
||||
// 评论创建者可以更新内容和情感分数
|
||||
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
|
||||
.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
|
||||
.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
|
||||
.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
|
||||
.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
|
||||
.from('project_comments')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('project_id', projectId);
|
||||
|
||||
// 从Supabase获取情感分析统计
|
||||
const { data: sentimentStats } = await supabase
|
||||
.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++;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 从ClickHouse获取评论时间趋势
|
||||
type TrendItem = { date: string; comment_count: number };
|
||||
let timeTrend: TrendItem[] = [];
|
||||
|
||||
try {
|
||||
const result = await clickhouse.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 as TrendItem[] : [];
|
||||
} 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);
|
||||
}
|
||||
});
|
||||
|
||||
export default projectCommentsRouter;
|
||||
1866
backend/src/swagger/index.ts
Normal file
1866
backend/src/swagger/index.ts
Normal file
File diff suppressed because it is too large
Load Diff
86
backend/src/utils/clickhouse.ts
Normal file
86
backend/src/utils/clickhouse.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { createClient } from '@clickhouse/client';
|
||||
import config from '../config';
|
||||
|
||||
// Create ClickHouse client with error handling
|
||||
const createClickHouseClient = () => {
|
||||
try {
|
||||
return createClient({
|
||||
host: `http://${config.clickhouse.host}:${config.clickhouse.port}`,
|
||||
username: config.clickhouse.user,
|
||||
password: config.clickhouse.password,
|
||||
database: config.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 }: { query: string; values?: any[] }) => {
|
||||
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
|
||||
export const initClickHouse = async () => {
|
||||
try {
|
||||
// Create database if not exists
|
||||
await clickhouse.query({
|
||||
query: `CREATE DATABASE IF NOT EXISTS ${config.clickhouse.database}`,
|
||||
});
|
||||
|
||||
// Create tables for tracking events
|
||||
await clickhouse.query({
|
||||
query: `
|
||||
CREATE TABLE IF NOT EXISTS ${config.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.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.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...');
|
||||
}
|
||||
};
|
||||
|
||||
export default clickhouse;
|
||||
538
backend/src/utils/initDatabase.ts
Normal file
538
backend/src/utils/initDatabase.ts
Normal file
@@ -0,0 +1,538 @@
|
||||
import supabase from './supabase';
|
||||
import clickhouse from './clickhouse';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* 初始化 Supabase (PostgreSQL) 数据库表
|
||||
*/
|
||||
export const initSupabaseTables = async () => {
|
||||
try {
|
||||
console.log('开始初始化 Supabase 数据表...');
|
||||
|
||||
// 创建用户扩展表
|
||||
await supabase.rpc('create_user_profiles_if_not_exists');
|
||||
|
||||
// 创建项目表
|
||||
await supabase.rpc('create_projects_table_if_not_exists');
|
||||
|
||||
// 创建网红(影响者)表
|
||||
await supabase.rpc('create_influencers_table_if_not_exists');
|
||||
|
||||
// 创建项目-网红关联表
|
||||
await supabase.rpc('create_project_influencers_table_if_not_exists');
|
||||
|
||||
// 创建帖子表
|
||||
await supabase.rpc('create_posts_table_if_not_exists');
|
||||
|
||||
// 创建评论表
|
||||
await supabase.rpc('create_comments_table_if_not_exists');
|
||||
|
||||
// 创建项目评论表
|
||||
await supabase.rpc('create_project_comments_table_if_not_exists');
|
||||
|
||||
console.log('Supabase 数据表初始化完成');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('初始化 Supabase 数据表失败:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 初始化 ClickHouse 数据库表
|
||||
*/
|
||||
export const initClickHouseTables = async () => {
|
||||
try {
|
||||
console.log('开始初始化 ClickHouse 数据表...');
|
||||
|
||||
// 创建事件表
|
||||
await clickhouse.query({
|
||||
query: `
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
event_id UUID DEFAULT generateUUIDv4(),
|
||||
project_id UUID,
|
||||
influencer_id UUID,
|
||||
post_id UUID NULL,
|
||||
platform String,
|
||||
event_type Enum(
|
||||
'follower_change' = 1,
|
||||
'post_like_change' = 2,
|
||||
'post_view_change' = 3,
|
||||
'click' = 4,
|
||||
'comment' = 5,
|
||||
'share' = 6,
|
||||
'project_comment' = 7
|
||||
),
|
||||
metric_value Int64,
|
||||
event_metadata String,
|
||||
timestamp DateTime DEFAULT now()
|
||||
) ENGINE = MergeTree()
|
||||
PARTITION BY toYYYYMM(timestamp)
|
||||
ORDER BY (platform, influencer_id, post_id, event_type, timestamp)
|
||||
`
|
||||
});
|
||||
|
||||
// 创建统计视图 - 按天统计
|
||||
await clickhouse.query({
|
||||
query: `
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS daily_stats
|
||||
ENGINE = SummingMergeTree()
|
||||
PARTITION BY toYYYYMM(date)
|
||||
ORDER BY (date, platform, influencer_id, event_type)
|
||||
AS SELECT
|
||||
toDate(timestamp) AS date,
|
||||
platform,
|
||||
influencer_id,
|
||||
event_type,
|
||||
SUM(metric_value) AS total_value,
|
||||
COUNT(*) AS event_count
|
||||
FROM events
|
||||
GROUP BY date, platform, influencer_id, event_type
|
||||
`
|
||||
});
|
||||
|
||||
// 创建统计视图 - 按月统计
|
||||
await clickhouse.query({
|
||||
query: `
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS monthly_stats
|
||||
ENGINE = SummingMergeTree()
|
||||
ORDER BY (month, platform, influencer_id, event_type)
|
||||
AS SELECT
|
||||
toStartOfMonth(timestamp) AS month,
|
||||
platform,
|
||||
influencer_id,
|
||||
event_type,
|
||||
SUM(metric_value) AS total_value,
|
||||
COUNT(*) AS event_count
|
||||
FROM events
|
||||
GROUP BY month, platform, influencer_id, event_type
|
||||
`
|
||||
});
|
||||
|
||||
// 创建帖子互动统计视图
|
||||
await clickhouse.query({
|
||||
query: `
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS post_interaction_stats
|
||||
ENGINE = SummingMergeTree()
|
||||
ORDER BY (post_id, event_type, date)
|
||||
AS SELECT
|
||||
post_id,
|
||||
event_type,
|
||||
toDate(timestamp) AS date,
|
||||
SUM(metric_value) AS value,
|
||||
COUNT(*) AS count
|
||||
FROM events
|
||||
WHERE post_id IS NOT NULL
|
||||
GROUP BY post_id, event_type, date
|
||||
`
|
||||
});
|
||||
|
||||
// 创建项目互动统计视图
|
||||
await clickhouse.query({
|
||||
query: `
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS project_interaction_stats
|
||||
ENGINE = SummingMergeTree()
|
||||
ORDER BY (project_id, event_type, date)
|
||||
AS SELECT
|
||||
project_id,
|
||||
event_type,
|
||||
toDate(timestamp) AS date,
|
||||
SUM(metric_value) AS value,
|
||||
COUNT(*) AS count
|
||||
FROM events
|
||||
WHERE project_id IS NOT NULL AND event_type = 'project_comment'
|
||||
GROUP BY project_id, event_type, date
|
||||
`
|
||||
});
|
||||
|
||||
console.log('ClickHouse 数据表初始化完成');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('初始化 ClickHouse 数据表失败:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 初始化 Supabase 存储函数
|
||||
*/
|
||||
export const initSupabaseFunctions = async () => {
|
||||
try {
|
||||
console.log('开始初始化 Supabase 存储过程...');
|
||||
|
||||
// 创建用户简档表的存储过程
|
||||
await supabase.rpc('create_function_create_user_profiles_if_not_exists');
|
||||
|
||||
// 创建项目表的存储过程
|
||||
await supabase.rpc('create_function_create_projects_table_if_not_exists');
|
||||
|
||||
// 创建网红表的存储过程
|
||||
await supabase.rpc('create_function_create_influencers_table_if_not_exists');
|
||||
|
||||
// 创建项目-网红关联表的存储过程
|
||||
await supabase.rpc('create_function_create_project_influencers_table_if_not_exists');
|
||||
|
||||
// 创建帖子表的存储过程
|
||||
await supabase.rpc('create_function_create_posts_table_if_not_exists');
|
||||
|
||||
// 创建评论表的存储过程
|
||||
await supabase.rpc('create_function_create_comments_table_if_not_exists');
|
||||
|
||||
// 创建项目评论表的存储过程
|
||||
await supabase.rpc('create_function_create_project_comments_table_if_not_exists');
|
||||
|
||||
// 创建评论相关的SQL函数
|
||||
console.log('创建评论相关的SQL函数...');
|
||||
const commentsSQL = await fs.readFile(
|
||||
path.join(__dirname, 'supabase-comments-functions.sql'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
// 使用Supabase执行SQL
|
||||
const { error: commentsFunctionsError } = await supabase.rpc(
|
||||
'pgclient_execute',
|
||||
{ query: commentsSQL }
|
||||
);
|
||||
|
||||
if (commentsFunctionsError) {
|
||||
console.error('创建评论SQL函数失败:', commentsFunctionsError);
|
||||
} else {
|
||||
console.log('评论SQL函数创建成功');
|
||||
}
|
||||
|
||||
console.log('Supabase 存储过程初始化完成');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('初始化 Supabase 存储过程失败:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建测试数据
|
||||
*/
|
||||
export const createSampleData = async () => {
|
||||
try {
|
||||
console.log('开始创建测试数据...');
|
||||
|
||||
// 创建测试用户
|
||||
const { data: user, error: userError } = await supabase.auth.admin.createUser({
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
user_metadata: {
|
||||
full_name: '测试用户'
|
||||
}
|
||||
});
|
||||
|
||||
if (userError) {
|
||||
console.error('创建测试用户失败:', userError);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 创建测试项目
|
||||
const { data: project, error: projectError } = await supabase
|
||||
.from('projects')
|
||||
.insert({
|
||||
name: '测试营销活动',
|
||||
description: '这是一个测试营销活动',
|
||||
created_by: user.user.id
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (projectError) {
|
||||
console.error('创建测试项目失败:', projectError);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 创建项目评论
|
||||
await supabase
|
||||
.from('project_comments')
|
||||
.insert([
|
||||
{
|
||||
project_id: project.id,
|
||||
user_id: user.user.id,
|
||||
content: '这是对项目的一条测试评论',
|
||||
sentiment_score: 0.8
|
||||
},
|
||||
{
|
||||
project_id: project.id,
|
||||
user_id: user.user.id,
|
||||
content: '这个项目很有前景',
|
||||
sentiment_score: 0.9
|
||||
},
|
||||
{
|
||||
project_id: project.id,
|
||||
user_id: user.user.id,
|
||||
content: '需要关注这个项目的进展',
|
||||
sentiment_score: 0.7
|
||||
}
|
||||
]);
|
||||
|
||||
// 创建测试网红
|
||||
const platforms = ['youtube', 'instagram', 'tiktok'];
|
||||
const influencers = [];
|
||||
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
const platform = platforms[Math.floor(Math.random() * platforms.length)];
|
||||
|
||||
const { data: influencer, error: influencerError } = await supabase
|
||||
.from('influencers')
|
||||
.insert({
|
||||
name: `测试网红 ${i}`,
|
||||
platform,
|
||||
profile_url: `https://${platform}.com/user${i}`,
|
||||
external_id: `user_${platform}_${i}`,
|
||||
followers_count: Math.floor(Math.random() * 1000000) + 1000,
|
||||
video_count: Math.floor(Math.random() * 500) + 10
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (influencerError) {
|
||||
console.error(`创建测试网红 ${i} 失败:`, influencerError);
|
||||
continue;
|
||||
}
|
||||
|
||||
influencers.push(influencer);
|
||||
|
||||
// 将网红添加到项目
|
||||
await supabase
|
||||
.from('project_influencers')
|
||||
.insert({
|
||||
project_id: project.id,
|
||||
influencer_id: influencer.influencer_id
|
||||
});
|
||||
|
||||
// 为每个网红创建 3-5 个帖子
|
||||
const postCount = Math.floor(Math.random() * 3) + 3;
|
||||
|
||||
for (let j = 1; j <= postCount; j++) {
|
||||
const { data: post, error: postError } = await supabase
|
||||
.from('posts')
|
||||
.insert({
|
||||
influencer_id: influencer.influencer_id,
|
||||
platform,
|
||||
post_url: `https://${platform}.com/user${i}/post${j}`,
|
||||
title: `测试帖子 ${j} - 由 ${influencer.name} 发布`,
|
||||
description: `这是一个测试帖子的描述 ${j}`,
|
||||
published_at: new Date(Date.now() - Math.floor(Math.random() * 30) * 24 * 60 * 60 * 1000).toISOString()
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (postError) {
|
||||
console.error(`创建测试帖子 ${j} 失败:`, postError);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 为每个帖子创建 2-10 个评论
|
||||
const commentCount = Math.floor(Math.random() * 9) + 2;
|
||||
|
||||
for (let k = 1; k <= commentCount; k++) {
|
||||
await supabase
|
||||
.from('comments')
|
||||
.insert({
|
||||
post_id: post.post_id,
|
||||
user_id: user.user.id,
|
||||
content: `这是对帖子 ${post.title} 的测试评论 ${k}`,
|
||||
sentiment_score: (Math.random() * 2 - 1) // -1 到 1 之间的随机数
|
||||
});
|
||||
}
|
||||
|
||||
// 创建 ClickHouse 事件数据
|
||||
// 粉丝变化事件
|
||||
await clickhouse.query({
|
||||
query: `
|
||||
INSERT INTO events (
|
||||
project_id,
|
||||
influencer_id,
|
||||
platform,
|
||||
event_type,
|
||||
metric_value,
|
||||
event_metadata
|
||||
) VALUES (?, ?, ?, 'follower_change', ?, ?)
|
||||
`,
|
||||
values: [
|
||||
project.id,
|
||||
influencer.influencer_id,
|
||||
platform,
|
||||
Math.floor(Math.random() * 1000) - 200, // -200 到 800 之间的随机数
|
||||
JSON.stringify({ source: 'api_crawler' })
|
||||
]
|
||||
});
|
||||
|
||||
// 帖子点赞变化事件
|
||||
await clickhouse.query({
|
||||
query: `
|
||||
INSERT INTO events (
|
||||
project_id,
|
||||
influencer_id,
|
||||
post_id,
|
||||
platform,
|
||||
event_type,
|
||||
metric_value,
|
||||
event_metadata
|
||||
) VALUES (?, ?, ?, ?, 'post_like_change', ?, ?)
|
||||
`,
|
||||
values: [
|
||||
project.id,
|
||||
influencer.influencer_id,
|
||||
post.post_id,
|
||||
platform,
|
||||
Math.floor(Math.random() * 500) + 10, // 10 到 510 之间的随机数
|
||||
JSON.stringify({ source: 'api_crawler' })
|
||||
]
|
||||
});
|
||||
|
||||
// 帖子观看数变化事件
|
||||
await clickhouse.query({
|
||||
query: `
|
||||
INSERT INTO events (
|
||||
project_id,
|
||||
influencer_id,
|
||||
post_id,
|
||||
platform,
|
||||
event_type,
|
||||
metric_value,
|
||||
event_metadata
|
||||
) VALUES (?, ?, ?, ?, 'post_view_change', ?, ?)
|
||||
`,
|
||||
values: [
|
||||
project.id,
|
||||
influencer.influencer_id,
|
||||
post.post_id,
|
||||
platform,
|
||||
Math.floor(Math.random() * 5000) + 100, // 100 到 5100 之间的随机数
|
||||
JSON.stringify({ source: 'api_crawler' })
|
||||
]
|
||||
});
|
||||
|
||||
// 互动事件
|
||||
const interactionTypes = ['click', 'comment', 'share'];
|
||||
const interactionType = interactionTypes[Math.floor(Math.random() * interactionTypes.length)];
|
||||
|
||||
await clickhouse.query({
|
||||
query: `
|
||||
INSERT INTO events (
|
||||
project_id,
|
||||
influencer_id,
|
||||
post_id,
|
||||
platform,
|
||||
event_type,
|
||||
metric_value,
|
||||
event_metadata
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
values: [
|
||||
project.id,
|
||||
influencer.influencer_id,
|
||||
post.post_id,
|
||||
platform,
|
||||
interactionType,
|
||||
1,
|
||||
JSON.stringify({
|
||||
ip: '192.168.1.' + Math.floor(Math.random() * 255),
|
||||
user_agent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
|
||||
})
|
||||
]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 创建项目评论事件
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
await clickhouse.query({
|
||||
query: `
|
||||
INSERT INTO events (
|
||||
project_id,
|
||||
event_type,
|
||||
metric_value,
|
||||
event_metadata
|
||||
) VALUES (?, 'project_comment', ?, ?)
|
||||
`,
|
||||
values: [
|
||||
project.id,
|
||||
1,
|
||||
JSON.stringify({
|
||||
user_id: user.user.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
comment: `项目评论事件 ${i}`
|
||||
})
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
console.log('测试数据创建完成');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('创建测试数据失败:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查数据库连接
|
||||
*/
|
||||
export const checkDatabaseConnection = async () => {
|
||||
try {
|
||||
console.log('检查数据库连接...');
|
||||
|
||||
// 检查 Supabase 连接
|
||||
try {
|
||||
// 仅检查连接是否正常,不执行实际查询
|
||||
const { data, error } = await supabase.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.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;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 初始化数据库 - 此函数现在仅作为手动初始化的入口点
|
||||
* 只有通过管理API明确调用时才会执行实际的初始化
|
||||
*/
|
||||
export const initDatabase = async () => {
|
||||
try {
|
||||
console.log('开始数据库初始化...');
|
||||
console.log('警告: 此操作将修改数据库结构,请确保您知道自己在做什么');
|
||||
|
||||
// 初始化 Supabase 函数
|
||||
await initSupabaseFunctions();
|
||||
|
||||
// 初始化 Supabase 表
|
||||
await initSupabaseTables();
|
||||
|
||||
// 初始化 ClickHouse 表
|
||||
await initClickHouseTables();
|
||||
|
||||
console.log('数据库初始化完成');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('数据库初始化失败:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
189
backend/src/utils/queue.ts
Normal file
189
backend/src/utils/queue.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { Queue, Worker, Job } from 'bullmq';
|
||||
import config from '../config';
|
||||
|
||||
// Define queue names
|
||||
export const QUEUE_NAMES = {
|
||||
ANALYTICS: 'analytics',
|
||||
NOTIFICATIONS: 'notifications',
|
||||
};
|
||||
|
||||
// Define job data types
|
||||
interface AnalyticsJobData {
|
||||
type: 'process_views' | 'process_likes' | 'process_followers';
|
||||
data: Record<string, any>;
|
||||
}
|
||||
|
||||
interface NotificationJobData {
|
||||
type: 'new_follower' | 'new_like';
|
||||
data: Record<string, any>;
|
||||
}
|
||||
|
||||
// Create Redis connection options
|
||||
const redisOptions = {
|
||||
host: config.bull.redis.host,
|
||||
port: config.bull.redis.port,
|
||||
password: config.bull.redis.password,
|
||||
};
|
||||
|
||||
// Create queues with error handling
|
||||
let analyticsQueue: Queue<AnalyticsJobData>;
|
||||
let notificationsQueue: Queue<NotificationJobData>;
|
||||
|
||||
try {
|
||||
analyticsQueue = new Queue<AnalyticsJobData>(QUEUE_NAMES.ANALYTICS, {
|
||||
connection: redisOptions,
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
backoff: {
|
||||
type: 'exponential',
|
||||
delay: 1000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
notificationsQueue = new Queue<NotificationJobData>(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: string, data: AnalyticsJobData) => {
|
||||
console.log(`Mock analytics job added: ${name}`, data);
|
||||
return { id: 'mock-job-id' } as any;
|
||||
},
|
||||
close: async () => console.log('Mock analytics queue closed'),
|
||||
} as any;
|
||||
|
||||
notificationsQueue = {
|
||||
add: async (name: string, data: NotificationJobData) => {
|
||||
console.log(`Mock notification job added: ${name}`, data);
|
||||
return { id: 'mock-job-id' } as any;
|
||||
},
|
||||
close: async () => console.log('Mock notifications queue closed'),
|
||||
} as any;
|
||||
}
|
||||
|
||||
// Initialize workers
|
||||
export const initWorkers = () => {
|
||||
try {
|
||||
// Analytics worker
|
||||
const analyticsWorker = new Worker<AnalyticsJobData>(
|
||||
QUEUE_NAMES.ANALYTICS,
|
||||
async (job: Job<AnalyticsJobData>) => {
|
||||
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 as string}`);
|
||||
}
|
||||
},
|
||||
{ connection: redisOptions }
|
||||
);
|
||||
|
||||
// Notifications worker
|
||||
const notificationsWorker = new Worker<NotificationJobData>(
|
||||
QUEUE_NAMES.NOTIFICATIONS,
|
||||
async (job: Job<NotificationJobData>) => {
|
||||
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 as string}`);
|
||||
}
|
||||
},
|
||||
{ connection: redisOptions }
|
||||
);
|
||||
|
||||
// Handle worker events
|
||||
analyticsWorker.on('completed', (job: Job<AnalyticsJobData>) => {
|
||||
console.log(`Analytics job ${job.id} completed`);
|
||||
});
|
||||
|
||||
analyticsWorker.on('failed', (job: Job<AnalyticsJobData> | undefined, err: Error) => {
|
||||
console.error(`Analytics job ${job?.id} failed with error ${err.message}`);
|
||||
});
|
||||
|
||||
notificationsWorker.on('completed', (job: Job<NotificationJobData>) => {
|
||||
console.log(`Notification job ${job.id} completed`);
|
||||
});
|
||||
|
||||
notificationsWorker.on('failed', (job: Job<NotificationJobData> | undefined, err: Error) => {
|
||||
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'),
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to add jobs to queues
|
||||
export const addAnalyticsJob = async (
|
||||
type: AnalyticsJobData['type'],
|
||||
data: Record<string, any>,
|
||||
options = {}
|
||||
) => {
|
||||
try {
|
||||
return await analyticsQueue.add(type, { type, data } as AnalyticsJobData, options);
|
||||
} catch (error) {
|
||||
console.error('Error adding analytics job:', error);
|
||||
console.log('Job details:', { type, data });
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const addNotificationJob = async (
|
||||
type: NotificationJobData['type'],
|
||||
data: Record<string, any>,
|
||||
options = {}
|
||||
) => {
|
||||
try {
|
||||
return await notificationsQueue.add(type, { type, data } as NotificationJobData, options);
|
||||
} catch (error) {
|
||||
console.error('Error adding notification job:', error);
|
||||
console.log('Job details:', { type, data });
|
||||
return null;
|
||||
}
|
||||
};
|
||||
80
backend/src/utils/redis.ts
Normal file
80
backend/src/utils/redis.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { createClient } from 'redis';
|
||||
import config from '../config';
|
||||
|
||||
// Create Redis client
|
||||
const redisClient = createClient({
|
||||
url: `redis://${config.redis.password ? `${config.redis.password}@` : ''}${config.redis.host}:${config.redis.port}`,
|
||||
});
|
||||
|
||||
// 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<string, string>();
|
||||
|
||||
return {
|
||||
isOpen: true,
|
||||
connect: async () => console.log('Mock Redis client connected'),
|
||||
get: async (key: string) => store.get(key) || null,
|
||||
set: async (key: string, value: string) => {
|
||||
store.set(key, value);
|
||||
return 'OK';
|
||||
},
|
||||
incr: async (key: string) => {
|
||||
const current = parseInt(store.get(key) || '0', 10);
|
||||
const newValue = current + 1;
|
||||
store.set(key, newValue.toString());
|
||||
return newValue;
|
||||
},
|
||||
decr: async (key: string) => {
|
||||
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: ReturnType<typeof createMockRedisClient> | null = 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;
|
||||
}
|
||||
};
|
||||
|
||||
// Export the appropriate client
|
||||
const getRedisClient = async () => {
|
||||
try {
|
||||
if (redisClient.isOpen) {
|
||||
return redisClient;
|
||||
}
|
||||
return await connectRedis();
|
||||
} catch (error) {
|
||||
if (!mockRedisClient) {
|
||||
mockRedisClient = createMockRedisClient();
|
||||
}
|
||||
return mockRedisClient;
|
||||
}
|
||||
};
|
||||
|
||||
export { redisClient, connectRedis, getRedisClient };
|
||||
export default redisClient;
|
||||
97
backend/src/utils/supabase-comments-functions.sql
Normal file
97
backend/src/utils/supabase-comments-functions.sql
Normal file
@@ -0,0 +1,97 @@
|
||||
-- 创建获取所有评论的函数
|
||||
CREATE
|
||||
OR REPLACE FUNCTION public .get_comments_with_posts() RETURNS TABLE (
|
||||
comment_id UUID,
|
||||
content TEXT,
|
||||
sentiment_score FLOAT,
|
||||
created_at TIMESTAMP WITH TIME ZONE,
|
||||
updated_at TIMESTAMP WITH TIME ZONE,
|
||||
post_id UUID,
|
||||
user_id UUID,
|
||||
user_profile JSONB,
|
||||
post JSONB
|
||||
) LANGUAGE plpgsql SECURITY DEFINER AS $$ BEGIN
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
c .comment_id,
|
||||
c .content,
|
||||
c .sentiment_score,
|
||||
c .created_at,
|
||||
c .updated_at,
|
||||
c .post_id,
|
||||
c .user_id,
|
||||
jsonb_build_object(
|
||||
'id',
|
||||
up.id,
|
||||
'full_name',
|
||||
up.full_name,
|
||||
'avatar_url',
|
||||
up.avatar_url
|
||||
) AS user_profile,
|
||||
jsonb_build_object(
|
||||
'post_id',
|
||||
p.post_id,
|
||||
'title',
|
||||
p.title,
|
||||
'description',
|
||||
p.description,
|
||||
'platform',
|
||||
p.platform,
|
||||
'post_url',
|
||||
p.post_url,
|
||||
'published_at',
|
||||
p.published_at,
|
||||
'influencer_id',
|
||||
p.influencer_id
|
||||
) AS post
|
||||
FROM
|
||||
public .comments c
|
||||
LEFT JOIN public .user_profiles up ON c .user_id = up.id
|
||||
LEFT JOIN public .posts p ON c .post_id = p.post_id
|
||||
ORDER BY
|
||||
c .created_at DESC;
|
||||
|
||||
END;
|
||||
|
||||
$$;
|
||||
|
||||
-- 创建获取特定帖子评论的函数
|
||||
CREATE
|
||||
OR REPLACE FUNCTION public .get_comments_for_post(post_id_param UUID) RETURNS TABLE (
|
||||
comment_id UUID,
|
||||
content TEXT,
|
||||
sentiment_score FLOAT,
|
||||
created_at TIMESTAMP WITH TIME ZONE,
|
||||
updated_at TIMESTAMP WITH TIME ZONE,
|
||||
post_id UUID,
|
||||
user_id UUID,
|
||||
user_profile JSONB
|
||||
) LANGUAGE plpgsql SECURITY DEFINER AS $$ BEGIN
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
c .comment_id,
|
||||
c .content,
|
||||
c .sentiment_score,
|
||||
c .created_at,
|
||||
c .updated_at,
|
||||
c .post_id,
|
||||
c .user_id,
|
||||
jsonb_build_object(
|
||||
'id',
|
||||
up.id,
|
||||
'full_name',
|
||||
up.full_name,
|
||||
'avatar_url',
|
||||
up.avatar_url
|
||||
) AS user_profile
|
||||
FROM
|
||||
public .comments c
|
||||
LEFT JOIN public .user_profiles up ON c .user_id = up.id
|
||||
WHERE
|
||||
c .post_id = post_id_param
|
||||
ORDER BY
|
||||
c .created_at DESC;
|
||||
|
||||
END;
|
||||
|
||||
$$;
|
||||
738
backend/src/utils/supabase-functions.sql
Normal file
738
backend/src/utils/supabase-functions.sql
Normal file
@@ -0,0 +1,738 @@
|
||||
-- 为系统创建所需的存储过程和表
|
||||
-- 创建用户简档表的函数
|
||||
CREATE
|
||||
OR REPLACE FUNCTION create_function_create_user_profiles_if_not_exists() RETURNS void LANGUAGE plpgsql AS $$ BEGIN
|
||||
EXECUTE $FUNC$ CREATE
|
||||
OR REPLACE FUNCTION create_user_profiles_if_not_exists() RETURNS void LANGUAGE plpgsql AS $INNER$ BEGIN
|
||||
-- 检查用户简档表是否存在
|
||||
IF NOT EXISTS (
|
||||
SELECT
|
||||
FROM
|
||||
pg_tables
|
||||
WHERE
|
||||
schemaname = 'public'
|
||||
AND tablename = 'user_profiles'
|
||||
) THEN -- 创建用户简档表
|
||||
CREATE TABLE public .user_profiles (
|
||||
id UUID PRIMARY KEY REFERENCES auth.users(id) ON
|
||||
DELETE
|
||||
CASCADE,
|
||||
full_name TEXT,
|
||||
avatar_url TEXT,
|
||||
website TEXT,
|
||||
company TEXT,
|
||||
role TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT now()
|
||||
);
|
||||
|
||||
-- 添加 RLS 策略
|
||||
ALTER TABLE
|
||||
public .user_profiles ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- 创建只有自己可以更新自己简档的策略
|
||||
CREATE POLICY "Users can view all profiles" ON public .user_profiles FOR
|
||||
SELECT
|
||||
USING (true);
|
||||
|
||||
CREATE POLICY "Users can update own profile" ON public .user_profiles FOR
|
||||
UPDATE
|
||||
USING (auth.uid() = id);
|
||||
|
||||
CREATE POLICY "Users can insert own profile" ON public .user_profiles FOR
|
||||
INSERT
|
||||
WITH CHECK (auth.uid() = id);
|
||||
|
||||
-- 创建新用户时自动创建简档的触发器
|
||||
CREATE
|
||||
OR REPLACE FUNCTION public .handle_new_user() RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER AS $TRIGGER$ BEGIN
|
||||
INSERT INTO
|
||||
public .user_profiles (id, full_name, avatar_url)
|
||||
VALUES
|
||||
(
|
||||
NEW .id,
|
||||
NEW .raw_user_meta_data ->> 'full_name',
|
||||
NEW .raw_user_meta_data ->> 'avatar_url'
|
||||
);
|
||||
|
||||
RETURN NEW;
|
||||
|
||||
END;
|
||||
|
||||
$TRIGGER$;
|
||||
|
||||
-- 创建触发器
|
||||
DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users;
|
||||
|
||||
CREATE TRIGGER on_auth_user_created AFTER
|
||||
INSERT
|
||||
ON auth.users FOR EACH ROW EXECUTE FUNCTION public .handle_new_user();
|
||||
|
||||
RAISE NOTICE 'Created user_profiles table with RLS policies and triggers';
|
||||
|
||||
ELSE RAISE NOTICE 'user_profiles table already exists';
|
||||
|
||||
END IF;
|
||||
|
||||
END;
|
||||
|
||||
$INNER$;
|
||||
|
||||
$FUNC$;
|
||||
|
||||
RAISE NOTICE 'Created function create_user_profiles_if_not_exists()';
|
||||
|
||||
END;
|
||||
|
||||
$$;
|
||||
|
||||
-- 创建项目表的函数
|
||||
CREATE
|
||||
OR REPLACE FUNCTION create_function_create_projects_table_if_not_exists() RETURNS void LANGUAGE plpgsql AS $$ BEGIN
|
||||
EXECUTE $FUNC$ CREATE
|
||||
OR REPLACE FUNCTION create_projects_table_if_not_exists() RETURNS void LANGUAGE plpgsql AS $INNER$ BEGIN
|
||||
-- 检查项目表是否存在
|
||||
IF NOT EXISTS (
|
||||
SELECT
|
||||
FROM
|
||||
pg_tables
|
||||
WHERE
|
||||
schemaname = 'public'
|
||||
AND tablename = 'projects'
|
||||
) THEN -- 创建项目表
|
||||
CREATE TABLE public .projects (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
status TEXT DEFAULT 'active' CHECK (status IN ('active', 'archived', 'completed')),
|
||||
start_date TIMESTAMP WITH TIME ZONE,
|
||||
end_date TIMESTAMP WITH TIME ZONE,
|
||||
created_by UUID REFERENCES auth.users(id) ON
|
||||
DELETE
|
||||
SET
|
||||
NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT now()
|
||||
);
|
||||
|
||||
-- 添加 RLS 策略
|
||||
ALTER TABLE
|
||||
public .projects ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- 创建只有创建者可以管理项目的策略
|
||||
CREATE POLICY "Users can view own projects" ON public .projects FOR
|
||||
SELECT
|
||||
USING (auth.uid() = created_by);
|
||||
|
||||
CREATE POLICY "Users can insert own projects" ON public .projects FOR
|
||||
INSERT
|
||||
WITH CHECK (auth.uid() = created_by);
|
||||
|
||||
CREATE POLICY "Users can update own projects" ON public .projects FOR
|
||||
UPDATE
|
||||
USING (auth.uid() = created_by);
|
||||
|
||||
CREATE POLICY "Users can delete own projects" ON public .projects FOR
|
||||
DELETE
|
||||
USING (auth.uid() = created_by);
|
||||
|
||||
-- 创建更新时间的触发器
|
||||
CREATE
|
||||
OR REPLACE FUNCTION public .update_project_updated_at() RETURNS TRIGGER LANGUAGE plpgsql AS $TRIGGER$ BEGIN
|
||||
NEW .updated_at = now();
|
||||
|
||||
RETURN NEW;
|
||||
|
||||
END;
|
||||
|
||||
$TRIGGER$;
|
||||
|
||||
-- 创建触发器
|
||||
DROP TRIGGER IF EXISTS on_project_updated ON public .projects;
|
||||
|
||||
CREATE TRIGGER on_project_updated BEFORE
|
||||
UPDATE
|
||||
ON public .projects FOR EACH ROW EXECUTE FUNCTION public .update_project_updated_at();
|
||||
|
||||
RAISE NOTICE 'Created projects table with RLS policies and triggers';
|
||||
|
||||
ELSE RAISE NOTICE 'projects table already exists';
|
||||
|
||||
END IF;
|
||||
|
||||
END;
|
||||
|
||||
$INNER$;
|
||||
|
||||
$FUNC$;
|
||||
|
||||
RAISE NOTICE 'Created function create_projects_table_if_not_exists()';
|
||||
|
||||
END;
|
||||
|
||||
$$;
|
||||
|
||||
-- 创建网红(影响者)表的函数
|
||||
CREATE
|
||||
OR REPLACE FUNCTION create_function_create_influencers_table_if_not_exists() RETURNS void LANGUAGE plpgsql AS $$ BEGIN
|
||||
EXECUTE $FUNC$ CREATE
|
||||
OR REPLACE FUNCTION create_influencers_table_if_not_exists() RETURNS void LANGUAGE plpgsql AS $INNER$ BEGIN
|
||||
-- 检查网红表是否存在
|
||||
IF NOT EXISTS (
|
||||
SELECT
|
||||
FROM
|
||||
pg_tables
|
||||
WHERE
|
||||
schemaname = 'public'
|
||||
AND tablename = 'influencers'
|
||||
) THEN -- 创建网红表
|
||||
CREATE TABLE public .influencers (
|
||||
influencer_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL,
|
||||
platform TEXT CHECK (
|
||||
platform IN (
|
||||
'youtube',
|
||||
'instagram',
|
||||
'tiktok',
|
||||
'twitter',
|
||||
'facebook'
|
||||
)
|
||||
),
|
||||
profile_url TEXT,
|
||||
external_id TEXT UNIQUE,
|
||||
followers_count INT DEFAULT 0,
|
||||
video_count INT DEFAULT 0,
|
||||
platform_count INT DEFAULT 1,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT now()
|
||||
);
|
||||
|
||||
-- 添加 RLS 策略
|
||||
ALTER TABLE
|
||||
public .influencers ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- 创建所有认证用户可以查看网红的策略
|
||||
CREATE POLICY "Authenticated users can view influencers" ON public .influencers FOR
|
||||
SELECT
|
||||
USING (auth.role() = 'authenticated');
|
||||
|
||||
-- 创建有权限的用户可以更新网红的策略
|
||||
CREATE POLICY "Authenticated users can insert influencers" ON public .influencers FOR
|
||||
INSERT
|
||||
WITH CHECK (auth.role() = 'authenticated');
|
||||
|
||||
CREATE POLICY "Authenticated users can update influencers" ON public .influencers FOR
|
||||
UPDATE
|
||||
USING (auth.role() = 'authenticated');
|
||||
|
||||
-- 创建更新时间的触发器
|
||||
CREATE
|
||||
OR REPLACE FUNCTION public .update_influencer_updated_at() RETURNS TRIGGER LANGUAGE plpgsql AS $TRIGGER$ BEGIN
|
||||
NEW .updated_at = now();
|
||||
|
||||
RETURN NEW;
|
||||
|
||||
END;
|
||||
|
||||
$TRIGGER$;
|
||||
|
||||
-- 创建触发器
|
||||
DROP TRIGGER IF EXISTS on_influencer_updated ON public .influencers;
|
||||
|
||||
CREATE TRIGGER on_influencer_updated BEFORE
|
||||
UPDATE
|
||||
ON public .influencers FOR EACH ROW EXECUTE FUNCTION public .update_influencer_updated_at();
|
||||
|
||||
RAISE NOTICE 'Created influencers table with RLS policies and triggers';
|
||||
|
||||
ELSE RAISE NOTICE 'influencers table already exists';
|
||||
|
||||
END IF;
|
||||
|
||||
END;
|
||||
|
||||
$INNER$;
|
||||
|
||||
$FUNC$;
|
||||
|
||||
RAISE NOTICE 'Created function create_influencers_table_if_not_exists()';
|
||||
|
||||
END;
|
||||
|
||||
$$;
|
||||
|
||||
-- 创建项目-网红关联表的函数
|
||||
CREATE
|
||||
OR REPLACE FUNCTION create_function_create_project_influencers_table_if_not_exists() RETURNS void LANGUAGE plpgsql AS $$ BEGIN
|
||||
EXECUTE $FUNC$ CREATE
|
||||
OR REPLACE FUNCTION create_project_influencers_table_if_not_exists() RETURNS void LANGUAGE plpgsql AS $INNER$ BEGIN
|
||||
-- 检查项目-网红关联表是否存在
|
||||
IF NOT EXISTS (
|
||||
SELECT
|
||||
FROM
|
||||
pg_tables
|
||||
WHERE
|
||||
schemaname = 'public'
|
||||
AND tablename = 'project_influencers'
|
||||
) THEN -- 创建项目-网红关联表
|
||||
CREATE TABLE public .project_influencers (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
project_id UUID REFERENCES public .projects(id) ON
|
||||
DELETE
|
||||
CASCADE,
|
||||
influencer_id UUID REFERENCES public .influencers(influencer_id) ON
|
||||
DELETE
|
||||
CASCADE,
|
||||
status TEXT DEFAULT 'active' CHECK (status IN ('active', 'inactive', 'completed')),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
|
||||
UNIQUE (project_id, influencer_id)
|
||||
);
|
||||
|
||||
-- 添加索引
|
||||
CREATE INDEX idx_project_influencers_project_id ON public .project_influencers(project_id);
|
||||
|
||||
CREATE INDEX idx_project_influencers_influencer_id ON public .project_influencers(influencer_id);
|
||||
|
||||
-- 添加 RLS 策略
|
||||
ALTER TABLE
|
||||
public .project_influencers ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- 创建只有项目创建者可以管理项目-网红关联的策略
|
||||
CREATE POLICY "Users can view project influencers" ON public .project_influencers FOR
|
||||
SELECT
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT
|
||||
1
|
||||
FROM
|
||||
public .projects
|
||||
WHERE
|
||||
id = project_id
|
||||
AND created_by = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "Users can insert project influencers" ON public .project_influencers FOR
|
||||
INSERT
|
||||
WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT
|
||||
1
|
||||
FROM
|
||||
public .projects
|
||||
WHERE
|
||||
id = project_id
|
||||
AND created_by = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "Users can update project influencers" ON public .project_influencers FOR
|
||||
UPDATE
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT
|
||||
1
|
||||
FROM
|
||||
public .projects
|
||||
WHERE
|
||||
id = project_id
|
||||
AND created_by = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "Users can delete project influencers" ON public .project_influencers FOR
|
||||
DELETE
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT
|
||||
1
|
||||
FROM
|
||||
public .projects
|
||||
WHERE
|
||||
id = project_id
|
||||
AND created_by = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- 创建更新时间的触发器
|
||||
CREATE
|
||||
OR REPLACE FUNCTION public .update_project_influencer_updated_at() RETURNS TRIGGER LANGUAGE plpgsql AS $TRIGGER$ BEGIN
|
||||
NEW .updated_at = now();
|
||||
|
||||
RETURN NEW;
|
||||
|
||||
END;
|
||||
|
||||
$TRIGGER$;
|
||||
|
||||
-- 创建触发器
|
||||
DROP TRIGGER IF EXISTS on_project_influencer_updated ON public .project_influencers;
|
||||
|
||||
CREATE TRIGGER on_project_influencer_updated BEFORE
|
||||
UPDATE
|
||||
ON public .project_influencers FOR EACH ROW EXECUTE FUNCTION public .update_project_influencer_updated_at();
|
||||
|
||||
RAISE NOTICE 'Created project_influencers table with RLS policies and triggers';
|
||||
|
||||
ELSE RAISE NOTICE 'project_influencers table already exists';
|
||||
|
||||
END IF;
|
||||
|
||||
END;
|
||||
|
||||
$INNER$;
|
||||
|
||||
$FUNC$;
|
||||
|
||||
RAISE NOTICE 'Created function create_project_influencers_table_if_not_exists()';
|
||||
|
||||
END;
|
||||
|
||||
$$;
|
||||
|
||||
-- 创建帖子表的函数
|
||||
CREATE
|
||||
OR REPLACE FUNCTION create_function_create_posts_table_if_not_exists() RETURNS void LANGUAGE plpgsql AS $$ BEGIN
|
||||
EXECUTE $FUNC$ CREATE
|
||||
OR REPLACE FUNCTION create_posts_table_if_not_exists() RETURNS void LANGUAGE plpgsql AS $INNER$ BEGIN
|
||||
-- 检查帖子表是否存在
|
||||
IF NOT EXISTS (
|
||||
SELECT
|
||||
FROM
|
||||
pg_tables
|
||||
WHERE
|
||||
schemaname = 'public'
|
||||
AND tablename = 'posts'
|
||||
) THEN -- 创建帖子表
|
||||
CREATE TABLE public .posts (
|
||||
post_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
influencer_id UUID REFERENCES public .influencers(influencer_id) ON
|
||||
DELETE
|
||||
CASCADE,
|
||||
platform TEXT CHECK (
|
||||
platform IN (
|
||||
'youtube',
|
||||
'instagram',
|
||||
'tiktok',
|
||||
'twitter',
|
||||
'facebook'
|
||||
)
|
||||
),
|
||||
post_url TEXT UNIQUE NOT NULL,
|
||||
title TEXT,
|
||||
description TEXT,
|
||||
published_at TIMESTAMP WITH TIME ZONE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT now()
|
||||
);
|
||||
|
||||
-- 添加索引
|
||||
CREATE INDEX idx_posts_influencer_id ON public .posts(influencer_id);
|
||||
|
||||
CREATE INDEX idx_posts_platform ON public .posts(platform);
|
||||
|
||||
CREATE INDEX idx_posts_published_at ON public .posts(published_at);
|
||||
|
||||
-- 添加 RLS 策略
|
||||
ALTER TABLE
|
||||
public .posts ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- 创建所有认证用户可以查看帖子的策略
|
||||
CREATE POLICY "Authenticated users can view posts" ON public .posts FOR
|
||||
SELECT
|
||||
USING (auth.role() = 'authenticated');
|
||||
|
||||
-- 创建有权限的用户可以更新帖子的策略
|
||||
CREATE POLICY "Authenticated users can insert posts" ON public .posts FOR
|
||||
INSERT
|
||||
WITH CHECK (auth.role() = 'authenticated');
|
||||
|
||||
CREATE POLICY "Authenticated users can update posts" ON public .posts FOR
|
||||
UPDATE
|
||||
USING (auth.role() = 'authenticated');
|
||||
|
||||
-- 创建更新时间的触发器
|
||||
CREATE
|
||||
OR REPLACE FUNCTION public .update_post_updated_at() RETURNS TRIGGER LANGUAGE plpgsql AS $TRIGGER$ BEGIN
|
||||
NEW .updated_at = now();
|
||||
|
||||
RETURN NEW;
|
||||
|
||||
END;
|
||||
|
||||
$TRIGGER$;
|
||||
|
||||
-- 创建触发器
|
||||
DROP TRIGGER IF EXISTS on_post_updated ON public .posts;
|
||||
|
||||
CREATE TRIGGER on_post_updated BEFORE
|
||||
UPDATE
|
||||
ON public .posts FOR EACH ROW EXECUTE FUNCTION public .update_post_updated_at();
|
||||
|
||||
RAISE NOTICE 'Created posts table with RLS policies and triggers';
|
||||
|
||||
ELSE RAISE NOTICE 'posts table already exists';
|
||||
|
||||
END IF;
|
||||
|
||||
END;
|
||||
|
||||
$INNER$;
|
||||
|
||||
$FUNC$;
|
||||
|
||||
RAISE NOTICE 'Created function create_posts_table_if_not_exists()';
|
||||
|
||||
END;
|
||||
|
||||
$$;
|
||||
|
||||
-- 创建评论表的函数
|
||||
CREATE
|
||||
OR REPLACE FUNCTION create_function_create_comments_table_if_not_exists() RETURNS void LANGUAGE plpgsql AS $$ BEGIN
|
||||
EXECUTE $FUNC$ CREATE
|
||||
OR REPLACE FUNCTION create_comments_table_if_not_exists() RETURNS void LANGUAGE plpgsql AS $INNER$ BEGIN
|
||||
-- 检查评论表是否存在
|
||||
IF NOT EXISTS (
|
||||
SELECT
|
||||
FROM
|
||||
pg_tables
|
||||
WHERE
|
||||
schemaname = 'public'
|
||||
AND tablename = 'comments'
|
||||
) THEN -- 创建评论表
|
||||
CREATE TABLE public .comments (
|
||||
comment_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
post_id UUID REFERENCES public .posts(post_id) ON
|
||||
DELETE
|
||||
CASCADE,
|
||||
user_id UUID REFERENCES auth.users(id) ON
|
||||
DELETE
|
||||
SET
|
||||
NULL,
|
||||
content TEXT NOT NULL,
|
||||
sentiment_score FLOAT,
|
||||
status TEXT DEFAULT 'approved' CHECK (status IN ('approved', 'pending', 'rejected')),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT now()
|
||||
);
|
||||
|
||||
-- 添加索引
|
||||
CREATE INDEX idx_comments_post_id ON public .comments(post_id);
|
||||
|
||||
CREATE INDEX idx_comments_user_id ON public .comments(user_id);
|
||||
|
||||
CREATE INDEX idx_comments_created_at ON public .comments(created_at);
|
||||
|
||||
-- 添加 RLS 策略
|
||||
ALTER TABLE
|
||||
public .comments ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- 创建所有认证用户可以查看评论的策略
|
||||
CREATE POLICY "Authenticated users can view comments" ON public .comments FOR
|
||||
SELECT
|
||||
USING (auth.role() = 'authenticated');
|
||||
|
||||
-- 创建有权限的用户可以添加评论的策略
|
||||
CREATE POLICY "Authenticated users can insert comments" ON public .comments FOR
|
||||
INSERT
|
||||
WITH CHECK (auth.role() = 'authenticated');
|
||||
|
||||
-- 创建只有评论作者可以更新评论的策略
|
||||
CREATE POLICY "Users can update own comments" ON public .comments FOR
|
||||
UPDATE
|
||||
USING (auth.uid() = user_id);
|
||||
|
||||
-- 创建只有评论作者可以删除评论的策略
|
||||
CREATE POLICY "Users can delete own comments" ON public .comments FOR
|
||||
DELETE
|
||||
USING (auth.uid() = user_id);
|
||||
|
||||
-- 创建更新时间的触发器
|
||||
CREATE
|
||||
OR REPLACE FUNCTION public .update_comment_updated_at() RETURNS TRIGGER LANGUAGE plpgsql AS $TRIGGER$ BEGIN
|
||||
NEW .updated_at = now();
|
||||
|
||||
RETURN NEW;
|
||||
|
||||
END;
|
||||
|
||||
$TRIGGER$;
|
||||
|
||||
-- 创建触发器
|
||||
DROP TRIGGER IF EXISTS on_comment_updated ON public .comments;
|
||||
|
||||
CREATE TRIGGER on_comment_updated BEFORE
|
||||
UPDATE
|
||||
ON public .comments FOR EACH ROW EXECUTE FUNCTION public .update_comment_updated_at();
|
||||
|
||||
RAISE NOTICE 'Created comments table with RLS policies and triggers';
|
||||
|
||||
ELSE RAISE NOTICE 'comments table already exists';
|
||||
|
||||
END IF;
|
||||
|
||||
END;
|
||||
|
||||
$INNER$;
|
||||
|
||||
$FUNC$;
|
||||
|
||||
RAISE NOTICE 'Created function create_comments_table_if_not_exists()';
|
||||
|
||||
END;
|
||||
|
||||
$$;
|
||||
|
||||
-- 创建项目评论表的函数
|
||||
CREATE
|
||||
OR REPLACE FUNCTION create_function_create_project_comments_table_if_not_exists() RETURNS void LANGUAGE plpgsql AS $$ BEGIN
|
||||
EXECUTE $FUNC$ CREATE
|
||||
OR REPLACE FUNCTION create_project_comments_table_if_not_exists() RETURNS void LANGUAGE plpgsql AS $INNER$ BEGIN
|
||||
-- 检查项目评论表是否存在
|
||||
IF NOT EXISTS (
|
||||
SELECT
|
||||
FROM
|
||||
pg_tables
|
||||
WHERE
|
||||
schemaname = 'public'
|
||||
AND tablename = 'project_comments'
|
||||
) THEN -- 创建项目评论表
|
||||
CREATE TABLE public .project_comments (
|
||||
comment_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
project_id UUID REFERENCES public .projects(id) ON
|
||||
DELETE
|
||||
CASCADE,
|
||||
user_id UUID REFERENCES auth.users(id) ON
|
||||
DELETE
|
||||
SET
|
||||
NULL,
|
||||
content TEXT NOT NULL,
|
||||
sentiment_score FLOAT,
|
||||
status TEXT DEFAULT 'approved' CHECK (status IN ('approved', 'pending', 'rejected')),
|
||||
is_pinned BOOLEAN DEFAULT false,
|
||||
parent_id UUID REFERENCES public .project_comments(comment_id) ON
|
||||
DELETE
|
||||
SET
|
||||
NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT now()
|
||||
);
|
||||
|
||||
-- 添加索引
|
||||
CREATE INDEX idx_project_comments_project_id ON public .project_comments(project_id);
|
||||
|
||||
CREATE INDEX idx_project_comments_user_id ON public .project_comments(user_id);
|
||||
|
||||
CREATE INDEX idx_project_comments_parent_id ON public .project_comments(parent_id);
|
||||
|
||||
CREATE INDEX idx_project_comments_created_at ON public .project_comments(created_at);
|
||||
|
||||
-- 添加 RLS 策略
|
||||
ALTER TABLE
|
||||
public .project_comments ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- 创建项目评论可见性策略
|
||||
CREATE POLICY "Project members can view project comments" ON public .project_comments FOR
|
||||
SELECT
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT
|
||||
1
|
||||
FROM
|
||||
public .projects
|
||||
WHERE
|
||||
id = project_id
|
||||
AND (
|
||||
created_by = auth.uid()
|
||||
OR EXISTS (
|
||||
SELECT
|
||||
1
|
||||
FROM
|
||||
public .project_influencers pi
|
||||
JOIN public .influencers i ON pi.influencer_id = i.influencer_id
|
||||
WHERE
|
||||
pi.project_id = project_id
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
-- 创建认证用户可以添加评论的策略
|
||||
CREATE POLICY "Authenticated users can insert project comments" ON public .project_comments FOR
|
||||
INSERT
|
||||
WITH CHECK (auth.role() = 'authenticated');
|
||||
|
||||
-- 创建只有评论作者可以更新评论的策略
|
||||
CREATE POLICY "Users can update own project comments" ON public .project_comments FOR
|
||||
UPDATE
|
||||
USING (auth.uid() = user_id);
|
||||
|
||||
-- 创建项目所有者和评论作者可以删除评论的策略
|
||||
CREATE POLICY "Project owner and comment creator can delete project comments" ON public .project_comments FOR
|
||||
DELETE
|
||||
USING (
|
||||
auth.uid() = user_id
|
||||
OR EXISTS (
|
||||
SELECT
|
||||
1
|
||||
FROM
|
||||
public .projects
|
||||
WHERE
|
||||
id = project_id
|
||||
AND created_by = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- 创建更新时间的触发器
|
||||
CREATE
|
||||
OR REPLACE FUNCTION public .update_project_comment_updated_at() RETURNS TRIGGER LANGUAGE plpgsql AS $TRIGGER$ BEGIN
|
||||
NEW .updated_at = now();
|
||||
|
||||
RETURN NEW;
|
||||
|
||||
END;
|
||||
|
||||
$TRIGGER$;
|
||||
|
||||
-- 创建触发器
|
||||
DROP TRIGGER IF EXISTS on_project_comment_updated ON public .project_comments;
|
||||
|
||||
CREATE TRIGGER on_project_comment_updated BEFORE
|
||||
UPDATE
|
||||
ON public .project_comments FOR EACH ROW EXECUTE FUNCTION public .update_project_comment_updated_at();
|
||||
|
||||
-- 创建获取评论回复数量的函数
|
||||
CREATE
|
||||
OR REPLACE FUNCTION public .get_reply_counts_for_comments(parent_ids UUID [ ]) RETURNS TABLE (parent_id UUID, count BIGINT) LANGUAGE sql SECURITY DEFINER AS $$
|
||||
SELECT
|
||||
parent_id,
|
||||
COUNT(*) as count
|
||||
FROM
|
||||
public .project_comments
|
||||
WHERE
|
||||
parent_id IS NOT NULL
|
||||
AND parent_id = ANY(parent_ids)
|
||||
GROUP BY
|
||||
parent_id;
|
||||
|
||||
$$;
|
||||
|
||||
RAISE NOTICE 'Created project_comments table with RLS policies and triggers';
|
||||
|
||||
ELSE RAISE NOTICE 'project_comments table already exists';
|
||||
|
||||
END IF;
|
||||
|
||||
END;
|
||||
|
||||
$INNER$;
|
||||
|
||||
$FUNC$;
|
||||
|
||||
RAISE NOTICE 'Created function create_project_comments_table_if_not_exists()';
|
||||
|
||||
END;
|
||||
|
||||
$$;
|
||||
19
backend/src/utils/supabase.ts
Normal file
19
backend/src/utils/supabase.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import config from '../config';
|
||||
|
||||
// Validate Supabase URL
|
||||
const validateSupabaseUrl = (url: string): string => {
|
||||
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 = createClient(
|
||||
validateSupabaseUrl(config.supabase.url),
|
||||
config.supabase.key || 'dummy-key'
|
||||
);
|
||||
|
||||
export default supabase;
|
||||
26
backend/start-server.sh
Executable file
26
backend/start-server.sh
Executable file
@@ -0,0 +1,26 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 获取配置的端口,默认为4000
|
||||
PORT=$(grep "PORT=" .env | cut -d= -f2 || echo "4000")
|
||||
echo "Service configured to use port $PORT"
|
||||
|
||||
# 查找并停止使用该端口的进程
|
||||
PID=$(lsof -ti :$PORT)
|
||||
if [ ! -z "$PID" ]; then
|
||||
echo "Stopping process $PID using port $PORT..."
|
||||
kill -9 $PID
|
||||
sleep 1
|
||||
fi
|
||||
|
||||
# 停止可能正在运行的服务器
|
||||
echo "Stopping any running server..."
|
||||
pkill -f "node dist/index.js" || true
|
||||
pkill -f "tsx watch src/index.ts" || true
|
||||
|
||||
# 构建项目
|
||||
echo "Building project..."
|
||||
npm run build
|
||||
|
||||
# 启动服务器
|
||||
echo "Starting server..."
|
||||
npm start
|
||||
19
backend/tsconfig.json
Normal file
19
backend/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user