| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509 |
- 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<WorkStats> {
- 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<WorkType> {
- 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<number> {
- 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<string>();
- 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<void> {
- // 获取该账号下所有作品
- 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<string, string> = {
- '已发布': '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<void> {
- 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<string>
- ): 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<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: { 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();
|