SpringBoot + Vue 集成阿里云OSS直传最佳实践

SpringBoot + Vue 集成阿里云OSS直传最佳实践

前言

本文档基于实际项目开发经验,总结了SpringBoot后端与Vue前端集成阿里云OSS直传的完整解决方案,包括常见问题的排查和解决方法。

错误原因分析

在本次实践中,我们遇到了以下几个关键错误:

1. CORS跨域问题

错误现象Access to fetch at 'https://ruimei-avatar.oss-cn-beijing.aliyuncs.com/' from origin 'http://localhost:9555' has been blocked by CORS policy

根本原因:阿里云OSS默认不允许跨域请求,需要在OSS控制台配置CORS规则。

解决方案

  • 登录阿里云OSS控制台
  • 进入对应Bucket的权限管理 → 跨域设置
  • 添加跨域规则:
    • 来源:http://localhost:9555(开发环境)
    • 允许Methods:GET, POST, PUT, HEAD
    • 允许Headers:*
    • 暴露Headers:ETag, x-oss-request-id
    • 缓存时间:3600

2. Policy时间格式问题

错误现象AccessDenied: Policy not yet valid

根本原因:后端生成的时间格式没有使用UTC时区,导致与OSS服务器时间不一致。

解决方案:所有时间格式化都必须使用UTC时区

java 复制代码
SimpleDateFormat dateFormat = new SimpleDateFormat("yyyyMMdd");
dateFormat.setTimeZone(java.util.TimeZone.getTimeZone("UTC"));

SimpleDateFormat expirationFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'");
expirationFormat.setTimeZone(java.util.TimeZone.getTimeZone("UTC"));

3. 签名字段映射错误

错误现象SignatureDoesNotMatch: SignatureProvided: undefined

根本原因:前端使用的字段名与后端返回的字段名不匹配。

