吃透 MinIO:从底层架构到全场景文件上传下载实战,一篇搞定企业级对象存储

简介: MinIO是100%兼容S3协议的高性能、云原生对象存储系统,解决非结构化数据存储痛点。本文详解其架构原理、单/分布式部署、Spring Boot整合(含上传/下载/分片/秒传)、权限安全与生产最佳实践,助力企业构建高可用、低成本自建存储方案。

在互联网业务的全链路开发中,非结构化数据(图片、视频、文档、安装包等)的存储与访问始终是核心需求之一。传统的本地文件存储、FTP服务器在扩展性、高可用、访问性能上难以匹配云原生时代的业务诉求,而公有云对象存储又存在数据主权、成本、内网访问性能等问题。MinIO作为一款高性能、云原生、100%兼容S3协议的对象存储系统,凭借轻量部署、极简运维、无限扩展的特性,成为企业级自建对象存储的首选方案。

一、MinIO核心底层逻辑与架构解析

1.1 对象存储的核心定义与三大存储类型的本质区别

对象存储的核心存储单元是对象,每个对象由三部分组成:

  • 对象数据:文件本身的二进制内容
  • 元数据:描述对象的键值对信息,比如Content-Type、文件大小、MD5、自定义标签等
  • 唯一标识符:全局唯一的对象名,通过该标识符可精准定位对象,无需依赖树形目录结构

为了清晰区分易混淆的存储类型,这里对三大主流存储方案做核心对比:

存储类型 核心结构 访问方式 核心优势 典型适用场景
块存储 固定大小的块(通常4KB) 裸设备挂载 极低延迟、随机读写性能强 数据库、虚拟机磁盘
文件存储 树形目录结构 POSIX协议、SMB/NFS 多节点共享、兼容传统文件操作 办公文件共享、日志存储
对象存储 扁平化键值对结构 HTTP/HTTPS的RESTful API 无限横向扩展、海量数据低成本存储、元数据能力强 图片/视频/文档等非结构化数据、静态资源托管、数据备份

1.2 MinIO的核心架构设计

MinIO的设计核心理念是极简主义,一切皆对象,完全兼容S3协议,原生适配云原生环境。其核心架构如下:

MinIO分为单机部署和分布式部署两种模式,生产环境优先选择分布式部署,核心组件说明如下:

  • 节点(Server Node):运行MinIO服务的服务器实例,集群内所有节点完全对等,无中心节点、无单点故障。
  • 驱动器(Drive):节点上挂载的磁盘,是MinIO数据存储的最小物理单元,生产环境建议每个驱动器对应独立的物理磁盘。
  • 桶(Bucket):对象的顶层容器,相当于文件系统的根目录,全局唯一,用于隔离不同业务的对象数据。
  • 纠删码(Erasure Code):MinIO高可用的核心,基于Reed-Solomon算法实现,将对象切分为N个数据块和M个奇偶校验块,只要剩余可用块总数≥N,就能完整恢复数据。相比三副本机制,存储空间利用率提升一倍以上。
  • 分布式一致性:基于严格的读后写、写后读一致性模型,所有写操作必须写入超过半数的节点后才会返回成功,确保数据的强一致性。

1.3 MinIO的核心特性

  • 100% S3协议兼容:完全兼容亚马逊S3 API,现有基于S3开发的应用可无缝迁移到MinIO,无需修改业务代码。
  • 极致性能:基于Go语言开发,原生支持高并发,在标准硬件上可实现每秒数GB的吞吐量,延迟低至毫秒级。
  • 极简部署:单个二进制文件即可启动服务,支持Docker、Kubernetes、裸机等多种部署方式,运维成本极低。
  • 无限扩展:通过集群扩容可实现EB级别的存储容量扩展,性能随节点数量线性增长。
  • 丰富的企业级特性:支持版本控制、生命周期管理、WORM(一次写入多次读取)、数据加密、配额管理、事件通知等。

二、MinIO环境快速搭建

2.1 单机模式部署(开发测试环境)

单机模式适合开发测试场景,单节点单驱动器,无纠删码数据保护,执行以下Docker命令即可一键启动:

docker run -d \
 --name minio \
 -p 9000:9000 \
 -p 9001:9001 \
 -v /data/minio/data:/data \
 -v /data/minio/config:/root/.minio \
 -e "MINIO_ROOT_USER=minioadmin" \
 -e "MINIO_ROOT_PASSWORD=minioadmin@2026" \
 minio/minio server /data --console-address ":9001"

参数说明:

  • 9000端口:MinIO API服务端口,应用程序通过该端口与MinIO交互
  • 9001端口:MinIO Web控制台端口,用于可视化管理桶、对象、权限等配置
  • MINIO_ROOT_USER/MINIO_ROOT_PASSWORD:管理员账号密码,生产环境需设置高强度密码
  • 目录挂载:将容器内的数据目录挂载到宿主机,实现数据持久化

部署完成后,访问http://宿主机IP:9001,输入管理员账号密码即可进入控制台,创建业务桶(如demo-bucket)并配置访问策略。

2.2 分布式模式部署

分布式模式实现多节点多驱动器部署,通过纠删码提供数据高可用,无单点故障。以下是4节点8驱动器的Docker Compose部署方案,每个节点挂载2个独立驱动器:

version: '3.8'
services:
 minio1:
   image: minio/minio:latest
   hostname: minio1
   ports:
     - "9000:9000"
     - "9001:9001"
   volumes:
     - /data/minio1/drive1:/data1
     - /data/minio1/drive2:/data2
   environment:
     MINIO_ROOT_USER: minioadmin
     MINIO_ROOT_PASSWORD: minioadmin@2026
   command: server http://minio{1...4}/data{1...2} --console-address ":9001"
   healthcheck:
     test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
     interval: 30s
     timeout: 20s
     retries: 3
 minio2:
   image: minio/minio:latest
   hostname: minio2
   ports:
     - "9002:9000"
     - "9003:9001"
   volumes:
     - /data/minio2/drive1:/data1
     - /data/minio2/drive2:/data2
   environment:
     MINIO_ROOT_USER: minioadmin
     MINIO_ROOT_PASSWORD: minioadmin@2026
   command: server http://minio{1...4}/data{1...2} --console-address ":9001"
   healthcheck:
     test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
     interval: 30s
     timeout: 20s
     retries: 3
 minio3:
   image: minio/minio:latest
   hostname: minio3
   ports:
     - "9004:9000"
     - "9005:9001"
   volumes:
     - /data/minio3/drive1:/data1
     - /data/minio3/drive2:/data2
   environment:
     MINIO_ROOT_USER: minioadmin
     MINIO_ROOT_PASSWORD: minioadmin@2026
   command: server http://minio{1...4}/data{1...2} --console-address ":9001"
   healthcheck:
     test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
     interval: 30s
     timeout: 20s
     retries: 3
 minio4:
   image: minio/minio:latest
   hostname: minio4
   ports:
     - "9006:9000"
     - "9007:9001"
   volumes:
     - /data/minio4/drive1:/data1
     - /data/minio4/drive2:/data2
   environment:
     MINIO_ROOT_USER: minioadmin
     MINIO_ROOT_PASSWORD: minioadmin@2026
   command: server http://minio{1...4}/data{1...2} --console-address ":9001"
   healthcheck:
     test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
     interval: 30s
     timeout: 20s
     retries: 3

部署说明:

  • 4个节点共8个驱动器,MinIO会自动划分为4个数据块+4个校验块,最多可容忍4个驱动器同时故障,数据不丢失
  • 所有节点的管理员账号密码必须完全一致,否则集群无法正常启动
  • 生产环境建议每个驱动器挂载独立的物理磁盘,避免单磁盘故障导致多个驱动器失效
  • 集群前端需配置负载均衡,将请求均匀分发到各个节点,提升并发性能

三、Spring Boot项目整合MinIO

3.1 项目依赖配置

