Spring Boot 3 文件上传下载与 Excel 导入导出完整指南

Spring Boot 3 文件上传下载与 Excel 导入导出完整指南

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 
         http://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.0</version>
        <relativePath/>
    </parent>
    
    <groupId>com.example</groupId>
    <artifactId>file-upload-download-demo</artifactId>
    <version>1.0.0</version>
    <name>file-upload-download-demo</name>
    
    <properties>
        <java.version>17</java.version>
        <hutool.version>5.8.22</hutool.version>
        <easyexcel.version>3.3.2</easyexcel.version>
    </properties>
    
    <dependencies>
        <!-- Spring Boot Web Starter -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        
        <!-- Spring Boot Validation Starter (用于文件上传验证) -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        
        <!-- Hutool 工具包 (简化文件操作) -->
        <dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-all</artifactId>
            <version>${hutool.version}</version>
        </dependency>
        
        <!-- EasyExcel (处理Excel导入导出) -->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>easyexcel</artifactId>
            <version>${easyexcel.version}</version>
        </dependency>
        
        <!-- Apache POI (EasyExcel底层依赖,通常不需要单独引入) -->
        <!-- 如果需要直接使用POI,可以引入以下依赖 -->
        <!--
        <dependency>
            <groupId>org.apache.poi</groupId>
            <artifactId>poi-ooxml</artifactId>
            <version>5.2.4</version>
        </dependency>
        -->
        
        <!-- Spring Boot Test Starter -->
        <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>
            </plugin>
        </plugins>
    </build>
</project>

2. 应用配置文件

创建 application.yml 配置文件:

# application.yml
server:
  port: 8080

spring:
  servlet:
    multipart:
      # 启用文件上传
      enabled: true
      # 单个文件最大大小 (10MB)
      max-file-size: 10MB
      # 单次请求最大大小 (50MB)
      max-request-size: 50MB
      # 文件大小阈值,超过此值会写入磁盘
      file-size-threshold: 2KB
      # 临时文件位置
      location: ${java.io.tmpdir}
  
  # 文件存储配置
  file:
    upload:
      # 上传文件存储目录
      dir: ${user.home}/uploads
      # 允许的文件类型
      allowed-extensions: jpg,jpeg,png,gif,pdf,doc,docx,xls,xlsx,txt,csv
      # 最大文件大小 (字节)
      max-size: 10485760  # 10MB

# 日志配置
logging:
  level:
    com.example: debug

3. 公共响应封装类

创建统一的响应结果封装类:

package com.example.common;

import lombok.Data;
import lombok.experimental.Accessors;

/**
 * 统一响应结果封装类
 * @param <T> 响应数据类型
 */
@Data
@Accessors(chain = true)
public class Result<T> {
    /**
     * 响应状态码
     */
    private Integer code;
    
    /**
     * 响应消息
     */
    private String message;
    
    /**
     * 响应数据
     */
    private T data;
    
    /**
     * 成功响应构造方法
     */
    public static <T> Result<T> success() {
        return new Result<T>().setCode(200).setMessage("操作成功");
    }
    
    /**
     * 成功响应构造方法(带数据)
     */
    public static <T> Result<T> success(T data) {
        return new Result<T>().setCode(200).setMessage("操作成功").setData(data);
    }
    
    /**
     * 成功响应构造方法(带消息和数据)
     */
    public static <T> Result<T> success(String message, T data) {
        return new Result<T>().setCode(200).setMessage(message).setData(data);
    }
    
    /**
     * 失败响应构造方法
     */
    public static <T> Result<T> error(String message) {
        return new Result<T>().setCode(500).setMessage(message);
    }
    
    /**
     * 失败响应构造方法(带状态码和消息)
     */
    public static <T> Result<T> error(Integer code, String message) {
        return new Result<T>().setCode(code).setMessage(message);
    }
}

4. 文件上传下载控制器

4.1 基础文件上传下载控制器

package com.example.controller;

import cn.hutool.core.io.FileUtil;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil;
import com.example.common.Result;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import java.io.File;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

/**
 * 文件上传下载控制器
 * 提供基础的文件上传、下载、删除功能
 */
@Slf4j
@RestController
@RequestMapping("/api/file")
public class FileController {
    
    /**
     * 上传文件存储目录路径
     * 从配置文件中读取
     */
    @Value("${spring.file.upload.dir}")
    private String uploadDir;
    
    /**
     * 允许的文件扩展名列表
     * 从配置文件中读取并转换为List
     */
    @Value("${spring.file.upload.allowed-extensions}")
    private String allowedExtensions;
    
    /**
     * 最大文件大小(字节)
     * 从配置文件中读取
     */
    @Value("${spring.file.upload.max-size}")
    private long maxSize;
    
