springboot3集成minio

1.说明

注意:本代码是在若依springboot3版本上实现的,如果你不是在若依上面实现,需要将所有用到若依的相关代码修改后才能运行

文件管理

  • 文件上传:支持单文件上传,可指定存储桶和路径,支持自动按日期目录存储
  • 文件下载:支持文件直接下载,自动处理文件名编码
  • 文件预览:支持图片、文档等文件的在线预览功能
  • 文件删除:支持单文件删除和批量删除
  • 文件重命名:支持文件重命名操作
  • 图片处理:支持图片压缩和格式转换(WebP),可配置压缩质量

文件夹管理

  • 创建文件夹:支持创建多级文件夹结构
  • 删除文件夹:支持递归删除文件夹及其内容
  • 重命名文件夹:支持文件夹重命名,自动处理路径下所有对象
  • 文件列表:支持按目录层级展示文件和文件夹,可选择递归展示

存储桶管理

  • 桶创建:支持创建存储桶,自动校验桶名合法性
  • 桶删除:支持删除存储桶,可选择强制删除非空桶
  • 策略设置:支持设置桶的访问策略(只读/读写/私有)
  • 桶统计:提供桶内对象数量和存储容量等统计信息

系统特性

  • 异常处理:规范化的异常处理机制,友好的错误提示
  • 参数校验:完善的参数校验,确保数据安全
  • 连接管理:支持连接超时设置和连接状态检测
  • 文件验证:支持文件类型和大小限制验证

架构设计

分层架构

  • Controller层:提供RESTful API接口,处理请求参数和响应
  • Service层:实现业务逻辑,处理核心功能
  • Config层:负责模块配置和客户端初始化
  • Util层:提供通用工具方法
  • Exception层:自定义异常处理
  • Domain层:定义数据模型

核心类说明

配置类
  • MinioConfig:MinIO服务配置类,负责客户端初始化和配置参数定义
  • MinioConstants:MinIO相关常量定义,包括文件分隔符、日期格式等
控制层
  • MinioFileController:文件操作控制器,提供文件上传、下载、预览、删除等接口
  • MinioBucketController:存储桶管理控制器,提供桶创建、策略设置、统计信息等接口
服务层
  • IMinioFileService:文件服务接口,定义文件操作相关方法
  • IMinioBucketService:存储桶服务接口,定义桶管理相关方法
  • MinioFileServiceImpl:文件服务实现,包含文件处理核心逻辑
  • MinioBucketServiceImpl:存储桶服务实现,包含桶管理核心逻辑
工具类
  • MinioUtils:MinIO操作辅助工具类,提供桶名验证、错误信息处理等功能
异常类
  • MinioException:自定义MinIO异常类,统一异常处理
领域类
  • FileTreeNode:文件树节点类,用于文件列表展示

API接口说明

文件接口 (/minio/file/*)

  • POST /upload:上传文件
  • POST /folder:创建文件夹
  • DELETE /:删除文件
  • DELETE /folder:删除文件夹
  • PUT /:重命名文件
  • PUT /folder:重命名文件夹
  • GET /download:下载文件
  • GET /preview:预览文件
  • GET /list:列出文件和文件夹

存储桶接口 (/minio/bucket/*)

  • GET /list:获取所有存储桶
  • POST /:创建存储桶
  • DELETE /:删除存储桶
  • GET /policy:获取存储桶访问策略
  • PUT /policy:设置存储桶访问策略
  • PUT /policy/read-only:设置存储桶为只读访问
  • PUT /policy/read-write:设置存储桶为读写访问
  • PUT /policy/private:设置存储桶为私有访问
  • GET /stats:获取存储桶统计信息
  • GET /connection/test:测试MinIO连接状态

配置说明

在application.yml中添加以下配置:

# MinIO文件服务器配置
minio:
  # minioAPI服务地址
  url: http://192.168.186.132:9000
  # 访问密钥(Access Key)
  accessKey: minioadmin
  # 私有密钥(Secret Key)
  secretKey: minioadmin
  # 默认存储桶名称
  defaultBucketName: ruoyi
  # 连接超时(秒)
  connectTimeout: 10
  # 写入超时(秒)
  writeTimeout: 100
  # 读取超时(秒)
  readTimeout: 20
  # 是否自动创建默认桶
  autoCreateBucket: true
  # 默认桶权限(read-only,read-write,private)
  defaultBucketPolicy: read-only
  # 是否启用图片压缩
  compressEnabled: true
  # 是否将压缩后的图片转换为webp格式
  convertToWebp: true
  # 压缩图片质量(0-100的整数)
  compressQuality: 30
  # 图片压缩阈值(字节),超过此大小才压缩
  compressThreshold: 102400

2.导入依赖

<dependency>
    <groupId>io.minio</groupId>
    <artifactId>minio</artifactId>
    <version>8.5.2</version>
</dependency>

 <!-- webp-imageio 图片转化为webp -->
<dependency>
    <groupId>org.sejda.imageio</groupId>
    <artifactId>webp-imageio</artifactId>
    <version>0.4.18</version>
</dependency>

<!-- Thumbnailator 图片压缩 -->
<dependency>
    <groupId>net.coobird</groupId>
    <artifactId>thumbnailator</artifactId>
    <version>0.1.6</version>
</dependency>

3.相关代码

1.FileUtils

package com.ruoyi.file.utils;

import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.common.utils.uuid.IdUtils;
import com.ruoyi.file.local.config.LocalFileConstants;
import com.ruoyi.file.local.exception.LocalFileException;
import org.apache.commons.io.FilenameUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.io.IOException;
import java.nio.file.*;
import java.util.HashMap;
import java.util.Map;

/**
 * 本地文件操作工具类
 *
 * @author ruoyi
 */
public class FileUtils {
    private static final Logger log = LoggerFactory.getLogger(FileUtils.class);
    
    /**
     * 自定义MIME类型映射
     */
    private static final Map<String, String> MIME_TYPE_MAP = new HashMap<>();
    
    static {
        // 初始化自定义MIME类型映射
        MIME_TYPE_MAP.put("webp", "image/webp");
        MIME_TYPE_MAP.put("svg", "image/svg+xml");
        MIME_TYPE_MAP.put("avif", "image/avif");
        MIME_TYPE_MAP.put("heic", "image/heic");
        MIME_TYPE_MAP.put("heif", "image/heif");
    }

    /**
     * 生成唯一文件名
     *
     * @param originalFilename 原始文件名
     * @return 唯一文件名
     */
    public static String generateUniqueFileName(String originalFilename) {
        String extension = FilenameUtils.getExtension(originalFilename);
        return IdUtils.fastSimpleUUID() + (StringUtils.isNotBlank(extension) ? "." + extension : "");
    }

    /**
     * 创建路径中的所有目录
     *
     * @param path 完整路径
     * @return 创建的目录
     */
    public static File createDirectories(String path) {
        File directory = new File(path);
        if (!directory.exists() && !directory.mkdirs()) {
            throw new LocalFileException("无法创建目录: " + path);
        }
        return directory;
    }


    /**
     * 获取文件的MIME类型
     *
     * @param path 文件路径
     * @return MIME类型
     */
    public static String getContentType(Path path) {
        try {
            // 先尝试从文件系统获取MIME类型
            String contentType = Files.probeContentType(path);
            
            // 如果获取不到MIME类型或为null,尝试从文件扩展名获取
            if (contentType == null || contentType.isEmpty()) {
                String extension = FilenameUtils.getExtension(path.toString()).toLowerCase();
                if (extension != null && !extension.isEmpty()) {
                    String mimeType = MIME_TYPE_MAP.get(extension);
                    if (mimeType != null) {
                        return mimeType;
                    }
                }
            }
            
            return contentType != null ? contentType : LocalFileConstants.DEFAULT_CONTENT_TYPE;
        } catch (IOException e) {
            log.error("获取文件类型失败: {}", e.getMessage());
            return LocalFileConstants.DEFAULT_CONTENT_TYPE;
        }
    }


    /**
     * 格式化文件大小
     *
     * @param size 文件大小(字节)
     * @return 格式化后的文件大小
     */
    public static String formatFileSize(long size) {
        if (size <= 0) return "0 B";
        final String[] units = new String[]{"B", "KB", "MB", "GB", "TB"};
        int digitGroups = (int) (Math.log10(size) / Math.log10(1024));
        return String.format("%.2f %s", size / Math.pow(1024, digitGroups), units[digitGroups]);
    }

} 

2.ImageCompressUtils

package com.ruoyi.file.utils;

import com.luciad.imageio.webp.WebPWriteParam;
import com.ruoyi.common.config.RuoYiConfig;
import com.ruoyi.common.constant.Constants;
import com.ruoyi.common.utils.StringUtils;
import org.apache.poi.util.IOUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.multipart.MultipartFile;

import javax.imageio.IIOImage;
import javax.imageio.ImageIO;
import javax.imageio.ImageWriteParam;
import javax.imageio.ImageWriter;
import javax.imageio.stream.ImageOutputStream;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.io.*;
import java.net.URL;
import java.net.URLConnection;
import java.util.*;

/**
 * 图片处理工具类
 *
 * @author ruoyi
 */
public class ImageCompressUtils {
    // 支持的WebP格式扩展名列表,用于判断是否需要转换为WebP格式
    // 注意:这里的扩展名不包含点号(.),例如 "jpg" 而不是 ".jpg"
    // 这个列表是为了在处理图片时判断是否需要将图片转换为WebP格式
    // 如果图片的扩展名在这个列表中,那么就会将其转换为WebP格式
    // 如果图片的扩展名不在这个列表中,那么就不会进行转换,因为其他格式不支持转换为WebP格式
    public static final List<String> WEBP_SUPPORTED_EXTENSIONS = Arrays.asList("jpg", "jpeg", "png", "webp");

    // 图片文件扩展名列表,用于判断文件是否为图片类型
    public static final List<String> IMAGE_EXTENSIONS = Arrays.asList(
            "jpg", "jpeg", "png", "gif", "bmp", "webp", "tiff", "tif", "svg", "ico");

    /**
     * 自定义方法获取文件扩展名(不使用FilenameUtils)
     * @param filename 文件名
     * @return 小写的文件扩展名(不包含点号)
     */
    public static String getFileExtension(String filename) {
        if (filename == null || filename.isEmpty()) {
            return "";
        }
        
        int dotIndex = filename.lastIndexOf('.');
        if (dotIndex == -1 || dotIndex == filename.length() - 1) {
            return "";
        }
        
        return filename.substring(dotIndex + 1).toLowerCase();
    }

    /**
     * 判断文件是否为图片类型
     *
     * @param file 待检查的文件
     * @return 如果是图片返回true,否则返回false
     */
    public static boolean isImage(MultipartFile file) {
        if (file == null || file.isEmpty()) {
            System.err.println("ImageUtils.isImage: 文件为空");
            return false;
        }
        
        // 检查文件扩展名 - 使用自定义方法获取扩展名
        String originalFilename = file.getOriginalFilename();
        if (originalFilename != null) {
            String extension = getFileExtension(originalFilename);
            System.err.println("ImageUtils.isImage: 文件扩展名 = " + extension);
            if (!extension.isEmpty() && IMAGE_EXTENSIONS.contains(extension)) {
                System.err.println("ImageUtils.isImage: 扩展名检测为图片");
                return true;
            }
        } else {
            System.err.println("ImageUtils.isImage: 无法获取文件名");
        }
        
        // 检查文件的MIME类型
        String contentType = file.getContentType();
        System.err.println("ImageUtils.isImage: 内容类型 = " + contentType);
        if (contentType != null && contentType.startsWith("image/")) {
            System.err.println("ImageUtils.isImage: MIME类型检测为图片");
            return true;
        }
        
        return false;
    }

    /**
     * 将图片压缩并转换为WebP格式
     *
     * @param file               图片文件
     * @param compressionQuality 压缩质量,范围从0.0到1.0
     * @param isConvertToWebP    是否转化为webp
     * @throws IOException 当IO操作失败时抛出
     */
    public static byte[] compressAndToWebp(MultipartFile file, float compressionQuality, boolean isConvertToWebP) throws IOException {
        // 使用自定义方法获取扩展名
        String originalFilename = file.getOriginalFilename();
        if (originalFilename == null) {
            throw new IllegalArgumentException("文件名不能为空");
        }
        
        String extension = getFileExtension(originalFilename);
        System.err.println("compressAndToWebp: 文件扩展名 = " + extension);
        
        // 检查图片格式是否支持
        if (!WEBP_SUPPORTED_EXTENSIONS.contains(extension)) {
            throw new IllegalArgumentException("不支持的图片格式: " + extension);
        }
        
        // 读取文件字节
        byte[] fileBytes = file.getBytes();
        System.err.println("compressAndToWebp: 读取文件字节完成,大小 = " + fileBytes.length);
        
        // 使用ByteArrayInputStream读取图片
        try (ByteArrayInputStream bais = new ByteArrayInputStream(fileBytes)) {
            BufferedImage originalImage = ImageIO.read(bais);
            
            if (originalImage == null) {
                System.err.println("compressAndToWebp: 无法读取图片数据,图片为null");
                throw new IllegalArgumentException("无法读取图片数据");
            }
            
            System.err.println("compressAndToWebp: 成功读取图片,尺寸 = " + originalImage.getWidth() + "x" + originalImage.getHeight());
            
            // 如果是PNG格式且有透明通道,特殊处理
            if (extension.equals("png") && originalImage.getColorModel().hasAlpha()) {
                System.err.println("compressAndToWebp: PNG图片有透明通道,进行特殊处理");
                // 创建不带透明通道的新图像
                BufferedImage newImage = new BufferedImage(
                        originalImage.getWidth(), 
                        originalImage.getHeight(), 
                        BufferedImage.TYPE_INT_RGB);
                
                // 使用白色背景填充
                Graphics2D g = newImage.createGraphics();
                g.setColor(Color.WHITE);
                g.fillRect(0, 0, originalImage.getWidth(), originalImage.getHeight());
                g.drawImage(originalImage, 0, 0, null);
                g.dispose();
                
                originalImage = newImage;
            }
            
            if (!isConvertToWebP) {
                System.err.println("compressAndToWebp: 不转换为WebP,直接压缩为JPEG");
                // 如果不转换为WebP,直接返回JPEG压缩后的字节数组
                return compressToJpeg(originalImage, compressionQuality);
            }
            
            System.err.println("compressAndToWebp: 转换为WebP格式");
            // 转换为WebP格式
            return convertToWebP(originalImage, compressionQuality);
        } catch (IOException e) {
            System.err.println("compressAndToWebp异常: " + e.getMessage());
            e.printStackTrace();
            throw e;
        }
    }

    /**
     * 压缩为JPEG格式
     */
    private static byte[] compressToJpeg(BufferedImage image, float compressionQuality) throws IOException {
        try (ByteArrayOutputStream jpegOutputStream = new ByteArrayOutputStream()) {
            ImageWriter jpegWriter = ImageIO.getImageWritersByFormatName("jpeg").next();
            ImageWriteParam jpegWriteParam = jpegWriter.getDefaultWriteParam();
            jpegWriteParam.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
            jpegWriteParam.setCompressionQuality(compressionQuality);

            try (ImageOutputStream jpegIos = ImageIO.createImageOutputStream(jpegOutputStream)) {
                jpegWriter.setOutput(jpegIos);
                jpegWriter.write(null, new IIOImage(image, null, null), jpegWriteParam);
            } finally {
                jpegWriter.dispose();
            }
            return jpegOutputStream.toByteArray();
        }
    }

    /**
     * 转换为WebP格式
     */
    private static byte[] convertToWebP(BufferedImage image, float compressionQuality) throws IOException {
        try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
            WebPWriteParam writeParam = new WebPWriteParam(Locale.getDefault());
            writeParam.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
            writeParam.setCompressionType(writeParam.getCompressionTypes()[WebPWriteParam.LOSSY_COMPRESSION]);
            writeParam.setCompressionQuality(compressionQuality);

            Iterator<ImageWriter> writers = ImageIO.getImageWritersByMIMEType("image/webp");
            if (!writers.hasNext()) {
                throw new IllegalStateException("No writers found for WebP format");
            }

            ImageWriter writer = writers.next();
            try (ImageOutputStream ios = ImageIO.createImageOutputStream(baos)) {
                writer.setOutput(ios);
                writer.write(null, new IIOImage(image, null, null), writeParam);
            } finally {
                writer.dispose();
            }
            return baos.toByteArray();
        }
    }
}

3.PathUtils

package com.ruoyi.file.utils;

import org.apache.commons.lang3.StringUtils;

/**
 * 路径处理工具类
 */
public class PathUtils {

    /**
     * 规范化路径
     * @param path 原始路径
     * @return 规范化后的路径,以斜杠开头,不以斜杠结尾
     */
    public static String normalizePath(String path) {
        if (StringUtils.isBlank(path)) {
            return "/";
        }
        // 去除首尾空格
        path = path.trim();
        // 将所有反斜杠转为正斜杠
        path = path.replace('\\', '/');
        // 处理连续斜杠
        while (path.contains("//")) {
            path = path.replace("//", "/");
        }
        // 去除末尾的斜杠
        path = StringUtils.removeEnd(path, "/");
        // 确保路径以斜杠开头
        if (!path.startsWith("/")) {
            path = "/" + path;
        }
        return path;
    }
    
