| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444 |
- <?php
- namespace app\service;
- use think\Db;
- use think\Log;
- use think\Queue;
- class AIGatewayService{
- /** 图生图成功时记录实际使用的模型(供日志 / Redis) */
- private $lastImg2ImgSuccessMeta = null;
- /**
- * 获取最近一次图生图成功信息(无则 null)
- */
- public function getLastImg2ImgSuccessMeta(): ?array
- {
- return $this->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;
- }
- }
|