refactor(file): 重构文件存储配置为 file-storage 并增加上传校验
- 合并 AliyunOssProperties/LocalFileProperties/MinioProperties 为 FileStorageProperties - oss.* 配置前缀统一改为 file-storage.*(type/upload/minio/aliyun/local) - FileController 增加文件扩展名白名单校验 - spring.servlet.multipart 上限改由 file-storage.upload.max-file-size 驱动
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
package com.youlai.boot.file.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 文件存储顶层配置:存储类型与上传限制。
|
||||
* <p>
|
||||
* minio / aliyun / local 子树由各自 {@code FileService} 实现类绑定,
|
||||
* 此处仅承载 {@code type} 与 {@code upload}。
|
||||
*/
|
||||
@Data
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "file-storage")
|
||||
public class FileStorageProperties {
|
||||
|
||||
/** 存储类型:minio | aliyun | local */
|
||||
private String type;
|
||||
|
||||
private Upload upload = new Upload();
|
||||
|
||||
@Data
|
||||
public static class Upload {
|
||||
|
||||
/** 单文件大小上限,如 50MB;同时作为 spring.servlet.multipart 的上限 */
|
||||
private String maxFileSize;
|
||||
|
||||
/** 允许的文件扩展名白名单(置空表示不限制) */
|
||||
private List<String> allowedExtensions;
|
||||
}
|
||||
|
||||
/**
|
||||
* 允许扩展名集合(小写、不含点);空集合表示不限制。
|
||||
*/
|
||||
public Set<String> getAllowedExtensions() {
|
||||
if (upload == null || upload.allowedExtensions == null) {
|
||||
return Collections.emptySet();
|
||||
}
|
||||
return upload.allowedExtensions.stream()
|
||||
.map(String::trim)
|
||||
.filter(s -> !s.isEmpty())
|
||||
.map(s -> s.startsWith(".") ? s.substring(1) : s)
|
||||
.map(String::toLowerCase)
|
||||
.collect(Collectors.toUnmodifiableSet());
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,13 @@
|
||||
package com.youlai.boot.file.controller;
|
||||
|
||||
import cn.hutool.core.io.FileUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.youlai.boot.common.exception.BusinessException;
|
||||
import com.youlai.boot.common.result.Result;
|
||||
import com.youlai.boot.file.service.FileService;
|
||||
import com.youlai.boot.common.result.ResultCode;
|
||||
import com.youlai.boot.file.config.FileStorageProperties;
|
||||
import com.youlai.boot.file.model.FileInfo;
|
||||
import com.youlai.boot.file.service.FileService;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.enums.ParameterIn;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
@@ -13,6 +18,8 @@ import lombok.SneakyThrows;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 文件控制层
|
||||
*
|
||||
@@ -26,6 +33,7 @@ import org.springframework.web.multipart.MultipartFile;
|
||||
public class FileController {
|
||||
|
||||
private final FileService fileService;
|
||||
private final FileStorageProperties fileStorageProperties;
|
||||
|
||||
@PostMapping
|
||||
@Operation(summary = "文件上传")
|
||||
@@ -39,6 +47,7 @@ public class FileController {
|
||||
)
|
||||
@RequestPart(value = "file") MultipartFile file
|
||||
) {
|
||||
validateFileExtension(file);
|
||||
FileInfo fileInfo = fileService.uploadFile(file);
|
||||
return Result.success(fileInfo);
|
||||
}
|
||||
@@ -52,4 +61,19 @@ public class FileController {
|
||||
boolean result = fileService.deleteFile(filePath);
|
||||
return Result.judge(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件扩展名白名单校验:{@code allowed-extensions} 为空时不限制。
|
||||
*/
|
||||
private void validateFileExtension(MultipartFile file) {
|
||||
Set<String> allowedExtensions = fileStorageProperties.getAllowedExtensions();
|
||||
if (allowedExtensions.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
String suffix = FileUtil.getSuffix(file.getOriginalFilename());
|
||||
if (StrUtil.isBlank(suffix) || !allowedExtensions.contains(suffix.toLowerCase())) {
|
||||
throw new BusinessException(ResultCode.UPLOAD_FILE_EXCEPTION,
|
||||
"不支持的文件类型: " + (StrUtil.isBlank(suffix) ? "" : "." + suffix));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,8 +23,8 @@ import java.io.InputStream;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Component
|
||||
@ConditionalOnProperty(value = "oss.type", havingValue = "aliyun")
|
||||
@ConfigurationProperties(prefix = "oss.aliyun")
|
||||
@ConditionalOnProperty(value = "file-storage.type", havingValue = "aliyun")
|
||||
@ConfigurationProperties(prefix = "file-storage.aliyun")
|
||||
@RequiredArgsConstructor
|
||||
@Data
|
||||
public class AliyunFileServiceImpl implements FileService {
|
||||
@@ -32,7 +32,7 @@ public class AliyunFileServiceImpl implements FileService {
|
||||
private String endpoint;
|
||||
private String accessKeyId;
|
||||
private String accessKeySecret;
|
||||
private String bucketName;
|
||||
private String bucket;
|
||||
|
||||
private OSS aliyunOssClient;
|
||||
|
||||
@@ -51,12 +51,12 @@ public class AliyunFileServiceImpl implements FileService {
|
||||
try (InputStream inputStream = file.getInputStream()) {
|
||||
ObjectMetadata metadata = new ObjectMetadata();
|
||||
metadata.setContentType(file.getContentType());
|
||||
PutObjectRequest putObjectRequest = new PutObjectRequest(bucketName, fileName, inputStream, metadata);
|
||||
PutObjectRequest putObjectRequest = new PutObjectRequest(bucket, fileName, inputStream, metadata);
|
||||
aliyunOssClient.putObject(putObjectRequest);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("文件上传失败");
|
||||
}
|
||||
String fileUrl = "https://" + bucketName + "." + endpoint + "/" + fileName;
|
||||
String fileUrl = "https://" + bucket + "." + endpoint + "/" + fileName;
|
||||
FileInfo fileInfo = new FileInfo();
|
||||
fileInfo.setName(originalFilename);
|
||||
fileInfo.setUrl(fileUrl);
|
||||
@@ -66,9 +66,9 @@ public class AliyunFileServiceImpl implements FileService {
|
||||
@Override
|
||||
public boolean deleteFile(String filePath) {
|
||||
Assert.notBlank(filePath, "删除文件路径不能为空");
|
||||
String fileHost = "https://" + bucketName + "." + endpoint;
|
||||
String fileHost = "https://" + bucket + "." + endpoint;
|
||||
String fileName = filePath.substring(fileHost.length() + 1);
|
||||
aliyunOssClient.deleteObject(bucketName, fileName);
|
||||
aliyunOssClient.deleteObject(bucket, fileName);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,13 +22,13 @@ import java.time.LocalDateTime;
|
||||
@Data
|
||||
@Slf4j
|
||||
@Component
|
||||
@ConditionalOnProperty(value = "oss.type", havingValue = "local")
|
||||
@ConfigurationProperties(prefix = "oss.local")
|
||||
@ConditionalOnProperty(value = "file-storage.type", havingValue = "local")
|
||||
@ConfigurationProperties(prefix = "file-storage.local")
|
||||
@RequiredArgsConstructor
|
||||
public class LocalFileServiceImpl implements FileService {
|
||||
|
||||
@Value("${oss.local.storage-path}")
|
||||
private String storagePath;
|
||||
@Value("${file-storage.local.path}")
|
||||
private String path;
|
||||
|
||||
@Override
|
||||
public FileInfo uploadFile(MultipartFile file) {
|
||||
@@ -36,7 +36,7 @@ public class LocalFileServiceImpl implements FileService {
|
||||
String suffix = FileUtil.getSuffix(originalFilename);
|
||||
String fileName = IdUtil.simpleUUID()+ "." + suffix;;
|
||||
String folder = DateUtil.format(LocalDateTime.now(), DatePattern.PURE_DATE_PATTERN);
|
||||
String filePrefix = storagePath.endsWith(File.separator) ? storagePath : storagePath + File.separator;
|
||||
String filePrefix = path.endsWith(File.separator) ? path : path + File.separator;
|
||||
try (InputStream inputStream = file.getInputStream()) {
|
||||
FileUtil.writeFromStream(inputStream, filePrefix + folder + File.separator + fileName);
|
||||
} catch (Exception e) {
|
||||
@@ -55,9 +55,9 @@ public class LocalFileServiceImpl implements FileService {
|
||||
if (filePath == null || filePath.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
if (FileUtil.isDirectory(storagePath + filePath)) {
|
||||
if (FileUtil.isDirectory(path + filePath)) {
|
||||
return false;
|
||||
}
|
||||
return FileUtil.del(storagePath + filePath);
|
||||
return FileUtil.del(path + filePath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,8 @@ import java.io.InputStream;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Component
|
||||
@ConditionalOnProperty(value = "oss.type", havingValue = "minio")
|
||||
@ConfigurationProperties(prefix = "oss.minio")
|
||||
@ConditionalOnProperty(value = "file-storage.type", havingValue = "minio")
|
||||
@ConfigurationProperties(prefix = "file-storage.minio")
|
||||
@RequiredArgsConstructor
|
||||
@Data
|
||||
@Slf4j
|
||||
@@ -35,8 +35,8 @@ public class MinioFileServiceImpl implements FileService {
|
||||
private String endpoint;
|
||||
private String accessKey;
|
||||
private String secretKey;
|
||||
private String bucketName;
|
||||
private String customDomain;
|
||||
private String bucket;
|
||||
private String domain;
|
||||
|
||||
private MinioClient minioClient;
|
||||
|
||||
@@ -50,7 +50,7 @@ public class MinioFileServiceImpl implements FileService {
|
||||
|
||||
@Override
|
||||
public FileInfo uploadFile(MultipartFile file) {
|
||||
createBucketIfAbsent(bucketName);
|
||||
createBucketIfAbsent(bucket);
|
||||
String originalFilename = file.getOriginalFilename();
|
||||
String suffix = FileUtil.getSuffix(originalFilename);
|
||||
String dateFolder = DateUtil.format(LocalDateTime.now(), "yyyyMMdd");
|
||||
@@ -58,7 +58,7 @@ public class MinioFileServiceImpl implements FileService {
|
||||
|
||||
try (InputStream inputStream = file.getInputStream()) {
|
||||
PutObjectArgs putObjectArgs = PutObjectArgs.builder()
|
||||
.bucket(bucketName)
|
||||
.bucket(bucket)
|
||||
.object(dateFolder + "/"+ fileName)
|
||||
.contentType(file.getContentType())
|
||||
.stream(inputStream, inputStream.available(), -1)
|
||||
@@ -66,16 +66,16 @@ public class MinioFileServiceImpl implements FileService {
|
||||
minioClient.putObject(putObjectArgs);
|
||||
|
||||
String fileUrl;
|
||||
if (StrUtil.isBlank(customDomain)) {
|
||||
if (StrUtil.isBlank(domain)) {
|
||||
GetPresignedObjectUrlArgs getPresignedObjectUrlArgs = GetPresignedObjectUrlArgs.builder()
|
||||
.bucket(bucketName)
|
||||
.bucket(bucket)
|
||||
.object(dateFolder + "/"+ fileName)
|
||||
.method(Method.GET)
|
||||
.build();
|
||||
fileUrl = minioClient.getPresignedObjectUrl(getPresignedObjectUrlArgs);
|
||||
fileUrl = fileUrl.substring(0, fileUrl.indexOf("?"));
|
||||
} else {
|
||||
fileUrl = customDomain + "/"+ bucketName + "/"+ dateFolder + "/"+ fileName;
|
||||
fileUrl = domain + "/"+ bucket + "/"+ dateFolder + "/"+ fileName;
|
||||
}
|
||||
|
||||
FileInfo fileInfo = new FileInfo();
|
||||
@@ -93,13 +93,13 @@ public class MinioFileServiceImpl implements FileService {
|
||||
Assert.notBlank(filePath, "删除文件路径不能为空");
|
||||
try {
|
||||
String fileName;
|
||||
if (StrUtil.isNotBlank(customDomain)) {
|
||||
fileName = filePath.substring(customDomain.length() + 1 + bucketName.length() + 1);
|
||||
if (StrUtil.isNotBlank(domain)) {
|
||||
fileName = filePath.substring(domain.length() + 1 + bucket.length() + 1);
|
||||
} else {
|
||||
fileName = filePath.substring(endpoint.length() + 1 + bucketName.length() + 1);
|
||||
fileName = filePath.substring(endpoint.length() + 1 + bucket.length() + 1);
|
||||
}
|
||||
minioClient.removeObject(RemoveObjectArgs.builder()
|
||||
.bucket(bucketName).object(fileName).build());
|
||||
.bucket(bucket).object(fileName).build());
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
log.error("删除文件失败", e);
|
||||
@@ -107,23 +107,23 @@ public class MinioFileServiceImpl implements FileService {
|
||||
}
|
||||
}
|
||||
|
||||
private static String publicBucketPolicy(String bucketName) {
|
||||
private static String publicBucketPolicy(String bucket) {
|
||||
return "{\"Version\":\"2012-10-17\","
|
||||
+ "\"Statement\":[{\"Effect\":\"Allow\","
|
||||
+ "\"Principal\":{\"AWS\":[\"*\"]},"
|
||||
+ "\"Action\":[\"s3:ListBucketMultipartUploads\",\"s3:GetBucketLocation\",\"s3:ListBucket\"],"
|
||||
+ "\"Resource\":[\"arn:aws:s3:::" + bucketName + "\"]},"
|
||||
+ "\"Resource\":[\"arn:aws:s3:::" + bucket + "\"]},"
|
||||
+ "{\"Effect\":\"Allow\"," + "\"Principal\":{\"AWS\":[\"*\"]},"
|
||||
+ "\"Action\":[\"s3:ListMultipartUploadParts\",\"s3:PutObject\",\"s3:AbortMultipartUpload\",\"s3:DeleteObject\",\"s3:GetObject\"],"
|
||||
+ "\"Resource\":[\"arn:aws:s3:::" + bucketName + "/*\"]}]}";
|
||||
+ "\"Resource\":[\"arn:aws:s3:::" + bucket + "/*\"]}]}";
|
||||
}
|
||||
|
||||
@SneakyThrows
|
||||
private void createBucketIfAbsent(String bucketName) {
|
||||
if (!minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build())) {
|
||||
minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucketName).build());
|
||||
private void createBucketIfAbsent(String bucket) {
|
||||
if (!minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucket).build())) {
|
||||
minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucket).build());
|
||||
minioClient.setBucketPolicy(SetBucketPolicyArgs.builder()
|
||||
.bucket(bucketName).config(publicBucketPolicy(bucketName)).build());
|
||||
.bucket(bucket).config(publicBucketPolicy(bucket)).build());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user