liuhairui 5 napja
szülő
commit
634806bc8c

+ 161 - 0
application/api/controller/Common.php

@@ -8,9 +8,11 @@ use app\common\library\Upload;
 use app\common\model\Area;
 use app\common\model\Version;
 use fast\Random;
+use OSS\OssClient;
 use think\captcha\Captcha;
 use think\Config;
 use think\Hook;
+use think\Log;
 
 /**
  * 公共接口
@@ -20,6 +22,165 @@ class Common extends Api
     protected $noNeedLogin = ['init', 'captcha'];
     protected $noNeedRight = '*';
 
+    /**
+     * 获取 OSS 配置
+     */
+    public static function getOssConfig(): array
+    {
+        $config = Config::get('oss');
+        return is_array($config) ? $config : [];
+    }
+
+    /**
+     * OSS 配置是否可用
+     */
+    public static function isOssEnabled(): bool
+    {
+        $config = self::getOssConfig();
+        return !empty($config['accessKeyId'])
+            && !empty($config['accessKeySecret'])
+            && !empty($config['endpoint'])
+            && !empty($config['bucket']);
+    }
+
+    /**
+     * 归一化 OSS 对象键
+     */
+    public static function normalizeOssObjectKey(string $objectKey): string
+    {
+        return ltrim(str_replace('\\', '/', trim($objectKey)), '/');
+    }
+
+    /**
+     * 上传本地文件到 OSS
+     *
+     * @param string $localFullPath 本地文件完整路径
+     *   示例:D:/phpstudy_pro/WWW/mes-ai-server-api/public/uploads/material/2026-03-25/a.png
+     * @param string $objectKey OSS 对象键(Bucket 内的相对路径,不要带域名)
+     *   示例:uploads/material/2026-03-25/a.png
+     * @return bool true=上传成功;false=未配置/本地文件不存在/上传失败
+     */
+    public static function uploadLocalFileToOss(string $localFullPath, string $objectKey): bool
+    {
+        if (!self::isOssEnabled() || !is_file($localFullPath)) {
+            return false;
+        }
+        $config = self::getOssConfig();
+        $objectKey = self::normalizeOssObjectKey($objectKey);
+        if ($objectKey === '') {
+            return false;
+        }
+        try {
+            $ossClient = new OssClient(
+                $config['accessKeyId'],
+                $config['accessKeySecret'],
+                $config['endpoint']
+            );
+            $ossClient->uploadFile($config['bucket'], $objectKey, $localFullPath);
+            return true;
+        } catch (\Throwable $e) {
+            Log::write('[OSS uploadLocalFileToOss] ' . $e->getMessage() . ' | objectKey=' . $objectKey . ' | local=' . $localFullPath, 'error');
+            return false;
+        }
+    }
+
+    /**
+     * 删除 OSS 对象
+     */
+    public static function deleteOssObject(string $objectKeyOrUrl): bool
+    {
+        if (!self::isOssEnabled()) {
+            return false;
+        }
+        $config = self::getOssConfig();
+        $objectKey = self::normalizeOssObjectKey($objectKeyOrUrl);
+        if ($objectKey === '') {
+            return false;
+        }
+        try {
+            $ossClient = new OssClient(
+                $config['accessKeyId'],
+                $config['accessKeySecret'],
+                $config['endpoint']
+            );
+            $ossClient->deleteObject($config['bucket'], $objectKey);
+            return true;
+        } catch (\Throwable $e) {
+            return false;
+        }
+    }
+
+    /**
+     * 把相对路径拼接为完整 OSS URL;已是 http(s) 则原样返回
+     * 路径$taskInfo['image_url'] = '/uploads/Product/img2img-20260317152818-69b902924548d.png'
+     * Common::ossFullUrl((string)$taskInfo['image_url']);
+     */
+    public static function ossFullUrl(string $path): string
+    {
+        $path = trim($path);
+        if ($path === '' || stripos($path, 'http://') === 0 || stripos($path, 'https://') === 0) {
+            return $path;
+        }
+        $config = self::getOssConfig();
+        $host = trim((string)($config['host'] ?? ''));
+        if ($host === '') {
+            return $path;
+        }
+        if (stripos($host, 'http://') !== 0 && stripos($host, 'https://') !== 0) {
+            $host = 'https://' . $host;
+        }
+        return rtrim($host, '/') . '/' . ltrim($path, '/');
+    }
+
+    /**
+     * product_template.chinese_description 入库:多页为 JSON 数组字符串,避免数组被写成 "Array"。
+     *
+     * @param mixed $raw 前端数组、合法 JSON 数组字符串、或历史单段纯文本
+     */
+    public static function encodeChineseDescriptionForDb($raw): string
+    {
+        if (is_array($raw)) {
+            return json_encode($raw, JSON_UNESCAPED_UNICODE) ?: '[]';
+        }
+        if ($raw === null || $raw === '') {
+            return '';
+        }
+        if (!is_string($raw)) {
+            return '';
+        }
+        $trimmed = trim($raw);
+        if ($trimmed === '') {
+            return '';
+        }
+        $decoded = json_decode($trimmed, true);
+        if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
+            return json_encode($decoded, JSON_UNESCAPED_UNICODE) ?: $trimmed;
+        }
+
+        return $raw;
+    }
+
+    /**
+     * 读出给前端:合法 JSON 数组则转 array,否则保持原字符串。
+     *
+     * @return array|string
+     */
+    public static function decodeChineseDescriptionForApi($stored)
+    {
+        if ($stored === null || $stored === '') {
+            return [];
+        }
+        if (!is_string($stored)) {
+            return $stored;
+        }
+        $decoded = json_decode($stored, true);
+        if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
+            return $decoded;
+        }
+
+        return $stored;
+    }
+
     public function _initialize()
     {
 

+ 382 - 28
application/api/controller/Material.php

@@ -43,6 +43,192 @@ class Material extends Api
         return 'jpg';
     }
 
