全部笔记All notes

Spring MVC 文件上传与下载完整实现指南

阅读 8m 11s8m 11s read

概述

文件上传和下载是Web应用中的常见功能。Spring MVC通过MultipartResolver接口提供了完善的文件上传支持,结合ResponseEntity可以实现灵活的文件下载功能。

核心技术栈

  • Spring MVC 4.3.6: 提供文件处理的核心支持
  • Commons FileUpload: Apache的文件上传组件
  • Commons IO: 提供文件操作的工具类
  • MultipartFile: Spring的文件上传抽象接口

应用场景

  • 用户头像上传
  • 文档资料管理
  • 图片/视频分享
  • 批量数据导入导出
  • 在线文件存储服务

环境准备

Maven依赖配置

在pom.xml中添加以下依赖:

<!-- Spring Web MVC -->
<dependency>
  <groupId>org.springframework</groupId>
  <artifactId>spring-web</artifactId>
  <version>4.3.6.RELEASE</version>
</dependency>
<dependency>
  <groupId>org.springframework</groupId>
  <artifactId>spring-webmvc</artifactId>
  <version>4.3.6.RELEASE</version>
</dependency>

<!-- 文件上传组件 -->
<dependency>
  <groupId>commons-fileupload</groupId>
  <artifactId>commons-fileupload</artifactId>
  <version>1.3.2</version>
</dependency>
<dependency>
  <groupId>commons-io</groupId>
  <artifactId>commons-io</artifactId>
  <version>2.5</version>
</dependency>

<!-- Servlet API -->
<dependency>
  <groupId>javax.servlet</groupId>
  <artifactId>servlet-api</artifactId>
  <version>2.5</version>
  <scope>provided</scope>
</dependency>

<!-- 可选:JSON处理 -->
<dependency>
  <groupId>com.fasterxml.jackson.core</groupId>
  <artifactId>jackson-databind</artifactId>
  <version>2.9.8</version>
</dependency>

Web配置文件

在web.xml中配置Spring MVC的DispatcherServlet:

<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee 
         http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd"
         version="3.1">
         
    <!-- 配置字符编码过滤器 -->
    <filter>
        <filter-name>encodingFilter</filter-name>
        <filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class>
        <init-param>
            <param-name>encoding</param-name>
            <param-value>UTF-8</param-value>
        </init-param>
        <init-param>
            <param-name>forceEncoding</param-name>
            <param-value>true</param-value>
        </init-param>
    </filter>
    <filter-mapping>
        <filter-name>encodingFilter</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>
    
    <!-- 配置Spring MVC DispatcherServlet -->
    <servlet>
        <servlet-name>springmvc</servlet-name>
        <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
        <init-param>
            <param-name>contextConfigLocation</param-name>
            <param-value>classpath:springmvc-config.xml</param-value>
        </init-param>
        <load-on-startup>1</load-on-startup>
    </servlet>
    <servlet-mapping>
        <servlet-name>springmvc</servlet-name>
        <url-pattern>/</url-pattern>
    </servlet-mapping>
</web-app>

Spring MVC配置

在springmvc-config.xml中配置文件上传解析器:

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns:context="http://www.springframework.org/schema/context"
       xmlns:mvc="http://www.springframework.org/schema/mvc"
       xsi:schemaLocation="
        http://www.springframework.org/schema/beans
        http://www.springframework.org/schema/beans/spring-beans.xsd
        http://www.springframework.org/schema/context
        http://www.springframework.org/schema/context/spring-context.xsd
        http://www.springframework.org/schema/mvc
        http://www.springframework.org/schema/mvc/spring-mvc.xsd">

    <!-- 开启组件扫描 -->
    <context:component-scan base-package="com.owlbay.controller" />
    
    <!-- 开启注解驱动 -->
    <mvc:annotation-driven />
    
    <!-- 配置视图解析器 -->
    <bean id="viewResolver" 
          class="org.springframework.web.servlet.view.InternalResourceViewResolver">
        <property name="prefix" value="/WEB-INF/jsp/" />
        <property name="suffix" value=".jsp" />
    </bean>
    
    <!-- 配置文件上传解析器 -->
    <bean id="multipartResolver" 
          class="org.springframework.web.multipart.commons.CommonsMultipartResolver">
        <!-- 设置请求编码格式 -->
        <property name="defaultEncoding" value="UTF-8" />
        <!-- 设置允许上传的最大文件大小(10MB) -->
        <property name="maxUploadSize" value="10485760" />
        <!-- 设置允许上传的单个文件最大大小(5MB) -->
        <property name="maxUploadSizePerFile" value="5242880" />
        <!-- 设置上传文件的临时目录 -->
        <property name="uploadTempDir" value="/temp" />
        <!-- 设置内存中的最大缓存大小 -->
        <property name="maxInMemorySize" value="4096" />
    </bean>
    
    <!-- 静态资源处理 -->
    <mvc:resources mapping="/static/**" location="/static/" />
    <mvc:resources mapping="/upload/**" location="/upload/" />
</beans>

文件上传实现

基本原理

文件上传的基本原理:

  1. 客户端:使用multipart/form-data编码类型提交表单
  2. 服务端:通过MultipartResolver解析请求,提取文件数据
  3. 存储:将文件保存到服务器指定位置
  4. 响应:返回上传结果给客户端

单文件上传

创建上传页面

创建fileUpload.jsp页面:

