feat(紫鸟): 令牌级静态代理支持——白名单被拒的 key 走已授权代理 IP

- shop_key 新增 proxy_url / ip_whitelist_fail_count(V114 迁移)
- HttpClientPool 支持按代理地址复用的静态代理客户端
- 白名单连续失败达阈值后停止自动重试,控制台可按 key 配置代理(改动自
  上一工作阶段遗留,功能已编译验证)
This commit is contained in:
2026-09-11 17:11:50 +08:00
parent e6021593ea
commit 47459e2089
13 changed files with 419 additions and 22 deletions
@@ -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<>();
}