Spring Boot + Apache Fesod 实战:打造生产级 Excel 导入导出通用组件(异步任务 + 错误报告 + 模板填充一条龙)

上一篇《Apache Fesod 入门教程:高性能 Excel 处理从 0 到 1》讲清了 Fesod 的核心 API 和独立使用方式。但真到了 Spring Boot 项目里落地,你会发现事情远不止”读和写”那么简单——

  • 大文件上传超时、接口响应太慢?→ 得搞异步任务 + 进度查询;
  • 导入 10 万行,其中 300 行有问题,怎么让用户知道哪几行错了、错在哪?→ 得生成错误报告 Excel 供下载;
  • 导出合同、报价单、财务报表这类格式复杂的文件,用纯代码拼样式写死?→ 得用模板填充(Fill API),运营同学自己改模板就行;
  • 每个业务模块都复制一遍读写代码?→ 得封装通用 ExcelService,业务只关心”写你的 VO 类 + 你的业务逻辑”。

这篇就把这些生产环境的硬需求一次性解决,直接打造一个可复用、可落地、可扩展的 Spring Boot + Fesod 通用组件。所有代码复制即可运行。


0)整体架构设计:一个模块解决 80% Excel 场景

先看我们最终交付的组件分层结构,做到业务无感知、职责清晰:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
┌─────────────────────────────────────────────────────────┐
│ Controller 层(接口门面) │
│ POST /excel/import → 上传 Excel,返回任务ID │
│ GET /excel/task/{id} → 查询导入/导出任务状态 + 进度 │
│ GET /excel/export → 触发导出,返回任务ID │
│ GET /excel/download/{fileName} → 下载结果/错误报告 │
├─────────────────────────────────────────────────────────┤
│ ExcelTaskService(异步任务编排) │
│ - 提交任务 → 存入 Redis / DB → 启动 @Async 线程 │
│ - 记录进度 total / success / fail / message │
│ - 异常捕获 → 记录到 ErrorReport │
├─────────────────────────────────────────────────────────┤
│ ExcelCommonService(通用读写封装) │
│ - <T> read(InputStream, Class<T>, ReadCallback<T>) │
│ - <T> write(OutputStream, Class<T>, Supplier<List<T>>) │
│ - writeErrorReport(OutputStream, List<ErrorRow>) │
│ - fillTemplate(InputStream template, Map data, Output) │
├─────────────────────────────────────────────────────────┤
│ ReadCallback / WriteCallback(业务回调接口) │
│ - 业务层实现 onBatch(List<T>) → 做入库/业务处理 │
├─────────────────────────────────────────────────────────┤
│ Converter / Validator(自定义扩展) │
│ - 枚举 ↔ 中文 字典转换器 │
│ - JSR-380 单条数据校验器(保留行号) │
└─────────────────────────────────────────────────────────┘

核心设计原则:通用层不碰业务,业务层不写 IO。


1)项目骨架 & 依赖准备

1.1 完整 pom.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
<?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">
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.4</version>
<relativePath/>
</parent>

<properties>
<java.version>17</java.version>
<fesod.version>1.3.0</fesod.version>
</properties>

<dependencies>
<!-- ===== Web + 基础 ===== -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>

<!-- ===== Apache Fesod(Excel 核心) ===== -->
<dependency>
<groupId>cn.idev.excel</groupId>
<artifactId>fastexcel</artifactId>
<version>${fesod.version}</version>
</dependency>

<!-- ===== Redis(存任务状态,可选) ===== -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

<!-- ===== 工具 ===== -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.32</version>
</dependency>
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
<version>2.0.53</version>
</dependency>
</dependencies>
</project>

1.2 application.yml(关键配置)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
server:
port: 8080
servlet:
multipart:
max-file-size: 100MB # 单文件最大 100MB
max-request-size: 100MB # 请求体最大 100MB

spring:
servlet:
multipart:
enabled: true
data:
redis:
host: localhost
port: 6379
database: 0

# 自定义:Excel 任务存储目录
excel:
task-dir: ${user.home}/excel-tasks # 临时文件 / 导出结果 / 错误报告存放目录
batch-size: 500 # 每批入库数量
async-core-pool-size: 4 # 异步任务核心线程数