<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<!DOCTYPE html>
<html>
<head>
    <meta http-equiv="Content-Type" content="text/html;charset=UTF-8"/>
    <title>文件上传示例</title>
    <style>
        .upload-container {
            width: 600px;
            margin: 50px auto;
            padding: 20px;
            border: 1px solid #ddd;
            border-radius: 5px;
        }
        .form-group {
            margin-bottom: 15px;
        }
        label {
            display: inline-block;
            width: 100px;
            text-align: right;
            margin-right: 10px;
        }
        input[type="text"], input[type="file"] {
            width: 300px;
            padding: 5px;
        }
        .btn {
            margin-left: 110px;
            padding: 8px 20px;
            background-color: #4CAF50;
            color: white;
            border: none;
            border-radius: 4px;
            cursor: pointer;
        }
        .btn:hover {
            background-color: #45a049;
        }
        #progressBar {
            width: 100%;
            height: 20px;
            border: 1px solid #ddd;
            display: none;
            margin-top: 10px;
        }
        #progress {
            width: 0%;
            height: 100%;
            background-color: #4CAF50;
            text-align: center;
            line-height: 20px;
            color: white;
        }
    </style>
    <script>
        function validateForm() {
            var name = document.getElementById("name").value;
            var file = document.getElementById("file").value;
            
            if(name.trim() === ""){
                alert("请填写上传人姓名");
                return false;
            }
            
            if(file.length === 0 || file === ""){
                alert("请选择要上传的文件");
                return false;
            }
            
            // 获取文件扩展名
            var ext = file.substring(file.lastIndexOf('.') + 1).toLowerCase();
            var allowedExts = ['jpg', 'jpeg', 'png', 'gif', 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'txt'];
            
            if(allowedExts.indexOf(ext) === -1){
                alert("不支持的文件类型!\n支持的类型:" + allowedExts.join(', '));
                return false;
            }
            
            return true;
        }
        
        // 显示文件信息
        function showFileInfo() {
            var fileInput = document.getElementById("file");
            var files = fileInput.files;
            
            if(files.length > 0) {
                var fileInfo = "选中文件:\n";
                for(var i = 0; i < files.length; i++) {
                    fileInfo += "- " + files[i].name + " (" + formatFileSize(files[i].size) + ")\n";
                }
                document.getElementById("fileInfo").innerText = fileInfo;
            }
        }
        
        // 格式化文件大小
        function formatFileSize(bytes) {
            if(bytes === 0) return '0 Bytes';
            var k = 1024;
            var sizes = ['Bytes', 'KB', 'MB', 'GB'];
            var i = Math.floor(Math.log(bytes) / Math.log(k));
            return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
        }
    </script>
</head>
<body>
    <div class="upload-container">
        <h2>文件上传</h2>
        <form action="${pageContext.request.contextPath}/fileUpload" 
              method="post" 
              enctype="multipart/form-data" 
              onsubmit="return validateForm()">
            
            <div class="form-group">
                <label for="name">上传人:</label>
                <input type="text" id="name" name="name" placeholder="请输入您的姓名"/>
            </div>
            
            <div class="form-group">
                <label for="file">选择文件:</label>
                <input type="file" id="file" name="uploadfile" 
                       multiple="multiple" onchange="showFileInfo()"/>
            </div>
            
            <div class="form-group">
                <pre id="fileInfo"></pre>
            </div>
            
            <div class="form-group">
                <input type="submit" value="开始上传" class="btn"/>
            </div>
            
            <div id="progressBar">
                <div id="progress">0%</div>
            </div>
        </form>
    </div>
</body>
</html>

创建结果页面

创建上传成功页面success.jsp:

<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<!DOCTYPE html>
<html>
<head>
    <title>上传成功</title>
    <style>
        .success-container {
            width: 500px;
            margin: 100px auto;
            text-align: center;
            padding: 20px;
            border: 2px solid #4CAF50;
            border-radius: 5px;
            background-color: #f0f8f0;
        }
        .success-icon {
            color: #4CAF50;
            font-size: 48px;
        }
        a {
            color: #4CAF50;
            text-decoration: none;
        }
    </style>
</head>
<body>
    <div class="success-container">
        <div class="success-icon">✓</div>
        <h2>文件上传成功!</h2>
        <p>您的文件已成功上传到服务器。</p>
        <p><a href="${pageContext.request.contextPath}/fileUpload.jsp">继续上传</a> | 
           <a href="${pageContext.request.contextPath}/download.jsp">文件下载</a></p>
    </div>
</body>
</html>

创建上传失败页面error.jsp:

<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<!DOCTYPE html>
<html>
<head>
    <title>上传失败</title>
    <style>
        .error-container {
            width: 500px;
            margin: 100px auto;
            text-align: center;
            padding: 20px;
            border: 2px solid #f44336;
            border-radius: 5px;
            background-color: #fef0f0;
        }
        .error-icon {
            color: #f44336;
            font-size: 48px;
        }
        a {
            color: #f44336;
            text-decoration: none;
        }
    </style>
</head>
<body>
    <div class="error-container">
        <div class="error-icon">✗</div>
        <h2>文件上传失败!</h2>
        <p>错误信息:${errorMsg}</p>
        <p><a href="javascript:history.back()">返回重试</a></p>
    </div>
</body>
</html>

创建文件上传控制器

创建FileUploadController.java:

package com.owlbay.controller;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.apache.commons.io.FileUtils;
import javax.servlet.http.HttpServletRequest;
import java.io.File;
import java.io.IOException;
import java.text.SimpleDateFormat;
import java.util.*;

/**
 * 文件上传控制器
 * @author Owlbay
 * @date 2022/05/17
 */
@Controller
public class FileUploadController {
    
    // 允许上传的文件类型
    private static final List<String> ALLOWED_EXTENSIONS = Arrays.asList(
        "jpg", "jpeg", "png", "gif", "bmp", 
        "pdf", "doc", "docx", "xls", "xlsx", 
        "ppt", "pptx", "txt", "zip", "rar"
    );
    
    // 最大文件大小(5MB)
    private static final long MAX_FILE_SIZE = 5 * 1024 * 1024;
    
    /**
     * 显示文件上传页面
     */
    @RequestMapping(value = "/upload", method = RequestMethod.GET)
    public String showUploadForm() {
        return "fileUpload";
    }
    
    /**
     * 处理单文件上传
     */
    @RequestMapping(value = "/fileUpload", method = RequestMethod.POST)
    public String handleFileUpload(
            @RequestParam("name") String name,
            @RequestParam("uploadfile") MultipartFile file,
            HttpServletRequest request,
            Model model) {
        
        // 验证文件是否为空
        if (file.isEmpty()) {
            model.addAttribute("errorMsg", "请选择要上传的文件");
            return "error";
        }
        
        try {
            // 验证文件大小
            if (file.getSize() > MAX_FILE_SIZE) {
                model.addAttribute("errorMsg", "文件大小超过限制(最大5MB)");
                return "error";
            }
            
            // 获取文件扩展名
            String originalFilename = file.getOriginalFilename();
            String extension = getFileExtension(originalFilename);
            
            // 验证文件类型
            if (!isAllowedExtension(extension)) {
                model.addAttribute("errorMsg", "不支持的文件类型");
                return "error";
            }
            
            // 生成唯一文件名
            String newFilename = generateUniqueFilename(name, originalFilename);
            
            // 获取上传目录
            String uploadDir = getUploadDirectory(request);
            File uploadPath = new File(uploadDir);
            
            // 创建目录
            if (!uploadPath.exists()) {
                uploadPath.mkdirs();
            }
            
            // 保存文件
            File destFile = new File(uploadPath, newFilename);
            file.transferTo(destFile);
            
            // 记录上传信息
            logUploadInfo(name, originalFilename, newFilename, file.getSize());
            
            model.addAttribute("filename", newFilename);
            model.addAttribute("filesize", formatFileSize(file.getSize()));
            
            return "success";
            
        } catch (IOException e) {
            e.printStackTrace();
            model.addAttribute("errorMsg", "文件上传失败:" + e.getMessage());
            return "error";
        }
    }
    
