import { AppDataSource, Comment, PlatformAccount, Work } from '../models/index.js'; import { AppError } from '../middleware/error.js'; import { ERROR_CODES, HTTP_STATUS, WS_EVENTS } from '@media-manager/shared'; import type { Comment as CommentType, CommentStats, PaginatedData, PlatformType, } from '@media-manager/shared'; import { wsManager } from '../websocket/index.js'; import { headlessBrowserService, type WorkComments, type CookieData } from './HeadlessBrowserService.js'; import { CookieManager } from '../automation/cookie.js'; import { logger } from '../utils/logger.js'; interface GetCommentsParams { page: number; pageSize: number; accountId?: number; workId?: number; platform?: string; isRead?: boolean; keyword?: string; } export class CommentService { private commentRepository = AppDataSource.getRepository(Comment); async getComments(userId: number, params: GetCommentsParams): Promise> { const { page, pageSize, accountId, workId, platform, isRead, keyword } = params; const skip = (page - 1) * pageSize; const queryBuilder = this.commentRepository .createQueryBuilder('comment') .where('comment.userId = :userId', { userId }); if (accountId) { queryBuilder.andWhere('comment.accountId = :accountId', { accountId }); } if (workId) { // 直接使用 workId 查询 queryBuilder.andWhere('comment.workId = :workId', { workId }); logger.info(`Querying comments for workId: ${workId}`); } if (platform) { queryBuilder.andWhere('comment.platform = :platform', { platform }); } if (isRead !== undefined) { queryBuilder.andWhere('comment.isRead = :isRead', { isRead }); } if (keyword) { queryBuilder.andWhere( '(comment.content LIKE :keyword OR comment.authorName LIKE :keyword)', { keyword: `%${keyword}%` } ); } // 打印查询 SQL 用于调试 logger.info(`Comment query: userId=${userId}, accountId=${accountId}, workId=${workId}, platform=${platform}`); const [comments, total] = await queryBuilder .orderBy('comment.commentTime', 'DESC') .skip(skip) .take(pageSize) .getManyAndCount(); logger.info(`Found ${total} comments`); return { items: comments.map(this.formatComment), total, page, pageSize, totalPages: Math.ceil(total / pageSize), }; } async getStats(userId: number): Promise { const totalCount = await this.commentRepository.count({ where: { userId } }); const unreadCount = await this.commentRepository.count({ where: { userId, isRead: false } }); const unrepliedCount = await this.commentRepository .createQueryBuilder('comment') .where('comment.userId = :userId', { userId }) .andWhere('comment.replyContent IS NULL') .getCount(); const today = new Date(); today.setHours(0, 0, 0, 0); const todayCount = await this.commentRepository .createQueryBuilder('comment') .where('comment.userId = :userId', { userId }) .andWhere('comment.createdAt >= :today', { today }) .getCount(); return { totalCount, unreadCount, unrepliedCount, todayCount }; } async markAsRead(userId: number, commentIds: number[]): Promise { await this.commentRepository .createQueryBuilder() .update(Comment) .set({ isRead: true }) .where('userId = :userId AND id IN (:...commentIds)', { userId, commentIds }) .execute(); } async replyComment(userId: number, commentId: number, content: string): Promise { const comment = await this.commentRepository.findOne({ where: { id: commentId, userId }, }); if (!comment) { throw new AppError('评论不存在', HTTP_STATUS.NOT_FOUND, ERROR_CODES.COMMENT_NOT_FOUND); } // TODO: 调用平台适配器发送回复 // 这里只更新本地记录 await this.commentRepository.update(commentId, { replyContent: content, repliedAt: new Date(), isRead: true, }); const updated = await this.commentRepository.findOne({ where: { id: commentId } }); wsManager.sendToUser(userId, WS_EVENTS.COMMENT_REPLIED, { comment: this.formatComment(updated!), }); return this.formatComment(updated!); } async batchReply( userId: number, commentIds: number[], content: string ): Promise<{ success: number; failed: number }> { let success = 0; let failed = 0; for (const commentId of commentIds) { try { await this.replyComment(userId, commentId, content); success++; } catch { failed++; } } return { success, failed }; } /** * 异步同步评论(后台执行,通过 WebSocket 通知结果) */ syncCommentsAsync(userId: number, accountId?: number): void { // 通知用户同步已开始 - 直接使用字符串,并在 payload 中加入 event 字段 wsManager.sendToUser(userId, 'comment:sync_started', { event: 'sync_started', accountId, message: '正在同步评论...', }); // 进度回调:在处理每个作品时发送进度更新 const onProgress = (current: number, total: number, workTitle: string) => { const progress = total > 0 ? Math.round((current / total) * 100) : 0; wsManager.sendToUser(userId, 'comment:sync_progress', { event: 'sync_progress', accountId, current, total, progress, workTitle, message: `正在同步: ${workTitle || `作品 ${current}/${total}`}`, }); }; // 后台执行同步任务 this.syncComments(userId, accountId, onProgress) .then((result) => { logger.info(`Comment sync completed: synced ${result.synced} comments from ${result.accounts} accounts`); // 同步完成,通知用户 wsManager.sendToUser(userId, 'comment:synced', { event: 'synced', accountId, syncedCount: result.synced, accountCount: result.accounts, message: `同步完成,共同步 ${result.synced} 条评论`, }); }) .catch((error) => { logger.error('Comment sync failed:', error); // 同步失败,通知用户 wsManager.sendToUser(userId, 'comment:sync_failed', { event: 'sync_failed', accountId, message: error instanceof Error ? error.message : '同步失败,请稍后重试', }); }); } /** * 同步指定账号的评论 * @param onProgress 进度回调 (current, total, workTitle) */ async syncComments( userId: number, accountId?: number, onProgress?: (current: number, total: number, workTitle: string) => void ): Promise<{ synced: number; accounts: number }> { const accountRepository = AppDataSource.getRepository(PlatformAccount); // 获取需要同步的账号列表 const whereCondition: { userId: number; id?: number; platform?: string } = { userId }; if (accountId) { whereCondition.id = accountId; } const accounts = await accountRepository.find({ where: whereCondition }); if (accounts.length === 0) { throw new AppError('没有找到可同步的账号', HTTP_STATUS.NOT_FOUND, ERROR_CODES.ACCOUNT_NOT_FOUND); } let totalSynced = 0; let syncedAccounts = 0; for (const account of accounts) { try { // 只处理支持的平台 if (account.platform !== 'douyin' && account.platform !== 'xiaohongshu' && account.platform !== 'weixin_video') { logger.info(`Skipping unsupported platform: ${account.platform}`); continue; } // 解密 Cookie if (!account.cookieData) { logger.warn(`Account ${account.id} has no cookies`); continue; } let decryptedCookies: string; try { decryptedCookies = CookieManager.decrypt(account.cookieData); } catch { decryptedCookies = account.cookieData; } // 解析 Cookie - 支持两种格式 let cookies: CookieData[]; try { // 先尝试 JSON 格式 cookies = JSON.parse(decryptedCookies); } catch { // 如果 JSON 解析失败,尝试解析 "name=value; name2=value2" 格式 cookies = this.parseCookieString(decryptedCookies, account.platform); if (cookies.length === 0) { logger.error(`Invalid cookie format for account ${account.id}`); continue; } } // 获取评论数据 - 根据平台类型调用不同方法 logger.info(`Syncing comments for account ${account.id} (${account.platform})...`); let workComments: Array<{ videoId: string; videoTitle: string; comments: Array<{ commentId: string; authorId: string; authorName: string; authorAvatar: string; content: string; likeCount: number; commentTime: string; replyCount?: number; parentCommentId?: string; }>; }> = []; if (account.platform === 'douyin') { workComments = await headlessBrowserService.fetchDouyinCommentsViaApi(cookies); } else if (account.platform === 'xiaohongshu') { workComments = await headlessBrowserService.fetchXiaohongshuCommentsViaApi(cookies); } else if (account.platform === 'weixin_video') { workComments = await headlessBrowserService.fetchWeixinVideoCommentsViaApi(cookies); } // 获取该账号的所有作品,用于关联 const workRepository = AppDataSource.getRepository(Work); const accountWorks = await workRepository.find({ where: { userId, accountId: account.id }, }); logger.info(`Found ${accountWorks.length} works for account ${account.id}`); // 保存评论到数据库 let accountSynced = 0; const totalWorks = workComments.length; // 创建 platformVideoId -> workId 的快速映射 const videoIdToWorkMap = new Map(); for (const work of accountWorks) { if (work.platformVideoId) { videoIdToWorkMap.set(work.platformVideoId, { id: work.id, title: work.title }); // 同时存储不带前缀的版本(如果 platformVideoId 是 "douyin_xxx" 格式) if (work.platformVideoId.includes('_')) { const parts = work.platformVideoId.split('_'); if (parts.length >= 2) { videoIdToWorkMap.set(parts.slice(1).join('_'), { id: work.id, title: work.title }); } } } } logger.info(`Created videoId mapping with ${videoIdToWorkMap.size} entries`); for (let workIndex = 0; workIndex < workComments.length; workIndex++) { const workComment = workComments[workIndex]; // 发送进度更新 if (onProgress) { onProgress(workIndex + 1, totalWorks, workComment.videoTitle || `作品 ${workIndex + 1}`); } let workId: number | null = null; const commentVideoId = workComment.videoId?.toString() || ''; const commentVideoTitle = workComment.videoTitle?.trim() || ''; logger.info(`Trying to match work for videoId: "${commentVideoId}", title: "${commentVideoTitle}"`); // 1. 【首选】通过 platformVideoId (aweme_id) 匹配 - 最可靠的方式 if (commentVideoId) { // 直接匹配 if (videoIdToWorkMap.has(commentVideoId)) { const matched = videoIdToWorkMap.get(commentVideoId)!; workId = matched.id; logger.info(`Matched work by videoId: ${commentVideoId} -> workId: ${workId}, title: "${matched.title}"`); } // 尝试带平台前缀匹配 if (!workId) { const prefixedId = `douyin_${commentVideoId}`; if (videoIdToWorkMap.has(prefixedId)) { const matched = videoIdToWorkMap.get(prefixedId)!; workId = matched.id; logger.info(`Matched work by prefixed videoId: ${prefixedId} -> workId: ${workId}`); } } // 遍历匹配(处理各种格式) if (!workId) { const matchedWork = accountWorks.find(w => { if (!w.platformVideoId) return false; // 尝试各种匹配方式 return w.platformVideoId === commentVideoId || w.platformVideoId === `douyin_${commentVideoId}` || w.platformVideoId.endsWith(`_${commentVideoId}`) || w.platformVideoId.includes(commentVideoId); }); if (matchedWork) { workId = matchedWork.id; logger.info(`Matched work by videoId iteration: ${commentVideoId} -> workId: ${workId}, platformVideoId: ${matchedWork.platformVideoId}`); } } } // 2. 通过标题精确匹配 if (!workId && commentVideoTitle) { let matchedWork = accountWorks.find(w => { if (!w.title) return false; return w.title.trim() === commentVideoTitle; }); // 去除空白字符后匹配 if (!matchedWork) { const normalizedCommentTitle = commentVideoTitle.replace(/\s+/g, ''); matchedWork = accountWorks.find(w => { if (!w.title) return false; return w.title.trim().replace(/\s+/g, '') === normalizedCommentTitle; }); } // 包含匹配 if (!matchedWork) { matchedWork = accountWorks.find(w => { if (!w.title) return false; const workTitle = w.title.trim(); const shortCommentTitle = commentVideoTitle.slice(0, 50); const shortWorkTitle = workTitle.slice(0, 50); return workTitle.includes(shortCommentTitle) || commentVideoTitle.includes(shortWorkTitle) || shortWorkTitle.includes(shortCommentTitle) || shortCommentTitle.includes(shortWorkTitle); }); } // 模糊匹配 if (!matchedWork) { const normalizeTitle = (title: string) => { return title.replace(/[^\u4e00-\u9fa5a-zA-Z0-9]/g, '').toLowerCase(); }; const normalizedCommentTitle = normalizeTitle(commentVideoTitle); matchedWork = accountWorks.find(w => { if (!w.title) return false; const normalizedWorkTitle = normalizeTitle(w.title); return normalizedWorkTitle.slice(0, 40) === normalizedCommentTitle.slice(0, 40) || normalizedWorkTitle.includes(normalizedCommentTitle.slice(0, 30)) || normalizedCommentTitle.includes(normalizedWorkTitle.slice(0, 30)); }); } if (matchedWork) { workId = matchedWork.id; logger.info(`Matched work by title: "${matchedWork.title}" -> workId: ${workId}`); } } // 3. 如果只有一个作品,直接关联 if (!workId && accountWorks.length === 1) { workId = accountWorks[0].id; logger.info(`Only one work, using default: workId: ${workId}`); } logger.info(`Final work mapping: videoId="${commentVideoId}", title="${commentVideoTitle}", workId=${workId}`); for (const comment of workComment.comments) { try { // 过滤无效评论内容 - 放宽限制,只过滤纯操作按钮文本 if (!comment.content || /^(回复|删除|举报|点赞|分享|收藏)$/.test(comment.content.trim())) { logger.debug(`Skipping invalid comment content: ${comment.content}`); continue; } // 检查评论是否已存在(基于内容+作者+账号的去重) const existing = await this.commentRepository .createQueryBuilder('comment') .where('comment.accountId = :accountId', { accountId: account.id }) .andWhere('comment.authorName = :authorName', { authorName: comment.authorName }) .andWhere('comment.content = :content', { content: comment.content }) .getOne(); if (!existing) { const newComment = this.commentRepository.create({ userId, accountId: account.id, workId, // 关联作品 ID platform: account.platform as PlatformType, videoId: workComment.videoId, commentId: comment.commentId, authorId: comment.authorId, authorName: comment.authorName, authorAvatar: comment.authorAvatar, content: comment.content, likeCount: comment.likeCount, commentTime: comment.commentTime ? new Date(comment.commentTime) : new Date(), isRead: false, isTop: false, }); await this.commentRepository.save(newComment); accountSynced++; logger.info(`Saved comment: "${comment.content.slice(0, 30)}..." -> workId: ${workId}`); } else { // 如果评论已存在但没有 workId,更新它 if (!existing.workId && workId) { await this.commentRepository.update(existing.id, { workId }); logger.info(`Updated existing comment workId: ${existing.id} -> ${workId}`); } } } catch (saveError) { logger.warn(`Failed to save comment ${comment.commentId}:`, saveError); } } } if (accountSynced > 0) { totalSynced += accountSynced; syncedAccounts++; logger.info(`Synced ${accountSynced} comments for account ${account.id}`); // 注意:不在这里发送 COMMENT_SYNCED,而是由 syncCommentsAsync 统一发送 } } catch (accountError) { logger.error(`Failed to sync comments for account ${account.id}:`, accountError); } } // 尝试修复没有 workId 的现有评论 await this.fixOrphanedComments(userId); return { synced: totalSynced, accounts: syncedAccounts }; } /** * 修复没有 workId 的评论 */ private async fixOrphanedComments(userId: number): Promise { try { const workRepository = AppDataSource.getRepository(Work); // 获取所有没有 workId 的评论 const orphanedComments = await this.commentRepository.find({ where: { userId, workId: undefined as unknown as number }, }); if (orphanedComments.length === 0) return; logger.info(`Found ${orphanedComments.length} comments without workId, trying to fix...`); // 获取用户的所有作品 const works = await workRepository.find({ where: { userId } }); // 创建多种格式的 videoId -> workId 映射 const videoIdToWork = new Map(); for (const work of works) { if (work.platformVideoId) { // 存储原始 platformVideoId videoIdToWork.set(work.platformVideoId, { id: work.id, title: work.title }); // 如果是 "douyin_xxx" 格式,也存储纯 ID if (work.platformVideoId.startsWith('douyin_')) { const pureId = work.platformVideoId.replace('douyin_', ''); videoIdToWork.set(pureId, { id: work.id, title: work.title }); } // 如果是纯数字 ID,也存储带前缀的版本 if (/^\d+$/.test(work.platformVideoId)) { videoIdToWork.set(`douyin_${work.platformVideoId}`, { id: work.id, title: work.title }); } } } let fixedCount = 0; for (const comment of orphanedComments) { let matchedWorkId: number | null = null; // 1. 尝试通过 videoId 精确匹配 if (comment.videoId) { if (videoIdToWork.has(comment.videoId)) { matchedWorkId = videoIdToWork.get(comment.videoId)!.id; } // 尝试带前缀匹配 if (!matchedWorkId) { const prefixedId = `douyin_${comment.videoId}`; if (videoIdToWork.has(prefixedId)) { matchedWorkId = videoIdToWork.get(prefixedId)!.id; } } // 尝试去掉前缀匹配 if (!matchedWorkId && comment.videoId.includes('_')) { const pureId = comment.videoId.split('_').pop()!; if (videoIdToWork.has(pureId)) { matchedWorkId = videoIdToWork.get(pureId)!.id; } } // 遍历查找包含关系 if (!matchedWorkId) { const matchedWork = works.find(w => w.platformVideoId?.includes(comment.videoId!) || comment.videoId!.includes(w.platformVideoId || '') ); if (matchedWork) { matchedWorkId = matchedWork.id; } } } // 2. 尝试通过账号匹配(如果该账号只有一个作品) if (!matchedWorkId) { const accountWorks = works.filter(w => w.accountId === comment.accountId); if (accountWorks.length === 1) { matchedWorkId = accountWorks[0].id; } } if (matchedWorkId) { await this.commentRepository.update(comment.id, { workId: matchedWorkId }); fixedCount++; logger.info(`Fixed comment ${comment.id} (videoId: ${comment.videoId}) -> workId: ${matchedWorkId}`); } } logger.info(`Fixed ${fixedCount}/${orphanedComments.length} orphaned comments`); } catch (error) { logger.warn('Failed to fix orphaned comments:', error); } } /** * 将 cookie 字符串解析为 cookie 列表 */ private parseCookieString(cookieString: string, platform: string): CookieData[] { // 获取平台对应的域名 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: CookieData[] = []; 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 formatComment(comment: Comment): CommentType { return { id: comment.id, userId: comment.userId, accountId: comment.accountId, workId: comment.workId || undefined, platform: comment.platform!, videoId: comment.videoId || '', platformVideoUrl: comment.platformVideoUrl, commentId: comment.commentId, parentCommentId: comment.parentCommentId, authorId: comment.authorId || '', authorName: comment.authorName || '', authorAvatar: comment.authorAvatar, content: comment.content || '', likeCount: comment.likeCount, replyContent: comment.replyContent, repliedAt: comment.repliedAt?.toISOString() || null, isRead: comment.isRead, isTop: comment.isTop, commentTime: comment.commentTime?.toISOString() || '', createdAt: comment.createdAt.toISOString(), }; } }