diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/ReferenceAwareCleaner.java b/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/ReferenceAwareCleaner.java new file mode 100644 index 00000000..085a5b4d --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/ReferenceAwareCleaner.java @@ -0,0 +1,58 @@ +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.Predicate; + +/** + * 引用感知清理决策(task-163)。 + * + * 清理流程对每个文件先做引用判定:被引用(job/result/chunk/scope 引用)→ 跳过; + * 仅未引用文件进入清理。本类固化决策逻辑(清理执行本身由各清理服务负责)。 + */ +@Service +public class ReferenceAwareCleaner { + + public enum CleanDecision { + /** 被引用,跳过清理。 */ + SKIP_REFERENCED, + /** 未引用,可清理。 */ + CLEAN, + } + + public record FileDecision(File file, CleanDecision decision) { + } + + /** + * 单文件决策:引用判定抛异常时保守跳过(宁可保留不可误删)。 + */ + public CleanDecision decide(File file, Predicate isReferenced) { + if (file == null || isReferenced == null) { + return CleanDecision.CLEAN; + } + try { + return isReferenced.test(file.getName()) + ? CleanDecision.SKIP_REFERENCED + : CleanDecision.CLEAN; + } catch (Exception ex) { + return CleanDecision.SKIP_REFERENCED; + } + } + + /** + * 批量决策:混合批次中仅未引用文件 CLEAN;引用判定失败的文件保守跳过。 + */ + public List decideBatch(List files, Predicate isReferenced) { + List decisions = new ArrayList<>(); + if (files == null) { + return decisions; + } + for (File file : files) { + decisions.add(new FileDecision(file, decide(file, isReferenced))); + } + return decisions; + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/file/service/ReferenceAwareCleanerTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/file/service/ReferenceAwareCleanerTest.java new file mode 100644 index 00000000..fb1a1f63 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/file/service/ReferenceAwareCleanerTest.java @@ -0,0 +1,92 @@ +package com.nanri.aiimage.modules.file.service; + +import com.nanri.aiimage.modules.file.service.ReferenceAwareCleaner.CleanDecision; +import com.nanri.aiimage.modules.file.service.ReferenceAwareCleaner.FileDecision; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * task-163:引用中文件不清契约(plan 09)。 + * 被 job/result 等引用的文件 → 跳过清理;仅未引用文件清理;引用判定失败保守 + * 跳过;批量混合;幂等。 + */ +class ReferenceAwareCleanerTest { + + private final ReferenceAwareCleaner cleaner = new ReferenceAwareCleaner(); + + private final File jobReferenced = new File("target/tmp/job-ref.tmp"); + private final File resultReferenced = new File("target/tmp/result-ref.tmp"); + private final File unreferenced = new File("target/tmp/free.tmp"); + + @Test + void jobReferencedFileIsSkipped() { + CleanDecision decision = cleaner.decide(jobReferenced, name -> name.equals("job-ref.tmp")); + + assertEquals(CleanDecision.SKIP_REFERENCED, decision, "被 job 引用 → 跳过清理"); + } + + @Test + void resultReferencedFileIsSkipped() { + CleanDecision decision = cleaner.decide(resultReferenced, name -> name.equals("result-ref.tmp")); + + assertEquals(CleanDecision.SKIP_REFERENCED, decision, "被 result 引用 → 跳过清理"); + } + + @Test + void unreferencedFilePasses() { + CleanDecision decision = cleaner.decide(unreferenced, name -> false); + + assertEquals(CleanDecision.CLEAN, decision, "未引用文件可清理"); + } + + @Test + void multiReferenceAnyHitSkips() { + // 多引用来源:chunk/scope/job/result 任一命中即跳过 + CleanDecision decision = cleaner.decide(jobReferenced, + name -> name.equals("other") || name.equals("job-ref.tmp") || name.equals("x")); + + assertEquals(CleanDecision.SKIP_REFERENCED, decision); + } + + @Test + void referenceReleasedAfterHistoryDeleteAllowsClean() { + // 删除历史后引用解除:谓词从 true 变 false → 可清理 + CleanDecision before = cleaner.decide(jobReferenced, name -> true); + CleanDecision after = cleaner.decide(jobReferenced, name -> false); + + assertEquals(CleanDecision.SKIP_REFERENCED, before); + assertEquals(CleanDecision.CLEAN, after, "引用解除后可清理"); + } + + @Test + void batchMixedOnlyUnreferencedCleans() { + List decisions = cleaner.decideBatch( + List.of(jobReferenced, unreferenced, resultReferenced), + name -> name.equals("job-ref.tmp") || name.equals("result-ref.tmp")); + + assertEquals(3, decisions.size()); + assertEquals(CleanDecision.SKIP_REFERENCED, decisions.get(0).decision()); + assertEquals(CleanDecision.CLEAN, decisions.get(1).decision(), "仅未引用文件清理"); + assertEquals(CleanDecision.SKIP_REFERENCED, decisions.get(2).decision()); + } + + @Test + void nullInputsAreSafe() { + assertEquals(CleanDecision.CLEAN, cleaner.decide(null, name -> true)); + assertEquals(CleanDecision.CLEAN, cleaner.decide(unreferenced, null)); + assertTrue(cleaner.decideBatch(null, name -> false).isEmpty()); + } + + @Test + void checkIsIdempotent() { + CleanDecision first = cleaner.decide(jobReferenced, name -> name.equals("job-ref.tmp")); + CleanDecision second = cleaner.decide(jobReferenced, name -> name.equals("job-ref.tmp")); + + assertEquals(first, second, "同输入同决策"); + } +}