Java 大文件上传实战:从底层原理到分布式落地(含分片 / 断点续传 / 秒传)

内容分享2小时前发布
0 0 0

一、前言:大文件上传的痛点与解决方案

在 Java 开发中,大文件上传是高频需求也是技术难点。传统单文件直接上传方案在面对 1GB 以上文件时,常会出现超时失败、内存溢出、用户体验差等问题 —— 比如网络波动导致上传中断后需重新上传、大文件加载占用过多内存导致服务 OOM、上传进度无法感知等。

本文将从底层原理出发,结合实战场景,手把手实现一套支持分片上传、断点续传、秒传、并发控制的大文件上传方案。方案基于 JDK 17+Spring Boot 3.x 构建,整合 MyBatis-Plus、MinIO、Redis 等组件,所有代码均可直接运行,同时兼顾深度与可读性,让新手能快速上手,资深开发者能夯实底层逻辑。

二、底层原理:看透大文件上传的核心逻辑

2.1 为什么传统单文件上传行不通?

传统上传方案是将文件作为一个整体通过 HTTP 请求发送到服务端,核心问题集中在三点:

超时风险:大文件传输时间长,容易触发 HTTP 超时(默认 Tomcat 超时为 60 秒),网络波动时直接上传失败;内存压力:服务端接收文件时,会将整个文件加载到内存处理,1GB 文件可能直接导致 JVM OOM;体验极差:上传中断后需重新上传整个文件,无进度反馈,用户无法预估时间。

2.2 核心解决方案:分片上传

2.2.1 分片上传原理

分片上传是将大文件拆分为多个小分片(如 5MB / 片),分别上传到服务端,服务端接收完所有分片后再合并为原始文件。核心流程如下:

Java 大文件上传实战:从底层原理到分布式落地(含分片 / 断点续传 / 秒传)

2.2.2 关键技术点拆解

文件唯一标识:用文件 MD5 作为唯一标识,确保分片与原始文件一一对应,也是秒传和断点续传的核心依据;分片拆分规则:按固定大小拆分(如 5MB),最后一片大小可能小于固定值,需记录总分片数和当前分片索引;分片传输保障:每个分片上传时携带 MD5、分片索引、总分片数等元数据,服务端校验合法性;合并逻辑:所有分片上传完成后,按分片索引顺序合并,避免文件损坏。

2.3 断点续传与秒传的底层逻辑

2.3.1 断点续传原理

断点续传基于分片上传,核心是「记录已上传分片」,避免重复上传:

前端上传前先查询服务端:该文件已上传的分片索引列表;前端跳过已上传分片,仅上传未完成的分片;支持暂停 / 继续功能:暂停时仅停止分片上传,不清理已上传分片;继续时重复第一步逻辑。

Java 大文件上传实战:从底层原理到分布式落地(含分片 / 断点续传 / 秒传)

2.3.2 秒传原理

秒传的核心是「文件预校验」,本质是利用文件唯一标识(MD5)快速判断文件是否已存在:

前端计算文件 MD5 后,先向服务端发送秒传校验请求;服务端查询该 MD5 对应的文件是否已上传完成;若已存在,则直接返回秒传成功,无需上传任何分片;若不存在或未上传完成,则进入分片上传流程。

2.3.3 易混淆点区分
技术点 核心逻辑 适用场景
分片上传 拆分文件 + 分块传输 + 合并 所有大文件上传(100MB+)
断点续传 记录已上传分片 + 跳过重复上传 网络不稳定、大文件长时间上传
秒传 MD5 预校验 + 已上传文件匹配 重复上传同一文件(如用户二次上传)

2.4 关键技术选型说明

组件 版本号 作用说明 选择理由
JDK 17 开发运行环境 长期支持版本,兼容 Spring Boot 3.x
Spring Boot 3.2.5 项目基础框架 最新稳定版,支持 JDK 17+
MyBatis-Plus 3.5.4.4 持久层框架 简化 CRUD,支持分页、乐观锁等
MinIO 8.5.12 分布式文件存储 轻量、兼容 S3 协议,适合大文件存储
Redis 7.2.4 分布式锁、缓存已上传分片 高性能,支持分布式场景
Fastjson2 2.0.49 JSON 序列化 / 反序列化 速度快,支持 JDK 17+
Lombok 1.18.30 简化 POJO 代码 减少 getter/setter 等冗余代码
Springdoc-OpenAPI 2.2.0 Swagger3 接口文档 适配 Spring Boot 3.x,替代 Springfox
Vue 3 3.4.21 前端框架 轻量、响应式,适合上传组件开发
Axios 1.6.8 前端 HTTP 请求工具 支持中断请求、进度监听
Spark-MD5 3.0.2 前端大文件 MD5 计算 支持分片计算 MD5,避免内存溢出

三、实战准备:环境搭建与项目初始化

3.1 开发环境要求

JDK 17(需配置环境变量)MySQL 8.0(用于存储文件元数据、分片信息)Redis 7.0+(用于分布式锁、缓存)MinIO 8.5+(分布式存储,可选本地存储替代)Maven 3.8+(项目构建工具)Node.js 16+(前端项目运行)

3.2 后端项目初始化(Spring Boot)

3.2.1 pom.xml 依赖配置


<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.2.5</version>
        <relativePath/>
    </parent>
    <groupId>com.ken.file</groupId>
    <artifactId>large-file-upload</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>large-file-upload</name>
    <description>Java大文件上传实战项目</description>
    <properties>
        <java.version>17</java.version>
        <mybatis-plus.version>3.5.4.4</mybatis-plus.version>
        <minio.version>8.5.12</minio.version>
        <fastjson2.version>2.0.49</fastjson2.version>
        <springdoc.version>2.2.0</springdoc.version>
        <guava.version>32.1.3-jre</guava.version>
    </properties>
    <dependencies>
        <!-- Spring Boot核心依赖 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
 
        <!-- MyBatis-Plus -->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>${mybatis-plus.version}</version>
        </dependency>
 
        <!-- 数据库驱动 -->
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <scope>runtime</scope>
        </dependency>
 
        <!-- MinIO -->
        <dependency>
            <groupId>io.minio</groupId>
            <artifactId>minio</artifactId>
            <version>${minio.version}</version>
        </dependency>
 
        <!-- JSON处理 -->
        <dependency>
            <groupId>com.alibaba.fastjson2</groupId>
            <artifactId>fastjson2</artifactId>
            <version>${fastjson2.version}</version>
        </dependency>
 
        <!-- 工具类 -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.18.30</version>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>com.google.guava</groupId>
            <artifactId>guava</artifactId>
            <version>${guava.version}</version>
        </dependency>
 
        <!-- Swagger3 -->
        <dependency>
            <groupId>org.springdoc</groupId>
            <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
            <version>${springdoc.version}</version>
        </dependency>
 
        <!-- 测试依赖 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
 
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>
3.2.2 核心配置文件(application.yml)


spring:
  # 数据源配置
  datasource:
    url: jdbc:mysql://localhost:3306/large_file_upload?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
    username: root
    password: root123456
    driver-class-name: com.mysql.cj.jdbc.Driver
  # Redis配置
  redis:
    host: localhost
    port: 6379
    password:
    database: 0
    timeout: 3000ms
  # 上传文件临时存储路径
  servlet:
    multipart:
      enabled: true
      max-file-size: 10MB # 单个分片最大大小(需大于前端分片大小)
      max-request-size: 100MB # 单次请求最大大小
 
# Spring Boot配置
server:
  port: 8080
  servlet:
    context-path: /file-upload
 
