lastImg2ImgSuccessMeta; } /** * 格式化图生图成功报告(队列日志可读) */ public function formatImg2ImgSuccessReport(string $imagePath): string { $meta = $this->lastImg2ImgSuccessMeta; if (empty($meta)) { return "图生图成功\n生成图片:{$imagePath}\n完成时间:" . date('Y-m-d H:i:s'); } $lines = [ '图生图成功(第 ' . $meta['attempt_no'] . ' 个模型生效,共配置 ' . $meta['total'] . ' 个模型)', '', '━━━━━━━━ 成功模型 ━━━━━━━━', '模型:' . $meta['model_name'] . '(sort=' . $meta['sort'] . ' | id=' . $meta['id'] . ' | ' . $meta['supplier'] . ')', '结果:成功', '生成图片:' . $imagePath, '完成时间:' . date('Y-m-d H:i:s'), ]; if (!empty($meta['skipped'])) { $lines[] = ''; $lines[] = '说明:前 ' . count($meta['skipped']) . ' 个模型已自动跳过(失败)'; foreach ($meta['skipped'] as $i => $skip) { $lines[] = ' - 模型' . ($i + 1) . ' ' . $skip['model_name'] . ':' . $skip['brief']; } } return implode("\n", $lines); } /** * 根据模型与任务类型构建 API 请求体(主分发方法) * @param string $status_val 任务类型:图生文、文生文、文生图、图生图 * @param string $model 模型名,如 gpt-4、gemini_flash、dall-e-3 等 * @param string $prompt 提示词 * @param string $size 尺寸或比例,如 850x1133 或 9:16(文生图/图生图用) * @param string $product_base64Data 产品图 base64(图生图用) * @param string $product_mimeType 产品图 MIME 类型(图生图用) * @param string $template_base64Data 模板图 base64(图生图用) * @param string $template_mimeType 模板图 MIME 类型(图生图用) * @return array API 响应 * @throws \Exception */ public function buildRequestData( string $status_val, string $model, string $prompt, string $size = '', string $product_base64Data = '', string $product_mimeType = '', string $template_base64Data = '', string $template_mimeType = '' ) { // 1. 通用参数校验(提前拦截无效参数) $validStatus = ['图生文', '文生文', '文生图', '图生图']; if (!in_array($status_val, $validStatus)) { throw new \Exception("无效的任务类型: {$status_val}"); } // 图生图:按 ai_model 中 model_type=图生图 的 sort 依次尝试(同模型内仍走 callApi 多供应商切换) if ($status_val === '图生图') { return $this->executeImg2ImgWithFailover( $model, $prompt, $size, $product_base64Data, $product_mimeType, $template_base64Data, $template_mimeType ); } // 2. 按「模型+任务类型」分发到对应细分方法 switch (true) { // 文生文 case $status_val === '文生文' && $model === 'gemini_flash': $data = $this->buildText2TextGeminiFlash($prompt); break; case $status_val === '文生文' && $model === 'gpt-4': $data = $this->buildText2TextOpenAi($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; // 未匹配的组合 default: throw new \Exception("未配置模型+任务类型组合: {$model}({$status_val})"); } // 3. 统一调用 API(文生图出图耗时较长,适当放宽超时时间) $timeout = 60; if ($status_val === '文生图' && $model === 'gemini-3-pro-image-preview') { $timeout = 180; } return $this->callApi($data, $model, $timeout); } /** * 图生图:按 ai_model 中「图生图」能力 + sort 依次尝试;每个 model_name 内仍按 callApi 切换供应商 */ private function executeImg2ImgWithFailover( string $preferredModel, string $prompt, string $size, string $productBase64, string $productMimeType, string $templateBase64, string $templateMimeType ): array { $models = $this->resolveImg2ImgModels(); if (empty($models)) { throw new \Exception('未配置图生图模型,请在 ai_model 表中添加 model_type 含「图生图」且 status=1 的记录'); } $attempts = []; $skipped = []; $timeout = 180; foreach ($models as $index => $modelInfo) { $modelName = $modelInfo['model_name']; try { $data = $this->buildImg2ImgPayload( $modelName, $prompt, $size, $productBase64, $productMimeType, $templateBase64, $templateMimeType ); $result = $this->callApi($data, $modelName, $timeout); $this->lastImg2ImgSuccessMeta = [ 'model_name' => $modelName, 'sort' => $modelInfo['sort'], 'id' => $modelInfo['id'], 'supplier' => $modelInfo['supplier'], 'attempt_no' => $index + 1, 'total' => count($models), 'skipped' => $skipped, ]; return $result; } catch (\Exception $e) { $classified = $this->classifyImg2ImgFailure($e->getMessage()); $skipped[] = [ 'model_name' => $modelName, 'brief' => $classified['brief'], ]; $attempts[] = array_merge($modelInfo, $classified); Log::warning('[图生图] 尝试模型 ' . ($index + 1) . ' 失败: ' . $modelName . ' | ' . $classified['brief']); } } $this->lastImg2ImgSuccessMeta = null; throw new \Exception($this->formatImg2ImgFailureReport($attempts, count($models))); } /** * 获取图生图模型列表(去重、严格按 sort/id;含 supplier 供日志展示) */ private function resolveImg2ImgModels(): array { $rows = Db::name('ai_model') ->where('status', '1') ->whereRaw("FIND_IN_SET('图生图', model_type) > 0") ->order('sort ASC, id ASC') ->field('id,model_name,sort,supplier') ->select(); $ordered = []; $seen = []; foreach ($rows as $row) { $name = trim((string)$row['model_name']); if ($name === '' || isset($seen[$name])) { continue; } $seen[$name] = true; $ordered[] = [ 'id' => (int)$row['id'], 'model_name' => $name, 'sort' => trim((string)($row['sort'] ?? '')), 'supplier' => trim((string)($row['supplier'] ?? '')) ?: '未知供应商', ]; } return $ordered; } /** * 图生图多模型失败汇总(队列日志 / Redis 可读格式) */ private function formatImg2ImgFailureReport(array $attempts, int $total): string { $lines = [ "图生图失败(共尝试 {$total} 个模型)", '', ]; foreach ($attempts as $index => $attempt) { $no = $index + 1; $lines[] = "━━━━━━━━ 尝试模型 {$no} ━━━━━━━━"; $lines[] = "模型:{$attempt['model_name']}(sort={$attempt['sort']} | id={$attempt['id']} | {$attempt['supplier']})"; $lines[] = '结果:失败'; $lines[] = '失败原因:' . $attempt['brief']; $lines[] = '接口问题:' . $attempt['api_issue']; $lines[] = '供应商问题:' . $attempt['supplier_issue']; $lines[] = '建议解决方案:' . $attempt['solution']; $lines[] = ''; } return rtrim(implode("\n", $lines)); } /** * 图生图单次失败归类:接口问题 vs 供应商问题 */ private function classifyImg2ImgFailure(string $rawMessage): array { $apiIssue = false; $supplierIssue = false; $solutions = []; if (preg_match('/模型配置不存在|URL\/Key为空|JSON编码失败|未配置图生图|invalid_request|contents is required|请求参数不合法|请求参数错误|API端点不存在/u', $rawMessage)) { $apiIssue = true; $solutions[] = '检查 ai_model:model_name、api_url(需 :generateContent)、api_key 是否正确'; } if (preg_match('/503|No available tokens|upstream|do_request_failed|insufficient_quota|算力|额度|model_not_found|server_error|服务暂时不可用|服务器内部错误|bad_response_body/u', $rawMessage)) { $supplierIssue = true; $solutions[] = '联系 API 供应商确认 Key 额度、模型通道是否正常'; } if (preg_match('/CURL失败|CURLE_|Could not connect|Connection timed out|timed out after|无法连接/u', $rawMessage)) { $supplierIssue = true; $solutions[] = '检查服务器到 API 域名的网络(防火墙、超时、是否被墙)'; } if (preg_match('/401|403|authentication|认证失败|API密钥无效/u', $rawMessage)) { $apiIssue = true; $supplierIssue = true; $solutions[] = '核对 api_key 是否与供应商后台令牌一致'; } if (preg_match('/429|rate_limit|请求过于频繁/u', $rawMessage)) { $supplierIssue = true; $solutions[] = '降低调用频率或联系供应商提升配额'; } if (!$apiIssue && !$supplierIssue) { $supplierIssue = true; $solutions[] = '等待几分钟后重试,或联系 API 服务商排查'; } return [ 'brief' => $this->extractBriefImg2ImgError($rawMessage), 'api_issue' => $apiIssue ? '是' : '否', 'supplier_issue' => $supplierIssue ? '是' : '否', 'solution' => implode(';', array_unique($solutions)), ]; } /** 从 callApi 异常中提取一行简短原因 */ private function extractBriefImg2ImgError(string $rawMessage): string { $rawMessage = trim(str_replace(["\r\n", "\r"], "\n", $rawMessage)); if (preg_match('/模型配置不存在:\s*(.+)/u', $rawMessage, $m)) { return '模型配置不存在: ' . trim($m[1]); } if (preg_match('/\(错误代码:\s*([^)]+)\):\s*(.+?)(?:\n|$)/u', $rawMessage, $m)) { return trim($m[1]) . ': ' . trim($m[2]); } foreach (explode("\n", $rawMessage) as $line) { $line = trim($line, "- \t"); if ($line === '' || strpos($line, '建议解决方案') !== false || strpos($line, '失败详情') !== false) { continue; } if (preg_match('/^第\d+个接口/u', $line)) { return $line; } } $firstLine = strtok($rawMessage, "\n"); return mb_substr($firstLine ?: $rawMessage, 0, 180); } /** * 按 model_name 构建图生图请求体(Gemini generateContent / 火山 Seedream 自动识别) */ private function buildImg2ImgPayload( string $model, string $prompt, string $size, string $productBase64, string $productMimeType, string $templateBase64, string $templateMimeType ): array { if ($this->isSeedreamImg2ImgModel($model)) { return $this->buildImage2ImageSeedream( $model, $prompt, $size, $productBase64, $productMimeType, $templateBase64, $templateMimeType ); } return $this->buildImage2ImageGemini3ProImage( $prompt, $size, $productBase64, $productMimeType, $templateBase64, $templateMimeType ); } /** 火山方舟 Seedream 图生图模型(model_name 含 seedream) */ private function isSeedreamImg2ImgModel(string $model): bool { return stripos($model, 'seedream') !== false; } /** * 图生图 - 火山引擎 Seedream(OpenAI 兼容 images/generations,支持多参考图) */ private function buildImage2ImageSeedream( string $model, string $prompt, string $size, string $productBase64, string $productMimeType, string $templateBase64, string $templateMimeType ): array { $images = []; if ($productBase64 !== '') { $images[] = $this->toSeedreamImageRef($productBase64, $productMimeType); } if ($templateBase64 !== '') { $ref = $this->toSeedreamImageRef($templateBase64, $templateMimeType); if (!in_array($ref, $images, true)) { $images[] = $ref; } } if (empty($images)) { throw new \Exception('图生图参考图不能为空'); } return [ 'model' => $model, 'prompt' => $prompt, 'image' => count($images) === 1 ? $images[0] : $images, 'size' => $this->mapSizeToSeedream($size), 'sequential_image_generation' => 'disabled', 'response_format' => 'b64_json', 'watermark' => false, ]; } private function toSeedreamImageRef(string $base64, string $mimeType): string { $base64 = preg_replace('/\s+/', '', $base64); $mimeType = trim($mimeType) ?: 'image/jpeg'; return 'data:' . $mimeType . ';base64,' . $base64; } /** 将 1024x1024 / 850x1133 等映射为 Seedream 支持的 1K/2K/4K */ private function mapSizeToSeedream(string $size): string { $size = trim($size); if ($size === '') { return '2K'; } if (preg_match('/^(\d)[kK]$/', $size, $m)) { return $m[1] . 'K'; } if (strpos($size, 'x') !== false) { $parts = explode('x', $size, 2); $w = (int)($parts[0] ?? 0); $h = (int)($parts[1] ?? 0); $max = max($w, $h); if ($max >= 3000) { return '4K'; } if ($max >= 1400) { return '2K'; } return '1K'; } return '2K'; } // -------------------------- 细分方法:按「任务类型+模型」拆分 -------------------------- /** * 文生文 - 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" ] ]; } /** * 文生文 - OpenAI 兼容格式(gpt-4 / gpt-4o 等,model 与 ai_model.model_name 一致) */ private function buildText2TextOpenAi(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' => ['TEXT', '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' => ['TEXT', 'IMAGE'], 'imageConfig' => ['aspectRatio' => $size !== '' ? $size : '1:1', 'imageSize' => '1K'] ] ]; } /** * 构建视频请求体 * $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; } /** * cURL SSL 选项:证书可读则验证,否则关闭(兼容 Windows 无 CA、Linux open_basedir 限制) */ private function applyCurlSslOptions(array &$options): void { $caFile = trim((string)(ini_get('curl.cainfo') ?: ini_get('openssl.cafile'))); // 须加 @:线上 open_basedir 不允许读 /etc/pki/... 时,is_readable 会告警并中断请求 if ($caFile !== '' && @is_readable($caFile)) { $options[CURLOPT_SSL_VERIFYPEER] = true; $options[CURLOPT_SSL_VERIFYHOST] = 2; $options[CURLOPT_CAINFO] = $caFile; return; } $options[CURLOPT_SSL_VERIFYPEER] = false; $options[CURLOPT_SSL_VERIFYHOST] = 0; } /** * 通用 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, CURLOPT_TCP_NODELAY => true, CURLOPT_FORBID_REUSE => false, CURLOPT_FRESH_CONNECT => false ]; $this->applyCurlSslOptions($curlOptions); 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 出图 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; } }