    /**
     * 确保路径以斜杠结尾
     * @param path 原始路径
     * @return 以斜杠结尾的路径
     */
    public static String ensureEndWithSlash(String path) {
        if (StringUtils.isBlank(path)) {
            return "/";
        }
        if (!path.endsWith("/")) {
            path = path + "/";
        }
        return path;
    }
    
    /**
     * 确保路径不以斜杠结尾
     * @param path 原始路径
     * @return 不以斜杠结尾的路径
     */
    public static String ensureNotEndWithSlash(String path) {
        if (StringUtils.isBlank(path)) {
            return "/";
        }
        return StringUtils.removeEnd(path, "/");
    }
}

4.FileTreeNode

package com.ruoyi.file.minio.domain;


import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.util.List;

@Data
@AllArgsConstructor
@NoArgsConstructor
@Schema(description = "文件树节点")
public class FileTreeNode {
    /** 桶名称 */
    @Schema(description = "桶名称")
    private String bucketName;
    /** 文件或目录名称 */
    @Schema(description = "文件或目录名称")
    private String name;
    /** 是否为目录 */
    @Schema(description = "是否为目录")
    private boolean isDirectory;
    /** 是否有子项 */
    @Schema(description = "是否有子项")
    private boolean hasChildren;
    /** 子节点列表 */
    @Schema(description = "子节点列表")
    private List<FileTreeNode> children;
    /** 相对于根路径 */
    @Schema(description = "相对于根路径")
    private String path;
    /** 浏览器访问路径 */
    @Schema(description = "浏览器访问路径")
    private String url;
    /** 文件大小 **/
    @Schema(description = "文件大小")
    private String size;
    /** 创建时间 **/
    @Schema(description = "创建时间")
    private String createTime;
    /** 更新时间 **/
    @Schema(description = "更新时间")
    private String updateTime;
}

5.MinioException

package com.ruoyi.file.minio.exception;

import java.io.Serial;

/**
 * MinIO操作异常类
 *
 * @author ruoyi
 */
public class MinioException extends RuntimeException {
    @Serial
    private static final long serialVersionUID = 1L;

    /**
     * 错误码
     */
    private String code;

    /**
     * 构造方法
     *
     * @param message 错误消息
     */
    public MinioException(String message) {
        super(message);
    }

    /**
     * 构造方法
     *
     * @param message 错误消息
     * @param cause   异常原因
     */
    public MinioException(String message, Throwable cause) {
        super(message, cause);
    }

    /**
     * 构造方法
     *
     * @param code    错误码
     * @param message 错误消息
     */
    public MinioException(String code, String message) {
        super(message);
        this.code = code;
    }

    /**
     * 构造方法
     *
     * @param code    错误码
     * @param message 错误消息
     * @param cause   异常原因
     */
    public MinioException(String code, String message, Throwable cause) {
        super(message, cause);
        this.code = code;
    }

    public String getCode() {
        return code;
    }
}

6.MinioUtils

package com.ruoyi.file.minio.util;

import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.common.utils.uuid.IdUtils;
import com.ruoyi.file.minio.config.MinioConstants;
import com.ruoyi.file.minio.exception.MinioException;
import io.minio.errors.*;
import org.apache.commons.io.FilenameUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;
import java.io.InputStream;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;

/**
 * MinIO工具类
 *
 * @author ruoyi
 */
public class MinioUtils {
    /**
     * 检查存储桶名称是否有效
     *
     * @param bucketName 存储桶名称
     * @throws MinioException 如果名称无效则抛出异常
     */
    public static void checkBucketName(String bucketName) throws MinioException {
        if (StringUtils.isBlank(bucketName)) {
            throw new MinioException("存储桶名称不能为空");
        }

        // 名称长度必须在3到63个字符之间
        if (bucketName.length() < 3 || bucketName.length() > 63) {
            throw new MinioException("存储桶名称长度必须在3到63个字符之间");
        }

        // 只能包含小写字母、数字和短横线
        if (!bucketName.matches("^[a-z0-9.-]+$")) {
            throw new MinioException("存储桶名称只能包含小写字母、数字、短横线和点");
        }

        // 必须以字母或数字开头和结尾
        if (!bucketName.matches("^[a-z0-9].*[a-z0-9]$")) {
            throw new MinioException("存储桶名称必须以字母或数字开头和结尾");
        }

        // 不能是IP地址格式
        if (bucketName.matches("^(\\d{1,3}\\.){3}\\d{1,3}$")) {
            throw new MinioException("存储桶名称不能是IP地址格式");
        }
    }


    /**
     * 从异常中提取友好的错误消息
     *
     * @param e 异常
     * @return 友好的错误消息
     */
    public static String getFriendlyErrorMessage(Exception e) {
        if (e instanceof ErrorResponseException) {
            ErrorResponseException ere = (ErrorResponseException) e;
            return ere.getMessage() + (ere.errorResponse() != null ? ": " + ere.errorResponse().message() : "");
        } else if (e instanceof InsufficientDataException) {
            return "MinIO客户端收到的数据少于预期,操作失败";
        } else if (e instanceof InternalException) {
            return "MinIO服务器内部错误";
        } else if (e instanceof InvalidKeyException) {
            return "无效的访问密钥或私有密钥";
        } else if (e instanceof InvalidResponseException) {
            return "MinIO客户端收到无效的响应";
        } else if (e instanceof IOException) {
            return "I/O错误: " + e.getMessage();
        } else if (e instanceof NoSuchAlgorithmException) {
            return "请求中指定的签名算法不可用";
        } else if (e instanceof ServerException) {
            return "MinIO服务器端错误";
        } else if (e instanceof XmlParserException) {
            return "解析XML响应时发生错误";
        }
        return e.getMessage();
    }

} 

7.IMinioFileService

package com.ruoyi.file.minio.service;

import com.ruoyi.file.minio.domain.FileTreeNode;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.web.multipart.MultipartFile;

import java.io.InputStream;
import java.util.List;
import java.util.Map;

/**
 * MinIO文件管理服务接口
 *
 * @author ruoyi
 */
public interface IMinioFileService {

    /**
     * 上传文件
     *
     * @param file       文件
     * @param bucketName 存储桶名称,如果为空则使用默认的桶
     * @param objectPath 对象存储路径,如果为空则使用默认路径(当前日期)
     * @return 文件访问路径等信息
     */
    Map<String, Object> uploadFile(MultipartFile file, String bucketName, String objectPath);

    /**
     * 创建文件夹
     *
     * @param bucketName 存储桶名称
     * @param folderPath 文件夹路径
     */
    void createFolder(String bucketName, String folderPath);

    /**
     * 删除文件
     *
     * @param bucketName 存储桶名称
     * @param objectName 对象名称
     */
    void deleteFile(String bucketName, String objectName);

    /**
     * 删除文件夹
     *
     * @param bucketName 存储桶名称
     * @param folderPath 文件夹路径
     * @param recursive  是否递归删除所有文件,true-删除所有,false-如果目录非空则不删除
     */
    void deleteFolder(String bucketName, String folderPath, boolean recursive);

    /**
     * 重命名文件
     *
     * @param bucketName    存储桶名称
     * @param objectName    原对象名称
     * @param newObjectName 新对象名称
     */
    void renameFile(String bucketName, String objectName, String newObjectName);

    /**
     * 重命名文件夹
     *
     * @param bucketName    存储桶名称
     * @param folderPath    原文件夹路径
     * @param newFolderPath 新文件夹路径
     * @return 成功重命名的对象数量
     */
    int renameFolder(String bucketName, String folderPath, String newFolderPath);

    /**
     * 获取文件流
     *
     * @param bucketName 存储桶名称
     * @param objectName 对象名称
     * @return 文件流
     */
    InputStream getObject(String bucketName, String objectName);

    /**
     * 获取文件信息和文件流
     *
     * @param bucketName 存储桶名称
     * @param objectName 对象名称
     * @return 包含文件信息和文件流的Map,包括stream(输入流)、size(大小)、contentType(内容类型)、lastModified(最后修改时间)等
     */
    Map<String, Object> getObjectInfo(String bucketName, String objectName);

    /**
     * 下载文件
     *
     * @param bucketName 存储桶名称
     * @param objectName 对象名称
     * @param response   HTTP响应对象
     */
    void downloadFile(String bucketName, String objectName, HttpServletResponse response);

    /**
     * 预览文件
     *
     * @param bucketName 存储桶名称
     * @param objectName 对象名称
     * @param response   HTTP响应对象
     */
    void previewFile(String bucketName, String objectName, HttpServletResponse response);

    /**
     * 列出指定目录下的文件和文件夹
     *
     * @param bucketName 存储桶名称
     * @param prefix     前缀(目录路径)
     * @param recursive  是否递归查询
     * @return 文件和文件夹列表,以FileTreeNode格式返回
     */
    List<FileTreeNode> listObjects(String bucketName, String prefix, boolean recursive);
}

8.IMinioBucketService

package com.ruoyi.file.minio.service;

import java.util.List;
import java.util.Map;

/**
 * MinIO存储桶管理服务接口
 *
 * @author ruoyi
 */
public interface IMinioBucketService {

    /**
     * 获取所有存储桶列表
     *
     * @return 存储桶信息列表,每个桶包含名称、创建时间和访问策略等信息
     */
    List<Map<String, Object>> listBuckets();

    /**
     * 检查存储桶是否存在
     *
     * @param bucketName 存储桶名称
     * @return 如果存在返回true,否则返回false
     */
    boolean bucketExists(String bucketName);

    /**
     * 创建存储桶
     *
     * @param bucketName 存储桶名称(必须符合DNS命名规范)
     * @param isPublic   是否为公共访问桶(true表示可公开读取,false表示私有)
     */
    void createBucket(String bucketName, Boolean isPublic);

    /**
     * 创建存储桶并设置指定的策略类型
     *
     * @param bucketName  存储桶名称(必须符合DNS命名规范)
     * @param policyType  策略类型:"read-only"(只读), "read-write"(读写), "private"(私有), "write-only"(只写)
     */
    void createBucket(String bucketName, String policyType);

    /**
     * 获取存储桶访问策略
     *
     * @param bucketName 存储桶名称
     * @return 存储桶策略信息映射
     */
    Map<String, Object> getBucketPolicy(String bucketName);

    /**
     * 修改存储桶访问策略
     *
     * @param bucketName 存储桶名称
     * @param isPublic   是否为公共访问桶(true表示可公开读取,false表示私有)
     * @deprecated 使用 {@link #updateBucketPolicy(String, String)} 代替
     */
    @Deprecated
    void updateBucket(String bucketName, Boolean isPublic);

    /**
     * 更新存储桶访问策略
     *
     * @param bucketName 存储桶名称
     * @param policyType 策略类型:"read-only"(只读), "read-write"(读写), "private"(私有), "write-only"(只写)
     */
    void updateBucketPolicy(String bucketName, String policyType);

    /**
     * 设置存储桶为只读访问策略
     *
     * @param bucketName 存储桶名称
     */
    void setBucketReadOnlyPolicy(String bucketName);

    /**
     * 设置存储桶为读写访问策略
     *
     * @param bucketName 存储桶名称
     */
    void setBucketReadWritePolicy(String bucketName);

    /**
     * 设置存储桶为私有访问策略
     *
     * @param bucketName 存储桶名称
     */
    void setBucketPrivatePolicy(String bucketName);

    /**
     * 设置存储桶为只写访问策略
     *
     * @param bucketName 存储桶名称
     */
    void setBucketWriteOnlyPolicy(String bucketName);

    /**
     * 删除存储桶
     *
     * @param bucketName 存储桶名称
     * @param recursive  是否递归删除非空桶,true-先清空桶再删除,false-如果桶不为空则抛出异常
     */
    void deleteBucket(String bucketName, boolean recursive);

    /**
     * 获取存储桶统计信息
     *
     * @param bucketName 存储桶名称
     * @return 统计信息,包含对象数量、总大小等
     */
    Map<String, Object> getBucketStats(String bucketName);
}

9.MinioBucketServiceImpl

package com.ruoyi.file.minio.service.impl;

import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.file.minio.config.MinioConstants;
import com.ruoyi.file.minio.exception.MinioException;
import com.ruoyi.file.minio.service.IMinioBucketService;
import com.ruoyi.file.minio.util.MinioUtils;
import io.minio.*;
import io.minio.messages.Bucket;
import io.minio.messages.Item;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * MinIO存储桶管理服务实现类
 *
 * @author ruoyi
 */
@Service
public class MinioBucketServiceImpl implements IMinioBucketService {

    private static final Logger log = LoggerFactory.getLogger(MinioBucketServiceImpl.class);

    private final MinioClient minioClient;

    public  MinioBucketServiceImpl(MinioClient minioClient) {
        this.minioClient = minioClient;
    }

    /**
     * 获取所有存储桶列表
     */
    @Override
    public List<Map<String, Object>> listBuckets() {
        try {
            List<Map<String, Object>> bucketList = new ArrayList<>();
            List<Bucket> buckets = minioClient.listBuckets();

            for (Bucket bucket : buckets) {
                Map<String, Object> bucketInfo = new HashMap<>();
                bucketInfo.put("name", bucket.name());
                bucketInfo.put("creationTime", bucket.creationDate());

                // 获取桶策略
                Map<String, Object> policyInfo = getBucketPolicy(bucket.name());
                bucketInfo.put("policyType", policyInfo.get("policyType"));
                // 获取桶统计信息
                try {
                    Map<String, Object> stats = getBucketStats(bucket.name());
                    bucketInfo.put("objectCount", stats.get("objectCount"));
                    bucketInfo.put("size", stats.get("size"));
                } catch (Exception e) {
                    log.warn("获取桶 {} 统计信息失败: {}", bucket.name(), e.getMessage());
                    bucketInfo.put("objectCount", 0);
                    bucketInfo.put("size", 0);
                }

                bucketList.add(bucketInfo);
            }

            return bucketList;
        } catch (Exception e) {
            String errorMsg = "获取桶列表失败: " + MinioUtils.getFriendlyErrorMessage(e);
            log.error(errorMsg, e);
            throw new MinioException(errorMsg, e);
        }
    }

