From 637f1253489167be7add24a3aa293b20da141bf1 Mon Sep 17 00:00:00 2001 From: meowrain Date: Sat, 10 Jan 2026 16:49:34 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E6=A0=B8=E5=BF=83=E5=8A=9F=E8=83=BD=EF=BC=8C?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E6=9C=AC=E5=9C=B0=E5=92=8C=E4=BA=91=E5=AD=98?= =?UTF-8?q?=E5=82=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现通用文件上传、存储和访问功能,支持文件去重和多种存储策略。 主要变更: - 新增文件上传接口,支持小文件同步去重、大文件异步处理 - 实现本地存储和腾讯云COS存储策略 - 新增哈希计算服务,支持异步计算大文件哈希 - 新增文件访问控制器,提供文件访问能力 - 扩展附件实体和服务,实现完整的文件管理 - 新增配置类,支持灵活的存储策略切换 - 优化删除状态枚举类型从String改为Integer - 配置文件上传大小限制和存储相关配置 技术细节: - 小文件(<=10MB)同步计算SHA256哈希并去重 - 大文件异步计算哈希,提升上传响应速度 - 支持按日期自动组织文件目录结构 - 集成Hutool工具简化文件操作 Co-Authored-By: Claude --- .../framework/core/enums/DelStatusEnum.java | 8 +- aioj-backend-file-service/pom.xml | 8 + .../fileservice/FileServiceApplication.java | 4 + .../fileservice/config/AsyncConfig.java | 49 ++++++ .../config/FileConfigurationProperties.java | 94 ++++++++++ .../fileservice/config/FileStorageConfig.java | 51 ++++++ .../config/SwaggerConfiguration.java | 45 +++++ .../controller/AttachmentController.java | 8 +- .../controller/FileAccessController.java | 83 +++++++++ .../fileservice/dao/entity/AttachmentDO.java | 2 +- .../dao/mapper/AttachmentMapper.java | 2 + .../service/AttachmentService.java | 3 + .../service/HashCalculationService.java | 78 +++++++++ .../service/impl/AttachmentServiceImpl.java | 107 ++++++++++++ .../storage/FileStorageStrategy.java | 68 ++++++++ .../storage/impl/CosFileStorageStrategy.java | 164 ++++++++++++++++++ .../impl/LocalFileStorageStrategy.java | 116 +++++++++++++ .../src/main/resources/application.yml | 57 +++++- 18 files changed, 935 insertions(+), 12 deletions(-) create mode 100644 aioj-backend-file-service/src/main/java/cn/meowrain/aioj/backend/fileservice/config/AsyncConfig.java create mode 100644 aioj-backend-file-service/src/main/java/cn/meowrain/aioj/backend/fileservice/config/FileConfigurationProperties.java create mode 100644 aioj-backend-file-service/src/main/java/cn/meowrain/aioj/backend/fileservice/config/FileStorageConfig.java create mode 100644 aioj-backend-file-service/src/main/java/cn/meowrain/aioj/backend/fileservice/config/SwaggerConfiguration.java create mode 100644 aioj-backend-file-service/src/main/java/cn/meowrain/aioj/backend/fileservice/controller/FileAccessController.java create mode 100644 aioj-backend-file-service/src/main/java/cn/meowrain/aioj/backend/fileservice/service/HashCalculationService.java create mode 100644 aioj-backend-file-service/src/main/java/cn/meowrain/aioj/backend/fileservice/storage/FileStorageStrategy.java create mode 100644 aioj-backend-file-service/src/main/java/cn/meowrain/aioj/backend/fileservice/storage/impl/CosFileStorageStrategy.java create mode 100644 aioj-backend-file-service/src/main/java/cn/meowrain/aioj/backend/fileservice/storage/impl/LocalFileStorageStrategy.java diff --git a/aioj-backend-common/aioj-backend-common-core/src/main/java/cn/meowrain/aioj/backend/framework/core/enums/DelStatusEnum.java b/aioj-backend-common/aioj-backend-common-core/src/main/java/cn/meowrain/aioj/backend/framework/core/enums/DelStatusEnum.java index 4d8afe3..7fcd246 100644 --- a/aioj-backend-common/aioj-backend-common-core/src/main/java/cn/meowrain/aioj/backend/framework/core/enums/DelStatusEnum.java +++ b/aioj-backend-common/aioj-backend-common-core/src/main/java/cn/meowrain/aioj/backend/framework/core/enums/DelStatusEnum.java @@ -5,15 +5,15 @@ package cn.meowrain.aioj.backend.framework.core.enums; */ public enum DelStatusEnum { - STATUS_NORMAL("0"), STATUS_DELETE("1"); + STATUS_NORMAL(0), STATUS_DELETE(1); - private final String code; + private final Integer code; - DelStatusEnum(String code) { + DelStatusEnum(Integer code) { this.code = code; } - public String code() { + public Integer code() { return this.code; } diff --git a/aioj-backend-file-service/pom.xml b/aioj-backend-file-service/pom.xml index 9f0b477..74d98fd 100644 --- a/aioj-backend-file-service/pom.xml +++ b/aioj-backend-file-service/pom.xml @@ -81,5 +81,13 @@ spring-boot-starter-test test + + org.springframework.boot + spring-boot-starter-actuator + + + cn.hutool + hutool-crypto + \ No newline at end of file diff --git a/aioj-backend-file-service/src/main/java/cn/meowrain/aioj/backend/fileservice/FileServiceApplication.java b/aioj-backend-file-service/src/main/java/cn/meowrain/aioj/backend/fileservice/FileServiceApplication.java index 9033663..52d42c4 100644 --- a/aioj-backend-file-service/src/main/java/cn/meowrain/aioj/backend/fileservice/FileServiceApplication.java +++ b/aioj-backend-file-service/src/main/java/cn/meowrain/aioj/backend/fileservice/FileServiceApplication.java @@ -1,9 +1,13 @@ package cn.meowrain.aioj.backend.fileservice; +import org.mybatis.spring.annotation.MapperScan; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; @SpringBootApplication +@EnableConfigurationProperties +@MapperScan("cn.meowrain.aioj.backend.fileservice.dao.mapper") public class FileServiceApplication { public static void main(String[] args) { SpringApplication.run(FileServiceApplication.class); diff --git a/aioj-backend-file-service/src/main/java/cn/meowrain/aioj/backend/fileservice/config/AsyncConfig.java b/aioj-backend-file-service/src/main/java/cn/meowrain/aioj/backend/fileservice/config/AsyncConfig.java new file mode 100644 index 0000000..323b437 --- /dev/null +++ b/aioj-backend-file-service/src/main/java/cn/meowrain/aioj/backend/fileservice/config/AsyncConfig.java @@ -0,0 +1,49 @@ +package cn.meowrain.aioj.backend.fileservice.config; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.Executor; +import java.util.concurrent.ThreadPoolExecutor; + +/** + * 异步配置类 + * + * @author meowrain + * @since 2026-01-10 + */ +@Slf4j +@Configuration +@EnableAsync +public class AsyncConfig { + + @Value("${file-config.async.core-pool-size:2}") + private int corePoolSize; + + @Value("${file-config.async.max-pool-size:5}") + private int maxPoolSize; + + @Value("${file-config.async.queue-capacity:100}") + private int queueCapacity; + + @Value("${file-config.async.thread-name-prefix:file-async-}") + private String threadNamePrefix; + + @Bean("fileExecutor") + public Executor fileExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(corePoolSize); + executor.setMaxPoolSize(maxPoolSize); + executor.setQueueCapacity(queueCapacity); + executor.setThreadNamePrefix(threadNamePrefix); + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); + executor.initialize(); + log.info("文件异步线程池初始化完成: coreSize={}, maxSize={}, queueCapacity={}", + corePoolSize, maxPoolSize, queueCapacity); + return executor; + } +} diff --git a/aioj-backend-file-service/src/main/java/cn/meowrain/aioj/backend/fileservice/config/FileConfigurationProperties.java b/aioj-backend-file-service/src/main/java/cn/meowrain/aioj/backend/fileservice/config/FileConfigurationProperties.java new file mode 100644 index 0000000..d37b4d7 --- /dev/null +++ b/aioj-backend-file-service/src/main/java/cn/meowrain/aioj/backend/fileservice/config/FileConfigurationProperties.java @@ -0,0 +1,94 @@ +package cn.meowrain.aioj.backend.fileservice.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * 文件存储配置属性 + * + * @author meowrain + * @since 2026-01-10 + */ +@Component +@Data +@ConfigurationProperties(prefix = FileConfigurationProperties.PREFIX) +public class FileConfigurationProperties { + static final String PREFIX = "file-config"; + + /** + * 是否使用云存储 + */ + private boolean useCloud = false; + + /** + * 本地存储配置 + */ + private Local local = new Local(); + + /** + * 腾讯云COS存储配置 + */ + private Cos cos = new Cos(); + + @Data + public static class Local { + /** + * 本地存储基础路径 + */ + private String basePath = "uploads"; + + /** + * 访问域名 + */ + private String domain = "http://localhost:10013/api"; + + /** + * URL前缀 + */ + private String urlPrefix = "/file"; + } + + @Data + public static class Cos { + /** + * 是否启用COS + */ + private boolean enabled = false; + + /** + * 腾讯云SecretId + */ + private String secretId; + + /** + * 腾讯云SecretKey + */ + private String secretKey; + + /** + * 区域,如ap-guangzhou、ap-beijing等 + */ + private String region; + + /** + * 存储桶名称 + */ + private String bucketName; + + /** + * 访问域名(可选,如果配置则使用该域名生成访问URL) + */ + private String domain; + + /** + * 文件前缀 + */ + private String prefix = ""; + + /** + * 预签名URL过期时间(秒) + */ + private Integer presignedUrlExpireSeconds = 3600; + } +} diff --git a/aioj-backend-file-service/src/main/java/cn/meowrain/aioj/backend/fileservice/config/FileStorageConfig.java b/aioj-backend-file-service/src/main/java/cn/meowrain/aioj/backend/fileservice/config/FileStorageConfig.java new file mode 100644 index 0000000..e232cf5 --- /dev/null +++ b/aioj-backend-file-service/src/main/java/cn/meowrain/aioj/backend/fileservice/config/FileStorageConfig.java @@ -0,0 +1,51 @@ +package cn.meowrain.aioj.backend.fileservice.config; + +import cn.meowrain.aioj.backend.fileservice.storage.FileStorageStrategy; +import cn.meowrain.aioj.backend.fileservice.storage.impl.CosFileStorageStrategy; +import cn.meowrain.aioj.backend.fileservice.storage.impl.LocalFileStorageStrategy; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; + +/** + * 文件存储配置类 + * 根据配置自动选择存储策略 + * + * @author meowrain + * @since 2026-01-10 + */ +@Slf4j +@Configuration +@RequiredArgsConstructor +public class FileStorageConfig { + + private final LocalFileStorageStrategy localFileStorageStrategy; + private final FileConfigurationProperties fileConfigurationProperties; + private final ApplicationContext applicationContext; + + /** + * 配置文件存储策略 + * 优先使用腾讯云COS,如果未启用则使用本地存储 + */ + @Bean + @Primary + public FileStorageStrategy fileStorageStrategy() { + if (fileConfigurationProperties.isUseCloud()) { + try { + CosFileStorageStrategy cosStrategy = applicationContext.getBean(CosFileStorageStrategy.class); + log.info("使用腾讯云COS文件存储策略"); + return cosStrategy; + } catch (Exception e) { + log.warn("腾讯云COS未配置,回退到本地文件存储策略"); + return localFileStorageStrategy; + } + } else { + log.info("使用本地文件存储策略"); + return localFileStorageStrategy; + } + } +} diff --git a/aioj-backend-file-service/src/main/java/cn/meowrain/aioj/backend/fileservice/config/SwaggerConfiguration.java b/aioj-backend-file-service/src/main/java/cn/meowrain/aioj/backend/fileservice/config/SwaggerConfiguration.java new file mode 100644 index 0000000..b21d34a --- /dev/null +++ b/aioj-backend-file-service/src/main/java/cn/meowrain/aioj/backend/fileservice/config/SwaggerConfiguration.java @@ -0,0 +1,45 @@ +package cn.meowrain.aioj.backend.fileservice.config; + +import com.github.xiaoymin.knife4j.spring.annotations.EnableKnife4j; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Contact; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.info.License; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Swagger配置类 + * + * @author meowrain + * @since 2026-01-10 + */ +@Slf4j +@Configuration +@EnableKnife4j +public class SwaggerConfiguration implements ApplicationRunner { + + @Value("${server.port:8080}") + private String serverPort; + + @Value("${server.servlet.context-path:}") + private String contextPath; + + @Bean + public OpenAPI customerOpenAPI() { + return new OpenAPI().info(new Info().title("AIOJ-文件服务✨") + .description("文件上传、下载、存储管理等功能") + .version("v1.0.0") + .contact(new Contact().name("meowrain").email("meowrain@126.com")) + .license(new License().name("MeowRain").url("https://meowrain.cn"))); + } + + @Override + public void run(ApplicationArguments args) throws Exception { + log.info("✨API Document: http://127.0.0.1:{}{}/doc.html", serverPort, contextPath); + } +} diff --git a/aioj-backend-file-service/src/main/java/cn/meowrain/aioj/backend/fileservice/controller/AttachmentController.java b/aioj-backend-file-service/src/main/java/cn/meowrain/aioj/backend/fileservice/controller/AttachmentController.java index 2291a9c..a6ee585 100644 --- a/aioj-backend-file-service/src/main/java/cn/meowrain/aioj/backend/fileservice/controller/AttachmentController.java +++ b/aioj-backend-file-service/src/main/java/cn/meowrain/aioj/backend/fileservice/controller/AttachmentController.java @@ -9,6 +9,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; import java.util.List; @@ -21,11 +22,16 @@ import java.util.List; * @since 2026-01-10 */ @RestController -@RequestMapping("/v1/attachment") +@RequestMapping("/v1/file") @RequiredArgsConstructor public class AttachmentController { private final AttachmentService attachmentService; + @Operation(summary = "通用文件上传组件") + @PostMapping("/upload") + public Result uploadFile(@RequestParam("file")MultipartFile file) { + return Results.success(attachmentService.upload(file)); + } /** * 分页查询通用附件表列表 diff --git a/aioj-backend-file-service/src/main/java/cn/meowrain/aioj/backend/fileservice/controller/FileAccessController.java b/aioj-backend-file-service/src/main/java/cn/meowrain/aioj/backend/fileservice/controller/FileAccessController.java new file mode 100644 index 0000000..a06c8cc --- /dev/null +++ b/aioj-backend-file-service/src/main/java/cn/meowrain/aioj/backend/fileservice/controller/FileAccessController.java @@ -0,0 +1,83 @@ +package cn.meowrain.aioj.backend.fileservice.controller; + +import cn.hutool.core.util.StrUtil; +import io.swagger.v3.oas.annotations.Operation; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +/** + * 本地文件访问控制器 + * + * @author meowrain + * @since 2026-01-10 + */ +@Slf4j +@RestController +@ConditionalOnProperty(prefix = "file-config", name = "use-cloud", havingValue = "false", matchIfMissing = true) +public class FileAccessController { + + @Value("${file-config.local.base-path:uploads}") + private String basePath; + + @Value("${file-config.local.domain:http://localhost:10013}") + private String domain; + + @Value("${server.servlet.context-path:}") + private String contextPath; + + @GetMapping("/file/**") + @Operation(summary = "访问本地文件") + public void accessFile(HttpServletRequest request, HttpServletResponse response) throws IOException { + String requestURI = request.getRequestURI(); + // 去除 context-path 和 /file 前缀,获取相对路径 + String prefix = contextPath + "/file"; + String filePath = requestURI.substring(prefix.length()); + + // 移除开头的斜杠(如果有) + if (filePath.startsWith("/")) { + filePath = filePath.substring(1); + } + + if (StrUtil.isBlank(filePath)) { + response.setStatus(HttpServletResponse.SC_BAD_REQUEST); + return; + } + + // 使用 Paths.get 自动处理路径分隔符 + Path targetPath = Paths.get(basePath, filePath).normalize(); + + // 安全检查:防止路径遍历攻击 + if (!targetPath.startsWith(Paths.get(basePath).normalize())) { + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + return; + } + + if (!Files.exists(targetPath)) { + response.setStatus(HttpServletResponse.SC_NOT_FOUND); + return; + } + + String contentType = Files.probeContentType(targetPath); + if (StrUtil.isBlank(contentType)) { + contentType = MediaType.APPLICATION_OCTET_STREAM_VALUE; + } + + response.setContentType(contentType); + response.setHeader(HttpHeaders.CONTENT_DISPOSITION, + "inline; filename=\"" + targetPath.getFileName().toString() + "\""); + + Files.copy(targetPath, response.getOutputStream()); + } +} diff --git a/aioj-backend-file-service/src/main/java/cn/meowrain/aioj/backend/fileservice/dao/entity/AttachmentDO.java b/aioj-backend-file-service/src/main/java/cn/meowrain/aioj/backend/fileservice/dao/entity/AttachmentDO.java index 720ad19..751ee70 100644 --- a/aioj-backend-file-service/src/main/java/cn/meowrain/aioj/backend/fileservice/dao/entity/AttachmentDO.java +++ b/aioj-backend-file-service/src/main/java/cn/meowrain/aioj/backend/fileservice/dao/entity/AttachmentDO.java @@ -103,7 +103,7 @@ public class AttachmentDO implements Serializable { * 逻辑删除(0-正常, 1-已删除) */ @TableField("is_deleted") - private Byte isDeleted; + private Integer isDeleted; /** * 创建时间 diff --git a/aioj-backend-file-service/src/main/java/cn/meowrain/aioj/backend/fileservice/dao/mapper/AttachmentMapper.java b/aioj-backend-file-service/src/main/java/cn/meowrain/aioj/backend/fileservice/dao/mapper/AttachmentMapper.java index 4926d09..b0a6a97 100644 --- a/aioj-backend-file-service/src/main/java/cn/meowrain/aioj/backend/fileservice/dao/mapper/AttachmentMapper.java +++ b/aioj-backend-file-service/src/main/java/cn/meowrain/aioj/backend/fileservice/dao/mapper/AttachmentMapper.java @@ -2,6 +2,7 @@ package cn.meowrain.aioj.backend.fileservice.dao.mapper; import cn.meowrain.aioj.backend.fileservice.dao.entity.AttachmentDO; import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; /** *

@@ -11,6 +12,7 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper; * @author meowrain * @since 2026-01-10 */ +@Mapper public interface AttachmentMapper extends BaseMapper { } diff --git a/aioj-backend-file-service/src/main/java/cn/meowrain/aioj/backend/fileservice/service/AttachmentService.java b/aioj-backend-file-service/src/main/java/cn/meowrain/aioj/backend/fileservice/service/AttachmentService.java index e7f62b1..e279df8 100644 --- a/aioj-backend-file-service/src/main/java/cn/meowrain/aioj/backend/fileservice/service/AttachmentService.java +++ b/aioj-backend-file-service/src/main/java/cn/meowrain/aioj/backend/fileservice/service/AttachmentService.java @@ -2,6 +2,7 @@ package cn.meowrain.aioj.backend.fileservice.service; import cn.meowrain.aioj.backend.fileservice.dao.entity.AttachmentDO; import com.baomidou.mybatisplus.extension.service.IService; +import org.springframework.web.multipart.MultipartFile; /** *

@@ -13,4 +14,6 @@ import com.baomidou.mybatisplus.extension.service.IService; */ public interface AttachmentService extends IService { + + AttachmentDO upload(MultipartFile file); } diff --git a/aioj-backend-file-service/src/main/java/cn/meowrain/aioj/backend/fileservice/service/HashCalculationService.java b/aioj-backend-file-service/src/main/java/cn/meowrain/aioj/backend/fileservice/service/HashCalculationService.java new file mode 100644 index 0000000..ac25370 --- /dev/null +++ b/aioj-backend-file-service/src/main/java/cn/meowrain/aioj/backend/fileservice/service/HashCalculationService.java @@ -0,0 +1,78 @@ +package cn.meowrain.aioj.backend.fileservice.service; + +import cn.hutool.crypto.digest.DigestUtil; +import cn.meowrain.aioj.backend.fileservice.config.FileConfigurationProperties; +import cn.meowrain.aioj.backend.fileservice.dao.entity.AttachmentDO; +import cn.meowrain.aioj.backend.fileservice.dao.mapper.AttachmentMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; + +/** + * 异步哈希计算服务 + * + * @author meowrain + * @since 2026-01-10 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class HashCalculationService { + + private final AttachmentMapper attachmentMapper; + private final FileConfigurationProperties fileConfigurationProperties; + + /** + * 异步计算大文件哈希(只计算,不删除文件) + * + * @param attachmentId 新上传的附件ID + * @param storagePath 存储路径 + * @param storageType 存储类型 + */ + @Async("fileExecutor") + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void calculateHashAsync(Long attachmentId, String storagePath, String storageType) { + FileInputStream fis = null; + try { + log.info("开始异步计算大文件哈希: attachmentId={}, storagePath={}, storageType={}", + attachmentId, storagePath, storageType); + + String fileHash; + if ("LOCAL".equals(storageType)) { + String fullPath = fileConfigurationProperties.getLocal().getBasePath() + "/" + storagePath; + File file = new File(fullPath); + fis = new FileInputStream(file); + fileHash = DigestUtil.sha256Hex(fis); + } else { + log.warn("暂不支持云存储的异步哈希计算: storageType={}", storageType); + return; + } + + // 只更新哈希值,不删除文件 + AttachmentDO update = new AttachmentDO(); + update.setId(attachmentId); + update.setFileHash(fileHash); + attachmentMapper.updateById(update); + + log.info("大文件哈希计算完成: attachmentId={}, fileHash={}", attachmentId, fileHash); + + } catch (IOException e) { + log.error("异步计算大文件哈希失败: attachmentId={}", attachmentId, e); + } finally { + if (fis != null) { + try { + fis.close(); + } catch (IOException e) { + log.error("关闭文件流失败", e); + } + } + } + } +} diff --git a/aioj-backend-file-service/src/main/java/cn/meowrain/aioj/backend/fileservice/service/impl/AttachmentServiceImpl.java b/aioj-backend-file-service/src/main/java/cn/meowrain/aioj/backend/fileservice/service/impl/AttachmentServiceImpl.java index 6f4a557..37e86a1 100644 --- a/aioj-backend-file-service/src/main/java/cn/meowrain/aioj/backend/fileservice/service/impl/AttachmentServiceImpl.java +++ b/aioj-backend-file-service/src/main/java/cn/meowrain/aioj/backend/fileservice/service/impl/AttachmentServiceImpl.java @@ -1,10 +1,25 @@ package cn.meowrain.aioj.backend.fileservice.service.impl; +import cn.hutool.core.io.FileUtil; +import cn.hutool.core.util.IdUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.crypto.digest.DigestUtil; import cn.meowrain.aioj.backend.fileservice.dao.entity.AttachmentDO; import cn.meowrain.aioj.backend.fileservice.dao.mapper.AttachmentMapper; import cn.meowrain.aioj.backend.fileservice.service.AttachmentService; +import cn.meowrain.aioj.backend.fileservice.service.HashCalculationService; +import cn.meowrain.aioj.backend.fileservice.storage.FileStorageStrategy; +import cn.meowrain.aioj.backend.framework.core.enums.DelStatusEnum; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.time.LocalDateTime; /** *

@@ -14,7 +29,99 @@ import org.springframework.stereotype.Service; * @author meowrain * @since 2026-01-10 */ +@Slf4j @Service +@RequiredArgsConstructor public class AttachmentServiceImpl extends ServiceImpl implements AttachmentService { + private final FileStorageStrategy fileStorageStrategy; + private final HashCalculationService hashCalculationService; + + /** + * 是否启用文件去重 + */ + @Value("${file-config.deduplication-enabled:true}") + private boolean deduplicationEnabled; + + /** + * 同步计算哈希的文件大小阈值(字节) + * 超过此大小异步计算,默认 10MB + */ + @Value("${file-config.sync-hash-threshold:10485760}") + private long syncHashThreshold; + + @Override + public AttachmentDO upload(MultipartFile file) { + try { + String originalFilename = file.getOriginalFilename(); + String fileExtension = FileUtil.extName(originalFilename); + long fileSize = file.getSize(); + + // 判断文件大小,选择处理方式 + boolean isSmallFile = fileSize <= syncHashThreshold; + + if (deduplicationEnabled && isSmallFile) { + // ========== 小文件:同步计算哈希,立即去重 ========== + String fileHash = DigestUtil.sha256Hex(file.getInputStream()); + + AttachmentDO existingFile = getOne(new LambdaQueryWrapper() + .eq(AttachmentDO::getFileHash, fileHash) + .eq(AttachmentDO::getIsDeleted, DelStatusEnum.STATUS_NORMAL.code()) + .last("LIMIT 1")); + + if (existingFile != null) { + log.info("小文件已存在,返回已有文件: fileName={}, fileHash={}, existingId={}", + originalFilename, fileHash, existingFile.getId()); + return existingFile; + } + + // 文件不存在,上传并保存哈希 + return uploadAndSave(file, originalFilename, fileExtension, fileHash); + } else { + // ========== 大文件:直接上传,异步计算哈希 ========== + AttachmentDO attachmentDO = uploadAndSave(file, originalFilename, fileExtension, null); + + // 异步计算哈希(只计算,不删除文件) + hashCalculationService.calculateHashAsync(attachmentDO.getId(), attachmentDO.getStoragePath(), attachmentDO.getStorageType()); + + return attachmentDO; + } + + } catch (IOException e) { + log.error("文件上传失败: {}", file.getOriginalFilename(), e); + throw new RuntimeException("文件上传失败: " + e.getMessage(), e); + } + } + + /** + * 上传文件并保存记录 + */ + private AttachmentDO uploadAndSave(MultipartFile file, String originalFilename, + String fileExtension, String fileHash) throws IOException { + String fileName = IdUtil.fastSimpleUUID() + StrUtil.DOT + fileExtension; + String datePath = LocalDateTime.now().toLocalDate().toString().replace("-", "/"); + String relativePath = datePath + "/" + fileName; + + String fileUrl = fileStorageStrategy.upload(file, relativePath); + + AttachmentDO attachmentDO = new AttachmentDO(); + attachmentDO.setId(IdUtil.getSnowflakeNextId()); + attachmentDO.setFileName(originalFilename); + attachmentDO.setFileExtension(fileExtension); + attachmentDO.setFileSize(file.getSize()); + attachmentDO.setFileHash(fileHash); + attachmentDO.setMimeType(file.getContentType()); + attachmentDO.setStorageType(fileStorageStrategy.getStorageType()); + attachmentDO.setStoragePath(relativePath); + attachmentDO.setCreatedAt(LocalDateTime.now()); + attachmentDO.setUpdatedAt(LocalDateTime.now()); + attachmentDO.setIsDeleted(DelStatusEnum.STATUS_NORMAL.code()); + + save(attachmentDO); + log.info("文件上传成功: fileName={}, storageType={}, storagePath={}, fileHash={}, size={}KB", + originalFilename, fileStorageStrategy.getStorageType(), relativePath, + fileHash, file.getSize() / 1024); + + return attachmentDO; + } } diff --git a/aioj-backend-file-service/src/main/java/cn/meowrain/aioj/backend/fileservice/storage/FileStorageStrategy.java b/aioj-backend-file-service/src/main/java/cn/meowrain/aioj/backend/fileservice/storage/FileStorageStrategy.java new file mode 100644 index 0000000..bc53ba4 --- /dev/null +++ b/aioj-backend-file-service/src/main/java/cn/meowrain/aioj/backend/fileservice/storage/FileStorageStrategy.java @@ -0,0 +1,68 @@ +package cn.meowrain.aioj.backend.fileservice.storage; + +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.io.InputStream; + +/** + * 文件存储策略接口 + * 支持多种存储方式:本地存储、腾讯云COS、阿里云OSS等 + * + * @author meowrain + * @since 2026-01-10 + */ +public interface FileStorageStrategy { + + /** + * 上传文件 + * + * @param file 文件 + * @param filePath 文件存储路径 + * @return 文件访问URL + * @throws IOException 上传失败时抛出 + */ + String upload(MultipartFile file, String filePath) throws IOException; + + /** + * 上传文件流 + * + * @param inputStream 文件流 + * @param filePath 文件存储路径 + * @param fileSize 文件大小 + * @return 文件访问URL + * @throws IOException 上传失败时抛出 + */ + String upload(InputStream inputStream, String filePath, long fileSize) throws IOException; + + /** + * 删除文件 + * + * @param filePath 文件存储路径 + * @return 是否删除成功 + */ + boolean delete(String filePath); + + /** + * 判断文件是否存在 + * + * @param filePath 文件存储路径 + * @return 是否存在 + */ + boolean exists(String filePath); + + /** + * 获取文件访问URL + * + * @param filePath 文件存储路径 + * @return 文件访问URL + */ + String getFileUrl(String filePath); + + /** + * 获取存储类型标识 + * + * @return 存储类型,如 LOCAL、COS、OSS + */ + String getStorageType(); +} diff --git a/aioj-backend-file-service/src/main/java/cn/meowrain/aioj/backend/fileservice/storage/impl/CosFileStorageStrategy.java b/aioj-backend-file-service/src/main/java/cn/meowrain/aioj/backend/fileservice/storage/impl/CosFileStorageStrategy.java new file mode 100644 index 0000000..5013f93 --- /dev/null +++ b/aioj-backend-file-service/src/main/java/cn/meowrain/aioj/backend/fileservice/storage/impl/CosFileStorageStrategy.java @@ -0,0 +1,164 @@ +package cn.meowrain.aioj.backend.fileservice.storage.impl; + +import cn.meowrain.aioj.backend.fileservice.storage.FileStorageStrategy; +import com.qcloud.cos.COSClient; +import com.qcloud.cos.ClientConfig; +import com.qcloud.cos.auth.BasicCOSCredentials; +import com.qcloud.cos.auth.COSCredentials; +import com.qcloud.cos.http.HttpMethodName; +import com.qcloud.cos.model.ObjectMetadata; +import com.qcloud.cos.model.PutObjectRequest; +import com.qcloud.cos.region.Region; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.util.Date; + +/** + * 腾讯云COS文件存储策略 + * + * @author meowrain + * @since 2026-01-10 + */ +@Slf4j +@Component("cosFileStorageStrategy") +@ConditionalOnProperty(prefix = "file-config.cos", name = "enabled", havingValue = "true") +public class CosFileStorageStrategy implements FileStorageStrategy { + + @Value("${file-config.cos.secret-id}") + private String secretId; + + @Value("${file-config.cos.secret-key}") + private String secretKey; + + @Value("${file-config.cos.region}") + private String region; + + @Value("${file-config.cos.bucket-name}") + private String bucketName; + + @Value("${file-config.cos.domain:}") + private String domain; + + @Value("${file-config.cos.prefix:}") + private String prefix; + + @Value("${file-config.cos.presigned-url-expire-seconds:3600}") + private Integer presignedUrlExpireSeconds; + + private COSClient cosClient; + + @PostConstruct + public void init() { + try { + COSCredentials cred = new BasicCOSCredentials(secretId, secretKey); + ClientConfig clientConfig = new ClientConfig(new Region(region)); + cosClient = new COSClient(cred, clientConfig); + log.info("腾讯云COS存储初始化成功, bucket: {}, region: {}", bucketName, region); + } catch (Exception e) { + log.error("腾讯云COS存储初始化失败", e); + throw new RuntimeException("初始化腾讯云COS存储失败", e); + } + } + + @PreDestroy + public void destroy() { + if (cosClient != null) { + cosClient.shutdown(); + log.info("腾讯云COS客户端已关闭"); + } + } + + @Override + public String upload(org.springframework.web.multipart.MultipartFile file, String filePath) throws IOException { + try { + String key = buildKey(filePath); + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentLength(file.getSize()); + metadata.setContentType(file.getContentType()); + + PutObjectRequest request = new PutObjectRequest(bucketName, key, file.getInputStream(), metadata); + cosClient.putObject(request); + + log.info("文件上传到COS成功: {}", key); + return getFileUrl(filePath); + } catch (Exception e) { + log.error("文件上传到COS失败: {}", filePath, e); + throw new IOException("文件上传到COS失败: " + e.getMessage(), e); + } + } + + @Override + public String upload(InputStream inputStream, String filePath, long fileSize) throws IOException { + try { + String key = buildKey(filePath); + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentLength(fileSize); + + PutObjectRequest request = new PutObjectRequest(bucketName, key, inputStream, metadata); + cosClient.putObject(request); + + log.info("文件流上传到COS成功: {}", key); + return getFileUrl(filePath); + } catch (Exception e) { + log.error("文件流上传到COS失败: {}", filePath, e); + throw new IOException("文件流上传到COS失败: " + e.getMessage(), e); + } + } + + @Override + public boolean delete(String filePath) { + try { + String key = buildKey(filePath); + cosClient.deleteObject(bucketName, key); + log.info("文件从COS删除成功: {}", key); + return true; + } catch (Exception e) { + log.error("文件从COS删除失败: {}", filePath, e); + return false; + } + } + + @Override + public boolean exists(String filePath) { + try { + String key = buildKey(filePath); + return cosClient.doesObjectExist(bucketName, key); + } catch (Exception e) { + log.error("检查文件是否存在失败: {}", filePath, e); + return false; + } + } + + @Override + public String getFileUrl(String filePath) { + String key = buildKey(filePath); + + if (domain != null && !domain.isEmpty()) { + return domain + "/" + key; + } + + Date expirationDate = new Date(System.currentTimeMillis() + presignedUrlExpireSeconds * 1000L); + URL url = cosClient.generatePresignedUrl(bucketName, key, expirationDate, HttpMethodName.GET); + return url.toString(); + } + + @Override + public String getStorageType() { + return "COS"; + } + + private String buildKey(String filePath) { + if (prefix != null && !prefix.isEmpty()) { + return prefix + "/" + filePath; + } + return filePath; + } +} diff --git a/aioj-backend-file-service/src/main/java/cn/meowrain/aioj/backend/fileservice/storage/impl/LocalFileStorageStrategy.java b/aioj-backend-file-service/src/main/java/cn/meowrain/aioj/backend/fileservice/storage/impl/LocalFileStorageStrategy.java new file mode 100644 index 0000000..6f16eb9 --- /dev/null +++ b/aioj-backend-file-service/src/main/java/cn/meowrain/aioj/backend/fileservice/storage/impl/LocalFileStorageStrategy.java @@ -0,0 +1,116 @@ +package cn.meowrain.aioj.backend.fileservice.storage.impl; + +import cn.meowrain.aioj.backend.fileservice.storage.FileStorageStrategy; +import cn.hutool.core.io.FileUtil; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; + +/** + * 本地文件存储策略 + * + * @author meowrain + * @since 2026-01-10 + */ +@Slf4j +@Component("localFileStorageStrategy") +@RequiredArgsConstructor +public class LocalFileStorageStrategy implements FileStorageStrategy { + + @Value("${file-config.local.base-path:uploads}") + private String basePath; + + @Value("${file-config.local.domain:http://localhost:10013/api}") + private String domain; + + @Value("${file-config.local.url-prefix:/file}") + private String urlPrefix; + + @PostConstruct + public void init() { + try { + Path baseDir = Paths.get(basePath); + if (!Files.exists(baseDir)) { + Files.createDirectories(baseDir); + log.info("创建本地文件存储目录: {}", basePath); + } + } catch (IOException e) { + log.error("创建本地文件存储目录失败: {}", basePath, e); + throw new RuntimeException("初始化本地文件存储失败", e); + } + } + + @Override + public String upload(MultipartFile file, String filePath) throws IOException { + Path targetPath = Paths.get(basePath, filePath); + Path parentDir = targetPath.getParent(); + + if (parentDir != null && !Files.exists(parentDir)) { + Files.createDirectories(parentDir); + } + + Files.copy(file.getInputStream(), targetPath, StandardCopyOption.REPLACE_EXISTING); + log.info("文件上传成功: {}", targetPath); + + return getFileUrl(filePath); + } + + @Override + public String upload(InputStream inputStream, String filePath, long fileSize) throws IOException { + Path targetPath = Paths.get(basePath, filePath); + Path parentDir = targetPath.getParent(); + + if (parentDir != null && !Files.exists(parentDir)) { + Files.createDirectories(parentDir); + } + + Files.copy(inputStream, targetPath, StandardCopyOption.REPLACE_EXISTING); + log.info("文件流上传成功: {}", targetPath); + + return getFileUrl(filePath); + } + + @Override + public boolean delete(String filePath) { + try { + Path targetPath = Paths.get(basePath, filePath); + if (Files.exists(targetPath)) { + Files.delete(targetPath); + log.info("文件删除成功: {}", targetPath); + return true; + } + return false; + } catch (IOException e) { + log.error("文件删除失败: {}", filePath, e); + return false; + } + } + + @Override + public boolean exists(String filePath) { + Path targetPath = Paths.get(basePath, filePath); + return Files.exists(targetPath); + } + + @Override + public String getFileUrl(String filePath) { + // 确保URL中始终使用正斜杠 + String normalizedPath = filePath.replace("\\", "/"); + return domain + urlPrefix + "/" + normalizedPath; + } + + @Override + public String getStorageType() { + return "LOCAL"; + } +} diff --git a/aioj-backend-file-service/src/main/resources/application.yml b/aioj-backend-file-service/src/main/resources/application.yml index 5f2c040..69dd91c 100644 --- a/aioj-backend-file-service/src/main/resources/application.yml +++ b/aioj-backend-file-service/src/main/resources/application.yml @@ -3,6 +3,10 @@ spring: name: file-service profiles: active: @env@ + servlet: + multipart: + max-file-size: 500MB + max-request-size: 500MB server: port: 10013 servlet: @@ -20,8 +24,8 @@ springdoc: operations-sorter: alpha group-configs: - group: 'default' - paths-to-match: '/api/**' - packages-to-scan: cn.meowrain.aioj.backend.fileservice.controller + paths-to-match: '/**' + packages-to-scan: cn.meowrain.aioj.backend.fileservice knife4j: basic: enable: true @@ -31,7 +35,48 @@ mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl mapper-locations: classpath*:/mapper/**/*.xml -aioj: - log: - enabled: true - max-length: 20000 \ No newline at end of file + +# 文件存储配置 +file-config: + # 是否使用云存储(true=腾讯云COS,false=本地存储) + use-cloud: false + # 是否启用文件去重(相同文件只存储一份) + deduplication-enabled: true + # 同步计算哈希的文件大小阈值(字节),默认 10MB + sync-hash-threshold: 10485760 + # 本地存储配置 + local: + # 本地存储基础路径(相对路径) + base-path: uploads + # 访问域名(不含context-path) + domain: http://localhost:10013 + # URL前缀(需包含context-path) + url-prefix: /api/file + # 腾讯云COS存储配置 + cos: + # 是否启用COS + enabled: false + # 腾讯云SecretId + secret-id: your-secret-id + # 腾讯云SecretKey + secret-key: your-secret-key + # 区域,如ap-guangzhou、ap-beijing等 + region: ap-guangzhou + # 存储桶名称 + bucket-name: your-bucket-name + # 访问域名(可选,如果配置则使用该域名生成访问URL) + domain: https://your-bucket-name.cos.ap-guangzhou.myqcloud.com + # 文件前缀 + prefix: aioj + # 预签名URL过期时间(秒) + presigned-url-expire-seconds: 3600 + # 异步线程池配置(用于大文件哈希计算) + async: + # 核心线程数 + core-pool-size: 2 + # 最大线程数 + max-pool-size: 5 + # 队列容量 + queue-capacity: 100 + # 线程名前缀 + thread-name-prefix: file-async-