    /**
     * 初始化上传目录
     */
    @PostConstruct
    public void init() {
        // 创建上传目录(如果不存在)
        File uploadDirectory = new File(uploadDir);
        if (!uploadDirectory.exists()) {
            boolean created = uploadDirectory.mkdirs();
            if (created) {
                log.info("创建上传目录: {}", uploadDir);
            }
        }
        log.info("文件上传目录: {}", uploadDir);
    }
    
    /**
     * 单文件上传接口
     * 
     * @param file 上传的文件
     * @return 上传结果,包含文件访问路径
     */
    @PostMapping("/upload")
    public Result<String> uploadFile(@RequestParam("file") MultipartFile file) {
        try {
            // 验证文件是否为空
            if (file.isEmpty()) {
                return Result.error("上传文件不能为空");
            }
            
            // 验证文件大小
            if (file.getSize() > maxSize) {
                return Result.error("文件大小不能超过 " + (maxSize / 1024 / 1024) + "MB");
            }
            
            // 验证文件扩展名
            String originalFilename = file.getOriginalFilename();
            if (!isValidFileType(originalFilename)) {
                return Result.error("不支持的文件类型,请上传以下类型文件: " + allowedExtensions);
            }
            
            // 生成唯一的文件名(避免文件名冲突)
            String uniqueFilename = generateUniqueFilename(originalFilename);
            
            // 构建完整的文件路径
            Path filePath = Paths.get(uploadDir, uniqueFilename);
            
            // 保存文件到磁盘
            file.transferTo(filePath.toFile());
            
            // 返回文件访问路径(相对路径)
            String fileUrl = "/api/file/download/" + uniqueFilename;
            
            log.info("文件上传成功: 原始文件名={}, 保存路径={}", originalFilename, filePath);
            return Result.success("文件上传成功", fileUrl);
            
        } catch (IOException e) {
            log.error("文件上传失败", e);
            return Result.error("文件上传失败: " + e.getMessage());
        } catch (Exception e) {
            log.error("文件上传异常", e);
            return Result.error("文件上传异常: " + e.getMessage());
        }
    }
    
    /**
     * 多文件上传接口
     * 
     * @param files 上传的文件数组
     * @return 上传结果列表,包含每个文件的访问路径
     */
    @PostMapping("/upload/batch")
    public Result<List<String>> uploadFiles(@RequestParam("files") MultipartFile[] files) {
        if (files == null || files.length == 0) {
            return Result.error("上传文件不能为空");
        }
        
        // 限制一次上传的文件数量(可选)
        if (files.length > 10) {
            return Result.error("一次最多上传10个文件");
        }
        
        try {
            List<String> fileUrls = Arrays.stream(files)
                .filter(file -> !file.isEmpty()) // 过滤空文件
                .map(this::processSingleFile)    // 处理单个文件
                .filter(result -> result.getCode() == 200) // 过滤成功的结果
                .map(Result::getData)            // 提取文件URL
                .collect(Collectors.toList());
            
            if (fileUrls.isEmpty()) {
                return Result.error("没有有效的文件被上传");
            }
            
            log.info("批量文件上传成功,共上传 {} 个文件", fileUrls.size());
            return Result.success("批量文件上传成功", fileUrls);
            
        } catch (Exception e) {
            log.error("批量文件上传失败", e);
            return Result.error("批量文件上传失败: " + e.getMessage());
        }
    }
    
    /**
     * 处理单个文件上传的私有方法
     * 
     * @param file 单个文件
     * @return 上传结果
     */
    private Result<String> processSingleFile(MultipartFile file) {
        try {
            // 验证文件大小
            if (file.getSize() > maxSize) {
                return Result.error("文件 " + file.getOriginalFilename() + " 大小超过限制");
            }
            
            // 验证文件扩展名
            String originalFilename = file.getOriginalFilename();
            if (!isValidFileType(originalFilename)) {
                return Result.error("文件 " + originalFilename + " 类型不支持");
            }
            
            // 生成唯一文件名
            String uniqueFilename = generateUniqueFilename(originalFilename);
            Path filePath = Paths.get(uploadDir, uniqueFilename);
            
            // 保存文件
            file.transferTo(filePath.toFile());
            
            return Result.success("/api/file/download/" + uniqueFilename);
            
        } catch (IOException e) {
            log.error("处理文件 {} 失败", file.getOriginalFilename(), e);
            return Result.error("处理文件失败: " + e.getMessage());
        }
    }
    
    /**
     * 文件下载接口
     * 
     * @param filename 要下载的文件名
     * @param response HTTP响应对象
     * @throws IOException IO异常
     */
    @GetMapping("/download/{filename:.+}")
    public void downloadFile(@PathVariable String filename, HttpServletResponse response) throws IOException {
        // 验证文件名安全性(防止路径遍历攻击)
        if (!isValidFilename(filename)) {
            response.sendError(HttpStatus.BAD_REQUEST.value(), "无效的文件名");
            return;
        }
        
        // 构建文件完整路径
        Path filePath = Paths.get(uploadDir, filename);
        
        // 检查文件是否存在
        if (!Files.exists(filePath)) {
            response.sendError(HttpStatus.NOT_FOUND.value(), "文件不存在");
            return;
        }
        
        // 设置响应头
        response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE);
        response.setContentLengthLong(Files.size(filePath));
        
