1)返回 task_id,请轮询 getStatus 接口获取结果 * * 模式说明(由参考图数量 + 生成数量自动判定): * - 0 张参考 + 1 张输出 → text_to_image 文生单图 * - 0 张参考 + N 张输出 → text_to_images 文生组图 * - 1 张参考 + 1 张输出 → image_to_image 单图生单图 * - 1 张参考 + N 张输出 → image_to_images 单图生组图 * - M 张参考 + N 张输出 → images_to_images 组图生组图 * * AI 调用成功后会写入 picture_storage(img_id、prompt、reference_img_url 等;generate_img_url 暂不写入) */ public function generate() { set_time_limit(600); ini_set('max_execution_time', '600'); try { $params = $this->mergeRequestParams(); $prompt = trim((string)($params['prompt'] ?? '')); if ($prompt === '') { return json(['code' => 0, 'msg' => 'prompt 不能为空']); } $referenceImages = $this->resolveReferenceImages($params); $referenceCount = count($referenceImages); $genConfig = $this->buildGenerationConfig($params, $referenceCount); $model = trim((string)($params['model'] ?? self::DEFAULT_MODEL)); $stream = filter_var($params['stream'] ?? false, FILTER_VALIDATE_BOOLEAN); $watermark = filter_var($params['watermark'] ?? false, FILTER_VALIDATE_BOOLEAN); $size = trim((string)($params['size'] ?? '2K')); $requestBody = [ 'model' => $model, 'prompt' => $prompt, 'sequential_image_generation' => $genConfig['sequential_image_generation'], 'response_format' => 'url', 'size' => $size, 'stream' => $stream, 'watermark' => $watermark, ]; if ($genConfig['sequential_image_generation'] !== 'disabled') { $requestBody['sequential_image_generation_options'] = [ 'max_images' => $genConfig['max_images'], ]; } if ($referenceCount > 0) { $requestBody['image'] = $referenceCount === 1 ? $referenceImages[0] : $referenceImages; } $taskPayload = [ 'prompt' => $prompt, 'model' => $model, 'size' => $size, 'watermark' => $watermark, 'stream' => $stream, 'reference_images' => $referenceImages, 'gen_config' => $genConfig, 'request_body' => $requestBody, ]; if ($genConfig['max_images'] > 1) { return $this->submitBatchTask($taskPayload, $genConfig, $referenceImages, $referenceCount); } $payload = SeedreamImageGenerationJob::execute($taskPayload); $dbError = (string)($payload['db_save_error'] ?? ''); unset($payload['db_save_error']); return $this->jsonGenerateSuccess($payload, $dbError); } catch (\Exception $e) { $this->logError('[ImageGeneration/generate] ' . $e->getMessage()); return json(['code' => 0, 'msg' => $e->getMessage()]); } } /** * 查询组图异步任务状态 * GET/POST /api/image_generation/getStatus * 参数:task_id */ public function getStatus() { $taskId = trim((string)$this->request->param('task_id', '')); if ($taskId === '') { return json(['code' => 0, 'msg' => 'task_id 不能为空']); } $redis = getTaskRedis(); $raw = $redis->get(SeedreamImageGenerationJob::REDIS_KEY_PREFIX . $taskId); if (!$raw) { return json(['code' => 0, 'msg' => '任务不存在或已过期']); } $info = json_decode($raw, true); if (!is_array($info)) { return json(['code' => 0, 'msg' => '任务数据异常']); } return json([ 'code' => 1, 'msg' => '查询成功', 'data' => $info, ]); } /** * 组图任务提交到独立队列 seedreamimage */ private function submitBatchTask( array $taskPayload, array $genConfig, array $referenceImages, int $referenceCount ): \think\response\Json { $taskId = 'sd' . date('YmdHis') . mt_rand(1000, 9999); $taskPayload['task_id'] = $taskId; $taskPayload['stream'] = false; $redis = getTaskRedis(); $redis->set( SeedreamImageGenerationJob::REDIS_KEY_PREFIX . $taskId, json_encode([ 'status' => 'pending', 'task_id' => $taskId, 'mode' => $genConfig['mode'], 'reference_count' => $referenceCount, 'reference_images' => $referenceImages, 'max_images' => $genConfig['max_images'], 'created_at' => date('Y-m-d H:i:s'), ], JSON_UNESCAPED_UNICODE), ['EX' => SeedreamImageGenerationJob::TASK_TTL] ); Queue::push('app\job\SeedreamImageGenerationJob', $taskPayload, SeedreamImageGenerationJob::QUEUE_NAME); return json([ 'code' => 1, 'msg' => '组图任务已提交,请稍后查询结果', 'data' => [ 'async' => true, 'task_id' => $taskId, 'status' => 'pending', 'mode' => $genConfig['mode'], 'reference_count' => $referenceCount, 'reference_images' => $referenceImages, 'max_images' => $genConfig['max_images'], 'poll_url' => '/api/image_generation/getStatus', ], ]); } /** * AI 成功但数据库失败时,仍返回生成结果并附带警告 */ private function jsonGenerateSuccess(array $payload, string $dbError = ''): \think\response\Json { if ($dbError !== '') { $payload['db_save_error'] = $dbError; } return json([ 'code' => 1, 'msg' => $dbError !== '' ? '生成成功,但数据库保存失败' : '生成成功', 'data' => $payload, ]); } /** * 根据前端参数构建组图配置 */ private function buildGenerationConfig(array $params, int $referenceCount): array { $maxImages = (int)($params['max_images'] ?? $params['generate_count'] ?? $params['output_count'] ?? 1); $maxImages = max(1, min(15, $maxImages)); $sequentialMode = trim((string)($params['sequential_image_generation'] ?? '')); if ($sequentialMode === '') { $sequentialMode = $maxImages > 1 ? 'auto' : 'disabled'; } return [ 'max_images' => $maxImages, 'sequential_image_generation' => $sequentialMode, 'mode' => $this->detectGenerationMode($referenceCount, $maxImages), ]; } /** * 判定当前生成模式 */ private function detectGenerationMode(int $referenceCount, int $maxImages): string { if ($referenceCount === 0) { return $maxImages > 1 ? 'text_to_images' : 'text_to_image'; } if ($referenceCount === 1) { return $maxImages > 1 ? 'image_to_images' : 'image_to_image'; } return $maxImages > 1 ? 'images_to_images' : 'images_to_image'; } /** * 解析参考图列表:上传文件/base64 先落 OSS,再以公网 URL 传给 AI * @return string[] */ private function resolveReferenceImages(array $params): array { $resolved = []; foreach (['image', 'images'] as $field) { $uploaded = $this->request->file($field); if (empty($uploaded)) { continue; } $fileList = is_array($uploaded) ? $uploaded : [$uploaded]; foreach ($fileList as $file) { if (!$file) { continue; } $ossUrl = $this->uploadUploadedFileToOss($file); if ($ossUrl !== '') { $resolved[] = $ossUrl; } } } $rawList = []; foreach (['images', 'reference_images', 'image_urls'] as $key) { if (empty($params[$key]) || !is_array($params[$key])) { continue; } foreach ($params[$key] as $item) { if ($item !== null && $item !== '') { $rawList[] = $item; } } } foreach (['image', 'image_url'] as $key) { if (empty($params[$key])) { continue; } if (is_array($params[$key])) { foreach ($params[$key] as $item) { if ($item !== null && $item !== '') { $rawList[] = $item; } } } else { $rawList[] = $params[$key]; } } foreach ($rawList as $raw) { $item = $this->resolveSingleReferenceImage((string)$raw); if ($item !== '') { $resolved[] = $item; } } return array_slice($resolved, 0, 14); } /** * 解析单张参考图(URL/路径直接使用;base64 先上传 OSS) */ private function resolveSingleReferenceImage(string $raw): string { $raw = trim($raw); if ($raw === '') { return ''; } if (stripos($raw, 'data:image/') === 0) { return $this->uploadBase64ImageToOss($raw); } if (stripos($raw, 'http://') === 0 || stripos($raw, 'https://') === 0) { return $raw; } if (preg_match('/^[A-Za-z0-9+\/=\s]+$/', $raw) && strlen($raw) > 100) { $ossUrl = $this->uploadBase64ImageToOss($raw); if ($ossUrl !== '') { return $ossUrl; } } $normalizedPath = ltrim(str_replace('\\', '/', $raw), '/'); $publicUrl = $this->buildOssPublicUrl($normalizedPath); if (stripos($publicUrl, 'http://') === 0 || stripos($publicUrl, 'https://') === 0) { return $publicUrl; } $localPath = ROOT_PATH . 'public' . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $normalizedPath); if (is_file($localPath)) { return $this->uploadLocalFileToOss($localPath, $normalizedPath); } try { $loaded = $this->readImageBinary($raw); $dataUrl = 'data:' . $loaded['mimeType'] . ';base64,' . $loaded['base64Data']; return $this->uploadBase64ImageToOss($dataUrl); } catch (\Exception $e) { $this->logWarning('[ImageGeneration] 读取参考图失败: ' . $e->getMessage()); return ''; } } /** * form-data 上传文件:落盘 → OSS → 返回公网 URL */ private function uploadUploadedFileToOss($file): string { if (!$this->isOssEnabled()) { throw new \Exception('OSS 未配置,无法上传参考图'); } $ext = $this->resolveUploadedImageExt($file); if ($ext === '') { throw new \Exception('不支持的图片格式'); } $dateDir = date('Ymd'); $saveDir = ROOT_PATH . 'public' . DIRECTORY_SEPARATOR . 'uploads' . DIRECTORY_SEPARATOR . 'imagegen' . DIRECTORY_SEPARATOR . $dateDir . DIRECTORY_SEPARATOR; if (!is_dir($saveDir)) { mkdir($saveDir, 0755, true); } $fileName = 'ref_' . str_replace('.', '', uniqid('', true)) . '.' . $ext; $localFullPath = $saveDir . $fileName; $saved = false; if (method_exists($file, 'isValid') && $file->isValid()) { $info = $file->move($saveDir, $fileName); if ($info) { $localFullPath = $saveDir . $info->getFilename(); $saved = true; } } if (!$saved) { $uploadInfo = $file->getInfo(); $tmpPath = isset($uploadInfo['tmp_name']) ? (string)$uploadInfo['tmp_name'] : ''; if ($tmpPath === '' || !is_file($tmpPath)) { $tmpPath = (string)($file->getRealPath() ?: ''); } if ($tmpPath === '' || !is_file($tmpPath) || !@copy($tmpPath, $localFullPath)) { throw new \Exception('参考图保存失败'); } } $objectKey = 'uploads/imagegen/' . $dateDir . '/' . basename($localFullPath); return $this->uploadLocalFileToOss($localFullPath, $objectKey); } /** * base64 图片:落盘 → OSS → 返回公网 URL */ private function uploadBase64ImageToOss(string $base64Input): string { if (!$this->isOssEnabled()) { throw new \Exception('OSS 未配置,无法上传参考图'); } $parsed = $this->parseBase64Image($base64Input); if ($parsed === null) { return ''; } [$ext, $imageData] = $parsed; $dateDir = date('Ymd'); $saveDir = ROOT_PATH . 'public' . DIRECTORY_SEPARATOR . 'uploads' . DIRECTORY_SEPARATOR . 'imagegen' . DIRECTORY_SEPARATOR . $dateDir . DIRECTORY_SEPARATOR; if (!is_dir($saveDir)) { mkdir($saveDir, 0755, true); } $fileName = 'ref_' . str_replace('.', '', uniqid('', true)) . '.' . $ext; $localFullPath = $saveDir . $fileName; if (file_put_contents($localFullPath, $imageData) === false) { throw new \Exception('参考图本地保存失败'); } $objectKey = 'uploads/imagegen/' . $dateDir . '/' . $fileName; return $this->uploadLocalFileToOss($localFullPath, $objectKey); } /** * 本地文件上传 OSS 并返回公网 URL */ private function uploadLocalFileToOss(string $localFullPath, string $objectKey): string { $objectKey = $this->normalizeOssObjectKey($objectKey); if (!$this->putFileToOss($localFullPath, $objectKey)) { throw new \Exception('参考图上传 OSS 失败'); } $url = $this->buildOssPublicUrl($objectKey); if (stripos($url, 'http://') !== 0 && stripos($url, 'https://') !== 0) { throw new \Exception('无法获取参考图 OSS 访问地址,请检查 oss.host 配置'); } return $url; } /** * 解析 base64 图片 * @return array{0:string,1:string}|null [扩展名, 二进制内容] */ private function parseBase64Image(string $base64Input): ?array { $base64Input = trim($base64Input); if ($base64Input === '') { return null; } $ext = 'jpg'; $rawBase64 = $base64Input; $prefix = 'data:image/'; if (stripos($base64Input, $prefix) === 0) { $semi = stripos($base64Input, ';base64,'); if ($semi === false) { return null; } $mimePart = strtolower(substr($base64Input, strlen($prefix), $semi - strlen($prefix))); if ($mimePart === 'jpeg') { $mimePart = 'jpg'; } if (in_array($mimePart, ['jpg', 'png', 'gif', 'webp', 'bmp'], true)) { $ext = $mimePart; } $rawBase64 = substr($base64Input, $semi + 8); } $rawBase64 = preg_replace('/\s+/', '', $rawBase64); if ($rawBase64 === '') { return null; } $imageData = base64_decode($rawBase64, true); if ($imageData === false || strlen($imageData) < 100) { return null; } return [$ext, $imageData]; } /** * 解析上传图片扩展名 */ private function resolveUploadedImageExt($file): string { $name = $file->getInfo('name') ?? ''; $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION)); if ($ext === 'jpeg') { $ext = 'jpg'; } $allowed = ['jpg', 'png', 'gif', 'webp', 'bmp']; if ($ext && in_array($ext, $allowed, true)) { return $ext; } $mime = method_exists($file, 'getMime') ? strtolower((string)$file->getMime()) : ''; $map = [ 'image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif', 'image/webp' => 'webp', 'image/bmp' => 'bmp', ]; return $map[$mime] ?? ''; } /** * 读取图片二进制(本地路径 / 完整 URL / OSS 相对路径) * @return array{base64Data:string,mimeType:string} */ private function readImageBinary(string $imageUrl): array { $imageUrl = trim($imageUrl); if ($imageUrl === '') { throw new \Exception('图片路径不能为空'); } $rootPath = str_replace('\\', '/', ROOT_PATH); $relativePath = ltrim($imageUrl, '/'); $localPath = rtrim($rootPath, '/') . '/public/' . $relativePath; $localPathDecoded = rtrim($rootPath, '/') . '/public/' . urldecode($relativePath); $imageContent = false; $mimeType = ''; if (preg_match('/^https?:\/\//i', $imageUrl)) { $imageContent = @file_get_contents($imageUrl); } elseif (file_exists($localPath)) { $imageContent = @file_get_contents($localPath); if ($imageContent !== false) { $finfo = finfo_open(FILEINFO_MIME_TYPE); $mimeType = finfo_file($finfo, $localPath); finfo_close($finfo); } } elseif (file_exists($localPathDecoded)) { $imageContent = @file_get_contents($localPathDecoded); if ($imageContent !== false) { $finfo = finfo_open(FILEINFO_MIME_TYPE); $mimeType = finfo_file($finfo, $localPathDecoded); finfo_close($finfo); } } else { $ossConfig = $this->getOssConfig(); $ossHost = trim((string)($ossConfig['host'] ?? '')); if ($ossHost !== '') { if (stripos($ossHost, 'http://') !== 0 && stripos($ossHost, 'https://') !== 0) { $ossHost = 'https://' . $ossHost; } $remoteUrl = rtrim($ossHost, '/') . '/' . ltrim($relativePath, '/'); $imageContent = @file_get_contents($remoteUrl); } } if ($imageContent === false || $imageContent === '') { throw new \Exception('图片内容读取失败(本地/OSS均未读取到)'); } if ($mimeType === '') { $finfo = finfo_open(FILEINFO_MIME_TYPE); $mimeType = finfo_buffer($finfo, $imageContent); finfo_close($finfo); } if (!$mimeType) { $mimeType = 'image/png'; } return [ 'base64Data' => base64_encode($imageContent), 'mimeType' => $mimeType, ]; } /** * 获取 OSS 配置 */ private function getOssConfig(): array { $config = Config::get('oss'); return is_array($config) ? $config : []; } /** * OSS 是否可用 */ private function isOssEnabled(): bool { $config = $this->getOssConfig(); return !empty($config['accessKeyId']) && !empty($config['accessKeySecret']) && !empty($config['endpoint']) && !empty($config['bucket']); } /** * 归一化 OSS 对象键 */ private function normalizeOssObjectKey(string $objectKey): string { return ltrim(str_replace('\\', '/', trim($objectKey)), '/'); } /** * 上传本地文件到 OSS */ private function putFileToOss(string $localFullPath, string $objectKey): bool { if (!$this->isOssEnabled() || !is_file($localFullPath)) { return false; } $config = $this->getOssConfig(); $objectKey = $this->normalizeOssObjectKey($objectKey); if ($objectKey === '') { return false; } try { $ossClient = new OssClient( $config['accessKeyId'], $config['accessKeySecret'], $config['endpoint'] ); $ossClient->uploadFile($config['bucket'], $objectKey, $localFullPath); return true; } catch (\Throwable $e) { $this->logError('[ImageGeneration/OSS] ' . $e->getMessage() . ' | objectKey=' . $objectKey); return false; } } /** * 相对路径转 OSS 公网 URL */ private function buildOssPublicUrl(string $path): string { $path = trim($path); if ($path === '' || stripos($path, 'http://') === 0 || stripos($path, 'https://') === 0) { return $path; } $config = $this->getOssConfig(); $host = trim((string)($config['host'] ?? '')); if ($host === '') { return $path; } if (stripos($host, 'http://') !== 0 && stripos($host, 'https://') !== 0) { $host = 'https://' . $host; } return rtrim($host, '/') . '/' . ltrim($path, '/'); } private function logError(string $message): void { Log::write($message, 'error'); } private function logWarning(string $message): void { Log::write($message, 'warning'); } /** * 合并 JSON Body 与 form 参数 */ private function mergeRequestParams(): array { $params = $this->request->param(); if (!empty($params['prompt']) || !empty($params['image']) || !empty($params['images'])) { return $params; } $content = $this->request->getInput(); if (!is_string($content) || $content === '') { return $params; } $json = json_decode($content, true); if (!is_array($json)) { return $params; } return array_merge($params, $json); } }