Spring Boot Excel 导入实战:把“导入失败”改成逐行错误报告

一个 Excel 有几百行,接口只返回"导入失败",使用者该从哪里改?

更麻烦的是:他改完再传,接口又提示邮箱重复。原来上一次已经写进了前面几行。

这篇做一个可以直接运行的联系人导入接口。表格只有"姓名、邮箱"两列,先把两个行为定下来:字段校验失败时,返回具体行号和原因,整批不写库;写库过程中发生约束冲突时,整批回滚。

例如,上传这张表:

Excel 行号 姓名 邮箱
1 姓名 邮箱
2 张三 zhang@example.com
3 空白 空白
4 空白 bad
5 李四 ZHANG@example.com

这里的"空白"表示单元格没有内容。接口返回 HTTP 422,正文是:

json 复制代码
{
  "importedRows": 0,
  "errors": [
    {"row": 4, "column": "姓名", "message": "姓名必填,且不超过 50 个字符"},
    {"row": 4, "column": "邮箱", "message": "邮箱格式不正确"},
    {"row": 5, "column": "邮箱", "message": "邮箱在本文件中重复"}
  ]
}

第 2 行虽然正确,也没有入库。第 3 行被跳过后,后面的错误仍然指向 Excel 里的第 4、5 行,使用者不用再自己换算。

先检查完整文件,再开始写库

边读一行、边插一行,代码短,却很容易把"本次失败"变成"前面成功、后面失败"。如果产品要求整批导入,这个语义就不对了。

下面的处理顺序把解析和写入分开:先收集字段错误;没有错误,才进入一个数据库事务。

这张图中的两种失败发生在不同阶段。校验失败时根本没有执行插入;数据库冲突发生在事务中,需要撤销本批已经执行的插入。返回的 importedRows 都是 0,但实现不能混在一起。

接口约定邮箱大小写不敏感,会先去掉两端空格、转为小写,再做文件内判重。数据库也为邮箱设置唯一约束,用于处理已有记录以及并发写入带来的冲突。

这里的格式校验只是常规邮箱形状检查,不验证邮箱是否真实存在,也没有实现完整的邮件地址规范。

一个可启动的 Spring Boot 接口

示例使用 JDK 21、Spring Boot 3.5.5 和 Apache POI 5.4.1。H2 用作本地内存数据库,方便直接运行;应用退出后,本例导入的数据不会保留。

文件结构如下:

text 复制代码
excel-import-lab/
  pom.xml
  src/main/java/demo/ImportApp.java
  src/main/resources/application.properties
  src/main/resources/schema.sql

pom.xml

xml 复制代码
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <parent>
    <groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>3.5.5</version><relativePath/>
  </parent>
  <groupId>demo</groupId><artifactId>excel-import-lab</artifactId><version>1.0</version>
  <properties><java.version>21</java.version><project.build.sourceEncoding>UTF-8</project.build.sourceEncoding></properties>
  <dependencies>
    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>
    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-jdbc</artifactId></dependency>
    <dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><scope>runtime</scope></dependency>
    <dependency><groupId>org.apache.poi</groupId><artifactId>poi-ooxml</artifactId><version>5.4.1</version></dependency>
    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
  </dependencies>
  <build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
</project>

src/main/resources/application.properties

properties 复制代码
spring.datasource.url=jdbc:h2:mem:imports;DB_CLOSE_DELAY=-1
spring.datasource.username=sa
spring.datasource.password=
spring.sql.init.mode=always
spring.servlet.multipart.max-file-size=2MB
spring.servlet.multipart.max-request-size=3MB

src/main/resources/schema.sql

sql 复制代码
CREATE TABLE IF NOT EXISTS contacts (
    id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    name VARCHAR(50) NOT NULL,
    email VARCHAR(254) NOT NULL UNIQUE
);

src/main/java/demo/ImportApp.java

java 复制代码
package demo;

import java.io.IOException;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import javax.sql.DataSource;
import org.apache.poi.ss.usermodel.*;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.ResponseEntity;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.multipart.MaxUploadSizeExceededException;

@SpringBootApplication
@RestController
public class ImportApp {
    private final JdbcTemplate jdbc;
    private final TransactionTemplate transaction;

    public ImportApp(JdbcTemplate jdbc, DataSource dataSource) {
        this.jdbc = jdbc;
        this.transaction = new TransactionTemplate(new DataSourceTransactionManager(dataSource));
    }

    public static void main(String[] args) {
        SpringApplication.run(ImportApp.class, args);
    }

    record RowError(int row, String column, String message) {}
    record Contact(String name, String email) {}
    record ImportResult(int importedRows, List<RowError> errors) {}