        // 设置文件名(处理中文文件名)
        String encodedFilename = URLEncoder.encode(filename, StandardCharsets.UTF_8)
            .replaceAll("\\+", "%20"); // 处理空格
        response.setHeader(HttpHeaders.CONTENT_DISPOSITION, 
            "attachment; filename=\"" + encodedFilename + "\"; filename*=UTF-8''" + encodedFilename);
        
        // 写入文件内容到响应
        Files.copy(filePath, response.getOutputStream());
        response.getOutputStream().flush();
    }
    
    /**
     * 通过文件名直接下载(返回ResponseEntity方式)
     * 这种方式更适合需要返回JSON错误信息的场景
     * 
     * @param filename 要下载的文件名
     * @return 文件流响应
     */
    @GetMapping("/download2/{filename:.+}")
    public ResponseEntity<byte[]> downloadFile2(@PathVariable String filename) {
        try {
            // 验证文件名安全性
            if (!isValidFilename(filename)) {
                return ResponseEntity.badRequest().build();
            }
            
            // 构建文件路径
            Path filePath = Paths.get(uploadDir, filename);
            
            // 检查文件是否存在
            if (!Files.exists(filePath)) {
                return ResponseEntity.notFound().build();
            }
            
            // 读取文件内容
            byte[] fileContent = Files.readAllBytes(filePath);
            
            // 构建响应头
            String encodedFilename = URLEncoder.encode(filename, StandardCharsets.UTF_8)
                .replaceAll("\\+", "%20");
            HttpHeaders headers = new HttpHeaders();
            headers.setContentType(MediaType.APPLICATION_OCTET_STREAM);
            headers.setContentLength(fileContent.length);
            headers.setContentDispositionFormData("attachment", encodedFilename);
            
            return ResponseEntity.ok()
                .headers(headers)
                .body(fileContent);
                
        } catch (IOException e) {
            log.error("文件下载失败: {}", filename, e);
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
        }
    }
    
    /**
     * 删除文件接口
     * 
     * @param filename 要删除的文件名
     * @return 删除结果
     */
    @DeleteMapping("/delete/{filename:.+}")
    public Result<String> deleteFile(@PathVariable String filename) {
        try {
            // 验证文件名安全性
            if (!isValidFilename(filename)) {
                return Result.error("无效的文件名");
            }
            
            // 构建文件路径
            Path filePath = Paths.get(uploadDir, filename);
            
            // 检查文件是否存在
            if (!Files.exists(filePath)) {
                return Result.error("文件不存在");
            }
            
            // 删除文件
            boolean deleted = Files.deleteIfExists(filePath);
            if (deleted) {
                log.info("文件删除成功: {}", filename);
                return Result.success("文件删除成功");
            } else {
                return Result.error("文件删除失败");
            }
            
        } catch (IOException e) {
            log.error("文件删除失败: {}", filename, e);
            return Result.error("文件删除失败: " + e.getMessage());
        }
    }
    
    /**
     * 验证文件扩展名是否合法
     * 
     * @param filename 文件名
     * @return 是否合法
     */
    private boolean isValidFileType(String filename) {
        if (!StringUtils.hasText(filename)) {
            return false;
        }
        
        // 获取文件扩展名(转换为小写)
        String extension = FileUtil.extName(filename).toLowerCase();
        
        // 检查扩展名是否在允许列表中
        List<String> allowedList = Arrays.asList(allowedExtensions.split(","));
        return allowedList.contains(extension);
    }
    
    /**
     * 验证文件名安全性(防止路径遍历攻击)
     * 
     * @param filename 文件名
     * @return 是否安全
     */
    private boolean isValidFilename(String filename) {
        if (!StringUtils.hasText(filename)) {
            return false;
        }
        
        // 检查是否包含路径分隔符(防止路径遍历)
        if (filename.contains("..") || filename.contains("/") || filename.contains("\\")) {
            return false;
        }
        
        // 检查文件名长度
        if (filename.length() > 255) {
            return false;
        }
        
        return true;
    }
    
    /**
     * 生成唯一的文件名
     * 使用UUID + 原始文件扩展名
     * 
     * @param originalFilename 原始文件名
     * @return 唯一文件名
     */
    private String generateUniqueFilename(String originalFilename) {
        // 获取文件扩展名
        String extension = FileUtil.extName(originalFilename);
        
        // 生成UUID(使用Hutool的IdUtil)
        String uuid = IdUtil.fastSimpleUUID();
        
        // 如果原始文件没有扩展名,则不添加扩展名
        if (StrUtil.isBlank(extension)) {
            return uuid;
        } else {
            return uuid + "." + extension;
        }
    }
}

