Forráskód Böngészése

Merge branch 'test' into check_points

lh 1 hónapja
szülő
commit
5e9865467c

+ 14 - 12
app/Console/Commands/ProcessBatchAnimeEpisodeGenerationCommand.php

@@ -157,18 +157,9 @@ class ProcessBatchAnimeEpisodeGenerationCommand extends Command
         if ($task->episode_number > 1) {
         if ($task->episode_number > 1) {
             $prevEpisodeNumber = $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 . '集)尚未完成,跳过当前任务 (anime_id: ' . $task->anime_id . ')');
-                return false;
-            }
-
-            // 检查前一集是否已在系统中生成(查询 mp_anime_episodes)
+            // 优先判断前一集是否已在系统中生成(查询 mp_anime_episodes)。
+            // 用户手动创建的前一集也视为已存在,直接跳过批量任务状态判断,
+            // 避免批量任务前一集失败后卡住整个批量任务。
             $prevEpisodeExists = DB::table('mp_anime_episodes')
             $prevEpisodeExists = DB::table('mp_anime_episodes')
                 ->where('anime_id', $task->anime_id)
                 ->where('anime_id', $task->anime_id)
                 ->where('episode_number', $prevEpisodeNumber)
                 ->where('episode_number', $prevEpisodeNumber)
@@ -176,6 +167,17 @@ class ProcessBatchAnimeEpisodeGenerationCommand extends Command
                 ->exists();
                 ->exists();
 
 
             if (!$prevEpisodeExists) {
             if (!$prevEpisodeExists) {
+                // 系统中不存在前一集时,才进行批量任务状态判断
+                $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 . '集)尚未完成,跳过当前任务 (anime_id: ' . $task->anime_id . ')');
+                    return false;
+                }
+
                 dLog('command')->error('[' . date('Y-m-d H:i:s') . '] 前一集(第' . $prevEpisodeNumber . '集)不存在,无法继续生成 (anime_id: ' . $task->anime_id . ')');
                 dLog('command')->error('[' . date('Y-m-d H:i:s') . '] 前一集(第' . $prevEpisodeNumber . '集)不存在,无法继续生成 (anime_id: ' . $task->anime_id . ')');
 
 
                 // 标记为失败
                 // 标记为失败

+ 1 - 1
app/Consts/BaseConst.php

@@ -85,7 +85,7 @@ class BaseConst
     const VOLC_PIC_MODELS = ['doubao-seedream-5-0-260128', 'doubao-seedream-5-0-lite-260128', 'doubao-seedream-4-5-251128'];
     const VOLC_PIC_MODELS = ['doubao-seedream-5-0-260128', 'doubao-seedream-5-0-lite-260128', 'doubao-seedream-4-5-251128'];
     const GPT_IMAGE2_MODELS = ['gpt-image-2'];
     const GPT_IMAGE2_MODELS = ['gpt-image-2'];
     const NANO_BANANA_MODELS = ['NanoBanana2', 'NanoBananaPro', 'GptImage2'];
     const NANO_BANANA_MODELS = ['NanoBanana2', 'NanoBananaPro', 'GptImage2'];
-    const GPT_TEXT_MODELS = ['gpt-5.4', 'gpt-5.6-terra'];
+    const GPT_TEXT_MODELS = ['gpt-5.4', 'gpt-5.6-terra', 'gpt-5.6-sol', 'gpt-5.6-luna'];
     const GEMINI_TEXT_MODELS = [
     const GEMINI_TEXT_MODELS = [
         'gemini-3-pro-preview',
         'gemini-3-pro-preview',
         'gemini-3-flash-preview',
         'gemini-3-flash-preview',

+ 11 - 0
app/Http/Controllers/Anime/AnimeController.php

@@ -2243,6 +2243,17 @@ class AnimeController extends BaseController
         return $this->success($result);
         return $this->success($result);
     }
     }
 
 
+    /**
+     * 获取剧本总集数
+     * @param Request $request
+     * @return mixed
+     */
+    public function getScriptTotalEpisodes(Request $request) {
+        $data = $request->all();
+        $result = $this->AnimeService->getScriptTotalEpisodes($data);
+        return $this->success($result);
+    }
+
     private function handleVideoTimePointEnd() {
     private function handleVideoTimePointEnd() {
         // 处理音频时长,循环查询dub_video_task_queue列表中的任务
         // 处理音频时长,循环查询dub_video_task_queue列表中的任务
         $queueLength = Redis::llen('dub_video_task_queue');
         $queueLength = Redis::llen('dub_video_task_queue');

+ 1 - 13
app/Http/Controllers/Canvas/CanvasController.php

@@ -40,23 +40,11 @@ class CanvasController extends BaseController
         return $this->success($result);
         return $this->success($result);
     }
     }
 
 
-    // 保存画布完整文档;revision 冲突返回 409 和服务端当前 revision
+    // 保存画布完整文档
     public function editCanvas(Request $request)
     public function editCanvas(Request $request)
     {
     {
         $data   = $request->all();
         $data   = $request->all();
         $result = $this->CanvasService->editCanvas($data);
         $result = $this->CanvasService->editCanvas($data);
-
-        if (isset($result['revision_conflict']) && $result['revision_conflict']) {
-            return response()->json([
-                'msg'  => '画布已被其他会话修改,请拉取最新版本',
-                'code' => 409,
-                'data' => [
-                    'canvas_id' => $result['canvas_id'],
-                    'revision'  => $result['revision']
-                ]
-            ], 409)->setEncodingOptions(JSON_UNESCAPED_UNICODE);
-        }
-
         return $this->success($result);
         return $this->success($result);
     }
     }
 
 

