|
|
@@ -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('图片生成任务不存在', [
|