Преглед изворни кода

1.新增任务中心类型2.保存剧本资产接口适配任务中心3.批量生成剧集适配任务中心

lh пре 1 месец
родитељ
комит
b8738040ef

+ 9 - 2
app/Console/Commands/CheckProductPicTasksCommand.php

@@ -41,11 +41,18 @@ class CheckProductPicTasksCommand extends Command
 
         $stats = $this->animeService->checkProductPicTasks();
 
+        // 兼容:检查任务中心中 saveScriptProducts 类型的任务是否完成,完成则更新状态和结果
+        $taskCenterUpdated = $this->animeService->checkSaveScriptProductTaskCenters();
+
         $time_end = microtime(true);
         $this->info('检查完成: ' . json_encode($stats, JSON_UNESCAPED_UNICODE));
+        if ($taskCenterUpdated > 0) {
+            $this->info('任务中心saveScriptProducts任务更新: ' . $taskCenterUpdated . ' 条');
+        }
         dLog('command')->info('产品图片生成任务检查完成', [
-            'stats'     => $stats,
-            'cost_time' => round($time_end - $time_start, 2)
+            'stats'                => $stats,
+            'task_center_updated'  => $taskCenterUpdated,
+            'cost_time'            => round($time_end - $time_start, 2)
         ]);
         dLog('command')->info('====================结束检查产品图片生成任务状态====================');
 

+ 7 - 0
app/Console/Commands/ProcessBatchAnimeEpisodeGenerationCommand.php

@@ -81,6 +81,13 @@ class ProcessBatchAnimeEpisodeGenerationCommand extends Command
                 'trace' => $e->getTraceAsString()
             ]);
         } finally {
+            // 更新任务中心:该 anime 的批量生成任务若已全部结束,同步状态和结果
+            try {
+                $this->deepSeekService->finishBatchGenerateEpisodesTaskCenters($animeId);
+            } catch (\Throwable $e) {
+                dLog('command')->error('批量生成分集任务中心同步异常: ' . $e->getMessage());
+            }
+
             // 显式释放处理锁;即使进程异常退出,连接断开后 MySQL 也会自动释放
             DB::statement('SELECT RELEASE_LOCK(?)', [$lockName]);
             dLog('command')->info("anime_id: {$animeId} 处理结束,已释放处理锁");

+ 3 - 0
app/Models/MpTaskCenter.php

@@ -16,6 +16,8 @@ class MpTaskCenter extends Model
     const TYPE_TEXT = 'text';
     const TYPE_IMAGE = 'image';
     const TYPE_VIDEO = 'video';
+    const TYPE_SAVE_SCRIPT_PRODUCTS = 'saveScriptProducts';
+    const TYPE_BATCH_GENERATE_EPISODES = 'batchGenerateEpisodes';
 
     protected $fillable = [
         'uid',
@@ -28,5 +30,6 @@ class MpTaskCenter extends Model
         'error_message',
         'prompt',
         'params',
+        'params_md5',
     ];
 }

+ 312 - 8
app/Services/Anime/AnimeService.php

@@ -7,6 +7,7 @@ use App\Consts\ErrorConst;
 use App\Facade\Site;
 use App\Libs\Utils;
 use App\Models\MpGeneratePicTask;
+use App\Models\MpTaskCenter;
 use App\Services\AIGeneration\AIImageGenerationService;
 use App\Services\AIGeneration\AIVideoGenerationService;
 use App\Services\DeepSeek\DeepSeekService;
@@ -8223,6 +8224,18 @@ class AnimeService
         
         $script_name = $script->script_name ?? 'script_' . $script_id;
         
+        // 根据入参创建/复用任务中心记录(params md5 验重,避免重复任务)
+        $taskCenterId = $this->createScriptProductTaskCenter(
+            $uid,
+            $cpid,
+            $script_id,
+            $products,
+            $model,
+            $ratio,
+            $resolution,
+            $auto_generate_images
+        );
+
         // 检查是否已经有该剧本的资产数据
         $existingMappings = DB::table('mp_script_product_mappings')
             ->where('script_id', $script_id)
