liuhairui há 1 dia atrás
pai
commit
3cd3d7747c

+ 3 - 2
application/api/controller/WorkOrder.php

@@ -398,6 +398,7 @@ class WorkOrder extends Api{
 
         // 1. 先根据任务 ID 查询即梦任务状态(GET /tasks/{id})
         $queryResult = $this->fetchVolcImgToVideoTask($taskId, $apiUrl, $apiKey);
+
         if (!$queryResult['ok']) {
             return json([
                 'code' => 0,
@@ -858,7 +859,7 @@ class WorkOrder extends Api{
             'size' => $postData['size'],
             'sys_rq' => date("Y-m-d H:i:s")
         ];
-     
+
         // 尝试插入数据
         try {
             $res = Db::name('video')->insert($videoData);
@@ -2004,4 +2005,4 @@ class WorkOrder extends Api{
         $publicPath = str_replace('\\', '/', ROOT_PATH . 'public/' . ltrim($file, '/'));
         return is_file($publicPath) ? $publicPath : '';
     }
-}
+}

+ 141 - 18
application/job/ImageToImageJob.php

@@ -187,10 +187,24 @@ class ImageToImageJob{
         $statusType = trim($data['status_type'] ?? '');
         $productImg = trim($data['product_img'] ?? '');
         $templateImg = trim($data['template_img'] ?? '');
+        $oldImg = trim($data['old_img'] ?? '');
+        $refImg = trim($data['ref_img'] ?? '');
+        $height = trim((string)($data['height'] ?? ''));
+        $width = trim((string)($data['width'] ?? ''));
         $model = trim($data['model'] ?? '');
-        $sysId = trim($data['sys_id'] ?? '');
+        $sysId = (int)($data['sys_id'] ?? 0);
         $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';
+            }
+        }
+
         // 失败统一回写任务状态,避免多处重复代码
         $fail = function ($msg) use ($data) {
             if (!empty($data['task_id'])) {
@@ -233,6 +247,8 @@ class ImageToImageJob{
         $templateMime = 'image/png';
         $productExt = 'png';
         $templateExt = 'png';
+        $sourceProductPath = '';
+        $sourceTemplatePath = '';
 
         if ($statusType === 'ProductImageGeneration') {
             // 产品图创作:前端直接传 base64,先解析,等模型成功后再入库/落盘
@@ -268,42 +284,109 @@ class ImageToImageJob{
                 // 回传清晰错误,方便定位是产品图还是模板图读取失败
                 return $fail('读取产品图/模板图失败: ' . $e->getMessage());
             }
+        } elseif ($statusType === 'texttoimage') {
+            // 前端传 old_img(原图 base64 或路径),ref_img(参考图,可选)
+            $inputOldImg = $oldImg !== '' ? $oldImg : $productImg;
+            $inputRefImg = $refImg !== '' ? $refImg : $templateImg;
+
+            if (preg_match('/data:image\/(png|jpg|jpeg|webp);base64,(.+)$/is', $inputOldImg, $pm)) {
+                $productBase64 = preg_replace('/\s+/', '', $pm[2]);
+                $ext = strtolower($pm[1]);
+                $productMime = ($ext === 'jpg' ? 'image/jpeg' : 'image/' . $ext);
+                $productExt = ($ext === 'jpeg' ? 'jpg' : $ext);
+            } elseif ($inputOldImg !== '') {
+                $sourceProductPath = ltrim(str_replace('\\', '/', $inputOldImg), '/');
+                try {
+                    $productImgSource = Common::ossFullUrl((string)$inputOldImg);
+                    $productImgRaw = AIGatewayService::file_get_contents($productImgSource);
+                    $productBase64 = $productImgRaw['base64Data'];
+                    $productMime = $productImgRaw['mimeType'];
+                } catch (\Exception $e) {
+                    return $fail('读取原图失败: ' . $e->getMessage());
+                }
+            } else {
+                return $fail('原图不能为空');
+            }
+
+            if ($inputRefImg !== '') {
+                if (preg_match('/data:image\/(png|jpg|jpeg|webp);base64,(.+)$/is', $inputRefImg, $tm)) {
+                    $templateBase64 = preg_replace('/\s+/', '', $tm[2]);
+                    $ext = strtolower($tm[1]);
+                    $templateMime = ($ext === 'jpg' ? 'image/jpeg' : 'image/' . $ext);
+                    $templateExt = ($ext === 'jpeg' ? 'jpg' : $ext);
+                } else {
+                    $sourceTemplatePath = ltrim(str_replace('\\', '/', $inputRefImg), '/');
+                    try {
+                        $templateImgSource = Common::ossFullUrl((string)$inputRefImg);
+                        $templateImgRaw = AIGatewayService::file_get_contents($templateImgSource);
+                        $templateBase64 = $templateImgRaw['base64Data'];
+                        $templateMime = $templateImgRaw['mimeType'];
+                    } catch (\Exception $e) {
+                        return $fail('读取参考图失败: ' . $e->getMessage());
+                    }
+                }
+            }
         } else {
             return $fail('当前页面未进行配置,请联系管理员开通权限');
         }
 
         // 3) 调模型生成图像
-        $defaultPrompt = '请完成产品模板替换:
+        if ($statusType === 'texttoimage') {
+            $promptContent = $prompt !== '' ? $prompt : '请根据原图生成高质量图片';
+
+            // 原图/参考图先落盘并上传 OSS(即使 AI 失败也能在 OSS 看到 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);
+            }
+            if ($sourceProductPath === '' && !empty($productBase64)) {
+                $sourceFile = 'source-' . uniqid() . '.' . $productExt;
+                $sourceImageData = base64_decode($productBase64);
+                if ($sourceImageData === false || !file_put_contents($saveDir . $sourceFile, $sourceImageData)) {
+                    return $fail('原图保存失败');
+                }
+                $sourceProductPath = 'uploads/texttoimage/' . $dateSegment . '/' . $sourceFile;
+                if (!Common::uploadLocalFileToOss((string)($saveDir . $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)) {
+                        echo 'OSS 上传参考图失败: ' . $sourceTemplatePath . "\n";
+                    }
+                }
+            }
+        } else {
+            $defaultPrompt = '请完成产品模板替换:
                             1. 从产品图提取产品主体、品牌名称、核心文案;
                             2. 从模板图继承版式布局、文字排版、色彩风格、背景元素;
                             3. 将模板图中的产品和文字替换为产品图的内容;
                             4. 最终生成的图片与模板图视觉风格统一,仅替换产品和文字。';
-        $promptContent = $prompt ? $prompt . "\n\n" . $defaultPrompt : $defaultPrompt;
+            $promptContent = $prompt ? $prompt . "\n\n" . $defaultPrompt : $defaultPrompt;
+        }
         $aiGateway = new AIGatewayService();
+        // texttoimage:原图 + 提示词走图生图(gemini-3-pro-image-preview)
         $res = $aiGateway->buildRequestData(
             $statusVal,
             $model,
             $promptContent,
             $size,
-            $productBase64,
+            (string)$productBase64,
             $productMime,
-            $templateBase64,
+            (string)$templateBase64,
             $templateMime
         );
 
-        // 兼容两种返回格式:inlineData.data 或 text 中的 data:image;base64
-        $generatedBase64 = null;
-        if (isset($res['candidates'][0]['content']['parts'][0]['inlineData']['data'])) {
-            $generatedBase64 = $res['candidates'][0]['content']['parts'][0]['inlineData']['data'];
-        } elseif (isset($res['candidates'][0]['content']['parts'][0]['text'])) {
-            $text = $res['candidates'][0]['content']['parts'][0]['text'];
-            if (preg_match('/data:image\/(png|jpg|jpeg|webp);base64,([^\)]+)/i', $text, $m)) {
-                $generatedBase64 = preg_replace('/\s+/', '', $m[2]);
-            }
-        }
+        $generatedBase64 = $aiGateway->extractImageBase64FromResponse($res);
         if (!$generatedBase64) {
-            $errMsg = isset($res['error']['message']) ? $res['error']['message'] : '未获取到图片数据';
-            return $fail($errMsg);
+            $aiGateway->logImageResponseDebug($res, $data['task_id'] ?? '');
+            return $fail($aiGateway->describeImageExtractFailure($res));
         }
         $generatedImageData = base64_decode($generatedBase64);
         if ($generatedImageData === false || strlen($generatedImageData) < 100) {
@@ -397,7 +480,47 @@ class ImageToImageJob{
 
             $complete($dbImgPath);
             return $dbImgPath;
-        }else{
+        } 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,
+            ]);
+
+            $complete($dbImgPath);
+            return $dbImgPath;
+        } else {
             return $fail('当前页面未进行配置,请联系管理员开通权限');
         }
     }

+ 343 - 15
application/service/AIGatewayService.php

@@ -1,6 +1,7 @@
 <?php
 namespace app\service;
 use think\Db;
+use think\Log;
 use think\Queue;
 class AIGatewayService{
 
@@ -50,6 +51,9 @@ class AIGatewayService{
             case $status_val === '文生图' && $model === 'gemini-3-pro-preview':
                 $data = $this->buildText2ImageGemini3Pro($prompt, $size);
                 break;
+            case $status_val === '文生图' && $model === 'gemini-3-pro-image-preview':
+                $data = $this->buildText2ImageGemini3ProImage($prompt, $size);
+                break;
 
             // 图生文
             case $status_val === '图生文' && $model === 'gemini-3-pro-preview':
@@ -68,8 +72,11 @@ class AIGatewayService{
                 throw new \Exception("未配置模型+任务类型组合: {$model}({$status_val})");
         }
 
-        // 3. 统一调用 API(图生图耗时通常更长,适当放宽超时时间)
-        $timeout = ($status_val === '图生图') ? 180 : 60;
+        // 3. 统一调用 API(图生图/文生图出图耗时较长,适当放宽超时时间)
+        $timeout = 60;
+        if ($status_val === '图生图' || ($status_val === '文生图' && $model === 'gemini-3-pro-image-preview')) {
+            $timeout = 180;
+        }
         return $this->callApi($data, $model, $timeout);
     }
 
@@ -144,6 +151,50 @@ class AIGatewayService{
         ];
     }
 
+    /**
+     * 文生图 - gemini-3-pro-image-preview 模型(纯文本出图)
+     */
+    private function buildText2ImageGemini3ProImage(string $prompt, string $size): array
+    {
+        $supportedAspectRatios = ['1:1', '4:3', '3:4', '16:9', '9:16'];
+        if ($size === '' || !in_array($size, $supportedAspectRatios, true)) {
+            if (!empty($size) && strpos($size, 'x') !== false) {
+                $parts = explode('x', trim($size), 2);
+                if (count($parts) === 2) {
+                    $w = (int)$parts[0];
+                    $h = (int)$parts[1];
+                    if ($w > 0 && $h > 0) {
+                        $ratio = $w / $h;
+                        $standard = [['1:1', 1], ['4:3', 4 / 3], ['3:4', 3 / 4], ['16:9', 16 / 9], ['9:16', 9 / 16]];
+                        $minDiff = PHP_FLOAT_MAX;
+                        foreach ($standard as $r) {
+                            $diff = abs($ratio - $r[1]);
+                            if ($diff < $minDiff) {
+                                $minDiff = $diff;
+                                $size = $r[0];
+                            }
+                        }
+                    }
+                }
+            } else {
+                $size = '1:1';
+            }
+        }
+
+        return [
+            'contents' => [
+                [
+                    'role' => 'user',
+                    'parts' => [['text' => $prompt]]
+                ]
+            ],
+            'generationConfig' => [
+                'responseModalities' => ['IMAGE'],
+                'imageConfig' => ['aspectRatio' => $size, 'imageSize' => '1K']
+            ]
+        ];
+    }
+
     /**
      * 图生文 - gemini-3-pro-preview 模型
      */
@@ -187,7 +238,7 @@ class AIGatewayService{
                 $h = (int)$parts[1];
                 if ($w > 0 && $h > 0) {
                     $ratio = $w / $h;
-                    $standard = [['1:1', 1], ['4:3', 4/3], ['3:4', 3/4], ['16:9', 16/9], ['9:16', 9/16]];
+                    $standard = [['1:1', 1], ['4:3', 4 / 3], ['3:4', 3 / 4], ['16:9', 16 / 9], ['9:16', 9 / 16]];
                     $minDiff = PHP_FLOAT_MAX;
                     foreach ($standard as $r) {
                         $diff = abs($ratio - $r[1]);
@@ -200,6 +251,12 @@ class AIGatewayService{
             }
         }
 
+        // 网关要求固定 2 张 inline_data;无参考图时用原图占位(与线上一致)
+        if ($templateBase64 === '' && $productBase64 !== '') {
+            $templateBase64 = $productBase64;
+            $templateMimeType = $productMimeType;
+        }
+
         return [
             'contents' => [
                 [
@@ -207,7 +264,7 @@ class AIGatewayService{
                     'parts' => [
                         ['text' => $prompt],
                         ['inline_data' => ['mime_type' => $productMimeType, 'data' => $productBase64]],
-                        ['inline_data' => ['mime_type' => $templateMimeType, 'data' => $templateBase64]]
+                        ['inline_data' => ['mime_type' => $templateMimeType, 'data' => $templateBase64]],
                     ]
                 ]
             ],
@@ -229,22 +286,26 @@ class AIGatewayService{
         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' => [
-                        ['type' => 'text', 'text' => $prompt],
-                        [
-                            'type' => 'image_url',
-                            'image_url' => ['url' => 'data:' . $productMimeType . ';base64,' . $productBase64]
-                        ],
-                        [
-                            'type' => 'image_url',
-                            'image_url' => ['url' => 'data:' . $templateMimeType . ';base64,' . $templateBase64]
-                        ]
-                    ]
+                    'content' => $content
                 ]
             ],
             'response_modalities' => ['image'],
@@ -800,4 +861,271 @@ class AIGatewayService{
             'mimeType' => $mimeType
         ];
     }
+
+    /**
+     * 从 AI 响应中提取图片 base64(兼容 Gemini / OpenAI 多种返回格式)
+     */
+    public function extractImageBase64FromResponse(array $res): ?string
+    {
+        // 兼容网关包装:{ "data": { "candidates": [...] } }
+        if (empty($res['candidates']) && !empty($res['data']) && is_array($res['data'])) {
+            if (!empty($res['data']['candidates'])) {
+                $res = array_merge($res, $res['data']);
+            } elseif (!empty($res['data']['choices'])) {
+                $res = array_merge($res, $res['data']);
+            }
+        }
+
+        if (!empty($res['data'][0]['b64_json'])) {
+            return preg_replace('/\s+/', '', (string)$res['data'][0]['b64_json']);
+        }
+        if (!empty($res['data'][0]['url'])) {
+            $content = @file_get_contents((string)$res['data'][0]['url']);
+            if ($content !== false && $content !== '') {
+                return base64_encode($content);
+            }
+        }
+
+        if (!empty($res['choices'][0]['message']['content'])) {
+            $content = $res['choices'][0]['message']['content'];
+            if (is_string($content)) {
+                $parsed = $this->parseBase64FromText($content);
+                if ($parsed) {
+                    return $parsed;
+                }
+            } elseif (is_array($content)) {
+                foreach ($content as $item) {
+                    if (($item['type'] ?? '') === 'image_url' && !empty($item['image_url']['url'])) {
+                        $parsed = $this->parseBase64FromText((string)$item['image_url']['url']);
+                        if ($parsed) {
+                            return $parsed;
+                        }
+                    }
+                    if (!empty($item['b64_json'])) {
+                        return preg_replace('/\s+/', '', (string)$item['b64_json']);
+                    }
+                }
+            }
+        }
+
+        // Gemini 出图(与 TextToImageJob 线上一致)
+        if (!empty($res['candidates'][0]['content']['parts']) && is_array($res['candidates'][0]['content']['parts'])) {
+            foreach ($res['candidates'][0]['content']['parts'] as $part) {
+                foreach (['inlineData', 'inline_data'] as $key) {
+                    if (!empty($part[$key]['data'])) {
+                        $raw = preg_replace('/\s+/', '', (string)$part[$key]['data']);
+                        if (preg_match('/^data:image\//i', $raw)) {
+                            $parsed = $this->parseBase64FromText($raw);
+                            if ($parsed) {
+                                return $parsed;
+                            }
+                        } elseif (strlen($raw) > 100 && base64_decode($raw, true) !== false) {
+                            return $raw;
+                        }
+                    }
+                }
+                if (!empty($part['text']) && preg_match('/data:image\/(png|jpg|jpeg|webp);base64,(.+)$/is', (string)$part['text'], $m)) {
+                    return preg_replace('/\s+/', '', $m[2]);
+                }
+            }
+        }
+
+        if (!empty($res['candidates']) && is_array($res['candidates'])) {
+            foreach ($res['candidates'] as $candidate) {
+                $parts = $candidate['content']['parts'] ?? [];
+                foreach ($parts as $part) {
+                    foreach (['inlineData', 'inline_data'] as $key) {
+                        if (!empty($part[$key]['data'])) {
+                            $parsed = $this->parseBase64FromText((string)$part[$key]['data']);
+                            if ($parsed) {
+                                return $parsed;
+                            }
+                        }
+                    }
+                    foreach (['fileData', 'file_data'] as $key) {
+                        $uri = $part[$key]['fileUri'] ?? ($part[$key]['file_uri'] ?? '');
+                        if ($uri !== '') {
+                            $parsed = $this->fetchImageBase64FromUri((string)$uri);
+                            if ($parsed) {
+                                return $parsed;
+                            }
+                        }
+                    }
+                    if (!empty($part['text'])) {
+                        $parsed = $this->parseBase64FromText((string)$part['text']);
+                        if ($parsed) {
+                            return $parsed;
+                        }
+                    }
+                }
+            }
+        }
+
+        return $this->findImageBase64Deep($res);
+    }
+
+    /**
+     * 深度递归扫描响应中的图片数据
+     */
+    private function findImageBase64Deep($node, int $depth = 0): ?string
+    {
+        if ($depth > 15) {
+            return null;
+        }
+        if (is_string($node)) {
+            return $this->parseBase64FromText($node);
+        }
+        if (!is_array($node)) {
+            return null;
+        }
+
+        foreach (['inlineData', 'inline_data'] as $key) {
+            if (!empty($node[$key]['data'])) {
+                $parsed = $this->parseBase64FromText((string)$node[$key]['data']);
+                if ($parsed) {
+                    return $parsed;
+                }
+            }
+        }
+        if (!empty($node['b64_json'])) {
+            return preg_replace('/\s+/', '', (string)$node['b64_json']);
+        }
+        if (!empty($node['url']) && is_string($node['url'])) {
+            $parsed = $this->fetchImageBase64FromUri($node['url']);
+            if ($parsed) {
+                return $parsed;
+            }
+        }
+
+        foreach ($node as $value) {
+            if (is_array($value) || is_string($value)) {
+                $found = $this->findImageBase64Deep($value, $depth + 1);
+                if ($found) {
+                    return $found;
+                }
+            }
+        }
+
+        return null;
+    }
+
+    /**
+     * 从 URL / data URI 拉取图片并转 base64
+     */
+    private function fetchImageBase64FromUri(string $uri): ?string
+    {
+        $uri = trim($uri);
+        if ($uri === '') {
+            return null;
+        }
+        if (strpos($uri, 'data:') === 0) {
+            return $this->parseBase64FromText($uri);
+        }
+        if (!preg_match('/^https?:\/\//i', $uri)) {
+            return null;
+        }
+        $content = @file_get_contents($uri);
+        if ($content === false || $content === '') {
+            return null;
+        }
+        return base64_encode($content);
+    }
+
+    /**
+     * 图片提取失败时的可读错误信息
+     */
+    public function describeImageExtractFailure(array $res): string
+    {
+        if (!empty($res['error']['message'])) {
+            return (string)$res['error']['message'];
+        }
+
+        $text = '';
+        if (!empty($res['candidates'][0]['content']['parts'])) {
+            foreach ($res['candidates'][0]['content']['parts'] as $part) {
+                if (!empty($part['text']) && $text === '') {
+                    $text = (string)$part['text'];
+                }
+            }
+        }
+        if ($text === '' && !empty($res['choices'][0]['message']['content'])) {
+            $msgContent = $res['choices'][0]['message']['content'];
+            if (is_string($msgContent)) {
+                $text = $msgContent;
+            }
+        }
+
+        $reason = $res['candidates'][0]['finishReason']
+            ?? ($res['choices'][0]['finish_reason'] ?? '');
+        if ($text !== '') {
+            $prefix = $reason !== '' ? "模型未返回图片({$reason})" : '未获取到图片数据';
+            return $prefix . ': ' . mb_substr($text, 0, 150);
+        }
+        if ($reason !== '' && strtoupper((string)$reason) !== 'STOP') {
+            return '模型未返回图片: ' . $reason;
+        }
+
+        return '未获取到图片数据,请检查模型是否支持出图';
+    }
+
+    /**
+     * 图片提取失败时写入调试日志(截断 base64,避免日志过大)
+     */
+    public function logImageResponseDebug(array $res, string $taskId = ''): void
+    {
+        try {
+            $sanitized = $this->sanitizeResponseForLog($res);
+            Log::write(
+                '[AI image extract failed] task=' . $taskId . ' response=' . mb_substr(json_encode($sanitized, JSON_UNESCAPED_UNICODE), 0, 4000),
+                'error'
+            );
+        } catch (\Throwable $e) {
+            // 日志失败不阻断
+        }
+    }
+
+    /**
+     * @param mixed $node
+     * @return mixed
+     */
+    private function sanitizeResponseForLog($node, int $depth = 0)
+    {
+        if ($depth > 10) {
+            return '...';
+        }
+        if (is_string($node)) {
+            return strlen($node) > 100 ? (substr($node, 0, 50) . '...(len=' . strlen($node) . ')') : $node;
+        }
+        if (!is_array($node)) {
+            return $node;
+        }
+        $out = [];
+        foreach ($node as $key => $value) {
+            if (in_array($key, ['data', 'b64_json'], true) && is_string($value) && strlen($value) > 100) {
+                $out[$key] = '...(len=' . strlen($value) . ')';
+                continue;
+            }
+            $out[$key] = $this->sanitizeResponseForLog($value, $depth + 1);
+        }
+        return $out;
+    }
+
+    /**
+     * 解析 data:image/...;base64 或裸 base64 字符串
+     */
+    private function parseBase64FromText(string $content): ?string
+    {
+        $content = trim($content);
+        if ($content === '') {
+            return null;
+        }
+        if (preg_match('/data:image\/(?:png|jpg|jpeg|webp);base64,(.+)$/is', $content, $m)) {
+            return preg_replace('/\s+/', '', $m[1]);
+        }
+        $clean = preg_replace('/\s+/', '', $content);
+        if (strlen($clean) > 100 && base64_decode($clean, true) !== false) {
+            return $clean;
+        }
+        return null;
+    }
 }

+ 1 - 0
vendor/mpdf/mpdf

@@ -0,0 +1 @@
+Subproject commit e175b05e3e00977b85feb96a8cccb174ac63621f