From 70d724382defcceeb1f1d017c64bcc82e8670035 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Sat, 29 Aug 2026 18:34:02 +0800 Subject: [PATCH] =?UTF-8?q?task-23:=20=E5=9B=BE=E7=89=87=E5=B5=8C=E5=85=A5?= =?UTF-8?q?=E6=88=90=E5=8A=9F=E5=90=8E=E7=AB=8B=E5=8D=B3=E9=87=8A=E6=94=BE?= =?UTF-8?q?=E5=A4=96=E9=83=A8=E7=BC=A9=E7=95=A5=E5=9B=BE=E5=AD=97=E8=8A=82?= =?UTF-8?q?=E5=89=AF=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BoundedImageCache.release(url) 移除条目并扣减字节计数,byte[] 可被 GC 回收; embedImage 在 addPicture 成功后立即释放,同一 URL 后续行复用 pictureIndex 不重复嵌入。 全量测试 532 通过。 --- .../ShopDataCrawlExcelAssemblyService.java | 2 + .../shopdatacrawl/util/BoundedImageCache.java | 15 ++ .../util/ShopDataCrawlImageReleaseTest.java | 211 ++++++++++++++++++ 3 files changed, 228 insertions(+) create mode 100644 backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/util/ShopDataCrawlImageReleaseTest.java diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlExcelAssemblyService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlExcelAssemblyService.java index bf8d9bc5..490bc941 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlExcelAssemblyService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlExcelAssemblyService.java @@ -246,6 +246,8 @@ public class ShopDataCrawlExcelAssemblyService { if (pictureIndex == null) { pictureIndex = workbook.addPicture(image.bytes(), Workbook.PICTURE_TYPE_JPEG); pictureIndexes.put(normalizedUrl, pictureIndex); + // 嵌入成功后立即释放缩略图字节副本,byte[] 可被 GC 回收;后续行复用 pictureIndex。 + imageCache.release(normalizedUrl); } Drawing drawing = sheet.createDrawingPatriarch(); ClientAnchor anchor = workbook.getCreationHelper().createClientAnchor(); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/util/BoundedImageCache.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/util/BoundedImageCache.java index 2eb30f29..9ef3b1fa 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/util/BoundedImageCache.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/util/BoundedImageCache.java @@ -82,6 +82,21 @@ public class BoundedImageCache extends LinkedHashMap eldest = entrySet().iterator().next(); sizeBytes -= eldest.getValue().bytes().length; diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/util/ShopDataCrawlImageReleaseTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/util/ShopDataCrawlImageReleaseTest.java new file mode 100644 index 00000000..5a2f41aa --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/util/ShopDataCrawlImageReleaseTest.java @@ -0,0 +1,211 @@ +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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Task 23:图片嵌入成功后立即释放外部缩略图字节副本。 + * BoundedImageCache.release(url) 在嵌入成功后移除条目并扣减字节计数, + * 使缩略图 byte[] 可被 GC 回收,同一 URL 后续行复用已登记的 pictureIndex; + * 嵌入失败(图片缺失)时条目保留,不误释放。 + */ +class ShopDataCrawlImageReleaseTest { + @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"; + + @Test + void test_task_023_image_release_normal_default_path() { + // 正常输入:release 返回被释放条目并扣减字节计数,重复 release 安全。 + BoundedImageCache cache = new BoundedImageCache(10_000, 100); + SimilarAsinImageEmbedder.ResizedImage imageA = resizedImage(100); + cache.put(URL_A, imageA); + assertEquals(100, cache.sizeBytes()); + + SimilarAsinImageEmbedder.ResizedImage released = cache.release(URL_A); + assertEquals(imageA, released, "release 返回被释放的条目"); + assertNull(cache.get(URL_A), "条目已移除"); + assertEquals(0, cache.sizeBytes(), "字节计数归零"); + assertNull(cache.release(URL_A), "重复 release 安全返回 null"); + assertEquals(0, cache.sizeBytes()); + } + + @Test + void test_task_023_image_release_normal_multiple_items() { + // 批量场景:多个 URL 全部嵌入后逐一释放,字节计数精确归零。 + BoundedImageCache cache = new BoundedImageCache(10_000, 100); + cache.put(URL_A, resizedImage(100)); + cache.put(URL_B, resizedImage(200)); + cache.put("https://thumb.example/C.jpg", resizedImage(300)); + assertEquals(600, cache.sizeBytes()); + + assertEquals(100, cache.release(URL_A).bytes().length); + assertEquals(500, cache.sizeBytes()); + assertEquals(200, cache.release(URL_B).bytes().length); + assertEquals(300, cache.sizeBytes()); + cache.release("https://thumb.example/C.jpg"); + assertEquals(0, cache.sizeBytes(), "全部释放后字节归零"); + assertEquals(0, cache.size()); + } + + @Test + void test_task_023_image_release_normal_repeated_operation_is_idempotent() throws Exception { + // 重复执行:同一 URL 多行嵌入只释放一次,后续行复用 pictureIndex,不产生重复图。 + SimilarAsinImageEmbedder imageEmbedder = mock(SimilarAsinImageEmbedder.class); + when(imageEmbedder.fetchAndResizeForCache(URL_A)) + .thenReturn(new SimilarAsinImageEmbedder.ResizedImage(jpegBytes(), 2, 2)); + ShopDataCrawlExcelAssemblyService service = + new ShopDataCrawlExcelAssemblyService(imageEmbedder, 10_000); + + List items = new ArrayList<>(); + for (int i = 0; i < 5; i++) { + ShopDataCrawlRowDto row = new ShopDataCrawlRowDto(); + row.setDate("2026-07-25"); + row.setAsin("B0" + String.format("%08d", i)); + row.setCommodityImage(URL_A); + 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("release.xlsx").toFile(); + service.writeWorkbook(output, items); + try (XSSFWorkbook workbook = new XSSFWorkbook(new FileInputStream(output))) { + assertEquals(1, workbook.getAllPictures().size(), "同一 URL 只嵌入一张图"); + assertEquals(5, workbook.getSheet("英国").getLastRowNum(), "5 行全部写入"); + } + } + + @Test + void test_task_023_image_release_boundary_empty_input() { + // 空输入:空缓存 release 任意 URL 安全返回 null,不抛异常。 + BoundedImageCache cache = new BoundedImageCache(10_000, 100); + assertNull(cache.release(URL_A)); + assertNull(cache.release("")); + assertNull(cache.release(null)); + assertEquals(0, cache.sizeBytes()); + assertEquals(0, cache.size()); + } + + @Test + void test_task_023_image_release_boundary_single_item() throws Exception { + // 单元素:单 URL 单行嵌入后字节立即归零,缓存为空。 + SimilarAsinImageEmbedder imageEmbedder = mock(SimilarAsinImageEmbedder.class); + when(imageEmbedder.fetchAndResizeForCache(URL_B)) + .thenReturn(new SimilarAsinImageEmbedder.ResizedImage(jpegBytes(), 2, 2)); + ShopDataCrawlExcelAssemblyService service = + new ShopDataCrawlExcelAssemblyService(imageEmbedder, 10_000); + + ShopDataCrawlRowDto row = new ShopDataCrawlRowDto(); + row.setDate("2026-07-25"); + row.setAsin("B000000001"); + row.setCommodityImage(URL_B); + ShopDataCrawlCountryResultDto country = new ShopDataCrawlCountryResultDto(); + country.setCountry("UK"); + country.setItems(List.of(row)); + ShopDataCrawlResultItemVo item = new ShopDataCrawlResultItemVo(); + item.setSuccess(true); + item.setCountryResults(List.of(country)); + + File output = tempDir.resolve("release-single.xlsx").toFile(); + service.writeWorkbook(output, List.of(item)); + try (XSSFWorkbook workbook = new XSSFWorkbook(new FileInputStream(output))) { + assertEquals(1, workbook.getAllPictures().size()); + } + } + + @Test + void test_task_023_image_release_boundary_limit_and_overflow() { + // 上限/超限:释放后字节预算恢复,可继续放入新图而不触发淘汰。 + BoundedImageCache cache = new BoundedImageCache(100, 10); + cache.put(URL_A, resizedImage(100)); + assertEquals(100, cache.sizeBytes()); + assertEquals(0, cache.evictionCount(), "单图占满预算"); + + cache.release(URL_A); + cache.put(URL_B, resizedImage(90)); + assertEquals(90, cache.sizeBytes()); + assertEquals(0, cache.evictionCount(), "释放后新图放入不触发淘汰"); + assertEquals(0, cache.rejectedCount()); + } + + @Test + void test_task_023_image_release_invalid_input_rejected() { + // 非法参数:release 对空白 key 安全返回 null;cache 本身非法构造仍被拒绝。 + BoundedImageCache cache = new BoundedImageCache(10_000, 100); + assertNull(cache.release(" ")); + assertNull(cache.release("\t")); + assertEquals(0, cache.size()); + } + + @Test + void test_task_023_image_release_dependency_failure_releases_resources() throws Exception { + // 依赖失败:图片下载失败(fetch 返回 null)时兜底 URL 文本, + // 其他已成功嵌入的条目正常释放,失败 URL 不产生残留字节。 + SimilarAsinImageEmbedder imageEmbedder = mock(SimilarAsinImageEmbedder.class); + when(imageEmbedder.fetchAndResizeForCache(URL_A)) + .thenReturn(new SimilarAsinImageEmbedder.ResizedImage(jpegBytes(), 2, 2)); + when(imageEmbedder.fetchAndResizeForCache("https://thumb.example/fail.jpg")).thenReturn(null); + ShopDataCrawlExcelAssemblyService service = + new ShopDataCrawlExcelAssemblyService(imageEmbedder, 10_000); + + List items = new ArrayList<>(); + for (int i = 0; i < 4; i++) { + ShopDataCrawlRowDto row = new ShopDataCrawlRowDto(); + row.setDate("2026-07-25"); + row.setAsin("B0" + String.format("%08d", i)); + row.setCommodityImage(i % 2 == 0 ? URL_A : "https://thumb.example/fail.jpg"); + 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("release-fail.xlsx").toFile(); + service.writeWorkbook(output, items); + try (XSSFWorkbook workbook = new XSSFWorkbook(new FileInputStream(output))) { + assertEquals(1, workbook.getAllPictures().size(), "成功图片嵌入,失败图片兜底"); + assertEquals("https://thumb.example/fail.jpg", + workbook.getSheet("英国").getRow(2).getCell(2).getStringCellValue()); + } + } + + 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(); + } +}