WorkService.ts 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897
  1. import { AppDataSource, Work, PlatformAccount, Comment, WorkDayStatistics } from '../models/index.js';
  2. import { AppError } from '../middleware/error.js';
  3. import { ERROR_CODES, HTTP_STATUS, WS_EVENTS } from '@media-manager/shared';
  4. import type { PlatformType, Work as WorkType, WorkStats, WorksQueryParams } from '@media-manager/shared';
  5. import { logger } from '../utils/logger.js';
  6. import { parseCookieString } from '../utils/cookieParser.js';
  7. import { headlessBrowserService } from './HeadlessBrowserService.js';
  8. import { CookieManager } from '../automation/cookie.js';
  9. import { WorkDayStatisticsService } from './WorkDayStatisticsService.js';
  10. import { taskQueueService } from './TaskQueueService.js';
  11. import { wsManager } from '../websocket/index.js';
  12. export class WorkService {
  13. private workRepository = AppDataSource.getRepository(Work);
  14. private accountRepository = AppDataSource.getRepository(PlatformAccount);
  15. private commentRepository = AppDataSource.getRepository(Comment);
  16. private workDayStatisticsService = new WorkDayStatisticsService();
  17. /**
  18. * 获取作品列表
  19. */
  20. async getWorks(userId: number, params: WorksQueryParams): Promise<{ items: WorkType[]; total: number }> {
  21. const queryBuilder = this.workRepository
  22. .createQueryBuilder('work')
  23. .where('work.userId = :userId', { userId });
  24. if (params.accountId) {
  25. queryBuilder.andWhere('work.accountId = :accountId', { accountId: params.accountId });
  26. }
  27. if (params.platform) {
  28. queryBuilder.andWhere('work.platform = :platform', { platform: params.platform });
  29. }
  30. if (params.status) {
  31. queryBuilder.andWhere('work.status = :status', { status: params.status });
  32. }
  33. if (params.keyword) {
  34. queryBuilder.andWhere('work.title LIKE :keyword', { keyword: `%${params.keyword}%` });
  35. }
  36. const page = params.page || 1;
  37. const pageSize = params.pageSize || 12;
  38. const [items, total] = await queryBuilder
  39. .orderBy('work.publishTime', 'DESC')
  40. .skip((page - 1) * pageSize)
  41. .take(pageSize)
  42. .getManyAndCount();
  43. return {
  44. items: items.map(this.formatWork),
  45. total,
  46. };
  47. }
  48. /**
  49. * 获取作品统计
  50. */
  51. async getStats(userId: number, params?: WorksQueryParams): Promise<WorkStats> {
  52. const queryBuilder = this.workRepository
  53. .createQueryBuilder('work')
  54. .select([
  55. 'COUNT(*) as totalCount',
  56. 'SUM(CASE WHEN status = "published" THEN 1 ELSE 0 END) as publishedCount',
  57. 'CAST(SUM(work.playCount) AS SIGNED) as totalPlayCount',
  58. 'CAST(SUM(work.likeCount) AS SIGNED) as totalLikeCount',
  59. 'CAST(SUM(work.commentCount) AS SIGNED) as totalCommentCount',
  60. ])
  61. .where('work.userId = :userId', { userId });
  62. if (params?.accountId) {
  63. queryBuilder.andWhere('work.accountId = :accountId', { accountId: params.accountId });
  64. }
  65. if (params?.platform) {
  66. queryBuilder.andWhere('work.platform = :platform', { platform: params.platform });
  67. }
  68. if (params?.status) {
  69. queryBuilder.andWhere('work.status = :status', { status: params.status });
  70. }
  71. if (params?.keyword) {
  72. queryBuilder.andWhere('work.title LIKE :keyword', { keyword: `%${params.keyword}%` });
  73. }
  74. const result = await queryBuilder.getRawOne();
  75. return {
  76. totalCount: parseInt(result.totalCount) || 0,
  77. publishedCount: parseInt(result.publishedCount) || 0,
  78. totalPlayCount: parseInt(result.totalPlayCount) || 0,
  79. totalLikeCount: parseInt(result.totalLikeCount) || 0,
  80. totalCommentCount: parseInt(result.totalCommentCount) || 0,
  81. };
  82. }
  83. /**
  84. * 获取单个作品
  85. */
  86. async getWorkById(userId: number, workId: number): Promise<WorkType> {
  87. const work = await this.workRepository.findOne({
  88. where: { id: workId, userId },
  89. });
  90. if (!work) {
  91. throw new AppError('作品不存在', HTTP_STATUS.NOT_FOUND, ERROR_CODES.NOT_FOUND);
  92. }
  93. return this.formatWork(work);
  94. }
  95. /**
  96. * 同步账号的作品
  97. */
  98. async syncWorks(
  99. userId: number,
  100. accountId?: number,
  101. platform?: string,
  102. onProgress?: (progress: number, currentStep: string) => void
  103. ): Promise<{
  104. synced: number;
  105. accounts: number;
  106. accountSummaries: Array<{
  107. accountId: number;
  108. platform: PlatformType;
  109. worksListLength: number;
  110. worksCount: number;
  111. source?: string;
  112. pythonAvailable?: boolean;
  113. syncedCount: number;
  114. }>;
  115. }> {
  116. logger.info(`[SyncWorks] Starting sync for userId: ${userId}, accountId: ${accountId || 'all'}, platform: ${platform || 'all'}`);
  117. // 先查看所有账号(调试用)
  118. const allAccounts = await this.accountRepository.find({ where: { userId } });
  119. logger.info(`[SyncWorks] All accounts for user ${userId}: ${allAccounts.map(a => `id=${a.id},status=${a.status},platform=${a.platform}`).join('; ')}`);
  120. // 同时查询 active 和 expired 状态的账号(expired 的账号 cookie 可能实际上还有效)
  121. const queryBuilder = this.accountRepository
  122. .createQueryBuilder('account')
  123. .where('account.userId = :userId', { userId })
  124. .andWhere('account.status IN (:...statuses)', { statuses: ['active', 'expired'] });
  125. if (accountId) {
  126. queryBuilder.andWhere('account.id = :accountId', { accountId });
  127. } else if (platform) {
  128. queryBuilder.andWhere('account.platform = :platform', { platform });
  129. }
  130. const accounts = await queryBuilder.getMany();
  131. logger.info(`[SyncWorks] Found ${accounts.length} accounts (active + expired)`);
  132. let totalSynced = 0;
  133. let accountCount = 0;
  134. const accountSummaries: Array<{
  135. accountId: number;
  136. platform: PlatformType;
  137. worksListLength: number;
  138. worksCount: number;
  139. source?: string;
  140. pythonAvailable?: boolean;
  141. syncedCount: number;
  142. }> = [];
  143. for (let i = 0; i < accounts.length; i++) {
  144. const account = accounts[i];
  145. try {
  146. logger.info(`[SyncWorks] Syncing account ${account.id} (${account.platform}, status: ${account.status})`);
  147. onProgress?.(
  148. Math.min(95, 5 + Math.round((i / Math.max(1, accounts.length)) * 90)),
  149. `同步账号 ${i + 1}/${accounts.length}: ${account.accountName || account.id} (${account.platform})`
  150. );
  151. const result = await this.syncAccountWorks(userId, account, (p, step) => {
  152. const overall = 5 + Math.round(((i + Math.max(0, Math.min(1, p))) / Math.max(1, accounts.length)) * 90);
  153. onProgress?.(Math.min(95, overall), step);
  154. });
  155. totalSynced += result.syncedCount;
  156. accountCount++;
  157. accountSummaries.push({
  158. accountId: account.id,
  159. platform: account.platform as PlatformType,
  160. worksListLength: result.worksListLength,
  161. worksCount: result.worksCount,
  162. source: result.source,
  163. pythonAvailable: result.pythonAvailable,
  164. syncedCount: result.syncedCount,
  165. });
  166. logger.info(`[SyncWorks] Account ${account.id} synced ${result.syncedCount} works`);
  167. // 如果同步成功且账号状态是 expired,则恢复为 active
  168. if (result.syncedCount > 0 && account.status === 'expired') {
  169. await this.accountRepository.update(account.id, { status: 'active' });
  170. logger.info(`[SyncWorks] Account ${account.id} status restored to active`);
  171. }
  172. } catch (error) {
  173. logger.error(`Failed to sync works for account ${account.id}:`, error);
  174. }
  175. }
  176. onProgress?.(100, `同步完成:共同步 ${totalSynced} 条作品(${accountCount} 个账号)`);
  177. logger.info(`[SyncWorks] Complete: ${totalSynced} works synced from ${accountCount} accounts`);
  178. return { synced: totalSynced, accounts: accountCount, accountSummaries };
  179. }
  180. /**
  181. * 同步单个账号的作品
  182. */
  183. async syncAccountWorks(
  184. userId: number,
  185. account: PlatformAccount,
  186. onProgress?: (progress: number, currentStep: string) => void
  187. ): Promise<{
  188. syncedCount: number;
  189. worksListLength: number;
  190. worksCount: number;
  191. source?: string;
  192. pythonAvailable?: boolean;
  193. }> {
  194. logger.info(`[SyncAccountWorks] Starting for account ${account.id} (${account.platform})`);
  195. if (!account.cookieData) {
  196. logger.warn(`Account ${account.id} has no cookie data`);
  197. return { syncedCount: 0, worksListLength: 0, worksCount: 0 };
  198. }
  199. // 解密 Cookie
  200. let decryptedCookies: string;
  201. try {
  202. decryptedCookies = CookieManager.decrypt(account.cookieData);
  203. logger.info(`[SyncAccountWorks] Cookie decrypted successfully`);
  204. } catch {
  205. decryptedCookies = account.cookieData;
  206. logger.info(`[SyncAccountWorks] Using raw cookie data`);
  207. }
  208. // 解析 Cookie - 支持两种格式
  209. const platform = account.platform as PlatformType;
  210. let cookieList: { name: string; value: string; domain: string; path: string }[];
  211. try {
  212. // 先尝试 JSON 格式
  213. cookieList = JSON.parse(decryptedCookies);
  214. logger.info(`[SyncAccountWorks] Parsed ${cookieList.length} cookies from JSON format`);
  215. } catch {
  216. // 如果 JSON 解析失败,尝试解析 "name=value; name2=value2" 格式
  217. cookieList = parseCookieString(decryptedCookies, platform);
  218. logger.info(`[SyncAccountWorks] Parsed ${cookieList.length} cookies from string format`);
  219. if (cookieList.length === 0) {
  220. logger.error(`Invalid cookie format for account ${account.id}`);
  221. return { syncedCount: 0, worksListLength: 0, worksCount: 0 };
  222. }
  223. }
  224. // 获取作品列表
  225. logger.info(`[SyncAccountWorks] Fetching account info from ${platform}...`);
  226. onProgress?.(0.1, `获取作品列表中:${account.accountName || account.id} (${platform})`);
  227. const accountInfo = await headlessBrowserService.fetchAccountInfo(platform, cookieList, {
  228. onWorksFetchProgress: (info) => {
  229. const declaredTotal = typeof info.declaredTotal === 'number' ? info.declaredTotal : null;
  230. const ratio = declaredTotal !== null && declaredTotal > 0 ? Math.min(1, info.totalSoFar / declaredTotal) : 0;
  231. onProgress?.(
  232. 0.1 + ratio * 0.2,
  233. `拉取作品中:${account.accountName || account.id} (${platform}) ${declaredTotal !== null ? `${info.totalSoFar}/${declaredTotal}` : info.totalSoFar}`
  234. );
  235. },
  236. });
  237. logger.info(`[SyncAccountWorks] Got ${accountInfo.worksList?.length || 0} works from API`);
  238. onProgress?.(
  239. 0.3,
  240. `拉取完成:${account.accountName || account.id} (${platform}) python=${accountInfo.pythonAvailable ? 'ok' : 'off'} source=${accountInfo.source || 'unknown'} list=${accountInfo.worksList?.length || 0} total=${accountInfo.worksCount || 0}`
  241. );
  242. // #6075: 同步作品时同步更新账号的头像和昵称
  243. if (accountInfo.accountName || accountInfo.avatarUrl) {
  244. const accountUpdate: Partial<PlatformAccount> = {};
  245. const defaultNames = [`${platform}账号`, '未知账号', '抖音账号', '小红书账号', '快手账号', '视频号账号', 'B站账号', '头条账号', '百家号账号'];
  246. if (accountInfo.accountName && !defaultNames.includes(accountInfo.accountName) && accountInfo.accountName !== account.accountName) {
  247. accountUpdate.accountName = accountInfo.accountName;
  248. }
  249. if (accountInfo.avatarUrl && accountInfo.avatarUrl !== account.avatarUrl) {
  250. accountUpdate.avatarUrl = accountInfo.avatarUrl;
  251. }
  252. if (Object.keys(accountUpdate).length > 0) {
  253. await this.accountRepository.update(account.id, accountUpdate as any);
  254. logger.info(`[SyncAccountWorks] Updated account info: ${Object.keys(accountUpdate).join(', ')}`);
  255. // Bug #6075: 改用前端监听的 ACCOUNT_DATA_CHANGED 事件,确保前端能刷新账号数据(头像、昵称等)
  256. wsManager.sendToUser(userId, WS_EVENTS.ACCOUNT_DATA_CHANGED, {
  257. accountId: account.id,
  258. ...accountUpdate,
  259. });
  260. }
  261. }
  262. let syncedCount = 0;
  263. // 收集远程作品的 platformVideoId
  264. const remotePlatformVideoIds = new Set<string>();
  265. if (accountInfo.worksList && accountInfo.worksList.length > 0) {
  266. const legacyToCanonicalInRun = new Map<string, string>();
  267. const processedPlatformVideoIds = new Set<string>();
  268. const total = accountInfo.worksList.length;
  269. for (const workItem of accountInfo.worksList) {
  270. const titleForId = (workItem.title || '').trim();
  271. const publishTimeForId = (workItem.publishTime || '').trim();
  272. const legacyFallbackId = `${platform}_${titleForId}_${publishTimeForId}`;
  273. let canonicalVideoId = (workItem.videoId || '').trim() || legacyFallbackId;
  274. // 小红书每日同步只同步三个月内的作品(原为一年内,现改为三个月)
  275. if (platform === 'xiaohongshu') {
  276. const publishedAt = this.parsePublishTime(workItem.publishTime);
  277. if (publishedAt) {
  278. const threeMonthsAgo = new Date();
  279. threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3);
  280. if (publishedAt < threeMonthsAgo) continue;
  281. }
  282. }
  283. if (platform === 'weixin_video') {
  284. const rawVideoId = (workItem.videoId || '').trim();
  285. if (rawVideoId) {
  286. legacyToCanonicalInRun.set(legacyFallbackId, rawVideoId);
  287. canonicalVideoId = rawVideoId;
  288. } else {
  289. const mapped = legacyToCanonicalInRun.get(legacyFallbackId);
  290. if (mapped) {
  291. canonicalVideoId = mapped;
  292. }
  293. }
  294. }
  295. if (processedPlatformVideoIds.has(canonicalVideoId)) {
  296. continue;
  297. }
  298. processedPlatformVideoIds.add(canonicalVideoId);
  299. remotePlatformVideoIds.add(canonicalVideoId);
  300. if (legacyFallbackId !== canonicalVideoId) {
  301. remotePlatformVideoIds.add(legacyFallbackId);
  302. }
  303. let work = await this.workRepository.findOne({
  304. where: { accountId: account.id, platformVideoId: canonicalVideoId },
  305. });
  306. if (platform === 'weixin_video' && work && legacyFallbackId !== canonicalVideoId) {
  307. const legacyWork = await this.workRepository.findOne({
  308. where: { accountId: account.id, platformVideoId: legacyFallbackId },
  309. });
  310. if (legacyWork && legacyWork.id !== work.id) {
  311. await AppDataSource.getRepository(Comment).update(
  312. { workId: legacyWork.id },
  313. { workId: work.id }
  314. );
  315. await this.workDayStatisticsService.deleteByWorkId(legacyWork.id);
  316. await this.workRepository.delete(legacyWork.id);
  317. }
  318. }
  319. if (!work && legacyFallbackId !== canonicalVideoId) {
  320. const legacyWork = await this.workRepository.findOne({
  321. where: { accountId: account.id, platformVideoId: legacyFallbackId },
  322. });
  323. if (legacyWork) {
  324. const canonicalWork = await this.workRepository.findOne({
  325. where: { accountId: account.id, platformVideoId: canonicalVideoId },
  326. });
  327. if (canonicalWork) {
  328. await AppDataSource.getRepository(Comment).update(
  329. { workId: legacyWork.id },
  330. { workId: canonicalWork.id }
  331. );
  332. await this.workDayStatisticsService.deleteByWorkId(legacyWork.id);
  333. await this.workRepository.delete(legacyWork.id);
  334. work = canonicalWork;
  335. } else {
  336. await this.workRepository.update(legacyWork.id, {
  337. platformVideoId: canonicalVideoId,
  338. });
  339. work = { ...legacyWork, platformVideoId: canonicalVideoId };
  340. }
  341. }
  342. }
  343. if (work) {
  344. await this.workRepository.update(work.id, {
  345. title: workItem.title || work.title,
  346. coverUrl: workItem.coverUrl || work.coverUrl,
  347. videoUrl: workItem.videoUrl !== undefined ? workItem.videoUrl || null : work.videoUrl,
  348. duration: workItem.duration || work.duration,
  349. status: workItem.status || work.status,
  350. playCount: workItem.playCount ?? work.playCount,
  351. likeCount: workItem.likeCount ?? work.likeCount,
  352. commentCount: workItem.commentCount ?? work.commentCount,
  353. shareCount: workItem.shareCount ?? work.shareCount,
  354. collectCount: workItem.collectCount ?? work.collectCount,
  355. // Bug #6154: 同步作品时同步更新 yesterday_* 字段,确保「作品数据」页能立即看到最新数据
  356. yesterdayPlayCount: workItem.playCount ?? work.playCount,
  357. yesterdayLikeCount: workItem.likeCount ?? work.likeCount,
  358. yesterdayCommentCount: workItem.commentCount ?? work.commentCount,
  359. yesterdayShareCount: workItem.shareCount ?? work.shareCount,
  360. yesterdayCollectCount: workItem.collectCount ?? work.collectCount,
  361. });
  362. } else {
  363. // 创建新作品
  364. const newWork = this.workRepository.create({
  365. userId,
  366. accountId: account.id,
  367. platform,
  368. platformVideoId: canonicalVideoId,
  369. title: workItem.title || '',
  370. coverUrl: workItem.coverUrl || '',
  371. videoUrl: workItem.videoUrl || null,
  372. duration: workItem.duration || '00:00',
  373. status: this.normalizeStatus(workItem.status),
  374. publishTime: this.parsePublishTime(workItem.publishTime),
  375. playCount: workItem.playCount || 0,
  376. likeCount: workItem.likeCount || 0,
  377. commentCount: workItem.commentCount || 0,
  378. shareCount: workItem.shareCount || 0,
  379. collectCount: workItem.collectCount || 0,
  380. // Bug #6154: 创建作品时初始化 yesterday_* 字段,确保「作品数据」页能显示数据
  381. yesterdayPlayCount: workItem.playCount || 0,
  382. yesterdayLikeCount: workItem.likeCount || 0,
  383. yesterdayCommentCount: workItem.commentCount || 0,
  384. yesterdayShareCount: workItem.shareCount || 0,
  385. yesterdayCollectCount: workItem.collectCount || 0,
  386. });
  387. await this.workRepository.save(newWork);
  388. }
  389. syncedCount++;
  390. if (syncedCount === 1 || syncedCount === total || syncedCount % 10 === 0) {
  391. onProgress?.(0.3 + (syncedCount / total) * 0.65, `写入作品:${account.accountName || account.id} ${syncedCount}/${total}`);
  392. }
  393. }
  394. logger.info(`Synced ${syncedCount} works for account ${account.id}`);
  395. }
  396. if (platform === 'weixin_video') {
  397. await this.dedupeWeixinVideoWorks(account.id);
  398. }
  399. // 删除本地存在但远程已删除的作品
  400. const remoteListLength = accountInfo.worksList?.length || 0;
  401. const expectedRemoteCount = accountInfo.worksCount || 0;
  402. const remoteComplete =
  403. typeof accountInfo.worksListComplete === 'boolean'
  404. ? accountInfo.worksListComplete
  405. : expectedRemoteCount > 0
  406. ? remoteListLength >= expectedRemoteCount
  407. : remoteListLength > 0;
  408. let skipLocalDeletions = false;
  409. if (!remoteComplete) {
  410. logger.warn(
  411. `[SyncAccountWorks] Skipping local deletions for account ${account.id} because remote works list seems incomplete (remote=${remoteListLength}, expected=${expectedRemoteCount})`
  412. );
  413. skipLocalDeletions = true;
  414. } else if (remotePlatformVideoIds.size === 0) {
  415. logger.warn(`[SyncAccountWorks] Skipping local deletions for account ${account.id} because no remote IDs were collected`);
  416. skipLocalDeletions = true;
  417. } else {
  418. const localWorks = await this.workRepository.find({
  419. where: { accountId: account.id },
  420. });
  421. if (platform === 'weixin_video') {
  422. logger.info(`[SyncAccountWorks] Skipping local deletions for ${platform} account ${account.id} to avoid false deletions`);
  423. skipLocalDeletions = true;
  424. }
  425. if (platform === 'xiaohongshu') {
  426. logger.info(`[SyncAccountWorks] Skipping local deletions for ${platform} account ${account.id} (only syncing works within 3 months)`);
  427. skipLocalDeletions = true;
  428. }
  429. const matchedCount = localWorks.reduce(
  430. (sum, w) => sum + (remotePlatformVideoIds.has(w.platformVideoId) ? 1 : 0),
  431. 0
  432. );
  433. const matchRatio = localWorks.length > 0 ? matchedCount / localWorks.length : 1;
  434. if (!skipLocalDeletions && localWorks.length >= 10 && matchRatio < 0.2) {
  435. logger.warn(
  436. `[SyncAccountWorks] Skipping local deletions for account ${account.id} because remote/local ID match ratio is too low (matched=${matchedCount}/${localWorks.length})`
  437. );
  438. skipLocalDeletions = true;
  439. }
  440. if (!skipLocalDeletions) {
  441. let deletedCount = 0;
  442. for (const localWork of localWorks) {
  443. if (!remotePlatformVideoIds.has(localWork.platformVideoId)) {
  444. await AppDataSource.getRepository(Comment).delete({ workId: localWork.id });
  445. await this.workDayStatisticsService.deleteByWorkId(localWork.id);
  446. await this.workRepository.delete(localWork.id);
  447. deletedCount++;
  448. logger.info(`Deleted work ${localWork.id} (${localWork.title}) - no longer exists on platform`);
  449. }
  450. }
  451. if (deletedCount > 0) {
  452. logger.info(`Deleted ${deletedCount} works that no longer exist on platform for account ${account.id}`);
  453. }
  454. }
  455. }
  456. // 同步作品后:不再基于 works 累计值写 work_day_statistics,当天快照全部交给各平台专门的“每日数据”任务
  457. // try {
  458. // await this.saveWorkDayStatistics(account);
  459. // } catch (error) {
  460. // logger.error(`[SyncAccountWorks] Failed to save day statistics for account ${account.id}:`, error);
  461. // }
  462. // 小红书:如果是新作品且 work_day_statistics 中尚无任何记录,则补首批日统计 & works.yesterday_*(不受14天限制)
  463. if (platform === 'xiaohongshu') {
  464. try {
  465. const works = await this.workRepository.find({
  466. where: { accountId: account.id, platform },
  467. select: ['id'],
  468. });
  469. const workIds = works.map((w) => w.id);
  470. if (workIds.length > 0) {
  471. const rows = await AppDataSource.getRepository(WorkDayStatistics)
  472. .createQueryBuilder('wds')
  473. .select('DISTINCT wds.work_id', 'workId')
  474. .where('wds.work_id IN (:...ids)', { ids: workIds })
  475. .getRawMany();
  476. const hasStats = new Set<number>(rows.map((r: any) => Number(r.workId)));
  477. const needInitIds = workIds.filter((id) => !hasStats.has(id));
  478. if (needInitIds.length > 0) {
  479. logger.info(
  480. `[SyncAccountWorks] XHS account ${account.id} has ${needInitIds.length} works without statistics, running initial note/base import.`
  481. );
  482. // 放入任务队列异步执行,避免阻塞同步作品流程
  483. taskQueueService.createTask(account.userId, {
  484. type: 'xhs_work_stats_backfill',
  485. title: `小红书作品补数(${needInitIds.length})`,
  486. accountId: account.id,
  487. platform: 'xiaohongshu',
  488. data: {
  489. workIds: needInitIds,
  490. },
  491. });
  492. }
  493. }
  494. } catch (err) {
  495. logger.error(
  496. `[SyncAccountWorks] Failed to backfill XHS work_day_statistics for account ${account.id}:`,
  497. err
  498. );
  499. }
  500. }
  501. // 抖音:如果是新作品且 work_day_statistics 中尚无任何记录,则异步补齐历史日统计 & works.yesterday_*(使用 DouyinWorkStatisticsImportService)
  502. if (platform === 'douyin') {
  503. try {
  504. const works = await this.workRepository.find({
  505. where: { accountId: account.id, platform },
  506. select: ['id'],
  507. });
  508. const workIds = works.map((w) => w.id);
  509. if (workIds.length > 0) {
  510. const rows = await AppDataSource.getRepository(WorkDayStatistics)
  511. .createQueryBuilder('wds')
  512. .select('DISTINCT wds.work_id', 'workId')
  513. .where('wds.work_id IN (:...ids)', { ids: workIds })
  514. .getRawMany();
  515. const hasStats = new Set<number>(rows.map((r: any) => Number(r.workId)));
  516. const needInitIds = workIds.filter((id) => !hasStats.has(id));
  517. if (needInitIds.length > 0) {
  518. logger.info(
  519. `[SyncAccountWorks] DY account ${account.id} has ${needInitIds.length} works without statistics, enqueue dy_work_stats_backfill task.`
  520. );
  521. taskQueueService.createTask(account.userId, {
  522. type: 'dy_work_stats_backfill',
  523. title: `抖音作品补数(${needInitIds.length})`,
  524. accountId: account.id,
  525. platform: 'douyin',
  526. data: {
  527. workIds: needInitIds,
  528. },
  529. });
  530. }
  531. }
  532. } catch (err) {
  533. logger.error(
  534. `[SyncAccountWorks] Failed to enqueue DY work_day_statistics backfill for account ${account.id}:`,
  535. err
  536. );
  537. }
  538. }
  539. // 百家号:如果是新作品且 work_day_statistics 中尚无任何记录,则异步补齐历史日统计 & works.yesterday_*(使用 BaijiahaoWorkDailyStatisticsImportService)
  540. if (platform === 'baijiahao') {
  541. try {
  542. const works = await this.workRepository.find({
  543. where: { accountId: account.id, platform },
  544. select: ['id'],
  545. });
  546. const workIds = works.map((w) => w.id);
  547. if (workIds.length > 0) {
  548. const rows = await AppDataSource.getRepository(WorkDayStatistics)
  549. .createQueryBuilder('wds')
  550. .select('DISTINCT wds.work_id', 'workId')
  551. .where('wds.work_id IN (:...ids)', { ids: workIds })
  552. .getRawMany();
  553. const hasStats = new Set<number>(rows.map((r: any) => Number(r.workId)));
  554. const needInitIds = workIds.filter((id) => !hasStats.has(id));
  555. if (needInitIds.length > 0) {
  556. logger.info(
  557. `[SyncAccountWorks] BJ account ${account.id} has ${needInitIds.length} works without statistics, enqueue bj_work_stats_backfill task.`
  558. );
  559. taskQueueService.createTask(account.userId, {
  560. type: 'bj_work_stats_backfill',
  561. title: `百家号作品补数(${needInitIds.length})`,
  562. accountId: account.id,
  563. platform: 'baijiahao',
  564. data: {
  565. workIds: needInitIds,
  566. },
  567. });
  568. }
  569. }
  570. } catch (err) {
  571. logger.error(
  572. `[SyncAccountWorks] Failed to enqueue BJ work_day_statistics backfill for account ${account.id}:`,
  573. err
  574. );
  575. }
  576. }
  577. // 视频号:如果是新作品且 work_day_statistics 中尚无任何记录,则异步补齐日统计 & works.yesterday_*(使用 WeixinVideoWorkStatisticsImportService)
  578. if (platform === 'weixin_video') {
  579. try {
  580. const works = await this.workRepository.find({
  581. where: { accountId: account.id, platform },
  582. select: ['id'],
  583. });
  584. const workIds = works.map((w) => w.id);
  585. if (workIds.length > 0) {
  586. const rows = await AppDataSource.getRepository(WorkDayStatistics)
  587. .createQueryBuilder('wds')
  588. .select('DISTINCT wds.work_id', 'workId')
  589. .where('wds.work_id IN (:...ids)', { ids: workIds })
  590. .getRawMany();
  591. const hasStats = new Set<number>(rows.map((r: any) => Number(r.workId)));
  592. const needInitIds = workIds.filter((id) => !hasStats.has(id));
  593. if (needInitIds.length > 0) {
  594. logger.info(
  595. `[SyncAccountWorks] WX account ${account.id} has ${needInitIds.length} works without statistics, enqueue wx_work_stats_backfill task.`
  596. );
  597. taskQueueService.createTask(account.userId, {
  598. type: 'wx_work_stats_backfill',
  599. title: `视频号作品补数(${needInitIds.length})`,
  600. accountId: account.id,
  601. platform: 'weixin_video',
  602. data: {
  603. workIds: needInitIds,
  604. },
  605. });
  606. }
  607. }
  608. } catch (err) {
  609. logger.error(
  610. `[SyncAccountWorks] Failed to enqueue WX work_day_statistics backfill for account ${account.id}:`,
  611. err
  612. );
  613. }
  614. }
  615. return {
  616. syncedCount,
  617. worksListLength: accountInfo.worksList?.length || 0,
  618. worksCount: accountInfo.worksCount || 0,
  619. source: accountInfo.source,
  620. pythonAvailable: accountInfo.pythonAvailable,
  621. };
  622. }
  623. /**
  624. * 保存作品每日统计数据
  625. */
  626. private async saveWorkDayStatistics(account: PlatformAccount): Promise<void> {
  627. // 已废弃:同步作品时不再基于 works 表当前累计值,往 work_day_statistics 写“今天快照”。
  628. // 保留空实现仅为兼容旧调用点,避免影响同步主流程。
  629. logger.info(
  630. `[SaveWorkDayStatistics] Skip snapshot-based work_day_statistics when syncing works for account ${account.id} (disabled by design).`
  631. );
  632. }
  633. /**
  634. * 标准化状态
  635. */
  636. private normalizeStatus(status: string): string {
  637. const statusMap: Record<string, string> = {
  638. '已发布': 'published',
  639. '审核中': 'reviewing',
  640. '未通过': 'rejected',
  641. '草稿': 'draft',
  642. 'published': 'published',
  643. 'reviewing': 'reviewing',
  644. 'rejected': 'rejected',
  645. 'draft': 'draft',
  646. };
  647. return statusMap[status] || 'published';
  648. }
  649. /**
  650. * 解析发布时间
  651. */
  652. private parsePublishTime(timeStr: string): Date | null {
  653. if (!timeStr) return null;
  654. // 尝试解析各种格式
  655. // 格式: "2025年12月19日 06:33"
  656. const match = timeStr.match(/(\d{4})年(\d{1,2})月(\d{1,2})日\s*(\d{1,2}):(\d{2})/);
  657. if (match) {
  658. const [, year, month, day, hour, minute] = match;
  659. return new Date(parseInt(year), parseInt(month) - 1, parseInt(day), parseInt(hour), parseInt(minute));
  660. }
  661. // 尝试直接解析
  662. const date = new Date(timeStr);
  663. if (!isNaN(date.getTime())) {
  664. return date;
  665. }
  666. return null;
  667. }
  668. /**
  669. * 删除本地作品记录
  670. */
  671. async deleteWork(userId: number, workId: number): Promise<void> {
  672. const work = await this.workRepository.findOne({
  673. where: { id: workId, userId },
  674. });
  675. if (!work) {
  676. throw new AppError('作品不存在', HTTP_STATUS.NOT_FOUND, ERROR_CODES.NOT_FOUND);
  677. }
  678. // 先删除关联的评论和作品每日统计
  679. await AppDataSource.getRepository(Comment).delete({ workId });
  680. await this.workDayStatisticsService.deleteByWorkId(workId);
  681. // 删除作品
  682. await this.workRepository.delete(workId);
  683. logger.info(`Deleted work ${workId} for user ${userId}`);
  684. }
  685. /**
  686. * 删除平台上的作品
  687. * @returns 包含 accountId 用于后续刷新作品列表
  688. */
  689. async deletePlatformWork(
  690. userId: number,
  691. workId: number,
  692. onCaptchaRequired?: (captchaInfo: { taskId: string }) => Promise<string>
  693. ): Promise<{ success: boolean; errorMessage?: string; accountId?: number }> {
  694. const work = await this.workRepository.findOne({
  695. where: { id: workId, userId },
  696. relations: ['account'],
  697. });
  698. if (!work) {
  699. throw new AppError('作品不存在', HTTP_STATUS.NOT_FOUND, ERROR_CODES.NOT_FOUND);
  700. }
  701. const account = await this.accountRepository.findOne({
  702. where: { id: work.accountId },
  703. });
  704. if (!account || !account.cookieData) {
  705. throw new AppError('账号不存在或未登录', HTTP_STATUS.BAD_REQUEST, ERROR_CODES.ACCOUNT_NOT_FOUND);
  706. }
  707. // 解密 Cookie
  708. let decryptedCookies: string;
  709. try {
  710. decryptedCookies = CookieManager.decrypt(account.cookieData);
  711. } catch {
  712. decryptedCookies = account.cookieData;
  713. }
  714. // 根据平台调用对应的删除方法
  715. if (account.platform === 'douyin') {
  716. const { DouyinAdapter } = await import('../automation/platforms/douyin.js');
  717. const adapter = new DouyinAdapter();
  718. const result = await adapter.deleteWork(decryptedCookies, work.platformVideoId, onCaptchaRequired);
  719. if (result.success) {
  720. // 更新作品状态为已删除
  721. await this.workRepository.update(workId, { status: 'deleted' });
  722. logger.info(`Platform work ${workId} deleted successfully`);
  723. }
  724. return { ...result, accountId: account.id };
  725. }
  726. if (account.platform === 'xiaohongshu') {
  727. const { XiaohongshuAdapter } = await import('../automation/platforms/xiaohongshu.js');
  728. const adapter = new XiaohongshuAdapter();
  729. const result = await adapter.deleteWork(decryptedCookies, work.platformVideoId, onCaptchaRequired);
  730. if (result.success) {
  731. // 更新作品状态为已删除
  732. await this.workRepository.update(workId, { status: 'deleted' });
  733. logger.info(`Platform work ${workId} (xiaohongshu) deleted successfully`);
  734. }
  735. return { ...result, accountId: account.id };
  736. }
  737. return { success: false, errorMessage: '暂不支持该平台删除功能' };
  738. }
  739. /**
  740. * 格式化作品
  741. */
  742. private formatWork(work: Work): WorkType {
  743. return {
  744. id: work.id,
  745. accountId: work.accountId,
  746. platform: work.platform as PlatformType,
  747. platformVideoId: work.platformVideoId,
  748. title: work.title,
  749. description: work.description || undefined,
  750. coverUrl: work.coverUrl,
  751. videoUrl: work.videoUrl || undefined,
  752. duration: work.duration,
  753. status: work.status as 'published' | 'reviewing' | 'rejected' | 'draft' | 'deleted',
  754. publishTime: work.publishTime?.toISOString() || '',
  755. playCount: work.playCount,
  756. likeCount: work.likeCount,
  757. commentCount: work.commentCount,
  758. shareCount: work.shareCount,
  759. collectCount: work.collectCount,
  760. yesterdayPlayCount: work.yesterdayPlayCount,
  761. yesterdayLikeCount: work.yesterdayLikeCount,
  762. yesterdayCommentCount: work.yesterdayCommentCount,
  763. yesterdayShareCount: work.yesterdayShareCount,
  764. yesterdayCollectCount: work.yesterdayCollectCount,
  765. yesterdayRecommendCount: (work as any).yesterdayRecommendCount,
  766. yesterdayFansIncrease: work.yesterdayFansIncrease,
  767. yesterdayCoverClickRate: work.yesterdayCoverClickRate,
  768. yesterdayAvgWatchDuration: work.yesterdayAvgWatchDuration,
  769. yesterdayTotalWatchDuration: work.yesterdayTotalWatchDuration,
  770. yesterdayCompletionRate: work.yesterdayCompletionRate,
  771. yesterdayTwoSecondExitRate: work.yesterdayTwoSecondExitRate,
  772. yesterdayCompletionRate5s: work.yesterdayCompletionRate5s,
  773. yesterdayExposureCount: work.yesterdayExposureCount,
  774. createdAt: work.createdAt.toISOString(),
  775. updatedAt: work.updatedAt.toISOString(),
  776. };
  777. }
  778. private async dedupeWeixinVideoWorks(accountId: number): Promise<void> {
  779. const works = await this.workRepository.find({ where: { accountId } });
  780. const groups = new Map<string, Work[]>();
  781. for (const w of works) {
  782. if (!w.title || !w.publishTime) continue;
  783. const key = `${w.title}__${w.publishTime.toISOString()}`;
  784. const list = groups.get(key);
  785. if (list) list.push(w);
  786. else groups.set(key, [w]);
  787. }
  788. for (const list of groups.values()) {
  789. if (list.length <= 1) continue;
  790. list.sort((a, b) => {
  791. const qa = a.platformVideoId.startsWith('weixin_video_') ? 0 : 1;
  792. const qb = b.platformVideoId.startsWith('weixin_video_') ? 0 : 1;
  793. if (qa !== qb) return qb - qa;
  794. return (b.updatedAt?.getTime?.() || 0) - (a.updatedAt?.getTime?.() || 0);
  795. });
  796. const keep = list[0];
  797. for (const dup of list.slice(1)) {
  798. if (dup.id === keep.id) continue;
  799. await this.commentRepository.update({ workId: dup.id }, { workId: keep.id });
  800. await this.workRepository.delete(dup.id);
  801. }
  802. }
  803. }
  804. }
  805. export const workService = new WorkService();