    @PostMapping("/contacts/import")
    public ResponseEntity<ImportResult> upload(@RequestParam("file") MultipartFile file)
            throws IOException {
        if (file.isEmpty()) return failure(400, 0, "文件", "文件为空");
        if (file.getSize() > 2 * 1024 * 1024) return failure(413, 0, "文件", "文件超过 2 MiB");
        var errors = new ArrayList<RowError>();
        var contacts = new ArrayList<Contact>();
        Set<String> seen = new HashSet<>();
        var formatter = new DataFormatter(Locale.ROOT);

        // 解析结束后才进入数据库事务,读文件期间不占用写事务。
        try (var input = file.getInputStream(); var workbook = WorkbookFactory.create(input)) {
            if (workbook.getNumberOfSheets() != 1) {
                return failure(400, 0, "工作表", "请只保留一张工作表");
            }
            var sheet = workbook.getSheetAt(0);
            var header = sheet.getRow(0);
            if (!"姓名".equals(value(header, 0, formatter))
                    || !"邮箱".equals(value(header, 1, formatter))) {
                return failure(400, 1, "表头", "前两列必须依次为姓名、邮箱");
            }
            if (sheet.getLastRowNum() > 1000) {
                return failure(400, 0, "文件", "只接受第 2 至 1001 行内的数据");
            }
            // POI 行下标从 0 开始,反馈给用户的 Excel 行号加 1。
            for (int i = 1; i <= sheet.getLastRowNum(); i++) {
                var row = sheet.getRow(i);
                if (row == null) continue;
                if (formula(row, 0) || formula(row, 1)) {
                    if (formula(row, 0)) errors.add(new RowError(i + 1, "姓名", "请粘贴值,不要使用公式"));
                    if (formula(row, 1)) errors.add(new RowError(i + 1, "邮箱", "请粘贴值,不要使用公式"));
                    continue;
                }
                String name = value(row, 0, formatter);
                // 本接口约定邮箱大小写不敏感;入库前统一为小写。
                String email = value(row, 1, formatter).toLowerCase(Locale.ROOT);
                if (name.isBlank() && email.isBlank()) continue;
                int before = errors.size();
                if (name.isBlank() || name.length() > 50) {
                    errors.add(new RowError(i + 1, "姓名", "姓名必填,且不超过 50 个字符"));
                }
                if (email.length() > 254 || !email.matches("[^\\s@]+@[^\\s@]+\\.[^\\s@]+")) {
                    errors.add(new RowError(i + 1, "邮箱", "邮箱格式不正确"));
                } else if (!seen.add(email)) {
                    errors.add(new RowError(i + 1, "邮箱", "邮箱在本文件中重复"));
                }
                if (errors.size() == before) contacts.add(new Contact(name, email));
            }
        } catch (IOException | RuntimeException ex) {
            // 文件解析错误不向客户端暴露库异常或内部路径。
            return failure(400, 0, "文件", "无法读取 Excel,请检查格式和文件是否损坏");
        }
        if (!errors.isEmpty()) return ResponseEntity.unprocessableEntity().body(new ImportResult(0, errors));
        if (contacts.isEmpty()) return failure(400, 0, "文件", "没有可导入的数据");

        try {
            transaction.executeWithoutResult(status -> {
                // 任意一行违反数据库约束,异常离开事务后整批回滚。
                for (Contact contact : contacts) {
                    jdbc.update("INSERT INTO contacts(name, email) VALUES (?, ?)",
                        contact.name(), contact.email());
                }
            });
        } catch (DataIntegrityViolationException ex) {
            return failure(409, 0, "数据库", "数据与现有记录或数据库约束冲突,整批未导入");
        }
        return ResponseEntity.ok(new ImportResult(contacts.size(), List.of()));
    }

    static String value(Row row, int column, DataFormatter formatter) {
        return row == null ? "" : formatter.formatCellValue(row.getCell(column)).trim();
    }

    static boolean formula(Row row, int column) {
        return row.getCell(column) != null && row.getCell(column).getCellType() == CellType.FORMULA;
    }

    static ResponseEntity<ImportResult> failure(int status, int row, String column, String message) {
        return ResponseEntity.status(status)
            .body(new ImportResult(0, List.of(new RowError(row, column, message))));
    }

    @RestControllerAdvice
    static class UploadErrors {
        @ExceptionHandler(MaxUploadSizeExceededException.class)
        ResponseEntity<ImportResult> tooLarge() {
            return failure(413, 0, "文件", "上传超过大小限制");
        }
    }
}

