Kaynağa Gözat

图片上传配置

liuchengsen 1 ay önce
ebeveyn
işleme
2b95fc68b2

+ 8 - 0
pom.xml

@@ -54,6 +54,7 @@
         <bouncycastle.version>1.84</bouncycastle.version>
         <alipay-sdk.version>4.40.865.ALL</alipay-sdk.version>
         <wxjava.version>4.7.0</wxjava.version>
+        <aliyun-oss.version>3.18.1</aliyun-oss.version>
     </properties>
 
     <!-- 依赖版本管理(子模块按需引入,无需写版本号) -->
@@ -181,6 +182,13 @@
                 <artifactId>weixin-java-pay</artifactId>
                 <version>${wxjava.version}</version>
             </dependency>
+
+            <!-- 阿里云OSS -->
+            <dependency>
+                <groupId>com.aliyun.oss</groupId>
+                <artifactId>aliyun-sdk-oss</artifactId>
+                <version>${aliyun-oss.version}</version>
+            </dependency>
         </dependencies>
     </dependencyManagement>
 

+ 9 - 0
zhijiayun-gateway/src/main/resources/application-prod.yml

@@ -66,6 +66,15 @@ sms:
   expire-minutes: 5
   endpoint: dysmsapi.aliyuncs.com
 
+# 阿里云OSS配置(生产环境)
+oss:
+  endpoint: ${OSS_ENDPOINT:oss-cn-hangzhou.aliyuncs.com}
+  access-key-id: ${ALIBABA_CLOUD_ACCESS_KEY_ID:}
+  access-key-secret: ${ALIBABA_CLOUD_ACCESS_KEY_SECRET:}
+  bucket-name: ${OSS_BUCKET_NAME:zhijiayun-pharmacy}
+  base-path: ${OSS_BASE_PATH:uploads/}
+  url-prefix: ${OSS_URL_PREFIX:}
+
 # 生产环境日志:只记录 warn 及以上,减少性能开销
 logging:
   level:

+ 10 - 0
zhijiayun-gateway/src/main/resources/application.yml

@@ -67,6 +67,16 @@ sms:
   expire-minutes: 5
   endpoint: dysmsapi.aliyuncs.com
 
+# 阿里云OSS配置
+oss:
+  endpoint: ${OSS_ENDPOINT:oss-cn-hangzhou.aliyuncs.com}
+  access-key-id: ${ALIBABA_CLOUD_ACCESS_KEY_ID:LTAI5tDwjfteBvivYN41r8sJ}
+  access-key-secret: ${ALIBABA_CLOUD_ACCESS_KEY_SECRET:yowuOGi2nYYnrqGpO3qcz94C4brcPp}
+  bucket-name: ${OSS_BUCKET_NAME:zhijiayun-pharmacy}
+  base-path: ${OSS_BASE_PATH:uploads/}
+  # 访问URL前缀(如绑定了自定义域名,可覆盖;默认使用OSS公网域名)
+  url-prefix: ${OSS_URL_PREFIX:}
+
 # MyBatis-Plus 配置
 mybatis-plus:
   configuration:

+ 46 - 3
zhijiayun-payment/src/main/java/com/xuekairui/payment/config/PaymentConfig.java