    /**
     * 处理多文件上传
     */
    @RequestMapping(value = "/multiFileUpload", method = RequestMethod.POST)
    @ResponseBody
    public Map<String, Object> handleMultiFileUpload(
            @RequestParam("name") String name,
            @RequestParam("uploadfiles") List<MultipartFile> files,
            HttpServletRequest request) {
        
        Map<String, Object> result = new HashMap<>();
        List<Map<String, String>> uploadedFiles = new ArrayList<>();
        int successCount = 0;
        int failCount = 0;
        
        for (MultipartFile file : files) {
            Map<String, String> fileInfo = new HashMap<>();
            
            try {
                if (!file.isEmpty()) {
                    // 处理单个文件上传
                    String originalFilename = file.getOriginalFilename();
                    String extension = getFileExtension(originalFilename);
                    
                    // 验证文件
                    if (!isAllowedExtension(extension)) {
                        fileInfo.put("name", originalFilename);
                        fileInfo.put("status", "failed");
                        fileInfo.put("message", "不支持的文件类型");
                        failCount++;
                        continue;
                    }
                    
                    if (file.getSize() > MAX_FILE_SIZE) {
                        fileInfo.put("name", originalFilename);
                        fileInfo.put("status", "failed");
                        fileInfo.put("message", "文件大小超过限制");
                        failCount++;
                        continue;
                    }
                    
                    // 生成文件名并保存
                    String newFilename = generateUniqueFilename(name, originalFilename);
                    String uploadDir = getUploadDirectory(request);
                    File uploadPath = new File(uploadDir);
                    
                    if (!uploadPath.exists()) {
                        uploadPath.mkdirs();
                    }
                    
                    File destFile = new File(uploadPath, newFilename);
                    file.transferTo(destFile);
                    
                    fileInfo.put("name", originalFilename);
                    fileInfo.put("newName", newFilename);
                    fileInfo.put("size", formatFileSize(file.getSize()));
                    fileInfo.put("status", "success");
                    successCount++;
                }
                
            } catch (Exception e) {
                fileInfo.put("name", file.getOriginalFilename());
                fileInfo.put("status", "failed");
                fileInfo.put("message", e.getMessage());
                failCount++;
            }
            
            uploadedFiles.add(fileInfo);
        }
        
        result.put("files", uploadedFiles);
        result.put("successCount", successCount);
        result.put("failCount", failCount);
        result.put("total", files.size());
        
        return result;
    }
    
    /**
     * 获取文件扩展名
     */
    private String getFileExtension(String filename) {
        if (filename == null || filename.isEmpty()) {
            return "";
        }
        int dotIndex = filename.lastIndexOf('.');
        return (dotIndex == -1) ? "" : filename.substring(dotIndex + 1).toLowerCase();
    }
    
    /**
     * 检查文件扩展名是否允许
     */
    private boolean isAllowedExtension(String extension) {
        return ALLOWED_EXTENSIONS.contains(extension.toLowerCase());
    }
    
    /**
     * 生成唯一的文件名
     */
    private String generateUniqueFilename(String uploaderName, String originalFilename) {
        SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMdd");
        String dateFolder = sdf.format(new Date());
        String uuid = UUID.randomUUID().toString().replace("-", "");
        String extension = getFileExtension(originalFilename);
        
        // 格式:日期/上传人_时间戳_UUID.扩展名
        return dateFolder + "/" + uploaderName + "_" + 
               System.currentTimeMillis() + "_" + 
               uuid + "." + extension;
    }
    
    /**
     * 获取上传目录路径
     */
    private String getUploadDirectory(HttpServletRequest request) {
        String realPath = request.getSession().getServletContext().getRealPath("/");
        return realPath + "upload" + File.separator;
    }
    
    /**
     * 格式化文件大小
     */
    private String formatFileSize(long size) {
        if (size <= 0) return "0 B";
        final String[] units = {"B", "KB", "MB", "GB", "TB"};
        int digitGroups = (int) (Math.log10(size) / Math.log10(1024));
        return String.format("%.2f %s", size / Math.pow(1024, digitGroups), units[digitGroups]);
    }
    
    /**
     * 记录上传日志
     */
    private void logUploadInfo(String uploader, String originalName, String savedName, long size) {
        // 这里可以将上传信息保存到数据库或日志文件
        System.out.println(String.format(
            "文件上传 - 上传人: %s, 原始文件名: %s, 保存文件名: %s, 大小: %s",
            uploader, originalName, savedName, formatFileSize(size)
        ));
    }
}

多文件上传

多文件上传页面

创建multiFileUpload.jsp:

<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<!DOCTYPE html>
<html>
<head>
    <title>多文件上传</title>
    <style>
        .file-item {
            margin: 5px 0;
            padding: 5px;
            border: 1px solid #ddd;
            border-radius: 3px;
        }
        .remove-btn {
            color: red;
            cursor: pointer;
            float: right;
        }
    </style>
    <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
</head>
<body>
    <div class="upload-container">
        <h2>多文件上传</h2>
        <form id="multiUploadForm" enctype="multipart/form-data">
            <div class="form-group">
                <label>上传人:</label>
                <input type="text" name="name" required/>
            </div>
            <div class="form-group">
                <label>选择文件:</label>
                <input type="file" id="fileInput" multiple/>
            </div>
            <div id="fileList"></div>
            <button type="button" onclick="uploadFiles()">开始上传</button>
        </form>
        <div id="result"></div>
    </div>
    
    <script>
        var selectedFiles = [];
        
        $('#fileInput').change(function() {
            var files = this.files;
            var fileList = $('#fileList');
            fileList.empty();
            selectedFiles = [];
            
            for(var i = 0; i < files.length; i++) {
                selectedFiles.push(files[i]);
                var item = $('<div class="file-item">' + 
                    files[i].name + ' (' + formatFileSize(files[i].size) + ')' +
                    '<span class="remove-btn" onclick="removeFile(' + i + ')">×</span>' +
                    '</div>');
                fileList.append(item);
            }
        });
        
        function removeFile(index) {
            selectedFiles.splice(index, 1);
            updateFileList();
        }
        
        function updateFileList() {
            var fileList = $('#fileList');
            fileList.empty();
            
            selectedFiles.forEach(function(file, index) {
                var item = $('<div class="file-item">' + 
                    file.name + ' (' + formatFileSize(file.size) + ')' +
                    '<span class="remove-btn" onclick="removeFile(' + index + ')">×</span>' +
                    '</div>');
                fileList.append(item);
            });
        }
        
        function uploadFiles() {
            var formData = new FormData();
            formData.append('name', $('input[name="name"]').val());
            
            selectedFiles.forEach(function(file) {
                formData.append('uploadfiles', file);
            });
            
            $.ajax({
                url: '${pageContext.request.contextPath}/multiFileUpload',
                type: 'POST',
                data: formData,
                processData: false,
                contentType: false,
                success: function(result) {
                    showResult(result);
                },
                error: function() {
                    alert('上传失败!');
                }
            });
        }
        
        function showResult(result) {
            var html = '<h3>上传结果</h3>';
            html += '<p>总计:' + result.total + ' 个文件</p>';
            html += '<p>成功:' + result.successCount + ' 个</p>';
            html += '<p>失败:' + result.failCount + ' 个</p>';
            html += '<ul>';
            
            result.files.forEach(function(file) {
                html += '<li>' + file.name + ' - ' + 
                    (file.status === 'success' ? '成功' : '失败:' + file.message) + 
                    '</li>';
            });
            
            html += '</ul>';
            $('#result').html(html);
        }
        
        function formatFileSize(bytes) {
            if(bytes === 0) return '0 Bytes';
            var k = 1024;
            var sizes = ['Bytes', 'KB', 'MB', 'GB'];
            var i = Math.floor(Math.log(bytes) / Math.log(k));
            return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
        }
    </script>
