소스 검색

feat(cockpit): monitoring/distribution 回显 snapshotAt 快照标记(票 62 批次四)

- 响应顶层补 data.snapshotAt(与 map-snapshot 同形同义),调用方第 2 页起原样回带
- 背景:该端点 page>1 强制要求 snapshotAt,但响应从不回显 ⇒ 前端跨页分页无从对齐,
  台账在真实环境每次翻页都 400(mock 放宽了该约束所以既有闸门未暴露)
- 契约测试:回显断言 + 跨页 token 稳定 + 第 2 页缺 token 必须 400(成对反证)
- docs/cockpit/README.md 补 snapshotAt 回显与分页约束说明
- cockpit 全量 425/425 通过
Kazerin 18 시간 전
부모
커밋
c53840af21

+ 1 - 0
docs/cockpit/README.md

@@ -67,6 +67,7 @@
 - 地图(跨四个模式常驻的中央一张图)只从 `map-snapshot` 取数;`/monitoring/distribution` 是运行监测模式「监测分布」面板的载荷,不作为地图取数路径。
 - `map-snapshot` 的目标契约接受 `industry`、`district`、`deviceType` 三个筛选入参;分页与 `snapshotAt` 语义不因筛选改变。**2026-09-24 人工裁定**:正式行业扩为 `GAS` / `WATER_SUPPLY` / `DRAINAGE` / `MANHOLE`(井盖为独立根类型);对外 `typeCode` 使用 `equipment_type.id` 的字符串形式,`type_name` 仅用于展示。`monitoring/overview`、`monitoring/situation`、`map-snapshot`、`monitoring/distribution`、事件和基础设施服务均按该四行业口径校验;对外 `typeCode` 使用 `equipment_type.id` 的字符串形式,`type_name` 仅用于展示。旧 `type_id/parent_type_id` 不是当前 schema 字段。
 - 服务端完成过滤,并在响应 `mapFilter{industry,district,districtName,districtFilter,deviceType,pointCodes,deviceCodes}` 回显**实际生效范围**(编码为过滤后全量、分页前,升序)。
+- **`snapshotAt` 回显(执行票 62 补齐)**:`monitoring/distribution` 的 `data.snapshotAt` 回显本次响应依据的快照标记,与 `map-snapshot` 同形同义。该端点 `page > 1` 时**必须**带非空 `snapshotAt`(不带即 400 `INVALID_REQUEST`「监测分布分页参数不合法」)——此前响应不回显该值,调用方只能自己找时间戳猜,跨页对齐无从保证;现在第 1 页取回什么、后续页就原样回带什么。`page = 1` 可不带(后端据此开新快照)。
 - `coordinateValidation` 汇总是**当前筛选范围内的全量口径**(再切片分页);逐点/逐设备保留 `renderable=false` 的对象。
 - 逐点 `industry` 由类型推导(口径同 distribution:类型可解析、`objectKind = SMART_DEVICE`、行业属正式行业);不可推导时为 `null`,**默认不排除对象**,仅在请求了 `industry`/`deviceType` 过滤时按推导结果筛选。
 

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

