From dd569edc6694cf6ddbe1cdbaf5eb6bbcb2ef8413 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:08:50 +0800 Subject: [PATCH] =?UTF-8?q?task-149:=20=E4=B8=B4=E6=97=B6=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E6=97=B6=E9=97=B4=E5=85=83=E6=95=B0=E6=8D=AE=E8=A1=A5?= =?UTF-8?q?=E7=BC=BA=EF=BC=88TempFileMetadata=20=E5=88=9B=E5=BB=BA/?= =?UTF-8?q?=E4=BF=AE=E6=94=B9/=E8=AE=BF=E9=97=AE=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E4=B8=8E=E5=A4=A7=E5=B0=8F=E8=AF=BB=E5=8F=96=EF=BC=8Cmtime=20?= =?UTF-8?q?=E5=9B=9E=E9=80=80=E4=B8=8D=E6=94=B9=E5=8F=98=E6=B8=85=E7=90=86?= =?UTF-8?q?=E8=AF=AD=E4=B9=89=EF=BC=8CIO=20=E9=94=99=E8=AF=AF=E5=AE=B9?= =?UTF-8?q?=E5=BF=8D=EF=BC=89+=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/LocalTempCleanupService.java | 3 +- .../file/service/TempFileMetadata.java | 57 +++++++++ .../file/service/TempFileMetadataTest.java | 109 ++++++++++++++++++ 3 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/file/service/TempFileMetadata.java create mode 100644 backend-java/src/test/java/com/nanri/aiimage/modules/file/service/TempFileMetadataTest.java 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), "修改时间 <= 当前时间"); + } +}