| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055 |
- 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<PlatformType, () => 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<PaginatedData<PublishTaskType>> {
- 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<number, { successCount: number; failCount: number; actualTargetAccounts: number[] }> = {};
- 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<PublishTaskDetail> {
- 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<PublishTaskType> {
- // 验证目标账号是否存在
- 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<CreatePublishTaskRequest>,
- ): Promise<PublishTaskType> {
- 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<void> {
- 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<number>();
- let publishProxyExtra: Awaited<ReturnType<PublishService['buildPublishProxyExtra']>> = 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<string> => {
- 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<void> {
- 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<void> {
- 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<PublishTaskType> {
- 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<void> {
- 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<null | {
- enabled: boolean;
- provider: 'shenlong';
- city: string;
- productKey: string;
- signature: string;
- regionCode?: string;
- regionName?: string;
- }> {
- 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<void> {
- 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,
- })) || [],
- };
- }
- }
|