|
|
@@ -0,0 +1,532 @@
|
|
|
+package com.zsjz.ai.common.utils;
|
|
|
+
|
|
|
+import com.zsjz.ai.common.base.ErrorEnum;
|
|
|
+import com.zsjz.ai.common.exception.ServerException;
|
|
|
+import jakarta.annotation.PostConstruct;
|
|
|
+import lombok.extern.slf4j.Slf4j;
|
|
|
+import org.springframework.beans.factory.annotation.Value;
|
|
|
+import org.springframework.stereotype.Component;
|
|
|
+import org.springframework.web.multipart.MultipartFile;
|
|
|
+
|
|
|
+import java.io.ByteArrayInputStream;
|
|
|
+import java.io.InputStream;
|
|
|
+import java.net.URLEncoder;
|
|
|
+import java.nio.charset.StandardCharsets;
|
|
|
+import java.nio.file.Files;
|
|
|
+import java.nio.file.Path;
|
|
|
+import java.nio.file.Paths;
|
|
|
+import java.nio.file.StandardCopyOption;
|
|
|
+import java.time.LocalDateTime;
|
|
|
+import java.time.OffsetDateTime;
|
|
|
+import java.time.ZoneId;
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.List;
|
|
|
+import java.util.UUID;
|
|
|
+import java.util.stream.Stream;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 本地文件系统存储工具类
|
|
|
+ * <p>封装本地磁盘文件操作,供业务层调用(替代原 MinIO 存储)</p>
|
|
|
+ * <p>文件访问 URL:{base-url}/files/{path}(内联预览,支持 Range);下载 URL:{base-url}/file-download/{path}</p>
|
|
|
+ *
|
|
|
+ * @author cc
|
|
|
+ * @since 2026-08-07
|
|
|
+ */
|
|
|
+@Slf4j
|
|
|
+@Component
|
|
|
+public class LocalFileStorageUtil {
|
|
|
+
|
|
|
+ private static final long MAX_BYTES_FOR_IN_MEMORY = 10 * 1024 * 1024L;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 本地存储根目录(绝对路径)
|
|
|
+ */
|
|
|
+ private final Path rootPath;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 文件对外访问的基础地址,如 <a href="http://localhost:8081">...</a>
|
|
|
+ */
|
|
|
+ private final String baseUrl;
|
|
|
+
|
|
|
+ public LocalFileStorageUtil(
|
|
|
+ @Value("${storage.local.root-path:./data/files}") String rootPath,
|
|
|
+ @Value("${storage.local.base-url:http://localhost:8081}") String baseUrl) {
|
|
|
+ this.rootPath = Paths.get(rootPath).toAbsolutePath().normalize();
|
|
|
+ this.baseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 启动时确保存储根目录存在
|
|
|
+ */
|
|
|
+ @PostConstruct
|
|
|
+ public void init() {
|
|
|
+ try {
|
|
|
+ Files.createDirectories(rootPath);
|
|
|
+ log.info("[LocalStorage] 本地文件存储初始化完成, root={}", rootPath);
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("[LocalStorage] 初始化存储目录失败, root={}", rootPath, e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 上传文件
|
|
|
+ *
|
|
|
+ * @param file 文件
|
|
|
+ * @param filePath 存储路径,如 "knowledge/1/doc/xxx.md"
|
|
|
+ * @return 文件路径
|
|
|
+ */
|
|
|
+ public String upload(MultipartFile file, String filePath) {
|
|
|
+ Path target = resolve(filePath);
|
|
|
+ // 幂等写入:文件已存在时不执行任何操作,直接返回
|
|
|
+ if (Files.exists(target)) {
|
|
|
+ log.debug("[LocalStorage] 文件已存在,跳过写入, path={}", filePath);
|
|
|
+ return filePath;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ Files.createDirectories(target.getParent());
|
|
|
+ try (InputStream in = file.getInputStream()) {
|
|
|
+ Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING);
|
|
|
+ }
|
|
|
+ return target.toString();
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("[LocalStorage] 上传失败, path={}", filePath, e);
|
|
|
+ throw new ServerException(ErrorEnum.FILE_UPLOAD_FAILED);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 上传输入流
|
|
|
+ *
|
|
|
+ * @param inputStream 输入流
|
|
|
+ * @param filePath 存储路径
|
|
|
+ * @return 文件路径
|
|
|
+ */
|
|
|
+ public String upload(InputStream inputStream, String filePath) {
|
|
|
+ Path target = resolve(filePath);
|
|
|
+ // 幂等写入:文件已存在时不执行任何操作,直接返回
|
|
|
+ if (Files.exists(target)) {
|
|
|
+ log.debug("[LocalStorage] 文件已存在,跳过写入, path={}", filePath);
|
|
|
+ return filePath;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ Files.createDirectories(target.getParent());
|
|
|
+ Files.copy(inputStream, target, StandardCopyOption.REPLACE_EXISTING);
|
|
|
+ return filePath;
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("[LocalStorage] 上传失败, path={}", filePath, e);
|
|
|
+ throw new ServerException(ErrorEnum.FILE_UPLOAD_FAILED);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 上传字符串内容
|
|
|
+ *
|
|
|
+ * @param content 字符串内容
|
|
|
+ * @param filePath 存储路径
|
|
|
+ * @return 文件路径
|
|
|
+ */
|
|
|
+ public String uploadString(String content, String filePath) {
|
|
|
+ // 幂等写入:文件已存在时不执行任何操作,直接返回
|
|
|
+ if (Files.exists(resolve(filePath))) {
|
|
|
+ log.debug("[LocalStorage] 文件已存在,跳过写入, path={}", filePath);
|
|
|
+ return filePath;
|
|
|
+ }
|
|
|
+ byte[] bytes = content.getBytes(StandardCharsets.UTF_8);
|
|
|
+ try (ByteArrayInputStream bais = new ByteArrayInputStream(bytes)) {
|
|
|
+ return upload(bais, filePath);
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("[LocalStorage] 上传字符串失败, path={}", filePath, e);
|
|
|
+ throw new ServerException(ErrorEnum.FILE_UPLOAD_FAILED);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 下载文件(返回 InputStream,调用方需自行关闭)
|
|
|
+ *
|
|
|
+ * @param filePath 文件路径
|
|
|
+ * @return 输入流
|
|
|
+ */
|
|
|
+ public InputStream download(String filePath) {
|
|
|
+ Path target = resolve(filePath);
|
|
|
+ try {
|
|
|
+ return Files.newInputStream(target);
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("[LocalStorage] 下载失败, path={}", filePath, e);
|
|
|
+ throw new ServerException(ErrorEnum.FILE_DOWNLOAD_FAILED);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 流式下载文件(返回 InputStream,适用于大文件,调用方需自行关闭)
|
|
|
+ *
|
|
|
+ * @param filePath 文件路径
|
|
|
+ * @return 输入流
|
|
|
+ */
|
|
|
+ public InputStream downloadStream(String filePath) {
|
|
|
+ return download(filePath);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 下载文件为字节数组(仅适用于小文件 <10MB)
|
|
|
+ *
|
|
|
+ * @param filePath 文件路径
|
|
|
+ * @return 字节数组
|
|
|
+ */
|
|
|
+ public byte[] downloadBytes(String filePath) {
|
|
|
+ try {
|
|
|
+ // 1. 检查文件大小,超过 10MB 禁止全量读入内存
|
|
|
+ long size = Files.size(resolve(filePath));
|
|
|
+ if (size > MAX_BYTES_FOR_IN_MEMORY) {
|
|
|
+ throw new ServerException(ErrorEnum.FILE_TOO_LARGE_FOR_MEMORY);
|
|
|
+ }
|
|
|
+ try (InputStream in = download(filePath)) {
|
|
|
+ return in.readAllBytes();
|
|
|
+ }
|
|
|
+ } catch (ServerException e) {
|
|
|
+ throw e;
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("[LocalStorage] 下载字节失败, path={}", filePath, e);
|
|
|
+ throw new ServerException(ErrorEnum.FILE_DOWNLOAD_FAILED);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 删除文件
|
|
|
+ *
|
|
|
+ * @param filePath 文件路径
|
|
|
+ */
|
|
|
+ public void delete(String filePath) {
|
|
|
+ try {
|
|
|
+ Files.deleteIfExists(resolve(filePath));
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("[LocalStorage] 删除失败, path={}", filePath, e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取文件内联访问 URL(由静态资源映射 {@code /files/**} 提供服务)
|
|
|
+ *
|
|
|
+ * @param filePath 文件路径
|
|
|
+ * @return 访问URL
|
|
|
+ */
|
|
|
+ public String getPresignedUrl(String filePath) {
|
|
|
+ return getPresignedUrl(filePath, null);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取文件内联访问 URL(本地存储由扩展名推断 MIME,contentType 参数保留以兼容调用方)
|
|
|
+ *
|
|
|
+ * @param filePath 文件路径
|
|
|
+ * @param contentType 文件MIME类型,为null时不覆盖
|
|
|
+ * @return 访问URL
|
|
|
+ */
|
|
|
+ public String getPresignedUrl(String filePath, String contentType) {
|
|
|
+ return baseUrl + "/files/" + encodePath(filePath);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取用于浏览器下载的 URL(Content-Disposition: attachment)
|
|
|
+ *
|
|
|
+ * @param filePath 文件路径
|
|
|
+ * @param downloadFileName 下载时展示的文件名
|
|
|
+ * @param contentType MIME 类型,可为 null
|
|
|
+ * @return 下载 URL
|
|
|
+ */
|
|
|
+ public String getPresignedDownloadUrl(String filePath, String downloadFileName, String contentType) {
|
|
|
+ String name = downloadFileName;
|
|
|
+ if (name == null || name.isBlank()) {
|
|
|
+ int slash = filePath.lastIndexOf('/');
|
|
|
+ name = slash >= 0 ? filePath.substring(slash + 1) : filePath;
|
|
|
+ }
|
|
|
+ String encodedName = URLEncoder.encode(name, StandardCharsets.UTF_8).replace("+", "%20");
|
|
|
+ return baseUrl + "/file-download/" + encodePath(filePath) + "?name=" + encodedName;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取文件公开访问URL
|
|
|
+ * <p>格式:{base-url}/files/{filePath}</p>
|
|
|
+ *
|
|
|
+ * @param filePath 文件路径
|
|
|
+ * @return 公开访问URL
|
|
|
+ */
|
|
|
+ public String getPublicUrl(String filePath) {
|
|
|
+ return getPresignedUrl(filePath);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 生成文件存储路径
|
|
|
+ *
|
|
|
+ * @param knowledgeId 知识库ID
|
|
|
+ * @param fileName 文件名
|
|
|
+ * @return 存储路径
|
|
|
+ */
|
|
|
+ public String generatePath(Long knowledgeId, String fileName) {
|
|
|
+ String ext = "";
|
|
|
+ int dotIdx = fileName.lastIndexOf('.');
|
|
|
+ if (dotIdx > 0) {
|
|
|
+ ext = fileName.substring(dotIdx);
|
|
|
+ }
|
|
|
+ return String.format("knowledge/%d/doc/%s%s", knowledgeId, UUID.randomUUID().toString().replace("-", ""), ext);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 列出指定前缀下的所有文件相对路径(递归)
|
|
|
+ *
|
|
|
+ * @param prefix 路径前缀
|
|
|
+ * @return 文件路径列表
|
|
|
+ */
|
|
|
+ public List<String> listObjects(String prefix) {
|
|
|
+ try {
|
|
|
+ List<String> result = new ArrayList<>();
|
|
|
+ Path scan = resolve(prefix);
|
|
|
+ Path parent = Files.isDirectory(scan) ? scan : scan.getParent();
|
|
|
+ if (parent == null || !Files.isDirectory(parent)) {
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+ try (Stream<Path> stream = Files.walk(parent)) {
|
|
|
+ stream.filter(Files::isRegularFile).forEach(p -> {
|
|
|
+ String rel = rootPath.relativize(p).toString().replace('\\', '/');
|
|
|
+ if (rel.startsWith(prefix)) {
|
|
|
+ result.add(rel);
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+ result.sort(String::compareTo);
|
|
|
+ return result;
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("[LocalStorage] 列举对象失败, prefix={}", prefix, e);
|
|
|
+ throw new ServerException(ErrorEnum.FILE_DOWNLOAD_FAILED);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 非递归列出指定目录前缀下的「直接子条目」。
|
|
|
+ * <p>本地文件系统为真实目录结构,直接列举一级子目录与文件。</p>
|
|
|
+ *
|
|
|
+ * @param directoryPrefix 目录前缀,必须以 {@code /} 结尾(如 {@code sessions/123/inputs/})
|
|
|
+ * @return 直接子条目列表(包含目录与文件)
|
|
|
+ */
|
|
|
+ public List<MinioDirEntry> listDirectoryEntries(String directoryPrefix) {
|
|
|
+ try {
|
|
|
+ List<MinioDirEntry> result = new ArrayList<>();
|
|
|
+ Path dir = resolve(directoryPrefix);
|
|
|
+ if (!Files.isDirectory(dir)) {
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+ try (Stream<Path> stream = Files.list(dir)) {
|
|
|
+ List<Path> children = stream.sorted().toList();
|
|
|
+ for (Path child : children) {
|
|
|
+ MinioDirEntry entry = new MinioDirEntry();
|
|
|
+ entry.name = child.getFileName().toString();
|
|
|
+ boolean isDir = Files.isDirectory(child);
|
|
|
+ entry.directory = isDir;
|
|
|
+ String rel = rootPath.relativize(child).toString().replace('\\', '/');
|
|
|
+ // 目录条目:去掉尾部斜杠;文件条目:完整相对路径
|
|
|
+ entry.objectName = isDir ? stripTrailingSlash(rel) : rel;
|
|
|
+ entry.size = isDir ? 0L : Files.size(child);
|
|
|
+ entry.lastModified = isDir ? null
|
|
|
+ : OffsetDateTime.ofInstant(Files.getLastModifiedTime(child).toInstant(), ZoneId.systemDefault()).toString();
|
|
|
+ result.add(entry);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return result;
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("[LocalStorage] 列举目录失败, prefix={}", directoryPrefix, e);
|
|
|
+ throw new ServerException(ErrorEnum.FILE_DOWNLOAD_FAILED);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private static String stripTrailingSlash(String s) {
|
|
|
+ while (s.endsWith("/")) {
|
|
|
+ s = s.substring(0, s.length() - 1);
|
|
|
+ }
|
|
|
+ return s;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 目录条目(非递归列举结果)
|
|
|
+ */
|
|
|
+ public static class MinioDirEntry {
|
|
|
+ public String name;
|
|
|
+ public String objectName;
|
|
|
+ public boolean directory;
|
|
|
+ public long size;
|
|
|
+ public String lastModified;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 删除指定前缀下的所有文件(用于会话工作区清理)
|
|
|
+ *
|
|
|
+ * @param prefix 路径前缀
|
|
|
+ * @return 删除的文件数量
|
|
|
+ */
|
|
|
+ public int deleteByPrefix(String prefix) {
|
|
|
+ try {
|
|
|
+ List<String> objects = listObjects(prefix);
|
|
|
+ if (objects.isEmpty()) {
|
|
|
+ return 0;
|
|
|
+ }
|
|
|
+ for (String obj : objects) {
|
|
|
+ delete(obj);
|
|
|
+ }
|
|
|
+ log.info("[LocalStorage] 批量删除完成, prefix={}, count={}", prefix, objects.size());
|
|
|
+ return objects.size();
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("[LocalStorage] 批量删除失败, prefix={}", prefix, e);
|
|
|
+ throw new ServerException(ErrorEnum.FILE_DOWNLOAD_FAILED);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 复制文件
|
|
|
+ *
|
|
|
+ * @param sourcePath 源路径
|
|
|
+ * @param destPath 目标路径
|
|
|
+ */
|
|
|
+ public void copyObject(String sourcePath, String destPath) {
|
|
|
+ try {
|
|
|
+ Path src = resolve(sourcePath);
|
|
|
+ Path dest = resolve(destPath);
|
|
|
+ Files.createDirectories(dest.getParent());
|
|
|
+ Files.copy(src, dest, StandardCopyOption.REPLACE_EXISTING);
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("[LocalStorage] 复制文件失败, src={}, dest={}", sourcePath, destPath, e);
|
|
|
+ throw new ServerException(ErrorEnum.FILE_UPLOAD_FAILED);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 判断文件是否存在
|
|
|
+ *
|
|
|
+ * @param filePath 文件路径
|
|
|
+ * @return 是否存在
|
|
|
+ */
|
|
|
+ public boolean exists(String filePath) {
|
|
|
+ try {
|
|
|
+ return Files.isRegularFile(resolve(filePath));
|
|
|
+ } catch (Exception e) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 健康检查:验证本地存储目录是否可访问
|
|
|
+ *
|
|
|
+ * @return true=可用
|
|
|
+ */
|
|
|
+ public boolean checkHealth() {
|
|
|
+ try {
|
|
|
+ return Files.isDirectory(rootPath) && Files.isWritable(rootPath);
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("[LocalStorage] 健康检查失败", e);
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取文件元数据
|
|
|
+ *
|
|
|
+ * @param filePath 文件路径
|
|
|
+ * @return 文件元数据
|
|
|
+ */
|
|
|
+ public FileStat statObject(String filePath) {
|
|
|
+ try {
|
|
|
+ Path target = resolve(filePath);
|
|
|
+ if (!Files.isRegularFile(target)) {
|
|
|
+ throw new java.nio.file.NoSuchFileException(filePath);
|
|
|
+ }
|
|
|
+ String contentType;
|
|
|
+ try {
|
|
|
+ contentType = Files.probeContentType(target);
|
|
|
+ } catch (Exception e) {
|
|
|
+ contentType = null;
|
|
|
+ }
|
|
|
+ return new FileStat(Files.size(target), contentType,
|
|
|
+ LocalDateTime.ofInstant(Files.getLastModifiedTime(target).toInstant(), ZoneId.systemDefault()));
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("[LocalStorage] 获取文件元数据失败, path={}", filePath, e);
|
|
|
+ throw new ServerException(ErrorEnum.FILE_DOWNLOAD_FAILED);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 本地文件元数据
|
|
|
+ */
|
|
|
+ public static class FileStat {
|
|
|
+ private final long size;
|
|
|
+ private final String contentType;
|
|
|
+ private final LocalDateTime lastModified;
|
|
|
+
|
|
|
+ FileStat(long size, String contentType, LocalDateTime lastModified) {
|
|
|
+ this.size = size;
|
|
|
+ this.contentType = contentType;
|
|
|
+ this.lastModified = lastModified;
|
|
|
+ }
|
|
|
+
|
|
|
+ public long size() {
|
|
|
+ return size;
|
|
|
+ }
|
|
|
+
|
|
|
+ public String contentType() {
|
|
|
+ return contentType;
|
|
|
+ }
|
|
|
+
|
|
|
+ public LocalDateTime lastModified() {
|
|
|
+ return lastModified;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 删除旧头像:兼容完整URL和相对路径(兼容历史 MinIO URL 与新本地 URL)
|
|
|
+ *
|
|
|
+ * @param avatar 头像URL或相对路径
|
|
|
+ */
|
|
|
+ public void deleteAvatar(String avatar) {
|
|
|
+ if (avatar == null || avatar.isEmpty()) return;
|
|
|
+ String path = avatar;
|
|
|
+ try {
|
|
|
+ // 兼容旧数据(MinIO 完整URL,形如 {endpoint}/qingjian/{path})和新数据(本地 URL,形如 {base}/files/{path})
|
|
|
+ if (avatar.contains("/qingjian/")) {
|
|
|
+ path = avatar.substring(avatar.indexOf("/qingjian/") + 10);
|
|
|
+ } else if (avatar.contains("/files/")) {
|
|
|
+ path = avatar.substring(avatar.indexOf("/files/") + 7);
|
|
|
+ int q = path.indexOf('?');
|
|
|
+ if (q >= 0) {
|
|
|
+ path = path.substring(0, q);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ delete(path);
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("[LocalStorage] 删除头像失败: path={}, error={}", path, e.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 安全解析文件路径:防止路径穿越
|
|
|
+ *
|
|
|
+ * @param filePath 相对路径
|
|
|
+ * @return 根目录下的绝对路径
|
|
|
+ */
|
|
|
+ public Path resolve(String filePath) {
|
|
|
+ Path p = rootPath.resolve(filePath).normalize();
|
|
|
+ if (!p.startsWith(rootPath)) {
|
|
|
+ throw new ServerException("非法文件路径: " + filePath);
|
|
|
+ }
|
|
|
+ return p;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 对路径每段做 URL 编码(保留 / 分隔符)
|
|
|
+ */
|
|
|
+ private static String encodePath(String filePath) {
|
|
|
+ String[] segs = filePath.split("/", -1);
|
|
|
+ StringBuilder sb = new StringBuilder();
|
|
|
+ for (int i = 0; i < segs.length; i++) {
|
|
|
+ if (i > 0) {
|
|
|
+ sb.append('/');
|
|
|
+ }
|
|
|
+ sb.append(URLEncoder.encode(segs[i], StandardCharsets.UTF_8).replace("+", "%20"));
|
|
|
+ }
|
|
|
+ return sb.toString();
|
|
|
+ }
|
|
|
+}
|