瀏覽代碼

Merge branch 'main' of http://gitlab.pubdata.cn/hlm/multi-platform-media-manage

Ethanfly 12 小時之前
父節點
當前提交
9ed40e814f

+ 47 - 18
client/src/stores/auth.ts

@@ -4,11 +4,15 @@ import { authApi } from '@/api/auth';
 import { accountsApi } from '@/api/accounts';
 import type { User, LoginRequest } from '@media-manager/shared';
 import { useServerStore } from './server';
+import { useTaskQueueStore } from './taskQueue';
 
 export const useAuthStore = defineStore('auth', () => {
   const user = ref<User | null>(null);
   const accessToken = ref<string | null>(null);
   const refreshToken = ref<string | null>(null);
+  let autoAccountSyncTimer: ReturnType<typeof setInterval> | null = null;
+  let autoAccountSyncTimeout: ReturnType<typeof setTimeout> | null = null;
+  let autoAccountSyncRunning = false;
 
   const isAuthenticated = computed(() => !!accessToken.value && !!user.value);
   const isAdmin = computed(() => user.value?.role === 'admin');
@@ -38,6 +42,7 @@ export const useAuthStore = defineStore('auth', () => {
     accessToken.value = null;
     refreshToken.value = null;
     user.value = null;
+    stopAutoAccountSync();
     localStorage.removeItem(`${serverKey}_accessToken`);
     localStorage.removeItem(`${serverKey}_refreshToken`);
   }
@@ -48,25 +53,51 @@ export const useAuthStore = defineStore('auth', () => {
     saveTokens(result.accessToken, result.refreshToken);
     user.value = result.user;
     
-    // 登录成功后自动刷新所有账号状态
-    refreshAllAccountsInBackground();
+    startAutoAccountSync();
     
     return result;
   }
   
-  // 后台刷新所有账号状态(延迟执行,避免服务启动时网络不稳定导致误判)
-  async function refreshAllAccountsInBackground(delay = 5000) {
-    // 延迟执行,给后端服务足够的启动时间
-    setTimeout(async () => {
-      try {
-        console.log('[Auth] Starting background account refresh...');
-        await accountsApi.refreshAllAccounts();
-        console.log('[Auth] Background account refresh completed');
-      } catch (error) {
-        // 刷新失败不应影响用户体验,静默处理
-        console.warn('[Auth] Background account refresh failed:', error);
+  async function enqueueSyncAccountTasks(): Promise<void> {
+    if (!isAuthenticated.value || autoAccountSyncRunning) return;
+    autoAccountSyncRunning = true;
+
+    try {
+      const taskStore = useTaskQueueStore();
+      const accounts = await accountsApi.getAccounts();
+
+      for (const account of accounts) {
+        await taskStore.syncAccount(account.id, account.accountName);
       }
-    }, delay);
+    } catch (error) {
+      console.warn('[Auth] Auto account sync failed:', error);
+    } finally {
+      autoAccountSyncRunning = false;
+    }
+  }
+
+  function startAutoAccountSync(initialDelay = 5000) {
+    stopAutoAccountSync();
+
+    autoAccountSyncTimeout = setTimeout(() => {
+      enqueueSyncAccountTasks();
+    }, initialDelay);
+
+    const intervalMs = 10 * 60 * 1000;
+    autoAccountSyncTimer = setInterval(() => {
+      enqueueSyncAccountTasks();
+    }, intervalMs);
+  }
+
+  function stopAutoAccountSync() {
+    if (autoAccountSyncTimeout) {
+      clearTimeout(autoAccountSyncTimeout);
+      autoAccountSyncTimeout = null;
+    }
+    if (autoAccountSyncTimer) {
+      clearInterval(autoAccountSyncTimer);
+      autoAccountSyncTimer = null;
+    }
   }
 
   // 注册
@@ -82,8 +113,7 @@ export const useAuthStore = defineStore('auth', () => {
     try {
       user.value = await authApi.getMe();
       
-      // 应用启动时自动刷新所有账号状态
-      refreshAllAccountsInBackground();
+      startAutoAccountSync();
       
       return true;
     } catch {
@@ -97,8 +127,7 @@ export const useAuthStore = defineStore('auth', () => {
           localStorage.setItem(`${serverKey}_accessToken`, result.accessToken);
           user.value = await authApi.getMe();
           
-          // Token 刷新成功后也刷新账号状态
-          refreshAllAccountsInBackground();
+          startAutoAccountSync();
           
           return true;
         } catch {

+ 16 - 6
server/python/platforms/douyin.py

@@ -479,18 +479,26 @@ class DouyinPublisher(BasePublisher):
             
             # 调用作品列表 API
             cursor = page * page_size
-            api_url = f"https://creator.douyin.com/janus/douyin/creator/pc/work_list?status=0&scene=star_atlas&device_platform=android&count={page_size}&max_cursor={cursor}&cookie_enabled=true&browser_language=zh-CN&browser_platform=Win32&browser_name=Mozilla&browser_online=true&timezone_name=Asia%2FShanghai&aid=1128"
+            # 移除 scene=star_atlas 和 aid=1128,使用更通用的参数
+            api_url = f"https://creator.douyin.com/janus/douyin/creator/pc/work_list?status=0&device_platform=android&count={page_size}&max_cursor={cursor}&cookie_enabled=true&browser_language=zh-CN&browser_platform=Win32&browser_name=Mozilla&browser_online=true&timezone_name=Asia%2FShanghai"
             
             response = await self.page.evaluate(f'''
                 async () => {{
-                    const resp = await fetch("{api_url}", {{
-                        credentials: 'include',
-                        headers: {{ 'Accept': 'application/json' }}
-                    }});
-                    return await resp.json();
+                    try {{
+                        const resp = await fetch("{api_url}", {{
+                            credentials: 'include',
+                            headers: {{ 'Accept': 'application/json' }}
+                        }});
+                        return await resp.json();
+                    }} catch (e) {{
+                        return {{ error: e.toString() }};
+                    }}
                 }}
             ''')
             
+            if response.get('error'):
+                print(f"[{self.platform_name}] API 请求失败: {response.get('error')}", flush=True)
+            
             print(f"[{self.platform_name}] API 响应: has_more={response.get('has_more')}, aweme_list={len(response.get('aweme_list', []))}")
             
             aweme_list = response.get('aweme_list', [])
@@ -502,6 +510,8 @@ class DouyinPublisher(BasePublisher):
                     continue
                 
                 statistics = aweme.get('statistics', {})
+                # 打印调试信息,确认字段存在
+                # print(f"[{self.platform_name}] 作品 {aweme_id} 统计: {statistics}", flush=True)
                 
                 # 获取封面
                 cover_url = ''

+ 30 - 17
server/src/services/AccountService.ts

@@ -510,8 +510,22 @@ export class AccountService {
                 if (isValidProfile) {
                   updateData.accountName = profile.accountName;
                   updateData.avatarUrl = profile.avatarUrl;
-                  updateData.fansCount = profile.fansCount;
-                  updateData.worksCount = profile.worksCount;
+                  
+                  // 仅在粉丝数有效时更新(避免因获取失败导致的归零)
+                  if (profile.fansCount !== undefined) {
+                    // 如果新粉丝数为 0,但原粉丝数 > 0,可能是获取失败,记录警告并跳过更新
+                    if (profile.fansCount === 0 && (account.fansCount || 0) > 0) {
+                      logger.warn(`[refreshAccount] Fans count dropped to 0 for ${accountId} (was ${account.fansCount}). Ignoring potential fetch error.`);
+                    } else {
+                      updateData.fansCount = profile.fansCount;
+                    }
+                  }
+                  
+                  if (profile.worksCount === 0 && (account.worksCount || 0) > 0) {
+                    logger.warn(`[refreshAccount] Works count dropped to 0 for ${accountId} (was ${account.worksCount}). Ignoring potential fetch error.`);
+                  } else {
+                    updateData.worksCount = profile.worksCount;
+                  }
                   
                   // 如果获取到了有效的 accountId(如抖音号),也更新它
                   // 这样可以修正之前使用错误 ID(如 Cookie 值)保存的账号
@@ -555,21 +569,20 @@ export class AccountService {
 
     // 保存账号每日统计数据(粉丝数、作品数)
     // 无论是否更新了粉丝数/作品数,都要保存当前值到统计表,确保每天都有记录
-    // TODO: 暂时注释掉,待后续需要时再启用
-    // if (updated) {
-    //   try {
-    //     const userDayStatisticsService = new UserDayStatisticsService();
-    //     await userDayStatisticsService.saveStatistics({
-    //       accountId,
-    //       fansCount: updated.fansCount || 0,
-    //       worksCount: updated.worksCount || 0,
-    //     });
-    //     logger.debug(`[AccountService] Saved account day statistics for account ${accountId} (fans: ${updated.fansCount || 0}, works: ${updated.worksCount || 0})`);
-    //   } catch (error) {
-    //     logger.error(`[AccountService] Failed to save account day statistics for account ${accountId}:`, error);
-    //     // 不抛出错误,不影响主流程
-    //   }
-    // }
+    if (updated) {
+      try {
+        const userDayStatisticsService = new UserDayStatisticsService();
+        await userDayStatisticsService.saveStatistics({
+          accountId,
+          fansCount: updated.fansCount || 0,
+          worksCount: updated.worksCount || 0,
+        });
+        logger.debug(`[AccountService] Saved account day statistics for account ${accountId} (fans: ${updated.fansCount || 0}, works: ${updated.worksCount || 0})`);
+      } catch (error) {
+        logger.error(`[AccountService] Failed to save account day statistics for account ${accountId}:`, error);
+        // 不抛出错误,不影响主流程
+      }
+    }
 
     // 通知其他客户端
     wsManager.sendToUser(userId, WS_EVENTS.ACCOUNT_UPDATED, { account: this.formatAccount(updated!) });

+ 17 - 15
server/src/services/HeadlessBrowserService.ts

@@ -76,7 +76,7 @@ export interface AccountInfo {
   accountId: string;
   accountName: string;
   avatarUrl: string;
-  fansCount: number;
+  fansCount?: number;
   worksCount: number;
   worksList?: WorkItem[];
 }
@@ -744,7 +744,7 @@ class HeadlessBrowserService {
     let accountId = `douyin_${Date.now()}`;
     let accountName = '抖音账号';
     let avatarUrl = '';
-    let fansCount = 0;
+    let fansCount: number | undefined;
     let worksCount = 0;
     let worksList: WorkItem[] = [];
     let isLoggedIn = false;
@@ -1143,7 +1143,7 @@ class HeadlessBrowserService {
     let accountId = `bilibili_${Date.now()}`;
     let accountName = 'B站账号';
     let avatarUrl = '';
-    let fansCount = 0;
+    let fansCount: number | undefined;
     let worksCount = 0;
 
     try {
@@ -1194,7 +1194,7 @@ class HeadlessBrowserService {
     let accountId = `kuaishou_${Date.now()}`;
     let accountName = '快手账号';
     let avatarUrl = '';
-    let fansCount = 0;
+    let fansCount: number | undefined;
     let worksCount = 0;
 
     try {
@@ -1245,7 +1245,7 @@ class HeadlessBrowserService {
     let accountId = `weixin_video_${Date.now()}`;
     let accountName = '视频号账号';
     let avatarUrl = '';
-    let fansCount = 0;
+    let fansCount: number | undefined;
     let worksCount = 0;
     let finderId = '';
 
@@ -1564,7 +1564,7 @@ class HeadlessBrowserService {
     let accountId = `xiaohongshu_${Date.now()}`;
     let accountName = '小红书账号';
     let avatarUrl = '';
-    let fansCount = 0;
+    let fansCount: number | undefined;
     let worksCount = 0;
 
     // 用于存储捕获的数据
@@ -1607,8 +1607,8 @@ class HeadlessBrowserService {
                 avatar: userInfo.image || userInfo.avatar || userInfo.images,
                 userId: userInfo.user_id || userInfo.userId,
                 redId: userInfo.red_id || userInfo.redId,
-                fans: userInfo.fans || userInfo.fansCount,
-                notes: userInfo.notes || userInfo.noteCount,
+                fans: userInfo.fans ?? userInfo.fansCount,
+                notes: userInfo.notes ?? userInfo.noteCount,
               };
               logger.info(`[Xiaohongshu API] Captured user info:`, capturedData.userInfo);
             }
@@ -1652,7 +1652,8 @@ class HeadlessBrowserService {
       const currentUrl = page.url();
       if (currentUrl.includes('login') || currentUrl.includes('passport')) {
         logger.warn('[Xiaohongshu] Cookie expired, needs login');
-        return this.getDefaultAccountInfo('xiaohongshu');
+        // 返回空信息,fansCount 为 undefined,避免重置为 0
+        return { accountId, accountName, avatarUrl, fansCount: undefined, worksCount: 0 };
       }
 
       // 等待 API 响应
@@ -1733,10 +1734,10 @@ class HeadlessBrowserService {
         } else if (capturedData.userInfo.userId) {
           accountId = `xiaohongshu_${capturedData.userInfo.userId}`;
         }
-        if (capturedData.userInfo.fans) {
+        if (capturedData.userInfo.fans !== undefined) {
           fansCount = capturedData.userInfo.fans;
         }
-        if (capturedData.userInfo.notes) {
+        if (capturedData.userInfo.notes !== undefined) {
           worksCount = capturedData.userInfo.notes;
         }
       }
@@ -2510,8 +2511,8 @@ class HeadlessBrowserService {
           accountId: result.account_id || `${platform}_${Date.now()}`,
           accountName: result.account_name,
           avatarUrl: result.avatar_url || '',
-          fansCount: result.fans_count || 0,
-          worksCount: result.works_count || 0,
+          fansCount: result.fans_count,
+          worksCount: result.works_count ?? 0,
         };
       }
 
@@ -2546,7 +2547,7 @@ class HeadlessBrowserService {
       accountId: `${platform}_${Date.now()}`,
       accountName: `${name}账号`,
       avatarUrl: '',
-      fansCount: 0,
+      fansCount: undefined,
       worksCount: 0,
     };
   }
@@ -3065,7 +3066,8 @@ class HeadlessBrowserService {
 
       // 方式1:直接调用 API 获取作品列表(优先)
       logger.info('[API Interception] Fetching works via direct API...');
-      let works = await this.fetchWorksDirectApi(page);
+      const apiResult = await this.fetchWorksDirectApi(page);
+      let works = apiResult.works;
 
       // 方式2:如果直接调用失败,尝试通过页面触发 API
       if (works.length === 0) {

+ 28 - 24
server/src/services/RedisTaskQueue.ts

@@ -1,9 +1,9 @@
 import { Queue, Worker, Job, QueueEvents } from 'bullmq';
 import IORedis from 'ioredis';
 import { v4 as uuidv4 } from 'uuid';
-import { 
-  Task, 
-  TaskType, 
+import {
+  Task,
+  TaskType,
   TaskResult,
   TaskProgressUpdate,
   CreateTaskRequest,
@@ -24,7 +24,7 @@ const redisConnection = new IORedis({
 
 // 任务执行器类型
 type TaskExecutor = (
-  task: Task, 
+  task: Task,
   updateProgress: (update: Partial<TaskProgressUpdate>) => void
 ) => Promise<TaskResult>;
 
@@ -39,19 +39,20 @@ class RedisTaskQueueService {
   private queue: Queue;
   private queueEvents: QueueEvents;
   private worker: Worker | null = null;
-  
+
   // 任务执行器 Map<TaskType, TaskExecutor>
   private executors: Map<TaskType, TaskExecutor> = new Map();
-  
+
   // 内存中缓存用户任务(用于快速查询)
   private userTasks: Map<number, Task[]> = new Map();
-  
+
   // 最大并行任务数
   private concurrency = 5;
 
   constructor() {
     // 创建队列
     this.queue = new Queue(QUEUE_NAME, {
+      // @ts-ignore
       connection: redisConnection,
       defaultJobOptions: {
         removeOnComplete: { count: 100 },  // 保留最近100个完成的任务
@@ -66,11 +67,12 @@ class RedisTaskQueueService {
 
     // 创建队列事件监听
     this.queueEvents = new QueueEvents(QUEUE_NAME, {
+      // @ts-ignore
       connection: redisConnection.duplicate(),
     });
 
     this.setupEventListeners();
-    
+
     logger.info('Redis Task Queue Service initialized');
   }
 
@@ -106,6 +108,7 @@ class RedisTaskQueueService {
         return this.processJob(job);
       },
       {
+        // @ts-ignore
         connection: redisConnection.duplicate(),
         concurrency: this.concurrency,  // 并行处理任务数
       }
@@ -143,7 +146,7 @@ class RedisTaskQueueService {
   private async processJob(job: Job): Promise<TaskResult> {
     const taskData = job.data as Task & { userId: number };
     const { userId } = taskData;
-    
+
     const executor = this.executors.get(taskData.type);
     if (!executor) {
       throw new Error(`No executor registered for task type: ${taskData.type}`);
@@ -156,15 +159,15 @@ class RedisTaskQueueService {
     });
 
     // 通知前端任务开始
-    this.notifyUser(userId, TASK_WS_EVENTS.TASK_STARTED, { 
-      task: this.getTaskFromCache(userId, taskData.id) 
+    this.notifyUser(userId, TASK_WS_EVENTS.TASK_STARTED, {
+      task: this.getTaskFromCache(userId, taskData.id)
     });
 
     // 进度更新回调
     const updateProgress = async (update: Partial<TaskProgressUpdate>) => {
       // 更新 Job 进度
       await job.updateProgress(update);
-      
+
       // 更新内存缓存
       this.updateTaskInCache(userId, taskData.id, {
         progress: update.progress,
@@ -184,7 +187,7 @@ class RedisTaskQueueService {
 
     try {
       const result = await executor(taskData, updateProgress);
-      
+
       // 更新缓存
       this.updateTaskInCache(userId, taskData.id, {
         status: 'completed',
@@ -194,14 +197,14 @@ class RedisTaskQueueService {
       });
 
       // 通知前端
-      this.notifyUser(userId, TASK_WS_EVENTS.TASK_COMPLETED, { 
-        task: this.getTaskFromCache(userId, taskData.id) 
+      this.notifyUser(userId, TASK_WS_EVENTS.TASK_COMPLETED, {
+        task: this.getTaskFromCache(userId, taskData.id)
       });
 
       return result;
     } catch (error) {
       const errorMessage = error instanceof Error ? error.message : '任务执行失败';
-      
+
       // 更新缓存
       this.updateTaskInCache(userId, taskData.id, {
         status: 'failed',
@@ -210,8 +213,8 @@ class RedisTaskQueueService {
       });
 
       // 通知前端
-      this.notifyUser(userId, TASK_WS_EVENTS.TASK_FAILED, { 
-        task: this.getTaskFromCache(userId, taskData.id) 
+      this.notifyUser(userId, TASK_WS_EVENTS.TASK_FAILED, {
+        task: this.getTaskFromCache(userId, taskData.id)
       });
 
       throw error;
@@ -230,7 +233,7 @@ class RedisTaskQueueService {
    * 创建新任务
    */
   async createTask(userId: number, request: CreateTaskRequest): Promise<Task & { userId: number }> {
-    const task: Task & { userId: number; [key: string]: unknown } = {
+    const task: Task & { userId: number;[key: string]: unknown } = {
       id: uuidv4(),
       type: request.type,
       title: request.title || this.getDefaultTitle(request.type),
@@ -303,7 +306,7 @@ class RedisTaskQueueService {
 
       // 通知前端
       this.notifyUser(userId, TASK_WS_EVENTS.TASK_CANCELLED, { task });
-      
+
       logger.info(`Task cancelled: ${taskId}`);
       return true;
     }
@@ -318,18 +321,18 @@ class RedisTaskQueueService {
     const tasks = this.userTasks.get(userId);
     if (!tasks) return;
 
-    const completedTasks = tasks.filter(t => 
+    const completedTasks = tasks.filter(t =>
       t.status === 'completed' || t.status === 'failed' || t.status === 'cancelled'
     );
 
     if (completedTasks.length > keepCount) {
-      completedTasks.sort((a, b) => 
+      completedTasks.sort((a, b) =>
         new Date(b.completedAt || 0).getTime() - new Date(a.completedAt || 0).getTime()
       );
-      
+
       const toRemove = completedTasks.slice(keepCount);
       const toRemoveIds = new Set(toRemove.map(t => t.id));
-      
+
       this.userTasks.set(userId, tasks.filter(t => !toRemoveIds.has(t.id)));
     }
   }
@@ -407,6 +410,7 @@ class RedisTaskQueueService {
       sync_account: '同步账号信息',
       publish_video: '发布视频',
       batch_reply: '批量回复评论',
+      delete_work: '删除作品',
     };
     return titles[type] || '未知任务';
   }

+ 1 - 0
server/src/services/TaskQueueService.ts

@@ -256,6 +256,7 @@ class TaskQueueService {
       sync_account: '同步账号信息',
       publish_video: '发布视频',
       batch_reply: '批量回复评论',
+      delete_work: '删除作品',
     };
     return titles[type] || '未知任务';
   }

+ 18 - 7
server/src/services/XiaohongshuAccountOverviewImportService.ts

@@ -217,7 +217,7 @@ function parseXhsExcel(
     }
 
     const sheet = wb.Sheets[sheetName];
-    const rows = XLSX.utils.sheet_to_json<Record<string, any>>(sheet, { defval: '' });
+    const rows = (XLSX.utils.sheet_to_json(sheet, { defval: '' }) as Record<string, any>[]);
 
     if (rows.length) {
       const keys = Object.keys(rows[0] || {});
@@ -373,20 +373,17 @@ export class XiaohongshuAccountOverviewImportService {
         throw new Error('未登录/需要重新登录(跳转到 login)');
       }
 
-      // 检测“暂无访问权限 / 权限申请中”提示:标记账号 expired + 推送提示
+      // 检测“暂无访问权限 / 权限申请中”提示:仅推送提示,不修改账号状态(避免误判或用户不想自动变更)
       const bodyText = (await page.textContent('body').catch(() => '')) || '';
       if (bodyText.includes('暂无访问权限') || bodyText.includes('数据权限申请中') || bodyText.includes('次日再来查看')) {
-        await this.accountRepository.update(account.id, { status: 'expired' as any });
-        wsManager.sendToUser(account.userId, WS_EVENTS.ACCOUNT_UPDATED, {
-          account: { id: account.id, status: 'expired', platform: 'xiaohongshu' },
-        });
+        // await this.accountRepository.update(account.id, { status: 'expired' as any });
         wsManager.sendToUser(account.userId, WS_EVENTS.SYSTEM_MESSAGE, {
           level: 'warning',
           message: `小红书账号「${account.accountName || account.accountId || account.id}」暂无数据看板访问权限,请到小红书创作服务平台申请数据权限(通过后一般次日生效)。`,
           platform: 'xiaohongshu',
           accountId: account.id,
         });
-        throw new Error('小红书数据看板暂无访问权限/申请中,已标记 expired 并通知用户');
+        throw new Error('小红书数据看板暂无访问权限/申请中,已通知用户');
       }
 
       // 统一入口:账号概览 -> 笔记数据
@@ -415,6 +412,20 @@ export class XiaohongshuAccountOverviewImportService {
           perDay = parseXhsExcel(filePath, mode);
           for (const v of perDay.values()) {
             const { recordDate, ...patch } = v;
+
+            // 修正:如果导入的数据是今天的,且没有粉丝总数(Excel只有涨粉数),则使用账号当前的粉丝数
+            // 避免因为导入导致今天的粉丝数被重置为 0
+            const today = new Date();
+            today.setHours(0, 0, 0, 0);
+
+            // 比较时间戳
+            if (recordDate.getTime() === today.getTime()) {
+              if ((patch as any).fansCount === undefined && account.fansCount !== undefined && account.fansCount > 0) {
+                (patch as any).fansCount = account.fansCount;
+                logger.info(`[XHS Import] Injected current fansCount=${account.fansCount} for today's record (accountId=${account.id})`);
+              }
+            }
+
             const r = await this.userDayStatisticsService.saveStatisticsForDate(account.id, recordDate, patch);
             inserted += r.inserted;
             updated += r.updated;