Quellcode durchsuchen

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

Ethanfly vor 14 Stunden
Ursprung
Commit
1c1aab47b9

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

@@ -797,18 +797,32 @@ function formatRate(rateLike: any): string {
 
 function calcDetailRangeDatesFixed14Days(): { start: string; end: string } {
   const publish = dayjs(selectedWork.value?.publishTime);
+  const platform = selectedWork.value?.platform;
 
-  // 小红书:趋势固定「发布后 14 天(含发布日)」,与页面时间范围无关
-  if (selectedWork.value?.platform === 'xiaohongshu' && publish.isValid()) {
+  // 小红书:趋势固定「发布后 14 天(含发布日)」,与页面时间范围无关
+  if (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 clampedStart = publish.isValid() && publish.isAfter(start) ? publish : start;
+  const today = dayjs().startOf('day');
+
+  // 抖音:作品详情趋势固定为「今天往回 30 天(含今天)」,与列表筛选无关;且不早于发布时间
+  if (platform === 'douyin') {
+    let end = today;
+    let start = end.subtract(29, 'day'); // 近30天(含当天)
+    if (publish.isValid() && publish.isAfter(start)) {
+      start = publish.startOf('day');
+    }
+    return { start: start.format('YYYY-MM-DD'), end: end.format('YYYY-MM-DD') };
+  }
+
+  const end = dayjs(endDate.value || today);
+
+  // 其他平台:保持原逻辑(以页面 endDate 为截止的近 14 天),且不早于发布时间
+  const start14 = end.subtract(13, 'day'); // 近14天(含当天)
+  const clampedStart = publish.isValid() && publish.isAfter(start14) ? publish.startOf('day') : start14;
   return { start: clampedStart.format('YYYY-MM-DD'), end: end.format('YYYY-MM-DD') };
 }
 
@@ -846,11 +860,11 @@ const douyinMetricCards = computed<MetricCardConfig[]>(() => {
     { key: 'collectCount', label: '收藏量', value: formatNumber(d.collectCount || base?.collectsCount || 0) },
     { key: 'shareCount', label: '分享量', value: formatNumber(d.shareCount || base?.sharesCount || 0) },
     { key: 'fansIncrease', label: '涨粉量', value: formatNumber(d.fansIncrease || 0) },
-    { label: '平均观看时长', value: formatAvgWatchDurationSeconds(d.avgWatchDuration) },
     { key: 'completionRate', label: '完播率', value: formatRate(d.completionRate) },
     { key: 'twoSecondExitRate', label: '2s退出率', value: formatRate(d.twoSecondExitRate) },
     // 5s 完播率仅为昨日快照,不参与趋势联动
     { label: '5s完播率', value: formatRate(d.completionRate5s) },
+    { label: '平均观看时长', value: formatAvgWatchDurationSeconds(d.avgWatchDuration) },
   ];
 });
 

+ 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();
+

+ 121 - 177
server/src/services/DouyinAccountOverviewImportService.ts

@@ -1,7 +1,6 @@
 import fs from 'node:fs/promises';
 import path from 'node:path';
 import { chromium, type Browser } from 'playwright';
-import * as XLSXNS from 'xlsx';
 import { AppDataSource, PlatformAccount } from '../models/index.js';
 import { BrowserManager } from '../automation/browser.js';
 import { logger } from '../utils/logger.js';
@@ -11,10 +10,6 @@ import type { ProxyConfig } from '@media-manager/shared';
 import { WS_EVENTS } from '@media-manager/shared';
 import { wsManager } from '../websocket/index.js';
 
-// xlsx 在 ESM 下可能挂在 default 上;这里做一次兼容兜底
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-const XLSX: any = (XLSXNS as any).default ?? (XLSXNS as any);
-
 type PlaywrightCookie = {
   name: string;
   value: string;
@@ -67,20 +62,14 @@ function normalizeDateText(input: unknown): Date | null {
   return null;
 }
 
-function parseChineseNumberLike(input: unknown): number | null {
-  if (input === null || input === undefined) return null;
-  const s = String(input).trim();
-  if (!s) return null;
-  // 8,077
-  const plain = s.replace(/,/g, '');
-  // 4.8万
-  const wan = plain.match(/^(\d+(\.\d+)?)\s*万$/);
-  if (wan) return Math.round(Number(wan[1]) * 10000);
-  const yi = plain.match(/^(\d+(\.\d+)?)\s*亿$/);
-  if (yi) return Math.round(Number(yi[1]) * 100000000);
-  const n = Number(plain.replace(/[^\d.-]/g, ''));
-  if (Number.isFinite(n)) return Math.round(n);
-  return null;
+function toRatePercentStringFromValue(val: unknown): string | undefined {
+  const n = typeof val === 'number' ? val : Number(val);
+  if (!Number.isFinite(n)) return undefined;
+  if (n === 0) return '0';
+  const scaled = n * 100;
+  const rounded = Math.round(scaled * 100) / 100;
+  const s = rounded.toFixed(2).replace(/\.00$/, '').replace(/(\.\d)0$/, '$1');
+  return `${s}%`;
 }
 
 function parseCookiesFromAccount(cookieData: string | null): PlaywrightCookie[] {
@@ -162,92 +151,56 @@ async function createBrowserForAccount(proxy: ProxyConfig | null): Promise<{ bro
   return { browser, shouldClose: false };
 }
 
-function parseDouyinExcel(
-  filePath: string
-): Map<string, { recordDate: Date } & Record<string, any>> {
-  const wb = XLSX.readFile(filePath);
-  const result = new Map<string, { recordDate: Date } & Record<string, any>>();
-
-  logger.info(
-    `[DY Import] Excel loaded. file=${path.basename(filePath)} sheets=${wb.SheetNames.join(' | ')}`
-  );
-
-  for (const sheetName of wb.SheetNames) {
-    const sheet = wb.Sheets[sheetName];
-    const rows = XLSX.utils.sheet_to_json<Record<string, any>>(sheet, { defval: '' });
-
-    if (!rows.length) {
-      logger.warn(`[DY Import] Sheet empty. name=${sheetName}`);
-      continue;
-    }
-
-    const keys = Object.keys(rows[0] || {});
-    logger.info(`[DY Import] Sheet parsed. name=${sheetName} rows=${rows.length} keys=${keys.join(',')}`);
-
-    const normalizeKey = (k: string) => k.replace(/^\uFEFF/, '').trim();
-
-    for (const row of rows) {
-      const rawKeys = Object.keys(row || {});
-      if (!rawKeys.length) continue;
-      const keysNormalized = rawKeys.map((k) => ({ raw: k, norm: normalizeKey(k) }));
-
-      // 兼容 Excel 表头带 BOM/空格:优先找包含“日期”的列作为日期列
-      const dateKey =
-        keysNormalized.find((k) => k.norm === '日期')?.raw ??
-        keysNormalized.find((k) => k.norm.includes('日期'))?.raw ??
-        keysNormalized.find((k) => k.norm.toLowerCase() === 'date')?.raw ??
-        keysNormalized[0]!.raw;
-
-      const dateVal = (row as any)[dateKey];
-
-      const d = normalizeDateText(dateVal);
-      if (!d) continue;
-
-      const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
-      if (!result.has(key)) result.set(key, { recordDate: d });
-      const obj = result.get(key)!;
-
-      // 抖音导出的 Excel 通常是两列:日期 + 指标名(如“作品分享/净增粉丝/作品点赞/播放量...”)
-      // 因此优先按“第二列标题”做自动映射,避免漏掉“沈凉音”这种全量导出格式。
-      const metricKeyRaw = keysNormalized.find((k) => k.raw !== dateKey)?.raw;
-      if (!metricKeyRaw) continue;
-      const metricKey = normalizeKey(metricKeyRaw);
-
-      // 显式排除:主页访问 / 取关粉丝
-      if (metricKey.includes('主页访问') || metricKey.includes('取关粉丝')) continue;
+type DashboardMetricTrendPoint = {
+  date_time?: string; // YYYYMMDD
+  value?: number;
+  douyin_value?: number;
+  xigua_value?: number;
+  yumme_value?: number;
+  change_rate?: number;
+};
 
-      const rawVal = (row as any)[metricKeyRaw];
-      if (rawVal === undefined || rawVal === null) continue;
+type DashboardMetricItem = {
+  english_metric_name?: string;
+  metric_name?: string;
+  metric_value?: number;
+  trends?: DashboardMetricTrendPoint[];
+};
 
-      // 1)封面点击率:字符串百分比直接存
-      if (metricKey.includes('封面点击率')) {
-        const s = String(rawVal).trim();
-        if (s) (obj as any).coverClickRate = s;
-        continue;
-      }
+type DashboardResponse = {
+  status_code?: number;
+  status_msg?: string;
+  metrics?: DashboardMetricItem[];
+};
 
-      // 2)其余按数值解析
-      const n = parseChineseNumberLike(rawVal);
-      if (typeof n !== 'number') continue;
-
-      if (metricKey.includes('播放')) (obj as any).playCount = n;
-      else if (metricKey.includes('点赞')) (obj as any).likeCount = n;
-      else if (metricKey.includes('评论')) (obj as any).commentCount = n;
-      else if (metricKey.includes('分享')) (obj as any).shareCount = n;
-      else if (metricKey.includes('净增粉丝') || metricKey.includes('新增粉丝')) (obj as any).fansIncrease = n;
-      // 总粉丝数/总粉丝量:入库 fans_count
-      else if (metricKey.includes('总粉丝')) (obj as any).fansCount = n;
-    }
-  }
+function parseYmdCompactToDate(ymd: unknown): Date | null {
+  const s = String(ymd || '').trim();
+  if (!/^\d{8}$/.test(s)) return null;
+  const yyyy = Number(s.slice(0, 4));
+  const mm = Number(s.slice(4, 6));
+  const dd = Number(s.slice(6, 8));
+  if (!yyyy || !mm || !dd) return null;
+  const d = new Date(yyyy, mm - 1, dd);
+  d.setHours(0, 0, 0, 0);
+  return d;
+}
 
-  return result;
+function pickTrendValue(pt: DashboardMetricTrendPoint): number | undefined {
+  // 优先使用聚合 value;若不存在则兜底 douyin_value
+  const v =
+    typeof pt?.value === 'number'
+      ? pt.value
+      : typeof pt?.douyin_value === 'number'
+        ? pt.douyin_value
+        : undefined;
+  if (!Number.isFinite(v as number)) return undefined;
+  return v as number;
 }
 
 export class DouyinAccountOverviewImportService {
   private accountRepository = AppDataSource.getRepository(PlatformAccount);
   private userDayStatisticsService = new UserDayStatisticsService();
 
-  private downloadDir = path.resolve(process.cwd(), 'tmp', 'douyin-account-overview');
   private stateDir = path.resolve(process.cwd(), 'tmp', 'douyin-storage-state');
 
   private getStatePath(accountId: number) {
@@ -316,8 +269,6 @@ export class DouyinAccountOverviewImportService {
    * 为所有抖音账号导出“账号总览-短视频-数据表现-近30天”并导入 user_day_statistics
    */
   async runDailyImportForAllDouyinAccounts(): Promise<void> {
-    await ensureDir(this.downloadDir);
-
     const accounts = await this.accountRepository.find({
       where: { platform: 'douyin' as any },
     });
@@ -463,94 +414,87 @@ export class DouyinAccountOverviewImportService {
       }
       await page.waitForTimeout(1200);
 
-      // 逐个指标导出(排除:主页访问 / 取关粉丝)
-      // 说明:抖音导出通常是“日期 + 指标”两列,每次只能导出当前选中的指标
-      // 注意:抖音 UI 上“总粉丝”文案可能是「总粉丝量」而不是「总粉丝数」
-      const metricsToExport: Array<{ name: string; candidates: string[] }> = [
-        { name: '播放量', candidates: ['播放量'] },
-        { name: '作品点赞', candidates: ['作品点赞', '点赞'] },
-        { name: '作品评论', candidates: ['作品评论', '评论'] },
-        { name: '作品分享', candidates: ['作品分享', '分享'] },
-        { name: '封面点击率', candidates: ['封面点击率'] },
-        { name: '净增粉丝', candidates: ['净增粉丝', '新增粉丝'] },
-        { name: '总粉丝量', candidates: ['总粉丝量', '总粉丝数', '粉丝总量'] },
-      ];
-
       let totalInserted = 0;
       let totalUpdated = 0;
-      let mergedDays = new Map<string, { recordDate: Date } & Record<string, any>>();
-      const savedExcelPaths: string[] = [];
-
-      const clickMetric = async (metric: { name: string; candidates: string[] }) => {
-        // 先精确匹配,失败后用包含匹配(适配 UI 文案差异)
-        for (const c of metric.candidates) {
-          const locatorExact = page.getByText(c, { exact: true }).first();
-          const exactCount = await locatorExact.count().catch(() => 0);
-          if (exactCount > 0) {
-            await locatorExact.click().catch(() => undefined);
-            await page.waitForTimeout(800);
-            return c;
-          }
-        }
-        for (const c of metric.candidates) {
-          const locatorFuzzy = page.getByText(c, { exact: false }).first();
-          const fuzzyCount = await locatorFuzzy.count().catch(() => 0);
-          if (fuzzyCount > 0) {
-            await locatorFuzzy.click().catch(() => undefined);
-            await page.waitForTimeout(800);
-            return c;
-          }
-        }
-        logger.warn(`[DY Import] metric not found on page. accountId=${account.id} metric=${metric.name}`);
-        return null;
-      };
+      const apiUrl = 'https://creator.douyin.com/janus/douyin/creator/data/overview/dashboard';
 
-      for (const metric of metricsToExport) {
-        logger.info(`[DY Import] accountId=${account.id} exporting metric: ${metric.name}...`);
-        await clickMetric(metric);
-
-        const [download] = await Promise.all([
-          page.waitForEvent('download', { timeout: 60_000 }),
-          page.getByText('导出数据', { exact: true }).first().click(),
-        ]);
-
-        const filename = `${account.id}_${Date.now()}_${download.suggestedFilename()}`;
-        const filePath = path.join(this.downloadDir, filename);
-        await download.saveAs(filePath);
-        // 保留 Excel 不删除,便于核对数据;路径打日志方便查看
-        const absolutePath = path.resolve(filePath);
-        savedExcelPaths.push(absolutePath);
-        logger.info(
-          `[DY Import] Excel saved (${metric.name}): ${absolutePath}`
-        );
+      logger.info(`[DY Import] accountId=${account.id} fetch dashboard (POST recent_days=30)...`);
+
+      // 优先监听页面自身是否会发起该请求;若没有,则在页面上下文里手动 fetch(浏览器自动带 cookie)
+      const responsePromise = page
+        .waitForResponse(
+          (res) => res.request().method() === 'POST' && res.url().includes('/janus/douyin/creator/data/overview/dashboard'),
+          { timeout: 8000 }
+        )
+        .catch(() => null);
 
+      const evalPromise = page.evaluate(async (url) => {
         try {
-          const perDay = parseDouyinExcel(filePath);
-          // 合并不同指标到同一日期 patch(与小红书维度一致)
-          for (const [k, v] of perDay.entries()) {
-            if (!mergedDays.has(k)) mergedDays.set(k, { recordDate: v.recordDate });
-            const base = mergedDays.get(k)!;
-            Object.assign(base, v);
-          }
-          logger.info(
-            `[DY Import] metric exported & parsed. accountId=${account.id} metric=${metric.name} file=${path.basename(filePath)} days=${perDay.size}`
-          );
-        } finally {
-          // 默认导入后删除 Excel,避免磁盘堆积;仅在显式 KEEP_DY_XLSX=true 时保留(用于调试)
-          if (process.env.KEEP_DY_XLSX === 'true') {
-            logger.warn(`[DY Import] KEEP_DY_XLSX=true, keep file: ${filePath}`);
-          } else {
-            await fs.unlink(filePath).catch(() => undefined);
-          }
+          const r = await fetch(url, {
+            method: 'POST',
+            credentials: 'include',
+            headers: {
+              accept: 'application/json, text/plain, */*',
+              'content-type': 'application/json',
+            },
+            body: JSON.stringify({ recent_days: 30 }),
+          });
+          const json = await r.json().catch(() => null);
+          return json;
+        } catch (e: any) {
+          return { status_code: -1, status_msg: String(e?.message || e) };
         }
+      }, apiUrl);
+
+      const [res, evalJson] = await Promise.all([responsePromise, evalPromise]);
+      const body = ((res ? await res.json().catch(() => null) : null) ?? evalJson ?? null) as DashboardResponse | null;
+
+      if (!body || typeof body !== 'object') {
+        throw new Error('overview/dashboard 响应不是 JSON');
+      }
+      if (Number(body.status_code) !== 0) {
+        throw new Error(`overview/dashboard 返回非成功: code=${body.status_code} msg=${body.status_msg || ''}`);
       }
 
-      // 汇总:本账号导出的 7 个 Excel 已解析
-      logger.info(
-        `[DY Import] accountId=${account.id} 共 ${savedExcelPaths.length} 个 Excel 已解析`
-      );
-      if (savedExcelPaths.length !== 7) {
-        logger.warn(`[DY Import] accountId=${account.id} 预期 7 个 Excel,实际 ${savedExcelPaths.length} 个`);
+      const metrics = Array.isArray(body.metrics) ? body.metrics : [];
+      if (!metrics.length) {
+        logger.warn(`[DY Import] dashboard metrics empty. accountId=${account.id}`);
+      }
+
+      const mergedDays = new Map<string, { recordDate: Date } & Record<string, any>>();
+
+      const setDay = (d: Date) => {
+        const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
+        if (!mergedDays.has(key)) mergedDays.set(key, { recordDate: d });
+        return mergedDays.get(key)!;
+      };
+
+      for (const m of metrics) {
+        const en = String(m?.english_metric_name || '').trim();
+        const trends = Array.isArray(m?.trends) ? m.trends : [];
+        if (!en || !trends.length) continue;
+
+        for (const pt of trends) {
+          const d = parseYmdCompactToDate(pt?.date_time);
+          if (!d) continue;
+          const obj = setDay(d);
+          const v = pickTrendValue(pt);
+          if (v === undefined) continue;
+
+          // 显式排除:主页访问 / 取关粉丝(库里没有对应字段)
+          if (en === 'homepage_view_cnt' || en === 'cancel_fans_cnt') continue;
+
+          if (en === 'total_fans_cnt') (obj as any).fansCount = Math.round(v);
+          else if (en === 'play_cnt') (obj as any).playCount = Math.round(v);
+          else if (en === 'digg_cnt') (obj as any).likeCount = Math.round(v);
+          else if (en === 'comment_cnt') (obj as any).commentCount = Math.round(v);
+          else if (en === 'share_count') (obj as any).shareCount = Math.round(v);
+          else if (en === 'net_fans_cnt') (obj as any).fansIncrease = Math.round(v);
+          else if (en === 'cover_click_ratio') {
+            const s = toRatePercentStringFromValue(v);
+            if (s != null) (obj as any).coverClickRate = s;
+          }
+        }
       }
 
       // 合并完成后统一入库(避免同一天多次 update)

+ 37 - 5
server/src/services/DouyinWorkStatisticsImportService.ts

@@ -224,8 +224,8 @@ class DouyinMetricsTrendClient {
       browser_online: 'true',
       timezone_name: 'Asia/Shanghai',
       item_id: itemId,
-      // 按照浏览器抓包使用 trend_type=2,表示直接使用原始指标曲线(不是增量/差值)
-      trend_type: '2',
+      // 按照浏览器抓包使用 trend_type=1,与抖音后台曲线口径保持一致
+      trend_type: '1',
       time_unit: '1',
       metrics_group: '0,1,3',
       metrics: metric,
@@ -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 任务事件类型