Selaa lähdekoodia

新增批量生成动漫剧集功能

lh 13 tuntia sitten
vanhempi
commit
ee897c4e5d

+ 227 - 0
app/Console/Commands/ProcessBatchEpisodeGenerationCommand.php

@@ -0,0 +1,227 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\DB;
+use App\Services\DeepSeek\DeepSeekService;
+use App\Facade\Site;
+
+/**
+ * 批量生成分集定时任务
+ * 每次执行处理一个待生成的剧集
+ */
+class ProcessBatchEpisodeGenerationCommand extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'batch:generate-episodes';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = '批量生成分集定时任务,每次处理一个待生成的剧集';
+
+    protected $deepSeekService;
+
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct(DeepSeekService $deepSeekService)
+    {
+        parent::__construct();
+        $this->deepSeekService = $deepSeekService;
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * @return int
+     */
+    public function handle()
+    {
+        dLog('command')->info('开始执行批量生成分集任务...');
+
+        $anime_tasks = DB::table('mp_batch_episode_generation_details')->where('status', 'pending')->pluck('anime_id')->toArray();
+        foreach ($anime_tasks as $anime_id) {
+            dLog('command')->info("~~~~~~开始执行($anime_id)任务~~~~~~");
+            try {
+                // 查找状态为 pending 的任务,按 anime_id 和 episode_number 排序
+                $task = DB::table('mp_batch_episode_generation_details')
+                    ->where('status', 'pending')
+                    ->orderBy('anime_id')
+                    ->orderBy('episode_number')
+                    ->first();
+
+                if (!$task) {
+                    dLog('command')->info('[' . date('Y-m-d H:i:s') . '] 没有待处理的任务');
+                    return 0;
+                }
+
+                dLog('command')->info('[' . date('Y-m-d H:i:s') . '] 找到待处理任务 - Anime ID: ' . $task->anime_id . ', Episode: ' . $task->episode_number);
+
+                // 检查前一集是否已完成(如果不是第1集)
+                if ($task->episode_number > 1) {
+                    $prevEpisodeNumber = $task->episode_number - 1;
+                    
+                    // 检查前一集是否在批量任务中
+                    $prevTaskInBatch = DB::table('mp_batch_episode_generation_details')
+                        ->where('anime_id', $task->anime_id)
+                        ->where('episode_number', $prevEpisodeNumber)
+                        ->first();
+                    
+                    if ($prevTaskInBatch && $prevTaskInBatch->status !== 'completed') {
+                        dLog('command')->info('[' . date('Y-m-d H:i:s') . '] 前一集(第' . $prevEpisodeNumber . '集)尚未完成,跳过当前任务');
+                        return 0;
+                    }
+                    
+                    // 检查前一集是否已在系统中生成(查询 mp_anime_episodes)
+                    $prevEpisodeExists = DB::table('mp_anime_episodes')
+                        ->where('anime_id', $task->anime_id)
+                        ->where('episode_number', $prevEpisodeNumber)
+                        ->where('is_default', 1)
+                        ->exists();
+                    
+                    if (!$prevEpisodeExists) {
+                        dLog('command')->error('[' . date('Y-m-d H:i:s') . '] 前一集(第' . $prevEpisodeNumber . '集)不存在,无法继续生成');
+                        
+                        // 标记为失败
+                        DB::table('mp_batch_episode_generation_details')
+                            ->where('id', $task->id)
+                            ->update([
+                                'status' => 'failed',
+                                'error_message' => "前一集(第{$prevEpisodeNumber}集)不存在,无法继续生成",
+                                'updated_at' => now()
+                            ]);
+                        
+                        return 1;
+                    }
+                }
+
+                // 检查当前集是否已经存在(避免重复生成)
+                $currentEpisodeExists = DB::table('mp_anime_episodes')
+                    ->where('anime_id', $task->anime_id)
+                    ->where('episode_number', $task->episode_number)
+                    ->where('is_default', 1)
+                    ->exists();
+                
+                if ($currentEpisodeExists) {
+                    dLog('command')->info('[' . date('Y-m-d H:i:s') . '] 第' . $task->episode_number . '集已存在,跳过生成');
+                    
+                    // 标记为已完成
+                    DB::table('mp_batch_episode_generation_details')
+                        ->where('id', $task->id)
+                        ->update([
+                            'status' => 'completed',
+                            'completed_at' => now(),
+                            'updated_at' => now()
+                        ]);
+                    
+                    return 0;
+                }
+
+                // 标记为处理中
+                DB::table('mp_batch_episode_generation_details')
+                    ->where('id', $task->id)
+                    ->update([
+                        'status' => 'processing',
+                        'updated_at' => now()
+                    ]);
+
+                // 设置用户上下文(绑定到容器)
+                app()->instance('siteData', [
+                    'uid' => $task->uid,
+                    'cpid' => $task->cpid
+                ]);
+
+                // 解析请求数据
+                $requestData = json_decode($task->request_data, true);
+                $requestData['episode_number'] = $task->episode_number;
+                
+                // 使用"继续策划下一集"逻辑
+                $requestData['prompt'] = '继续策划下一集';
+
+                dLog('command')->info('[' . date('Y-m-d H:i:s') . '] 开始生成第' . $task->episode_number . '集...');
+
+                // 调用非流式生成方法
+                $result = $this->deepSeekService->chatForAceNonStream($requestData);
+
+                // 检查是否有错误
+                if (isset($result['error']) && $result['error']) {
+                    dLog('command')->error('[' . date('Y-m-d H:i:s') . '] 生成失败: ' . $result['error']);
+                    
+                    // 更新重试次数
+                    $retryCount = $task->retry_count + 1;
+                    
+                    // 标记为失败
+                    DB::table('mp_batch_episode_generation_details')
+                        ->where('id', $task->id)
+                        ->update([
+                            'status' => 'pending',
+                            'error_message' => $result['error'],
+                            'retry_count' => $retryCount,
+                            'updated_at' => now()
+                        ]);
+
+                    // 记录错误日志
+                    logDB('batch_episode_generation', 'error', "第{$task->episode_number}集生成失败", [
+                        'anime_id' => $task->anime_id,
+                        'episode_number' => $task->episode_number,
+                        'error' => $result['error']
+                    ]);
+                    
+                    return 1;
+                }
+
+                // 标记为完成
+                DB::table('mp_batch_episode_generation_details')
+                    ->where('id', $task->id)
+                    ->update([
+                        'status' => 'completed',
+                        'result_data' => json_encode($result, JSON_UNESCAPED_UNICODE),
+                        'completed_at' => now(),
+                        'updated_at' => now()
+                    ]);
+
+                dLog('command')->info('[' . date('Y-m-d H:i:s') . '] 第' . $task->episode_number . '集生成成功');
+
+                return 0;
+
+            } catch (\Exception $e) {
+                dLog('command')->error('[' . date('Y-m-d H:i:s') . '] 生成失败: ' . $e->getMessage());
+
+                if (isset($task)) {
+                    // 更新重试次数
+                    $retryCount = $task->retry_count + 1;
+                    
+                    // 标记为失败
+                    DB::table('mp_batch_episode_generation_details')
+                        ->where('id', $task->id)
+                        ->update([
+                            'status' => 'pending',
+                            'error_message' => $e->getMessage(),
+                            'retry_count' => $retryCount,
+                            'updated_at' => now()
+                        ]);
+
+                    // 记录错误日志
+                    logDB('batch_episode_generation', 'error', "第{$task->episode_number}集生成失败", [
+                        'anime_id' => $task->anime_id,
+                        'episode_number' => $task->episode_number,
+                        'error' => $e->getMessage(),
+                        'trace' => $e->getTraceAsString()
+                    ]);
+                }
+
+                return 1;
+            }
+        }
+    }
+}