@@ -18,6 +18,9 @@ import java.io.InputStream;
 import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
 
 /**
  * 支付模块配置
@@ -33,6 +36,9 @@ public class PaymentConfig {
 
     private final PaymentProperties paymentProperties;
 
+    /** 已提取到临时文件的证书缓存(避免重复提取) */
+    private static final Map<String, String> TEMP_CERT_CACHE = new ConcurrentHashMap<>();
+
     // ======================== 工具方法 ========================
 
     /**
@@ -64,23 +70,60 @@ public class PaymentConfig {
 
     /**
      * 解析证书文件路径为文件系统绝对路径
-     * <p>优先从 classpath 解析(IDE 模式返回 target/classes 下的路径),
-     * 找不到则返回原始路径(适用于外部挂载证书的生产环境)
+     * <ul>
+     *   <li>文件系统优先:先检查原始路径是否在磁盘上存在(支持JAR外部挂载证书)</li>
+     *   <li>classpath 降级:文件系统找不到时,从 classpath 解析
+     *     <ul><li>IDE 环境:返回 target/classes 下的绝对路径</li>
+     *     <li>JAR 环境:提取到临时文件并缓存</li></ul>
+     *   </li>
+     * </ul>
      */
     private String resolveFilePath(String path) {
         if (path == null || path.isBlank()) {
             return path;
         }
 
+        // 1. 文件系统优先:检查原始路径是否直接存在(外部挂载证书)
+        if (Files.exists(Path.of(path))) {
+            log.info("证书文件在磁盘上找到: {}", path);
+            return path;
+        }
+
+        // 2. 检查缓存(JAR运行中已提取过)
+        String cached = TEMP_CERT_CACHE.get(path);
+        if (cached != null && Files.exists(Path.of(cached))) {
+            return cached;
+        }
+
+        // 3. classpath 降级
         ClassPathResource resource = new ClassPathResource(path);
         if (resource.exists()) {
             try {
+                // IDE 环境:直接返回 classpath 下的文件绝对路径
                 return resource.getFile().getAbsolutePath();
             } catch (Exception e) {
-                log.warn("从 classpath 解析文件路径失败(可能运行在JAR中): {}, 使用原始路径", path);
+                // JAR 环境:无法直接获取 File,提取到临时文件
+                log.info("运行在JAR中,正在将证书提取到临时文件: {}", path);
+                try (InputStream is = resource.getInputStream()) {
+                    String suffix = path.contains(".")
+                            ? path.substring(path.lastIndexOf("."))
+                            : ".tmp";
+                    Path tempFile = Files.createTempFile("cert-", suffix);
+                    tempFile.toFile().deleteOnExit();
+                    Files.copy(is, tempFile, StandardCopyOption.REPLACE_EXISTING);
+                    String tempPath = tempFile.toAbsolutePath().toString();
+                    TEMP_CERT_CACHE.put(path, tempPath);
+                    log.info("证书已提取到临时文件: {}", tempPath);
+                    return tempPath;
+                } catch (Exception ex) {
+                    throw new RuntimeException("无法从JAR中提取证书文件: " + path
+                            + "。请确保证书文件在 classpath 中(如 certs/prod/alipay/)"
+                            + " 或通过绝对路径指向外部证书", ex);
+                }
             }
         }
 
+        // 4. 都不存在,返回原始路径(由调用方 SDK 自行报错)
         return path;
     }
 

+ 46 - 2
zhijiayun-payment/src/main/java/com/xuekairui/payment/controller/PaymentCallbackController.java

@@ -17,8 +17,13 @@ import org.springframework.web.bind.annotation.*;
 import org.springframework.core.io.ClassPathResource;
 
 import java.io.BufferedReader;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
 import java.util.HashMap;
 import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
 
 /**
  * 支付回调控制器(接收微信/支付宝付款通知)
@@ -41,6 +46,9 @@ public class PaymentCallbackController {
     private final PaymentProperties paymentProperties;
     private final ObjectProvider<WxPayService> wxPayServiceProvider;
 
+    /** 已提取到临时文件的证书缓存(避免每次回调都重复提取) */
+    private static final Map<String, String> TEMP_CERT_CACHE = new ConcurrentHashMap<>();
+
     // ======================== 微信支付回调 ========================
 
     /**
@@ -294,16 +302,52 @@ public class PaymentCallbackController {
     }
 
     /**
-     * 解析证书文件路径为文件系统绝对路径(优先从 classpath 解析)
+     * 解析证书文件路径为文件系统绝对路径
+     * <ul>
+     *   <li>文件系统优先:先检查原始路径是否在磁盘上存在(支持JAR外部挂载证书)</li>
+     *   <li>classpath 降级:文件系统找不到时,从 classpath 解析
+     *     <ul><li>IDE 环境:返回 target/classes 下的绝对路径</li>
+     *     <li>JAR 环境:提取到临时文件并缓存</li></ul>
+     *   </li>
+     * </ul>
      */
     private String resolveFilePath(String path) {
         if (path == null || path.isBlank()) return path;
+
+        // 1. 文件系统优先:检查原始路径是否直接存在(外部挂载证书)
+        if (Files.exists(Path.of(path))) {
+            return path;
+        }
+
+        // 2. 检查缓存
+        String cached = TEMP_CERT_CACHE.get(path);
+        if (cached != null && Files.exists(Path.of(cached))) {
+            return cached;
+        }
+
+        // 3. classpath 降级
         ClassPathResource resource = new ClassPathResource(path);
         if (resource.exists()) {
             try {
                 return resource.getFile().getAbsolutePath();
             } catch (Exception e) {
-                log.warn("从 classpath 解析文件路径失败: {}, 使用原始路径", path);
+                // JAR 环境:提取到临时文件
+                log.info("运行在JAR中,正在将证书提取到临时文件: {}", path);
+                try (InputStream is = resource.getInputStream()) {
+                    String suffix = path.contains(".")
+                            ? path.substring(path.lastIndexOf("."))
+                            : ".tmp";
+                    Path tempFile = Files.createTempFile("cert-", suffix);
+                    tempFile.toFile().deleteOnExit();
+                    Files.copy(is, tempFile, StandardCopyOption.REPLACE_EXISTING);
+                    String tempPath = tempFile.toAbsolutePath().toString();
+                    TEMP_CERT_CACHE.put(path, tempPath);
+                    log.info("证书已提取到临时文件: {}", tempPath);
+                    return tempPath;
+                } catch (Exception ex) {
+                    throw new RuntimeException("无法从JAR中提取证书文件: " + path
+                            + "。请确保证书文件在 classpath 中(如 certs/prod/alipay/)", ex);
+                }
             }
         }
         return path;

+ 65 - 2
zhijiayun-test/src/main/resources/static/invite-test.html

@@ -587,6 +587,46 @@ async function uploadFile() {
     }
 }
 
