大文件上传系统开发指南(基于原生JS+SpringBoot)

项目概述

大家好,我是一个在浙江奋斗的Java程序员,最近接了个"刺激"的外包项目 - 开发一个支持20G大文件上传下载的系统,还要兼容IE9这种上古浏览器。客户要求使用原生JS实现(不能用jQuery等库),支持文件夹上传(保留层级结构),还要加密传输和断点续传。预算只有100元,但需要提供完整技术支持和源代码。

这活儿听着就刺激,但咱是谁?是打不死的小强!下面我就分享一下我的解决方案和部分代码实现。

技术选型

  • 前端:Vue3 CLI + 原生JavaScript(兼容IE9)
  • 后端:SpringBoot + Tomcat
  • 数据库:MySQL(主要存用户信息和文件元数据)
  • 文件存储:服务器本地文件系统
  • 加密:SM4(国密)和AES

系统架构

浏览器(IE9+) ←HTTP→ SpringBoot后端 ←本地IO→ 文件存储
                     ↑
                    MySQL(元数据)

前端实现(关键代码)

1. 文件夹上传组件(兼容IE9)





export default {
  data() {
    return {
      fileList: [],
      chunkSize: 5 * 1024 * 1024, // 5MB分片
      concurrent: 3 // 并发上传数
    }
  },
  methods: {
    triggerFileInput() {
      document.getElementById('fileInput').click();
    },
    
    handleFileChange(e) {
      const files = e.target.files;
      if (!files.length) return;
      
      // 处理文件夹结构
      const fileTree = this.buildFileTree(files);
      this.prepareUpload(fileTree);
    },
    
    // 构建文件树结构(保留文件夹层级)
    buildFileTree(files) {
      const tree = {};
      
      for (let i = 0; i < files.length; i++) {
        const file = files[i];
        const path = file.webkitRelativePath || file.relativePath || file.name;
        const parts = path.split('/');
        
        // 添加文件信息
        const fileName = parts[parts.length - 1];
        current[fileName] = {
          file: file,
          relativePath: path,
          size: file.size,
          chunks: Math.ceil(file.size / this.chunkSize),
          uploadedChunks: 0,
          progress: 0,
          status: '等待上传'
        };
      }
      
      return tree;
    },
    
    // 准备上传队列
    prepareUpload(tree) {
      const flattenFiles = [];
      
      const traverse = (node, path = '') => {
        for (const key in node) {
          if (key === '__files__' || key === '__dirs__') continue;
          
          const newPath = path ? `${path}/${key}` : key;
          if (node[key].file) {
            // 是文件
            flattenFiles.push({
              ...node[key],
              relativePath: newPath
            });
          } else {
            // 是目录,继续遍历
            traverse(node[key].__dirs__, newPath);
          }
        }
      };
      
      traverse(tree);
      this.fileList = flattenFiles;
      this.startUpload();
    },
    
    // 开始上传
    startUpload() {
      const activeUploads = 0;
      
      const uploadNext = () => {
        if (activeUploads >= this.concurrent) return;
        
        const file = this.fileList.find(f => f.status === '等待上传');
        if (!file) {
          if (this.fileList.every(f => f.status === '上传完成')) {
            this.$emit('upload-complete');
          }
          return;
        }
      };
      
      uploadNext();
    },
    
    // 分片上传文件
    async uploadFile(fileEntry) {
      const file = fileEntry.file;
      const fileId = this.generateFileId(file);
      
      // 检查断点续传信息
      const resumeInfo = this.getResumeInfo(fileId);
      let startChunk = resumeInfo ? resumeInfo.uploadedChunks : 0;
      
      for (let i = startChunk; i < fileEntry.chunks; i++) {
        const start = i * this.chunkSize;
        const end = Math.min(start + this.chunkSize, file.size);
        const chunk = file.slice(start, end);
        
        // 读取分片内容(兼容IE9)
        const chunkData = await this.readFileAsArrayBuffer(chunk);
        
        // 加密处理(这里简化,实际应该用Web Crypto API或polyfill)
        const encryptedData = this.encryptData(chunkData, 'AES'); // 实际应该用SM4
        
        const formData = new FormData();
        formData.append('fileId', fileId);
        formData.append('chunkIndex', i);
        formData.append('totalChunks', fileEntry.chunks);
        formData.append('relativePath', fileEntry.relativePath);
        formData.append('fileSize', file.size);
        formData.append('chunk', new Blob([encryptedData]));
        formData.append('fileName', file.name);
      }
      
      // 所有分片上传完成,通知服务器合并
      await this.mergeFile(fileId, fileEntry.relativePath, file.size, file.name);
      fileEntry.status = '上传完成';
      this.clearResumeInfo(fileId);
    },
    
    // 以下是兼容IE9的工具方法
    readFileAsArrayBuffer(file) {
      return new Promise((resolve) => {
        const reader = new FileReader();
        reader.onload = (e) => resolve(e.target.result);
        reader.readAsArrayBuffer(file);
      });
    },
    
    encryptData(data, algorithm) {
      // 实际项目中应该使用Web Crypto API或polyfill
      // 这里简化处理,实际加密代码会更复杂
      if (typeof data === 'string') {
        return btoa(data); // 简单base64模拟加密
      } else {
        const bytes = new Uint8Array(data);
        let result = '';
        for (let i = 0; i < bytes.length; i++) {
          result += String.fromCharCode(bytes[i]);
        }
        return btoa(result);
      }
    },
    
    async uploadChunk(formData) {
      return new Promise((resolve, reject) => {
        const xhr = new XMLHttpRequest();
        xhr.open('POST', '/api/upload/chunk', true);
        
        xhr.onload = () => {
          if (xhr.status >= 200 && xhr.status < 300) {
            resolve(JSON.parse(xhr.responseText));
          } else {
            reject(new Error('上传失败'));
          }
        };
        
        xhr.onerror = () => reject(new Error('网络错误'));
        xhr.send(formData);
      });
    },
    
    async mergeFile(fileId, relativePath, fileSize, fileName) {
      return new Promise((resolve, reject) => {
        const xhr = new XMLHttpRequest();
        xhr.open('POST', '/api/upload/merge', true);
        xhr.setRequestHeader('Content-Type', 'application/json');
        
        xhr.onload = () => {
          if (xhr.status >= 200 && xhr.status < 300) {
            resolve(JSON.parse(xhr.responseText));
          } else {
            reject(new Error('合并失败'));
          }
        };
        
        xhr.onerror = () => reject(new Error('网络错误'));
        xhr.send(JSON.stringify({
          fileId,
          relativePath,
          fileSize,
          fileName
        }));
      });
    },
    
    // 断点续传相关方法(使用localStorage存储)
    generateFileId(file) {
      // 简单生成文件ID,实际应该更可靠
      return `${file.name}-${file.size}-${file.lastModified}`;
    },
    
    getResumeInfo(fileId) {
      const info = localStorage.getItem(`upload_resume_${fileId}`);
      return info ? JSON.parse(info) : null;
    },
    
    saveResumeInfo(fileId, fileEntry) {
      localStorage.setItem(`upload_resume_${fileId}`, JSON.stringify({
        uploadedChunks: fileEntry.uploadedChunks,
        relativePath: fileEntry.relativePath,
        fileSize: fileEntry.size,
        fileName: fileEntry.file.name
      }));
    },
    
    clearResumeInfo(fileId) {
      localStorage.removeItem(`upload_resume_${fileId}`);
    },
    
    formatSize(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];
    }
  }
}


