SpringBoot + MinIO 实现大文件秒传 + 断点续传 + 分片上传

SpringBoot + MinIO 实现大文件秒传 + 断点续传 + 分片上传

接口调用顺序(标准固定)

  1. 校验 MD5(秒传入口)
  2. 没秒传 → 查已上传分片(断点续传用)
  3. 初始化分片(没续传就初始化,否则直接接着上传)
  4. 上传单个分片
  5. 全部传完 → 合并分片

1. 秒传+断点续传

秒传核心逻辑 :上传前先计算文件的 MD5 哈希值,后端查询file_info表是否存在相同 MD5 且上传状态为已完成的记录。若存在,直接返回文件 URL,实现秒传。
断点续传核心逻辑 :断点续传是分片上传的自然延伸。当上传中断后,用户重新上传相同文件时,前端先计算 MD5,后端查询file_chunk表,返回该文件已成功上传的分片序号列表。前端只需要上传不在列表中的分片即可。

java 复制代码
/**
 * 秒传 + 断点续传:查询已上传分片
 * 前端上传前先调用,判断文件是否已存在(秒传)/ 哪些分片已上传(断点续传)
 * @param fileMd5 文件唯一标识MD5
 * @return 断点续传VO(是否存在、已上传分片列表)
 */
public BreakpointUploadVO checkBreakpoint(String fileMd5) throws Exception {
    BreakpointUploadVO vo = new BreakpointUploadVO();

    // 1. 秒传判断:查询文件是否已完整上传完成(上传状态=1)
    LambdaQueryWrapper<FileInfo> infoWrapper = Wrappers.lambdaQuery();
    infoWrapper.eq(FileInfo::getFileMd5, fileMd5)
            .eq(FileInfo::getUploadStatus, 1);
    FileInfo info = fileInfoMapper.selectOne(infoWrapper);

    // 文件已存在,直接返回文件地址,实现秒传
    if (info != null) {
        vo.setExist(true);
        vo.setFileUrl(getPresignedUrl(info.getObjectName()));
        return vo;
    }

    // 2. 断点续传:查询该文件已上传成功的分片序号
    LambdaQueryWrapper<FileChunk> chunkWrapper = Wrappers.lambdaQuery();
    chunkWrapper.eq(FileChunk::getUploadId, fileMd5);
    List<FileChunk> chunks = fileChunkMapper.selectList(chunkWrapper);

    if (chunks != null && !chunks.isEmpty()) {
        // 把已上传分片序号存入集合,返回给前端跳过上传
        Set<Integer> uploadedChunks = new HashSet<>();
        for (FileChunk chunk : chunks) {
            uploadedChunks.add(chunk.getChunkIndex());
            vo.setUploadId(fileMd5);
            vo.setObjectName(chunk.getObjectName());
        }

        vo.setUploadedChunks(uploadedChunks);
    }
    vo.setExist(false);
    return vo;
}

2. 初始化

核心逻辑 :秒传 / 断点续传校验不通过 时,前端发起分片上传初始化。后端直接使用文件 MD5 作为上传唯一标识 uploadId,并生成文件在 MinIO 中的唯一存储路径(objectName),返回给前端用于后续分片上传。

java 复制代码
/**
     * 初始化分片上传
     * 生成唯一上传ID、文件存储路径(objectName)
     * @param request 初始化请求参数(文件名、MD5等)
     * @return 上传ID + 文件存储路径
     */
    public InitUploadResponse initUpload(InitUploadRequest request) {
        // 直接用文件MD5作为上传唯一标识
        String uploadId = request.getFileMd5();

        // 获取文件后缀名
        String suffix = getSuffix(request.getFilename());

        String uuid = UUID.randomUUID().toString().replace("-", "");
        String objectName = "files/"
                + LocalDate.now()
                + "/"
                + uuid  // 这里用 UUID,不要用 MD5
                + suffix;

        return new InitUploadResponse(uploadId, objectName);
    }

3. 上传单个分片

核心逻辑前端 将大文件按固定大小切割分片,循环调用上传接口。
后端逻辑

  1. 先查询数据库,分片已存在则直接跳过,不重复上传、不重复存储。
  2. 分片不存在,则上传到 MinIO 的临时分片目录。
  3. 记录分片信息到 file_chunk 表,作为断点续传的依据。
