fix: 增加综合导出工具类

This commit is contained in:
tangwei 2026-08-04 18:41:42 +08:00
parent deac23c517
commit 0b8ca9063c

View File

@ -0,0 +1,521 @@
package com.yfd.platform.utils;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.apache.poi.ss.usermodel.*;
import org.apache.poi.xssf.streaming.SXSSFSheet;
import org.apache.poi.xssf.streaming.SXSSFWorkbook;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import jakarta.servlet.ServletOutputStream;
import jakarta.servlet.http.HttpServletResponse;
import java.io.*;
import java.net.URLEncoder;
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* 综合导出工具类 将多维度数据导出为 ZIP 压缩包内含若干 Excel (.xlsx) 文件每个 Excel 支持多 Sheet
*
* <h3>使用示例</h3>
* <pre>{@code
* // 1. 构建 Sheet 数据
* SheetData stationSheet = new SheetData("电站基础信息",
* Arrays.asList("测站编码", "测站名称", "经度", "纬度"),
* stationRows); // List<List<Object>>
*
* SheetData monitorSheet = new SheetData("监测数据",
* Arrays.asList("时间", "水位", "流量"),
* monitorRows);
*
* // 2. 构建 Excel 数据一个 Excel = 多个 Sheet
* ExcelData excel1 = new ExcelData("电站数据", Arrays.asList(stationSheet, monitorSheet));
* ExcelData excel2 = new ExcelData("流域数据", Arrays.asList(basinSheet));
*
* // 3. 导出到 HttpServletResponse
* ExportZipUtil.exportToResponse(response, "数据导出_20260731.zip", Arrays.asList(excel1, excel2));
*
* // 或导出到文件
* ExportZipUtil.exportToFile(new File("D:/export.zip"), Arrays.asList(excel1, excel2));
*
* // 类型安全的构建方式
* SheetData sheet = ExportZipUtil.fromTypedData("电站信息",
* Arrays.asList("编码", "名称", "经度"),
* Arrays.asList(Station::getStcd, Station::getStnm, Station::getLgtd),
* stationList);
* }</pre>
*
* @author system
* @since 2026-07-31
*/
public class ExportZipUtil {
private static final Logger log = LoggerFactory.getLogger(ExportZipUtil.class);
private ExportZipUtil() {
// 工具类禁止实例化
}
// ======================== 数据模型 ========================
/**
* 单个 Sheet 页数据定义
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class SheetData {
/** Sheet 页名称 */
private String sheetName;
/** 表头列名集合 */
private List<String> headers;
/** 数据行集合(每行是一个 Object 列表,与 headers 一一对应) */
private List<List<Object>> rows;
}
/**
* 单个 Excel 文件数据定义一个 Excel = 多个 Sheet
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class ExcelData {
/** Excel 文件名(不含 .xlsx 扩展名,在 ZIP 中自动添加) */
private String fileName;
/** 该 Excel 包含的所有 Sheet 页 */
private List<SheetData> sheets;
}
// ======================== 核心导出方法 ========================
/**
* 导出 ZIP 压缩包到 HttpServletResponse浏览器下载
*
* @param response HttpServletResponse
* @param zipFileName 下载文件名 .zip 扩展名
* @param excelList 要导出的 Excel 数据集合
*/
public static void exportToResponse(HttpServletResponse response, String zipFileName, List<ExcelData> excelList) {
response.setContentType("application/zip");
response.setCharacterEncoding("UTF-8");
try {
response.setHeader("Content-Disposition",
"attachment; filename=" + URLEncoder.encode(zipFileName, "UTF-8"));
} catch (UnsupportedEncodingException e) {
response.setHeader("Content-Disposition", "attachment; filename=export.zip");
}
try (ServletOutputStream os = response.getOutputStream()) {
exportToStream(os, excelList);
os.flush();
} catch (IOException e) {
log.error("导出 ZIP 到 Response 失败", e);
throw new RuntimeException("导出失败: " + e.getMessage(), e);
}
}
/**
* 导出 ZIP 压缩包到本地文件
*
* @param targetFile 目标文件
* @param excelList 要导出的 Excel 数据集合
*/
public static void exportToFile(File targetFile, List<ExcelData> excelList) {
try (FileOutputStream fos = new FileOutputStream(targetFile)) {
exportToStream(fos, excelList);
} catch (IOException e) {
log.error("导出 ZIP 到文件失败: {}", targetFile.getAbsolutePath(), e);
throw new RuntimeException("导出失败: " + e.getMessage(), e);
}
}
/**
* 导出 ZIP 压缩包到任意 OutputStream
* <p>
* 实现原理先将每个 Excel 写入临时字节数组再用内存 ZIP 打包避免磁盘 IO
*
* @param os 目标输出流
* @param excelList 要导出的 Excel 数据集合
*/
public static void exportToStream(OutputStream os, List<ExcelData> excelList) throws IOException {
if (excelList == null || excelList.isEmpty()) {
log.warn("导出数据为空,跳过 ZIP 生成");
return;
}
// 延迟创建 ZIP累计所有 Excel 字节数组
ByteArrayOutputStream zipBaos = new ByteArrayOutputStream();
try (java.util.zip.ZipOutputStream zos = new java.util.zip.ZipOutputStream(zipBaos)) {
for (ExcelData excelData : excelList) {
if (excelData.getSheets() == null || excelData.getSheets().isEmpty()) {
continue;
}
byte[] excelBytes = workbookToBytes(createWorkbook(excelData));
String entryName = sanitizeFileName(excelData.getFileName()) + ".xlsx";
java.util.zip.ZipEntry entry = new java.util.zip.ZipEntry(entryName);
zos.putNextEntry(entry);
zos.write(excelBytes);
zos.closeEntry();
}
zos.finish();
}
os.write(zipBaos.toByteArray());
}
// ======================== Excel 生成方法 ========================
/**
* 根据 ExcelData 创建一个完整的 Excel Workbook含所有 Sheet
* <p>
* 使用 SXSSFWorkbook流式写入支持大数据量导出内存占用可控
*
* @param excelData 单个 Excel 的数据定义
* @return 填充完毕的 Workbook调用方负责关闭以释放临时文件
*/
public static Workbook createWorkbook(ExcelData excelData) {
SXSSFWorkbook workbook = new SXSSFWorkbook(100); // 内存中保留 100 其余写临时文件
CellStyle headerStyle = createHeaderStyle(workbook);
CellStyle dataStyle = createDataStyle(workbook);
for (SheetData sheetData : excelData.getSheets()) {
if (sheetData == null) {
continue;
}
fillSheet(workbook, sheetData, headerStyle, dataStyle);
}
return workbook;
}
/**
* 填充单个 Sheet 页的数据
*
* @param workbook 目标 Workbook
* @param sheetData Sheet 数据定义
* @param headerStyle 表头样式
* @param dataStyle 数据行样式
*/
public static void fillSheet(Workbook workbook, SheetData sheetData, CellStyle headerStyle, CellStyle dataStyle) {
String name = safeName(sheetData.getSheetName());
Sheet sheet = workbook.createSheet(name);
// SXSSFSheet 必须在写入数据前开启列追踪否则 autoSizeColumn 会报错
if (sheet instanceof SXSSFSheet) {
((SXSSFSheet) sheet).trackAllColumnsForAutoSizing();
}
List<String> headers = sheetData.getHeaders();
List<List<Object>> rows = sheetData.getRows();
int colCount = headers != null ? headers.size() : 0;
// 设置列宽默认值后续在数据写入后统一自动调整
int currentRow = 0;
// ---- 写表头 ----
if (headers != null && !headers.isEmpty()) {
Row headerRow = sheet.createRow(currentRow++);
headerRow.setHeightInPoints(22);
for (int i = 0; i < colCount; i++) {
Cell cell = headerRow.createCell(i);
cell.setCellValue(headers.get(i));
cell.setCellStyle(headerStyle);
}
}
// ---- 写数据行 ----
if (rows != null) {
for (List<Object> rowData : rows) {
Row dataRow = sheet.createRow(currentRow++);
for (int i = 0; i < Math.min(rowData.size(), colCount); i++) {
Cell cell = dataRow.createCell(i);
Object value = rowData.get(i);
setCellValue(cell, value);
cell.setCellStyle(dataStyle);
}
}
}
// ---- 自动调整列宽 ----
autoSizeColumns(sheet, colCount);
// 冻结首行表头固定
if (currentRow > 1) {
sheet.createFreezePane(0, 1);
}
}
// ======================== 样式定义 ========================
/**
* 创建表头样式蓝色背景白色加粗字体居中对齐带边框
*/
public static CellStyle createHeaderStyle(Workbook workbook) {
CellStyle style = workbook.createCellStyle();
// 背景色深蓝
style.setFillForegroundColor(IndexedColors.ROYAL_BLUE.getIndex());
style.setFillPattern(FillPatternType.SOLID_FOREGROUND);
// 字体白色加粗
Font font = workbook.createFont();
font.setBold(true);
font.setColor(IndexedColors.WHITE.getIndex());
font.setFontHeightInPoints((short) 11);
style.setFont(font);
// 对齐居中
style.setAlignment(HorizontalAlignment.CENTER);
style.setVerticalAlignment(VerticalAlignment.CENTER);
// 边框
setThinBorder(style);
return style;
}
/**
* 创建数据行样式无背景正常字体左对齐带边框
*/
public static CellStyle createDataStyle(Workbook workbook) {
CellStyle style = workbook.createCellStyle();
style.setAlignment(HorizontalAlignment.LEFT);
style.setVerticalAlignment(VerticalAlignment.CENTER);
// 自动换行
style.setWrapText(true);
setThinBorder(style);
return style;
}
/**
* 设置细线边框
*/
private static void setThinBorder(CellStyle style) {
style.setBorderTop(BorderStyle.THIN);
style.setBorderRight(BorderStyle.THIN);
style.setBorderBottom(BorderStyle.THIN);
style.setBorderLeft(BorderStyle.THIN);
}
// ======================== 列宽自动调整 ========================
/**
* 自动调整列宽根据内容宽度
* <p>
* 遍历每一列取表头和所有数据行中最宽的内容作为列宽基准
*
* @param sheet Sheet 对象
* @param colCount 列数
*/
public static void autoSizeColumns(Sheet sheet, int colCount) {
for (int i = 0; i < colCount; i++) {
sheet.autoSizeColumn(i);
// 自动列宽后留一点余量避免内容紧贴边框
int currentWidth = sheet.getColumnWidth(i);
if (currentWidth > 0) {
sheet.setColumnWidth(i, Math.min(currentWidth + 512, 255 * 256)); // 最大 255 字符宽
}
}
}
// ======================== 辅助方法 ========================
/**
* 类型安全方式构建 SheetData
* <p>
* 适用于已有实体类 List 的场景通过方法引用指定字段取值避免手动逐字段提取
*
* <pre>{@code
* SheetData data = ExportZipUtil.fromTypedData("电站信息",
* Arrays.asList("编码", "名称", "经度", "纬度"),
* Arrays.asList(Station::getStcd, Station::getStnm, Station::getLgtd, Station::getLttd),
* stationList);
* }</pre>
*
* @param sheetName Sheet 名称
* @param headers 表头列表
* @param extractors 字段取值函数 headers 一一对应
* @param data 实体数据集合
* @param <T> 实体类型
* @return 构建好的 SheetData
*/
public static <T> SheetData fromTypedData(String sheetName,
List<String> headers,
List<Function<T, Object>> extractors,
List<T> data) {
List<List<Object>> rows = data.stream()
.map(entity -> extractors.stream()
.map(ext -> ext.apply(entity))
.collect(Collectors.toList()))
.collect(Collectors.toList());
return new SheetData(sheetName, headers, rows);
}
/**
* Workbook 写入字节数组并关闭 Workbook
*/
private static byte[] workbookToBytes(Workbook workbook) throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try {
workbook.write(baos);
} finally {
workbook.close();
// SXSSFWorkbook 会清理临时文件
if (workbook instanceof SXSSFWorkbook) {
((SXSSFWorkbook) workbook).dispose();
}
}
return baos.toByteArray();
}
/**
* 文件名非法字符过滤Windows + 通用
*/
private static String sanitizeFileName(String fileName) {
if (fileName == null || fileName.isEmpty()) {
return "sheet";
}
return fileName.replaceAll("[\\\\/:*?\"<>|]", "_");
}
/**
* Sheet 名称安全处理Excel Sheet 名最长 31 字符
*/
private static String safeName(String name) {
if (name == null || name.isEmpty()) {
return "Sheet1";
}
// Excel Sheet 名禁止包含 [ ] : * ? / \
String safe = name.replaceAll("[\\[\\]:*?/\\\\]", "_");
return safe.length() > 31 ? safe.substring(0, 31) : safe;
}
/**
* 设置单元格值自动适配类型
*/
private static void setCellValue(Cell cell, Object value) {
if (value == null) {
cell.setCellValue("");
} else if (value instanceof Number) {
cell.setCellValue(((Number) value).doubleValue());
} else if (value instanceof Boolean) {
cell.setCellValue((Boolean) value);
} else if (value instanceof java.util.Date) {
cell.setCellValue((java.util.Date) value);
} else {
cell.setCellValue(String.valueOf(value));
}
}
// ======================== 测试方法 ========================
/**
* 本地运行测试生成模拟数据 ZIP 文件到 D:/export_sample.zip
*/
public static void main(String[] args) {
// ---------- Excel 1电站基础数据1个Sheet基础信息 ----------
List<List<Object>> stationRows = new ArrayList<>();
for (int i = 1; i <= 50; i++) {
stationRows.add(Arrays.asList(
"STCD" + String.format("%05d", i), // 测站编码
"XX水电站" + i + "", // 测站名称
"长江流域", // 所在流域
110.123456 + Math.random() * 10, // 经度
30.654321 + Math.random() * 10, // 纬度
"坝式水电站", // 工程类型
Math.random() > 0.5 ? "运行中" : "在建", // 建设状态
"省级", // 级别
2000 + (i % 24) // 建成时间
));
}
SheetData stationSheet = new SheetData("电站基础信息",
Arrays.asList("测站编码", "测站名称", "所在流域", "经度", "纬度",
"工程类型", "建设状态", "级别", "建成时间"),
stationRows);
// ---------- Sheet 2水位监测数据 ----------
List<List<Object>> monitorRows = new ArrayList<>();
String[] months = {"2026-01", "2026-02", "2026-03", "2026-04", "2026-05", "2026-06"};
for (int i = 1; i <= 30; i++) {
for (String month : months) {
monitorRows.add(Arrays.asList(
"STCD" + String.format("%05d", i),
"XX水电站" + i + "",
month,
String.format("%.2f", 100 + Math.random() * 50), // 水位(m)
String.format("%.2f", 500 + Math.random() * 2000), // 流量(m³/s)
String.format("%.1f", 15 + Math.random() * 20), // 水温()
Math.random() > 0.8 ? "超标" : "正常" // 水质状态
));
}
}
SheetData monitorSheet = new SheetData("水位监测数据",
Arrays.asList("测站编码", "测站名称", "监测月份", "水位(m)", "流量(m³/s)",
"水温(℃)", "水质状态"),
monitorRows);
// ---------- Excel 2流域概况数据 ----------
List<List<Object>> basinRows = new ArrayList<>();
String[] basinNames = {"长江流域", "黄河流域", "珠江流域", "淮河流域", "松花江流域", "海河流域"};
String[] provinces = {"湖北/湖南/四川", "河南/山东/陕西", "广东/广西", "安徽/江苏", "黑龙江/吉林", "河北/天津"};
for (int i = 0; i < basinNames.length; i++) {
basinRows.add(Arrays.asList(
"RV" + String.format("%03d", i + 1), // 流域编码
basinNames[i], // 流域名称
i == 0 ? "一级" : "二级", // 级别
provinces[i], // 覆盖省份
100 + Math.random() * 900, // 流域面积(万km²)
500 + Math.random() * 5000, // 年径流量(亿m³)
(int) (1000 + Math.random() * 2000), // 电站数量
"良好" // 生态状态
));
}
SheetData basinSheet = new SheetData("流域概况",
Arrays.asList("流域编码", "流域名称", "级别", "覆盖省份", "流域面积(万km²)",
"年径流量(亿m³)", "电站数量(个)", "生态状态"),
basinRows);
// ---------- Sheet 4鱼道设施数据 ----------
List<List<Object>> fishwayRows = new ArrayList<>();
String[] types = {"竖缝式鱼道", "池堰式鱼道", "升鱼机", "集运鱼船"};
for (int i = 1; i <= 20; i++) {
fishwayRows.add(Arrays.asList(
"STCD" + String.format("%05d", i),
"XX水电站" + i + "",
types[i % types.length], // 鱼道类型
String.format("%.1f", 5 + Math.random() * 95), // 过鱼效率(%)
String.format("%.2f", 50 + Math.random() * 200), // 鱼道长度(m)
String.format("%.1f", 2 + Math.random() * 8), // 鱼道宽度(m)
Math.random() > 0.3 ? "运行中" : "检修中", // 运行状态
"2025-0" + (1 + i % 9) + "-01" // 投运日期
));
}
SheetData fishwaySheet = new SheetData("鱼道设施",
Arrays.asList("电站编码", "电站名称", "鱼道类型", "过鱼效率(%)",
"鱼道长度(m)", "鱼道宽度(m)", "运行状态", "投运日期"),
fishwayRows);
// ---------- 组装导出 ----------
ExcelData excel1 = new ExcelData("电站综合数据", Arrays.asList(stationSheet, monitorSheet));
ExcelData excel2 = new ExcelData("流域与鱼道数据", Arrays.asList(basinSheet, fishwaySheet));
String outputPath = "D:/export_sample.zip";
try {
log.info("开始生成测试数据...");
exportToFile(new File(outputPath), Arrays.asList(excel1, excel2));
log.info("导出成功!文件路径: {}", outputPath);
System.out.println("导出成功!文件路径: " + outputPath);
} catch (Exception e) {
log.error("导出失败", e);
System.err.println("导出失败: " + e.getMessage());
}
}
}