+async function uploadFileToOss() {
+    const fileInput = document.getElementById('upload-oss-file');
+    const statusEl = document.getElementById('upload-oss-status');
+    const rawEl = document.getElementById('upload-oss-raw');
+    const durEl = document.getElementById('upload-oss-duration');
+    const box = document.getElementById('upload-oss-result-box');
+
+    if (!fileInput || !fileInput.files || !fileInput.files[0]) {
+        alert('请先选择文件'); return;
+    }
+    const formData = new FormData();
+    formData.append('file', fileInput.files[0]);
+
+    updateBadge(statusEl, 'pending', '上传中...');
+    box.style.display = 'block';
+    const startTime = Date.now();
+
+    try {
+        const headers = {};
+        if (TOKEN()) { headers['Authorization'] = 'Bearer ' + TOKEN(); }
+        const res = await fetch(BASE_URL() + '/api/upload/oss', { method: 'POST', headers, body: formData });
+        const text = await res.text();
+        const duration = Date.now() - startTime;
+        let data;
+        try { data = safeJsonParse(text); } catch (e) { data = { _raw: text }; }
+        if (durEl) durEl.textContent = duration + 'ms';
+        if (rawEl) rawEl.textContent = JSON.stringify(data, null, 2);
+        const passed = res.ok && data?.code === 200;
+        updateBadge(statusEl, passed ? 'success' : 'error', passed ? '通过 ' + res.status : '失败 ' + res.status);
+        if (!stats.hasOwnProperty('upload-oss-file')) {
+            stats.total++;
+            document.getElementById('totalCount').textContent = stats.total;
+        }
+        updateStats('upload-oss-file', passed);
+    } catch (e) {
+        if (rawEl) rawEl.textContent = 'Error: ' + e.message;
+        updateBadge(statusEl, 'error', '失败');
+    }
+}
+
 // ===================== 侧切换 =====================
 function switchSide(side) {
     activeSide = side;
@@ -1438,7 +1478,7 @@ function renderUploadModule() {
             <span class="side-tag side-user">用户侧</span>
             <span class="auth-required auth-tag">需认证</span>
         </div>
-        <div class="section-desc">上传单个文件,返回文件访问路径。使用 multipart/form-data 格式,字段名: file</div>
+        <div class="section-desc">上传单个文件到本地存储,返回文件访问路径。使用 multipart/form-data 格式,字段名: file</div>
         <div class="form-grid">
             <div class="form-group">
                 <label>选择文件</label>
@@ -1453,6 +1493,29 @@ function renderUploadModule() {
             </div>
             <div class="response-content" id="upload-raw"></div>
         </div>
+    </div>
+    <div class="test-section">
+        <div class="section-title">
+            <span class="method method-post">POST</span>
+            <span class="path">/api/upload/oss</span>
+            <span class="side-tag side-user">用户侧</span>
+            <span class="auth-required auth-tag">需认证</span>
+        </div>
+        <div class="section-desc">上传单个文件到阿里云 OSS,返回公网访问 URL。使用 multipart/form-data 格式,字段名: file</div>
+        <div class="form-grid">
+            <div class="form-group">
+                <label>选择文件</label>
+                <input type="file" id="upload-oss-file" accept="image/*,.pdf,.doc,.docx" style="padding:8px;">
+            </div>
+        </div>
+        <button class="btn btn-primary" onclick="uploadFileToOss()">上传到 OSS</button>
+        <div class="response-box" id="upload-oss-result-box" style="display:none;">
+            <div class="response-header">
+                <span class="response-title">响应 <span id="upload-oss-duration" style="color:#999;font-weight:400;"></span></span>
+                <span class="status-badge" id="upload-oss-status"></span>
+            </div>
+            <div class="response-content" id="upload-oss-raw"></div>
+        </div>
     </div>`;
 }
 
@@ -1893,7 +1956,7 @@ function runInviteTests() { runBatchTests('invite'); }
 function runCouponTests() { runBatchTests('coupon'); }
 function runMembershipTests() { runBatchTests('membership'); }
 function runPaymentTests() { runBatchTests('payment'); }
-function runUploadTests() { uploadFile(); }
+function runUploadTests() { uploadFile(); uploadFileToOss(); }
 function runSearchTests() { runBatchTests('search'); }
 function runPlatformAccountTests() { runBatchTests('pa'); }
 function runBusinessLicenseTests() { runBatchTests('bl'); }

+ 6 - 0
zhijiayun-user/pom.xml

@@ -77,6 +77,12 @@
             <artifactId>dysmsapi20170525</artifactId>
         </dependency>
 
+        <!-- 阿里云OSS -->
+        <dependency>
+            <groupId>com.aliyun.oss</groupId>
+            <artifactId>aliyun-sdk-oss</artifactId>
+        </dependency>
+
         <!-- Hutool (微信API) -->
         <dependency>
             <groupId>cn.hutool</groupId>

+ 32 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/config/OssProperties.java

@@ -0,0 +1,32 @@
+package com.xuekairui.user.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+/**
+ * 阿里云 OSS 对象存储配置属性
+ */
+@Data
+@Component
+@ConfigurationProperties(prefix = "oss")
+public class OssProperties {
+
+    /** OSS 地域节点(如 oss-cn-hangzhou.aliyuncs.com) */
+    private String endpoint = "oss-cn-hangzhou.aliyuncs.com";
+
+    /** 阿里云 AccessKey ID */
+    private String accessKeyId;
+
+    /** 阿里云 AccessKey Secret */
+    private String accessKeySecret;
+
+    /** Bucket 名称 */
+    private String bucketName;
+
+    /** OSS 存储基础路径(如 uploads/) */
+    private String basePath = "uploads/";
+
+    /** 访问URL前缀(绑定了自定义域名时可设置,为空则使用 OSS 公网域名) */
+    private String urlPrefix = "";
+}

+ 26 - 3
zhijiayun-user/src/main/java/com/xuekairui/user/controller/FileUploadController.java

@@ -1,6 +1,8 @@
 package com.xuekairui.user.controller;
 
 import com.xuekairui.common.Result;
+import com.xuekairui.user.service.OssService;
+import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.web.bind.annotation.*;
@@ -14,20 +16,25 @@ import java.util.UUID;
 
 /**
  * 通用文件上传控制器
- * 所有文件存储在 /opt/uploads 目录下
+ * <ul>
+ *   <li>POST /api/upload — 本地存储(兼容旧版)</li>
+ *   <li>POST /api/upload/oss — 阿里云 OSS 存储,返回公网 URL</li>
+ * </ul>
  */
 @Slf4j
 @RestController
 @RequestMapping("/api/upload")
+@RequiredArgsConstructor
 public class FileUploadController {
 
+    private final OssService ossService;
+
     @Value("${upload.path:/opt/uploads}")
     private String uploadPath;
 
     /**
-     * 上传单个文件
+     * 上传单个文件(本地存储)
      * POST /api/upload
-     * Header: Authorization: Bearer {token}
      * Body: multipart/form-data, field: file
      *
      * @return 文件访问相对路径
@@ -63,4 +70,20 @@ public class FileUploadController {
         String fileUrl = "/opt/uploads/" + fileName;
         return Result.success(fileUrl);
     }
+
+    /**
+     * 上传文件到阿里云 OSS
+     * POST /api/upload/oss
+     * Body: multipart/form-data, field: file
+     *
+     * @return 可公开访问的 OSS 文件 URL
+     */
+    @PostMapping("/oss")
+    public Result<String> uploadToOss(@RequestParam("file") MultipartFile file) {
+        if (file.isEmpty()) {
+            return Result.error(400, "文件不能为空");
+        }
+        String fileUrl = ossService.upload(file);
+        return Result.success(fileUrl);
+    }
 }

+ 145 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/service/OssService.java

@@ -0,0 +1,145 @@
+package com.xuekairui.user.service;
+
+import com.aliyun.oss.OSS;
+import com.aliyun.oss.OSSClientBuilder;
+import com.aliyun.oss.model.PutObjectRequest;
+import com.xuekairui.common.BusinessException;
+import com.xuekairui.common.ErrorCode;
+import com.xuekairui.user.config.OssProperties;
+import jakarta.annotation.PostConstruct;
+import jakarta.annotation.PreDestroy;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.UUID;
+
+/**
+ * 阿里云 OSS 文件上传服务
+ * <p>
+ * 支持将 MultipartFile 上传到 OSS,返回可访问的文件 URL。
+ * 使用 UUID 生成唯一文件名,按日期分目录存储。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class OssService {
+
+    private final OssProperties ossProperties;
+
+    private OSS ossClient;
+
+    @PostConstruct
+    public void init() {
+        if (ossProperties.getAccessKeyId() == null || ossProperties.getAccessKeyId().isBlank()) {
+            log.warn("OSS 未配置 access-key-id,跳过初始化");
+            return;
+        }
+        if (ossProperties.getBucketName() == null || ossProperties.getBucketName().isBlank()) {
+            log.warn("OSS 未配置 bucket-name,跳过初始化");
+            return;
+        }
+        ossClient = new OSSClientBuilder().build(
+                ossProperties.getEndpoint(),
+                ossProperties.getAccessKeyId(),
+                ossProperties.getAccessKeySecret());
+        log.info("OSS 客户端初始化完成: endpoint={}, bucket={}",
+                ossProperties.getEndpoint(), ossProperties.getBucketName());
+    }
+
+    @PreDestroy
+    public void destroy() {
+        if (ossClient != null) {
+            ossClient.shutdown();
+            log.info("OSS 客户端已关闭");
+        }
+    }
+
+    /**
+     * 上传文件到 OSS 并返回可访问 URL
+     *
+     * @param file 上传的文件
+     * @return 可公开访问的文件 URL
+     */
+    public String upload(MultipartFile file) {
+        ensureReady();
+
+        // 获取原始文件名扩展名
+        String originalFilename = file.getOriginalFilename();
+        String extension = "";
+        if (originalFilename != null && originalFilename.contains(".")) {
+            extension = originalFilename.substring(originalFilename.lastIndexOf("."));
+        }
+
+        // 生成唯一文件名:basePath/日期/UUID.扩展名
+        String datePath = java.time.LocalDate.now().toString().replace("-", "/");
+        String fileName = UUID.randomUUID().toString().replace("-", "") + extension;
+        String objectKey = normalizeBasePath() + datePath + "/" + fileName;
+
+        try (InputStream inputStream = file.getInputStream()) {
+            PutObjectRequest putRequest = new PutObjectRequest(
+                    ossProperties.getBucketName(), objectKey, inputStream);
+            // 不设置 Content-Type,OSS 会自动根据后缀推断
+
+            ossClient.putObject(putRequest);
+
+            String fileUrl = buildAccessUrl(objectKey);
+            log.info("OSS 上传成功: objectKey={}, size={} bytes, url={}",
+                    objectKey, file.getSize(), fileUrl);
+            return fileUrl;
+        } catch (IOException e) {
+            log.error("OSS 上传失败: filename={}", originalFilename, e);
+            throw new BusinessException(ErrorCode.BUSINESS_ERROR, "文件上传失败,请稍后重试");
+        }
+    }
+
+    /**
+     * 确保 OSS 客户端可用
+     */
+    private void ensureReady() {
+        if (ossClient == null) {
+            throw new BusinessException(ErrorCode.BUSINESS_ERROR, "OSS 服务未配置,请联系管理员");
+        }
+    }
+
+    /**
+     * 规范化 basePath,确保以 /结尾,不以 / 开头
+     */
+    private String normalizeBasePath() {
+        String path = ossProperties.getBasePath();
+        if (path == null || path.isBlank()) {
+            return "";
+        }
+        path = path.trim();
+        // 去掉开头的 /
+        if (path.startsWith("/")) {
+            path = path.substring(1);
+        }
+        // 确保以 / 结尾
+        if (!path.endsWith("/")) {
+            path = path + "/";
+        }
+        return path;
+    }
+
+    /**
+     * 构建可访问的 URL
+     */
+    private String buildAccessUrl(String objectKey) {
+        String urlPrefix = ossProperties.getUrlPrefix();
+        if (urlPrefix != null && !urlPrefix.isBlank()) {
+            // 自定义域名
+            String prefix = urlPrefix.trim();
+            if (prefix.endsWith("/")) {
+                prefix = prefix.substring(0, prefix.length() - 1);
+            }
+            return prefix + "/" + objectKey;
+        }
+        // 默认 OSS 公网 URL: https://{bucket}.{endpoint}/{objectKey}
+        return "https://" + ossProperties.getBucketName() + "."
+                + ossProperties.getEndpoint() + "/" + objectKey;
+    }
+}