+ 240 - 21
app/Services/Anime/AnimeService.php

@@ -6989,6 +6989,16 @@ class AnimeService
         // 根据is_public参数确定存储的user_id(公共库user_id=0,cpid保持不变)
         // 根据is_public参数确定存储的user_id(公共库user_id=0,cpid保持不变)
         $store_uid = $is_public ? 0 : $uid;
         $store_uid = $is_public ? 0 : $uid;
         
         
+        // 新增参数:剧本ID和剧集序号(支持数组传参,多个序号也可用英文逗号隔开)
+        $script_id = (int)getProp($data, 'script_id', 0);
+        $episode_numbers = $this->normalizeEpisodeNumbers(getProp($data, 'episode_number', []));
+        if ($script_id && empty($episode_numbers)) {
+            Utils::throwError('20003:剧集序号不能为空');
+        }
+        if (!$script_id && !empty($episode_numbers)) {
+            Utils::throwError('20003:请提供剧本ID');
+        }
+        
         $product_name = trim(getProp($data, 'product_name'));
         $product_name = trim(getProp($data, 'product_name'));
         if (!$product_name) Utils::throwError('20003:名称不能为空');
         if (!$product_name) Utils::throwError('20003:名称不能为空');
         $product_name = preg_replace('/^[\s*\[\]【】[]{}{}\x{3000}]+|[\s*\[\]【】[]{}{}\x{3000}]+$/u', '', $product_name);
         $product_name = preg_replace('/^[\s*\[\]【】[]{}{}\x{3000}]+|[\s*\[\]【】[]{}{}\x{3000}]+$/u', '', $product_name);
@@ -7035,23 +7045,38 @@ class AnimeService
             $parent_level = $parent->level;
             $parent_level = $parent->level;
         }
         }
 
 
-        $id = DB::table('mp_products')->insertGetId([
-            'user_id'       => $store_uid,
-            'cpid'          => $cpid,
-            'type'          => 1, // 1=角色图片
-            'parent_id'     => $parent_id,
-            'level'         => $parent_level + 1,
-            'product_name'  => $product_name,
-            'url'           => $url,
-            'pic_prompt'    => $pic_prompt,
-            'product'       => $product,
-            'versions'      => json_encode($versions, 256),
-            'sort_order'    => time(), // 使用时间戳作为排序权重
-            'created_at'    => date('Y-m-d H:i:s'),
-            'updated_at'    => date('Y-m-d H:i:s')
-        ]);
+        // 创建资产并保存剧本资产关联关系(同一事务)
+        DB::beginTransaction();
+        try {
+            $id = DB::table('mp_products')->insertGetId([
+                'user_id'       => $store_uid,
+                'cpid'          => $cpid,
+                'type'          => 1, // 1=角色图片
+                'parent_id'     => $parent_id,
+                'level'         => $parent_level + 1,
+                'product_name'  => $product_name,
+                'url'           => $url,
+                'pic_prompt'    => $pic_prompt,
+                'product'       => $product,
+                'versions'      => json_encode($versions, 256),
+                'sort_order'    => time(), // 使用时间戳作为排序权重
+                'created_at'    => date('Y-m-d H:i:s'),
+                'updated_at'    => date('Y-m-d H:i:s')
+            ]);
 
 
-        if (!$id) Utils::throwError('20003:创建失败');
+            if (!$id) Utils::throwError('20003:创建失败');
+            
+            // 保存剧本资产关联关系
+            if ($script_id) {
+                $this->saveProductScriptMapping($id, $script_id, $episode_numbers);
+            }
+            
+            DB::commit();
+        } catch (\Exception $e) {
+            DB::rollback();
+            throw $e;
+        }
+        
         return [
         return [
             'id'            => $id,
             'id'            => $id,
             'type'          => 1,
             'type'          => 1,
@@ -7074,6 +7099,16 @@ class AnimeService
         $versions = getProp($data, 'versions');
         $versions = getProp($data, 'versions');
         $is_public = getProp($data, 'is_public', 0); // 0=个人资产库 1=公共资产库
         $is_public = getProp($data, 'is_public', 0); // 0=个人资产库 1=公共资产库
         
         
+        // 新增参数:剧本ID和剧集序号(支持数组传参,多个序号也可用英文逗号隔开)
+        $script_id = (int)getProp($data, 'script_id', 0);
+        $episode_numbers = $this->normalizeEpisodeNumbers(getProp($data, 'episode_number', []));
+        if ($script_id && empty($episode_numbers)) {
+            Utils::throwError('20003:剧集序号不能为空');
+        }
+        if (!$script_id && !empty($episode_numbers)) {
+            Utils::throwError('20003:请提供剧本ID');
+        }
+        
         // 根据is_public参数确定查询的user_id(公共库user_id=0,cpid保持不变)
         // 根据is_public参数确定查询的user_id(公共库user_id=0,cpid保持不变)
         $query_uid = $is_public ? 0 : $uid;
         $query_uid = $is_public ? 0 : $uid;
         
         
@@ -7134,7 +7169,7 @@ class AnimeService
             $updateData['pic_prompt'] = $pic_prompt;
             $updateData['pic_prompt'] = $pic_prompt;
         }
         }
         
         
