Parcourir la source

feat(cockpit): 新增实体/事件/工单只读详情端点与契约测试(票65)——六类设施详情、免鉴权事件详情、工单详情

Kazerin il y a 6 jours
Parent
commit
79f471a8ea
12 fichiers modifiés avec 756 ajouts et 0 suppressions
  1. 50 0
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitEntityDetailController.java
  2. 70 0
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitEventDetailController.java
  3. 63 0
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitWorkOrderDetailController.java
  4. 22 0
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitEntityDetailControllerTest.java
  5. 59 0
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitEntityDetailServiceTest.java
  6. 39 0
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitEventDetailServiceTest.java
  7. 23 0
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitOperatingMetricFacilityIdentityContractTest.java
  8. 22 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/entity/CockpitEntityCategory.java
  9. 79 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/entity/CockpitEntityDetailResponse.java
  10. 186 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/entity/CockpitEntityDetailService.java
  11. 5 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/entity/CockpitEntityNotFoundException.java
  12. 138 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/CockpitEventDetailService.java

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

@@ -0,0 +1,50 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.cockpit.entity.CockpitEntityDetailResponse;
+import com.zksy.cockpit.entity.CockpitEntityDetailService;
+import com.zksy.cockpit.entity.CockpitEntityNotFoundException;
+import com.zksy.cockpit.foundation.CockpitErrorPayload;
+import com.zksy.cockpit.foundation.CockpitInvalidRequestException;
+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.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.util.UriUtils;
+
+import javax.servlet.http.HttpServletRequest;
+import java.nio.charset.StandardCharsets;
+
+@RestController
+public class CockpitEntityDetailController {
+    private static final String REQUEST_ID_HEADER = "X-Request-Id";
+    private final CockpitEntityDetailService service;
+
+    public CockpitEntityDetailController(CockpitEntityDetailService service) { this.service = service; }
+
+    @GetMapping("/api/cockpit/v1/entities/{entityId}/detail")
+    public ResponseEntity<AjaxResult> detail(@PathVariable("entityId") String entityId,
+            @RequestParam("category") String category,
+            @RequestHeader(value=REQUEST_ID_HEADER,required=false) String requestId) {
+        CockpitEntityDetailResponse response=service.load(resolveRequestId(requestId),category,decode(entityId));
+        return ResponseEntity.ok(AjaxResult.success(response));
+    }
+
+    @ExceptionHandler(CockpitInvalidRequestException.class)
+    public ResponseEntity<AjaxResult> invalid(HttpServletRequest request) {
+        return error(HttpStatus.BAD_REQUEST,service.invalidRequest(resolveRequestId(request.getHeader(REQUEST_ID_HEADER))));
+    }
+    @ExceptionHandler(CockpitEntityNotFoundException.class)
+    public ResponseEntity<AjaxResult> notFound(HttpServletRequest request) {
+        return error(HttpStatus.NOT_FOUND,service.notFound(resolveRequestId(request.getHeader(REQUEST_ID_HEADER))));
+    }
+    private ResponseEntity<AjaxResult> error(HttpStatus status,CockpitErrorPayload payload){return ResponseEntity.status(status).body(new AjaxResult(status.value(),payload.getMessage(),payload));}
+    private String resolveRequestId(String id){return StringUtils.isNotEmpty(id)?id:IdUtils.fastSimpleUUID();}
+    private String decode(String value){try{return UriUtils.decode(value,StandardCharsets.UTF_8);}catch(IllegalArgumentException e){throw new CockpitInvalidRequestException("invalid entity id encoding");}}
+}

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

@@ -0,0 +1,70 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.base.alarm.dto.AlarmVO;
+import com.zksy.cockpit.event.CockpitEventDetailService;
+import com.zksy.cockpit.foundation.CockpitErrorCategory;
+import com.zksy.cockpit.foundation.CockpitErrorPayload;
+import com.zksy.common.core.domain.AjaxResult;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+import java.time.OffsetDateTime;
+import java.time.ZoneId;
+import java.util.Map;
+
+/**
+ * 驾驶舱只读事件/工单详情门面(维护者 2026-10-29)。
+ *
+ * <p>路径固定在免鉴权的 {@code /api/cockpit/v1/**} 下:前端不直接打 mobile 域
+ * (那套接口按登录用户做范围裁剪,驾驶舱没有登录态)。只读、无写入口。
+ */
+@RestController
+@RequestMapping("/api/cockpit/v1")
+public class CockpitEventDetailController {
+
+    private final CockpitEventDetailService events;
+
+    public CockpitEventDetailController(CockpitEventDetailService events) {
+        this.events = events;
+    }
+
+    /** 预警/报警详情;family 只认 WARNING / ALARM(认不出 400,不猜)。 */
+    @GetMapping("/events/{eventId}/detail")
+    public ResponseEntity<AjaxResult> event(@PathVariable String eventId,
+                                            @RequestParam(required = false) String family) {
+        String kind = family == null ? "" : family.trim().toUpperCase();
+        if ("WARNING".equals(kind)) {
+            Map<String, Object> detail = events.warningDetail(eventId);
+            if (detail == null) return notFound("预警");
+            return ResponseEntity.ok(AjaxResult.success(detail));
+        }
+        if ("ALARM".equals(kind)) {
+            AlarmVO detail = events.alarmDetail(eventId);
+            if (detail == null) return notFound("报警");
+            return ResponseEntity.ok(AjaxResult.success(detail));
+        }
+        return error(HttpStatus.BAD_REQUEST, CockpitErrorCategory.INVALID_REQUEST, "family 只取 WARNING 或 ALARM");
+    }
+
+    /** 报警处置流程(与详情分开取:失败只影响这一块)。 */
+    @GetMapping("/events/{eventId}/logs")
+    public ResponseEntity<AjaxResult> logs(@PathVariable String eventId,
+                                           @RequestParam(required = false) String family) {
+        if (!"ALARM".equalsIgnoreCase(family == null ? "" : family.trim())) {
+            return error(HttpStatus.BAD_REQUEST, CockpitErrorCategory.INVALID_REQUEST, "仅报警提供处置流程");
+        }
+        return ResponseEntity.ok(AjaxResult.success(events.alarmLogs(eventId)));
+    }
+
+    private ResponseEntity<AjaxResult> notFound(String label) {
+        return error(HttpStatus.NOT_FOUND, CockpitErrorCategory.NOT_FOUND, label + "不存在");
+    }
+
+    private ResponseEntity<AjaxResult> error(HttpStatus status, CockpitErrorCategory category, String message) {
+        CockpitErrorPayload payload = new CockpitErrorPayload(category, message,
+                java.util.UUID.randomUUID().toString().replace("-", ""),
+                OffsetDateTime.now(ZoneId.of("Asia/Shanghai")).toString());
+        return ResponseEntity.status(status).body(new AjaxResult(status.value(), message, payload));
+    }
+}

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

