Sfoglia il codice sorgente

抖音作品补数任务

Ethanfly 9 ore fa
parent
commit
f98996d827

+ 66 - 0
server/src/scripts/delete-works-for-account.ts

@@ -0,0 +1,66 @@
+import { initDatabase, AppDataSource, Work, PlatformAccount } from '../models/index.js';
+import { logger } from '../utils/logger.js';
+import { workService } from '../services/WorkService.js';
+
+async function main() {
+  const arg = process.argv[2];
+  if (!arg) {
+    logger.error('请提供账号ID(platform_accounts 主键)');
+    logger.info('用法: tsx src/scripts/delete-works-for-account.ts <accountId>');
+    process.exit(1);
+  }
+
+  const accountId = Number(arg);
+  if (!Number.isInteger(accountId) || accountId <= 0) {
+    logger.error(`无效的账号ID: ${arg}`);
+    process.exit(1);
+  }
+
+  try {
+    await initDatabase();
+    const accountRepo = AppDataSource.getRepository(PlatformAccount);
+    const workRepo = AppDataSource.getRepository(Work);
+
+    const account = await accountRepo.findOne({ where: { id: accountId } });
+    if (!account) {
+      logger.error(`未找到账号 ID=${accountId}`);
+      process.exit(1);
+    }
+
+    logger.info(
+      `准备删除账号下作品: ID=${account.id}, platform=${account.platform}, accountId=${account.accountId}, name=${account.accountName}`
+    );
+
+    const works = await workRepo.find({
+      where: { accountId: account.id },
+      order: { publishTime: 'DESC' },
+    });
+
+    if (!works.length) {
+      logger.info(`账号 ${account.id} 下没有作品记录,无需删除`);
+      process.exit(0);
+    }
+
+    logger.info(`共找到 ${works.length} 条作品记录,将逐条删除(含关联的每日统计和评论)`);
+
+    for (const work of works) {
+      try {
+        await workService.deleteWork(work.userId, work.id);
+        logger.info(
+          `已删除 work.id=${work.id}, platformVideoId=${work.platformVideoId}, title=${work.title}`
+        );
+      } catch (e) {
+        logger.error(`删除 work.id=${work.id} 失败`, e);
+      }
+    }
+
+    logger.info(`账号 ${account.id} 下的作品删除流程结束`);
+    process.exit(0);
+  } catch (e) {
+    logger.error('执行失败:', e);
+    process.exit(1);
+  }
+}
+
+void main();
+

+ 35 - 3
server/src/services/DouyinWorkStatisticsImportService.ts