@@ -8275,7 +8288,7 @@ class AnimeService
                 ]);
                 
                 // 直接返回 SSE 流轮询结果
-                return $this->pollProductImageTasks($script_id, $script_name, $productTaskMap, $typeFolderIds);
+                return $this->pollProductImageTasks($script_id, $script_name, $productTaskMap, $typeFolderIds, $taskCenterId);
             }
             
             // 如果部分资产没有任务ID,只为这些资产创建任务
@@ -8356,7 +8369,7 @@ class AnimeService
                     
                     // 如果有新创建的任务,返回 SSE 流
                     if (!empty($productTaskMap)) {
-                        return $this->pollProductImageTasks($script_id, $script_name, $productTaskMap, $typeFolderIds);
+                        return $this->pollProductImageTasks($script_id, $script_name, $productTaskMap, $typeFolderIds, $taskCenterId);
                     }
                     
                 } catch (\Exception $e) {
@@ -8373,15 +8386,27 @@ class AnimeService
             }
             
             // 如果不需要生成图片,返回已存在的信息
-            return [
+            $resultData = [
                 'success' => true,
                 'message' => '剧本资产已存在',
                 'script_id' => $script_id,
                 'script_name' => $script_name,
                 'type_folder_ids' => $typeFolderIds,
                 'existing_products' => count($productIds),
-                'tasks_count' => count($productTaskMap)
+                'tasks_count' => count($productTaskMap),
+                'task_center_id' => $taskCenterId
             ];
+
+            // 接口已正常完成,更新任务中心状态和结果
+            if ($taskCenterId) {
+                $this->taskCenterService->updateTask($taskCenterId, [
+                    'status' => MpTaskCenter::STATUS_SUCCESS,
+                    'result' => json_encode($resultData, JSON_UNESCAPED_UNICODE),
+                    'error_message' => null
+                ]);
+            }
+
+            return $resultData;
         }
 
         // 解析 products(可能是 JSON 字符串或数组)
@@ -8644,23 +8669,43 @@ class AnimeService
             // 事务提交成功后,如果有图片生成任务,则返回 SSE 流
             if ($auto_generate_images && !empty($productTaskMap)) {
                 // 返回 SSE 流(事务外轮询)
-                return $this->pollProductImageTasks($script_id, $script_name, $productTaskMap, $typeFolderIds);
+                return $this->pollProductImageTasks($script_id, $script_name, $productTaskMap, $typeFolderIds, $taskCenterId);
             }
             
             // 如果不需要生成图片,返回普通结果