后端实现(SpringBoot关键代码)

1. 文件上传控制器

@RestController
@RequestMapping("/api/upload")
public class FileUploadController {
    
    private static final String UPLOAD_DIR = "/var/bigfileupload/"; // 实际应该从配置读取
    private static final int CHUNK_SIZE = 5 * 1024 * 1024; // 5MB
    
    @PostMapping("/chunk")
    public ResponseEntity uploadChunk(
            @RequestParam("fileId") String fileId,
            @RequestParam("chunkIndex") int chunkIndex,
            @RequestParam("totalChunks") int totalChunks,
            @RequestParam("relativePath") String relativePath,
            @RequestParam("fileSize") long fileSize,
            @RequestParam("fileName") String fileName,
            @RequestParam("chunk") MultipartFile chunk) throws IOException {
        
        // 创建临时目录
        String tempDir = UPLOAD_DIR + "temp/" + fileId + "/";
        File tempDirFile = new File(tempDir);
        if (!tempDirFile.exists()) {
            tempDirFile.mkdirs();
        }
        
        // 保存分片(实际应该先解密)
        String chunkPath = tempDir + chunkIndex;
        chunk.transferTo(new File(chunkPath));
        
        // 记录上传进度(实际应该用数据库)
        UploadProgress progress = new UploadProgress();
        progress.setFileId(fileId);
        progress.setUploadedChunks(chunkIndex + 1);
        progress.setTotalChunks(totalChunks);
        progress.setRelativePath(relativePath);
        progress.setFileSize(fileSize);
        progress.setFileName(fileName);
        // saveToDatabase(progress); // 实际应该存数据库
        
        return ResponseEntity.ok().body(Map.of(
            "status", "success",
            "chunkIndex", chunkIndex,
            "fileId", fileId
        ));
    }
    
