task-155: 孤立文件巡检报表(只读巡检超保留期且无引用文件,报表含路径/大小/时间,绝不删除,可重复)+ 8 条测试
This commit is contained in:
+86
@@ -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<OrphanFileEntry> 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<String> isReferenced) {
|
||||
List<OrphanFileEntry> entries = new ArrayList<>();
|
||||
if (dir == null || !dir.isDirectory() || expireBefore == null) {
|
||||
return new OrphanFileReport(entries, 0);
|
||||
}
|
||||
try (Stream<Path> 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;
|
||||
}
|
||||
}
|
||||
+121
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user