Skip to content

Commit dc6c96a

Browse files
authored
feat(system/file): 添加文件名冲突检测和自动重命名功能 (#206)
1 parent b2aa1b8 commit dc6c96a

3 files changed

Lines changed: 268 additions & 9 deletions

File tree

continew-system/src/main/java/top/continew/admin/system/service/impl/FileServiceImpl.java

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
import top.continew.admin.system.model.resp.file.FileStatisticsResp;
4646
import top.continew.admin.system.service.FileService;
4747
import top.continew.admin.system.service.StorageService;
48+
import top.continew.admin.system.util.FileNameGenerator;
4849
import top.continew.starter.cache.redisson.util.RedisLockUtils;
4950
import top.continew.starter.core.constant.StringConstants;
5051
import top.continew.starter.core.util.CollUtils;
@@ -228,11 +229,21 @@ private FileInfo upload(Object file, String parentPath, String storageCode, Stri
228229
// 构建上传预处理对象
229230
StorageDO storage = storageService.getByCode(storageCode);
230231
CheckUtils.throwIf(DisEnableStatusEnum.DISABLE.equals(storage.getStatus()), "请先启用存储 [{}]", storage.getCode());
232+
233+
// 创建父级目录
234+
this.createParentDir(parentPath, storage);
235+
236+
// 生成唯一文件名(处理重名情况)
237+
String originalFileName = getOriginalFileName(file);
238+
String uniqueFileName = FileNameGenerator.generateUniqueName(originalFileName, parentPath, storage.getId(), baseMapper);
239+
231240
UploadPretreatment uploadPretreatment = fileStorageService.of(file)
232241
.setPlatform(storage.getCode())
233242
.setHashCalculatorSha256(true)
234243
.putAttr(ClassUtil.getClassName(StorageDO.class, false), storage)
235-
.setPath(this.pretreatmentPath(parentPath));
244+
.setPath(this.pretreatmentPath(parentPath))
245+
.setSaveFilename(uniqueFileName)
246+
.setOriginalFilename(uniqueFileName);
236247
// 图片文件生成缩略图
237248
if (FileTypeEnum.IMAGE.getExtensions().contains(extName)) {
238249
uploadPretreatment.setIgnoreThumbnailException(true, true);
@@ -241,25 +252,38 @@ private FileInfo upload(Object file, String parentPath, String storageCode, Stri
241252
uploadPretreatment.setProgressMonitor(new ProgressListener() {
242253
@Override
243254
public void start() {
244-
log.info("开始上传");
255+
log.info("开始上传文件: {}", uniqueFileName);
245256
}
246257

247258
@Override
248259
public void progress(long progressSize, Long allSize) {
249-
log.info("已上传 [{}],总大小 [{}]", progressSize, allSize);
260+
log.info("文件 [{}] 已上传 [{}],总大小 [{}]", uniqueFileName, progressSize, allSize);
250261
}
251262

252263
@Override
253264
public void finish() {
254-
log.info("上传结束");
265+
log.info("文件 [{}] 上传完成", uniqueFileName);
255266
}
256267
});
257-
// 创建父级目录
258-
this.createParentDir(parentPath, storage);
259268
// 上传
260269
return uploadPretreatment.upload();
261270
}
262271

272+
/**
273+
* 获取原始文件名
274+
*
275+
* @param file 文件对象(MultipartFile 或 File)
276+
* @return 原始文件名
277+
*/
278+
private String getOriginalFileName(Object file) {
279+
if (file instanceof MultipartFile multipartFile) {
280+
return multipartFile.getOriginalFilename();
281+
} else if (file instanceof File ioFile) {
282+
return ioFile.getName();
283+
}
284+
return "unknown";
285+
}
286+
263287
/**
264288
* 处理路径
265289
*

continew-system/src/main/java/top/continew/admin/system/service/impl/MultipartUploadServiceImpl.java

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,11 @@
3333
import top.continew.admin.system.model.resp.file.FilePartInfo;
3434
import top.continew.admin.system.model.resp.file.MultipartUploadInitResp;
3535
import top.continew.admin.system.model.resp.file.MultipartUploadResp;
36+
import top.continew.admin.system.mapper.FileMapper;
3637
import top.continew.admin.system.service.FileService;
3738
import top.continew.admin.system.service.MultipartUploadService;
3839
import top.continew.admin.system.service.StorageService;
40+
import top.continew.admin.system.util.FileNameGenerator;
3941
import top.continew.starter.core.exception.BaseException;
4042

4143
import java.time.LocalDateTime;
@@ -62,6 +64,8 @@ public class MultipartUploadServiceImpl implements MultipartUploadService {
6264

6365
private final FileService fileService;
6466

67+
private final FileMapper fileMapper;
68+
6569
@Override
6670
public MultipartUploadInitResp initMultipartUpload(MultipartUploadInitReq multiPartUploadInitReq) {
6771
// 后续可以增加storageCode参数 指定某个存储平台 当前设计是默认存储平台
@@ -85,6 +89,24 @@ public MultipartUploadInitResp initMultipartUpload(MultipartUploadInitReq multiP
8589
//todo else 待定 更换存储平台 或分片大小有变更 是否需要删除原先分片
8690

8791
}
92+
93+
// 检测文件名是否已存在(同一目录下文件名不能重复)
94+
String originalFileName = multiPartUploadInitReq.getFileName();
95+
String parentPath = multiPartUploadInitReq.getParentPath();
96+
boolean exists = fileMapper.lambdaQuery()
97+
.eq(FileDO::getParentPath, parentPath)
98+
.eq(FileDO::getStorageId, storageDO.getId())
99+
.eq(FileDO::getName, originalFileName)
100+
.ne(FileDO::getType, FileTypeEnum.DIR)
101+
.exists();
102+
if (exists) {
103+
throw new BaseException("文件名已存在:" + originalFileName);
104+
}
105+
106+
// 生成唯一文件名(处理重名情况)
107+
String uniqueFileName = FileNameGenerator.generateUniqueName(originalFileName, parentPath, storageDO.getId(), fileMapper);
108+
multiPartUploadInitReq.setFileName(uniqueFileName);
109+
88110
StorageHandler storageHandler = storageHandlerFactory.createHandler(storageDO.getType());
89111
//文件元信息
90112
Map<String, String> metaData = multiPartUploadInitReq.getMetaData();
@@ -147,16 +169,18 @@ public FileDO completeMultipartUpload(String uploadId) {
147169

148170
// 完成上传
149171
storageHandler.completeMultipartUpload(storageDO, parts, initResp.getPath(), uploadId, needVerify);
172+
// 文件名已在初始化阶段处理为唯一文件名
173+
String uniqueFileName = initResp.getFileName().replaceFirst("^[/\\\\]+", "");
150174
FileDO file = new FileDO();
151-
file.setName(initResp.getFileName().replaceFirst("^[/\\\\]+", ""));
152-
file.setOriginalName(initResp.getFileName().replaceFirst("^[/\\\\]+", ""));
175+
file.setName(uniqueFileName);
176+
file.setOriginalName(uniqueFileName);
153177
file.setPath(initResp.getPath());
154178
file.setParentPath(initResp.getParentPath());
155179
file.setSize(initResp.getFileSize());
156180
file.setSha256(initResp.getFileMd5());
157181
file.setExtension(initResp.getExtension());
158182
file.setContentType(initResp.getContentType());
159-
file.setType(FileTypeEnum.getByExtension(FileUtil.extName(initResp.getFileName())));
183+
file.setType(FileTypeEnum.getByExtension(FileUtil.extName(uniqueFileName)));
160184
file.setStorageId(storageDO.getId());
161185
fileService.save(file);
162186
multipartUploadDao.deleteMultipartUpload(uploadId);
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
/*
2+
* Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package top.continew.admin.system.util;
18+
19+
import java.util.List;
20+
21+
import lombok.extern.slf4j.Slf4j;
22+
23+
import cn.hutool.core.util.StrUtil;
24+
25+
import top.continew.admin.system.enums.FileTypeEnum;
26+
import top.continew.admin.system.mapper.FileMapper;
27+
import top.continew.admin.system.model.entity.FileDO;
28+
import top.continew.starter.core.util.CollUtils;
29+
30+
/**
31+
* 文件名生成工具类
32+
*
33+
* <p>
34+
* 提供文件重名检测和自动重命名功能。当文件名冲突时,自动添加序号后缀,如:file.txt → file(1).txt
35+
* </p>
36+
*
37+
* @author fjwupeng
38+
* @since 2026/2/06
39+
*/
40+
@Slf4j
41+
public class FileNameGenerator {
42+
43+
private FileNameGenerator() {
44+
// 工具类禁止实例化
45+
}
46+
47+
/**
48+
* 生成唯一文件名
49+
*
50+
* <p>
51+
* 当目标目录存在同名文件时,自动添加序号后缀:
52+
* <ul>
53+
* <li>file.txt → file(1).txt → file(2).txt → ...</li>
54+
* <li>无扩展名:README → README(1) → README(2) → ...</li>
55+
* <li>隐藏文件:.gitignore → .gitignore(1) → .gitignore(2) → ...</li>
56+
* </ul>
57+
* </p>
58+
*
59+
* @param fileName 原始文件名
60+
* @param parentPath 上级目录路径
61+
* @param storageId 存储ID
62+
* @param fileMapper 文件Mapper
63+
* @return 唯一文件名
64+
*/
65+
public static String generateUniqueName(String fileName, String parentPath, Long storageId, FileMapper fileMapper) {
66+
// 1. 先检查原始文件名是否可用
67+
boolean exists = existsByName(parentPath, storageId, fileName, fileMapper);
68+
if (!exists) {
69+
return fileName;
70+
}
71+
72+
// 2. 解析文件名
73+
String[] parts = parseFileName(fileName);
74+
String baseName = parts[0];
75+
String extension = parts[1];
76+
77+
// 3. 获取该目录下所有可能的冲突文件名(优化:批量查询)
78+
List<String> existingNames = selectNamesByParentPath(parentPath, storageId, baseName, fileMapper);
79+
80+
// 4. 寻找第一个可用的序号
81+
int counter = 1;
82+
while (true) {
83+
String newName = buildFileNameWithCounter(baseName, extension, counter);
84+
if (!existingNames.contains(newName)) {
85+
log.debug("文件名 [{}] 重命名为 [{}]", fileName, newName);
86+
return newName;
87+
}
88+
counter++;
89+
90+
// 安全限制,防止无限循环
91+
if (counter > 9999) {
92+
log.warn("文件名重命名超过最大限制,使用当前时间戳: {}", fileName);
93+
return baseName + "_" + System.currentTimeMillis() + (StrUtil.isNotBlank(extension) ? "." + extension : "");
94+
}
95+
}
96+
}
97+
98+
/**
99+
* 解析文件名为基础名和扩展名
100+
*
101+
* <p>
102+
* 示例:
103+
* </p>
104+
* <ul>
105+
* <li>"document.pdf" → ["document", "pdf"]</li>
106+
* <li>"README" → ["README", ""]</li>
107+
* <li>".gitignore" → [".gitignore", ""]</li>
108+
* <li>"archive.tar.gz" → ["archive.tar", "gz"]</li>
109+
* </ul>
110+
*
111+
* @param fileName 文件名
112+
* @return 数组 [基础名, 扩展名],扩展名可能为空字符串
113+
*/
114+
public static String[] parseFileName(String fileName) {
115+
if (StrUtil.isBlank(fileName)) {
116+
return new String[]{"", ""};
117+
}
118+
119+
// 处理隐藏文件(以.开头)
120+
boolean isHidden = fileName.startsWith(".");
121+
String nameWithoutDot = isHidden ? fileName.substring(1) : fileName;
122+
123+
// 处理空文件名(如只有"."的情况)
124+
if (nameWithoutDot.isEmpty()) {
125+
return new String[]{fileName, ""};
126+
}
127+
128+
// 查找最后一个点号位置
129+
int lastDotIndex = nameWithoutDot.lastIndexOf('.');
130+
131+
// 点号不存在或在开头(如 ".bashrc"),视为无扩展名
132+
if (lastDotIndex <= 0) {
133+
return new String[]{fileName, ""};
134+
}
135+
136+
String baseName = isHidden ? "." + nameWithoutDot.substring(0, lastDotIndex) : nameWithoutDot.substring(0, lastDotIndex);
137+
String extension = nameWithoutDot.substring(lastDotIndex + 1);
138+
139+
// 扩展名不应包含路径分隔符(安全检查)
140+
if (extension.contains("/") || extension.contains("\\")) {
141+
return new String[]{fileName, ""};
142+
}
143+
144+
return new String[]{baseName, extension};
145+
}
146+
147+
/**
148+
* 构建带序号的文件名
149+
*
150+
* @param baseName 基础名
151+
* @param extension 扩展名(可能为空)
152+
* @param counter 序号(必须 >= 1)
153+
* @return 新文件名,如 "file(1).txt"
154+
*/
155+
public static String buildFileNameWithCounter(String baseName, String extension, int counter) {
156+
if (counter < 1) {
157+
throw new IllegalArgumentException("序号必须大于等于1");
158+
}
159+
160+
StringBuilder sb = new StringBuilder(baseName);
161+
sb.append("(").append(counter).append(")");
162+
163+
if (StrUtil.isNotBlank(extension)) {
164+
sb.append(".").append(extension);
165+
}
166+
167+
return sb.toString();
168+
}
169+
170+
/**
171+
* 检查文件是否存在
172+
*
173+
* @param parentPath 上级目录
174+
* @param storageId 存储ID
175+
* @param name 文件名
176+
* @param fileMapper 文件Mapper
177+
* @return true: 存在
178+
*/
179+
private static boolean existsByName(String parentPath, Long storageId, String name, FileMapper fileMapper) {
180+
return fileMapper.lambdaQuery()
181+
.eq(FileDO::getParentPath, parentPath)
182+
.eq(FileDO::getStorageId, storageId)
183+
.eq(FileDO::getName, name)
184+
.ne(FileDO::getType, FileTypeEnum.DIR)
185+
.exists();
186+
}
187+
188+
/**
189+
* 查询指定目录下的文件名称列表(用于重名检测)
190+
*
191+
* @param parentPath 上级目录
192+
* @param storageId 存储ID
193+
* @param namePrefix 名称前缀(可为null,表示查询所有)
194+
* @param fileMapper 文件Mapper
195+
* @return 文件名列表
196+
*/
197+
private static List<String> selectNamesByParentPath(String parentPath, Long storageId, String namePrefix, FileMapper fileMapper) {
198+
var wrapper = fileMapper.lambdaQuery()
199+
.eq(FileDO::getParentPath, parentPath)
200+
.eq(FileDO::getStorageId, storageId)
201+
.ne(FileDO::getType, FileTypeEnum.DIR)
202+
.select(FileDO::getName)
203+
.last("LIMIT 10000"); // 限制最大查询数量,防止内存溢出
204+
205+
if (StrUtil.isNotBlank(namePrefix)) {
206+
wrapper.likeRight(FileDO::getName, namePrefix);
207+
}
208+
209+
return CollUtils.mapToList(wrapper.list(), FileDO::getName);
210+
}
211+
}

0 commit comments

Comments
 (0)