# MyBatis-Plus配置
mybatis-plus:
  mapper-locations: classpath:mapper/*.xml
  type-aliases-package: com.ken.file.entity
  configuration:
    map-underscore-to-camel-case: true
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
  global-config:
    db-config:
      id-type: auto
      logic-delete-field: isDeleted
      logic-delete-value: 1
      logic-not-delete-value: 0
 
# MinIO配置
minio:
  endpoint: http://localhost:9000
  access-key: minioadmin
  secret-key: minioadmin
  bucket-name: large-file-bucket # 存储大文件的桶名(需提前创建)
 
# 自定义上传配置
file:
  upload:
    chunk-size: 5242880 # 分片大小5MB(5*1024*1024)
    local-storage-path: D:/large-file-upload/local-storage # 本地存储路径(单机模式)
    expire-days: 7 # 未完成上传的分片过期时间(天)

3.3 数据库设计(MySQL 8.0)

需创建两张核心表:
file_metadata
(文件元数据表)和
file_chunk
(分片信息表),SQL 脚本如下:



CREATE DATABASE IF NOT EXISTS large_file_upload DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE large_file_upload;
 
-- 文件元数据表:存储文件整体信息
CREATE TABLE IF NOT EXISTS file_metadata (
    id BIGINT AUTO_INCREMENT COMMENT '主键ID' PRIMARY KEY,
    file_md5 VARCHAR(32) NOT NULL COMMENT '文件唯一标识(MD5)',
    file_name VARCHAR(255) NOT NULL COMMENT '原始文件名',
    file_size BIGINT NOT NULL COMMENT '文件总大小(字节)',
    chunk_total INT NOT NULL COMMENT '总分片数',
    file_suffix VARCHAR(50) COMMENT '文件后缀(如mp4、zip)',
    storage_type TINYINT NOT NULL DEFAULT 1 COMMENT '存储类型:1-本地存储,2-MinIO',
    file_path VARCHAR(512) COMMENT '文件存储路径(本地路径或MinIO访问路径)',
    status TINYINT NOT NULL DEFAULT 0 COMMENT '文件状态:0-上传中,1-上传完成,2-上传失败',
    create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    is_deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0-未删除,1-已删除',
    UNIQUE KEY uk_file_md5 (file_md5) COMMENT '文件MD5唯一索引'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文件元数据表';
 
-- 分片信息表:存储单个分片的信息
CREATE TABLE IF NOT EXISTS file_chunk (
    id BIGINT AUTO_INCREMENT COMMENT '主键ID' PRIMARY KEY,
    file_md5 VARCHAR(32) NOT NULL COMMENT '文件唯一标识(关联file_metadata.file_md5)',
    chunk_index INT NOT NULL COMMENT '分片索引(从0开始)',
    chunk_size BIGINT NOT NULL COMMENT '分片大小(字节)',
    chunk_path VARCHAR(512) NOT NULL COMMENT '分片存储路径',
    create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    is_deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0-未删除,1-已删除',
    UNIQUE KEY uk_file_md5_chunk_index (file_md5, chunk_index) COMMENT '文件MD5+分片索引唯一索引'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分片信息表';
 
-- 索引优化:加快查询速度
CREATE INDEX idx_file_md5_status ON file_metadata(file_md5, status);
CREATE INDEX idx_file_md5 ON file_chunk(file_md5);

3.4 前端项目初始化(Vue 3)

3.4.1 项目创建与依赖安装


# 创建Vue项目
npm create vue@latest large-file-upload-frontend
cd large-file-upload-frontend
# 安装核心依赖
npm install axios@1.6.8 spark-md5@3.0.2 element-plus@2.7.0
3.4.2 前端核心配置(src/utils/request.js)


import axios from 'axios';
 
// 创建Axios实例
const service = axios.create({
  baseURL: 'http://localhost:8080/file-upload',
  timeout: 60000, // 分片上传超时时间(1分钟)
  headers: {
    'Content-Type': 'application/json'
  }
});
 
// 请求拦截器
service.interceptors.request.use(
  config => {
    // 可添加token等认证信息
    return config;
  },
  error => {
    Promise.reject(error);
  }
);
 
// 响应拦截器
service.interceptors.response.use(
  response => {
    const res = response.data;
    if (res.code !== 200) {
      console.error('请求失败:', res.msg);
      return Promise.reject(res);
    }
    return res;
  },
  error => {
    console.error('请求异常:', error.message);
    return Promise.reject(error);
  }
);
 
export default service;

四、核心组件开发:后端实现

4.1 实体类设计(Entity)

4.1.1 文件元数据实体(FileMetadata.java)


package com.ken.file.entity;
 
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDateTime;
 
/**
 * 文件元数据表实体
 * @author ken
 */
@Data
@TableName("file_metadata")
public class FileMetadata {
 
    /**
     * 主键ID
     */
    @TableId(type = IdType.AUTO)
    private Long id;
 
    /**
     * 文件唯一标识(MD5)
     */
    private String fileMd5;
 
    /**
     * 原始文件名
     */
    private String fileName;
 
    /**
     * 文件总大小(字节)
     */
    private Long fileSize;
 
    /**
     * 总分片数
     */
    private Integer chunkTotal;
 
    /**
     * 文件后缀(如mp4、zip)
     */
    private String fileSuffix;
 
    /**
     * 存储类型:1-本地存储,2-MinIO
     */
    private Integer storageType;
 
    /**
     * 文件存储路径(本地路径或MinIO访问路径)
     */
    private String filePath;
 
    /**
     * 文件状态:0-上传中,1-上传完成,2-上传失败
     */
    private Integer status;
 
    /**
     * 创建时间
     */
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime createTime;
 
    /**
     * 更新时间
     */
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime updateTime;
 
    /**
     * 逻辑删除:0-未删除,1-已删除
     */
    private Integer isDeleted;
}
4.1.2 分片信息实体(FileChunk.java)


package com.ken.file.entity;
 
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDateTime;
 
/**
 * 分片信息表实体
 * @author ken
 */
@Data
@TableName("file_chunk")
public class FileChunk {
 
    /**
     * 主键ID
     */
    @TableId(type = IdType.AUTO)
    private Long id;
 
    /**
     * 文件唯一标识(关联file_metadata.file_md5)
     */
    private String fileMd5;
 
    /**
     * 分片索引(从0开始)
     */
    private Integer chunkIndex;
 
    /**
     * 分片大小(字节)
     */
    private Long chunkSize;
 
    /**
     * 分片存储路径
     */
    private String chunkPath;
 
    /**
     * 创建时间
     */
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime createTime;
 
    /**
     * 更新时间
     */
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime updateTime;
 
    /**
     * 逻辑删除:0-未删除,1-已删除
     */
    private Integer isDeleted;
}

4.2 DTO 与 VO 设计

4.2.1 上传请求 DTO(FileUploadDTO.java)


package com.ken.file.dto;
 
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import org.springframework.web.multipart.MultipartFile;
 
/**
 * 分片上传请求DTO
 * @author ken
 */
@Data
@Schema(description = "分片上传请求参数")
public class FileUploadDTO {
 
    /**
     * 文件MD5唯一标识
     */
    @NotBlank(message = "文件MD5不能为空")
    @Schema(description = "文件MD5唯一标识", requiredMode = Schema.RequiredMode.REQUIRED)
    private String fileMd5;
 
    /**
     * 原始文件名
     */
    @NotBlank(message = "文件名不能为空")
    @Schema(description = "原始文件名", requiredMode = Schema.RequiredMode.REQUIRED)
    private String fileName;
 
    /**
     * 文件总大小(字节)
     */
    @NotNull(message = "文件大小不能为空")
    @Schema(description = "文件总大小(字节)", requiredMode = Schema.RequiredMode.REQUIRED)
    private Long fileSize;
 
    /**
     * 当前分片索引(从0开始)
     */
    @NotNull(message = "分片索引不能为空")
    @Schema(description = "当前分片索引(从0开始)", requiredMode = Schema.RequiredMode.REQUIRED)
    private Integer chunkIndex;
 
    /**
     * 总分片数
     */
    @NotNull(message = "总分片数不能为空")
    @Schema(description = "总分片数", requiredMode = Schema.RequiredMode.REQUIRED)
    private Integer chunkTotal;
 
    /**
     * 分片文件
     */
    @NotNull(message = "分片文件不能为空")
    @Schema(description = "分片文件", requiredMode = Schema.RequiredMode.REQUIRED)
    private MultipartFile chunkFile;
}
4.2.2 响应 VO(ResultVO.java)


package com.ken.file.vo;
 
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.experimental.Accessors;
 
/**
 * 统一响应VO
 * @author ken
 */
@Data
@Accessors(chain = true)
@Schema(description = "统一响应结果")
public class ResultVO<T> {
 
    /**
     * 响应码:200-成功,500-失败
     */
    @Schema(description = "响应码", example = "200")
    private Integer code;
 
    /**
     * 响应信息
     */
    @Schema(description = "响应信息", example = "操作成功")
    private String msg;
 
    /**
     * 响应数据
     */
    @Schema(description = "响应数据")
    private T data;
 
    /**
     * 成功响应(无数据)
     */
    public static <T> ResultVO<T> success() {
        return new ResultVO<T>().setCode(200).setMsg("操作成功");
    }
 
    /**
     * 成功响应(带数据)
     */
    public static <T> ResultVO<T> success(T data) {
        return new ResultVO<T>().setCode(200).setMsg("操作成功").setData(data);
    }
 
    /**
     * 失败响应
     */
    public static <T> ResultVO<T> fail(String msg) {
        return new ResultVO<T>().setCode(500).setMsg(msg);
    }
 
    /**
     * 失败响应(带数据)
     */
    public static <T> ResultVO<T> fail(String msg, T data) {
        return new ResultVO<T>().setCode(500).setMsg(msg).setData(data);
    }
}

4.3 Mapper 层开发(MyBatis-Plus)

4.3.1 文件元数据 Mapper(FileMetadataMapper.java)


package com.ken.file.mapper;
 
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ken.file.entity.FileMetadata;
import org.apache.ibatis.annotations.Param;
import org.springframework.stereotype.Repository;
 
/**
 * 文件元数据Mapper
 * @author ken
 */
@Repository
public interface FileMetadataMapper extends BaseMapper<FileMetadata> {
 
    /**
     * 根据文件MD5查询文件元数据
     * @param fileMd5 文件MD5
     * @return 文件元数据实体
     */
    FileMetadata selectByFileMd5(@Param("fileMd5") String fileMd5);
 
    /**
     * 根据文件MD5更新文件状态
     * @param fileMd5 文件MD5
     * @param status 目标状态
     * @return 影响行数
     */
    int updateStatusByFileMd5(@Param("fileMd5") String fileMd5, @Param("status") Integer status);
}
4.3.2 分片信息 Mapper(FileChunkMapper.java)


package com.ken.file.mapper;
 
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ken.file.entity.FileChunk;
import org.apache.ibatis.annotations.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
 
/**
 * 分片信息Mapper
 * @author ken
 */
@Repository
public interface FileChunkMapper extends BaseMapper<FileChunk> {
 