+ 4 - 0
app/Console/Kernel.php

@@ -15,6 +15,7 @@ class Kernel extends ConsoleKernel
         RegisterUserCommand::class,
         AnimeCheckImgUrlCommand::class,
         Commands\ProcessImageGenerationQueueCommand::class,
+        Commands\ProcessBatchEpisodeGenerationCommand::class,
         SegmentCheckAudioCommand::class
     ];
 
@@ -41,6 +42,9 @@ class Kernel extends ConsoleKernel
         
         // 处理图片生成任务队列
         $schedule->command('image-generation:process-queue')->everyMinute();
+        
+        // 批量生成分集任务(每分钟执行一次,每次处理一个待生成的剧集)
+        $schedule->command('batch:generate-episodes')->everyMinute();
     }
 
     /**

+ 0 - 196
app/Jobs/ProcessBatchEpisodeGenerationJob.php

@@ -1,196 +0,0 @@
-<?php
-
-namespace App\Jobs;
-
-use Illuminate\Bus\Queueable;
-use Illuminate\Contracts\Queue\ShouldQueue;
-use Illuminate\Foundation\Bus\Dispatchable;
-use Illuminate\Queue\InteractsWithQueue;
-use Illuminate\Queue\SerializesModels;
-use Illuminate\Support\Facades\DB;
-use App\Services\DeepSeek\DeepSeekService;
-use App\Facade\Site;
-
-/**
- * 批量生成分集任务队列
- */
-class ProcessBatchEpisodeGenerationJob implements ShouldQueue
-{
-    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
-
-    protected $taskId;
-    protected $animeId;
-    protected $startEpisodeNumber;
-    protected $totalEpisodes;
-    protected $requestData;
-    protected $uid;
-    protected $cpid;
-
-    /**
-     * 任务最大尝试次数
-     */
-    public $tries = 3;
-
-    /**
-     * 任务超时时间(秒)
-     */
-    public $timeout = 1800;
-
-    /**
-     * Create a new job instance.
-     *
-     * @param int $taskId 批量任务ID
-     * @param int $animeId 动漫ID
-     * @param int $startEpisodeNumber 起始集数
-     * @param int $totalEpisodes 总共生成集数
-     * @param array $requestData 请求数据
-     * @param int $uid 用户ID
-     * @param int $cpid 公司ID
-     */
-    public function __construct($taskId, $animeId, $startEpisodeNumber, $totalEpisodes, $requestData, $uid, $cpid)
-    {
-        $this->taskId = $taskId;
-        $this->animeId = $animeId;
-        $this->startEpisodeNumber = $startEpisodeNumber;
-        $this->totalEpisodes = $totalEpisodes;
-        $this->requestData = $requestData;
-        $this->uid = $uid;
-        $this->cpid = $cpid;
-    }
-
-    /**
-     * Execute the job.
-     *
-     * @return void
-     */
-    public function handle()
-    {
-        try {
-            // 设置当前用户上下文
-            Site::setUid($this->uid);
-            Site::setCpid($this->cpid);
-
-            $deepSeekService = app(DeepSeekService::class);
-
-            // 逐集生成
-            for ($i = 0; $i < $this->totalEpisodes; $i++) {
-                $currentEpisodeNumber = $this->startEpisodeNumber + $i;
-
-                // 更新当前任务进度
-                $this->updateTaskProgress($currentEpisodeNumber, 'processing');
-
-                try {
-                    // 准备当前集的数据
-                    $episodeData = $this->requestData;
-                    $episodeData['episode_number'] = $currentEpisodeNumber;
-                    $episodeData['prompt'] = '确认分镜大纲';
-
-                    // 调用非流式生成方法
-                    $result = $deepSeekService->chatForAceNonStream($episodeData);
-
-                    // 标记当前集完成
-                    $this->updateTaskProgress($currentEpisodeNumber, 'completed', $result);
-
-                } catch (\Exception $e) {
-                    // 标记当前集失败
-                    $this->updateTaskProgress($currentEpisodeNumber, 'failed', null, $e->getMessage());
-                    
-                    // 记录错误日志
-                    logDB('batch_episode_generation', 'error', "第{$currentEpisodeNumber}集生成失败", [
-                        'task_id' => $this->taskId,
-                        'anime_id' => $this->animeId,
-                        'episode_number' => $currentEpisodeNumber,
-                        'error' => $e->getMessage(),
-                        'trace' => $e->getTraceAsString()
-                    ]);
-
-                    // 继续生成下一集(可选:根据需求决定是否中断整个任务)
-                    // throw $e; // 如果要中断整个任务,取消注释这行
-                }
-            }
-
-            // 标记整个任务完成
-            DB::table('mp_batch_episode_generation_tasks')
-                ->where('id', $this->taskId)
-                ->update([
-                    'status' => 'completed',
-                    'completed_at' => now(),
-                    'updated_at' => now()
-                ]);
-
-        } catch (\Exception $e) {
-            // 标记整个任务失败
-            DB::table('mp_batch_episode_generation_tasks')
-                ->where('id', $this->taskId)
-                ->update([
-                    'status' => 'failed',
-                    'error_message' => $e->getMessage(),
-                    'updated_at' => now()
-                ]);
-
-            // 记录错误日志
-            logDB('batch_episode_generation', 'error', '批量生成任务失败', [
-                'task_id' => $this->taskId,
-                'anime_id' => $this->animeId,
-                'error' => $e->getMessage(),
-                'trace' => $e->getTraceAsString()
-            ]);
-
-            throw $e;
-        }
-    }
-
-    /**
-     * 更新任务进度
-     */
-    private function updateTaskProgress($episodeNumber, $status, $result = null, $errorMessage = null)
-    {
-        $updateData = [
-            'current_episode' => $episodeNumber,
-            'updated_at' => now()
-        ];
-
-        if ($status === 'completed') {
-            $updateData['completed_episodes'] = DB::raw('completed_episodes + 1');
-        }
-
-        if ($errorMessage) {
-            $updateData['error_message'] = $errorMessage;
-        }
-
-        DB::table('mp_batch_episode_generation_tasks')
-            ->where('id', $this->taskId)
-            ->update($updateData);
-
-        // 保存每集的详细状态
-        DB::table('mp_batch_episode_generation_details')->insert([
-            'task_id' => $this->taskId,
-            'episode_number' => $episodeNumber,
-            'status' => $status,
-            'result_data' => $result ? json_encode($result, JSON_UNESCAPED_UNICODE) : null,
-            'error_message' => $errorMessage,
-            'created_at' => now(),
-            'updated_at' => now()
-        ]);
-    }
-
-    /**
-     * 任务失败处理
-     */
-    public function failed(\Throwable $exception)
-    {
-        DB::table('mp_batch_episode_generation_tasks')
-            ->where('id', $this->taskId)
-            ->update([
-                'status' => 'failed',
-                'error_message' => $exception->getMessage(),
-                'updated_at' => now()
-            ]);
-
-        logDB('batch_episode_generation', 'error', '批量生成任务彻底失败', [
-            'task_id' => $this->taskId,
-            'anime_id' => $this->animeId,
-            'error' => $exception->getMessage()
-        ]);
-    }
-}