-        if (empty($updateData)) {
+        if (empty($updateData) && !$script_id) {
             Utils::throwError('20003:没有需要更新的数据');
             Utils::throwError('20003:没有需要更新的数据');
         }
         }
         
         
@@ -7188,11 +7223,195 @@ class AnimeService
             $updateData['versions'] = json_encode($versions, 256);
             $updateData['versions'] = json_encode($versions, 256);
         }
         }
         
         
-        $updateData['updated_at'] = date('Y-m-d H:i:s');
+        if (!empty($updateData)) {
+            $updateData['updated_at'] = date('Y-m-d H:i:s');
+        }
         
         
-        return DB::table('mp_products')
-        ->where('cpid', $cpid)
-        ->where('id', $id)->update($updateData);
+        // 更新资产并保存剧本资产关联关系(同一事务)
+        DB::beginTransaction();
+        try {
+            $updated = true;
+            if (!empty($updateData)) {
+                $updated = DB::table('mp_products')
+                    ->where('cpid', $cpid)
+                    ->where('id', $id)
+                    ->update($updateData);
+            }
+            
+            // 保存剧本资产关联关系
+            if ($script_id) {
+                $this->saveProductScriptMapping($id, $script_id, $episode_numbers);
+            }
+            
+            DB::commit();
+        } catch (\Exception $e) {
+            DB::rollback();
+            throw $e;
+        }
+        
+        return $updated;
+    }
+    
+    /**
+     * 保存单一资产与剧本剧集的关联关系
+     *
+     * @param int $product_id 资产ID
+     * @param int $script_id 剧本ID
+     * @param array $episode_numbers 剧集序号数组(序号从1开始)
+     * @return int 新增关联数量
+     */
+    private function saveProductScriptMapping($product_id, $script_id, $episode_numbers) {
+        $uid = Site::getUid();
+        
+        // 验证剧本是否存在
+        $script = DB::table('mp_scripts')
+            ->where('id', $script_id)
+            ->where('is_deleted', 0)
+            ->first();
+        
+        if (!$script) {
+            Utils::throwError('20003:剧本不存在');
+        }
+        
+        // 验证剧本归属:只能关联自己的剧本
+        if ((int)$script->user_id !== (int)$uid) {
+            Utils::throwError('20003:只能添加到自己的剧本');
+        }
+        
+        // 使用 splitContent 拆分剧本内容获取总集数
+        $totalEpisodes = $this->getScriptTotalEpisodeCount($script);
+        
+        // 解析剧集序号(数组)
+        $episodes = [];
+        foreach ($episode_numbers as $item) {
+            if (is_array($item)) {
+                continue;
+            }
+            $item = trim((string)$item);
+            if ($item === '' || !is_numeric($item)) {
+                Utils::throwError('20003:剧集序号格式不正确,仅支持数字数组或英文逗号分隔的数字');
+            }
+            $episodes[] = (int)$item;
+        }
+        
+        // 去重并校验序号范围(序号从1开始)
+        $episodes = array_values(array_unique($episodes));
+        if (empty($episodes)) {
+            Utils::throwError('20003:剧集序号不能为空');
+        }
+        
+        foreach ($episodes as $episode) {
+            if ($episode < 1 || $episode > $totalEpisodes) {
+                Utils::throwError('20003:剧集序号 ' . $episode . ' 超出范围,剧本共 ' . $totalEpisodes . ' 集(序号从1开始)');
+            }
+        }
+        
+        $now = now();
+        
+        // 过滤已存在的关联,避免重复
+        $existingEpisodes = DB::table('mp_script_product_mappings')
+            ->where('script_id', $script_id)
+            ->where('product_id', $product_id)
+            ->whereIn('episode_number', $episodes)
+            ->pluck('episode_number')
+            ->all();
+        
+        $mappingRecords = [];
+        foreach ($episodes as $episode) {
+            if (in_array($episode, $existingEpisodes)) {
+                continue;
+            }
+            $mappingRecords[] = [
+                'script_id' => $script_id,
+                'product_id' => $product_id,
+                'episode_number' => $episode,
+                'created_at' => $now,
+                'updated_at' => $now,
+            ];
+        }
+        
+        $insertedCount = 0;
+        if (!empty($mappingRecords)) {
+            $insertedCount = DB::table('mp_script_product_mappings')->insert($mappingRecords) ? count($mappingRecords) : 0;
+        }
+        
+        return $insertedCount;
+    }
+    
+    /**
+     * 规范化剧集序号参数,统一为数组
+     * 兼容传入数组(如 [1, 3, 5])、逗号分隔字符串(如 "1,3,5")以及字符串数组(如 ["1,3", 5])
+     *
+     * @param mixed $episode_number 剧集序号参数
+     * @return array
+     */
+    private function normalizeEpisodeNumbers($episode_number) {
+        if (!is_array($episode_number)) {
+            $episode_number = explode(',', (string)$episode_number);
+        }
+        
+        $items = [];
+        foreach ($episode_number as $item) {
+            if (is_array($item)) {
+                continue;
+            }
+            foreach (explode(',', (string)$item) as $part) {
+                $part = trim($part);
+                if ($part !== '') {
+                    $items[] = $part;
+                }
+            }
+        }
+        
+        return $items;
+    }
+    
+    /**
+     * 获取剧本总集数(通过 splitContent 拆分剧本内容)
+     *
+     * @param object $script 剧本记录
+     * @return int 总集数
+     */
+    private function getScriptTotalEpisodeCount($script) {
+        if (empty($script->content)) {
+            Utils::throwError('20003:剧本内容为空,无法确定剧集数量');
+        }
+        
+        $totalEpisodes = count(splitContent($script->content));
+        if ($totalEpisodes < 1) {
+            Utils::throwError('20003:剧本内容无法拆分出剧集');
+        }
+        
+        return $totalEpisodes;
+    }
+    
+    /**
+     * 获取剧本总集数
+     *
+     * @param array $data 包含 script_id(剧本ID)
+     * @return array
+     */
+    public function getScriptTotalEpisodes($data) {
+        $script_id = (int)getProp($data, 'script_id', 0);
+        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:剧本不存在');
+        }
+        
+        return [
+            'script_id' => $script_id,
+            'script_name' => $script->script_name ?? 'script_' . $script_id,
+            'episode_num' => $this->getScriptTotalEpisodeCount($script),
+        ];
     }
     }
     
     
     /**
     /**

+ 158 - 287
app/Services/Canvas/CanvasService.php

@@ -29,9 +29,6 @@ class CanvasService
     /** 关联来源类型白名单 */
     /** 关联来源类型白名单 */
     const SOURCE_TYPES = ['none', 'product', 'episode'];
     const SOURCE_TYPES = ['none', 'product', 'episode'];
 
 
