Spring Boot 大数据量 Excel 导入导出功能实现指南
一、功能概述
一个完整的"数据批量导入"功能通常包含以下接口:
| 接口 | 作用 | 核心难点 |
|---|---|---|
| 导出模板 | 提供标准 Excel 模板供用户填写 | 无 |
| 导入数据 | 上传 Excel,校验后入库 | 大文件解析、数据校验、异步入库 |
| 查询批次列表 | 分页展示导入历史 | 数据权限、多条件筛选 |
| 查询明细列表 | 分页展示某批次的详情 | 大数据量分页 |
| 导出明细 | 导出某批次的全部数据 | 大数据量 Excel 生成 |
| 作废批次 | 逻辑删除某批次 | 权限校验、状态流转 |
二、架构设计
2.1 分层架构
Controller(参数校验、响应封装)
↓
Service 接口(业务契约定义)
↓
Service 实现(核心业务逻辑)
├── 同步:文件解析、数据校验、主表保存
└── 异步:线程池批量插入明细
↓
Mapper / DAO(数据访问)
↓
MySQL(数据存储)
2.2 主从表设计
主表(import_batch) 从表(import_detail)
┌──────────────────┐ ┌──────────────────┐
│ id │◄───┐ │ id │
│ batch_no │ │ │ batch_id (FK) │──┘
│ status │ │ │ batch_no (冗余) │
│ detail_count(冗余)│ │ │ ...业务字段... │
│ create_user_id │ │ amount │
│ create_time │ │ create_time │
└──────────────────┘ └──────────────────┘
1 : N 关系
设计要点:
- 主表冗余
detail_count避免 COUNT 子查询 - 从表冗余
batch_no避免导出时 JOIN - 从表按
batch_id建索引支撑明细查询和导出
2.3 接口交互时序
前端 后端 数据库 线程池
│ │ │ │
│── POST /import (file+参数) ──→ │ │ │
│ │── 解析Excel ──→ │ │
│ │── 逐行校验 ──→ │ │
│ │ (失败则直接返回错误) │ │
│ │── 生成批次号(Redis) ──→ │ │
│ │── INSERT主表 ──→ │ │
│ │ │← 返回主键 │
│ │── 提交异步任务 ──→ │ │
│ │ │ │── 分批INSERT
│← 返回"导入成功,批次号:xxx" ── │ │ │── ...
│ │ │ │── 完成
│ │ │ │
│── POST /list-batch ──→ │ │ │
│ │── SELECT主表(分页) ──→ │ │
│← 返回批次列表 ── │ │ │
三、涉及的设计模式和知识点
3.1 模板方法模式(导入流程)
导入流程是固定的骨架,只有"校验规则"和"数据转换"可变:
解析文件 → 逐行校验 → 转换实体 → 保存主表 → 异步批量插入
(固定) (可变) (可变) (固定) (固定)
3.2 生产者-消费者模式(线程池异步)
- 生产者:HTTP 请求线程,将解析好的数据列表提交到线程池
- 消费者:线程池工作线程,分批执行 INSERT
- 缓冲区:线程池的任务队列(ArrayBlockingQueue)
3.3 批量操作模式(分批 INSERT)
将大量数据分成固定大小的小批次处理,平衡内存占用和网络开销:
12万条 → 按2000条一批 → 60次批量INSERT
3.4 涉及的核心知识点
| 知识点 | 应用位置 |
|---|---|
| POI Excel 解析与生成 | 导入解析、导出生成、模板导出 |
| ThreadPoolExecutor | 异步批量插入 |
| MyBatis 批量 INSERT | XML 中 foreach 标签 |
| PageHelper 分页 | 列表查询 |
| Redis 原子递增 | 批次号生成(并发安全) |
| multipart/form-data | 文件上传 |
| HttpServletResponse 流式输出 | 文件下载 |
| LambdaQueryWrapper 条件构造 | 动态查询条件 |
| 数据权限过滤 | 只查自己的数据 |
| 逻辑删除 | 作废(status 字段) |
| CharacterEncodingFilter | 中文编码问题 |
注:
博客:
https://blog.csdn.net/badao_liumang_qizhi
四、完整示例代码
以下是一个通用的"商品数据批量导入"示例,包含完整的分层代码。
4.1 建表 SQL
sql
-- 主表:导入批次
CREATE TABLE `import_batch` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
`batch_no` VARCHAR(12) NOT NULL COMMENT '批次号:8位日期+4位流水',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '1-有效 0-作废',
`detail_count` INT NOT NULL DEFAULT 0 COMMENT '明细数量(冗余)',
`owner_id` VARCHAR(32) NOT NULL COMMENT '操作人ID',
`owner_name` VARCHAR(64) NOT NULL COMMENT '操作人姓名',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_batch_no` (`batch_no`),
KEY `idx_owner_id` (`owner_id`),
KEY `idx_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 从表:导入明细
CREATE TABLE `import_detail` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
`batch_id` BIGINT NOT NULL COMMENT '批次ID',
`batch_no` VARCHAR(12) NOT NULL COMMENT '批次号(冗余)',
`code` VARCHAR(32) DEFAULT NULL COMMENT '编码',
`name` VARCHAR(128) DEFAULT NULL COMMENT '名称',
`category` VARCHAR(32) DEFAULT NULL COMMENT '分类',
`amount` INT NOT NULL COMMENT '数量',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_batch_id` (`batch_id`),
KEY `idx_code` (`code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
4.2 Entity
java
package com.example.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import java.io.Serializable;
import java.util.Date;
import lombok.Data;
@Data
@TableName("import_batch")
public class ImportBatch implements Serializable {
@TableId(type = IdType.AUTO)
private Long id;
private String batchNo;
private Integer status;
private Integer detailCount;
private String ownerId;
private String ownerName;
private Date createTime;
private Date updateTime;
}
package com.example.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import java.io.Serializable;
import java.util.Date;
import lombok.Data;
@Data
@TableName("import_detail")
public class ImportDetail implements Serializable {
@TableId(type = IdType.AUTO)
private Long id;
private Long batchId;
private String batchNo;
private String code;
private String name;
private String category;
private Integer amount;
private Date createTime;
}
4.3 Mapper
java
package com.example.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.entity.ImportBatch;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ImportBatchMapper extends BaseMapper<ImportBatch> {
}
package com.example.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.entity.ImportDetail;
import java.util.List;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
@Mapper
public interface ImportDetailMapper extends BaseMapper<ImportDetail> {
void saveBatch(@Param("list") List<ImportDetail> list);
}
4.4 Mapper XML(批量插入)
xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.mapper.ImportDetailMapper">
<insert id="saveBatch" parameterType="java.util.List">
INSERT INTO import_detail
(batch_id, batch_no, code, name, category, amount, create_time)
VALUES
<foreach collection="list" item="item" separator=",">
(#{item.batchId}, #{item.batchNo}, #{item.code}, #{item.name},
#{item.category}, #{item.amount}, NOW())
</foreach>
</insert>
</mapper>
4.5 DTO
java
package com.example.dto;
import lombok.Data;
import java.io.Serializable;
/** 批次列表查询参数. */
@Data
public class BatchQueryDto implements Serializable {
private String batchNo; // 模糊搜索
private Integer status; // 状态筛选
private String ownerId; // 数据权限
private String createTimeStart;
private String createTimeEnd;
private Integer pageNum;
private Integer pageSize;
}
package com.example.dto;
import lombok.Data;
import java.io.Serializable;
/** 明细列表查询参数. */
@Data
public class DetailQueryDto implements Serializable {
private Long batchId; // 必填
private String code; // 精准搜索
private String name; // 模糊搜索
private String category; // 精准搜索
private Integer pageNum;
private Integer pageSize;
}
4.6 线程池配置
java
package com.example.config;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class ThreadPoolConfig {
@Bean(name = "importThreadPool", destroyMethod = "shutdown")
public ThreadPoolExecutor importThreadPool() {
return new ThreadPoolExecutor(
4, 8, 60, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(16),
new NamedThreadFactory("import-pool"),
new ThreadPoolExecutor.CallerRunsPolicy()
);
}
static class NamedThreadFactory implements ThreadFactory {
private final String prefix;
private final AtomicInteger counter = new AtomicInteger(1);
NamedThreadFactory(String prefix) {
this.prefix = prefix + "-thread-";
}
@Override
public Thread newThread(Runnable r) {
return new Thread(r, prefix + counter.getAndIncrement());
}
}
}
4.7 Service 接口
java
package com.example.service;
import com.example.dto.BatchQueryDto;
import com.example.dto.DetailQueryDto;
import jakarta.servlet.http.HttpServletResponse;
import java.util.Map;
import org.springframework.web.multipart.MultipartFile;
public interface DataImportService {
Map<String, Object> listBatch(BatchQueryDto params);
Map<String, Object> listDetail(DetailQueryDto params);
String importData(MultipartFile file, String ownerId, String ownerName);
void exportTemplate(HttpServletResponse response);
void exportDetail(Long batchId, String ownerId, HttpServletResponse response);
Boolean invalidateBatch(Long batchId, String ownerId);
}
4.8 Service 实现
java
package com.example.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.github.pagehelper.PageHelper;
import com.github.pagehelper.PageInfo;
import com.example.dto.BatchQueryDto;
import com.example.dto.DetailQueryDto;
import com.example.entity.ImportBatch;
import com.example.entity.ImportDetail;
import com.example.mapper.ImportBatchMapper;
import com.example.mapper.ImportDetailMapper;
import com.example.service.DataImportService;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletResponse;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import lombok.extern.slf4j.Slf4j;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellType;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
@Slf4j
@Service
public class DataImportServiceImpl implements DataImportService {
private static final int MAX_ROWS = 120000;
private static final int BATCH_SIZE = 2000;
private static final String BATCH_KEY_PREFIX = "import:batch_no:";
private static final int STATUS_VALID = 1;
private static final int STATUS_INVALID = 0;
private static final String[] TEMPLATE_HEADERS = {"编码", "名称", "分类", "数量"};
private static final String[] EXPORT_HEADERS = {"批次号", "编码", "名称", "分类", "数量"};
@Resource
private ImportBatchMapper batchMapper;
@Resource
private ImportDetailMapper detailMapper;
@Resource
@Qualifier("importThreadPool")
private ThreadPoolExecutor threadPool;
@Resource
private StringRedisTemplate redisTemplate;
// ==================== 查询批次列表 ====================
@Override
public Map<String, Object> listBatch(BatchQueryDto params) {
int pageNum = params.getPageNum() == null ? 1 : params.getPageNum();
int pageSize = params.getPageSize() == null ? 50 : params.getPageSize();
PageHelper.startPage(pageNum, pageSize);
LambdaQueryWrapper<ImportBatch> w = new LambdaQueryWrapper<>();
// 数据权限
w.eq(params.getOwnerId() != null, ImportBatch::getOwnerId, params.getOwnerId());
// 条件筛选
w.like(params.getBatchNo() != null, ImportBatch::getBatchNo, params.getBatchNo());
w.eq(params.getStatus() != null, ImportBatch::getStatus, params.getStatus());
w.ge(params.getCreateTimeStart() != null, ImportBatch::getCreateTime, params.getCreateTimeStart());
w.le(params.getCreateTimeEnd() != null, ImportBatch::getCreateTime, params.getCreateTimeEnd());
// 排序
w.orderByDesc(ImportBatch::getBatchNo);
List<ImportBatch> list = batchMapper.selectList(w);
PageInfo<ImportBatch> page = new PageInfo<>(list);
Map<String, Object> result = new HashMap<>();
result.put("list", page.getList());
result.put("total", page.getTotal());
return result;
}
// ==================== 查询明细列表 ====================
@Override
public Map<String, Object> listDetail(DetailQueryDto params) {
int pageNum = params.getPageNum() == null ? 1 : params.getPageNum();
int pageSize = params.getPageSize() == null ? 50 : params.getPageSize();
PageHelper.startPage(pageNum, pageSize);
LambdaQueryWrapper<ImportDetail> w = new LambdaQueryWrapper<>();
w.eq(ImportDetail::getBatchId, params.getBatchId());
w.eq(params.getCode() != null, ImportDetail::getCode, params.getCode());
w.like(params.getName() != null, ImportDetail::getName, params.getName());
w.eq(params.getCategory() != null, ImportDetail::getCategory, params.getCategory());
List<ImportDetail> list = detailMapper.selectList(w);
PageInfo<ImportDetail> page = new PageInfo<>(list);
Map<String, Object> result = new HashMap<>();
result.put("list", page.getList());
result.put("total", page.getTotal());
return result;
}
// ==================== 导入数据 ====================
@Override
public String importData(MultipartFile file, String ownerId, String ownerName) {
// 1. 文件格式校验
String fileName = file.getOriginalFilename();
if (fileName == null || !fileName.endsWith(".xlsx")) {
throw new RuntimeException("请上传xlsx格式的文件");
}
// 2. 解析并校验
List<ImportDetail> detailList;
try (InputStream is = file.getInputStream();
XSSFWorkbook workbook = new XSSFWorkbook(is)) {
Sheet sheet = workbook.getSheetAt(0);
int lastRow = sheet.getLastRowNum();
int dataCount = lastRow - 1; // 减去表头和示例行
if (dataCount <= 0) {
throw new RuntimeException("导入数据为空");
}
if (dataCount > MAX_ROWS) {
throw new RuntimeException("单次导入上限" + MAX_ROWS + "条");
}
detailList = new ArrayList<>();
for (int i = 2; i <= lastRow; i++) { // 从第3行开始
Row row = sheet.getRow(i);
if (row == null || isRowEmpty(row)) continue;
// 校验数量字段
String amountStr = getCellValue(row, 3);
Integer amount = parsePositiveInt(amountStr, 999999);
if (amount == null) {
throw new RuntimeException("第" + (i + 1) + "行:数量必须为大于0的正整数,最多六位");
}
ImportDetail detail = new ImportDetail();
detail.setCode(getCellValue(row, 0));
detail.setName(getCellValue(row, 1));
detail.setCategory(getCellValue(row, 2));
detail.setAmount(amount);
detailList.add(detail);
}
} catch (RuntimeException e) {
throw e;
} catch (Exception e) {
throw new RuntimeException("文件解析失败");
}
if (detailList.isEmpty()) {
throw new RuntimeException("导入数据为空");
}
// 3. 生成批次号(Redis原子递增)
String batchNo = generateBatchNo();
// 4. 保存主表
ImportBatch batch = new ImportBatch();
batch.setBatchNo(batchNo);
batch.setStatus(STATUS_VALID);
batch.setDetailCount(detailList.size());
batch.setOwnerId(ownerId);
batch.setOwnerName(ownerName);
batch.setCreateTime(new Date());
batchMapper.insert(batch);
// 5. 补充明细的批次信息
Long batchId = batch.getId();
for (ImportDetail d : detailList) {
d.setBatchId(batchId);
d.setBatchNo(batchNo);
}
// 6. 异步批量插入
threadPool.execute(() -> {
try {
for (int i = 0; i < detailList.size(); i += BATCH_SIZE) {
int end = Math.min(i + BATCH_SIZE, detailList.size());
detailMapper.saveBatch(detailList.subList(i, end));
}
log.info("异步导入完成,批次:{},数量:{}", batchNo, detailList.size());
} catch (Exception e) {
log.error("异步导入失败,批次:{}", batchNo, e);
}
});
return "导入成功,批次号:" + batchNo;
}
// ==================== 导出模板 ====================
@Override
public void exportTemplate(HttpServletResponse response) {
try (XSSFWorkbook wb = new XSSFWorkbook()) {
Sheet sheet = wb.createSheet("导入模板");
Row header = sheet.createRow(0);
for (int i = 0; i < TEMPLATE_HEADERS.length; i++) {
header.createCell(i).setCellValue(TEMPLATE_HEADERS[i]);
}
// 示例行
Row sample = sheet.createRow(1);
sample.createCell(0).setCellValue("P001");
sample.createCell(1).setCellValue("示例商品");
sample.createCell(2).setCellValue("电器");
sample.createCell(3).setCellValue("100");
setDownloadHeaders(response, "导入模板.xlsx");
wb.write(response.getOutputStream());
response.getOutputStream().flush();
} catch (Exception e) {
throw new RuntimeException("导出模板失败");
}
}
// ==================== 导出明细 ====================
@Override
public void exportDetail(Long batchId, String ownerId, HttpServletResponse response) {
ImportBatch batch = batchMapper.selectById(batchId);
if (batch == null) throw new RuntimeException("批次不存在");
if (!batch.getOwnerId().equals(ownerId)) throw new RuntimeException("无权操作");
LambdaQueryWrapper<ImportDetail> w = new LambdaQueryWrapper<>();
w.eq(ImportDetail::getBatchId, batchId);
List<ImportDetail> list = detailMapper.selectList(w);
try (XSSFWorkbook wb = new XSSFWorkbook()) {
Sheet sheet = wb.createSheet("导入明细");
Row header = sheet.createRow(0);
for (int i = 0; i < EXPORT_HEADERS.length; i++) {
header.createCell(i).setCellValue(EXPORT_HEADERS[i]);
}
int rowIdx = 1;
for (ImportDetail d : list) {
Row row = sheet.createRow(rowIdx++);
row.createCell(0).setCellValue(batch.getBatchNo());
row.createCell(1).setCellValue(d.getCode());
row.createCell(2).setCellValue(d.getName());
row.createCell(3).setCellValue(d.getCategory());
row.createCell(4).setCellValue(d.getAmount());
}
String dateStr = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));
setDownloadHeaders(response, "数据导出-" + dateStr + ".xlsx");
wb.write(response.getOutputStream());
response.getOutputStream().flush();
} catch (RuntimeException e) {
throw e;
} catch (Exception e) {
throw new RuntimeException("导出失败");
}
}
// ==================== 作废批次 ====================
@Override
public Boolean invalidateBatch(Long batchId, String ownerId) {
ImportBatch batch = batchMapper.selectById(batchId);
if (batch == null) throw new RuntimeException("批次不存在");
if (!batch.getOwnerId().equals(ownerId)) throw new RuntimeException("无权操作");
if (batch.getStatus() == STATUS_INVALID) throw new RuntimeException("已作废");
LambdaUpdateWrapper<ImportBatch> w = new LambdaUpdateWrapper<>();
w.eq(ImportBatch::getId, batchId);
w.set(ImportBatch::getStatus, STATUS_INVALID);
batchMapper.update(null, w);
return true;
}
// ==================== 私有工具方法 ====================
private String generateBatchNo() {
String date = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));
String key = BATCH_KEY_PREFIX + date;
Long seq = redisTemplate.opsForValue().increment(key);
if (seq != null && seq == 1L) {
redisTemplate.expire(key, 2, TimeUnit.DAYS);
}
if (seq == null || seq > 9999) {
throw new RuntimeException("当天批次号已达上限");
}
return date + String.format("%04d", seq);
}
private Integer parsePositiveInt(String str, int max) {
if (str == null || str.isBlank()) return null;
try {
if (str.contains(".")) str = str.substring(0, str.indexOf("."));
int val = Integer.parseInt(str.trim());
return (val > 0 && val <= max) ? val : null;
} catch (NumberFormatException e) {
return null;
}
}
private boolean isRowEmpty(Row row) {
for (int i = 0; i < row.getLastCellNum(); i++) {
Cell cell = row.getCell(i);
if (cell != null && cell.getCellType() != CellType.BLANK) {
String val = getCellValue(row, i);
if (val != null && !val.isBlank()) return false;
}
}
return true;
}
private String getCellValue(Row row, int idx) {
Cell cell = row.getCell(idx);
if (cell == null) return null;
return switch (cell.getCellType()) {
case STRING -> cell.getStringCellValue().trim();
case NUMERIC -> {
double v = cell.getNumericCellValue();
yield (v == Math.floor(v)) ? String.valueOf((long) v) : String.valueOf(v);
}
case BOOLEAN -> String.valueOf(cell.getBooleanCellValue());
default -> null;
};
}
private void setDownloadHeaders(HttpServletResponse response, String fileName) {
response.setContentType(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setHeader("Content-Disposition",
"attachment;filename=" + URLEncoder.encode(fileName, StandardCharsets.UTF_8));
}
}
4.9 Controller
java
package com.example.controller;
import com.example.dto.BatchQueryDto;
import com.example.dto.DetailQueryDto;
import com.example.service.DataImportService;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletResponse;
import java.util.Map;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
@RestController
@RequestMapping("/api/import")
public class DataImportController {
@Resource
private DataImportService service;
@PostMapping("/list-batch")
public Map<String, Object> listBatch(@RequestBody BatchQueryDto params) {
return Map.of("success", true, "data", service.listBatch(params));
}
@PostMapping("/list-detail")
public Map<String, Object> listDetail(@RequestBody DetailQueryDto params) {
return Map.of("success", true, "data", service.listDetail(params));
}
@PostMapping("/import")
public Map<String, Object> importData(
@RequestParam("file") MultipartFile file,
@RequestParam("ownerId") String ownerId,
@RequestParam("ownerName") String ownerName) {
return Map.of("success", true, "data", service.importData(file, ownerId, ownerName));
}
@GetMapping("/export-template")
public void exportTemplate(HttpServletResponse response) {
service.exportTemplate(response);
}
@GetMapping("/export-detail")
public void exportDetail(
@RequestParam("batchId") Long batchId,
@RequestParam("ownerId") String ownerId,
HttpServletResponse response) {
service.exportDetail(batchId, ownerId, response);
}
@PostMapping("/invalidate")
public Map<String, Object> invalidate(
@RequestParam("batchId") Long batchId,
@RequestParam("ownerId") String ownerId) {
return Map.of("success", true, "data", service.invalidateBatch(batchId, ownerId));
}
}
4.10 编码过滤器
java
package com.example.config;
import jakarta.servlet.Filter;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.filter.CharacterEncodingFilter;
@Configuration
public class EncodingConfig {
@Bean
public FilterRegistrationBean<Filter> importEncodingFilter() {
CharacterEncodingFilter filter = new CharacterEncodingFilter();
filter.setEncoding("UTF-8");
filter.setForceRequestEncoding(true);
filter.setForceResponseEncoding(true);
FilterRegistrationBean<Filter> reg = new FilterRegistrationBean<>();
reg.setFilter(filter);
reg.addUrlPatterns("/api/import/import");
reg.setOrder(Integer.MIN_VALUE);
return reg;
}
}
五、前后端交互要点
5.1 文件上传(前端 → 后端)
javascript
// 前端 JavaScript 示例
const formData = new FormData();
formData.append('file', fileInput.files[0]);
formData.append('ownerId', '10001');
formData.append('ownerName', '张三');
const response = await fetch('/api/import/import', {
method: 'POST',
body: formData
// 注意:不要手动设置 Content-Type,浏览器会自动加 boundary
});
const result = await response.json();
5.2 文件下载(后端 → 前端)
javascript
// 前端下载文件
window.open('/api/import/export-detail?batchId=1&ownerId=10001');
// 或使用 fetch + blob
const response = await fetch('/api/import/export-detail?batchId=1&ownerId=10001');
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = '导出文件.xlsx';
a.click();
URL.revokeObjectURL(url);
5.3 作废二次确认(前端逻辑)
javascript
// 前端点击作废按钮
async function handleInvalidate(batchId, ownerId) {
const confirmed = await showConfirmDialog('是否作废该批次数据?');
if (!confirmed) return;
const response = await fetch(
`/api/import/invalidate?batchId=${batchId}&ownerId=${ownerId}`,
{ method: 'POST' }
);
const result = await response.json();
if (result.success) {
showMessage('作废成功');
refreshList(); // 刷新列表
} else {
showMessage(result.errorMsg);
}
}
六、关键流程总结
┌─────────────────────────────────────────────────────────────────┐
│ 导入流程 │
├─────────────────────────────────────────────────────────────────┤
│ [同步] 接收文件 → 解析Excel → 逐行校验(遇错即停) → 生成批次号 │
│ → 保存主表 │
│ [异步] 线程池分批INSERT明细(每批2000条) │
│ [响应] 返回"导入成功,批次号:xxx" │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 查询流程 │
├─────────────────────────────────────────────────────────────────┤
│ 主页面:查主表(数据权限+条件+分页+排序) → 返回列表 │
│ 详情页:查从表(batch_id+条件+分页) → 返回明细 │
│ 无JOIN,无GROUP BY,无COUNT子查询 │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 导出流程 │
├─────────────────────────────────────────────────────────────────┤
│ 权限校验 → 查主表信息 → 查从表全部数据 → 内存生成Excel │
│ → 设置响应头 → 流式输出到HTTP响应 → 浏览器下载 │
└─────────────────────────────────────────────────────────────────┘