Parcourir la source

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

Ethanfly il y a 21 heures
Parent
commit
4bf396d5c7

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

@@ -15,15 +15,9 @@ declare module 'vue' {
     ElBadge: typeof import('element-plus/es')['ElBadge']
     ElButton: typeof import('element-plus/es')['ElButton']
     ElButtonGroup: typeof import('element-plus/es')['ElButtonGroup']
-    ElCascader: typeof import('element-plus/es')['ElCascader']
     ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
-    ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
-    ElCol: typeof import('element-plus/es')['ElCol']
     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']
@@ -43,7 +37,6 @@ declare module 'vue' {
     ElProgress: typeof import('element-plus/es')['ElProgress']
     ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
     ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
-    ElRow: typeof import('element-plus/es')['ElRow']
     ElSelect: typeof import('element-plus/es')['ElSelect']
     ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
     ElSwitch: typeof import('element-plus/es')['ElSwitch']
@@ -52,8 +45,6 @@ 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']

+ 31 - 13
server/src/services/BaijiahaoContentOverviewImportService.ts

@@ -285,7 +285,7 @@ function findArrayWithDateLikeField(root: any): { arr: any[]; dateKey: string }
     const s = String(v).trim();
     return /^\d{8}$/.test(s) || /^\d{4}[-/]\d{1,2}[-/]\d{1,2}$/.test(s);
   };
-  const dateKeyCandidates = ['day', 'date', 'stat_day', 'statDay', 'dt', 'time', 'the_day'];
+  const dateKeyCandidates = ['event_day', 'day', 'date', 'stat_day', 'statDay', 'dt', 'time', 'the_day'];
   const candidates: Array<{ arr: any[]; dateKey: string }> = [];
 
   while (queue.length) {
@@ -356,24 +356,24 @@ function parseBaijiahaoAppStatisticV3(json: any): Map<string, { recordDate: Date
     if (!result.has(key)) result.set(key, { recordDate: d });
     const obj = result.get(key)!;
 
-    // 阅读量 → playCount
-    const play = pickNumber(item, ['read_cnt', 'readCount', 'read', 'pv', 'view_cnt', 'viewCount', 'views']);
+    // 阅读量 → playCount(百家号 appStatisticV3 使用 view_count)
+    const play = pickNumber(item, ['view_count', 'read_cnt', 'readCount', 'read', 'pv', 'view_cnt', 'viewCount', 'views']);
     if (typeof play === 'number') (obj as any).playCount = play;
 
-    // 点赞量 → likeCount
-    const like = pickNumber(item, ['like_cnt', 'praise_cnt', 'praise', 'likeCount', 'likes']);
+    // 点赞量 → likeCount(百家号 API 使用 likes_count)
+    const like = pickNumber(item, ['likes_count', 'like_cnt', 'praise_cnt', 'praise', 'likeCount', 'likes']);
     if (typeof like === 'number') (obj as any).likeCount = like;
 
-    // 评论量 → commentCount
-    const comment = pickNumber(item, ['comment_cnt', 'commentCount', 'comments']);
+    // 评论量 → commentCount(百家号 API 使用 comment_count)
+    const comment = pickNumber(item, ['comment_count', 'comment_cnt', 'commentCount', 'comments']);
     if (typeof comment === 'number') (obj as any).commentCount = comment;
 
-    // 收藏量 → collectCount
-    const collect = pickNumber(item, ['collect_cnt', 'favorite_cnt', 'fav_cnt', 'collectCount', 'favorites']);
+    // 收藏量 → collectCount(百家号 API 字段为 collect_count)
+    const collect = pickNumber(item, ['collect_count', 'collect_cnt', 'favorite_cnt', 'fav_cnt', 'collectCount', 'favorites']);
     if (typeof collect === 'number') (obj as any).collectCount = collect;
 
-    // 分享量 → shareCount
-    const share = pickNumber(item, ['share_cnt', 'shareCount', 'shares']);
+    // 分享量 → shareCount(百家号 API 使用 share_count)
+    const share = pickNumber(item, ['share_count', 'share_cnt', 'shareCount', 'shares']);
     if (typeof share === 'number') (obj as any).shareCount = share;
 
     // 点击率 → coverClickRate
@@ -385,8 +385,8 @@ function parseBaijiahaoAppStatisticV3(json: any): Map<string, { recordDate: Date
     const clickRate = formatPercentString(clickRateRaw);
     if (clickRate) (obj as any).coverClickRate = clickRate;
 
-    // 作品涨粉量 → fansIncrease(只取涨粉
-    const fansInc = pickNumber(item, ['works_fans_inc', 'worksFansInc', 'content_fans_inc', 'fans_inc', 'fansIncrease']);
+    // 作品涨粉量 → fansIncrease(百家号 API 使用 fans_increase / fans_add_cnt
+    const fansInc = pickNumber(item, ['fans_increase', 'fans_add_cnt', 'works_fans_inc', 'worksFansInc', 'content_fans_inc', 'fans_inc', 'fansIncrease']);
     if (typeof fansInc === 'number') (obj as any).fansIncrease = fansInc;
   }
 
@@ -632,8 +632,26 @@ export class BaijiahaoContentOverviewImportService {
         }
         const json = await res.json().catch(() => null);
         if (!json) throw new Error('appStatisticV3 json parse failed');
+
+        // 调试:BJ_IMPORT_DEBUG=1 时把接口原始返回写入文件,便于对比
+        if (process.env.BJ_IMPORT_DEBUG === '1') {
+          const debugPath = path.join(this.downloadDir, `appStatisticV3_response_${account.id}_${Date.now()}.json`);
+          await ensureDir(this.downloadDir);
+          await fs.writeFile(debugPath, JSON.stringify(json, null, 2), 'utf-8');
+          logger.info(`[BJ Import] DEBUG: appStatisticV3 原始响应已写入 ${debugPath}`);
+        }
+
         const map = parseBaijiahaoAppStatisticV3(json);
         logger.info(`[BJ Import] appStatisticV3 fetched. accountId=${account.id} days=${map.size} range=${start_day}-${end_day}`);
+
+        // 调试:打印解析后指定日期的数据(如 2026-02-02)便于对比
+        if (process.env.BJ_IMPORT_DEBUG === '1' && map.size > 0) {
+          const sampleKeys = ['2026-02-02', '2026-02-01', '2026-01-16'];
+          for (const k of sampleKeys) {
+            const v = map.get(k);
+            if (v) logger.info(`[BJ Import] DEBUG: 解析后 ${k} => ${JSON.stringify(v)}`);
+          }
+        }
         return map;
       };
 

+ 3 - 0
server/src/services/HeadlessBrowserService.ts

@@ -944,6 +944,9 @@ class HeadlessBrowserService {
           info.pythonAvailable = true;
         } catch (error) {
           logger.warn(`[Python API] Failed to fetch works for baijiahao:`, error);
+          // 将 Python 侧错误抛出,让任务显示失败原因(如未登录、token 失效),而不是“共同步 0 个作品”
+          const msg = error instanceof Error ? error.message : String(error);
+          throw new Error(msg || '百家号拉取作品列表失败,请检查登录状态后重试');
         }
       }
 

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

@@ -412,6 +412,9 @@ class RedisTaskQueueService {
       publish_video: '发布视频',
       batch_reply: '批量回复评论',
       delete_work: '删除作品',
+      xhs_work_stats_backfill: '小红书作品补数',
+      dy_work_stats_backfill: '抖音作品补数',
+      bj_work_stats_backfill: '百家号作品补数',
     };
     return titles[type] || '未知任务';
   }

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

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

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

