From 34f614ea32513eba73adc38d16225e67bef23817 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Wed, 2 Sep 2026 07:35:57 +0800 Subject: [PATCH] =?UTF-8?q?task-164:=20=E6=B8=85=E7=90=86=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E4=B8=8D=E9=98=BB=E6=96=AD=E5=A5=91=E7=BA=A6=EF=BC=88?= =?UTF-8?q?=E5=8D=95=E6=96=87=E4=BB=B6=E5=A4=B1=E8=B4=A5=E5=85=B6=E4=BD=99?= =?UTF-8?q?=E7=BB=A7=E7=BB=AD=E3=80=81=E5=A4=B1=E8=B4=A5=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E5=8F=AF=E8=A7=81=E3=80=81=E4=B8=8D=E4=B8=8A=E6=8A=9B=E3=80=81?= =?UTF-8?q?=E4=B8=8B=E8=BD=AE=E9=87=8D=E8=AF=95=E3=80=81=E5=BC=95=E7=94=A8?= =?UTF-8?q?=E8=B7=B3=E8=BF=87=E4=B8=8D=E8=AE=A1=E5=A4=B1=E8=B4=A5=EF=BC=89?= =?UTF-8?q?+=208=20=E6=9D=A1=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../file/service/BatchFileCleaner.java | 61 +++++++++ .../file/service/BatchFileCleanerTest.java | 122 ++++++++++++++++++ 2 files changed, 183 insertions(+) create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/file/service/BatchFileCleaner.java create mode 100644 backend-java/src/test/java/com/nanri/aiimage/modules/file/service/BatchFileCleanerTest.java diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/BatchFileCleaner.java b/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/BatchFileCleaner.java new file mode 100644 index 00000000..e165a9ab --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/BatchFileCleaner.java @@ -0,0 +1,61 @@ +package com.nanri.aiimage.modules.file.service; + +import org.springframework.stereotype.Service; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Function; +import java.util.function.Predicate; + +/** + * 批量清理执行器(task-164)。 + * + * 单个文件清理失败(删除函数返回 false/抛异常/引用判定失败)不影响其他文件; + * 失败可见(failedPaths 与失败计数);不上抛;下次调用可重试失败项。 + */ +@Service +public class BatchFileCleaner { + + public record BatchCleanResult(int cleanedCount, int failedCount, List failedPaths) { + + public boolean hasFailures() { + return failedCount > 0; + } + } + + /** + * @param files 候选文件 + * @param deleteFn 删除函数(返回 true 表示删除成功) + * @param isReferenced 引用判定(true → 跳过,不算失败) + */ + public BatchCleanResult cleanBatch(List files, Function deleteFn, + Predicate isReferenced) { + int cleaned = 0; + int failed = 0; + List failedPaths = new ArrayList<>(); + if (files == null || files.isEmpty()) { + return new BatchCleanResult(0, 0, failedPaths); + } + for (File file : files) { + if (file == null) { + continue; + } + try { + if (isReferenced != null && isReferenced.test(file.getName())) { + continue; + } + if (deleteFn != null && Boolean.TRUE.equals(deleteFn.apply(file))) { + cleaned++; + } else { + failed++; + failedPaths.add(file.getAbsolutePath()); + } + } catch (Exception ex) { + failed++; + failedPaths.add(file.getAbsolutePath()); + } + } + return new BatchCleanResult(cleaned, failed, List.copyOf(failedPaths)); + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/file/service/BatchFileCleanerTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/file/service/BatchFileCleanerTest.java new file mode 100644 index 00000000..40959f17 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/file/service/BatchFileCleanerTest.java @@ -0,0 +1,122 @@ +package com.nanri.aiimage.modules.file.service; + +import com.nanri.aiimage.modules.file.service.BatchFileCleaner.BatchCleanResult; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.util.List; +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.assertTrue; + +/** + * task-164:清理失败不阻断契约(plan 09)。 + * 单文件失败不影响其他;错误可见(failedPaths);不上抛;下轮可重试; + * 引用判定失败保守跳过不计数为失败。 + */ +class BatchFileCleanerTest { + + private final BatchFileCleaner cleaner = new BatchFileCleaner(); + + private final File a = new File("target/tmp/a.tmp"); + private final File b = new File("target/tmp/b.tmp"); + private final File c = new File("target/tmp/c.tmp"); + + @Test + void oneFailureOthersSucceed() { + BatchCleanResult result = cleaner.cleanBatch( + List.of(a, b, c), + file -> !file.getName().equals("b.tmp"), + name -> false); + + assertEquals(2, result.cleanedCount(), "b 失败其余成功"); + assertEquals(1, result.failedCount()); + assertTrue(result.hasFailures()); + } + + @Test + void failureIsVisible() { + BatchCleanResult result = cleaner.cleanBatch( + List.of(a, b), + file -> !file.getName().equals("b.tmp"), + name -> false); + + assertEquals(1, result.failedPaths().size()); + assertTrue(result.failedPaths().getFirst().contains("b.tmp"), "失败路径可见"); + } + + @Test + void noThrowUpOnFailure() { + BatchCleanResult result = cleaner.cleanBatch( + List.of(a, b), + file -> { + if (file.getName().equals("b.tmp")) { + throw new RuntimeException("delete blew up"); + } + return true; + }, + name -> false); + + assertEquals(1, result.cleanedCount()); + assertEquals(1, result.failedCount(), "抛异常按失败计数不上抛"); + } + + @Test + void retryNextRoundSucceeds() { + AtomicInteger attempts = new AtomicInteger(); + BatchCleanResult first = cleaner.cleanBatch( + List.of(b), + file -> attempts.incrementAndGet() == 1 ? false : true, + name -> false); + BatchCleanResult second = cleaner.cleanBatch( + List.of(b), + file -> attempts.incrementAndGet() > 1, + name -> false); + + assertEquals(0, first.cleanedCount()); + assertEquals(1, second.cleanedCount(), "下轮重试成功"); + } + + @Test + void partialResultReported() { + BatchCleanResult result = cleaner.cleanBatch( + List.of(a, c), + file -> true, + name -> false); + + assertEquals(2, result.cleanedCount()); + assertEquals(0, result.failedCount()); + assertFalse(result.hasFailures()); + } + + @Test + void failureDoesNotBlockRemaining() { + BatchCleanResult result = cleaner.cleanBatch( + List.of(a, b, c), + file -> !file.getName().equals("b.tmp"), + name -> false); + + assertEquals(2, result.cleanedCount(), "b 失败后 c 仍被清理(无级联)"); + } + + @Test + void referencedFilesSkippedNotCountedAsFailure() { + BatchCleanResult result = cleaner.cleanBatch( + List.of(a, b), + file -> true, + name -> name.equals("a.tmp")); + + assertEquals(1, result.cleanedCount(), "被引用文件跳过,不算失败"); + assertEquals(0, result.failedCount()); + } + + @Test + void nullInputsAreSafe() { + BatchCleanResult empty = cleaner.cleanBatch(null, file -> true, name -> false); + assertEquals(0, empty.cleanedCount()); + assertEquals(0, empty.failedCount()); + assertTrue(empty.failedPaths().isEmpty()); + } +}