Files
crawler-plugin/backend-java/src/test/java/com/nanri/aiimage/config/HttpClientConnectionReuseTest.java
T
huangzd1997 e2607ab723
Build Backend JAR / build (push) Has been cancelled
task-98: 移除 similar-asin/appearance-patent 模块 Coze,状态机与共享组件改名 LLM
- similarasin/appearancepatent 模块全部 Coze 工作流调用改走 direct-LLM(已确认唯一运行路径)
- 共享组件改名:CozeTaskQueueGate→TaskQueueGate、CozeGroupResultPropagator→GroupResultPropagator
- 状态机改名:biz_task_scope_state 的 coze_* 列→llm_*、stateJson coze 键→llm(V100 迁移已应用生产)
- 删除 biz_coze_credential 表、CozeCredential* 类、SimilarAsinCozeClient、AppearancePatentCozeClient→LlmClient
- 前端 brand 页 Coze 文案→LLM;Python 后端删除 cozepy 依赖与死配置
- 修复 TaskResultFileJobWorker 启动失败:TaskFileJobConfig 注册 ResultFileJobHandlerRegistry 与 13 个 handler bean(含 validateCoverage 启动校验)
2026-09-01 02:20:50 +08:00

177 lines
8.3 KiB
Java

package com.nanri.aiimage.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.modules.brand.client.BrandCheckClient;
import com.nanri.aiimage.modules.brand.client.BrandCheckClient.BrandCheckBatchResult;
import com.nanri.aiimage.modules.similarasin.client.SimilarAsinLlmClient;
import com.nanri.aiimage.modules.ziniao.client.ZiniaoClientImpl;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.client.ClientHttpRequestFactory;
import org.springframework.http.client.JdkClientHttpRequestFactory;
import org.springframework.web.client.RestClient;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.net.http.HttpClient;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotSame;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Task 77:统一 LLM、品牌检查和紫鸟 HTTP 客户端的连接复用策略。
* 三个外部客户端统一使用 HttpClientPool 共享的 java.net.http.HttpClient
* (内置 keep-alive 连接复用):LLM 与品牌检查从无连接池的
* HttpURLConnection 迁移到共享池;紫鸟从每次调用新建 RestClient 改为
* 复用共享单例。同一 HttpClient 实例即表明连接复用同一连接池。
*/
class HttpClientConnectionReuseTest {
private final List<AutoCloseable> closeables = new ArrayList<>();
@BeforeEach
@SuppressWarnings("unchecked")
void setUp() throws Exception {
Field sharedField = HttpClientPool.class.getDeclaredField("sharedHttpClient");
sharedField.setAccessible(true);
Object previous = sharedField.get(null);
closeables.add(() -> sharedField.set(null, previous));
}
@AfterEach
void tearDown() throws Exception {
for (int i = closeables.size() - 1; i >= 0; i--) {
closeables.get(i).close();
}
}
private static ClientHttpRequestFactory factoryOf(RestClient client) throws Exception {
Field field = client.getClass().getDeclaredField("clientRequestFactory");
field.setAccessible(true);
return (ClientHttpRequestFactory) field.get(client);
}
private static HttpClient clientOf(ClientHttpRequestFactory factory) throws Exception {
Field field = factory.getClass().getDeclaredField("httpClient");
field.setAccessible(true);
return (HttpClient) field.get(factory);
}
/** 反射调用私有 restClient(),模拟真实请求前获取单例。 */
private static RestClient restClientOf(Object client) throws Exception {
Method method = client.getClass().getDeclaredMethod("restClient");
method.setAccessible(true);
return (RestClient) method.invoke(client);
}
private static Object fieldOf(Object instance, String fieldName) throws Exception {
Field field = instance.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
return field.get(instance);
}
private static void assertPooled(ClientHttpRequestFactory factory) throws Exception {
assertTrue(factory instanceof JdkClientHttpRequestFactory,
"统一使用基于 java.net.http.HttpClient 的连接池工厂,实际 " + factory.getClass().getSimpleName());
assertSame(HttpClientPool.sharedHttpClient(), clientOf(factory),
"工厂复用共享 HttpClient 连接池");
}
@Test
void test_task_077_brand_normal_default_path() throws Exception {
// 默认路径:品牌检查客户端通过共享池创建单例 RestClient,
// 工厂为带 keep-alive 连接池的 JdkClientHttpRequestFactory。
BrandCheckClient client = new BrandCheckClient(new BrandCheckProperties(), null);
BrandCheckBatchResult result = client.checkTitleText(" ");
assertTrue(result.brands().isEmpty(), "空标题安全跳过,不创建无效资源");
assertPooled(factoryOf(restClientOf(client)));
}
@Test
void test_task_077_brand_normal_multiple_items() throws Exception {
// 批量场景:Coze/品牌/紫鸟三个客户端各自持有独立 RestClient,
// 但底层连接池共用同一 HttpClient 实例,不重复创建。
SimilarAsinLlmClient llm = new SimilarAsinLlmClient(new SimilarAsinProperties(), new ObjectMapper(), null);
BrandCheckClient brand = new BrandCheckClient(new BrandCheckProperties(), null);
ZiniaoClientImpl ziniao = new ZiniaoClientImpl(new ZiniaoProperties(), new ObjectMapper());
HttpClient llmClient = clientOf(factoryOf(restClientOf(llm)));
HttpClient brandClient = clientOf(factoryOf(restClientOf(brand)));
HttpClient ziniaoClient = clientOf(factoryOf(restClientOf(ziniao)));
assertSame(llmClient, brandClient, "LLM 与品牌检查共享连接池");
assertSame(brandClient, ziniaoClient, "品牌检查与紫鸟共享连接池");
assertSame(HttpClientPool.sharedHttpClient(), llmClient, "与共享单例一致");
}
@Test
void test_task_077_brand_normal_repeated_operation_is_idempotent() throws Exception {
// 幂等:同一客户端重复触发请求创建逻辑只产生一个 RestClient,
// 重复调用返回同一实例,不重复创建客户端对象。
BrandCheckClient brand = new BrandCheckClient(new BrandCheckProperties(), null);
assertSame(restClientOf(brand), restClientOf(brand), "品牌客户端复用同一 RestClient");
ZiniaoClientImpl ziniao = new ZiniaoClientImpl(new ZiniaoProperties(), new ObjectMapper());
assertSame(restClientOf(ziniao), restClientOf(ziniao), "紫鸟客户端复用同一 RestClient");
}
@Test
void test_task_077_brand_boundary_empty_input() throws Exception {
// 空输入:空品牌列表不发起任何 HTTP 请求、不创建客户端资源。
BrandCheckClient brand = new BrandCheckClient(new BrandCheckProperties(), null);
BrandCheckBatchResult result = brand.checkAll(List.of(), "Terms");
assertTrue(result.brands().isEmpty());
assertTrue(result.faildData().isEmpty());
assertNull(fieldOf(brand, "sharedRestClient"), "无请求时不创建 RestClient");
}
@Test
void test_task_077_brand_boundary_single_item() throws Exception {
// 单元素:单客户端单请求走共享池,工厂带连接池,行为与批量一致。
BrandCheckClient brand = new BrandCheckClient(new BrandCheckProperties(), null);
assertPooled(factoryOf(restClientOf(brand)));
}
@Test
void test_task_077_brand_boundary_limit_and_overflow() throws Exception {
// 上限/超限:并发创建多个客户端实例共享同一底层 HttpClient,
// 不随实例数量线性增长连接资源。
int instances = 8;
for (int i = 0; i < instances; i++) {
restClientOf(new BrandCheckClient(new BrandCheckProperties(), null));
restClientOf(new ZiniaoClientImpl(new ZiniaoProperties(), new ObjectMapper()));
}
assertSame(HttpClientPool.sharedHttpClient(), HttpClientPool.sharedHttpClient(),
"8 个客户端共享同一个 HttpClient");
}
@Test
void test_task_077_brand_invalid_input_rejected() throws Exception {
// 非法参数:非法超时统一钳制到最小正数,不崩溃、行为确定。
ClientHttpRequestFactory zero = HttpClientPool.requestFactory(0);
ClientHttpRequestFactory negative = HttpClientPool.requestFactory(-5);
assertPooled(zero);
assertPooled(negative);
assertNotSame(zero, negative, "不同 readTimeout 各自独立工厂实例");
}
@Test
void test_task_077_brand_dependency_failure_releases_resources() throws Exception {
// 依赖失败:反复创建/销毁工厂后共享连接池仍稳定复用;
// 单个工厂创建失败不影响后续复用。
for (int i = 0; i < 5; i++) {
assertPooled(HttpClientPool.requestFactory(3000));
}
assertSame(HttpClientPool.sharedHttpClient(), HttpClientPool.sharedHttpClient(),
"多次构造后共享连接池实例不变");
}
}