feat: 实现 OSS 直传功能
后端实现: - 新增 OssTokenVo 响应类 - 修改 OssUtils 支持生成 OSS 直传 Token - 修改 FileUploadController 添加 /oss/token 接口 - 修改 SecurityConfig 配置 OSS 相关接口权限 - 更新多环境 OSS 配置 前端实现: - 新增 env.ts 工具函数,支持环境前缀 - file.ts 新增 getOssToken 和 uploadToOss 方法 - 修改 uploadFile 方法使用 OSS 直传 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
f1bb1447bb
commit
a1dcd529ef
50
reading-platform-frontend/src/utils/env.ts
Normal file
50
reading-platform-frontend/src/utils/env.ts
Normal file
@ -0,0 +1,50 @@
|
||||
/**
|
||||
* 环境工具函数
|
||||
*
|
||||
* 提供环境相关的配置和工具函数
|
||||
*/
|
||||
|
||||
/**
|
||||
* OSS 环境前缀映射
|
||||
*/
|
||||
const OSS_ENV_PREFIX_MAP: Record<string, string> = {
|
||||
development: 'dev',
|
||||
test: 'test',
|
||||
production: 'prod',
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取当前 Vite 环境
|
||||
*/
|
||||
export function getViteEnv(): string {
|
||||
return import.meta.env.MODE || 'development';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 OSS 环境前缀
|
||||
*
|
||||
* @returns OSS 环境前缀(dev/test/prod)
|
||||
*/
|
||||
export function getOssEnvPrefix(): string {
|
||||
const env = getViteEnv();
|
||||
return OSS_ENV_PREFIX_MAP[env] || 'dev';
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建完整的 OSS 目录路径
|
||||
*
|
||||
* @param bizDir 业务目录(如:avatar, course/cover)
|
||||
* @returns 完整目录路径(如:dev/avatar, test/course/cover)
|
||||
*/
|
||||
export function buildOssDirPath(bizDir?: string): string {
|
||||
const envPrefix = getOssEnvPrefix();
|
||||
|
||||
if (!bizDir) {
|
||||
return envPrefix;
|
||||
}
|
||||
|
||||
// 移除 bizDir 开头可能存在的环境前缀,避免重复
|
||||
const cleanBizDir = bizDir.replace(/^(dev|test|prod)\//, '');
|
||||
|
||||
return `${envPrefix}/${cleanBizDir}`;
|
||||
}
|
||||
@ -41,6 +41,8 @@ public class SecurityConfig {
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
// Public endpoints
|
||||
.requestMatchers("/api/v1/auth/**").permitAll()
|
||||
// OSS Token endpoint (for file upload)
|
||||
.requestMatchers("/api/v1/files/oss/token").permitAll()
|
||||
// Swagger/Knife4j endpoints
|
||||
.requestMatchers("/doc.html", "/swagger-ui/**", "/v3/api-docs/**", "/webjars/**").permitAll()
|
||||
// Static resources
|
||||
|
||||
@ -4,17 +4,22 @@ import com.aliyun.oss.OSS;
|
||||
import com.aliyun.oss.OSSClientBuilder;
|
||||
import com.aliyun.oss.model.*;
|
||||
import com.reading.platform.common.config.OssConfig;
|
||||
import com.reading.platform.dto.response.OssTokenVo;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.InputStream;
|
||||
import java.net.URL;
|
||||
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;
|
||||
|
||||
@ -417,4 +422,123 @@ public class OssUtils {
|
||||
default -> "application/octet-stream";
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成阿里云 OSS PostObject 直传 Token
|
||||
* <p>
|
||||
* 用于前端直传文件到 OSS,无需经过后端中转
|
||||
* </p>
|
||||
*
|
||||
* @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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 PostPolicy
|
||||
*
|
||||
* @param objectKey 对象键
|
||||
* @param expiration 过期时间戳
|
||||
* @return Base64 编码的 Policy
|
||||
*/
|
||||
private String buildPostPolicy(String objectKey, long expiration) {
|
||||
// ISO 8601 格式的时间
|
||||
String expireTime = java.time.Instant.ofEpochMilli(expiration)
|
||||
.toString()
|
||||
.replace("Z", "+00:00");
|
||||
|
||||
// 构建 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
package com.reading.platform.controller;
|
||||
|
||||
import com.reading.platform.common.response.Result;
|
||||
import com.reading.platform.common.util.OssUtils;
|
||||
import com.reading.platform.dto.response.OssTokenVo;
|
||||
import com.reading.platform.service.FileStorageService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
@ -21,6 +23,27 @@ import java.util.Map;
|
||||
public class FileUploadController {
|
||||
|
||||
private final FileStorageService fileStorageService;
|
||||
private final OssUtils ossUtils;
|
||||
|
||||
/**
|
||||
* 获取阿里云 OSS 直传 Token
|
||||
* <p>
|
||||
* 用于前端直传文件到 OSS,无需经过后端中转
|
||||
* </p>
|
||||
*
|
||||
* @param fileName 原始文件名(如:图片.jpg)
|
||||
* @param dir 目录前缀(可选,如:avatar, course/cover)
|
||||
* @return OSS 直传 Token VO
|
||||
*/
|
||||
@GetMapping("/oss/token")
|
||||
@Operation(summary = "获取阿里云 OSS 直传 Token")
|
||||
public Result<OssTokenVo> getOssToken(
|
||||
@RequestParam("fileName") String fileName,
|
||||
@RequestParam(value = "dir", required = false) String dir) {
|
||||
|
||||
OssTokenVo token = ossUtils.generatePostObjectToken(fileName, dir);
|
||||
return Result.success(token);
|
||||
}
|
||||
|
||||
@PostMapping("/upload")
|
||||
@Operation(summary = "上传文件")
|
||||
|
||||
@ -0,0 +1,57 @@
|
||||
package com.reading.platform.dto.response;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 阿里云 OSS 直传 Token 响应 VO
|
||||
* <p>
|
||||
* 用于前端直传阿里云 OSS(PostObject 方式)
|
||||
* </p>
|
||||
*
|
||||
* @author reading-platform
|
||||
* @since 2026-03-16
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class OssTokenVo {
|
||||
|
||||
/**
|
||||
* OSS 访问 ID(AccessKeyId)
|
||||
*/
|
||||
private String accessid;
|
||||
|
||||
/**
|
||||
* 合法性策略(Base64 编码的 Policy)
|
||||
*/
|
||||
private String policy;
|
||||
|
||||
/**
|
||||
* 签名信息
|
||||
*/
|
||||
private String signature;
|
||||
|
||||
/**
|
||||
* 上传目录前缀
|
||||
*/
|
||||
private String dir;
|
||||
|
||||
/**
|
||||
* OSS 上传地址(https://bucketname.endpoint)
|
||||
*/
|
||||
private String host;
|
||||
|
||||
/**
|
||||
* 完整文件路径(dir + fileName)
|
||||
*/
|
||||
private String key;
|
||||
|
||||
/**
|
||||
* 过期时间(秒)
|
||||
*/
|
||||
private Integer expire;
|
||||
}
|
||||
@ -68,6 +68,15 @@ jwt:
|
||||
secret: ${JWT_SECRET:dev-secret-key-for-development-only-reading-platform-2024}
|
||||
expiration: ${JWT_EXPIRATION:86400000}
|
||||
|
||||
# 阿里云 OSS 配置(开发环境)
|
||||
aliyun:
|
||||
oss:
|
||||
endpoint: ${OSS_ENDPOINT:oss-cn-shenzhen.aliyuncs.com}
|
||||
access-key-id: ${OSS_ACCESS_KEY_ID:LTAI5tKZhPofbThbSzDSiWoK}
|
||||
access-key-secret: ${OSS_ACCESS_KEY_SECRET:FtcsC7oQX3T0NaChaa9FYq2aoysQFM}
|
||||
bucket-name: ${OSS_BUCKET_NAME:lesingle-kid-course}
|
||||
max-file-size: ${OSS_MAX_FILE_SIZE:10485760}
|
||||
|
||||
# 日志配置(开发环境 - DEBUG 级别)
|
||||
logging:
|
||||
level:
|
||||
|
||||
@ -66,6 +66,15 @@ jwt:
|
||||
secret: ${JWT_SECRET}
|
||||
expiration: ${JWT_EXPIRATION:86400000}
|
||||
|
||||
# 阿里云 OSS 配置(开发环境)
|
||||
aliyun:
|
||||
oss:
|
||||
endpoint: ${OSS_ENDPOINT:oss-cn-shenzhen.aliyuncs.com}
|
||||
access-key-id: ${OSS_ACCESS_KEY_ID:LTAI5tKZhPofbThbSzDSiWoK}
|
||||
access-key-secret: ${OSS_ACCESS_KEY_SECRET:FtcsC7oQX3T0NaChaa9FYq2aoysQFM}
|
||||
bucket-name: ${OSS_BUCKET_NAME:lesingle-kid-course}
|
||||
max-file-size: ${OSS_MAX_FILE_SIZE:10485760}
|
||||
|
||||
# 日志配置(生产环境 - 降低日志级别)
|
||||
logging:
|
||||
level:
|
||||
|
||||
@ -66,6 +66,15 @@ jwt:
|
||||
secret: ${JWT_SECRET:test-secret-key-reading-platform-2024}
|
||||
expiration: ${JWT_EXPIRATION:86400000}
|
||||
|
||||
# 阿里云 OSS 配置(开发环境)
|
||||
aliyun:
|
||||
oss:
|
||||
endpoint: ${OSS_ENDPOINT:oss-cn-shenzhen.aliyuncs.com}
|
||||
access-key-id: ${OSS_ACCESS_KEY_ID:LTAI5tKZhPofbThbSzDSiWoK}
|
||||
access-key-secret: ${OSS_ACCESS_KEY_SECRET:FtcsC7oQX3T0NaChaa9FYq2aoysQFM}
|
||||
bucket-name: ${OSS_BUCKET_NAME:lesingle-kid-course}
|
||||
max-file-size: ${OSS_MAX_FILE_SIZE:10485760}
|
||||
|
||||
# 日志配置(测试环境)
|
||||
logging:
|
||||
level:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user