import { AppDataSource, PublishTask, PublishResult, PlatformAccount, SystemConfig } from '../models/index.js'; import { AppError } from '../middleware/error.js'; import { ERROR_CODES, HTTP_STATUS, WS_EVENTS } from '@media-manager/shared'; import type { PublishTask as PublishTaskType, PublishTaskDetail, CreatePublishTaskRequest, PaginatedData, PlatformType, PublishProxyConfig, } from '@media-manager/shared'; import { wsManager } from '../websocket/index.js'; import { DouyinAdapter } from '../automation/platforms/douyin.js'; import { XiaohongshuAdapter } from '../automation/platforms/xiaohongshu.js'; import { WeixinAdapter } from '../automation/platforms/weixin.js'; import { BaijiahaoAdapter } from '../automation/platforms/baijiahao.js'; import { BasePlatformAdapter } from '../automation/platforms/base.js'; import { logger } from '../utils/logger.js'; import path from 'path'; import { config } from '../config/index.js'; import { CookieManager } from '../automation/cookie.js'; import { taskQueueService } from './TaskQueueService.js'; import { In } from 'typeorm'; interface GetTasksParams { page: number; pageSize: number; status?: string; } const HEADFUL_RETRY_TIMEOUT_MS = 30 * 60 * 1000; export class PublishService { private taskRepository = AppDataSource.getRepository(PublishTask); private resultRepository = AppDataSource.getRepository(PublishResult); private accountRepository = AppDataSource.getRepository(PlatformAccount); private systemConfigRepository = AppDataSource.getRepository(SystemConfig); // 平台适配器工厂映射:每次发布独立创建,避免并发任务共享同一个 browser/page 状态 private adapterFactories: Map BasePlatformAdapter> = new Map(); constructor() { // 初始化平台适配器 this.adapterFactories.set('douyin', () => new DouyinAdapter()); this.adapterFactories.set('xiaohongshu', () => new XiaohongshuAdapter()); this.adapterFactories.set('weixin_video', () => new WeixinAdapter() as BasePlatformAdapter); this.adapterFactories.set('baijiahao', () => new BaijiahaoAdapter()); } /** * 将技术性错误消息翻译为用户友好的描述 */ private friendlyErrorMessage(error: unknown): string { const raw = error instanceof Error ? error.message : String(error || '发布失败'); const patterns: [RegExp, string][] = [ [/CAPTCHA_REQUIRED[::]?\s*(.*)/i, '需要验证码,请使用有头浏览器重新发布'], [/timeout/i, '操作超时,请稍后重试'], [/net::ERR_/i, '网络连接失败,请检查网络'], [/ECONNREFUSED|ECONNRESET/i, '网络连接被拒绝,请检查代理设置'], [/Navigation timeout/i, '页面加载超时,请稍后重试'], [/Target closed|browser closed/i, '浏览器已关闭,请重试'], [/automation.*not found|automation.*不可用/i, '本地自动化服务不可用,请联系管理员'], [/cookie.*过期|cookie.*失效|登录已过/i, '账号登录已过期,请重新登录'], [/请查看截图/i, '发布结果待确认,请前往平台创作者中心查看'], [/502|503|504|Bad Gateway|Service Unavailable/i, '平台服务器繁忙,请稍后重试'], [/upload.*fail|上传.*失败/i, '视频上传失败,请检查视频格式和大小'], ]; for (const [pattern, message] of patterns) { if (pattern.test(raw)) return message; } if (/^[\u4e00-\u9fff]/.test(raw) && raw.length <= 50) return raw; return raw.length > 100 ? raw.slice(0, 100) + '...' : raw; } /** * 获取平台适配器 */ private getAdapter(platform: PlatformType) { const createAdapter = this.adapterFactories.get(platform); if (!createAdapter) { throw new AppError(`不支持的平台: ${platform}`, HTTP_STATUS.BAD_REQUEST, ERROR_CODES.VALIDATION); } return createAdapter(); } private normalizeTargetAccountIds(targetAccounts: unknown): number[] { if (!Array.isArray(targetAccounts)) { return []; } const ids = targetAccounts .map((accountId) => Number(accountId)) .filter((accountId) => Number.isInteger(accountId) && accountId > 0); return [...new Set(ids)]; } private async getOwnedTargetAccountIds( userId: number, requestedAccountIds: number[] ): Promise<{ validAccountIds: number[]; invalidAccountIds: number[] }> { if (requestedAccountIds.length === 0) { return { validAccountIds: [], invalidAccountIds: [] }; } const accounts = await this.accountRepository.find({ where: { id: In(requestedAccountIds), userId }, select: ['id'], }); const ownedAccountIds = new Set(accounts.map((account) => account.id)); return { validAccountIds: requestedAccountIds.filter((accountId) => ownedAccountIds.has(accountId)), invalidAccountIds: requestedAccountIds.filter((accountId) => !ownedAccountIds.has(accountId)), }; } private resolvePublishText(task: PublishTask, platform: PlatformType): { title: string; description: string } { const taskTitle = String(task.title || '').trim(); const taskDescription = task.description == null ? '' : String(task.description).trim(); let title = taskTitle; let description = taskDescription; const platformConfig = Array.isArray(task.platformConfigs) ? task.platformConfigs.find((cfg: any) => String(cfg?.platform || '') === String(platform)) : undefined; if (platformConfig) { const cfgTitle = typeof platformConfig.title === 'string' ? platformConfig.title.trim() : ''; const hasCfgDescription = Object.prototype.hasOwnProperty.call(platformConfig, 'description'); const cfgDescription = typeof platformConfig.description === 'string' ? platformConfig.description.trim() : ''; if (cfgTitle) { title = cfgTitle; } if (hasCfgDescription) { description = cfgDescription; } } return { title, description }; } async getTasks(userId: number, params: GetTasksParams): Promise> { const { page, pageSize, status } = params; const skip = (page - 1) * pageSize; const queryBuilder = this.taskRepository .createQueryBuilder('task') .where('task.userId = :userId', { userId }); if (status) { queryBuilder.andWhere('task.status = :status', { status }); } const [tasks, total] = await queryBuilder .orderBy('task.createdAt', 'DESC') .skip(skip) .take(pageSize) .getManyAndCount(); // 批量查询每个任务的发布结果统计 // 修复 #6068:同时查询实际目标账号 ID,删除账号后 targetAccounts 原始缓存不再准确 let resultStatsMap: Record = {}; if (tasks.length > 0) { const taskIds = tasks.map(t => t.id); const stats = await this.resultRepository .createQueryBuilder('r') .select('r.taskId', 'taskId') .addSelect('COUNT(CASE WHEN r.status = :success THEN 1 END)', 'successCount') .addSelect('COUNT(CASE WHEN r.status = :failed THEN 1 END)', 'failCount') .addSelect('GROUP_CONCAT(DISTINCT r.account_id)', 'actualTargetAccountIds') .setParameter('success', 'success') .setParameter('failed', 'failed') .where('r.taskId IN (:...taskIds)', { taskIds }) .groupBy('r.taskId') .getRawMany(); for (const s of stats) { resultStatsMap[s.taskId] = { successCount: Number(s.successCount) || 0, failCount: Number(s.failCount) || 0, actualTargetAccounts: s.actualTargetAccountIds ? String(s.actualTargetAccountIds).split(',').map(Number).filter(Boolean) : [], }; } } return { items: tasks.map(t => { const formatted = this.formatTask(t); const stats = resultStatsMap[t.id]; if (stats) { formatted.successCount = stats.successCount; formatted.failCount = stats.failCount; // 修复 #6068:使用 PublishResult 中的实际账号 ID 覆盖原始缓存,删除账号后保持准确 if (stats.actualTargetAccounts.length > 0) { formatted.targetAccounts = stats.actualTargetAccounts; } } return formatted; }), total, page, pageSize, totalPages: Math.ceil(total / pageSize), }; } async getTaskById(userId: number, taskId: number): Promise { const task = await this.taskRepository.findOne({ where: { id: taskId, userId }, relations: ['results'], }); if (!task) { throw new AppError('任务不存在', HTTP_STATUS.NOT_FOUND, ERROR_CODES.NOT_FOUND); } // 基于 PublishResult 记录重新计算任务状态(修复 #6068:删除账号后状态不一致) // 修复 Bug #6165:当 task.status 为 'processing' 时也参与状态聚合,否则已完成的任务会一直显示"发布中" const results = task.results || []; const actualTargetAccounts = results.map(r => r.accountId); const completedResults = results.filter(r => r.status === 'success'); const failedResults = results.filter(r => r.status === 'failed'); let computedStatus = task.status; if (results.length > 0 && task.status !== 'pending' && task.status !== 'cancelled') { // 只有当任务尚未开始(pending)或已取消时,才保持原始状态 // 其他情况(processing 等)都根据 results 重新计算 if (completedResults.length === results.length) { computedStatus = 'completed'; } else if (failedResults.length === results.length) { computedStatus = 'failed'; } else if (completedResults.length + failedResults.length === results.length) { computedStatus = 'failed'; // 部分失败也标记为 failed(保持向后兼容) } else { computedStatus = task.status; // 仍有未完成的,保持原状态 } } return this.formatTaskDetail(task, actualTargetAccounts, computedStatus); } async createTask(userId: number, data: CreatePublishTaskRequest): Promise { // 验证目标账号是否存在 const requestedAccountIds = this.normalizeTargetAccountIds(data.targetAccounts); const { validAccountIds, invalidAccountIds } = await this.getOwnedTargetAccountIds(userId, requestedAccountIds); if (validAccountIds.length === 0) { throw new AppError('所选账号不存在或已被删除', HTTP_STATUS.BAD_REQUEST, ERROR_CODES.VALIDATION); } // 去重(修复#6034:作品数可能重复) const dedupAccountIds = validAccountIds; if (invalidAccountIds.length > 0) { logger.warn(`[PublishService] ${invalidAccountIds.length} invalid accounts skipped: ${invalidAccountIds.join(', ')}`); } const videoPath = data.videoPath || ''; const task = this.taskRepository.create({ userId, videoPath, videoFilename: videoPath ? videoPath.split(/[\\/]/).pop() || null : null, title: data.title, description: data.description || null, coverPath: data.coverPath || null, tags: data.tags || null, targetAccounts: dedupAccountIds, // 只保存有效且去重的账号 ID platformConfigs: data.platformConfigs || null, publishProxy: data.publishProxy || null, status: 'pending', // 初始状态为 pending,任务队列执行时再更新为 processing scheduledAt: data.scheduledAt ? new Date(data.scheduledAt) : null, }); await this.taskRepository.save(task); // 创建发布结果记录(只为有效且去重的账号创建) const results = dedupAccountIds.map((accountId) => this.resultRepository.create({ taskId: task.id, accountId, })); if (results.length > 0) { await this.resultRepository.save(results); } // 通知客户端 wsManager.sendToUser(userId, WS_EVENTS.TASK_CREATED, { task: this.formatTask(task) }); // 返回任务信息,发布任务将通过任务队列执行 // 调用者需要调用 taskQueueService.createTask 来创建队列任务 return this.formatTask(task); } async updateTask( userId: number, taskId: number, data: Partial, ): Promise { const task = await this.taskRepository.findOne({ where: { id: taskId, userId }, relations: ['results'], }); if (!task) { throw new AppError('任务不存在', HTTP_STATUS.NOT_FOUND, ERROR_CODES.NOT_FOUND); } if (task.status === 'processing') { throw new AppError('正在执行的任务不能修改', HTTP_STATUS.BAD_REQUEST, ERROR_CODES.VALIDATION); } const requestedAccounts = Array.isArray(data.targetAccounts) ? data.targetAccounts : (Array.isArray(task.targetAccounts) ? task.targetAccounts : []); const requestedAccountIds = this.normalizeTargetAccountIds(requestedAccounts); const { validAccountIds, invalidAccountIds } = await this.getOwnedTargetAccountIds(userId, requestedAccountIds); if (validAccountIds.length === 0) { throw new AppError('所选账号不存在或已被删除', HTTP_STATUS.BAD_REQUEST, ERROR_CODES.VALIDATION); } const dedupAccountIds = validAccountIds; if (invalidAccountIds.length > 0) { logger.warn(`[PublishService] ${invalidAccountIds.length} invalid accounts skipped on update: ${invalidAccountIds.join(', ')}`); } const nextVideoPath = data.videoPath ?? task.videoPath ?? ''; const nextTitle = data.title ?? task.title ?? ''; task.videoPath = nextVideoPath; task.videoFilename = nextVideoPath ? nextVideoPath.split(/[\\/]/).pop() || null : null; task.title = nextTitle; task.description = data.description ?? task.description ?? null; task.coverPath = data.coverPath ?? task.coverPath ?? null; task.tags = data.tags ?? task.tags ?? null; task.targetAccounts = dedupAccountIds; task.platformConfigs = data.platformConfigs ?? task.platformConfigs ?? null; task.publishProxy = data.publishProxy ?? task.publishProxy ?? null; task.scheduledAt = data.scheduledAt ? new Date(data.scheduledAt) : null; task.status = 'pending'; task.publishedAt = null; await this.taskRepository.save(task); await this.resultRepository.delete({ taskId }); const results = dedupAccountIds.map((accountId) => this.resultRepository.create({ taskId: task.id, accountId })); if (results.length > 0) { await this.resultRepository.save(results); } wsManager.sendToUser(userId, WS_EVENTS.TASK_CREATED, { task: this.formatTask(task) }); return this.formatTask(task); } /** * 带进度回调的发布任务执行 */ async executePublishTaskWithProgress( taskId: number, userId: number, onProgress?: (progress: number, message: string) => void ): Promise { const task = await this.taskRepository.findOne({ where: { id: taskId, userId }, relations: ['results'], }); if (!task) { throw new Error(`Task ${taskId} not found or does not belong to user ${userId}`); } // 更新任务状态为处理中 await this.taskRepository.update(taskId, { status: 'processing' }); wsManager.sendToUser(userId, WS_EVENTS.TASK_STATUS_CHANGED, { taskId, status: 'processing', }); const results = task.results || []; // 筛选需要执行的发布结果:跳过已成功的(修复#6038重试把成功的也发了) const resultsToExecute = results.filter(r => r.status !== 'success'); const skippedCount = results.length - resultsToExecute.length; if (skippedCount > 0) { logger.info(`Skipping ${skippedCount} already successful accounts`); } let successCount = 0; let failCount = 0; const executeCount = resultsToExecute.length; const successAccountIds = new Set(); let publishProxyExtra: Awaited> = null; try { publishProxyExtra = await this.buildPublishProxyExtra(task.publishProxy); } catch (error) { const errorMessage = error instanceof Error ? error.message : '发布代理配置错误'; logger.error(`[PublishService] publish proxy config error: ${errorMessage}`); for (const r of results) { await this.resultRepository.update(r.id, { status: 'failed', errorMessage, }); } await this.taskRepository.update(taskId, { status: 'failed', publishedAt: new Date(), }); wsManager.sendToUser(userId, WS_EVENTS.TASK_STATUS_CHANGED, { taskId, status: 'failed', successCount: 0, failCount: results.length, }); onProgress?.(100, `发布失败: ${errorMessage}`); return; } // 构建视频文件的完整路径 let videoPath = task.videoPath || ''; // 处理各种路径格式 if (videoPath) { // 如果路径以 /uploads/ 开头,提取相对路径部分 if (videoPath.startsWith('/uploads/')) { videoPath = path.join(config.upload.path, videoPath.replace('/uploads/', '')); } // 如果是相对路径(不是绝对路径),拼接上传目录 else if (!path.isAbsolute(videoPath)) { // 移除可能的重复 uploads 前缀 videoPath = videoPath.replace(/^uploads[\\\/]+uploads[\\\/]+/, ''); videoPath = videoPath.replace(/^uploads[\\\/]+/, ''); videoPath = path.join(config.upload.path, videoPath); } } logger.info(`Publishing video: ${videoPath}`); onProgress?.(5, `准备发布到 ${executeCount} 个账号...`); // 遍历所有目标账号,逐个发布 for (let i = 0; i < resultsToExecute.length; i++) { const result = resultsToExecute[i]; const accountProgress = Math.floor((i / executeCount) * 80) + 10; try { // 获取账号信息 const account = await this.accountRepository.findOne({ where: { id: result.accountId, userId }, }); if (!account) { logger.warn(`Account ${result.accountId} not found`); await this.resultRepository.update(result.id, { status: 'failed', errorMessage: '账号不存在', }); failCount++; continue; } if (!account.cookieData) { logger.warn(`Account ${result.accountId} has no cookies`); await this.resultRepository.update(result.id, { status: 'failed', errorMessage: '账号未登录', }); failCount++; continue; } // 解密 Cookie let decryptedCookies: string; try { decryptedCookies = CookieManager.decrypt(account.cookieData); } catch { // 如果解密失败,可能是未加密的 Cookie decryptedCookies = account.cookieData; } // 更新发布结果的平台信息 await this.resultRepository.update(result.id, { platform: account.platform, }); // 获取适配器 const adapter = this.getAdapter(account.platform as PlatformType); logger.info(`Publishing to account ${account.accountName} (${account.platform})`); onProgress?.(accountProgress, `正在发布到 ${account.accountName}...`); // 发送进度通知 wsManager.sendToUser(userId, WS_EVENTS.PUBLISH_PROGRESS, { taskId, accountId: account.id, platform: account.platform, status: 'uploading', progress: 0, message: '开始发布...', }); // 验证码处理回调(支持短信验证码和图形验证码) const onCaptchaRequired = async (captchaInfo: { taskId: string; type: 'sms' | 'image'; phone?: string; imageBase64?: string; }): Promise => { return new Promise((resolve, reject) => { const captchaTaskId = captchaInfo.taskId; // 发送验证码请求到前端 const message = captchaInfo.type === 'sms' ? '请输入短信验证码' : '请输入图片中的验证码'; logger.info(`[Publish] Requesting ${captchaInfo.type} captcha, taskId: ${captchaTaskId}, phone: ${captchaInfo.phone}`); wsManager.sendToUser(userId, WS_EVENTS.CAPTCHA_REQUIRED, { taskId, captchaTaskId, type: captchaInfo.type, phone: captchaInfo.phone || '', imageBase64: captchaInfo.imageBase64 || '', message, }); // 设置超时(2分钟) const timeout = setTimeout(() => { wsManager.removeCaptchaListener(captchaTaskId); reject(new Error('验证码输入超时')); }, 120000); // 注册验证码监听 wsManager.onCaptchaSubmit(captchaTaskId, (code: string) => { clearTimeout(timeout); wsManager.removeCaptchaListener(captchaTaskId); logger.info(`[Publish] Received captcha code for ${captchaTaskId}`); resolve(code); }); }); }; // 执行发布 const { title: resolvedTitle, description: resolvedDescription } = this.resolvePublishText( task, account.platform as PlatformType ); if (!resolvedTitle) { await this.resultRepository.update(result.id, { status: 'failed', errorMessage: '发布标题为空,请在发布管理中填写标题后重试', }); failCount++; continue; } const publishResult = await (adapter as any).publishVideo( decryptedCookies, { videoPath, title: resolvedTitle, description: resolvedDescription, coverPath: task.coverPath || undefined, tags: task.tags || undefined, extra: { userId, publishTaskId: taskId, publishAccountId: account.id, publishProxy: publishProxyExtra, }, }, (progress: number, message: string) => { // 发送进度更新 wsManager.sendToUser(userId, WS_EVENTS.PUBLISH_PROGRESS, { taskId, accountId: account.id, platform: account.platform, status: 'processing', progress, message, }); }, onCaptchaRequired ); if (publishResult.success) { await this.resultRepository.update(result.id, { status: 'success', videoUrl: publishResult.videoUrl || null, platformVideoId: publishResult.platformVideoId || null, publishedAt: new Date(), }); successCount++; successAccountIds.add(account.id); wsManager.sendToUser(userId, WS_EVENTS.PUBLISH_PROGRESS, { taskId, accountId: account.id, platform: account.platform, status: 'success', progress: 100, message: '发布成功', }); } else { await this.resultRepository.update(result.id, { status: 'failed', errorMessage: this.friendlyErrorMessage(publishResult.errorMessage), }); failCount++; wsManager.sendToUser(userId, WS_EVENTS.PUBLISH_PROGRESS, { taskId, accountId: account.id, platform: account.platform, status: 'failed', progress: 0, message: publishResult.errorMessage || '发布失败', }); } // 每个账号发布后等待一段时间,避免过于频繁 await new Promise(resolve => setTimeout(resolve, 5000)); } catch (error) { logger.error(`Failed to publish to account ${result.accountId}:`, error); const friendlyError = this.friendlyErrorMessage(error); await this.resultRepository.update(result.id, { status: 'failed', errorMessage: friendlyError, }); failCount++; } } // 更新任务状态: // - 只要有一条失败,任务状态就是 failed // - 只有全部成功(failCount===0)才标记为 completed const finalStatus = failCount > 0 ? 'failed' : 'completed'; await this.taskRepository.update(taskId, { status: finalStatus, publishedAt: new Date(), }); wsManager.sendToUser(userId, WS_EVENTS.TASK_STATUS_CHANGED, { taskId, status: finalStatus, successCount, failCount, }); onProgress?.(100, `发布完成: ${successCount} 成功, ${failCount} 失败`); logger.info(`Task ${taskId} completed: ${successCount} success, ${failCount} failed`); // 发布成功后,自动创建同步作品任务 if (successCount > 0) { // 为每个成功的账号创建同步任务 for (const accountId of successAccountIds) { const account = await this.accountRepository.findOne({ where: { id: accountId, userId } }); if (account) { await taskQueueService.createTask(userId, { type: 'sync_works', title: `同步作品 - ${account.accountName || '账号'}`, accountId: account.id, }); logger.info(`Created sync_works task for account ${accountId} after publish`); } } } } async cancelTask(userId: number, taskId: number): Promise { const task = await this.taskRepository.findOne({ where: { id: taskId, userId }, }); if (!task) { throw new AppError('任务不存在', HTTP_STATUS.NOT_FOUND, ERROR_CODES.NOT_FOUND); } if (!['pending', 'processing'].includes(task.status)) { throw new AppError('该任务状态不可取消', HTTP_STATUS.BAD_REQUEST, ERROR_CODES.VALIDATION); } await this.taskRepository.update(taskId, { status: 'cancelled' }); wsManager.sendToUser(userId, WS_EVENTS.TASK_STATUS_CHANGED, { taskId, status: 'cancelled', }); } /** * 将发布任务状态更新为 failed(用于任务执行器异常时调用) * 修复 Bug #6143:避免 publish_task 表状态停留在 'processing' */ async updateTaskStatusToFailed(taskId: number, userId: number): Promise { await this.taskRepository.update(taskId, { status: 'failed' }); wsManager.sendToUser(userId, WS_EVENTS.TASK_STATUS_CHANGED, { taskId, status: 'failed', }); } async retryTask(userId: number, taskId: number): Promise { const task = await this.taskRepository.findOne({ where: { id: taskId, userId }, }); if (!task) { throw new AppError('任务不存在', HTTP_STATUS.NOT_FOUND, ERROR_CODES.NOT_FOUND); } // 允许重试失败或卡住(processing)的任务 if (!['failed', 'processing'].includes(task.status)) { throw new AppError('只能重试失败或卡住的任务', HTTP_STATUS.BAD_REQUEST, ERROR_CODES.VALIDATION); } await this.taskRepository.update(taskId, { status: 'pending' }); // 重置失败的发布结果 await this.resultRepository.update( { taskId, status: 'failed' }, { status: null, errorMessage: null } ); const updated = await this.taskRepository.findOne({ where: { id: taskId, userId } }); wsManager.sendToUser(userId, WS_EVENTS.TASK_STATUS_CHANGED, { taskId, status: 'processing', }); // 返回任务信息,调用者需要通过任务队列重新执行 return this.formatTask(updated!); } /** * 单账号有头浏览器重试发布(用于验证码场景) * 调用 Node ????? 以有头浏览器模式执行发布 */ async retryAccountWithHeadfulBrowser( userId: number, taskId: number, accountId: number ): Promise<{ success: boolean; message: string; error?: string }> { const task = await this.taskRepository.findOne({ where: { id: taskId, userId }, relations: ['results'], }); if (!task) { throw new AppError('Task not found', HTTP_STATUS.NOT_FOUND, ERROR_CODES.NOT_FOUND); } const account = await this.accountRepository.findOne({ where: { id: accountId, userId }, }); if (!account) { throw new AppError('Account not found', HTTP_STATUS.NOT_FOUND, ERROR_CODES.NOT_FOUND); } const publishResult = task.results?.find(r => r.accountId === accountId); if (!publishResult) { throw new AppError('Publish result not found', HTTP_STATUS.NOT_FOUND, ERROR_CODES.NOT_FOUND); } let decryptedCookies: string; try { decryptedCookies = CookieManager.decrypt(account.cookieData || ''); } catch { decryptedCookies = account.cookieData || ''; } let videoPath = task.videoPath || ''; if (videoPath) { if (videoPath.startsWith('/uploads/')) { videoPath = path.join(config.upload.path, videoPath.replace('/uploads/', '')); } else if (!path.isAbsolute(videoPath)) { videoPath = videoPath.replace(/^uploads[\/]+uploads[\/]+/, ''); videoPath = videoPath.replace(/^uploads[\/]+/, ''); videoPath = path.join(config.upload.path, videoPath); } } const publishProxyExtra = await this.buildPublishProxyExtra(task.publishProxy); const adapter = this.getAdapter(account.platform as PlatformType); logger.info(`[Headful Publish] Starting headful browser publish for account ${account.accountName} (${account.platform})`); await this.resultRepository.update(publishResult.id, { status: null, errorMessage: null, }); wsManager.sendToUser(userId, WS_EVENTS.PUBLISH_PROGRESS, { taskId, accountId: account.id, platform: account.platform, status: 'processing', progress: 10, message: 'Starting visible browser publish...', }); try { const absoluteVideoPath = path.isAbsolute(videoPath) ? videoPath : path.resolve(process.cwd(), videoPath); const { title: resolvedTitle, description: resolvedDescription } = this.resolvePublishText( task, account.platform as PlatformType ); if (!resolvedTitle) { throw new Error('Publish title is empty'); } const result = await (adapter as any).publishVideo( decryptedCookies, { videoPath: absoluteVideoPath, title: resolvedTitle, description: resolvedDescription, coverPath: task.coverPath ? path.resolve(process.cwd(), task.coverPath) : undefined, tags: task.tags || [], extra: { userId, publishTaskId: taskId, publishAccountId: accountId, publishProxy: publishProxyExtra, }, }, (progress: number, message: string) => { wsManager.sendToUser(userId, WS_EVENTS.PUBLISH_PROGRESS, { taskId, accountId: account.id, platform: account.platform, status: 'processing', progress, message, }); }, undefined, { headless: false } ); if (result.success) { await this.resultRepository.update(publishResult.id, { status: 'success', videoUrl: result.videoUrl || null, platformVideoId: result.platformVideoId || null, publishedAt: new Date(), errorMessage: null, }); wsManager.sendToUser(userId, WS_EVENTS.PUBLISH_PROGRESS, { taskId, accountId: account.id, platform: account.platform, status: 'success', progress: 100, message: 'Publish completed', }); logger.info(`[Headful Publish] Success for account ${account.accountName}`); return { success: true, message: 'Publish completed' }; } const errorMsg = result.errorMessage || 'Publish failed'; await this.resultRepository.update(publishResult.id, { status: 'failed', errorMessage: errorMsg, }); wsManager.sendToUser(userId, WS_EVENTS.PUBLISH_PROGRESS, { taskId, accountId: account.id, platform: account.platform, status: 'failed', progress: 0, message: errorMsg, }); logger.warn(`[Headful Publish] Failed for account ${account.accountName}: ${errorMsg}`); return { success: false, message: 'Publish failed', error: errorMsg }; } catch (error) { const errorMsg = error instanceof Error && error.name === 'TimeoutError' ? 'Visible browser publish timed out' : error instanceof Error ? error.message : 'Publish failed'; await this.resultRepository.update(publishResult.id, { status: 'failed', errorMessage: errorMsg, }); wsManager.sendToUser(userId, WS_EVENTS.PUBLISH_PROGRESS, { taskId, accountId: account.id, platform: account.platform, status: 'failed', progress: 0, message: errorMsg, }); logger.error(`[Headful Publish] Error for account ${account.accountName}:`, error); return { success: false, message: 'Publish failed', error: errorMsg }; } } async deleteTask(userId: number, taskId: number): Promise { const task = await this.taskRepository.findOne({ where: { id: taskId, userId }, }); if (!task) { throw new AppError('任务不存在', HTTP_STATUS.NOT_FOUND, ERROR_CODES.NOT_FOUND); } // 不能删除正在执行的任务 if (task.status === 'processing') { throw new AppError('不能删除正在执行的任务', HTTP_STATUS.BAD_REQUEST, ERROR_CODES.VALIDATION); } // 先删除关联的发布结果 await this.resultRepository.delete({ taskId }); // 再删除任务 await this.taskRepository.delete(taskId); } private formatTask(task: PublishTask): PublishTaskType { return { id: task.id, userId: task.userId, videoPath: task.videoPath || '', videoFilename: task.videoFilename || '', title: task.title || '', description: task.description, coverPath: task.coverPath, tags: task.tags || [], targetAccounts: task.targetAccounts || [], platformConfigs: task.platformConfigs || [], publishProxy: task.publishProxy, status: task.status, scheduledAt: task.scheduledAt?.toISOString() || null, publishedAt: task.publishedAt?.toISOString() || null, createdAt: task.createdAt.toISOString(), updatedAt: task.updatedAt.toISOString(), }; } private async buildPublishProxyExtra(publishProxy: PublishProxyConfig | null | undefined): Promise { if (!publishProxy?.enabled) return null; const provider = publishProxy.provider || 'shenlong'; if (provider !== 'shenlong') return null; const rows = await this.systemConfigRepository.find({ where: { configKey: 'publish_proxy_shenlong_product_key' }, }); const productKey = String(rows?.[0]?.configValue || '').trim(); const signatureRows = await this.systemConfigRepository.find({ where: { configKey: 'publish_proxy_shenlong_signature' }, }); const signature = String(signatureRows?.[0]?.configValue || '').trim(); if (!productKey || !signature) return null; const rawRegionCode = publishProxy.regionCode ? String(publishProxy.regionCode).trim() : ''; let regionCode: string | undefined = rawRegionCode || undefined; // 格式化地区编码:神龙 API 支持 6 位编码,省级(0000)、地级(00) 均保留传递 if (regionCode && /^\d{6}$/.test(regionCode) && !regionCode.endsWith('00')) { regionCode = `${regionCode.slice(0, 4)}00`; } return { enabled: true, provider: 'shenlong', city: String(publishProxy.city || '').trim(), productKey, signature, regionCode, regionName: publishProxy.regionName ? String(publishProxy.regionName).trim() : undefined, }; } /** * 用户手动确认发布结果(如小红书"发布结果待确认"时用户确认已发布成功) */ async confirmPublishResult(userId: number, taskId: number, resultId: number): Promise { const task = await this.taskRepository.findOne({ where: { id: taskId, userId }, relations: ['results'], }); if (!task) { throw new AppError('任务不存在', HTTP_STATUS.NOT_FOUND, ERROR_CODES.NOT_FOUND); } const result = task.results?.find(r => r.id === resultId); if (!result) { throw new AppError('发布结果不存在', HTTP_STATUS.NOT_FOUND, ERROR_CODES.NOT_FOUND); } await this.resultRepository.update(resultId, { status: 'success', errorMessage: null, publishedAt: new Date(), }); // 检查是否所有结果都成功,如是则更新任务状态 const updatedResults = await this.resultRepository.find({ where: { taskId } }); const allSuccess = updatedResults.every(r => r.status === 'success'); if (allSuccess) { await this.taskRepository.update(taskId, { status: 'completed', publishedAt: new Date() }); } } private formatTaskDetail( task: PublishTask, overrideTargetAccounts?: number[], overrideStatus?: string, ): PublishTaskDetail { const base = this.formatTask(task); // 修复 #6068:使用实际存在的 PublishResult 账号列表覆盖缓存的 targetAccounts if (overrideTargetAccounts) { base.targetAccounts = overrideTargetAccounts; } if (overrideStatus) { base.status = overrideStatus as PublishTaskType['status']; } return { ...base, results: task.results?.map(r => ({ id: r.id, taskId: r.taskId, accountId: r.accountId, platform: r.platform!, status: r.status!, videoUrl: r.videoUrl, platformVideoId: r.platformVideoId, errorMessage: r.errorMessage, publishedAt: r.publishedAt?.toISOString() || null, })) || [], }; } }