AIGatewayService.php 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803
  1. <?php
  2. namespace app\service;
  3. use think\Db;
  4. use think\Queue;
  5. class AIGatewayService{
  6. /**
  7. * 根据模型与任务类型构建 API 请求体(主分发方法)
  8. * @param string $status_val 任务类型:图生文、文生文、文生图、图生图
  9. * @param string $model 模型名,如 gpt-4、gemini_flash、dall-e-3 等
  10. * @param string $prompt 提示词
  11. * @param string $size 尺寸或比例,如 850x1133 或 9:16(文生图/图生图用)
  12. * @param string $product_base64Data 产品图 base64(图生图用)
  13. * @param string $product_mimeType 产品图 MIME 类型(图生图用)
  14. * @param string $template_base64Data 模板图 base64(图生图用)
  15. * @param string $template_mimeType 模板图 MIME 类型(图生图用)
  16. * @return array API 响应
  17. * @throws \Exception
  18. */
  19. public function buildRequestData(
  20. string $status_val,
  21. string $model,
  22. string $prompt,
  23. string $size = '',
  24. string $product_base64Data = '',
  25. string $product_mimeType = '',
  26. string $template_base64Data = '',
  27. string $template_mimeType = ''
  28. ) {
  29. // 1. 通用参数校验(提前拦截无效参数)
  30. $validStatus = ['图生文', '文生文', '文生图', '图生图'];
  31. if (!in_array($status_val, $validStatus)) {
  32. throw new \Exception("无效的任务类型: {$status_val}");
  33. }
  34. // 2. 按「模型+任务类型」分发到对应细分方法
  35. switch (true) {
  36. // 文生文
  37. case $status_val === '文生文' && $model === 'gemini_flash':
  38. $data = $this->buildText2TextGeminiFlash($prompt);
  39. break;
  40. case $status_val === '文生文' && $model === 'gpt-4':
  41. $data = $this->buildText2TextGpt4($prompt);
  42. break;
  43. // 文生图
  44. case $status_val === '文生图' && $model === 'dall-e-3':
  45. $data = $this->buildText2ImageDallE3($prompt, $size);
  46. break;
  47. case $status_val === '文生图' && $model === 'gemini-3-pro-preview':
  48. $data = $this->buildText2ImageGemini3Pro($prompt, $size);
  49. break;
  50. // 图生文
  51. case $status_val === '图生文' && $model === 'gemini-3-pro-preview':
  52. $data = $this->buildImage2TextGemini3Pro($prompt, $product_base64Data, $product_mimeType);
  53. break;
  54. // 图生图
  55. case $status_val === '图生图' && $model === 'gemini-3-pro-image-preview':
  56. $data = $this->buildImage2ImageGemini3ProImage($prompt, $size, $product_base64Data, $product_mimeType, $template_base64Data, $template_mimeType);
  57. break;
  58. case $status_val === '图生图' && $model === 'gemini-3.1-flash-image-preview':
  59. $data = $this->buildImage2ImageGemini31Flash($prompt, $size, $product_base64Data, $product_mimeType, $template_base64Data, $template_mimeType);
  60. break;
  61. // 未匹配的组合
  62. default:
  63. throw new \Exception("未配置模型+任务类型组合: {$model}({$status_val})");
  64. }
  65. // 3. 统一调用 API(图生图耗时通常更长,适当放宽超时时间)
  66. $timeout = ($status_val === '图生图') ? 180 : 60;
  67. return $this->callApi($data, $model, $timeout);
  68. }
  69. // -------------------------- 细分方法:按「任务类型+模型」拆分 --------------------------
  70. /**
  71. * 文生文 - gemini_flash 模型
  72. */
  73. private function buildText2TextGeminiFlash(string $prompt): array
  74. {
  75. return [
  76. "contents" => [
  77. [
  78. "role" => "user",
  79. "parts" => [["text" => $prompt]]
  80. ]
  81. ],
  82. "generationConfig" => [
  83. "responseModalities" => ["TEXT"],
  84. "maxOutputTokens" => 1024,
  85. "temperature" => 0.7,
  86. "language" => "zh-CN"
  87. ]
  88. ];
  89. }
  90. /**
  91. * 文生文 - gpt-4 模型
  92. */
  93. private function buildText2TextGpt4(string $prompt): array
  94. {
  95. return [
  96. 'model' => 'gpt-4',
  97. 'messages' => [['role' => 'user', 'content' => $prompt]],
  98. 'temperature' => 0.7,
  99. 'max_tokens' => 1024
  100. ];
  101. }
  102. /**
  103. * 文生图 - dall-e-3 模型
  104. */
  105. private function buildText2ImageDallE3(string $prompt, string $size): array
  106. {
  107. return [
  108. 'group' => 'OpenAI',
  109. 'prompt' => $prompt,
  110. 'model' => 'dall-e-3',
  111. 'n' => 1,
  112. 'size' => $size,
  113. 'quality' => 'hd',
  114. 'style' => 'vivid',
  115. 'response_format' => 'url',
  116. ];
  117. }
  118. /**
  119. * 文生图 - gemini-3-pro-preview 模型
  120. */
  121. private function buildText2ImageGemini3Pro(string $prompt, string $size): array
  122. {
  123. $supportedAspectRatios = ['1:1', '4:3', '3:4', '16:9', '9:16'];
  124. $aspectRatio = (in_array($size, $supportedAspectRatios)) ? $size : '1:1';
  125. return [
  126. "model" => "gemini-3-pro-preview",
  127. "prompt" => $prompt,
  128. "size" => $aspectRatio,
  129. "n" => 1,
  130. "quality" => "standard",
  131. "response_format" => "url"
  132. ];
  133. }
  134. /**
  135. * 图生文 - gemini-3-pro-preview 模型
  136. */
  137. private function buildImage2TextGemini3Pro(string $prompt, string $productBase64, string $productMimeType): array
  138. {
  139. return [
  140. "contents" => [
  141. [
  142. "role" => "user",
  143. "parts" => [
  144. ["inlineData" => ["mimeType" => $productMimeType, "data" => $productBase64]],
  145. ["text" => $prompt]
  146. ]
  147. ]
  148. ],
  149. "generationConfig" => [
  150. "responseModalities" => ["TEXT"],
  151. "maxOutputTokens" => 1000,
  152. "temperature" => 0.7,
  153. "language" => "zh-CN"
  154. ]
  155. ];
  156. }
  157. /**
  158. * 图生图 - gemini-3-pro-image-preview 模型
  159. */
  160. private function buildImage2ImageGemini3ProImage(
  161. string $prompt,
  162. string $size,
  163. string $productBase64,
  164. string $productMimeType,
  165. string $templateBase64,
  166. string $templateMimeType
  167. ): array {
  168. // 尺寸转标准比例
  169. if (!empty($size) && strpos($size, 'x') !== false) {
  170. $parts = explode('x', trim($size), 2);
  171. if (count($parts) == 2) {
  172. $w = (int)$parts[0];
  173. $h = (int)$parts[1];
  174. if ($w > 0 && $h > 0) {
  175. $ratio = $w / $h;
  176. $standard = [['1:1', 1], ['4:3', 4/3], ['3:4', 3/4], ['16:9', 16/9], ['9:16', 9/16]];
  177. $minDiff = PHP_FLOAT_MAX;
  178. foreach ($standard as $r) {
  179. $diff = abs($ratio - $r[1]);
  180. if ($diff < $minDiff) {
  181. $minDiff = $diff;
  182. $size = $r[0];
  183. }
  184. }
  185. }
  186. }
  187. }
  188. return [
  189. 'contents' => [
  190. [
  191. 'role' => 'user',
  192. 'parts' => [
  193. ['text' => $prompt],
  194. ['inline_data' => ['mime_type' => $productMimeType, 'data' => $productBase64]],
  195. ['inline_data' => ['mime_type' => $templateMimeType, 'data' => $templateBase64]]
  196. ]
  197. ]
  198. ],
  199. 'generationConfig' => [
  200. 'responseModalities' => ['IMAGE'],
  201. 'imageConfig' => ['aspectRatio' => $size, 'imageSize' => '1K']
  202. ]
  203. ];
  204. }
  205. /**
  206. * 图生图 - gemini-3.1-flash-image-preview 模型
  207. */
  208. private function buildImage2ImageGemini31Flash(
  209. string $prompt,
  210. string $size,
  211. string $productBase64,
  212. string $productMimeType,
  213. string $templateBase64,
  214. string $templateMimeType
  215. ): array {
  216. return [
  217. 'model' => 'gemini-3.1-flash-image-preview',
  218. 'messages' => [
  219. [
  220. 'role' => 'user',
  221. 'content' => [
  222. ['type' => 'text', 'text' => $prompt],
  223. [
  224. 'type' => 'image_url',
  225. 'image_url' => ['url' => 'data:' . $productMimeType . ';base64,' . $productBase64]
  226. ],
  227. [
  228. 'type' => 'image_url',
  229. 'image_url' => ['url' => 'data:' . $templateMimeType . ';base64,' . $templateBase64]
  230. ]
  231. ]
  232. ]
  233. ],
  234. 'response_modalities' => ['image'],
  235. 'image_config' => [
  236. 'aspect_ratio' => $size,
  237. 'quality' => 'high',
  238. 'width' => '850',
  239. 'height' => '1133'
  240. ],
  241. 'temperature' => 0.3,
  242. 'top_p' => 0.8,
  243. 'max_tokens' => 2048
  244. ];
  245. }
  246. /**
  247. * 构建视频请求体
  248. * $status_val == 文生视频、图生视频、首图尾图生视频
  249. * $model 模型
  250. * $prompt 提示词
  251. * $size 尺寸大小
  252. * $seconds 时间
  253. */
  254. public function Txt_to_video($status_val,$prompt, $model, $size, $seconds)
  255. {
  256. //判断使用哪个模型、在判断此模型使用类型
  257. if ($model === 'sora-2') {
  258. if ($status_val == '文生视频') {
  259. $data = [
  260. 'prompt' => trim($prompt),
  261. 'model' => $model ?: 'sora-2',
  262. 'seconds' => (string)((int)$seconds ?: 5),
  263. 'size' => $size ?: '1920x1080'
  264. ];
  265. return self::callApi($this->config['sora-2']['api_url'], $this->config['sora-2']['api_key'], $data, 300);
  266. }
  267. }else{
  268. throw new \Exception("未配置模型类型: {$model}");
  269. }
  270. }
  271. /**
  272. * 计算最大公约数
  273. */
  274. public function gcd($a, $b) {
  275. while ($b != 0) {
  276. $temp = $a % $b;
  277. $a = $b;
  278. $b = $temp;
  279. }
  280. return $a;
  281. }
  282. /**
  283. * 通用 API 调用方法(支持多接口故障自动切换)
  284. *
  285. * @param array $data 请求数据(JSON 格式)
  286. * @param string $model 模型名
  287. * @param int $timeout 请求超时时间(秒)
  288. * @return array 接口响应数据(成功时返回解析后的数组)
  289. * @throws \Exception 所有接口都失败时抛出异常
  290. */
  291. public function callApi(array $data, string $model, int $timeout = 60): array
  292. {
  293. $allErrors = [];
  294. $httpCodes = [];
  295. $curlErrnos = [];
  296. // 1. 预加载该模型的所有API配置(按优先级排序,仅启用状态)
  297. $aiModelConfigs = Db::name("ai_model")
  298. ->where('model_name', $model)
  299. ->where('status', '1')
  300. ->order('sort ASC')
  301. ->select();
  302. if (empty($aiModelConfigs)) {
  303. throw new \Exception("模型配置不存在: {$model}");
  304. }
  305. // 2. 提前序列化JSON(只做一次,避免重复编码)
  306. $postData = json_encode($data, JSON_UNESCAPED_UNICODE);
  307. if (json_last_error() !== JSON_ERROR_NONE) {
  308. throw new \Exception("请求数据JSON编码失败: " . json_last_error_msg());
  309. }
  310. // 3. 遍历所有接口,逐个尝试调用
  311. foreach ($aiModelConfigs as $index => $config) {
  312. $ch = null;
  313. try {
  314. // 跳过空配置(同时记录到错误数组,避免最终汇总时 $httpCodes/$curlErrnos 为空导致未定义下标)
  315. if (empty($config['api_url']) || empty($config['api_key'])) {
  316. $allErrors[] = "第" . ($index + 1) . "个接口配置无效(URL/Key为空)";
  317. $httpCodes[] = 0;
  318. $curlErrnos[] = 0;
  319. continue;
  320. }
  321. // 初始化curl(每个接口新建连接,避免复用导致的问题)
  322. $ch = curl_init();
  323. $curlOptions = [
  324. CURLOPT_URL => $config['api_url'],
  325. CURLOPT_RETURNTRANSFER => true,
  326. CURLOPT_POST => true,
  327. CURLOPT_POSTFIELDS => $postData,
  328. CURLOPT_HTTPHEADER => [
  329. 'Content-Type: application/json',
  330. 'Authorization: Bearer ' . $config['api_key'],
  331. 'Connection: keep-alive',
  332. 'Keep-Alive: 300'
  333. ],
  334. CURLOPT_TIMEOUT => $timeout,
  335. CURLOPT_CONNECTTIMEOUT => 15,
  336. CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
  337. CURLOPT_FAILONERROR => false,
  338. // 生产环境建议开启SSL验证(需配置CA证书)
  339. CURLOPT_SSL_VERIFYPEER => true,
  340. CURLOPT_SSL_VERIFYHOST => 2,
  341. CURLOPT_TCP_NODELAY => true,
  342. CURLOPT_FORBID_REUSE => false,
  343. CURLOPT_FRESH_CONNECT => false
  344. ];
  345. curl_setopt_array($ch, $curlOptions);
  346. // 执行请求(每个接口仅调用一次,不做同接口重试)
  347. $response = curl_exec($ch);
  348. $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
  349. $curlErrno = curl_errno($ch);
  350. $curlError = curl_error($ch);
  351. // 检查CURL底层错误
  352. if ($response === false) {
  353. $errorMsg = "第" . ($index + 1) . "个接口CURL失败 [{$curlErrno}]: {$curlError}";
  354. $allErrors[] = $errorMsg;
  355. $httpCodes[] = $httpCode;
  356. $curlErrnos[] = $curlErrno;
  357. continue; // 跳过当前接口,尝试下一个
  358. }
  359. // 解析响应
  360. $result = empty($response) ? [] : json_decode($response, true);
  361. if (json_last_error() !== JSON_ERROR_NONE && !empty($response)) {
  362. $errorMsg = "第" . ($index + 1) . "个接口响应解析失败: " . json_last_error_msg();
  363. $allErrors[] = $errorMsg;
  364. $httpCodes[] = $httpCode;
  365. $curlErrnos[] = $curlErrno;
  366. continue;
  367. }
  368. // 检查API业务错误
  369. if (isset($result['error'])) {
  370. $apiErrorDetail = $result['error']['message'] ?? '';
  371. $errorType = $result['error']['type'] ?? '';
  372. $errorCode = $result['error']['code'] ?? '';
  373. $errorMessages = [
  374. 'invalid_request_error' => '请求参数错误',
  375. 'authentication_error' => '认证失败',
  376. 'rate_limit_error' => '请求频率过高',
  377. 'insufficient_quota' => '额度不足',
  378. 'billing_not_active' => '账户未开通付费',
  379. 'content_policy_violation' => '内容违反政策',
  380. 'model_not_found' => '模型不存在或无可用渠道',
  381. 'bad_response_body' => '上游响应体不完整',
  382. 'server_error' => '服务端临时异常'
  383. ];
  384. $friendlyMessage = $errorMessages[$errorCode] ?? ($errorMessages[$errorType] ?? 'API服务错误');
  385. $detailedError = "第" . ($index + 1) . "个接口{$friendlyMessage}";
  386. if ($errorCode) $detailedError .= " (错误代码: {$errorCode})";
  387. if ($apiErrorDetail) $detailedError .= ": {$apiErrorDetail}";
  388. $allErrors[] = $detailedError;
  389. $httpCodes[] = $httpCode;
  390. $curlErrnos[] = $curlErrno;
  391. continue;
  392. }
  393. // 检查HTTP状态码
  394. if ($httpCode !== 200) {
  395. $statusMessages = [
  396. 400 => '请求参数不合法',
  397. 401 => 'API密钥无效或权限不足',
  398. 403 => '访问被拒绝',
  399. 404 => 'API端点不存在',
  400. 429 => '请求过于频繁,请稍后再试',
  401. 500 => '服务器内部错误',
  402. 503 => '服务暂时不可用'
  403. ];
  404. $statusMessage = $statusMessages[$httpCode] ?? "HTTP错误({$httpCode})";
  405. $allErrors[] = "第" . ($index + 1) . "个接口{$statusMessage}";
  406. $httpCodes[] = $httpCode;
  407. $curlErrnos[] = $curlErrno;
  408. continue;
  409. }
  410. // 任意一个接口成功,直接返回结果
  411. curl_close($ch);
  412. return $result;
  413. } catch (\Exception $e) {
  414. // 捕获当前接口的异常,记录后尝试下一个
  415. $allErrors[] = "第" . ($index + 1) . "个接口异常: " . $e->getMessage();
  416. $httpCodes[] = 0;
  417. $curlErrnos[] = 0;
  418. if (is_resource($ch)) {
  419. curl_close($ch);
  420. }
  421. continue;
  422. }
  423. }
  424. // 所有接口都失败,抛出汇总异常
  425. $finalError = "所有API接口调用失败(共尝试" . count($aiModelConfigs) . "个接口)\n";
  426. $finalError .= "失败详情:\n- " . implode("\n- ", $allErrors) . "\n";
  427. $lastHttpCode = $httpCodes[count($httpCodes) - 1] ?? 0;
  428. $lastCurlErrno = $curlErrnos[count($curlErrnos) - 1] ?? 0;
  429. $finalError .= "建议解决方案: " . $this->getErrorSolution($lastHttpCode, $lastCurlErrno) . "\n";
  430. throw new \Exception($finalError);
  431. }
  432. /**
  433. * 获取错误原因
  434. */
  435. private function getErrorCause(int $httpCode, string $apiErrorDetail, int $curlErrno): string
  436. {
  437. $causeMap = [
  438. // HTTP状态码
  439. 400 => '参数格式错误/必填参数缺失',
  440. 401 => 'API Key无效/过期/无权限',
  441. 429 => '超出接口调用频率限制',
  442. 500 => '服务端内部故障',
  443. 503 => '服务维护/算力不足',
  444. // CURL错误码
  445. CURLE_OPERATION_TIMEDOUT => '请求超时(模型处理耗时超过设置值)',
  446. CURLE_COULDNT_CONNECT => '无法连接到API服务器',
  447. CURLE_SSL_CACERT => 'SSL证书验证失败',
  448. // 业务错误关键词
  449. 'model_not_found' => '模型渠道未开通/无可用资源',
  450. 'invalid_size' => '模型尺寸参数不符合要求',
  451. ];
  452. // 优先匹配CURL错误码
  453. if (isset($causeMap[$curlErrno])) {
  454. return $causeMap[$curlErrno];
  455. }
  456. // 匹配HTTP状态码
  457. if (isset($causeMap[$httpCode])) {
  458. return $causeMap[$httpCode];
  459. }
  460. // 匹配业务错误关键词
  461. foreach ($causeMap as $key => $cause) {
  462. if (is_string($key) && strpos($apiErrorDetail, $key) !== false) {
  463. return $cause;
  464. }
  465. }
  466. // 特殊关键词匹配
  467. if (strpos($apiErrorDetail, 'No available capacity') !== false) {
  468. return '模型算力不足,无可用资源';
  469. } elseif (strpos($apiErrorDetail, 'size is invalid') !== false) {
  470. return '模型尺寸参数无效';
  471. }
  472. // 兜底
  473. return $apiErrorDetail ?: '未知原因';
  474. }
  475. /**
  476. * 获取错误解决方案
  477. */
  478. private function getErrorSolution(int $httpCode, int $curlErrno): string
  479. {
  480. $solutionMap = [
  481. // HTTP状态码
  482. 400 => '1. 检查参数是否完整 2. 确认参数类型 3. 验证尺寸/模型名是否合法',
  483. 401 => '1. 检查API Key是否正确 2. 确认Key未过期/有对应模型权限',
  484. 429 => '1. 降低请求频率 2. 等待1-5分钟后重试 3. 联系服务商提升配额',
  485. 500 => '1. 等待几分钟后重试 2. 联系API服务商排查',
  486. 503 => '1. 等待算力释放 2. 联系服务商扩容',
  487. // CURL错误码
  488. CURLE_OPERATION_TIMEDOUT => '1. 延长超时时间 2. 检查模型生成耗时 3. 重试请求',
  489. CURLE_COULDNT_CONNECT => '1. 检查API地址是否正确 2. 验证网络连通性 3. 检查防火墙配置',
  490. CURLE_SSL_CACERT => '1. 开启SSL证书验证 2. 配置正确的CA证书路径 3. 确认API域名证书有效',
  491. ];
  492. if (isset($solutionMap[$curlErrno])) {
  493. return $solutionMap[$curlErrno];
  494. }
  495. if (isset($solutionMap[$httpCode])) {
  496. return $solutionMap[$httpCode];
  497. }
  498. return '1. 等待几分钟后重试 2. 检查API服务提供商状态 3. 联系服务提供商确认服务可用性';
  499. }
  500. // /**
  501. // * 通用 API 调用方法(支持重试机制)
  502. // *
  503. // * @param string $url 接口地址
  504. // * @param string $apiKey 授权密钥(Bearer Token)
  505. // * @param array $data 请求数据(JSON 格式)
  506. // *
  507. // * 功能说明:
  508. // * - 使用 cURL 发送 POST 请求到指定 API 接口
  509. // * - 设置请求头和超时时间等参数
  510. // * - 支持最多重试 2 次,当接口调用失败时自动重试
  511. // * - 返回成功时解析 JSON 响应为数组
  512. // *
  513. // * 异常处理:
  514. // * - 若全部重试失败,将抛出异常并包含最后一次错误信息
  515. // *
  516. // * @return array 接口响应数据(成功时返回解析后的数组)
  517. // * @throws \Exception 接口请求失败时抛出异常
  518. // */
  519. // public static function callApi($data,$model,$timeout = 60)
  520. // {
  521. // $maxRetries = 0; // 减少重试次数为0,避免不必要的等待
  522. // $attempt = 0;
  523. // $lastError = '';
  524. // $httpCode = 0;
  525. // $apiErrorDetail = '';
  526. //
  527. // $ai_model = Db::name("ai_model")
  528. // ->where('model_name',$model)
  529. // ->select();
  530. // $num = 0;
  531. // while ($attempt <= $maxRetries) {
  532. // try {
  533. // if(!$ai_model[$num]){
  534. // throw new \Exception("请求发送失败: " . $curlError);
  535. // }
  536. // $ch = curl_init();
  537. // curl_setopt_array($ch, [
  538. // CURLOPT_URL => $ai_model[$num]['api_url'],
  539. // CURLOPT_RETURNTRANSFER => true,
  540. // CURLOPT_POST => true,
  541. // CURLOPT_POSTFIELDS => json_encode($data, JSON_UNESCAPED_UNICODE),
  542. // CURLOPT_HTTPHEADER => [
  543. // 'Content-Type: application/json',
  544. // 'Authorization: Bearer ' . $ai_model[$num]['api_key']
  545. // ],
  546. // CURLOPT_TIMEOUT => (int) $timeout,
  547. // CURLOPT_SSL_VERIFYPEER => false,
  548. // CURLOPT_SSL_VERIFYHOST => false,
  549. // CURLOPT_CONNECTTIMEOUT => 15, // 减少连接超时时间为15秒
  550. // CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
  551. // CURLOPT_FAILONERROR => false
  552. // ]);
  553. //
  554. // $response = curl_exec($ch);
  555. // $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
  556. // $curlError = curl_error($ch);
  557. //
  558. // if ($response === false) {
  559. // // 尝试从curl错误信息中提取HTTP状态码
  560. // if (preg_match('/HTTP\/[0-9.]+\s+([0-9]+)/', $curlError, $matches)) {
  561. // $httpCode = (int)$matches[1];
  562. // }
  563. // throw new \Exception("请求发送失败: " . $curlError);
  564. // }
  565. //
  566. // $result = json_decode($response, true);
  567. //
  568. // // 检查API返回的错误
  569. // if (isset($result['error'])) {
  570. // $apiErrorDetail = $result['error']['message'] ?? '';
  571. // $errorType = $result['error']['type'] ?? '';
  572. // $errorCode = $result['error']['code'] ?? '';
  573. //
  574. // // 常见错误类型映射
  575. // $errorMessages = [
  576. // 'invalid_request_error' => '请求参数错误',
  577. // 'authentication_error' => '认证失败',
  578. // 'rate_limit_error' => '请求频率过高',
  579. // 'insufficient_quota' => '额度不足',
  580. // 'billing_not_active' => '账户未开通付费',
  581. // 'content_policy_violation' => '内容违反政策',
  582. // 'model_not_found' => '模型不存在或无可用渠道'
  583. // ];
  584. //
  585. // // 优先使用errorCode进行映射,如果没有则使用errorType
  586. // $friendlyMessage = $errorMessages[$errorCode] ?? ($errorMessages[$errorType] ?? 'API服务错误');
  587. //
  588. // // 构建详细的错误信息,包含错误代码、类型和详细描述
  589. // $detailedError = "{$friendlyMessage}";
  590. // if ($errorCode) {
  591. // $detailedError .= " (错误代码: {$errorCode})";
  592. // }
  593. // if ($apiErrorDetail) {
  594. // $detailedError .= ": {$apiErrorDetail}";
  595. // }
  596. //
  597. // throw new \Exception($detailedError);
  598. // }
  599. //
  600. // if ($httpCode !== 200) {
  601. // // HTTP状态码映射
  602. // $statusMessages = [
  603. // 400 => '请求参数不合法',
  604. // 401 => 'API密钥无效或权限不足',
  605. // 403 => '访问被拒绝',
  606. // 404 => 'API端点不存在',
  607. // 429 => '请求过于频繁,请稍后再试',
  608. // 500 => '服务器内部错误',
  609. // 503 => '服务暂时不可用'
  610. // ];
  611. //
  612. // $statusMessage = $statusMessages[$httpCode] ?? "HTTP错误({$httpCode})";
  613. // throw new \Exception($statusMessage);
  614. // }
  615. //
  616. // curl_close($ch);
  617. // return $result;
  618. //
  619. // } catch (\Exception $e) {
  620. // $lastError = $e->getMessage();
  621. // $attempt++;
  622. // $num++;
  623. // if ($attempt <= $maxRetries) {
  624. // sleep(pow(2, $attempt));
  625. // } else {
  626. // // 最终失败时的详细错误信息
  627. // $errorDetails = [
  628. // '错误原因' => self::getErrorCause($httpCode, $apiErrorDetail),
  629. // '解决方案' => self::getErrorSolution($httpCode),
  630. // '请求参数' => json_encode($data, JSON_UNESCAPED_UNICODE),
  631. // 'HTTP状态码' => $httpCode,
  632. // '重试次数' => $attempt
  633. // ];
  634. //
  635. // // 构建最终的错误信息,优先显示原始的详细错误消息
  636. // $finalError = "API请求失败\n";
  637. // $finalError .= "失败说明: " . $lastError . "\n"; // 使用原始的详细错误消息
  638. // $finalError .= "建议解决方案: " . $errorDetails['解决方案'] . "\n";
  639. // $finalError .= "技术详情: HTTP {$httpCode} - " . $errorDetails['错误原因'];
  640. //
  641. // throw new \Exception($finalError);
  642. // }
  643. // }
  644. // }
  645. // }
  646. //
  647. // private static function getErrorCause($httpCode, $apiErrorDetail)
  648. // {
  649. // $causeMap = [
  650. // 400 => '参数格式错误/必填参数缺失',
  651. // 401 => 'API Key无效/过期/无权限',
  652. // 429 => '超出接口调用频率限制',
  653. // 500 => '服务端内部故障',
  654. // 503 => '服务维护/算力不足',
  655. // 'model_not_found' => '模型渠道未开通/无可用资源',
  656. // 'invalid_size' => '模型尺寸参数不符合要求',
  657. // CURLE_OPERATION_TIMEDOUT => '请求超时(模型处理耗时超过设置值)'
  658. // ];
  659. //
  660. // if (strpos($apiErrorDetail, 'No available capacity') !== false) {
  661. // return '模型算力不足,无可用资源';
  662. // } elseif (strpos($apiErrorDetail, 'size is invalid') !== false) {
  663. // return '模型尺寸参数无效';
  664. // } elseif (isset($causeMap[$httpCode])) {
  665. // return $causeMap[$httpCode];
  666. // }
  667. // return $apiErrorDetail ?: '未知原因';
  668. // }
  669. //
  670. // private static function getErrorSolution($httpCode)
  671. // {
  672. // $solutionMap = [
  673. // 400 => '1. 检查参数是否完整 2. 确认参数类型(如seconds为字符串) 3. 验证尺寸/模型名是否合法',
  674. // 401 => '1. 检查API Key是否正确 2. 确认Key未过期/有对应模型权限',
  675. // 429 => '1. 降低请求频率 2. 等待1-5分钟后重试',
  676. // 500 => '1. 等待几分钟后重试 2. 联系API服务商排查',
  677. // 503 => '1. 等待算力释放 2. 联系服务商扩容',
  678. // CURLE_OPERATION_TIMEDOUT => '1. 延长超时时间 2. 检查模型生成耗时 3. 重试请求'
  679. // ];
  680. //
  681. // if (isset($solutionMap[$httpCode])) {
  682. // return $solutionMap[$httpCode];
  683. // }
  684. // return '1. 等待几分钟后重试 2. 检查API服务提供商状态 3. 联系服务提供商确认服务可用性';
  685. // }
  686. /**
  687. * 获取图片的base64数据和MIME类型
  688. * @return array 包含base64数据和MIME类型的数组
  689. */
  690. public static function file_get_contents($ImageUrl){
  691. // 兼容三种输入:
  692. // 1) 本地相对路径:uploads/xxx.png 或 /uploads/xxx.png
  693. // 2) 已是完整 URL:https://.../xxx.png
  694. // 3) OSS 相对路径(本地不存在时,用 oss.host 兜底拉取)
  695. $imageUrl = trim((string)$ImageUrl);
  696. if ($imageUrl === '') {
  697. throw new \Exception('图片路径不能为空');
  698. }
  699. $rootPath = str_replace('\\', '/', ROOT_PATH);
  700. $relativePath = ltrim($imageUrl, '/');
  701. $localPath = rtrim($rootPath, '/') . '/public/' . $relativePath;
  702. // 前端可能传了 URL 编码文件名(如中文名),本地匹配时做一次解码兜底
  703. $localPathDecoded = rtrim($rootPath, '/') . '/public/' . urldecode($relativePath);
  704. $imageContent = false;
  705. $mimeType = '';
  706. // A. 直接是完整 URL,直接远程读取
  707. if (preg_match('/^https?:\/\//i', $imageUrl)) {
  708. $imageContent = @file_get_contents($imageUrl);
  709. } else {
  710. // B. 本地优先:先按原路径查,再按 urldecode 后路径查
  711. if (file_exists($localPath)) {
  712. $imageContent = @file_get_contents($localPath);
  713. if ($imageContent !== false) {
  714. $finfo = finfo_open(FILEINFO_MIME_TYPE);
  715. $mimeType = finfo_file($finfo, $localPath);
  716. finfo_close($finfo);
  717. }
  718. } elseif (file_exists($localPathDecoded)) {
  719. $imageContent = @file_get_contents($localPathDecoded);
  720. if ($imageContent !== false) {
  721. $finfo = finfo_open(FILEINFO_MIME_TYPE);
  722. $mimeType = finfo_file($finfo, $localPathDecoded);
  723. finfo_close($finfo);
  724. }
  725. } else {
  726. // C. 本地不存在:尝试从 OSS 拉取
  727. $ossHost = trim((string)config('oss.host'));
  728. if ($ossHost !== '') {
  729. if (stripos($ossHost, 'http://') !== 0 && stripos($ossHost, 'https://') !== 0) {
  730. $ossHost = 'https://' . $ossHost;
  731. }
  732. $remoteUrl = rtrim($ossHost, '/') . '/' . ltrim($relativePath, '/');
  733. $imageContent = @file_get_contents($remoteUrl);
  734. }
  735. }
  736. }
  737. if ($imageContent === false || $imageContent === '') {
  738. throw new \Exception('图片内容读取失败(本地/OSS均未读取到)');
  739. }
  740. // 若本地未拿到 MIME,则根据二进制内容推断
  741. if ($mimeType === '') {
  742. $finfo = finfo_open(FILEINFO_MIME_TYPE);
  743. $mimeType = finfo_buffer($finfo, $imageContent);
  744. finfo_close($finfo);
  745. }
  746. if (!$mimeType) {
  747. $mimeType = 'image/png';
  748. }
  749. return [
  750. 'base64Data' => base64_encode($imageContent),
  751. 'mimeType' => $mimeType
  752. ];
  753. }
  754. }