5. Excel 数据模型

创建用于 Excel 导入导出的数据模型:

package com.example.model;

import com.alibaba.excel.annotation.ExcelProperty;
import com.alibaba.excel.annotation.format.DateTimeFormat;
import com.alibaba.excel.annotation.write.style.ColumnWidth;
import com.alibaba.excel.annotation.write.style.ContentRowHeight;
import com.alibaba.excel.annotation.write.style.HeadRowHeight;
import lombok.Data;

import java.math.BigDecimal;
import java.time.LocalDateTime;

/**
 * 用户数据模型
 * 用于Excel导入导出
 */
@Data
@HeadRowHeight(25)  // 表头行高
@ContentRowHeight(20) // 内容行高
@ColumnWidth(20)    // 默认列宽
public class UserExcelDTO {
    
    /**
     * 用户ID
     * Excel列名:用户ID
     * 列索引:0(第一列)
     */
    @ExcelProperty(value = "用户ID", index = 0)
    @ColumnWidth(15)
    private Long id;
    
    /**
     * 用户名
     * Excel列名:用户名
     */
    @ExcelProperty("用户名")
    @ColumnWidth(25)
    private String username;
    
    /**
     * 真实姓名
     * Excel列名:真实姓名
     */
    @ExcelProperty("真实姓名")
    @ColumnWidth(20)
    private String realName;
    
    /**
     * 邮箱
     * Excel列名:邮箱
     */
    @ExcelProperty("邮箱")
    @ColumnWidth(30)
    private String email;
    
    /**
     * 手机号
     * Excel列名:手机号
     */
    @ExcelProperty("手机号")
    @ColumnWidth(18)
    private String phone;
    
    /**
     * 年龄
     * Excel列名:年龄
     */
    @ExcelProperty("年龄")
    @ColumnWidth(10)
    private Integer age;
    
    /**
     * 工资
     * Excel列名:工资
     */
    @ExcelProperty("工资")
    @ColumnWidth(15)
    private BigDecimal salary;
    
    /**
     * 入职日期
     * Excel列名:入职日期
     * 日期格式:yyyy-MM-dd HH:mm:ss
     */
    @ExcelProperty("入职日期")
    @DateTimeFormat("yyyy-MM-dd HH:mm:ss")
    @ColumnWidth(22)
    private LocalDateTime hireDate;
    
    /**
     * 状态
     * Excel列名:状态
     * 0-禁用, 1-启用
     */
    @ExcelProperty("状态")
    @ColumnWidth(12)
    private Integer status;
}

6. Excel 导入导出控制器

package com.example.controller;

import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.util.IdUtil;
import com.alibaba.excel.EasyExcel;
import com.alibaba.excel.ExcelWriter;
import com.alibaba.excel.write.metadata.WriteSheet;
import com.example.common.Result;
import com.example.model.UserExcelDTO;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;

/**
 * Excel导入导出控制器
 * 使用EasyExcel实现Excel文件的导入导出功能
 */
@Slf4j
@RestController
@RequestMapping("/api/excel")
public class ExcelController {
    
    /**
     * 模拟数据库数据(实际项目中应从数据库查询)
     */
    private List<UserExcelDTO> getMockUserData() {
        List<UserExcelDTO> users = new ArrayList<>();
        
        for (int i = 1; i <= 100; i++) {
            UserExcelDTO user = new UserExcelDTO();
            user.setId((long) i);
            user.setUsername("user" + i);
            user.setRealName("用户" + i);
            user.setEmail("user" + i + "@example.com");
            user.setPhone("1380013800" + (i % 10));
            user.setAge(20 + (i % 50));
            user.setSalary(java.math.BigDecimal.valueOf(5000 + (i * 100)));
            user.setHireDate(LocalDateTime.now().minusDays(i));
            user.setStatus(i % 2 == 0 ? 1 : 0);
            users.add(user);
        }
        
        return users;
    }
    
    /**
     * 导出Excel文件(全量导出)
     * 
     * @param response HTTP响应对象
     * @throws IOException IO异常
     */
    @GetMapping("/export")
    public void exportExcel(HttpServletResponse response) throws IOException {
        try {
            // 获取要导出的数据
            List<UserExcelDTO> userData = getMockUserData();
            
            // 设置响应头
            String fileName = "用户数据_" + DateUtil.format(DateUtil.date(), "yyyyMMdd_HHmmss") + ".xlsx";
            setExcelResponseHeader(response, fileName);
            
            // 使用EasyExcel写入数据
            EasyExcel.write(response.getOutputStream(), UserExcelDTO.class)
                .sheet("用户数据")
                .doWrite(userData);
                
            log.info("Excel导出成功,共导出 {} 条数据", userData.size());
            
        } catch (Exception e) {
            log.error("Excel导出失败", e);
            // 导出失败时返回错误信息
            response.reset();
            response.setCharacterEncoding("utf-8");
            response.setContentType("application/json");
            response.getWriter().write("{\"code\":500,\"message\":\"导出失败: " + e.getMessage() + "\"}");
        }
    }
    