    @PostMapping("/merge")
    public ResponseEntity mergeFile(
            @RequestBody MergeRequest request) throws IOException, NoSuchAlgorithmException {
        
        String fileId = request.getFileId();
        String tempDir = UPLOAD_DIR + "temp/" + fileId + "/";
        File tempDirFile = new File(tempDir);
        
        if (!tempDirFile.exists()) {
            return ResponseEntity.badRequest().body(Map.of("error", "临时文件不存在"));
        }
        
        // 创建目标目录结构
        String relativePath = request.getRelativePath();
        String targetPath = UPLOAD_DIR + relativePath;
        File targetFile = new File(targetPath);
    }
    
    // 下载文件接口(非打包方式)
    @GetMapping("/download")
    public ResponseEntity downloadFile(
            @RequestParam String filePath,
            HttpServletResponse response) throws IOException {
        
        // 设置响应头
        HttpHeaders headers = new HttpHeaders();
        headers.add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + file.getName() + "\"");
        headers.add(HttpHeaders.CONTENT_TYPE, Files.probeContentType(file.toPath()));
        headers.add(HttpHeaders.CONTENT_LENGTH, String.valueOf(file.length()));
        
        return ResponseEntity.ok()
                .headers(headers)
                .body(resource);
    }
    
    // 文件夹下载(递归下载)
    @GetMapping("/download/folder")
    public void downloadFolder(
            @RequestParam String folderPath,
            HttpServletResponse response) throws IOException {
        
        // 实际实现应该递归遍历文件夹,生成zip或逐个文件下载
        // 这里简化处理,实际项目中需要更复杂的实现
        response.setContentType("application/zip");
        response.setHeader("Content-Disposition", "attachment; filename=\"" + new File(folderPath).getName() + ".zip\"");
        
        // 实际应该使用ZipOutputStream打包
        // 这里只是示例,实际不会这样实现
        try (ServletOutputStream out = response.getOutputStream()) {
            out.write("这不是真正的zip文件,实际应该递归打包文件夹".getBytes());
        }
    }
}

2. 数据库实体类

@Entity
@Table(name = "file_metadata")
public class FileMetadata {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String fileId;
    private String filePath;
    private String fileName;
    private long fileSize;
    private Date uploadTime;
    
    // getters and setters
}

兼容IE9的注意事项

  1. XMLHttpRequest:IE9不支持FormData,但支持XMLHttpRequest上传文件
  2. FileReader:IE10+才完全支持,IE9需要polyfill
  3. Blob:IE10+支持,IE9需要使用BlobBuilder
  4. 加密:Web Crypto API在IE9不可用,需要使用第三方库如CryptoJS

IE9兼容的加密方案示例

// 在index.html中引入CryptoJS
// 

// 修改encryptData方法
encryptData(data, algorithm) {
  if (algorithm === 'AES') {
    // 使用CryptoJS进行AES加密
    const key = CryptoJS.enc.Utf8.parse('1234567890123456'); // 实际应该从安全配置读取
    const iv = CryptoJS.enc.Utf8.parse('1234567890123456');
    
    let dataToEncrypt;
    if (typeof data === 'string') {
      dataToEncrypt = data;
    } else {
      // 如果是ArrayBuffer,转换为字符串
      const bytes = new Uint8Array(data);
      let str = '';
      for (let i = 0; i < bytes.length; i++) {
        str += String.fromCharCode(bytes[i]);
      }
      dataToEncrypt = str;
    }
    
    const encrypted = CryptoJS.AES.encrypt(dataToEncrypt, key, {
      iv: iv,
      mode: CryptoJS.mode.CBC,
      padding: CryptoJS.pad.Pkcs7
    });
    
    return encrypted.toString();
  }
  
  // 其他算法...
  return data; // 未加密
}

