m0_70156489 1 week ago
parent
commit
3d26ac05b8

+ 6 - 4
application/api/controller/WorkOrder.php

@@ -106,11 +106,12 @@ class WorkOrder extends Api{
             // 3. 执行对应处理逻辑并返回响应
             return $this->$method($params);
         } catch (\InvalidArgumentException $e) {
-            // 参数/方法异常(用户侧错误)
             return $this->jsonResponse(1, $e->getMessage());
-        } catch (\Throwable $e) {
-            // 系统异常(服务侧错误)
+        } catch (\Exception $e) {
             \think\Log::error('AI接口处理异常:' . $e->getMessage() . ' | 任务类型:' . ($params['status_val'] ?? '未知') . ' | 异常行:' . $e->getLine());
+            return $this->jsonResponse(1, $e->getMessage());
+        } catch (\Throwable $e) {
+            \think\Log::error('AI接口系统异常:' . $e->getMessage() . ' | 任务类型:' . ($params['status_val'] ?? '未知') . ' | 异常行:' . $e->getLine());
             return $this->jsonResponse(1, '服务异常,请稍后重试');
         }
     }
@@ -217,6 +218,7 @@ class WorkOrder extends Api{
             . "3. 不要添加任何额外的引导语、解释或开场白\n"
             . "4. 禁忌:不添加无关形容词,不修改产品核心信息,语言流畅自然";
         $prompt = ($params['prompt'] ?? '') . $promptTemplate;
+
         // 调用服务层生成内容
         $result = (new ImageService())->handleTextToText(
             $params['status_val'],
@@ -232,7 +234,7 @@ class WorkOrder extends Api{
         $isProductImageGeneration = ($params['status_type'] ?? '') === 'ProductImageGeneration';
         $isProductTemplateReplace = ($params['status_type'] ?? '') === 'ProductTemplateReplace';
 
-        if (!$isProductImageGeneration && !$isProductTemplateReplace) {
+        if (!$isProductImageGeneration && !$isProductTemplateReplace && !empty($params['id'])) {
             Db::name('product')->where('id', $params['id'])->update(['content' => $content]);
         }
 

+ 292 - 208
application/job/ImageToImageJob.php

@@ -38,12 +38,20 @@ class ImageToImageJob{
                 if (is_array($result) && isset($result['code']) && $result['code'] !== 0) {
                     throw new \Exception($result['msg'] ?? '图生图失败');
                 }
-                echo "🎉 任务 {$taskId} 执行完成,图片生成成功!\n";
+                if (is_array($result) && !empty($result['success_report'])) {
+                    echo $result['success_report'] . "\n";
+                } else {
+                    $imgPath = is_array($result) ? ($result['path'] ?? '') : $result;
+                    echo "🎉 任务 {$taskId} 执行完成,图片生成成功!\n";
+                    if ($imgPath !== '') {
+                        echo "生成图片:{$imgPath}\n";
+                    }
+                }
                 echo "结束时间:" . date('Y-m-d H:i:s') . "\n";
                 $job->delete();
 
             } catch (\Exception $e) {
-                echo "图生图失败: " . $e->getMessage() . "\n";
+                echo $e->getMessage() . "\n";
                 \think\Log::error('[ImageToImageJob] ' . $e->getMessage());
                 $job->delete();
             }
@@ -170,6 +178,7 @@ class ImageToImageJob{
      * 业务分支:
      * - ProductImageGeneration:产品图创作(前端传 base64,先调模型成功后再落盘入库)
      * - ProductTemplateReplace:产品替换(读取已有产品图/模板图路径,生成效果图并返回前端展示)
+     * - ProductWhiteBackground:产品白底抠图(单张 product_img,结果写入 product_white_background 表)
      *
      * 入参说明($data):
      * - prompt: 提示词
@@ -187,7 +196,7 @@ class ImageToImageJob{
      */
     public function get_img_to_img($data)
     {
-        // 1) 基础参数解析
+        //接收参数
         $prompt = trim($data['prompt'] ?? '');
         $size = trim($data['size'] ?? '');
         $statusVal = trim($data['status_val'] ?? '');
@@ -200,19 +209,9 @@ class ImageToImageJob{
         $width = trim((string)($data['width'] ?? ''));
         $model = trim($data['model'] ?? '');
         $sysId = $data['sys_id'];
-        $now = date('Y-m-d H:i:s');
-
-        // texttoimage:前端传 old_img(base64/路径),size 为空时用 width x height
-        if ($statusType === 'texttoimage') {
-            if ($size === '' && $width !== '' && $height !== '') {
-                $size = $width . 'x' . $height;
-            }
-            if ($size === '') {
-                $size = '1:1';
-            }
-        }
+        $sysrq = date('Y-m-d H:i:s');
 
-        // 失败统一回写任务状态,避免多处重复代码
+        /** 失败回调:写入 Redis 任务状态为「失败」,并返回统一错误结构 */
         $fail = function ($msg) use ($data) {
             if (!empty($data['task_id'])) {
                 try {
@@ -224,30 +223,13 @@ class ImageToImageJob{
                         'completed_at' => date('Y-m-d H:i:s')
                     ], JSON_UNESCAPED_UNICODE), ['EX' => 300]);
                 } catch (\Exception $e) {
-                    // Redis 不可用时不阻断主流程
+                    // Redis 不可用时仍返回错误,不阻断主流程
                 }
             }
             return ['code' => 1, 'msg' => $msg];
         };
 
-        // 成功统一回写任务状态
-        $complete = function ($imgPath) use ($data) {
-            if (!empty($data['task_id'])) {
-                try {
-                    $redis = getTaskRedis();
-                    $redis->set("img_to_img_task:" . $data['task_id'], json_encode([
-                        'status' => ImageService::TASK_STATUS_COMPLETED,
-                        'image' => $imgPath,
-                        'image_url' => $imgPath,
-                        'completed_at' => date('Y-m-d H:i:s')
-                    ], JSON_UNESCAPED_UNICODE), ['EX' => 300]);
-                } catch (\Exception $e) {
-                    // Redis 不可用时不阻断主流程
-                }
-            }
-        };
-
-        // 2) 解析输入图片(按页面类型分支)
+        // ========== 第二步:按 status_type 解析图片、拼提示词 ==========
         $productBase64 = null;
         $productMime = 'image/png';
         $templateBase64 = null;
@@ -256,43 +238,14 @@ class ImageToImageJob{
         $templateExt = 'png';
         $sourceProductPath = '';
         $sourceTemplatePath = '';
+        $texttoimageSaveDir = '';
+        $texttoimageDateSegment = '';
+        $whiteBgSaveDir = '';
+        $whiteBgDateSegment = '';
+        $productDbPath = '';
+        $promptContent = '';
 
-        if ($statusType === 'ProductImageGeneration') {
-            // 产品图创作:前端直接传 base64,先解析,等模型成功后再入库/落盘
-            preg_match('/data:image\/(png|jpg|jpeg);base64,([^"]+)/', $productImg, $pm);
-            if (empty($pm)) {
-                return $fail('产品图未找到图片数据');
-            }
-            $productBase64 = preg_replace('/\s+/', '', $pm[2]);
-            $productMime = ($pm[1] === 'jpg' ? 'image/jpeg' : 'image/' . $pm[1]);
-            $productExt = $pm[1];
-
-            preg_match('/data:image\/(png|jpg|jpeg);base64,([^"]+)/', $templateImg, $tm);
-            if (empty($tm)) {
-                return $fail('模板图未找到图片数据');
-            }
-            $templateBase64 = preg_replace('/\s+/', '', $tm[2]);
-            $templateMime = ($tm[1] === 'jpg' ? 'image/jpeg' : 'image/' . $tm[1]);
-            $templateExt = $tm[1];
-        } elseif ($statusType === 'ProductTemplateReplace') {
-            // 产品替换:优先转为 OSS 完整 URL 再读取,兼容库中存相对路径
-            $productImgSource = Common::ossFullUrl((string)$productImg);
-            $templateImgSource = Common::ossFullUrl((string)$templateImg);
-
-            try {
-                $productImgRaw = AIGatewayService::file_get_contents($productImgSource);
-                $productBase64 = $productImgRaw['base64Data'];
-                $productMime = $productImgRaw['mimeType'];
-
-                $templateImgRaw = AIGatewayService::file_get_contents($templateImgSource);
-                $templateBase64 = $templateImgRaw['base64Data'];
-                $templateMime = $templateImgRaw['mimeType'];
-            } catch (\Exception $e) {
-                // 回传清晰错误,方便定位是产品图还是模板图读取失败
-                return $fail('读取产品图/模板图失败: ' . $e->getMessage());
-            }
-        } elseif ($statusType === 'texttoimage') {
-            // 前端传 old_img(原图 base64 或路径),ref_img(参考图,可选)
+        if ($statusType === 'texttoimage') {
             $inputOldImg = $oldImg !== '' ? $oldImg : $productImg;
             $inputRefImg = $refImg !== '' ? $refImg : $templateImg;
 
@@ -304,8 +257,7 @@ class ImageToImageJob{
             } elseif ($inputOldImg !== '') {
                 $sourceProductPath = ltrim(str_replace('\\', '/', $inputOldImg), '/');
                 try {
-                    $productImgSource = Common::ossFullUrl((string)$inputOldImg);
-                    $productImgRaw = AIGatewayService::file_get_contents($productImgSource);
+                    $productImgRaw = AIGatewayService::file_get_contents(Common::ossFullUrl((string)$inputOldImg));
                     $productBase64 = $productImgRaw['base64Data'];
                     $productMime = $productImgRaw['mimeType'];
                 } catch (\Exception $e) {
@@ -324,8 +276,7 @@ class ImageToImageJob{
                 } else {
                     $sourceTemplatePath = ltrim(str_replace('\\', '/', $inputRefImg), '/');
                     try {
-                        $templateImgSource = Common::ossFullUrl((string)$inputRefImg);
-                        $templateImgRaw = AIGatewayService::file_get_contents($templateImgSource);
+                        $templateImgRaw = AIGatewayService::file_get_contents(Common::ossFullUrl((string)$inputRefImg));
                         $templateBase64 = $templateImgRaw['base64Data'];
                         $templateMime = $templateImgRaw['mimeType'];
                     } catch (\Exception $e) {
@@ -333,62 +284,165 @@ class ImageToImageJob{
                     }
                 }
             }
-        } else {
-            return $fail('当前页面未进行配置,请联系管理员开通权限');
-        }
 
-        // 3) 调模型生成图像
-        if ($statusType === 'texttoimage') {
-            $promptContent = $prompt !== '' ? $prompt : '请根据原图生成高质量图片';
-
-            // 原图/参考图先落盘并上传 OSS(即使 AI 失败也能在 OSS 看到 texttoimage 目录)
+            $texttoimageDateSegment = date('Y-m-d');
             $rootPath = str_replace('\\', '/', ROOT_PATH);
-            $dateSegment = date('Y-m-d');
-            $saveDir = rtrim($rootPath, '/') . '/public/uploads/texttoimage/' . $dateSegment . '/';
-            if (!is_dir($saveDir)) {
-                mkdir($saveDir, 0755, true);
+            $texttoimageSaveDir = rtrim($rootPath, '/') . '/public/uploads/texttoimage/' . $texttoimageDateSegment . '/';
+            if (!is_dir($texttoimageSaveDir)) {
+                mkdir($texttoimageSaveDir, 0755, true);
             }
+
             if ($sourceProductPath === '' && !empty($productBase64)) {
                 $sourceFile = 'source-' . uniqid() . '.' . $productExt;
                 $sourceImageData = base64_decode($productBase64);
-                if ($sourceImageData === false || !file_put_contents($saveDir . $sourceFile, $sourceImageData)) {
+                if ($sourceImageData === false || !file_put_contents($texttoimageSaveDir . $sourceFile, $sourceImageData)) {
                     return $fail('原图保存失败');
                 }
-                $sourceProductPath = 'uploads/texttoimage/' . $dateSegment . '/' . $sourceFile;
-                if (!Common::uploadLocalFileToOss((string)($saveDir . $sourceFile), (string)$sourceProductPath)) {
+                $sourceProductPath = 'uploads/texttoimage/' . $texttoimageDateSegment . '/' . $sourceFile;
+                if (!Common::uploadLocalFileToOss((string)($texttoimageSaveDir . $sourceFile), (string)$sourceProductPath)) {
                     echo 'OSS 上传原图失败: ' . $sourceProductPath . "\n";
                 }
             }
             if ($sourceTemplatePath === '' && !empty($templateBase64)) {
                 $refFile = 'ref-' . uniqid() . '.' . $templateExt;
                 $refImageData = base64_decode($templateBase64);
-                if ($refImageData !== false && file_put_contents($saveDir . $refFile, $refImageData)) {
-                    $sourceTemplatePath = 'uploads/texttoimage/' . $dateSegment . '/' . $refFile;
-                    if (!Common::uploadLocalFileToOss((string)($saveDir . $refFile), (string)$sourceTemplatePath)) {
+                if ($refImageData !== false && file_put_contents($texttoimageSaveDir . $refFile, $refImageData)) {
+                    $sourceTemplatePath = 'uploads/texttoimage/' . $texttoimageDateSegment . '/' . $refFile;
+                    if (!Common::uploadLocalFileToOss((string)($texttoimageSaveDir . $refFile), (string)$sourceTemplatePath)) {
                         echo 'OSS 上传参考图失败: ' . $sourceTemplatePath . "\n";
                     }
                 }
             }
-        } else {
+
+            $promptContent = $prompt !== '' ? $prompt : '请根据原图生成高质量图片';
+
+        } elseif ($statusType === 'ProductImageGeneration') {
+            preg_match('/data:image\/(png|jpg|jpeg);base64,([^"]+)/', $productImg, $pm);
+            if (empty($pm)) {
+                return $fail('产品图未找到图片数据');
+            }
+            $productBase64 = preg_replace('/\s+/', '', $pm[2]);
+            $productMime = ($pm[1] === 'jpg' ? 'image/jpeg' : 'image/' . $pm[1]);
+            $productExt = $pm[1];
+
+            preg_match('/data:image\/(png|jpg|jpeg);base64,([^"]+)/', $templateImg, $tm);
+            if (empty($tm)) {
+                return $fail('模板图未找到图片数据');
+            }
+            $templateBase64 = preg_replace('/\s+/', '', $tm[2]);
+            $templateMime = ($tm[1] === 'jpg' ? 'image/jpeg' : 'image/' . $tm[1]);
+            $templateExt = $tm[1];
+
+            $defaultPrompt = '请完成产品模板替换:
+                            1. 从产品图提取产品主体、品牌名称、核心文案;
+                            2. 从模板图继承版式布局、文字排版、色彩风格、背景元素;
+                            3. 将模板图中的产品和文字替换为产品图的内容;
+                            4. 最终生成的图片与模板图视觉风格统一,仅替换产品和文字。';
+            $promptContent = $prompt.$defaultPrompt;
+
+        } elseif ($statusType === 'ProductTemplateReplace') {
+            $productImgRaw = AIGatewayService::file_get_contents(Common::ossFullUrl((string)$productImg));
+            $productBase64 = $productImgRaw['base64Data'];
+            $productMime = $productImgRaw['mimeType'];
+
+            $templateImgRaw = AIGatewayService::file_get_contents(Common::ossFullUrl((string)$templateImg));
+            $templateBase64 = $templateImgRaw['base64Data'];
+            $templateMime = $templateImgRaw['mimeType'];
+
             $defaultPrompt = '请完成产品模板替换:
                             1. 从产品图提取产品主体、品牌名称、核心文案;
                             2. 从模板图继承版式布局、文字排版、色彩风格、背景元素;
                             3. 将模板图中的产品和文字替换为产品图的内容;
                             4. 最终生成的图片与模板图视觉风格统一,仅替换产品和文字。';
-            $promptContent = $prompt ? $prompt . "\n\n" . $defaultPrompt : $defaultPrompt;
+            $promptContent = $prompt.$defaultPrompt;
+
+        } elseif ($statusType === 'ProductWhiteBackground') {
+            //处理图片抠图
+            if (preg_match('/data:image\/(png|jpg|jpeg|webp);base64,(.+)$/is', $productImg, $pm)) {
+                $productBase64 = preg_replace('/\s+/', '', $pm[2]);
+                $ext = strtolower($pm[1]);
+                $productMime = ($ext === 'jpg' ? 'image/jpeg' : 'image/' . $ext);
+                $productExt = ($ext === 'jpeg' ? 'jpg' : $ext);
+            } elseif ($productImg !== '') {
+                try {
+                    $productImgRaw = AIGatewayService::file_get_contents(Common::ossFullUrl((string)$productImg));
+                    $productBase64 = $productImgRaw['base64Data'];
+                    $productMime = $productImgRaw['mimeType'];
+                    $productDbPath = ltrim(str_replace('\\', '/', $productImg), '/');
+                } catch (\Exception $e) {
+                    return $fail('读取图片失败: ' . $e->getMessage());
+                }
+            } else {
+                return $fail('图片不能为空');
+            }
+
+            $whiteBgDateSegment = date('Y-m-d');
+            $rootPath = str_replace('\\', '/', ROOT_PATH);
+            $whiteBgSaveDir = rtrim($rootPath, '/') . '/public/uploads/whitebackground/' . $whiteBgDateSegment . '/';
+            if (!is_dir($whiteBgSaveDir)) {
+                mkdir($whiteBgSaveDir, 0755, true);
+            }
+
+            if ($productDbPath === '' && !empty($productBase64)) {
+                $sourceFile = 'source-' . uniqid() . '.' . $productExt;
+                $sourceImageData = base64_decode($productBase64);
+                if ($sourceImageData === false || !file_put_contents($whiteBgSaveDir . $sourceFile, $sourceImageData)) {
+                    return $fail('产品图保存失败');
+                }
+                $productDbPath = 'uploads/whitebackground/' . $whiteBgDateSegment . '/' . $sourceFile;
+                if (!Common::uploadLocalFileToOss((string)($whiteBgSaveDir . $sourceFile), (string)$productDbPath)) {
+                    echo 'OSS 上传产品原图失败: ' . $productDbPath . "\n";
+                }
+            }
+
+            $promptContent = $prompt !== ''
+                ? $prompt
+                : '提取图中产品,更换成白底,保留原始色彩,不改变产品镜头朝向和主要结构,像一个全新的没有瑕疵的产品,并精修产品,清除所有指纹,灰尘与瑕疵';
+
+        } else {
+            return $fail('当前页面未进行配置,请联系管理员开通权限');
         }
+
+        //调模型生成图像(统一入口,只调一次)
         $aiGateway = new AIGatewayService();
-        // texttoimage:原图 + 提示词走图生图(gemini-3-pro-image-preview)
-        $res = $aiGateway->buildRequestData(
-            $statusVal,
-            $model,
-            $promptContent,
-            $size,
-            (string)$productBase64,
-            $productMime,
-            (string)$templateBase64,
-            $templateMime
-        );
+
+        /** 成功回调:写入 Redis(含实际模型),供前端轮询 */
+        $complete = function ($imgPath) use ($data, $aiGateway) {
+            if (!empty($data['task_id'])) {
+                try {
+                    $meta = $aiGateway->getLastImg2ImgSuccessMeta();
+                    $payload = [
+                        'status' => ImageService::TASK_STATUS_COMPLETED,
+                        'image' => $imgPath,
+                        'image_url' => $imgPath,
+                        'completed_at' => date('Y-m-d H:i:s'),
+                    ];
+                    if (!empty($meta)) {
+                        $payload['model'] = $meta['model_name'];
+                        $payload['model_info'] = $meta;
+                    }
+                    $redis = getTaskRedis();
+                    $redis->set("img_to_img_task:" . $data['task_id'], json_encode($payload, JSON_UNESCAPED_UNICODE), ['EX' => 300]);
+                } catch (\Exception $e) {
+                    // Redis 不可用时图片已生成,不影响 return
+                }
+            }
+        };
+
+        try {
+            $res = $aiGateway->buildRequestData(
+                $statusVal,
+                $model,
+                $promptContent,
+                $size,
+                (string)$productBase64,
+                $productMime,
+                (string)$templateBase64,
+                $templateMime
+            );
+        } catch (\Exception $e) {
+            return $fail($e->getMessage());
+        }
 
         $generatedBase64 = $aiGateway->extractImageBase64FromResponse($res);
         if (!$generatedBase64) {
@@ -400,9 +454,43 @@ class ImageToImageJob{
             return $fail('图片Base64解码失败');
         }
 
-        // 4) 按业务分支落盘入库
-        if ($statusType === 'ProductImageGeneration') {
-            // 4.1 产品图创作:保存产品图/模板图/生成图 -> 插入 product_image_generate
+        //存入数据库操作(按 status_type 分支)
+        if ($statusType === 'texttoimage') {
+            $fileName = 'text2img-' . date('YmdHis') . '-' . uniqid() . '.png';
+            $fullPath = $texttoimageSaveDir . $fileName;
+            if (!file_put_contents($fullPath, $generatedImageData)) {
+                return $fail('图片保存失败');
+            }
+
+            $dbImgPath = 'uploads/texttoimage/' . $texttoimageDateSegment . '/' . $fileName;
+            if (!Common::uploadLocalFileToOss((string)$fullPath, (string)$dbImgPath)) {
+                echo 'OSS 上传生成图失败: ' . $dbImgPath . "\n";
+            }
+
+            $sizeForDb = 0;
+            if ($width !== '' && is_numeric($width)) {
+                $sizeForDb = (float)$width;
+            } elseif ($size !== '' && is_numeric($size)) {
+                $sizeForDb = (float)$size;
+            }
+
+            Db::name('ai_text_image')->insert([
+                'old_img' => $sourceProductPath,
+                'ref_img' => $sourceTemplatePath,
+                'new_img' => $dbImgPath,
+                'prompt' => $prompt,
+                'size' => $sizeForDb,
+                'sys_id' => $sysId,
+                'sys_rq' => $sysrq,
+            ]);
+
+            $complete($dbImgPath);
+            return [
+                'path' => $dbImgPath,
+                'success_report' => $aiGateway->formatImg2ImgSuccessReport($dbImgPath),
+            ];
+
+        } elseif ($statusType === 'ProductImageGeneration') {
             $rootPath = str_replace('\\', '/', ROOT_PATH);
             $saveDir = rtrim($rootPath, '/') . '/public/uploads/Product/' . date('Y-m-d') . '/';
             if (!is_dir($saveDir)) {
@@ -441,14 +529,46 @@ class ImageToImageJob{
                 'status_val' => $statusVal,
                 'size' => $size,
                 'sys_id' => $sysId,
-                'createTime' => $now,
+                'createTime' => $sysrq,
+            ]);
+
+            $complete($generatedDbPath);
+            return [
+                'path' => $generatedDbPath,
+                'success_report' => $aiGateway->formatImg2ImgSuccessReport($generatedDbPath),
+            ];
+
+        } elseif ($statusType === 'ProductWhiteBackground') {
+            //处理图片抠图
+            $fileName = 'white-' . date('YmdHis') . '-' . uniqid() . '.png';
+            $fullPath = $whiteBgSaveDir . $fileName;
+            if (!file_put_contents($fullPath, $generatedImageData)) {
+                return $fail('白底图保存失败');
+            }
+
+            $generatedDbPath = 'uploads/whitebackground/' . $whiteBgDateSegment . '/' . $fileName;
+            if (!Common::uploadLocalFileToOss((string)$fullPath, (string)$generatedDbPath)) {
+                echo 'OSS 上传白底图失败: ' . $generatedDbPath . "\n";
+            }
+
+            Db::name('ai_white_background')->insert([
+                'product_img' => $productDbPath,
+                'generated_image' => $generatedDbPath,
+                'prompt' => $prompt,
+                'model' => $model,
+                'size' => $size,
+                'status_val' => $statusVal,
+                'sys_id' => $sysId,
+                'createTime' => $sysrq,
             ]);
 
             $complete($generatedDbPath);
-            return $generatedDbPath;
-        } else if ($statusType === 'ProductTemplateReplace') {
+            return [
+                'path' => $generatedDbPath,
+                'success_report' => $aiGateway->formatImg2ImgSuccessReport($generatedDbPath),
+            ];
 
-            // 4.2 产品替换:生成图落盘到 merchant/newimg -> 回写 product + product_image
+        } elseif ($statusType === 'ProductTemplateReplace') {
             $product = Db::name('product')->where('id', $data['id'])->find();
             if (empty($product)) {
                 return $fail('产品不存在');
@@ -486,47 +606,11 @@ class ImageToImageJob{
             ]);
 
             $complete($dbImgPath);
-            return $dbImgPath;
-        } elseif ($statusType === 'texttoimage') {
-            $rootPath = str_replace('\\', '/', ROOT_PATH);
-            $dateSegment = date('Y-m-d');
-            $saveDir = rtrim($rootPath, '/') . '/public/uploads/texttoimage/' . $dateSegment . '/';
-            if (!is_dir($saveDir)) {
-                mkdir($saveDir, 0755, true);
-            }
-
-            $fileName = 'text2img-' . date('YmdHis') . '-' . uniqid() . '.png';
-            $fullPath = $saveDir . $fileName;
-
-            if (!file_put_contents($fullPath, $generatedImageData)) {
-                return $fail('图片保存失败');
-            }
-
-            $dbImgPath = 'uploads/texttoimage/' . $dateSegment . '/' . $fileName;
-            if (!Common::uploadLocalFileToOss((string)$fullPath, (string)$dbImgPath)) {
-                echo 'OSS 上传生成图失败: ' . $dbImgPath . "\n";
-            }
-
-            // size 字段为 double:优先存 width,其次 size 数值
-            $sizeForDb = 0;
-            if ($width !== '' && is_numeric($width)) {
-                $sizeForDb = (float)$width;
-            } elseif ($size !== '' && is_numeric($size)) {
-                $sizeForDb = (float)$size;
-            }
-
-            Db::name('ai_text_image')->insert([
-                'old_img' => $sourceProductPath,
-                'ref_img' => $sourceTemplatePath,
-                'new_img' => $dbImgPath,
-                'prompt' => $prompt,
-                'size' => $sizeForDb,
-                'sys_id' => $sysId,
-                'sys_rq' => $now,
-            ]);
+            return [
+                'path' => $dbImgPath,
+                'success_report' => $aiGateway->formatImg2ImgSuccessReport($dbImgPath),
+            ];
 
-            $complete($dbImgPath);
-            return $dbImgPath;
         } else {
             return $fail('当前页面未进行配置,请联系管理员开通权限');
         }
@@ -534,61 +618,61 @@ class ImageToImageJob{
 
     public function ImageToImage($fileName, $outputDirRaw, $new_image_url, $width, $height)
     {
-        $rootPath = str_replace('\\', '/', ROOT_PATH);
-        $outputDir = rtrim($rootPath . 'public/' . ltrim($outputDirRaw, '/'), '/') . '/';
-        $dateDir = date('Y-m-d') . '/';
-        $fullBaseDir = $outputDir . $dateDir;
-
-        // 创建主目录和 imgtoimg 子目录
-        if (!is_dir($fullBaseDir)) {
-            mkdir($fullBaseDir, 0755, true);
-        }
-
-        $imgtoimgDir = $fullBaseDir . '1024x1303/';
-        if (!is_dir($imgtoimgDir)) {
-            mkdir($imgtoimgDir, 0755, true);
-        }
-
-        // 查询数据库原图记录
-        $record = Db::name('text_to_image')
-            ->where('old_image_url', 'like', "%{$fileName}")
-            ->order('id desc')
-            ->find();
-
-        if (!$record) {
-            return json(['code' => 1, 'msg' => '没有找到匹配的图像记录']);
-        }
-
-        // 调用 AI 图生图 API
-        $ai = new AIGatewayService();
-        $res = $ai->txt2imgWithControlNet('', $new_image_url);
-        if (!isset($res['code']) || $res['code'] !== 0) {
-            return json(['code' => 1, 'msg' => $res['msg'] ?? '图像生成失败']);
-        }
-
-        // 生成保存文件路径
-        $originalBaseName = pathinfo($new_image_url, PATHINFO_FILENAME);
-        $finalFileName = $originalBaseName . '.png';
-        $savePath = $imgtoimgDir . $finalFileName;
-
-        // 写入图像文件
-        if (!file_put_contents($savePath, base64_decode($res['data']['base64']))) {
-            return json(['code' => 1, 'msg' => '图像保存失败,请检查目录权限']);
-        }
-        // 图生图结果同步 OSS(失败不阻断)
-        $relativeImgPath = rtrim($outputDirRaw, '/') . '/' . $dateDir . '1024x1303/' . $finalFileName;
-        Common::uploadLocalFileToOss((string)$savePath, (string)$relativeImgPath);
-        // 构造相对路径用于数据库
-
-        // 更新数据库记录
-        Db::name('text_to_image')->where('id', $record['id'])->update([
-            'imgtoimg_url' => $relativeImgPath,
-            'status_name' => '图生图',
-            'error_msg' => '',
-            'update_time' => date('Y-m-d H:i:s')
-        ]);
-
-        // 返回成功响应
-        return "成功";
+        // $rootPath = str_replace('\\', '/', ROOT_PATH);
+        // $outputDir = rtrim($rootPath . 'public/' . ltrim($outputDirRaw, '/'), '/') . '/';
+        // $dateDir = date('Y-m-d') . '/';
+        // $fullBaseDir = $outputDir . $dateDir;
+
+        // // 创建主目录和 imgtoimg 子目录
+        // if (!is_dir($fullBaseDir)) {
+        //     mkdir($fullBaseDir, 0755, true);
+        // }
+
+        // $imgtoimgDir = $fullBaseDir . '1024x1303/';
+        // if (!is_dir($imgtoimgDir)) {
+        //     mkdir($imgtoimgDir, 0755, true);
+        // }
+
+        // // 查询数据库原图记录
+        // $record = Db::name('text_to_image')
+        //     ->where('old_image_url', 'like', "%{$fileName}")
+        //     ->order('id desc')
+        //     ->find();
+
+        // if (!$record) {
+        //     return json(['code' => 1, 'msg' => '没有找到匹配的图像记录']);
+        // }
+
+        // // 调用 AI 图生图 API
+        // $ai = new AIGatewayService();
+        // $res = $ai->txt2imgWithControlNet('', $new_image_url);
+        // if (!isset($res['code']) || $res['code'] !== 0) {
+        //     return json(['code' => 1, 'msg' => $res['msg'] ?? '图像生成失败']);
+        // }
+
+        // // 生成保存文件路径
+        // $originalBaseName = pathinfo($new_image_url, PATHINFO_FILENAME);
+        // $finalFileName = $originalBaseName . '.png';
+        // $savePath = $imgtoimgDir . $finalFileName;
+
+        // // 写入图像文件
+        // if (!file_put_contents($savePath, base64_decode($res['data']['base64']))) {
+        //     return json(['code' => 1, 'msg' => '图像保存失败,请检查目录权限']);
+        // }
+        // // 图生图结果同步 OSS(失败不阻断)
+        // $relativeImgPath = rtrim($outputDirRaw, '/') . '/' . $dateDir . '1024x1303/' . $finalFileName;
+        // Common::uploadLocalFileToOss((string)$savePath, (string)$relativeImgPath);
+        // // 构造相对路径用于数据库
+
+        // // 更新数据库记录
+        // Db::name('text_to_image')->where('id', $record['id'])->update([
+        //     'imgtoimg_url' => $relativeImgPath,
+        //     'status_name' => '图生图',
+        //     'error_msg' => '',
+        //     'update_time' => date('Y-m-d H:i:s')
+        // ]);
+
+        // // 返回成功响应
+        // return "成功";
     }
 }

+ 3 - 3
application/job/TextToTextJob.php

@@ -30,7 +30,7 @@ class TextToTextJob
             try {
                 echo " 开始处理文生文".date('Y-m-d H:i:s')."\n";
 
-                $result = $this->get_txt_to_txt($data);
+                $result = $this->get_txt_to_txt($data,'');
                 if (is_array($result) && isset($result['code']) && $result['code'] !== 0) {
                     throw new \Exception($result['msg'] ?? '文生文失败');
                 }
@@ -144,7 +144,7 @@ class TextToTextJob
                         3. 禁忌:不添加无关形容词,不修改产品核心信息;
                         需要优化的文案:";
             $prompt = $content. $data['prompt'];
-            $gptRes = $ai->buildRequestData($data['model'],$data['status_val'],$prompt);
+            $gptRes = $ai->buildRequestData($data['status_val'], $data['model'], $prompt);
             $gptText = trim($gptRes['choices'][0]['message']['content']);
             return $gptText;
         }else{
@@ -166,7 +166,7 @@ class TextToTextJob
 
             // 拼接提示词调用文生文接口
             $prompt = $template['english_content'] . $record['chinese_description'];
-            $gptRes = $ai->buildRequestData($data['model'],$data['status_val'],$prompt);
+            $gptRes = $ai->buildRequestData($data['status_val'], $data['model'], $prompt);
             $gptText = trim($gptRes['choices'][0]['message']['content'] ?? '');
 
             // 更新数据库记录

+ 395 - 82
application/service/AIGatewayService.php

@@ -5,6 +5,47 @@ use think\Log;
 use think\Queue;
 class AIGatewayService{
 
+    /** 图生图成功时记录实际使用的模型(供日志 / Redis) */
+    private $lastImg2ImgSuccessMeta = null;
+
+    /**
+     * 获取最近一次图生图成功信息(无则 null)
+     */
+    public function getLastImg2ImgSuccessMeta(): ?array
+    {
+        return $this->lastImg2ImgSuccessMeta;
+    }
+
+    /**
+     * 格式化图生图成功报告(队列日志可读)
+     */
+    public function formatImg2ImgSuccessReport(string $imagePath): string
+    {
+        $meta = $this->lastImg2ImgSuccessMeta;
+        if (empty($meta)) {
+            return "图生图成功\n生成图片:{$imagePath}\n完成时间:" . date('Y-m-d H:i:s');
+        }
+
+        $lines = [
+            '图生图成功(第 ' . $meta['attempt_no'] . ' 个模型生效,共配置 ' . $meta['total'] . ' 个模型)',
+            '',
+            '━━━━━━━━ 成功模型 ━━━━━━━━',
+            '模型:' . $meta['model_name'] . '(sort=' . $meta['sort'] . ' | id=' . $meta['id'] . ' | ' . $meta['supplier'] . ')',
+            '结果:成功',
+            '生成图片:' . $imagePath,
+            '完成时间:' . date('Y-m-d H:i:s'),
+        ];
+        if (!empty($meta['skipped'])) {
+            $lines[] = '';
+            $lines[] = '说明:前 ' . count($meta['skipped']) . ' 个模型已自动跳过(失败)';
+            foreach ($meta['skipped'] as $i => $skip) {
+                $lines[] = '  - 模型' . ($i + 1) . ' ' . $skip['model_name'] . ':' . $skip['brief'];
+            }
+        }
+
+        return implode("\n", $lines);
+    }
+
     /**
      * 根据模型与任务类型构建 API 请求体(主分发方法)
      * @param string $status_val 任务类型:图生文、文生文、文生图、图生图
@@ -34,6 +75,19 @@ class AIGatewayService{
             throw new \Exception("无效的任务类型: {$status_val}");
         }
 
+        // 图生图:按 ai_model 中 model_type=图生图 的 sort 依次尝试(同模型内仍走 callApi 多供应商切换)
+        if ($status_val === '图生图') {
+            return $this->executeImg2ImgWithFailover(
+                $model,
+                $prompt,
+                $size,
+                $product_base64Data,
+                $product_mimeType,
+                $template_base64Data,
+                $template_mimeType
+            );
+        }
+
         // 2. 按「模型+任务类型」分发到对应细分方法
         switch (true) {
             // 文生文
@@ -41,7 +95,7 @@ class AIGatewayService{
                 $data = $this->buildText2TextGeminiFlash($prompt);
                 break;
             case $status_val === '文生文' && $model === 'gpt-4':
-                $data = $this->buildText2TextGpt4($prompt);
+                $data = $this->buildText2TextOpenAi($prompt);
                 break;
 
             // 文生图
@@ -60,26 +114,316 @@ class AIGatewayService{
                 $data = $this->buildImage2TextGemini3Pro($prompt, $product_base64Data, $product_mimeType);
                 break;
 
-            // 图生图
-            case $status_val === '图生图' && $model === 'gemini-3-pro-image-preview':
-                $data = $this->buildImage2ImageGemini3ProImage($prompt, $size, $product_base64Data, $product_mimeType, $template_base64Data, $template_mimeType);
-                break;
-            case $status_val === '图生图' && $model === 'gemini-3.1-flash-image-preview':
-                $data = $this->buildImage2ImageGemini31Flash($prompt, $size, $product_base64Data, $product_mimeType, $template_base64Data, $template_mimeType);
-                break;
             // 未匹配的组合
             default:
                 throw new \Exception("未配置模型+任务类型组合: {$model}({$status_val})");
         }
 
-        // 3. 统一调用 API(图生图/文生图出图耗时较长,适当放宽超时时间)
+        // 3. 统一调用 API(文生图出图耗时较长,适当放宽超时时间)
         $timeout = 60;
-        if ($status_val === '图生图' || ($status_val === '文生图' && $model === 'gemini-3-pro-image-preview')) {
+        if ($status_val === '文生图' && $model === 'gemini-3-pro-image-preview') {
             $timeout = 180;
         }
         return $this->callApi($data, $model, $timeout);
     }
 
+    /**
+     * 图生图:按 ai_model 中「图生图」能力 + sort 依次尝试;每个 model_name 内仍按 callApi 切换供应商
+     */
+    private function executeImg2ImgWithFailover(
+        string $preferredModel,
+        string $prompt,
+        string $size,
+        string $productBase64,
+        string $productMimeType,
+        string $templateBase64,
+        string $templateMimeType
+    ): array {
+        $models = $this->resolveImg2ImgModels();
+        if (empty($models)) {
+            throw new \Exception('未配置图生图模型,请在 ai_model 表中添加 model_type 含「图生图」且 status=1 的记录');
+        }
+
+        $attempts = [];
+        $skipped = [];
+        $timeout = 180;
+
+        foreach ($models as $index => $modelInfo) {
+            $modelName = $modelInfo['model_name'];
+            try {
+                $data = $this->buildImg2ImgPayload(
+                    $modelName,
+                    $prompt,
+                    $size,
+                    $productBase64,
+                    $productMimeType,
+                    $templateBase64,
+                    $templateMimeType
+                );
+                $result = $this->callApi($data, $modelName, $timeout);
+                $this->lastImg2ImgSuccessMeta = [
+                    'model_name' => $modelName,
+                    'sort' => $modelInfo['sort'],
+                    'id' => $modelInfo['id'],
+                    'supplier' => $modelInfo['supplier'],
+                    'attempt_no' => $index + 1,
+                    'total' => count($models),
+                    'skipped' => $skipped,
+                ];
+                return $result;
+            } catch (\Exception $e) {
+                $classified = $this->classifyImg2ImgFailure($e->getMessage());
+                $skipped[] = [
+                    'model_name' => $modelName,
+                    'brief' => $classified['brief'],
+                ];
+                $attempts[] = array_merge($modelInfo, $classified);
+                Log::warning('[图生图] 尝试模型 ' . ($index + 1) . ' 失败: ' . $modelName . ' | ' . $classified['brief']);
+            }
+        }
+
+        $this->lastImg2ImgSuccessMeta = null;
+        throw new \Exception($this->formatImg2ImgFailureReport($attempts, count($models)));
+    }
+
+    /**
+     * 获取图生图模型列表(去重、严格按 sort/id;含 supplier 供日志展示)
+     */
+    private function resolveImg2ImgModels(): array
+    {
+        $rows = Db::name('ai_model')
+            ->where('status', '1')
+            ->whereRaw("FIND_IN_SET('图生图', model_type) > 0")
+            ->order('sort ASC, id ASC')
+            ->field('id,model_name,sort,supplier')
+            ->select();
+
+        $ordered = [];
+        $seen = [];
+        foreach ($rows as $row) {
+            $name = trim((string)$row['model_name']);
+            if ($name === '' || isset($seen[$name])) {
+                continue;
+            }
+            $seen[$name] = true;
+            $ordered[] = [
+                'id' => (int)$row['id'],
+                'model_name' => $name,
+                'sort' => trim((string)($row['sort'] ?? '')),
+                'supplier' => trim((string)($row['supplier'] ?? '')) ?: '未知供应商',
+            ];
+        }
+
+        return $ordered;
+    }
+
+    /**
+     * 图生图多模型失败汇总(队列日志 / Redis 可读格式)
+     */
+    private function formatImg2ImgFailureReport(array $attempts, int $total): string
+    {
+        $lines = [
+            "图生图失败(共尝试 {$total} 个模型)",
+            '',
+        ];
+
+        foreach ($attempts as $index => $attempt) {
+            $no = $index + 1;
+            $lines[] = "━━━━━━━━ 尝试模型 {$no} ━━━━━━━━";
+            $lines[] = "模型:{$attempt['model_name']}(sort={$attempt['sort']} | id={$attempt['id']} | {$attempt['supplier']})";
+            $lines[] = '结果:失败';
+            $lines[] = '失败原因:' . $attempt['brief'];
+            $lines[] = '接口问题:' . $attempt['api_issue'];
+            $lines[] = '供应商问题:' . $attempt['supplier_issue'];
+            $lines[] = '建议解决方案:' . $attempt['solution'];
+            $lines[] = '';
+        }
+
+        return rtrim(implode("\n", $lines));
+    }
+
+    /**
+     * 图生图单次失败归类:接口问题 vs 供应商问题
+     */
+    private function classifyImg2ImgFailure(string $rawMessage): array
+    {
+        $apiIssue = false;
+        $supplierIssue = false;
+        $solutions = [];
+
+        if (preg_match('/模型配置不存在|URL\/Key为空|JSON编码失败|未配置图生图|invalid_request|contents is required|请求参数不合法|请求参数错误|API端点不存在/u', $rawMessage)) {
+            $apiIssue = true;
+            $solutions[] = '检查 ai_model:model_name、api_url(需 :generateContent)、api_key 是否正确';
+        }
+
+        if (preg_match('/503|No available tokens|upstream|do_request_failed|insufficient_quota|算力|额度|model_not_found|server_error|服务暂时不可用|服务器内部错误|bad_response_body/u', $rawMessage)) {
+            $supplierIssue = true;
+            $solutions[] = '联系 API 供应商确认 Key 额度、模型通道是否正常';
+        }
+
+        if (preg_match('/CURL失败|CURLE_|Could not connect|Connection timed out|timed out after|无法连接/u', $rawMessage)) {
+            $supplierIssue = true;
+            $solutions[] = '检查服务器到 API 域名的网络(防火墙、超时、是否被墙)';
+        }
+
+        if (preg_match('/401|403|authentication|认证失败|API密钥无效/u', $rawMessage)) {
+            $apiIssue = true;
+            $supplierIssue = true;
+            $solutions[] = '核对 api_key 是否与供应商后台令牌一致';
+        }
+
+        if (preg_match('/429|rate_limit|请求过于频繁/u', $rawMessage)) {
+            $supplierIssue = true;
+            $solutions[] = '降低调用频率或联系供应商提升配额';
+        }
+
+        if (!$apiIssue && !$supplierIssue) {
+            $supplierIssue = true;
+            $solutions[] = '等待几分钟后重试,或联系 API 服务商排查';
+        }
+
+        return [
+            'brief' => $this->extractBriefImg2ImgError($rawMessage),
+            'api_issue' => $apiIssue ? '是' : '否',
+            'supplier_issue' => $supplierIssue ? '是' : '否',
+            'solution' => implode(';', array_unique($solutions)),
+        ];
+    }
+
+    /** 从 callApi 异常中提取一行简短原因 */
+    private function extractBriefImg2ImgError(string $rawMessage): string
+    {
+        $rawMessage = trim(str_replace(["\r\n", "\r"], "\n", $rawMessage));
+        if (preg_match('/模型配置不存在:\s*(.+)/u', $rawMessage, $m)) {
+            return '模型配置不存在: ' . trim($m[1]);
+        }
+        if (preg_match('/\(错误代码:\s*([^)]+)\):\s*(.+?)(?:\n|$)/u', $rawMessage, $m)) {
+            return trim($m[1]) . ': ' . trim($m[2]);
+        }
+        foreach (explode("\n", $rawMessage) as $line) {
+            $line = trim($line, "- \t");
+            if ($line === '' || strpos($line, '建议解决方案') !== false || strpos($line, '失败详情') !== false) {
+                continue;
+            }
+            if (preg_match('/^第\d+个接口/u', $line)) {
+                return $line;
+            }
+        }
+        $firstLine = strtok($rawMessage, "\n");
+        return mb_substr($firstLine ?: $rawMessage, 0, 180);
+    }
+
+    /**
+     * 按 model_name 构建图生图请求体(Gemini generateContent / 火山 Seedream 自动识别)
+     */
+    private function buildImg2ImgPayload(
+        string $model,
+        string $prompt,
+        string $size,
+        string $productBase64,
+        string $productMimeType,
+        string $templateBase64,
+        string $templateMimeType
+    ): array {
+        if ($this->isSeedreamImg2ImgModel($model)) {
+            return $this->buildImage2ImageSeedream(
+                $model,
+                $prompt,
+                $size,
+                $productBase64,
+                $productMimeType,
+                $templateBase64,
+                $templateMimeType
+            );
+        }
+
+        return $this->buildImage2ImageGemini3ProImage(
+            $prompt,
+            $size,
+            $productBase64,
+            $productMimeType,
+            $templateBase64,
+            $templateMimeType
+        );
+    }
+
+    /** 火山方舟 Seedream 图生图模型(model_name 含 seedream) */
+    private function isSeedreamImg2ImgModel(string $model): bool
+    {
+        return stripos($model, 'seedream') !== false;
+    }
+
+    /**
+     * 图生图 - 火山引擎 Seedream(OpenAI 兼容 images/generations,支持多参考图)
+     */
+    private function buildImage2ImageSeedream(
+        string $model,
+        string $prompt,
+        string $size,
+        string $productBase64,
+        string $productMimeType,
+        string $templateBase64,
+        string $templateMimeType
+    ): array {
+        $images = [];
+        if ($productBase64 !== '') {
+            $images[] = $this->toSeedreamImageRef($productBase64, $productMimeType);
+        }
+        if ($templateBase64 !== '') {
+            $ref = $this->toSeedreamImageRef($templateBase64, $templateMimeType);
+            if (!in_array($ref, $images, true)) {
+                $images[] = $ref;
+            }
+        }
+
+        if (empty($images)) {
+            throw new \Exception('图生图参考图不能为空');
+        }
+
+        return [
+            'model' => $model,
+            'prompt' => $prompt,
+            'image' => count($images) === 1 ? $images[0] : $images,
+            'size' => $this->mapSizeToSeedream($size),
+            'sequential_image_generation' => 'disabled',
+            'response_format' => 'b64_json',
+            'watermark' => false,
+        ];
+    }
+
+    private function toSeedreamImageRef(string $base64, string $mimeType): string
+    {
+        $base64 = preg_replace('/\s+/', '', $base64);
+        $mimeType = trim($mimeType) ?: 'image/jpeg';
+        return 'data:' . $mimeType . ';base64,' . $base64;
+    }
+
+    /** 将 1024x1024 / 850x1133 等映射为 Seedream 支持的 1K/2K/4K */
+    private function mapSizeToSeedream(string $size): string
+    {
+        $size = trim($size);
+        if ($size === '') {
+            return '2K';
+        }
+        if (preg_match('/^(\d)[kK]$/', $size, $m)) {
+            return $m[1] . 'K';
+        }
+        if (strpos($size, 'x') !== false) {
+            $parts = explode('x', $size, 2);
+            $w = (int)($parts[0] ?? 0);
+            $h = (int)($parts[1] ?? 0);
+            $max = max($w, $h);
+            if ($max >= 3000) {
+                return '4K';
+            }
+            if ($max >= 1400) {
+                return '2K';
+            }
+            return '1K';
+        }
+        return '2K';
+    }
+
 // -------------------------- 细分方法:按「任务类型+模型」拆分 --------------------------
 
     /**
@@ -104,15 +448,15 @@ class AIGatewayService{
     }
 
     /**
-     * 文生文 - gpt-4 模型
+     * 文生文 - OpenAI 兼容格式(gpt-4 / gpt-4o 等,model 与 ai_model.model_name 一致)
      */
-    private function buildText2TextGpt4(string $prompt): array
+    private function buildText2TextOpenAi(string $prompt): array
     {
         return [
-            'model' => 'gpt-4',
+            'model' => "gpt-4",
             'messages' => [['role' => 'user', 'content' => $prompt]],
             'temperature' => 0.7,
-            'max_tokens' => 1024
+            'max_tokens' => 1024,
         ];
     }
 
@@ -270,57 +614,11 @@ class AIGatewayService{
             ],
             'generationConfig' => [
                 'responseModalities' => ['TEXT', 'IMAGE'],
-                'imageConfig' => ['aspectRatio' => $size, 'imageSize' => '1K']
+                'imageConfig' => ['aspectRatio' => $size !== '' ? $size : '1:1', 'imageSize' => '1K']
             ]
         ];
     }
 
-    /**
-     * 图生图 - gemini-3.1-flash-image-preview 模型
-     */
-    private function buildImage2ImageGemini31Flash(
-        string $prompt,
-        string $size,
-        string $productBase64,
-        string $productMimeType,
-        string $templateBase64,
-        string $templateMimeType
-    ): array {
-        $content = [
-            ['type' => 'text', 'text' => $prompt],
-            [
-                'type' => 'image_url',
-                'image_url' => ['url' => 'data:' . $productMimeType . ';base64,' . $productBase64]
-            ],
-        ];
-        if (!empty($templateMimeType) && !empty($templateBase64)) {
-            $content[] = [
-                'type' => 'image_url',
-                'image_url' => ['url' => 'data:' . $templateMimeType . ';base64,' . $templateBase64]
-            ];
-        }
-
-        return [
-            'model' => 'gemini-3.1-flash-image-preview',
-            'messages' => [
-                [
-                    'role' => 'user',
-                    'content' => $content
-                ]
-            ],
-            'response_modalities' => ['image'],
-            'image_config' => [
-                'aspect_ratio' => $size,
-                'quality' => 'high',
-                'width' => '850',
-                'height' => '1133'
-            ],
-            'temperature' => 0.3,
-            'top_p' => 0.8,
-            'max_tokens' => 2048
-        ];
-    }
-
     /**
      * 构建视频请求体
      * $status_val == 文生视频、图生视频、首图尾图生视频
@@ -329,23 +627,23 @@ class AIGatewayService{
      * $size 尺寸大小
      * $seconds 时间
      */
-    public function Txt_to_video($status_val,$prompt, $model, $size, $seconds)
-    {
-        //判断使用哪个模型、在判断此模型使用类型
-        if ($model === 'sora-2') {
-            if ($status_val == '文生视频') {
-                $data = [
-                    'prompt' => trim($prompt),
-                    'model' => $model ?: 'sora-2',
-                    'seconds' => (string)((int)$seconds ?: 5),
-                    'size' => $size ?: '1920x1080'
-                ];
-                return self::callApi($this->config['sora-2']['api_url'], $this->config['sora-2']['api_key'], $data, 300);
-            }
-        }else{
-            throw new \Exception("未配置模型类型: {$model}");
-        }
-    }
+    // public function Txt_to_video($status_val,$prompt, $model, $size, $seconds)
+    // {
+    //     //判断使用哪个模型、在判断此模型使用类型
+    //     if ($model === 'sora-2') {
+    //         if ($status_val == '文生视频') {
+    //             $data = [
+    //                 'prompt' => trim($prompt),
+    //                 'model' => $model ?: 'sora-2',
+    //                 'seconds' => (string)((int)$seconds ?: 5),
+    //                 'size' => $size ?: '1920x1080'
+    //             ];
+    //             return self::callApi($this->config['sora-2']['api_url'], $this->config['sora-2']['api_key'], $data, 300);
+    //         }
+    //     }else{
+    //         throw new \Exception("未配置模型类型: {$model}");
+    //     }
+    // }
 
     /**
      * 计算最大公约数
@@ -360,6 +658,23 @@ class AIGatewayService{
     }
 
 
+    /**
+     * cURL SSL 选项:证书可读则验证,否则关闭(兼容 Windows 无 CA、Linux open_basedir 限制)
+     */
+    private function applyCurlSslOptions(array &$options): void
+    {
+        $caFile = trim((string)(ini_get('curl.cainfo') ?: ini_get('openssl.cafile')));
+        // 须加 @:线上 open_basedir 不允许读 /etc/pki/... 时,is_readable 会告警并中断请求
+        if ($caFile !== '' && @is_readable($caFile)) {
+            $options[CURLOPT_SSL_VERIFYPEER] = true;
+            $options[CURLOPT_SSL_VERIFYHOST] = 2;
+            $options[CURLOPT_CAINFO] = $caFile;
+            return;
+        }
+        $options[CURLOPT_SSL_VERIFYPEER] = false;
+        $options[CURLOPT_SSL_VERIFYHOST] = 0;
+    }
+
     /**
      * 通用 API 调用方法(支持多接口故障自动切换)
      *
@@ -421,13 +736,11 @@ class AIGatewayService{
                     CURLOPT_CONNECTTIMEOUT => 15,
                     CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
                     CURLOPT_FAILONERROR => false,
-                    // 生产环境建议开启SSL验证(需配置CA证书)
-                    CURLOPT_SSL_VERIFYPEER => true,
-                    CURLOPT_SSL_VERIFYHOST => 2,
                     CURLOPT_TCP_NODELAY => true,
                     CURLOPT_FORBID_REUSE => false,
                     CURLOPT_FRESH_CONNECT => false
                 ];
+                $this->applyCurlSslOptions($curlOptions);
 
                 curl_setopt_array($ch, $curlOptions);
 
@@ -908,7 +1221,7 @@ class AIGatewayService{
             }
         }
 
-        // Gemini 出图(与 TextToImageJob 线上一致)
+        // Gemini 出图
         if (!empty($res['candidates'][0]['content']['parts']) && is_array($res['candidates'][0]['content']['parts'])) {
             foreach ($res['candidates'][0]['content']['parts'] as $part) {
                 foreach (['inlineData', 'inline_data'] as $key) {

+ 37 - 18
application/service/ImageService.php

@@ -65,28 +65,47 @@ class ImageService
      */
     public function handleTextToText($status_val, string $prompt, string $model): array
     {
-        $ai = new AIGatewayService();
-        $gptRes = $ai->buildRequestData($status_val, $model, $prompt);
 
+        $gptRes = (new AIGatewayService())->buildRequestData($status_val, $model, $prompt);
 
-        $gptText = '';
-        if (isset($gptRes['candidates'][0]['content']['parts'][0]['text'])) {
-            $gptText = $gptRes['candidates'][0]['content']['parts'][0]['text'];
-        } elseif (isset($gptRes['choices'][0]['message']['content'])) {
-            $gptText = trim($gptRes['choices'][0]['message']['content']);
+        if (isset($gptRes['error'])) {
+            $err = $gptRes['error'];
+            $msg = is_array($err)
+                ? ($err['message'] ?? json_encode($err, JSON_UNESCAPED_UNICODE))
+                : (string)$err;
+            return ['success' => false, 'message' => $msg ?: '生成失败', 'data' => ''];
         }
-        if (isset($gptRes['error']) || isset($gptRes['code']) && $gptRes['code'] !== 0) {
-            return [
-                'success' => false,
-                'message' => $gptRes['msg'] ?? $gptRes['error']['message'] ?? '生成失败',
-                'data' => ''
-            ];
+
+        $gptText = $this->extractTextFromAiResponse($gptRes);
+        if ($gptText === '') {
+            return ['success' => false, 'message' => '模型未返回文本内容', 'data' => ''];
         }
-        return [
-            'success' => true,
-            'message' => '生成成功',
-            'data' => $gptText
-        ];
+
+        return ['success' => true, 'message' => '生成成功', 'data' => $gptText];
+    }
+
+    /**
+     * 从 AI 响应中提取文本(兼容 Gemini / OpenAI 格式)
+     */
+    private function extractTextFromAiResponse(array $response): string
+    {
+        if (isset($response['candidates'][0]['content']['parts'][0]['text'])) {
+            return trim((string)$response['candidates'][0]['content']['parts'][0]['text']);
+        }
+
+        $content = $response['choices'][0]['message']['content'] ?? null;
+        if (is_string($content)) {
+            return trim($content);
+        }
+        if (is_array($content)) {
+            foreach ($content as $part) {
+                if (is_array($part) && isset($part['text']) && $part['text'] !== '') {
+                    return trim((string)$part['text']);
+                }
+            }
+        }
+
+        return trim((string)($response['output_text'] ?? $response['text'] ?? ''));
     }
 
     /**