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@