Просмотр исходного кода

feat(cockpit): define device identity and domain boundary (#19)

Kazerin 2 дней назад
Родитель
Сommit
b5d7e8678c
14 измененных файлов с 814 добавлено и 0 удалено
  1. 62 0
      docs/cockpit/device-boundary-demo.html
  2. 47 0
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitDeviceBoundaryController.java
  3. 215 0
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitDeviceBoundaryControllerTest.java
  4. 33 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/device/CockpitDeviceBoundary.java
  5. 5 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/device/CockpitDeviceBoundaryAdapter.java
  6. 33 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/device/CockpitDeviceBoundaryResponse.java
  7. 216 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/device/CockpitDeviceBoundaryService.java
  8. 82 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/device/CockpitDeviceBoundarySnapshot.java
  9. 30 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/device/CockpitDeviceBoundaryStatistics.java
  10. 14 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/device/CockpitDeviceType.java
  11. 27 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/device/CockpitFacilityBoundary.java
  12. 18 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/device/CockpitIndustryStatistics.java
  13. 19 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/device/CockpitTypeScope.java
  14. 13 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/device/EmptyCockpitDeviceBoundaryAdapter.java

+ 62 - 0
docs/cockpit/device-boundary-demo.html

@@ -0,0 +1,62 @@
+<!doctype html>
+<html lang="zh-CN">
+<head>
+  <meta charset="utf-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1" />
+  <title>Cockpit Device Boundary Demo</title>
+  <style>
+    body { margin:0; min-height:100vh; background:#0b1622; color:#e8f1f8; font:15px/1.5 "Microsoft YaHei",sans-serif; }
+    main { width:min(1180px,calc(100vw - 32px)); margin:32px auto; background:#102030; border:1px solid #21465f; padding:24px; }
+    h1 { margin:0 0 6px; font-size:22px; } .endpoint { color:#78a2b8; font-family:Consolas,monospace; margin:0 0 18px; }
+    .summary { display:grid; grid-template-columns:repeat(5,1fr); gap:10px; margin-bottom:18px; }
+    .card { padding:12px; border:1px solid #2a5a75; background:#0d1922; }
+    .card strong { display:block; font-size:22px; } .card span { color:#8fb3c7; }
+    table { width:100%; border-collapse:collapse; margin-top:16px; } th,td { border:1px solid #2a5a75; padding:8px; text-align:left; }
+    th { background:#123247; } .facility td:first-child { color:#61d095; } .device td:first-child { color:#78a2b8; }
+    .unknown { color:#f0ad4e; } code { color:#c9e7f7; }
+  </style>
+</head>
+<body>
+<main>
+  <h1>驾驶舱设备身份与领域边界演示</h1>
+  <p class="endpoint">GET /api/cockpit/v1/device-boundary</p>
+  <section class="summary">
+    <div class="card"><strong id="facility-count"></strong><span>设施</span></div>
+    <div class="card"><strong id="device-count"></strong><span>智能设备</span></div>
+    <div class="card"><strong id="formal-device-count"></strong><span>正式范围设备</span></div>
+    <div class="card"><strong id="unknown-count"></strong><span>未知类型</span></div>
+    <div class="card"><strong id="mismatch-count"></strong><span>类型边界冲突</span></div>
+  </section>
+  <table>
+    <thead><tr><th>编码</th><th>名称</th><th>类型</th><th>行业</th><th>排水范围</th><th>对象</th><th>正式范围</th></tr></thead>
+    <tbody id="objects"></tbody>
+  </table>
+</main>
+<script>
+const payload = {
+  requestId:'demo-device-boundary',apiVersion:'v1',readOnly:true,
+  typeScope:{formalIndustries:['GAS','WATER_SUPPLY','DRAINAGE'],drainageScopes:['RAINWATER','SEWAGE'],objectKinds:['FACILITY','SMART_DEVICE']},
+  facilities:[
+    {facilityCode:'F-WATER-001',facilityName:'第一水厂',facilityType:{code:'WATER_PLANT',name:'水厂'},industry:'WATER_SUPPLY',objectKind:'FACILITY',formalScope:true},
+    {facilityCode:'F-PUMP-001',facilityName:'城北泵站',facilityType:{code:'PUMP_STATION',name:'泵站'},industry:'WATER_SUPPLY',objectKind:'FACILITY',formalScope:true},
+    {facilityCode:'F-PIPE-001',facilityName:'供水主线',facilityType:{code:'PIPELINE',name:'供水管线'},industry:'WATER_SUPPLY',objectKind:'FACILITY',formalScope:true}
+  ],
+  devices:[
+    {deviceCode:'EQ-GAS-001',deviceName:'燃气浓度计-001',deviceType:{code:'GAS_SENSOR',name:'燃气浓度计'},industry:'GAS',objectKind:'SMART_DEVICE',formalScope:true,typeResolution:'RESOLVED'},
+    {deviceCode:'EQ-WS-001',deviceName:'供水压力计-001',deviceType:{code:'WATER_SUPPLY_SENSOR',name:'供水压力计'},industry:'WATER_SUPPLY',objectKind:'SMART_DEVICE',formalScope:true,typeResolution:'RESOLVED'},
+    {deviceCode:'EQ-RAIN-001',deviceName:'雨水流量计-001',deviceType:{code:'RAINWATER_SENSOR',name:'雨水流量计'},industry:'DRAINAGE',drainageScope:'RAINWATER',objectKind:'SMART_DEVICE',formalScope:true,typeResolution:'RESOLVED'},
+    {deviceCode:'EQ-SEW-001',deviceName:'污水环境监测仪-001',deviceType:{code:'SEWAGE_ENVIRONMENT_SENSOR',name:'污水环境监测仪'},industry:'DRAINAGE',drainageScope:'SEWAGE',objectKind:'SMART_DEVICE',formalScope:true,typeResolution:'RESOLVED'},
+    {deviceCode:'EQ-MH-001',deviceName:'智能井盖-001',deviceType:{code:'SMART_MANHOLE',name:'智能井盖'},objectKind:'SMART_DEVICE',formalScope:false,typeResolution:'RESOLVED'},
+    {deviceCode:'EQ-UNKNOWN-001',deviceName:'未知类型设备-001',deviceType:{code:'LEGACY_UNKNOWN_TYPE'},objectKind:'SMART_DEVICE',formalScope:false,typeResolution:'UNKNOWN_TYPE'}
+  ],
+  statistics:{facilityCount:3,smartDeviceCount:6,formalSmartDeviceCount:4,unknownTypeCount:1,typeKindMismatchCount:0}
+};
+function row(item,isFacility){
+  return `<tr class="${isFacility?'facility':'device'} ${item.typeResolution==='UNKNOWN_TYPE'?'unknown':''}"><td><code>${item.facilityCode||item.deviceCode}</code></td><td>${item.facilityName||item.deviceName}</td><td>${(item.facilityType||item.deviceType||{}).name||'未知类型'}</td><td>${item.industry||'—'}</td><td>${item.drainageScope||'—'}</td><td>${item.objectKind}</td><td>${item.formalScope?'是':'否'}</td></tr>`;
+}
+document.getElementById('objects').innerHTML = payload.facilities.map(x=>row(x,true)).concat(payload.devices.map(x=>row(x,false))).join('');
+const stats=payload.statistics;
+facilityCount.textContent=stats.facilityCount;deviceCount.textContent=stats.smartDeviceCount;formalDeviceCount.textContent=stats.formalSmartDeviceCount;unknownCount.textContent=stats.unknownTypeCount;mismatchCount.textContent=stats.typeKindMismatchCount;
+</script>
+</body>
+</html>

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

@@ -0,0 +1,47 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.cockpit.device.CockpitDeviceBoundaryService;
+import com.zksy.cockpit.foundation.CockpitErrorPayload;
+import com.zksy.cockpit.foundation.CockpitReadException;
+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.RestController;
+
+import javax.servlet.http.HttpServletRequest;
+
+@RestController
+public class CockpitDeviceBoundaryController {
+    private static final String REQUEST_ID_HEADER = "X-Request-Id";
+
+    private final CockpitDeviceBoundaryService deviceBoundaryService;
+
+    public CockpitDeviceBoundaryController(CockpitDeviceBoundaryService deviceBoundaryService) {
+        this.deviceBoundaryService = deviceBoundaryService;
+    }
+
+    @GetMapping("/api/cockpit/v1/device-boundary")
+    @PreAuthorize("isAuthenticated()")
+    public ResponseEntity<AjaxResult> deviceBoundary(
+            @RequestHeader(value = REQUEST_ID_HEADER, required = false) String requestId) {
+        return ResponseEntity.ok(AjaxResult.success(deviceBoundaryService.load(resolveRequestId(requestId))));
+    }
+
+    @ExceptionHandler(CockpitReadException.class)
+    public ResponseEntity<AjaxResult> handleReadFailure(HttpServletRequest request) {
+        CockpitErrorPayload payload = deviceBoundaryService.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 String resolveRequestId(String requestId) {
+        return StringUtils.isNotEmpty(requestId) ? requestId : IdUtils.fastSimpleUUID();
+    }
+}

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

@@ -0,0 +1,215 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.cockpit.device.CockpitDeviceBoundaryAdapter;
+import com.zksy.cockpit.device.CockpitDeviceBoundaryService;
+import com.zksy.cockpit.device.CockpitDeviceBoundarySnapshot;
+import com.zksy.cockpit.device.CockpitDeviceBoundarySnapshot.SourceDevice;
+import com.zksy.cockpit.device.CockpitDeviceBoundarySnapshot.SourceFacility;
+import com.zksy.cockpit.device.CockpitDeviceBoundarySnapshot.SourceType;
+import com.zksy.cockpit.foundation.CockpitReadException;
+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.MvcResult;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import static org.hamcrest.Matchers.hasSize;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+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 CockpitDeviceBoundaryControllerTest {
+
+    private CockpitDeviceBoundaryAdapter adapter;
+    private MockMvc mockMvc;
+
+    @BeforeEach
+    void setUp() {
+        adapter = mock(CockpitDeviceBoundaryAdapter.class);
+        CockpitDeviceBoundaryService service = new CockpitDeviceBoundaryService(
+                adapter,
+                Clock.fixed(Instant.parse("2026-09-02T02:00:00Z"), ZoneOffset.UTC));
+        CockpitDeviceBoundaryController controller = new CockpitDeviceBoundaryController(service);
+        mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
+    }
+
+    @Test
+    void deviceBoundaryUsesCanonicalDeviceCodeAndSeparatesFacilitiesFromSmartDevices() throws Exception {
+        when(adapter.load()).thenReturn(fixture());
+
+        MvcResult result = mockMvc.perform(get("/api/cockpit/v1/device-boundary")
+                        .header("X-Request-Id", "device-boundary-request-001"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.msg").value("操作成功"))
+                .andExpect(jsonPath("$.data.requestId").value("device-boundary-request-001"))
+                .andExpect(jsonPath("$.data.apiVersion").value("v1"))
+                .andExpect(jsonPath("$.data.readOnly").value(true))
+                .andExpect(jsonPath("$.data.typeScope.formalIndustries", hasSize(3)))
+                .andExpect(jsonPath("$.data.typeScope.drainageScopes", hasSize(2)))
+                .andExpect(jsonPath("$.data.typeScope.objectKinds", hasSize(2)))
+                .andExpect(jsonPath("$.data.facilities", hasSize(3)))
+                .andExpect(jsonPath("$.data.devices", hasSize(6)))
+                .andExpect(jsonPath("$.data.facilities[0].facilityCode").value("F-WATER-001"))
+                .andExpect(jsonPath("$.data.facilities[0].objectKind").value("FACILITY"))
+                .andExpect(jsonPath("$.data.facilities[0].industry").value("WATER_SUPPLY"))
+                .andExpect(jsonPath("$.data.facilities[1].facilityCode").value("F-PUMP-001"))
+                .andExpect(jsonPath("$.data.facilities[1].objectKind").value("FACILITY"))
+                .andExpect(jsonPath("$.data.facilities[2].facilityCode").value("F-PIPE-001"))
+                .andExpect(jsonPath("$.data.facilities[2].objectKind").value("FACILITY"))
+                .andExpect(jsonPath("$.data.devices[0].deviceCode").value("EQ-GAS-001"))
+                .andExpect(jsonPath("$.data.devices[0].deviceType.code").value("GAS_SENSOR"))
+                .andExpect(jsonPath("$.data.devices[0].industry").value("GAS"))
+                .andExpect(jsonPath("$.data.devices[0].objectKind").value("SMART_DEVICE"))
+                .andExpect(jsonPath("$.data.devices[0].formalScope").value(true))
+                .andExpect(jsonPath("$.data.devices[1].deviceCode").value("EQ-WS-001"))
+                .andExpect(jsonPath("$.data.devices[1].deviceType.code").value("WATER_SUPPLY_SENSOR"))
+                .andExpect(jsonPath("$.data.devices[1].industry").value("WATER_SUPPLY"))
+                .andExpect(jsonPath("$.data.devices[1].objectKind").value("SMART_DEVICE"))
+                .andExpect(jsonPath("$.data.devices[2].deviceCode").value("EQ-RAIN-001"))
+                .andExpect(jsonPath("$.data.devices[2].deviceType.code").value("RAINWATER_SENSOR"))
+                .andExpect(jsonPath("$.data.devices[2].industry").value("DRAINAGE"))
+                .andExpect(jsonPath("$.data.devices[2].drainageScope").value("RAINWATER"))
+                .andExpect(jsonPath("$.data.devices[2].objectKind").value("SMART_DEVICE"))
+                .andExpect(jsonPath("$.data.devices[3].deviceCode").value("EQ-SEW-001"))
+                .andExpect(jsonPath("$.data.devices[3].deviceType.code").value("SEWAGE_ENVIRONMENT_SENSOR"))
+                .andExpect(jsonPath("$.data.devices[3].industry").value("DRAINAGE"))
+                .andExpect(jsonPath("$.data.devices[3].drainageScope").value("SEWAGE"))
+                .andExpect(jsonPath("$.data.devices[3].objectKind").value("SMART_DEVICE"))
+                .andExpect(jsonPath("$.data.devices[4].deviceCode").value("EQ-MH-001"))
+                .andExpect(jsonPath("$.data.devices[4].deviceType.code").value("SMART_MANHOLE"))
+                .andExpect(jsonPath("$.data.devices[4].objectKind").value("SMART_DEVICE"))
+                .andExpect(jsonPath("$.data.devices[5].deviceCode").value("EQ-UNKNOWN-001"))
+                .andExpect(jsonPath("$.data.devices[5].typeResolution").value("UNKNOWN_TYPE"))
+                .andExpect(jsonPath("$.data.devices[5].formalScope").value(false))
+                .andExpect(jsonPath("$.data.statistics.facilityCount").value(3))
+                .andExpect(jsonPath("$.data.statistics.smartDeviceCount").value(6))
+                .andExpect(jsonPath("$.data.statistics.formalSmartDeviceCount").value(4))
+                .andExpect(jsonPath("$.data.statistics.unknownTypeCount").value(1))
+                .andExpect(jsonPath("$.data.statistics.typeKindMismatchCount").value(0))
+                .andExpect(jsonPath("$.data.statistics.industries.gas.smartDeviceCount").value(1))
+                .andExpect(jsonPath("$.data.statistics.industries.waterSupply.facilityCount").value(3))
+                .andExpect(jsonPath("$.data.statistics.industries.waterSupply.smartDeviceCount").value(1))
+                .andExpect(jsonPath("$.data.statistics.industries.drainage.smartDeviceCount").value(2))
+                .andExpect(jsonPath("$.data.statistics.industries.drainage.rainwaterSmartDeviceCount").value(1))
+                .andExpect(jsonPath("$.data.statistics.industries.drainage.sewageSmartDeviceCount").value(1))
+                .andReturn();
+
+        String body = result.getResponse().getContentAsString();
+        assertFalse(body.contains("equipment_code"));
+        assertFalse(body.contains("equipmentId"));
+        assertFalse(body.contains("equipmentTypeId"));
+    }
+
+    @Test
+    void serverFailureReturnsTypedErrorInsteadOfPretendingToBeEmpty() throws Exception {
+        when(adapter.load()).thenThrow(new CockpitReadException("upstream unavailable"));
+
+        mockMvc.perform(get("/api/cockpit/v1/device-boundary")
+                        .header("X-Request-Id", "device-boundary-request-002"))
+                .andExpect(status().isInternalServerError())
+                .andExpect(jsonPath("$.code").value(500))
+                .andExpect(jsonPath("$.msg").value("驾驶舱设备边界数据暂不可用"))
+                .andExpect(jsonPath("$.data.category").value("SERVER_ERROR"))
+                .andExpect(jsonPath("$.data.message").value("驾驶舱设备边界数据暂不可用"))
+                .andExpect(jsonPath("$.data.requestId").value("device-boundary-request-002"))
+                .andExpect(jsonPath("$.data.serverTime").value("2026-09-02T10:00:00+08:00"));
+    }
+
+    @Test
+    void facilityFormalScopeRequiresFormalIndustryAndFacilityObjectKind() throws Exception {
+        when(adapter.load()).thenReturn(new CockpitDeviceBoundarySnapshot(
+                Arrays.asList(
+                        new SourceType("WATER_PLANT", "水厂", "WATER_SUPPLY", null, "FACILITY"),
+                        new SourceType("WATER_PLANT_DEVICE", "水厂设备", "WATER_SUPPLY", null, "SMART_DEVICE")),
+                Arrays.asList(
+                        new SourceFacility("F-WATER-001", "第一水厂", "WATER_PLANT"),
+                        new SourceFacility("F-UNKNOWN-001", "未知类型设施", "LEGACY_UNKNOWN_FACILITY_TYPE"),
+                        new SourceFacility("F-MISMATCH-001", "误挂设备类型的设施", "WATER_PLANT_DEVICE")),
+                Collections.emptyList()));
+
+        mockMvc.perform(get("/api/cockpit/v1/device-boundary"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.facilities[0].formalScope").value(true))
+                .andExpect(jsonPath("$.data.facilities[1].formalScope").value(false))
+                .andExpect(jsonPath("$.data.facilities[2].formalScope").value(false))
+                .andExpect(jsonPath("$.data.statistics.facilityCount").value(3))
+                .andExpect(jsonPath("$.data.statistics.industries.waterSupply.facilityCount").value(1))
+                .andExpect(jsonPath("$.data.statistics.unknownTypeCount").value(1))
+                .andExpect(jsonPath("$.data.statistics.typeKindMismatchCount").value(1));
+    }
+
+    @Test
+    void mutatingRequestIsRejectedBeforeReadAdapterIsCalled() throws Exception {
+        mockMvc.perform(post("/api/cockpit/v1/device-boundary"))
+                .andExpect(status().isMethodNotAllowed());
+
+        verifyNoInteractions(adapter);
+    }
+
+    @Test
+    void controllerOnlyExposesAuthenticatedReadOnlyOperations() {
+        assertTrue(Arrays.stream(CockpitDeviceBoundaryController.class.getDeclaredMethods())
+                .filter(method -> 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))
+                .allMatch(CockpitDeviceBoundaryControllerTest::isReadOnlyMappedMethod));
+
+        assertTrue(Arrays.stream(CockpitDeviceBoundaryController.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 static boolean isReadOnlyMappedMethod(java.lang.reflect.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 CockpitDeviceBoundarySnapshot fixture() {
+        List<SourceType> types = Arrays.asList(
+                new SourceType("WATER_PLANT", "水厂", "WATER_SUPPLY", null, "FACILITY"),
+                new SourceType("PUMP_STATION", "泵站", "WATER_SUPPLY", null, "FACILITY"),
+                new SourceType("PIPELINE", "供水管线", "WATER_SUPPLY", null, "FACILITY"),
+                new SourceType("GAS_SENSOR", "燃气浓度计", "GAS", null, "SMART_DEVICE"),
+                new SourceType("WATER_SUPPLY_SENSOR", "供水压力计", "WATER_SUPPLY", null, "SMART_DEVICE"),
+                new SourceType("RAINWATER_SENSOR", "雨水流量计", "DRAINAGE", "RAINWATER", "SMART_DEVICE"),
+                new SourceType("SEWAGE_ENVIRONMENT_SENSOR", "污水环境监测仪", "DRAINAGE", "SEWAGE", "SMART_DEVICE"),
+                new SourceType("SMART_MANHOLE", "智能井盖", null, null, "SMART_DEVICE"));
+
+        List<SourceFacility> facilities = Arrays.asList(
+                new SourceFacility("F-WATER-001", "第一水厂", "WATER_PLANT"),
+                new SourceFacility("F-PUMP-001", "城北泵站", "PUMP_STATION"),
+                new SourceFacility("F-PIPE-001", "供水主线", "PIPELINE"));
+
+        List<SourceDevice> devices = Arrays.asList(
+                new SourceDevice("EQ-GAS-001", "燃气浓度计-001", "GAS_SENSOR"),
+                new SourceDevice("EQ-WS-001", "供水压力计-001", "WATER_SUPPLY_SENSOR"),
+                new SourceDevice("EQ-RAIN-001", "雨水流量计-001", "RAINWATER_SENSOR"),
+                new SourceDevice("EQ-SEW-001", "污水环境监测仪-001", "SEWAGE_ENVIRONMENT_SENSOR"),
+                new SourceDevice("EQ-MH-001", "智能井盖-001", "SMART_MANHOLE"),
+                new SourceDevice("EQ-UNKNOWN-001", "未知类型设备-001", "LEGACY_UNKNOWN_TYPE"));
+
+        return new CockpitDeviceBoundarySnapshot(types, facilities, devices);
+    }
+}

+ 33 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/device/CockpitDeviceBoundary.java

@@ -0,0 +1,33 @@
+package com.zksy.cockpit.device;
+
+public class CockpitDeviceBoundary {
+    private final String deviceCode;
+    private final String deviceName;
+    private final CockpitDeviceType deviceType;
+    private final String industry;
+    private final String drainageScope;
+    private final String objectKind;
+    private final boolean formalScope;
+    private final String typeResolution;
+
+    public CockpitDeviceBoundary(String deviceCode, String deviceName, CockpitDeviceType deviceType,
+            String industry, String drainageScope, String objectKind, boolean formalScope, String typeResolution) {
+        this.deviceCode = deviceCode;
+        this.deviceName = deviceName;
+        this.deviceType = deviceType;
+        this.industry = industry;
+        this.drainageScope = drainageScope;
+        this.objectKind = objectKind;
+        this.formalScope = formalScope;
+        this.typeResolution = typeResolution;
+    }
+
+    public String getDeviceCode() { return deviceCode; }
+    public String getDeviceName() { return deviceName; }
+    public CockpitDeviceType getDeviceType() { return deviceType; }
+    public String getIndustry() { return industry; }
+    public String getDrainageScope() { return drainageScope; }
+    public String getObjectKind() { return objectKind; }
+    public boolean isFormalScope() { return formalScope; }
+    public String getTypeResolution() { return typeResolution; }
+}

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

@@ -0,0 +1,5 @@
+package com.zksy.cockpit.device;
+
+public interface CockpitDeviceBoundaryAdapter {
+    CockpitDeviceBoundarySnapshot load();
+}

+ 33 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/device/CockpitDeviceBoundaryResponse.java

@@ -0,0 +1,33 @@
+package com.zksy.cockpit.device;
+
+import java.util.List;
+
+public class CockpitDeviceBoundaryResponse {
+    private final String requestId;
+    private final String apiVersion;
+    private final boolean readOnly;
+    private final CockpitTypeScope typeScope;
+    private final List<CockpitFacilityBoundary> facilities;
+    private final List<CockpitDeviceBoundary> devices;
+    private final CockpitDeviceBoundaryStatistics statistics;
+
+    public CockpitDeviceBoundaryResponse(String requestId, String apiVersion, boolean readOnly,
+            CockpitTypeScope typeScope, List<CockpitFacilityBoundary> facilities,
+            List<CockpitDeviceBoundary> devices, CockpitDeviceBoundaryStatistics statistics) {
+        this.requestId = requestId;
+        this.apiVersion = apiVersion;
+        this.readOnly = readOnly;
+        this.typeScope = typeScope;
+        this.facilities = facilities;
+        this.devices = devices;
+        this.statistics = statistics;
+    }
+
+    public String getRequestId() { return requestId; }
+    public String getApiVersion() { return apiVersion; }
+    public boolean isReadOnly() { return readOnly; }
+    public CockpitTypeScope getTypeScope() { return typeScope; }
+    public List<CockpitFacilityBoundary> getFacilities() { return facilities; }
+    public List<CockpitDeviceBoundary> getDevices() { return devices; }
+    public CockpitDeviceBoundaryStatistics getStatistics() { return statistics; }
+}

+ 216 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/device/CockpitDeviceBoundaryService.java

@@ -0,0 +1,216 @@
+package com.zksy.cockpit.device;
+
+import com.zksy.cockpit.foundation.CockpitErrorCategory;
+import com.zksy.cockpit.foundation.CockpitErrorPayload;
+import org.springframework.stereotype.Service;
+
+import java.time.Clock;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+@Service
+public class CockpitDeviceBoundaryService {
+    private static final List<String> FORMAL_INDUSTRIES =
+            Collections.unmodifiableList(Arrays.asList("GAS", "WATER_SUPPLY", "DRAINAGE"));
+    private static final List<String> DRAINAGE_SCOPES =
+            Collections.unmodifiableList(Arrays.asList("RAINWATER", "SEWAGE"));
+    private static final List<String> OBJECT_KINDS =
+            Collections.unmodifiableList(Arrays.asList("FACILITY", "SMART_DEVICE"));
+
+    private final CockpitDeviceBoundaryAdapter adapter;
+    private final Clock clock;
+
+    public CockpitDeviceBoundaryService(CockpitDeviceBoundaryAdapter adapter, Clock clock) {
+        this.adapter = adapter;
+        this.clock = clock;
+    }
+
+    public CockpitDeviceBoundaryResponse load(String requestId) {
+        CockpitDeviceBoundarySnapshot snapshot = adapter.load();
+        Map<String, CockpitDeviceBoundarySnapshot.SourceType> types = snapshot == null || snapshot.getTypes() == null
+                ? Collections.emptyMap()
+                : snapshot.getTypes().stream()
+                        .filter(type -> type.getCode() != null)
+                        .collect(Collectors.toMap(
+                                CockpitDeviceBoundarySnapshot.SourceType::getCode,
+                                type -> type,
+                                (left, right) -> left));
+
+        List<CockpitFacilityBoundary> facilities = snapshot == null || snapshot.getFacilities() == null
+                ? Collections.emptyList()
+                : snapshot.getFacilities().stream()
+                        .map(facility -> mapFacility(facility, types))
+                        .collect(Collectors.toList());
+        List<CockpitDeviceBoundary> devices = snapshot == null || snapshot.getDevices() == null
+                ? Collections.emptyList()
+                : snapshot.getDevices().stream()
+                        .map(device -> mapDevice(device, types))
+                        .collect(Collectors.toList());
+        int typeKindMismatchCount = typeKindMismatchCount(snapshot, types);
+        int unknownTypeCount = unknownTypeCount(snapshot, types);
+
+        return new CockpitDeviceBoundaryResponse(
+                requestId,
+                "v1",
+                true,
+                new CockpitTypeScope(FORMAL_INDUSTRIES, DRAINAGE_SCOPES, OBJECT_KINDS),
+                facilities,
+                devices,
+                statistics(facilities, devices, unknownTypeCount, typeKindMismatchCount));
+    }
+
+    public CockpitErrorPayload serverError(String requestId) {
+        return new CockpitErrorPayload(
+                CockpitErrorCategory.SERVER_ERROR,
+                "驾驶舱设备边界数据暂不可用",
+                requestId,
+                businessTime());
+    }
+
+    private static CockpitFacilityBoundary mapFacility(CockpitDeviceBoundarySnapshot.SourceFacility facility,
+            Map<String, CockpitDeviceBoundarySnapshot.SourceType> types) {
+        CockpitDeviceBoundarySnapshot.SourceType type = types.get(facility.getTypeCode());
+        String industry = type == null ? null : type.getIndustry();
+        return new CockpitFacilityBoundary(
+                facility.getFacilityCode(),
+                facility.getFacilityName(),
+                new CockpitDeviceType(facility.getTypeCode(), type == null ? null : type.getName()),
+                "FACILITY",
+                industry,
+                type != null
+                        && "FACILITY".equals(type.getObjectKind())
+                        && industry != null
+                        && FORMAL_INDUSTRIES.contains(industry));
+    }
+
+    private static CockpitDeviceBoundary mapDevice(CockpitDeviceBoundarySnapshot.SourceDevice device,
+            Map<String, CockpitDeviceBoundarySnapshot.SourceType> types) {
+        CockpitDeviceBoundarySnapshot.SourceType type = types.get(device.getTypeCode());
+        String industry = type == null ? null : type.getIndustry();
+        String drainageScope = type == null ? null : type.getDrainageScope();
+        boolean formalScope = industry != null
+                && FORMAL_INDUSTRIES.contains(industry)
+                && (type != null && "SMART_DEVICE".equals(type.getObjectKind()));
+        String typeResolution = type == null
+                ? "UNKNOWN_TYPE"
+                : "SMART_DEVICE".equals(type.getObjectKind()) ? "RESOLVED" : "TYPE_KIND_MISMATCH";
+        return new CockpitDeviceBoundary(
+                device.getEquipmentCode(),
+                device.getDeviceName(),
+                new CockpitDeviceType(device.getTypeCode(), type == null ? null : type.getName()),
+                industry,
+                drainageScope,
+                "SMART_DEVICE",
+                formalScope,
+                typeResolution);
+    }
+
+    private static CockpitDeviceBoundaryStatistics statistics(List<CockpitFacilityBoundary> facilities,
+            List<CockpitDeviceBoundary> devices, int unknownTypeCount, int typeKindMismatchCount) {
+        Map<String, CockpitIndustryStatistics> industries = new LinkedHashMap<>();
+        industries.put("gas", new CockpitIndustryStatistics());
+        industries.put("waterSupply", new CockpitIndustryStatistics());
+        industries.put("drainage", new CockpitIndustryStatistics());
+
+        for (CockpitFacilityBoundary facility : facilities) {
+            if (facility.isFormalScope()) {
+                CockpitIndustryStatistics industryStatistics = industryBucket(industries, facility.getIndustry());
+                if (industryStatistics != null) {
+                    industryStatistics.addFacility();
+                }
+            }
+        }
+        for (CockpitDeviceBoundary device : devices) {
+            CockpitIndustryStatistics industryStatistics = industryBucket(industries, device.getIndustry());
+            if (industryStatistics != null) {
+                industryStatistics.addSmartDevice();
+            }
+            if ("DRAINAGE".equals(device.getIndustry()) && "RAINWATER".equals(device.getDrainageScope())) {
+                industries.get("drainage").addRainwaterSmartDevice();
+            } else if ("DRAINAGE".equals(device.getIndustry()) && "SEWAGE".equals(device.getDrainageScope())) {
+                industries.get("drainage").addSewageSmartDevice();
+            }
+        }
+
+        return new CockpitDeviceBoundaryStatistics(
+                facilities.size(),
+                devices.size(),
+                (int) devices.stream().filter(CockpitDeviceBoundary::isFormalScope).count(),
+                unknownTypeCount,
+                typeKindMismatchCount,
+                industries);
+    }
+
+    private static int unknownTypeCount(CockpitDeviceBoundarySnapshot snapshot,
+            Map<String, CockpitDeviceBoundarySnapshot.SourceType> types) {
+        if (snapshot == null) {
+            return 0;
+        }
+        int unknownCount = 0;
+        if (snapshot.getFacilities() != null) {
+            for (CockpitDeviceBoundarySnapshot.SourceFacility facility : snapshot.getFacilities()) {
+                if (!types.containsKey(facility.getTypeCode())) {
+                    unknownCount++;
+                }
+            }
+        }
+        if (snapshot.getDevices() != null) {
+            for (CockpitDeviceBoundarySnapshot.SourceDevice device : snapshot.getDevices()) {
+                if (!types.containsKey(device.getTypeCode())) {
+                    unknownCount++;
+                }
+            }
+        }
+        return unknownCount;
+    }
+
+    private static int typeKindMismatchCount(CockpitDeviceBoundarySnapshot snapshot,
+            Map<String, CockpitDeviceBoundarySnapshot.SourceType> types) {
+        if (snapshot == null) {
+            return 0;
+        }
+        int mismatchCount = 0;
+        if (snapshot.getFacilities() != null) {
+            for (CockpitDeviceBoundarySnapshot.SourceFacility facility : snapshot.getFacilities()) {
+                CockpitDeviceBoundarySnapshot.SourceType type = types.get(facility.getTypeCode());
+                if (type != null && !"FACILITY".equals(type.getObjectKind())) {
+                    mismatchCount++;
+                }
+            }
+        }
+        if (snapshot.getDevices() != null) {
+            for (CockpitDeviceBoundarySnapshot.SourceDevice device : snapshot.getDevices()) {
+                CockpitDeviceBoundarySnapshot.SourceType type = types.get(device.getTypeCode());
+                if (type != null && !"SMART_DEVICE".equals(type.getObjectKind())) {
+                    mismatchCount++;
+                }
+            }
+        }
+        return mismatchCount;
+    }
+
+    private static CockpitIndustryStatistics industryBucket(Map<String, CockpitIndustryStatistics> industries,
+            String industry) {
+        if ("GAS".equals(industry)) {
+            return industries.get("gas");
+        }
+        if ("WATER_SUPPLY".equals(industry)) {
+            return industries.get("waterSupply");
+        }
+        if ("DRAINAGE".equals(industry)) {
+            return industries.get("drainage");
+        }
+        return null;
+    }
+
+    private String businessTime() {
+        return DateTimeFormatter.ISO_OFFSET_DATE_TIME
+                .format(clock.instant().atZone(ZoneId.of("Asia/Shanghai")));
+    }
+}

+ 82 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/device/CockpitDeviceBoundarySnapshot.java

@@ -0,0 +1,82 @@
+package com.zksy.cockpit.device;
+
+import java.util.List;
+
+public class CockpitDeviceBoundarySnapshot {
+    private final List<SourceType> types;
+    private final List<SourceFacility> facilities;
+    private final List<SourceDevice> devices;
+
+    public CockpitDeviceBoundarySnapshot(List<SourceType> types, List<SourceFacility> facilities,
+            List<SourceDevice> devices) {
+        this.types = types;
+        this.facilities = facilities;
+        this.devices = devices;
+    }
+
+    public List<SourceType> getTypes() {
+        return types;
+    }
+
+    public List<SourceFacility> getFacilities() {
+        return facilities;
+    }
+
+    public List<SourceDevice> getDevices() {
+        return devices;
+    }
+
+    public static class SourceType {
+        private final String code;
+        private final String name;
+        private final String industry;
+        private final String drainageScope;
+        private final String objectKind;
+
+        public SourceType(String code, String name, String industry, String drainageScope, String objectKind) {
+            this.code = code;
+            this.name = name;
+            this.industry = industry;
+            this.drainageScope = drainageScope;
+            this.objectKind = objectKind;
+        }
+
+        public String getCode() { return code; }
+        public String getName() { return name; }
+        public String getIndustry() { return industry; }
+        public String getDrainageScope() { return drainageScope; }
+        public String getObjectKind() { return objectKind; }
+    }
+
+    public static class SourceFacility {
+        private final String facilityCode;
+        private final String facilityName;
+        private final String typeCode;
+
+        public SourceFacility(String facilityCode, String facilityName, String typeCode) {
+            this.facilityCode = facilityCode;
+            this.facilityName = facilityName;
+            this.typeCode = typeCode;
+        }
+
+        public String getFacilityCode() { return facilityCode; }
+        public String getFacilityName() { return facilityName; }
+        public String getTypeCode() { return typeCode; }
+    }
+
+    public static class SourceDevice {
+        private final String equipmentCode;
+        private final String deviceName;
+        private final String typeCode;
+
+        public SourceDevice(String equipmentCode, String deviceName, String typeCode) {
+            this.equipmentCode = equipmentCode;
+            this.deviceName = deviceName;
+            this.typeCode = typeCode;
+        }
+
+        public String getEquipmentCode() { return equipmentCode; }
+        public String getDeviceName() { return deviceName; }
+        public String getTypeCode() { return typeCode; }
+    }
+}

+ 30 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/device/CockpitDeviceBoundaryStatistics.java

@@ -0,0 +1,30 @@
+package com.zksy.cockpit.device;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+public class CockpitDeviceBoundaryStatistics {
+    private final int facilityCount;
+    private final int smartDeviceCount;
+    private final int formalSmartDeviceCount;
+    private final int unknownTypeCount;
+    private final int typeKindMismatchCount;
+    private final Map<String, CockpitIndustryStatistics> industries;
+
+    public CockpitDeviceBoundaryStatistics(int facilityCount, int smartDeviceCount, int formalSmartDeviceCount,
+            int unknownTypeCount, int typeKindMismatchCount, Map<String, CockpitIndustryStatistics> industries) {
+        this.facilityCount = facilityCount;
+        this.smartDeviceCount = smartDeviceCount;
+        this.formalSmartDeviceCount = formalSmartDeviceCount;
+        this.unknownTypeCount = unknownTypeCount;
+        this.typeKindMismatchCount = typeKindMismatchCount;
+        this.industries = new LinkedHashMap<>(industries);
+    }
+
+    public int getFacilityCount() { return facilityCount; }
+    public int getSmartDeviceCount() { return smartDeviceCount; }
+    public int getFormalSmartDeviceCount() { return formalSmartDeviceCount; }
+    public int getUnknownTypeCount() { return unknownTypeCount; }
+    public int getTypeKindMismatchCount() { return typeKindMismatchCount; }
+    public Map<String, CockpitIndustryStatistics> getIndustries() { return industries; }
+}

+ 14 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/device/CockpitDeviceType.java

@@ -0,0 +1,14 @@
+package com.zksy.cockpit.device;
+
+public class CockpitDeviceType {
+    private final String code;
+    private final String name;
+
+    public CockpitDeviceType(String code, String name) {
+        this.code = code;
+        this.name = name;
+    }
+
+    public String getCode() { return code; }
+    public String getName() { return name; }
+}

+ 27 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/device/CockpitFacilityBoundary.java

@@ -0,0 +1,27 @@
+package com.zksy.cockpit.device;
+
+public class CockpitFacilityBoundary {
+    private final String facilityCode;
+    private final String facilityName;
+    private final CockpitDeviceType facilityType;
+    private final String objectKind;
+    private final String industry;
+    private final boolean formalScope;
+
+    public CockpitFacilityBoundary(String facilityCode, String facilityName, CockpitDeviceType facilityType,
+            String objectKind, String industry, boolean formalScope) {
+        this.facilityCode = facilityCode;
+        this.facilityName = facilityName;
+        this.facilityType = facilityType;
+        this.objectKind = objectKind;
+        this.industry = industry;
+        this.formalScope = formalScope;
+    }
+
+    public String getFacilityCode() { return facilityCode; }
+    public String getFacilityName() { return facilityName; }
+    public CockpitDeviceType getFacilityType() { return facilityType; }
+    public String getObjectKind() { return objectKind; }
+    public String getIndustry() { return industry; }
+    public boolean isFormalScope() { return formalScope; }
+}

+ 18 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/device/CockpitIndustryStatistics.java

@@ -0,0 +1,18 @@
+package com.zksy.cockpit.device;
+
+public class CockpitIndustryStatistics {
+    private int facilityCount;
+    private int smartDeviceCount;
+    private int rainwaterSmartDeviceCount;
+    private int sewageSmartDeviceCount;
+
+    public int getFacilityCount() { return facilityCount; }
+    public int getSmartDeviceCount() { return smartDeviceCount; }
+    public int getRainwaterSmartDeviceCount() { return rainwaterSmartDeviceCount; }
+    public int getSewageSmartDeviceCount() { return sewageSmartDeviceCount; }
+
+    void addFacility() { facilityCount++; }
+    void addSmartDevice() { smartDeviceCount++; }
+    void addRainwaterSmartDevice() { rainwaterSmartDeviceCount++; }
+    void addSewageSmartDevice() { sewageSmartDeviceCount++; }
+}

+ 19 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/device/CockpitTypeScope.java

@@ -0,0 +1,19 @@
+package com.zksy.cockpit.device;
+
+import java.util.List;
+
+public class CockpitTypeScope {
+    private final List<String> formalIndustries;
+    private final List<String> drainageScopes;
+    private final List<String> objectKinds;
+
+    public CockpitTypeScope(List<String> formalIndustries, List<String> drainageScopes, List<String> objectKinds) {
+        this.formalIndustries = formalIndustries;
+        this.drainageScopes = drainageScopes;
+        this.objectKinds = objectKinds;
+    }
+
+    public List<String> getFormalIndustries() { return formalIndustries; }
+    public List<String> getDrainageScopes() { return drainageScopes; }
+    public List<String> getObjectKinds() { return objectKinds; }
+}

+ 13 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/device/EmptyCockpitDeviceBoundaryAdapter.java

@@ -0,0 +1,13 @@
+package com.zksy.cockpit.device;
+
+import org.springframework.stereotype.Component;
+
+import java.util.Collections;
+
+@Component
+public class EmptyCockpitDeviceBoundaryAdapter implements CockpitDeviceBoundaryAdapter {
+    @Override
+    public CockpitDeviceBoundarySnapshot load() {
+        return new CockpitDeviceBoundarySnapshot(Collections.emptyList(), Collections.emptyList(), Collections.emptyList());
+    }
+}