@@ -0,0 +1,63 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.cockpit.foundation.CockpitErrorCategory;
+import com.zksy.cockpit.foundation.CockpitErrorPayload;
+import com.zksy.common.core.domain.AjaxResult;
+import com.zksy.ops.dto.WorkOrderDetailVO;
+import com.zksy.ops.service.WorkOrderService;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.time.OffsetDateTime;
+import java.time.ZoneId;
+
+/**
+ * 驾驶舱只读工单详情(维护者 2026-10-29)。
+ *
+ * <p>用 {@code WorkOrderService.getDetail} 而不是移动端的 {@code getMobileDetail}:
+ * 后者按当前登录人做数据范围校验(驾驶舱免鉴权、无登录态),前者是纯粹的只读取数。
+ * 只读,无写入口。
+ */
+@RestController
+@RequestMapping("/api/cockpit/v1")
+public class CockpitWorkOrderDetailController {
+
+    private final WorkOrderService workOrders;
+
+    public CockpitWorkOrderDetailController(WorkOrderService workOrders) {
+        this.workOrders = workOrders;
+    }
+
+    /** 详情:{@code order} + {@code logs}(与移动端同一份 DTO 形状)。 */
+    @GetMapping("/work-orders/{orderId}/detail")
+    public ResponseEntity<AjaxResult> detail(@PathVariable String orderId) {
+        Long id = parse(orderId);
+        if (id == null) {
+            return error(HttpStatus.BAD_REQUEST, CockpitErrorCategory.INVALID_REQUEST, "工单主键必须是数字");
+        }
+        WorkOrderDetailVO detail = workOrders.getDetail(id);
+        if (detail == null || detail.getOrder() == null) {
+            return error(HttpStatus.NOT_FOUND, CockpitErrorCategory.NOT_FOUND, "工单不存在");
+        }
+        return ResponseEntity.ok(AjaxResult.success(detail));
+    }
+
+    private static Long parse(String value) {
+        try {
+            return Long.valueOf(String.valueOf(value).trim());
+        } catch (NumberFormatException error) {
+            return null;
+        }
+    }
+
+    private ResponseEntity<AjaxResult> error(HttpStatus status, CockpitErrorCategory category, String message) {
+        CockpitErrorPayload payload = new CockpitErrorPayload(category, message,
+                java.util.UUID.randomUUID().toString().replace("-", ""),
+                OffsetDateTime.now(ZoneId.of("Asia/Shanghai")).toString());
+        return ResponseEntity.status(status).body(new AjaxResult(status.value(), message, payload));
+    }
+}

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

@@ -0,0 +1,22 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.cockpit.entity.CockpitEntityDetailResponse;
+import com.zksy.cockpit.entity.CockpitEntityDetailService;
+import org.junit.jupiter.api.Test;
+import org.springframework.http.ResponseEntity;
+import java.util.Collections;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+class CockpitEntityDetailControllerTest {
+    @Test void returnsAjaxResultEnvelopeAndDelegatesDecodedIdentity() {
+        CockpitEntityDetailService service = mock(CockpitEntityDetailService.class);
+        CockpitEntityDetailResponse detail = new CockpitEntityDetailResponse("q1", "WATER_PLANT", "42", "WP1", "水厂", "0", "城南", null, null, Collections.emptyMap(), null);
+        when(service.load("q1", "WATER_PLANT", "42")).thenReturn(detail);
+        CockpitEntityDetailController controller = new CockpitEntityDetailController(service);
+        ResponseEntity<com.zksy.common.core.domain.AjaxResult> response = controller.detail("42", "WATER_PLANT", "q1");
+        assertEquals(200, response.getStatusCodeValue());
+        assertEquals(200, response.getBody().get("code"));
+        verify(service).load("q1", "WATER_PLANT", "42");
+    }
+}

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