    /**
     * 根据文件MD5查询已上传的分片索引
     * @param fileMd5 文件MD5
     * @return 已上传的分片索引列表
     */
    List<Integer> selectUploadedChunkIndexes(@Param("fileMd5") String fileMd5);
 
    /**
     * 根据文件MD5查询所有分片
     * @param fileMd5 文件MD5
     * @return 分片列表(按索引升序排列)
     */
    List<FileChunk> selectByFileMd5(@Param("fileMd5") String fileMd5);
 
    /**
     * 根据文件MD5删除所有分片
     * @param fileMd5 文件MD5
     * @return 影响行数
     */
    int deleteByFileMd5(@Param("fileMd5") String fileMd5);
}
4.3.3 Mapper XML 文件(FileMetadataMapper.xml)


<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ken.file.mapper.FileMetadataMapper">
 
    <select id="selectByFileMd5" resultType="com.ken.file.entity.FileMetadata">
        SELECT id, file_md5, file_name, file_size, chunk_total, file_suffix,
               storage_type, file_path, status, create_time, update_time, is_deleted
        FROM file_metadata
        WHERE file_md5 = #{fileMd5} AND is_deleted = 0
    </select>
 
    <update id="updateStatusByFileMd5">
        UPDATE file_metadata
        SET status = #{status}, update_time = CURRENT_TIMESTAMP
        WHERE file_md5 = #{fileMd5} AND is_deleted = 0
    </update>
 
</mapper>
4.3.4 Mapper XML 文件(FileChunkMapper.xml)


<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ken.file.mapper.FileChunkMapper">
 
    <select id="selectUploadedChunkIndexes" resultType="java.lang.Integer">
        SELECT chunk_index
        FROM file_chunk
        WHERE file_md5 = #{fileMd5} AND is_deleted = 0
        ORDER BY chunk_index ASC
    </select>
 
    <select id="selectByFileMd5" resultType="com.ken.file.entity.FileChunk">
        SELECT id, file_md5, chunk_index, chunk_size, chunk_path,
               create_time, update_time, is_deleted
        FROM file_chunk
        WHERE file_md5 = #{fileMd5} AND is_deleted = 0
        ORDER BY chunk_index ASC
    </select>
 
    <delete id="deleteByFileMd5">
        DELETE FROM file_chunk
        WHERE file_md5 = #{fileMd5} AND is_deleted = 0
    </delete>
 
</mapper>

4.4 服务层开发(Service)

4.4.1 存储策略接口(FileStorageStrategy.java)

采用策略模式设计存储方案,支持本地存储和 MinIO 分布式存储切换:



package com.ken.file.strategy;
 
import org.springframework.web.multipart.MultipartFile;
import java.io.InputStream;
 
/**
 * 文件存储策略接口
 * @author ken
 */
public interface FileStorageStrategy {
 
    /**
     * 存储分片文件
     * @param fileMd5 文件MD5
     * @param chunkIndex 分片索引
     * @param chunkFile 分片文件
     * @return 分片存储路径
     * @throws Exception 存储异常
     */
    String storeChunk(String fileMd5, Integer chunkIndex, MultipartFile chunkFile) throws Exception;
 
    /**
     * 合并分片文件
     * @param fileMd5 文件MD5
     * @param fileName 原始文件名
     * @param chunkPaths 分片存储路径列表(按索引顺序)
     * @return 合并后的文件存储路径
     * @throws Exception 合并异常
     */
    String mergeChunks(String fileMd5, String fileName, String[] chunkPaths) throws Exception;
 
    /**
     * 获取文件输入流
     * @param filePath 文件存储路径
     * @return 输入流
     * @throws Exception 读取异常
     */
    InputStream getFileInputStream(String filePath) throws Exception;
 
    /**
     * 删除分片文件
     * @param chunkPaths 分片存储路径列表
     * @throws Exception 删除异常
     */
    void deleteChunks(String[] chunkPaths) throws Exception;
}
4.4.2 本地存储实现(LocalFileStorage.java)


package com.ken.file.strategy.impl;
 
import com.ken.file.strategy.FileStorageStrategy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.ObjectUtils;
import org.springframework.web.multipart.MultipartFile;
import java.io.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Arrays;
 
/**
 * 本地文件存储实现
 * @author ken
 */
@Slf4j
@Component("localFileStorage")
public class LocalFileStorage implements FileStorageStrategy {
 
    /**
     * 本地存储根路径
     */
    @Value("${file.upload.local-storage-path}")
    private String localStoragePath;
 
    @Override
    public String storeChunk(String fileMd5, Integer chunkIndex, MultipartFile chunkFile) throws Exception {
        // 创建分片存储目录:根路径/文件MD5/分片索引
        Path chunkDir = Paths.get(localStoragePath, fileMd5);
        if (!Files.exists(chunkDir)) {
            Files.createDirectories(chunkDir);
        }
 
        // 分片文件路径
        String chunkFileName = chunkIndex + ".chunk";
        Path chunkPath = chunkDir.resolve(chunkFileName);
 
        // 写入分片文件
        try (InputStream inputStream = chunkFile.getInputStream();
             OutputStream outputStream = Files.newOutputStream(chunkPath)) {
            byte[] buffer = new byte[1024 * 1024];
            int len;
            while ((len = inputStream.read(buffer)) != -1) {
                outputStream.write(buffer, 0, len);
            }
            log.info("本地存储分片成功:fileMd5={}, chunkIndex={}, path={}", fileMd5, chunkIndex, chunkPath);
            return chunkPath.toString();
        } catch (Exception e) {
            log.error("本地存储分片失败:fileMd5={}, chunkIndex={}", fileMd5, chunkIndex, e);
            throw new Exception("分片存储失败", e);
        }
    }
 
    @Override
    public String mergeChunks(String fileMd5, String fileName, String[] chunkPaths) throws Exception {
        if (ObjectUtils.isEmpty(chunkPaths)) {
            throw new IllegalArgumentException("分片路径列表不能为空");
        }
 
        // 排序分片路径(确保按索引顺序合并)
        Arrays.sort(chunkPaths);
 
        // 创建合并后的文件路径:根路径/文件MD5_文件名
        String mergedFileName = fileMd5 + "_" + fileName;
        Path mergedFilePath = Paths.get(localStoragePath, mergedFileName);
 
        // 合并分片
        try (OutputStream outputStream = Files.newOutputStream(mergedFilePath)) {
            for (String chunkPath : chunkPaths) {
                Path chunkFile = Paths.get(chunkPath);
                if (!Files.exists(chunkFile)) {
                    throw new FileNotFoundException("分片文件不存在:" + chunkPath);
                }
 
                try (InputStream inputStream = Files.newInputStream(chunkFile)) {
                    byte[] buffer = new byte[1024 * 1024];
                    int len;
                    while ((len = inputStream.read(buffer)) != -1) {
                        outputStream.write(buffer, 0, len);
                    }
                }
            }
            log.info("本地合并分片成功:fileMd5={}, mergedPath={}", fileMd5, mergedFilePath);
            return mergedFilePath.toString();
        } catch (Exception e) {
            log.error("本地合并分片失败:fileMd5={}", fileMd5, e);
            throw new Exception("分片合并失败", e);
        }
    }
 
    @Override
    public InputStream getFileInputStream(String filePath) throws Exception {
        Path path = Paths.get(filePath);
        if (!Files.exists(path)) {
            throw new FileNotFoundException("文件不存在:" + filePath);
        }
        return Files.newInputStream(path);
    }
 
    @Override
    public void deleteChunks(String[] chunkPaths) throws Exception {
        if (ObjectUtils.isEmpty(chunkPaths)) {
            return;
        }
 
        for (String chunkPath : chunkPaths) {
            Path path = Paths.get(chunkPath);
            if (Files.exists(path)) {
                Files.delete(path);
                log.info("删除分片文件:{}", chunkPath);
            }
 
            // 删除分片所在目录(如果为空)
            Path chunkDir = path.getParent();
            if (Files.exists(chunkDir) && ObjectUtils.isEmpty(Files.list(chunkDir).count())) {
                Files.delete(chunkDir);
                log.info("删除分片目录:{}", chunkDir);
            }
        }
    }
}
4.4.3 MinIO 存储实现(MinIOFileStorage.java)


package com.ken.file.strategy.impl;
 
import com.ken.file.strategy.FileStorageStrategy;
import io.minio.BucketExistsArgs;
import io.minio.MakeBucketArgs;
import io.minio.MinioClient;
import io.minio.PutObjectArgs;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.ObjectUtils;
import org.springframework.web.multipart.MultipartFile;
import java.io.*;
import java.util.Arrays;
 
/**
 * MinIO分布式存储实现
 * @author ken
 */
@Slf4j
@Component("minIOFileStorage")
public class MinIOFileStorage implements FileStorageStrategy {
 
    private final MinioClient minioClient;
 
    /**
     * MinIO桶名
     */
    @Value("${minio.bucket-name}")
    private String bucketName;
 
    public MinIOFileStorage(
            @Value("${minio.endpoint}") String endpoint,
            @Value("${minio.access-key}") String accessKey,
            @Value("${minio.secret-key}") String secretKey) {
        this.minioClient = MinioClient.builder()
                .endpoint(endpoint)
                .credentials(accessKey, secretKey)
                .build();
        // 检查桶是否存在,不存在则创建
        checkAndCreateBucket();
    }
 
