最近在写视频课程的上传,需要上传的视频几百MB到几个G不等,普通的上传都限制了文件的大小,况且上传的文件太大的话会超时、异常等。所以这时候需要考虑分片上传了,把需要上传的视频分成多个小块上传到,最后再合并成一个视频进行存储。我这里上传到自有的Minio,其它存储应该也是大同小异。

前端:vue3+vue-simple-uploader
后端:springboot+Minio

先看前端效果

vue-simple-uploader原生组件的样式效果很不错了,vue-simple-uploader文档
在这里插入图片描述
下面这个是我上传成功后自定义的样式
在这里插入图片描述

再来看看流程图思路

在这里插入图片描述

有了流程我们就直接上代码了

1、前端安装vue-simple-uploader

npm install vue-simple-uploader@next --save

2、在main.ts引入

import uploader from 'vue-simple-uploader'
import 'vue-simple-uploader/dist/style.css';
// ...
const app = createApp(App)
// ...
// 引入上传组件
app.use(uploader)

3、前端组件全部代码

<template>
  <uploader
    ref="uploaderRef"
    :options="options"
    :auto-start="false"
    :fileStatusText="fileStatusText"
    class="uploader-container"
    @file-added="onFileAdded"
    @file-progress="onFileProgress"
    @file-success="onFileSuccess"
    @file-error="onFileError"
    @file-removed="onDelete"
  >
    <uploader-unsupport>您的浏览器不支持上传组件</uploader-unsupport>
    <uploader-drop>
      <uploader-btn :attrs="attrs">{{deProps.btnText}}</uploader-btn>
    </uploader-drop>
    <uploader-list>
      <template #default="props">
        <!-- 已上传的文件列表 -->
        <div v-for="file in uploadFileList" :key="file.fileId" :file="file" class="raw-list">
          <div class="file-title">{{ file.name }}</div>
          <div class="file-size">{{ parseFloat(file.size / 1024 / 1024).toFixed(2) }} MB</div>
          <div class="file-status">已上传</div>
          <el-button size="mini" type="danger" @click="onDelete(uploadFileList, file)">删除文件</el-button>
        </div>
        <!-- 正在上传的文件列表 -->
        <uploader-file v-for="file in props.fileList" :key="file.fileId" :file="file" :list="true" v-show="!file.completed" />
      </template>
    </uploader-list>
  </uploader>
</template>

<script setup>
import axios from 'axios';
import { getAccessToken,getTenantId } from '@/utils/auth';
import { propTypes } from '@/utils/propTypes'
const token = getAccessToken();
import {config} from '@/config/axios/config';
const emit = defineEmits(['success','delete'])

const fileStatusText = {
  success: '上传成功',
  error: '上传失败',
  uploading: '正在上传',
  paused: '暂停上传',
  waiting: '等待上传'
};
// 原来已上传过的文件列表
const uploadFileList = ref([]);
// const uploader = ref(null);
const currentFile = ref(null);
const isPaused = ref(false);

const deProps = defineProps({
  btnText: propTypes.string.def('选择文件上传') ,
  fileList: propTypes.array.def([]), // 原来已上传的文件列表
  singleFile: propTypes.bool.def(true), // 是否单文件上传
})

uploadFileList.value = deProps.fileList;

/**
 * 上传组件配置
 */
const options = {
  target: config.base_url + '/infra/minio/upload', // 目标上传 URL
  headers: {
      'tenant-id': getTenantId(),
      'Authorization': `Bearer ${token}`
  }, // 接口的定义, 根据实际情况而定
  chunkSize: 5 * 1024 * 1024, // 分块大小
  singleFile:  deProps.singleFile, // 是否单文件上传
  simultaneousUploads: 3, // 同时上传3个分片
  forceChunkSize: true, // 是否强制所有的块都是小于等于 chunkSize 的值。默认是 false。
  // fileParameterName: 'file', // 上传文件时文件的参数名,默认file
  maxChunkRetries: 3, // 最大自动失败重试上传次数
  testChunks: false, // 是否开启服务器分片校验
  // 额外的请求参数
  query: (file) => {
    return {
      uploadId: file.uploadId,
      fileName: file.name,
      totalChunks: file.chunks.length
    };
  },
  // 处理请求参数, 将参数名字修改成接口需要的
  processParams: (params, file, chunk) => {
    params.chunkIndex = chunk.offset; // 分片索引
    return params;
  }
};
// 限制上传的文件类型
const attrs = {
  accept: '.mp4,.png,.jpg,.txt,.pdf,.ppt,.pptx,.doc,docx,.xls,xlsx,.ofd,.zip,.rar',
};