基于Spring Boot 3.2.5、JDK 17构建项目,pom.xml配置如下:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">

   <modelVersion>4.0.0</modelVersion>
   <parent>
       <groupId>org.springframework.boot</groupId>
       <artifactId>spring-boot-starter-parent</artifactId>
       <version>3.2.5</version>
       <relativePath/>
   </parent>
   <groupId>com.jam</groupId>
   <artifactId>minio-demo</artifactId>
   <version>0.0.1-SNAPSHOT</version>
   <name>minio-demo</name>
   <description>MinIO file upload and download demo project</description>
   <properties>
       <java.version>17</java.version>
       <minio.version>8.5.12</minio.version>
       <mybatis-plus.version>3.5.6</mybatis-plus.version>
       <fastjson2.version>2.0.52</fastjson2.version>
       <guava.version>33.1.0-jre</guava.version>
       <lombok.version>1.18.30</lombok.version>
   </properties>
   <dependencies>
       <dependency>
           <groupId>org.springframework.boot</groupId>
           <artifactId>spring-boot-starter-web</artifactId>
       </dependency>
       <dependency>
           <groupId>org.springframework.boot</groupId>
           <artifactId>spring-boot-starter-jdbc</artifactId>
       </dependency>
       <dependency>
           <groupId>org.springframework.boot</groupId>
           <artifactId>spring-boot-starter-validation</artifactId>
       </dependency>
       <dependency>
           <groupId>org.springframework.boot</groupId>
           <artifactId>spring-boot-starter-aop</artifactId>
       </dependency>
       <dependency>
           <groupId>io.minio</groupId>
           <artifactId>minio</artifactId>
           <version>${minio.version}</version>
       </dependency>
       <dependency>
           <groupId>com.mysql</groupId>
           <artifactId>mysql-connector-j</artifactId>
           <scope>runtime</scope>
       </dependency>
       <dependency>
           <groupId>com.baomidou</groupId>
           <artifactId>mybatis-plus-boot-starter</artifactId>
           <version>${mybatis-plus.version}</version>
       </dependency>
       <dependency>
           <groupId>org.springdoc</groupId>
           <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
           <version>2.5.0</version>
       </dependency>
       <dependency>
           <groupId>com.alibaba.fastjson2</groupId>
           <artifactId>fastjson2</artifactId>
           <version>${fastjson2.version}</version>
       </dependency>
       <dependency>
           <groupId>com.google.guava</groupId>
           <artifactId>guava</artifactId>
           <version>${guava.version}</version>
       </dependency>
       <dependency>
           <groupId>org.projectlombok</groupId>
           <artifactId>lombok</artifactId>
           <version>${lombok.version}</version>
           <scope>provided</scope>
       </dependency>
       <dependency>
           <groupId>org.springframework.boot</groupId>
           <artifactId>spring-boot-starter-test</artifactId>
           <scope>test</scope>
       </dependency>
   </dependencies>
   <build>
       <plugins>
           <plugin>
               <groupId>org.springframework.boot</groupId>
               <artifactId>spring-boot-maven-plugin</artifactId>
               <configuration>
                   <excludes>
                       <exclude>
                           <groupId>org.projectlombok</groupId>
                           <artifactId>lombok</artifactId>
                       </exclude>
                   </excludes>
               </configuration>
           </plugin>
       </plugins>
   </build>
</project>

3.2 项目配置文件

application.yml配置文件,包含MinIO、数据源、MyBatis-Plus、Swagger3等核心配置:

server:
 port: 8080
 servlet:
   multipart:
     max-file-size: 100MB
     max-request-size: 100MB
spring:
 application:
   name: minio-demo
 datasource:
   driver-class-name: com.mysql.cj.jdbc.Driver
   url: jdbc:mysql://127.0.0.1:3306/minio_demo?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
   username: root
   password: root@2026
 jackson:
   date-format: yyyy-MM-dd HH:mm:ss
   time-zone: Asia/Shanghai
minio:
 endpoint: http://127.0.0.1:9000
 access-key: minioadmin
 secret-key: minioadmin@2026
 bucket-name: demo-bucket
mybatis-plus:
 mapper-locations: classpath*:/mapper/**/*.xml
 configuration:
   map-underscore-to-camel-case: true
   log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
 global-config:
   db-config:
     id-type: assign_id
     logic-delete-field: isDeleted
     logic-delete-value: 1
     logic-not-delete-value: 0
springdoc:
 api-docs:
   enabled: true
   path: /v3/api-docs
 swagger-ui:
   enabled: true
   path: /swagger-ui.html
   tags-sorter: alpha
   operations-sorter: alpha

3.3 数据库表结构设计

创建文件元数据表,存储上传文件的核心信息,用于业务查询与管理,MySQL 8.0执行以下SQL:

CREATE TABLE `file_info` (
 `id` bigint NOT NULL COMMENT '主键ID',
 `file_name` varchar(255) NOT NULL COMMENT '存储文件名(唯一)',
 `original_file_name` varchar(255) NOT NULL COMMENT '原始文件名',
 `bucket_name` varchar(100) NOT NULL COMMENT '存储桶名称',
 `file_path` varchar(500) NOT NULL COMMENT '文件存储路径',
 `file_size` bigint NOT NULL COMMENT '文件大小(字节)',
 `content_type` varchar(100) DEFAULT NULL COMMENT '文件MIME类型',
 `md5` varchar(32) NOT NULL COMMENT '文件MD5值',
 `is_deleted` tinyint NOT NULL DEFAULT '0' COMMENT '是否删除 0-未删除 1-已删除',
 `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
 `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
 PRIMARY KEY (`id`),
 UNIQUE KEY `uk_file_name` (`file_name`),
 KEY `idx_md5` (`md5`),
 KEY `idx_bucket_name` (`bucket_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='文件信息表';

3.4 核心常量定义

提取魔法值为常量,提升代码可维护性与可读性:

package com.jam.demo.constant;

/**
* MinIO相关常量
*
* @author ken
*/

public final class MinioConstants {

   private MinioConstants() {
   }

   /**
    * 默认分片大小 5MB
    */

   public static final long DEFAULT_PART_SIZE = 5 * 1024 * 1024L;

   /**
    * 最大文件大小 1GB
    */

   public static final long MAX_FILE_SIZE = 1024 * 1024 * 1024L;

   /**
    * 预签名URL默认过期时间 1小时(秒)
    */

   public static final int DEFAULT_PRESIGNED_EXPIRY = 3600;

   /**
    * 最小分片数量
    */

   public static final int MIN_PART_COUNT = 1;

   /**
    * 最大分片数量
    */

   public static final int MAX_PART_COUNT = 10000;

   /**
    * 公共读桶策略模板
    */

   public static final String PUBLIC_READ_POLICY = """
           {
               "
Version": "2012-10-17",
               "
Statement": [
                   {
                       "
Effect": "Allow",
                       "
Principal": "*",
                       "
Action": ["s3:GetObject"],
                       "
Resource": ["arn:aws:s3:::%s/*"]
                   }
               ]
           }
           """;
}

3.5 统一异常与响应处理

自定义业务异常类

package com.jam.demo.exception;

import lombok.Getter;

/**
* 业务异常
*
* @author ken
*/

@Getter
public class BusinessException extends RuntimeException {

   private final int code;

   public BusinessException(int code, String message) {
       super(message);
       this.code = code;
   }

   public BusinessException(String message) {
       super(message);
       this.code = 500;
   }
}

统一响应结果类

package com.jam.demo.common;

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

/**
* 统一响应结果
*
* @author ken
*/

@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "统一响应结果")
public class Result<T> {

   @Schema(description = "响应码", example = "200")
   private int code;

   @Schema(description = "响应信息", example = "操作成功")
   private String message;

   @Schema(description = "响应数据")
   private T data;

   public static <T> Result<T> success(T data) {
       return new Result<>(200, "操作成功", data);
   }

   public static <T> Result<T> success(String message, T data) {
       return new Result<>(200, message, data);
   }

   public static <T> Result<T> fail(String message) {
       return new Result<>(500, message, null);
   }

   public static <T> Result<T> fail(int code, String message) {
       return new Result<>(code, message, null);
   }
}

全局异常处理器

package com.jam.demo.exception;

import com.jam.demo.common.Result;
import io.minio.errors.MinioException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.validation.BindException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.util.StringUtils;

import java.util.Objects;

/**
* 全局异常处理器
*
* @author ken
*/

@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

   @ExceptionHandler(BusinessException.class)
   public Result<Void> handleBusinessException(BusinessException e)
{
       log.error("业务异常:", e);
       return Result.fail(e.getCode(), e.getMessage());
   }

   @ExceptionHandler(MinioException.class)
   public Result<Void> handleMinioException(MinioException e)
{
       log.error("MinIO操作异常:", e);
       return Result.fail("文件存储操作失败,请稍后重试");
   }

   @ExceptionHandler(MethodArgumentNotValidException.class)
   @ResponseStatus(HttpStatus.BAD_REQUEST)
   public Result<Void> handleValidException(MethodArgumentNotValidException e)
{
       log.error("参数校验异常:", e);
       String message = Objects.requireNonNull(e.getBindingResult().getFieldError()).getDefaultMessage();
       return Result.fail(400, StringUtils.hasText(message) ? message : "参数校验失败");
   }

   @ExceptionHandler(BindException.class)
   @ResponseStatus(HttpStatus.BAD_REQUEST)
   public Result<Void> handleBindException(BindException e)
{
       log.error("参数绑定异常:", e);
       String message = Objects.requireNonNull(e.getBindingResult().getFieldError()).getDefaultMessage();
       return Result.fail(400, StringUtils.hasText(message) ? message : "参数绑定失败");
   }

   @ExceptionHandler(Exception.class)
   public Result<Void> handleException(Exception e)
{
       log.error("系统异常:", e);
       return Result.fail("系统内部错误,请稍后重试");
   }
}

3.6 实体类与Mapper层

文件信息实体类

package com.jam.demo.entity;

import com.baomidou.mybatisplus.annotation.*;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;

import java.time.LocalDateTime;

/**
* 文件信息实体
*
* @author ken
*/

@Data
@TableName("file_info")
@Schema(description = "文件信息实体")
public class FileInfo {

   @Schema(description = "主键ID")
   @TableId(type = IdType.ASSIGN_ID)
   private Long id;

   @Schema(description = "存储文件名(唯一)")
   private String fileName;

   @Schema(description = "原始文件名")
   private String originalFileName;

   @Schema(description = "存储桶名称")
   private String bucketName;

   @Schema(description = "文件存储路径")
   private String filePath;

   @Schema(description = "文件大小(字节)")
   private Long fileSize;

   @Schema(description = "文件MIME类型")
   private String contentType;

   @Schema(description = "文件MD5值")
   private String md5;

   @Schema(description = "是否删除 0-未删除 1-已删除")
   @TableLogic
   private Integer isDeleted;

   @Schema(description = "创建时间")
   @TableField(fill = FieldFill.INSERT)
   private LocalDateTime createTime;

   @Schema(description = "更新时间")
   @TableField(fill = FieldFill.INSERT_UPDATE)
   private LocalDateTime updateTime;
}

Mapper接口

package com.jam.demo.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.jam.demo.entity.FileInfo;
import org.apache.ibatis.annotations.Mapper;

/**
* 文件信息Mapper
*
* @author ken
*/

@Mapper
public interface FileInfoMapper extends BaseMapper<FileInfo> {
}

3.7 MinIO核心配置与工具类

MinIO配置类

package com.jam.demo.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;
import org.springframework.util.StringUtils;
import com.jam.demo.exception.BusinessException;

/**
* MinIO配置类
*
* @author ken
*/

@Data
@Configuration
@ConfigurationProperties(prefix = "minio")
public class MinioConfig {

   private String endpoint;

   private String accessKey;

   private String secretKey;

   private String bucketName;

   /**
    * 构建MinioClient客户端并注入Spring容器
    */

   @Bean
   public MinioClient minioClient() {
       if (!StringUtils.hasText(endpoint)) {
           throw new BusinessException("MinIO endpoint不能为空");
       }
       if (!StringUtils.hasText(accessKey)) {
           throw new BusinessException("MinIO accessKey不能为空");
       }
       if (!StringUtils.hasText(secretKey)) {
           throw new BusinessException("MinIO secretKey不能为空");
       }
       return MinioClient.builder()
               .endpoint(endpoint)
               .credentials(accessKey, secretKey)
               .build();
   }
}

MinIO操作工具类

package com.jam.demo.util;

import com.jam.demo.constant.MinioConstants;
import com.jam.demo.exception.BusinessException;
import io.minio.*;
import io.minio.http.Method;
import io.minio.messages.Part;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;

import java.io.InputStream;
import java.util.concurrent.TimeUnit;

/**
* MinIO操作工具类
*
* @author ken
*/

@Slf4j
public final class MinioUtil {

   private MinioUtil() {
   }

   /**
    * 检查桶是否存在
    *
    * @param minioClient MinIO客户端
    * @param bucketName  桶名称
    * @return 桶是否存在
    */

   public static boolean bucketExists(MinioClient minioClient, String bucketName) {
       if (!StringUtils.hasText(bucketName)) {
           throw new BusinessException("桶名称不能为空");
       }
       try {
           return minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build());
       } catch (Exception e) {
           log.error("检查桶是否存在失败,bucketName:{}", bucketName, e);
           throw new BusinessException("检查桶是否存在失败");
       }
   }

   /**
    * 创建桶
    *
    * @param minioClient MinIO客户端
    * @param bucketName  桶名称
    */

   public static void createBucket(MinioClient minioClient, String bucketName) {
       if (bucketExists(minioClient, bucketName)) {
           return;
       }
       try {
           minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucketName).build());
       } catch (Exception e) {
           log.error("创建桶失败,bucketName:{}", bucketName, e);
           throw new BusinessException("创建桶失败");
       }
   }

   /**
    * 设置桶公共读策略
    *
    * @param minioClient MinIO客户端
    * @param bucketName  桶名称
    */

   public static void setBucketPublicReadPolicy(MinioClient minioClient, String bucketName) {
       if (!StringUtils.hasText(bucketName)) {
           throw new BusinessException("桶名称不能为空");
       }
       try {
           String policy = String.format(MinioConstants.PUBLIC_READ_POLICY, bucketName);
           minioClient.setBucketPolicy(SetBucketPolicyArgs.builder().bucket(bucketName).config(policy).build());
       } catch (Exception e) {
           log.error("设置桶策略失败,bucketName:{}", bucketName, e);
           throw new BusinessException("设置桶策略失败");
       }
   }

   /**
    * 上传文件
    *
    * @param minioClient  MinIO客户端
    * @param bucketName   桶名称
    * @param objectName   对象名称(存储文件名)
    * @param inputStream  文件输入流
    * @param contentType  文件MIME类型
    */

   public static void uploadFile(MinioClient minioClient, String bucketName, String objectName,
                                  InputStream inputStream, String contentType)
{
       if (!bucketExists(minioClient, bucketName)) {
           createBucket(minioClient, bucketName);
       }
       try {
           minioClient.putObject(PutObjectArgs.builder()
                   .bucket(bucketName)
                   .object(objectName)
                   .stream(inputStream, inputStream.available(), -1)
                   .contentType(contentType)
                   .build());
       } catch (Exception e) {
           log.error("上传文件失败,bucketName:{},objectName:{}", bucketName, objectName, e);
           throw new BusinessException("上传文件失败");
       }
   }

   /**
    * 下载文件
    *
    * @param minioClient MinIO客户端
    * @param bucketName  桶名称
    * @param objectName  对象名称
    * @return 文件输入流
    */

   public static InputStream downloadFile(MinioClient minioClient, String bucketName, String objectName) {
       if (!bucketExists(minioClient, bucketName)) {
           throw new BusinessException("桶不存在");
       }
       try {
           return minioClient.getObject(GetObjectArgs.builder()
                   .bucket(bucketName)
                   .object(objectName)
                   .build());
       } catch (Exception e) {
           log.error("下载文件失败,bucketName:{},objectName:{}", bucketName, objectName, e);
           throw new BusinessException("下载文件失败");
       }
   }

   /**
    * 删除文件
    *
    * @param minioClient MinIO客户端
    * @param bucketName  桶名称
    * @param objectName  对象名称
    */

   public static void deleteFile(MinioClient minioClient, String bucketName, String objectName) {
       if (!bucketExists(minioClient, bucketName)) {
           throw new BusinessException("桶不存在");
       }
       try {
           minioClient.removeObject(RemoveObjectArgs.builder()
                   .bucket(bucketName)
                   .object(objectName)
                   .build());
       } catch (Exception e) {
           log.error("删除文件失败,bucketName:{},objectName:{}", bucketName, objectName, e);
           throw new BusinessException("删除文件失败");
       }
   }

   /**
    * 获取预签名访问URL
    *
    * @param minioClient MinIO客户端
    * @param bucketName  桶名称
    * @param objectName  对象名称
    * @param expiry      过期时间(秒)
    * @param method      HTTP请求方法
    * @return 预签名URL
    */

   public static String getPresignedObjectUrl(MinioClient minioClient, String bucketName, String objectName,
                                                int expiry, Method method)
{
       if (!bucketExists(minioClient, bucketName)) {
           throw new BusinessException("桶不存在");
       }
       if (expiry <= 0) {
           expiry = MinioConstants.DEFAULT_PRESIGNED_EXPIRY;
       }
       try {
           return minioClient.getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder()
                   .bucket(bucketName)
                   .object(objectName)
                   .expiry(expiry, TimeUnit.SECONDS)
                   .method(method)
                   .build());
       } catch (Exception e) {
           log.error("获取预签名URL失败,bucketName:{},objectName:{}", bucketName, objectName, e);
           throw new BusinessException("获取预签名URL失败");
       }
   }

   /**
    * 初始化分片上传
    *
    * @param minioClient MinIO客户端
    * @param bucketName  桶名称
    * @param objectName  对象名称
    * @param contentType 文件MIME类型
    * @return uploadId 分片上传ID
    */

   public static String initMultipartUpload(MinioClient minioClient, String bucketName, String objectName,
                                              String contentType)
{
       if (!bucketExists(minioClient, bucketName)) {
           createBucket(minioClient, bucketName);
       }
       try {
           CreateMultipartUploadResponse response = minioClient.createMultipartUpload(
                   CreateMultipartUploadArgs.builder()
                           .bucket(bucketName)
                           .object(objectName)
                           .contentType(contentType)
                           .build());
           return response.result().uploadId();
       } catch (Exception e) {
           log.error("初始化分片上传失败,bucketName:{},objectName:{}", bucketName, objectName, e);
           throw new BusinessException("初始化分片上传失败");
       }
   }

   /**
    * 上传分片
    *
    * @param minioClient MinIO客户端
    * @param bucketName  桶名称
    * @param objectName  对象名称
    * @param uploadId    分片上传ID
    * @param partNumber  分片序号(从1开始)
    * @param inputStream 分片输入流
    * @param partSize    分片大小
    * @return 分片的etag值
    */

   public static String uploadPart(MinioClient minioClient, String bucketName, String objectName,
                                    String uploadId, int partNumber, InputStream inputStream, long partSize)
{
       try {
           UploadPartResponse response = minioClient.uploadPart(
                   UploadPartArgs.builder()
                           .bucket(bucketName)
                           .object(objectName)
                           .uploadId(uploadId)
                           .partNumber(partNumber)
                           .stream(inputStream, partSize, -1)
                           .build());
           return response.etag();
       } catch (Exception e) {
           log.error("上传分片失败,bucketName:{},objectName:{},partNumber:{}", bucketName, objectName, partNumber, e);
           throw new BusinessException("上传分片失败");
       }
   }

   /**
    * 合并分片
    *
    * @param minioClient MinIO客户端
    * @param bucketName  桶名称
    * @param objectName  对象名称
    * @param uploadId    分片上传ID
    * @param parts       分片数组(包含partNumber和etag)
    */

   public static void completeMultipartUpload(MinioClient minioClient, String bucketName, String objectName,
                                                String uploadId, Part[] parts)
{
       if (ObjectUtils.isEmpty(parts)) {
           throw new BusinessException("分片列表不能为空");
       }
       try {
           minioClient.completeMultipartUpload(
                   CompleteMultipartUploadArgs.builder()
                           .bucket(bucketName)
                           .object(objectName)
                           .uploadId(uploadId)
                           .parts(parts)
                           .build());
       } catch (Exception e) {
           log.error("合并分片失败,bucketName:{},objectName:{},uploadId:{}", bucketName, objectName, uploadId, e);
           throw new BusinessException("合并分片失败");
       }
   }

   /**
    * 取消分片上传
    *
    * @param minioClient MinIO客户端
    * @param bucketName  桶名称
    * @param objectName  对象名称
    * @param uploadId    分片上传ID
    */

   public static void abortMultipartUpload(MinioClient minioClient, String bucketName, String objectName,
                                             String uploadId)
{
       try {
           minioClient.abortMultipartUpload(
                   AbortMultipartUploadArgs.builder()
                           .bucket(bucketName)
                           .object(objectName)
                           .uploadId(uploadId)
                           .build());
       } catch (Exception e) {
           log.error("取消分片上传失败,bucketName:{},objectName:{},uploadId:{}", bucketName, objectName, uploadId, e);
           throw new BusinessException("取消分片上传失败");
       }
   }

   /**
    * 查询已上传的分片列表
    *
    * @param minioClient MinIO客户端
    * @param bucketName  桶名称
    * @param objectName  对象名称
    * @param uploadId    分片上传ID
    * @return 已上传的分片数组
    */

   public static Part[] listParts(MinioClient minioClient, String bucketName, String objectName, String uploadId) {
       try {
           ListPartsResponse response = minioClient.listParts(
                   ListPartsArgs.builder()
                           .bucket(bucketName)
                           .object(objectName)
                           .uploadId(uploadId)
                           .maxParts(MinioConstants.MAX_PART_COUNT)
                           .build());
           return response.result().partList().toArray(new Part[0]);
       } catch (Exception e) {
           log.error("查询分片列表失败,bucketName:{},objectName:{},uploadId:{}", bucketName, objectName, uploadId, e);
           throw new BusinessException("查询分片列表失败");
       }
   }
}

3.8 业务层实现

业务接口与VO定义

首先定义核心请求与响应VO,用于接口参数传递:

package com.jam.demo.vo;

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

import java.time.LocalDateTime;

/**
* 文件上传响应VO
*
* @author ken
*/

@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "文件上传响应VO")
public class FileUploadVO {

   @Schema(description = "文件ID")
   private Long id;

   @Schema(description = "存储文件名")
   private String fileName;

   @Schema(description = "原始文件名")
   private String originalFileName;

   @Schema(description = "文件大小(字节)")
   private Long fileSize;

   @Schema(description = "文件访问URL")
   private String url;

   @Schema(description = "上传时间")
   private LocalDateTime uploadTime;
}

package com.jam.demo.vo;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;

/**
* 分片上传初始化请求VO
*
* @author ken
*/

@Data
@Schema(description = "分片上传初始化请求VO")
public class MultipartUploadInitRequest {

   @NotBlank(message = "原始文件名不能为空")
   @Schema(description = "原始文件名", requiredMode = Schema.RequiredMode.REQUIRED)
   private String originalFileName;

   @NotBlank(message = "文件MD5值不能为空")
   @Schema(description = "文件MD5值", requiredMode = Schema.RequiredMode.REQUIRED)
   private String md5;

   @NotNull(message = "文件大小不能为空")
   @Schema(description = "文件大小(字节)", requiredMode = Schema.RequiredMode.REQUIRED)
   private Long fileSize;

   @Schema(description = "文件MIME类型")
   private String contentType;
}

package com.jam.demo.vo;

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

/**
* 分片上传初始化响应VO
*
* @author ken
*/

@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "分片上传初始化响应VO")
public class MultipartUploadInitVO {

   @Schema(description = "存储文件名")
   private String fileName;

   @Schema(description = "分片上传ID")
   private String uploadId;

   @Schema(description = "分片大小(字节)")
   private Long partSize;

   @Schema(description = "总分片数")
   private Integer totalPartCount;
}

package com.jam.demo.vo;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import org.springframework.web.multipart.MultipartFile;

/**
* 分片上传请求VO
*
* @author ken
*/

@Data
@Schema(description = "分片上传请求VO")
public class MultipartUploadPartRequest {

   @NotBlank(message = "存储文件名不能为空")
   @Schema(description = "存储文件名", requiredMode = Schema.RequiredMode.REQUIRED)
   private String fileName;

   @NotBlank(message = "分片上传ID不能为空")
   @Schema(description = "分片上传ID", requiredMode = Schema.RequiredMode.REQUIRED)
   private String uploadId;

   @NotNull(message = "分片序号不能为空")
   @Schema(description = "分片序号(从1开始)", requiredMode = Schema.RequiredMode.REQUIRED)
   private Integer partNumber;

   @NotNull(message = "分片文件不能为空")
   @Schema(description = "分片文件", requiredMode = Schema.RequiredMode.REQUIRED)
   private MultipartFile file;

   @Schema(description = "文件MD5值")
   private String md5;
}

package com.jam.demo.vo;

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

/**
* 分片上传响应VO
*
* @author ken
*/

@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "分片上传响应VO")
public class MultipartUploadPartVO {

   @Schema(description = "分片序号")
   private Integer partNumber;

   @Schema(description = "分片ETag值")
   private String etag;
}

package com.jam.demo.vo;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;

import java.util.List;

/**
* 合并分片请求VO
*
* @author ken
*/

@Data
@Schema(description = "合并分片请求VO")
public class MultipartUploadCompleteRequest {

   @NotBlank(message = "存储文件名不能为空")
   @Schema(description = "存储文件名", requiredMode = Schema.RequiredMode.REQUIRED)
   private String fileName;

   @NotBlank(message = "分片上传ID不能为空")
   @Schema(description = "分片上传ID", requiredMode = Schema.RequiredMode.REQUIRED)
   private String uploadId;

   @NotBlank(message = "文件MD5值不能为空")
   @Schema(description = "文件MD5值", requiredMode = Schema.RequiredMode.REQUIRED)
   private String md5;

   @NotNull(message = "文件大小不能为空")
   @Schema(description = "文件大小(字节)", requiredMode = Schema.RequiredMode.REQUIRED)
   private Long fileSize;

   @NotBlank(message = "原始文件名不能为空")
   @Schema(description = "原始文件名", requiredMode = Schema.RequiredMode.REQUIRED)
   private String originalFileName;

   @NotEmpty(message = "分片列表不能为空")
   @Schema(description = "分片列表", requiredMode = Schema.RequiredMode.REQUIRED)
   private List<MultipartUploadPartVO> parts;
}

定义业务接口:

package com.jam.demo.service;

import com.baomidou.mybatisplus.extension.service.IService;
import com.jam.demo.entity.FileInfo;
import com.jam.demo.vo.*;
import org.springframework.web.multipart.MultipartFile;

import jakarta.servlet.http.HttpServletResponse;

/**
* 文件服务接口
*
* @author ken
*/

public interface FileService extends IService<FileInfo> {

   /**
    * 普通文件上传
    *
    * @param file 上传的文件
    * @return 文件上传结果
    */

   FileUploadVO upload(MultipartFile file);

   /**
    * 文件下载
    *
    * @param fileName 存储文件名
    * @param response 响应对象
    */

   void download(String fileName, HttpServletResponse response);

   /**
    * 删除文件
    *
    * @param fileName 存储文件名
    * @return 删除结果
    */

   Boolean delete(String fileName);

   /**
    * 获取文件预签名访问URL
    *
    * @param fileName 存储文件名
    * @return 预签名URL
    */

   String getPresignedUrl(String fileName);

   /**
    * 初始化分片上传
    *
    * @param request 初始化请求参数
    * @return 分片上传初始化结果
    */

   MultipartUploadInitVO initMultipartUpload(MultipartUploadInitRequest request);

   /**
    * 上传分片
    *
    * @param request 分片上传请求参数
    * @return 分片上传结果
    */

   MultipartUploadPartVO uploadPart(MultipartUploadPartRequest request);

   /**
    * 合并分片
    *
    * @param request 合并分片请求参数
    * @return 合并结果
    */

   FileUploadVO completeMultipartUpload(MultipartUploadCompleteRequest request);

   /**
    * 校验文件是否已上传(秒传)
    *
    * @param md5 文件MD5值
    * @return 文件信息,若未上传返回null
    */

   FileUploadVO checkFileExist(String md5);
}

业务接口实现类

采用编程式事务保证文件上传与元数据存储的一致性,异常时自动回滚:

package com.jam.demo.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.jam.demo.config.MinioConfig;
import com.jam.demo.constant.MinioConstants;
import com.jam.demo.entity.FileInfo;
import com.jam.demo.exception.BusinessException;
import com.jam.demo.mapper.FileInfoMapper;
import com.jam.demo.service.FileService;
import com.jam.demo.util.MinioUtil;
import com.jam.demo.vo.*;
import io.minio.MinioClient;
import io.minio.http.Method;
import io.minio.messages.Part;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.TransactionTemplate;
import org.springframework.util.DigestUtils;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;

import jakarta.servlet.http.HttpServletResponse;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.UUID;

/**
* 文件服务实现类
*
* @author ken
*/

@Slf4j
@Service
public class FileServiceImpl extends ServiceImpl<FileInfoMapper, FileInfo> implements FileService {

   private final MinioClient minioClient;

   private final MinioConfig minioConfig;

   private final TransactionTemplate transactionTemplate;

   public FileServiceImpl(MinioClient minioClient, MinioConfig minioConfig, TransactionTemplate transactionTemplate) {
       this.minioClient = minioClient;
       this.minioConfig = minioConfig;
       this.transactionTemplate = transactionTemplate;
   }

   @Override
   public FileUploadVO upload(MultipartFile file) {
       if (file.isEmpty()) {
           throw new BusinessException("上传文件不能为空");
       }
       if (file.getSize() > MinioConstants.MAX_FILE_SIZE) {
           throw new BusinessException("文件大小超过最大限制");
       }
       String originalFileName = file.getOriginalFilename();
       if (!StringUtils.hasText(originalFileName)) {
           throw new BusinessException("文件名不能为空");
       }
       try {
           String md5 = DigestUtils.md5DigestAsHex(file.getInputStream());
           FileUploadVO existFile = checkFileExist(md5);
           if (!ObjectUtils.isEmpty(existFile)) {
               return existFile;
           }
           String suffix = originalFileName.substring(originalFileName.lastIndexOf("."));
           String fileName = UUID.randomUUID().toString().replace("-", "") + suffix;
           String contentType = file.getContentType();
           MinioUtil.uploadFile(minioClient, minioConfig.getBucketName(), fileName, file.getInputStream(), contentType);
           String url = MinioUtil.getPresignedObjectUrl(minioClient, minioConfig.getBucketName(), fileName, MinioConstants.DEFAULT_PRESIGNED_EXPIRY, Method.GET);
           FileInfo fileInfo = new FileInfo();
           fileInfo.setFileName(fileName);
           fileInfo.setOriginalFileName(originalFileName);
           fileInfo.setBucketName(minioConfig.getBucketName());
           fileInfo.setFilePath(minioConfig.getBucketName() + "/" + fileName);
           fileInfo.setFileSize(file.getSize());
           fileInfo.setContentType(contentType);
           fileInfo.setMd5(md5);
           Boolean saveResult = transactionTemplate.execute(status -> {
               try {
                   return save(fileInfo);
               } catch (Exception e) {
                   status.setRollbackOnly();
                   MinioUtil.deleteFile(minioClient, minioConfig.getBucketName(), fileName);
                   log.error("保存文件信息失败,回滚文件上传", e);
                   throw new BusinessException("保存文件信息失败");
               }
           });
           if (ObjectUtils.isEmpty(saveResult) || !saveResult) {
               MinioUtil.deleteFile(minioClient, minioConfig.getBucketName(), fileName);
               throw new BusinessException("保存文件信息失败");
           }
           FileUploadVO vo = new FileUploadVO();
           vo.setId(fileInfo.getId());
           vo.setFileName(fileName);
           vo.setOriginalFileName(originalFileName);
           vo.setFileSize(file.getSize());
           vo.setUrl(url);
           vo.setUploadTime(fileInfo.getCreateTime());
           return vo;
       } catch (BusinessException e) {
           throw e;
       } catch (Exception e) {
           log.error("文件上传失败", e);
           throw new BusinessException("文件上传失败");
       }
   }

   @Override
   public void download(String fileName, HttpServletResponse response) {
       if (!StringUtils.hasText(fileName)) {
           throw new BusinessException("文件名不能为空");
       }
       FileInfo fileInfo = getOne(new LambdaQueryWrapper<FileInfo>().eq(FileInfo::getFileName, fileName));
       if (ObjectUtils.isEmpty(fileInfo)) {
           throw new BusinessException("文件不存在");
       }
       try (InputStream inputStream = MinioUtil.downloadFile(minioClient, fileInfo.getBucketName(), fileName);
            OutputStream outputStream = response.getOutputStream()) {
           response.setContentType(fileInfo.getContentType());
           response.setContentLengthLong(fileInfo.getFileSize());
           response.setHeader("Content-Disposition", "attachment;filename=" + URLEncoder.encode(fileInfo.getOriginalFileName(), StandardCharsets.UTF_8.name()));
           response.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
           response.setHeader("Pragma", "no-cache");
           response.setHeader("Expires", "0");
           byte[] buffer = new byte[8192];
           int len;
           while ((len = inputStream.read(buffer)) != -1) {
               outputStream.write(buffer, 0, len);
           }
           outputStream.flush();
       } catch (BusinessException e) {
           throw e;
       } catch (Exception e) {
           log.error("文件下载失败,fileName:{}", fileName, e);
           throw new BusinessException("文件下载失败");
       }
   }

   @Override
   public Boolean delete(String fileName) {
       if (!StringUtils.hasText(fileName)) {
           throw new BusinessException("文件名不能为空");
       }
       FileInfo fileInfo = getOne(new LambdaQueryWrapper<FileInfo>().eq(FileInfo::getFileName, fileName));
       if (ObjectUtils.isEmpty(fileInfo)) {
           throw new BusinessException("文件不存在");
       }
       return transactionTemplate.execute(status -> {
           try {
               boolean removeResult = removeById(fileInfo.getId());
               if (!removeResult) {
                   status.setRollbackOnly();
                   return false;
               }
               MinioUtil.deleteFile(minioClient, fileInfo.getBucketName(), fileName);
               return true;
           } catch (Exception e) {
               status.setRollbackOnly();
               log.error("删除文件失败,fileName:{}", fileName, e);
               throw new BusinessException("删除文件失败");
           }
       });
   }

   @Override
   public String getPresignedUrl(String fileName) {
       if (!StringUtils.hasText(fileName)) {
           throw new BusinessException("文件名不能为空");
       }
       FileInfo fileInfo = getOne(new LambdaQueryWrapper<FileInfo>().eq(FileInfo::getFileName, fileName));
       if (ObjectUtils.isEmpty(fileInfo)) {
           throw new BusinessException("文件不存在");
       }
       return MinioUtil.getPresignedObjectUrl(minioClient, fileInfo.getBucketName(), fileName, MinioConstants.DEFAULT_PRESIGNED_EXPIRY, Method.GET);
   }

   @Override
   public MultipartUploadInitVO initMultipartUpload(MultipartUploadInitRequest request) {
       if (request.getFileSize() > MinioConstants.MAX_FILE_SIZE) {
           throw new BusinessException("文件大小超过最大限制");
       }
       FileUploadVO existFile = checkFileExist(request.getMd5());
       if (!ObjectUtils.isEmpty(existFile)) {
           throw new BusinessException("文件已存在,无需重复上传", 200);
       }
       String originalFileName = request.getOriginalFileName();
       String suffix = originalFileName.substring(originalFileName.lastIndexOf("."));
       String fileName = UUID.randomUUID().toString().replace("-", "") + suffix;
       long partSize = MinioConstants.DEFAULT_PART_SIZE;
       long fileSize = request.getFileSize();
       int totalPartCount = (int) Math.ceil((double) fileSize / partSize);
       if (totalPartCount < MinioConstants.MIN_PART_COUNT) {
           totalPartCount = MinioConstants.MIN_PART_COUNT;
       }
       if (totalPartCount > MinioConstants.MAX_PART_COUNT) {
           partSize = (long) Math.ceil((double) fileSize / MinioConstants.MAX_PART_COUNT);
           totalPartCount = MinioConstants.MAX_PART_COUNT;
       }
       String uploadId = MinioUtil.initMultipartUpload(minioClient, minioConfig.getBucketName(), fileName, request.getContentType());
       MultipartUploadInitVO vo = new MultipartUploadInitVO();
       vo.setFileName(fileName);
       vo.setUploadId(uploadId);
       vo.setPartSize(partSize);
       vo.setTotalPartCount(totalPartCount);
       return vo;
   }

   @Override
   public MultipartUploadPartVO uploadPart(MultipartUploadPartRequest request) {
       if (request.getFile().isEmpty()) {
           throw new BusinessException("分片文件不能为空");
       }
       try {
           String etag = MinioUtil.uploadPart(
                   minioClient,
                   minioConfig.getBucketName(),
                   request.getFileName(),
                   request.getUploadId(),
                   request.getPartNumber(),
                   request.getFile().getInputStream(),
                   request.getFile().getSize()
           );
           MultipartUploadPartVO vo = new MultipartUploadPartVO();
           vo.setPartNumber(request.getPartNumber());
           vo.setEtag(etag);
           return vo;
       } catch (BusinessException e) {
           throw e;
       } catch (Exception e) {
           log.error("分片上传失败,fileName:{},partNumber:{}", request.getFileName(), request.getPartNumber(), e);
           throw new BusinessException("分片上传失败");
       }
   }

   @Override
   public FileUploadVO completeMultipartUpload(MultipartUploadCompleteRequest request) {
       try {
           Part[] parts = request.getParts().stream()
                   .sorted((p1, p2) -> p1.getPartNumber() - p2.getPartNumber())
                   .map(vo -> new Part(vo.getPartNumber(), vo.getEtag()))
                   .toArray(Part[]::new);
           MinioUtil.completeMultipartUpload(
                   minioClient,
                   minioConfig.getBucketName(),
                   request.getFileName(),
                   request.getUploadId(),
                   parts
           );
           String url = MinioUtil.getPresignedObjectUrl(minioClient, minioConfig.getBucketName(), request.getFileName(), MinioConstants.DEFAULT_PRESIGNED_EXPIRY, Method.GET);
           FileInfo fileInfo = new FileInfo();
           fileInfo.setFileName(request.getFileName());
           fileInfo.setOriginalFileName(request.getOriginalFileName());
           fileInfo.setBucketName(minioConfig.getBucketName());
           fileInfo.setFilePath(minioConfig.getBucketName() + "/" + request.getFileName());
           fileInfo.setFileSize(request.getFileSize());
           fileInfo.setContentType("application/octet-stream");
           fileInfo.setMd5(request.getMd5());
           Boolean saveResult = transactionTemplate.execute(status -> {
               try {
                   return save(fileInfo);
               } catch (Exception e) {
                   status.setRollbackOnly();
                   MinioUtil.deleteFile(minioClient, minioConfig.getBucketName(), request.getFileName());
                   log.error("保存文件信息失败,回滚合并操作", e);
                   throw new BusinessException("保存文件信息失败");
               }
           });
           if (ObjectUtils.isEmpty(saveResult) || !saveResult) {
               MinioUtil.deleteFile(minioClient, minioConfig.getBucketName(), request.getFileName());
               throw new BusinessException("保存文件信息失败");
           }
           FileUploadVO vo = new FileUploadVO();
           vo.setId(fileInfo.getId());
           vo.setFileName(request.getFileName());
           vo.setOriginalFileName(request.getOriginalFileName());
           vo.setFileSize(request.getFileSize());
           vo.setUrl(url);
           vo.setUploadTime(fileInfo.getCreateTime());
           return vo;
       } catch (BusinessException e) {
           throw e;
       } catch (Exception e) {
           log.error("合并分片失败,fileName:{}", request.getFileName(), e);
           throw new BusinessException("合并分片失败");
       }
   }

   @Override
   public FileUploadVO checkFileExist(String md5) {
       if (!StringUtils.hasText(md5)) {
           throw new BusinessException("文件MD5值不能为空");
       }
       FileInfo fileInfo = getOne(new LambdaQueryWrapper<FileInfo>().eq(FileInfo::getMd5, md5));
       if (ObjectUtils.isEmpty(fileInfo)) {
           return null;
       }
       String url = MinioUtil.getPresignedObjectUrl(minioClient, fileInfo.getBucketName(), fileInfo.getFileName(), MinioConstants.DEFAULT_PRESIGNED_EXPIRY, Method.GET);
       FileUploadVO vo = new FileUploadVO();
       vo.setId(fileInfo.getId());
       vo.setFileName(fileInfo.getFileName());
       vo.setOriginalFileName(fileInfo.getOriginalFileName());
       vo.setFileSize(fileInfo.getFileSize());
       vo.setUrl(url);
       vo.setUploadTime(fileInfo.getCreateTime());
       return vo;
   }
}

3.9 控制层实现

基于RESTful规范设计接口,添加Swagger3注解与参数校验,覆盖全场景文件操作能力:

package com.jam.demo.controller;

import com.jam.demo.common.Result;
import com.jam.demo.service.FileService;
import com.jam.demo.vo.*;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import jakarta.servlet.http.HttpServletResponse;

/**
* 文件操作控制器
*
* @author ken
*/

@Slf4j
@RestController
@RequestMapping("/file")
@Tag(name = "文件操作接口", description = "包含普通文件上传、下载、删除、预签名URL获取、秒传校验等功能")
public class FileController {

   private final FileService fileService;

   public FileController(FileService fileService) {
       this.fileService = fileService;
   }

   @PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
   @Operation(summary = "普通文件上传", description = "支持单文件上传,内置秒传能力,文件大小限制1GB")
   public Result<FileUploadVO> upload(
           @Parameter(description = "上传的文件", required = true)

           @RequestPart("file") MultipartFile file) {
       return Result.success(fileService.upload(file));
   }

   @GetMapping("/download/{fileName}")
   @Operation(summary = "文件下载", description = "根据存储文件名下载文件,自动设置响应头与文件名编码")
   public void download(
           @Parameter(description = "存储文件名", required = true)

           @PathVariable String fileName,
           HttpServletResponse response) {
       fileService.download(fileName, response);
   }

   @DeleteMapping("/{fileName}")
   @Operation(summary = "删除文件", description = "根据存储文件名删除文件,同时删除MinIO中的对象与数据库元数据")
   public Result<Boolean> delete(
           @Parameter(description = "存储文件名", required = true)

           @PathVariable String fileName) {
       return Result.success(fileService.delete(fileName));
   }

   @GetMapping("/presigned-url/{fileName}")
   @Operation(summary = "获取文件预签名访问URL", description = "生成带签名的临时访问URL,默认有效期1小时")
   public Result<String> getPresignedUrl(
           @Parameter(description = "存储文件名", required = true)

           @PathVariable String fileName) {
       return Result.success(fileService.getPresignedUrl(fileName));
   }

   @GetMapping("/check-exist/{md5}")
   @Operation(summary = "校验文件是否已存在(秒传)", description = "根据文件MD5值校验是否已上传,已上传直接返回文件信息")
   public Result<FileUploadVO> checkFileExist(
           @Parameter(description = "文件MD5值", required = true)

           @PathVariable String md5) {
       FileUploadVO vo = fileService.checkFileExist(md5);
       if (vo == null) {
           return Result.fail(404, "文件不存在");
       }
       return Result.success(vo);
   }
}

package com.jam.demo.controller;

import com.jam.demo.common.Result;
import com.jam.demo.service.FileService;
import com.jam.demo.vo.*;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;

/**
* 分片上传控制器
*
* @author ken
*/

@Slf4j
@RestController
@RequestMapping("/multipart")
@Tag(name = "分片上传接口", description = "大文件分片上传、断点续传相关接口,支持GB级大文件上传")
public class MultipartUploadController {

   private final FileService fileService;

   public MultipartUploadController(FileService fileService) {
       this.fileService = fileService;
   }

   @PostMapping("/init")
   @Operation(summary = "初始化分片上传", description = "初始化分片上传任务,生成uploadId、分片大小、总分片数等信息")
   public Result<MultipartUploadInitVO> initMultipartUpload(@Valid @RequestBody MultipartUploadInitRequest request) {
       return Result.success(fileService.initMultipartUpload(request));
   }

   @PostMapping(value = "/upload-part", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
   @Operation(summary = "上传单个分片", description = "上传文件的单个分片,返回分片的ETag值,用于最终合并")
   public Result<MultipartUploadPartVO> uploadPart(@Valid MultipartUploadPartRequest request) {
       return Result.success(fileService.uploadPart(request));
   }

   @PostMapping("/complete")
   @Operation(summary = "合并分片", description = "所有分片上传完成后,调用此接口合并分片,生成完整文件并保存元数据")
   public Result<FileUploadVO> completeMultipartUpload(@Valid @RequestBody MultipartUploadCompleteRequest request) {
       return Result.success(fileService.completeMultipartUpload(request));
   }
}

3.10 项目启动类与辅助配置

项目启动类

package com.jam.demo;

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

/**
* 项目启动类
*
* @author ken
*/

@SpringBootApplication
@MapperScan("com.jam.demo.mapper")
public class MinioDemoApplication {

   public static void main(String[] args) {
       SpringApplication.run(MinioDemoApplication.class, args);
   }
}

MyBatis-Plus自动填充配置

package com.jam.demo.config;

import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import org.apache.ibatis.reflection.MetaObject;
import org.springframework.stereotype.Component;

import java.time.LocalDateTime;

/**
* MyBatis-Plus字段自动填充配置
*
* @author ken
*/

@Component
public class MyMetaObjectHandler implements MetaObjectHandler {

   @Override
   public void insertFill(MetaObject metaObject) {
       this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now());
       this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
   }

   @Override
   public void updateFill(MetaObject metaObject) {
       this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
   }
}

四、核心业务流程深度解析

4.1 普通文件上传全流程

普通文件上传适用于100MB以内的小文件场景,流程简单、开发成本低,核心流程如下:

核心逻辑说明:

  1. 秒传能力:通过文件MD5值实现秒传,避免重复上传相同文件,节省存储空间与带宽资源
  2. 事务一致性:采用编程式事务保证MinIO文件上传与数据库元数据保存的一致性,异常时自动回滚,避免脏数据
  3. 文件名唯一性:通过UUID生成全局唯一的存储文件名,避免同名文件覆盖问题

4.2 大文件分片上传与断点续传全流程

分片上传适用于100MB以上的大文件场景,解决了大文件上传超时、网络中断后需重新上传的痛点,核心流程如下:

核心逻辑说明:

  1. 断点续传实现:客户端记录已上传成功的分片,网络中断或页面刷新后,只需上传未完成的分片,无需从头开始
  2. 分片规则:默认分片大小5MB,最小分片数1,最大分片数10000,适配MinIO的分片上传规范,支持最大50GB的文件上传
  3. ETag校验:每个分片上传后返回唯一的ETag值,合并时需携带所有分片的ETag与序号,MinIO会校验分片的完整性,避免分片错乱或损坏
  4. 资源清理:未完成的分片上传任务,MinIO会保留未合并的分片,可通过生命周期规则自动清理过期的分片数据,避免存储空间浪费

五、前端对接实战示例

5.1 普通文件上传前端示例

基于原生JavaScript实现,适配主流浏览器,可直接集成到Vue、React等前端框架中:

// 普通文件上传
async function uploadFile(event) {
   const file = event.target.files[0];
   if (!file) {
       alert("请选择要上传的文件");
       return;
   }
   const formData = new FormData();
   formData.append("file", file);
   try {
       const response = await fetch("http://localhost:8080/file/upload", {
           method: "POST",
           body: formData
       });
       const result = await response.json();
       if (result.code === 200) {
           console.log("上传成功", result.data);
           alert("上传成功,文件访问地址:" + result.data.url);
       } else {
           alert("上传失败:" + result.message);
       }
   } catch (error) {
       console.error("上传异常", error);
       alert("上传异常,请检查网络");
   }
}

// 页面绑定
document.getElementById("fileInput").addEventListener("change", uploadFile);

5.2 大文件分片上传前端示例

实现文件切片、MD5计算、断点续传、进度展示等核心能力:

// 分片大小 5MB,与后端保持一致
const PART_SIZE = 5 * 1024 * 1024;
// 已上传的分片信息
let uploadedParts = [];
// 分片上传任务信息
let uploadTask = null;
// 文件MD5值
let fileMd5 = "";

// 计算文件MD5
async function calculateFileMD5(file) {
   return new Promise((resolve) => {
       const spark = new SparkMD5.ArrayBuffer();
       const fileReader = new FileReader();
       let loaded = 0;
       const chunkSize = 2 * 1024 * 1024;
       const totalChunks = Math.ceil(file.size / chunkSize);
       let currentChunk = 0;

       fileReader.onload = function (e) {
           spark.append(e.target.result);
           currentChunk++;
           loaded += e.target.result.byteLength;
           const progress = Math.floor((loaded / file.size) * 100);
           console.log("MD5计算进度:" + progress + "%");
           if (currentChunk < totalChunks) {
               loadNextChunk();
           } else {
               resolve(spark.end());
           }
       };

       function loadNextChunk() {
           const start = currentChunk * chunkSize;
           const end = Math.min(start + chunkSize, file.size);
           fileReader.readAsArrayBuffer(file.slice(start, end));
       }
       loadNextChunk();
   });
}

// 秒传校验
async function checkFileExist(md5) {
   const response = await fetch(`http://localhost:8080/file/check-exist/${md5}`);
   const result = await response.json();
   return result.code === 200 ? result.data : null;
}

// 初始化分片上传
async function initMultipartUpload(file, md5) {
   const response = await fetch("http://localhost:8080/multipart/init", {
       method: "POST",
       headers: {
           "Content-Type": "application/json"
       },
       body: JSON.stringify({
           originalFileName: file.name,
           md5: md5,
           fileSize: file.size,
           contentType: file.type
       })
   });
   const result = await response.json();
   if (result.code !== 200) {
       throw new Error(result.message);
   }
   return result.data;
}

// 上传单个分片
async function uploadPart(chunk, partNumber) {
   const formData = new FormData();
   formData.append("fileName", uploadTask.fileName);
   formData.append("uploadId", uploadTask.uploadId);
   formData.append("partNumber", partNumber);
   formData.append("file", chunk);
   const response = await fetch("http://localhost:8080/multipart/upload-part", {
       method: "POST",
       body: formData
   });
   const result = await response.json();
   if (result.code !== 200) {
       throw new Error(result.message);
   }
   return result.data;
}

// 合并分片
async function completeMultipartUpload(file) {
   const response = await fetch("http://localhost:8080/multipart/complete", {
       method: "POST",
       headers: {
           "Content-Type": "application/json"
       },
       body: JSON.stringify({
           fileName: uploadTask.fileName,
           uploadId: uploadTask.uploadId,
           md5: fileMd5,
           fileSize: file.size,
           originalFileName: file.name,
           parts: uploadedParts
       })
   });
   const result = await response.json();
   if (result.code !== 200) {
       throw new Error(result.message);
   }
   return result.data;
}

// 主上传方法
async function uploadLargeFile(event) {
   const file = event.target.files[0];
   if (!file) {
       alert("请选择要上传的文件");
       return;
   }
   try {
       // 1. 计算文件MD5
       console.log("开始计算文件MD5...");
       fileMd5 = await calculateFileMD5(file);
       console.log("文件MD5:", fileMd5);

       // 2. 秒传校验
       const existFile = await checkFileExist(fileMd5);
       if (existFile) {
           console.log("文件已存在,秒传成功", existFile);
           alert("秒传成功,文件访问地址:" + existFile.url);
           return;
       }

       // 3. 初始化分片上传
       console.log("初始化分片上传...");
       uploadTask = await initMultipartUpload(file, fileMd5);
       console.log("分片上传初始化完成", uploadTask);

       // 4. 切割文件
       const totalChunks = uploadTask.totalPartCount;
       const chunks = [];
       for (let i = 0; i < totalChunks; i++) {
           const start = i * uploadTask.partSize;
           const end = Math.min(start + uploadTask.partSize, file.size);
           chunks.push(file.slice(start, end));
       }

       // 5. 循环上传分片
       console.log("开始上传分片,总分片数:", totalChunks);
       uploadedParts = [];
       for (let i = 0; i < chunks.length; i++) {
           const partNumber = i + 1;
           try {
               const partResult = await uploadPart(chunks[i], partNumber);
               uploadedParts.push(partResult);
               const progress = Math.floor(((i + 1) / totalChunks) * 100);
               console.log(`分片${partNumber}/${totalChunks}上传成功,进度:${progress}%`);
           } catch (error) {
               console.error(`分片${partNumber}上传失败`, error);
               alert(`分片${partNumber}上传失败:${error.message}`);
               return;
           }
       }

       // 6. 合并分片
       console.log("所有分片上传完成,开始合并...");
       const finalResult = await completeMultipartUpload(file);
       console.log("文件上传完成", finalResult);
       alert("上传成功,文件访问地址:" + finalResult.url);

   } catch (error) {
       console.error("大文件上传异常", error);
       alert("上传失败:" + error.message);
   }
}

// 页面绑定
document.getElementById("largeFileInput").addEventListener("change", uploadLargeFile);

六、生产环境最佳实践

6.1 权限管控最佳实践

  1. 最小权限原则:禁止在业务代码中使用管理员账号,应通过MinIO控制台创建专属业务用户,仅分配对应桶的读写权限,避免权限泄露导致的数据安全问题
  2. 预签名URL优先:业务访问优先使用预签名URL,避免将桶设置为公共读,防止数据被恶意爬取或篡改
  3. 临时凭证管控:前端直传场景使用STS临时凭证,设置较短的有效期,避免永久凭证泄露
  4. 访问策略精细化:通过桶策略限制IP访问、请求来源、文件大小、文件类型,禁止上传可执行文件、脚本文件等危险类型

6.2 性能优化最佳实践

  1. 分片大小优化:小文件使用普通上传,大文件使用分片上传,100MB-1GB文件分片大小设置为5MB,1GB-10GB文件分片大小设置为10MB,10GB以上文件分片大小设置为50MB,平衡分片数量与上传性能
  2. 并发上传优化:客户端分片上传采用并发上传,控制并发数在3-5个,避免并发过高导致服务端压力过大
  3. 部署架构优化:MinIO节点与业务服务部署在同一内网,减少网络延迟;生产环境使用SSD磁盘,提升IO性能;前端配置CDN加速静态资源访问
  4. 连接池优化:MinIO客户端配置合理的连接池参数,设置最大连接数、连接超时时间、读写超时时间,避免连接泄露

6.3 数据高可用与安全最佳实践

  1. 纠删码配置:分布式部署时,数据块与校验块的比例建议设置为1:1,平衡存储空间利用率与数据安全性;至少部署4个节点,确保集群高可用
  2. 数据备份:定期对核心数据进行跨集群备份,开启MinIO的版本控制功能,防止误删除导致的数据丢失
  3. 数据加密:敏感数据开启服务端加密(SSE-S3),传输过程使用HTTPS协议,确保数据传输与存储的安全性
  4. 生命周期管理:配置生命周期规则,自动清理过期文件、临时分片、历史版本文件,降低存储成本

6.4 监控与运维最佳实践

  1. 监控指标采集:通过Prometheus+Grafana采集MinIO的核心监控指标,包括节点健康状态、磁盘使用率、请求吞吐量、延迟、错误率等,设置告警阈值
  2. 日志审计:开启MinIO的访问日志,记录所有操作请求,用于安全审计与问题排查
  3. 定期巡检:定期检查集群节点健康状态、磁盘健康状态、数据一致性,及时更换故障磁盘,避免集群可用性下降
  4. 版本升级:定期升级MinIO到最新稳定版本,修复已知漏洞与问题,提升性能与稳定性

七、常见问题排查与解决方案

7.1 跨域问题

问题现象:前端调用接口时出现CORS跨域错误解决方案

  1. 配置Spring Boot全局跨域过滤器:

package com.jam.demo.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;

import java.util.Arrays;

/**
* 全局跨域配置
*
* @author ken
*/

@Configuration
public class CorsConfig {

   @Bean
   public CorsFilter corsFilter() {
       CorsConfiguration config = new CorsConfiguration();
       config.setAllowedOriginPatterns(Arrays.asList("*"));
       config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
       config.setAllowedHeaders(Arrays.asList("*"));
       config.setAllowCredentials(true);
       config.setMaxAge(3600L);
       UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
       source.registerCorsConfiguration("/**", config);
       return new CorsFilter(source);
   }
}

  1. 在MinIO控制台配置桶的跨域规则,允许前端域名的跨域请求

7.2 大文件上传超时问题

问题现象:大文件上传时出现请求超时、连接重置错误解决方案

  1. 调整Spring Boot的文件上传大小限制与超时时间
  2. 调整Nginx反向代理的超时配置,设置proxy_connect_timeoutproxy_send_timeoutproxy_read_timeout为300s以上
  3. 开启分片上传,降低单个请求的文件大小,避免单次请求超时
  4. 调整MinIO客户端的超时参数,设置合理的读写超时时间

7.3 预签名URL失效问题

问题现象:生成的预签名URL访问时出现签名不匹配、过期错误解决方案

  1. 确保MinIO服务端与业务服务端的系统时间一致,时间差超过15分钟会导致签名校验失败
  2. 预签名URL的有效期设置合理,避免过期时间过短
  3. 确保生成预签名URL的endpoint与客户端访问的endpoint一致,内网与外网endpoint混用会导致签名不匹配
  4. 避免在预签名URL中包含特殊字符,文件名使用URL编码

7.4 分片合并失败问题

问题现象:所有分片上传完成后,合并分片时出现分片不存在、ETag不匹配错误解决方案

  1. 确保分片序号从1开始,连续且不重复,合并时按序号升序排列
  2. 确保每个分片的ETag值正确,与上传时返回的ETag完全一致
  3. 确保合并时使用的uploadId、bucketName、fileName与初始化时完全一致
  4. 检查未合并的分片是否被生命周期规则清理,延长临时分片的保留时间

八、总结

MinIO作为一款轻量、高性能、兼容S3协议的对象存储系统,完美适配了云原生时代企业级非结构化数据存储的需求。本文从MinIO的底层架构出发,拆解了其核心设计理念与特性,通过Spring Boot项目的全流程整合,实现了从普通文件上传下载、大文件分片上传、断点续传、秒传等全场景能力,同时梳理了生产环境的最佳实践与常见问题解决方案。

目录
相关文章
|
6天前
|
人工智能 JSON 监控
Claude Code 源码泄露:一份价值亿元的 AI 工程公开课
我以为顶级 AI 产品的护城河是模型。读完这 51.2 万行泄露的源码,我发现自己错了。
4357 17
|
17天前
|
人工智能 JSON 机器人
让龙虾成为你的“公众号分身” | 阿里云服务器玩Openclaw
本文带你零成本玩转OpenClaw:学生认证白嫖6个月阿里云服务器,手把手配置飞书机器人、接入免费/高性价比AI模型(NVIDIA/通义),并打造微信公众号“全自动分身”——实时抓热榜、AI选题拆解、一键发布草稿,5分钟完成热点→文章全流程!
16646 138
让龙虾成为你的“公众号分身” | 阿里云服务器玩Openclaw
|
5天前
|
人工智能 数据可视化 安全
王炸组合!阿里云 OpenClaw X 飞书 CLI,开启 Agent 基建狂潮!(附带免费使用6个月服务器)
本文详解如何用阿里云Lighthouse一键部署OpenClaw,结合飞书CLI等工具,让AI真正“动手”——自动群发、生成科研日报、整理知识库。核心理念:未来软件应为AI而生,CLI即AI的“手脚”,实现高效、安全、可控的智能自动化。
4819 8
王炸组合!阿里云 OpenClaw X 飞书 CLI,开启 Agent 基建狂潮!(附带免费使用6个月服务器)
|
7天前
|
人工智能 自然语言处理 数据挖掘
零基础30分钟搞定 Claude Code,这一步90%的人直接跳过了
本文直击Claude Code使用痛点,提供零基础30分钟上手指南:强调必须配置“工作上下文”(about-me.md+anti-ai-style.md)、采用Cowork/Code模式、建立标准文件结构、用提问式提示词驱动AI理解→规划→执行。附可复制模板与真实项目启动法,助你将Claude从聊天工具升级为高效执行系统。
|
6天前
|
人工智能 定位技术
Claude Code源码泄露:8大隐藏功能曝光
2026年3月,Anthropic因配置失误致Claude Code超51万行源码泄露,意外促成“被动开源”。代码中藏有8大未发布功能,揭示其向“超级智能体”演进的完整蓝图,引发AI编程领域震动。(239字)
2461 9