@@ -406,20 +406,44 @@ export class DouyinWorkStatisticsImportService {
    * 按账号同步作品日统计。检测到 cookie 失效时:先尝试同步/刷新账号一次;刷新仍失效则标记账号 expired。
    * @param isRetry 是否为「刷新账号后的重试」,避免无限递归
    */
-  private async importAccountWorksStatistics(account: PlatformAccount, isRetry = false): Promise<void> {
+  async importAccountWorksStatistics(
+    account: PlatformAccount,
+    isRetry = false,
+    options?: {
+      workIdFilter?: number[];
+      onProgress?: (payload: { index: number; total: number; work: Work }) => void;
+    }
+  ): Promise<void> {
     const cookies = parseCookiesFromAccount(account.cookieData);
     if (!cookies.length) {
       logger.warn(`[DY WorkStats] accountId=${account.id} cookieData 为空或无法解析,跳过`);
       return;
     }
 
-    const works = await this.workRepository.find({
+    let works = await this.workRepository.find({
       where: {
         accountId: account.id,
         platform: 'douyin' as any,
       },
     });
 
+    if (options?.workIdFilter && options.workIdFilter.length > 0) {
+      const filterSet = new Set(
+        options.workIdFilter.map((id) => Number(id)).filter((n) => Number.isFinite(n) && n > 0)
+      );
+      works = works.filter((w) => filterSet.has(w.id));
+    }
+
+    // 同小红书保持一致:按发布时间从近到远处理;无发布时间的排在最后
+    if (works.length > 1) {
+      works.sort((a, b) => {
+        const ta = a.publishTime ? new Date(a.publishTime as any).getTime() : -Infinity;
+        const tb = b.publishTime ? new Date(b.publishTime as any).getTime() : -Infinity;
+        if (tb !== ta) return tb - ta;
+        return (b.id || 0) - (a.id || 0);
+      });
+    }
+
     if (!works.length) {
       logger.info(`[DY WorkStats] accountId=${account.id} 没有作品,跳过`);
       return;
@@ -450,7 +474,15 @@ export class DouyinWorkStatisticsImportService {
       let totalInserted = 0;
       let totalUpdated = 0;
 
-      for (const work of works) {
+      const total = works.length;
+
+      for (let i = 0; i < works.length; i++) {
+        const work = works[i];
+
+        if (options?.onProgress) {
+          options.onProgress({ index: i + 1, total, work });
+        }
+
         const itemId = (work.platformVideoId || '').trim();
         if (!itemId) continue;
 

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

@@ -259,6 +259,7 @@ class TaskQueueService {
       batch_reply: '批量回复评论',
       delete_work: '删除作品',
       xhs_work_stats_backfill: '小红书作品补数',
+      dy_work_stats_backfill: '抖音作品补数',
     };
     return titles[type] || '未知任务';
   }

+ 51 - 47
server/src/services/WorkService.ts

@@ -440,12 +440,12 @@ export class WorkService {
       }
     }
 
-    // 保存每日统计数据
-    try {
-      await this.saveWorkDayStatistics(account);
-    } catch (error) {
-      logger.error(`[SyncAccountWorks] Failed to save day statistics for account ${account.id}:`, error);
-    }
+    // 同步作品后:不再基于 works 累计值写 work_day_statistics,当天快照全部交给各平台专门的“每日数据”任务
+    // try {
+    //   await this.saveWorkDayStatistics(account);
+    // } catch (error) {
+    //   logger.error(`[SyncAccountWorks] Failed to save day statistics for account ${account.id}:`, error);
+    // }
 
     // 小红书:如果是新作品且 work_day_statistics 中尚无任何记录,则补首批日统计 & works.yesterday_*(不受14天限制)
     if (platform === 'xiaohongshu') {
@@ -488,6 +488,46 @@ export class WorkService {
       }
     }
 
+    // 抖音:如果是新作品且 work_day_statistics 中尚无任何记录,则异步补齐历史日统计 & works.yesterday_*(使用 DouyinWorkStatisticsImportService)
+    if (platform === 'douyin') {
+      try {
+        const works = await this.workRepository.find({
+          where: { accountId: account.id, platform },
+          select: ['id'],
+        });
+        const workIds = works.map((w) => w.id);
+        if (workIds.length > 0) {
+          const rows = await AppDataSource.getRepository(WorkDayStatistics)
+            .createQueryBuilder('wds')
+            .select('DISTINCT wds.work_id', 'workId')
+            .where('wds.work_id IN (:...ids)', { ids: workIds })
+            .getRawMany();
+          const hasStats = new Set<number>(rows.map((r: any) => Number(r.workId)));
+          const needInitIds = workIds.filter((id) => !hasStats.has(id));
+
+          if (needInitIds.length > 0) {
+            logger.info(
+              `[SyncAccountWorks] DY account ${account.id} has ${needInitIds.length} works without statistics, enqueue dy_work_stats_backfill task.`
+            );
+            taskQueueService.createTask(account.userId, {
+              type: 'dy_work_stats_backfill',
+              title: `抖音作品补数(${needInitIds.length})`,
+              accountId: account.id,
+              platform: 'douyin',
+              data: {
+                workIds: needInitIds,
+              },
+            });
+          }
+        }
+      } catch (err) {
+        logger.error(
+          `[SyncAccountWorks] Failed to enqueue DY work_day_statistics backfill for account ${account.id}:`,
+          err
+        );
+      }
+    }
+
     return {
       syncedCount,
       worksListLength: accountInfo.worksList?.length || 0,
@@ -501,47 +541,11 @@ export class WorkService {
    * 保存作品每日统计数据
    */
   private async saveWorkDayStatistics(account: PlatformAccount): Promise<void> {
-    // 小红书作品的细分日统计通过 XiaohongshuWorkNoteStatisticsImportService 定时任务单独采集,
-    // 这里的基于「作品当前总量」的快照统计对小红书意义不大,避免口径混乱,直接跳过。
-    if (account.platform === 'xiaohongshu') {
-      logger.info(
-        `[SaveWorkDayStatistics] Skip snapshot-based work_day_statistics for xiaohongshu account ${account.id}, will be filled by dedicated XHS note statistics importer.`
-      );
-      return;
-    }
-
-    // 获取该账号下所有作品
-    const works = await this.workRepository.find({
-      where: { accountId: account.id },
-    });
-
-    if (works.length === 0) {
-      logger.info(`[SaveWorkDayStatistics] No works found for account ${account.id}`);
-      return;
-    }
-
-    // 构建统计数据列表(不再包含粉丝数,粉丝数从 user_day_statistics 表获取)
-    const statisticsList = works.map(work => ({
-      workId: work.id,
-      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}`);
-
-    // 直接使用 WorkDayStatisticsService 保存统计数据
-    try {
-      const workDayStatisticsService = new WorkDayStatisticsService();
-      const result = await workDayStatisticsService.saveStatistics(statisticsList);
-
-      logger.info(`[SaveWorkDayStatistics] Success: inserted=${result.inserted}, updated=${result.updated}`);
-    } catch (error) {
-      logger.error(`[SaveWorkDayStatistics] Failed to save statistics:`, error);
-      throw error;
-    }
+    // 已废弃:同步作品时不再基于 works 表当前累计值,往 work_day_statistics 写“今天快照”。
+    // 保留空实现仅为兼容旧调用点,避免影响同步主流程。
+    logger.info(
+      `[SaveWorkDayStatistics] Skip snapshot-based work_day_statistics when syncing works for account ${account.id} (disabled by design).`
+    );
   }
 
   /**

+ 51 - 0
server/src/services/taskExecutors.ts

@@ -9,6 +9,7 @@ import { WorkService } from './WorkService.js';
 import { AccountService } from './AccountService.js';
 import { PublishService } from './PublishService.js';
 import { XiaohongshuWorkNoteStatisticsImportService } from './XiaohongshuWorkNoteStatisticsImportService.js';
+import { DouyinWorkStatisticsImportService } from './DouyinWorkStatisticsImportService.js';
 import { AppDataSource, PlatformAccount } from '../models/index.js';
 import { logger } from '../utils/logger.js';
 
@@ -18,6 +19,7 @@ const workService = new WorkService();
 const accountService = new AccountService();
 const publishService = new PublishService();
 const xhsWorkStatsService = new XiaohongshuWorkNoteStatisticsImportService();
+const dyWorkStatsService = new DouyinWorkStatisticsImportService();
 
 type ProgressUpdater = (update: Partial<TaskProgressUpdate>) => void;
 
@@ -262,6 +264,54 @@ async function xhsWorkStatsBackfillExecutor(task: Task, updateProgress: Progress
 }
 
 /**
+ * 抖音作品日统计/快照补数(不阻塞同步作品)
+ * 任务 data: { workIds: number[] }
+ */
+async function dyWorkStatsBackfillExecutor(task: Task, updateProgress: ProgressUpdater): Promise<TaskResult> {
+  updateProgress({ progress: 5, currentStep: '准备抖音作品补数任务...' });
+
+  const userId = (task as Task & { userId?: number }).userId;
+  if (!userId) throw new Error('缺少用户ID');
+  if (!task.accountId) throw new Error('缺少账号ID');
+
+  const workIdsRaw = (task as Task & { workIds?: unknown }).workIds;
+  const workIds = Array.isArray(workIdsRaw)
+    ? workIdsRaw.map((x) => Number(x)).filter((n) => Number.isFinite(n) && n > 0)
+    : [];
+  if (!workIds.length) throw new Error('缺少 workIds');
+
+  // 仅允许当前用户自己的抖音账号
+  const account = await AppDataSource.getRepository(PlatformAccount).findOne({
+    where: { id: task.accountId, userId, platform: 'douyin' as any },
+  });
+  if (!account) throw new Error('未找到抖音账号或无权限');
+
+  const total = workIds.length;
+  updateProgress({ progress: 15, currentStep: `开始抖音作品补数(作品数:${total})...`, totalSteps: total });
+
+  await dyWorkStatsService.importAccountWorksStatistics(account, false, {
+    workIdFilter: workIds,
+    onProgress: ({ index, total, work }) => {
+      const pct = Math.min(99, Math.max(15, Math.round(15 + (index / Math.max(1, total)) * 84)));
+      updateProgress({
+        progress: pct,
+        currentStepIndex: index,
+        totalSteps: total,
+        currentStep: `第 ${index}/${total} 个作品:${(work.title || '').trim() || `workId=${work.id}`}`,
+      });
+    },
+  });
+
+  updateProgress({ progress: 100, currentStep: '抖音作品补数完成' });
+
+  return {
+    success: true,
+    message: `抖音作品补数完成,作品数:${workIds.length}`,
+    data: { workIdsCount: workIds.length },
+  };
+}
+
+/**
  * 注册所有任务执行器
  */
 export function registerTaskExecutors(): void {
@@ -271,6 +321,7 @@ export function registerTaskExecutors(): void {
   taskQueueService.registerExecutor('publish_video', publishVideoExecutor);
   taskQueueService.registerExecutor('delete_work', deleteWorkExecutor);
   taskQueueService.registerExecutor('xhs_work_stats_backfill', xhsWorkStatsBackfillExecutor);
+  taskQueueService.registerExecutor('dy_work_stats_backfill', dyWorkStatsBackfillExecutor);
   
   logger.info('All task executors registered');
 }

+ 13 - 7
shared/src/types/task.ts

@@ -4,13 +4,14 @@
 
 // 任务类型
 export type TaskType = 
-  | 'sync_comments'      // 同步评论
-  | 'sync_works'         // 同步作品
-  | 'sync_account'       // 同步账号信息
-  | 'publish_video'      // 发布视频
-  | 'batch_reply'        // 批量回复评论
-  | 'delete_work'        // 删除平台作品
-  | 'xhs_work_stats_backfill'; // 小红书作品首批日统计/快照补数(后台执行)
+  | 'sync_comments'          // 同步评论
+  | 'sync_works'             // 同步作品
+  | 'sync_account'           // 同步账号信息
+  | 'publish_video'          // 发布视频
+  | 'batch_reply'            // 批量回复评论
+  | 'delete_work'            // 删除平台作品
+  | 'xhs_work_stats_backfill' // 小红书作品首批日统计/快照补数(后台执行)
+  | 'dy_work_stats_backfill'; // 抖音作品日统计/快照补数(后台执行)
 
 // 任务状态
 export type TaskStatus = 
@@ -95,6 +96,11 @@ export const TASK_TYPE_CONFIG: Record<TaskType, {
     icon: 'DataLine',
     color: '#9b59b6',
   },
+  dy_work_stats_backfill: {
+    name: '抖音作品补数',
+    icon: 'DataLine',
+    color: '#2ecc71',
+  },
 };
 
 // WebSocket 任务事件类型