一个 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 会将工作簿加载到内存;行数检查发生在打开工作簿后,不能把它理解成超大文件的内存保护方案。只有格式没有数据的尾部行,也可能影响工作表的最后行号。
真正的大批量导入需要另做流式读取、任务状态和错误文件,这里没有用一个循环假装解决这些问题。
还有一个产品取舍要保留:本文选择整批成功或整批失败。若要改成"正确行先导入,错误行稍后补",成功数、失败数、重传去重都要重新设计,不能只删掉事务就算完成。
对这个小接口,交付标准很具体:人能按返回的行号修表,重传之前也知道上一批究竟有没有写进去。