-            return [
+            $resultData = [
                 'success' => true,
                 'type_folder_ids' => $typeFolderIds,
                 'inserted_count' => $insertedCount,
                 'total_records' => count($mappingRecords),
                 'created_products' => count($createdProductIds),
-                'image_tasks_created' => count($productTaskMap)
+                'image_tasks_created' => count($productTaskMap),
+                'task_center_id' => $taskCenterId
             ];
+
+            // 接口已正常完成,更新任务中心状态和结果
+            if ($taskCenterId) {
+                $this->taskCenterService->updateTask($taskCenterId, [
+                    'status' => MpTaskCenter::STATUS_SUCCESS,
+                    'result' => json_encode($resultData, JSON_UNESCAPED_UNICODE),
+                    'error_message' => null
+                ]);
+            }
+
+            return $resultData;
             
         } catch (\Exception $e) {
             // 回滚事务
             DB::rollback();
             
+            // 更新任务中心状态为失败
+            if (!empty($taskCenterId)) {
+                $this->taskCenterService->updateTask($taskCenterId, [
+                    'status' => MpTaskCenter::STATUS_FAILED,
+                    'error_message' => $e->getMessage()
+                ]);
+            }
+
             // 记录错误日志
             dLog('anime')->error('保存剧本资产失败: ' . $e->getMessage(), [
                 'script_id' => $script_id,
@@ -8674,6 +8719,138 @@ class AnimeService
     }
 
     /**
+     * 根据入参创建或复用任务中心记录
+     *
+     * 以规范化入参计算 md5 并保存到任务中心 params 字段中用于验重:
+     * 同一用户/企业下,相同入参且任务未结束(pending/processing)时复用已有任务,
+     * 避免生成重复任务。
+     *
+     * @param int|string $uid
+     * @param int|string $cpid
+     * @param int|string $script_id
+     * @param mixed      $products
+     * @param string     $model
+     * @param string     $ratio
+     * @param string     $resolution
+     * @param mixed      $auto_generate_images
+     * @return int 任务中心ID
+     */
+    private function createScriptProductTaskCenter($uid, $cpid, $script_id, $products, $model, $ratio, $resolution, $auto_generate_images) {
+        // 规范化 products(兼容 JSON 字符串和数组),保证 md5 稳定
+        if (is_string($products)) {
+            $decoded = json_decode($products, true);
+            if (json_last_error() === JSON_ERROR_NONE) {
+                $products = $decoded;
+            }
+        }
+
+        $params = [
+            'script_id'            => (int)$script_id,
+            'products'             => $products,
+            'model'                => $model,
+            'ratio'                => $ratio,
+            'resolution'           => $resolution,
+            'auto_generate_images' => (bool)$auto_generate_images,
+        ];
+
+        $paramsMd5 = md5(json_encode($params, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
+
+        // 验重:相同 md5 且任务未结束(pending/processing)时复用已有任务中心记录
+        $existing = DB::table('mp_task_center')
+            ->where('uid', (int)$uid)
+            ->where('cpid', (int)$cpid)
+            ->where('task_type', MpTaskCenter::TYPE_SAVE_SCRIPT_PRODUCTS)
+            ->whereIn('status', [MpTaskCenter::STATUS_PENDING, MpTaskCenter::STATUS_PROCESSING])
+            ->where('params_md5', $paramsMd5)
+            ->orderBy('id', 'desc')
+            ->first();
+
+        if ($existing) {
+            dLog('anime')->info('复用已有任务中心记录,避免重复任务', [
+                'script_id'      => $script_id,
+                'task_center_id' => $existing->id,
+                'params_md5'     => $paramsMd5,
+            ]);
+
+            return (int)$existing->id;
+        }
+
+        // 唯一索引 (uid, cpid, task_type, params_md5) 兜底并发重复,冲突时静默忽略
+        $now = date('Y-m-d H:i:s');
+        DB::table('mp_task_center')->insertOrIgnore([
+            'uid'           => (int)$uid,
+            'cpid'          => (int)$cpid,
+            'task_type'     => MpTaskCenter::TYPE_SAVE_SCRIPT_PRODUCTS,
+            'title'         => '剧本资产图片生成',
+            'ref_task_id'   => 0,
+            'status'        => MpTaskCenter::STATUS_PROCESSING,
+            'result'        => null,
+            'error_message' => null,
+            'prompt'        => null,
+            'params'        => json_encode($params + ['params_md5' => $paramsMd5], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
+            'params_md5'    => $paramsMd5,
+            'created_at'    => $now,
+            'updated_at'    => $now,
+        ]);
+
+        // 获取任务中心ID(insertOrIgnore 冲突时复用并发插入的已有记录)
+        $taskId = (int)DB::table('mp_task_center')
+            ->where('uid', (int)$uid)
+            ->where('cpid', (int)$cpid)
+            ->where('task_type', MpTaskCenter::TYPE_SAVE_SCRIPT_PRODUCTS)
+            ->where('params_md5', $paramsMd5)
+            ->orderBy('id', 'desc')
+            ->value('id');
+
+        dLog('anime')->info('创建任务中心记录', [
+            'script_id'      => $script_id,
+            'task_center_id' => $taskId,
+            'params_md5'     => $paramsMd5,
+        ]);
+
+        return $taskId;
+    }
+
+    /**
+     * 任务结束时更新任务中心记录的状态和结果
+     *
+     * result 字段保存与 saveScriptProducts 接口返回给客户端一致的结果数据
+     * (普通数组返回为完整返回数组,SSE 轮询为 complete/timeout 事件载荷)。
+     *
+     * @param int|null $taskCenterId
+     * @param array    $resultData 接口返回结果数组
+     * @param bool     $timeout 是否超时未完成
+     */
+    private function finishScriptProductTask($taskCenterId, array $resultData, $timeout = false) {
+        if (!$taskCenterId) {
+            return;
+        }
+
+        $stats = $resultData['stats'] ?? [];
+        $successCount = (int)array_sum(array_column((array)$stats, 'success'));
+        $completedCount = (int)array_sum(array_column((array)$stats, 'completed'));
+        $failedCount = $completedCount - $successCount;
+
+        // 与接口返回给客户端的结果保持一致
+        $result = json_encode($resultData, JSON_UNESCAPED_UNICODE);
+
+        if ($timeout) {
+            $this->taskCenterService->updateTask((int)$taskCenterId, [
+                'status'        => MpTaskCenter::STATUS_FAILED,
+                'result'        => $result,
+                'error_message' => '部分任务超时,请稍后查看结果',
+            ]);
+            return;
+        }
+
+        $this->taskCenterService->updateTask((int)$taskCenterId, [
+            'status'        => $failedCount > 0 ? MpTaskCenter::STATUS_FAILED : MpTaskCenter::STATUS_SUCCESS,
+            'result'        => $result,
+            'error_message' => $failedCount > 0 ? ('有 ' . $failedCount . ' 个资产生成失败') : null,
+        ]);
+    }
+
+    /**
      * 批量为剧本资产生成图片
      * 
      * @param array $data 请求参数
@@ -8787,7 +8964,7 @@ class AnimeService
      * @param array $type_folder_ids 类型文件夹ID映射
      * @return \Generator
      */
-    private function pollProductImageTasks($script_id, $script_name, $productTaskMap, $type_folder_ids) {
+    private function pollProductImageTasks($script_id, $script_name, $productTaskMap, $type_folder_ids, $taskCenterId = null) {
         $startTime = time();
         $timeout = 1800; // 30分钟超时
         $pollInterval = 10; // 10秒轮询一次
@@ -8956,6 +9133,9 @@ class AnimeService
                     'stats' => $stats,
                     'timestamp' => date('Y-m-d H:i:s')
                 ];
+                if ($taskCenterId) {
+                    $response['task_center_id'] = $taskCenterId;
+                }
                 
                 yield "data: " . json_encode($response, JSON_UNESCAPED_UNICODE) . "\n\n";
                 
@@ -8972,6 +9152,10 @@ class AnimeService
                         'script_name' => $script_name,
                         'stats' => $stats
                     ]);
+                    
+                    // 全部任务完成,更新任务中心状态和结果
+                    $this->finishScriptProductTask($taskCenterId, $response);
+
                     break;
                 }
             }
@@ -8986,6 +9170,9 @@ class AnimeService
                 'script_name' => $script_name,
                 'timestamp' => date('Y-m-d H:i:s')
             ];
+            if ($taskCenterId) {
+                $response['task_center_id'] = $taskCenterId;
+            }
             
             yield "data: " . json_encode($response, JSON_UNESCAPED_UNICODE) . "\n\n";
             
@@ -8994,6 +9181,9 @@ class AnimeService
                 'script_name' => $script_name,
                 'timeout' => $timeout
             ]);
