|
|
@@ -7,6 +7,7 @@ use App\Consts\ErrorConst;
|
|
|
use App\Facade\Site;
|
|
|
use App\Libs\Utils;
|
|
|
use App\Models\MpGeneratePicTask;
|
|
|
+use App\Models\MpTaskCenter;
|
|
|
use App\Services\AIGeneration\AIImageGenerationService;
|
|
|
use App\Services\AIGeneration\AIVideoGenerationService;
|
|
|
use App\Services\DeepSeek\DeepSeekService;
|
|
|
@@ -8223,6 +8224,18 @@ class AnimeService
|
|
|
|
|
|
$script_name = $script->script_name ?? 'script_' . $script_id;
|
|
|
|
|
|
+ // 根据入参创建/复用任务中心记录(params md5 验重,避免重复任务)
|
|
|
+ $taskCenterId = $this->createScriptProductTaskCenter(
|
|
|
+ $uid,
|
|
|
+ $cpid,
|
|
|
+ $script_id,
|
|
|
+ $products,
|
|
|
+ $model,
|
|
|
+ $ratio,
|
|
|
+ $resolution,
|
|
|
+ $auto_generate_images
|
|
|
+ );
|
|
|
+
|
|
|
// 检查是否已经有该剧本的资产数据
|
|
|
$existingMappings = DB::table('mp_script_product_mappings')
|
|
|
->where('script_id', $script_id)
|
|
|
@@ -8275,7 +8288,7 @@ class AnimeService
|
|
|
]);
|
|
|
|
|
|
// 直接返回 SSE 流轮询结果
|
|
|
- return $this->pollProductImageTasks($script_id, $script_name, $productTaskMap, $typeFolderIds);
|
|
|
+ return $this->pollProductImageTasks($script_id, $script_name, $productTaskMap, $typeFolderIds, $taskCenterId);
|
|
|
}
|
|
|
|
|
|
// 如果部分资产没有任务ID,只为这些资产创建任务
|
|
|
@@ -8356,7 +8369,7 @@ class AnimeService
|
|
|
|
|
|
// 如果有新创建的任务,返回 SSE 流
|
|
|
if (!empty($productTaskMap)) {
|
|
|
- return $this->pollProductImageTasks($script_id, $script_name, $productTaskMap, $typeFolderIds);
|
|
|
+ return $this->pollProductImageTasks($script_id, $script_name, $productTaskMap, $typeFolderIds, $taskCenterId);
|
|
|
}
|
|
|
|
|
|
} catch (\Exception $e) {
|
|
|
@@ -8373,15 +8386,27 @@ class AnimeService
|
|
|
}
|
|
|
|
|
|
// 如果不需要生成图片,返回已存在的信息
|
|
|
- return [
|
|
|
+ $resultData = [
|
|
|
'success' => true,
|
|
|
'message' => '剧本资产已存在',
|
|
|
'script_id' => $script_id,
|
|
|
'script_name' => $script_name,
|
|
|
'type_folder_ids' => $typeFolderIds,
|
|
|
'existing_products' => count($productIds),
|
|
|
- 'tasks_count' => count($productTaskMap)
|
|
|
+ 'tasks_count' => count($productTaskMap),
|
|
|
+ 'task_center_id' => $taskCenterId
|
|
|
];
|
|
|
+
|
|
|
+ // 接口已正常完成,更新任务中心状态和结果
|
|
|
+ if ($taskCenterId) {
|
|
|
+ $this->taskCenterService->updateTask($taskCenterId, [
|
|
|
+ 'status' => MpTaskCenter::STATUS_SUCCESS,
|
|
|
+ 'result' => json_encode($resultData, JSON_UNESCAPED_UNICODE),
|
|
|
+ 'error_message' => null
|
|
|
+ ]);
|
|
|
+ }
|
|
|
+
|
|
|
+ return $resultData;
|
|
|
}
|
|
|
|
|
|
// 解析 products(可能是 JSON 字符串或数组)
|
|
|
@@ -8644,23 +8669,43 @@ class AnimeService
|
|
|
// 事务提交成功后,如果有图片生成任务,则返回 SSE 流
|
|
|
if ($auto_generate_images && !empty($productTaskMap)) {
|
|
|
// 返回 SSE 流(事务外轮询)
|
|
|
- return $this->pollProductImageTasks($script_id, $script_name, $productTaskMap, $typeFolderIds);
|
|
|
+ return $this->pollProductImageTasks($script_id, $script_name, $productTaskMap, $typeFolderIds, $taskCenterId);
|
|
|
}
|
|
|
|
|
|
// 如果不需要生成图片,返回普通结果
|
|
|
- return [
|
|
|
+ $resultData = [
|
|
|
'success' => true,
|
|
|
'type_folder_ids' => $typeFolderIds,
|
|
|
'inserted_count' => $insertedCount,
|
|
|
'total_records' => count($mappingRecords),
|
|
|
'created_products' => count($createdProductIds),
|
|
|
- 'image_tasks_created' => count($productTaskMap)
|
|
|
+ 'image_tasks_created' => count($productTaskMap),
|
|
|
+ 'task_center_id' => $taskCenterId
|
|
|
];
|
|
|
+
|
|
|
+ // 接口已正常完成,更新任务中心状态和结果
|
|
|
+ if ($taskCenterId) {
|
|
|
+ $this->taskCenterService->updateTask($taskCenterId, [
|
|
|
+ 'status' => MpTaskCenter::STATUS_SUCCESS,
|
|
|
+ 'result' => json_encode($resultData, JSON_UNESCAPED_UNICODE),
|
|
|
+ 'error_message' => null
|
|
|
+ ]);
|
|
|
+ }
|
|
|
+
|
|
|
+ return $resultData;
|
|
|
|
|
|
} catch (\Exception $e) {
|
|
|
// 回滚事务
|
|
|
DB::rollback();
|
|
|
|
|
|
+ // 更新任务中心状态为失败
|
|
|
+ if (!empty($taskCenterId)) {
|
|
|
+ $this->taskCenterService->updateTask($taskCenterId, [
|
|
|
+ 'status' => MpTaskCenter::STATUS_FAILED,
|
|
|
+ 'error_message' => $e->getMessage()
|
|
|
+ ]);
|
|
|
+ }
|
|
|
+
|
|
|
// 记录错误日志
|
|
|
dLog('anime')->error('保存剧本资产失败: ' . $e->getMessage(), [
|
|
|
'script_id' => $script_id,
|
|
|
@@ -8674,6 +8719,138 @@ class AnimeService
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
+ * 根据入参创建或复用任务中心记录
|
|
|
+ *
|
|
|
+ * 以规范化入参计算 md5 并保存到任务中心 params 字段中用于验重:
|
|
|
+ * 同一用户/企业下,相同入参且任务未结束(pending/processing)时复用已有任务,
|
|
|
+ * 避免生成重复任务。
|
|
|
+ *
|
|
|
+ * @param int|string $uid
|
|
|
+ * @param int|string $cpid
|
|
|
+ * @param int|string $script_id
|
|
|
+ * @param mixed $products
|
|
|
+ * @param string $model
|
|
|
+ * @param string $ratio
|
|
|
+ * @param string $resolution
|
|
|
+ * @param mixed $auto_generate_images
|
|
|
+ * @return int 任务中心ID
|
|
|
+ */
|
|
|
+ private function createScriptProductTaskCenter($uid, $cpid, $script_id, $products, $model, $ratio, $resolution, $auto_generate_images) {
|
|
|
+ // 规范化 products(兼容 JSON 字符串和数组),保证 md5 稳定
|
|
|
+ if (is_string($products)) {
|
|
|
+ $decoded = json_decode($products, true);
|
|
|
+ if (json_last_error() === JSON_ERROR_NONE) {
|
|
|
+ $products = $decoded;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ $params = [
|
|
|
+ 'script_id' => (int)$script_id,
|
|
|
+ 'products' => $products,
|
|
|
+ 'model' => $model,
|
|
|
+ 'ratio' => $ratio,
|
|
|
+ 'resolution' => $resolution,
|
|
|
+ 'auto_generate_images' => (bool)$auto_generate_images,
|
|
|
+ ];
|
|
|
+
|
|
|
+ $paramsMd5 = md5(json_encode($params, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
|
|
+
|
|
|
+ // 验重:相同 md5 且任务未结束(pending/processing)时复用已有任务中心记录
|
|
|
+ $existing = DB::table('mp_task_center')
|
|
|
+ ->where('uid', (int)$uid)
|
|
|
+ ->where('cpid', (int)$cpid)
|
|
|
+ ->where('task_type', MpTaskCenter::TYPE_SAVE_SCRIPT_PRODUCTS)
|
|
|
+ ->whereIn('status', [MpTaskCenter::STATUS_PENDING, MpTaskCenter::STATUS_PROCESSING])
|
|
|
+ ->where('params_md5', $paramsMd5)
|
|
|
+ ->orderBy('id', 'desc')
|
|
|
+ ->first();
|
|
|
+
|
|
|
+ if ($existing) {
|
|
|
+ dLog('anime')->info('复用已有任务中心记录,避免重复任务', [
|
|
|
+ 'script_id' => $script_id,
|
|
|
+ 'task_center_id' => $existing->id,
|
|
|
+ 'params_md5' => $paramsMd5,
|
|
|
+ ]);
|
|
|
+
|
|
|
+ return (int)$existing->id;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 唯一索引 (uid, cpid, task_type, params_md5) 兜底并发重复,冲突时静默忽略
|
|
|
+ $now = date('Y-m-d H:i:s');
|
|
|
+ DB::table('mp_task_center')->insertOrIgnore([
|
|
|
+ 'uid' => (int)$uid,
|
|
|
+ 'cpid' => (int)$cpid,
|
|
|
+ 'task_type' => MpTaskCenter::TYPE_SAVE_SCRIPT_PRODUCTS,
|
|
|
+ 'title' => '剧本资产图片生成',
|
|
|
+ 'ref_task_id' => 0,
|
|
|
+ 'status' => MpTaskCenter::STATUS_PROCESSING,
|
|
|
+ 'result' => null,
|
|
|
+ 'error_message' => null,
|
|
|
+ 'prompt' => null,
|
|
|
+ 'params' => json_encode($params + ['params_md5' => $paramsMd5], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
|
|
+ 'params_md5' => $paramsMd5,
|
|
|
+ 'created_at' => $now,
|
|
|
+ 'updated_at' => $now,
|
|
|
+ ]);
|
|
|
+
|
|
|
+ // 获取任务中心ID(insertOrIgnore 冲突时复用并发插入的已有记录)
|
|
|
+ $taskId = (int)DB::table('mp_task_center')
|
|
|
+ ->where('uid', (int)$uid)
|
|
|
+ ->where('cpid', (int)$cpid)
|
|
|
+ ->where('task_type', MpTaskCenter::TYPE_SAVE_SCRIPT_PRODUCTS)
|
|
|
+ ->where('params_md5', $paramsMd5)
|
|
|
+ ->orderBy('id', 'desc')
|
|
|
+ ->value('id');
|
|
|
+
|
|
|
+ dLog('anime')->info('创建任务中心记录', [
|
|
|
+ 'script_id' => $script_id,
|
|
|
+ 'task_center_id' => $taskId,
|
|
|
+ 'params_md5' => $paramsMd5,
|
|
|
+ ]);
|
|
|
+
|
|
|
+ return $taskId;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 任务结束时更新任务中心记录的状态和结果
|
|
|
+ *
|
|
|
+ * result 字段保存与 saveScriptProducts 接口返回给客户端一致的结果数据
|
|
|
+ * (普通数组返回为完整返回数组,SSE 轮询为 complete/timeout 事件载荷)。
|
|
|
+ *
|
|
|
+ * @param int|null $taskCenterId
|
|
|
+ * @param array $resultData 接口返回结果数组
|
|
|
+ * @param bool $timeout 是否超时未完成
|
|
|
+ */
|
|
|
+ private function finishScriptProductTask($taskCenterId, array $resultData, $timeout = false) {
|
|
|
+ if (!$taskCenterId) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ $stats = $resultData['stats'] ?? [];
|
|
|
+ $successCount = (int)array_sum(array_column((array)$stats, 'success'));
|
|
|
+ $completedCount = (int)array_sum(array_column((array)$stats, 'completed'));
|
|
|
+ $failedCount = $completedCount - $successCount;
|
|
|
+
|
|
|
+ // 与接口返回给客户端的结果保持一致
|
|
|
+ $result = json_encode($resultData, JSON_UNESCAPED_UNICODE);
|
|
|
+
|
|
|
+ if ($timeout) {
|
|
|
+ $this->taskCenterService->updateTask((int)$taskCenterId, [
|
|
|
+ 'status' => MpTaskCenter::STATUS_FAILED,
|
|
|
+ 'result' => $result,
|
|
|
+ 'error_message' => '部分任务超时,请稍后查看结果',
|
|
|
+ ]);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ $this->taskCenterService->updateTask((int)$taskCenterId, [
|
|
|
+ 'status' => $failedCount > 0 ? MpTaskCenter::STATUS_FAILED : MpTaskCenter::STATUS_SUCCESS,
|
|
|
+ 'result' => $result,
|
|
|
+ 'error_message' => $failedCount > 0 ? ('有 ' . $failedCount . ' 个资产生成失败') : null,
|
|
|
+ ]);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
* 批量为剧本资产生成图片
|
|
|
*
|
|
|
* @param array $data 请求参数
|
|
|
@@ -8787,7 +8964,7 @@ class AnimeService
|
|
|
* @param array $type_folder_ids 类型文件夹ID映射
|
|
|
* @return \Generator
|
|
|
*/
|
|
|
- private function pollProductImageTasks($script_id, $script_name, $productTaskMap, $type_folder_ids) {
|
|
|
+ private function pollProductImageTasks($script_id, $script_name, $productTaskMap, $type_folder_ids, $taskCenterId = null) {
|
|
|
$startTime = time();
|
|
|
$timeout = 1800; // 30分钟超时
|
|
|
$pollInterval = 10; // 10秒轮询一次
|
|
|
@@ -8956,6 +9133,9 @@ class AnimeService
|
|
|
'stats' => $stats,
|
|
|
'timestamp' => date('Y-m-d H:i:s')
|
|
|
];
|
|
|
+ if ($taskCenterId) {
|
|
|
+ $response['task_center_id'] = $taskCenterId;
|
|
|
+ }
|
|
|
|
|
|
yield "data: " . json_encode($response, JSON_UNESCAPED_UNICODE) . "\n\n";
|
|
|
|
|
|
@@ -8972,6 +9152,10 @@ class AnimeService
|
|
|
'script_name' => $script_name,
|
|
|
'stats' => $stats
|
|
|
]);
|
|
|
+
|
|
|
+ // 全部任务完成,更新任务中心状态和结果
|
|
|
+ $this->finishScriptProductTask($taskCenterId, $response);
|
|
|
+
|
|
|
break;
|
|
|
}
|
|
|
}
|
|
|
@@ -8986,6 +9170,9 @@ class AnimeService
|
|
|
'script_name' => $script_name,
|
|
|
'timestamp' => date('Y-m-d H:i:s')
|
|
|
];
|
|
|
+ if ($taskCenterId) {
|
|
|
+ $response['task_center_id'] = $taskCenterId;
|
|
|
+ }
|
|
|
|
|
|
yield "data: " . json_encode($response, JSON_UNESCAPED_UNICODE) . "\n\n";
|
|
|
|
|
|
@@ -8994,6 +9181,9 @@ class AnimeService
|
|
|
'script_name' => $script_name,
|
|
|
'timeout' => $timeout
|
|
|
]);
|
|
|
+
|
|
|
+ // 超时未完成,更新任务中心状态和结果
|
|
|
+ $this->finishScriptProductTask($taskCenterId, $response, true);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -9102,6 +9292,120 @@ class AnimeService
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
+ * 定时任务:检查任务中心中 saveScriptProducts 类型的任务是否已完成
|
|
|
+ *
|
|
|
+ * 当保存剧本资产的 SSE 连接中断等原因导致任务中心记录未及时更新时,
|
|
|
+ * 通过关联资产的图片生成状态兜底判断任务是否全部完成;
|
|
|
+ * 全部完成后更新任务中心的状态和结果(result 与 saveScriptProducts 接口返回保持一致)。
|
|
|
+ *
|
|
|
+ * @return int 更新的任务中心记录数
|
|
|
+ */
|
|
|
+ public function checkSaveScriptProductTaskCenters() {
|
|
|
+ $updated = 0;
|
|
|
+
|
|
|
+ // 只扫描尚未结束的 saveScriptProducts 任务
|
|
|
+ $tasks = DB::table('mp_task_center')
|
|
|
+ ->where('task_type', MpTaskCenter::TYPE_SAVE_SCRIPT_PRODUCTS)
|
|
|
+ ->whereIn('status', [MpTaskCenter::STATUS_PENDING, MpTaskCenter::STATUS_PROCESSING])
|
|
|
+ ->orderBy('id', 'desc')
|
|
|
+ ->limit(500)
|
|
|
+ ->get();
|
|
|
+
|
|
|
+ // 与 pollProductImageTasks 保持一致的类型名称映射
|
|
|
+ $typeNames = [
|
|
|
+ 1 => 'roles', // 角色
|
|
|
+ 2 => 'scenes', // 场景
|
|
|
+ 3 => 'props', // 道具
|
|
|
+ ];
|
|
|
+
|
|
|
+ foreach ($tasks as $task) {
|
|
|
+ try {
|
|
|
+ $taskParams = json_decode((string)$task->params, true);
|
|
|
+ $script_id = is_array($taskParams) ? (int)($taskParams['script_id'] ?? 0) : 0;
|
|
|
+
|
|
|
+ if (!$script_id) {
|
|
|
+ dLog('command')->warning('saveScriptProducts任务缺少script_id,跳过', [
|
|
|
+ 'task_center_id' => $task->id,
|
|
|
+ ]);
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 获取该剧本关联的全部资产ID
|
|
|
+ $productIds = DB::table('mp_script_product_mappings')
|
|
|
+ ->where('script_id', $script_id)
|
|
|
+ ->pluck('product_id')
|
|
|
+ ->unique()
|
|
|
+ ->toArray();
|
|
|
+
|
|
|
+ if (empty($productIds)) {
|
|
|
+ // 剧本没有关联资产,不视为完成,等待下一次检查
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ $products = DB::table('mp_products')
|
|
|
+ ->whereIn('id', $productIds)
|
|
|
+ ->where('is_deleted', 0)
|
|
|
+ ->get();
|
|
|
+
|
|
|
+ $stats = [];
|
|
|
+ $allCompleted = true;
|
|
|
+
|
|
|
+ foreach ($products as $product) {
|
|
|
+ $typeName = $typeNames[$product->product] ?? 'unknown';
|
|
|
+ if (!isset($stats[$typeName])) {
|
|
|
+ $stats[$typeName] = ['total' => 0, 'success' => 0, 'completed' => 0];
|
|
|
+ }
|
|
|
+ $stats[$typeName]['total']++;
|
|
|
+
|
|
|
+ if ($product->pic_task_status === '生成成功') {
|
|
|
+ $stats[$typeName]['success']++;
|
|
|
+ $stats[$typeName]['completed']++;
|
|
|
+ } elseif ($product->pic_task_status === '生成失败') {
|
|
|
+ $stats[$typeName]['completed']++;
|
|
|
+ } else {
|
|
|
+ // 仍有资产在生成中或未生成,任务未完成
|
|
|
+ $allCompleted = false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!$allCompleted) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 全部完成,更新任务中心状态和结果(与接口返回结果保持一致)
|
|
|
+ $script = DB::table('mp_scripts')
|
|
|
+ ->where('id', $script_id)
|
|
|
+ ->where('is_deleted', 0)
|
|
|
+ ->first();
|
|
|
+ $script_name = $script->script_name ?? 'script_' . $script_id;
|
|
|
+
|
|
|
+ $this->finishScriptProductTask($task->id, [
|
|
|
+ 'type' => 'complete',
|
|
|
+ 'script_id' => $script_id,
|
|
|
+ 'script_name' => $script_name,
|
|
|
+ 'stats' => $stats,
|
|
|
+ 'timestamp' => date('Y-m-d H:i:s'),
|
|
|
+ 'task_center_id' => (int)$task->id,
|
|
|
+ ]);
|
|
|
+ $updated++;
|
|
|
+
|
|
|
+ dLog('command')->info('saveScriptProducts任务已完成,更新任务中心', [
|
|
|
+ 'task_center_id' => $task->id,
|
|
|
+ 'script_id' => $script_id,
|
|
|
+ 'stats' => $stats,
|
|
|
+ ]);
|
|
|
+ } catch (\Exception $e) {
|
|
|
+ dLog('command')->error('检查saveScriptProducts任务中心状态异常', [
|
|
|
+ 'task_center_id' => $task->id,
|
|
|
+ 'error' => $e->getMessage(),
|
|
|
+ ]);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return $updated;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
* 获取剧本关联的资产列表
|
|
|
* @param array $data 请求参数
|
|
|
* @return array
|