feat(ops): 新增 VSP 企微通知网关基础设施

- VspNotifyClient/Impl: HTTP 客户端,支持网络异常重试 + 线性退避
- VspNotifyProperties/Config: 配置属性与 RestTemplate Bean(JdkClientHttpRequestFactory)
- VspSendCardReqDTO/VspSyncStatusReqDTO/VspResponseDTO: 请求响应 DTO
- WechatUserIdResolver: 企微 userId 查询工具,通过 SocialUserApi 查询,供各业务线复用

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
lzh
2026-03-25 11:28:53 +08:00
parent 40e46d3650
commit e44c1f6f4e
19 changed files with 1231 additions and 0 deletions

View File

@@ -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 企微通知监听器
* <p>
* 职责单一:监听工单事件,调用 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);
}
// ==================== 核心方法 ====================
/**
* 派单成功后发送企微工单卡片
* <p>
* 由 {@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;
}
}
}

View File

@@ -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);
}

View File

@@ -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);
}
}
}
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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<String> 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;
}

View File

@@ -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;
}

View File

@@ -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<VspSendCardReqDTO> 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<VspSyncStatusReqDTO> 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<VspSyncStatusReqDTO> 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<VspSyncStatusReqDTO> 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();
}
}

View File

@@ -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();
}
}