1.3 启用异步 & 创建任务目录(启动类)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
@SpringBootApplication
@EnableAsync
@Slf4j
public class ExcelApplication implements ApplicationRunner {

@Value("${excel.task-dir}")
private String taskDir;

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

@Override
public void run(ApplicationArguments args) throws Exception {
File dir = new File(taskDir);
if (!dir.exists() && dir.mkdirs()) {
log.info("📁 Excel 任务目录已创建: {}", dir.getAbsolutePath());
}
}

/**
* 异步线程池:和 Tomcat 请求线程隔离,接口不会被 Excel 处理阻塞
*/
@Bean
public Executor excelTaskExecutor(@Value("${excel.async-core-pool-size:4}") int core) {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(core);
executor.setMaxPoolSize(core * 2);
executor.setQueueCapacity(500);
executor.setThreadNamePrefix("excel-task-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
}

2)第一步:通用读写服务 ExcelCommonService

这是整个组件的”心脏”,把 Fesod 原生 API 做一层薄封装,业务方根本不用知道 FesodSheet 是啥。

2.1 读回调接口 & 行错误记录对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
/**
* Excel 读取回调:业务方实现它,专注"拿到数据做什么"
*
* @param <T> Excel 对应的 POJO 类型
*/
public interface ExcelReadCallback<T> {

/**
* 每批数据处理(攒够 batchSize 调用一次)
*
* @param batch 当前批次数据
* @param startNo 这批数据在原始 Excel 的起始行号(1 基,不含表头,方便报错定位)
*/
void onBatch(List<T> batch, int startNo);

/**
* 单条数据校验失败 / 业务处理失败时收集错误行(可选实现)
*/
default void onRowError(int rowNo, String message, T rowData) {
// 默认不做,业务层可以 override 收集然后生成错误报告
}
}

/**
* 错误行记录(用于生成"导入错误报告.xlsx")
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class ExcelErrorRow {

@ExcelProperty("行号")
@ColumnWidth(10)
private Integer rowNo;

@ExcelProperty("错误原因")
@ColumnWidth(50)
private String message;

@ExcelProperty("原始数据摘要")
@ColumnWidth(40)
private String rawData;
}

2.2 ExcelCommonService 核心实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
@Slf4j
@Service
public class ExcelCommonService {

@Value("${excel.batch-size:500}")
private int batchSize;

/* ========================================================================
1. 通用读取:流式 → 攒批 → 回调业务
======================================================================== */

/**
* 通用读取(推荐)
*
* @param inputStream 上传的文件流
* @param clazz Excel 映射 POJO 类
* @param callback 业务回调
* @param <T> POJO 类型
*/
public <T> void read(InputStream inputStream,
Class<T> clazz,
ExcelReadCallback<T> callback) {
FesodSheet.read(inputStream, clazz, new BatchReadListener<>(callback, batchSize))
.sheet()
.doRead();
}

/**
* 内部攒批监听器
*/
@RequiredArgsConstructor
private static class BatchReadListener<T> implements ReadListener<T> {

private final ExcelReadCallback<T> callback;
private final int batchSize;

private final List<T> batch = new ArrayList<>();
private int currentBatchStartRow = 2; // Excel 数据从第 2 行开始(第 1 行是表头)

@Override
public void invoke(T data, AnalysisContext context) {
// JSR-380 单条校验(可选):拿到行号 → 不合法 → 收集错误
int rowNo = context.readRowHolder().getRowIndex() + 1; // 转成 Excel 行号(1 基)
Set<ConstraintViolation<T>> violations = ValidationUtil.validate(data);
if (!violations.isEmpty()) {
String msg = violations.stream()
.map(v -> v.getPropertyPath() + ": " + v.getMessage())
.collect(Collectors.joining("; "));
callback.onRowError(rowNo, msg, data);
return; // 校验失败的不再进批次
}
batch.add(data);
if (batch.size() >= batchSize) {
callback.onBatch(new ArrayList<>(batch), currentBatchStartRow);
batch.clear();
currentBatchStartRow = rowNo + 1;
}
}

@Override
public void doAfterAllAnalysed(AnalysisContext context) {
if (!batch.isEmpty()) {
callback.onBatch(new ArrayList<>(batch), currentBatchStartRow);
batch.clear();
}
}

@Override
public void onException(Exception e, AnalysisContext context) {
// 解析异常也收集(比如日期格式不合法)
int rowNo = context.readRowHolder() == null ? -1 : context.readRowHolder().getRowIndex() + 1;
callback.onRowError(rowNo, "解析失败: " + e.getMessage(), null);
// 不抛异常 → 整体继续处理剩下的行
}
}

/* ========================================================================
2. 通用写入:支持分页拉数据的供给式写法
======================================================================== */

/**
* 通用写入:传 List(小数据量)
*/
public <T> void write(OutputStream out, Class<T> clazz, List<T> data, String sheetName) {
FesodSheet.write(out, clazz).sheet(sheetName).doWrite(data);
}

/**
* 通用写入:供给式(大数据量导出 → 边查边写,内存友好)
*
* @param fetchData 业务方实现:传批次号/偏移量,返回一批数据;返回空集合代表结束
*/
public <T> void writeBigData(OutputStream out,
Class<T> clazz,
String sheetName,
Function<Integer, List<T>> fetchData) {
try (ExcelWriter writer = FesodSheet.write(out, clazz).build()) {
WriteSheet sheet = FesodSheet.writerSheet(sheetName).build();
int offset = 0;
List<T> batch;
while (!(batch = fetchData.apply(offset)).isEmpty()) {
writer.write(batch, sheet);
offset += batch.size();
}
}
}

/**
* 生成导入错误报告
*/
public void writeErrorReport(OutputStream out, List<ExcelErrorRow> errors) {
FesodSheet.write(out, ExcelErrorRow.class)
.sheet("错误明细")
.doWrite(errors);
}

/* ========================================================================
3. 模板填充(Fill API):合同 / 报价单 / 报表 这种复杂格式场景
======================================================================== */

/**
* 简单模板填充(单对象 + 多列表)
*
* @param templateIn 模板文件输入流(classpath 下的 .xlsx 模板)
* @param data 填充数据:普通字段直接 put,列表 put List
* @param out 输出流(response / file)
*/
public void fillTemplate(InputStream templateIn, Map<String, Object> data, OutputStream out) {
try (ExcelWriter writer = FesodSheet.write().withTemplate(templateIn).file(out).build()) {
WriteSheet sheet = FesodSheet.writerSheet().build();
writer.fill(data, sheet);
}
}
}

