当前位置: 首页 > news >正文

Springboot实现断点续传、分片下载

原理

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

合并:下载时通过线程池创建任务进行下载或上传、当判断最后一个分片传完时,调用合并方法,根据之前定义的文件名称顺序进行合并,肯能出现最后一个分片传完,之前分片未传完的情况,需要使用for循环进行判断,多文件未传输完,则等待一会继续判断。

本文将详细介绍断点续传、分片下载的前后端完整代码(前端代码需完善,后端逻辑可直接用)

目录结构

一、后端代码

项目依赖

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
        <version>2.3.1</version>
    </dependency>

    <dependency>
        <groupId>cn.hutool</groupId>
        <artifactId>hutool-all</artifactId>
        <version>5.1.0</version>
    </dependency>
</dependencies>

application.yml配置

file:
  upload:
    dir: D:/tmp
spring:
  servlet:
    multipart:
      enabled: true
      max-file-size: 100MB
      max-request-size: 100MB
      file-size-threshold: 2KB

实体类

FileChunk

import lombok.Data;

@Data
public class FileChunk {
    /**
     * 当前文件块,从1开始
     */
    private Integer chunkNumber;

    /**
     * 分块大小
     */
    private Long chunkSize;

    /**
     * 当前分块大小
     */
    private Long currentChunkSize;

    /**
     * 总大小
     */
    private Long totalSize;

    /**
     * 文件标识
     */
    private String identifier;

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

    /**
     * 相对路径
     */
    private String relativePath;

    /**
     * 总块数
     */
    private Integer totalChunks;
}

响应类

WebResponse

public class WebResponse {
    private boolean success;
    private String message;
    private Object data;

    public WebResponse(boolean success, String message, Object data) {
        this.success = success;
        this.message = message;
        this.data = data;
    }

    public static WebResponse success(String message, Object data) {
        return new WebResponse(true, message, data);
    }

    public static WebResponse success(String message) {
        return new WebResponse(true, message, null);
    }

    public static WebResponse error(String message) {
        return new WebResponse(false, message, null);
    }

    public boolean isSuccess() {
        return success;
    }

    public void setSuccess(boolean success) {
        this.success = success;
    }

    public String getMessage() {
        return message;
    }

    public void setMessage(String message) {
        this.message = message;
    }

    public Object getData() {
        return data;
    }

    public void setData(Object data) {
        this.data = data;
    }
}

断点续传

FileUploadController

import com.dreampen.dto.FileChunk;
import com.dreampen.response.WebResponse;
import com.dreampen.service.FileUploadService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

@RestController
@RequestMapping("/api/upload")
@CrossOrigin // 允许跨域请求
public class FileUploadController {

    @Autowired
    private FileUploadService fileUploadService;

    /**
     * 检查文件或文件块是否已存在
     */
    @GetMapping("/check")
    public ResponseEntity<Void> checkFileExists(FileChunk chunk) {
        boolean exists = fileUploadService.checkFileExists(chunk);
        if (exists) {
            // 分片存在,返回 200
            return ResponseEntity.ok().build();
        } else {
            // 分片不存在,返回 404
            return ResponseEntity.notFound().build();
        }
    }

    /**
     * 上传文件块
     */
    @PostMapping("/chunk")
    public WebResponse uploadChunk(
            @RequestParam(value = "chunkNumber") Integer chunkNumber,
            @RequestParam(value = "chunkSize") Long chunkSize,
            @RequestParam(value = "currentChunkSize") Long currentChunkSize,
            @RequestParam(value = "totalSize") Long totalSize,
            @RequestParam(value = "identifier") String identifier,
            @RequestParam(value = "filename") String filename,
            @RequestParam(value = "totalChunks") Integer totalChunks,
            @RequestParam("file") MultipartFile file) {

        String identifierName = identifier;
        // 如果 identifierName 包含逗号,取第一个值
        if (identifierName.contains(",")) {
            identifierName = identifierName.split(",")[0];
        }

        FileChunk chunk = new FileChunk();
        chunk.setChunkNumber(chunkNumber);
        chunk.setChunkSize(chunkSize);
        chunk.setCurrentChunkSize(currentChunkSize);
        chunk.setTotalSize(totalSize);
        chunk.setIdentifier(identifierName);
        chunk.setFilename(filename);
        chunk.setTotalChunks(totalChunks);

        return fileUploadService.uploadChunk(chunk, file);
    }

