228d481211
Java: - LLM 180s 读超时不再被全局 call-timeout 静默截断成 90s(长思考请求被掐断→重试→付费网关二次计费) - 代理 HttpClient 缓存改有界 LRU(jikip 每次提取新 IP,无界缓存持续泄漏 selector 线程与连接池) - 12 个 service 的 Redis 任务锁移出 @Transactional(自旋最坏 10s 白占 DB 连接,池仅 30),远端对象删/传改 afterCommit - 结果文件 Job 闸门拒绝时不再回退内联执行(改重新入队,避免把背压转嫁给 MQ 消费线程) - imagevideo 每秒扫描加列投影、过期清理加 LIMIT;权限页整表查询改列投影(不再拉回密码哈希) - 哈希改 HexFormat;补 5 处"不能改"的技术依据注释(批量插入会丢回填主键、流式丢模板与图片等) 前端:4 个工具页轮询改轻量端点(带 fallback);PriceTrack 快照节流写盘;候选店铺表分页;页面隐藏时停表 客户端:HTTP 连接池按出口复用(Session 仍每请求新建,保持无跨请求状态);品牌检测 WIPO 逐请求握手; 代理配置按 mtime 缓存;串行任务改专属池;异常降级为标签页重连;紫鸟启动改端口轮询;模板编译缓存; 日志上报连接与落盘收口;Flask 版本 API 改按请求复用连接
177 lines
7.9 KiB
Java
177 lines
7.9 KiB
Java
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;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 打开远程文件流(带超时),调用方负责关闭返回的流。
|
||
*
|
||
* <p>替代裸 {@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<InputStream> 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<ProxyEndpoint, HttpClient> PROXY_CLIENTS =
|
||
new BoundedLruCache<>(BoundedLruCache.DEFAULT_MAX_SIZE);
|
||
}
|