2.3 工具类:JSR-380 校验器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
* 单对象校验工具(保留字段级错误信息)
*/
public final class ValidationUtil {

private static final Validator VALIDATOR = Validation.buildDefaultValidatorFactory().getValidator();

public static <T> Set<ConstraintViolation<T>> validate(T obj) {
if (obj == null) {
return Collections.emptySet();
}
return VALIDATOR.validate(obj);
}

public static <T> void validateThrow(T obj) {
Set<ConstraintViolation<T>> vs = validate(obj);
if (!vs.isEmpty()) {
throw new IllegalArgumentException(vs.stream()
.map(v -> v.getPropertyPath() + ": " + v.getMessage())
.collect(Collectors.joining("; ")));
}
}

private ValidationUtil() {}
}

3)第二步:异步任务编排 ExcelTaskService

接口如果同步等 10 万行 Excel 处理完,网关/前端早就超时了。所以必须:上传 → 立刻返回任务ID → 后台跑 → 前端轮询进度。

3.1 任务状态模型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ExcelTaskInfo implements Serializable {

@Serial
private static final long serialVersionUID = 1L;

private String taskId;
private TaskType type; // IMPORT / EXPORT
private TaskStatus status; // PENDING / RUNNING / SUCCESS / FAILED
private String fileName; // 原文件名 or 导出文件名
private long total; // 总数
private long success; // 成功数
private long fail; // 失败数
private String message; // 备注 / 失败原因
private String resultFile; // 成功后:导出文件 or 错误报告 的下载路径
private LocalDateTime createTime;
private LocalDateTime updateTime;

public enum TaskType { IMPORT, EXPORT }
public enum TaskStatus { PENDING, RUNNING, SUCCESS, FAILED }
}

