From a9bd7726ff494debfcf4d8066ff86122e03ee633 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E6=B3=BD=E5=A8=81?= <958142070@qq.com> Date: Mon, 2 Mar 2026 17:08:39 +0800 Subject: [PATCH 01/12] =?UTF-8?q?refactor(storage):=20=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E5=AD=98=E5=82=A8=E7=9B=B8=E5=85=B3=E9=80=BB?= =?UTF-8?q?=E8=BE=91=E5=92=8C=E5=A4=9A=E5=A4=84=E6=8E=A5=E5=8F=A3=E8=B0=83?= =?UTF-8?q?=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 替换 BaseController 中的工具类调用,改用 SpringUtils 处理请求路径匹配 - 调整 CommonController 和 FileController 返回 FileInfo 字段映射,兼容 thumbnail 字段更名 - 移除 ContiNewAdminApplication 中无用的 @EnableFileStorage 注解及相关配置 - 优化 FileDO 实体类构造和转换逻辑,增强路径和文件名规范处理 - 精简 FileNameGenerator,改善文件名解析与拼接的安全性和格式一致性 - 重构 FileRecorderImpl,新增分片上传会话及文件分片持久化接口,实现分片上传数据管理 - FileRecycleServiceImpl 增加对文件路径归一化处理,支持回收站文件操作路径规范 - FileServiceImpl 上传逻辑增强,新增 SHA256 计算,调整路径处理及缩略图 URL 兼容 - 移除各处对旧文件存储核心类 org.dromara.x.file.storage 的依赖,改用 top.continew.starter.storage 新存储模型 - MultipartUploadServiceImpl 重构,去除旧分片 DAO,改用统一 FileStorageService 实现分片上传核心功能 - 优化多处接口参数及返回值结构,统一元信息和路径编码处理,提升系统存储层一致性和稳定性 --- continew-common/pom.xml | 17 +- .../base/controller/BaseController.java | 4 +- .../admin/ContiNewAdminApplication.java | 4 - .../system/config/file/FileRecorderImpl.java | 173 ++++++++++++-- .../system/controller/CommonController.java | 6 +- .../system/controller/FileController.java | 8 +- .../impl/RedisMultipartUploadDaoDaoImpl.java | 4 +- .../admin/system/model/entity/FileDO.java | 77 ++++--- .../admin/system/service/FileService.java | 4 +- .../service/impl/FileRecycleServiceImpl.java | 44 ++-- .../system/service/impl/FileServiceImpl.java | 101 +++++---- .../impl/MultipartUploadServiceImpl.java | 213 +++++++----------- .../service/impl/StorageServiceImpl.java | 57 ++--- .../system/service/impl/UserServiceImpl.java | 4 +- .../admin/system/util/FileNameGenerator.java | 37 +-- pom.xml | 2 +- 16 files changed, 435 insertions(+), 320 deletions(-) diff --git a/continew-common/pom.xml b/continew-common/pom.xml index d6564727..fd705068 100644 --- a/continew-common/pom.xml +++ b/continew-common/pom.xml @@ -26,17 +26,6 @@ cosid-spring-redis - - - org.dromara.x-file-storage - x-file-storage-spring - - - - com.amazonaws - aws-java-sdk-s3 - - software.amazon.awssdk @@ -79,6 +68,12 @@ continew-starter-validation + + + top.continew.starter + continew-starter-storage + + top.continew.starter diff --git a/continew-common/src/main/java/top/continew/admin/common/base/controller/BaseController.java b/continew-common/src/main/java/top/continew/admin/common/base/controller/BaseController.java index 175cbadf..682c0ee9 100644 --- a/continew-common/src/main/java/top/continew/admin/common/base/controller/BaseController.java +++ b/continew-common/src/main/java/top/continew/admin/common/base/controller/BaseController.java @@ -27,7 +27,7 @@ import top.continew.admin.common.base.service.BaseService; import top.continew.admin.common.config.crud.CrudApiPermissionPrefixCache; import top.continew.starter.auth.satoken.autoconfigure.SaTokenExtensionProperties; import top.continew.starter.core.util.ServletUtils; -import top.continew.starter.core.util.SpringWebUtils; +import top.continew.starter.core.util.SpringUtils; import top.continew.starter.extension.crud.annotation.CrudApi; import top.continew.starter.extension.crud.controller.AbstractCrudController; import top.continew.starter.extension.crud.enums.Api; @@ -69,7 +69,7 @@ public class BaseController, L, D, Q, C> exten SaTokenExtensionProperties saTokenExtensionProperties = SpringUtil.getBean(SaTokenExtensionProperties.class); if (saTokenExtensionProperties.isEnabled()) { String[] excludePatterns = saTokenExtensionProperties.getSecurity().getExcludes(); - if (SpringWebUtils.isMatch(ServletUtils.getRequestPath(), excludePatterns)) { + if (SpringUtils.isMatch(ServletUtils.getRequest().getServletPath(), excludePatterns)) { return; } } diff --git a/continew-server/src/main/java/top/continew/admin/ContiNewAdminApplication.java b/continew-server/src/main/java/top/continew/admin/ContiNewAdminApplication.java index 81117345..a92e0b2b 100644 --- a/continew-server/src/main/java/top/continew/admin/ContiNewAdminApplication.java +++ b/continew-server/src/main/java/top/continew/admin/ContiNewAdminApplication.java @@ -25,7 +25,6 @@ import com.alicp.jetcache.anno.config.EnableMethodCache; import io.swagger.v3.oas.annotations.Hidden; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.dromara.x.file.storage.spring.EnableFileStorage; import org.springframework.boot.ApplicationArguments; import org.springframework.boot.ApplicationRunner; import org.springframework.boot.SpringApplication; @@ -51,7 +50,6 @@ import top.nextdoc4j.core.configuration.NextDoc4jProperties; @Slf4j @EnableCrudApi @EnableGlobalResponse -@EnableFileStorage @EnableMethodCache(basePackages = "top.continew.admin") @EnableFeignClients @RestController @@ -63,8 +61,6 @@ public class ContiNewAdminApplication implements ApplicationRunner { private final ServerProperties serverProperties; public static void main(String[] args) { - // 禁用 AWS SDK for Java 1.x 弃用提示(1.x 由 x-file-storage 等依赖引入,计划后续迁移至 2.x) - System.setProperty("aws.java.v1.disableDeprecationAnnouncement", "true"); SpringApplication application = new SpringApplication(ContiNewAdminApplication.class); application.setDefaultProperties(MapUtil.of("continew-starter.version", ContiNewStarterVersion.getVersion())); application.run(args); diff --git a/continew-system/src/main/java/top/continew/admin/system/config/file/FileRecorderImpl.java b/continew-system/src/main/java/top/continew/admin/system/config/file/FileRecorderImpl.java index 4da0c27b..f80a7f57 100644 --- a/continew-system/src/main/java/top/continew/admin/system/config/file/FileRecorderImpl.java +++ b/continew-system/src/main/java/top/continew/admin/system/config/file/FileRecorderImpl.java @@ -17,16 +17,14 @@ package top.continew.admin.system.config.file; import cn.hutool.core.collection.CollUtil; -import cn.hutool.core.util.ClassUtil; +import cn.hutool.core.util.ObjectUtil; import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.URLUtil; import com.baomidou.mybatisplus.extension.conditions.query.LambdaQueryChainWrapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.dromara.x.file.storage.core.FileInfo; -import org.dromara.x.file.storage.core.recorder.FileRecorder; -import org.dromara.x.file.storage.core.upload.FilePartInfo; import org.springframework.stereotype.Component; +import top.continew.admin.system.dao.MultipartUploadDao; import top.continew.admin.system.mapper.FileMapper; import top.continew.admin.system.mapper.StorageMapper; import top.continew.admin.system.model.entity.FileDO; @@ -34,6 +32,10 @@ import top.continew.admin.system.model.entity.StorageDO; import top.continew.starter.core.constant.StringConstants; import top.continew.starter.core.util.CollUtils; import top.continew.starter.core.util.URLUtils; +import top.continew.starter.storage.domain.model.resp.FileInfo; +import top.continew.starter.storage.domain.model.resp.FilePartInfo; +import top.continew.starter.storage.domain.model.resp.MultipartInitResp; +import top.continew.starter.storage.service.FileRecorder; import java.util.List; import java.util.Map; @@ -53,25 +55,30 @@ public class FileRecorderImpl implements FileRecorder { private final FileMapper fileMapper; private final StorageMapper storageMapper; + private final MultipartUploadDao multipartUploadDao; @Override public boolean save(FileInfo fileInfo) { - if (fileInfo.getAttr() == null) { + // 分片初始化阶段不落库,只记录分片会话和分片信息 + if ("UPLOADING".equalsIgnoreCase(fileInfo.getMetadata() != null + ? fileInfo.getMetadata().get("status") + : null)) { + return true; + } + StorageDO storage = this.getStorageByPlatform(fileInfo.getPlatform()); + if (storage == null) { return true; } - // 保存文件信息 FileDO file = new FileDO(fileInfo); - StorageDO storage = (StorageDO)fileInfo.getAttr().get(ClassUtil.getClassName(StorageDO.class, false)); file.setStorageId(storage.getId()); fileMapper.insert(file); - // 方便文件上传完成后获取文件信息 - fileInfo.setId(String.valueOf(file.getId())); + fileInfo.setFileId(String.valueOf(file.getId())); if (!URLUtils.isHttpUrl(fileInfo.getUrl())) { String prefix = storage.getUrlPrefix(); - String url = URLUtil.normalize(prefix + fileInfo.getUrl(), false, true); + String url = URLUtil.normalize(prefix + fileInfo.getPath(), false, true); fileInfo.setUrl(url); - if (StrUtil.isNotBlank(fileInfo.getThUrl())) { - fileInfo.setThUrl(URLUtil.normalize(prefix + fileInfo.getThUrl(), false, true)); + if (StrUtil.isNotBlank(fileInfo.getThumbnailPath()) && !URLUtils.isHttpUrl(fileInfo.getThumbnailPath())) { + fileInfo.setThumbnailPath(URLUtil.normalize(prefix + fileInfo.getThumbnailPath(), false, true)); } } return true; @@ -88,8 +95,16 @@ public class FileRecorderImpl implements FileRecorder { } @Override - public boolean delete(String url) { - FileDO file = this.getFileByUrl(url); + public boolean delete(String platform, String path) { + StorageDO storage = this.getStorageByPlatform(platform); + if (storage == null) { + return true; + } + String normalizedPath = StrUtil.prependIfMissing(path, StringConstants.SLASH); + FileDO file = fileMapper.lambdaQuery() + .eq(FileDO::getStorageId, storage.getId()) + .eq(FileDO::getPath, normalizedPath) + .one(); if (file == null) { return true; } @@ -97,18 +112,129 @@ public class FileRecorderImpl implements FileRecorder { } @Override - public void update(FileInfo fileInfo) { - /* 不使用分片功能则无需重写 */ + public boolean update(FileInfo fileInfo) { + StorageDO storage = this.getStorageByPlatform(fileInfo.getPlatform()); + if (storage == null) { + return false; + } + FileDO file = new FileDO(fileInfo); + file.setStorageId(storage.getId()); + FileDO existFile = fileMapper.lambdaQuery() + .eq(FileDO::getStorageId, storage.getId()) + .eq(FileDO::getPath, file.getPath()) + .one(); + if (existFile == null) { + fileMapper.insert(file); + fileInfo.setFileId(String.valueOf(file.getId())); + return true; + } else { + file.setId(existFile.getId()); + boolean updated = fileMapper.updateById(file) > 0; + fileInfo.setFileId(String.valueOf(existFile.getId())); + return updated; + } } @Override public void saveFilePart(FilePartInfo filePartInfo) { - /* 不使用分片功能则无需重写 */ + top.continew.admin.system.model.resp.file.FilePartInfo target = new top.continew.admin.system.model.resp.file.FilePartInfo(); + target.setFileId(filePartInfo.getFileId()); + target.setPartNumber(filePartInfo.getPartNumber()); + target.setPartSize(filePartInfo.getPartSize()); + target.setPartMd5(filePartInfo.getPartMd5()); + target.setPartETag(filePartInfo.getPartETag()); + target.setUploadId(filePartInfo.getUploadId()); + target.setUploadTime(filePartInfo.getUploadTime()); + target.setStatus(filePartInfo.getStatus()); + target.setBucket(filePartInfo.getBucket()); + target.setPath(filePartInfo.getPath()); + multipartUploadDao.setFilePart(filePartInfo.getUploadId(), target); + } + + @Override + public List getFileParts(String fileId) { + return multipartUploadDao.getFileParts(fileId).stream().map(filePartInfo -> { + FilePartInfo target = new FilePartInfo(); + target.setFileId(filePartInfo.getFileId()); + target.setPartNumber(filePartInfo.getPartNumber()); + target.setPartSize(filePartInfo.getPartSize()); + target.setPartMd5(filePartInfo.getPartMd5()); + target.setPartETag(filePartInfo.getPartETag()); + target.setUploadId(filePartInfo.getUploadId()); + target.setUploadTime(filePartInfo.getUploadTime()); + target.setStatus(filePartInfo.getStatus()); + target.setBucket(filePartInfo.getBucket()); + target.setPath(filePartInfo.getPath()); + return target; + }).toList(); + } + + @Override + public void deleteFileParts(String fileId) { + multipartUploadDao.deleteFileParts(fileId); + } + + @Override + public String getUploadIdByMd5(String md5) { + return multipartUploadDao.getUploadIdByMd5(md5); } @Override - public void deleteFilePartByUploadId(String s) { - /* 不使用分片功能则无需重写 */ + public void setMd5Mapping(String md5, String uploadId) { + multipartUploadDao.setMd5Mapping(md5, uploadId); + } + + @Override + public void deleteMd5Mapping(String md5) { + multipartUploadDao.deleteMd5Mapping(md5); + } + + @Override + public void saveMultipartSession(String uploadId, MultipartInitResp initResp, Map metadata) { + top.continew.admin.system.model.resp.file.MultipartUploadInitResp target = new top.continew.admin.system.model.resp.file.MultipartUploadInitResp(); + target.setFileId(initResp.getFileId()); + target.setUploadId(initResp.getUploadId()); + target.setBucket(initResp.getBucket()); + target.setPlatform(initResp.getPlatform()); + target.setFileName(initResp.getFileName()); + target.setFileMd5(initResp.getFileMd5()); + target.setFileSize(ObjectUtil.defaultIfNull(initResp.getFileSize(), 0L)); + target.setExtension(initResp.getExtension()); + target.setContentType(initResp.getContentType()); + target.setParentPath(initResp.getParentPath()); + target.setPath(initResp.getPath()); + target.setPartSize(initResp.getPartSize()); + target.setUploadedPartNumbers(initResp.getUploadedPartNumbers()); + multipartUploadDao.setMultipartUpload(uploadId, target, metadata); + } + + @Override + public MultipartInitResp getMultipartSession(String uploadId) { + top.continew.admin.system.model.resp.file.MultipartUploadInitResp source = multipartUploadDao + .getMultipartUpload(uploadId); + if (source == null) { + return null; + } + MultipartInitResp target = new MultipartInitResp(); + target.setFileId(source.getFileId()); + target.setUploadId(source.getUploadId()); + target.setBucket(source.getBucket()); + target.setPlatform(source.getPlatform()); + target.setFileName(source.getFileName()); + target.setFileMd5(source.getFileMd5()); + target.setFileSize(source.getFileSize()); + target.setExtension(source.getExtension()); + target.setContentType(source.getContentType()); + target.setParentPath(source.getParentPath()); + target.setPath(source.getPath()); + target.setPartSize(source.getPartSize()); + target.setUploadedPartNumbers(source.getUploadedPartNumbers()); + return target; + } + + @Override + public void deleteMultipartSession(String uploadId) { + multipartUploadDao.deleteMultipartUpload(uploadId); } /** @@ -144,4 +270,11 @@ public class FileRecorderImpl implements FileRecorder { return urlPrefix.equals(URLUtil.normalize(storage.getUrlPrefix() + file.getParentPath(), false, true)); }).findFirst().orElse(null); } -} \ No newline at end of file + + private StorageDO getStorageByPlatform(String platform) { + if (StrUtil.isBlank(platform)) { + return null; + } + return storageMapper.lambdaQuery().eq(StorageDO::getCode, platform).one(); + } +} diff --git a/continew-system/src/main/java/top/continew/admin/system/controller/CommonController.java b/continew-system/src/main/java/top/continew/admin/system/controller/CommonController.java index 398869b5..3ab9f683 100644 --- a/continew-system/src/main/java/top/continew/admin/system/controller/CommonController.java +++ b/continew-system/src/main/java/top/continew/admin/system/controller/CommonController.java @@ -25,7 +25,6 @@ import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.constraints.NotNull; import lombok.RequiredArgsConstructor; -import org.dromara.x.file.storage.core.FileInfo; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; @@ -39,6 +38,7 @@ import top.continew.starter.extension.crud.model.resp.LabelValueResp; import top.continew.starter.extension.tenant.annotation.TenantIgnore; import top.continew.starter.extension.tenant.context.TenantContextHolder; import top.continew.starter.log.annotation.Log; +import top.continew.starter.storage.domain.model.resp.FileInfo; import java.io.IOException; import java.util.List; @@ -69,9 +69,9 @@ public class CommonController { ValidationUtils.throwIf(file::isEmpty, "文件不能为空"); FileInfo fileInfo = fileService.upload(file, parentPath); return FileUploadResp.builder() - .id(fileInfo.getId()) + .id(fileInfo.getFileId()) .url(fileInfo.getUrl()) - .thUrl(fileInfo.getThUrl()) + .thUrl(fileInfo.getThumbnailPath()) .metadata(fileInfo.getMetadata()) .build(); } diff --git a/continew-system/src/main/java/top/continew/admin/system/controller/FileController.java b/continew-system/src/main/java/top/continew/admin/system/controller/FileController.java index 132bffa3..91fa6ca0 100644 --- a/continew-system/src/main/java/top/continew/admin/system/controller/FileController.java +++ b/continew-system/src/main/java/top/continew/admin/system/controller/FileController.java @@ -24,7 +24,6 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; import lombok.RequiredArgsConstructor; -import org.dromara.x.file.storage.core.FileInfo; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; @@ -41,6 +40,7 @@ import top.continew.starter.extension.crud.annotation.CrudRequestMapping; import top.continew.starter.extension.crud.enums.Api; import top.continew.starter.extension.crud.model.resp.IdResp; import top.continew.starter.log.annotation.Log; +import top.continew.starter.storage.domain.model.resp.FileInfo; import java.io.IOException; @@ -77,9 +77,9 @@ public class FileController extends BaseController metadataMap = fileInfo.getMetadata(); + this.sha256 = metadataMap != null + ? StrUtil.blankToDefault(metadataMap.get("sha256"), metadataMap.get("etag")) + : null; + this.metadata = JSONUtil.toJsonStr(metadataMap); + String thumbnailPath = fileInfo.getThumbnailPath(); + if (StrUtil.isNotBlank(thumbnailPath)) { + this.thumbnailName = StrUtil.subAfter(thumbnailPath, StringConstants.SLASH, true); + } + this.thumbnailSize = fileInfo.getThumbnailSize(); + this.thumbnailMetadata = null; + this.setCreateTime(fileInfo.getUploadTime()); } /** @@ -156,27 +169,27 @@ public class FileDO extends BaseDO { public FileInfo toFileInfo(StorageDO storage) { FileInfo fileInfo = new FileInfo(); fileInfo.setPlatform(storage.getCode()); - fileInfo.setFilename(this.name); - fileInfo.setOriginalFilename(this.originalName); - // 暂不使用,所以保持空 - fileInfo.setBasePath(StringConstants.EMPTY); + fileInfo.setBucket(storage.getBucketName()); + fileInfo.setFileId(this.getId() == null ? null : String.valueOf(this.getId())); + fileInfo.setName(this.name); + fileInfo.setOriginalFileName(this.originalName); fileInfo.setSize(this.size); - fileInfo.setPath(StringConstants.SLASH.equals(this.parentPath) - ? StringConstants.EMPTY - : StrUtil.appendIfMissing(StrUtil - .removePrefix(this.parentPath, StringConstants.SLASH), StringConstants.SLASH)); - fileInfo.setExt(this.extension); + String normalizedPath = StrUtil.removePrefix(this.path, StringConstants.SLASH); + fileInfo.setPath(normalizedPath); + fileInfo.setFullPath(normalizedPath); fileInfo.setContentType(this.contentType); if (StrUtil.isNotBlank(this.metadata)) { fileInfo.setMetadata(JSONUtil.toBean(this.metadata, Map.class)); } - fileInfo.setUrl(StrUtil.removePrefix(this.path, StringConstants.SLASH)); + fileInfo.setUrl(URLUtil.normalize(storage.getUrlPrefix() + normalizedPath, false, true)); // 缩略图信息 - fileInfo.setThFilename(this.thumbnailName); - fileInfo.setThSize(this.thumbnailSize); - fileInfo.setThUrl(fileInfo.getPath() + fileInfo.getThFilename()); - if (StrUtil.isNotBlank(this.thumbnailMetadata)) { - fileInfo.setThMetadata(JSONUtil.toBean(this.thumbnailMetadata, Map.class)); + if (StrUtil.isNotBlank(this.thumbnailName)) { + String normalizedParentPath = StringConstants.SLASH.equals(this.parentPath) + ? StringConstants.EMPTY + : StrUtil.appendIfMissing(StrUtil + .removePrefix(this.parentPath, StringConstants.SLASH), StringConstants.SLASH); + fileInfo.setThumbnailPath(normalizedParentPath + this.thumbnailName); + fileInfo.setThumbnailSize(this.thumbnailSize); } return fileInfo; } diff --git a/continew-system/src/main/java/top/continew/admin/system/service/FileService.java b/continew-system/src/main/java/top/continew/admin/system/service/FileService.java index ff1bc1eb..56afce14 100644 --- a/continew-system/src/main/java/top/continew/admin/system/service/FileService.java +++ b/continew-system/src/main/java/top/continew/admin/system/service/FileService.java @@ -17,7 +17,6 @@ package top.continew.admin.system.service; import cn.hutool.core.util.StrUtil; -import org.dromara.x.file.storage.core.FileInfo; import org.springframework.web.multipart.MultipartFile; import top.continew.admin.common.base.service.BaseService; import top.continew.admin.system.model.entity.FileDO; @@ -28,6 +27,7 @@ import top.continew.admin.system.model.resp.file.FileResp; import top.continew.admin.system.model.resp.file.FileStatisticsResp; import top.continew.starter.core.constant.StringConstants; import top.continew.starter.data.service.IService; +import top.continew.starter.storage.domain.model.resp.FileInfo; import java.io.File; import java.io.IOException; @@ -175,4 +175,4 @@ public interface FileService extends BaseService> entry : fileListGroup.entrySet()) { StorageDO storage = storageGroup.get(entry.getKey()); - // 清空回收站 - FileInfo fileInfo = new FileInfo(); - fileInfo.setPlatform(storage.getCode()); - fileInfo.setBasePath(StringConstants.EMPTY); - fileInfo.setPath(storage.getRecycleBinPath()); - fileStorageService.delete(fileInfo); + List deletePaths = entry.getValue() + .stream() + .filter(file -> !FileTypeEnum.DIR.equals(file.getType())) + .map(file -> normalizeStoragePath(storage.getRecycleBinPath() + normalizeStoragePath(file + .getPath()))) + .toList(); + if (CollUtil.isNotEmpty(deletePaths)) { + fileStorageService.batchDelete(storage.getCode(), storage.getBucketName(), deletePaths); + } } } finally { InterceptorIgnoreHelper.clearIgnoreStrategy(); } } + private String normalizeStoragePath(String path) { + return StrUtil.removePrefix(path.replace("\\", StringConstants.SLASH) + .replaceAll("/+", StringConstants.SLASH), StringConstants.SLASH); + } + /** * 根据 ID 查询 * diff --git a/continew-system/src/main/java/top/continew/admin/system/service/impl/FileServiceImpl.java b/continew-system/src/main/java/top/continew/admin/system/service/impl/FileServiceImpl.java index e1504007..953e52f7 100644 --- a/continew-system/src/main/java/top/continew/admin/system/service/impl/FileServiceImpl.java +++ b/continew-system/src/main/java/top/continew/admin/system/service/impl/FileServiceImpl.java @@ -18,16 +18,12 @@ package top.continew.admin.system.service.impl; import cn.hutool.core.collection.CollUtil; import cn.hutool.core.io.file.FileNameUtil; -import cn.hutool.core.util.ClassUtil; +import cn.hutool.crypto.digest.DigestUtil; import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.URLUtil; import jakarta.annotation.Resource; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.dromara.x.file.storage.core.FileInfo; -import org.dromara.x.file.storage.core.FileStorageService; -import org.dromara.x.file.storage.core.ProgressListener; -import org.dromara.x.file.storage.core.upload.UploadPretreatment; import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -52,8 +48,13 @@ import top.continew.starter.core.util.CollUtils; import top.continew.starter.core.util.StrUtils; import top.continew.starter.core.util.validation.CheckUtils; import top.continew.starter.core.util.validation.ValidationUtils; +import top.continew.starter.storage.core.FileStorageService; +import top.continew.starter.storage.core.UploadPretreatment; +import top.continew.starter.storage.domain.model.resp.FileInfo; import java.io.File; +import java.io.IOException; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.function.Function; @@ -235,38 +236,28 @@ public class FileServiceImpl extends BaseServiceImpl img.size(100, 100)); + uploadPretreatment.thumbnail(100, 100); } - uploadPretreatment.setProgressMonitor(new ProgressListener() { - @Override - public void start() { - log.info("开始上传文件: {}", uniqueFileName); - } - - @Override - public void progress(long progressSize, Long allSize) { - log.info("文件 [{}] 已上传 [{}],总大小 [{}]", uniqueFileName, progressSize, allSize); - } - - @Override - public void finish() { - log.info("文件 [{}] 上传完成", uniqueFileName); - } - }); + uploadPretreatment.onProgress((progressSize, allSize, percentage) -> log + .info("文件 [{}] 已上传 [{}],总大小 [{}],进度 [{}%]", uniqueFileName, progressSize, allSize, percentage)); // 上传 - return uploadPretreatment.upload(); + log.info("开始上传文件: {}", uniqueFileName); + FileInfo fileInfo = uploadPretreatment.upload(); + log.info("文件 [{}] 上传完成", uniqueFileName); + return this.postProcessUploadResult(fileInfo, storage); } /** @@ -288,7 +279,7 @@ public class FileServiceImpl extends BaseServiceImpl - * 1.如果 path 为 {@code /},则设置为空
+ * 1.如果 path 为 {@code /},则保持为 {@code /}(避免触发自动日期目录)
* 2.如果 path 不以 {@code /} 结尾,则添加后缀 {@code /}
* 3.如果 path 以 {@code /} 开头,则移除前缀 {@code /}
* 示例:yyyy/MM/dd/ @@ -299,7 +290,8 @@ public class FileServiceImpl extends BaseServiceImpl()); + } + fileInfo.setUrl(URLUtil.normalize(storage.getUrlPrefix() + normalizeStoragePath(fileInfo + .getPath()), false, true)); + if (StrUtil.isNotBlank(fileInfo.getThumbnailPath()) && !StrUtil.startWithAny(fileInfo + .getThumbnailPath(), "http://", "https://")) { + fileInfo.setThumbnailPath(URLUtil.normalize(storage.getUrlPrefix() + normalizeStoragePath(fileInfo + .getThumbnailPath()), false, true)); + } + return fileInfo; + } + + private String normalizeStoragePath(String path) { + return StrUtil.removePrefix(path.replace("\\", StringConstants.SLASH) + .replaceAll("/+", StringConstants.SLASH), StringConstants.SLASH); + } +} diff --git a/continew-system/src/main/java/top/continew/admin/system/service/impl/MultipartUploadServiceImpl.java b/continew-system/src/main/java/top/continew/admin/system/service/impl/MultipartUploadServiceImpl.java index ef6bb67a..473edfe0 100644 --- a/continew-system/src/main/java/top/continew/admin/system/service/impl/MultipartUploadServiceImpl.java +++ b/continew-system/src/main/java/top/continew/admin/system/service/impl/MultipartUploadServiceImpl.java @@ -21,16 +21,10 @@ import cn.hutool.core.util.StrUtil; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; -import top.continew.admin.system.constant.MultipartUploadConstants; -import top.continew.admin.system.dao.MultipartUploadDao; import top.continew.admin.system.enums.FileTypeEnum; -import top.continew.admin.system.factory.StorageHandlerFactory; -import top.continew.admin.system.handler.StorageHandler; -import top.continew.admin.system.handler.impl.LocalStorageHandler; import top.continew.admin.system.model.entity.FileDO; import top.continew.admin.system.model.entity.StorageDO; import top.continew.admin.system.model.req.MultipartUploadInitReq; -import top.continew.admin.system.model.resp.file.FilePartInfo; import top.continew.admin.system.model.resp.file.MultipartUploadInitResp; import top.continew.admin.system.model.resp.file.MultipartUploadResp; import top.continew.admin.system.mapper.FileMapper; @@ -39,12 +33,12 @@ import top.continew.admin.system.service.MultipartUploadService; import top.continew.admin.system.service.StorageService; import top.continew.admin.system.util.FileNameGenerator; import top.continew.starter.core.exception.BaseException; +import top.continew.starter.core.constant.StringConstants; +import top.continew.starter.storage.core.FileStorageService; +import top.continew.starter.storage.domain.model.resp.FileInfo; +import top.continew.starter.storage.domain.model.resp.MultipartInitResp; -import java.time.LocalDateTime; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.stream.Collectors; +import java.io.IOException; /** * 分片上传业务实现 @@ -57,39 +51,13 @@ import java.util.stream.Collectors; public class MultipartUploadServiceImpl implements MultipartUploadService { private final StorageService storageService; - - private final StorageHandlerFactory storageHandlerFactory; - - private final MultipartUploadDao multipartUploadDao; - + private final FileStorageService fileStorageService; private final FileService fileService; - private final FileMapper fileMapper; @Override public MultipartUploadInitResp initMultipartUpload(MultipartUploadInitReq multiPartUploadInitReq) { - // 后续可以增加storageCode参数 指定某个存储平台 当前设计是默认存储平台 StorageDO storageDO = storageService.getByCode(null); - // 根据文件Md5查询当前存储平台是否初始化过分片 - String uploadId = multipartUploadDao.getUploadIdByMd5(multiPartUploadInitReq.getFileMd5()); - if (StrUtil.isNotBlank(uploadId)) { - MultipartUploadInitResp multipartUpload = multipartUploadDao.getMultipartUpload(uploadId); - //对比存储平台和分片大小是否一致 一致则返回结果 - if (multipartUpload != null && multipartUpload.getPartSize() - .equals(MultipartUploadConstants.MULTIPART_UPLOAD_PART_SIZE) && multipartUpload.getPlatform() - .equals(storageDO.getCode())) { - // 获取已上传分片信息 - List fileParts = multipartUploadDao.getFileParts(uploadId); - Set partNumbers = fileParts.stream() - .map(FilePartInfo::getPartNumber) - .collect(Collectors.toSet()); - multipartUpload.setUploadedPartNumbers(partNumbers); - return multipartUpload; - } - //todo else 待定 更换存储平台 或分片大小有变更 是否需要删除原先分片 - - } - // 检测文件名是否已存在(同一目录下文件名不能重复) String originalFileName = multiPartUploadInitReq.getFileName(); String parentPath = multiPartUploadInitReq.getParentPath(); @@ -104,124 +72,95 @@ public class MultipartUploadServiceImpl implements MultipartUploadService { } // 生成唯一文件名(处理重名情况) - String uniqueFileName = FileNameGenerator.generateUniqueName(originalFileName, parentPath, storageDO.getId(), fileMapper); + String uniqueFileName = FileNameGenerator.generateUniqueName(originalFileName, parentPath, storageDO + .getId(), fileMapper); multiPartUploadInitReq.setFileName(uniqueFileName); - - StorageHandler storageHandler = storageHandlerFactory.createHandler(storageDO.getType()); - //文件元信息 - Map metaData = multiPartUploadInitReq.getMetaData(); - MultipartUploadInitResp multipartUploadInitResp = storageHandler - .initMultipartUpload(storageDO, multiPartUploadInitReq); - // 缓存文件信息,md5和uploadId映射 - multipartUploadDao.setMultipartUpload(multipartUploadInitResp.getUploadId(), multipartUploadInitResp, metaData); - multipartUploadDao.setMd5Mapping(multiPartUploadInitReq.getFileMd5(), multipartUploadInitResp.getUploadId()); - return multipartUploadInitResp; + fileService.createParentDir(StrUtil.blankToDefault(parentPath, StringConstants.SLASH), storageDO); + + top.continew.starter.storage.domain.model.req.MultipartUploadInitReq storageReq = new top.continew.starter.storage.domain.model.req.MultipartUploadInitReq(); + storageReq.setPlatform(storageDO.getCode()); + storageReq.setBucket(storageDO.getBucketName()); + storageReq.setFileName(uniqueFileName); + storageReq.setFileSize(multiPartUploadInitReq.getFileSize()); + storageReq.setFileMd5(multiPartUploadInitReq.getFileMd5()); + storageReq.setContentType(multiPartUploadInitReq.getContentType()); + storageReq.setParentPath(StrUtil.blankToDefault(parentPath, StringConstants.SLASH)); + storageReq.setMetadata(multiPartUploadInitReq.getMetaData()); + MultipartInitResp initResp = fileStorageService.initMultipartUpload(storageReq); + return this.toAdminInitResp(initResp); } @Override public MultipartUploadResp uploadPart(MultipartFile file, String uploadId, Integer partNumber, String path) { StorageDO storageDO = storageService.getByCode(null); - StorageHandler storageHandler = storageHandlerFactory.createHandler(storageDO.getType()); - MultipartUploadResp resp = storageHandler.uploadPart(storageDO, path, uploadId, partNumber, file); - FilePartInfo partInfo = new FilePartInfo(); - partInfo.setUploadId(uploadId); - partInfo.setBucket(storageDO.getBucketName()); - partInfo.setPath(path); - partInfo.setPartNumber(partNumber); - partInfo.setPartETag(resp.getPartETag()); - partInfo.setPartSize(resp.getPartSize()); - partInfo.setStatus("SUCCESS"); - partInfo.setUploadTime(LocalDateTime.now()); - multipartUploadDao.setFilePart(uploadId, partInfo); - return resp; + try { + top.continew.starter.storage.domain.model.resp.MultipartUploadResp resp = fileStorageService + .uploadPart(storageDO.getCode(), storageDO + .getBucketName(), normalizeStoragePath(path), uploadId, partNumber, file.getInputStream()); + return this.toAdminUploadResp(resp); + } catch (IOException e) { + throw new BaseException("上传分片失败: " + e.getMessage(), e); + } } @Override public FileDO completeMultipartUpload(String uploadId) { - StorageDO storageDO = storageService.getByCode(null); - // 从 FileRecorder 获取所有分片信息 - List recordedParts = multipartUploadDao.getFileParts(uploadId); - MultipartUploadInitResp initResp = multipartUploadDao.getMultipartUpload(uploadId); - // 转换为 MultipartUploadResp - List parts = recordedParts.stream().map(partInfo -> { - MultipartUploadResp resp = new MultipartUploadResp(); - resp.setPartNumber(partInfo.getPartNumber()); - resp.setPartETag(partInfo.getPartETag()); - resp.setPartSize(partInfo.getPartSize()); - resp.setSuccess("SUCCESS".equals(partInfo.getStatus())); - return resp; - }).collect(Collectors.toList()); - - // 如果没有记录,使用客户端传入的分片信息 - if (parts.isEmpty()) { - throw new BaseException("没有找到任何分片信息"); + MultipartInitResp session = fileStorageService.getMultipartSession(uploadId); + if (session == null) { + throw new BaseException("无效的 uploadId: " + uploadId); } - - // 验证分片完整性 - validatePartsCompleteness(parts); - - // 获取策略,判断是否需要验证 - boolean needVerify = true; - StorageHandler storageHandler = storageHandlerFactory.createHandler(storageDO.getType()); - if (storageHandler instanceof LocalStorageHandler) { - needVerify = false; + FileInfo fileInfo = fileStorageService.completeMultipartUpload(uploadId, null); + StorageDO storageDO = storageService.getByCode(session.getPlatform()); + FileDO file = fileMapper.lambdaQuery() + .eq(FileDO::getStorageId, storageDO.getId()) + .eq(FileDO::getPath, StringConstants.SLASH + normalizeStoragePath(session.getPath())) + .one(); + if (file != null) { + return file; } - - // 完成上传 - storageHandler.completeMultipartUpload(storageDO, parts, initResp.getPath(), uploadId, needVerify); - // 文件名已在初始化阶段处理为唯一文件名 - String uniqueFileName = initResp.getFileName().replaceFirst("^[/\\\\]+", ""); - FileDO file = new FileDO(); - file.setName(uniqueFileName); - file.setOriginalName(uniqueFileName); - file.setPath(initResp.getPath()); - file.setParentPath(initResp.getParentPath()); - file.setSize(initResp.getFileSize()); - file.setSha256(initResp.getFileMd5()); - file.setExtension(initResp.getExtension()); - file.setContentType(initResp.getContentType()); - file.setType(FileTypeEnum.getByExtension(FileUtil.extName(uniqueFileName))); - file.setStorageId(storageDO.getId()); - fileService.save(file); - multipartUploadDao.deleteMultipartUpload(uploadId); - return file; + // 兼容兜底:记录器未完成落库时补录 + FileDO fallback = new FileDO(fileInfo); + fallback.setStorageId(storageDO.getId()); + fallback.setType(FileTypeEnum.getByExtension(FileUtil.extName(fallback.getName()))); + fileService.save(fallback); + return fallback; } @Override public void cancelMultipartUpload(String uploadId) { - StorageDO storageDO = storageService.getByCode(null); - multipartUploadDao.deleteMultipartUploadAll(uploadId); - StorageHandler storageHandler = storageHandlerFactory.createHandler(storageDO.getType()); - storageHandler.cleanPart(storageDO, uploadId); + fileStorageService.abortMultipartUpload(uploadId); } - /** - * 验证分片完整性 - * - * @param parts 分片信息 - */ - private void validatePartsCompleteness(List parts) { - if (parts.isEmpty()) { - throw new BaseException("没有找到任何分片信息"); - } - - // 检查分片编号连续性 - List partNumbers = parts.stream().map(MultipartUploadResp::getPartNumber).sorted().toList(); - - for (int i = 0; i < partNumbers.size(); i++) { - if (partNumbers.get(i) != i + 1) { - throw new BaseException("分片编号不连续,缺失分片: " + (i + 1)); - } - } + private MultipartUploadInitResp toAdminInitResp(MultipartInitResp source) { + MultipartUploadInitResp target = new MultipartUploadInitResp(); + target.setFileId(source.getFileId()); + target.setUploadId(source.getUploadId()); + target.setBucket(source.getBucket()); + target.setPlatform(source.getPlatform()); + target.setFileName(source.getFileName()); + target.setFileMd5(source.getFileMd5()); + target.setFileSize(source.getFileSize()); + target.setExtension(source.getExtension()); + target.setContentType(source.getContentType()); + target.setParentPath(source.getParentPath()); + target.setPath(StringConstants.SLASH + normalizeStoragePath(source.getPath())); + target.setPartSize(source.getPartSize()); + target.setUploadedPartNumbers(source.getUploadedPartNumbers()); + return target; + } - // 检查是否所有分片都成功 - List failedParts = parts.stream() - .filter(part -> !part.isSuccess()) - .map(MultipartUploadResp::getPartNumber) - .toList(); + private MultipartUploadResp toAdminUploadResp(top.continew.starter.storage.domain.model.resp.MultipartUploadResp source) { + MultipartUploadResp target = new MultipartUploadResp(); + target.setPartNumber(source.getPartNumber()); + target.setPartETag(source.getPartETag()); + target.setPartSize(source.getPartSize()); + target.setSuccess(source.isSuccess()); + target.setErrorMessage(source.getErrorMessage()); + return target; + } - if (!failedParts.isEmpty()) { - throw new BaseException("存在失败的分片: " + failedParts); - } + private String normalizeStoragePath(String path) { + return StrUtil.removePrefix(path.replace("\\", StringConstants.SLASH) + .replaceAll("/+", StringConstants.SLASH), StringConstants.SLASH); } } diff --git a/continew-system/src/main/java/top/continew/admin/system/service/impl/StorageServiceImpl.java b/continew-system/src/main/java/top/continew/admin/system/service/impl/StorageServiceImpl.java index d900bfea..1367d59e 100644 --- a/continew-system/src/main/java/top/continew/admin/system/service/impl/StorageServiceImpl.java +++ b/continew-system/src/main/java/top/continew/admin/system/service/impl/StorageServiceImpl.java @@ -17,15 +17,9 @@ package top.continew.admin.system.service.impl; import cn.hutool.core.bean.BeanUtil; -import cn.hutool.core.map.MapUtil; import cn.hutool.core.util.StrUtil; -import cn.hutool.core.util.URLUtil; import jakarta.annotation.Resource; import lombok.RequiredArgsConstructor; -import org.dromara.x.file.storage.core.FileStorageProperties; -import org.dromara.x.file.storage.core.FileStorageService; -import org.dromara.x.file.storage.core.FileStorageServiceBuilder; -import org.dromara.x.file.storage.core.platform.FileStorage; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import top.continew.admin.common.base.service.BaseServiceImpl; @@ -41,13 +35,15 @@ import top.continew.admin.system.model.resp.StorageResp; import top.continew.admin.system.service.FileService; import top.continew.admin.system.service.StorageService; import top.continew.starter.core.util.ExceptionUtils; -import top.continew.starter.core.util.SpringWebUtils; import top.continew.starter.core.util.validation.CheckUtils; import top.continew.starter.core.util.validation.ValidationUtils; +import top.continew.starter.storage.autoconfigure.properties.LocalStorageConfig; +import top.continew.starter.storage.autoconfigure.properties.OssStorageConfig; +import top.continew.starter.storage.core.FileStorageService; +import top.continew.starter.storage.strategy.impl.LocalStorageStrategy; +import top.continew.starter.storage.strategy.impl.OssStorageStrategy; -import java.util.Collections; import java.util.List; -import java.util.concurrent.CopyOnWriteArrayList; /** * 存储业务实现 @@ -159,6 +155,7 @@ public class StorageServiceImpl extends BaseServiceImpl fileStorageList = fileStorageService.getFileStorageList(); + if (fileStorageService.exists(storage.getCode()) && fileStorageService.isDynamic(storage.getCode())) { + fileStorageService.unload(storage.getCode()); + } switch (storage.getType()) { case LOCAL -> { - FileStorageProperties.LocalPlusConfig config = new FileStorageProperties.LocalPlusConfig(); + LocalStorageConfig config = new LocalStorageConfig(); + config.setEnabled(true); config.setPlatform(storage.getCode()); - config.setStoragePath(storage.getBucketName()); - fileStorageList.addAll(FileStorageServiceBuilder.buildLocalPlusFileStorage(Collections - .singletonList(config))); - // 注册资源映射 - SpringWebUtils.registerResourceHandler(MapUtil.of(URLUtil.url(storage.getDomain()).getPath(), storage - .getBucketName())); + config.setBucketName(storage.getBucketName()); + config.setEndpoint(storage.getDomain()); + fileStorageService.register(new LocalStorageStrategy(config)); } case OSS -> { - FileStorageProperties.AmazonS3Config config = new FileStorageProperties.AmazonS3Config(); + OssStorageConfig config = new OssStorageConfig(); + config.setEnabled(true); config.setPlatform(storage.getCode()); config.setAccessKey(storage.getAccessKey()); config.setSecretKey(storage.getSecretKey()); - config.setEndPoint(storage.getEndpoint()); + config.setEndpoint(storage.getEndpoint()); config.setBucketName(storage.getBucketName()); - fileStorageList.addAll(FileStorageServiceBuilder.buildAmazonS3FileStorage(Collections - .singletonList(config), null)); + config.setDomain(storage.getDomain()); + fileStorageService.register(new OssStorageStrategy(config)); } default -> throw new IllegalArgumentException("不支持的存储类型:%s".formatted(storage.getType())); } + if (Boolean.TRUE.equals(storage.getIsDefault())) { + fileStorageService.defaultStorage(storage.getCode()); + } } @Override public void unload(StorageDO storage) { - FileStorage fileStorage = fileStorageService.getFileStorage(storage.getCode()); - if (fileStorage == null) { + if (!fileStorageService.exists(storage.getCode()) || !fileStorageService.isDynamic(storage.getCode())) { return; } - CopyOnWriteArrayList fileStorageList = fileStorageService.getFileStorageList(); - fileStorageList.remove(fileStorage); - fileStorage.close(); - // 本地存储引擎需要移除资源映射 - if (StorageTypeEnum.LOCAL.equals(storage.getType())) { - SpringWebUtils.deRegisterResourceHandler(MapUtil.of(URLUtil.url(storage.getDomain()).getPath(), storage - .getBucketName())); - } + fileStorageService.unload(storage.getCode()); } /** @@ -253,4 +246,4 @@ public class StorageServiceImpl extends BaseServiceImpl * 当目标目录存在同名文件时,自动添加序号后缀: *
    - *
  • file.txt → file(1).txt → file(2).txt → ...
  • - *
  • 无扩展名:README → README(1) → README(2) → ...
  • - *
  • 隐藏文件:.gitignore → .gitignore(1) → .gitignore(2) → ...
  • + *
  • file.txt → file(1).txt → file(2).txt → ...
  • + *
  • 无扩展名:README → README(1) → README(2) → ...
  • + *
  • 隐藏文件:.gitignore → .gitignore(1) → .gitignore(2) → ...
  • *
*

* @@ -90,7 +90,9 @@ public class FileNameGenerator { // 安全限制,防止无限循环 if (counter > 9999) { log.warn("文件名重命名超过最大限制,使用当前时间戳: {}", fileName); - return baseName + "_" + System.currentTimeMillis() + (StrUtil.isNotBlank(extension) ? "." + extension : ""); + return baseName + "_" + System.currentTimeMillis() + (StrUtil.isNotBlank(extension) + ? "." + extension + : ""); } } } @@ -102,10 +104,10 @@ public class FileNameGenerator { * 示例: *

*
    - *
  • "document.pdf" → ["document", "pdf"]
  • - *
  • "README" → ["README", ""]
  • - *
  • ".gitignore" → [".gitignore", ""]
  • - *
  • "archive.tar.gz" → ["archive.tar", "gz"]
  • + *
  • "document.pdf" → ["document", "pdf"]
  • + *
  • "README" → ["README", ""]
  • + *
  • ".gitignore" → [".gitignore", ""]
  • + *
  • "archive.tar.gz" → ["archive.tar", "gz"]
  • *
* * @param fileName 文件名 @@ -113,7 +115,7 @@ public class FileNameGenerator { */ public static String[] parseFileName(String fileName) { if (StrUtil.isBlank(fileName)) { - return new String[]{"", ""}; + return new String[] {"", ""}; } // 处理隐藏文件(以.开头) @@ -122,7 +124,7 @@ public class FileNameGenerator { // 处理空文件名(如只有"."的情况) if (nameWithoutDot.isEmpty()) { - return new String[]{fileName, ""}; + return new String[] {fileName, ""}; } // 查找最后一个点号位置 @@ -130,18 +132,20 @@ public class FileNameGenerator { // 点号不存在或在开头(如 ".bashrc"),视为无扩展名 if (lastDotIndex <= 0) { - return new String[]{fileName, ""}; + return new String[] {fileName, ""}; } - String baseName = isHidden ? "." + nameWithoutDot.substring(0, lastDotIndex) : nameWithoutDot.substring(0, lastDotIndex); + String baseName = isHidden + ? "." + nameWithoutDot.substring(0, lastDotIndex) + : nameWithoutDot.substring(0, lastDotIndex); String extension = nameWithoutDot.substring(lastDotIndex + 1); // 扩展名不应包含路径分隔符(安全检查) if (extension.contains("/") || extension.contains("\\")) { - return new String[]{fileName, ""}; + return new String[] {fileName, ""}; } - return new String[]{baseName, extension}; + return new String[] {baseName, extension}; } /** @@ -194,7 +198,10 @@ public class FileNameGenerator { * @param fileMapper 文件Mapper * @return 文件名列表 */ - private static List selectNamesByParentPath(String parentPath, Long storageId, String namePrefix, FileMapper fileMapper) { + private static List selectNamesByParentPath(String parentPath, + Long storageId, + String namePrefix, + FileMapper fileMapper) { var wrapper = fileMapper.lambdaQuery() .eq(FileDO::getParentPath, parentPath) .eq(FileDO::getStorageId, storageId) diff --git a/pom.xml b/pom.xml index 6e721cac..8c4df8c8 100644 --- a/pom.xml +++ b/pom.xml @@ -13,7 +13,7 @@ top.continew.starter continew-starter - 2.15.0 + 2.16.0-SNAPSHOT top.continew.admin -- Gitee From 96bf23c9b50bba1f043a2640b1aa51a4d6a7d90c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E6=B3=BD=E5=A8=81?= <958142070@qq.com> Date: Mon, 2 Mar 2026 17:21:39 +0800 Subject: [PATCH 02/12] =?UTF-8?q?fix(file):=20=E4=BF=AE=E6=AD=A3=E7=BC=A9?= =?UTF-8?q?=E7=95=A5=E5=9B=BE=E8=B7=AF=E5=BE=84=E5=A4=84=E7=90=86=E9=80=BB?= =?UTF-8?q?=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 替换缩略图路径中的反斜杠为正斜杠 - 只有非http/https的路径才移除开头的斜杠 - 使用FileNameUtil获取缩略图名称代替字符串截取 - 优化缩略图名称的提取逻辑,提高兼容性与准确性 --- .../java/top/continew/admin/system/model/entity/FileDO.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/continew-system/src/main/java/top/continew/admin/system/model/entity/FileDO.java b/continew-system/src/main/java/top/continew/admin/system/model/entity/FileDO.java index 3d650c8d..86786d89 100644 --- a/continew-system/src/main/java/top/continew/admin/system/model/entity/FileDO.java +++ b/continew-system/src/main/java/top/continew/admin/system/model/entity/FileDO.java @@ -153,7 +153,11 @@ public class FileDO extends BaseDO { this.metadata = JSONUtil.toJsonStr(metadataMap); String thumbnailPath = fileInfo.getThumbnailPath(); if (StrUtil.isNotBlank(thumbnailPath)) { - this.thumbnailName = StrUtil.subAfter(thumbnailPath, StringConstants.SLASH, true); + String normalizedThumbnailPath = thumbnailPath.replace("\\", StringConstants.SLASH); + if (!normalizedThumbnailPath.startsWith("http://") && !normalizedThumbnailPath.startsWith("https://")) { + normalizedThumbnailPath = StrUtil.removePrefix(normalizedThumbnailPath, StringConstants.SLASH); + } + this.thumbnailName = FileNameUtil.getName(normalizedThumbnailPath); } this.thumbnailSize = fileInfo.getThumbnailSize(); this.thumbnailMetadata = null; -- Gitee From 83c51326813c09543d90c83619dc49f49c052d81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E6=B3=BD=E5=A8=81?= <958142070@qq.com> Date: Mon, 2 Mar 2026 17:32:42 +0800 Subject: [PATCH 03/12] =?UTF-8?q?refactor:=20=E5=B0=86MultipartUpload?= =?UTF-8?q?=E7=BC=93=E5=AD=98=E5=AE=9E=E7=8E=B0=E5=88=87=E6=8D=A2=E4=B8=BA?= =?UTF-8?q?Redis=E5=AE=9E=E7=8E=B0=E5=B9=B6=E5=88=A0=E9=99=A4=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E5=8F=8ARedis=20DAO?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除本地存储处理器及相关MultipartUpload本地和Redis DAO实现 - FileRecorderImpl中分片信息缓存逻辑切换为Redis操作,使用RedisUtils读写分片数据 - 将MD5到uploadId映射、分片上传信息及元数据缓存均迁移至Redis,并设置缓存过期时间 - 优化分片列表获取,读取Redis Hash并反序列化,保证分片按编号排序返回 - 删除分片及MD5映射时,改用Redis删除操作,增加异常捕获及日志 - 删除MultipartSession时同时删除相关缓存及MD5映射,避免遗留脏数据 - 移除对MultipartUploadDao接口的依赖,相关实现统一由缓存层管理 - 精简及优化部分日志输出,增强错误信息追踪能力 --- .../system/config/file/FileRecorderImpl.java | 143 ++++++-- .../admin/system/dao/MultipartUploadDao.java | 124 ------- .../impl/RedisMultipartUploadDaoDaoImpl.java | 260 -------------- .../admin/system/factory/S3ClientFactory.java | 61 ---- .../system/factory/StorageHandlerFactory.java | 73 ---- .../admin/system/handler/StorageHandler.java | 81 ----- .../handler/impl/LocalStorageHandler.java | 246 ------------- .../system/handler/impl/S3StorageHandler.java | 326 ------------------ 8 files changed, 117 insertions(+), 1197 deletions(-) delete mode 100644 continew-system/src/main/java/top/continew/admin/system/dao/MultipartUploadDao.java delete mode 100644 continew-system/src/main/java/top/continew/admin/system/dao/impl/RedisMultipartUploadDaoDaoImpl.java delete mode 100644 continew-system/src/main/java/top/continew/admin/system/factory/S3ClientFactory.java delete mode 100644 continew-system/src/main/java/top/continew/admin/system/factory/StorageHandlerFactory.java delete mode 100644 continew-system/src/main/java/top/continew/admin/system/handler/StorageHandler.java delete mode 100644 continew-system/src/main/java/top/continew/admin/system/handler/impl/LocalStorageHandler.java delete mode 100644 continew-system/src/main/java/top/continew/admin/system/handler/impl/S3StorageHandler.java diff --git a/continew-system/src/main/java/top/continew/admin/system/config/file/FileRecorderImpl.java b/continew-system/src/main/java/top/continew/admin/system/config/file/FileRecorderImpl.java index f80a7f57..350dbe09 100644 --- a/continew-system/src/main/java/top/continew/admin/system/config/file/FileRecorderImpl.java +++ b/continew-system/src/main/java/top/continew/admin/system/config/file/FileRecorderImpl.java @@ -20,15 +20,17 @@ import cn.hutool.core.collection.CollUtil; import cn.hutool.core.util.ObjectUtil; import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.URLUtil; +import cn.hutool.json.JSONUtil; import com.baomidou.mybatisplus.extension.conditions.query.LambdaQueryChainWrapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; -import top.continew.admin.system.dao.MultipartUploadDao; +import top.continew.admin.system.constant.MultipartUploadConstants; import top.continew.admin.system.mapper.FileMapper; import top.continew.admin.system.mapper.StorageMapper; import top.continew.admin.system.model.entity.FileDO; import top.continew.admin.system.model.entity.StorageDO; +import top.continew.starter.cache.redisson.util.RedisUtils; import top.continew.starter.core.constant.StringConstants; import top.continew.starter.core.util.CollUtils; import top.continew.starter.core.util.URLUtils; @@ -37,6 +39,9 @@ import top.continew.starter.storage.domain.model.resp.FilePartInfo; import top.continew.starter.storage.domain.model.resp.MultipartInitResp; import top.continew.starter.storage.service.FileRecorder; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.function.Function; @@ -55,7 +60,6 @@ public class FileRecorderImpl implements FileRecorder { private final FileMapper fileMapper; private final StorageMapper storageMapper; - private final MultipartUploadDao multipartUploadDao; @Override public boolean save(FileInfo fileInfo) { @@ -137,7 +141,7 @@ public class FileRecorderImpl implements FileRecorder { @Override public void saveFilePart(FilePartInfo filePartInfo) { - top.continew.admin.system.model.resp.file.FilePartInfo target = new top.continew.admin.system.model.resp.file.FilePartInfo(); + FilePartInfo target = new FilePartInfo(); target.setFileId(filePartInfo.getFileId()); target.setPartNumber(filePartInfo.getPartNumber()); target.setPartSize(filePartInfo.getPartSize()); @@ -148,45 +152,94 @@ public class FileRecorderImpl implements FileRecorder { target.setStatus(filePartInfo.getStatus()); target.setBucket(filePartInfo.getBucket()); target.setPath(filePartInfo.getPath()); - multipartUploadDao.setFilePart(filePartInfo.getUploadId(), target); + String key = MultipartUploadConstants.MULTIPART_PARTS_PREFIX + filePartInfo.getUploadId(); + String partKey = target.getPartNumber().toString(); + try { + RedisUtils.hSet(key, partKey, JSONUtil.toJsonStr(target)); + RedisUtils.expire(key, Duration.ofHours(MultipartUploadConstants.DEFAULT_EXPIRE_HOURS)); + log.debug("缓存分片信息: uploadId={}, partNumber={}", filePartInfo.getUploadId(), partKey); + } catch (Exception e) { + log.error("缓存分片信息失败: uploadId={}, partNumber={}", filePartInfo.getUploadId(), partKey, e); + throw new RuntimeException("缓存分片信息失败", e); + } } @Override public List getFileParts(String fileId) { - return multipartUploadDao.getFileParts(fileId).stream().map(filePartInfo -> { - FilePartInfo target = new FilePartInfo(); - target.setFileId(filePartInfo.getFileId()); - target.setPartNumber(filePartInfo.getPartNumber()); - target.setPartSize(filePartInfo.getPartSize()); - target.setPartMd5(filePartInfo.getPartMd5()); - target.setPartETag(filePartInfo.getPartETag()); - target.setUploadId(filePartInfo.getUploadId()); - target.setUploadTime(filePartInfo.getUploadTime()); - target.setStatus(filePartInfo.getStatus()); - target.setBucket(filePartInfo.getBucket()); - target.setPath(filePartInfo.getPath()); - return target; - }).toList(); + String key = MultipartUploadConstants.MULTIPART_PARTS_PREFIX + fileId; + try { + Map entries = RedisUtils.hGetAll(key); + if (CollUtil.isEmpty(entries)) { + return new ArrayList<>(); + } + return entries.values() + .stream() + .map(value -> JSONUtil.toBean(value + .toString(), top.continew.admin.system.model.resp.file.FilePartInfo.class)) + .sorted(Comparator.comparing(top.continew.admin.system.model.resp.file.FilePartInfo::getPartNumber)) + .map(filePartInfo -> { + FilePartInfo target = new FilePartInfo(); + target.setFileId(filePartInfo.getFileId()); + target.setPartNumber(filePartInfo.getPartNumber()); + target.setPartSize(filePartInfo.getPartSize()); + target.setPartMd5(filePartInfo.getPartMd5()); + target.setPartETag(filePartInfo.getPartETag()); + target.setUploadId(filePartInfo.getUploadId()); + target.setUploadTime(filePartInfo.getUploadTime()); + target.setStatus(filePartInfo.getStatus()); + target.setBucket(filePartInfo.getBucket()); + target.setPath(filePartInfo.getPath()); + return target; + }) + .toList(); + } catch (Exception e) { + log.error("获取分片列表失败: uploadId={}", fileId, e); + return new ArrayList<>(); + } } @Override public void deleteFileParts(String fileId) { - multipartUploadDao.deleteFileParts(fileId); + try { + RedisUtils.delete(MultipartUploadConstants.MULTIPART_PARTS_PREFIX + fileId); + log.debug("删除所有分片信息: uploadId={}", fileId); + } catch (Exception e) { + log.error("删除所有分片信息失败: uploadId={}", fileId, e); + } } @Override public String getUploadIdByMd5(String md5) { - return multipartUploadDao.getUploadIdByMd5(md5); + String md5Key = MultipartUploadConstants.MD5_TO_UPLOAD_ID_PREFIX + md5; + try { + return RedisUtils.hGet(md5Key, "uploadId"); + } catch (Exception e) { + log.error("根据MD5获取uploadId失败: md5={}", md5, e); + return null; + } } @Override public void setMd5Mapping(String md5, String uploadId) { - multipartUploadDao.setMd5Mapping(md5, uploadId); + String md5Key = MultipartUploadConstants.MD5_TO_UPLOAD_ID_PREFIX + md5; + try { + RedisUtils.hSet(md5Key, "uploadId", uploadId); + RedisUtils.expire(md5Key, Duration.ofHours(MultipartUploadConstants.DEFAULT_EXPIRE_HOURS)); + log.debug("缓存MD5映射: md5={}, uploadId={}", md5, uploadId); + } catch (Exception e) { + log.error("缓存MD5映射失败: md5={}, uploadId={}", md5, uploadId, e); + throw new RuntimeException("缓存MD5映射失败", e); + } } @Override public void deleteMd5Mapping(String md5) { - multipartUploadDao.deleteMd5Mapping(md5); + try { + RedisUtils.delete(MultipartUploadConstants.MD5_TO_UPLOAD_ID_PREFIX + md5); + log.debug("删除MD5映射: md5={}", md5); + } catch (Exception e) { + log.error("删除MD5映射失败: md5={}", md5, e); + } } @Override @@ -205,13 +258,36 @@ public class FileRecorderImpl implements FileRecorder { target.setPath(initResp.getPath()); target.setPartSize(initResp.getPartSize()); target.setUploadedPartNumbers(initResp.getUploadedPartNumbers()); - multipartUploadDao.setMultipartUpload(uploadId, target, metadata); + String key = MultipartUploadConstants.MULTIPART_UPLOAD_PREFIX + uploadId; + String metadataKey = MultipartUploadConstants.MULTIPART_METADATA_PREFIX + uploadId; + try { + RedisUtils.set(key, JSONUtil.toJsonStr(target), Duration + .ofHours(MultipartUploadConstants.DEFAULT_EXPIRE_HOURS)); + if (metadata != null && !metadata.isEmpty()) { + for (Map.Entry entry : metadata.entrySet()) { + RedisUtils.hSet(metadataKey, entry.getKey(), entry.getValue()); + } + RedisUtils.expire(metadataKey, Duration.ofHours(MultipartUploadConstants.DEFAULT_EXPIRE_HOURS)); + } + log.debug("缓存分片上传信息: uploadId={}", uploadId); + } catch (Exception e) { + log.error("缓存分片上传信息失败: uploadId={}", uploadId, e); + throw new RuntimeException("缓存分片上传信息失败", e); + } } @Override public MultipartInitResp getMultipartSession(String uploadId) { - top.continew.admin.system.model.resp.file.MultipartUploadInitResp source = multipartUploadDao - .getMultipartUpload(uploadId); + top.continew.admin.system.model.resp.file.MultipartUploadInitResp source = null; + try { + Object value = RedisUtils.get(MultipartUploadConstants.MULTIPART_UPLOAD_PREFIX + uploadId); + if (value != null) { + source = JSONUtil.toBean(value + .toString(), top.continew.admin.system.model.resp.file.MultipartUploadInitResp.class); + } + } catch (Exception e) { + log.error("获取分片上传信息失败: uploadId={}", uploadId, e); + } if (source == null) { return null; } @@ -234,7 +310,22 @@ public class FileRecorderImpl implements FileRecorder { @Override public void deleteMultipartSession(String uploadId) { - multipartUploadDao.deleteMultipartUpload(uploadId); + try { + String key = MultipartUploadConstants.MULTIPART_UPLOAD_PREFIX + uploadId; + String metadataKey = MultipartUploadConstants.MULTIPART_METADATA_PREFIX + uploadId; + String expireKey = MultipartUploadConstants.MULTIPART_EXPIRE_PREFIX + uploadId; + MultipartInitResp initResp = getMultipartSession(uploadId); + String fileMd5 = initResp != null ? initResp.getFileMd5() : null; + if (StrUtil.isNotBlank(fileMd5)) { + deleteMd5Mapping(fileMd5); + } + RedisUtils.delete(key); + RedisUtils.delete(metadataKey); + RedisUtils.delete(expireKey); + log.debug("删除分片上传信息: uploadId={}", uploadId); + } catch (Exception e) { + log.error("删除分片上传信息失败: uploadId={}", uploadId, e); + } } /** diff --git a/continew-system/src/main/java/top/continew/admin/system/dao/MultipartUploadDao.java b/continew-system/src/main/java/top/continew/admin/system/dao/MultipartUploadDao.java deleted file mode 100644 index 9fe20289..00000000 --- a/continew-system/src/main/java/top/continew/admin/system/dao/MultipartUploadDao.java +++ /dev/null @@ -1,124 +0,0 @@ -/* - * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package top.continew.admin.system.dao; - -import top.continew.admin.system.model.resp.file.FilePartInfo; -import top.continew.admin.system.model.resp.file.MultipartUploadInitResp; - -import java.util.List; -import java.util.Map; - -/** - * 分片上传持久化接口 - *

- * 纯粹的缓存操作,不包含业务逻辑: - * 1. MD5到uploadId的映射管理 - * 2. 分片信息缓存 - * 3. 上传状态缓存 - *

- * - * @author KAI - * @since 2.14.0 - */ -public interface MultipartUploadDao { - - /** - * 根据MD5获取uploadId - * - * @param md5 文件MD5值 - * @return uploadId,如果不存在则返回null - */ - String getUploadIdByMd5(String md5); - - /** - * 缓存MD5到uploadId的映射 - * - * @param md5 文件MD5值 - * @param uploadId 上传ID - */ - void setMd5Mapping(String md5, String uploadId); - - /** - * 删除MD5映射 - * - * @param md5 文件MD5值 - */ - void deleteMd5Mapping(String md5); - - /** - * 设置缓存分片上传信息 - * - * @param uploadId 上传ID - * @param initResp 初始化响应 - * @param metadata 元数据 - */ - void setMultipartUpload(String uploadId, MultipartUploadInitResp initResp, Map metadata); - - /** - * 获取分片上传信息 - * - * @param uploadId 上传ID - * @return 分片上传信息,如果不存在则返回null - */ - MultipartUploadInitResp getMultipartUpload(String uploadId); - - /** - * 删除分片上传信息 - * - * @param uploadId 上传ID - */ - void deleteMultipartUpload(String uploadId); - - void deleteMultipartUploadAll(String uploadId); - - /** - * 设置缓存分片信息 - * - * @param uploadId 上传ID - * @param filePartInfo 分片信息 - */ - void setFilePart(String uploadId, FilePartInfo filePartInfo); - - /** - * 获取所有分片信息 - * - * @param uploadId 上传ID - * @return 分片信息列表 - */ - List getFileParts(String uploadId); - - /** - * 删除所有分片信息 - * - * @param uploadId 上传ID - */ - void deleteFileParts(String uploadId); - - /** - * 检查分片是否存在 - * - * @param uploadId 上传ID - * @param partNumber 分片编号 - * @return 是否存在 - */ - boolean existsFilePart(String uploadId, int partNumber); - - /** - * 清理过期的缓存数据 - */ - void cleanupExpiredData(); -} diff --git a/continew-system/src/main/java/top/continew/admin/system/dao/impl/RedisMultipartUploadDaoDaoImpl.java b/continew-system/src/main/java/top/continew/admin/system/dao/impl/RedisMultipartUploadDaoDaoImpl.java deleted file mode 100644 index 577c1326..00000000 --- a/continew-system/src/main/java/top/continew/admin/system/dao/impl/RedisMultipartUploadDaoDaoImpl.java +++ /dev/null @@ -1,260 +0,0 @@ -/* - * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package top.continew.admin.system.dao.impl; - -import cn.hutool.core.util.StrUtil; -import cn.hutool.json.JSONUtil; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Repository; -import top.continew.admin.system.constant.MultipartUploadConstants; -import top.continew.admin.system.dao.MultipartUploadDao; -import top.continew.admin.system.model.resp.file.FilePartInfo; -import top.continew.admin.system.model.resp.file.MultipartUploadInitResp; -import top.continew.starter.cache.redisson.util.RedisUtils; - -import java.time.Duration; -import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; -import java.util.*; -import java.util.stream.Collectors; - -/** - * Redis分片上传缓存实现 - *

- * 核心功能: - * 1. MD5到uploadId的映射管理 - * 2. 分片信息缓存 - * 3. 上传状态缓存 - *

- * - * @author KAI - * @since 2025/7/30 17:40 - */ -@Slf4j -@Repository -public class RedisMultipartUploadDaoDaoImpl implements MultipartUploadDao { - - @Override - public String getUploadIdByMd5(String md5) { - String md5Key = MultipartUploadConstants.MD5_TO_UPLOAD_ID_PREFIX + md5; - try { - return RedisUtils.hGet(md5Key, "uploadId"); - } catch (Exception e) { - log.error("根据MD5获取uploadId失败: md5={}", md5, e); - return null; - } - } - - @Override - public void setMd5Mapping(String md5, String uploadId) { - String md5Key = MultipartUploadConstants.MD5_TO_UPLOAD_ID_PREFIX + md5; - try { - RedisUtils.hSet(md5Key, "uploadId", uploadId); - RedisUtils.expire(md5Key, Duration.ofHours(MultipartUploadConstants.DEFAULT_EXPIRE_HOURS)); - log.debug("缓存MD5映射: md5={}, uploadId={}", md5, uploadId); - } catch (Exception e) { - log.error("缓存MD5映射失败: md5={}, uploadId={}", md5, uploadId, e); - throw new RuntimeException("缓存MD5映射失败", e); - } - } - - @Override - public void deleteMd5Mapping(String md5) { - String md5Key = MultipartUploadConstants.MD5_TO_UPLOAD_ID_PREFIX + md5; - try { - RedisUtils.delete(md5Key); - log.debug("删除MD5映射: md5={}", md5); - } catch (Exception e) { - log.error("删除MD5映射失败: md5={}", md5, e); - } - } - - private String getMd5Mapping(String uploadId) { - List list = RedisUtils.getList(MultipartUploadConstants.MD5_TO_UPLOAD_ID_PREFIX); - return null; - } - - @Override - public void setMultipartUpload(String uploadId, MultipartUploadInitResp initResp, Map metadata) { - String key = MultipartUploadConstants.MULTIPART_UPLOAD_PREFIX + uploadId; - String metadataKey = MultipartUploadConstants.MULTIPART_METADATA_PREFIX + uploadId; - - try { - // 缓存初始化信息 - RedisUtils.set(key, JSONUtil.toJsonStr(initResp), Duration - .ofHours(MultipartUploadConstants.DEFAULT_EXPIRE_HOURS)); - - // 缓存元数据 - if (metadata != null && !metadata.isEmpty()) { - for (Map.Entry entry : metadata.entrySet()) { - RedisUtils.hSet(metadataKey, entry.getKey(), entry.getValue()); - } - RedisUtils.expire(metadataKey, Duration.ofHours(MultipartUploadConstants.DEFAULT_EXPIRE_HOURS)); - } - - log.debug("缓存分片上传信息: uploadId={}", uploadId); - } catch (Exception e) { - log.error("缓存分片上传信息失败: uploadId={}", uploadId, e); - throw new RuntimeException("缓存分片上传信息失败", e); - } - } - - @Override - public MultipartUploadInitResp getMultipartUpload(String uploadId) { - String key = MultipartUploadConstants.MULTIPART_UPLOAD_PREFIX + uploadId; - try { - Object value = RedisUtils.get(key); - if (value != null) { - return JSONUtil.toBean(value.toString(), MultipartUploadInitResp.class); - } - return null; - } catch (Exception e) { - log.error("获取分片上传信息失败: uploadId={}", uploadId, e); - return null; - } - } - - @Override - public void deleteMultipartUpload(String uploadId) { - try { - String key = MultipartUploadConstants.MULTIPART_UPLOAD_PREFIX + uploadId; - String metadataKey = MultipartUploadConstants.MULTIPART_METADATA_PREFIX + uploadId; - String expireKey = MultipartUploadConstants.MULTIPART_EXPIRE_PREFIX + uploadId; - - // 先获取MD5信息,再删除数据 - MultipartUploadInitResp initResp = getMultipartUpload(uploadId); - String fileMd5 = initResp != null ? initResp.getFileMd5() : null; - if (StrUtil.isNotBlank(fileMd5)) { - deleteMd5Mapping(fileMd5); - } - - // 删除分片上传相关数据 - RedisUtils.delete(key); - RedisUtils.delete(metadataKey); - RedisUtils.delete(expireKey); - - log.debug("删除分片上传信息: uploadId={}", uploadId); - } catch (Exception e) { - log.error("删除分片上传信息失败: uploadId={}", uploadId, e); - } - } - - @Override - public void deleteMultipartUploadAll(String uploadId) { - this.deleteMultipartUpload(uploadId); - this.deleteFileParts(uploadId); - // this.deleteMd5Mapping(); - } - - @Override - public void setFilePart(String uploadId, FilePartInfo partInfo) { - String key = MultipartUploadConstants.MULTIPART_PARTS_PREFIX + uploadId; - String partKey = partInfo.getPartNumber().toString(); - - try { - RedisUtils.hSet(key, partKey, JSONUtil.toJsonStr(partInfo)); - RedisUtils.expire(key, Duration.ofHours(MultipartUploadConstants.DEFAULT_EXPIRE_HOURS)); - log.debug("缓存分片信息: uploadId={}, partNumber={}", uploadId, partKey); - } catch (Exception e) { - log.error("缓存分片信息失败: uploadId={}, partNumber={}", uploadId, partKey, e); - throw new RuntimeException("缓存分片信息失败", e); - } - } - - @Override - public List getFileParts(String uploadId) { - String key = MultipartUploadConstants.MULTIPART_PARTS_PREFIX + uploadId; - - try { - Map entries = RedisUtils.hGetAll(key); - if (entries.isEmpty()) { - return new ArrayList<>(); - } - - return entries.values() - .stream() - .map(value -> JSONUtil.toBean(value.toString(), FilePartInfo.class)) - .sorted(Comparator.comparing(FilePartInfo::getPartNumber)) - .collect(Collectors.toList()); - } catch (Exception e) { - log.error("获取分片列表失败: uploadId={}", uploadId, e); - return new ArrayList<>(); - } - } - - @Override - public void deleteFileParts(String uploadId) { - String key = MultipartUploadConstants.MULTIPART_PARTS_PREFIX + uploadId; - - try { - RedisUtils.delete(key); - log.debug("删除所有分片信息: uploadId={}", uploadId); - } catch (Exception e) { - log.error("删除所有分片信息失败: uploadId={}", uploadId, e); - } - } - - @Override - public boolean existsFilePart(String uploadId, int partNumber) { - String key = MultipartUploadConstants.MULTIPART_PARTS_PREFIX + uploadId; - String partKey = String.valueOf(partNumber); - return RedisUtils.hExists(key, partKey); - } - - @Override - public void cleanupExpiredData() { - try { - // 获取所有分片上传的过期时间 - Collection keys = RedisUtils.keys(MultipartUploadConstants.MULTIPART_EXPIRE_PREFIX + "*"); - if (keys.isEmpty()) { - return; - } - - LocalDateTime now = LocalDateTime.now(); - List expiredUploadIds = new ArrayList<>(); - - for (String key : keys) { - String uploadId = key.substring(MultipartUploadConstants.MULTIPART_EXPIRE_PREFIX.length()); - Object value = RedisUtils.get(key); - - if (value != null) { - try { - LocalDateTime expireTime = LocalDateTime.parse(value - .toString(), DateTimeFormatter.ISO_LOCAL_DATE_TIME); - if (now.isAfter(expireTime)) { - expiredUploadIds.add(uploadId); - } - } catch (Exception e) { - log.warn("解析过期时间失败: uploadId={}, value={}", uploadId, value); - expiredUploadIds.add(uploadId); - } - } - } - - // 删除过期的数据 - for (String uploadId : expiredUploadIds) { - deleteMultipartUpload(uploadId); - deleteFileParts(uploadId); - log.info("清理过期数据: uploadId={}", uploadId); - } - - log.info("清理过期数据完成: count={}", expiredUploadIds.size()); - } catch (Exception e) { - log.error("清理过期数据失败", e); - } - } -} diff --git a/continew-system/src/main/java/top/continew/admin/system/factory/S3ClientFactory.java b/continew-system/src/main/java/top/continew/admin/system/factory/S3ClientFactory.java deleted file mode 100644 index bb73cb0b..00000000 --- a/continew-system/src/main/java/top/continew/admin/system/factory/S3ClientFactory.java +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package top.continew.admin.system.factory; - -import jakarta.annotation.PreDestroy; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; -import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; -import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; -import software.amazon.awssdk.regions.Region; -import software.amazon.awssdk.services.s3.S3Client; -import software.amazon.awssdk.services.s3.S3Configuration; -import software.amazon.awssdk.utils.SdkAutoCloseable; -import top.continew.admin.system.model.entity.StorageDO; - -import java.net.URI; -import java.util.concurrent.ConcurrentHashMap; - -/** - * 异步 S3 客户端工厂 - *

支持多 endpoint / 多 accessKey 的动态客户端池

- */ -@Slf4j -@Component -public class S3ClientFactory { - - private final ConcurrentHashMap CLIENT_CACHE = new ConcurrentHashMap<>(); - - public S3Client getClient(StorageDO storage) { - String key = storage.getEndpoint() + "|" + storage.getAccessKey(); - return CLIENT_CACHE.computeIfAbsent(key, k -> { - StaticCredentialsProvider auth = StaticCredentialsProvider.create(AwsBasicCredentials.create(storage - .getAccessKey(), storage.getSecretKey())); - return S3Client.builder() - .credentialsProvider(auth) - .endpointOverride(URI.create(storage.getEndpoint())) - .region(Region.US_EAST_1) - .serviceConfiguration(S3Configuration.builder().chunkedEncodingEnabled(false).build()) - .build(); - }); - } - - @PreDestroy - public void closeAll() { - CLIENT_CACHE.values().forEach(SdkAutoCloseable::close); - } -} diff --git a/continew-system/src/main/java/top/continew/admin/system/factory/StorageHandlerFactory.java b/continew-system/src/main/java/top/continew/admin/system/factory/StorageHandlerFactory.java deleted file mode 100644 index f6b0c740..00000000 --- a/continew-system/src/main/java/top/continew/admin/system/factory/StorageHandlerFactory.java +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package top.continew.admin.system.factory;/* - * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import cn.hutool.core.util.StrUtil; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Component; -import top.continew.admin.system.enums.StorageTypeEnum; -import top.continew.admin.system.handler.StorageHandler; -import top.continew.starter.core.exception.BaseException; - -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.concurrent.ConcurrentHashMap; - -/** - * 存储处理器工厂 - *

按类型分发 StorageHandler

- * - * @author KAI - * @since 2025/07/24 13:35 - */ -@Component -public class StorageHandlerFactory { - private final Map HANDLER_MAP = new ConcurrentHashMap<>(); - - @Autowired - public StorageHandlerFactory(List handlers) { - for (StorageHandler handler : handlers) { - HANDLER_MAP.put(handler.getType(), handler); - } - } - - /** - * 获取指定类型的存储处理器 - * - * @param type 存储类型 - * @return StorageHandler - */ - public StorageHandler createHandler(StorageTypeEnum type) { - return Optional.ofNullable(HANDLER_MAP.get(type)) - .orElseThrow(() -> new BaseException(StrUtil.format("不存在此类型存储处理器:{}: ", type))); - } -} \ No newline at end of file diff --git a/continew-system/src/main/java/top/continew/admin/system/handler/StorageHandler.java b/continew-system/src/main/java/top/continew/admin/system/handler/StorageHandler.java deleted file mode 100644 index 4882de6e..00000000 --- a/continew-system/src/main/java/top/continew/admin/system/handler/StorageHandler.java +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package top.continew.admin.system.handler; - -import org.springframework.web.multipart.MultipartFile; -import top.continew.admin.system.enums.StorageTypeEnum; -import top.continew.admin.system.model.entity.StorageDO; -import top.continew.admin.system.model.req.MultipartUploadInitReq; -import top.continew.admin.system.model.resp.file.MultipartUploadInitResp; -import top.continew.admin.system.model.resp.file.MultipartUploadResp; - -import java.util.List; - -/** - * 存储类型处理器 - *

- * 专注于文件操作,不包含业务逻辑 - * - * @author KAI - * @since 2025/7/30 17:15 - */ -public interface StorageHandler { - - MultipartUploadInitResp initMultipartUpload(StorageDO storageDO, MultipartUploadInitReq req); - - /** - * 分片上传 - * - * @param storageDO 存储实体 - * @param path 存储路径 - * @param uploadId 文件名 - * @param file 文件对象 - * @return {@link MultipartUploadResp} 分片上传结果 - */ - MultipartUploadResp uploadPart(StorageDO storageDO, - String path, - String uploadId, - Integer partNumber, - MultipartFile file); - - /** - * 合并分片 - * - * @param storageDO 存储实体 - * @param uploadId 上传Id - */ - void completeMultipartUpload(StorageDO storageDO, - List parts, - String path, - String uploadId, - boolean needVerify); - - /** - * 清楚分片 - * - * @param storageDO 存储实体 - * @param uploadId 上传Id - */ - void cleanPart(StorageDO storageDO, String uploadId); - - /** - * 获取存储类型 - * - * @return 存储类型 - */ - StorageTypeEnum getType(); -} diff --git a/continew-system/src/main/java/top/continew/admin/system/handler/impl/LocalStorageHandler.java b/continew-system/src/main/java/top/continew/admin/system/handler/impl/LocalStorageHandler.java deleted file mode 100644 index 7fe44788..00000000 --- a/continew-system/src/main/java/top/continew/admin/system/handler/impl/LocalStorageHandler.java +++ /dev/null @@ -1,246 +0,0 @@ -/* - * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package top.continew.admin.system.handler.impl; - -import cn.hutool.core.io.FileUtil; -import cn.hutool.core.util.StrUtil; -import cn.hutool.crypto.digest.DigestUtil; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; -import org.springframework.web.multipart.MultipartFile; -import top.continew.admin.system.constant.MultipartUploadConstants; -import top.continew.admin.system.enums.StorageTypeEnum; -import top.continew.admin.system.handler.StorageHandler; -import top.continew.admin.system.model.entity.StorageDO; -import top.continew.admin.system.model.req.MultipartUploadInitReq; -import top.continew.admin.system.model.resp.file.MultipartUploadInitResp; -import top.continew.admin.system.model.resp.file.MultipartUploadResp; -import top.continew.admin.system.service.FileService; -import top.continew.starter.core.exception.BaseException; - -import java.io.File; -import java.io.OutputStream; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.nio.file.StandardOpenOption; -import java.util.Comparator; -import java.util.List; -import java.util.UUID; - -/** - * 本地存储处理器 - *

实现分片上传、合并、取消等操作。

- * - * @author KAI - * @since 2023/7/30 22:58 - */ -@Slf4j -@Component -@RequiredArgsConstructor -public class LocalStorageHandler implements StorageHandler { - - private final FileService fileService; - - @Override - public MultipartUploadInitResp initMultipartUpload(StorageDO storageDO, MultipartUploadInitReq req) { - String uploadId = UUID.randomUUID().toString(); - String bucket = storageDO.getBucketName(); // 本地存储中,bucket是存储根路径 - String parentPath = req.getParentPath(); - String fileName = req.getFileName(); - StrUtil.blankToDefault(parentPath, StrUtil.SLASH); - String relativePath = StrUtil.endWith(parentPath, StrUtil.SLASH) - ? parentPath + fileName - : parentPath + StrUtil.SLASH + fileName; - try { - // 创建临时目录用于存储分片 - String tempDirPath = buildTempDirPath(bucket, uploadId); - FileUtil.mkdir(tempDirPath); - fileService.createParentDir(parentPath, storageDO); - // 构建返回结果 - MultipartUploadInitResp result = new MultipartUploadInitResp(); - result.setBucket(bucket); - result.setFileId(UUID.randomUUID().toString()); - result.setUploadId(uploadId); - result.setPlatform(storageDO.getCode()); - result.setFileName(fileName); - result.setFileMd5(req.getFileMd5()); - result.setFileSize(req.getFileSize()); - result.setExtension(FileUtil.extName(fileName)); - result.setContentType(req.getContentType()); - result.setPath(relativePath); - result.setParentPath(parentPath); - result.setPartSize(MultipartUploadConstants.MULTIPART_UPLOAD_PART_SIZE); - log.info("本地存储初始化分片上传成功: uploadId={}, path={}", uploadId, parentPath); - return result; - } catch (Exception e) { - log.error("本地存储初始化分片上传失败: {}", e.getMessage(), e); - throw new BaseException("本地存储初始化分片上传失败: " + e.getMessage(), e); - } - } - - @Override - public MultipartUploadResp uploadPart(StorageDO storageDO, - String path, - String uploadId, - Integer partNumber, - MultipartFile file) { - try { - long size = file.getSize(); - String bucket = storageDO.getBucketName(); - // 获取临时目录路径 - String tempDirPath = buildTempDirPath(bucket, uploadId); - - // 确保临时目录存在 - File tempDir = new File(tempDirPath); - if (!tempDir.exists()) { - FileUtil.mkdir(tempDirPath); - } - - // 保存分片文件 - String partFilePath = tempDirPath + File.separator + String.format("part_%s", partNumber); - File partFile = new File(partFilePath); - file.transferTo(partFile); - - // 计算ETag (使用MD5) - String etag = DigestUtil.md5Hex(partFile); - - // 构建返回结果 - MultipartUploadResp result = new MultipartUploadResp(); - result.setPartNumber(partNumber); - result.setPartETag(etag); - result.setPartSize(size); - result.setSuccess(true); - - log.info("本地存储分片上传成功: uploadId={}, partNumber={}, etag={}", uploadId, partNumber, etag); - return result; - } catch (Exception e) { - log.error("本地存储分片上传失败: uploadId={}, partNumber={}, error={}", uploadId, partNumber, e.getMessage(), e); - - MultipartUploadResp result = new MultipartUploadResp(); - result.setPartNumber(partNumber); - result.setSuccess(false); - result.setErrorMessage(e.getMessage()); - return result; - } - } - - @Override - public void completeMultipartUpload(StorageDO storageDO, - List parts, - String path, - String uploadId, - boolean needVerify) { - String bucket = storageDO.getBucketName(); // 本地存储中,bucket是存储根路径 - String tempDirPath = buildTempDirPath(bucket, uploadId); - - try { - // 本地存储不需要验证,直接使用传入的分片信息 - Path targetPath = Paths.get(bucket, path); - Files.createDirectories(targetPath.getParent()); - - // 合并分片 - try (OutputStream out = Files - .newOutputStream(targetPath, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING)) { - - // 按分片编号排序 - List sortedParts = parts.stream() - .filter(MultipartUploadResp::isSuccess) - .sorted(Comparator.comparingInt(MultipartUploadResp::getPartNumber)) - .toList(); - - // 逐个读取并写入 - for (MultipartUploadResp part : sortedParts) { - Path partPath = Paths.get(tempDirPath, String.format("part_%s", part.getPartNumber())); - - if (!Files.exists(partPath)) { - throw new BaseException("分片文件不存在: partNumber=" + part.getPartNumber()); - } - - Files.copy(partPath, out); - } - } - // 清理临时文件 - cleanupTempFiles(tempDirPath); - - log.info("本地存储分片合并成功: uploadId={}, targetPath={}", uploadId, targetPath); - - } catch (Exception e) { - log.error("本地存储分片合并失败: uploadId={}, path={}, error={}", uploadId, path, e.getMessage(), e); - throw new BaseException("完成分片上传失败: " + e.getMessage(), e); - } - } - - @Override - public void cleanPart(StorageDO storageDO, String uploadId) { - try { - String bucket = storageDO.getBucketName(); - // 获取临时目录路径 - String tempDirPath = buildTempDirPath(bucket, uploadId); - - // 清理临时文件 - cleanupTempFiles(tempDirPath); - - log.info("本地存储分片清理成功: uploadId={}", uploadId); - } catch (Exception e) { - log.error("本地存储分片清理失败: uploadId={}, error={}", uploadId, e.getMessage(), e); - throw new BaseException("本地存储分片清理失败: " + e.getMessage(), e); - } - } - - @Override - public StorageTypeEnum getType() { - return StorageTypeEnum.LOCAL; - } - - /** - * 构建临时目录路径 - * - * @param bucket 存储桶(本地存储根路径) - * @param uploadId 上传ID - * @return 临时目录路径 - */ - private String buildTempDirPath(String bucket, String uploadId) { - return StrUtil - .appendIfMissing(bucket, File.separator) + MultipartUploadConstants.TEMP_DIR_NAME + File.separator + uploadId; - } - - /** - * 构建目标文件路径 - * - * @param bucket 存储桶(本地存储根路径) - * @param path 文件路径 - * @return 目标文件路径 - */ - private String buildTargetDirPath(String bucket, String path) { - return StrUtil.appendIfMissing(bucket, File.separator) + path; - } - - /** - * 清理临时文件 - * - * @param tempDirPath 临时目录路径 - */ - private void cleanupTempFiles(String tempDirPath) { - try { - FileUtil.del(tempDirPath); - } catch (Exception e) { - log.warn("清理临时文件失败: {}, {}", tempDirPath, e.getMessage()); - } - } -} \ No newline at end of file diff --git a/continew-system/src/main/java/top/continew/admin/system/handler/impl/S3StorageHandler.java b/continew-system/src/main/java/top/continew/admin/system/handler/impl/S3StorageHandler.java deleted file mode 100644 index 6183849f..00000000 --- a/continew-system/src/main/java/top/continew/admin/system/handler/impl/S3StorageHandler.java +++ /dev/null @@ -1,326 +0,0 @@ -/* - * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package top.continew.admin.system.handler.impl; - -import cn.hutool.core.io.FileUtil; -import cn.hutool.core.util.StrUtil; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; -import org.springframework.web.multipart.MultipartFile; -import software.amazon.awssdk.core.sync.RequestBody; -import software.amazon.awssdk.services.s3.S3Client; -import software.amazon.awssdk.services.s3.model.*; -import top.continew.admin.system.constant.MultipartUploadConstants; -import top.continew.admin.system.enums.StorageTypeEnum; -import top.continew.admin.system.factory.S3ClientFactory; -import top.continew.admin.system.handler.StorageHandler; -import top.continew.admin.system.model.entity.StorageDO; -import top.continew.admin.system.model.req.MultipartUploadInitReq; -import top.continew.admin.system.model.resp.file.MultipartUploadInitResp; -import top.continew.admin.system.model.resp.file.MultipartUploadResp; -import top.continew.admin.system.service.FileService; -import top.continew.starter.core.exception.BaseException; - -import java.util.*; -import java.util.stream.Collectors; - -/** - * S3存储处理器 - *

使用AWS SDK 2.x版本API。实现分片上传、合并、取消等操作。

- * - * @author KAI - * @since 2025/07/30 20:10 - */ -@Component -@Slf4j -@RequiredArgsConstructor -public class S3StorageHandler implements StorageHandler { - - private final S3ClientFactory s3ClientFactory; - - private final FileService fileService; - - @Override - public MultipartUploadInitResp initMultipartUpload(StorageDO storageDO, MultipartUploadInitReq req) { - String bucket = storageDO.getBucketName(); - String parentPath = req.getParentPath(); - String fileName = req.getFileName(); - String contentType = req.getContentType(); - StrUtil.blankToDefault(parentPath, StrUtil.SLASH); - String relativePath = StrUtil.endWith(parentPath, StrUtil.SLASH) - ? parentPath + fileName - : parentPath + StrUtil.SLASH + fileName; - - fileService.createParentDir(parentPath, storageDO); - try { - // 构建请求 - CreateMultipartUploadRequest.Builder requestBuilder = CreateMultipartUploadRequest.builder() - .bucket(bucket) - .key(buildS3Key(relativePath)) - .contentType(contentType); - - // 添加元数据 暂时注释掉 mataData传递中文会导致签名校验不通过 - // if (metaData != null && !metaData.isEmpty()) { - // requestBuilder.metadata(metaData); - // } - - S3Client s3Client = s3ClientFactory.getClient(storageDO); - log.info("S3初始化分片上传: bucket={}, key={}, contentType={}", bucket, buildS3Key(relativePath), contentType); - - // 执行请求 - CreateMultipartUploadResponse response = s3Client.createMultipartUpload(requestBuilder.build()); - String uploadId = response.uploadId(); - // 构建返回结果 - MultipartUploadInitResp result = new MultipartUploadInitResp(); - result.setBucket(bucket); - result.setFileId(UUID.randomUUID().toString()); - - result.setUploadId(uploadId); - result.setPlatform(storageDO.getCode()); - result.setFileName(fileName); - result.setFileMd5(req.getFileMd5()); - result.setFileSize(req.getFileSize()); - result.setExtension(FileUtil.extName(fileName)); - result.setContentType(req.getContentType()); - result.setPath(relativePath); - result.setParentPath(parentPath); - result.setPartSize(MultipartUploadConstants.MULTIPART_UPLOAD_PART_SIZE); - log.info("S3初始化分片上传成功: uploadId={}, path={}", uploadId, relativePath); - return result; - - } catch (Exception e) { - throw new BaseException("S3初始化分片上传失败: " + e.getMessage(), e); - } - } - - @Override - public MultipartUploadResp uploadPart(StorageDO storageDO, - String path, - String uploadId, - Integer partNumber, - MultipartFile file) { - try { - String bucket = storageDO.getBucketName(); - // 读取数据到内存(注意:实际使用时可能需要优化大文件处理) - byte[] bytes = file.getBytes(); - - // 构建请求 - UploadPartRequest request = UploadPartRequest.builder() - .bucket(bucket) - .key(buildS3Key(path)) - .uploadId(uploadId) - .partNumber(partNumber) - .contentLength((long)bytes.length) - .build(); - - // 执行上传 - S3Client s3Client = s3ClientFactory.getClient(storageDO); - UploadPartResponse response = s3Client.uploadPart(request, RequestBody.fromBytes(bytes)); - // 构建返回结果 - MultipartUploadResp result = new MultipartUploadResp(); - result.setPartNumber(partNumber); - result.setPartETag(response.eTag()); - result.setSuccess(true); - log.info("S3上传分片成功: partNumber={} for key={} with uploadId={}", partNumber, path, uploadId); - log.info("上传分片ETag: {}", response.eTag()); - - return result; - - } catch (Exception e) { - MultipartUploadResp result = new MultipartUploadResp(); - result.setPartNumber(partNumber); - result.setSuccess(false); - result.setErrorMessage(e.getMessage()); - log.error("S3上传分片失败: partNumber={} for key={} with uploadId={} errorMessage={}", partNumber, path, uploadId, e - .getMessage()); - return result; - } - } - - @Override - public void completeMultipartUpload(StorageDO storageDO, - List parts, - String path, - String uploadId, - boolean needVerify) { - if (path == null) { - throw new BaseException("无效的uploadId: " + uploadId); - } - String bucket = storageDO.getBucketName(); - S3Client s3Client = s3ClientFactory.getClient(storageDO); - // 如果需要验证,比较本地记录和S3的分片信息 - if (needVerify) { - List s3Parts = listParts(bucket, path, uploadId, s3Client); - validateParts(parts, s3Parts); - } - // 构建已完成的分片列表 - List completedParts = parts.stream() - .filter(MultipartUploadResp::isSuccess) - .map(part -> CompletedPart.builder().partNumber(part.getPartNumber()).eTag(part.getPartETag()).build()) - .sorted(Comparator.comparingInt(CompletedPart::partNumber)) - .collect(Collectors.toList()); - - // 构建请求 - CompleteMultipartUploadRequest request = CompleteMultipartUploadRequest.builder() - .bucket(bucket) - .key(buildS3Key(path)) - .uploadId(uploadId) - .multipartUpload(CompletedMultipartUpload.builder().parts(completedParts).build()) - .build(); - - // 完成上传 - s3Client.completeMultipartUpload(request); - log.info("S3完成分片上传: key={}, uploadId={}, parts={}", buildS3Key(path), uploadId, completedParts.size()); - } - - @Override - public void cleanPart(StorageDO storageDO, String uploadId) { - try { - String bucket = storageDO.getBucketName(); - S3Client s3Client = s3ClientFactory.getClient(storageDO); - - // 列出所有未完成的分片上传 - ListMultipartUploadsRequest listRequest = ListMultipartUploadsRequest.builder().bucket(bucket).build(); - - ListMultipartUploadsResponse listResponse = s3Client.listMultipartUploads(listRequest); - - // 查找匹配的上传任务 - Optional targetUpload = listResponse.uploads() - .stream() - .filter(upload -> upload.uploadId().equals(uploadId)) - .findFirst(); - - if (targetUpload.isPresent()) { - MultipartUpload upload = targetUpload.get(); - - // 取消分片上传 - AbortMultipartUploadRequest abortRequest = AbortMultipartUploadRequest.builder() - .bucket(bucket) - .key(upload.key()) - .uploadId(uploadId) - .build(); - - s3Client.abortMultipartUpload(abortRequest); - log.info("S3清理分片上传成功: bucket={}, key={}, uploadId={}", bucket, upload.key(), uploadId); - } else { - log.warn("S3未找到对应的分片上传任务: uploadId={}", uploadId); - } - - } catch (Exception e) { - log.error("S3清理分片上传失败: uploadId={}, error={}", uploadId, e.getMessage(), e); - throw new BaseException("S3清理分片上传失败: " + e.getMessage(), e); - } - } - - @Override - public StorageTypeEnum getType() { - return StorageTypeEnum.OSS; - } - - /** - * 列出已上传的分片 - */ - public List listParts(String bucket, String path, String uploadId, S3Client s3Client) { - try { - // 构建请求 - ListPartsRequest request = ListPartsRequest.builder() - .bucket(bucket) - .key(buildS3Key(path)) - .uploadId(uploadId) - .build(); - - // 获取分片列表 - ListPartsResponse response = s3Client.listParts(request); - - // 转换结果 - return response.parts().stream().map(part -> { - MultipartUploadResp result = new MultipartUploadResp(); - result.setPartNumber(part.partNumber()); - result.setPartETag(part.eTag()); - result.setSuccess(true); - return result; - }).collect(Collectors.toList()); - - } catch (Exception e) { - throw new BaseException("S3列出分片失败: " + e.getMessage(), e); - } - } - - /** - * 验证分片一致性 - * - * @param recordParts 记录部件 - * @param s3Parts s3零件 - */ - private void validateParts(List recordParts, List s3Parts) { - Map recordMap = recordParts.stream() - .collect(Collectors.toMap(MultipartUploadResp::getPartNumber, MultipartUploadResp::getPartETag)); - - Map s3Map = s3Parts.stream() - .collect(Collectors.toMap(MultipartUploadResp::getPartNumber, MultipartUploadResp::getPartETag)); - - // 检查分片数量 - if (recordMap.size() != s3Map.size()) { - throw new BaseException(String.format("分片数量不一致: 本地记录=%d, S3=%d", recordMap.size(), s3Map.size())); - } - - // 检查每个分片 - List missingParts = new ArrayList<>(); - List mismatchParts = new ArrayList<>(); - - for (Map.Entry entry : recordMap.entrySet()) { - Integer partNumber = entry.getKey(); - String recordETag = entry.getValue(); - String s3ETag = s3Map.get(partNumber); - - if (s3ETag == null) { - missingParts.add(partNumber); - } else if (!recordETag.equals(s3ETag)) { - mismatchParts.add(partNumber); - } - } - - if (!missingParts.isEmpty()) { - throw new BaseException("S3缺失分片: " + missingParts); - } - - if (!mismatchParts.isEmpty()) { - throw new BaseException("分片ETag不匹配: " + mismatchParts); - } - } - - /** - * 规范化 S3 对象 key,去掉前导斜杠,合并多余斜杠。 - * - * @param rawKey 你传入的完整路径,比如 "/folder//子目录//文件名.png" - * @return 规范化后的 key,比如 "folder/子目录/文件名.png" - */ - public static String buildS3Key(String rawKey) { - if (rawKey == null || rawKey.isEmpty()) { - throw new IllegalArgumentException("key 不能为空"); - } - // 去掉前导斜杠 - while (rawKey.startsWith("/")) { - rawKey = rawKey.substring(1); - } - // 替换连续多个斜杠为一个斜杠 - rawKey = rawKey.replaceAll("/+", "/"); - return rawKey; - } - -} \ No newline at end of file -- Gitee From fd27bc6459effcf30c8225560668573707c5a0e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E6=B3=BD=E5=A8=81?= <958142070@qq.com> Date: Mon, 2 Mar 2026 19:38:05 +0800 Subject: [PATCH 04/12] =?UTF-8?q?fix(storage):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=88=86=E7=89=87=E4=B8=8A=E4=BC=A0=E6=97=B6=E6=97=A0=E6=95=88?= =?UTF-8?q?uploadId=E5=A4=84=E7=90=86=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加对uploadId获取MultipartSession的校验,避免空指针异常 - 使用MultipartSession中的平台和桶信息替代原先的存储配置 - 优化路径处理,优先使用session的路径信息 - 抛出明确的BaseException提示无效的uploadId - 处理上传分片时可能的IO异常并抛出业务异常 --- .../service/impl/MultipartUploadServiceImpl.java | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/continew-system/src/main/java/top/continew/admin/system/service/impl/MultipartUploadServiceImpl.java b/continew-system/src/main/java/top/continew/admin/system/service/impl/MultipartUploadServiceImpl.java index 473edfe0..7cf71f86 100644 --- a/continew-system/src/main/java/top/continew/admin/system/service/impl/MultipartUploadServiceImpl.java +++ b/continew-system/src/main/java/top/continew/admin/system/service/impl/MultipartUploadServiceImpl.java @@ -92,11 +92,15 @@ public class MultipartUploadServiceImpl implements MultipartUploadService { @Override public MultipartUploadResp uploadPart(MultipartFile file, String uploadId, Integer partNumber, String path) { - StorageDO storageDO = storageService.getByCode(null); + MultipartInitResp session = fileStorageService.getMultipartSession(uploadId); + if (session == null) { + throw new BaseException("无效的 uploadId: " + uploadId); + } + String targetPath = StrUtil.blankToDefault(session.getPath(), path); try { top.continew.starter.storage.domain.model.resp.MultipartUploadResp resp = fileStorageService - .uploadPart(storageDO.getCode(), storageDO - .getBucketName(), normalizeStoragePath(path), uploadId, partNumber, file.getInputStream()); + .uploadPart(session.getPlatform(), session + .getBucket(), normalizeStoragePath(targetPath), uploadId, partNumber, file.getInputStream()); return this.toAdminUploadResp(resp); } catch (IOException e) { throw new BaseException("上传分片失败: " + e.getMessage(), e); -- Gitee From a770dd8ce5451e48fdcf93993089e6f94d11fba3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E6=B3=BD=E5=A8=81?= <958142070@qq.com> Date: Mon, 2 Mar 2026 19:53:22 +0800 Subject: [PATCH 05/12] =?UTF-8?q?fix(storage):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=88=86=E7=89=87=E4=B8=8A=E4=BC=A0=E9=BB=98=E8=AE=A4=E5=88=86?= =?UTF-8?q?=E7=89=87=E5=A4=A7=E5=B0=8F=E8=AE=BE=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../system/constant/MultipartUploadConstants.java | 10 +--------- .../admin/system/service/impl/StorageServiceImpl.java | 4 ++++ 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/continew-system/src/main/java/top/continew/admin/system/constant/MultipartUploadConstants.java b/continew-system/src/main/java/top/continew/admin/system/constant/MultipartUploadConstants.java index c6ebbbd6..c05df941 100644 --- a/continew-system/src/main/java/top/continew/admin/system/constant/MultipartUploadConstants.java +++ b/continew-system/src/main/java/top/continew/admin/system/constant/MultipartUploadConstants.java @@ -84,15 +84,7 @@ public class MultipartUploadConstants { public static final long DEFAULT_EXPIRE_HOURS = 24; /** - * 临时文件夹 - *

- * 分片上传的临时文件夹名称 - *

- */ - public static final String TEMP_DIR_NAME = "temp"; - - /** - * 分片大小 + * 分片大小 - 必须是 5MB 不然会报错 分片合并时,存在“非最后一片 < 5MB”的分片 */ public static final long MULTIPART_UPLOAD_PART_SIZE = 5 * 1024 * 1024; } diff --git a/continew-system/src/main/java/top/continew/admin/system/service/impl/StorageServiceImpl.java b/continew-system/src/main/java/top/continew/admin/system/service/impl/StorageServiceImpl.java index 1367d59e..f0d34041 100644 --- a/continew-system/src/main/java/top/continew/admin/system/service/impl/StorageServiceImpl.java +++ b/continew-system/src/main/java/top/continew/admin/system/service/impl/StorageServiceImpl.java @@ -26,6 +26,7 @@ import top.continew.admin.common.base.service.BaseServiceImpl; import top.continew.admin.common.enums.DisEnableStatusEnum; import top.continew.admin.common.model.req.CommonStatusUpdateReq; import top.continew.admin.common.util.SecureUtils; +import top.continew.admin.system.constant.MultipartUploadConstants; import top.continew.admin.system.enums.StorageTypeEnum; import top.continew.admin.system.mapper.StorageMapper; import top.continew.admin.system.model.entity.StorageDO; @@ -198,6 +199,9 @@ public class StorageServiceImpl extends BaseServiceImpl throw new IllegalArgumentException("不支持的存储类型:%s".formatted(storage.getType())); -- Gitee From f7a9d09fef3a750d99d6b4ee74808b0decdb0841 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E6=B3=BD=E5=A8=81?= <958142070@qq.com> Date: Mon, 2 Mar 2026 20:40:27 +0800 Subject: [PATCH 06/12] =?UTF-8?q?refactor(storage):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E5=88=86=E7=89=87=E4=B8=8A=E4=BC=A0=E9=85=8D=E7=BD=AE=E5=92=8C?= =?UTF-8?q?=E7=9B=B8=E5=85=B3=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除MultipartUploadConstants中的固定分片大小常量 - 引入StorageProperties支持动态配置分片阈值和大小 - 在LocalStorage和OSS配置中根据配置设置分片上传阈值和分片大小 - 新增方法解析分片上传阈值、分片大小和本地临时目录,支持默认值回退 - 清理相关导入,优化代码结构 --- .../constant/MultipartUploadConstants.java | 5 ---- .../service/impl/StorageServiceImpl.java | 28 ++++++++++++++++--- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/continew-system/src/main/java/top/continew/admin/system/constant/MultipartUploadConstants.java b/continew-system/src/main/java/top/continew/admin/system/constant/MultipartUploadConstants.java index c05df941..228f7795 100644 --- a/continew-system/src/main/java/top/continew/admin/system/constant/MultipartUploadConstants.java +++ b/continew-system/src/main/java/top/continew/admin/system/constant/MultipartUploadConstants.java @@ -82,9 +82,4 @@ public class MultipartUploadConstants { *

*/ public static final long DEFAULT_EXPIRE_HOURS = 24; - - /** - * 分片大小 - 必须是 5MB 不然会报错 分片合并时,存在“非最后一片 < 5MB”的分片 - */ - public static final long MULTIPART_UPLOAD_PART_SIZE = 5 * 1024 * 1024; } diff --git a/continew-system/src/main/java/top/continew/admin/system/service/impl/StorageServiceImpl.java b/continew-system/src/main/java/top/continew/admin/system/service/impl/StorageServiceImpl.java index f0d34041..2aca7f63 100644 --- a/continew-system/src/main/java/top/continew/admin/system/service/impl/StorageServiceImpl.java +++ b/continew-system/src/main/java/top/continew/admin/system/service/impl/StorageServiceImpl.java @@ -26,7 +26,6 @@ import top.continew.admin.common.base.service.BaseServiceImpl; import top.continew.admin.common.enums.DisEnableStatusEnum; import top.continew.admin.common.model.req.CommonStatusUpdateReq; import top.continew.admin.common.util.SecureUtils; -import top.continew.admin.system.constant.MultipartUploadConstants; import top.continew.admin.system.enums.StorageTypeEnum; import top.continew.admin.system.mapper.StorageMapper; import top.continew.admin.system.model.entity.StorageDO; @@ -40,6 +39,8 @@ import top.continew.starter.core.util.validation.CheckUtils; import top.continew.starter.core.util.validation.ValidationUtils; import top.continew.starter.storage.autoconfigure.properties.LocalStorageConfig; import top.continew.starter.storage.autoconfigure.properties.OssStorageConfig; +import top.continew.starter.storage.autoconfigure.properties.StorageProperties; +import top.continew.starter.storage.common.constant.StorageConstant; import top.continew.starter.storage.core.FileStorageService; import top.continew.starter.storage.strategy.impl.LocalStorageStrategy; import top.continew.starter.storage.strategy.impl.OssStorageStrategy; @@ -57,6 +58,7 @@ import java.util.List; public class StorageServiceImpl extends BaseServiceImpl implements StorageService { private final FileStorageService fileStorageService; + private final StorageProperties storageProperties; @Resource private FileService fileService; @@ -188,6 +190,9 @@ public class StorageServiceImpl extends BaseServiceImpl { @@ -199,9 +204,8 @@ public class StorageServiceImpl extends BaseServiceImpl throw new IllegalArgumentException("不支持的存储类型:%s".formatted(storage.getType())); @@ -219,6 +223,22 @@ public class StorageServiceImpl extends BaseServiceImpl 0 ? threshold : StorageConstant.DEFAULT_MULTIPART_UPLOAD_THRESHOLD; + } + + private long resolveMultipartUploadPartSize() { + long partSize = storageProperties.getMultipartUploadPartSize(); + return partSize > 0 ? partSize : StorageConstant.DEFAULT_MULTIPART_UPLOAD_PART_SIZE; + } + + private String resolveLocalMultipartTempDir() { + return StrUtil.isBlank(storageProperties.getLocalMultipartTempDir()) + ? StorageConstant.DEFAULT_LOCAL_MULTIPART_TEMP_DIR + : storageProperties.getLocalMultipartTempDir(); + } + /** * 解密 SecretKey * -- Gitee From aa91017507cbef158a87a448dcf2bc110702036d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E6=B3=BD=E5=A8=81?= <958142070@qq.com> Date: Tue, 3 Mar 2026 11:05:40 +0800 Subject: [PATCH 07/12] =?UTF-8?q?feat(storage):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=88=86=E7=89=87=E4=B8=8A=E4=BC=A0=E9=85=8D=E7=BD=AE=E6=94=AF?= =?UTF-8?q?=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 数据库表增加字段 multipart_upload_part_size 和 multipart_temp_dir - StorageDO 实体新增分片上传大小和本地分片临时目录属性 - StorageReq 请求对象添加分片上传大小和本地分片临时目录字段及验证 - StorageResp 响应对象添加分片上传大小和本地分片临时目录字段 - StorageServiceImpl 实现分片上传阈值和分片大小的全局与实例级解析逻辑 - 实现本地存储分片临时目录的配置回退和解析逻辑 --- .../db/changelog/mysql/main_table.sql | 2 + .../db/changelog/postgresql/main_table.sql | 4 + .../admin/system/model/entity/StorageDO.java | 12 ++- .../admin/system/model/req/StorageReq.java | 17 +++- .../admin/system/model/resp/StorageResp.java | 14 ++- .../service/impl/StorageServiceImpl.java | 98 ++++++++++++++++++- 6 files changed, 141 insertions(+), 6 deletions(-) diff --git a/continew-server/src/main/resources/db/changelog/mysql/main_table.sql b/continew-server/src/main/resources/db/changelog/mysql/main_table.sql index 66ed46d9..d9e2739e 100644 --- a/continew-server/src/main/resources/db/changelog/mysql/main_table.sql +++ b/continew-server/src/main/resources/db/changelog/mysql/main_table.sql @@ -292,6 +292,8 @@ CREATE TABLE IF NOT EXISTS `sys_storage` ( `endpoint` varchar(255) DEFAULT NULL COMMENT 'Endpoint', `bucket_name` varchar(255) NOT NULL COMMENT 'Bucket', `domain` varchar(255) DEFAULT NULL COMMENT '域名', + `multipart_upload_part_size` bigint(20) DEFAULT NULL COMMENT '分片上传大小(字节)', + `multipart_temp_dir` varchar(255) DEFAULT NULL COMMENT '本地分片临时目录', `recycle_bin_enabled` bit(1) NOT NULL DEFAULT b'1' COMMENT '启用回收站', `recycle_bin_path` varchar(255) DEFAULT NULL COMMENT '回收站路径', `description` varchar(200) DEFAULT NULL COMMENT '描述', diff --git a/continew-server/src/main/resources/db/changelog/postgresql/main_table.sql b/continew-server/src/main/resources/db/changelog/postgresql/main_table.sql index ca5b2c1c..84c272e2 100644 --- a/continew-server/src/main/resources/db/changelog/postgresql/main_table.sql +++ b/continew-server/src/main/resources/db/changelog/postgresql/main_table.sql @@ -475,6 +475,8 @@ CREATE TABLE IF NOT EXISTS "sys_storage" ( "endpoint" varchar(255) DEFAULT NULL, "bucket_name" varchar(255) NOT NULL, "domain" varchar(255) DEFAULT NULL, + "multipart_upload_part_size" int8 DEFAULT NULL, + "multipart_temp_dir" varchar(255) DEFAULT NULL, "recycle_bin_enabled" bool NOT NULL DEFAULT true, "recycle_bin_path" varchar(255) DEFAULT NULL, "description" varchar(200) DEFAULT NULL, @@ -501,6 +503,8 @@ COMMENT ON COLUMN "sys_storage"."secret_key" IS 'Secret Key'; COMMENT ON COLUMN "sys_storage"."endpoint" IS 'Endpoint'; COMMENT ON COLUMN "sys_storage"."bucket_name" IS 'Bucket'; COMMENT ON COLUMN "sys_storage"."domain" IS '域名'; +COMMENT ON COLUMN "sys_storage"."multipart_upload_part_size" IS '分片上传大小(字节)'; +COMMENT ON COLUMN "sys_storage"."multipart_temp_dir" IS '本地分片临时目录'; COMMENT ON COLUMN "sys_storage"."recycle_bin_enabled" IS '启用回收站'; COMMENT ON COLUMN "sys_storage"."recycle_bin_path" IS '回收站路径'; COMMENT ON COLUMN "sys_storage"."description" IS '描述'; diff --git a/continew-system/src/main/java/top/continew/admin/system/model/entity/StorageDO.java b/continew-system/src/main/java/top/continew/admin/system/model/entity/StorageDO.java index ef950984..f362bcce 100644 --- a/continew-system/src/main/java/top/continew/admin/system/model/entity/StorageDO.java +++ b/continew-system/src/main/java/top/continew/admin/system/model/entity/StorageDO.java @@ -86,6 +86,16 @@ public class StorageDO extends BaseDO { */ private String domain; + /** + * 分片上传大小(字节) + */ + private Long multipartUploadPartSize; + + /** + * 本地分片临时目录 + */ + private String multipartTempDir; + /** * 启用回收站 */ @@ -139,4 +149,4 @@ public class StorageDO extends BaseDO { } return "%s://%s.%s/".formatted(url.getProtocol(), this.bucketName, host); } -} \ No newline at end of file +} diff --git a/continew-system/src/main/java/top/continew/admin/system/model/req/StorageReq.java b/continew-system/src/main/java/top/continew/admin/system/model/req/StorageReq.java index 56a42830..f595300d 100644 --- a/continew-system/src/main/java/top/continew/admin/system/model/req/StorageReq.java +++ b/continew-system/src/main/java/top/continew/admin/system/model/req/StorageReq.java @@ -20,6 +20,7 @@ import cn.sticki.spel.validator.constrain.SpelNotBlank; import cn.sticki.spel.validator.jakarta.SpelValid; import com.fasterxml.jackson.annotation.JsonIgnore; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; @@ -112,6 +113,20 @@ public class StorageReq implements Serializable { @NotBlank(message = "访问路径不能为空", groups = ValidationGroup.Storage.Local.class) private String domain; + /** + * 分片上传大小(字节) + */ + @Schema(description = "分片上传大小(字节)", example = "5242880") + @Min(value = 1, message = "分片上传大小必须大于 0") + private Long multipartUploadPartSize; + + /** + * 本地分片临时目录 + */ + @Schema(description = "本地分片临时目录", example = "/tmp/continew-multipart") + @Length(max = 255, message = "本地分片临时目录长度不能超过 {max} 个字符") + private String multipartTempDir; + /** * 启用回收站 */ @@ -150,4 +165,4 @@ public class StorageReq implements Serializable { */ @JsonIgnore private Boolean isDefault; -} \ No newline at end of file +} diff --git a/continew-system/src/main/java/top/continew/admin/system/model/resp/StorageResp.java b/continew-system/src/main/java/top/continew/admin/system/model/resp/StorageResp.java index 0cfa93a3..8c7b5e3a 100644 --- a/continew-system/src/main/java/top/continew/admin/system/model/resp/StorageResp.java +++ b/continew-system/src/main/java/top/continew/admin/system/model/resp/StorageResp.java @@ -85,6 +85,18 @@ public class StorageResp extends BaseDetailResp { @Schema(description = "域名", example = "http://localhost:8000/file") private String domain; + /** + * 分片上传大小(字节) + */ + @Schema(description = "分片上传大小(字节)", example = "5242880") + private Long multipartUploadPartSize; + + /** + * 本地分片临时目录 + */ + @Schema(description = "本地分片临时目录", example = "/tmp/continew-multipart") + private String multipartTempDir; + /** * 启用回收站 */ @@ -120,4 +132,4 @@ public class StorageResp extends BaseDetailResp { return this.getIsDefault(); } -} \ No newline at end of file +} diff --git a/continew-system/src/main/java/top/continew/admin/system/service/impl/StorageServiceImpl.java b/continew-system/src/main/java/top/continew/admin/system/service/impl/StorageServiceImpl.java index 2aca7f63..70dd8f88 100644 --- a/continew-system/src/main/java/top/continew/admin/system/service/impl/StorageServiceImpl.java +++ b/continew-system/src/main/java/top/continew/admin/system/service/impl/StorageServiceImpl.java @@ -191,8 +191,8 @@ public class StorageServiceImpl extends BaseServiceImpl { @@ -205,7 +205,7 @@ public class StorageServiceImpl extends BaseServiceImpl throw new IllegalArgumentException("不支持的存储类型:%s".formatted(storage.getType())); @@ -223,22 +223,114 @@ public class StorageServiceImpl extends BaseServiceImpl + * 当上传文件大小超过该阈值时,存储策略将走分片上传流程。 + * 取值优先级: + *

+ *

+ * 1. 全局配置 {@code continew-starter.storage.multipart-upload-threshold} + *

+ *

+ * 2. 框架默认值 {@link StorageConstant#DEFAULT_MULTIPART_UPLOAD_THRESHOLD} + *

+ * + * @return 最终生效的分片上传阈值(字节) + */ private long resolveMultipartUploadThreshold() { long threshold = storageProperties.getMultipartUploadThreshold(); return threshold > 0 ? threshold : StorageConstant.DEFAULT_MULTIPART_UPLOAD_THRESHOLD; } + /** + * 解析全局分片大小(字节) + * + *

+ * 用于在存储实例未配置分片大小时作为回退值。 + * 取值优先级: + *

+ *

+ * 1. 全局配置 {@code continew-starter.storage.multipart-upload-part-size} + *

+ *

+ * 2. 框架默认值 {@link StorageConstant#DEFAULT_MULTIPART_UPLOAD_PART_SIZE} + *

+ * + * @return 最终生效的全局分片大小(字节) + */ private long resolveMultipartUploadPartSize() { long partSize = storageProperties.getMultipartUploadPartSize(); return partSize > 0 ? partSize : StorageConstant.DEFAULT_MULTIPART_UPLOAD_PART_SIZE; } + /** + * 解析存储实例分片大小(字节) + * + *

+ * 取值优先级: + *

+ *

+ * 1. 存储实例配置({@link StorageDO#getMultipartUploadPartSize()}) + *

+ *

+ * 2. 全局配置(见 {@link #resolveMultipartUploadPartSize()}) + *

+ * + * @param storage 存储配置实体 + * @return 最终生效的分片大小(字节) + */ + private long resolveMultipartUploadPartSize(StorageDO storage) { + Long partSize = storage.getMultipartUploadPartSize(); + return (partSize != null && partSize > 0) ? partSize : resolveMultipartUploadPartSize(); + } + + /** + * 解析全局本地分片临时目录 + * + *

+ * 用于本地存储实例未配置临时目录时的回退值。 + * 取值优先级: + *

+ *

+ * 1. 全局配置 {@code continew-starter.storage.local-multipart-temp-dir} + *

+ *

+ * 2. 框架默认值 {@link StorageConstant#DEFAULT_LOCAL_MULTIPART_TEMP_DIR} + *

+ * + * @return 最终生效的全局本地分片临时目录 + */ private String resolveLocalMultipartTempDir() { return StrUtil.isBlank(storageProperties.getLocalMultipartTempDir()) ? StorageConstant.DEFAULT_LOCAL_MULTIPART_TEMP_DIR : storageProperties.getLocalMultipartTempDir(); } + /** + * 解析存储实例本地分片临时目录 + * + *

+ * 仅本地存储策略使用该值。 + * 取值优先级: + *

+ *

+ * 1. 存储实例配置({@link StorageDO#getMultipartTempDir()},并进行 trim) + *

+ *

+ * 2. 全局配置(见 {@link #resolveLocalMultipartTempDir()}) + *

+ * + * @param storage 存储配置实体 + * @return 最终生效的本地分片临时目录 + */ + private String resolveLocalMultipartTempDir(StorageDO storage) { + return StrUtil.isBlank(storage.getMultipartTempDir()) + ? resolveLocalMultipartTempDir() + : storage.getMultipartTempDir().trim(); + } + /** * 解密 SecretKey * -- Gitee From 23f78de6249aff1ddfa868c10fca82df1f4bd47a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E6=B3=BD=E5=A8=81?= <958142070@qq.com> Date: Tue, 3 Mar 2026 11:13:05 +0800 Subject: [PATCH 08/12] =?UTF-8?q?feat(storage):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=88=86=E7=89=87=E4=B8=8A=E4=BC=A0=E9=98=88=E5=80=BC=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在数据库表中添加 multipart_upload_threshold 字段,支持 MySQL 和 PostgreSQL - 在 StorageDO、StorageReq 和 StorageResp 实体类中增加 multipartUploadThreshold 字段 - 修改 StorageServiceImpl,支持根据存储实例优先级解析分片上传阈值配置 - 保持原有默认阈值配置兼容性,新增方法根据存储实例和全局配置确定阈值 - 更新相关注释和接口文档说明分片上传阈值含义及使用示例 --- .../db/changelog/mysql/main_table.sql | 1 + .../db/changelog/postgresql/main_table.sql | 2 ++ .../admin/system/model/entity/StorageDO.java | 5 ++++ .../admin/system/model/req/StorageReq.java | 7 ++++++ .../admin/system/model/resp/StorageResp.java | 6 +++++ .../service/impl/StorageServiceImpl.java | 25 +++++++++++++++++-- 6 files changed, 44 insertions(+), 2 deletions(-) diff --git a/continew-server/src/main/resources/db/changelog/mysql/main_table.sql b/continew-server/src/main/resources/db/changelog/mysql/main_table.sql index d9e2739e..f540f7ce 100644 --- a/continew-server/src/main/resources/db/changelog/mysql/main_table.sql +++ b/continew-server/src/main/resources/db/changelog/mysql/main_table.sql @@ -292,6 +292,7 @@ CREATE TABLE IF NOT EXISTS `sys_storage` ( `endpoint` varchar(255) DEFAULT NULL COMMENT 'Endpoint', `bucket_name` varchar(255) NOT NULL COMMENT 'Bucket', `domain` varchar(255) DEFAULT NULL COMMENT '域名', + `multipart_upload_threshold` bigint(20) DEFAULT NULL COMMENT '分片上传阈值(字节)', `multipart_upload_part_size` bigint(20) DEFAULT NULL COMMENT '分片上传大小(字节)', `multipart_temp_dir` varchar(255) DEFAULT NULL COMMENT '本地分片临时目录', `recycle_bin_enabled` bit(1) NOT NULL DEFAULT b'1' COMMENT '启用回收站', diff --git a/continew-server/src/main/resources/db/changelog/postgresql/main_table.sql b/continew-server/src/main/resources/db/changelog/postgresql/main_table.sql index 84c272e2..05bf71ac 100644 --- a/continew-server/src/main/resources/db/changelog/postgresql/main_table.sql +++ b/continew-server/src/main/resources/db/changelog/postgresql/main_table.sql @@ -475,6 +475,7 @@ CREATE TABLE IF NOT EXISTS "sys_storage" ( "endpoint" varchar(255) DEFAULT NULL, "bucket_name" varchar(255) NOT NULL, "domain" varchar(255) DEFAULT NULL, + "multipart_upload_threshold" int8 DEFAULT NULL, "multipart_upload_part_size" int8 DEFAULT NULL, "multipart_temp_dir" varchar(255) DEFAULT NULL, "recycle_bin_enabled" bool NOT NULL DEFAULT true, @@ -503,6 +504,7 @@ COMMENT ON COLUMN "sys_storage"."secret_key" IS 'Secret Key'; COMMENT ON COLUMN "sys_storage"."endpoint" IS 'Endpoint'; COMMENT ON COLUMN "sys_storage"."bucket_name" IS 'Bucket'; COMMENT ON COLUMN "sys_storage"."domain" IS '域名'; +COMMENT ON COLUMN "sys_storage"."multipart_upload_threshold" IS '分片上传阈值(字节)'; COMMENT ON COLUMN "sys_storage"."multipart_upload_part_size" IS '分片上传大小(字节)'; COMMENT ON COLUMN "sys_storage"."multipart_temp_dir" IS '本地分片临时目录'; COMMENT ON COLUMN "sys_storage"."recycle_bin_enabled" IS '启用回收站'; diff --git a/continew-system/src/main/java/top/continew/admin/system/model/entity/StorageDO.java b/continew-system/src/main/java/top/continew/admin/system/model/entity/StorageDO.java index f362bcce..a8767e6c 100644 --- a/continew-system/src/main/java/top/continew/admin/system/model/entity/StorageDO.java +++ b/continew-system/src/main/java/top/continew/admin/system/model/entity/StorageDO.java @@ -86,6 +86,11 @@ public class StorageDO extends BaseDO { */ private String domain; + /** + * 分片上传阈值(字节) + */ + private Long multipartUploadThreshold; + /** * 分片上传大小(字节) */ diff --git a/continew-system/src/main/java/top/continew/admin/system/model/req/StorageReq.java b/continew-system/src/main/java/top/continew/admin/system/model/req/StorageReq.java index f595300d..8536bf32 100644 --- a/continew-system/src/main/java/top/continew/admin/system/model/req/StorageReq.java +++ b/continew-system/src/main/java/top/continew/admin/system/model/req/StorageReq.java @@ -113,6 +113,13 @@ public class StorageReq implements Serializable { @NotBlank(message = "访问路径不能为空", groups = ValidationGroup.Storage.Local.class) private String domain; + /** + * 分片上传阈值(字节) + */ + @Schema(description = "分片上传阈值(字节)", example = "10485760") + @Min(value = 1, message = "分片上传阈值必须大于 0") + private Long multipartUploadThreshold; + /** * 分片上传大小(字节) */ diff --git a/continew-system/src/main/java/top/continew/admin/system/model/resp/StorageResp.java b/continew-system/src/main/java/top/continew/admin/system/model/resp/StorageResp.java index 8c7b5e3a..85c28857 100644 --- a/continew-system/src/main/java/top/continew/admin/system/model/resp/StorageResp.java +++ b/continew-system/src/main/java/top/continew/admin/system/model/resp/StorageResp.java @@ -85,6 +85,12 @@ public class StorageResp extends BaseDetailResp { @Schema(description = "域名", example = "http://localhost:8000/file") private String domain; + /** + * 分片上传阈值(字节) + */ + @Schema(description = "分片上传阈值(字节)", example = "10485760") + private Long multipartUploadThreshold; + /** * 分片上传大小(字节) */ diff --git a/continew-system/src/main/java/top/continew/admin/system/service/impl/StorageServiceImpl.java b/continew-system/src/main/java/top/continew/admin/system/service/impl/StorageServiceImpl.java index 70dd8f88..ebe56ec0 100644 --- a/continew-system/src/main/java/top/continew/admin/system/service/impl/StorageServiceImpl.java +++ b/continew-system/src/main/java/top/continew/admin/system/service/impl/StorageServiceImpl.java @@ -190,7 +190,7 @@ public class StorageServiceImpl extends BaseServiceImpl 0 ? threshold : StorageConstant.DEFAULT_MULTIPART_UPLOAD_THRESHOLD; } + /** + * 解析存储实例分片上传阈值(字节) + * + *

+ * 取值优先级: + *

+ *

+ * 1. 存储实例配置({@link StorageDO#getMultipartUploadThreshold()}) + *

+ *

+ * 2. 全局配置(见 {@link #resolveMultipartUploadThreshold()}) + *

+ * + * @param storage 存储配置实体 + * @return 最终生效的分片上传阈值(字节) + */ + private long resolveMultipartUploadThreshold(StorageDO storage) { + Long threshold = storage.getMultipartUploadThreshold(); + return (threshold != null && threshold > 0) ? threshold : resolveMultipartUploadThreshold(); + } + /** * 解析全局分片大小(字节) * -- Gitee From 35f8d2d7f8058435b5ab9d50eaf123a216131595 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E6=B3=BD=E5=A8=81?= <958142070@qq.com> Date: Tue, 3 Mar 2026 11:28:49 +0800 Subject: [PATCH 09/12] =?UTF-8?q?fix(storage):=20=E9=AA=8C=E8=AF=81?= =?UTF-8?q?=E5=AF=B9=E8=B1=A1=E5=AD=98=E5=82=A8=E5=88=86=E7=89=87=E5=A4=A7?= =?UTF-8?q?=E5=B0=8F=E4=B8=8D=E5=B0=91=E4=BA=8E5MB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../top/continew/admin/system/enums/StorageTypeEnum.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/continew-system/src/main/java/top/continew/admin/system/enums/StorageTypeEnum.java b/continew-system/src/main/java/top/continew/admin/system/enums/StorageTypeEnum.java index 064e500d..e3a7e1db 100644 --- a/continew-system/src/main/java/top/continew/admin/system/enums/StorageTypeEnum.java +++ b/continew-system/src/main/java/top/continew/admin/system/enums/StorageTypeEnum.java @@ -27,6 +27,7 @@ import top.continew.starter.core.constant.StringConstants; import top.continew.starter.core.enums.BaseEnum; import top.continew.starter.core.util.URLUtils; import top.continew.starter.core.util.validation.ValidationUtils; +import top.continew.starter.storage.common.constant.StorageConstant; /** * 存储类型枚举 @@ -66,6 +67,10 @@ public enum StorageTypeEnum implements BaseEnum { ValidationUtils.validate(req, ValidationGroup.Storage.OSS.class); ValidationUtils.throwIf(StrUtil.isNotBlank(req.getDomain()) && !ReUtil .isMatch(RegexConstants.URL_HTTP_NOT_IP, req.getDomain()), "域名格式不正确"); + Long multipartUploadPartSize = req.getMultipartUploadPartSize(); + ValidationUtils.throwIf(multipartUploadPartSize != null + && multipartUploadPartSize < StorageConstant.DEFAULT_MULTIPART_UPLOAD_PART_SIZE, + "对象存储分片大小不能小于 5MB"); } }; -- Gitee From fd056ee3c21485cf52f627b0ce589233856b8511 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E6=B3=BD=E5=A8=81?= <958142070@qq.com> Date: Tue, 3 Mar 2026 11:50:14 +0800 Subject: [PATCH 10/12] =?UTF-8?q?fix(multipart-upload):=20=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E5=88=86=E7=89=87=E5=A4=A7=E5=B0=8F=E4=B8=80=E8=87=B4?= =?UTF-8?q?=E6=80=A7=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在上传分片时校验分片序号是否合法,确保在有效区间内(1..totalParts) - 验证非最后一片的大小必须等于会话中定义的分片大小 - 验证最后一片大小必须等于剩余字节数,支持整除情况 - 如果分片大小与预期不符,抛出异常提示分片大小不匹配 --- .../admin/system/enums/StorageTypeEnum.java | 5 +-- .../impl/MultipartUploadServiceImpl.java | 44 +++++++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/continew-system/src/main/java/top/continew/admin/system/enums/StorageTypeEnum.java b/continew-system/src/main/java/top/continew/admin/system/enums/StorageTypeEnum.java index e3a7e1db..a4796b8a 100644 --- a/continew-system/src/main/java/top/continew/admin/system/enums/StorageTypeEnum.java +++ b/continew-system/src/main/java/top/continew/admin/system/enums/StorageTypeEnum.java @@ -68,9 +68,8 @@ public enum StorageTypeEnum implements BaseEnum { ValidationUtils.throwIf(StrUtil.isNotBlank(req.getDomain()) && !ReUtil .isMatch(RegexConstants.URL_HTTP_NOT_IP, req.getDomain()), "域名格式不正确"); Long multipartUploadPartSize = req.getMultipartUploadPartSize(); - ValidationUtils.throwIf(multipartUploadPartSize != null - && multipartUploadPartSize < StorageConstant.DEFAULT_MULTIPART_UPLOAD_PART_SIZE, - "对象存储分片大小不能小于 5MB"); + ValidationUtils + .throwIf(multipartUploadPartSize != null && multipartUploadPartSize < StorageConstant.DEFAULT_MULTIPART_UPLOAD_PART_SIZE, "对象存储分片大小不能小于 5MB"); } }; diff --git a/continew-system/src/main/java/top/continew/admin/system/service/impl/MultipartUploadServiceImpl.java b/continew-system/src/main/java/top/continew/admin/system/service/impl/MultipartUploadServiceImpl.java index 7cf71f86..6c83ca09 100644 --- a/continew-system/src/main/java/top/continew/admin/system/service/impl/MultipartUploadServiceImpl.java +++ b/continew-system/src/main/java/top/continew/admin/system/service/impl/MultipartUploadServiceImpl.java @@ -96,6 +96,7 @@ public class MultipartUploadServiceImpl implements MultipartUploadService { if (session == null) { throw new BaseException("无效的 uploadId: " + uploadId); } + validatePartSize(file, session, partNumber); String targetPath = StrUtil.blankToDefault(session.getPath(), path); try { top.continew.starter.storage.domain.model.resp.MultipartUploadResp resp = fileStorageService @@ -163,6 +164,49 @@ public class MultipartUploadServiceImpl implements MultipartUploadService { return target; } + /** + * 基于分片会话固化参数校验当前分片大小一致性。 + * + *

+ * 规则: + *

+ *

+ * 1. 分片序号必须在合法区间(1..totalParts) + *

+ *

+ * 2. 非最后一片大小必须等于会话 partSize + *

+ *

+ * 3. 最后一片大小必须等于剩余字节数(支持整除时等于 partSize) + *

+ * + * @param file 当前上传分片 + * @param session 分片上传会话 + * @param partNumber 分片序号 + */ + private void validatePartSize(MultipartFile file, MultipartInitResp session, Integer partNumber) { + if (partNumber == null || partNumber < 1) { + throw new BaseException("分片序号不合法: " + partNumber); + } + Long sessionPartSize = session.getPartSize(); + Long sessionFileSize = session.getFileSize(); + if (sessionPartSize == null || sessionPartSize <= 0 || sessionFileSize == null || sessionFileSize <= 0) { + return; + } + long partSize = sessionPartSize; + long fileSize = sessionFileSize; + long totalParts = (fileSize + partSize - 1) / partSize; + if (partNumber > totalParts) { + throw new BaseException("分片序号超出范围: " + partNumber); + } + long expectedPartSize = partNumber < totalParts ? partSize : fileSize - (totalParts - 1) * partSize; + long actualPartSize = file.getSize(); + if (actualPartSize != expectedPartSize) { + throw new BaseException("分片大小不匹配: partNumber=%s, expected=%s, actual=%s" + .formatted(partNumber, expectedPartSize, actualPartSize)); + } + } + private String normalizeStoragePath(String path) { return StrUtil.removePrefix(path.replace("\\", StringConstants.SLASH) .replaceAll("/+", StringConstants.SLASH), StringConstants.SLASH); -- Gitee From b6f6ca2512fd1a010fa6425606bab5c56978a77a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E6=B3=BD=E5=A8=81?= <958142070@qq.com> Date: Tue, 3 Mar 2026 15:20:28 +0800 Subject: [PATCH 11/12] =?UTF-8?q?feat(file):=20=E6=94=AF=E6=8C=81=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E4=B8=8A=E4=BC=A0=E8=BF=9B=E5=BA=A6=E6=9F=A5=E8=AF=A2?= =?UTF-8?q?=E5=92=8C=E9=BB=98=E8=AE=A4=E5=AD=98=E5=82=A8=E4=B8=8A=E4=BC=A0?= =?UTF-8?q?=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 文件上传接口新增uploadTaskId参数,支持上传任务ID追踪进度 - 新增文件上传进度查询接口,返回上传状态、进度百分比及相关信息 - 新增默认存储上传配置接口,提供当前默认存储的相关上传配置信息 - 实现上传过程中进度数据保存至Redis,保证上传状态实时更新 - 上传异常或完成时更新上传进度状态,支持失败与完成状态区分 - 增加FileUploadProgressResp和FileUploadConfigResp响应参数类 - 新增文件上传进度状态枚举,定义上传流程各阶段状态 - StorageService接口及实现新增获取默认上传配置方法 - 相关服务与控制器注入StorageService及相应接口实现 --- .../system/controller/FileController.java | 26 +++- .../enums/FileUploadProgressStatusEnum.java | 65 ++++++++ .../model/resp/file/FileUploadConfigResp.java | 80 ++++++++++ .../resp/file/FileUploadProgressResp.java | 86 +++++++++++ .../admin/system/service/FileService.java | 26 ++++ .../admin/system/service/StorageService.java | 10 +- .../system/service/impl/FileServiceImpl.java | 145 ++++++++++++++++-- .../service/impl/StorageServiceImpl.java | 17 ++ 8 files changed, 437 insertions(+), 18 deletions(-) create mode 100644 continew-system/src/main/java/top/continew/admin/system/enums/FileUploadProgressStatusEnum.java create mode 100644 continew-system/src/main/java/top/continew/admin/system/model/resp/file/FileUploadConfigResp.java create mode 100644 continew-system/src/main/java/top/continew/admin/system/model/resp/file/FileUploadProgressResp.java diff --git a/continew-system/src/main/java/top/continew/admin/system/controller/FileController.java b/continew-system/src/main/java/top/continew/admin/system/controller/FileController.java index 91fa6ca0..2581a255 100644 --- a/continew-system/src/main/java/top/continew/admin/system/controller/FileController.java +++ b/continew-system/src/main/java/top/continew/admin/system/controller/FileController.java @@ -30,11 +30,14 @@ import org.springframework.web.multipart.MultipartFile; import top.continew.admin.common.base.controller.BaseController; import top.continew.admin.system.model.query.FileQuery; import top.continew.admin.system.model.req.FileReq; +import top.continew.admin.system.model.resp.file.FileUploadConfigResp; +import top.continew.admin.system.model.resp.file.FileUploadProgressResp; import top.continew.admin.system.model.resp.file.FileDirCalcSizeResp; import top.continew.admin.system.model.resp.file.FileResp; import top.continew.admin.system.model.resp.file.FileStatisticsResp; import top.continew.admin.system.model.resp.file.FileUploadResp; import top.continew.admin.system.service.FileService; +import top.continew.admin.system.service.StorageService; import top.continew.starter.core.util.validation.ValidationUtils; import top.continew.starter.extension.crud.annotation.CrudRequestMapping; import top.continew.starter.extension.crud.enums.Api; @@ -57,6 +60,8 @@ import java.io.IOException; @CrudRequestMapping(value = "/system/file", api = {Api.PAGE, Api.UPDATE, Api.BATCH_DELETE}) public class FileController extends BaseController { + private final StorageService storageService; + /** * 上传文件 *

@@ -73,9 +78,10 @@ public class FileController extends BaseController { + + /** + * 初始化 + */ + INIT("INIT", "初始化"), + + /** + * 上传中 + */ + UPLOADING("UPLOADING", "上传中"), + + /** + * 收尾处理中(文件字节已传输完成,等待后端处理完成) + */ + FINALIZING("FINALIZING", "收尾处理中"), + + /** + * 已完成 + */ + COMPLETED("COMPLETED", "已完成"), + + /** + * 失败 + */ + FAILED("FAILED", "失败"), + + /** + * 未找到任务 + */ + NOT_FOUND("NOT_FOUND", "未找到任务"); + + private final String value; + private final String description; +} diff --git a/continew-system/src/main/java/top/continew/admin/system/model/resp/file/FileUploadConfigResp.java b/continew-system/src/main/java/top/continew/admin/system/model/resp/file/FileUploadConfigResp.java new file mode 100644 index 00000000..1b94ae10 --- /dev/null +++ b/continew-system/src/main/java/top/continew/admin/system/model/resp/file/FileUploadConfigResp.java @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package top.continew.admin.system.model.resp.file; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import top.continew.admin.system.enums.StorageTypeEnum; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 默认存储上传配置响应参数 + * + * @author echo + * @since 2026/3/3 12:20 + */ +@Data +@Schema(description = "默认存储上传配置响应参数") +public class FileUploadConfigResp implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 默认存储 ID + */ + @Schema(description = "默认存储 ID", example = "1") + private Long storageId; + + /** + * 默认存储名称 + */ + @Schema(description = "默认存储名称", example = "本地存储") + private String storageName; + + /** + * 默认存储编码 + */ + @Schema(description = "默认存储编码", example = "local") + private String storageCode; + + /** + * 存储类型(1: 本地存储,2: 对象存储) + */ + @Schema(description = "存储类型(1: 本地存储,2: 对象存储)", example = "1") + private StorageTypeEnum storageType; + + /** + * 分片上传阈值(字节) + */ + @Schema(description = "分片上传阈值(字节)", example = "10485760") + private Long multipartUploadThreshold; + + /** + * 分片上传大小(字节) + */ + @Schema(description = "分片上传大小(字节)", example = "5242880") + private Long multipartUploadPartSize; + + /** + * 本地分片临时目录(仅本地存储返回) + */ + @Schema(description = "本地分片临时目录(仅本地存储返回)", example = "/tmp/continew-multipart") + private String multipartTempDir; +} diff --git a/continew-system/src/main/java/top/continew/admin/system/model/resp/file/FileUploadProgressResp.java b/continew-system/src/main/java/top/continew/admin/system/model/resp/file/FileUploadProgressResp.java new file mode 100644 index 00000000..aeb09ed6 --- /dev/null +++ b/continew-system/src/main/java/top/continew/admin/system/model/resp/file/FileUploadProgressResp.java @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package top.continew.admin.system.model.resp.file; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import top.continew.admin.system.enums.FileUploadProgressStatusEnum; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 文件上传进度响应参数 + * + * @author echo + * @since 2026/3/3 12:20 + */ +@Data +@Schema(description = "文件上传进度响应参数") +public class FileUploadProgressResp implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 上传任务 ID + */ + @Schema(description = "上传任务 ID", example = "upload-task-1") + private String uploadTaskId; + + /** + * 任务状态(INIT/UPLOADING/FINALIZING/COMPLETED/FAILED/NOT_FOUND) + */ + @Schema(description = "任务状态(INIT/UPLOADING/FINALIZING/COMPLETED/FAILED/NOT_FOUND)", example = "UPLOADING") + private FileUploadProgressStatusEnum status; + + /** + * 上传进度百分比 + */ + @Schema(description = "上传进度百分比", example = "65") + private Integer percentage; + + /** + * 已上传字节数 + */ + @Schema(description = "已上传字节数", example = "3407872") + private Long bytesRead; + + /** + * 总字节数 + */ + @Schema(description = "总字节数", example = "5242880") + private Long totalBytes; + + /** + * 文件 ID(完成上传后返回) + */ + @Schema(description = "文件 ID(完成上传后返回)", example = "1897293810343682049") + private String fileId; + + /** + * 文件 URL(完成上传后返回) + */ + @Schema(description = "文件 URL(完成上传后返回)", example = "http://localhost:8000/file/example.png") + private String url; + + /** + * 错误信息 + */ + @Schema(description = "错误信息") + private String message; +} diff --git a/continew-system/src/main/java/top/continew/admin/system/service/FileService.java b/continew-system/src/main/java/top/continew/admin/system/service/FileService.java index 56afce14..59700eb2 100644 --- a/continew-system/src/main/java/top/continew/admin/system/service/FileService.java +++ b/continew-system/src/main/java/top/continew/admin/system/service/FileService.java @@ -23,6 +23,7 @@ import top.continew.admin.system.model.entity.FileDO; import top.continew.admin.system.model.entity.StorageDO; import top.continew.admin.system.model.query.FileQuery; import top.continew.admin.system.model.req.FileReq; +import top.continew.admin.system.model.resp.file.FileUploadProgressResp; import top.continew.admin.system.model.resp.file.FileResp; import top.continew.admin.system.model.resp.file.FileStatisticsResp; import top.continew.starter.core.constant.StringConstants; @@ -76,6 +77,23 @@ public interface FileService extends BaseService implements FileService { + private static final String FILE_UPLOAD_PROGRESS_PREFIX = "file:upload:progress:"; + private static final Duration FILE_UPLOAD_PROGRESS_EXPIRE = Duration.ofHours(1); + private final FileStorageService fileStorageService; @Lazy @Resource @@ -107,12 +115,18 @@ public class FileServiceImpl extends BaseServiceImpl storageIds) { if (CollUtil.isEmpty(storageIds)) { @@ -217,13 +252,14 @@ public class FileServiceImpl extends BaseServiceImpl allExtensions = FileTypeEnum.getAllExtensions(); CheckUtils.throwIf(!allExtensions.contains(extName), "不支持的文件类型,仅支持 {} 格式的文件", String .join(StringConstants.COMMA, allExtensions)); @@ -239,6 +275,11 @@ public class FileServiceImpl extends BaseServiceImpl log - .info("文件 [{}] 已上传 [{}],总大小 [{}],进度 [{}%]", uniqueFileName, progressSize, allSize, percentage)); - // 上传 - log.info("开始上传文件: {}", uniqueFileName); - FileInfo fileInfo = uploadPretreatment.upload(); - log.info("文件 [{}] 上传完成", uniqueFileName); - return this.postProcessUploadResult(fileInfo, storage); + uploadPretreatment.onProgress((progressSize, allSize, percentage) -> { + log.info("文件 [{}] 已上传 [{}],总大小 [{}],进度 [{}%]", uniqueFileName, progressSize, allSize, percentage); + if (StrUtil.isNotBlank(normalizedUploadTaskId)) { + FileUploadProgressStatusEnum status = (allSize > 0 && progressSize >= allSize) || percentage >= 100 + ? FileUploadProgressStatusEnum.FINALIZING + : FileUploadProgressStatusEnum.UPLOADING; + this.saveUploadProgress(normalizedUploadTaskId, status, progressSize, allSize, percentage, null, null, null); + } + }); + try { + // 上传 + log.info("开始上传文件: {}", uniqueFileName); + FileInfo fileInfo = uploadPretreatment.upload(); + log.info("文件 [{}] 上传完成", uniqueFileName); + FileInfo result = this.postProcessUploadResult(fileInfo, storage); + if (StrUtil.isNotBlank(normalizedUploadTaskId)) { + long completedSize = result.getSize() == null ? totalBytes : result.getSize(); + this.saveUploadProgress(normalizedUploadTaskId, FileUploadProgressStatusEnum.COMPLETED, completedSize, totalBytes, 100, null, result + .getFileId(), result.getUrl()); + } + return result; + } catch (RuntimeException e) { + if (StrUtil.isNotBlank(normalizedUploadTaskId)) { + this.saveUploadProgress(normalizedUploadTaskId, FileUploadProgressStatusEnum.FAILED, 0L, totalBytes, 0, e + .getMessage(), null, null); + } + throw e; + } } /** @@ -410,4 +472,57 @@ public class FileServiceImpl extends BaseServiceImpl Date: Tue, 17 Mar 2026 16:11:25 +0800 Subject: [PATCH 12/12] =?UTF-8?q?fix(storage):=20=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=E5=AF=B9=E8=B1=A1=E5=AD=98=E5=82=A8=E9=85=8D=E7=BD=AE=EF=BC=8C?= =?UTF-8?q?=E5=90=AF=E7=94=A8=E8=B7=AF=E5=BE=84=E6=A0=B7=E5=BC=8F=E8=AE=BF?= =?UTF-8?q?=E9=97=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../continew/admin/system/service/impl/StorageServiceImpl.java | 1 + 1 file changed, 1 insertion(+) diff --git a/continew-system/src/main/java/top/continew/admin/system/service/impl/StorageServiceImpl.java b/continew-system/src/main/java/top/continew/admin/system/service/impl/StorageServiceImpl.java index 53dd09a4..114a0a5a 100644 --- a/continew-system/src/main/java/top/continew/admin/system/service/impl/StorageServiceImpl.java +++ b/continew-system/src/main/java/top/continew/admin/system/service/impl/StorageServiceImpl.java @@ -223,6 +223,7 @@ public class StorageServiceImpl extends BaseServiceImpl throw new IllegalArgumentException("不支持的存储类型:%s".formatted(storage.getType())); -- Gitee