WorkService.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509
  1. import { AppDataSource, Work, PlatformAccount, Comment } 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. export class WorkService {
  10. private workRepository = AppDataSource.getRepository(Work);
  11. private accountRepository = AppDataSource.getRepository(PlatformAccount);
  12. /**
  13. * 获取作品列表
  14. */
  15. async getWorks(userId: number, params: WorksQueryParams): Promise<{ items: WorkType[]; total: number }> {
  16. const queryBuilder = this.workRepository
  17. .createQueryBuilder('work')
  18. .where('work.userId = :userId', { userId });
  19. if (params.accountId) {
  20. queryBuilder.andWhere('work.accountId = :accountId', { accountId: params.accountId });
  21. }
  22. if (params.platform) {
  23. queryBuilder.andWhere('work.platform = :platform', { platform: params.platform });
  24. }
  25. if (params.status) {
  26. queryBuilder.andWhere('work.status = :status', { status: params.status });
  27. }
  28. if (params.keyword) {
  29. queryBuilder.andWhere('work.title LIKE :keyword', { keyword: `%${params.keyword}%` });
  30. }
  31. const page = params.page || 1;
  32. const pageSize = params.pageSize || 12;
  33. const [items, total] = await queryBuilder
  34. .orderBy('work.publishTime', 'DESC')
  35. .skip((page - 1) * pageSize)
  36. .take(pageSize)
  37. .getManyAndCount();
  38. return {
  39. items: items.map(this.formatWork),
  40. total,
  41. };
  42. }
  43. /**
  44. * 获取作品统计
  45. */
  46. async getStats(userId: number): Promise<WorkStats> {
  47. const result = await this.workRepository
  48. .createQueryBuilder('work')
  49. .select([
  50. 'COUNT(*) as totalCount',
  51. 'SUM(CASE WHEN status = "published" THEN 1 ELSE 0 END) as publishedCount',
  52. 'SUM(play_count) as totalPlayCount',
  53. 'SUM(like_count) as totalLikeCount',
  54. 'SUM(comment_count) as totalCommentCount',
  55. ])
  56. .where('work.userId = :userId', { userId })
  57. .getRawOne();
  58. return {
  59. totalCount: parseInt(result.totalCount) || 0,
  60. publishedCount: parseInt(result.publishedCount) || 0,
  61. totalPlayCount: parseInt(result.totalPlayCount) || 0,
  62. totalLikeCount: parseInt(result.totalLikeCount) || 0,
  63. totalCommentCount: parseInt(result.totalCommentCount) || 0,
  64. };
  65. }
  66. /**
  67. * 获取单个作品
  68. */
  69. async getWorkById(userId: number, workId: number): Promise<WorkType> {
  70. const work = await this.workRepository.findOne({
  71. where: { id: workId, userId },
  72. });
  73. if (!work) {
  74. throw new AppError('作品不存在', HTTP_STATUS.NOT_FOUND, ERROR_CODES.NOT_FOUND);
  75. }
  76. return this.formatWork(work);
  77. }
  78. /**
  79. * 同步账号的作品
  80. */
  81. async syncWorks(userId: number, accountId?: number): Promise<{ synced: number; accounts: number }> {
  82. logger.info(`[SyncWorks] Starting sync for userId: ${userId}, accountId: ${accountId || 'all'}`);
  83. // 先查看所有账号(调试用)
  84. const allAccounts = await this.accountRepository.find({ where: { userId } });
  85. logger.info(`[SyncWorks] All accounts for user ${userId}: ${allAccounts.map(a => `id=${a.id},status=${a.status},platform=${a.platform}`).join('; ')}`);
  86. // 同时查询 active 和 expired 状态的账号(expired 的账号 cookie 可能实际上还有效)
  87. const queryBuilder = this.accountRepository
  88. .createQueryBuilder('account')
  89. .where('account.userId = :userId', { userId })
  90. .andWhere('account.status IN (:...statuses)', { statuses: ['active', 'expired'] });
  91. if (accountId) {
  92. queryBuilder.andWhere('account.id = :accountId', { accountId });
  93. }
  94. const accounts = await queryBuilder.getMany();
  95. logger.info(`[SyncWorks] Found ${accounts.length} accounts (active + expired)`);
  96. let totalSynced = 0;
  97. let accountCount = 0;
  98. for (const account of accounts) {
  99. try {
  100. logger.info(`[SyncWorks] Syncing account ${account.id} (${account.platform}, status: ${account.status})`);
  101. const synced = await this.syncAccountWorks(userId, account);
  102. totalSynced += synced;
  103. accountCount++;
  104. logger.info(`[SyncWorks] Account ${account.id} synced ${synced} works`);
  105. // 如果同步成功且账号状态是 expired,则恢复为 active
  106. if (synced > 0 && account.status === 'expired') {
  107. await this.accountRepository.update(account.id, { status: 'active' });
  108. logger.info(`[SyncWorks] Account ${account.id} status restored to active`);
  109. }
  110. } catch (error) {
  111. logger.error(`Failed to sync works for account ${account.id}:`, error);
  112. }
  113. }
  114. logger.info(`[SyncWorks] Complete: ${totalSynced} works synced from ${accountCount} accounts`);
  115. return { synced: totalSynced, accounts: accountCount };
  116. }
  117. /**
  118. * 同步单个账号的作品
  119. */
  120. private async syncAccountWorks(userId: number, account: PlatformAccount): Promise<number> {
  121. logger.info(`[SyncAccountWorks] Starting for account ${account.id} (${account.platform})`);
  122. if (!account.cookieData) {
  123. logger.warn(`Account ${account.id} has no cookie data`);
  124. return 0;
  125. }
  126. // 解密 Cookie
  127. let decryptedCookies: string;
  128. try {
  129. decryptedCookies = CookieManager.decrypt(account.cookieData);
  130. logger.info(`[SyncAccountWorks] Cookie decrypted successfully`);
  131. } catch {
  132. decryptedCookies = account.cookieData;
  133. logger.info(`[SyncAccountWorks] Using raw cookie data`);
  134. }
  135. // 解析 Cookie - 支持两种格式
  136. const platform = account.platform as PlatformType;
  137. let cookieList: { name: string; value: string; domain: string; path: string }[];
  138. try {
  139. // 先尝试 JSON 格式
  140. cookieList = JSON.parse(decryptedCookies);
  141. logger.info(`[SyncAccountWorks] Parsed ${cookieList.length} cookies from JSON format`);
  142. } catch {
  143. // 如果 JSON 解析失败,尝试解析 "name=value; name2=value2" 格式
  144. cookieList = this.parseCookieString(decryptedCookies, platform);
  145. logger.info(`[SyncAccountWorks] Parsed ${cookieList.length} cookies from string format`);
  146. if (cookieList.length === 0) {
  147. logger.error(`Invalid cookie format for account ${account.id}`);
  148. return 0;
  149. }
  150. }
  151. // 获取作品列表
  152. logger.info(`[SyncAccountWorks] Fetching account info from ${platform}...`);
  153. const accountInfo = await headlessBrowserService.fetchAccountInfo(platform, cookieList);
  154. logger.info(`[SyncAccountWorks] Got ${accountInfo.worksList?.length || 0} works from API`);
  155. let syncedCount = 0;
  156. // 收集远程作品的 platformVideoId
  157. const remotePlatformVideoIds = new Set<string>();
  158. if (accountInfo.worksList && accountInfo.worksList.length > 0) {
  159. for (const workItem of accountInfo.worksList) {
  160. // 生成一个唯一的视频ID
  161. const platformVideoId = workItem.videoId || `${platform}_${workItem.title}_${workItem.publishTime}`.substring(0, 100);
  162. remotePlatformVideoIds.add(platformVideoId);
  163. // 查找是否已存在
  164. const existingWork = await this.workRepository.findOne({
  165. where: { accountId: account.id, platformVideoId },
  166. });
  167. if (existingWork) {
  168. // 更新现有作品
  169. await this.workRepository.update(existingWork.id, {
  170. title: workItem.title || existingWork.title,
  171. coverUrl: workItem.coverUrl || existingWork.coverUrl,
  172. duration: workItem.duration || existingWork.duration,
  173. status: workItem.status || existingWork.status,
  174. playCount: workItem.playCount ?? existingWork.playCount,
  175. likeCount: workItem.likeCount ?? existingWork.likeCount,
  176. commentCount: workItem.commentCount ?? existingWork.commentCount,
  177. shareCount: workItem.shareCount ?? existingWork.shareCount,
  178. });
  179. } else {
  180. // 创建新作品
  181. const work = this.workRepository.create({
  182. userId,
  183. accountId: account.id,
  184. platform,
  185. platformVideoId,
  186. title: workItem.title || '',
  187. coverUrl: workItem.coverUrl || '',
  188. duration: workItem.duration || '00:00',
  189. status: this.normalizeStatus(workItem.status),
  190. publishTime: this.parsePublishTime(workItem.publishTime),
  191. playCount: workItem.playCount || 0,
  192. likeCount: workItem.likeCount || 0,
  193. commentCount: workItem.commentCount || 0,
  194. shareCount: workItem.shareCount || 0,
  195. });
  196. await this.workRepository.save(work);
  197. }
  198. syncedCount++;
  199. }
  200. logger.info(`Synced ${syncedCount} works for account ${account.id}`);
  201. }
  202. // 删除本地存在但远程已删除的作品
  203. const localWorks = await this.workRepository.find({
  204. where: { accountId: account.id },
  205. });
  206. let deletedCount = 0;
  207. for (const localWork of localWorks) {
  208. if (!remotePlatformVideoIds.has(localWork.platformVideoId)) {
  209. // 先删除关联的评论
  210. await AppDataSource.getRepository(Comment).delete({ workId: localWork.id });
  211. // 再删除作品
  212. await this.workRepository.delete(localWork.id);
  213. deletedCount++;
  214. logger.info(`Deleted work ${localWork.id} (${localWork.title}) - no longer exists on platform`);
  215. }
  216. }
  217. if (deletedCount > 0) {
  218. logger.info(`Deleted ${deletedCount} works that no longer exist on platform for account ${account.id}`);
  219. }
  220. // 保存每日统计数据
  221. try {
  222. await this.saveWorkDayStatistics(account);
  223. } catch (error) {
  224. logger.error(`[SyncAccountWorks] Failed to save day statistics for account ${account.id}:`, error);
  225. }
  226. return syncedCount;
  227. }
  228. /**
  229. * 保存作品每日统计数据
  230. */
  231. private async saveWorkDayStatistics(account: PlatformAccount): Promise<void> {
  232. // 获取该账号下所有作品
  233. const works = await this.workRepository.find({
  234. where: { accountId: account.id },
  235. });
  236. if (works.length === 0) {
  237. logger.info(`[SaveWorkDayStatistics] No works found for account ${account.id}`);
  238. return;
  239. }
  240. // 构建统计数据列表(不再包含粉丝数,粉丝数从 user_day_statistics 表获取)
  241. const statisticsList = works.map(work => ({
  242. workId: work.id,
  243. playCount: work.playCount || 0,
  244. likeCount: work.likeCount || 0,
  245. commentCount: work.commentCount || 0,
  246. shareCount: work.shareCount || 0,
  247. collectCount: work.collectCount || 0,
  248. }));
  249. logger.info(`[SaveWorkDayStatistics] Saving ${statisticsList.length} work statistics for account ${account.id}`);
  250. // 直接使用 WorkDayStatisticsService 保存统计数据
  251. try {
  252. const workDayStatisticsService = new WorkDayStatisticsService();
  253. const result = await workDayStatisticsService.saveStatistics(statisticsList);
  254. logger.info(`[SaveWorkDayStatistics] Success: inserted=${result.inserted}, updated=${result.updated}`);
  255. } catch (error) {
  256. logger.error(`[SaveWorkDayStatistics] Failed to save statistics:`, error);
  257. throw error;
  258. }
  259. }
  260. /**
  261. * 标准化状态
  262. */
  263. private normalizeStatus(status: string): string {
  264. const statusMap: Record<string, string> = {
  265. '已发布': 'published',
  266. '审核中': 'reviewing',
  267. '未通过': 'rejected',
  268. '草稿': 'draft',
  269. 'published': 'published',
  270. 'reviewing': 'reviewing',
  271. 'rejected': 'rejected',
  272. 'draft': 'draft',
  273. };
  274. return statusMap[status] || 'published';
  275. }
  276. /**
  277. * 解析发布时间
  278. */
  279. private parsePublishTime(timeStr: string): Date | null {
  280. if (!timeStr) return null;
  281. // 尝试解析各种格式
  282. // 格式: "2025年12月19日 06:33"
  283. const match = timeStr.match(/(\d{4})年(\d{1,2})月(\d{1,2})日\s*(\d{1,2}):(\d{2})/);
  284. if (match) {
  285. const [, year, month, day, hour, minute] = match;
  286. return new Date(parseInt(year), parseInt(month) - 1, parseInt(day), parseInt(hour), parseInt(minute));
  287. }
  288. // 尝试直接解析
  289. const date = new Date(timeStr);
  290. if (!isNaN(date.getTime())) {
  291. return date;
  292. }
  293. return null;
  294. }
  295. /**
  296. * 删除本地作品记录
  297. */
  298. async deleteWork(userId: number, workId: number): Promise<void> {
  299. const work = await this.workRepository.findOne({
  300. where: { id: workId, userId },
  301. });
  302. if (!work) {
  303. throw new AppError('作品不存在', HTTP_STATUS.NOT_FOUND, ERROR_CODES.NOT_FOUND);
  304. }
  305. // 先删除关联的评论
  306. await AppDataSource.getRepository(Comment).delete({ workId });
  307. // 删除作品
  308. await this.workRepository.delete(workId);
  309. logger.info(`Deleted work ${workId} for user ${userId}`);
  310. }
  311. /**
  312. * 删除平台上的作品
  313. * @returns 包含 accountId 用于后续刷新作品列表
  314. */
  315. async deletePlatformWork(
  316. userId: number,
  317. workId: number,
  318. onCaptchaRequired?: (captchaInfo: { taskId: string }) => Promise<string>
  319. ): Promise<{ success: boolean; errorMessage?: string; accountId?: number }> {
  320. const work = await this.workRepository.findOne({
  321. where: { id: workId, userId },
  322. relations: ['account'],
  323. });
  324. if (!work) {
  325. throw new AppError('作品不存在', HTTP_STATUS.NOT_FOUND, ERROR_CODES.NOT_FOUND);
  326. }
  327. const account = await this.accountRepository.findOne({
  328. where: { id: work.accountId },
  329. });
  330. if (!account || !account.cookieData) {
  331. throw new AppError('账号不存在或未登录', HTTP_STATUS.BAD_REQUEST, ERROR_CODES.ACCOUNT_NOT_FOUND);
  332. }
  333. // 解密 Cookie
  334. let decryptedCookies: string;
  335. try {
  336. decryptedCookies = CookieManager.decrypt(account.cookieData);
  337. } catch {
  338. decryptedCookies = account.cookieData;
  339. }
  340. // 根据平台调用对应的删除方法
  341. if (account.platform === 'douyin') {
  342. const { DouyinAdapter } = await import('../automation/platforms/douyin.js');
  343. const adapter = new DouyinAdapter();
  344. const result = await adapter.deleteWork(decryptedCookies, work.platformVideoId, onCaptchaRequired);
  345. if (result.success) {
  346. // 更新作品状态为已删除
  347. await this.workRepository.update(workId, { status: 'deleted' });
  348. logger.info(`Platform work ${workId} deleted successfully`);
  349. }
  350. return { ...result, accountId: account.id };
  351. }
  352. if (account.platform === 'xiaohongshu') {
  353. const { XiaohongshuAdapter } = await import('../automation/platforms/xiaohongshu.js');
  354. const adapter = new XiaohongshuAdapter();
  355. const result = await adapter.deleteWork(decryptedCookies, work.platformVideoId, onCaptchaRequired);
  356. if (result.success) {
  357. // 更新作品状态为已删除
  358. await this.workRepository.update(workId, { status: 'deleted' });
  359. logger.info(`Platform work ${workId} (xiaohongshu) deleted successfully`);
  360. }
  361. return { ...result, accountId: account.id };
  362. }
  363. return { success: false, errorMessage: '暂不支持该平台删除功能' };
  364. }
  365. /**
  366. * 将 cookie 字符串解析为 cookie 列表
  367. */
  368. private parseCookieString(cookieString: string, platform: PlatformType): {
  369. name: string;
  370. value: string;
  371. domain: string;
  372. path: string;
  373. }[] {
  374. // 获取平台对应的域名
  375. const domainMap: Record<string, string> = {
  376. douyin: '.douyin.com',
  377. kuaishou: '.kuaishou.com',
  378. xiaohongshu: '.xiaohongshu.com',
  379. weixin_video: '.qq.com',
  380. bilibili: '.bilibili.com',
  381. toutiao: '.toutiao.com',
  382. baijiahao: '.baidu.com',
  383. qie: '.qq.com',
  384. dayuhao: '.alibaba.com',
  385. };
  386. const domain = domainMap[platform] || `.${platform}.com`;
  387. // 解析 "name=value; name2=value2" 格式的 cookie 字符串
  388. const cookies: { name: string; value: string; domain: string; path: string }[] = [];
  389. const pairs = cookieString.split(';');
  390. for (const pair of pairs) {
  391. const trimmed = pair.trim();
  392. if (!trimmed) continue;
  393. const eqIndex = trimmed.indexOf('=');
  394. if (eqIndex === -1) continue;
  395. const name = trimmed.substring(0, eqIndex).trim();
  396. const value = trimmed.substring(eqIndex + 1).trim();
  397. if (name && value) {
  398. cookies.push({
  399. name,
  400. value,
  401. domain,
  402. path: '/',
  403. });
  404. }
  405. }
  406. return cookies;
  407. }
  408. /**
  409. * 格式化作品
  410. */
  411. private formatWork(work: Work): WorkType {
  412. return {
  413. id: work.id,
  414. accountId: work.accountId,
  415. platform: work.platform as PlatformType,
  416. platformVideoId: work.platformVideoId,
  417. title: work.title,
  418. description: work.description || undefined,
  419. coverUrl: work.coverUrl,
  420. videoUrl: work.videoUrl || undefined,
  421. duration: work.duration,
  422. status: work.status as 'published' | 'reviewing' | 'rejected' | 'draft' | 'deleted',
  423. publishTime: work.publishTime?.toISOString() || '',
  424. playCount: work.playCount,
  425. likeCount: work.likeCount,
  426. commentCount: work.commentCount,
  427. shareCount: work.shareCount,
  428. collectCount: work.collectCount,
  429. createdAt: work.createdAt.toISOString(),
  430. updatedAt: work.updatedAt.toISOString(),
  431. };
  432. }
  433. }
  434. export const workService = new WorkService();