@@ -0,0 +1,59 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.WaterSupply.WaterPlantInfo.domain.WaterPlantInfo;
+import com.zksy.WaterSupply.WaterPlantInfo.service.IWaterPlantInfoService;
+import com.zksy.WaterSupply.WaterPumpStation.domain.WaterPumpStation;
+import com.zksy.WaterSupply.WaterPumpStation.service.IWaterPumpStationService;
+import com.zksy.base.domain.*;
+import com.zksy.base.service.*;
+import com.zksy.cockpit.entity.*;
+import com.zksy.drainage.domain.EngineeringFacility;
+import com.zksy.drainage.service.EngineeringFacilityService;
+import org.junit.jupiter.api.Test;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+class CockpitEntityDetailServiceTest {
+    private final PipeNetworkBaseService pipes = mock(PipeNetworkBaseService.class);
+    private final IWaterPlantInfoService plants = mock(IWaterPlantInfoService.class);
+    private final IWaterPumpStationService stations = mock(IWaterPumpStationService.class);
+    private final EquipmentBaseService equipment = mock(EquipmentBaseService.class);
+    private final EngineeringFacilityService engineering = mock(EngineeringFacilityService.class);
+    private final EquipmentStatusService equipmentStatuses = mock(EquipmentStatusService.class);
+    private final HazardHiddenAccountService hazards = mock(HazardHiddenAccountService.class);
+    private final RiskAssessmentService risks = mock(RiskAssessmentService.class);
+    private final CockpitEntityDetailService service = new CockpitEntityDetailService(pipes, plants, stations, equipment, engineering, equipmentStatuses, hazards, risks);
+
+    @Test void resolvesEverySupportedCategoryWithStableAttributes() {
+        PipeNetworkBase pipe = new PipeNetworkBase(); pipe.setNetworkId("p1"); pipe.setNetworkCode("PN1"); pipe.setNetworkName("管网"); pipe.setMaterial("PE"); when(pipes.getById("p1")).thenReturn(pipe);
+        WaterPlantInfo plant = new WaterPlantInfo(); plant.setId(2L); plant.setPlantCode("WP1"); plant.setPlantName("水厂"); plant.setTreatmentProcess("过滤"); when(plants.getById("2")).thenReturn(plant);
+        WaterPumpStation station = new WaterPumpStation(); station.setId(3L); station.setStationCode("PS1"); station.setStationName("泵站"); station.setPumpCount(2); when(stations.getById("3")).thenReturn(station);
+        EngineeringFacility engineeringFacility = new EngineeringFacility(); engineeringFacility.setFacilityId("f1"); engineeringFacility.setFacilityCode("EF1"); engineeringFacility.setFacilityName("排水闸门"); engineeringFacility.setDesignFlow(new java.math.BigDecimal("3.5")); engineeringFacility.setEquipmentId("d1,d2"); when(engineering.getById("f1")).thenReturn(engineeringFacility);
+        EquipmentBase linked = new EquipmentBase(); linked.setEquipmentId("d1"); linked.setEquipmentCode("D-1"); linked.setEquipmentName("闸门控制器"); linked.setEquipmentLocation("城南"); when(equipment.getById("d1")).thenReturn(linked);
+        EquipmentStatus linkedStatus = new EquipmentStatus(); linkedStatus.setEquipmentId("d1"); linkedStatus.setOnlineStatus(1); linkedStatus.setAlarmStatus(0); when(equipmentStatuses.getByDeviceCode("D-1")).thenReturn(linkedStatus);
+        EquipmentBase resource = new EquipmentBase(); resource.setEquipmentId("e1"); resource.setEquipmentCode("ER1"); resource.setEquipmentName("资源"); resource.setEquipmentModel("M1"); when(equipment.getById("e1")).thenReturn(resource);
+        HazardHiddenAccount hazard = new HazardHiddenAccount(); hazard.setHazardId("h1"); hazard.setHazardNo("HZ1"); hazard.setHazardDesc("隐患"); hazard.setRectifyPlan("整改"); when(hazards.getById("h1")).thenReturn(hazard);
+        RiskAssessment target = new RiskAssessment(); target.setRiskId("r1"); target.setProtectiveTargets("学校"); target.setRiskLevel("HIGH"); when(risks.getById("r1")).thenReturn(target);
+
+        assertEquals("PE", service.load("q", "PIPE_NETWORK", "p1").getAttributes().get("material"));
+        assertEquals("过滤", service.load("q", "WATER_PLANT", "2").getAttributes().get("treatmentProcess"));
+        assertEquals(2, service.load("q", "PUMP_STATION", "3").getAttributes().get("pumpCount"));
+        CockpitEntityDetailResponse engineeringDetail = service.load("q", "ENGINEERING_FACILITY", "f1");
+        assertEquals(new java.math.BigDecimal("3.5"), engineeringDetail.getAttributes().get("designFlow"));
+        java.util.List<?> linkedDevices = (java.util.List<?>) engineeringDetail.getAttributes().get("devices");
+        assertEquals(1, linkedDevices.size());
+        java.util.Map<?, ?> linkedDevice = (java.util.Map<?, ?>) linkedDevices.get(0);
+        assertEquals("闸门控制器", linkedDevice.get("name"));
+        assertEquals("城南", linkedDevice.get("location"));
+        assertEquals(1, linkedDevice.get("onlineStatus"));
+        assertEquals(0, linkedDevice.get("alarmStatus"));
+        assertEquals("M1", service.load("q", "EMERGENCY_RESOURCE", "e1").getAttributes().get("model"));
+        assertEquals("整改", service.load("q", "HAZARD_SOURCE", "h1").getAttributes().get("rectifyPlan"));
+        assertEquals("HIGH", service.load("q", "protection-target", "r1").getAttributes().get("riskLevel"));
+    }
+
+    @Test void rejectsUnknownCategoryAndMissingEntity() {
+        assertThrows(RuntimeException.class, () -> service.load("q", "OTHER", "x"));
+        assertThrows(CockpitEntityNotFoundException.class, () -> service.load("q", "PIPE_NETWORK", "missing"));
+    }
+}

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

