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

feat(cockpit): add warning multidimensional analysis (#42)

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

+ 226 - 0
docs/cockpit/multidimensional-analysis-demo.html

@@ -0,0 +1,226 @@
+<!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; }
+    main { display:grid; grid-template-columns:minmax(380px,470px) 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 8px; font-size:14px; }
+    .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); }
+    .dim { margin-bottom:14px; }
+    .dim h3 { margin:0 0 6px; font-size:13px; font-weight:600; }
+    .dim h3 small { color:var(--muted); font-weight:400; margin-left:6px; }
+    .buckets { display:grid; gap:4px; }
+    .bucket { display:grid; grid-template-columns:minmax(90px,150px) 1fr 54px 54px; gap:8px; align-items:center; cursor:pointer; padding:2px 4px; border-radius:3px; }
+    .bucket:hover { background:#f4f7fa; }
+    .bucket.on { background:#e8f0fe; font-weight:600; }
+    .bucket .bar { height:11px; background:#e6ecf2; border-radius:3px; overflow:hidden; }
+    .bucket .bar i { display:block; height:100%; background:var(--blue); }
+    .bucket.on .bar i { background:#0f8a4d; }
+    .bucket .num { text-align:right; color:var(--muted); font-variant-numeric:tabular-nums; }
+    .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:200px; }
+    .marker { position:absolute; width:14px; height:14px; margin:-7px 0 0 -7px; border-radius:50%; background:#d0342c; border:2px solid #fff; }
+    .label { position:absolute; font-size:11px; transform:translate(9px,-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; }
+    @media (max-width:980px){ 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="totalCount">0</b></span><span>筛选 <b id="filterText">无</b></span><span>更新 <b id="updated">--</b></span></div>
+</header>
+<main>
+  <aside>
+    <div class="toolbar" style="padding:0 0 10px; border-bottom:none">
+      <button id="clearFilter" type="button">清除全部筛选</button>
+      <button id="refresh" type="button">手动刷新</button>
+    </div>
+    <div id="dimensions"></div>
+    <div class="empty" id="empty" style="display:none">当前筛选下没有事件。</div>
+  </aside>
+  <section>
+    <div class="toolbar">
+      <strong>中央一张图(当前筛选事件点位)</strong>
+      <span id="mapHint" style="color:var(--muted)">点击左侧任一桶即可交叉筛选</span>
+    </div>
+    <div class="map" id="map"></div>
+    <div class="caption" id="caption">点击桶后所有图表与地图同步更新</div>
+  </section>
+</main>
+<script>
+  const LEVEL_LABEL = { 1: "RED", 2: "YELLOW", 3: "BLUE", 4: "GREEN" };
+  const INDUSTRY_LABEL = { GAS: "燃气", WATER_SUPPLY: "供水", DRAINAGE: "排水" };
+  const DEVICE_TYPE_LABEL = { GAS_SENSOR: "燃气浓度计", WATER_SUPPLY_SENSOR: "供水压力计", SEWAGE_ENV: "污水环境监测" };
+
+  // Deterministic fixture: type, cause (null = 缺失成因), level, region (null = 未分区), deviceType, day/hour.
+  const fixture = {
+    WARNING: [
+      { no: "MW-001", type: "GAS_CONCENTRATION", cause: "浓度超限", level: 1, region: "城区A", deviceType: "GAS_SENSOR", day: "2026-09-01", hour: 9 },
+      { no: "MW-002", type: "GAS_CONCENTRATION", cause: "浓度超限", level: 1, region: "城区A", deviceType: "GAS_SENSOR", day: "2026-09-01", hour: 10 },
+      { no: "MW-003", type: "WATER_PRESSURE", cause: null, level: 2, region: "城区B", deviceType: "WATER_SUPPLY_SENSOR", day: "2026-09-01", hour: 11 },
+      { no: "MW-004", type: "WATER_PRESSURE", cause: "压力波动", level: 2, region: "城区B", deviceType: "WATER_SUPPLY_SENSOR", day: "2026-09-02", hour: 9 },
+      { no: "MW-005", type: "SEWAGE_CONCENTRATION", cause: "浓度超限", level: 2, region: "城区C", deviceType: "SEWAGE_ENV", day: "2026-09-02", hour: 10 },
+      { no: "MW-006", type: "SEWAGE_CONCENTRATION", cause: "设备故障", level: 3, region: "城区C", deviceType: "SEWAGE_ENV", day: "2026-09-02", hour: 11 },
+      { no: "MW-007", type: "WATER_PRESSURE", cause: null, level: 4, region: null, deviceType: "WATER_SUPPLY_SENSOR", day: "2026-09-02", hour: 12 },
+      { no: "MW-008", type: "GAS_CONCENTRATION", cause: "设备故障", level: 9, region: "城区A", deviceType: "GAS_SENSOR", day: "2026-09-02", hour: 13 }
+    ],
+    ALARM: [
+      { no: "MA-001", type: "GAS_CONCENTRATION", cause: "浓度超限", level: 1, region: "城区A", deviceType: "GAS_SENSOR", day: "2026-09-02", hour: 14 },
+      { no: "MA-002", type: "WATER_PRESSURE", cause: null, level: 2, region: "城区B", deviceType: "WATER_SUPPLY_SENSOR", day: "2026-09-02", hour: 15 }
+    ]
+  };
+  const START = "2026-09-01T00:00:00+08:00";
+  const END = "2026-09-02T23:59:00+08:00";
+
+  let family = "WARNING";
+  const filters = { industry: null, level: null, eventType: null, cause: null, deviceType: null, region: null };
+
+  const industryOf = e => e.deviceType === "GAS_SENSOR" ? "GAS" : e.deviceType === "WATER_SUPPLY_SENSOR" ? "WATER_SUPPLY" : "DRAINAGE";
+  const levelKeyOf = e => (e.level >= 1 && e.level <= 4) ? String(e.level) : "UNKNOWN";
+  const causeKeyOf = e => e.cause || "UNKNOWN_CAUSE";
+  const regionKeyOf = e => e.region || "UNASSIGNED_REGION";
+
+  function scoped() {
+    return fixture[family].filter(e =>
+      (!filters.industry || industryOf(e) === filters.industry) &&
+      (!filters.level || levelKeyOf(e) === String(filters.level)) &&
+      (!filters.eventType || e.type === filters.eventType) &&
+      (!filters.cause || causeKeyOf(e) === filters.cause) &&
+      (!filters.deviceType || e.deviceType === filters.deviceType) &&
+      (!filters.region || regionKeyOf(e) === filters.region));
+  }
+
+  function group(list, keyFn, labelFn) {
+    const counts = new Map();
+    list.forEach(e => {
+      const key = keyFn(e);
+      const row = counts.get(key) || { count: 0, label: labelFn(e, key) };
+      row.count++;
+      counts.set(key, row);
+    });
+    return counts;
+  }
+
+  function renderBuckets(container, title, hint, list, order, keyFn, labelFn, onPick, activeKey) {
+    const counts = group(list, keyFn, labelFn);
+    const keys = order.filter(k => counts.has(k) || !counts.size).concat(
+      [...counts.keys()].filter(k => !order.includes(k)));
+    const max = Math.max(1, ...[...counts.values()].map(v => v.count));
+    const total = list.length;
+    const html = keys.map(k => {
+      const row = counts.get(k) || { count: 0, label: labelFn(null, k) };
+      const pct = total ? (row.count / total * 100).toFixed(2) : "0.00";
+      return `<div class="bucket ${activeKey === k ? "on" : ""}" data-key="${k}">
+        <span>${row.label}</span>
+        <span class="bar"><i style="width:${row.count / max * 100}%"></i></span>
+        <span class="num">${row.count}</span>
+        <span class="num">${pct}%</span>
+      </div>`;
+    }).join("");
+    container.insertAdjacentHTML("beforeend",
+      `<div class="dim"><h3>${title}<small>${hint}</small></h3><div class="buckets">${html}</div></div>`);
+    container.querySelectorAll(".dim:last-child .bucket").forEach(el =>
+      el.addEventListener("click", () => onPick(el.dataset.key)));
+  }
+
+  function render() {
+    const list = scoped();
+    const box = document.getElementById("dimensions");
+    box.innerHTML = "";
+    document.getElementById("empty").style.display = list.length ? "none" : "block";
+    document.getElementById("familyName").textContent = family === "WARNING" ? "预警" : "报警";
+    document.getElementById("totalCount").textContent = list.length;
+    document.getElementById("updated").textContent = new Date().toLocaleTimeString("zh-CN");
+    document.getElementById("filterText").textContent =
+      Object.entries(filters).filter(([, v]) => v).map(([k, v]) => k + "=" + v).join(", ") || "无";
+
+    renderBuckets(box, "事件类型", "按 warningType 聚合", list, [],
+      e => e.type, (e, k) => (e ? e.type : k),
+      key => { filters.eventType = filters.eventType === key ? null : key; render(); }, filters.eventType);
+
+    renderBuckets(box, "成因", "缺失成因保留为 未知/缺失成因", list, ["UNKNOWN_CAUSE"],
+      e => causeKeyOf(e), (e, k) => (k === "UNKNOWN_CAUSE" ? "未知/缺失成因" : (e ? e.cause : k)),
+      key => { filters.cause = filters.cause === key ? null : key; render(); }, filters.cause);
+
+    renderBuckets(box, "行业", "仅正式行业", list, ["GAS", "WATER_SUPPLY", "DRAINAGE"],
+      e => industryOf(e), (e, k) => INDUSTRY_LABEL[k] || k,
+      key => { filters.industry = filters.industry === key ? null : key; render(); }, filters.industry);
+
+    renderBuckets(box, "设备类型", "智能设备类型", list, Object.keys(DEVICE_TYPE_LABEL),
+      e => e.deviceType, (e, k) => DEVICE_TYPE_LABEL[k] || k,
+      key => { filters.deviceType = filters.deviceType === key ? null : key; render(); }, filters.deviceType);
+
+    renderBuckets(box, "等级", "1红 2黄 3蓝 4绿", list, ["1", "2", "3", "4", "UNKNOWN"],
+      e => levelKeyOf(e), (e, k) => LEVEL_LABEL[k] || "UNKNOWN",
+      key => { filters.level = String(filters.level) === key ? null : Number(key); render(); },
+      filters.level === null ? null : String(filters.level));
+
+    renderBuckets(box, "区域", "缺失区域保留为 未分区", list, ["UNASSIGNED_REGION"],
+      e => regionKeyOf(e), (e, k) => (k === "UNASSIGNED_REGION" ? "未分区" : (e ? e.region : k)),
+      key => { filters.region = filters.region === key ? null : key; render(); }, filters.region);
+
+    renderBuckets(box, "时间", "Asia/Shanghai 日桶", list, ["2026-09-01", "2026-09-02"],
+      e => e.day, (e, k) => k,
+      () => {}, null);
+
+    const map = document.getElementById("map");
+    map.innerHTML = "";
+    if (list.length) {
+      const lons = list.map((e, i) => 110.13 + i * 0.006), lats = list.map((e, i) => 28.44 + (i % 4) * 0.009);
+      const minLon = Math.min(...lons), maxLon = Math.max(...lons), minLat = Math.min(...lats), maxLat = Math.max(...lats);
+      list.forEach((e, i) => {
+        const x = 12 + (lons[i] - minLon) / Math.max(0.001, maxLon - minLon) * 76;
+        const y = 12 + (maxLat - lats[i]) / Math.max(0.001, maxLat - minLat) * 76;
+        const el = document.createElement("div");
+        el.className = "marker";
+        el.style.left = x + "%"; el.style.top = y + "%";
+        el.title = e.no;
+        map.appendChild(el);
+        const l = document.createElement("div");
+        l.className = "label"; l.style.left = x + "%"; l.style.top = y + "%";
+        l.textContent = e.no;
+        map.appendChild(l);
+      });
+    }
+    document.getElementById("caption").textContent =
+      `${list.length} 个事件点位 · 窗口 ${START} ~ ${END}`;
+  }
+
+  document.getElementById("tabWARNING").addEventListener("click", () => setFamily("WARNING"));
+  document.getElementById("tabALARM").addEventListener("click", () => setFamily("ALARM"));
+  function setFamily(next) {
+    family = next;
+    Object.keys(filters).forEach(k => filters[k] = null);
+    document.getElementById("tabWARNING").classList.toggle("on", family === "WARNING");
+    document.getElementById("tabALARM").classList.toggle("on", family === "ALARM");
+    render();
+  }
+  document.getElementById("clearFilter").addEventListener("click", () => {
+    Object.keys(filters).forEach(k => filters[k] = null);
+    render();
+  });
+  document.getElementById("refresh").addEventListener("click", render);
+  render();
+</script>
+</body>
+</html>

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

@@ -0,0 +1,82 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.cockpit.event.CockpitMultidimensionalAnalysisService;
+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 CockpitMultidimensionalAnalysisController {
+    private static final String REQUEST_ID_HEADER = "X-Request-Id";
+
+    private final CockpitMultidimensionalAnalysisService analysisService;
+
+    public CockpitMultidimensionalAnalysisController(
+            CockpitMultidimensionalAnalysisService analysisService) {
+        this.analysisService = analysisService;
+    }
+
+    @GetMapping("/api/cockpit/v1/events/multidimensional-analysis")
+    @PreAuthorize("isAuthenticated()")
+    public ResponseEntity<AjaxResult> multidimensionalAnalysis(
+            @RequestParam(value = "eventFamily", required = false) String eventFamily,
+            @RequestParam(value = "industry", required = false) String industry,
+            @RequestParam(value = "level", required = false) String level,
+            @RequestParam(value = "eventType", required = false) String eventType,
+            @RequestParam(value = "cause", required = false) String cause,
+            @RequestParam(value = "deviceType", required = false) String deviceType,
+            @RequestParam(value = "region", required = false) String region,
+            @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(
+                analysisService.load(
+                        resolveRequestId(requestId),
+                        eventFamily,
+                        industry,
+                        level,
+                        eventType,
+                        cause,
+                        deviceType,
+                        region,
+                        startTime,
+                        endTime)));
+    }
+
+    @ExceptionHandler(CockpitInvalidRequestException.class)
+    public ResponseEntity<AjaxResult> handleInvalidRequest(
+            HttpServletRequest request,
+            CockpitInvalidRequestException exception) {
+        return error(HttpStatus.BAD_REQUEST, analysisService.invalidRequest(
+                resolveRequestId(request.getHeader(REQUEST_ID_HEADER)),
+                exception.getMessage()));
+    }
+
+    @ExceptionHandler(CockpitReadException.class)
+    public ResponseEntity<AjaxResult> handleReadFailure(HttpServletRequest request) {
+        return error(HttpStatus.INTERNAL_SERVER_ERROR, analysisService.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();
+    }
+}

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

@@ -0,0 +1,430 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.cockpit.event.CockpitMultidimensionalAnalysisAdapter;
+import com.zksy.cockpit.event.CockpitMultidimensionalAnalysisResponse;
+import com.zksy.cockpit.event.CockpitMultidimensionalAnalysisService;
+import com.zksy.cockpit.event.CockpitMultidimensionalAnalysisSnapshot;
+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 CockpitMultidimensionalAnalysisControllerTest {
+
+    private static final String ENDPOINT = "/api/cockpit/v1/events/multidimensional-analysis";
+    private static final String START = "2026-09-01T00:00:00+08:00";
+    private static final String END = "2026-09-02T23:59:00+08:00";
+
+    private CockpitMultidimensionalAnalysisAdapter adapter;
+    private MockMvc mockMvc;
+
+    @BeforeEach
+    void setUp() {
+        adapter = mock(CockpitMultidimensionalAnalysisAdapter.class);
+        CockpitMultidimensionalAnalysisService service = new CockpitMultidimensionalAnalysisService(
+                adapter,
+                Clock.fixed(Instant.parse("2026-09-02T02:00:00Z"), ZoneOffset.UTC));
+        mockMvc = MockMvcBuilders
+                .standaloneSetup(new CockpitMultidimensionalAnalysisController(service))
+                .build();
+        when(adapter.load()).thenReturn(fixture());
+    }
+
+    @Test
+    void warningFamilyExposesSevenDimensionsWithCodesLabelsAndPercentages() throws Exception {
+        mockMvc.perform(get(ENDPOINT)
+                        .param("eventFamily", "WARNING")
+                        .param("startTime", START)
+                        .param("endTime", END)
+                        .header("X-Request-Id", "analysis-001"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data.requestId").value("analysis-001"))
+                .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.totalCount").value(8))
+                .andExpect(jsonPath("$.data.dimensions", hasSize(7)))
+                .andExpect(jsonPath("$.data.dimensions[0].dimensionCode").value("EVENT_TYPE"))
+                .andExpect(jsonPath("$.data.dimensions[0].dimensionLabel").value("事件类型"))
+                .andExpect(jsonPath("$.data.dimensions[1].dimensionCode").value("CAUSE"))
+                .andExpect(jsonPath("$.data.dimensions[2].dimensionCode").value("INDUSTRY"))
+                .andExpect(jsonPath("$.data.dimensions[3].dimensionCode").value("DEVICE_TYPE"))
+                .andExpect(jsonPath("$.data.dimensions[4].dimensionCode").value("LEVEL"))
+                .andExpect(jsonPath("$.data.dimensions[5].dimensionCode").value("REGION"))
+                .andExpect(jsonPath("$.data.dimensions[6].dimensionCode").value("TIME"))
+                .andExpect(jsonPath("$.data.range.type").value("ANALYSIS_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.mapFilter.eventFamily").value("WARNING"))
+                .andExpect(jsonPath("$.data.mapFilter.deviceCodes", hasSize(8)));
+    }
+
+    @Test
+    void alarmFamilyIsIsolatedFromWarnings() 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.totalCount").value(2))
+                .andExpect(jsonPath("$.data.dimensions[2].buckets[0].bucketCode").value("GAS"))
+                .andExpect(jsonPath("$.data.dimensions[2].buckets[0].count").value(1))
+                .andExpect(jsonPath("$.data.dimensions[2].buckets[1].bucketCode").value("WATER_SUPPLY"))
+                .andExpect(jsonPath("$.data.dimensions[2].buckets[1].count").value(1))
+                .andExpect(jsonPath("$.data.dimensions[2].buckets[2].bucketCode").value("DRAINAGE"))
+                .andExpect(jsonPath("$.data.dimensions[2].buckets[2].count").value(0));
+    }
+
+    @Test
+    void percentagesMatchCountsOverTotal() throws Exception {
+        mockMvc.perform(get(ENDPOINT)
+                        .param("eventFamily", "WARNING")
+                        .param("startTime", START)
+                        .param("endTime", END))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.totalCount").value(8))
+                .andExpect(jsonPath("$.data.dimensions[4].totalCount").value(8))
+                .andExpect(jsonPath("$.data.dimensions[4].buckets[0].bucketCode").value("1"))
+                .andExpect(jsonPath("$.data.dimensions[4].buckets[0].bucketLabel").value("RED"))
+                .andExpect(jsonPath("$.data.dimensions[4].buckets[0].count").value(2))
+                .andExpect(jsonPath("$.data.dimensions[4].buckets[0].percentage").value("25.00"))
+                .andExpect(jsonPath("$.data.dimensions[4].buckets[1].bucketLabel").value("YELLOW"))
+                .andExpect(jsonPath("$.data.dimensions[4].buckets[1].count").value(3))
+                .andExpect(jsonPath("$.data.dimensions[4].buckets[1].percentage").value("37.50"))
+                .andExpect(jsonPath("$.data.dimensions[4].buckets[2].bucketLabel").value("BLUE"))
+                .andExpect(jsonPath("$.data.dimensions[4].buckets[2].count").value(1))
+                .andExpect(jsonPath("$.data.dimensions[4].buckets[3].bucketLabel").value("GREEN"))
+                .andExpect(jsonPath("$.data.dimensions[4].buckets[3].count").value(1))
+                .andExpect(jsonPath("$.data.dimensions[4].buckets[4].bucketCode").value("UNKNOWN"))
+                .andExpect(jsonPath("$.data.dimensions[4].buckets[4].count").value(1));
+    }
+
+    @Test
+    void unknownCauseKeepsAnExplicitBucketWithARealCount() throws Exception {
+        mockMvc.perform(get(ENDPOINT)
+                        .param("eventFamily", "WARNING")
+                        .param("startTime", START)
+                        .param("endTime", END))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.dimensions[1].dimensionCode").value("CAUSE"))
+                .andExpect(jsonPath("$.data.dimensions[1].buckets[?(@.bucketCode=='UNKNOWN_CAUSE')].bucketLabel")
+                        .value("未知/缺失成因"))
+                .andExpect(jsonPath("$.data.dimensions[1].buckets[?(@.bucketCode=='UNKNOWN_CAUSE')].count")
+                        .value(2));
+    }
+
+    @Test
+    void levelDictionaryIsFixedOrderRedYellowBlueGreenUnknown() throws Exception {
+        mockMvc.perform(get(ENDPOINT)
+                        .param("eventFamily", "WARNING")
+                        .param("startTime", START)
+                        .param("endTime", END))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.dimensions[4].buckets", hasSize(5)))
+                .andExpect(jsonPath("$.data.dimensions[4].buckets[0].bucketCode").value("1"))
+                .andExpect(jsonPath("$.data.dimensions[4].buckets[0].bucketLabel").value("RED"))
+                .andExpect(jsonPath("$.data.dimensions[4].buckets[1].bucketCode").value("2"))
+                .andExpect(jsonPath("$.data.dimensions[4].buckets[1].bucketLabel").value("YELLOW"))
+                .andExpect(jsonPath("$.data.dimensions[4].buckets[2].bucketCode").value("3"))
+                .andExpect(jsonPath("$.data.dimensions[4].buckets[2].bucketLabel").value("BLUE"))
+                .andExpect(jsonPath("$.data.dimensions[4].buckets[3].bucketCode").value("4"))
+                .andExpect(jsonPath("$.data.dimensions[4].buckets[3].bucketLabel").value("GREEN"))
+                .andExpect(jsonPath("$.data.dimensions[4].buckets[4].bucketCode").value("UNKNOWN"))
+                .andExpect(jsonPath("$.data.dimensions[4].buckets[4].bucketLabel").value("UNKNOWN"));
+    }
+
+    @Test
+    void industryAndLevelFiltersNarrowEveryDimensionAndMapFilter() throws Exception {
+        mockMvc.perform(get(ENDPOINT)
+                        .param("eventFamily", "WARNING")
+                        .param("industry", "GAS")
+                        .param("level", "1")
+                        .param("startTime", START)
+                        .param("endTime", END))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.filters.industry").value("GAS"))
+                .andExpect(jsonPath("$.data.filters.level").value(1))
+                .andExpect(jsonPath("$.data.totalCount").value(2))
+                .andExpect(jsonPath("$.data.dimensions[0].totalCount").value(2))
+                .andExpect(jsonPath("$.data.dimensions[2].buckets[0].bucketCode").value("GAS"))
+                .andExpect(jsonPath("$.data.dimensions[2].buckets[0].count").value(2))
+                .andExpect(jsonPath("$.data.dimensions[2].buckets[1].count").value(0))
+                .andExpect(jsonPath("$.data.dimensions[2].buckets[2].count").value(0))
+                .andExpect(jsonPath("$.data.mapFilter.industry").value("GAS"))
+                .andExpect(jsonPath("$.data.mapFilter.level").value(1))
+                .andExpect(jsonPath("$.data.mapFilter.deviceCodes", hasSize(2)));
+    }
+
+    @Test
+    void causeRegionEventTypeAndDeviceTypeFiltersNarrowConsistently() throws Exception {
+        mockMvc.perform(get(ENDPOINT)
+                        .param("eventFamily", "WARNING")
+                        .param("cause", "浓度超限")
+                        .param("startTime", START)
+                        .param("endTime", END))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.totalCount").value(3))
+                .andExpect(jsonPath("$.data.filters.cause").value("浓度超限"));
+
+        mockMvc.perform(get(ENDPOINT)
+                        .param("eventFamily", "WARNING")
+                        .param("region", "城区B")
+                        .param("startTime", START)
+                        .param("endTime", END))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.totalCount").value(2))
+                .andExpect(jsonPath("$.data.filters.region").value("城区B"));
+
+        mockMvc.perform(get(ENDPOINT)
+                        .param("eventFamily", "WARNING")
+                        .param("eventType", "GAS_CONCENTRATION")
+                        .param("startTime", START)
+                        .param("endTime", END))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.totalCount").value(3))
+                .andExpect(jsonPath("$.data.filters.eventType").value("GAS_CONCENTRATION"));
+
+        mockMvc.perform(get(ENDPOINT)
+                        .param("eventFamily", "WARNING")
+                        .param("deviceType", "WATER_SUPPLY_SENSOR")
+                        .param("startTime", START)
+                        .param("endTime", END))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.totalCount").value(3))
+                .andExpect(jsonPath("$.data.filters.deviceType").value("WATER_SUPPLY_SENSOR"));
+    }
+
+    @Test
+    void emptyWindowKeepsEveryDimensionWithZeroPercentages() throws Exception {
+        mockMvc.perform(get(ENDPOINT)
+                        .param("eventFamily", "WARNING")
+                        .param("startTime", "2026-09-10T00:00:00+08:00")
+                        .param("endTime", "2026-09-11T00:00:00+08:00"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.totalCount").value(0))
+                .andExpect(jsonPath("$.data.dimensions", hasSize(7)))
+                .andExpect(jsonPath("$.data.dimensions[4].buckets[0].count").value(0))
+                .andExpect(jsonPath("$.data.dimensions[4].buckets[0].percentage").value("0.00"))
+                .andExpect(jsonPath("$.data.dimensions[1].buckets[0].percentage").value("0.00"))
+                .andExpect(jsonPath("$.data.dimensions[2].buckets[0].percentage").value("0.00"))
+                .andExpect(jsonPath("$.data.mapFilter.deviceCodes", hasSize(0)));
+    }
+
+    @Test
+    void timeDimensionUsesAsiaShanghaiDayBoundaries() throws Exception {
+        mockMvc.perform(get(ENDPOINT)
+                        .param("eventFamily", "WARNING")
+                        .param("startTime", START)
+                        .param("endTime", END))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.dimensions[6].buckets", hasSize(2)))
+                .andExpect(jsonPath("$.data.dimensions[6].buckets[0].bucketCode")
+                        .value("2026-09-01T00:00:00+08:00"))
+                .andExpect(jsonPath("$.data.dimensions[6].buckets[0].count").value(3))
+                .andExpect(jsonPath("$.data.dimensions[6].buckets[1].bucketCode")
+                        .value("2026-09-02T00:00:00+08:00"))
+                .andExpect(jsonPath("$.data.dimensions[6].buckets[1].count").value(5));
+    }
+
+    @Test
+    void noAccuracyOrTruthfulnessFieldsExist() throws Exception {
+        mockMvc.perform(get(ENDPOINT).param("eventFamily", "WARNING"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.accuracy").doesNotExist())
+                .andExpect(jsonPath("$.data.truthfulness").doesNotExist())
+                .andExpect(jsonPath("$.data.falsePositiveRate").doesNotExist())
+                .andExpect(jsonPath("$.data.dimensions[?(@.dimensionCode=='ACCURACY')]").isEmpty());
+    }
+
+    @Test
+    void invalidFamilyIndustryLevelOrWindowReturnsTypedBadRequestBeforeAdapter() 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("industry", "MANHOLE"))
+                .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("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("analysis window must not exceed 31 days"));
+
+        verifyNoInteractions(adapter);
+    }
+
+    @Test
+    void defaultWindowCoversSevenDays() throws Exception {
+        mockMvc.perform(get(ENDPOINT).param("eventFamily", "WARNING"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.range.endTime").value("2026-09-02T10:00:00+08:00"))
+                .andExpect(jsonPath("$.data.range.startTime").value("2026-08-26T10:00:00+08:00"))
+                .andExpect(jsonPath("$.data.dimensions[6].buckets", hasSize(8)));
+    }
+
+    @Test
+    void serverFailureReturnsTypedServerError() throws Exception {
+        when(adapter.load()).thenThrow(new CockpitReadException("upstream unavailable"));
+
+        mockMvc.perform(get(ENDPOINT)
+                        .param("eventFamily", "WARNING")
+                        .header("X-Request-Id", "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("analysis-500"));
+    }
+
+    @Test
+    void mutatingRequestIsRejectedBeforeReadAdapterIsCalled() throws Exception {
+        mockMvc.perform(post(ENDPOINT))
+                .andExpect(status().isMethodNotAllowed());
+
+        verifyNoInteractions(adapter);
+    }
+
+    @Test
+    void controllerOnlyExposesAuthenticatedReadOnlyOperations() {
+        assertTrue(Arrays.stream(CockpitMultidimensionalAnalysisController.class.getDeclaredMethods())
+                .filter(this::hasRequestMappingAnnotation)
+                .allMatch(CockpitMultidimensionalAnalysisControllerTest::isReadOnlyMappedMethod));
+
+        assertTrue(Arrays.stream(CockpitMultidimensionalAnalysisController.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 static CockpitMultidimensionalAnalysisSnapshot fixture() {
+        return new CockpitMultidimensionalAnalysisSnapshot(
+                "2026-09-02T23:59:50+08:00",
+                Arrays.asList(
+                        type("GAS_SENSOR", "燃气浓度计", "GAS"),
+                        type("WATER_SUPPLY_SENSOR", "供水压力计", "WATER_SUPPLY"),
+                        type("SEWAGE_ENV", "污水环境监测", "DRAINAGE"),
+                        new CockpitMultidimensionalAnalysisSnapshot.SourceType(
+                                "WATER_PLANT", "水厂", "WATER_SUPPLY", "FACILITY"),
+                        new CockpitMultidimensionalAnalysisSnapshot.SourceType(
+                                "SMART_MANHOLE", "智能井盖", null, "SMART_DEVICE")),
+                Arrays.asList(
+                        warning("MW-001", "GAS_CONCENTRATION", "浓度超限", 1,
+                                "2026-09-01T09:00:00+08:00", "城区A", "GAS_SENSOR", "D-GAS-1"),
+                        warning("MW-002", "GAS_CONCENTRATION", "浓度超限", 1,
+                                "2026-09-01T10:00:00+08:00", "城区A", "GAS_SENSOR", "D-GAS-2"),
+                        warning("MW-003", "WATER_PRESSURE", null, 2,
+                                "2026-09-01T11:00:00+08:00", "城区B", "WATER_SUPPLY_SENSOR", "D-WS-1"),
+                        warning("MW-004", "WATER_PRESSURE", "压力波动", 2,
+                                "2026-09-02T09:00:00+08:00", "城区B", "WATER_SUPPLY_SENSOR", "D-WS-2"),
+                        warning("MW-005", "SEWAGE_CONCENTRATION", "浓度超限", 2,
+                                "2026-09-02T10:00:00+08:00", "城区C", "SEWAGE_ENV", "D-DR-1"),
+                        warning("MW-006", "SEWAGE_CONCENTRATION", "设备故障", 3,
+                                "2026-09-02T11:00:00+08:00", "城区C", "SEWAGE_ENV", "D-DR-2"),
+                        warning("MW-007", "WATER_PRESSURE", null, 4,
+                                "2026-09-02T12:00:00+08:00", null, "WATER_SUPPLY_SENSOR", "D-WS-3"),
+                        warning("MW-008", "GAS_CONCENTRATION", "设备故障", 9,
+                                "2026-09-02T13:00:00+08:00", "城区A", "GAS_SENSOR", "D-GAS-3"),
+                        warning("MW-LEGACY", "LEGACY_TYPE", "old", 1,
+                                "2026-09-02T13:30:00+08:00", "城区A", "LEGACY", "D-LEGACY"),
+                        warning("MW-MISMATCH", "FACILITY_TYPE", "x", 1,
+                                "2026-09-02T13:40:00+08:00", "城区A", "WATER_PLANT", "D-PLANT"),
+                        warning("MW-MH", "MANHOLE_TYPE", "x", 1,
+                                "2026-09-02T13:50:00+08:00", "城区A", "SMART_MANHOLE", "D-MH")),
+                Arrays.asList(
+                        alarm("MA-001", "GAS_CONCENTRATION", "浓度超限", 1,
+                                "2026-09-02T14:00:00+08:00", "城区A", "GAS_SENSOR", "D-GAS-9"),
+                        alarm("MA-002", "WATER_PRESSURE", null, 2,
+                                "2026-09-02T15:00:00+08:00", "城区B", "WATER_SUPPLY_SENSOR", "D-WS-9")));
+    }
+
+    private static CockpitMultidimensionalAnalysisSnapshot.SourceType type(
+            String code, String name, String industry) {
+        return new CockpitMultidimensionalAnalysisSnapshot.SourceType(
+                code, name, industry, "SMART_DEVICE");
+    }
+
+    private static CockpitMultidimensionalAnalysisSnapshot.SourceWarning warning(
+            String no, String eventType, String cause, Integer level, String eventTime,
+            String region, String typeCode, String deviceCode) {
+        return new CockpitMultidimensionalAnalysisSnapshot.SourceWarning(
+                no, eventType, cause, level, eventTime, region, typeCode, deviceCode);
+    }
+
+    private static CockpitMultidimensionalAnalysisSnapshot.SourceAlarm alarm(
+            String id, String eventType, String cause, Integer level, String eventTime,
+            String region, String typeCode, String deviceCode) {
+        return new CockpitMultidimensionalAnalysisSnapshot.SourceAlarm(
+                id, eventType, cause, level, eventTime, region, typeCode, deviceCode);
+    }
+}

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

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

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

@@ -0,0 +1,178 @@
+package com.zksy.cockpit.event;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+public class CockpitMultidimensionalAnalysisResponse {
+    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 int totalCount;
+    private final MapFilter mapFilter;
+    private final List<Dimension> dimensions;
+
+    public CockpitMultidimensionalAnalysisResponse(
+            String requestId,
+            String apiVersion,
+            boolean readOnly,
+            String displayName,
+            String timezone,
+            String eventFamily,
+            Range range,
+            String updatedAt,
+            Filters filters,
+            int totalCount,
+            MapFilter mapFilter,
+            List<Dimension> dimensions) {
+        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.totalCount = totalCount;
+        this.mapFilter = mapFilter;
+        this.dimensions = dimensions;
+    }
+
+    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 int getTotalCount() { return totalCount; }
+    public MapFilter getMapFilter() { return mapFilter; }
+    public List<Dimension> getDimensions() { return dimensions; }
+
+    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 String industry;
+        private final Integer level;
+        private final String eventType;
+        private final String cause;
+        private final String deviceType;
+        private final String region;
+
+        public Filters(String eventFamily, String industry, Integer level, String eventType,
+                String cause, String deviceType, String region) {
+            this.eventFamily = eventFamily;
+            this.industry = industry;
+            this.level = level;
+            this.eventType = eventType;
+            this.cause = cause;
+            this.deviceType = deviceType;
+            this.region = region;
+        }
+
+        public String getEventFamily() { return eventFamily; }
+        public String getIndustry() { return industry; }
+        public Integer getLevel() { return level; }
+        public String getEventType() { return eventType; }
+        public String getCause() { return cause; }
+        public String getDeviceType() { return deviceType; }
+        public String getRegion() { return region; }
+    }
+
+    public static class MapFilter {
+        private final String eventFamily;
+        private final String industry;
+        private final Integer level;
+        private final String eventType;
+        private final String cause;
+        private final String deviceType;
+        private final String region;
+        private final List<String> deviceCodes;
+
+        public MapFilter(String eventFamily, String industry, Integer level, String eventType,
+                String cause, String deviceType, String region, List<String> deviceCodes) {
+            this.eventFamily = eventFamily;
+            this.industry = industry;
+            this.level = level;
+            this.eventType = eventType;
+            this.cause = cause;
+            this.deviceType = deviceType;
+            this.region = region;
+            this.deviceCodes = deviceCodes;
+        }
+
+        public String getEventFamily() { return eventFamily; }
+        public String getIndustry() { return industry; }
+        public Integer getLevel() { return level; }
+        public String getEventType() { return eventType; }
+        public String getCause() { return cause; }
+        public String getDeviceType() { return deviceType; }
+        public String getRegion() { return region; }
+        public List<String> getDeviceCodes() { return deviceCodes; }
+    }
+
+    public static class Dimension {
+        private final String dimensionCode;
+        private final String dimensionLabel;
+        private final int totalCount;
+        private final List<Bucket> buckets;
+
+        public Dimension(String dimensionCode, String dimensionLabel, int totalCount, List<Bucket> buckets) {
+            this.dimensionCode = dimensionCode;
+            this.dimensionLabel = dimensionLabel;
+            this.totalCount = totalCount;
+            this.buckets = buckets;
+        }
+
+        public String getDimensionCode() { return dimensionCode; }
+        public String getDimensionLabel() { return dimensionLabel; }
+        public int getTotalCount() { return totalCount; }
+        public List<Bucket> getBuckets() { return buckets; }
+    }
+
+    public static class Bucket {
+        private final String bucketCode;
+        private final String bucketLabel;
+        private final int count;
+        private final BigDecimal percentage;
+
+        public Bucket(String bucketCode, String bucketLabel, int count, BigDecimal percentage) {
+            this.bucketCode = bucketCode;
+            this.bucketLabel = bucketLabel;
+            this.count = count;
+            this.percentage = percentage;
+        }
+
+        public String getBucketCode() { return bucketCode; }
+        public String getBucketLabel() { return bucketLabel; }
+        public int getCount() { return count; }
+
+        @JsonFormat(shape = JsonFormat.Shape.STRING)
+        public BigDecimal getPercentage() { return percentage; }
+    }
+}

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

@@ -0,0 +1,581 @@
+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 multidimensional aggregation for 预警处置 / 预警分析.
+ *
+ * <p>Aggregates one dimension at a time over the same filtered event set so every
+ * chart stays mutually consistent. Unknown / missing cause keeps its own explicit
+ * bucket (UNKNOWN_CAUSE) instead of being silently dropped.
+ */
+@Service
+public class CockpitMultidimensionalAnalysisService {
+    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 String UNKNOWN_CAUSE = "UNKNOWN_CAUSE";
+    private static final String UNKNOWN_CAUSE_LABEL = "未知/缺失成因";
+    private static final String UNKNOWN_EVENT_TYPE = "UNKNOWN_EVENT_TYPE";
+    private static final String UNKNOWN_EVENT_TYPE_LABEL = "未知事件类型";
+    private static final String UNASSIGNED_REGION = "UNASSIGNED_REGION";
+    private static final String UNASSIGNED_REGION_LABEL = "未分区";
+    private static final String UNKNOWN_LEVEL = "UNKNOWN";
+    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 static final DateTimeFormatter DAY =
+            DateTimeFormatter.ofPattern("yyyy-MM-dd'T'00:00:00XXX");
+
+    private final CockpitMultidimensionalAnalysisAdapter adapter;
+    private final Clock clock;
+    private CockpitMultidimensionalAnalysisSnapshot state;
+
+    public CockpitMultidimensionalAnalysisService(
+            CockpitMultidimensionalAnalysisAdapter adapter,
+            Clock clock) {
+        this.adapter = adapter;
+        this.clock = clock;
+    }
+
+    public synchronized CockpitMultidimensionalAnalysisResponse load(
+            String requestId,
+            String eventFamily,
+            String industry,
+            String level,
+            String eventType,
+            String cause,
+            String deviceType,
+            String region,
+            String startTime,
+            String endTime) {
+        String family = resolveFamily(eventFamily);
+        String selectedIndustry = resolveIndustry(industry);
+        Integer selectedLevel = resolveLevel(level);
+        String selectedType = blankToNull(eventType);
+        String selectedCause = blankToNull(cause);
+        String selectedDeviceType = blankToNull(deviceType);
+        String selectedRegion = blankToNull(region);
+        TimeWindow window = resolveWindow(startTime, endTime);
+        if (state == null) {
+            CockpitMultidimensionalAnalysisSnapshot loaded = adapter.load();
+            state = loaded == null
+                    ? CockpitMultidimensionalAnalysisSnapshot.empty(businessTime())
+                    : loaded;
+        }
+        return build(requestId, family, selectedIndustry, selectedLevel, selectedType,
+                selectedCause, selectedDeviceType, selectedRegion, 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 CockpitMultidimensionalAnalysisResponse build(
+            String requestId,
+            String family,
+            String industry,
+            Integer level,
+            String eventType,
+            String cause,
+            String deviceType,
+            String region,
+            TimeWindow window) {
+        Map<String, CockpitMultidimensionalAnalysisSnapshot.SourceType> types = types(state);
+        List<Fact> facts = WARNING.equals(family)
+                ? warningFacts(state, types)
+                : alarmFacts(state, types);
+
+        List<Fact> scoped = new ArrayList<>();
+        for (Fact fact : facts) {
+            if (!inWindow(fact, window)) {
+                continue;
+            }
+            if (industry != null && !industry.equals(fact.industry)) {
+                continue;
+            }
+            if (level != null && !level.equals(fact.level)) {
+                continue;
+            }
+            if (eventType != null && !eventType.equals(fact.eventType)) {
+                continue;
+            }
+            if (cause != null && !cause.equals(fact.causeKey())) {
+                continue;
+            }
+            if (deviceType != null && !deviceType.equals(fact.deviceType)) {
+                continue;
+            }
+            if (region != null && !region.equals(fact.regionKey())) {
+                continue;
+            }
+            scoped.add(fact);
+        }
+
+        int total = scoped.size();
+        Set<String> deviceCodes = new LinkedHashSet<>();
+        for (Fact fact : scoped) {
+            if (!isBlank(fact.deviceCode)) {
+                deviceCodes.add(fact.deviceCode.trim());
+            }
+        }
+
+        List<CockpitMultidimensionalAnalysisResponse.Dimension> dimensions = new ArrayList<>();
+        dimensions.add(dimension("EVENT_TYPE", "事件类型", total, eventTypeBuckets(scoped)));
+        dimensions.add(dimension("CAUSE", "成因", total, causeBuckets(scoped)));
+        dimensions.add(dimension("INDUSTRY", "行业", total, industryBuckets(scoped)));
+        dimensions.add(dimension("DEVICE_TYPE", "设备类型", total, deviceTypeBuckets(scoped, types)));
+        dimensions.add(dimension("LEVEL", "等级", total, levelBuckets(scoped)));
+        dimensions.add(dimension("REGION", "区域", total, regionBuckets(scoped)));
+        dimensions.add(dimension("TIME", "时间", total, timeBuckets(scoped, window)));
+
+        return new CockpitMultidimensionalAnalysisResponse(
+                requestId,
+                "v1",
+                true,
+                "预警分析",
+                "Asia/Shanghai",
+                family,
+                new CockpitMultidimensionalAnalysisResponse.Range(
+                        "ANALYSIS_WINDOW",
+                        timestamp(window.start),
+                        timestamp(window.end)),
+                isBlank(state.getUpdatedAt()) ? businessTime() : state.getUpdatedAt(),
+                new CockpitMultidimensionalAnalysisResponse.Filters(
+                        family, industry, level, eventType, cause, deviceType, region),
+                total,
+                new CockpitMultidimensionalAnalysisResponse.MapFilter(
+                        family, industry, level, eventType, cause, deviceType, region, sorted(deviceCodes)),
+                dimensions);
+    }
+
+    private static CockpitMultidimensionalAnalysisResponse.Dimension dimension(
+            String code, String label, int total, List<CockpitMultidimensionalAnalysisResponse.Bucket> buckets) {
+        return new CockpitMultidimensionalAnalysisResponse.Dimension(code, label, total, buckets);
+    }
+
+    private static List<CockpitMultidimensionalAnalysisResponse.Bucket> eventTypeBuckets(List<Fact> facts) {
+        Map<String, Integer> counts = new LinkedHashMap<>();
+        Map<String, String> labels = new LinkedHashMap<>();
+        for (Fact fact : facts) {
+            String code = isBlank(fact.eventType) ? UNKNOWN_EVENT_TYPE : fact.eventType.trim();
+            String label = isBlank(fact.eventType) ? UNKNOWN_EVENT_TYPE_LABEL : fact.eventType.trim();
+            counts.merge(code, 1, Integer::sum);
+            labels.putIfAbsent(code, label);
+        }
+        return orderedBuckets(counts, labels, facts.size(), Comparator.naturalOrder());
+    }
+
+    private static List<CockpitMultidimensionalAnalysisResponse.Bucket> causeBuckets(List<Fact> facts) {
+        Map<String, Integer> counts = new LinkedHashMap<>();
+        Map<String, String> labels = new LinkedHashMap<>();
+        for (Fact fact : facts) {
+            String code = fact.causeKey();
+            String label = isBlank(fact.cause) ? UNKNOWN_CAUSE_LABEL : fact.cause.trim();
+            counts.merge(code, 1, Integer::sum);
+            labels.putIfAbsent(code, label);
+        }
+        // The unknown bucket is always present so the cockpit never hides missing causes.
+        counts.putIfAbsent(UNKNOWN_CAUSE, 0);
+        labels.putIfAbsent(UNKNOWN_CAUSE, UNKNOWN_CAUSE_LABEL);
+        return orderedBuckets(counts, labels, facts.size(), Comparator.naturalOrder());
+    }
+
+    private static List<CockpitMultidimensionalAnalysisResponse.Bucket> industryBuckets(
+            List<Fact> facts) {
+        Map<String, Integer> counts = new LinkedHashMap<>();
+        Map<String, String> labels = new LinkedHashMap<>();
+        for (String industry : INDUSTRIES) {
+            counts.put(industry, 0);
+            labels.put(industry, industry);
+        }
+        for (Fact fact : facts) {
+            if (fact.industry != null) {
+                counts.merge(fact.industry, 1, Integer::sum);
+            }
+        }
+        return orderedBuckets(counts, labels, facts.size(),
+                Comparator.comparingInt(CockpitMultidimensionalAnalysisService::industryIndex));
+    }
+
+    private static List<CockpitMultidimensionalAnalysisResponse.Bucket> deviceTypeBuckets(
+            List<Fact> facts,
+            Map<String, CockpitMultidimensionalAnalysisSnapshot.SourceType> types) {
+        Map<String, Integer> counts = new LinkedHashMap<>();
+        Map<String, String> labels = new LinkedHashMap<>();
+        for (CockpitMultidimensionalAnalysisSnapshot.SourceType type : types.values()) {
+            if (SMART_DEVICE.equals(type.getObjectKind())
+                    && type.getIndustry() != null
+                    && INDUSTRIES.contains(type.getIndustry())) {
+                counts.putIfAbsent(type.getCode(), 0);
+                labels.putIfAbsent(type.getCode(),
+                        isBlank(type.getName()) ? type.getCode() : type.getName());
+            }
+        }
+        for (Fact fact : facts) {
+            if (fact.deviceType != null) {
+                counts.merge(fact.deviceType, 1, Integer::sum);
+            }
+        }
+        return orderedBuckets(counts, labels, facts.size(), Comparator.naturalOrder());
+    }
+
+    private static List<CockpitMultidimensionalAnalysisResponse.Bucket> levelBuckets(List<Fact> facts) {
+        Map<String, Integer> counts = new LinkedHashMap<>();
+        Map<String, String> labels = new LinkedHashMap<>();
+        for (int code = 1; code <= 4; code++) {
+            labels.put(String.valueOf(code), CockpitEventLevelColor.resolve(code));
+            counts.put(String.valueOf(code), 0);
+        }
+        labels.put(UNKNOWN_LEVEL, UNKNOWN_LEVEL);
+        counts.put(UNKNOWN_LEVEL, 0);
+        for (Fact fact : facts) {
+            String key = fact.level == null || fact.level < 1 || fact.level > 4
+                    ? UNKNOWN_LEVEL
+                    : String.valueOf(fact.level);
+            counts.merge(key, 1, Integer::sum);
+        }
+        List<String> order = Arrays.asList("1", "2", "3", "4", UNKNOWN_LEVEL);
+        return orderedBuckets(counts, labels, facts.size(),
+                Comparator.comparingInt(order::indexOf));
+    }
+
+    private static List<CockpitMultidimensionalAnalysisResponse.Bucket> regionBuckets(List<Fact> facts) {
+        Map<String, Integer> counts = new LinkedHashMap<>();
+        Map<String, String> labels = new LinkedHashMap<>();
+        for (Fact fact : facts) {
+            String code = fact.regionKey();
+            String label = isBlank(fact.region) ? UNASSIGNED_REGION_LABEL : fact.region.trim();
+            counts.merge(code, 1, Integer::sum);
+            labels.putIfAbsent(code, label);
+        }
+        return orderedBuckets(counts, labels, facts.size(), Comparator.naturalOrder());
+    }
+
+    private static List<CockpitMultidimensionalAnalysisResponse.Bucket> timeBuckets(
+            List<Fact> facts, TimeWindow window) {
+        ZonedDateTime first = window.start.atZoneSameInstant(BUSINESS_ZONE).truncatedTo(ChronoUnit.DAYS);
+        ZonedDateTime last = window.end.atZoneSameInstant(BUSINESS_ZONE).truncatedTo(ChronoUnit.DAYS);
+        Map<String, Integer> counts = new LinkedHashMap<>();
+        Map<String, String> labels = new LinkedHashMap<>();
+        for (ZonedDateTime day = first; !day.isAfter(last); day = day.plusDays(1)) {
+            String key = DAY.format(day);
+            counts.put(key, 0);
+            labels.put(key, key);
+        }
+        for (Fact fact : facts) {
+            ZonedDateTime day = fact.eventTime.atZoneSameInstant(BUSINESS_ZONE).truncatedTo(ChronoUnit.DAYS);
+            String key = DAY.format(day);
+            if (counts.containsKey(key)) {
+                counts.merge(key, 1, Integer::sum);
+            }
+        }
+        return orderedBuckets(counts, labels, facts.size(), Comparator.naturalOrder());
+    }
+
+    private static List<CockpitMultidimensionalAnalysisResponse.Bucket> orderedBuckets(
+            Map<String, Integer> counts,
+            Map<String, String> labels,
+            int total,
+            Comparator<String> order) {
+        List<String> keys = new ArrayList<>(counts.keySet());
+        keys.sort(order);
+        List<CockpitMultidimensionalAnalysisResponse.Bucket> buckets = new ArrayList<>();
+        for (String key : keys) {
+            buckets.add(new CockpitMultidimensionalAnalysisResponse.Bucket(
+                    key,
+                    labels.getOrDefault(key, key),
+                    counts.get(key),
+                    percentage(counts.get(key), total)));
+        }
+        return buckets;
+    }
+
+    private static BigDecimal percentage(int count, int total) {
+        if (total <= 0) {
+            return BigDecimal.ZERO.setScale(2);
+        }
+        return BigDecimal.valueOf(count)
+                .multiply(BigDecimal.valueOf(100))
+                .divide(BigDecimal.valueOf(total), 2, RoundingMode.HALF_UP);
+    }
+
+    private List<Fact> warningFacts(
+            CockpitMultidimensionalAnalysisSnapshot snapshot,
+            Map<String, CockpitMultidimensionalAnalysisSnapshot.SourceType> types) {
+        List<Fact> facts = new ArrayList<>();
+        if (snapshot.getWarnings() == null) {
+            return facts;
+        }
+        for (CockpitMultidimensionalAnalysisSnapshot.SourceWarning warning : snapshot.getWarnings()) {
+            if (warning == null) {
+                continue;
+            }
+            CockpitMultidimensionalAnalysisSnapshot.SourceType type = resolveType(types, warning.getTypeCode());
+            if (type == null) {
+                continue;
+            }
+            facts.add(new Fact(
+                    warning.getWarningType(),
+                    warning.getCause(),
+                    warning.getLevel(),
+                    parseEventTime(warning.getEventTime()),
+                    warning.getRegion(),
+                    type.getIndustry(),
+                    type.getCode(),
+                    warning.getDeviceCode()));
+        }
+        return facts;
+    }
+
+    private List<Fact> alarmFacts(
+            CockpitMultidimensionalAnalysisSnapshot snapshot,
+            Map<String, CockpitMultidimensionalAnalysisSnapshot.SourceType> types) {
+        List<Fact> facts = new ArrayList<>();
+        if (snapshot.getAlarms() == null) {
+            return facts;
+        }
+        for (CockpitMultidimensionalAnalysisSnapshot.SourceAlarm alarm : snapshot.getAlarms()) {
+            if (alarm == null) {
+                continue;
+            }
+            CockpitMultidimensionalAnalysisSnapshot.SourceType type = resolveType(types, alarm.getTypeCode());
+            if (type == null) {
+                continue;
+            }
+            facts.add(new Fact(
+                    alarm.getWarningType(),
+                    alarm.getCause(),
+                    alarm.getLevel(),
+                    parseEventTime(alarm.getEventTime()),
+                    alarm.getRegion(),
+                    type.getIndustry(),
+                    type.getCode(),
+                    alarm.getDeviceCode()));
+        }
+        return facts;
+    }
+
+    private static CockpitMultidimensionalAnalysisSnapshot.SourceType resolveType(
+            Map<String, CockpitMultidimensionalAnalysisSnapshot.SourceType> types,
+            String typeCode) {
+        if (isBlank(typeCode)) {
+            return null;
+        }
+        CockpitMultidimensionalAnalysisSnapshot.SourceType type = types.get(typeCode.trim());
+        if (type == null
+                || !SMART_DEVICE.equals(type.getObjectKind())
+                || type.getIndustry() == null
+                || !INDUSTRIES.contains(type.getIndustry())) {
+            return null;
+        }
+        return type;
+    }
+
+    private static boolean inWindow(Fact fact, TimeWindow window) {
+        if (fact.eventTime == null) {
+            return false;
+        }
+        return !fact.eventTime.isBefore(window.start) && !fact.eventTime.isAfter(window.end);
+    }
+
+    private static Map<String, CockpitMultidimensionalAnalysisSnapshot.SourceType> types(
+            CockpitMultidimensionalAnalysisSnapshot snapshot) {
+        Map<String, CockpitMultidimensionalAnalysisSnapshot.SourceType> result = new LinkedHashMap<>();
+        if (snapshot.getTypes() != null) {
+            for (CockpitMultidimensionalAnalysisSnapshot.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 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 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 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("analysis 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 int industryIndex(String industry) {
+        int index = INDUSTRIES.indexOf(industry);
+        return index < 0 ? Integer.MAX_VALUE : index;
+    }
+
+    private static List<String> sorted(Set<String> values) {
+        List<String> result = new ArrayList<>(values);
+        Collections.sort(result, Comparator.nullsLast(String::compareTo));
+        return result;
+    }
+
+    private static String blankToNull(String value) {
+        return isBlank(value) ? null : value.trim();
+    }
+
+    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 Fact {
+        private final String eventType;
+        private final String cause;
+        private final Integer level;
+        private final OffsetDateTime eventTime;
+        private final String region;
+        private final String industry;
+        private final String deviceType;
+        private final String deviceCode;
+
+        private Fact(String eventType, String cause, Integer level, OffsetDateTime eventTime,
+                String region, String industry, String deviceType, String deviceCode) {
+            this.eventType = eventType;
+            this.cause = cause;
+            this.level = level;
+            this.eventTime = eventTime;
+            this.region = region;
+            this.industry = industry;
+            this.deviceType = deviceType;
+            this.deviceCode = deviceCode;
+        }
+
+        private String causeKey() {
+            return isBlank(cause) ? UNKNOWN_CAUSE : cause.trim();
+        }
+
+        private String regionKey() {
+            return isBlank(region) ? UNASSIGNED_REGION : region.trim();
+        }
+    }
+}

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

@@ -0,0 +1,139 @@
+package com.zksy.cockpit.event;
+
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Read snapshot for 预警处置 / 预警分析.
+ *
+ * <p>Events carry the raw dimension values (type, cause, region, level, device
+ * type) so the service can aggregate every dimension consistently and keep an
+ * explicit bucket for unknown / missing causes.
+ */
+public class CockpitMultidimensionalAnalysisSnapshot {
+    private final String updatedAt;
+    private final List<SourceType> types;
+    private final List<SourceWarning> warnings;
+    private final List<SourceAlarm> alarms;
+
+    public CockpitMultidimensionalAnalysisSnapshot(
+            String updatedAt,
+            List<SourceType> types,
+            List<SourceWarning> warnings,
+            List<SourceAlarm> alarms) {
+        this.updatedAt = updatedAt;
+        this.types = types;
+        this.warnings = warnings;
+        this.alarms = alarms;
+    }
+
+    public static CockpitMultidimensionalAnalysisSnapshot empty(String updatedAt) {
+        return new CockpitMultidimensionalAnalysisSnapshot(
+                updatedAt,
+                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 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 String warningType;
+        private final String cause;
+        private final Integer level;
+        private final String eventTime;
+        private final String region;
+        private final String typeCode;
+        private final String deviceCode;
+
+        public SourceWarning(
+                String warningNo,
+                String warningType,
+                String cause,
+                Integer level,
+                String eventTime,
+                String region,
+                String typeCode,
+                String deviceCode) {
+            this.warningNo = warningNo;
+            this.warningType = warningType;
+            this.cause = cause;
+            this.level = level;
+            this.eventTime = eventTime;
+            this.region = region;
+            this.typeCode = typeCode;
+            this.deviceCode = deviceCode;
+        }
+
+        public String getWarningNo() { return warningNo; }
+        public String getWarningType() { return warningType; }
+        public String getCause() { return cause; }
+        public Integer getLevel() { return level; }
+        public String getEventTime() { return eventTime; }
+        public String getRegion() { return region; }
+        public String getTypeCode() { return typeCode; }
+        public String getDeviceCode() { return deviceCode; }
+    }
+
+    public static class SourceAlarm {
+        private final String alarmId;
+        private final String warningType;
+        private final String cause;
+        private final Integer level;
+        private final String eventTime;
+        private final String region;
+        private final String typeCode;
+        private final String deviceCode;
+
+        public SourceAlarm(
+                String alarmId,
+                String warningType,
+                String cause,
+                Integer level,
+                String eventTime,
+                String region,
+                String typeCode,
+                String deviceCode) {
+            this.alarmId = alarmId;
+            this.warningType = warningType;
+            this.cause = cause;
+            this.level = level;
+            this.eventTime = eventTime;
+            this.region = region;
+            this.typeCode = typeCode;
+            this.deviceCode = deviceCode;
+        }
+
+        public String getAlarmId() { return alarmId; }
+        public String getWarningType() { return warningType; }
+        public String getCause() { return cause; }
+        public Integer getLevel() { return level; }
+        public String getEventTime() { return eventTime; }
+        public String getRegion() { return region; }
+        public String getTypeCode() { return typeCode; }
+        public String getDeviceCode() { return deviceCode; }
+    }
+}

+ 26 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/EmptyCockpitMultidimensionalAnalysisAdapter.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 EmptyCockpitMultidimensionalAnalysisAdapter implements CockpitMultidimensionalAnalysisAdapter {
+    private final Clock clock;
+
+    public EmptyCockpitMultidimensionalAnalysisAdapter(Clock clock) {
+        this.clock = clock;
+    }
+
+    @Override
+    public CockpitMultidimensionalAnalysisSnapshot load() {
+        return CockpitMultidimensionalAnalysisSnapshot.empty(businessTime());
+    }
+
+    private String businessTime() {
+        return DateTimeFormatter.ISO_OFFSET_DATE_TIME
+                .format(clock.instant().atZone(ZoneId.of("Asia/Shanghai")));
+    }
+}