    /**
     * 检查并创建MinIO桶
     */
    private void checkAndCreateBucket() {
        try {
            boolean exists = minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build());
            if (!exists) {
                minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucketName).build());
                log.info("创建MinIO桶成功:{}", bucketName);
            }
        } catch (Exception e) {
            log.error("检查/创建MinIO桶失败", e);
            throw new RuntimeException("MinIO初始化失败", e);
        }
    }
 
    @Override
    public String storeChunk(String fileMd5, Integer chunkIndex, MultipartFile chunkFile) throws Exception {
        // MinIO分片存储路径:chunk/文件MD5/分片索引.chunk
        String chunkPath = String.format("chunk/%s/%d.chunk", fileMd5, chunkIndex);
 
        try (InputStream inputStream = chunkFile.getInputStream()) {
            minioClient.putObject(
                    PutObjectArgs.builder()
                            .bucket(bucketName)
                            .object(chunkPath)
                            .stream(inputStream, chunkFile.getSize(), -1)
                            .contentType(chunkFile.getContentType())
                            .build()
            );
            log.info("MinIO存储分片成功:fileMd5={}, chunkIndex={}, path={}", fileMd5, chunkIndex, chunkPath);
            return chunkPath;
        } catch (Exception e) {
            log.error("MinIO存储分片失败:fileMd5={}, chunkIndex={}", fileMd5, chunkIndex, e);
            throw new Exception("分片存储失败", e);
        }
    }
 
    @Override
    public String mergeChunks(String fileMd5, String fileName, String[] chunkPaths) throws Exception {
        if (ObjectUtils.isEmpty(chunkPaths)) {
            throw new IllegalArgumentException("分片路径列表不能为空");
        }
 
        // 排序分片路径(确保按索引顺序合并)
        Arrays.sort(chunkPaths);
 
        // MinIO合并后的文件路径:file/文件MD5_文件名
        String mergedFilePath = String.format("file/%s_%s", fileMd5, fileName);
 
        // MinIO不支持直接合并分片,需先下载到本地临时目录合并,再上传
        File tempDir = new File(System.getProperty("java.io.tmpdir"), "minio-chunk-merge");
        if (!tempDir.exists()) {
            tempDir.mkdirs();
        }
 
        // 临时合并文件
        File tempMergedFile = new File(tempDir, mergedFilePath.replace("/", "_"));
 
        try (OutputStream outputStream = new FileOutputStream(tempMergedFile)) {
            for (String chunkPath : chunkPaths) {
                // 下载分片到本地临时流
                try (InputStream inputStream = minioClient.getObject(
                        io.minio.GetObjectArgs.builder()
                                .bucket(bucketName)
                                .object(chunkPath)
                                .build()
                )) {
                    byte[] buffer = new byte[1024 * 1024];
                    int len;
                    while ((len = inputStream.read(buffer)) != -1) {
                        outputStream.write(buffer, 0, len);
                    }
                }
            }
 
            // 上传合并后的文件到MinIO
            try (InputStream inputStream = new FileInputStream(tempMergedFile)) {
                minioClient.putObject(
                        PutObjectArgs.builder()
                                .bucket(bucketName)
                                .object(mergedFilePath)
                                .stream(inputStream, tempMergedFile.length(), -1)
                                .contentType(getContentType(fileName))
                                .build()
                );
            }
 
            log.info("MinIO合并分片成功:fileMd5={}, mergedPath={}", fileMd5, mergedFilePath);
            return mergedFilePath;
        } catch (Exception e) {
            log.error("MinIO合并分片失败:fileMd5={}", fileMd5, e);
            throw new Exception("分片合并失败", e);
        } finally {
            // 删除临时文件
            if (tempMergedFile.exists()) {
                tempMergedFile.delete();
            }
        }
    }
 
    @Override
    public InputStream getFileInputStream(String filePath) throws Exception {
        return minioClient.getObject(
                io.minio.GetObjectArgs.builder()
                        .bucket(bucketName)
                        .object(filePath)
                        .build()
        );
    }
 
    @Override
    public void deleteChunks(String[] chunkPaths) throws Exception {
        if (ObjectUtils.isEmpty(chunkPaths)) {
            return;
        }
 
        for (String chunkPath : chunkPaths) {
            minioClient.removeObject(
                    io.minio.RemoveObjectArgs.builder()
                            .bucket(bucketName)
                            .object(chunkPath)
                            .build()
            );
            log.info("MinIO删除分片文件:{}", chunkPath);
        }
    }
 
    /**
     * 根据文件名获取Content-Type
     * @param fileName 文件名
     * @return Content-Type
     */
    private String getContentType(String fileName) {
        String suffix = fileName.substring(fileName.lastIndexOf("."));
        return switch (suffix.toLowerCase()) {
            case ".mp4" -> "video/mp4";
            case ".zip" -> "application/zip";
            case ".pdf" -> "application/pdf";
            case ".jpg", ".jpeg" -> "image/jpeg";
            case ".png" -> "image/png";
            default -> "application/octet-stream";
        };
    }
}
4.4.3 存储策略工厂(FileStorageFactory.java)


package com.ken.file.factory;
 
import com.ken.file.strategy.FileStorageStrategy;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
 
import java.util.Map;
 
/**
 * 文件存储策略工厂
 * @author ken
 */
@Component
@RequiredArgsConstructor
public class FileStorageFactory {
 
    /**
     * 所有存储策略实现(Spring自动注入)
     */
    private final Map<String, FileStorageStrategy> storageStrategyMap;
 
    /**
     * 当前存储类型:local-本地存储,minio-MinIO存储
     */
    @Value("${file.upload.storage-type:local}")
    private String currentStorageType;
 
    /**
     * 获取存储策略实例
     * @return 存储策略实现
     */
    public FileStorageStrategy getStorageStrategy() {
        String strategyBeanName = currentStorageType.equals("minio") ? "minIOFileStorage" : "localFileStorage";
        FileStorageStrategy strategy = storageStrategyMap.get(strategyBeanName);
        if (StringUtils.isEmpty(strategy)) {
            throw new RuntimeException("不支持的存储类型:" + currentStorageType);
        }
        return strategy;
    }
 
    /**
     * 切换存储策略
     * @param storageType 存储类型:local/minio
     */
    public void switchStorageStrategy(String storageType) {
        if (!"local".equals(storageType) && !"minio".equals(storageType)) {
            throw new IllegalArgumentException("无效的存储类型:" + storageType);
        }
        this.currentStorageType = storageType;
    }
}
4.4.4 核心服务实现(FileUploadService.java)


package com.ken.file.service;
 
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.google.common.collect.Lists;
import com.ken.file.dto.FileUploadDTO;
import com.ken.file.entity.FileChunk;
import com.ken.file.entity.FileMetadata;
import com.ken.file.factory.FileStorageFactory;
import com.ken.file.mapper.FileChunkMapper;
import com.ken.file.mapper.FileMetadataMapper;
import com.ken.file.strategy.FileStorageStrategy;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;
 
import java.time.LocalDateTime;
import java.util.List;
import java.util.stream.Collectors;
 
/**
 * 大文件上传核心服务
 * @author ken
 */
@Slf4j
@Service
@RequiredArgsConstructor
public class FileUploadService {
 
    private final FileMetadataMapper fileMetadataMapper;
    private final FileChunkMapper fileChunkMapper;
    private final FileStorageFactory storageFactory;
    private final RedissonClient redissonClient;
 
    /**
     * 分片大小(字节)
     */
    @Value("${file.upload.chunk-size}")
    private Long chunkSize;
 
    /**
     * 秒传校验:根据文件MD5判断是否已上传完成
     * @param fileMd5 文件MD5
     * @return true-秒传成功,false-需正常上传
     */
    public boolean checkSecondUpload(String fileMd5) {
        if (!StringUtils.hasText(fileMd5)) {
            log.error("秒传校验失败:文件MD5不能为空");
            return false;
        }
 
        // 查询文件元数据,状态为1(上传完成)则秒传成功
        FileMetadata metadata = fileMetadataMapper.selectByFileMd5(fileMd5);
        boolean secondUpload = !ObjectUtils.isEmpty(metadata) && metadata.getStatus() == 1;
        log.info("秒传校验结果:fileMd5={}, 秒传={}", fileMd5, secondUpload);
        return secondUpload;
    }
 
    /**
     * 断点续传校验:查询已上传的分片索引
     * @param fileMd5 文件MD5
     * @return 已上传的分片索引列表
     */
    public List<Integer> checkBreakpointUpload(String fileMd5) {
        if (!StringUtils.hasText(fileMd5)) {
            log.error("断点续传校验失败:文件MD5不能为空");
            return Lists.newArrayList();
        }
 
        List<Integer> uploadedIndexes = fileChunkMapper.selectUploadedChunkIndexes(fileMd5);
        log.info("断点续传校验结果:fileMd5={}, 已上传分片数={}", fileMd5, uploadedIndexes.size());
        return uploadedIndexes;
    }
 