const uploaderRef = ref(null);

/**
 * 模版中禁止了自动上传(:auto-start="false")
 */
const onFileAdded = async (file) => {
  currentFile.value = file;
  isPaused.value = false;
  
  try {
    // 1. 初始化上传会话
    const initResponse = await axios.post(
      `${config.base_url}/infra/minio/init`,
      { fileName: file.name, fileSize: file.size },
      {
        headers: {
            'tenant-id': getTenantId(),
            'Authorization': `Bearer ${token}`
        },
      }
    );

    if (initResponse.data.code === 0) {
      file.uploadId = initResponse.data.data;
    } else {
      throw new Error(initResponse.data.msg);
    }
    
    // 2. 获取已上传分片列表
    const chunksResponse = await axios.get(
      `${config.base_url}/infra/minio/uploaded-parts/${file.uploadId}`,
      {
        params: { totalChunks: file.chunks.length },
        headers: {
            'tenant-id': getTenantId(),
            'Authorization': `Bearer ${token}`
        },
      },
    );
    
    if (chunksResponse.data.code === 0) {
      // 设置已上传分片
      file.uploadedChunks = chunksResponse.data.data || [];
    }
    
    // 开始上传
    file.resume();
  } catch (error) {
    file.cancel();
    console.error('初始化上传出错:', error);
  }
};

// 上传进度事件
const onFileProgress = (rootFile, file, chunk) => {
  // 使用 progress() 方法获取上传进度
  const progress = Math.round(file.progress() * 100);
  console.log(`上传进度: ${progress}%`);
};

// 文件上传成功
const onFileSuccess = async (rootFile, file, response) => {
  try {
    // 调用合并接口
    const mergeResponse = await axios.post(config.base_url + '/infra/minio/merge', {
      uploadId: file.uploadId,
      fileName: file.name
    },{
      headers: {
        'tenant-id': getTenantId(),
        'Authorization': `Bearer ${token}`
      },
    });
    
    if (mergeResponse.data.code === 0) {
      // 添加到已上传文件列表
      uploadFileList.value.push({
        fileId: file.uploadId,
        name: file.name,
        size: file.size,
        url: mergeResponse.data.data
      });
    } else {
      console.error('文件合并失败:', mergeResponse.data.msg);
    }
    console.log(uploadFileList.value)
    emit('success', uploadFileList.value);
  } catch (error) {
    console.error('文件合并请求失败:', error);
  } finally {
    currentFile.value = null;
  }
};

// 文件上传失败
const onFileError = (rootFile, file, message) => {
  console.error('文件上传失败:', message);
  currentFile.value = null;
};

// 暂停/继续上传
const togglePause = () => {
  if (!currentFile.value) return;
  
  if (isPaused.value) {
    currentFile.value.resume();
  } else {
    currentFile.value.pause();
  }
  isPaused.value = !isPaused.value;
};

// 取消上传
const onCancel = async (file) => {
  if (file.status === 'uploading') {
    try {
      // 调用后端取消接口
      await axios.post(`${config.base_url}/infra/minio/cancel/${file.uploadId}`,{
        headers: {
            'tenant-id': getTenantId(),
            'Authorization': `Bearer ${token}`
        },
      });
      file.cancel();
    } catch (error) {
      console.error('取消上传失败:', error);
    }
  } else {
    file.cancel();
  }
  currentFile.value = null;
};