    /**
     * 检查存储桶是否存在
     *
     * @param bucketName 存储桶名称
     * @return 如果存在返回true,否则返回false
     */
    @Override
    public boolean bucketExists(String bucketName) {
        try {
            MinioUtils.checkBucketName(bucketName);
            return minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build());
        } catch (MinioException e) {
            throw e;
        } catch (Exception e) {
            String errorMsg = "检查桶是否存在失败: " + MinioUtils.getFriendlyErrorMessage(e);
            log.error(errorMsg, e);
            throw new MinioException(errorMsg, e);
        }
    }

    /**
     * 创建存储桶
     *
     * @param bucketName 存储桶名称(必须符合DNS命名规范)
     * @param isPublic   是否为公共访问桶(true表示可公开读取,false表示私有)
     */
    @Override
    public void createBucket(String bucketName, Boolean isPublic) {
        String policyType = (isPublic != null && isPublic) ? 
                MinioConstants.BucketPolicy.READ_ONLY : MinioConstants.BucketPolicy.PRIVATE;
        createBucket(bucketName, policyType);
    }

    /**
     * 创建存储桶并设置指定的策略类型
     *
     * @param bucketName  存储桶名称(必须符合DNS命名规范)
     * @param policyType  策略类型:"read-only"(只读), "read-write"(读写), "private"(私有), "write-only"(只写)
     */
    @Override
    public void createBucket(String bucketName, String policyType) {
        try {
            // 验证桶名称
            MinioUtils.checkBucketName(bucketName);

            // 检查桶是否已存在
            boolean exists = bucketExists(bucketName);
            if (exists) {
                throw new MinioException("存储桶 '" + bucketName + "' 已存在");
            }

            // 创建桶
            minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucketName).build());
            log.info("成功创建存储桶: {}", bucketName);

            // 设置桶访问策略
            updateBucketPolicy(bucketName, policyType);
        } catch (MinioException e) {
            throw e;
        } catch (Exception e) {
            String errorMsg = "创建存储桶失败: " + MinioUtils.getFriendlyErrorMessage(e);
            log.error(errorMsg, e);
            throw new MinioException(errorMsg, e);
        }
    }

    /**
     * 修改存储桶访问策略
     *
     * @param bucketName 存储桶名称
     * @param isPublic   是否为公共访问桶(true表示可公开读取,false表示私有)
     * @deprecated 使用更灵活的updateBucketPolicy方法代替
     */
    @Override
    @Deprecated
    public void updateBucket(String bucketName, Boolean isPublic) {
        String policyType = (isPublic != null && isPublic) ? 
                MinioConstants.BucketPolicy.READ_ONLY : MinioConstants.BucketPolicy.PRIVATE;
        updateBucketPolicy(bucketName, policyType);
    }

    /**
     * 更新存储桶访问策略
     * 
     * @param bucketName 存储桶名称
     * @param policyType 策略类型:"read-only"(只读), "read-write"(读写), "private"(私有), "write-only"(只写)
     */
    @Override
    public void updateBucketPolicy(String bucketName, String policyType) {
        try {
            MinioUtils.checkBucketName(bucketName);
            
            // 检查桶是否存在
            if (!bucketExists(bucketName)) {
                throw new MinioException("存储桶 '" + bucketName + "' 不存在");
            }
            
            // 根据策略类型应用相应的策略
            switch (policyType) {
                case MinioConstants.BucketPolicy.READ_ONLY:
                    setBucketReadOnlyPolicy(bucketName);
                    break;
                case MinioConstants.BucketPolicy.READ_WRITE:
                    setBucketReadWritePolicy(bucketName);
                    break;
                case MinioConstants.BucketPolicy.PRIVATE:
                default:
                    setBucketPrivatePolicy(bucketName);
                    break;
            }
            
            log.info("成功更新存储桶 {} 的访问策略为: {}", bucketName, policyType);
        } catch (MinioException e) {
            throw e;
        } catch (Exception e) {
            String errorMsg = "更新桶访问策略失败: " + MinioUtils.getFriendlyErrorMessage(e);
            log.error(errorMsg, e);
            throw new MinioException(errorMsg, e);
        }
    }

    /**
     * 设置存储桶为只写访问策略
     * 
     * @param bucketName 存储桶名称
     */
    @Override
    public void setBucketWriteOnlyPolicy(String bucketName) {
        try {
            MinioUtils.checkBucketName(bucketName);
            
            // 检查桶是否存在
            if (!bucketExists(bucketName)) {
                throw new MinioException("存储桶 '" + bucketName + "' 不存在");
            }
            
            // 设置只写策略(允许上传但不允许下载)
            String policy = "{\n" +
                    "    \"Version\": \"2012-10-17\",\n" +
                    "    \"Statement\": [\n" +
                    "        {\n" +
                    "            \"Effect\": \"Allow\",\n" +
                    "            \"Principal\": {\"AWS\": [\"*\"]},\n" +
                    "            \"Action\": [\"s3:PutObject\"],\n" +
                    "            \"Resource\": [\"arn:aws:s3:::" + bucketName + "/*\"]\n" +
                    "        }\n" +
                    "    ]\n" +
                    "}";

            minioClient.setBucketPolicy(
                    SetBucketPolicyArgs.builder()
                            .bucket(bucketName)
                            .config(policy)
                            .build()
            );
            
            log.info("成功设置存储桶 {} 为只写访问策略", bucketName);
        } catch (MinioException e) {
            throw e;
        } catch (Exception e) {
            String errorMsg = "设置只写访问策略失败: " + MinioUtils.getFriendlyErrorMessage(e);
            log.error(errorMsg, e);
            throw new MinioException(errorMsg, e);
        }
    }

    /**
     * 获取存储桶访问策略
     *
     * @param bucketName 存储桶名称
     * @return 存储桶策略信息映射
     */
    @Override
    public Map<String, Object> getBucketPolicy(String bucketName) {
        try {
            MinioUtils.checkBucketName(bucketName);

            // 检查桶是否存在
            if (!bucketExists(bucketName)) {
                throw new MinioException("存储桶 '" + bucketName + "' 不存在");
            }

            Map<String, Object> policyInfo = new HashMap<>();
            String policyType = MinioConstants.BucketPolicy.PRIVATE;
            boolean isPublic = false;

            try {
                String policy = minioClient.getBucketPolicy(
                        GetBucketPolicyArgs.builder().bucket(bucketName).build()
                );

                if (StringUtils.isNotEmpty(policy)) {
                    // 判断策略类型
                    if (policy.contains("\"Effect\":\"Allow\"")) {
                        isPublic = true;

                        // 区分只读和读写
                        if (policy.contains("\"s3:GetObject\"")) {
                            if (!policy.contains("\"s3:PutObject\"") && !policy.contains("\"s3:DeleteObject\"")) {
                                policyType = MinioConstants.BucketPolicy.READ_ONLY;
                            } else if (policy.contains("\"s3:PutObject\"") || policy.contains("\"s3:DeleteObject\"")) {
                                policyType = MinioConstants.BucketPolicy.READ_WRITE;
                            }
                        } else if (policy.contains("\"s3:PutObject\"") && !policy.contains("\"s3:GetObject\"")) {
                            // 只写策略
                            policyType = "write-only";
                        }
                    }
                    
                    // 记录下日志以便调试
                    log.debug("桶 {} 的策略内容: {}", bucketName, policy);
                    log.debug("桶 {} 的识别策略类型: {}", bucketName, policyType);
                }
            } catch (Exception e) {
                // 没有策略则默认为私有
                log.warn("获取桶 {} 策略失败,将视为私有桶: {}", bucketName, e.getMessage());
            }

            policyInfo.put("isPublic", isPublic);
            policyInfo.put("policyType", policyType);
            policyInfo.put("bucketName", bucketName);

            return policyInfo;
        } catch (MinioException e) {
            throw e;
        } catch (Exception e) {
            String errorMsg = "获取桶访问策略失败: " + MinioUtils.getFriendlyErrorMessage(e);
            log.error(errorMsg, e);
            throw new MinioException(errorMsg, e);
        }
    }

    /**
     * 设置存储桶为只读访问策略
     */
    @Override
    public void setBucketReadOnlyPolicy(String bucketName) {
        try {
            MinioUtils.checkBucketName(bucketName);
            
            // 检查桶是否存在
            if (!bucketExists(bucketName)) {
                throw new MinioException("存储桶 '" + bucketName + "' 不存在");
            }
            
            // 简化的只读策略,只设置允许读取的权限,不使用Deny语句
            String policy = "{\n" +
                    "    \"Version\": \"2012-10-17\",\n" +
                    "    \"Statement\": [\n" +
                    "        {\n" +
                    "            \"Effect\": \"Allow\",\n" +
                    "            \"Principal\": {\"AWS\": [\"*\"]},\n" +
                    "            \"Action\": [\"s3:GetObject\", \"s3:ListBucket\"],\n" +
                    "            \"Resource\": [\"arn:aws:s3:::" + bucketName + "/*\", \"arn:aws:s3:::" + bucketName + "\"]\n" +
                    "        }\n" +
                    "    ]\n" +
                    "}";

            minioClient.setBucketPolicy(
                    SetBucketPolicyArgs.builder()
                            .bucket(bucketName)
                            .config(policy)
                            .build()
            );
            
            log.info("成功设置存储桶 {} 为只读访问策略", bucketName);
            
            // 验证策略是否设置成功
            String resultPolicy = minioClient.getBucketPolicy(
                    GetBucketPolicyArgs.builder().bucket(bucketName).build()
            );
            log.debug("设置后的桶 {} 策略内容: {}", bucketName, resultPolicy);
        } catch (MinioException e) {
            throw e;
        } catch (Exception e) {
            String errorMsg = "设置只读访问策略失败: " + MinioUtils.getFriendlyErrorMessage(e);
            log.error(errorMsg, e);
            throw new MinioException(errorMsg, e);
        }
    }

    /**
     * 设置存储桶为读写访问策略
     */
    @Override
    public void setBucketReadWritePolicy(String bucketName) {
        try {
            MinioUtils.checkBucketName(bucketName);

            // 检查桶是否存在
            if (!bucketExists(bucketName)) {
                throw new MinioException("存储桶 '" + bucketName + "' 不存在");
            }

            String policy = "{\n" +
                    "    \"Version\": \"2012-10-17\",\n" +
                    "    \"Statement\": [\n" +
                    "        {\n" +
                    "            \"Effect\": \"Allow\",\n" +
                    "            \"Principal\": {\"AWS\": [\"*\"]},\n" +
                    "            \"Action\": [\"s3:GetObject\", \"s3:PutObject\", \"s3:DeleteObject\"],\n" +
                    "            \"Resource\": [\"arn:aws:s3:::" + bucketName + "/*\"]\n" +
                    "        }\n" +
                    "    ]\n" +
                    "}";

            minioClient.setBucketPolicy(
                    SetBucketPolicyArgs.builder()
                            .bucket(bucketName)
                            .config(policy)
                            .build()
            );

            log.info("成功设置存储桶 {} 为读写访问策略", bucketName);
        } catch (MinioException e) {
            throw e;
        } catch (Exception e) {
            String errorMsg = "设置读写访问策略失败: " + MinioUtils.getFriendlyErrorMessage(e);
            log.error(errorMsg, e);
            throw new MinioException(errorMsg, e);
        }
    }

    /**
     * 设置存储桶为私有访问策略
     */
    @Override
    public void setBucketPrivatePolicy(String bucketName) {
        try {
            MinioUtils.checkBucketName(bucketName);
            
            // 检查桶是否存在
            if (!bucketExists(bucketName)) {
                throw new MinioException("存储桶 '" + bucketName + "' 不存在");
            }
            
            // 使用显式拒绝的私有策略,显式拒绝所有操作
            String policy = "{\n" +
                    "    \"Version\": \"2012-10-17\",\n" +
                    "    \"Statement\": [\n" +
                    "        {\n" +
                    "            \"Effect\": \"Deny\",\n" +
                    "            \"Principal\": {\"AWS\": [\"*\"]},\n" +
                    "            \"Action\": [\"s3:GetObject\", \"s3:ListBucket\", \"s3:PutObject\", \"s3:DeleteObject\"],\n" +
                    "            \"Resource\": [\"arn:aws:s3:::" + bucketName + "/*\", \"arn:aws:s3:::" + bucketName + "\"]\n" +
                    "        }\n" +
                    "    ]\n" +
                    "}";
            
            minioClient.setBucketPolicy(
                    SetBucketPolicyArgs.builder()
                            .bucket(bucketName)
                            .config(policy)
                            .build()
            );
            
            log.info("成功设置存储桶 {} 为私有访问策略", bucketName);
        } catch (MinioException e) {
            throw e;
        } catch (Exception e) {
            String errorMsg = "设置私有访问策略失败: " + MinioUtils.getFriendlyErrorMessage(e);
            log.error(errorMsg, e);
            throw new MinioException(errorMsg, e);
        }
    }

    /**
     * 删除存储桶
     */
    @Override
    public void deleteBucket(String bucketName, boolean recursive) {
        try {
            MinioUtils.checkBucketName(bucketName);

            // 检查桶是否存在
            if (!bucketExists(bucketName)) {
                throw new MinioException("存储桶 '" + bucketName + "' 不存在");
            }

            // 检查桶是否为空并收集对象名称
            Iterable<Result<Item>> objects = minioClient.listObjects(
                    ListObjectsArgs.builder()
                            .bucket(bucketName)
                            .recursive(true)
                            .build()
            );

            List<String> objectNames = new ArrayList<>();
            boolean isEmpty = true;

            // 收集所有对象名称
            for (Result<Item> result : objects) {
                Item item = result.get();
                isEmpty = false;
                objectNames.add(item.objectName());
            }

            // 处理非空桶
            if (!isEmpty) {
                if (!recursive) {
                    // 非递归模式,桶不为空则报错
                    throw new MinioException("存储桶 '" + bucketName + "' 不为空,无法删除。请先清空桶或使用递归删除选项。");
                } else {
                    // 递归模式:删除桶内所有对象
                    log.info("递归删除存储桶 {} 中的 {} 个对象", bucketName, objectNames.size());
                    for (String objectName : objectNames) {
                        minioClient.removeObject(
                                RemoveObjectArgs.builder()
                                        .bucket(bucketName)
                                        .object(objectName)
                                        .build()
                        );
                    }
                }
            }

            // 删除桶
            minioClient.removeBucket(RemoveBucketArgs.builder().bucket(bucketName).build());
            log.info("成功删除存储桶: {}", bucketName);
        } catch (MinioException e) {
            throw e;
        } catch (Exception e) {
            String errorMsg = "删除存储桶失败: " + MinioUtils.getFriendlyErrorMessage(e);
            log.error(errorMsg, e);
            throw new MinioException(errorMsg, e);
        }
    }

    /**
     * 获取存储桶统计信息
     *
     * @param bucketName 存储桶名称
     * @return 统计信息,包含对象数量、总大小等
     */
    @Override
    public Map<String, Object> getBucketStats(String bucketName) {
        try {
            MinioUtils.checkBucketName(bucketName);

            // 检查桶是否存在
            if (!bucketExists(bucketName)) {
                throw new MinioException("存储桶 '" + bucketName + "' 不存在");
            }

            Map<String, Object> stats = new HashMap<>();
            long objectCount = 0;
            long totalSize = 0;

            // 获取所有对象进行统计
            Iterable<Result<Item>> objects = minioClient.listObjects(
                    ListObjectsArgs.builder()
                            .bucket(bucketName)
                            .recursive(true)
                            .build()
            );

            // 计算对象数量和总大小
            for (Result<Item> result : objects) {
                Item item = result.get();
                objectCount++;
                totalSize += item.size();
            }

            stats.put("objectCount", objectCount);
            stats.put("size", totalSize);
            stats.put("sizeHuman", formatSize(totalSize));

            return stats;
        } catch (MinioException e) {
            throw e;
        } catch (Exception e) {
            String errorMsg = "获取存储桶统计信息失败: " + MinioUtils.getFriendlyErrorMessage(e);
            log.error(errorMsg, e);
            throw new MinioException(errorMsg, e);
        }
    }

    /**
     * 格式化文件大小为人类可读格式
     *
     * @param size 文件大小(字节)
     * @return 格式化后的大小
     */
    private String formatSize(long size) {
        if (size <= 0) {
            return "0 B";
        }

        final String[] units = new String[]{"B", "KB", "MB", "GB", "TB", "PB", "EB"};
        int digitGroups = (int) (Math.log10(size) / Math.log10(1024));

        return String.format("%.2f %s", size / Math.pow(1024, digitGroups), units[digitGroups]);
    }
}

10.MinioFileServiceImpl

package com.ruoyi.file.minio.service.impl;

import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.file.minio.config.MinioConfig;
import com.ruoyi.file.minio.config.MinioConstants;
import com.ruoyi.file.minio.domain.FileTreeNode;
import com.ruoyi.file.minio.exception.MinioException;
import com.ruoyi.file.minio.service.IMinioBucketService;
import com.ruoyi.file.minio.service.IMinioFileService;
import com.ruoyi.file.minio.util.MinioUtils;
import com.ruoyi.file.utils.FileUtils;
import com.ruoyi.file.utils.ImageCompressUtils;
import com.ruoyi.file.utils.PathUtils;
import io.minio.*;
import io.minio.messages.DeleteError;
import io.minio.messages.DeleteObject;
import io.minio.messages.Item;
import jakarta.servlet.http.HttpServletResponse;
import org.apache.commons.io.IOUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.text.SimpleDateFormat;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.*;

/**
 * MinIO文件管理服务实现
 */
@Service
public class MinioFileServiceImpl implements IMinioFileService {

    private static final Logger log = LoggerFactory.getLogger(MinioFileServiceImpl.class);

    private final MinioClient minioClient;

    private final MinioConfig minioConfig;

    private final IMinioBucketService minioBucketService;

    public MinioFileServiceImpl(MinioClient minioClient, MinioConfig minioConfig, IMinioBucketService minioBucketService) {
        this.minioClient = minioClient;
        this.minioConfig = minioConfig;
        this.minioBucketService = minioBucketService;
    }

    /**
     * 上传文件
     *
     * @param file       文件
     * @param bucketName 存储桶名称,如果为空则使用默认的桶
     * @param objectPath 对象存储路径,如果为空则使用默认路径(当前日期)
     * @return 文件访问路径等信息
     */
    @Override
    public Map<String, Object> uploadFile(MultipartFile file, String bucketName, String objectPath) {
        try {
            if (file == null || file.isEmpty()) {
                throw new MinioException("上传文件不能为空");
            }

            // 确定存储桶名称
            String finalBucketName = StringUtils.isNotBlank(bucketName) ? bucketName : minioConfig.getDefaultBucketName();

            // 确定存储路径
            String finalObjectPath;
            if (StringUtils.isNotBlank(objectPath)) {
                finalObjectPath = PathUtils.normalizePath(objectPath);
                finalObjectPath = PathUtils.ensureEndWithSlash(finalObjectPath);
                // 移除前导斜杠用于MinIO操作
                finalObjectPath = finalObjectPath.startsWith("/") ? finalObjectPath.substring(1) : finalObjectPath;
            } else {
                finalObjectPath = generateDefaultPath().substring(1) + "/"; // 去除前导斜杠
            }

            // 文件原始名称和大小
            String originalFilename = file.getOriginalFilename();
            long fileSize = file.getSize();

            // 使用FileUtils生成唯一文件名
            String fileName = FileUtils.generateUniqueFileName(originalFilename);
            String objectName = finalObjectPath + fileName;

            // 检查存储桶是否存在,不存在则创建
            boolean bucketExists = minioClient.bucketExists(BucketExistsArgs.builder().bucket(finalBucketName).build());
            if (!bucketExists) {
                minioClient.makeBucket(MakeBucketArgs.builder().bucket(finalBucketName).build());
                minioBucketService.updateBucketPolicy(finalBucketName, MinioConstants.BucketPolicy.READ_WRITE);
            }

            // 判断是否需要压缩图片
            boolean isImage = false;
            byte[] compressedImageBytes = null;
            String contentType = file.getContentType();

            // 检查文件是否为图片
            boolean isImageCheck = ImageCompressUtils.isImage(file);

            // 如果启用了图片压缩,且文件确实是图片类型,则进行压缩
            if (minioConfig.isCompressEnabled() && isImageCheck && fileSize > minioConfig.getCompressThreshold()) {
                isImage = true;
                try {
                    float quality = minioConfig.getCompressQuality() / 100.0f;  // 将整数质量值转换为0-1之间的浮点数
                    boolean convertToWebp = minioConfig.isConvertToWebp();

                    // 压缩图片并可能转换为WebP格式
                    compressedImageBytes = ImageCompressUtils.compressAndToWebp(file, quality, convertToWebp);

                    // 如果转换为WebP格式,更新文件名
                    if (convertToWebp) {
                        fileName = FileUtils.generateUniqueFileName("image.webp");
                        objectName = finalObjectPath + fileName;
                    }

                    log.info("图片压缩成功:原始大小={}, 压缩后大小={}, 压缩率={}%",
                            fileSize,
                            compressedImageBytes.length,
                            String.format("%.2f", (1 - compressedImageBytes.length / (float) fileSize) * 100));
                } catch (Exception e) {
                    // 压缩失败,记录错误但继续使用原始文件
                    log.error("图片压缩失败,将使用原始文件: {}", e.getMessage());
                    isImage = false;
                    compressedImageBytes = null;
                }
            }

            // 上传文件到MinIO
            if (compressedImageBytes != null) {
                // 上传压缩后的图片
                minioClient.putObject(
                        PutObjectArgs.builder()
                                .bucket(finalBucketName)
                                .object(objectName)
                                .stream(new ByteArrayInputStream(compressedImageBytes), compressedImageBytes.length, -1)
                                .contentType(isImage ? (minioConfig.isConvertToWebp() ? "image/webp" : contentType) : contentType)
                                .build()
                );
            } else {
                // 上传原始文件
                minioClient.putObject(
                        PutObjectArgs.builder()
                                .bucket(finalBucketName)
                                .object(objectName)
                                .stream(file.getInputStream(), file.getSize(), -1)
                                .contentType(contentType)
                                .build()
                );
            }

            // 构建文件访问URL
            String url = minioConfig.getUrl() + "/" + finalBucketName + "/" + objectName;

            // 返回文件信息
            Map<String, Object> result = new HashMap<>();
            result.put("fileName", fileName);
            result.put("originalFilename", originalFilename);
            result.put("size", FileUtils.formatFileSize(isImage && compressedImageBytes != null ? compressedImageBytes.length : fileSize));
            result.put("contentType", isImage && minioConfig.isConvertToWebp() ? "image/webp" : contentType);
            result.put("bucketName", finalBucketName);
            result.put("objectName", objectName);
            result.put("url", url);

            return result;
        } catch (Exception e) {
            throw new MinioException("上传文件失败: " + e.getMessage(), e);
        }
    }