java 复制代码
/**
     * 上传单个分片
     * 前端循环调用,分片上传;自动跳过已上传分片
     * @param uploadId 上传唯一标识(文件MD5)
     * @param chunkIndex 当前分片序号
     * @param file 分片文件流
     */
    public void uploadChunk(
            String uploadId,
            Integer chunkIndex,
            String objectName,
            MultipartFile file
    ) throws Exception {

        // 参数合法性校验
        if (uploadId == null || uploadId.isBlank()) {
            throw new IllegalArgumentException("uploadId不能为空");
        }
        if (chunkIndex == null || chunkIndex < 0) {
            throw new IllegalArgumentException("chunkIndex非法");
        }

        // 1. 先查数据库:该分片是否已经上传过
        LambdaQueryWrapper<FileChunk> wrapper = Wrappers.lambdaQuery();
        wrapper.eq(FileChunk::getUploadId, uploadId)
                .eq(FileChunk::getChunkIndex, chunkIndex);

        FileChunk existChunk = fileChunkMapper.selectOne(wrapper);

        // 2. 已存在 → 直接返回,不执行上传、不重复存储
        if (existChunk != null) {
            return;
        }

        // 生成分片在 Minio 中的临时存储路径
        String chunkObjectName = getChunkObjectName(uploadId, chunkIndex);

        // 3. 上传分片到 Minio
        minioClient.putObject(
                PutObjectArgs.builder()
                        .bucket(bucket)
                        .object(chunkObjectName)
                        .stream(file.getInputStream(), file.getSize(), -1)
                        .contentType(file.getContentType())
                        .build()
        );

        // 4. 记录分片信息(断点续传核心依据)
        FileChunk chunk = new FileChunk();
        chunk.setUploadId(uploadId);
        chunk.setChunkIndex(chunkIndex);
        chunk.setObjectName(objectName);
        chunk.setCreateTime(LocalDateTime.now());
        fileChunkMapper.insert(chunk);
    }

4. 分片合片

核心逻辑 :所有分片上传完成后,前端调用合并接口。
后端执行:

  1. 校验所有分片是否存在,按顺序组装分片。
  2. 调用 MinIO 合并接口,将临时分片合并为完整文件。
  3. 合并成功后,清理 MinIO 临时分片、删除数据库分片记录。
  4. 写入文件信息到 file_info 表,标记上传完成,返回文件访问地址。
java 复制代码
/**
     * 合并分片
     * 所有分片上传完成后调用,合并为完整文件,清理临时分片
     * @param request 合并请求参数
     * @return 完整文件的访问地址
     */
    public String completeUpload(CompleteUploadRequest request) throws Exception {
        String uploadId = request.getUploadId();
        String objectName = request.getObjectName();
        Integer totalChunks = request.getTotalChunks();

        // 参数校验
        if (totalChunks == null || totalChunks <= 0) {
            throw new IllegalArgumentException("totalChunks非法");
        }

        // 组装所有分片源,用于合并
        List<ComposeSource> sources = new ArrayList<>();

        for (int i = 0; i < totalChunks; i++) {
            String chunkObjectName = getChunkObjectName(uploadId, i);

            // 检查分片是否存在,不存在会抛出异常
            minioClient.statObject(
                    StatObjectArgs.builder()
                            .bucket(bucket)
                            .object(chunkObjectName)
                            .build()
            );

            // 添加到合并源列表
            sources.add(
                    ComposeSource.builder()
                            .bucket(bucket)
                            .object(chunkObjectName)
                            .build()
            );
        }

        // 执行 Minio 分片合并
        minioClient.composeObject(
                ComposeObjectArgs.builder()
                        .bucket(bucket)
                        .object(objectName)
                        .sources(sources)
                        .build()
        );

        // 删除 Minio 中的临时分片文件
        removeChunks(uploadId, totalChunks);

        // 4. 删除数据库中的分片记录
        LambdaQueryWrapper<FileChunk> wrapper = Wrappers.lambdaQuery();
        wrapper.eq(FileChunk::getUploadId, uploadId);
        fileChunkMapper.delete(wrapper);

        // 5. 记录完整文件信息,标记上传完成
        FileInfo info = new FileInfo();
        info.setFileMd5(uploadId);
        info.setObjectName(objectName);
        info.setUploadStatus(1);
        fileInfoMapper.insert(info);

        // 返回带签名的文件访问地址
        return getPresignedUrl(objectName);
    }

实体类

java 复制代码
/**
 * 断点续传返回:已上传分片列表
 */
@Data
public class BreakpointUploadVO {
    // 是否已经完整上传(秒传)
    private boolean exist;

    // 上传ID(exist=false 时返回)
    private String uploadId;

    // 原始文件对象名(exist=false 时返回)
    private String objectName;

    // 已上传的分片序号
    private Set<Integer> uploadedChunks;

    // 文件地址(exist=true 时返回)
    private String fileUrl;
}

@Data
@TableName("file_info")
public class FileInfo {

    @TableId(type = IdType.AUTO)
    private Long id;

    /**
     * 文件MD5哈希值(秒传核心)
     */
    private String fileMd5;

    /**
     * MinIO对象名称
     */
    private String objectName;

    /**
     * 上传状态:0-未完成,1-已完成
     */
    private Integer uploadStatus;
}

/**
 * 文件分片上传记录(断点续传核心)
 */