</body>
</html>

大文件上传

对于大文件上传,需要实现分片上传功能:

/**
 * 大文件分片上传控制器
 */
@RestController
@RequestMapping("/chunk")
public class ChunkUploadController {
    
    // 存储上传进度
    private Map<String, ChunkUploadInfo> uploadProgressMap = new ConcurrentHashMap<>();
    
    /**
     * 接收文件分片
     */
    @PostMapping("/upload")
    public Map<String, Object> uploadChunk(
            @RequestParam("file") MultipartFile file,
            @RequestParam("chunkNumber") int chunkNumber,
            @RequestParam("totalChunks") int totalChunks,
            @RequestParam("identifier") String identifier,
            @RequestParam("filename") String filename) {
        
        Map<String, Object> result = new HashMap<>();
        
        try {
            // 创建临时目录存储分片
            String tempDir = "temp/" + identifier;
            File tempDirFile = new File(tempDir);
            if (!tempDirFile.exists()) {
                tempDirFile.mkdirs();
            }
            
            // 保存分片
            String chunkFilename = filename + ".part" + chunkNumber;
            File chunkFile = new File(tempDir, chunkFilename);
            file.transferTo(chunkFile);
            
            // 更新上传进度
            ChunkUploadInfo uploadInfo = uploadProgressMap.computeIfAbsent(
                identifier, k -> new ChunkUploadInfo(filename, totalChunks)
            );
            uploadInfo.addUploadedChunk(chunkNumber);
            
            // 检查是否所有分片都已上传
            if (uploadInfo.isComplete()) {
                // 合并分片
                mergeChunks(identifier, filename, totalChunks);
                uploadProgressMap.remove(identifier);
                
                result.put("status", "complete");
                result.put("message", "文件上传完成");
            } else {
                result.put("status", "uploading");
                result.put("progress", uploadInfo.getProgress());
            }
            
        } catch (Exception e) {
            result.put("status", "error");
            result.put("message", e.getMessage());
        }
        
        return result;
    }
    
    /**
     * 合并分片
     */
    private void mergeChunks(String identifier, String filename, int totalChunks) 
            throws IOException {
        String tempDir = "temp/" + identifier;
        String uploadDir = "upload/";
        File uploadDirFile = new File(uploadDir);
        if (!uploadDirFile.exists()) {
            uploadDirFile.mkdirs();
        }
        
        // 创建目标文件
        File mergedFile = new File(uploadDir, filename);
        try (FileOutputStream fos = new FileOutputStream(mergedFile)) {
            // 按顺序合并分片
            for (int i = 1; i <= totalChunks; i++) {
                File chunkFile = new File(tempDir, filename + ".part" + i);
                Files.copy(chunkFile.toPath(), fos);
                chunkFile.delete(); // 删除分片
            }
        }
        
        // 删除临时目录
        new File(tempDir).delete();
    }
    
    /**
     * 获取上传进度
     */
    @GetMapping("/progress/{identifier}")
    public Map<String, Object> getProgress(@PathVariable String identifier) {
        Map<String, Object> result = new HashMap<>();
        ChunkUploadInfo uploadInfo = uploadProgressMap.get(identifier);
        
        if (uploadInfo != null) {
            result.put("progress", uploadInfo.getProgress());
            result.put("uploadedChunks", uploadInfo.getUploadedChunks());
            result.put("totalChunks", uploadInfo.getTotalChunks());
        } else {
            result.put("progress", 0);
        }
        
        return result;
    }
    
    /**
     * 分片上传信息类
     */
    private static class ChunkUploadInfo {
        private String filename;
        private int totalChunks;
        private Set<Integer> uploadedChunks = new HashSet<>();
        
        public ChunkUploadInfo(String filename, int totalChunks) {
            this.filename = filename;
            this.totalChunks = totalChunks;
        }
        
        public void addUploadedChunk(int chunkNumber) {
            uploadedChunks.add(chunkNumber);
        }
        
        public boolean isComplete() {
            return uploadedChunks.size() == totalChunks;
        }
        
        public int getProgress() {
            return (int) ((uploadedChunks.size() * 100.0) / totalChunks);
        }
        
        // getters...
    }
}

上传进度显示

使用Ajax实现文件上传进度显示:

function uploadWithProgress() {
    var formData = new FormData();
    var fileInput = document.getElementById('file');
    var file = fileInput.files[0];
    
    formData.append('file', file);
    formData.append('name', document.getElementById('name').value);
    
    $.ajax({
        url: '/fileUpload',
        type: 'POST',
        data: formData,
        processData: false,
        contentType: false,
        xhr: function() {
            var xhr = new window.XMLHttpRequest();
            // 监听上传进度
            xhr.upload.addEventListener('progress', function(evt) {
                if (evt.lengthComputable) {
                    var percentComplete = evt.loaded / evt.total * 100;
                    $('#progress').css('width', percentComplete + '%');
                    $('#progress').text(Math.round(percentComplete) + '%');
                }
            }, false);
            return xhr;
        },
        success: function(response) {
            alert('上传成功!');
        },
        error: function() {
            alert('上传失败!');
        }
    });
}

文件下载实现

基本下载

文件下载的实现方式有多种,最常用的是通过ResponseEntity返回文件流。

创建下载页面

