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:
En 2026-03-16 18:13:56 +08:00
parent f1bb1447bb
commit a1dcd529ef
8 changed files with 283 additions and 0 deletions

View 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}`;
}

View File

@ -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

View File

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

View File

@ -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 = "上传文件")

View File

@ -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>
* 用于前端直传阿里云 OSSPostObject 方式
* </p>
*
* @author reading-platform
* @since 2026-03-16
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class OssTokenVo {
/**
* OSS 访问 IDAccessKeyId
*/
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;
}

View File

@ -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:

View File

@ -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:

View File

@ -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: