浏览代码

fix(cockpit): reject snapshots expired during load

Kazerin 3 周之前
父节点
当前提交
a52b1c7e13

+ 25 - 0
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/BusinessCockpitMapSnapshotAdapterTest.java

@@ -42,6 +42,7 @@ import static org.mockito.ArgumentMatchers.anyLong;
 import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
@@ -317,11 +318,35 @@ class BusinessCockpitMapSnapshotAdapterTest {
                 eq(java.util.concurrent.TimeUnit.SECONDS));
     }
 
+    @Test
+    void snapshotThatExpiresDuringBuildIsNotPublishedToRedis() {
+        RedisTemplate<Object, Object> sharedRedis = mock(RedisTemplate.class);
+        ValueOperations<Object, Object> values = mock(ValueOperations.class);
+        Clock advancingClock = mock(Clock.class);
+        when(sharedRedis.opsForValue()).thenReturn(values);
+        when(values.get(any())).thenReturn(null);
+        when(advancingClock.getZone()).thenReturn(ZoneOffset.UTC);
+        when(advancingClock.instant()).thenReturn(
+                Instant.parse("2026-09-02T02:00:00Z"),
+                Instant.parse("2026-09-02T02:10:00.001Z"));
+        when(monitorPointService.list()).thenReturn(Collections.emptyList());
+        when(equipmentBaseService.list()).thenReturn(Collections.emptyList());
+        when(equipmentTypeService.list()).thenReturn(Collections.emptyList());
+        BusinessCockpitMapSnapshotAdapter expiringAdapter = new BusinessCockpitMapSnapshotAdapter(
+                monitorPointService, equipmentBaseService, equipmentTypeService, deviceFactResolver,
+                advancingClock, sharedRedis);
+
+        assertThrows(CockpitSnapshotExpiredException.class,
+                () -> expiringAdapter.load("2026-09-02T10:00:00+08:00"));
+        verify(values, never()).setIfAbsent(any(), any(), anyLong(), any());
+    }
 
     @Test
     void expiredSnapshotAtIsRejectedInsteadOfLoadingCurrentRelations() {
         when(monitorPointService.list()).thenReturn(Collections.emptyList());
 
+        assertThrows(CockpitSnapshotExpiredException.class,
+                () -> adapter.load("2026-09-02T09:50:00+08:00"));
         assertThrows(CockpitSnapshotExpiredException.class,
                 () -> adapter.load("2026-09-02T09:49:59+08:00"));
     }

+ 9 - 4
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/BusinessCockpitMapSnapshotAdapter.java

@@ -101,13 +101,13 @@ public class BusinessCockpitMapSnapshotAdapter implements CockpitMapSnapshotAdap
         String resolvedSnapshotAt = snapshotAt == null ? businessTime() : snapshotAt;
         OffsetDateTime requestedAt = OffsetDateTime.parse(resolvedSnapshotAt);
         OffsetDateTime now = OffsetDateTime.now(clock);
-        if (requestedAt.isBefore(now.minus(SNAPSHOT_RETENTION))
-                || requestedAt.isAfter(now.plus(SNAPSHOT_RETENTION))) {
+        if (!requestedAt.isAfter(now.minus(SNAPSHOT_RETENTION))
+                || !requestedAt.isBefore(now.plus(SNAPSHOT_RETENTION))) {
             throw new CockpitSnapshotExpiredException("snapshot timestamp outside retention window");
         }
 
         snapshots.keySet().removeIf(cachedAt ->
-                OffsetDateTime.parse(cachedAt).isBefore(now.minus(SNAPSHOT_RETENTION)));
+                !OffsetDateTime.parse(cachedAt).isAfter(now.minus(SNAPSHOT_RETENTION)));
         CockpitMapSnapshot cached = snapshots.get(resolvedSnapshotAt);
         if (cached != null) {
             return cached;
@@ -128,8 +128,13 @@ public class BusinessCockpitMapSnapshotAdapter implements CockpitMapSnapshotAdap
         }
 
         CockpitMapSnapshot built = buildSnapshot(resolvedSnapshotAt);
+        OffsetDateTime writeTime = OffsetDateTime.now(clock);
+        if (!requestedAt.isAfter(writeTime.minus(SNAPSHOT_RETENTION))
+                || !requestedAt.isBefore(writeTime.plus(SNAPSHOT_RETENTION))) {
+            throw new CockpitSnapshotExpiredException("snapshot timestamp expired during load");
+        }
         if (redisTemplate != null) {
-            Duration remaining = Duration.between(now, requestedAt.plus(SNAPSHOT_RETENTION));
+            Duration remaining = Duration.between(writeTime, requestedAt.plus(SNAPSHOT_RETENTION));
             long ttlSeconds = Math.max(1,
                     remaining.getSeconds() + (remaining.getNano() > 0 ? 1 : 0));
             Boolean stored = redisTemplate.opsForValue()