feat(紫鸟): 令牌级静态代理支持——白名单被拒的 key 走已授权代理 IP
- shop_key 新增 proxy_url / ip_whitelist_fail_count(V114 迁移) - HttpClientPool 支持按代理地址复用的静态代理客户端 - 白名单连续失败达阈值后停止自动重试,控制台可按 key 配置代理(改动自 上一工作阶段遗留,功能已编译验证)
This commit is contained in:
@@ -3,8 +3,15 @@ package com.nanri.aiimage.config;
|
||||
import org.springframework.http.client.ClientHttpRequestFactory;
|
||||
import org.springframework.http.client.JdkClientHttpRequestFactory;
|
||||
|
||||
import java.net.Authenticator;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.PasswordAuthentication;
|
||||
import java.net.ProxySelector;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Task 77:外部 HTTP 客户端统一连接复用池。
|
||||
@@ -50,14 +57,78 @@ public class HttpClientPool {
|
||||
|
||||
/** 按 readTimeout(毫秒)创建共享连接池工厂;非法值钳制到最小正数。 */
|
||||
public static ClientHttpRequestFactory requestFactory(int readTimeoutMillis) {
|
||||
return requestFactory(readTimeoutMillis, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 readTimeout 创建请求工厂;proxyUrl 非空时改走该静态代理(用于出口 IP 需白名单的场景)。
|
||||
* 代理 HttpClient 按代理地址缓存复用,避免每次请求新建连接池。
|
||||
*/
|
||||
public static ClientHttpRequestFactory requestFactory(int readTimeoutMillis, String proxyUrl) {
|
||||
long safeReadTimeout = Math.max(1L, readTimeoutMillis);
|
||||
long callTimeout = configuredCallTimeoutMillis;
|
||||
if (callTimeout > 0L) {
|
||||
safeReadTimeout = Math.min(safeReadTimeout, callTimeout);
|
||||
}
|
||||
JdkClientHttpRequestFactory factory =
|
||||
new JdkClientHttpRequestFactory(sharedHttpClient());
|
||||
new JdkClientHttpRequestFactory(httpClientFor(proxyUrl));
|
||||
factory.setReadTimeout(Duration.ofMillis(safeReadTimeout));
|
||||
return factory;
|
||||
}
|
||||
|
||||
/** 解析代理地址并返回对应 HttpClient;地址为空/非法时回退共享直连客户端。 */
|
||||
private static HttpClient httpClientFor(String proxyUrl) {
|
||||
URI uri = parseProxyUri(proxyUrl);
|
||||
if (uri == null) {
|
||||
return sharedHttpClient();
|
||||
}
|
||||
ProxyEndpoint endpoint = new ProxyEndpoint(uri.getHost(), uri.getPort(), uri.getUserInfo());
|
||||
return PROXY_CLIENTS.computeIfAbsent(endpoint, HttpClientPool::buildProxyClient);
|
||||
}
|
||||
|
||||
private static HttpClient buildProxyClient(ProxyEndpoint endpoint) {
|
||||
HttpClient.Builder builder = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofMillis(10_000L))
|
||||
.version(HttpClient.Version.HTTP_1_1)
|
||||
.proxy(ProxySelector.of(new InetSocketAddress(endpoint.host(), endpoint.port())));
|
||||
if (endpoint.userInfo() != null && !endpoint.userInfo().isBlank()) {
|
||||
String[] parts = endpoint.userInfo().split(":", 2);
|
||||
String user = parts[0];
|
||||
char[] password = parts.length > 1 ? parts[1].toCharArray() : new char[0];
|
||||
builder.authenticator(new Authenticator() {
|
||||
@Override
|
||||
protected PasswordAuthentication getPasswordAuthentication() {
|
||||
return new PasswordAuthentication(user, password);
|
||||
}
|
||||
});
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 http(s)://[user:pass@]host:port;不合法返回 null(由调用方回退直连)。
|
||||
*/
|
||||
private static URI parseProxyUri(String proxyUrl) {
|
||||
if (proxyUrl == null || proxyUrl.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
URI uri = URI.create(proxyUrl.trim());
|
||||
if (uri.getHost() == null || uri.getHost().isBlank() || uri.getPort() <= 0) {
|
||||
return null;
|
||||
}
|
||||
String scheme = uri.getScheme();
|
||||
if (scheme != null && !scheme.equalsIgnoreCase("http") && !scheme.equalsIgnoreCase("https")) {
|
||||
return null;
|
||||
}
|
||||
return uri;
|
||||
} catch (Exception ex) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private record ProxyEndpoint(String host, int port, String userInfo) {
|
||||
}
|
||||
|
||||
private static final Map<ProxyEndpoint, HttpClient> PROXY_CLIENTS = new ConcurrentHashMap<>();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user