task-25: 店铺结果 workbook 的 SXSSF 流式写入路径

评估结论:SXSSF 无法访问/删除模板行,与现有模板重写流程不兼容,
故模板路径保留 XSSFWorkbook,另提供 writeWorkbookStreaming —— 空构造
SXSSFWorkbook + 自建 5 国工作表与表头,行数据按 rowAccessWindow spill 到磁盘,
写入后 dispose() 释放 spill 文件;图片嵌入复用现有链路,失败行兜底 URL 文本。
全量测试 548 通过。
This commit is contained in:
2026-08-29 18:46:40 +08:00
parent f816fed4f5
commit 4e4af7911a
2 changed files with 358 additions and 2 deletions
@@ -18,6 +18,7 @@ import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.xssf.usermodel.XSSFDrawing;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.apache.poi.xssf.streaming.SXSSFWorkbook;
import org.openxmlformats.schemas.drawingml.x2006.spreadsheetDrawing.CTTwoCellAnchor;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Service;
@@ -141,6 +142,60 @@ public class ShopDataCrawlExcelAssemblyService {
return rowsByCountry(items).values().stream().mapToInt(List::size).sum();
}
/**
* Task 25SXSSF 流式写入路径。空构造 SXSSFWorkbook + 自建 5 个国家工作表与表头,
* 数据行按 rowAccessWindow 数量 spill 到磁盘临时文件,写入后 dispose() 释放 spill 文件。
* 模板路径(validateTemplate/样式/累计替换)保留 XSSFWorkbook 不变。
* 返回实际写入的数据行数;图片下载失败的行兜底为 URL 文本,不中断整表。
*/
public int writeWorkbookStreaming(File outputXlsx, List<ShopDataCrawlResultItemVo> items, int rowAccessWindow) {
if (outputXlsx == null) {
throw new BusinessException("输出文件路径不能为空");
}
if (rowAccessWindow <= 0) {
throw new IllegalArgumentException("rowAccessWindow 必须为正数,实际 " + rowAccessWindow);
}
if (items == null) {
throw new IllegalArgumentException("items 不能为 null");
}
SXSSFWorkbook workbook = new SXSSFWorkbook(rowAccessWindow);
try (FileOutputStream output = new FileOutputStream(outputXlsx)) {
Map<String, List<ShopDataCrawlRowDto>> rowsByCountry = rowsByCountry(items);
BoundedImageCache imageCache = newImageCache();
prefetchImages(rowsByCountry, imageCache);
Map<String, Integer> pictureIndexes = new LinkedHashMap<>();
for (int i = 0; i < COUNTRIES.size(); i++) {
writeStreamingSheet(workbook, i, rowsByCountry.get(COUNTRIES.get(i)), imageCache, pictureIndexes);
}
workbook.write(output);
return rowsByCountry.values().stream().mapToInt(List::size).sum();
} catch (BusinessException ex) {
throw ex;
} catch (Exception ex) {
throw new BusinessException("生成店铺数据抓取流式 Excel 失败: " + ex.getMessage());
} finally {
workbook.dispose();
}
}
private void writeStreamingSheet(Workbook workbook,
int index,
List<ShopDataCrawlRowDto> rows,
BoundedImageCache imageCache,
Map<String, Integer> pictureIndexes) {
Sheet sheet = workbook.createSheet(SHEETS.get(index));
Row header = sheet.createRow(0);
for (int column = 0; column < HEADERS.size(); column++) {
header.createCell(column).setCellValue(HEADERS.get(column));
}
sheet.setColumnWidth(IMAGE_COLUMN, IMAGE_COLUMN_WIDTH);
int rowIndex = 1;
for (ShopDataCrawlRowDto value : rows == null ? List.<ShopDataCrawlRowDto>of() : rows) {
Row row = sheet.createRow(rowIndex++);
writeDataRow(workbook, sheet, row, value, new CellStyle[HEADERS.size()], imageCache, pictureIndexes);
}
}
void validateTemplate(XSSFWorkbook workbook) {
if (workbook.getNumberOfSheets() != SHEETS.size()) {
throw new BusinessException("店铺数据抓取模板工作表数量不正确");
@@ -205,7 +260,7 @@ public class ShopDataCrawlExcelAssemblyService {
}
}
private void writeDataRow(XSSFWorkbook workbook,
private void writeDataRow(Workbook workbook,
Sheet sheet,
Row row,
ShopDataCrawlRowDto value,
@@ -249,7 +304,7 @@ public class ShopDataCrawlExcelAssemblyService {
return currentTemplate || outputColumn < IMAGE_COLUMN ? outputColumn : outputColumn - 1;
}
private void embedImage(XSSFWorkbook workbook,
private void embedImage(Workbook workbook,
Sheet sheet,
Row row,
String imageUrl,