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

feat(cockpit): add infrastructure operating information (#33)

Kazerin 1 месяц назад
Родитель
Сommit
fcfe0378b2

+ 329 - 0
docs/cockpit/infrastructure-operating-information-demo.html

@@ -0,0 +1,329 @@
+<!doctype html>
+<html lang="zh-CN">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <title>基础设施 / 运行信息</title>
+  <style>
+    :root { color-scheme:light; --ink:#172033; --muted:#667085; --line:#d9dee7; --panel:#fff; --page:#f3f5f8;
+            --accent:#1570ef; --ok:#067647; --watch:#b54708; --danger:#b42318; --idle:#98a2b3; }
+    * { box-sizing:border-box; }
+    body { margin:0; min-height:100vh; background:var(--page); color:var(--ink);
+           font-family:"Microsoft YaHei",Arial,sans-serif; font-size:13px; }
+    header { display:flex; flex-wrap:wrap; gap:12px; align-items:center; justify-content:space-between;
+             padding:14px 20px; background:var(--panel); border-bottom:1px solid var(--line); }
+    h1 { margin:0; font-size:18px; letter-spacing:0; }
+    .meta { display:flex; flex-wrap:wrap; gap:14px; color:var(--muted); }
+    .meta b { color:var(--ink); font-weight:600; }
+    button { height:30px; min-width:76px; padding:0 12px; border:1px solid #d0d5dd; border-radius:4px;
+             background:var(--panel); color:var(--ink); cursor:pointer; font-size:13px; }
+    button:hover { border-color:var(--accent); color:var(--accent); }
+    main { display:grid; grid-template-columns:minmax(340px,420px) minmax(0,1fr); gap:0;
+           min-height:calc(100vh - 59px); }
+    aside { padding:16px 18px; background:var(--panel); border-right:1px solid var(--line); overflow:auto; }
+    section { display:grid; grid-template-rows:auto minmax(0,1fr) auto; min-height:0; }
+    h2 { margin:0 0 10px; font-size:14px; font-weight:600; }
+    h2 + .seg { margin-bottom:16px; }
+    h2.mid { margin-top:20px; }
+    .seg { display:flex; gap:6px; }
+    .seg button.active { border-color:var(--accent); background:#eff6ff; color:var(--accent); font-weight:600; }
+    .kpis { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:10px; }
+    .kpi { border:1px solid var(--line); border-radius:6px; padding:10px 12px; }
+    .kpi span { display:block; color:var(--muted); font-size:12px; }
+    .kpi b { display:block; margin-top:4px; font-size:20px; font-weight:600; }
+    .kpi em { font-style:normal; font-size:12px; color:var(--muted); }
+    table { width:100%; border-collapse:collapse; }
+    th { text-align:left; font-weight:600; font-size:12px; color:var(--muted);
+         padding:6px 8px; border-bottom:1px solid var(--line); }
+    td { padding:8px; border-bottom:1px solid #eef1f5; vertical-align:middle; }
+    tr.metric { cursor:pointer; }
+    tr.metric:hover td { background:#f8fafc; }
+    tr.metric.sel td { background:#eff6ff; }
+    .name { font-weight:600; }
+    .code { display:block; color:var(--muted); font-size:11px; font-weight:400; }
+    .badge { display:inline-block; padding:1px 7px; border-radius:10px; font-size:11px; line-height:18px;
+             border:1px solid currentColor; }
+    .b-NORMAL, .b-CURRENT { color:var(--ok); }
+    .b-WATCH, .b-STALE { color:var(--watch); }
+    .b-OVERLOAD, .b-EXPIRED { color:var(--danger); }
+    .b-UNKNOWN, .b-EMPTY { color:var(--idle); }
+    .trend { display:flex; align-items:flex-end; gap:2px; height:22px; }
+    .trend i { width:7px; background:#c7d7ea; display:block; }
+    .trend i.hi { background:var(--accent); }
+    .toolbar { display:flex; align-items:center; justify-content:space-between; gap:12px;
+               padding:12px 18px; background:var(--panel); border-bottom:1px solid var(--line); }
+    .map { position:relative; background:#eef2f6;
+           background-image:linear-gradient(#dde3ea 1px,transparent 1px),linear-gradient(90deg,#dde3ea 1px,transparent 1px);
+           background-size:64px 64px; }
+    .point { position:absolute; width:12px; height:12px; margin:-6px 0 0 -6px; border-radius:50%;
+             background:#9fb3c8; border:2px solid #fff; box-shadow:0 1px 3px rgba(16,24,40,.25); }
+    .point.flagged { background:var(--danger); }
+    .point.linked { background:var(--accent); outline:3px solid rgba(21,112,239,.28); }
+    .plabel { position:absolute; padding:1px 5px; border-radius:3px; background:rgba(255,255,255,.93);
+              border:1px solid var(--line); font-size:11px; white-space:nowrap; color:#344054; }
+    .caption { padding:10px 18px; background:var(--panel); border-top:1px solid var(--line); color:var(--muted); }
+    .caption b { color:var(--ink); }
+    .legend { position:absolute; left:14px; bottom:14px; padding:6px 10px; border-radius:4px;
+              background:rgba(255,255,255,.92); border:1px solid var(--line); color:var(--muted); font-size:12px; }
+    @media (max-width:920px) { main { grid-template-columns:1fr; } .map { min-height:380px; } }
+  </style>
+</head>
+<body>
+<header>
+  <h1>基础设施 / 运行信息</h1>
+  <div class="meta">
+    <span>范围 <b id="rangeText">--</b></span>
+    <span>时区 <b id="timezone">Asia/Shanghai</b></span>
+    <span>新鲜度 <b id="freshness">--</b></span>
+    <span>更新 <b id="updatedAt">--</b></span>
+  </div>
+  <button id="refresh" type="button">手动刷新</button>
+</header>
+
+<main>
+  <aside>
+    <h2>行业</h2>
+    <div class="seg" id="industries"></div>
+    <h2>运行能力</h2>
+    <div class="kpis" id="kpis"></div>
+    <h2 class="mid">行业运行指标</h2>
+    <table>
+      <thead>
+        <tr><th>指标</th><th>当前值</th><th>负荷</th><th>新鲜度</th><th>趋势</th></tr>
+      </thead>
+      <tbody id="metrics"></tbody>
+    </table>
+  </aside>
+
+  <section>
+    <div class="toolbar">
+      <strong id="mapTitle">中央一张图</strong>
+      <span class="meta">选中异常指标可联动定位</span>
+    </div>
+    <div class="map" id="map">
+      <div class="legend">灰点=常规点位 · 红点=异常指标关联点位 · 蓝点=当前选中指标关联点位</div>
+    </div>
+    <div class="caption" id="caption">未选择指标</div>
+  </section>
+</main>
+
+<script>
+  const fixture = {
+    updatedAt: "2026-09-02T09:59:50+08:00",
+    range: { type: "LAST_24_HOURS", start: "2026-09-01T10:00:00+08:00", end: "2026-09-02T10:00:00+08:00" },
+    industries: [
+      {
+        industry: { code: "GAS", key: "gas", label: "燃气" },
+        metrics: [
+          { metricCode: "gas_supply_pressure", metricName: "燃气供应压力", category: "SUPPLY", unit: "MPa",
+            value: "0.32", capacityValue: "0.40", loadRatio: "0.8000", loadState: "WATCH",
+            freshness: { status: "CURRENT", reason: "SAMPLE_CURRENT", ageSeconds: 30 },
+            trend: [{ bucket: "2026-08-31", value: "0.29" }, { bucket: "2026-09-01", value: "0.30" }, { bucket: "2026-09-02", value: "0.32" }],
+            abnormal: false, pointCodes: ["PT-GAS-001"] },
+          { metricCode: "gas_flow_rate", metricName: "燃气流量", category: "FLOW", unit: "m3/h",
+            value: "1200.5", capacityValue: "2000", loadRatio: "0.6003", loadState: "NORMAL",
+            freshness: { status: "STALE", reason: "ONE_UPDATE_CYCLE_MISSED", ageSeconds: 90 },
+            trend: [{ bucket: "2026-09-01", value: "980" }, { bucket: "2026-09-02", value: "1200.5" }],
+            abnormal: false, pointCodes: ["PT-GAS-002"] },
+          { metricCode: "gas_remaining_capacity", metricName: "燃气剩余承载能力", category: "CAPACITY", unit: "m3",
+            value: "800", capacityValue: "2000", loadRatio: "0.4000", loadState: "NORMAL",
+            freshness: { status: "CURRENT", reason: "SAMPLE_CURRENT", ageSeconds: 12 },
+            trend: [], abnormal: false, pointCodes: [] }
+        ]
+      },
+      {
+        industry: { code: "WATER_SUPPLY", key: "waterSupply", label: "供水" },
+        metrics: [
+          { metricCode: "water_supply_flow", metricName: "供水流量", category: "SUPPLY", unit: "m3/h",
+            value: "5000", capacityValue: "4000", loadRatio: "1.2500", loadState: "OVERLOAD",
+            freshness: { status: "CURRENT", reason: "SAMPLE_CURRENT", ageSeconds: 20 },
+            trend: [{ bucket: "2026-09-01", value: "4200" }, { bucket: "2026-09-02", value: "5000" }],
+            abnormal: true, pointCodes: ["PT-WS-001", "PT-WS-002"] },
+          { metricCode: "water_pressure", metricName: "供水压力", category: "PRESSURE", unit: "MPa",
+            value: "0.45", capacityValue: null, loadRatio: null, loadState: "UNKNOWN",
+            freshness: { status: "EMPTY", reason: "NO_SOURCE", ageSeconds: null },
+            trend: [], abnormal: false, pointCodes: ["PT-WS-003"] },
+          { metricCode: "water_quality_turbidity", metricName: "供水浊度", category: "QUALITY", unit: "NTU",
+            value: "0.62", capacityValue: "1.00", loadRatio: "0.6200", loadState: "NORMAL",
+            freshness: { status: "CURRENT", reason: "SAMPLE_CURRENT", ageSeconds: 45 },
+            trend: [{ bucket: "2026-09-01", value: "0.58" }, { bucket: "2026-09-02", value: "0.62" }],
+            abnormal: false, pointCodes: ["PT-WS-004"] }
+        ]
+      },
+      {
+        industry: { code: "DRAINAGE", key: "drainage", label: "排水" },
+        metrics: [
+          { metricCode: "sewage_cod", metricName: "污水 COD", category: "QUALITY", unit: "mg/L",
+            value: "18.2", capacityValue: "50", loadRatio: "0.3640", loadState: "NORMAL",
+            freshness: { status: "EXPIRED", reason: "TWO_UPDATE_CYCLES_MISSED", ageSeconds: 300 },
+            trend: [{ bucket: "2026-09-01", value: "16.4" }, { bucket: "2026-09-02", value: "18.2" }],
+            abnormal: true, pointCodes: ["PT-DR-001"] },
+          { metricCode: "rainwater_flow", metricName: "雨水流量", category: "FLOW", unit: "m3/h",
+            value: "880", capacityValue: "1000", loadRatio: "0.8800", loadState: "WATCH",
+            freshness: { status: "CURRENT", reason: "SAMPLE_CURRENT", ageSeconds: 18 },
+            trend: [{ bucket: "2026-09-01", value: "610" }, { bucket: "2026-09-02", value: "880" }],
+            abnormal: false, pointCodes: ["PT-DR-002"] },
+          { metricCode: "sewage_pump_capacity", metricName: "污水泵站承载能力", category: "CAPACITY", unit: "m3/h",
+            value: "3200", capacityValue: "3000", loadRatio: "1.0667", loadState: "OVERLOAD",
+            freshness: { status: "CURRENT", reason: "SAMPLE_CURRENT", ageSeconds: 25 },
+            trend: [], abnormal: true, pointCodes: ["PT-DR-003"] }
+        ]
+      }
+    ]
+  };
+
+  const points = {
+    "PT-GAS-001": { x: 24, y: 30, name: "燃气调压站" },
+    "PT-GAS-002": { x: 37, y: 46, name: "燃气流量点" },
+    "PT-WS-001": { x: 55, y: 28, name: "供水主干管 A" },
+    "PT-WS-002": { x: 66, y: 44, name: "供水主干管 B" },
+    "PT-WS-003": { x: 48, y: 58, name: "供水压力点" },
+    "PT-WS-004": { x: 74, y: 62, name: "供水水质点" },
+    "PT-DR-001": { x: 30, y: 68, name: "污水监测点" },
+    "PT-DR-002": { x: 82, y: 34, name: "雨水流量点" },
+    "PT-DR-003": { x: 62, y: 74, name: "污水泵站" }
+  };
+
+  let state = { industry: "WATER_SUPPLY", metricCode: null, lastRefresh: null };
+
+  function currentIndustry() {
+    return fixture.industries.find(item => item.industry.code === state.industry) || fixture.industries[0];
+  }
+
+  function selectedMetric() {
+    return currentIndustry().metrics.find(m => m.metricCode === state.metricCode) || null;
+  }
+
+  function allFlaggedPointCodes() {
+    const codes = new Set();
+    fixture.industries.forEach(block => block.metrics
+      .filter(m => m.abnormal)
+      .forEach(m => m.pointCodes.forEach(code => codes.add(code))));
+    return codes;
+  }
+
+  function renderIndustries() {
+    document.getElementById("industries").innerHTML = fixture.industries.map(block =>
+      `<button type="button" data-code="${block.industry.code}" class="${block.industry.code === state.industry ? "active" : ""}">${block.industry.label}</button>`
+    ).join("");
+  }
+
+  function renderHeader() {
+    document.getElementById("rangeText").textContent = fixture.range.type;
+    document.getElementById("timezone").textContent = "Asia/Shanghai";
+    const expired = currentIndustry().metrics.some(m => m.freshness.status === "EXPIRED");
+    const stale = currentIndustry().metrics.some(m => m.freshness.status === "STALE");
+    document.getElementById("freshness").textContent = expired ? "EXPIRED" : stale ? "STALE" : "CURRENT";
+    document.getElementById("updatedAt").textContent = state.lastRefresh || fixture.updatedAt;
+  }
+
+  function renderKpis() {
+    const block = currentIndustry();
+    const loaded = block.metrics.filter(m => m.value !== null);
+    const overload = block.metrics.filter(m => m.loadState === "OVERLOAD").length;
+    const abnormal = block.metrics.filter(m => m.abnormal).length;
+    const ratios = block.metrics.filter(m => m.loadRatio !== null).map(m => Number(m.loadRatio));
+    const peak = ratios.length ? Math.max.apply(null, ratios).toFixed(4) : "--";
+    const supply = block.metrics.filter(m => m.category === "SUPPLY" || m.category === "FLOW");
+    const supplyValue = supply.length ? supply[0].value + " " + supply[0].unit : "--";
+    document.getElementById("kpis").innerHTML = `
+      <div class="kpi"><span>供应 / 输送</span><b>${supplyValue}</b><em>${supply.length} 项指标</em></div>
+      <div class="kpi"><span>最高负荷率</span><b>${peak}</b><em>${overload} 项超载</em></div>
+      <div class="kpi"><span>可用指标</span><b>${loaded.length}/${block.metrics.length}</b><em>缺失显式为空</em></div>
+      <div class="kpi"><span>异常指标</span><b>${abnormal}</b><em>可联动定位</em></div>`;
+  }
+
+  function trendBars(points) {
+    if (!points.length) return '<span class="code">无趋势</span>';
+    const values = points.map(p => Number(p.value));
+    const max = Math.max.apply(null, values) || 1;
+    return '<div class="trend">' + points.map((p, i) =>
+      `<i class="${i === points.length - 1 ? "hi" : ""}" style="height:${Math.max(3, Number(p.value) / max * 22)}px" title="${p.bucket}: ${p.value}"></i>`
+    ).join("") + "</div>";
+  }
+
+  function renderMetrics() {
+    const block = currentIndustry();
+    if (!block.metrics.length) {
+      document.getElementById("metrics").innerHTML =
+        '<tr><td colspan="5">该行业无可用运行指标(EMPTY / NO_METRICS)</td></tr>';
+      return;
+    }
+    document.getElementById("metrics").innerHTML = block.metrics.map(metric => {
+      const ratio = metric.loadRatio === null ? "--" : metric.loadRatio;
+      const selected = metric.metricCode === state.metricCode ? " sel" : "";
+      return `<tr class="metric${selected}" data-code="${metric.metricCode}">
+        <td><span class="name">${metric.metricName}</span><span class="code">${metric.metricCode} · ${metric.category} · ${metric.unit}</span></td>
+        <td>${metric.value === null ? "--" : metric.value}</td>
+        <td>${ratio}<br><span class="badge b-${metric.loadState}">${metric.loadState}</span></td>
+        <td><span class="badge b-${metric.freshness.status}">${metric.freshness.status}</span></td>
+        <td>${trendBars(metric.trend)}</td>
+      </tr>`;
+    }).join("");
+  }
+
+  function renderMap() {
+    const flagged = allFlaggedPointCodes();
+    const chosen = selectedMetric();
+    const linked = new Set(chosen ? chosen.pointCodes : []);
+    const map = document.getElementById("map");
+    map.querySelectorAll(".point,.plabel").forEach(node => node.remove());
+    Object.keys(points).forEach(code => {
+      const point = points[code];
+      const node = document.createElement("div");
+      node.className = "point" + (linked.has(code) ? " linked" : flagged.has(code) ? " flagged" : "");
+      node.style.left = point.x + "%";
+      node.style.top = point.y + "%";
+      node.title = code + " · " + point.name;
+      map.appendChild(node);
+      const label = document.createElement("div");
+      label.className = "plabel";
+      label.style.left = "calc(" + point.x + "% + 10px)";
+      label.style.top = "calc(" + point.y + "% - 9px)";
+      label.textContent = code;
+      map.appendChild(label);
+    });
+    document.getElementById("mapTitle").textContent = "中央一张图 · " + currentIndustry().industry.label;
+    const caption = document.getElementById("caption");
+    if (!chosen) {
+      caption.innerHTML = "未选择指标,红色点位为当前全部异常指标关联点位";
+    } else {
+      caption.innerHTML = "已选 <b>" + chosen.metricName + "</b>(" + chosen.metricCode + ") · 负荷状态 <b>"
+        + chosen.loadState + "</b> · 新鲜度 <b>" + chosen.freshness.status + "</b> · 关联点位 <b>"
+        + (chosen.pointCodes.length ? chosen.pointCodes.join("、") : "无") + "</b>";
+    }
+  }
+
+  function render() {
+    renderIndustries();
+    renderHeader();
+    renderKpis();
+    renderMetrics();
+    renderMap();
+  }
+
+  document.getElementById("industries").addEventListener("click", event => {
+    const button = event.target.closest("button[data-code]");
+    if (!button) return;
+    state.industry = button.dataset.code;
+    state.metricCode = null;
+    render();
+  });
+
+  document.getElementById("metrics").addEventListener("click", event => {
+    const row = event.target.closest("tr.metric");
+    if (!row) return;
+    state.metricCode = state.metricCode === row.dataset.code ? null : row.dataset.code;
+    render();
+  });
+
+  document.getElementById("refresh").addEventListener("click", () => {
+    state.lastRefresh = new Date().toISOString();
+    state.metricCode = null;
+    render();
+  });
+
+  render();
+</script>
+</body>
+</html>

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

@@ -0,0 +1,72 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.cockpit.foundation.CockpitErrorPayload;
+import com.zksy.cockpit.foundation.CockpitInvalidRequestException;
+import com.zksy.cockpit.foundation.CockpitReadException;
+import com.zksy.cockpit.infrastructure.CockpitInfrastructureOperatingInformationService;
+import com.zksy.common.core.domain.AjaxResult;
+import com.zksy.common.utils.StringUtils;
+import com.zksy.common.utils.uuid.IdUtils;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.servlet.http.HttpServletRequest;
+
+@RestController
+public class CockpitInfrastructureOperatingInformationController {
+    private static final String REQUEST_ID_HEADER = "X-Request-Id";
+
+    private final CockpitInfrastructureOperatingInformationService operatingInformationService;
+
+    public CockpitInfrastructureOperatingInformationController(
+            CockpitInfrastructureOperatingInformationService operatingInformationService) {
+        this.operatingInformationService = operatingInformationService;
+    }
+
+    @GetMapping("/api/cockpit/v1/infrastructure/operating-information")
+    @PreAuthorize("isAuthenticated()")
+    public ResponseEntity<AjaxResult> operatingInformation(
+            @RequestParam(value = "industry", required = false) String industry,
+            @RequestParam(value = "range", required = false) String range,
+            @RequestParam(value = "from", required = false) String from,
+            @RequestParam(value = "to", required = false) String to,
+            @RequestHeader(value = REQUEST_ID_HEADER, required = false) String requestId) {
+        return ResponseEntity.ok(AjaxResult.success(
+                operatingInformationService.load(
+                        resolveRequestId(requestId),
+                        industry,
+                        range,
+                        from,
+                        to)));
+    }
+
+    @ExceptionHandler(CockpitInvalidRequestException.class)
+    public ResponseEntity<AjaxResult> handleInvalidRequest(
+            HttpServletRequest request,
+            CockpitInvalidRequestException exception) {
+        return error(HttpStatus.BAD_REQUEST, operatingInformationService.invalidRequest(
+                resolveRequestId(request.getHeader(REQUEST_ID_HEADER)),
+                exception.getMessage()));
+    }
+
+    @ExceptionHandler(CockpitReadException.class)
+    public ResponseEntity<AjaxResult> handleReadFailure(HttpServletRequest request) {
+        return error(HttpStatus.INTERNAL_SERVER_ERROR, operatingInformationService.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();
+    }
+}

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

@@ -0,0 +1,355 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.cockpit.foundation.CockpitReadException;
+import com.zksy.cockpit.infrastructure.CockpitInfrastructureOperatingInformationAdapter;
+import com.zksy.cockpit.infrastructure.CockpitInfrastructureOperatingInformationService;
+import com.zksy.cockpit.infrastructure.CockpitInfrastructureOperatingInformationSnapshot;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PatchMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+
+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.hamcrest.Matchers.hasSize;
+import static org.hamcrest.Matchers.nullValue;
+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 CockpitInfrastructureOperatingInformationControllerTest {
+
+    private static final String ENDPOINT = "/api/cockpit/v1/infrastructure/operating-information";
+
+    private CockpitInfrastructureOperatingInformationAdapter adapter;
+    private MockMvc mockMvc;
+
+    @BeforeEach
+    void setUp() {
+        adapter = mock(CockpitInfrastructureOperatingInformationAdapter.class);
+        CockpitInfrastructureOperatingInformationService service =
+                new CockpitInfrastructureOperatingInformationService(
+                        adapter,
+                        Clock.fixed(Instant.parse("2026-09-02T02:00:00Z"), ZoneOffset.UTC),
+                        60);
+        mockMvc = MockMvcBuilders
+                .standaloneSetup(new CockpitInfrastructureOperatingInformationController(service))
+                .build();
+    }
+
+    @Test
+    void operatingInformationExposesIndustryMetricsWithUnitsAndLoadState() throws Exception {
+        when(adapter.load()).thenReturn(fixture());
+
+        mockMvc.perform(get(ENDPOINT).header("X-Request-Id", "operating-information-001"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.msg").value("操作成功"))
+                .andExpect(jsonPath("$.data.requestId").value("operating-information-001"))
+                .andExpect(jsonPath("$.data.apiVersion").value("v1"))
+                .andExpect(jsonPath("$.data.readOnly").value(true))
+                .andExpect(jsonPath("$.data.displayName").value("运行信息"))
+                .andExpect(jsonPath("$.data.timezone").value("Asia/Shanghai"))
+                .andExpect(jsonPath("$.data.range.type").value("LAST_24_HOURS"))
+                .andExpect(jsonPath("$.data.range.start").value("2026-09-01T10:00:00+08:00"))
+                .andExpect(jsonPath("$.data.range.end").value("2026-09-02T10:00:00+08:00"))
+                .andExpect(jsonPath("$.data.selectedIndustry").value(nullValue()))
+                .andExpect(jsonPath("$.data.updatedAt").value("2026-09-02T09:59:50+08:00"))
+                .andExpect(jsonPath("$.data.freshness.status").value("CURRENT"))
+                .andExpect(jsonPath("$.data.industries", hasSize(3)))
+                .andExpect(jsonPath("$.data.industries[0].industry.code").value("GAS"))
+                .andExpect(jsonPath("$.data.industries[0].industry.key").value("gas"))
+                .andExpect(jsonPath("$.data.industries[0].industry.label").value("燃气"))
+                .andExpect(jsonPath("$.data.industries[0].state").value("READY"))
+                .andExpect(jsonPath("$.data.industries[0].metrics", hasSize(2)))
+                .andExpect(jsonPath("$.data.industries[0].metrics[0].metricCode").value("gas_supply_pressure"))
+                .andExpect(jsonPath("$.data.industries[0].metrics[0].category").value("SUPPLY"))
+                .andExpect(jsonPath("$.data.industries[0].metrics[0].unit").value("MPa"))
+                .andExpect(jsonPath("$.data.industries[0].metrics[0].value").value("0.32"))
+                .andExpect(jsonPath("$.data.industries[0].metrics[0].capacityValue").value("0.40"))
+                .andExpect(jsonPath("$.data.industries[0].metrics[0].loadRatio").value("0.8000"))
+                .andExpect(jsonPath("$.data.industries[0].metrics[0].loadState").value("WATCH"))
+                .andExpect(jsonPath("$.data.industries[0].metrics[0].freshness.status").value("CURRENT"))
+                .andExpect(jsonPath("$.data.industries[0].metrics[0].freshness.ageSeconds").value(30))
+                .andExpect(jsonPath("$.data.industries[0].metrics[0].trend", hasSize(2)))
+                .andExpect(jsonPath("$.data.industries[0].metrics[0].trend[0].bucket").value("2026-09-01"))
+                .andExpect(jsonPath("$.data.industries[0].metrics[0].trend[0].value").value("0.30"))
+                .andExpect(jsonPath("$.data.industries[0].metrics[1].metricCode").value("gas_flow_rate"))
+                .andExpect(jsonPath("$.data.industries[0].metrics[1].category").value("FLOW"))
+                .andExpect(jsonPath("$.data.industries[0].metrics[1].freshness.status").value("STALE"))
+                .andExpect(jsonPath("$.data.industries[0].metrics[1].freshness.ageSeconds").value(90))
+                .andExpect(jsonPath("$.data.industries[0].metrics[1].loadRatio").value("0.6003"))
+                .andExpect(jsonPath("$.data.industries[0].metrics[1].loadState").value("NORMAL"))
+                .andExpect(jsonPath("$.data.industries[0].metrics[1].abnormal").value(false))
+                .andExpect(jsonPath("$.data.industries[0].abnormalMetricCount").value(0))
+                .andExpect(jsonPath("$.data.excluded.unknownIndustryCount").value(1))
+                .andExpect(jsonPath("$.data.excluded.nonFormalIndustryCount").value(1));
+    }
+
+    @Test
+    void overloadedAndExpiredMetricsAreAbnormalAndCarryMapPoints() throws Exception {
+        when(adapter.load()).thenReturn(fixture());
+
+        mockMvc.perform(get(ENDPOINT))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.industries[1].industry.code").value("WATER_SUPPLY"))
+                .andExpect(jsonPath("$.data.industries[1].industry.key").value("waterSupply"))
+                .andExpect(jsonPath("$.data.industries[1].metrics[0].metricCode").value("water_supply_flow"))
+                .andExpect(jsonPath("$.data.industries[1].metrics[0].loadRatio").value("1.2500"))
+                .andExpect(jsonPath("$.data.industries[1].metrics[0].loadState").value("OVERLOAD"))
+                .andExpect(jsonPath("$.data.industries[1].metrics[0].abnormal").value(true))
+                .andExpect(jsonPath("$.data.industries[1].abnormalMetricCount").value(1))
+                .andExpect(jsonPath("$.data.industries[1].mapFilter.metricCodes", hasSize(1)))
+                .andExpect(jsonPath("$.data.industries[1].mapFilter.metricCodes[0]").value("water_supply_flow"))
+                .andExpect(jsonPath("$.data.industries[1].mapFilter.pointCodes", hasSize(2)))
+                .andExpect(jsonPath("$.data.industries[1].mapFilter.pointCodes[0]").value("PT-WS-001"))
+                .andExpect(jsonPath("$.data.industries[1].mapFilter.pointCodes[1]").value("PT-WS-002"))
+                .andExpect(jsonPath("$.data.industries[2].industry.code").value("DRAINAGE"))
+                .andExpect(jsonPath("$.data.industries[2].metrics[0].category").value("QUALITY"))
+                .andExpect(jsonPath("$.data.industries[2].metrics[0].unit").value("mg/L"))
+                .andExpect(jsonPath("$.data.industries[2].metrics[0].loadState").value("NORMAL"))
+                .andExpect(jsonPath("$.data.industries[2].metrics[0].freshness.status").value("EXPIRED"))
+                .andExpect(jsonPath("$.data.industries[2].metrics[0].freshness.reason").value("TWO_UPDATE_CYCLES_MISSED"))
+                .andExpect(jsonPath("$.data.industries[2].metrics[0].freshness.ageSeconds").value(300))
+                .andExpect(jsonPath("$.data.industries[2].metrics[0].abnormal").value(true))
+                .andExpect(jsonPath("$.data.industries[2].abnormalMetricCount").value(1))
+                .andExpect(jsonPath("$.data.industries[2].mapFilter.pointCodes[0]").value("PT-DR-001"));
+    }
+
+    @Test
+    void missingSampleIsExplicitlyUnknownInsteadOfZero() throws Exception {
+        when(adapter.load()).thenReturn(fixture());
+
+        mockMvc.perform(get(ENDPOINT))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.industries[1].metrics[1].metricCode").value("water_pressure"))
+                .andExpect(jsonPath("$.data.industries[1].metrics[1].sampledAt").value(nullValue()))
+                .andExpect(jsonPath("$.data.industries[1].metrics[1].value").value("0.45"))
+                .andExpect(jsonPath("$.data.industries[1].metrics[1].capacityValue").value(nullValue()))
+                .andExpect(jsonPath("$.data.industries[1].metrics[1].loadRatio").value(nullValue()))
+                .andExpect(jsonPath("$.data.industries[1].metrics[1].loadState").value("UNKNOWN"))
+                .andExpect(jsonPath("$.data.industries[1].metrics[1].freshness.status").value("EMPTY"))
+                .andExpect(jsonPath("$.data.industries[1].metrics[1].freshness.reason").value("NO_SOURCE"))
+                .andExpect(jsonPath("$.data.industries[1].metrics[1].freshness.ageSeconds").value(nullValue()))
+                .andExpect(jsonPath("$.data.industries[1].metrics[1].abnormal").value(false));
+    }
+
+    @Test
+    void industryFilterReturnsOnlyTheRequestedIndustry() throws Exception {
+        when(adapter.load()).thenReturn(fixture());
+
+        mockMvc.perform(get(ENDPOINT).queryParam("industry", "DRAINAGE"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.selectedIndustry.code").value("DRAINAGE"))
+                .andExpect(jsonPath("$.data.selectedIndustry.key").value("drainage"))
+                .andExpect(jsonPath("$.data.selectedIndustry.label").value("排水"))
+                .andExpect(jsonPath("$.data.industries", hasSize(3)))
+                .andExpect(jsonPath("$.data.industries[0].metrics", hasSize(0)))
+                .andExpect(jsonPath("$.data.industries[0].state").value("EMPTY"))
+                .andExpect(jsonPath("$.data.industries[2].metrics", hasSize(1)));
+    }
+
+    @Test
+    void customRangeOverridesPresetAndReportsCustomType() throws Exception {
+        when(adapter.load()).thenReturn(fixture());
+
+        mockMvc.perform(get(ENDPOINT)
+                        .queryParam("range", "LAST_30_DAYS")
+                        .queryParam("from", "2026-09-01T00:00:00+08:00")
+                        .queryParam("to", "2026-09-02T00:00:00+08:00"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.range.type").value("CUSTOM"))
+                .andExpect(jsonPath("$.data.range.start").value("2026-09-01T00:00:00+08:00"))
+                .andExpect(jsonPath("$.data.range.end").value("2026-09-02T00:00:00+08:00"));
+    }
+
+    @Test
+    void presetRangeIsReportedWithExplicitBusinessBoundaries() throws Exception {
+        when(adapter.load()).thenReturn(fixture());
+
+        mockMvc.perform(get(ENDPOINT).queryParam("range", "LAST_7_DAYS"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.range.type").value("LAST_7_DAYS"))
+                .andExpect(jsonPath("$.data.range.start").value("2026-08-26T10:00:00+08:00"))
+                .andExpect(jsonPath("$.data.range.end").value("2026-09-02T10:00:00+08:00"));
+    }
+
+    @Test
+    void invalidIndustryReturnsTypedBadRequestBeforeReadAdapterIsCalled() throws Exception {
+        mockMvc.perform(get(ENDPOINT).queryParam("industry", "MANHOLE"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.code").value(400))
+                .andExpect(jsonPath("$.data.category").value("INVALID_REQUEST"))
+                .andExpect(jsonPath("$.data.message").value("industry must be GAS, WATER_SUPPLY or DRAINAGE"));
+
+        verifyNoInteractions(adapter);
+    }
+
+    @Test
+    void invalidRangeReturnsTypedBadRequestBeforeReadAdapterIsCalled() throws Exception {
+        mockMvc.perform(get(ENDPOINT).queryParam("range", "LAST_YEAR"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.data.category").value("INVALID_REQUEST"))
+                .andExpect(jsonPath("$.data.message")
+                        .value("range must be LAST_24_HOURS, LAST_7_DAYS or LAST_30_DAYS"));
+
+        verifyNoInteractions(adapter);
+    }
+
+    @Test
+    void partialOrReversedCustomRangeReturnsTypedBadRequest() throws Exception {
+        mockMvc.perform(get(ENDPOINT).queryParam("from", "2026-09-01T00:00:00+08:00"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.data.message").value("from and to must be provided together"));
+
+        mockMvc.perform(get(ENDPOINT).queryParam("from", "not-a-time").queryParam("to", "also-not-a-time"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.data.message").value("invalid from"));
+
+        mockMvc.perform(get(ENDPOINT)
+                        .queryParam("from", "2026-09-02T00:00:00+08:00")
+                        .queryParam("to", "2026-09-01T00:00:00+08:00"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.data.message").value("from must not be after to"));
+
+        verifyNoInteractions(adapter);
+    }
+
+    @Test
+    void industriesWithoutMetricsReturnExplicitEmptyState() throws Exception {
+        when(adapter.load()).thenReturn(emptyFixture());
+
+        mockMvc.perform(get(ENDPOINT))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.industries", hasSize(3)))
+                .andExpect(jsonPath("$.data.industries[0].state").value("EMPTY"))
+                .andExpect(jsonPath("$.data.industries[0].stateReason").value("NO_METRICS"))
+                .andExpect(jsonPath("$.data.industries[0].metrics", hasSize(0)))
+                .andExpect(jsonPath("$.data.industries[0].abnormalMetricCount").value(0))
+                .andExpect(jsonPath("$.data.industries[2].state").value("EMPTY"))
+                .andExpect(jsonPath("$.data.freshness.status").value("EMPTY"))
+                .andExpect(jsonPath("$.data.freshness.reason").value("NO_METRICS"));
+    }
+
+    @Test
+    void mutatingRequestIsRejectedBeforeReadAdapterIsCalled() throws Exception {
+        mockMvc.perform(post(ENDPOINT))
+                .andExpect(status().isMethodNotAllowed());
+
+        verifyNoInteractions(adapter);
+    }
+
+    @Test
+    void serverFailureReturnsTypedServerError() throws Exception {
+        when(adapter.load()).thenThrow(new CockpitReadException("upstream unavailable"));
+
+        mockMvc.perform(get(ENDPOINT).header("X-Request-Id", "operating-information-500"))
+                .andExpect(status().isInternalServerError())
+                .andExpect(jsonPath("$.code").value(500))
+                .andExpect(jsonPath("$.data.category").value("SERVER_ERROR"))
+                .andExpect(jsonPath("$.data.message").value("驾驶舱基础设施运行信息数据暂不可用"))
+                .andExpect(jsonPath("$.data.requestId").value("operating-information-500"));
+    }
+
+    @Test
+    void controllerExposesAuthenticatedReadOnlyMethodsOnly() {
+        for (Method method : CockpitInfrastructureOperatingInformationController.class.getDeclaredMethods()) {
+            if (method.isAnnotationPresent(GetMapping.class)) {
+                assertTrue(!method.isAnnotationPresent(PostMapping.class)
+                        && !method.isAnnotationPresent(PutMapping.class)
+                        && !method.isAnnotationPresent(PatchMapping.class)
+                        && !method.isAnnotationPresent(DeleteMapping.class));
+            }
+        }
+
+        assertTrue(Arrays.stream(CockpitInfrastructureOperatingInformationController.class.getDeclaredMethods())
+                .anyMatch(method -> method.isAnnotationPresent(PreAuthorize.class)
+                        && "isAuthenticated()".equals(
+                                method.getAnnotation(PreAuthorize.class).value())));
+    }
+
+
+    private static CockpitInfrastructureOperatingInformationSnapshot fixture() {
+        return new CockpitInfrastructureOperatingInformationSnapshot(
+                "2026-09-02T09:59:50+08:00",
+                Arrays.asList(
+                        metric("gas_supply_pressure", "燃气供应压力", "SUPPLY", "MPa", "GAS",
+                                "0.32", "0.40", "2026-09-02T09:59:30+08:00", false,
+                                Collections.singletonList("PT-GAS-001"),
+                                Arrays.asList(
+                                        point("2026-09-01", "0.30"),
+                                        point("2026-09-02", "0.32"))),
+                        metric("gas_flow_rate", "燃气流量", "FLOW", "m3/h", "GAS",
+                                "1200.5", "2000", "2026-09-02T09:58:30+08:00", false,
+                                Collections.emptyList(), Collections.emptyList()),
+                        metric("water_supply_flow", "供水流量", "SUPPLY", "m3/h", "WATER_SUPPLY",
+                                "5000", "4000", "2026-09-02T09:59:40+08:00", false,
+                                Arrays.asList("PT-WS-001", "PT-WS-002"), Collections.emptyList()),
+                        metric("water_pressure", "供水压力", "PRESSURE", "MPa", "WATER_SUPPLY",
+                                "0.45", null, null, false,
+                                Collections.emptyList(), Collections.emptyList()),
+                        metric("sewage_cod", "污水COD", "QUALITY", "mg/L", "DRAINAGE",
+                                "18.2", "50", "2026-09-02T09:55:00+08:00", false,
+                                Collections.singletonList("PT-DR-001"), Collections.emptyList()),
+                        metric("manhole_tilt", "井盖倾角", "OTHER", "deg", "MANHOLE",
+                                "1.5", null, "2026-09-02T09:59:00+08:00", false,
+                                Collections.emptyList(), Collections.emptyList()),
+                        metric("orphan_metric", "未归属指标", "OTHER", "", null,
+                                "3", null, "2026-09-02T09:59:00+08:00", false,
+                                Collections.emptyList(), Collections.emptyList())));
+    }
+
+    private static CockpitInfrastructureOperatingInformationSnapshot emptyFixture() {
+        return new CockpitInfrastructureOperatingInformationSnapshot(
+                "2026-09-02T09:59:50+08:00", Collections.emptyList());
+    }
+
+    private static CockpitInfrastructureOperatingInformationSnapshot.SourceMetric metric(
+            String metricCode,
+            String metricName,
+            String category,
+            String unit,
+            String industry,
+            String value,
+            String capacityValue,
+            String sampledAt,
+            boolean abnormal,
+            java.util.List<String> pointCodes,
+            java.util.List<CockpitInfrastructureOperatingInformationSnapshot.SourcePoint> trend) {
+        return new CockpitInfrastructureOperatingInformationSnapshot.SourceMetric(
+                metricCode,
+                metricName,
+                category,
+                unit,
+                industry,
+                value == null ? null : new BigDecimal(value),
+                capacityValue == null ? null : new BigDecimal(capacityValue),
+                sampledAt,
+                abnormal,
+                pointCodes,
+                trend);
+    }
+
+    private static CockpitInfrastructureOperatingInformationSnapshot.SourcePoint point(
+            String bucket, String value) {
+        return new CockpitInfrastructureOperatingInformationSnapshot.SourcePoint(
+                bucket, value == null ? null : new BigDecimal(value));
+    }
+}

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

@@ -0,0 +1,5 @@
+package com.zksy.cockpit.infrastructure;
+
+public interface CockpitInfrastructureOperatingInformationAdapter {
+    CockpitInfrastructureOperatingInformationSnapshot load();
+}

+ 260 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/infrastructure/CockpitInfrastructureOperatingInformationResponse.java

@@ -0,0 +1,260 @@
+package com.zksy.cockpit.infrastructure;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+public class CockpitInfrastructureOperatingInformationResponse {
+    private final String requestId;
+    private final String apiVersion;
+    private final boolean readOnly;
+    private final String displayName;
+    private final String timezone;
+    private final Range range;
+    private final String updatedAt;
+    private final Freshness freshness;
+    private final Industry selectedIndustry;
+    private final List<IndustryOperatingInformation> industries;
+    private final Excluded excluded;
+
+    public CockpitInfrastructureOperatingInformationResponse(
+            String requestId,
+            String apiVersion,
+            boolean readOnly,
+            String displayName,
+            String timezone,
+            Range range,
+            String updatedAt,
+            Freshness freshness,
+            Industry selectedIndustry,
+            List<IndustryOperatingInformation> industries,
+            Excluded excluded) {
+        this.requestId = requestId;
+        this.apiVersion = apiVersion;
+        this.readOnly = readOnly;
+        this.displayName = displayName;
+        this.timezone = timezone;
+        this.range = range;
+        this.updatedAt = updatedAt;
+        this.freshness = freshness;
+        this.selectedIndustry = selectedIndustry;
+        this.industries = industries;
+        this.excluded = excluded;
+    }
+
+    public String getRequestId() { return requestId; }
+    public String getApiVersion() { return apiVersion; }
+    public boolean isReadOnly() { return readOnly; }
+    public String getDisplayName() { return displayName; }
+    public String getTimezone() { return timezone; }
+    public Range getRange() { return range; }
+    public String getUpdatedAt() { return updatedAt; }
+    public Freshness getFreshness() { return freshness; }
+    public Industry getSelectedIndustry() { return selectedIndustry; }
+    public List<IndustryOperatingInformation> getIndustries() { return industries; }
+    public Excluded getExcluded() { return excluded; }
+
+    public static class Range {
+        private final String type;
+        private final String start;
+        private final String end;
+
+        public Range(String type, String start, String end) {
+            this.type = type;
+            this.start = start;
+            this.end = end;
+        }
+
+        public String getType() { return type; }
+        public String getStart() { return start; }
+        public String getEnd() { return end; }
+    }
+
+    public static class Freshness {
+        private final String status;
+        private final String reason;
+        private final String updatedAt;
+        private final String serverUpdatedAt;
+
+        public Freshness(String status, String reason, String updatedAt, String serverUpdatedAt) {
+            this.status = status;
+            this.reason = reason;
+            this.updatedAt = updatedAt;
+            this.serverUpdatedAt = serverUpdatedAt;
+        }
+
+        public String getStatus() { return status; }
+        public String getReason() { return reason; }
+        public String getUpdatedAt() { return updatedAt; }
+        public String getServerUpdatedAt() { return serverUpdatedAt; }
+    }
+
+    public static class Industry {
+        private final String code;
+        private final String key;
+        private final String label;
+
+        public Industry(String code, String key, String label) {
+            this.code = code;
+            this.key = key;
+            this.label = label;
+        }
+
+        public String getCode() { return code; }
+        public String getKey() { return key; }
+        public String getLabel() { return label; }
+    }
+
+    public static class IndustryOperatingInformation {
+        private final Industry industry;
+        private final String state;
+        private final String stateReason;
+        private final List<OperatingMetric> metrics;
+        private final int abnormalMetricCount;
+        private final MapFilter mapFilter;
+
+        public IndustryOperatingInformation(
+                Industry industry,
+                String state,
+                String stateReason,
+                List<OperatingMetric> metrics,
+                int abnormalMetricCount,
+                MapFilter mapFilter) {
+            this.industry = industry;
+            this.state = state;
+            this.stateReason = stateReason;
+            this.metrics = metrics;
+            this.abnormalMetricCount = abnormalMetricCount;
+            this.mapFilter = mapFilter;
+        }
+
+        public Industry getIndustry() { return industry; }
+        public String getState() { return state; }
+        public String getStateReason() { return stateReason; }
+        public List<OperatingMetric> getMetrics() { return metrics; }
+        public int getAbnormalMetricCount() { return abnormalMetricCount; }
+        public MapFilter getMapFilter() { return mapFilter; }
+    }
+
+    public static class MetricFreshness {
+        private final String status;
+        private final String reason;
+        private final Long ageSeconds;
+
+        public MetricFreshness(String status, String reason, Long ageSeconds) {
+            this.status = status;
+            this.reason = reason;
+            this.ageSeconds = ageSeconds;
+        }
+
+        public String getStatus() { return status; }
+        public String getReason() { return reason; }
+        public Long getAgeSeconds() { return ageSeconds; }
+    }
+
+    public static class OperatingMetric {
+        private final String metricCode;
+        private final String metricName;
+        private final String category;
+        private final String unit;
+        private final BigDecimal value;
+        private final BigDecimal capacityValue;
+        private final BigDecimal loadRatio;
+        private final String loadState;
+        private final String sampledAt;
+        private final MetricFreshness freshness;
+        private final List<TrendPoint> trend;
+        private final boolean abnormal;
+        private final List<String> pointCodes;
+
+        public OperatingMetric(
+                String metricCode,
+                String metricName,
+                String category,
+                String unit,
+                BigDecimal value,
+                BigDecimal capacityValue,
+                BigDecimal loadRatio,
+                String loadState,
+                String sampledAt,
+                MetricFreshness freshness,
+                List<TrendPoint> trend,
+                boolean abnormal,
+                List<String> pointCodes) {
+            this.metricCode = metricCode;
+            this.metricName = metricName;
+            this.category = category;
+            this.unit = unit;
+            this.value = value;
+            this.capacityValue = capacityValue;
+            this.loadRatio = loadRatio;
+            this.loadState = loadState;
+            this.sampledAt = sampledAt;
+            this.freshness = freshness;
+            this.trend = trend;
+            this.abnormal = abnormal;
+            this.pointCodes = pointCodes;
+        }
+
+        public String getMetricCode() { return metricCode; }
+        public String getMetricName() { return metricName; }
+        public String getCategory() { return category; }
+        public String getUnit() { return unit; }
+        @JsonFormat(shape = JsonFormat.Shape.STRING)
+        public BigDecimal getValue() { return value; }
+        @JsonFormat(shape = JsonFormat.Shape.STRING)
+        public BigDecimal getCapacityValue() { return capacityValue; }
+        @JsonFormat(shape = JsonFormat.Shape.STRING)
+        public BigDecimal getLoadRatio() { return loadRatio; }
+        public String getLoadState() { return loadState; }
+        public String getSampledAt() { return sampledAt; }
+        public MetricFreshness getFreshness() { return freshness; }
+        public List<TrendPoint> getTrend() { return trend; }
+        public boolean isAbnormal() { return abnormal; }
+        public List<String> getPointCodes() { return pointCodes; }
+    }
+
+    public static class TrendPoint {
+        private final String bucket;
+        private final BigDecimal value;
+
+        public TrendPoint(String bucket, BigDecimal value) {
+            this.bucket = bucket;
+            this.value = value;
+        }
+
+        public String getBucket() { return bucket; }
+        @JsonFormat(shape = JsonFormat.Shape.STRING)
+        public BigDecimal getValue() { return value; }
+    }
+
+    public static class MapFilter {
+        private final String industry;
+        private final List<String> metricCodes;
+        private final List<String> pointCodes;
+
+        public MapFilter(String industry, List<String> metricCodes, List<String> pointCodes) {
+            this.industry = industry;
+            this.metricCodes = metricCodes;
+            this.pointCodes = pointCodes;
+        }
+
+        public String getIndustry() { return industry; }
+        public List<String> getMetricCodes() { return metricCodes; }
+        public List<String> getPointCodes() { return pointCodes; }
+    }
+
+    public static class Excluded {
+        private final int unknownIndustryCount;
+        private final int nonFormalIndustryCount;
+
+        public Excluded(int unknownIndustryCount, int nonFormalIndustryCount) {
+            this.unknownIndustryCount = unknownIndustryCount;
+            this.nonFormalIndustryCount = nonFormalIndustryCount;
+        }
+
+        public int getUnknownIndustryCount() { return unknownIndustryCount; }
+        public int getNonFormalIndustryCount() { return nonFormalIndustryCount; }
+    }
+}

+ 414 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/infrastructure/CockpitInfrastructureOperatingInformationService.java

@@ -0,0 +1,414 @@
+package com.zksy.cockpit.infrastructure;
+
+import com.zksy.cockpit.foundation.CockpitErrorCategory;
+import com.zksy.cockpit.foundation.CockpitErrorPayload;
+import com.zksy.cockpit.foundation.CockpitFreshnessStatus;
+import com.zksy.cockpit.foundation.CockpitInvalidRequestException;
+import com.zksy.cockpit.foundation.CockpitReadException;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.Clock;
+import java.time.Duration;
+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.Arrays;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+
+@Service
+public class CockpitInfrastructureOperatingInformationService {
+    private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
+    private static final List<String> INDUSTRIES = Arrays.asList("GAS", "WATER_SUPPLY", "DRAINAGE");
+    private static final List<String> RANGES = Arrays.asList("LAST_24_HOURS", "LAST_7_DAYS", "LAST_30_DAYS");
+    private static final List<String> CATEGORIES =
+            Arrays.asList("SUPPLY", "FLOW", "PRESSURE", "QUALITY", "CAPACITY");
+    private static final int MAX_CALIBRATION_PERIOD_SECONDS = 3600;
+    private static final BigDecimal WATCH_RATIO = new BigDecimal("0.8");
+    private static final BigDecimal OVERLOAD_RATIO = BigDecimal.ONE;
+
+    private final CockpitInfrastructureOperatingInformationAdapter adapter;
+    private final Clock clock;
+    private final int defaultCalibrationPeriodSeconds;
+
+    public CockpitInfrastructureOperatingInformationService(
+            CockpitInfrastructureOperatingInformationAdapter adapter,
+            Clock clock,
+            @Value("${cockpit.freshness.default-calibration-period-seconds:60}")
+            int defaultCalibrationPeriodSeconds) {
+        this.adapter = adapter;
+        this.clock = clock;
+        this.defaultCalibrationPeriodSeconds = defaultCalibrationPeriodSeconds;
+        if (defaultCalibrationPeriodSeconds < 1
+                || defaultCalibrationPeriodSeconds > MAX_CALIBRATION_PERIOD_SECONDS) {
+            throw new IllegalArgumentException(
+                    "defaultCalibrationPeriodSeconds must be between 1 and 3600 seconds");
+        }
+    }
+
+    public CockpitInfrastructureOperatingInformationResponse load(
+            String requestId,
+            String industry,
+            String range,
+            String from,
+            String to) {
+        String selectedIndustry = resolveIndustry(industry);
+        Window window = resolveWindow(range, from, to);
+
+        CockpitInfrastructureOperatingInformationSnapshot snapshot = adapter.load();
+        List<CockpitInfrastructureOperatingInformationSnapshot.SourceMetric> sources =
+                snapshot == null || snapshot.getMetrics() == null
+                        ? Collections.emptyList()
+                        : snapshot.getMetrics();
+
+        int unknownIndustryCount = 0;
+        int nonFormalIndustryCount = 0;
+        List<IndustryAccumulator> accumulators = new ArrayList<>();
+        for (String code : INDUSTRIES) {
+            accumulators.add(new IndustryAccumulator(code, key(code), label(code)));
+        }
+
+        for (CockpitInfrastructureOperatingInformationSnapshot.SourceMetric source : sources) {
+            if (source == null || isBlank(source.getMetricCode())) {
+                continue;
+            }
+            String sourceIndustry = source.getIndustry();
+            if (isBlank(sourceIndustry)) {
+                unknownIndustryCount++;
+                continue;
+            }
+            String normalizedIndustry = sourceIndustry.trim();
+            if (!INDUSTRIES.contains(normalizedIndustry)) {
+                nonFormalIndustryCount++;
+                continue;
+            }
+            if (selectedIndustry != null && !selectedIndustry.equals(normalizedIndustry)) {
+                continue;
+            }
+            accumulator(accumulators, normalizedIndustry).add(metric(source));
+        }
+
+        List<CockpitInfrastructureOperatingInformationResponse.IndustryOperatingInformation> industries =
+                new ArrayList<>();
+        for (IndustryAccumulator accumulator : accumulators) {
+            industries.add(accumulator.build());
+        }
+
+        String serverUpdatedAt = businessTime();
+        String updatedAt = snapshot == null || isBlank(snapshot.getUpdatedAt())
+                ? serverUpdatedAt
+                : snapshot.getUpdatedAt();
+        boolean hasMetrics = industries.stream().anyMatch(item -> !item.getMetrics().isEmpty());
+
+        return new CockpitInfrastructureOperatingInformationResponse(
+                requestId,
+                "v1",
+                true,
+                "运行信息",
+                "Asia/Shanghai",
+                new CockpitInfrastructureOperatingInformationResponse.Range(
+                        window.getType(),
+                        format(window.getStart()),
+                        format(window.getEnd())),
+                updatedAt,
+                new CockpitInfrastructureOperatingInformationResponse.Freshness(
+                        hasMetrics ? CockpitFreshnessStatus.CURRENT.name() : CockpitFreshnessStatus.EMPTY.name(),
+                        hasMetrics ? "READY" : "NO_METRICS",
+                        updatedAt,
+                        serverUpdatedAt),
+                selectedIndustry == null ? null : industry(selectedIndustry),
+                industries,
+                new CockpitInfrastructureOperatingInformationResponse.Excluded(
+                        unknownIndustryCount,
+                        nonFormalIndustryCount));
+    }
+
+    public CockpitErrorPayload invalidRequest(String requestId, String message) {
+        return new CockpitErrorPayload(
+                CockpitErrorCategory.INVALID_REQUEST,
+                message,
+                requestId,
+                businessTime());
+    }
+
+    public CockpitErrorPayload serverError(String requestId) {
+        return new CockpitErrorPayload(
+                CockpitErrorCategory.SERVER_ERROR,
+                "驾驶舱基础设施运行信息数据暂不可用",
+                requestId,
+                businessTime());
+    }
+
+    private CockpitInfrastructureOperatingInformationResponse.OperatingMetric metric(
+            CockpitInfrastructureOperatingInformationSnapshot.SourceMetric source) {
+        CockpitInfrastructureOperatingInformationResponse.MetricFreshness freshness =
+                freshness(source.getSampledAt());
+        BigDecimal ratio = loadRatio(source.getValue(), source.getCapacityValue());
+        String loadState = loadState(ratio);
+        boolean abnormal = source.isAbnormal()
+                || "OVERLOAD".equals(loadState)
+                || CockpitFreshnessStatus.EXPIRED.name().equals(freshness.getStatus());
+        return new CockpitInfrastructureOperatingInformationResponse.OperatingMetric(
+                source.getMetricCode().trim(),
+                isBlank(source.getMetricName()) ? source.getMetricCode().trim() : source.getMetricName().trim(),
+                category(source.getCategory()),
+                source.getUnit() == null ? "" : source.getUnit(),
+                source.getValue(),
+                source.getCapacityValue(),
+                ratio,
+                loadState,
+                source.getSampledAt(),
+                freshness,
+                trend(source),
+                abnormal,
+                pointCodes(source));
+    }
+
+    private static List<CockpitInfrastructureOperatingInformationResponse.TrendPoint> trend(
+            CockpitInfrastructureOperatingInformationSnapshot.SourceMetric source) {
+        List<CockpitInfrastructureOperatingInformationResponse.TrendPoint> points = new ArrayList<>();
+        if (source.getTrend() == null) {
+            return points;
+        }
+        for (CockpitInfrastructureOperatingInformationSnapshot.SourcePoint point : source.getTrend()) {
+            if (point == null) {
+                continue;
+            }
+            points.add(new CockpitInfrastructureOperatingInformationResponse.TrendPoint(
+                    point.getBucket(), point.getValue()));
+        }
+        points.sort(Comparator.comparing(
+                CockpitInfrastructureOperatingInformationResponse.TrendPoint::getBucket,
+                Comparator.nullsLast(String::compareTo)));
+        return points;
+    }
+
+    private static List<String> pointCodes(
+            CockpitInfrastructureOperatingInformationSnapshot.SourceMetric source) {
+        Set<String> distinct = new LinkedHashSet<>();
+        if (source.getPointCodes() != null) {
+            for (String code : source.getPointCodes()) {
+                if (!isBlank(code)) {
+                    distinct.add(code.trim());
+                }
+            }
+        }
+        return new ArrayList<>(distinct);
+    }
+
+    private static BigDecimal loadRatio(BigDecimal value, BigDecimal capacityValue) {
+        if (value == null || capacityValue == null || capacityValue.signum() <= 0) {
+            return null;
+        }
+        return value.divide(capacityValue, 4, RoundingMode.HALF_UP);
+    }
+
+    private static String loadState(BigDecimal ratio) {
+        if (ratio == null) {
+            return "UNKNOWN";
+        }
+        if (ratio.compareTo(OVERLOAD_RATIO) > 0) {
+            return "OVERLOAD";
+        }
+        if (ratio.compareTo(WATCH_RATIO) >= 0) {
+            return "WATCH";
+        }
+        return "NORMAL";
+    }
+
+    private CockpitInfrastructureOperatingInformationResponse.MetricFreshness freshness(String sampledAt) {
+        if (isBlank(sampledAt)) {
+            return new CockpitInfrastructureOperatingInformationResponse.MetricFreshness(
+                    CockpitFreshnessStatus.EMPTY.name(), "NO_SOURCE", null);
+        }
+        OffsetDateTime sample;
+        try {
+            sample = OffsetDateTime.parse(sampledAt);
+        } catch (DateTimeParseException exception) {
+            throw new CockpitReadException("invalid metric sampledAt: " + exception.getMessage());
+        }
+        long ageSeconds = Math.max(0, Duration.between(sample.toInstant(), clock.instant()).getSeconds());
+        long missedCycles = ageSeconds / defaultCalibrationPeriodSeconds;
+        if (missedCycles <= 0) {
+            return new CockpitInfrastructureOperatingInformationResponse.MetricFreshness(
+                    CockpitFreshnessStatus.CURRENT.name(), "CURRENT", ageSeconds);
+        }
+        if (missedCycles == 1) {
+            return new CockpitInfrastructureOperatingInformationResponse.MetricFreshness(
+                    CockpitFreshnessStatus.STALE.name(), "ONE_UPDATE_CYCLE_MISSED", ageSeconds);
+        }
+        return new CockpitInfrastructureOperatingInformationResponse.MetricFreshness(
+                CockpitFreshnessStatus.EXPIRED.name(), "TWO_UPDATE_CYCLES_MISSED", ageSeconds);
+    }
+
+    private Window resolveWindow(String range, String from, String to) {
+        boolean hasFrom = !isBlank(from);
+        boolean hasTo = !isBlank(to);
+        if (hasFrom != hasTo) {
+            throw new CockpitInvalidRequestException("from and to must be provided together");
+        }
+        if (hasFrom) {
+            OffsetDateTime start = parseTime(from, "from");
+            OffsetDateTime end = parseTime(to, "to");
+            if (start.isAfter(end)) {
+                throw new CockpitInvalidRequestException("from must not be after to");
+            }
+            return new Window("CUSTOM", start, end);
+        }
+        String resolved = isBlank(range) ? "LAST_24_HOURS" : range.trim();
+        if (!RANGES.contains(resolved)) {
+            throw new CockpitInvalidRequestException(
+                    "range must be LAST_24_HOURS, LAST_7_DAYS or LAST_30_DAYS");
+        }
+        OffsetDateTime end = OffsetDateTime.now(clock).atZoneSameInstant(BUSINESS_ZONE).toOffsetDateTime();
+        OffsetDateTime start;
+        if ("LAST_7_DAYS".equals(resolved)) {
+            start = end.minusDays(7);
+        } else if ("LAST_30_DAYS".equals(resolved)) {
+            start = end.minusDays(30);
+        } else {
+            start = end.minusHours(24);
+        }
+        return new Window(resolved, start, end);
+    }
+
+    private String resolveIndustry(String industry) {
+        if (isBlank(industry)) {
+            return null;
+        }
+        String resolved = industry.trim();
+        if (!INDUSTRIES.contains(resolved)) {
+            throw new CockpitInvalidRequestException("industry must be GAS, WATER_SUPPLY or DRAINAGE");
+        }
+        return resolved;
+    }
+
+    private static OffsetDateTime parseTime(String value, String fieldName) {
+        try {
+            return OffsetDateTime.parse(value);
+        } catch (DateTimeParseException exception) {
+            throw new CockpitInvalidRequestException("invalid " + fieldName);
+        }
+    }
+
+    private static String category(String value) {
+        if (isBlank(value)) {
+            return "OTHER";
+        }
+        String resolved = value.trim().toUpperCase();
+        return CATEGORIES.contains(resolved) ? resolved : "OTHER";
+    }
+
+    private static IndustryAccumulator accumulator(List<IndustryAccumulator> accumulators, String code) {
+        for (IndustryAccumulator accumulator : accumulators) {
+            if (accumulator.getCode().equals(code)) {
+                return accumulator;
+            }
+        }
+        throw new IllegalStateException("unsupported industry: " + code);
+    }
+
+    private static CockpitInfrastructureOperatingInformationResponse.Industry industry(String code) {
+        return new CockpitInfrastructureOperatingInformationResponse.Industry(code, key(code), label(code));
+    }
+
+    private static String key(String code) {
+        if ("WATER_SUPPLY".equals(code)) {
+            return "waterSupply";
+        }
+        return code.toLowerCase();
+    }
+
+    private static String label(String code) {
+        if ("GAS".equals(code)) {
+            return "燃气";
+        }
+        if ("WATER_SUPPLY".equals(code)) {
+            return "供水";
+        }
+        if ("DRAINAGE".equals(code)) {
+            return "排水";
+        }
+        return code;
+    }
+
+    private static String format(OffsetDateTime value) {
+        return DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(value);
+    }
+
+    private String businessTime() {
+        return format(OffsetDateTime.now(clock).atZoneSameInstant(BUSINESS_ZONE).toOffsetDateTime());
+    }
+
+    private static boolean isBlank(String value) {
+        return value == null || value.trim().isEmpty();
+    }
+
+    private static class Window {
+        private final String type;
+        private final OffsetDateTime start;
+        private final OffsetDateTime end;
+
+        private Window(String type, OffsetDateTime start, OffsetDateTime end) {
+            this.type = type;
+            this.start = start;
+            this.end = end;
+        }
+
+        private String getType() { return type; }
+        private OffsetDateTime getStart() { return start; }
+        private OffsetDateTime getEnd() { return end; }
+    }
+
+    private static class IndustryAccumulator {
+        private final String code;
+        private final String key;
+        private final String label;
+        private final List<CockpitInfrastructureOperatingInformationResponse.OperatingMetric> metrics =
+                new ArrayList<>();
+
+        private IndustryAccumulator(String code, String key, String label) {
+            this.code = code;
+            this.key = key;
+            this.label = label;
+        }
+
+        private String getCode() { return code; }
+
+        private void add(CockpitInfrastructureOperatingInformationResponse.OperatingMetric metric) {
+            metrics.add(metric);
+        }
+
+        private CockpitInfrastructureOperatingInformationResponse.IndustryOperatingInformation build() {
+            List<String> metricCodes = new ArrayList<>();
+            Set<String> pointCodes = new LinkedHashSet<>();
+            int abnormalCount = 0;
+            for (CockpitInfrastructureOperatingInformationResponse.OperatingMetric metric : metrics) {
+                if (!metric.isAbnormal()) {
+                    continue;
+                }
+                abnormalCount++;
+                metricCodes.add(metric.getMetricCode());
+                pointCodes.addAll(metric.getPointCodes());
+            }
+            return new CockpitInfrastructureOperatingInformationResponse.IndustryOperatingInformation(
+                    new CockpitInfrastructureOperatingInformationResponse.Industry(code, key, label),
+                    metrics.isEmpty() ? "EMPTY" : "READY",
+                    metrics.isEmpty() ? "NO_METRICS" : "READY",
+                    metrics,
+                    abnormalCount,
+                    new CockpitInfrastructureOperatingInformationResponse.MapFilter(
+                            code,
+                            metricCodes,
+                            new ArrayList<>(pointCodes)));
+        }
+    }
+}

+ 81 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/infrastructure/CockpitInfrastructureOperatingInformationSnapshot.java

@@ -0,0 +1,81 @@
+package com.zksy.cockpit.infrastructure;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+public class CockpitInfrastructureOperatingInformationSnapshot {
+    private final String updatedAt;
+    private final List<SourceMetric> metrics;
+
+    public CockpitInfrastructureOperatingInformationSnapshot(String updatedAt, List<SourceMetric> metrics) {
+        this.updatedAt = updatedAt;
+        this.metrics = metrics;
+    }
+
+    public String getUpdatedAt() { return updatedAt; }
+    public List<SourceMetric> getMetrics() { return metrics; }
+
+    public static class SourceMetric {
+        private final String metricCode;
+        private final String metricName;
+        private final String category;
+        private final String unit;
+        private final String industry;
+        private final BigDecimal value;
+        private final BigDecimal capacityValue;
+        private final String sampledAt;
+        private final boolean abnormal;
+        private final List<String> pointCodes;
+        private final List<SourcePoint> trend;
+
+        public SourceMetric(
+                String metricCode,
+                String metricName,
+                String category,
+                String unit,
+                String industry,
+                BigDecimal value,
+                BigDecimal capacityValue,
+                String sampledAt,
+                boolean abnormal,
+                List<String> pointCodes,
+                List<SourcePoint> trend) {
+            this.metricCode = metricCode;
+            this.metricName = metricName;
+            this.category = category;
+            this.unit = unit;
+            this.industry = industry;
+            this.value = value;
+            this.capacityValue = capacityValue;
+            this.sampledAt = sampledAt;
+            this.abnormal = abnormal;
+            this.pointCodes = pointCodes;
+            this.trend = trend;
+        }
+
+        public String getMetricCode() { return metricCode; }
+        public String getMetricName() { return metricName; }
+        public String getCategory() { return category; }
+        public String getUnit() { return unit; }
+        public String getIndustry() { return industry; }
+        public BigDecimal getValue() { return value; }
+        public BigDecimal getCapacityValue() { return capacityValue; }
+        public String getSampledAt() { return sampledAt; }
+        public boolean isAbnormal() { return abnormal; }
+        public List<String> getPointCodes() { return pointCodes; }
+        public List<SourcePoint> getTrend() { return trend; }
+    }
+
+    public static class SourcePoint {
+        private final String bucket;
+        private final BigDecimal value;
+
+        public SourcePoint(String bucket, BigDecimal value) {
+            this.bucket = bucket;
+            this.value = value;
+        }
+
+        public String getBucket() { return bucket; }
+        public BigDecimal getValue() { return value; }
+    }
+}

+ 12 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/infrastructure/EmptyCockpitInfrastructureOperatingInformationAdapter.java

@@ -0,0 +1,12 @@
+package com.zksy.cockpit.infrastructure;
+
+import org.springframework.stereotype.Component;
+
+@Component
+public class EmptyCockpitInfrastructureOperatingInformationAdapter
+        implements CockpitInfrastructureOperatingInformationAdapter {
+    @Override
+    public CockpitInfrastructureOperatingInformationSnapshot load() {
+        return null;
+    }
+}