Просмотр исходного кода

优化保存剧本资产接口

lh 22 часов назад
Родитель
Сommit
487d985ced
3 измененных файлов с 822 добавлено и 61 удалено
  1. 27 1
      app/Http/Controllers/Anime/AnimeController.php
  2. 793 58
      app/Services/Anime/AnimeService.php
  3. 2 2
      routes/api.php

+ 27 - 1
app/Http/Controllers/Anime/AnimeController.php

@@ -2293,7 +2293,33 @@ class AnimeController extends BaseController
     public function saveScriptProducts(Request $request) {
         $data = $request->all();
         $result = $this->AnimeService->saveScriptProducts($data);
-        return $this->success(['success' => $result ? 1 : 0]);
+        
+        // 判断是否是 SSE 流(Generator)
+        if ($result instanceof \Generator) {
+            // 返回 SSE 响应
+            return response()->stream(function () use ($result) {
+                // 设置不缓冲输出
+                if (ob_get_level()) {
+                    ob_end_flush();
+                }
+                
+                foreach ($result as $event) {
+                    echo $event;
+                    if (ob_get_level()) {
+                        ob_flush();
+                    }
+                    flush();
+                }
+            }, 200, [
+                'Content-Type' => 'text/event-stream',
+                'Cache-Control' => 'no-cache',
+                'Connection' => 'keep-alive',
+                'X-Accel-Buffering' => 'no'
+            ]);
+        }
+        
+        // 普通JSON响应
+        return $this->success($result);
     }
 
     /**

+ 793 - 58
app/Services/Anime/AnimeService.php

@@ -6353,6 +6353,7 @@ class AnimeService
         $cpid = Site::getCpid();
         $script_id = getProp($data, 'script_id');
         $products = getProp($data, 'products', []);
+        $auto_generate_images = getProp($data, 'auto_generate_images', true); // 是否自动生成图片,默认true
 
         if (!$script_id) {
             Utils::throwError('20003:请提供剧本ID');
@@ -6367,6 +6368,171 @@ class AnimeService
         if (!$script) {
             Utils::throwError('20003:剧本不存在');
         }
+        
+        $script_name = $script->script_name ?? 'script_' . $script_id;
+        
+        // 检查是否已经有该剧本的资产数据
+        $existingMappings = DB::table('mp_script_product_mappings')
+            ->where('script_id', $script_id)
+            ->get();
+        
+        if ($existingMappings->isNotEmpty()) {
+            // 已存在资产映射,检查是否所有资产都有图片生成任务
+            $productIds = $existingMappings->pluck('product_id')->unique()->toArray();
+            
+            // 查询这些资产的图片生成任务状态
+            $productsWithTasks = DB::table('mp_products')
+                ->whereIn('id', $productIds)
+                ->where('is_deleted', 0)
+                ->get();
+            
+            $productTaskMap = [];
+            $typeFolderIds = [];
+            $hasAllTasks = true;
+            
+            foreach ($productsWithTasks as $product) {
+                // 收集类型文件夹ID
+                if ($product->parent_id > 0 && !in_array($product->parent_id, $typeFolderIds)) {
+                    $parentFolder = DB::table('mp_products')
+                        ->where('id', $product->parent_id)
+                        ->where('type', 2)
+                        ->first();
+                    
+                    if ($parentFolder) {
+                        $typeFolderIds[$parentFolder->product] = $parentFolder->id;
+                    }
+                }
+                
+                // 检查是否有图片生成任务
+                if (!empty($product->pic_task_id)) {
+                    $productTaskMap[$product->id] = $product->pic_task_id;
+                } else {
+                    // 有资产没有图片任务
+                    $hasAllTasks = false;
+                }
+            }
+            
+            // 如果所有资产都有任务ID,直接轮询返回结果
+            if ($hasAllTasks && !empty($productTaskMap)) {
+                dLog('anime')->info('剧本资产已存在且都有图片任务,直接轮询结果', [
+                    'script_id' => $script_id,
+                    'script_name' => $script_name,
+                    'product_count' => count($productTaskMap)
+                ]);
+                
+                // 直接返回 SSE 流轮询结果
+                return $this->pollProductImageTasks($script_id, $script_name, $productTaskMap, $typeFolderIds);
+            }
+            
+            // 如果部分资产没有任务ID,只为这些资产创建任务
+            if (!$hasAllTasks && $auto_generate_images) {
+                dLog('anime')->info('剧本资产已存在,但部分资产没有图片任务,为缺失的资产创建任务', [
+                    'script_id' => $script_id,
+                    'script_name' => $script_name
+                ]);
+                
+                // 开启事务
+                DB::beginTransaction();
+                
+                try {
+                    // 默认图片生成参数
+                    $model = 'doubao-seedream-5-0-lite-260128';
+                    $width = 1600;
+                    $height = 2848;
+                    
+                    foreach ($productsWithTasks as $product) {
+                        // 跳过已有任务的资产
+                        if (!empty($product->pic_task_id)) {
+                            continue;
+                        }
+                        
+                        // 跳过没有提示词的资产
+                        if (empty($product->pic_prompt)) {
+                            dLog('anime')->warning('资产没有提示词,跳过图片生成', [
+                                'product_id' => $product->id,
+                                'product_name' => $product->product_name
+                            ]);
+                            continue;
+                        }
+                        
+                        try {
+                            // 创建图片生成任务
+                            $params = [
+                                'prompt' => $product->pic_prompt,
+                                'model' => $model,
+                                'ref_img_urls' => [],
+                                'width' => $width,
+                                'height' => $height
+                            ];
+                            
+                            $task = $this->aiImageGenerationService->createImageGenerationTask($params);
+                            $task_id = $task->id;
+                            
+                            if ($task_id) {
+                                $productTaskMap[$product->id] = $task_id;
+                                
+                                // 在事务中更新资产表的任务ID和状态
+                                DB::table('mp_products')
+                                    ->where('id', $product->id)
+                                    ->update([
+                                        'pic_task_id' => $task_id,
+                                        'pic_task_status' => '生成中',
+                                        'updated_at' => now()
+                                    ]);
+                                    
+                                dLog('anime')->info('为已存在资产创建图片生成任务', [
+                                    'product_id' => $product->id,
+                                    'task_id' => $task_id
+                                ]);
+                            }
+                        } catch (\Exception $e) {
+                            dLog('anime')->error('创建资产图片生成任务失败', [
+                                'product_id' => $product->id,
+                                'error' => $e->getMessage()
+                            ]);
+                            
+                            // 标记为失败(仍在事务中)
+                            DB::table('mp_products')
+                                ->where('id', $product->id)
+                                ->update([
+                                    'pic_task_status' => '生成失败',
+                                    'updated_at' => now()
+                                ]);
+                        }
+                    }
+                    
+                    // 提交事务
+                    DB::commit();
+                    
+                    // 如果有新创建的任务,返回 SSE 流
+                    if (!empty($productTaskMap)) {
+                        return $this->pollProductImageTasks($script_id, $script_name, $productTaskMap, $typeFolderIds);
+                    }
+                    
+                } catch (\Exception $e) {
+                    // 回滚事务
+                    DB::rollback();
+                    
+                    dLog('anime')->error('为已存在资产创建图片任务失败', [
+                        'script_id' => $script_id,
+                        'error' => $e->getMessage()
+                    ]);
+                    
+                    Utils::throwError('20003:创建图片任务失败:' . $e->getMessage());
+                }
+            }
+            
+            // 如果不需要生成图片,返回已存在的信息
+            return [
+                'success' => true,
+                'message' => '剧本资产已存在',
+                'script_id' => $script_id,
+                'script_name' => $script_name,
+                'type_folder_ids' => $typeFolderIds,
+                'existing_products' => count($productIds),
+                'tasks_count' => count($productTaskMap)
+            ];
+        }
 
         // 解析 products(可能是 JSON 字符串或数组)
         if (is_string($products)) {
@@ -6380,63 +6546,577 @@ class AnimeService
             Utils::throwError('20003:产品数据不能为空');
         }
 
-        // 验证每个 product_id 是否存在
-        $productIds = array_column($products, 'product_id');
-        $validProductIds = DB::table('mp_products')
-            ->where('cpid', $cpid)
-            // ->where('user_id', $uid)
-            ->where('is_deleted', 0)
-            ->whereIn('id', $productIds)
-            ->pluck('id')
-            ->toArray();
+        // 以下是原有的创建逻辑...
+        // 开启事务
+        DB::beginTransaction();
+        
+        try {
+            $now = now();
+            $mappingRecords = [];
+            $createdProductIds = []; // 记录所有创建的资产ID
+            
+            // 获取剧本名称
+            $script_name = $script->script_name ?? 'script_' . $script_id;
+            
+            // 存储每个类型的根文件夹ID
+            $typeFolderIds = [];
+            
+            // 默认图片生成参数
+            $model = 'doubao-seedream-5-0-lite-260128';
+            $width = 1600;
+            $height = 2848;
+            
+            // 遍历每个产品类型(type: 1=角色, 2=场景, 3=道具)
+            foreach ($products as $productGroup) {
+                $type = getProp($productGroup, 'type');
+                $title = getProp($productGroup, 'title', '');
+                $content = getProp($productGroup, 'content', []);
+                
+                if (!in_array($type, [1, 2, 3]) || empty($content) || count($content) < 2) {
+                    continue;
+                }
+                
+                // 为当前类型创建根文件夹(如果还未创建)
+                if (!isset($typeFolderIds[$type])) {
+                    $typeFolderIds[$type] = DB::table('mp_products')->insertGetId([
+                        'user_id' => $uid,
+                        'cpid' => $cpid,
+                        'type' => 2, // 1.资产 2文件夹
+                        'product' => $type, // 1.主体 2.场景 3.道具
+                        'parent_id' => 0, // 根文件夹,parent_id=0
+                        'level' => 1, // 第一层级
+                        'product_name' => $script_name,
+                        'sort_order' => time() + $type,
+                        'is_deleted' => 0,
+                        'created_at' => $now,
+                        'updated_at' => $now
+                    ]);
+                }
+                
+                $folder_id = $typeFolderIds[$type];
+                
+                // 获取表头(第一行)并查找关键列的位置
+                $headers = $content[0];
+                $nameColumnIndex = -1;      // 资产名称列索引
+                $sequenceColumnIndex = -1;  // 使用范围列索引
+                $promptColumnIndex = -1;    // 参考提示词列索引
+                
+                foreach ($headers as $index => $header) {
+                    // 查找"资产名称"列
+                    if (strpos($header, '资产名称') !== false || strpos($header, '名称') !== false) {
+                        $nameColumnIndex = $index;
+                    }
+                    // 查找"使用范围"列
+                    if (strpos($header, '使用范围') !== false || strpos($header, '范围') !== false) {
+                        $sequenceColumnIndex = $index;
+                    }
+                    // 查找"参考提示词"列(可能是"干净参考图提示词"、"参考提示词"等)
+                    if (strpos($header, '参考') !== false && strpos($header, '提示词') !== false) {
+                        $promptColumnIndex = $index;
+                    }
+                }
+                
+                // 验证必要的列是否都找到了
+                if ($nameColumnIndex === -1) {
+                    dLog('anime')->warning('未找到资产名称列', ['type' => $type, 'headers' => $headers]);
+                    continue;
+                }
+                if ($sequenceColumnIndex === -1) {
+                    dLog('anime')->warning('未找到使用范围列', ['type' => $type, 'headers' => $headers]);
+                    continue;
+                }
+                
+                // 跳过表头,解析每一行数据
+                for ($i = 1; $i < count($content); $i++) {
+                    $row = $content[$i];
+                    
+                    // 确保行数据有效
+                    if (empty($row) || count($row) <= max($nameColumnIndex, $sequenceColumnIndex)) {
+                        continue;
+                    }
+                    
+                    // 根据动态查找的列索引提取数据
+                    $product_name = isset($row[$nameColumnIndex]) ? trim($row[$nameColumnIndex]) : '';
+                    $sequence_str = isset($row[$sequenceColumnIndex]) ? trim($row[$sequenceColumnIndex]) : '';
+                    $pic_prompt = '';
+                    
+                    // 提取参考提示词(如果找到了该列)
+                    if ($promptColumnIndex >= 0 && isset($row[$promptColumnIndex])) {
+                        $pic_prompt = trim($row[$promptColumnIndex]);
+                    }
+                    
+                    // 解析使用范围(逗号分隔的序号)
+                    $sequences = [];
+                    if (!empty($sequence_str)) {
+                        $sequenceParts = array_map('trim', explode(',', $sequence_str));
+                        foreach ($sequenceParts as $part) {
+                            if (is_numeric($part)) {
+                                $sequences[] = (int)$part;
+                            }
+                        }
+                    }
+                    
+                    if (empty($product_name)) {
+                        continue;
+                    }
+                    
+                    // 直接插入新资产到 mp_products 表(parent_id 指向对应类型的根文件夹)
+                    $product_id = DB::table('mp_products')->insertGetId([
+                        'product_name' => $product_name,
+                        'type' => 1,    // 1=资产 2.文件夹
+                        'product' => $type, // 1=角色, 2=场景, 3=道具
+                        'parent_id' => $folder_id, // 父文件夹是该类型的根文件夹
+                        'level' => 2, // 第二层级
+                        'pic_prompt' => $pic_prompt,
+                        'user_id' => $uid,
+                        'cpid' => $cpid,
+                        'sort_order' => time(),
+                        'is_deleted' => 0,
+                        'created_at' => $now,
+                        'updated_at' => $now,
+                    ]);
+                    
+                    // 记录创建的资产ID
+                    $createdProductIds[] = $product_id;
+                    
+                    // 为每个序号创建映射记录
+                    if (!empty($sequences)) {
+                        foreach ($sequences as $sequence) {
+                            $mappingRecords[] = [
+                                'script_id' => $script_id,
+                                'product_id' => $product_id,
+                                'episode_number' => $sequence,
+                                'created_at' => $now,
+                                'updated_at' => $now,
+                            ];
+                        }
+                    } else {
+                        // 如果没有指定序号,创建一个默认映射(序号为0)
+                        $mappingRecords[] = [
+                            'script_id' => $script_id,
+                            'product_id' => $product_id,
+                            'episode_number' => 0,
+                            'created_at' => $now,
+                            'updated_at' => $now,
+                        ];
+                    }
+                }
+            }
 
-        // 检查是否有无效的 product_id
-        $invalidIds = array_diff($productIds, $validProductIds);
-        if (!empty($invalidIds)) {
-            Utils::throwError('20003:以下资产ID不存在或无权访问 ' . implode(', ', $invalidIds));
-        }
+            if (empty($mappingRecords)) {
+                Utils::throwError('20003:没有有效的产品数据');
+            }
 
-        // 准备插入数据
-        $records = [];
-        $now = now();
+            // 批量插入映射数据(使用 insertOrIgnore 避免重复插入)
+            $insertedCount = DB::table('mp_script_product_mappings')->insertOrIgnore($mappingRecords);
+            
+            // 如果需要自动生成图片,在事务中创建图片生成任务
+            $productTaskMap = [];
+            if ($auto_generate_images && !empty($createdProductIds)) {
+                // 查询所有创建的资产
+                $productsToGenerate = DB::table('mp_products')
+                    ->whereIn('id', $createdProductIds)
+                    ->where('is_deleted', 0)
+                    ->get();
+                
+                foreach ($productsToGenerate as $product) {
+                    if (empty($product->pic_prompt)) {
+                        dLog('anime')->warning('资产没有提示词,跳过图片生成', [
+                            'product_id' => $product->id,
+                            'product_name' => $product->product_name
+                        ]);
+                        continue;
+                    }
+                    
+                    try {
+                        // 创建图片生成任务
+                        $params = [
+                            'prompt' => $product->pic_prompt,
+                            'model' => $model,
+                            'ref_img_urls' => [],
+                            'width' => $width,
+                            'height' => $height
+                        ];
+                        
+                        $task = $this->aiImageGenerationService->createImageGenerationTask($params);
+                        $task_id = $task->id;
+                        
+                        if ($task_id) {
+                            $productTaskMap[$product->id] = $task_id;
+                            
+                            // 在事务中更新资产表的任务ID和状态
+                            DB::table('mp_products')
+                                ->where('id', $product->id)
+                                ->update([
+                                    'pic_task_id' => $task_id,
+                                    'pic_task_status' => '生成中',
+                                    'updated_at' => now()
+                                ]);
+                                
+                            dLog('anime')->info('创建资产图片生成任务', [
+                                'product_id' => $product->id,
+                                'task_id' => $task_id
+                            ]);
+                        }
+                    } catch (\Exception $e) {
+                        dLog('anime')->error('创建资产图片生成任务失败', [
+                            'product_id' => $product->id,
+                            'error' => $e->getMessage()
+                        ]);
+                        
+                        // 标记为失败(仍在事务中)
+                        DB::table('mp_products')
+                            ->where('id', $product->id)
+                            ->update([
+                                'pic_task_status' => '生成失败',
+                                'updated_at' => now()
+                            ]);
+                    }
+                }
+            }
+            
+            // 提交事务
+            DB::commit();
+            
+            // 事务提交成功后,如果有图片生成任务,则返回 SSE 流
+            if ($auto_generate_images && !empty($productTaskMap)) {
+                // 返回 SSE 流(事务外轮询)
+                return $this->pollProductImageTasks($script_id, $script_name, $productTaskMap, $typeFolderIds);
+            }
+            
+            // 如果不需要生成图片,返回普通结果
+            return [
+                'success' => true,
+                'type_folder_ids' => $typeFolderIds,
+                'inserted_count' => $insertedCount,
+                'total_records' => count($mappingRecords),
+                'created_products' => count($createdProductIds),
+                'image_tasks_created' => count($productTaskMap)
+            ];
+            
+        } catch (\Exception $e) {
+            // 回滚事务
+            DB::rollback();
+            
+            // 记录错误日志
+            dLog('anime')->error('保存剧本资产失败: ' . $e->getMessage(), [
+                'script_id' => $script_id,
+                'error' => $e->getMessage(),
+                'trace' => $e->getTraceAsString()
+            ]);
+            
+            // 统一使用 Utils::throwError 抛出错误
+            Utils::throwError('20003:保存剧本资产失败:' . $e->getMessage());
+        }
+    }
 
+    /**
+     * 批量为剧本资产生成图片
+     * 
+     * @param array $data 请求参数
+     * @return \Generator 返回 SSE 流
+     */
+    public function batchGenerateProductImages($data) {
+        $script_id = getProp($data, 'script_id');
+        $type_folder_ids = getProp($data, 'type_folder_ids', []); // {1: 角色文件夹ID, 2: 场景文件夹ID, 3: 道具文件夹ID}
+        
+        if (!$script_id) {
+            Utils::throwError('20003:请提供剧本ID');
+        }
+        
+        // 验证剧本是否存在
+        $script = DB::table('mp_scripts')
+            ->where('id', $script_id)
+            ->where('is_deleted', 0)
+            ->first();
+            
+        if (!$script) {
+            Utils::throwError('20003:剧本不存在');
+        }
+        
+        $script_name = $script->script_name ?? 'script_' . $script_id;
+        
+        // 获取需要生成图片的所有资产
+        $products = DB::table('mp_products')
+            ->whereIn('parent_id', array_values($type_folder_ids))
+            ->where('is_deleted', 0)
+            ->whereIn('type', [1, 2, 3]) // 只处理资产,不处理文件夹
+            ->get();
+        
+        if ($products->isEmpty()) {
+            Utils::throwError('20003:没有找到需要生成图片的资产');
+        }
+        
+        // 默认参数
+        $model = 'doubao-seedream-5-0-lite-260128';
+        $width = 1600;
+        $height = 2848;
+        
+        // 开始为每个资产创建图片生成任务
+        $taskIds = [];
+        $productTaskMap = []; // 资产ID到任务ID的映射
+        
         foreach ($products as $product) {
-            if (!isset($product['product_id']) || !isset($product['episode_number'])) {
+            if (empty($product->pic_prompt)) {
+                dLog('anime')->warning('资产没有提示词,跳过', ['product_id' => $product->id, 'product_name' => $product->product_name]);
                 continue;
             }
-
-            $productId = $product['product_id'];
-            $episodeNumbers = $product['episode_number'];
-
-            // 如果 episode_number 是数组,则展开成多条记录
-            if (is_array($episodeNumbers)) {
-                foreach ($episodeNumbers as $episodeNumber) {
-                    $records[] = [
-                        'script_id' => $script_id,
-                        'product_id' => $productId,
-                        'episode_number' => $episodeNumber,
-                        'created_at' => $now,
-                        'updated_at' => $now,
-                    ];
+            
+            try {
+                // 创建图片生成任务
+                $params = [
+                    'prompt' => $product->pic_prompt,
+                    'model' => $model,
+                    'ref_img_urls' => [],
+                    'width' => $width,
+                    'height' => $height
+                ];
+                
+                $task = $this->aiImageGenerationService->createImageGenerationTask($params);
+                $task_id = $task->id;
+                
+                if ($task_id) {
+                    $taskIds[] = $task_id;
+                    $productTaskMap[$product->id] = $task_id;
+                    
+                    // 更新资产表的任务ID和状态
+                    DB::table('mp_products')
+                        ->where('id', $product->id)
+                        ->update([
+                            'pic_task_id' => $task_id,
+                            'pic_task_status' => '生成中',
+                            'updated_at' => now()
+                        ]);
+                        
+                    dLog('anime')->info('创建资产图片生成任务', ['product_id' => $product->id, 'task_id' => $task_id]);
                 }
-            } else {
-                // 如果 episode_number 是单个值,直接插入
-                $records[] = [
+            } catch (\Exception $e) {
+                dLog('anime')->error('创建资产图片生成任务失败', [
+                    'product_id' => $product->id,
+                    'error' => $e->getMessage()
+                ]);
+                
+                // 标记为失败
+                DB::table('mp_products')
+                    ->where('id', $product->id)
+                    ->update([
+                        'pic_task_status' => '生成失败',
+                        'updated_at' => now()
+                    ]);
+            }
+        }
+        
+        if (empty($taskIds)) {
+            Utils::throwError('20003:没有成功创建任何图片生成任务');
+        }
+        
+        // 返回 SSE 流
+        return $this->pollProductImageTasks($script_id, $script_name, $productTaskMap, $type_folder_ids);
+    }
+    
+    /**
+     * 轮询资产图片生成任务状态(SSE流式返回)
+     * 只通过查询数据库获取任务状态,不主动调用API
+     * 
+     * @param int $script_id 剧本ID
+     * @param string $script_name 剧本名称
+     * @param array $productTaskMap 资产ID到任务ID的映射
+     * @param array $type_folder_ids 类型文件夹ID映射
+     * @return \Generator
+     */
+    private function pollProductImageTasks($script_id, $script_name, $productTaskMap, $type_folder_ids) {
+        $startTime = time();
+        $timeout = 1800; // 30分钟超时
+        $pollInterval = 10; // 10秒轮询一次
+        $lastStatus = []; // 记录上次的状态,用于判断是否有变化
+        
+        // 定义类型名称映射
+        $typeNames = [
+            1 => 'roles',  // 角色
+            2 => 'scenes',      // 场景
+            3 => 'props'        // 道具
+        ];
+        
+        while (time() - $startTime < $timeout) {
+            sleep($pollInterval);
+            
+            // 查询所有资产的当前状态
+            $products = DB::table('mp_products')
+                ->whereIn('id', array_keys($productTaskMap))
+                ->where('is_deleted', 0)
+                ->get();
+            
+            // 统计各类型的状态(使用类型名称作为键名)
+            $stats = [
+                'character' => ['total' => 0, 'success' => 0, 'completed' => 0],  // 角色
+                'scene' => ['total' => 0, 'success' => 0, 'completed' => 0],      // 场景
+                'prop' => ['total' => 0, 'success' => 0, 'completed' => 0],       // 道具
+            ];
+            
+            $allCompleted = true;
+            $hasStatusChange = false;
+            
+            foreach ($products as $product) {
+                $type = $product->product;
+                $typeName = $typeNames[$type] ?? 'unknown';  // 使用 unknown 作为未知类型
+                
+                // 如果类型名称不在 stats 中,初始化
+                if (!isset($stats[$typeName])) {
+                    $stats[$typeName] = ['total' => 0, 'success' => 0, 'completed' => 0];
+                }
+                
+                $stats[$typeName]['total']++;
+                
+                $task_id = $product->pic_task_id;
+                $currentStatus = $product->pic_task_status;
+                
+                // 检查状态是否有变化
+                if (!isset($lastStatus[$product->id]) || $lastStatus[$product->id] !== $currentStatus) {
+                    $hasStatusChange = true;
+                    $lastStatus[$product->id] = $currentStatus;
+                }
+                
+                // 如果是生成中,查询任务表状态
+                if ($currentStatus === '生成中' && $task_id) {
+                    // 只查询数据库,不调用API
+                    $task = DB::table('mp_generate_pic_tasks')
+                        ->where('id', $task_id)
+                        ->first();
+                    
+                    if ($task) {
+                        // 检查任务状态
+                        if ($task->status === 'success' && $task->result_url) {
+                            // 解析图片URL
+                            $result_urls = $task->result_url;
+                            if (is_string($result_urls)) {
+                                $result_urls = json_decode($result_urls, true);
+                            }
+                            $img_url = is_array($result_urls) ? $result_urls[0] : $task->result_url;
+                            
+                            // 更新资产表状态
+                            DB::table('mp_products')
+                                ->where('id', $product->id)
+                                ->update([
+                                    'pic_task_status' => '生成成功',
+                                    'url' => $img_url,
+                                    'updated_at' => now()
+                                ]);
+                                
+                            $stats[$typeName]['success']++;
+                            $stats[$typeName]['completed']++;
+                            $hasStatusChange = true;
+                            
+                            dLog('anime')->info('资产图片生成成功', [
+                                'product_id' => $product->id,
+                                'task_id' => $task_id,
+                                'img_url' => $img_url
+                            ]);
+                        } elseif ($task->status === 'failed') {
+                            // 更新资产表状态
+                            DB::table('mp_products')
+                                ->where('id', $product->id)
+                                ->update([
+                                    'pic_task_status' => '生成失败',
+                                    'updated_at' => now()
+                                ]);
+                                
+                            $stats[$typeName]['completed']++;
+                            $hasStatusChange = true;
+                            
+                            dLog('anime')->warning('资产图片生成失败', [
+                                'product_id' => $product->id,
+                                'task_id' => $task_id,
+                                'error' => $task->error_message ?? '未知错误'
+                            ]);
+                        } else {
+                            // 任务还在处理中
+                            $allCompleted = false;
+                        }
+                    } else {
+                        // 任务不存在,标记为失败
+                        DB::table('mp_products')
+                            ->where('id', $product->id)
+                            ->update([
+                                'pic_task_status' => '生成失败',
+                                'updated_at' => now()
+                            ]);
+                            
+                        $stats[$type]['completed']++;
+                        $hasStatusChange = true;
+                        
+                        dLog('anime')->error('图片生成任务不存在', [
+                            'product_id' => $product->id,
+                            'task_id' => $task_id
+                        ]);
+                    }
+                } elseif ($currentStatus === '生成成功') {
+                    $stats[$typeName]['success']++;
+                    $stats[$typeName]['completed']++;
+                } elseif ($currentStatus === '生成失败') {
+                    $stats[$typeName]['completed']++;
+                } else {
+                    // 其他状态也认为未完成
+                    $allCompleted = false;
+                }
+            }
+            
+            // 移除没有数据的类型(total=0)
+            foreach ($stats as $typeName => $stat) {
+                if ($stat['total'] === 0) {
+                    unset($stats[$typeName]);
+                }
+            }
+            
+            // 如果有状态变化,发送 SSE 更新
+            if ($hasStatusChange) {
+                $eventType = $allCompleted ? 'complete' : 'update';
+                
+                $response = [
+                    'type' => $eventType,
                     'script_id' => $script_id,
-                    'product_id' => $productId,
-                    'episode_number' => $episodeNumbers,
-                    'created_at' => $now,
-                    'updated_at' => $now,
+                    'script_name' => $script_name,
+                    'stats' => $stats,
+                    'timestamp' => date('Y-m-d H:i:s')
                 ];
+                
+                yield "data: " . json_encode($response, JSON_UNESCAPED_UNICODE) . "\n\n";
+                
+                dLog('anime')->info('SSE更新', [
+                    'event_type' => $eventType,
+                    'script_id' => $script_id,
+                    'script_name' => $script_name,
+                    'stats' => $stats
+                ]);
+                
+                if ($allCompleted) {
+                    dLog('anime')->info('所有资产图片生成任务已完成', [
+                        'script_id' => $script_id,
+                        'script_name' => $script_name,
+                        'stats' => $stats
+                    ]);
+                    break;
+                }
             }
         }
-
-        if (empty($records)) {
-            Utils::throwError('20003:没有有效的产品数据');
+        
+        // 超时处理
+        if (time() - $startTime >= $timeout && !$allCompleted) {
+            $response = [
+                'type' => 'timeout',
+                'message' => '部分任务超时,请稍后查看结果',
+                'script_id' => $script_id,
+                'script_name' => $script_name,
+                'timestamp' => date('Y-m-d H:i:s')
+            ];
+            
+            yield "data: " . json_encode($response, JSON_UNESCAPED_UNICODE) . "\n\n";
+            
+            dLog('anime')->warning('资产图片生成任务超时', [
+                'script_id' => $script_id,
+                'script_name' => $script_name,
+                'timeout' => $timeout
+            ]);
         }
-
-        // 批量插入数据(使用 insertOrIgnore 避免重复插入)
-        return DB::table('mp_script_product_mappings')->insertOrIgnore($records);
     }
 
     /**
@@ -6469,7 +7149,6 @@ class AnimeService
             ->join('mp_products as product', 'mapping.product_id', '=', 'product.id')
             ->where('mapping.script_id', $script_id)
             ->where('product.cpid', $cpid)
-            // ->where('product.user_id', $uid)
             ->where('product.is_deleted', 0);
 
         // 如果提供了 episode_number,则过滤指定集数
@@ -6477,37 +7156,93 @@ class AnimeService
             $query->where('mapping.episode_number', $episode_number);
         }
 
-        $products = $query->select(
+        $mappings = $query->select(
                 'mapping.script_id',
                 'mapping.product_id',
                 'mapping.episode_number',
                 'product.product_name',
+                'product.type',
                 'product.product',
                 'product.pic_prompt',
                 'product.url',
+                'product.pic_task_id',
+                'product.pic_task_status',
                 'product.three_view_image_url',
                 'mapping.created_at'
             )
+            ->orderBy('mapping.product_id', 'asc')
             ->orderBy('mapping.episode_number', 'asc')
-            ->orderBy('mapping.id', 'asc')
-            ->get()
-            ->map(function ($item) {
-                return [
-                    'script_id' => $item->script_id,
-                    'episode_number' => $item->episode_number,
-                    'product_id' => $item->product_id,
+            ->get();
+
+        // 按 product_id 分组,合并 episode_number
+        $productMap = [];
+        foreach ($mappings as $item) {
+            $product_id = $item->product_id;
+            
+            if (!isset($productMap[$product_id])) {
+                $productMap[$product_id] = [
+                    'product_id' => $product_id,
                     'product_name' => $item->product_name,
+                    'type' => (int)$item->type,
                     'product' => (int)$item->product,
                     'pic_prompt' => $item->pic_prompt,
                     'url' => $item->url,
-                    // 'three_view_image_url' => $item->three_view_image_url,
+                    'pic_task_id' => $item->pic_task_id,
+                    'pic_task_status' => $item->pic_task_status,
+                    'episode_numbers' => [],
                     'created_at' => $item->created_at,
                 ];
-            })
-            ->toArray();
+            }
+            
+            // 添加 episode_number(去重)
+            if (!in_array($item->episode_number, $productMap[$product_id]['episode_numbers'])) {
+                $productMap[$product_id]['episode_numbers'][] = $item->episode_number;
+            }
+        }
+        
+        // 按类型分组
+        $roles = [];  // type=1 角色/主体
+        $scenes = [];      // type=2 场景
+        $props = [];       // type=3 道具
+        
+        foreach ($productMap as $product) {
+            // 将 episode_numbers 数组转换为逗号分隔的字符串
+            $episodeNumbersStr = implode(',', $product['episode_numbers']);
+            
+            // 构造返回数据
+            $productData = [
+                'product_id' => $product['product_id'],
+                'product_name' => $product['product_name'],
+                'product' => $product['product'],
+                'pic_prompt' => $product['pic_prompt'],
+                'url' => $product['url'],
+                'pic_task_id' => $product['pic_task_id'],
+                'pic_task_status' => $product['pic_task_status'],
+                'episode_numbers' => $episodeNumbersStr,
+                'created_at' => $product['created_at'],
+            ];
+            
+            // 根据 type 分类(注意:type 字段表示资产类型,不是文件夹)
+            // 根据 product 字段分类(1=角色, 2=场景, 3=道具)
+            switch ($product['product']) {
+                case 1:
+                    $roles[] = $productData;
+                    break;
+                case 2:
+                    $scenes[] = $productData;
+                    break;
+                case 3:
+                    $props[] = $productData;
+                    break;
+            }
+        }
 
         return [
-            'list' => $products,
+            'script_id' => $script_id,
+            'script_name' => $script->script_name ?? '',
+            'roles' => $roles,      // 主体
+            'scenes' => $scenes,          // 场景
+            'props' => $props,            // 道具
         ];
     }
 

+ 2 - 2
routes/api.php

@@ -232,8 +232,8 @@ Route::group(['middleware' => ['bindToken', 'bindExportToken', 'checkLogin']], f
             Route::post('generateThreeView', [AnimeController::class, 'generateThreeView']);
             
             // 剧本资产关联管理
-            Route::post('saveScriptProducts', [AnimeController::class, 'saveScriptProducts']);     // 保存剧本资产关联
-            Route::get('getScriptProducts', [AnimeController::class, 'getScriptProducts']);        // 获取剧本关联的资产列表
+            Route::post('saveScriptProducts', [AnimeController::class, 'saveScriptProducts']);     // 保存剧本资产
+            Route::get('getScriptProducts', [AnimeController::class, 'getScriptProducts']);        // 获取剧本资产
 
             // 提示词模板管理
             Route::get('promptTemplates', [PromptTemplateController::class, 'promptTemplates']);          // 提示词模板列表