Selaa lähdekoodia

feat(cockpit): add warning and alarm read models (#24)

Kazerin 12 tuntia sitten
vanhempi
commit
45b4d35537

+ 112 - 0
docs/cockpit/event-read-demo.html

@@ -0,0 +1,112 @@
+<!doctype html>
+<html lang="zh-CN">
+<head>
+  <meta charset="utf-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1" />
+  <title>Cockpit Warning/Alarm Read Model Demo</title>
+  <style>
+    body { margin:0; min-height:100vh; background:#08131f; color:#e8f1f8; font:15px/1.5 "Microsoft YaHei",sans-serif; }
+    main { width:min(1240px,calc(100vw - 32px)); margin:28px auto; display:grid; gap:16px; }
+    header, section { background:#0e1e2d; border:1px solid #21465f; padding:20px; }
+    h1,h2 { margin:0 0 10px; } h1 { font-size:22px; } h2 { font-size:17px; }
+    .endpoint { color:#78a2b8; font-family:Consolas,monospace; margin:0; }
+    .toolbar { display:flex; gap:8px; flex-wrap:wrap; margin-bottom:14px; }
+    button { border:1px solid #2f7098; background:#123247; color:#dff2ff; padding:8px 14px; cursor:pointer; }
+    button.active { outline:2px solid #61c2ff; }
+    .layout { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr); gap:16px; align-items:start; }
+    .family { border:1px solid #275b74; background:#0b1922; padding:14px; }
+    .family.warning { border-color:#f2c94c; } .family.alarm { border-color:#ff7a7a; }
+    .event { border:1px solid #2f7098; background:#0e1e2d; padding:12px; margin-top:10px; }
+    .title { display:flex; justify-content:space-between; gap:8px; align-items:flex-start; }
+    .name { font-weight:700; } .code { color:#78a2b8; font-family:Consolas,monospace; font-size:12px; overflow-wrap:anywhere; }
+    .badge { font-size:12px; padding:3px 8px; border:1px solid; white-space:nowrap; }
+    .RED { color:#ff7a7a; border-color:#ff7a7a; } .YELLOW { color:#f2c94c; border-color:#f2c94c; }
+    .BLUE { color:#61c2ff; border-color:#61c2ff; } .GREEN { color:#68d095; border-color:#68d095; } .UNKNOWN { color:#a9b7c0; border-color:#a9b7c0; }
+    .meta { margin:8px 0 0; color:#8fb3c7; font-size:13px; } .value { font-size:22px; font-weight:700; margin-top:6px; }
+    .value small { font-size:13px; color:#8fb3c7; font-weight:400; margin-left:4px; }
+    .relation { margin-top:12px; border:1px dashed #f2c94c; background:#101a24; padding:12px; }
+    .empty { color:#a9b7c0; margin-top:10px; } .mono { font-family:Consolas,monospace; overflow-wrap:anywhere; }
+    pre { max-height:340px; overflow:auto; background:#071019; border:1px solid #275b74; padding:12px; }
+    .note { color:#8fb3c7; font-size:13px; }
+  </style>
+</head>
+<body>
+<main>
+  <header>
+    <h1>驾驶舱预警 / 报警只读模型演示</h1>
+    <p class="endpoint">GET /api/cockpit/v1/equipments/{deviceCode}/events/current</p>
+    <p class="endpoint">GET /api/cockpit/v1/equipments/{deviceCode}/events/history?startTime=&amp;endTime=&amp;page=1&amp;pageSize=100</p>
+  </header>
+  <section>
+    <div class="toolbar">
+      <button type="button" data-mode="warning" class="active">独立预警</button>
+      <button type="button" data-mode="alarm">独立报警</button>
+      <button type="button" data-mode="upgraded">预警升级为报警</button>
+    </div>
+    <div class="layout">
+      <div id="warning-family" class="family warning">
+        <h2>预警区域</h2><div id="warnings"></div>
+      </div>
+      <div id="alarm-family" class="family alarm">
+        <h2>报警区域</h2><div id="alarms"></div>
+      </div>
+    </div>
+    <div id="relation-panel"></div>
+  </section>
+  <section>
+    <h2>响应契约</h2>
+    <p class="note">预警与报警始终是两个事件族;alarmWarningRelations 只显示后端关联表给出的权威关系,前端不做真伪判断或时间戳推断。</p>
+    <pre id="payload" class="mono"></pre>
+  </section>
+</main>
+<script>
+  const response = (warnings, alarms, relations) => ({
+    code:200, msg:'操作成功', data:{
+      requestId:'event-read-demo', apiVersion:'v1', readOnly:true,
+      deviceCode:'TC-PS-WS-202609020012', deviceType:'ws_sewage_environment', deviceTypeName:'污水环境监测设备',
+      state: warnings.length || alarms.length ? 'READY' : 'EMPTY', stateReason: warnings.length || alarms.length ? 'READY' : 'NO_EVENTS',
+      warnings, alarms, alarmWarningRelations:relations, startTime:null, endTime:null,
+      timezone:'Asia/Shanghai', page:1, pageSize:0, total:warnings.length + alarms.length
+    }
+  });
+  const warning = (no, level, color, status, value, time) => ({ warningNo:no, warningName:'污水COD预警', warningType:'WS_ENV',
+    level, levelColor:color, status, eventTime:time, value, unit:'mg/L', deviceCode:'TC-PS-WS-202609020012',
+    deviceName:'污水环境监测设备', location:'太常片区' });
+  const alarm = (id, level, color, status, value, time) => ({ alarmId:id, warningCode:'WS_ENV_HIGH', warningName:'污水COD报警',
+    warningType:'WS_ENV', level, levelColor:color, status, eventTime:time, value, thresholdMinValue:40, thresholdMaxValue:50,
+    unit:'mg/L', deviceCode:'TC-PS-WS-202609020012', deviceName:'污水环境监测设备', location:'太常片区' });
+  const relation = (warningNo, alarmId) => ({ relationId:'REL-' + warningNo, warningNo, alarmId, relatedAt:'2026-09-02T10:00:00+08:00' });
+  const responses = {
+    warning: response([warning('WRN-20260902-001', 2, 'YELLOW', 'PROCESSING', 18.2, '2026-09-02T09:59:30+08:00')], [], []),
+    alarm: response([], [alarm('ALM-20260902-001', 1, 'RED', 'UNHANDLED', 42, '2026-09-02T10:00:00+08:00')], []),
+    upgraded: response(
+      [warning('WRN-20260902-001', 2, 'YELLOW', 'PROCESSING', 18.2, '2026-09-02T09:59:30+08:00')],
+      [alarm('ALM-20260902-001', 1, 'RED', 'UNHANDLED', 42, '2026-09-02T10:00:00+08:00')],
+      [relation('WRN-20260902-001', 'ALM-20260902-001')])
+  };
+  function renderEvent(item, codeField) {
+    return `<article class="event">
+      <div class="title"><div><div class="name">${item.warningName}</div><div class="code">${item[codeField]}</div></div><span class="badge ${item.levelColor}">${item.level} ${item.levelColor}</span></div>
+      <div class="value">${item.value ?? '—'}${item.unit ? '<small>' + item.unit + '</small>' : ''}</div>
+      <p class="meta">状态:${item.status}<br />时间:${item.eventTime}<br />设备:${item.deviceCode}</p>
+    </article>`;
+  }
+  function render(mode) {
+    document.querySelectorAll('button[data-mode]').forEach(button => button.classList.toggle('active', button.dataset.mode === mode));
+    const payload = responses[mode], data = payload.data;
+    document.getElementById('warnings').innerHTML = data.warnings.length
+      ? data.warnings.map(item => renderEvent(item, 'warningNo')).join('')
+      : '<p class="empty">本状态下没有预警事件。</p>';
+    document.getElementById('alarms').innerHTML = data.alarms.length
+      ? data.alarms.map(item => renderEvent(item, 'alarmId')).join('')
+      : '<p class="empty">本状态下没有报警事件。</p>';
+    document.getElementById('relation-panel').innerHTML = data.alarmWarningRelations.length
+      ? data.alarmWarningRelations.map(item => `<div class="relation"><strong>权威关联:</strong> ${item.warningNo} ⇄ ${item.alarmId}<br /><span class="note">${item.relationId} · ${item.relatedAt}</span></div>`).join('')
+      : '';
+    document.getElementById('payload').textContent = JSON.stringify(payload, null, 2);
+  }
+  document.querySelectorAll('button[data-mode]').forEach(button => button.addEventListener('click', () => render(button.dataset.mode)));
+  render('warning');
+</script>
+</body>
+</html>

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

@@ -0,0 +1,99 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.cockpit.event.CockpitEventReadResponse;
+import com.zksy.cockpit.event.CockpitEventReadService;
+import com.zksy.cockpit.foundation.CockpitDeviceNotFoundException;
+import com.zksy.cockpit.foundation.CockpitErrorPayload;
+import com.zksy.cockpit.foundation.CockpitInvalidRequestException;
+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.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 CockpitEventReadController {
+    private static final String REQUEST_ID_HEADER = "X-Request-Id";
+
+    private final CockpitEventReadService eventService;
+
+    public CockpitEventReadController(CockpitEventReadService eventService) {
+        this.eventService = eventService;
+    }
+
+    @GetMapping("/api/cockpit/v1/equipments/{deviceCode}/events/current")
+    @PreAuthorize("isAuthenticated()")
+    public ResponseEntity<AjaxResult> currentEvents(
+            @PathVariable("deviceCode") String deviceCode,
+            @RequestHeader(value = REQUEST_ID_HEADER, required = false) String requestId) {
+        CockpitEventReadResponse response = eventService.loadCurrent(
+                resolveRequestId(requestId), decodePathSegment(deviceCode));
+        return ResponseEntity.ok(AjaxResult.success(response));
+    }
+
+    @GetMapping("/api/cockpit/v1/equipments/{deviceCode}/events/history")
+    @PreAuthorize("isAuthenticated()")
+    public ResponseEntity<AjaxResult> historyEvents(
+            @PathVariable("deviceCode") String deviceCode,
+            @RequestParam(value = "startTime") String startTime,
+            @RequestParam(value = "endTime") String endTime,
+            @RequestParam(value = "page", required = false) Integer page,
+            @RequestParam(value = "pageSize", required = false) Integer pageSize,
+            @RequestHeader(value = REQUEST_ID_HEADER, required = false) String requestId) {
+        CockpitEventReadResponse response = eventService.loadHistory(
+                resolveRequestId(requestId),
+                decodePathSegment(deviceCode),
+                startTime,
+                endTime,
+                page,
+                pageSize);
+        return ResponseEntity.ok(AjaxResult.success(response));
+    }
+
+    @ExceptionHandler(CockpitInvalidRequestException.class)
+    public ResponseEntity<AjaxResult> handleInvalidRequest(HttpServletRequest request) {
+        return error(HttpStatus.BAD_REQUEST, eventService.invalidRequest(
+                resolveRequestId(request.getHeader(REQUEST_ID_HEADER))));
+    }
+
+    @ExceptionHandler(CockpitDeviceNotFoundException.class)
+    public ResponseEntity<AjaxResult> handleNotFound(HttpServletRequest request) {
+        return error(HttpStatus.NOT_FOUND, eventService.notFound(
+                resolveRequestId(request.getHeader(REQUEST_ID_HEADER))));
+    }
+
+    @ExceptionHandler(CockpitReadException.class)
+    public ResponseEntity<AjaxResult> handleReadFailure(HttpServletRequest request) {
+        return error(HttpStatus.INTERNAL_SERVER_ERROR, eventService.serverError(
+                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 requestId) {
+        return StringUtils.isNotEmpty(requestId) ? requestId : IdUtils.fastSimpleUUID();
+    }
+
+    private String decodePathSegment(String value) {
+        try {
+            return UriUtils.decode(value, StandardCharsets.UTF_8);
+        } catch (IllegalArgumentException exception) {
+            throw new CockpitInvalidRequestException("invalid device code encoding");
+        }
+    }
+}

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

@@ -0,0 +1,251 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.cockpit.event.CockpitAlarmWarningRelation;
+import com.zksy.cockpit.event.CockpitEventAlarm;
+import com.zksy.cockpit.event.CockpitEventReadAdapter;
+import com.zksy.cockpit.event.CockpitEventReadService;
+import com.zksy.cockpit.event.CockpitEventSnapshot;
+import com.zksy.cockpit.event.CockpitEventWarning;
+import com.zksy.cockpit.event.CockpitEventLevelColor;
+import com.zksy.cockpit.foundation.CockpitDeviceNotFoundException;
+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.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.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 CockpitEventReadControllerTest {
+    private static final String DEVICE_CODE = "TC-PS-WS-202609020012";
+
+    private CockpitEventReadAdapter adapter;
+    private MockMvc mockMvc;
+
+    @BeforeEach
+    void setUp() {
+        adapter = mock(CockpitEventReadAdapter.class);
+        CockpitEventReadService service = new CockpitEventReadService(
+                adapter,
+                Clock.fixed(Instant.parse("2026-09-02T02:00:00Z"), ZoneOffset.UTC));
+        mockMvc = MockMvcBuilders
+                .standaloneSetup(new CockpitEventReadController(service))
+                .build();
+    }
+
+    @Test
+    void currentEventsReturnSeparateFamiliesAndAuthoritativeRelations() throws Exception {
+        when(adapter.loadCurrent(DEVICE_CODE)).thenReturn(currentFixture());
+
+        mockMvc.perform(get("/api/cockpit/v1/equipments/" + DEVICE_CODE + "/events/current")
+                        .header("X-Request-Id", "event-current-001"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data.requestId").value("event-current-001"))
+                .andExpect(jsonPath("$.data.apiVersion").value("v1"))
+                .andExpect(jsonPath("$.data.readOnly").value(true))
+                .andExpect(jsonPath("$.data.deviceCode").value(DEVICE_CODE))
+                .andExpect(jsonPath("$.data.state").value("READY"))
+                .andExpect(jsonPath("$.data.warnings", org.hamcrest.Matchers.hasSize(2)))
+                .andExpect(jsonPath("$.data.warnings[0].warningNo").value("WRN-20260902-001"))
+                .andExpect(jsonPath("$.data.warnings[0].level").value(1))
+                .andExpect(jsonPath("$.data.warnings[0].levelColor").value("RED"))
+                .andExpect(jsonPath("$.data.warnings[0].status").value("PROCESSING"))
+                .andExpect(jsonPath("$.data.warnings[0].deviceCode").value(DEVICE_CODE))
+                .andExpect(jsonPath("$.data.warnings[1].level").value(2))
+                .andExpect(jsonPath("$.data.warnings[1].levelColor").value("YELLOW"))
+                .andExpect(jsonPath("$.data.alarms", org.hamcrest.Matchers.hasSize(1)))
+                .andExpect(jsonPath("$.data.alarms[0].alarmId").value("ALM-20260902-001"))
+                .andExpect(jsonPath("$.data.alarms[0].level").value(3))
+                .andExpect(jsonPath("$.data.alarms[0].levelColor").value("BLUE"))
+                .andExpect(jsonPath("$.data.alarms[0].status").value("UNHANDLED"))
+                .andExpect(jsonPath("$.data.alarms[0].deviceCode").value(DEVICE_CODE))
+                .andExpect(jsonPath("$.data.alarmWarningRelations", org.hamcrest.Matchers.hasSize(1)))
+                .andExpect(jsonPath("$.data.alarmWarningRelations[0].warningNo").value("WRN-20260902-001"))
+                .andExpect(jsonPath("$.data.alarmWarningRelations[0].alarmId").value("ALM-20260902-001"));
+    }
+
+    @Test
+    void eventLevelColorDictionaryCoversOneRedToFourGreen() throws Exception {
+        when(adapter.loadCurrent(DEVICE_CODE)).thenReturn(levelFixture());
+
+        mockMvc.perform(get("/api/cockpit/v1/equipments/" + DEVICE_CODE + "/events/current"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.warnings[0].levelColor").value("RED"))
+                .andExpect(jsonPath("$.data.warnings[1].levelColor").value("YELLOW"))
+                .andExpect(jsonPath("$.data.warnings[2].levelColor").value("BLUE"))
+                .andExpect(jsonPath("$.data.warnings[3].levelColor").value("GREEN"));
+    }
+
+    @Test
+    void historyEventsReturnPagedContractAndPreserveFamilies() throws Exception {
+        when(adapter.loadHistory(
+                DEVICE_CODE,
+                java.time.OffsetDateTime.parse("2026-09-01T10:00:00+08:00"),
+                java.time.OffsetDateTime.parse("2026-09-02T10:00:00+08:00"),
+                1,
+                100)).thenReturn(historyFixture());
+
+        mockMvc.perform(get("/api/cockpit/v1/equipments/" + DEVICE_CODE + "/events/history")
+                        .param("startTime", "2026-09-01T10:00:00+08:00")
+                        .param("endTime", "2026-09-02T10:00:00+08:00")
+                        .param("page", "1")
+                        .param("pageSize", "100")
+                        .header("X-Request-Id", "event-history-001"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.requestId").value("event-history-001"))
+                .andExpect(jsonPath("$.data.startTime").value("2026-09-01T10:00:00+08:00"))
+                .andExpect(jsonPath("$.data.endTime").value("2026-09-02T10:00:00+08:00"))
+                .andExpect(jsonPath("$.data.timezone").value("Asia/Shanghai"))
+                .andExpect(jsonPath("$.data.page").value(1))
+                .andExpect(jsonPath("$.data.pageSize").value(100))
+                .andExpect(jsonPath("$.data.total").value(3))
+                .andExpect(jsonPath("$.data.warnings", org.hamcrest.Matchers.hasSize(2)))
+                .andExpect(jsonPath("$.data.alarms", org.hamcrest.Matchers.hasSize(1)))
+                .andExpect(jsonPath("$.data.alarmWarningRelations", org.hamcrest.Matchers.hasSize(1)));
+    }
+
+    @Test
+    void historyRejectsInvertedRangeAndInvalidPage() throws Exception {
+        mockMvc.perform(get("/api/cockpit/v1/equipments/" + DEVICE_CODE + "/events/history")
+                        .param("startTime", "2026-09-02T10:00:00+08:00")
+                        .param("endTime", "2026-09-01T10:00:00+08:00"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.data.category").value("INVALID_REQUEST"));
+
+        mockMvc.perform(get("/api/cockpit/v1/equipments/" + DEVICE_CODE + "/events/history")
+                        .param("startTime", "2026-09-01T10:00:00+08:00")
+                        .param("endTime", "2026-09-02T10:00:00+08:00")
+                        .param("page", "0"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.data.category").value("INVALID_REQUEST"));
+    }
+
+    @Test
+    void currentEventsReturnSuccessfulEmptyState() throws Exception {
+        when(adapter.loadCurrent(DEVICE_CODE)).thenReturn(emptyFixture());
+
+        mockMvc.perform(get("/api/cockpit/v1/equipments/" + DEVICE_CODE + "/events/current"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.state").value("EMPTY"))
+                .andExpect(jsonPath("$.data.stateReason").value("NO_EVENTS"))
+                .andExpect(jsonPath("$.data.warnings", org.hamcrest.Matchers.hasSize(0)))
+                .andExpect(jsonPath("$.data.alarms", org.hamcrest.Matchers.hasSize(0)))
+                .andExpect(jsonPath("$.data.alarmWarningRelations", org.hamcrest.Matchers.hasSize(0)));
+    }
+
+    @Test
+    void missingDeviceAndAdapterFailureUseTypedReadOnlyErrors() throws Exception {
+        when(adapter.loadCurrent("MISSING")).thenThrow(new CockpitDeviceNotFoundException("device not found"));
+        when(adapter.loadCurrent("BROKEN")).thenThrow(new CockpitReadException("read failed"));
+
+        mockMvc.perform(get("/api/cockpit/v1/equipments/MISSING/events/current"))
+                .andExpect(status().isNotFound())
+                .andExpect(jsonPath("$.data.category").value("NOT_FOUND"));
+        mockMvc.perform(get("/api/cockpit/v1/equipments/BROKEN/events/current"))
+                .andExpect(status().isInternalServerError())
+                .andExpect(jsonPath("$.data.category").value("SERVER_ERROR"));
+    }
+
+    @Test
+    void controllerExposesReadOnlyMappedMethodsOnly() throws Exception {
+        Method[] methods = CockpitEventReadController.class.getDeclaredMethods();
+        for (Method method : methods) {
+            if (method.isAnnotationPresent(org.springframework.web.bind.annotation.GetMapping.class)) {
+                assertTrue(!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));
+            }
+        }
+
+        mockMvc.perform(post("/api/cockpit/v1/equipments/" + DEVICE_CODE + "/events/current"))
+                .andExpect(status().isMethodNotAllowed());
+        verifyNoInteractions(adapter);
+    }
+
+    private void assertTrue(boolean condition) {
+        org.junit.jupiter.api.Assertions.assertTrue(condition);
+    }
+
+    private CockpitEventSnapshot currentFixture() {
+        return new CockpitEventSnapshot(
+                DEVICE_CODE,
+                "ws_sewage_environment",
+                "污水环境监测设备",
+                Arrays.asList(
+                        new CockpitEventWarning(
+                                "WRN-20260902-001", "污水COD预警", "WS_ENV", 1, "RED",
+                                "PROCESSING", "2026-09-02T09:59:30+08:00",
+                                new BigDecimal("18.2"), "mg/L", DEVICE_CODE, "污水环境监测设备", "太常片区"),
+                        new CockpitEventWarning(
+                                "WRN-20260902-002", "污水pH预警", "WS_ENV", 2, "YELLOW",
+                                "PENDING", "2026-09-02T09:58:30+08:00",
+                                new BigDecimal("8.1"), "", DEVICE_CODE, "污水环境监测设备", "太常片区")),
+                Collections.singletonList(new CockpitEventAlarm(
+                        "ALM-20260902-001", "WS_ENV_HIGH", "污水COD报警", "WS_ENV", 3, "BLUE",
+                        "UNHANDLED", "2026-09-02T10:00:00+08:00",
+                        new BigDecimal("42.0"), new BigDecimal("40.0"), new BigDecimal("50.0"), "mg/L",
+                        DEVICE_CODE, "污水环境监测设备", "太常片区")),
+                Collections.singletonList(new CockpitAlarmWarningRelation(
+                        "REL-20260902-001", "WRN-20260902-001", "ALM-20260902-001",
+                        "2026-09-02T10:00:00+08:00")),
+                null);
+    }
+
+    private CockpitEventSnapshot levelFixture() {
+        return new CockpitEventSnapshot(
+                DEVICE_CODE,
+                "ws_sewage_environment",
+                "污水环境监测设备",
+                Arrays.asList(
+                        warning("WRN-1", 1), warning("WRN-2", 2),
+                        warning("WRN-3", 3), warning("WRN-4", 4)),
+                Collections.emptyList(),
+                Collections.emptyList(),
+                null);
+    }
+
+    private CockpitEventSnapshot historyFixture() {
+        return new CockpitEventSnapshot(
+                DEVICE_CODE,
+                "ws_sewage_environment",
+                "污水环境监测设备",
+                Arrays.asList(warning("WRN-20260901-001", 2), warning("WRN-20260901-002", 4)),
+                Collections.singletonList(new CockpitEventAlarm(
+                        "ALM-20260901-001", "WS_ENV_HIGH", "污水COD报警", "WS_ENV", 1, "RED",
+                        "HANDLED", "2026-09-01T15:30:00+08:00",
+                        new BigDecimal("45.0"), new BigDecimal("40.0"), new BigDecimal("50.0"), "mg/L",
+                        DEVICE_CODE, "污水环境监测设备", "太常片区")),
+                Collections.singletonList(new CockpitAlarmWarningRelation(
+                        "REL-20260901-001", "WRN-20260901-001", "ALM-20260901-001",
+                        "2026-09-01T15:30:00+08:00")),
+                3);
+    }
+
+    private CockpitEventSnapshot emptyFixture() {
+        return new CockpitEventSnapshot(
+                DEVICE_CODE, "gas_pressure", "燃气压力计",
+                Collections.emptyList(), Collections.emptyList(), Collections.emptyList(), null);
+    }
+
+    private CockpitEventWarning warning(String warningNo, Integer level) {
+        return new CockpitEventWarning(
+                warningNo, "等级映射预警", "WS_ENV", level, CockpitEventLevelColor.resolve(level), "PENDING",
+                "2026-09-02T09:59:30+08:00", null, "", DEVICE_CODE, "污水环境监测设备", "太常片区");
+    }
+}

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

@@ -0,0 +1,24 @@
+package com.zksy.cockpit.event;
+
+public class CockpitAlarmWarningRelation {
+    private final String relationId;
+    private final String warningNo;
+    private final String alarmId;
+    private final String relatedAt;
+
+    public CockpitAlarmWarningRelation(
+            String relationId,
+            String warningNo,
+            String alarmId,
+            String relatedAt) {
+        this.relationId = relationId;
+        this.warningNo = warningNo;
+        this.alarmId = alarmId;
+        this.relatedAt = relatedAt;
+    }
+
+    public String getRelationId() { return relationId; }
+    public String getWarningNo() { return warningNo; }
+    public String getAlarmId() { return alarmId; }
+    public String getRelatedAt() { return relatedAt; }
+}

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

@@ -0,0 +1,70 @@
+package com.zksy.cockpit.event;
+
+import java.math.BigDecimal;
+
+public class CockpitEventAlarm {
+    private final String alarmId;
+    private final String warningCode;
+    private final String warningName;
+    private final String warningType;
+    private final Integer level;
+    private final String levelColor;
+    private final String status;
+    private final String eventTime;
+    private final BigDecimal value;
+    private final BigDecimal thresholdMinValue;
+    private final BigDecimal thresholdMaxValue;
+    private final String unit;
+    private final String deviceCode;
+    private final String deviceName;
+    private final String location;
+
+    public CockpitEventAlarm(
+            String alarmId,
+            String warningCode,
+            String warningName,
+            String warningType,
+            Integer level,
+            String levelColor,
+            String status,
+            String eventTime,
+            BigDecimal value,
+            BigDecimal thresholdMinValue,
+            BigDecimal thresholdMaxValue,
+            String unit,
+            String deviceCode,
+            String deviceName,
+            String location) {
+        this.alarmId = alarmId;
+        this.warningCode = warningCode;
+        this.warningName = warningName;
+        this.warningType = warningType;
+        this.level = level;
+        this.levelColor = levelColor;
+        this.status = status;
+        this.eventTime = eventTime;
+        this.value = value;
+        this.thresholdMinValue = thresholdMinValue;
+        this.thresholdMaxValue = thresholdMaxValue;
+        this.unit = unit;
+        this.deviceCode = deviceCode;
+        this.deviceName = deviceName;
+        this.location = location;
+    }
+
+    public String getAlarmId() { return alarmId; }
+    public String getWarningCode() { return warningCode; }
+    public String getWarningName() { return warningName; }
+    public String getWarningType() { return warningType; }
+    public Integer getLevel() { return level; }
+    public String getLevelColor() { return levelColor; }
+    public String getStatus() { return status; }
+    public String getEventTime() { return eventTime; }
+    public BigDecimal getValue() { return value; }
+    public BigDecimal getThresholdMinValue() { return thresholdMinValue; }
+    public BigDecimal getThresholdMaxValue() { return thresholdMaxValue; }
+    public String getUnit() { return unit; }
+    public String getDeviceCode() { return deviceCode; }
+    public String getDeviceName() { return deviceName; }
+    public String getLocation() { return location; }
+}

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

@@ -0,0 +1,19 @@
+package com.zksy.cockpit.event;
+
+public final class CockpitEventLevelColor {
+    private CockpitEventLevelColor() {
+    }
+
+    public static String resolve(Integer level) {
+        if (level == null) {
+            return "UNKNOWN";
+        }
+        switch (level) {
+            case 1: return "RED";
+            case 2: return "YELLOW";
+            case 3: return "BLUE";
+            case 4: return "GREEN";
+            default: return "UNKNOWN";
+        }
+    }
+}

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

@@ -0,0 +1,14 @@
+package com.zksy.cockpit.event;
+
+import java.time.OffsetDateTime;
+
+public interface CockpitEventReadAdapter {
+    CockpitEventSnapshot loadCurrent(String deviceCode);
+
+    CockpitEventSnapshot loadHistory(
+            String deviceCode,
+            OffsetDateTime startTime,
+            OffsetDateTime endTime,
+            int page,
+            int pageSize);
+}

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

@@ -0,0 +1,78 @@
+package com.zksy.cockpit.event;
+
+import java.util.List;
+
+public class CockpitEventReadResponse {
+    private final String requestId;
+    private final String apiVersion;
+    private final boolean readOnly;
+    private final String deviceCode;
+    private final String deviceType;
+    private final String deviceTypeName;
+    private final String state;
+    private final String stateReason;
+    private final List<CockpitEventWarning> warnings;
+    private final List<CockpitEventAlarm> alarms;
+    private final List<CockpitAlarmWarningRelation> alarmWarningRelations;
+    private final String startTime;
+    private final String endTime;
+    private final String timezone;
+    private final int page;
+    private final int pageSize;
+    private final int total;
+
+    public CockpitEventReadResponse(
+            String requestId,
+            String apiVersion,
+            boolean readOnly,
+            String deviceCode,
+            String deviceType,
+            String deviceTypeName,
+            String state,
+            String stateReason,
+            List<CockpitEventWarning> warnings,
+            List<CockpitEventAlarm> alarms,
+            List<CockpitAlarmWarningRelation> alarmWarningRelations,
+            String startTime,
+            String endTime,
+            String timezone,
+            int page,
+            int pageSize,
+            int total) {
+        this.requestId = requestId;
+        this.apiVersion = apiVersion;
+        this.readOnly = readOnly;
+        this.deviceCode = deviceCode;
+        this.deviceType = deviceType;
+        this.deviceTypeName = deviceTypeName;
+        this.state = state;
+        this.stateReason = stateReason;
+        this.warnings = warnings;
+        this.alarms = alarms;
+        this.alarmWarningRelations = alarmWarningRelations;
+        this.startTime = startTime;
+        this.endTime = endTime;
+        this.timezone = timezone;
+        this.page = page;
+        this.pageSize = pageSize;
+        this.total = total;
+    }
+
+    public String getRequestId() { return requestId; }
+    public String getApiVersion() { return apiVersion; }
+    public boolean isReadOnly() { return readOnly; }
+    public String getDeviceCode() { return deviceCode; }
+    public String getDeviceType() { return deviceType; }
+    public String getDeviceTypeName() { return deviceTypeName; }
+    public String getState() { return state; }
+    public String getStateReason() { return stateReason; }
+    public List<CockpitEventWarning> getWarnings() { return warnings; }
+    public List<CockpitEventAlarm> getAlarms() { return alarms; }
+    public List<CockpitAlarmWarningRelation> getAlarmWarningRelations() { return alarmWarningRelations; }
+    public String getStartTime() { return startTime; }
+    public String getEndTime() { return endTime; }
+    public String getTimezone() { return timezone; }
+    public int getPage() { return page; }
+    public int getPageSize() { return pageSize; }
+    public int getTotal() { return total; }
+}

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

@@ -0,0 +1,170 @@
+package com.zksy.cockpit.event;
+
+import com.zksy.cockpit.foundation.CockpitDeviceNotFoundException;
+import com.zksy.cockpit.foundation.CockpitErrorCategory;
+import com.zksy.cockpit.foundation.CockpitErrorPayload;
+import com.zksy.cockpit.foundation.CockpitInvalidRequestException;
+import com.zksy.cockpit.foundation.CockpitReadException;
+import org.springframework.stereotype.Service;
+
+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.ArrayList;
+import java.util.List;
+
+@Service
+public class CockpitEventReadService {
+    private static final int MAX_PAGE_SIZE = 1000;
+
+    private final CockpitEventReadAdapter adapter;
+    private final Clock clock;
+
+    public CockpitEventReadService(CockpitEventReadAdapter adapter, Clock clock) {
+        this.adapter = adapter;
+        this.clock = clock;
+    }
+
+    public CockpitEventReadResponse loadCurrent(String requestId, String deviceCode) {
+        if (isBlank(deviceCode)) {
+            throw new CockpitInvalidRequestException("device code is required");
+        }
+        CockpitEventSnapshot snapshot = adapter.loadCurrent(deviceCode.trim());
+        if (snapshot == null) {
+            throw new CockpitDeviceNotFoundException("device not found");
+        }
+        return response(requestId, snapshot, null, null, 1, 0);
+    }
+
+    public CockpitEventReadResponse loadHistory(
+            String requestId,
+            String deviceCode,
+            String startTime,
+            String endTime,
+            Integer page,
+            Integer pageSize) {
+        if (isBlank(deviceCode)) {
+            throw new CockpitInvalidRequestException("device code is required");
+        }
+        OffsetDateTime start = parseTime(startTime, "start time");
+        OffsetDateTime end = parseTime(endTime, "end time");
+        if (start.isAfter(end)) {
+            throw new CockpitInvalidRequestException("start time must not be after end time");
+        }
+        int resolvedPage = page == null ? 1 : page;
+        int resolvedPageSize = pageSize == null ? 100 : pageSize;
+        if (resolvedPage < 1 || resolvedPageSize < 1 || resolvedPageSize > MAX_PAGE_SIZE) {
+            throw new CockpitInvalidRequestException("invalid history page");
+        }
+        CockpitEventSnapshot snapshot = adapter.loadHistory(
+                deviceCode.trim(), start, end, resolvedPage, resolvedPageSize);
+        if (snapshot == null) {
+            throw new CockpitDeviceNotFoundException("device not found");
+        }
+        return response(requestId, snapshot, startTime, endTime, resolvedPage, resolvedPageSize);
+    }
+
+    public CockpitErrorPayload invalidRequest(String requestId) {
+        return error(requestId, CockpitErrorCategory.INVALID_REQUEST, "事件查询参数不合法");
+    }
+
+    public CockpitErrorPayload notFound(String requestId) {
+        return error(requestId, CockpitErrorCategory.NOT_FOUND, "设备不存在");
+    }
+
+    public CockpitErrorPayload serverError(String requestId) {
+        return error(requestId, CockpitErrorCategory.SERVER_ERROR, "驾驶舱事件数据暂不可用");
+    }
+
+    private CockpitEventReadResponse response(
+            String requestId,
+            CockpitEventSnapshot snapshot,
+            String startTime,
+            String endTime,
+            int page,
+            int pageSize) {
+        List<CockpitEventWarning> warnings = safeList(snapshot.getWarnings());
+        List<CockpitEventAlarm> alarms = safeList(snapshot.getAlarms());
+        List<CockpitAlarmWarningRelation> relations = safeList(snapshot.getAlarmWarningRelations());
+        String state = warnings.isEmpty() && alarms.isEmpty() ? "EMPTY" : "READY";
+        String stateReason = "EMPTY".equals(state) ? "NO_EVENTS" : "READY";
+        int resolvedTotal = snapshot.getTotal() == null ? warnings.size() + alarms.size() : snapshot.getTotal();
+        return new CockpitEventReadResponse(
+                requestId,
+                "v1",
+                true,
+                snapshot.getDeviceCode(),
+                snapshot.getDeviceType(),
+                snapshot.getDeviceTypeName(),
+                state,
+                stateReason,
+                warnings,
+                alarms,
+                relations,
+                startTime,
+                endTime,
+                "Asia/Shanghai",
+                page,
+                pageSize,
+                resolvedTotal);
+    }
+
+
+    private List<CockpitEventWarning> decorateWarnings(List<CockpitEventWarning> source) {
+        List<CockpitEventWarning> result = new ArrayList<>();
+        for (CockpitEventWarning item : source) {
+            if (item == null) {
+                continue;
+            }
+            result.add(new CockpitEventWarning(
+                    item.getWarningNo(), item.getWarningName(), item.getWarningType(), item.getLevel(),
+                    CockpitEventLevelColor.resolve(item.getLevel()), item.getStatus(), item.getEventTime(),
+                    item.getValue(), item.getUnit(), item.getDeviceCode(), item.getDeviceName(), item.getLocation()));
+        }
+        return result;
+    }
+
+    private List<CockpitEventAlarm> decorateAlarms(List<CockpitEventAlarm> source) {
+        List<CockpitEventAlarm> result = new ArrayList<>();
+        for (CockpitEventAlarm item : source) {
+            if (item == null) {
+                continue;
+            }
+            result.add(new CockpitEventAlarm(
+                    item.getAlarmId(), item.getWarningCode(), item.getWarningName(), item.getWarningType(),
+                    item.getLevel(), CockpitEventLevelColor.resolve(item.getLevel()), item.getStatus(),
+                    item.getEventTime(), item.getValue(), item.getThresholdMinValue(), item.getThresholdMaxValue(),
+                    item.getUnit(), item.getDeviceCode(), item.getDeviceName(), item.getLocation()));
+        }
+        return result;
+    }
+    private <T> List<T> safeList(List<T> values) {
+        return values == null ? new ArrayList<>() : values;
+    }
+
+    private OffsetDateTime parseTime(String value, String fieldName) {
+        if (isBlank(value)) {
+            throw new CockpitInvalidRequestException(fieldName + " is required");
+        }
+        try {
+            return OffsetDateTime.parse(value);
+        } catch (DateTimeParseException exception) {
+            throw new CockpitInvalidRequestException("invalid " + fieldName);
+        }
+    }
+
+    private CockpitErrorPayload error(String requestId, CockpitErrorCategory category, String message) {
+        return new CockpitErrorPayload(
+                category,
+                message,
+                requestId,
+                DateTimeFormatter.ISO_OFFSET_DATE_TIME
+                        .format(clock.instant().atZone(ZoneId.of("Asia/Shanghai"))));
+    }
+
+    private static boolean isBlank(String value) {
+        return value == null || value.trim().isEmpty();
+    }
+}

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

@@ -0,0 +1,38 @@
+package com.zksy.cockpit.event;
+
+import java.util.List;
+
+public class CockpitEventSnapshot {
+    private final String deviceCode;
+    private final String deviceType;
+    private final String deviceTypeName;
+    private final List<CockpitEventWarning> warnings;
+    private final List<CockpitEventAlarm> alarms;
+    private final List<CockpitAlarmWarningRelation> alarmWarningRelations;
+    private final Integer total;
+
+    public CockpitEventSnapshot(
+            String deviceCode,
+            String deviceType,
+            String deviceTypeName,
+            List<CockpitEventWarning> warnings,
+            List<CockpitEventAlarm> alarms,
+            List<CockpitAlarmWarningRelation> alarmWarningRelations,
+            Integer total) {
+        this.deviceCode = deviceCode;
+        this.deviceType = deviceType;
+        this.deviceTypeName = deviceTypeName;
+        this.warnings = warnings;
+        this.alarms = alarms;
+        this.alarmWarningRelations = alarmWarningRelations;
+        this.total = total;
+    }
+
+    public String getDeviceCode() { return deviceCode; }
+    public String getDeviceType() { return deviceType; }
+    public String getDeviceTypeName() { return deviceTypeName; }
+    public List<CockpitEventWarning> getWarnings() { return warnings; }
+    public List<CockpitEventAlarm> getAlarms() { return alarms; }
+    public List<CockpitAlarmWarningRelation> getAlarmWarningRelations() { return alarmWarningRelations; }
+    public Integer getTotal() { return total; }
+}

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

@@ -0,0 +1,58 @@
+package com.zksy.cockpit.event;
+
+import java.math.BigDecimal;
+
+public class CockpitEventWarning {
+    private final String warningNo;
+    private final String warningName;
+    private final String warningType;
+    private final Integer level;
+    private final String levelColor;
+    private final String status;
+    private final String eventTime;
+    private final BigDecimal value;
+    private final String unit;
+    private final String deviceCode;
+    private final String deviceName;
+    private final String location;
+
+    public CockpitEventWarning(
+            String warningNo,
+            String warningName,
+            String warningType,
+            Integer level,
+            String levelColor,
+            String status,
+            String eventTime,
+            BigDecimal value,
+            String unit,
+            String deviceCode,
+            String deviceName,
+            String location) {
+        this.warningNo = warningNo;
+        this.warningName = warningName;
+        this.warningType = warningType;
+        this.level = level;
+        this.levelColor = levelColor;
+        this.status = status;
+        this.eventTime = eventTime;
+        this.value = value;
+        this.unit = unit;
+        this.deviceCode = deviceCode;
+        this.deviceName = deviceName;
+        this.location = location;
+    }
+
+    public String getWarningNo() { return warningNo; }
+    public String getWarningName() { return warningName; }
+    public String getWarningType() { return warningType; }
+    public Integer getLevel() { return level; }
+    public String getLevelColor() { return levelColor; }
+    public String getStatus() { return status; }
+    public String getEventTime() { return eventTime; }
+    public BigDecimal getValue() { return value; }
+    public String getUnit() { return unit; }
+    public String getDeviceCode() { return deviceCode; }
+    public String getDeviceName() { return deviceName; }
+    public String getLocation() { return location; }
+}

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

@@ -0,0 +1,23 @@
+package com.zksy.cockpit.event;
+
+import org.springframework.stereotype.Component;
+
+import java.time.OffsetDateTime;
+
+@Component
+public class EmptyCockpitEventReadAdapter implements CockpitEventReadAdapter {
+    @Override
+    public CockpitEventSnapshot loadCurrent(String deviceCode) {
+        return null;
+    }
+
+    @Override
+    public CockpitEventSnapshot loadHistory(
+            String deviceCode,
+            OffsetDateTime startTime,
+            OffsetDateTime endTime,
+            int page,
+            int pageSize) {
+        return null;
+    }
+}