    /**
     * 上传分片文件
     * @param uploadDTO 分片上传参数
     * @return 分片上传结果(true-成功,false-失败)
     */
    @Transactional(rollbackFor = Exception.class)
    public boolean uploadChunk(FileUploadDTO uploadDTO) {
        // 参数校验
        validateChunkUploadParams(uploadDTO);
 
        String fileMd5 = uploadDTO.getFileMd5();
        Integer chunkIndex = uploadDTO.getChunkIndex();
        Integer chunkTotal = uploadDTO.getChunkTotal();
        String fileName = uploadDTO.getFileName();
        Long fileSize = uploadDTO.getFileSize();
 
        try {
            // 1. 检查分片是否已上传(避免重复上传)
            LambdaQueryWrapper<FileChunk> chunkQuery = Wrappers.lambdaQuery(FileChunk.class)
                    .eq(FileChunk::getFileMd5, fileMd5)
                    .eq(FileChunk::getChunkIndex, chunkIndex)
                    .eq(FileChunk::getIsDeleted, 0);
            FileChunk existingChunk = fileChunkMapper.selectOne(chunkQuery);
            if (!ObjectUtils.isEmpty(existingChunk)) {
                log.info("分片已上传,无需重复上传:fileMd5={}, chunkIndex={}", fileMd5, chunkIndex);
                return true;
            }
 
            // 2. 存储分片文件
            FileStorageStrategy storageStrategy = storageFactory.getStorageStrategy();
            String chunkPath = storageStrategy.storeChunk(fileMd5, chunkIndex, uploadDTO.getChunkFile());
 
            // 3. 保存分片信息到数据库
            FileChunk chunk = new FileChunk();
            chunk.setFileMd5(fileMd5);
            chunk.setChunkIndex(chunkIndex);
            chunk.setChunkSize(uploadDTO.getChunkFile().getSize());
            chunk.setChunkPath(chunkPath);
            chunk.setCreateTime(LocalDateTime.now());
            chunk.setUpdateTime(LocalDateTime.now());
            chunk.setIsDeleted(0);
            fileChunkMapper.insert(chunk);
 
            // 4. 初始化文件元数据(如果不存在)
            FileMetadata metadata = fileMetadataMapper.selectByFileMd5(fileMd5);
            if (ObjectUtils.isEmpty(metadata)) {
                metadata = new FileMetadata();
                metadata.setFileMd5(fileMd5);
                metadata.setFileName(fileName);
                metadata.setFileSize(fileSize);
                metadata.setChunkTotal(chunkTotal);
                metadata.setFileSuffix(getFileSuffix(fileName));
                metadata.setStorageType(storageFactory.getStorageStrategy().getClass().getSimpleName().contains("MinIO") ? 2 : 1);
                metadata.setStatus(0); // 状态:0-上传中
                metadata.setCreateTime(LocalDateTime.now());
                metadata.setUpdateTime(LocalDateTime.now());
                metadata.setIsDeleted(0);
                fileMetadataMapper.insert(metadata);
            }
 
            log.info("分片上传成功:fileMd5={}, chunkIndex={}/{})", fileMd5, chunkIndex, chunkTotal);
            return true;
        } catch (Exception e) {
            log.error("分片上传失败:fileMd5={}, chunkIndex={}", fileMd5, chunkIndex, e);
            throw new RuntimeException("分片上传失败", e);
        }
    }
 
    /**
     * 合并分片文件
     * @param fileMd5 文件MD5
     * @return 合并后的文件存储路径
     */
    @Transactional(rollbackFor = Exception.class)
    public String mergeChunks(String fileMd5) {
        if (!StringUtils.hasText(fileMd5)) {
            log.error("分片合并失败:文件MD5不能为空");
            throw new IllegalArgumentException("文件MD5不能为空");
        }
 
        // 分布式锁:防止同一文件同时被合并
        String lockKey = "file:merge:" + fileMd5;
        RLock lock = redissonClient.getLock(lockKey);
        try {
            // 尝试获取锁,最多等待3秒,持有锁最多1分钟
            boolean locked = lock.tryLock(3, 60, java.util.concurrent.TimeUnit.SECONDS);
            if (!locked) {
                log.error("分片合并失败:获取分布式锁超时,fileMd5={}", fileMd5);
                throw new RuntimeException("合并任务繁忙,请稍后重试");
            }
 
            // 1. 查询文件元数据和所有分片
            FileMetadata metadata = fileMetadataMapper.selectByFileMd5(fileMd5);
            if (ObjectUtils.isEmpty(metadata)) {
                log.error("分片合并失败:文件元数据不存在,fileMd5={}", fileMd5);
                throw new RuntimeException("文件元数据不存在");
            }
 
            List<FileChunk> chunks = fileChunkMapper.selectByFileMd5(fileMd5);
            if (CollectionUtils.isEmpty(chunks)) {
                log.error("分片合并失败:未找到分片文件,fileMd5={}", fileMd5);
                throw new RuntimeException("未找到分片文件");
            }
 
            // 2. 校验分片数量是否完整
            int chunkTotal = metadata.getChunkTotal();
            if (chunks.size() != chunkTotal) {
                log.error("分片合并失败:分片数量不完整,fileMd5={}, 预期={}, 实际={}", fileMd5, chunkTotal, chunks.size());
                throw new RuntimeException("分片数量不完整,无法合并");
            }
 
            // 3. 提取分片路径(按索引顺序)
            String[] chunkPaths = chunks.stream()
                    .sorted((c1, c2) -> c1.getChunkIndex() - c2.getChunkIndex())
                    .map(FileChunk::getChunkPath)
                    .toArray(String[]::new);
 
            // 4. 合并分片
            FileStorageStrategy storageStrategy = storageFactory.getStorageStrategy();
            String mergedFilePath = storageStrategy.mergeChunks(fileMd5, metadata.getFileName(), chunkPaths);
 
            // 5. 更新文件元数据(存储路径、状态)
            metadata.setFilePath(mergedFilePath);
            metadata.setStatus(1); // 状态:1-上传完成
            metadata.setUpdateTime(LocalDateTime.now());
            fileMetadataMapper.updateById(metadata);
 
            // 6. 删除分片文件(合并成功后清理)
            storageStrategy.deleteChunks(chunkPaths);
            fileChunkMapper.deleteByFileMd5(fileMd5);
 
            log.info("分片合并成功:fileMd5={}, 合并后路径={}", fileMd5, mergedFilePath);
            return mergedFilePath;
        } catch (Exception e) {
            log.error("分片合并失败:fileMd5={}", fileMd5, e);
            throw new RuntimeException("分片合并失败", e);
        } finally {
            // 释放锁
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
 
    /**
     * 校验分片上传参数
     * @param uploadDTO 分片上传参数
     */
    private void validateChunkUploadParams(FileUploadDTO uploadDTO) {
        if (ObjectUtils.isEmpty(uploadDTO)) {
            throw new IllegalArgumentException("分片上传参数不能为空");
        }
        if (!StringUtils.hasText(uploadDTO.getFileMd5())) {
            throw new IllegalArgumentException("文件MD5不能为空");
        }
        if (!StringUtils.hasText(uploadDTO.getFileName())) {
            throw new IllegalArgumentException("文件名不能为空");
        }
        if (ObjectUtils.isEmpty(uploadDTO.getChunkFile()) || uploadDTO.getChunkFile().isEmpty()) {
            throw new IllegalArgumentException("分片文件不能为空");
        }
        if (ObjectUtils.isEmpty(uploadDTO.getChunkIndex()) || uploadDTO.getChunkIndex() < 0) {
            throw new IllegalArgumentException("分片索引必须大于等于0");
        }
        if (ObjectUtils.isEmpty(uploadDTO.getChunkTotal()) || uploadDTO.getChunkTotal() <= 0) {
            throw new IllegalArgumentException("总分片数必须大于0");
        }
        if (ObjectUtils.isEmpty(uploadDTO.getFileSize()) || uploadDTO.getFileSize() <= 0) {
            throw new IllegalArgumentException("文件大小必须大于0");
        }
 
        // 校验分片索引不超过总分片数
        if (uploadDTO.getChunkIndex() >= uploadDTO.getChunkTotal()) {
            throw new IllegalArgumentException(
                    String.format("分片索引非法:索引=%d,总分片数=%d",
                            uploadDTO.getChunkIndex(), uploadDTO.getChunkTotal())
            );
        }
    }
 
    /**
     * 获取文件后缀
     * @param fileName 文件名
     * @return 文件后缀(如mp4、zip)
     */
    private String getFileSuffix(String fileName) {
        if (!StringUtils.hasText(fileName) || !fileName.contains(".")) {
            return "";
        }
        return fileName.substring(fileName.lastIndexOf("."));
    }
}

4.5 控制器开发(Controller)



package com.ken.file.controller;
 
import com.ken.file.dto.FileUploadDTO;
import com.ken.file.service.FileUploadService;
import com.ken.file.vo.ResultVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
 
import java.util.List;
 
/**
 * 大文件上传控制器
 * @author ken
 */
@Slf4j
@RestController
@RequestMapping("/api/upload")
@RequiredArgsConstructor
@Tag(name = "大文件上传接口", description = "支持分片上传、断点续传、秒传")
public class FileUploadController {
 
    private final FileUploadService fileUploadService;
 
    /**
     * 秒传校验接口
     * @param fileMd5 文件MD5
     * @return 秒传校验结果
     */
    @GetMapping("/second-check")
    @Operation(summary = "秒传校验", description = "根据文件MD5判断是否已上传完成,支持秒传")
    public ResultVO<Boolean> checkSecondUpload(
            @Parameter(description = "文件MD5唯一标识", required = true)
            @RequestParam String fileMd5) {
        try {
            boolean secondUpload = fileUploadService.checkSecondUpload(fileMd5);
            return ResultVO.success(secondUpload);
        } catch (Exception e) {
            log.error("秒传校验接口异常", e);
            return ResultVO.fail("秒传校验失败:" + e.getMessage());
        }
    }
 
    /**
     * 断点续传校验接口
     * @param fileMd5 文件MD5
     * @return 已上传的分片索引列表
     */
    @GetMapping("/breakpoint-check")
    @Operation(summary = "断点续传校验", description = "查询已上传的分片索引,支持断点续传")
    public ResultVO<List<Integer>> checkBreakpointUpload(
            @Parameter(description = "文件MD5唯一标识", required = true)
            @RequestParam String fileMd5) {
        try {
            List<Integer> uploadedIndexes = fileUploadService.checkBreakpointUpload(fileMd5);
            return ResultVO.success(uploadedIndexes);
        } catch (Exception e) {
            log.error("断点续传校验接口异常", e);
            return ResultVO.fail("断点续传校验失败:" + e.getMessage());
        }
    }
 
    /**
     * 分片上传接口
     * @param uploadDTO 分片上传参数
     * @return 分片上传结果
     */
    @PostMapping("/chunk")
    @Operation(summary = "分片上传", description = "上传单个分片文件,支持并发上传")
    public ResultVO<Boolean> uploadChunk(@ModelAttribute FileUploadDTO uploadDTO) {
        try {
            boolean success = fileUploadService.uploadChunk(uploadDTO);
            return ResultVO.success(success);
        } catch (Exception e) {
            log.error("分片上传接口异常", e);
            return ResultVO.fail("分片上传失败:" + e.getMessage());
        }
    }
 
    /**
     * 合并分片接口
     * @param fileMd5 文件MD5
     * @return 合并后的文件存储路径
     */
    @PostMapping("/merge")
    @Operation(summary = "合并分片", description = "所有分片上传完成后,调用此接口合并分片")
    public ResultVO<String> mergeChunks(
            @Parameter(description = "文件MD5唯一标识", required = true)
            @RequestParam String fileMd5) {
        try {
            String mergedFilePath = fileUploadService.mergeChunks(fileMd5);
            return ResultVO.success(mergedFilePath, "分片合并成功");
        } catch (Exception e) {
            log.error("合并分片接口异常", e);
            return ResultVO.fail("分片合并失败:" + e.getMessage());
        }
    }
}

4.6 全局配置类

4.6.1 Swagger3 配置(SwaggerConfig.java)


package com.ken.file.config;
 
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.info.License;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
 
/**
 * Swagger3配置类(Springdoc-OpenAPI)
 * @author ken
 */
@Configuration
public class SwaggerConfig {
 
    @Bean
    public OpenAPI largeFileUploadOpenAPI() {
        return new OpenAPI()
                .info(new Info()
                        .title("大文件上传接口文档")
                        .description("支持分片上传、断点续传、秒传的大文件上传方案接口文档")
                        .version("v1.0.0")
                        .license(new License()
                                .name("Apache 2.0")
                                .url("https://www.apache.org/licenses/LICENSE-2.0")));
    }
}
4.6.2 Redisson 配置(RedissonConfig.java)


package com.ken.file.config;
 
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
 
/**
 * Redisson配置类(分布式锁)
 * @author ken
 */
@Configuration
public class RedissonConfig {
 
    @Value("${spring.redis.host}")
    private String host;
 
    @Value("${spring.redis.port}")
    private String port;
 
    @Value("${spring.redis.password:}")
    private String password;
 
    @Value("${spring.redis.database:0}")
    private int database;
 
    @Bean
    public RedissonClient redissonClient() {
        // 配置Redisson客户端
        Config config = new Config();
        String redisAddress = String.format("redis://%s:%s", host, port);
        config.useSingleServer()
                .setAddress(redisAddress)
                .setDatabase(database)
                .setTimeout(3000);
 
        // 配置密码(如果有)
        if (!org.springframework.util.StringUtils.isEmpty(password)) {
            config.useSingleServer().setPassword(password);
        }
 
        RedissonClient redissonClient = Redisson.create(config);
        return redissonClient;
    }
}
4.6.3 全局异常处理(GlobalExceptionHandler.java)


package com.ken.file.config;
 
import com.ken.file.vo.ResultVO;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
 
/**
 * 全局异常处理器
 * @author ken
 */
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
 
    /**
     * 处理参数校验异常(@Valid注解触发)
     */
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResultVO<Void> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
        BindingResult bindingResult = e.getBindingResult();
        StringBuilder errorMsg = new StringBuilder("参数校验失败:");
        for (FieldError fieldError : bindingResult.getFieldErrors()) {
            errorMsg.append(fieldError.getField()).append(":").append(fieldError.getDefaultMessage()).append(";");
        }
        log.error(errorMsg.toString(), e);
        return ResultVO.fail(errorMsg.toString());
    }
 