    /**
     * 创建文件夹
     *
     * @param bucketName 存储桶名称
     * @param folderPath 文件夹路径
     */
    @Override
    public void createFolder(String bucketName, String folderPath) {
        try {
            if (StringUtils.isBlank(bucketName)) {
                throw new IllegalArgumentException("存储桶名称不能为空");
            }

            if (StringUtils.isBlank(folderPath)) {
                throw new IllegalArgumentException("文件夹路径不能为空");
            }

            // 使用PathUtils规范化路径
            String normalizedPath = PathUtils.normalizePath(folderPath);
            normalizedPath = PathUtils.ensureEndWithSlash(normalizedPath);

            // 移除路径开头的斜杠,MinIO不需要前导斜杠
            String folderName = normalizedPath.startsWith("/") ? normalizedPath.substring(1) : normalizedPath;

            // 检查桶是否存在,如果不存在则创建
            boolean bucketExists = minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build());
            if (!bucketExists) {
                minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucketName).build());
                minioBucketService.updateBucketPolicy(bucketName, MinioConstants.BucketPolicy.READ_WRITE);
            }

            // 创建空文件作为文件夹标记
            minioClient.putObject(
                    PutObjectArgs.builder()
                            .bucket(bucketName)
                            .object(folderName)
                            .stream(new ByteArrayInputStream(new byte[0]), 0, -1)
                            .build()
            );

            log.info("已创建文件夹: " + bucketName + "/" + folderName);
        } catch (Exception e) {
            throw new RuntimeException("创建文件夹失败: " + e.getMessage(), e);
        }
    }

    /**
     * 删除文件
     *
     * @param bucketName 存储桶名称
     * @param objectName 对象名称
     */
    @Override
    public void deleteFile(String bucketName, String objectName) {
        try {
            if (StringUtils.isBlank(bucketName)) {
                throw new IllegalArgumentException("存储桶名称不能为空");
            }

            if (StringUtils.isBlank(objectName)) {
                throw new IllegalArgumentException("对象名称不能为空");
            }

            // 处理路径,使用PathUtils规范化
            String normalizedPath = PathUtils.normalizePath(objectName);
            // 移除路径开头的斜杠,MinIO不需要前导斜杠
            String object = normalizedPath.startsWith("/") ? normalizedPath.substring(1) : normalizedPath;

            // 检查桶是否存在
            boolean bucketExists = minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build());
            if (!bucketExists) {
                throw new IllegalArgumentException("存储桶 '" + bucketName + "' 不存在");
            }

            // 删除文件
            minioClient.removeObject(
                    RemoveObjectArgs.builder()
                            .bucket(bucketName)
                            .object(object)
                            .build()
            );

            log.info("删除文件成功: {}/{}", bucketName, object);
        } catch (Exception e) {
            throw new RuntimeException("删除文件失败: " + e.getMessage(), e);
        }
    }

    /**
     * 删除文件夹
     *
     * @param bucketName 存储桶名称
     * @param folderPath 文件夹路径
     * @param recursive  是否递归删除所有文件,true-删除所有,false-如果目录非空则不删除
     */
    @Override
    public void deleteFolder(String bucketName, String folderPath, boolean recursive) {
        try {
            if (StringUtils.isBlank(bucketName)) {
                throw new IllegalArgumentException("存储桶名称不能为空");
            }

            if (StringUtils.isBlank(folderPath)) {
                throw new IllegalArgumentException("文件夹路径不能为空");
            }

            // 规范化路径
            String normalizedPath = PathUtils.normalizePath(folderPath);
            normalizedPath = PathUtils.ensureEndWithSlash(normalizedPath);
            
            // 移除路径开头的斜杠,MinIO不需要前导斜杠
            String folder = normalizedPath.startsWith("/") ? normalizedPath.substring(1) : normalizedPath;

            // 检查桶是否存在
            boolean bucketExists = minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build());
            if (!bucketExists) {
                throw new IllegalArgumentException("存储桶 '" + bucketName + "' 不存在");
            }

            // 获取文件夹下的所有对象
            List<DeleteObject> objectsToDelete = new ArrayList<>();
            Iterable<Result<Item>> results = minioClient.listObjects(
                    ListObjectsArgs.builder()
                            .bucket(bucketName)
                            .prefix(folder)
                            .recursive(true)
                            .build()
            );

            // 收集要删除的对象
            int count = 0;
            for (Result<Item> result : results) {
                Item item = result.get();
                objectsToDelete.add(new DeleteObject(item.objectName()));
                count++;
            }

            // 检查是否找到了对象
            if (objectsToDelete.isEmpty()) {
                throw new IllegalArgumentException("找不到指定的文件夹: " + folderPath);
            }

            // 如果非递归模式且文件夹非空(不仅包含文件夹本身),则不删除
            if (!recursive && count > 1) {
                throw new IllegalArgumentException("文件夹不为空,无法删除: " + folderPath);
            }

            // 批量删除对象
            Iterable<Result<DeleteError>> deleteResults = minioClient.removeObjects(
                    RemoveObjectsArgs.builder()
                            .bucket(bucketName)
                            .objects(objectsToDelete)
                            .build()
            );

            // 检查删除结果
            for (Result<DeleteError> deleteResult : deleteResults) {
                DeleteError error = deleteResult.get();
                log.error("删除对象失败: {}", error.message());
            }

            log.info("删除文件夹及其内容成功: {}/{}", bucketName, folder);
        } catch (Exception e) {
            throw new RuntimeException("删除文件夹失败: " + e.getMessage(), e);
        }
    }

    /**
     * 重命名文件
     *
     * @param bucketName    存储桶名称
     * @param objectName    原对象名称
     * @param newObjectName 新对象名称
     */
    @Override
    public void renameFile(String bucketName, String objectName, String newObjectName) {
        try {
            if (StringUtils.isBlank(bucketName)) {
                throw new IllegalArgumentException("存储桶名称不能为空");
            }

            if (StringUtils.isBlank(objectName) || StringUtils.isBlank(newObjectName)) {
                throw new IllegalArgumentException("对象名称不能为空");
            }

            // 使用PathUtils规范化路径
            String normalizedOldPath = PathUtils.normalizePath(objectName);
            String normalizedNewPath = PathUtils.normalizePath(newObjectName);
            
            // MinIO不需要前导斜杠
            String sourceObject = normalizedOldPath.startsWith("/") ? normalizedOldPath.substring(1) : normalizedOldPath;
            String targetObject = normalizedNewPath.startsWith("/") ? normalizedNewPath.substring(1) : normalizedNewPath;

            // 检查桶是否存在
            boolean bucketExists = minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build());
            if (!bucketExists) {
                throw new IllegalArgumentException("存储桶 '" + bucketName + "' 不存在");
            }

            // 检查源文件是否存在
            try {
                minioClient.statObject(
                        StatObjectArgs.builder()
                                .bucket(bucketName)
                                .object(sourceObject)
                                .build()
                );
            } catch (Exception e) {
                throw new IllegalArgumentException("源文件不存在: " + objectName);
            }

            // 复制对象到新路径
            minioClient.copyObject(
                    CopyObjectArgs.builder()
                            .source(CopySource.builder().bucket(bucketName).object(sourceObject).build())
                            .bucket(bucketName)
                            .object(targetObject)
                            .build()
            );

            // 删除旧对象
            minioClient.removeObject(
                    RemoveObjectArgs.builder()
                            .bucket(bucketName)
                            .object(sourceObject)
                            .build()
            );

            log.info("重命名文件成功: {}/{} -> {}/{}", bucketName, sourceObject, bucketName, targetObject);
        } catch (Exception e) {
            throw new RuntimeException("重命名文件失败: " + e.getMessage(), e);
        }
    }

    /**
     * 重命名文件夹
     *
     * @param bucketName    存储桶名称
     * @param folderPath    原文件夹路径
     * @param newFolderPath 新文件夹路径
     * @return 成功重命名的对象数量
     */
    @Override
    public int renameFolder(String bucketName, String folderPath, String newFolderPath) {
        try {
            if (StringUtils.isBlank(bucketName)) {
                throw new IllegalArgumentException("存储桶名称不能为空");
            }

            if (StringUtils.isBlank(folderPath) || StringUtils.isBlank(newFolderPath)) {
                throw new IllegalArgumentException("文件夹路径不能为空");
            }

            // 使用PathUtils规范化路径
            String normalizedOldPath = PathUtils.normalizePath(folderPath);
            String normalizedNewPath = PathUtils.normalizePath(newFolderPath);
            
            // 确保路径以斜杠结尾
            normalizedOldPath = PathUtils.ensureEndWithSlash(normalizedOldPath);
            normalizedNewPath = PathUtils.ensureEndWithSlash(normalizedNewPath);
            
            // 检查路径层级,除最后一级目录外,前面的目录必须一致
            java.nio.file.Path sourcePath = java.nio.file.Paths.get(normalizedOldPath);
            java.nio.file.Path targetPath = java.nio.file.Paths.get(normalizedNewPath);
            java.nio.file.Path sourceParent = sourcePath.getParent();
            java.nio.file.Path targetParent = targetPath.getParent();

            // 如果两个路径都是根路径下的直接子目录,则允许重命名
            boolean isRootLevelRename = (sourceParent == null || sourceParent.toString().equals("/") || sourceParent.toString().isEmpty())
                    && (targetParent == null || targetParent.toString().equals("/") || targetParent.toString().isEmpty());

            if (!isRootLevelRename && (sourceParent == null || targetParent == null || !sourceParent.equals(targetParent))) {
                throw new IllegalArgumentException("只能重命名最后一级目录,前面的目录层级必须一致");
            }

            // 处理前缀路径,移除开头的斜杠用于Minio操作
            String oldFolder = normalizedOldPath.startsWith("/") ? normalizedOldPath.substring(1) : normalizedOldPath;
            String newFolder = normalizedNewPath.startsWith("/") ? normalizedNewPath.substring(1) : normalizedNewPath;

            // 检查桶是否存在
            boolean bucketExists = minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build());
            if (!bucketExists) {
                throw new IllegalArgumentException("存储桶 '" + bucketName + "' 不存在");
            }

            // 获取文件夹下的所有对象
            List<String> objectsToRename = new ArrayList<>();
            Iterable<Result<Item>> results = minioClient.listObjects(
                    ListObjectsArgs.builder()
                            .bucket(bucketName)
                            .prefix(oldFolder)
                            .recursive(true)
                            .build()
            );

            for (Result<Item> result : results) {
                Item item = result.get();
                objectsToRename.add(item.objectName());
            }

            // 检查是否找到了原文件夹
            if (objectsToRename.isEmpty()) {
                throw new IllegalArgumentException("找不到指定的文件夹: " + folderPath);
            }

            // 重命名每个对象
            int renamedCount = 0;
            for (String oldObjectName : objectsToRename) {
                String newObjectName = oldObjectName.replace(oldFolder, newFolder);

                // 复制对象到新路径
                minioClient.copyObject(
                        CopyObjectArgs.builder()
                                .source(CopySource.builder().bucket(bucketName).object(oldObjectName).build())
                                .bucket(bucketName)
                                .object(newObjectName)
                                .build()
                );

                // 删除旧对象
                minioClient.removeObject(
                        RemoveObjectArgs.builder()
                                .bucket(bucketName)
                                .object(oldObjectName)
                                .build()
                );

                renamedCount++;
            }

            // 如果没有重命名任何对象(可能是一个空文件夹),创建一个目标空文件夹
            if (renamedCount == 0) {
                // 检查原始文件夹是否存在
                boolean folderExists = false;
                Iterable<Result<Item>> checkResults = minioClient.listObjects(
                        ListObjectsArgs.builder()
                                .bucket(bucketName)
                                .prefix(oldFolder)
                                .build()
                );

                // 原文件夹存在但为空,则创建新的空文件夹,并删除原始空文件夹
                if (checkResults.iterator().hasNext()) {
                    createFolder(bucketName, newFolder);

                    // 删除原始空文件夹
                    minioClient.removeObject(
                            RemoveObjectArgs.builder()
                                    .bucket(bucketName)
                                    .object(oldFolder)
                                    .build()
                    );

                    renamedCount = 1;
                } else {
                    throw new IllegalArgumentException("找不到指定的文件夹: " + folderPath);
                }
            }

            return renamedCount;
        } catch (Exception e) {
            throw new RuntimeException("重命名文件夹失败: " + e.getMessage(), e);
        }
    }

    /**
     * 获取文件流
     *
     * @param bucketName 存储桶名称
     * @param objectName 对象名称
     * @return 文件流
     */
    @Override
    public InputStream getObject(String bucketName, String objectName) {
        try {
            if (StringUtils.isBlank(bucketName)) {
                throw new IllegalArgumentException("存储桶名称不能为空");
            }

            if (StringUtils.isBlank(objectName)) {
                throw new IllegalArgumentException("对象名称不能为空");
            }

            // 检查桶是否存在
            boolean bucketExists = minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build());
            if (!bucketExists) {
                throw new IllegalArgumentException("存储桶 '" + bucketName + "' 不存在");
            }

            // 获取对象
            return minioClient.getObject(
                    GetObjectArgs.builder()
                            .bucket(bucketName)
                            .object(objectName)
                            .build()
            );
        } catch (Exception e) {
            throw new RuntimeException("获取文件失败: " + e.getMessage(), e);
        }
    }

    /**
     * 获取文件信息和文件流
     *
     * @param bucketName 存储桶名称
     * @param objectName 对象名称
     * @return 包含文件信息和文件流的Map,包括stream(输入流)、size(大小)、contentType(内容类型)、lastModified(最后修改时间)等
     */
    @Override
    public Map<String, Object> getObjectInfo(String bucketName, String objectName) {
        try {
            if (StringUtils.isBlank(bucketName)) {
                throw new MinioException("存储桶名称不能为空");
            }

            if (StringUtils.isBlank(objectName)) {
                throw new MinioException("对象名称不能为空");
            }

            // 处理路径,移除前导斜杠
            String object = objectName.startsWith("/") ? objectName.substring(1) : objectName;

            // 检查桶是否存在
            boolean bucketExists = minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build());
            if (!bucketExists) {
                throw new MinioException("存储桶 '" + bucketName + "' 不存在");
            }

            // 获取对象状态信息
            StatObjectResponse stat = minioClient.statObject(
                    StatObjectArgs.builder()
                            .bucket(bucketName)
                            .object(object)
                            .build()
            );

            // 获取对象数据流
            InputStream stream = minioClient.getObject(
                    GetObjectArgs.builder()
                            .bucket(bucketName)
                            .object(object)
                            .build()
            );

            // 组装结果
            Map<String, Object> objectInfo = new HashMap<>();
            objectInfo.put("stream", stream);
            objectInfo.put("size", stat.size());
            objectInfo.put("contentType", stat.contentType());
            objectInfo.put("etag", stat.etag());
            objectInfo.put("lastModified", stat.lastModified());
            objectInfo.put("name", object.substring(object.lastIndexOf("/") + 1));
            objectInfo.put("path", object);
            objectInfo.put("url", minioConfig.getUrl() + "/" + bucketName + "/" + object);

            return objectInfo;
        } catch (MinioException e) {
            throw e;
        } catch (Exception e) {
            String errorMsg = "获取文件信息失败: " + MinioUtils.getFriendlyErrorMessage(e);
            throw new MinioException(errorMsg, e);
        }
    }

    /**
     * 列出指定目录下的文件和文件夹
     *
     * @param bucketName 存储桶名称
     * @param prefix     前缀(目录路径)
     * @param recursive  是否递归查询
     * @return 文件和文件夹列表,以嵌套的TreeNode格式返回
     */
    @Override
    public List<FileTreeNode> listObjects(String bucketName, String prefix, boolean recursive) {
        try {
            if (StringUtils.isBlank(bucketName)) {
                throw new IllegalArgumentException("存储桶名称不能为空");
            }

            // 检查桶是否存在
            boolean bucketExists = minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build());
            if (!bucketExists) {
                throw new IllegalArgumentException("存储桶 '" + bucketName + "' 不存在");
            }

            // 规范化前缀路径
            String normalizedPrefix = processPrefix(prefix);
            
            // 根据是否递归查询选择不同的处理方式
            if (recursive) {
                return listObjectsRecursive(bucketName, normalizedPrefix);
            } else {
                return listObjectsNonRecursive(bucketName, normalizedPrefix);
            }
        } catch (Exception e) {
            throw new RuntimeException("列出对象失败: " + e.getMessage(), e);
        }
    }
    
    /**
     * 处理前缀路径
     * 
     * @param prefix 原始前缀路径
     * @return 处理后的前缀
     */
    private String processPrefix(String prefix) {
        // 处理特殊情况:根目录 "/"
        if (prefix != null && prefix.equals("/")) {
            return null;
        }
        
        // 规范化并处理前缀
        String finalPrefix = "";
        if (StringUtils.isNotBlank(prefix)) {
            // 去除前导斜杠(MinIO不需要前导斜杠)
            finalPrefix = prefix.startsWith("/") ? prefix.substring(1) : prefix;
            // 确保目录以斜杠结尾
            if (!finalPrefix.endsWith("/") && !finalPrefix.isEmpty()) {
                finalPrefix = finalPrefix + "/";
            }
        }
        
        return finalPrefix;
    }
    
    /**
     * 递归模式列出对象
     */
    private List<FileTreeNode> listObjectsRecursive(String bucketName, String prefix) throws Exception {
        // 获取对象列表
        Iterable<Result<Item>> results = minioClient.listObjects(
                ListObjectsArgs.builder()
                        .bucket(bucketName)
                        .prefix(prefix)
                        .recursive(true)
                        .build()
        );

        // 创建节点映射表,用于构建树
        Map<String, FileTreeNode> nodeMap = new HashMap<>();

        // 处理所有对象,构建树形结构
        for (Result<Item> result : results) {
            Item item = result.get();
            String objectName = item.objectName();

            // 跳过空对象
            if (objectName.isEmpty()) {
                continue;
            }

            // 规范化对象路径
            String path = "/" + objectName;
            boolean isDirectory = item.isDir() || objectName.endsWith("/");

            // 如果是目录,移除末尾斜杠以便于处理
            if (isDirectory && path.endsWith("/")) {
                path = PathUtils.ensureNotEndWithSlash(path);
            }

            // 创建当前对象的节点,添加大小和时间信息
            Long size = isDirectory ? 0L : item.size();
            Date lastModified = isDirectory ? null : Date.from(item.lastModified().toInstant());
            processPathAndCreateNodes(bucketName, path, isDirectory, nodeMap, size, lastModified);
        }

        // 构建父子关系并获取根节点
        return buildTreeStructure(nodeMap);
    }
    
    /**
     * 非递归模式列出对象
     */
    private List<FileTreeNode> listObjectsNonRecursive(String bucketName, String prefix) throws Exception {
        List<FileTreeNode> result = new ArrayList<>();
        
        // 使用MinIO的delimiter方式获取当前层级
        Iterable<Result<Item>> levelItems = minioClient.listObjects(
                ListObjectsArgs.builder()
                        .bucket(bucketName)
                        .prefix(prefix)
                        .delimiter("/")
                        .build()
        );
        
        // 处理当前层级对象
        for (Result<Item> itemResult : levelItems) {
            try {
                Item item = itemResult.get();
                String objectName = item.objectName();
                
                // 跳过空对象
                if (objectName.isEmpty()) {
                    continue;
                }
                
                // 将对象名转换为路径
                String path = "/" + objectName;
                boolean isDirectory = item.isDir() || objectName.endsWith("/");
                
                // 如果是目录,移除末尾斜杠
                if (isDirectory && path.endsWith("/")) {
                    path = PathUtils.ensureNotEndWithSlash(path);
                }
                
                // 创建节点
                FileTreeNode node = createNodeFromItem(bucketName, path, item);
                
                // 直接添加到结果列表
                result.add(node);
            } catch (Exception e) {
                log.debug("处理对象时出错: {}", e.getMessage());
                // 跳过单个对象的错误,继续处理其他对象
                continue;
            }
        }
        
        // 如果找不到任何对象,且指定了前缀,可能是因为前缀本身就是一个文件夹
        if (result.isEmpty() && StringUtils.isNotBlank(prefix)) {
            // 检查前缀是否存在
            try {
                // 移除末尾斜杠以检查文件夹对象
                String folderObject = prefix.endsWith("/") ? prefix : prefix + "/";
                minioClient.statObject(
                        StatObjectArgs.builder()
                                .bucket(bucketName)
                                .object(folderObject)
                                .build()
                );
                
                // 如果能够执行到这里,说明文件夹存在但为空
                log.debug("访问的文件夹存在但为空: {}/{}", bucketName, prefix);
            } catch (Exception e) {
                // 前缀不存在或无法访问
                log.debug("找不到指定的前缀: {}/{}", bucketName, prefix);
            }
        }
        
        return result;
    }
    
    /**
     * 从MinIO的Item对象创建FileTreeNode
     */
    private FileTreeNode createNodeFromItem(String bucketName, String path, Item item) {
        boolean isDirectory = item.isDir() || item.objectName().endsWith("/");
        
        // 创建基本节点
        FileTreeNode node = createNode(bucketName, path, isDirectory);
        
        // 如果不是目录,设置文件信息
        if (!isDirectory) {
            // 设置文件大小
            node.setSize(FileUtils.formatFileSize(item.size()));
            
            // 设置时间信息
            Date lastModified = Date.from(item.lastModified().toInstant());
            SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
            String formattedDate = dateFormat.format(lastModified);
            node.setCreateTime(formattedDate);
            node.setUpdateTime(formattedDate);
            
            // 设置URL
            String objectPath = path.startsWith("/") ? path.substring(1) : path;
            node.setUrl(minioConfig.getUrl() + "/" + bucketName + "/" + objectPath);
        } else {
            // 为目录设置空的子节点列表(非递归模式)
            node.setChildren(new ArrayList<>());
        }
        
        return node;
    }

    /**
     * 处理路径并创建所有必要的节点
     */
    private void processPathAndCreateNodes(String bucketName, String path, boolean isDirectory, Map<String, FileTreeNode> nodeMap) {
        processPathAndCreateNodes(bucketName, path, isDirectory, nodeMap, null, null);
    }

    /**
     * 处理路径并创建所有必要的节点(包含文件大小和时间信息)
     */
    private void processPathAndCreateNodes(String bucketName, String path, boolean isDirectory,
                                           Map<String, FileTreeNode> nodeMap, Long size, Date lastModified) {
        // 如果是根路径,跳过
        if (path.equals("/")) {
            return;
        }

        // 如果节点已存在,跳过
        if (nodeMap.containsKey(path)) {
            return;
        }

        // 创建当前节点
        FileTreeNode node = createNode(bucketName, path, isDirectory, size, lastModified);
        nodeMap.put(path, node);

        // 确保所有父目录路径都存在
        String parentPath = getParentPath(path);
        if (parentPath == null || parentPath.equals("/")) {
            return;
        }

        // 递归创建父路径节点
        if (!nodeMap.containsKey(parentPath)) {
            processPathAndCreateNodes(bucketName, parentPath, true, nodeMap);
        }
    }

    /**
     * 构建树结构,建立父子关系
     */
    private List<FileTreeNode> buildTreeStructure(Map<String, FileTreeNode> nodeMap) {
        List<FileTreeNode> rootNodes = new ArrayList<>();

        // 遍历所有节点,建立父子关系
        for (Map.Entry<String, FileTreeNode> entry : nodeMap.entrySet()) {
            String path = entry.getKey();
            FileTreeNode node = entry.getValue();

            // 根节点直接添加到结果
            String parentPath = getParentPath(path);
            if (parentPath == null || parentPath.equals("/")) {
                rootNodes.add(node);
            } else {
                // 非根节点添加到父节点的子节点列表中
                FileTreeNode parentNode = nodeMap.get(parentPath);
                if (parentNode != null) {
                    if (parentNode.getChildren() == null) {
                        parentNode.setChildren(new ArrayList<>());
                    }
                    if (!parentNode.getChildren().contains(node)) {
                        parentNode.getChildren().add(node);
                        parentNode.setHasChildren(true);
                    }
                }
            }
        }

        return rootNodes;
    }

    /**
     * 创建文件树节点
     */
    private FileTreeNode createNode(String bucketName, String path, boolean isDirectory) {
        FileTreeNode node = new FileTreeNode();
        node.setBucketName(bucketName);

        // 提取名称
        String name;
        if (path.equals("/")) {
            name = "/";
        } else {
            int lastSlashIndex = path.lastIndexOf('/');
            name = lastSlashIndex >= 0 ? path.substring(lastSlashIndex + 1) : path;
        }

        node.setName(name);
        node.setPath(path);
        node.setDirectory(isDirectory);
        node.setHasChildren(isDirectory);
        if (isDirectory) {
            node.setChildren(new ArrayList<>());
            node.setSize("0");
        } else {
            node.setChildren(null);
            // 如果不是目录,则设置URL访问路径
            String objectPath = path.startsWith("/") ? path.substring(1) : path;
            node.setUrl(minioConfig.getUrl() + "/" + bucketName + "/" + objectPath);
        }

        return node;
    }

    /**
     * 创建文件树节点,包含大小和时间信息
     */
    private FileTreeNode createNode(String bucketName, String path, boolean isDirectory, Long size, Date lastModified) {
        FileTreeNode node = createNode(bucketName, path, isDirectory);

        // 设置文件大小(格式化为易读形式)
        if (!isDirectory && size != null) {
            node.setSize(FileUtils.formatFileSize(size));
        } else {
            node.setSize("-");
        }

        // 设置时间信息
        if (lastModified != null) {
            SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
            String formattedDate = dateFormat.format(lastModified);
            node.setCreateTime(formattedDate);
            node.setUpdateTime(formattedDate);
        }

        return node;
    }

    /**
     * 获取父路径
     */
    private String getParentPath(String path) {
        if (path.equals("/")) {
            return null;
        }
        int lastSlashIndex = path.lastIndexOf('/');
        if (lastSlashIndex <= 0) {
            return "/";
        }
        return path.substring(0, lastSlashIndex);
    }

    /**
     * 生成默认的存储路径,格式为:/yyyy/MM/dd/
     */
    private String generateDefaultPath() {
        LocalDate now = LocalDate.now();
        return "/" + now.format(DateTimeFormatter.ofPattern("yyyy/MM/dd"));
    }

    /**
     * 下载文件
     *
     * @param bucketName 存储桶名称
     * @param objectName 对象名称
     * @param response   HTTP响应对象
     */
    @Override
    public void downloadFile(String bucketName, String objectName, HttpServletResponse response) {
        try {
            // 检查桶策略,如果是私有桶,需要校验当前用户是否有权限
            Map<String, Object> policyInfo = minioBucketService.getBucketPolicy(bucketName);
            boolean isPrivate = MinioConstants.BucketPolicy.PRIVATE.equals(policyInfo.get("policyType"));

            // 如果是私有桶,记录访问日志
            if (isPrivate) {
                log.info("访问私有存储桶 {} 中的文件 {}", bucketName, objectName);
                // 实际应用中,您可以在此处添加权限验证逻辑
            }

            // 获取文件流和元数据
            Map<String, Object> fileInfo = getObjectInfo(bucketName, objectName);
            InputStream inputStream = (InputStream) fileInfo.get("stream");
            String contentType = (String) fileInfo.getOrDefault("contentType", MediaType.APPLICATION_OCTET_STREAM_VALUE);
            long contentLength = (long) fileInfo.getOrDefault("size", -1L);

            // 提取文件名
            String fileName = objectName.substring(objectName.lastIndexOf("/") + 1);
            fileName = URLEncoder.encode(fileName, StandardCharsets.UTF_8);

            // 设置响应头
            response.setContentType(contentType);
            response.setCharacterEncoding(StandardCharsets.UTF_8.name());
            response.setHeader(HttpHeaders.CONTENT_DISPOSITION, "attachment;filename=" + fileName);
            if (contentLength > 0) {
                response.setContentLengthLong(contentLength);
            }

            // 将文件流写入响应
            IOUtils.copy(inputStream, response.getOutputStream());
            response.flushBuffer();
        } catch (Exception e) {
            log.error("文件下载失败: {}", e.getMessage(), e);
            try {
                response.setContentType(MediaType.TEXT_PLAIN_VALUE);
                response.getWriter().write("文件下载失败: " + e.getMessage());
            } catch (IOException ex) {
                log.error("写入错误响应失败", ex);
            }
        }
    }

    /**
     * 预览文件
     *
     * @param bucketName 存储桶名称
     * @param objectName 对象名称
     * @param response   HTTP响应对象
     */
    @Override
    public void previewFile(String bucketName, String objectName, HttpServletResponse response) {
        try {
            // 检查桶策略,如果是私有桶,需要校验当前用户是否有权限
            Map<String, Object> policyInfo = minioBucketService.getBucketPolicy(bucketName);
            boolean isPrivate = MinioConstants.BucketPolicy.PRIVATE.equals(policyInfo.get("policyType"));

            // 如果是私有桶,记录访问日志
            if (isPrivate) {
                log.info("预览私有存储桶 {} 中的文件 {}", bucketName, objectName);
                // 实际应用中,您可以在此处添加权限验证逻辑
            }

            // 获取文件流和元数据
            Map<String, Object> fileInfo = getObjectInfo(bucketName, objectName);
            InputStream inputStream = (InputStream) fileInfo.get("stream");
            String contentType = (String) fileInfo.getOrDefault("contentType", MediaType.APPLICATION_OCTET_STREAM_VALUE);
            long contentLength = (long) fileInfo.getOrDefault("size", -1L);

            // 设置响应头
            response.setContentType(contentType);
            if (contentLength > 0) {
                response.setContentLengthLong(contentLength);
            }

            // 将文件流写入响应
            IOUtils.copy(inputStream, response.getOutputStream());
            response.flushBuffer();
        } catch (Exception e) {
            log.error("文件预览失败: {}", e.getMessage(), e);
            try {
                response.setContentType(MediaType.TEXT_PLAIN_VALUE);
                response.getWriter().write("文件预览失败: " + e.getMessage());
            } catch (java.io.IOException ex) {
                log.error("写入错误响应失败", ex);
            }
        }
    }
}