-    /** 当前支持的画布文档 schema 版本 */
-    const SUPPORTED_SCHEMA_VERSION = 1;
-
     /** 画布文档大小与图规模限制 */
     /** 画布文档大小与图规模限制 */
     const MAX_DOCUMENT_BYTES = 2097152; // 2MB
     const MAX_DOCUMENT_BYTES = 2097152; // 2MB
     const MAX_NODES         = 2000;
     const MAX_NODES         = 2000;
@@ -52,6 +49,7 @@ class CanvasService
         $cpid       = Site::getCpid();
         $cpid       = Site::getCpid();
         $anime_id   = (int)getProp($data, 'anime_id', 0);
         $anime_id   = (int)getProp($data, 'anime_id', 0);
         $episode_id = (int)getProp($data, 'episode_id', 0);
         $episode_id = (int)getProp($data, 'episode_id', 0);
+        $title = getProp($data, 'title');
         $page       = max(1, (int)getProp($data, 'page', 1));
         $page       = max(1, (int)getProp($data, 'page', 1));
         $page_size  = min(100, max(1, (int)getProp($data, 'page_size', 20)));
         $page_size  = min(100, max(1, (int)getProp($data, 'page_size', 20)));
 
 
@@ -60,8 +58,7 @@ class CanvasService
             ->where('cpid', $cpid)
             ->where('cpid', $cpid)
             ->where('is_deleted', 0)
             ->where('is_deleted', 0)
             ->select('canvas_uuid as canvas_id', 'name as title', 'description', 'cover_url',
             ->select('canvas_uuid as canvas_id', 'name as title', 'description', 'cover_url',
-                'anime_id', 'episode_id', 'episode_number', 'schema_version', 'revision',
-                'created_at', 'updated_at');
+                'anime_id', 'episode_id', 'episode_number', 'created_at', 'updated_at');
 
 
         if ($anime_id) {
         if ($anime_id) {
             $query->where('anime_id', $anime_id);
             $query->where('anime_id', $anime_id);
@@ -69,6 +66,9 @@ class CanvasService
         if ($episode_id) {
         if ($episode_id) {
             $query->where('episode_id', $episode_id);
             $query->where('episode_id', $episode_id);
         }
         }
+        if ($title) {
+            $query->where('name', 'like', "%$title%");
+        }
 
 
         $list = $query->orderByDesc('id')->paginate($page_size, ['*'], 'page', $page);
         $list = $query->orderByDesc('id')->paginate($page_size, ['*'], 'page', $page);
 
 
@@ -92,9 +92,11 @@ class CanvasService
     }
     }
 
 
     /**
     /**
-     * 创建画布(完整文档模式,兼容旧的 name-only 创建)
+     * 创建画布
      *
      *
-     * @param array $data 完整画布文档或旧版 name/description/cover_url
+     * 必填:name、document;canvas_id 可选(缺省自动生成);可选:anime_id、episode_id、episode_number
+     *
+     * @param array $data
      * @return array
      * @return array
      */
      */
     public function createCanvas($data)
     public function createCanvas($data)
@@ -103,54 +105,39 @@ class CanvasService
         $cpid = Site::getCpid();
         $cpid = Site::getCpid();
         $now  = date('Y-m-d H:i:s');
         $now  = date('Y-m-d H:i:s');
 
 
