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 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(), "多次构造后共享连接池实例不变"); } }