diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinImageEmbedder.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinImageEmbedder.java index 284f0989..78764d16 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinImageEmbedder.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinImageEmbedder.java @@ -485,11 +485,15 @@ public class SimilarAsinImageEmbedder { try { ResizedImage thumb = readLocalCachedThumb(url); if (thumb == null) { - thumb = fetchAndResizeDirect(url); - downloaded.incrementAndGet(); + thumb = fetchAndResizeDirectForTest(url); + if (thumb == null) { + thumb = fetchAndResizeDirect(url); + downloaded.incrementAndGet(); + } } else { localHit.incrementAndGet(); } + ensureImageWorkNotInterrupted(); imageSpool.put(url, thumb); } catch (Exception ex) { failed.incrementAndGet(); @@ -1233,6 +1237,7 @@ public class SimilarAsinImageEmbedder { private final Path directory; private final ConcurrentMap images = new ConcurrentHashMap<>(); + private volatile boolean closed; public ImageSpool(Path directory) throws IOException { this.directory = Objects.requireNonNull(directory, "directory must not be null") @@ -1240,6 +1245,11 @@ public class SimilarAsinImageEmbedder { Files.createDirectories(this.directory); } + /** Task 18:暴露临时目录,供生命周期校验与日志。 */ + public Path directory() { + return directory; + } + public SpoolImage get(String url) { if (url == null) { return null; @@ -1249,6 +1259,9 @@ public class SimilarAsinImageEmbedder { } public SpoolImage put(String url, ResizedImage image) throws IOException { + if (closed) { + throw new IOException("image spool closed: " + directory); + } if (url == null || url.isBlank() || image == null || image.bytes() == null || image.bytes().length == 0) { throw new IOException("invalid image spool entry"); } @@ -1281,6 +1294,7 @@ public class SimilarAsinImageEmbedder { @Override public void close() throws IOException { + closed = true; images.clear(); if (!Files.exists(directory)) { return; diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinImageEmbedderSpoolLifecycleTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinImageEmbedderSpoolLifecycleTest.java new file mode 100644 index 00000000..30d10fa6 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinImageEmbedderSpoolLifecycleTest.java @@ -0,0 +1,226 @@ +package com.nanri.aiimage.modules.similarasin.util; + +import com.nanri.aiimage.config.SimilarAsinProperties; +import com.nanri.aiimage.modules.file.service.oss.OssStorageService; +import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder.ImageSpool; +import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder.ResizedImage; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +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; + +/** + * Task 18:统一图片 spool 生命周期,确保超时、取消和异常路径删除临时文件。 + * - ImageSpool 增加 closed 状态:close 后 put 拒绝、close 幂等; + * - 长预算 prefetchToDisk 在 deadline/中断后仍会把在途任务写入的残留文件清理掉 + * (ImageSpool.cleanupOrphanFiles 只清理未被索引的文件,已索引文件由 close 兜底); + * - 超时路径取消在途任务后,无新文件产生(put 前中断检查),close 后可删除目录。 + */ +class SimilarAsinImageEmbedderSpoolLifecycleTest { + + private SimilarAsinImageEmbedder embedder; + + @BeforeEach + void setUp() { + embedder = new SimilarAsinImageEmbedder(new SimilarAsinProperties(), mock(OssStorageService.class)); + } + + @AfterEach + void shutdown() { + embedder.shutdown(); + } + + private static ImageSpool newSpool() throws Exception { + return new ImageSpool(Files.createTempDirectory("spool-lifecycle-test-")); + } + + private static ResizedImage resizedImage(int seed) { + return new ResizedImage(new byte[]{(byte) seed, (byte) seed, (byte) seed, (byte) seed}, seed, seed); + } + + /** 通过反射调用私有长预算预取入口。 */ + private static void invokePrefetchToDisk(SimilarAsinImageEmbedder e, List urls, + ImageSpool spool) throws Exception { + Method m = SimilarAsinImageEmbedder.class.getDeclaredMethod( + "prefetchToDisk", java.util.Collection.class, ImageSpool.class); + m.setAccessible(true); + m.invoke(e, urls, spool); + } + + @Test + void test_task_018_image_normal_default_path() throws Exception { + // 正常输入:预取落盘 → close 删除临时目录与全部文件。 + ImageSpool spool = newSpool(); + List urls = List.of("https://img.example.com/a.jpg", "https://img.example.com/b.jpg"); + embedder.registerPrefetchHandler(urls.get(0), () -> resizedImage(1)); + embedder.registerPrefetchResult(urls.get(0), resizedImage(1)); + embedder.registerPrefetchHandler(urls.get(1), () -> resizedImage(2)); + embedder.registerPrefetchResult(urls.get(1), resizedImage(2)); + + invokePrefetchToDisk(embedder, urls, spool); + + assertEquals(2, spool.size(), "预取结果全部落盘"); + spool.close(); + assertFalse(Files.exists(spool.directory()), "close 后临时目录必须删除"); + } + + @Test + void test_task_018_image_normal_multiple_items() throws Exception { + // 批量场景:100 个 url 全部落盘,close 后目录清空。 + ImageSpool spool = newSpool(); + List urls = new ArrayList<>(); + for (int i = 0; i < 100; i++) { + urls.add("https://img.example.com/multi-" + i + ".jpg"); + final int idx = i; + embedder.registerPrefetchHandler(urls.get(i), () -> resizedImage(idx)); + embedder.registerPrefetchResult(urls.get(i), resizedImage(idx)); + } + + invokePrefetchToDisk(embedder, urls, spool); + + assertEquals(100, spool.size()); + spool.close(); + assertFalse(Files.exists(spool.directory())); + } + + @Test + void test_task_018_image_normal_repeated_operation_is_idempotent() throws Exception { + // 重复执行:close 幂等;已索引文件不重复写入(size 不增长)。 + ImageSpool spool = newSpool(); + String url = "https://img.example.com/idem.jpg"; + embedder.registerPrefetchHandler(url, () -> resizedImage(7)); + embedder.registerPrefetchResult(url, resizedImage(7)); + + invokePrefetchToDisk(embedder, List.of(url), spool); + assertEquals(1, spool.size()); + spool.close(); + spool.close(); + assertFalse(Files.exists(spool.directory()), "close 幂等,二次 close 不抛异常"); + + ImageSpool fresh = newSpool(); + invokePrefetchToDisk(embedder, List.of(url), fresh); + invokePrefetchToDisk(embedder, List.of(url), fresh); + assertEquals(1, fresh.size(), "重复预取同一 url 不产生重复文件"); + fresh.close(); + } + + @Test + void test_task_018_image_boundary_empty_input() throws Exception { + // 空输入:空列表预取安全跳过;close 对空 spool 幂等删除。 + ImageSpool spool = newSpool(); + invokePrefetchToDisk(embedder, List.of(), spool); + invokePrefetchToDisk(embedder, null, spool); + assertEquals(0, spool.size()); + spool.close(); + assertFalse(Files.exists(spool.directory())); + } + + @Test + void test_task_018_image_boundary_single_item() throws Exception { + // 单 url:不依赖批量路径,close 后目录删除。 + ImageSpool spool = newSpool(); + String url = "https://img.example.com/single.jpg"; + embedder.registerPrefetchHandler(url, () -> resizedImage(3)); + embedder.registerPrefetchResult(url, resizedImage(3)); + + invokePrefetchToDisk(embedder, List.of(url), spool); + assertEquals(1, spool.size()); + spool.close(); + assertFalse(Files.exists(spool.directory())); + } + + @Test + void test_task_018_image_boundary_limit_and_overflow() throws Exception { + // 超时/取消:阻塞 handler 在 deadline 后被取消,取消后不产生新文件; + // 取消瞬间已在途的写入文件由 close 兜底清理,目录可完整删除。 + ImageSpool spool = newSpool(); + String slow = "https://img.example.com/slow.jpg"; + embedder.registerPrefetchHandler(slow, () -> { + try { + TimeUnit.SECONDS.sleep(30L); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + + Thread caller = new Thread(() -> { + try { + invokePrefetchToDisk(embedder, List.of(slow), spool); + } catch (Exception ignored) { + // 中断或超时路径允许异常 + } + }); + caller.start(); + caller.join(TimeUnit.SECONDS.toMillis(2)); + caller.interrupt(); + caller.join(TimeUnit.SECONDS.toMillis(5)); + + try (var paths = Files.walk(spool.directory())) { + long files = paths.filter(Files::isRegularFile).count(); + spool.close(); + assertFalse(Files.exists(spool.directory()), "取消后 close 必须能删除整个目录,残留文件数=" + files); + } + } + + @Test + void test_task_018_image_invalid_input_rejected() throws Exception { + // 非法参数:null url 预取跳过;close 后 put 抛 IOException(已识别消息)。 + ImageSpool spool = newSpool(); + invokePrefetchToDisk(embedder, java.util.Arrays.asList(null, " "), spool); + assertEquals(0, spool.size()); + spool.close(); + + IOException ioEx = assertThrows(IOException.class, + () -> spool.put("https://img.example.com/late.jpg", resizedImage(9))); + assertTrue(ioEx.getMessage().contains("closed"), "close 后写入应拒绝,消息含 closed"); + assertNull(spool.get("https://img.example.com/late.jpg"), "close 后 get 返回 null"); + assertTrue(spool.size() == 0, "close 后 size 为 0"); + } + + @Test + void test_task_018_image_dependency_failure_releases_resources() throws Exception { + // 依赖失败:预取中单个 url 抛异常不阻断其余;失败路径无残留文件; + // 中断后的恢复重试成功;close 删除目录。 + ImageSpool spool = newSpool(); + List urls = List.of("https://img.example.com/fail.jpg", "https://img.example.com/ok.jpg"); + AtomicInteger failCalls = new AtomicInteger(0); + embedder.registerPrefetchHandler(urls.get(0), () -> { + if (failCalls.getAndIncrement() == 0) { + throw new IllegalStateException("http down"); + } + }); + embedder.registerPrefetchHandler(urls.get(1), () -> resizedImage(2)); + embedder.registerPrefetchResult(urls.get(1), resizedImage(2)); + + invokePrefetchToDisk(embedder, urls, spool); + + assertNull(spool.get(urls.get(0)), "失败 url 不落 spool"); + assertNotNull(spool.get(urls.get(1)), "正常 url 正常落 spool"); + assertEquals(1, spool.size(), "失败路径不残留文件"); + + embedder.registerPrefetchResult(urls.get(0), resizedImage(1)); + invokePrefetchToDisk(embedder, List.of(urls.get(0)), spool); + assertNotNull(spool.get(urls.get(0)), "恢复后重试成功"); + assertEquals(2, spool.size()); + + spool.close(); + assertFalse(Files.exists(spool.directory()), "失败+恢复后 close 仍能删除目录"); + } +}