AIGatewayService.php 56 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444
  1. <?php
  2. namespace app\service;
  3. use think\Db;
  4. use think\Log;
  5. use think\Queue;
  6. class AIGatewayService{
  7. /** 图生图成功时记录实际使用的模型(供日志 / Redis) */
  8. private $lastImg2ImgSuccessMeta = null;
  9. /**
  10. * 获取最近一次图生图成功信息(无则 null)
  11. */
  12. public function getLastImg2ImgSuccessMeta(): ?array
  13. {
  14. return $this->lastImg2ImgSuccessMeta;
  15. }
  16. /**
  17. * 格式化图生图成功报告(队列日志可读)
  18. */
  19. public function formatImg2ImgSuccessReport(string $imagePath): string
  20. {
  21. $meta = $this->lastImg2ImgSuccessMeta;
  22. if (empty($meta)) {
  23. return "图生图成功\n生成图片:{$imagePath}\n完成时间:" . date('Y-m-d H:i:s');
  24. }
  25. $lines = [
  26. '图生图成功(第 ' . $meta['attempt_no'] . ' 个模型生效,共配置 ' . $meta['total'] . ' 个模型)',
  27. '',
  28. '━━━━━━━━ 成功模型 ━━━━━━━━',
  29. '模型:' . $meta['model_name'] . '(sort=' . $meta['sort'] . ' | id=' . $meta['id'] . ' | ' . $meta['supplier'] . ')',
  30. '结果:成功',
  31. '生成图片:' . $imagePath,
  32. '完成时间:' . date('Y-m-d H:i:s'),
  33. ];
  34. if (!empty($meta['skipped'])) {
  35. $lines[] = '';
  36. $lines[] = '说明:前 ' . count($meta['skipped']) . ' 个模型已自动跳过(失败)';
  37. foreach ($meta['skipped'] as $i => $skip) {
  38. $lines[] = ' - 模型' . ($i + 1) . ' ' . $skip['model_name'] . ':' . $skip['brief'];
  39. }
  40. }
  41. return implode("\n", $lines);
  42. }
  43. /**
  44. * 根据模型与任务类型构建 API 请求体(主分发方法)
  45. * @param string $status_val 任务类型:图生文、文生文、文生图、图生图
  46. * @param string $model 模型名,如 gpt-4、gemini_flash、dall-e-3 等
  47. * @param string $prompt 提示词
  48. * @param string $size 尺寸或比例,如 850x1133 或 9:16(文生图/图生图用)
  49. * @param string $product_base64Data 产品图 base64(图生图用)
  50. * @param string $product_mimeType 产品图 MIME 类型(图生图用)
  51. * @param string $template_base64Data 模板图 base64(图生图用)
  52. * @param string $template_mimeType 模板图 MIME 类型(图生图用)
  53. * @return array API 响应
  54. * @throws \Exception
  55. */
  56. public function buildRequestData(
  57. string $status_val,
  58. string $model,
  59. string $prompt,
  60. string $size = '',
  61. string $product_base64Data = '',
  62. string $product_mimeType = '',
  63. string $template_base64Data = '',
  64. string $template_mimeType = ''
  65. ) {
  66. // 1. 通用参数校验(提前拦截无效参数)
  67. $validStatus = ['图生文', '文生文', '文生图', '图生图'];
  68. if (!in_array($status_val, $validStatus)) {
  69. throw new \Exception("无效的任务类型: {$status_val}");
  70. }
  71. // 图生图:按 ai_model 中 model_type=图生图 的 sort 依次尝试(同模型内仍走 callApi 多供应商切换)
  72. if ($status_val === '图生图') {
  73. return $this->executeImg2ImgWithFailover(
  74. $model,
  75. $prompt,
  76. $size,
  77. $product_base64Data,
  78. $product_mimeType,
  79. $template_base64Data,
  80. $template_mimeType
  81. );
  82. }
  83. // 2. 按「模型+任务类型」分发到对应细分方法
  84. switch (true) {
  85. // 文生文
  86. case $status_val === '文生文' && $model === 'gemini_flash':
  87. $data = $this->buildText2TextGeminiFlash($prompt);
  88. break;
  89. case $status_val === '文生文' && $model === 'gpt-4':
  90. $data = $this->buildText2TextOpenAi($prompt);
  91. break;
  92. // 文生图
  93. case $status_val === '文生图' && $model === 'dall-e-3':
  94. $data = $this->buildText2ImageDallE3($prompt, $size);
  95. break;
  96. case $status_val === '文生图' && $model === 'gemini-3-pro-preview':
  97. $data = $this->buildText2ImageGemini3Pro($prompt, $size);
  98. break;
  99. case $status_val === '文生图' && $model === 'gemini-3-pro-image-preview':
  100. $data = $this->buildText2ImageGemini3ProImage($prompt, $size);
  101. break;
  102. // 图生文
  103. case $status_val === '图生文' && $model === 'gemini-3-pro-preview':
  104. $data = $this->buildImage2TextGemini3Pro($prompt, $product_base64Data, $product_mimeType);
  105. break;
  106. // 未匹配的组合
  107. default:
  108. throw new \Exception("未配置模型+任务类型组合: {$model}({$status_val})");
  109. }
  110. // 3. 统一调用 API(文生图出图耗时较长,适当放宽超时时间)
  111. $timeout = 60;
  112. if ($status_val === '文生图' && $model === 'gemini-3-pro-image-preview') {
  113. $timeout = 180;
  114. }
  115. return $this->callApi($data, $model, $timeout);
  116. }
  117. /**
  118. * 图生图:按 ai_model 中「图生图」能力 + sort 依次尝试;每个 model_name 内仍按 callApi 切换供应商
  119. */
  120. private function executeImg2ImgWithFailover(
  121. string $preferredModel,
  122. string $prompt,
  123. string $size,
  124. string $productBase64,
  125. string $productMimeType,
  126. string $templateBase64,
  127. string $templateMimeType
  128. ): array {
  129. $models = $this->resolveImg2ImgModels();
  130. if (empty($models)) {
  131. throw new \Exception('未配置图生图模型,请在 ai_model 表中添加 model_type 含「图生图」且 status=1 的记录');
  132. }
  133. $attempts = [];
  134. $skipped = [];
  135. $timeout = 180;
  136. foreach ($models as $index => $modelInfo) {
  137. $modelName = $modelInfo['model_name'];
  138. try {
  139. $data = $this->buildImg2ImgPayload(
  140. $modelName,
  141. $prompt,
  142. $size,
  143. $productBase64,
  144. $productMimeType,
  145. $templateBase64,
  146. $templateMimeType
  147. );
  148. $result = $this->callApi($data, $modelName, $timeout);
  149. $this->lastImg2ImgSuccessMeta = [
  150. 'model_name' => $modelName,
  151. 'sort' => $modelInfo['sort'],
  152. 'id' => $modelInfo['id'],
  153. 'supplier' => $modelInfo['supplier'],
  154. 'attempt_no' => $index + 1,
  155. 'total' => count($models),
  156. 'skipped' => $skipped,
  157. ];
  158. return $result;
  159. } catch (\Exception $e) {
  160. $classified = $this->classifyImg2ImgFailure($e->getMessage());
  161. $skipped[] = [
  162. 'model_name' => $modelName,
  163. 'brief' => $classified['brief'],
  164. ];
  165. $attempts[] = array_merge($modelInfo, $classified);
  166. Log::warning('[图生图] 尝试模型 ' . ($index + 1) . ' 失败: ' . $modelName . ' | ' . $classified['brief']);
  167. }
  168. }
  169. $this->lastImg2ImgSuccessMeta = null;
  170. throw new \Exception($this->formatImg2ImgFailureReport($attempts, count($models)));
  171. }
  172. /**
  173. * 获取图生图模型列表(去重、严格按 sort/id;含 supplier 供日志展示)
  174. */
  175. private function resolveImg2ImgModels(): array
  176. {
  177. $rows = Db::name('ai_model')
  178. ->where('status', '1')
  179. ->whereRaw("FIND_IN_SET('图生图', model_type) > 0")
  180. ->order('sort ASC, id ASC')
  181. ->field('id,model_name,sort,supplier')
  182. ->select();
  183. $ordered = [];
  184. $seen = [];
  185. foreach ($rows as $row) {
  186. $name = trim((string)$row['model_name']);
  187. if ($name === '' || isset($seen[$name])) {
  188. continue;
  189. }
  190. $seen[$name] = true;
  191. $ordered[] = [
  192. 'id' => (int)$row['id'],
  193. 'model_name' => $name,
  194. 'sort' => trim((string)($row['sort'] ?? '')),
  195. 'supplier' => trim((string)($row['supplier'] ?? '')) ?: '未知供应商',
  196. ];
  197. }
  198. return $ordered;
  199. }
  200. /**
  201. * 图生图多模型失败汇总(队列日志 / Redis 可读格式)
  202. */
  203. private function formatImg2ImgFailureReport(array $attempts, int $total): string
  204. {
  205. $lines = [
  206. "图生图失败(共尝试 {$total} 个模型)",
  207. '',
  208. ];
  209. foreach ($attempts as $index => $attempt) {
  210. $no = $index + 1;
  211. $lines[] = "━━━━━━━━ 尝试模型 {$no} ━━━━━━━━";
  212. $lines[] = "模型:{$attempt['model_name']}(sort={$attempt['sort']} | id={$attempt['id']} | {$attempt['supplier']})";
  213. $lines[] = '结果:失败';
  214. $lines[] = '失败原因:' . $attempt['brief'];
  215. $lines[] = '接口问题:' . $attempt['api_issue'];
  216. $lines[] = '供应商问题:' . $attempt['supplier_issue'];
  217. $lines[] = '建议解决方案:' . $attempt['solution'];
  218. $lines[] = '';
  219. }
  220. return rtrim(implode("\n", $lines));
  221. }
  222. /**
  223. * 图生图单次失败归类:接口问题 vs 供应商问题
  224. */
  225. private function classifyImg2ImgFailure(string $rawMessage): array
  226. {
  227. $apiIssue = false;
  228. $supplierIssue = false;
  229. $solutions = [];
  230. if (preg_match('/模型配置不存在|URL\/Key为空|JSON编码失败|未配置图生图|invalid_request|contents is required|请求参数不合法|请求参数错误|API端点不存在/u', $rawMessage)) {
  231. $apiIssue = true;
  232. $solutions[] = '检查 ai_model:model_name、api_url(需 :generateContent)、api_key 是否正确';
  233. }
  234. if (preg_match('/503|No available tokens|upstream|do_request_failed|insufficient_quota|算力|额度|model_not_found|server_error|服务暂时不可用|服务器内部错误|bad_response_body/u', $rawMessage)) {
  235. $supplierIssue = true;
  236. $solutions[] = '联系 API 供应商确认 Key 额度、模型通道是否正常';
  237. }
  238. if (preg_match('/CURL失败|CURLE_|Could not connect|Connection timed out|timed out after|无法连接/u', $rawMessage)) {
  239. $supplierIssue = true;
  240. $solutions[] = '检查服务器到 API 域名的网络(防火墙、超时、是否被墙)';
  241. }
  242. if (preg_match('/401|403|authentication|认证失败|API密钥无效/u', $rawMessage)) {
  243. $apiIssue = true;
  244. $supplierIssue = true;
  245. $solutions[] = '核对 api_key 是否与供应商后台令牌一致';
  246. }
  247. if (preg_match('/429|rate_limit|请求过于频繁/u', $rawMessage)) {
  248. $supplierIssue = true;
  249. $solutions[] = '降低调用频率或联系供应商提升配额';
  250. }
  251. if (!$apiIssue && !$supplierIssue) {
  252. $supplierIssue = true;
  253. $solutions[] = '等待几分钟后重试,或联系 API 服务商排查';
  254. }
  255. return [
  256. 'brief' => $this->extractBriefImg2ImgError($rawMessage),
  257. 'api_issue' => $apiIssue ? '是' : '否',
  258. 'supplier_issue' => $supplierIssue ? '是' : '否',
  259. 'solution' => implode(';', array_unique($solutions)),
  260. ];
  261. }
  262. /** 从 callApi 异常中提取一行简短原因 */
  263. private function extractBriefImg2ImgError(string $rawMessage): string
  264. {
  265. $rawMessage = trim(str_replace(["\r\n", "\r"], "\n", $rawMessage));
  266. if (preg_match('/模型配置不存在:\s*(.+)/u', $rawMessage, $m)) {
  267. return '模型配置不存在: ' . trim($m[1]);
  268. }
  269. if (preg_match('/\(错误代码:\s*([^)]+)\):\s*(.+?)(?:\n|$)/u', $rawMessage, $m)) {
  270. return trim($m[1]) . ': ' . trim($m[2]);
  271. }
  272. foreach (explode("\n", $rawMessage) as $line) {
  273. $line = trim($line, "- \t");
  274. if ($line === '' || strpos($line, '建议解决方案') !== false || strpos($line, '失败详情') !== false) {
  275. continue;
  276. }
  277. if (preg_match('/^第\d+个接口/u', $line)) {
  278. return $line;
  279. }
  280. }
  281. $firstLine = strtok($rawMessage, "\n");
  282. return mb_substr($firstLine ?: $rawMessage, 0, 180);
  283. }
  284. /**
  285. * 按 model_name 构建图生图请求体(Gemini generateContent / 火山 Seedream 自动识别)
  286. */
  287. private function buildImg2ImgPayload(
  288. string $model,
  289. string $prompt,
  290. string $size,
  291. string $productBase64,
  292. string $productMimeType,
  293. string $templateBase64,
  294. string $templateMimeType
  295. ): array {
  296. if ($this->isSeedreamImg2ImgModel($model)) {
  297. return $this->buildImage2ImageSeedream(
  298. $model,
  299. $prompt,
  300. $size,
  301. $productBase64,
  302. $productMimeType,
  303. $templateBase64,
  304. $templateMimeType
  305. );
  306. }
  307. return $this->buildImage2ImageGemini3ProImage(
  308. $prompt,
  309. $size,
  310. $productBase64,
  311. $productMimeType,
  312. $templateBase64,
  313. $templateMimeType
  314. );
  315. }
  316. /** 火山方舟 Seedream 图生图模型(model_name 含 seedream) */
  317. private function isSeedreamImg2ImgModel(string $model): bool
  318. {
  319. return stripos($model, 'seedream') !== false;
  320. }
  321. /**
  322. * 图生图 - 火山引擎 Seedream(OpenAI 兼容 images/generations,支持多参考图)
  323. */
  324. private function buildImage2ImageSeedream(
  325. string $model,
  326. string $prompt,
  327. string $size,
  328. string $productBase64,
  329. string $productMimeType,
  330. string $templateBase64,
  331. string $templateMimeType
  332. ): array {
  333. $images = [];
  334. if ($productBase64 !== '') {
  335. $images[] = $this->toSeedreamImageRef($productBase64, $productMimeType);
  336. }
  337. if ($templateBase64 !== '') {
  338. $ref = $this->toSeedreamImageRef($templateBase64, $templateMimeType);
  339. if (!in_array($ref, $images, true)) {
  340. $images[] = $ref;
  341. }
  342. }
  343. if (empty($images)) {
  344. throw new \Exception('图生图参考图不能为空');
  345. }
  346. return [
  347. 'model' => $model,
  348. 'prompt' => $prompt,
  349. 'image' => count($images) === 1 ? $images[0] : $images,
  350. 'size' => $this->mapSizeToSeedream($size),
  351. 'sequential_image_generation' => 'disabled',
  352. 'response_format' => 'b64_json',
  353. 'watermark' => false,
  354. ];
  355. }
  356. private function toSeedreamImageRef(string $base64, string $mimeType): string
  357. {
  358. $base64 = preg_replace('/\s+/', '', $base64);
  359. $mimeType = trim($mimeType) ?: 'image/jpeg';
  360. return 'data:' . $mimeType . ';base64,' . $base64;
  361. }
  362. /** 将 1024x1024 / 850x1133 等映射为 Seedream 支持的 1K/2K/4K */
  363. private function mapSizeToSeedream(string $size): string
  364. {
  365. $size = trim($size);
  366. if ($size === '') {
  367. return '2K';
  368. }
  369. if (preg_match('/^(\d)[kK]$/', $size, $m)) {
  370. return $m[1] . 'K';
  371. }
  372. if (strpos($size, 'x') !== false) {
  373. $parts = explode('x', $size, 2);
  374. $w = (int)($parts[0] ?? 0);
  375. $h = (int)($parts[1] ?? 0);
  376. $max = max($w, $h);
  377. if ($max >= 3000) {
  378. return '4K';
  379. }
  380. if ($max >= 1400) {
  381. return '2K';
  382. }
  383. return '1K';
  384. }
  385. return '2K';
  386. }
  387. // -------------------------- 细分方法:按「任务类型+模型」拆分 --------------------------
  388. /**
  389. * 文生文 - gemini_flash 模型
  390. */
  391. private function buildText2TextGeminiFlash(string $prompt): array
  392. {
  393. return [
  394. "contents" => [
  395. [
  396. "role" => "user",
  397. "parts" => [["text" => $prompt]]
  398. ]
  399. ],
  400. "generationConfig" => [
  401. "responseModalities" => ["TEXT"],
  402. "maxOutputTokens" => 1024,
  403. "temperature" => 0.7,
  404. "language" => "zh-CN"
  405. ]
  406. ];
  407. }
  408. /**
  409. * 文生文 - OpenAI 兼容格式(gpt-4 / gpt-4o 等,model 与 ai_model.model_name 一致)
  410. */
  411. private function buildText2TextOpenAi(string $prompt): array
  412. {
  413. return [
  414. 'model' => "gpt-4",
  415. 'messages' => [['role' => 'user', 'content' => $prompt]],
  416. 'temperature' => 0.7,
  417. 'max_tokens' => 1024,
  418. ];
  419. }
  420. /**
  421. * 文生图 - dall-e-3 模型
  422. */
  423. private function buildText2ImageDallE3(string $prompt, string $size): array
  424. {
  425. return [
  426. 'group' => 'OpenAI',
  427. 'prompt' => $prompt,
  428. 'model' => 'dall-e-3',
  429. 'n' => 1,
  430. 'size' => $size,
  431. 'quality' => 'hd',
  432. 'style' => 'vivid',
  433. 'response_format' => 'url',
  434. ];
  435. }
  436. /**
  437. * 文生图 - gemini-3-pro-preview 模型
  438. */
  439. private function buildText2ImageGemini3Pro(string $prompt, string $size): array
  440. {
  441. $supportedAspectRatios = ['1:1', '4:3', '3:4', '16:9', '9:16'];
  442. $aspectRatio = (in_array($size, $supportedAspectRatios)) ? $size : '1:1';
  443. return [
  444. "model" => "gemini-3-pro-preview",
  445. "prompt" => $prompt,
  446. "size" => $aspectRatio,
  447. "n" => 1,
  448. "quality" => "standard",
  449. "response_format" => "url"
  450. ];
  451. }
  452. /**
  453. * 文生图 - gemini-3-pro-image-preview 模型(纯文本出图)
  454. */
  455. private function buildText2ImageGemini3ProImage(string $prompt, string $size): array
  456. {
  457. $supportedAspectRatios = ['1:1', '4:3', '3:4', '16:9', '9:16'];
  458. if ($size === '' || !in_array($size, $supportedAspectRatios, true)) {
  459. if (!empty($size) && strpos($size, 'x') !== false) {
  460. $parts = explode('x', trim($size), 2);
  461. if (count($parts) === 2) {
  462. $w = (int)$parts[0];
  463. $h = (int)$parts[1];
  464. if ($w > 0 && $h > 0) {
  465. $ratio = $w / $h;
  466. $standard = [['1:1', 1], ['4:3', 4 / 3], ['3:4', 3 / 4], ['16:9', 16 / 9], ['9:16', 9 / 16]];
  467. $minDiff = PHP_FLOAT_MAX;
  468. foreach ($standard as $r) {
  469. $diff = abs($ratio - $r[1]);
  470. if ($diff < $minDiff) {
  471. $minDiff = $diff;
  472. $size = $r[0];
  473. }
  474. }
  475. }
  476. }
  477. } else {
  478. $size = '1:1';
  479. }
  480. }
  481. return [
  482. 'contents' => [
  483. [
  484. 'role' => 'user',
  485. 'parts' => [['text' => $prompt]]
  486. ]
  487. ],
  488. 'generationConfig' => [
  489. 'responseModalities' => ['TEXT', 'IMAGE'],
  490. 'imageConfig' => ['aspectRatio' => $size, 'imageSize' => '1K']
  491. ]
  492. ];
  493. }
  494. /**
  495. * 图生文 - gemini-3-pro-preview 模型
  496. */
  497. private function buildImage2TextGemini3Pro(string $prompt, string $productBase64, string $productMimeType): array
  498. {
  499. return [
  500. "contents" => [
  501. [
  502. "role" => "user",
  503. "parts" => [
  504. ["inlineData" => ["mimeType" => $productMimeType, "data" => $productBase64]],
  505. ["text" => $prompt]
  506. ]
  507. ]
  508. ],
  509. "generationConfig" => [
  510. "responseModalities" => ["TEXT"],
  511. "maxOutputTokens" => 1000,
  512. "temperature" => 0.7,
  513. "language" => "zh-CN"
  514. ]
  515. ];
  516. }
  517. /**
  518. * 图生图 - gemini-3-pro-image-preview 模型
  519. */
  520. private function buildImage2ImageGemini3ProImage(
  521. string $prompt,
  522. string $size,
  523. string $productBase64,
  524. string $productMimeType,
  525. string $templateBase64,
  526. string $templateMimeType
  527. ): array {
  528. // 尺寸转标准比例
  529. if (!empty($size) && strpos($size, 'x') !== false) {
  530. $parts = explode('x', trim($size), 2);
  531. if (count($parts) == 2) {
  532. $w = (int)$parts[0];
  533. $h = (int)$parts[1];
  534. if ($w > 0 && $h > 0) {
  535. $ratio = $w / $h;
  536. $standard = [['1:1', 1], ['4:3', 4 / 3], ['3:4', 3 / 4], ['16:9', 16 / 9], ['9:16', 9 / 16]];
  537. $minDiff = PHP_FLOAT_MAX;
  538. foreach ($standard as $r) {
  539. $diff = abs($ratio - $r[1]);
  540. if ($diff < $minDiff) {
  541. $minDiff = $diff;
  542. $size = $r[0];
  543. }
  544. }
  545. }
  546. }
  547. }
  548. // 网关要求固定 2 张 inline_data;无参考图时用原图占位(与线上一致)
  549. if ($templateBase64 === '' && $productBase64 !== '') {
  550. $templateBase64 = $productBase64;
  551. $templateMimeType = $productMimeType;
  552. }
  553. return [
  554. 'contents' => [
  555. [
  556. 'role' => 'user',
  557. 'parts' => [
  558. ['text' => $prompt],
  559. ['inline_data' => ['mime_type' => $productMimeType, 'data' => $productBase64]],
  560. ['inline_data' => ['mime_type' => $templateMimeType, 'data' => $templateBase64]],
  561. ]
  562. ]
  563. ],
  564. 'generationConfig' => [
  565. 'responseModalities' => ['TEXT', 'IMAGE'],
  566. 'imageConfig' => ['aspectRatio' => $size !== '' ? $size : '1:1', 'imageSize' => '1K']
  567. ]
  568. ];
  569. }
  570. /**
  571. * 构建视频请求体
  572. * $status_val == 文生视频、图生视频、首图尾图生视频
  573. * $model 模型
  574. * $prompt 提示词
  575. * $size 尺寸大小
  576. * $seconds 时间
  577. */
  578. // public function Txt_to_video($status_val,$prompt, $model, $size, $seconds)
  579. // {
  580. // //判断使用哪个模型、在判断此模型使用类型
  581. // if ($model === 'sora-2') {
  582. // if ($status_val == '文生视频') {
  583. // $data = [
  584. // 'prompt' => trim($prompt),
  585. // 'model' => $model ?: 'sora-2',
  586. // 'seconds' => (string)((int)$seconds ?: 5),
  587. // 'size' => $size ?: '1920x1080'
  588. // ];
  589. // return self::callApi($this->config['sora-2']['api_url'], $this->config['sora-2']['api_key'], $data, 300);
  590. // }
  591. // }else{
  592. // throw new \Exception("未配置模型类型: {$model}");
  593. // }
  594. // }
  595. /**
  596. * 计算最大公约数
  597. */
  598. public function gcd($a, $b) {
  599. while ($b != 0) {
  600. $temp = $a % $b;
  601. $a = $b;
  602. $b = $temp;
  603. }
  604. return $a;
  605. }
  606. /**
  607. * cURL SSL 选项:证书可读则验证,否则关闭(兼容 Windows 无 CA、Linux open_basedir 限制)
  608. */
  609. private function applyCurlSslOptions(array &$options): void
  610. {
  611. $caFile = trim((string)(ini_get('curl.cainfo') ?: ini_get('openssl.cafile')));
  612. // 须加 @:线上 open_basedir 不允许读 /etc/pki/... 时,is_readable 会告警并中断请求
  613. if ($caFile !== '' && @is_readable($caFile)) {
  614. $options[CURLOPT_SSL_VERIFYPEER] = true;
  615. $options[CURLOPT_SSL_VERIFYHOST] = 2;
  616. $options[CURLOPT_CAINFO] = $caFile;
  617. return;
  618. }
  619. $options[CURLOPT_SSL_VERIFYPEER] = false;
  620. $options[CURLOPT_SSL_VERIFYHOST] = 0;
  621. }
  622. /**
  623. * 通用 API 调用方法(支持多接口故障自动切换)
  624. *
  625. * @param array $data 请求数据(JSON 格式)
  626. * @param string $model 模型名
  627. * @param int $timeout 请求超时时间(秒)
  628. * @return array 接口响应数据(成功时返回解析后的数组)
  629. * @throws \Exception 所有接口都失败时抛出异常
  630. */
  631. public function callApi(array $data, string $model, int $timeout = 60): array
  632. {
  633. $allErrors = [];
  634. $httpCodes = [];
  635. $curlErrnos = [];
  636. // 1. 预加载该模型的所有API配置(按优先级排序,仅启用状态)
  637. $aiModelConfigs = Db::name("ai_model")
  638. ->where('model_name', $model)
  639. ->where('status', '1')
  640. ->order('sort ASC')
  641. ->select();
  642. if (empty($aiModelConfigs)) {
  643. throw new \Exception("模型配置不存在: {$model}");
  644. }
  645. // 2. 提前序列化JSON(只做一次,避免重复编码)
  646. $postData = json_encode($data, JSON_UNESCAPED_UNICODE);
  647. if (json_last_error() !== JSON_ERROR_NONE) {
  648. throw new \Exception("请求数据JSON编码失败: " . json_last_error_msg());
  649. }
  650. // 3. 遍历所有接口,逐个尝试调用
  651. foreach ($aiModelConfigs as $index => $config) {
  652. $ch = null;
  653. try {
  654. // 跳过空配置(同时记录到错误数组,避免最终汇总时 $httpCodes/$curlErrnos 为空导致未定义下标)
  655. if (empty($config['api_url']) || empty($config['api_key'])) {
  656. $allErrors[] = "第" . ($index + 1) . "个接口配置无效(URL/Key为空)";
  657. $httpCodes[] = 0;
  658. $curlErrnos[] = 0;
  659. continue;
  660. }
  661. // 初始化curl(每个接口新建连接,避免复用导致的问题)
  662. $ch = curl_init();
  663. $curlOptions = [
  664. CURLOPT_URL => $config['api_url'],
  665. CURLOPT_RETURNTRANSFER => true,
  666. CURLOPT_POST => true,
  667. CURLOPT_POSTFIELDS => $postData,
  668. CURLOPT_HTTPHEADER => [
  669. 'Content-Type: application/json',
  670. 'Authorization: Bearer ' . $config['api_key'],
  671. 'Connection: keep-alive',
  672. 'Keep-Alive: 300'
  673. ],
  674. CURLOPT_TIMEOUT => $timeout,
  675. CURLOPT_CONNECTTIMEOUT => 15,
  676. CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
  677. CURLOPT_FAILONERROR => false,
  678. CURLOPT_TCP_NODELAY => true,
  679. CURLOPT_FORBID_REUSE => false,
  680. CURLOPT_FRESH_CONNECT => false
  681. ];
  682. $this->applyCurlSslOptions($curlOptions);
  683. curl_setopt_array($ch, $curlOptions);
  684. // 执行请求(每个接口仅调用一次,不做同接口重试)
  685. $response = curl_exec($ch);
  686. $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
  687. $curlErrno = curl_errno($ch);
  688. $curlError = curl_error($ch);
  689. // 检查CURL底层错误
  690. if ($response === false) {
  691. $errorMsg = "第" . ($index + 1) . "个接口CURL失败 [{$curlErrno}]: {$curlError}";
  692. $allErrors[] = $errorMsg;
  693. $httpCodes[] = $httpCode;
  694. $curlErrnos[] = $curlErrno;
  695. continue; // 跳过当前接口,尝试下一个
  696. }
  697. // 解析响应
  698. $result = empty($response) ? [] : json_decode($response, true);
  699. if (json_last_error() !== JSON_ERROR_NONE && !empty($response)) {
  700. $errorMsg = "第" . ($index + 1) . "个接口响应解析失败: " . json_last_error_msg();
  701. $allErrors[] = $errorMsg;
  702. $httpCodes[] = $httpCode;
  703. $curlErrnos[] = $curlErrno;
  704. continue;
  705. }
  706. // 检查API业务错误
  707. if (isset($result['error'])) {
  708. $apiErrorDetail = $result['error']['message'] ?? '';
  709. $errorType = $result['error']['type'] ?? '';
  710. $errorCode = $result['error']['code'] ?? '';
  711. $errorMessages = [
  712. 'invalid_request_error' => '请求参数错误',
  713. 'authentication_error' => '认证失败',
  714. 'rate_limit_error' => '请求频率过高',
  715. 'insufficient_quota' => '额度不足',
  716. 'billing_not_active' => '账户未开通付费',
  717. 'content_policy_violation' => '内容违反政策',
  718. 'model_not_found' => '模型不存在或无可用渠道',
  719. 'bad_response_body' => '上游响应体不完整',
  720. 'server_error' => '服务端临时异常'
  721. ];
  722. $friendlyMessage = $errorMessages[$errorCode] ?? ($errorMessages[$errorType] ?? 'API服务错误');
  723. $detailedError = "第" . ($index + 1) . "个接口{$friendlyMessage}";
  724. if ($errorCode) $detailedError .= " (错误代码: {$errorCode})";
  725. if ($apiErrorDetail) $detailedError .= ": {$apiErrorDetail}";
  726. $allErrors[] = $detailedError;
  727. $httpCodes[] = $httpCode;
  728. $curlErrnos[] = $curlErrno;
  729. continue;
  730. }
  731. // 检查HTTP状态码
  732. if ($httpCode !== 200) {
  733. $statusMessages = [
  734. 400 => '请求参数不合法',
  735. 401 => 'API密钥无效或权限不足',
  736. 403 => '访问被拒绝',
  737. 404 => 'API端点不存在',
  738. 429 => '请求过于频繁,请稍后再试',
  739. 500 => '服务器内部错误',
  740. 503 => '服务暂时不可用'
  741. ];
  742. $statusMessage = $statusMessages[$httpCode] ?? "HTTP错误({$httpCode})";
  743. $allErrors[] = "第" . ($index + 1) . "个接口{$statusMessage}";
  744. $httpCodes[] = $httpCode;
  745. $curlErrnos[] = $curlErrno;
  746. continue;
  747. }
  748. // 任意一个接口成功,直接返回结果
  749. curl_close($ch);
  750. return $result;
  751. } catch (\Exception $e) {
  752. // 捕获当前接口的异常,记录后尝试下一个
  753. $allErrors[] = "第" . ($index + 1) . "个接口异常: " . $e->getMessage();
  754. $httpCodes[] = 0;
  755. $curlErrnos[] = 0;
  756. if (is_resource($ch)) {
  757. curl_close($ch);
  758. }
  759. continue;
  760. }
  761. }
  762. // 所有接口都失败,抛出汇总异常
  763. $finalError = "所有API接口调用失败(共尝试" . count($aiModelConfigs) . "个接口)\n";
  764. $finalError .= "失败详情:\n- " . implode("\n- ", $allErrors) . "\n";
  765. $lastHttpCode = $httpCodes[count($httpCodes) - 1] ?? 0;
  766. $lastCurlErrno = $curlErrnos[count($curlErrnos) - 1] ?? 0;
  767. $finalError .= "建议解决方案: " . $this->getErrorSolution($lastHttpCode, $lastCurlErrno) . "\n";
  768. throw new \Exception($finalError);
  769. }
  770. /**
  771. * 获取错误原因
  772. */
  773. private function getErrorCause(int $httpCode, string $apiErrorDetail, int $curlErrno): string
  774. {
  775. $causeMap = [
  776. // HTTP状态码
  777. 400 => '参数格式错误/必填参数缺失',
  778. 401 => 'API Key无效/过期/无权限',
  779. 429 => '超出接口调用频率限制',
  780. 500 => '服务端内部故障',
  781. 503 => '服务维护/算力不足',
  782. // CURL错误码
  783. CURLE_OPERATION_TIMEDOUT => '请求超时(模型处理耗时超过设置值)',
  784. CURLE_COULDNT_CONNECT => '无法连接到API服务器',
  785. CURLE_SSL_CACERT => 'SSL证书验证失败',
  786. // 业务错误关键词
  787. 'model_not_found' => '模型渠道未开通/无可用资源',
  788. 'invalid_size' => '模型尺寸参数不符合要求',
  789. ];
  790. // 优先匹配CURL错误码
  791. if (isset($causeMap[$curlErrno])) {
  792. return $causeMap[$curlErrno];
  793. }
  794. // 匹配HTTP状态码
  795. if (isset($causeMap[$httpCode])) {
  796. return $causeMap[$httpCode];
  797. }
  798. // 匹配业务错误关键词
  799. foreach ($causeMap as $key => $cause) {
  800. if (is_string($key) && strpos($apiErrorDetail, $key) !== false) {
  801. return $cause;
  802. }
  803. }
  804. // 特殊关键词匹配
  805. if (strpos($apiErrorDetail, 'No available capacity') !== false) {
  806. return '模型算力不足,无可用资源';
  807. } elseif (strpos($apiErrorDetail, 'size is invalid') !== false) {
  808. return '模型尺寸参数无效';
  809. }
  810. // 兜底
  811. return $apiErrorDetail ?: '未知原因';
  812. }
  813. /**
  814. * 获取错误解决方案
  815. */
  816. private function getErrorSolution(int $httpCode, int $curlErrno): string
  817. {
  818. $solutionMap = [
  819. // HTTP状态码
  820. 400 => '1. 检查参数是否完整 2. 确认参数类型 3. 验证尺寸/模型名是否合法',
  821. 401 => '1. 检查API Key是否正确 2. 确认Key未过期/有对应模型权限',
  822. 429 => '1. 降低请求频率 2. 等待1-5分钟后重试 3. 联系服务商提升配额',
  823. 500 => '1. 等待几分钟后重试 2. 联系API服务商排查',
  824. 503 => '1. 等待算力释放 2. 联系服务商扩容',
  825. // CURL错误码
  826. CURLE_OPERATION_TIMEDOUT => '1. 延长超时时间 2. 检查模型生成耗时 3. 重试请求',
  827. CURLE_COULDNT_CONNECT => '1. 检查API地址是否正确 2. 验证网络连通性 3. 检查防火墙配置',
  828. CURLE_SSL_CACERT => '1. 开启SSL证书验证 2. 配置正确的CA证书路径 3. 确认API域名证书有效',
  829. ];
  830. if (isset($solutionMap[$curlErrno])) {
  831. return $solutionMap[$curlErrno];
  832. }
  833. if (isset($solutionMap[$httpCode])) {
  834. return $solutionMap[$httpCode];
  835. }
  836. return '1. 等待几分钟后重试 2. 检查API服务提供商状态 3. 联系服务提供商确认服务可用性';
  837. }
  838. // /**
  839. // * 通用 API 调用方法(支持重试机制)
  840. // *
  841. // * @param string $url 接口地址
  842. // * @param string $apiKey 授权密钥(Bearer Token)
  843. // * @param array $data 请求数据(JSON 格式)
  844. // *
  845. // * 功能说明:
  846. // * - 使用 cURL 发送 POST 请求到指定 API 接口
  847. // * - 设置请求头和超时时间等参数
  848. // * - 支持最多重试 2 次,当接口调用失败时自动重试
  849. // * - 返回成功时解析 JSON 响应为数组
  850. // *
  851. // * 异常处理:
  852. // * - 若全部重试失败,将抛出异常并包含最后一次错误信息
  853. // *
  854. // * @return array 接口响应数据(成功时返回解析后的数组)
  855. // * @throws \Exception 接口请求失败时抛出异常
  856. // */
  857. // public static function callApi($data,$model,$timeout = 60)
  858. // {
  859. // $maxRetries = 0; // 减少重试次数为0,避免不必要的等待
  860. // $attempt = 0;
  861. // $lastError = '';
  862. // $httpCode = 0;
  863. // $apiErrorDetail = '';
  864. //
  865. // $ai_model = Db::name("ai_model")
  866. // ->where('model_name',$model)
  867. // ->select();
  868. // $num = 0;
  869. // while ($attempt <= $maxRetries) {
  870. // try {
  871. // if(!$ai_model[$num]){
  872. // throw new \Exception("请求发送失败: " . $curlError);
  873. // }
  874. // $ch = curl_init();
  875. // curl_setopt_array($ch, [
  876. // CURLOPT_URL => $ai_model[$num]['api_url'],
  877. // CURLOPT_RETURNTRANSFER => true,
  878. // CURLOPT_POST => true,
  879. // CURLOPT_POSTFIELDS => json_encode($data, JSON_UNESCAPED_UNICODE),
  880. // CURLOPT_HTTPHEADER => [
  881. // 'Content-Type: application/json',
  882. // 'Authorization: Bearer ' . $ai_model[$num]['api_key']
  883. // ],
  884. // CURLOPT_TIMEOUT => (int) $timeout,
  885. // CURLOPT_SSL_VERIFYPEER => false,
  886. // CURLOPT_SSL_VERIFYHOST => false,
  887. // CURLOPT_CONNECTTIMEOUT => 15, // 减少连接超时时间为15秒
  888. // CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
  889. // CURLOPT_FAILONERROR => false
  890. // ]);
  891. //
  892. // $response = curl_exec($ch);
  893. // $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
  894. // $curlError = curl_error($ch);
  895. //
  896. // if ($response === false) {
  897. // // 尝试从curl错误信息中提取HTTP状态码
  898. // if (preg_match('/HTTP\/[0-9.]+\s+([0-9]+)/', $curlError, $matches)) {
  899. // $httpCode = (int)$matches[1];
  900. // }
  901. // throw new \Exception("请求发送失败: " . $curlError);
  902. // }
  903. //
  904. // $result = json_decode($response, true);
  905. //
  906. // // 检查API返回的错误
  907. // if (isset($result['error'])) {
  908. // $apiErrorDetail = $result['error']['message'] ?? '';
  909. // $errorType = $result['error']['type'] ?? '';
  910. // $errorCode = $result['error']['code'] ?? '';
  911. //
  912. // // 常见错误类型映射
  913. // $errorMessages = [
  914. // 'invalid_request_error' => '请求参数错误',
  915. // 'authentication_error' => '认证失败',
  916. // 'rate_limit_error' => '请求频率过高',
  917. // 'insufficient_quota' => '额度不足',
  918. // 'billing_not_active' => '账户未开通付费',
  919. // 'content_policy_violation' => '内容违反政策',
  920. // 'model_not_found' => '模型不存在或无可用渠道'
  921. // ];
  922. //
  923. // // 优先使用errorCode进行映射,如果没有则使用errorType
  924. // $friendlyMessage = $errorMessages[$errorCode] ?? ($errorMessages[$errorType] ?? 'API服务错误');
  925. //
  926. // // 构建详细的错误信息,包含错误代码、类型和详细描述
  927. // $detailedError = "{$friendlyMessage}";
  928. // if ($errorCode) {
  929. // $detailedError .= " (错误代码: {$errorCode})";
  930. // }
  931. // if ($apiErrorDetail) {
  932. // $detailedError .= ": {$apiErrorDetail}";
  933. // }
  934. //
  935. // throw new \Exception($detailedError);
  936. // }
  937. //
  938. // if ($httpCode !== 200) {
  939. // // HTTP状态码映射
  940. // $statusMessages = [
  941. // 400 => '请求参数不合法',
  942. // 401 => 'API密钥无效或权限不足',
  943. // 403 => '访问被拒绝',
  944. // 404 => 'API端点不存在',
  945. // 429 => '请求过于频繁,请稍后再试',
  946. // 500 => '服务器内部错误',
  947. // 503 => '服务暂时不可用'
  948. // ];
  949. //
  950. // $statusMessage = $statusMessages[$httpCode] ?? "HTTP错误({$httpCode})";
  951. // throw new \Exception($statusMessage);
  952. // }
  953. //
  954. // curl_close($ch);
  955. // return $result;
  956. //
  957. // } catch (\Exception $e) {
  958. // $lastError = $e->getMessage();
  959. // $attempt++;
  960. // $num++;
  961. // if ($attempt <= $maxRetries) {
  962. // sleep(pow(2, $attempt));
  963. // } else {
  964. // // 最终失败时的详细错误信息
  965. // $errorDetails = [
  966. // '错误原因' => self::getErrorCause($httpCode, $apiErrorDetail),
  967. // '解决方案' => self::getErrorSolution($httpCode),
  968. // '请求参数' => json_encode($data, JSON_UNESCAPED_UNICODE),
  969. // 'HTTP状态码' => $httpCode,
  970. // '重试次数' => $attempt
  971. // ];
  972. //
  973. // // 构建最终的错误信息,优先显示原始的详细错误消息
  974. // $finalError = "API请求失败\n";
  975. // $finalError .= "失败说明: " . $lastError . "\n"; // 使用原始的详细错误消息
  976. // $finalError .= "建议解决方案: " . $errorDetails['解决方案'] . "\n";
  977. // $finalError .= "技术详情: HTTP {$httpCode} - " . $errorDetails['错误原因'];
  978. //
  979. // throw new \Exception($finalError);
  980. // }
  981. // }
  982. // }
  983. // }
  984. //
  985. // private static function getErrorCause($httpCode, $apiErrorDetail)
  986. // {
  987. // $causeMap = [
  988. // 400 => '参数格式错误/必填参数缺失',
  989. // 401 => 'API Key无效/过期/无权限',
  990. // 429 => '超出接口调用频率限制',
  991. // 500 => '服务端内部故障',
  992. // 503 => '服务维护/算力不足',
  993. // 'model_not_found' => '模型渠道未开通/无可用资源',
  994. // 'invalid_size' => '模型尺寸参数不符合要求',
  995. // CURLE_OPERATION_TIMEDOUT => '请求超时(模型处理耗时超过设置值)'
  996. // ];
  997. //
  998. // if (strpos($apiErrorDetail, 'No available capacity') !== false) {
  999. // return '模型算力不足,无可用资源';
  1000. // } elseif (strpos($apiErrorDetail, 'size is invalid') !== false) {
  1001. // return '模型尺寸参数无效';
  1002. // } elseif (isset($causeMap[$httpCode])) {
  1003. // return $causeMap[$httpCode];
  1004. // }
  1005. // return $apiErrorDetail ?: '未知原因';
  1006. // }
  1007. //
  1008. // private static function getErrorSolution($httpCode)
  1009. // {
  1010. // $solutionMap = [
  1011. // 400 => '1. 检查参数是否完整 2. 确认参数类型(如seconds为字符串) 3. 验证尺寸/模型名是否合法',
  1012. // 401 => '1. 检查API Key是否正确 2. 确认Key未过期/有对应模型权限',
  1013. // 429 => '1. 降低请求频率 2. 等待1-5分钟后重试',
  1014. // 500 => '1. 等待几分钟后重试 2. 联系API服务商排查',
  1015. // 503 => '1. 等待算力释放 2. 联系服务商扩容',
  1016. // CURLE_OPERATION_TIMEDOUT => '1. 延长超时时间 2. 检查模型生成耗时 3. 重试请求'
  1017. // ];
  1018. //
  1019. // if (isset($solutionMap[$httpCode])) {
  1020. // return $solutionMap[$httpCode];
  1021. // }
  1022. // return '1. 等待几分钟后重试 2. 检查API服务提供商状态 3. 联系服务提供商确认服务可用性';
  1023. // }
  1024. /**
  1025. * 获取图片的base64数据和MIME类型
  1026. * @return array 包含base64数据和MIME类型的数组
  1027. */
  1028. public static function file_get_contents($ImageUrl){
  1029. // 兼容三种输入:
  1030. // 1) 本地相对路径:uploads/xxx.png 或 /uploads/xxx.png
  1031. // 2) 已是完整 URL:https://.../xxx.png
  1032. // 3) OSS 相对路径(本地不存在时,用 oss.host 兜底拉取)
  1033. $imageUrl = trim((string)$ImageUrl);
  1034. if ($imageUrl === '') {
  1035. throw new \Exception('图片路径不能为空');
  1036. }
  1037. $rootPath = str_replace('\\', '/', ROOT_PATH);
  1038. $relativePath = ltrim($imageUrl, '/');
  1039. $localPath = rtrim($rootPath, '/') . '/public/' . $relativePath;
  1040. // 前端可能传了 URL 编码文件名(如中文名),本地匹配时做一次解码兜底
  1041. $localPathDecoded = rtrim($rootPath, '/') . '/public/' . urldecode($relativePath);
  1042. $imageContent = false;
  1043. $mimeType = '';
  1044. // A. 直接是完整 URL,直接远程读取
  1045. if (preg_match('/^https?:\/\//i', $imageUrl)) {
  1046. $imageContent = @file_get_contents($imageUrl);
  1047. } else {
  1048. // B. 本地优先:先按原路径查,再按 urldecode 后路径查
  1049. if (file_exists($localPath)) {
  1050. $imageContent = @file_get_contents($localPath);
  1051. if ($imageContent !== false) {
  1052. $finfo = finfo_open(FILEINFO_MIME_TYPE);
  1053. $mimeType = finfo_file($finfo, $localPath);
  1054. finfo_close($finfo);
  1055. }
  1056. } elseif (file_exists($localPathDecoded)) {
  1057. $imageContent = @file_get_contents($localPathDecoded);
  1058. if ($imageContent !== false) {
  1059. $finfo = finfo_open(FILEINFO_MIME_TYPE);
  1060. $mimeType = finfo_file($finfo, $localPathDecoded);
  1061. finfo_close($finfo);
  1062. }
  1063. } else {
  1064. // C. 本地不存在:尝试从 OSS 拉取
  1065. $ossHost = trim((string)config('oss.host'));
  1066. if ($ossHost !== '') {
  1067. if (stripos($ossHost, 'http://') !== 0 && stripos($ossHost, 'https://') !== 0) {
  1068. $ossHost = 'https://' . $ossHost;
  1069. }
  1070. $remoteUrl = rtrim($ossHost, '/') . '/' . ltrim($relativePath, '/');
  1071. $imageContent = @file_get_contents($remoteUrl);
  1072. }
  1073. }
  1074. }
  1075. if ($imageContent === false || $imageContent === '') {
  1076. throw new \Exception('图片内容读取失败(本地/OSS均未读取到)');
  1077. }
  1078. // 若本地未拿到 MIME,则根据二进制内容推断
  1079. if ($mimeType === '') {
  1080. $finfo = finfo_open(FILEINFO_MIME_TYPE);
  1081. $mimeType = finfo_buffer($finfo, $imageContent);
  1082. finfo_close($finfo);
  1083. }
  1084. if (!$mimeType) {
  1085. $mimeType = 'image/png';
  1086. }
  1087. return [
  1088. 'base64Data' => base64_encode($imageContent),
  1089. 'mimeType' => $mimeType
  1090. ];
  1091. }
  1092. /**
  1093. * 从 AI 响应中提取图片 base64(兼容 Gemini / OpenAI 多种返回格式)
  1094. */
  1095. public function extractImageBase64FromResponse(array $res): ?string
  1096. {
  1097. // 兼容网关包装:{ "data": { "candidates": [...] } }
  1098. if (empty($res['candidates']) && !empty($res['data']) && is_array($res['data'])) {
  1099. if (!empty($res['data']['candidates'])) {
  1100. $res = array_merge($res, $res['data']);
  1101. } elseif (!empty($res['data']['choices'])) {
  1102. $res = array_merge($res, $res['data']);
  1103. }
  1104. }
  1105. if (!empty($res['data'][0]['b64_json'])) {
  1106. return preg_replace('/\s+/', '', (string)$res['data'][0]['b64_json']);
  1107. }
  1108. if (!empty($res['data'][0]['url'])) {
  1109. $content = @file_get_contents((string)$res['data'][0]['url']);
  1110. if ($content !== false && $content !== '') {
  1111. return base64_encode($content);
  1112. }
  1113. }
  1114. if (!empty($res['choices'][0]['message']['content'])) {
  1115. $content = $res['choices'][0]['message']['content'];
  1116. if (is_string($content)) {
  1117. $parsed = $this->parseBase64FromText($content);
  1118. if ($parsed) {
  1119. return $parsed;
  1120. }
  1121. } elseif (is_array($content)) {
  1122. foreach ($content as $item) {
  1123. if (($item['type'] ?? '') === 'image_url' && !empty($item['image_url']['url'])) {
  1124. $parsed = $this->parseBase64FromText((string)$item['image_url']['url']);
  1125. if ($parsed) {
  1126. return $parsed;
  1127. }
  1128. }
  1129. if (!empty($item['b64_json'])) {
  1130. return preg_replace('/\s+/', '', (string)$item['b64_json']);
  1131. }
  1132. }
  1133. }
  1134. }
  1135. // Gemini 出图
  1136. if (!empty($res['candidates'][0]['content']['parts']) && is_array($res['candidates'][0]['content']['parts'])) {
  1137. foreach ($res['candidates'][0]['content']['parts'] as $part) {
  1138. foreach (['inlineData', 'inline_data'] as $key) {
  1139. if (!empty($part[$key]['data'])) {
  1140. $raw = preg_replace('/\s+/', '', (string)$part[$key]['data']);
  1141. if (preg_match('/^data:image\//i', $raw)) {
  1142. $parsed = $this->parseBase64FromText($raw);
  1143. if ($parsed) {
  1144. return $parsed;
  1145. }
  1146. } elseif (strlen($raw) > 100 && base64_decode($raw, true) !== false) {
  1147. return $raw;
  1148. }
  1149. }
  1150. }
  1151. if (!empty($part['text']) && preg_match('/data:image\/(png|jpg|jpeg|webp);base64,(.+)$/is', (string)$part['text'], $m)) {
  1152. return preg_replace('/\s+/', '', $m[2]);
  1153. }
  1154. }
  1155. }
  1156. if (!empty($res['candidates']) && is_array($res['candidates'])) {
  1157. foreach ($res['candidates'] as $candidate) {
  1158. $parts = $candidate['content']['parts'] ?? [];
  1159. foreach ($parts as $part) {
  1160. foreach (['inlineData', 'inline_data'] as $key) {
  1161. if (!empty($part[$key]['data'])) {
  1162. $parsed = $this->parseBase64FromText((string)$part[$key]['data']);
  1163. if ($parsed) {
  1164. return $parsed;
  1165. }
  1166. }
  1167. }
  1168. foreach (['fileData', 'file_data'] as $key) {
  1169. $uri = $part[$key]['fileUri'] ?? ($part[$key]['file_uri'] ?? '');
  1170. if ($uri !== '') {
  1171. $parsed = $this->fetchImageBase64FromUri((string)$uri);
  1172. if ($parsed) {
  1173. return $parsed;
  1174. }
  1175. }
  1176. }
  1177. if (!empty($part['text'])) {
  1178. $parsed = $this->parseBase64FromText((string)$part['text']);
  1179. if ($parsed) {
  1180. return $parsed;
  1181. }
  1182. }
  1183. }
  1184. }
  1185. }
  1186. return $this->findImageBase64Deep($res);
  1187. }
  1188. /**
  1189. * 深度递归扫描响应中的图片数据
  1190. */
  1191. private function findImageBase64Deep($node, int $depth = 0): ?string
  1192. {
  1193. if ($depth > 15) {
  1194. return null;
  1195. }
  1196. if (is_string($node)) {
  1197. return $this->parseBase64FromText($node);
  1198. }
  1199. if (!is_array($node)) {
  1200. return null;
  1201. }
  1202. foreach (['inlineData', 'inline_data'] as $key) {
  1203. if (!empty($node[$key]['data'])) {
  1204. $parsed = $this->parseBase64FromText((string)$node[$key]['data']);
  1205. if ($parsed) {
  1206. return $parsed;
  1207. }
  1208. }
  1209. }
  1210. if (!empty($node['b64_json'])) {
  1211. return preg_replace('/\s+/', '', (string)$node['b64_json']);
  1212. }
  1213. if (!empty($node['url']) && is_string($node['url'])) {
  1214. $parsed = $this->fetchImageBase64FromUri($node['url']);
  1215. if ($parsed) {
  1216. return $parsed;
  1217. }
  1218. }
  1219. foreach ($node as $value) {
  1220. if (is_array($value) || is_string($value)) {
  1221. $found = $this->findImageBase64Deep($value, $depth + 1);
  1222. if ($found) {
  1223. return $found;
  1224. }
  1225. }
  1226. }
  1227. return null;
  1228. }
  1229. /**
  1230. * 从 URL / data URI 拉取图片并转 base64
  1231. */
  1232. private function fetchImageBase64FromUri(string $uri): ?string
  1233. {
  1234. $uri = trim($uri);
  1235. if ($uri === '') {
  1236. return null;
  1237. }
  1238. if (strpos($uri, 'data:') === 0) {
  1239. return $this->parseBase64FromText($uri);
  1240. }
  1241. if (!preg_match('/^https?:\/\//i', $uri)) {
  1242. return null;
  1243. }
  1244. $content = @file_get_contents($uri);
  1245. if ($content === false || $content === '') {
  1246. return null;
  1247. }
  1248. return base64_encode($content);
  1249. }
  1250. /**
  1251. * 图片提取失败时的可读错误信息
  1252. */
  1253. public function describeImageExtractFailure(array $res): string
  1254. {
  1255. if (!empty($res['error']['message'])) {
  1256. return (string)$res['error']['message'];
  1257. }
  1258. $text = '';
  1259. if (!empty($res['candidates'][0]['content']['parts'])) {
  1260. foreach ($res['candidates'][0]['content']['parts'] as $part) {
  1261. if (!empty($part['text']) && $text === '') {
  1262. $text = (string)$part['text'];
  1263. }
  1264. }
  1265. }
  1266. if ($text === '' && !empty($res['choices'][0]['message']['content'])) {
  1267. $msgContent = $res['choices'][0]['message']['content'];
  1268. if (is_string($msgContent)) {
  1269. $text = $msgContent;
  1270. }
  1271. }
  1272. $reason = $res['candidates'][0]['finishReason']
  1273. ?? ($res['choices'][0]['finish_reason'] ?? '');
  1274. if ($text !== '') {
  1275. $prefix = $reason !== '' ? "模型未返回图片({$reason})" : '未获取到图片数据';
  1276. return $prefix . ': ' . mb_substr($text, 0, 150);
  1277. }
  1278. if ($reason !== '' && strtoupper((string)$reason) !== 'STOP') {
  1279. return '模型未返回图片: ' . $reason;
  1280. }
  1281. return '未获取到图片数据,请检查模型是否支持出图';
  1282. }
  1283. /**
  1284. * 图片提取失败时写入调试日志(截断 base64,避免日志过大)
  1285. */
  1286. public function logImageResponseDebug(array $res, string $taskId = ''): void
  1287. {
  1288. try {
  1289. $sanitized = $this->sanitizeResponseForLog($res);
  1290. Log::write(
  1291. '[AI image extract failed] task=' . $taskId . ' response=' . mb_substr(json_encode($sanitized, JSON_UNESCAPED_UNICODE), 0, 4000),
  1292. 'error'
  1293. );
  1294. } catch (\Throwable $e) {
  1295. // 日志失败不阻断
  1296. }
  1297. }
  1298. /**
  1299. * @param mixed $node
  1300. * @return mixed
  1301. */
  1302. private function sanitizeResponseForLog($node, int $depth = 0)
  1303. {
  1304. if ($depth > 10) {
  1305. return '...';
  1306. }
  1307. if (is_string($node)) {
  1308. return strlen($node) > 100 ? (substr($node, 0, 50) . '...(len=' . strlen($node) . ')') : $node;
  1309. }
  1310. if (!is_array($node)) {
  1311. return $node;
  1312. }
  1313. $out = [];
  1314. foreach ($node as $key => $value) {
  1315. if (in_array($key, ['data', 'b64_json'], true) && is_string($value) && strlen($value) > 100) {
  1316. $out[$key] = '...(len=' . strlen($value) . ')';
  1317. continue;
  1318. }
  1319. $out[$key] = $this->sanitizeResponseForLog($value, $depth + 1);
  1320. }
  1321. return $out;
  1322. }
  1323. /**
  1324. * 解析 data:image/...;base64 或裸 base64 字符串
  1325. */
  1326. private function parseBase64FromText(string $content): ?string
  1327. {
  1328. $content = trim($content);
  1329. if ($content === '') {
  1330. return null;
  1331. }
  1332. if (preg_match('/data:image\/(?:png|jpg|jpeg|webp);base64,(.+)$/is', $content, $m)) {
  1333. return preg_replace('/\s+/', '', $m[1]);
  1334. }
  1335. $clean = preg_replace('/\s+/', '', $content);
  1336. if (strlen($clean) > 100 && base64_decode($clean, true) !== false) {
  1337. return $clean;
  1338. }
  1339. return null;
  1340. }
  1341. }