11.MinioConfig

package com.ruoyi.file.minio.config;

import io.minio.MinioClient;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * MinIO对象存储配置类
 *
 * @author ruoyi
 */
@Data
@Configuration
@ConfigurationProperties(prefix = "minio")
public class MinioConfig {
    /** MinIO服务地址 */
    private String url;

    /** MinIO访问密钥(Access Key) */
    private String accessKey;

    /** MinIO私有密钥(Secret Key) */
    private String secretKey;

    /** 默认存储桶名称 */
    private String defaultBucketName;

    /** 连接超时时间(单位:秒),默认为10秒 */
    private int connectTimeout = 10;

    /** 写入超时时间(单位:秒),默认为60秒 */
    private int writeTimeout = 100;

    /** 读取超时时间(单位:秒),默认为10秒 */
    private int readTimeout = 20;

    /** 是否自动创建默认存储桶,默认为true */
    private boolean autoCreateBucket = true;

    /** 默认存储桶策略,可选值:read-only(只读)、read-write(读写)、private(私有),默认为read-only */
    private String defaultBucketPolicy = "read-only";

    /** 是否启用图片压缩,默认不启用 */
    private boolean compressEnabled = false;

    /** 是否将压缩后的图片转换为webp格式(true,false) */
    private boolean convertToWebp = false;

    /** 压缩图片质量(0-100的整数,0代表最差,100代表最好) */
    private Integer compressQuality = 30;
    
    /** 图片压缩阈值(单位:字节),超过此大小的图片才会被压缩,默认为100KB */
    private Long compressThreshold = 102400L;

    /**
     * 创建MinIO客户端
     *
     * @return MinIO客户端对象
     */
    @Bean
    public MinioClient minioClient() {
        return MinioClient.builder()
                .endpoint(url)
                .credentials(accessKey, secretKey)
                .build();
    }
}

12.MinioConstants

package com.ruoyi.file.minio.config;

/**
 * MinIO常量定义类
 *
 * @author ruoyi
 */
public class MinioConstants {

    /**
     * 默认文件夹分隔符
     */
    public static final String FOLDER_SEPARATOR = "/";

    /**
     * 默认日期格式(按年月日组织目录)
     */
    public static final String DATE_FORMAT_PATH = "yyyy/MM/dd";
    /**
     * 默认内容类型
     */
    public static final String DEFAULT_CONTENT_TYPE = "application/octet-stream";