    /**
     * 合并文件块
     */
    @PostMapping("/merge")
    public WebResponse mergeChunks(
            @RequestParam("identifier") String identifier,
            @RequestParam("filename") String filename,
            @RequestParam("totalChunks") Integer totalChunks) {
        return fileUploadService.mergeChunks(identifier, filename, totalChunks);
    }
}

FileUploadService

import cn.hutool.core.io.FileUtil;
import com.dreampen.dto.FileChunk;
import com.dreampen.response.WebResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

import java.io.File;
import java.io.IOException;
import java.io.RandomAccessFile;

@Service
public class FileUploadService {

    private static final Logger logger = LoggerFactory.getLogger(FileUploadService.class);

    @Value("${file.upload.dir}")
    private String uploadDir;

    /**
     * 检查文件是否已上传过
     */
    public boolean checkFileExists(FileChunk chunk) {
        String storeChunkPath = uploadDir + File.separator + "chunks" + File.separator + chunk.getIdentifier() + File.separator + chunk.getChunkNumber();
        File storeChunk = new File(storeChunkPath);
        return storeChunk.exists() && chunk.getChunkSize() == storeChunk.length();
    }

    /**
     * 上传文件块
     */
    public WebResponse uploadChunk(FileChunk chunk, MultipartFile file) {
        try {
            if (file.isEmpty()) {
                return WebResponse.error("文件块为空");
            }

            // 创建块文件目录
            String chunkDirPath = uploadDir + File.separator + "chunks" + File.separator + chunk.getIdentifier();
            File chunkDir = new File(chunkDirPath);
            if (!chunkDir.exists()) {
                chunkDir.mkdirs();
            }

            // 保存分块
            String chunkPath = chunkDirPath + File.separator + chunk.getChunkNumber();
            file.transferTo(new File(chunkPath));

            return WebResponse.success("文件块上传成功");
        } catch (IOException e) {
            logger.error(e.getMessage(),e);
            return WebResponse.error("文件块上传失败: " + e.getMessage());
        }
    }

    /**
     * 合并文件块
     */
    public WebResponse mergeChunks(String identifier, String filename, Integer totalChunks) {
        try {
            String chunkDirPath = uploadDir + File.separator + "chunks" + File.separator + identifier;
            if(!FileUtil.exist(chunkDirPath)){
                return WebResponse.error("文件合并失败, 目录不存在" );
            }

            File chunkDir = new File(chunkDirPath);
            // 创建目标文件
            String filePath = uploadDir + File.separator + filename;
            File destFile = new File(filePath);
            if (destFile.exists()) {
                destFile.delete();
            }
            // 使用RandomAccessFile合并文件块
            try (RandomAccessFile randomAccessFile = new RandomAccessFile(destFile, "rw")) {
                byte[] buffer = new byte[1024];
                for (int i = 1; i <= totalChunks; i++) {
                    File chunk = new File(chunkDirPath + File.separator + i);
                    if (!chunk.exists()) {
                        return WebResponse.error("文件块" + i + "不存在");
                    }

                    try (java.io.FileInputStream fis = new java.io.FileInputStream(chunk)) {
                        int len;
                        while ((len = fis.read(buffer)) != -1) {
                            randomAccessFile.write(buffer, 0, len);
                        }
                    }
                }
            }

            // 清理临时文件块
            FileUtil.del(chunkDir);

            return WebResponse.success("文件合并成功", filePath);
        } catch (IOException e) {
            logger.error(e.getMessage(),e);
            return WebResponse.error("文件合并失败: " + e.getMessage());
        }
    }
}

分片下载