    /**
     * 分页导出Excel文件
     * 适用于大数据量导出,避免内存溢出
     * 
     * @param response HTTP响应对象
     * @throws IOException IO异常
     */
    @GetMapping("/export/paginated")
    public void exportExcelPaginated(HttpServletResponse response) throws IOException {
        try {
            // 设置响应头
            String fileName = "用户数据_分页_" + DateUtil.format(DateUtil.date(), "yyyyMMdd_HHmmss") + ".xlsx";
            setExcelResponseHeader(response, fileName);
            
            // 创建ExcelWriter
            ExcelWriter excelWriter = EasyExcel.write(response.getOutputStream(), UserExcelDTO.class)
                .build();
            
            // 创建工作表
            WriteSheet writeSheet = EasyExcel.writerSheet("用户数据").build();
            
            // 分页查询并写入数据(模拟分页)
            int pageSize = 1000; // 每页1000条
            int currentPage = 0;
            List<UserExcelDTO> pageData;
            
            do {
                // 模拟分页查询(实际项目中应调用数据库分页查询)
                pageData = getMockPageData(currentPage, pageSize);
                
                if (!pageData.isEmpty()) {
                    // 写入当前页数据
                    excelWriter.write(pageData, writeSheet);
                    currentPage++;
                }
                
            } while (!pageData.isEmpty());
            
            // 关闭ExcelWriter
            excelWriter.finish();
            
            log.info("分页Excel导出成功,共导出 {} 页", currentPage);
            
        } catch (Exception e) {
            log.error("分页Excel导出失败", e);
            response.reset();
            response.setCharacterEncoding("utf-8");
            response.setContentType("application/json");
            response.getWriter().write("{\"code\":500,\"message\":\"导出失败: " + e.getMessage() + "\"}");
        }
    }
    
    /**
     * 模拟分页数据查询
     * 
     * @param page 页码(从0开始)
     * @param size 每页大小
     * @return 分页数据
     */
    private List<UserExcelDTO> getMockPageData(int page, int size) {
        // 模拟总共10000条数据
        int totalRecords = 10000;
        int start = page * size;
        int end = Math.min(start + size, totalRecords);
        
        if (start >= totalRecords) {
            return CollUtil.newArrayList();
        }
        
        List<UserExcelDTO> pageData = new ArrayList<>();
        for (int i = start; i < end; i++) {
            UserExcelDTO user = new UserExcelDTO();
            user.setId((long) i + 1);
            user.setUsername("user" + (i + 1));
            user.setRealName("用户" + (i + 1));
            user.setEmail("user" + (i + 1) + "@example.com");
            user.setPhone("1380013800" + ((i + 1) % 10));
            user.setAge(20 + ((i + 1) % 50));
            user.setSalary(java.math.BigDecimal.valueOf(5000 + ((i + 1) * 100)));
            user.setHireDate(LocalDateTime.now().minusDays(i + 1));
            user.setStatus((i + 1) % 2 == 0 ? 1 : 0);
            pageData.add(user);
        }
        
        return pageData;
    }
    
    /**
     * Excel文件导入
     * 
     * @param file 上传的Excel文件
     * @return 导入结果
     */
    @PostMapping("/import")
    public Result<String> importExcel(@RequestParam("file") MultipartFile file) {
        try {
            // 验证文件
            if (file.isEmpty()) {
                return Result.error("请选择要导入的Excel文件");
            }
            
            String originalFilename = file.getOriginalFilename();
            if (!originalFilename.endsWith(".xlsx") && !originalFilename.endsWith(".xls")) {
                return Result.error("请上传Excel文件(.xlsx或.xls格式)");
            }
            
            // 读取Excel文件数据
            List<UserExcelDTO> importedData = new ArrayList<>();
            
            // 使用EasyExcel读取数据
            EasyExcel.read(file.getInputStream(), UserExcelDTO.class, 
                new com.alibaba.excel.read.listener.PageReadListener<UserExcelDTO>(dataList -> {
                    // 处理每一批数据(默认100条一批)
                    importedData.addAll(dataList);
                    
                    // 这里可以进行数据验证、转换、保存到数据库等操作
                    // 为了示例简单,这里只做简单处理
                    log.info("读取到 {} 条数据", dataList.size());
                }))
                .sheet()
                .doRead();
            
            // 验证导入的数据
            if (importedData.isEmpty()) {
                return Result.error("Excel文件中没有有效数据");
            }
            
            // 实际项目中,这里应该将数据保存到数据库
            // validateAndSaveData(importedData);
            
            log.info("Excel导入成功,共导入 {} 条数据", importedData.size());
            return Result.success("Excel导入成功,共导入 " + importedData.size() + " 条数据");
            
        } catch (Exception e) {
            log.error("Excel导入失败", e);
            return Result.error("Excel导入失败: " + e.getMessage());
        }
    }
    