+ 1 - 1
app/Services/Anime/AnimeService.php

@@ -544,7 +544,7 @@ class AnimeService
         $uid = Site::getUid();
         $anime_id = getProp($data, 'anime_id');
         $anime = DB::table('mp_animes')->where('id', $anime_id)->where('is_deleted', 0)->where('user_id', $uid)
-        ->selectRaw('id as anime_id, anime_name, intro, highlights, roles, role_relationship, core_contradiction, art_style, scenes, status, remark, generate_status, created_at, ace_mode')->first();
+        ->selectRaw('id as anime_id, anime_name, intro, highlights, roles, role_relationship, core_contradiction, art_style, scenes, status, remark, generate_status, created_at, ace_mode, end_episode_sequence')->first();
         if (!$anime) Utils::throwError('20003:权限不足');
         $anime = (array)$anime;
         $anime['roles'] = json_decode($anime['roles']);

+ 177 - 115
app/Services/DeepSeek/DeepSeekService.php

@@ -7862,7 +7862,7 @@ Q版卡通风格,头大身小,造型圆润可爱,线条简单,色彩明
         }
 
         // 获取最后一集序号
-        $end_episode_sequence = getProp($anime, 'end_episode_sequence');
+        $end_episode_sequence = getProp($anime, 'end_episode_sequence', 0);
         if ($end_episode_sequence && $episode_number > $end_episode_sequence) Utils::throwError("20003:总集数只有{$end_episode_sequence}集,不允许继续策划下一集!");
 
         // 转换主体列表和场景列表的格式
@@ -8441,16 +8441,15 @@ Q版卡通风格,头大身小,造型圆润可爱,线条简单,色彩明
         $model = getProp($data, 'model');
         if (!$model) {
             // 用户没有输入,从anime表获取
-            $anime = DB::table('mp_animes')->where('id', $anime_id)->first();
             $model = getProp($anime, 'model');
             if (!$model) {
                 // anime表也没有,使用默认值
-                $model = 'doubao-seed-2-0-mini-260215';
+                $model = 'deepseek-v4-pro';
             }
         }
         // 验证模型是否在可用模型表中
         if (!DB::table('mp_text_models')->where('model', $model)->where('is_enabled', 1)->exists()) {
-            $model = 'doubao-seed-2-0-mini-260215';
+            $model = 'deepseek-v4-pro';
         }
 
         // 模型兼容性处理:deepseek-chat 和 deepseek-reasoner 映射到 deepseek-v4-flash
