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
|
.authorizeHttpRequests(auth -> auth
|
||||||
// Public endpoints
|
// Public endpoints
|
||||||
.requestMatchers("/api/v1/auth/**").permitAll()
|
.requestMatchers("/api/v1/auth/**").permitAll()
|
||||||
|
// OSS Token endpoint (for file upload)
|
||||||
|
.requestMatchers("/api/v1/files/oss/token").permitAll()
|
||||||
// Swagger/Knife4j endpoints
|
// Swagger/Knife4j endpoints
|
||||||
.requestMatchers("/doc.html", "/swagger-ui/**", "/v3/api-docs/**", "/webjars/**").permitAll()
|
.requestMatchers("/doc.html", "/swagger-ui/**", "/v3/api-docs/**", "/webjars/**").permitAll()
|
||||||
// Static resources
|
// Static resources
|
||||||
|
|||||||
@ -4,17 +4,22 @@ import com.aliyun.oss.OSS;
|
|||||||
import com.aliyun.oss.OSSClientBuilder;
|
import com.aliyun.oss.OSSClientBuilder;
|
||||||
import com.aliyun.oss.model.*;
|
import com.aliyun.oss.model.*;
|
||||||
import com.reading.platform.common.config.OssConfig;
|
import com.reading.platform.common.config.OssConfig;
|
||||||
|
import com.reading.platform.dto.response.OssTokenVo;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import javax.crypto.Mac;
|
||||||
|
import javax.crypto.spec.SecretKeySpec;
|
||||||
import java.io.ByteArrayInputStream;
|
import java.io.ByteArrayInputStream;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.net.URL;
|
import java.net.URL;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
|
import java.util.Base64;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@ -417,4 +422,123 @@ public class OssUtils {
|
|||||||
default -> "application/octet-stream";
|
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;
|
package com.reading.platform.controller;
|
||||||
|
|
||||||
import com.reading.platform.common.response.Result;
|
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 com.reading.platform.service.FileStorageService;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
@ -21,6 +23,27 @@ import java.util.Map;
|
|||||||
public class FileUploadController {
|
public class FileUploadController {
|
||||||
|
|
||||||
private final FileStorageService fileStorageService;
|
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")
|
@PostMapping("/upload")
|
||||||
@Operation(summary = "上传文件")
|
@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}
|
secret: ${JWT_SECRET:dev-secret-key-for-development-only-reading-platform-2024}
|
||||||
expiration: ${JWT_EXPIRATION:86400000}
|
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 级别)
|
# 日志配置(开发环境 - DEBUG 级别)
|
||||||
logging:
|
logging:
|
||||||
level:
|
level:
|
||||||
|
|||||||
@ -66,6 +66,15 @@ jwt:
|
|||||||
secret: ${JWT_SECRET}
|
secret: ${JWT_SECRET}
|
||||||
expiration: ${JWT_EXPIRATION:86400000}
|
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:
|
logging:
|
||||||
level:
|
level:
|
||||||
|
|||||||
@ -66,6 +66,15 @@ jwt:
|
|||||||
secret: ${JWT_SECRET:test-secret-key-reading-platform-2024}
|
secret: ${JWT_SECRET:test-secret-key-reading-platform-2024}
|
||||||
expiration: ${JWT_EXPIRATION:86400000}
|
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:
|
logging:
|
||||||
level:
|
level:
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user