浏览代码

优化接口

liuhairui 20 小时之前
父节点
当前提交
0bbdfcee80

+ 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()
     {
 

+ 549 - 4
application/api/controller/Index.php

@@ -1,14 +1,12 @@
 <?php
-
 namespace app\api\controller;
-
 use app\common\controller\Api;
 use app\service\AIGatewayService;
 use think\Db;
 use think\Exception;
 
 /**
- * 首页接口
+ * 本地测试接口
  */
 class Index extends Api
 {
@@ -23,6 +21,553 @@ class Index extends Api
         $this->success('请求成功');
     }
 
-    
+    // 向量引擎配置
+    private $baseUrl = "https://api.vectorengine.ai/v1";
+    private $apiKey = "sk-P877pnXMk2erRS2an7qEa3Kdb3rIb7JVAWZ39lhA8HeN71gZ"; // 从控制台获取
+    private $timeout = 120; // 超时时间(秒),视频生成需要更长
+
+    /**
+     * 文生图接口
+     * POST /api/index/textToImage
+     * 参数: prompt (string) 提示词
+     */
+    public function textToImage()
+    {
+        $params = $this->request->param();
+        if (empty($params)) {
+            $this->error('提示词不能为空');
+        }
+
+        $data = [
+            "model"  => "gemini-3-pro-image-preview", // 可替换为 dall-e-3 等
+            "prompt" => $params['prompt'],
+            "n"      => 1,
+            "size"   => "1024x1024"
+        ];
+
+        $result = $this->requestVectorEngine("/images/generations", $data);
+        if ($result['code'] === 0) {
+            $this->success('生成成功', $result['data']);
+        } else {
+            $this->error($result['msg'], $result['data']);
+        }
+    }
+
+    /**
+     * 图生文接口
+     * POST /api/index/imageToText
+     * 参数: image_url (string) 公网图片URL, prompt (string) 提问指令
+     */
+    public function imageToText()
+    {
+        $imageUrl = $this->request->post('image_url');
+        $prompt   = $this->request->post('prompt', '描述这张图片的内容');
+
+        if (empty($imageUrl)) {
+            $this->error('图片URL不能为空');
+        }
+
+        $data = [
+            "model"    => "gpt-4-vision-preview",
+            "messages" => [
+                [
+                    "role"    => "user",
+                    "content" => [
+                        ["type" => "text", "text" => $prompt],
+                        ["type" => "image_url", "image_url" => ["url" => $imageUrl]]
+                    ]
+                ]
+            ],
+            "max_tokens" => 1000
+        ];
+
+        $result = $this->requestVectorEngine("/chat/completions", $data);
+        if ($result['code'] === 0) {
+            $this->success('识别成功', $result['data']);
+        } else {
+            $this->error($result['msg'], $result['data']);
+        }
+    }
+
+    /**
+     * 文生视频接口
+     * POST /api/index/textToVideo
+     * 参数: prompt (string) 提示词, duration (int) 时长(秒), resolution (string) 分辨率
+     */
+    public function textToVideo()
+    {
+        $prompt     = $this->request->post('prompt');
+        $duration   = $this->request->post('duration', 5);
+        $resolution = $this->request->post('resolution', '720p');
+
+        if (empty($prompt)) {
+            $this->error('提示词不能为空');
+        }
+
+        $data = [
+            "model"      => "kling-1.6", // 可替换为 seedance-2.0 等
+            "prompt"     => $prompt,
+            "duration"   => (int)$duration,
+            "resolution" => $resolution
+        ];
+
+        $result = $this->requestVectorEngine("/videos/generations", $data);
+        if ($result['code'] === 0) {
+            $this->success('生成成功', $result['data']);
+        } else {
+            $this->error($result['msg'], $result['data']);
+        }
+    }
+
+    /**
+     * 封装向量引擎通用CURL请求
+     */
+    private function requestVectorEngine($endpoint, $data)
+    {
+        $url = $this->baseUrl . $endpoint;
+        $ch  = curl_init();
+
+        curl_setopt_array($ch, [
+            CURLOPT_URL            => $url,
+            CURLOPT_RETURNTRANSFER => true,
+            CURLOPT_POST           => true,
+            CURLOPT_POSTFIELDS     => json_encode($data),
+            CURLOPT_HTTPHEADER     => [
+                "Content-Type: application/json",
+                "Authorization: Bearer " . $this->apiKey
+            ],
+            CURLOPT_SSL_VERIFYPEER => false, // 生产环境建议开启
+            CURLOPT_TIMEOUT        => $this->timeout
+        ]);
+
+        $response = curl_exec($ch);
+        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+        $error    = curl_error($ch);
+        curl_close($ch);
+
+        if ($error) {
+            return ['code' => -1, 'msg' => '请求失败: ' . $error, 'data' => null];
+        }
+        if ($httpCode !== 200) {
+            return ['code' => $httpCode, 'msg' => 'API返回异常', 'data' => json_decode($response, true)];
+        }
+        return ['code' => 0, 'msg' => '成功', 'data' => json_decode($response, true)];
+    }
+
+    public function GetTxtToImg(){
+        $params = $this->request->param();
+
+        $prompt = $params['prompt'];//提示词
+        $model = $params['model'];//模型
+        $size = $params['size'];//尺寸
+
+        // 调用AI生成图片
+        $aiGateway = new AIGatewayService();
+        $res = $aiGateway->callDalleApi($prompt, $model, $size);
+
+        // 提取base64图片数据
+        $imageData = '';
+        $imageType = 'png'; // 默认图片类型
+        if (isset($res['candidates'][0]['content']['parts'][0]['text'])) {
+            $text_content = $res['candidates'][0]['content']['parts'][0]['text'];
+            // 匹配base64图片数据和类型
+            if (preg_match('/data:image\/([a-zA-Z0-9]+);base64,([^\s]+)/', $text_content, $matches)) {
+                $imageType = strtolower($matches[1]);
+                $base64Data = $matches[2];
+                // 解码base64数据
+                $imageData = base64_decode($base64Data);
+            }
+        }
+
+        if (!$imageData) {
+            return json(['code' => 1, 'msg' => '图片生成失败,未找到有效图片数据']);
+        }
+
+        // 创建保存目录(public/uploads/log/YYYY-MM/)
+        $yearMonth = date('Ym');
+        $saveDir = ROOT_PATH . 'public/uploads/ceshi/' . $yearMonth . '/';
+        if (!is_dir($saveDir)) {
+            mkdir($saveDir, 0755, true);
+        }
+
+        // 生成唯一文件名
+        $fileName = uniqid() . '.' . $imageType;
+        $filePath = $saveDir . $fileName;
+
+        // 保存图片到本地文件
+        if (file_put_contents($filePath, $imageData) === false) {
+            return json(['code' => 1, 'msg' => '图片保存失败']);
+        }
+
+        // 生成前端可访问的URL
+        $imageUrl = '/uploads/ceshi/' . $yearMonth . '/' . $fileName;
+
+        // 返回标准JSON响应
+        return json([
+            'code' => 0,
+            'msg' => '图片生成成功',
+            'data' => [
+                'url' => $imageUrl
+            ]
+        ]);
+    }
+
+
+
+    /**
+     * 学生端文生视频接口 - 用于生成视频
+     */
+    public function GetTxtToVideo(){
+        $aiGateway = new AIGatewayService();
+        $apiUrl = $aiGateway->config['videos']['api_url'];
+        $apiKey = $aiGateway->config['videos']['api_key'];
+
+        // 获取并验证参数
+        $params = $this->request->param();
+        //    echo "<pre>";
+        //    print_r($params);
+        //    echo "<pre>";die;
+
+        $postData = [
+            'prompt' => $params['prompt'],
+            'model' => $params['model'],
+            'seconds' => $params['duration'],
+            'size' => $params['size'],
+        ];
+
+        // 初始化CURL
+        $ch = curl_init();
+        curl_setopt($ch, CURLOPT_URL, $apiUrl);
+        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+        curl_setopt($ch, CURLOPT_POST, true);
+        curl_setopt($ch, CURLOPT_POSTFIELDS, $postData);
+        curl_setopt($ch, CURLOPT_HTTPHEADER, [
+            'Authorization: Bearer ' . $apiKey
+        ]);
+        curl_setopt($ch, CURLOPT_TIMEOUT, 300);
+        curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
+        curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0);
+        curl_setopt($ch, CURLOPT_HEADER, true); // 获取响应头
+        curl_setopt($ch, CURLOPT_VERBOSE, true); // 启用详细输出以进行调试
+        // 创建临时文件来捕获详细的cURL输出
+        $verbose = fopen('php://temp', 'w+');
+        curl_setopt($ch, CURLOPT_STDERR, $verbose);
+        // 执行请求
+        $response = curl_exec($ch);
+        //HTTP状态码
+        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+        // 获取详细的cURL调试信息
+        rewind($verbose);
+        //CURL调试信息
+        $verboseLog = stream_get_contents($verbose);
+        fclose($verbose);
+        // 分离头部和主体
+        $header_size = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
+        //响应头部
+        $header = substr($response, 0, $header_size);
+        //响应主体
+        $body = substr($response, $header_size);
+        // 检查CURL错误
+        $curlError = curl_error($ch);
+        curl_close($ch);
+
+        $responseData = json_decode($body, true);
+
+        // 检查API是否返回了错误信息
+        if (isset($responseData['error'])) {
+            $errorMessage = isset($responseData['error']['message']) ? $responseData['error']['message'] : 'API请求失败';
+            return json([
+                'code' => 1,
+                'msg' => '视频生成请求失败',
+                'data' => [
+                    'error_type' => isset($responseData['error']['type']) ? $responseData['error']['type'] : 'unknown',
+                    'error_code' => isset($responseData['error']['code']) ? $responseData['error']['code'] : 'unknown',
+                    'error_message' => $errorMessage
+                ]
+            ]);
+        }
+
+        // 检查是否有自定义错误格式
+        if (isset($responseData['code']) && $responseData['code'] === 'fail_to_fetch_task' && isset($responseData['message'])) {
+            return json([
+                'code' => 1,
+                'msg' => '视频生成请求失败',
+                'data' => [
+                    'error_code' => $responseData['code'],
+                    'error_message' => $responseData['message']
+                ]
+            ]);
+        }
+
+        // 检查是否存在id字段
+        if (!isset($responseData['id'])) {
+            return json([
+                'code' => 1,
+                'msg' => '无法获取视频ID',
+                'data' => [
+                    'response_data' => $responseData,
+                    'http_code' => $httpCode
+                ]
+            ]);
+        }
+
+        // 1. 先检查视频状态
+        $statusUrl = 'https://chatapi.onechats.ai/v1/videos/' . $responseData['id'];
+        $statusData = $this->fetchVideoStatus($statusUrl, $apiKey);
+
+        // 检查视频状态
+        if ($statusData['status'] !== 'completed') {
+            return json([
+                'code' => 202,
+                'msg' => '视频尚未生成完成',
+                'data' => [
+                    'video_id' => $responseData['id'],
+                    'status' => $statusData['status'],
+                    'progress' => $statusData['progress'],
+                    'created_at' => $statusData['created_at'],
+                    'message' => '请稍后再试,视频仍在' . ($statusData['status'] === 'queued' ? '排队中' : '处理中')
+                ]
+            ]);
+        }
+
+        // 2. 视频生成完成,准备下载
+        $apiUrl = 'https://chatapi.onechats.ai/v1/videos/' . $responseData['id'] . '/content';
+
+        // 获取可选的variant参数
+        $variant = $this->request->get('variant', '');
+        if (!empty($variant)) {
+            $apiUrl .= '?variant=' . urlencode($variant);
+        }
+
+        // 创建保存目录
+        $saveDir = ROOT_PATH . 'public' . DS . 'uploads' . DS . 'videos' . DS . date('Ymd');
+        if (!is_dir($saveDir)) {
+            mkdir($saveDir, 0755, true);
+        }
+
+        // 生成唯一文件名
+        $filename = $responseData['id'] . '.mp4';
+        $localPath = DS . 'uploads' . DS . 'videos' . DS . date('Ymd') . DS . $filename;
+        $fullPath = $saveDir . DS . $filename;
+
+        // 3. 下载视频
+        $videoData = $this->downloadVideo($apiUrl, $apiKey);
+
+        // 4. 保存视频文件
+        if (file_put_contents($fullPath, $videoData) === false) {
+            throw new Exception('视频保存失败');
+        }
+
+        // 确保路径使用正斜杠,并只保存相对路径部分
+        $localPath = str_replace('\\', '/', $localPath);
+        // 移除开头的斜杠,确保路径格式为uploads/videos/...
+        $savePath = ltrim($localPath, '/');
+
+        // 返回成功响应
+        return json([
+            'code' => 0,
+            'msg' => '视频下载成功',
+            'data' => [
+                'video_id' => $responseData['id'],
+                'web_url' => $savePath
+            ]
+        ]);
+    }
+
+    /**
+     * 学生端视频状态查询接口 - 用于通过video_id查询视频状态
+     */
+    public function Getvideo_id(){
+        // 获取并验证参数
+        $params = $this->request->param();
+        $videoId = $params['video_id'] ?? '';
+
+        // 验证video_id参数
+        if (empty($videoId)) {
+            return json([
+                'code' => 1,
+                'msg' => '缺少必要参数:video_id',
+                'data' => []
+            ]);
+        }
+
+        $aiGateway = new AIGatewayService();
+//        $apiUrl = $aiGateway->config['videos']['api_url'];
+        $apiKey = $aiGateway->config['videos']['api_key'];
+
+        $apiUrl = 'https://chatapi.onechats.ai/v1/videos/' . $videoId;
+
+        // 先检查本地是否已经有该视频
+        $localVideoPath = ROOT_PATH . 'public' . DS . 'uploads' . DS . 'videos' . DS . date('Ymd') . DS . $videoId . '.mp4';
+        if (file_exists($localVideoPath)) {
+            // 视频已存在本地,直接返回
+            $webPath = '/uploads/videos/' . date('Ymd') . '/' . $videoId . '.mp4';
+            return json([
+                'code' => 0,
+                'msg' => '视频下载成功',
+                'data' => [
+                    'video_id' => $videoId,
+                    'web_url' => substr($webPath, 1) // 移除开头的斜杠
+                ]
+            ]);
+        }
+
+        // 检查视频状态
+        $statusData = $this->fetchVideoStatus($apiUrl, $apiKey);
+
+        // 检查视频状态
+        if ($statusData['status'] !== 'completed') {
+            return json([
+                'code' => 202,
+                'msg' => '视频尚未生成完成',
+                'data' => [
+                    'video_id' => $videoId,
+                    'status' => $statusData['status'],
+                    'progress' => isset($statusData['progress']) ? $statusData['progress'] : 0,
+                    'created_at' => $statusData['created_at'],
+                    'message' => '请稍后再试,视频仍在' . ($statusData['status'] === 'queued' ? '排队中' : '处理中')
+                ]
+            ]);
+        }
+
+        // 视频生成完成,下载视频
+        $downloadUrl = 'https://chatapi.onechats.ai/v1/videos/' . $videoId . '/content';
+
+        // 获取可选的variant参数
+        $variant = $this->request->get('variant', '');
+        if (!empty($variant)) {
+            $downloadUrl .= '?variant=' . urlencode($variant);
+        }
+
+        // 创建保存目录
+        $saveDir = ROOT_PATH . 'public' . DS . 'uploads' . DS . 'videos' . DS . date('Ymd');
+        if (!is_dir($saveDir)) {
+            mkdir($saveDir, 0755, true);
+        }
+
+        // 生成唯一文件名
+        $filename = $videoId . '.mp4';
+        $localPath = DS . 'uploads' . DS . 'videos' . DS . date('Ymd') . DS . $filename;
+        $fullPath = $saveDir . DS . $filename;
+
+        // 下载视频
+        $videoData = $this->downloadVideo($downloadUrl, $apiKey);
+
+        // 保存视频文件
+        if (file_put_contents($fullPath, $videoData) === false) {
+            throw new Exception('视频保存失败');
+        }
+
+        // 确保路径使用正斜杠,并只保存相对路径部分
+        $localPath = str_replace(DIRECTORY_SEPARATOR, '/', $localPath);
+        // 移除开头的斜杠,确保路径格式为uploads/videos/...
+        $savePath = ltrim($localPath, '/');
+
+        // 返回成功响应
+        return json([
+            'code' => 0,
+            'msg' => '视频下载成功',
+            'data' => [
+                'video_id' => $videoId,
+                'web_url' => $savePath
+            ]
+        ]);
+    }
+
+    /**
+     * 九个分镜头生成流程
+     * 模型:gemini-3-pro-image-preview
+     * 说明:
+     * 第一步:(提示词 + 原始图片 = 九个分镜头图片) 或 (提示词 = 九个分镜头图片)
+     * 第二步:使用九个分镜头图进行裁剪单图生成连贯视频
+     * 第三步:在通过分镜头视频拼接成一个完整的视频(列如每个分镜头视频为8秒,九个为72秒形成完整视频)
+     */
+    public function Get_txttonineimg()
+    {
+        // 发起接口请求
+//        $apiUrl = 'https://chatapi.onechats.ai/v1beta/models/gemini-3-pro-image-preview:generateContent';
+        $apiUrl = 'https://chatapi.onechats.ai/v1beta/models/gemini-3-pro-image-preview:streamGenerateContent';
+        $apiKey = '';
+
+//        $params = $this->request->param();
+        $prompt = '生成一个苹果(九个分镜头图片)';
+
+        $requestData = [
+            "contents" => [
+                [
+                    "role" => "user",
+                    "parts" => [
+                        ["text" => $prompt]
+                    ]
+                ]
+            ],
+            "generationConfig" => [
+                "responseModalities" => ["TEXT", "IMAGE"],
+                "imageConfig" => [
+                    "aspectRatio" => "1:1"
+                ]
+            ]
+        ];
+
+        $ch = curl_init();
+        curl_setopt($ch, CURLOPT_URL, $apiUrl);
+        curl_setopt($ch, CURLOPT_POST, true);
+        curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($requestData, JSON_UNESCAPED_UNICODE));
+        curl_setopt($ch, CURLOPT_HTTPHEADER, [
+            'Content-Type: application/json',
+            'Authorization: Bearer ' . $apiKey
+        ]);
+        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+        curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); // 开发环境临时关闭SSL验证
+        curl_setopt($ch, CURLOPT_TIMEOUT, 60); // 生成图片超时时间(建议60秒)
+
+        $response = curl_exec($ch);
+        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+
+        $error = curl_error($ch);
+        curl_close($ch);
+        $res = json_decode($response, true);
+
+        // 构建URL路径(使用正斜杠)
+        $url_path = '/uploads/txtnewimg/';
+        // 构建物理路径(使用正斜杠确保统一格式)
+        $save_path = ROOT_PATH . 'public' . '/' . 'uploads' . '/' . 'txtnewimg'  . '/';
+        // 移除ROOT_PATH中可能存在的反斜杠,确保统一使用正斜杠
+        $save_path = str_replace('\\', '/', $save_path);
+        // 自动创建文件夹(如果不存在)
+        if (!is_dir($save_path)) {
+            mkdir($save_path, 0755, true);
+        }
+
+        // 提取base64图片数据
+        $text_content = $res['candidates'][0]['content']['parts'][0]['inlineData']['data'];
+        $str = 'data:image/jpeg;base64,';
+        $text_content = $str. $text_content;
+        // 匹配base64图片数据
+        preg_match('/data:image\/(png|jpg|jpeg);base64,([^"]+)/', $text_content, $matches);
+        if (empty($matches)) {
+            return '未找到图片数据';
+        }
+        $image_type = $matches[1];
+        $base64_data = $matches[2];
+
+        // 解码base64数据
+        $image_data = base64_decode($base64_data);
+        if ($image_data === false) {
+            return '图片解码失败';
+        }
+
+        // 生成唯一文件名(包含扩展名)
+        $file_name = uniqid() . '.' . $image_type;
+        $full_file_path = $save_path . $file_name;
+
+        // 保存图片到文件系统
+        if (!file_put_contents($full_file_path, $image_data)) {
+            return '图片保存失败';
+        }
+        // 生成数据库存储路径(使用正斜杠格式)
+        $db_img_path = $url_path . $file_name;
+        return  $db_img_path;
+    }
 
 }