-        if ($this->isDocumentRequest($data)) {
-            $doc         = $this->validateDocument($data, true);
-            $canvas_uuid = $doc['canvas_id'];
-            if (DB::table('mp_canvases')->where('canvas_uuid', $canvas_uuid)->exists()) {
-                Utils::throwError('20003:画布ID已存在');
-            }
-
-            $row = [
-                'uid'            => $uid,
-                'cpid'           => $cpid,
-                'canvas_uuid'    => $canvas_uuid,
-                'name'           => $doc['title'],
-                'description'    => (string)getProp($data, 'description', ''),
-                'cover_url'      => (string)getProp($data, 'cover_url', ''),
-                'anime_id'       => $doc['scope']['anime_id'],
-                'episode_id'     => $doc['scope']['episode_id'],
-                'episode_number' => $doc['scope']['episode_number'],
-                'schema_version' => self::SUPPORTED_SCHEMA_VERSION,
-                'revision'       => 1,
-                'document'       => json_encode($doc, JSON_UNESCAPED_UNICODE),
-                'is_deleted'     => 0,
-                'created_at'     => $now,
-                'updated_at'     => $now
-            ];
-            $canvas_id = DB::table('mp_canvases')->insertGetId($row);
-            if (!$canvas_id) {
-                Utils::throwError('20003:创建画布失败');
-            }
-            $row['id'] = $canvas_id;
-            return $this->buildDocument((object)$row);
+        $canvas_uuid = trim((string)getProp($data, 'canvas_id', ''));
+        if ($canvas_uuid === '') {
+            $canvas_uuid = $this->generateCanvasId();
+        }
+        if (mb_strlen($canvas_uuid) > 64) {
+            Utils::throwError('20003:canvas_id长度超出限制');
+        }
+        if (DB::table('mp_canvases')->where('canvas_uuid', $canvas_uuid)->exists()) {
+            Utils::throwError('20003:画布ID已存在');
         }
         }
 
 
-        // 旧版 name-only 创建,保留兼容
-        $name = trim((string)getProp($data, 'name', ''));
+        $name = trim((string)$this->docProp($data, 'name', ''));
         if ($name === '') {
         if ($name === '') {
             Utils::throwError('20003:请提供画布名称');
             Utils::throwError('20003:请提供画布名称');
         }
         }
-        $canvas_uuid = $this->generateCanvasId();
+
+        $document = $this->documentForStorage($this->extractDocument($data));
+        if ($document === null) {
+            Utils::throwError('20003:请提供document');
+        }
+        $this->checkDocumentSize($document);
+
         $canvas_id = DB::table('mp_canvases')->insertGetId([
         $canvas_id = DB::table('mp_canvases')->insertGetId([
             'uid'            => $uid,
             'uid'            => $uid,
             'cpid'           => $cpid,
             'cpid'           => $cpid,
             'canvas_uuid'    => $canvas_uuid,
             'canvas_uuid'    => $canvas_uuid,
             'name'           => $name,
             'name'           => $name,
-            'description'    => (string)getProp($data, 'description', ''),
-            'cover_url'      => (string)getProp($data, 'cover_url', ''),
-            'schema_version' => self::SUPPORTED_SCHEMA_VERSION,
-            'revision'       => 1,
-            'document'       => null,
+            'description'    => (string)$this->docProp($data, 'description', ''),
+            'cover_url'      => (string)$this->docProp($data, 'cover_url', ''),
+            'anime_id'       => $this->scopeProp($data, 'anime_id'),
+            'episode_id'     => $this->scopeProp($data, 'episode_id'),
+            'episode_number' => $this->scopeProp($data, 'episode_number'),
+            'document'       => $document,
             'is_deleted'     => 0,
             'is_deleted'     => 0,
             'created_at'     => $now,
             'created_at'     => $now,
             'updated_at'     => $now
             'updated_at'     => $now
@@ -159,13 +146,15 @@ class CanvasService
             Utils::throwError('20003:创建画布失败');
             Utils::throwError('20003:创建画布失败');
         }
         }
 
 
-        return $this->canvasDetail(['canvas_id' => $canvas_uuid]);
+        return $this->documentResult($canvas_uuid, $name, $document);
     }
     }
 
 
     /**
     /**
-     * 保存画布完整文档;revision 不匹配时返回冲突标记
+     * 保存画布完整文档
      *
      *
-     * @param array $data 完整画布文档
+     * 必填:canvas_id、name、document;可选:anime_id、episode_id、episode_number(不传保留原值)
+     *
+     * @param array $data
      * @return array
      * @return array
      */
      */
     public function editCanvas($data)
     public function editCanvas($data)
@@ -176,32 +165,33 @@ class CanvasService
         }
         }
 
 
         $canvas = $this->checkCanvas($canvas_id);
         $canvas = $this->checkCanvas($canvas_id);
-        $doc    = $this->validateDocument($data, false, $canvas);
 
 
-        if ((int)$doc['revision'] !== (int)$canvas->revision) {
-            return [
-                'revision_conflict' => true,
-                'canvas_id'         => $this->canvasKey($canvas),
-                'revision'          => (int)$canvas->revision
-            ];
+        $name = trim((string)$this->docProp($data, 'name', ''));
+        if ($name === '') {
+            Utils::throwError('20003:请提供画布名称');
         }
         }
 
 
