Selaa lähdekoodia

feat(cockpit): 报警/工单实时同步 —— 工单状态增量帧 + 设备帧 eventId 透传

执行票 13(后端切片 3,决策票 01)。按维护者裁定实施。

诊断先行:唯一工单推送 workOrderReminderPush 是管理端提醒(需
authenticate + 按部门/责任人过滤),驾驶舱收不到;工单状态增量此前
不存在 —— 证实决策 01 的预判。工单状态流转不写 update_time(transition()
只 set orderStatus,仅创建路径写 updateTime,无 FieldFill),故增量
检测必须逐单比对 orderStatus,不能靠时间过滤。

新增 cockpitWorkOrderStateDelta 帧(CockpitWorkOrderDeltaPushTask):
- 复用仓库已验证的「定时扫描 + 指纹比对 + 变化才推」模式(同
  WorkOrderWebSocketPushTask),推给驾驶舱全量订阅会话(免鉴权
  scope=all),不做按人可见性过滤 —— 内容是状态增量,不是提醒清单。
- 首扫只建基线不推送:REST 才是初始快照(与 cockpitSubscribe 既有
  语义一致),WS 只收后续变化。
- 诚实边界:帧带 scanIntervalMs(默认 60s,可配)标注更新延迟上限;
  不携带工单 updateTime(该列流转时不更新,携带会误导);脏状态码
  原样上报、stateName 显式 null;不泄漏数据库主键 orderId(对外键是
  workOrderCode,与 REST 读模型同口径)。
- stateCode/stateName 出自工单字典单源(票 11 的
  CockpitWorkOrderStateDictionary,0-7)。
- 无全量订阅会话时不查库;发送失败的失效会话被清出订阅集合(重连
  语义:断线后重订即可,错过的增量由 REST 重取兜底)。

设备状态增量帧 cockpitDeviceStatusDelta 补 eventId 透传(维护者裁定):
Redis 消息携带时给前端精确重取该报警的依据,缺省显式 null
(WriteMapNullValue 序列化,不静默丢)。是否携带由发布方决定,不臆造。

协议 javadoc 与 docs/cockpit/README.md 同步(新增帧示例与工单增量
口径章节)。REST 快照复用票 11/12 读模型,无新端点。

验证:针对性契约测试 21/21(新增 8 例:基线不推送/状态变化推送/
新工单推送/无变化不推/脏码 null 名称/无会话不查库/失效会话清理/
eventId 透传);全量 8 模块 422/422 通过。
Kazerin 1 viikko sitten
vanhempi
commit
d8a6b03e16

+ 13 - 2
docs/cockpit/README.md

@@ -123,13 +123,22 @@
 → {"type":"cockpitSubscribe","scope":"all"}
 ← {"type":"cockpitSubscribed","scope":"all","serverUpdatedAt":"2026-09-02T10:00:00+08:00"}
 
-// 增量推送(迁移期同时下发 device_code 别名;monitoringPointCode 关联监测点,
-// 未关联设备为 null 且不静默丢失)
+// 设备状态增量推送(迁移期同时下发 device_code 别名;monitoringPointCode 关联监测点,
+// 未关联设备为 null 且不静默丢失;eventId 为报警标识透传,发布方未携带时显式 null)
 ← {"type":"cockpitDeviceStatusDelta","deviceCode":"TC-PS-WS-202609020012",
    "device_code":"TC-PS-WS-202609020012",
    "monitoringPointCode":"MP-001",
+   "eventId":null,
    "data":{"status":"ALARM","statusTime":"2026-09-02T09:59:50+08:00",
            "serverUpdatedAt":"2026-09-02T10:00:00+08:00"}}