+ 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);
         }
     }
-}
+}

+ 1 - 1
application/api/controller/OrderSuperLoss.php

@@ -9,7 +9,7 @@ use think\Cache;
 use \think\Request;
 use \think\Db;
 /**
- * 工单超节损核算接口
+ *
  */
 class OrderSuperLoss extends Api
 {

+ 49 - 17
application/api/controller/Product.php

@@ -11,6 +11,14 @@ class Product extends Api
     protected $noNeedLogin = ['*'];
     protected $noNeedRight = ['*'];
 
+    /**
+     * 本地文件同步到 OSS(配置齐全时);失败不影响主流程
+     */
+    protected function maybeUploadToOss($localFullPath, $objectKey)
+    {
+        return Common::uploadLocalFileToOss((string)$localFullPath, (string)$objectKey);
+    }
+
     /**
      * 商户菜单
      * @return void
@@ -139,6 +147,11 @@ class Product extends Api
             unset($item); // 解除引用
         }
 
+        foreach ($list as &$item) {
+                $item['产品图片'] = Common::ossFullUrl((string)$item['产品图片']);
+                $item['产品效果图'] = Common::ossFullUrl((string)$item['产品效果图']);
+        }
+        unset($item);
         $result = [
             'list' => $list,
             'total' => $total,
@@ -157,26 +170,17 @@ class Product extends Api
      */
     public function productDetail()
     {
-        // 1. 请求方法验证
         if (!$this->request->isGet()) {
             $this->error('只支持GET请求');
         }
-
-        // 2. 参数获取与验证
         $param = $this->request->param();
-
         if (empty($param['id']) || !is_numeric($param['id'])) {
             $this->error('产品ID参数错误');
         }
-
-        // 3. 参数安全处理
         $productId = intval($param['id']);
-
         if ($productId <= 0) {
             $this->error('产品ID必须为正整数');
         }
-
-        // 4. 查询数据
         try {
             $product = \db('product')
                 ->field([
@@ -190,13 +194,11 @@ class Product extends Api
                 ])
                 ->where('id', $productId)
                 ->whereNull('deleteTime')
+                ->order('id desc')
                 ->find();
-
         } catch (\Exception $e) {
             $this->error('查询产品详情失败:' . $e->getMessage());
         }
-
-        // 5. 检查查询结果
         if (empty($product)) {
             $this->error('产品不存在或已被删除');
         }
@@ -226,12 +228,39 @@ class Product extends Api
         // 7. 移除原始图片字段,保持返回数据整洁
         unset($product['product_img'], $product['product_new_img']);
 
-        $product_image = \db('product_image')
-            ->where('product_id', $productId)
-            ->whereNull('mod_rq')
-            ->order('id desc')
+        $product_image = \db('product_image')->alias('a')
+            ->field('
+                a.id,
+                a.product_id,
+                a.template_id,
+                a.product_new_img,
+                a.product_content,
+                a.createTime,
+                b.thumbnail_image,
+                b.template_name
+            ')
+            ->join('product_template b', 'a.template_id = b.id', 'LEFT')
+            ->where('a.product_id', $productId)
+            ->whereNull('a.mod_rq')
+            ->order('a.id desc')
             ->select();
 
+        foreach ($product_image as &$item) {
+            if (!empty($item['product_new_img']) || !empty($item['thumbnail_image'])) {
+                $item['product_new_img'] = Common::ossFullUrl((string)$item['product_new_img']);
+                $item['thumbnail_image'] = Common::ossFullUrl((string)$item['thumbnail_image']);
+            }
+        }
+        unset($item);
+
+        // $product 是单条记录,不是列表,直接处理
+        if (!empty($product['产品图片'])) {
+            $product['产品图片'] = Common::ossFullUrl((string)$product['产品图片']);
+        }
+        if (!empty($product['产品效果图'])) {
+            $product['产品效果图'] = Common::ossFullUrl((string)$product['产品效果图']);
+        }
+
         return json([
             'code' => 0,
             'msg' => '获取产品详情成功',
@@ -298,6 +327,8 @@ class Product extends Api
                     }
                     if (file_put_contents($saveDir . $fileName, $imageData)) {
                         $productImgPath = 'uploads/merchant/' . $prefix . '/' . $productCode . '/' . 'oldimg/'. $fileName;
+                        // 本地保存成功后,尝试同步 OSS(失败不阻塞新增)
+                        $this->maybeUploadToOss($saveDir . $fileName, $productImgPath);
                     }
                 }
             }
@@ -430,11 +461,12 @@ class Product extends Api
     }
 
     /**
-     * 产品删除(软删除,设置 deleteTime)
+     * 产品删除
      */
     public function Product_Del()
     {
         $params = $this->request->param();
+
         $record['deleteTime'] = date('Y-m-d H:i:s');
         $res = Db::name('product')->where('id', $params['id'])->update($record);
         if (!$res) {

文件差异内容过多而无法显示
+ 416 - 552
application/api/controller/WorkOrder.php


部分文件因为文件数量过多而无法显示