PublishService.ts 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764
  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 { KuaishouAdapter } from '../automation/platforms/kuaishou.js';
  17. import { BilibiliAdapter } from '../automation/platforms/bilibili.js';
  18. import { BaijiahaoAdapter } from '../automation/platforms/baijiahao.js';
  19. import { BasePlatformAdapter } from '../automation/platforms/base.js';
  20. import { logger } from '../utils/logger.js';
  21. import path from 'path';
  22. import { config } from '../config/index.js';
  23. import { CookieManager } from '../automation/cookie.js';
  24. import { taskQueueService } from './TaskQueueService.js';
  25. import { In } from 'typeorm';
  26. interface GetTasksParams {
  27. page: number;
  28. pageSize: number;
  29. status?: string;
  30. }
  31. export class PublishService {
  32. private taskRepository = AppDataSource.getRepository(PublishTask);
  33. private resultRepository = AppDataSource.getRepository(PublishResult);
  34. private accountRepository = AppDataSource.getRepository(PlatformAccount);
  35. private systemConfigRepository = AppDataSource.getRepository(SystemConfig);
  36. // 平台适配器映射
  37. private adapters: Map<PlatformType, BasePlatformAdapter> = new Map();
  38. constructor() {
  39. // 初始化平台适配器
  40. this.adapters.set('douyin', new DouyinAdapter());
  41. this.adapters.set('xiaohongshu', new XiaohongshuAdapter());
  42. this.adapters.set('weixin_video', new WeixinAdapter());
  43. this.adapters.set('kuaishou', new KuaishouAdapter());
  44. this.adapters.set('bilibili', new BilibiliAdapter());
  45. this.adapters.set('baijiahao', new BaijiahaoAdapter());
  46. }
  47. /**
  48. * 获取平台适配器
  49. */
  50. private getAdapter(platform: PlatformType) {
  51. const adapter = this.adapters.get(platform);
  52. if (!adapter) {
  53. throw new AppError(`不支持的平台: ${platform}`, HTTP_STATUS.BAD_REQUEST, ERROR_CODES.VALIDATION);
  54. }
  55. return adapter;
  56. }
  57. async getTasks(userId: number, params: GetTasksParams): Promise<PaginatedData<PublishTaskType>> {
  58. const { page, pageSize, status } = params;
  59. const skip = (page - 1) * pageSize;
  60. const queryBuilder = this.taskRepository
  61. .createQueryBuilder('task')
  62. .where('task.userId = :userId', { userId });
  63. if (status) {
  64. queryBuilder.andWhere('task.status = :status', { status });
  65. }
  66. const [tasks, total] = await queryBuilder
  67. .orderBy('task.createdAt', 'DESC')
  68. .skip(skip)
  69. .take(pageSize)
  70. .getManyAndCount();
  71. return {
  72. items: tasks.map(this.formatTask),
  73. total,
  74. page,
  75. pageSize,
  76. totalPages: Math.ceil(total / pageSize),
  77. };
  78. }
  79. async getTaskById(userId: number, taskId: number): Promise<PublishTaskDetail> {
  80. const task = await this.taskRepository.findOne({
  81. where: { id: taskId, userId },
  82. relations: ['results'],
  83. });
  84. if (!task) {
  85. throw new AppError('任务不存在', HTTP_STATUS.NOT_FOUND, ERROR_CODES.NOT_FOUND);
  86. }
  87. return this.formatTaskDetail(task);
  88. }
  89. async createTask(userId: number, data: CreatePublishTaskRequest): Promise<PublishTaskType> {
  90. // 验证目标账号是否存在
  91. const validAccountIds: number[] = [];
  92. const invalidAccountIds: number[] = [];
  93. for (const accountId of data.targetAccounts) {
  94. const account = await this.accountRepository.findOne({
  95. where: { id: accountId, userId }
  96. });
  97. if (account) {
  98. validAccountIds.push(accountId);
  99. } else {
  100. invalidAccountIds.push(accountId);
  101. logger.warn(`[PublishService] Account ${accountId} not found or not owned by user ${userId}, skipping`);
  102. }
  103. }
  104. if (validAccountIds.length === 0) {
  105. throw new AppError('所选账号不存在或已被删除', HTTP_STATUS.BAD_REQUEST, ERROR_CODES.VALIDATION);
  106. }
  107. if (invalidAccountIds.length > 0) {
  108. logger.warn(`[PublishService] ${invalidAccountIds.length} invalid accounts skipped: ${invalidAccountIds.join(', ')}`);
  109. }
  110. const task = this.taskRepository.create({
  111. userId,
  112. videoPath: data.videoPath,
  113. videoFilename: data.videoPath.split('/').pop() || null,
  114. title: data.title,
  115. description: data.description || null,
  116. coverPath: data.coverPath || null,
  117. tags: data.tags || null,
  118. targetAccounts: validAccountIds, // 只保存有效的账号 ID
  119. platformConfigs: data.platformConfigs || null,
  120. publishProxy: data.publishProxy || null,
  121. status: 'pending', // 初始状态为 pending,任务队列执行时再更新为 processing
  122. scheduledAt: data.scheduledAt ? new Date(data.scheduledAt) : null,
  123. });
  124. await this.taskRepository.save(task);
  125. // 创建发布结果记录(只为有效账号创建)
  126. for (const accountId of validAccountIds) {
  127. const result = this.resultRepository.create({
  128. taskId: task.id,
  129. accountId,
  130. });
  131. await this.resultRepository.save(result);
  132. }
  133. // 通知客户端
  134. wsManager.sendToUser(userId, WS_EVENTS.TASK_CREATED, { task: this.formatTask(task) });
  135. // 返回任务信息,发布任务将通过任务队列执行
  136. // 调用者需要调用 taskQueueService.createTask 来创建队列任务
  137. return this.formatTask(task);
  138. }
  139. /**
  140. * 带进度回调的发布任务执行
  141. */
  142. async executePublishTaskWithProgress(
  143. taskId: number,
  144. userId: number,
  145. onProgress?: (progress: number, message: string) => void
  146. ): Promise<void> {
  147. const task = await this.taskRepository.findOne({
  148. where: { id: taskId },
  149. relations: ['results'],
  150. });
  151. if (!task) {
  152. throw new Error(`Task ${taskId} not found`);
  153. }
  154. // 更新任务状态为处理中
  155. await this.taskRepository.update(taskId, { status: 'processing' });
  156. wsManager.sendToUser(userId, WS_EVENTS.TASK_STATUS_CHANGED, {
  157. taskId,
  158. status: 'processing',
  159. });
  160. const results = task.results || [];
  161. let successCount = 0;
  162. let failCount = 0;
  163. const totalAccounts = results.length;
  164. let publishProxyExtra: Awaited<ReturnType<PublishService['buildPublishProxyExtra']>> = null;
  165. try {
  166. publishProxyExtra = await this.buildPublishProxyExtra(task.publishProxy);
  167. } catch (error) {
  168. const errorMessage = error instanceof Error ? error.message : '发布代理配置错误';
  169. logger.error(`[PublishService] publish proxy config error: ${errorMessage}`);
  170. for (const r of results) {
  171. await this.resultRepository.update(r.id, {
  172. status: 'failed',
  173. errorMessage,
  174. });
  175. }
  176. await this.taskRepository.update(taskId, {
  177. status: 'failed',
  178. publishedAt: new Date(),
  179. });
  180. wsManager.sendToUser(userId, WS_EVENTS.TASK_STATUS_CHANGED, {
  181. taskId,
  182. status: 'failed',
  183. successCount: 0,
  184. failCount: totalAccounts,
  185. });
  186. onProgress?.(100, `发布失败: ${errorMessage}`);
  187. return;
  188. }
  189. // 构建视频文件的完整路径
  190. let videoPath = task.videoPath || '';
  191. // 处理各种路径格式
  192. if (videoPath) {
  193. // 如果路径以 /uploads/ 开头,提取相对路径部分
  194. if (videoPath.startsWith('/uploads/')) {
  195. videoPath = path.join(config.upload.path, videoPath.replace('/uploads/', ''));
  196. }
  197. // 如果是相对路径(不是绝对路径),拼接上传目录
  198. else if (!path.isAbsolute(videoPath)) {
  199. // 移除可能的重复 uploads 前缀
  200. videoPath = videoPath.replace(/^uploads[\\\/]+uploads[\\\/]+/, '');
  201. videoPath = videoPath.replace(/^uploads[\\\/]+/, '');
  202. videoPath = path.join(config.upload.path, videoPath);
  203. }
  204. }
  205. logger.info(`Publishing video: ${videoPath}`);
  206. onProgress?.(5, `准备发布到 ${totalAccounts} 个账号...`);
  207. // 遍历所有目标账号,逐个发布
  208. for (let i = 0; i < results.length; i++) {
  209. const result = results[i];
  210. const accountProgress = Math.floor((i / totalAccounts) * 80) + 10;
  211. try {
  212. // 获取账号信息
  213. const account = await this.accountRepository.findOne({
  214. where: { id: result.accountId },
  215. });
  216. if (!account) {
  217. logger.warn(`Account ${result.accountId} not found`);
  218. await this.resultRepository.update(result.id, {
  219. status: 'failed',
  220. errorMessage: '账号不存在',
  221. });
  222. failCount++;
  223. continue;
  224. }
  225. if (!account.cookieData) {
  226. logger.warn(`Account ${result.accountId} has no cookies`);
  227. await this.resultRepository.update(result.id, {
  228. status: 'failed',
  229. errorMessage: '账号未登录',
  230. });
  231. failCount++;
  232. continue;
  233. }
  234. // 解密 Cookie
  235. let decryptedCookies: string;
  236. try {
  237. decryptedCookies = CookieManager.decrypt(account.cookieData);
  238. } catch {
  239. // 如果解密失败,可能是未加密的 Cookie
  240. decryptedCookies = account.cookieData;
  241. }
  242. // 更新发布结果的平台信息
  243. await this.resultRepository.update(result.id, {
  244. platform: account.platform,
  245. });
  246. // 获取适配器
  247. const adapter = this.getAdapter(account.platform as PlatformType);
  248. logger.info(`Publishing to account ${account.accountName} (${account.platform})`);
  249. onProgress?.(accountProgress, `正在发布到 ${account.accountName}...`);
  250. // 发送进度通知
  251. wsManager.sendToUser(userId, WS_EVENTS.PUBLISH_PROGRESS, {
  252. taskId,
  253. accountId: account.id,
  254. platform: account.platform,
  255. status: 'uploading',
  256. progress: 0,
  257. message: '开始发布...',
  258. });
  259. // 验证码处理回调(支持短信验证码和图形验证码)
  260. const onCaptchaRequired = async (captchaInfo: {
  261. taskId: string;
  262. type: 'sms' | 'image';
  263. phone?: string;
  264. imageBase64?: string;
  265. }): Promise<string> => {
  266. return new Promise((resolve, reject) => {
  267. const captchaTaskId = captchaInfo.taskId;
  268. // 发送验证码请求到前端
  269. const message = captchaInfo.type === 'sms'
  270. ? '请输入短信验证码'
  271. : '请输入图片中的验证码';
  272. logger.info(`[Publish] Requesting ${captchaInfo.type} captcha, taskId: ${captchaTaskId}, phone: ${captchaInfo.phone}`);
  273. wsManager.sendToUser(userId, WS_EVENTS.CAPTCHA_REQUIRED, {
  274. taskId,
  275. captchaTaskId,
  276. type: captchaInfo.type,
  277. phone: captchaInfo.phone || '',
  278. imageBase64: captchaInfo.imageBase64 || '',
  279. message,
  280. });
  281. // 设置超时(2分钟)
  282. const timeout = setTimeout(() => {
  283. wsManager.removeCaptchaListener(captchaTaskId);
  284. reject(new Error('验证码输入超时'));
  285. }, 120000);
  286. // 注册验证码监听
  287. wsManager.onCaptchaSubmit(captchaTaskId, (code: string) => {
  288. clearTimeout(timeout);
  289. wsManager.removeCaptchaListener(captchaTaskId);
  290. logger.info(`[Publish] Received captcha code for ${captchaTaskId}`);
  291. resolve(code);
  292. });
  293. });
  294. };
  295. // 执行发布
  296. const publishResult = await adapter.publishVideo(
  297. decryptedCookies,
  298. {
  299. videoPath,
  300. title: task.title || '',
  301. description: task.description || undefined,
  302. coverPath: task.coverPath || undefined,
  303. tags: task.tags || undefined,
  304. extra: {
  305. userId,
  306. publishTaskId: taskId,
  307. publishAccountId: account.id,
  308. publishProxy: publishProxyExtra,
  309. },
  310. },
  311. (progress, message) => {
  312. // 发送进度更新
  313. wsManager.sendToUser(userId, WS_EVENTS.PUBLISH_PROGRESS, {
  314. taskId,
  315. accountId: account.id,
  316. platform: account.platform,
  317. status: 'processing',
  318. progress,
  319. message,
  320. });
  321. },
  322. onCaptchaRequired
  323. );
  324. if (publishResult.success) {
  325. await this.resultRepository.update(result.id, {
  326. status: 'success',
  327. videoUrl: publishResult.videoUrl || null,
  328. platformVideoId: publishResult.platformVideoId || null,
  329. publishedAt: new Date(),
  330. });
  331. successCount++;
  332. wsManager.sendToUser(userId, WS_EVENTS.PUBLISH_PROGRESS, {
  333. taskId,
  334. accountId: account.id,
  335. platform: account.platform,
  336. status: 'success',
  337. progress: 100,
  338. message: '发布成功',
  339. });
  340. } else {
  341. await this.resultRepository.update(result.id, {
  342. status: 'failed',
  343. errorMessage: publishResult.errorMessage || '发布失败',
  344. });
  345. failCount++;
  346. wsManager.sendToUser(userId, WS_EVENTS.PUBLISH_PROGRESS, {
  347. taskId,
  348. accountId: account.id,
  349. platform: account.platform,
  350. status: 'failed',
  351. progress: 0,
  352. message: publishResult.errorMessage || '发布失败',
  353. });
  354. }
  355. // 每个账号发布后等待一段时间,避免过于频繁
  356. await new Promise(resolve => setTimeout(resolve, 5000));
  357. } catch (error) {
  358. logger.error(`Failed to publish to account ${result.accountId}:`, error);
  359. await this.resultRepository.update(result.id, {
  360. status: 'failed',
  361. errorMessage: error instanceof Error ? error.message : '发布失败',
  362. });
  363. failCount++;
  364. }
  365. }
  366. // 更新任务状态
  367. const finalStatus = failCount === 0 ? 'completed' : (successCount === 0 ? 'failed' : 'completed');
  368. await this.taskRepository.update(taskId, {
  369. status: finalStatus,
  370. publishedAt: new Date(),
  371. });
  372. wsManager.sendToUser(userId, WS_EVENTS.TASK_STATUS_CHANGED, {
  373. taskId,
  374. status: finalStatus,
  375. successCount,
  376. failCount,
  377. });
  378. onProgress?.(100, `发布完成: ${successCount} 成功, ${failCount} 失败`);
  379. logger.info(`Task ${taskId} completed: ${successCount} success, ${failCount} failed`);
  380. // 发布成功后,自动创建同步作品任务
  381. if (successCount > 0) {
  382. // 收集成功发布的账号ID
  383. const successAccountIds = new Set<number>();
  384. for (const result of results) {
  385. if (result.status === 'success') {
  386. successAccountIds.add(result.accountId);
  387. }
  388. }
  389. // 为每个成功的账号创建同步任务
  390. for (const accountId of successAccountIds) {
  391. const account = await this.accountRepository.findOne({ where: { id: accountId } });
  392. if (account) {
  393. taskQueueService.createTask(userId, {
  394. type: 'sync_works',
  395. title: `同步作品 - ${account.accountName || '账号'}`,
  396. accountId: account.id,
  397. });
  398. logger.info(`Created sync_works task for account ${accountId} after publish`);
  399. }
  400. }
  401. }
  402. }
  403. async cancelTask(userId: number, taskId: number): Promise<void> {
  404. const task = await this.taskRepository.findOne({
  405. where: { id: taskId, userId },
  406. });
  407. if (!task) {
  408. throw new AppError('任务不存在', HTTP_STATUS.NOT_FOUND, ERROR_CODES.NOT_FOUND);
  409. }
  410. if (!['pending', 'processing'].includes(task.status)) {
  411. throw new AppError('该任务状态不可取消', HTTP_STATUS.BAD_REQUEST, ERROR_CODES.VALIDATION);
  412. }
  413. await this.taskRepository.update(taskId, { status: 'cancelled' });
  414. wsManager.sendToUser(userId, WS_EVENTS.TASK_STATUS_CHANGED, {
  415. taskId,
  416. status: 'cancelled',
  417. });
  418. }
  419. async retryTask(userId: number, taskId: number): Promise<PublishTaskType> {
  420. const task = await this.taskRepository.findOne({
  421. where: { id: taskId, userId },
  422. });
  423. if (!task) {
  424. throw new AppError('任务不存在', HTTP_STATUS.NOT_FOUND, ERROR_CODES.NOT_FOUND);
  425. }
  426. // 允许重试失败或卡住(processing)的任务
  427. if (!['failed', 'processing'].includes(task.status)) {
  428. throw new AppError('只能重试失败或卡住的任务', HTTP_STATUS.BAD_REQUEST, ERROR_CODES.VALIDATION);
  429. }
  430. await this.taskRepository.update(taskId, { status: 'pending' });
  431. // 重置失败的发布结果
  432. await this.resultRepository.update(
  433. { taskId, status: 'failed' },
  434. { status: null, errorMessage: null }
  435. );
  436. const updated = await this.taskRepository.findOne({ where: { id: taskId } });
  437. wsManager.sendToUser(userId, WS_EVENTS.TASK_STATUS_CHANGED, {
  438. taskId,
  439. status: 'processing',
  440. });
  441. // 返回任务信息,调用者需要通过任务队列重新执行
  442. return this.formatTask(updated!);
  443. }
  444. /**
  445. * 单账号有头浏览器重试发布(用于验证码场景)
  446. * 调用 Python API 以有头浏览器模式执行发布
  447. */
  448. async retryAccountWithHeadfulBrowser(
  449. userId: number,
  450. taskId: number,
  451. accountId: number
  452. ): Promise<{ success: boolean; message: string; error?: string }> {
  453. // 1. 验证任务存在
  454. const task = await this.taskRepository.findOne({
  455. where: { id: taskId, userId },
  456. relations: ['results'],
  457. });
  458. if (!task) {
  459. throw new AppError('任务不存在', HTTP_STATUS.NOT_FOUND, ERROR_CODES.NOT_FOUND);
  460. }
  461. // 2. 获取账号信息
  462. const account = await this.accountRepository.findOne({
  463. where: { id: accountId },
  464. });
  465. if (!account) {
  466. throw new AppError('账号不存在', HTTP_STATUS.NOT_FOUND, ERROR_CODES.NOT_FOUND);
  467. }
  468. // 3. 获取发布结果记录
  469. const publishResult = task.results?.find(r => r.accountId === accountId);
  470. if (!publishResult) {
  471. throw new AppError('发布结果不存在', HTTP_STATUS.NOT_FOUND, ERROR_CODES.NOT_FOUND);
  472. }
  473. // 4. 解密 Cookie
  474. let decryptedCookies: string;
  475. try {
  476. decryptedCookies = CookieManager.decrypt(account.cookieData || '');
  477. } catch {
  478. decryptedCookies = account.cookieData || '';
  479. }
  480. // 5. 构建视频文件的完整路径
  481. let videoPath = task.videoPath || '';
  482. if (videoPath) {
  483. if (videoPath.startsWith('/uploads/')) {
  484. videoPath = path.join(config.upload.path, videoPath.replace('/uploads/', ''));
  485. } else if (!path.isAbsolute(videoPath)) {
  486. videoPath = videoPath.replace(/^uploads[\\\/]+uploads[\\\/]+/, '');
  487. videoPath = videoPath.replace(/^uploads[\\\/]+/, '');
  488. videoPath = path.join(config.upload.path, videoPath);
  489. }
  490. }
  491. const publishProxyExtra = await this.buildPublishProxyExtra(task.publishProxy);
  492. // 6. 调用 Python API(有头浏览器模式)
  493. const PYTHON_SERVICE_URL = process.env.PYTHON_PUBLISH_SERVICE_URL || 'http://localhost:5005';
  494. logger.info(`[Headful Publish] Starting headful browser publish for account ${account.accountName} (${account.platform})`);
  495. // 更新状态为处理中
  496. await this.resultRepository.update(publishResult.id, {
  497. status: null,
  498. errorMessage: null,
  499. });
  500. // 发送 WebSocket 通知
  501. wsManager.sendToUser(userId, WS_EVENTS.PUBLISH_PROGRESS, {
  502. taskId,
  503. accountId: account.id,
  504. platform: account.platform,
  505. status: 'processing',
  506. progress: 10,
  507. message: '正在启动有头浏览器发布...',
  508. });
  509. try {
  510. const absoluteVideoPath = path.isAbsolute(videoPath)
  511. ? videoPath
  512. : path.resolve(process.cwd(), videoPath);
  513. const response = await fetch(`${PYTHON_SERVICE_URL}/publish/ai-assisted`, {
  514. method: 'POST',
  515. headers: { 'Content-Type': 'application/json' },
  516. body: JSON.stringify({
  517. platform: account.platform,
  518. cookie: decryptedCookies,
  519. user_id: userId,
  520. publish_task_id: taskId,
  521. publish_account_id: accountId,
  522. proxy: publishProxyExtra,
  523. title: task.title,
  524. description: task.description || task.title,
  525. video_path: absoluteVideoPath,
  526. cover_path: task.coverPath ? path.resolve(process.cwd(), task.coverPath) : undefined,
  527. tags: task.tags || [],
  528. headless: false, // 关键:使用有头浏览器模式
  529. }),
  530. signal: AbortSignal.timeout(600000), // 10分钟超时
  531. });
  532. const result = await response.json();
  533. if (result.success) {
  534. // 更新发布结果为成功
  535. await this.resultRepository.update(publishResult.id, {
  536. status: 'success',
  537. videoUrl: result.video_url || null,
  538. platformVideoId: result.video_id || null,
  539. publishedAt: new Date(),
  540. errorMessage: null,
  541. });
  542. wsManager.sendToUser(userId, WS_EVENTS.PUBLISH_PROGRESS, {
  543. taskId,
  544. accountId: account.id,
  545. platform: account.platform,
  546. status: 'success',
  547. progress: 100,
  548. message: '发布成功',
  549. });
  550. logger.info(`[Headful Publish] Success for account ${account.accountName}`);
  551. return { success: true, message: '发布成功' };
  552. } else {
  553. // 发布失败
  554. const errorMsg = result.error || '发布失败';
  555. await this.resultRepository.update(publishResult.id, {
  556. status: 'failed',
  557. errorMessage: errorMsg,
  558. });
  559. wsManager.sendToUser(userId, WS_EVENTS.PUBLISH_PROGRESS, {
  560. taskId,
  561. accountId: account.id,
  562. platform: account.platform,
  563. status: 'failed',
  564. progress: 0,
  565. message: errorMsg,
  566. });
  567. logger.warn(`[Headful Publish] Failed for account ${account.accountName}: ${errorMsg}`);
  568. return { success: false, message: '发布失败', error: errorMsg };
  569. }
  570. } catch (error) {
  571. const errorMsg = error instanceof Error ? error.message : '发布失败';
  572. await this.resultRepository.update(publishResult.id, {
  573. status: 'failed',
  574. errorMessage: errorMsg,
  575. });
  576. wsManager.sendToUser(userId, WS_EVENTS.PUBLISH_PROGRESS, {
  577. taskId,
  578. accountId: account.id,
  579. platform: account.platform,
  580. status: 'failed',
  581. progress: 0,
  582. message: errorMsg,
  583. });
  584. logger.error(`[Headful Publish] Error for account ${account.accountName}:`, error);
  585. return { success: false, message: '发布失败', error: errorMsg };
  586. }
  587. }
  588. async deleteTask(userId: number, taskId: number): Promise<void> {
  589. const task = await this.taskRepository.findOne({
  590. where: { id: taskId, userId },
  591. });
  592. if (!task) {
  593. throw new AppError('任务不存在', HTTP_STATUS.NOT_FOUND, ERROR_CODES.NOT_FOUND);
  594. }
  595. // 不能删除正在执行的任务
  596. if (task.status === 'processing') {
  597. throw new AppError('不能删除正在执行的任务', HTTP_STATUS.BAD_REQUEST, ERROR_CODES.VALIDATION);
  598. }
  599. // 先删除关联的发布结果
  600. await this.resultRepository.delete({ taskId });
  601. // 再删除任务
  602. await this.taskRepository.delete(taskId);
  603. }
  604. private formatTask(task: PublishTask): PublishTaskType {
  605. return {
  606. id: task.id,
  607. userId: task.userId,
  608. videoPath: task.videoPath || '',
  609. videoFilename: task.videoFilename || '',
  610. title: task.title || '',
  611. description: task.description,
  612. coverPath: task.coverPath,
  613. tags: task.tags || [],
  614. targetAccounts: task.targetAccounts || [],
  615. platformConfigs: task.platformConfigs || [],
  616. publishProxy: task.publishProxy,
  617. status: task.status,
  618. scheduledAt: task.scheduledAt?.toISOString() || null,
  619. publishedAt: task.publishedAt?.toISOString() || null,
  620. createdAt: task.createdAt.toISOString(),
  621. updatedAt: task.updatedAt.toISOString(),
  622. };
  623. }
  624. private async buildPublishProxyExtra(publishProxy: PublishProxyConfig | null | undefined): Promise<null | {
  625. enabled: boolean;
  626. provider: 'shenlong';
  627. city: string;
  628. apiUrl: string;
  629. regionCode?: string;
  630. regionName?: string;
  631. }> {
  632. if (!publishProxy?.enabled) return null;
  633. const provider = publishProxy.provider || 'shenlong';
  634. if (provider !== 'shenlong') return null;
  635. const rows = await this.systemConfigRepository.find({
  636. where: { configKey: 'publish_proxy_city_api_url' },
  637. });
  638. const cityApiUrl = String(rows?.[0]?.configValue || '').trim();
  639. if (!cityApiUrl) {
  640. return null;
  641. }
  642. return {
  643. enabled: true,
  644. provider: 'shenlong',
  645. city: String(publishProxy.city || '').trim(),
  646. apiUrl: cityApiUrl,
  647. regionCode: publishProxy.regionCode ? String(publishProxy.regionCode).trim() : undefined,
  648. regionName: publishProxy.regionName ? String(publishProxy.regionName).trim() : undefined,
  649. };
  650. }
  651. private formatTaskDetail(task: PublishTask): PublishTaskDetail {
  652. return {
  653. ...this.formatTask(task),
  654. results: task.results?.map(r => ({
  655. id: r.id,
  656. taskId: r.taskId,
  657. accountId: r.accountId,
  658. platform: r.platform!,
  659. status: r.status!,
  660. videoUrl: r.videoUrl,
  661. platformVideoId: r.platformVideoId,
  662. errorMessage: r.errorMessage,
  663. publishedAt: r.publishedAt?.toISOString() || null,
  664. })) || [],
  665. };
  666. }
  667. }