delete(); return; } $redisKey = self::REDIS_KEY_PREFIX . $taskId; $redis = getTaskRedis(); echo "\n" . date('Y-m-d H:i:s') . " [SeedreamImageGenerationJob] 开始 task_id={$taskId}\n"; try { $existing = $redis->get($redisKey); if ($existing) { $info = json_decode($existing, true); if (is_array($info) && ($info['status'] ?? '') === 'completed') { echo "任务 {$taskId} 已完成,跳过\n"; $job->delete(); return; } } $redis->set($redisKey, json_encode([ 'status' => 'processing', 'task_id' => $taskId, 'started_at' => date('Y-m-d H:i:s'), ], JSON_UNESCAPED_UNICODE), ['EX' => self::TASK_TTL]); set_time_limit(900); ini_set('max_execution_time', '900'); $payload = self::execute(is_array($data) ? $data : []); $redis->set($redisKey, json_encode(array_merge($payload, [ 'status' => 'completed', 'task_id' => $taskId, 'completed_at' => date('Y-m-d H:i:s'), ]), JSON_UNESCAPED_UNICODE), ['EX' => self::TASK_TTL]); echo date('Y-m-d H:i:s') . " [SeedreamImageGenerationJob] 完成 task_id={$taskId}\n"; $job->delete(); } catch (\Exception $e) { echo date('Y-m-d H:i:s') . " [SeedreamImageGenerationJob] 失败: " . $e->getMessage() . "\n"; Log::write('[SeedreamImageGenerationJob] ' . $e->getMessage(), 'error'); $redis->set($redisKey, json_encode([ 'status' => 'failed', 'task_id' => $taskId, 'error' => $e->getMessage(), 'completed_at' => date('Y-m-d H:i:s'), ], JSON_UNESCAPED_UNICODE), ['EX' => self::TASK_TTL]); $job->delete(); } } /** * 执行 Seedream 生图(组图/单图均可) */ public static function execute(array $data): array { $prompt = trim((string)($data['prompt'] ?? '')); $model = trim((string)($data['model'] ?? self::DEFAULT_MODEL)); $size = trim((string)($data['size'] ?? '2K')); $stream = filter_var($data['stream'] ?? false, FILTER_VALIDATE_BOOLEAN); $referenceImages = is_array($data['reference_images'] ?? null) ? $data['reference_images'] : []; $genConfig = is_array($data['gen_config'] ?? null) ? $data['gen_config'] : []; $requestBody = is_array($data['request_body'] ?? null) ? $data['request_body'] : []; if (empty($requestBody)) { $referenceCount = count($referenceImages); $maxImages = max(1, min(15, (int)($genConfig['max_images'] ?? 1))); $sequentialMode = trim((string)($genConfig['sequential_image_generation'] ?? '')); if ($sequentialMode === '') { $sequentialMode = $maxImages > 1 ? 'auto' : 'disabled'; } $requestBody = [ 'model' => $model, 'prompt' => $prompt, 'sequential_image_generation' => $sequentialMode, 'response_format' => 'url', 'size' => $size, 'stream' => $stream, 'watermark' => filter_var($data['watermark'] ?? false, FILTER_VALIDATE_BOOLEAN), ]; if ($sequentialMode !== 'disabled') { $requestBody['sequential_image_generation_options'] = [ 'max_images' => $maxImages, ]; } if ($referenceCount > 0) { $requestBody['image'] = $referenceCount === 1 ? $referenceImages[0] : $referenceImages; } $genConfig['max_images'] = $maxImages; $genConfig['sequential_image_generation'] = $sequentialMode; $genConfig['mode'] = self::detectGenerationMode($referenceCount, $maxImages); } $result = self::callSeedreamApi($requestBody, $model, $stream); $images = self::normalizeImageResult($result); $imgId = self::extractApiImageId($result); $taskId = trim((string)($data['task_id'] ?? '')); $stored = self::persistGeneratedImagesToOss($images, $imgId, $referenceImages, $taskId); $images = $stored['images']; $generateImgUrl = implode(',', $stored['urls']); $payload = [ 'img_id' => $imgId, 'mode' => $genConfig['mode'] ?? '', 'reference_count' => count($referenceImages), 'reference_images' => $referenceImages, 'generate_images' => $stored['urls'], 'max_images' => (int)($genConfig['max_images'] ?? count($images)), 'images' => $images, 'model' => $result['model'] ?? $model, 'usage' => $result['usage'] ?? null, 'created' => $result['created'] ?? null, ]; try { $payload['storage_id'] = self::savePictureStorage([ 'img_id' => $imgId, 'prompt' => $prompt, 'reference_img_url' => implode(',', $referenceImages), 'generate_img_url' => $generateImgUrl, 'model' => $result['model'] ?? $model, 'img_size' => $size, ]); } catch (\Exception $e) { $payload['db_save_error'] = $e->getMessage(); Log::write('[SeedreamImageGenerationJob] 数据库保存失败: ' . $e->getMessage(), 'error'); } return $payload; } private static 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'; } private static function callSeedreamApi(array $requestBody, string $model, bool $stream = false): array { $configs = self::resolveModelApiConfigs($model); if (empty($configs)) { throw new \Exception("模型配置不存在: {$model}"); } $postData = json_encode($requestBody, JSON_UNESCAPED_UNICODE); if (json_last_error() !== JSON_ERROR_NONE) { throw new \Exception('请求数据 JSON 编码失败: ' . json_last_error_msg()); } $errors = []; foreach ($configs as $index => $config) { $apiUrl = self::sanitizeApiUrl((string)($config['api_url'] ?? '')); $apiKey = trim((string)($config['api_key'] ?? '')); if ($apiUrl === '' || $apiKey === '') { $errors[] = '第' . ($index + 1) . '个接口配置无效(URL/Key为空)'; continue; } $headers = [ 'Content-Type: application/json', 'Authorization: Bearer ' . $apiKey, ]; if ($stream) { $headers[] = 'Accept: text/event-stream'; } $ch = curl_init(); curl_setopt_array($ch, [ CURLOPT_URL => $apiUrl, CURLOPT_POST => true, CURLOPT_POSTFIELDS => $postData, CURLOPT_HTTPHEADER => $headers, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 900, CURLOPT_CONNECTTIMEOUT => 30, CURLOPT_SSL_VERIFYPEER => false, CURLOPT_SSL_VERIFYHOST => 0, ]); $response = curl_exec($ch); $httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); $curlErrno = curl_errno($ch); $curlError = curl_error($ch); curl_close($ch); if ($response === false) { $errors[] = '第' . ($index + 1) . '个接口请求失败 [curl:' . $curlErrno . '] ' . ($curlError !== '' ? $curlError : '请求超时或网络异常'); continue; } if ($httpCode !== 200) { $decoded = json_decode($response, true); $msg = is_array($decoded) ? ($decoded['error']['message'] ?? json_encode($decoded, JSON_UNESCAPED_UNICODE)) : self::truncateText((string)$response, 300); $errors[] = '第' . ($index + 1) . '个接口 HTTP ' . $httpCode . ': ' . $msg; continue; } try { return $stream ? self::parseStreamResponse($response) : self::parseJsonResponse($response); } catch (\Exception $e) { $errors[] = '第' . ($index + 1) . '个接口响应解析失败: ' . $e->getMessage(); } } throw new \Exception(($stream ? '流式' : '') . '生成失败: ' . implode('; ', $errors)); } private static function resolveModelApiConfigs(string $model): array { $rows = Db::name('ai_model') ->where('model_name', $model) ->where('status', '1') ->order('sort ASC') ->select(); $configs = []; $seenUrls = []; $fallbackKey = ''; foreach ($rows as $row) { $apiUrl = self::sanitizeApiUrl((string)($row['api_url'] ?? '')); $apiKey = trim((string)($row['api_key'] ?? '')); if ($apiKey !== '' && $fallbackKey === '') { $fallbackKey = $apiKey; } if ($apiUrl === '' || $apiKey === '') { continue; } $urlKey = strtolower(rtrim($apiUrl, '/')); if (isset($seenUrls[$urlKey])) { continue; } $seenUrls[$urlKey] = true; $configs[] = ['api_url' => $apiUrl, 'api_key' => $apiKey]; } $defaultUrl = rtrim(self::DEFAULT_API_URL, '/'); if ($fallbackKey !== '' && !isset($seenUrls[strtolower($defaultUrl)])) { $configs[] = ['api_url' => self::DEFAULT_API_URL, 'api_key' => $fallbackKey]; } return $configs; } private static function sanitizeApiUrl(string $url): string { $url = trim(str_replace(["\r\n", "\r", "\n", "\t"], ' ', $url)); if ($url === '') { return ''; } foreach (preg_split('/\s+/', $url) as $part) { $part = trim($part); if ($part !== '' && preg_match('/^https?:\/\//i', $part)) { return $part; } } return ''; } private static function parseJsonResponse(string $raw): array { $result = json_decode($raw, true); if (!is_array($result)) { throw new \Exception('API 响应解析失败'); } if (isset($result['error'])) { throw new \Exception($result['error']['message'] ?? 'API 返回错误'); } if (empty($result['data']) || !is_array($result['data'])) { throw new \Exception('未解析到有效图片结果'); } return $result; } private static function parseStreamResponse(string $raw): array { $images = []; $usage = null; $model = ''; $created = null; $apiId = ''; foreach (preg_split('/\r?\n/', $raw) as $line) { $line = trim($line); if ($line === '' || $line === 'data: [DONE]' || stripos($line, 'data:') !== 0) { continue; } $event = json_decode(trim(substr($line, 5)), true); if (!is_array($event)) { continue; } if (isset($event['error'])) { throw new \Exception($event['error']['message'] ?? 'API 返回错误'); } if (!empty($event['model'])) { $model = (string)$event['model']; } if (!empty($event['id'])) { $apiId = (string)$event['id']; } if (isset($event['created'])) { $created = $event['created']; } if (isset($event['usage'])) { $usage = $event['usage']; } $type = (string)($event['type'] ?? ''); if ($type === 'image_generation.partial_succeeded' && !empty($event['url'])) { $images[] = [ 'url' => $event['url'], 'size' => $event['size'] ?? '', 'image_index' => $event['image_index'] ?? count($images), ]; } if ($type === 'image_generation.completed' && !empty($event['data']) && is_array($event['data'])) { foreach ($event['data'] as $item) { if (!empty($item['url'])) { $images[] = $item; } } } } if (empty($images)) { $decoded = json_decode($raw, true); if (is_array($decoded) && !empty($decoded['data'])) { return $decoded; } throw new \Exception('未解析到有效图片结果'); } return [ 'id' => $apiId, 'model' => $model, 'created' => $created, 'data' => $images, 'usage' => $usage, ]; } private static function extractApiImageId(array $result): string { foreach (['id', 'request_id', 'task_id'] as $key) { if (!empty($result[$key])) { return substr((string)$result[$key], 0, 50); } } return substr(str_replace('.', '', uniqid('img', true)), 0, 50); } private static function savePictureStorage(array $data): int { $insert = [ 'img_id' => substr((string)($data['img_id'] ?? ''), 0, 50), 'prompt' => (string)($data['prompt'] ?? ''), 'reference_img_url' => (string)($data['reference_img_url'] ?? ''), 'generate_img_url' => (string)($data['generate_img_url'] ?? ''), 'model' => substr((string)($data['model'] ?? ''), 0, 255), 'img_size' => substr((string)($data['img_size'] ?? ''), 0, 255), 'sys_rq' => date('Y-m-d H:i:s'), ]; if ($insert['img_id'] === '' || $insert['model'] === '') { throw new \Exception('picture_storage 入库参数不完整'); } $storageId = Db::name('picture_storage')->insertGetId($insert); if (!$storageId) { throw new \Exception('picture_storage 入库失败'); } return (int)$storageId; } private static function normalizeImageResult(array $result): array { $items = $result['data'] ?? []; if (!is_array($items)) { return []; } $images = []; foreach ($items as $item) { if (!is_array($item) || empty($item['url'])) { continue; } $images[] = [ 'url' => $item['url'], 'size' => $item['size'] ?? '', 'image_index' => $item['image_index'] ?? null, ]; } return $images; } /** * 生成图落盘并上传 OSS(目录/命名规则与参考图一致:uploads/imagegen/{日期}/) * @return array{urls:string[],images:array} */ private static function persistGeneratedImagesToOss( array $images, string $imgId, array $referenceImages, string $taskId = '' ): array { if (empty($images)) { throw new \Exception('没有可保存的生成图'); } if (!self::isOssEnabled()) { throw new \Exception('OSS 未配置,无法保存生成图'); } $defaultExt = self::resolveOutputExtFromReference($referenceImages); $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); } $safeId = preg_replace('/[^a-zA-Z0-9_]/', '_', $imgId); $safeTask = $taskId !== '' ? preg_replace('/[^a-zA-Z0-9_]/', '_', $taskId) : ''; $ossUrls = []; $storedImages = []; foreach ($images as $index => $image) { $sourceUrl = trim((string)($image['url'] ?? '')); if ($sourceUrl === '') { continue; } $binary = self::downloadRemoteImage($sourceUrl); $ext = self::detectImageExt($binary, $defaultExt); $suffix = $safeTask !== '' ? $safeTask . '_' . $index : $safeId . '_' . $index; $fileName = 'gen_' . $suffix . '.' . $ext; $localFullPath = $saveDir . $fileName; if (file_put_contents($localFullPath, $binary) === false) { throw new \Exception('生成图本地保存失败'); } $objectKey = 'uploads/imagegen/' . $dateDir . '/' . $fileName; if (!self::putFileToOss($localFullPath, $objectKey)) { throw new \Exception('生成图上传 OSS 失败: ' . $objectKey); } $ossUrl = self::buildOssPublicUrl($objectKey); if (stripos($ossUrl, 'http://') !== 0 && stripos($ossUrl, 'https://') !== 0) { throw new \Exception('无法获取生成图 OSS 访问地址'); } $ossUrls[] = $ossUrl; $storedImages[] = array_merge($image, [ 'url' => $ossUrl, 'oss_url' => $ossUrl, 'object_key' => $objectKey, 'source_url' => $sourceUrl, ]); } if (empty($ossUrls)) { throw new \Exception('生成图 OSS 保存结果为空'); } return ['urls' => $ossUrls, 'images' => $storedImages]; } /** * 从参考图推断输出扩展名(无参考图时默认 jpg) */ private static function resolveOutputExtFromReference(array $referenceImages): string { foreach ($referenceImages as $url) { $url = trim((string)$url); if ($url === '') { continue; } $path = (string)(parse_url($url, PHP_URL_PATH) ?: ''); $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION)); if ($ext === 'jpeg') { $ext = 'jpg'; } if (in_array($ext, ['jpg', 'png', 'gif', 'webp', 'bmp'], true)) { return $ext; } } return 'jpg'; } private static function detectImageExt(string $binary, string $fallback = 'jpg'): string { $finfo = finfo_open(FILEINFO_MIME_TYPE); $mime = finfo_buffer($finfo, $binary); finfo_close($finfo); $map = [ 'image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif', 'image/webp' => 'webp', 'image/bmp' => 'bmp', ]; if ($mime && isset($map[$mime])) { return $map[$mime]; } $fallback = strtolower($fallback); return in_array($fallback, ['jpg', 'png', 'gif', 'webp', 'bmp'], true) ? $fallback : 'jpg'; } private static function downloadRemoteImage(string $url): string { $ch = curl_init(); curl_setopt_array($ch, [ CURLOPT_URL => $url, CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => true, CURLOPT_TIMEOUT => 120, CURLOPT_CONNECTTIMEOUT => 15, CURLOPT_SSL_VERIFYPEER => false, CURLOPT_SSL_VERIFYHOST => 0, ]); $binary = curl_exec($ch); $httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); $curlError = curl_error($ch); curl_close($ch); if ($binary === false || $binary === '') { throw new \Exception('生成图下载失败: ' . ($curlError ?: $url)); } if ($httpCode !== 200) { throw new \Exception('生成图下载失败 HTTP ' . $httpCode); } if (strlen($binary) < 100) { throw new \Exception('生成图内容无效'); } return $binary; } private static function getOssConfig(): array { $config = Config::get('oss'); return is_array($config) ? $config : []; } private static function isOssEnabled(): bool { $config = self::getOssConfig(); return !empty($config['accessKeyId']) && !empty($config['accessKeySecret']) && !empty($config['endpoint']) && !empty($config['bucket']); } private static function normalizeOssObjectKey(string $objectKey): string { return ltrim(str_replace('\\', '/', trim($objectKey)), '/'); } private static function putFileToOss(string $localFullPath, string $objectKey): bool { if (!self::isOssEnabled() || !is_file($localFullPath)) { return false; } $config = self::getOssConfig(); $objectKey = self::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) { Log::write('[SeedreamImageGenerationJob/OSS] ' . $e->getMessage() . ' | objectKey=' . $objectKey, 'error'); return false; } } private static function buildOssPublicUrl(string $path): string { $path = trim($path); if ($path === '' || stripos($path, 'http://') === 0 || stripos($path, 'https://') === 0) { return $path; } $config = self::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 static function truncateText(string $text, int $maxLen): string { $text = trim(preg_replace('/\s+/', ' ', $text)); if ($text === '' || strlen($text) <= $maxLen) { return $text; } return substr($text, 0, $maxLen) . '...'; } }