+    /**
+     * 根据 material_url 删除 public 下对应文件(如 uploads/material/2026-03-25/xxx.png)
+     */
+    protected function unlinkMaterialFileByUrl($materialUrl)
+    {
+        if ($materialUrl === null || $materialUrl === '') {
+            return;
+        }
+        $rel = str_replace('\\', '/', trim((string) $materialUrl));
+        $rel = ltrim($rel, '/');
+        if ($rel === '') {
+            return;
+        }
+        $fullPath = rtrim(str_replace('\\', '/', ROOT_PATH), '/') . '/public/' . $rel;
+        if (is_file($fullPath)) {
+            @unlink($fullPath);
+        }
+    }
+
+    /**
+     * 图层所属页码(多页模版):0 起,缺省或非数字按 0
+     */
+    protected function layerPageIndex(array $layer): int
+    {
+        if (!array_key_exists('page_index', $layer)) {
+            return 0;
+        }
+        $v = $layer['page_index'];
+        if ($v === '' || $v === null) {
+            return 0;
+        }
+        if (is_numeric($v)) {
+            return (int) $v;
+        }
+
+        return 0;
+    }
+
+    /**
+     * 解析 product_template.page_image_urls(JSON 数组)
+     */
+    protected function decodePageImageUrlsField($stored): array
+    {
+        if ($stored === null || $stored === '') {
+            return [];
+        }
+        if (is_array($stored)) {
+            return $stored;
+        }
+        if (!is_string($stored)) {
+            return [];
+        }
+        $decoded = json_decode($stored, true);
+        return is_array($decoded) ? $decoded : [];
+    }
+
+    /**
+     * 删除多页预览图(本地 + OSS),用于更新前清理或删模版
+     */
+    protected function deleteStoredPageGalleryImages($pageImageUrlsJsonOrArray): void
+    {
+        $list = is_array($pageImageUrlsJsonOrArray)
+            ? $pageImageUrlsJsonOrArray
+            : $this->decodePageImageUrlsField($pageImageUrlsJsonOrArray);
+        foreach ($list as $p) {
+            if ($p === null || $p === '') {
+                continue;
+            }
+            $this->unlinkMaterialFileByUrl((string) $p);
+            Common::deleteOssObject((string) $p);
+        }
+    }
+
+    /**
+     * 将 preview_images[] 中每页 dataURL 落盘并同步 OSS,返回与下标对齐的路径数组(失败页为空串占位)
+     *
+     * @param array  $previewImages 下标 0=第 1 页…
+     * @param string $saveDir       public/uploads/template/YYYY-mm-dd/
+     * @param string $dateYmd       YYYY-mm-dd
+     * @return array{paths: string[], oss: bool[]}
+     */
+    protected function savePreviewGalleryBase64List(array $previewImages, string $saveDir, string $dateYmd): array
+    {
+        if ($previewImages === []) {
+            return ['paths' => [], 'oss' => []];
+        }
+        $keys = array_keys($previewImages);
+        $maxIdx = $keys === [] ? -1 : max(array_map('intval', $keys));
+        $n = $maxIdx >= 0 ? ($maxIdx + 1) : 0;
+        $paths = array_fill(0, $n, '');
+        $oss = array_fill(0, $n, false);
+
+        foreach ($previewImages as $pageIndex => $base64Data) {
+            $i = (int) $pageIndex;
+            if ($i < 0 || $i >= $n) {
+                continue;
+            }
+            if (!is_string($base64Data) || trim($base64Data) === '') {
+                continue;
+            }
+            if (!preg_match('/data:image\/(png|jpg|jpeg|webp);base64,(.+)/is', $base64Data, $m)) {
+                continue;
+            }
+            $imageType = strtolower($m[1]);
+            if ($imageType === 'jpeg') {
+                $imageType = 'jpg';
+            }
+            $b64 = preg_replace('/\s+/', '', $m[2]);
+            $imageData = base64_decode($b64, true);
+            if ($imageData === false || strlen($imageData) < 100) {
+                continue;
+            }
+            $fn = 'page_' . $i . '_' . uniqid() . '_' . date('YmdHis') . '.' . $imageType;
+            $full = $saveDir . $fn;
+            if (!file_put_contents($full, $imageData)) {
+                continue;
+            }
+            $dbPath = '/uploads/template/' . $dateYmd . '/' . $fn;
+            $paths[$i] = $dbPath;
+            $oss[$i] = Common::uploadLocalFileToOss((string) $full, (string) $dbPath);
+        }
+
+        return ['paths' => $paths, 'oss' => $oss];
+    }
+
+    /**
+     * 从 layers 解析最终 material_id 列表(与写入 relation 时逻辑一致,同一 id 可出现多次)
+     */
+    protected function collectMaterialIdsFromLayers($layers, $layerIdToMaterial)
+    {
+        $ids = [];
+        if (empty($layers) || !is_array($layers)) {
+            return $ids;
+        }
+        foreach ($layers as $layer) {
+            $materialId = $layer['material_id'] ?? null;
+            if (isset($layer['id']) && isset($layerIdToMaterial[$layer['id']])) {
+                $materialId = $layerIdToMaterial[$layer['id']]['id'];
+            }
+            if ($materialId !== null && $materialId !== '') {
+                $ids[] = (int) $materialId;
+            }
+        }
+        return $ids;
+    }
+
+    /**
+     * 按图层引用次数增加 template_material.count
+     */
+    protected function incrementMaterialUseCountsFromIds(array $materialIds)
+    {
+        if (empty($materialIds)) {
+            return;
+        }
+        $counts = array_count_values($materialIds);
+        foreach ($counts as $mid => $cnt) {
+            if ($mid > 0 && $cnt > 0) {
+                Db::name('template_material')->where('id', $mid)->setInc('count', $cnt);
+            }
+        }
+    }
+
+    /**
+     * 某模版旧关联中各 material_id 出现几次,count 减几次(删除关联前调用)
+     */
+    protected function decrementMaterialUseCountsByTemplateId($templateId)
+    {
+        $rows = Db::name('template_material_relation')->where('template_id', $templateId)->column('material_id');
+        if (empty($rows)) {
+            return;
+        }
+        $ids = [];
+        foreach ($rows as $mid) {
+            if ($mid !== null && $mid !== '' && (int) $mid > 0) {
+                $ids[] = (int) $mid;
+            }
+        }
+        if (empty($ids)) {
+            return;
+        }
+        $counts = array_count_values($ids);
+        foreach ($counts as $mid => $cnt) {
+            Db::name('template_material')->where('id', $mid)->setDec('count', $cnt);
+        }
+    }
+
     /**
      * 新增素材图片上传
      *
@@ -69,6 +255,7 @@ class Material extends Api
 
         $uploaded = [];
 
+        // 兼容单图(img)与多图(img[]),ThinkPHP 会返回对象或数组
         $files = $this->request->file('img');
         if (!empty($files)) {
             $fileList = is_array($files) ? $files : [$files];
@@ -80,13 +267,18 @@ class Material extends Api
                 if (!$file || !$file->isValid()) {
                     continue;
                 }
+                // 先落本地(后续可用于备份/排障),再尝试同步 OSS
                 $saveFileName = uniqid() . '_' . date('YmdHis') . '.' . $this->resolveUploadedImageExt($file);
                 $info = $file->move($materialSavePath, $saveFileName);
                 if (!$info) {
                     continue;
                 }
                 $savedName = $info->getFilename();
-                $materialUrl = '/uploads/material/' . $dateDir . '/' . $savedName;
+                $fullLocalPath = $materialSavePath . $savedName;
+                $materialUrl = 'uploads/material/' . $dateDir . '/' . $savedName;
+                // OSS 失败不阻断主流程(本地已保存)
+                Common::uploadLocalFileToOss((string)$fullLocalPath, (string)$materialUrl);
+
                 $materialRecord = [
                     'sys_id'        => $sysId,
                     'Category_id'   => $categoryId,
@@ -95,10 +287,12 @@ class Material extends Api
                     'create_time'   => date('Y-m-d H:i:s'),
                     'count'         => 1
                 ];
+
                 $materialId = Db::name('template_material')->insertGetId($materialRecord);
                 $uploaded[] = ['id' => $materialId, 'material_url' => $materialUrl];
             }
         } else {
+            // 兼容旧版 base64 传参:uploaded_materials=[{data, material_name}, ...]
             $materials = $params['uploaded_materials'] ?? [];
             if (empty($materials) || !is_array($materials)) {
                 return json(['code' => 1, 'msg' => '请上传至少一张素材图片(img/img[] 或 uploaded_materials)']);
@@ -120,7 +314,10 @@ class Material extends Api
                 if (!file_put_contents($fullPath, $imageData)) {
                     continue;
                 }
-                $materialUrl = '/uploads/material/' . $dateDir . '/' . $fileName;
+                $materialUrl = 'uploads/material/' . $dateDir . '/' . $fileName;
+                // base64 分支同样尝试同步 OSS
+                Common::uploadLocalFileToOss((string)$fullPath, (string)$materialUrl);
+
                 $materialRecord = [
                     'sys_id'        => $sysId,
                     'Category_id'   => $categoryId,
@@ -142,24 +339,35 @@ class Material extends Api
     }
 
     /**
-     * 素材图片删除(软删除)
+     * 素材图片删除:物理删库 + 删除 public 下对应图片文件
      */
     public function materialDelete()
     {
         $params = $this->request->param();
-        $record['mod_rq'] = date('Y-m-d H:i:s');
-        $res = Db::name('template_material')->where('id', $params['id'])->update($record);
-        if (!$res) {
+        if (empty($params['id'])) {
+            return json(['code' => 1, 'msg' => 'id 不能为空', 'data' => '']);
+        }
+        $id = intval($params['id']);
+        $row = Db::name('template_material')->where('id', $id)->find();
+        if (!$row) {
+            return json(['code' => 1, 'msg' => '记录不存在', 'data' => '']);
+        }
+        $list = Db::name('template_material_relation')->where('material_id', $id)->select();
+        if ($list) {
             return json([
                 'code' => 1,
-                'msg'  => '删除失败',
+                'msg'  => '当前素材已被模版使用,不可删除',
                 'data' => ''
             ]);
         }
-        return json([
-            'code' => 0,
-            'msg'  => '删除成功'
-        ]);
+        $this->unlinkMaterialFileByUrl($row['material_url'] ?? '');
+        // 同步删除 OSS 对象(如未配置 OSS 或删除失败,不阻断数据库删除)
+        Common::deleteOssObject((string)($row['material_url'] ?? ''));
+        $res = Db::name('template_material')->where('id', $id)->delete();
+        if (!$res) {
+            return json(['code' => 1, 'msg' => '删除失败', 'data' => '']);
+        }
+        return json(['code' => 0, 'msg' => '删除成功']);
     }
 
     /**
@@ -173,9 +381,9 @@ class Material extends Api
             return json(['code' => 1, 'msg' => 'id 不能为空']);
         }
         $id = intval($params['id']);
-        $row = Db::name('template_material')->where('id', $id)->whereNull('mod_rq')->find();
+        $row = Db::name('template_material')->where('id', $id)->find();
         if (!$row) {
-            return json(['code' => 1, 'msg' => '记录不存在或已删除']);
+            return json(['code' => 1, 'msg' => '记录不存在']);
         }
 
         $update = ['update_time' => date('Y-m-d H:i:s')];
@@ -205,27 +413,32 @@ class Material extends Api
     public function Material_List(){
         $params = $this->request->param();
         $page = max(1, intval($params['page'] ?? 1));
-        $pageSize = min(500, max(1, intval($params['pageSize'] ?? 100)));
+        $pageSize = min(500, max(1, intval($params['pageSize'] ?? 30)));
 
         $where = [];
         if (!empty($params['search'])) {
             // 使用更安全的查询方式,material_name 与 category_name 任一匹配即可
             $search = trim($params['search']);
-            $where['a.material_name|b.category_name'] = ['like', '%' . $search . '%'];
+            $where['a.material_name|b.category_name|a.material_url'] = ['like', '%' . $search . '%'];
         }
         if (!empty($params['Category_id'])) {
             $where['a.Category_id'] = ['like', '%' . $params['Category_id'] . '%'];
         }
 
         $query = Db::name('template_material')->alias('a')
-            ->field('a.id,a.sys_id,a.Category_id,b.category_name,a.material_name,a.material_url')
+            ->field('a.id,a.sys_id,a.Category_id,b.category_name,a.material_name,a.material_url,a.count')
             ->join('template_material_category b', 'a.Category_id = b.id AND b.mod_rq IS NULL', 'LEFT')
             ->where($where)
             ->whereNull('a.mod_rq')
             ->order('a.id desc');
-
         $total = (clone $query)->count();
         $data = $query->page($page, $pageSize)->select();
+        foreach ($data as &$item) {
+            if (!empty($item['material_url'])) {
+                $item['material_url'] = Common::ossFullUrl((string)$item['material_url']);
+            }
+        }
+        unset($item);
 
         return json([
             'code'  => 0,
@@ -242,11 +455,31 @@ class Material extends Api
     public function Template_Material_Relation(){
         $params = $this->request->param();
         $res = Db::name('template_material_relation')->alias('a')
-            ->field('a.*, b.material_url,c.canvasWidth,c.canvasHeight,c.size')
+            ->field('a.*,c.chinese_description,c.page_image_urls, b.material_url,c.canvasWidth,c.canvasHeight,c.size')
             ->join('template_material b', 'a.material_id = b.id', 'left')
             ->join('product_template c', 'a.template_id = c.id', 'left')
             ->where('a.template_id',$params['id'])->select();
-
+        foreach ($res as &$item) {
+            if (!empty($item['material_url'])) {
+                $item['material_url'] = Common::ossFullUrl((string)$item['material_url']);
+            }
+            if (array_key_exists('chinese_description', $item)) {
+                $item['chinese_description'] = Common::decodeChineseDescriptionForApi($item['chinese_description']);
+            }
+            if (array_key_exists('page_image_urls', $item) && $item['page_image_urls'] !== null && $item['page_image_urls'] !== '') {
+                $arr = json_decode((string) $item['page_image_urls'], true);
+                if (is_array($arr)) {
+                    foreach ($arr as &$pu) {
+                        if ($pu !== null && $pu !== '') {
+                            $pu = Common::ossFullUrl((string) $pu);
+                        }
+                    }
+                    unset($pu);
+                    $item['page_image_urls'] = $arr;
+                }
+            }
+        }
+        unset($item);
         // 处理null值,转换为空字符串
         if($res){
             foreach($res as &$item){
@@ -282,6 +515,7 @@ class Material extends Api
 //        echo "<pre>";die;
         // 处理 uploaded_materials:保存素材图片到 uploads/material/ 并写入 template_material 表
         $layerIdToMaterial = []; // layer_id => ['id'=>material_id, 'url'=>material_url]
+        $ossSync = ['configured' => Common::isOssEnabled(), 'materials' => [], 'template_main' => null, 'template_thumb' => null];
         if (!empty($params['uploaded_materials'])) {
             $materialSavePath = str_replace('\\', '/', ROOT_PATH . 'public/uploads/material/' . date('Y-m-d') . '/');
             if (!is_dir($materialSavePath)) {
@@ -304,20 +538,28 @@ class Material extends Api
                     continue;
                 }
                 $materialUrl = 'uploads/material/' . date('Y-m-d') . '/' . $fileName;
+                // uploaded_materials 分支:素材图先落本地,再尝试同步到 OSS(失败不影响新增模版)
+                $ossSync['materials'][] = [
+                    'objectKey' => $materialUrl,
+                    'ok'        => Common::uploadLocalFileToOss((string)$fullPath, (string)$materialUrl),
+                ];
                 $materialRecord = [
                     'sys_id' => $params['sys_id'] ?? '',
                     'material_url' => $materialUrl,
                     'type' => $item['type'] ?? '',
+                    'Category_id' => $item['Category_id'] ?? '',
+                    'chinese_description' => $item['chinese_description'] ?? '',
+                    'material_name' => $item['material_name'] ?? '',
                     'create_time' => date('Y-m-d H:i:s'),
                     'count' => 1
                 ];
+
                 $materialId = Db::name('template_material')->insertGetId($materialRecord);
                 if ($materialId && isset($item['layer_id'])) {
                     $layerIdToMaterial[$item['layer_id']] = ['id' => $materialId, 'url' => $materialUrl];
                 }
             }
         }
-
         $save_path = ROOT_PATH . 'public' . '/' . 'uploads' . '/' . 'template' .'/'. date('Y-m-d')  . '/';
         // 移除ROOT_PATH中可能存在的反斜杠,确保统一使用正斜杠
         $save_path = str_replace('\\', '/', $save_path);
@@ -350,20 +592,44 @@ class Material extends Api
         }
         // 生成数据库存储路径(使用正斜杠格式)
         $db_img_path = '/uploads/template/'. date('Y-m-d')  .'/' . $file_name;
+        // 预览图(模板原图)同步 OSS,保持本地/云端路径一致(失败见返回 oss_sync 与 runtime/log)
+        $ossSync['template_main'] = Common::uploadLocalFileToOss((string)$full_file_path, (string)$db_img_path);
 
         // 生成缩略图
         $thumbnail_path = $this->generateThumbnail($full_file_path, $save_path, $file_name);
         $db_thumbnail_path = '/uploads/template/'.date('Y-m-d')  .'/' . $thumbnail_path;
+        $fullThumbnailPath = $save_path . $thumbnail_path;
+        // 缩略图文件存在时再同步 OSS,避免空文件名导致无效上传
+        if (!empty($thumbnail_path) && is_file($fullThumbnailPath)) {
+            $ossSync['template_thumb'] = Common::uploadLocalFileToOss((string)$fullThumbnailPath, (string)$db_thumbnail_path);
+        } else {
+            $ossSync['template_thumb'] = false;
+        }
+
+        $dateYmd = date('Y-m-d');
+        $newGalleryPathsForRollback = []; // 多页预览路径;插入失败时用于回滚删除
+        // 多页预览图:preview_images 与页下标一致,落盘 + OSS,JSON 存入 page_image_urls(TEXT,需建表字段)
+        if (!empty($params['preview_images']) && is_array($params['preview_images'])) {
+            $gal = $this->savePreviewGalleryBase64List($params['preview_images'], $save_path, $dateYmd);
+            $newGalleryPathsForRollback = $gal['paths'];
+            $ossSync['page_images'] = $gal['oss'];
+        }
 
         //新增到模版表(product_template)
         $record['toexamine'] = '审核通过';
 
         $record['sys_id'] = $params['sys_id'];
+        // 多页提示词:chinese_description 为数组时下标 0=第 1 页…,入库 JSON;缺省存 []
+        $record['chinese_description'] = Common::encodeChineseDescriptionForDb($params['chinese_description'] ?? []);
+        $record['template_name'] = isset($params['template_name']) ? (string) $params['template_name'] : '';
         $record['canvasWidth'] = $params['canvasWidth'];
         $record['canvasHeight'] = $params['canvasHeight'];
         $record['size'] = $params['canvasRatio'];
         $record['template_image_url'] = $db_img_path;//原图
         $record['thumbnail_image'] = $db_thumbnail_path;//缩略图
+        if (!empty($params['preview_images']) && is_array($params['preview_images'])) {
+            $record['page_image_urls'] = json_encode($newGalleryPathsForRollback, JSON_UNESCAPED_UNICODE);
+        }
 
         $record['sys_rq'] = date('Y-m-d');
         $record['create_time'] = date('Y-m-d H:i:s');
@@ -376,6 +642,7 @@ class Material extends Api
             if (file_exists($full_file_path)) {
                 unlink($full_file_path);
             }
+            $this->deleteStoredPageGalleryImages($newGalleryPathsForRollback);
             return '数据库插入失败';
         }
 
@@ -423,7 +690,7 @@ class Material extends Api
                     'fill_mode' => $layer['fill_mode'] ?? $layer['fillMode'] ?? '',//填充模式 solid/none
                     'fill_color' => $layer['fill_color'] ?? $layer['fillColor'] ?? '',//填充色
                     'stroke_color' => $layer['stroke_color'] ?? $layer['strokeColor'] ?? '',//描边色
-                    'page_index' => $layer['page_index'] ?? $layer['page_index'] ?? '',//画布分页排序
+                    'page_index' => $this->layerPageIndex($layer),// 多页:0=第 1 页画布
                     'stroke_width' => isset($layer['stroke_width']) ? floatval($layer['stroke_width']) : (isset($layer['strokeWidth']) ? floatval($layer['strokeWidth']) : 0),//描边宽度
                     'create_time' => date('Y-m-d H:i:s')
 
@@ -432,6 +699,11 @@ class Material extends Api
                 // 插入关联记录
                 Db::name('template_material_relation')->insert($relationData);
             }
+            $this->incrementMaterialUseCountsFromIds($this->collectMaterialIdsFromLayers($layers, $layerIdToMaterial));
+        }
+        $pageUrlsOut = [];
+        foreach ($newGalleryPathsForRollback as $pu) {
+            $pageUrlsOut[] = ($pu === '' || $pu === null) ? '' : Common::ossFullUrl((string) $pu);
         }
         return json([
             'code' => 0,
@@ -439,7 +711,10 @@ class Material extends Api
             'data' => '',
             'template_id' => $templateId,
             'template_image_url' => $db_img_path,
-            'template_image' => $db_thumbnail_path
+            'template_image' => $db_thumbnail_path,
+            'page_image_urls' => $pageUrlsOut,
+            // OSS 是否开启、各文件是否上传成功(任一为 false 时查 runtime/log 中 [OSS uploadLocalFileToOss])
+            'oss_sync' => $ossSync,
         ]);
     }
 
@@ -472,6 +747,9 @@ class Material extends Api
             ]);
         }
 
+        $oldGalleryPathsToDeleteAfterDbOk = [];
+        $newGalleryPathsRollbackOnFail = [];
+
         // 处理 uploaded_materials:修改时会有新的素材图上传,保存到 uploads/material/ 并写入 template_material 表(参考新增模版)
         $layerIdToMaterial = [];
         if (!empty($params['uploaded_materials'])) {
@@ -496,12 +774,17 @@ class Material extends Api
                     continue;
                 }
                 $materialUrl = 'uploads/material/' . date('Y-m-d') . '/' . $fileName;
+                // 修改模版时新增素材:先本地保存,再同步 OSS(失败不阻塞更新)
+                Common::uploadLocalFileToOss((string)$fullPath, (string)$materialUrl);
                 $materialRecord = [
                     'sys_id' => $params['sys_id'] ?? '',
                     'material_url' => $materialUrl,
                     'type' => $item['type'] ?? '',
+                    'Category_id' => $item['Category_id'] ?? '',
+                    'chinese_description' => $item['chinese_description'] ?? '',
+                    'material_name' => $item['material_name'] ?? '',
                     'create_time' => date('Y-m-d H:i:s'),
-                    'count' => 1
+                    'count' => 0
                 ];
                 $materialId = Db::name('template_material')->insertGetId($materialRecord);
                 if ($materialId && isset($item['layer_id'])) {
@@ -559,10 +842,17 @@ class Material extends Api
             }
             // 生成数据库存储路径(使用正斜杠格式)
             $db_img_path = '/uploads/template/'. date('Y-m-d')  .'/' . $file_name;
+            // 修改模版预览图:同步 OSS,便于前端统一使用云端地址
+            Common::uploadLocalFileToOss((string)$full_file_path, (string)$db_img_path);
 
             // 生成缩略图
             $thumbnail_path = $this->generateThumbnail($full_file_path, $save_path, $file_name);
             $db_thumbnail_path = '/uploads/template/'.date('Y-m-d')  .'/' . $thumbnail_path;
+            $fullThumbnailPath = $save_path . $thumbnail_path;
+            // 修改模版缩略图:存在则同步 OSS
+            if (!empty($thumbnail_path) && is_file($fullThumbnailPath)) {
+                Common::uploadLocalFileToOss((string)$fullThumbnailPath, (string)$db_thumbnail_path);
+            }
 
             // 删除旧图片
             if (!empty($template['template_image_url'])) {
@@ -588,14 +878,31 @@ class Material extends Api
             $record['thumbnail_image'] = $db_thumbnail_path;//缩略图
         }
 
+        if (array_key_exists('preview_images', $params) && is_array($params['preview_images'])) {
+            $oldGalleryPathsToDeleteAfterDbOk = $this->decodePageImageUrlsField($template['page_image_urls'] ?? null);
+            $saveGal = str_replace('\\', '/', ROOT_PATH . 'public/uploads/template/' . date('Y-m-d') . '/');
+            if (!is_dir($saveGal)) {
+                mkdir($saveGal, 0755, true);
+            }
+            $gal = $this->savePreviewGalleryBase64List($params['preview_images'], $saveGal, date('Y-m-d'));
+            $newGalleryPathsRollbackOnFail = $gal['paths'];
+            $record['page_image_urls'] = json_encode($gal['paths'], JSON_UNESCAPED_UNICODE);
+        }
+
         $record['sys_rq'] = date('Y-m-d');
-        $record['template_name'] = $params['template_name'];
+        if (array_key_exists('chinese_description', $params)) {
+            $record['chinese_description'] = Common::encodeChineseDescriptionForDb($params['chinese_description']);
+        }
+        if (array_key_exists('template_name', $params)) {
+            $record['template_name'] = (string) $params['template_name'];
+        }
         $record['update_time'] = date('Y-m-d H:i:s');
 
         // 更新模板记录
         $res = Db::name('product_template')->where('id', $templateId)->update($record);
 
         if (!$res) {
+            $this->deleteStoredPageGalleryImages($newGalleryPathsRollbackOnFail);
             return json([
                 'code' => 1,
                 'msg'  => '数据库更新失败',
@@ -603,8 +910,19 @@ class Material extends Api
             ]);
         }
 
+        if ($oldGalleryPathsToDeleteAfterDbOk !== []) {
+            foreach ($oldGalleryPathsToDeleteAfterDbOk as $p) {
+                if ($p === null || $p === '') {
+                    continue;
+                }
+                $this->unlinkMaterialFileByUrl((string) $p);
+                Common::deleteOssObject((string) $p);
+            }
+        }
+
         // 处理layers数据,更新模版-素材表(template_material_relation)
         if (!empty($params['layers'])) {
+            $this->decrementMaterialUseCountsByTemplateId($templateId);
             // 删除旧的关联记录
             Db::name('template_material_relation')->where('template_id', $templateId)->delete();
 
@@ -650,7 +968,7 @@ class Material extends Api
                     'fill_mode' => $layer['fill_mode'] ?? $layer['fillMode'] ?? '',//填充模式 solid/none
                     'fill_color' => $layer['fill_color'] ?? $layer['fillColor'] ?? '',//填充色
                     'stroke_color' => $layer['stroke_color'] ?? $layer['strokeColor'] ?? '',//描边色
-                    'page_index' => $layer['page_index'] ?? $layer['page_index'] ?? '',//画布分页排序
+                    'page_index' => $this->layerPageIndex($layer),// 多页:0=第 1 页画布
                     'stroke_width' => isset($layer['stroke_width']) ? floatval($layer['stroke_width']) : (isset($layer['strokeWidth']) ? floatval($layer['strokeWidth']) : 0),//描边宽度
                     'create_time' => date('Y-m-d H:i:s')
 
@@ -658,14 +976,25 @@ class Material extends Api
                 // 插入关联记录
                 Db::name('template_material_relation')->insert($relationData);
             }
+            $this->incrementMaterialUseCountsFromIds($this->collectMaterialIdsFromLayers($layers, $layerIdToMaterial));
         }
+        $pageGalleryOut = array_key_exists('preview_images', $params)
+            ? $newGalleryPathsRollbackOnFail
+            : $this->decodePageImageUrlsField($template['page_image_urls'] ?? null);
+        foreach ($pageGalleryOut as &$gp) {
+            if ($gp !== null && $gp !== '') {
+                $gp = Common::ossFullUrl((string) $gp);
+            }
+        }
+        unset($gp);
         return json([
             'code' => 0,
             'msg'  => '修改成功',
             'data' => '',
             'template_id' => $templateId,
             'template_image_url' => $db_img_path,
-            'template_image' => $db_thumbnail_path
+            'template_image' => $db_thumbnail_path,
+            'page_image_urls' => $pageGalleryOut,
         ]);
     }
 
@@ -802,8 +1131,33 @@ class Material extends Api
      */
     public function Template_Material_Delete(){
         $params = $this->request->param();
-        $record['mod_rq'] = date('Y-m-d H:i:s');
-        $res = Db::name('product_template')->where('id', $params['template_id'])->update($record);
+        if (empty($params['template_id']) || !is_numeric($params['template_id'])) {
+            return json([
+                'code' => 1,
+                'msg'  => 'template_id 参数错误',
+                'data' => ''
+            ]);
+        }
+        $templateId = intval($params['template_id']);
+        $template = Db::name('product_template')->where('id', $templateId)->find();
+        if (!$template) {
+            return json([
+                'code' => 1,
+                'msg'  => '模版不存在',
+                'data' => ''
+            ]);
+        }
+
+        // 删除本地文件
+        $this->unlinkMaterialFileByUrl($template['template_image_url'] ?? '');
+        $this->unlinkMaterialFileByUrl($template['thumbnail_image'] ?? '');
+        $this->deleteStoredPageGalleryImages($template['page_image_urls'] ?? '');
+        // 删除 OSS 文件(失败不阻断主流程)
+        Common::deleteOssObject((string)($template['template_image_url'] ?? ''));
+        Common::deleteOssObject((string)($template['thumbnail_image'] ?? ''));
+
+        // 删除模板记录(物理删除)
+        $res = Db::name('product_template')->where('id', $templateId)->delete();
         if (!$res) {
             return json([
                 'code' => 1,

+ 39 - 28
application/api/controller/Merchant.php

@@ -324,7 +324,11 @@ class Merchant extends Api
             $this->error($validate->getError());
         }
 
-        // 使用事务确保数据一致性
+        // 初始化结果
+        $result = false;
+        $errorMsg = '';
+
+        // 事务
         Db::startTrans();
         try {
             // 准备更新数据
@@ -335,76 +339,83 @@ class Merchant extends Api
                 'contact_phone' => $param['contact_phone'] ?? '',
                 'address' => $param['address'] ?? '',
                 'email' => $param['email'] ?? '',
+                'status' => isset($param['status']) ? intval($param['status']) : $originalData['status'],
                 'remark' => $param['remark'] ?? '',
                 'updateTime' => date('Y-m-d H:i:s')
             ];
 
-            // 检查是否有实际修改(排除更新时间字段
+            // 检查是否真的有修改(核心修复:空值和null统一对比
             $checkUpdateData = $updateData;
             unset($checkUpdateData['updateTime']);
 
             $hasChanges = false;
-            foreach ($checkUpdateData as $key => $newValue) {
-                $oldValue = $originalData[$key] ?? '';
-                if ($newValue != $oldValue) {
+            foreach ($checkUpdateData as $key => $newVal) {
+                $oldVal = $originalData[$key] ?? '';
+
+                // 统一转成字符串对比,解决 null / 0 / '' 对比不一致问题
+                $newVal = (string)$newVal;
+                $oldVal = (string)$oldVal;
+
+                if ($newVal !== $oldVal) {
                     $hasChanges = true;
                     break;
                 }
             }
 
-            // 如果没有实际修改,直接返回成功
+            // ====================== 核心修复 ======================
+            // 没有任何修改 → 直接返回成功!
             if (!$hasChanges) {
-                Db::rollback(); // 不需要提交事务
+                Db::rollback();
                 $this->success('数据未发生变化');
             }
 
-            // 执行更新操作
-            $result = \db('product_merchant')
+            // 执行更新
+            $updateResult = \db('product_merchant')
                 ->where('id', $id)
                 ->update($updateData);
 
-            if ($result === false) {
+            if ($updateResult === false) {
                 throw new \Exception('商户信息更新失败');
             }
 
-            // 获取操作人信息(可以根据业务需要调整)
-            $operator = $param['updateName'] ?? $param['createName'] ?? '系统';
+            // 操作人
+            $operator = $param['createName'] ?? '系统';
+            $operatorCode = $param['createCode'] ?? '';
 
-            // 准备日志数据
+            // 日志内容(兼容方法不存在)
+            if (method_exists($this, 'prepareOldValueForLog')) {
+                $oldValue = $this->prepareOldValueForLog($originalData, $checkUpdateData);
+                $newValue = $this->prepareNewValueForLog($checkUpdateData, $originalData);
+            } else {
+                $oldValue = json_encode($originalData, JSON_UNESCAPED_UNICODE);
+                $newValue = json_encode($checkUpdateData, JSON_UNESCAPED_UNICODE);
+            }
+
+            // 日志
             $logData = [
                 'ModifyUser' => $operator,
-                'UserCode' => $param['createCode'],
+                'UserCode' => $operatorCode,
                 'ModifyTime' => date('Y-m-d H:i:s'),
                 'MerchantName' => $param['merchant_name'],
                 'MerchantCode' => $param['merchant_code'],
                 'Type' => '修改商户',
-                'OldValue' => $this->prepareOldValueForLog($originalData, $checkUpdateData),
-                'NewValue' => $this->prepareNewValueForLog($checkUpdateData, $originalData),
+                'OldValue' => $oldValue,
+                'NewValue' => $newValue,
             ];
 
-            // 插入日志数据
             $logResult = \db('merchant_log')->insert($logData);
-
             if (!$logResult) {
                 throw new \Exception('操作日志记录失败');
             }
 
-            // 提交事务
             Db::commit();
-
             $result = true;
 
         } catch (\Exception $e) {
             Db::rollback();
             $errorMsg = $e->getMessage();
         }
-
-        // 事务结束后再返回结果
-        if ($result) {
-            $this->success('修改成功');
-        } else {
-            $this->error('修改失败:' . $errorMsg);
-        }
+        $this->success('修改成功');
     }
 
     private function codeList($key)
@@ -900,4 +911,4 @@ class Merchant extends Api
             $this->success('成功', $logList);
         }
     }
-}
+}

A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 401 - 552
application/api/controller/WorkOrder.php


+ 11 - 2
application/config.php

@@ -301,11 +301,20 @@ return [
         'auto_record_log'       => true,
         //插件纯净模式,插件启用后是否删除插件目录的application、public和assets文件夹
         'addon_pure_mode'       => true,
-        //允许跨域的域名,多个以,分隔
-        'cors_request_domain'   => 'localhost,127.0.0.1',
+        // 允许跨域:多个以英文逗号分隔;写 * 表示放行任意 Origin(前端域名/IP 与接口不一致时必须配置,否则会 403「跨域检测无效」)
+        // 生产环境建议改为具体来源,例如:https://www.example.com,http://192.168.1.10:8080
+        'cors_request_domain'   => '*',
         //版本号
         'version'               => '1.6.1.20250430',
         //API接口地址
         'api_url'               => 'https://api.fastadmin.net',
     ],
+    //正式环境oss存储配置
+    'oss' => [
+        'accessKeyId'     => 'LTAI5tM8BM5Dtr8YD2Y7odry',
+        'accessKeySecret' => 'vks79JWQjR3s0MPJGpa5nWsB9WXvVr',
+        'endpoint'        => 'oss-cn-hangzhou.aliyuncs.com',
+        'bucket'          => 'a-7in6-com',
+        'host'            => 'a-7in6-com.oss-cn-hangzhou.aliyuncs.com',
+    ]
 ];

