WorkService.ts 29 KB

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