3.2 ExcelTaskService(Redis 存任务状态 + @Async 执行)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
@Slf4j
@Service
public class ExcelTaskService {

private static final String KEY_PREFIX = "excel:task:";
private static final Duration EXPIRE = Duration.ofHours(24);

@Resource
private StringRedisTemplate redisTemplate;
@Resource
private ExcelCommonService excelCommonService;
@Value("${excel.task-dir}")
private String taskDir;

/* =========================== 任务存储(Redis) =========================== */

public void save(ExcelTaskInfo task) {
task.setUpdateTime(LocalDateTime.now());
redisTemplate.opsForValue().set(
KEY_PREFIX + task.getTaskId(),
JSON.toJSONString(task),
EXPIRE
);
}

public ExcelTaskInfo get(String taskId) {
String json = redisTemplate.opsForValue().get(KEY_PREFIX + taskId);
if (StrUtil.isBlank(json)) {
throw new IllegalArgumentException("任务不存在或已过期: " + taskId);
}
return JSON.parseObject(json, ExcelTaskInfo.class);
}

public void updateProgress(String taskId, long incSuccess, long incFail, String msg) {
ExcelTaskInfo task = get(taskId);
task.setSuccess(task.getSuccess() + incSuccess);
task.setFail(task.getFail() + incFail);
if (StrUtil.isNotBlank(msg)) {
task.setMessage(msg);
}
save(task);
}

/* =========================== 提交导入任务 =========================== */

/**
* 提交导入任务 → 立刻返回 taskId,后台异步执行
*/
public String submitImport(MultipartFile file, Class<?> pojoClazz, ExcelReadCallback<?> callback) {
// 1. 落盘原文件 + 生成任务
String taskId = IdUtil.fastSimpleUUID();
String origName = file.getOriginalFilename();
Path savedPath = Path.of(taskDir, taskId + "_" + (origName == null ? "upload.xlsx" : origName));
try {
file.transferTo(savedPath);
} catch (IOException e) {
throw new RuntimeException("文件保存失败", e);
}

ExcelTaskInfo task = ExcelTaskInfo.builder()
.taskId(taskId)
.type(ExcelTaskInfo.TaskType.IMPORT)
.status(ExcelTaskInfo.TaskStatus.PENDING)
.fileName(savedPath.getFileName().toString())
.createTime(LocalDateTime.now())
.build();
save(task);

// 2. 丢给 @Async 线程
doImportAsync(taskId, savedPath, pojoClazz, callback);
return taskId;
}

@Async("excelTaskExecutor")
public <T> void doImportAsync(String taskId, Path filePath, Class<T> pojoClazz, ExcelReadCallback<T> callback) {
ExcelTaskInfo task = get(taskId);
task.setStatus(ExcelTaskInfo.TaskStatus.RUNNING);
save(task);

List<ExcelErrorRow> errors = Collections.synchronizedList(new ArrayList<>());

try (InputStream in = Files.newInputStream(filePath)) {
// 把"业务回调"再包一层:顺便统计进度 + 收集错误
ExcelReadCallback<T> wrapper = new ExcelReadCallback<>() {
@Override
public void onBatch(List<T> batch, int startNo) {
try {
callback.onBatch(batch, startNo);
updateProgress(taskId, batch.size(), 0, null);
} catch (Exception e) {
// 批量入库失败 → 逐条回滚处理,标记每一条失败(简单实现,生产可做事务)
for (int i = 0; i < batch.size(); i++) {
errors.add(new ExcelErrorRow(startNo + i, "批量处理失败: " + e.getMessage(), JSON.toJSONString(batch.get(i))));
}
updateProgress(taskId, 0, batch.size(), "出现批量错误,请下载错误报告查看");
}
}

@Override
public void onRowError(int rowNo, String message, T rowData) {
errors.add(new ExcelErrorRow(rowNo, message, JSON.toJSONString(rowData)));
updateProgress(taskId, 0, 1, null);
}
};

excelCommonService.read(in, pojoClazz, wrapper);

// 生成错误报告(如果有错误)
if (!errors.isEmpty()) {
Path errPath = Path.of(taskDir, taskId + "_errors.xlsx");
try (OutputStream out = Files.newOutputStream(errPath)) {
excelCommonService.writeErrorReport(out, errors);
}
task.setResultFile("/excel/download/" + errPath.getFileName());
task.setMessage("处理完成,存在 " + errors.size() + " 条错误数据");
} else {
task.setMessage("全部导入成功");
}
task.setStatus(ExcelTaskInfo.TaskStatus.SUCCESS);

} catch (Exception e) {
log.error("导入任务失败 taskId={}", taskId, e);
task.setStatus(ExcelTaskInfo.TaskStatus.FAILED);
task.setMessage("系统异常: " + e.getMessage());
} finally {
save(task);
// 可选:删除原文件节省磁盘(错误报告保留 24h)
// FileUtils.deleteQuietly(filePath.toFile());
}
}

/* =========================== 提交导出任务 =========================== */

/**
* 通用导出(供给式,分页从 DB 拉)
*
* @param exportFileName 下载时显示的文件名(不含扩展名)
* @param pojoClazz Excel VO 类型
* @param sheetName sheet 名
* @param fetchData 分页取数函数:入参 offset,返回一批数据,空集合代表结束
*/
public <T> String submitExport(String exportFileName,
Class<T> pojoClazz,
String sheetName,
Function<Integer, List<T>> fetchData) {
String taskId = IdUtil.fastSimpleUUID();
String safeName = (exportFileName == null ? "export" : exportFileName) + ".xlsx";
Path outPath = Path.of(taskDir, taskId + "_" + safeName);

ExcelTaskInfo task = ExcelTaskInfo.builder()
.taskId(taskId)
.type(ExcelTaskInfo.TaskType.EXPORT)
.status(ExcelTaskInfo.TaskStatus.PENDING)
.fileName(safeName)
.createTime(LocalDateTime.now())
.build();
save(task);

doExportAsync(taskId, outPath, pojoClazz, sheetName, fetchData);
return taskId;
}

@Async("excelTaskExecutor")
public <T> void doExportAsync(String taskId, Path outPath, Class<T> pojoClazz,
String sheetName, Function<Integer, List<T>> fetchData) {
ExcelTaskInfo task = get(taskId);
task.setStatus(ExcelTaskInfo.TaskStatus.RUNNING);
save(task);

try (OutputStream out = Files.newOutputStream(outPath)) {
// 包装一下 fetchData:每写完一批就更新进度
final long[] totalWritten = {0};
Function<Integer, List<T>> wrapper = offset -> {
List<T> batch = fetchData.apply(offset);
if (!batch.isEmpty()) {
totalWritten[0] += batch.size();
updateProgress(taskId, batch.size(), 0, "已导出 " + totalWritten[0] + " 行");
}
return batch;
};
excelCommonService.writeBigData(out, pojoClazz, sheetName, wrapper);

task.setStatus(ExcelTaskInfo.TaskStatus.SUCCESS);
task.setMessage("导出完成,共 " + totalWritten[0] + " 行");
task.setResultFile("/excel/download/" + outPath.getFileName());
} catch (Exception e) {
log.error("导出任务失败 taskId={}", taskId, e);
task.setStatus(ExcelTaskInfo.TaskStatus.FAILED);
task.setMessage("导出失败: " + e.getMessage());
} finally {
save(task);
}
}
}

