Ver Fonte

小红书数据校验完成

Ethanfly há 15 horas atrás
pai
commit
b8cc9e50b6

+ 50 - 7
client/src/views/Analytics/Work/index.vue

@@ -770,13 +770,20 @@ function formatDurationSeconds(secLike: any): string {
   return `${s}秒`;
 }
 
-/** 平均观看时长:保留 2 位小数,不取整 */
+/** 平均观看时长:保留 2 位小数(非小红书口径) */
 function formatAvgWatchDurationSeconds(secLike: any): string {
   const s = Math.max(0, parseFloat(String(secLike ?? '0')) || 0);
   if (s >= 60) return `${Math.floor(s / 60)}分${(s % 60).toFixed(2)}秒`;
   return `${s.toFixed(2)}秒`;
 }
 
+/** 小红书平均观看时长:取整(秒) */
+function formatAvgWatchDurationSecondsInt(secLike: any): string {
+  const s = Math.max(0, Math.round(parseFloat(String(secLike ?? '0')) || 0));
+  if (s >= 60) return `${Math.floor(s / 60)}分${s % 60}秒`;
+  return `${s}秒`;
+}
+
 function formatRate(rateLike: any): string {
   const raw = String(rateLike ?? '0').trim();
   if (!raw) return '0%';
@@ -789,9 +796,18 @@ function formatRate(rateLike: any): string {
 }
 
 function calcDetailRangeDatesFixed14Days(): { start: string; end: string } {
+  const publish = dayjs(selectedWork.value?.publishTime);
+
+  // 小红书:趋势固定为「发布后 14 天(含发布日)」,与页面时间范围无关
+  if (selectedWork.value?.platform === 'xiaohongshu' && publish.isValid()) {
+    const start = publish.startOf('day');
+    const end = start.add(13, 'day'); // 发布日 + 13 天 = 共 14 天
+    return { start: start.format('YYYY-MM-DD'), end: end.format('YYYY-MM-DD') };
+  }
+
+  // 其他平台:保持原逻辑(以页面筛选 endDate 为截止的近 14 天,并且不早于发布时间)
   const end = dayjs(endDate.value || dayjs().format('YYYY-MM-DD'));
   const start = end.subtract(13, 'day'); // 近14天(含当天)
-  const publish = dayjs(selectedWork.value?.publishTime);
   const clampedStart = publish.isValid() && publish.isAfter(start) ? publish : start;
   return { start: clampedStart.format('YYYY-MM-DD'), end: end.format('YYYY-MM-DD') };
 }
@@ -812,7 +828,7 @@ const xhsMetricCards = computed<MetricCardConfig[]>(() => {
     { key: 'collectCount' as const, label: '收藏量', value: formatNumber(d.collectCount || selectedWork.value?.collectsCount || 0) },
     { key: 'shareCount' as const, label: '分享量', value: formatNumber(d.shareCount || selectedWork.value?.sharesCount || 0) },
     { key: 'coverClickRate' as const, label: '封面点击率', value: formatRate(d.coverClickRate) },
-    { key: 'avgWatchDuration' as const, label: '平均观看时长', value: formatAvgWatchDurationSeconds(d.avgWatchDuration) },
+    { key: 'avgWatchDuration' as const, label: '平均观看时长', value: formatAvgWatchDurationSecondsInt(d.avgWatchDuration) },
     { key: 'completionRate' as const, label: '完播率', value: formatRate(d.completionRate) },
     { key: 'twoSecondExitRate' as const, label: '2s退出率', value: formatRate(d.twoSecondExitRate) },
     { key: 'fansIncrease' as const, label: '涨粉量', value: formatNumber(d.fansIncrease || 0) },
@@ -1012,9 +1028,13 @@ function updatePlayTrendChart(stats: Array<any>) {
   const metric = activeTrendMetric.value;
   const seriesName = trendTitle.value.replace('趋势', '');
 
+  const isRateMetric =
+    metric === 'coverClickRate' || metric === 'completionRate' || metric === 'twoSecondExitRate';
+  const isDurationMetric = metric === 'avgWatchDuration' || metric === 'totalWatchDuration';
+
   const values = sortedStats.map((s) => {
     const v = s?.[metric];
-    if (metric === 'coverClickRate' || metric === 'completionRate' || metric === 'twoSecondExitRate') {
+    if (isRateMetric) {
       const raw = String(v ?? '0').trim();
       if (raw.includes('%')) return Number(raw.replace('%', '')) || 0;
       const n = Number(raw);
@@ -1022,7 +1042,7 @@ function updatePlayTrendChart(stats: Array<any>) {
       return n <= 1 ? n * 100 : n;
     }
     if (metric === 'avgWatchDuration') {
-      return Math.max(0, parseInt(String(v ?? '0'), 10) || 0);
+      return Math.max(0, Math.round(parseFloat(String(v ?? '0')) || 0));
     }
     if (metric === 'totalWatchDuration') {
       return Math.max(0, parseInt(String(v ?? '0'), 10) || 0);
@@ -1036,6 +1056,24 @@ function updatePlayTrendChart(stats: Array<any>) {
       axisPointer: {
         type: 'cross',
       },
+      formatter: (params: any) => {
+        const arr = Array.isArray(params) ? params : [params];
+        if (arr.length === 0) return '';
+        const dateLabel = arr[0]?.axisValueLabel || arr[0]?.axisValue || '';
+        const lines: string[] = [`${dateLabel}`];
+        for (const p of arr) {
+          const name = p?.seriesName ?? '';
+          const val = typeof p?.data === 'number' ? p.data : Number(p?.data ?? 0);
+          if (isRateMetric) {
+            lines.push(`${name}:${(Number.isFinite(val) ? val : 0).toFixed(2).replace(/\.00$/, '')}%`);
+          } else if (isDurationMetric) {
+            lines.push(`${name}:${formatDurationSeconds(Number.isFinite(val) ? Math.round(val) : 0)}`);
+          } else {
+            lines.push(`${name}:${formatNumber(Number.isFinite(val) ? val : 0)}`);
+          }
+        }
+        return lines.join('<br/>');
+      },
     },
     grid: {
       left: '3%',
@@ -1057,9 +1095,14 @@ function updatePlayTrendChart(stats: Array<any>) {
       type: 'value',
       axisLabel: {
         formatter: (value: number) => {
-          if (value >= 10000) {
-            return (value / 10000).toFixed(1) + '万';
+          if (isRateMetric) {
+            const n = Number.isFinite(value) ? value : 0;
+            return `${n.toFixed(2).replace(/\.00$/, '')}%`;
+          }
+          if (isDurationMetric) {
+            return formatDurationSeconds(Number.isFinite(value) ? Math.round(value) : 0);
           }
+          if (value >= 10000) return (value / 10000).toFixed(1) + '万';
           return String(value);
         },
       },

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

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

+ 10 - 5
server/src/services/WorkService.ts

@@ -6,7 +6,7 @@ import { logger } from '../utils/logger.js';
 import { headlessBrowserService } from './HeadlessBrowserService.js';
 import { CookieManager } from '../automation/cookie.js';
 import { WorkDayStatisticsService } from './WorkDayStatisticsService.js';
-import { XiaohongshuWorkNoteStatisticsImportService } from './XiaohongshuWorkNoteStatisticsImportService.js';
+import { taskQueueService } from './TaskQueueService.js';
 
 export class WorkService {
   private workRepository = AppDataSource.getRepository(Work);
@@ -468,10 +468,15 @@ export class WorkService {
             logger.info(
               `[SyncAccountWorks] XHS account ${account.id} has ${needInitIds.length} works without statistics, running initial note/base import.`
             );
-            const svc = new XiaohongshuWorkNoteStatisticsImportService();
-            await svc.importAccountWorksStatistics(account, false, {
-              workIdFilter: needInitIds,
-              ignorePublishTimeLimit: true,
+            // 放入任务队列异步执行,避免阻塞同步作品流程
+            taskQueueService.createTask(account.userId, {
+              type: 'xhs_work_stats_backfill',
+              title: `小红书作品补数(${needInitIds.length})`,
+              accountId: account.id,
+              platform: 'xiaohongshu',
+              data: {
+                workIds: needInitIds,
+              },
             });
           }
         }

+ 58 - 3
server/src/services/XiaohongshuWorkNoteStatisticsImportService.ts

@@ -241,11 +241,17 @@ export class XiaohongshuWorkNoteStatisticsImportService {
    * 按账号同步作品日统计
    * @param options.workIdFilter 仅处理指定 workId(用于「同步作品后为新作品补首批日统计」)
    * @param options.ignorePublishTimeLimit 是否忽略「发布日期+14天」限制(用于首批补数据)
+   * @param options.ignorePublishAgeLimit 是否忽略「发布超过 1 年跳过」限制(用于首次全量补数)
    */
   async importAccountWorksStatistics(
     account: PlatformAccount,
     isRetry = false,
-    options?: { workIdFilter?: number[]; ignorePublishTimeLimit?: boolean }
+    options?: {
+      workIdFilter?: number[];
+      ignorePublishTimeLimit?: boolean;
+      ignorePublishAgeLimit?: boolean;
+      onProgress?: (payload: { index: number; total: number; work: Work }) => void;
+    }
   ): Promise<void> {
     const cookies = parseCookiesFromAccount(account.cookieData);
     if (!cookies.length) {
@@ -261,13 +267,57 @@ export class XiaohongshuWorkNoteStatisticsImportService {
       where.id = In(options.workIdFilter);
     }
 
-    const works = await this.workRepository.find({ where });
+    let works = await this.workRepository.find({ where });
 
     if (!works.length) {
       logger.info(`[XHS WorkStats] accountId=${account.id} 没有作品,跳过`);
       return;
     }
 
+    // 用户更关心最近发布的作品:按发布时间从近到远处理;无发布时间的排最后
+    works.sort((a, b) => {
+      const ta = a.publishTime ? new Date(a.publishTime).getTime() : -Infinity;
+      const tb = b.publishTime ? new Date(b.publishTime).getTime() : -Infinity;
+      if (tb !== ta) return tb - ta;
+      return (b.id || 0) - (a.id || 0);
+    });
+
+    // 定时任务优化:发布超过 1 年的作品,跳过 note/base 拉取(不更新 works.yesterday_*,也不写 work_day_statistics)
+    // 仅在首次同步作品的“全量补数”场景(ignorePublishAgeLimit=true)放开此限制
+    if (!options?.ignorePublishAgeLimit) {
+      const now = new Date();
+      const formatter = new Intl.DateTimeFormat('en-CA', {
+        timeZone: 'Asia/Shanghai',
+        year: 'numeric',
+        month: '2-digit',
+        day: '2-digit',
+      });
+      const parts = formatter.formatToParts(now);
+      const get = (type: string) => parts.find((p) => p.type === type)?.value ?? '0';
+      const y = parseInt(get('year'), 10);
+      const m = parseInt(get('month'), 10) - 1;
+      const d = parseInt(get('day'), 10);
+      const todayChina = new Date(y, m, d, 0, 0, 0, 0);
+      const cutoff = new Date(todayChina);
+      cutoff.setFullYear(cutoff.getFullYear() - 1);
+
+      const before = works.length;
+      works = works.filter((w) => {
+        if (!w.publishTime) return true; // 没有发布时间:保守起见不跳过
+        const pub = new Date(w.publishTime);
+        pub.setHours(0, 0, 0, 0);
+        return pub.getTime() >= cutoff.getTime();
+      });
+      const skipped = before - works.length;
+      if (skipped > 0) {
+        logger.info(`[XHS WorkStats] accountId=${account.id} skip old works (>1y). skipped=${skipped} remain=${works.length}`);
+      }
+      if (!works.length) {
+        logger.info(`[XHS WorkStats] accountId=${account.id} all works are older than 1y, skip.`);
+        return;
+      }
+    }
+
     const { browser, shouldClose } = await createBrowserForAccount(account.proxyConfig);
     let context: BrowserContext | null = null;
     let closedDueToLoginExpired = false;
@@ -287,7 +337,12 @@ export class XiaohongshuWorkNoteStatisticsImportService {
       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 noteId = (work.platformVideoId || '').trim();
         if (!noteId) continue;
 

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

@@ -8,6 +8,8 @@ import { CommentService } from './CommentService.js';
 import { WorkService } from './WorkService.js';
 import { AccountService } from './AccountService.js';
 import { PublishService } from './PublishService.js';
+import { XiaohongshuWorkNoteStatisticsImportService } from './XiaohongshuWorkNoteStatisticsImportService.js';
+import { AppDataSource, PlatformAccount } from '../models/index.js';
 import { logger } from '../utils/logger.js';
 
 // 创建服务实例
@@ -15,6 +17,7 @@ const commentService = new CommentService();
 const workService = new WorkService();
 const accountService = new AccountService();
 const publishService = new PublishService();
+const xhsWorkStatsService = new XiaohongshuWorkNoteStatisticsImportService();
 
 type ProgressUpdater = (update: Partial<TaskProgressUpdate>) => void;
 
@@ -211,6 +214,54 @@ async function deleteWorkExecutor(task: Task, updateProgress: ProgressUpdater):
 }
 
 /**
+ * 小红书作品首批日统计/快照补数任务执行器(不阻塞同步作品)
+ * 任务 data: { workIds: number[] }
+ */
+async function xhsWorkStatsBackfillExecutor(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: 'xiaohongshu' as any },
+  });
+  if (!account) throw new Error('未找到账号或无权限');
+
+  const total = workIds.length;
+  updateProgress({ progress: 15, currentStep: `开始补数(作品数:${total})...`, totalSteps: total });
+
+  await xhsWorkStatsService.importAccountWorksStatistics(account, false, {
+    workIdFilter: workIds,
+    ignorePublishTimeLimit: true,
+    ignorePublishAgeLimit: true,
+    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 {
@@ -219,6 +270,7 @@ export function registerTaskExecutors(): void {
   taskQueueService.registerExecutor('sync_account', syncAccountExecutor);
   taskQueueService.registerExecutor('publish_video', publishVideoExecutor);
   taskQueueService.registerExecutor('delete_work', deleteWorkExecutor);
+  taskQueueService.registerExecutor('xhs_work_stats_backfill', xhsWorkStatsBackfillExecutor);
   
   logger.info('All task executors registered');
 }

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

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