import { launchBrowser } from '../automation/browserProvider.js'; /// import type { Browser, BrowserContext, 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 { BrowserManager } from '../automation/browser.js'; import type { ProxyConfig } from '@media-manager/shared'; import { CookieManager } from '../automation/cookie.js'; import { WS_EVENTS } from '@media-manager/shared'; import { wsManager } from '../websocket/index.js'; import type { PlatformType } from '@media-manager/shared'; /** 抖音 metrics_trend 返回 user not match / 未登录时抛出,用于触发「先刷新账号、再决定是否账号失效」 */ export class DouyinLoginExpiredError extends Error { constructor(message = 'DOUYIN_LOGIN_EXPIRED') { super(message); this.name = 'DouyinLoginExpiredError'; } } type PlaywrightCookie = { name: string; value: string; domain?: string; path?: string; url?: string; expires?: number; httpOnly?: boolean; secure?: boolean; sameSite?: 'Lax' | 'None' | 'Strict'; }; type TrendPoint = { date_time?: string; value?: string | number }; type MetricsTrendResponse = { status_code: number; status_msg?: string; trend_map?: Record< string, Record >; }; interface DailyWorkStatPatch { workId: number; recordDate: Date; playCount?: number; likeCount?: number; commentCount?: number; shareCount?: number; collectCount?: number; fansIncrease?: number; completionRate?: string; twoSecondExitRate?: string; } function tryDecryptCookieData(cookieData: string | null): string | null { if (!cookieData) return null; const raw = cookieData.trim(); if (!raw) return null; try { return CookieManager.decrypt(raw); } catch { return raw; } } function parseCookiesFromAccount(cookieData: string | null): PlaywrightCookie[] { const rawOrDecrypted = tryDecryptCookieData(cookieData); if (!rawOrDecrypted) return []; const raw = rawOrDecrypted.trim(); if (!raw) return []; // 1) JSON array(最常见) if (raw.startsWith('[') || raw.startsWith('{')) { try { const parsed = JSON.parse(raw); const arr = Array.isArray(parsed) ? parsed : (parsed?.cookies ? parsed.cookies : []); if (!Array.isArray(arr)) return []; return arr .map((c: any) => { const name = String(c?.name ?? '').trim(); const value = String(c?.value ?? '').trim(); if (!name) return null; const domain = c?.domain ? String(c.domain) : undefined; const pathVal = c?.path ? String(c.path) : '/'; const url = !domain ? 'https://creator.douyin.com' : undefined; const sameSiteRaw = c?.sameSite; const sameSite = sameSiteRaw === 'Lax' || sameSiteRaw === 'None' || sameSiteRaw === 'Strict' ? sameSiteRaw : undefined; return { name, value, domain, path: pathVal, url, expires: typeof c?.expires === 'number' ? c.expires : undefined, httpOnly: typeof c?.httpOnly === 'boolean' ? c.httpOnly : undefined, secure: typeof c?.secure === 'boolean' ? c.secure : undefined, sameSite, } satisfies PlaywrightCookie; }) .filter(Boolean) as PlaywrightCookie[]; } catch { // fallthrough } } // 2) "a=b; c=d" 拼接格式 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.douyin.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 launchBrowser({ 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 parseChinaDateFromDateTimeString(dateTime: unknown): Date | null { if (!dateTime) return null; const s = String(dateTime).trim(); if (s.length < 10) return null; const ymd = s.slice(0, 10); // YYYY-MM-DD const m = ymd.match(/^(\d{4})-(\d{2})-(\d{2})$/); if (!m) return null; const yyyy = Number(m[1]); const mm = Number(m[2]); const dd = Number(m[3]); if (!yyyy || !mm || !dd) return null; const d = new Date(yyyy, mm - 1, dd, 0, 0, 0, 0); return d; } 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 toInt(val: unknown, defaultValue = 0): number { const n = toNumber(val, NaN); if (!Number.isFinite(n)) return defaultValue; return Math.round(n); } function normalizePercentString(val: unknown): string | undefined { const n = toNumber(val, NaN); if (!Number.isFinite(n)) return undefined; // 小于等于 0 统一记为 "0" if (n <= 0) return '0'; // 原始值视为 0-1 之间的小数,这里 *100 后四舍五入保留两位小数并加 "%" 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 toFixed2String(val: unknown): string | undefined { const n = toNonNegativeNumber(val); if (n == null) return undefined; const rounded = Math.round(n * 100) / 100; return rounded.toFixed(2); } function isDouyinLoginExpiredByApi(body: any): boolean { const code = Number(body?.status_code); const msg = String(body?.status_msg || ''); if (code === 20001) return true; // user not match if (msg.includes('user not match')) return true; if (msg.includes('登录') && msg.includes('失效')) return true; return false; } class DouyinMetricsTrendClient { private capturedHeaders: Record | null = null; private buildTrendUrl(itemId: string, metric: string): string { const base = 'https://creator.douyin.com/janus/douyin/creator/data/item_analysis/metrics_trend'; const params = new URLSearchParams({ aid: '2906', app_name: 'aweme_creator_platform', device_platform: 'web', // referer/user_agent 等埋点参数保留,尽量贴近浏览器请求 referer: 'https://creator.douyin.com/creator-micro/data-center/content', user_agent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36', cookie_enabled: 'true', screen_width: '1920', screen_height: '1080', browser_language: 'zh-CN', browser_platform: 'Win32', browser_name: 'Mozilla', browser_version: '5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36', browser_online: 'true', timezone_name: 'Asia/Shanghai', item_id: itemId, // 按照浏览器抓包使用 trend_type=1,与抖音后台曲线口径保持一致 trend_type: '1', time_unit: '1', metrics_group: '0,1,3', metrics: metric, }); return `${base}?${params.toString()}`; } private filterCapturedHeaders(h: Record): Record { const out: Record = {}; const allowList = new Set([ 'accept', 'accept-language', 'agw-js-conv', 'user-agent', 'x-secsdk-csrf-token', ]); for (const [k, v] of Object.entries(h || {})) { const key = k.toLowerCase(); if (key === 'cookie') continue; if (key === 'host') continue; if (key === 'authority') continue; if (key === 'content-length') continue; if (key === 'referer') continue; // 每次按作品详情页动态设置 if (allowList.has(key)) out[key] = v; // 保留所有 x- 前缀的 header(有些风控字段会放在 x- 里) else if (key.startsWith('x-')) out[key] = v; } return out; } private async captureHeadersFromRealRequest(page: Page, metricLabel: string, itemId: string, metric: string): Promise { // 通过点击 UI 触发一次真实请求,抓取其 headers(尤其是 x-secsdk-csrf-token) const apiPattern = /\/janus\/douyin\/creator\/data\/item_analysis\/metrics_trend/i; const wait = page.waitForResponse((res) => { if (res.request().method() !== 'GET') return false; const u = res.url(); return apiPattern.test(u) && u.includes(`item_id=${itemId}`) && u.includes(`metrics=${metric}`); }, { timeout: 25_000 }); // 指标卡片(如:点赞量/播放量/评论量...) // 允许用 "|" 提供多个候选文案(适配 UI 文案差异) const labels = metricLabel .split('|') .map((s) => s.trim()) .filter(Boolean); let clicked = false; for (const label of labels.length ? labels : [metricLabel]) { const loc = page.getByText(label, { exact: false }).first(); const cnt = await loc.count().catch(() => 0); if (!cnt) continue; await loc.waitFor({ state: 'visible', timeout: 10_000 }).catch(() => undefined); await loc.click().catch(() => undefined); clicked = true; break; } if (!clicked) { // 不强制失败:有些情况下 UI 不可点击,但直连请求仍可能成功 logger.warn(`[DY WorkStats] Could not click metric label on page for header capture. label=${metricLabel}`); } const res = await wait; const headers = res.request().headers(); this.capturedHeaders = this.filterCapturedHeaders(headers); logger.info(`[DY WorkStats] Captured request headers for metrics_trend. keys=${Object.keys(this.capturedHeaders).join(',')}`); } async fetchTrend( ctx: BrowserContext, page: Page, itemId: string, metric: string, metricLabelForFallback: string, refererUrl: string ): Promise { const url = this.buildTrendUrl(itemId, metric); const headers: Record = { accept: 'application/json, text/plain, */*', 'accept-language': 'zh-CN,zh;q=0.9', referer: refererUrl, 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36', ...(this.capturedHeaders || {}), }; // 1) 先尝试直接请求(最快) try { const res = await ctx.request.get(url, { headers, timeout: 25_000, }); const json = (await res.json().catch(() => null)) as MetricsTrendResponse | null; if (json && typeof json === 'object') return json; } catch { // fallthrough } // 2) 如果直连失败,抓一次真实请求 header 后重试 if (!this.capturedHeaders) { await this.captureHeadersFromRealRequest(page, metricLabelForFallback, itemId, metric).catch(() => undefined); } const headers2: Record = { accept: 'application/json, text/plain, */*', 'accept-language': 'zh-CN,zh;q=0.9', referer: refererUrl, 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36', ...(this.capturedHeaders || {}), }; const res2 = await ctx.request.get(url, { headers: headers2, timeout: 25_000, }); const json2 = (await res2.json().catch(() => null)) as MetricsTrendResponse | null; if (!json2) throw new Error('metrics_trend 响应不是 JSON'); return json2; } } function toNonNegativeNumber(val: unknown): number | undefined { const n = toNumber(val, NaN); if (!Number.isFinite(n)) return undefined; return n < 0 ? 0 : n; } /** * 比率类:0 不加 "%"(返回 "0"),非 0 时 *100 后四舍五入到两位小数并加 "%" * 例如: 0.12345 -> "12.35%", 0.0 -> "0" */ function toRatePercentStringFromValue(val: unknown): string | undefined { const n = toNumber(val, NaN); 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}%`; } export class DouyinWorkStatisticsImportService { private accountRepository = AppDataSource.getRepository(PlatformAccount); private workRepository = AppDataSource.getRepository(Work); private workDayStatisticsService = new WorkDayStatisticsService(); static async runDailyImport(): Promise { const svc = new DouyinWorkStatisticsImportService(); await svc.runDailyImportForAllDouyinAccounts(); } async runDailyImportForAllDouyinAccounts(): Promise { const accounts = await this.accountRepository.find({ where: { platform: 'douyin' as any }, }); logger.info(`[DY WorkStats] Start import for ${accounts.length} accounts`); for (const account of accounts) { try { await this.importAccountWorksStatistics(account); } catch (e) { logger.error( `[DY WorkStats] Account failed. accountId=${account.id} name=${account.accountName || ''}`, e ); // 单账号失败仅记录日志,不中断循环,其他账号照常同步 } } logger.info('[DY WorkStats] All accounts done'); } /** * 按账号同步作品日统计。检测到 cookie 失效时:先尝试同步/刷新账号一次;刷新仍失效则标记账号 expired。 * @param isRetry 是否为「刷新账号后的重试」,避免无限递归 */ async importAccountWorksStatistics( account: PlatformAccount, isRetry = false, options?: { workIdFilter?: number[]; onProgress?: (payload: { index: number; total: number; work: Work }) => void; } ): Promise { const cookies = parseCookiesFromAccount(account.cookieData); if (!cookies.length) { logger.warn(`[DY WorkStats] accountId=${account.id} cookieData 为空或无法解析,跳过`); return; } 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; } 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/144.0.0.0 Safari/537.36', }); await context.addCookies(cookies as any); context.setDefaultTimeout(60_000); if (!context) { throw new Error('BrowserContext 初始化失败'); } const ctx = context; const page = await context.newPage(); const client = new DouyinMetricsTrendClient(); let totalInserted = 0; let totalUpdated = 0; 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; const detailUrl = `https://creator.douyin.com/creator-micro/work-management/work-detail/${encodeURIComponent( itemId )}?enter_from=item_data`; try { await page.goto(detailUrl, { waitUntil: 'domcontentloaded' }).catch(() => undefined); await page.waitForTimeout(1200); if (page.url().includes('login') || page.url().includes('passport')) { throw new DouyinLoginExpiredError('work-detail 页面跳转登录,cookie 可能失效'); } // metrics -> 入库字段映射 const metricsPlan: Array<{ metric: string; label: string; apply: (patch: DailyWorkStatPatch, v: unknown) => void; }> = [ { metric: 'view_count', label: '播放量', apply: (p, v) => (p.playCount = toInt(v, 0)) }, { metric: 'like_count', label: '点赞量', apply: (p, v) => (p.likeCount = toInt(v, 0)) }, { metric: 'comment_count', label: '评论量', apply: (p, v) => (p.commentCount = toInt(v, 0)) }, { metric: 'share_count', label: '分享量', apply: (p, v) => (p.shareCount = toInt(v, 0)) }, { metric: 'favorite_count', label: '收藏量|收藏数', apply: (p, v) => (p.collectCount = toInt(v, 0)) }, { metric: 'subscribe_count', label: '涨粉量|涨粉数', apply: (p, v) => (p.fansIncrease = toInt(v, 0)) }, { metric: 'completion_rate', label: '完播率', apply: (p, v) => { const s = normalizePercentString(v); if (s != null) p.completionRate = s; }, }, { metric: 'bounce_rate_2s', label: '2s退出率|2s跳出率|2s跳出', apply: (p, v) => { const s = normalizePercentString(v); if (s != null) p.twoSecondExitRate = s; }, }, ]; const dayMap = new Map(); for (const m of metricsPlan) { const body = await client.fetchTrend(ctx, page, itemId, m.metric, m.label, detailUrl); if (!body || typeof body !== 'object') continue; // 调试:把 metrics_trend 原始返回打印成 JSON,方便和抖音后台对比 if (work.id === 39 && m.metric === 'completion_rate') { try { logger.info( `[DY WorkStats][debug metrics_trend raw] workId=${work.id} itemId=${itemId} metric=${m.metric} body=${JSON.stringify( body )}` ); } catch { // ignore JSON stringify error } } if (isDouyinLoginExpiredByApi(body)) { throw new DouyinLoginExpiredError(body.status_msg || 'metrics_trend: user not match'); } if (Number(body.status_code) !== 0) { logger.warn( `[DY WorkStats] metrics_trend 非成功返回. accountId=${account.id} workId=${work.id} itemId=${itemId} metric=${m.metric} code=${body.status_code} msg=${body.status_msg || ''}` ); continue; } const trendMap = body.trend_map || {}; const metricMap = (trendMap as any)[m.metric] as Record | undefined; if (!metricMap) continue; // 优先取 group "0"(一般为“总计/全部”),否则兜底合并全部 group const points = Array.isArray(metricMap['0']) ? metricMap['0'] : Object.values(metricMap).flatMap((arr) => (Array.isArray(arr) ? arr : [])); // 调试:打印 workId=39 的 completion_rate 全量 points,确认与抖音后台返回是否一致 if (work.id === 39 && m.metric === 'completion_rate') { try { logger.info( `[DY WorkStats][debug completion_rate points] workId=${work.id} itemId=${itemId} points=${JSON.stringify( points )}` ); } catch { // ignore JSON stringify error } } for (const pt of points) { // 调试:打印指定作品的完播率原始值 if (work.id === 39 && m.metric === 'completion_rate') { logger.info( `[DY WorkStats][debug completion_rate] workId=${work.id} itemId=${itemId} date=${pt?.date_time} raw_value=${pt?.value}` ); } const d = parseChinaDateFromDateTimeString(pt?.date_time); if (!d) continue; const key = d.getTime(); let entry = dayMap.get(key); if (!entry) { entry = { workId: work.id, recordDate: d }; dayMap.set(key, entry); } m.apply(entry, pt?.value); } } const patches = Array.from(dayMap.values()).sort( (a, b) => a.recordDate.getTime() - b.recordDate.getTime() ); if (!patches.length) continue; // 同时补充作品级昨日快照(works.yesterday_*),使用 item/mget metrics try { await this.applyWorkSnapshotFromItemMget(ctx, itemId, detailUrl, work.id); } catch (e) { logger.warn( `[DY WorkStats] Failed to update works snapshot from item/mget. accountId=${account.id} workId=${work.id} itemId=${itemId}`, e ); } const result = await this.workDayStatisticsService.saveStatisticsForDateBatch( patches.map((p) => ({ workId: p.workId, recordDate: p.recordDate, playCount: p.playCount, likeCount: p.likeCount, commentCount: p.commentCount, shareCount: p.shareCount, collectCount: p.collectCount, fansIncrease: p.fansIncrease, completionRate: p.completionRate, twoSecondExitRate: p.twoSecondExitRate, })) ); totalInserted += result.inserted; totalUpdated += result.updated; } catch (e) { if (e instanceof DouyinLoginExpiredError) { closedDueToLoginExpired = true; if (context) { await context.close().catch(() => undefined); context = null; } if (shouldClose) { await browser.close().catch(() => undefined); } // cookie 过期处理:先刷新一次账号,再决定是否标记 expired if (!isRetry) { logger.info(`[DY WorkStats] accountId=${account.id} 登录失效,尝试同步账号后重试...`); try { const accountService = new AccountService(); const refreshResult = await accountService.refreshAccount(account.userId, account.id); if (refreshResult.needReLogin) { await this.markAccountExpired(account, 'cookie 过期,需要重新登录'); return; } const refreshed = await this.accountRepository.findOne({ where: { id: account.id } }); if (refreshed) { logger.info(`[DY WorkStats] accountId=${account.id} 同步账号成功,重新拉取作品数据`); return this.importAccountWorksStatistics(refreshed, true); } } catch (refreshErr) { logger.error(`[DY WorkStats] accountId=${account.id} 同步账号失败`, refreshErr); await this.markAccountExpired(account, '同步账号失败,已标记过期'); return; } } else { await this.markAccountExpired(account, '同步后仍失效,已标记过期'); return; } } logger.error( `[DY WorkStats] Failed to import work stats. accountId=${account.id} workId=${work.id} itemId=${itemId}`, e ); } } logger.info( `[DY 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); } } } } /** * 使用 item/mget 接口为 works 表补充昨日快照(yesterday_* 字段) */ private async applyWorkSnapshotFromItemMget( ctx: BrowserContext, itemId: string, refererUrl: string, workId: number ): Promise { const url = `https://creator.douyin.com/web/api/creator/item/mget?ids=${encodeURIComponent( itemId )}&fields=metrics%2Creview%2Cplay_info`; const headers: Record = { accept: '*/*', 'accept-language': 'zh-CN,zh;q=0.9', referer: refererUrl, 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36', }; const res = await ctx.request.get(url, { headers, timeout: 25_000 }); const body = (await res.json().catch(() => null)) as any; if (!body || typeof body !== 'object' || Number(body.status_code) !== 0) { return; } const items = Array.isArray(body.items) ? body.items : []; const first = items[0]; if (!first || typeof first !== 'object') return; const metrics = first.metrics || {}; const patch: Partial = {}; const viewCount = toNonNegativeNumber(metrics.view_count); if (viewCount != null) (patch as any).yesterdayPlayCount = Math.trunc(viewCount); const likeCount = toNonNegativeNumber(metrics.like_count); if (likeCount != null) (patch as any).yesterdayLikeCount = Math.trunc(likeCount); const commentCount = toNonNegativeNumber(metrics.comment_count); if (commentCount != null) (patch as any).yesterdayCommentCount = Math.trunc(commentCount); const shareCount = toNonNegativeNumber(metrics.share_count); if (shareCount != null) (patch as any).yesterdayShareCount = Math.trunc(shareCount); const collectCount = toNonNegativeNumber(metrics.favorite_count); if (collectCount != null) (patch as any).yesterdayCollectCount = Math.trunc(collectCount); const fansIncrease = toNonNegativeNumber(metrics.subscribe_count); if (fansIncrease != null) (patch as any).yesterdayFansIncrease = Math.trunc(fansIncrease); // 平均观看时长(秒):保留两位小数 const avgWatchStr = toFixed2String(metrics.avg_view_second); if (avgWatchStr != null) (patch as any).yesterdayAvgWatchDuration = avgWatchStr; const completionRateStr = toRatePercentStringFromValue(metrics.completion_rate); if (completionRateStr != null) (patch as any).yesterdayCompletionRate = completionRateStr; const twoSecondExitRateStr = toRatePercentStringFromValue(metrics.bounce_rate_2s); if (twoSecondExitRateStr != null) (patch as any).yesterdayTwoSecondExitRate = twoSecondExitRateStr; const completion5sStr = toRatePercentStringFromValue(metrics.completion_rate_5s); if (completion5sStr != null) (patch as any).yesterdayCompletionRate5s = completion5sStr; if (Object.keys(patch).length === 0) return; await this.workRepository.update(workId, patch as any); } private async markAccountExpired(account: PlatformAccount, reason: string): Promise { await this.accountRepository.update(account.id, { status: 'expired' as any }); wsManager.sendToUser(account.userId, WS_EVENTS.ACCOUNT_UPDATED, { account: { id: account.id, status: 'expired', platform: 'douyin' as PlatformType }, }); wsManager.sendToUser(account.userId, WS_EVENTS.SYSTEM_MESSAGE, { level: 'warning', message: `抖音账号「${account.accountName || account.accountId || account.id}」登录已失效:${reason}`, platform: 'douyin', accountId: account.id, }); } }