    /**
     * 设置Excel文件下载的响应头
     * 
     * @param response HTTP响应对象
     * @param fileName 文件名
     * @throws IOException IO异常
     */
    private void setExcelResponseHeader(HttpServletResponse response, String fileName) throws IOException {
        // 设置内容类型
        response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
        response.setCharacterEncoding("utf-8");
        
        // 编码文件名(处理中文)
        String encodedFileName = URLEncoder.encode(fileName, StandardCharsets.UTF_8)
            .replaceAll("\\+", "%20");
        
        // 设置Content-Disposition头
        response.setHeader("Content-Disposition", 
            "attachment; filename=\"" + encodedFileName + "\"; filename*=UTF-8''" + encodedFileName);
    }
}

7. Excel 导入数据验证监听器

创建专门的数据验证监听器,用于处理导入过程中的数据验证:

package com.example.listener;

import cn.hutool.core.util.StrUtil;
import com.alibaba.excel.context.AnalysisContext;
import com.alibaba.excel.read.listener.ReadListener;
import com.example.model.UserExcelDTO;
import lombok.extern.slf4j.Slf4j;

import java.util.ArrayList;
import java.util.List;

/**
 * Excel导入数据验证监听器
 * 在读取Excel数据时进行数据验证
 */
@Slf4j
public class UserExcelDataListener implements ReadListener<UserExcelDTO> {
    
    /**
     * 每批处理的数据量
     */
    private static final int BATCH_COUNT = 100;
    
    /**
     * 临时存储数据的列表
     */
    private final List<UserExcelDTO> cachedData = new ArrayList<>();
    
    /**
     * 存储验证错误信息
     */
    private final List<String> errorMessages = new ArrayList<>();
    
    /**
     * 行号计数器(从1开始,包含表头)
     */
    private int currentRow = 1;
    
    @Override
    public void invoke(UserExcelDTO data, AnalysisContext context) {
        currentRow++;
        
        // 验证数据
        String validationError = validateData(data, currentRow);
        if (validationError != null) {
            errorMessages.add("第" + currentRow + "行: " + validationError);
            return; // 跳过无效数据
        }
        
        // 添加到缓存
        cachedData.add(data);
        
        // 达到批次数量时处理数据
        if (cachedData.size() >= BATCH_COUNT) {
            saveData();
            cachedData.clear();
        }
    }
    
    @Override
    public void doAfterAllAnalysed(AnalysisContext context) {
        // 处理剩余数据
        saveData();
        cachedData.clear();
        
        // 输出验证结果
        if (!errorMessages.isEmpty()) {
            log.warn("Excel导入完成,发现 {} 个验证错误", errorMessages.size());
            errorMessages.forEach(log::warn);
        } else {
            log.info("Excel导入完成,所有数据验证通过");
        }
    }
    
    /**
     * 保存数据到数据库(实际项目中实现)
     */
    private void saveData() {
        if (!cachedData.isEmpty()) {
            log.info("保存 {} 条数据到数据库", cachedData.size());
            // 实际项目中这里应该调用Service层保存数据
            // userService.saveBatch(cachedData);
        }
    }
    
    /**
     * 验证单行数据
     * 
     * @param data 要验证的数据
     * @param rowNumber 行号
     * @return 验证错误信息,null表示验证通过
     */
    private String validateData(UserExcelDTO data, int rowNumber) {
        // 验证用户名
        if (StrUtil.isBlank(data.getUsername())) {
            return "用户名不能为空";
        }
        if (data.getUsername().length() > 50) {
            return "用户名长度不能超过50个字符";
        }
        
        // 验证真实姓名
        if (StrUtil.isBlank(data.getRealName())) {
            return "真实姓名不能为空";
        }
        
        // 验证邮箱格式
        if (StrUtil.isNotBlank(data.getEmail())) {
            if (!data.getEmail().matches("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$")) {
                return "邮箱格式不正确";
            }
        }
        
        // 验证手机号格式
        if (StrUtil.isNotBlank(data.getPhone())) {
            if (!data.getPhone().matches("^1[3-9]\\d{9}$")) {
                return "手机号格式不正确";
            }
        }
        
        // 验证年龄范围
        if (data.getAge() != null) {
            if (data.getAge() < 0 || data.getAge() > 150) {
                return "年龄必须在0-150之间";
            }
        }
        
        // 验证工资
        if (data.getSalary() != null) {
            if (data.getSalary().compareTo(java.math.BigDecimal.ZERO) < 0) {
                return "工资不能为负数";
            }
        }
        
        // 验证状态
        if (data.getStatus() != null) {
            if (data.getStatus() != 0 && data.getStatus() != 1) {
                return "状态只能为0(禁用)或1(启用)";
            }
        }
        
        return null; // 验证通过
    }
    
