From f6978795711d5506be6b67c532ac181935cfc0ae Mon Sep 17 00:00:00 2001 From: luoqiz Date: Wed, 21 Oct 2026 17:29:15 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(system):=20=E6=96=87=E4=BB=B6=E8=90=BD?= =?UTF-8?q?=E7=9B=98=E5=90=8D=E6=94=B9=E7=94=A8=E5=94=AF=E4=B8=80=20ID?= =?UTF-8?q?=EF=BC=8C=E5=8E=9F=E5=A7=8B=E6=96=87=E4=BB=B6=E5=90=8D=E4=BB=85?= =?UTF-8?q?=E4=BD=9C=E5=B1=95=E7=A4=BA=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FileNameGenerator 重写为「32 位唯一 ID + 原扩展名」,删除同名探测与序号重命名 - 单文件与分片上传的落盘名统一走 generateStoredName;分片上传新增 MultipartOriginalNameStore 暂存原始文件名,完成时回填展示名 - 分片初始化改用 FileTypeEnum.getByExtension 校验扩展名,顺带消除 toLowerCase 的地区问题与无扩展名 NPE - 文件记录、上传响应与文档同步区分存储名与原始名 - 补充 FileNameGenerator、MultipartOriginalNameStore、FileRecorderImpl 回填测试 Assisted-by: DeepSeek Harness --- .../system/config/file/FileRecorderImpl.java | 15 ++ .../constant/MultipartUploadConstants.java | 10 + .../admin/system/model/entity/FileDO.java | 8 + .../system/model/resp/file/FileResp.java | 6 +- .../resp/file/MultipartUploadCreateResp.java | 4 +- .../system/service/impl/FileServiceImpl.java | 16 +- .../impl/MultipartUploadServiceImpl.java | 37 ++-- .../admin/system/util/FileNameGenerator.java | 197 +++--------------- .../util/MultipartOriginalNameStore.java | 141 +++++++++++++ .../config/file/FileRecorderImplTest.java | 181 ++++++++++++++++ .../system/util/FileNameGeneratorTest.java | 84 ++++++++ .../util/MultipartOriginalNameStoreTest.java | 75 +++++++ docs/manual/system/file.mdx | 12 +- 13 files changed, 579 insertions(+), 207 deletions(-) create mode 100644 continew-system/src/main/java/top/continew/admin/system/util/MultipartOriginalNameStore.java create mode 100644 continew-system/src/test/java/top/continew/admin/system/config/file/FileRecorderImplTest.java create mode 100644 continew-system/src/test/java/top/continew/admin/system/util/FileNameGeneratorTest.java create mode 100644 continew-system/src/test/java/top/continew/admin/system/util/MultipartOriginalNameStoreTest.java diff --git a/continew-system/src/main/java/top/continew/admin/system/config/file/FileRecorderImpl.java b/continew-system/src/main/java/top/continew/admin/system/config/file/FileRecorderImpl.java index 4d91804b3..d03fe8ad4 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 @@ -31,6 +31,7 @@ import top.continew.admin.system.model.entity.FileDO; import top.continew.admin.system.model.entity.StorageDO; import top.continew.admin.system.model.resp.file.MultipartUploadCreateResp; +import top.continew.admin.system.util.MultipartOriginalNameStore; import top.continew.starter.cache.redisson.util.RedisUtils; import top.continew.starter.core.constant.StringConstants; import top.continew.starter.core.util.CollUtils; @@ -127,6 +128,12 @@ public boolean update(FileInfo fileInfo) { } FileDO file = new FileDO(fileInfo); file.setStorageId(storage.getId()); + // 分片上传的落盘文件名是唯一 ID,展示名需取回原始文件名,避免退化为存储名 + String originalFileName = MultipartOriginalNameStore.resolveByMetadata(fileInfo + .getMetadata()); + if (StrUtil.isNotBlank(originalFileName)) { + file.setOriginalName(originalFileName); + } FileDO existFile = fileMapper.lambdaQuery() .eq(FileDO::getStorageId, storage.getId()) .eq(FileDO::getPath, file.getPath()) @@ -136,6 +143,11 @@ public boolean update(FileInfo fileInfo) { fileInfo.setFileId(String.valueOf(file.getId())); return true; } else { + // 暂存缺失时沿用已有展示名,避免用存储名覆盖原记录 + if (StrUtil.isBlank(originalFileName) && StrUtil.isNotBlank(existFile + .getOriginalName())) { + file.setOriginalName(existFile.getOriginalName()); + } file.setId(existFile.getId()); boolean updated = fileMapper.updateById(file) > 0; fileInfo.setFileId(String.valueOf(existFile.getId())); @@ -230,6 +242,8 @@ public void saveMultipartSession(String uploadId, MultipartInitResp initResp, target.setUploadId(initResp.getUploadId()); target.setBucket(initResp.getBucket()); target.setPlatform(initResp.getPlatform()); + // TODO 升级 starter 后需为 MultipartUploadCreateResp 补 originalFileName,并在 save/get 双向拷贝, + // 否则会话中的原始文件名会在 Redis 往返时丢失 target.setFileName(initResp.getFileName()); target.setFileMd5(initResp.getFileMd5()); target.setFileSize(ObjectUtil.defaultIfNull(initResp.getFileSize(), 0L)); @@ -306,6 +320,7 @@ public void deleteMultipartSession(String uploadId) { RedisUtils.delete(key); RedisUtils.delete(metadataKey); RedisUtils.delete(expireKey); + MultipartOriginalNameStore.delete(uploadId); log.debug("删除分片上传信息: uploadId={}", uploadId); } catch (Exception e) { log.error("删除分片上传信息失败: uploadId={}", uploadId, e); 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 f0f012489..d4a8d4248 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 @@ -75,6 +75,16 @@ public class MultipartUploadConstants { */ public static final String MULTIPART_EXPIRE_PREFIX = "multipart:expire:"; + /** + * 原始文件名前缀 + *

+ * 用于暂存分片上传的原始文件名。分片上传的落盘文件名为唯一 ID,完成上传时需据此回填文件记录的展示名。 + * 键格式:multipart:original-name:{uploadId} + * 值格式:String,即用户上传时的原始文件名 + *

