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 位随机十六进制串与原扩展名拼接而成,扩展名保留原有大小写:
+ *
*
- * - file.txt → file(1).txt → file(2).txt → ...
- * - 无扩展名:README → README(1) → README(2) → ...
- * - 隐藏文件:.gitignore → .gitignore(1) → .gitignore(2) → ...
+ * - {@code 报告.PNG} → {@code 3f2a...d6e.PNG}
+ * - {@code archive.tar.gz} → {@code 3f2a...d6e.gz}
+ * - {@code README} → {@code 3f2a...d6e}
*
- *
- *
- * @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", " "));
- }
-}