FileDownloadController

import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.io.File;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

@RestController
@RequestMapping("/api/download")
@CrossOrigin
public class FileDownloadController {

    @Value("${file.upload.dir}")
    private String uploadDir;

    /**
     * 获取文件列表
     */
    @GetMapping("/files")
    public ResponseEntity<List<Map<String, Object>>> getFileList() {
        File dir = new File(uploadDir);
        if (!dir.exists() || !dir.isDirectory()) {
            return ResponseEntity.ok(new ArrayList<>());
        }

        List<Map<String, Object>> fileList = new ArrayList<>();
        File[] files = dir.listFiles((d, name) -> !name.equals("chunks"));
        if (files != null) {
            for (File file : files) {
                if (file.isFile()) {
                    Map<String, Object> fileInfo = new HashMap<>();
                    fileInfo.put("fileName", file.getName());
                    fileInfo.put("size", file.length());
                    fileList.add(fileInfo);
                }
            }
        }
        return ResponseEntity.ok(fileList);
    }

    /**
     * 分片下载文件
     */
    @GetMapping("/chunk/{fileName}")
    public ResponseEntity<byte[]> downloadChunk(
            @PathVariable String fileName,
            @RequestHeader(value = "Range", required = false) String rangeHeader
    ) {
        File file = new File(uploadDir + File.separator + fileName);

        if (!file.exists()) {
            return ResponseEntity.notFound().build();
        }

        try {
            long fileLength = file.length();
            long start = 0;
            long end = fileLength - 1;

            // 解析Range头
            if (rangeHeader != null && rangeHeader.startsWith("bytes=")) {
                String[] ranges = rangeHeader.substring("bytes=".length()).split("-");
                start = Long.parseLong(ranges[0]);
                if (ranges.length > 1 && !ranges[1].isEmpty()) {
                    end = Long.parseLong(ranges[1]);
                }
            }

            // 边界检查
            if (start < 0 || end >= fileLength || start > end) {
                return ResponseEntity.status(HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE).build();
            }

            long contentLength = end - start + 1;

            // 读取指定范围的文件内容
            byte[] content = new byte[(int) contentLength];
            try (RandomAccessFile randomAccessFile = new RandomAccessFile(file, "r")) {
                randomAccessFile.seek(start);
                randomAccessFile.read(content);
            }

            HttpHeaders headers = new HttpHeaders();
            headers.add("Content-Range", String.format("bytes %d-%d/%d", start, end, fileLength));
            headers.add("Accept-Ranges", "bytes");
            headers.add("Content-Type", "application/octet-stream");
            headers.add("Content-Length", String.valueOf(contentLength));
            headers.add("Content-Disposition", "attachment; filename=\"" + fileName + "\"");

            return new ResponseEntity<>(content, headers, 
                rangeHeader != null ? HttpStatus.PARTIAL_CONTENT : HttpStatus.OK);

        } catch (IOException e) {
            e.printStackTrace();
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
        }
    }
}

前端代码

index.html

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title></title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet">
    <style>
        .upload-container, .download-container {
            margin-top: 30px;
            padding: 20px;
            border: 1px solid #ddd;
            border-radius: 5px;
        }
        .progress {
            margin-top: 10px;
            height: 25px;
        }
        .file-list {
            margin-top: 20px;
        }
        .file-item {
            padding: 10px;
            margin-bottom: 5px;
            border: 1px solid #eee;
            border-radius: 5px;
            display: flex;
            justify-content: space-between;
            align-items: center;
        }
    </style>