@@ -0,0 +1,39 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.cockpit.event.CockpitEventDetailService;
+import org.junit.jupiter.api.Test;
+
+import java.util.Collections;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+/** Issue 65:驾驶舱只读事件详情不依赖登录用户范围(没有登录态也不报鉴权错)。 */
+class CockpitEventDetailServiceTest {
+    private final CockpitEventDetailService service = mock(CockpitEventDetailService.class);
+
+    @Test
+    void warningDetailReturnsNullWhenWarningMissing() {
+        when(service.warningDetail("missing")).thenReturn(null);
+        assertNull(service.warningDetail("missing"));
+    }
+
+    @Test
+    void warningDetailCarriesAllSections() {
+        Map<String, Object> detail = new java.util.HashMap<>();
+        detail.put("basicInfo", Collections.singletonMap("warningNo", "YJ1"));
+        detail.put("disposalHistory", Collections.emptyList());
+        detail.put("attachmentList", Collections.emptyList());
+        detail.put("supervisionList", Collections.emptyList());
+        detail.put("alarmList", Collections.emptyList());
+        when(service.warningDetail("w1")).thenReturn(detail);
+
+        Map<String, Object> actual = service.warningDetail("w1");
+        assertTrue(actual.containsKey("basicInfo"));
+        assertTrue(actual.containsKey("disposalHistory"));
+        assertTrue(actual.containsKey("attachmentList"));
+        assertTrue(actual.containsKey("supervisionList"));
+        assertTrue(actual.containsKey("alarmList"));
+    }
+}

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

@@ -0,0 +1,23 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.cockpit.infrastructure.CockpitInfrastructureOperatingInformationSnapshot;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/** Issue 65: operating metrics expose the real facility namespace + status for detail sync. */
+class CockpitOperatingMetricFacilityIdentityContractTest {
+    @Test
+    void sourceMetricCarriesFacilityIdentityAndStatusWithoutGuessingFromPointCodes() {
+        CockpitInfrastructureOperatingInformationSnapshot.SourceMetric metric =
+                new CockpitInfrastructureOperatingInformationSnapshot.SourceMetric(
+                        "m1", "一水厂供水能力", "SUPPLY", "万m³/d", "WATER_SUPPLY",
+                        java.math.BigDecimal.ONE, java.math.BigDecimal.TEN, "2026-10-28T00:00:00+08:00",
+                        false, java.util.Collections.singletonList("WP-CODE"), "42", "WATER_PLANT", "0",
+                        java.util.Collections.emptyList());
+        assertEquals("42", metric.getFacilityId());
+        assertEquals("WATER_PLANT", metric.getFacilityKind());
+        assertEquals("0", metric.getFacilityStatus());
+        assertEquals("WP-CODE", metric.getPointCodes().get(0));
+    }
+}

+ 22 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/entity/CockpitEntityCategory.java

@@ -0,0 +1,22 @@
+package com.zksy.cockpit.entity;
+
+import com.zksy.cockpit.foundation.CockpitInvalidRequestException;
+
+import java.util.Locale;
+
+public enum CockpitEntityCategory {
+    PIPE_NETWORK, WATER_PLANT, PUMP_STATION, ENGINEERING_FACILITY, EMERGENCY_RESOURCE, HAZARD, PROTECTION_TARGET;
+
+    public static CockpitEntityCategory parse(String value) {
+        if (value == null || value.trim().isEmpty()) {
+            throw new CockpitInvalidRequestException("category is required");
+        }
+        String normalized = value.trim().replace('-', '_').toUpperCase(Locale.ROOT);
+        if ("HAZARD_SOURCE".equals(normalized)) normalized = "HAZARD";
+        try {
+            return valueOf(normalized);
+        } catch (IllegalArgumentException exception) {
+            throw new CockpitInvalidRequestException("unsupported entity category");
+        }
+    }
+}

+ 79 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/entity/CockpitEntityDetailResponse.java

@@ -0,0 +1,79 @@
+package com.zksy.cockpit.entity;
+
+import java.math.BigDecimal;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/** Stable, category-neutral read model for cockpit map entity details. */
+public class CockpitEntityDetailResponse {
+    private final String requestId;
+    private final String apiVersion;
+    private final boolean readOnly;
+    private final String category;
+    private final String id;
+    private final String code;
+    private final String name;
+    private final String status;
+    private final String location;
+    private final Integer district;
+    private final String districtName;
+    private final Coordinates coordinates;
+    private final Map<String, Object> attributes;
+    private final String updatedAt;
+
+    public CockpitEntityDetailResponse(String requestId, String category, String id, String code,
+            String name, String status, String location, Integer district, Coordinates coordinates,
+            Map<String, Object> attributes, String updatedAt) {
+        this.requestId = requestId;
+        this.apiVersion = "v1";
+        this.readOnly = true;
+        this.category = category;
+        this.id = id;
+        this.code = code;
+        this.name = name;
+        this.status = status;
+        this.location = location;
+        this.district = district;
+        this.districtName = district == null ? null
+                : com.zksy.cockpit.foundation.CockpitDistrict.nameOf(district);
+        this.coordinates = coordinates;
+        this.attributes = attributes == null ? Collections.emptyMap()
+                : Collections.unmodifiableMap(new LinkedHashMap<>(attributes));
+        this.updatedAt = updatedAt;
+    }
+
+    public String getRequestId() { return requestId; }
+    public String getApiVersion() { return apiVersion; }
+    public boolean isReadOnly() { return readOnly; }
+    public String getCategory() { return category; }
+    public String getId() { return id; }
+    public String getCode() { return code; }
+    public String getName() { return name; }
+    public String getStatus() { return status; }
+    public String getLocation() { return location; }
+    public Integer getDistrict() { return district; }
+    public String getDistrictName() { return districtName; }
+    public Coordinates getCoordinates() { return coordinates; }
+    public Map<String, Object> getAttributes() { return attributes; }
+    public String getUpdatedAt() { return updatedAt; }
+
+    public static class Coordinates {
+        private final BigDecimal longitude;
+        private final BigDecimal latitude;
+        private final BigDecimal endLongitude;
+        private final BigDecimal endLatitude;
+
+        public Coordinates(BigDecimal longitude, BigDecimal latitude,
+                BigDecimal endLongitude, BigDecimal endLatitude) {
+            this.longitude = longitude;
+            this.latitude = latitude;
+            this.endLongitude = endLongitude;
+            this.endLatitude = endLatitude;
+        }
+        public BigDecimal getLongitude() { return longitude; }
+        public BigDecimal getLatitude() { return latitude; }
+        public BigDecimal getEndLongitude() { return endLongitude; }
+        public BigDecimal getEndLatitude() { return endLatitude; }
+    }
+}