    /**
     * 获取验证错误信息
     * 
     * @return 错误信息列表
     */
    public List<String> getErrorMessages() {
        return new ArrayList<>(errorMessages);
    }
    
    /**
     * 是否存在验证错误
     * 
     * @return true表示存在错误
     */
    public boolean hasErrors() {
        return !errorMessages.isEmpty();
    }
}

8. 使用验证监听器的导入控制器

package com.example.controller;

import com.alibaba.excel.EasyExcel;
import com.example.common.Result;
import com.example.listener.UserExcelDataListener;
import com.example.model.UserExcelDTO;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

/**
 * 使用验证监听器的Excel导入控制器
 */
@Slf4j
@RestController
@RequestMapping("/api/excel/validated")
public class ValidatedExcelController {
    
    /**
     * 使用验证监听器导入Excel
     * 
     * @param file 上传的Excel文件
     * @return 导入结果
     */
    @PostMapping("/import")
    public Result<String> importExcelWithValidation(@RequestParam("file") MultipartFile file) {
        try {
            // 验证文件
            if (file.isEmpty()) {
                return Result.error("请选择要导入的Excel文件");
            }
            
            String originalFilename = file.getOriginalFilename();
            if (!originalFilename.endsWith(".xlsx") && !originalFilename.endsWith(".xls")) {
                return Result.error("请上传Excel文件(.xlsx或.xls格式)");
            }
            
            // 创建验证监听器
            UserExcelDataListener listener = new UserExcelDataListener();
            
            // 读取Excel文件
            EasyExcel.read(file.getInputStream(), UserExcelDTO.class, listener)
                .sheet()
                .doRead();
            
            // 检查是否有验证错误
            if (listener.hasErrors()) {
                StringBuilder errorMessage = new StringBuilder("Excel导入完成,但存在以下问题:\n");
                listener.getErrorMessages().forEach(msg -> errorMessage.append(msg).append("\n"));
                return Result.error(errorMessage.toString());
            }
            
            return Result.success("Excel导入成功");
            
        } catch (Exception e) {
            log.error("Excel导入失败", e);
            return Result.error("Excel导入失败: " + e.getMessage());
        }
    }
}

9. 前端HTML测试页面

