Przeglądaj źródła

视频号作品补数

Ethanfly 12 godzin temu
rodzic
commit
1c51f8b14a

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

@@ -415,6 +415,7 @@ class RedisTaskQueueService {
       xhs_work_stats_backfill: '小红书作品补数',
       dy_work_stats_backfill: '抖音作品补数',
       bj_work_stats_backfill: '百家号作品补数',
+      wx_work_stats_backfill: '视频号作品补数',
     };
     return titles[type] || '未知任务';
   }

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

@@ -261,6 +261,7 @@ class TaskQueueService {
       xhs_work_stats_backfill: '小红书作品补数',
       dy_work_stats_backfill: '抖音作品补数',
       bj_work_stats_backfill: '百家号作品补数',
+      wx_work_stats_backfill: '视频号作品补数',
     };
     return titles[type] || '未知任务';
   }

+ 19 - 6
server/src/services/WeixinVideoWorkStatisticsImportService.ts

@@ -162,6 +162,12 @@ function getRecentChinaDates(days: number): Date[] {
   return dates;
 }
 
+/** 四舍五入保留两位小数,用于 yesterday_completion_rate / yesterday_avg_watch_duration 入库 */
+function round2(value: number | null | undefined): number {
+  if (value == null || !Number.isFinite(value)) return 0;
+  return Math.round(value * 100) / 100;
+}
+
 const STATISTIC_POST_URL = 'https://channels.weixin.qq.com/platform/statistic/post';
 const TAB_SINGLE_VIDEO_SELECTORS = [
   // 与当前后台 DOM 一致:ul.weui-desktop-tab_navs_inner 下 li.weui-desktop-tab_nav 内的 a
@@ -198,8 +204,12 @@ export class WeixinVideoWorkStatisticsImportService {
     await svc.runDailyImportForAllWeixinVideoAccounts();
   }
 
-  /** 仅同步指定账号(用于测试),showBrowser=true 时显示浏览器窗口 */
-  static async runDailyImportForAccount(accountId: number, showBrowser = false): Promise<void> {
+  /** 仅同步指定账号(用于测试/补数),showBrowser=true 时显示浏览器窗口;仅同步 workIds 时用于作品补数 */
+  static async runDailyImportForAccount(
+    accountId: number,
+    showBrowser = false,
+    onlyWorkIds?: number[]
+  ): Promise<void> {
     const svc = new WeixinVideoWorkStatisticsImportService();
     const account = await svc.accountRepository.findOne({
       where: { id: accountId, platform: 'weixin_video' as any },
@@ -207,8 +217,11 @@ export class WeixinVideoWorkStatisticsImportService {
     if (!account) {
       throw new Error(`未找到视频号账号 id=${accountId}`);
     }
-    logger.info(`[WX WorkStats] 单账号同步 accountId=${accountId} showBrowser=${showBrowser}`);
-    await svc.importAccountWorksStatistics(account, showBrowser);
+    logger.info(
+      `[WX WorkStats] 单账号同步 accountId=${accountId} showBrowser=${showBrowser}` +
+        (onlyWorkIds?.length ? ` 仅作品 ${onlyWorkIds.length} 个` : '')
+    );
+    await svc.importAccountWorksStatistics(account, showBrowser, onlyWorkIds?.length ? { onlyWorkIds } : undefined);
   }
 
   /** 仅同步指定作品(用于接口/测试),showBrowser=true 时显示浏览器窗口;返回 inserted/updated */
@@ -455,9 +468,9 @@ export class WeixinVideoWorkStatisticsImportService {
         const forwardCount = Number(it.forwardCount) || 0;
         const followCount = Number(it.followCount) || 0;
         const fullPlayRate = it.fullPlayRate;
-        const compRate = fullPlayRate != null ? `${Number(fullPlayRate) * 100}%` : '0';
+        const compRate = fullPlayRate != null ? `${round2(Number(fullPlayRate) * 100)}%` : '0';
         const avgSec = it.avgPlayTimeSec;
-        const avgDur = avgSec != null ? `${Number(avgSec)}秒` : '0';
+        const avgDur = avgSec != null ? `${round2(Number(avgSec))}秒` : '0';
         workUpdates.push({
           work_id: workId,
           yesterday_play_count: readCount,

+ 40 - 0
server/src/services/WorkService.ts

@@ -582,6 +582,46 @@ export class WorkService {
       }
     }
 
+    // 视频号:如果是新作品且 work_day_statistics 中尚无任何记录,则异步补齐日统计 & works.yesterday_*(使用 WeixinVideoWorkStatisticsImportService)
+    if (platform === 'weixin_video') {
+      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] WX account ${account.id} has ${needInitIds.length} works without statistics, enqueue wx_work_stats_backfill task.`
+            );
+            taskQueueService.createTask(account.userId, {
+              type: 'wx_work_stats_backfill',
+              title: `视频号作品补数(${needInitIds.length})`,
+              accountId: account.id,
+              platform: 'weixin_video',
+              data: {
+                workIds: needInitIds,
+              },
+            });
+          }
+        }
+      } catch (err) {
+        logger.error(
+          `[SyncAccountWorks] Failed to enqueue WX work_day_statistics backfill for account ${account.id}:`,
+          err
+        );
+      }
+    }
+
     return {
       syncedCount,
       worksListLength: accountInfo.worksList?.length || 0,

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

@@ -11,6 +11,7 @@ import { PublishService } from './PublishService.js';
 import { XiaohongshuWorkNoteStatisticsImportService } from './XiaohongshuWorkNoteStatisticsImportService.js';
 import { DouyinWorkStatisticsImportService } from './DouyinWorkStatisticsImportService.js';
 import { BaijiahaoWorkDailyStatisticsImportService } from './BaijiahaoWorkDailyStatisticsImportService.js';
+import { WeixinVideoWorkStatisticsImportService } from './WeixinVideoWorkStatisticsImportService.js';
 import { AppDataSource, PlatformAccount } from '../models/index.js';
 import { logger } from '../utils/logger.js';
 
@@ -348,6 +349,43 @@ async function bjWorkStatsBackfillExecutor(task: Task, updateProgress: ProgressU
 }
 
 /**
+ * 视频号作品日统计/快照补数(不阻塞同步作品)
+ * 任务 data: { workIds: number[] }
+ */
+async function wxWorkStatsBackfillExecutor(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 data = task as Task & { data?: { workIds?: unknown }; workIds?: unknown };
+  const workIdsRaw = data.workIds ?? data.data?.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: 'weixin_video' as any },
+  });
+  if (!account) throw new Error('未找到视频号账号或无权限');
+
+  const total = workIds.length;
+  updateProgress({ progress: 15, currentStep: `开始视频号作品补数(作品数:${total})...`, totalSteps: total });
+
+  await WeixinVideoWorkStatisticsImportService.runDailyImportForAccount(account.id, false, workIds);
+
+  updateProgress({ progress: 100, currentStep: '视频号作品补数完成' });
+
+  return {
+    success: true,
+    message: `视频号作品补数完成,作品数:${workIds.length}`,
+    data: { workIdsCount: workIds.length },
+  };
+}
+
+/**
  * 注册所有任务执行器
  */
 export function registerTaskExecutors(): void {
@@ -359,6 +397,7 @@ export function registerTaskExecutors(): void {
   taskQueueService.registerExecutor('xhs_work_stats_backfill', xhsWorkStatsBackfillExecutor);
   taskQueueService.registerExecutor('dy_work_stats_backfill', dyWorkStatsBackfillExecutor);
   taskQueueService.registerExecutor('bj_work_stats_backfill', bjWorkStatsBackfillExecutor);
+  taskQueueService.registerExecutor('wx_work_stats_backfill', wxWorkStatsBackfillExecutor);
 
   logger.info('All task executors registered');
 }

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

@@ -12,7 +12,8 @@ export type TaskType =
   | 'delete_work'            // 删除平台作品
   | 'xhs_work_stats_backfill' // 小红书作品首批日统计/快照补数(后台执行)
   | 'dy_work_stats_backfill'  // 抖音作品日统计/快照补数(后台执行)
-  | 'bj_work_stats_backfill'; // 百家号作品日统计/快照补数(后台执行)
+  | 'bj_work_stats_backfill'  // 百家号作品日统计/快照补数(后台执行)
+  | 'wx_work_stats_backfill'; // 视频号作品日统计/快照补数(后台执行)
 
 // 任务状态
 export type TaskStatus = 
@@ -107,6 +108,11 @@ export const TASK_TYPE_CONFIG: Record<TaskType, {
     icon: 'DataLine',
     color: '#3498db',
   },
+  wx_work_stats_backfill: {
+    name: '视频号作品补数',
+    icon: 'DataLine',
+    color: '#07c160',
+  },
 };
 
 // WebSocket 任务事件类型