import { AppDataSource, Work, PlatformAccount, Comment } from '../models/index.js'; import { AppError } from '../middleware/error.js'; import { ERROR_CODES, HTTP_STATUS } from '@media-manager/shared'; import type { PlatformType, Work as WorkType, WorkStats, WorksQueryParams } from '@media-manager/shared'; import { logger } from '../utils/logger.js'; import { headlessBrowserService } from './HeadlessBrowserService.js'; import { CookieManager } from '../automation/cookie.js'; import { WorkDayStatisticsService } from './WorkDayStatisticsService.js'; export class WorkService { private workRepository = AppDataSource.getRepository(Work); private accountRepository = AppDataSource.getRepository(PlatformAccount); /** * 获取作品列表 */ async getWorks(userId: number, params: WorksQueryParams): Promise<{ items: WorkType[]; total: number }> { const queryBuilder = this.workRepository .createQueryBuilder('work') .where('work.userId = :userId', { userId }); if (params.accountId) { queryBuilder.andWhere('work.accountId = :accountId', { accountId: params.accountId }); } if (params.platform) { queryBuilder.andWhere('work.platform = :platform', { platform: params.platform }); } if (params.status) { queryBuilder.andWhere('work.status = :status', { status: params.status }); } if (params.keyword) { queryBuilder.andWhere('work.title LIKE :keyword', { keyword: `%${params.keyword}%` }); } const page = params.page || 1; const pageSize = params.pageSize || 12; const [items, total] = await queryBuilder .orderBy('work.publishTime', 'DESC') .skip((page - 1) * pageSize) .take(pageSize) .getManyAndCount(); return { items: items.map(this.formatWork), total, }; } /** * 获取作品统计 */ async getStats(userId: number): Promise { const result = await this.workRepository .createQueryBuilder('work') .select([ 'COUNT(*) as totalCount', 'SUM(CASE WHEN status = "published" THEN 1 ELSE 0 END) as publishedCount', 'SUM(play_count) as totalPlayCount', 'SUM(like_count) as totalLikeCount', 'SUM(comment_count) as totalCommentCount', ]) .where('work.userId = :userId', { userId }) .getRawOne(); return { totalCount: parseInt(result.totalCount) || 0, publishedCount: parseInt(result.publishedCount) || 0, totalPlayCount: parseInt(result.totalPlayCount) || 0, totalLikeCount: parseInt(result.totalLikeCount) || 0, totalCommentCount: parseInt(result.totalCommentCount) || 0, }; } /** * 获取单个作品 */ async getWorkById(userId: number, workId: number): Promise { const work = await this.workRepository.findOne({ where: { id: workId, userId }, }); if (!work) { throw new AppError('作品不存在', HTTP_STATUS.NOT_FOUND, ERROR_CODES.NOT_FOUND); } return this.formatWork(work); } /** * 同步账号的作品 */ async syncWorks(userId: number, accountId?: number): Promise<{ synced: number; accounts: number }> { logger.info(`[SyncWorks] Starting sync for userId: ${userId}, accountId: ${accountId || 'all'}`); // 先查看所有账号(调试用) const allAccounts = await this.accountRepository.find({ where: { userId } }); logger.info(`[SyncWorks] All accounts for user ${userId}: ${allAccounts.map(a => `id=${a.id},status=${a.status},platform=${a.platform}`).join('; ')}`); // 同时查询 active 和 expired 状态的账号(expired 的账号 cookie 可能实际上还有效) const queryBuilder = this.accountRepository .createQueryBuilder('account') .where('account.userId = :userId', { userId }) .andWhere('account.status IN (:...statuses)', { statuses: ['active', 'expired'] }); if (accountId) { queryBuilder.andWhere('account.id = :accountId', { accountId }); } const accounts = await queryBuilder.getMany(); logger.info(`[SyncWorks] Found ${accounts.length} accounts (active + expired)`); let totalSynced = 0; let accountCount = 0; for (const account of accounts) { try { logger.info(`[SyncWorks] Syncing account ${account.id} (${account.platform}, status: ${account.status})`); const synced = await this.syncAccountWorks(userId, account); totalSynced += synced; accountCount++; logger.info(`[SyncWorks] Account ${account.id} synced ${synced} works`); // 如果同步成功且账号状态是 expired,则恢复为 active if (synced > 0 && account.status === 'expired') { await this.accountRepository.update(account.id, { status: 'active' }); logger.info(`[SyncWorks] Account ${account.id} status restored to active`); } } catch (error) { logger.error(`Failed to sync works for account ${account.id}:`, error); } } logger.info(`[SyncWorks] Complete: ${totalSynced} works synced from ${accountCount} accounts`); return { synced: totalSynced, accounts: accountCount }; } /** * 同步单个账号的作品 */ private async syncAccountWorks(userId: number, account: PlatformAccount): Promise { logger.info(`[SyncAccountWorks] Starting for account ${account.id} (${account.platform})`); if (!account.cookieData) { logger.warn(`Account ${account.id} has no cookie data`); return 0; } // 解密 Cookie let decryptedCookies: string; try { decryptedCookies = CookieManager.decrypt(account.cookieData); logger.info(`[SyncAccountWorks] Cookie decrypted successfully`); } catch { decryptedCookies = account.cookieData; logger.info(`[SyncAccountWorks] Using raw cookie data`); } // 解析 Cookie - 支持两种格式 const platform = account.platform as PlatformType; let cookieList: { name: string; value: string; domain: string; path: string }[]; try { // 先尝试 JSON 格式 cookieList = JSON.parse(decryptedCookies); logger.info(`[SyncAccountWorks] Parsed ${cookieList.length} cookies from JSON format`); } catch { // 如果 JSON 解析失败,尝试解析 "name=value; name2=value2" 格式 cookieList = this.parseCookieString(decryptedCookies, platform); logger.info(`[SyncAccountWorks] Parsed ${cookieList.length} cookies from string format`); if (cookieList.length === 0) { logger.error(`Invalid cookie format for account ${account.id}`); return 0; } } // 获取作品列表 logger.info(`[SyncAccountWorks] Fetching account info from ${platform}...`); const accountInfo = await headlessBrowserService.fetchAccountInfo(platform, cookieList); logger.info(`[SyncAccountWorks] Got ${accountInfo.worksList?.length || 0} works from API`); let syncedCount = 0; // 收集远程作品的 platformVideoId const remotePlatformVideoIds = new Set(); if (accountInfo.worksList && accountInfo.worksList.length > 0) { for (const workItem of accountInfo.worksList) { // 生成一个唯一的视频ID const platformVideoId = workItem.videoId || `${platform}_${workItem.title}_${workItem.publishTime}`.substring(0, 100); remotePlatformVideoIds.add(platformVideoId); // 查找是否已存在 const existingWork = await this.workRepository.findOne({ where: { accountId: account.id, platformVideoId }, }); if (existingWork) { // 更新现有作品 await this.workRepository.update(existingWork.id, { title: workItem.title || existingWork.title, coverUrl: workItem.coverUrl || existingWork.coverUrl, duration: workItem.duration || existingWork.duration, status: workItem.status || existingWork.status, playCount: workItem.playCount ?? existingWork.playCount, likeCount: workItem.likeCount ?? existingWork.likeCount, commentCount: workItem.commentCount ?? existingWork.commentCount, shareCount: workItem.shareCount ?? existingWork.shareCount, }); } else { // 创建新作品 const work = this.workRepository.create({ userId, accountId: account.id, platform, platformVideoId, title: workItem.title || '', coverUrl: workItem.coverUrl || '', duration: workItem.duration || '00:00', status: this.normalizeStatus(workItem.status), publishTime: this.parsePublishTime(workItem.publishTime), playCount: workItem.playCount || 0, likeCount: workItem.likeCount || 0, commentCount: workItem.commentCount || 0, shareCount: workItem.shareCount || 0, }); await this.workRepository.save(work); } syncedCount++; } logger.info(`Synced ${syncedCount} works for account ${account.id}`); } // 删除本地存在但远程已删除的作品 const localWorks = await this.workRepository.find({ where: { accountId: account.id }, }); let deletedCount = 0; for (const localWork of localWorks) { if (!remotePlatformVideoIds.has(localWork.platformVideoId)) { // 先删除关联的评论 await AppDataSource.getRepository(Comment).delete({ workId: localWork.id }); // 再删除作品 await this.workRepository.delete(localWork.id); deletedCount++; logger.info(`Deleted work ${localWork.id} (${localWork.title}) - no longer exists on platform`); } } if (deletedCount > 0) { logger.info(`Deleted ${deletedCount} works that no longer exist on platform for account ${account.id}`); } // 保存每日统计数据 try { await this.saveWorkDayStatistics(account); } catch (error) { logger.error(`[SyncAccountWorks] Failed to save day statistics for account ${account.id}:`, error); } return syncedCount; } /** * 保存作品每日统计数据 */ private async saveWorkDayStatistics(account: PlatformAccount): Promise { // 获取该账号下所有作品 const works = await this.workRepository.find({ where: { accountId: account.id }, }); if (works.length === 0) { logger.info(`[SaveWorkDayStatistics] No works found for account ${account.id}`); return; } // 构建统计数据列表(不再包含粉丝数,粉丝数从 user_day_statistics 表获取) const statisticsList = works.map(work => ({ workId: work.id, playCount: work.playCount || 0, likeCount: work.likeCount || 0, commentCount: work.commentCount || 0, shareCount: work.shareCount || 0, collectCount: work.collectCount || 0, })); logger.info(`[SaveWorkDayStatistics] Saving ${statisticsList.length} work statistics for account ${account.id}`); // 直接使用 WorkDayStatisticsService 保存统计数据 try { const workDayStatisticsService = new WorkDayStatisticsService(); const result = await workDayStatisticsService.saveStatistics(statisticsList); logger.info(`[SaveWorkDayStatistics] Success: inserted=${result.inserted}, updated=${result.updated}`); } catch (error) { logger.error(`[SaveWorkDayStatistics] Failed to save statistics:`, error); throw error; } } /** * 标准化状态 */ private normalizeStatus(status: string): string { const statusMap: Record = { '已发布': 'published', '审核中': 'reviewing', '未通过': 'rejected', '草稿': 'draft', 'published': 'published', 'reviewing': 'reviewing', 'rejected': 'rejected', 'draft': 'draft', }; return statusMap[status] || 'published'; } /** * 解析发布时间 */ private parsePublishTime(timeStr: string): Date | null { if (!timeStr) return null; // 尝试解析各种格式 // 格式: "2025年12月19日 06:33" const match = timeStr.match(/(\d{4})年(\d{1,2})月(\d{1,2})日\s*(\d{1,2}):(\d{2})/); if (match) { const [, year, month, day, hour, minute] = match; return new Date(parseInt(year), parseInt(month) - 1, parseInt(day), parseInt(hour), parseInt(minute)); } // 尝试直接解析 const date = new Date(timeStr); if (!isNaN(date.getTime())) { return date; } return null; } /** * 删除本地作品记录 */ async deleteWork(userId: number, workId: number): Promise { const work = await this.workRepository.findOne({ where: { id: workId, userId }, }); if (!work) { throw new AppError('作品不存在', HTTP_STATUS.NOT_FOUND, ERROR_CODES.NOT_FOUND); } // 先删除关联的评论 await AppDataSource.getRepository(Comment).delete({ workId }); // 删除作品 await this.workRepository.delete(workId); logger.info(`Deleted work ${workId} for user ${userId}`); } /** * 删除平台上的作品 * @returns 包含 accountId 用于后续刷新作品列表 */ async deletePlatformWork( userId: number, workId: number, onCaptchaRequired?: (captchaInfo: { taskId: string }) => Promise ): Promise<{ success: boolean; errorMessage?: string; accountId?: number }> { const work = await this.workRepository.findOne({ where: { id: workId, userId }, relations: ['account'], }); if (!work) { throw new AppError('作品不存在', HTTP_STATUS.NOT_FOUND, ERROR_CODES.NOT_FOUND); } const account = await this.accountRepository.findOne({ where: { id: work.accountId }, }); if (!account || !account.cookieData) { throw new AppError('账号不存在或未登录', HTTP_STATUS.BAD_REQUEST, ERROR_CODES.ACCOUNT_NOT_FOUND); } // 解密 Cookie let decryptedCookies: string; try { decryptedCookies = CookieManager.decrypt(account.cookieData); } catch { decryptedCookies = account.cookieData; } // 根据平台调用对应的删除方法 if (account.platform === 'douyin') { const { DouyinAdapter } = await import('../automation/platforms/douyin.js'); const adapter = new DouyinAdapter(); const result = await adapter.deleteWork(decryptedCookies, work.platformVideoId, onCaptchaRequired); if (result.success) { // 更新作品状态为已删除 await this.workRepository.update(workId, { status: 'deleted' }); logger.info(`Platform work ${workId} deleted successfully`); } return { ...result, accountId: account.id }; } if (account.platform === 'xiaohongshu') { const { XiaohongshuAdapter } = await import('../automation/platforms/xiaohongshu.js'); const adapter = new XiaohongshuAdapter(); const result = await adapter.deleteWork(decryptedCookies, work.platformVideoId, onCaptchaRequired); if (result.success) { // 更新作品状态为已删除 await this.workRepository.update(workId, { status: 'deleted' }); logger.info(`Platform work ${workId} (xiaohongshu) deleted successfully`); } return { ...result, accountId: account.id }; } return { success: false, errorMessage: '暂不支持该平台删除功能' }; } /** * 将 cookie 字符串解析为 cookie 列表 */ private parseCookieString(cookieString: string, platform: PlatformType): { name: string; value: string; domain: string; path: string; }[] { // 获取平台对应的域名 const domainMap: Record = { douyin: '.douyin.com', kuaishou: '.kuaishou.com', xiaohongshu: '.xiaohongshu.com', weixin_video: '.qq.com', bilibili: '.bilibili.com', toutiao: '.toutiao.com', baijiahao: '.baidu.com', qie: '.qq.com', dayuhao: '.alibaba.com', }; const domain = domainMap[platform] || `.${platform}.com`; // 解析 "name=value; name2=value2" 格式的 cookie 字符串 const cookies: { name: string; value: string; domain: string; path: string }[] = []; const pairs = cookieString.split(';'); for (const pair of pairs) { const trimmed = pair.trim(); if (!trimmed) continue; const eqIndex = trimmed.indexOf('='); if (eqIndex === -1) continue; const name = trimmed.substring(0, eqIndex).trim(); const value = trimmed.substring(eqIndex + 1).trim(); if (name && value) { cookies.push({ name, value, domain, path: '/', }); } } return cookies; } /** * 格式化作品 */ private formatWork(work: Work): WorkType { return { id: work.id, accountId: work.accountId, platform: work.platform as PlatformType, platformVideoId: work.platformVideoId, title: work.title, description: work.description || undefined, coverUrl: work.coverUrl, videoUrl: work.videoUrl || undefined, duration: work.duration, status: work.status as 'published' | 'reviewing' | 'rejected' | 'draft' | 'deleted', publishTime: work.publishTime?.toISOString() || '', playCount: work.playCount, likeCount: work.likeCount, commentCount: work.commentCount, shareCount: work.shareCount, collectCount: work.collectCount, createdAt: work.createdAt.toISOString(), updatedAt: work.updatedAt.toISOString(), }; } } export const workService = new WorkService();