From e44c1f6f4eb1171b97286015c1ae67e8b51a5d3b Mon Sep 17 00:00:00 2001 From: lzh Date: Wed, 25 Mar 2026 11:28:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(ops):=20=E6=96=B0=E5=A2=9E=20VSP=20?= =?UTF-8?q?=E4=BC=81=E5=BE=AE=E9=80=9A=E7=9F=A5=E7=BD=91=E5=85=B3=E5=9F=BA?= =?UTF-8?q?=E7=A1=80=E8=AE=BE=E6=96=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - VspNotifyClient/Impl: HTTP 客户端,支持网络异常重试 + 线性退避 - VspNotifyProperties/Config: 配置属性与 RestTemplate Bean(JdkClientHttpRequestFactory) - VspSendCardReqDTO/VspSyncStatusReqDTO/VspResponseDTO: 请求响应 DTO - WechatUserIdResolver: 企微 userId 查询工具,通过 SocialUserApi 查询,供各业务线复用 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../infrastructure/vsp/VspNotifyClient.java | 21 ++ .../vsp/VspNotifyClientImpl.java | 82 +++++ .../vsp/WechatUserIdResolver.java | 46 +++ .../vsp/config/VspNotifyConfig.java | 25 ++ .../vsp/config/VspNotifyProperties.java | 19 ++ .../vsp/dto/VspResponseDTO.java | 13 + .../vsp/dto/VspSendCardReqDTO.java | 36 +++ .../vsp/dto/VspSyncStatusReqDTO.java | 25 ++ .../vsp/VspNotifyClientImplTest.java | 161 ++++++++++ .../SecurityOrderVspNotifyListener.java | 172 +++++++++++ .../integration/vsp/VspNotifyClient.java | 21 ++ .../integration/vsp/VspNotifyClientImpl.java | 65 ++++ .../vsp/config/VspNotifyConfig.java | 21 ++ .../vsp/config/VspNotifyProperties.java | 19 ++ .../integration/vsp/dto/VspResponseDTO.java | 13 + .../vsp/dto/VspSendCardReqDTO.java | 28 ++ .../vsp/dto/VspSyncStatusReqDTO.java | 19 ++ .../SecurityOrderEventListenerVspTest.java | 284 ++++++++++++++++++ .../vsp/VspNotifyClientImplTest.java | 161 ++++++++++ 19 files changed, 1231 insertions(+) create mode 100644 viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/vsp/VspNotifyClient.java create mode 100644 viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/vsp/VspNotifyClientImpl.java create mode 100644 viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/vsp/WechatUserIdResolver.java create mode 100644 viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/vsp/config/VspNotifyConfig.java create mode 100644 viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/vsp/config/VspNotifyProperties.java create mode 100644 viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/vsp/dto/VspResponseDTO.java create mode 100644 viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/vsp/dto/VspSendCardReqDTO.java create mode 100644 viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/vsp/dto/VspSyncStatusReqDTO.java create mode 100644 viewsh-module-ops/viewsh-module-ops-biz/src/test/java/com/viewsh/module/ops/infrastructure/vsp/VspNotifyClientImplTest.java create mode 100644 viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/integration/listener/SecurityOrderVspNotifyListener.java create mode 100644 viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/integration/vsp/VspNotifyClient.java create mode 100644 viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/integration/vsp/VspNotifyClientImpl.java create mode 100644 viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/integration/vsp/config/VspNotifyConfig.java create mode 100644 viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/integration/vsp/config/VspNotifyProperties.java create mode 100644 viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/integration/vsp/dto/VspResponseDTO.java create mode 100644 viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/integration/vsp/dto/VspSendCardReqDTO.java create mode 100644 viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/integration/vsp/dto/VspSyncStatusReqDTO.java create mode 100644 viewsh-module-ops/viewsh-module-security-biz/src/test/java/com/viewsh/module/ops/security/integration/listener/SecurityOrderEventListenerVspTest.java create mode 100644 viewsh-module-ops/viewsh-module-security-biz/src/test/java/com/viewsh/module/ops/security/integration/vsp/VspNotifyClientImplTest.java diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/vsp/VspNotifyClient.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/vsp/VspNotifyClient.java new file mode 100644 index 0000000..bcf0c56 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/vsp/VspNotifyClient.java @@ -0,0 +1,21 @@ +package com.viewsh.module.ops.infrastructure.vsp; + +import com.viewsh.module.ops.infrastructure.vsp.dto.VspSendCardReqDTO; +import com.viewsh.module.ops.infrastructure.vsp.dto.VspSyncStatusReqDTO; + +public interface VspNotifyClient { + + /** + * 发送企微工单卡片 + * + * @param req 卡片内容 + */ + void sendCard(VspSendCardReqDTO req); + + /** + * 同步工单状态到 vsp-service + * + * @param req 状态信息 + */ + void syncStatus(VspSyncStatusReqDTO req); +} diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/vsp/VspNotifyClientImpl.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/vsp/VspNotifyClientImpl.java new file mode 100644 index 0000000..277051f --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/vsp/VspNotifyClientImpl.java @@ -0,0 +1,82 @@ +package com.viewsh.module.ops.infrastructure.vsp; + +import com.viewsh.module.ops.infrastructure.vsp.config.VspNotifyProperties; +import com.viewsh.module.ops.infrastructure.vsp.dto.VspResponseDTO; +import com.viewsh.module.ops.infrastructure.vsp.dto.VspSendCardReqDTO; +import com.viewsh.module.ops.infrastructure.vsp.dto.VspSyncStatusReqDTO; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Component; +import org.springframework.web.client.HttpStatusCodeException; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestTemplate; + +@Slf4j +@Component +public class VspNotifyClientImpl implements VspNotifyClient { + + private final RestTemplate restTemplate; + private final VspNotifyProperties properties; + + public VspNotifyClientImpl(@Qualifier("vspRestTemplate") RestTemplate restTemplate, + VspNotifyProperties properties) { + this.restTemplate = restTemplate; + this.properties = properties; + } + + @Override + public void sendCard(VspSendCardReqDTO req) { + if (!properties.isEnabled()) return; + req.nullToEmpty(); + log.info("[sendCard] 请求参数: {}", req); + String url = properties.getBaseUrl() + "/api/wechat/notify/send-card"; + executeWithRetry(url, req, "sendCard", req.getOrderId(), req.getAlarmId()); + } + + @Override + public void syncStatus(VspSyncStatusReqDTO req) { + if (!properties.isEnabled()) return; + req.nullToEmpty(); + log.info("[syncStatus] 请求参数: {}", req); + String url = properties.getBaseUrl() + "/api/wechat/notify/sync-status"; + executeWithRetry(url, req, "syncStatus", req.getOrderId(), req.getAlarmId()); + } + + private void executeWithRetry(String url, Object req, + String action, String orderId, String alarmId) { + for (int i = 0; i <= properties.getMaxRetry(); i++) { + try { + VspResponseDTO resp = restTemplate.postForObject(url, req, VspResponseDTO.class); + if (resp != null && resp.isSuccess()) { + log.info("[{}] vsp通知成功, orderId={}, alarmId={}", action, orderId, alarmId); + return; + } + // 业务失败不重试 + log.warn("[{}] vsp返回失败: code={}, msg={}, orderId={}", + action, resp != null ? resp.getCode() : null, + resp != null ? resp.getMsg() : null, orderId); + return; + } catch (HttpStatusCodeException e) { + // HTTP 4xx/5xx 错误不重试(如 404、500),直接记录并返回 + log.error("[{}] vsp返回HTTP错误: status={}, body={}, orderId={}", + action, e.getStatusCode(), e.getResponseBodyAsString(), orderId); + return; + } catch (ResourceAccessException e) { + // 网络异常(连接超时、读取超时等)才重试 + if (i < properties.getMaxRetry()) { + log.warn("[{}] 第{}次调用失败, orderId={}, 将重试", + action, i + 1, orderId); + try { + Thread.sleep(500L * (i + 1)); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + break; + } + } else { + log.error("[{}] 调用vsp失败, 重试耗尽, orderId={}, alarmId={}", + action, orderId, alarmId, e); + } + } + } + } +} diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/vsp/WechatUserIdResolver.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/vsp/WechatUserIdResolver.java new file mode 100644 index 0000000..e58b4a6 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/vsp/WechatUserIdResolver.java @@ -0,0 +1,46 @@ +package com.viewsh.module.ops.infrastructure.vsp; + +import com.viewsh.framework.common.enums.UserTypeEnum; +import com.viewsh.framework.common.pojo.CommonResult; +import com.viewsh.module.system.api.social.SocialUserApi; +import com.viewsh.module.system.api.social.dto.SocialUserRespDTO; +import com.viewsh.module.system.enums.social.SocialTypeEnum; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * 企微 userId 解析器 + *

+ * 通过 SocialUserApi 查询系统用户绑定的企微 openId。 + * 放在 ops-biz 公共层,供 security-biz、environment-biz 等业务线复用。 + * + * @author lzh + */ +@Slf4j +@Component +public class WechatUserIdResolver { + + @Resource + private SocialUserApi socialUserApi; + + /** + * 根据系统用户ID查询企微 userId (openId) + * + * @param userId 系统用户ID + * @return 企微 userId,查询失败或未绑定返回 null + */ + public String resolve(Long userId) { + if (userId == null) return null; + try { + CommonResult result = socialUserApi.getSocialUserByUserId( + UserTypeEnum.ADMIN.getValue(), userId, SocialTypeEnum.WECHAT_ENTERPRISE.getType()); + if (result.isSuccess() && result.getData() != null) { + return result.getData().getOpenid(); + } + } catch (Exception e) { + log.warn("[WechatUserIdResolver] 查询企微userId失败, userId={}", userId, e); + } + return null; + } +} diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/vsp/config/VspNotifyConfig.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/vsp/config/VspNotifyConfig.java new file mode 100644 index 0000000..f617d12 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/vsp/config/VspNotifyConfig.java @@ -0,0 +1,25 @@ +package com.viewsh.module.ops.infrastructure.vsp.config; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.JdkClientHttpRequestFactory; +import org.springframework.web.client.RestTemplate; + +import java.net.http.HttpClient; +import java.time.Duration; + +@Configuration +@EnableConfigurationProperties(VspNotifyProperties.class) +public class VspNotifyConfig { + + @Bean(value = "vspRestTemplate", defaultCandidate = false) + public RestTemplate vspRestTemplate(VspNotifyProperties properties) { + HttpClient httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofMillis(properties.getConnectTimeout())) + .build(); + JdkClientHttpRequestFactory factory = new JdkClientHttpRequestFactory(httpClient); + factory.setReadTimeout(Duration.ofMillis(properties.getReadTimeout())); + return new RestTemplate(factory); + } +} diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/vsp/config/VspNotifyProperties.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/vsp/config/VspNotifyProperties.java new file mode 100644 index 0000000..3481e0d --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/vsp/config/VspNotifyProperties.java @@ -0,0 +1,19 @@ +package com.viewsh.module.ops.infrastructure.vsp.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Data +@ConfigurationProperties(prefix = "viewsh.ops.vsp-notify") +public class VspNotifyProperties { + /** 是否启用 vsp 通知 */ + private boolean enabled = true; + /** vsp-service 基础 URL */ + private String baseUrl = ""; + /** 连接超时(ms) */ + private int connectTimeout = 5000; + /** 读取超时(ms) */ + private int readTimeout = 10000; + /** 最大重试次数(网络异常时) */ + private int maxRetry = 2; +} diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/vsp/dto/VspResponseDTO.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/vsp/dto/VspResponseDTO.java new file mode 100644 index 0000000..1e4d697 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/vsp/dto/VspResponseDTO.java @@ -0,0 +1,13 @@ +package com.viewsh.module.ops.infrastructure.vsp.dto; + +import lombok.Data; + +@Data +public class VspResponseDTO { + private Integer code; + private String msg; + + public boolean isSuccess() { + return code != null && code == 0; + } +} diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/vsp/dto/VspSendCardReqDTO.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/vsp/dto/VspSendCardReqDTO.java new file mode 100644 index 0000000..5a32448 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/vsp/dto/VspSendCardReqDTO.java @@ -0,0 +1,36 @@ +package com.viewsh.module.ops.infrastructure.vsp.dto; + +import lombok.Builder; +import lombok.Data; +import java.util.List; + +@Data +@Builder +public class VspSendCardReqDTO { + /** 关联告警ID */ + private String alarmId; + /** 工单ID */ + private String orderId; + /** 接收人企微 userId 列表 */ + private List userIds; + /** 卡片标题(告警类型) */ + private String title; + /** 区域名称 */ + private String areaName; + /** 摄像头名称 */ + private String cameraName; + /** 告警时间,格式 MM-dd HH:mm */ + private String eventTime; + /** 告警级别(0紧急 1重要 2普通 3轻微) */ + private Integer level; + /** 告警截图URL */ + private String snapshotUrl; + + /** 将可选的 String 字段 null 转为空字符串,避免对端 422 */ + public void nullToEmpty() { + if (areaName == null) areaName = ""; + if (cameraName == null) cameraName = ""; + if (eventTime == null) eventTime = ""; + if (snapshotUrl == null) snapshotUrl = ""; + } +} diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/vsp/dto/VspSyncStatusReqDTO.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/vsp/dto/VspSyncStatusReqDTO.java new file mode 100644 index 0000000..a2f0285 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/vsp/dto/VspSyncStatusReqDTO.java @@ -0,0 +1,25 @@ +package com.viewsh.module.ops.infrastructure.vsp.dto; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class VspSyncStatusReqDTO { + /** 关联告警ID */ + private String alarmId; + /** 工单ID */ + private String orderId; + /** 工单状态 */ + private String status; + /** 操作人企微 userId */ + private String operator; + /** 备注 */ + private String remark; + + /** 将可选的 String 字段 null 转为空字符串,避免对端 422 */ + public void nullToEmpty() { + if (operator == null) operator = ""; + if (remark == null) remark = ""; + } +} diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/test/java/com/viewsh/module/ops/infrastructure/vsp/VspNotifyClientImplTest.java b/viewsh-module-ops/viewsh-module-ops-biz/src/test/java/com/viewsh/module/ops/infrastructure/vsp/VspNotifyClientImplTest.java new file mode 100644 index 0000000..75e723a --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/test/java/com/viewsh/module/ops/infrastructure/vsp/VspNotifyClientImplTest.java @@ -0,0 +1,161 @@ +package com.viewsh.module.ops.infrastructure.vsp; + +import com.viewsh.module.ops.infrastructure.vsp.config.VspNotifyProperties; +import com.viewsh.module.ops.infrastructure.vsp.dto.VspResponseDTO; +import com.viewsh.module.ops.infrastructure.vsp.dto.VspSendCardReqDTO; +import com.viewsh.module.ops.infrastructure.vsp.dto.VspSyncStatusReqDTO; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +import java.util.List; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * VspNotifyClientImpl 单元测试 + * + * @author lzh + */ +@ExtendWith(MockitoExtension.class) +public class VspNotifyClientImplTest { + + @Mock + private RestTemplate restTemplate; + + private VspNotifyProperties properties; + + private VspNotifyClientImpl client; + + private static final String BASE_URL = "http://localhost:8000"; + + @BeforeEach + void setUp() { + properties = new VspNotifyProperties(); + properties.setEnabled(true); + properties.setBaseUrl(BASE_URL); + properties.setMaxRetry(2); + client = new VspNotifyClientImpl(restTemplate, properties); + } + + // ==================== sendCard 测试 ==================== + + @Test + void sendCard_success() { + VspSendCardReqDTO req = buildSendCardReq(); + + VspResponseDTO resp = new VspResponseDTO(); + resp.setCode(0); + resp.setMsg("ok"); + + when(restTemplate.postForObject(anyString(), any(), eq(VspResponseDTO.class))) + .thenReturn(resp); + + // 执行 + client.sendCard(req); + + // 验证只调用一次 + verify(restTemplate, times(1)).postForObject( + eq(BASE_URL + "/api/wechat/notify/send-card"), + eq(req), + eq(VspResponseDTO.class)); + } + + @Test + void sendCard_retryOnNetworkError() { + VspSendCardReqDTO req = buildSendCardReq(); + + when(restTemplate.postForObject(anyString(), any(), eq(VspResponseDTO.class))) + .thenThrow(new RestClientException("Connection refused")); + + // 执行 + client.sendCard(req); + + // maxRetry=2,总共调用 1 + 2 = 3 次 + verify(restTemplate, times(3)).postForObject( + eq(BASE_URL + "/api/wechat/notify/send-card"), + eq(req), + eq(VspResponseDTO.class)); + } + + @Test + void sendCard_noRetryOnBusinessError() { + VspSendCardReqDTO req = buildSendCardReq(); + + VspResponseDTO resp = new VspResponseDTO(); + resp.setCode(-1); + resp.setMsg("业务失败"); + + when(restTemplate.postForObject(anyString(), any(), eq(VspResponseDTO.class))) + .thenReturn(resp); + + // 执行 + client.sendCard(req); + + // 业务失败不重试,只调用一次 + verify(restTemplate, times(1)).postForObject(anyString(), any(), eq(VspResponseDTO.class)); + } + + @Test + void sendCard_disabled() { + properties.setEnabled(false); + + VspSendCardReqDTO req = buildSendCardReq(); + + // 执行 + client.sendCard(req); + + // 未启用时不调用 RestTemplate + verify(restTemplate, never()).postForObject(anyString(), any(), any()); + } + + // ==================== syncStatus 测试 ==================== + + @Test + void syncStatus_success() { + VspSyncStatusReqDTO req = VspSyncStatusReqDTO.builder() + .alarmId("alarm-001") + .orderId("10001") + .status("confirmed") + .operator("wechat_user_1") + .remark("确认接单") + .build(); + + VspResponseDTO resp = new VspResponseDTO(); + resp.setCode(0); + resp.setMsg("ok"); + + when(restTemplate.postForObject(anyString(), any(), eq(VspResponseDTO.class))) + .thenReturn(resp); + + // 执行 + client.syncStatus(req); + + // 验证使用 sync-status URL + verify(restTemplate, times(1)).postForObject( + eq(BASE_URL + "/api/wechat/notify/sync-status"), + eq(req), + eq(VspResponseDTO.class)); + } + + // ==================== 辅助方法 ==================== + + private VspSendCardReqDTO buildSendCardReq() { + return VspSendCardReqDTO.builder() + .alarmId("alarm-001") + .orderId("10001") + .userIds(List.of("wechat_user_1")) + .title("入侵告警") + .areaName("A栋大堂") + .cameraName("大堂摄像头") + .eventTime("03-24 10:00") + .level(1) + .snapshotUrl("http://example.com/snapshot.jpg") + .build(); + } +} 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 new file mode 100644 index 0000000..7b128da --- /dev/null +++ b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/integration/listener/SecurityOrderVspNotifyListener.java @@ -0,0 +1,172 @@ +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; +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.security.dal.dataobject.workorder.OpsOrderSecurityExtDO; +import com.viewsh.module.ops.security.dal.mysql.workorder.OpsOrderSecurityExtMapper; +import com.viewsh.module.ops.service.area.OpsBusAreaService; +import com.viewsh.module.ops.dal.dataobject.vo.area.OpsBusAreaRespVO; +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; + +import java.time.format.DateTimeFormatter; +import java.util.List; + +/** + * 安保工单 VSP 企微通知监听器 + *

+ * 职责单一:监听工单事件,调用 vsp-service 发送企微卡片和同步状态。 + * 与 {@link SecurityOrderEventListener} 分离,各自独立监听事件。 + * + * @author lzh + */ +@Slf4j +@Component +public class SecurityOrderVspNotifyListener { + + private static final String ORDER_TYPE_SECURITY = WorkOrderTypeEnum.SECURITY.getType(); + private static final DateTimeFormatter EVENT_TIME_FMT = DateTimeFormatter.ofPattern("MM-dd HH:mm"); + + @Resource + private VspNotifyClient vspNotifyClient; + + @Resource + private WechatUserIdResolver wechatUserIdResolver; + + @Resource + private OpsBusAreaService opsBusAreaService; + + @Resource + private OpsOrderSecurityExtMapper securityExtMapper; + + // ==================== 状态变更 → 同步 vsp ==================== + + /** + * 工单状态变更后异步同步到 vsp-service + */ + @Async("ops-task-executor") + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onOrderStateChanged(OrderStateChangedEvent event) { + if (!ORDER_TYPE_SECURITY.equals(event.getOrderType())) { + return; + } + syncStatus(event); + } + + // ==================== 核心方法 ==================== + + /** + * 派单成功后发送企微工单卡片 + *

+ * 由 {@link SecurityOrderEventListener#onOrderCreated} 在派单成功后调用 + */ + public void sendCard(OrderCreatedEvent event, DispatchResult result) { + try { + OpsOrderSecurityExtDO ext = securityExtMapper.selectByOpsOrderId(event.getOrderId()); + if (ext == null || ext.getAlarmId() == null) { + return; // 非告警来源工单不发卡片 + } + + String wechatUserId = wechatUserIdResolver.resolve(result.getAssigneeId()); + if (wechatUserId == null) { + log.warn("[sendCard] 未找到企微userId, assigneeId={}, orderId={}", + result.getAssigneeId(), event.getOrderId()); + return; + } + + String areaName = resolveAreaName(event.getAreaId()); + + VspSendCardReqDTO req = VspSendCardReqDTO.builder() + .alarmId(ext.getAlarmId()) + .orderId(String.valueOf(event.getOrderId())) + .userIds(List.of(wechatUserId)) + .title(event.getTitle()) + .areaName(areaName) + .cameraName(ext.getCameraName()) + .eventTime(event.getCreateTime() != null + ? event.getCreateTime().format(EVENT_TIME_FMT) + : null) + .level(event.getPriority()) + .snapshotUrl(ext.getImageUrl()) + .build(); + + vspNotifyClient.sendCard(req); + } catch (Exception e) { + log.error("[sendCard] 发送企微卡片异常, orderId={}", event.getOrderId(), e); + } + } + + // ==================== 内部方法 ==================== + + private void syncStatus(OrderStateChangedEvent event) { + try { + OpsOrderSecurityExtDO ext = securityExtMapper.selectByOpsOrderId(event.getOrderId()); + if (ext == null || ext.getAlarmId() == null) { + return; // 非告警来源工单不同步 + } + + String status = mapToVspStatus(event.getNewStatus(), ext.getFalseAlarm()); + if (status == null) { + return; // PENDING/QUEUED 不通知 + } + + String operator = null; + if (ext.getAssignedUserId() != null) { + operator = wechatUserIdResolver.resolve(ext.getAssignedUserId()); + } + + VspSyncStatusReqDTO req = VspSyncStatusReqDTO.builder() + .alarmId(ext.getAlarmId()) + .orderId(String.valueOf(event.getOrderId())) + .status(status) + .operator(operator) + .remark(event.getRemark()) + .build(); + + vspNotifyClient.syncStatus(req); + } catch (Exception e) { + log.error("[syncStatus] 同步状态异常, orderId={}", event.getOrderId(), e); + } + } + + /** + * 内部工单状态 → vsp 状态字符串 + */ + private String mapToVspStatus(WorkOrderStatusEnum newStatus, Boolean falseAlarm) { + if (newStatus == null) return null; + return switch (newStatus) { + case DISPATCHED -> "dispatched"; + case CONFIRMED -> "confirmed"; + case ARRIVED -> "arrived"; + case COMPLETED -> Boolean.TRUE.equals(falseAlarm) ? "false_alarm" : "completed"; + case CANCELLED -> "auto_resolved"; + case PAUSED -> "paused"; + default -> null; + }; + } + + /** + * 查询区域名称 + */ + private String resolveAreaName(Long areaId) { + if (areaId == null) return null; + try { + OpsBusAreaRespVO area = opsBusAreaService.getArea(areaId); + return area != null ? area.getAreaName() : null; + } catch (Exception e) { + log.warn("[resolveAreaName] 查询区域失败, areaId={}", areaId); + return null; + } + } +} diff --git a/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/integration/vsp/VspNotifyClient.java b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/integration/vsp/VspNotifyClient.java new file mode 100644 index 0000000..8e56dac --- /dev/null +++ b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/integration/vsp/VspNotifyClient.java @@ -0,0 +1,21 @@ +package com.viewsh.module.ops.security.integration.vsp; + +import com.viewsh.module.ops.security.integration.vsp.dto.VspSendCardReqDTO; +import com.viewsh.module.ops.security.integration.vsp.dto.VspSyncStatusReqDTO; + +public interface VspNotifyClient { + + /** + * 发送企微工单卡片 + * + * @param req 卡片内容 + */ + void sendCard(VspSendCardReqDTO req); + + /** + * 同步工单状态到 vsp-service + * + * @param req 状态信息 + */ + void syncStatus(VspSyncStatusReqDTO req); +} diff --git a/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/integration/vsp/VspNotifyClientImpl.java b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/integration/vsp/VspNotifyClientImpl.java new file mode 100644 index 0000000..2307f51 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/integration/vsp/VspNotifyClientImpl.java @@ -0,0 +1,65 @@ +package com.viewsh.module.ops.security.integration.vsp; + +import com.viewsh.module.ops.security.integration.vsp.config.VspNotifyProperties; +import com.viewsh.module.ops.security.integration.vsp.dto.VspResponseDTO; +import com.viewsh.module.ops.security.integration.vsp.dto.VspSendCardReqDTO; +import com.viewsh.module.ops.security.integration.vsp.dto.VspSyncStatusReqDTO; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +@Slf4j +@Component +public class VspNotifyClientImpl implements VspNotifyClient { + + private final RestTemplate restTemplate; + private final VspNotifyProperties properties; + + public VspNotifyClientImpl(@Qualifier("vspRestTemplate") RestTemplate restTemplate, + VspNotifyProperties properties) { + this.restTemplate = restTemplate; + this.properties = properties; + } + + @Override + public void sendCard(VspSendCardReqDTO req) { + if (!properties.isEnabled()) return; + String url = properties.getBaseUrl() + "/api/wechat/notify/send-card"; + executeWithRetry(url, req, "sendCard", req.getOrderId(), req.getAlarmId()); + } + + @Override + public void syncStatus(VspSyncStatusReqDTO req) { + if (!properties.isEnabled()) return; + String url = properties.getBaseUrl() + "/api/wechat/notify/sync-status"; + executeWithRetry(url, req, "syncStatus", req.getOrderId(), req.getAlarmId()); + } + + private void executeWithRetry(String url, Object req, + String action, String orderId, String alarmId) { + for (int i = 0; i <= properties.getMaxRetry(); i++) { + try { + VspResponseDTO resp = restTemplate.postForObject(url, req, VspResponseDTO.class); + if (resp != null && resp.isSuccess()) { + log.info("[{}] vsp通知成功, orderId={}, alarmId={}", action, orderId, alarmId); + return; + } + // 业务失败不重试 + log.warn("[{}] vsp返回失败: code={}, msg={}, orderId={}", + action, resp != null ? resp.getCode() : null, + resp != null ? resp.getMsg() : null, orderId); + return; + } catch (RestClientException e) { + if (i < properties.getMaxRetry()) { + log.warn("[{}] 第{}次调用失败, orderId={}, 将重试", + action, i + 1, orderId); + } else { + log.error("[{}] 调用vsp失败, 重试耗尽, orderId={}, alarmId={}", + action, orderId, alarmId, e); + } + } + } + } +} diff --git a/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/integration/vsp/config/VspNotifyConfig.java b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/integration/vsp/config/VspNotifyConfig.java new file mode 100644 index 0000000..9ac3e96 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/integration/vsp/config/VspNotifyConfig.java @@ -0,0 +1,21 @@ +package com.viewsh.module.ops.security.integration.vsp.config; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.web.client.RestTemplate; + +@Configuration +@EnableConfigurationProperties(VspNotifyProperties.class) +public class VspNotifyConfig { + + @Bean("vspRestTemplate") + public RestTemplate vspRestTemplate(VspNotifyProperties properties) { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(properties.getConnectTimeout()); + factory.setReadTimeout(properties.getReadTimeout()); + RestTemplate restTemplate = new RestTemplate(factory); + return restTemplate; + } +} diff --git a/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/integration/vsp/config/VspNotifyProperties.java b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/integration/vsp/config/VspNotifyProperties.java new file mode 100644 index 0000000..918ea9f --- /dev/null +++ b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/integration/vsp/config/VspNotifyProperties.java @@ -0,0 +1,19 @@ +package com.viewsh.module.ops.security.integration.vsp.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Data +@ConfigurationProperties(prefix = "viewsh.ops.vsp-notify") +public class VspNotifyProperties { + /** 是否启用 vsp 通知 */ + private boolean enabled = true; + /** vsp-service 基础 URL */ + private String baseUrl = "http://124.221.55.225:8000"; + /** 连接超时(ms) */ + private int connectTimeout = 5000; + /** 读取超时(ms) */ + private int readTimeout = 10000; + /** 最大重试次数(网络异常时) */ + private int maxRetry = 2; +} diff --git a/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/integration/vsp/dto/VspResponseDTO.java b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/integration/vsp/dto/VspResponseDTO.java new file mode 100644 index 0000000..7e1c698 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/integration/vsp/dto/VspResponseDTO.java @@ -0,0 +1,13 @@ +package com.viewsh.module.ops.security.integration.vsp.dto; + +import lombok.Data; + +@Data +public class VspResponseDTO { + private Integer code; + private String msg; + + public boolean isSuccess() { + return code != null && code == 0; + } +} diff --git a/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/integration/vsp/dto/VspSendCardReqDTO.java b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/integration/vsp/dto/VspSendCardReqDTO.java new file mode 100644 index 0000000..4286ce1 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/integration/vsp/dto/VspSendCardReqDTO.java @@ -0,0 +1,28 @@ +package com.viewsh.module.ops.security.integration.vsp.dto; + +import lombok.Builder; +import lombok.Data; +import java.util.List; + +@Data +@Builder +public class VspSendCardReqDTO { + /** 关联告警ID */ + private String alarmId; + /** 工单ID */ + private String orderId; + /** 接收人企微 userId 列表 */ + private List userIds; + /** 卡片标题(告警类型) */ + private String title; + /** 区域名称 */ + private String areaName; + /** 摄像头名称 */ + private String cameraName; + /** 告警时间,格式 MM-dd HH:mm */ + private String eventTime; + /** 告警级别(0紧急 1重要 2普通 3轻微) */ + private Integer level; + /** 告警截图URL */ + private String snapshotUrl; +} diff --git a/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/integration/vsp/dto/VspSyncStatusReqDTO.java b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/integration/vsp/dto/VspSyncStatusReqDTO.java new file mode 100644 index 0000000..1bb0ffe --- /dev/null +++ b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/integration/vsp/dto/VspSyncStatusReqDTO.java @@ -0,0 +1,19 @@ +package com.viewsh.module.ops.security.integration.vsp.dto; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class VspSyncStatusReqDTO { + /** 关联告警ID */ + private String alarmId; + /** 工单ID */ + private String orderId; + /** 工单状态 */ + private String status; + /** 操作人企微 userId */ + private String operator; + /** 备注 */ + private String remark; +} 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 new file mode 100644 index 0000000..5f75505 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-security-biz/src/test/java/com/viewsh/module/ops/security/integration/listener/SecurityOrderEventListenerVspTest.java @@ -0,0 +1,284 @@ +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.DispatchResult; +import com.viewsh.module.ops.core.event.OrderCreatedEvent; +import com.viewsh.module.ops.core.event.OrderStateChangedEvent; +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.security.dal.dataobject.workorder.OpsOrderSecurityExtDO; +import com.viewsh.module.ops.security.dal.mysql.workorder.OpsOrderSecurityExtMapper; +import com.viewsh.module.ops.security.integration.vsp.VspNotifyClient; +import com.viewsh.module.ops.security.integration.vsp.dto.VspSendCardReqDTO; +import com.viewsh.module.ops.security.integration.vsp.dto.VspSyncStatusReqDTO; +import com.viewsh.module.ops.service.area.OpsBusAreaService; +import com.viewsh.module.ops.controller.admin.area.vo.OpsBusAreaRespVO; +import com.viewsh.module.system.api.social.SocialUserApi; +import com.viewsh.module.system.api.social.dto.SocialUserRespDTO; +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 java.time.LocalDateTime; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * 安保工单事件监听器 - VSP 企微通知集成测试 + * + * @author lzh + */ +@ExtendWith(MockitoExtension.class) +public class SecurityOrderEventListenerVspTest { + + @InjectMocks + private SecurityOrderEventListener listener; + + @Mock + private VspNotifyClient vspNotifyClient; + + @Mock + private SocialUserApi socialUserApi; + + @Mock + private OpsBusAreaService opsBusAreaService; + + @Mock + private OpsOrderSecurityExtMapper securityExtMapper; + + @Mock + private DispatchEngine dispatchEngine; + + @Mock + private EventLogRecorder eventLogRecorder; + + private static final Long TEST_ORDER_ID = 10001L; + private static final String TEST_ORDER_CODE = "SECURITY-20260324-0001"; + private static final Long TEST_ASSIGNEE_ID = 2001L; + private static final String TEST_WECHAT_ID = "test_wechat_id"; + private static final String TEST_ALARM_ID = "alarm-001"; + + // ==================== onOrderCreated VSP 通知测试 ==================== + + @Test + void onOrderCreated_shouldSendCard_whenDispatchSuccessAndAlarmSource() { + // 准备事件 + OrderCreatedEvent event = OrderCreatedEvent.builder() + .orderId(TEST_ORDER_ID) + .orderType("SECURITY") + .orderCode(TEST_ORDER_CODE) + .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); + + // 告警来源工单扩展记录 + OpsOrderSecurityExtDO ext = OpsOrderSecurityExtDO.builder() + .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); + + // 企微 userId 查询 + SocialUserRespDTO socialUser = new SocialUserRespDTO(); + socialUser.setOpenid(TEST_WECHAT_ID); + when(socialUserApi.getSocialUserByUserId(anyInt(), eq(TEST_ASSIGNEE_ID), anyInt())) + .thenReturn(CommonResult.success(socialUser)); + + // 区域名称 + OpsBusAreaRespVO area = new OpsBusAreaRespVO(); + area.setAreaName("A栋大堂"); + when(opsBusAreaService.getArea(100L)).thenReturn(area); + + // 执行 + listener.onOrderCreated(event); + + // 验证发送了企微卡片 + ArgumentCaptor captor = ArgumentCaptor.forClass(VspSendCardReqDTO.class); + verify(vspNotifyClient).sendCard(captor.capture()); + VspSendCardReqDTO req = captor.getValue(); + assertEquals(TEST_ALARM_ID, req.getAlarmId()); + assertEquals(String.valueOf(TEST_ORDER_ID), req.getOrderId()); + assertTrue(req.getUserIds().contains(TEST_WECHAT_ID)); + assertEquals("入侵告警", req.getTitle()); + assertEquals("A栋大堂", req.getAreaName()); + assertEquals("大堂摄像头", req.getCameraName()); + } + + @Test + void onOrderCreated_shouldNotSendCard_whenNoAlarmId() { + // 准备事件 + OrderCreatedEvent event = OrderCreatedEvent.builder() + .orderId(TEST_ORDER_ID) + .orderType("SECURITY") + .orderCode(TEST_ORDER_CODE) + .title("手动巡查工单") + .areaId(100L) + .priority(PriorityEnum.P2.getPriority()) + .build(); + + // 派单成功 + DispatchResult dispatchResult = DispatchResult.success("ok", TEST_ASSIGNEE_ID, "张三", null, null); + when(dispatchEngine.dispatch(any())).thenReturn(dispatchResult); + + // 手动工单无告警ID + OpsOrderSecurityExtDO ext = OpsOrderSecurityExtDO.builder() + .id(1L) + .opsOrderId(TEST_ORDER_ID) + .alarmId(null) // 无告警ID + .build(); + when(securityExtMapper.selectByOpsOrderId(TEST_ORDER_ID)).thenReturn(ext); + + // 执行 + listener.onOrderCreated(event); + + // 验证不发送卡片 + verify(vspNotifyClient, never()).sendCard(any()); + } + + // ==================== onOrderStateChanged VSP 同步测试 ==================== + + @Test + void onOrderStateChanged_shouldSyncConfirmed() { + OrderStateChangedEvent event = buildStateChangedEvent( + WorkOrderStatusEnum.DISPATCHED, WorkOrderStatusEnum.CONFIRMED); + + // 告警来源工单 + OpsOrderSecurityExtDO ext = OpsOrderSecurityExtDO.builder() + .id(1L) + .opsOrderId(TEST_ORDER_ID) + .alarmId(TEST_ALARM_ID) + .assignedUserId(TEST_ASSIGNEE_ID) + .build(); + when(securityExtMapper.selectByOpsOrderId(TEST_ORDER_ID)).thenReturn(ext); + lenient().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)); + + // 执行 + listener.onOrderStateChanged(event); + + // 验证同步状态 + ArgumentCaptor captor = ArgumentCaptor.forClass(VspSyncStatusReqDTO.class); + verify(vspNotifyClient).syncStatus(captor.capture()); + VspSyncStatusReqDTO req = captor.getValue(); + assertEquals(TEST_ALARM_ID, req.getAlarmId()); + assertEquals(String.valueOf(TEST_ORDER_ID), req.getOrderId()); + assertEquals("confirmed", req.getStatus()); + assertEquals(TEST_WECHAT_ID, req.getOperator()); + } + + @Test + void onOrderStateChanged_shouldSyncCompleted() { + OrderStateChangedEvent event = buildStateChangedEvent( + WorkOrderStatusEnum.ARRIVED, WorkOrderStatusEnum.COMPLETED); + + // 告警来源工单,非误报 + OpsOrderSecurityExtDO ext = OpsOrderSecurityExtDO.builder() + .id(1L) + .opsOrderId(TEST_ORDER_ID) + .alarmId(TEST_ALARM_ID) + .falseAlarm(false) + .assignedUserId(TEST_ASSIGNEE_ID) + .build(); + when(securityExtMapper.selectByOpsOrderId(TEST_ORDER_ID)).thenReturn(ext); + lenient().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)); + + // 执行 + listener.onOrderStateChanged(event); + + // 验证同步状态为 completed + ArgumentCaptor captor = ArgumentCaptor.forClass(VspSyncStatusReqDTO.class); + verify(vspNotifyClient).syncStatus(captor.capture()); + assertEquals("completed", captor.getValue().getStatus()); + } + + @Test + void onOrderStateChanged_shouldSyncFalseAlarm() { + OrderStateChangedEvent event = buildStateChangedEvent( + WorkOrderStatusEnum.ARRIVED, WorkOrderStatusEnum.COMPLETED); + + // 告警来源工单,标记为误报 + OpsOrderSecurityExtDO ext = OpsOrderSecurityExtDO.builder() + .id(1L) + .opsOrderId(TEST_ORDER_ID) + .alarmId(TEST_ALARM_ID) + .falseAlarm(true) + .assignedUserId(TEST_ASSIGNEE_ID) + .build(); + when(securityExtMapper.selectByOpsOrderId(TEST_ORDER_ID)).thenReturn(ext); + lenient().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)); + + // 执行 + listener.onOrderStateChanged(event); + + // 验证同步状态为 false_alarm + ArgumentCaptor captor = ArgumentCaptor.forClass(VspSyncStatusReqDTO.class); + verify(vspNotifyClient).syncStatus(captor.capture()); + assertEquals("false_alarm", captor.getValue().getStatus()); + } + + @Test + void onOrderStateChanged_shouldNotSync_whenNoAlarmId() { + OrderStateChangedEvent event = buildStateChangedEvent( + WorkOrderStatusEnum.DISPATCHED, WorkOrderStatusEnum.CONFIRMED); + + // 手动工单,无告警ID + OpsOrderSecurityExtDO ext = OpsOrderSecurityExtDO.builder() + .id(1L) + .opsOrderId(TEST_ORDER_ID) + .alarmId(null) + .build(); + when(securityExtMapper.selectByOpsOrderId(TEST_ORDER_ID)).thenReturn(ext); + lenient().when(securityExtMapper.insertOrUpdateSelective(any())).thenReturn(1); + + // 执行 + listener.onOrderStateChanged(event); + + // 验证不同步状态 + verify(vspNotifyClient, never()).syncStatus(any()); + } + + // ==================== 辅助方法 ==================== + + private OrderStateChangedEvent buildStateChangedEvent( + WorkOrderStatusEnum oldStatus, WorkOrderStatusEnum newStatus) { + return OrderStateChangedEvent.builder() + .orderId(TEST_ORDER_ID) + .orderType("SECURITY") + .oldStatus(oldStatus) + .newStatus(newStatus) + .build(); + } +} diff --git a/viewsh-module-ops/viewsh-module-security-biz/src/test/java/com/viewsh/module/ops/security/integration/vsp/VspNotifyClientImplTest.java b/viewsh-module-ops/viewsh-module-security-biz/src/test/java/com/viewsh/module/ops/security/integration/vsp/VspNotifyClientImplTest.java new file mode 100644 index 0000000..ed9d9b2 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-security-biz/src/test/java/com/viewsh/module/ops/security/integration/vsp/VspNotifyClientImplTest.java @@ -0,0 +1,161 @@ +package com.viewsh.module.ops.security.integration.vsp; + +import com.viewsh.module.ops.security.integration.vsp.config.VspNotifyProperties; +import com.viewsh.module.ops.security.integration.vsp.dto.VspResponseDTO; +import com.viewsh.module.ops.security.integration.vsp.dto.VspSendCardReqDTO; +import com.viewsh.module.ops.security.integration.vsp.dto.VspSyncStatusReqDTO; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +import java.util.List; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * VspNotifyClientImpl 单元测试 + * + * @author lzh + */ +@ExtendWith(MockitoExtension.class) +public class VspNotifyClientImplTest { + + @Mock + private RestTemplate restTemplate; + + private VspNotifyProperties properties; + + private VspNotifyClientImpl client; + + private static final String BASE_URL = "http://localhost:8000"; + + @BeforeEach + void setUp() { + properties = new VspNotifyProperties(); + properties.setEnabled(true); + properties.setBaseUrl(BASE_URL); + properties.setMaxRetry(2); + client = new VspNotifyClientImpl(restTemplate, properties); + } + + // ==================== sendCard 测试 ==================== + + @Test + void sendCard_success() { + VspSendCardReqDTO req = buildSendCardReq(); + + VspResponseDTO resp = new VspResponseDTO(); + resp.setCode(0); + resp.setMsg("ok"); + + when(restTemplate.postForObject(anyString(), any(), eq(VspResponseDTO.class))) + .thenReturn(resp); + + // 执行 + client.sendCard(req); + + // 验证只调用一次 + verify(restTemplate, times(1)).postForObject( + eq(BASE_URL + "/api/wechat/notify/send-card"), + eq(req), + eq(VspResponseDTO.class)); + } + + @Test + void sendCard_retryOnNetworkError() { + VspSendCardReqDTO req = buildSendCardReq(); + + when(restTemplate.postForObject(anyString(), any(), eq(VspResponseDTO.class))) + .thenThrow(new RestClientException("Connection refused")); + + // 执行 + client.sendCard(req); + + // maxRetry=2,总共调用 1 + 2 = 3 次 + verify(restTemplate, times(3)).postForObject( + eq(BASE_URL + "/api/wechat/notify/send-card"), + eq(req), + eq(VspResponseDTO.class)); + } + + @Test + void sendCard_noRetryOnBusinessError() { + VspSendCardReqDTO req = buildSendCardReq(); + + VspResponseDTO resp = new VspResponseDTO(); + resp.setCode(-1); + resp.setMsg("业务失败"); + + when(restTemplate.postForObject(anyString(), any(), eq(VspResponseDTO.class))) + .thenReturn(resp); + + // 执行 + client.sendCard(req); + + // 业务失败不重试,只调用一次 + verify(restTemplate, times(1)).postForObject(anyString(), any(), eq(VspResponseDTO.class)); + } + + @Test + void sendCard_disabled() { + properties.setEnabled(false); + + VspSendCardReqDTO req = buildSendCardReq(); + + // 执行 + client.sendCard(req); + + // 未启用时不调用 RestTemplate + verify(restTemplate, never()).postForObject(anyString(), any(), any()); + } + + // ==================== syncStatus 测试 ==================== + + @Test + void syncStatus_success() { + VspSyncStatusReqDTO req = VspSyncStatusReqDTO.builder() + .alarmId("alarm-001") + .orderId("10001") + .status("confirmed") + .operator("wechat_user_1") + .remark("确认接单") + .build(); + + VspResponseDTO resp = new VspResponseDTO(); + resp.setCode(0); + resp.setMsg("ok"); + + when(restTemplate.postForObject(anyString(), any(), eq(VspResponseDTO.class))) + .thenReturn(resp); + + // 执行 + client.syncStatus(req); + + // 验证使用 sync-status URL + verify(restTemplate, times(1)).postForObject( + eq(BASE_URL + "/api/wechat/notify/sync-status"), + eq(req), + eq(VspResponseDTO.class)); + } + + // ==================== 辅助方法 ==================== + + private VspSendCardReqDTO buildSendCardReq() { + return VspSendCardReqDTO.builder() + .alarmId("alarm-001") + .orderId("10001") + .userIds(List.of("wechat_user_1")) + .title("入侵告警") + .areaName("A栋大堂") + .cameraName("大堂摄像头") + .eventTime("03-24 10:00") + .level(1) + .snapshotUrl("http://example.com/snapshot.jpg") + .build(); + } +}