Spring Boot 大数据量 Excel 导入导出功能实现指南

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响应 → 浏览器下载                     │
└─────────────────────────────────────────────────────────────────┘
相关推荐
小刘|1 小时前
Spring AI 结构化输出 + 大模型参数全解(含千问调优)
java·后端·spring
copyer_xyf1 小时前
FastAPI 项目骨架搭建
前端·后端·python
霸道流氓气质1 小时前
Java 单元测试生成大量 Excel 测试数据实战指南
java·单元测试·excel
laowangpython1 小时前
tokio-rstracing:Rust 可观测性的标准答案
开发语言·后端·其他·rust
IT_陈寒1 小时前
Python虚拟环境的这个坑,我居然绕了三天才爬出来
前端·人工智能·后端
我登哥MVP1 小时前
SpringCloud 核心组件解析:服务熔断和降级
java·spring boot·后端·spring·spring cloud·java-ee·maven
Oneslide2 小时前
Claude Code 插件完全指南:安装与技能大全
后端
摇滚侠2 小时前
SpringMVC 入门到实战 异常处理 83-85
java·后端·spring·maven·intellij-idea
TPBoreas2 小时前
springboot我们项目中的常见注解
java·spring boot·后端