task-23: 图片嵌入成功后立即释放外部缩略图字节副本

BoundedImageCache.release(url) 移除条目并扣减字节计数,byte[] 可被 GC 回收;
embedImage 在 addPicture 成功后立即释放,同一 URL 后续行复用 pictureIndex 不重复嵌入。
全量测试 532 通过。
This commit is contained in:
2026-08-29 18:34:02 +08:00
parent 4a11fd612e
commit 70d724382d
3 changed files with 228 additions and 0 deletions
@@ -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<ShopDataCrawlResultItemVo> 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<ShopDataCrawlResultItemVo> 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();
}
}