From d64ef5284a0db386dbc4c9f1712b51c4d89c5fb0 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 06:47:03 +0800 Subject: [PATCH] =?UTF-8?q?task-155:=20=E5=AD=A4=E7=AB=8B=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E5=B7=A1=E6=A3=80=E6=8A=A5=E8=A1=A8=EF=BC=88=E5=8F=AA?= =?UTF-8?q?=E8=AF=BB=E5=B7=A1=E6=A3=80=E8=B6=85=E4=BF=9D=E7=95=99=E6=9C=9F?= =?UTF-8?q?=E4=B8=94=E6=97=A0=E5=BC=95=E7=94=A8=E6=96=87=E4=BB=B6=EF=BC=8C?= =?UTF-8?q?=E6=8A=A5=E8=A1=A8=E5=90=AB=E8=B7=AF=E5=BE=84/=E5=A4=A7?= =?UTF-8?q?=E5=B0=8F/=E6=97=B6=E9=97=B4=EF=BC=8C=E7=BB=9D=E4=B8=8D?= =?UTF-8?q?=E5=88=A0=E9=99=A4=EF=BC=8C=E5=8F=AF=E9=87=8D=E5=A4=8D=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/TempOrphanInspector.java | 86 +++++++++++++ .../file/service/TempOrphanInspectorTest.java | 121 ++++++++++++++++++ 2 files changed, 207 insertions(+) create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/file/service/TempOrphanInspector.java create mode 100644 backend-java/src/test/java/com/nanri/aiimage/modules/file/service/TempOrphanInspectorTest.java 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 new file mode 100644 index 00000000..1f4e22a5 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/TempOrphanInspector.java @@ -0,0 +1,86 @@ +package com.nanri.aiimage.modules.file.service; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Predicate; +import java.util.stream.Stream; + +/** + * 临时目录孤立文件巡检(task-155)。 + * + * 只读巡检:输出"无任何引用(或引用判定失败视为未引用候选)且超过保留期"的 + * 文件清单报表(日志 + 返回对象);绝不删除文件。巡检可重复执行。 + */ +@Slf4j +@Service +public class TempOrphanInspector { + + public record OrphanFileEntry(String path, long sizeBytes, String lastModifiedAt) { + } + + public record OrphanFileReport(List entries, int totalBytes) { + + public boolean isEmpty() { + return entries == null || entries.isEmpty(); + } + } + + /** + * 巡检目录内孤立文件。 + * + * @param dir 临时根目录 + * @param expireBefore 保留期边界(lastModified 早于该时刻视为过期) + * @param isReferenced fileKey/文件名 → 是否仍被引用(引用判定失败返回 false 时 + * 文件进候选,由报表人工复核;判定抛异常时保守跳过该文件) + */ + public OrphanFileReport inspectOrphanFiles(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("orphan inspect 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("orphan inspect walk failed dir={} err={}", dir.getAbsolutePath(), ex.getMessage()); + } + int totalBytes = entries.stream().mapToInt(entry -> (int) Math.min(Integer.MAX_VALUE, entry.sizeBytes())).sum(); + OrphanFileReport report = new OrphanFileReport(entries, totalBytes); + if (!report.isEmpty()) { + log.info("temp orphan file report: count={} totalBytes={} dir={}", + report.entries().size(), totalBytes, dir.getAbsolutePath()); + for (OrphanFileEntry entry : report.entries()) { + log.info("temp orphan file: 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/TempOrphanInspectorTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/file/service/TempOrphanInspectorTest.java new file mode 100644 index 00000000..ba239e5f --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/file/service/TempOrphanInspectorTest.java @@ -0,0 +1,121 @@ +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.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * task-155:孤立文件巡检报表契约(plan 09)。 + * 只读巡检:超保留期且无引用的文件输出报表(路径/大小/时间);被引用不报; + * 不删除;批量/空报表/可重复。 + */ +class TempOrphanInspectorTest { + + @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; + } + + private Instant hourAgo() { + return Instant.now().minus(1, ChronoUnit.HOURS); + } + + private Instant hourLater() { + return Instant.now().plus(1, ChronoUnit.HOURS); + } + + @Test + void orphanDetectedWhenExpiredAndUnreferenced() throws Exception { + Path file = writeFile("orphan.tmp", "x".repeat(10)); + + var report = inspector.inspectOrphanFiles(tempDir.toFile(), hourLater(), name -> false); + + assertEquals(1, report.entries().size()); + assertTrue(report.entries().getFirst().path().contains("orphan.tmp")); + } + + @Test + void referencedFileNotReported() throws Exception { + writeFile("kept.tmp", "x"); + + var report = inspector.inspectOrphanFiles(tempDir.toFile(), hourLater(), name -> name.equals("kept.tmp")); + + assertTrue(report.isEmpty(), "被引用文件不得进入报表"); + } + + @Test + void reportIncludesDetails() throws Exception { + Path file = writeFile("detail.tmp", "0123456789"); + + var report = inspector.inspectOrphanFiles(tempDir.toFile(), hourLater(), name -> false); + + assertEquals(1, report.entries().size()); + 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 readOnlyDoesNotDelete() throws Exception { + Path file = writeFile("readonly.tmp", "keep-me"); + + inspector.inspectOrphanFiles(tempDir.toFile(), hourLater(), name -> false); + + assertTrue(Files.exists(file), "巡检只读,不得删除文件"); + } + + @Test + void batchReportListsAllOrphans() throws Exception { + writeFile("a.tmp", "1"); + writeFile("b.tmp", "22"); + writeFile("c.tmp", "333"); + + var report = inspector.inspectOrphanFiles(tempDir.toFile(), hourLater(), name -> false); + + assertEquals(3, report.entries().size()); + assertEquals(6, report.totalBytes()); + } + + @Test + void emptyReportWhenNoOrphans() throws Exception { + writeFile("fresh.tmp", "new"); // 未超保留期 + + var report = inspector.inspectOrphanFiles(tempDir.toFile(), hourAgo(), name -> false); + + assertTrue(report.isEmpty()); + } + + @Test + void rerunIsSafeAndStable() throws Exception { + writeFile("rerun.tmp", "x"); + + var first = inspector.inspectOrphanFiles(tempDir.toFile(), hourLater(), name -> false); + var second = inspector.inspectOrphanFiles(tempDir.toFile(), hourLater(), name -> false); + + assertEquals(first.entries().size(), second.entries().size()); + assertEquals(first.entries().getFirst().path(), second.entries().getFirst().path()); + } + + @Test + void invalidInputsAreGraceful() { + assertTrue(inspector.inspectOrphanFiles(null, hourAgo(), name -> false).isEmpty()); + assertTrue(inspector.inspectOrphanFiles(tempDir.toFile(), null, name -> false).isEmpty()); + assertTrue(inspector.inspectOrphanFiles(tempDir.resolve("absent").toFile(), hourAgo(), name -> false).isEmpty()); + } +}