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项目实际上也需要设置跨域,只是可能在以下情况下没有遇到跨域问题:
- 同域部署:如果demo的前后端部署在同一域名下,就不存在跨域问题
- 代理转发:使用了nginx等代理服务器转发请求
- 已配置CORS:OSS Bucket已经配置了对应的CORS规则
- 浏览器环境:某些浏览器或开发环境可能禁用了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直传集成的关键在于:
- 正确的签名算法:使用OSS V4多层HMAC-SHA256
- 时区一致性:所有时间使用UTC时区
- CORS配置:在OSS控制台正确配置跨域规则
- 字段映射:前后端字段名保持一致
- FormData顺序:按照OSS要求的字段顺序构建
通过以上实践,可以实现稳定可靠的OSS直传功能,避免常见的配置和实现问题。