解决方案

  • 后端返回字段:signature
  • 前端使用字段:signatureData.signature(不是signatureData['x-oss-signature']

4. OSS V4签名算法错误

错误现象:签名验证失败

根本原因:使用了简单的HMAC-SHA256,而不是OSS V4要求的多层签名算法。

解决方案:实现完整的OSS V4签名算法

java 复制代码
// 多层HMAC-SHA256签名
byte[] kDate = hmacsha256(("aliyun_v4" + secretAccessKey).getBytes(), dateStr);
byte[] kRegion = hmacsha256(kDate, region);
byte[] kService = hmacsha256(kRegion, "oss");
byte[] kSigning = hmacsha256(kService, "aliyun_v4_request");
byte[] signature = hmacsha256(kSigning, policyBase64);

为什么Demo项目不需要设置跨域?

通过分析demo项目代码发现,demo项目实际上也需要设置跨域,只是可能在以下情况下没有遇到跨域问题:

  1. 同域部署:如果demo的前后端部署在同一域名下,就不存在跨域问题
  2. 代理转发:使用了nginx等代理服务器转发请求
  3. 已配置CORS:OSS Bucket已经配置了对应的CORS规则
  4. 浏览器环境:某些浏览器或开发环境可能禁用了CORS检查

重要提醒:任何从浏览器直接向OSS发起的请求都会涉及跨域问题,必须在OSS控制台配置CORS规则。

完整实现方案

后端实现(SpringBoot)

1. OSS配置类
java 复制代码
@Data
@Component
@ConfigurationProperties(prefix = "oss")
public class OssProperties {
    private String endpoint;
    private String accessKeyId;
    private String accessKeySecret;
    private String bucketName;
    private String region;
    private Long expireTime = 3600L; // 默认1小时
}
2. 签名生成接口
java 复制代码
@RestController
@RequestMapping("/admin/file")
public class FileController {
    
    @Autowired
    private OssProperties ossProperties;
    
    @PostMapping("/oss/signature")
    public ResultBean<Map<String, Object>> getOssSignature() {
        try {
            // 设置过期时间(当前时间 + 配置的过期时间)
            long expireEndTime = System.currentTimeMillis() + ossProperties.getExpireTime() * 1000;
            Date expiration = new Date(expireEndTime);
            
            // 生成签名所需的日期 - 使用UTC时区
            SimpleDateFormat dateFormat = new SimpleDateFormat("yyyyMMdd");
            dateFormat.setTimeZone(java.util.TimeZone.getTimeZone("UTC"));
            String dateStr = dateFormat.format(new Date());
            
            SimpleDateFormat dateTimeFormat = new SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'");
            dateTimeFormat.setTimeZone(java.util.TimeZone.getTimeZone("UTC"));
            String dateTimeStr = dateTimeFormat.format(new Date());
            
            // 构建x-oss-credential
            String x_oss_credential = ossProperties.getAccessKeyId() + "/" + dateStr + "/" + ossProperties.getRegion() + "/oss/aliyun_v4_request";
            
            // 构建policy - 使用UTC时区格式化过期时间
            Map<String, Object> policyMap = new HashMap<>();
            SimpleDateFormat expirationFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'");
            expirationFormat.setTimeZone(java.util.TimeZone.getTimeZone("UTC"));
            policyMap.put("expiration", expirationFormat.format(expiration));
            
            List<Object> conditions = new ArrayList<>();
            conditions.add(Map.of("bucket", ossProperties.getBucketName()));
            conditions.add(Map.of("x-oss-signature-version", "OSS4-HMAC-SHA256"));
            conditions.add(Map.of("x-oss-credential", x_oss_credential));
            conditions.add(Map.of("x-oss-date", dateTimeStr));
            conditions.add(Arrays.asList("content-length-range", 1, 52428800)); // 50MB
            conditions.add(Arrays.asList("eq", "$success_action_status", "200"));
            conditions.add(Arrays.asList("starts-with", "$key", "uploads/"));
            conditions.add(Arrays.asList("starts-with", "$Content-Type", ""));
            
            policyMap.put("conditions", conditions);
            
            // 将policy转换为JSON并进行Base64编码
            String policyJson = new ObjectMapper().writeValueAsString(policyMap);
            String policyBase64 = Base64.encodeBase64String(policyJson.getBytes("UTF-8"));
            
            // 生成OSS V4签名
            String signature = generateOssV4Signature(policyBase64, dateStr, ossProperties.getRegion(), ossProperties.getAccessKeySecret());
            
            // 构建返回结果
            Map<String, Object> result = new HashMap<>();
            result.put("OSSAccessKeyId", ossProperties.getAccessKeyId());
            result.put("policy", policyBase64);
            result.put("signature", signature);
            result.put("host", "https://" + ossProperties.getBucketName() + "." + ossProperties.getEndpoint());
            result.put("expire", expireEndTime / 1000);
            result.put("x-oss-signature-version", "OSS4-HMAC-SHA256");
            result.put("x-oss-credential", x_oss_credential);
            result.put("x-oss-date", dateTimeStr);
            result.put("success_action_status", "200");
            result.put("dir", "uploads/");
            
            return ResultBean.success(result);
        } catch (Exception e) {
            throw new ApiException("生成OSS签名失败: " + e.getMessage());
        }
    }
    
    /**
     * 生成OSS V4签名
     */
    private String generateOssV4Signature(String policyBase64, String dateStr, String region, String secretAccessKey) throws Exception {
        // OSS V4签名算法:多层HMAC-SHA256
        byte[] kDate = hmacsha256(("aliyun_v4" + secretAccessKey).getBytes(), dateStr);
        byte[] kRegion = hmacsha256(kDate, region);
        byte[] kService = hmacsha256(kRegion, "oss");
        byte[] kSigning = hmacsha256(kService, "aliyun_v4_request");
        byte[] signature = hmacsha256(kSigning, policyBase64);
        
        return Hex.encodeHexString(signature);
    }
    
    /**
     * HMAC-SHA256计算(支持字符串参数)
     */
    private byte[] hmacsha256(byte[] key, String data) throws Exception {
        return hmacsha256(key, data.getBytes("UTF-8"));
    }
    
    /**
     * HMAC-SHA256计算
     */
    private byte[] hmacsha256(byte[] key, byte[] data) throws Exception {
        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(key, "HmacSHA256"));
        return mac.doFinal(data);
    }
}

