1
0

2 Коммиты 5ae1e0c19e ... b8ef2a9eb1

Автор SHA1 Сообщение Дата
  Kazerin b8ef2a9eb1 feat(cockpit): add map snapshot pagination and coordinate gate (#20) 2 дней назад
  Kazerin eb3d42a775 fix(water): scope threshold devices and refine UI (#9) 2 дней назад
20 измененных файлов с 1125 добавлено и 2 удалено
  1. 124 0
      docs/cockpit/map-snapshot-demo.html
  2. 9 0
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/WaterSupply/MonitorAlarm/WaterSupplyThresholdController.java
  3. 73 0
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitMapSnapshotController.java
  4. 46 0
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/WaterSupply/MonitorAlarm/WaterSupplyThresholdControllerTest.java
  5. 293 0
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitMapSnapshotControllerTest.java
  6. 6 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/WaterSupply/MonitorAlarm/service/IWaterSupplyThresholdService.java
  7. 12 1
      pipe-network-service/zksy-system/src/main/java/com/zksy/WaterSupply/MonitorAlarm/service/impl/WaterSupplyThresholdServiceImpl.java
  8. 3 1
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/CockpitErrorCategory.java
  9. 7 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/CockpitInvalidRequestException.java
  10. 7 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/CockpitSnapshotExpiredException.java
  11. 20 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitCoordinateValidation.java
  12. 24 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitCoordinateValidationSummary.java
  13. 90 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitMapSnapshot.java
  14. 5 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitMapSnapshotAdapter.java
  15. 50 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitMapSnapshotResponse.java
  16. 258 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitMapSnapshotService.java
  17. 34 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitMonitoringPointSummary.java
  18. 26 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitUnassociatedEquipmentSummary.java
  19. 26 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/EmptyCockpitMapSnapshotAdapter.java
  20. 12 0
      pipe-network-service/zksy-system/src/test/java/com/zksy/WaterSupply/MonitorAlarm/WaterSupplyThresholdServiceContractTest.java

+ 124 - 0
docs/cockpit/map-snapshot-demo.html

@@ -0,0 +1,124 @@
+<!doctype html>
+<html lang="zh-CN">
+<head>
+  <meta charset="utf-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1" />
+  <title>Cockpit Map Snapshot Demo</title>
+  <style>
+    body { margin:0; min-height:100vh; background:#08131f; color:#e8f1f8; font:15px/1.5 "Microsoft YaHei",sans-serif; }
+    main { width:min(1220px,calc(100vw - 32px)); margin:28px auto; display:grid; gap:16px; }
+    header, section { background:#0e1e2d; border:1px solid #21465f; padding:20px; }
+    h1 { margin:0 0 6px; font-size:22px; } .endpoint { color:#78a2b8; font-family:Consolas,monospace; margin:0; }
+    .layout { display:grid; grid-template-columns:minmax(0,2fr) minmax(260px,1fr); gap:16px; }
+    .toolbar { display:flex; gap:10px; align-items:center; flex-wrap:wrap; margin-bottom:14px; }
+    button { border:1px solid #2f7098; background:#123247; color:#dff2ff; padding:8px 14px; cursor:pointer; }
+    button:disabled { opacity:.45; cursor:not-allowed; }
+    .map { position:relative; height:480px; overflow:hidden; background:linear-gradient(135deg,#0b1a28,#102b3b); border:1px solid #275b74; }
+    .map::before { content:""; position:absolute; inset:0; background-image:linear-gradient(rgba(60,130,160,.12) 1px,transparent 1px),linear-gradient(90deg,rgba(60,130,160,.12) 1px,transparent 1px); background-size:48px 48px; }
+    .marker { position:absolute; width:26px; height:26px; border-radius:50%; display:grid; place-items:center; font-size:11px; font-weight:700; border:2px solid rgba(255,255,255,.75); transform:translate(-50%,-50%); box-shadow:0 0 14px currentColor; }
+    .point { background:#20a35d; color:#052; } .device { background:#3d87d8; color:#021c33; border-radius:6px; }
+    .marker span { position:absolute; top:30px; left:50%; transform:translateX(-50%); white-space:nowrap; color:#cfe8f6; text-shadow:0 1px 3px #000; }
+    .stats { display:grid; grid-template-columns:repeat(4,1fr); gap:8px; margin-bottom:12px; }
+    .card { background:#0b1922; border:1px solid #275b74; padding:10px; } .card strong { display:block; font-size:20px; } .card span { color:#8fb3c7; }
+    table { width:100%; border-collapse:collapse; } th,td { border:1px solid #275b74; padding:8px; text-align:left; } th { background:#123247; }
+    .invalid { color:#f0ad4e; } .valid { color:#68d095; } .note { color:#8fb3c7; font-size:13px; }
+  </style>
+</head>
+<body>
+<main>
+  <header>
+    <h1>驾驶舱地图快照分页演示</h1>
+    <p class="endpoint">GET /api/cockpit/v1/map-snapshot?page=1&amp;pageSize=2</p>
+  </header>
+  <div class="layout">
+    <section>
+      <div class="toolbar">
+        <button id="load">开始加载第 1 页</button>
+        <button id="reset">重新开始</button>
+        <span id="status" class="note">地图框架已就绪,等待分页数据。</span>
+      </div>
+      <div class="stats">
+        <div class="card"><strong id="loaded-count">0</strong><span>已加载对象</span></div>
+        <div class="card"><strong id="renderable-count">0</strong><span>可渲染对象</span></div>
+        <div class="card"><strong id="blocked-count">0</strong><span>坐标禁渲染</span></div>
+        <div class="card"><strong id="complete">否</strong><span>加载完成</span></div>
+      </div>
+      <div id="map" class="map" aria-label="一张图地图框架"></div>
+    </section>
+    <section>
+      <h2>不可渲染对象</h2>
+      <table>
+        <thead><tr><th>编码</th><th>状态</th><th>原因</th></tr></thead>
+        <tbody id="blocked"></tbody>
+      </table>
+      <p class="note">已关联设备的点位由监测点承载,不重复生成独立设备点位。</p>
+    </section>
+  </div>
+</main>
+<script>
+const snapshotAt='2026-09-02T10:00:00+08:00';
+const pages=[
+  {page:1,pageSize:2,snapshotAt,total:7,batchUpdatedAt:snapshotAt,complete:false,monitoringPoints:[
+    {pointCode:'MP-001',pointName:'供水干管监测点',pointStatus:'NORMAL',statusReasons:[],longitude:110.141,latitude:28.452,coordinateValidation:{state:'VALID',reason:null,renderable:true,crs:'WGS84'}},
+    {pointCode:'MP-002',pointName:'燃气调压站监测点',pointStatus:'ALARM',statusReasons:['EQ-ASSOC-001 报警'],longitude:110.142,latitude:28.453,coordinateValidation:{state:'VALID',reason:null,renderable:true,crs:'WGS84'}}
+  ],unassociatedEquipment:[]},
+  {page:2,pageSize:2,snapshotAt,total:7,batchUpdatedAt:snapshotAt,complete:false,monitoringPoints:[
+    {pointCode:'MP-003',pointName:'污水排口监测点',pointStatus:'WARNING',statusReasons:['EQ-FREE-003 预警'],longitude:110.143,latitude:28.454,coordinateValidation:{state:'UNVERIFIED',reason:'UNVERIFIED_COORDINATE',renderable:false,crs:'WGS84'}}
+  ],unassociatedEquipment:[
+    {deviceCode:'EQ-FREE-001',deviceName:'供水压力计-001',longitude:181,latitude:28.455,coordinateValidation:{state:'INVALID',reason:'LONGITUDE_OUT_OF_RANGE',renderable:false,crs:'WGS84'}}
+  ]},
+  {page:3,pageSize:2,snapshotAt,total:7,batchUpdatedAt:snapshotAt,complete:false,monitoringPoints:[],unassociatedEquipment:[
+    {deviceCode:'EQ-FREE-002',deviceName:'智能井盖-002',longitude:110.145,latitude:28.456,coordinateValidation:{state:'VALID',reason:null,renderable:true,crs:'WGS84'}},
+    {deviceCode:'EQ-FREE-003',deviceName:'污水环境监测仪-003',longitude:110.146,latitude:28.457,coordinateValidation:{state:'UNVERIFIED',reason:'UNVERIFIED_COORDINATE',renderable:false,crs:'WGS84'}}
+  ]},
+  {page:4,pageSize:2,snapshotAt,total:7,batchUpdatedAt:snapshotAt,complete:true,monitoringPoints:[],unassociatedEquipment:[
+    {deviceCode:'EQ-FREE-004',deviceName:'零坐标设备-004',longitude:0,latitude:0,coordinateValidation:{state:'INVALID',reason:'ZERO_COORDINATE',renderable:false,crs:'WGS84'}}
+  ]}
+];
+let pageIndex=0, loaded=0, renderable=0, blocked=0;
+const map=document.getElementById('map'), statusText=document.getElementById('status'), loadButton=document.getElementById('load');
+function percent(value,min,max){ return ((value-min)/(max-min)*82+9).toFixed(2)+'%'; }
+function marker(item,isPoint){
+  const marker=document.createElement('div');
+  marker.className='marker '+(isPoint?'point':'device');
+  marker.style.left=percent(item.longitude,110.135,110.152);
+  marker.style.top=percent(item.latitude,28.445,28.462);
+  marker.textContent=isPoint?'点':'设';
+  const label=document.createElement('span'); label.textContent=isPoint?item.pointName:item.deviceName; marker.appendChild(label);
+  map.appendChild(marker);
+}
+function blockedRow(item,isPoint){
+  const row=document.createElement('tr'); row.className='invalid';
+  row.innerHTML=`<td>${isPoint?item.pointCode:item.deviceCode}</td><td>${item.coordinateValidation.state}</td><td>${item.coordinateValidation.reason}</td>`;
+  document.getElementById('blocked').appendChild(row);
+}
+function loadPage(){
+  if(pageIndex>=pages.length) return;
+  const page=pages[pageIndex++];
+  page.monitoringPoints.forEach(item=>add(item,true));
+  page.unassociatedEquipment.forEach(item=>add(item,false));
+  loaded+=page.monitoringPoints.length+page.unassociatedEquipment.length;
+  document.getElementById('loaded-count').textContent=loaded;
+  document.getElementById('renderable-count').textContent=renderable;
+  document.getElementById('blocked-count').textContent=blocked;
+  document.getElementById('complete').textContent=page.complete?'是':'否';
+  statusText.textContent=page.complete?`快照 ${snapshotAt} 已加载完成。`:`已加载第 ${page.page} 页,快照 ${snapshotAt} 保持一致。`;
+  loadButton.disabled=page.complete;
+  loadButton.textContent=page.complete?'加载完成':`加载第 ${page.page+1} 页`;
+}
+function add(item,isPoint){
+  if(item.coordinateValidation.renderable){ marker(item,isPoint); renderable++; }
+  else { blockedRow(item,isPoint); blocked++; }
+}
+loadButton.addEventListener('click',loadPage);
+document.getElementById('reset').addEventListener('click',()=>{
+  pageIndex=0; loaded=0; renderable=0; blocked=0;
+  map.querySelectorAll('.marker').forEach(x=>x.remove());
+  document.getElementById('blocked').innerHTML='';
+  document.getElementById('loaded-count').textContent='0'; document.getElementById('renderable-count').textContent='0';
+  document.getElementById('blocked-count').textContent='0'; document.getElementById('complete').textContent='否';
+  statusText.textContent='地图框架已就绪,等待分页数据。'; loadButton.disabled=false; loadButton.textContent='开始加载第 1 页';
+});
+</script>
+</body>
+</html>

+ 9 - 0
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/WaterSupply/MonitorAlarm/WaterSupplyThresholdController.java

@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.zksy.WaterSupply.MonitorAlarm.dto.in.WaterThresholdSaveInDTO;
 import com.zksy.WaterSupply.MonitorAlarm.service.IWaterSupplyThresholdService;
 import com.zksy.base.alarm.domain.WarningThreshold;
+import com.zksy.base.domain.EquipmentBase;
 import com.zksy.common.annotation.Log;
 import com.zksy.common.core.domain.AjaxResult;
 import com.zksy.common.enums.BusinessType;
@@ -55,6 +56,14 @@ public class WaterSupplyThresholdController {
         return AjaxResult.success(waterSupplyThresholdService.findThresholdList(deviceCode, warningType, warningCode));
     }
 
+    @GetMapping("/devices")
+    @ApiOperation("可配置阈值的供水监测设备")
+    @PreAuthorize("@ss.hasPermi('waterSupply:alarm:threshold:list')")
+    public AjaxResult getThresholdDevices() {
+        List<EquipmentBase> devices = waterSupplyThresholdService.findThresholdDevices();
+        return AjaxResult.success(devices);
+    }
+
     @GetMapping("/detail/{id}")
     @ApiOperation("阈值详情")
     @PreAuthorize("@ss.hasPermi('waterSupply:alarm:threshold:list')")

+ 73 - 0
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitMapSnapshotController.java

@@ -0,0 +1,73 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.cockpit.foundation.CockpitInvalidRequestException;
+import com.zksy.cockpit.foundation.CockpitErrorPayload;
+import com.zksy.cockpit.foundation.CockpitReadException;
+import com.zksy.cockpit.foundation.CockpitSnapshotExpiredException;
+import com.zksy.cockpit.map.CockpitMapSnapshotResponse;
+import com.zksy.cockpit.map.CockpitMapSnapshotService;
+import com.zksy.common.core.domain.AjaxResult;
+import com.zksy.common.utils.StringUtils;
+import com.zksy.common.utils.uuid.IdUtils;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.servlet.http.HttpServletRequest;
+
+@RestController
+public class CockpitMapSnapshotController {
+    private static final String REQUEST_ID_HEADER = "X-Request-Id";
+
+    private final CockpitMapSnapshotService mapSnapshotService;
+
+    public CockpitMapSnapshotController(CockpitMapSnapshotService mapSnapshotService) {
+        this.mapSnapshotService = mapSnapshotService;
+    }
+
+    @GetMapping("/api/cockpit/v1/map-snapshot")
+    @PreAuthorize("isAuthenticated()")
+    public ResponseEntity<AjaxResult> mapSnapshot(
+            @RequestParam(value = "page", defaultValue = "1") Integer page,
+            @RequestParam(value = "pageSize", defaultValue = "500") Integer pageSize,
+            @RequestParam(value = "snapshotAt", required = false) String snapshotAt,
+            @RequestHeader(value = REQUEST_ID_HEADER, required = false) String requestId) {
+        CockpitMapSnapshotResponse response = mapSnapshotService.load(
+                resolveRequestId(requestId), page, pageSize, snapshotAt);
+        return ResponseEntity.ok(AjaxResult.success(response));
+    }
+
+    @ExceptionHandler(CockpitInvalidRequestException.class)
+    public ResponseEntity<AjaxResult> handleInvalidRequest(HttpServletRequest request) {
+        return badRequest(mapSnapshotService.invalidRequest(
+                resolveRequestId(request.getHeader(REQUEST_ID_HEADER))));
+    }
+
+    @ExceptionHandler(CockpitSnapshotExpiredException.class)
+    public ResponseEntity<AjaxResult> handleSnapshotExpired(HttpServletRequest request) {
+        return badRequest(mapSnapshotService.snapshotExpired(
+                resolveRequestId(request.getHeader(REQUEST_ID_HEADER))));
+    }
+
+    @ExceptionHandler(CockpitReadException.class)
+    public ResponseEntity<AjaxResult> handleReadFailure(HttpServletRequest request) {
+        CockpitErrorPayload payload = mapSnapshotService.serverError(
+                resolveRequestId(request.getHeader(REQUEST_ID_HEADER)));
+        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
+                .body(new AjaxResult(HttpStatus.INTERNAL_SERVER_ERROR.value(), payload.getMessage(), payload));
+    }
+
+    private ResponseEntity<AjaxResult> badRequest(CockpitErrorPayload payload) {
+        return ResponseEntity.status(HttpStatus.BAD_REQUEST)
+                .body(new AjaxResult(HttpStatus.BAD_REQUEST.value(), payload.getMessage(), payload));
+    }
+
+    private String resolveRequestId(String requestId) {
+        return StringUtils.isNotEmpty(requestId) ? requestId : IdUtils.fastSimpleUUID();
+    }
+}

+ 46 - 0
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/WaterSupply/MonitorAlarm/WaterSupplyThresholdControllerTest.java

@@ -0,0 +1,46 @@
+package com.zksy.web.controller.WaterSupply.MonitorAlarm;
+
+import com.zksy.WaterSupply.MonitorAlarm.service.IWaterSupplyThresholdService;
+import com.zksy.base.domain.EquipmentBase;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+import java.util.Arrays;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+class WaterSupplyThresholdControllerTest {
+
+    private IWaterSupplyThresholdService thresholdService;
+    private MockMvc mockMvc;
+
+    @BeforeEach
+    void setUp() {
+        thresholdService = mock(IWaterSupplyThresholdService.class);
+        WaterSupplyThresholdController controller = new WaterSupplyThresholdController();
+        ReflectionTestUtils.setField(controller, "waterSupplyThresholdService", thresholdService);
+        mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
+    }
+
+    @Test
+    void thresholdDeviceEndpointReturnsOnlyWaterDevices() throws Exception {
+        EquipmentBase flow = new EquipmentBase();
+        flow.setEquipmentCode("CN-GS-202609020001");
+        EquipmentBase pressure = new EquipmentBase();
+        pressure.setEquipmentCode("CB-GS-202609020001");
+        when(thresholdService.findThresholdDevices()).thenReturn(Arrays.asList(flow, pressure));
+
+        mockMvc.perform(get("/waterSupply/alarm/threshold/devices"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data[0].equipmentCode").value("CN-GS-202609020001"))
+                .andExpect(jsonPath("$.data[1].equipmentCode").value("CB-GS-202609020001"));
+    }
+}

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

@@ -0,0 +1,293 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.cockpit.foundation.CockpitInvalidRequestException;
+import com.zksy.cockpit.foundation.CockpitReadException;
+import com.zksy.cockpit.foundation.CockpitSnapshotExpiredException;
+import com.zksy.cockpit.map.CockpitMapSnapshot;
+import com.zksy.cockpit.map.CockpitMapSnapshotAdapter;
+import com.zksy.cockpit.map.CockpitMapSnapshotService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+import java.lang.reflect.Method;
+import java.math.BigDecimal;
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.Arrays;
+import java.util.Collections;
+
+import static org.hamcrest.Matchers.hasSize;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+class CockpitMapSnapshotControllerTest {
+
+    private static final String SNAPSHOT_AT = "2026-09-02T10:00:00+08:00";
+
+    private CockpitMapSnapshotAdapter adapter;
+    private MockMvc mockMvc;
+
+    @BeforeEach
+    void setUp() {
+        adapter = mock(CockpitMapSnapshotAdapter.class);
+        CockpitMapSnapshotService service = new CockpitMapSnapshotService(
+                adapter,
+                Clock.fixed(Instant.parse("2026-09-02T02:00:00Z"), ZoneOffset.UTC));
+        CockpitMapSnapshotController controller = new CockpitMapSnapshotController(service);
+        mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
+    }
+
+    @Test
+    void firstPageUsesDefaultPaginationAndReturnsReadOnlyCamelCaseSnapshotContract() throws Exception {
+        when(adapter.load(null)).thenReturn(fixture());
+
+        mockMvc.perform(get("/api/cockpit/v1/map-snapshot")
+                        .header("X-Request-Id", "map-snapshot-request-001"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.msg").value("操作成功"))
+                .andExpect(jsonPath("$.data.requestId").value("map-snapshot-request-001"))
+                .andExpect(jsonPath("$.data.apiVersion").value("v1"))
+                .andExpect(jsonPath("$.data.readOnly").value(true))
+                .andExpect(jsonPath("$.data.page").value(1))
+                .andExpect(jsonPath("$.data.pageSize").value(500))
+                .andExpect(jsonPath("$.data.snapshotAt").value(SNAPSHOT_AT))
+                .andExpect(jsonPath("$.data.batchUpdatedAt").value(SNAPSHOT_AT))
+                .andExpect(jsonPath("$.data.total").value(7))
+                .andExpect(jsonPath("$.data.complete").value(true))
+                .andExpect(jsonPath("$.data.monitoringPoints", hasSize(3)))
+                .andExpect(jsonPath("$.data.unassociatedEquipment", hasSize(4)))
+                .andExpect(jsonPath("$.data.monitoringPoints[0].pointCode").value("MP-001"))
+                .andExpect(jsonPath("$.data.monitoringPoints[0].pointStatus").value("NORMAL"))
+                .andExpect(jsonPath("$.data.monitoringPoints[0].coordinateValidation.renderable").value(true))
+                .andExpect(jsonPath("$.data.monitoringPoints[0].coordinateValidation.state").value("VALID"))
+                .andExpect(jsonPath("$.data.unassociatedEquipment[0].deviceCode").value("EQ-FREE-001"))
+                .andExpect(jsonPath("$.data.unassociatedEquipment[0].coordinateValidation.renderable").value(false))
+                .andExpect(jsonPath("$.data.unassociatedEquipment[0].coordinateValidation.state").value("INVALID"))
+                .andExpect(jsonPath("$.data.unassociatedEquipment[0].coordinateValidation.reason").value("LONGITUDE_OUT_OF_RANGE"))
+                .andExpect(jsonPath("$.data.coordinateValidation.crs").value("WGS84"))
+                .andExpect(jsonPath("$.data.coordinateValidation.totalObjectCount").value(7))
+                .andExpect(jsonPath("$.data.coordinateValidation.renderableCount").value(3))
+                .andExpect(jsonPath("$.data.coordinateValidation.invalidCount").value(2))
+                .andExpect(jsonPath("$.data.coordinateValidation.unverifiedCount").value(2));
+    }
+
+    @Test
+    void pagesShareOneSnapshotAndDoNotDuplicateOrOmitObjects() throws Exception {
+        when(adapter.load(SNAPSHOT_AT)).thenReturn(fixture());
+
+        mockMvc.perform(get("/api/cockpit/v1/map-snapshot")
+                        .param("page", "1")
+                        .param("pageSize", "2")
+                        .param("snapshotAt", SNAPSHOT_AT))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.snapshotAt").value(SNAPSHOT_AT))
+                .andExpect(jsonPath("$.data.total").value(7))
+                .andExpect(jsonPath("$.data.complete").value(false))
+                .andExpect(jsonPath("$.data.monitoringPoints", hasSize(2)))
+                .andExpect(jsonPath("$.data.monitoringPoints[0].pointCode").value("MP-001"))
+                .andExpect(jsonPath("$.data.monitoringPoints[1].pointCode").value("MP-002"))
+                .andExpect(jsonPath("$.data.unassociatedEquipment", hasSize(0)));
+
+        mockMvc.perform(get("/api/cockpit/v1/map-snapshot")
+                        .param("page", "2")
+                        .param("pageSize", "2")
+                        .param("snapshotAt", SNAPSHOT_AT))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.snapshotAt").value(SNAPSHOT_AT))
+                .andExpect(jsonPath("$.data.total").value(7))
+                .andExpect(jsonPath("$.data.complete").value(false))
+                .andExpect(jsonPath("$.data.monitoringPoints", hasSize(1)))
+                .andExpect(jsonPath("$.data.monitoringPoints[0].pointCode").value("MP-003"))
+                .andExpect(jsonPath("$.data.unassociatedEquipment", hasSize(1)))
+                .andExpect(jsonPath("$.data.unassociatedEquipment[0].deviceCode").value("EQ-FREE-001"));
+
+        mockMvc.perform(get("/api/cockpit/v1/map-snapshot")
+                        .param("page", "3")
+                        .param("pageSize", "2")
+                        .param("snapshotAt", SNAPSHOT_AT))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.snapshotAt").value(SNAPSHOT_AT))
+                .andExpect(jsonPath("$.data.total").value(7))
+                .andExpect(jsonPath("$.data.complete").value(false))
+                .andExpect(jsonPath("$.data.monitoringPoints", hasSize(0)))
+                .andExpect(jsonPath("$.data.unassociatedEquipment", hasSize(2)))
+                .andExpect(jsonPath("$.data.unassociatedEquipment[0].deviceCode").value("EQ-FREE-002"))
+                .andExpect(jsonPath("$.data.unassociatedEquipment[1].deviceCode").value("EQ-FREE-003"));
+
+        mockMvc.perform(get("/api/cockpit/v1/map-snapshot")
+                        .param("page", "4")
+                        .param("pageSize", "2")
+                        .param("snapshotAt", SNAPSHOT_AT))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.snapshotAt").value(SNAPSHOT_AT))
+                .andExpect(jsonPath("$.data.total").value(7))
+                .andExpect(jsonPath("$.data.complete").value(true))
+                .andExpect(jsonPath("$.data.monitoringPoints", hasSize(0)))
+                .andExpect(jsonPath("$.data.unassociatedEquipment", hasSize(1)))
+                .andExpect(jsonPath("$.data.unassociatedEquipment[0].deviceCode").value("EQ-FREE-004"))
+                .andExpect(jsonPath("$.data.unassociatedEquipment[0].coordinateValidation.reason").value("ZERO_COORDINATE"));
+    }
+
+    @Test
+    void invalidPaginationReturnsTypedBadRequestBeforeReadAdapterIsCalled() throws Exception {
+        mockMvc.perform(get("/api/cockpit/v1/map-snapshot")
+                        .param("page", "0")
+                        .param("pageSize", "1001"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.code").value(400))
+                .andExpect(jsonPath("$.data.category").value("INVALID_REQUEST"))
+                .andExpect(jsonPath("$.data.message").value("地图快照分页参数不合法"));
+
+        verifyNoInteractions(adapter);
+    }
+
+    @Test
+    void invalidSnapshotTimestampReturnsTypedBadRequestBeforeReadAdapterIsCalled() throws Exception {
+        mockMvc.perform(get("/api/cockpit/v1/map-snapshot")
+                        .param("snapshotAt", "not-a-timestamp"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.code").value(400))
+                .andExpect(jsonPath("$.data.category").value("INVALID_REQUEST"))
+                .andExpect(jsonPath("$.data.message").value("地图快照分页参数不合法"));
+
+        verifyNoInteractions(adapter);
+    }
+
+    @Test
+    void expiredSnapshotReturnsTypedBadRequestAndDoesNotPretendToBeEmpty() throws Exception {
+        when(adapter.load("2026-09-02T09:00:00+08:00"))
+                .thenThrow(new CockpitSnapshotExpiredException("snapshot expired"));
+
+        mockMvc.perform(get("/api/cockpit/v1/map-snapshot")
+                        .param("page", "2")
+                        .param("pageSize", "500")
+                        .param("snapshotAt", "2026-09-02T09:00:00+08:00"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.code").value(400))
+                .andExpect(jsonPath("$.data.category").value("SNAPSHOT_EXPIRED"))
+                .andExpect(jsonPath("$.data.message").value("地图快照已失效,请重新加载"));
+    }
+
+    @Test
+    void serverFailureReturnsTypedServerError() throws Exception {
+        when(adapter.load(null)).thenThrow(new CockpitReadException("upstream unavailable"));
+
+        mockMvc.perform(get("/api/cockpit/v1/map-snapshot"))
+                .andExpect(status().isInternalServerError())
+                .andExpect(jsonPath("$.code").value(500))
+                .andExpect(jsonPath("$.data.category").value("SERVER_ERROR"))
+                .andExpect(jsonPath("$.data.message").value("驾驶舱地图快照数据暂不可用"));
+    }
+
+    @Test
+    void mutatingRequestIsRejectedBeforeReadAdapterIsCalled() throws Exception {
+        mockMvc.perform(post("/api/cockpit/v1/map-snapshot"))
+                .andExpect(status().isMethodNotAllowed());
+
+        verifyNoInteractions(adapter);
+    }
+
+    @Test
+    void controllerOnlyExposesAuthenticatedReadOnlyOperations() {
+        assertTrue(Arrays.stream(CockpitMapSnapshotController.class.getDeclaredMethods())
+                .filter(this::hasRequestMappingAnnotation)
+                .allMatch(CockpitMapSnapshotControllerTest::isReadOnlyMappedMethod));
+
+        assertTrue(Arrays.stream(CockpitMapSnapshotController.class.getDeclaredMethods())
+                .anyMatch(method -> method.isAnnotationPresent(org.springframework.security.access.prepost.PreAuthorize.class)
+                        && method.getAnnotation(org.springframework.security.access.prepost.PreAuthorize.class)
+                        .value()
+                        .equals("isAuthenticated()")));
+    }
+
+    private boolean hasRequestMappingAnnotation(Method method) {
+        return method.isAnnotationPresent(org.springframework.web.bind.annotation.RequestMapping.class)
+                || method.isAnnotationPresent(org.springframework.web.bind.annotation.GetMapping.class)
+                || method.isAnnotationPresent(org.springframework.web.bind.annotation.PostMapping.class)
+                || method.isAnnotationPresent(org.springframework.web.bind.annotation.PutMapping.class)
+                || method.isAnnotationPresent(org.springframework.web.bind.annotation.PatchMapping.class)
+                || method.isAnnotationPresent(org.springframework.web.bind.annotation.DeleteMapping.class);
+    }
+
+    private static boolean isReadOnlyMappedMethod(Method method) {
+        return method.isAnnotationPresent(org.springframework.web.bind.annotation.GetMapping.class)
+                && !method.isAnnotationPresent(org.springframework.web.bind.annotation.PostMapping.class)
+                && !method.isAnnotationPresent(org.springframework.web.bind.annotation.PutMapping.class)
+                && !method.isAnnotationPresent(org.springframework.web.bind.annotation.PatchMapping.class)
+                && !method.isAnnotationPresent(org.springframework.web.bind.annotation.DeleteMapping.class);
+    }
+
+    private static CockpitMapSnapshot fixture() {
+        return new CockpitMapSnapshot(
+                SNAPSHOT_AT,
+                Arrays.asList(
+                        new CockpitMapSnapshot.SourceMonitoringPoint(
+                                "MP-002",
+                                "燃气调压站监测点",
+                                "ALARM",
+                                Collections.singletonList("EQ-ASSOC-001 报警"),
+                                new BigDecimal("110.142"),
+                                new BigDecimal("28.453"),
+                                true,
+                                Collections.singletonList("EQ-ASSOC-001")),
+                        new CockpitMapSnapshot.SourceMonitoringPoint(
+                                "MP-001",
+                                "供水干管监测点",
+                                "NORMAL",
+                                Collections.emptyList(),
+                                new BigDecimal("110.141"),
+                                new BigDecimal("28.452"),
+                                true,
+                                Collections.emptyList()),
+                        new CockpitMapSnapshot.SourceMonitoringPoint(
+                                "MP-003",
+                                "污水排口监测点",
+                                "WARNING",
+                                Collections.singletonList("EQ-FREE-003 预警"),
+                                new BigDecimal("110.143"),
+                                new BigDecimal("28.454"),
+                                false,
+                                Collections.emptyList())),
+                Arrays.asList(
+                        new CockpitMapSnapshot.SourceSmartDevice(
+                                "EQ-ASSOC-001",
+                                "燃气浓度计-001",
+                                new BigDecimal("110.142"),
+                                new BigDecimal("28.453"),
+                                true),
+                        new CockpitMapSnapshot.SourceSmartDevice(
+                                "EQ-FREE-002",
+                                "智能井盖-002",
+                                new BigDecimal("110.145"),
+                                new BigDecimal("28.456"),
+                                true),
+                        new CockpitMapSnapshot.SourceSmartDevice(
+                                "EQ-FREE-001",
+                                "供水压力计-001",
+                                new BigDecimal("181"),
+                                new BigDecimal("28.455"),
+                                true),
+                        new CockpitMapSnapshot.SourceSmartDevice(
+                                "EQ-FREE-003",
+                                "污水环境监测仪-003",
+                                new BigDecimal("110.146"),
+                                new BigDecimal("28.457"),
+                                false),
+                        new CockpitMapSnapshot.SourceSmartDevice(
+                                "EQ-FREE-004",
+                                "零坐标设备-004",
+                                BigDecimal.ZERO,
+                                BigDecimal.ZERO,
+                                true)));
+    }
+}

+ 6 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/WaterSupply/MonitorAlarm/service/IWaterSupplyThresholdService.java

@@ -3,6 +3,7 @@ package com.zksy.WaterSupply.MonitorAlarm.service;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.zksy.WaterSupply.MonitorAlarm.dto.in.WaterThresholdSaveInDTO;
 import com.zksy.base.alarm.domain.WarningThreshold;
+import com.zksy.base.domain.EquipmentBase;
 
 import java.util.List;
 
@@ -23,6 +24,11 @@ public interface IWaterSupplyThresholdService {
      */
     List<WarningThreshold> findThresholdList(String deviceCode, String warningType, String warningCode);
 
+    /**
+     * 查询可配置阈值的供水监测设备
+     */
+    List<EquipmentBase> findThresholdDevices();
+
     /**
      * 根据ID查询阈值详情
      */

+ 12 - 1
pipe-network-service/zksy-system/src/main/java/com/zksy/WaterSupply/MonitorAlarm/service/impl/WaterSupplyThresholdServiceImpl.java

@@ -67,6 +67,13 @@ public class WaterSupplyThresholdServiceImpl extends ServiceImpl<WarningThreshol
         return this.list(wrapper);
     }
 
+    @Override
+    public List<EquipmentBase> findThresholdDevices() {
+        return waterEquipmentList().stream()
+                .filter(equipment -> StrUtil.isNotBlank(equipment.getEquipmentCode()))
+                .collect(Collectors.toList());
+    }
+
     @Override
     public WarningThreshold getThresholdDetail(String id) {
         WarningThreshold threshold = this.getById(id);
@@ -182,7 +189,7 @@ public class WaterSupplyThresholdServiceImpl extends ServiceImpl<WarningThreshol
     }
 
     private Set<String> resolveWaterDeviceCodes() {
-        List<EquipmentBase> equipmentList = equipmentBaseService.findByTopLevelType(waterTopLevelTypeName);
+        List<EquipmentBase> equipmentList = waterEquipmentList();
         if (CollUtil.isEmpty(equipmentList)) {
             return Collections.emptySet();
         }
@@ -193,6 +200,10 @@ public class WaterSupplyThresholdServiceImpl extends ServiceImpl<WarningThreshol
                 .collect(Collectors.toSet());
     }
 
+    private List<EquipmentBase> waterEquipmentList() {
+        return equipmentBaseService.findByTopLevelType(waterTopLevelTypeName);
+    }
+
     private List<String> splitDeviceCodes(String deviceCode) {
         if (StrUtil.isBlank(deviceCode)) {
             return Collections.emptyList();

+ 3 - 1
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/CockpitErrorCategory.java

@@ -2,5 +2,7 @@ package com.zksy.cockpit.foundation;
 
 public enum CockpitErrorCategory {
     UNAUTHORIZED,
-    SERVER_ERROR
+    SERVER_ERROR,
+    INVALID_REQUEST,
+    SNAPSHOT_EXPIRED
 }

+ 7 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/CockpitInvalidRequestException.java

@@ -0,0 +1,7 @@
+package com.zksy.cockpit.foundation;
+
+public class CockpitInvalidRequestException extends RuntimeException {
+    public CockpitInvalidRequestException(String message) {
+        super(message);
+    }
+}

+ 7 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/CockpitSnapshotExpiredException.java

@@ -0,0 +1,7 @@
+package com.zksy.cockpit.foundation;
+
+public class CockpitSnapshotExpiredException extends RuntimeException {
+    public CockpitSnapshotExpiredException(String message) {
+        super(message);
+    }
+}

+ 20 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitCoordinateValidation.java

@@ -0,0 +1,20 @@
+package com.zksy.cockpit.map;
+
+public class CockpitCoordinateValidation {
+    private final String state;
+    private final String reason;
+    private final boolean renderable;
+    private final String crs;
+
+    public CockpitCoordinateValidation(String state, String reason, boolean renderable, String crs) {
+        this.state = state;
+        this.reason = reason;
+        this.renderable = renderable;
+        this.crs = crs;
+    }
+
+    public String getState() { return state; }
+    public String getReason() { return reason; }
+    public boolean isRenderable() { return renderable; }
+    public String getCrs() { return crs; }
+}

+ 24 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitCoordinateValidationSummary.java

@@ -0,0 +1,24 @@
+package com.zksy.cockpit.map;
+
+public class CockpitCoordinateValidationSummary {
+    private final String crs;
+    private final int totalObjectCount;
+    private final int renderableCount;
+    private final int invalidCount;
+    private final int unverifiedCount;
+
+    public CockpitCoordinateValidationSummary(String crs, int totalObjectCount, int renderableCount, int invalidCount,
+            int unverifiedCount) {
+        this.crs = crs;
+        this.totalObjectCount = totalObjectCount;
+        this.renderableCount = renderableCount;
+        this.invalidCount = invalidCount;
+        this.unverifiedCount = unverifiedCount;
+    }
+
+    public String getCrs() { return crs; }
+    public int getTotalObjectCount() { return totalObjectCount; }
+    public int getRenderableCount() { return renderableCount; }
+    public int getInvalidCount() { return invalidCount; }
+    public int getUnverifiedCount() { return unverifiedCount; }
+}

+ 90 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitMapSnapshot.java

@@ -0,0 +1,90 @@
+package com.zksy.cockpit.map;
+
+import java.math.BigDecimal;
+import java.util.Collections;
+import java.util.List;
+
+public class CockpitMapSnapshot {
+    private final String snapshotAt;
+    private final List<SourceMonitoringPoint> monitoringPoints;
+    private final List<SourceSmartDevice> smartDevices;
+
+    public CockpitMapSnapshot(String snapshotAt, List<SourceMonitoringPoint> monitoringPoints,
+            List<SourceSmartDevice> smartDevices) {
+        this.snapshotAt = snapshotAt;
+        this.monitoringPoints = monitoringPoints;
+        this.smartDevices = smartDevices;
+    }
+
+    public static CockpitMapSnapshot empty(String snapshotAt) {
+        return new CockpitMapSnapshot(snapshotAt, Collections.emptyList(), Collections.emptyList());
+    }
+
+    public String getSnapshotAt() {
+        return snapshotAt;
+    }
+
+    public List<SourceMonitoringPoint> getMonitoringPoints() {
+        return monitoringPoints;
+    }
+
+    public List<SourceSmartDevice> getSmartDevices() {
+        return smartDevices;
+    }
+
+    public static class SourceMonitoringPoint {
+        private final String pointCode;
+        private final String pointName;
+        private final String pointStatus;
+        private final List<String> statusReasons;
+        private final BigDecimal longitude;
+        private final BigDecimal latitude;
+        private final boolean coordinateVerified;
+        private final List<String> deviceCodes;
+
+        public SourceMonitoringPoint(String pointCode, String pointName, String pointStatus,
+                List<String> statusReasons, BigDecimal longitude, BigDecimal latitude, boolean coordinateVerified,
+                List<String> deviceCodes) {
+            this.pointCode = pointCode;
+            this.pointName = pointName;
+            this.pointStatus = pointStatus;
+            this.statusReasons = statusReasons;
+            this.longitude = longitude;
+            this.latitude = latitude;
+            this.coordinateVerified = coordinateVerified;
+            this.deviceCodes = deviceCodes;
+        }
+
+        public String getPointCode() { return pointCode; }
+        public String getPointName() { return pointName; }
+        public String getPointStatus() { return pointStatus; }
+        public List<String> getStatusReasons() { return statusReasons; }
+        public BigDecimal getLongitude() { return longitude; }
+        public BigDecimal getLatitude() { return latitude; }
+        public boolean isCoordinateVerified() { return coordinateVerified; }
+        public List<String> getDeviceCodes() { return deviceCodes; }
+    }
+
+    public static class SourceSmartDevice {
+        private final String deviceCode;
+        private final String deviceName;
+        private final BigDecimal longitude;
+        private final BigDecimal latitude;
+        private final boolean coordinateVerified;
+
+        public SourceSmartDevice(String deviceCode, String deviceName, BigDecimal longitude, BigDecimal latitude,
+                boolean coordinateVerified) {
+            this.deviceCode = deviceCode;
+            this.deviceName = deviceName;
+            this.longitude = longitude;
+            this.latitude = latitude;
+            this.coordinateVerified = coordinateVerified;
+        }
+
+        public String getDeviceCode() { return deviceCode; }
+        public String getDeviceName() { return deviceName; }
+        public BigDecimal getLongitude() { return longitude; }
+        public BigDecimal getLatitude() { return latitude; }
+        public boolean isCoordinateVerified() { return coordinateVerified; }
+    }
+}

+ 5 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitMapSnapshotAdapter.java

@@ -0,0 +1,5 @@
+package com.zksy.cockpit.map;
+
+public interface CockpitMapSnapshotAdapter {
+    CockpitMapSnapshot load(String snapshotAt);
+}

+ 50 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitMapSnapshotResponse.java

@@ -0,0 +1,50 @@
+package com.zksy.cockpit.map;
+
+import java.util.List;
+
+public class CockpitMapSnapshotResponse {
+    private final String requestId;
+    private final String apiVersion;
+    private final boolean readOnly;
+    private final int page;
+    private final int pageSize;
+    private final String snapshotAt;
+    private final int total;
+    private final String batchUpdatedAt;
+    private final boolean complete;
+    private final List<CockpitMonitoringPointSummary> monitoringPoints;
+    private final List<CockpitUnassociatedEquipmentSummary> unassociatedEquipment;
+    private final CockpitCoordinateValidationSummary coordinateValidation;
+
+    public CockpitMapSnapshotResponse(String requestId, String apiVersion, boolean readOnly, int page, int pageSize,
+            String snapshotAt, int total, String batchUpdatedAt, boolean complete,
+            List<CockpitMonitoringPointSummary> monitoringPoints,
+            List<CockpitUnassociatedEquipmentSummary> unassociatedEquipment,
+            CockpitCoordinateValidationSummary coordinateValidation) {
+        this.requestId = requestId;
+        this.apiVersion = apiVersion;
+        this.readOnly = readOnly;
+        this.page = page;
+        this.pageSize = pageSize;
+        this.snapshotAt = snapshotAt;
+        this.total = total;
+        this.batchUpdatedAt = batchUpdatedAt;
+        this.complete = complete;
+        this.monitoringPoints = monitoringPoints;
+        this.unassociatedEquipment = unassociatedEquipment;
+        this.coordinateValidation = coordinateValidation;
+    }
+
+    public String getRequestId() { return requestId; }
+    public String getApiVersion() { return apiVersion; }
+    public boolean isReadOnly() { return readOnly; }
+    public int getPage() { return page; }
+    public int getPageSize() { return pageSize; }
+    public String getSnapshotAt() { return snapshotAt; }
+    public int getTotal() { return total; }
+    public String getBatchUpdatedAt() { return batchUpdatedAt; }
+    public boolean isComplete() { return complete; }
+    public List<CockpitMonitoringPointSummary> getMonitoringPoints() { return monitoringPoints; }
+    public List<CockpitUnassociatedEquipmentSummary> getUnassociatedEquipment() { return unassociatedEquipment; }
+    public CockpitCoordinateValidationSummary getCoordinateValidation() { return coordinateValidation; }
+}

+ 258 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitMapSnapshotService.java

@@ -0,0 +1,258 @@
+package com.zksy.cockpit.map;
+
+import com.zksy.cockpit.foundation.CockpitErrorCategory;
+import com.zksy.cockpit.foundation.CockpitErrorPayload;
+import com.zksy.cockpit.foundation.CockpitInvalidRequestException;
+import com.zksy.cockpit.foundation.CockpitSnapshotExpiredException;
+import org.springframework.stereotype.Service;
+
+import java.math.BigDecimal;
+import java.time.Clock;
+import java.time.OffsetDateTime;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Locale;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+@Service
+public class CockpitMapSnapshotService {
+    private static final int DEFAULT_PAGE_SIZE = 500;
+    private static final int MAX_PAGE_SIZE = 1000;
+    private static final String WGS84 = "WGS84";
+
+    private final CockpitMapSnapshotAdapter adapter;
+    private final Clock clock;
+
+    public CockpitMapSnapshotService(CockpitMapSnapshotAdapter adapter, Clock clock) {
+        this.adapter = adapter;
+        this.clock = clock;
+    }
+
+    public CockpitMapSnapshotResponse load(String requestId, Integer page, Integer pageSize, String snapshotAt) {
+        int safePage = page == null ? 1 : page;
+        int safePageSize = pageSize == null ? DEFAULT_PAGE_SIZE : pageSize;
+        validatePagination(safePage, safePageSize, snapshotAt);
+        validateSnapshotAt(snapshotAt);
+
+        CockpitMapSnapshot snapshot = adapter.load(snapshotAt);
+        if (snapshot == null) {
+            snapshot = CockpitMapSnapshot.empty(resolveSnapshotAt(snapshotAt));
+        }
+        if (snapshot.getSnapshotAt() == null) {
+            throw new CockpitSnapshotExpiredException("snapshot timestamp missing");
+        }
+        if (snapshotAt != null && !snapshotAt.equals(snapshot.getSnapshotAt())) {
+            throw new CockpitSnapshotExpiredException("snapshot timestamp changed");
+        }
+
+        List<CockpitMonitoringPointSummary> points = monitoringPoints(snapshot);
+        List<CockpitUnassociatedEquipmentSummary> equipment = unassociatedEquipment(snapshot);
+        int total = points.size() + equipment.size();
+        int fromIndex = Math.min((safePage - 1) * safePageSize, total);
+        int toIndex = Math.min(fromIndex + safePageSize, total);
+        List<CockpitMonitoringPointSummary> pagePoints = points.subList(
+                clamp(fromIndex, 0, points.size()),
+                clamp(toIndex, 0, points.size()));
+        List<CockpitUnassociatedEquipmentSummary> pageEquipment = equipment.subList(
+                clamp(fromIndex - points.size(), 0, equipment.size()),
+                clamp(toIndex - points.size(), 0, equipment.size()));
+
+        return new CockpitMapSnapshotResponse(
+                requestId,
+                "v1",
+                true,
+                safePage,
+                safePageSize,
+                snapshot.getSnapshotAt(),
+                total,
+                snapshot.getSnapshotAt(),
+                toIndex >= total,
+                pagePoints,
+                pageEquipment,
+                coordinateValidation(points, equipment));
+    }
+
+    public CockpitErrorPayload invalidRequest(String requestId) {
+        return new CockpitErrorPayload(
+                CockpitErrorCategory.INVALID_REQUEST,
+                "地图快照分页参数不合法",
+                requestId,
+                businessTime());
+    }
+
+    public CockpitErrorPayload snapshotExpired(String requestId) {
+        return new CockpitErrorPayload(
+                CockpitErrorCategory.SNAPSHOT_EXPIRED,
+                "地图快照已失效,请重新加载",
+                requestId,
+                businessTime());
+    }
+
+    public CockpitErrorPayload serverError(String requestId) {
+        return new CockpitErrorPayload(
+                CockpitErrorCategory.SERVER_ERROR,
+                "驾驶舱地图快照数据暂不可用",
+                requestId,
+                businessTime());
+    }
+
+    private static void validatePagination(int page, int pageSize, String snapshotAt) {
+        if (page < 1 || pageSize < 1 || pageSize > MAX_PAGE_SIZE || (page > 1 && isBlank(snapshotAt))) {
+            throw new CockpitInvalidRequestException("invalid pagination");
+        }
+    }
+
+    private static void validateSnapshotAt(String snapshotAt) {
+        if (snapshotAt == null) {
+            return;
+        }
+        try {
+            OffsetDateTime.parse(snapshotAt);
+        } catch (DateTimeParseException exception) {
+            throw new CockpitInvalidRequestException("invalid snapshot timestamp");
+        }
+    }
+
+    private static boolean isBlank(String value) {
+        return value == null || value.trim().isEmpty();
+    }
+
+    private String resolveSnapshotAt(String snapshotAt) {
+        return snapshotAt == null ? businessTime() : snapshotAt;
+    }
+
+    private static List<CockpitMonitoringPointSummary> monitoringPoints(CockpitMapSnapshot snapshot) {
+        if (snapshot.getMonitoringPoints() == null) {
+            return Collections.emptyList();
+        }
+        return snapshot.getMonitoringPoints().stream()
+                .map(CockpitMapSnapshotService::monitoringPoint)
+                .sorted(Comparator.comparing(CockpitMonitoringPointSummary::getPointCode,
+                        Comparator.nullsLast(String::compareTo)))
+                .collect(Collectors.toList());
+    }
+
+    private static CockpitMonitoringPointSummary monitoringPoint(
+            CockpitMapSnapshot.SourceMonitoringPoint source) {
+        return new CockpitMonitoringPointSummary(
+                source.getPointCode(),
+                source.getPointName(),
+                pointStatus(source.getPointStatus()),
+                source.getStatusReasons() == null ? Collections.emptyList() : source.getStatusReasons(),
+                source.getLongitude(),
+                source.getLatitude(),
+                validate(source.getLongitude(), source.getLatitude(), source.isCoordinateVerified()));
+    }
+
+    private static String pointStatus(String status) {
+        return status == null || status.trim().isEmpty()
+                ? "UNKNOWN"
+                : status.trim().toUpperCase(Locale.ROOT);
+    }
+
+    private static List<CockpitUnassociatedEquipmentSummary> unassociatedEquipment(CockpitMapSnapshot snapshot) {
+        if (snapshot.getSmartDevices() == null) {
+            return Collections.emptyList();
+        }
+        Set<String> associatedDeviceCodes = associatedDeviceCodes(snapshot);
+        return snapshot.getSmartDevices().stream()
+                .filter(device -> device.getDeviceCode() == null || !associatedDeviceCodes.contains(device.getDeviceCode()))
+                .map(CockpitMapSnapshotService::unassociatedEquipment)
+                .sorted(Comparator.comparing(CockpitUnassociatedEquipmentSummary::getDeviceCode,
+                        Comparator.nullsLast(String::compareTo)))
+                .collect(Collectors.toList());
+    }
+
+    private static Set<String> associatedDeviceCodes(CockpitMapSnapshot snapshot) {
+        if (snapshot.getMonitoringPoints() == null) {
+            return Collections.emptySet();
+        }
+        return snapshot.getMonitoringPoints().stream()
+                .map(CockpitMapSnapshot.SourceMonitoringPoint::getDeviceCodes)
+                .filter(Objects::nonNull)
+                .flatMap(List::stream)
+                .filter(Objects::nonNull)
+                .collect(Collectors.toSet());
+    }
+
+    private static CockpitUnassociatedEquipmentSummary unassociatedEquipment(
+            CockpitMapSnapshot.SourceSmartDevice source) {
+        return new CockpitUnassociatedEquipmentSummary(
+                source.getDeviceCode(),
+                source.getDeviceName(),
+                source.getLongitude(),
+                source.getLatitude(),
+                validate(source.getLongitude(), source.getLatitude(), source.isCoordinateVerified()));
+    }
+
+    private static int clamp(int value, int minimum, int maximum) {
+        return Math.max(minimum, Math.min(value, maximum));
+    }
+
+    private static CockpitCoordinateValidation validate(BigDecimal longitude, BigDecimal latitude,
+            boolean coordinateVerified) {
+        if (!coordinateVerified) {
+            return new CockpitCoordinateValidation("UNVERIFIED", "UNVERIFIED_COORDINATE", false, WGS84);
+        }
+        if (longitude == null) {
+            return new CockpitCoordinateValidation("INVALID", "MISSING_LONGITUDE", false, WGS84);
+        }
+        if (latitude == null) {
+            return new CockpitCoordinateValidation("INVALID", "MISSING_LATITUDE", false, WGS84);
+        }
+        if (longitude.compareTo(new BigDecimal("-180")) < 0 || longitude.compareTo(new BigDecimal("180")) > 0) {
+            return new CockpitCoordinateValidation("INVALID", "LONGITUDE_OUT_OF_RANGE", false, WGS84);
+        }
+        if (latitude.compareTo(new BigDecimal("-90")) < 0 || latitude.compareTo(new BigDecimal("90")) > 0) {
+            return new CockpitCoordinateValidation("INVALID", "LATITUDE_OUT_OF_RANGE", false, WGS84);
+        }
+        if (longitude.signum() == 0 && latitude.signum() == 0) {
+            return new CockpitCoordinateValidation("INVALID", "ZERO_COORDINATE", false, WGS84);
+        }
+        return new CockpitCoordinateValidation("VALID", null, true, WGS84);
+    }
+
+    private static CockpitCoordinateValidationSummary coordinateValidation(
+            List<CockpitMonitoringPointSummary> points, List<CockpitUnassociatedEquipmentSummary> equipment) {
+        int renderableCount = 0;
+        int invalidCount = 0;
+        int unverifiedCount = 0;
+        for (CockpitMonitoringPointSummary point : points) {
+            CockpitCoordinateValidation validation = point.getCoordinateValidation();
+            if (validation.isRenderable()) {
+                renderableCount++;
+            } else if ("INVALID".equals(validation.getState())) {
+                invalidCount++;
+            } else {
+                unverifiedCount++;
+            }
+        }
+        for (CockpitUnassociatedEquipmentSummary device : equipment) {
+            CockpitCoordinateValidation validation = device.getCoordinateValidation();
+            if (validation.isRenderable()) {
+                renderableCount++;
+            } else if ("INVALID".equals(validation.getState())) {
+                invalidCount++;
+            } else {
+                unverifiedCount++;
+            }
+        }
+        return new CockpitCoordinateValidationSummary(
+                WGS84,
+                points.size() + equipment.size(),
+                renderableCount,
+                invalidCount,
+                unverifiedCount);
+    }
+
+    private String businessTime() {
+        return DateTimeFormatter.ISO_OFFSET_DATE_TIME
+                .format(clock.instant().atZone(ZoneId.of("Asia/Shanghai")));
+    }
+}

+ 34 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitMonitoringPointSummary.java

@@ -0,0 +1,34 @@
+package com.zksy.cockpit.map;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+public class CockpitMonitoringPointSummary {
+    private final String pointCode;
+    private final String pointName;
+    private final String pointStatus;
+    private final List<String> statusReasons;
+    private final BigDecimal longitude;
+    private final BigDecimal latitude;
+    private final CockpitCoordinateValidation coordinateValidation;
+
+    public CockpitMonitoringPointSummary(String pointCode, String pointName, String pointStatus,
+            List<String> statusReasons, BigDecimal longitude, BigDecimal latitude,
+            CockpitCoordinateValidation coordinateValidation) {
+        this.pointCode = pointCode;
+        this.pointName = pointName;
+        this.pointStatus = pointStatus;
+        this.statusReasons = statusReasons;
+        this.longitude = longitude;
+        this.latitude = latitude;
+        this.coordinateValidation = coordinateValidation;
+    }
+
+    public String getPointCode() { return pointCode; }
+    public String getPointName() { return pointName; }
+    public String getPointStatus() { return pointStatus; }
+    public List<String> getStatusReasons() { return statusReasons; }
+    public BigDecimal getLongitude() { return longitude; }
+    public BigDecimal getLatitude() { return latitude; }
+    public CockpitCoordinateValidation getCoordinateValidation() { return coordinateValidation; }
+}

+ 26 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitUnassociatedEquipmentSummary.java

@@ -0,0 +1,26 @@
+package com.zksy.cockpit.map;
+
+import java.math.BigDecimal;
+
+public class CockpitUnassociatedEquipmentSummary {
+    private final String deviceCode;
+    private final String deviceName;
+    private final BigDecimal longitude;
+    private final BigDecimal latitude;
+    private final CockpitCoordinateValidation coordinateValidation;
+
+    public CockpitUnassociatedEquipmentSummary(String deviceCode, String deviceName, BigDecimal longitude,
+            BigDecimal latitude, CockpitCoordinateValidation coordinateValidation) {
+        this.deviceCode = deviceCode;
+        this.deviceName = deviceName;
+        this.longitude = longitude;
+        this.latitude = latitude;
+        this.coordinateValidation = coordinateValidation;
+    }
+
+    public String getDeviceCode() { return deviceCode; }
+    public String getDeviceName() { return deviceName; }
+    public BigDecimal getLongitude() { return longitude; }
+    public BigDecimal getLatitude() { return latitude; }
+    public CockpitCoordinateValidation getCoordinateValidation() { return coordinateValidation; }
+}

+ 26 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/EmptyCockpitMapSnapshotAdapter.java

@@ -0,0 +1,26 @@
+package com.zksy.cockpit.map;
+
+import org.springframework.stereotype.Service;
+
+import java.time.Clock;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+
+@Service
+public class EmptyCockpitMapSnapshotAdapter implements CockpitMapSnapshotAdapter {
+    private final Clock clock;
+
+    public EmptyCockpitMapSnapshotAdapter(Clock clock) {
+        this.clock = clock;
+    }
+
+    @Override
+    public CockpitMapSnapshot load(String snapshotAt) {
+        return CockpitMapSnapshot.empty(snapshotAt == null ? businessTime() : snapshotAt);
+    }
+
+    private String businessTime() {
+        return DateTimeFormatter.ISO_OFFSET_DATE_TIME
+                .format(clock.instant().atZone(ZoneId.of("Asia/Shanghai")));
+    }
+}

+ 12 - 0
pipe-network-service/zksy-system/src/test/java/com/zksy/WaterSupply/MonitorAlarm/WaterSupplyThresholdServiceContractTest.java

@@ -287,6 +287,18 @@ class WaterSupplyThresholdServiceContractTest {
         assertFalse(values.contains("GAS-FLOW-1"));
     }
 
+    @Test
+    void thresholdDeviceOptionsOnlyContainWaterDevices() {
+        List<EquipmentBase> devices = service.findThresholdDevices();
+
+        assertEquals(Arrays.asList("WS-FLOW-1", "WS-FLOW-2", "WS-PRESSURE-1"), devices.stream()
+                .map(EquipmentBase::getEquipmentCode)
+                .collect(Collectors.toList()));
+        assertFalse(devices.stream()
+                .map(EquipmentBase::getEquipmentCode)
+                .anyMatch(code -> code.startsWith("YJ-")));
+    }
+
     @Test
     void pageReturnsEmptyDataWhenNoWaterDevicesExist() {
         when(equipmentBaseService.findByTopLevelType("供水")).thenReturn(Collections.emptyList());