@Data
@TableName("file_chunk")
public class FileChunk {

    @TableId(type = IdType.AUTO)
    private Long id;

    // 文件唯一标识(MD5)
    private String fileMd5;

    // 分片序号
    private Integer chunkIndex;

    // 文件原始名字
    private String objectName;

    // 上传时间
    private LocalDateTime createTime;
}

@Data
public class InitUploadRequest {

    /**
     * 文件MD5哈希值
     */
    private String fileMd5;

    /**
     * 原始文件名
     */
    private String filename;

    /**
     * 文件总大小
     */
    private Long fileSize;

    /**
     * 分片总数
     */
    private Integer totalChunks;

    /**
     * 文件 MIME 类型
     */
    private String contentType;
}

@Data
@AllArgsConstructor
public class InitUploadResponse {

    /**
     * 本次上传 ID
     */
    private String uploadId;

    /**
     * 最终文件对象名
     */
    private String objectName;
}

@Data
public class CompleteUploadRequest {

    /**
     * 上传 ID
     */
    private String uploadId;

    /**
     * 最终文件对象名
     */
    private String objectName;

    /**
     * 分片总数
     */
    private Integer totalChunks;
}

工具类

java 复制代码
/**
     * 删除 Minio 中的临时分片文件
     * @param uploadId 上传唯一标识
     * @param totalChunks 总分片数
     */
    private void removeChunks(String uploadId, int totalChunks) throws Exception {
        List<DeleteObject> objects = new LinkedList<>();

        // 组装所有需要删除的分片
        for (int i = 0; i < totalChunks; i++) {
            objects.add(new DeleteObject(getChunkObjectName(uploadId, i)));
        }

        // 批量删除
        Iterable<Result<DeleteError>> results = minioClient.removeObjects(
                RemoveObjectsArgs.builder()
                        .bucket(bucket)
                        .objects(objects)
                        .build()
        );

        // 必须遍历结果,Minio才会真正执行删除
        for (Result<DeleteError> result : results) {
            DeleteError error = result.get();
            System.err.println("删除分片失败: " + error.objectName() + ", " + error.message());
        }
    }

    /**
     * 获取 Minio 文件的预签名访问地址(有时效性,安全)
     * @param objectName 文件路径
     * @return 可直接访问的URL
     */
    private String getPresignedUrl(String objectName) throws Exception {
        return minioClient.getPresignedObjectUrl(
                GetPresignedObjectUrlArgs.builder()
                        .bucket(bucket)
                        .object(objectName)
                        .method(Method.GET)
                        .expiry(60 * 60) // 有效期1小时
                        .build()
        );
    }

    /**
     * 生成分片在 Minio 中的临时对象名
     * @param uploadId 上传唯一标识
     * @param chunkIndex 分片序号
     * @return 临时路径
     */
    private String getChunkObjectName(String uploadId, Integer chunkIndex) {
        return "temp/chunks/" + uploadId + "/" + chunkIndex;
    }

    /**
     * 安全获取文件后缀名
     * @param filename 原始文件名
     * @return 后缀(如 .mp4)
     */
    private String getSuffix(String filename) {
        if (filename == null || filename.isBlank()) {
            return "";
        }

        // URL编码防止中文/特殊字符问题
        String safeName = URLEncoder.encode(filename, StandardCharsets.UTF_8);

        int index = safeName.lastIndexOf(".");
        if (index == -1) {
            return "";
        }

        return safeName.substring(index);
    }
相关推荐
祀爱1 小时前
ASP.NET Core 集成NLog详细教程
数据库·后端·asp.net
鹏程十八少1 小时前
13. Android 面了50位Kotlin候选人,这36个语法坑90%的人答不全
前端·后端·面试
东宇科技1 小时前
用CladueCode来玩tp8+swoole(常用案例)
后端·swoole
Shadow(⊙o⊙)1 小时前
硬核手搓解析!进程-内核分析:命令行参数及环境变量,重构main()
linux·运维·服务器·开发语言·c++·后端·学习
Devin~Y1 小时前
电商AIGC智能客服面试:JVM调优、Spring Cloud微服务、Redis缓存、Kafka消息、K8s观测与RAG落地
java·jvm·spring boot·redis·spring cloud·kafka·kubernetes
毋语天1 小时前
Claude Code 完整安装与配置指南(含 CC-Switch 多供应商切换工具)
后端·python·ai编程
StackNoOverflow2 小时前
RabbitMQ 入门详解(含安装 + 配置 + 管理后台)
开发语言·后端·ruby
斌果^O^2 小时前
普通 SpringBoot 单体项目改造成微服务(Nacos+Gateway + 内部服务免鉴权)
java·spring boot·spring
养肥胖虎10 小时前
Docker学习笔记:后端、数据库和反向代理怎么一起跑起来
后端·nginx·docker·postgresql·go·部署