task-26: 模板 workbook 大行数下的样式、图片和工作表兼容测试

writeWorkbook(模板路径)改为返回实际写入行数,与 streaming 路径一致;
新增 1000/5000 行规模兼容测试:模板表头样式保留、同 URL 图片按 pictureIndex 去重复用、
5 工作表顺序名称保持模板语义、失败行兜底 URL 文本不中断整表。
全量测试 556 通过。
This commit is contained in:
2026-08-29 18:55:01 +08:00
parent 6594e1f5d7
commit 8ea4d2f502
2 changed files with 325 additions and 1 deletions
@@ -83,7 +83,7 @@ public class ShopDataCrawlExcelAssemblyService {
imageEmbedder.prefetch(prefetchBudget().boundedUrls(imageUrls(rowsByCountry)), imageCache);
}
public void writeWorkbook(File outputXlsx, List<ShopDataCrawlResultItemVo> items) {
public int writeWorkbook(File outputXlsx, List<ShopDataCrawlResultItemVo> items) {
try (InputStream input = new ClassPathResource(TEMPLATE).getInputStream();
XSSFWorkbook workbook = new XSSFWorkbook(input);
FileOutputStream output = new FileOutputStream(outputXlsx)) {
@@ -96,6 +96,7 @@ public class ShopDataCrawlExcelAssemblyService {
writeSheet(workbook, workbook.getSheetAt(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) {
@@ -0,0 +1,323 @@
package com.nanri.aiimage.modules.shopdatacrawl.service;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlCountryResultDto;
import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlRowDto;
import com.nanri.aiimage.modules.shopdatacrawl.model.vo.ShopDataCrawlResultItemVo;
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder;
import org.apache.poi.openxml4j.util.ZipSecureFile;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* Task 26:模板 workbook 大行数下的样式、图片和工作表兼容测试。
* writeWorkbookXSSFWorkbook 模板路径)返回实际写入的数据行数,与 streaming 路径一致;
* 大行数(1000/5000)下验证:模板 styleRow 样式传递到写入行、同 URL 图片只嵌入一次、
* 5 个工作表顺序与名称保持模板语义、失败行兜底 URL 文本不中断整表。
*/
class ShopDataCrawlTemplateLargeWorkbookTest {
@TempDir Path tempDir;
private static final String URL_TEMPLATE = "https://thumb.example/img";
@BeforeAll
static void relaxZipSecurity() {
// 大行数下 XSSFWorkbook 写出含大量图片的 xlsx 内部条目数超过 POI 5.2.5 默认防护阈值。
ZipSecureFile.setMaxFileCount(2_000_000L);
ZipSecureFile.setMinInflateRatio(0.0);
}
private static SimilarAsinImageEmbedder okEmbedder() {
SimilarAsinImageEmbedder imageEmbedder = mock(SimilarAsinImageEmbedder.class);
when(imageEmbedder.fetchAndResizeForCache(any())).thenAnswer(
invocation -> new SimilarAsinImageEmbedder.ResizedImage(jpegBytes(), 2, 2));
return imageEmbedder;
}
@Test
void test_task_026_row_count_image_workbook_normal_default_path() throws Exception {
// 正常输入:2000 行 5 国模板写入,返回行数正确;
// 模板表头样式保留(与模板文件一致);5 个工作表顺序/名称保持模板语义;
// 唯一图片各嵌入一次。
SimilarAsinImageEmbedder imageEmbedder = okEmbedder();
ShopDataCrawlExcelAssemblyService service =
new ShopDataCrawlExcelAssemblyService(imageEmbedder, 64L * 1024 * 1024);
List<ShopDataCrawlResultItemVo> items = items(2000, 5);
File output = tempDir.resolve("template-2000.xlsx").toFile();
int written = service.writeWorkbook(output, items);
assertEquals(2000, written, "writeWorkbook 返回实际写入行数");
try (XSSFWorkbook workbook = new XSSFWorkbook(new FileInputStream(output))) {
assertEquals(ShopDataCrawlExcelAssemblyService.SHEETS, sheetNames(workbook));
for (int i = 0; i < 5; i++) {
assertEquals(400, workbook.getSheetAt(i).getLastRowNum(), "每国 400 行");
}
int templateHeaderStyle = templateHeaderStyleIndex();
assertEquals(templateHeaderStyle,
workbook.getSheet("英国").getRow(0).getCell(0).getCellStyle().getIndex(),
"表头样式保留");
assertEquals("2026-07-25",
workbook.getSheet("英国").getRow(1).getCell(0).getStringCellValue(), "数据行文本正确");
assertEquals(2000, workbook.getAllPictures().size(), "2000 个唯一 URL 各嵌入一张图");
assertEquals(80f, workbook.getSheet("英国").getRow(1).getHeightInPoints(), "图片行行高自适应");
}
}
@Test
void test_task_026_row_count_image_workbook_normal_multiple_items() throws Exception {
// 批量场景:两个任务项合并 2000 行,返回行数合计正确,顺序稳定。
ShopDataCrawlExcelAssemblyService service =
new ShopDataCrawlExcelAssemblyService(okEmbedder(), 64L * 1024 * 1024);
List<ShopDataCrawlResultItemVo> all = new ArrayList<>(items(1200, 5));
all.addAll(items(800, 5));
File output = tempDir.resolve("template-multi.xlsx").toFile();
int written = service.writeWorkbook(output, all);
assertEquals(2000, written);
try (XSSFWorkbook workbook = new XSSFWorkbook(new FileInputStream(output))) {
int total = 0;
for (int i = 0; i < 5; i++) {
total += workbook.getSheetAt(i).getLastRowNum();
}
assertEquals(2000, total, "批量合并行数不丢失");
assertEquals(1200, workbook.getAllPictures().size(), "重复 URL 按 pictureIndex 去重复用");
}
}
@Test
void test_task_026_row_count_image_workbook_normal_repeated_operation_is_idempotent() throws Exception {
// 重复执行:同一输入两次模板写入,行数/图片数/样式一致,不产生重复记录。
ShopDataCrawlExcelAssemblyService service =
new ShopDataCrawlExcelAssemblyService(okEmbedder(), 64L * 1024 * 1024);
List<ShopDataCrawlResultItemVo> items = items(1000, 5);
File firstOut = tempDir.resolve("template-idem-1.xlsx").toFile();
File secondOut = tempDir.resolve("template-idem-2.xlsx").toFile();
int first = service.writeWorkbook(firstOut, items);
int second = service.writeWorkbook(secondOut, items);
assertEquals(first, second, "重复写入行数一致");
try (XSSFWorkbook wb1 = new XSSFWorkbook(new FileInputStream(firstOut));
XSSFWorkbook wb2 = new XSSFWorkbook(new FileInputStream(secondOut))) {
assertEquals(wb1.getAllPictures().size(), wb2.getAllPictures().size(), "图片数一致");
assertEquals(wb1.getSheet("英国").getLastRowNum(), wb2.getSheet("英国").getLastRowNum());
assertEquals(wb1.getSheet("英国").getRow(0).getCell(0).getCellStyle().getIndex(),
wb2.getSheet("英国").getRow(0).getCell(0).getCellStyle().getIndex(), "表头样式一致");
assertEquals("B000000000", wb1.getSheet("英国").getRow(1).getCell(1).getStringCellValue(),
"首行数据一致");
}
}
@Test
void test_task_026_row_count_image_workbook_boundary_empty_input() throws Exception {
// 空输入:0 行模板写入返回 0,表头与模板样式保留,无图片。
ShopDataCrawlExcelAssemblyService service =
new ShopDataCrawlExcelAssemblyService(okEmbedder(), 64L * 1024 * 1024);
File output = tempDir.resolve("template-empty.xlsx").toFile();
int written = service.writeWorkbook(output, List.of());
assertEquals(0, written);
try (XSSFWorkbook workbook = new XSSFWorkbook(new FileInputStream(output))) {
for (int i = 0; i < 5; i++) {
assertEquals(0, workbook.getSheetAt(i).getLastRowNum(), "只有表头行");
assertEquals(ShopDataCrawlExcelAssemblyService.HEADERS.get(0),
workbook.getSheetAt(i).getRow(0).getCell(0).getStringCellValue(), "表头保留");
}
assertEquals(0, workbook.getAllPictures().size());
}
}
@Test
void test_task_026_row_count_image_workbook_boundary_single_item() throws Exception {
// 单元素:1 行模板写入返回 1,样式/图片完整,不依赖批量路径。
ShopDataCrawlExcelAssemblyService service =
new ShopDataCrawlExcelAssemblyService(okEmbedder(), 64L * 1024 * 1024);
List<ShopDataCrawlResultItemVo> items = items(1, 1);
File output = tempDir.resolve("template-single.xlsx").toFile();
int written = service.writeWorkbook(output, items);
assertEquals(1, written);
try (XSSFWorkbook workbook = new XSSFWorkbook(new FileInputStream(output))) {
assertEquals(1, workbook.getSheet("英国").getLastRowNum());
assertEquals("B000000000", workbook.getSheet("英国").getRow(1).getCell(1).getStringCellValue());
assertEquals(1, workbook.getAllPictures().size());
assertEquals(templateHeaderStyleIndex(),
workbook.getSheet("英国").getRow(0).getCell(0).getCellStyle().getIndex(),
"表头样式保留");
}
}
@Test
void test_task_026_row_count_image_workbook_boundary_limit_and_overflow() throws Exception {
// 上限/超限:5000 行(MAX_ROWS 规模)模板写入在预算内完成,行数完整;
// 失败图片行兜底 URL 文本,成功图片各嵌入一次,不发生无界图片堆积。
SimilarAsinImageEmbedder imageEmbedder = mock(SimilarAsinImageEmbedder.class);
when(imageEmbedder.fetchAndResizeForCache(any())).thenAnswer(invocation -> {
String url = invocation.getArgument(0);
if (url.contains("fail")) {
return null;
}
return new SimilarAsinImageEmbedder.ResizedImage(jpegBytes(), 2, 2);
});
ShopDataCrawlExcelAssemblyService service =
new ShopDataCrawlExcelAssemblyService(imageEmbedder, 64L * 1024 * 1024);
List<ShopDataCrawlResultItemVo> items = items(5000, 5);
for (int i = 0; i < 5000; i += 2) {
setImage(items, i, "https://thumb.example/fail" + i + ".jpg");
}
File output = tempDir.resolve("template-5000.xlsx").toFile();
long start = System.currentTimeMillis();
int written = service.writeWorkbook(output, items);
long elapsed = System.currentTimeMillis() - start;
assertEquals(5000, written);
assertTrue(elapsed < 60_000, "5000 行模板写入须在预算内完成,实际=" + elapsed + "ms");
assertEquals(5000, service.countRows(items), "countRows 与写入行数一致");
try (XSSFWorkbook workbook = new XSSFWorkbook(new FileInputStream(output))) {
int total = 0;
for (int i = 0; i < 5; i++) {
total += workbook.getSheetAt(i).getLastRowNum();
}
assertEquals(5000, total, "读回行数完整");
assertEquals(2500, workbook.getAllPictures().size(), "2500 行成功图片嵌入");
}
}
@Test
void test_task_026_row_count_image_workbook_invalid_input_rejected() throws Exception {
// 非法参数:null 输出路径/失败项(success=false)被跳过/无结果项 → 明确行为。
ShopDataCrawlExcelAssemblyService service =
new ShopDataCrawlExcelAssemblyService(okEmbedder(), 64L * 1024 * 1024);
List<ShopDataCrawlResultItemVo> items = items(10, 1);
assertThrows(BusinessException.class, () -> service.writeWorkbook(null, items));
ShopDataCrawlResultItemVo failed = new ShopDataCrawlResultItemVo();
failed.setSuccess(false);
failed.setError("抓取失败");
List<ShopDataCrawlResultItemVo> withFailed = new ArrayList<>(items);
withFailed.add(failed);
File output = tempDir.resolve("template-invalid.xlsx").toFile();
assertEquals(10, service.writeWorkbook(output, withFailed), "失败项被跳过");
File empty = tempDir.resolve("template-none.xlsx").toFile();
assertEquals(0, service.writeWorkbook(empty, null), "null items 安全返回 0");
}
@Test
void test_task_026_row_count_image_workbook_dependency_failure_releases_resources() throws Exception {
// 依赖失败:图片下载抛异常 → 单行兜底 URL 文本,整表行数完整、无图片;
// 目标目录不可写抛 BusinessException;重试成功不残留临时状态。
SimilarAsinImageEmbedder imageEmbedder = mock(SimilarAsinImageEmbedder.class);
when(imageEmbedder.fetchAndResizeForCache(any())).thenThrow(
new RuntimeException("image service down"));
ShopDataCrawlExcelAssemblyService service =
new ShopDataCrawlExcelAssemblyService(imageEmbedder, 64L * 1024 * 1024);
List<ShopDataCrawlResultItemVo> items = items(100, 1);
File output = tempDir.resolve("template-fail.xlsx").toFile();
int written = service.writeWorkbook(output, items);
assertEquals(100, written, "图片失败不阻塞行写入");
try (XSSFWorkbook workbook = new XSSFWorkbook(new FileInputStream(output))) {
assertEquals(100, workbook.getSheet("英国").getLastRowNum());
assertEquals(0, workbook.getAllPictures().size(), "无图片嵌入");
assertEquals(URL_TEMPLATE + "-0.jpg",
workbook.getSheet("英国").getRow(1).getCell(2).getStringCellValue(), "兜底 URL 文本");
}
File locked = tempDir.resolve("locked").toFile();
assertTrue(locked.mkdir(), "目录占位模拟不可写目标");
assertThrows(BusinessException.class, () -> service.writeWorkbook(locked, items));
File retried = tempDir.resolve("template-retry.xlsx").toFile();
assertEquals(100, service.writeWorkbook(retried, items), "失败后重试成功");
}
private static int templateHeaderStyleIndex() throws Exception {
try (org.apache.poi.ss.usermodel.Workbook template =
new XSSFWorkbook(new org.springframework.core.io.ClassPathResource(
"templates/shop-data-crawl/文档格式.xlsx").getInputStream())) {
return template.getSheetAt(0).getRow(0).getCell(0).getCellStyle().getIndex();
}
}
private static void setImage(List<ShopDataCrawlResultItemVo> items, int index, String url) {
int i = 0;
for (ShopDataCrawlResultItemVo item : items) {
for (ShopDataCrawlCountryResultDto country : item.getCountryResults()) {
for (ShopDataCrawlRowDto row : country.getItems()) {
if (i == index) {
row.setCommodityImage(url);
return;
}
i++;
}
}
}
}
private static List<ShopDataCrawlResultItemVo> items(int rowCount, int countryCount) {
List<ShopDataCrawlResultItemVo> items = new ArrayList<>();
ShopDataCrawlResultItemVo item = new ShopDataCrawlResultItemVo();
item.setSuccess(true);
List<ShopDataCrawlCountryResultDto> countryResults = new ArrayList<>();
List<String> countries = ShopDataCrawlExcelAssemblyService.COUNTRIES.subList(0, countryCount);
for (String country : countries) {
ShopDataCrawlCountryResultDto countryResult = new ShopDataCrawlCountryResultDto();
countryResult.setCountry(country);
countryResult.setItems(new ArrayList<>());
countryResults.add(countryResult);
}
for (int i = 0; i < rowCount; i++) {
ShopDataCrawlRowDto row = new ShopDataCrawlRowDto();
row.setDate("2026-07-25");
row.setAsin("B0" + String.format("%08d", i));
row.setBrand("Brand");
row.setCommodityImage(URL_TEMPLATE + "-" + i + ".jpg");
row.setInventorySales("11");
row.setSalesRank("22");
row.setPageViews("33");
row.setUnitsSold("44");
row.setPrice("12.50");
row.setRecommendedOffer("12.00");
countryResults.get(i % countryCount).getItems().add(row);
}
item.setCountryResults(countryResults);
items.add(item);
return items;
}
private static List<String> sheetNames(XSSFWorkbook workbook) {
List<String> names = new ArrayList<>();
for (int i = 0; i < workbook.getNumberOfSheets(); i++) {
names.add(workbook.getSheetAt(i).getSheetName());
}
return names;
}
private static byte[] jpegBytes() throws Exception {
BufferedImage image = new BufferedImage(2, 2, BufferedImage.TYPE_INT_RGB);
ByteArrayOutputStream output = new ByteArrayOutputStream();
ImageIO.write(image, "jpg", output);
return output.toByteArray();
}
}