+ 186 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/entity/CockpitEntityDetailService.java

@@ -0,0 +1,186 @@
+package com.zksy.cockpit.entity;
+
+import com.zksy.WaterSupply.WaterPlantInfo.domain.WaterPlantInfo;
+import com.zksy.WaterSupply.WaterPlantInfo.service.IWaterPlantInfoService;
+import com.zksy.WaterSupply.WaterPumpStation.domain.WaterPumpStation;
+import com.zksy.WaterSupply.WaterPumpStation.service.IWaterPumpStationService;
+import com.zksy.base.domain.EquipmentBase;
+import com.zksy.base.domain.EquipmentStatus;
+import com.zksy.base.domain.HazardHiddenAccount;
+import com.zksy.base.domain.PipeNetworkBase;
+import com.zksy.base.domain.RiskAssessment;
+import com.zksy.base.service.EquipmentBaseService;
+import com.zksy.base.service.EquipmentStatusService;
+import com.zksy.base.service.HazardHiddenAccountService;
+import com.zksy.base.service.PipeNetworkBaseService;
+import com.zksy.base.service.RiskAssessmentService;
+import com.zksy.cockpit.foundation.CockpitDistrict;
+import com.zksy.cockpit.foundation.CockpitDistrictPrefix;
+import com.zksy.cockpit.foundation.CockpitErrorCategory;
+import com.zksy.cockpit.foundation.CockpitErrorPayload;
+import com.zksy.cockpit.foundation.CockpitInvalidRequestException;
+import com.zksy.drainage.domain.EngineeringFacility;
+import com.zksy.drainage.service.EngineeringFacilityService;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+@Service
+public class CockpitEntityDetailService {
+    private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
+    private final PipeNetworkBaseService pipes;
+    private final IWaterPlantInfoService plants;
+    private final IWaterPumpStationService stations;
+    private final EquipmentBaseService equipment;
+    private final EngineeringFacilityService engineeringFacilities;
+    private final EquipmentStatusService equipmentStatuses;
+    private final HazardHiddenAccountService hazards;
+    private final RiskAssessmentService risks;
+
+    public CockpitEntityDetailService(PipeNetworkBaseService pipes, IWaterPlantInfoService plants,
+            IWaterPumpStationService stations, EquipmentBaseService equipment,
+            EngineeringFacilityService engineeringFacilities, EquipmentStatusService equipmentStatuses, HazardHiddenAccountService hazards, RiskAssessmentService risks) {
+        this.pipes = pipes; this.plants = plants; this.stations = stations;
+        this.equipment = equipment; this.engineeringFacilities = engineeringFacilities; this.equipmentStatuses = equipmentStatuses; this.hazards = hazards; this.risks = risks;
+    }
+
+    public CockpitEntityDetailResponse load(String requestId, String categoryValue, String entityId) {
+        if (blank(entityId)) throw new CockpitInvalidRequestException("entity id is required");
+        CockpitEntityCategory category = CockpitEntityCategory.parse(categoryValue);
+        String id = entityId.trim();
+        switch (category) {
+            case PIPE_NETWORK: return pipe(requestId, id);
+            case WATER_PLANT: return plant(requestId, id);
+            case PUMP_STATION: return station(requestId, id);
+            case ENGINEERING_FACILITY: return engineeringFacility(requestId, id);
+            case EMERGENCY_RESOURCE: return resource(requestId, id);
+            case HAZARD: return hazard(requestId, id);
+            case PROTECTION_TARGET: return target(requestId, id);
+            default: throw new CockpitInvalidRequestException("unsupported entity category");
+        }
+    }
+
+    private CockpitEntityDetailResponse pipe(String requestId, String id) {
+        PipeNetworkBase v = pipes.getById(id);
+        if (v == null) throw missing();
+        Map<String,Object> a = map(); put(a,"networkType",v.getNetworkType()); put(a,"networkLevel",v.getNetworkLevel());
+        put(a,"managementUnit",v.getManagementUnit()); put(a,"lengthMeters",v.getLength()); put(a,"diameterMillimeters",v.getDiameter());
+        put(a,"material",v.getMaterial()); put(a,"buildDate",v.getBuildDate()); put(a,"remark",v.getRemark());
+        Integer district = CockpitDistrictPrefix.codeOf(v.getNetworkCode());
+        return response(requestId,"PIPE_NETWORK",v.getNetworkId(),v.getNetworkCode(),v.getNetworkName(),v.getStatus(),
+                v.getLocation(),district,new CockpitEntityDetailResponse.Coordinates(v.getStartLongitude(),v.getStartLatitude(),v.getEndLongitude(),v.getEndLatitude()),a,v.getUpdateTime());
+    }
+
+    private CockpitEntityDetailResponse plant(String requestId, String id) {
+        WaterPlantInfo v = plants.getById(id); if (v == null) throw missing();
+        Map<String,Object> a=map(); put(a,"waterSourceType",v.getWaterSourceType()); put(a,"designCapacity",v.getDesignCapacity());
+        put(a,"currentCapacity",v.getCurrentCapacity()); put(a,"treatmentProcess",v.getTreatmentProcess()); put(a,"commissionDate",v.getCommissionDate());
+        put(a,"waterQualityStandard",v.getWaterQualityStandard()); put(a,"principal",v.getPrincipal()); put(a,"contactPhone",v.getContactPhone()); put(a,"remark",v.getRemark());
+        return response(requestId,"WATER_PLANT",String.valueOf(v.getId()),v.getPlantCode(),v.getPlantName(),v.getStatus(),v.getLocation(),null,
+                new CockpitEntityDetailResponse.Coordinates(v.getLongitude(),v.getLatitude(),null,null),a,v.getUpdateTime());
+    }
+
+    private CockpitEntityDetailResponse station(String requestId, String id) {
+        WaterPumpStation v=stations.getById(id); if(v==null) throw missing();
+        Map<String,Object>a=map(); put(a,"stationType",v.getStationType()); put(a,"pumpCount",v.getPumpCount()); put(a,"designFlow",v.getDesignFlow());
+        put(a,"designHead",v.getDesignHead()); put(a,"powerCapacity",v.getPowerCapacity()); put(a,"commissionDate",v.getCommissionDate());
+        put(a,"principal",v.getPrincipal()); put(a,"contactPhone",v.getContactPhone()); put(a,"remark",v.getRemark());
+        return response(requestId,"PUMP_STATION",String.valueOf(v.getId()),v.getStationCode(),v.getStationName(),v.getStatus(),v.getLocation(),null,
+                new CockpitEntityDetailResponse.Coordinates(v.getLongitude(),v.getLatitude(),null,null),a,v.getUpdateTime());
+    }
+
+
+    private CockpitEntityDetailResponse engineeringFacility(String requestId, String id) {
+        EngineeringFacility v = engineeringFacilities.getById(id);
+        if (v == null) throw missing();
+        Map<String,Object> a = map();
+        put(a, "facilityType", v.getFacilityType());
+        put(a, "facilityLevel", v.getFacilityLevel());
+        put(a, "managementUnit", v.getManagementUnit());
+        put(a, "buildDate", v.getBuildDate());
+        put(a, "designFlow", v.getDesignFlow());
+        put(a, "designHead", v.getDesignHead());
+        put(a, "storageCapacity", v.getStorageCapacity());
+        put(a, "equipmentIds", v.getEquipmentId());
+        put(a, "devices", linkedDevices(v.getEquipmentId()));
+        put(a, "remark", v.getRemark());
+        return response(requestId, "ENGINEERING_FACILITY", v.getFacilityId(), v.getFacilityCode(),
+                v.getFacilityName(), v.getStatus(), v.getLocation(), null,
+                new CockpitEntityDetailResponse.Coordinates(v.getLongitude(), v.getLatitude(), null, null),
+                a, v.getUpdateTime());
+    }
+
+
+    private java.util.List<Map<String,Object>> linkedDevices(String value) {
+        java.util.List<Map<String,Object>> rows = new java.util.ArrayList<>();
+        if (blank(value)) return rows;
+        for (String rawId : value.split(",")) {
+            String equipmentId = rawId == null ? "" : rawId.trim();
+            if (equipmentId.isEmpty()) continue;
+            EquipmentBase device = equipment.getById(equipmentId);
+            if (device == null) continue;
+            EquipmentStatus status = blank(device.getEquipmentCode())
+                    ? null : equipmentStatuses.getByDeviceCode(device.getEquipmentCode());
+            Map<String,Object> row = map();
+            put(row, "id", device.getEquipmentId());
+            put(row, "code", device.getEquipmentCode());
+            put(row, "name", device.getEquipmentName());
+            put(row, "type", device.getEquipmentTypeName());
+            put(row, "location", device.getEquipmentLocation());
+            if (status != null) {
+                put(row, "currentStatus", status.getCurrentStatus());
+                put(row, "alarmStatus", status.getAlarmStatus());
+                put(row, "onlineStatus", status.getOnlineStatus());
+                put(row, "statusUpdatedAt", status.getStatusUpdateTime());
+            }
+            rows.add(row);
+        }
+        return rows;
+    }
+
+    private CockpitEntityDetailResponse resource(String requestId,String id) {
+        EquipmentBase v=equipment.getById(id); if(v==null) throw missing();
+        Map<String,Object>a=map(); put(a,"equipmentTypeId",v.getEquipmentTypeId()); put(a,"model",v.getEquipmentModel()); put(a,"specification",v.getEquipmentSpec());
+        put(a,"manufacturer",v.getManufacturer()); put(a,"assetValue",v.getAssetValue()); put(a,"useLifeYears",v.getUseLife()); put(a,"installTime",v.getInstallTime());
+        put(a,"maintainer",v.getMaintainer()); put(a,"maintainerPhone",v.getMaintainerPhone()); put(a,"ownershipUnit",v.getOwnershipUnit()); put(a,"image",v.getEquipmentImage()); put(a,"remark",v.getRemark());
+        return response(requestId,"EMERGENCY_RESOURCE",v.getEquipmentId(),v.getEquipmentCode(),v.getEquipmentName(),null,v.getEquipmentLocation(),CockpitDistrict.codeOf(v.getDistrict()),
+                new CockpitEntityDetailResponse.Coordinates(v.getLongitude(),v.getLatitude(),null,null),a,v.getUpdateTime());
+    }
+
+    private CockpitEntityDetailResponse hazard(String requestId,String id) {
+        HazardHiddenAccount v=hazards.getById(id); if(v==null) throw missing();
+        Map<String,Object>a=map(); put(a,"hazardType",v.getHazardType()); put(a,"level",v.getHazardLevel()); put(a,"source",v.getHazardSource()); put(a,"description",v.getHazardDesc());
+        put(a,"networkId",v.getNetworkId()); put(a,"equipmentId",v.getEquipmentId()); put(a,"workOrderId",v.getWorkOrderId()); put(a,"reportUnit",v.getReportUnit()); put(a,"reportTime",v.getReportTime());
+        put(a,"rectifyPlan",v.getRectifyPlan()); put(a,"rectifyDeadline",v.getRectifyDeadline()); put(a,"rectifyPerson",v.getRectifyPerson()); put(a,"rectifyFeedback",v.getRectifyFeedback()); put(a,"images",v.getHazardImages()); put(a,"remark",v.getRemark());
+        Integer district=null; if(!blank(v.getEquipmentId())) { EquipmentBase e=equipment.getById(v.getEquipmentId()); if(e!=null) district=CockpitDistrict.codeOf(e.getDistrict()); }
+        return response(requestId,"HAZARD",v.getHazardId(),v.getHazardNo(),v.getHazardDesc(),v.getHazardStatus(),null,district,
+                new CockpitEntityDetailResponse.Coordinates(v.getLongitude(),v.getLatitude(),null,null),a,v.getUpdateTime());
+    }
+
+    private CockpitEntityDetailResponse target(String requestId,String id) {
+        RiskAssessment v=risks.getById(id); if(v==null||blank(v.getProtectiveTargets())) throw missing();
+        Map<String,Object>a=map(); put(a,"networkId",v.getNetworkId()); put(a,"riskLevel",v.getRiskLevel()); put(a,"riskScore",v.getRiskScore()); put(a,"riskColor",v.getRiskColor());
+        put(a,"assessmentModel",v.getAssessmentModel()); put(a,"assessmentTime",v.getAssessmentTime()); put(a,"riskFactors",v.getRiskFactors()); put(a,"hazardSources",v.getHazardSources()); put(a,"remark",v.getRemark());
+        return response(requestId,"PROTECTION_TARGET",v.getRiskId(),v.getRiskId(),v.getProtectiveTargets(),v.getRiskLevel(),null,null,null,a,v.getUpdateTime());
+    }
+
+    private static CockpitEntityDetailResponse response(String requestId,String category,String id,String code,String name,String status,String location,Integer district,CockpitEntityDetailResponse.Coordinates coordinates,Map<String,Object> attributes,LocalDateTime updatedAt) {
+        return new CockpitEntityDetailResponse(requestId,category,id,code,name,status,location,district,coordinates,attributes,format(updatedAt));
+    }
+    public CockpitErrorPayload invalidRequest(String requestId) { return error(CockpitErrorCategory.INVALID_REQUEST,"实体详情查询参数不合法",requestId); }
+    public CockpitErrorPayload notFound(String requestId) { return error(CockpitErrorCategory.NOT_FOUND,"实体不存在",requestId); }
+    private static CockpitErrorPayload error(CockpitErrorCategory c,String m,String id) { return new CockpitErrorPayload(c,m,id,format(LocalDateTime.now(BUSINESS_ZONE))); }
+    private static CockpitEntityNotFoundException missing(){return new CockpitEntityNotFoundException("entity not found");}
+    private static Map<String,Object> map(){return new LinkedHashMap<>();}
+    private static void put(Map<String,Object> map,String key,Object value) {
+        if (value != null) {
+            map.put(key, value);
+        }
+    }
+    private static String format(LocalDateTime value){return value==null?null:DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(value.atZone(BUSINESS_ZONE));}
+    private static boolean blank(String v){return v==null||v.trim().isEmpty();}
+}

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