    /**
     * 处理文件大小超限异常
     */
    @ExceptionHandler(MaxUploadSizeExceededException.class)
    public ResultVO<Void> handleMaxUploadSizeExceededException(MaxUploadSizeExceededException e) {
        String msg = "文件大小超过限制,请检查分片大小是否符合要求";
        log.error(msg, e);
        return ResultVO.fail(msg);
    }
 
    /**
     * 处理非法参数异常
     */
    @ExceptionHandler(IllegalArgumentException.class)
    public ResultVO<Void> handleIllegalArgumentException(IllegalArgumentException e) {
        log.error("非法参数异常:{}", e.getMessage(), e);
        return ResultVO.fail(e.getMessage());
    }
 
    /**
     * 处理运行时异常
     */
    @ExceptionHandler(RuntimeException.class)
    public ResultVO<Void> handleRuntimeException(RuntimeException e) {
        log.error("运行时异常:{}", e.getMessage(), e);
        return ResultVO.fail("操作失败:" + e.getMessage());
    }
 
    /**
     * 处理所有未捕获的异常
     */
    @ExceptionHandler(Exception.class)
    public ResultVO<Void> handleException(Exception e) {
        log.error("系统异常:", e);
        return ResultVO.fail("系统异常,请联系管理员");
    }
}

4.7 项目启动类(LargeFileUploadApplication.java)



package com.ken.file;
 
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
 
/**
 * 大文件上传项目启动类
 * @author ken
 */
@SpringBootApplication
@MapperScan("com.ken.file.mapper") // 扫描Mapper接口
@EnableScheduling // 启用定时任务(用于清理过期分片)
public class LargeFileUploadApplication {
 
    public static void main(String[] args) {
        SpringApplication.run(LargeFileUploadApplication.class, args);
        System.out.println("大文件上传服务启动成功!访问地址:http://localhost:8080/file-upload/swagger-ui/index.html");
    }
}

4.8 定时任务:清理过期分片(ExpiredChunkCleaner.java)



package com.ken.file.task;
 
import com.ken.file.entity.FileMetadata;
import com.ken.file.entity.FileChunk;
import com.ken.file.factory.FileStorageFactory;
import com.ken.file.mapper.FileChunkMapper;
import com.ken.file.mapper.FileMetadataMapper;
import com.ken.file.strategy.FileStorageStrategy;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils;
import org.springframework.util.ObjectUtils;
 
import java.time.LocalDateTime;
import java.util.List;
import java.util.stream.Collectors;
 
/**
 * 定时清理过期未完成的分片文件
 * @author ken
 */
@Slf4j
@Component
@RequiredArgsConstructor
public class ExpiredChunkCleaner {
 
    private final FileMetadataMapper fileMetadataMapper;
    private final FileChunkMapper fileChunkMapper;
    private final FileStorageFactory storageFactory;
 
    /**
     * 分片过期天数
     */
    @Value("${file.upload.expire-days}")
    private Integer expireDays;
 
    /**
     * 每天凌晨2点执行清理任务
     */
    @Scheduled(cron = "0 0 2 * * ?")
    @Transactional(rollbackFor = Exception.class)
    public void cleanExpiredChunks() {
        log.info("开始清理过期未完成的分片文件,过期天数:{}天", expireDays);
 
        // 计算过期时间点(当前时间 - expireDays天)
        LocalDateTime expireTime = LocalDateTime.now().minusDays(expireDays);
 
        // 1. 查询所有状态为“上传中”且创建时间早于过期时间的文件元数据
        List<FileMetadata> expiredMetadataList = fileMetadataMapper.selectList(
                com.baomidou.mybatisplus.core.conditions.query.Wrappers.lambdaQuery(FileMetadata.class)
                        .eq(FileMetadata::getStatus, 0) // 状态:0-上传中
                        .lt(FileMetadata::getCreateTime, expireTime)
                        .eq(FileMetadata::getIsDeleted, 0)
        );
 
        if (CollectionUtils.isEmpty(expiredMetadataList)) {
            log.info("未发现过期未完成的分片文件,清理任务结束");
            return;
        }
 
        // 2. 逐个清理过期文件的分片
        FileStorageStrategy storageStrategy = storageFactory.getStorageStrategy();
        for (FileMetadata metadata : expiredMetadataList) {
            String fileMd5 = metadata.getFileMd5();
            try {
                // 2.1 查询该文件的所有分片
                List<FileChunk> chunks = fileChunkMapper.selectByFileMd5(fileMd5);
                if (!CollectionUtils.isEmpty(chunks)) {
                    // 2.2 删除存储的分片文件
                    String[] chunkPaths = chunks.stream()
                            .map(FileChunk::getChunkPath)
                            .toArray(String[]::new);
                    storageStrategy.deleteChunks(chunkPaths);
 
                    // 2.3 删除数据库中的分片记录
                    fileChunkMapper.deleteByFileMd5(fileMd5);
                }
 
                // 3. 标记文件元数据为删除状态
                metadata.setIsDeleted(1);
                metadata.setUpdateTime(LocalDateTime.now());
                fileMetadataMapper.updateById(metadata);
 
                log.info("清理过期分片成功:fileMd5={}, fileName={}", fileMd5, metadata.getFileName());
            } catch (Exception e) {
                log.error("清理过期分片失败:fileMd5={}", fileMd5, e);
                // 单个文件清理失败不影响其他文件
                continue;
            }
        }
 
        log.info("过期分片清理任务完成,共清理{}个文件的分片", expiredMetadataList.size());
    }
}

