diff --git a/continew-common/pom.xml b/continew-common/pom.xml index d6564727f043f5387574f883fab395fc954ddb97..fd7050687a88a36b22a24229fee44a16612b1475 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 175cbadfeeac4864517dfdc387bc4fdfa98df1e4..682c0ee9e8a5c2bd91750c95038e6fdff58abd08 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 811173451e8abfc7f065eff8b50956d5fb6bddce..a92e0b2bf4cf9d048dce4e2c788be9ca12b53757 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-server/src/main/resources/db/changelog/mysql/main_table.sql b/continew-server/src/main/resources/db/changelog/mysql/main_table.sql index 66ed46d9757b10e534e45946a2befad32e3892c3..f540f7ce87934e74e5b548226bd92b579e1af87a 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,9 @@ 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 '启用回收站', `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 ca5b2c1cf168aadd72d6640ef3146c1759d37727..05bf71aca7f48c212745e8db08443481593b9dac 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,9 @@ 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, "recycle_bin_path" varchar(255) DEFAULT NULL, "description" varchar(200) DEFAULT NULL, @@ -501,6 +504,9 @@ 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 '启用回收站'; 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/config/file/FileRecorderImpl.java b/continew-system/src/main/java/top/continew/admin/system/config/file/FileRecorderImpl.java index 4da0c27b273f3286c1172394c92745520675602f..350dbe0945a643fc402c3f0617e8e5891d4f3b48 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,24 +17,31 @@ 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 cn.hutool.json.JSONUtil; 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.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; +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.time.Duration; +import java.util.ArrayList; +import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.function.Function; @@ -56,22 +63,26 @@ public class FileRecorderImpl implements FileRecorder { @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 +99,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 +116,216 @@ 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) { - /* 不使用分片功能则无需重写 */ + 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()); + 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) { + 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) { + try { + RedisUtils.delete(MultipartUploadConstants.MULTIPART_PARTS_PREFIX + fileId); + log.debug("删除所有分片信息: uploadId={}", fileId); + } catch (Exception e) { + log.error("删除所有分片信息失败: uploadId={}", fileId, e); + } } @Override - public void deleteFilePartByUploadId(String s) { - /* 不使用分片功能则无需重写 */ + 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) { + 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 + 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()); + 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 = 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; + } + 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) { + 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); + } } /** @@ -144,4 +361,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/constant/MultipartUploadConstants.java b/continew-system/src/main/java/top/continew/admin/system/constant/MultipartUploadConstants.java index c6ebbbd6c7b9229e2bccd9cc48e240fbdd9c8781..228f7795072370365d3ccd8c22e35fd294d47481 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,17 +82,4 @@ public class MultipartUploadConstants { *

*/ public static final long DEFAULT_EXPIRE_HOURS = 24; - - /** - * 临时文件夹 - *

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

- */ - public static final String TEMP_DIR_NAME = "temp"; - - /** - * 分片大小 - */ - public static final long MULTIPART_UPLOAD_PART_SIZE = 5 * 1024 * 1024; } 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 398869b5a5a5948fac7033037ad590d21fa06b41..3ab9f6837452ad59fdd54bfe96ce02d2e4748cd8 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 132bffa375bd9db2e177eda5a6b27acac1cca789..2581a2556ac9dadf829c8de53d0d34803f79cfc4 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,23 +24,26 @@ 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; 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; 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; @@ -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,17 +78,34 @@ public class FileController extends BaseController - * 纯粹的缓存操作,不包含业务逻辑: - * 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 fc29e5a8115a129a8bbee9a35ca52af03c93ded3..0000000000000000000000000000000000000000 --- 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.getFileMd5(); - 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); - } - } -} \ No newline at end of file diff --git a/continew-system/src/main/java/top/continew/admin/system/enums/FileUploadProgressStatusEnum.java b/continew-system/src/main/java/top/continew/admin/system/enums/FileUploadProgressStatusEnum.java new file mode 100644 index 0000000000000000000000000000000000000000..598bb87af280c2f55401426cf9e2bbc61e60aa8c --- /dev/null +++ b/continew-system/src/main/java/top/continew/admin/system/enums/FileUploadProgressStatusEnum.java @@ -0,0 +1,65 @@ +/* + * 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.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import top.continew.starter.core.enums.BaseEnum; + +/** + * 文件上传进度状态枚举 + * + * @author echo + * @since 2026/3/3 14:55 + */ +@Getter +@RequiredArgsConstructor +public enum FileUploadProgressStatusEnum implements BaseEnum { + + /** + * 初始化 + */ + 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/enums/StorageTypeEnum.java b/continew-system/src/main/java/top/continew/admin/system/enums/StorageTypeEnum.java index 064e500da51e7d2ca351451769f89d5c67e4aa91..a4796b8a2a9df07de986f6a735d6b25267ce34f5 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,9 @@ 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"); } }; 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 bb73cb0bb99b9228377fb3bb0c0122ac597bbd5b..0000000000000000000000000000000000000000 --- 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 f6b0c740fac8990c808a68531f354b76cd8f2bfb..0000000000000000000000000000000000000000 --- 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/impl/LocalStorageHandler.java b/continew-system/src/main/java/top/continew/admin/system/handler/impl/LocalStorageHandler.java deleted file mode 100644 index 7fe44788162e9e632f46f2d7b0068e0a5798984a..0000000000000000000000000000000000000000 --- 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 6183849fbab49e07bc2434746c5b8a49e137190f..0000000000000000000000000000000000000000 --- 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 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 c60814c70ea045c5b81ef96c6a3ca8e87ab4f462..86786d8958d51bd0d59c321d4f1ffae5df87848c 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 @@ -16,17 +16,18 @@ package top.continew.admin.system.model.entity; -import cn.hutool.core.date.DateUtil; +import cn.hutool.core.io.file.FileNameUtil; import cn.hutool.core.util.StrUtil; +import cn.hutool.core.util.URLUtil; import cn.hutool.json.JSONUtil; import com.baomidou.mybatisplus.annotation.TableLogic; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import lombok.NoArgsConstructor; -import org.dromara.x.file.storage.core.FileInfo; import top.continew.admin.common.base.model.entity.BaseDO; import top.continew.admin.system.enums.FileTypeEnum; import top.continew.starter.core.constant.StringConstants; +import top.continew.starter.storage.domain.model.resp.FileInfo; import java.io.Serial; import java.util.Map; @@ -127,24 +128,40 @@ public class FileDO extends BaseDO { * @param fileInfo {@link FileInfo} 文件信息 */ public FileDO(FileInfo fileInfo) { - this.name = fileInfo.getFilename(); - this.originalName = fileInfo.getOriginalFilename(); + String normalizedPath = StrUtil.blankToDefault(fileInfo.getPath(), fileInfo.getFullPath()); + normalizedPath = normalizedPath.replace("\\", StringConstants.SLASH).replaceAll("/+", StringConstants.SLASH); + normalizedPath = StrUtil.removePrefix(normalizedPath, StringConstants.SLASH); + normalizedPath = StrUtil.removeSuffix(normalizedPath, StringConstants.SLASH); + String fileName = StrUtil.blankToDefault(fileInfo.getName(), FileNameUtil.getName(normalizedPath)); + if (StrUtil.isBlank(normalizedPath)) { + normalizedPath = fileName; + } + this.name = fileName; + this.originalName = StrUtil.blankToDefault(fileInfo.getOriginalFileName(), fileName); this.size = fileInfo.getSize(); - // 如果为空,则为 /;如果不为空,则调整格式为:/xxx - this.parentPath = StrUtil.isEmpty(fileInfo.getPath()) - ? StringConstants.SLASH - : StrUtil.removeSuffix(StrUtil.prependIfMissing(fileInfo - .getPath(), StringConstants.SLASH), StringConstants.SLASH); - this.path = StrUtil.prependIfMissing(fileInfo.getUrl(), StringConstants.SLASH); - this.extension = fileInfo.getExt(); + int lastSlash = normalizedPath.lastIndexOf(StringConstants.SLASH); + String parent = lastSlash < 0 ? StringConstants.EMPTY : normalizedPath.substring(0, lastSlash); + this.parentPath = StrUtil.isBlank(parent) ? StringConstants.SLASH : StringConstants.SLASH + parent; + this.path = StringConstants.SLASH + normalizedPath; + this.extension = FileNameUtil.extName(fileName); this.contentType = fileInfo.getContentType(); this.type = FileTypeEnum.getByExtension(this.extension); - this.sha256 = fileInfo.getHashInfo().getSha256(); - this.metadata = JSONUtil.toJsonStr(fileInfo.getMetadata()); - this.thumbnailName = fileInfo.getThFilename(); - this.thumbnailSize = fileInfo.getThSize(); - this.thumbnailMetadata = JSONUtil.toJsonStr(fileInfo.getThMetadata()); - this.setCreateTime(DateUtil.toLocalDateTime(fileInfo.getCreateTime())); + Map 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)) { + 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; + this.setCreateTime(fileInfo.getUploadTime()); } /** @@ -156,27 +173,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/model/entity/StorageDO.java b/continew-system/src/main/java/top/continew/admin/system/model/entity/StorageDO.java index ef95098465ac51660de275f4f7f84a1dbc3062da..a8767e6cd900afd10ee241449af78b16f394e447 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,21 @@ public class StorageDO extends BaseDO { */ private String domain; + /** + * 分片上传阈值(字节) + */ + private Long multipartUploadThreshold; + + /** + * 分片上传大小(字节) + */ + private Long multipartUploadPartSize; + + /** + * 本地分片临时目录 + */ + private String multipartTempDir; + /** * 启用回收站 */ @@ -139,4 +154,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 56a4283076a47be9f54bea93dc32a26623384966..8536bf32701bf57c2e61263634f40d414dfd923f 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,27 @@ 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; + + /** + * 分片上传大小(字节) + */ + @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 +172,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 0cfa93a30ea846dffa6278ef20eeca71ffc06241..85c288577d7f5615fb3ea0fe005d3403a96a07a2 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,24 @@ public class StorageResp extends BaseDetailResp { @Schema(description = "域名", example = "http://localhost:8000/file") private String domain; + /** + * 分片上传阈值(字节) + */ + @Schema(description = "分片上传阈值(字节)", example = "10485760") + private Long multipartUploadThreshold; + + /** + * 分片上传大小(字节) + */ + @Schema(description = "分片上传大小(字节)", example = "5242880") + private Long multipartUploadPartSize; + + /** + * 本地分片临时目录 + */ + @Schema(description = "本地分片临时目录", example = "/tmp/continew-multipart") + private String multipartTempDir; + /** * 启用回收站 */ @@ -120,4 +138,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/handler/StorageHandler.java b/continew-system/src/main/java/top/continew/admin/system/model/resp/file/FileUploadConfigResp.java similarity index 30% rename from continew-system/src/main/java/top/continew/admin/system/handler/StorageHandler.java rename to continew-system/src/main/java/top/continew/admin/system/model/resp/file/FileUploadConfigResp.java index 4882de6e7135ebe2f4c2e46e1ffa4e66de6823ce..1b94ae10fec54a3ae4195456558ba1272ef83a2e 100644 --- a/continew-system/src/main/java/top/continew/admin/system/handler/StorageHandler.java +++ b/continew-system/src/main/java/top/continew/admin/system/model/resp/file/FileUploadConfigResp.java @@ -14,68 +14,67 @@ * limitations under the License. */ -package top.continew.admin.system.handler; +package top.continew.admin.system.model.resp.file; -import org.springframework.web.multipart.MultipartFile; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; 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; +import java.io.Serial; +import java.io.Serializable; /** - * 存储类型处理器 - *

- * 专注于文件操作,不包含业务逻辑 + * 默认存储上传配置响应参数 * - * @author KAI - * @since 2025/7/30 17:15 + * @author echo + * @since 2026/3/3 12:20 */ -public interface StorageHandler { +@Data +@Schema(description = "默认存储上传配置响应参数") +public class FileUploadConfigResp implements Serializable { - MultipartUploadInitResp initMultipartUpload(StorageDO storageDO, MultipartUploadInitReq req); + @Serial + private static final long serialVersionUID = 1L; /** - * 分片上传 - * - * @param storageDO 存储实体 - * @param path 存储路径 - * @param uploadId 文件名 - * @param file 文件对象 - * @return {@link MultipartUploadResp} 分片上传结果 + * 默认存储 ID */ - MultipartUploadResp uploadPart(StorageDO storageDO, - String path, - String uploadId, - Integer partNumber, - MultipartFile file); + @Schema(description = "默认存储 ID", example = "1") + private Long storageId; /** - * 合并分片 - * - * @param storageDO 存储实体 - * @param uploadId 上传Id + * 默认存储名称 */ - void completeMultipartUpload(StorageDO storageDO, - List parts, - String path, - String uploadId, - boolean needVerify); + @Schema(description = "默认存储名称", example = "本地存储") + private String storageName; /** - * 清楚分片 - * - * @param storageDO 存储实体 - * @param uploadId 上传Id + * 默认存储编码 */ - void cleanPart(StorageDO storageDO, String uploadId); + @Schema(description = "默认存储编码", example = "local") + private String storageCode; /** - * 获取存储类型 - * - * @return 存储类型 + * 存储类型(1: 本地存储,2: 对象存储) */ - StorageTypeEnum getType(); + @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 0000000000000000000000000000000000000000..aeb09ed66a2a7b5af0f4402e054fd78442b2ac7d --- /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 ff1bc1eb9f784003dd94b4da31b2d510c9affa41..59700eb2a9b560d63bbe2e75d06596579c93470a 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,17 +17,18 @@ 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; 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; import top.continew.starter.data.service.IService; +import top.continew.starter.storage.domain.model.resp.FileInfo; import java.io.File; import java.io.IOException; @@ -76,6 +77,23 @@ 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 e1504007bc2c098c447e64ba3579afb715f34593..ef84ddd0dc2af621137366b90867a8841c75f007 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,13 @@ 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.core.util.StrUtil; import cn.hutool.core.util.URLUtil; +import cn.hutool.crypto.digest.DigestUtil; +import cn.hutool.json.JSONUtil; 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; @@ -41,19 +38,28 @@ 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.admin.system.enums.FileUploadProgressStatusEnum; import top.continew.admin.system.service.FileService; import top.continew.admin.system.service.StorageService; import top.continew.admin.system.util.FileNameGenerator; import top.continew.starter.cache.redisson.util.RedisLockUtils; +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.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.time.Duration; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.function.Function; @@ -70,6 +76,9 @@ import java.util.stream.Collectors; @RequiredArgsConstructor public class FileServiceImpl extends BaseServiceImpl 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 @@ -106,12 +115,18 @@ public class FileServiceImpl extends BaseServiceImpl storageIds) { if (CollUtil.isEmpty(storageIds)) { @@ -216,13 +252,14 @@ public class FileServiceImpl extends BaseServiceImpl allExtensions = FileTypeEnum.getAllExtensions(); CheckUtils.throwIf(!allExtensions.contains(extName), "不支持的文件类型,仅支持 {} 格式的文件", String .join(StringConstants.COMMA, allExtensions)); @@ -235,38 +272,54 @@ public class FileServiceImpl extends BaseServiceImpl img.size(100, 100)); + uploadPretreatment.thumbnail(100, 100); } - uploadPretreatment.setProgressMonitor(new ProgressListener() { - @Override - public void start() { - log.info("开始上传文件: {}", uniqueFileName); + 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); } - - @Override - public void progress(long progressSize, Long allSize) { - log.info("文件 [{}] 已上传 [{}],总大小 [{}]", uniqueFileName, progressSize, allSize); + }); + 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()); } - - @Override - public void finish() { - log.info("文件 [{}] 上传完成", uniqueFileName); + return result; + } catch (RuntimeException e) { + if (StrUtil.isNotBlank(normalizedUploadTaskId)) { + this.saveUploadProgress(normalizedUploadTaskId, FileUploadProgressStatusEnum.FAILED, 0L, totalBytes, 0, e + .getMessage(), null, null); } - }); - // 上传 - return uploadPretreatment.upload(); + throw e; + } } /** @@ -288,7 +341,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 +352,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); + } + + private long getFileSize(Object file) { + if (file instanceof MultipartFile multipartFile) { + return multipartFile.getSize(); + } + if (file instanceof File ioFile) { + return ioFile.length(); + } + return 0L; + } + + private void saveUploadProgress(String uploadTaskId, + FileUploadProgressStatusEnum status, + long bytesRead, + long totalBytes, + int percentage, + String message, + String fileId, + String url) { + String key = FILE_UPLOAD_PROGRESS_PREFIX + uploadTaskId; + FileUploadProgressResp current = null; + Object value = RedisUtils.get(key); + if (value != null) { + current = JSONUtil.toBean(value.toString(), FileUploadProgressResp.class); + } + if (current != null && isFinalStatus(current.getStatus()) && !isFinalStatus(status)) { + return; + } + if (!isFinalStatus(status) && current != null) { + bytesRead = Math.max(bytesRead, current.getBytesRead() == null ? 0L : current.getBytesRead()); + percentage = Math.max(percentage, current.getPercentage() == null ? 0 : current.getPercentage()); + } + + FileUploadProgressResp resp = new FileUploadProgressResp(); + resp.setUploadTaskId(uploadTaskId); + resp.setStatus(status); + resp.setBytesRead(bytesRead); + resp.setTotalBytes(totalBytes); + resp.setPercentage(percentage); + resp.setMessage(StrUtil.blankToDefault(message, current == null ? null : current.getMessage())); + resp.setFileId(StrUtil.blankToDefault(fileId, current == null ? null : current.getFileId())); + resp.setUrl(StrUtil.blankToDefault(url, current == null ? null : current.getUrl())); + RedisUtils.set(key, JSONUtil.toJsonStr(resp), FILE_UPLOAD_PROGRESS_EXPIRE); + } + + private boolean isFinalStatus(String status) { + return StrUtil.equalsAnyIgnoreCase(status, FileUploadProgressStatusEnum.COMPLETED + .getValue(), FileUploadProgressStatusEnum.FAILED.getValue()); + } + + private boolean isFinalStatus(FileUploadProgressStatusEnum status) { + return status == FileUploadProgressStatusEnum.COMPLETED || status == FileUploadProgressStatusEnum.FAILED; + } +} 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 ef6bb67a3a2a6b90a4ff1ea21eadee05f8ee36fb..6c83ca09b37cf99e1fe389044708fad12a07a490 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,143 @@ 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; + MultipartInitResp session = fileStorageService.getMultipartSession(uploadId); + 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 + .uploadPart(session.getPlatform(), session + .getBucket(), normalizeStoragePath(targetPath), 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); + } + + 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; + } + + 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; } /** - * 验证分片完整性 + * 基于分片会话固化参数校验当前分片大小一致性。 + * + *

+ * 规则: + *

+ *

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

+ *

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

+ *

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

* - * @param parts 分片信息 + * @param file 当前上传分片 + * @param session 分片上传会话 + * @param partNumber 分片序号 */ - private void validatePartsCompleteness(List parts) { - if (parts.isEmpty()) { - throw new BaseException("没有找到任何分片信息"); + private void validatePartSize(MultipartFile file, MultipartInitResp session, Integer partNumber) { + if (partNumber == null || partNumber < 1) { + throw new BaseException("分片序号不合法: " + partNumber); } - - // 检查分片编号连续性 - 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)); - } + Long sessionPartSize = session.getPartSize(); + Long sessionFileSize = session.getFileSize(); + if (sessionPartSize == null || sessionPartSize <= 0 || sessionFileSize == null || sessionFileSize <= 0) { + return; } - - // 检查是否所有分片都成功 - List failedParts = parts.stream() - .filter(part -> !part.isSuccess()) - .map(MultipartUploadResp::getPartNumber) - .toList(); - - if (!failedParts.isEmpty()) { - throw new BaseException("存在失败的分片: " + failedParts); + 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); } } 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 d900bfeac18551f5f3bba6009439f8865fe2301b..114a0a5a9045b26aa4021080691c3f1614ea3fef 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; @@ -38,16 +32,21 @@ import top.continew.admin.system.model.entity.StorageDO; import top.continew.admin.system.model.query.StorageQuery; import top.continew.admin.system.model.req.StorageReq; import top.continew.admin.system.model.resp.StorageResp; +import top.continew.admin.system.model.resp.file.FileUploadConfigResp; 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.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; -import java.util.Collections; import java.util.List; -import java.util.concurrent.CopyOnWriteArrayList; /** * 存储业务实现 @@ -60,6 +59,7 @@ import java.util.concurrent.CopyOnWriteArrayList; public class StorageServiceImpl extends BaseServiceImpl implements StorageService { private final FileStorageService fileStorageService; + private final StorageProperties storageProperties; @Resource private FileService fileService; @@ -159,6 +159,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()); + config.setMultipartUploadThreshold(resolveMultipartUploadThreshold(storage)); + config.setMultipartUploadPartSize(resolveMultipartUploadPartSize(storage)); + config.setMultipartTempDir(resolveLocalMultipartTempDir(storage)); + 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()); + config.setMultipartUploadThreshold(resolveMultipartUploadThreshold(storage)); + config.setMultipartUploadPartSize(resolveMultipartUploadPartSize(storage)); + config.setPathStyleAccessEnabled(true); + 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()); + } + + /** + * 解析分片上传阈值(字节) + * + *

+ * 当上传文件大小超过该阈值时,存储策略将走分片上传流程。 + * 取值优先级: + *

+ *

+ * 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. 存储实例配置({@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(); + } + + /** + * 解析全局分片大小(字节) + * + *

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

+ *

+ * 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(); } /** @@ -253,4 +401,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 6e721cacd75626e0bd0cd5379f6a93fd5dd541ff..8c4df8c8c7a51480944b1f35d2ebbf58868bf5df 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