library-picturebook-activity/oss-direct-upload-demo/backend/OssUtils.java
En b9ed5e17c6 feat: OSS 客户端直传改造(STS Token 签发 + 前端直传 + CORS 自动配置)
后端新增 OssUtils/OssTokenVo/OssCorsInitRunner,通过 STS 临时凭证实现客户端直传 OSS;
前端 upload API 适配直传流程,赛事创建/作品提交/作业/富文本编辑器均已切换;
多环境(dev/test/prod) OSS 配置补全;新增 oss-direct-upload-demo 示例项目及 E2E 测试。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 15:19:43 +08:00

258 lines
8.5 KiB
Java
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package com.example.oss.util;
import com.aliyun.oss.OSS;
import com.aliyun.oss.OSSClientBuilder;
import com.aliyun.oss.model.SetBucketCORSRequest;
import com.example.oss.config.OssConfig;
import com.example.oss.vo.OssTokenVo;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.Arrays;
import java.util.Base64;
import java.util.List;
import java.util.UUID;
/**
* 阿里云 OSS 工具类(前端直传专用)
* <p>
* 仅包含前端直传所需的核心方法:
* - generatePostObjectToken: 生成 PostObject 直传 Token
* - configureBucketCors: 配置 Bucket CORS 规则
* </p>
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class OssUtils {
private final OssConfig ossConfig;
/**
* 获取 OSS 客户端
*
* @return OSS 客户端
*/
private OSS getOssClient() {
return new OSSClientBuilder().build(
ossConfig.getEndpoint(),
ossConfig.getAccessKeyId(),
ossConfig.getAccessKeySecret()
);
}
// ==================== 前端直传核心方法 ====================
/**
* 生成阿里云 OSS PostObject 直传 Token
* <p>
* 用于前端直传文件到 OSS无需经过后端中转。
* 前端拿到此 Token 后,通过 FormData + POST 方式直接上传到 OSS。
* </p>
*
* <h3>签名流程:</h3>
* <ol>
* <li>构建 Policy JSON包含过期时间和 key 约束)</li>
* <li>Base64 编码 Policy</li>
* <li>使用 AccessKeySecret + HMAC-SHA1 对 Base64(Policy) 计算签名</li>
* </ol>
*
* @param fileName 原始文件名(如:图片.jpg
* @param dir 目录前缀可选avatar, course/cover
* @return OSS 直传 Token VO
*/
public OssTokenVo generatePostObjectToken(String fileName, String dir) {
// 校验文件名
if (fileName == null || fileName.isEmpty()) {
throw new IllegalArgumentException("文件名不能为空");
}
// 校验文件扩展名
if (!isAllowedExtension(fileName)) {
throw new IllegalArgumentException("不支持的文件类型:" + fileName);
}
// 获取文件扩展名
String extension = getFileExtension(fileName);
// 生成唯一文件名UUID + 原扩展名)
String uniqueFilename = UUID.randomUUID().toString().replace("-", "") + extension;
// 生成日期路径2026-03-16/
String datePath = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) + "/";
// 组合存储路径
String objectKey;
if (dir != null && !dir.isEmpty()) {
// 确保 dir 以/结尾
if (!dir.endsWith("/")) {
dir = dir + "/";
}
objectKey = dir + datePath + uniqueFilename;
} else {
objectKey = datePath + uniqueFilename;
}
// 设置过期时间30 秒)
int expire = 30;
long expiration = System.currentTimeMillis() + expire * 1000L;
// 构建 PostPolicy
String policy = buildPostPolicy(objectKey, expiration);
// 计算签名
String signature = computeSignature(policy, ossConfig.getAccessKeySecret());
// 构建上传地址
String host = "https://" + ossConfig.getBucketName() + "." + ossConfig.getEndpoint();
// 返回 Token VO
return OssTokenVo.builder()
.accessid(ossConfig.getAccessKeyId())
.policy(policy)
.signature(signature)
.dir(dir != null ? dir : "")
.host(host)
.key(objectKey)
.expire(expire)
.build();
}
// ==================== CORS 配置 ====================
/**
* 配置 OSS Bucket CORS 规则,解决前端直传跨域问题
* <p>
* 需确保 OSS 账号有 oss:PutBucketCors 权限。
* 也可在阿里云控制台手动配置。
* </p>
*/
public void configureBucketCors() {
if (!Boolean.TRUE.equals(ossConfig.getCorsEnabled())) {
return;
}
OSS ossClient = getOssClient();
try {
SetBucketCORSRequest request = new SetBucketCORSRequest(ossConfig.getBucketName());
SetBucketCORSRequest.CORSRule rule = new SetBucketCORSRequest.CORSRule();
// 允许的来源开发localhost生产需配置实际域名
String origins = ossConfig.getCorsAllowedOrigins();
if (origins == null || origins.isBlank()) {
origins = "http://localhost:5173,http://localhost:5174";
}
rule.setAllowedOrigins(Arrays.asList(origins.trim().split("\s*,\s*")));
// 允许的方法POST直传、GET、PUT、DELETE、HEAD
rule.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "HEAD"));
// 允许的请求头(* 表示全部PostObject 需要)
rule.setAllowedHeaders(Arrays.asList("*"));
// 暴露给前端的响应头
rule.setExposeHeaders(Arrays.asList("ETag", "x-oss-request-id"));
// 预检请求缓存时间(秒)
rule.setMaxAgeSeconds(600);
request.setCorsRules(List.of(rule));
ossClient.setBucketCORS(request);
log.info("OSS Bucket CORS 配置成功,允许来源: {}", origins);
} catch (Exception e) {
log.warn("OSS Bucket CORS 配置失败(可在阿里云控制台手动配置): {}", e.getMessage());
} finally {
ossClient.shutdown();
}
}
// ==================== 私有辅助方法 ====================
/**
* 构建 PostPolicy
*
* @param objectKey 对象键
* @param expiration 过期时间戳
* @return Base64 编码的 Policy
*/
private String buildPostPolicy(String objectKey, long expiration) {
// ISO 8601 GMT 格式(阿里云要求 YYYY-MM-DDTHH:mm:ss.sssZ必须以 Z 结尾)
String expireTime = java.time.Instant.ofEpochMilli(expiration).toString();
// 构建 Policy JSON
String policyJson = String.format(
"{\"expiration\":\"%s\",\"conditions\":[[\"eq\",\"$key\",\"%s\"]]}",
expireTime,
objectKey
);
// Base64 编码
return Base64.getEncoder().encodeToString(policyJson.getBytes(StandardCharsets.UTF_8));
}
/**
* 计算签名
*
* @param policy Base64 编码的 Policy
* @param accessKeySecret OSS 访问密钥
* @return 签名Base64 编码)
*/
private String computeSignature(String policy, String accessKeySecret) {
try {
// 使用 HMAC-SHA1 算法
Mac mac = Mac.getInstance("HmacSHA1");
SecretKeySpec keySpec = new SecretKeySpec(
accessKeySecret.getBytes(StandardCharsets.UTF_8),
"HmacSHA1"
);
mac.init(keySpec);
// 计算签名
byte[] signData = mac.doFinal(policy.getBytes(StandardCharsets.UTF_8));
// Base64 编码
return Base64.getEncoder().encodeToString(signData);
} catch (Exception e) {
log.error("计算 OSS 签名失败:{}", e.getMessage(), e);
throw new RuntimeException("计算 OSS 签名失败:" + e.getMessage(), e);
}
}
/**
* 检查文件扩展名是否允许
*
* @param filename 文件名
* @return 是否允许
*/
private boolean isAllowedExtension(String filename) {
if (filename == null || filename.isEmpty()) {
return false;
}
String extension = getFileExtension(filename).toLowerCase();
return Arrays.stream(ossConfig.getAllowedExtensions())
.anyMatch(allowed -> allowed.toLowerCase().equals(extension));
}
/**
* 获取文件扩展名
*
* @param filename 文件名
* @return 扩展名(包含点)
*/
private String getFileExtension(String filename) {
if (filename == null || filename.isEmpty()) {
return "";
}
int lastDot = filename.lastIndexOf(".");
if (lastDot == -1) {
return "";
}
return filename.substring(lastDot);
}
}