WorkService.ts 15 KB

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