创建download.jsp:

<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ page import="java.net.URLEncoder" %>
<!DOCTYPE html>
<html>
<head>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
    <title>文件下载中心</title>
    <style>
        .download-container {
            width: 800px;
            margin: 50px auto;
            padding: 20px;
        }
        .file-list {
            border: 1px solid #ddd;
            border-radius: 5px;
            padding: 20px;
        }
        .file-item {
            display: flex;
            justify-content: space-between;
            align-items: center;
            padding: 10px;
            border-bottom: 1px solid #eee;
        }
        .file-item:last-child {
            border-bottom: none;
        }
        .file-info {
            flex: 1;
        }
        .file-name {
            font-weight: bold;
            margin-bottom: 5px;
        }
        .file-meta {
            color: #666;
            font-size: 14px;
        }
        .download-btn {
            background-color: #4CAF50;
            color: white;
            padding: 8px 16px;
            text-decoration: none;
            border-radius: 4px;
        }
        .download-btn:hover {
            background-color: #45a049;
        }
    </style>
</head>
<body>
    <div class="download-container">
        <h2>文件下载中心</h2>
        
        <div class="file-list">
            <h3>可下载文件列表</h3>
            
            <!-- 示例文件列表 -->
            <div class="file-item">
                <div class="file-info">
                    <div class="file-name">示例文档.pdf</div>
                    <div class="file-meta">大小: 2.5 MB | 上传时间: 2025-09-12</div>
                </div>
                <a href="${pageContext.request.contextPath}/download?filename=示例文档.pdf" 
                   class="download-btn">下载</a>
            </div>
            
            <div class="file-item">
                <div class="file-info">
                    <div class="file-name">项目报告.docx</div>
                    <div class="file-meta">大小: 1.2 MB | 上传时间: 2025-09-11</div>
                </div>
                <a href="${pageContext.request.contextPath}/download?filename=<%=URLEncoder.encode("项目报告.docx", "UTF-8")%>" 
                   class="download-btn">下载</a>
            </div>
            
            <div class="file-item">
                <div class="file-info">
                    <div class="file-name">数据分析.xlsx</div>
                    <div class="file-meta">大小: 856 KB | 上传时间: 2025-09-10</div>
                </div>
                <a href="${pageContext.request.contextPath}/download?filename=<%=URLEncoder.encode("数据分析.xlsx", "UTF-8")%>" 
                   class="download-btn">下载</a>
            </div>
        </div>
        
        <!-- 批量下载 -->
        <div style="margin-top: 20px;">
            <h3>批量下载</h3>
            <form id="batchDownloadForm">
                <label><input type="checkbox" name="files" value="示例文档.pdf"> 示例文档.pdf</label><br>
                <label><input type="checkbox" name="files" value="项目报告.docx"> 项目报告.docx</label><br>
                <label><input type="checkbox" name="files" value="数据分析.xlsx"> 数据分析.xlsx</label><br>
                <button type="button" onclick="batchDownload()" style="margin-top: 10px;">批量下载</button>
            </form>
        </div>
    </div>
    
    <script>
        function batchDownload() {
            var checkboxes = document.getElementsByName('files');
            var selectedFiles = [];
            
            for(var i = 0; i < checkboxes.length; i++) {
                if(checkboxes[i].checked) {
                    selectedFiles.push(checkboxes[i].value);
                }
            }
            
            if(selectedFiles.length === 0) {
                alert('请选择要下载的文件');
                return;
            }
            
            // 发送批量下载请求
            var form = document.createElement('form');
            form.method = 'POST';
            form.action = '${pageContext.request.contextPath}/batchDownload';
            
            selectedFiles.forEach(function(file) {
                var input = document.createElement('input');
                input.type = 'hidden';
                input.name = 'files';
                input.value = file;
                form.appendChild(input);
            });
            
            document.body.appendChild(form);
            form.submit();
            document.body.removeChild(form);
        }
    </script>
</body>
</html>

中文文件名处理

创建文件下载控制器FileDownloadController.java:

package com.owlbay.controller;

import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.http.*;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*;
import org.apache.commons.io.FileUtils;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.*;
import java.net.URLEncoder;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;

/**
 * 文件下载控制器
 * @author Owlbay
 * @date 2022/05/17
 */
@Controller
public class FileDownloadController {
    
    /**
     * 基本文件下载(处理中文文件名)
     */
    @RequestMapping("/download")
    public ResponseEntity<byte[]> fileDownload(
            HttpServletRequest request,
            @RequestParam("filename") String filename) throws Exception {
            
        // 安全性检查:防止目录遍历攻击
        if (filename.contains("..") || filename.contains("/") || filename.contains("\\")) {
            return ResponseEntity.badRequest().build();
        }
        
        // 获取文件路径
        String uploadDir = request.getSession().getServletContext().getRealPath("/upload/");
        File file = new File(uploadDir, filename);
        
        // 检查文件是否存在
        if (!file.exists() || !file.isFile()) {
            return ResponseEntity.notFound().build();
        }
        
        // 检查文件是否可读
        if (!file.canRead()) {
            return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
        }
        
        // 对文件名进行编码处理
        String encodedFilename = encodeFilename(request, filename);
        
        // 设置响应头
        HttpHeaders headers = new HttpHeaders();
        headers.setContentDispositionFormData("attachment", encodedFilename);
        headers.setContentType(MediaType.APPLICATION_OCTET_STREAM);
        headers.setContentLength(file.length());
        
        // 添加缓存控制
        headers.setCacheControl(CacheControl.noCache());
        headers.setPragma("no-cache");
        headers.setExpires(0);
        
        // 读取文件内容并返回
        byte[] fileContent = FileUtils.readFileToByteArray(file);
        
        return new ResponseEntity<>(fileContent, headers, HttpStatus.OK);
    }
    
    /**
     * 使用流式下载(适合大文件)
     */
    @GetMapping("/streamDownload")
    public void streamDownload(
            HttpServletRequest request,
            HttpServletResponse response,
            @RequestParam("filename") String filename) throws Exception {
            
        // 安全性检查
        if (filename.contains("..") || filename.contains("/") || filename.contains("\\")) {
            response.sendError(HttpServletResponse.SC_BAD_REQUEST);
            return;
        }
        
        String uploadDir = request.getSession().getServletContext().getRealPath("/upload/");
        File file = new File(uploadDir, filename);
        
        if (!file.exists() || !file.isFile()) {
            response.sendError(HttpServletResponse.SC_NOT_FOUND);
            return;
        }
        
        // 设置响应头
        response.setContentType("application/octet-stream");
        response.setContentLengthLong(file.length());
        
        String encodedFilename = encodeFilename(request, filename);
        response.setHeader("Content-Disposition", "attachment; filename=\"" + encodedFilename + "\"");
        
        // 使用缓冲流进行文件传输
        try (BufferedInputStream bis = new BufferedInputStream(new FileInputStream(file));
             BufferedOutputStream bos = new BufferedOutputStream(response.getOutputStream())) {
            
            byte[] buffer = new byte[4096];
            int bytesRead;
            
            while ((bytesRead = bis.read(buffer)) != -1) {
                bos.write(buffer, 0, bytesRead);
            }
            
            bos.flush();
        }
    }
    