4)第三步:Controller 接口门面(给前端调用)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
@Slf4j
@RestController
@RequestMapping("/excel")
@RequiredArgsConstructor
public class ExcelController {

private final ExcelTaskService excelTaskService;
private final UserExcelService userExcelService; // 业务 Service,见第 5 章

@Value("${excel.task-dir}")
private String taskDir;

/* ========== 1. 用户导入 ========== */

@PostMapping("/user/import")
public R<String> userImport(@RequestParam("file") MultipartFile file) {
if (file.isEmpty()) {
return R.fail("文件为空");
}
String taskId = userExcelService.submitImport(file);
return R.ok(taskId);
}

/* ========== 2. 用户导出(带条件) ========== */

@GetMapping("/user/export")
public R<String> userExport(@RequestParam(required = false) String keyword) {
String taskId = userExcelService.submitExport(keyword);
return R.ok(taskId);
}

/* ========== 3. 通用:查任务进度 ========== */

@GetMapping("/task/{taskId}")
public R<ExcelTaskInfo> taskInfo(@PathVariable String taskId) {
return R.ok(excelTaskService.get(taskId));
}

/* ========== 4. 通用:下载结果文件 / 错误报告 ========== */

@GetMapping("/download/{fileName}")
public void download(@PathVariable String fileName, HttpServletResponse response) throws IOException {
// 防止路径穿越
if (fileName.contains("..") || fileName.contains("/") || fileName.contains("\\")) {
response.sendError(HttpServletResponse.SC_BAD_REQUEST, "非法文件名");
return;
}
Path filePath = Path.of(taskDir, fileName);
if (!Files.exists(filePath)) {
response.sendError(HttpServletResponse.SC_NOT_FOUND, "文件不存在");
return;
}
String downloadName = fileName;
// 去掉 taskId 前缀(第 1 个下划线之后才是用户友好名)
int idx = fileName.indexOf('_');
if (idx > 0 && idx < fileName.length() - 1) {
downloadName = fileName.substring(idx + 1);
}
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setHeader(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename*=UTF-8''" + URLEncoder.encode(downloadName, StandardCharsets.UTF_8));
Files.copy(filePath, response.getOutputStream());
response.flushBuffer();
}
}

通用返回体 R<T>:

1
2
3
4
5
6
7
8
9
10
11
@Data
@AllArgsConstructor
@NoArgsConstructor
public class R<T> {
private int code;
private String msg;
private T data;

public static <T> R<T> ok(T data) { return new R<>(0, "ok", data); }
public static <T> R<T> fail(String msg) { return new R<>(500, msg, null); }
}

5)第四步:业务方写代码有多爽?以”用户导入/导出”为例

