Ethanfly преди 13 часа
родител
ревизия
7004e3d395

+ 7 - 0
client/src/components.d.ts

@@ -16,10 +16,14 @@ declare module 'vue' {
     ElButton: typeof import('element-plus/es')['ElButton']
     ElButtonGroup: typeof import('element-plus/es')['ElButtonGroup']
     ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
+    ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
     ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
     ElContainer: typeof import('element-plus/es')['ElContainer']
     ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
+    ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
+    ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
     ElDialog: typeof import('element-plus/es')['ElDialog']
+    ElDivider: typeof import('element-plus/es')['ElDivider']
     ElDrawer: typeof import('element-plus/es')['ElDrawer']
     ElDropdown: typeof import('element-plus/es')['ElDropdown']
     ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
@@ -35,6 +39,7 @@ declare module 'vue' {
     ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
     ElOption: typeof import('element-plus/es')['ElOption']
     ElPagination: typeof import('element-plus/es')['ElPagination']
+    ElProgress: typeof import('element-plus/es')['ElProgress']
     ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
     ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
     ElSelect: typeof import('element-plus/es')['ElSelect']
@@ -45,6 +50,8 @@ declare module 'vue' {
     ElTabPane: typeof import('element-plus/es')['ElTabPane']
     ElTabs: typeof import('element-plus/es')['ElTabs']
     ElTag: typeof import('element-plus/es')['ElTag']
+    ElText: typeof import('element-plus/es')['ElText']
+    ElUpload: typeof import('element-plus/es')['ElUpload']
     Icons: typeof import('./components/icons/index.vue')['default']
     RouterLink: typeof import('vue-router')['RouterLink']
     RouterView: typeof import('vue-router')['RouterView']

+ 9 - 0
server/env.example

@@ -91,6 +91,15 @@ MAX_IMAGE_SIZE=10
 ENCRYPTION_KEY=your-encryption-key-32-chars-long!
 
 # ----------------------------------------
+# 内部 API 配置 (Python 服务调用 Node.js API)
+# ----------------------------------------
+# 内部 API 密钥 (用于 Python 服务调用 Node.js 内部接口)
+INTERNAL_API_KEY=internal-api-key-default
+
+# Node.js API 地址 (供 Python 服务使用)
+NODEJS_API_URL=http://localhost:3000
+
+# ----------------------------------------
 # AI 配置 - 阿里云百炼千问大模型 (可选,用于智能功能)
 # ----------------------------------------
 # 阿里云百炼 API Key (以 sk- 开头)

+ 69 - 342
server/python/app.py

@@ -17,12 +17,10 @@ import os
 import sys
 import argparse
 import traceback
+import requests
 from datetime import datetime, date
 from pathlib import Path
 
-import pymysql
-from pymysql.cursors import DictCursor
-
 # 确保当前目录在 Python 路径中
 CURRENT_DIR = Path(__file__).parent.resolve()
 if str(CURRENT_DIR) not in sys.path:
@@ -116,29 +114,32 @@ HEADLESS_MODE = os.environ.get('HEADLESS', 'true').lower() == 'true'
 print(f"[Config] HEADLESS env value: '{os.environ.get('HEADLESS', 'NOT SET')}'", flush=True)
 print(f"[Config] HEADLESS_MODE: {HEADLESS_MODE}", flush=True)
 
-# 数据库配置
-DB_CONFIG = {
-    'host': os.environ.get('DB_HOST', 'localhost'),
-    'port': int(os.environ.get('DB_PORT', 3306)),
-    'user': os.environ.get('DB_USERNAME', 'root'),
-    'password': os.environ.get('DB_PASSWORD', ''),
-    'database': os.environ.get('DB_DATABASE', 'media_manager'),
-    'charset': 'utf8mb4',
-    'cursorclass': DictCursor
-}
-print(f"[DB_CONFIG] host={DB_CONFIG['host']}, port={DB_CONFIG['port']}, user={DB_CONFIG['user']}, db={DB_CONFIG['database']}, pwd_len={len(DB_CONFIG['password'])}", flush=True)
-
-
-def get_db_connection():
-    """获取数据库连接"""
-    print(f"[DEBUG DB] 正在连接数据库...", flush=True)
-    print(f"[DEBUG DB] host={DB_CONFIG['host']}, port={DB_CONFIG['port']}, user={DB_CONFIG['user']}, db={DB_CONFIG['database']}", flush=True)
+# Node.js API 配置
+NODEJS_API_BASE_URL = os.environ.get('NODEJS_API_URL', 'http://localhost:3000')
+INTERNAL_API_KEY = os.environ.get('INTERNAL_API_KEY', 'internal-api-key-default')
+print(f"[API Config] Node.js API: {NODEJS_API_BASE_URL}", flush=True)
+
+
+def call_nodejs_api(method: str, endpoint: str, data: dict = None, params: dict = None) -> dict:
+    """调用 Node.js 内部 API"""
+    url = f"{NODEJS_API_BASE_URL}/api/internal{endpoint}"
+    headers = {
+        'Content-Type': 'application/json',
+        'X-Internal-API-Key': INTERNAL_API_KEY,
+    }
+    
     try:
-        conn = pymysql.connect(**DB_CONFIG)
-        print(f"[DEBUG DB] 数据库连接成功!", flush=True)
-        return conn
-    except Exception as e:
-        print(f"[DEBUG DB] 数据库连接失败: {e}", flush=True)
+        if method.upper() == 'GET':
+            response = requests.get(url, headers=headers, params=params, timeout=30)
+        elif method.upper() == 'POST':
+            response = requests.post(url, headers=headers, json=data, timeout=30)
+        else:
+            raise ValueError(f"Unsupported HTTP method: {method}")
+        
+        response.raise_for_status()
+        return response.json()
+    except requests.exceptions.RequestException as e:
+        print(f"[API Error] 调用 Node.js API 失败: {e}", flush=True)
         raise
 
 # ==================== 签名相关(小红书专用) ====================
@@ -643,70 +644,16 @@ def save_work_day_statistics():
         if not statistics_list:
             return jsonify({"success": False, "error": "缺少 statistics 参数"}), 400
         
-        today = date.today()
-        inserted_count = 0
-        updated_count = 0
-        
         print(f"[WorkDayStatistics] 收到请求: {len(statistics_list)} 条统计数据")
         
-        conn = get_db_connection()
-        try:
-            with conn.cursor() as cursor:
-                for stat in statistics_list:
-                    work_id = stat.get("work_id")
-                    if not work_id:
-                        continue
-                    
-                    fans_count = stat.get("fans_count", 0)
-                    play_count = stat.get("play_count", 0)
-                    like_count = stat.get("like_count", 0)
-                    comment_count = stat.get("comment_count", 0)
-                    share_count = stat.get("share_count", 0)
-                    collect_count = stat.get("collect_count", 0)
-                    
-                    # 检查当天是否已有记录
-                    cursor.execute(
-                        "SELECT id FROM work_day_statistics WHERE work_id = %s AND record_date = %s",
-                        (work_id, today)
-                    )
-                    existing = cursor.fetchone()
-                    
-                    if existing:
-                        # 更新已有记录
-                        cursor.execute(
-                            """UPDATE work_day_statistics 
-                               SET fans_count = %s, play_count = %s, like_count = %s, 
-                                   comment_count = %s, share_count = %s, collect_count = %s,
-                                   updated_at = NOW()
-                               WHERE id = %s""",
-                            (fans_count, play_count, like_count, comment_count, 
-                             share_count, collect_count, existing['id'])
-                        )
-                        updated_count += 1
-                    else:
-                        # 插入新记录
-                        cursor.execute(
-                            """INSERT INTO work_day_statistics 
-                               (work_id, record_date, fans_count, play_count, like_count, 
-                                comment_count, share_count, collect_count, created_at, updated_at)
-                               VALUES (%s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW())""",
-                            (work_id, today, fans_count, play_count, like_count, 
-                             comment_count, share_count, collect_count)
-                        )
-                        inserted_count += 1
-                
-                conn.commit()
-        finally:
-            conn.close()
+        # 调用 Node.js API 保存数据
+        result = call_nodejs_api('POST', '/work-day-statistics', {
+            'statistics': statistics_list
+        })
         
-        print(f"[WorkDayStatistics] 完成: 新增 {inserted_count} 条, 更新 {updated_count} 条")
+        print(f"[WorkDayStatistics] 完成: 新增 {result.get('inserted', 0)} 条, 更新 {result.get('updated', 0)} 条")
         
-        return jsonify({
-            "success": True,
-            "inserted": inserted_count,
-            "updated": updated_count,
-            "message": f"保存成功: 新增 {inserted_count} 条, 更新 {updated_count} 条"
-        })
+        return jsonify(result)
         
     except Exception as e:
         traceback.print_exc()
@@ -749,114 +696,20 @@ def get_statistics_trend():
         if not user_id:
             return jsonify({"success": False, "error": "缺少 user_id 参数"}), 400
         
-        conn = get_db_connection()
-        try:
-            with conn.cursor() as cursor:
-                # 构建查询:关联 works 表获取用户的作品,然后汇总统计数据
-                # 注意:粉丝数是账号级别的数据,每个账号每天只取一个值(使用 MAX)
-                # 其他指标(播放、点赞等)是作品级别的数据,需要累加
-                sql = """
-                    SELECT 
-                        record_date,
-                        SUM(account_fans) as total_fans,
-                        SUM(account_views) as total_views,
-                        SUM(account_likes) as total_likes,
-                        SUM(account_comments) as total_comments,
-                        SUM(account_shares) as total_shares,
-                        SUM(account_collects) as total_collects
-                    FROM (
-                        SELECT 
-                            wds.record_date,
-                            w.accountId,
-                            MAX(wds.fans_count) as account_fans,
-                            SUM(wds.play_count) as account_views,
-                            SUM(wds.like_count) as account_likes,
-                            SUM(wds.comment_count) as account_comments,
-                            SUM(wds.share_count) as account_shares,
-                            SUM(wds.collect_count) as account_collects
-                        FROM work_day_statistics wds
-                        INNER JOIN works w ON wds.work_id = w.id
-                        WHERE w.userId = %s
-                """
-                params = [user_id]
-                
-                # 支持两种日期筛选方式:start_date/end_date 或 days
-                if start_date and end_date:
-                    sql += " AND wds.record_date >= %s AND wds.record_date <= %s"
-                    params.extend([start_date, end_date])
-                else:
-                    # 默认使用 days 参数
-                    days_value = min(int(days or 7), 30)
-                    sql += " AND wds.record_date >= DATE_SUB(CURDATE(), INTERVAL %s DAY)"
-                    params.append(days_value)
-                
-                if account_id:
-                    sql += " AND w.accountId = %s"
-                    params.append(account_id)
-                
-                sql += """
-                        GROUP BY wds.record_date, w.accountId
-                    ) as account_stats
-                    GROUP BY record_date
-                    ORDER BY record_date ASC
-                """
-                
-                cursor.execute(sql, params)
-                results = cursor.fetchall()
-                
-                # 构建响应数据
-                dates = []
-                fans = []
-                views = []
-                likes = []
-                comments = []
-                shares = []
-                collects = []
-                
-                for row in results:
-                    # 格式化日期为 "MM-DD" 格式
-                    record_date = row['record_date']
-                    if isinstance(record_date, str):
-                        dates.append(record_date[5:10])  # "2026-01-16" -> "01-16"
-                    else:
-                        dates.append(record_date.strftime("%m-%d"))
-                    
-                    # 确保返回整数类型
-                    fans.append(int(row['total_fans'] or 0))
-                    views.append(int(row['total_views'] or 0))
-                    likes.append(int(row['total_likes'] or 0))
-                    comments.append(int(row['total_comments'] or 0))
-                    shares.append(int(row['total_shares'] or 0))
-                    collects.append(int(row['total_collects'] or 0))
-                
-                # 如果没有数据,生成空的日期范围
-                if not dates:
-                    from datetime import timedelta
-                    today = date.today()
-                    for i in range(days, 0, -1):
-                        d = today - timedelta(days=i-1)
-                        dates.append(d.strftime("%m-%d"))
-                        fans.append(0)
-                        views.append(0)
-                        likes.append(0)
-                        comments.append(0)
-                        shares.append(0)
-                        collects.append(0)
-                
-                return jsonify({
-                    "success": True,
-                    "data": {
-                        "dates": dates,
-                        "fans": fans,
-                        "views": views,
-                        "likes": likes,
-                        "comments": comments,
-                        "shares": shares,
-                        "collects": collects
-                    }
-                })
-        finally:
-            conn.close()
+        # 调用 Node.js API 获取数据
+        params = {"user_id": user_id}
+        if days:
+            params["days"] = days
+        if start_date:
+            params["start_date"] = start_date
+        if end_date:
+            params["end_date"] = end_date
+        if account_id:
+            params["account_id"] = account_id
+        
+        result = call_nodejs_api('GET', '/work-day-statistics/trend', params=params)
+        
+        return jsonify(result)
             
     except Exception as e:
         traceback.print_exc()
@@ -905,111 +758,20 @@ def get_statistics_by_platform():
         if not user_id:
             return jsonify({"success": False, "error": "缺少 user_id 参数"}), 400
         
-        conn = get_db_connection()
-        try:
-            with conn.cursor() as cursor:
-                # 简化查询:按平台分组获取统计数据
-                # 1. 从 platform_accounts 获取当前粉丝数(注意:字段名是下划线命名 fans_count, user_id)
-                # 2. 从 work_day_statistics 获取播放量等累计数据
-                
-                # 根据日期参数构建日期条件
-                if start_date and end_date:
-                    date_condition = "wds.record_date >= %s AND wds.record_date <= %s"
-                    date_params = [start_date, end_date]
-                else:
-                    days_value = min(int(days or 30), 30)
-                    date_condition = "wds.record_date >= DATE_SUB(CURDATE(), INTERVAL %s DAY)"
-                    date_params = [days_value]
-                
-                # 注意:数据库存储的是累积值,所以区间增量 = 最后一天 - 第一天
-                sql = f"""
-                    SELECT 
-                        pa.platform,
-                        pa.fans_count as current_fans,
-                        COALESCE(last_day.views, 0) - COALESCE(first_day.views, 0) as viewsIncrease,
-                        COALESCE(last_day.likes, 0) - COALESCE(first_day.likes, 0) as likesIncrease,
-                        COALESCE(last_day.comments, 0) - COALESCE(first_day.comments, 0) as commentsIncrease,
-                        COALESCE(last_day.collects, 0) - COALESCE(first_day.collects, 0) as collectsIncrease,
-                        COALESCE(first_day.fans, pa.fans_count) as earliest_fans
-                    FROM platform_accounts pa
-                    LEFT JOIN (
-                        -- 获取每个账号在区间内第一天的累积值
-                        SELECT 
-                            accountId, fans, views, likes, comments, collects
-                        FROM (
-                            SELECT 
-                                w.accountId,
-                                MAX(wds.fans_count) as fans,
-                                SUM(wds.play_count) as views,
-                                SUM(wds.like_count) as likes,
-                                SUM(wds.comment_count) as comments,
-                                SUM(wds.collect_count) as collects,
-                                wds.record_date,
-                                ROW_NUMBER() OVER (PARTITION BY w.accountId ORDER BY wds.record_date ASC) as rn
-                            FROM work_day_statistics wds
-                            INNER JOIN works w ON wds.work_id = w.id
-                            WHERE w.userId = %s
-                              AND {date_condition}
-                            GROUP BY w.accountId, wds.record_date
-                        ) ranked
-                        WHERE rn = 1
-                    ) first_day ON pa.id = first_day.accountId
-                    LEFT JOIN (
-                        -- 获取每个账号在区间内最后一天的累积值
-                        SELECT 
-                            accountId, fans, views, likes, comments, collects
-                        FROM (
-                            SELECT 
-                                w.accountId,
-                                MAX(wds.fans_count) as fans,
-                                SUM(wds.play_count) as views,
-                                SUM(wds.like_count) as likes,
-                                SUM(wds.comment_count) as comments,
-                                SUM(wds.collect_count) as collects,
-                                wds.record_date,
-                                ROW_NUMBER() OVER (PARTITION BY w.accountId ORDER BY wds.record_date DESC) as rn
-                            FROM work_day_statistics wds
-                            INNER JOIN works w ON wds.work_id = w.id
-                            WHERE w.userId = %s
-                              AND {date_condition}
-                            GROUP BY w.accountId, wds.record_date
-                        ) ranked
-                        WHERE rn = 1
-                    ) last_day ON pa.id = last_day.accountId
-                    WHERE pa.user_id = %s
-                    ORDER BY current_fans DESC
-                """
-                
-                # 构建参数列表:first_day子查询(user_id + date_params) + last_day子查询(user_id + date_params) + 主查询(user_id)
-                params = [user_id] + date_params + [user_id] + date_params + [user_id]
-                cursor.execute(sql, params)
-                results = cursor.fetchall()
-                
-                # 构建响应数据
-                platform_data = []
-                for row in results:
-                    current_fans = int(row['current_fans'] or 0)
-                    earliest_fans = int(row['earliest_fans'] or current_fans)
-                    fans_increase = current_fans - earliest_fans
-                    
-                    platform_data.append({
-                        "platform": row['platform'],
-                        "fansCount": current_fans,
-                        "fansIncrease": fans_increase,
-                        "viewsCount": int(row['viewsIncrease'] or 0),  # 区间增量
-                        "likesCount": int(row['likesIncrease'] or 0),  # 区间增量
-                        "commentsCount": int(row['commentsIncrease'] or 0),  # 区间增量
-                        "collectsCount": int(row['collectsIncrease'] or 0),  # 区间增量
-                    })
-                
-                print(f"[PlatformStats] 返回 {len(platform_data)} 个平台的数据")
-                
-                return jsonify({
-                    "success": True,
-                    "data": platform_data
-                })
-        finally:
-            conn.close()
+        # 调用 Node.js API 获取数据
+        params = {"user_id": user_id}
+        if days:
+            params["days"] = days
+        if start_date:
+            params["start_date"] = start_date
+        if end_date:
+            params["end_date"] = end_date
+        
+        result = call_nodejs_api('GET', '/work-day-statistics/platforms', params=params)
+        
+        print(f"[PlatformStats] 返回 {len(result.get('data', []))} 个平台的数据")
+        
+        return jsonify(result)
             
     except Exception as e:
         traceback.print_exc()
@@ -1049,51 +811,16 @@ def get_work_statistics_history():
         if not work_ids:
             return jsonify({"success": False, "error": "缺少 work_ids 参数"}), 400
         
-        conn = get_db_connection()
-        try:
-            with conn.cursor() as cursor:
-                # 构建查询
-                placeholders = ', '.join(['%s'] * len(work_ids))
-                sql = f"""SELECT work_id, record_date, fans_count, play_count, like_count, 
-                                 comment_count, share_count, collect_count
-                          FROM work_day_statistics 
-                          WHERE work_id IN ({placeholders})"""
-                params = list(work_ids)
-                
-                if start_date:
-                    sql += " AND record_date >= %s"
-                    params.append(start_date)
-                if end_date:
-                    sql += " AND record_date <= %s"
-                    params.append(end_date)
-                
-                sql += " ORDER BY work_id, record_date"
-                
-                cursor.execute(sql, params)
-                results = cursor.fetchall()
-        finally:
-            conn.close()
-        
-        # 按 work_id 分组
-        grouped_data = {}
-        for row in results:
-            work_id = str(row['work_id'])
-            if work_id not in grouped_data:
-                grouped_data[work_id] = []
-            grouped_data[work_id].append({
-                'record_date': row['record_date'].strftime('%Y-%m-%d') if row['record_date'] else None,
-                'fans_count': row['fans_count'],
-                'play_count': row['play_count'],
-                'like_count': row['like_count'],
-                'comment_count': row['comment_count'],
-                'share_count': row['share_count'],
-                'collect_count': row['collect_count']
-            })
+        # 调用 Node.js API 获取数据
+        request_data = {"work_ids": work_ids}
+        if start_date:
+            request_data["start_date"] = start_date
+        if end_date:
+            request_data["end_date"] = end_date
         
-        return jsonify({
-            "success": True,
-            "data": grouped_data
-        })
+        result = call_nodejs_api('POST', '/work-day-statistics/batch', data=request_data)
+        
+        return jsonify(result)
         
     except Exception as e:
         traceback.print_exc()

BIN
server/python/platforms/__pycache__/base.cpython-311.pyc


BIN
server/python/platforms/__pycache__/douyin.cpython-311.pyc


BIN
server/python/platforms/__pycache__/xiaohongshu.cpython-311.pyc


+ 1 - 4
server/python/requirements.txt

@@ -17,8 +17,5 @@ Pillow>=9.0.0
 # 二维码生成(登录用)
 qrcode>=7.0
 
-# HTTP 请求
+# HTTP 请求(用于调用 Node.js API)
 requests>=2.28.0
-
-# MySQL 数据库连接
-pymysql>=1.0.0

+ 3 - 0
server/src/config/index.ts

@@ -54,6 +54,9 @@ export const config = {
     key: process.env.ENCRYPTION_KEY || 'your-encryption-key-32-chars-long!',
   },
 
+  // 内部 API 密钥(供 Python 服务调用)
+  internalApiKey: process.env.INTERNAL_API_KEY || 'internal-api-key-default',
+
   // AI 配置 - 阿里云百炼千问大模型
   ai: {
     apiKey: process.env.DASHSCOPE_API_KEY || process.env.OPENAI_API_KEY || '',

+ 2 - 0
server/src/routes/index.ts

@@ -10,6 +10,7 @@ import systemRoutes from './system.js';
 import aiRoutes from './ai.js';
 import worksRoutes from './works.js';
 import tasksRoutes from './tasks.js';
+import internalRoutes from './internal.js';
 import { authenticate } from '../middleware/auth.js';
 
 export function setupRoutes(app: Express): void {
@@ -25,4 +26,5 @@ export function setupRoutes(app: Express): void {
   app.use('/api/system', systemRoutes);
   app.use('/api/ai', aiRoutes);
   app.use('/api/tasks', authenticate, tasksRoutes);
+  app.use('/api/internal', internalRoutes); // 内部 API(供 Python 服务调用)
 }

+ 156 - 0
server/src/routes/internal.ts

@@ -0,0 +1,156 @@
+/**
+ * 内部 API 路由
+ * 供 Python 服务调用,不需要用户认证
+ * 但需要内部 API 密钥验证
+ */
+import { Router, Request, Response, NextFunction } from 'express';
+import { body, query } from 'express-validator';
+import { WorkDayStatisticsService } from '../services/WorkDayStatisticsService.js';
+import { asyncHandler } from '../middleware/error.js';
+import { validateRequest } from '../middleware/validate.js';
+import { config } from '../config/index.js';
+
+const router = Router();
+const workDayStatisticsService = new WorkDayStatisticsService();
+
+// 内部 API 密钥验证中间件
+const validateInternalApiKey = (req: Request, res: Response, next: NextFunction) => {
+  const apiKey = req.headers['x-internal-api-key'] as string;
+  const expectedKey = config.internalApiKey || 'internal-api-key-default';
+  
+  if (!apiKey || apiKey !== expectedKey) {
+    return res.status(401).json({
+      success: false,
+      error: 'Invalid internal API key',
+    });
+  }
+  
+  next();
+};
+
+// 应用内部 API 密钥验证
+router.use(validateInternalApiKey);
+
+/**
+ * POST /api/internal/work-day-statistics
+ * 保存作品日统计数据
+ */
+router.post(
+  '/work-day-statistics',
+  [
+    body('statistics').isArray().withMessage('statistics 必须是数组'),
+    body('statistics.*.workId').optional().isNumeric().withMessage('workId 必须是数字'),
+    body('statistics.*.work_id').optional().isNumeric().withMessage('work_id 必须是数字'),
+    validateRequest,
+  ],
+  asyncHandler(async (req, res) => {
+    // 兼容 Python 传过来的下划线命名
+    const statistics = req.body.statistics.map((item: any) => ({
+      workId: item.workId ?? item.work_id,
+      fansCount: item.fansCount ?? item.fans_count ?? 0,
+      playCount: item.playCount ?? item.play_count ?? 0,
+      likeCount: item.likeCount ?? item.like_count ?? 0,
+      commentCount: item.commentCount ?? item.comment_count ?? 0,
+      shareCount: item.shareCount ?? item.share_count ?? 0,
+      collectCount: item.collectCount ?? item.collect_count ?? 0,
+    }));
+
+    const result = await workDayStatisticsService.saveStatistics(statistics);
+
+    res.json({
+      success: true,
+      inserted: result.inserted,
+      updated: result.updated,
+      message: `保存成功: 新增 ${result.inserted} 条, 更新 ${result.updated} 条`,
+    });
+  })
+);
+
+/**
+ * GET /api/internal/work-day-statistics/trend
+ * 获取数据趋势
+ */
+router.get(
+  '/work-day-statistics/trend',
+  [
+    query('user_id').notEmpty().withMessage('user_id 不能为空'),
+    validateRequest,
+  ],
+  asyncHandler(async (req, res) => {
+    const userId = parseInt(req.query.user_id as string);
+    const days = req.query.days ? parseInt(req.query.days as string) : undefined;
+    const startDate = req.query.start_date as string | undefined;
+    const endDate = req.query.end_date as string | undefined;
+    const accountId = req.query.account_id ? parseInt(req.query.account_id as string) : undefined;
+
+    const data = await workDayStatisticsService.getTrend(userId, {
+      days,
+      startDate,
+      endDate,
+      accountId,
+    });
+
+    res.json({
+      success: true,
+      data,
+    });
+  })
+);
+
+/**
+ * GET /api/internal/work-day-statistics/platforms
+ * 按平台分组获取统计数据
+ */
+router.get(
+  '/work-day-statistics/platforms',
+  [
+    query('user_id').notEmpty().withMessage('user_id 不能为空'),
+    validateRequest,
+  ],
+  asyncHandler(async (req, res) => {
+    const userId = parseInt(req.query.user_id as string);
+    const days = req.query.days ? parseInt(req.query.days as string) : undefined;
+    const startDate = req.query.start_date as string | undefined;
+    const endDate = req.query.end_date as string | undefined;
+
+    const data = await workDayStatisticsService.getStatisticsByPlatform(userId, {
+      days,
+      startDate,
+      endDate,
+    });
+
+    res.json({
+      success: true,
+      data,
+    });
+  })
+);
+
+/**
+ * POST /api/internal/work-day-statistics/batch
+ * 批量获取作品的历史统计数据
+ */
+router.post(
+  '/work-day-statistics/batch',
+  [
+    body('work_ids').isArray().withMessage('work_ids 必须是数组'),
+    validateRequest,
+  ],
+  asyncHandler(async (req, res) => {
+    const workIds = req.body.work_ids;
+    const startDate = req.body.start_date as string | undefined;
+    const endDate = req.body.end_date as string | undefined;
+
+    const data = await workDayStatisticsService.getWorkStatisticsHistory(workIds, {
+      startDate,
+      endDate,
+    });
+
+    res.json({
+      success: true,
+      data,
+    });
+  })
+);
+
+export default router;

+ 391 - 0
server/src/services/WorkDayStatisticsService.ts

@@ -0,0 +1,391 @@
+import { AppDataSource, WorkDayStatistics, Work, PlatformAccount } from '../models/index.js';
+import { Between, In } from 'typeorm';
+
+interface StatisticsItem {
+  workId: number;
+  fansCount?: number;
+  playCount?: number;
+  likeCount?: number;
+  commentCount?: number;
+  shareCount?: number;
+  collectCount?: number;
+}
+
+interface SaveResult {
+  inserted: number;
+  updated: number;
+}
+
+interface TrendData {
+  dates: string[];
+  fans: number[];
+  views: number[];
+  likes: number[];
+  comments: number[];
+  shares: number[];
+  collects: number[];
+}
+
+interface PlatformStatItem {
+  platform: string;
+  fansCount: number;
+  fansIncrease: number;
+  viewsCount: number;
+  likesCount: number;
+  commentsCount: number;
+  collectsCount: number;
+}
+
+interface WorkStatisticsItem {
+  recordDate: string;
+  fansCount: number;
+  playCount: number;
+  likeCount: number;
+  commentCount: number;
+  shareCount: number;
+  collectCount: number;
+}
+
+export class WorkDayStatisticsService {
+  private statisticsRepository = AppDataSource.getRepository(WorkDayStatistics);
+  private workRepository = AppDataSource.getRepository(Work);
+  private accountRepository = AppDataSource.getRepository(PlatformAccount);
+
+  /**
+   * 保存作品日统计数据
+   * 当天的数据走更新流,日期变化走新增流
+   */
+  async saveStatistics(statistics: StatisticsItem[]): Promise<SaveResult> {
+    const today = new Date();
+    today.setHours(0, 0, 0, 0);
+
+    let insertedCount = 0;
+    let updatedCount = 0;
+
+    for (const stat of statistics) {
+      if (!stat.workId) continue;
+
+      // 检查当天是否已有记录
+      const existing = await this.statisticsRepository.findOne({
+        where: {
+          workId: stat.workId,
+          recordDate: today,
+        },
+      });
+
+      if (existing) {
+        // 更新已有记录
+        await this.statisticsRepository.update(existing.id, {
+          fansCount: stat.fansCount ?? existing.fansCount,
+          playCount: stat.playCount ?? existing.playCount,
+          likeCount: stat.likeCount ?? existing.likeCount,
+          commentCount: stat.commentCount ?? existing.commentCount,
+          shareCount: stat.shareCount ?? existing.shareCount,
+          collectCount: stat.collectCount ?? existing.collectCount,
+        });
+        updatedCount++;
+      } else {
+        // 插入新记录
+        const newStat = this.statisticsRepository.create({
+          workId: stat.workId,
+          recordDate: today,
+          fansCount: stat.fansCount ?? 0,
+          playCount: stat.playCount ?? 0,
+          likeCount: stat.likeCount ?? 0,
+          commentCount: stat.commentCount ?? 0,
+          shareCount: stat.shareCount ?? 0,
+          collectCount: stat.collectCount ?? 0,
+        });
+        await this.statisticsRepository.save(newStat);
+        insertedCount++;
+      }
+    }
+
+    return { inserted: insertedCount, updated: updatedCount };
+  }
+
+  /**
+   * 获取数据趋势
+   */
+  async getTrend(
+    userId: number,
+    options: {
+      days?: number;
+      startDate?: string;
+      endDate?: string;
+      accountId?: number;
+    }
+  ): Promise<TrendData> {
+    const { days = 7, startDate, endDate, accountId } = options;
+
+    // 计算日期范围
+    let dateStart: Date;
+    let dateEnd: Date;
+
+    if (startDate && endDate) {
+      dateStart = new Date(startDate);
+      dateEnd = new Date(endDate);
+    } else {
+      dateEnd = new Date();
+      dateStart = new Date();
+      dateStart.setDate(dateStart.getDate() - Math.min(days, 30) + 1);
+    }
+
+    // 构建查询
+    const queryBuilder = this.statisticsRepository
+      .createQueryBuilder('wds')
+      .innerJoin(Work, 'w', 'wds.work_id = w.id')
+      .select('wds.record_date', 'recordDate')
+      .addSelect('w.accountId', 'accountId')
+      .addSelect('MAX(wds.fans_count)', 'accountFans')
+      .addSelect('SUM(wds.play_count)', 'accountViews')
+      .addSelect('SUM(wds.like_count)', 'accountLikes')
+      .addSelect('SUM(wds.comment_count)', 'accountComments')
+      .addSelect('SUM(wds.share_count)', 'accountShares')
+      .addSelect('SUM(wds.collect_count)', 'accountCollects')
+      .where('w.userId = :userId', { userId })
+      .andWhere('wds.record_date >= :dateStart', { dateStart })
+      .andWhere('wds.record_date <= :dateEnd', { dateEnd })
+      .groupBy('wds.record_date')
+      .addGroupBy('w.accountId')
+      .orderBy('wds.record_date', 'ASC');
+
+    if (accountId) {
+      queryBuilder.andWhere('w.accountId = :accountId', { accountId });
+    }
+
+    const accountResults = await queryBuilder.getRawMany();
+
+    // 按日期汇总所有账号的数据
+    const dateMap = new Map<string, {
+      fans: number;
+      views: number;
+      likes: number;
+      comments: number;
+      shares: number;
+      collects: number;
+    }>();
+
+    for (const row of accountResults) {
+      const dateKey = row.recordDate instanceof Date 
+        ? row.recordDate.toISOString().split('T')[0]
+        : String(row.recordDate).split('T')[0];
+      
+      if (!dateMap.has(dateKey)) {
+        dateMap.set(dateKey, {
+          fans: 0,
+          views: 0,
+          likes: 0,
+          comments: 0,
+          shares: 0,
+          collects: 0,
+        });
+      }
+
+      const current = dateMap.get(dateKey)!;
+      current.fans += parseInt(row.accountFans) || 0;
+      current.views += parseInt(row.accountViews) || 0;
+      current.likes += parseInt(row.accountLikes) || 0;
+      current.comments += parseInt(row.accountComments) || 0;
+      current.shares += parseInt(row.accountShares) || 0;
+      current.collects += parseInt(row.accountCollects) || 0;
+    }
+
+    // 构建响应数据
+    const dates: string[] = [];
+    const fans: number[] = [];
+    const views: number[] = [];
+    const likes: number[] = [];
+    const comments: number[] = [];
+    const shares: number[] = [];
+    const collects: number[] = [];
+
+    // 按日期排序
+    const sortedDates = Array.from(dateMap.keys()).sort();
+    for (const dateKey of sortedDates) {
+      dates.push(dateKey.slice(5)); // "YYYY-MM-DD" -> "MM-DD"
+      const data = dateMap.get(dateKey)!;
+      fans.push(data.fans);
+      views.push(data.views);
+      likes.push(data.likes);
+      comments.push(data.comments);
+      shares.push(data.shares);
+      collects.push(data.collects);
+    }
+
+    // 如果没有数据,生成空的日期范围
+    if (dates.length === 0) {
+      const d = new Date(dateStart);
+      while (d <= dateEnd) {
+        dates.push(`${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`);
+        fans.push(0);
+        views.push(0);
+        likes.push(0);
+        comments.push(0);
+        shares.push(0);
+        collects.push(0);
+        d.setDate(d.getDate() + 1);
+      }
+    }
+
+    return { dates, fans, views, likes, comments, shares, collects };
+  }
+
+  /**
+   * 按平台分组获取统计数据
+   */
+  async getStatisticsByPlatform(
+    userId: number,
+    options: {
+      days?: number;
+      startDate?: string;
+      endDate?: string;
+    }
+  ): Promise<PlatformStatItem[]> {
+    const { days = 30, startDate, endDate } = options;
+
+    // 计算日期范围
+    let dateStart: Date;
+    let dateEnd: Date;
+
+    if (startDate && endDate) {
+      dateStart = new Date(startDate);
+      dateEnd = new Date(endDate);
+    } else {
+      dateEnd = new Date();
+      dateStart = new Date();
+      dateStart.setDate(dateStart.getDate() - Math.min(days, 30) + 1);
+    }
+
+    // 获取用户的所有账号
+    const accounts = await this.accountRepository.find({
+      where: { userId },
+    });
+
+    const platformData: PlatformStatItem[] = [];
+
+    for (const account of accounts) {
+      // 获取该账号在区间内第一天和最后一天的数据
+      const firstDayQuery = this.statisticsRepository
+        .createQueryBuilder('wds')
+        .innerJoin(Work, 'w', 'wds.work_id = w.id')
+        .select('MAX(wds.fans_count)', 'fans')
+        .addSelect('SUM(wds.play_count)', 'views')
+        .addSelect('SUM(wds.like_count)', 'likes')
+        .addSelect('SUM(wds.comment_count)', 'comments')
+        .addSelect('SUM(wds.collect_count)', 'collects')
+        .where('w.accountId = :accountId', { accountId: account.id })
+        .andWhere('wds.record_date = (SELECT MIN(record_date) FROM work_day_statistics wds2 INNER JOIN works w2 ON wds2.work_id = w2.id WHERE w2.accountId = :accountId2 AND wds2.record_date >= :dateStart AND wds2.record_date <= :dateEnd)', {
+          accountId2: account.id,
+          dateStart,
+          dateEnd,
+        });
+
+      const lastDayQuery = this.statisticsRepository
+        .createQueryBuilder('wds')
+        .innerJoin(Work, 'w', 'wds.work_id = w.id')
+        .select('MAX(wds.fans_count)', 'fans')
+        .addSelect('SUM(wds.play_count)', 'views')
+        .addSelect('SUM(wds.like_count)', 'likes')
+        .addSelect('SUM(wds.comment_count)', 'comments')
+        .addSelect('SUM(wds.collect_count)', 'collects')
+        .where('w.accountId = :accountId', { accountId: account.id })
+        .andWhere('wds.record_date = (SELECT MAX(record_date) FROM work_day_statistics wds2 INNER JOIN works w2 ON wds2.work_id = w2.id WHERE w2.accountId = :accountId2 AND wds2.record_date >= :dateStart AND wds2.record_date <= :dateEnd)', {
+          accountId2: account.id,
+          dateStart,
+          dateEnd,
+        });
+
+      const [firstDay, lastDay] = await Promise.all([
+        firstDayQuery.getRawOne(),
+        lastDayQuery.getRawOne(),
+      ]);
+
+      const currentFans = account.fansCount ?? 0;
+      const earliestFans = parseInt(firstDay?.fans) || currentFans;
+      const fansIncrease = currentFans - earliestFans;
+
+      const viewsIncrease = (parseInt(lastDay?.views) || 0) - (parseInt(firstDay?.views) || 0);
+      const likesIncrease = (parseInt(lastDay?.likes) || 0) - (parseInt(firstDay?.likes) || 0);
+      const commentsIncrease = (parseInt(lastDay?.comments) || 0) - (parseInt(firstDay?.comments) || 0);
+      const collectsIncrease = (parseInt(lastDay?.collects) || 0) - (parseInt(firstDay?.collects) || 0);
+
+      platformData.push({
+        platform: account.platform,
+        fansCount: currentFans,
+        fansIncrease,
+        viewsCount: Math.max(0, viewsIncrease),
+        likesCount: Math.max(0, likesIncrease),
+        commentsCount: Math.max(0, commentsIncrease),
+        collectsCount: Math.max(0, collectsIncrease),
+      });
+    }
+
+    // 按粉丝数降序排序
+    platformData.sort((a, b) => b.fansCount - a.fansCount);
+
+    return platformData;
+  }
+
+  /**
+   * 批量获取作品的历史统计数据
+   */
+  async getWorkStatisticsHistory(
+    workIds: number[],
+    options: {
+      startDate?: string;
+      endDate?: string;
+    }
+  ): Promise<Record<string, WorkStatisticsItem[]>> {
+    const { startDate, endDate } = options;
+
+    const queryBuilder = this.statisticsRepository
+      .createQueryBuilder('wds')
+      .select('wds.work_id', 'workId')
+      .addSelect('wds.record_date', 'recordDate')
+      .addSelect('wds.fans_count', 'fansCount')
+      .addSelect('wds.play_count', 'playCount')
+      .addSelect('wds.like_count', 'likeCount')
+      .addSelect('wds.comment_count', 'commentCount')
+      .addSelect('wds.share_count', 'shareCount')
+      .addSelect('wds.collect_count', 'collectCount')
+      .where('wds.work_id IN (:...workIds)', { workIds })
+      .orderBy('wds.work_id', 'ASC')
+      .addOrderBy('wds.record_date', 'ASC');
+
+    if (startDate) {
+      queryBuilder.andWhere('wds.record_date >= :startDate', { startDate });
+    }
+    if (endDate) {
+      queryBuilder.andWhere('wds.record_date <= :endDate', { endDate });
+    }
+
+    const results = await queryBuilder.getRawMany();
+
+    // 按 workId 分组
+    const groupedData: Record<string, WorkStatisticsItem[]> = {};
+
+    for (const row of results) {
+      const workId = String(row.workId);
+      if (!groupedData[workId]) {
+        groupedData[workId] = [];
+      }
+
+      const recordDate = row.recordDate instanceof Date
+        ? row.recordDate.toISOString().split('T')[0]
+        : String(row.recordDate).split('T')[0];
+
+      groupedData[workId].push({
+        recordDate,
+        fansCount: parseInt(row.fansCount) || 0,
+        playCount: parseInt(row.playCount) || 0,
+        likeCount: parseInt(row.likeCount) || 0,
+        commentCount: parseInt(row.commentCount) || 0,
+        shareCount: parseInt(row.shareCount) || 0,
+        collectCount: parseInt(row.collectCount) || 0,
+      });
+    }
+
+    return groupedData;
+  }
+}

+ 13 - 25
server/src/services/WorkService.ts

@@ -5,8 +5,7 @@ import type { PlatformType, Work as WorkType, WorkStats, WorksQueryParams } from
 import { logger } from '../utils/logger.js';
 import { headlessBrowserService } from './HeadlessBrowserService.js';
 import { CookieManager } from '../automation/cookie.js';
-
-const PYTHON_SERVICE_URL = process.env.PYTHON_PUBLISH_SERVICE_URL || process.env.XHS_SERVICE_URL || 'http://localhost:5005';
+import { WorkDayStatisticsService } from './WorkDayStatisticsService.js';
 
 export class WorkService {
   private workRepository = AppDataSource.getRepository(Work);
@@ -278,36 +277,25 @@ export class WorkService {
 
     // 构建统计数据列表
     const statisticsList = works.map(work => ({
-      work_id: work.id,
-      fans_count: account.fansCount || 0,
-      play_count: work.playCount || 0,
-      like_count: work.likeCount || 0,
-      comment_count: work.commentCount || 0,
-      share_count: work.shareCount || 0,
-      collect_count: work.collectCount || 0,
+      workId: work.id,
+      fansCount: account.fansCount || 0,
+      playCount: work.playCount || 0,
+      likeCount: work.likeCount || 0,
+      commentCount: work.commentCount || 0,
+      shareCount: work.shareCount || 0,
+      collectCount: work.collectCount || 0,
     }));
 
     logger.info(`[SaveWorkDayStatistics] Saving ${statisticsList.length} work statistics for account ${account.id}`);
 
-    // 调用 Python API 保存统计数据
+    // 直接使用 WorkDayStatisticsService 保存统计数据
     try {
-      const response = await fetch(`${PYTHON_SERVICE_URL}/work_day_statistics`, {
-        method: 'POST',
-        headers: {
-          'Content-Type': 'application/json',
-        },
-        body: JSON.stringify({ statistics: statisticsList }),
-      });
-
-      const result = await response.json();
+      const workDayStatisticsService = new WorkDayStatisticsService();
+      const result = await workDayStatisticsService.saveStatistics(statisticsList);
 
-      if (result.success) {
-        logger.info(`[SaveWorkDayStatistics] Success: inserted=${result.inserted}, updated=${result.updated}`);
-      } else {
-        logger.error(`[SaveWorkDayStatistics] Failed: ${result.error}`);
-      }
+      logger.info(`[SaveWorkDayStatistics] Success: inserted=${result.inserted}, updated=${result.updated}`);
     } catch (error) {
-      logger.error(`[SaveWorkDayStatistics] API call failed:`, error);
+      logger.error(`[SaveWorkDayStatistics] Failed to save statistics:`, error);
       throw error;
     }
   }