    /**
     * 根据浏览器类型对文件名进行编码
     */
    private String encodeFilename(HttpServletRequest request, String filename) 
            throws UnsupportedEncodingException {
        
        String userAgent = request.getHeader("User-Agent");
        
        // 针对IE浏览器的处理
        if (userAgent != null && (userAgent.contains("MSIE") || 
            userAgent.contains("Trident") || userAgent.contains("Edge"))) {
            // IE浏览器,使用URLEncoder编码
            return URLEncoder.encode(filename, "UTF-8").replace("+", "%20");
        } 
        // 其他现代浏览器
        else {
            // 使用ISO-8859-1编码
            return new String(filename.getBytes("UTF-8"), "ISO-8859-1");
        }
    }
    
    /**
     * 批量下载(打包成zip)
     */
    @PostMapping("/batchDownload")
    public void batchDownload(
            HttpServletRequest request,
            HttpServletResponse response,
            @RequestParam("files") List<String> filenames) throws Exception {
            
        // 设置响应头
        response.setContentType("application/zip");
        response.setHeader("Content-Disposition", 
            "attachment; filename=\"batch_download.zip\"");
        
        String uploadDir = request.getSession().getServletContext().getRealPath("/upload/");
        
        // 创建ZIP输出流
        try (ZipOutputStream zos = new ZipOutputStream(response.getOutputStream())) {
            
            for (String filename : filenames) {
                // 安全性检查
                if (filename.contains("..") || filename.contains("/") || filename.contains("\\")) {
                    continue;
                }
                
                File file = new File(uploadDir, filename);
                if (file.exists() && file.isFile()) {
                    // 添加文件到ZIP
                    ZipEntry entry = new ZipEntry(filename);
                    zos.putNextEntry(entry);
                    
                    // 写入文件内容
                    try (FileInputStream fis = new FileInputStream(file)) {
                        byte[] buffer = new byte[4096];
                        int bytesRead;
                        while ((bytesRead = fis.read(buffer)) != -1) {
                            zos.write(buffer, 0, bytesRead);
                        }
                    }
                    
                    zos.closeEntry();
                }
            }
        }
    }
    
    /**
     * 获取文件信息(用于下载前预览)
     */
    @GetMapping("/fileInfo")
    @ResponseBody
    public Map<String, Object> getFileInfo(
            HttpServletRequest request,
            @RequestParam("filename") String filename) {
            
        Map<String, Object> info = new HashMap<>();
        
        try {
            String uploadDir = request.getSession().getServletContext().getRealPath("/upload/");
            File file = new File(uploadDir, filename);
            
            if (file.exists() && file.isFile()) {
                info.put("name", filename);
                info.put("size", formatFileSize(file.length()));
                info.put("lastModified", new Date(file.lastModified()));
                info.put("type", getFileType(filename));
                info.put("exists", true);
            } else {
                info.put("exists", false);
            }
        } catch (Exception e) {
            info.put("error", e.getMessage());
        }
        
        return info;
    }
    
    /**
     * 获取文件类型
     */
    private String getFileType(String filename) {
        String extension = filename.substring(filename.lastIndexOf('.') + 1).toLowerCase();
        
        Map<String, String> mimeTypes = new HashMap<>();
        mimeTypes.put("pdf", "application/pdf");
        mimeTypes.put("doc", "application/msword");
        mimeTypes.put("docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document");
        mimeTypes.put("xls", "application/vnd.ms-excel");
        mimeTypes.put("xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
        mimeTypes.put("jpg", "image/jpeg");
        mimeTypes.put("jpeg", "image/jpeg");
        mimeTypes.put("png", "image/png");
        mimeTypes.put("gif", "image/gif");
        mimeTypes.put("txt", "text/plain");
        mimeTypes.put("zip", "application/zip");
        mimeTypes.put("rar", "application/x-rar-compressed");
        
        return mimeTypes.getOrDefault(extension, "application/octet-stream");
    }
    
    /**
     * 格式化文件大小
     */
    private String formatFileSize(long size) {
        if (size <= 0) return "0 B";
        final String[] units = {"B", "KB", "MB", "GB", "TB"};
        int digitGroups = (int) (Math.log10(size) / Math.log10(1024));
        return String.format("%.2f %s", size / Math.pow(1024, digitGroups), units[digitGroups]);
    }
}

断点续传

实现支持断点续传的下载功能:

/**
 * 支持断点续传的下载
 */
@GetMapping("/resumeDownload")
public void resumeDownload(
        HttpServletRequest request,
        HttpServletResponse response,
        @RequestParam("filename") String filename) throws Exception {
        
    String uploadDir = request.getSession().getServletContext().getRealPath("/upload/");
    File file = new File(uploadDir, filename);
    
    if (!file.exists() || !file.isFile()) {
        response.sendError(HttpServletResponse.SC_NOT_FOUND);
        return;
    }
    
    long fileLength = file.length();
    long start = 0;
    long end = fileLength - 1;
    
    // 解析Range请求头
    String range = request.getHeader("Range");
    if (range != null && range.startsWith("bytes=")) {
        String[] ranges = range.substring(6).split("-");
        start = Long.parseLong(ranges[0]);
        if (ranges.length > 1 && !ranges[1].isEmpty()) {
            end = Long.parseLong(ranges[1]);
        }
    }
    
    // 计算内容长度
    long contentLength = end - start + 1;
    
    // 设置响应头
    response.setContentType("application/octet-stream");
    response.setHeader("Accept-Ranges", "bytes");
    response.setHeader("Content-Length", String.valueOf(contentLength));
    response.setHeader("Content-Range", "bytes " + start + "-" + end + "/" + fileLength);
    
    String encodedFilename = encodeFilename(request, filename);
    response.setHeader("Content-Disposition", "attachment; filename=\"" + encodedFilename + "\"");
    
    // 如果是部分内容请求
    if (range != null) {
        response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
    }
    
    // 传输文件内容
    try (RandomAccessFile raf = new RandomAccessFile(file, "r");
         OutputStream out = response.getOutputStream()) {
        
        raf.seek(start);
        byte[] buffer = new byte[4096];
        long remaining = contentLength;
        
        while (remaining > 0) {
            int bytesToRead = (int) Math.min(buffer.length, remaining);
            int bytesRead = raf.read(buffer, 0, bytesToRead);
            
            if (bytesRead == -1) {
                break;
            }
            
            out.write(buffer, 0, bytesRead);
            remaining -= bytesRead;
        }
    }
}

批量下载

批量下载功能已在上面的代码中实现,这里补充前端实现:

// 批量下载的前端实现
function initBatchDownload() {
    // 全选/反选功能
    $('#selectAll').change(function() {
        $('input[name="files"]').prop('checked', this.checked);
    });
    
    // 批量下载按钮
    $('#batchDownloadBtn').click(function() {
        var selectedFiles = [];
        $('input[name="files"]:checked').each(function() {
            selectedFiles.push($(this).val());
        });
        
        if (selectedFiles.length === 0) {
            alert('请选择要下载的文件');
            return;
        }
        
        // 创建表单并提交
        var form = $('<form>', {
            method: 'POST',
            action: contextPath + '/batchDownload'
        });
        
        selectedFiles.forEach(function(file) {
            form.append($('<input>', {
                type: 'hidden',
                name: 'files',
                value: file
            }));
        });
        
        form.appendTo('body').submit().remove();
    });
}

安全性考虑

文件类型限制

/**
 * 文件类型安全检查
 */
public class FileSecurityUtil {
    
