AIGatewayService.php 44 KB

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