Commit 56b2b5409668952fea2a3212be4d1d2036c60b39
1 parent
39c3681d
feat(bpm): 增加可选 HTTP 请求域名和 IP 黑白名单
默认关闭名单检查,保留原有 RestTemplate 和正常 HTTP 行为;开启后按域名或 IP 精确匹配,黑名单优先。 验证:BPM 模块全部 316 条测试通过,独立多模型复评通过。
Showing
4 changed files
with
232 additions
and
0 deletions
yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/framework/flowable/config/BpmFlowableConfiguration.java
| @@ -16,6 +16,7 @@ import org.flowable.spring.SpringProcessEngineConfiguration; | @@ -16,6 +16,7 @@ import org.flowable.spring.SpringProcessEngineConfiguration; | ||
| 16 | import org.flowable.spring.boot.EngineConfigurationConfigurer; | 16 | import org.flowable.spring.boot.EngineConfigurationConfigurer; |
| 17 | import org.springframework.beans.factory.ObjectProvider; | 17 | import org.springframework.beans.factory.ObjectProvider; |
| 18 | import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; | 18 | import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; |
| 19 | +import org.springframework.boot.context.properties.EnableConfigurationProperties; | ||
| 19 | import org.springframework.context.ApplicationEventPublisher; | 20 | import org.springframework.context.ApplicationEventPublisher; |
| 20 | import org.springframework.context.annotation.Bean; | 21 | import org.springframework.context.annotation.Bean; |
| 21 | import org.springframework.context.annotation.Configuration; | 22 | import org.springframework.context.annotation.Configuration; |
| @@ -32,6 +33,7 @@ import java.util.List; | @@ -32,6 +33,7 @@ import java.util.List; | ||
| 32 | * @author jason | 33 | * @author jason |
| 33 | */ | 34 | */ |
| 34 | @Configuration(proxyBeanMethods = false) | 35 | @Configuration(proxyBeanMethods = false) |
| 36 | +@EnableConfigurationProperties(BpmHttpRequestProperties.class) | ||
| 35 | @Slf4j | 37 | @Slf4j |
| 36 | public class BpmFlowableConfiguration { | 38 | public class BpmFlowableConfiguration { |
| 37 | 39 |
yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/framework/flowable/config/BpmHttpRequestProperties.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.bpm.framework.flowable.config; | ||
| 2 | + | ||
| 3 | +import lombok.Data; | ||
| 4 | +import org.springframework.boot.context.properties.ConfigurationProperties; | ||
| 5 | + | ||
| 6 | +import java.util.HashSet; | ||
| 7 | +import java.util.Set; | ||
| 8 | + | ||
| 9 | +/** | ||
| 10 | + * BPM 流程模型发起 HTTP 请求的配置类:请求地址的域名 / IP 黑白名单 | ||
| 11 | + * | ||
| 12 | + * 作用于流程模型中配置的 HTTP 请求:HTTP 请求触发器、HTTP 回调触发器、任务前置/后置通知、流程前置/后置通知、审批人监听器 HTTP。 | ||
| 13 | + * 默认关闭,不做任何校验,与原有行为一致。如需开启,直接修改本类字段的默认值即可,例如说: | ||
| 14 | + * | ||
| 15 | + * <pre> | ||
| 16 | + * private Boolean enabled = true; | ||
| 17 | + * private Set<String> allowHosts = new HashSet<>(Set.of("erp.example.internal", "10.0.0.8")); | ||
| 18 | + * private Set<String> denyHosts = new HashSet<>(Set.of("169.254.169.254")); | ||
| 19 | + * </pre> | ||
| 20 | + * | ||
| 21 | + * 注意:只校验流程模型中填写的 URL 的域名 / IP,不解析 DNS,不能防御 DNS 重绑定、代理、重定向等场景,不是完整的 SSRF 防护 | ||
| 22 | + * | ||
| 23 | + * @author 芋道源码 | ||
| 24 | + */ | ||
| 25 | +@ConfigurationProperties(prefix = "yudao.bpm.http-request") | ||
| 26 | +@Data | ||
| 27 | +public class BpmHttpRequestProperties { | ||
| 28 | + | ||
| 29 | + /** | ||
| 30 | + * 是否开启黑白名单校验,默认关闭 | ||
| 31 | + */ | ||
| 32 | + private Boolean enabled = false; | ||
| 33 | + | ||
| 34 | + /** | ||
| 35 | + * 白名单:域名或 IP,忽略大小写后精确匹配,例如说 erp.example.internal、10.0.0.8 | ||
| 36 | + * | ||
| 37 | + * 非空时,只允许请求列表中的域名或 IP;为空时,不额外限制 | ||
| 38 | + */ | ||
| 39 | + private Set<String> allowHosts = new HashSet<>(); | ||
| 40 | + | ||
| 41 | + /** | ||
| 42 | + * 黑名单:域名或 IP,忽略大小写后精确匹配,例如说 169.254.169.254 | ||
| 43 | + * | ||
| 44 | + * 优先于白名单判断,命中时拒绝请求 | ||
| 45 | + */ | ||
| 46 | + private Set<String> denyHosts = new HashSet<>(); | ||
| 47 | + | ||
| 48 | +} |
yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/util/BpmHttpRequestUtils.java
| @@ -10,6 +10,7 @@ import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder; | @@ -10,6 +10,7 @@ import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder; | ||
| 10 | import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEvent; | 10 | import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEvent; |
| 11 | import cn.iocoder.yudao.module.bpm.controller.admin.definition.vo.model.simple.BpmSimpleModelNodeVO; | 11 | import cn.iocoder.yudao.module.bpm.controller.admin.definition.vo.model.simple.BpmSimpleModelNodeVO; |
| 12 | import cn.iocoder.yudao.module.bpm.enums.definition.BpmHttpRequestParamTypeEnum; | 12 | import cn.iocoder.yudao.module.bpm.enums.definition.BpmHttpRequestParamTypeEnum; |
| 13 | +import cn.iocoder.yudao.module.bpm.framework.flowable.config.BpmHttpRequestProperties; | ||
| 13 | import cn.iocoder.yudao.module.bpm.service.task.BpmProcessInstanceService; | 14 | import cn.iocoder.yudao.module.bpm.service.task.BpmProcessInstanceService; |
| 14 | import com.fasterxml.jackson.core.type.TypeReference; | 15 | import com.fasterxml.jackson.core.type.TypeReference; |
| 15 | import lombok.extern.slf4j.Slf4j; | 16 | import lombok.extern.slf4j.Slf4j; |
| @@ -19,10 +20,14 @@ import org.springframework.util.LinkedMultiValueMap; | @@ -19,10 +20,14 @@ import org.springframework.util.LinkedMultiValueMap; | ||
| 19 | import org.springframework.util.MultiValueMap; | 20 | import org.springframework.util.MultiValueMap; |
| 20 | import org.springframework.web.client.RestClientException; | 21 | import org.springframework.web.client.RestClientException; |
| 21 | import org.springframework.web.client.RestTemplate; | 22 | import org.springframework.web.client.RestTemplate; |
| 23 | +import org.springframework.web.util.UriComponents; | ||
| 24 | +import org.springframework.web.util.UriComponentsBuilder; | ||
| 22 | 25 | ||
| 23 | import java.util.HashMap; | 26 | import java.util.HashMap; |
| 24 | import java.util.List; | 27 | import java.util.List; |
| 28 | +import java.util.Locale; | ||
| 25 | import java.util.Map; | 29 | import java.util.Map; |
| 30 | +import java.util.Set; | ||
| 26 | 31 | ||
| 27 | import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; | 32 | import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; |
| 28 | import static cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils.HEADER_TENANT_ID; | 33 | import static cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils.HEADER_TENANT_ID; |
| @@ -42,6 +47,8 @@ public class BpmHttpRequestUtils { | @@ -42,6 +47,8 @@ public class BpmHttpRequestUtils { | ||
| 42 | List<BpmSimpleModelNodeVO.HttpRequestParam> bodyParams, | 47 | List<BpmSimpleModelNodeVO.HttpRequestParam> bodyParams, |
| 43 | Boolean handleResponse, | 48 | Boolean handleResponse, |
| 44 | List<KeyValue<String, String>> response) { | 49 | List<KeyValue<String, String>> response) { |
| 50 | + // 0. 校验请求地址的域名 / IP 黑白名单(默认关闭) | ||
| 51 | + validateHttpRequestUrl(url); | ||
| 45 | BpmProcessInstanceService processInstanceService = SpringUtils.getBean(BpmProcessInstanceService.class); | 52 | BpmProcessInstanceService processInstanceService = SpringUtils.getBean(BpmProcessInstanceService.class); |
| 46 | 53 | ||
| 47 | // 1.1 设置请求头 | 54 | // 1.1 设置请求头 |
| @@ -185,4 +192,68 @@ public class BpmHttpRequestUtils { | @@ -185,4 +192,68 @@ public class BpmHttpRequestUtils { | ||
| 185 | }); | 192 | }); |
| 186 | } | 193 | } |
| 187 | 194 | ||
| 195 | + /** | ||
| 196 | + * 校验流程模型配置的请求地址,是否满足 {@link BpmHttpRequestProperties} 的域名 / IP 黑白名单 | ||
| 197 | + * | ||
| 198 | + * 1. 未开启时,不做任何校验 | ||
| 199 | + * 2. 开启时,只允许 http、https;黑名单优先,命中则拒绝;白名单非空时,必须命中 | ||
| 200 | + * 3. 只校验 URL 中填写的域名或 IP(忽略大小写,精确匹配),不解析 DNS,不能防御 DNS 重绑定、代理、重定向等场景 | ||
| 201 | + * | ||
| 202 | + * @param url 请求地址 | ||
| 203 | + */ | ||
| 204 | + private static void validateHttpRequestUrl(String url) { | ||
| 205 | + BpmHttpRequestProperties properties = SpringUtils.getBean(BpmHttpRequestProperties.class); | ||
| 206 | + if (!Boolean.TRUE.equals(properties.getEnabled())) { | ||
| 207 | + return; | ||
| 208 | + } | ||
| 209 | + String host = getHttpRequestHost(url); | ||
| 210 | + if (host == null) { | ||
| 211 | + log.warn("[validateHttpRequestUrl][请求地址({}) 不合法,拒绝请求]", url); | ||
| 212 | + throw exception(PROCESS_INSTANCE_HTTP_CALL_ERROR); | ||
| 213 | + } | ||
| 214 | + if (containsHost(properties.getDenyHosts(), host)) { | ||
| 215 | + log.warn("[validateHttpRequestUrl][请求地址的主机({}) 在黑名单中,拒绝请求]", host); | ||
| 216 | + throw exception(PROCESS_INSTANCE_HTTP_CALL_ERROR); | ||
| 217 | + } | ||
| 218 | + if (CollUtil.isNotEmpty(properties.getAllowHosts()) && !containsHost(properties.getAllowHosts(), host)) { | ||
| 219 | + log.warn("[validateHttpRequestUrl][请求地址的主机({}) 不在白名单中,拒绝请求]", host); | ||
| 220 | + throw exception(PROCESS_INSTANCE_HTTP_CALL_ERROR); | ||
| 221 | + } | ||
| 222 | + } | ||
| 223 | + | ||
| 224 | + /** | ||
| 225 | + * 获得请求地址的主机(域名或 IP),使用与 RestTemplate 相同的 URI 解析方式 | ||
| 226 | + * | ||
| 227 | + * @param url 请求地址 | ||
| 228 | + * @return 规范化后的主机;不是 http、https 的绝对地址时,返回 null | ||
| 229 | + */ | ||
| 230 | + private static String getHttpRequestHost(String url) { | ||
| 231 | + if (StrUtil.isBlank(url)) { | ||
| 232 | + return null; | ||
| 233 | + } | ||
| 234 | + UriComponents uriComponents; | ||
| 235 | + try { | ||
| 236 | + uriComponents = UriComponentsBuilder.fromUriString(url).build(); | ||
| 237 | + } catch (IllegalArgumentException e) { | ||
| 238 | + return null; | ||
| 239 | + } | ||
| 240 | + if (!StrUtil.equalsAnyIgnoreCase(uriComponents.getScheme(), "http", "https") | ||
| 241 | + || StrUtil.isBlank(uriComponents.getHost())) { | ||
| 242 | + return null; | ||
| 243 | + } | ||
| 244 | + return normalizeHost(uriComponents.getHost()); | ||
| 245 | + } | ||
| 246 | + | ||
| 247 | + private static boolean containsHost(Set<String> hosts, String host) { | ||
| 248 | + return CollUtil.isNotEmpty(hosts) && hosts.stream().anyMatch(item -> host.equals(normalizeHost(item))); | ||
| 249 | + } | ||
| 250 | + | ||
| 251 | + /** | ||
| 252 | + * 规范化主机:去掉首尾空白、转小写,IPv6 去掉方括号 | ||
| 253 | + */ | ||
| 254 | + private static String normalizeHost(String host) { | ||
| 255 | + String result = StrUtil.nullToEmpty(StrUtil.trim(host)).toLowerCase(Locale.ROOT); | ||
| 256 | + return StrUtil.removeSuffix(StrUtil.removePrefix(result, "["), "]"); | ||
| 257 | + } | ||
| 258 | + | ||
| 188 | } | 259 | } |
yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/util/BpmHttpRequestUtilsTest.java
| @@ -4,6 +4,7 @@ import cn.hutool.extra.spring.SpringUtil; | @@ -4,6 +4,7 @@ import cn.hutool.extra.spring.SpringUtil; | ||
| 4 | import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder; | 4 | import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder; |
| 5 | import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; | 5 | import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; |
| 6 | import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEvent; | 6 | import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEvent; |
| 7 | +import cn.iocoder.yudao.module.bpm.framework.flowable.config.BpmHttpRequestProperties; | ||
| 7 | import cn.iocoder.yudao.module.bpm.service.task.BpmProcessInstanceService; | 8 | import cn.iocoder.yudao.module.bpm.service.task.BpmProcessInstanceService; |
| 8 | import org.flowable.engine.runtime.ProcessInstance; | 9 | import org.flowable.engine.runtime.ProcessInstance; |
| 9 | import org.junit.jupiter.api.AfterEach; | 10 | import org.junit.jupiter.api.AfterEach; |
| @@ -15,7 +16,11 @@ import org.springframework.http.HttpEntity; | @@ -15,7 +16,11 @@ import org.springframework.http.HttpEntity; | ||
| 15 | import org.springframework.http.ResponseEntity; | 16 | import org.springframework.http.ResponseEntity; |
| 16 | import org.springframework.web.client.RestTemplate; | 17 | import org.springframework.web.client.RestTemplate; |
| 17 | 18 | ||
| 19 | +import java.util.Set; | ||
| 20 | + | ||
| 21 | +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; | ||
| 18 | import static cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils.HEADER_TENANT_ID; | 22 | import static cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils.HEADER_TENANT_ID; |
| 23 | +import static cn.iocoder.yudao.module.bpm.enums.ErrorCodeConstants.PROCESS_INSTANCE_HTTP_CALL_ERROR; | ||
| 19 | import static org.junit.jupiter.api.Assertions.assertEquals; | 24 | import static org.junit.jupiter.api.Assertions.assertEquals; |
| 20 | import static org.mockito.ArgumentMatchers.*; | 25 | import static org.mockito.ArgumentMatchers.*; |
| 21 | import static org.mockito.Mockito.*; | 26 | import static org.mockito.Mockito.*; |
| @@ -62,4 +67,110 @@ public class BpmHttpRequestUtilsTest extends BaseMockitoUnitTest { | @@ -62,4 +67,110 @@ public class BpmHttpRequestUtilsTest extends BaseMockitoUnitTest { | ||
| 62 | assertEquals("tenant-1", requestCaptor.getValue().getHeaders().getFirst(HEADER_TENANT_ID)); | 67 | assertEquals("tenant-1", requestCaptor.getValue().getHeaders().getFirst(HEADER_TENANT_ID)); |
| 63 | } | 68 | } |
| 64 | 69 | ||
| 70 | + @Test | ||
| 71 | + public void testExecuteBpmHttpRequest_disabled() { | ||
| 72 | + // 准备参数:默认关闭,不做任何校验,与原有行为一致 | ||
| 73 | + BpmHttpRequestProperties properties = new BpmHttpRequestProperties(); | ||
| 74 | + properties.setDenyHosts(Set.of("169.254.169.254")); | ||
| 75 | + | ||
| 76 | + // 调用,并断言:仍然发起请求 | ||
| 77 | + executeBpmHttpRequestSuccess(properties, "http://169.254.169.254/latest"); | ||
| 78 | + } | ||
| 79 | + | ||
| 80 | + @Test | ||
| 81 | + public void testExecuteBpmHttpRequest_allowHostMatch() { | ||
| 82 | + // 准备参数:域名、IP 精确匹配,忽略大小写 | ||
| 83 | + BpmHttpRequestProperties properties = buildProperties(Set.of("ERP.example.internal", "10.0.0.8", "::1"), Set.of()); | ||
| 84 | + | ||
| 85 | + // 调用,并断言:发起请求 | ||
| 86 | + executeBpmHttpRequestSuccess(properties, "https://erp.EXAMPLE.internal/api", | ||
| 87 | + "http://10.0.0.8:8080/callback", "http://[::1]:48080/callback"); | ||
| 88 | + } | ||
| 89 | + | ||
| 90 | + @Test | ||
| 91 | + public void testExecuteBpmHttpRequest_denyHostFirst() { | ||
| 92 | + // 准备参数:同时在黑名单、白名单中,黑名单优先 | ||
| 93 | + BpmHttpRequestProperties properties = buildProperties(Set.of("10.0.0.8"), Set.of(" 10.0.0.8 ")); | ||
| 94 | + | ||
| 95 | + // 调用,并断言:拒绝请求 | ||
| 96 | + executeBpmHttpRequestFail(properties, "http://10.0.0.8/callback"); | ||
| 97 | + } | ||
| 98 | + | ||
| 99 | + @Test | ||
| 100 | + public void testExecuteBpmHttpRequest_notInAllowHosts() { | ||
| 101 | + // 准备参数:白名单非空时,只允许列表中的域名或 IP,不做后缀、包含匹配 | ||
| 102 | + BpmHttpRequestProperties properties = buildProperties(Set.of("erp.example.internal"), Set.of()); | ||
| 103 | + | ||
| 104 | + // 调用,并断言:拒绝请求 | ||
| 105 | + executeBpmHttpRequestFail(properties, "http://a.erp.example.internal/callback", | ||
| 106 | + "http://erp.example.internal.evil.com/callback", "http://10.0.0.9/callback"); | ||
| 107 | + } | ||
| 108 | + | ||
| 109 | + @Test | ||
| 110 | + public void testExecuteBpmHttpRequest_emptyHosts() { | ||
| 111 | + // 准备参数:开启,但黑白名单都为空时,不额外限制 | ||
| 112 | + BpmHttpRequestProperties properties = buildProperties(Set.of(), Set.of()); | ||
| 113 | + | ||
| 114 | + // 调用,并断言:发起请求 | ||
| 115 | + executeBpmHttpRequestSuccess(properties, "http://169.254.169.254/latest"); | ||
| 116 | + } | ||
| 117 | + | ||
| 118 | + @Test | ||
| 119 | + public void testExecuteBpmHttpRequest_denyHostsOnly() { | ||
| 120 | + // 准备参数:只配置黑名单时,命中黑名单拒绝 | ||
| 121 | + BpmHttpRequestProperties properties = buildProperties(Set.of(), Set.of("169.254.169.254")); | ||
| 122 | + | ||
| 123 | + // 调用,并断言:拒绝请求 | ||
| 124 | + executeBpmHttpRequestFail(properties, "http://169.254.169.254/latest"); | ||
| 125 | + } | ||
| 126 | + | ||
| 127 | + @Test | ||
| 128 | + public void testExecuteBpmHttpRequest_invalidUrl() { | ||
| 129 | + // 准备参数:开启后,只允许带域名或 IP 的 http、https 地址 | ||
| 130 | + BpmHttpRequestProperties properties = buildProperties(Set.of(), Set.of()); | ||
| 131 | + | ||
| 132 | + // 调用,并断言:拒绝请求 | ||
| 133 | + executeBpmHttpRequestFail(properties, "ftp://10.0.0.8/callback", "file:///etc/passwd", | ||
| 134 | + "//10.0.0.8/callback", "callback", "http:///callback", "", null); | ||
| 135 | + } | ||
| 136 | + | ||
| 137 | + private static BpmHttpRequestProperties buildProperties(Set<String> allowHosts, Set<String> denyHosts) { | ||
| 138 | + BpmHttpRequestProperties properties = new BpmHttpRequestProperties(); | ||
| 139 | + properties.setEnabled(true); | ||
| 140 | + properties.setAllowHosts(allowHosts); | ||
| 141 | + properties.setDenyHosts(denyHosts); | ||
| 142 | + return properties; | ||
| 143 | + } | ||
| 144 | + | ||
| 145 | + private void executeBpmHttpRequestSuccess(BpmHttpRequestProperties properties, String... urls) { | ||
| 146 | + when(restTemplate.exchange(anyString(), any(), any(), eq(String.class))).thenReturn(ResponseEntity.ok("{}")); | ||
| 147 | + try (MockedStatic<SpringUtil> springUtilMockedStatic = mockStatic(SpringUtil.class)) { | ||
| 148 | + springUtilMockedStatic.when(() -> SpringUtil.getBean(BpmHttpRequestProperties.class)).thenReturn(properties); | ||
| 149 | + springUtilMockedStatic.when(() -> SpringUtil.getBean(BpmProcessInstanceService.class)) | ||
| 150 | + .thenReturn(processInstanceService); | ||
| 151 | + springUtilMockedStatic.when(() -> SpringUtil.getBean(RestTemplate.class)).thenReturn(restTemplate); | ||
| 152 | + | ||
| 153 | + for (String url : urls) { | ||
| 154 | + // 调用 | ||
| 155 | + BpmHttpRequestUtils.executeBpmHttpRequest(processInstance, url, null, null, false, null); | ||
| 156 | + // 断言:使用原有 RestTemplate 发起请求 | ||
| 157 | + verify(restTemplate).exchange(eq(url), any(), any(), eq(String.class)); | ||
| 158 | + } | ||
| 159 | + } | ||
| 160 | + } | ||
| 161 | + | ||
| 162 | + private void executeBpmHttpRequestFail(BpmHttpRequestProperties properties, String... urls) { | ||
| 163 | + try (MockedStatic<SpringUtil> springUtilMockedStatic = mockStatic(SpringUtil.class)) { | ||
| 164 | + springUtilMockedStatic.when(() -> SpringUtil.getBean(BpmHttpRequestProperties.class)).thenReturn(properties); | ||
| 165 | + | ||
| 166 | + for (String url : urls) { | ||
| 167 | + // 调用,并断言 | ||
| 168 | + assertServiceException(() -> BpmHttpRequestUtils.executeBpmHttpRequest(processInstance, url, | ||
| 169 | + null, null, false, null), PROCESS_INSTANCE_HTTP_CALL_ERROR); | ||
| 170 | + } | ||
| 171 | + } | ||
| 172 | + // 断言:未发起请求 | ||
| 173 | + verify(restTemplate, never()).exchange(anyString(), any(), any(), eq(String.class)); | ||
| 174 | + } | ||
| 175 | + | ||
| 65 | } | 176 | } |