-        $next_revision   = (int)$canvas->revision + 1;
-        $doc['revision'] = $next_revision;
-        $doc['canvas_id'] = $this->canvasKey($canvas);
-
-        DB::table('mp_canvases')->where('id', $canvas->id)->update([
-            'name'           => $doc['title'],
-            'anime_id'       => $doc['scope']['anime_id'],
-            'episode_id'     => $doc['scope']['episode_id'],
-            'episode_number' => $doc['scope']['episode_number'],
-            'schema_version' => self::SUPPORTED_SCHEMA_VERSION,
-            'revision'       => $next_revision,
-            'document'       => json_encode($doc, JSON_UNESCAPED_UNICODE),
-            'updated_at'     => date('Y-m-d H:i:s')
-        ]);
+        $document = $this->documentForStorage($this->extractDocument($data));
+        if ($document === null) {
+            Utils::throwError('20003:请提供document');
+        }
+        $this->checkDocumentSize($document);
+
+        $update = [
+            'name'       => $name,
+            'document'   => $document,
+            'updated_at' => date('Y-m-d H:i:s')
+        ];
+        foreach (['anime_id', 'episode_id', 'episode_number'] as $field) {
+            $value = $this->scopeProp($data, $field, null);
+            if ($value !== null) {
+                $update[$field] = $value;
+            }
+        }
 
 
-        return $this->canvasDetail(['canvas_id' => $doc['canvas_id']]);
+        DB::table('mp_canvases')->where('id', $canvas->id)->update($update);
+
+        return $this->documentResult($this->canvasKey($canvas), $name, $document);
     }
     }
 
 
     /**
     /**
@@ -241,7 +231,14 @@ class CanvasService
     {
     {
         $canvas_id = (string)getProp($data, 'canvas_id', '');
         $canvas_id = (string)getProp($data, 'canvas_id', '');
         $canvas    = $this->checkCanvas($canvas_id);
         $canvas    = $this->checkCanvas($canvas_id);
-        return $this->buildDocument($canvas);
+
+        $raw = (string)getProp($canvas, 'document', '');
+        if ($raw !== '') {
+            return $this->documentResult($this->canvasKey($canvas), (string)$canvas->name, $raw);
+        }
+
+        // 旧画布无 document 时由节点/关联表兜底构建
+        return $this->documentResult($this->canvasKey($canvas), (string)$canvas->name, $this->buildLegacyDocument($canvas));
     }
     }
 
 
     /**
     /**
@@ -578,270 +575,146 @@ class CanvasService
     }
     }
 
 
     /**
     /**
-     * 判断请求是否为完整文档创建
+     * 提取前端传入的 document(必填字段)
      *
      *
-     * @param mixed $data
-     * @return bool
+     * @param array $data
+     * @return mixed
      */
      */
-    private function isDocumentRequest($data)
+    private function extractDocument($data)
     {
     {
-        return is_array($data) && (
-            array_key_exists('graph', $data) ||
-            array_key_exists('schema_version', $data) ||
-            array_key_exists('title', $data) ||
-            array_key_exists('scope', $data) ||
-            array_key_exists('canvas_id', $data)
-        );
+        if (!is_array($data)) {
+            Utils::throwError('20003:请求体格式不正确');
+        }
+        if (!array_key_exists('document', $data)) {
+            Utils::throwError('20003:请提供document');
+        }
+        return $data['document'];
     }
     }
 
 
     /**
     /**
-     * 校验并规范化完整画布文档
+     * 规范化 document 用于数据库存储:JSON 对象转字符串;JSON 字符串原样保存;空值存 null
      *
      *
-     * @param array       $data
-     * @param bool        $isCreate
-     * @param object|null $canvas
-     * @return array
+     * @param mixed $document
+     * @return string|null
      */
      */
-    private function validateDocument($data, $isCreate, $canvas = null)
+    private function documentForStorage($document)
     {
     {
-        if (!is_array($data)) {
-            Utils::throwError('20003:请求体格式不正确');
-        }
-
-        $schema_version = (int)getProp($data, 'schema_version', self::SUPPORTED_SCHEMA_VERSION);
-        if ($schema_version !== self::SUPPORTED_SCHEMA_VERSION) {
-            Utils::throwError('20003:不支持的schema_version');
+        if ($document === null || $document === '') {
+            return null;
         }
         }
-
-        $canvas_id = (string)getProp($data, 'canvas_id', '');
-        if ($isCreate) {
-            if ($canvas_id === '') {
-                $canvas_id = $this->generateCanvasId();
-            }
-            if (mb_strlen($canvas_id) > 64) {
-                Utils::throwError('20003:canvas_id长度超出限制');
-            }
-        } else {
-            $key = $canvas ? $this->canvasKey($canvas) : '';
-            if ($canvas_id !== '' && $canvas_id !== $key && $canvas_id !== (string)getProp($canvas, 'id', 0)) {
-                Utils::throwError('20003:canvas_id与画布不一致');
+        if (is_array($document) || is_object($document)) {
+            $json = json_encode($document, JSON_UNESCAPED_UNICODE);
+            if ($json === false) {
+                Utils::throwError('20003:document格式不正确');
             }
             }
-            $canvas_id = $key;
-        }
-
-        $revision = $isCreate ? 1 : (int)getProp($data, 'revision', -1);
-        if (!$isCreate && $revision < 0) {
-            Utils::throwError('20003:缺少revision');
+            return $json;
         }
         }
-
-        $title = trim((string)getProp($data, 'title', getProp($data, 'name', '')));
-        if ($title === '') {
-            $title = '未命名画布';
-        }
-        if (mb_strlen($title) > self::MAX_TITLE_LENGTH) {
-            Utils::throwError('20003:画布标题长度超出限制');
+        if (is_string($document)) {
+            return $document;
         }
         }
+        Utils::throwError('20003:document格式不正确');
+    }
 
 
-        $scope = getProp($data, 'scope', []);
-        if (!is_array($scope)) {
-            Utils::throwError('20003:scope格式不正确');
+    /**
+     * 存储的 document 返回给前端:JSON 字符串解码为对象;非 JSON 字符串原样返回
+     *
+     * @param mixed $document
+     * @return mixed
+     */
+    private function documentForResponse($document)
+    {
+        if ($document === null || $document === '') {
+            return null;
         }
         }
-        $scope = $this->checkScope($scope);
-
-        $graph = getProp($data, 'graph', []);
-        if (!is_array($graph)) {
-            Utils::throwError('20003:graph格式不正确');
+        if (is_array($document) || is_object($document)) {
+            return $document;
         }
         }
-        $graph = $this->normalizeGraph($graph);
-
-        $preferences = getProp($data, 'preferences', []);
-        if (!is_array($preferences)) {
-            $preferences = [];
+        $raw = (string)$document;
+        $doc = json_decode($raw, true);
+        if ($doc === null && trim($raw) !== 'null') {
+            return $raw;
         }
         }
-
-        $doc = [
-            'schema_version' => self::SUPPORTED_SCHEMA_VERSION,
-            'canvas_id'      => $canvas_id,
-            'revision'       => $revision,
-            'scope'          => $scope,
-            'title'          => $title,
-            'graph'          => $graph,
-            'preferences'    => $preferences
-        ];
-
-        if (strlen(json_encode($doc, JSON_UNESCAPED_UNICODE)) > self::MAX_DOCUMENT_BYTES) {
-            Utils::throwError('20003:画布文档大小超出限制');
-        }
-        $this->checkTextAndUrlLimits($doc);
-
         return $doc;
         return $doc;
     }
     }
 
 
     /**
     /**
-     * 校验 scope 归属:动漫属于当前用户,剧集属于该动漫
+     * 组装画布文档相关返回结构:document 原样返回,canvas_id 由服务端维护
      *
      *
-     * @param array $scope
+     * @param string      $canvas_id
+     * @param mixed       $document
      * @return array
      * @return array
      */
      */