五、核心组件开发:前端实现(Vue 3)

5.1 大文件上传核心组件(FileUploader.vue)

vue



<template>
  <div class="file-upload-container">
    <el-upload
      class="upload-demo"
      action=""
      :auto-upload="false"
      :on-change="handleFileChange"
      :show-file-list="false"
      accept=""
    >
      <el-button type="primary">选择大文件</el-button>
    </el-upload>
 
    <!-- 上传状态展示 -->
    <div v-if="uploading" class="upload-status">
      <el-progress :percentage="progress" stroke-width="4"></el-progress>
      <div class="upload-actions">
        <el-button 
          type="warning" 
          size="small" 
          @click="toggleUpload"
          v-if="!paused"
        >
          暂停
        </el-button>
        <el-button 
          type="success" 
          size="small" 
          @click="toggleUpload"
          v-if="paused"
        >
          继续
        </el-button>
        <el-button 
          type="danger" 
          size="small" 
          @click="cancelUpload"
        >
          取消
        </el-button>
      </div>
      <p class="upload-info">
        {{ currentChunk }}/{{ totalChunks }} 分片上传中({{ formatSize(uploadedSize) }}/{{ formatSize(fileSize) }})
      </p>
    </div>
 
    <!-- 上传结果展示 -->
    <div v-if="uploadSuccess" class="upload-result success">
      <el-icon><success-filled /></el-icon>
      <span>文件上传成功!存储路径:{{ uploadedFilePath }}</span>
    </div>
    <div v-if="uploadError" class="upload-result error">
      <el-icon><error-filled /></el-icon>
      <span>上传失败:{{ errorMsg }}</span>
    </div>
  </div>
</template>
 
<script setup>
import { ref, onMounted, watch } from 'vue';
import { ElMessage, ElProgress, ElButton, ElIcon, ElUpload } from 'element-plus';
import { SuccessFilled, ErrorFilled } from '@element-plus/icons-vue';
import SparkMD5 from 'spark-md5';
import request from '@/utils/request';
 
// 组件状态
const file = ref(null); // 选中的文件
const fileMd5 = ref(''); // 文件MD5
const fileSize = ref(0); // 文件总大小(字节)
const fileName = ref(''); // 文件名
const chunkSize = ref(5 * 1024 * 1024); // 分片大小(5MB)
const totalChunks = ref(0); // 总分片数
const currentChunk = ref(0); // 当前正在上传的分片索引
const uploadedSize = ref(0); // 已上传大小(字节)
const progress = ref(0); // 上传进度(百分比)
const uploading = ref(false); // 是否正在上传
const paused = ref(false); // 是否暂停上传
const uploadSuccess = ref(false); // 上传是否成功
const uploadError = ref(false); // 上传是否失败
const errorMsg = ref(''); // 错误信息
const uploadedFilePath = ref(''); // 上传成功后的文件路径
const uploadedIndexes = ref([]); // 已上传的分片索引列表
const abortControllers = ref({}); // 存储每个分片的AbortController,用于取消请求
 
/**
 * 处理文件选择
 */
const handleFileChange = (uploadFile) => {
  // 重置状态
  resetUploadStatus();
  
  // 获取文件信息
  file.value = uploadFile.raw;
  fileSize.value = file.value.size;
  fileName.value = file.value.name;
  
  // 计算总分片数
  totalChunks.value = Math.ceil(fileSize.value / chunkSize.value);
  ElMessage.info(`文件准备就绪:${fileName.value}(${formatSize(fileSize.value)}),共${totalChunks.value}个分片`);
  
  // 计算文件MD5(大文件分片计算)
  calculateFileMd5();
};
 
/**
 * 计算文件MD5(分片计算,避免大文件占用过多内存)
 */
const calculateFileMd5 = () => {
  if (!file.value) return;
  
  ElMessage.loading('正在计算文件MD5,请稍候...', { duration: 0 });
  const fileReader = new FileReader();
  const spark = new SparkMD5.ArrayBuffer();
  const chunkSize = 2 * 1024 * 1024; // 计算MD5的分片大小(2MB)
  let offset = 0;
  
  // 递归读取文件分片计算MD5
  const loadNextChunk = () => {
    const fileSlice = file.value.slice(offset, offset + chunkSize);
    fileReader.readAsArrayBuffer(fileSlice);
  };
  
  fileReader.onload = (e) => {
    spark.append(e.target.result);
    offset += chunkSize;
    
    if (offset < file.value.size) {
      // 继续读取下一个分片
      loadNextChunk();
    } else {
      // 计算完成
      fileMd5.value = spark.end();
      ElMessage.success(`文件MD5计算完成:${fileMd5.value}`);
      
      // 开始上传流程(先校验秒传和断点续传)
      startUploadProcess();
    }
  };
  
  fileReader.onerror = () => {
    ElMessage.error('文件MD5计算失败,请重新选择文件');
    resetUploadStatus();
  };
  
  // 开始读取第一个分片
  loadNextChunk();
};
 
/**
 * 开始上传流程(秒传校验 -> 断点续传校验 -> 分片上传)
 */
const startUploadProcess = async () => {
  if (!fileMd5.value) {
    ElMessage.error('文件MD5为空,无法上传');
    return;
  }
  
  try {
    // 1. 秒传校验
    const secondCheckRes = await request.get('/api/upload/second-check', {
      params: { fileMd5: fileMd5.value }
    });
    if (secondCheckRes.data) {
      // 秒传成功
      uploadSuccess.value = true;
      uploadedFilePath.value = `已存在文件(MD5:${fileMd5.value})`;
      ElMessage.success('文件已存在,秒传成功!');
      return;
    }
    
    // 2. 断点续传校验(查询已上传的分片)
    const breakpointCheckRes = await request.get('/api/upload/breakpoint-check', {
      params: { fileMd5: fileMd5.value }
    });
    uploadedIndexes.value = breakpointCheckRes.data || [];
    ElMessage.info(`发现已上传${uploadedIndexes.value.length}个分片,将继续上传剩余分片`);
    
    // 计算已上传大小
    uploadedSize.value = uploadedIndexes.value.length * chunkSize.value;
    // 最后一个分片可能小于chunkSize,这里简化计算
    if (uploadedIndexes.value.length === totalChunks.value) {
      uploadedSize.value = fileSize.value;
    }
    progress.value = Math.round((uploadedSize.value / fileSize.value) * 100);
    
    // 3. 开始上传分片
    uploading.value = true;
    paused.value = false;
    uploadNextChunk();
  } catch (err) {
    handleUploadError('上传初始化失败:' + (err.msg || err.message || '未知错误'));
  }
};
 
/**
 * 上传下一个分片
 */
const uploadNextChunk = async () => {
  if (paused.value || uploadError.value) return;
  
  // 找到下一个未上传的分片索引
  while (currentChunk.value < totalChunks.value) {
    if (!uploadedIndexes.value.includes(currentChunk.value)) {
      break;
    }
    currentChunk.value++;
  }
  
  // 所有分片上传完成,调用合并接口
  if (currentChunk.value >= totalChunks.value) {
    await mergeChunks();
    return;
  }
  
  try {
    // 准备当前分片数据
    const start = currentChunk.value * chunkSize.value;
    const end = Math.min(start + chunkSize.value, fileSize.value);
    const chunkFile = file.value.slice(start, end);
    
    // 创建FormData
    const formData = new FormData();
    formData.append('fileMd5', fileMd5.value);
    formData.append('fileName', fileName.value);
    formData.append('fileSize', fileSize.value);
    formData.append('chunkIndex', currentChunk.value);
    formData.append('chunkTotal', totalChunks.value);
    formData.append('chunkFile', chunkFile, `${fileMd5.value}-${currentChunk.value}.chunk`);
    
    // 创建AbortController,用于取消请求
    const controller = new AbortController();
    abortControllers.value[currentChunk.value] = controller;
    
    // 上传分片
    await request.post('/api/upload/chunk', formData, {
      headers: { 'Content-Type': 'multipart/form-data' },
      signal: controller.signal,
      onUploadProgress: (e) => {
        // 计算当前分片的上传进度,并更新总进度
        if (e.lengthComputable) {
          const chunkProgress = e.loaded / e.total;
          const totalProgress = (uploadedSize.value + chunkProgress * (end - start)) / fileSize.value * 100;
          progress.value = Math.round(totalProgress);
        }
      }
    });
    
    // 分片上传成功
    uploadedIndexes.value.push(currentChunk.value);
    uploadedSize.value += (end - start);
    ElMessage.success(`分片 ${currentChunk.value + 1}/${totalChunks.value} 上传成功`);
    
    // 上传下一个分片
    currentChunk.value++;
    uploadNextChunk();
  } catch (err) {
    if (err.name !== 'AbortError') {
      // 非主动取消的错误
      handleUploadError(`分片 ${currentChunk.value + 1} 上传失败:${err.msg || err.message}`);
    }
  }
};
 