前面 4 步全是通用组件,写一次,全项目复用。真正到业务模块,就简单到离谱了。

5.1 定义 VO(加注解 + 加校验注解)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@Data
public class UserImportVO {

@NotBlank(message = "用户名不能为空")
@ExcelProperty("用户名")
@ColumnWidth(15)
private String username;

@NotBlank(message = "手机号不能为空")
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
@ExcelProperty("手机号")
@ColumnWidth(15)
private String phone;

@Email(message = "邮箱格式不正确")
@ExcelProperty("邮箱")
@ColumnWidth(25)
private String email;

@NotNull(message = "年龄不能为空")
@Min(value = 1, message = "年龄不能小于1岁")
@Max(value = 150, message = "年龄不能大于150岁")
@ExcelProperty("年龄")
@ColumnWidth(8)
private Integer age;

/**
* 枚举 ↔ 中文 自定义转换:见下面 Converter
*/
@ExcelProperty(value = "性别", converter = GenderConverter.class)
@ColumnWidth(8)
private Integer gender; // 存 DB 用 0/1,Excel 里显示 男/女
}

5.2 自定义 Converter(枚举字典翻译)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/**
* 性别字段:Excel 中"男/女" ↔ 数据库 1/0
*/
public class GenderConverter implements Converter<Integer> {

@Override
public Class<?> supportJavaTypeKey() { return Integer.class; }

@Override
public CellDataTypeEnum supportExcelTypeKey() { return CellDataTypeEnum.STRING; }

@Override
public Integer convertToJavaData(ReadConverterContext<?> ctx) {
String val = ctx.getReadCellData().getStringValue();
if (StrUtil.isBlank(val)) return null;
return switch (val.trim()) {
case "男" -> 1;
case "女" -> 0;
default -> throw new IllegalArgumentException("性别只能是 男/女,实为: " + val);
};
}

@Override
public WriteCellData<?> convertToExcelData(WriteConverterContext<Integer> ctx) {
Integer val = ctx.getValue();
String s = val == null ? "" : (val == 1 ? "男" : val == 0 ? "女" : "未知");
return new WriteCellData<>(s);
}
}

