From 958057ad8fef75e4b76d0bea397c4cd7bc2c6ad1 Mon Sep 17 00:00:00 2001 From: yangyang Date: Wed, 15 Apr 2026 15:40:22 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=EF=BC=9A=E6=A0=B9=E6=8D=AE?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E5=A4=B9=E8=B7=AF=E5=BE=84=E6=9F=A5=E8=AF=A2?= =?UTF-8?q?=E6=89=80=E6=9C=89=E6=96=87=E4=BB=B6=E5=A4=B9=E5=92=8C=E6=96=87?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../entity/resp/data/LocalFileNodeVO.java | 29 +++ .../java/com/sdm/common/utils/FilesUtil.java | 215 ++++++++++++++++++ .../data/controller/DataFileController.java | 18 ++ .../sdm/data/model/req/LocalFileListReq.java | 14 ++ 4 files changed, 276 insertions(+) create mode 100644 common/src/main/java/com/sdm/common/entity/resp/data/LocalFileNodeVO.java create mode 100644 data/src/main/java/com/sdm/data/model/req/LocalFileListReq.java diff --git a/common/src/main/java/com/sdm/common/entity/resp/data/LocalFileNodeVO.java b/common/src/main/java/com/sdm/common/entity/resp/data/LocalFileNodeVO.java new file mode 100644 index 00000000..9809fed7 --- /dev/null +++ b/common/src/main/java/com/sdm/common/entity/resp/data/LocalFileNodeVO.java @@ -0,0 +1,29 @@ +package com.sdm.common.entity.resp.data; + +import lombok.Data; + +@Data +public class LocalFileNodeVO { + + /** 名称 */ + private String name; + + /** 完整路径 */ + private String fullPath; + + /** 是否文件夹 1是文件夹 0 文件 */ + private String directory; + + /** 文件大小(字节,文件夹为 0) */ + private long size; + + /** 最后修改时间 */ + private String lastModifiedTime; + + /** 文件的md5 */ + private String fileNameHash; + + /** 文件的类型 */ + private String contentType; + +} diff --git a/common/src/main/java/com/sdm/common/utils/FilesUtil.java b/common/src/main/java/com/sdm/common/utils/FilesUtil.java index bd166762..e12b6ee3 100644 --- a/common/src/main/java/com/sdm/common/utils/FilesUtil.java +++ b/common/src/main/java/com/sdm/common/utils/FilesUtil.java @@ -1,5 +1,6 @@ package com.sdm.common.utils; +import com.sdm.common.entity.resp.data.LocalFileNodeVO; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.tuple.Pair; import org.springframework.http.HttpStatus; @@ -16,9 +17,14 @@ import java.nio.charset.StandardCharsets; import java.nio.file.*; import java.nio.file.attribute.BasicFileAttributes; import java.nio.file.attribute.FileTime; +import java.security.MessageDigest; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; import java.util.*; import java.util.concurrent.atomic.AtomicReference; import java.util.regex.Pattern; +import java.util.stream.Collectors; import java.util.stream.Stream; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; @@ -26,6 +32,19 @@ import java.util.zip.ZipOutputStream; @Slf4j @Component public class FilesUtil { + + /** + * 时间格式化器(抽取为常量,线程安全) + */ + private static final DateTimeFormatter DATE_TIME_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + /** + * 哈希算法常量 + */ + private static final String SHA256 = "SHA-256"; + + /** * 在MappedByteBuffer释放后再对它进行读操作的话就会引发jvm crash,在并发情况下很容易发生 * 正在释放时另一个线程正开始读取,于是crash就发生了。所以为了系统稳定性释放前一般需要检 查是否还有线程在读或写 @@ -868,7 +887,203 @@ public class FilesUtil { + // ==================== 优化核心:文件列表扫描 ==================== + /** + * 安全扫描文件夹下的一级文件/目录 + * 增强:路径安全校验、空目录保护、流自动关闭、完整异常处理 + * @param folderPath 文件夹路径 + * @return 文件节点VO列表 + */ + public static List listFiles(String folderPath) { + // 1. 空值校验 + if (folderPath == null || folderPath.isBlank()) { + log.error("listFiles 参数异常:文件夹路径不能为空"); + throw new IllegalArgumentException("文件夹路径不能为空"); + } + + // 2. 标准化路径(防路径穿越、相对路径混乱) + Path rootPath; + try { + rootPath = Paths.get(folderPath).normalize().toAbsolutePath(); + } catch (InvalidPathException | SecurityException e) { + log.error("解析路径失败:{}", folderPath, e); + throw new IllegalArgumentException("无效的文件夹路径:" + folderPath, e); + } + + // 3. 路径存在检查 + if (!Files.exists(rootPath)) { + log.warn("文件夹不存在:{}", rootPath); + throw new RuntimeException("路径不存在:" + folderPath); + } + + // 4. 是否是目录 + if (!Files.isDirectory(rootPath)) { + log.error("指定路径不是文件夹:{}", rootPath); + throw new RuntimeException("指定路径不是文件夹:" + folderPath); + } + + // 5. 可读权限 + if (!Files.isReadable(rootPath)) { + log.error("无文件夹读取权限:{}", rootPath); + throw new RuntimeException("无文件夹读取权限:" + folderPath); + } + + // 6. 安全遍历目录(必须 try-with-resources 关闭流) + try (Stream pathStream = Files.list(rootPath)) { + return pathStream + .map(FilesUtil::convertToFileNode) // 静态方法必须用 类名:: + .sorted(fileNodeComparator()) + .collect(Collectors.toList()); + } catch (SecurityException se) { + log.error("遍历文件夹被安全策略阻止:{}", rootPath, se); + throw new RuntimeException("文件夹访问被拒绝:" + folderPath, se); + } catch (IOException e) { + log.error("读取目录发生IO异常:{}", rootPath, e); + throw new RuntimeException("读取目录失败:" + folderPath, e); + } + } + + /** + * 路径 -> LocalFileNodeVO 转换(增强健壮性、空保护、异常隔离) + * 单个文件异常不会导致整个列表失败 + */ + private static LocalFileNodeVO convertToFileNode(Path path) { + LocalFileNodeVO vo = new LocalFileNodeVO(); + try { + // 基础名称与路径 + String fileName = path.getFileName() == null ? "未知名称" : path.getFileName().toString(); + vo.setName(fileName); + vo.setFullPath(path.toAbsolutePath().toString()); + + // 目录/文件标记 + boolean isDirectory = Files.isDirectory(path); + vo.setDirectory(isDirectory ? "1" : "0"); + + if (isDirectory) { + vo.setSize(0L); + vo.setContentType("directory"); + } else { + // 文件大小(异常保护) + vo.setSize(Files.size(path)); + // 内容类型 + vo.setContentType(resolveContentTypeSafely(path)); + } + + // 文件名哈希(空值安全) + vo.setFileNameHash(calcFileNameHash(vo.getName())); + + // 最后修改时间(异常保护) + vo.setLastModifiedTime(getLastModifiedTimeSafe(path)); + + } catch (Exception e) { + // 单个文件读取失败,只打警告,不中断整个列表 + log.warn("读取文件信息失败,已跳过:{}", path, e); + // 设置默认值,保证VO结构完整 + vo.setSize(0L); + vo.setContentType("unknown"); + vo.setLastModifiedTime("-"); + } + return vo; + } + + /** + * 安全获取最后修改时间(容错) + */ + private static String getLastModifiedTimeSafe(Path path) { + try { + FileTime fileTime = Files.getLastModifiedTime(path); + LocalDateTime time = LocalDateTime.ofInstant(fileTime.toInstant(), ZoneId.systemDefault()); + return time.format(DATE_TIME_FORMATTER); + } catch (Exception e) { + log.debug("获取文件修改时间失败:{}", path, e); + return "-"; + } + } + + /** + * 文件排序规则(文件夹优先 > 修改时间倒序 > 名称正序) + */ + private static Comparator fileNodeComparator() { + return Comparator + .comparing(LocalFileNodeVO::getDirectory, Comparator.reverseOrder()) + .thenComparing( + LocalFileNodeVO::getLastModifiedTime, + Comparator.nullsLast(Comparator.reverseOrder()) + ) + .thenComparing( + LocalFileNodeVO::getName, + Comparator.nullsFirst(String.CASE_INSENSITIVE_ORDER) + ); + } + + /** + * 安全获取文件ContentType(不抛异常) + */ + private static String resolveContentTypeSafely(Path path) { + try { + String type = Files.probeContentType(path); + if (type != null) { + return type; + } + } catch (Exception ignored) { + // 不打印异常,避免日志泛滥 + } + return fallbackContentType(path.getFileName() == null ? "" : path.getFileName().toString()); + } + + /** + * 根据后缀名兜底ContentType(空值安全) + */ + private static String fallbackContentType(String fileName) { + if (fileName == null || fileName.isBlank()) { + return "application/octet-stream"; + } + String lower = fileName.toLowerCase(); + if (lower.endsWith(".txt")) return "text/plain"; + if (lower.endsWith(".json")) return "application/json"; + if (lower.endsWith(".xml")) return "application/xml"; + if (lower.endsWith(".csv")) return "text/csv"; + if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg"; + if (lower.endsWith(".png")) return "image/png"; + if (lower.endsWith(".gif")) return "image/gif"; + if (lower.endsWith(".pdf")) return "application/pdf"; + if (lower.endsWith(".zip")) return "application/zip"; + if (lower.endsWith(".jar")) return "application/java-archive"; + // 未知类型返回标准二进制流,避免前端解析异常 + return "application/octet-stream"; + } + + /** + * 计算文件名SHA-256哈希(线程安全、空值安全) + */ + private static String calcFileNameHash(String fileName) { + if (fileName == null || fileName.isBlank()) { + return ""; + } + try { + MessageDigest digest = MessageDigest.getInstance(SHA256); + byte[] hashBytes = digest.digest(fileName.getBytes(StandardCharsets.UTF_8)); + return bytesToHex(hashBytes); + } catch (Exception e) { + log.warn("计算文件名Hash失败:{}", fileName, e); + return ""; + } + } + + /** + * 字节数组转16进制(健壮性增强) + */ + private static String bytesToHex(byte[] bytes) { + if (bytes == null || bytes.length == 0) { + return ""; + } + StringBuilder sb = new StringBuilder(bytes.length * 2); + for (byte b : bytes) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } } diff --git a/data/src/main/java/com/sdm/data/controller/DataFileController.java b/data/src/main/java/com/sdm/data/controller/DataFileController.java index e9d8229d..e3236060 100644 --- a/data/src/main/java/com/sdm/data/controller/DataFileController.java +++ b/data/src/main/java/com/sdm/data/controller/DataFileController.java @@ -26,7 +26,9 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.extern.slf4j.Slf4j; import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.validation.annotation.Validated; @@ -47,6 +49,12 @@ public class DataFileController implements IDataFeignClient { @Autowired private IDataFileService IDataFileService; + /** + * 本地可访问的基础路径 + */ + @Value("${local.file.basePath:/home/simulation}") + private String LOCAL_BASE_STORAGE_PATH ; + /** * 创建文件夹 * @@ -783,4 +791,14 @@ public class DataFileController implements IDataFeignClient { return IDataFileService.getFileMinioObjectkeysByDirIds(dirIds); } + @PostMapping("/getLocalFiles") + public SdmResponse> scanDir(@RequestBody LocalFileListReq req) { + String folderPath = req.getFolderPath(); + if(StringUtils.isBlank(folderPath)||!folderPath.startsWith(LOCAL_BASE_STORAGE_PATH)) { + throw new RuntimeException("目录路径非法"); + } + List localFiles = FilesUtil.listFiles(folderPath); + return SdmResponse.success(localFiles); + } + } \ No newline at end of file diff --git a/data/src/main/java/com/sdm/data/model/req/LocalFileListReq.java b/data/src/main/java/com/sdm/data/model/req/LocalFileListReq.java new file mode 100644 index 00000000..4aecc091 --- /dev/null +++ b/data/src/main/java/com/sdm/data/model/req/LocalFileListReq.java @@ -0,0 +1,14 @@ +package com.sdm.data.model.req; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class LocalFileListReq { + /** + * 目录路径 + */ + @NotBlank + private String folderPath; + +}