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); }