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>
1441

被折叠的 条评论
为什么被折叠?



