惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Google DeepMind News
Google DeepMind News
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
酷 壳 – CoolShell
酷 壳 – CoolShell
WordPress大学
WordPress大学
小众软件
小众软件
博客园 - 司徒正美
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
Jina AI
Jina AI
Hugging Face - Blog
Hugging Face - Blog
博客园 - Franky
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
量子位
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
雷峰网
雷峰网
云风的 BLOG
云风的 BLOG
M
MIT News - Artificial intelligence
F
Fortinet All Blogs
T
Tailwind CSS Blog
Martin Fowler
Martin Fowler
I
InfoQ
The GitHub Blog
The GitHub Blog
有赞技术团队
有赞技术团队
The Cloudflare Blog
罗磊的独立博客

博客园 - 漫思

python使用.env构建开发和生产环境 python项目的构建 nodejs构建CICD时的思考  成为 AI 智能体工程师的 10 个步骤 es6的 yield python 中的 yield 笔记本的A壳 thinkpad 更换 reduce() in Python python的字段赋值和取值的操作 python的语法类似于lodash分组展开,合并分组操作 SelectMany C# lodash 数组的常用做法 lodash里面的常用方法 技术的边界 Reduce 和 Transduce 的含义 尤雨溪创办的 VoidZero 官宣加入 Cloudflare,前端 Vite 等保持开源 Ramda 函数库参考教程 ramda es 数组的方法 flatmap map object.entity的教程 Map与FlatMap:在数据处理中的区别与联系 flat、flatmap与map的用法区别1 flat、flatmap与map的用法区别 AI不是从天而降,它经历了七十年三起三落:读懂AI的第三课 Agent 17 种架构模式 分析 & 思考 只有踩过坑才懂:前端生成唯一 ID,别用 Date.now ()了!试试它crypto.randomUUID() FastAPI python并发 代码是 AI 写的,生产事故谁背锅? AI Agent 走出 Demo 幻觉的唯一解药:Harness Engineering
大文件上传下载处理方案-断点续传,秒传,分片,合并
漫思 · 2026-04-09 · via 博客园 - 漫思

通常一些文件如视频文件体积都比较大,对于这些文件的上传需求要满足大文件的上传要求。http协议本身对上传文件大小没有限制,但是客户的网络环境质量、电脑硬件环境等参差不齐,如果一个大文件快上传完了网断了没有上传完成,需要客户重新上传,用户体验非常差,所以对于大文件上传的要求最基本的是断点续传。

引用百度百科:断点续传指的是在下载或上传时,将下载或上传任务(一个文件或一个压缩包)人为的划分为几个部分,每一个部分采用一个线程进行上传或下载,如果碰到网络故障,可以从已经上传或下载的部分开始继续上传下载未完成的部分,而没有必要从头开始上传下载,断点续传可以提高节省操作时间,提高用户体验性。

大文件分片上传方案基于 “前端分片 + 后端合并 + 断点续传 + 秒传” 核心设计,完整流程分为 前端预处理 → 校验阶段 → 分片上传 → 后端合并 → 播放 / 下载 五个核心阶段,前后端分工明确:

import com.lgh.common.properties.MinIoProperties;
import com.lgh.web.service.VedioService;
import io.minio.*;
import io.minio.errors.MinioException;
import io.minio.messages.DeleteError;
import io.minio.messages.DeleteObject;
import io.minio.messages.Item;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.codec.digest.DigestUtils;
import org.apache.commons.compress.utils.IOUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.*;
import java.util.List;
import java.util.ArrayList;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
 * 视频服务实现类
 * @Author GuihaoLv
 */
