buildText2TextGeminiFlash($prompt); break; case $status_val === '文生文' && $model === 'gpt-4': $data = $this->buildText2TextGpt4($prompt); break; // 文生图 case $status_val === '文生图' && $model === 'dall-e-3': $data = $this->buildText2ImageDallE3($prompt, $size); break; case $status_val === '文生图' && $model === 'gemini-3-pro-preview': $data = $this->buildText2ImageGemini3Pro($prompt, $size); break; case $status_val === '文生图' && $model === 'gemini-3-pro-image-preview': $data = $this->buildText2ImageGemini3ProImage($prompt, $size); break; // 图生文 case $status_val === '图生文' && $model === 'gemini-3-pro-preview': $data = $this->buildImage2TextGemini3Pro($prompt, $product_base64Data, $product_mimeType); break; // 图生图 case $status_val === '图生图' && $model === 'gemini-3-pro-image-preview': $data = $this->buildImage2ImageGemini3ProImage($prompt, $size, $product_base64Data, $product_mimeType, $template_base64Data, $template_mimeType); break; case $status_val === '图生图' && $model === 'gemini-3.1-flash-image-preview': $data = $this->buildImage2ImageGemini31Flash($prompt, $size, $product_base64Data, $product_mimeType, $template_base64Data, $template_mimeType); break; // 未匹配的组合 default: throw new \Exception("未配置模型+任务类型组合: {$model}({$status_val})"); } // 3. 统一调用 API(图生图/文生图出图耗时较长,适当放宽超时时间) $timeout = 60; if ($status_val === '图生图' || ($status_val === '文生图' && $model === 'gemini-3-pro-image-preview')) { $timeout = 180; } return $this->callApi($data, $model, $timeout); } // -------------------------- 细分方法:按「任务类型+模型」拆分 -------------------------- /** * 文生文 - gemini_flash 模型 */ private function buildText2TextGeminiFlash(string $prompt): array { return [ "contents" => [ [ "role" => "user", "parts" => [["text" => $prompt]] ] ], "generationConfig" => [ "responseModalities" => ["TEXT"], "maxOutputTokens" => 1024, "temperature" => 0.7, "language" => "zh-CN" ] ]; } /** * 文生文 - gpt-4 模型 */ private function buildText2TextGpt4(string $prompt): array { return [ 'model' => 'gpt-4', 'messages' => [['role' => 'user', 'content' => $prompt]], 'temperature' => 0.7, 'max_tokens' => 1024 ]; } /** * 文生图 - dall-e-3 模型 */ private function buildText2ImageDallE3(string $prompt, string $size): array { return [ 'group' => 'OpenAI', 'prompt' => $prompt, 'model' => 'dall-e-3', 'n' => 1, 'size' => $size, 'quality' => 'hd', 'style' => 'vivid', 'response_format' => 'url', ]; } /** * 文生图 - gemini-3-pro-preview 模型 */ private function buildText2ImageGemini3Pro(string $prompt, string $size): array { $supportedAspectRatios = ['1:1', '4:3', '3:4', '16:9', '9:16']; $aspectRatio = (in_array($size, $supportedAspectRatios)) ? $size : '1:1'; return [ "model" => "gemini-3-pro-preview", "prompt" => $prompt, "size" => $aspectRatio, "n" => 1, "quality" => "standard", "response_format" => "url" ]; } /** * 文生图 - gemini-3-pro-image-preview 模型(纯文本出图) */ private function buildText2ImageGemini3ProImage(string $prompt, string $size): array { $supportedAspectRatios = ['1:1', '4:3', '3:4', '16:9', '9:16']; if ($size === '' || !in_array($size, $supportedAspectRatios, true)) { if (!empty($size) && strpos($size, 'x') !== false) { $parts = explode('x', trim($size), 2); if (count($parts) === 2) { $w = (int)$parts[0]; $h = (int)$parts[1]; if ($w > 0 && $h > 0) { $ratio = $w / $h; $standard = [['1:1', 1], ['4:3', 4 / 3], ['3:4', 3 / 4], ['16:9', 16 / 9], ['9:16', 9 / 16]]; $minDiff = PHP_FLOAT_MAX; foreach ($standard as $r) { $diff = abs($ratio - $r[1]); if ($diff < $minDiff) { $minDiff = $diff; $size = $r[0]; } } } } } else { $size = '1:1'; } } return [ 'contents' => [ [ 'role' => 'user', 'parts' => [['text' => $prompt]] ] ], 'generationConfig' => [ 'responseModalities' => ['IMAGE'], 'imageConfig' => ['aspectRatio' => $size, 'imageSize' => '1K'] ] ]; } /** * 图生文 - gemini-3-pro-preview 模型 */ private function buildImage2TextGemini3Pro(string $prompt, string $productBase64, string $productMimeType): array { return [ "contents" => [ [ "role" => "user", "parts" => [ ["inlineData" => ["mimeType" => $productMimeType, "data" => $productBase64]], ["text" => $prompt] ] ] ], "generationConfig" => [ "responseModalities" => ["TEXT"], "maxOutputTokens" => 1000, "temperature" => 0.7, "language" => "zh-CN" ] ]; } /** * 图生图 - gemini-3-pro-image-preview 模型 */ private function buildImage2ImageGemini3ProImage( string $prompt, string $size, string $productBase64, string $productMimeType, string $templateBase64, string $templateMimeType ): array { // 尺寸转标准比例 if (!empty($size) && strpos($size, 'x') !== false) { $parts = explode('x', trim($size), 2); if (count($parts) == 2) { $w = (int)$parts[0]; $h = (int)$parts[1]; if ($w > 0 && $h > 0) { $ratio = $w / $h; $standard = [['1:1', 1], ['4:3', 4 / 3], ['3:4', 3 / 4], ['16:9', 16 / 9], ['9:16', 9 / 16]]; $minDiff = PHP_FLOAT_MAX; foreach ($standard as $r) { $diff = abs($ratio - $r[1]); if ($diff < $minDiff) { $minDiff = $diff; $size = $r[0]; } } } } } // 网关要求固定 2 张 inline_data;无参考图时用原图占位(与线上一致) if ($templateBase64 === '' && $productBase64 !== '') { $templateBase64 = $productBase64; $templateMimeType = $productMimeType; } return [ 'contents' => [ [ 'role' => 'user', 'parts' => [ ['text' => $prompt], ['inline_data' => ['mime_type' => $productMimeType, 'data' => $productBase64]], ['inline_data' => ['mime_type' => $templateMimeType, 'data' => $templateBase64]], ] ] ], 'generationConfig' => [ 'responseModalities' => ['IMAGE'], 'imageConfig' => ['aspectRatio' => $size, 'imageSize' => '1K'] ] ]; } /** * 图生图 - gemini-3.1-flash-image-preview 模型 */ private function buildImage2ImageGemini31Flash( string $prompt, string $size, string $productBase64, string $productMimeType, string $templateBase64, string $templateMimeType ): array { $content = [ ['type' => 'text', 'text' => $prompt], [ 'type' => 'image_url', 'image_url' => ['url' => 'data:' . $productMimeType . ';base64,' . $productBase64] ], ]; if (!empty($templateMimeType) && !empty($templateBase64)) { $content[] = [ 'type' => 'image_url', 'image_url' => ['url' => 'data:' . $templateMimeType . ';base64,' . $templateBase64] ]; } return [ 'model' => 'gemini-3.1-flash-image-preview', 'messages' => [ [ 'role' => 'user', 'content' => $content ] ], 'response_modalities' => ['image'], 'image_config' => [ 'aspect_ratio' => $size, 'quality' => 'high', 'width' => '850', 'height' => '1133' ], 'temperature' => 0.3, 'top_p' => 0.8, 'max_tokens' => 2048 ]; } /** * 构建视频请求体 * $status_val == 文生视频、图生视频、首图尾图生视频 * $model 模型 * $prompt 提示词 * $size 尺寸大小 * $seconds 时间 */ public function Txt_to_video($status_val,$prompt, $model, $size, $seconds) { //判断使用哪个模型、在判断此模型使用类型 if ($model === 'sora-2') { if ($status_val == '文生视频') { $data = [ 'prompt' => trim($prompt), 'model' => $model ?: 'sora-2', 'seconds' => (string)((int)$seconds ?: 5), 'size' => $size ?: '1920x1080' ]; return self::callApi($this->config['sora-2']['api_url'], $this->config['sora-2']['api_key'], $data, 300); } }else{ throw new \Exception("未配置模型类型: {$model}"); } } /** * 计算最大公约数 */ public function gcd($a, $b) { while ($b != 0) { $temp = $a % $b; $a = $b; $b = $temp; } return $a; } /** * 通用 API 调用方法(支持多接口故障自动切换) * * @param array $data 请求数据(JSON 格式) * @param string $model 模型名 * @param int $timeout 请求超时时间(秒) * @return array 接口响应数据(成功时返回解析后的数组) * @throws \Exception 所有接口都失败时抛出异常 */ public function callApi(array $data, string $model, int $timeout = 60): array { $allErrors = []; $httpCodes = []; $curlErrnos = []; // 1. 预加载该模型的所有API配置(按优先级排序,仅启用状态) $aiModelConfigs = Db::name("ai_model") ->where('model_name', $model) ->where('status', '1') ->order('sort ASC') ->select(); if (empty($aiModelConfigs)) { throw new \Exception("模型配置不存在: {$model}"); } // 2. 提前序列化JSON(只做一次,避免重复编码) $postData = json_encode($data, JSON_UNESCAPED_UNICODE); if (json_last_error() !== JSON_ERROR_NONE) { throw new \Exception("请求数据JSON编码失败: " . json_last_error_msg()); } // 3. 遍历所有接口,逐个尝试调用 foreach ($aiModelConfigs as $index => $config) { $ch = null; try { // 跳过空配置(同时记录到错误数组,避免最终汇总时 $httpCodes/$curlErrnos 为空导致未定义下标) if (empty($config['api_url']) || empty($config['api_key'])) { $allErrors[] = "第" . ($index + 1) . "个接口配置无效(URL/Key为空)"; $httpCodes[] = 0; $curlErrnos[] = 0; continue; } // 初始化curl(每个接口新建连接,避免复用导致的问题) $ch = curl_init(); $curlOptions = [ CURLOPT_URL => $config['api_url'], CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => $postData, CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'Authorization: Bearer ' . $config['api_key'], 'Connection: keep-alive', 'Keep-Alive: 300' ], CURLOPT_TIMEOUT => $timeout, CURLOPT_CONNECTTIMEOUT => 15, CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1, CURLOPT_FAILONERROR => false, // 生产环境建议开启SSL验证(需配置CA证书) CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_TCP_NODELAY => true, CURLOPT_FORBID_REUSE => false, CURLOPT_FRESH_CONNECT => false ]; curl_setopt_array($ch, $curlOptions); // 执行请求(每个接口仅调用一次,不做同接口重试) $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $curlErrno = curl_errno($ch); $curlError = curl_error($ch); // 检查CURL底层错误 if ($response === false) { $errorMsg = "第" . ($index + 1) . "个接口CURL失败 [{$curlErrno}]: {$curlError}"; $allErrors[] = $errorMsg; $httpCodes[] = $httpCode; $curlErrnos[] = $curlErrno; continue; // 跳过当前接口,尝试下一个 } // 解析响应 $result = empty($response) ? [] : json_decode($response, true); if (json_last_error() !== JSON_ERROR_NONE && !empty($response)) { $errorMsg = "第" . ($index + 1) . "个接口响应解析失败: " . json_last_error_msg(); $allErrors[] = $errorMsg; $httpCodes[] = $httpCode; $curlErrnos[] = $curlErrno; continue; } // 检查API业务错误 if (isset($result['error'])) { $apiErrorDetail = $result['error']['message'] ?? ''; $errorType = $result['error']['type'] ?? ''; $errorCode = $result['error']['code'] ?? ''; $errorMessages = [ 'invalid_request_error' => '请求参数错误', 'authentication_error' => '认证失败', 'rate_limit_error' => '请求频率过高', 'insufficient_quota' => '额度不足', 'billing_not_active' => '账户未开通付费', 'content_policy_violation' => '内容违反政策', 'model_not_found' => '模型不存在或无可用渠道', 'bad_response_body' => '上游响应体不完整', 'server_error' => '服务端临时异常' ]; $friendlyMessage = $errorMessages[$errorCode] ?? ($errorMessages[$errorType] ?? 'API服务错误'); $detailedError = "第" . ($index + 1) . "个接口{$friendlyMessage}"; if ($errorCode) $detailedError .= " (错误代码: {$errorCode})"; if ($apiErrorDetail) $detailedError .= ": {$apiErrorDetail}"; $allErrors[] = $detailedError; $httpCodes[] = $httpCode; $curlErrnos[] = $curlErrno; continue; } // 检查HTTP状态码 if ($httpCode !== 200) { $statusMessages = [ 400 => '请求参数不合法', 401 => 'API密钥无效或权限不足', 403 => '访问被拒绝', 404 => 'API端点不存在', 429 => '请求过于频繁,请稍后再试', 500 => '服务器内部错误', 503 => '服务暂时不可用' ]; $statusMessage = $statusMessages[$httpCode] ?? "HTTP错误({$httpCode})"; $allErrors[] = "第" . ($index + 1) . "个接口{$statusMessage}"; $httpCodes[] = $httpCode; $curlErrnos[] = $curlErrno; continue; } // 任意一个接口成功,直接返回结果 curl_close($ch); return $result; } catch (\Exception $e) { // 捕获当前接口的异常,记录后尝试下一个 $allErrors[] = "第" . ($index + 1) . "个接口异常: " . $e->getMessage(); $httpCodes[] = 0; $curlErrnos[] = 0; if (is_resource($ch)) { curl_close($ch); } continue; } } // 所有接口都失败,抛出汇总异常 $finalError = "所有API接口调用失败(共尝试" . count($aiModelConfigs) . "个接口)\n"; $finalError .= "失败详情:\n- " . implode("\n- ", $allErrors) . "\n"; $lastHttpCode = $httpCodes[count($httpCodes) - 1] ?? 0; $lastCurlErrno = $curlErrnos[count($curlErrnos) - 1] ?? 0; $finalError .= "建议解决方案: " . $this->getErrorSolution($lastHttpCode, $lastCurlErrno) . "\n"; throw new \Exception($finalError); } /** * 获取错误原因 */ private function getErrorCause(int $httpCode, string $apiErrorDetail, int $curlErrno): string { $causeMap = [ // HTTP状态码 400 => '参数格式错误/必填参数缺失', 401 => 'API Key无效/过期/无权限', 429 => '超出接口调用频率限制', 500 => '服务端内部故障', 503 => '服务维护/算力不足', // CURL错误码 CURLE_OPERATION_TIMEDOUT => '请求超时(模型处理耗时超过设置值)', CURLE_COULDNT_CONNECT => '无法连接到API服务器', CURLE_SSL_CACERT => 'SSL证书验证失败', // 业务错误关键词 'model_not_found' => '模型渠道未开通/无可用资源', 'invalid_size' => '模型尺寸参数不符合要求', ]; // 优先匹配CURL错误码 if (isset($causeMap[$curlErrno])) { return $causeMap[$curlErrno]; } // 匹配HTTP状态码 if (isset($causeMap[$httpCode])) { return $causeMap[$httpCode]; } // 匹配业务错误关键词 foreach ($causeMap as $key => $cause) { if (is_string($key) && strpos($apiErrorDetail, $key) !== false) { return $cause; } } // 特殊关键词匹配 if (strpos($apiErrorDetail, 'No available capacity') !== false) { return '模型算力不足,无可用资源'; } elseif (strpos($apiErrorDetail, 'size is invalid') !== false) { return '模型尺寸参数无效'; } // 兜底 return $apiErrorDetail ?: '未知原因'; } /** * 获取错误解决方案 */ private function getErrorSolution(int $httpCode, int $curlErrno): string { $solutionMap = [ // HTTP状态码 400 => '1. 检查参数是否完整 2. 确认参数类型 3. 验证尺寸/模型名是否合法', 401 => '1. 检查API Key是否正确 2. 确认Key未过期/有对应模型权限', 429 => '1. 降低请求频率 2. 等待1-5分钟后重试 3. 联系服务商提升配额', 500 => '1. 等待几分钟后重试 2. 联系API服务商排查', 503 => '1. 等待算力释放 2. 联系服务商扩容', // CURL错误码 CURLE_OPERATION_TIMEDOUT => '1. 延长超时时间 2. 检查模型生成耗时 3. 重试请求', CURLE_COULDNT_CONNECT => '1. 检查API地址是否正确 2. 验证网络连通性 3. 检查防火墙配置', CURLE_SSL_CACERT => '1. 开启SSL证书验证 2. 配置正确的CA证书路径 3. 确认API域名证书有效', ]; if (isset($solutionMap[$curlErrno])) { return $solutionMap[$curlErrno]; } if (isset($solutionMap[$httpCode])) { return $solutionMap[$httpCode]; } return '1. 等待几分钟后重试 2. 检查API服务提供商状态 3. 联系服务提供商确认服务可用性'; } // /** // * 通用 API 调用方法(支持重试机制) // * // * @param string $url 接口地址 // * @param string $apiKey 授权密钥(Bearer Token) // * @param array $data 请求数据(JSON 格式) // * // * 功能说明: // * - 使用 cURL 发送 POST 请求到指定 API 接口 // * - 设置请求头和超时时间等参数 // * - 支持最多重试 2 次,当接口调用失败时自动重试 // * - 返回成功时解析 JSON 响应为数组 // * // * 异常处理: // * - 若全部重试失败,将抛出异常并包含最后一次错误信息 // * // * @return array 接口响应数据(成功时返回解析后的数组) // * @throws \Exception 接口请求失败时抛出异常 // */ // public static function callApi($data,$model,$timeout = 60) // { // $maxRetries = 0; // 减少重试次数为0,避免不必要的等待 // $attempt = 0; // $lastError = ''; // $httpCode = 0; // $apiErrorDetail = ''; // // $ai_model = Db::name("ai_model") // ->where('model_name',$model) // ->select(); // $num = 0; // while ($attempt <= $maxRetries) { // try { // if(!$ai_model[$num]){ // throw new \Exception("请求发送失败: " . $curlError); // } // $ch = curl_init(); // curl_setopt_array($ch, [ // CURLOPT_URL => $ai_model[$num]['api_url'], // CURLOPT_RETURNTRANSFER => true, // CURLOPT_POST => true, // CURLOPT_POSTFIELDS => json_encode($data, JSON_UNESCAPED_UNICODE), // CURLOPT_HTTPHEADER => [ // 'Content-Type: application/json', // 'Authorization: Bearer ' . $ai_model[$num]['api_key'] // ], // CURLOPT_TIMEOUT => (int) $timeout, // CURLOPT_SSL_VERIFYPEER => false, // CURLOPT_SSL_VERIFYHOST => false, // CURLOPT_CONNECTTIMEOUT => 15, // 减少连接超时时间为15秒 // CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1, // CURLOPT_FAILONERROR => false // ]); // // $response = curl_exec($ch); // $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); // $curlError = curl_error($ch); // // if ($response === false) { // // 尝试从curl错误信息中提取HTTP状态码 // if (preg_match('/HTTP\/[0-9.]+\s+([0-9]+)/', $curlError, $matches)) { // $httpCode = (int)$matches[1]; // } // throw new \Exception("请求发送失败: " . $curlError); // } // // $result = json_decode($response, true); // // // 检查API返回的错误 // if (isset($result['error'])) { // $apiErrorDetail = $result['error']['message'] ?? ''; // $errorType = $result['error']['type'] ?? ''; // $errorCode = $result['error']['code'] ?? ''; // // // 常见错误类型映射 // $errorMessages = [ // 'invalid_request_error' => '请求参数错误', // 'authentication_error' => '认证失败', // 'rate_limit_error' => '请求频率过高', // 'insufficient_quota' => '额度不足', // 'billing_not_active' => '账户未开通付费', // 'content_policy_violation' => '内容违反政策', // 'model_not_found' => '模型不存在或无可用渠道' // ]; // // // 优先使用errorCode进行映射,如果没有则使用errorType // $friendlyMessage = $errorMessages[$errorCode] ?? ($errorMessages[$errorType] ?? 'API服务错误'); // // // 构建详细的错误信息,包含错误代码、类型和详细描述 // $detailedError = "{$friendlyMessage}"; // if ($errorCode) { // $detailedError .= " (错误代码: {$errorCode})"; // } // if ($apiErrorDetail) { // $detailedError .= ": {$apiErrorDetail}"; // } // // throw new \Exception($detailedError); // } // // if ($httpCode !== 200) { // // HTTP状态码映射 // $statusMessages = [ // 400 => '请求参数不合法', // 401 => 'API密钥无效或权限不足', // 403 => '访问被拒绝', // 404 => 'API端点不存在', // 429 => '请求过于频繁,请稍后再试', // 500 => '服务器内部错误', // 503 => '服务暂时不可用' // ]; // // $statusMessage = $statusMessages[$httpCode] ?? "HTTP错误({$httpCode})"; // throw new \Exception($statusMessage); // } // // curl_close($ch); // return $result; // // } catch (\Exception $e) { // $lastError = $e->getMessage(); // $attempt++; // $num++; // if ($attempt <= $maxRetries) { // sleep(pow(2, $attempt)); // } else { // // 最终失败时的详细错误信息 // $errorDetails = [ // '错误原因' => self::getErrorCause($httpCode, $apiErrorDetail), // '解决方案' => self::getErrorSolution($httpCode), // '请求参数' => json_encode($data, JSON_UNESCAPED_UNICODE), // 'HTTP状态码' => $httpCode, // '重试次数' => $attempt // ]; // // // 构建最终的错误信息,优先显示原始的详细错误消息 // $finalError = "API请求失败\n"; // $finalError .= "失败说明: " . $lastError . "\n"; // 使用原始的详细错误消息 // $finalError .= "建议解决方案: " . $errorDetails['解决方案'] . "\n"; // $finalError .= "技术详情: HTTP {$httpCode} - " . $errorDetails['错误原因']; // // throw new \Exception($finalError); // } // } // } // } // // private static function getErrorCause($httpCode, $apiErrorDetail) // { // $causeMap = [ // 400 => '参数格式错误/必填参数缺失', // 401 => 'API Key无效/过期/无权限', // 429 => '超出接口调用频率限制', // 500 => '服务端内部故障', // 503 => '服务维护/算力不足', // 'model_not_found' => '模型渠道未开通/无可用资源', // 'invalid_size' => '模型尺寸参数不符合要求', // CURLE_OPERATION_TIMEDOUT => '请求超时(模型处理耗时超过设置值)' // ]; // // if (strpos($apiErrorDetail, 'No available capacity') !== false) { // return '模型算力不足,无可用资源'; // } elseif (strpos($apiErrorDetail, 'size is invalid') !== false) { // return '模型尺寸参数无效'; // } elseif (isset($causeMap[$httpCode])) { // return $causeMap[$httpCode]; // } // return $apiErrorDetail ?: '未知原因'; // } // // private static function getErrorSolution($httpCode) // { // $solutionMap = [ // 400 => '1. 检查参数是否完整 2. 确认参数类型(如seconds为字符串) 3. 验证尺寸/模型名是否合法', // 401 => '1. 检查API Key是否正确 2. 确认Key未过期/有对应模型权限', // 429 => '1. 降低请求频率 2. 等待1-5分钟后重试', // 500 => '1. 等待几分钟后重试 2. 联系API服务商排查', // 503 => '1. 等待算力释放 2. 联系服务商扩容', // CURLE_OPERATION_TIMEDOUT => '1. 延长超时时间 2. 检查模型生成耗时 3. 重试请求' // ]; // // if (isset($solutionMap[$httpCode])) { // return $solutionMap[$httpCode]; // } // return '1. 等待几分钟后重试 2. 检查API服务提供商状态 3. 联系服务提供商确认服务可用性'; // } /** * 获取图片的base64数据和MIME类型 * @return array 包含base64数据和MIME类型的数组 */ public static function file_get_contents($ImageUrl){ // 兼容三种输入: // 1) 本地相对路径:uploads/xxx.png 或 /uploads/xxx.png // 2) 已是完整 URL:https://.../xxx.png // 3) OSS 相对路径(本地不存在时,用 oss.host 兜底拉取) $imageUrl = trim((string)$ImageUrl); if ($imageUrl === '') { throw new \Exception('图片路径不能为空'); } $rootPath = str_replace('\\', '/', ROOT_PATH); $relativePath = ltrim($imageUrl, '/'); $localPath = rtrim($rootPath, '/') . '/public/' . $relativePath; // 前端可能传了 URL 编码文件名(如中文名),本地匹配时做一次解码兜底 $localPathDecoded = rtrim($rootPath, '/') . '/public/' . urldecode($relativePath); $imageContent = false; $mimeType = ''; // A. 直接是完整 URL,直接远程读取 if (preg_match('/^https?:\/\//i', $imageUrl)) { $imageContent = @file_get_contents($imageUrl); } else { // B. 本地优先:先按原路径查,再按 urldecode 后路径查 if (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 { // C. 本地不存在:尝试从 OSS 拉取 $ossHost = trim((string)config('oss.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均未读取到)'); } // 若本地未拿到 MIME,则根据二进制内容推断 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 ]; } /** * 从 AI 响应中提取图片 base64(兼容 Gemini / OpenAI 多种返回格式) */ public function extractImageBase64FromResponse(array $res): ?string { // 兼容网关包装:{ "data": { "candidates": [...] } } if (empty($res['candidates']) && !empty($res['data']) && is_array($res['data'])) { if (!empty($res['data']['candidates'])) { $res = array_merge($res, $res['data']); } elseif (!empty($res['data']['choices'])) { $res = array_merge($res, $res['data']); } } if (!empty($res['data'][0]['b64_json'])) { return preg_replace('/\s+/', '', (string)$res['data'][0]['b64_json']); } if (!empty($res['data'][0]['url'])) { $content = @file_get_contents((string)$res['data'][0]['url']); if ($content !== false && $content !== '') { return base64_encode($content); } } if (!empty($res['choices'][0]['message']['content'])) { $content = $res['choices'][0]['message']['content']; if (is_string($content)) { $parsed = $this->parseBase64FromText($content); if ($parsed) { return $parsed; } } elseif (is_array($content)) { foreach ($content as $item) { if (($item['type'] ?? '') === 'image_url' && !empty($item['image_url']['url'])) { $parsed = $this->parseBase64FromText((string)$item['image_url']['url']); if ($parsed) { return $parsed; } } if (!empty($item['b64_json'])) { return preg_replace('/\s+/', '', (string)$item['b64_json']); } } } } // Gemini 出图(与 TextToImageJob 线上一致) if (!empty($res['candidates'][0]['content']['parts']) && is_array($res['candidates'][0]['content']['parts'])) { foreach ($res['candidates'][0]['content']['parts'] as $part) { foreach (['inlineData', 'inline_data'] as $key) { if (!empty($part[$key]['data'])) { $raw = preg_replace('/\s+/', '', (string)$part[$key]['data']); if (preg_match('/^data:image\//i', $raw)) { $parsed = $this->parseBase64FromText($raw); if ($parsed) { return $parsed; } } elseif (strlen($raw) > 100 && base64_decode($raw, true) !== false) { return $raw; } } } if (!empty($part['text']) && preg_match('/data:image\/(png|jpg|jpeg|webp);base64,(.+)$/is', (string)$part['text'], $m)) { return preg_replace('/\s+/', '', $m[2]); } } } if (!empty($res['candidates']) && is_array($res['candidates'])) { foreach ($res['candidates'] as $candidate) { $parts = $candidate['content']['parts'] ?? []; foreach ($parts as $part) { foreach (['inlineData', 'inline_data'] as $key) { if (!empty($part[$key]['data'])) { $parsed = $this->parseBase64FromText((string)$part[$key]['data']); if ($parsed) { return $parsed; } } } foreach (['fileData', 'file_data'] as $key) { $uri = $part[$key]['fileUri'] ?? ($part[$key]['file_uri'] ?? ''); if ($uri !== '') { $parsed = $this->fetchImageBase64FromUri((string)$uri); if ($parsed) { return $parsed; } } } if (!empty($part['text'])) { $parsed = $this->parseBase64FromText((string)$part['text']); if ($parsed) { return $parsed; } } } } } return $this->findImageBase64Deep($res); } /** * 深度递归扫描响应中的图片数据 */ private function findImageBase64Deep($node, int $depth = 0): ?string { if ($depth > 15) { return null; } if (is_string($node)) { return $this->parseBase64FromText($node); } if (!is_array($node)) { return null; } foreach (['inlineData', 'inline_data'] as $key) { if (!empty($node[$key]['data'])) { $parsed = $this->parseBase64FromText((string)$node[$key]['data']); if ($parsed) { return $parsed; } } } if (!empty($node['b64_json'])) { return preg_replace('/\s+/', '', (string)$node['b64_json']); } if (!empty($node['url']) && is_string($node['url'])) { $parsed = $this->fetchImageBase64FromUri($node['url']); if ($parsed) { return $parsed; } } foreach ($node as $value) { if (is_array($value) || is_string($value)) { $found = $this->findImageBase64Deep($value, $depth + 1); if ($found) { return $found; } } } return null; } /** * 从 URL / data URI 拉取图片并转 base64 */ private function fetchImageBase64FromUri(string $uri): ?string { $uri = trim($uri); if ($uri === '') { return null; } if (strpos($uri, 'data:') === 0) { return $this->parseBase64FromText($uri); } if (!preg_match('/^https?:\/\//i', $uri)) { return null; } $content = @file_get_contents($uri); if ($content === false || $content === '') { return null; } return base64_encode($content); } /** * 图片提取失败时的可读错误信息 */ public function describeImageExtractFailure(array $res): string { if (!empty($res['error']['message'])) { return (string)$res['error']['message']; } $text = ''; if (!empty($res['candidates'][0]['content']['parts'])) { foreach ($res['candidates'][0]['content']['parts'] as $part) { if (!empty($part['text']) && $text === '') { $text = (string)$part['text']; } } } if ($text === '' && !empty($res['choices'][0]['message']['content'])) { $msgContent = $res['choices'][0]['message']['content']; if (is_string($msgContent)) { $text = $msgContent; } } $reason = $res['candidates'][0]['finishReason'] ?? ($res['choices'][0]['finish_reason'] ?? ''); if ($text !== '') { $prefix = $reason !== '' ? "模型未返回图片({$reason})" : '未获取到图片数据'; return $prefix . ': ' . mb_substr($text, 0, 150); } if ($reason !== '' && strtoupper((string)$reason) !== 'STOP') { return '模型未返回图片: ' . $reason; } return '未获取到图片数据,请检查模型是否支持出图'; } /** * 图片提取失败时写入调试日志(截断 base64,避免日志过大) */ public function logImageResponseDebug(array $res, string $taskId = ''): void { try { $sanitized = $this->sanitizeResponseForLog($res); Log::write( '[AI image extract failed] task=' . $taskId . ' response=' . mb_substr(json_encode($sanitized, JSON_UNESCAPED_UNICODE), 0, 4000), 'error' ); } catch (\Throwable $e) { // 日志失败不阻断 } } /** * @param mixed $node * @return mixed */ private function sanitizeResponseForLog($node, int $depth = 0) { if ($depth > 10) { return '...'; } if (is_string($node)) { return strlen($node) > 100 ? (substr($node, 0, 50) . '...(len=' . strlen($node) . ')') : $node; } if (!is_array($node)) { return $node; } $out = []; foreach ($node as $key => $value) { if (in_array($key, ['data', 'b64_json'], true) && is_string($value) && strlen($value) > 100) { $out[$key] = '...(len=' . strlen($value) . ')'; continue; } $out[$key] = $this->sanitizeResponseForLog($value, $depth + 1); } return $out; } /** * 解析 data:image/...;base64 或裸 base64 字符串 */ private function parseBase64FromText(string $content): ?string { $content = trim($content); if ($content === '') { return null; } if (preg_match('/data:image\/(?:png|jpg|jpeg|webp);base64,(.+)$/is', $content, $m)) { return preg_replace('/\s+/', '', $m[1]); } $clean = preg_replace('/\s+/', '', $content); if (strlen($clean) > 100 && base64_decode($clean, true) !== false) { return $clean; } return null; } }