PublishService.ts 38 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055
  1. import { AppDataSource, PublishTask, PublishResult, PlatformAccount, SystemConfig } from '../models/index.js';
  2. import { AppError } from '../middleware/error.js';
  3. import { ERROR_CODES, HTTP_STATUS, WS_EVENTS } from '@media-manager/shared';
  4. import type {
  5. PublishTask as PublishTaskType,
  6. PublishTaskDetail,
  7. CreatePublishTaskRequest,
  8. PaginatedData,
  9. PlatformType,
  10. PublishProxyConfig,
  11. } from '@media-manager/shared';
  12. import { wsManager } from '../websocket/index.js';
  13. import { DouyinAdapter } from '../automation/platforms/douyin.js';
  14. import { XiaohongshuAdapter } from '../automation/platforms/xiaohongshu.js';
  15. import { WeixinAdapter } from '../automation/platforms/weixin.js';
  16. import { BaijiahaoAdapter } from '../automation/platforms/baijiahao.js';
  17. import { BasePlatformAdapter } from '../automation/platforms/base.js';
  18. import { logger } from '../utils/logger.js';
  19. import path from 'path';
  20. import { config } from '../config/index.js';
  21. import { CookieManager } from '../automation/cookie.js';
  22. import { taskQueueService } from './TaskQueueService.js';
  23. import { In } from 'typeorm';
  24. interface GetTasksParams {
  25. page: number;
  26. pageSize: number;
  27. status?: string;
  28. }
  29. const HEADFUL_RETRY_TIMEOUT_MS = 30 * 60 * 1000;
  30. export class PublishService {
  31. private taskRepository = AppDataSource.getRepository(PublishTask);
  32. private resultRepository = AppDataSource.getRepository(PublishResult);
  33. private accountRepository = AppDataSource.getRepository(PlatformAccount);
  34. private systemConfigRepository = AppDataSource.getRepository(SystemConfig);
  35. // 平台适配器工厂映射:每次发布独立创建,避免并发任务共享同一个 browser/page 状态
  36. private adapterFactories: Map<PlatformType, () => BasePlatformAdapter> = new Map();
  37. constructor() {
  38. // 初始化平台适配器
  39. this.adapterFactories.set('douyin', () => new DouyinAdapter());
  40. this.adapterFactories.set('xiaohongshu', () => new XiaohongshuAdapter());
  41. this.adapterFactories.set('weixin_video', () => new WeixinAdapter() as BasePlatformAdapter);
  42. this.adapterFactories.set('baijiahao', () => new BaijiahaoAdapter());
  43. }
  44. /**
  45. * 将技术性错误消息翻译为用户友好的描述
  46. */
  47. private friendlyErrorMessage(error: unknown): string {
  48. const raw = error instanceof Error ? error.message : String(error || '发布失败');
  49. const patterns: [RegExp, string][] = [
  50. [/CAPTCHA_REQUIRED[::]?\s*(.*)/i, '需要验证码,请使用有头浏览器重新发布'],
  51. [/timeout/i, '操作超时,请稍后重试'],
  52. [/net::ERR_/i, '网络连接失败,请检查网络'],
  53. [/ECONNREFUSED|ECONNRESET/i, '网络连接被拒绝,请检查代理设置'],
  54. [/Navigation timeout/i, '页面加载超时,请稍后重试'],
  55. [/Target closed|browser closed/i, '浏览器已关闭,请重试'],
  56. [/automation.*not found|automation.*不可用/i, '本地自动化服务不可用,请联系管理员'],
  57. [/cookie.*过期|cookie.*失效|登录已过/i, '账号登录已过期,请重新登录'],
  58. [/请查看截图/i, '发布结果待确认,请前往平台创作者中心查看'],
  59. [/502|503|504|Bad Gateway|Service Unavailable/i, '平台服务器繁忙,请稍后重试'],
  60. [/upload.*fail|上传.*失败/i, '视频上传失败,请检查视频格式和大小'],
  61. ];
  62. for (const [pattern, message] of patterns) {
  63. if (pattern.test(raw)) return message;
  64. }
  65. if (/^[\u4e00-\u9fff]/.test(raw) && raw.length <= 50) return raw;
  66. return raw.length > 100 ? raw.slice(0, 100) + '...' : raw;
  67. }
  68. /**
  69. * 获取平台适配器
  70. */
  71. private getAdapter(platform: PlatformType) {
  72. const createAdapter = this.adapterFactories.get(platform);
  73. if (!createAdapter) {
  74. throw new AppError(`不支持的平台: ${platform}`, HTTP_STATUS.BAD_REQUEST, ERROR_CODES.VALIDATION);
  75. }
  76. return createAdapter();
  77. }
  78. private normalizeTargetAccountIds(targetAccounts: unknown): number[] {
  79. if (!Array.isArray(targetAccounts)) {
  80. return [];
  81. }
  82. const ids = targetAccounts
  83. .map((accountId) => Number(accountId))
  84. .filter((accountId) => Number.isInteger(accountId) && accountId > 0);
  85. return [...new Set(ids)];
  86. }
  87. private async getOwnedTargetAccountIds(
  88. userId: number,
  89. requestedAccountIds: number[]
  90. ): Promise<{ validAccountIds: number[]; invalidAccountIds: number[] }> {
  91. if (requestedAccountIds.length === 0) {
  92. return { validAccountIds: [], invalidAccountIds: [] };
  93. }
  94. const accounts = await this.accountRepository.find({
  95. where: { id: In(requestedAccountIds), userId },
  96. select: ['id'],
  97. });
  98. const ownedAccountIds = new Set(accounts.map((account) => account.id));
  99. return {
  100. validAccountIds: requestedAccountIds.filter((accountId) => ownedAccountIds.has(accountId)),
  101. invalidAccountIds: requestedAccountIds.filter((accountId) => !ownedAccountIds.has(accountId)),
  102. };
  103. }
  104. private resolvePublishText(task: PublishTask, platform: PlatformType): { title: string; description: string } {
  105. const taskTitle = String(task.title || '').trim();
  106. const taskDescription = task.description == null ? '' : String(task.description).trim();
  107. let title = taskTitle;
  108. let description = taskDescription;
  109. const platformConfig = Array.isArray(task.platformConfigs)
  110. ? task.platformConfigs.find((cfg: any) => String(cfg?.platform || '') === String(platform))
  111. : undefined;
  112. if (platformConfig) {
  113. const cfgTitle = typeof platformConfig.title === 'string' ? platformConfig.title.trim() : '';
  114. const hasCfgDescription = Object.prototype.hasOwnProperty.call(platformConfig, 'description');
  115. const cfgDescription = typeof platformConfig.description === 'string' ? platformConfig.description.trim() : '';
  116. if (cfgTitle) {
  117. title = cfgTitle;
  118. }
  119. if (hasCfgDescription) {
  120. description = cfgDescription;
  121. }
  122. }
  123. return { title, description };
  124. }
  125. async getTasks(userId: number, params: GetTasksParams): Promise<PaginatedData<PublishTaskType>> {
  126. const { page, pageSize, status } = params;
  127. const skip = (page - 1) * pageSize;
  128. const queryBuilder = this.taskRepository
  129. .createQueryBuilder('task')
  130. .where('task.userId = :userId', { userId });
  131. if (status) {
  132. queryBuilder.andWhere('task.status = :status', { status });
  133. }
  134. const [tasks, total] = await queryBuilder
  135. .orderBy('task.createdAt', 'DESC')
  136. .skip(skip)
  137. .take(pageSize)
  138. .getManyAndCount();
  139. // 批量查询每个任务的发布结果统计
  140. // 修复 #6068:同时查询实际目标账号 ID,删除账号后 targetAccounts 原始缓存不再准确
  141. let resultStatsMap: Record<number, { successCount: number; failCount: number; actualTargetAccounts: number[] }> = {};
  142. if (tasks.length > 0) {
  143. const taskIds = tasks.map(t => t.id);
  144. const stats = await this.resultRepository
  145. .createQueryBuilder('r')
  146. .select('r.taskId', 'taskId')
  147. .addSelect('COUNT(CASE WHEN r.status = :success THEN 1 END)', 'successCount')
  148. .addSelect('COUNT(CASE WHEN r.status = :failed THEN 1 END)', 'failCount')
  149. .addSelect('GROUP_CONCAT(DISTINCT r.account_id)', 'actualTargetAccountIds')
  150. .setParameter('success', 'success')
  151. .setParameter('failed', 'failed')
  152. .where('r.taskId IN (:...taskIds)', { taskIds })
  153. .groupBy('r.taskId')
  154. .getRawMany();
  155. for (const s of stats) {
  156. resultStatsMap[s.taskId] = {
  157. successCount: Number(s.successCount) || 0,
  158. failCount: Number(s.failCount) || 0,
  159. actualTargetAccounts: s.actualTargetAccountIds
  160. ? String(s.actualTargetAccountIds).split(',').map(Number).filter(Boolean)
  161. : [],
  162. };
  163. }
  164. }
  165. return {
  166. items: tasks.map(t => {
  167. const formatted = this.formatTask(t);
  168. const stats = resultStatsMap[t.id];
  169. if (stats) {
  170. formatted.successCount = stats.successCount;
  171. formatted.failCount = stats.failCount;
  172. // 修复 #6068:使用 PublishResult 中的实际账号 ID 覆盖原始缓存,删除账号后保持准确
  173. if (stats.actualTargetAccounts.length > 0) {
  174. formatted.targetAccounts = stats.actualTargetAccounts;
  175. }
  176. }
  177. return formatted;
  178. }),
  179. total,
  180. page,
  181. pageSize,
  182. totalPages: Math.ceil(total / pageSize),
  183. };
  184. }
  185. async getTaskById(userId: number, taskId: number): Promise<PublishTaskDetail> {
  186. const task = await this.taskRepository.findOne({
  187. where: { id: taskId, userId },
  188. relations: ['results'],
  189. });
  190. if (!task) {
  191. throw new AppError('任务不存在', HTTP_STATUS.NOT_FOUND, ERROR_CODES.NOT_FOUND);
  192. }
  193. // 基于 PublishResult 记录重新计算任务状态(修复 #6068:删除账号后状态不一致)
  194. // 修复 Bug #6165:当 task.status 为 'processing' 时也参与状态聚合,否则已完成的任务会一直显示"发布中"
  195. const results = task.results || [];
  196. const actualTargetAccounts = results.map(r => r.accountId);
  197. const completedResults = results.filter(r => r.status === 'success');
  198. const failedResults = results.filter(r => r.status === 'failed');
  199. let computedStatus = task.status;
  200. if (results.length > 0 && task.status !== 'pending' && task.status !== 'cancelled') {
  201. // 只有当任务尚未开始(pending)或已取消时,才保持原始状态
  202. // 其他情况(processing 等)都根据 results 重新计算
  203. if (completedResults.length === results.length) {
  204. computedStatus = 'completed';
  205. } else if (failedResults.length === results.length) {
  206. computedStatus = 'failed';
  207. } else if (completedResults.length + failedResults.length === results.length) {
  208. computedStatus = 'failed'; // 部分失败也标记为 failed(保持向后兼容)
  209. } else {
  210. computedStatus = task.status; // 仍有未完成的,保持原状态
  211. }
  212. }
  213. return this.formatTaskDetail(task, actualTargetAccounts, computedStatus);
  214. }
  215. async createTask(userId: number, data: CreatePublishTaskRequest): Promise<PublishTaskType> {
  216. // 验证目标账号是否存在
  217. const requestedAccountIds = this.normalizeTargetAccountIds(data.targetAccounts);
  218. const { validAccountIds, invalidAccountIds } = await this.getOwnedTargetAccountIds(userId, requestedAccountIds);
  219. if (validAccountIds.length === 0) {
  220. throw new AppError('所选账号不存在或已被删除', HTTP_STATUS.BAD_REQUEST, ERROR_CODES.VALIDATION);
  221. }
  222. // 去重(修复#6034:作品数可能重复)
  223. const dedupAccountIds = validAccountIds;
  224. if (invalidAccountIds.length > 0) {
  225. logger.warn(`[PublishService] ${invalidAccountIds.length} invalid accounts skipped: ${invalidAccountIds.join(', ')}`);
  226. }
  227. const videoPath = data.videoPath || '';
  228. const task = this.taskRepository.create({
  229. userId,
  230. videoPath,
  231. videoFilename: videoPath ? videoPath.split(/[\\/]/).pop() || null : null,
  232. title: data.title,
  233. description: data.description || null,
  234. coverPath: data.coverPath || null,
  235. tags: data.tags || null,
  236. targetAccounts: dedupAccountIds, // 只保存有效且去重的账号 ID
  237. platformConfigs: data.platformConfigs || null,
  238. publishProxy: data.publishProxy || null,
  239. status: 'pending', // 初始状态为 pending,任务队列执行时再更新为 processing
  240. scheduledAt: data.scheduledAt ? new Date(data.scheduledAt) : null,
  241. });
  242. await this.taskRepository.save(task);
  243. // 创建发布结果记录(只为有效且去重的账号创建)
  244. const results = dedupAccountIds.map((accountId) => this.resultRepository.create({
  245. taskId: task.id,
  246. accountId,
  247. }));
  248. if (results.length > 0) {
  249. await this.resultRepository.save(results);
  250. }
  251. // 通知客户端
  252. wsManager.sendToUser(userId, WS_EVENTS.TASK_CREATED, { task: this.formatTask(task) });
  253. // 返回任务信息,发布任务将通过任务队列执行
  254. // 调用者需要调用 taskQueueService.createTask 来创建队列任务
  255. return this.formatTask(task);
  256. }
  257. async updateTask(
  258. userId: number,
  259. taskId: number,
  260. data: Partial<CreatePublishTaskRequest>,
  261. ): Promise<PublishTaskType> {
  262. const task = await this.taskRepository.findOne({
  263. where: { id: taskId, userId },
  264. relations: ['results'],
  265. });
  266. if (!task) {
  267. throw new AppError('任务不存在', HTTP_STATUS.NOT_FOUND, ERROR_CODES.NOT_FOUND);
  268. }
  269. if (task.status === 'processing') {
  270. throw new AppError('正在执行的任务不能修改', HTTP_STATUS.BAD_REQUEST, ERROR_CODES.VALIDATION);
  271. }
  272. const requestedAccounts = Array.isArray(data.targetAccounts)
  273. ? data.targetAccounts
  274. : (Array.isArray(task.targetAccounts) ? task.targetAccounts : []);
  275. const requestedAccountIds = this.normalizeTargetAccountIds(requestedAccounts);
  276. const { validAccountIds, invalidAccountIds } = await this.getOwnedTargetAccountIds(userId, requestedAccountIds);
  277. if (validAccountIds.length === 0) {
  278. throw new AppError('所选账号不存在或已被删除', HTTP_STATUS.BAD_REQUEST, ERROR_CODES.VALIDATION);
  279. }
  280. const dedupAccountIds = validAccountIds;
  281. if (invalidAccountIds.length > 0) {
  282. logger.warn(`[PublishService] ${invalidAccountIds.length} invalid accounts skipped on update: ${invalidAccountIds.join(', ')}`);
  283. }
  284. const nextVideoPath = data.videoPath ?? task.videoPath ?? '';
  285. const nextTitle = data.title ?? task.title ?? '';
  286. task.videoPath = nextVideoPath;
  287. task.videoFilename = nextVideoPath ? nextVideoPath.split(/[\\/]/).pop() || null : null;
  288. task.title = nextTitle;
  289. task.description = data.description ?? task.description ?? null;
  290. task.coverPath = data.coverPath ?? task.coverPath ?? null;
  291. task.tags = data.tags ?? task.tags ?? null;
  292. task.targetAccounts = dedupAccountIds;
  293. task.platformConfigs = data.platformConfigs ?? task.platformConfigs ?? null;
  294. task.publishProxy = data.publishProxy ?? task.publishProxy ?? null;
  295. task.scheduledAt = data.scheduledAt ? new Date(data.scheduledAt) : null;
  296. task.status = 'pending';
  297. task.publishedAt = null;
  298. await this.taskRepository.save(task);
  299. await this.resultRepository.delete({ taskId });
  300. const results = dedupAccountIds.map((accountId) => this.resultRepository.create({ taskId: task.id, accountId }));
  301. if (results.length > 0) {
  302. await this.resultRepository.save(results);
  303. }
  304. wsManager.sendToUser(userId, WS_EVENTS.TASK_CREATED, { task: this.formatTask(task) });
  305. return this.formatTask(task);
  306. }
  307. /**
  308. * 带进度回调的发布任务执行
  309. */
  310. async executePublishTaskWithProgress(
  311. taskId: number,
  312. userId: number,
  313. onProgress?: (progress: number, message: string) => void
  314. ): Promise<void> {
  315. const task = await this.taskRepository.findOne({
  316. where: { id: taskId, userId },
  317. relations: ['results'],
  318. });
  319. if (!task) {
  320. throw new Error(`Task ${taskId} not found or does not belong to user ${userId}`);
  321. }
  322. // 更新任务状态为处理中
  323. await this.taskRepository.update(taskId, { status: 'processing' });
  324. wsManager.sendToUser(userId, WS_EVENTS.TASK_STATUS_CHANGED, {
  325. taskId,
  326. status: 'processing',
  327. });
  328. const results = task.results || [];
  329. // 筛选需要执行的发布结果:跳过已成功的(修复#6038重试把成功的也发了)
  330. const resultsToExecute = results.filter(r => r.status !== 'success');
  331. const skippedCount = results.length - resultsToExecute.length;
  332. if (skippedCount > 0) {
  333. logger.info(`Skipping ${skippedCount} already successful accounts`);
  334. }
  335. let successCount = 0;
  336. let failCount = 0;
  337. const executeCount = resultsToExecute.length;
  338. const successAccountIds = new Set<number>();
  339. let publishProxyExtra: Awaited<ReturnType<PublishService['buildPublishProxyExtra']>> = null;
  340. try {
  341. publishProxyExtra = await this.buildPublishProxyExtra(task.publishProxy);
  342. } catch (error) {
  343. const errorMessage = error instanceof Error ? error.message : '发布代理配置错误';
  344. logger.error(`[PublishService] publish proxy config error: ${errorMessage}`);
  345. for (const r of results) {
  346. await this.resultRepository.update(r.id, {
  347. status: 'failed',
  348. errorMessage,
  349. });
  350. }
  351. await this.taskRepository.update(taskId, {
  352. status: 'failed',
  353. publishedAt: new Date(),
  354. });
  355. wsManager.sendToUser(userId, WS_EVENTS.TASK_STATUS_CHANGED, {
  356. taskId,
  357. status: 'failed',
  358. successCount: 0,
  359. failCount: results.length,
  360. });
  361. onProgress?.(100, `发布失败: ${errorMessage}`);
  362. return;
  363. }
  364. // 构建视频文件的完整路径
  365. let videoPath = task.videoPath || '';
  366. // 处理各种路径格式
  367. if (videoPath) {
  368. // 如果路径以 /uploads/ 开头,提取相对路径部分
  369. if (videoPath.startsWith('/uploads/')) {
  370. videoPath = path.join(config.upload.path, videoPath.replace('/uploads/', ''));
  371. }
  372. // 如果是相对路径(不是绝对路径),拼接上传目录
  373. else if (!path.isAbsolute(videoPath)) {
  374. // 移除可能的重复 uploads 前缀
  375. videoPath = videoPath.replace(/^uploads[\\\/]+uploads[\\\/]+/, '');
  376. videoPath = videoPath.replace(/^uploads[\\\/]+/, '');
  377. videoPath = path.join(config.upload.path, videoPath);
  378. }
  379. }
  380. logger.info(`Publishing video: ${videoPath}`);
  381. onProgress?.(5, `准备发布到 ${executeCount} 个账号...`);
  382. // 遍历所有目标账号,逐个发布
  383. for (let i = 0; i < resultsToExecute.length; i++) {
  384. const result = resultsToExecute[i];
  385. const accountProgress = Math.floor((i / executeCount) * 80) + 10;
  386. try {
  387. // 获取账号信息
  388. const account = await this.accountRepository.findOne({
  389. where: { id: result.accountId, userId },
  390. });
  391. if (!account) {
  392. logger.warn(`Account ${result.accountId} not found`);
  393. await this.resultRepository.update(result.id, {
  394. status: 'failed',
  395. errorMessage: '账号不存在',
  396. });
  397. failCount++;
  398. continue;
  399. }
  400. if (!account.cookieData) {
  401. logger.warn(`Account ${result.accountId} has no cookies`);
  402. await this.resultRepository.update(result.id, {
  403. status: 'failed',
  404. errorMessage: '账号未登录',
  405. });
  406. failCount++;
  407. continue;
  408. }
  409. // 解密 Cookie
  410. let decryptedCookies: string;
  411. try {
  412. decryptedCookies = CookieManager.decrypt(account.cookieData);
  413. } catch {
  414. // 如果解密失败,可能是未加密的 Cookie
  415. decryptedCookies = account.cookieData;
  416. }
  417. // 更新发布结果的平台信息
  418. await this.resultRepository.update(result.id, {
  419. platform: account.platform,
  420. });
  421. // 获取适配器
  422. const adapter = this.getAdapter(account.platform as PlatformType);
  423. logger.info(`Publishing to account ${account.accountName} (${account.platform})`);
  424. onProgress?.(accountProgress, `正在发布到 ${account.accountName}...`);
  425. // 发送进度通知
  426. wsManager.sendToUser(userId, WS_EVENTS.PUBLISH_PROGRESS, {
  427. taskId,
  428. accountId: account.id,
  429. platform: account.platform,
  430. status: 'uploading',
  431. progress: 0,
  432. message: '开始发布...',
  433. });
  434. // 验证码处理回调(支持短信验证码和图形验证码)
  435. const onCaptchaRequired = async (captchaInfo: {
  436. taskId: string;
  437. type: 'sms' | 'image';
  438. phone?: string;
  439. imageBase64?: string;
  440. }): Promise<string> => {
  441. return new Promise((resolve, reject) => {
  442. const captchaTaskId = captchaInfo.taskId;
  443. // 发送验证码请求到前端
  444. const message = captchaInfo.type === 'sms'
  445. ? '请输入短信验证码'
  446. : '请输入图片中的验证码';
  447. logger.info(`[Publish] Requesting ${captchaInfo.type} captcha, taskId: ${captchaTaskId}, phone: ${captchaInfo.phone}`);
  448. wsManager.sendToUser(userId, WS_EVENTS.CAPTCHA_REQUIRED, {
  449. taskId,
  450. captchaTaskId,
  451. type: captchaInfo.type,
  452. phone: captchaInfo.phone || '',
  453. imageBase64: captchaInfo.imageBase64 || '',
  454. message,
  455. });
  456. // 设置超时(2分钟)
  457. const timeout = setTimeout(() => {
  458. wsManager.removeCaptchaListener(captchaTaskId);
  459. reject(new Error('验证码输入超时'));
  460. }, 120000);
  461. // 注册验证码监听
  462. wsManager.onCaptchaSubmit(captchaTaskId, (code: string) => {
  463. clearTimeout(timeout);
  464. wsManager.removeCaptchaListener(captchaTaskId);
  465. logger.info(`[Publish] Received captcha code for ${captchaTaskId}`);
  466. resolve(code);
  467. });
  468. });
  469. };
  470. // 执行发布
  471. const { title: resolvedTitle, description: resolvedDescription } = this.resolvePublishText(
  472. task,
  473. account.platform as PlatformType
  474. );
  475. if (!resolvedTitle) {
  476. await this.resultRepository.update(result.id, {
  477. status: 'failed',
  478. errorMessage: '发布标题为空,请在发布管理中填写标题后重试',
  479. });
  480. failCount++;
  481. continue;
  482. }
  483. const publishResult = await (adapter as any).publishVideo(
  484. decryptedCookies,
  485. {
  486. videoPath,
  487. title: resolvedTitle,
  488. description: resolvedDescription,
  489. coverPath: task.coverPath || undefined,
  490. tags: task.tags || undefined,
  491. extra: {
  492. userId,
  493. publishTaskId: taskId,
  494. publishAccountId: account.id,
  495. publishProxy: publishProxyExtra,
  496. },
  497. },
  498. (progress: number, message: string) => {
  499. // 发送进度更新
  500. wsManager.sendToUser(userId, WS_EVENTS.PUBLISH_PROGRESS, {
  501. taskId,
  502. accountId: account.id,
  503. platform: account.platform,
  504. status: 'processing',
  505. progress,
  506. message,
  507. });
  508. },
  509. onCaptchaRequired
  510. );
  511. if (publishResult.success) {
  512. await this.resultRepository.update(result.id, {
  513. status: 'success',
  514. videoUrl: publishResult.videoUrl || null,
  515. platformVideoId: publishResult.platformVideoId || null,
  516. publishedAt: new Date(),
  517. });
  518. successCount++;
  519. successAccountIds.add(account.id);
  520. wsManager.sendToUser(userId, WS_EVENTS.PUBLISH_PROGRESS, {
  521. taskId,
  522. accountId: account.id,
  523. platform: account.platform,
  524. status: 'success',
  525. progress: 100,
  526. message: '发布成功',
  527. });
  528. } else {
  529. await this.resultRepository.update(result.id, {
  530. status: 'failed',
  531. errorMessage: this.friendlyErrorMessage(publishResult.errorMessage),
  532. });
  533. failCount++;
  534. wsManager.sendToUser(userId, WS_EVENTS.PUBLISH_PROGRESS, {
  535. taskId,
  536. accountId: account.id,
  537. platform: account.platform,
  538. status: 'failed',
  539. progress: 0,
  540. message: publishResult.errorMessage || '发布失败',
  541. });
  542. }
  543. // 每个账号发布后等待一段时间,避免过于频繁
  544. await new Promise(resolve => setTimeout(resolve, 5000));
  545. } catch (error) {
  546. logger.error(`Failed to publish to account ${result.accountId}:`, error);
  547. const friendlyError = this.friendlyErrorMessage(error);
  548. await this.resultRepository.update(result.id, {
  549. status: 'failed',
  550. errorMessage: friendlyError,
  551. });
  552. failCount++;
  553. }
  554. }
  555. // 更新任务状态:
  556. // - 只要有一条失败,任务状态就是 failed
  557. // - 只有全部成功(failCount===0)才标记为 completed
  558. const finalStatus = failCount > 0 ? 'failed' : 'completed';
  559. await this.taskRepository.update(taskId, {
  560. status: finalStatus,
  561. publishedAt: new Date(),
  562. });
  563. wsManager.sendToUser(userId, WS_EVENTS.TASK_STATUS_CHANGED, {
  564. taskId,
  565. status: finalStatus,
  566. successCount,
  567. failCount,
  568. });
  569. onProgress?.(100, `发布完成: ${successCount} 成功, ${failCount} 失败`);
  570. logger.info(`Task ${taskId} completed: ${successCount} success, ${failCount} failed`);
  571. // 发布成功后,自动创建同步作品任务
  572. if (successCount > 0) {
  573. // 为每个成功的账号创建同步任务
  574. for (const accountId of successAccountIds) {
  575. const account = await this.accountRepository.findOne({ where: { id: accountId, userId } });
  576. if (account) {
  577. await taskQueueService.createTask(userId, {
  578. type: 'sync_works',
  579. title: `同步作品 - ${account.accountName || '账号'}`,
  580. accountId: account.id,
  581. });
  582. logger.info(`Created sync_works task for account ${accountId} after publish`);
  583. }
  584. }
  585. }
  586. }
  587. async cancelTask(userId: number, taskId: number): Promise<void> {
  588. const task = await this.taskRepository.findOne({
  589. where: { id: taskId, userId },
  590. });
  591. if (!task) {
  592. throw new AppError('任务不存在', HTTP_STATUS.NOT_FOUND, ERROR_CODES.NOT_FOUND);
  593. }
  594. if (!['pending', 'processing'].includes(task.status)) {
  595. throw new AppError('该任务状态不可取消', HTTP_STATUS.BAD_REQUEST, ERROR_CODES.VALIDATION);
  596. }
  597. await this.taskRepository.update(taskId, { status: 'cancelled' });
  598. wsManager.sendToUser(userId, WS_EVENTS.TASK_STATUS_CHANGED, {
  599. taskId,
  600. status: 'cancelled',
  601. });
  602. }
  603. /**
  604. * 将发布任务状态更新为 failed(用于任务执行器异常时调用)
  605. * 修复 Bug #6143:避免 publish_task 表状态停留在 'processing'
  606. */
  607. async updateTaskStatusToFailed(taskId: number, userId: number): Promise<void> {
  608. await this.taskRepository.update(taskId, { status: 'failed' });
  609. wsManager.sendToUser(userId, WS_EVENTS.TASK_STATUS_CHANGED, {
  610. taskId,
  611. status: 'failed',
  612. });
  613. }
  614. async retryTask(userId: number, taskId: number): Promise<PublishTaskType> {
  615. const task = await this.taskRepository.findOne({
  616. where: { id: taskId, userId },
  617. });
  618. if (!task) {
  619. throw new AppError('任务不存在', HTTP_STATUS.NOT_FOUND, ERROR_CODES.NOT_FOUND);
  620. }
  621. // 允许重试失败或卡住(processing)的任务
  622. if (!['failed', 'processing'].includes(task.status)) {
  623. throw new AppError('只能重试失败或卡住的任务', HTTP_STATUS.BAD_REQUEST, ERROR_CODES.VALIDATION);
  624. }
  625. await this.taskRepository.update(taskId, { status: 'pending' });
  626. // 重置失败的发布结果
  627. await this.resultRepository.update(
  628. { taskId, status: 'failed' },
  629. { status: null, errorMessage: null }
  630. );
  631. const updated = await this.taskRepository.findOne({ where: { id: taskId, userId } });
  632. wsManager.sendToUser(userId, WS_EVENTS.TASK_STATUS_CHANGED, {
  633. taskId,
  634. status: 'processing',
  635. });
  636. // 返回任务信息,调用者需要通过任务队列重新执行
  637. return this.formatTask(updated!);
  638. }
  639. /**
  640. * 单账号有头浏览器重试发布(用于验证码场景)
  641. * 调用 Node ????? 以有头浏览器模式执行发布
  642. */
  643. async retryAccountWithHeadfulBrowser(
  644. userId: number,
  645. taskId: number,
  646. accountId: number
  647. ): Promise<{ success: boolean; message: string; error?: string }> {
  648. const task = await this.taskRepository.findOne({
  649. where: { id: taskId, userId },
  650. relations: ['results'],
  651. });
  652. if (!task) {
  653. throw new AppError('Task not found', HTTP_STATUS.NOT_FOUND, ERROR_CODES.NOT_FOUND);
  654. }
  655. const account = await this.accountRepository.findOne({
  656. where: { id: accountId, userId },
  657. });
  658. if (!account) {
  659. throw new AppError('Account not found', HTTP_STATUS.NOT_FOUND, ERROR_CODES.NOT_FOUND);
  660. }
  661. const publishResult = task.results?.find(r => r.accountId === accountId);
  662. if (!publishResult) {
  663. throw new AppError('Publish result not found', HTTP_STATUS.NOT_FOUND, ERROR_CODES.NOT_FOUND);
  664. }
  665. let decryptedCookies: string;
  666. try {
  667. decryptedCookies = CookieManager.decrypt(account.cookieData || '');
  668. } catch {
  669. decryptedCookies = account.cookieData || '';
  670. }
  671. let videoPath = task.videoPath || '';
  672. if (videoPath) {
  673. if (videoPath.startsWith('/uploads/')) {
  674. videoPath = path.join(config.upload.path, videoPath.replace('/uploads/', ''));
  675. } else if (!path.isAbsolute(videoPath)) {
  676. videoPath = videoPath.replace(/^uploads[\/]+uploads[\/]+/, '');
  677. videoPath = videoPath.replace(/^uploads[\/]+/, '');
  678. videoPath = path.join(config.upload.path, videoPath);
  679. }
  680. }
  681. const publishProxyExtra = await this.buildPublishProxyExtra(task.publishProxy);
  682. const adapter = this.getAdapter(account.platform as PlatformType);
  683. logger.info(`[Headful Publish] Starting headful browser publish for account ${account.accountName} (${account.platform})`);
  684. await this.resultRepository.update(publishResult.id, {
  685. status: null,
  686. errorMessage: null,
  687. });
  688. wsManager.sendToUser(userId, WS_EVENTS.PUBLISH_PROGRESS, {
  689. taskId,
  690. accountId: account.id,
  691. platform: account.platform,
  692. status: 'processing',
  693. progress: 10,
  694. message: 'Starting visible browser publish...',
  695. });
  696. try {
  697. const absoluteVideoPath = path.isAbsolute(videoPath)
  698. ? videoPath
  699. : path.resolve(process.cwd(), videoPath);
  700. const { title: resolvedTitle, description: resolvedDescription } = this.resolvePublishText(
  701. task,
  702. account.platform as PlatformType
  703. );
  704. if (!resolvedTitle) {
  705. throw new Error('Publish title is empty');
  706. }
  707. const result = await (adapter as any).publishVideo(
  708. decryptedCookies,
  709. {
  710. videoPath: absoluteVideoPath,
  711. title: resolvedTitle,
  712. description: resolvedDescription,
  713. coverPath: task.coverPath ? path.resolve(process.cwd(), task.coverPath) : undefined,
  714. tags: task.tags || [],
  715. extra: {
  716. userId,
  717. publishTaskId: taskId,
  718. publishAccountId: accountId,
  719. publishProxy: publishProxyExtra,
  720. },
  721. },
  722. (progress: number, message: string) => {
  723. wsManager.sendToUser(userId, WS_EVENTS.PUBLISH_PROGRESS, {
  724. taskId,
  725. accountId: account.id,
  726. platform: account.platform,
  727. status: 'processing',
  728. progress,
  729. message,
  730. });
  731. },
  732. undefined,
  733. { headless: false }
  734. );
  735. if (result.success) {
  736. await this.resultRepository.update(publishResult.id, {
  737. status: 'success',
  738. videoUrl: result.videoUrl || null,
  739. platformVideoId: result.platformVideoId || null,
  740. publishedAt: new Date(),
  741. errorMessage: null,
  742. });
  743. wsManager.sendToUser(userId, WS_EVENTS.PUBLISH_PROGRESS, {
  744. taskId,
  745. accountId: account.id,
  746. platform: account.platform,
  747. status: 'success',
  748. progress: 100,
  749. message: 'Publish completed',
  750. });
  751. logger.info(`[Headful Publish] Success for account ${account.accountName}`);
  752. return { success: true, message: 'Publish completed' };
  753. }
  754. const errorMsg = result.errorMessage || 'Publish failed';
  755. await this.resultRepository.update(publishResult.id, {
  756. status: 'failed',
  757. errorMessage: errorMsg,
  758. });
  759. wsManager.sendToUser(userId, WS_EVENTS.PUBLISH_PROGRESS, {
  760. taskId,
  761. accountId: account.id,
  762. platform: account.platform,
  763. status: 'failed',
  764. progress: 0,
  765. message: errorMsg,
  766. });
  767. logger.warn(`[Headful Publish] Failed for account ${account.accountName}: ${errorMsg}`);
  768. return { success: false, message: 'Publish failed', error: errorMsg };
  769. } catch (error) {
  770. const errorMsg =
  771. error instanceof Error && error.name === 'TimeoutError'
  772. ? 'Visible browser publish timed out'
  773. : error instanceof Error
  774. ? error.message
  775. : 'Publish failed';
  776. await this.resultRepository.update(publishResult.id, {
  777. status: 'failed',
  778. errorMessage: errorMsg,
  779. });
  780. wsManager.sendToUser(userId, WS_EVENTS.PUBLISH_PROGRESS, {
  781. taskId,
  782. accountId: account.id,
  783. platform: account.platform,
  784. status: 'failed',
  785. progress: 0,
  786. message: errorMsg,
  787. });
  788. logger.error(`[Headful Publish] Error for account ${account.accountName}:`, error);
  789. return { success: false, message: 'Publish failed', error: errorMsg };
  790. }
  791. }
  792. async deleteTask(userId: number, taskId: number): Promise<void> {
  793. const task = await this.taskRepository.findOne({
  794. where: { id: taskId, userId },
  795. });
  796. if (!task) {
  797. throw new AppError('任务不存在', HTTP_STATUS.NOT_FOUND, ERROR_CODES.NOT_FOUND);
  798. }
  799. // 不能删除正在执行的任务
  800. if (task.status === 'processing') {
  801. throw new AppError('不能删除正在执行的任务', HTTP_STATUS.BAD_REQUEST, ERROR_CODES.VALIDATION);
  802. }
  803. // 先删除关联的发布结果
  804. await this.resultRepository.delete({ taskId });
  805. // 再删除任务
  806. await this.taskRepository.delete(taskId);
  807. }
  808. private formatTask(task: PublishTask): PublishTaskType {
  809. return {
  810. id: task.id,
  811. userId: task.userId,
  812. videoPath: task.videoPath || '',
  813. videoFilename: task.videoFilename || '',
  814. title: task.title || '',
  815. description: task.description,
  816. coverPath: task.coverPath,
  817. tags: task.tags || [],
  818. targetAccounts: task.targetAccounts || [],
  819. platformConfigs: task.platformConfigs || [],
  820. publishProxy: task.publishProxy,
  821. status: task.status,
  822. scheduledAt: task.scheduledAt?.toISOString() || null,
  823. publishedAt: task.publishedAt?.toISOString() || null,
  824. createdAt: task.createdAt.toISOString(),
  825. updatedAt: task.updatedAt.toISOString(),
  826. };
  827. }
  828. private async buildPublishProxyExtra(publishProxy: PublishProxyConfig | null | undefined): Promise<null | {
  829. enabled: boolean;
  830. provider: 'shenlong';
  831. city: string;
  832. productKey: string;
  833. signature: string;
  834. regionCode?: string;
  835. regionName?: string;
  836. }> {
  837. if (!publishProxy?.enabled) return null;
  838. const provider = publishProxy.provider || 'shenlong';
  839. if (provider !== 'shenlong') return null;
  840. const rows = await this.systemConfigRepository.find({
  841. where: { configKey: 'publish_proxy_shenlong_product_key' },
  842. });
  843. const productKey = String(rows?.[0]?.configValue || '').trim();
  844. const signatureRows = await this.systemConfigRepository.find({
  845. where: { configKey: 'publish_proxy_shenlong_signature' },
  846. });
  847. const signature = String(signatureRows?.[0]?.configValue || '').trim();
  848. if (!productKey || !signature) return null;
  849. const rawRegionCode = publishProxy.regionCode ? String(publishProxy.regionCode).trim() : '';
  850. let regionCode: string | undefined = rawRegionCode || undefined;
  851. // 格式化地区编码:神龙 API 支持 6 位编码,省级(0000)、地级(00) 均保留传递
  852. if (regionCode && /^\d{6}$/.test(regionCode) && !regionCode.endsWith('00')) {
  853. regionCode = `${regionCode.slice(0, 4)}00`;
  854. }
  855. return {
  856. enabled: true,
  857. provider: 'shenlong',
  858. city: String(publishProxy.city || '').trim(),
  859. productKey,
  860. signature,
  861. regionCode,
  862. regionName: publishProxy.regionName ? String(publishProxy.regionName).trim() : undefined,
  863. };
  864. }
  865. /**
  866. * 用户手动确认发布结果(如小红书"发布结果待确认"时用户确认已发布成功)
  867. */
  868. async confirmPublishResult(userId: number, taskId: number, resultId: number): Promise<void> {
  869. const task = await this.taskRepository.findOne({
  870. where: { id: taskId, userId },
  871. relations: ['results'],
  872. });
  873. if (!task) {
  874. throw new AppError('任务不存在', HTTP_STATUS.NOT_FOUND, ERROR_CODES.NOT_FOUND);
  875. }
  876. const result = task.results?.find(r => r.id === resultId);
  877. if (!result) {
  878. throw new AppError('发布结果不存在', HTTP_STATUS.NOT_FOUND, ERROR_CODES.NOT_FOUND);
  879. }
  880. await this.resultRepository.update(resultId, {
  881. status: 'success',
  882. errorMessage: null,
  883. publishedAt: new Date(),
  884. });
  885. // 检查是否所有结果都成功,如是则更新任务状态
  886. const updatedResults = await this.resultRepository.find({ where: { taskId } });
  887. const allSuccess = updatedResults.every(r => r.status === 'success');
  888. if (allSuccess) {
  889. await this.taskRepository.update(taskId, { status: 'completed', publishedAt: new Date() });
  890. }
  891. }
  892. private formatTaskDetail(
  893. task: PublishTask,
  894. overrideTargetAccounts?: number[],
  895. overrideStatus?: string,
  896. ): PublishTaskDetail {
  897. const base = this.formatTask(task);
  898. // 修复 #6068:使用实际存在的 PublishResult 账号列表覆盖缓存的 targetAccounts
  899. if (overrideTargetAccounts) {
  900. base.targetAccounts = overrideTargetAccounts;
  901. }
  902. if (overrideStatus) {
  903. base.status = overrideStatus as PublishTaskType['status'];
  904. }
  905. return {
  906. ...base,
  907. results: task.results?.map(r => ({
  908. id: r.id,
  909. taskId: r.taskId,
  910. accountId: r.accountId,
  911. platform: r.platform!,
  912. status: r.status!,
  913. videoUrl: r.videoUrl,
  914. platformVideoId: r.platformVideoId,
  915. errorMessage: r.errorMessage,
  916. publishedAt: r.publishedAt?.toISOString() || null,
  917. })) || [],
  918. };
  919. }
  920. }