    // 允许的文件扩展名白名单
    private static final Set<String> ALLOWED_EXTENSIONS = new HashSet<>(Arrays.asList(
        "jpg", "jpeg", "png", "gif", "bmp", "pdf", 
        "doc", "docx", "xls", "xlsx", "ppt", "pptx",
        "txt", "zip", "rar", "7z"
    ));
    
    // 禁止的文件扩展名黑名单
    private static final Set<String> BLOCKED_EXTENSIONS = new HashSet<>(Arrays.asList(
        "exe", "bat", "cmd", "sh", "php", "jsp", 
        "asp", "aspx", "js", "jar", "war"
    ));
    
    /**
     * 检查文件是否安全
     */
    public static boolean isFileSafe(String filename) {
        if (filename == null || filename.isEmpty()) {
            return false;
        }
        
        // 获取文件扩展名
        String extension = getFileExtension(filename).toLowerCase();
        
        // 检查黑名单
        if (BLOCKED_EXTENSIONS.contains(extension)) {
            return false;
        }
        
        // 检查白名单
        return ALLOWED_EXTENSIONS.contains(extension);
    }
    
    /**
     * 检查文件内容类型
     */
    public static boolean isContentTypeSafe(MultipartFile file) {
        String contentType = file.getContentType();
        
        // 检查MIME类型
        if (contentType == null || contentType.contains("text/html") || 
            contentType.contains("text/javascript")) {
            return false;
        }
        
        // 可以使用Apache Tika进行更深入的内容检测
        return true;
    }
    
    /**
     * 生成安全的文件名
     */
    public static String sanitizeFilename(String filename) {
        // 移除路径分隔符和特殊字符
        return filename.replaceAll("[/\\\\:*?\"<>|]", "_")
                      .replaceAll("\\.\\.", "_");
    }
}

文件大小限制

在Spring配置中设置文件大小限制:

<!-- 文件上传大小限制配置 -->
<bean id="multipartResolver" 
      class="org.springframework.web.multipart.commons.CommonsMultipartResolver">
    <!-- 单个文件最大大小:10MB -->
    <property name="maxUploadSizePerFile" value="10485760" />
    <!-- 整个请求最大大小:50MB -->
    <property name="maxUploadSize" value="52428800" />
    <!-- 内存中最大大小:1MB,超过此大小将写入临时文件 -->
    <property name="maxInMemorySize" value="1048576" />
</bean>

存储路径安全

/**
 * 安全的文件存储管理
 */
@Component
public class FileStorageService {
    
    @Value("${file.upload.path:/data/uploads}")
    private String uploadPath;
    
    /**
     * 获取安全的存储路径
     */
    public File getSecureStoragePath(String filename) throws IOException {
        // 规范化文件名
        String safeFilename = FileSecurityUtil.sanitizeFilename(filename);
        
        // 创建日期目录
        SimpleDateFormat sdf = new SimpleDateFormat("yyyy/MM/dd");
        String dateFolder = sdf.format(new Date());
        
        // 构建完整路径
        File directory = new File(uploadPath, dateFolder);
        if (!directory.exists()) {
            directory.mkdirs();
        }
        
        // 返回文件对象
        File file = new File(directory, safeFilename);
        
        // 确保文件路径在上传目录内(防止目录遍历)
        if (!file.getCanonicalPath().startsWith(directory.getCanonicalPath())) {
            throw new SecurityException("非法的文件路径");
        }
        
        return file;
    }
    
    /**
     * 定期清理过期文件
     */
    @Scheduled(cron = "0 0 2 * * ?")
    public void cleanupExpiredFiles() {
        // 清理30天前的临时文件
        long thirtyDaysAgo = System.currentTimeMillis() - (30L * 24 * 60 * 60 * 1000);
        
        File uploadDir = new File(uploadPath, "temp");
        if (uploadDir.exists()) {
            File[] files = uploadDir.listFiles();
            if (files != null) {
                for (File file : files) {
                    if (file.lastModified() < thirtyDaysAgo) {
                        file.delete();
                    }
                }
            }
        }
    }
}

文件名安全处理

防止文件名注入和路径遍历攻击:

/**
 * 文件名安全处理工具
 */
public class FilenameUtils {
    
    /**
     * 验证文件名是否安全
     */
    public static boolean isFilenameSafe(String filename) {
        // 检查是否包含路径分隔符
        if (filename.contains("/") || filename.contains("\\") || 
            filename.contains("..")) {
            return false;
        }
        
        // 检查是否包含特殊字符
        String pattern = "^[a-zA-Z0-9._-]+$";
        return filename.matches(pattern);
    }
    
    /**
     * 生成唯一且安全的文件名
     */
    public static String generateSafeFilename(String originalFilename) {
        String extension = getFileExtension(originalFilename);
        String timestamp = String.valueOf(System.currentTimeMillis());
        String uuid = UUID.randomUUID().toString().replace("-", "");
        
        return String.format("%s_%s.%s", timestamp, uuid, extension);
    }
}

性能优化

异步上传

使用Spring的异步支持实现文件异步上传:

@Configuration
@EnableAsync
public class AsyncConfig {
    
    @Bean
    public Executor asyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(2);
        executor.setMaxPoolSize(5);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("FileUpload-");
        executor.initialize();
        return executor;
    }
}

