From 7a43ed6c4304a123138557a33de60c7a9e79926d 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:30:53 +0800 Subject: [PATCH] =?UTF-8?q?task-163:=20=E5=BC=95=E7=94=A8=E4=B8=AD?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E4=B8=8D=E6=B8=85=E5=A5=91=E7=BA=A6=EF=BC=88?= =?UTF-8?q?job/result=20=E5=BC=95=E7=94=A8=E8=B7=B3=E8=BF=87=E3=80=81?= =?UTF-8?q?=E5=BC=95=E7=94=A8=E8=A7=A3=E9=99=A4=E5=90=8E=E5=8F=AF=E6=B8=85?= =?UTF-8?q?=E3=80=81=E5=88=A4=E5=AE=9A=E5=A4=B1=E8=B4=A5=E4=BF=9D=E5=AE=88?= =?UTF-8?q?=E8=B7=B3=E8=BF=87=E3=80=81=E6=89=B9=E9=87=8F=E6=B7=B7=E5=90=88?= =?UTF-8?q?=E3=80=81=E5=B9=82=E7=AD=89=EF=BC=89+=208=20=E6=9D=A1=E6=B5=8B?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../file/service/ReferenceAwareCleaner.java | 58 ++++++++++++ .../service/ReferenceAwareCleanerTest.java | 92 +++++++++++++++++++ 2 files changed, 150 insertions(+) create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/file/service/ReferenceAwareCleaner.java create mode 100644 backend-java/src/test/java/com/nanri/aiimage/modules/file/service/ReferenceAwareCleanerTest.java 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, "同输入同决策"); + } +}