Parcourir la source

feat(cockpit): add websocket scope subscription (#28)

Kazerin il y a 1 mois
Parent
commit
33df12ea2d

+ 180 - 0
docs/cockpit/websocket-scope-delta-demo.html

@@ -0,0 +1,180 @@
+<!doctype html>
+<html lang="en">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <title>Cockpit WebSocket Scope Delta Demo</title>
+  <style>
+    :root { color-scheme: light; font-family: Arial, sans-serif; }
+    * { box-sizing: border-box; }
+    body { margin: 0; background: #f4f7fb; color: #172b4d; }
+    header { background: #17324d; color: white; padding: 24px 32px; }
+    h1 { margin: 0; font-size: 22px; }
+    p { margin: 8px 0 0; opacity: .8; }
+    main { max-width: 1080px; margin: 0 auto; padding: 24px; }
+    .toolbar { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 20px; }
+    button { border: 1px solid #2557a7; background: white; color: #2557a7; padding: 8px 12px; cursor: pointer; }
+    button.primary { background: #2557a7; color: white; }
+    button:disabled { opacity: .5; cursor: not-allowed; }
+    .grid { display: grid; grid-template-columns: 1fr 360px; gap: 20px; }
+    .panel { background: white; border: 1px solid #d5dce6; padding: 16px; }
+    .panel h2 { margin-top: 0; font-size: 16px; }
+    #devices { display: grid; gap: 10px; }
+    .device { border: 1px solid #dde3ec; padding: 10px 12px; }
+    .device-name { font-weight: 700; }
+    .status { display: inline-block; min-width: 82px; text-align: center; padding: 2px 6px; color: white; font-size: 12px; }
+    .ALARM { background: #c53030; } .WORK_ORDER { background: #b7791f; } .OFFLINE { background: #4a5568; }
+    .NORMAL { background: #2f855a; } .EMPTY { background: #4a5568; }
+    .meta { color: #5d6b82; font-size: 12px; margin-top: 4px; }
+    #events { height: 320px; overflow: auto; font: 12px/1.5 Consolas, monospace; background: #f7f9fc; padding: 10px; }
+    .event-old { color: #b83280; } .event-push { color: #2c7a7b; } .event-state { color: #805ad5; }
+    @media (max-width: 860px) { .grid { grid-template-columns: 1fr; } }
+  </style>
+</head>
+<body>
+  <header>
+    <h1>Cockpit WebSocket Scope Delta Demo</h1>
+    <p>REST is the authoritative initial snapshot. WebSocket deltas are accepted only when serverUpdatedAt is newer.</p>
+  </header>
+  <main>
+    <div class="toolbar">
+      <button class="primary" id="load">1. Load REST snapshot</button>
+      <button id="replay" disabled>2. Replay pushes / reconnect</button>
+      <button id="live">Connect live WebSocket</button>
+    </div>
+    <div class="grid">
+      <section class="panel">
+        <h2>Devices</h2>
+        <div id="devices"></div>
+      </section>
+      <section class="panel">
+        <h2>Protocol events</h2>
+        <div id="events"></div>
+      </section>
+    </div>
+  </main>
+  <script>
+    const REST_URL = "http://localhost:8300/api/cockpit/v1/map-snapshot";
+    const WS_URL = "ws://localhost:8300/ws/deviceStatus";
+    let snapshotLoaded = false;
+    const latest = new Map();
+    const sampleDevices = [
+      { code: "TC-PS-WS-202609020001", name: "Pressure monitor 001", status: "NORMAL", statusTime: "2026-09-02T09:58:00+08:00", serverUpdatedAt: "2026-09-02T10:00:00+08:00" },
+      { code: "TC-PS-WS-202609020002", name: "Pressure monitor 002", status: "NORMAL", statusTime: "2026-09-02T09:58:00+08:00", serverUpdatedAt: "2026-09-02T10:00:00+08:00" }
+    ];
+    const scriptedMessages = [
+      { type: "cockpitDeviceStatusDelta", deviceCode: "TC-PS-WS-202609020001", device_code: "TC-PS-WS-202609020001",
+        data: { status: "ALARM", statusTime: "2026-09-02T10:01:00+08:00", serverUpdatedAt: "2026-09-02T10:01:00+08:00" } },
+      { type: "cockpitDeviceStatusDelta", deviceCode: "TC-PS-WS-202609020001", device_code: "TC-PS-WS-202609020001",
+        data: { status: "NORMAL", statusTime: "2026-09-02T10:00:30+08:00", serverUpdatedAt: "2026-09-02T10:00:30+08:00" } },
+      { type: "cockpitDeviceStatusDelta", deviceCode: "TC-PS-WS-202609020099", device_code: "TC-PS-WS-202609020099",
+        data: { status: "EMPTY", reason: "DEVICE_NOT_FOUND", statusTime: null, serverUpdatedAt: "2026-09-02T10:02:00+08:00" } },
+      { type: "__disconnect" },
+      { type: "__reconnect" },
+      { type: "cockpitDeviceStatusDelta", deviceCode: "TC-PS-WS-202609020001", device_code: "TC-PS-WS-202609020001",
+        data: { status: "NORMAL", statusTime: "2026-09-02T10:03:00+08:00", serverUpdatedAt: "2026-09-02T10:03:00+08:00" } }
+    ];
+
+    function time(value) {
+      return value ? value.replace("T", " ") : "-";
+    }
+    function log(message, className) {
+      const node = document.createElement("div");
+      node.textContent = message;
+      if (className) node.className = className;
+      document.getElementById("events").appendChild(node);
+      document.getElementById("events").scrollTop = document.getElementById("events").scrollHeight;
+    }
+    function render() {
+      const root = document.getElementById("devices");
+      root.replaceChildren();
+      sampleDevices.forEach((device, index) => {
+        const current = latest.get(device.code) || device;
+        const item = document.createElement("article");
+        item.className = "device";
+        item.innerHTML = `<div class="device-name">${device.name}</div>
+          <span class="status ${current.status}">${current.status}</span>
+          <div class="meta">${device.code}<br>statusTime: ${time(current.statusTime)}<br>serverUpdatedAt: ${time(current.serverUpdatedAt)}</div>`;
+        root.appendChild(item);
+      });
+      document.getElementById("replay").disabled = !snapshotLoaded;
+    }
+    function accept(message) {
+      if (!message || message.type !== "cockpitDeviceStatusDelta") return;
+      const code = message.deviceCode;
+      const data = message.data || {};
+      const previous = latest.get(code);
+      if (previous && data.serverUpdatedAt <= previous.serverUpdatedAt) {
+        log(`IGNORED OLD ${code} @ ${data.serverUpdatedAt}`, "event-old");
+        return;
+      }
+      latest.set(code, data);
+      render();
+      log(`DELTA ${code} -> ${data.status}${data.reason ? ` (${data.reason})` : ""}`, "event-push");
+    }
+    function loadSnapshot() {
+      log(`GET ${REST_URL}`);
+      fetch(REST_URL, { headers: { Accept: "application/json" } })
+        .then((response) => response.ok ? response.json() : Promise.reject(new Error(`HTTP ${response.status}`)))
+        .then((body) => {
+          const points = body.data?.monitoringPoints || [];
+          const unassociated = body.data?.unassociatedEquipment || [];
+          points.forEach((point, index) => {
+            const code = `${point.pointCode}-DEMO`;
+            latest.set(code, { status: point.pointStatus || "EMPTY", statusTime: body.data.snapshotAt, serverUpdatedAt: body.data.snapshotAt });
+            sampleDevices[index] = { code, name: point.pointName || code, status: point.pointStatus || "EMPTY", statusTime: body.data.snapshotAt, serverUpdatedAt: body.data.snapshotAt };
+          });
+          unassociated.forEach((device, index) => {
+            const offset = points.length + index;
+            const code = device.deviceCode;
+            latest.set(code, { status: "EMPTY", statusTime: null, serverUpdatedAt: body.data.snapshotAt });
+            sampleDevices[offset] = { code, name: device.deviceName || code, status: "EMPTY", statusTime: null, serverUpdatedAt: body.data.snapshotAt };
+          });
+          snapshotLoaded = true;
+          log(`SNAPSHOT ${body.data.snapshotAt}`, "event-state");
+          render();
+        })
+        .catch(() => {
+          sampleDevices.forEach((device) => latest.set(device.code, device));
+          snapshotLoaded = true;
+          log("SNAPSHOT fallback sample (REST unavailable)", "event-state");
+          render();
+        });
+    }
+    function replay() {
+      let index = 0;
+      const step = () => {
+        if (index >= scriptedMessages.length) return;
+        const message = scriptedMessages[index++];
+        if (message.type === "__disconnect") {
+          log("DISCONNECTED", "event-state");
+        } else if (message.type === "__reconnect") {
+          log("RECONNECT {\"type\":\"cockpitSubscribe\",\"scope\":\"all\"}", "event-state");
+        } else {
+          accept(message);
+        }
+        setTimeout(step, 650);
+      };
+      step();
+    }
+    function connectLive() {
+      const socket = new WebSocket(WS_URL);
+      socket.onopen = () => {
+        log("WEBSOCKET OPEN", "event-state");
+        socket.send(JSON.stringify({ type: "cockpitSubscribe", scope: "all" }));
+      };
+      socket.onmessage = (event) => {
+        const message = JSON.parse(event.data);
+        log(`MESSAGE ${JSON.stringify(message)}`, message.type === "cockpitDeviceStatusDelta" ? "event-push" : "event-state");
+        accept(message);
+      };
+      socket.onclose = () => log("WEBSOCKET CLOSED", "event-state");
+      socket.onerror = () => log("WEBSOCKET UNAVAILABLE; use scripted replay", "event-old");
+    }
+    document.getElementById("load").addEventListener("click", loadSnapshot);
+    document.getElementById("replay").addEventListener("click", replay);
+    document.getElementById("live").addEventListener("click", connectLive);
+    render();
+  </script>
+</body>
+</html>

+ 73 - 0
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/websocket/DeviceStatusDeltaTracker.java

@@ -0,0 +1,73 @@
+package com.zksy.web.websocket;
+
+import org.springframework.stereotype.Component;
+
+import java.time.LocalDateTime;
+import java.time.OffsetDateTime;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * 以服务端更新时间为准过滤重放消息,只放行状态真正变化的设备。
+ */
+@Component
+public class DeviceStatusDeltaTracker {
+
+    private static final DateTimeFormatter BUSINESS_TIME =
+            DateTimeFormatter.ISO_OFFSET_DATE_TIME;
+
+    private final Map<String, Delta> latestByDevice = new ConcurrentHashMap<>();
+
+    public boolean shouldPush(String deviceCode, OffsetDateTime serverUpdatedAt, String status, String statusTime) {
+        if (deviceCode == null || deviceCode.isEmpty()) {
+            return false;
+        }
+        Delta next = new Delta(serverUpdatedAt, status, statusTime);
+        boolean[] changed = {false};
+        latestByDevice.compute(deviceCode, (key, current) -> {
+            if (current != null && !next.serverUpdatedAt.isAfter(current.serverUpdatedAt)) {
+                return current;
+            }
+            if (current != null && current.isSameState(next)) {
+                return next;
+            }
+            changed[0] = true;
+            return next;
+        });
+        return changed[0];
+    }
+
+    public OffsetDateTime parseServerUpdatedAt(String value) {
+        if (value == null || value.isBlank()) {
+            return OffsetDateTime.now(ZoneOffset.ofHours(8));
+        }
+        try {
+            return OffsetDateTime.parse(value, BUSINESS_TIME);
+        } catch (DateTimeParseException ignored) {
+            return LocalDateTime.parse(value).atZone(ZoneOffset.ofHours(8)).toOffsetDateTime();
+        }
+    }
+
+    private static final class Delta {
+        private final OffsetDateTime serverUpdatedAt;
+        private final String status;
+        private final String statusTime;
+
+        private Delta(OffsetDateTime serverUpdatedAt, String status, String statusTime) {
+            this.serverUpdatedAt = serverUpdatedAt;
+            this.status = status;
+            this.statusTime = statusTime;
+        }
+
+        private boolean isSameState(Delta next) {
+            return status.equals(next.status) && safeEquals(statusTime, next.statusTime);
+        }
+
+        private boolean safeEquals(String left, String right) {
+            return left == null ? right == null : left.equals(right);
+        }
+    }
+}

+ 122 - 25
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/websocket/DeviceStatusRedisListener.java

@@ -11,6 +11,11 @@ import org.springframework.stereotype.Component;
 import org.springframework.web.socket.WebSocketSession;
 
 import java.nio.charset.StandardCharsets;
+import java.time.LocalDateTime;
+import java.time.OffsetDateTime;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeFormatter;
+import java.util.HashSet;
 import java.util.Set;
 
 /**
@@ -34,6 +39,9 @@ public class DeviceStatusRedisListener implements MessageListener {
     @Autowired
     private DeviceStatusWebSocketHandler webSocketHandler;
 
+    @Autowired
+    private DeviceStatusDeltaTracker deltaTracker;
+
     /**
      * Redis 频道名称
      */
@@ -47,64 +55,153 @@ public class DeviceStatusRedisListener implements MessageListener {
         try {
             JSONObject msgObj = JSONObject.parseObject(body);
             String deviceCode = msgObj.getString("deviceCode");
+            if (deviceCode == null || deviceCode.isEmpty()) {
+                deviceCode = msgObj.getString("device_code");
+            }
 
             if (deviceCode == null || deviceCode.isEmpty()) {
                 log.warn("Redis 消息缺少 deviceCode 字段: {}", body);
                 return;
             }
 
-            // 查找订阅了该设备的所有 WebSocket 会话
+            // 查找设备编码订阅和全量驾驶舱订阅的 WebSocket 会话。
             Set<String> sessionIds = subscriptionManager.getSubscribedSessions(deviceCode);
-            if (sessionIds.isEmpty()) {
+            Set<String> cockpitSessionIds = subscriptionManager.getAllScopeSessions();
+            if (sessionIds.isEmpty() && cockpitSessionIds.isEmpty()) {
                 log.debug("无会话订阅设备: deviceCode={}", deviceCode);
                 return;
             }
 
-            // 查询最新设备状态
-            EquipmentStatus status = equipmentStatusService.getByDeviceCode(deviceCode);
-            if (status == null) {
-                log.warn("未找到设备状态: deviceCode={}", deviceCode);
+            // 查询最新设备状态,缺失时不臆造状态,而是给出显式回退原因。
+            boolean deviceExists = equipmentStatusService.existsByDeviceCode(deviceCode);
+            EquipmentStatus latestStatus = null;
+            String status;
+            String statusTime = null;
+            String reason;
+            if (!deviceExists) {
+                status = "EMPTY";
+                reason = "DEVICE_NOT_FOUND";
+            } else {
+                latestStatus = equipmentStatusService.getByDeviceCode(deviceCode);
+                if (latestStatus == null) {
+                    status = "EMPTY";
+                    reason = "NO_STATUS";
+                } else {
+                    status = resolveStatus(latestStatus);
+                    statusTime = formatBusinessTime(latestStatus.getStatusUpdateTime());
+                    reason = null;
+                }
+            }
+            OffsetDateTime serverUpdatedAt = deltaTracker.parseServerUpdatedAt(serverUpdatedAtValue(msgObj));
+            if (!deltaTracker.shouldPush(deviceCode, serverUpdatedAt, status, statusTime)) {
+                log.debug("设备状态未变化或消息过期: deviceCode={}", deviceCode);
                 return;
             }
 
             // 构建推送消息
-            JSONObject data = JSONObject.parseObject(JSONObject.toJSONString(status));
-            // 客户端可能同时订阅多个设备,必须在推送中携带设备编码以便路由状态。
-            data.put("deviceCode", deviceCode);
-            if (msgObj.containsKey("eventId")) {
-                data.put("eventId", msgObj.getString("eventId"));
+            String pushPayload = null;
+            if (latestStatus != null) {
+                JSONObject data = JSONObject.parseObject(JSONObject.toJSONString(latestStatus));
+                // 客户端可能同时订阅多个设备,必须在推送中携带设备编码以便路由状态。
+                data.put("deviceCode", deviceCode);
+                if (msgObj.containsKey("eventId")) {
+                    data.put("eventId", msgObj.getString("eventId"));
+                }
+                pushPayload = legacyPushMessage(deviceCode, data).toJSONString();
             }
-            JSONObject pushMsg = new JSONObject();
-            pushMsg.put("type", "deviceStatusPush");
-            pushMsg.put("deviceCode", deviceCode);
-            pushMsg.put("data", data);
+            String cockpitPayload = cockpitDeltaMessage(deviceCode, status, statusTime,
+                    serverUpdatedAt, reason).toJSONString();
 
-            String pushPayload = pushMsg.toJSONString();
-
-            // 推送到所有订阅了该设备的会话
             int successCount = 0;
             int failCount = 0;
-            for (String sessionId : sessionIds) {
-                WebSocketSession session = DeviceStatusWebSocketHandler.getSession(sessionId);
-                if (session != null && session.isOpen()) {
-                    if (webSocketHandler.sendText(session, pushPayload)) {
+            Set<String> allScopeSet = new HashSet<>(cockpitSessionIds);
+            if (pushPayload != null) {
+                for (String sessionId : sessionIds) {
+                    if (allScopeSet.contains(sessionId)) {
+                        continue;
+                    }
+                    if (send(sessionId, pushPayload)) {
                         successCount++;
                     } else {
                         failCount++;
                     }
+                }
+            }
+            for (String sessionId : allScopeSet) {
+                if (send(sessionId, cockpitPayload)) {
+                    successCount++;
                 } else {
-                    // 会话已失效,清理订阅
-                    subscriptionManager.removeSession(sessionId);
                     failCount++;
                 }
             }
 
             log.info("设备状态推送完成: deviceCode={}, 成功={}, 失败={}, 订阅会话数={}",
-                    deviceCode, successCount, failCount, sessionIds.size());
+                    deviceCode, successCount, failCount, sessionIds.size() + cockpitSessionIds.size());
 
         } catch (Exception e) {
             log.error("处理 Redis 设备状态变更消息异常: body={}", body, e);
         }
     }
 
+    private boolean send(String sessionId, String payload) {
+        WebSocketSession session = DeviceStatusWebSocketHandler.getSession(sessionId);
+        if (session != null && session.isOpen()) {
+            if (webSocketHandler.sendText(session, payload)) {
+                return true;
+            }
+        } else {
+            subscriptionManager.removeSession(sessionId);
+        }
+        return false;
+    }
+
+    private JSONObject legacyPushMessage(String deviceCode, JSONObject data) {
+        JSONObject pushMsg = new JSONObject();
+        pushMsg.put("type", "deviceStatusPush");
+        pushMsg.put("deviceCode", deviceCode);
+        pushMsg.put("data", data);
+        return pushMsg;
+    }
+
+    private String serverUpdatedAtValue(JSONObject msgObj) {
+        String value = msgObj.getString("serverUpdatedAt");
+        return value == null || value.isEmpty() ? msgObj.getString("changedAt") : value;
+    }
+
+    private JSONObject cockpitDeltaMessage(String deviceCode, String status, String statusTime,
+            OffsetDateTime serverUpdatedAt, String reason) {
+        String serverUpdatedAtText = DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(serverUpdatedAt);
+        JSONObject delta = new JSONObject();
+        delta.put("type", "cockpitDeviceStatusDelta");
+        delta.put("deviceCode", deviceCode);
+        delta.put("device_code", deviceCode);
+        JSONObject data = new JSONObject();
+        data.put("status", status);
+        data.put("statusTime", statusTime);
+        data.put("serverUpdatedAt", serverUpdatedAtText);
+        data.put("reason", reason);
+        delta.put("data", data);
+        return delta;
+    }
+
+    private String resolveStatus(EquipmentStatus status) {
+        if (Integer.valueOf(1).equals(status.getAlarmStatus())) {
+            return "ALARM";
+        }
+        if (Integer.valueOf(0).equals(status.getOnlineStatus())) {
+            return "OFFLINE";
+        }
+        if (Integer.valueOf(3).equals(status.getCurrentStatus())) {
+            return "WORK_ORDER";
+        }
+        return "NORMAL";
+    }
+
+    private String formatBusinessTime(LocalDateTime value) {
+        if (value == null) {
+            return null;
+        }
+        return DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(value.atZone(ZoneOffset.ofHours(8)).toOffsetDateTime());
+    }
+
 }

+ 39 - 1
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/websocket/DeviceStatusWebSocketHandler.java

@@ -19,6 +19,9 @@ import org.springframework.web.socket.WebSocketSession;
 import org.springframework.web.socket.handler.TextWebSocketHandler;
 
 import java.io.IOException;
+import java.time.OffsetDateTime;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeFormatter;
 import java.util.HashSet;
 import java.util.HashMap;
 import java.util.Collections;
@@ -36,6 +39,7 @@ import java.util.concurrent.ConcurrentHashMap;
  *   <li><b>authenticate</b> — 使用登录 JWT 鉴权(工单推送必需)</li>
  *   <li><b>subscribe</b> — 批量订阅设备,后端状态变更时主动推送</li>
  *   <li><b>unsubscribe</b> — 取消订阅设备</li>
+ *   <li><b>cockpitSubscribe</b> — 驾驶舱全量状态订阅(scope=all)</li>
  *   <li><b>deviceStatus</b> — 单次查询设备状态(兼容旧接口)</li>
  *   <li><b>workOrderReminder</b> — 查询当前用户可见的待处理/超时工单</li>
  *   <li><b>ping</b> — 心跳检测</li>
@@ -55,6 +59,10 @@ import java.util.concurrent.ConcurrentHashMap;
  * → {"type":"unsubscribe","deviceCodes":["DEV001"]}
  * ← {"type":"unsubscribed","deviceCodes":["DEV001"]}
  *
+ * // 驾驶舱全量订阅
+ * → {"type":"cockpitSubscribe","scope":"all"}
+ * ← {"type":"cockpitSubscribed","scope":"all","serverUpdatedAt":"2026-09-02T10:00:00+08:00"}
+ *
  * // 心跳
  * → {"type":"ping"}
  * ← {"type":"pong","timestamp":1690000000000}
@@ -66,6 +74,10 @@ import java.util.concurrent.ConcurrentHashMap;
  * // 设备状态变更推送(服务端主动推送)
  * ← {"type":"deviceStatusPush","deviceCode":"DEV001","data":{...}}
  *
+ * // 驾驶舱增量推送
+ * ← {"type":"cockpitDeviceStatusDelta","deviceCode":"DEV001","device_code":"DEV001",
+ *    "data":{"status":"ALARM","statusTime":"2026-09-02T09:59:50+08:00","serverUpdatedAt":"2026-09-02T10:00:00+08:00"}}
+ *
  * // 工单提醒推送(服务端每分钟扫描,有变化才推送)
  * ← {"type":"workOrderReminderPush","success":true,"data":{"items":[...],"total":1}}
  * </pre>
@@ -180,7 +192,10 @@ public class DeviceStatusWebSocketHandler extends TextWebSocketHandler {
                 case "unsubscribe":
                     handleUnsubscribe(session, request);
                     break;
-                    //查询设备状态(单次查询)
+                case "cockpitSubscribe":
+                    handleCockpitSubscribe(session, request);
+                    break;
+                //查询设备状态(单次查询)
                 case "deviceStatus":
                     handleDeviceStatus(session, request);
                     break;
@@ -324,6 +339,24 @@ public class DeviceStatusWebSocketHandler extends TextWebSocketHandler {
         log.info("取消订阅: sessionId={}, 取消设备={}, 剩余订阅={}", session.getId(), deviceCodes, remaining);
     }
 
+    /**
+     * 驾驶舱全量范围订阅;REST 仍是初始快照,这里只接收后续状态增量。
+     */
+    private void handleCockpitSubscribe(WebSocketSession session, JSONObject request) throws IOException {
+        String scope = request.getString("scope");
+        if (!"all".equals(scope)) {
+            sendError(session, "缺少 scope 参数或暂不支持该范围");
+            return;
+        }
+        subscriptionManager.subscribeAll(session.getId());
+
+        JSONObject response = new JSONObject();
+        response.put("type", "cockpitSubscribed");
+        response.put("scope", scope);
+        response.put("serverUpdatedAt", nowIso());
+        sendText(session, response.toJSONString());
+    }
+
     /**
      * 处理设备状态查询(兼容旧接口)
      */
@@ -354,6 +387,11 @@ public class DeviceStatusWebSocketHandler extends TextWebSocketHandler {
         sendText(session, response.toJSONString());
     }
 
+    private static String nowIso() {
+        return DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(
+                OffsetDateTime.now(ZoneOffset.ofHours(8)));
+    }
+
     @Override
     public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
         String sessionId = session.getId();

+ 28 - 1
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/websocket/SubscriptionManager.java

@@ -34,6 +34,11 @@ public class SubscriptionManager {
      */
     private final ConcurrentHashMap<String, Set<String>> deviceToSessions = new ConcurrentHashMap<>();
 
+    /**
+     * 全量驾驶舱订阅会话ID集合。
+     */
+    private final Set<String> allScopeSessions = ConcurrentHashMap.newKeySet();
+
     /**
      * 批量订阅设备
      *
@@ -46,6 +51,8 @@ public class SubscriptionManager {
             return getSubscribedDevices(sessionId);
         }
 
+        allScopeSessions.remove(sessionId);
+
         // 更新正向映射
         sessionToDevices.compute(sessionId, (k, existing) -> {
             if (existing == null) {
@@ -102,12 +109,25 @@ public class SubscriptionManager {
         return getSubscribedDevices(sessionId);
     }
 
+    /**
+     * 订阅全部设备状态增量。
+     */
+    public void subscribeAll(String sessionId) {
+        clearDeviceSubscriptions(sessionId);
+        allScopeSessions.add(sessionId);
+    }
+
     /**
      * 移除会话的所有订阅(连接断开时调用)
      *
      * @param sessionId WebSocket 会话ID
      */
     public void removeSession(String sessionId) {
+        allScopeSessions.remove(sessionId);
+        clearDeviceSubscriptions(sessionId);
+    }
+
+    private void clearDeviceSubscriptions(String sessionId) {
         Set<String> deviceCodes = sessionToDevices.remove(sessionId);
         if (deviceCodes == null || deviceCodes.isEmpty()) {
             return;
@@ -134,6 +154,13 @@ public class SubscriptionManager {
         return sessions != null ? Collections.unmodifiableSet(sessions) : Collections.emptySet();
     }
 
+    /**
+     * 获取全量订阅的会话ID集合。
+     */
+    public Set<String> getAllScopeSessions() {
+        return Collections.unmodifiableSet(allScopeSessions);
+    }
+
     /**
      * 获取指定会话订阅的所有设备编码
      *
@@ -152,4 +179,4 @@ public class SubscriptionManager {
         return String.format("活跃会话数=%d, 被订阅设备数=%d",
                 sessionToDevices.size(), deviceToSessions.size());
     }
-}
+}

+ 317 - 0
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/websocket/DeviceStatusCockpitWebSocketHandlerTest.java

@@ -0,0 +1,317 @@
+package com.zksy.web.websocket;
+
+import com.alibaba.fastjson.JSONObject;
+import com.zksy.base.domain.EquipmentStatus;
+import com.zksy.base.service.EquipmentStatusService;
+import com.zksy.base.service.BusinessWorkOrderService;
+import com.zksy.framework.web.service.TokenService;
+import org.springframework.data.redis.connection.Message;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.web.socket.CloseStatus;
+import org.springframework.web.socket.TextMessage;
+import org.springframework.web.socket.WebSocketSession;
+
+import java.util.HashMap;
+import java.nio.charset.StandardCharsets;
+import java.time.LocalDateTime;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class DeviceStatusCockpitWebSocketHandlerTest {
+    private static final String DEVICE_CODE = "TC-PS-WS-202609020012";
+
+    private DeviceStatusWebSocketHandler handler;
+    private WebSocketSession session;
+    private EquipmentStatusService equipmentStatusService;
+    private SubscriptionManager subscriptionManager;
+
+    @BeforeEach
+    void setUp() {
+        handler = new DeviceStatusWebSocketHandler();
+        session = mock(WebSocketSession.class);
+        HashMap<String, Object> attributes = new HashMap<>();
+        org.mockito.Mockito.doReturn("ws-session-1").when(session).getId();
+        org.mockito.Mockito.doReturn(true).when(session).isOpen();
+        org.mockito.Mockito.doReturn(attributes).when(session).getAttributes();
+
+        equipmentStatusService = mock(EquipmentStatusService.class);
+        subscriptionManager = new SubscriptionManager();
+        ReflectionTestUtils.setField(handler, "equipmentStatusService", equipmentStatusService);
+        ReflectionTestUtils.setField(handler, "subscriptionManager", subscriptionManager);
+        ReflectionTestUtils.setField(handler, "tokenService", mock(TokenService.class));
+        ReflectionTestUtils.setField(handler, "businessWorkOrderService", mock(BusinessWorkOrderService.class));
+        ReflectionTestUtils.setField(handler, "workOrderMaxItems", 100);
+    }
+
+    @Test
+    void cockpitSubscribeAllReturnsExplicitScopeSubscription() throws Exception {
+        handler.handleTextMessage(session, new TextMessage(
+                "{\"type\":\"cockpitSubscribe\",\"scope\":\"all\"}"));
+
+        ArgumentCaptor<TextMessage> response = ArgumentCaptor.forClass(TextMessage.class);
+        verify(session).sendMessage(response.capture());
+        JSONObject payload = JSONObject.parseObject(response.getValue().getPayload());
+        assertEquals("cockpitSubscribed", payload.getString("type"));
+        assertEquals("all", payload.getString("scope"));
+        assertTrue(payload.containsKey("serverUpdatedAt"));
+    }
+
+    @Test
+    void cockpitAllScopeReceivesChangedDeviceDelta() throws Exception {
+        handler.afterConnectionEstablished(session);
+        handler.handleTextMessage(session, new TextMessage(
+                "{\"type\":\"cockpitSubscribe\",\"scope\":\"all\"}"));
+
+        EquipmentStatus status = new EquipmentStatus();
+        status.setCurrentStatus(1);
+        status.setAlarmStatus(1);
+        status.setOnlineStatus(1);
+        status.setStatusUpdateTime(LocalDateTime.parse("2026-09-02T09:59:50"));
+        when(equipmentStatusService.getByDeviceCode(DEVICE_CODE)).thenReturn(status);
+        when(equipmentStatusService.existsByDeviceCode(DEVICE_CODE)).thenReturn(true);
+        DeviceStatusRedisListener listener = new DeviceStatusRedisListener();
+        ReflectionTestUtils.setField(listener, "subscriptionManager", subscriptionManager);
+        ReflectionTestUtils.setField(listener, "equipmentStatusService", equipmentStatusService);
+        ReflectionTestUtils.setField(listener, "webSocketHandler", handler);
+        ReflectionTestUtils.setField(listener, "deltaTracker", new DeviceStatusDeltaTracker());
+        listener.onMessage(message("{\"deviceCode\":\"" + DEVICE_CODE + "\","
+                + "\"serverUpdatedAt\":\"2026-09-02T10:00:00+08:00\"}"), null);
+
+        ArgumentCaptor<TextMessage> messages = ArgumentCaptor.forClass(TextMessage.class);
+        verify(session, org.mockito.Mockito.times(2)).sendMessage(messages.capture());
+        JSONObject delta = messages.getAllValues().stream()
+                .map(item -> JSONObject.parseObject(item.getPayload()))
+                .filter(item -> "cockpitDeviceStatusDelta".equals(item.getString("type")))
+                .findFirst().orElse(null);
+        assertNotNull(delta);
+        assertEquals(DEVICE_CODE, delta.getString("deviceCode"));
+        assertEquals(DEVICE_CODE, delta.getString("device_code"));
+        assertEquals("ALARM", delta.getJSONObject("data").getString("status"));
+        assertEquals("2026-09-02T09:59:50+08:00", delta.getJSONObject("data").getString("statusTime"));
+        assertEquals("2026-09-02T10:00:00+08:00", delta.getJSONObject("data").getString("serverUpdatedAt"));
+    }
+
+    @Test
+    void existingDeviceCodeSubscriptionStillReceivesLegacyPush() throws Exception {
+        handler.afterConnectionEstablished(session);
+        handler.handleTextMessage(session, new TextMessage(
+                "{\"type\":\"subscribe\",\"deviceCodes\":[\"" + DEVICE_CODE + "\"]}"));
+
+        EquipmentStatus status = new EquipmentStatus();
+        status.setCurrentStatus(1);
+        status.setAlarmStatus(1);
+        status.setOnlineStatus(1);
+        status.setStatusUpdateTime(LocalDateTime.parse("2026-09-02T09:59:50"));
+        when(equipmentStatusService.getByDeviceCode(DEVICE_CODE)).thenReturn(status);
+        when(equipmentStatusService.existsByDeviceCode(DEVICE_CODE)).thenReturn(true);
+        DeviceStatusRedisListener listener = newListener();
+        listener.onMessage(message("{\"deviceCode\":\"" + DEVICE_CODE + "\","
+                + "\"serverUpdatedAt\":\"2026-09-02T10:00:00+08:00\"}"), null);
+
+        ArgumentCaptor<TextMessage> messages = ArgumentCaptor.forClass(TextMessage.class);
+        verify(session, org.mockito.Mockito.times(2)).sendMessage(messages.capture());
+        JSONObject push = messages.getAllValues().stream()
+                .map(item -> JSONObject.parseObject(item.getPayload()))
+                .filter(item -> "deviceStatusPush".equals(item.getString("type")))
+                .findFirst().orElse(null);
+        assertNotNull(push);
+        assertEquals(DEVICE_CODE, push.getString("deviceCode"));
+        assertEquals(DEVICE_CODE, push.getJSONObject("data").getString("deviceCode"));
+    }
+
+    @Test
+    void cockpitSubscriptionAcceptsLegacyRedisDeviceCodeAlias() throws Exception {
+        handler.afterConnectionEstablished(session);
+        handler.handleTextMessage(session, new TextMessage(
+                "{\"type\":\"cockpitSubscribe\",\"scope\":\"all\"}"));
+
+        when(equipmentStatusService.getByDeviceCode(DEVICE_CODE)).thenReturn(alarmStatus());
+        when(equipmentStatusService.existsByDeviceCode(DEVICE_CODE)).thenReturn(true);
+        DeviceStatusRedisListener listener = newListener();
+        listener.onMessage(message("{\"device_code\":\"" + DEVICE_CODE + "\","
+                + "\"serverUpdatedAt\":\"2026-09-02T10:00:00+08:00\"}"), null);
+
+        ArgumentCaptor<TextMessage> messages = ArgumentCaptor.forClass(TextMessage.class);
+        verify(session, org.mockito.Mockito.times(2)).sendMessage(messages.capture());
+        JSONObject delta = messages.getAllValues().stream()
+                .map(item -> JSONObject.parseObject(item.getPayload()))
+                .filter(item -> "cockpitDeviceStatusDelta".equals(item.getString("type")))
+                .findFirst().orElse(null);
+        assertNotNull(delta);
+        assertEquals(DEVICE_CODE, delta.getString("deviceCode"));
+    }
+
+    @Test
+    void cockpitAllScopeIgnoresUnchangedDeviceStates() throws Exception {
+        handler.afterConnectionEstablished(session);
+        handler.handleTextMessage(session, new TextMessage(
+                "{\"type\":\"cockpitSubscribe\",\"scope\":\"all\"}"));
+
+        when(equipmentStatusService.getByDeviceCode(DEVICE_CODE)).thenReturn(alarmStatus());
+        when(equipmentStatusService.existsByDeviceCode(DEVICE_CODE)).thenReturn(true);
+        DeviceStatusRedisListener listener = newListener();
+        listener.onMessage(message(event(DEVICE_CODE, "2026-09-02T10:00:00+08:00")), null);
+        listener.onMessage(message(event(DEVICE_CODE, "2026-09-02T10:00:01+08:00")), null);
+
+        verify(session, org.mockito.Mockito.times(2)).sendMessage(ArgumentCaptor.forClass(TextMessage.class).capture());
+    }
+
+    @Test
+    void cockpitAllScopeIgnoresOldOutOfOrderServerUpdates() throws Exception {
+        handler.afterConnectionEstablished(session);
+        handler.handleTextMessage(session, new TextMessage(
+                "{\"type\":\"cockpitSubscribe\",\"scope\":\"all\"}"));
+
+        when(equipmentStatusService.getByDeviceCode(DEVICE_CODE)).thenReturn(alarmStatus());
+        when(equipmentStatusService.existsByDeviceCode(DEVICE_CODE)).thenReturn(true);
+        DeviceStatusRedisListener listener = newListener();
+        listener.onMessage(message(event(DEVICE_CODE, "2026-09-02T10:00:05+08:00")), null);
+
+        EquipmentStatus recovered = alarmStatus();
+        recovered.setAlarmStatus(0);
+        when(equipmentStatusService.getByDeviceCode(DEVICE_CODE)).thenReturn(recovered);
+        when(equipmentStatusService.existsByDeviceCode(DEVICE_CODE)).thenReturn(true);
+        listener.onMessage(message(event(DEVICE_CODE, "2026-09-02T10:00:00+08:00")), null);
+
+        verify(session, org.mockito.Mockito.times(2)).sendMessage(ArgumentCaptor.forClass(TextMessage.class).capture());
+    }
+
+    @Test
+    void unknownDeviceReturnsExplicitCockpitFallback() throws Exception {
+        handler.afterConnectionEstablished(session);
+        handler.handleTextMessage(session, new TextMessage(
+                "{\"type\":\"cockpitSubscribe\",\"scope\":\"all\"}"));
+
+        String unknownCode = "TC-PS-WS-202609099999";
+        when(equipmentStatusService.existsByDeviceCode(unknownCode)).thenReturn(false);
+        DeviceStatusRedisListener listener = newListener();
+        listener.onMessage(message(event(unknownCode, "2026-09-02T10:00:00+08:00")), null);
+
+        ArgumentCaptor<TextMessage> messages = ArgumentCaptor.forClass(TextMessage.class);
+        verify(session, org.mockito.Mockito.times(2)).sendMessage(messages.capture());
+        JSONObject delta = messages.getAllValues().stream()
+                .map(item -> JSONObject.parseObject(item.getPayload()))
+                .filter(item -> "cockpitDeviceStatusDelta".equals(item.getString("type")))
+                .findFirst().orElse(null);
+        assertNotNull(delta);
+        assertEquals(unknownCode, delta.getString("deviceCode"));
+        assertEquals("EMPTY", delta.getJSONObject("data").getString("status"));
+        assertEquals("DEVICE_NOT_FOUND", delta.getJSONObject("data").getString("reason"));
+        assertEquals("2026-09-02T10:00:00+08:00", delta.getJSONObject("data").getString("serverUpdatedAt"));
+    }
+
+    @Test
+    void missingPointStatusReturnsExplicitNoStatusFallback() throws Exception {
+        handler.afterConnectionEstablished(session);
+        handler.handleTextMessage(session, new TextMessage(
+                "{\"type\":\"cockpitSubscribe\",\"scope\":\"all\"}"));
+
+        when(equipmentStatusService.existsByDeviceCode(DEVICE_CODE)).thenReturn(true);
+        when(equipmentStatusService.getByDeviceCode(DEVICE_CODE)).thenReturn(null);
+        DeviceStatusRedisListener listener = newListener();
+        listener.onMessage(message(event(DEVICE_CODE, "2026-09-02T10:00:00+08:00")), null);
+
+        ArgumentCaptor<TextMessage> messages = ArgumentCaptor.forClass(TextMessage.class);
+        verify(session, org.mockito.Mockito.times(2)).sendMessage(messages.capture());
+        JSONObject delta = messages.getAllValues().stream()
+                .map(item -> JSONObject.parseObject(item.getPayload()))
+                .filter(item -> "cockpitDeviceStatusDelta".equals(item.getString("type")))
+                .findFirst().orElse(null);
+        assertNotNull(delta);
+        assertEquals("EMPTY", delta.getJSONObject("data").getString("status"));
+        assertEquals("NO_STATUS", delta.getJSONObject("data").getString("reason"));
+    }
+
+    @Test
+    void pingAnswersWithPong() throws Exception {
+        handler.handleTextMessage(session, new TextMessage("{\"type\":\"ping\"}"));
+
+        ArgumentCaptor<TextMessage> response = ArgumentCaptor.forClass(TextMessage.class);
+        verify(session).sendMessage(response.capture());
+        JSONObject payload = JSONObject.parseObject(response.getValue().getPayload());
+        assertEquals("pong", payload.getString("type"));
+        assertTrue(payload.getLongValue("timestamp") > 0);
+    }
+
+    @Test
+    void disconnectCleansUpSubscriptionAndReconnectRecoversIt() throws Exception {
+        handler.afterConnectionEstablished(session);
+        handler.handleTextMessage(session, new TextMessage(
+                "{\"type\":\"cockpitSubscribe\",\"scope\":\"all\"}"));
+        handler.afterConnectionClosed(session, CloseStatus.NORMAL);
+        when(equipmentStatusService.getByDeviceCode(DEVICE_CODE)).thenReturn(alarmStatus());
+        when(equipmentStatusService.existsByDeviceCode(DEVICE_CODE)).thenReturn(true);
+        DeviceStatusRedisListener listener = newListener();
+        listener.onMessage(message(event(DEVICE_CODE, "2026-09-02T10:00:00+08:00")), null);
+
+        WebSocketSession reconnected = mock(WebSocketSession.class);
+        HashMap<String, Object> attributes = new HashMap<>();
+        org.mockito.Mockito.doReturn("ws-session-2").when(reconnected).getId();
+        org.mockito.Mockito.doReturn(true).when(reconnected).isOpen();
+        org.mockito.Mockito.doReturn(attributes).when(reconnected).getAttributes();
+        handler.afterConnectionEstablished(reconnected);
+        handler.handleTextMessage(reconnected, new TextMessage(
+                "{\"type\":\"cockpitSubscribe\",\"scope\":\"all\"}"));
+
+        when(equipmentStatusService.getByDeviceCode(DEVICE_CODE)).thenReturn(recoveredNormal());
+        listener.onMessage(message(event(DEVICE_CODE, "2026-09-02T10:00:01+08:00")), null);
+
+        ArgumentCaptor<TextMessage> messages = ArgumentCaptor.forClass(TextMessage.class);
+        verify(reconnected, org.mockito.Mockito.times(2)).sendMessage(messages.capture());
+        long deltas = messages.getAllValues().stream()
+                .filter(item -> JSONObject.parseObject(item.getPayload()).getString("type")
+                        .equals("cockpitDeviceStatusDelta"))
+                .count();
+        assertEquals(1, deltas);
+    }
+
+    private static Message message(String payload) {
+        return new Message() {
+            @Override
+            public byte[] getBody() {
+                return payload.getBytes(StandardCharsets.UTF_8);
+            }
+
+            @Override
+            public byte[] getChannel() {
+                return DeviceStatusRedisListener.CHANNEL.getBytes(StandardCharsets.UTF_8);
+            }
+        };
+    }
+
+    private DeviceStatusRedisListener newListener() {
+        DeviceStatusRedisListener listener = new DeviceStatusRedisListener();
+        ReflectionTestUtils.setField(listener, "subscriptionManager", subscriptionManager);
+        ReflectionTestUtils.setField(listener, "equipmentStatusService", equipmentStatusService);
+        ReflectionTestUtils.setField(listener, "webSocketHandler", handler);
+        ReflectionTestUtils.setField(listener, "deltaTracker", new DeviceStatusDeltaTracker());
+        return listener;
+    }
+
+    private static String event(String deviceCode, String serverUpdatedAt) {
+        return "{\"deviceCode\":\"" + deviceCode + "\",\"serverUpdatedAt\":\"" + serverUpdatedAt + "\"}";
+    }
+
+    private EquipmentStatus alarmStatus() {
+        EquipmentStatus status = new EquipmentStatus();
+        status.setCurrentStatus(1);
+        status.setAlarmStatus(1);
+        status.setOnlineStatus(1);
+        status.setStatusUpdateTime(LocalDateTime.parse("2026-09-02T09:59:50"));
+        return status;
+    }
+
+    private EquipmentStatus recoveredNormal() {
+        EquipmentStatus status = alarmStatus();
+        status.setAlarmStatus(0);
+        return status;
+    }
+}

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

@@ -10,6 +10,9 @@ import org.springframework.transaction.support.TransactionSynchronization;
 import org.springframework.transaction.support.TransactionSynchronizationManager;
 
 import java.time.LocalDateTime;
+import java.time.OffsetDateTime;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeFormatter;
 import java.util.LinkedHashMap;
 import java.util.Map;
 import java.util.UUID;
@@ -67,6 +70,8 @@ public class DeviceStatusChangePublisher {
         event.put("onlineStatus", onlineStatus);
         event.put("source", source);
         event.put("changedAt", LocalDateTime.now().toString());
+        event.put("serverUpdatedAt", DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(
+                OffsetDateTime.now(ZoneOffset.ofHours(8))));
         try {
             redisTemplate.convertAndSend(CHANNEL, objectMapper.writeValueAsString(event));
             log.info("设备状态变更事件已发布: deviceCode={}, equipmentId={}, source={}",

+ 5 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/base/service/EquipmentStatusService.java

@@ -49,4 +49,9 @@ public interface EquipmentStatusService extends IService<EquipmentStatus> {
      * @return 设备状态,未找到返回 null
      */
     EquipmentStatus getByDeviceCode(String deviceCode);
+
+    /**
+     * 判断设备编码对应的智能设备是否存在。
+     */
+    boolean existsByDeviceCode(String deviceCode);
 }

+ 10 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/base/service/impl/EquipmentStatusServiceImpl.java

@@ -294,6 +294,16 @@ public class EquipmentStatusServiceImpl extends ServiceImpl<EquipmentStatusMappe
         return status;
     }
 
+    @Override
+    public boolean existsByDeviceCode(String deviceCode) {
+        if (deviceCode == null || deviceCode.isEmpty()) {
+            return false;
+        }
+        LambdaQueryWrapper<EquipmentBase> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(EquipmentBase::getEquipmentCode, deviceCode);
+        return equipmentBaseMapper.selectCount(wrapper) > 0;
+    }
+
     private boolean hasStateChange(EquipmentStatus oldStatus, EquipmentStatus patch) {
         if (oldStatus == null || patch == null) {
             return false;