diff --git a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/integration/listener/BadgeAreaBoundEventListener.java b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/integration/listener/BadgeAreaBoundEventListener.java new file mode 100644 index 00000000..6841b4ff --- /dev/null +++ b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/integration/listener/BadgeAreaBoundEventListener.java @@ -0,0 +1,111 @@ +package com.viewsh.module.ops.environment.integration.listener; + +import com.viewsh.framework.common.pojo.CommonResult; +import com.viewsh.module.iot.api.device.IotDeviceQueryApi; +import com.viewsh.module.iot.api.device.dto.IotDeviceSimpleRespDTO; +import com.viewsh.module.ops.enums.BadgeDeviceStatusEnum; +import com.viewsh.module.ops.environment.integration.dto.IotDeviceStatusChangedEventDTO; +import com.viewsh.module.ops.environment.service.badge.BadgeDeviceStatusService; +import com.viewsh.module.ops.service.area.event.AreaDeviceBoundEvent; +import com.viewsh.module.ops.service.area.event.AreaDeviceUnboundEvent; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * 区域-工牌设备绑定/解绑事件监听器 + *
+ * 绑定({@link AreaDeviceBoundEvent}): + * BADGE 关系建立前,IoT 实时上线事件会被 {@code BadgeDeviceStatusEventHandler.isBadgeDevice()} + * 拒掉;建立关系后没有任何机制回填 Redis,导致设备直到下次定时对账(5/30 分钟)才会出现在 + * "可分配工牌"列表,期间收到的工单也无法派给该设备。监听器在绑定事务提交后定向查询一次 + * IoT 设备信息(含状态、昵称),回写 Ops 工牌缓存。 + *
+ * 解绑({@link AreaDeviceUnboundEvent}): + * 解绑后 SyncJob 因关系记录消失不会再扫到该设备,Redis 工牌缓存得等 24h TTL 自然过期, + * 期间该设备仍可能出现在"可分配/活跃工牌"列表里。监听器在解绑事务提交后立即清理 Redis 状态, + * 与绑定路径形成闭环。 + *
+ * 二者均使用 AFTER_COMMIT + @Async:事务提交后才在独立线程执行,不阻塞绑定/解绑接口响应。
+ *
+ * @author lzh
+ */
+@Slf4j
+@Component
+public class BadgeAreaBoundEventListener {
+
+ private static final String TYPE_BADGE = "BADGE";
+
+ @Resource
+ private IotDeviceQueryApi iotDeviceQueryApi;
+
+ @Resource
+ private BadgeDeviceStatusService badgeDeviceStatusService;
+
+ @Async("ops-task-executor")
+ @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
+ public void onAreaDeviceBound(AreaDeviceBoundEvent event) {
+ if (event == null || !TYPE_BADGE.equals(event.getRelationType())) {
+ return;
+ }
+ Long deviceId = event.getDeviceId();
+ Long areaId = event.getAreaId();
+ if (deviceId == null) {
+ return;
+ }
+
+ try {
+ // 单次 RPC 取齐 state + nickname + deviceName(IotDeviceSimpleRespDTO 已含 state 字段)
+ CommonResult
+ * 在 {@code AreaDeviceRelationService.bindDevice()} 成功插入关系记录后发布。
+ *
+ * 业务背景:BADGE 关系建立前,IoT 实时上线事件会因 {@code BadgeDeviceStatusEventHandler.isBadgeDevice()}
+ * 返回 false 而被丢弃;建立关系后没有任何机制回填 Redis,需等定时对账 Job 才能恢复,
+ * 表现为 "可分配工牌列表" 不出现新绑定的设备、新工单也不会派给它。
+ * 监听方(条线模块)通过订阅本事件完成一次定向状态同步。
+ *
+ * @author lzh
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class AreaDeviceBoundEvent {
+
+ /** 区域ID */
+ private Long areaId;
+
+ /** 设备ID */
+ private Long deviceId;
+
+ /** 关联类型:TRAFFIC_COUNTER / BEACON / BADGE */
+ private String relationType;
+}
diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/area/event/AreaDeviceUnboundEvent.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/area/event/AreaDeviceUnboundEvent.java
new file mode 100644
index 00000000..522dcfc6
--- /dev/null
+++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/area/event/AreaDeviceUnboundEvent.java
@@ -0,0 +1,33 @@
+package com.viewsh.module.ops.service.area.event;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * 区域-设备解绑完成事件
+ *
+ * 在 {@code AreaDeviceRelationService.unbindDevice()} 成功删除关系记录后发布。
+ *
+ * 业务背景:BADGE 解绑后 SyncJob 不再扫到该设备,Redis 工牌缓存等 24h TTL 才过期,
+ * 期间该设备仍可能出现在"可分配/活跃工牌"列表里。条线监听器订阅本事件后立即清理 Redis,
+ * 与 {@link AreaDeviceBoundEvent} 的回填路径形成闭环。
+ *
+ * @author lzh
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class AreaDeviceUnboundEvent {
+
+ /** 区域ID */
+ private Long areaId;
+
+ /** 设备ID */
+ private Long deviceId;
+
+ /** 关联类型:TRAFFIC_COUNTER / BEACON / BADGE */
+ private String relationType;
+}
diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/test/java/com/viewsh/module/ops/service/area/AreaDeviceRelationServiceTest.java b/viewsh-module-ops/viewsh-module-ops-biz/src/test/java/com/viewsh/module/ops/service/area/AreaDeviceRelationServiceTest.java
index a086ad41..55d1f1ce 100644
--- a/viewsh-module-ops/viewsh-module-ops-biz/src/test/java/com/viewsh/module/ops/service/area/AreaDeviceRelationServiceTest.java
+++ b/viewsh-module-ops/viewsh-module-ops-biz/src/test/java/com/viewsh/module/ops/service/area/AreaDeviceRelationServiceTest.java
@@ -1,5 +1,8 @@
package com.viewsh.module.ops.service.area;
+import com.viewsh.framework.common.pojo.CommonResult;
+import com.viewsh.module.iot.api.device.IotDeviceQueryApi;
+import com.viewsh.module.iot.api.device.dto.IotDeviceSimpleRespDTO;
import com.viewsh.module.ops.dal.dataobject.area.OpsAreaDeviceRelationDO;
import com.viewsh.module.ops.dal.dataobject.area.OpsBusAreaDO;
import com.viewsh.module.ops.dal.mysql.area.OpsAreaDeviceRelationMapper;
@@ -15,6 +18,7 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.context.ApplicationEventPublisher;
import java.util.Arrays;
import java.util.Collections;
@@ -43,14 +47,20 @@ class AreaDeviceRelationServiceTest {
@Mock
private OpsAreaDeviceRelationMapper opsAreaDeviceRelationMapper;
- @Mock
- private OpsBusAreaMapper opsBusAreaMapper;
-
- @Mock
- private AreaDeviceService areaDeviceService;
-
- @InjectMocks
- private AreaDeviceRelationServiceImpl areaDeviceRelationService;
+ @Mock
+ private OpsBusAreaMapper opsBusAreaMapper;
+
+ @Mock
+ private AreaDeviceService areaDeviceService;
+
+ @Mock
+ private ApplicationEventPublisher eventPublisher;
+
+ @Mock
+ private IotDeviceQueryApi iotDeviceQueryApi;
+
+ @InjectMocks
+ private AreaDeviceRelationServiceImpl areaDeviceRelationService;
private OpsBusAreaDO testArea;
private OpsAreaDeviceRelationDO testRelation;
@@ -121,12 +131,22 @@ class AreaDeviceRelationServiceTest {
return 1;
});
+ // bindDevice 内部会调 IoT 接口阻断式校验设备存在性
+ IotDeviceSimpleRespDTO iotDevice = new IotDeviceSimpleRespDTO();
+ iotDevice.setId(50001L);
+ iotDevice.setDeviceName("TRAFFIC_COUNTER_001");
+ iotDevice.setProductId(10L);
+ iotDevice.setProductKey("traffic_counter_v1");
+ when(iotDeviceQueryApi.getDevice(50001L)).thenReturn(CommonResult.success(iotDevice));
+
// When
Long relationId = areaDeviceRelationService.bindDevice(bindReq);
// Then
assertNotNull(relationId);
verify(opsAreaDeviceRelationMapper, times(1)).insert(any(OpsAreaDeviceRelationDO.class));
+ // 验证绑定成功后发布事件,供条线监听器回填 Redis
+ verify(eventPublisher, times(1)).publishEvent(any(com.viewsh.module.ops.service.area.event.AreaDeviceBoundEvent.class));
}
@Test
@@ -225,6 +245,8 @@ class AreaDeviceRelationServiceTest {
// Then
assertTrue(result);
verify(opsAreaDeviceRelationMapper, times(1)).deleteById(1L);
+ // 验证解绑后发布事件,供条线监听器清理 Redis
+ verify(eventPublisher, times(1)).publishEvent(any(com.viewsh.module.ops.service.area.event.AreaDeviceUnboundEvent.class));
}
@Test
@@ -238,6 +260,8 @@ class AreaDeviceRelationServiceTest {
// Then
assertFalse(result); // 第一次就返回false
verify(opsAreaDeviceRelationMapper, never()).deleteById(anyLong());
+ // 不存在的关联不应触发事件
+ verify(eventPublisher, never()).publishEvent(any());
}
}