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:
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 = "";
|
||||
}
|
||||
}
|
||||
@@ -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 = "";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user