library-picturebook-activity/oss-direct-upload-demo/backend/OssUtils.java

258 lines
8.5 KiB
Java
Raw Normal View History

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