5.3 业务 Service:30 行搞定导入导出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
@Service
@RequiredArgsConstructor
public class UserExcelService {

private final ExcelTaskService excelTaskService;
private final UserMapper userMapper; // 你项目里的 MyBatis Mapper
private final UserConverter userConverter; // MapStruct:VO ↔ Entity,不是必须

/**
* 提交用户导入任务
*/
public String submitImport(MultipartFile file) {
return excelTaskService.submitImport(file, UserImportVO.class, (batch, startNo) -> {
// 👇 这就是你唯一要写的"业务逻辑":把 VO 批量转 Entity → 入库
List<UserEntity> entities = batch.stream()
.map(userConverter::voToEntity)
.toList();
userMapper.batchInsert(entities); // 自己的 MyBatis batch insert
});
}

/**
* 提交用户导出任务(供给式分页拉取,内存友好)
*/
public String submitExport(String keyword) {
int pageSize = 1000;
return excelTaskService.submitExport(
"用户列表_" + LocalDate.now(),
UserImportVO.class,
"用户数据",
offset -> {
// 按 offset / pageSize 分页查
Page<UserEntity> page = userMapper.selectPage(
new Page<>(offset / pageSize + 1, pageSize),
keyword
);
if (page.getRecords().isEmpty()) {
return Collections.emptyList();
}
return page.getRecords().stream()
.map(userConverter::entityToVo)
.toList();
}
);
}
}

就这么点!再也不用每次写 Fesod 监听器、IO、错误处理、进度、文件下载了。


6)第五步:高级场景 —— 模板填充(合同 / 报价单 / 财务报表)

纯代码写复杂样式太痛苦。运营同学希望改个表头标题、改个 Logo 不用发版,Fill API + 模板文件就是标准答案。

6.1 准备 Excel 模板

在 src/main/resources/templates/quotation_template.xlsx 做模板,变量用 {xxx}、列表用 {.xxx}:

ABCD
公司报价单
客户名称{customerName}日期{date}
订单号{orderNo}销售{salesMan}
序号产品名称数量单价
{.index}{.productName}{.qty}{.price}
…………
合计{totalAmount}

6.2 一行代码填充

在 ExcelCommonService 中已经封装了 fillTemplate,业务层:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
@Service
@RequiredArgsConstructor
public class QuotationService {

private final ExcelCommonService excelCommonService;

/**
* 生成报价单并返回文件流(小文件同步返回,大文件走任务)
*/
public void generateQuotation(String orderNo, HttpServletResponse response) throws IOException {
// 1. 从 DB 取业务数据
QuotationOrder order = quotationMapper.selectByOrderNo(orderNo);
List<QuotationItem> items = quotationItemMapper.selectByOrderNo(orderNo);

// 2. 组装 Fesod Fill 需要的 Map
Map<String, Object> data = new HashMap<>();
data.put("customerName", order.getCustomerName());
data.put("date", LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE));
data.put("orderNo", order.getOrderNo());
data.put("salesMan", order.getSalesMan());
data.put("totalAmount", "¥" + items.stream()
.map(i -> i.getPrice().multiply(BigDecimal.valueOf(i.getQty())))
.reduce(BigDecimal.ZERO, BigDecimal::add));
// 列表:里面的每个对象要有 productName/qty/price/index 属性
data.put("index", 0); // 占位?实际用 index 可以在业务里自己加序号字段
data.put("list", items);

// 3. 模板 + 数据 → 输出
String fileName = "报价单_" + orderNo + ".xlsx";
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setHeader(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename*=UTF-8''" + URLEncoder.encode(fileName, StandardCharsets.UTF_8));

try (InputStream in = new ClassPathResource("templates/quotation_template.xlsx").getInputStream();
OutputStream out = response.getOutputStream()) {
excelCommonService.fillTemplate(in, data, out);
}
}
}

运营改 Excel 模板就能改报表样式,开发零参与 🎉。


7)生产环境避坑 & 调优清单 ⚠️