+
+            // 超时未完成,更新任务中心状态和结果
+            $this->finishScriptProductTask($taskCenterId, $response, true);
         }
     }
 
@@ -9102,6 +9292,120 @@ class AnimeService
     }
 
     /**
+     * 定时任务:检查任务中心中 saveScriptProducts 类型的任务是否已完成
+     *
+     * 当保存剧本资产的 SSE 连接中断等原因导致任务中心记录未及时更新时,
+     * 通过关联资产的图片生成状态兜底判断任务是否全部完成;
+     * 全部完成后更新任务中心的状态和结果(result 与 saveScriptProducts 接口返回保持一致)。
+     *
+     * @return int 更新的任务中心记录数
+     */
+    public function checkSaveScriptProductTaskCenters() {
+        $updated = 0;
+
+        // 只扫描尚未结束的 saveScriptProducts 任务
+        $tasks = DB::table('mp_task_center')
+            ->where('task_type', MpTaskCenter::TYPE_SAVE_SCRIPT_PRODUCTS)
+            ->whereIn('status', [MpTaskCenter::STATUS_PENDING, MpTaskCenter::STATUS_PROCESSING])
+            ->orderBy('id', 'desc')
+            ->limit(500)
+            ->get();
+
+        // 与 pollProductImageTasks 保持一致的类型名称映射
+        $typeNames = [
+            1 => 'roles',  // 角色
+            2 => 'scenes', // 场景
+            3 => 'props',  // 道具
+        ];
+
+        foreach ($tasks as $task) {
+            try {
+                $taskParams = json_decode((string)$task->params, true);
+                $script_id = is_array($taskParams) ? (int)($taskParams['script_id'] ?? 0) : 0;
+
+                if (!$script_id) {
+                    dLog('command')->warning('saveScriptProducts任务缺少script_id,跳过', [
+                        'task_center_id' => $task->id,
+                    ]);
+                    continue;
+                }
+
+                // 获取该剧本关联的全部资产ID
+                $productIds = DB::table('mp_script_product_mappings')
+                    ->where('script_id', $script_id)
+                    ->pluck('product_id')
+                    ->unique()
+                    ->toArray();
+
+                if (empty($productIds)) {
+                    // 剧本没有关联资产,不视为完成,等待下一次检查
+                    continue;
+                }
+
+                $products = DB::table('mp_products')
+                    ->whereIn('id', $productIds)
+                    ->where('is_deleted', 0)
+                    ->get();
+
+                $stats = [];
+                $allCompleted = true;
+
+                foreach ($products as $product) {
+                    $typeName = $typeNames[$product->product] ?? 'unknown';
+                    if (!isset($stats[$typeName])) {
+                        $stats[$typeName] = ['total' => 0, 'success' => 0, 'completed' => 0];
+                    }
+                    $stats[$typeName]['total']++;
+
+                    if ($product->pic_task_status === '生成成功') {
+                        $stats[$typeName]['success']++;
+                        $stats[$typeName]['completed']++;
+                    } elseif ($product->pic_task_status === '生成失败') {
+                        $stats[$typeName]['completed']++;
+                    } else {
+                        // 仍有资产在生成中或未生成,任务未完成
+                        $allCompleted = false;
+                    }
+                }
+
+                if (!$allCompleted) {
+                    continue;
+                }
+
+                // 全部完成,更新任务中心状态和结果(与接口返回结果保持一致)
+                $script = DB::table('mp_scripts')
+                    ->where('id', $script_id)
+                    ->where('is_deleted', 0)
+                    ->first();
+                $script_name = $script->script_name ?? 'script_' . $script_id;
+
+                $this->finishScriptProductTask($task->id, [
+                    'type'           => 'complete',
+                    'script_id'      => $script_id,
+                    'script_name'    => $script_name,
+                    'stats'          => $stats,
+                    'timestamp'      => date('Y-m-d H:i:s'),
+                    'task_center_id' => (int)$task->id,
+                ]);
+                $updated++;
+
+                dLog('command')->info('saveScriptProducts任务已完成,更新任务中心', [
+                    'task_center_id' => $task->id,
+                    'script_id'      => $script_id,
+                    'stats'          => $stats,
+                ]);
+            } catch (\Exception $e) {
+                dLog('command')->error('检查saveScriptProducts任务中心状态异常', [
+                    'task_center_id' => $task->id,
+                    'error'          => $e->getMessage(),
+                ]);
+            }
+        }
+
+        return $updated;
+    }
+
+    /**
      * 获取剧本关联的资产列表
      * @param array $data 请求参数
      * @return array

+ 214 - 1
app/Services/DeepSeek/DeepSeekService.php

@@ -9,6 +9,7 @@ use App\Libs\Utils;
 use GuzzleHttp\Client;
 use DateTime;
 use DateTimeZone;
+use App\Models\MpTaskCenter;
 use App\Models\MpUserPointsDetail;
 use App\Services\AIGeneration\AIImageGenerationService;
 use App\Services\PointsService;
@@ -11181,6 +11182,16 @@ Q版卡通风格,头大身小,造型圆润可爱,线条简单,色彩明
                 Utils::throwError('20003:创建批量生成任务失败');
             }
         }
+
+        // 创建/复用任务中心记录(按入参+type验重,避免重复任务)
+        $taskCenterId = $this->createBatchGenerateEpisodesTaskCenter(
+            $uid,
+            $cpid,
+            $anime_id,
+            $generate_episode_number,
+            $startEpisodeNumber,
+            $endEpisodeNumber
+        );
         
         return [
             'anime_id' => $anime_id,
@@ -11191,11 +11202,213 @@ Q版卡通风格,头大身小,造型圆润可爱,线条简单,色彩明
             'skipped_episodes' => $skippedTasks,
             'message' => "已创建批量生成任务,将生成第{$startEpisodeNumber}集到第{$endEpisodeNumber}集,共{$generate_episode_number}集。" . 
                          "已创建: " . implode(',', $createdTasks) . 
-                         (!empty($skippedTasks) ? ";已跳过: " . implode(',', $skippedTasks) : "")
+                         (!empty($skippedTasks) ? ";已跳过: " . implode(',', $skippedTasks) : ""),
+            'task_center_id' => $taskCenterId
         ];
     }
 
     /**
+     * 根据入参创建或复用批量生成分集的任务中心记录
+     *
+     * 以入参(anime_id、generate_episode_number)计算 md5 存入 params_md5 字段用于验重:
+     * 同一用户/企业下,相同入参且任务未结束(pending/processing)时复用已有任务,
+     * 避免生成重复任务;唯一索引 (uid, cpid, task_type, params_md5) 兜底并发重复。
+     *
+     * @param int|string $uid
+     * @param int|string $cpid
+     * @param int|string $anime_id
+     * @param int        $generate_episode_number
+     * @param int        $startEpisodeNumber
+     * @param int        $endEpisodeNumber
+     * @return int 任务中心ID
+     */
+    private function createBatchGenerateEpisodesTaskCenter($uid, $cpid, $anime_id, $generate_episode_number, $startEpisodeNumber, $endEpisodeNumber) {
+        $params = [
+            'anime_id'                => (int)$anime_id,
+            'generate_episode_number' => (int)$generate_episode_number,
+            'start_episode'           => (int)$startEpisodeNumber,
+            'end_episode'             => (int)$endEpisodeNumber,
+        ];
+
+        // 验重键:入参(anime_id + generate_episode_number)+ task_type
+        $paramsMd5 = md5(json_encode([
+            'anime_id'                => (int)$anime_id,
+            'generate_episode_number' => (int)$generate_episode_number,
+        ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
+
+        // 验重:相同 md5 且任务未结束(pending/processing)时复用已有任务中心记录
+        $existing = DB::table('mp_task_center')
+            ->where('uid', (int)$uid)
+            ->where('cpid', (int)$cpid)
+            ->where('task_type', MpTaskCenter::TYPE_BATCH_GENERATE_EPISODES)
+            ->whereIn('status', [MpTaskCenter::STATUS_PENDING, MpTaskCenter::STATUS_PROCESSING])
+            ->where('params_md5', $paramsMd5)
+            ->orderBy('id', 'desc')
+            ->first();
+
+        if ($existing) {
+            dLog('deepseek')->info('复用批量生成分集任务中心记录,避免重复任务', [
+                'anime_id'       => $anime_id,
+                'task_center_id' => $existing->id,
+                'params_md5'     => $paramsMd5,
+            ]);
+
+            return (int)$existing->id;
+        }
+
+        $now = date('Y-m-d H:i:s');
+        DB::table('mp_task_center')->insertOrIgnore([
+            'uid'           => (int)$uid,
+            'cpid'          => (int)$cpid,
+            'task_type'     => MpTaskCenter::TYPE_BATCH_GENERATE_EPISODES,
+            'title'         => '批量生成分集',
+            'ref_task_id'   => 0,
+            'status'        => MpTaskCenter::STATUS_PROCESSING,
+            'result'        => null,
+            'error_message' => null,
+            'prompt'        => null,
+            'params'        => json_encode($params + ['params_md5' => $paramsMd5], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
+            'params_md5'    => $paramsMd5,
+            'created_at'    => $now,
+            'updated_at'    => $now,
+        ]);
+
+        // 获取任务中心ID(insertOrIgnore 冲突时复用并发插入的已有记录)
+        $taskId = (int)DB::table('mp_task_center')
+            ->where('uid', (int)$uid)
+            ->where('cpid', (int)$cpid)
+            ->where('task_type', MpTaskCenter::TYPE_BATCH_GENERATE_EPISODES)
+            ->where('params_md5', $paramsMd5)
+            ->orderBy('id', 'desc')
+            ->value('id');
+
+        dLog('deepseek')->info('创建批量生成分集任务中心记录', [
+            'anime_id'       => $anime_id,
+            'task_center_id' => $taskId,
+            'params_md5'     => $paramsMd5,
+        ]);
+
+        return $taskId;
+    }
+
+    /**
+     * 批量生成分集全部结束后,更新任务中心记录的状态和结果
+     *
+     * 由批量生成 worker 子进程在退出前调用。分集详情任务的状态会在多处更新
+     * (completed/failed/pending/processing),这里只在所有详情任务均已结束时
+     * 统一汇总更新,避免在每个更新点重复处理。
+     *
+     * @param int|string $animeId
+     * @return int 更新的任务中心记录数
+     */
+    public function finishBatchGenerateEpisodesTaskCenters($animeId) {
+        $animeId = (int)$animeId;
+        $updated = 0;
+
+        // 该 anime 是否还有未结束的详情任务
+        $hasUnfinished = DB::table('mp_batch_episode_generation_details')
+            ->where('anime_id', $animeId)
+            ->whereIn('status', ['pending', 'processing'])
+            ->exists();
+
+        if ($hasUnfinished) {
+            return 0;
+        }
+
+        // 扫描该 anime 相关的未完成任务中心记录
+        $tasks = DB::table('mp_task_center')
+            ->where('task_type', MpTaskCenter::TYPE_BATCH_GENERATE_EPISODES)
+            ->whereIn('status', [MpTaskCenter::STATUS_PENDING, MpTaskCenter::STATUS_PROCESSING])
+            ->orderBy('id', 'desc')
+            ->limit(500)
+            ->get();
+
+        foreach ($tasks as $task) {
+            try {
+                $taskParams = json_decode((string)$task->params, true);
+                if (!is_array($taskParams) || (int)($taskParams['anime_id'] ?? 0) !== $animeId) {
+                    continue;
+                }
+
+                $startEpisode = (int)($taskParams['start_episode'] ?? 0);
+                $endEpisode = (int)($taskParams['end_episode'] ?? 0);
+                if ($startEpisode < 1 || $endEpisode < $startEpisode) {
+                    continue;
+                }
+
+                $details = DB::table('mp_batch_episode_generation_details')
+                    ->where('anime_id', $animeId)
+                    ->whereBetween('episode_number', [$startEpisode, $endEpisode])
+                    ->get();
+
+                // 该批次范围内存在缺失记录,等待下一次检查
+                if ($details->isEmpty() || $details->count() < ($endEpisode - $startEpisode + 1)) {
+                    continue;
+                }
+
+                $completedEpisodes = [];
+                $failedEpisodes = [];
+                $allFinished = true;
+
+                foreach ($details as $detail) {
+                    if ($detail->status === 'completed') {
+                        $completedEpisodes[] = $detail->episode_number;
+                    } elseif ($detail->status === 'failed') {
+                        $failedEpisodes[] = [
+                            'episode_number' => $detail->episode_number,
+                            'error_message'  => $detail->error_message ?? '',
+                        ];
+                    } else {
+                        // pending/processing(防御处理,正常不会走到)
+                        $allFinished = false;
+                        break;
+                    }
+                }
+
+                if (!$allFinished) {
+                    continue;
+                }
+
+                $result = json_encode([
+                    'anime_id'           => $animeId,
+                    'start_episode'      => $startEpisode,
+                    'end_episode'        => $endEpisode,
+                    'total_episodes'     => count($details),
+                    'completed_count'    => count($completedEpisodes),
+                    'failed_count'       => count($failedEpisodes),
+                    'completed_episodes' => $completedEpisodes,
+                    'failed_episodes'    => $failedEpisodes,
+                ], JSON_UNESCAPED_UNICODE);
+
+                DB::table('mp_task_center')
+                    ->where('id', $task->id)
+                    ->update([
+                        'status'        => !empty($failedEpisodes) ? MpTaskCenter::STATUS_FAILED : MpTaskCenter::STATUS_SUCCESS,
+                        'result'        => $result,
+                        'error_message' => !empty($failedEpisodes) ? ('有 ' . count($failedEpisodes) . ' 集生成失败') : null,
+                        'updated_at'    => date('Y-m-d H:i:s'),
+                    ]);
+                $updated++;
+
+                dLog('command')->info('批量生成分集任务已完成,更新任务中心', [
+                    'task_center_id' => $task->id,
+                    'anime_id'       => $animeId,
+                    'start_episode'  => $startEpisode,
+                    'end_episode'    => $endEpisode,
+                ]);
+            } catch (\Exception $e) {
+                dLog('command')->error('更新批量生成分集任务中心异常', [
+                    'task_center_id' => $task->id,
+                    'anime_id'       => $animeId,
+                    'error'          => $e->getMessage(),
+                ]);
+            }
+        }
+
+        return $updated;
+    }
+
+    /**
      * 获取批量生成任务状态
      * 
      * @param array $data 请求数据

+ 2 - 1
app/Services/TaskCenterService.php

@@ -28,7 +28,7 @@ class TaskCenterService
 
         return MpTaskCenter::create([
             'uid'          => $data['uid'] ?? $uid,
-            '$cpid'          => $data['cpid'] ?? $cpid,
+            'cpid'           => $data['cpid'] ?? $cpid,
             'task_type'    => $type,
             'title'        => $data['title'] ?? '',
             'ref_task_id'  => $data['ref_task_id'] ?? 0,
@@ -37,6 +37,7 @@ class TaskCenterService
             'error_message'=> $data['error_message'] ?? null,
             'prompt'       => $data['prompt'] ?? null,
             'params'       => $data['params'] ?? null,
+            'params_md5'   => $data['params_md5'] ?? null,
         ]);
     }