task-26: 模板 workbook 大行数下的样式、图片和工作表兼容测试
writeWorkbook(模板路径)改为返回实际写入行数,与 streaming 路径一致; 新增 1000/5000 行规模兼容测试:模板表头样式保留、同 URL 图片按 pictureIndex 去重复用、 5 工作表顺序名称保持模板语义、失败行兜底 URL 文本不中断整表。 全量测试 556 通过。
This commit is contained in:
+2
-1
@@ -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) {
|
||||
|
||||
+323
@@ -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 大行数下的样式、图片和工作表兼容测试。
|
||||
* writeWorkbook(XSSFWorkbook 模板路径)返回实际写入的数据行数,与 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user