坑点现象解决方案
异步任务不生效@Async 方法和调用方在同一个类里异步方法必须放到另一个 Bean中(Spring AOP 代理原因)。本文 ExcelTaskService 被 Controller 调,天然跨 Bean,OK
任务状态丢失Redis 挂了或重启关键任务改为 DB + Redis 双写;非关键任务 Redis 24h 过期足够
大文件上传超时Nginx / 网关 504Nginx 调 proxy_read_timeout + 前端用分片上传
百万行导出 OOMdoWrite(list) 全查内存永远用供给式 writeBigData,分页查 DB 边查边写
导入时行号不准报错指向的行跟 Excel 对不上context.readRowHolder().getRowIndex() + 1 才是 Excel 行号(1 基);多 Sheet 注意切换
日期/数字格式化不生效@DateTimeFormat 写在 String 字段上日期字段必须是 java.util.Date/LocalDateTime,数字字段必须是 Number/BigDecimal
Excel 中带公式读出来是公式字符串不是值用 CellData<T> 类型接收或在 POJO 上加 @ExcelProperty 配合公式处理
文件名中文乱码下载后文件名乱码必须用 filename*=UTF-8''xxx(RFC 5987),不要只写 filename=xxx

8)前端怎么调?(参考代码)

用 Vue + axios 举个最小可用例子,逻辑很通用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 1. 触发导入
async function importExcel(file: File) {
const fd = new FormData()
fd.append('file', file)
const { data } = await axios.post('/excel/user/import', fd)
pollTask(data.data) // 拿到 taskId 开始轮询
}

// 2. 触发导出
async function exportExcel(keyword?: string) {
const { data } = await axios.get('/excel/user/export', { params: { keyword } })
pollTask(data.data)
}

// 3. 轮询进度(每秒 1 次,成功/失败就停)
async function pollTask(taskId: string) {
const timer = setInterval(async () => {
const { data } = await axios.get(`/excel/task/${taskId}`)
const task = data.data
console.log(`进度: 成功 ${task.success} / 失败 ${task.fail} / ${task.message}`)

if (task.status === 'SUCCESS' || task.status === 'FAILED') {
clearInterval(timer)
if (task.resultFile) {
// 有文件 → 下载
window.location.href = task.resultFile
}
ElMessage.success(task.message)
}
}, 1000)
}

9)总结 & 可扩展方向

9.1 本篇交付物清单

本篇给了你一套开箱即用的 Spring Boot + Fesod 通用组件:

交付物作用代码位置
ExcelCommonService通用读 / 写 / 模板填充 / 攒批 / JSR-380 校验 / 错误收集第 2 章
ExcelTaskService任务状态模型 + Redis 存储 + @Async 异步编排 + 进度更新 + 错误报告生成第 3 章
ExcelController导入 / 导出 / 查进度 / 下载 4 个接口第 4 章
GenderConverter 示例字典枚举自定义转换器,所有”中文 ↔ 代码值”场景直接照搬第 5 章
模板填充示例合同、报价单、报表这类”固定格式”场景第 6 章
生产环境 8 大避坑清单踩一次亏半天的问题第 7 章
前端轮询示例代码前后端联动的最小闭环第 8 章

9.2 后续可扩展方向(进阶)

  1. 多租户 + 权限:下载接口校验当前用户是否是任务提交者;
  2. 任务补偿:定时扫 FAILED 状态任务,自动重试 3 次;
  3. WebHook / 消息通知:任务完成后用 WebSocket / 钉钉 / 邮件推送,不必前端轮询;
  4. 导入模板下发:导出空模板带下拉选项(字典值)、带批注提示怎么填;
  5. 百万级数据分片导出:分多个 sheet,或分多个 zip 文件打包下载;
  6. 加解密:敏感数据导出自动加密,导入自动解密。


Spring Boot + Apache Fesod 实战:打造生产级 Excel 导入导出通用组件(异步任务 + 错误报告 + 模板填充一条龙)
https://www.pcboy.com.cn/2026/09/29/Spring-Boot-Apache-Fesod实战:生产级Excel导入导出通用组件/
作者
chituer
发布于
2026年9月29日
许可协议