| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800 |
- import { AppDataSource, Work, PlatformAccount, Comment, WorkDayStatistics } 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';
- import { taskQueueService } from './TaskQueueService.js';
- export class WorkService {
- private workRepository = AppDataSource.getRepository(Work);
- private accountRepository = AppDataSource.getRepository(PlatformAccount);
- private commentRepository = AppDataSource.getRepository(Comment);
- private workDayStatisticsService = new WorkDayStatisticsService();
- /**
- * 获取作品列表
- */
- 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,
- platform?: string,
- onProgress?: (progress: number, currentStep: string) => void
- ): Promise<{
- synced: number;
- accounts: number;
- accountSummaries: Array<{
- accountId: number;
- platform: PlatformType;
- worksListLength: number;
- worksCount: number;
- source?: string;
- pythonAvailable?: boolean;
- syncedCount: number;
- }>;
- }> {
- logger.info(`[SyncWorks] Starting sync for userId: ${userId}, accountId: ${accountId || 'all'}, platform: ${platform || '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 });
- } else if (platform) {
- queryBuilder.andWhere('account.platform = :platform', { platform });
- }
- const accounts = await queryBuilder.getMany();
- logger.info(`[SyncWorks] Found ${accounts.length} accounts (active + expired)`);
- let totalSynced = 0;
- let accountCount = 0;
- const accountSummaries: Array<{
- accountId: number;
- platform: PlatformType;
- worksListLength: number;
- worksCount: number;
- source?: string;
- pythonAvailable?: boolean;
- syncedCount: number;
- }> = [];
- for (let i = 0; i < accounts.length; i++) {
- const account = accounts[i];
- try {
- logger.info(`[SyncWorks] Syncing account ${account.id} (${account.platform}, status: ${account.status})`);
- onProgress?.(
- Math.min(95, 5 + Math.round((i / Math.max(1, accounts.length)) * 90)),
- `同步账号 ${i + 1}/${accounts.length}: ${account.accountName || account.id} (${account.platform})`
- );
- const result = await this.syncAccountWorks(userId, account, (p, step) => {
- const overall = 5 + Math.round(((i + Math.max(0, Math.min(1, p))) / Math.max(1, accounts.length)) * 90);
- onProgress?.(Math.min(95, overall), step);
- });
- totalSynced += result.syncedCount;
- accountCount++;
- accountSummaries.push({
- accountId: account.id,
- platform: account.platform as PlatformType,
- worksListLength: result.worksListLength,
- worksCount: result.worksCount,
- source: result.source,
- pythonAvailable: result.pythonAvailable,
- syncedCount: result.syncedCount,
- });
- logger.info(`[SyncWorks] Account ${account.id} synced ${result.syncedCount} works`);
- // 如果同步成功且账号状态是 expired,则恢复为 active
- if (result.syncedCount > 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);
- }
- }
- onProgress?.(100, `同步完成:共同步 ${totalSynced} 条作品(${accountCount} 个账号)`);
- logger.info(`[SyncWorks] Complete: ${totalSynced} works synced from ${accountCount} accounts`);
- return { synced: totalSynced, accounts: accountCount, accountSummaries };
- }
- /**
- * 同步单个账号的作品
- */
- private async syncAccountWorks(
- userId: number,
- account: PlatformAccount,
- onProgress?: (progress: number, currentStep: string) => void
- ): Promise<{
- syncedCount: number;
- worksListLength: number;
- worksCount: number;
- source?: string;
- pythonAvailable?: boolean;
- }> {
- logger.info(`[SyncAccountWorks] Starting for account ${account.id} (${account.platform})`);
- if (!account.cookieData) {
- logger.warn(`Account ${account.id} has no cookie data`);
- return { syncedCount: 0, worksListLength: 0, worksCount: 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 { syncedCount: 0, worksListLength: 0, worksCount: 0 };
- }
- }
- // 获取作品列表
- logger.info(`[SyncAccountWorks] Fetching account info from ${platform}...`);
- onProgress?.(0.1, `获取作品列表中:${account.accountName || account.id} (${platform})`);
- const accountInfo = await headlessBrowserService.fetchAccountInfo(platform, cookieList, {
- onWorksFetchProgress: (info) => {
- const declaredTotal = typeof info.declaredTotal === 'number' ? info.declaredTotal : 0;
- const ratio = declaredTotal > 0 ? Math.min(1, info.totalSoFar / declaredTotal) : 0;
- onProgress?.(
- 0.1 + ratio * 0.2,
- `拉取作品中:${account.accountName || account.id} (${platform}) ${info.totalSoFar}/${declaredTotal || '?'}`
- );
- },
- });
- logger.info(`[SyncAccountWorks] Got ${accountInfo.worksList?.length || 0} works from API`);
- onProgress?.(
- 0.3,
- `拉取完成:${account.accountName || account.id} (${platform}) python=${accountInfo.pythonAvailable ? 'ok' : 'off'} source=${accountInfo.source || 'unknown'} list=${accountInfo.worksList?.length || 0} total=${accountInfo.worksCount || 0}`
- );
- let syncedCount = 0;
- // 收集远程作品的 platformVideoId
- const remotePlatformVideoIds = new Set<string>();
- if (accountInfo.worksList && accountInfo.worksList.length > 0) {
- const legacyToCanonicalInRun = new Map<string, string>();
- const processedPlatformVideoIds = new Set<string>();
- const total = accountInfo.worksList.length;
- for (const workItem of accountInfo.worksList) {
- const titleForId = (workItem.title || '').trim();
- const publishTimeForId = (workItem.publishTime || '').trim();
- const legacyFallbackId = `${platform}_${titleForId}_${publishTimeForId}`.substring(0, 100);
- let canonicalVideoId = (workItem.videoId || '').trim() || legacyFallbackId;
- if (platform === 'weixin_video') {
- const rawVideoId = (workItem.videoId || '').trim();
- if (rawVideoId) {
- legacyToCanonicalInRun.set(legacyFallbackId, rawVideoId);
- canonicalVideoId = rawVideoId;
- } else {
- const mapped = legacyToCanonicalInRun.get(legacyFallbackId);
- if (mapped) {
- canonicalVideoId = mapped;
- }
- }
- }
- if (processedPlatformVideoIds.has(canonicalVideoId)) {
- continue;
- }
- processedPlatformVideoIds.add(canonicalVideoId);
- remotePlatformVideoIds.add(canonicalVideoId);
- if (legacyFallbackId !== canonicalVideoId) {
- remotePlatformVideoIds.add(legacyFallbackId);
- }
- let work = await this.workRepository.findOne({
- where: { accountId: account.id, platformVideoId: canonicalVideoId },
- });
- if (platform === 'weixin_video' && work && legacyFallbackId !== canonicalVideoId) {
- const legacyWork = await this.workRepository.findOne({
- where: { accountId: account.id, platformVideoId: legacyFallbackId },
- });
- if (legacyWork && legacyWork.id !== work.id) {
- await AppDataSource.getRepository(Comment).update(
- { workId: legacyWork.id },
- { workId: work.id }
- );
- await this.workDayStatisticsService.deleteByWorkId(legacyWork.id);
- await this.workRepository.delete(legacyWork.id);
- }
- }
- if (!work && legacyFallbackId !== canonicalVideoId) {
- const legacyWork = await this.workRepository.findOne({
- where: { accountId: account.id, platformVideoId: legacyFallbackId },
- });
- if (legacyWork) {
- const canonicalWork = await this.workRepository.findOne({
- where: { accountId: account.id, platformVideoId: canonicalVideoId },
- });
- if (canonicalWork) {
- await AppDataSource.getRepository(Comment).update(
- { workId: legacyWork.id },
- { workId: canonicalWork.id }
- );
- await this.workDayStatisticsService.deleteByWorkId(legacyWork.id);
- await this.workRepository.delete(legacyWork.id);
- work = canonicalWork;
- } else {
- await this.workRepository.update(legacyWork.id, {
- platformVideoId: canonicalVideoId,
- });
- work = { ...legacyWork, platformVideoId: canonicalVideoId };
- }
- }
- }
- if (work) {
- await this.workRepository.update(work.id, {
- title: workItem.title || work.title,
- coverUrl: workItem.coverUrl || work.coverUrl,
- videoUrl: workItem.videoUrl !== undefined ? workItem.videoUrl || null : work.videoUrl,
- duration: workItem.duration || work.duration,
- status: workItem.status || work.status,
- playCount: workItem.playCount ?? work.playCount,
- likeCount: workItem.likeCount ?? work.likeCount,
- commentCount: workItem.commentCount ?? work.commentCount,
- shareCount: workItem.shareCount ?? work.shareCount,
- collectCount: workItem.collectCount ?? work.collectCount,
- });
- } else {
- // 创建新作品
- const work = this.workRepository.create({
- userId,
- accountId: account.id,
- platform,
- platformVideoId: canonicalVideoId,
- title: workItem.title || '',
- coverUrl: workItem.coverUrl || '',
- videoUrl: workItem.videoUrl || null,
- 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,
- collectCount: workItem.collectCount || 0,
- });
- await this.workRepository.save(work);
- }
- syncedCount++;
- if (syncedCount === 1 || syncedCount === total || syncedCount % 10 === 0) {
- onProgress?.(0.3 + (syncedCount / total) * 0.65, `写入作品:${account.accountName || account.id} ${syncedCount}/${total}`);
- }
- }
- logger.info(`Synced ${syncedCount} works for account ${account.id}`);
- }
- if (platform === 'weixin_video') {
- await this.dedupeWeixinVideoWorks(account.id);
- }
- // 删除本地存在但远程已删除的作品
- const remoteListLength = accountInfo.worksList?.length || 0;
- const expectedRemoteCount = accountInfo.worksCount || 0;
- const remoteComplete =
- typeof accountInfo.worksListComplete === 'boolean'
- ? accountInfo.worksListComplete
- : expectedRemoteCount > 0
- ? remoteListLength >= expectedRemoteCount
- : remoteListLength > 0;
- let skipLocalDeletions = false;
- if (!remoteComplete) {
- logger.warn(
- `[SyncAccountWorks] Skipping local deletions for account ${account.id} because remote works list seems incomplete (remote=${remoteListLength}, expected=${expectedRemoteCount})`
- );
- skipLocalDeletions = true;
- } else if (remotePlatformVideoIds.size === 0) {
- logger.warn(`[SyncAccountWorks] Skipping local deletions for account ${account.id} because no remote IDs were collected`);
- skipLocalDeletions = true;
- } else {
- const localWorks = await this.workRepository.find({
- where: { accountId: account.id },
- });
- if (platform === 'weixin_video') {
- logger.info(`[SyncAccountWorks] Skipping local deletions for ${platform} account ${account.id} to avoid false deletions`);
- skipLocalDeletions = true;
- }
- const matchedCount = localWorks.reduce(
- (sum, w) => sum + (remotePlatformVideoIds.has(w.platformVideoId) ? 1 : 0),
- 0
- );
- const matchRatio = localWorks.length > 0 ? matchedCount / localWorks.length : 1;
- if (!skipLocalDeletions && localWorks.length >= 10 && matchRatio < 0.2) {
- logger.warn(
- `[SyncAccountWorks] Skipping local deletions for account ${account.id} because remote/local ID match ratio is too low (matched=${matchedCount}/${localWorks.length})`
- );
- skipLocalDeletions = true;
- }
- if (!skipLocalDeletions) {
- let deletedCount = 0;
- for (const localWork of localWorks) {
- if (!remotePlatformVideoIds.has(localWork.platformVideoId)) {
- await AppDataSource.getRepository(Comment).delete({ workId: localWork.id });
- await this.workDayStatisticsService.deleteByWorkId(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);
- }
- // 小红书:如果是新作品且 work_day_statistics 中尚无任何记录,则补首批日统计 & works.yesterday_*(不受14天限制)
- if (platform === 'xiaohongshu') {
- try {
- const works = await this.workRepository.find({
- where: { accountId: account.id, platform },
- select: ['id'],
- });
- const workIds = works.map((w) => w.id);
- if (workIds.length > 0) {
- const rows = await AppDataSource.getRepository(WorkDayStatistics)
- .createQueryBuilder('wds')
- .select('DISTINCT wds.work_id', 'workId')
- .where('wds.work_id IN (:...ids)', { ids: workIds })
- .getRawMany();
- const hasStats = new Set<number>(rows.map((r: any) => Number(r.workId)));
- const needInitIds = workIds.filter((id) => !hasStats.has(id));
- if (needInitIds.length > 0) {
- logger.info(
- `[SyncAccountWorks] XHS account ${account.id} has ${needInitIds.length} works without statistics, running initial note/base import.`
- );
- // 放入任务队列异步执行,避免阻塞同步作品流程
- taskQueueService.createTask(account.userId, {
- type: 'xhs_work_stats_backfill',
- title: `小红书作品补数(${needInitIds.length})`,
- accountId: account.id,
- platform: 'xiaohongshu',
- data: {
- workIds: needInitIds,
- },
- });
- }
- }
- } catch (err) {
- logger.error(
- `[SyncAccountWorks] Failed to backfill XHS work_day_statistics for account ${account.id}:`,
- err
- );
- }
- }
- return {
- syncedCount,
- worksListLength: accountInfo.worksList?.length || 0,
- worksCount: accountInfo.worksCount || 0,
- source: accountInfo.source,
- pythonAvailable: accountInfo.pythonAvailable,
- };
- }
- /**
- * 保存作品每日统计数据
- */
- private async saveWorkDayStatistics(account: PlatformAccount): Promise<void> {
- // 小红书作品的细分日统计通过 XiaohongshuWorkNoteStatisticsImportService 定时任务单独采集,
- // 这里的基于「作品当前总量」的快照统计对小红书意义不大,避免口径混乱,直接跳过。
- if (account.platform === 'xiaohongshu') {
- logger.info(
- `[SaveWorkDayStatistics] Skip snapshot-based work_day_statistics for xiaohongshu account ${account.id}, will be filled by dedicated XHS note statistics importer.`
- );
- return;
- }
- // 获取该账号下所有作品
- 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.workDayStatisticsService.deleteByWorkId(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,
- yesterdayPlayCount: work.yesterdayPlayCount,
- yesterdayLikeCount: work.yesterdayLikeCount,
- yesterdayCommentCount: work.yesterdayCommentCount,
- yesterdayShareCount: work.yesterdayShareCount,
- yesterdayCollectCount: work.yesterdayCollectCount,
- yesterdayRecommendCount: (work as any).yesterdayRecommendCount,
- yesterdayFansIncrease: work.yesterdayFansIncrease,
- yesterdayCoverClickRate: work.yesterdayCoverClickRate,
- yesterdayAvgWatchDuration: work.yesterdayAvgWatchDuration,
- yesterdayTotalWatchDuration: work.yesterdayTotalWatchDuration,
- yesterdayCompletionRate: work.yesterdayCompletionRate,
- yesterdayTwoSecondExitRate: work.yesterdayTwoSecondExitRate,
- yesterdayCompletionRate5s: work.yesterdayCompletionRate5s,
- yesterdayExposureCount: work.yesterdayExposureCount,
- createdAt: work.createdAt.toISOString(),
- updatedAt: work.updatedAt.toISOString(),
- };
- }
- private async dedupeWeixinVideoWorks(accountId: number): Promise<void> {
- const works = await this.workRepository.find({ where: { accountId } });
- const groups = new Map<string, Work[]>();
- for (const w of works) {
- if (!w.title || !w.publishTime) continue;
- const key = `${w.title}__${w.publishTime.toISOString()}`;
- const list = groups.get(key);
- if (list) list.push(w);
- else groups.set(key, [w]);
- }
- for (const list of groups.values()) {
- if (list.length <= 1) continue;
- list.sort((a, b) => {
- const qa = a.platformVideoId.startsWith('weixin_video_') ? 0 : 1;
- const qb = b.platformVideoId.startsWith('weixin_video_') ? 0 : 1;
- if (qa !== qb) return qb - qa;
- return (b.updatedAt?.getTime?.() || 0) - (a.updatedAt?.getTime?.() || 0);
- });
- const keep = list[0];
- for (const dup of list.slice(1)) {
- if (dup.id === keep.id) continue;
- await this.commentRepository.update({ workId: dup.id }, { workId: keep.id });
- await this.workRepository.delete(dup.id);
- }
- }
- }
- }
- export const workService = new WorkService();
|