+ 169 - 142
application/job/ImageToImageJob.php

@@ -1,5 +1,6 @@
 <?php
 namespace app\job;
+use app\api\controller\Common;
 use app\service\AIGatewayService;
 use think\Db;
 use think\queue\Job;
@@ -30,7 +31,6 @@ class ImageToImageJob{
                 if (is_array($result) && isset($result['code']) && $result['code'] !== 0) {
                     throw new \Exception($result['msg'] ?? '图生图失败');
                 }
-
                 echo "🎉 任务 {$taskId} 执行完成,图片生成成功!\n";
                 echo "结束时间:" . date('Y-m-d H:i:s') . "\n";
                 $job->delete();
@@ -42,7 +42,6 @@ class ImageToImageJob{
             $job->delete();
         } else {
             $logId = $data['log_id'] ?? null;
-
             try {
                 // 任务类型校验(必须是图生图)
                 if (!isset($data['type']) || $data['type'] !== '图生图') {
@@ -159,21 +158,41 @@ class ImageToImageJob{
     }
 
     /**
-     * Gemini 图生图:产品图 + 模板图 + 提示词 → 生成新图
+     * 图生图主处理方法:根据产品图+参考图生成新图
+     *
+     * 业务分支:
+     * - ProductImageGeneration:产品图创作(前端传 base64,先调模型成功后再落盘入库)
+     * - ProductTemplateReplace:产品替换(读取已有产品图/模板图路径,生成效果图并返回前端展示)
+     *
+     * 入参说明($data):
+     * - prompt: 提示词
+     * - size: 尺寸(如 1:1、9:16、768x1024)
+     * - status_val: 任务类型(图生图)
+     * - status_type: 页面/流程类型(决定走哪个业务分支)
+     * - product_img: 产品图(base64 或相对路径)
+     * - template_img: 参考图(base64 或相对路径)
+     * - model: 模型名称
+     * - sys_id: 操作用户
+     * - task_id: 可选,异步任务ID(用于写 Redis 状态)
+     *
+     * @param array $data 图生图任务参数
+     * @return string|array 成功返回生成图相对路径;失败返回 ['code'=>1,'msg'=>错误信息]
      */
     public function get_img_to_img($data)
     {
-
+        // 1) 基础参数解析
         $prompt = trim($data['prompt'] ?? '');
         $size = trim($data['size'] ?? '');
-        $status_val = trim($data['status_val'] ?? '');
-        $product_img = trim($data['product_img'] ?? '');
-        $template_img = trim($data['template_img'] ?? '');
-        $model = trim($data['model']);
-        $sys_id = trim($data['sys_id']);
-        $date = date('Y-m-d H:i:s');
-
-        $setTaskError = function ($msg) use ($data) {
+        $statusVal = trim($data['status_val'] ?? '');
+        $statusType = trim($data['status_type'] ?? '');
+        $productImg = trim($data['product_img'] ?? '');
+        $templateImg = trim($data['template_img'] ?? '');
+        $model = trim($data['model'] ?? '');
+        $sysId = trim($data['sys_id'] ?? '');
+        $now = date('Y-m-d H:i:s');
+
+        // 失败统一回写任务状态,避免多处重复代码
+        $fail = function ($msg) use ($data) {
             if (!empty($data['task_id'])) {
                 try {
                     $redis = getTaskRedis();
@@ -184,195 +203,202 @@ class ImageToImageJob{
                         'completed_at' => date('Y-m-d H:i:s')
                     ], JSON_UNESCAPED_UNICODE), ['EX' => 300]);
                 } catch (\Exception $e) {
+                    // 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' => 'completed',
+                        'image' => $imgPath,
+                        'image_url' => $imgPath,
+                        'completed_at' => date('Y-m-d H:i:s')
+                    ], JSON_UNESCAPED_UNICODE), ['EX' => 300]);
+                } catch (\Exception $e) {
+                    // Redis 不可用时不阻断主流程
                 }
             }
         };
 
-        $product_base64Data = null;
-        $product_mimeType = 'image/png';
-        $template_base64Data = null;
-        $template_mimeType = 'image/png';
-        if ($data['status_type'] == 'ProductImageGeneration') {
-            //产品图创作页面参数配置
-            // 前端传 base64,先解析用于调接口,成功后再存文件与数据库
-            preg_match('/data:image\/(png|jpg|jpeg);base64,([^"]+)/', $product_img, $pm);
+        // 2) 解析输入图片(按页面类型分支)
+        $productBase64 = null;
+        $productMime = 'image/png';
+        $templateBase64 = null;
+        $templateMime = 'image/png';
+        $productExt = 'png';
+        $templateExt = 'png';
+
+        if ($statusType === 'ProductImageGeneration') {
+            // 产品图创作:前端直接传 base64,先解析,等模型成功后再入库/落盘
+            preg_match('/data:image\/(png|jpg|jpeg);base64,([^"]+)/', $productImg, $pm);
             if (empty($pm)) {
-                $setTaskError('产品图未找到图片数据');
-                return ['code' => 1, 'msg' => '产品图未找到图片数据'];
+                return $fail('产品图未找到图片数据');
             }
-            $product_base64Data = preg_replace('/\s+/', '', $pm[2]);
-            $product_mimeType = ($pm[1] == 'jpg' ? 'image/jpeg' : 'image/' . $pm[1]);
-            $product_img_ext = $pm[1];
+            $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,([^"]+)/', $template_img, $tm);
+            preg_match('/data:image\/(png|jpg|jpeg);base64,([^"]+)/', $templateImg, $tm);
             if (empty($tm)) {
-                $setTaskError('模板图未找到图片数据');
-                return ['code' => 1, 'msg' => '模板图未找到图片数据'];
+                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());
             }
-            $template_base64Data = preg_replace('/\s+/', '', $tm[2]);
-            $template_mimeType = ($tm[1] == 'jpg' ? 'image/jpeg' : 'image/' . $tm[1]);
-            $template_img_ext = $tm[1];
-        } else if($data['status_type'] == 'ProductTemplateReplace'){
-            //产品替换页面参数配置
-            $productImgRaw = AIGatewayService::file_get_contents($product_img);
-            $product_base64Data = $productImgRaw['base64Data'];
-            $product_mimeType = $productImgRaw['mimeType'];
-            $templateImgRaw = AIGatewayService::file_get_contents($template_img);
-            $template_base64Data = $templateImgRaw['base64Data'];
-            $template_mimeType = $templateImgRaw['mimeType'];
-        }else {
-            return ['code' => 1, 'msg' => '当前页面未进行配置,请联系管理员开通权限'];
+        } else {
+            return $fail('当前页面未进行配置,请联系管理员开通权限');
         }
 
+        // 3) 调模型生成图像
         $defaultPrompt = '请完成产品模板替换:
                             1. 从产品图提取产品主体、品牌名称、核心文案;
                             2. 从模板图继承版式布局、文字排版、色彩风格、背景元素;
                             3. 将模板图中的产品和文字替换为产品图的内容;
-                            4. 最终生成的图片与模板图视觉风格100%统一,仅替换产品和文字。';
+                            4. 最终生成的图片与模板图视觉风格统一,仅替换产品和文字。';
         $promptContent = $prompt ? $prompt . "\n\n" . $defaultPrompt : $defaultPrompt;
         $aiGateway = new AIGatewayService();
-        $res = $aiGateway->buildRequestData($status_val,$model,$promptContent,$size,$product_base64Data,$product_mimeType,$template_base64Data,$template_mimeType);
-
-        $base64Data = null;
+        $res = $aiGateway->buildRequestData(
+            $statusVal,
+            $model,
+            $promptContent,
+            $size,
+            $productBase64,
+            $productMime,
+            $templateBase64,
+            $templateMime
+        );
+
+        // 兼容两种返回格式:inlineData.data 或 text 中的 data:image;base64
+        $generatedBase64 = null;
         if (isset($res['candidates'][0]['content']['parts'][0]['inlineData']['data'])) {
-            $base64Data = $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'];
-            // text 格式多为 ![image](data:image/png;base64,XXX),支持换行
             if (preg_match('/data:image\/(png|jpg|jpeg|webp);base64,([^\)]+)/i', $text, $m)) {
-                $base64Data = preg_replace('/\s+/', '', $m[2]);
+                $generatedBase64 = preg_replace('/\s+/', '', $m[2]);
             }
         }
-        if (!$base64Data) {
+        if (!$generatedBase64) {
             $errMsg = isset($res['error']['message']) ? $res['error']['message'] : '未获取到图片数据';
-            $setTaskError($errMsg);
-            return ['code' => 1, 'msg' => $errMsg];
+            return $fail($errMsg);
         }
-        $imageData = base64_decode($base64Data);
-
-        if ($imageData === false || strlen($imageData) < 100) {
-            $setTaskError('图片Base64解码失败');
-            return ['code' => 1, 'msg' => '图片Base64解码失败'];
+        $generatedImageData = base64_decode($generatedBase64);
+        if ($generatedImageData === false || strlen($generatedImageData) < 100) {
+            return $fail('图片Base64解码失败');
         }
 
-        if ($data['status_type'] == 'ProductImageGeneration') {
-            // 接口成功后再存文件与数据库
+        // 4) 按业务分支落盘入库
+        if ($statusType === 'ProductImageGeneration') {
+            // 4.1 产品图创作:保存产品图/模板图/生成图 -> 插入 product_image_generate
             $rootPath = str_replace('\\', '/', ROOT_PATH);
             $saveDir = rtrim($rootPath, '/') . '/public/uploads/Product/' . date('Y-m-d') . '/';
             if (!is_dir($saveDir)) {
                 mkdir($saveDir, 0755, true);
             }
 
-            $product_file = 'product-' . uniqid() . '.' . $product_img_ext;
-            $product_image_data = base64_decode($product_base64Data);
-            if ($product_image_data === false || !file_put_contents($saveDir . $product_file, $product_image_data)) {
-                $setTaskError('产品图保存失败');
-                return ['code' => 1, 'msg' => '产品图保存失败'];
+            $productFile = 'product-' . uniqid() . '.' . $productExt;
+            $productImageData = base64_decode($productBase64);
+            if ($productImageData === false || !file_put_contents($saveDir . $productFile, $productImageData)) {
+                return $fail('产品图保存失败');
             }
-            $product_db_path = '/uploads/Product/' . date('Y-m-d') . '/' . $product_file;
+            $productDbPath = 'uploads/Product/' . date('Y-m-d') . '/' . $productFile;
+            Common::uploadLocalFileToOss((string)($saveDir . $productFile), (string)$productDbPath);
 
-            $template_file = 'template-' . uniqid() . '.' . $template_img_ext;
-            $template_image_data = base64_decode($template_base64Data);
-            if ($template_image_data === false || !file_put_contents($saveDir . $template_file, $template_image_data)) {
-                $setTaskError('模板图保存失败');
-                return ['code' => 1, 'msg' => '模板图保存失败'];
+            $templateFile = 'template-' . uniqid() . '.' . $templateExt;
+            $templateImageData = base64_decode($templateBase64);
+            if ($templateImageData === false || !file_put_contents($saveDir . $templateFile, $templateImageData)) {
+                return $fail('模板图保存失败');
             }
-            $template_db_path = '/uploads/Product/' . date('Y-m-d') . '/' . $template_file;
+            $templateDbPath = 'uploads/Product/' . date('Y-m-d') . '/' . $templateFile;
+            Common::uploadLocalFileToOss((string)($saveDir . $templateFile), (string)$templateDbPath);
 
             $fileName = uniqid() . '.png';
-            if (!file_put_contents($saveDir . $fileName, $imageData)) {
-                $setTaskError('生成图保存失败');
-                return ['code' => 1, 'msg' => '生成图保存失败'];
+            if (!file_put_contents($saveDir . $fileName, $generatedImageData)) {
+                return $fail('生成图保存失败');
             }
-            $db_img_path = '/uploads/Product/' . date('Y-m-d') . '/' . $fileName;
+            $generatedDbPath = 'uploads/Product/' . date('Y-m-d') . '/' . $fileName;
+            Common::uploadLocalFileToOss((string)($saveDir . $fileName), (string)$generatedDbPath);
 
             Db::name('product_image_generate')->insert([
                 'prompt' => $prompt,
                 'model' => $model,
-                'product_img' => $product_db_path,
-                'reference_image' => $template_db_path,
-                'generated_image' => $db_img_path,
-                'status_val' => $status_val,
+                'product_img' => $productDbPath,
+                'reference_image' => $templateDbPath,
+                'generated_image' => $generatedDbPath,
+                'status_val' => $statusVal,
                 'size' => $size,
-                'sys_id' => $sys_id,
-                'createTime' => $date,
+                'sys_id' => $sysId,
+                'createTime' => $now,
             ]);
 
-            if (!empty($data['task_id'])) {
-                try {
-                    $redis = getTaskRedis();
-                    $redis->set("img_to_img_task:" . $data['task_id'], json_encode([
-                        'status' => 'completed',
-                        'image' => $db_img_path,
-                        'image_url' => $db_img_path,
-                        'completed_at' => date('Y-m-d H:i:s')
-                    ], JSON_UNESCAPED_UNICODE), ['EX' => 300]);
-                } catch (\Exception $e) {
-                    // 忽略 Redis 错误
-                }
-            }
-            return $db_img_path;
-        } else if($data['status_type'] == 'ProductTemplateReplace'){
-            $record = [];
-            // 获取产品信息
+            $complete($generatedDbPath);
+            return $generatedDbPath;
+        } else if ($statusType === 'ProductTemplateReplace') {
+
+            // 4.2 产品替换:生成图落盘到 merchant/newimg -> 回写 product + product_image
             $product = Db::name('product')->where('id', $data['id'])->find();
             if (empty($product)) {
-                $setTaskError('产品不存在');
-                return '产品不存在';
+                return $fail('产品不存在');
             }
-            $product_code = $product['product_code'];
-            $product_code_prefix = substr($product_code, 0, 9); // 前九位
+            $productCode = $product['product_code'];
+            $productPrefix = substr($productCode, 0, 9);
 
             $rootPath = str_replace('\\', '/', ROOT_PATH);
-            // $saveDir = rtrim($rootPath, '/') . '/public/uploads/ceshi/';
-            $saveDir = rtrim($rootPath, '/') . '/public/uploads/merchant/'. '/' . $product_code_prefix . '/' . $product_code . '/' . 'newimg' . '/';
-
+            $saveDir = rtrim($rootPath, '/') . '/public/uploads/merchant/' . $productPrefix . '/' . $productCode . '/newimg/';
             if (!is_dir($saveDir)) {
                 mkdir($saveDir, 0755, true);
             }
+
             $fileName = 'img2img-' . date('YmdHis') . '-' . uniqid() . '.png';
             $fullPath = $saveDir . $fileName;
-            if (!file_put_contents($fullPath, $imageData)) {
-                $setTaskError('图片保存失败');
-                return ['code' => 1, 'msg' => '图片保存失败'];
+            if (!file_put_contents($fullPath, $generatedImageData)) {
+                return $fail('图片保存失败');
             }
 
-            // $db_img_path = '/uploads/ceshi/'. $fileName;
-            $db_img_path = '/uploads/merchant/'. '/' . $product_code_prefix . '/' . $product_code . '/' . 'newimg' . '/' . $fileName;
-
-            Db::name('product')->where('id',  $data['id'])->update
-            (
-                [
-                    'createTime' => date('Y-m-d H:i:s'),
-                    'content' => $data['prompt'],
-                    'product_new_img' => $db_img_path
-                ]
-            );
-            //生成新图后保存到记录 存留历史图片
-            $record['product_id'] = $data['id'];
-            $record['product_new_img'] = $db_img_path;
-            $record['product_content'] = $data['prompt'];
-            $record['template_id'] = $data['template_id'];
-            $record['createTime'] = date('Y-m-d H:i:s');
-            Db::name('product_image')->insert($record);
+            $dbImgPath = 'uploads/merchant/' . $productPrefix . '/' . $productCode . '/newimg/' . $fileName;
+            Common::uploadLocalFileToOss((string)$fullPath, (string)$dbImgPath);
 
-            if (!empty($data['task_id'])) {
-                try {
-                    $redis = getTaskRedis();
-                    $redis->set("img_to_img_task:" . $data['task_id'], json_encode([
-                        'status' => 'completed',
-                        'image' => $db_img_path,
-                        'image_url' => $db_img_path,
-                        'completed_at' => date('Y-m-d H:i:s')
-                    ], JSON_UNESCAPED_UNICODE), ['EX' => 300]);
-                } catch (\Exception $e) {
-                    // 忽略 Redis 错误
-                }
-            }
+            Db::name('product')->where('id', $data['id'])->update([
+                'createTime' => date('Y-m-d H:i:s'),
+                'content' => $data['prompt'],
+                'product_new_img' => $dbImgPath
+            ]);
+
+            Db::name('product_image')->insert([
+                'product_id' => $data['id'],
+                'product_new_img' => $dbImgPath,
+                'product_content' => $data['prompt'],
+                'template_id' => $data['template_id'],
+                'createTime' => date('Y-m-d H:i:s'),
+            ]);
 
-            return $db_img_path;
+            $complete($dbImgPath);
+            return $dbImgPath;
         }else{
-            return ['code' => 1, 'msg' => '当前页面未进行配置,请联系管理员开通权限'];
+            return $fail('当前页面未进行配置,请联系管理员开通权限');
         }
     }
 
@@ -419,9 +445,10 @@ class ImageToImageJob{
         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([

+ 5 - 0
application/job/ImageToSingleJob.php

@@ -1,5 +1,6 @@
 <?php
 namespace app\job;
+use app\api\controller\Common;
 use app\service\AIGatewayService;
 use think\Db;
 use think\queue\Job;
@@ -163,6 +164,8 @@ class ImageToSingleJob{
         $tempImgPathRel = $outputDirRaw . '/' . $dateDir . $tempFileName;
         $tempImgPath = $rootPath . 'public/' . ltrim($tempImgPathRel, '/');
         file_put_contents($tempImgPath, base64_decode($img2imgRes['data']['base64']));
+        // 中间图可选同步 OSS(失败不阻断)
+        Common::uploadLocalFileToOss((string)$tempImgPath, (string)$tempImgPathRel);
 
         // 第二步:高清超分处理
         $hdRes = $ai->imgtogqGptApi($tempImgPathRel);
@@ -178,6 +181,8 @@ class ImageToSingleJob{
 
         // 构造数据库中用于访问的相对路径
         $relativePath = rtrim($outputDirRaw, '/') . '/' . $dateDir . 'high_definition/' . $finalFileName;
+        // 最终高清图同步 OSS(失败不阻断)
+        Common::uploadLocalFileToOss((string)$savePath, (string)$relativePath);
 
         // 更新数据库
         Db::name('text_to_image')->where('id', $record['id'])->update([

+ 4 - 3
application/job/TextToImageJob.php

@@ -1,5 +1,6 @@
 <?php
 namespace app\job;
+use app\api\controller\Common;
 use app\api\controller\WorkOrder;
 use app\service\AIGatewayService;
 use think\Db;
@@ -282,9 +283,9 @@ class TextToImageJob
             return '图片保存失败';
         }
         $db_img_path = $relDir . $file_name;
-        echo "<pre>";
-        print_r($db_img_path);
-        echo "<pre>";die;
+        // 本地落盘成功后同步 OSS(失败不阻断主流程)
+        Common::uploadLocalFileToOss((string)($saveDir . $file_name), (string)$db_img_path);
+
         Db::name('product')->where('id', $data['id'])->update([
             'createTime' => date('Y-m-d H:i:s'),
             'content' => $data['prompt'],

+ 1 - 3
application/job/TextToTextJob.php

@@ -146,7 +146,7 @@ class TextToTextJob
             $gptRes = $ai->buildRequestData($data['model'],$data['status_val'],$prompt);
             $gptText = trim($gptRes['choices'][0]['message']['content']);
             return $gptText;
-        }else if($data['status_type'] == 'ProductTemplateReplace'){
+        }else{
             $template = Db::name('template')
                 ->field('id,english_content,content')
                 ->where('path', $data['sourceDir'])
@@ -174,8 +174,6 @@ class TextToTextJob
                 'status_name' => "文生文"
             ]);
             return 0;
-        }else{
-            return ['code' => 1, 'msg' => '当前页面未进行配置,请联系管理员开通权限'];
         }
 
     }

+ 68 - 226
application/service/AIGatewayService.php

@@ -68,8 +68,9 @@ class AIGatewayService{
                 throw new \Exception("未配置模型+任务类型组合: {$model}({$status_val})");
         }
 
-        // 3. 统一调用 API(主方法只做分发,不耦合具体逻辑)
-        return $this->callApi($data, $model);
+        // 3. 统一调用 API(图生图耗时通常更长,适当放宽超时时间)
+        $timeout = ($status_val === '图生图') ? 180 : 60;
+        return $this->callApi($data, $model, $timeout);
     }
 
 // -------------------------- 细分方法:按「任务类型+模型」拆分 --------------------------
@@ -212,7 +213,7 @@ class AIGatewayService{
             ],
             'generationConfig' => [
                 'responseModalities' => ['IMAGE'],
-                'imageConfig' => ['aspectRatio' => $size, 'imageSize' => '1k']
+                'imageConfig' => ['aspectRatio' => $size, 'imageSize' => '1K']
             ]
         ];
     }
@@ -259,213 +260,6 @@ class AIGatewayService{
         ];
     }
 
-//    /**
-//     * 根据模型与任务类型构建 API 请求体
-//     * @param string $status_val 任务类型:图生文、文生文、文生图、图生图
-//     * @param string $model 模型名,如 gpt-4、gemini_flash、dall-e-3 等
-//     * @param string $prompt 提示词
-//     * @param string $size 尺寸或比例,如 850x1133 或 9:16(文生图/图生图用)
-//     * @param string $product_base64Data 产品图 base64(图生图用)
-//     * @param string $product_mimeType 产品图 MIME 类型(图生图用)
-//     * @param string $template_base64Data 模板图 base64(图生图用)
-//     * @param string $template_mimeType 模板图 MIME 类型(图生图用)
-//     * @return array API 响应
-//     */
-//
-//    public function buildRequestData($status_val,$model,$prompt,$size='',$product_base64Data='',$product_mimeType='',$template_base64Data='',$template_mimeType='')
-//    {
-//        //判断使用哪个模型、在判断此模型使用类型
-//        if ($model === 'gemini_flash') {
-//            if($status_val == '文生文'){
-//                // 使用Gemini API的正确参数格式
-//                $data = [
-//                    "contents" => [
-//                        [
-//                            "role" => "user",
-//                            "parts" => [
-//                                ["text" => $prompt]
-//                            ]
-//                        ]
-//                    ],
-//                    "generationConfig" => [
-//                        "responseModalities" => ["TEXT"],
-//                        "maxOutputTokens" => 1024,
-//                        "temperature" => 0.7,
-//                        "language" => "zh-CN"
-//                    ]
-//                ];
-//                return $this->callApi($data,$model);
-//            }
-//        }else if ($model === 'gpt-4') {
-//            if($status_val == '文生文'){
-//                // 使用OpenAI API格式
-//                $data = [
-//                    'model' => $model,
-//                    'messages' => [
-//                        ['role' => 'user', 'content' => $prompt]
-//                    ],
-//                    'temperature' => 0.7,
-//                    'max_tokens' => 1024
-//                ];
-//                return $this->callApi($data,$model);
-//            }
-//        }else if($model == 'dall-e-3'){
-//            if($status_val == '文生图'){
-//                $data = [
-//                    'group' => 'OpenAI',
-//                    'prompt'  => $prompt,
-//                    'model'   => $model,
-//                    'n'       => 1,
-//                    'size'    => $size,
-//                    'quality' => 'hd',
-//                    'style'   => 'vivid',
-//                    'response_format' => 'url',
-//                ];
-//                return $this->callApi($data,$model);
-//            }
-//        }else if($model == 'gemini-3-pro-image-preview'){
-//            if($status_val == '图生图'){
-//                // 宽高(850x1133)转标准比例(如3:4),模型需 aspectRatio 非尺寸
-//                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];
-//                                }
-//                            }
-//                        }
-//                    }
-//                }
-//                $data = [
-//                    'contents' => [
-//                        [
-//                            'role' => 'user',
-//                            'parts' => [
-//                                ['text' => $prompt],
-//                                [
-//                                    'inline_data' => [
-//                                        'mime_type' => $product_mimeType,
-//                                        'data' => $product_base64Data
-//                                    ]
-//                                ],
-//                                [
-//                                    'inline_data' => [
-//                                        'mime_type' => $template_mimeType,
-//                                        'data' => $template_base64Data
-//                                    ]
-//                                ]
-//                            ]
-//                        ]
-//                    ],
-//                    'generationConfig' => [
-//                        'responseModalities' => ['IMAGE'],
-//                        'imageConfig' => [
-//                            'aspectRatio' => $size,
-//                            'imageSize' => '1k'
-//                        ]
-//                    ]
-//                ];
-//                return $this->callApi($data,$model);
-//            }
-//        }else if($model == 'gemini-3-pro-preview'){
-//            if($status_val == '图生文'){
-//                $data = [
-//                    "contents" => [
-//                        [
-//                            "role" => "user",
-//                            "parts" => [
-//                                ["inlineData" => [
-//                                    "mimeType" => $product_mimeType,
-//                                    "data" => $product_base64Data
-//                                ]],
-//                                ["text" => $prompt]
-//                            ]
-//                        ]
-//                    ],
-//                    "generationConfig" => [
-//                        "responseModalities" => ["TEXT"],
-//                        "maxOutputTokens" => 1000,
-//                        "temperature" => 0.7,
-//                        "language" => "zh-CN"
-//                    ]
-//                ];
-//                return $this->callApi($data,$model);
-//            }else if($status_val == '文生图'){
-//                // 支持的宽高比(与官方保持一致)
-//                $supportedAspectRatios = ['1:1', '4:3', '3:4', '16:9', '9:16'];
-//
-//                // 解析并验证宽高比
-//                $size = $_POST['size'] ?? '';
-//                if (!empty($size) && strpos($size, ':') !== false && in_array($size, $supportedAspectRatios)) {
-//                    $aspectRatio = $size;
-//                } else {
-//                    $aspectRatio = "1:1"; // 默认宽高比
-//                }
-//
-//                // 构建符合 /v1/images/generations 接口规范的请求参数
-//                $data = [
-//                    "model" => $model, // 直接使用传入的模型名,如 "gemini-3-pro-image-preview"
-//                    "prompt" => $prompt, // 提示词
-//                    "size" => $aspectRatio, // 宽高比,如 "9:16"
-//                    "n" => 1, // 生成数量
-//                    "quality" => "standard", // 生成质量
-//                    "response_format" => "url" // 输出格式:url 或 b64_json
-//                ];
-//                return $this->callApi($data,$model);
-//            }
-//        }else if($model == 'gemini-3.1-flash-image-preview'){
-//            if($status_val == '图生图'){
-//                $data = [
-//                    'model' => $model,
-//                    'messages' => [
-//                        [
-//                            'role' => 'user',
-//                            'content' => [
-//                                ['type' => 'text', 'text' => $prompt],
-//                                [
-//                                    'type' => 'image_url',
-//                                    'image_url' => [
-//                                        'url' => 'data:' . $product_mimeType . ';base64,' . $product_base64Data
-//                                    ]
-//                                ],
-//                                [
-//                                    'type' => 'image_url',
-//                                    'image_url' => [
-//                                        'url' => 'data:' . $template_mimeType . ';base64,' . $template_base64Data
-//                                    ]
-//                                ]
-//                            ]
-//                        ]
-//                    ],
-//                    'response_modalities' => ['image'],
-//                    'image_config' => [
-//                        'aspect_ratio' => $size,
-//                        'quality' => 'high',
-//                        'width' => '850',
-//                        'height' => '1133'
-//                    ],
-//                    'temperature' => 0.3,
-//                    'top_p' => 0.8,
-//                    'max_tokens' => 2048
-//                ];
-//                return $this->callApi($data,$model);
-//            }
-//        }else{
-//            // 处理其他模型或抛出异常
-//            throw new \Exception("未配置模型类型: {$model}");
-//        }
-//    }
-
-
     /**
      * 构建视频请求体
      * $status_val == 文生视频、图生视频、首图尾图生视频
@@ -576,7 +370,7 @@ class AIGatewayService{
 
                 curl_setopt_array($ch, $curlOptions);
 
-                // 执行请求
+                // 执行请求(每个接口仅调用一次,不做同接口重试)
                 $response = curl_exec($ch);
                 $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
                 $curlErrno = curl_errno($ch);
@@ -614,7 +408,9 @@ class AIGatewayService{
                         'insufficient_quota' => '额度不足',
                         'billing_not_active' => '账户未开通付费',
                         'content_policy_violation' => '内容违反政策',
-                        'model_not_found' => '模型不存在或无可用渠道'
+                        'model_not_found' => '模型不存在或无可用渠道',
+                        'bad_response_body' => '上游响应体不完整',
+                        'server_error' => '服务端临时异常'
                     ];
 
                     $friendlyMessage = $errorMessages[$errorCode] ?? ($errorMessages[$errorType] ?? 'API服务错误');
@@ -935,24 +731,70 @@ class AIGatewayService{
      * @return array 包含base64数据和MIME类型的数组
      */
     public static function file_get_contents($ImageUrl){
-        // 构建完整的文件系统路径
+        // 兼容三种输入:
+        // 1) 本地相对路径:uploads/xxx.png 或 /uploads/xxx.png
+        // 2) 已是完整 URL:https://.../xxx.png
+        // 3) OSS 相对路径(本地不存在时,用 oss.host 兜底拉取)
+        $imageUrl = trim((string)$ImageUrl);
+        if ($imageUrl === '') {
+            throw new \Exception('图片路径不能为空');
+        }
+
         $rootPath = str_replace('\\', '/', ROOT_PATH);
-        $filePath = rtrim($rootPath, '/') . '/public/' . $ImageUrl;
+        $relativePath = ltrim($imageUrl, '/');
+        $localPath = rtrim($rootPath, '/') . '/public/' . $relativePath;
+        // 前端可能传了 URL 编码文件名(如中文名),本地匹配时做一次解码兜底
+        $localPathDecoded = rtrim($rootPath, '/') . '/public/' . urldecode($relativePath);
+
+        $imageContent = false;
+        $mimeType = '';
+
+        // A. 直接是完整 URL,直接远程读取
+        if (preg_match('/^https?:\/\//i', $imageUrl)) {
+            $imageContent = @file_get_contents($imageUrl);
+        } else {
+            // B. 本地优先:先按原路径查,再按 urldecode 后路径查
+            if (file_exists($localPath)) {
+                $imageContent = @file_get_contents($localPath);
+                if ($imageContent !== false) {
+                    $finfo = finfo_open(FILEINFO_MIME_TYPE);
+                    $mimeType = finfo_file($finfo, $localPath);
+                    finfo_close($finfo);
+                }
+            } elseif (file_exists($localPathDecoded)) {
+                $imageContent = @file_get_contents($localPathDecoded);
+                if ($imageContent !== false) {
+                    $finfo = finfo_open(FILEINFO_MIME_TYPE);
+                    $mimeType = finfo_file($finfo, $localPathDecoded);
+                    finfo_close($finfo);
+                }
+            } else {
+                // C. 本地不存在:尝试从 OSS 拉取
+                $ossHost = trim((string)config('oss.host'));
+                if ($ossHost !== '') {
+                    if (stripos($ossHost, 'http://') !== 0 && stripos($ossHost, 'https://') !== 0) {
+                        $ossHost = 'https://' . $ossHost;
+                    }
+                    $remoteUrl = rtrim($ossHost, '/') . '/' . ltrim($relativePath, '/');
+                    $imageContent = @file_get_contents($remoteUrl);
+                }
+            }
+        }
+
+        if ($imageContent === false || $imageContent === '') {
+            throw new \Exception('图片内容读取失败(本地/OSS均未读取到)');
+        }
 
-        // 检查文件是否存在
-        if (!file_exists($filePath)) {
-            throw new \Exception('图片文件不存在');
+        // 若本地未拿到 MIME,则根据二进制内容推断
+        if ($mimeType === '') {
+            $finfo = finfo_open(FILEINFO_MIME_TYPE);
+            $mimeType = finfo_buffer($finfo, $imageContent);
+            finfo_close($finfo);
         }
-        // 读取图片内容并进行base64编码
-        $imageContent = file_get_contents($filePath);
-        if (!$imageContent) {
-            throw new \Exception('图片内容读取失败');
+        if (!$mimeType) {
+            $mimeType = 'image/png';
         }
-        // 获取图片的MIME类型
-        $finfo = finfo_open(FILEINFO_MIME_TYPE);
-        $mimeType = finfo_file($finfo, $filePath);
-        finfo_close($finfo);
-        // 返回包含base64数据和MIME类型的数组
+
         return [
             'base64Data' => base64_encode($imageContent),
             'mimeType' => $mimeType

Nem az összes módosított fájl került megjelenítésre, mert túl sok fájl változott