+
+// 工单状态增量推送(执行票 13,2026-10-09 起):扫描检测到 orderStatus 真实变化时
+// 批量推给全量订阅会话;首扫只建基线不推送(REST 才是初始快照)
+← {"type":"cockpitWorkOrderStateDelta","scanIntervalMs":60000,
+   "serverUpdatedAt":"2026-10-09T10:00:00+08:00",
+   "data":{"items":[{"workOrderCode":"WO202609291245022597",
+           "deviceCode":"TC-PS-WS-202609020012","stateCode":5,"stateName":"已结案"}],
+           "total":1}}
 ```
 
 `authenticate` 帧仅服务管理端工单推送(`workOrderReminder` 需要用户身份),驾驶舱链路不使用、不要求。
@@ -138,6 +147,8 @@
 
 增量帧字段口径:`monitoringPointCode` 为设备关联监测点的编码(`equipment_base.equipment_code` → `equipment_point_rel` → `monitor_point.point_code`);未关联设备该字段显式 `null`,不静默丢失。`data.reason` 无异常时显式 `null`。
 
+**工单状态增量口径(执行票 13)**:`cockpitWorkOrderStateDelta` 由后端定时扫描 `work_order.order_status` 逐单比对产生(工单状态流转不写 `update_time`,不能靠时间过滤),只有真实变化才推送;`scanIntervalMs` 是诚实标注的更新延迟上限(默认 60 秒,`websocket.cockpit-work-order-delta.scan-interval-ms` 可配),消费方据此标注「状态变化最迟 N 秒内可见」。帧内**不携带工单 updateTime**(该列在状态流转时不更新,携带会误导),变化时刻以帧级 `serverUpdatedAt`(扫描时间)为准。`stateCode`/`stateName` 出自工单状态字典单源(0–7,与 REST 读模型一致);字典外的脏码原样上报、`stateName` 显式 `null`;工单对外标识是 `workOrderCode`(order_no),**不泄漏数据库主键 orderId**(与 REST 读模型同口径)。**该帧与 `cockpitDeviceStatusDelta` 语义不同**:后者是设备在线/报警状态,不得当作工单状态消费(决策票 01)。工单被删除(现网无此操作)不产生增量,由消费方 REST 重取兜底。
+
 ## 6. 启动与联调
 
 后端启动(Java 11+,Maven 3.9):

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

@@ -0,0 +1,206 @@
+package com.zksy.web.websocket;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.alibaba.fastjson.serializer.SerializerFeature;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.zksy.base.domain.EquipmentBase;
+import com.zksy.base.mapper.EquipmentBaseMapper;
+import com.zksy.cockpit.workorder.CockpitWorkOrderState;
+import com.zksy.cockpit.workorder.CockpitWorkOrderStateDictionary;
+import com.zksy.ops.domain.WorkOrder;
+import com.zksy.ops.service.WorkOrderService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+import org.springframework.web.socket.WebSocketSession;
+
+import java.time.OffsetDateTime;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.stream.Collectors;
+
+/**
+ * 驾驶舱工单状态增量推送任务(执行票 13)。
+ *
+ * <p>工单状态流转写在本后端内,但没有事件通道;外部系统的报警状态走 Redis
+ * {@code device:status:change},工单没有对应频道。这里用「定时扫描 + 逐单比对
+ * orderStatus」证明真实变化后才推送 —— 与 {@link WorkOrderWebSocketPushTask}
+ * 同一模式,区别在于:</p>
+ * <ul>
+ *   <li>推送对象是驾驶舱全量订阅会话(免鉴权,scope=all),不做按人可见性过滤;</li>
+ *   <li>内容是**状态增量**(某单从状态 X 变到 Y),不是待处理提醒清单;</li>
+ *   <li>首扫只建基线不推送 —— REST 才是初始快照(与 {@code cockpitSubscribe}
+ *       的既有语义一致),WS 只收后续变化。</li>
+ * </ul>
+ *
+ * <p>诚实边界:扫描式增量的更新延迟以帧内 {@code scanIntervalMs} 显式标注,
+ * 由消费方呈现;工单状态变化不写 {@code update_time}(仅创建路径写,且无
+ * FieldFill),因此帧内**不携带 updateTime**,只有扫描时刻
+ * {@code serverUpdatedAt} —— 不给消费方一个看起来可信实则不更新的时间。
+ * 设备状态增量({@code cockpitDeviceStatusDelta})不得被当作工单状态增量
+ * 消费,两者帧类型不同、语义不同(决策票 01)。
+ */
+@Slf4j
+@Component
+public class CockpitWorkOrderDeltaPushTask {
+
+    public static final String PUSH_TYPE = "cockpitWorkOrderStateDelta";
+
+    @Autowired
+    private WorkOrderService workOrderService;
+
+    @Autowired
+    private EquipmentBaseMapper equipmentBaseMapper;
+
+    @Autowired
+    private SubscriptionManager subscriptionManager;
+
+    @Autowired
+    private DeviceStatusWebSocketHandler webSocketHandler;
+
+    /** 帧内标注的更新延迟上限,与 @Scheduled 间隔同源同值。 */
+    @Value("${websocket.cockpit-work-order-delta.scan-interval-ms:60000}")
+    private long scanIntervalMs;
+
+    /** orderId -> 上次扫描时状态。变化检测的唯一依据是 orderStatus,不是 update_time。 */
+    private final Map<Long, Integer> lastStateByOrderId = new ConcurrentHashMap<>();
+
+    /** 首扫建基线用的标记:基线建立前不推送任何增量。 */
+    private volatile boolean baselineReady = false;
+
+    @Scheduled(
+            fixedDelayString = "${websocket.cockpit-work-order-delta.scan-interval-ms:60000}",
+            initialDelayString = "${websocket.cockpit-work-order-delta.initial-delay-ms:20000}")
+    public void pushWorkOrderStateDelta() {
+        // 无驾驶舱全量订阅时不查库(与工单提醒任务同一纪律:先看会话再扫表)。
+        Set<String> sessionIds = subscriptionManager.getAllScopeSessions();
+        if (sessionIds.isEmpty()) {
+            return;
+        }
+        List<WorkOrder> changed = scanChangedOrders();
+        if (changed.isEmpty()) {
+            return;
+        }
+        String payload = deltaPayload(changed);
+        int success = 0;
+        for (String sessionId : sessionIds) {
+            if (send(sessionId, payload)) {
+                success++;
+            }
+        }
+        log.info("驾驶舱工单状态增量推送: 变化工单={} 会话数={} 成功={}",
+                changed.size(), sessionIds.size(), success);
+    }
+
+    /**
+     * 全表读取工单当前状态并与上次扫描比对;首扫只建基线。
+     *
+     * <p>必须逐单比对 orderStatus:状态流转不写 update_time,靠时间过滤会漏掉
+     * 派单/接单/验收/取消等全部流转。工单从扫描中消失(理论上不删除)不产生
+     * 增量,由消费方 REST 重取兜底。</p>
+     */
+    List<WorkOrder> scanChangedOrders() {
+        List<WorkOrder> orders = workOrderService.list(new LambdaQueryWrapper<WorkOrder>()
+                .select(WorkOrder::getOrderId, WorkOrder::getOrderNo,
+                        WorkOrder::getDeviceId, WorkOrder::getOrderStatus));
+        Map<Long, Integer> next = new HashMap<>();
+        for (WorkOrder order : orders) {
+            if (order == null || order.getOrderId() == null) {
+                continue;
+            }
+            next.put(order.getOrderId(), order.getOrderStatus());
+        }
+        List<WorkOrder> changed = new ArrayList<>();
+        if (baselineReady) {
+            for (WorkOrder order : orders) {
+                if (order == null || order.getOrderId() == null) {
+                    continue;
+                }
+                if (!Objects.equals(next.get(order.getOrderId()),
+                        lastStateByOrderId.get(order.getOrderId()))) {
+                    changed.add(order);
+                }
+            }
+        }
+        lastStateByOrderId.clear();
+        lastStateByOrderId.putAll(next);
+        baselineReady = true;
+        return changed;
+    }
+
+    /** 变化工单批 → 单帧增量载荷(一次扫描的所有变化合并为一帧)。 */
+    String deltaPayload(List<WorkOrder> changed) {
+        Map<String, String> deviceCodeByEquipmentId = deviceCodeIndex(changed);
+        List<JSONObject> items = new ArrayList<>();
+        for (WorkOrder order : changed) {
+            JSONObject item = new JSONObject();
+            // 与 REST 读模型同标识:工单对外的键是 order_no,不泄漏数据库主键 orderId。
+            item.put("workOrderCode", order.getOrderNo());
+            item.put("deviceCode", order.getDeviceId() == null ? null
+                    : deviceCodeByEquipmentId.get(order.getDeviceId()));
+            item.put("stateCode", order.getOrderStatus());
+            item.put("stateName", stateName(order.getOrderStatus()));
+            items.add(item);
+        }
+        JSONObject data = new JSONObject();
+        data.put("items", items);
+        data.put("total", items.size());
+
+        JSONObject frame = new JSONObject();
+        frame.put("type", PUSH_TYPE);
+        // 诚实标注更新延迟上限:消费方据此显示「状态变化最迟 scanIntervalMs 内可见」。
+        frame.put("scanIntervalMs", scanIntervalMs);
+        frame.put("serverUpdatedAt", nowIso());
+        frame.put("data", data);
+        return JSON.toJSONString(frame, SerializerFeature.WriteMapNullValue);
+    }
+
+    /** 状态名出自驾驶舱工单字典单源(票 11);未知码不编造名称,显式 null。 */
+    private static String stateName(Integer code) {
+        CockpitWorkOrderState state = CockpitWorkOrderStateDictionary.byCode(code);
+        return state == null ? null : state.getName();
+    }
+
+    /** 变化工单涉及的设备 → 设备编码;设备缺失时编码为 null(显式给,不静默丢)。 */
+    private Map<String, String> deviceCodeIndex(List<WorkOrder> changed) {
+        List<String> equipmentIds = changed.stream()
+                .map(WorkOrder::getDeviceId)
+                .filter(Objects::nonNull)
+                .distinct()
+                .collect(Collectors.toList());
+        if (equipmentIds.isEmpty()) {
+            return new HashMap<>();
+        }
+        Map<String, String> result = new HashMap<>();
+        for (EquipmentBase equipment : equipmentBaseMapper.selectBatchIds(equipmentIds)) {
+            if (equipment != null && equipment.getEquipmentId() != null) {
+                result.put(equipment.getEquipmentId(), equipment.getEquipmentCode());
+            }
+        }
+        return result;
+    }
+
+    private boolean send(String sessionId, String payload) {
+        WebSocketSession session = DeviceStatusWebSocketHandler.getSession(sessionId);
+        if (session != null && session.isOpen()) {
+            return webSocketHandler.sendText(session, payload);
+        }
+        subscriptionManager.removeSession(sessionId);
+        return false;
+    }
+
+    private static String nowIso() {
+        return DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(
+                OffsetDateTime.now(ZoneOffset.ofHours(8)));
+    }
+}

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

@@ -125,7 +125,8 @@ public class DeviceStatusRedisListener implements MessageListener {
             }
             String cockpitPayload = JSON.toJSONString(
                     cockpitDeltaMessage(deviceCode, monitoringPointCode(deviceCode),
-                            status, statusTime, serverUpdatedAt, reason),
+                            status, statusTime, serverUpdatedAt, reason,
+                            msgObj.getString("eventId")),
                     SerializerFeature.WriteMapNullValue);
 
             int successCount = 0;
@@ -184,14 +185,20 @@ public class DeviceStatusRedisListener implements MessageListener {
         return value == null || value.isEmpty() ? msgObj.getString("changedAt") : value;
     }
 
+    /**
+     * 驾驶舱增量帧。{@code eventId} 为报警标识透传(执行票 13 裁定):Redis 消息
+     * 携带时给前端精确重取该报警的依据,缺省显式 null(WriteMapNullValue 序列化,
+     * 不静默丢)—— 是否真的携带由发布方(zk-api-service)决定,这里不臆造。
+     */
     private JSONObject cockpitDeltaMessage(String deviceCode, String monitoringPointCode, String status,
-            String statusTime, OffsetDateTime serverUpdatedAt, String reason) {
+            String statusTime, OffsetDateTime serverUpdatedAt, String reason, String eventId) {
         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);
         delta.put("monitoringPointCode", monitoringPointCode);
+        delta.put("eventId", eventId);
         JSONObject data = new JSONObject();
         data.put("status", status);
         data.put("statusTime", statusTime);

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

@@ -81,9 +81,18 @@ import java.util.concurrent.ConcurrentHashMap;
  *
  * // 驾驶舱增量推送
  * ← {"type":"cockpitDeviceStatusDelta","deviceCode":"DEV001","device_code":"DEV001",
+ *    "eventId":"alarm-id-or-null",
+ *    "monitoringPointCode":null,
  *    "data":{"status":"ALARM","statusTime":"2026-09-02T09:59:50+08:00","serverUpdatedAt":"2026-09-02T10:00:00+08:00"}}
  *
- * // 工单提醒推送(服务端每分钟扫描,有变化才推送)
+ * // 驾驶舱工单状态增量推送(执行票 13:扫描检测到 orderStatus 真实变化时批量推给全量订阅会话;
+ * // 首扫只建基线不推送,REST 才是初始快照;scanIntervalMs 是诚实标注的更新延迟上限)
+ * ← {"type":"cockpitWorkOrderStateDelta","scanIntervalMs":60000,
+ *    "serverUpdatedAt":"2026-10-09T10:00:00+08:00",
+ *    "data":{"items":[{"workOrderCode":"WO-1","deviceCode":"DEV001",
+ *    "stateCode":5,"stateName":"已结案"}],"total":1}}
+ *
+ * // 工单提醒推送(服务端每分钟扫描,有变化才推送;仅管理端,需 authenticate)
  * ← {"type":"workOrderReminderPush","success":true,"data":{"items":[...],"total":1}}
  * </pre>
  *

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

@@ -0,0 +1,239 @@
+package com.zksy.web.websocket;
+
+import com.alibaba.fastjson.JSONObject;
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.zksy.base.domain.EquipmentBase;
+import com.zksy.base.mapper.EquipmentBaseMapper;
+import com.zksy.ops.domain.WorkOrder;
+import com.zksy.ops.service.WorkOrderService;
+import com.zksy.framework.web.service.TokenService;
+import com.zksy.base.service.EquipmentStatusService;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+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.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+/**
+ * 驾驶舱工单状态增量契约(执行票 13)。
+ *
+ * <p>锁定四条外部行为:首扫建基线不推送(REST 才是初始快照);只有 orderStatus
+ * 真实变化才推(工单状态流转不写 update_time,扫描必须逐单比对状态);帧形状
+ * 携带 scanIntervalMs/serverUpdatedAt 诚实标注延迟;无全量订阅会话时不查库、
+ * 失效会话被清理(降级与重连语义)。</p>
+ */
+class CockpitWorkOrderDeltaPushTaskTest {
+
+    private static final String SESSION_ID = "wo-delta-session-1";
+
+    private DeviceStatusWebSocketHandler handler;
+    private WebSocketSession session;
+    private SubscriptionManager subscriptionManager;
+    private WorkOrderService workOrderService;
+    private EquipmentBaseMapper equipmentBaseMapper;
+    private CockpitWorkOrderDeltaPushTask task;
+
+    @BeforeAll
+    static void initLambdaCache() {
+        MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
+        TableInfoHelper.initTableInfo(assistant, WorkOrder.class);
+        TableInfoHelper.initTableInfo(assistant, EquipmentBase.class);
+    }
+
+    @BeforeEach
+    void setUp() throws Exception {
+        handler = new DeviceStatusWebSocketHandler();
+        session = mock(WebSocketSession.class);
+        HashMap<String, Object> attributes = new HashMap<>();
+        org.mockito.Mockito.doReturn(SESSION_ID).when(session).getId();
+        org.mockito.Mockito.doReturn(true).when(session).isOpen();
+        org.mockito.Mockito.doReturn(attributes).when(session).getAttributes();
+        ReflectionTestUtils.setField(handler, "equipmentStatusService", mock(EquipmentStatusService.class));
+        subscriptionManager = new SubscriptionManager();
+        ReflectionTestUtils.setField(handler, "subscriptionManager", subscriptionManager);
+        ReflectionTestUtils.setField(handler, "tokenService", mock(TokenService.class));
+        ReflectionTestUtils.setField(handler, "businessWorkOrderService", mock(WorkOrderService.class));
+
+        workOrderService = mock(WorkOrderService.class);
+        equipmentBaseMapper = mock(EquipmentBaseMapper.class);
+        task = new CockpitWorkOrderDeltaPushTask();
+        ReflectionTestUtils.setField(task, "workOrderService", workOrderService);
+        ReflectionTestUtils.setField(task, "equipmentBaseMapper", equipmentBaseMapper);
+        ReflectionTestUtils.setField(task, "subscriptionManager", subscriptionManager);
+        ReflectionTestUtils.setField(task, "webSocketHandler", handler);
+        ReflectionTestUtils.setField(task, "scanIntervalMs", 60000L);
+
+        // 驾驶舱接入:连接 + 全量订阅(免鉴权,ADR 0005)
+        handler.afterConnectionEstablished(session);
+        handler.handleTextMessage(session, new TextMessage(
+                "{\"type\":\"cockpitSubscribe\",\"scope\":\"all\"}"));
+    }
+
+    @AfterEach
+    void tearDown() {
+        handler.afterConnectionClosed(session, CloseStatus.NORMAL);
+    }
+
+    @Test
+    void firstScanEstablishesBaselineWithoutPush() throws Exception {
+        when(workOrderService.list(any(LambdaQueryWrapper.class))).thenReturn(Arrays.asList(
+                order(279L, "WO-279", "EQ-1", 5),
+                order(280L, "WO-280", "EQ-1", 1)));
+
+        task.pushWorkOrderStateDelta();
+
+        // 只有订阅回执一帧,没有任何增量(REST 才是初始快照)
+        assertEquals(0, deltaFrames().size());
+    }
+
+    @Test
+    void stateChangePushesDeltaWithDictionaryStateNames() throws Exception {
+        when(workOrderService.list(any(LambdaQueryWrapper.class))).thenReturn(Arrays.asList(
+                order(279L, "WO-279", "EQ-1", 5),
+                order(280L, "WO-280", "EQ-1", 1)));
+        task.pushWorkOrderStateDelta(); // 基线
+
+        when(workOrderService.list(any(LambdaQueryWrapper.class))).thenReturn(Arrays.asList(
+                order(279L, "WO-279", "EQ-1", 5),
+                order(280L, "WO-280", "EQ-1", 5)));
+        when(equipmentBaseMapper.selectBatchIds(any())).thenReturn(Collections.singletonList(
+                equipment("EQ-1", "TC-PS-001")));
+
+        task.pushWorkOrderStateDelta();
+
+        List<JSONObject> frames = deltaFrames();
+        assertEquals(1, frames.size());
+        JSONObject frame = frames.get(0);
+        assertEquals("cockpitWorkOrderStateDelta", frame.getString("type"));
+        assertEquals(60000L, frame.getLongValue("scanIntervalMs"), "帧必须诚实标注更新延迟上限");
+        assertNotNull(frame.getString("serverUpdatedAt"));
+        JSONObject data = frame.getJSONObject("data");
+        assertEquals(1, data.getIntValue("total"));
+        JSONObject item = data.getJSONArray("items").getJSONObject(0);
+        assertEquals("WO-280", item.getString("workOrderCode"));
+        assertFalse(item.containsKey("orderId"), "增量帧不泄漏数据库主键(REST 读模型同口径)");
+        assertEquals("TC-PS-001", item.getString("deviceCode"));
+        assertEquals(5, item.getIntValue("stateCode"));
+        assertEquals("已结案", item.getString("stateName"), "状态名出自字典单源(票 11)");
+    }
+
+    @Test
+    void newlyAppearingOrderIsPushedAsDelta() throws Exception {
+        when(workOrderService.list(any(LambdaQueryWrapper.class))).thenReturn(Collections.singletonList(
+                order(279L, "WO-279", "EQ-1", 5)));
+        task.pushWorkOrderStateDelta(); // 基线
+
+        when(workOrderService.list(any(LambdaQueryWrapper.class))).thenReturn(Arrays.asList(
+                order(279L, "WO-279", "EQ-1", 5),
+                order(281L, "WO-281", null, 0)));
+        task.pushWorkOrderStateDelta();
+
+        JSONObject item = deltaFrames().get(0).getJSONObject("data").getJSONArray("items").getJSONObject(0);
+        assertEquals("WO-281", item.getString("workOrderCode"));
+        assertEquals("待派单", item.getString("stateName"));
+        assertNull(item.getString("deviceCode"), "无设备关联时编码显式 null,不静默丢");
+    }
+
+    @Test
+    void unchangedOrdersProduceNoSecondPush() throws Exception {
+        when(workOrderService.list(any(LambdaQueryWrapper.class))).thenReturn(Collections.singletonList(
+                order(279L, "WO-279", "EQ-1", 5)));
+        task.pushWorkOrderStateDelta();
+        task.pushWorkOrderStateDelta();
+
+        assertEquals(0, deltaFrames().size(), "状态未变不得重复推送");
+    }
+
+    @Test
+    void unknownStateCodeKeepsRawCodeWithExplicitNullName() throws Exception {
+        when(workOrderService.list(any(LambdaQueryWrapper.class))).thenReturn(Collections.singletonList(
+                order(279L, "WO-279", "EQ-1", 5)));
+        task.pushWorkOrderStateDelta();
+
+        when(workOrderService.list(any(LambdaQueryWrapper.class))).thenReturn(Collections.singletonList(
+                order(279L, "WO-279", "EQ-1", 99)));
+        task.pushWorkOrderStateDelta();
+
+        JSONObject item = deltaFrames().get(0).getJSONObject("data").getJSONArray("items").getJSONObject(0);
+        assertEquals(99, item.getIntValue("stateCode"), "脏状态码原样上报");
+        assertNull(item.getString("stateName"), "字典外的码不编造名称");
+    }
+
+    @Test
+    void noCockpitSessionMeansNoDatabaseScan() {
+        subscriptionManager.removeSession(SESSION_ID);
+
+        task.pushWorkOrderStateDelta();
+
+        verifyNoInteractions(workOrderService, equipmentBaseMapper);
+    }
+
+    @Test
+    void staleAllScopeSessionIsPrunedWhenSendFails() throws Exception {
+        when(workOrderService.list(any(LambdaQueryWrapper.class))).thenReturn(Collections.singletonList(
+                order(279L, "WO-279", "EQ-1", 5)));
+        task.pushWorkOrderStateDelta(); // 基线
+
+        // 模拟断线后会话未清理:订阅集合里有一个已无活连接的会话
+        subscriptionManager.subscribeAll("dead-session");
+        when(workOrderService.list(any(LambdaQueryWrapper.class))).thenReturn(Collections.singletonList(
+                order(279L, "WO-279", "EQ-1", 1)));
+
+        task.pushWorkOrderStateDelta();
+
+        assertFalse(subscriptionManager.getAllScopeSessions().contains("dead-session"),
+                "发送失败的失效会话必须被清出订阅集合");
+        assertTrue(subscriptionManager.getAllScopeSessions().contains(SESSION_ID));
+    }
+
+    // --- 断言与夹具 ---
+
+    /** 从会话已发消息中筛出工单状态增量帧(订阅回执不算)。 */
+    private List<JSONObject> deltaFrames() throws Exception {
+        ArgumentCaptor<TextMessage> messages = ArgumentCaptor.forClass(TextMessage.class);
+        verify(session, org.mockito.Mockito.atLeast(0)).sendMessage(messages.capture());
+        return messages.getAllValues().stream()
+                .map(item -> JSONObject.parseObject(item.getPayload()))
+                .filter(item -> "cockpitWorkOrderStateDelta".equals(item.getString("type")))
+                .collect(java.util.stream.Collectors.toList());
+    }
+
+    private static WorkOrder order(Long orderId, String orderNo, String deviceId, int status) {
+        WorkOrder order = new WorkOrder();
+        order.setOrderId(orderId);
+        order.setOrderNo(orderNo);
+        order.setDeviceId(deviceId);
+        order.setOrderStatus(status);
+        return order;
+    }
+
+    private static EquipmentBase equipment(String equipmentId, String equipmentCode) {
+        EquipmentBase equipment = new EquipmentBase();
+        equipment.setEquipmentId(equipmentId);
+        equipment.setEquipmentCode(equipmentCode);
+        return equipment;
+    }
+}

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

@@ -179,6 +179,43 @@ class DeviceStatusCockpitWebSocketHandlerTest {
         assertTrue(delta.get("monitoringPointCode") == null);
     }
 
+    @Test
+    void cockpitDeltaCarriesEventIdWhenPresentAndExplicitNullWhenAbsent() throws Exception {
+        // 执行票 13 裁定:Redis 消息携带 eventId 时透传(前端可精确重取该报警),
+        // 缺省显式 null(WriteMapNullValue 序列化),不静默丢字段。
+        handler.afterConnectionEstablished(session);
+        handler.handleTextMessage(session, new TextMessage(
+                "{\"type\":\"cockpitSubscribe\",\"scope\":\"all\"}"));
+
+        String deviceA = "DEV-A-EVT";
+        String deviceB = "DEV-B-NOEVT";
+        when(equipmentStatusService.existsByDeviceCode(deviceA)).thenReturn(true);
+        when(equipmentStatusService.getByDeviceCode(deviceA)).thenReturn(alarmStatus());
+        when(equipmentStatusService.existsByDeviceCode(deviceB)).thenReturn(true);
+        when(equipmentStatusService.getByDeviceCode(deviceB)).thenReturn(alarmStatus());
+        when(equipmentBaseService.getOne(org.mockito.ArgumentMatchers.any())).thenReturn(null);
+
+        DeviceStatusRedisListener listener = newListener();
+        listener.onMessage(message("{\"deviceCode\":\"" + deviceA + "\","
+                + "\"eventId\":\"alarm-evt-1\",\"serverUpdatedAt\":\"2026-09-02T10:00:00+08:00\"}"), null);
+        listener.onMessage(message("{\"deviceCode\":\"" + deviceB + "\","
+                + "\"serverUpdatedAt\":\"2026-09-02T10:00:01+08:00\"}"), null);
+
+        ArgumentCaptor<TextMessage> messages = ArgumentCaptor.forClass(TextMessage.class);
+        verify(session, org.mockito.Mockito.times(3)).sendMessage(messages.capture());
+        java.util.Map<String, JSONObject> deltas = new java.util.HashMap<>();
+        for (TextMessage item : messages.getAllValues()) {
+            JSONObject frame = JSONObject.parseObject(item.getPayload());
+            if ("cockpitDeviceStatusDelta".equals(frame.getString("type"))) {
+                deltas.put(frame.getString("deviceCode"), frame);
+            }
+        }
+        assertEquals(2, deltas.size());
+        assertEquals("alarm-evt-1", deltas.get(deviceA).getString("eventId"));
+        assertTrue(deltas.get(deviceB).containsKey("eventId"), "缺省时字段也必须在帧里显式存在");
+        assertTrue(deltas.get(deviceB).get("eventId") == null);
+    }
+
     @Test
     void existingDeviceCodeSubscriptionStillReceivesLegacyPush() throws Exception {
         handler.afterConnectionEstablished(session);