+ */ + public static final String MULTIPART_ORIGINAL_NAME_PREFIX = "multipart:original-name:"; + /** * 默认过期时间(小时) *

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 f4cb208b3..a67e875f1 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 @@ -48,11 +48,19 @@ public class FileDO extends BaseDO { /** * 名称 + * + *

+ * 存储中的实际文件名:文件为「唯一 ID + 原扩展名」(见 {@code FileNameGenerator}),文件夹仍为可读名称。 + *

*/ private String name; /** * 原始名称 + * + *

+ * 用户上传时的原始文件名,也是列表、详情等界面展示的名称。 + *

*/ private String originalName; diff --git a/continew-system/src/main/java/top/continew/admin/system/model/resp/file/FileResp.java b/continew-system/src/main/java/top/continew/admin/system/model/resp/file/FileResp.java index 1aed31132..f8351a6ab 100644 --- a/continew-system/src/main/java/top/continew/admin/system/model/resp/file/FileResp.java +++ b/continew-system/src/main/java/top/continew/admin/system/model/resp/file/FileResp.java @@ -40,8 +40,12 @@ public class FileResp extends BaseDetailResp { /** * 名称 + * + *

+ * 存储中的实际文件名,文件为「唯一 ID + 原扩展名」,展示请使用 {@link #originalName}。 + *

*/ - @Schema(description = "名称", example = "6824afe8408da079832dcfb6.jpg") + @Schema(description = "名称(存储名)", example = "3f2a1c9e5b7d4a6f8c0e1d2b3a4c5d6e.jpg") private String name; /** diff --git a/continew-system/src/main/java/top/continew/admin/system/model/resp/file/MultipartUploadCreateResp.java b/continew-system/src/main/java/top/continew/admin/system/model/resp/file/MultipartUploadCreateResp.java index 09d3f52b0..9c017cf46 100644 --- a/continew-system/src/main/java/top/continew/admin/system/model/resp/file/MultipartUploadCreateResp.java +++ b/continew-system/src/main/java/top/continew/admin/system/model/resp/file/MultipartUploadCreateResp.java @@ -57,9 +57,9 @@ public class MultipartUploadCreateResp implements Serializable { private String platform; /** - * 文件名称 + * 存储文件名 */ - @Schema(description = "文件名称") + @Schema(description = "存储文件名(唯一 ID + 原扩展名)", example = "3f2a1c9e5b7d4a6f8c0e1d2b3a4c5d6e.zip") private String fileName; /** 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 a20ff33f9..a57dde7ff 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 @@ -308,10 +308,8 @@ private FileInfo doUpload(Object file, String parentPath, String storageCode, St // 创建父级目录 this.createParentDir(parentPath, storage); - // 生成唯一文件名(处理重名情况) - String uniqueFileName = - FileNameGenerator.generateUniqueName(originalFileName, parentPath, storage - .getId(), baseMapper); + // 落盘文件名使用唯一 ID(扩展名不变),原始文件名仅作为展示名,避免同名文件互相覆盖 + String storedFileName = FileNameGenerator.generateStoredName(originalFileName); String sha256 = calculateSha256(file); String normalizedUploadTaskId = StrUtil.emptyToNull(StrUtil.trim(uploadTaskId)); long totalBytes = this.getFileSize(file); @@ -323,7 +321,7 @@ private FileInfo doUpload(Object file, String parentPath, String storageCode, St UploadPretreatment uploadPretreatment = fileStorageService.of(normalizeUploadSource(file)) .platform(storage.getCode()) .path(this.pretreatmentPath(parentPath)) - .fileName(uniqueFileName) + .fileName(storedFileName) .metadata("sha256", sha256) .metadata("storageCode", storage.getCode()) .metadata("storageId", String.valueOf(storage.getId())); @@ -332,8 +330,8 @@ private FileInfo doUpload(Object file, String parentPath, String storageCode, St uploadPretreatment.thumbnail(100, 100); } uploadPretreatment.onProgress((progressSize, allSize, percentage) -> { - log.info("文件 [{}] 已上传 [{}],总大小 [{}],进度 [{}%]", uniqueFileName, progressSize, allSize, - percentage); + log.info("文件 [{}] 已上传 [{}],总大小 [{}],进度 [{}%]", originalFileName, progressSize, + allSize, percentage); if (StrUtil.isNotBlank(normalizedUploadTaskId)) { FileUploadProgressStatusEnum status = (allSize > 0 && progressSize >= allSize) || percentage >= 100 @@ -345,9 +343,9 @@ private FileInfo doUpload(Object file, String parentPath, String storageCode, St }); try { // 上传 - log.info("开始上传文件: {}", uniqueFileName); + log.info("开始上传文件: {}", originalFileName); FileInfo fileInfo = uploadPretreatment.upload(); - log.info("文件 [{}] 上传完成", uniqueFileName); + log.info("文件 [{}] 上传完成", originalFileName); FileInfo result = this.postProcessUploadResult(fileInfo, storage); if (StrUtil.isNotBlank(normalizedUploadTaskId)) { long completedSize = result.getSize() == null ? totalBytes : result.getSize(); 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 75aeeb4d0..2e41c7ce9 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 @@ -32,6 +32,7 @@ import top.continew.admin.system.service.MultipartUploadService; import top.continew.admin.system.service.StorageService; import top.continew.admin.system.util.FileNameGenerator; +import top.continew.admin.system.util.MultipartOriginalNameStore; import top.continew.admin.system.util.StoragePathValidator; import top.continew.starter.core.constant.StringConstants; import top.continew.starter.core.exception.BaseException; @@ -64,45 +65,34 @@ public class MultipartUploadServiceImpl implements MultipartUploadService { public MultipartUploadCreateResp initMultipartUpload( MultipartUploadCreateReq multiPartUploadInitReq) { // 校验文件扩展名是否在白名单内,防止任意类型文件上传(如 .jsp/.html 等) - String extName = FileUtil.extName(multiPartUploadInitReq.getFileName()).toLowerCase(); + String originalFileName = multiPartUploadInitReq.getFileName(); + FileTypeEnum fileType = FileTypeEnum.getByExtension(FileUtil.extName(originalFileName)); List allExtensions = FileTypeEnum.getAllExtensions(); - CheckUtils.throwIf(!allExtensions.contains(extName), "不支持的文件类型,仅支持 {} 格式的文件", String + CheckUtils.throwIf(fileType == FileTypeEnum.UNKNOWN, "不支持的文件类型,仅支持 {} 格式的文件", String .join(StringConstants.COMMA, allExtensions)); // 校验上级目录路径与文件名,防止通过 ../ 等路径穿越将文件写入存储根目录之外 StoragePathValidator.validate(multiPartUploadInitReq.getParentPath()); - StoragePathValidator.validateName(multiPartUploadInitReq.getFileName()); + StoragePathValidator.validateName(originalFileName); StorageDO storageDO = storageService.getByCode(null); - // 检测文件名是否已存在(同一目录下文件名不能重复) - String originalFileName = multiPartUploadInitReq.getFileName(); String parentPath = multiPartUploadInitReq.getParentPath(); - boolean exists = fileMapper.lambdaQuery() - .eq(FileDO::getParentPath, parentPath) - .eq(FileDO::getStorageId, storageDO.getId()) - .eq(FileDO::getName, originalFileName) - .ne(FileDO::getType, FileTypeEnum.DIR) - .exists(); - if (exists) { - throw new BaseException("文件名已存在:" + originalFileName); - } - - // 生成唯一文件名(处理重名情况) - String uniqueFileName = - FileNameGenerator.generateUniqueName(originalFileName, parentPath, storageDO - .getId(), fileMapper); - multiPartUploadInitReq.setFileName(uniqueFileName); fileService.createParentDir(StrUtil.blankToDefault(parentPath, StringConstants.SLASH), storageDO); + // 落盘文件名使用唯一 ID(扩展名不变),原始文件名单独暂存,待完成上传时回填到文件记录 + // TODO 升级到支持 originalFileName 的 starter 版本后,改为 storageReq.setOriginalFileName(originalFileName), + // 并移除 MultipartOriginalNameStore 的暂存与 FileRecorderImpl 的回填 + String storedFileName = FileNameGenerator.generateStoredName(originalFileName); MultipartUploadInitReq storageReq = new MultipartUploadInitReq(); storageReq.setPlatform(storageDO.getCode()); storageReq.setBucket(storageDO.getBucketName()); - storageReq.setFileName(uniqueFileName); + storageReq.setFileName(storedFileName); 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); + MultipartOriginalNameStore.save(initResp.getUploadId(), originalFileName); return this.toAdminInitResp(initResp); } @@ -139,6 +129,8 @@ public FileDO completeMultipartUpload(String uploadId) { if (session == null) { throw new BaseException("无效的 uploadId: " + uploadId); } + // 分片会话在启动器完成上传后即被清理,原始文件名必须在此之前取出 + String originalFileName = MultipartOriginalNameStore.resolve(uploadId); FileInfo fileInfo = fileStorageService.completeMultipartUpload(uploadId, null); StorageDO storageDO = storageService.getByCode(session.getPlatform()); FileDO file = fileMapper.lambdaQuery() @@ -151,6 +143,9 @@ public FileDO completeMultipartUpload(String uploadId) { // 兼容兜底:记录器未完成落库时补录 FileDO fallback = new FileDO(fileInfo); fallback.setStorageId(storageDO.getId()); + if (StrUtil.isNotBlank(originalFileName)) { + fallback.setOriginalName(originalFileName); + } fallback.setType(FileTypeEnum.getByExtension(FileUtil.extName(fallback.getName()))); fileService.save(fallback); return fallback; diff --git a/continew-system/src/main/java/top/continew/admin/system/util/FileNameGenerator.java b/continew-system/src/main/java/top/continew/admin/system/util/FileNameGenerator.java index ec5c29de5..79fd53937 100644 --- a/continew-system/src/main/java/top/continew/admin/system/util/FileNameGenerator.java +++ b/continew-system/src/main/java/top/continew/admin/system/util/FileNameGenerator.java @@ -16,26 +16,26 @@ package top.continew.admin.system.util; +import cn.hutool.core.io.file.FileNameUtil; +import cn.hutool.core.util.IdUtil; import cn.hutool.core.util.StrUtil; -import lombok.extern.slf4j.Slf4j; -import top.continew.admin.system.enums.FileTypeEnum; -import top.continew.admin.system.mapper.FileMapper; -import top.continew.admin.system.model.entity.FileDO; -import top.continew.starter.core.util.CollUtils; - -import java.util.List; +import top.continew.starter.core.constant.StringConstants; /** - * 文件名生成工具类 + * 存储文件名生成工具类 + * + *

+ * 文件落盘时不使用用户上传的原始文件名,而是生成「唯一 ID + 原扩展名」作为存储名,例如 + * {@code 3f2a1c9e5b7d4a6f8c0e1d2b3a4c5d6e.png}。原始文件名只作为展示名({@code sys_file.original_name})保留。 + *

* *

- * 提供文件重名检测和自动重命名功能。当文件名冲突时,自动添加序号后缀,如:file.txt → file(1).txt + * 这样处理的收益:同名文件不再需要 {@code file(1).txt} 式的重命名探测,也不会因为并发上传或回收站还原而互相覆盖;扩展名保持不变,存储引擎与浏览器的类型识别不受影响。 *

* * @author fjwupeng * @since 4.2.0 */ -@Slf4j public class FileNameGenerator { private FileNameGenerator() { @@ -43,177 +43,28 @@ private FileNameGenerator() { } /** - * 生成唯一文件名 + * 生成存储文件名 * *

- * 当目标目录存在同名文件时,自动添加序号后缀: + * 由 32 位随机十六进制串与原扩展名拼接而成,扩展名保留原有大小写: + *

* - *

- * - * @param fileName 原始文件名 - * @param parentPath 上级目录路径 - * @param storageId 存储ID - * @param fileMapper 文件Mapper - * @return 唯一文件名 - */ - public static String generateUniqueName(String fileName, String parentPath, Long storageId, - FileMapper fileMapper) { - // 1. 先检查原始文件名是否可用 - boolean exists = existsByName(parentPath, storageId, fileName, fileMapper); - if (!exists) { - return fileName; - } - - // 2. 解析文件名 - String[] parts = parseFileName(fileName); - String baseName = parts[0]; - String extension = parts[1]; - - // 3. 获取该目录下所有可能的冲突文件名(优化:批量查询) - List existingNames = - selectNamesByParentPath(parentPath, storageId, baseName, fileMapper); - - // 4. 寻找第一个可用的序号 - int counter = 1; - while (true) { - String newName = buildFileNameWithCounter(baseName, extension, counter); - if (!existingNames.contains(newName)) { - log.debug("文件名 [{}] 重命名为 [{}]", fileName, newName); - return newName; - } - counter++; - - // 安全限制,防止无限循环 - if (counter > 9999) { - log.warn("文件名重命名超过最大限制,使用当前时间戳: {}", fileName); - return baseName + "_" + System.currentTimeMillis() + (StrUtil.isNotBlank(extension) - ? "." + extension - : ""); - } - } - } - - /** - * 解析文件名为基础名和扩展名 * *

- * 示例: + * 唯一 ID 为 122 位随机数,无需查询数据库做存在性校验,也不做重试。 *

- *
    - *
  • "document.pdf" → ["document", "pdf"]
  • - *
  • "README" → ["README", ""]
  • - *
  • ".gitignore" → [".gitignore", ""]
  • - *
  • "archive.tar.gz" → ["archive.tar", "gz"]
  • - *
- * - * @param fileName 文件名 - * @return 数组 [基础名, 扩展名],扩展名可能为空字符串 - */ - public static String[] parseFileName(String fileName) { - if (StrUtil.isBlank(fileName)) { - return new String[] {"", ""}; - } - - // 处理隐藏文件(以.开头) - boolean isHidden = fileName.startsWith("."); - String nameWithoutDot = isHidden ? fileName.substring(1) : fileName; - - // 处理空文件名(如只有"."的情况) - if (nameWithoutDot.isEmpty()) { - return new String[] {fileName, ""}; - } - - // 查找最后一个点号位置 - int lastDotIndex = nameWithoutDot.lastIndexOf('.'); - - // 点号不存在或在开头(如 ".bashrc"),视为无扩展名 - if (lastDotIndex <= 0) { - return new String[] {fileName, ""}; - } - - 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[] {baseName, extension}; - } - - /** - * 构建带序号的文件名 - * - * @param baseName 基础名 - * @param extension 扩展名(可能为空) - * @param counter 序号(必须 >= 1) - * @return 新文件名,如 "file(1).txt" - */ - public static String buildFileNameWithCounter(String baseName, String extension, int counter) { - if (counter < 1) { - throw new IllegalArgumentException("序号必须大于等于1"); - } - - StringBuilder sb = new StringBuilder(baseName); - sb.append("(").append(counter).append(")"); - - if (StrUtil.isNotBlank(extension)) { - sb.append(".").append(extension); - } - - return sb.toString(); - } - - /** - * 检查文件是否存在 * - * @param parentPath 上级目录 - * @param storageId 存储ID - * @param name 文件名 - * @param fileMapper 文件Mapper - * @return true: 存在 + * @param originalFileName 原始文件名 + * @return 存储文件名 */ - private static boolean existsByName(String parentPath, Long storageId, String name, - FileMapper fileMapper) { - return fileMapper.lambdaQuery() - .eq(FileDO::getParentPath, parentPath) - .eq(FileDO::getStorageId, storageId) - .eq(FileDO::getName, name) - .ne(FileDO::getType, FileTypeEnum.DIR) - .exists(); - } - - /** - * 查询指定目录下的文件名称列表(用于重名检测) - * - * @param parentPath 上级目录 - * @param storageId 存储ID - * @param namePrefix 名称前缀(可为null,表示查询所有) - * @param fileMapper 文件Mapper - * @return 文件名列表 - */ - private static List selectNamesByParentPath(String parentPath, - Long storageId, - String namePrefix, - FileMapper fileMapper) { - var wrapper = fileMapper.lambdaQuery() - .eq(FileDO::getParentPath, parentPath) - .eq(FileDO::getStorageId, storageId) - .ne(FileDO::getType, FileTypeEnum.DIR) - .select(FileDO::getName) - .last("LIMIT 10000"); // 限制最大查询数量,防止内存溢出 - - if (StrUtil.isNotBlank(namePrefix)) { - wrapper.likeRight(FileDO::getName, namePrefix); - } - - return CollUtils.mapToList(wrapper.list(), FileDO::getName); + public static String generateStoredName(String originalFileName) { + String storedName = IdUtil.fastSimpleUUID(); + String extension = FileNameUtil.extName(originalFileName); + return StrUtil.isBlank(extension) ? storedName + : storedName + StringConstants.DOT + extension; } } diff --git a/continew-system/src/main/java/top/continew/admin/system/util/MultipartOriginalNameStore.java b/continew-system/src/main/java/top/continew/admin/system/util/MultipartOriginalNameStore.java new file mode 100644 index 000000000..e133333cb --- /dev/null +++ b/continew-system/src/main/java/top/continew/admin/system/util/MultipartOriginalNameStore.java @@ -0,0 +1,141 @@ +/* + * 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.util; + +import cn.hutool.core.util.StrUtil; +import lombok.extern.slf4j.Slf4j; +import top.continew.admin.system.constant.MultipartUploadConstants; +import top.continew.starter.cache.redisson.util.RedisUtils; + +import java.time.Duration; +import java.util.Map; + +/** + * 分片上传原始文件名暂存工具 + * + *

+ * 文件落盘名统一使用唯一 ID,而分片上传完成时启动器会用会话中的文件名覆盖展示名(见 + * {@code FileStorageService#completeMultipartUpload}),因此需要在初始化阶段把用户上传时的原始文件名单独暂存,待完成上传时回填到文件记录。 + *

+ * + *

+ * 采用独立缓存键而非对象元数据:既不污染存储引擎的对象元数据,也不用担心非 ASCII 文件名进入 HTTP 头。缓存不可用时整体降级为「展示名退化为存储名」,不影响上传本身。 + *

+ * + * @author luoqiz + * @since 5.0.0 + */ +@Slf4j +public class MultipartOriginalNameStore { + + // TODO 待 continew-starter 支持分片会话携带原始文件名后,删除本类,改为向 MultipartUploadInitReq 传入 originalFileName + + /** + * 文件元数据中的分片上传 ID 键名 + * + *

+ * 由 {@code FileStorageService#completeMultipartUpload} 在调用记录器前写入。 + *

+ */ + private static final String METADATA_UPLOAD_ID = "uploadId"; + + private MultipartOriginalNameStore() { + } + + /** + * 暂存原始文件名 + * + *

+ * 缓存键已存在时保留首次写入的值:同一 MD5 的分片上传会话会被复用,此时上传 ID 相同,以首次初始化的文件名为准。 + *

+ * + *

+ * 写入失败不向上抛出:调用方此时已创建好存储会话,抛出会让用户看到失败提示,但会话与分片数据已经产生,反而制造孤儿数据。 + *

+ * + * @param uploadId 分片上传 ID + * @param originalName 原始文件名 + */ + public static void save(String uploadId, String originalName) { + if (StrUtil.isBlank(uploadId) || StrUtil.isBlank(originalName)) { + return; + } + try { + // TODO 会话 TTL 目前是固定常量,若后续改为可配置,需与此处保持同步 + RedisUtils.setIfAbsent(key(uploadId), originalName, Duration + .ofHours(MultipartUploadConstants.DEFAULT_EXPIRE_HOURS)); + } catch (Exception e) { + log.warn("暂存原始文件名失败,文件展示名将退化为存储名:uploadId={}", uploadId, e); + } + } + + /** + * 查询暂存的原始文件名 + * + * @param uploadId 分片上传 ID + * @return 原始文件名(未暂存或查询失败时返回 {@code null}) + */ + public static String resolve(String uploadId) { + if (StrUtil.isBlank(uploadId)) { + return null; + } + try { + String originalName = RedisUtils.get(key(uploadId)); + if (StrUtil.isBlank(originalName)) { + log.debug("未查询到暂存的原始文件名:uploadId={}", uploadId); + return null; + } + return originalName; + } catch (Exception e) { + log.warn("查询原始文件名失败,文件展示名将退化为存储名:uploadId={}", uploadId, e); + return null; + } + } + + /** + * 基于文件元数据查询暂存的原始文件名 + * + * @param metadata 文件元数据 + * @return 原始文件名(元数据中无分片上传 ID 或未暂存时返回 {@code null}) + */ + public static String resolveByMetadata(Map metadata) { + if (metadata == null) { + return null; + } + return resolve(metadata.get(METADATA_UPLOAD_ID)); + } + + /** + * 删除暂存的原始文件名 + * + * @param uploadId 分片上传 ID + */ + public static void delete(String uploadId) { + if (StrUtil.isBlank(uploadId)) { + return; + } + try { + RedisUtils.delete(key(uploadId)); + } catch (Exception e) { + log.warn("删除暂存的原始文件名失败:uploadId={}", uploadId, e); + } + } + + private static String key(String uploadId) { + return MultipartUploadConstants.MULTIPART_ORIGINAL_NAME_PREFIX + uploadId; + } +} diff --git a/continew-system/src/test/java/top/continew/admin/system/config/file/FileRecorderImplTest.java b/continew-system/src/test/java/top/continew/admin/system/config/file/FileRecorderImplTest.java new file mode 100644 index 000000000..a25826a09 --- /dev/null +++ b/continew-system/src/test/java/top/continew/admin/system/config/file/FileRecorderImplTest.java @@ -0,0 +1,181 @@ +/* + * 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.config.file; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +import com.baomidou.mybatisplus.extension.conditions.query.LambdaQueryChainWrapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; +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.admin.system.util.MultipartOriginalNameStore; +import top.continew.starter.storage.domain.model.resp.FileInfo; + +import java.util.HashMap; +import java.util.Map; + +/** + * 文件记录回填展示名测试 + * + *

+ * 分片上传的落盘文件名是唯一 ID,启动器在完成上传时会把会话中的文件名(即唯一 ID)写入展示名,因此记录器必须用暂存的原始文件名覆盖它。 + * 这里固定两条契约:回填同时覆盖「新增」与「更新」两条落库分支;暂存缺失时保留原值,绝不写入空值。 + *

+ * + *

+ * {@link MultipartOriginalNameStore} 的静态初始化只有日志器,不依赖 Spring 容器,因此可以直接静态 mock;这也是此处能写单元测试而 + * {@code RedisUtils} 不能的原因。 + *

+ * + * @author luoqiz + * @since 5.0.0 + */ +class FileRecorderImplTest { + + private static final String STORED_NAME = "3f2a1c9e5b7d4a6f8c0e1d2b3a4c5d6e.pdf"; + + private static final String ORIGINAL_NAME = "年度报告.pdf"; + + private FileMapper fileMapper; + + private FileRecorderImpl fileRecorder; + + @BeforeEach + void setUp() { + this.fileMapper = mock(FileMapper.class); + StorageMapper storageMapper = mock(StorageMapper.class); + StorageDO storage = new StorageDO(); + storage.setId(1L); + storage.setCode("local"); + this.stubStorageQuery(storageMapper, storage); + this.fileRecorder = new FileRecorderImpl(this.fileMapper, storageMapper); + } + + @Test + void shouldBackfillOriginalNameWhenInserting() { + this.stubFileQuery(null); + ArgumentCaptor captor = ArgumentCaptor.forClass(FileDO.class); + when(this.fileMapper.insert(captor.capture())).thenReturn(1); + + try (MockedStatic store = + mockStatic(MultipartOriginalNameStore.class)) { + store.when(() -> MultipartOriginalNameStore.resolveByMetadata(any())) + .thenReturn(ORIGINAL_NAME); + assertTrue(this.fileRecorder.update(this.buildFileInfo())); + } + + assertEquals(ORIGINAL_NAME, captor.getValue().getOriginalName()); + assertEquals(STORED_NAME, captor.getValue().getName()); + } + + @Test + void shouldBackfillOriginalNameWhenUpdatingExistingRecord() { + FileDO existFile = new FileDO(); + existFile.setId(9L); + this.stubFileQuery(existFile); + ArgumentCaptor captor = ArgumentCaptor.forClass(FileDO.class); + when(this.fileMapper.updateById(captor.capture())).thenReturn(1); + + try (MockedStatic store = + mockStatic(MultipartOriginalNameStore.class)) { + store.when(() -> MultipartOriginalNameStore.resolveByMetadata(any())) + .thenReturn(ORIGINAL_NAME); + assertTrue(this.fileRecorder.update(this.buildFileInfo())); + } + + assertEquals(9L, captor.getValue().getId()); + assertEquals(ORIGINAL_NAME, captor.getValue().getOriginalName()); + } + + @Test + void shouldKeepStoredNameWhenOriginalNameMissing() { + this.stubFileQuery(null); + ArgumentCaptor captor = ArgumentCaptor.forClass(FileDO.class); + when(this.fileMapper.insert(captor.capture())).thenReturn(1); + FileInfo fileInfo = this.buildFileInfo(); + fileInfo.getMetadata().remove("uploadId"); + + try (MockedStatic store = + mockStatic(MultipartOriginalNameStore.class)) { + store.when(() -> MultipartOriginalNameStore.resolveByMetadata(any())).thenReturn(null); + assertTrue(this.fileRecorder.update(fileInfo)); + } + + assertEquals(STORED_NAME, captor.getValue().getOriginalName()); + } + + @Test + void shouldKeepExistingOriginalNameWhenOriginalNameMissing() { + FileDO existFile = new FileDO(); + existFile.setId(9L); + existFile.setOriginalName(ORIGINAL_NAME); + this.stubFileQuery(existFile); + ArgumentCaptor captor = ArgumentCaptor.forClass(FileDO.class); + when(this.fileMapper.updateById(captor.capture())).thenReturn(1); + FileInfo fileInfo = this.buildFileInfo(); + fileInfo.getMetadata().remove("uploadId"); + + try (MockedStatic store = + mockStatic(MultipartOriginalNameStore.class)) { + store.when(() -> MultipartOriginalNameStore.resolveByMetadata(any())).thenReturn(null); + assertTrue(this.fileRecorder.update(fileInfo)); + } + + assertEquals(ORIGINAL_NAME, captor.getValue().getOriginalName()); + } + + @SuppressWarnings("unchecked") + private void stubStorageQuery(StorageMapper storageMapper, StorageDO storage) { + LambdaQueryChainWrapper query = mock(LambdaQueryChainWrapper.class); + when(storageMapper.lambdaQuery()).thenReturn(query); + when(query.eq(any(), any())).thenReturn(query); + when(query.one()).thenReturn(storage); + } + + @SuppressWarnings("unchecked") + private void stubFileQuery(FileDO existFile) { + LambdaQueryChainWrapper query = mock(LambdaQueryChainWrapper.class); + when(this.fileMapper.lambdaQuery()).thenReturn(query); + when(query.eq(any(), any())).thenReturn(query); + when(query.one()).thenReturn(existFile); + } + + private FileInfo buildFileInfo() { + FileInfo fileInfo = new FileInfo(); + fileInfo.setPlatform("local"); + fileInfo.setBucket("test"); + fileInfo.setName(STORED_NAME); + fileInfo.setPath("2026/10/21/" + STORED_NAME); + fileInfo.setOriginalFileName(STORED_NAME); + fileInfo.setSize(1024L); + Map metadata = new HashMap<>(); + metadata.put("uploadId", "upload-1"); + metadata.put("sha256", "e3b0c44298fc1c149afbf4c8996fb924"); + fileInfo.setMetadata(metadata); + return fileInfo; + } +} diff --git a/continew-system/src/test/java/top/continew/admin/system/util/FileNameGeneratorTest.java b/continew-system/src/test/java/top/continew/admin/system/util/FileNameGeneratorTest.java new file mode 100644 index 000000000..a5a459937 --- /dev/null +++ b/continew-system/src/test/java/top/continew/admin/system/util/FileNameGeneratorTest.java @@ -0,0 +1,84 @@ +/* + * 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.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +import java.util.regex.Pattern; + +/** + * 存储文件名生成测试 + * + * @author luoqiz + * @since 5.0.0 + */ +class FileNameGeneratorTest { + + /** + * 32 位小写十六进制串,可选带一个扩展名 + */ + private static final Pattern STORED_NAME_PATTERN = Pattern + .compile("^[0-9a-f]{32}(\\.[0-9a-zA-Z]+)?$"); + + @Test + void shouldGenerateUuidWithExtension() { + String storedName = FileNameGenerator.generateStoredName("报告.pdf"); + + assertTrue(STORED_NAME_PATTERN.matcher(storedName).matches()); + assertEquals(32 + ".pdf".length(), storedName.length()); + assertTrue(storedName.endsWith(".pdf")); + } + + @Test + void shouldKeepExtensionCase() { + String storedName = FileNameGenerator.generateStoredName("截图.PNG"); + + assertTrue(storedName.endsWith(".PNG")); + assertTrue(STORED_NAME_PATTERN.matcher(storedName).matches()); + } + + @Test + void shouldUseLastExtensionSegment() { + assertTrue(FileNameGenerator.generateStoredName("archive.tar.gz").endsWith(".gz")); + } + + @Test + void shouldNotAppendDotWhenExtensionAbsent() { + String storedName = FileNameGenerator.generateStoredName("README"); + + assertEquals(32, storedName.length()); + assertFalse(storedName.contains(".")); + } + + @Test + void shouldTolerateNullAndBlankFileName() { + assertTrue(STORED_NAME_PATTERN.matcher(FileNameGenerator.generateStoredName(null)) + .matches()); + assertTrue(STORED_NAME_PATTERN.matcher(FileNameGenerator.generateStoredName("")).matches()); + } + + @Test + void shouldGenerateDifferentNamesForSameFileName() { + assertNotEquals(FileNameGenerator.generateStoredName("a.png"), FileNameGenerator + .generateStoredName("a.png")); + } +} diff --git a/continew-system/src/test/java/top/continew/admin/system/util/MultipartOriginalNameStoreTest.java b/continew-system/src/test/java/top/continew/admin/system/util/MultipartOriginalNameStoreTest.java new file mode 100644 index 000000000..08e3082bd --- /dev/null +++ b/continew-system/src/test/java/top/continew/admin/system/util/MultipartOriginalNameStoreTest.java @@ -0,0 +1,75 @@ +/* + * 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.util; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertNull; + +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +/** + * 分片上传原始文件名暂存测试 + * + *

+ * 仅覆盖不访问 Redis 的兜底分支:{@code RedisUtils} 的静态初始化依赖 Spring 容器,纯单元测试环境下无法加载,Redis 读写路径需由集成测试覆盖。 + *

+ * + *

+ * 这些分支并非边角料:正常上传时 {@code FileRecorderImpl#update} 会无条件调用 + * {@link MultipartOriginalNameStore#resolveByMetadata},一旦此处抛异常,所有上传都会失败,因此「元数据缺失时安静地返回 + * null」是需要被固定下来的契约。 + *

+ * + * @author luoqiz + * @since 5.0.0 + */ +class MultipartOriginalNameStoreTest { + + @Test + void shouldReturnNullWhenMetadataAbsent() { + assertNull(MultipartOriginalNameStore.resolveByMetadata(null)); + assertNull(MultipartOriginalNameStore.resolveByMetadata(new HashMap<>())); + } + + @Test + void shouldReturnNullWhenMetadataHasNoUploadId() { + Map metadata = new HashMap<>(); + metadata.put("sha256", "e3b0c44298fc1c149afbf4c8996fb924"); + metadata.put("uploadId", " "); + + assertNull(MultipartOriginalNameStore.resolveByMetadata(metadata)); + } + + @Test + void shouldIgnoreBlankUploadId() { + assertNull(MultipartOriginalNameStore.resolve(null)); + assertNull(MultipartOriginalNameStore.resolve(" ")); + assertDoesNotThrow(() -> MultipartOriginalNameStore.save(null, "年度报告.PDF")); + assertDoesNotThrow(() -> MultipartOriginalNameStore.save(" ", "年度报告.PDF")); + assertDoesNotThrow(() -> MultipartOriginalNameStore.delete(null)); + assertDoesNotThrow(() -> MultipartOriginalNameStore.delete(" ")); + } + + @Test + void shouldIgnoreBlankOriginalName() { + assertDoesNotThrow(() -> MultipartOriginalNameStore.save("upload-1", null)); + assertDoesNotThrow(() -> MultipartOriginalNameStore.save("upload-1", " ")); + } +} diff --git a/docs/manual/system/file.mdx b/docs/manual/system/file.mdx index aef67270e..fb51471b6 100644 --- a/docs/manual/system/file.mdx +++ b/docs/manual/system/file.mdx @@ -26,7 +26,7 @@ description: 文件的上传、预览、分片上传、秒传与回收站,以 | 字段 | 说明 | |:-----|:-----| -| `name` / `original_name` | 存储名与原始文件名 | +| `name` / `original_name` | 存储名(唯一 ID + 原扩展名)与原始文件名,展示以 `original_name` 为准 | | `size` | 大小(字节) | | `parent_path` / `path` | 上级目录与完整路径,默认按 `/yyyy/MM/dd` 归档 | | `type` | 类型(0:目录;1:其他;2:图片;3:文档;4:视频;5:音频),按扩展名归入 `FileTypeEnum` | @@ -36,6 +36,16 @@ description: 文件的上传、预览、分片上传、秒传与回收站,以 上传时不指定 `parentPath` 则按日期目录归档。单文件大小受 Spring multipart 限制(`max-file-size` 默认 10MB),超出请走分片上传。 +## 存储命名规则 + +文件落盘名不使用用户上传的原始文件名,统一为「32 位唯一 ID + 原扩展名」,例如 `3f2a1c9e5b7d4a6f8c0e1d2b3a4c5d6e.png`,扩展名保留原有大小写。原始文件名保存在 `sys_file.original_name`,列表、详情、重命名与下载名均以它为准。 + +这样的好处是同一目录下可以存在多个同名文件,并发上传与回收站还原都不会互相覆盖存储中的实体文件。文件夹(`type = 0`)仍使用可读名称,不参与唯一 ID 命名。该规则只作用于新上传的文件,存量文件保留原有命名,其 URL 不受影响。 + + +直接访问存储直链时,浏览器保存的默认文件名即为唯一 ID 名;需要原始文件名时请从文件管理页面下载(页面按 `original_name` 命名)。对外的 `FileResp.name` 也随之变为唯一 ID 名,需要展示名称请使用 `originalName`。 + + ## 秒传与分片上传 前端上传前先调 `GET /system/file/check?fileHash=xxx` 提交 SHA256,命中已有文件时直接复用记录,不再传输内容。 From 55a12d3ec83ca642eaf503082f4249f1313fd264 Mon Sep 17 00:00:00 2001 From: luoqiz Date: Wed, 21 Oct 2026 17:29:24 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(system):=20=E6=8B=92=E7=BB=9D=E9=80=9A?= =?UTF-8?q?=E8=BF=87=E4=BF=AE=E6=94=B9=E6=8E=A5=E5=8F=A3=E5=8F=98=E6=9B=B4?= =?UTF-8?q?=E4=B8=8A=E7=BA=A7=E7=9B=AE=E5=BD=95=EF=BC=8C=E5=B9=B6=E9=99=90?= =?UTF-8?q?=E5=88=B6=E5=90=8D=E7=A7=B0=E9=95=BF=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - beforeUpdate 对非 null 的 parentPath 归一后与原记录比较,目录实际变化直接拒绝,避免只改记录路径导致实体文件失联 - 将 normalizeParentPath / isSameParentPath 抽到 StoragePathValidator,供服务与测试复用 - validateName 增加 255 字符上限,与 sys_file.name / original_name 列宽对齐,避免落库时 DataTooLong 留下孤儿会话 - 补充名称长度、非法名称/路径、路径归一化与同目录判定测试 Assisted-by: DeepSeek Harness --- .../system/service/impl/FileServiceImpl.java | 14 ++++ .../system/util/StoragePathValidator.java | 54 ++++++++++++- .../system/util/StoragePathValidatorTest.java | 79 +++++++++++++++++++ 3 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 continew-system/src/test/java/top/continew/admin/system/util/StoragePathValidatorTest.java 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 a57dde7ff..d131cd57c 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 @@ -184,6 +184,20 @@ public void beforeUpdate(FileReq req, Long id) { // 需与新建入口做同样的路径穿越校验,避免通过修改接口改写出存储根目录之外的路径 StoragePathValidator.validate(req.getParentPath()); StoragePathValidator.validateName(req.getOriginalName()); + // 修改上级目录只会改写记录中的路径,并不会搬运存储中的实体文件,改完即失效,故拒绝实际变更; + // null 表示未提交该字段(如仅重命名),空白值则视为要改到根目录,同样需要参与比较 + if (req.getParentPath() != null) { + String targetParentPath = StoragePathValidator.normalizeParentPath(req.getParentPath()); + // TODO 此处与基类 update 各查一次 getById,如后续基类支持回传实体可合并为一次 + FileDO existFile = this.getById(id); + if (existFile != null) { + CheckUtils + .throwIf(!StoragePathValidator.isSameParentPath(targetParentPath, existFile + .getParentPath()), "暂不支持移动文件或文件夹"); + } + // 统一根目录表示,避免空白值被写入记录后又按另一种形式拼出路径 + req.setParentPath(targetParentPath); + } } @Override diff --git a/continew-system/src/main/java/top/continew/admin/system/util/StoragePathValidator.java b/continew-system/src/main/java/top/continew/admin/system/util/StoragePathValidator.java index ec22a24b4..e74ea005d 100644 --- a/continew-system/src/main/java/top/continew/admin/system/util/StoragePathValidator.java +++ b/continew-system/src/main/java/top/continew/admin/system/util/StoragePathValidator.java @@ -49,6 +49,17 @@ public class StoragePathValidator { private StoragePathValidator() { } + /** + * 名称最大长度 + * + *

+ * 与 {@code sys_file.name} / {@code sys_file.original_name} 的列长(varchar(255))对齐。单文件上传的 + * {@code MultipartFile#getOriginalFilename()} 与分片上传的 {@code fileName} 均无注解校验,必须在入口统一拦截: + * 超长名称若在落库时才抛 DataTooLong,此时存储实体已写入、启动器的会话清理不会执行,会留下无法重试的孤儿会话。 + *

+ */ + private static final int MAX_NAME_LENGTH = 255; + /** * 校验上级目录路径是否合法 * @@ -69,6 +80,8 @@ public static void validate(String parentPath) { if (StrUtil.isBlank(parentPath) || StringConstants.SLASH.equals(parentPath)) { return; } + // TODO 尚未限制 parentPath 长度及其与名称的组合长度(sys_file.path 为 varchar(512)), + // 超深目录 + 长名称仍可能在落库时触发 DataTooLong String normalized = parentPath.replace(StringConstants.BACKSLASH, StringConstants.SLASH); throwIf(containsControlChar(normalized), "非法的目录路径:不允许包含控制字符"); throwIf(OS_ABSOLUTE_PATH_PATTERN.matcher(normalized).matches(), @@ -85,13 +98,14 @@ public static void validate(String parentPath) { * *

* 名称同样会参与存储路径拼接,因此不允许包含路径分隔符、盘符分隔符、控制字符,也不允许是 - * {@code .}、{@code ..} 等纯点号形式。 + * {@code .}、{@code ..} 等纯点号形式;长度不能超过 {@value #MAX_NAME_LENGTH} 个字符。 *

* * @param name 文件或文件夹名称 */ public static void validateName(String name) { throwIf(StrUtil.isBlank(name), "名称不能为空"); + throwIf(name.length() > MAX_NAME_LENGTH, "名称长度不能超过 " + MAX_NAME_LENGTH + " 个字符"); throwIf(containsControlChar(name), "非法的名称:不允许包含控制字符"); throwIf(StrUtil.containsAny(name, StringConstants.SLASH, StringConstants.BACKSLASH), "非法的名称:不允许包含路径分隔符"); @@ -100,6 +114,44 @@ public static void validateName(String name) { throwIf(isDotSegment(name), "非法的名称:不允许为 . 或 .. 形式"); } + /** + * 规范化上级目录 + * + *

+ * 系统的目录路径统一以 {@code /} 开头(见 {@code FileDO(FileInfo)} 与 {@code createParentDir}), + * 因此这里先补前导斜杠,再把空白值与根目录 {@code /} 归一,最后去掉末尾斜杠: + * {@code user/}、{@code /user/} 都归一为 {@code /user},避免存量路径比较时误判为目录变更, + * 也避免把不合规范的路径写回记录。 + *

+ * + * @param parentPath 上级目录路径 + * @return 规范化后的上级目录路径 + */ + public static String normalizeParentPath(String parentPath) { + String normalized = StrUtil.blankToDefault(parentPath, StringConstants.SLASH); + normalized = StrUtil.prependIfMissing(normalized, StringConstants.SLASH); + while (normalized.length() > 1 && normalized.endsWith(StringConstants.SLASH)) { + normalized = normalized.substring(0, normalized.length() - 1); + } + return normalized; + } + + /** + * 判断两个上级目录是否为同一个目录 + * + *

+ * 比较前会统一按 {@link #normalizeParentPath(String)} 归一,因此 {@code user/} 与 {@code /user}、 + * 空白值与 {@code /} 均视为同一目录。 + *

+ * + * @param left 上级目录路径 + * @param right 上级目录路径 + * @return 是否为同一目录 + */ + public static boolean isSameParentPath(String left, String right) { + return StrUtil.equals(normalizeParentPath(left), normalizeParentPath(right)); + } + /** * 条件成立时抛出 400 异常 * diff --git a/continew-system/src/test/java/top/continew/admin/system/util/StoragePathValidatorTest.java b/continew-system/src/test/java/top/continew/admin/system/util/StoragePathValidatorTest.java new file mode 100644 index 000000000..35d68e492 --- /dev/null +++ b/continew-system/src/test/java/top/continew/admin/system/util/StoragePathValidatorTest.java @@ -0,0 +1,79 @@ +/* + * 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.util; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; +import top.continew.starter.core.exception.BaseException; + +/** + * 存储路径与名称校验测试 + * + * @author luoqiz + * @since 5.0.0 + */ +class StoragePathValidatorTest { + + @Test + void shouldAcceptNameAtMaxLength() { + assertDoesNotThrow(() -> StoragePathValidator.validateName("a".repeat(255))); + } + + @Test + void shouldRejectNameOverMaxLength() { + assertThrows(BaseException.class, () -> StoragePathValidator.validateName("a".repeat(256))); + } + + @Test + void shouldRejectUnsafeNames() { + assertThrows(BaseException.class, () -> StoragePathValidator.validateName("a/b.pdf")); + assertThrows(BaseException.class, () -> StoragePathValidator.validateName("a\\b.pdf")); + assertThrows(BaseException.class, () -> StoragePathValidator.validateName("a:b.pdf")); + assertThrows(BaseException.class, () -> StoragePathValidator.validateName("a\u0000b.pdf")); + assertThrows(BaseException.class, () -> StoragePathValidator.validateName(".")); + assertThrows(BaseException.class, () -> StoragePathValidator.validateName("..")); + } + + @Test + void shouldRejectUnsafeParentPaths() { + assertThrows(BaseException.class, () -> StoragePathValidator.validate("/docs/../secret")); + assertThrows(BaseException.class, () -> StoragePathValidator.validate("docs//sub")); + assertThrows(BaseException.class, () -> StoragePathValidator.validate("C:/windows")); + assertDoesNotThrow(() -> StoragePathValidator.validate("/docs/sub/")); + } + + @Test + void shouldNormalizeParentPath() { + assertEquals("/", StoragePathValidator.normalizeParentPath(null)); + assertEquals("/", StoragePathValidator.normalizeParentPath(" ")); + assertEquals("/", StoragePathValidator.normalizeParentPath("/")); + assertEquals("/docs", StoragePathValidator.normalizeParentPath("docs/")); + assertEquals("/docs", StoragePathValidator.normalizeParentPath("/docs/")); + } + + @Test + void shouldCompareParentPathAfterNormalization() { + assertTrue(StoragePathValidator.isSameParentPath("docs/", "/docs")); + assertTrue(StoragePathValidator.isSameParentPath(null, "/")); + assertFalse(StoragePathValidator.isSameParentPath("/docs", "/archive")); + } +} From 0fbe14a9629b18cde728ef605cc2d0106b75b043 Mon Sep 17 00:00:00 2001 From: luoqiz Date: Thu, 22 Oct 2026 11:30:29 +0800 Subject: [PATCH 3/4] =?UTF-8?q?build:=20=E5=8D=87=E7=BA=A7=20starter=20?= =?UTF-8?q?=E8=87=B3=203.0.0-SNAPSHOT=EF=BC=88Spring=20Boot=204.1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 父 POM 2.17.0 → 3.0.0-SNAPSHOT,同步 Spring Boot 4.1 基线 - ServerProperties 迁移至 org.springframework.boot.web.server.autoconfigure - Checkstyle 14.3 的 NonEmptyAtclauseDescription token 由 PARAM_LITERAL 改为 PARAM_BLOCK_TAG Assisted-by: DeepSeek Harness --- .../admin/extension/scheduling/ScheduleServerApplication.java | 2 +- .../main/java/top/continew/admin/ContiNewAdminApplication.java | 2 +- pom.xml | 2 +- style/ocn-checkstyle.xml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/continew-extension/continew-extension-schedule-server/src/main/java/top/continew/admin/extension/scheduling/ScheduleServerApplication.java b/continew-extension/continew-extension-schedule-server/src/main/java/top/continew/admin/extension/scheduling/ScheduleServerApplication.java index ff1c4f020..2e52d444f 100644 --- a/continew-extension/continew-extension-schedule-server/src/main/java/top/continew/admin/extension/scheduling/ScheduleServerApplication.java +++ b/continew-extension/continew-extension-schedule-server/src/main/java/top/continew/admin/extension/scheduling/ScheduleServerApplication.java @@ -27,7 +27,7 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringBootVersion; import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.boot.autoconfigure.web.ServerProperties; +import org.springframework.boot.web.server.autoconfigure.ServerProperties; /** * 任务调度服务端启动程序 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 d3e4ac76c..1cb84988b 100644 --- a/continew-server/src/main/java/top/continew/admin/ContiNewAdminApplication.java +++ b/continew-server/src/main/java/top/continew/admin/ContiNewAdminApplication.java @@ -31,7 +31,7 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringBootVersion; import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.boot.autoconfigure.web.ServerProperties; +import org.springframework.boot.web.server.autoconfigure.ServerProperties; import org.springframework.cloud.openfeign.EnableFeignClients; import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.annotation.EnableScheduling; diff --git a/pom.xml b/pom.xml index 52c0b1683..8019231a6 100644 --- a/pom.xml +++ b/pom.xml @@ -13,7 +13,7 @@ top.continew.starter continew-starter - 2.17.0 + 3.0.0-SNAPSHOT top.continew.admin diff --git a/style/ocn-checkstyle.xml b/style/ocn-checkstyle.xml index e1a9ec8f9..4e4d01c39 100644 --- a/style/ocn-checkstyle.xml +++ b/style/ocn-checkstyle.xml @@ -112,7 +112,7 @@ - + From 5569a39c7c1400b56e74c85eb422e7b4363e6e22 Mon Sep 17 00:00:00 2001 From: luoqiz Date: Thu, 22 Oct 2026 11:30:40 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(system):=20=E5=88=86=E7=89=87=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0=E6=94=B9=E7=94=A8=20starter=20=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E5=8E=9F=E5=A7=8B=E5=90=8D=E5=B9=B6=E7=A7=BB=E9=99=A4=20Redis?= =?UTF-8?q?=20=E6=9A=82=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MultipartUploadCreateResp 增加 originalFileName,save/get 双向拷贝,保证 Redis 会话往返不丢 - init 向 starter 传入 originalFileName,复用判定与清理键都按原始名 + 上级目录计算 - 完成分片由 starter 从会话回填原始名,删除 MultipartOriginalNameStore、常量、记录器手工回填与相关测试 - 保留原始名为空时沿用已有展示名的兜底 - 补充会话 JSON 往返与展示名兜底测试 Assisted-by: DeepSeek Harness --- .../system/config/file/FileRecorderImpl.java | 71 +++++---- .../constant/MultipartUploadConstants.java | 10 -- .../resp/file/MultipartUploadCreateResp.java | 6 + .../impl/MultipartUploadServiceImpl.java | 15 +- .../util/MultipartOriginalNameStore.java | 141 ------------------ .../config/file/FileRecorderImplTest.java | 76 ++++------ .../util/MultipartOriginalNameStoreTest.java | 75 ---------- 7 files changed, 83 insertions(+), 311 deletions(-) delete mode 100644 continew-system/src/main/java/top/continew/admin/system/util/MultipartOriginalNameStore.java delete mode 100644 continew-system/src/test/java/top/continew/admin/system/util/MultipartOriginalNameStoreTest.java diff --git a/continew-system/src/main/java/top/continew/admin/system/config/file/FileRecorderImpl.java b/continew-system/src/main/java/top/continew/admin/system/config/file/FileRecorderImpl.java index d03fe8ad4..9558198b5 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 @@ -31,7 +31,6 @@ import top.continew.admin.system.model.entity.FileDO; import top.continew.admin.system.model.entity.StorageDO; import top.continew.admin.system.model.resp.file.MultipartUploadCreateResp; -import top.continew.admin.system.util.MultipartOriginalNameStore; import top.continew.starter.cache.redisson.util.RedisUtils; import top.continew.starter.core.constant.StringConstants; import top.continew.starter.core.util.CollUtils; @@ -128,12 +127,6 @@ public boolean update(FileInfo fileInfo) { } FileDO file = new FileDO(fileInfo); file.setStorageId(storage.getId()); - // 分片上传的落盘文件名是唯一 ID,展示名需取回原始文件名,避免退化为存储名 - String originalFileName = MultipartOriginalNameStore.resolveByMetadata(fileInfo - .getMetadata()); - if (StrUtil.isNotBlank(originalFileName)) { - file.setOriginalName(originalFileName); - } FileDO existFile = fileMapper.lambdaQuery() .eq(FileDO::getStorageId, storage.getId()) .eq(FileDO::getPath, file.getPath()) @@ -143,8 +136,8 @@ public boolean update(FileInfo fileInfo) { fileInfo.setFileId(String.valueOf(file.getId())); return true; } else { - // 暂存缺失时沿用已有展示名,避免用存储名覆盖原记录 - if (StrUtil.isBlank(originalFileName) && StrUtil.isNotBlank(existFile + // 完成分片时原始文件名已由 starter 从会话回填;为空时沿用已有展示名,避免用存储名覆盖 + if (StrUtil.isBlank(fileInfo.getOriginalFileName()) && StrUtil.isNotBlank(existFile .getOriginalName())) { file.setOriginalName(existFile.getOriginalName()); } @@ -237,22 +230,7 @@ public void deleteMd5Mapping(String md5) { @Override public void saveMultipartSession(String uploadId, MultipartInitResp initResp, Map metadata) { - MultipartUploadCreateResp target = new MultipartUploadCreateResp(); - target.setFileId(initResp.getFileId()); - target.setUploadId(initResp.getUploadId()); - target.setBucket(initResp.getBucket()); - target.setPlatform(initResp.getPlatform()); - // TODO 升级 starter 后需为 MultipartUploadCreateResp 补 originalFileName,并在 save/get 双向拷贝, - // 否则会话中的原始文件名会在 Redis 往返时丢失 - 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()); + MultipartUploadCreateResp target = toCreateResp(initResp); String key = MultipartUploadConstants.MULTIPART_UPLOAD_PREFIX + uploadId; String metadataKey = MultipartUploadConstants.MULTIPART_METADATA_PREFIX + uploadId; try { @@ -289,12 +267,48 @@ public MultipartInitResp getMultipartSession(String uploadId) { if (source == null) { return null; } + return toInitResp(source); + } + + /** + * 会话转换为 Redis 存储对象 + * + * @param source 分片上传会话 + * @return Redis 存储对象 + */ + static MultipartUploadCreateResp toCreateResp(MultipartInitResp source) { + MultipartUploadCreateResp target = new MultipartUploadCreateResp(); + target.setFileId(source.getFileId()); + target.setUploadId(source.getUploadId()); + target.setBucket(source.getBucket()); + target.setPlatform(source.getPlatform()); + target.setFileName(source.getFileName()); + target.setOriginalFileName(source.getOriginalFileName()); + target.setFileMd5(source.getFileMd5()); + target.setFileSize(ObjectUtil.defaultIfNull(source.getFileSize(), 0L)); + 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; + } + + /** + * Redis 存储对象转换为会话 + * + * @param source Redis 存储对象 + * @return 分片上传会话 + */ + static MultipartInitResp toInitResp(MultipartUploadCreateResp source) { 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.setOriginalFileName(source.getOriginalFileName()); target.setFileMd5(source.getFileMd5()); target.setFileSize(source.getFileSize()); target.setExtension(source.getExtension()); @@ -312,15 +326,10 @@ public void deleteMultipartSession(String uploadId) { 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); - MultipartOriginalNameStore.delete(uploadId); + // 会话键映射由 starter 在 cleanup 中调用 deleteSessionKeyMapping 条件删除 log.debug("删除分片上传信息: uploadId={}", uploadId); } catch (Exception e) { log.error("删除分片上传信息失败: uploadId={}", uploadId, e); 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 d4a8d4248..f0f012489 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 @@ -75,16 +75,6 @@ public class MultipartUploadConstants { */ public static final String MULTIPART_EXPIRE_PREFIX = "multipart:expire:"; - /** - * 原始文件名前缀 - *

- * 用于暂存分片上传的原始文件名。分片上传的落盘文件名为唯一 ID,完成上传时需据此回填文件记录的展示名。 - * 键格式:multipart:original-name:{uploadId} - * 值格式:String,即用户上传时的原始文件名 - *

- */ - public static final String MULTIPART_ORIGINAL_NAME_PREFIX = "multipart:original-name:"; - /** * 默认过期时间(小时) *

diff --git a/continew-system/src/main/java/top/continew/admin/system/model/resp/file/MultipartUploadCreateResp.java b/continew-system/src/main/java/top/continew/admin/system/model/resp/file/MultipartUploadCreateResp.java index 9c017cf46..1d9be627c 100644 --- a/continew-system/src/main/java/top/continew/admin/system/model/resp/file/MultipartUploadCreateResp.java +++ b/continew-system/src/main/java/top/continew/admin/system/model/resp/file/MultipartUploadCreateResp.java @@ -62,6 +62,12 @@ public class MultipartUploadCreateResp implements Serializable { @Schema(description = "存储文件名(唯一 ID + 原扩展名)", example = "3f2a1c9e5b7d4a6f8c0e1d2b3a4c5d6e.zip") private String fileName; + /** + * 原始文件名 + */ + @Schema(description = "原始文件名", example = "年度报告.zip") + private String originalFileName; + /** * 文件MD5 */ 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 2e41c7ce9..efe7981b7 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 @@ -32,7 +32,6 @@ import top.continew.admin.system.service.MultipartUploadService; import top.continew.admin.system.service.StorageService; import top.continew.admin.system.util.FileNameGenerator; -import top.continew.admin.system.util.MultipartOriginalNameStore; import top.continew.admin.system.util.StoragePathValidator; import top.continew.starter.core.constant.StringConstants; import top.continew.starter.core.exception.BaseException; @@ -78,21 +77,19 @@ public MultipartUploadCreateResp initMultipartUpload( fileService.createParentDir(StrUtil.blankToDefault(parentPath, StringConstants.SLASH), storageDO); - // 落盘文件名使用唯一 ID(扩展名不变),原始文件名单独暂存,待完成上传时回填到文件记录 - // TODO 升级到支持 originalFileName 的 starter 版本后,改为 storageReq.setOriginalFileName(originalFileName), - // 并移除 MultipartOriginalNameStore 的暂存与 FileRecorderImpl 的回填 + // 落盘文件名使用唯一 ID(扩展名不变),原始文件名交给 starter 会话携带,完成上传时自动回填 String storedFileName = FileNameGenerator.generateStoredName(originalFileName); MultipartUploadInitReq storageReq = new MultipartUploadInitReq(); storageReq.setPlatform(storageDO.getCode()); storageReq.setBucket(storageDO.getBucketName()); storageReq.setFileName(storedFileName); + storageReq.setOriginalFileName(originalFileName); 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); - MultipartOriginalNameStore.save(initResp.getUploadId(), originalFileName); return this.toAdminInitResp(initResp); } @@ -129,8 +126,6 @@ public FileDO completeMultipartUpload(String uploadId) { if (session == null) { throw new BaseException("无效的 uploadId: " + uploadId); } - // 分片会话在启动器完成上传后即被清理,原始文件名必须在此之前取出 - String originalFileName = MultipartOriginalNameStore.resolve(uploadId); FileInfo fileInfo = fileStorageService.completeMultipartUpload(uploadId, null); StorageDO storageDO = storageService.getByCode(session.getPlatform()); FileDO file = fileMapper.lambdaQuery() @@ -140,12 +135,9 @@ public FileDO completeMultipartUpload(String uploadId) { if (file != null) { return file; } - // 兼容兜底:记录器未完成落库时补录 + // 兼容兜底:记录器未完成落库时补录,原始文件名已由 starter 从会话回填到 fileInfo FileDO fallback = new FileDO(fileInfo); fallback.setStorageId(storageDO.getId()); - if (StrUtil.isNotBlank(originalFileName)) { - fallback.setOriginalName(originalFileName); - } fallback.setType(FileTypeEnum.getByExtension(FileUtil.extName(fallback.getName()))); fileService.save(fallback); return fallback; @@ -163,6 +155,7 @@ private MultipartUploadCreateResp toAdminInitResp(MultipartInitResp source) { target.setBucket(source.getBucket()); target.setPlatform(source.getPlatform()); target.setFileName(source.getFileName()); + target.setOriginalFileName(source.getOriginalFileName()); target.setFileMd5(source.getFileMd5()); target.setFileSize(source.getFileSize()); target.setExtension(source.getExtension()); diff --git a/continew-system/src/main/java/top/continew/admin/system/util/MultipartOriginalNameStore.java b/continew-system/src/main/java/top/continew/admin/system/util/MultipartOriginalNameStore.java deleted file mode 100644 index e133333cb..000000000 --- a/continew-system/src/main/java/top/continew/admin/system/util/MultipartOriginalNameStore.java +++ /dev/null @@ -1,141 +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.util; - -import cn.hutool.core.util.StrUtil; -import lombok.extern.slf4j.Slf4j; -import top.continew.admin.system.constant.MultipartUploadConstants; -import top.continew.starter.cache.redisson.util.RedisUtils; - -import java.time.Duration; -import java.util.Map; - -/** - * 分片上传原始文件名暂存工具 - * - *

- * 文件落盘名统一使用唯一 ID,而分片上传完成时启动器会用会话中的文件名覆盖展示名(见 - * {@code FileStorageService#completeMultipartUpload}),因此需要在初始化阶段把用户上传时的原始文件名单独暂存,待完成上传时回填到文件记录。 - *

- * - *

- * 采用独立缓存键而非对象元数据:既不污染存储引擎的对象元数据,也不用担心非 ASCII 文件名进入 HTTP 头。缓存不可用时整体降级为「展示名退化为存储名」,不影响上传本身。 - *

- * - * @author luoqiz - * @since 5.0.0 - */ -@Slf4j -public class MultipartOriginalNameStore { - - // TODO 待 continew-starter 支持分片会话携带原始文件名后,删除本类,改为向 MultipartUploadInitReq 传入 originalFileName - - /** - * 文件元数据中的分片上传 ID 键名 - * - *

- * 由 {@code FileStorageService#completeMultipartUpload} 在调用记录器前写入。 - *

- */ - private static final String METADATA_UPLOAD_ID = "uploadId"; - - private MultipartOriginalNameStore() { - } - - /** - * 暂存原始文件名 - * - *

- * 缓存键已存在时保留首次写入的值:同一 MD5 的分片上传会话会被复用,此时上传 ID 相同,以首次初始化的文件名为准。 - *

- * - *

- * 写入失败不向上抛出:调用方此时已创建好存储会话,抛出会让用户看到失败提示,但会话与分片数据已经产生,反而制造孤儿数据。 - *

- * - * @param uploadId 分片上传 ID - * @param originalName 原始文件名 - */ - public static void save(String uploadId, String originalName) { - if (StrUtil.isBlank(uploadId) || StrUtil.isBlank(originalName)) { - return; - } - try { - // TODO 会话 TTL 目前是固定常量,若后续改为可配置,需与此处保持同步 - RedisUtils.setIfAbsent(key(uploadId), originalName, Duration - .ofHours(MultipartUploadConstants.DEFAULT_EXPIRE_HOURS)); - } catch (Exception e) { - log.warn("暂存原始文件名失败,文件展示名将退化为存储名:uploadId={}", uploadId, e); - } - } - - /** - * 查询暂存的原始文件名 - * - * @param uploadId 分片上传 ID - * @return 原始文件名(未暂存或查询失败时返回 {@code null}) - */ - public static String resolve(String uploadId) { - if (StrUtil.isBlank(uploadId)) { - return null; - } - try { - String originalName = RedisUtils.get(key(uploadId)); - if (StrUtil.isBlank(originalName)) { - log.debug("未查询到暂存的原始文件名:uploadId={}", uploadId); - return null; - } - return originalName; - } catch (Exception e) { - log.warn("查询原始文件名失败,文件展示名将退化为存储名:uploadId={}", uploadId, e); - return null; - } - } - - /** - * 基于文件元数据查询暂存的原始文件名 - * - * @param metadata 文件元数据 - * @return 原始文件名(元数据中无分片上传 ID 或未暂存时返回 {@code null}) - */ - public static String resolveByMetadata(Map metadata) { - if (metadata == null) { - return null; - } - return resolve(metadata.get(METADATA_UPLOAD_ID)); - } - - /** - * 删除暂存的原始文件名 - * - * @param uploadId 分片上传 ID - */ - public static void delete(String uploadId) { - if (StrUtil.isBlank(uploadId)) { - return; - } - try { - RedisUtils.delete(key(uploadId)); - } catch (Exception e) { - log.warn("删除暂存的原始文件名失败:uploadId={}", uploadId, e); - } - } - - private static String key(String uploadId) { - return MultipartUploadConstants.MULTIPART_ORIGINAL_NAME_PREFIX + uploadId; - } -} diff --git a/continew-system/src/test/java/top/continew/admin/system/config/file/FileRecorderImplTest.java b/continew-system/src/test/java/top/continew/admin/system/config/file/FileRecorderImplTest.java index a25826a09..c1da73bcc 100644 --- a/continew-system/src/test/java/top/continew/admin/system/config/file/FileRecorderImplTest.java +++ b/continew-system/src/test/java/top/continew/admin/system/config/file/FileRecorderImplTest.java @@ -20,35 +20,30 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.when; +import cn.hutool.json.JSONUtil; import com.baomidou.mybatisplus.extension.conditions.query.LambdaQueryChainWrapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; -import org.mockito.MockedStatic; 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.admin.system.util.MultipartOriginalNameStore; +import top.continew.admin.system.model.resp.file.MultipartUploadCreateResp; import top.continew.starter.storage.domain.model.resp.FileInfo; +import top.continew.starter.storage.domain.model.resp.MultipartInitResp; import java.util.HashMap; import java.util.Map; /** - * 文件记录回填展示名测试 + * 文件记录展示名测试 * *

- * 分片上传的落盘文件名是唯一 ID,启动器在完成上传时会把会话中的文件名(即唯一 ID)写入展示名,因此记录器必须用暂存的原始文件名覆盖它。 - * 这里固定两条契约:回填同时覆盖「新增」与「更新」两条落库分支;暂存缺失时保留原值,绝不写入空值。 - *

- * - *

- * {@link MultipartOriginalNameStore} 的静态初始化只有日志器,不依赖 Spring 容器,因此可以直接静态 mock;这也是此处能写单元测试而 - * {@code RedisUtils} 不能的原因。 + * 分片上传的落盘文件名是唯一 ID,原始文件名由 starter 会话携带并在完成时回填到 {@link FileInfo}。这里固定三条契约: + * 新增与更新都使用回填后的原始名;原始名为空时新增保留存储名、更新沿用已有展示名;会话 JSON 往返不丢原始名。 *

* * @author luoqiz @@ -76,78 +71,73 @@ void setUp() { } @Test - void shouldBackfillOriginalNameWhenInserting() { + void shouldUseOriginalNameWhenInserting() { this.stubFileQuery(null); ArgumentCaptor captor = ArgumentCaptor.forClass(FileDO.class); when(this.fileMapper.insert(captor.capture())).thenReturn(1); - try (MockedStatic store = - mockStatic(MultipartOriginalNameStore.class)) { - store.when(() -> MultipartOriginalNameStore.resolveByMetadata(any())) - .thenReturn(ORIGINAL_NAME); - assertTrue(this.fileRecorder.update(this.buildFileInfo())); - } + assertTrue(this.fileRecorder.update(this.buildFileInfo(ORIGINAL_NAME))); assertEquals(ORIGINAL_NAME, captor.getValue().getOriginalName()); assertEquals(STORED_NAME, captor.getValue().getName()); } @Test - void shouldBackfillOriginalNameWhenUpdatingExistingRecord() { + void shouldUseOriginalNameWhenUpdatingExistingRecord() { FileDO existFile = new FileDO(); existFile.setId(9L); this.stubFileQuery(existFile); ArgumentCaptor captor = ArgumentCaptor.forClass(FileDO.class); when(this.fileMapper.updateById(captor.capture())).thenReturn(1); - try (MockedStatic store = - mockStatic(MultipartOriginalNameStore.class)) { - store.when(() -> MultipartOriginalNameStore.resolveByMetadata(any())) - .thenReturn(ORIGINAL_NAME); - assertTrue(this.fileRecorder.update(this.buildFileInfo())); - } + assertTrue(this.fileRecorder.update(this.buildFileInfo(ORIGINAL_NAME))); assertEquals(9L, captor.getValue().getId()); assertEquals(ORIGINAL_NAME, captor.getValue().getOriginalName()); } @Test - void shouldKeepStoredNameWhenOriginalNameMissing() { + void shouldKeepStoredNameWhenOriginalNameMissingOnInsert() { this.stubFileQuery(null); ArgumentCaptor captor = ArgumentCaptor.forClass(FileDO.class); when(this.fileMapper.insert(captor.capture())).thenReturn(1); - FileInfo fileInfo = this.buildFileInfo(); - fileInfo.getMetadata().remove("uploadId"); - try (MockedStatic store = - mockStatic(MultipartOriginalNameStore.class)) { - store.when(() -> MultipartOriginalNameStore.resolveByMetadata(any())).thenReturn(null); - assertTrue(this.fileRecorder.update(fileInfo)); - } + assertTrue(this.fileRecorder.update(this.buildFileInfo(null))); assertEquals(STORED_NAME, captor.getValue().getOriginalName()); } @Test - void shouldKeepExistingOriginalNameWhenOriginalNameMissing() { + void shouldKeepExistingOriginalNameWhenOriginalNameMissingOnUpdate() { FileDO existFile = new FileDO(); existFile.setId(9L); existFile.setOriginalName(ORIGINAL_NAME); this.stubFileQuery(existFile); ArgumentCaptor captor = ArgumentCaptor.forClass(FileDO.class); when(this.fileMapper.updateById(captor.capture())).thenReturn(1); - FileInfo fileInfo = this.buildFileInfo(); - fileInfo.getMetadata().remove("uploadId"); - try (MockedStatic store = - mockStatic(MultipartOriginalNameStore.class)) { - store.when(() -> MultipartOriginalNameStore.resolveByMetadata(any())).thenReturn(null); - assertTrue(this.fileRecorder.update(fileInfo)); - } + assertTrue(this.fileRecorder.update(this.buildFileInfo(null))); assertEquals(ORIGINAL_NAME, captor.getValue().getOriginalName()); } + @Test + void shouldPreserveOriginalNameAcrossSessionJsonRoundTrip() { + MultipartInitResp session = new MultipartInitResp(); + session.setUploadId("upload-1"); + session.setFileName(STORED_NAME); + session.setOriginalFileName(ORIGINAL_NAME); + session.setFileSize(1024L); + + MultipartUploadCreateResp stored = FileRecorderImpl.toCreateResp(session); + MultipartUploadCreateResp restored = JSONUtil.toBean(JSONUtil.toJsonStr(stored), + MultipartUploadCreateResp.class); + MultipartInitResp actual = FileRecorderImpl.toInitResp(restored); + + assertEquals(ORIGINAL_NAME, actual.getOriginalFileName()); + assertEquals(STORED_NAME, actual.getFileName()); + } + @SuppressWarnings("unchecked") private void stubStorageQuery(StorageMapper storageMapper, StorageDO storage) { LambdaQueryChainWrapper query = mock(LambdaQueryChainWrapper.class); @@ -164,13 +154,13 @@ private void stubFileQuery(FileDO existFile) { when(query.one()).thenReturn(existFile); } - private FileInfo buildFileInfo() { + private FileInfo buildFileInfo(String originalFileName) { FileInfo fileInfo = new FileInfo(); fileInfo.setPlatform("local"); fileInfo.setBucket("test"); fileInfo.setName(STORED_NAME); fileInfo.setPath("2026/10/21/" + STORED_NAME); - fileInfo.setOriginalFileName(STORED_NAME); + fileInfo.setOriginalFileName(originalFileName); fileInfo.setSize(1024L); Map metadata = new HashMap<>(); metadata.put("uploadId", "upload-1"); diff --git a/continew-system/src/test/java/top/continew/admin/system/util/MultipartOriginalNameStoreTest.java b/continew-system/src/test/java/top/continew/admin/system/util/MultipartOriginalNameStoreTest.java deleted file mode 100644 index 08e3082bd..000000000 --- a/continew-system/src/test/java/top/continew/admin/system/util/MultipartOriginalNameStoreTest.java +++ /dev/null @@ -1,75 +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.util; - -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -import static org.junit.jupiter.api.Assertions.assertNull; - -import org.junit.jupiter.api.Test; - -import java.util.HashMap; -import java.util.Map; - -/** - * 分片上传原始文件名暂存测试 - * - *

- * 仅覆盖不访问 Redis 的兜底分支:{@code RedisUtils} 的静态初始化依赖 Spring 容器,纯单元测试环境下无法加载,Redis 读写路径需由集成测试覆盖。 - *

- * - *

- * 这些分支并非边角料:正常上传时 {@code FileRecorderImpl#update} 会无条件调用 - * {@link MultipartOriginalNameStore#resolveByMetadata},一旦此处抛异常,所有上传都会失败,因此「元数据缺失时安静地返回 - * null」是需要被固定下来的契约。 - *

- * - * @author luoqiz - * @since 5.0.0 - */ -class MultipartOriginalNameStoreTest { - - @Test - void shouldReturnNullWhenMetadataAbsent() { - assertNull(MultipartOriginalNameStore.resolveByMetadata(null)); - assertNull(MultipartOriginalNameStore.resolveByMetadata(new HashMap<>())); - } - - @Test - void shouldReturnNullWhenMetadataHasNoUploadId() { - Map metadata = new HashMap<>(); - metadata.put("sha256", "e3b0c44298fc1c149afbf4c8996fb924"); - metadata.put("uploadId", " "); - - assertNull(MultipartOriginalNameStore.resolveByMetadata(metadata)); - } - - @Test - void shouldIgnoreBlankUploadId() { - assertNull(MultipartOriginalNameStore.resolve(null)); - assertNull(MultipartOriginalNameStore.resolve(" ")); - assertDoesNotThrow(() -> MultipartOriginalNameStore.save(null, "年度报告.PDF")); - assertDoesNotThrow(() -> MultipartOriginalNameStore.save(" ", "年度报告.PDF")); - assertDoesNotThrow(() -> MultipartOriginalNameStore.delete(null)); - assertDoesNotThrow(() -> MultipartOriginalNameStore.delete(" ")); - } - - @Test - void shouldIgnoreBlankOriginalName() { - assertDoesNotThrow(() -> MultipartOriginalNameStore.save("upload-1", null)); - assertDoesNotThrow(() -> MultipartOriginalNameStore.save("upload-1", " ")); - } -}