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