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);
    }
相关推荐
阿祖zu1 小时前
芝士就是力量!开源私有化部署与 GitHub 双向同步的个人知识笔记 App
前端·后端·ios
不才不才不不才1 小时前
Spring 源码系列(16): doDispatch 全流程——一次请求的主干链路
java·后端·spring
IT_陈寒1 小时前
SpringBoot自动配置失效?这个隐藏配置坑了我一整晚
前端·人工智能·后端
搜狐技术产品小编20231 小时前
告别“黑盒”与误判:如何用“多智能体对抗辩论”重构内容安全审核系统
后端·多agent
DantyWei2 小时前
controller注册及调用时机
后端
edwarddamon2 小时前
Spring Cloud 配置热更新与 Bean 代理机制梳理
java·后端
码事漫谈2 小时前
我用 Seed Evolving 做了个 AI 小说写作工具
后端
Raas1003 小时前
MAIGateway,魔芋企业级AI网关的FinAPI成本竞争力设计
java·后端·网关·api网关·魔芋ai·finapi·mai gateway
计算机魔术师3 小时前
传统基础架构 - 微服务
大数据·后端·微服务·架构·开发工具·编程语言
DantyWei3 小时前
kube-controller-manager的leader选举流程和策略
后端