258 lines
8.5 KiB
Java
258 lines
8.5 KiB
Java
|
|
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);
|
|||
|
|
}
|
|||
|
|
}
|