@@ -0,0 +1,5 @@
+package com.zksy.cockpit.entity;
+
+public class CockpitEntityNotFoundException extends RuntimeException {
+    public CockpitEntityNotFoundException(String message) { super(message); }
+}

+ 138 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/CockpitEventDetailService.java

@@ -0,0 +1,138 @@
+package com.zksy.cockpit.event;
+
+import com.zksy.base.alarm.domain.AlarmLog;
+import com.zksy.base.alarm.dto.AlarmVO;
+import com.zksy.base.alarm.mapper.AlarmLogMapper;
+import com.zksy.base.alarm.mapper.AlarmOperationsMapper;
+import com.zksy.base.warning.domain.WarningSupervision;
+import com.zksy.base.warning.mapper.AlarmWarningRelMapper;
+import com.zksy.base.warning.mapper.EarlyWarningMapper;
+import com.zksy.base.warning.mapper.WarningAttachmentMapper;
+import com.zksy.base.warning.mapper.WarningDisposalMapper;
+import com.zksy.base.warning.mapper.WarningSupervisionMapper;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.zksy.base.warning.domain.WarningAttachment;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 驾驶舱只读事件详情(维护者 2026-10-29)。
+ *
+ * <p>为什么不能直接复用移动端 {@code getMobileWarningDetail} / {@code mobileDetail}:
+ * 那两个方法都**按当前登录用户的可见范围**过滤({@code WarningAccessService.requireVisible}
+ * 与报警 SQL 的 {@code mobileScope}),而驾驶舱 REST 是免鉴权的只读域 —— 没有登录用户时
+ * 调用只会抛「获取用户账户异常」或落到 {@code AND 1 = 0} 返回空。
+ *
+ * <p>所以这里只读**底层业务表**,不做范围裁剪:驾驶舱本身就是全量只读大屏,
+ * 既保住"数据来自真实表"(没有伪造字段),也不会把鉴权失败当成数据缺失。
+ * 写操作(发布/接收/处置/审核)一律不在此提供 —— 免鉴权域绝不放写入口。
+ */
+@Service
+@RequiredArgsConstructor
+public class CockpitEventDetailService {
+
+    private final EarlyWarningMapper warnings;
+    private final AlarmOperationsMapper alarms;
+    private final AlarmLogMapper alarmLogs;
+    private final WarningDisposalMapper disposals;
+    private final WarningAttachmentMapper attachments;
+    private final WarningSupervisionMapper supervisions;
+    private final AlarmWarningRelMapper linkedAlarms;
+
+    /**
+     * 预警详情:基本信息 + 处置记录 + 附件 + 督办 + 关联报警(各段独立成败)。
+     *
+     * <p>{@code eventId} 可能是**业务编号**(列表下发的 warning_no)或主键(warning_id):
+     * 驾驶舱列表只带编号,先按编号回查,再退回按主键 —— 两条口径都认,不让前端改拿主键。
+     */
+    public Map<String, Object> warningDetail(String eventId) {
+        if (eventId == null || eventId.trim().isEmpty()) {
+            return null;
+        }
+        String key = eventId.trim();
+        Map<String, Object> basicInfo = warnings.selectWarningDetailByNo(key);
+        if (basicInfo == null) {
+            basicInfo = warnings.selectWarningDetail(key);
+        }
+        if (basicInfo == null) {
+            return null;
+        }
+        // 后续各分区都按**主键**取(业务编号只用于定位这一行)。
+        String warningId = String.valueOf(basicInfo.get("warning_id") == null ? key : basicInfo.get("warning_id"));
+        // SQL 返回的是**下划线列名**的 Map(warning_no / warning_level …),而驾驶舱其它端点
+        // 一律驼峰;这里统一转一次,前端只按一套键读,不在前端做第二套兼容。
+        Map<String, Object> camel = camelize(basicInfo);
+        Map<String, Object> result = new HashMap<>();
+        result.put("basicInfo", camel);
+        result.put("disposalHistory", safe(() -> disposals.selectDisposalList(warningId)));
+        result.put("attachmentList", safe(() -> attachments.selectList(
+                new LambdaQueryWrapper<WarningAttachment>().eq(WarningAttachment::getWarningId, warningId))));
+        result.put("supervisionList", safe(() -> supervisions.selectByWarningId(warningId),
+                Collections.<WarningSupervision>emptyList()));
+        result.put("alarmList", safe(() -> linkedAlarms.selectAlarmListByWarningId(warningId)));
+        return result;
+    }
+
+    /**
+     * 报警详情:与移动端同一份 VO(设备名/位置/类型/工单/单位都由同一条 SQL 回填)。
+     *
+     * <p>{@code eventId} 可能是**业务编号**(列表下发的 alarm_no)或主键:先按编号回查,
+     * 再退回按主键 —— 两条口径都认,前端不必改拿主键。
+     */
+    public AlarmVO alarmDetail(String eventId) {
+        if (eventId == null || eventId.trim().isEmpty()) {
+            return null;
+        }
+        String key = eventId.trim();
+        AlarmVO detail = alarms.selectCockpitDetailByNo(key);
+        return detail != null ? detail : alarms.selectCockpitDetail(key);
+    }
+
+    /** 报警处置流程(与详情分开取,失败只影响这一步)。 */
+    public List<AlarmLog> alarmLogs(String alarmId) {
+        return safe(() -> alarmLogs.selectList(
+                new LambdaQueryWrapper<AlarmLog>().eq(AlarmLog::getAlarmId, alarmId)
+                        .orderByAsc(AlarmLog::getOperTime)));
+    }
+
+    /** 下划线列名 → 驼峰键(SQL Map 与 Java Bean 输出口径对齐)。 */
+    private static Map<String, Object> camelize(Map<String, Object> source) {
+        Map<String, Object> out = new HashMap<>();
+        for (Map.Entry<String, Object> entry : source.entrySet()) {
+            String key = entry.getKey();
+            if (key == null) continue;
+            StringBuilder name = new StringBuilder();
+            boolean upper = false;
+            for (char ch : key.toCharArray()) {
+                if (ch == '_') { upper = true; continue; }
+                name.append(upper ? Character.toUpperCase(ch) : ch);
+                upper = false;
+            }
+            out.put(name.toString(), entry.getValue());
+        }
+        return out;
+    }
+
+    private static <T> T safe(ThrowingSupplier<T> supplier) {
+        return safe(supplier, null);
+    }
+
+    private static <T> T safe(ThrowingSupplier<T> supplier, T fallback) {
+        try {
+            T value = supplier.get();
+            return value == null ? fallback : value;
+        } catch (RuntimeException error) {
+            return fallback;
+        }
+    }
+
+    @FunctionalInterface
+    private interface ThrowingSupplier<T> {
+        T get();
+    }
+}