创建一个简单的HTML页面用于测试:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>文件上传下载测试</title>
    <style>
        body { font-family: Arial, sans-serif; margin: 20px; }
        .container { max-width: 800px; margin: 0 auto; }
        .section { margin-bottom: 30px; padding: 20px; border: 1px solid #ddd; border-radius: 5px; }
        .button { padding: 10px 20px; margin: 5px; background: #007bff; color: white; border: none; border-radius: 3px; cursor: pointer; }
        .button:hover { background: #0056b3; }
        .result { margin-top: 10px; padding: 10px; background: #f8f9fa; border-radius: 3px; }
        input[type="file"] { margin: 10px 0; }
    </style>
</head>
<body>
    <div class="container">
        <h1>Spring Boot 3 文件上传下载与Excel导入导出测试</h1>
        
        <!-- 文件上传测试 -->
        <div class="section">
            <h2>1. 文件上传测试</h2>
            <input type="file" id="fileInput" multiple>
            <button class="button" onclick="uploadFile()">上传文件</button>
            <button class="button" onclick="uploadFiles()">批量上传</button>
            <div id="uploadResult" class="result"></div>
        </div>
        
        <!-- 文件下载测试 -->
        <div class="section">
            <h2>2. 文件下载测试</h2>
            <input type="text" id="filenameInput" placeholder="输入文件名(如:abc123.txt)">
            <button class="button" onclick="downloadFile()">下载文件</button>
            <div id="downloadResult" class="result"></div>
        </div>
        
        <!-- Excel导出测试 -->
        <div class="section">
            <h2>3. Excel导出测试</h2>
            <button class="button" onclick="exportExcel()">导出Excel</button>
            <button class="button" onclick="exportExcelPaginated()">分页导出Excel</button>
        </div>
        
        <!-- Excel导入测试 -->
        <div class="section">
            <h2>4. Excel导入测试</h2>
            <input type="file" id="excelFileInput" accept=".xlsx,.xls">
            <button class="button" onclick="importExcel()">导入Excel</button>
            <div id="importResult" class="result"></div>
        </div>
    </div>

    <script>
        // 基础URL
        const BASE_URL = 'http://localhost:8080/api';
        
        // 上传单个文件
        function uploadFile() {
            const fileInput = document.getElementById('fileInput');
            const file = fileInput.files[0];
            if (!file) {
                alert('请选择文件');
                return;
            }
            
            const formData = new FormData();
            formData.append('file', file);
            
            fetch(`${BASE_URL}/file/upload`, {
                method: 'POST',
                body: formData
            })
            .then(response => response.json())
            .then(data => {
                document.getElementById('uploadResult').innerHTML = 
                    `<strong>结果:</strong> ${JSON.stringify(data, null, 2)}`;
            })
            .catch(error => {
                console.error('上传失败:', error);
                document.getElementById('uploadResult').innerHTML = 
                    `<strong>错误:</strong> ${error.message}`;
            });
        }
        
        // 批量上传文件
        function uploadFiles() {
            const fileInput = document.getElementById('fileInput');
            const files = fileInput.files;
            if (files.length === 0) {
                alert('请选择文件');
                return;
            }
            
            const formData = new FormData();
            for (let i = 0; i < files.length; i++) {
                formData.append('files', files[i]);
            }
            
            fetch(`${BASE_URL}/file/upload/batch`, {
                method: 'POST',
                body: formData
            })
            .then(response => response.json())
            .then(data => {
                document.getElementById('uploadResult').innerHTML = 
                    `<strong>结果:</strong> ${JSON.stringify(data, null, 2)}`;
            })
            .catch(error => {
                console.error('批量上传失败:', error);
                document.getElementById('uploadResult').innerHTML = 
                    `<strong>错误:</strong> ${error.message}`;
            });
        }
        
        // 下载文件
        function downloadFile() {
            const filename = document.getElementById('filenameInput').value;
            if (!filename) {
                alert('请输入文件名');
                return;
            }
            
            window.location.href = `${BASE_URL}/file/download/${filename}`;
        }
        
        // 导出Excel
        function exportExcel() {
            window.location.href = `${BASE_URL}/excel/export`;
        }
        
        // 分页导出Excel
        function exportExcelPaginated() {
            window.location.href = `${BASE_URL}/excel/export/paginated`;
        }
        
        // 导入Excel
        function importExcel() {
            const fileInput = document.getElementById('excelFileInput');
            const file = fileInput.files[0];
            if (!file) {
                alert('请选择Excel文件');
                return;
            }
            
            const formData = new FormData();
            formData.append('file', file);
            
            fetch(`${BASE_URL}/excel/import`, {
                method: 'POST',
                body: formData
            })
            .then(response => response.json())
            .then(data => {
                document.getElementById('importResult').innerHTML = 
                    `<strong>结果:</strong> ${JSON.stringify(data, null, 2)}`;
            })
            .catch(error => {
                console.error('导入失败:', error);
                document.getElementById('importResult').innerHTML = 
                    `<strong>错误:</strong> ${error.message}`;
            });
        }
    </script>
</body>
</html>

10. 启动类

package com.example;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

/**
 * Spring Boot 应用启动类
 */
@SpringBootApplication
public class FileUploadDownloadApplication {
    public static void main(String[] args) {
        SpringApplication.run(FileUploadDownloadApplication.class, args);
    }
}

11. 最佳实践总结

11.1 文件上传最佳实践

  1. 安全性验证

    • 验证文件扩展名
    • 验证文件大小
    • 防止路径遍历攻击
    • 验证文件内容类型(可选)
  2. 文件存储

    • 使用唯一文件名避免冲突
    • 考虑使用云存储(如阿里云OSS、AWS S3)
    • 定期清理临时文件
  3. 性能优化

    • 限制单次上传文件数量
    • 使用异步处理大文件
    • 考虑分片上传

11.2 文件下载最佳实践

  1. 响应头设置

    • 正确设置Content-Type
    • 处理中文文件名编码
    • 设置合适的缓存策略
  2. 大文件处理

    • 使用流式传输避免内存溢出
    • 支持断点续传(可选)

11.3 Excel导入导出最佳实践

  1. 导出优化

    • 大数据量使用分页导出
    • 使用EasyExcel避免内存溢出
    • 添加进度提示(前端)
  2. 导入验证

    • 使用监听器进行逐行验证
    • 提供详细的错误信息
    • 支持部分成功导入
  3. 用户体验

    • 提供模板下载
    • 显示导入进度
    • 支持预览功能

11.4 异常处理

  1. 统一异常处理

    @ControllerAdvice
    public class GlobalExceptionHandler {
        
        @ExceptionHandler(MultipartException.class)
        public ResponseEntity<Result<String>> handleMultipartException(MultipartException e) {
            return ResponseEntity.badRequest().body(Result.error("文件上传异常: " + e.getMessage()));
        }
        
        @ExceptionHandler(IOException.class)
        public ResponseEntity<Result<String>> handleIOException(IOException e) {
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body(Result.error("IO异常: " + e.getMessage()));
        }
    }
    
  2. 日志记录

    • 记录关键操作日志
    • 记录错误详细信息
    • 考虑记录操作用户信息

这个完整的实现涵盖了Spring Boot 3中文件上传下载和Excel导入导出的所有标准用法,包含了安全性、性能、用户体验等多个方面的最佳实践。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

龙茶清欢

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值