</head>
<body>
<div class="container">
    <!-- 上传区域 -->
    <div class="upload-container">
        <h3>文件上传(支持断点续传)</h3>
        <div class="mb-3">
            <label for="fileUpload" class="form-label">选择文件</label>
            <input class="form-control" type="file" id="fileUpload">
        </div>
        <button id="uploadBtn" class="btn btn-primary">上传文件</button>
        <div class="progress d-none" id="uploadProgress">
            <div class="progress-bar" role="progressbar" style="width: 0%;" id="uploadProgressBar">0%</div>
        </div>
        <div id="uploadStatus" class="mt-2"></div>
    </div>

    <div class="download-container">
        <h3>文件列表</h3>
        <button id="refreshBtn" class="btn btn-secondary mb-3">刷新文件列表</button>
        <div id="fileList" class="file-list">
            <div class="alert alert-info">点击刷新按钮获取文件列表</div>
        </div>
        <div class="progress d-none" id="downloadProgress">
            <div class="progress-bar" role="progressbar" style="width: 0%;" id="downloadProgressBar">0%</div>
        </div>
        <button id="pauseBtn" class="btn btn-warning mt-2 d-none" onclick="toggleDownload()">暂停</button>
        <div id="downloadStatus" class="mt-2"></div>
    </div>
</div>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/spark-md5@3.0.2/spark-md5.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/resumablejs@1.1.0/resumable.min.js"></script>

