diff --git a/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/integration/listener/SecurityOrderEventListener.java b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/integration/listener/SecurityOrderEventListener.java index 68e0f04..c517d57 100644 --- a/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/integration/listener/SecurityOrderEventListener.java +++ b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/integration/listener/SecurityOrderEventListener.java @@ -21,6 +21,7 @@ import com.viewsh.module.ops.security.dal.dataobject.workorder.OpsOrderSecurityE import com.viewsh.module.ops.security.dal.mysql.workorder.OpsOrderSecurityExtMapper; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; import org.springframework.transaction.event.TransactionPhase; @@ -90,13 +91,13 @@ public class SecurityOrderEventListener { DispatchResult result = dispatchEngine.dispatch(context); if (result.isSuccess()) { - log.info("安保工单自动派单完成: orderId={}, assigneeId={}", event.getOrderId(), result.getAssigneeId()); + log.info("安保工单自动派单完成: orderId={}, assigneeId={}, path={}", + event.getOrderId(), result.getAssigneeId(), result.getPath()); // 记录派单成功日志 recordLog(EventDomain.DISPATCH, LogType.ORDER_DISPATCHED, "自动派单成功,分配给: " + result.getAssigneeName(), event.getOrderId(), result.getAssigneeId()); - // 派单成功后发送企微卡片通知 - vspNotifyListener.sendCard(event, result); + // 企微卡片通知统一在 handleDispatched 中发送(状态变为 DISPATCHED 时触发) } else { log.warn("安保工单自动派单失败: orderId={}, reason={}", event.getOrderId(), result.getMessage()); // 记录派单失败日志 @@ -116,8 +117,14 @@ public class SecurityOrderEventListener { /** * 状态变更事件 - 记录扩展表时间点 + 业务日志 + *
+ * 使用 @EventListener 而非 @TransactionalEventListener(AFTER_COMMIT), + * 确保 autoDispatchNext 在 AFTER_COMMIT 中派发下一单时发布的 DISPATCHED + * 事件也能被捕获。各 handler 方法使用 @Async 异步执行。 + *
+ * 参考保洁模块 CleanOrderEventListener 的实现模式。 */ - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @EventListener public void onOrderStateChanged(OrderStateChangedEvent event) { if (!ORDER_TYPE_SECURITY.equals(event.getOrderType())) { return; @@ -205,6 +212,11 @@ public class SecurityOrderEventListener { } recordLog(EventDomain.DISPATCH, LogType.ORDER_DISPATCHED, message, orderId, assigneeId); + + // 3. 工单推送时发送企微卡片通知(暂停恢复不重发,人员已知晓该工单) + if (event.getOldStatus() != WorkOrderStatusEnum.PAUSED) { + vspNotifyListener.sendCardByOrderId(orderId, assigneeId); + } } private void handleConfirmed(Long orderId, OrderStateChangedEvent event) { diff --git a/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/integration/listener/SecurityOrderVspNotifyListener.java b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/integration/listener/SecurityOrderVspNotifyListener.java index 7b128da..3210c33 100644 --- a/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/integration/listener/SecurityOrderVspNotifyListener.java +++ b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/integration/listener/SecurityOrderVspNotifyListener.java @@ -1,7 +1,5 @@ package com.viewsh.module.ops.security.integration.listener; -import com.viewsh.module.ops.core.dispatch.model.DispatchResult; -import com.viewsh.module.ops.core.event.OrderCreatedEvent; import com.viewsh.module.ops.core.event.OrderStateChangedEvent; import com.viewsh.module.ops.enums.WorkOrderStatusEnum; import com.viewsh.module.ops.enums.WorkOrderTypeEnum; @@ -9,6 +7,8 @@ import com.viewsh.module.ops.infrastructure.vsp.VspNotifyClient; import com.viewsh.module.ops.infrastructure.vsp.WechatUserIdResolver; import com.viewsh.module.ops.infrastructure.vsp.dto.VspSendCardReqDTO; import com.viewsh.module.ops.infrastructure.vsp.dto.VspSyncStatusReqDTO; +import com.viewsh.module.ops.dal.dataobject.workorder.OpsOrderDO; +import com.viewsh.module.ops.dal.mysql.workorder.OpsOrderMapper; import com.viewsh.module.ops.security.dal.dataobject.workorder.OpsOrderSecurityExtDO; import com.viewsh.module.ops.security.dal.mysql.workorder.OpsOrderSecurityExtMapper; import com.viewsh.module.ops.service.area.OpsBusAreaService; @@ -47,6 +47,9 @@ public class SecurityOrderVspNotifyListener { @Resource private OpsBusAreaService opsBusAreaService; + @Resource + private OpsOrderMapper opsOrderMapper; + @Resource private OpsOrderSecurityExtMapper securityExtMapper; @@ -67,43 +70,49 @@ public class SecurityOrderVspNotifyListener { // ==================== 核心方法 ==================== /** - * 派单成功后发送企微工单卡片 + * 工单推送(DISPATCHED)时发送企微工单卡片 *
- * 由 {@link SecurityOrderEventListener#onOrderCreated} 在派单成功后调用 + * 由 {@link SecurityOrderEventListener#onOrderStateChanged} 在工单状态变为 DISPATCHED 时调用, + * 统一覆盖直接派单和从队列推送两种场景。 */ - public void sendCard(OrderCreatedEvent event, DispatchResult result) { + public void sendCardByOrderId(Long orderId, Long assigneeId) { try { - OpsOrderSecurityExtDO ext = securityExtMapper.selectByOpsOrderId(event.getOrderId()); + OpsOrderSecurityExtDO ext = securityExtMapper.selectByOpsOrderId(orderId); if (ext == null || ext.getAlarmId() == null) { return; // 非告警来源工单不发卡片 } - String wechatUserId = wechatUserIdResolver.resolve(result.getAssigneeId()); + String wechatUserId = wechatUserIdResolver.resolve(assigneeId); if (wechatUserId == null) { - log.warn("[sendCard] 未找到企微userId, assigneeId={}, orderId={}", - result.getAssigneeId(), event.getOrderId()); + log.warn("[sendCard] 未找到企微userId, assigneeId={}, orderId={}", assigneeId, orderId); return; } - String areaName = resolveAreaName(event.getAreaId()); + OpsOrderDO order = opsOrderMapper.selectById(orderId); + if (order == null) { + log.warn("[sendCard] 工单不存在, orderId={}", orderId); + return; + } + + String areaName = resolveAreaName(order.getAreaId()); VspSendCardReqDTO req = VspSendCardReqDTO.builder() .alarmId(ext.getAlarmId()) - .orderId(String.valueOf(event.getOrderId())) + .orderId(String.valueOf(orderId)) .userIds(List.of(wechatUserId)) - .title(event.getTitle()) + .title(order.getTitle()) .areaName(areaName) .cameraName(ext.getCameraName()) - .eventTime(event.getCreateTime() != null - ? event.getCreateTime().format(EVENT_TIME_FMT) + .eventTime(order.getCreateTime() != null + ? order.getCreateTime().format(EVENT_TIME_FMT) : null) - .level(event.getPriority()) + .level(order.getPriority()) .snapshotUrl(ext.getImageUrl()) .build(); vspNotifyClient.sendCard(req); } catch (Exception e) { - log.error("[sendCard] 发送企微卡片异常, orderId={}", event.getOrderId(), e); + log.error("[sendCard] 发送企微卡片异常, orderId={}", orderId, e); } } diff --git a/viewsh-module-ops/viewsh-module-security-biz/src/test/java/com/viewsh/module/ops/security/integration/listener/SecurityOrderEventListenerTest.java b/viewsh-module-ops/viewsh-module-security-biz/src/test/java/com/viewsh/module/ops/security/integration/listener/SecurityOrderEventListenerTest.java index 7b16739..894d20e 100644 --- a/viewsh-module-ops/viewsh-module-security-biz/src/test/java/com/viewsh/module/ops/security/integration/listener/SecurityOrderEventListenerTest.java +++ b/viewsh-module-ops/viewsh-module-security-biz/src/test/java/com/viewsh/module/ops/security/integration/listener/SecurityOrderEventListenerTest.java @@ -5,6 +5,7 @@ import com.viewsh.module.ops.core.dispatch.model.OrderDispatchContext; import com.viewsh.module.ops.core.event.OrderCompletedEvent; import com.viewsh.module.ops.core.event.OrderCreatedEvent; import com.viewsh.module.ops.core.event.OrderStateChangedEvent; +import com.viewsh.module.ops.dal.mysql.workorder.OpsOrderMapper; import com.viewsh.module.ops.enums.PriorityEnum; import com.viewsh.module.ops.enums.WorkOrderStatusEnum; import com.viewsh.module.ops.security.dal.dataobject.workorder.OpsOrderSecurityExtDO; @@ -34,9 +35,15 @@ public class SecurityOrderEventListenerTest { @Mock private OpsOrderSecurityExtMapper securityExtMapper; + @Mock + private OpsOrderMapper opsOrderMapper; + @Mock private DispatchEngine dispatchEngine; + @Mock + private SecurityOrderVspNotifyListener vspNotifyListener; + private static final Long TEST_ORDER_ID = 10001L; private static final String TEST_ORDER_CODE = "SECURITY-20260310-0001"; @@ -141,9 +148,6 @@ public class SecurityOrderEventListenerTest { OrderStateChangedEvent event = buildStateChangedEvent( WorkOrderStatusEnum.ARRIVED, WorkOrderStatusEnum.COMPLETED); - OpsOrderSecurityExtDO existingExt = OpsOrderSecurityExtDO.builder() - .id(1L).opsOrderId(TEST_ORDER_ID).build(); - when(securityExtMapper.selectByOpsOrderId(TEST_ORDER_ID)).thenReturn(existingExt); lenient().when(securityExtMapper.insertOrUpdateSelective(any())).thenReturn(1); // 执行 @@ -157,20 +161,6 @@ public class SecurityOrderEventListenerTest { assertNotNull(ext.getCompletedTime()); } - @Test - void testOnOrderStateChanged_Completed_NoExt_Skipped() { - OrderStateChangedEvent event = buildStateChangedEvent( - WorkOrderStatusEnum.ARRIVED, WorkOrderStatusEnum.COMPLETED); - - when(securityExtMapper.selectByOpsOrderId(TEST_ORDER_ID)).thenReturn(null); - - // 执行 - listener.onOrderStateChanged(event); - - // 验证:扩展记录不存在时不写入 - verify(securityExtMapper, never()).insertOrUpdateSelective(any()); - } - @Test void testOnOrderStateChanged_CleanType_Ignored() { OrderStateChangedEvent event = OrderStateChangedEvent.builder() diff --git a/viewsh-module-ops/viewsh-module-security-biz/src/test/java/com/viewsh/module/ops/security/integration/listener/SecurityOrderEventListenerVspTest.java b/viewsh-module-ops/viewsh-module-security-biz/src/test/java/com/viewsh/module/ops/security/integration/listener/SecurityOrderEventListenerVspTest.java index 415e7b5..427bb7f 100644 --- a/viewsh-module-ops/viewsh-module-security-biz/src/test/java/com/viewsh/module/ops/security/integration/listener/SecurityOrderEventListenerVspTest.java +++ b/viewsh-module-ops/viewsh-module-security-biz/src/test/java/com/viewsh/module/ops/security/integration/listener/SecurityOrderEventListenerVspTest.java @@ -1,28 +1,30 @@ package com.viewsh.module.ops.security.integration.listener; -import com.viewsh.framework.common.pojo.CommonResult; import com.viewsh.module.ops.core.dispatch.DispatchEngine; +import com.viewsh.module.ops.core.dispatch.model.DispatchPath; import com.viewsh.module.ops.core.dispatch.model.DispatchResult; import com.viewsh.module.ops.core.event.OrderCreatedEvent; import com.viewsh.module.ops.core.event.OrderStateChangedEvent; +import com.viewsh.module.ops.dal.dataobject.workorder.OpsOrderDO; +import com.viewsh.module.ops.dal.mysql.workorder.OpsOrderMapper; import com.viewsh.module.ops.enums.PriorityEnum; import com.viewsh.module.ops.enums.WorkOrderStatusEnum; import com.viewsh.module.ops.infrastructure.log.recorder.EventLogRecorder; +import com.viewsh.module.ops.infrastructure.vsp.VspNotifyClient; +import com.viewsh.module.ops.infrastructure.vsp.WechatUserIdResolver; +import com.viewsh.module.ops.infrastructure.vsp.dto.VspSendCardReqDTO; import com.viewsh.module.ops.security.dal.dataobject.workorder.OpsOrderSecurityExtDO; import com.viewsh.module.ops.security.dal.mysql.workorder.OpsOrderSecurityExtMapper; -import com.viewsh.module.ops.infrastructure.vsp.VspNotifyClient; -import com.viewsh.module.ops.infrastructure.vsp.dto.VspSendCardReqDTO; -import com.viewsh.module.ops.infrastructure.vsp.dto.VspSyncStatusReqDTO; import com.viewsh.module.ops.service.area.OpsBusAreaService; import com.viewsh.module.ops.dal.dataobject.vo.area.OpsBusAreaRespVO; import com.viewsh.module.system.api.social.SocialUserApi; -import com.viewsh.module.system.api.social.dto.SocialUserRespDTO; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; -import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; import java.time.LocalDateTime; @@ -31,21 +33,23 @@ import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; /** - * 安保工单事件监听器 - VSP 企微通知集成测试 + * 安保工单事件监听器 - VSP 企微通知时机测试 + *
+ * 验证 sendCard 在工单状态变为 DISPATCHED 时发送(而非创建时)
*
* @author lzh
*/
@ExtendWith(MockitoExtension.class)
public class SecurityOrderEventListenerVspTest {
- @InjectMocks
private SecurityOrderEventListener listener;
+ private SecurityOrderVspNotifyListener vspNotifyListener;
@Mock
private VspNotifyClient vspNotifyClient;
@Mock
- private SocialUserApi socialUserApi;
+ private WechatUserIdResolver wechatUserIdResolver;
@Mock
private OpsBusAreaService opsBusAreaService;
@@ -53,6 +57,9 @@ public class SecurityOrderEventListenerVspTest {
@Mock
private OpsOrderSecurityExtMapper securityExtMapper;
+ @Mock
+ private OpsOrderMapper opsOrderMapper;
+
@Mock
private DispatchEngine dispatchEngine;
@@ -65,11 +72,28 @@ public class SecurityOrderEventListenerVspTest {
private static final String TEST_WECHAT_ID = "test_wechat_id";
private static final String TEST_ALARM_ID = "alarm-001";
- // ==================== onOrderCreated VSP 通知测试 ====================
+ @BeforeEach
+ void setUp() {
+ // 构建真实 vspNotifyListener 并注入 mock 依赖
+ vspNotifyListener = new SecurityOrderVspNotifyListener();
+ ReflectionTestUtils.setField(vspNotifyListener, "vspNotifyClient", vspNotifyClient);
+ ReflectionTestUtils.setField(vspNotifyListener, "wechatUserIdResolver", wechatUserIdResolver);
+ ReflectionTestUtils.setField(vspNotifyListener, "opsBusAreaService", opsBusAreaService);
+ ReflectionTestUtils.setField(vspNotifyListener, "opsOrderMapper", opsOrderMapper);
+ ReflectionTestUtils.setField(vspNotifyListener, "securityExtMapper", securityExtMapper);
+
+ // 构建真实 listener 并注入依赖
+ listener = new SecurityOrderEventListener();
+ ReflectionTestUtils.setField(listener, "securityExtMapper", securityExtMapper);
+ ReflectionTestUtils.setField(listener, "dispatchEngine", dispatchEngine);
+ ReflectionTestUtils.setField(listener, "eventLogRecorder", eventLogRecorder);
+ ReflectionTestUtils.setField(listener, "vspNotifyListener", vspNotifyListener);
+ }
+
+ // ==================== onOrderCreated 不再发送卡片 ====================
@Test
- void onOrderCreated_shouldSendCard_whenDispatchSuccessAndAlarmSource() {
- // 准备事件
+ void onOrderCreated_shouldNotSendCard_whenEnqueueOnly() {
OrderCreatedEvent event = OrderCreatedEvent.builder()
.orderId(TEST_ORDER_ID)
.orderType("SECURITY")
@@ -77,36 +101,77 @@ public class SecurityOrderEventListenerVspTest {
.title("入侵告警")
.areaId(100L)
.priority(PriorityEnum.P1.getPriority())
- .createTime(LocalDateTime.of(2026, 3, 24, 10, 0))
.build();
- // 派单成功
- DispatchResult dispatchResult = DispatchResult.success("ok", TEST_ASSIGNEE_ID, "张三", null, null);
- when(dispatchEngine.dispatch(any())).thenReturn(dispatchResult);
+ DispatchResult result = DispatchResult.success("ok", TEST_ASSIGNEE_ID, "张三",
+ DispatchPath.ENQUEUE_ONLY, 1L);
+ when(dispatchEngine.dispatch(any())).thenReturn(result);
- // 告警来源工单扩展记录
+ listener.onOrderCreated(event);
+
+ // onOrderCreated 不发卡片
+ verify(vspNotifyClient, never()).sendCard(any());
+ }
+
+ @Test
+ void onOrderCreated_shouldNotSendCard_whenDirectDispatch() {
+ OrderCreatedEvent event = OrderCreatedEvent.builder()
+ .orderId(TEST_ORDER_ID)
+ .orderType("SECURITY")
+ .orderCode(TEST_ORDER_CODE)
+ .title("入侵告警")
+ .areaId(100L)
+ .priority(PriorityEnum.P1.getPriority())
+ .build();
+
+ DispatchResult result = DispatchResult.success("ok", TEST_ASSIGNEE_ID, "张三",
+ DispatchPath.DIRECT_DISPATCH, null);
+ when(dispatchEngine.dispatch(any())).thenReturn(result);
+
+ listener.onOrderCreated(event);
+
+ // 即使直接派单,onOrderCreated 也不发卡片(由 handleDispatched 处理)
+ verify(vspNotifyClient, never()).sendCard(any());
+ }
+
+ // ==================== handleDispatched 发送卡片(端到端验证) ====================
+
+ @Test
+ void onOrderStateChanged_shouldSendCard_whenDispatched() {
+ OrderStateChangedEvent event = buildStateChangedEvent(
+ WorkOrderStatusEnum.QUEUED, WorkOrderStatusEnum.DISPATCHED);
+ event.addPayload("assigneeId", TEST_ASSIGNEE_ID);
+ event.addPayload("assigneeName", "张三");
+ event.addPayload("assigneePhone", "13800138000");
+
+ // mock:告警来源工单
OpsOrderSecurityExtDO ext = OpsOrderSecurityExtDO.builder()
- .id(1L)
- .opsOrderId(TEST_ORDER_ID)
- .alarmId(TEST_ALARM_ID)
- .cameraName("大堂摄像头")
+ .id(1L).opsOrderId(TEST_ORDER_ID)
+ .alarmId(TEST_ALARM_ID).cameraName("大堂摄像头")
.imageUrl("http://example.com/snapshot.jpg")
.build();
when(securityExtMapper.selectByOpsOrderId(TEST_ORDER_ID)).thenReturn(ext);
+ when(securityExtMapper.insertOrUpdateSelective(any())).thenReturn(1);
- // 企微 userId 查询
- SocialUserRespDTO socialUser = new SocialUserRespDTO();
- socialUser.setOpenid(TEST_WECHAT_ID);
- when(socialUserApi.getSocialUserByUserId(anyInt(), eq(TEST_ASSIGNEE_ID), anyInt()))
- .thenReturn(CommonResult.success(socialUser));
+ // mock:企微 userId
+ when(wechatUserIdResolver.resolve(TEST_ASSIGNEE_ID)).thenReturn(TEST_WECHAT_ID);
- // 区域名称
+ // mock:工单主表
+ OpsOrderDO order = new OpsOrderDO();
+ order.setId(TEST_ORDER_ID);
+ order.setTitle("入侵告警");
+ order.setAreaId(100L);
+ order.setPriority(PriorityEnum.P1.getPriority());
+ order.setCreateTime(LocalDateTime.of(2026, 3, 24, 10, 0));
+ when(opsOrderMapper.selectById(TEST_ORDER_ID)).thenReturn(order);
+
+ // mock:区域名称
OpsBusAreaRespVO area = new OpsBusAreaRespVO();
area.setAreaName("A栋大堂");
when(opsBusAreaService.getArea(100L)).thenReturn(area);
// 执行
- listener.onOrderCreated(event);
+ listener.onOrderStateChanged(event);
// 验证发送了企微卡片
ArgumentCaptor