ソースを参照

feat(cockpit): add type-specific current and historical metrics (#23)

Kazerin 10 時間 前
コミット
74e9963277

+ 145 - 0
docs/cockpit/type-metrics-demo.html

@@ -0,0 +1,145 @@
+<!doctype html>
+<html lang="zh-CN">
+<head>
+  <meta charset="utf-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1" />
+  <title>Cockpit Type Metrics Demo</title>
+  <style>
+    :root { color-scheme: dark; }
+    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; }
+    .state-grid { display:grid; grid-template-columns:repeat(4,minmax(140px,1fr)); gap:10px; margin-bottom:16px; }
+    .state { background:#0b1922; border:1px solid #275b74; padding:10px; }
+    .state strong { display:block; font-size:16px; } .state span { color:#8fb3c7; font-size:13px; }
+    .state.ok strong { color:#68d095; } .state.warning strong { color:#f2c94c; } .state.empty strong { color:#a9b7c0; } .state.error strong { color:#ff7a7a; }
+    .cards { display:grid; grid-template-columns:repeat(auto-fit,minmax(210px,1fr)); gap:12px; }
+    .card { background:#0b1922; border:1px solid #275b74; padding:12px; }
+    .card-top { display:flex; justify-content:space-between; gap:8px; align-items:flex-start; }
+    .metric-name { font-weight:700; } .metric-code { color:#78a2b8; font-family:Consolas,monospace; font-size:12px; }
+    .metric-value { font-size:26px; font-weight:700; margin:8px 0 4px; } .metric-value small { font-size:13px; font-weight:400; color:#8fb3c7; margin-left:4px; }
+    .badge { font-size:12px; padding:3px 8px; border:1px solid; white-space:nowrap; }
+    .normal { color:#68d095; border-color:#68d095; } .warning { color:#f2c94c; border-color:#f2c94c; } .alarm { color:#ff7a7a; border-color:#ff7a7a; }
+    .unknown { color:#a9b7c0; border-color:#a9b7c0; } .fresh { color:#68d095; border-color:#68d095; } .stale { color:#f2c94c; border-color:#f2c94c; }
+    .history { margin-top:10px; } .trend { display:block; width:100%; height:42px; }
+    .mono { font-family:Consolas,monospace; overflow-wrap:anywhere; } .note { color:#8fb3c7; font-size:13px; }
+    pre { max-height:360px; overflow:auto; background:#071019; border:1px solid #275b74; padding:12px; }
+    .missing { display:grid; place-items:center; min-height:220px; border:1px dashed #3f667d; color:#a9b7c0; }
+  </style>
+</head>
+<body>
+<main>
+  <header>
+    <h1>驾驶舱设备类型指标演示</h1>
+    <p class="endpoint">GET /api/cockpit/v1/equipments/{deviceCode}/metrics/current</p>
+    <p class="endpoint">GET /api/cockpit/v1/equipments/{deviceCode}/metrics/history?startTime=&amp;endTime=&amp;page=1&amp;pageSize=100</p>
+  </header>
+  <section>
+    <div class="toolbar">
+      <button type="button" data-mode="gas" class="active">燃气</button>
+      <button type="button" data-mode="supply">供水</button>
+      <button type="button" data-mode="drainage">排水</button>
+      <button type="button" data-mode="sewage">污水环境</button>
+      <button type="button" data-mode="empty">成功空数据</button>
+      <button type="button" data-mode="stale">过期数据</button>
+      <button type="button" data-mode="failure">加载失败</button>
+    </div>
+    <div id="detail">
+      <div class="state-grid">
+        <div class="state ok"><strong id="state-value">CURRENT</strong><span>响应状态</span></div>
+        <div class="state"><strong id="state-reason">READY</strong><span>状态原因</span></div>
+        <div class="state"><strong id="device-type">-</strong><span>设备类型</span></div>
+        <div class="state"><strong id="read-only">true</strong><span>只读接口</span></div>
+      </div>
+      <h2>指标卡与历史趋势</h2>
+      <div id="metrics" class="cards"></div>
+    </div>
+    <div id="missing" class="missing" hidden><div><strong id="missing-title"></strong><p class="note" id="missing-reason"></p></div></div>
+  </section>
+  <section>
+    <h2>当前响应契约</h2>
+    <p class="note">页面只按接口返回的 metricCode / metricName / unit / points 渲染,不硬编码数据库字段;未知指标保留 UNKNOWN 展示。</p>
+    <pre id="payload" class="mono"></pre>
+  </section>
+</main>
+<script>
+  const demoClock = '2026-09-02T10:00:00+08:00';
+  const current = (deviceCode, deviceType, deviceTypeName, metrics, state='CURRENT', reason='READY') => ({
+    code:200, msg:'操作成功', data:{ requestId:'type-metrics-demo-' + deviceCode, apiVersion:'v1', readOnly:true,
+      deviceCode, deviceType, deviceTypeName, state, stateReason:reason, metrics, startTime:null, endTime:null,
+      timezone:'Asia/Shanghai', page:1, pageSize:0, total:0 }
+  });
+  const point = (value, sampledAt, status='NORMAL') => ({ value, status, sampledAt, freshness:'HISTORICAL' });
+  const metric = (code, name, value, unit, status, sampledAt, points=[]) => ({ metricCode:code, metricName:name, value, unit, status, sampledAt, freshness:'CURRENT', points });
+  const responses = {
+    gas: current('TC-RQ-202609020001', 'gas_pressure', '燃气压力计', [
+      metric('PRESSURE', '管道压力', 0.36, 'MPa', 'NORMAL', '2026-09-02T09:59:40+08:00', [point(.38,'09:40'),point(.37,'09:50'),point(.36,'10:00')]),
+      metric('TEMPERATURE', '介质温度', 23.4, '℃', 'NORMAL', '2026-09-02T09:59:40+08:00')
+    ]),
+    supply: current('TC-GS-202609020005', 'gs_pressure', '供水压力计', [
+      metric('PRESSURE', '供水压力', 0.29, 'MPa', 'NORMAL', '2026-09-02T09:59:45+08:00'),
+      metric('FLOW', '瞬时流量', 42.7, 'm³/h', 'WARNING', '2026-09-02T09:59:45+08:00')
+    ]),
+    drainage: current('TC-PS-YS-202609020009', 'ws_rainflow', '雨水流量计', [
+      metric('FLOW', '雨水流量', 138.2, 'L/s', 'NORMAL', '2026-09-02T09:59:50+08:00'),
+      metric('LIQUID_LEVEL', '液位', 1.18, 'm', 'NORMAL', '2026-09-02T09:59:50+08:00')
+    ]),
+    sewage: current('TC-PS-WS-202609020012', 'ws_sewage_environment', '污水环境监测设备', [
+      metric('SUSPENDED_SOLIDS', '悬浮物', 12.5, 'mg/L', 'NORMAL', '2026-09-02T09:59:30+08:00'),
+      metric('COD', 'COD', 18.2, 'mg/L', 'WARNING', '2026-09-02T09:59:30+08:00'),
+      metric('AMMONIA_NITROGEN', '氨氮', 0.8, 'mg/L', 'NORMAL', '2026-09-02T09:59:30+08:00'),
+      metric('CONDUCTIVITY', '电导率', 780, 'μS/cm', 'NORMAL', '2026-09-02T09:59:30+08:00'),
+      metric('PH', 'pH', 7.2, '', 'NORMAL', '2026-09-02T09:59:30+08:00'),
+      metric('UNKNOWN', '厂商私有参数', 1, '', 'UNKNOWN', '2026-09-02T09:59:30+08:00')
+    ]),
+    empty: current('TC-RQ-202609020021', 'gas_pressure', '燃气压力计', [], 'EMPTY', 'NO_METRICS'),
+    stale: current('TC-GS-202609020022', 'gs_pressure', '供水压力计', [
+      metric('PRESSURE', '供水压力', 0.31, 'MPa', 'NORMAL', '2026-09-02T09:58:00+08:00')
+    ], 'STALE', 'SAMPLE_OUTDATED'),
+    failure: { code:500, msg:'驾驶舱设备指标数据暂不可用', data:{ category:'SERVER_ERROR', message:'驾驶舱设备指标数据暂不可用', requestId:'type-metrics-demo-500', serverTime:demoClock } }
+  };
+  responses.stale.data.metrics[0].freshness = 'STALE';
+  function trend(points) {
+    if (!points || points.length < 2) return '<div class="note history">暂无历史序列</div>';
+    const values = points.map(item => item.value);
+    const min = Math.min(...values), max = Math.max(...values);
+    const range = max === min ? 1 : max - min;
+    const coords = points.map((item, index) => (10 + index * 80 / (points.length - 1)).toFixed(1) + ',' + (36 - (item.value - min) / range * 28).toFixed(1)).join(' ');
+    return '<svg class="trend history" viewBox="0 0 100 42" preserveAspectRatio="none"><polyline points="' + coords + '" fill="none" stroke="#61c2ff" stroke-width="2" /></svg>';
+  }
+  function render(mode) {
+    document.querySelectorAll('button[data-mode]').forEach(button => button.classList.toggle('active', button.dataset.mode === mode));
+    const payload = responses[mode], success = payload.code === 200;
+    document.getElementById('detail').hidden = !success;
+    document.getElementById('missing').hidden = success;
+    document.getElementById('payload').textContent = JSON.stringify(payload, null, 2);
+    if (!success) {
+      document.getElementById('missing-title').textContent = 'HTTP ' + payload.code + ':' + payload.msg;
+      document.getElementById('missing-reason').textContent = '失败状态独立展示,不与空数据或过期数据混淆。';
+      return;
+    }
+    const data = payload.data;
+    document.getElementById('state-value').textContent = data.state;
+    document.getElementById('state-reason').textContent = data.stateReason;
+    document.getElementById('device-type').textContent = data.deviceType;
+    document.getElementById('read-only').textContent = data.readOnly;
+    document.getElementById('metrics').innerHTML = data.metrics.map(item => `
+      <article class="card">
+        <div class="card-top">
+          <div><div class="metric-name">${item.metricName}</div><div class="metric-code">${item.metricCode}</div></div>
+          <div><span class="badge ${String(item.status).toLowerCase()}">${item.status || '-'}</span><span class="badge ${item.freshness === 'STALE' ? 'stale' : 'fresh'}">${item.freshness}</span></div>
+        </div>
+        <div class="metric-value">${item.value ?? '—'}${item.unit ? '<small>' + item.unit + '</small>' : ''}</div>
+        ${trend(item.points)}
+      </article>`).join('');
+  }
+  document.querySelectorAll('button[data-mode]').forEach(button => button.addEventListener('click', () => render(button.dataset.mode)));
+  render('gas');
+</script>
+</body>
+</html>

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

@@ -0,0 +1,99 @@
+package com.zksy.web.controller.cockpit;
+
+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.cockpit.type.CockpitTypeMetricsResponse;
+import com.zksy.cockpit.type.CockpitTypeMetricsService;
+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 CockpitTypeMetricsController {
+    private static final String REQUEST_ID_HEADER = "X-Request-Id";
+
+    private final CockpitTypeMetricsService metricsService;
+
+    public CockpitTypeMetricsController(CockpitTypeMetricsService metricsService) {
+        this.metricsService = metricsService;
+    }
+
+    @GetMapping("/api/cockpit/v1/equipments/{deviceCode}/metrics/current")
+    @PreAuthorize("isAuthenticated()")
+    public ResponseEntity<AjaxResult> currentMetrics(
+            @PathVariable("deviceCode") String deviceCode,
+            @RequestHeader(value = REQUEST_ID_HEADER, required = false) String requestId) {
+        CockpitTypeMetricsResponse response = metricsService.loadCurrent(
+                resolveRequestId(requestId), decodePathSegment(deviceCode));
+        return ResponseEntity.ok(AjaxResult.success(response));
+    }
+
+    @GetMapping("/api/cockpit/v1/equipments/{deviceCode}/metrics/history")
+    @PreAuthorize("isAuthenticated()")
+    public ResponseEntity<AjaxResult> historyMetrics(
+            @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) {
+        CockpitTypeMetricsResponse response = metricsService.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, metricsService.invalidRequest(
+                resolveRequestId(request.getHeader(REQUEST_ID_HEADER))));
+    }
+
+    @ExceptionHandler(CockpitDeviceNotFoundException.class)
+    public ResponseEntity<AjaxResult> handleNotFound(HttpServletRequest request) {
+        return error(HttpStatus.NOT_FOUND, metricsService.notFound(
+                resolveRequestId(request.getHeader(REQUEST_ID_HEADER))));
+    }
+
+    @ExceptionHandler(CockpitReadException.class)
+    public ResponseEntity<AjaxResult> handleReadFailure(HttpServletRequest request) {
+        return error(HttpStatus.INTERNAL_SERVER_ERROR, metricsService.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");
+        }
+    }
+}

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

@@ -0,0 +1,235 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.cockpit.foundation.CockpitDeviceNotFoundException;
+import com.zksy.cockpit.foundation.CockpitReadException;
+import com.zksy.cockpit.type.CockpitMetricSnapshot;
+import com.zksy.cockpit.type.CockpitTypeMetricsAdapter;
+import com.zksy.cockpit.type.CockpitTypeMetricsService;
+import com.zksy.cockpit.type.CockpitTypeMetricsSnapshot;
+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.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 CockpitTypeMetricsControllerTest {
+    private static final String DEVICE_CODE = "TC-PS-WS-202609020012";
+    private static final String NOW = "2026-09-02T10:00:00+08:00";
+
+    private CockpitTypeMetricsAdapter adapter;
+    private MockMvc mockMvc;
+
+    @BeforeEach
+    void setUp() {
+        adapter = mock(CockpitTypeMetricsAdapter.class);
+        CockpitTypeMetricsService service = new CockpitTypeMetricsService(
+                adapter,
+                Clock.fixed(Instant.parse("2026-09-02T02:00:00Z"), ZoneOffset.UTC));
+        mockMvc = MockMvcBuilders
+                .standaloneSetup(new CockpitTypeMetricsController(service))
+                .build();
+    }
+
+    @Test
+    void currentMetricsReturnSewageFiveMetricContract() throws Exception {
+        when(adapter.loadCurrent(DEVICE_CODE)).thenReturn(sewageFixture("2026-09-02T09:59:30+08:00"));
+
+        mockMvc.perform(get("/api/cockpit/v1/equipments/" + DEVICE_CODE + "/metrics/current")
+                        .header("X-Request-Id", "type-metrics-current-001"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.msg").value("操作成功"))
+                .andExpect(jsonPath("$.data.requestId").value("type-metrics-current-001"))
+                .andExpect(jsonPath("$.data.apiVersion").value("v1"))
+                .andExpect(jsonPath("$.data.readOnly").value(true))
+                .andExpect(jsonPath("$.data.deviceCode").value(DEVICE_CODE))
+                .andExpect(jsonPath("$.data.deviceType").value("ws_sewage_environment"))
+                .andExpect(jsonPath("$.data.state").value("CURRENT"))
+                .andExpect(jsonPath("$.data.metrics", org.hamcrest.Matchers.hasSize(6)))
+                .andExpect(jsonPath("$.data.metrics[0].metricCode").value("SUSPENDED_SOLIDS"))
+                .andExpect(jsonPath("$.data.metrics[0].metricName").value("悬浮物"))
+                .andExpect(jsonPath("$.data.metrics[0].value").value(12.5))
+                .andExpect(jsonPath("$.data.metrics[0].unit").value("mg/L"))
+                .andExpect(jsonPath("$.data.metrics[0].status").value("NORMAL"))
+                .andExpect(jsonPath("$.data.metrics[0].sampledAt").value("2026-09-02T09:59:30+08:00"))
+                .andExpect(jsonPath("$.data.metrics[0].freshness").value("CURRENT"))
+                .andExpect(jsonPath("$.data.metrics[1].metricCode").value("COD"))
+                .andExpect(jsonPath("$.data.metrics[2].metricCode").value("AMMONIA_NITROGEN"))
+                .andExpect(jsonPath("$.data.metrics[3].metricCode").value("CONDUCTIVITY"))
+                .andExpect(jsonPath("$.data.metrics[4].metricCode").value("PH"))
+                .andExpect(jsonPath("$.data.metrics[5].metricCode").value("UNKNOWN"));
+    }
+
+    @Test
+    void currentMetricsMarkMissingRecentSampleAsStale() throws Exception {
+        when(adapter.loadCurrent(DEVICE_CODE)).thenReturn(sewageFixture("2026-09-02T09:58:00+08:00"));
+
+        mockMvc.perform(get("/api/cockpit/v1/equipments/" + DEVICE_CODE + "/metrics/current"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.state").value("STALE"))
+                .andExpect(jsonPath("$.data.metrics[0].freshness").value("STALE"));
+    }
+
+    @Test
+    void historyMetricsReturnOrderedPagedContract() 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(sewageHistoryFixture());
+
+        mockMvc.perform(get("/api/cockpit/v1/equipments/" + DEVICE_CODE + "/metrics/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", "type-metrics-history-001"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data.requestId").value("type-metrics-history-001"))
+                .andExpect(jsonPath("$.data.apiVersion").value("v1"))
+                .andExpect(jsonPath("$.data.readOnly").value(true))
+                .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(2))
+                .andExpect(jsonPath("$.data.metrics", org.hamcrest.Matchers.hasSize(1)))
+                .andExpect(jsonPath("$.data.metrics[0].metricCode").value("PH"))
+                .andExpect(jsonPath("$.data.metrics[0].points", org.hamcrest.Matchers.hasSize(2)))
+                .andExpect(jsonPath("$.data.metrics[0].points[0].sampledAt").value("2026-09-02T09:58:00+08:00"))
+                .andExpect(jsonPath("$.data.metrics[0].points[1].sampledAt").value("2026-09-02T09:59:00+08:00"))
+                .andExpect(jsonPath("$.data.metrics[0].points[0].freshness").value("HISTORICAL"));
+    }
+
+    @Test
+    void historyRejectsInvertedRange() throws Exception {
+        mockMvc.perform(get("/api/cockpit/v1/equipments/" + DEVICE_CODE + "/metrics/history")
+                        .param("startTime", "2026-09-02T10:00:00+08:00")
+                        .param("endTime", "2026-09-01T10:00:00+08:00"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.code").value(400))
+                .andExpect(jsonPath("$.data.category").value("INVALID_REQUEST"));
+    }
+
+    @Test
+    void currentMetricsReturnEmptyStateWithoutMistakingItForFailure() throws Exception {
+        when(adapter.loadCurrent(DEVICE_CODE)).thenReturn(emptyFixture());
+
+        mockMvc.perform(get("/api/cockpit/v1/equipments/" + DEVICE_CODE + "/metrics/current"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.state").value("EMPTY"))
+                .andExpect(jsonPath("$.data.stateReason").value("NO_METRICS"))
+                .andExpect(jsonPath("$.data.metrics", 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/metrics/current"))
+                .andExpect(status().isNotFound())
+                .andExpect(jsonPath("$.data.category").value("NOT_FOUND"));
+        mockMvc.perform(get("/api/cockpit/v1/equipments/BROKEN/metrics/current"))
+                .andExpect(status().isInternalServerError())
+                .andExpect(jsonPath("$.data.category").value("SERVER_ERROR"));
+    }
+
+    @Test
+    void controllerExposesReadOnlyMappedMethodsOnly() throws Exception {
+        Method[] methods = CockpitTypeMetricsController.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 + "/metrics/current"))
+                .andExpect(status().isMethodNotAllowed());
+        verifyNoInteractions(adapter);
+    }
+
+    private CockpitTypeMetricsSnapshot sewageFixture(String sampledAt) {
+        return new CockpitTypeMetricsSnapshot(
+                DEVICE_CODE,
+                "ws_sewage_environment",
+                "污水环境监测设备",
+                Arrays.asList(
+                        metric("SUSPENDED_SOLIDS", "悬浮物", new BigDecimal("12.5"), "mg/L", "NORMAL", sampledAt, null),
+                        metric("COD", "COD", new BigDecimal("18.2"), "mg/L", "WARNING", sampledAt, null),
+                        metric("AMMONIA_NITROGEN", "氨氮", new BigDecimal("0.8"), "mg/L", "NORMAL", sampledAt, null),
+                        metric("CONDUCTIVITY", "电导率", new BigDecimal("780"), "μS/cm", "NORMAL", sampledAt, null),
+                        metric("PH", "pH", new BigDecimal("7.2"), "", "NORMAL", sampledAt, null),
+                        new CockpitMetricSnapshot(
+                                null,
+                                "厂商私有参数",
+                                new BigDecimal("1"),
+                                "",
+                                "UNKNOWN",
+                                sampledAt,
+                                null)));
+    }
+
+    private CockpitTypeMetricsSnapshot sewageHistoryFixture() {
+        return new CockpitTypeMetricsSnapshot(
+                DEVICE_CODE,
+                "ws_sewage_environment",
+                "污水环境监测设备",
+                Collections.singletonList(new CockpitMetricSnapshot(
+                        "PH",
+                        "pH",
+                        null,
+                        "",
+                        null,
+                        null,
+                        Arrays.asList(
+                                new CockpitMetricSnapshot.PointSnapshot(
+                                        new BigDecimal("7.3"), "NORMAL", "2026-09-02T09:59:00+08:00"),
+                                new CockpitMetricSnapshot.PointSnapshot(
+                                        new BigDecimal("7.2"), "NORMAL", "2026-09-02T09:58:00+08:00")))));
+    }
+
+    private CockpitTypeMetricsSnapshot emptyFixture() {
+        return new CockpitTypeMetricsSnapshot(DEVICE_CODE, "gas_pressure", "燃气压力计", Collections.emptyList());
+    }
+
+    private CockpitMetricSnapshot metric(
+            String metricCode,
+            String metricName,
+            BigDecimal value,
+            String unit,
+            String status,
+            String sampledAt,
+            java.util.List<CockpitMetricSnapshot.PointSnapshot> points) {
+        return new CockpitMetricSnapshot(
+                metricCode,
+                metricName,
+                value,
+                unit,
+                status,
+                sampledAt,
+                points);
+    }
+}

+ 43 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/type/CockpitMetric.java

@@ -0,0 +1,43 @@
+package com.zksy.cockpit.type;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+public class CockpitMetric {
+    private final String metricCode;
+    private final String metricName;
+    private final BigDecimal value;
+    private final String unit;
+    private final String status;
+    private final String sampledAt;
+    private final String freshness;
+    private final List<CockpitMetricPoint> points;
+
+    public CockpitMetric(
+            String metricCode,
+            String metricName,
+            BigDecimal value,
+            String unit,
+            String status,
+            String sampledAt,
+            String freshness,
+            List<CockpitMetricPoint> points) {
+        this.metricCode = metricCode;
+        this.metricName = metricName;
+        this.value = value;
+        this.unit = unit;
+        this.status = status;
+        this.sampledAt = sampledAt;
+        this.freshness = freshness;
+        this.points = points;
+    }
+
+    public String getMetricCode() { return metricCode; }
+    public String getMetricName() { return metricName; }
+    public BigDecimal getValue() { return value; }
+    public String getUnit() { return unit; }
+    public String getStatus() { return status; }
+    public String getSampledAt() { return sampledAt; }
+    public String getFreshness() { return freshness; }
+    public List<CockpitMetricPoint> getPoints() { return points; }
+}

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

@@ -0,0 +1,22 @@
+package com.zksy.cockpit.type;
+
+import java.math.BigDecimal;
+
+public class CockpitMetricPoint {
+    private final BigDecimal value;
+    private final String status;
+    private final String sampledAt;
+    private final String freshness;
+
+    public CockpitMetricPoint(BigDecimal value, String status, String sampledAt, String freshness) {
+        this.value = value;
+        this.status = status;
+        this.sampledAt = sampledAt;
+        this.freshness = freshness;
+    }
+
+    public BigDecimal getValue() { return value; }
+    public String getStatus() { return status; }
+    public String getSampledAt() { return sampledAt; }
+    public String getFreshness() { return freshness; }
+}

+ 55 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/type/CockpitMetricSnapshot.java

@@ -0,0 +1,55 @@
+package com.zksy.cockpit.type;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+public class CockpitMetricSnapshot {
+    private final String metricCode;
+    private final String metricName;
+    private final BigDecimal value;
+    private final String unit;
+    private final String status;
+    private final String sampledAt;
+    private final List<PointSnapshot> points;
+
+    public CockpitMetricSnapshot(
+            String metricCode,
+            String metricName,
+            BigDecimal value,
+            String unit,
+            String status,
+            String sampledAt,
+            List<PointSnapshot> points) {
+        this.metricCode = metricCode;
+        this.metricName = metricName;
+        this.value = value;
+        this.unit = unit;
+        this.status = status;
+        this.sampledAt = sampledAt;
+        this.points = points;
+    }
+
+    public String getMetricCode() { return metricCode; }
+    public String getMetricName() { return metricName; }
+    public BigDecimal getValue() { return value; }
+    public String getUnit() { return unit; }
+    public String getStatus() { return status; }
+    public String getSampledAt() { return sampledAt; }
+    public List<PointSnapshot> getPoints() { return points; }
+
+    public static class PointSnapshot {
+        private final BigDecimal value;
+        private final String status;
+        private final String sampledAt;
+
+        public PointSnapshot(BigDecimal value, String status, String sampledAt) {
+            this.value = value;
+            this.status = status;
+            this.sampledAt = sampledAt;
+        }
+
+        public BigDecimal getValue() { return value; }
+        public String getStatus() { return status; }
+        public String getSampledAt() { return sampledAt; }
+    }
+}

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

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

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

@@ -0,0 +1,70 @@
+package com.zksy.cockpit.type;
+
+import java.util.List;
+
+public class CockpitTypeMetricsResponse {
+    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<CockpitMetric> metrics;
+    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 CockpitTypeMetricsResponse(
+            String requestId,
+            String apiVersion,
+            boolean readOnly,
+            String deviceCode,
+            String deviceType,
+            String deviceTypeName,
+            String state,
+            String stateReason,
+            List<CockpitMetric> metrics,
+            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.metrics = metrics;
+        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<CockpitMetric> getMetrics() { return metrics; }
+    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; }
+}

+ 245 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/type/CockpitTypeMetricsService.java

@@ -0,0 +1,245 @@
+package com.zksy.cockpit.type;
+
+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.Instant;
+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.Comparator;
+import java.util.List;
+
+@Service
+public class CockpitTypeMetricsService {
+    private static final int STALE_AFTER_SECONDS = 60;
+    private static final int MAX_PAGE_SIZE = 1000;
+
+    private final CockpitTypeMetricsAdapter adapter;
+    private final Clock clock;
+
+    public CockpitTypeMetricsService(CockpitTypeMetricsAdapter adapter, Clock clock) {
+        this.adapter = adapter;
+        this.clock = clock;
+    }
+
+    public CockpitTypeMetricsResponse loadCurrent(String requestId, String deviceCode) {
+        if (isBlank(deviceCode)) {
+            throw new CockpitInvalidRequestException("device code is required");
+        }
+
+        CockpitTypeMetricsSnapshot snapshot = adapter.loadCurrent(deviceCode.trim());
+        if (snapshot == null) {
+            throw new CockpitDeviceNotFoundException("device not found");
+        }
+
+        List<CockpitMetric> metrics = new ArrayList<>();
+        if (snapshot.getMetrics() != null) {
+            for (CockpitMetricSnapshot source : snapshot.getMetrics()) {
+                if (source == null) {
+                    continue;
+                }
+                String freshness = currentFreshness(source.getSampledAt());
+                metrics.add(new CockpitMetric(
+                        normalizedMetricCode(source.getMetricCode()),
+                        normalizedMetricName(source.getMetricName()),
+                        source.getValue(),
+                        normalizedUnit(source.getUnit()),
+                        source.getStatus(),
+                        source.getSampledAt(),
+                        freshness,
+                        null));
+            }
+        }
+
+        String state = stateForCurrent(metrics);
+        return new CockpitTypeMetricsResponse(
+                requestId,
+                "v1",
+                true,
+                snapshot.getDeviceCode(),
+                snapshot.getDeviceType(),
+                snapshot.getDeviceTypeName(),
+                state,
+                stateReason(state),
+                metrics,
+                null,
+                null,
+                "Asia/Shanghai",
+                1,
+                0,
+                0);
+    }
+
+    public CockpitTypeMetricsResponse 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");
+        }
+
+        CockpitTypeMetricsSnapshot snapshot = adapter.loadHistory(
+                deviceCode.trim(),
+                start,
+                end,
+                resolvedPage,
+                resolvedPageSize);
+        if (snapshot == null) {
+            throw new CockpitDeviceNotFoundException("device not found");
+        }
+
+        List<CockpitMetric> metrics = new ArrayList<>();
+        int total = 0;
+        if (snapshot.getMetrics() != null) {
+            for (CockpitMetricSnapshot source : snapshot.getMetrics()) {
+                if (source == null) {
+                    continue;
+                }
+
+                List<CockpitMetricPoint> points = new ArrayList<>();
+                if (source.getPoints() != null) {
+                    for (CockpitMetricSnapshot.PointSnapshot sourcePoint : source.getPoints()) {
+                        if (sourcePoint == null) {
+                            continue;
+                        }
+                        points.add(new CockpitMetricPoint(
+                                sourcePoint.getValue(),
+                                sourcePoint.getStatus(),
+                                sourcePoint.getSampledAt(),
+                                "HISTORICAL"));
+                    }
+                    points.sort(Comparator.comparing(CockpitMetricPoint::getSampledAt));
+                }
+                total += points.size();
+                metrics.add(new CockpitMetric(
+                        normalizedMetricCode(source.getMetricCode()),
+                        normalizedMetricName(source.getMetricName()),
+                        source.getValue(),
+                        normalizedUnit(source.getUnit()),
+                        source.getStatus(),
+                        source.getSampledAt(),
+                        "HISTORICAL",
+                        points));
+            }
+        }
+
+        String state = metrics.isEmpty() || total == 0 ? "EMPTY" : "READY";
+        return new CockpitTypeMetricsResponse(
+                requestId,
+                "v1",
+                true,
+                snapshot.getDeviceCode(),
+                snapshot.getDeviceType(),
+                snapshot.getDeviceTypeName(),
+                state,
+                stateReason(state),
+                metrics,
+                startTime,
+                endTime,
+                "Asia/Shanghai",
+                resolvedPage,
+                resolvedPageSize,
+                total);
+    }
+
+    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 String currentFreshness(String sampledAt) {
+        try {
+            OffsetDateTime sampleTime = OffsetDateTime.parse(sampledAt);
+            Instant staleAt = clock.instant().minusSeconds(STALE_AFTER_SECONDS);
+            return sampleTime.toInstant().isBefore(staleAt) ? "STALE" : "CURRENT";
+        } catch (DateTimeParseException | NullPointerException exception) {
+            throw new CockpitReadException("invalid metric sampledAt: " + exception.getMessage());
+        }
+    }
+
+    private String stateForCurrent(List<CockpitMetric> metrics) {
+        if (metrics.isEmpty()) {
+            return "EMPTY";
+        }
+        boolean stale = metrics.stream().anyMatch(metric -> "STALE".equals(metric.getFreshness()));
+        return stale ? "STALE" : "CURRENT";
+    }
+
+    private String stateReason(String state) {
+        if ("EMPTY".equals(state)) {
+            return "NO_METRICS";
+        }
+        if ("STALE".equals(state)) {
+            return "SAMPLE_OUTDATED";
+        }
+        if ("CURRENT".equals(state)) {
+            return "READY";
+        }
+        return "READY";
+    }
+
+    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 String normalizedMetricCode(String value) {
+        return isBlank(value) ? "UNKNOWN" : value.trim();
+    }
+
+    private String normalizedMetricName(String value) {
+        return isBlank(value) ? "未知指标" : value.trim();
+    }
+
+    private String normalizedUnit(String value) {
+        return value == null ? "" : value;
+    }
+
+    private static boolean isBlank(String value) {
+        return value == null || value.trim().isEmpty();
+    }
+}

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

@@ -0,0 +1,26 @@
+package com.zksy.cockpit.type;
+
+import java.util.List;
+
+public class CockpitTypeMetricsSnapshot {
+    private final String deviceCode;
+    private final String deviceType;
+    private final String deviceTypeName;
+    private final List<CockpitMetricSnapshot> metrics;
+
+    public CockpitTypeMetricsSnapshot(
+            String deviceCode,
+            String deviceType,
+            String deviceTypeName,
+            List<CockpitMetricSnapshot> metrics) {
+        this.deviceCode = deviceCode;
+        this.deviceType = deviceType;
+        this.deviceTypeName = deviceTypeName;
+        this.metrics = metrics;
+    }
+
+    public String getDeviceCode() { return deviceCode; }
+    public String getDeviceType() { return deviceType; }
+    public String getDeviceTypeName() { return deviceTypeName; }
+    public List<CockpitMetricSnapshot> getMetrics() { return metrics; }
+}

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

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