Selaa lähdekoodia

fix(cockpit): 报警标识按 alarm_no 解析,修正 ALARM 工单查询恒空

复验时发现两处由真实库暴露的缺陷(执行票 12 范围 1):

1. `alarm_data.id` 在真实库中**全部是同一个全零占位值**
   (`00000000000000000000000000000000`,6/6 行相同),不具标识性。
   `loadByEvent` 原用 `alarmDataService.getById(eventCode)` 按该主键解析,
   导致 `GET /events/ALARM/{code}/work-orders` **恒回 EMPTY** —— 即使该报警
   确实有工单(实测 work_order 279/280 各被一条报警引用)。
   改为优先按 `alarm_no`(真实单号)解析,主键仅作兼容回退。

2. `eventAssociation.eventCode` 原回 `alarm.getId()`,即把全零占位值当报警
   标识发出,无法区分报警。改为回 `alarm_no`,为空时才退回 id(不编造)。

同时把设备/报警维度的工单查询由 `lambdaQuery()` 链改为 `list(wrapper)`:
与仓库其它只读适配器一致,且单测可直接桩 `list(...)`,无需伪造 MyBatis
链式对象。

新增 `BusinessCockpitWorkOrderReadAdapterTest`(2 例)钉住这两个缺陷 ——
控制器层测试用 mock 适配器测不到;已做变异检验(把 eventCode 改回 id
或用空列表跳过 alarm_no 解析),测试确实变红。

验证:`-Dtest=Cockpit*Test,Business*AdapterTest` **371/371 通过**
(此前 311 的统计漏掉了 `Business*AdapterTest` 一族,非真实下降)。
Kazerin 1 viikko sitten
vanhempi
commit
905f593a4c

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