代码通过 POI 的 DataFormatter 读取单元格显示值,缺失单元格按空字符串处理;前两列出现公式时直接返回错误,让用户粘贴值后再导入。这样不会把公式文本当作邮箱,也不会依赖文件里可能过期的计算缓存。关于格式化和公式单元格的区别,可以对照 Apache POI 的 DataFormatter 文档

在项目目录执行:

bash 复制代码
mvn spring-boot:run

按前面的表格创建 contacts.xlsx,表头必须是"姓名、邮箱"。在另一个终端发送请求:

bash 复制代码
curl -i -F "file=@contacts.xlsx" http://localhost:8080/contacts/import

Windows PowerShell 下把 curl 写成 curl.exe 即可。

再准备一份只有两条有效记录的文件:姓名完整,邮箱各不相同且库里还没有。上传后,响应会是 HTTP 200:

json 复制代码
{"importedRows":2,"errors":[]}

接口读取第一张且唯一一张工作表的前两列,忽略两列都空的行;多余的列不参与导入。

事务是否有效,要在中间故意放一个冲突

只拿一份正常文件验证,查不出部分写入的问题。

可以先导入一条邮箱为 used@example.com 的记录。然后准备第二份文件,第一条使用新邮箱 new@example.com,第二条仍使用 used@example.com

这两条都能通过文件内校验。执行插入时,第一条先写入,第二条触发数据库唯一约束。异常离开 TransactionTemplate 后,事务回滚;外层再把它转换成 HTTP 409。

运行后的检查应当同时满足:原来的 used@example.com 还在,new@example.com 没有留下。只检查"接口返回 409"不够,因为错误响应本身不能证明数据库回滚了。

本地集成测试使用实际 Spring Boot 上下文、MockMvc 上传生成的 xlsx 文件,再查询 H2;没有把数据库替换成 mock。检查结果包括:

输入 接口结果 数据库结果
两条有效记录 200,导入 2 条 新增 2 条,邮箱转为小写
含空白行、缺姓名、坏邮箱、文件内重复 422,错误指向第 4、5 行 新增 0 条
第二条与已有邮箱冲突 409,导入 0 条 原记录保留,第一条新记录回滚
空文件、损坏文件、只有表头 400 新增 0 条
邮箱单元格使用公式 422,指出邮箱列 新增 0 条

数据库冲突这里给的是整批级错误,row=0 表示不对应某个 Excel 行号。字段校验错误才携带真实行号。前端展示时需要区分这两类,别把它显示成"第 0 行出错"。

如果业务还要求列出"哪些邮箱已经存在",可以在写入前批量查询一次,补充用户可读的行级错误。不过查询和插入之间仍可能有别的请求写入,数据库约束与事务回滚仍然要保留。

把这段代码搬进项目时

这个实现有意限制为小文件:上传不超过 2 MiB,数据只接受 Excel 的第 2 至 1001 行。POI 会将工作簿加载到内存;行数检查发生在打开工作簿后,不能把它理解成超大文件的内存保护方案。只有格式没有数据的尾部行,也可能影响工作表的最后行号。

真正的大批量导入需要另做流式读取、任务状态和错误文件,这里没有用一个循环假装解决这些问题。

还有一个产品取舍要保留:本文选择整批成功或整批失败。若要改成"正确行先导入,错误行稍后补",成功数、失败数、重传去重都要重新设计,不能只删掉事务就算完成。

对这个小接口,交付标准很具体:人能按返回的行号修表,重传之前也知道上一批究竟有没有写进去。

相关推荐
殷紫川1 小时前
Java 27 九大核心特性解析与实战
java
杨运交1 小时前
[073][示例]基于Redis分布式锁的定时任务调度实践
spring boot
用户094248568031 小时前
第11章:OpenJDK反射、动态代理与 MethodHandle 初探
java·jvm
小蒜学长1 小时前
基于SpringBoot+Vue的游戏论坛系统的设计与实现(代码+数据库+LW)
java·后端·springboot·游戏论坛系统·社区生态
SimonKing1 小时前
文档杂乱怎么查找:用 Papra 搭一个极简文档管理系统
java·后端·程序员
captain3761 小时前
网络原理(7)-NAT(Network Address Translation)
java·网络·网络协议·java-ee
jaysee-sjc1 小时前
【苍穹外卖】Day01:从零认识企业级项目开发
java·开发语言·数据库·mysql·spring·intellij-idea·mybatis
雨辰AI1 小时前
多租户数据库资源配额管控|避免租户资源抢占雪崩(金仓 / 达梦 / 高斯 /openGauss 全库原生适配)
java·大数据·数据库·后端
AI深栈1 小时前
第 12 章 · AI智能体的结构化输出与流式响应
java·人工智能