-    private function checkScope($scope)
+    private function documentResult($canvas_id, $name, $document)
     {
     {
-        $anime_id   = (int)getProp($scope, 'anime_id', 0);
-        $episode_id = (int)getProp($scope, 'episode_id', 0);
-        if (!$anime_id || !$episode_id) {
-            Utils::throwError('20003:scope.anime_id和scope.episode_id不能为空');
-        }
-
-        $anime = DB::table('mp_animes')
-            ->where('id', $anime_id)
-            ->where('user_id', Site::getUid())
-            ->where('is_deleted', 0)
-            ->first();
-        if (!$anime) {
-            Utils::throwError('20003:动漫不存在或无权访问');
-        }
-
-        $episode = DB::table('mp_anime_episodes')
-            ->where('id', $episode_id)
-            ->where('anime_id', $anime_id)
-            ->first();
-        if (!$episode) {
-            Utils::throwError('20003:剧集不存在或不属于该动漫');
-        }
-
         return [
         return [
-            'anime_id'       => $anime_id,
-            'episode_id'     => $episode_id,
-            'episode_number' => (int)$episode->episode_number
+            'canvas_id' => (string)$canvas_id,
+            'name'      => (string)$name,
+            'document'  => $this->documentForResponse($document)
         ];
         ];
     }
     }
 
 
     /**
     /**
-     * 图结构最小校验:节点/边数量、ID 唯一、边端点存在
+     * 校验 document 存储大小(仅整体大小限制,不解析内部结构)
      *
      *
-     * @param array $graph
-     * @return array
+     * @param string|null $document
+     * @return void
      */
      */
-    private function normalizeGraph($graph)
+    private function checkDocumentSize($document)
     {
     {
-        $viewport = getProp($graph, 'viewport', ['x' => 0, 'y' => 0, 'zoom' => 1]);
-        if (!is_array($viewport)) {
-            $viewport = ['x' => 0, 'y' => 0, 'zoom' => 1];
-        }
-        $nodes = getProp($graph, 'nodes', []);
-        $edges = getProp($graph, 'edges', []);
-        if (!is_array($nodes)) {
-            $nodes = [];
-        }
-        if (!is_array($edges)) {
-            $edges = [];
-        }
-
-        if (count($nodes) > self::MAX_NODES) {
-            Utils::throwError('20003:节点数超出限制');
-        }
-        if (count($edges) > self::MAX_EDGES) {
-            Utils::throwError('20003:边数超出限制');
-        }
-
-        $nodeIds = [];
-        foreach ($nodes as $node) {
-            if (!is_array($node)) {
-                Utils::throwError('20003:节点格式不正确');
-            }
-            $id = getProp($node, 'id', '');
-            if ($id === '' || $id === null || !is_scalar($id)) {
-                Utils::throwError('20003:节点ID不能为空');
-            }
-            $idKey = (string)$id;
-            if (isset($nodeIds[$idKey])) {
-                Utils::throwError('20003:节点ID重复:' . $idKey);
-            }
-            $nodeIds[$idKey] = true;
-        }
-
-        $edgeIds = [];
-        foreach ($edges as $edge) {
-            if (!is_array($edge)) {
-                Utils::throwError('20003:边格式不正确');
-            }
-            $source = (string)getProp($edge, 'source', '');
-            $target = (string)getProp($edge, 'target', '');
-            if (!isset($nodeIds[$source]) || !isset($nodeIds[$target])) {
-                Utils::throwError('20003:边端点不存在');
-            }
-
-            $edgeId = getProp($edge, 'id', '');
-            if ($edgeId !== '' && $edgeId !== null) {
-                if (!is_scalar($edgeId)) {
-                    Utils::throwError('20003:边ID格式不正确');
-                }
-                $edgeKey = (string)$edgeId;
-                if (isset($edgeIds[$edgeKey])) {
-                    Utils::throwError('20003:边ID重复:' . $edgeKey);
-                }
-                $edgeIds[$edgeKey] = true;
-            }
+        if ($document !== null && strlen($document) > self::MAX_DOCUMENT_BYTES) {
+            Utils::throwError('20003:画布文档大小超出限制');
         }
         }
-
-        return [
-            'viewport' => $viewport,
-            'nodes'    => $nodes,
-            'edges'    => $edges
-        ];
     }
     }
 
 
     /**
     /**
-     * 递归限制文档内文本与 URL 长度
+     * 读取请求中 document 相关字段(兼容 document 放在顶层或 document 字段内)
      *
      *
-     * @param mixed $value
-     * @return void
+     * @param array  $data
+     * @param string $key
+     * @param mixed  $default
+     * @return mixed
      */
      */