    /**
     * 存储桶相关策略常量
     */
    public static class BucketPolicy {
        /**
         * 公共读策略
         */
        public static final String READ_ONLY = "read-only";

        /**
         * 公共读写策略
         */
        public static final String READ_WRITE = "read-write";

        /**
         * 私有策略
         */
        public static final String PRIVATE = "private";

    }
} 

13.MinioBucketController

package com.ruoyi.file.minio.contoller;

import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.file.minio.exception.MinioException;
import com.ruoyi.file.minio.service.IMinioBucketService;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.*;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * MinIO存储桶管理控制器
 *
 * @author ruoyi
 */
@RestController
@RequestMapping("/minio/bucket")
@Tag(name = "MinIO存储桶管理", description = "MinIO存储桶的创建、删除、策略设置等操作")
public class MinioBucketController extends BaseController {

    private static final Logger log = LoggerFactory.getLogger(MinioBucketController.class);

    private final IMinioBucketService minioBucketService;

    public MinioBucketController(IMinioBucketService minioBucketService) {
        this.minioBucketService = minioBucketService;
    }

    /**
     * 获取所有存储桶
     */
    @GetMapping("/list")
    @Schema(description = "获取MinIO中所有的存储桶及其详细信息")
    @ApiResponse(responseCode = "200", description = "获取成功,返回存储桶列表")
    public AjaxResult listBuckets() {
        try {
            List<Map<String, Object>> buckets = minioBucketService.listBuckets();
            return success(buckets);
        } catch (MinioException e) {
            log.error("获取存储桶列表失败: {}", e.getMessage(), e);
            return error(e.getMessage());
        } catch (Exception e) {
            log.error("获取存储桶列表失败: {}", e.getMessage(), e);
            return error("获取存储桶列表失败: " + e.getMessage());
        }
    }

    /**
     * 创建存储桶
     */
    @PostMapping
    @Schema(description = "创建MinIO存储桶,可设置访问策略类型")
    @ApiResponse(responseCode = "200", description = "存储桶创建成功")
    public AjaxResult createBucket(
            @Parameter(description = "存储桶名称(必须符合DNS命名规范)", required = true)
            @RequestParam("bucketName") String bucketName,

            @Parameter(description = "访问策略类型(read-only:只读, read-write:读写, private:私有)", schema = @Schema(defaultValue = "read-only"))
            @RequestParam(value = "policyType", defaultValue = "read-only") String policyType) {
        try {
            minioBucketService.createBucket(bucketName, policyType);
            return success("存储桶创建成功");
        } catch (MinioException e) {
            log.error("创建存储桶失败: {}", e.getMessage(), e);
            return error(e.getMessage());
        } catch (Exception e) {
            log.error("创建存储桶失败: {}", e.getMessage(), e);
            return error("创建存储桶失败: " + e.getMessage());
        }
    }

    /**
     * 获取存储桶访问策略
     */
    @GetMapping("/policy")
    @Schema(description = "获取MinIO存储桶的访问策略信息")
    public AjaxResult getBucketPolicy(
            @Parameter(description = "存储桶名称", required = true)
            @RequestParam("bucketName") String bucketName) {
        try {
            Map<String, Object> policyInfo = minioBucketService.getBucketPolicy(bucketName);
            return success(policyInfo);
        } catch (MinioException e) {
            log.error("获取存储桶访问策略失败: {}", e.getMessage(), e);
            return error(e.getMessage());
        } catch (Exception e) {
            log.error("获取存储桶访问策略失败: {}", e.getMessage(), e);
            return error("获取存储桶访问策略失败: " + e.getMessage());
        }
    }

    /**
     * 设置存储桶的访问策略
     */
    @PutMapping("/policy")
    @Schema(description = "修改MinIO存储桶的访问策略")
    @ApiResponse(responseCode = "200", description = "访问策略设置成功")
    public AjaxResult setBucketPolicy(
            @Parameter(description = "存储桶名称", required = true)
            @RequestParam("bucketName") String bucketName,

            @Parameter(description = "策略类型(read-only:只读, read-write:读写, private:私有)", required = true)
            @RequestParam("policyType") String policyType) {
        try {
            minioBucketService.updateBucketPolicy(bucketName, policyType);
            return success("存储桶访问策略设置成功");
        } catch (MinioException e) {
            log.error("设置存储桶访问策略失败: {}", e.getMessage(), e);
            return error(e.getMessage());
        } catch (Exception e) {
            log.error("设置存储桶访问策略失败: {}", e.getMessage(), e);
            return error("设置存储桶访问策略失败: " + e.getMessage());
        }
    }

    /**
     * 设置存储桶为只读访问策略
     */
    @PutMapping("/policy/read-only")
    @Schema(description = "设置MinIO存储桶为只读访问(公共读取权限)")
    public AjaxResult setBucketReadOnlyPolicy(
            @Parameter(description = "存储桶名称", required = true)
            @RequestParam("bucketName") String bucketName) {
        try {
            minioBucketService.setBucketReadOnlyPolicy(bucketName);
            return success("存储桶已设置为只读访问");
        } catch (MinioException e) {
            log.error("设置存储桶只读策略失败: {}", e.getMessage(), e);
            return error(e.getMessage());
        } catch (Exception e) {
            log.error("设置存储桶只读策略失败: {}", e.getMessage(), e);
            return error("设置存储桶只读策略失败: " + e.getMessage());
        }
    }

    /**
     * 设置存储桶为读写访问策略
     */
    @PutMapping("/policy/read-write")
    @Schema(description = "设置MinIO存储桶为读写访问(公共读写权限)")
    public AjaxResult setBucketReadWritePolicy(
            @Parameter(description = "存储桶名称", required = true)
            @RequestParam("bucketName") String bucketName) {
        try {
            minioBucketService.setBucketReadWritePolicy(bucketName);
            return success("存储桶已设置为读写访问");
        } catch (MinioException e) {
            log.error("设置存储桶读写策略失败: {}", e.getMessage(), e);
            return error(e.getMessage());
        } catch (Exception e) {
            log.error("设置存储桶读写策略失败: {}", e.getMessage(), e);
            return error("设置存储桶读写策略失败: " + e.getMessage());
        }
    }

    /**
     * 设置存储桶为私有访问策略
     */
    @PutMapping("/policy/private")
    @Schema(description = "设置MinIO存储桶为私有访问(仅授权用户可访问)")
    public AjaxResult setBucketPrivatePolicy(
            @Parameter(description = "存储桶名称", required = true)
            @RequestParam("bucketName") String bucketName) {
        try {
            minioBucketService.setBucketPrivatePolicy(bucketName);
            return success("存储桶已设置为私有访问");
        } catch (MinioException e) {
            log.error("设置存储桶私有策略失败: {}", e.getMessage(), e);
            return error(e.getMessage());
        } catch (Exception e) {
            log.error("设置存储桶私有策略失败: {}", e.getMessage(), e);
            return error("设置存储桶私有策略失败: " + e.getMessage());
        }
    }

    /**
     * 获取存储桶统计信息
     */
    @GetMapping("/stats")
    @Schema(description = "获取MinIO存储桶的对象数量和存储大小等统计信息")
    public AjaxResult getBucketStats(
            @Parameter(description = "存储桶名称", required = true)
            @RequestParam("bucketName") String bucketName) {
        try {
            Map<String, Object> stats = minioBucketService.getBucketStats(bucketName);
            return success(stats);
        } catch (MinioException e) {
            log.error("获取存储桶统计信息失败: {}", e.getMessage(), e);
            return error(e.getMessage());
        } catch (Exception e) {
            log.error("获取存储桶统计信息失败: {}", e.getMessage(), e);
            return error("获取存储桶统计信息失败: " + e.getMessage());
        }
    }

    /**
     * 删除存储桶
     */
    @DeleteMapping
    @Schema(description = "删除MinIO存储桶,可选择是否递归删除非空桶")
    @ApiResponse(responseCode = "200", description = "存储桶删除成功")
    public AjaxResult deleteBucket(
            @Parameter(description = "存储桶名称", required = true)
            @RequestParam("bucketName") String bucketName,

            @Parameter(description = "是否递归删除(true表示先清空桶再删除,false表示桶不为空则报错)", schema = @Schema(defaultValue = "false"))
            @RequestParam(value = "recursive", defaultValue = "false") boolean recursive) {
        try {
            minioBucketService.deleteBucket(bucketName, recursive);
            return success("存储桶删除成功");
        } catch (MinioException e) {
            log.error("删除存储桶失败: {}", e.getMessage(), e);
            return error(e.getMessage());
        } catch (Exception e) {
            log.error("删除存储桶失败: {}", e.getMessage(), e);
            return error("删除存储桶失败: " + e.getMessage());
        }
    }

    /**
     * 测试MinIO连接状态
     */
    @GetMapping("/connection/test")
    @Schema(description = "测试MinIO连接状态,检查应用是否能成功连接到MinIO服务器")
    @ApiResponse(responseCode = "200", description = "返回连接测试结果")
    public AjaxResult testConnection() {
        try {
            long startTime = System.currentTimeMillis();
            
            // 尝试执行一个基本操作来测试连接
            minioBucketService.listBuckets();
            
            long endTime = System.currentTimeMillis();
            long responseTime = endTime - startTime;
            
            Map<String, Object> result = new HashMap<>();
            result.put("status", "success");
            result.put("message", "成功连接到MinIO服务");
            result.put("responseTime", responseTime + "ms");
            
            return success(result);
        } catch (MinioException e) {
            log.error("MinIO连接测试失败: {}", e.getMessage(), e);
            Map<String, Object> result = new HashMap<>();
            result.put("status", "failed");
            result.put("message", "MinIO连接失败: " + e.getMessage());
            result.put("error", e.getMessage());
            return AjaxResult.error("MinIO连接失败", result);
        } catch (Exception e) {
            log.error("MinIO连接测试失败: {}", e.getMessage(), e);
            Map<String, Object> result = new HashMap<>();
            result.put("status", "failed");
            result.put("message", "MinIO连接失败: " + e.getMessage());
            result.put("error", e.getMessage());
            return AjaxResult.error("MinIO连接失败", result);
        }
    }
}

14.MinioFileController

package com.ruoyi.file.minio.contoller;

import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.file.minio.exception.MinioException;
import com.ruoyi.file.minio.service.IMinioFileService;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import java.util.Map;

/**
 * MinIO文件管理控制器
 *
 * @author ruoyi
 */
@RestController
@RequestMapping("/minio/file")
@Tag(name = "MinIO文件管理", description = "MinIO文件上传、下载、删除、列表等操作")
public class MinioFileController extends BaseController {

    private static final Logger log = LoggerFactory.getLogger(MinioFileController.class);

    private final IMinioFileService minioFileService;

    public MinioFileController(IMinioFileService minioFileService) {
        this.minioFileService = minioFileService;
    }


    /**
     * 上传文件
     */
    @PostMapping("/upload")
    @Schema(description = "上传文件至MinIO存储,支持指定存储桶和对象路径")
    @ApiResponse(responseCode = "200", description = "上传成功,返回文件访问信息")
    public AjaxResult uploadFile(
            @Parameter(description = "要上传的文件", required = true)
            @RequestParam("file") MultipartFile file,

            @Parameter(description = "存储桶名称,不指定则使用默认桶")
            @RequestParam(value = "bucketName", required = false) String bucketName,

            @Parameter(description = "对象存储路径,不指定则使用默认日期路径")
            @RequestParam(value = "objectPath", required = false) String objectPath) {
        try {
            Map<String, Object> result = minioFileService.uploadFile(file, bucketName, objectPath);
            return success(result);
        } catch (MinioException e) {
            log.error("文件上传失败: {}", e.getMessage(), e);
            return error(e.getMessage());
        } catch (Exception e) {
            log.error("文件上传失败: {}", e.getMessage(), e);
            return error("文件上传失败: " + e.getMessage());
        }
    }

    /**
     * 创建文件夹
     */
    @PostMapping("/folder")
    @Schema(description = "在MinIO存储中创建文件夹结构")
    @ApiResponse(responseCode = "200", description = "文件夹创建成功")
    public AjaxResult createFolder(
            @Parameter(description = "存储桶名称", required = true)
            @RequestParam("bucketName") String bucketName,

            @Parameter(description = "文件夹路径", required = true)
            @RequestParam("folderPath") String folderPath) {
        try {
            minioFileService.createFolder(bucketName, folderPath);
            return success("文件夹创建成功");
        } catch (MinioException e) {
            log.error("创建文件夹失败: {}", e.getMessage(), e);
            return error(e.getMessage());
        } catch (Exception e) {
            log.error("创建文件夹失败: {}", e.getMessage(), e);
            return error("创建文件夹失败: " + e.getMessage());
        }
    }

    /**
     * 删除文件
     */
    @DeleteMapping
    @Schema(description = "删除MinIO存储中的指定文件")
    @ApiResponse(responseCode = "200", description = "文件删除成功")
    public AjaxResult deleteFile(
            @Parameter(description = "存储桶名称", required = true)
            @RequestParam("bucketName") String bucketName,

            @Parameter(description = "对象名称/路径", required = true)
            @RequestParam("objectName") String objectName) {
        try {
            minioFileService.deleteFile(bucketName, objectName);
            return success("文件删除成功");
        } catch (MinioException e) {
            log.error("删除文件失败: {}", e.getMessage(), e);
            return error(e.getMessage());
        } catch (Exception e) {
            log.error("删除文件失败: {}", e.getMessage(), e);
            return error("删除文件失败: " + e.getMessage());
        }
    }

    /**
     * 删除文件夹
     */
    @DeleteMapping("/folder")
    @Schema(description = "删除MinIO存储中的文件夹及其内容")
    @ApiResponse(responseCode = "200", description = "文件夹删除成功")
    public AjaxResult deleteFolder(
            @Parameter(description = "存储桶名称", required = true)
            @RequestParam("bucketName") String bucketName,

            @Parameter(description = "文件夹路径", required = true)
            @RequestParam("folderPath") String folderPath,

            @Parameter(description = "是否递归删除", schema = @Schema(defaultValue = "false"))
            @RequestParam(value = "recursive", defaultValue = "false") boolean recursive) {
        try {
            minioFileService.deleteFolder(bucketName, folderPath, recursive);
            return success("文件夹删除成功");
        } catch (MinioException e) {
            log.error("删除文件夹失败: {}", e.getMessage(), e);
            return error(e.getMessage());
        } catch (Exception e) {
            log.error("删除文件夹失败: {}", e.getMessage(), e);
            return error("删除文件夹失败: " + e.getMessage());
        }
    }

    /**
     * 重命名文件
     */
    @PutMapping
    @Schema(description = "重命名MinIO存储中的文件")
    @ApiResponse(responseCode = "200", description = "文件重命名成功")
    public AjaxResult renameFile(
            @Parameter(description = "存储桶名称", required = true)
            @RequestParam("bucketName") String bucketName,

            @Parameter(description = "原对象名称/路径", required = true)
            @RequestParam("objectName") String objectName,

            @Parameter(description = "新对象名称/路径", required = true)
            @RequestParam("newObjectName") String newObjectName) {
        try {
            minioFileService.renameFile(bucketName, objectName, newObjectName);
            return success("文件重命名成功");
        } catch (MinioException e) {
            log.error("重命名文件失败: {}", e.getMessage(), e);
            return error(e.getMessage());
        } catch (Exception e) {
            log.error("重命名文件失败: {}", e.getMessage(), e);
            return error("重命名文件失败: " + e.getMessage());
        }
    }

    /**
     * 重命名文件夹
     */
    @PutMapping("/folder")
    @Schema(description = "重命名MinIO存储中的文件夹")
    @ApiResponse(responseCode = "200", description = "文件夹重命名成功,返回处理对象数量")
    public AjaxResult renameFolder(
            @Parameter(description = "存储桶名称", required = true)
            @RequestParam("bucketName") String bucketName,

            @Parameter(description = "原文件夹路径", required = true)
            @RequestParam("folderPath") String folderPath,

            @Parameter(description = "新文件夹路径", required = true)
            @RequestParam("newFolderPath") String newFolderPath) {
        try {
            int count = minioFileService.renameFolder(bucketName, folderPath, newFolderPath);
            return success("文件夹重命名成功,共处理 " + count + " 个对象");
        } catch (MinioException e) {
            log.error("重命名文件夹失败: {}", e.getMessage(), e);
            return error(e.getMessage());
        } catch (Exception e) {
            log.error("重命名文件夹失败: {}", e.getMessage(), e);
            return error("重命名文件夹失败: " + e.getMessage());
        }
    }

    /**
     * 下载文件
     */
    @GetMapping("/download")
    @Schema(description = "从MinIO存储下载指定文件")
    public void downloadFile(
            @Parameter(description = "存储桶名称", required = true) 
            @RequestParam("bucketName") String bucketName,
            
            @Parameter(description = "对象名称/路径", required = true) 
            @RequestParam("objectName") String objectName,
            
            HttpServletResponse response) {
        minioFileService.downloadFile(bucketName, objectName, response);
    }

    /**
     * 预览文件
     */
    @GetMapping("/preview")
    @Schema(description = "预览MinIO存储中的文件,直接输出文件内容而不是附件形式")
    public void previewFile(
            @Parameter(description = "存储桶名称", required = true) 
            @RequestParam("bucketName") String bucketName,
            
            @Parameter(description = "对象名称/路径", required = true) 
            @RequestParam("objectName") String objectName,
            
            HttpServletResponse response) {
        minioFileService.previewFile(bucketName, objectName, response);
    }