/**
 * 合并分片
 */
const mergeChunks = async () => {
  try {
    ElMessage.loading('正在合并分片,请稍候...', { duration: 0 });
    const res = await request.post('/api/upload/merge', null, {
      params: { fileMd5: fileMd5.value }
    });
    
    // 合并成功
    uploadedFilePath.value = res.data;
    uploadSuccess.value = true;
    uploading.value = false;
    progress.value = 100;
    ElMessage.success('所有分片上传完成,文件合并成功!');
  } catch (err) {
    handleUploadError('分片合并失败:' + (err.msg || err.message));
  }
};
 
/**
 * 暂停/继续上传
 */
const toggleUpload = () => {
  if (paused.value) {
    // 继续上传
    paused.value = false;
    ElMessage.info('继续上传...');
    uploadNextChunk();
  } else {
    // 暂停上传(取消当前正在上传的分片请求)
    paused.value = true;
    const currentController = abortControllers.value[currentChunk.value];
    if (currentController) {
      currentController.abort();
      ElMessage.info('已暂停上传');
    }
  }
};
 
/**
 * 取消上传
 */
const cancelUpload = () => {
  // 取消所有正在上传的请求
  Object.values(abortControllers.value).forEach(controller => {
    controller.abort();
  });
  
  // 重置状态
  resetUploadStatus();
  ElMessage.warning('已取消上传');
};
 
/**
 * 处理上传错误
 */
const handleUploadError = (msg) => {
  uploadError.value = true;
  errorMsg.value = msg;
  uploading.value = false;
  ElMessage.error(msg);
};
 
/**
 * 重置上传状态
 */
const resetUploadStatus = () => {
  file.value = null;
  fileMd5.value = '';
  fileSize.value = 0;
  fileName.value = '';
  totalChunks.value = 0;
  currentChunk.value = 0;
  uploadedSize.value = 0;
  progress.value = 0;
  uploading.value = false;
  paused.value = false;
  uploadSuccess.value = false;
  uploadError.value = false;
  errorMsg.value = '';
  uploadedFilePath.value = '';
  uploadedIndexes.value = [];
  abortControllers.value = {};
};
 
/**
 * 格式化文件大小(字节 -> KB/MB/GB)
 */
const formatSize = (bytes) => {
  if (bytes < 1024) return bytes + ' B';
  else if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB';
  else if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
  else return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
};
 
onMounted(() => {
  // 组件挂载时的初始化逻辑
  console.log('大文件上传组件初始化完成');
});
</script>
 
<style scoped>
.file-upload-container {
  max-width: 800px;
  margin: 20px auto;
  padding: 20px;
  border: 1px solid #e5e7eb;
  border-radius: 8px;
}
 
.upload-status {
  margin-top: 20px;
  padding: 15px;
  background-color: #f9fafb;
  border-radius: 6px;
}
 
.upload-actions {
  margin: 15px 0;
  display: flex;
  gap: 10px;
}
 
.upload-info {
  color: #6b7280;
  font-size: 14px;
  margin-top: 5px;
}
 
.upload-result {
  margin-top: 20px;
  padding: 15px;
  border-radius: 6px;
  display: flex;
  align-items: center;
  gap: 10px;
}
 
.success {
  background-color: #f0fdf4;
  color: #166534;
  border: 1px solid #bbf7d0;
}
 
.error {
  background-color: #fef2f2;
  color: #b91c1c;
  border: 1px solid #fee2e2;
}
</style>

5.2 前端页面使用(App.vue)

vue



<template>
  <div id="app">
    <h1>Java大文件上传实战演示</h1>
    <FileUploader />
  </div>
</template>
 
<script setup>
import FileUploader from './components/FileUploader.vue';
</script>
 
<style>
#app {
  max-width: 1200px;
  margin: 0 auto;
  padding: 2rem;
  text-align: center;
}
 
h1 {
  color: #1e40af;
  margin-bottom: 2rem;
}
</style>

5.3 前端路由配置(src/router/index.js)

javascript



import { createRouter, createWebHistory } from 'vue-router';
import HomeView from '../views/HomeView.vue';
 
const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    {
      path: '/',
      name: 'home',
      component: HomeView
    }
  ]
});
 
export default router;

5.4 前端入口文件(src/main.js)

javascript



import { createApp } from 'vue';
import App from './App.vue';
import router from './router';
import ElementPlus from 'element-plus';
import 'element-plus/dist/index.css';
 
const app = createApp(App);
 
app.use(router);
app.use(ElementPlus);
 
app.mount('#app');

六、系统测试与验证

6.1 测试环境准备

启动依赖服务

MySQL 8.0:创建
large_file_upload
数据库并执行 SQL 脚本Redis 7.0+:默认配置启动(无需额外配置)MinIO 8.5+:

bash



# 启动MinIO(Windows示例)
minio.exe server D:minio-data --console-address ":9001"
# 访问控制台:http://localhost:9001,创建桶large-file-bucket

启动后端服务

直接运行
LargeFileUploadApplication.java
的 main 方法访问 Swagger 文档:http://localhost:8080/file-upload/swagger-ui/index.html 验证接口是否正常

启动前端服务



cd large-file-upload-frontend
npm run dev
# 访问前端页面:http://localhost:5173

6.2 核心功能测试用例

6.2.1 秒传功能测试

选择一个小文件(如 1MB 的文本文件)上传,等待上传完成;再次选择同一个文件上传,观察是否直接提示 “秒传成功”,无需上传分片;检查数据库
file_metadata
表,确认该文件状态为 1(上传完成)。

6.2.2 断点续传测试

选择一个大文件(如 1GB 的视频文件)开始上传;在上传过程中点击 “暂停” 按钮,观察上传是否停止;刷新页面后重新选择同一个文件,观察是否自动识别已上传分片并从断点继续;点击 “继续” 按钮,确认能从暂停处继续上传。

6.2.3 分片合并测试

上传一个中等大小的文件(如 50MB),确保分多个分片;所有分片上传完成后,观察是否自动调用合并接口;检查存储路径(本地或 MinIO),确认合并后的文件可正常打开且与原文件一致;检查数据库
file_chunk
表,确认分片记录已被删除。

6.2.4 并发上传测试

打开两个浏览器窗口,同时上传同一个大文件;观察是否出现分片覆盖或合并冲突(通过分布式锁避免);确认最终只有一个合并操作成功,且文件完整。

6.2.5 异常处理测试

上传过程中断开网络,观察是否提示错误信息;上传超过分片大小限制的文件(如配置为 5MB 分片,上传 10MB 的单个分片),观察是否提示大小超限;数据库连接失败时,上传分片,观察是否返回友好错误信息。

6.3 性能测试建议

单文件上传速度测试

分别测试 100MB、1GB、5GB 文件的上传耗时;记录平均分片上传时间和合并时间;对比不同存储策略(本地存储 vs MinIO)的性能差异。

并发量测试

使用 JMeter 模拟 10/50/100 个并发用户同时上传文件;监控服务端 CPU、内存占用,数据库连接数,Redis 性能;观察是否出现超时、OOM 等问题。

断点续传效率测试

模拟上传到 50% 时中断,测试重新上传的耗时(应接近剩余 50% 的上传时间);测试不同断点位置(10%/50%/90%)的续传效率。

七、技术优化与扩展

7.1 性能优化点

分片大小动态调整

根据文件类型自动调整分片大小(如视频文件用 10MB 分片,文本文件用 1MB 分片);示例代码修改:在前端计算分片大小时,根据文件后缀动态设置
chunkSize

分片上传并发控制

同时上传多个分片(如并发上传 3-5 个分片),提升上传速度;注意:并发数不宜过多,避免占用过多网络资源;实现思路:维护一个上传队列,限制同时执行的上传请求数量。

MD5 计算优化

前端使用 Web Worker 计算文件 MD5,避免阻塞主线程;后端缓存文件 MD5 与文件信息的映射,减少数据库查询。

存储优化

MinIO 配置多节点集群,提升分布式存储性能和可靠性;本地存储使用 SSD 磁盘,减少 IO 瓶颈。

7.2 功能扩展建议

文件加密传输

分片上传前进行加密,服务端合并后解密;可使用 AES 算法,密钥通过 HTTPS 传输。

上传权限控制

整合 Spring Security,实现基于角色的上传权限控制;记录用户上传记录,实现文件归属管理。

文件预览与下载

增加文件下载接口,支持断点续传下载;对图片、视频等文件提供在线预览功能。

上传进度实时同步

使用 WebSocket 实时推送上传进度到前端;适合多端协作场景,如 A 上传文件,B 可查看实时进度。

分布式部署支持

服务端集群部署时,使用 Nginx 负载均衡;确保分片存储在共享存储(如 MinIO 集群),避免单节点依赖。

八、总结与展望

大文件上传是 Java 开发中的典型场景,核心挑战在于如何平衡传输效率、可靠性和用户体验。本文从底层原理出发,通过分片上传、断点续传、秒传三大核心技术,结合 Spring Boot、MinIO、Redis 等组件,实现了一套可直接落地的大文件上传方案。

© 版权声明

相关文章

暂无评论

您必须登录才能参与评论!
立即登录
none
暂无评论...