@@ -0,0 +1,154 @@
+package com.zksy.web.controller.cockpit;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.zksy.base.alarm.domain.AlarmData;
+import com.zksy.base.alarm.service.AlarmDataService;
+import com.zksy.base.domain.EquipmentBase;
+import com.zksy.base.mapper.EquipmentBaseMapper;
+import com.zksy.base.warning.mapper.AlarmWarningRelMapper;
+import com.zksy.base.warning.service.EarlyWarningService;
+import com.zksy.cockpit.workorder.BusinessCockpitWorkOrderReadAdapter;
+import com.zksy.cockpit.workorder.CockpitWorkOrderReadSnapshot;
+import com.zksy.cockpit.workorder.CockpitWorkOrderSummary;
+import com.zksy.ops.domain.WorkOrder;
+import com.zksy.ops.service.WorkOrderService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+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.when;
+
+/**
+ * 工单读适配器契约(执行票 12 范围 1)。
+ *
+ * <p>这两组断言针对**真实库暴露过的缺陷**,控制器层测试用 mock 适配器测不到:
+ * <ol>
+ *   <li>`alarm_data.id` 在真实库中全部是同一个**全零占位值**(非标识性)——
+ *       若按 `id` 解析报警,`events/ALARM/{code}/work-orders` 恒回 EMPTY,
+ *       即使该报警确实有工单。故必须按 `alarm_no` 解析。</li>
+ *   <li>设备维度返回该设备已有**全部**工单,未关联报警者须**显式**标 false,
+ *       且不得伪造 `eventAssociation`(不得拿全零 id 冒充报警标识)。</li>
+ * </ol>
+ */
+class BusinessCockpitWorkOrderReadAdapterTest {
+
+    private static final String DEVICE_CODE = "EQ-001";
+    private static final String EQUIPMENT_ID = "EQID-001";
+    /** 真实库中 alarm_data.id 的取值:全零占位,不具标识性。 */
+    private static final String PLACEHOLDER_ID = "00000000000000000000000000000000";
+
+    private WorkOrderService workOrderService;
+    private AlarmDataService alarmDataService;
+    private EquipmentBaseMapper equipmentBaseMapper;
+    private BusinessCockpitWorkOrderReadAdapter adapter;
+
+    @BeforeEach
+    void setUp() {
+        // MyBatis-Plus 的 LambdaQueryWrapper 需要实体的 lambda 缓存才能解析
+        // method reference(`EquipmentBase::getEquipmentCode`)。纯单测里没有
+        // Spring/MyBatis 上下文,需显式注册一次,否则构建 wrapper 就抛
+        // "can not find lambda cache for this entity"。
+        initLambdaCache(EquipmentBase.class);
+        initLambdaCache(WorkOrder.class);
+        initLambdaCache(AlarmData.class);
+
+        workOrderService = mock(WorkOrderService.class);
+        alarmDataService = mock(AlarmDataService.class);
+        equipmentBaseMapper = mock(EquipmentBaseMapper.class);
+        adapter = new BusinessCockpitWorkOrderReadAdapter(
+                workOrderService,
+                mock(EarlyWarningService.class),
+                mock(AlarmWarningRelMapper.class),
+                alarmDataService,
+                equipmentBaseMapper);
+    }
+
+    /** 注册实体的 lambda 缓存(等价于 MyBatis-Plus 启动时的 TableInfoHelper 初始化)。 */
+    private static void initLambdaCache(Class<?> entityClass) {
+        com.baomidou.mybatisplus.core.metadata.TableInfoHelper.initTableInfo(
+                new org.apache.ibatis.builder.MapperBuilderAssistant(
+                        new org.apache.ibatis.session.Configuration(), ""),
+                entityClass);
+    }
+
+    /** 报警维度:必须按 alarm_no 解析,不能因主键是全零占位就恒回 EMPTY。 */
+    @Test
+    void alarmEventResolvesByAlarmNoNotByPlaceholderId() {
+        AlarmData alarm = alarm(PLACEHOLDER_ID, "BJ202609291243545660", 279L);
+        when(alarmDataService.list(any(LambdaQueryWrapper.class)))
+                .thenReturn(Collections.singletonList(alarm));
+        when(workOrderService.list(any(LambdaQueryWrapper.class)))
+                .thenReturn(Collections.singletonList(order(279L, "WO-279", 5)));
+
+        CockpitWorkOrderReadSnapshot snapshot =
+                adapter.loadByEvent("ALARM", "BJ202609291243545660");
+
+        assertNotNull(snapshot, "按真实单号必须能解析到报警");
+        assertEquals(1, snapshot.getWorkOrders().size(), "该报警确有工单,不得回 EMPTY");
+        CockpitWorkOrderSummary summary = snapshot.getWorkOrders().get(0);
+        assertEquals("WO-279", summary.getWorkOrderCode());
+        assertTrue(summary.isAssociatedWithCurrentEvent(), "报警维度命中的工单即实际关联");
+        // eventCode 必须回真实单号,不得回全零占位 id
+        assertEquals("BJ202609291243545660", summary.getEventAssociation().getEventCode());
+        assertEquals("BJ202609291243545660", snapshot.getEventCode());
+    }
+
+    /** 未关联报警的工单:显式 false 且不得伪造 eventAssociation。 */
+    @Test
+    void deviceScopeMarksUnassociatedOrdersExplicitlyWithoutFabricatingAssociation() {
+        when(equipmentBaseMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(equipment());
+        List<WorkOrder> orders = Arrays.asList(
+                order(279L, "WO-LINKED", 5),
+                order(281L, "WO-ORPHAN", 1));
+        // 只有 279 被报警引用;281 无任何报警关联
+        when(alarmDataService.list(any(LambdaQueryWrapper.class)))
+                .thenReturn(Collections.singletonList(alarm(PLACEHOLDER_ID, "BJ-001", 279L)));
+        when(workOrderService.list(any(LambdaQueryWrapper.class))).thenReturn(orders);
+
+        CockpitWorkOrderReadSnapshot snapshot = adapter.loadByDevice(DEVICE_CODE);
+
+        assertEquals(2, snapshot.getWorkOrders().size(), "设备维度返回已有全部工单");
+        CockpitWorkOrderSummary linked = snapshot.getWorkOrders().get(0);
+        assertTrue(linked.isAssociatedWithCurrentEvent());
+        assertEquals("BJ-001", linked.getEventAssociation().getEventCode());
+
+        CockpitWorkOrderSummary orphan = snapshot.getWorkOrders().get(1);
+        assertFalse(orphan.isAssociatedWithCurrentEvent(), "未关联必须显式 false");
+        assertNull(orphan.getEventAssociation(), "未关联不得伪造关联对象");
+    }
+
+    // --- fixtures ---
+
+    private static AlarmData alarm(String id, String alarmNo, Long workOrderId) {
+        AlarmData alarm = new AlarmData();
+        alarm.setId(id);
+        alarm.setAlarmNo(alarmNo);
+        alarm.setWorkOrderId(workOrderId);
+        return alarm;
+    }
+
+    private static WorkOrder order(Long orderId, String orderNo, int status) {
+        WorkOrder order = new WorkOrder();
+        order.setOrderId(orderId);
+        order.setOrderNo(orderNo);
+        order.setOrderStatus(status);
+        return order;
+    }
+
+    private static EquipmentBase equipment() {
+        EquipmentBase base = new EquipmentBase();
+        base.setEquipmentId(EQUIPMENT_ID);
+        base.setEquipmentCode(DEVICE_CODE);
+        return base;
+    }
+}

+ 69 - 13
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/workorder/BusinessCockpitWorkOrderReadAdapter.java

@@ -58,10 +58,7 @@ public class BusinessCockpitWorkOrderReadAdapter implements CockpitWorkOrderRead
         if (equipment == null) {
             return new CockpitWorkOrderReadSnapshot(deviceCode, null, null, Collections.emptyList());
         }
-        List<WorkOrder> orders = workOrderService.lambdaQuery()
-                .eq(WorkOrder::getDeviceId, equipment.getEquipmentId())
-                .orderByDesc(WorkOrder::getCreateTime)
-                .list();
+        List<WorkOrder> orders = deviceOrders(equipment.getEquipmentId());
         // 设备维度:返回该设备**已有全部工单**。逐条解析该工单实际关联的报警
         // (alarm_data.work_order_id = work_order.order_id,经真实库核实),
         // 未关联任何报警的工单以 associatedWithCurrentEvent=false 显式标出 ——
@@ -72,12 +69,66 @@ public class BusinessCockpitWorkOrderReadAdapter implements CockpitWorkOrderRead
                     AlarmData alarm = alarmByOrderId.get(order.getOrderId());
                     return alarm == null
                             ? toSummary(order, null, null, false)
-                            : toSummary(order, "ALARM", alarm.getId(), true);
+                            : toSummary(order, "ALARM", alarmReference(alarm), true);
                 })
                 .collect(Collectors.toList());
         return new CockpitWorkOrderReadSnapshot(deviceCode, null, null, summaries);
     }
 
