cc 3 周之前
父節點
當前提交
02e46765e7

+ 8 - 1
ai-server/src/main/java/com/zsjz/ai/common/base/ErrorEnum.java

@@ -15,7 +15,14 @@ public enum ErrorEnum {
     UNAUTHORIZED(401, "系统未授权!"),
     FORBIDDEN(403, "禁止访问!"),
     NOT_FOUND(404, "资源不存在!"),
-    SERVER_ERROR(500, "内部服务错误!");
+    SERVER_ERROR(500, "内部服务错误!"),
+
+
+    // ========== 文件存储 ==========
+    FILE_UPLOAD_FAILED(80001, "文件上传失败"),
+    FILE_DOWNLOAD_FAILED(80002, "文件下载失败"),
+    FILE_URL_FAILED(80003, "获取文件URL失败"),
+    FILE_TOO_LARGE_FOR_MEMORY(80004, "文件过大,无法加载到内存,请使用流式下载");
 
     private final int code;
     private final String message;

+ 51 - 0
ai-server/src/main/java/com/zsjz/ai/common/pgsql/JsonbTypeHandler.java

@@ -0,0 +1,51 @@
+package com.zsjz.ai.common.pgsql;
+
+import org.apache.ibatis.type.BaseTypeHandler;
+import org.apache.ibatis.type.JdbcType;
+import org.apache.ibatis.type.MappedJdbcTypes;
+import org.apache.ibatis.type.MappedTypes;
+import org.postgresql.util.PGobject;
+
+import java.sql.CallableStatement;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+
+/**
+ * PostgreSQL JSONB类型处理器
+ * 使用PGobject显式声明jsonb类型,避免PostgreSQL将参数识别为varchar
+ *
+ * @author cc
+ * @since 2026-05-19
+ */
+@MappedTypes(String.class)
+@MappedJdbcTypes(JdbcType.OTHER)
+public class JsonbTypeHandler extends BaseTypeHandler<String> {
+
+    @Override
+    public void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType) throws SQLException {
+        if (parameter == null || parameter.isBlank()) {
+            ps.setNull(i, JdbcType.OTHER.TYPE_CODE);
+            return;
+        }
+        PGobject pgObject = new PGobject();
+        pgObject.setType("jsonb");
+        pgObject.setValue(parameter);
+        ps.setObject(i, pgObject);
+    }
+
+    @Override
+    public String getNullableResult(ResultSet rs, String columnName) throws SQLException {
+        return rs.getString(columnName);
+    }
+
+    @Override
+    public String getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
+        return rs.getString(columnIndex);
+    }
+
+    @Override
+    public String getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
+        return cs.getString(columnIndex);
+    }
+}

+ 532 - 0
ai-server/src/main/java/com/zsjz/ai/common/utils/LocalFileStorageUtil.java

@@ -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);
+    }
+
+    /**
+     * 下载文件为字节数组(仅适用于小文件 &lt;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();
+    }
+}