@Service
public class AsyncFileService {
    
    @Async
    public CompletableFuture<String> uploadFileAsync(MultipartFile file, String uploadPath) {
        try {
            // 模拟耗时操作
            Thread.sleep(1000);
            
            // 保存文件
            String filename = generateUniqueFilename(file.getOriginalFilename());
            File destFile = new File(uploadPath, filename);
            file.transferTo(destFile);
            
            return CompletableFuture.completedFuture(filename);
        } catch (Exception e) {
            return CompletableFuture.failedFuture(e);
        }
    }
}

分片上传

前端分片上传实现:

function uploadLargeFile(file) {
    const chunkSize = 5 * 1024 * 1024; // 5MB per chunk
    const totalChunks = Math.ceil(file.size / chunkSize);
    const identifier = generateUUID();
    
    let currentChunk = 0;
    
    function uploadNextChunk() {
        if (currentChunk >= totalChunks) {
            console.log('Upload complete');
            return;
        }
        
        const start = currentChunk * chunkSize;
        const end = Math.min(start + chunkSize, file.size);
        const chunk = file.slice(start, end);
        
        const formData = new FormData();
        formData.append('file', chunk);
        formData.append('chunkNumber', currentChunk + 1);
        formData.append('totalChunks', totalChunks);
        formData.append('identifier', identifier);
        formData.append('filename', file.name);
        
        $.ajax({
            url: '/chunk/upload',
            type: 'POST',
            data: formData,
            processData: false,
            contentType: false,
            success: function(response) {
                currentChunk++;
                updateProgress(currentChunk, totalChunks);
                
                if (response.status === 'uploading') {
                    uploadNextChunk();
                } else if (response.status === 'complete') {
                    onUploadComplete(response);
                }
            },
            error: function() {
                onUploadError(currentChunk);
            }
        });
    }
    
    uploadNextChunk();
}

function updateProgress(current, total) {
    const percent = (current / total) * 100;
    $('#uploadProgress').css('width', percent + '%');
    $('#uploadProgress').text(Math.round(percent) + '%');
}

function generateUUID() {
    return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
        const r = Math.random() * 16 | 0;
        const v = c === 'x' ? r : (r & 0x3 | 0x8);
        return v.toString(16);
    });
}

CDN加速

配置CDN加速文件下载:

@Configuration
public class CdnConfig {
    
    @Value("${cdn.enable:false}")
    private boolean cdnEnabled;
    
    @Value("${cdn.domain:}")
    private String cdnDomain;
    
    /**
     * 获取文件访问URL
     */
    public String getFileUrl(String filename) {
        if (cdnEnabled && StringUtils.hasText(cdnDomain)) {
            return cdnDomain + "/files/" + filename;
        } else {
            return "/download?filename=" + filename;
        }
    }
}

最佳实践

1. 使用配置文件管理上传参数

# application.properties
file.upload.path=/data/uploads
file.upload.max-size=10MB
file.upload.allowed-types=jpg,jpeg,png,gif,pdf,doc,docx
file.upload.temp-dir=/data/temp
file.upload.cleanup.enabled=true
file.upload.cleanup.max-age=30

2. 实现文件元数据管理

@Entity
@Table(name = "file_metadata")
public class FileMetadata {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(name = "original_name")
    private String originalName;
    
    @Column(name = "stored_name")
    private String storedName;
    
    @Column(name = "file_size")
    private Long fileSize;
    
    @Column(name = "content_type")
    private String contentType;
    
    @Column(name = "upload_time")
    private Date uploadTime;
    
    @Column(name = "uploader")
    private String uploader;
    
    @Column(name = "download_count")
    private Integer downloadCount = 0;
    
    @Column(name = "file_hash")
    private String fileHash;
    
    // getters and setters...
}

3. 添加病毒扫描

@Component
public class VirusScanService {
    
    /**
     * 扫描文件是否包含病毒
     */
    public boolean scanFile(File file) {
        // 集成ClamAV或其他防病毒引擎
        // 这里是示例代码
        try {
            ProcessBuilder pb = new ProcessBuilder(
                "clamscan", "--no-summary", file.getAbsolutePath()
            );
            Process process = pb.start();
            int exitCode = process.waitFor();
            
            // 0 = no virus, 1 = virus found
            return exitCode == 0;
        } catch (Exception e) {
            // 扫描失败,保守起见返回false
            return false;
        }
    }
}

4. 实现存储策略

/**
 * 文件存储策略接口
 */
public interface FileStorageStrategy {
    String store(MultipartFile file) throws IOException;
    Resource load(String filename) throws IOException;
    void delete(String filename) throws IOException;
}

/**
 * 本地存储实现
 */
@Component
@Profile("local")
public class LocalFileStorage implements FileStorageStrategy {
    // 实现代码...
}

/**
 * 云存储实现(如阿里云OSS)
 */
@Component
@Profile("cloud")
public class CloudFileStorage implements FileStorageStrategy {
    // 实现代码...
}

常见问题

1. 文件上传大小限制问题

问题:上传大文件时报错”Maximum upload size exceeded”

解决方案:

<!-- 在web.xml中配置 -->
<filter>
    <filter-name>MultipartFilter</filter-name>
    <filter-class>org.springframework.web.multipart.support.MultipartFilter</filter-class>
    <init-param>
        <param-name>multipartResolverBeanName</param-name>
        <param-value>multipartResolver</param-value>
    </init-param>
</filter>
<filter-mapping>
    <filter-name>MultipartFilter</filter-name>
    <url-pattern>/*</url-pattern>
</filter-mapping>

2. 中文文件名乱码

问题:下载文件时中文名称显示乱码

解决方案:

  • 确保请求和响应的字符编码设置正确
  • 根据不同浏览器使用不同的编码方式
  • 使用URL编码处理特殊字符

3. 并发上传问题

问题:多用户同时上传可能造成文件名冲突

解决方案:

  • 使用UUID生成唯一文件名
  • 添加时间戳前缀
  • 使用数据库管理文件元信息

4. 内存溢出问题

问题:上传大文件时出现OutOfMemoryError

解决方案:

  • 设置合适的maxInMemorySize
  • 使用流式处理而非一次性读取
  • 实现分片上传

5. 安全漏洞防护

常见漏洞:

  • 文件上传漏洞(上传可执行文件)
  • 路径遍历漏洞
  • 文件包含漏洞

防护措施:

  • 严格的文件类型校验
  • 文件内容检测
  • 安全的文件名处理
  • 独立的文件存储目录
  • 禁止直接访问上传文件

相关文章

相关章节

Spring MVC 相关

实用扩展