    /**
     * 列出文件和文件夹
     */
    @GetMapping("/list")
    @Schema(description = "列出MinIO存储中指定路径下的文件和文件夹")
    @ApiResponse(responseCode = "200", description = "获取列表成功,返回文件和文件夹集合")
    public AjaxResult listObjects(
            @Parameter(description = "存储桶名称", required = true)
            @RequestParam("bucketName") String bucketName,

            @Parameter(description = "前缀/目录路径")
            @RequestParam(value = "prefix", required = false) String prefix,

            @Parameter(description = "是否递归查询", schema = @Schema(defaultValue = "false"))
            @RequestParam(value = "recursive", defaultValue = "false") boolean recursive) {
        try {
            return success(minioFileService.listObjects(bucketName, prefix, recursive));
        } catch (MinioException e) {
            log.error("列出对象失败: {}", e.getMessage(), e);
            return error(e.getMessage());
        } catch (Exception e) {
            log.error("列出对象失败: {}", e.getMessage(), e);
            return error("列出对象失败: " + e.getMessage());
        }
    }
}

4.前端vue代码

1.index.vue

<template>
  <div class="app-container">
    <el-card class="main-card">
      <template #header>
        <div class="card-header">
          <span class="title">MinIO文件管理系统</span>
          <div class="header-actions">
            <el-tooltip :content="connectionInfo" placement="left">
              <div class="status-wrapper">
                <el-button :type="connectionStatus.type" plain size="small" @click="testConnection" class="status-button">
                  <span class="status-icon" :class="connectionStatus.type"></span>
                  <span>存储服务:{{ connectionStatus.text }}</span>
                </el-button>
              </div>
            </el-tooltip>
          </div>
        </div>
      </template>
      
      <el-row :gutter="20">
        <!-- 左侧存储桶区域 -->
        <el-col :span="6">
          <bucket-manager @select-bucket="selectBucket" />
        </el-col>
        
        <!-- 右侧文件列表区域 -->
        <el-col :span="18">
          <file-list :bucket-name="activeBucket" />
        </el-col>
      </el-row>
    </el-card>
  </div>
</template>

<script setup>
import { ref, watch, onMounted, computed, onUnmounted } from 'vue'
import { ElMessage } from 'element-plus'
import BucketManager from './bucket.vue'
import FileList from './file-list.vue'
import { getMinioConnection } from '@/api/file/minio'

// 当前选中的存储桶
const activeBucket = ref('')

// MinIO连接状态
const connectionState = ref({
  status: 'checking',  // 'success', 'failed', 'checking'
  message: '正在检查连接状态...',
  responseTime: '',
  error: ''
})

// 计算显示的连接状态样式和文本
const connectionStatus = computed(() => {
  switch (connectionState.value.status) {
    case 'success':
      return { type: 'success', text: '已连接' }
    case 'failed':
      return { type: 'danger', text: '连接失败' }
    case 'checking':
    default:
      return { type: 'info', text: '检查中' }
  }
})

// 计算鼠标悬浮时显示的信息
const connectionInfo = computed(() => {
  if (connectionState.value.status === 'success') {
    return `连接正常 (响应时间: ${connectionState.value.responseTime})`
  } else if (connectionState.value.status === 'failed') {
    return `连接失败: ${connectionState.value.message || '未知错误'}`
  }
  return '正在检查连接状态...'
})

// 选择存储桶
const selectBucket = (bucketName) => {
  console.log('MinIO管理页面 - 收到存储桶选择事件:', bucketName)
  activeBucket.value = bucketName
}

// 监听存储桶变化
watch(activeBucket, (newVal, oldVal) => {
  console.log('MinIO管理页面 - 存储桶变化:', oldVal, ' => ', newVal)
})

// 测试MinIO连接
const testConnection = async () => {
  connectionState.value.status = 'checking'
  connectionState.value.message = '正在检查连接状态...'
  
  try {
    const res = await getMinioConnection()
    if (res.code === 200) {
      connectionState.value = {
        status: 'success',
        message: res.data.message,
        responseTime: res.data.responseTime,
        error: ''
      }
    } else {
      connectionState.value = {
        status: 'failed',
        message: res.msg || '连接失败',
        responseTime: '',
        error: res.data?.error || '未知错误'
      }
    }
  } catch (error) {
    connectionState.value = {
      status: 'failed',
      message: '连接请求失败',
      responseTime: '',
      error: error.message || '网络错误'
    }
    console.error('MinIO连接测试失败:', error)
  }
}

// 定时检查连接状态(每5分钟)
let connectionTimer

onMounted(() => {
  // 组件挂载时检查连接
  testConnection()
  
  // 设置定时任务
  connectionTimer = setInterval(() => {
    testConnection()
  }, 5 * 60 * 1000) // 5分钟
})

// 组件卸载前清除定时器
onUnmounted(() => {
  if (connectionTimer) {
    clearInterval(connectionTimer)
  }
})
</script>

<style scoped>
.app-container {
  padding: 20px;
}

.main-card {
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
  border-radius: 8px;
}

.card-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.title {
  font-size: 18px;
  font-weight: 600;
  color: #303133;
}

.header-actions {
  display: flex;
  gap: 10px;
}

.status-wrapper {
  margin-right: 10px;
}

.status-button {
  display: flex;
  align-items: center;
  gap: 5px;
  padding: 8px 12px;
  min-width: 125px;
}

.status-icon {
  display: inline-block;
  width: 8px;
  height: 8px;
  border-radius: 50%;
  margin-right: 3px;
}

.status-icon.success {
  background-color: #67c23a;
}

.status-icon.danger {
  background-color: #f56c6c;
}

.status-icon.info {
  background-color: #909399;
}
</style> 

2.file-list.vue

<template>
  <div class="file-list-container">
    <el-card>
      <template #header>
        <div class="card-header">
          <div class="file-path-container">
            <div class="bucket-section">
              <span class="label">存储桶:</span>
              <template v-if="bucketName">
                <el-button link type="primary" @click="navigateTo('')">{{ bucketName }}</el-button>
              </template>
              <template v-else>
                <span>请选择存储桶</span>
              </template>
            </div>

            <div class="path-section">
              <span class="label">当前路径:</span>
              <template v-if="currentPath">
                <template v-for="(segment, index) in pathSegments" :key="index">
                  <el-button link type="primary" @click="navigateTo(getPathAtLevel(index))">
                    {{ segment }}
                  </el-button>
                  <span v-if="index < pathSegments.length - 1">/</span>
                </template>
              </template>
              <span v-else>根目录</span>
            </div>
          </div>
          <div class="file-actions">
            <el-button type="primary" :disabled="!bucketName" @click="handleUpload">
              <el-icon><Upload /></el-icon>上传文件
            </el-button>
            <el-button type="success" :disabled="!bucketName" @click="handleCreateFolder">
              <el-icon><FolderAdd /></el-icon>新建文件夹
            </el-button>
            <el-button plain :disabled="!bucketName || currentPath === ''" @click="goBack">
              <el-icon><Back /></el-icon>返回上级
            </el-button>
          </div>
        </div>
      </template>

      <div v-loading="fileLoading">
        <div v-if="!bucketName" class="empty-bucket-message">
          请先选择或创建存储桶
        </div>
        <div v-else>
          <el-upload
            ref="uploadRef"
            :action="uploadUrl"
            :headers="headers"
            :data="uploadData"
            :on-success="handleUploadSuccess"
            :on-error="handleUploadError"
            :before-upload="beforeUpload"
            :show-file-list="false"
            style="display: none;">
          </el-upload>

          <el-table :data="fileList" @row-dblclick="handleRowDblClick" style="width: 100%">
            <el-table-column label="名称" min-width="180">
              <template #default="scope">
                <div class="file-name">
                  <el-icon v-if="scope.row.directory"><Folder /></el-icon>
                  <el-icon v-else><Document /></el-icon>
                  <span class="file-name-text">{{ scope.row.name }}</span>
                </div>
              </template>
            </el-table-column>
            <el-table-column label="类型" width="100">
              <template #default="scope">
                <span>{{ scope.row.directory ? '文件夹' : '文件' }}</span>
              </template>
            </el-table-column>
            <el-table-column label="大小" width="100" align="right">
              <template #default="scope">
                <span>{{ scope.row.size || '-' }}</span>
              </template>
            </el-table-column>
            <el-table-column label="创建时间" width="160">
              <template #default="scope">
                <span>{{ scope.row.createTime || '-' }}</span>
              </template>
            </el-table-column>
            <el-table-column label="修改时间" width="160">
              <template #default="scope">
                <span>{{ scope.row.updateTime || '-' }}</span>
              </template>
            </el-table-column>
            <el-table-column label="操作" width="240" fixed="right">
              <template #default="scope">
                <!-- 文件操作 -->
                <template v-if="!scope.row.directory">
                  <el-tooltip content="下载" placement="top">
                    <el-button link type="primary" @click="handleDownload(scope.row)">
                      <el-icon><Download /></el-icon>
                    </el-button>
                  </el-tooltip>
                  <el-tooltip content="在浏览器预览" placement="top">
                    <el-button link type="primary" @click="handlePreviewFile(scope.row)" v-if="isPreviewable(scope.row)">
                      <el-icon><View /></el-icon>
                    </el-button>
                  </el-tooltip>
                  <el-tooltip content="在浏览器打开" placement="top">
                    <el-button link type="primary" @click="handleOpenInBrowser(scope.row)" v-if="scope.row.url">
                      <el-icon><Link /></el-icon>
                    </el-button>
                  </el-tooltip>
                </template>

                <!-- 文件夹操作 -->
                <template v-if="scope.row.directory">
                  <el-tooltip content="上传文件" placement="top">
                    <el-button link type="primary" @click="handleUploadToFolder(scope.row)">
                      <el-icon><Upload /></el-icon>
                    </el-button>
                  </el-tooltip>
                  <el-tooltip content="新建文件夹" placement="top">
                    <el-button link type="success" @click="handleCreateSubFolder(scope.row)">
                      <el-icon><FolderAdd /></el-icon>
                    </el-button>
                  </el-tooltip>
                </template>

                <!-- 共有操作 -->
                <el-tooltip content="删除" placement="top">
                  <el-button link type="danger" @click="handleDelete(scope.row)">
                    <el-icon><Delete /></el-icon>
                  </el-button>
                </el-tooltip>
              </template>
            </el-table-column>
          </el-table>

          <div v-if="fileList.length === 0" class="empty-text">文件夹为空</div>
        </div>
      </div>
    </el-card>

    <!-- 上传文件对话框 -->
    <el-dialog v-model="uploadDialogVisible" title="上传文件" width="400px">
      <div class="upload-dialog-content">
        <el-upload
          ref="uploadDialogRef"
          :action="uploadUrl"
          :headers="headers"
          :data="uploadData"
          :on-success="handleUploadSuccess"
          :on-error="handleUploadError"
          :before-upload="beforeUpload"
          multiple
          drag>
          <el-icon class="el-icon--upload"><upload-filled /></el-icon>
          <div class="el-upload__text">
            拖拽文件到此处或 <em>点击上传</em>
          </div>
        </el-upload>
      </div>
      <template #footer>
        <div class="dialog-footer">
          <el-button @click="uploadDialogVisible = false">取消</el-button>
          <el-button type="primary" @click="confirmUpload">完成</el-button>
        </div>
      </template>
    </el-dialog>
  </div>
</template>

<script setup>
import { ref, reactive, computed, watch, getCurrentInstance } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
  createFolder, deleteFile, deleteFolder,
  listObjects, uploadFile, previewFile
} from '@/api/file/minio'
import {
  Folder, FolderAdd, Document, Delete,
  Upload, Download, Back, UploadFilled, View, Link
} from '@element-plus/icons-vue'
import { getToken } from '@/utils/auth'

const { proxy } = getCurrentInstance()

const props = defineProps({
  bucketName: {
    type: String,
    default: ''
  }
})

// 文件列表
const fileList = ref([])
const fileLoading = ref(false)
const currentPath = ref('')

// 上传相关
const uploadRef = ref(null)
const uploadDialogRef = ref(null)
const uploadDialogVisible = ref(false)
const uploadData = ref({})
const targetFolderPath = ref('')

// 计算上传URL
const uploadUrl = computed(() => {
  return import.meta.env.VITE_APP_BASE_API + '/minio/file/upload'
})

// 请求头
const headers = computed(() => {
  return {
    Authorization: 'Bearer ' + getToken()
  }
})

// 计算路径段
const pathSegments = computed(() => {
  if (!currentPath.value || typeof currentPath.value !== 'string') return []
  return currentPath.value.split('/')
})

// 获取特定层级的路径
const getPathAtLevel = (level) => {
  if (level < 0 || level >= pathSegments.value.length) return ''
  return pathSegments.value.slice(0, level + 1).join('/')
}

// 导航到指定路径
const navigateTo = (path) => {
  console.log('导航到路径:', path)
  currentPath.value = path || ''
  fetchFileList()
}

// 监听存储桶变化
watch(() => props.bucketName, (newVal) => {
  if (newVal) {
    currentPath.value = ''
    fetchFileList()
  } else {
    fileList.value = []
  }
}, { immediate: true })

// 获取文件列表
const fetchFileList = async () => {
  if (!props.bucketName) return

  try {
    fileLoading.value = true
    console.log('开始获取文件列表:', props.bucketName, currentPath.value)

    // 确保currentPath值是合适的格式
    let prefix = ''
    if (currentPath.value && typeof currentPath.value === 'string') {
      prefix = currentPath.value
      // 只在非空目录路径后添加斜杠,使API正确识别这是一个目录
      if (prefix && !prefix.endsWith('/') && prefix !== '') {
        prefix = prefix + '/'
      }
    }

    // 使用懒加载模式,recursive=false
    console.log('请求参数:', { bucketName: props.bucketName, prefix: prefix, recursive: false })
    const res = await listObjects(props.bucketName, prefix, false)
    console.log('文件列表API响应:', res)

    if (res && res.code === 200) {
      // 将返回的数据转换为组件需要的格式
      fileList.value = (res.data || []).map(item => {
        // 确保item.hasChildren字段可用
        return {
          ...item,
          directory: item.directory || item.isDir || false,
          hasChildren: item.hasChildren || false,
          // 如果API返回的没有name但有path,从path中提取名称
          name: item.name || (item.path ? item.path.split('/').pop() || '' : '')
        }
      })
      console.log('文件列表数据已更新,长度:', fileList.value.length)
    } else {
      console.error('获取文件列表失败:', res)
      ElMessage.error(res && res.msg ? res.msg : '获取文件列表失败')
    }
  } catch (error) {
    console.error('获取文件列表出现异常:', error)
    ElMessage.error('获取文件列表失败:' + (error.message || '未知错误'))
  } finally {
    fileLoading.value = false
  }
}

// 判断文件是否可预览
const isPreviewable = (file) => {
  if (!file || file.directory) return false
  
  // 获取文件扩展名
  const fileName = file.name || ''
  const ext = fileName.split('.').pop().toLowerCase()
  
  // 定义可预览的文件类型
  const previewableTypes = [
    // 图片
    'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg',
    // 文档
    'pdf', 'txt',
    // 视频
    'mp4', 'webm',
    // 音频
    'mp3', 'wav'
  ]
  
  return previewableTypes.includes(ext)
}

// 双击行处理
const handleRowDblClick = (row) => {
  if (row && row.directory) {
    console.log('双击文件夹:', row)

    // 如果文件夹没有子项,就不需要进入
    if (row.hasChildren === false) {
      ElMessage.info('此文件夹为空')
      return
    }

    // 进入文件夹,移除开头的斜杠
    if (!row.path) {
      console.error('文件夹路径不存在')
      return
    }

    let path = row.path
    if (typeof path === 'string' && path.startsWith('/')) {
      path = path.substring(1)
    } else if (typeof path !== 'string') {
      console.error('文件夹路径类型错误')
      return
    }

    console.log('设置新路径:', path)
    currentPath.value = path
    fetchFileList()
  }
}

// 返回上级目录
const goBack = () => {
  if (!currentPath.value || typeof currentPath.value !== 'string') {
    currentPath.value = ''
    return
  }

  const pathParts = currentPath.value.split('/')
  pathParts.pop()
  currentPath.value = pathParts.join('/')
  fetchFileList()
}

// 创建文件夹
const handleCreateFolder = () => {
  createFolderDialog(currentPath.value || '')
}

// 创建子文件夹
const handleCreateSubFolder = (folder) => {
  const folderPath = folder.path.substring(1) // 去掉开头的斜杠
  createFolderDialog(folderPath)
}