@Service
@Slf4j
public class VedioServiceImpl implements VedioService {
    @Autowired
    private MinioClient minioClient;
    @Autowired
    private MinIoProperties minIoProperties;
     /**
     * 文件上传前检查
     * @param fileMd5
     * @return
     */
    public Boolean checkFileExists(String fileMd5) {
        // 1. 构建完整文件在MinIO中的存储路径(与分块目录规则一致)
        String fullFilePath = getFullFilePath(fileMd5);
        try {
            // 2. 检查MinIO中是否存在该对象
            StatObjectArgs statArgs = StatObjectArgs.builder()
                    .bucket(minIoProperties.getBucketName())
                    .object(fullFilePath)
                    .build();
            minioClient.statObject(statArgs);
            log.info("文件已存在,MD5:{},MinIO路径:{}", fileMd5, fullFilePath);
            return true;
        } catch (MinioException e) {
            // MinIO返回对象不存在异常,说明文件未上传
            if (e.getMessage().equals("NoSuchKey")) {
                log.info("文件不存在,MD5:{}", fileMd5);
                return false;
            }
            // 其他异常打印日志,返回false
            log.error("检查文件存在性失败,MD5:{}", fileMd5, e);
            return false;
        } catch (Exception e) {
            log.error("检查文件存在性异常,MD5:{}", fileMd5, e);
            return false;
        }
    }
    /**
     * 检查分块是否已存在
     * 逻辑:拼接分块文件路径,检查MinIO中是否存在该分块对象
     */
    public Boolean checkChunkExists(String fileMd5, int chunk) {
        // 1. 构建分块文件在MinIO中的路径
        String chunkFilePath = getChunkFileFolderPath(fileMd5) + chunk;
        try {
            // 2. 检查分块对象是否存在
            StatObjectArgs statArgs = StatObjectArgs.builder()
                    .bucket(minIoProperties.getBucketName())
                    .object(chunkFilePath)
                    .build();
            minioClient.statObject(statArgs);
            log.info("分块已存在,MD5:{},分块索引:{}", fileMd5, chunk);
            return true;
        } catch (MinioException e) {
            if (e.getMessage().equals("NoSuchKey")) {
                log.info("分块不存在,MD5:{},分块索引:{}", fileMd5, chunk);
                return false;
            }
            log.error("检查分块存在性失败,MD5:{},分块索引:{}", fileMd5, chunk, e);
            return false;
        } catch (Exception e) {
            log.error("检查分块存在性异常,MD5:{},分块索引:{}", fileMd5, chunk, e);
            return false;
        }
    }
    /**
     * 上传分块文件到MinIO
     * 逻辑:MultipartFile转InputStream,上传到分块指定路径
     */
    public Boolean uploadChunk(MultipartFile file, String fileMd5, int chunk) {
        // 1. 校验参数
        if (file.isEmpty()) {
            log.error("上传分块失败,文件为空,MD5:{},分块索引:{}", fileMd5, chunk);
            return false;
        }
        // 2. 构建分块存储路径
        String chunkFilePath = getChunkFileFolderPath(fileMd5) + chunk;
        try (InputStream inputStream = file.getInputStream()) {
            // 3. 上传分块到MinIO
            PutObjectArgs putArgs = PutObjectArgs.builder()
                    .bucket(minIoProperties.getBucketName())
                    .object(chunkFilePath)
                    .stream(inputStream, file.getSize(), -1) // -1表示自动检测文件大小
                    .contentType(file.getContentType())
                    .build();
            minioClient.putObject(putArgs);
            log.info("分块上传成功,MD5:{},分块索引:{},路径:{}", fileMd5, chunk, chunkFilePath);
            return true;
        } catch (Exception e) {
            log.error("分块上传失败,MD5:{},分块索引:{}", fileMd5, chunk, e);
            return false;
        }
    }
    /**
     * 合并分块文件(优化版)
     * 核心逻辑:1. 有序构建分块列表 2. 合并文件 3. MD5校验 4. 清理分块
     * @param fileMd5 文件唯一标识(MD5)
     * @param fileName 原始文件名(含扩展名)
     * @param chunkTotal 分块总数(新增参数:避免遍历MinIO获取分块,提升性能)
     * @return 合并是否成功
     */
    public Boolean mergeChunk(String fileMd5, String fileName, int chunkTotal) {
        // 1. 基础路径构建
        String chunkFileFolderPath = getChunkFileFolderPath(fileMd5); //分块文件存储目录
        String extName = fileName.substring(fileName.lastIndexOf(".")); // 提取文件扩展名
        String mergeFilePath = getFilePathByMd5(fileMd5, extName); // 合并后文件路径
        try {
            // 2. 有序构建分块源列表(参考代码核心逻辑:按索引生成,保证顺序)
            List<ComposeSource> sourceObjectList = Stream.iterate(0, i -> ++i)
                    .limit(chunkTotal)
                    .map(i -> ComposeSource.builder()
                            .bucket(minIoProperties.getBucketName())
                            .object(chunkFileFolderPath.concat(Integer.toString(i)))
                            .build())
                    .collect(Collectors.toList());
            // 3. 执行MinIO分块合并
            ObjectWriteResponse response = minioClient.composeObject(
                    ComposeObjectArgs.builder()
                            .bucket(minIoProperties.getBucketName())
                            .object(mergeFilePath)
                            .sources(sourceObjectList)
                            .build());
            log.info("合并文件成功:{}", mergeFilePath);
            // 4. 下载合并后的文件,进行MD5校验(核心:保证文件完整性)
            File minioFile = downloadFileFromMinIO(minIoProperties.getBucketName(), mergeFilePath);
            if (minioFile == null) {
                log.error("下载合并后文件失败,mergeFilePath:{}", mergeFilePath);
                return false;
            }
            // 5. MD5校验逻辑
            try (InputStream newFileInputStream = new FileInputStream(minioFile)) {
                String md5Hex = DigestUtils.md5Hex(newFileInputStream);
                // 比对MD5,不一致则返回失败
                if (!fileMd5.equals(md5Hex)) {
                    log.error("文件合并校验失败,MD5不一致:原始{},合并后{}", fileMd5, md5Hex);
                    return false;
                }
                // 可选:此处可添加文件大小记录、入库等业务逻辑
                log.info("文件MD5校验通过,MD5:{}", fileMd5);
            }
            // 6. 清理分块文件(参考代码的清理逻辑,增加容错)
            clearChunkFiles(chunkFileFolderPath, chunkTotal);
            // 7. 临时文件删除(finally中兜底)
            return true;
        } catch (Exception e) {
            log.error("合并文件失败,fileMd5:{},异常:{}", fileMd5, e.getMessage(), e);
            return false;
        } finally {
            // 兜底:删除临时文件(若存在)
            File minioFile = new File(System.getProperty("java.io.tmpdir"), "minio.merge");
            if (minioFile.exists()) {
                minioFile.delete();
            }
        }
    }
    /**
     * 从MinIO下载文件到本地临时文件(参考代码的downloadFileFromMinIO)
     * @param bucket 桶名
     * @param objectName 对象路径
     * @return 本地临时文件
     */
    private File downloadFileFromMinIO(String bucket, String objectName) {
        File minioFile = null;
        FileOutputStream outputStream = null;
        try {
            // 从MinIO获取文件流
            InputStream stream = minioClient.getObject(GetObjectArgs.builder()
                    .bucket(bucket)
                    .object(objectName)
                    .build());
            // 创建临时文件
            minioFile = File.createTempFile("minio", ".merge");
            outputStream = new FileOutputStream(minioFile);
            IOUtils.copy(stream, outputStream); // 拷贝流到临时文件
            return minioFile;
        } catch (Exception e) {
            log.error("下载MinIO文件失败,bucket:{},object:{}", bucket, objectName, e);
            return null;
        } finally {
            // 关闭流
            if (outputStream != null) {
                try {
                    outputStream.close();
                } catch (IOException e) {
                    log.error("关闭文件输出流失败", e);
                }
            }
        }
    }
    /**
     * 清除分块文件(参考代码的clearChunkFiles,优化异常处理)
     * @param chunkFileFolderPath 分块文件目录
     * @param chunkTotal 分块总数
     */
    private void clearChunkFiles(String chunkFileFolderPath, int chunkTotal) {
        try {
            List<DeleteObject> deleteObjects = Stream.iterate(0, i -> ++i)
                    .limit(chunkTotal)
                    .map(i -> new DeleteObject(chunkFileFolderPath.concat(Integer.toString(i))))
                    .collect(Collectors.toList());
            RemoveObjectsArgs removeObjectsArgs = RemoveObjectsArgs.builder()
                    .bucket(minIoProperties.getBucketName())
                    .objects(deleteObjects)
                    .build();
            Iterable<Result<DeleteError>> results = minioClient.removeObjects(removeObjectsArgs);
            results.forEach(r -> {
                try {
                    DeleteError deleteError = r.get();
                    if (deleteError != null) {
                        log.error("清除分块文件失败,objectname:{}", deleteError.objectName());
                    }
                } catch (Exception e) {
                    log.error("遍历分块删除结果失败", e);
                }
            });
            log.info("分块文件清理完成,共处理{}个分块", chunkTotal);
        } catch (Exception e) {
            log.error("清除分块文件失败,chunkFileFolderPath:{}", chunkFileFolderPath, e);
        }
    }
    // ------------------- 私有工具方法 -------------------
    /**
     * 构建分块文件存储目录路径
     * 规则:md5前两位拆分目录 + md5 + chunk/
     * 示例:md5=abc123 → a/b/abc123/chunk/
     */
    private String getChunkFileFolderPath(String fileMd5) {
        return fileMd5.substring(0, 1) + "/" + fileMd5.substring(1, 2) + "/" + fileMd5 + "/chunk/";
    }
    /**
     * 构建完整文件存储路径(不带文件名)
     */
    private String getFullFilePath(String fileMd5) {
        return fileMd5.substring(0, 1) + "/" + fileMd5.substring(1, 2) + "/" + fileMd5 + "/";
    }
    /**
     * 构建完整文件存储路径(带文件名)
     */
    private String getFullFilePath(String fileMd5, String fileName) {
        return getFullFilePath(fileMd5) + fileName;
    }
    /**
     * 参考代码的getFilePathByMd5:构建合并后文件的完整路径
     * @param fileMd5 文件MD5
     * @param fileExt 文件扩展名(含.)
     * @return 完整路径
     */
    private String getFilePathByMd5(String fileMd5, String fileExt) {
        return fileMd5.substring(0, 1) + "/" + fileMd5.substring(1, 2) + "/" + fileMd5 + "/" + fileMd5 + fileExt;
    }
}