@@ -74,6 +74,8 @@ class CockpitMonitoringDistributionControllerTest {
                 .andExpect(jsonPath("$.data.readOnly").value(true))
                 .andExpect(jsonPath("$.data.displayName").value("监测分布"))
                 .andExpect(jsonPath("$.data.timezone").value("Asia/Shanghai"))
+                // 执行票 62:响应必须回显快照标记(调用方第 2 页起原样回带)。
+                .andExpect(jsonPath("$.data.snapshotAt").value(SNAPSHOT_AT))
                 .andExpect(jsonPath("$.data.pagination.page").value(1))
                 .andExpect(jsonPath("$.data.pagination.pageSize").value(500))
                 .andExpect(jsonPath("$.data.pagination.total").value(9))
@@ -332,6 +334,31 @@ class CockpitMonitoringDistributionControllerTest {
                 .andExpect(jsonPath("$.data.requestId").value("monitoring-distribution-500"));
     }
 
+    @Test
+    void snapshotTokenIsEchoedAndStaysStableAcrossPages() throws Exception {
+        // 执行票 62:调用方拿不到 token 就无法跨页对齐。「第 1 页给的 token,
+        // 第 2 页原样回带」必须真的落在同一份快照上,且两页回显同一个 token。
+        String pageOneToken = com.jayway.jsonpath.JsonPath.read(
+                mockMvc.perform(get(ENDPOINT).param("page", "1").param("pageSize", "2"))
+                        .andExpect(status().isOk())
+                        .andExpect(jsonPath("$.data.snapshotAt").value(SNAPSHOT_AT))
+                        .andReturn().getResponse().getContentAsString(),
+                "$.data.snapshotAt");
+        mockMvc.perform(get(ENDPOINT).param("page", "2").param("pageSize", "2").param("snapshotAt", pageOneToken))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.snapshotAt").value(SNAPSHOT_AT))
+                .andExpect(jsonPath("$.data.pagination.page").value(2))
+                .andExpect(jsonPath("$.data.pagination.hasMore").value(true));
+    }
+
+    @Test
+    void pageBeyondFirstWithoutSnapshotTokenIsRejected() throws Exception {
+        // 反证:第 2 页不带 token 必须 400(否则上面那条「同快照」断言可能是恒真)。
+        mockMvc.perform(get(ENDPOINT).param("page", "2").param("pageSize", "2"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.data.message").value("监测分布分页参数不合法"));
+    }
+
     @Test
     void mutatingRequestIsRejectedBeforeReadAdapterIsCalled() throws Exception {
         mockMvc.perform(post(ENDPOINT))

+ 9 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringDistributionResponse.java

@@ -15,6 +15,12 @@ public class CockpitMonitoringDistributionResponse {
     private final String displayName;
     private final String timezone;
     private final Range range;
+    /**
+     * 本次响应所依据的**快照标记**(执行票 62 补):与 {@code map-snapshot} 的
+     * {@code snapshotAt} 同形同义。调用方第 2 页起必须原样回带该值,
+     * 否则后端无法保证同一批分页视图落在同一份快照上(`validatePagination` 会 400)。
+     */
+    private final String snapshotAt;
     private final String updatedAt;
     /**
      * 数据时间=记录创建时间({@code create_time})跨台账对象取 max;
@@ -39,6 +45,7 @@ public class CockpitMonitoringDistributionResponse {
             String displayName,
             String timezone,
             Range range,
+            String snapshotAt,
             String updatedAt,
             String createdAt,
             Freshness freshness,
@@ -57,6 +64,7 @@ public class CockpitMonitoringDistributionResponse {
         this.displayName = displayName;
         this.timezone = timezone;
         this.range = range;
+        this.snapshotAt = snapshotAt;
         this.updatedAt = updatedAt;
         this.createdAt = createdAt;
         this.freshness = freshness;
@@ -77,6 +85,7 @@ public class CockpitMonitoringDistributionResponse {
     public String getDisplayName() { return displayName; }
     public String getTimezone() { return timezone; }
     public Range getRange() { return range; }
+    public String getSnapshotAt() { return snapshotAt; }
     public String getUpdatedAt() { return updatedAt; }
     public String getCreatedAt() { return createdAt; }
     public Freshness getFreshness() { return freshness; }

+ 3 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringDistributionService.java

@@ -429,6 +429,9 @@ public class CockpitMonitoringDistributionService {
                 "监测分布",
                 "Asia/Shanghai",
                 new CockpitMonitoringDistributionResponse.Range("CURRENT_BASELINE"),
+                // 执行票 62:回显本次响应依据的快照标记(与 map-snapshot 同形),
+                // 调用方第 2 页起原样回带——在此之前响应不给 token,跨页分页无从对齐。
+                snapshot.getSnapshotAt(),
                 updatedAt,
                 createdAt,
                 new CockpitMonitoringDistributionResponse.Freshness(