-    private function checkTextAndUrlLimits($value)
+    private function docProp($data, $key, $default = '')
     {
     {
-        if (is_string($value)) {
-            if (strlen($value) > self::MAX_TEXT_LENGTH) {
-                Utils::throwError('20003:文本内容长度超出限制');
-            }
-            if (preg_match('#^https?://#i', $value) && strlen($value) > self::MAX_URL_LENGTH) {
-                Utils::throwError('20003:URL长度超出限制');
-            }
-            return;
+        if (array_key_exists($key, $data)) {
+            return $data[$key];
         }
         }
-        if (!is_array($value)) {
-            return;
-        }
-        foreach ($value as $item) {
-            $this->checkTextAndUrlLimits($item);
+        $doc = getProp($data, 'document', null);
+        if (is_array($doc) && array_key_exists($key, $doc)) {
+            return $doc[$key];
         }
         }
+        return $default;
     }
     }
 
 
     /**
     /**
-     * 组装完整文档;旧画布无 document 时从节点/关联表构建
+     * 读取画布元数据列(anime_id/episode_id/episode_number),
+     * 兼容顶层字段、document 顶层字段、document.scope 三种位置;不影响 document 原样保存
      *
      *
-     * @param object $canvas
-     * @return array
+     * @param array  $data
+     * @param string $key
+     * @param mixed  $default
+     * @return int|null
      */
      */
-    private function buildDocument($canvas)
+    private function scopeProp($data, $key, $default = 0)
     {
     {
-        $raw = (string)getProp($canvas, 'document', '');
-        if ($raw !== '') {
-            $doc = json_decode($raw, true);
-            if (is_array($doc)) {
-                $doc['canvas_id']      = $this->canvasKey($canvas);
-                $doc['revision']       = (int)getProp($canvas, 'revision', 1);
-                $doc['schema_version'] = (int)getProp($canvas, 'schema_version', self::SUPPORTED_SCHEMA_VERSION);
-                $doc['scope']          = $this->scopeFromRow($canvas);
-                $doc['title']          = (string)getProp($canvas, 'name', getProp($doc, 'title', '未命名画布'));
-                if (!isset($doc['graph']) || !is_array($doc['graph'])) {
-                    $doc['graph'] = ['viewport' => ['x' => 0, 'y' => 0, 'zoom' => 1], 'nodes' => [], 'edges' => []];
-                }
-                if (!isset($doc['preferences']) || !is_array($doc['preferences'])) {
-                    $doc['preferences'] = ['minimap_visible' => true, 'snap_to_grid_enabled' => false];
-                }
-                return $doc;
+        if (array_key_exists($key, $data)) {
+            return (int)$data[$key];
+        }
+        $doc = getProp($data, 'document', null);
+        if (is_array($doc)) {
+            if (array_key_exists($key, $doc)) {
+                return (int)$doc[$key];
+            }
+            $scope = getProp($doc, 'scope', []);
+            if (is_array($scope) && array_key_exists($key, $scope)) {
+                return (int)$scope[$key];
             }
             }
         }
         }
-        return $this->buildLegacyDocument($canvas);
+        $scope = getProp($data, 'scope', null);
+        if (is_array($scope) && array_key_exists($key, $scope)) {
+            return (int)$scope[$key];
+        }
+        return $default;
     }
     }
 
 
     /**
     /**
@@ -888,9 +761,7 @@ class CanvasService
         }
         }
 
 
         return [
         return [
-            'schema_version' => self::SUPPORTED_SCHEMA_VERSION,
             'canvas_id'      => $this->canvasKey($canvas),
             'canvas_id'      => $this->canvasKey($canvas),
-            'revision'       => (int)getProp($canvas, 'revision', 1),
             'scope'          => $this->scopeFromRow($canvas),
             'scope'          => $this->scopeFromRow($canvas),
             'title'          => (string)getProp($canvas, 'name', '未命名画布'),
             'title'          => (string)getProp($canvas, 'name', '未命名画布'),
             'graph'          => [
             'graph'          => [

+ 2 - 2
app/Transformer/DeepSeek/DeepSeekTransformer.php

@@ -33,8 +33,8 @@ class DeepSeekTransformer
                     ->orderByDesc('id')
                     ->orderByDesc('id')
                     ->first();
                     ->first();
 
 
-                if ($latestTask && in_array(getProp($latestTask, 'status'), ['pending', 'processing'])) {
-                    $is_products = 2; // 资产生成中
+                if ($latestTask) {
+                    $is_products = 2; // 有生成任务但还没资产保存
                 } else {
                 } else {
                     $is_products = 0;
                     $is_products = 0;
                 }
                 }

A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 1 - 1
resources/views/animeIndex.blade.php


+ 1 - 0
routes/api.php

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