| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659 |
- 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<PaginatedData<CommentType>> {
- 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<CommentStats> {
- 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<void> {
- 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<CommentType> {
- 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<string, { id: number; title: string }>();
- 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<void> {
- 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<string, { id: number; title: string }>();
- 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<string, string> = {
- 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(),
- };
- }
- }
|