feat: 实现文件服务核心功能,支持本地和云存储

实现通用文件上传、存储和访问功能,支持文件去重和多种存储策略。

主要变更:
- 新增文件上传接口,支持小文件同步去重、大文件异步处理
- 实现本地存储和腾讯云COS存储策略
- 新增哈希计算服务,支持异步计算大文件哈希
- 新增文件访问控制器,提供文件访问能力
- 扩展附件实体和服务,实现完整的文件管理
- 新增配置类,支持灵活的存储策略切换
- 优化删除状态枚举类型从String改为Integer
- 配置文件上传大小限制和存储相关配置

技术细节:
- 小文件(<=10MB)同步计算SHA256哈希并去重
- 大文件异步计算哈希,提升上传响应速度
- 支持按日期自动组织文件目录结构
- 集成Hutool工具简化文件操作

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-01-10 16:49:34 +08:00
parent 4ee3ebcbec
commit 637f125348
18 changed files with 935 additions and 12 deletions

View File

@@ -81,5 +81,13 @@
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-crypto</artifactId>
</dependency>
</dependencies>
</project>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<AttachmentDO> uploadFile(@RequestParam("file")MultipartFile file) {
return Results.success(attachmentService.upload(file));
}
/**
* 分页查询通用附件表列表

View File

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

View File

@@ -103,7 +103,7 @@ public class AttachmentDO implements Serializable {
* 逻辑删除(0-正常, 1-已删除)
*/
@TableField("is_deleted")
private Byte isDeleted;
private Integer isDeleted;
/**
* 创建时间

View File

@@ -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;
/**
* <p>
@@ -11,6 +12,7 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper;
* @author meowrain
* @since 2026-01-10
*/
@Mapper
public interface AttachmentMapper extends BaseMapper<AttachmentDO> {
}

View File

@@ -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;
/**
* <p>
@@ -13,4 +14,6 @@ import com.baomidou.mybatisplus.extension.service.IService;
*/
public interface AttachmentService extends IService<AttachmentDO> {
AttachmentDO upload(MultipartFile file);
}

View File

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

View File

@@ -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;
/**
* <p>
@@ -14,7 +29,99 @@ import org.springframework.stereotype.Service;
* @author meowrain
* @since 2026-01-10
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AttachmentServiceImpl extends ServiceImpl<AttachmentMapper, AttachmentDO> 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<AttachmentDO>()
.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;
}
}

View File

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

View File

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

View File

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

View File

@@ -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
# 文件存储配置
file-config:
# 是否使用云存储true=腾讯云COSfalse=本地存储)
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-