@@ -8906,6 +8905,9 @@ Q版卡通风格,头大身小,造型圆润可爱,线条简单,色彩明
         $episode['episode_id'] = $episode_id;
         $episode['roles'] = $merged_roles;
         $episode['scenes'] = $merged_scenes;
+        $episode['props'] = $merged_props;
+        $episode['end_episode_sequence'] = $end_episode_sequence;
+        
         // $episode['acts'] = isset($episode_arr['acts']) && is_array($episode_arr['acts']) ? $episode_arr['acts'] : [];
         yield [
             'type' => 'done',
@@ -8917,33 +8919,69 @@ Q版卡通风格,头大身小,造型圆润可爱,线条简单,色彩明
     }
 
     /**
-     * chatForAce的非流式版本 - 用于队列任务
-     * 与chatForAce逻辑完全一致,但不使用流式输出
+     * chatForAce的非流式版本 - 用于定时任务
+     * 只获取最终 type=done 的数据,并验证必要字段
      * 
      * @param array $data 请求数据
      * @return array 生成结果
      */
     public function chatForAceNonStream($data) {
-        $allChunks = [];
         $finalResult = null;
         
-        // 调用流式方法并收集所有数据
+        // 调用流式方法,只保存 type=done 的数据
         foreach ($this->chatForAce($data) as $chunk) {
-            $allChunks[] = $chunk;
-            
-            // 保存最终结果
+            // 只关注最终结果
             if (isset($chunk['type']) && $chunk['type'] === 'done') {
                 $finalResult = $chunk;
+                break; // 找到 done 就退出循环
             }
         }
         
-        // 返回最终结果
-        if ($finalResult) {
+        // 验证最终结果
+        if (!$finalResult) {
+            return ['error' => '生成失败:未获取到最终结果'];
+        }
+        
+        // 检查是否有错误
+        if (isset($finalResult['error']) && $finalResult['error']) {
             return $finalResult;
         }
         
-        // 如果没有done类型的数据,返回最后一个chunk
-        return end($allChunks) ?: ['error' => '生成失败'];
+        // 验证必要字段
+        $episode = $finalResult['episode'] ?? [];
+        
+        // 验证 roles
+        if (empty($episode['roles']) || !is_array($episode['roles'])) {
+            return ['error' => '生成失败:主体列表(roles)为空或格式错误'];
+        }
+        
+        // 验证 scenes
+        if (empty($episode['scenes']) || !is_array($episode['scenes'])) {
+            return ['error' => '生成失败:场景列表(scenes)为空或格式错误'];
+        }
+        
+        // 验证 props(道具可以为空,但必须存在且为数组)
+        if (!isset($episode['props']) || !is_array($episode['props'])) {
+            $episode['props'] = [];
+        }
+        
+        // 验证 acts(分镜剧本)
+        if (empty($episode['acts']) || !is_array($episode['acts'])) {
+            return ['error' => '生成失败:分镜剧本(acts)为空或格式错误'];
+        }
+        
+        // 更新验证后的 episode 数据
+        $finalResult['episode'] = $episode;
+        
+        // 记录验证成功日志
+        dLog('deepseek')->info('chatForAceNonStream 验证通过', [
+            'roles_count' => count($episode['roles']),
+            'scenes_count' => count($episode['scenes']),
+            'props_count' => count($episode['props']),
+            'acts_count' => count($episode['acts'])
+        ]);
+        
+        return $finalResult;
     }
 
     /**
@@ -8972,6 +9010,11 @@ Q版卡通风格,头大身小,造型圆润可爱,线条简单,色彩明
             Utils::throwError('20003:该剧集不存在');
         }
         
+        // 检查是否是全能模式
+        if ((int)getProp($anime, 'ace_mode') !== 1) {
+            Utils::throwError('20003:只有全能模式才支持批量生成');
+        }
+        
         // 检查是否是多剧集模式
         if ((int)getProp($anime, 'is_multi') !== 1) {
             Utils::throwError('20003:单剧集不支持批量生成');
@@ -8988,15 +9031,6 @@ Q版卡通风格,头大身小,造型圆润可爱,线条简单,色彩明
         // 确定起始集数
         $startEpisodeNumber = $currentMaxEpisode + 1;
         
-        // 不支持指定起始集数(必须按现有集数循序渐进)
-        // // 如果用户指定了起始集数(通过episode_number参数)
-        // if (isset($data['episode_number'])) {
-        //     $userStartEpisode = (int)getProp($data, 'episode_number');
-        //     if ($userStartEpisode > $currentMaxEpisode) {
-        //         $startEpisodeNumber = $userStartEpisode;
-        //     }
-        // }
-        
         // 计算结束集数
         $endEpisodeNumber = $startEpisodeNumber + $generate_episode_number - 1;
         
@@ -9006,68 +9040,78 @@ Q版卡通风格,头大身小,造型圆润可爱,线条简单,色彩明
             Utils::throwError("20003:总集数只有{$end_episode_sequence}集,无法生成到第{$endEpisodeNumber}集");
         }
         
-        // 检查是否已有进行中的任务
-        $existingTask = DB::table('mp_batch_episode_generation_tasks')
-            ->where('anime_id', $anime_id)
-            ->whereIn('status', ['pending', 'processing'])
-            ->first();
-        
-        if ($existingTask) {
-            Utils::throwError('20003:该剧集已有批量生成任务正在进行中,请等待完成后再试');
-        }
-        
         $now = date('Y-m-d H:i:s');
+        $createdTasks = [];
+        $skippedTasks = [];
         
-        // 准备任务数据
-        $taskData = [
-            'anime_id' => $anime_id,
-            'uid' => $uid,
-            'cpid' => $cpid,
-            'start_episode' => $startEpisodeNumber,
-            'end_episode' => $endEpisodeNumber,
-            'total_episodes' => $generate_episode_number,
-            'current_episode' => 0,
-            'completed_episodes' => 0,
-            'status' => 'pending',
-            'request_data' => json_encode($data, JSON_UNESCAPED_UNICODE),
-            'created_at' => $now,
-            'updated_at' => $now
-        ];
-        
-        // 创建批量生成任务记录
-        $taskId = DB::table('mp_batch_episode_generation_tasks')->insertGetId($taskData);
-        
-        if (!$taskId) {
-            Utils::throwError('20003:创建批量生成任务失败');
-        }
-        
-        // 更新任务状态为processing
-        DB::table('mp_batch_episode_generation_tasks')
-            ->where('id', $taskId)
-            ->update([
-                'status' => 'processing',
+        // 为每一集创建详情记录
+        for ($episodeNumber = $startEpisodeNumber; $episodeNumber <= $endEpisodeNumber; $episodeNumber++) {
+            // 检查是否已有该集的任务记录
+            $existingTask = DB::table('mp_batch_episode_generation_details')
+                ->where('anime_id', $anime_id)
+                ->where('episode_number', $episodeNumber)
+                ->first();
+            
+            if ($existingTask) {
+                // 如果已存在且未完成,跳过
+                if (in_array($existingTask->status, ['pending', 'processing'])) {
+                    $skippedTasks[] = $episodeNumber;
+                    continue;
+                }
+                
+                // 如果是失败状态,可以重新创建(删除旧记录)
+                if ($existingTask->status === 'failed') {
+                    DB::table('mp_batch_episode_generation_details')
+                        ->where('id', $existingTask->id)
+                        ->delete();
+                }
+                
+                // 如果是已完成状态,也跳过
+                if ($existingTask->status === 'completed') {
+                    $skippedTasks[] = $episodeNumber;
+                    continue;
+                }
+            }
+            
+            // 准备任务数据
+            $taskData = [
+                'anime_id' => $anime_id,
+                'uid' => $uid,
+                'cpid' => $cpid,
+                'episode_number' => $episodeNumber,
+                'status' => 'pending',
+                'request_data' => json_encode($data, JSON_UNESCAPED_UNICODE),
+                'retry_count' => 0,
+                'created_at' => $now,
                 'updated_at' => $now
-            ]);
+            ];
+            
+            // 创建任务记录
+            $taskId = DB::table('mp_batch_episode_generation_details')->insertGetId($taskData);
+            
+            if ($taskId) {
+                $createdTasks[] = $episodeNumber;
+            }
+        }
         
-        // 分发到队列
-        \App\Jobs\ProcessBatchEpisodeGenerationJob::dispatch(
-            $taskId,
-            $anime_id,
-            $startEpisodeNumber,
-            $generate_episode_number,
-            $data,
-            $uid,
-            $cpid
-        );
+        if (empty($createdTasks)) {
+            if (!empty($skippedTasks)) {
+                Utils::throwError('20003:所有集数都已存在任务记录,无需重复创建。已跳过集数:' . implode(',', $skippedTasks));
+            } else {
+                Utils::throwError('20003:创建批量生成任务失败');
+            }
+        }
         
         return [
-            'task_id' => $taskId,
             'anime_id' => $anime_id,
             'start_episode' => $startEpisodeNumber,
             'end_episode' => $endEpisodeNumber,
             'total_episodes' => $generate_episode_number,
-            'status' => 'processing',
-            'message' => "已创建批量生成任务,将生成第{$startEpisodeNumber}集到第{$endEpisodeNumber}集,共{$generate_episode_number}集"
+            'created_episodes' => $createdTasks,
+            'skipped_episodes' => $skippedTasks,
+            'message' => "已创建批量生成任务,将生成第{$startEpisodeNumber}集到第{$endEpisodeNumber}集,共{$generate_episode_number}集。" . 
+                         "已创建: " . implode(',', $createdTasks) . 
+                         (!empty($skippedTasks) ? ";已跳过: " . implode(',', $skippedTasks) : "")
         ];
     }
 
@@ -9078,67 +9122,85 @@ Q版卡通风格,头大身小,造型圆润可爱,线条简单,色彩明
      * @return array 任务状态信息
      */
     public function getBatchGenerationTaskStatus($data) {
-        $task_id = getProp($data, 'task_id');
         $anime_id = getProp($data, 'anime_id');
         
-        if (!$task_id && !$anime_id) {
-            Utils::throwError('20003:task_id或anime_id参数缺失');
-        }
-        
-        $query = DB::table('mp_batch_episode_generation_tasks');
-        
-        if ($task_id) {
-            $query->where('id', $task_id);
-        } else {
-            // 获取该anime最新的任务
-            $query->where('anime_id', $anime_id)
-                  ->orderByDesc('id')
-                  ->limit(1);
-        }
-        
-        $task = $query->first();
-        
-        if (!$task) {
-            Utils::throwError('20003:任务不存在');
+        if (!$anime_id) {
+            Utils::throwError('20003:anime_id参数缺失');
         }
         
-        // 获取任务详情
-        $details = DB::table('mp_batch_episode_generation_details')
-            ->where('task_id', $task->id)
+        // 获取该anime的所有任务
+        $tasks = DB::table('mp_batch_episode_generation_details')
+            ->where('anime_id', $anime_id)
             ->orderBy('episode_number')
             ->get()
             ->map(function($item) {
                 return [
+                    'id' => $item->id,
                     'episode_number' => $item->episode_number,
                     'status' => $item->status,
                     'error_message' => $item->error_message,
+                    'retry_count' => $item->retry_count,
                     'created_at' => $item->created_at,
-                    'updated_at' => $item->updated_at
+                    'updated_at' => $item->updated_at,
+                    'completed_at' => $item->completed_at
                 ];
             })
             ->toArray();
         
+        if (empty($tasks)) {
+            Utils::throwError('20003:该剧集没有批量生成任务');
+        }
+        
+        // 统计各状态数量
+        $totalCount = count($tasks);
+        $pendingCount = 0;
+        $processingCount = 0;
+        $completedCount = 0;
+        $failedCount = 0;
+        
+        foreach ($tasks as $task) {
+            switch ($task['status']) {
+                case 'pending':
+                    $pendingCount++;
+                    break;
+                case 'processing':
+                    $processingCount++;
+                    break;
+                case 'completed':
+                    $completedCount++;
+                    break;
+                case 'failed':
+                    $failedCount++;
+                    break;
+            }
+        }
+        
         // 计算进度百分比
         $progress = 0;
-        if ($task->total_episodes > 0) {
-            $progress = round(($task->completed_episodes / $task->total_episodes) * 100, 2);
+        if ($totalCount > 0) {
+            $progress = round(($completedCount / $totalCount) * 100, 2);
+        }
+        
+        // 判断整体状态
+        $overallStatus = 'processing';
+        if ($completedCount === $totalCount) {
+            $overallStatus = 'completed';
+        } elseif ($pendingCount === $totalCount) {
+            $overallStatus = 'pending';
+        } elseif ($failedCount > 0 && ($pendingCount + $processingCount) === 0) {
+            $overallStatus = 'failed';
         }
         
         return [
-            'task_id' => $task->id,
-            'anime_id' => $task->anime_id,
-            'start_episode' => $task->start_episode,
-            'end_episode' => $task->end_episode,
-            'total_episodes' => $task->total_episodes,
-            'current_episode' => $task->current_episode,
-            'completed_episodes' => $task->completed_episodes,
+            'anime_id' => $anime_id,
+            'total_episodes' => $totalCount,
+            'pending_count' => $pendingCount,
+            'processing_count' => $processingCount,
+            'completed_count' => $completedCount,
+            'failed_count' => $failedCount,
             'progress' => $progress,
-            'status' => $task->status,
-            'error_message' => $task->error_message,
-            'details' => $details,
-            'created_at' => $task->created_at,
-            'updated_at' => $task->updated_at,
-            'completed_at' => $task->completed_at
+            'overall_status' => $overallStatus,
+            'tasks' => $tasks
         ];
     }
 

+ 8 - 23
database/migrations/2026_01_29_000001_create_batch_episode_generation_tables.php

@@ -13,40 +13,26 @@ class CreateBatchEpisodeGenerationTables extends Migration
      */
     public function up()
     {
-        // 批量生成分集任务主表
-        Schema::create('mp_batch_episode_generation_tasks', function (Blueprint $table) {
+        // 批量生成分集详情表(仅保留详情表)
+        Schema::create('mp_batch_episode_generation_details', function (Blueprint $table) {
             $table->id();
             $table->unsignedBigInteger('anime_id')->comment('动漫ID');
             $table->unsignedBigInteger('uid')->comment('用户ID');
             $table->unsignedBigInteger('cpid')->comment('公司ID');
-            $table->integer('start_episode')->comment('起始集数');
-            $table->integer('end_episode')->comment('结束集数');
-            $table->integer('total_episodes')->comment('总集数');
-            $table->integer('current_episode')->default(0)->comment('当前处理的集数');
-            $table->integer('completed_episodes')->default(0)->comment('已完成集数');
-            $table->string('status', 20)->default('pending')->comment('任务状态:pending-待处理, processing-处理中, completed-已完成, failed-失败');
+            $table->integer('episode_number')->comment('集数');
+            $table->string('status', 20)->default('pending')->comment('状态:pending-待处理, processing-处理中, completed-已完成, failed-失败');
             $table->text('request_data')->nullable()->comment('请求数据JSON');
+            $table->text('result_data')->nullable()->comment('结果数据JSON');
             $table->text('error_message')->nullable()->comment('错误信息');
+            $table->integer('retry_count')->default(0)->comment('重试次数');
             $table->timestamp('completed_at')->nullable()->comment('完成时间');
             $table->timestamps();
             
             $table->index('anime_id');
             $table->index('uid');
             $table->index('status');
-        });
-
-        // 批量生成分集详情表
-        Schema::create('mp_batch_episode_generation_details', function (Blueprint $table) {
-            $table->id();
-            $table->unsignedBigInteger('task_id')->comment('任务ID');
-            $table->integer('episode_number')->comment('集数');
-            $table->string('status', 20)->comment('状态:processing-处理中, completed-已完成, failed-失败');
-            $table->text('result_data')->nullable()->comment('结果数据JSON');
-            $table->text('error_message')->nullable()->comment('错误信息');
-            $table->timestamps();
-            
-            $table->index('task_id');
-            $table->index(['task_id', 'episode_number']);
+            $table->index(['anime_id', 'episode_number'], 'idx_anime_episode');
+            $table->unique(['anime_id', 'episode_number'], 'uk_anime_episode');
         });
     }
 
@@ -58,6 +44,5 @@ class CreateBatchEpisodeGenerationTables extends Migration
     public function down()
     {
         Schema::dropIfExists('mp_batch_episode_generation_details');
-        Schema::dropIfExists('mp_batch_episode_generation_tasks');
     }
 }

+ 0 - 375
docs/批量生成分集功能说明.md

@@ -1,376 +0,0 @@
-# 批量生成分集功能说明
-
-## 功能概述
-
-该功能允许用户批量生成多个剧集,支持从当前集数自动往后生成指定数量的剧集。使用队列机制实现异步处理,提升用户体验。
-
-## 核心特性
-
-1. **队列异步处理**: 任务放入队列异步执行,前端无需长时间等待
-2. **进度跟踪**: 提供实时任务状态查询接口
-3. **完全复用逻辑**: 批量生成完全使用 `chatForAce` 方法的逻辑,包括资产库应用、提示词等
-4. **错误容错**: 单集失败不影响其他集的生成
-5. **防重复执行**: 同一剧集同时只能有一个批量任务执行
-
-## 技术架构
-
-### 1. 数据库表结构
-
-#### mp_batch_episode_generation_tasks (批量生成任务主表)
-- `id`: 主键
-- `anime_id`: 动漫ID
-- `uid`: 用户ID
-- `cpid`: 公司ID
-- `start_episode`: 起始集数
-- `end_episode`: 结束集数
-- `total_episodes`: 总集数
-- `current_episode`: 当前处理的集数
-- `completed_episodes`: 已完成集数
-- `status`: 任务状态 (pending/processing/completed/failed)
-- `request_data`: 请求数据JSON
-- `error_message`: 错误信息
-- `completed_at`: 完成时间
-- `created_at`: 创建时间
-- `updated_at`: 更新时间
-
-#### mp_batch_episode_generation_details (批量生成详情表)
-- `id`: 主键
-- `task_id`: 任务ID
-- `episode_number`: 集数
-- `status`: 状态 (processing/completed/failed)
-- `result_data`: 结果数据JSON
-- `error_message`: 错误信息
-- `created_at`: 创建时间
-- `updated_at`: 更新时间
-
-### 2. 核心类文件
-
-#### ProcessBatchEpisodeGenerationJob (队列任务类)
-**位置**: `app/Jobs/ProcessBatchEpisodeGenerationJob.php`
-
-**职责**:
-- 处理批量生成任务
-- 逐集调用 `chatForAceNonStream` 方法
-- 更新任务进度
-- 记录每集的生成结果
-
-**关键参数**:
-- `$taskId`: 批量任务ID
-- `$animeId`: 动漫ID
-- `$startEpisodeNumber`: 起始集数
-- `$totalEpisodes`: 总共生成集数
-- `$requestData`: 请求数据
-- `$uid`: 用户ID
-- `$cpid`: 公司ID
-
-#### DeepSeekService 新增方法
-
-##### chatForAceNonStream
-**功能**: `chatForAce` 的非流式版本,用于队列任务调用
-
-**实现方式**: 
-- 调用 `chatForAce` 流式方法
-- 收集所有数据块
-- 返回最终结果
-
-**返回值**: 与 `chatForAce` 的 `done` 类型数据一致
-
-##### batchGenerateEpisodes
-**功能**: 创建批量生成任务
-
-**参数**:
-- `anime_id`: 动漫ID (必填)
-- `generate_episode_number`: 要生成的集数 (必填)
-- `episode_number`: 起始集数 (可选,默认从当前最大集数+1开始)
-- 其他与 `chatForAce` 相同的参数
-
-**流程**:
-1. 验证参数
-2. 检查是否为多剧集模式
-3. 计算起始和结束集数
-4. 检查是否超过总集数限制
-5. 检查是否已有进行中的任务
-6. 创建任务记录
-7. 分发到队列
-
-**返回值**:
-```json
-{
-    "task_id": 1,
-    "anime_id": 123,
-    "start_episode": 2,
-    "end_episode": 6,
-    "total_episodes": 5,
-    "status": "processing",
-    "message": "已创建批量生成任务,将生成第2集到第6集,共5集"
-}
-```
-
-##### getBatchGenerationTaskStatus
-**功能**: 查询批量生成任务状态
-
-**参数**:
-- `task_id`: 任务ID (与 `anime_id` 二选一)
-- `anime_id`: 动漫ID (与 `task_id` 二选一,返回最新任务)
-
-**返回值**:
-```json
-{
-    "task_id": 1,
-    "anime_id": 123,
-    "start_episode": 2,
-    "end_episode": 6,
-    "total_episodes": 5,
-    "current_episode": 3,
-    "completed_episodes": 2,
-    "progress": 40.00,
-    "status": "processing",
-    "error_message": null,
-    "details": [
-        {
-            "episode_number": 2,
-            "status": "completed",
-            "error_message": null,
-            "created_at": "2026-01-29 10:00:00",
-            "updated_at": "2026-01-29 10:05:00"
-        },
-        {
-            "episode_number": 3,
-            "status": "processing",
-            "error_message": null,
-            "created_at": "2026-01-29 10:05:00",
-            "updated_at": "2026-01-29 10:05:00"
-        }
-    ],
-    "created_at": "2026-01-29 10:00:00",
-    "updated_at": "2026-01-29 10:05:00",
-    "completed_at": null
-}
-```
-
-### 3. API 接口
-
-#### 创建批量生成任务
-**接口**: `POST /api/anime/batchGenerateEpisodes`
-
-**请求参数**:
-```json
-{
-    "anime_id": 123,
-    "generate_episode_number": 5,
-    "episode_number": 2,  // 可选,指定起始集数
-    "file": null,
-    "content": "",
-    "bid": 0,
-    "script_id": 0,
-    "prompt": "确认分镜大纲",
-    "products": [],
-    "art_style": "",
-    "model": ""
-}
-```
-
-**响应示例**:
-```json
-{
-    "code": 0,
-    "msg": "success",
-    "data": {
-        "task_id": 1,
-        "anime_id": 123,
-        "start_episode": 2,
-        "end_episode": 6,
-        "total_episodes": 5,
-        "status": "processing",
-        "message": "已创建批量生成任务,将生成第2集到第6集,共5集"
-    }
-}
-```
-
-#### 查询任务状态
-**接口**: `GET /api/anime/getBatchGenerationTaskStatus`
-
-**请求参数**:
-```
-task_id=1
-或
-anime_id=123
-```
-
-**响应示例**: (见上方 getBatchGenerationTaskStatus 返回值)
-
-## 使用流程
-
-### 前端调用流程
-
-1. **发起批量生成请求**
-```javascript
-// 用户输入要生成5集,当前是第2集
-const response = await fetch('/api/anime/batchGenerateEpisodes', {
-    method: 'POST',
-    headers: {
-        'Content-Type': 'application/json',
-        'Authorization': 'Bearer <token>'
-    },
-    body: JSON.stringify({
-        anime_id: 123,
-        generate_episode_number: 5,
-        // 其他参数与 chatForAce 一致
-    })
-});
-
-const result = await response.json();
-const taskId = result.data.task_id;
-```
-
-2. **轮询查询任务状态**
-```javascript
-const checkStatus = async () => {
-    const response = await fetch(`/api/anime/getBatchGenerationTaskStatus?task_id=${taskId}`);
-    const result = await response.json();
-    
-    // 更新UI显示进度
-    updateProgressBar(result.data.progress);
-    updateEpisodeList(result.data.details);
-    
-    // 如果任务未完成,继续轮询
-    if (result.data.status === 'processing') {
-        setTimeout(checkStatus, 3000); // 每3秒查询一次
-    } else if (result.data.status === 'completed') {
-        showSuccessMessage('批量生成完成!');
-    } else if (result.data.status === 'failed') {
-        showErrorMessage(result.data.error_message);
-    }
-};
-
-// 开始轮询
-checkStatus();
-```
-
-3. **防止重复操作**
-```javascript
-// 在发起新任务前,先检查是否有进行中的任务
-const checkExistingTask = async () => {
-    const response = await fetch(`/api/anime/getBatchGenerationTaskStatus?anime_id=${animeId}`);
-    const result = await response.json();
-    
-    if (result.data && ['pending', 'processing'].includes(result.data.status)) {
-        alert('已有批量生成任务正在进行中,请等待完成后再试');
-        return false;
-    }
-    return true;
-};
-
-// 创建任务前先检查
-if (await checkExistingTask()) {
-    // 创建新任务
-}
-```
-
-### 前端 UI 建议
-
-1. **任务状态显示**
-   - 显示整体进度条
-   - 显示当前正在生成的集数
-   - 显示已完成集数/总集数
-
-2. **详细列表**
-   - 显示每集的状态(等待中/处理中/已完成/失败)
-   - 失败的集显示错误原因
-   - 已完成的集提供预览入口
-
-3. **操作按钮**
-   - 禁用"开始生成"按钮当有任务进行中
-   - 提供"查看任务详情"按钮
-   - 提供"取消任务"按钮(未来扩展)
-
-## 迁移数据库
-
-运行以下命令创建所需数据表:
-
-```bash
-php artisan migrate
-```
-
-如果需要手动创建,执行:
-
-```sql
-```
-
-## 队列配置
-
-确保 Laravel 队列已正确配置并运行:
-
-1. 配置队列驱动 (`.env`):
-```
-QUEUE_CONNECTION=database
-```
-
-2. 运行队列监听:
-```bash
-php artisan queue:work
-```
-
-或使用 Supervisor 进行持久化监听。
-
-## 注意事项
-
-1. **性能考虑**
-   - 批量生成会占用较多AI接口资源
-   - 建议限制单次生成的集数上限(如最多10集)
-   - 合理设置队列worker数量
-
-2. **错误处理**
-   - 单集失败不会中断整个任务
-   - 所有错误信息都会记录到详情表
-   - 建议提供"重试失败集数"功能(未来扩展)
-
-3. **用户体验**
-   - 前端应该定期轮询任务状态(建议3-5秒)
-   - 任务完成后应通知用户(可使用浏览器通知API)
-   - 提供任务历史记录查询
-
-4. **资源管理**
-   - 定期清理已完成的旧任务记录
-   - 监控队列任务堆积情况
-   - 合理设置任务超时时间
-
-## 未来扩展
-
-1. **取消任务**: 允许用户取消正在进行的批量生成
-2. **重试机制**: 自动或手动重试失败的集数
-3. **优先级队列**: 支持任务优先级设置
-4. **通知系统**: 任务完成后发送邮件或站内通知
-5. **并行生成**: 支持多集并行生成(需注意资源占用)
-
-## 测试建议
-
-1. **单元测试**
-   - 测试 `chatForAceNonStream` 方法
-   - 测试 `batchGenerateEpisodes` 参数验证
-   - 测试任务状态查询
-
-2. **集成测试**
-   - 测试完整的批量生成流程
-   - 测试错误处理机制
-   - 测试并发任务冲突
-
-3. **性能测试**
-   - 测试大批量生成(如20集)
-   - 测试多用户同时批量生成
-   - 监控内存和CPU使用
-
-## 常见问题
-
-### Q: 如何取消正在进行的批量任务?
-A: 当前版本不支持取消任务,可以通过停止队列worker来中断,但已提交的任务会在重启后继续执行。
-
-### Q: 任务失败后如何重试?
-A: 可以使用相同的参数重新创建批量任务,系统会自动从已完成的集数继续生成。
-
-### Q: 批量生成会影响手动生成吗?
-A: 不会。批量生成使用独立的队列任务,不影响用户手动操作。
-
-### Q: 如何查看队列任务执行日志?
-A: 查看 `storage/logs/laravel.log` 文件,搜索 `batch_episode_generation` 关键字。