瀏覽代碼

feat(cockpit): add warning history analysis (#40)

Kazerin 1 月之前
父節點
當前提交
ed9bdafc08

+ 266 - 0
docs/cockpit/history-analysis-demo.html

@@ -0,0 +1,266 @@
+<!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 { --ink:#16324f; --muted:#64748b; --line:#d9e1ea; --panel:#fff; --page:#edf2f7; --blue:#1470ef; }
+    * { 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; justify-content:space-between; align-items:center; padding:14px 20px; background:var(--panel); border-bottom:1px solid var(--line); }
+    h1 { margin:0; font-size:18px; }
+    .tabs { display:inline-flex; border:1px solid var(--line); border-radius:5px; overflow:hidden; }
+    .tabs button { border:0; border-right:1px solid var(--line); padding:8px 16px; background:#f4f7fa; }
+    .tabs button.on { background:var(--blue); color:#fff; }
+    .meta { display:flex; gap:14px; color:var(--muted); flex-wrap:wrap; }
+    .meta b { color:var(--ink); font-weight:600; }
+    button { cursor:pointer; border:1px solid #b9c8d5; background:var(--panel); padding:7px 12px; border-radius:4px; }
+    select { border:1px solid #b9c8d5; border-radius:4px; padding:6px 8px; background:#fff; }
+    main { display:grid; grid-template-columns:minmax(340px,420px) minmax(0,1fr); min-height:calc(100vh - 57px); }
+    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-width:0; }
+    h2 { margin:0 0 10px; font-size:14px; }
+    .kpis { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; margin-bottom:14px; }
+    .kpi { border:1px solid var(--line); border-radius:5px; padding:8px 10px; }
+    .kpi span { display:block; color:var(--muted); font-size:11px; }
+    .kpi b { display:block; margin-top:2px; font-size:18px; }
+    table { width:100%; border-collapse:collapse; font-size:12px; }
+    th,td { text-align:left; padding:6px 7px; border-bottom:1px solid #edf1f5; }
+    th { color:var(--muted); font-weight:600; }
+    .toolbar { display:flex; flex-wrap:wrap; gap:10px; justify-content:space-between; align-items:center; padding:10px 18px; background:var(--panel); border-bottom:1px solid var(--line); }
+    .chart { position:relative; padding:10px 18px; background:#fff; overflow:auto; }
+    .columns { display:flex; align-items:flex-end; gap:10px; height:220px; border-bottom:1px solid var(--line); }
+    .col { flex:1; min-width:44px; display:flex; flex-direction:column; justify-content:flex-end; align-items:center; gap:5px; cursor:pointer; }
+    .col .bars { display:flex; align-items:flex-end; gap:3px; width:100%; height:150px; }
+    .col .bars i { display:block; width:100%; border-radius:3px 3px 0 0; }
+    .col .bars .ev { background:var(--blue); opacity:.85; }
+    .col .bars .hd { background:#0f8a4d; }
+    .col small { font-size:11px; color:var(--muted); }
+    .col .eff { font-size:11px; color:#b65c08; }
+    .col.selected .bars { outline:2px solid var(--ink); }
+    .map { position:relative; background:#e2e9f0; background-image:linear-gradient(#d3dce5 1px,transparent 1px),linear-gradient(90deg,#d3dce5 1px,transparent 1px); background-size:56px 56px; min-height:150px; }
+    .marker { position:absolute; width:13px; height:13px; margin:-6px 0 0 -6px; border-radius:50%; background:#d0342c; border:2px solid #fff; }
+    .marker.hot { outline:3px solid var(--ink); outline-offset:1px; }
+    .label { position:absolute; font-size:11px; transform:translate(8px,-6px); background:rgba(255,255,255,.92); padding:1px 5px; border-radius:3px; border:1px solid var(--line); pointer-events:none; }
+    .caption { padding:10px 18px; background:var(--panel); border-top:1px solid var(--line); color:var(--muted); }
+    .empty { color:var(--muted); padding:12px; border:1px dashed var(--line); border-radius:5px; margin-top:10px; }
+    .comparison { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-bottom:10px; }
+    .comparison .kpi b.mom-up { color:#0f8a4d; } .comparison .kpi b.mom-down { color:#d0342c; }
+    @media (max-width:940px){ main{grid-template-columns:1fr;} }
+  </style>
+</head>
+<body>
+<header>
+  <h1>预警处置 / 历史分析</h1>
+  <div class="tabs">
+    <button id="tabWARNING" class="on" type="button">预警</button>
+    <button id="tabALARM" type="button">报警</button>
+  </div>
+  <div class="meta"><span>事件族 <b id="familyName">预警</b></span><span>范围 <b id="rangeText">--</b></span><span>更新 <b id="updated">--</b></span></div>
+</header>
+<main>
+  <aside>
+    <h2>筛选与摘要</h2>
+    <div class="comparison">
+      <label style="color:var(--muted);font-size:12px">等级
+        <select id="fLevel"><option value="">全部</option><option value="1">1 级</option><option value="2">2 级</option><option value="3">3 级</option><option value="4">4 级</option></select>
+      </label>
+      <label style="color:var(--muted);font-size:12px">行业
+        <select id="fIndustry"><option value="">全部</option><option value="GAS">燃气</option><option value="WATER_SUPPLY">供水</option><option value="DRAINAGE">排水</option></select>
+      </label>
+      <label style="color:var(--muted);font-size:12px">天数
+        <select id="fDays"><option value="3">近 3 天</option><option value="5" selected>近 5 天</option><option value="7">近 7 天</option></select>
+      </label>
+    </div>
+    <div class="toolbar" style="padding:0 0 12px; border-bottom:none">
+      <button id="apply" type="button">应用</button>
+      <button id="refresh" type="button">手动刷新</button>
+    </div>
+
+    <div class="kpis">
+      <div class="kpi"><span>事件总数</span><b id="totalEvents">0</b></div>
+      <div class="kpi"><span>已处置(结案)</span><b id="totalHandled">0</b></div>
+      <div class="kpi"><span>处置效率</span><b id="totalEff">0%</b></div>
+    </div>
+    <h2>同比/环比摘要</h2>
+    <div class="comparison">
+      <div class="kpi"><span>近段 vs 前段(事件)</span><b id="momEvents" class="mom-up">--</b></div>
+      <div class="kpi"><span>近段 vs 前段(效率)</span><b id="momEff" class="mom-up">--</b></div>
+    </div>
+    <h2>每日明细</h2>
+    <table><thead><tr><th>日期</th><th>事件</th><th>处置</th><th>效率</th></tr></thead>
+      <tbody id="dayRows"></tbody></table>
+    <div class="empty" id="empty" style="display:none">所选时间段内没有事件(空时间段状态)。</div>
+  </aside>
+  <section>
+    <div class="toolbar">
+      <strong>趋势(蓝=事件数 绿=已处置数)</strong>
+      <span style="color:var(--muted)">点击某一列:筛选该时间桶并联动下方中央一张图</span>
+    </div>
+    <div class="chart"><div class="columns" id="columns"></div></div>
+    <div class="toolbar"><strong>中央一张图(当前选中桶的设备)</strong><span id="bucketLabel" style="color:var(--muted)">未选中</span></div>
+    <div class="map" id="map"></div>
+    <div class="caption" id="caption">事件按 hourly 分桶落入 Asia/Shanghai;点击列查看该桶设备点位</div>
+  </section>
+</main>
+<script>
+  const INDUSTRY = { GAS_SENSOR: "GAS", WATER_SUPPLY_SENSOR: "WATER_SUPPLY", SEWAGE_ENV: "DRAINAGE" };
+  const HANDLED = 6;
+  const days = day => {
+    const d = new Date(Date.UTC(2026, 8, day));
+    return d.toISOString().slice(0, 10);
+  };
+
+  // Multi-day fixture, deterministic. Each event: day, hour, level, type, work order state (null = 无工单).
+  const buildFixture = () => {
+    const warnings = [], alarms = [];
+    const specs = [
+      { day: 1, hour: 8,  level: 1, type: "GAS_SENSOR", precision: "w" },
+      { day: 1, hour: 12, level: 2, type: "WATER_SUPPLY_SENSOR", precision: "w" },
+      { day: 1, hour: 20, level: 3, type: "SEWAGE_ENV", precision: "a" },
+      { day: 2, hour: 7,  level: 1, type: "GAS_SENSOR", precision: "w" },
+      { day: 2, hour: 10, level: 2, type: "GAS_SENSOR", precision: "a" },
+      { day: 2, hour: 15, level: 4, type: "WATER_SUPPLY_SENSOR", precision: "w" },
+      { day: 3, hour: 9,  level: 1, type: "GAS_SENSOR", precision: "w" },
+      { day: 3, hour: 9,  level: 1, type: "GAS_SENSOR", precision: "a" },
+      { day: 3, hour: 18, level: 2, type: "SEWAGE_ENV", precision: "w" },
+      { day: 4, hour: 6,  level: 3, type: "WATER_SUPPLY_SENSOR", precision: "w" },
+      { day: 4, hour: 13, level: 1, type: "GAS_SENSOR", precision: "a" },
+      { day: 5, hour: 8,  level: 2, type: "SEWAGE_ENV", precision: "w" },
+      { day: 5, hour: 11, level: 1, type: "GAS_SENSOR", precision: "w" }
+    ];
+    specs.forEach((s, i) => {
+      const device = `D-${s.type === "GAS_SENSOR" ? "GAS" : s.type === "WATER_SUPPLY_SENSOR" ? "WS" : "DR"}-${i + 1}`;
+      const event = {
+        no: (s.precision === "w" ? "H" : "A") + String(i + 1).padStart(3, "0"),
+        level: s.level, hour: s.hour, day: days(s.day), type: s.type,
+        device, lon: 110.13 + i * 0.004, lat: 28.44 + (i % 4) * 0.01,
+        wo: i % 3 === 0 ? HANDLED : (i % 3 === 1 ? 4 : null)
+      };
+      (s.precision === "w" ? warnings : alarms).push(event);
+    });
+    return { warnings, alarms };
+  };
+  const fixture = buildFixture();
+
+  let family = "WARNING";
+  const filters = { level: "", industry: "", days: 5 };
+
+  function events() { return family === "WARNING" ? fixture.warnings : fixture.alarms; }
+
+  function render() {
+    const startDay = "2026-09-" + String(Math.max(1, 6 - filters.days)).padStart(2, "0");
+    const endDay = "2026-09-06";
+    const inWindow = e => e.day >= startDay && e.day <= endDay;
+    const list = events().filter(e =>
+      inWindow(e) &&
+      (!filters.level || String(e.level) === filters.level) &&
+      (!filters.industry || INDUSTRY[e.type] === filters.industry));
+
+    const daily = {};
+    let totalEvents = 0, totalHandled = 0;
+    const buckets = {};
+    list.forEach(e => {
+      const day = e.day, key = e.day + "T" + String(e.hour).padStart(2, "0") + ":00:00+08:00";
+      totalEvents++;
+      if (e.wo === HANDLED) totalHandled++;
+      (daily[day] = daily[day] || { ev: 0, hd: 0, eff: 0, devices: new Set() });
+      daily[day].ev++;
+      if (e.wo === HANDLED) daily[day].hd++;
+      daily[day].devices.add(e.device);
+      (buckets[key] = buckets[key] || []).push(e);
+    });
+
+    document.getElementById("familyName").textContent = family === "WARNING" ? "预警" : "报警";
+    document.getElementById("rangeText").textContent = startDay + " ~ " + endDay;
+    document.getElementById("updated").textContent = new Date().toLocaleTimeString("zh-CN");
+    document.getElementById("totalEvents").textContent = totalEvents;
+    document.getElementById("totalHandled").textContent = totalHandled;
+    document.getElementById("totalEff").textContent =
+      totalEvents ? (totalHandled / totalEvents * 100).toFixed(1) + "%" : "--";
+    document.getElementById("empty").style.display = totalEvents ? "none" : "block";
+
+    // MoM-style comparison: last half vs previous half of the window.
+    const half = Math.floor(list.length / 2);
+    if (list.length >= 2) {
+      const evA = list.slice(0, half).length, evB = list.length - half;
+      const hdA = list.slice(0, half).filter(e => e.wo === HANDLED).length;
+      const hdB = list.slice(half).filter(e => e.wo === HANDLED).length;
+      const rateA = evA ? hdA / evA : 0, rateB = evB ? hdB / evB : 0;
+      setMom("momEvents", evB - evA, evA);
+      setMom("momEff", rateB - rateA, rateA);
+    } else {
+      document.getElementById("momEvents").textContent = "--";
+      document.getElementById("momEff").textContent = "--";
+    }
+
+    document.getElementById("dayRows").innerHTML = Object.keys(daily).sort().map(d => {
+      const row = daily[d];
+      return `<tr><td>${d}</td><td>${row.ev}</td><td>${row.hd}</td><td>${row.ev ? (row.hd / row.ev * 100).toFixed(0) + "%" : "--"}</td></tr>`;
+    }).join("");
+
+    const maxEv = Math.max(1, ...Object.values(daily).map(d => d.ev));
+    document.getElementById("columns").innerHTML = Object.keys(daily).sort().map(d => {
+      const row = daily[d];
+      const pct = row.ev ? (row.hd / row.ev * 100).toFixed(0) : 0;
+      return `<div class="col" data-day="${d}"><span class="eff">${pct}%</span><div class="bars">
+        <i class="ev" style="height:${row.ev / maxEv * 130}px"></i>
+        <i class="hd" style="height:${row.hd / maxEv * 130}px"></i>
+      </div><small>${d.slice(5)}</small></div>`;
+    }).join("");
+
+    document.querySelectorAll(".col").forEach(col =>
+      col.addEventListener("click", () => selectBucket(daily[col.dataset.day], col.dataset.day)));
+  }
+
+  function setMom(id, delta, base) {
+    const el = document.getElementById(id);
+    if (!base) { el.textContent = delta > 0 ? "+" + delta : String(delta); return; }
+    const pct = delta / base * 100;
+    el.textContent = (pct >= 0 ? "+" : "") + pct.toFixed(1) + "%";
+    el.className = pct > 0 ? "mom-up" : pct < 0 ? "mom-down" : "";
+  }
+
+  function selectBucket(day, label) {
+    const map = document.getElementById("map");
+    map.innerHTML = "";
+    const selected = events().filter(e => e.day === label);
+    document.getElementById("bucketLabel").textContent = label + " 选中(" + selected.length + " 个事件)";
+    if (!selected.length) { document.getElementById("caption").textContent = "该桶无事件"; return; }
+    const lons = selected.map(e => e.lon), lats = selected.map(e => e.lat);
+    const minLon = Math.min(...lons), maxLon = Math.max(...lons), minLat = Math.min(...lats), maxLat = Math.max(...lats);
+    const xs = e => 12 + (e.lon - minLon) / Math.max(0.001, maxLon - minLon) * 76;
+    const ys = e => 12 + (maxLat - e.lat) / Math.max(0.001, maxLat - minLat) * 76;
+    selected.forEach(e => {
+      const el = document.createElement("div");
+      el.className = "marker hot";
+      el.style.left = xs(e) + "%"; el.style.top = ys(e) + "%";
+      map.appendChild(el);
+      const l = document.createElement("div");
+      l.className = "label"; l.style.left = xs(e) + "%"; l.style.top = ys(e) + "%";
+      l.textContent = e.no + " · " + e.device;
+      map.appendChild(l);
+    });
+    document.getElementById("caption").textContent = `${label} 桶内 ${selected.length} 个事件的点位(联动中央一张图)`;
+  }
+
+  document.getElementById("tabWARNING").addEventListener("click", () => setFamily("WARNING"));
+  document.getElementById("tabALARM").addEventListener("click", () => setFamily("ALARM"));
+  function setFamily(next) {
+    family = next;
+    document.getElementById("tabWARNING").classList.toggle("on", family === "WARNING");
+    document.getElementById("tabALARM").classList.toggle("on", family === "ALARM");
+    render();
+  }
+  document.getElementById("apply").addEventListener("click", () => {
+    filters.level = document.getElementById("fLevel").value;
+    filters.industry = document.getElementById("fIndustry").value;
+    filters.days = Number(document.getElementById("fDays").value);
+    render();
+  });
+  document.getElementById("refresh").addEventListener("click", render);
+  render();
+</script>
+</body>
+</html>

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

@@ -0,0 +1,73 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.cockpit.event.CockpitHistoryAnalysisService;
+import com.zksy.cockpit.foundation.CockpitErrorPayload;
+import com.zksy.cockpit.foundation.CockpitInvalidRequestException;
+import com.zksy.cockpit.foundation.CockpitReadException;
+import com.zksy.common.core.domain.AjaxResult;
+import com.zksy.common.utils.StringUtils;
+import com.zksy.common.utils.uuid.IdUtils;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.servlet.http.HttpServletRequest;
+
+@RestController
+public class CockpitHistoryAnalysisController {
+    private static final String REQUEST_ID_HEADER = "X-Request-Id";
+
+    private final CockpitHistoryAnalysisService historyAnalysisService;
+
+    public CockpitHistoryAnalysisController(CockpitHistoryAnalysisService historyAnalysisService) {
+        this.historyAnalysisService = historyAnalysisService;
+    }
+
+    @GetMapping("/api/cockpit/v1/events/history-analysis")
+    @PreAuthorize("isAuthenticated()")
+    public ResponseEntity<AjaxResult> historyAnalysis(
+            @RequestParam(value = "eventFamily", required = false) String eventFamily,
+            @RequestParam(value = "level", required = false) String level,
+            @RequestParam(value = "industry", required = false) String industry,
+            @RequestParam(value = "startTime", required = false) String startTime,
+            @RequestParam(value = "endTime", required = false) String endTime,
+            @RequestHeader(value = REQUEST_ID_HEADER, required = false) String requestId) {
+        return ResponseEntity.ok(AjaxResult.success(
+                historyAnalysisService.load(
+                        resolveRequestId(requestId),
+                        eventFamily,
+                        level,
+                        industry,
+                        startTime,
+                        endTime)));
+    }
+
+    @ExceptionHandler(CockpitInvalidRequestException.class)
+    public ResponseEntity<AjaxResult> handleInvalidRequest(
+            HttpServletRequest request,
+            CockpitInvalidRequestException exception) {
+        return error(HttpStatus.BAD_REQUEST, historyAnalysisService.invalidRequest(
+                resolveRequestId(request.getHeader(REQUEST_ID_HEADER)),
+                exception.getMessage()));
+    }
+
+    @ExceptionHandler(CockpitReadException.class)
+    public ResponseEntity<AjaxResult> handleReadFailure(HttpServletRequest request) {
+        return error(HttpStatus.INTERNAL_SERVER_ERROR, historyAnalysisService.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();
+    }
+}

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

@@ -0,0 +1,351 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.cockpit.event.CockpitHistoryAnalysisAdapter;
+import com.zksy.cockpit.event.CockpitHistoryAnalysisResponse;
+import com.zksy.cockpit.event.CockpitHistoryAnalysisService;
+import com.zksy.cockpit.event.CockpitHistoryAnalysisSnapshot;
+import com.zksy.cockpit.foundation.CockpitReadException;
+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.hamcrest.Matchers.hasSize;
+import static org.hamcrest.Matchers.nullValue;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+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 CockpitHistoryAnalysisControllerTest {
+
+    private static final String ENDPOINT = "/api/cockpit/v1/events/history-analysis";
+    private static final String START = "2026-09-01T23:00:00+08:00";
+    private static final String END = "2026-09-02T02:00:00+08:00";
+    private static final String BUCKET_23 = "2026-09-01T23:00:00+08:00";
+    private static final String BUCKET_00 = "2026-09-02T00:00:00+08:00";
+    private static final String BUCKET_01 = "2026-09-02T01:00:00+08:00";
+    private static final String BUCKET_02 = "2026-09-02T02:00:00+08:00";
+
+    private CockpitHistoryAnalysisAdapter adapter;
+    private MockMvc mockMvc;
+
+    @BeforeEach
+    void setUp() {
+        adapter = mock(CockpitHistoryAnalysisAdapter.class);
+        CockpitHistoryAnalysisService service = new CockpitHistoryAnalysisService(
+                adapter,
+                Clock.fixed(Instant.parse("2026-09-02T02:00:00Z"), ZoneOffset.UTC));
+        mockMvc = MockMvcBuilders
+                .standaloneSetup(new CockpitHistoryAnalysisController(service))
+                .build();
+        when(adapter.load()).thenReturn(fixture());
+    }
+
+    @Test
+    void warningFamilyExposesSummaryRowsBucketsAndHandledScope() throws Exception {
+        mockMvc.perform(get(ENDPOINT)
+                        .param("eventFamily", "WARNING")
+                        .param("startTime", START)
+                        .param("endTime", END)
+                        .header("X-Request-Id", "history-analysis-001"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data.requestId").value("history-analysis-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.eventFamily").value("WARNING"))
+                .andExpect(jsonPath("$.data.range.type").value("TREND_WINDOW"))
+                .andExpect(jsonPath("$.data.range.startTime").value(START))
+                .andExpect(jsonPath("$.data.range.endTime").value(END))
+                .andExpect(jsonPath("$.data.filters.eventFamily").value("WARNING"))
+                .andExpect(jsonPath("$.data.filters.level").value(nullValue()))
+                .andExpect(jsonPath("$.data.filters.industry").value(nullValue()))
+                .andExpect(jsonPath("$.data.summary.eventCount").value(5))
+                .andExpect(jsonPath("$.data.summary.handledCount").value(2))
+                .andExpect(jsonPath("$.data.summary.handlingEfficiency.value").value("0.4000"))
+                .andExpect(jsonPath("$.data.summary.handlingEfficiency.state").value("AVAILABLE"))
+                .andExpect(jsonPath("$.data.rows", hasSize(4)))
+                .andExpect(jsonPath("$.data.rows[0].timeBucket").value(BUCKET_23))
+                .andExpect(jsonPath("$.data.rows[0].eventCount").value(1))
+                .andExpect(jsonPath("$.data.rows[0].handledCount").value(1))
+                .andExpect(jsonPath("$.data.rows[0].handlingEfficiency.value").value("1.0000"))
+                .andExpect(jsonPath("$.data.rows[0].mapFilter.deviceCodes[0]").value("D-GAS-1"))
+                .andExpect(jsonPath("$.data.rows[1].timeBucket").value(BUCKET_00))
+                .andExpect(jsonPath("$.data.rows[1].eventCount").value(2))
+                .andExpect(jsonPath("$.data.rows[1].handledCount").value(0))
+                .andExpect(jsonPath("$.data.rows[1].handlingEfficiency.value").value("0.0000"))
+                .andExpect(jsonPath("$.data.rows[1].handlingEfficiency.state").value("AVAILABLE"))
+                .andExpect(jsonPath("$.data.rows[2].timeBucket").value(BUCKET_01))
+                .andExpect(jsonPath("$.data.rows[2].eventCount").value(2))
+                .andExpect(jsonPath("$.data.rows[2].handledCount").value(1))
+                .andExpect(jsonPath("$.data.rows[2].handlingEfficiency.value").value("0.5000"))
+                .andExpect(jsonPath("$.data.rows[3].timeBucket").value(BUCKET_02))
+                .andExpect(jsonPath("$.data.rows[3].eventCount").value(0))
+                .andExpect(jsonPath("$.data.rows[3].handlingEfficiency.state").value("EMPTY"))
+                .andExpect(jsonPath("$.data.rows[3].handlingEfficiency.reason").value("NO_EVENTS"))
+                .andExpect(jsonPath("$.data.rows[3].handlingEfficiency.value").value("0.0000"));
+    }
+
+    @Test
+    void alarmFamilyIsIndependentFromWarnings() throws Exception {
+        mockMvc.perform(get(ENDPOINT)
+                        .param("eventFamily", "ALARM")
+                        .param("startTime", START)
+                        .param("endTime", END))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.eventFamily").value("ALARM"))
+                .andExpect(jsonPath("$.data.summary.eventCount").value(2))
+                .andExpect(jsonPath("$.data.summary.handledCount").value(1))
+                .andExpect(jsonPath("$.data.summary.handlingEfficiency.value").value("0.5000"))
+                .andExpect(jsonPath("$.data.rows[2].eventCount").value(2))
+                .andExpect(jsonPath("$.data.rows[2].handledCount").value(1))
+                .andExpect(jsonPath("$.data.rows[0].eventCount").value(0))
+                .andExpect(jsonPath("$.data.rows[1].eventCount").value(0));
+    }
+
+    @Test
+    void asiaShanghaiHourBoundaryPlacesLateNightEventInPreviousBucket() throws Exception {
+        mockMvc.perform(get(ENDPOINT)
+                        .param("eventFamily", "WARNING")
+                        .param("startTime", START)
+                        .param("endTime", END))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.rows[0].timeBucket").value("2026-09-01T23:00:00+08:00"))
+                .andExpect(jsonPath("$.data.rows[0].eventCount").value(1))
+                .andExpect(jsonPath("$.data.rows[1].timeBucket").value("2026-09-02T00:00:00+08:00"))
+                .andExpect(jsonPath("$.data.rows[2].timeBucket").value("2026-09-02T01:00:00+08:00"));
+    }
+
+    @Test
+    void handledScopeCountsOnlyClosedStateSixOrders() throws Exception {
+        CockpitHistoryAnalysisResponse response = service("WARNING", START, END);
+        assertEquals(5, response.getSummary().getEventCount());
+        assertEquals(2, response.getSummary().getHandledCount());
+        assertEquals("0.4000", response.getSummary().getHandlingEfficiency().getValue().toPlainString());
+    }
+
+    @Test
+    void emptyTimeWindowReturnsSuccessfulAllZeroState() throws Exception {
+        mockMvc.perform(get(ENDPOINT)
+                        .param("eventFamily", "WARNING")
+                        .param("startTime", "2026-09-03T00:00:00+08:00")
+                        .param("endTime", "2026-09-03T02:00:00+08:00"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.summary.eventCount").value(0))
+                .andExpect(jsonPath("$.data.summary.handlingEfficiency.state").value("EMPTY"))
+                .andExpect(jsonPath("$.data.summary.handlingEfficiency.reason").value("NO_EVENTS"))
+                .andExpect(jsonPath("$.data.rows", hasSize(3)))
+                .andExpect(jsonPath("$.data.rows[0].eventCount").value(0))
+                .andExpect(jsonPath("$.data.rows[2].handlingEfficiency.state").value("EMPTY"));
+    }
+
+    @Test
+    void defaultWindowCoversSevenDaysEndingAtBusinessTime() throws Exception {
+        mockMvc.perform(get(ENDPOINT).param("eventFamily", "WARNING"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.range.startTime").value("2026-08-26T10:00:00+08:00"))
+                .andExpect(jsonPath("$.data.range.endTime").value("2026-09-02T10:00:00+08:00"))
+                .andExpect(jsonPath("$.data.rows", hasSize(169)))
+                .andExpect(jsonPath("$.data.rows[0].timeBucket").value("2026-08-26T10:00:00+08:00"))
+                .andExpect(jsonPath("$.data.rows[168].timeBucket").value("2026-09-02T10:00:00+08:00"));
+    }
+
+    @Test
+    void levelAndIndustryFiltersNarrowSummaryAndRows() throws Exception {
+        mockMvc.perform(get(ENDPOINT)
+                        .param("eventFamily", "WARNING")
+                        .param("level", "2")
+                        .param("startTime", START)
+                        .param("endTime", END))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.filters.level").value(2))
+                .andExpect(jsonPath("$.data.summary.eventCount").value(2))
+                .andExpect(jsonPath("$.data.summary.handledCount").value(0));
+
+        mockMvc.perform(get(ENDPOINT)
+                        .param("eventFamily", "WARNING")
+                        .param("industry", "GAS")
+                        .param("startTime", START)
+                        .param("endTime", END))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.summary.eventCount").value(2))
+                .andExpect(jsonPath("$.data.summary.handledCount").value(1));
+    }
+
+    @Test
+    void invalidEventFamilyLevelIndustryOrWindowReturnsTypedBadRequestBeforeAdapter() throws Exception {
+        mockMvc.perform(get(ENDPOINT))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.code").value(400))
+                .andExpect(jsonPath("$.data.category").value("INVALID_REQUEST"))
+                .andExpect(jsonPath("$.data.message").value(
+                        "eventFamily must be WARNING or ALARM"));
+
+        mockMvc.perform(get(ENDPOINT).param("eventFamily", "WHATEVER"))
+                .andExpect(status().isBadRequest());
+
+        mockMvc.perform(get(ENDPOINT).param("eventFamily", "WARNING").param("level", "9"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.data.message").value(
+                        "level must be an integer between 1 and 4"));
+
+        mockMvc.perform(get(ENDPOINT).param("eventFamily", "WARNING").param("industry", "MANHOLE"))
+                .andExpect(status().isBadRequest());
+
+        mockMvc.perform(get(ENDPOINT).param("eventFamily", "WARNING").param("startTime", START))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.data.message").value(
+                        "start time and end time must be provided together"));
+
+        mockMvc.perform(get(ENDPOINT)
+                        .param("eventFamily", "WARNING")
+                        .param("startTime", END)
+                        .param("endTime", START))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.data.message").value(
+                        "start time must not be after end time"));
+
+        mockMvc.perform(get(ENDPOINT)
+                        .param("eventFamily", "WARNING")
+                        .param("startTime", "2026-08-01T00:00:00+08:00")
+                        .param("endTime", "2026-09-02T00:00:00+08:00"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.data.message").value(
+                        "history window must not exceed 31 days"));
+
+        verifyNoInteractions(adapter);
+    }
+
+    @Test
+    void serverFailureReturnsTypedServerError() throws Exception {
+        when(adapter.load()).thenThrow(new CockpitReadException("upstream unavailable"));
+
+        mockMvc.perform(get(ENDPOINT)
+                        .param("eventFamily", "WARNING")
+                        .header("X-Request-Id", "history-analysis-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("history-analysis-500"));
+    }
+
+    @Test
+    void mutatingRequestIsRejectedBeforeReadAdapterIsCalled() throws Exception {
+        mockMvc.perform(post(ENDPOINT))
+                .andExpect(status().isMethodNotAllowed());
+
+        verifyNoInteractions(adapter);
+    }
+
+    @Test
+    void controllerOnlyExposesAuthenticatedReadOnlyOperations() {
+        assertTrue(Arrays.stream(CockpitHistoryAnalysisController.class.getDeclaredMethods())
+                .filter(this::hasRequestMappingAnnotation)
+                .allMatch(CockpitHistoryAnalysisControllerTest::isReadOnlyMappedMethod));
+
+        assertTrue(Arrays.stream(CockpitHistoryAnalysisController.class.getDeclaredMethods())
+                .anyMatch(method -> method.isAnnotationPresent(PreAuthorize.class)
+                        && "isAuthenticated()".equals(
+                                method.getAnnotation(PreAuthorize.class).value())));
+    }
+
+    private boolean hasRequestMappingAnnotation(Method method) {
+        return method.isAnnotationPresent(org.springframework.web.bind.annotation.RequestMapping.class)
+                || method.isAnnotationPresent(GetMapping.class)
+                || method.isAnnotationPresent(PostMapping.class)
+                || method.isAnnotationPresent(PutMapping.class)
+                || method.isAnnotationPresent(PatchMapping.class)
+                || method.isAnnotationPresent(DeleteMapping.class);
+    }
+
+    private static boolean isReadOnlyMappedMethod(Method method) {
+        return method.isAnnotationPresent(GetMapping.class)
+                && !method.isAnnotationPresent(PostMapping.class)
+                && !method.isAnnotationPresent(PutMapping.class)
+                && !method.isAnnotationPresent(PatchMapping.class)
+                && !method.isAnnotationPresent(DeleteMapping.class);
+    }
+
+    private CockpitHistoryAnalysisResponse service(String family, String start, String end) {
+        return new CockpitHistoryAnalysisService(
+                adapter,
+                Clock.fixed(Instant.parse("2026-09-02T02:00:00Z"), ZoneOffset.UTC))
+                .load("direct", family, null, null, start, end);
+    }
+
+    private static CockpitHistoryAnalysisSnapshot fixture() {
+        return new CockpitHistoryAnalysisSnapshot(
+                "2026-09-02T09:59:50+08:00",
+                Arrays.asList(
+                        type("GAS_SENSOR", "GAS"),
+                        type("WATER_SUPPLY_SENSOR", "WATER_SUPPLY"),
+                        type("SEWAGE_ENV", "DRAINAGE"),
+                        new CockpitHistoryAnalysisSnapshot.SourceType(
+                                "WATER_PLANT", "水厂", "WATER_SUPPLY", "FACILITY"),
+                        new CockpitHistoryAnalysisSnapshot.SourceType(
+                                "SMART_MANHOLE", "智能井盖", null, "SMART_DEVICE")),
+                Arrays.asList(
+                        warning("HW-001", 1, "2026-09-01T23:30:00+08:00", "GAS_SENSOR", "D-GAS-1"),
+                        warning("HW-002", 2, "2026-09-02T00:15:00+08:00", "GAS_SENSOR", "D-GAS-2"),
+                        warning("HW-003", 2, "2026-09-02T00:45:00+08:00", "WATER_SUPPLY_SENSOR", "D-WS-1"),
+                        warning("HW-004", 4, "2026-09-02T01:20:00+08:00", "SEWAGE_ENV", "D-DR-1"),
+                        warning("HW-005", 1, "2026-09-02T01:50:00+08:00", "WATER_SUPPLY_SENSOR", "D-WS-2"),
+                        warning("HW-LEGACY", 1, "2026-09-02T00:10:00+08:00", "LEGACY", "D-LEGACY"),
+                        warning("HW-MH", 1, "2026-09-01T23:59:00+08:00", "SMART_MANHOLE", "D-MH")),
+                Arrays.asList(
+                        alarm("HA-001", 3, "2026-09-02T01:10:00+08:00", "WATER_SUPPLY_SENSOR", "D-WS-3"),
+                        alarm("HA-002", 1, "2026-09-02T01:30:00+08:00", "GAS_SENSOR", "D-GAS-3")),
+                Arrays.asList(
+                        workOrder("HW-001", 6),
+                        workOrder("HW-002", 4),
+                        workOrder("HW-003", 1),
+                        workOrder("HW-004", 6),
+                        workOrder("HW-005", 7),
+                        workOrder("HA-001", 6)));
+    }
+
+    private static CockpitHistoryAnalysisSnapshot.SourceType type(String code, String industry) {
+        return new CockpitHistoryAnalysisSnapshot.SourceType(
+                code, code, industry, "SMART_DEVICE");
+    }
+
+    private static CockpitHistoryAnalysisSnapshot.SourceWarning warning(
+            String no, Integer level, String eventTime, String typeCode, String deviceCode) {
+        return new CockpitHistoryAnalysisSnapshot.SourceWarning(
+                no, level, eventTime, typeCode, deviceCode);
+    }
+
+    private static CockpitHistoryAnalysisSnapshot.SourceAlarm alarm(
+            String id, Integer level, String eventTime, String typeCode, String deviceCode) {
+        return new CockpitHistoryAnalysisSnapshot.SourceAlarm(
+                id, level, eventTime, typeCode, deviceCode);
+    }
+
+    private static CockpitHistoryAnalysisSnapshot.SourceWorkOrderLink workOrder(
+            String eventNo, Integer stateCode) {
+        return new CockpitHistoryAnalysisSnapshot.SourceWorkOrderLink(eventNo, stateCode);
+    }
+}

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

@@ -0,0 +1,5 @@
+package com.zksy.cockpit.event;
+
+public interface CockpitHistoryAnalysisAdapter {
+    CockpitHistoryAnalysisSnapshot load();
+}

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

@@ -0,0 +1,164 @@
+package com.zksy.cockpit.event;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+public class CockpitHistoryAnalysisResponse {
+    private final String requestId;
+    private final String apiVersion;
+    private final boolean readOnly;
+    private final String displayName;
+    private final String timezone;
+    private final String eventFamily;
+    private final Range range;
+    private final String updatedAt;
+    private final Filters filters;
+    private final Summary summary;
+    private final List<Row> rows;
+
+    public CockpitHistoryAnalysisResponse(
+            String requestId,
+            String apiVersion,
+            boolean readOnly,
+            String displayName,
+            String timezone,
+            String eventFamily,
+            Range range,
+            String updatedAt,
+            Filters filters,
+            Summary summary,
+            List<Row> rows) {
+        this.requestId = requestId;
+        this.apiVersion = apiVersion;
+        this.readOnly = readOnly;
+        this.displayName = displayName;
+        this.timezone = timezone;
+        this.eventFamily = eventFamily;
+        this.range = range;
+        this.updatedAt = updatedAt;
+        this.filters = filters;
+        this.summary = summary;
+        this.rows = rows;
+    }
+
+    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 String getEventFamily() { return eventFamily; }
+    public Range getRange() { return range; }
+    public String getUpdatedAt() { return updatedAt; }
+    public Filters getFilters() { return filters; }
+    public Summary getSummary() { return summary; }
+    public List<Row> getRows() { return rows; }
+
+    public static class Range {
+        private final String type;
+        private final String startTime;
+        private final String endTime;
+
+        public Range(String type, String startTime, String endTime) {
+            this.type = type;
+            this.startTime = startTime;
+            this.endTime = endTime;
+        }
+
+        public String getType() { return type; }
+        public String getStartTime() { return startTime; }
+        public String getEndTime() { return endTime; }
+    }
+
+    public static class Filters {
+        private final String eventFamily;
+        private final Integer level;
+        private final String industry;
+
+        public Filters(String eventFamily, Integer level, String industry) {
+            this.eventFamily = eventFamily;
+            this.level = level;
+            this.industry = industry;
+        }
+
+        public String getEventFamily() { return eventFamily; }
+        public Integer getLevel() { return level; }
+        public String getIndustry() { return industry; }
+    }
+
+    public static class Summary {
+        private final int eventCount;
+        private final int handledCount;
+        private final HandlingEfficiency handlingEfficiency;
+
+        public Summary(int eventCount, int handledCount, HandlingEfficiency handlingEfficiency) {
+            this.eventCount = eventCount;
+            this.handledCount = handledCount;
+            this.handlingEfficiency = handlingEfficiency;
+        }
+
+        public int getEventCount() { return eventCount; }
+        public int getHandledCount() { return handledCount; }
+        public HandlingEfficiency getHandlingEfficiency() { return handlingEfficiency; }
+    }
+
+    public static class HandlingEfficiency {
+        private final BigDecimal value;
+        private final int numerator;
+        private final int denominator;
+        private final String state;
+        private final String reason;
+
+        public HandlingEfficiency(BigDecimal value, int numerator, int denominator, String state, String reason) {
+            this.value = value;
+            this.numerator = numerator;
+            this.denominator = denominator;
+            this.state = state;
+            this.reason = reason;
+        }
+
+        @JsonFormat(shape = JsonFormat.Shape.STRING)
+        public BigDecimal getValue() { return value; }
+        public int getNumerator() { return numerator; }
+        public int getDenominator() { return denominator; }
+        public String getState() { return state; }
+        public String getReason() { return reason; }
+    }
+
+    public static class Row {
+        private final String timeBucket;
+        private final int eventCount;
+        private final int handledCount;
+        private final HandlingEfficiency handlingEfficiency;
+        private final RowMapFilter mapFilter;
+
+        public Row(String timeBucket, int eventCount, int handledCount,
+                HandlingEfficiency handlingEfficiency, RowMapFilter mapFilter) {
+            this.timeBucket = timeBucket;
+            this.eventCount = eventCount;
+            this.handledCount = handledCount;
+            this.handlingEfficiency = handlingEfficiency;
+            this.mapFilter = mapFilter;
+        }
+
+        public String getTimeBucket() { return timeBucket; }
+        public int getEventCount() { return eventCount; }
+        public int getHandledCount() { return handledCount; }
+        public HandlingEfficiency getHandlingEfficiency() { return handlingEfficiency; }
+        public RowMapFilter getMapFilter() { return mapFilter; }
+    }
+
+    public static class RowMapFilter {
+        private final String eventFamily;
+        private final List<String> deviceCodes;
+
+        public RowMapFilter(String eventFamily, List<String> deviceCodes) {
+            this.eventFamily = eventFamily;
+            this.deviceCodes = deviceCodes;
+        }
+
+        public String getEventFamily() { return eventFamily; }
+        public List<String> getDeviceCodes() { return deviceCodes; }
+    }
+}

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

@@ -0,0 +1,422 @@
+package com.zksy.cockpit.event;
+
+import com.zksy.cockpit.foundation.CockpitErrorCategory;
+import com.zksy.cockpit.foundation.CockpitErrorPayload;
+import com.zksy.cockpit.foundation.CockpitInvalidRequestException;
+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.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.time.temporal.ChronoUnit;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Read-only aggregation for 预警处置 / 历史分析.
+ *
+ * <p>Buckets events hourly in Asia/Shanghai, keeps warning and alarm families
+ * isolated, and resolves the handled disposal scope through the authoritative
+ * work-order dictionary (ADR-0010): an event counts as handled only when its
+ * associated work order stateCode equals 6 (已结案).
+ */
+@Service
+public class CockpitHistoryAnalysisService {
+    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 String SMART_DEVICE = "SMART_DEVICE";
+    private static final String WARNING = "WARNING";
+    private static final String ALARM = "ALARM";
+    private static final int HANDLED_STATE_CODE = 6;
+    private static final long DEFAULT_WINDOW_DAYS = 7;
+    private static final long MAX_WINDOW_DAYS = 31;
+    private static final DateTimeFormatter TIMESTAMP =
+            DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssXXX");
+
+    private final CockpitHistoryAnalysisAdapter adapter;
+    private final Clock clock;
+    private CockpitHistoryAnalysisSnapshot state;
+
+    public CockpitHistoryAnalysisService(
+            CockpitHistoryAnalysisAdapter adapter,
+            Clock clock) {
+        this.adapter = adapter;
+        this.clock = clock;
+    }
+
+    public synchronized CockpitHistoryAnalysisResponse load(
+            String requestId,
+            String eventFamily,
+            String level,
+            String industry,
+            String startTime,
+            String endTime) {
+        String family = resolveFamily(eventFamily);
+        Integer resolvedLevel = resolveLevel(level);
+        String selectedIndustry = resolveIndustry(industry);
+        TimeWindow window = resolveWindow(startTime, endTime);
+        if (state == null) {
+            CockpitHistoryAnalysisSnapshot loaded = adapter.load();
+            state = loaded == null
+                    ? CockpitHistoryAnalysisSnapshot.empty(businessTime())
+                    : loaded;
+        }
+        return build(requestId, family, resolvedLevel, selectedIndustry, window);
+    }
+
+    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 CockpitHistoryAnalysisResponse build(
+            String requestId,
+            String family,
+            Integer level,
+            String industry,
+            TimeWindow window) {
+        Map<String, CockpitHistoryAnalysisSnapshot.SourceType> types = types(state);
+        Map<String, Integer> handledLinks = handledLinks(state.getWorkOrders());
+        List<EventFact> facts = WARNING.equals(family)
+                ? warningFacts(state, types, level, industry)
+                : alarmFacts(state, types, level, industry);
+
+        ZonedDateTime first = window.start
+                .atZoneSameInstant(BUSINESS_ZONE)
+                .truncatedTo(ChronoUnit.HOURS);
+        ZonedDateTime last = window.end
+                .atZoneSameInstant(BUSINESS_ZONE)
+                .truncatedTo(ChronoUnit.HOURS);
+        List<String> bucketKeys = new ArrayList<>();
+        Map<String, Bucket> buckets = new LinkedHashMap<>();
+        for (ZonedDateTime bucket = first; !bucket.isAfter(last); bucket = bucket.plusHours(1)) {
+            String key = TIMESTAMP.format(bucket);
+            bucketKeys.add(key);
+            buckets.put(key, new Bucket());
+        }
+
+        int totalEvents = 0;
+        int totalHandled = 0;
+        for (EventFact fact : facts) {
+            if (fact.eventTime == null) {
+                continue;
+            }
+            ZonedDateTime bucket = fact.eventTime
+                    .atZoneSameInstant(BUSINESS_ZONE)
+                    .truncatedTo(ChronoUnit.HOURS);
+            String key = TIMESTAMP.format(bucket);
+            Bucket target = buckets.get(key);
+            if (target == null) {
+                continue;
+            }
+            totalEvents++;
+            target.eventCount++;
+            Integer stateCode = fact.eventNo == null ? null : handledLinks.get(fact.eventNo.trim());
+            if (HANDLED_STATE_CODE == effectiveStateCode(stateCode)) {
+                target.handledCount++;
+                totalHandled++;
+            }
+            if (!isBlank(fact.deviceCode)) {
+                target.deviceCodes.add(fact.deviceCode.trim());
+            }
+        }
+
+        List<CockpitHistoryAnalysisResponse.Row> rows = new ArrayList<>();
+        for (String key : bucketKeys) {
+            Bucket bucket = buckets.get(key);
+            rows.add(new CockpitHistoryAnalysisResponse.Row(
+                    key,
+                    bucket.eventCount,
+                    bucket.handledCount,
+                    efficiency(bucket.handledCount, bucket.eventCount),
+                    new CockpitHistoryAnalysisResponse.RowMapFilter(
+                            family, sorted(bucket.deviceCodes))));
+        }
+
+        String updatedAt = isBlank(state.getUpdatedAt()) ? businessTime() : state.getUpdatedAt();
+        return new CockpitHistoryAnalysisResponse(
+                requestId,
+                "v1",
+                true,
+                "历史分析",
+                "Asia/Shanghai",
+                family,
+                new CockpitHistoryAnalysisResponse.Range(
+                        "TREND_WINDOW",
+                        timestamp(window.start),
+                        timestamp(window.end)),
+                updatedAt,
+                new CockpitHistoryAnalysisResponse.Filters(family, level, industry),
+                new CockpitHistoryAnalysisResponse.Summary(
+                        totalEvents,
+                        totalHandled,
+                        efficiency(totalHandled, totalEvents)),
+                rows);
+    }
+
+    private List<EventFact> warningFacts(
+            CockpitHistoryAnalysisSnapshot snapshot,
+            Map<String, CockpitHistoryAnalysisSnapshot.SourceType> types,
+            Integer level,
+            String industry) {
+        List<EventFact> facts = new ArrayList<>();
+        if (snapshot.getWarnings() == null) {
+            return facts;
+        }
+        for (CockpitHistoryAnalysisSnapshot.SourceWarning warning : snapshot.getWarnings()) {
+            if (warning == null
+                    || !inScope(types, warning.getTypeCode(), industry)
+                    || (level != null && !level.equals(warning.getLevel()))) {
+                continue;
+            }
+            facts.add(new EventFact(
+                    warning.getWarningNo(),
+                    parseEventTime(warning.getEventTime()),
+                    warning.getDeviceCode()));
+        }
+        return facts;
+    }
+
+    private List<EventFact> alarmFacts(
+            CockpitHistoryAnalysisSnapshot snapshot,
+            Map<String, CockpitHistoryAnalysisSnapshot.SourceType> types,
+            Integer level,
+            String industry) {
+        List<EventFact> facts = new ArrayList<>();
+        if (snapshot.getAlarms() == null) {
+            return facts;
+        }
+        for (CockpitHistoryAnalysisSnapshot.SourceAlarm alarm : snapshot.getAlarms()) {
+            if (alarm == null
+                    || !inScope(types, alarm.getTypeCode(), industry)
+                    || (level != null && !level.equals(alarm.getLevel()))) {
+                continue;
+            }
+            facts.add(new EventFact(
+                    alarm.getAlarmId(),
+                    parseEventTime(alarm.getEventTime()),
+                    alarm.getDeviceCode()));
+        }
+        return facts;
+    }
+
+    private static boolean inScope(
+            Map<String, CockpitHistoryAnalysisSnapshot.SourceType> types,
+            String typeCode,
+            String industry) {
+        CockpitHistoryAnalysisSnapshot.SourceType type =
+                isBlank(typeCode) ? null : types.get(typeCode.trim());
+        if (type == null
+                || !SMART_DEVICE.equals(type.getObjectKind())
+                || type.getIndustry() == null
+                || !INDUSTRIES.contains(type.getIndustry())) {
+            return false;
+        }
+        return industry == null || industry.equals(type.getIndustry());
+    }
+
+    private static Map<String, Integer> handledLinks(
+            List<CockpitHistoryAnalysisSnapshot.SourceWorkOrderLink> links) {
+        Map<String, Integer> result = new LinkedHashMap<>();
+        if (links == null) {
+            return result;
+        }
+        for (CockpitHistoryAnalysisSnapshot.SourceWorkOrderLink link : links) {
+            if (link != null && !isBlank(link.getEventNo())) {
+                result.putIfAbsent(link.getEventNo().trim(), link.getStateCode());
+            }
+        }
+        return result;
+    }
+
+    private static int effectiveStateCode(Integer stateCode) {
+        return stateCode == null ? -1 : stateCode;
+    }
+
+    private static CockpitHistoryAnalysisResponse.HandlingEfficiency efficiency(
+            int handled, int events) {
+        if (events <= 0) {
+            return new CockpitHistoryAnalysisResponse.HandlingEfficiency(
+                    BigDecimal.ZERO.setScale(4), handled, events, "EMPTY", "NO_EVENTS");
+        }
+        return new CockpitHistoryAnalysisResponse.HandlingEfficiency(
+                BigDecimal.valueOf(handled)
+                        .divide(BigDecimal.valueOf(events), 4, RoundingMode.HALF_UP),
+                handled,
+                events,
+                "AVAILABLE",
+                null);
+    }
+
+    private static Map<String, CockpitHistoryAnalysisSnapshot.SourceType> types(
+            CockpitHistoryAnalysisSnapshot snapshot) {
+        Map<String, CockpitHistoryAnalysisSnapshot.SourceType> result = new LinkedHashMap<>();
+        if (snapshot.getTypes() != null) {
+            for (CockpitHistoryAnalysisSnapshot.SourceType type : snapshot.getTypes()) {
+                if (type != null && !isBlank(type.getCode())) {
+                    result.putIfAbsent(type.getCode().trim(), type);
+                }
+            }
+        }
+        return result;
+    }
+
+    private static String resolveFamily(String eventFamily) {
+        if (isBlank(eventFamily)) {
+            throw new CockpitInvalidRequestException("eventFamily must be WARNING or ALARM");
+        }
+        String resolved = eventFamily.trim();
+        if (!WARNING.equals(resolved) && !ALARM.equals(resolved)) {
+            throw new CockpitInvalidRequestException("eventFamily must be WARNING or ALARM");
+        }
+        return resolved;
+    }
+
+    private static Integer resolveLevel(String level) {
+        if (isBlank(level)) {
+            return null;
+        }
+        try {
+            int resolved = Integer.parseInt(level.trim());
+            if (resolved < 1 || resolved > 4) {
+                throw new CockpitInvalidRequestException(
+                        "level must be an integer between 1 and 4");
+            }
+            return resolved;
+        } catch (NumberFormatException exception) {
+            throw new CockpitInvalidRequestException(
+                    "level must be an integer between 1 and 4");
+        }
+    }
+
+    private static 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 TimeWindow resolveWindow(String startTime, String endTime) {
+        boolean hasStart = !isBlank(startTime);
+        boolean hasEnd = !isBlank(endTime);
+        if (hasStart != hasEnd) {
+            throw new CockpitInvalidRequestException("start time and end time must be provided together");
+        }
+        if (!hasStart) {
+            OffsetDateTime end = OffsetDateTime.now(clock)
+                    .atZoneSameInstant(BUSINESS_ZONE)
+                    .toOffsetDateTime();
+            return new TimeWindow(end.minusDays(DEFAULT_WINDOW_DAYS), end);
+        }
+        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");
+        }
+        if (Duration.between(start, end).toDays() > MAX_WINDOW_DAYS) {
+            throw new CockpitInvalidRequestException("history window must not exceed 31 days");
+        }
+        return new TimeWindow(start, end);
+    }
+
+    private static 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 static OffsetDateTime parseEventTime(String value) {
+        if (isBlank(value)) {
+            return null;
+        }
+        try {
+            return OffsetDateTime.parse(value);
+        } catch (DateTimeParseException exception) {
+            return null;
+        }
+    }
+
+    private static List<String> sorted(Set<String> values) {
+        List<String> result = new ArrayList<>(values);
+        Collections.sort(result, Comparator.nullsLast(String::compareTo));
+        return result;
+    }
+
+    private String businessTime() {
+        return TIMESTAMP.format(OffsetDateTime.now(clock)
+                .atZoneSameInstant(BUSINESS_ZONE)
+                .toOffsetDateTime());
+    }
+
+    private static String timestamp(OffsetDateTime value) {
+        return TIMESTAMP.format(value.atZoneSameInstant(BUSINESS_ZONE));
+    }
+
+    private static boolean isBlank(String value) {
+        return value == null || value.trim().isEmpty();
+    }
+
+    private static class TimeWindow {
+        private final OffsetDateTime start;
+        private final OffsetDateTime end;
+
+        private TimeWindow(OffsetDateTime start, OffsetDateTime end) {
+            this.start = start;
+            this.end = end;
+        }
+    }
+
+    private static class EventFact {
+        private final String eventNo;
+        private final OffsetDateTime eventTime;
+        private final String deviceCode;
+
+        private EventFact(String eventNo, OffsetDateTime eventTime, String deviceCode) {
+            this.eventNo = eventNo;
+            this.eventTime = eventTime;
+            this.deviceCode = deviceCode;
+        }
+    }
+
+    private static class Bucket {
+        private final Set<String> deviceCodes = new LinkedHashSet<>();
+        private int eventCount;
+        private int handledCount;
+
+        private Bucket() {
+        }
+    }
+}

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

@@ -0,0 +1,191 @@
+package com.zksy.cockpit.event;
+
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Read snapshot for 预警处置 / 历史分析.
+ *
+ * <p>Delivers both event families separately plus optional work-order links used
+ * to resolve the handled disposal scope. A null workOrders list is treated as no
+ * associated work orders (handled count zero).
+ */
+public class CockpitHistoryAnalysisSnapshot {
+    private final String updatedAt;
+    private final List<SourceType> types;
+    private final List<SourceWarning> warnings;
+    private final List<SourceAlarm> alarms;
+    private final List<SourceWorkOrderLink> workOrders;
+
+    public CockpitHistoryAnalysisSnapshot(
+            String updatedAt,
+            List<SourceType> types,
+            List<SourceWarning> warnings,
+            List<SourceAlarm> alarms,
+            List<SourceWorkOrderLink> workOrders) {
+        this.updatedAt = updatedAt;
+        this.types = types;
+        this.warnings = warnings;
+        this.alarms = alarms;
+        this.workOrders = workOrders;
+    }
+
+    public static CockpitHistoryAnalysisSnapshot empty(String updatedAt) {
+        return new CockpitHistoryAnalysisSnapshot(
+                updatedAt,
+                Collections.emptyList(),
+                Collections.emptyList(),
+                Collections.emptyList(),
+                Collections.emptyList());
+    }
+
+    public String getUpdatedAt() {
+        return updatedAt;
+    }
+
+    public List<SourceType> getTypes() {
+        return types;
+    }
+
+    public List<SourceWarning> getWarnings() {
+        return warnings;
+    }
+
+    public List<SourceAlarm> getAlarms() {
+        return alarms;
+    }
+
+    public List<SourceWorkOrderLink> getWorkOrders() {
+        return workOrders;
+    }
+
+    public static class SourceType {
+        private final String code;
+        private final String name;
+        private final String industry;
+        private final String objectKind;
+
+        public SourceType(String code, String name, String industry, String objectKind) {
+            this.code = code;
+            this.name = name;
+            this.industry = industry;
+            this.objectKind = objectKind;
+        }
+
+        public String getCode() {
+            return code;
+        }
+
+        public String getName() {
+            return name;
+        }
+
+        public String getIndustry() {
+            return industry;
+        }
+
+        public String getObjectKind() {
+            return objectKind;
+        }
+    }
+
+    public static class SourceWarning {
+        private final String warningNo;
+        private final Integer level;
+        private final String eventTime;
+        private final String typeCode;
+        private final String deviceCode;
+
+        public SourceWarning(
+                String warningNo,
+                Integer level,
+                String eventTime,
+                String typeCode,
+                String deviceCode) {
+            this.warningNo = warningNo;
+            this.level = level;
+            this.eventTime = eventTime;
+            this.typeCode = typeCode;
+            this.deviceCode = deviceCode;
+        }
+
+        public String getWarningNo() {
+            return warningNo;
+        }
+
+        public Integer getLevel() {
+            return level;
+        }
+
+        public String getEventTime() {
+            return eventTime;
+        }
+
+        public String getTypeCode() {
+            return typeCode;
+        }
+
+        public String getDeviceCode() {
+            return deviceCode;
+        }
+    }
+
+    public static class SourceAlarm {
+        private final String alarmId;
+        private final Integer level;
+        private final String eventTime;
+        private final String typeCode;
+        private final String deviceCode;
+
+        public SourceAlarm(
+                String alarmId,
+                Integer level,
+                String eventTime,
+                String typeCode,
+                String deviceCode) {
+            this.alarmId = alarmId;
+            this.level = level;
+            this.eventTime = eventTime;
+            this.typeCode = typeCode;
+            this.deviceCode = deviceCode;
+        }
+
+        public String getAlarmId() {
+            return alarmId;
+        }
+
+        public Integer getLevel() {
+            return level;
+        }
+
+        public String getEventTime() {
+            return eventTime;
+        }
+
+        public String getTypeCode() {
+            return typeCode;
+        }
+
+        public String getDeviceCode() {
+            return deviceCode;
+        }
+    }
+
+    public static class SourceWorkOrderLink {
+        private final String eventNo;
+        private final Integer stateCode;
+
+        public SourceWorkOrderLink(String eventNo, Integer stateCode) {
+            this.eventNo = eventNo;
+            this.stateCode = stateCode;
+        }
+
+        public String getEventNo() {
+            return eventNo;
+        }
+
+        public Integer getStateCode() {
+            return stateCode;
+        }
+    }
+}

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

@@ -0,0 +1,26 @@
+package com.zksy.cockpit.event;
+
+import org.springframework.stereotype.Service;
+
+import java.time.Clock;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+
+@Service
+public class EmptyCockpitHistoryAnalysisAdapter implements CockpitHistoryAnalysisAdapter {
+    private final Clock clock;
+
+    public EmptyCockpitHistoryAnalysisAdapter(Clock clock) {
+        this.clock = clock;
+    }
+
+    @Override
+    public CockpitHistoryAnalysisSnapshot load() {
+        return CockpitHistoryAnalysisSnapshot.empty(businessTime());
+    }
+
+    private String businessTime() {
+        return DateTimeFormatter.ISO_OFFSET_DATE_TIME
+                .format(clock.instant().atZone(ZoneId.of("Asia/Shanghai")));
+    }
+}