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

新增导入剧本资产功能

lh пре 1 недеља
родитељ
комит
56f3be6e8a
4 измењених фајлова са 879 додато и 4 уклоњено
  1. 42 0
      app/Http/Controllers/Anime/AnimeController.php
  2. 835 4
      app/Services/Anime/AnimeService.php
  3. 1 0
      composer.json
  4. 1 0
      routes/api.php

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

@@ -2486,6 +2486,48 @@ class AnimeController extends BaseController
     }
 
     /**
+     * 通过 Excel 批量导入剧本资产
+     * 支持 xlsx/xls,先校验 Excel 数据完整性,再执行资产创建与图片生成
+     * @param Request $request
+     * @return mixed
+     */
+    public function importScriptProductsByExcel(Request $request) {
+        // 忽略所有超时限制
+        set_time_limit(0);
+        ini_set('max_execution_time', '0');
+
+        $data = $request->all();
+        $file = $request->file('file');
+        $result = $this->AnimeService->importScriptProductsByExcel($data, $file);
+
+        // 判断是否是 SSE 流(Generator):资产图片仍在生成中,需要轮询
+        if ($result instanceof \Generator) {
+            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);
+    }
+
+    /**
      * 转让剧本归属(剧本及其关联资产,逐个剧本独立事务)
      * @param Request $request
      * @return mixed

+ 835 - 4
app/Services/Anime/AnimeService.php

@@ -21,6 +21,7 @@ use Illuminate\Support\Facades\Log;
 use Illuminate\Support\Facades\Redis;
 use OSS\Core\OssException;
 use OSS\OssClient;
+use PhpOffice\PhpSpreadsheet\IOFactory;
 
 class AnimeService
 {
@@ -8596,6 +8597,834 @@ class AnimeService
     }
 
     /**
+     * 通过 Excel 批量导入剧本资产(独立实现,不依赖 saveScriptProducts)
+     *
+     * 流程:
+     * 1. 校验剧本存在且属于当前用户
+     * 2. 解析 Excel 并校验强制列与数据完整性(资产类型/资产名称/使用范围/参考提示词)
+     * 3. 名称去重(与 Excel 内及该剧本已有关联资产比对)
+     * 4. 事务内创建类型文件夹、资产与剧本映射
+     * 5. 为没有图片的资产创建图片生成任务,生成中返回 SSE 轮询
+     *
+     * @param array $data 请求参数
+     * @param \Illuminate\Http\UploadedFile $file 上传的 Excel 文件
+     * @return array|\Generator
+     */
+    public function importScriptProductsByExcel($data, $file) {
+        $uid = Site::getUid();
+        $cpid = Site::getCpid();
+        $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)
+            ->where('user_id', $uid)
+            ->first();
+
+        if (!$script) {
+            Utils::throwError('20003:剧本不存在或无权限操作');
+        }
+
+        $script_name = $script->script_name ?? 'script_' . $script_id;
+
+        // 校验上传文件
+        if (!$file) {
+            Utils::throwError('20003:请上传Excel文件');
+        }
+
+        $extension = strtolower($file->getClientOriginalExtension());
+        if (!in_array($extension, ['xlsx', 'xls'])) {
+            Utils::throwError('20003:文件格式错误,仅支持 xlsx、xls 格式');
+        }
+
+        if ($file->getSize() > 10 * 1024 * 1024) {
+            Utils::throwError('20003:文件大小不能超过10M');
+        }
+
+        // 解析 Excel(第一行为表头)
+        $rows = $this->readImportExcelRows($file);
+        if (empty($rows)) {
+            Utils::throwError('20003:Excel内容为空');
+        }
+
+        $headers = array_shift($rows);
+
+        // 定位强制列
+        $typeColumnIndex = $this->findImportExcelColumnIndex($headers, ['资产类型'], ['资产类型', '类型']);
+        $nameColumnIndex = $this->findImportExcelColumnIndex($headers, ['资产名称'], ['资产名称', '名称']);
+        $rangeColumnIndex = $this->findImportExcelColumnIndex($headers, ['使用范围'], ['使用范围', '范围']);
+        $promptColumnIndex = $this->findImportExcelColumnIndex($headers, ['参考提示词', '提示词'], ['提示词']);
+
+        $missingColumns = [];
+        if ($typeColumnIndex < 0) {
+            $missingColumns[] = '资产类型';
+        }
+        if ($nameColumnIndex < 0) {
+            $missingColumns[] = '资产名称';
+        }
+        if ($rangeColumnIndex < 0) {
+            $missingColumns[] = '使用范围';
+        }
+        if ($promptColumnIndex < 0) {
+            $missingColumns[] = '参考提示词';
+        }
+
+        if (!empty($missingColumns)) {
+            Utils::throwError('20003:Excel缺少必填列:' . implode('、', $missingColumns));
+        }
+
+        // 先整体校验 Excel 数据,所有异常行一次性返回
+        $validation = $this->validateImportExcelRows(
+            $rows,
+            $typeColumnIndex,
+            $nameColumnIndex,
+            $rangeColumnIndex,
+            $promptColumnIndex
+        );
+        $errors = $validation['errors'];
+        $checkedRows = $validation['checked_rows'];
+
+        if (empty($checkedRows)) {
+            $errors[] = 'Excel中没有有效的数据行';
+        }
+
+        if (!empty($errors)) {
+            Utils::throwError('20003:Excel数据校验失败:' . implode(';', $errors));
+        }
+
+        // 名称去重:Excel 内部 + 该剧本已关联资产(按 createProduct 的名称判断逻辑)
+        $existingNames = $this->getScriptProductNames($script_id, $cpid);
+        $dedupResult = $this->deduplicateImportRows($checkedRows, $existingNames);
+        $validRows = $dedupResult['valid_rows'];
+        $skipped = $dedupResult['skipped'];
+
+        // 图片生成参数(与 saveScriptProducts 保持一致的校验规则)
+        $model = getProp($data, 'model');
+        if (!$model) {
+            $model = 'doubao-seedream-5-0-lite-260128';
+        }
+        if (!DB::table('mp_image_models')->where('model', $model)->where('is_enabled', 1)->exists()) {
+            Utils::throwError('20003:该模型已不可用,请更换!');
+        }
+
+        $ratio = getProp($data, 'ratio', '9:16');
+        $resolution = strtolower(getProp($data, 'resolution', '2k'));
+        $sizeMap = BaseConst::IMAGE_RESOLUTION_SIZE_MAP;
+
+        if (!isset($sizeMap[$resolution])) {
+            Utils::throwError('20003:分辨率参数错误,仅支持 2k、3k、4k');
+        }
+        if (!isset($sizeMap[$resolution][$ratio])) {
+            Utils::throwError('20003:宽高比参数错误,该分辨率下不支持 ' . $ratio . ' 比例');
+        }
+
+        $width = $sizeMap[$resolution][$ratio]['width'];
+        $height = $sizeMap[$resolution][$ratio]['height'];
+
+        $autoRaw = getProp($data, 'auto_generate_images', true);
+        $auto_generate_images = !in_array($autoRaw, [false, 0, '0', 'false', 'False'], true);
+
+        // 创建/复用任务中心记录(md5 验重,避免重复任务)
+        $taskCenterId = $this->createScriptProductTaskCenter(
+            $uid,
+            $cpid,
+            $script_id,
+            $validRows,
+            $model,
+            $ratio,
+            $resolution,
+            $auto_generate_images
+        );
+
+        // 全部为重复项:参照 saveScriptProducts,检查已存在资产的图片状态,缺图的补建任务
+        if (empty($validRows)) {
+            $existingTaskInfo = $this->prepareExistingScriptProductTasks(
+                $script_id,
+                $model,
+                $width,
+                $height,
+                $auto_generate_images
+            );
+
+            $productTaskMap = $existingTaskInfo['product_task_map'];
+            $typeFolderIds = $existingTaskInfo['type_folder_ids'];
+
+            if (!empty($productTaskMap)) {
+                $message = 'Excel中的资产均已存在,正在为缺少图片的资产生成图片';
+            } elseif (!$existingTaskInfo['has_all_tasks']) {
+                $message = 'Excel中的资产均已存在,部分资产没有参考提示词,无法生成图片';
+            } else {
+                $message = 'Excel中的资产均已存在,且图片已全部生成完成';
+            }
+
+            $resultData = [
+                'success'             => true,
+                'message'             => $message,
+                'script_id'           => $script_id,
+                'script_name'         => $script_name,
+                'imported_count'      => 0,
+                'existing_products'   => $existingTaskInfo['product_count'],
+                'skipped_count'       => count($skipped),
+                'skipped'             => $skipped,
+                'created_products'    => [],
+                'type_folder_ids'     => $typeFolderIds,
+                'pending_tasks_count' => count($productTaskMap),
+                'image_tasks_created' => $existingTaskInfo['created_count'],
+                'task_center_id'      => $taskCenterId,
+            ];
+
+            // 有需要轮询的任务(已有任务或本次补建):走 SSE 返回结果
+            if (!empty($productTaskMap)) {
+                return $this->pollProductImageTasks(
+                    $script_id,
+                    $script_name,
+                    $productTaskMap,
+                    $typeFolderIds,
+                    $taskCenterId,
+                    ['import_result' => $resultData]
+                );
+            }
+
+            // 资产图片已全部生成完成或无法生成:直接返回结果
+            if ($taskCenterId) {
+                $this->taskCenterService->updateTask($taskCenterId, [
+                    'status'        => MpTaskCenter::STATUS_SUCCESS,
+                    'result'        => json_encode($resultData, JSON_UNESCAPED_UNICODE),
+                    'error_message' => null,
+                ]);
+            }
+
+            return $resultData;
+        }
+
+        // 事务内创建资产、类型文件夹与剧本映射
+        $typeFolderIds = [];
+        $createdProducts = [];
+        $mappingRecords = [];
+        $now = now();
+
+        DB::beginTransaction();
+        try {
+            foreach ($validRows as $row) {
+                $type = $row['type'];
+
+                if (!isset($typeFolderIds[$type])) {
+                    // 与 createProduct 一致:先查找该剧本已存在的类型文件夹,没有则创建
+                    $folder = DB::table('mp_products')
+                        ->where('cpid', $cpid)
+                        ->where('type', 2)
+                        ->where('product', $type)
+                        ->where('product_name', $script_name)
+                        ->where('parent_id', 0)
+                        ->where('is_deleted', 0)
+                        ->first();
+
+                    if ($folder) {
+                        $typeFolderIds[$type] = $folder->id;
+                    } else {
+                        $typeFolderIds[$type] = DB::table('mp_products')->insertGetId([
+                            'user_id'      => $uid,
+                            'cpid'         => $cpid,
+                            'type'         => 2, // 文件夹
+                            'product'      => $type, // 1=角色 2=场景 3=道具
+                            '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];
+
+                $product_id = DB::table('mp_products')->insertGetId([
+                    'user_id'      => $uid,
+                    'cpid'         => $cpid,
+                    'type'         => 1, // 资产
+                    'product'      => $type,
+                    'parent_id'    => $folder_id,
+                    'level'        => 2,
+                    'product_name' => $row['product_name'],
+                    'pic_prompt'   => $row['pic_prompt'],
+                    'sort_order'   => time(),
+                    'is_deleted'   => 0,
+                    'created_at'   => $now,
+                    'updated_at'   => $now,
+                ]);
+
+                $createdProducts[] = [
+                    'id'           => $product_id,
+                    'type'         => $type,
+                    'product_name' => $row['product_name'],
+                    'pic_prompt'   => $row['pic_prompt'],
+                ];
+
+                // 为每个使用范围(集数)创建映射记录
+                foreach ($row['episode_numbers'] as $episodeNumber) {
+                    $mappingRecords[] = [
+                        'script_id'      => $script_id,
+                        'product_id'     => $product_id,
+                        'episode_number' => $episodeNumber,
+                        'created_at'     => $now,
+                        'updated_at'     => $now,
+                    ];
+                }
+            }
+
+            // 批量插入映射数据(使用 insertOrIgnore 避免重复插入)
+            DB::table('mp_script_product_mappings')->insertOrIgnore($mappingRecords);
+
+            // 剧本已有资产,同步更新is_products标记
+            DB::table('mp_scripts')->where('id', $script_id)->update([
+                'is_products' => 1,
+                'updated_at'  => $now,
+            ]);
+
+            DB::commit();
+        } catch (\Exception $e) {
+            DB::rollback();
+
+            if ($taskCenterId) {
+                $this->taskCenterService->updateTask($taskCenterId, [
+                    'status'        => MpTaskCenter::STATUS_FAILED,
+                    'error_message' => $e->getMessage(),
+                ]);
+            }
+
+            dLog('anime')->error('Excel导入剧本资产失败: ' . $e->getMessage(), [
+                'script_id' => $script_id,
+                'error'     => $e->getMessage(),
+                'trace'     => $e->getTraceAsString(),
+            ]);
+
+            Utils::throwError('20003:Excel导入剧本资产失败:' . $e->getMessage());
+        }
+
+        // 参照 saveScriptProducts:为新建资产以及已存在但缺图的资产统一补建图片生成任务
+        $existingTaskInfo = $this->prepareExistingScriptProductTasks(
+            $script_id,
+            $model,
+            $width,
+            $height,
+            $auto_generate_images
+        );
+
+        $productTaskMap = $existingTaskInfo['product_task_map'];
+
+        $resultData = [
+            'success'             => true,
+            'message'             => '导入完成',
+            'script_id'           => $script_id,
+            'script_name'         => $script_name,
+            'imported_count'      => count($createdProducts),
+            'skipped_count'       => count($skipped),
+            'skipped'             => $skipped,
+            'created_products'    => array_map(function ($item) {
+                return [
+                    'id'           => $item['id'],
+                    'type'         => $item['type'],
+                    'product_name' => $item['product_name'],
+                ];
+            }, $createdProducts),
+            'type_folder_ids'     => $typeFolderIds,
+            'pending_tasks_count' => count($productTaskMap),
+            'image_tasks_created' => $existingTaskInfo['created_count'],
+            'task_center_id'      => $taskCenterId,
+        ];
+
+        // 有待生成的图片任务:返回 SSE 轮询结果(导入统计放在 init 事件中)
+        if (!empty($productTaskMap)) {
+            return $this->pollProductImageTasks(
+                $script_id,
+                $script_name,
+                $productTaskMap,
+                $typeFolderIds,
+                $taskCenterId,
+                ['import_result' => $resultData]
+            );
+        }
+
+        // 所有资产都已有图片或未开启生成:直接返回结果
+        if ($taskCenterId) {
+            $this->taskCenterService->updateTask($taskCenterId, [
+                'status'        => MpTaskCenter::STATUS_SUCCESS,
+                'result'        => json_encode($resultData, JSON_UNESCAPED_UNICODE),
+                'error_message' => null,
+            ]);
+        }
+
+        return $resultData;
+    }
+
+    /**
+     * 校验 Excel 数据行(先校验完毕,再进行后续逻辑)
+     *
+     * @param array $rows
+     * @param int $typeColumnIndex
+     * @param int $nameColumnIndex
+     * @param int $rangeColumnIndex
+     * @param int $promptColumnIndex
+     * @return array ['errors' => [], 'checked_rows' => []]
+     */
+    private function validateImportExcelRows(array $rows, $typeColumnIndex, $nameColumnIndex, $rangeColumnIndex, $promptColumnIndex) {
+        $errors = [];
+        $checkedRows = [];
+
+        foreach ($rows as $rowIndex => $row) {
+            // 数据从第2行开始(表头占第1行)
+            $excelRowNo = $rowIndex + 2;
+
+            if (!is_array($row) || $this->isImportExcelRowEmpty($row)) {
+                continue;
+            }
+
+            $typeRaw = trim((string)($row[$typeColumnIndex] ?? ''));
+            $nameRaw = trim((string)($row[$nameColumnIndex] ?? ''));
+            $rangeRaw = trim((string)($row[$rangeColumnIndex] ?? ''));
+            $promptRaw = trim((string)($row[$promptColumnIndex] ?? ''));
+
+            $missingFields = [];
+            if ($typeRaw === '') {
+                $missingFields[] = '资产类型';
+            }
+            if ($nameRaw === '') {
+                $missingFields[] = '资产名称';
+            }
+            if ($rangeRaw === '') {
+                $missingFields[] = '使用范围';
+            }
+            if ($promptRaw === '') {
+                $missingFields[] = '参考提示词';
+            }
+
+            if (!empty($missingFields)) {
+                $errors[] = "第{$excelRowNo}行:" . implode('、', $missingFields) . '不能为空';
+                continue;
+            }
+
+            // 资产类型校验
+            $productType = $this->resolveImportProductType($typeRaw);
+            if (!$productType) {
+                $errors[] = "第{$excelRowNo}行:资产类型「{$typeRaw}」不正确,仅支持 角色/场景/道具";
+                continue;
+            }
+
+            // 资产名称去除两边特殊符号
+            $productName = $this->normalizeImportProductName($nameRaw);
+            if ($productName === '') {
+                $errors[] = "第{$excelRowNo}行:资产名称「{$nameRaw}」去除特殊符号后为空";
+                continue;
+            }
+
+            if (mb_strlen($productName, 'UTF-8') > 64) {
+                $errors[] = "第{$excelRowNo}行:资产名称「{$productName}」超过64个字符";
+                continue;
+            }
+
+            // 解析使用范围(逗号分隔的集数)
+            $episodeNumbers = $this->parseImportEpisodeNumbers($rangeRaw);
+            if (empty($episodeNumbers)) {
+                $errors[] = "第{$excelRowNo}行:使用范围「{$rangeRaw}」格式错误,仅支持数字或逗号分隔的数字";
+                continue;
+            }
+
+            $checkedRows[] = [
+                'row'             => $excelRowNo,
+                'type'            => $productType,
+                'product_name'    => $productName,
+                'episode_numbers' => $episodeNumbers,
+                'pic_prompt'      => $promptRaw,
+            ];
+        }
+
+        return [
+            'errors'       => $errors,
+            'checked_rows' => $checkedRows,
+        ];
+    }
+
+    /**
+     * Excel 资产名称去重:Excel 内部 + 剧本已有关联资产
+     *
+     * @param array $checkedRows
+     * @param array $existingNames 剧本已有关联资产名称集合(name => true)
+     * @return array ['valid_rows' => [], 'skipped' => []]
+     */
+    private function deduplicateImportRows(array $checkedRows, array $existingNames) {
+        $seenNames = [];
+        $validRows = [];
+        $skipped = [];
+
+        foreach ($checkedRows as $row) {
+            $nameKey = $row['product_name'];
+            $typeText = $this->getImportProductTypeText($row['type']);
+
+            if (isset($seenNames[$nameKey])) {
+                $skipped[] = [
+                    'row'          => $row['row'],
+                    'type'         => $typeText,
+                    'product_name' => $row['product_name'],
+                    'reason'       => 'Excel中资产名称重复',
+                ];
+                continue;
+            }
+
+            if (isset($existingNames[$nameKey])) {
+                $skipped[] = [
+                    'row'          => $row['row'],
+                    'type'         => $typeText,
+                    'product_name' => $row['product_name'],
+                    'reason'       => '剧本中已存在同名资产',
+                ];
+                continue;
+            }
+
+            $seenNames[$nameKey] = true;
+            $validRows[] = $row;
+        }
+
+        return [
+            'valid_rows' => $validRows,
+            'skipped'    => $skipped,
+        ];
+    }
+
+    /**
+     * 读取 Excel 文件为二维数组(第一行为表头)
+     *
+     * @param \Illuminate\Http\UploadedFile $file
+     * @return array
+     */
+    private function readImportExcelRows($file) {
+        try {
+            $reader = IOFactory::createReaderForFile($file->getRealPath());
+            $reader->setReadDataOnly(true);
+
+            $spreadsheet = $reader->load($file->getRealPath());
+            $sheet = $spreadsheet->getSheet(0);
+            $rows = $sheet->toArray(null, true, true, false);
+
+            $spreadsheet->disconnectWorksheets();
+            unset($spreadsheet);
+
+            return $rows;
+        } catch (\Exception $e) {
+            dLog('anime')->error('Excel文件解析失败', [
+                'error' => $e->getMessage(),
+            ]);
+
+            Utils::throwError('20003:Excel文件解析失败,请确认文件格式正确');
+        }
+    }
+
+    /**
+     * 查找 Excel 表头列索引(先精确匹配,再模糊匹配)
+     *
+     * @param array $headers
+     * @param array $exactKeywords
+     * @param array $fuzzyKeywords
+     * @return int
+     */
+    private function findImportExcelColumnIndex(array $headers, array $exactKeywords, array $fuzzyKeywords) {
+        foreach ($headers as $index => $header) {
+            $header = trim((string)$header);
+            if ($header === '') {
+                continue;
+            }
+
+            foreach ($exactKeywords as $keyword) {
+                if ($header === $keyword) {
+                    return $index;
+                }
+            }
+        }
+
+        foreach ($headers as $index => $header) {
+            $header = trim((string)$header);
+            if ($header === '') {
+                continue;
+            }
+
+            foreach ($fuzzyKeywords as $keyword) {
+                if (mb_strpos($header, $keyword) !== false) {
+                    return $index;
+                }
+            }
+        }
+
+        return -1;
+    }
+
+    /**
+     * 判断 Excel 行是否为空行
+     *
+     * @param array $row
+     * @return bool
+     */
+    private function isImportExcelRowEmpty(array $row) {
+        foreach ($row as $cell) {
+            if (trim((string)$cell) !== '') {
+                return false;
+            }
+        }
+
+        return true;
+    }
+
+    /**
+     * 资产名称去除两边特殊符号(与 createProduct 保持一致)
+     *
+     * @param string $name
+     * @return string
+     */
+    private function normalizeImportProductName($name) {
+        $name = trim((string)$name);
+        $name = preg_replace('/^[\s*\[\]【】[]{}{}\x{3000}]+|[\s*\[\]【】[]{}{}\x{3000}]+$/u', '', $name);
+
+        if (!$name || preg_match('/^[-—_]+$/u', $name)) {
+            return '';
+        }
+
+        return $name;
+    }
+
+    /**
+     * 解析 Excel 中的资产类型
+     *
+     * @param string $value
+     * @return int|null 1=角色 2=场景 3=道具
+     */
+    private function resolveImportProductType($value) {
+        $value = trim((string)$value);
+        $map = [
+            '角色' => 1,
+            '主体' => 1,
+            '人物' => 1,
+            '1'    => 1,
+            '场景' => 2,
+            '2'    => 2,
+            '道具' => 3,
+            '3'    => 3,
+        ];
+
+        return isset($map[$value]) ? $map[$value] : null;
+    }
+
+    /**
+     * 资产类型文案
+     *
+     * @param int $type
+     * @return string
+     */
+    private function getImportProductTypeText($type) {
+        $map = [1 => '角色', 2 => '场景', 3 => '道具'];
+
+        return isset($map[$type]) ? $map[$type] : (string)$type;
+    }
+
+    /**
+     * 解析使用范围(逗号分隔的集数)
+     *
+     * @param string $value
+     * @return array
+     */
+    private function parseImportEpisodeNumbers($value) {
+        $parts = preg_split('/[,,、\s]+/u', trim((string)$value));
+        $episodeNumbers = [];
+
+        foreach ($parts as $part) {
+            if ($part === '') {
+                continue;
+            }
+
+            if (!is_numeric($part)) {
+                return [];
+            }
+
+            $episodeNumbers[] = (int)$part;
+        }
+
+        $episodeNumbers = array_values(array_unique($episodeNumbers));
+        sort($episodeNumbers);
+
+        return $episodeNumbers;
+    }
+
+    /**
+     * 获取剧本已关联资产名称集合(按 createProduct 的名称判断逻辑)
+     *
+     * @param int $script_id
+     * @param int $cpid
+     * @return array
+     */
+    private function getScriptProductNames($script_id, $cpid) {
+        $products = DB::table('mp_script_product_mappings as m')
+            ->join('mp_products as p', 'm.product_id', '=', 'p.id')
+            ->where('m.script_id', $script_id)
+            ->where('p.cpid', $cpid)
+            ->where('p.type', 1)
+            ->where('p.is_deleted', 0)
+            ->get(['p.product_name']);
+
+        $names = [];
+        foreach ($products as $product) {
+            $name = $this->normalizeImportProductName($product->product_name);
+            if ($name !== '') {
+                $names[$name] = true;
+            }
+        }
+
+        return $names;
+    }
+
+    /**
+     * 已有关联资产的图片任务处理(参照 saveScriptProducts 的处理方式)
+     *
+     * - 已有图片任务的资产:收集任务ID,交给轮询
+     * - 已有图片且生成成功的资产:跳过
+     * - 没有图片的资产:允许自动生成时补建图片任务(需要提示词)
+     *
+     * @param int $script_id
+     * @param string $model
+     * @param int $width
+     * @param int $height
+     * @param bool $auto_generate_images
+     * @return array ['product_task_map' => [], 'type_folder_ids' => [], 'has_all_tasks' => bool, 'product_count' => int, 'created_count' => int]
+     */
+    private function prepareExistingScriptProductTasks($script_id, $model, $width, $height, $auto_generate_images) {
+        $productTaskMap = [];
+        $typeFolderIds = [];
+        $hasAllTasks = true;
+        $createdCount = 0;
+
+        $existingMappings = DB::table('mp_script_product_mappings')
+            ->where('script_id', $script_id)
+            ->get();
+
+        $productIds = $existingMappings->pluck('product_id')->unique()->toArray();
+
+        $productsWithTasks = DB::table('mp_products')
+            ->whereIn('id', $productIds)
+            ->where('is_deleted', 0)
+            ->get();
+
+        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;
+            } elseif ($product->url && $product->pic_task_status == '生成成功') {
+                continue;
+            } else {
+                // 有资产没有图片
+                $hasAllTasks = false;
+            }
+        }
+
+        // 为没有图片的资产补建图片生成任务
+        if (!$hasAllTasks && $auto_generate_images && $productsWithTasks->isNotEmpty()) {
+            DB::beginTransaction();
+
+            try {
+                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 {
+                        $task = $this->aiImageGenerationService->createImageGenerationTask([
+                            'prompt'       => $product->pic_prompt,
+                            'model'        => $model,
+                            'ref_img_urls' => [],
+                            'width'        => $width,
+                            'height'       => $height,
+                        ]);
+
+                        $task_id = $task->id;
+                        if ($task_id) {
+                            $productTaskMap[$product->id] = $task_id;
+                            $createdCount++;
+
+                            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();
+            } catch (\Exception $e) {
+                DB::rollback();
+
+                dLog('anime')->error('为已存在资产创建图片任务失败', [
+                    'script_id' => $script_id,
+                    'error'     => $e->getMessage(),
+                ]);
+
+                Utils::throwError('20003:创建图片任务失败:' . $e->getMessage());
+            }
+        }
+
+        return [
+            'product_task_map' => $productTaskMap,
+            'type_folder_ids'  => $typeFolderIds,
+            'has_all_tasks'    => $hasAllTasks,
+            'product_count'    => count($productIds),
+            'created_count'    => $createdCount,
+        ];
+    }
+
+    /**
      * 保存剧本资产关联关系
      * @param array $data 请求参数
      * @return bool
@@ -9391,9 +10220,11 @@ class AnimeService
      * @param string $script_name 剧本名称
      * @param array $productTaskMap 资产ID到任务ID的映射
      * @param array $type_folder_ids 类型文件夹ID映射
+     * @param int|null $taskCenterId 任务中心ID
+     * @param array $initExtra 追加到 init 事件中的额外数据
      * @return \Generator
      */
-    private function pollProductImageTasks($script_id, $script_name, $productTaskMap, $type_folder_ids, $taskCenterId = null) {
+    private function pollProductImageTasks($script_id, $script_name, $productTaskMap, $type_folder_ids, $taskCenterId = null, array $initExtra = []) {
         $startTime = time();
         $timeout = 1800; // 30分钟超时
         $pollInterval = 10; // 10秒轮询一次
@@ -9401,13 +10232,13 @@ class AnimeService
 
         // 流一开始即返回 init 事件(saveScriptProducts 场景下携带任务中心ID)
         if ($taskCenterId) {
-            $initResponse = [
+            $initResponse = array_merge([
                 'type'           => 'init',
                 'script_id'      => $script_id,
                 'script_name'    => $script_name,
                 'timestamp'      => date('Y-m-d H:i:s'),
                 'task_center_id' => (int)$taskCenterId,
-            ];
+            ], $initExtra);
 
             yield "data: " . json_encode($initResponse, JSON_UNESCAPED_UNICODE) . "\n\n";
         }
@@ -9538,7 +10369,7 @@ class AnimeService
                                 'updated_at' => now()
                             ]);
                             
-                        $stats[$type]['completed']++;
+                        $stats[$typeName]['completed']++;
                         $hasStatusChange = true;
                         
                         dLog('anime')->error('图片生成任务不存在', [

+ 1 - 0
composer.json

@@ -20,6 +20,7 @@
 		"laravel/framework": "^8.75",
 		"laravel/sanctum": "^2.11",
 		"laravel/tinker": "^2.5",
+		"phpoffice/phpspreadsheet": "^1.29",
 		"phpoffice/phpword": "^1.4",
 		"pimple/pimple": "^3.0",
 		"predis/predis": "^2.1",

+ 1 - 0
routes/api.php

@@ -298,6 +298,7 @@ Route::group(['middleware' => ['bindToken', 'bindExportToken', 'checkLogin']], f
             
             // 剧本资产关联管理
             Route::post('saveScriptProducts', [AnimeController::class, 'saveScriptProducts']);     // 保存剧本资产
+            Route::post('importScriptProductsByExcel', [AnimeController::class, 'importScriptProductsByExcel']); // Excel导入剧本资产
             Route::post('transferScriptOwner', [AnimeController::class, 'transferScriptOwner']);  // 转让剧本归属(剧本+关联资产)
             Route::get('getScriptProducts', [AnimeController::class, 'getScriptProducts']);        // 获取剧本资产
             Route::get('getScriptTotalEpisodes', [AnimeController::class, 'getScriptTotalEpisodes']);   // 获取剧本总集数