前端实现(Vue)

1. OSS上传工具类
javascript 复制代码
// utils/ossUpload.js
import request from '@/utils/request'

export default {
  /**
   * 获取OSS直传签名
   */
  async getOssSignature() {
    try {
      const response = await request({
        url: '/admin/file/oss/signature',
        method: 'post'
      })
      if (response.code === 200) {
        return response.data
      } else {
        throw new Error(response.message || '获取OSS签名失败')
      }
    } catch (error) {
      console.error('获取OSS签名失败:', error)
      throw error
    }
  },

  /**
   * 直传文件到OSS
   */
  async uploadToOss(file, brandId, shopId, module) {
    try {
      // 获取签名信息
      const signatureData = await this.getOssSignature()
      
      // 生成文件名
      const timestamp = Date.now()
      const randomStr = Math.random().toString(36).substring(2, 8)
      const fileExtension = file.name.split('.').pop()
      const fileName = `${timestamp}_${randomStr}.${fileExtension}`
      
      // 构建文件路径
      const filePath = `${brandId}/${shopId}/${module}/${fileName}`
      const key = signatureData.dir + filePath
      
      // 构建FormData - 按照OSS要求的顺序
      const formData = new FormData()
      formData.append('key', key)
      formData.append('policy', signatureData.policy)
      formData.append('OSSAccessKeyId', signatureData.OSSAccessKeyId)
      formData.append('success_action_status', signatureData.success_action_status || '200')
      formData.append('x-oss-signature-version', signatureData['x-oss-signature-version'])
      formData.append('x-oss-credential', signatureData['x-oss-credential'])
      formData.append('x-oss-date', signatureData['x-oss-date'])
      formData.append('x-oss-signature', signatureData.signature) // 注意:使用signature字段
      
      // 设置Content-Type
      const contentType = this.getContentType(file.name)
      if (contentType) {
        formData.append('Content-Type', contentType)
      }
      
      formData.append('file', file) // file 必须为最后一个表单域
      
      // 直传到OSS
      const uploadResponse = await fetch(signatureData.host, {
        method: 'POST',
        body: formData
      })
      
      if (uploadResponse.status === 200) {
        const fileUrl = `${signatureData.host}/${key}`
        return {
          success: true,
          fileUrl: fileUrl,
          fileName: fileName,
          filePath: filePath,
          key: key
        }
      } else {
        throw new Error(`上传失败,状态码: ${uploadResponse.status}`)
      }
    } catch (error) {
      console.error('OSS上传失败:', error)
      throw error
    }
  },

  /**
   * 获取文件Content-Type
   */
  getContentType(fileName) {
    const extension = fileName.split('.').pop().toLowerCase()
    const mimeTypes = {
      'jpg': 'image/jpeg',
      'jpeg': 'image/jpeg',
      'png': 'image/png',
      'gif': 'image/gif',
      'pdf': 'application/pdf',
      'doc': 'application/msword',
      'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
      'xls': 'application/vnd.ms-excel',
      'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
    }
    return mimeTypes[extension] || 'application/octet-stream'
  }
}
2. Vue组件中使用
vue 复制代码
<template>
  <el-upload
    class="avatar-uploader"
    :show-file-list="false"
    :before-upload="beforeUpload"
    :http-request="customUpload"
  >
    <img v-if="imageUrl" :src="imageUrl" class="avatar" />
    <i v-else class="el-icon-plus avatar-uploader-icon"></i>
  </el-upload>
</template>

<script>
import ossUpload from '@/utils/ossUpload'