<script>
    // 基础配置
    const API_BASE_URL = 'http://localhost:8555/api';

    // DOM元素
    const uploadBtn = document.getElementById('uploadBtn');
    const fileUpload = document.getElementById('fileUpload');
    const uploadProgress = document.getElementById('uploadProgress');
    const uploadProgressBar = document.getElementById('uploadProgressBar');
    const uploadStatus = document.getElementById('uploadStatus');
    const refreshBtn = document.getElementById('refreshBtn');
    const fileList = document.getElementById('fileList');
    const downloadProgress = document.getElementById('downloadProgress');
    const downloadProgressBar = document.getElementById('downloadProgressBar');
    const downloadStatus = document.getElementById('downloadStatus');

    // 初始化resumable.js
    const resumable = new Resumable({
        target: `${API_BASE_URL}/upload/chunk`,
        query: {},
        chunkSize: 1 * 1024 * 1024, // 分片大小为1MB
        simultaneousUploads: 3,
        testChunks: true,
        throttleProgressCallbacks: 1,
        chunkNumberParameterName: 'chunkNumber',
        chunkSizeParameterName: 'chunkSize',
        currentChunkSizeParameterName: 'currentChunkSize',
        totalSizeParameterName: 'totalSize',
        identifierParameterName: 'identifier',
        fileNameParameterName: 'filename',
        totalChunksParameterName: 'totalChunks',
        method: 'POST',
        headers: {
            'Accept': 'application/json'
        },
        testMethod: 'GET',
        testTarget: `${API_BASE_URL}/upload/check`
    });

    // 分配事件监听器
    resumable.assignBrowse(fileUpload);

    // 文件添加事件 - 显示文件名
    resumable.on('fileAdded', function(file) {
        console.log('File added:', file);
        // 显示已选择的文件名
        uploadStatus.innerHTML = `<div class="alert alert-info">已选择文件: ${file.fileName} (${formatFileSize(file.size)})</div>`;

        // 显示文件信息卡片
        const fileInfoDiv = document.createElement('div');
        fileInfoDiv.className = 'card mt-2 mb-2';
        fileInfoDiv.innerHTML = `
            <div class="card-body">
                <h5 class="card-title">文件信息</h5>
                <p class="card-text">文件名: ${file.fileName}</p>
                <p class="card-text">大小: ${formatFileSize(file.size)}</p>
                <p class="card-text">类型: ${file.file.type || '未知'}</p>
                <p class="card-text">分片数: ${file.chunks.length}</p>
            </div>
        `;

        // 清除旧的文件信息
        const oldFileInfo = document.querySelector('.card');
        if (oldFileInfo) {
            oldFileInfo.remove();
        }

        // 插入到uploadStatus之前
        uploadStatus.parentNode.insertBefore(fileInfoDiv, uploadStatus);
    });

    // 格式化文件大小
    function formatFileSize(bytes) {
        if (bytes === 0) return '0 Bytes';
        const k = 1024;
        const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
        const i = Math.floor(Math.log(bytes) / Math.log(k));
        return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
    }

    // 分块开始事件
    resumable.on('chunkingStart', function(file) {
        console.log('开始分块:', file.fileName);
    });

    // 分块进度事件
    resumable.on('chunkingProgress', function(file, ratio) {
        console.log('分块进度:', Math.floor(ratio * 100) + '%');
    });

    // 分块完成事件
    resumable.on('chunkingComplete', function(file) {
        console.log('分块完成');
    });

    // 上传开始事件
    resumable.on('uploadStart', function() {
        console.log('开始上传');
        uploadStatus.innerHTML = '<div class="alert alert-info">开始上传文件块...</div>';
        window.mergeCalled = false;
    });

    // 上传进度事件
    resumable.on('fileProgress', function(file) {
        const progress = Math.floor(file.progress() * 100);
        uploadProgress.classList.remove('d-none');
        uploadProgressBar.style.width = `${progress}%`;
        uploadProgressBar.textContent = `${progress}%`;

        // 显示当前上传块信息
        const uploadedChunks = file.chunks.filter(chunk => chunk.status === 2).length;
        const totalChunks = file.chunks.length;
        uploadStatus.innerHTML = `<div class="alert alert-info">正在上传: ${uploadedChunks}/${totalChunks} 块 (${progress}%)</div>`;
    });

    // 总体进度事件
    resumable.on('progress', function() {
        console.log('总体进度:', Math.floor(resumable.progress() * 100) + '%');
    });

    // 上传成功事件
    resumable.on('fileSuccess', function(file, response) {
        console.log('文件上传成功,准备合并');
        const parsedResponse = JSON.parse(response);
        if (parsedResponse.success) {
            // 避免重复调用合并接口
            if (window.mergeCalled) {
                console.log('合并已经调用过,跳过');
                return;
            }
            window.mergeCalled = true;

            uploadStatus.innerHTML = '<div class="alert alert-info">所有分块上传成功,正在合并文件...</div>';

            // 使用FormData发送合并请求
            const formData = new FormData();
            formData.append('identifier', file.uniqueIdentifier);
            formData.append('filename', file.fileName);
            formData.append('totalChunks', file.chunks.length);

            axios.post(`${API_BASE_URL}/upload/merge`, formData)
                .then(function(response) {
                    if (response.data.success) {
                        uploadStatus.innerHTML = `<div class="alert alert-success">文件上传并合并成功!</div>`;
                        // 刷新文件列表
                        refreshFileList();
                    } else {
                        uploadStatus.innerHTML = `<div class="alert alert-danger">文件合并失败: ${response.data.message}</div>`;
                    }
                })
                .catch(function(error) {
                    uploadStatus.innerHTML = `<div class="alert alert-danger">合并请求出错: ${error.message}</div>`;
                });
        } else {
            uploadStatus.innerHTML = `<div class="alert alert-danger">上传失败: ${parsedResponse.message}</div>`;
        }
    });

    // 块上传错误事件
    resumable.on('chunkUploadError', function(file, chunk, message) {
        console.error('块上传错误:', chunk.offset, message);
        uploadStatus.innerHTML = `<div class="alert alert-warning">块 ${chunk.offset+1}/${file.chunks.length} 上传失败,系统将重试</div>`;
    });

    // 上传错误事件
    resumable.on('fileError', function(file, response) {
        try {
            const parsedResponse = JSON.parse(response);
            uploadStatus.innerHTML = `<div class="alert alert-danger">上传错误: ${parsedResponse.message || '未知错误'}</div>`;
        } catch (e) {
            uploadStatus.innerHTML = `<div class="alert alert-danger">上传错误: ${response || '未知错误'}</div>`;
        }
    });

    // 点击上传按钮事件
    uploadBtn.addEventListener('click', function() {
        if (!resumable.files.length) {
            uploadStatus.innerHTML = '<div class="alert alert-warning">请先选择文件!</div>';
            return;
        }
        uploadStatus.innerHTML = '<div class="alert alert-info">开始上传...</div>';
        resumable.upload();
    });

    // 获取文件列表
    function refreshFileList() {
        axios.get(`${API_BASE_URL}/download/files`)
            .then(function(response) {
                if (response.data.length > 0) {
                    let html = '';
                    response.data.forEach(function(file) {
                        html += `
                            <div class="file-item">
                                <span>${file.fileName} (${formatFileSize(file.size)})</span>
                                <div>
                                    <button class="btn btn-primary btn-sm" onclick="startDownload('${file.fileName}')" id="downloadBtn-${file.fileName}">下载</button>
                                    <button class="btn btn-warning btn-sm d-none" onclick="toggleDownload()" id="pauseBtn">暂停</button>
                                </div>
                            </div>
                        `;
                    });
                    fileList.innerHTML = html;
                } else {
                    fileList.innerHTML = '<div class="alert alert-info">没有文件</div>';
                }
            })
            .catch(function(error) {
                fileList.innerHTML = `<div class="alert alert-danger">获取文件列表失败: ${error.message}</div>`;
            });
    }

    // 全局变量用于控制下载状态
    let isDownloading = false;
    let downloadedChunks = [];
    let currentFileName = '';
    let currentStart = 0;
    let currentTotalSize = 0;

    // 切换下载状态
    function toggleDownload() {
        const pauseBtn = document.getElementById('pauseBtn');
        if (isDownloading) {
            isDownloading = false;
            pauseBtn.textContent = '继续';
            downloadStatus.innerHTML = '<div class="alert alert-warning">下载已暂停</div>';
        } else {
            isDownloading = true;
            pauseBtn.textContent = '暂停';
            if (currentFileName) {
                continueDownload();
            }
        }
    }

    // 继续下载
    async function continueDownload() {
        if (!currentFileName || !isDownloading) return;
        
        downloadStatus.innerHTML = '<div class="alert alert-info">继续下载文件...</div>';
        await startDownload(currentFileName, true);
    }

    // 分片下载文件
    async function startDownload(fileName, isContinue = false) {
        // 检查是否正在下载
        if (isDownloading && !isContinue) {
            downloadStatus.innerHTML = '<div class="alert alert-warning">文件正在下载中,请等待当前下载完成...</div>';
            return;
        }

        // 禁用当前文件的下载按钮
        const downloadBtn = document.getElementById(`downloadBtn-${fileName}`);
        if (downloadBtn) {
            downloadBtn.disabled = true;
            downloadBtn.classList.add('disabled');
        }

        const chunkSize = 1024 * 1024; // 1MB per chunk
        let downloadedSize = 0;
        
        try {
            // 重置下载状态
            if (!isContinue) {
                currentFileName = fileName;
                currentStart = 0;
                downloadedChunks = [];
                isDownloading = true;
                
                // 获取文件大小
                const response = await axios.get(`${API_BASE_URL}/download/chunk/${fileName}`);
                currentTotalSize = parseInt(response.headers['content-length']);
            }

            downloadProgress.classList.remove('d-none');
            document.getElementById('pauseBtn').classList.remove('d-none');
            document.getElementById('pauseBtn').textContent = '暂停';

            // 计算已下载大小
            downloadedSize = downloadedChunks.reduce((acc, chunk) => acc + chunk.size, 0);
            
            while (currentStart < currentTotalSize && isDownloading) {
                const end = Math.min(currentStart + chunkSize, currentTotalSize);
                const chunk = await downloadChunk(fileName, currentStart, end, currentTotalSize);
                downloadedChunks.push(chunk);
                downloadedSize += chunk.size;

                // 更新进度
                const progress = Math.floor((downloadedSize / currentTotalSize) * 100);
                downloadProgressBar.style.width = `${progress}%`;
                downloadProgressBar.textContent = `${progress}%`;
                downloadStatus.innerHTML = `<div class="alert alert-info">已下载: ${formatFileSize(downloadedSize)} / ${formatFileSize(currentTotalSize)}</div>`;

                currentStart = end;
            }

            // 如果下载完成
            if (currentStart >= currentTotalSize) {
                // 合并所有分片
                const blob = new Blob(downloadedChunks, { type: 'application/octet-stream' });
                const url = window.URL.createObjectURL(blob);

                // 创建下载链接
                const a = document.createElement('a');
                a.href = url;
                a.download = fileName;
                document.body.appendChild(a);
                a.click();
                window.URL.revokeObjectURL(url);
                document.body.removeChild(a);

                // 重置状态
                downloadStatus.innerHTML = '<div class="alert alert-success">文件下载完成!</div>';
                document.getElementById('pauseBtn').classList.add('d-none');
                resetDownloadState();
            }
        } catch (error) {
            downloadStatus.innerHTML = `<div class="alert alert-danger">下载失败: ${error.message}</div>`;
            console.error('下载错误:', error);
            resetDownloadState();
        }
    }

    // 重置下载状态
    function resetDownloadState() {
        isDownloading = false;
        currentFileName = '';
        currentStart = 0;
        currentTotalSize = 0;
        downloadedChunks = [];

        // 恢复所有下载按钮状态
        document.querySelectorAll('[id^="downloadBtn-"]').forEach(btn => {
            btn.disabled = false;
            btn.classList.remove('disabled');
        });
    }

    // 下载单个分片
    async function downloadChunk(fileName, start, end, total) {
        const response = await axios.get(`${API_BASE_URL}/download/chunk/${fileName}`, {
            headers: {
                Range: `bytes=${start}-${end-1}`
            },
            responseType: 'blob'
        });
        return response.data;
    }

    // 刷新按钮事件
    refreshBtn.addEventListener('click', refreshFileList);

    // 初始加载文件列表
    document.addEventListener('DOMContentLoaded', function() {
        refreshFileList();
    });