// 删除文件
const onDelete = async (list,file) => {
  try {
    // 调用后端删除接口
    await axios.delete(`http://localhost:48080/admin-api/infra/minio/delete?path=${file.url}`,{
      headers: {
          'tenant-id': getTenantId(),
          'Authorization': `Bearer ${token}`
      },
    });
    
    // 从列表中移除
    const index = list.findIndex(f => f.fileId === file.fileId);
    if (index !== -1) {
      list.splice(index, 1);
    }
    emit('delete', list);
  } catch (error) {
    console.error('删除文件失败:', error);
  }
};
</script>
<style lang="scss" scoped>
.uploader-container {
  border: 1px solid #eee;
  border-radius: 4px;
  padding: 15px;
}

.raw-list{
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 10px 20px;
  .file-title{
    width: 30%;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
}

</style> 

4、使用组件

<upload-chunk 
ref="uploadChunkRef" 
btnText="上传视频" 
:fileList="fileList" 
@success="uploadChunkSuccess" 
@delete="uploadChunkDelete" />

前端的代码就这么多,接下来看看后端。

1、添加minio依赖

<dependency>
    <groupId>io.minio</groupId>
    <artifactId>minio</artifactId>
    <version>8.5.7</version>
</dependency>

2、创建3个请求实体类

public class FileChunkInitReqVO {
    private String fileId;
    private String fileName;
    private Long fileSize;
}

public class FileChunkMergeReqVO {
    private String uploadId;
    private String fileName;
}

public class FileChunkUploadReqVO {
    private String uploadId;
    private String fileName;
    private Integer chunkIndex;
    private Integer totalChunks;
    private MultipartFile file;
}

3、Controller类

/**
 * 删除指定文件
 *
 * @param path 文件ID
 * @return 响应状态
 */
@DeleteMapping("/delete")
@PermitAll
public CommonResult<String> deleteFile(@RequestParam String path) {
    try {
        fileService.deleteFile(path);
        return CommonResult.success("File deleted successfully.");
    } catch (IOException | NoSuchAlgorithmException | InvalidKeyException e) {
        return CommonResult.error(HttpStatus.INTERNAL_SERVER_ERROR.value(), "Error deleting file: " + e.getMessage());
    }
}

///初始化分片上传
@PostMapping("/init")
@PermitAll
public CommonResult<String> initUploadSession(@RequestBody FileChunkInitReqVO reqVO) {
    try {
        String uploadId = fileService.initUploadSession(reqVO.getFileName(), reqVO.getFileSize());
        return CommonResult.success(uploadId);
    } catch (Exception e) {
        log.error("初始化上传会话失败", e);
        return CommonResult.error(HttpStatus.INTERNAL_SERVER_ERROR.value(), "初始化上传会话失败");
    }
}

//上传文件分片
@PostMapping("/upload")
@PermitAll
public CommonResult<Boolean> uploadFilePart(@Validated FileChunkUploadReqVO reqVO) {
    try {
        boolean result = fileService.uploadFilePart(
                reqVO.getUploadId(),
                reqVO.getChunkIndex(),
                reqVO.getTotalChunks(),
                reqVO.getFile()
        );
        return CommonResult.success(result);
    } catch (Exception e) {
        log.error("上传分片失败", e);
        return CommonResult.error(HttpStatus.INTERNAL_SERVER_ERROR.value(), "上传分片失败");
    }
}

//获取已上传分片列表
@GetMapping("/uploaded-parts/{uploadId}")
@PermitAll
public CommonResult<List<Integer>> getUploadedParts(
        @PathVariable String uploadId,
        @RequestParam int totalChunks) {
    try {
        List<Integer> uploadedParts = fileService.getUploadedParts(uploadId, totalChunks);
        return CommonResult.success(uploadedParts);
    } catch (Exception e) {
        log.error("获取已上传分片失败", e);
        return CommonResult.error(HttpStatus.INTERNAL_SERVER_ERROR.value(), "获取已上传分片失败");
    }
}

//合并文件分片
@PostMapping("/merge")
@PermitAll
public CommonResult<String> mergeFileParts(@RequestBody FileChunkMergeReqVO reqVO) {
    try {
        String fileUrl = fileService.mergeFileParts(
                reqVO.getUploadId(),
                reqVO.getFileName()
        );
        return CommonResult.success(fileUrl);
    } catch (Exception e) {
        log.error("合并文件失败", e);
        return CommonResult.error(HttpStatus.INTERNAL_SERVER_ERROR.value(), "合并文件失败");
    }
}

//取消上传
@PostMapping("/cancel/{uploadId}")
@PermitAll
public CommonResult<Boolean> cancelUpload(@PathVariable String uploadId) {
    try {
        fileService.cancelUpload(uploadId);
        return CommonResult.success(true);
    } catch (Exception e) {
        log.error("取消上传失败", e);
        return CommonResult.error(HttpStatus.INTERNAL_SERVER_ERROR.value(), "取消上传失败");
    }
}

5、service类


@Service
public class MinioChunkUploadService {

    @Resource
    private FileMapper fileMapper;
	// 改成自己的
    private static final String endpoint = "http://xxxx";
    private static final String accessKey = "BDnZ1SS3Kq0pxxxxxx";
    private static final String accessSecret = "MAdjW4rd0hXoZNrxxxxxxxx";
    private static final String bucketName = "xxtigrixxx";
    private static final String CHUNK_PREFIX = "chunks/";
    private static final String MERGED_PREFIX = "merged/";

    /**
     * 创建 Minio 客户端
     * @return
     */
    private MinioClient createMinioClient() {
        return MinioClient.builder()
                .endpoint(endpoint)
                .credentials(accessKey, accessSecret)
                .build();
    }

    /**
     * 删除指定文件
     *
     * @param path 文件路径
     */
    public void deleteFile(String path) throws IOException, NoSuchAlgorithmException, InvalidKeyException {
        MinioClient minioClient = createMinioClient();
        try {
            String fileNames = MERGED_PREFIX + path;
            // 删除文件
            minioClient.removeObject(RemoveObjectArgs.builder().bucket(bucketName).object(fileNames).build());
        } catch (MinioException e) {
            throw new IOException("Error deleting file: " + e.getMessage(), e);
        }
    }

    // 新增方法:检查分片是否已存在
    public boolean checkChunkExists(String fileId, int chunkIndex)
            throws IOException, NoSuchAlgorithmException, InvalidKeyException {

        MinioClient minioClient = createMinioClient();
        try {
            String objectName = fileId + "/chunk-" + chunkIndex;
            minioClient.statObject(StatObjectArgs.builder()
                    .bucket(bucketName)
                    .object(objectName)
                    .build());
            return true;
        } catch (Exception e) {
            throw new IOException("Error checking chunk existence: " + e.getMessage(), e);
        }
    }

    // 新增方法:获取已上传分片列表
    public List<Integer> getUploadedChunks(String fileId)
            throws IOException, NoSuchAlgorithmException, InvalidKeyException {

        MinioClient minioClient = createMinioClient();
        List<Integer> uploadedChunks = new ArrayList<>();

        // 列出所有分片
        Iterable<Result<Item>> results = minioClient.listObjects(
                ListObjectsArgs.builder()
                        .bucket(bucketName)
                        .prefix(fileId + "/chunk-")
                        .build());

        for (Result<Item> result : results) {
            Item item = null;
            try {
                item = result.get();
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
            String objectName = item.objectName();
            // 提取分片索引: fileId/chunk-123
            String chunkStr = objectName.substring(objectName.lastIndexOf("-") + 1);
            try {
                int chunkIndex = Integer.parseInt(chunkStr);
                uploadedChunks.add(chunkIndex);
            } catch (NumberFormatException ignored) {
                // 忽略无效分片名
            }
        }
        return uploadedChunks;
    }


    /**
     * 初始化上传会话
     */
    public String initUploadSession(String fileName, long fileSize) {
        // 生成唯一上传ID
        return UUID.randomUUID().toString();
    }

    /**
     * 上传文件分片
     */
    public boolean uploadFilePart(String uploadId, int chunkIndex, int totalChunks, MultipartFile filePart)
            throws IOException, NoSuchAlgorithmException, InvalidKeyException, MinioException {

        // 构建分片对象名称
        String objectName = CHUNK_PREFIX + uploadId + "/" + chunkIndex;

        MinioClient minioClient = createMinioClient();
        // 上传文件分片
        minioClient.putObject(
                PutObjectArgs.builder()
                        .bucket(bucketName)
                        .object(objectName)
                        .stream(filePart.getInputStream(), filePart.getSize(), -1)
                        .contentType(filePart.getContentType())
                        .build()
        );

        return true;
    }

    /**
     * 获取已上传分片列表
     */
    public List<Integer> getUploadedParts(String uploadId, int totalChunks)
            throws IOException, NoSuchAlgorithmException, InvalidKeyException, MinioException {

        List<Integer> uploadedParts = new ArrayList<>();
        MinioClient minioClient = createMinioClient();
        // 列出所有分片
        Iterable<Result<Item>> results = minioClient.listObjects(
                ListObjectsArgs.builder()
                        .bucket(bucketName)
                        .prefix(CHUNK_PREFIX + uploadId + "/")
                        .build()
        );

        for (Result<Item> result : results) {
            try {
                Item item = result.get();
                String objectName = item.objectName();
                // 提取分片索引: chunks/uploadId/123
                String chunkIndexStr = objectName.substring(objectName.lastIndexOf("/") + 1);
                uploadedParts.add(Integer.parseInt(chunkIndexStr));
            } catch (Exception e) {
                System.out.println(e.getMessage());
            }
        }

        return uploadedParts;
    }

    /**
     * 合并文件分片
     */
    public String mergeFileParts(String uploadId, String fileName)
            throws IOException, NoSuchAlgorithmException, InvalidKeyException, MinioException {

        // 获取所有分片
        List<String> partNames = new ArrayList<>();
        List<ComposeSource> sources = new ArrayList<>();
        MinioClient minioClient = createMinioClient();
        Iterable<Result<Item>> results = minioClient.listObjects(
                ListObjectsArgs.builder()
                        .bucket(bucketName)
                        .prefix(CHUNK_PREFIX + uploadId + "/")
                        .build()
        );

        for (Result<Item> result : results) {
            Item item = result.get();
            partNames.add(item.objectName());
            sources.add(
                    ComposeSource.builder()
                            .bucket(bucketName)
                            .object(item.objectName())
                            .build()
            );
        }

        // 按分片索引排序
        sources.sort((a, b) -> {
            int indexA = Integer.parseInt(a.object().substring(a.object().lastIndexOf("/") + 1));
            int indexB = Integer.parseInt(b.object().substring(b.object().lastIndexOf("/") + 1));
            return Integer.compare(indexA, indexB);
        });

        // 构建最终文件对象名称
        String finalObjectName = MERGED_PREFIX + DateUtil.format(LocalDateTime.now(), "yyyyMMdd") + "/" + uploadId + "/" + fileName;

        // 合并文件
        minioClient.composeObject(
                ComposeObjectArgs.builder()
                        .bucket(bucketName)
                        .object(finalObjectName)
                        .sources(sources)
                        .build()
        );

        // 删除分片文件
        for (String partName : partNames) {
            minioClient.removeObject(
                    RemoveObjectArgs.builder()
                            .bucket(bucketName)
                            .object(partName)
                            .build()
            );
        }

        // 返回文件访问URL
        return finalObjectName;
    }

    /**
     * 取消上传
     */
    public void cancelUpload(String uploadId)
            throws IOException, NoSuchAlgorithmException, InvalidKeyException, MinioException {
        MinioClient minioClient = createMinioClient();
        // 删除所有分片
        Iterable<Result<Item>> results = minioClient.listObjects(
                ListObjectsArgs.builder()
                        .bucket(bucketName)
                        .prefix(CHUNK_PREFIX + uploadId + "/")
                        .build()
        );

        for (Result<Item> result : results) {
            Item item = result.get();
            minioClient.removeObject(
                    RemoveObjectArgs.builder()
                            .bucket(bucketName)
                            .object(item.objectName())
                            .build()
            );
        }
    }
}

OK,全部代码完成,有问题或哪里不对的地方欢迎指正

Logo

魔乐社区(Modelers.cn) 是一个中立、公益的人工智能社区,提供人工智能工具、模型、数据的托管、展示与应用协同服务,为人工智能开发及爱好者搭建开放的学习交流平台。社区通过理事会方式运作,由全产业链共同建设、共同运营、共同享有,推动国产AI生态繁荣发展。

更多推荐