export default {
  data() {
    return {
      imageUrl: ''
    }
  },
  methods: {
    beforeUpload(file) {
      const isImage = file.type.startsWith('image/')
      const isLt2M = file.size / 1024 / 1024 < 2

      if (!isImage) {
        this.$message.error('只能上传图片文件!')
        return false
      }
      if (!isLt2M) {
        this.$message.error('上传图片大小不能超过 2MB!')
        return false
      }
      return true
    },

    async customUpload(options) {
      try {
        const result = await ossUpload.uploadToOss(
          options.file,
          'brandId', // 品牌ID
          'shopId',  // 门店ID
          'project'  // 模块名
        )
        
        if (result.success) {
          this.imageUrl = result.fileUrl
          this.$message.success('上传成功!')
        }
      } catch (error) {
        this.$message.error('上传失败: ' + error.message)
      }
    }
  }
}
</script>

关键配置要点

1. OSS Bucket CORS配置

json 复制代码
{
  "CORSRule": [
    {
      "AllowedOrigin": ["http://localhost:9555", "https://yourdomain.com"],
      "AllowedMethod": ["GET", "POST", "PUT", "HEAD"],
      "AllowedHeader": ["*"],
      "ExposeHeader": ["ETag", "x-oss-request-id"],
      "MaxAgeSeconds": 3600
    }
  ]
}

2. 时区设置

所有时间格式化必须使用UTC时区:

java 复制代码
dateFormat.setTimeZone(java.util.TimeZone.getTimeZone("UTC"));

3. FormData字段顺序

OSS对FormData字段顺序有要求,file字段必须放在最后:

javascript 复制代码
formData.append('key', key)
formData.append('policy', policy)
// ... 其他字段
formData.append('file', file) // 最后添加

4. 签名算法

必须使用OSS V4的多层HMAC-SHA256签名算法,不能使用简单的HMAC-SHA256。

常见问题排查

1. CORS错误

  • 检查OSS控制台CORS配置
  • 确认允许的域名是否正确
  • 检查允许的HTTP方法

2. 签名错误

  • 检查时区设置是否为UTC
  • 验证字段名映射是否正确
  • 确认使用了正确的OSS V4签名算法

3. Policy错误

  • 检查过期时间格式
  • 验证policy条件是否完整
  • 确认文件大小限制

4. 上传失败

  • 检查FormData字段顺序
  • 验证Content-Type设置
  • 确认文件路径格式

总结

OSS直传集成的关键在于:

  1. 正确的签名算法:使用OSS V4多层HMAC-SHA256
  2. 时区一致性:所有时间使用UTC时区
  3. CORS配置:在OSS控制台正确配置跨域规则
  4. 字段映射:前后端字段名保持一致
  5. FormData顺序:按照OSS要求的字段顺序构建

通过以上实践,可以实现稳定可靠的OSS直传功能,避免常见的配置和实现问题。

相关推荐
摇滚侠3 小时前
Spring Boot3零基础教程,Kafka 小结,笔记79
spring boot·笔记·kafka
摇滚侠3 小时前
Spring Boot3零基础教程,自定义 starter,把项目封装成依赖给别人使用,笔记65
数据库·spring boot·笔记
刘一说3 小时前
Spring Boot 主程序入口与启动流程深度解析:从 `@SpringBootApplication` 到应用就绪
java·spring boot·后端
一 乐3 小时前
车辆管理|校园车辆信息|基于SprinBoot+vue的校园车辆管理系统(源码+数据库+文档)
java·前端·数据库·vue.js·论文·毕设·车辆管理
百锦再4 小时前
Python、Java与Go:AI大模型时代的语言抉择
java·前端·vue.js·人工智能·python·go·1024程序员节
菩提树下的凡夫4 小时前
前端vue的开发流程
前端·javascript·vue.js
Zz燕4 小时前
G6实战_手把手实现简单流程图
javascript·vue.js
D11_4 小时前
阿里云服务器百度站长平台验证完整指南:SSH文件验证详解
服务器·百度·阿里云
極光未晚5 小时前
乾坤微前端项目:前端处理后台分批次返回的 Markdown 流式数据
前端·vue.js·面试