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-