部署说明

  1. 前端构建

    npm install
    npm run build
    

    将生成的dist目录内容部署到Tomcat的webapps/ROOT目录

  2. 后端配置

    • 修改application.properties
      server.port=8080
      spring.servlet.multipart.max-file-size=21GB
      spring.servlet.multipart.max-request-size=21GB
      file.upload-dir=/var/bigfileupload/
      
    • 确保上传目录存在且有写入权限:
      mkdir -p /var/bigfileupload/temp
      chmod 777 /var/bigfileupload
      
  3. 数据库初始化

    • 创建MySQL数据库并执行SQL脚本:
      CREATE TABLE file_metadata (
        id BIGINT AUTO_INCREMENT PRIMARY KEY,
        file_id VARCHAR(255) NOT NULL,
        file_path TEXT NOT NULL,
        file_name VARCHAR(255) NOT NULL,
        file_size BIGINT NOT NULL,
        upload_time DATETIME NOT NULL
      );
      
      

开发文档要点

  1. 系统功能

    • 大文件分片上传(支持20GB+)
    • 文件夹上传(保留层级结构)
    • 断点续传(基于localStorage)
    • 加密传输和存储(AES/SM4)
    • 兼容IE9+等主流浏览器
  2. API文档

    • POST /api/upload/chunk - 上传文件分片
    • POST /api/upload/merge - 合并文件分片
    • GET /api/upload/download - 下载单个文件
    • GET /api/upload/download/folder - 下载整个文件夹
  3. 部署文档

    • 环境要求:JDK 8+, Node.js, MySQL, Tomcat 8+
    • 配置文件说明
    • 初始化脚本

总结

这个项目确实挑战不小,但通过合理的分片上传、断点续传机制和兼容性处理,我们还是能够实现客户的需求。关键点在于:

  1. 前端使用原生JS实现文件夹结构解析和上传队列管理
  2. 后端提供分片上传和合并接口
  3. 使用localStorage存储上传进度实现断点续传
  4. 通过CryptoJS等库实现兼容IE9的加密

由于预算有限,我省略了一些高级功能如:

  • 完整的SM4加密实现(实际需要引入Bouncy Castle等库)
  • 分布式存储支持
  • 详细的权限控制
  • 完善的错误处理和日志

如果需要更完整的实现,建议考虑:

  1. 使用WebUploader等成熟库(但需要处理兼容性问题)
  2. 增加预算购买商业组件
  3. 分阶段开发,先实现核心功能

最后,欢迎加入我们的QQ群374992201,一起交流技术,合作接单!群里经常有红包和技术分享,还有项目合作机会哦!

导入项目

导入到Eclipse:点南查看教程
导入到IDEA:点击查看教程
springboot统一配置:点击查看教程

工程

image

NOSQL

NOSQL示例不需要任何配置,可以直接访问测试
image

创建数据表

选择对应的数据表脚本,这里以SQL为例
image
image

修改数据库连接信息

image

访问页面进行测试

image

文件存储路径

up6/upload/年/月/日/guid/filename
image
image

效果预览

文件上传

文件上传

文件刷新续传

支持离线保存文件进度,在关闭浏览器,刷新浏览器后进行不丢失,仍然能够继续上传
文件续传

文件夹上传

支持上传文件夹并保留层级结构,同样支持进度信息离线保存,刷新页面,关闭页面,重启系统不丢失上传进度。
文件夹上传

下载示例

点击下载完整示例

Logo

火山引擎开发者社区是火山引擎打造的AI技术生态平台,聚焦Agent与大模型开发,提供豆包系列模型(图像/视频/视觉)、智能分析与会话工具,并配套评测集、动手实验室及行业案例库。社区通过技术沙龙、挑战赛等活动促进开发者成长,新用户可领50万Tokens权益,助力构建智能应用。

更多推荐