// 创建文件夹对话框
const createFolderDialog = (basePath) => {
  if (typeof basePath !== 'string') {
    basePath = ''
  }

  ElMessageBox.prompt('请输入文件夹名称', '新建文件夹', {
    confirmButtonText: '确定',
    cancelButtonText: '取消',
    inputPattern: /^[^\\/:*?"<>|]+$/,
    inputErrorMessage: '文件夹名称不能包含以下字符: \\ / : * ? " < > |'
  }).then(({ value }) => {
    const folderPath = basePath
      ? `${basePath}/${value}`
      : value

    createFolder(props.bucketName, folderPath).then(res => {
      if (res.code === 200) {
        ElMessage.success('文件夹创建成功')
        fetchFileList()
      } else {
        ElMessage.error(res.msg || '创建文件夹失败')
      }
    }).catch(error => {
      console.error('创建文件夹失败', error)
      ElMessage.error('创建文件夹失败')
    })
  }).catch(() => {})
}

// 删除文件或文件夹
const handleDelete = (row) => {
  const isDir = row.directory
  const message = isDir ? `确定要删除文件夹 "${row.name}" 吗?` : `确定要删除文件 "${row.name}" 吗?`

  ElMessageBox.confirm(message, '警告', {
    confirmButtonText: '确定',
    cancelButtonText: '取消',
    type: 'warning'
  }).then(() => {
    if (isDir) {
      deleteFolder(props.bucketName, row.path.substring(1), true).then(res => {
        if (res.code === 200) {
          ElMessage.success('文件夹删除成功')
          fetchFileList()
        } else {
          ElMessage.error(res.msg || '删除文件夹失败')
        }
      }).catch(() => {
        ElMessage.error('删除文件夹失败')
      })
    } else {
      deleteFile(props.bucketName, row.path.substring(1)).then(res => {
        if (res.code === 200) {
          ElMessage.success('文件删除成功')
          fetchFileList()
        } else {
          ElMessage.error(res.msg || '删除文件失败')
        }
      }).catch(() => {
        ElMessage.error('删除文件失败')
      })
    }
  }).catch(() => {})
}

// 上传文件
const handleUpload = () => {
  uploadData.value = {
    bucketName: props.bucketName,
    objectPath: currentPath.value || ''
  }
  targetFolderPath.value = currentPath.value || ''
  uploadDialogVisible.value = true
}

// 上传文件到特定文件夹
const handleUploadToFolder = (folder) => {
  if (!folder || !folder.path) {
    ElMessage.warning('文件夹路径无效')
    return
  }

  let folderPath = folder.path
  if (typeof folderPath === 'string' && folderPath.startsWith('/')) {
    folderPath = folderPath.substring(1) // 去掉开头的斜杠
  } else if (typeof folderPath !== 'string') {
    folderPath = ''
  }

  uploadData.value = {
    bucketName: props.bucketName,
    objectPath: folderPath
  }
  targetFolderPath.value = folderPath
  uploadDialogVisible.value = true
}

// 上传前检查
const beforeUpload = (file) => {
  // 这里可以添加文件类型、大小限制等
  return true
}

// 上传成功回调
const handleUploadSuccess = (response, file, fileList) => {
  if (response.code === 200) {
    ElMessage.success(`文件 ${file.name} 上传成功`)
  } else {
    ElMessage.error(response.msg || `文件 ${file.name} 上传失败`)
  }
}

// 上传失败回调
const handleUploadError = (error, file, fileList) => {
  ElMessage.error(`文件 ${file.name} 上传失败`)
  console.error('上传失败', error)
}

// 确认上传完成
const confirmUpload = () => {
  uploadDialogVisible.value = false
  fetchFileList()
}

// 下载文件
const handleDownload = (row) => {
  const bucketName = props.bucketName
  const objectName = row.path.substring(1)
  const fileName = row.name

  // 创建一个带有认证头的fetch请求
  fetch(`${import.meta.env.VITE_APP_BASE_API}/minio/file/download?bucketName=${bucketName}&objectName=${encodeURIComponent(objectName)}`, {
    method: 'GET',
    headers: {
      Authorization: 'Bearer ' + getToken()
    }
  })
    .then(response => {
      if (!response.ok) {
        throw new Error('下载失败')
      }
      return response.blob()
    })
    .then(blob => {
      // 创建一个临时URL并触发下载
      const url = window.URL.createObjectURL(blob)
      const link = document.createElement('a')
      link.href = url
      link.download = fileName
      document.body.appendChild(link)
      link.click()
      
      // 清理
      window.URL.revokeObjectURL(url)
      document.body.removeChild(link)
    })
    .catch(error => {
      console.error('下载文件失败', error)
      ElMessage.error('下载文件失败')
    })
}

// 在浏览器中预览文件
const handlePreviewFile = (row) => {
  const bucketName = props.bucketName
  const objectName = row.path.substring(1)
  
  // 创建一个带有认证头的新窗口
  const previewWindow = window.open('', '_blank')
  
  // 创建一个带有认证头的fetch请求
  fetch(`${import.meta.env.VITE_APP_BASE_API}/minio/file/preview?bucketName=${bucketName}&objectName=${encodeURIComponent(objectName)}`, {
    method: 'GET',
    headers: {
      Authorization: 'Bearer ' + getToken()
    }
  })
    .then(response => {
      if (!response.ok) {
        throw new Error('预览失败')
      }
      return response.blob()
    })
    .then(blob => {
      // 创建一个临时URL并在新窗口中显示
      const url = window.URL.createObjectURL(blob)
      previewWindow.location.href = url
      
      // 在新窗口关闭时释放URL
      previewWindow.onunload = () => {
        window.URL.revokeObjectURL(url)
      }
    })
    .catch(error => {
      console.error('预览文件失败', error)
      previewWindow.close()
      ElMessage.error('预览文件失败')
    })
}

// 在浏览器中打开文件
const handleOpenInBrowser = (row) => {
  if (row.url) {
    window.open(row.url, '_blank')
  } else {
    ElMessage.warning('该文件没有可访问的URL')
  }
}
</script>

<style scoped>
.card-header {
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
}

.file-path-container {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 10px;
}

.bucket-section, .path-section {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 5px;
}

.label {
  font-weight: bold;
  margin-right: 5px;
}

.file-actions {
  display: flex;
  gap: 10px;
}

.file-name {
  display: flex;
  align-items: flex-start;
  gap: 5px;
}

.file-name-text {
  word-break: break-word;
  word-wrap: break-word;
  white-space: normal;
  line-height: 1.5;
}

.empty-text {
  text-align: center;
  color: #909399;
  padding: 20px 0;
}

.empty-bucket-message {
  text-align: center;
  padding: 50px 0;
  color: #909399;
  font-size: 16px;
}

.upload-dialog-content {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
}

.dialog-footer {
  display: flex;
  justify-content: flex-end;
  margin-top: 20px;
}
</style>

3.bucket.vue

<template>
  <div class="bucket-container">
    <el-card>
      <template #header>
        <div class="card-header">
          <span>存储桶管理</span>
          <el-button type="primary" plain size="small" @click="handleCreateBucket">
            <el-icon><Plus /></el-icon>新建存储桶
          </el-button>
        </div>
      </template>
      <div v-loading="bucketLoading">
        <el-menu :default-active="activeBucket" @select="handleBucketSelect">
          <el-menu-item v-for="bucket in buckets" :key="bucket.name" :index="bucket.name">
            <el-icon><Folder /></el-icon>
            <span>{{ bucket.name }}</span>
            <div class="bucket-actions">
              <el-tooltip :content="getPolicyTypeText(bucket.policyType)" placement="top">
                <el-button link type="primary" @click.stop="handleSetBucketPolicy(bucket)">
                  <el-icon><Setting /></el-icon>
                </el-button>
              </el-tooltip>
              <el-tooltip content="删除存储桶" placement="top">
                <el-button link type="danger" @click.stop="handleDeleteBucket(bucket)">
                  <el-icon><Delete /></el-icon>
                </el-button>
              </el-tooltip>
            </div>
          </el-menu-item>
        </el-menu>
        <div v-if="buckets.length === 0" class="empty-text">暂无存储桶</div>
      </div>
    </el-card>
  </div>
</template>

<script setup>
import { ref, onMounted, h } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { 
  listBuckets, createBucket, setBucketPolicy, deleteBucket,
  setBucketReadOnlyPolicy, setBucketReadWritePolicy, setBucketPrivatePolicy
} from '@/api/file/minio'
import { 
  Plus, Folder, Delete, Setting
} from '@element-plus/icons-vue'

// 存储桶列表
const buckets = ref([])
const activeBucket = ref('')
const bucketLoading = ref(false)

// 定义事件
const emit = defineEmits(['select-bucket'])

// 初始化
onMounted(() => {
  fetchBuckets()
})

// 获取存储桶列表
const fetchBuckets = async () => {
  try {
    bucketLoading.value = true
    console.log('开始获取存储桶列表')
    const res = await listBuckets()
    console.log('存储桶列表API响应:', res)
    
    if (res && res.code === 200) {
      buckets.value = res.data || []
      console.log('存储桶列表数据已更新,长度:', buckets.value.length)
    } else {
      console.error('获取存储桶列表失败:', res)
      ElMessage.error(res && res.msg ? res.msg : '获取存储桶列表失败')
    }
  } catch (error) {
    console.error('获取存储桶列表出现异常:', error)
    ElMessage.error('获取存储桶列表失败:' + (error.message || '未知错误'))
  } finally {
    bucketLoading.value = false
  }
}

// 将策略类型转换为显示文本
const getPolicyTypeText = (policyType) => {
  switch (policyType) {
    case 'read-only':
      return '访问权限: 只读'
    case 'read-write':
      return '访问权限: 读写'
    case 'private':
      return '访问权限: 私有'
    default:
      return '访问权限: 未知'
  }
}

// 创建存储桶
const handleCreateBucket = () => {
  ElMessageBox.prompt('请输入存储桶名称', '创建存储桶', {
    confirmButtonText: '确定',
    cancelButtonText: '取消',
    inputPattern: /^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/,
    inputErrorMessage: '存储桶名称只能包含小写字母、数字和连字符,且必须以字母或数字开头和结尾'
  }).then(({ value }) => {
    console.log('准备创建存储桶:', value)
    createBucket(value, 'read-only').then(res => {
      console.log('创建存储桶响应:', res)
      if (res.code === 200) {
        ElMessage.success('存储桶创建成功')
        fetchBuckets()
      } else {
        ElMessage.error(res.msg || '创建存储桶失败')
      }
    }).catch(error => {
      console.error('创建存储桶失败', error)
      ElMessage.error('创建存储桶失败')
    })
  }).catch(() => {})
}

// 设置存储桶访问策略
const handleSetBucketPolicy = (bucket) => {
  const currentPolicyType = bucket.policyType || 'private'
  
  // 创建自定义对话框样式
  ElMessageBox({
    title: '设置访问权限',
    dangerouslyUseHTMLString: true,
    customClass: 'policy-dialog',
    message: `
      <div class="policy-options">
        <p class="policy-title">请选择存储桶访问权限:</p>
        <div class="radio-group">
          <label class="radio-item ${currentPolicyType === 'read-only' ? 'checked' : ''}">
            <input type="radio" name="policyType" value="read-only" ${currentPolicyType === 'read-only' ? 'checked' : ''}>
            <div class="radio-label">
              <span class="radio-dot"></span>
              <div>
                <strong>只读访问</strong>
                <div class="policy-desc">允许匿名用户获取存储桶中的对象</div>
              </div>
            </div>
          </label>
          <label class="radio-item ${currentPolicyType === 'read-write' ? 'checked' : ''}">
            <input type="radio" name="policyType" value="read-write" ${currentPolicyType === 'read-write' ? 'checked' : ''}>
            <div class="radio-label">
              <span class="radio-dot"></span>
              <div>
                <strong>读写访问</strong>
                <div class="policy-desc">允许匿名用户获取和上传对象</div>
              </div>
            </div>
          </label>
          <label class="radio-item ${currentPolicyType === 'private' ? 'checked' : ''}">
            <input type="radio" name="policyType" value="private" ${currentPolicyType === 'private' ? 'checked' : ''}>
            <div class="radio-label">
              <span class="radio-dot"></span>
              <div>
                <strong>私有访问</strong>
                <div class="policy-desc">只有授权用户可以访问存储桶中的对象</div>
              </div>
            </div>
          </label>
        </div>
      </div>
      <style>
        .policy-dialog .el-message-box__message {
          width: 100%;
          padding: 0;
        }
        .policy-options {
          padding: 5px;
        }
        .policy-title {
          margin-bottom: 15px;
          font-weight: bold;
        }
        .radio-group {
          display: flex;
          flex-direction: column;
          gap: 15px;
          width: 100%;
        }
        .radio-item {
          display: flex;
          align-items: center;
          padding: 10px 15px;
          border-radius: 5px;
          border: 1px solid #e4e7ed;
          cursor: pointer;
          transition: all 0.3s;
        }
        .radio-item:hover {
          border-color: #409EFF;
          background-color: #F5F7FA;
        }
        .radio-item.checked {
          border-color: #409EFF;
          background-color: #ECF5FF;
        }
        .radio-item input {
          position: absolute;
          opacity: 0;
        }
        .radio-label {
          display: flex;
          align-items: center;
          width: 100%;
        }
        .radio-dot {
          width: 18px;
          height: 18px;
          border-radius: 50%;
          border: 1px solid #DCDFE6;
          margin-right: 10px;
          position: relative;
        }
        .checked .radio-dot {
          border-color: #409EFF;
        }
        .checked .radio-dot:after {
          content: "";
          position: absolute;
          width: 10px;
          height: 10px;
          background-color: #409EFF;
          border-radius: 50%;
          top: 3px;
          left: 3px;
        }
        .policy-desc {
          color: #909399;
          font-size: 12px;
          margin-top: 2px;
        }
      </style>
    `,
    width: '450px',
    showCancelButton: true,
    confirmButtonText: '确定',
    cancelButtonText: '取消',
    beforeClose: (action, instance, done) => {
      if (action === 'confirm') {
        // 获取选中的策略
        const selectedEl = document.querySelector('input[name="policyType"]:checked')
        const selectedPolicy = selectedEl ? selectedEl.value : currentPolicyType
        
        // 更新选中状态的CSS
        document.querySelectorAll('.radio-item').forEach(item => {
          item.classList.remove('checked')
        })
        selectedEl?.closest('.radio-item')?.classList.add('checked')
        
        // 只有当选择了不同的策略才执行API调用
        if (selectedPolicy && selectedPolicy !== currentPolicyType) {
          console.log('准备设置存储桶权限:', bucket.name, '改为', selectedPolicy)
          
          let apiCall
          switch (selectedPolicy) {
            case 'read-only':
              apiCall = setBucketReadOnlyPolicy(bucket.name)
              break
            case 'read-write':
              apiCall = setBucketReadWritePolicy(bucket.name)
              break
            case 'private':
              apiCall = setBucketPrivatePolicy(bucket.name)
              break
            default:
              apiCall = setBucketPolicy(bucket.name, selectedPolicy)
          }
          
          apiCall.then(res => {
            console.log('设置存储桶权限响应:', res)
            if (res.code === 200) {
              ElMessage.success('访问权限设置成功')
              fetchBuckets()
            } else {
              ElMessage.error(res.msg || '设置访问权限失败')
            }
          }).catch(error => {
            console.error('设置访问权限失败', error)
            ElMessage.error('设置访问权限失败')
          })
        }
      }
      done()
    }
  })
  
  // 添加事件监听,点击单选按钮时更新样式
  setTimeout(() => {
    const radioItems = document.querySelectorAll('.radio-item')
    radioItems.forEach(item => {
      item.addEventListener('click', () => {
        // 移除所有checked类
        radioItems.forEach(i => i.classList.remove('checked'))
        // 为当前项添加checked类
        item.classList.add('checked')
        // 选中对应的radio
        const radio = item.querySelector('input[type="radio"]')
        if (radio) radio.checked = true
      })
    })
  }, 100)
}

// 删除存储桶
const handleDeleteBucket = (bucket) => {
  ElMessageBox.confirm(`确定要删除存储桶 "${bucket.name}" 吗?`, '警告', {
    confirmButtonText: '确定',
    cancelButtonText: '取消',
    type: 'warning'
  }).then(() => {
    console.log('准备删除存储桶:', bucket.name)
    deleteBucket(bucket.name, false).then(res => {
      console.log('删除存储桶响应:', res)
      if (res.code === 200) {
        ElMessage.success('存储桶删除成功')
        if (activeBucket.value === bucket.name) {
          activeBucket.value = ''
          emit('select-bucket', '')
        }
        fetchBuckets()
      } else if (res.msg && res.msg.includes('不为空')) {
        ElMessageBox.confirm(
          '存储桶不为空,是否强制删除?',
          '警告',
          {
            confirmButtonText: '确定',
            cancelButtonText: '取消',
            type: 'warning'
          }
        ).then(() => {
          console.log('准备强制删除存储桶:', bucket.name)
          deleteBucket(bucket.name, true).then(res => {
            console.log('强制删除存储桶响应:', res)
            if (res.code === 200) {
              ElMessage.success('存储桶删除成功')
              if (activeBucket.value === bucket.name) {
                activeBucket.value = ''
                emit('select-bucket', '')
              }
              fetchBuckets()
            } else {
              ElMessage.error(res.msg || '强制删除存储桶失败')
            }
          }).catch(() => {
            ElMessage.error('强制删除存储桶失败')
          })
        }).catch(() => {})
      } else {
        ElMessage.error(res.msg || '删除存储桶失败')
      }
    }).catch(() => {
      ElMessage.error('删除存储桶失败')
    })
  }).catch(() => {})
}

// 选择存储桶
const handleBucketSelect = (bucketName) => {
  console.log('选择存储桶:', bucketName)
  activeBucket.value = bucketName
  emit('select-bucket', bucketName)
}
</script>

<style scoped>
.card-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.bucket-actions {
  position: absolute;
  right: 20px;
  display: none;
}

.el-menu-item:hover .bucket-actions {
  display: flex;
}

.empty-text {
  text-align: center;
  color: #909399;
  padding: 20px 0;
}
</style> 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值