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

View File

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

View File

@@ -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 解析器
* <p>
* 通过 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<SocialUserRespDTO> 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;
}
}

View File

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

View File

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

View File

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

View File

@@ -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<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;
/** 将可选的 String 字段 null 转为空字符串,避免对端 422 */
public void nullToEmpty() {
if (areaName == null) areaName = "";
if (cameraName == null) cameraName = "";
if (eventTime == null) eventTime = "";
if (snapshotUrl == null) snapshotUrl = "";
}
}

View File

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