</script>

</body>
</html>

核心实现原理详解

文件分片:使用Resumable.js将大文件分割成多个小块(默认1MB),每块单独上传

检查已上传部分:上传前先调用/api/upload/check检查服务器已保存的分片

断点续传流程:文件的唯一标识符由文件名和大小计算得出,服务端在临时目录下按标识符创建文件夹存储分片,上传完成后调用合并接口,服务端将分片按顺序合并

文件合并:服务端使用RandomAccessFile实现高效文件合并

分片下载:前端Header需要配置Range,来读取当前下载是从第几个切片进行下载

效果展示

断点续传

分片下载

相关文章:

  • 项目二 - 任务4:等差数列求和
  • 二分 —— 基本算法刷题路程
  • “群芳争艳”:CoreData 4 种方法计算最大值的效率比较(上)
  • Spring Boot 下 MySQL Redis双重复用提高服务器性能
  • 春芽儿智能跳绳:以创新技术引领运动健康新潮流
  • C++(18)—类和对象(下) ③static成员
  • 再看自适应RAG方法:SEAKR|PIKE-RAG|DeepRAG
  • skynet.dispatch可用的已注册的协议类型
  • 前端开发中的单引号(‘ ‘)、双引号( )和反引号( `)使用
  • 【AIGC】零样本学习方法综述(TPAMI 2023 研究综述)
  • java面向对象练习
  • Linux进程控制(五)之做一个简易的shell
  • 玄机靶场:apache日志分析
  • 4.7-python request库的基本使用
  • 【区块链安全 | 第三十三篇】备忘单
  • 其它理论原则
  • 从存储仓库到智能中枢:AI时代NAS的进化革命
  • Android使用声网SDK实现音视频互动(RTC)功能
  • 【算法应用】基于融合A星-粒子群算法求解六边形栅格地图路径规划
  • 分行经理个人简历
  • 六安公司网/seo快排技术教程
  • 兰州做网站价格/电脑优化大师官方免费下载
  • 平远县建设工程交易中心网站/seo岗位工作内容
  • 建设网站的目标/线上线下推广方案
  • 企业网站建设相关书籍在线阅读/怎么在腾讯地图上添加自己的店铺
  • 怎么做网站链接的快捷方式/南宁seo外包服务商