diff --git a/src/main/java/com/youlai/boot/file/config/AliyunOssProperties.java b/src/main/java/com/youlai/boot/file/config/AliyunOssProperties.java
deleted file mode 100644
index e69de29b..00000000
diff --git a/src/main/java/com/youlai/boot/file/config/FileStorageProperties.java b/src/main/java/com/youlai/boot/file/config/FileStorageProperties.java
new file mode 100644
index 00000000..e00f199e
--- /dev/null
+++ b/src/main/java/com/youlai/boot/file/config/FileStorageProperties.java
@@ -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;
+
+/**
+ * 文件存储顶层配置:存储类型与上传限制。
+ *
+ * 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 allowedExtensions;
+ }
+
+ /**
+ * 允许扩展名集合(小写、不含点);空集合表示不限制。
+ */
+ public Set 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());
+ }
+}
diff --git a/src/main/java/com/youlai/boot/file/config/LocalFileProperties.java b/src/main/java/com/youlai/boot/file/config/LocalFileProperties.java
deleted file mode 100644
index e69de29b..00000000
diff --git a/src/main/java/com/youlai/boot/file/config/MinioProperties.java b/src/main/java/com/youlai/boot/file/config/MinioProperties.java
deleted file mode 100644
index e69de29b..00000000
diff --git a/src/main/java/com/youlai/boot/file/controller/FileController.java b/src/main/java/com/youlai/boot/file/controller/FileController.java
index 0975f8c7..154d981b 100644
--- a/src/main/java/com/youlai/boot/file/controller/FileController.java
+++ b/src/main/java/com/youlai/boot/file/controller/FileController.java
@@ -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 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));
+ }
+ }
}
diff --git a/src/main/java/com/youlai/boot/file/service/impl/AliyunFileServiceImpl.java b/src/main/java/com/youlai/boot/file/service/impl/AliyunFileServiceImpl.java
index 55b6ee90..6d4e93e0 100644
--- a/src/main/java/com/youlai/boot/file/service/impl/AliyunFileServiceImpl.java
+++ b/src/main/java/com/youlai/boot/file/service/impl/AliyunFileServiceImpl.java
@@ -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;
}
}
diff --git a/src/main/java/com/youlai/boot/file/service/impl/LocalFileServiceImpl.java b/src/main/java/com/youlai/boot/file/service/impl/LocalFileServiceImpl.java
index 555408e6..4905625d 100644
--- a/src/main/java/com/youlai/boot/file/service/impl/LocalFileServiceImpl.java
+++ b/src/main/java/com/youlai/boot/file/service/impl/LocalFileServiceImpl.java
@@ -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);
}
}
diff --git a/src/main/java/com/youlai/boot/file/service/impl/MinioFileServiceImpl.java b/src/main/java/com/youlai/boot/file/service/impl/MinioFileServiceImpl.java
index 3a0d6caa..b69a2acb 100644
--- a/src/main/java/com/youlai/boot/file/service/impl/MinioFileServiceImpl.java
+++ b/src/main/java/com/youlai/boot/file/service/impl/MinioFileServiceImpl.java
@@ -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());
}
}
}
diff --git a/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/src/main/resources/META-INF/additional-spring-configuration-metadata.json
index 18c6c5d1..3e7ba880 100644
--- a/src/main/resources/META-INF/additional-spring-configuration-metadata.json
+++ b/src/main/resources/META-INF/additional-spring-configuration-metadata.json
@@ -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",
+ "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",
diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml
index e5ad78cf..6d039620 100644
--- a/src/main/resources/application-dev.yml
+++ b/src/main/resources/application-dev.yml
@@ -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 走域名,留空则用 endpoint(IP)格式
+ 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:
# 阿里云短信
diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml
index 37891d80..87369740 100644
--- a/src/main/resources/application-prod.yml
+++ b/src/main/resources/application-prod.yml
@@ -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 走域名,留空则用 endpoint(IP)格式
+ 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:
# 阿里云短信
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index d1ce1eb8..d0e236f3 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -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@