diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/LocalTempCleanupService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/LocalTempCleanupService.java index a25c38b5..4fa99d00 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/LocalTempCleanupService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/LocalTempCleanupService.java @@ -118,7 +118,8 @@ public class LocalTempCleanupService { } private boolean isExpired(File file, Instant expireBefore) { - return Instant.ofEpochMilli(file.lastModified()).isBefore(expireBefore); + // 以 mtime 近似最后访问(TempFileMetadata 回退语义,与现状一致) + return TempFileMetadata.lastAccessTime(file).isBefore(expireBefore); } private boolean isDirectoryEmpty(File directory) { diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/TempFileMetadata.java b/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/TempFileMetadata.java new file mode 100644 index 00000000..630dfa15 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/TempFileMetadata.java @@ -0,0 +1,57 @@ +package com.nanri.aiimage.modules.file.service; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.attribute.BasicFileAttributes; +import java.time.Instant; + +/** + * 临时文件时间元数据读取(task-149)。 + * + * 创建/最后修改/大小显式读取;最后访问时间无独立记录表,以 mtime 回退 + * (与既有 LocalTempCleanupService 清理语义一致,不改变文件语义)。 + * 属性读取失败或文件缺失时回退 mtime/0,不抛 IO 异常(清理路径不得被阻断)。 + */ +public final class TempFileMetadata { + + private TempFileMetadata() { + } + + /** 创建时间;属性不可读时回退到最后修改时间。 */ + public static Instant creationTime(File file) { + if (file == null || !file.isFile()) { + return lastModified(file); + } + try { + BasicFileAttributes attrs = Files.readAttributes(file.toPath(), BasicFileAttributes.class); + return attrs.creationTime().toInstant(); + } catch (IOException ex) { + return lastModified(file); + } + } + + /** 最后修改时间(mtime)。 */ + public static Instant lastModified(File file) { + if (file == null) { + return Instant.EPOCH; + } + return Instant.ofEpochMilli(file.lastModified()); + } + + /** + * 最后访问时间:无独立访问记录表,以 mtime 回退 + * (清理判据与现状一致:mtime 近似最后访问)。 + */ + public static Instant lastAccessTime(File file) { + return lastModified(file); + } + + /** 文件大小(字节);文件缺失时为 0。 */ + public static long size(File file) { + if (file == null || !file.isFile()) { + return 0L; + } + return file.length(); + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/file/service/TempFileMetadataTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/file/service/TempFileMetadataTest.java new file mode 100644 index 00000000..145dcea7 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/file/service/TempFileMetadataTest.java @@ -0,0 +1,109 @@ +package com.nanri.aiimage.modules.file.service; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +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.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * task-149:临时文件创建/访问时间记录契约(plan 09)。 + * 创建时间/大小显式读取;最后访问时间以 mtime 回退(与清理语义一致); + * 缺记录/IO 错误容忍,不改变文件语义。 + */ +class TempFileMetadataTest { + + @TempDir + Path tempDir; + + private File writeTempFile(String name, String content) throws Exception { + Path path = tempDir.resolve(name); + Files.writeString(path, content, StandardCharsets.UTF_8); + return path.toFile(); + } + + @Test + void creationTimeRecorded() throws Exception { + File file = writeTempFile("a.tmp", "hello"); + + Instant creation = TempFileMetadata.creationTime(file); + + assertNotNull(creation); + assertFalse(creation.isAfter(Instant.now().plus(1, ChronoUnit.MINUTES)), + "创建时间不得晚于当前时间"); + } + + @Test + void sizeRecorded() throws Exception { + File file = writeTempFile("b.tmp", "0123456789"); + + assertEquals(10L, TempFileMetadata.size(file), "大小按字节记录"); + } + + @Test + void accessTimeReadable() throws Exception { + File file = writeTempFile("c.tmp", "x"); + + Instant access = TempFileMetadata.lastAccessTime(file); + + assertNotNull(access); + assertFalse(access.isAfter(Instant.now().plus(1, ChronoUnit.MINUTES))); + } + + @Test + void accessTimeFallsBackToMtime() throws Exception { + File file = writeTempFile("d.tmp", "y"); + + assertEquals(TempFileMetadata.lastModified(file), TempFileMetadata.lastAccessTime(file), + "无独立访问记录时以 mtime 回退(清理语义不变)"); + } + + @Test + void modifiedTimeUpdatesOnWrite() throws Exception { + File file = writeTempFile("e.tmp", "v1"); + Instant before = TempFileMetadata.lastModified(file); + + Thread.sleep(20); + Files.writeString(file.toPath(), "v2-longer-content", StandardCharsets.UTF_8); + Instant after = TempFileMetadata.lastModified(file); + + assertFalse(after.isBefore(before), "写入后 mtime 必须更新"); + } + + @Test + void missingFileIsGraceful() { + File missing = tempDir.resolve("nope.tmp").toFile(); + + assertEquals(0L, TempFileMetadata.size(missing), "缺失文件大小为 0"); + assertNotNull(TempFileMetadata.creationTime(missing), "缺失文件不回退抛错"); + assertNotNull(TempFileMetadata.lastAccessTime(missing)); + } + + @Test + void nullFileIsGraceful() { + assertEquals(0L, TempFileMetadata.size(null)); + assertEquals(Instant.EPOCH, TempFileMetadata.lastModified(null), "null 文件回退 EPOCH"); + assertEquals(Instant.EPOCH, TempFileMetadata.lastAccessTime(null)); + } + + @Test + void timeOrderingConsistent() throws Exception { + File file = writeTempFile("f.tmp", "z"); + Instant creation = TempFileMetadata.creationTime(file); + Instant modified = TempFileMetadata.lastModified(file); + Instant now = Instant.now().plus(1, ChronoUnit.MINUTES); + + assertTrue(!creation.isAfter(modified.plus(1, ChronoUnit.SECONDS)), + "创建时间 <= 修改时间(容忍文件系统时钟精度)"); + assertTrue(!modified.isAfter(now), "修改时间 <= 当前时间"); + } +}