@@ -265,6 +265,16 @@ export class WorkService {
         const legacyFallbackId = `${platform}_${titleForId}_${publishTimeForId}`.substring(0, 100);
         let canonicalVideoId = (workItem.videoId || '').trim() || legacyFallbackId;
 
+        // 小红书每日同步只同步三个月内的作品(原为一年内,现改为三个月)
+        if (platform === 'xiaohongshu') {
+          const publishedAt = this.parsePublishTime(workItem.publishTime);
+          if (publishedAt) {
+            const threeMonthsAgo = new Date();
+            threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3);
+            if (publishedAt < threeMonthsAgo) continue;
+          }
+        }
+
         if (platform === 'weixin_video') {
           const rawVideoId = (workItem.videoId || '').trim();
           if (rawVideoId) {
@@ -409,6 +419,10 @@ export class WorkService {
         logger.info(`[SyncAccountWorks] Skipping local deletions for ${platform} account ${account.id} to avoid false deletions`);
         skipLocalDeletions = true;
       }
+      if (platform === 'xiaohongshu') {
+        logger.info(`[SyncAccountWorks] Skipping local deletions for ${platform} account ${account.id} (only syncing works within 3 months)`);
+        skipLocalDeletions = true;
+      }
 
       const matchedCount = localWorks.reduce(
         (sum, w) => sum + (remotePlatformVideoIds.has(w.platformVideoId) ? 1 : 0),
@@ -528,6 +542,46 @@ export class WorkService {
       }
     }
 
+    // 百家号:如果是新作品且 work_day_statistics 中尚无任何记录,则异步补齐历史日统计 & works.yesterday_*(使用 BaijiahaoWorkDailyStatisticsImportService)
+    if (platform === 'baijiahao') {
+      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] BJ account ${account.id} has ${needInitIds.length} works without statistics, enqueue bj_work_stats_backfill task.`
+            );
+            taskQueueService.createTask(account.userId, {
+              type: 'bj_work_stats_backfill',
+              title: `百家号作品补数(${needInitIds.length})`,
+              accountId: account.id,
+              platform: 'baijiahao',
+              data: {
+                workIds: needInitIds,
+              },
+            });
+          }
+        }
+      } catch (err) {
+        logger.error(
+          `[SyncAccountWorks] Failed to enqueue BJ work_day_statistics backfill for account ${account.id}:`,
+          err
+        );
+      }
+    }
+
     return {
       syncedCount,
       worksListLength: accountInfo.worksList?.length || 0,

+ 38 - 1
server/src/services/taskExecutors.ts

@@ -10,6 +10,7 @@ import { AccountService } from './AccountService.js';
 import { PublishService } from './PublishService.js';
 import { XiaohongshuWorkNoteStatisticsImportService } from './XiaohongshuWorkNoteStatisticsImportService.js';
 import { DouyinWorkStatisticsImportService } from './DouyinWorkStatisticsImportService.js';
+import { BaijiahaoWorkDailyStatisticsImportService } from './BaijiahaoWorkDailyStatisticsImportService.js';
 import { AppDataSource, PlatformAccount } from '../models/index.js';
 import { logger } from '../utils/logger.js';
 
@@ -312,6 +313,41 @@ async function dyWorkStatsBackfillExecutor(task: Task, updateProgress: ProgressU
 }
 
 /**
+ * 百家号作品日统计/快照补数(不阻塞同步作品)
+ * 任务 data: { workIds: number[] },实际执行时对整个账号运行 importAccountWorkDaily(补全所有作品的历史日统计)
+ */
+async function bjWorkStatsBackfillExecutor(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)
+    : [];
+
+  // 仅允许当前用户自己的百家号账号
+  const account = await AppDataSource.getRepository(PlatformAccount).findOne({
+    where: { id: task.accountId, userId, platform: 'baijiahao' as any },
+  });
+  if (!account) throw new Error('未找到百家号账号或无权限');
+
+  updateProgress({ progress: 15, currentStep: `开始百家号作品补数(需补数作品:${workIds.length})...` });
+
+  await BaijiahaoWorkDailyStatisticsImportService.runDailyImportForAccount(account.id);
+
+  updateProgress({ progress: 100, currentStep: '百家号作品补数完成' });
+
+  return {
+    success: true,
+    message: `百家号作品补数完成`,
+    data: { workIdsCount: workIds.length },
+  };
+}
+
+/**
  * 注册所有任务执行器
  */
 export function registerTaskExecutors(): void {
@@ -322,6 +358,7 @@ export function registerTaskExecutors(): void {
   taskQueueService.registerExecutor('delete_work', deleteWorkExecutor);
   taskQueueService.registerExecutor('xhs_work_stats_backfill', xhsWorkStatsBackfillExecutor);
   taskQueueService.registerExecutor('dy_work_stats_backfill', dyWorkStatsBackfillExecutor);
-  
+  taskQueueService.registerExecutor('bj_work_stats_backfill', bjWorkStatsBackfillExecutor);
+
   logger.info('All task executors registered');
 }

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

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