+    /**
+     * 按对外报警标识解析报警:先按 {@code alarm_no}(真实单号),再退回主键 {@code id}。
+     *
+     * <p>必须优先 alarm_no —— 真实库中 `id` 是全零占位值,仅按 id 查不可能命中真实报警。
+     * 退回 id 是为了兼容将来主键恢复为真实 UUID 的数据。
+     */
+    private AlarmData findAlarmByReference(String eventCode) {
+        if (isBlank(eventCode)) {
+            return null;
+        }
+        String reference = eventCode.trim();
+        List<AlarmData> byNo = alarmDataService.list(new LambdaQueryWrapper<AlarmData>()
+                .eq(AlarmData::getAlarmNo, reference)
+                .last("LIMIT 1"));
+        if (byNo != null) {
+            for (AlarmData alarm : byNo) {
+                if (alarm != null) {
+                    return alarm;
+                }
+            }
+        }
+        return alarmDataService.getById(reference);
+    }
+
+    /**
+     * 报警的对外可读标识。
+     *
+     * <p>真实库中 {@code alarm_data.id} **全部是全零占位值**(非标识性),拿它当
+     * {@code eventCode} 只会给出一个无法区分的假标识;{@code alarm_no} 才是真实单号。
+     * 故优先回 `alarm_no`,为空时才退回 `id`(不编造)。
+     */
+    private static String alarmReference(AlarmData alarm) {
+        return isBlank(alarm.getAlarmNo()) ? alarm.getId() : alarm.getAlarmNo();
+    }
+
+    private static boolean isBlank(String value) {
+        return value == null || value.trim().isEmpty();
+    }
+
+    /**
+     * 设备已有全部工单,按创建时间倒序。
+     *
+     * <p>用 `list(wrapper)` 而非 `lambdaQuery()` 链式调用:与仓库其它只读适配器一致,
+     * 也让单测可以像其它适配器测试那样直接桩 `list(...)`,不必伪造 MyBatis 链式对象。
+     */
+    private List<WorkOrder> deviceOrders(String equipmentId) {
+        if (isBlank(equipmentId)) {
+            return Collections.emptyList();
+        }
+        return workOrderService.list(new LambdaQueryWrapper<WorkOrder>()
+                .eq(WorkOrder::getDeviceId, equipmentId.trim())
+                .orderByDesc(WorkOrder::getCreateTime));
+    }
+
     /** 批量解析「工单 → 关联报警」:一次查询覆盖本页全部工单,避免逐条打库。 */
     private Map<Long, AlarmData> alarmsByOrderId(List<WorkOrder> orders) {
         List<Long> orderIds = orders.stream()
@@ -102,16 +153,21 @@ public class BusinessCockpitWorkOrderReadAdapter implements CockpitWorkOrderRead
     @Override
     public CockpitWorkOrderReadSnapshot loadByEvent(String eventSource, String eventCode) {
         if ("ALARM".equals(eventSource)) {
-            AlarmData alarm = alarmDataService.getById(eventCode);
-            List<WorkOrder> orders = alarm == null || alarm.getWorkOrderId() == null ? Collections.emptyList()
-                    : workOrderService.lambdaQuery()
-                    .eq(WorkOrder::getOrderId, alarm.getWorkOrderId())
-                    .orderByDesc(WorkOrder::getCreateTime)
-                    .list();
+            // 报警标识用 alarm_no 解析,不用主键 id:真实库中 alarm_data.id 全部是
+            // 同一个全零占位值(非标识性),getById 既无法区分报警、也永远取不到
+            // 任何真实报警(实测所有 ALARM 查询恒回 EMPTY,即使该报警确有工单)。
+            AlarmData alarm = findAlarmByReference(eventCode);
+            List<WorkOrder> orders = alarm == null || alarm.getWorkOrderId() == null
+                    ? Collections.emptyList()
+                    : workOrderService.list(new LambdaQueryWrapper<WorkOrder>()
+                            .eq(WorkOrder::getOrderId, alarm.getWorkOrderId())
+                            .orderByDesc(WorkOrder::getCreateTime));
             // 报警维度:工单经 alarm_data.work_order_id 反查,命中即「实际关联本报警」。
-            return new CockpitWorkOrderReadSnapshot(null, eventSource, eventCode,
+            // 回显的 eventCode 用报警真实单号(与请求标识一致的可读值)。
+            String resolvedEventCode = alarm == null ? eventCode : alarmReference(alarm);
+            return new CockpitWorkOrderReadSnapshot(null, eventSource, resolvedEventCode,
                     orders.stream()
-                            .map(order -> toSummary(order, eventSource, eventCode, true))
+                            .map(order -> toSummary(order, eventSource, resolvedEventCode, true))
                             .collect(Collectors.toList()));
         }
         if ("WARNING".equals(eventSource)) {