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:
Ray.Hao
2026-07-18 01:03:15 +08:00
parent eb2fd56f9a
commit 666f4929e5
12 changed files with 187 additions and 90 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,55 +1,70 @@
{
"properties": [
{
"name": "oss.type",
"name": "file-storage.type",
"type": "java.lang.String",
"description": "OSS 类型 (目前支持aliyun、minio)"
"description": "文件存储类型minio | aliyun | local"
},
{
"name": "oss.minio.endpoint",
"name": "file-storage.upload.max-file-size",
"type": "java.lang.String",
"description": "单文件大小上限(如 50MB同时作为 spring.servlet.multipart 的上限"
},
{
"name": "file-storage.upload.allowed-extensions",
"type": "java.util.List<java.lang.String>",
"description": "允许的文件扩展名白名单(置空表示不限制)"
},
{
"name": "file-storage.minio.endpoint",
"type": "java.lang.String",
"description": "MinIO 服务 Endpoint"
},
{
"name": "oss.minio.access-key",
"name": "file-storage.minio.access-key",
"type": "java.lang.String",
"description": "MinIO 访问凭据"
},
{
"name": "oss.minio.secret-key",
"name": "file-storage.minio.secret-key",
"type": "java.lang.String",
"description": "MinIO 凭据密钥"
},
{
"name": "oss.minio.bucket-name",
"name": "file-storage.minio.bucket",
"type": "java.lang.String",
"description": "MinIO 存储桶名称"
},
{
"name": "oss.minio.custom-domain",
"name": "file-storage.minio.domain",
"type": "java.lang.String",
"description": "MinIO 自定义域名"
"description": "MinIO 自定义域名(文件 URL 域名)"
},
{
"name": "oss.aliyun.endpoint",
"name": "file-storage.aliyun.endpoint",
"type": "java.lang.String",
"description": "阿里云 OSS 服务 Endpoint"
},
{
"name": "oss.aliyun.access-key-id",
"name": "file-storage.aliyun.access-key-id",
"type": "java.lang.String",
"description": "阿里云 OSS 访问凭据 ID"
},
{
"name": "oss.aliyun.access-key-secret",
"name": "file-storage.aliyun.access-key-secret",
"type": "java.lang.String",
"description": "阿里云 OSS 凭据密钥"
},
{
"name": "oss.aliyun.bucket-name",
"name": "file-storage.aliyun.bucket",
"type": "java.lang.String",
"description": "阿里云 OSS 存储桶名称"
},
{
"name": "file-storage.local.path",
"type": "java.lang.String",
"description": "本地存储根目录(相对路径基于工作目录)"
},
{
"name": "xxl.job.enabled",
"type": "java.lang.Boolean",

View File

@@ -105,36 +105,39 @@ security:
- /favicon.ico
- /error
# 文件存储配置
oss:
# OSS 类型 (目前支持aliyun、minio)
# 文件存储
file-storage:
# 存储类型minio | aliyun | local
type: minio
# MinIO 对象存储服务
# 上传限制
upload:
# 单文件大小上限(同时作为 spring.servlet.multipart 的上限,单一来源)
max-file-size: 50MB
# 允许的文件扩展名白名单(置空表示不限制)
allowed-extensions:
- jpg
- jpeg
- png
- gif
# MinIO 对象存储
minio:
# 服务Endpoint
endpoint: http://localhost:9000
# 访问凭据
access-key: minioadmin
# 凭据密钥
secret-key: minioadmin
# 存储桶名称
bucket-name: public
# (可选)自定义域名如果配置了域名生成的文件URL是域名格式未配置则URL则是IP格式 (eg: https://www.youlai.tech/storage)
custom-domain:
# 阿里云OSS对象存储服务
endpoint: http://111.229.83.153:9000
access-key: bybaddp7zyARpgNbEGKf
secret-key: p9rBdQZPBIJcMH23iyFkZkXmmawbmwPlk3JLlaaj
bucket: public
# 自定义域名:配置后文件 URL 走域名,留空则用 endpointIP格式
domain:
# 阿里云 OSS
aliyun:
# 服务Endpoint
endpoint: oss-cn-hangzhou.aliyuncs.com
# 访问凭据
access-key-id: your-access-key-id
# 凭据密钥
access-key-secret: your-access-key-secret
# 存储桶名称
bucket-name: default
bucket: default
# 本地存储
local:
# 文件存储路径 请注意下mac用户请使用 /Users/your-username/your-path/否则会有权限问题windows用户请使用 D:/your-path/
storage-path: /Users/theo/home/
# 存储根目录(相对路径基于工作目录;生产建议挂载独立卷)
path: ./uploads/
# 短信配置
sms:
# 阿里云短信

View File

@@ -104,36 +104,38 @@ security:
- /favicon.ico
- /error
# 文件存储配置
oss:
# OSS 类型 (目前支持aliyun、minio)
# 文件存储
file-storage:
# 存储类型minio | aliyun | local
type: minio
# MinIO 对象存储服务
# 上传限制
upload:
# 单文件大小上限(同时作为 spring.servlet.multipart 的上限,单一来源)
max-file-size: 50MB
# 允许的文件扩展名白名单(置空表示不限制)
allowed-extensions:
- jpg
- jpeg
- png
- gif
# MinIO 对象存储
minio:
# 服务Endpoint
endpoint: http://localhost:9000
# 访问凭据
access-key: minioadmin
# 凭据密钥
secret-key: minioadmin
# 存储桶名称
bucket-name: public
# (可选)自定义域名如果配置了域名生成的文件URL是域名格式未配置则URL则是IP格式 (eg: https://www.youlai.tech/storage)
custom-domain:
# 阿里云OSS对象存储服务
bucket: public
# 自定义域名:配置后文件 URL 走域名,留空则用 endpointIP格式
domain:
# 阿里云 OSS
aliyun:
# 服务Endpoint
endpoint: oss-cn-hangzhou.aliyuncs.com
# 访问凭据
access-key-id: your-access-key-id
# 凭据密钥
access-key-secret: your-access-key-secret
# 存储桶名称
bucket-name: default
bucket: default
# 本地存储
local:
# 文件存储路径 请注意下mac用户请使用 /Users/your-username/your-path/否则会有权限问题windows用户请使用 D:/your-path/
storage-path: /Users/theo/home/
# 存储根目录(相对路径基于工作目录;生产建议挂载独立卷)
path: ./uploads/
# 短信配置
sms:
# 阿里云短信

View File

@@ -7,8 +7,9 @@ spring:
import: classpath:codegen.yml
servlet:
multipart:
max-file-size: 50MB
max-request-size: 50MB
# 单文件大小上限,取自 file-storage.upload.max-file-size(单一来源)
max-file-size: ${file-storage.upload.max-file-size:50MB}
max-request-size: ${file-storage.upload.max-file-size:50MB}
# 在 banner.txt 中显示项目版本,使用 @project.version@ 从 pom.xml 获取
project:
version: @project.version@