Ver Fonte

小红书同步作品数据,定时任务

Ethanfly há 1 dia atrás
pai
commit
e911174a7f

Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
client/dist-electron/main.js


Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
client/dist-electron/main.js.map


Diff do ficheiro suprimidas por serem muito extensas
+ 43 - 1
client/dist-electron/preload.js


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

@@ -40,6 +40,8 @@ declare module 'vue' {
     ElSelect: typeof import('element-plus/es')['ElSelect']
     ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
     ElSwitch: typeof import('element-plus/es')['ElSwitch']
+    ElTable: typeof import('element-plus/es')['ElTable']
+    ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
     ElTabPane: typeof import('element-plus/es')['ElTabPane']
     ElTabs: typeof import('element-plus/es')['ElTabs']
     ElTag: typeof import('element-plus/es')['ElTag']

+ 13 - 0
database/migrations/add_fields_to_work_day_statistics.sql

@@ -0,0 +1,13 @@
+-- 为 work_day_statistics 表添加扩展字段的迁移脚本
+-- 执行日期: 2026-02-02
+
+USE media_manager;
+
+ALTER TABLE work_day_statistics
+  ADD COLUMN impression_count INT DEFAULT 0 COMMENT '曝光数/展现量' AFTER play_count,
+  ADD COLUMN rise_fans_count INT DEFAULT 0 COMMENT '涨粉数' AFTER collect_count,
+  ADD COLUMN cover_click_rate VARCHAR(50) DEFAULT '0' COMMENT '封面点击率' AFTER rise_fans_count,
+  ADD COLUMN avg_watch_duration VARCHAR(50) DEFAULT '0' COMMENT '平均观看时长(秒)' AFTER cover_click_rate,
+  ADD COLUMN finish_rate VARCHAR(50) DEFAULT '0' COMMENT '完播率' AFTER avg_watch_duration,
+  ADD COLUMN exit_view2s_rate VARCHAR(50) DEFAULT '0' COMMENT '2秒退出率' AFTER finish_rate;
+

+ 7 - 0
database/migrations/add_total_watch_duration_to_work_day_statistics.sql

@@ -0,0 +1,7 @@
+-- 为 work_day_statistics 表添加 total_watch_duration(总观看时长)字段
+-- 执行日期: 2026-02-02
+
+USE media_manager;
+
+ALTER TABLE work_day_statistics
+  ADD COLUMN total_watch_duration VARCHAR(50) DEFAULT '0' COMMENT '总观看时长(秒)' AFTER avg_watch_duration;

+ 16 - 0
database/migrations/clean_work_day_statistics_orphans_and_add_fk.sql

@@ -0,0 +1,16 @@
+-- 1. 清理 work_day_statistics 中 work_id 在 works 表不存在的孤儿数据
+-- 2. 为 work_day_statistics.work_id 添加外键,删除 works 时级联删除对应日统计
+-- 执行日期: 2026-02-02
+
+USE media_manager;
+
+-- 删除孤儿数据:work_day_statistics 中 work_id 在 works 里不存在的记录
+DELETE wds
+FROM work_day_statistics wds
+LEFT JOIN works w ON wds.work_id = w.id
+WHERE w.id IS NULL;
+
+-- 添加外键:删除 works 时自动删除对应 work_day_statistics
+ALTER TABLE work_day_statistics
+  ADD CONSTRAINT fk_work_day_statistics_work
+  FOREIGN KEY (work_id) REFERENCES works(id) ON DELETE CASCADE;

+ 12 - 0
database/migrations/rename_work_day_statistics_columns.sql

@@ -0,0 +1,12 @@
+-- 将 work_day_statistics 表字段名改为与 API 口径一致
+-- 执行日期: 2026-02-02
+-- 若已执行过 add_fields_to_work_day_statistics.sql,用本脚本重命名列
+
+USE media_manager;
+
+-- 若列名为旧名则重命名(MySQL 5.7+ 用 CHANGE COLUMN)
+ALTER TABLE work_day_statistics
+  CHANGE COLUMN impression_count exposure_count INT DEFAULT 0 COMMENT '曝光数/展现量',
+  CHANGE COLUMN rise_fans_count fans_increase INT DEFAULT 0 COMMENT '涨粉数',
+  CHANGE COLUMN finish_rate completion_rate VARCHAR(50) DEFAULT '0' COMMENT '完播率',
+  CHANGE COLUMN exit_view2s_rate two_second_exit_rate VARCHAR(50) DEFAULT '0' COMMENT '2秒退出率';

+ 35 - 1
database/schema.sql

@@ -185,21 +185,55 @@ CREATE TABLE IF NOT EXISTS operation_logs (
     INDEX idx_log_user_time (user_id, created_at)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
 
+-- 作品表(关联平台账号)
+CREATE TABLE IF NOT EXISTS works (
+    id INT PRIMARY KEY AUTO_INCREMENT,
+    userId INT NOT NULL,
+    accountId INT NOT NULL,
+    platform VARCHAR(20) NOT NULL,
+    platform_video_id VARCHAR(500) NOT NULL,
+    title VARCHAR(200) DEFAULT '',
+    description TEXT,
+    cover_url VARCHAR(500) DEFAULT '',
+    video_url VARCHAR(500),
+    duration VARCHAR(20) DEFAULT '00:00',
+    status VARCHAR(20) DEFAULT 'published',
+    publish_time DATETIME,
+    play_count INT DEFAULT 0,
+    like_count INT DEFAULT 0,
+    comment_count INT DEFAULT 0,
+    share_count INT DEFAULT 0,
+    collect_count INT DEFAULT 0,
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    INDEX idx_user_platform (userId, platform),
+    UNIQUE KEY uk_account_platform_video (accountId, platform_video_id),
+    FOREIGN KEY (accountId) REFERENCES platform_accounts(id) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
 -- 作品每日统计表(记录每天的数据快照)
 CREATE TABLE IF NOT EXISTS work_day_statistics (
     id INT PRIMARY KEY AUTO_INCREMENT,
     work_id INT NOT NULL,
     record_date DATE NOT NULL,
     play_count INT DEFAULT 0 COMMENT '播放数',
+    exposure_count INT DEFAULT 0 COMMENT '曝光数/展现量',
     like_count INT DEFAULT 0 COMMENT '点赞数',
     comment_count INT DEFAULT 0 COMMENT '评论数',
     share_count INT DEFAULT 0 COMMENT '分享数',
     collect_count INT DEFAULT 0 COMMENT '收藏数',
+    fans_increase INT DEFAULT 0 COMMENT '涨粉数',
+    cover_click_rate VARCHAR(50) DEFAULT '0' COMMENT '封面点击率',
+    avg_watch_duration VARCHAR(50) DEFAULT '0' COMMENT '平均观看时长(秒)',
+    total_watch_duration VARCHAR(50) DEFAULT '0' COMMENT '总观看时长(秒)',
+    completion_rate VARCHAR(50) DEFAULT '0' COMMENT '完播率',
+    two_second_exit_rate VARCHAR(50) DEFAULT '0' COMMENT '2秒退出率',
     created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
     updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
     UNIQUE KEY uk_work_date (work_id, record_date),
     INDEX idx_work_id (work_id),
-    INDEX idx_record_date (record_date)
+    INDEX idx_record_date (record_date),
+    FOREIGN KEY (work_id) REFERENCES works(id) ON DELETE CASCADE
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='作品每日统计数据';
 
 -- 账号每日统计表(记录每个平台账号的粉丝数和作品数,不关联作品)

+ 2 - 0
server/package.json

@@ -7,7 +7,9 @@
   "scripts": {
     "dev": "tsx watch src/app.ts",
     "xhs:import": "tsx src/scripts/run-xhs-import.ts",
+    "xhs:work-stats": "tsx src/scripts/run-xhs-work-stats-import.ts",
     "check:trend": "tsx src/scripts/check-trend-data.ts",
+    "clean:work-day-orphans": "tsx src/scripts/clean-work-day-statistics-orphans.ts",
     "xhs:auth": "set XHS_IMPORT_HEADLESS=0&& set XHS_STORAGE_STATE_BOOTSTRAP=1&& tsx src/scripts/run-xhs-import.ts",
     "build": "tsc",
     "start": "node dist/app.js",

+ 33 - 0
server/src/models/entities/WorkDayStatistics.ts

@@ -15,6 +15,9 @@ export class WorkDayStatistics {
   @Column({ name: 'play_count', type: 'int', default: 0, comment: '播放数' })
   playCount!: number;
 
+  @Column({ name: 'exposure_count', type: 'int', default: 0, comment: '曝光数/展现量' })
+  exposureCount!: number;
+
   @Column({ name: 'like_count', type: 'int', default: 0, comment: '点赞数' })
   likeCount!: number;
 
@@ -27,6 +30,36 @@ export class WorkDayStatistics {
   @Column({ name: 'collect_count', type: 'int', default: 0, comment: '收藏数' })
   collectCount!: number;
 
+  @Column({ name: 'fans_increase', type: 'int', default: 0, comment: '涨粉数' })
+  fansIncrease!: number;
+
+  @Column({ name: 'cover_click_rate', type: 'varchar', length: 50, default: '0', comment: '封面点击率' })
+  coverClickRate!: string;
+
+  @Column({
+    name: 'avg_watch_duration',
+    type: 'varchar',
+    length: 50,
+    default: '0',
+    comment: '平均观看时长(秒)',
+  })
+  avgWatchDuration!: string;
+
+  @Column({
+    name: 'total_watch_duration',
+    type: 'varchar',
+    length: 50,
+    default: '0',
+    comment: '总观看时长(秒)',
+  })
+  totalWatchDuration!: string;
+
+  @Column({ name: 'completion_rate', type: 'varchar', length: 50, default: '0', comment: '完播率' })
+  completionRate!: string;
+
+  @Column({ name: 'two_second_exit_rate', type: 'varchar', length: 50, default: '0', comment: '2秒退出率' })
+  twoSecondExitRate!: string;
+
   @Column({ type: 'datetime', name: 'created_at', default: () => 'CURRENT_TIMESTAMP' })
   createdAt!: Date;
 

+ 17 - 0
server/src/routes/internal.ts

@@ -53,10 +53,27 @@ router.post(
       workId: item.workId ?? item.work_id,
       fansCount: item.fansCount ?? item.fans_count ?? 0,
       playCount: item.playCount ?? item.play_count ?? 0,
+      exposureCount:
+        item.exposureCount ??
+        item.exposure_count ??
+        item.impressionCount ??
+        item.impression_count ??
+        0,
       likeCount: item.likeCount ?? item.like_count ?? 0,
       commentCount: item.commentCount ?? item.comment_count ?? 0,
       shareCount: item.shareCount ?? item.share_count ?? 0,
       collectCount: item.collectCount ?? item.collect_count ?? 0,
+      fansIncrease:
+        item.fansIncrease ??
+        item.fans_increase ??
+        item.riseFansCount ??
+        item.rise_fans_count ??
+        0,
+      coverClickRate: item.coverClickRate ?? item.cover_click_rate,
+      avgWatchDuration: item.avgWatchDuration ?? item.avg_watch_duration,
+      totalWatchDuration: item.totalWatchDuration ?? item.total_watch_duration ?? '0',
+      completionRate: item.completionRate ?? item.completion_rate ?? item.finish_rate ?? item.full_view_rate,
+      twoSecondExitRate: item.twoSecondExitRate ?? item.two_second_exit_rate ?? item.exit_view2s_rate,
     }));
 
     const result = await workDayStatisticsService.saveStatistics(statistics);

+ 29 - 0
server/src/scheduler/index.ts

@@ -10,6 +10,7 @@ import { XiaohongshuAccountOverviewImportService } from '../services/Xiaohongshu
 import { DouyinAccountOverviewImportService } from '../services/DouyinAccountOverviewImportService.js';
 import { BaijiahaoContentOverviewImportService } from '../services/BaijiahaoContentOverviewImportService.js';
 import { WeixinVideoDataCenterImportService } from '../services/WeixinVideoDataCenterImportService.js';
+import { XiaohongshuWorkNoteStatisticsImportService } from '../services/XiaohongshuWorkNoteStatisticsImportService.js';
 
 /**
  * 定时任务调度器
@@ -18,6 +19,7 @@ export class TaskScheduler {
   private jobs: Map<string, schedule.Job> = new Map();
   private isRefreshingAccounts = false; // 账号刷新锁,防止任务重叠执行
   private isXhsImportRunning = false; // 小红书导入锁,防止任务重叠执行
+  private isXhsWorkImportRunning = false; // 小红书作品日统计导入锁
   private isDyImportRunning = false; // 抖音导入锁,防止任务重叠执行
   private isBjImportRunning = false; // 百家号导入锁,防止任务重叠执行
   private isWxImportRunning = false; // 视频号导入锁,防止任务重叠执行
@@ -39,6 +41,13 @@ export class TaskScheduler {
     // 注意:node-schedule 使用服务器本地时区
     this.scheduleJob('xhs-account-overview-import', '0 12 * * *', this.importXhsAccountOverviewLast30Days.bind(this));
 
+    // 每天 12:40:同步小红书作品维度的「笔记详情-按天」数据,写入 work_day_statistics
+    this.scheduleJob(
+      'xhs-work-note-statistics-import',
+      '40 12 * * *',
+      this.importXhsWorkNoteStatistics.bind(this)
+    );
+
     // 每天 12:10:批量导出抖音“数据中心-账号总览-短视频-数据表现-近30天”,导入 user_day_statistics
     this.scheduleJob('dy-account-overview-import', '10 12 * * *', this.importDyAccountOverviewLast30Days.bind(this));
 
@@ -58,6 +67,9 @@ export class TaskScheduler {
     logger.info('[Scheduler] Scheduled jobs:');
     logger.info('[Scheduler]   - check-publish-tasks: every minute (* * * * *)');
     logger.info('[Scheduler]   - xhs-account-overview-import: daily at 12:00 (0 12 * * *)');
+    logger.info(
+      '[Scheduler]   - xhs-work-note-statistics-import: daily at 12:40 (40 12 * * *)'
+    );
     logger.info('[Scheduler]   - dy-account-overview-import:  daily at 12:10 (10 12 * * *)');
     logger.info('[Scheduler]   - bj-content-overview-import: daily at 12:20 (20 12 * * *)');
     logger.info('[Scheduler]   - wx-video-data-center-import: daily at 12:30 (30 12 * * *)');
@@ -337,6 +349,23 @@ export class TaskScheduler {
   }
 
   /**
+   * 小红书:作品维度「笔记详情-按天」→ 导入 work_day_statistics
+   */
+  private async importXhsWorkNoteStatistics(): Promise<void> {
+    if (this.isXhsWorkImportRunning) {
+      logger.info('[Scheduler] XHS work note statistics import is already running, skipping...');
+      return;
+    }
+
+    this.isXhsWorkImportRunning = true;
+    try {
+      await XiaohongshuWorkNoteStatisticsImportService.runDailyImport();
+    } finally {
+      this.isXhsWorkImportRunning = false;
+    }
+  }
+
+  /**
    * 抖音:账号总览-短视频-数据表现导出(近30天)→ 导入 user_day_statistics
    */
   private async importDyAccountOverviewLast30Days(): Promise<void> {

+ 51 - 0
server/src/scripts/clean-work-day-statistics-orphans.ts

@@ -0,0 +1,51 @@
+/**
+ * 清理 work_day_statistics 表中的孤儿数据(work_id 在 works 表中不存在的记录),
+ * 执行后可运行 database/migrations/clean_work_day_statistics_orphans_and_add_fk.sql 添加外键。
+ */
+import { initDatabase, AppDataSource } from '../models/index.js';
+import { logger } from '../utils/logger.js';
+
+async function main() {
+  try {
+    await initDatabase();
+    const qr = AppDataSource.createQueryRunner();
+    await qr.connect();
+
+    try {
+      // 统计孤儿数据
+      const countResult = await qr.query(
+        `SELECT COUNT(*) AS cnt FROM work_day_statistics wds
+         LEFT JOIN works w ON wds.work_id = w.id
+         WHERE w.id IS NULL`
+      );
+      const orphanCount = Number(countResult?.[0]?.cnt ?? 0);
+
+      if (orphanCount === 0) {
+        logger.info('[CleanOrphans] work_day_statistics 中无孤儿数据。');
+      } else {
+        logger.info(`[CleanOrphans] 发现 ${orphanCount} 条孤儿数据,正在删除...`);
+        const deleteResult = await qr.query(
+          `DELETE wds FROM work_day_statistics wds
+           LEFT JOIN works w ON wds.work_id = w.id
+           WHERE w.id IS NULL`
+        );
+        const affected = (deleteResult as { affectedRows?: number })?.affectedRows ?? orphanCount;
+        logger.info(`[CleanOrphans] 已删除 ${affected} 条孤儿数据。`);
+      }
+
+      // 提示添加外键
+      logger.info(
+        '[CleanOrphans] 若需为 work_day_statistics.work_id 添加外键(删除 works 时级联删除),请执行:database/migrations/clean_work_day_statistics_orphans_and_add_fk.sql'
+      );
+    } finally {
+      await qr.release();
+    }
+
+    process.exit(0);
+  } catch (e) {
+    logger.error('[CleanOrphans] 执行失败:', e);
+    process.exit(1);
+  }
+}
+
+void main();

+ 18 - 0
server/src/scripts/run-xhs-work-stats-import.ts

@@ -0,0 +1,18 @@
+import { initDatabase } from '../models/index.js';
+import { logger } from '../utils/logger.js';
+import { XiaohongshuWorkNoteStatisticsImportService } from '../services/XiaohongshuWorkNoteStatisticsImportService.js';
+
+async function main() {
+  try {
+    await initDatabase();
+    logger.info('[XHS WorkStats] Manual run start...');
+    await XiaohongshuWorkNoteStatisticsImportService.runDailyImport();
+    logger.info('[XHS WorkStats] Manual run done.');
+    process.exit(0);
+  } catch (e) {
+    logger.error('[XHS WorkStats] Manual run failed:', e);
+    process.exit(1);
+  }
+}
+
+void main();

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

@@ -96,6 +96,7 @@ export interface WorkItem {
   likeCount: number;
   commentCount: number;
   shareCount: number;
+  collectCount?: number;
 }
 
 export interface CommentItem {
@@ -795,6 +796,7 @@ class HeadlessBrowserService {
         likeCount: work.like_count || 0,
         commentCount: work.comment_count || 0,
         shareCount: work.share_count || 0,
+        collectCount: work.collect_count ?? 0,
       }));
 
       let newCount = 0;

+ 130 - 5
server/src/services/WorkDayStatisticsService.ts

@@ -5,10 +5,17 @@ import { logger } from '../utils/logger.js';
 interface StatisticsItem {
   workId: number;
   playCount?: number;
+  exposureCount?: number;
   likeCount?: number;
   commentCount?: number;
   shareCount?: number;
   collectCount?: number;
+  fansIncrease?: number;
+  coverClickRate?: string;
+  avgWatchDuration?: string;
+  totalWatchDuration?: string;
+  completionRate?: string;
+  twoSecondExitRate?: string;
 }
 
 interface SaveResult {
@@ -66,6 +73,15 @@ export class WorkDayStatisticsService {
   }
 
   /**
+   * 按作品 ID 删除该作品的所有每日统计(work 被删除时调用,或用于清理孤儿数据)
+   * @returns 被删除的行数
+   */
+  async deleteByWorkId(workId: number): Promise<number> {
+    const result = await this.statisticsRepository.delete({ workId });
+    return result.affected ?? 0;
+  }
+
+  /**
    * 获取某个账号在指定日期(<= targetDate)时各作品的“最新一条”累计数据总和
    * 口径:对该账号所有作品,每个作品取 record_date <= targetDate 的最大日期那条记录,然后把 play/like/comment/collect 求和
    */
@@ -108,12 +124,11 @@ export class WorkDayStatisticsService {
   }
 
   /**
-   * 保存作品日统计数据
+   * 保存作品日统计数据(按「今天」的中国时间日历日)
    * 当天的数据走更新流,日期变化走新增流
    */
   async saveStatistics(statistics: StatisticsItem[]): Promise<SaveResult> {
-    const today = new Date();
-    today.setHours(0, 0, 0, 0);
+    const today = this.getTodayInChina();
 
     let insertedCount = 0;
     let updatedCount = 0;
@@ -130,25 +145,39 @@ export class WorkDayStatisticsService {
       });
 
       if (existing) {
-        // 更新已有记录(不再包含粉丝数,粉丝数从 user_day_statistics 表获取)
+        // 更新已有记录
         await this.statisticsRepository.update(existing.id, {
           playCount: stat.playCount ?? existing.playCount,
+          exposureCount: stat.exposureCount ?? existing.exposureCount,
           likeCount: stat.likeCount ?? existing.likeCount,
           commentCount: stat.commentCount ?? existing.commentCount,
           shareCount: stat.shareCount ?? existing.shareCount,
           collectCount: stat.collectCount ?? existing.collectCount,
+          fansIncrease: stat.fansIncrease ?? existing.fansIncrease,
+          coverClickRate: stat.coverClickRate ?? existing.coverClickRate ?? '0',
+          avgWatchDuration: stat.avgWatchDuration ?? existing.avgWatchDuration ?? '0',
+          totalWatchDuration: stat.totalWatchDuration ?? existing.totalWatchDuration ?? '0',
+          completionRate: stat.completionRate ?? existing.completionRate ?? '0',
+          twoSecondExitRate: stat.twoSecondExitRate ?? existing.twoSecondExitRate ?? '0',
         });
         updatedCount++;
       } else {
-        // 插入新记录(不再包含粉丝数,粉丝数从 user_day_statistics 表获取)
+        // 插入新记录
         const newStat = this.statisticsRepository.create({
           workId: stat.workId,
           recordDate: today,
           playCount: stat.playCount ?? 0,
+          exposureCount: stat.exposureCount ?? 0,
           likeCount: stat.likeCount ?? 0,
           commentCount: stat.commentCount ?? 0,
           shareCount: stat.shareCount ?? 0,
           collectCount: stat.collectCount ?? 0,
+          fansIncrease: stat.fansIncrease ?? 0,
+          coverClickRate: stat.coverClickRate ?? '0',
+          avgWatchDuration: stat.avgWatchDuration ?? '0',
+          totalWatchDuration: stat.totalWatchDuration ?? '0',
+          completionRate: stat.completionRate ?? '0',
+          twoSecondExitRate: stat.twoSecondExitRate ?? '0',
         });
         await this.statisticsRepository.save(newStat);
         insertedCount++;
@@ -158,6 +187,102 @@ export class WorkDayStatisticsService {
     return { inserted: insertedCount, updated: updatedCount };
   }
 
+  /**
+   * 获取「中国时区(Asia/Shanghai)当前日历日」的 Date
+   * 用于存储 record_date,避免服务器时区与业务日期不一致
+   */
+  private getTodayInChina(): Date {
+    const formatter = new Intl.DateTimeFormat('en-CA', {
+      timeZone: 'Asia/Shanghai',
+      year: 'numeric',
+      month: '2-digit',
+      day: '2-digit',
+    });
+    const parts = formatter.formatToParts(new Date());
+    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);
+    return new Date(Date.UTC(y, m, d, 0, 0, 0, 0));
+  }
+
+  /**
+   * 保存指定日期的作品日统计数据(按 workId + recordDate 维度 upsert)
+   * 说明:recordDate 会被归零到当天 00:00:00(本地时间),避免主键冲突
+   */
+  async saveStatisticsForDate(
+    workId: number,
+    recordDate: Date,
+    patch: Omit<StatisticsItem, 'workId'>
+  ): Promise<SaveResult> {
+    const d = new Date(recordDate);
+    d.setHours(0, 0, 0, 0);
+
+    const existing = await this.statisticsRepository.findOne({
+      where: { workId, recordDate: d },
+    });
+
+    if (existing) {
+      await this.statisticsRepository.update(existing.id, {
+        playCount: patch.playCount ?? existing.playCount,
+        exposureCount: patch.exposureCount ?? existing.exposureCount,
+        likeCount: patch.likeCount ?? existing.likeCount,
+        commentCount: patch.commentCount ?? existing.commentCount,
+        shareCount: patch.shareCount ?? existing.shareCount,
+        collectCount: patch.collectCount ?? existing.collectCount,
+        fansIncrease: patch.fansIncrease ?? existing.fansIncrease,
+        coverClickRate: patch.coverClickRate ?? existing.coverClickRate ?? '0',
+        avgWatchDuration: patch.avgWatchDuration ?? existing.avgWatchDuration ?? '0',
+        totalWatchDuration: patch.totalWatchDuration ?? existing.totalWatchDuration ?? '0',
+        completionRate: patch.completionRate ?? existing.completionRate ?? '0',
+        twoSecondExitRate: patch.twoSecondExitRate ?? existing.twoSecondExitRate ?? '0',
+      });
+      return { inserted: 0, updated: 1 };
+    }
+
+    const newStat = this.statisticsRepository.create({
+      workId,
+      recordDate: d,
+      playCount: patch.playCount ?? 0,
+      exposureCount: patch.exposureCount ?? 0,
+      likeCount: patch.likeCount ?? 0,
+      commentCount: patch.commentCount ?? 0,
+      shareCount: patch.shareCount ?? 0,
+      collectCount: patch.collectCount ?? 0,
+      fansIncrease: patch.fansIncrease ?? 0,
+      coverClickRate: patch.coverClickRate ?? '0',
+      avgWatchDuration: patch.avgWatchDuration ?? '0',
+      totalWatchDuration: patch.totalWatchDuration ?? '0',
+      completionRate: patch.completionRate ?? '0',
+      twoSecondExitRate: patch.twoSecondExitRate ?? '0',
+    });
+
+    await this.statisticsRepository.save(newStat);
+    return { inserted: 1, updated: 0 };
+  }
+
+  /**
+   * 批量保存指定日期范围的作品日统计数据(每条记录自带日期)
+   */
+  async saveStatisticsForDateBatch(
+    items: Array<{ workId: number; recordDate: Date } & Omit<StatisticsItem, 'workId'>>
+  ): Promise<SaveResult> {
+    let insertedCount = 0;
+    let updatedCount = 0;
+
+    for (const it of items) {
+      const { workId, recordDate, ...patch } = it;
+      const result = await this.saveStatisticsForDate(workId, recordDate, patch);
+      insertedCount += result.inserted;
+      updatedCount += result.updated;
+    }
+
+    logger.info(
+      `[WorkDayStatistics] Date-batch save completed: inserted=${insertedCount}, updated=${updatedCount}`
+    );
+    return { inserted: insertedCount, updated: updatedCount };
+  }
+
   // 平台名称映射
   private platformNameMap: Record<string, string> = {
     xiaohongshu: '小红书',

+ 17 - 1
server/src/services/WorkService.ts

@@ -11,6 +11,7 @@ export class WorkService {
   private workRepository = AppDataSource.getRepository(Work);
   private accountRepository = AppDataSource.getRepository(PlatformAccount);
   private commentRepository = AppDataSource.getRepository(Comment);
+  private workDayStatisticsService = new WorkDayStatisticsService();
 
   /**
    * 获取作品列表
@@ -299,6 +300,7 @@ export class WorkService {
               { workId: legacyWork.id },
               { workId: work.id }
             );
+            await this.workDayStatisticsService.deleteByWorkId(legacyWork.id);
             await this.workRepository.delete(legacyWork.id);
           }
         }
@@ -318,6 +320,7 @@ export class WorkService {
                 { workId: legacyWork.id },
                 { workId: canonicalWork.id }
               );
+              await this.workDayStatisticsService.deleteByWorkId(legacyWork.id);
               await this.workRepository.delete(legacyWork.id);
               work = canonicalWork;
             } else {
@@ -339,6 +342,7 @@ export class WorkService {
             likeCount: workItem.likeCount ?? work.likeCount,
             commentCount: workItem.commentCount ?? work.commentCount,
             shareCount: workItem.shareCount ?? work.shareCount,
+            collectCount: workItem.collectCount ?? work.collectCount,
           });
         } else {
           // 创建新作品
@@ -356,6 +360,7 @@ export class WorkService {
             likeCount: workItem.likeCount || 0,
             commentCount: workItem.commentCount || 0,
             shareCount: workItem.shareCount || 0,
+            collectCount: workItem.collectCount || 0,
           });
 
           await this.workRepository.save(work);
@@ -419,6 +424,7 @@ export class WorkService {
         for (const localWork of localWorks) {
           if (!remotePlatformVideoIds.has(localWork.platformVideoId)) {
             await AppDataSource.getRepository(Comment).delete({ workId: localWork.id });
+            await this.workDayStatisticsService.deleteByWorkId(localWork.id);
             await this.workRepository.delete(localWork.id);
             deletedCount++;
             logger.info(`Deleted work ${localWork.id} (${localWork.title}) - no longer exists on platform`);
@@ -451,6 +457,15 @@ 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 },
@@ -537,8 +552,9 @@ export class WorkService {
       throw new AppError('作品不存在', HTTP_STATUS.NOT_FOUND, ERROR_CODES.NOT_FOUND);
     }
 
-    // 先删除关联的评论
+    // 先删除关联的评论和作品每日统计
     await AppDataSource.getRepository(Comment).delete({ workId });
+    await this.workDayStatisticsService.deleteByWorkId(workId);
 
     // 删除作品
     await this.workRepository.delete(workId);

+ 3 - 1
server/src/services/XiaohongshuAccountOverviewImportService.ts

@@ -192,7 +192,7 @@ async function createBrowserForAccount(proxy: ProxyConfig | null): Promise<{ bro
   return { browser, shouldClose: false };
 }
 
-function parseXhsExcel(
+export function parseXhsExcel(
   filePath: string,
   mode: ExportMode
 ): Map<string, { recordDate: Date } & Record<string, any>> {
@@ -259,6 +259,8 @@ function parseXhsExcel(
   return result;
 }
 
+export { parseCookiesFromAccount, createBrowserForAccount };
+
 export class XiaohongshuAccountOverviewImportService {
   private accountRepository = AppDataSource.getRepository(PlatformAccount);
   private userDayStatisticsService = new UserDayStatisticsService();

+ 483 - 0
server/src/services/XiaohongshuWorkNoteStatisticsImportService.ts

@@ -0,0 +1,483 @@
+import { chromium, type Browser, type BrowserContext, type Page } from 'playwright';
+import { AppDataSource, PlatformAccount, Work } from '../models/index.js';
+import { logger } from '../utils/logger.js';
+import { WorkDayStatisticsService } from './WorkDayStatisticsService.js';
+import { AccountService } from './AccountService.js';
+import type { ProxyConfig } from '@media-manager/shared';
+import { BrowserManager } from '../automation/browser.js';
+
+/** 小红书笔记详情页跳转到登录时抛出,用于触发「先刷新登录、再决定是否账号失效」 */
+export class XhsLoginExpiredError extends Error {
+  constructor() {
+    super('XHS_LOGIN_EXPIRED');
+    this.name = 'XhsLoginExpiredError';
+  }
+}
+
+type PlaywrightCookie = {
+  name: string;
+  value: string;
+  domain?: string;
+  path?: string;
+  url?: string;
+  expires?: number;
+  httpOnly?: boolean;
+  secure?: boolean;
+  sameSite?: 'Lax' | 'None' | 'Strict';
+};
+
+interface NoteTrendItem {
+  date?: number | string;
+  count?: number | string;
+  count_with_double?: number | string;
+  /** API 返回的比率数值字段(用于 cover_click_rate、two_second_exit_rate、completion_rate 等,存时加 "%") */
+  coun?: number | string;
+}
+
+interface NoteDaySection {
+  view_list?: NoteTrendItem[];
+  like_list?: NoteTrendItem[];
+  comment_list?: NoteTrendItem[];
+  share_list?: NoteTrendItem[];
+  collect_list?: NoteTrendItem[];
+  rise_fans_list?: NoteTrendItem[];
+  imp_list?: NoteTrendItem[];
+  cover_click_rate_list?: NoteTrendItem[];
+  exit_view2s_list?: NoteTrendItem[];
+  view_time_list?: NoteTrendItem[];
+  finish_list?: NoteTrendItem[];
+}
+
+interface NoteBaseData {
+  day?: NoteDaySection;
+}
+
+interface DailyWorkStatPatch {
+  workId: number;
+  recordDate: Date;
+  playCount?: number;
+  exposureCount?: number;
+  likeCount?: number;
+  commentCount?: number;
+  shareCount?: number;
+  collectCount?: number;
+  fansIncrease?: number;
+  coverClickRate?: string;
+  avgWatchDuration?: string;
+  totalWatchDuration?: string;
+  completionRate?: string;
+  twoSecondExitRate?: string;
+}
+
+function parseCookiesFromAccount(cookieData: string | null): PlaywrightCookie[] {
+  if (!cookieData) return [];
+  const raw = cookieData.trim();
+  if (!raw) return [];
+
+  try {
+    const parsed = JSON.parse(raw) as PlaywrightCookie[];
+    if (Array.isArray(parsed)) {
+      return parsed.map((c) => ({
+        ...c,
+        url: c.url || 'https://creator.xiaohongshu.com',
+      }));
+    }
+  } catch {
+    // fallthrough
+  }
+
+  const pairs = raw.split(';').map((p) => p.trim()).filter(Boolean);
+  const cookies: PlaywrightCookie[] = [];
+  for (const p of pairs) {
+    const idx = p.indexOf('=');
+    if (idx <= 0) continue;
+    const name = p.slice(0, idx).trim();
+    const value = p.slice(idx + 1).trim();
+    if (!name) continue;
+    cookies.push({ name, value, url: 'https://creator.xiaohongshu.com' });
+  }
+  return cookies;
+}
+
+async function createBrowserForAccount(proxy: ProxyConfig | null): Promise<{ browser: Browser; shouldClose: boolean }> {
+  const headless = true;
+  if (proxy?.enabled) {
+    const server = `${proxy.type}://${proxy.host}:${proxy.port}`;
+    const browser = await chromium.launch({
+      headless,
+      proxy: {
+        server,
+        username: proxy.username,
+        password: proxy.password,
+      },
+      args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu', '--window-size=1920,1080'],
+    });
+    return { browser, shouldClose: true };
+  }
+  const browser = await BrowserManager.getBrowser({ headless });
+  return { browser, shouldClose: false };
+}
+
+function getChinaDateFromTimestamp(ts: number): Date {
+  const d = new Date(ts);
+  const formatter = new Intl.DateTimeFormat('en-CA', {
+    timeZone: 'Asia/Shanghai',
+    year: 'numeric',
+    month: '2-digit',
+    day: '2-digit',
+  });
+  const parts = formatter.formatToParts(d);
+  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 day = parseInt(get('day'), 10);
+  return new Date(y, m, day, 0, 0, 0, 0);
+}
+
+function toNumber(val: unknown, defaultValue = 0): number {
+  if (typeof val === 'number') return Number.isFinite(val) ? val : defaultValue;
+  if (typeof val === 'string') {
+    const n = Number(val);
+    return Number.isFinite(n) ? n : defaultValue;
+  }
+  return defaultValue;
+}
+
+function toRateString(val: unknown): string | undefined {
+  const n = toNumber(val, NaN);
+  if (!Number.isFinite(n)) return undefined;
+  return n.toString();
+}
+
+/** 从 item 取 coun(或 count)转为字符串并追加 "%",用于比率类字段 */
+function toRatePercentString(item: NoteTrendItem): string | undefined {
+  const raw = item?.coun ?? item?.count;
+  const n = toNumber(raw, NaN);
+  if (!Number.isFinite(n)) return undefined;
+  return `${n}%`;
+}
+
+export class XiaohongshuWorkNoteStatisticsImportService {
+  private accountRepository = AppDataSource.getRepository(PlatformAccount);
+  private workRepository = AppDataSource.getRepository(Work);
+  private workDayStatisticsService = new WorkDayStatisticsService();
+
+  /**
+   * 统一入口:定时任务调用,批量为所有小红书账号同步作品日统计
+   */
+  static async runDailyImport(): Promise<void> {
+    const svc = new XiaohongshuWorkNoteStatisticsImportService();
+    await svc.runDailyImportForAllXhsAccounts();
+  }
+
+  async runDailyImportForAllXhsAccounts(): Promise<void> {
+    const accounts = await this.accountRepository.find({
+      where: { platform: 'xiaohongshu' as any },
+    });
+
+    logger.info(`[XHS WorkStats] Start import for ${accounts.length} accounts`);
+
+    for (const account of accounts) {
+      try {
+        await this.importAccountWorksStatistics(account);
+      } catch (e) {
+        logger.error(
+          `[XHS WorkStats] Account failed. accountId=${account.id} name=${account.accountName || ''}`,
+          e
+        );
+        // 单账号失败(含登录失效、刷新失败等)仅记录日志,不中断循环,其他账号照常同步
+      }
+    }
+
+    logger.info('[XHS WorkStats] All accounts done');
+  }
+
+  /**
+   * 按账号同步作品日统计。检测到登录失效时:先尝试刷新登录一次;刷新仍失效则执行账号失效,刷新成功则用新 cookie 重试一次。
+   * @param isRetry 是否为「刷新登录后的重试」,避免无限递归
+   */
+  private async importAccountWorksStatistics(account: PlatformAccount, isRetry = false): Promise<void> {
+    const cookies = parseCookiesFromAccount(account.cookieData);
+    if (!cookies.length) {
+      logger.warn(`[XHS WorkStats] accountId=${account.id} cookieData 为空或无法解析,跳过`);
+      return;
+    }
+
+    const works = await this.workRepository.find({
+      where: {
+        accountId: account.id,
+        platform: 'xiaohongshu' as any,
+      },
+    });
+
+    if (!works.length) {
+      logger.info(`[XHS WorkStats] accountId=${account.id} 没有作品,跳过`);
+      return;
+    }
+
+    const { browser, shouldClose } = await createBrowserForAccount(account.proxyConfig);
+    let context: BrowserContext | null = null;
+    let closedDueToLoginExpired = false;
+    try {
+      context = await browser.newContext({
+        viewport: { width: 1920, height: 1080 },
+        locale: 'zh-CN',
+        timezoneId: 'Asia/Shanghai',
+        userAgent:
+          'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
+      });
+      await context.addCookies(cookies as any);
+      context.setDefaultTimeout(60_000);
+
+      const page = await context.newPage();
+
+      let totalInserted = 0;
+      let totalUpdated = 0;
+
+      for (const work of works) {
+        const noteId = (work.platformVideoId || '').trim();
+        if (!noteId) continue;
+
+        try {
+          const data = await this.fetchNoteBaseData(page, noteId);
+          if (!data) continue;
+
+          const patches = this.buildDailyStatisticsFromNoteData(work.id, data);
+          if (!patches.length) continue;
+
+          const result = await this.workDayStatisticsService.saveStatisticsForDateBatch(
+            patches.map((p) => ({
+              workId: p.workId,
+              recordDate: p.recordDate,
+              playCount: p.playCount,
+              exposureCount: p.exposureCount,
+              likeCount: p.likeCount,
+              commentCount: p.commentCount,
+              shareCount: p.shareCount,
+              collectCount: p.collectCount,
+              fansIncrease: p.fansIncrease,
+              coverClickRate: p.coverClickRate,
+              avgWatchDuration: p.avgWatchDuration,
+              totalWatchDuration: p.totalWatchDuration,
+              completionRate: p.completionRate,
+              twoSecondExitRate: p.twoSecondExitRate,
+            }))
+          );
+
+          totalInserted += result.inserted;
+          totalUpdated += result.updated;
+        } catch (e) {
+          if (e instanceof XhsLoginExpiredError) {
+            closedDueToLoginExpired = true;
+            if (context) {
+              await context.close().catch(() => undefined);
+              context = null;
+            }
+            if (shouldClose) {
+              await browser.close().catch(() => undefined);
+            }
+
+            try {
+              if (!isRetry) {
+                logger.info(`[XHS WorkStats] accountId=${account.id} 登录失效,尝试刷新登录...`);
+                try {
+                  const accountService = new AccountService();
+                  const refreshResult = await accountService.refreshAccount(account.userId, account.id);
+                  if (refreshResult.needReLogin) {
+                    await this.accountRepository.update(account.id, { status: 'expired' as any });
+                    logger.warn(`[XHS WorkStats] accountId=${account.id} 刷新后仍需重新登录,已标记账号失效`);
+                    return;
+                  }
+                  const refreshed = await this.accountRepository.findOne({ where: { id: account.id } });
+                  if (refreshed) {
+                    logger.info(`[XHS WorkStats] accountId=${account.id} 刷新成功,重新同步数据`);
+                    return this.importAccountWorksStatistics(refreshed, true);
+                  }
+                } catch (refreshErr) {
+                  logger.error(`[XHS WorkStats] accountId=${account.id} 刷新登录失败`, refreshErr);
+                  await this.accountRepository.update(account.id, { status: 'expired' as any });
+                  logger.warn(`[XHS WorkStats] accountId=${account.id} 已标记账号失效`);
+                  return;
+                }
+              } else {
+                await this.accountRepository.update(account.id, { status: 'expired' as any });
+                logger.warn(`[XHS WorkStats] accountId=${account.id} 刷新后仍跳转登录,已标记账号失效`);
+                return;
+              }
+            } catch (expireErr) {
+              logger.error(
+                `[XHS WorkStats] accountId=${account.id} 账号失效处理异常(不影响其他账号)`,
+                expireErr
+              );
+              return;
+            }
+          }
+          logger.error(
+            `[XHS WorkStats] Failed to import note stats. accountId=${account.id} workId=${work.id} noteId=${noteId}`,
+            e
+          );
+        }
+      }
+
+      logger.info(
+        `[XHS WorkStats] accountId=${account.id} completed. inserted=${totalInserted}, updated=${totalUpdated}`
+      );
+    } finally {
+      if (!closedDueToLoginExpired) {
+        if (context) {
+          await context.close().catch(() => undefined);
+        }
+        if (shouldClose) {
+          await browser.close().catch(() => undefined);
+        }
+      }
+    }
+  }
+
+  private async fetchNoteBaseData(page: Page, noteId: string): Promise<NoteBaseData | null> {
+    const noteUrl = `https://creator.xiaohongshu.com/statistics/note-detail?noteId=${encodeURIComponent(
+      noteId
+    )}`;
+    const apiPattern = /\/api\/galaxy\/creator\/datacenter\/note\/base\?note_id=/i;
+
+    const responsePromise = page
+      .waitForResponse(
+        (res) => res.url().match(apiPattern) != null && res.request().method() === 'GET',
+        { timeout: 30_000 }
+      )
+      .catch(() => null);
+
+    await page.goto(noteUrl, { waitUntil: 'domcontentloaded' }).catch(() => undefined);
+    await page.waitForTimeout(1500);
+
+    if (page.url().includes('login')) {
+      logger.warn(
+        `[XHS WorkStats] note-detail 页面跳转到登录,可能 cookie 失效。noteId=${noteId}`
+      );
+      throw new XhsLoginExpiredError();
+    }
+
+    let res = await responsePromise;
+    if (!res) {
+      // 兜底再等一轮
+      try {
+        res = await page.waitForResponse(
+          (r) => r.url().match(apiPattern) != null && r.request().method() === 'GET',
+          { timeout: 15_000 }
+        );
+      } catch {
+        logger.warn(
+          `[XHS WorkStats] 未捕获到 note/base 接口响应,跳过该笔记。noteId=${noteId}`
+        );
+        return null;
+      }
+    }
+
+    const body = await res.json().catch(() => null);
+    if (!body || typeof body !== 'object') {
+      logger.warn(`[XHS WorkStats] note/base 响应不是 JSON,跳过。noteId=${noteId}`);
+      return null;
+    }
+
+    const data = (body as any).data as NoteBaseData | undefined;
+    if (!data || typeof data !== 'object') {
+      logger.warn(`[XHS WorkStats] note/base data 为空,跳过。noteId=${noteId}`);
+      return null;
+    }
+
+    return data;
+  }
+
+  private buildDailyStatisticsFromNoteData(workId: number, data: NoteBaseData): DailyWorkStatPatch[] {
+    const day = data.day;
+    if (!day) return [];
+
+    const map = new Map<number, DailyWorkStatPatch>();
+
+    const addIntMetric = (items: NoteTrendItem[] | undefined, field: keyof DailyWorkStatPatch) => {
+      if (!Array.isArray(items)) return;
+      for (const item of items) {
+        const tsRaw = item?.date;
+        if (tsRaw == null) continue;
+        const ts = toNumber(tsRaw, NaN);
+        if (!Number.isFinite(ts)) continue;
+        const d = getChinaDateFromTimestamp(ts);
+        const key = d.getTime();
+        let entry = map.get(key);
+        if (!entry) {
+          entry = { workId, recordDate: d };
+          map.set(key, entry);
+        }
+        const prev = (entry as any)[field] ?? 0;
+        const inc = toNumber(item.count, 0);
+        (entry as any)[field] = prev + inc;
+      }
+    };
+
+    /** 比率类:使用 item.coun,转为字符串并加 "%" */
+    const addRatePercentMetric = (items: NoteTrendItem[] | undefined, field: keyof DailyWorkStatPatch) => {
+      if (!Array.isArray(items)) return;
+      for (const item of items) {
+        const tsRaw = item?.date;
+        if (tsRaw == null) continue;
+        const ts = toNumber(tsRaw, NaN);
+        if (!Number.isFinite(ts)) continue;
+        const d = getChinaDateFromTimestamp(ts);
+        const key = d.getTime();
+        let entry = map.get(key);
+        if (!entry) {
+          entry = { workId, recordDate: d };
+          map.set(key, entry);
+        }
+        const s = toRatePercentString(item);
+        if (s != null) {
+          (entry as any)[field] = s;
+        }
+      }
+    };
+
+    /** 时长/数值字符串(不加 %) */
+    const addRateMetric = (items: NoteTrendItem[] | undefined, field: keyof DailyWorkStatPatch) => {
+      if (!Array.isArray(items)) return;
+      for (const item of items) {
+        const tsRaw = item?.date;
+        if (tsRaw == null) continue;
+        const ts = toNumber(tsRaw, NaN);
+        if (!Number.isFinite(ts)) continue;
+        const d = getChinaDateFromTimestamp(ts);
+        const key = d.getTime();
+        let entry = map.get(key);
+        if (!entry) {
+          entry = { workId, recordDate: d };
+          map.set(key, entry);
+        }
+        const s = toRateString(item.count_with_double ?? item.count ?? item.coun);
+        if (s != null) {
+          (entry as any)[field] = s;
+        }
+      }
+    };
+
+    // 数值型:每日增量
+    addIntMetric(day.view_list, 'playCount');
+    addIntMetric(day.like_list, 'likeCount');
+    addIntMetric(day.comment_list, 'commentCount');
+    addIntMetric(day.share_list, 'shareCount');
+    addIntMetric(day.collect_list, 'collectCount');
+    addIntMetric(day.imp_list, 'exposureCount');
+    addIntMetric(day.rise_fans_list, 'fansIncrease');
+
+    // 比率类:使用 coun 字段,入库时带 "%"
+    addRatePercentMetric(day.cover_click_rate_list, 'coverClickRate');
+    addRatePercentMetric(day.exit_view2s_list, 'twoSecondExitRate');
+    addRatePercentMetric(day.finish_list, 'completionRate');
+
+    // 平均观看时长:view_time_list → avg_watch_duration(数值字符串,不加 %)
+    addRateMetric(day.view_time_list, 'avgWatchDuration');
+
+    return Array.from(map.values()).sort(
+      (a, b) => a.recordDate.getTime() - b.recordDate.getTime()
+    );
+  }
+}
+

Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff