diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/TempOrphanInspector.java b/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/TempOrphanInspector.java index 1f4e22a5..67ab77ae 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/TempOrphanInspector.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/TempOrphanInspector.java @@ -83,4 +83,53 @@ public class TempOrphanInspector { } return report; } + + /** + * 超保留期仍被引用巡检(task-160)。 + * + * 只读:临时文件超保留期但仍被任务引用的清单(供人工处理);绝不删除。 + * 与 {@link #inspectOrphanFiles} 互补(该处报"未引用",此处报"被引用")。 + */ + public OrphanFileReport inspectOverRetentionReferenced(File dir, Instant expireBefore, Predicate isReferenced) { + List entries = new ArrayList<>(); + if (dir == null || !dir.isDirectory() || expireBefore == null) { + return new OrphanFileReport(entries, 0); + } + try (Stream paths = Files.walk(dir.toPath())) { + paths.filter(Files::isRegularFile).forEach(path -> { + File file = path.toFile(); + Instant lastModified = TempFileMetadata.lastModified(file); + if (!lastModified.isBefore(expireBefore)) { + return; + } + boolean referenced; + try { + referenced = isReferenced != null && isReferenced.test(file.getName()); + } catch (Exception ex) { + log.warn("over-retention reference check failed file={} err={}", + file.getName(), ex.getMessage()); + return; + } + if (referenced) { + entries.add(new OrphanFileEntry( + path.toString(), + file.length(), + lastModified.toString())); + } + }); + } catch (IOException ex) { + log.warn("over-retention inspect walk failed dir={} err={}", dir.getAbsolutePath(), ex.getMessage()); + } + OrphanFileReport report = new OrphanFileReport(entries, + entries.stream().mapToInt(entry -> (int) Math.min(Integer.MAX_VALUE, entry.sizeBytes())).sum()); + if (!report.isEmpty()) { + log.info("temp over-retention referenced report: count={} dir={}", + report.entries().size(), dir.getAbsolutePath()); + for (OrphanFileEntry entry : report.entries()) { + log.info("temp over-retention referenced: path={} sizeBytes={} lastModifiedAt={}", + entry.path(), entry.sizeBytes(), entry.lastModifiedAt()); + } + } + return report; + } } diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/file/service/OverRetentionReferencedTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/file/service/OverRetentionReferencedTest.java new file mode 100644 index 00000000..267047c4 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/file/service/OverRetentionReferencedTest.java @@ -0,0 +1,119 @@ +package com.nanri.aiimage.modules.file.service; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.time.temporal.ChronoUnit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * task-160:超保留期仍被引用巡检契约(plan 09)。 + * 临时文件超保留期但仍有引用 → 检出(供人工处理);未超期不报; + * 超期未引用不报(归孤立文件报表);只读;可重复。 + */ +class OverRetentionReferencedTest { + + @TempDir + Path tempDir; + + private final TempOrphanInspector inspector = new TempOrphanInspector(); + + private Path writeFile(String name, String content) throws Exception { + Path path = tempDir.resolve(name); + Files.writeString(path, content, StandardCharsets.UTF_8); + return path; + } + + @Test + void overRetentionReferencedDetected() throws Exception { + writeFile("kept.tmp", "x"); + + var report = inspector.inspectOverRetentionReferenced( + tempDir.toFile(), Instant.now().plus(1, ChronoUnit.HOURS), name -> name.equals("kept.tmp")); + + assertEquals(1, report.entries().size(), "超期且被引用必须检出"); + } + + @Test + void inRetentionNotReported() throws Exception { + writeFile("fresh.tmp", "x"); + + var report = inspector.inspectOverRetentionReferenced( + tempDir.toFile(), Instant.now().minus(1, ChronoUnit.HOURS), name -> true); + + assertTrue(report.isEmpty(), "未超保留期不报"); + } + + @Test + void overRetentionUnreferencedNotReportedHere() throws Exception { + writeFile("orphan.tmp", "x"); + + var report = inspector.inspectOverRetentionReferenced( + tempDir.toFile(), Instant.now().plus(1, ChronoUnit.HOURS), name -> false); + + assertTrue(report.isEmpty(), "超期未引用归孤立文件报表,不在此处重复报"); + } + + @Test + void boundaryRetentionIsGraceful() throws Exception { + writeFile("edge.tmp", "x"); + Instant now = Instant.now(); + + var report = inspector.inspectOverRetentionReferenced(tempDir.toFile(), now, name -> true); + + // 边界(mtime 与边界接近):不抛错、报表确定 + assertTrue(report.isEmpty() || report.entries().size() == 1); + } + + @Test + void emptyReportWhenNothingReferenced() throws Exception { + writeFile("a.tmp", "x"); + + var report = inspector.inspectOverRetentionReferenced( + tempDir.toFile(), Instant.now().plus(1, ChronoUnit.HOURS), name -> false); + + assertTrue(report.isEmpty()); + } + + @Test + void readOnlyDoesNotDelete() throws Exception { + Path file = writeFile("referenced.tmp", "keep"); + + inspector.inspectOverRetentionReferenced( + tempDir.toFile(), Instant.now().plus(1, ChronoUnit.HOURS), name -> true); + + assertTrue(Files.exists(file), "只读巡检不得删除"); + } + + @Test + void reportIncludesDetails() throws Exception { + writeFile("detail.tmp", "0123456789"); + + var report = inspector.inspectOverRetentionReferenced( + tempDir.toFile(), Instant.now().plus(1, ChronoUnit.HOURS), name -> true); + + TempOrphanInspector.OrphanFileEntry entry = report.entries().getFirst(); + assertTrue(entry.path().contains("detail.tmp")); + assertEquals(10L, entry.sizeBytes()); + assertTrue(entry.lastModifiedAt() != null && !entry.lastModifiedAt().isBlank()); + } + + @Test + void rerunIsSafeAndStable() throws Exception { + writeFile("rerun.tmp", "x"); + + var first = inspector.inspectOverRetentionReferenced( + tempDir.toFile(), Instant.now().plus(1, ChronoUnit.HOURS), name -> true); + var second = inspector.inspectOverRetentionReferenced( + tempDir.toFile(), Instant.now().plus(1, ChronoUnit.HOURS), name -> true); + + assertEquals(first.entries().size(), second.entries().size()); + assertEquals(first.entries().getFirst().path(), second.entries().getFirst().path()); + } +}