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/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..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
@@ -136,6 +136,11 @@ public boolean update(FileInfo fileInfo) {
fileInfo.setFileId(String.valueOf(file.getId()));
return true;
} else {
+ // 完成分片时原始文件名已由 starter 从会话回填;为空时沿用已有展示名,避免用存储名覆盖
+ if (StrUtil.isBlank(fileInfo.getOriginalFileName()) && StrUtil.isNotBlank(existFile
+ .getOriginalName())) {
+ file.setOriginalName(existFile.getOriginalName());
+ }
file.setId(existFile.getId());
boolean updated = fileMapper.updateById(file) > 0;
fileInfo.setFileId(String.valueOf(existFile.getId()));
@@ -225,20 +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());
- 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 {
@@ -275,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());
@@ -298,14 +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);
+ // 会话键映射由 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/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..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
@@ -57,11 +57,17 @@ public class MultipartUploadCreateResp implements Serializable {
private String platform;
/**
- * 文件名称
+ * 存储文件名
*/
- @Schema(description = "文件名称")
+ @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/FileServiceImpl.java b/continew-system/src/main/java/top/continew/admin/system/service/impl/FileServiceImpl.java
index a20ff33f9..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
@@ -308,10 +322,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 +335,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 +344,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 +357,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..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
@@ -64,39 +64,26 @@ 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(扩展名不变),原始文件名交给 starter 会话携带,完成上传时自动回填
+ String storedFileName = FileNameGenerator.generateStoredName(originalFileName);
MultipartUploadInitReq storageReq = new MultipartUploadInitReq();
storageReq.setPlatform(storageDO.getCode());
storageReq.setBucket(storageDO.getBucketName());
- storageReq.setFileName(uniqueFileName);
+ storageReq.setFileName(storedFileName);
+ storageReq.setOriginalFileName(originalFileName);
storageReq.setFileSize(multiPartUploadInitReq.getFileSize());
storageReq.setFileMd5(multiPartUploadInitReq.getFileMd5());
storageReq.setContentType(multiPartUploadInitReq.getContentType());
@@ -148,7 +135,7 @@ public FileDO completeMultipartUpload(String uploadId) {
if (file != null) {
return file;
}
- // 兼容兜底:记录器未完成落库时补录
+ // 兼容兜底:记录器未完成落库时补录,原始文件名已由 starter 从会话回填到 fileInfo
FileDO fallback = new FileDO(fileInfo);
fallback.setStorageId(storageDO.getId());
fallback.setType(FileTypeEnum.getByExtension(FileUtil.extName(fallback.getName())));
@@ -168,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/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/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/config/file/FileRecorderImplTest.java b/continew-system/src/test/java/top/continew/admin/system/config/file/FileRecorderImplTest.java
new file mode 100644
index 000000000..c1da73bcc
--- /dev/null
+++ b/continew-system/src/test/java/top/continew/admin/system/config/file/FileRecorderImplTest.java
@@ -0,0 +1,171 @@
+/*
+ * 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.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 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.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,原始文件名由 starter 会话携带并在完成时回填到 {@link FileInfo}。这里固定三条契约:
+ * 新增与更新都使用回填后的原始名;原始名为空时新增保留存储名、更新沿用已有展示名;会话 JSON 往返不丢原始名。
+ *
+ *
+ * @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 shouldUseOriginalNameWhenInserting() {
+ this.stubFileQuery(null);
+ ArgumentCaptor captor = ArgumentCaptor.forClass(FileDO.class);
+ when(this.fileMapper.insert(captor.capture())).thenReturn(1);
+
+ assertTrue(this.fileRecorder.update(this.buildFileInfo(ORIGINAL_NAME)));
+
+ assertEquals(ORIGINAL_NAME, captor.getValue().getOriginalName());
+ assertEquals(STORED_NAME, captor.getValue().getName());
+ }
+
+ @Test
+ 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);
+
+ assertTrue(this.fileRecorder.update(this.buildFileInfo(ORIGINAL_NAME)));
+
+ assertEquals(9L, captor.getValue().getId());
+ assertEquals(ORIGINAL_NAME, captor.getValue().getOriginalName());
+ }
+
+ @Test
+ void shouldKeepStoredNameWhenOriginalNameMissingOnInsert() {
+ this.stubFileQuery(null);
+ ArgumentCaptor captor = ArgumentCaptor.forClass(FileDO.class);
+ when(this.fileMapper.insert(captor.capture())).thenReturn(1);
+
+ assertTrue(this.fileRecorder.update(this.buildFileInfo(null)));
+
+ assertEquals(STORED_NAME, captor.getValue().getOriginalName());
+ }
+
+ @Test
+ 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);
+
+ 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);
+ 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(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(originalFileName);
+ 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/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"));
+ }
+}
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,命中已有文件时直接复用记录,不再传输内容。
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 @@
-
+