diff --git a/backend-java/src/main/java/com/nanri/aiimage/config/StorageProperties.java b/backend-java/src/main/java/com/nanri/aiimage/config/StorageProperties.java index 9351f7cf..a371e892 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/config/StorageProperties.java +++ b/backend-java/src/main/java/com/nanri/aiimage/config/StorageProperties.java @@ -14,6 +14,8 @@ public class StorageProperties { private long sourceRetentionHours = 24; private long resultRetentionHours = 24; private long transientPayloadRetentionHours = 72; + /** 临时目录容量告警阈值(字节),超过时记录告警日志(task-154)。 */ + private long capacityWarnBytes = 50L * 1024 * 1024 * 1024; public String getLocalTempDir() { String configured = localTempDir == null ? "" : localTempDir.trim(); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/TempDirCapacityMonitor.java b/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/TempDirCapacityMonitor.java new file mode 100644 index 00000000..4ff2d7b0 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/TempDirCapacityMonitor.java @@ -0,0 +1,78 @@ +package com.nanri.aiimage.modules.file.service; + +import cn.hutool.core.io.FileUtil; +import com.nanri.aiimage.config.StorageProperties; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +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.util.stream.Stream; + +/** + * 临时目录磁盘容量告警(task-154)。 + * + * 容量超过阈值时记录告警日志(当前容量/阈值);告警带频率限制(默认每 10 分钟 + * 至多一次,防刷屏);测量失败或目录缺失时仅记日志,不抛异常、不阻塞业务。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class TempDirCapacityMonitor { + + private static final long WARN_MIN_INTERVAL_MILLIS = 10 * 60 * 1000L; + + private final StorageProperties storageProperties; + + private volatile long lastWarnAtMillis = 0L; + + @Scheduled(cron = "${aiimage.storage.capacity-warn-cron:0 */30 * * * *}") + public void checkTempDirCapacity() { + File tempDir = FileUtil.file(storageProperties.getLocalTempDir()); + if (!tempDir.exists() || !tempDir.isDirectory()) { + return; + } + long usage = measureUsageBytes(tempDir); + if (usage < 0) { + log.warn("temp dir capacity measurement failed dir={}", tempDir.getAbsolutePath()); + return; + } + long threshold = storageProperties.getCapacityWarnBytes(); + if (usage > threshold) { + warnWithRateLimit(tempDir, usage, threshold); + } + } + + /** 测量目录总字节数;失败返回 -1(不抛)。 */ + public long measureUsageBytes(File dir) { + if (dir == null || !dir.isDirectory()) { + return -1L; + } + try (Stream paths = Files.walk(dir.toPath())) { + return paths.mapToLong(path -> { + try { + return Files.isRegularFile(path) ? Files.size(path) : 0L; + } catch (IOException ex) { + return 0L; + } + }).sum(); + } catch (IOException ex) { + log.warn("temp dir capacity walk failed dir={}", dir.getAbsolutePath(), ex); + return -1L; + } + } + + void warnWithRateLimit(File tempDir, long usage, long threshold) { + long now = System.currentTimeMillis(); + if (now - lastWarnAtMillis < WARN_MIN_INTERVAL_MILLIS) { + return; + } + lastWarnAtMillis = now; + log.warn("temp dir capacity warning: usage={} bytes, threshold={} bytes, dir={}", + usage, threshold, tempDir.getAbsolutePath()); + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/file/service/TempDirCapacityMonitorTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/file/service/TempDirCapacityMonitorTest.java new file mode 100644 index 00000000..0e47fa7f --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/file/service/TempDirCapacityMonitorTest.java @@ -0,0 +1,110 @@ +package com.nanri.aiimage.modules.file.service; + +import com.nanri.aiimage.config.StorageProperties; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.test.util.ReflectionTestUtils; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +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-154:临时目录磁盘容量告警契约(plan 09)。 + * 容量超阈值记录告警(当前容量/阈值);未超不告警;告警频率受限(防刷屏); + * 测量失败容忍;不抛异常不阻塞业务。 + */ +class TempDirCapacityMonitorTest { + + @TempDir + Path tempDir; + + private StorageProperties properties; + private TempDirCapacityMonitor monitor; + + @BeforeEach + void setUp() { + properties = new StorageProperties(); + properties.setLocalTempDir(tempDir.toString()); + monitor = new TempDirCapacityMonitor(properties); + } + + @Test + void usageMeasuredCorrectly() throws Exception { + Files.writeString(tempDir.resolve("a.tmp"), "0123456789", StandardCharsets.UTF_8); + Path sub = tempDir.resolve("sub"); + Files.createDirectories(sub); + Files.writeString(sub.resolve("b.tmp"), "12345", StandardCharsets.UTF_8); + + long usage = monitor.measureUsageBytes(tempDir.toFile()); + + assertEquals(15L, usage, "容量按字节递归求和"); + } + + @Test + void emptyDirUsageIsZero() { + assertEquals(0L, monitor.measureUsageBytes(tempDir.toFile()), "空目录容量为 0"); + } + + @Test + void warningTriggeredOverThreshold() throws Exception { + Files.writeString(tempDir.resolve("big.tmp"), "x".repeat(100), StandardCharsets.UTF_8); + properties.setCapacityWarnBytes(10); + ReflectionTestUtils.setField(monitor, "lastWarnAtMillis", 0L); + + // 超阈值 → 告警路径(频率窗口内可告警) + monitor.warnWithRateLimit(tempDir.toFile(), 100L, 10L); + long lastWarn = (long) ReflectionTestUtils.getField(monitor, "lastWarnAtMillis"); + assertTrue(lastWarn > 0L, "告警后记录时间戳(频率限制用)"); + } + + @Test + void underThresholdDoesNotWarn() throws Exception { + Files.writeString(tempDir.resolve("small.tmp"), "x", StandardCharsets.UTF_8); + properties.setCapacityWarnBytes(10_000); + + monitor.checkTempDirCapacity(); + + long lastWarn = (long) ReflectionTestUtils.getField(monitor, "lastWarnAtMillis"); + assertEquals(0L, lastWarn, "未超阈值不告警"); + } + + @Test + void warningRateLimited() throws Exception { + ReflectionTestUtils.setField(monitor, "lastWarnAtMillis", System.currentTimeMillis()); + long before = (long) ReflectionTestUtils.getField(monitor, "lastWarnAtMillis"); + + monitor.warnWithRateLimit(tempDir.toFile(), 100L, 10L); + + assertEquals(before, ReflectionTestUtils.getField(monitor, "lastWarnAtMillis"), + "频率窗口内重复告警被抑制"); + } + + @Test + void measureFailureIsGraceful() { + File missing = tempDir.resolve("nope").toFile(); + assertEquals(-1L, monitor.measureUsageBytes(missing), "目录缺失测量返回 -1"); + assertEquals(-1L, monitor.measureUsageBytes(null)); + } + + @Test + void checkDoesNotThrowOnMissingDir() { + properties.setLocalTempDir(tempDir.resolve("absent").toString()); + + monitor.checkTempDirCapacity(); + } + + @Test + void warningDoesNotBlockBusiness() { + // 告警路径不抛异常(业务不阻塞) + ReflectionTestUtils.setField(monitor, "lastWarnAtMillis", 0L); + monitor.warnWithRateLimit(tempDir.toFile(), 999L, 1L); + monitor.checkTempDirCapacity(); + } +}