From 8c664a479d25c12438cf168c71df50eb9e4751c2 Mon Sep 17 00:00:00 2001 From: lzh Date: Wed, 22 Apr 2026 18:03:51 +0800 Subject: [PATCH 1/3] =?UTF-8?q?refactor(ops):=20=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E8=BD=AC=E6=8D=A2=E6=88=90=E5=8A=9F=E4=B8=8D=E5=86=8D=E9=95=9C?= =?UTF-8?q?=E5=83=8F=E5=86=99=20bus=5Flog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:每次 transition() 成功 commit 后,OrderTransitionAuditListener 都会 向 bus_log 写一条"状态转换成功: X -> Y"的镜像记录。该信息已由各条线 EventListener(CleanOrderEventListener 的 ORDER_DISPATCHED 等)和 ops_order_event 表完整覆盖,bus_log 里这条镜像形成噪声且与业务日志重复, 线上一次工单流转能产出 4-5 条同义日志,运维抓异常时被淹没。 改动:onAfterCommit 在 event.isSuccess()=true 时直接 return; 失败 / 派发被拒(DISPATCH_REJECTED)/ 回滚三类异常路径继续写, 保留运维真正关心的"为什么失败"审计闭环。 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../lifecycle/audit/OrderTransitionAuditListener.java | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/core/lifecycle/audit/OrderTransitionAuditListener.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/core/lifecycle/audit/OrderTransitionAuditListener.java index bb10af99..33cf64d2 100644 --- a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/core/lifecycle/audit/OrderTransitionAuditListener.java +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/core/lifecycle/audit/OrderTransitionAuditListener.java @@ -46,12 +46,20 @@ public class OrderTransitionAuditListener { private EventLogRecorder eventLogRecorder; /** - * 主事务已提交:照事件声明写一条审计日志。 + * 主事务已提交:仅在转换异常路径下写 bus_log。 + *

+ * 取舍:成功路径不再写"状态转换成功"镜像记录——业务详情已由各条线 EventListener + * (如 CleanOrderEventListener 的 ORDER_DISPATCHED 等)和 ops_order_event 表覆盖, + * 此处镜像在 bus_log 形成噪声且与业务日志重复。仅保留派发被拒(DISPATCH_REJECTED) + * 等运维需追溯的异常类型,便于审计真正的"为什么失败"。 *

* fallbackExecution=true:在无事务上下文时也执行(如测试、跨线程补写场景)。 */ @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) public void onAfterCommit(OrderTransitionAttemptedEvent event) { + if (event.isSuccess()) { + return; + } try { eventLogRecorder.recordSync(toRecord(event, /*rolledBack=*/false)); } catch (Exception e) { From 6c4153fe23e5be8de1877e5c95df71fa477ea4b6 Mon Sep 17 00:00:00 2001 From: lzh Date: Wed, 22 Apr 2026 18:04:10 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(ops):=20=E6=89=8B=E5=8A=A8=E6=B4=BE?= =?UTF-8?q?=E5=8D=95=E6=8F=90=E5=89=8D=E5=86=99=E6=89=A7=E8=A1=8C=E4=BA=BA?= =?UTF-8?q?=E5=AD=97=E6=AE=B5=EF=BC=8C=E4=BF=AE=E5=A4=8D=E6=8C=89=E9=94=AE?= =?UTF-8?q?=E6=8A=A5"=E6=B2=A1=E6=9C=89=E5=B7=A5=E5=8D=95"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:管理员手动派单后,工牌按键查询持续返回"没有工单",TTS 循环播报"工单 来啦"但用户无法响应(线上日志可见 deviceId=58 一段时间内连续 8 次按键查询 全部命中 CleanOrderAuditEventHandler.handleQueryEvent 的"没有工单"分支)。 根因:ManualOrderActionFacade.dispatch() 原顺序是 1. transition() —— 事务内同步发布 OrderStateChangedEvent, BadgeDeviceStatusEventListener.onOrderStateChanged 重新 selectById 拿 assigneeId 决定是否写 Redis 工单关联; 2. update assigneeId / assigneeName。 第 1 步事件触发时 assigneeId 仍是 null,listener 走"工单未关联设备,跳过 处理"分支,Redis ops:badge:status:{deviceId} 的 currentOpsOrderId 永远不 会写入;同时主表 assigneeDeviceId 也始终为 null, CleanOrderAuditEventHandler.handleQueryEvent 用 "WHERE assignee_device_id=?" 查工单永远落空 → "没有工单"。 修复:把执行人字段更新前置到 transition() 之前,并补 setAssigneeDeviceId (与 OrderLifecycleManagerImpl.dispatch() 自动派单路径一致)。 事件 listener 此时 selectById 拿得到 deviceId,正常落 Redis;audit 查询也命中,按键路径恢复。 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../core/manual/ManualOrderActionFacade.java | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/core/manual/ManualOrderActionFacade.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/core/manual/ManualOrderActionFacade.java index c02168c3..f6d58b31 100644 --- a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/core/manual/ManualOrderActionFacade.java +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/core/manual/ManualOrderActionFacade.java @@ -72,7 +72,19 @@ public class ManualOrderActionFacade { boolean idle = strategy.isAssigneeIdle(cmd, order); WorkOrderStatusEnum targetStatus = idle ? WorkOrderStatusEnum.DISPATCHED : WorkOrderStatusEnum.QUEUED; - // 4. 状态变更 + // 4. 提前写入执行人字段 + // 注:必须在 transition() 之前完成。transition() 在事务内同步发布 OrderStateChangedEvent, + // BadgeDeviceStatusEventListener 会再次 selectById 拿 assigneeId 决定是否写 Redis 工单关联; + // 若此处后置则事件触发时 assigneeId 仍为 null,工牌按键查询 (assigneeDeviceId) 永远查不到工单。 + // 同时写 assigneeDeviceId,与 OrderLifecycleManagerImpl.dispatch() 自动派单路径对齐。 + OpsOrderDO assigneeUpdate = new OpsOrderDO(); + assigneeUpdate.setId(cmd.getOrderId()); + assigneeUpdate.setAssigneeId(cmd.getAssigneeId()); + assigneeUpdate.setAssigneeName(cmd.getAssigneeName()); + assigneeUpdate.setAssigneeDeviceId(cmd.getAssigneeId()); + opsOrderMapper.updateById(assigneeUpdate); + + // 5. 状态变更 OrderTransitionRequest request = OrderTransitionRequest.builder() .orderId(cmd.getOrderId()) .targetStatus(targetStatus) @@ -89,13 +101,6 @@ public class ManualOrderActionFacade { throw new IllegalStateException("手动派单失败: " + result.getMessage()); } - // 5. 更新主表执行人(只更新 assignee 字段,避免覆盖状态机已写入的 status) - OpsOrderDO assigneeUpdate = new OpsOrderDO(); - assigneeUpdate.setId(cmd.getOrderId()); - assigneeUpdate.setAssigneeId(cmd.getAssigneeId()); - assigneeUpdate.setAssigneeName(cmd.getAssigneeName()); - opsOrderMapper.updateById(assigneeUpdate); - // 6. 条线后置 // 注:业务日志由生命周期事件 → 条线 EventListener 统一记录,此处不重复写 strategy.afterDispatch(cmd, order); From 3cfd342318b7fb7812ac250018588dfd35d610de Mon Sep 17 00:00:00 2001 From: lzh Date: Wed, 22 Apr 2026 18:04:47 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(ops):=20BADGE=20=E7=BB=91=E5=AE=9A/?= =?UTF-8?q?=E8=A7=A3=E7=BB=91=E5=90=8E=E5=8D=B3=E6=97=B6=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E5=B7=A5=E7=89=8C=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题: 1. 设备先上线、后绑定为 BADGE 时,"可分配工牌"列表迟迟不出现该设备; 2. 已绑定区域的设备收不到工单(被分派策略过滤掉)。 根因:实时上线路径 BadgeDeviceStatusEventHandler.onMessage 在写 ops:badge:status:{deviceId} Redis 缓存前,先 isBadgeDevice() 校验 ops_area_device_relation 是否存在 BADGE 关系。设备如果在绑定前就上线, 事件被丢弃;建立关系后又没有任何机制回填 Redis,得等 5/30 分钟的 BadgeDeviceStatusSyncJob 对账才会被发现(线上日志可见 deviceId=58 在 绑定后 30 分钟才被对账修正:reason=定时对账修正-上线)。 解绑同样有反向缺口:关系记录删了但 Redis 缓存得等 24h TTL 自然过期, 期间 listAvailableBadges 仍可能返回该设备。 修复:在 ops-biz 引入 AreaDeviceBoundEvent / AreaDeviceUnboundEvent, bindDevice / unbindDevice 成功后通过 ApplicationEventPublisher 发布; environment-biz 新增 BadgeAreaBoundEventListener 仅订阅 BADGE 类型, 使用 @TransactionalEventListener(AFTER_COMMIT) + @Async 确保事务提交后 异步执行不阻塞接口: - 绑定:单次调 IotDeviceQueryApi.getDevice 取齐 state + nickname + deviceName,根据状态写 Redis(IDLE/OFFLINE); - 解绑:直接调 BadgeDeviceStatusService.deleteBadgeStatus 清理 Redis。 依赖方向遵循 environment-biz → ops-biz;ops-biz 不反向依赖条线模块, 通过事件解耦,与现有 OrderStateChangedEvent 模式一致。 测试: - AreaDeviceRelationServiceTest 补 iotDeviceQueryApi mock 让原本因缺 mock 而 RED 的 testBindDevice_TrafficCounter_Success 转绿; - 新增对 bound / unbound 事件 publishEvent 调用的 verify。 - 全套 10/10 通过。 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../listener/BadgeAreaBoundEventListener.java | 111 ++++++++++++++++++ .../area/AreaDeviceRelationServiceImpl.java | 25 ++++ .../area/event/AreaDeviceBoundEvent.java | 34 ++++++ .../area/event/AreaDeviceUnboundEvent.java | 33 ++++++ .../area/AreaDeviceRelationServiceTest.java | 40 +++++-- 5 files changed, 235 insertions(+), 8 deletions(-) create mode 100644 viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/integration/listener/BadgeAreaBoundEventListener.java create mode 100644 viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/area/event/AreaDeviceBoundEvent.java create mode 100644 viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/area/event/AreaDeviceUnboundEvent.java 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 result = iotDeviceQueryApi.getDevice(deviceId); + if (result == null || !result.isSuccess() || result.getData() == null) { + log.warn("[BadgeAreaBoundEventListener] 查询 IoT 设备失败,跳过回填: deviceId={}, msg={}", + deviceId, result != null ? result.getMsg() : "null"); + return; + } + IotDeviceSimpleRespDTO device = result.getData(); + + // IotDeviceSimpleRespDTO.state 与 IotDeviceStatusChangedEventDTO 的 status 编码一致 + // (0=未激活,1=在线,2=离线),未激活/离线统一回写 OFFLINE + BadgeDeviceStatusEnum target = IotDeviceStatusChangedEventDTO.STATUS_ONLINE.equals(device.getState()) + ? BadgeDeviceStatusEnum.IDLE + : BadgeDeviceStatusEnum.OFFLINE; + + badgeDeviceStatusService.updateBadgeOnlineStatus( + deviceId, + device.getDeviceName(), + device.getNickname(), + target == BadgeDeviceStatusEnum.IDLE ? areaId : null, + target, + "BADGE 绑定后回填"); + + log.info("[BadgeAreaBoundEventListener] 工牌设备状态回填完成: deviceId={}, areaId={}, target={}", + deviceId, areaId, target); + } catch (Exception e) { + log.error("[BadgeAreaBoundEventListener] 工牌设备状态回填失败: deviceId={}, areaId={}", + deviceId, areaId, e); + } + } + + @Async("ops-task-executor") + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) + public void onAreaDeviceUnbound(AreaDeviceUnboundEvent event) { + if (event == null || !TYPE_BADGE.equals(event.getRelationType())) { + return; + } + Long deviceId = event.getDeviceId(); + if (deviceId == null) { + return; + } + + try { + badgeDeviceStatusService.deleteBadgeStatus(deviceId); + log.info("[BadgeAreaBoundEventListener] 工牌设备解绑后 Redis 状态已清理: deviceId={}, areaId={}", + deviceId, event.getAreaId()); + } catch (Exception e) { + log.error("[BadgeAreaBoundEventListener] 工牌设备解绑后清理失败: deviceId={}", deviceId, e); + } + } +} diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/area/AreaDeviceRelationServiceImpl.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/area/AreaDeviceRelationServiceImpl.java index ba1a63dc..5757044f 100644 --- a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/area/AreaDeviceRelationServiceImpl.java +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/area/AreaDeviceRelationServiceImpl.java @@ -12,8 +12,11 @@ import com.viewsh.module.ops.dal.dataobject.vo.area.AreaDeviceBindReqVO; import com.viewsh.module.ops.dal.dataobject.vo.area.AreaDeviceRelationRespVO; import com.viewsh.module.ops.dal.dataobject.vo.area.AreaDeviceUpdateReqVO; import com.viewsh.module.ops.enums.ErrorCodeConstants; +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.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.validation.annotation.Validated; @@ -46,6 +49,9 @@ public class AreaDeviceRelationServiceImpl implements AreaDeviceRelationService @Resource private AreaDeviceService areaDeviceService; + @Resource + private ApplicationEventPublisher eventPublisher; + private static final String TYPE_TRAFFIC_COUNTER = "TRAFFIC_COUNTER"; private static final String TYPE_BEACON = "BEACON"; private static final String TYPE_BADGE = "BADGE"; @@ -116,6 +122,16 @@ public class AreaDeviceRelationServiceImpl implements AreaDeviceRelationService // 清除可能存在的 NULL_CACHE 标记 areaDeviceService.evictConfigCache(relation.getAreaId(), relation.getRelationType()); + // 发布绑定事件 + // 用途:BADGE 绑定前的实时上线事件会被丢弃(无 BADGE 关系), + // 条线监听器订阅此事件后可立即从 IoT 拉取当前状态,回填 Redis 工牌缓存, + // 避免新绑定的设备直到下次 5/30 分钟对账才能被派单或显示在"可分配工牌"列表。 + eventPublisher.publishEvent(AreaDeviceBoundEvent.builder() + .areaId(relation.getAreaId()) + .deviceId(relation.getDeviceId()) + .relationType(relation.getRelationType()) + .build()); + return relation.getId(); } @@ -158,6 +174,15 @@ public class AreaDeviceRelationServiceImpl implements AreaDeviceRelationService if (deleted) { // 同步 Redis 缓存 areaDeviceService.evictConfigCache(existing.getAreaId(), existing.getRelationType()); + + // 发布解绑事件,与绑定路径形成闭环 + // 用途:解绑后 SyncJob 不再扫到该设备,BADGE 类型 Redis 缓存得等 24h TTL 才过期, + // 期间设备仍可能出现在"可分配工牌"列表里。条线监听器收到事件立即清理 Redis。 + eventPublisher.publishEvent(AreaDeviceUnboundEvent.builder() + .areaId(existing.getAreaId()) + .deviceId(existing.getDeviceId()) + .relationType(existing.getRelationType()) + .build()); } return deleted; } diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/area/event/AreaDeviceBoundEvent.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/area/event/AreaDeviceBoundEvent.java new file mode 100644 index 00000000..bc158da9 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/area/event/AreaDeviceBoundEvent.java @@ -0,0 +1,34 @@ +package com.viewsh.module.ops.service.area.event; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 区域-设备绑定完成事件 + *

+ * 在 {@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()); } }