From 87a6d20f883b56f12030471f80f0c4f489ed32c6 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:40:20 +0800 Subject: [PATCH] =?UTF-8?q?task-154:=20=E4=B8=B4=E6=97=B6=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E7=A3=81=E7=9B=98=E5=AE=B9=E9=87=8F=E5=91=8A=E8=AD=A6?= =?UTF-8?q?=EF=BC=88aiimage.storage.capacity-warn-bytes=20=E9=98=88?= =?UTF-8?q?=E5=80=BC=E3=80=81=E5=91=8A=E8=AD=A6=E5=90=AB=E5=BD=93=E5=89=8D?= =?UTF-8?q?=E5=AE=B9=E9=87=8F/=E9=98=88=E5=80=BC=E3=80=8110=20=E5=88=86?= =?UTF-8?q?=E9=92=9F=E9=A2=91=E7=8E=87=E9=99=90=E5=88=B6=E3=80=81=E6=B5=8B?= =?UTF-8?q?=E9=87=8F=E5=A4=B1=E8=B4=A5=E5=AE=B9=E5=BF=8D=E4=B8=8D=E9=98=BB?= =?UTF-8?q?=E5=A1=9E=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 --- .../aiimage/config/StorageProperties.java | 2 + .../file/service/TempDirCapacityMonitor.java | 78 +++++++++++++ .../service/TempDirCapacityMonitorTest.java | 110 ++++++++++++++++++ 3 files changed, 190 insertions(+) create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/file/service/TempDirCapacityMonitor.java create mode 100644 backend-java/src/test/java/com/nanri/aiimage/modules/file/service/TempDirCapacityMonitorTest.java 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(); + } +}