package com.nanri.aiimage.config;
import com.nanri.aiimage.common.util.BoundedLruCache;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.client.ClientHttpRequestFactory;
import org.springframework.http.client.JdkClientHttpRequestFactory;
import java.io.IOException;
import java.io.InputStream;
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.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
/**
* Task 77:外部 HTTP 客户端统一连接复用池。
* LLM / 品牌检查 / 紫鸟三个外部客户端共用同一个 java.net.http.HttpClient
* (内置 keep-alive 连接池),避免各自新建短命客户端导致连接无法复用、
* 每次请求都重新建连。各客户端按自身超时创建独立的
* JdkClientHttpRequestFactory(共享底层连接池),RestClient 单例懒加载。
*/
@Slf4j
public class HttpClientPool {
private static volatile HttpClient sharedHttpClient;
private static volatile long configuredCallTimeoutMillis;
/** 由 Spring 配置属性在启动阶段调用,确保共享客户端使用实际的 connect/call 配置。 */
public static void configure(long connectTimeoutMillis, long callTimeoutMillis) {
configuredCallTimeoutMillis = Math.max(1_000L, callTimeoutMillis);
synchronized (HttpClientPool.class) {
if (sharedHttpClient == null) {
sharedHttpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofMillis(Math.max(1_000L, connectTimeoutMillis)))
.version(HttpClient.Version.HTTP_1_1)
.build();
}
}
}
/** 共享连接池实例:单一 HttpClient 承载全部外部调用的连接复用。 */
public static HttpClient sharedHttpClient() {
HttpClient client = sharedHttpClient;
if (client != null) {
return client;
}
synchronized (HttpClientPool.class) {
if (sharedHttpClient == null) {
sharedHttpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofMillis(10_000L))
.version(HttpClient.Version.HTTP_1_1)
.build();
}
return sharedHttpClient;
}
}
/**
* 打开远程文件流(带超时),调用方负责关闭返回的流。
*
*
替代裸 {@code URI.create(url).toURL().openStream()}:后者走 JVM 默认超时(0 = 无限),
* 上游半开连接或挂起时会把 Tomcat 工作线程无限占用(管理端批量打包可同时挂多个)。
* 返回的流是流式的,适用于「服务端代理下载 OSS 文件转发给浏览器」这类不落盘场景。
*
* @param url 远程地址
* @param timeout 等待响应超时(连接建立 + 响应头);非法值钳制到 1 秒
* @throws IOException 非 2xx 响应或网络异常
* @throws InterruptedException 线程被中断
*/
public static InputStream openStreamWithTimeout(String url, Duration timeout) throws IOException, InterruptedException {
Duration effective = (timeout == null || timeout.isZero() || timeout.isNegative())
? Duration.ofSeconds(1)
: timeout;
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
.timeout(effective)
.GET()
.build();
HttpResponse response = sharedHttpClient().send(request, HttpResponse.BodyHandlers.ofInputStream());
if (response.statusCode() / 100 != 2) {
try {
response.body().close();
} catch (Exception ignored) {
// 关闭失败不影响错误上报
}
throw new IOException("远程文件返回非 2xx: HTTP " + response.statusCode());
}
return response.body();
}
/** 按 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 > callTimeout) {
// 读超时以调用方显式值为准,不再被全局 call-timeout 截断:
// LLM 长思考配的是 180s(llm-read-timeout-millis),曾被静默压到 90s,
// 导致请求在 90s 被掐断 → 上层重试 → 付费网关二次计费(2026-09-15 修复)。
// 各调用方的超时已由各自的 HttpConfigResolver 钳制,此处不再二次收敛。
log.debug("读超时 {}ms 超过全局 call-timeout {}ms,按调用方显式值生效", safeReadTimeout, callTimeout);
}
JdkClientHttpRequestFactory factory =
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 BoundedLruCache PROXY_CLIENTS =
new BoundedLruCache<>(BoundedLruCache.DEFAULT_MAX_SIZE);
}