task-22: 店铺 Excel 图片缓存替换为有界字节缓存
BoundedImageCache(字节预算+条目上限,FIFO 淘汰,超预算单图拒绝)替换 ShopDataCrawlExcelAssemblyService 中的无界 ConcurrentHashMap;单图超预算时 embed 走原有直接下载兜底。全量测试 524 通过。
This commit is contained in:
+204
@@ -0,0 +1,204 @@
|
||||
package com.nanri.aiimage.modules.shopdatacrawl.util;
|
||||
|
||||
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.shopdatacrawl.service.ShopDataCrawlExcelAssemblyService;
|
||||
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder;
|
||||
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||
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 java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* Task 22:将店铺 Excel 图片缓存替换为有界字节缓存。
|
||||
* BoundedImageCache 以总字节预算 + 条目上限约束图片缓存,超过预算按 FIFO 淘汰最旧条目,
|
||||
* 单图超过预算时拒绝缓存(embed 阶段走原有直接下载兜底),保证内存峰值有界。
|
||||
* ShopDataCrawlExcelAssemblyService 的 workbook 组装改用该缓存,语义与无界缓存一致。
|
||||
*/
|
||||
class ShopDataCrawlBoundedImageCacheTest {
|
||||
@TempDir Path tempDir;
|
||||
|
||||
private static final String URL_A = "https://thumb.example/A.jpg";
|
||||
private static final String URL_B = "https://thumb.example/B.jpg";
|
||||
private static final String URL_C = "https://thumb.example/C.jpg";
|
||||
|
||||
@Test
|
||||
void test_task_022_image_cache_excel_normal_default_path() {
|
||||
// 正常输入:预算充足时全部放入,get 命中,字节合计正确,无淘汰。
|
||||
BoundedImageCache cache = new BoundedImageCache(10_000, 100);
|
||||
SimilarAsinImageEmbedder.ResizedImage imageA = resizedImage(100);
|
||||
SimilarAsinImageEmbedder.ResizedImage imageB = resizedImage(200);
|
||||
cache.put(URL_A, imageA);
|
||||
cache.put(URL_B, imageB);
|
||||
|
||||
assertEquals(imageA, cache.get(URL_A), "A 命中");
|
||||
assertEquals(imageB, cache.get(URL_B), "B 命中");
|
||||
assertEquals(300, cache.sizeBytes(), "字节合计正确");
|
||||
assertEquals(2, cache.size());
|
||||
assertEquals(0, cache.evictionCount(), "预算充足不淘汰");
|
||||
assertEquals(0, cache.rejectedCount(), "无拒绝");
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_022_image_cache_excel_normal_multiple_items() {
|
||||
// 批量场景:大量图片超过字节预算 → 最旧条目被淘汰,最新条目可命中,总字节有界。
|
||||
BoundedImageCache cache = new BoundedImageCache(1_000, 100);
|
||||
List<SimilarAsinImageEmbedder.ResizedImage> images = new ArrayList<>();
|
||||
for (int i = 0; i < 50; i++) {
|
||||
SimilarAsinImageEmbedder.ResizedImage image = resizedImage(100);
|
||||
images.add(image);
|
||||
cache.put(URL_A + i, image);
|
||||
}
|
||||
assertTrue(cache.sizeBytes() <= 1_000, "总字节不得超过预算,实际=" + cache.sizeBytes());
|
||||
assertTrue(cache.size() <= 10, "1_000 预算 / 100 每图最多容纳 10 张");
|
||||
assertTrue(cache.evictionCount() >= 40, "超出部分被淘汰");
|
||||
assertNull(cache.get(URL_A + "0"), "最旧条目已淘汰");
|
||||
assertNotNull(cache.get(URL_A + "49"), "最新条目仍可命中");
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_022_image_cache_excel_normal_repeated_operation_is_idempotent() {
|
||||
// 重复执行:同一 URL 重复 put 替换不增加字节;putIfAbsent 不覆盖已有值。
|
||||
BoundedImageCache cache = new BoundedImageCache(1_000, 100);
|
||||
SimilarAsinImageEmbedder.ResizedImage first = resizedImage(100);
|
||||
SimilarAsinImageEmbedder.ResizedImage second = resizedImage(100);
|
||||
cache.put(URL_A, first);
|
||||
cache.put(URL_A, second);
|
||||
assertEquals(100, cache.sizeBytes(), "替换不重复计入字节");
|
||||
assertEquals(1, cache.size());
|
||||
|
||||
SimilarAsinImageEmbedder.ResizedImage replaced = cache.putIfAbsent(URL_A, resizedImage(200));
|
||||
assertEquals(second, replaced, "putIfAbsent 返回已有值");
|
||||
assertEquals(100, cache.sizeBytes(), "putIfAbsent 不替换字节");
|
||||
assertEquals(0, cache.evictionCount(), "重复操作不触发淘汰");
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_022_image_cache_excel_boundary_empty_input() {
|
||||
// 空输入:空缓存 get 返回 null;null/空集合安全。
|
||||
BoundedImageCache cache = new BoundedImageCache(1_000, 100);
|
||||
assertNull(cache.get(URL_A));
|
||||
assertEquals(0, cache.size());
|
||||
assertEquals(0, cache.sizeBytes());
|
||||
assertNull(cache.putIfAbsent(null, resizedImage(10)), "null key 不放入");
|
||||
assertEquals(0, cache.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_022_image_cache_excel_boundary_single_item() {
|
||||
// 单元素边界:单图恰好等于预算可放入;剩余预算恰够时刚好放下。
|
||||
BoundedImageCache exact = new BoundedImageCache(100, 100);
|
||||
SimilarAsinImageEmbedder.ResizedImage image = resizedImage(100);
|
||||
exact.put(URL_A, image);
|
||||
assertEquals(image, exact.get(URL_A), "单图等于预算可缓存");
|
||||
assertEquals(100, exact.sizeBytes());
|
||||
|
||||
BoundedImageCache tight = new BoundedImageCache(150, 100);
|
||||
tight.put(URL_A, resizedImage(100));
|
||||
tight.put(URL_B, resizedImage(50));
|
||||
assertEquals(2, tight.size(), "剩余预算恰够时恰好放下");
|
||||
assertEquals(0, tight.evictionCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_022_image_cache_excel_boundary_limit_and_overflow() {
|
||||
// 上限/超限:单图超过预算被拒绝缓存且不占用预算;总字节永不超过预算。
|
||||
BoundedImageCache cache = new BoundedImageCache(100, 100);
|
||||
SimilarAsinImageEmbedder.ResizedImage huge = resizedImage(200);
|
||||
cache.put(URL_A, huge);
|
||||
assertNull(cache.get(URL_A), "单图超预算拒绝缓存");
|
||||
assertEquals(0, cache.sizeBytes(), "被拒绝的图不占用预算");
|
||||
assertEquals(1, cache.rejectedCount());
|
||||
|
||||
cache.put(URL_B, resizedImage(60));
|
||||
cache.put(URL_C, resizedImage(60));
|
||||
assertEquals(1, cache.size(), "第二次放入触发淘汰,仅保留最新");
|
||||
assertNull(cache.get(URL_B));
|
||||
assertNotNull(cache.get(URL_C));
|
||||
assertEquals(60, cache.sizeBytes(), "总字节不超预算");
|
||||
assertTrue(cache.evictionCount() >= 1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_022_image_cache_excel_invalid_input_rejected() {
|
||||
// 非法参数:非正预算/非正条目上限 → 拒绝;null 值拒绝。
|
||||
assertThrows(IllegalArgumentException.class, () -> new BoundedImageCache(0, 100));
|
||||
assertThrows(IllegalArgumentException.class, () -> new BoundedImageCache(-1, 100));
|
||||
assertThrows(IllegalArgumentException.class, () -> new BoundedImageCache(1_000, 0));
|
||||
BoundedImageCache cache = new BoundedImageCache(1_000, 100);
|
||||
assertThrows(IllegalArgumentException.class, () -> cache.put(null, resizedImage(10)));
|
||||
assertThrows(IllegalArgumentException.class, () -> cache.put("", resizedImage(10)));
|
||||
assertThrows(IllegalArgumentException.class, () -> cache.put(URL_A, null));
|
||||
assertEquals(0, cache.size(), "非法输入不产生缓存条目");
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_022_image_cache_excel_dependency_failure_releases_resources() throws Exception {
|
||||
// 依赖失败:图片下载失败不占缓存预算,缓存继续可用;
|
||||
// Excel 组装用有界缓存后仍产出完整 workbook,缓存字节有界。
|
||||
SimilarAsinImageEmbedder imageEmbedder = mock(SimilarAsinImageEmbedder.class);
|
||||
String failUrl = "https://thumb.example/fail.jpg";
|
||||
when(imageEmbedder.fetchAndResizeForCache(failUrl)).thenReturn(null);
|
||||
String okUrl = "https://thumb.example/ok.jpg";
|
||||
when(imageEmbedder.fetchAndResizeForCache(okUrl))
|
||||
.thenReturn(new SimilarAsinImageEmbedder.ResizedImage(jpegBytes(), 2, 2));
|
||||
|
||||
long smallBudget = 200;
|
||||
ShopDataCrawlExcelAssemblyService service =
|
||||
new ShopDataCrawlExcelAssemblyService(imageEmbedder, smallBudget);
|
||||
List<ShopDataCrawlResultItemVo> items = new ArrayList<>();
|
||||
for (int i = 0; i < 10; i++) {
|
||||
ShopDataCrawlRowDto row = new ShopDataCrawlRowDto();
|
||||
row.setDate("2026-07-25");
|
||||
row.setAsin("B0" + String.format("%08d", i));
|
||||
row.setCommodityImage(i % 2 == 0 ? okUrl : failUrl);
|
||||
ShopDataCrawlCountryResultDto country = new ShopDataCrawlCountryResultDto();
|
||||
country.setCountry("UK");
|
||||
country.setItems(List.of(row));
|
||||
ShopDataCrawlResultItemVo item = new ShopDataCrawlResultItemVo();
|
||||
item.setSuccess(true);
|
||||
item.setCountryResults(List.of(country));
|
||||
items.add(item);
|
||||
}
|
||||
|
||||
File output = tempDir.resolve("bounded.xlsx").toFile();
|
||||
service.writeWorkbook(output, items);
|
||||
|
||||
try (XSSFWorkbook workbook = new XSSFWorkbook(new FileInputStream(output))) {
|
||||
assertEquals(10, workbook.getSheet("英国").getLastRowNum(), "全部 10 行写入");
|
||||
assertEquals(1, workbook.getAllPictures().size(), "失败图片不嵌入,成功图片去重嵌入");
|
||||
assertEquals("https://thumb.example/fail.jpg",
|
||||
workbook.getSheet("英国").getRow(2).getCell(2).getStringCellValue(),
|
||||
"失败图片兜底为 URL 文本");
|
||||
}
|
||||
}
|
||||
|
||||
private static SimilarAsinImageEmbedder.ResizedImage resizedImage(int bytes) {
|
||||
return new SimilarAsinImageEmbedder.ResizedImage(new byte[bytes], 2, 2);
|
||||
}
|
||||
|
||||
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