diff --git a/backend-java/src/main/java/com/nanri/aiimage/metrics/ExternalCallMetricsRecorder.java b/backend-java/src/main/java/com/nanri/aiimage/metrics/ExternalCallMetricsRecorder.java new file mode 100644 index 00000000..0c75eb79 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/metrics/ExternalCallMetricsRecorder.java @@ -0,0 +1,111 @@ +package com.nanri.aiimage.metrics; + +import io.micrometer.core.instrument.DistributionSummary; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.http.client.ClientHttpRequestInterceptor; +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.stereotype.Component; + +import java.util.concurrent.TimeUnit; + +/** + * Task 78:外部调用统一指标记录器。 + * 所有外部 HTTP 客户端(Coze / 品牌检查 / 紫鸟)在构建 RestClient 时挂载 + * {@link #interceptor(String)} 拦截器,统一记录: + * + * 指标注册表通过 ObjectProvider 懒获取,未配置 Micrometer 时全部静默跳过, + * 不改变既有调用语义。 + */ +@Component +public class ExternalCallMetricsRecorder { + + private final ObjectProvider meterRegistryProvider; + + public ExternalCallMetricsRecorder(ObjectProvider meterRegistryProvider) { + this.meterRegistryProvider = meterRegistryProvider; + } + + /** 单元测试入口:直接绑定一个指标注册表。 */ + public ExternalCallMetricsRecorder(MeterRegistry registry) { + this(registry == null ? null : new ObjectProvider() { + @Override + public MeterRegistry getObject() { + return registry; + } + + @Override + public MeterRegistry getObject(Object... args) { + return registry; + } + + @Override + public MeterRegistry getIfAvailable() { + return registry; + } + + @Override + public MeterRegistry getIfUnique() { + return registry; + } + }); + } + + /** RestClient 拦截器:记录单次 HTTP 调用的耗时、payload 字节与成功/失败。 */ + public ClientHttpRequestInterceptor interceptor(String client) { + return (request, body, execution) -> { + long startedAt = System.nanoTime(); + long payloadBytes = body == null ? 0L : body.length; + try { + ClientHttpResponse response = execution.execute(request, body); + boolean success = response.getStatusCode().is2xxSuccessful(); + record(client, success ? "success" : "failure", startedAt, payloadBytes); + return response; + } catch (Exception ex) { + record(client, "failure", startedAt, payloadBytes); + throw ex; + } + }; + } + + /** 重试循环内每次进入下一次尝试前调用。 */ + public void recordRetry(String client) { + MeterRegistry registry = meterRegistry(); + if (registry != null) { + registry.counter("aiimage.external-call.retry.total", "client", client).increment(); + } + } + + private void record(String client, String result, long startedAt, long payloadBytes) { + MeterRegistry registry = meterRegistry(); + if (registry == null) { + return; + } + long durationNanos = System.nanoTime() - startedAt; + registry.counter("aiimage.external-call.total", "client", client, "result", result).increment(); + if (durationNanos >= 0L) { + Timer.builder("aiimage.external-call.duration") + .tag("client", client) + .tag("result", result) + .register(registry) + .record(durationNanos, TimeUnit.NANOSECONDS); + } + if (payloadBytes >= 0L) { + DistributionSummary.builder("aiimage.external-call.payload.bytes") + .tag("client", client) + .baseUnit("bytes") + .register(registry) + .record(payloadBytes); + } + } + + private MeterRegistry meterRegistry() { + return meterRegistryProvider == null ? null : meterRegistryProvider.getIfAvailable(); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/brand/client/BrandCheckClient.java b/backend-java/src/main/java/com/nanri/aiimage/modules/brand/client/BrandCheckClient.java index 161b2a60..1cd1457f 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/brand/client/BrandCheckClient.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/brand/client/BrandCheckClient.java @@ -4,7 +4,6 @@ import com.fasterxml.jackson.annotation.JsonAlias; import com.nanri.aiimage.config.BrandCheckProperties; import jakarta.annotation.PreDestroy; import lombok.Data; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; @@ -27,7 +26,6 @@ import java.util.concurrent.ThreadFactory; import java.util.concurrent.atomic.AtomicInteger; @Component -@RequiredArgsConstructor @Slf4j public class BrandCheckClient { @@ -37,8 +35,15 @@ public class BrandCheckClient { private static final int BRAND_CHECK_CONCURRENCY = 3; private final BrandCheckProperties properties; + private final com.nanri.aiimage.metrics.ExternalCallMetricsRecorder externalCallMetrics; private volatile RestClient sharedRestClient; + public BrandCheckClient(BrandCheckProperties properties, + com.nanri.aiimage.metrics.ExternalCallMetricsRecorder externalCallMetrics) { + this.properties = properties; + this.externalCallMetrics = externalCallMetrics; + } + private final ExecutorService checkExecutor = Executors.newFixedThreadPool( BRAND_CHECK_CONCURRENCY, namedThreadFactory("brand-check")); @@ -162,9 +167,12 @@ public class BrandCheckClient { } synchronized (this) { if (sharedRestClient == null) { - sharedRestClient = RestClient.builder() - .requestFactory(HttpClientPool.requestFactory(properties.getReadTimeoutMillis())) - .build(); + RestClient.Builder builder = RestClient.builder() + .requestFactory(HttpClientPool.requestFactory(properties.getReadTimeoutMillis())); + if (externalCallMetrics != null) { + builder.requestInterceptor(externalCallMetrics.interceptor("brand")); + } + sharedRestClient = builder.build(); } return sharedRestClient; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinCozeClient.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinCozeClient.java index 7d70c2f9..2a93d042 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinCozeClient.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinCozeClient.java @@ -6,7 +6,6 @@ import com.nanri.aiimage.config.SimilarAsinProperties; import com.nanri.aiimage.modules.coze.service.CozeCredentialPoolService; import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinResultRowDto; import com.nanri.aiimage.modules.similarasin.util.SimilarAsinLogSupport; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; @@ -27,7 +26,6 @@ import java.util.Map; import java.util.concurrent.atomic.AtomicLong; @Component -@RequiredArgsConstructor @Slf4j public class SimilarAsinCozeClient { @@ -39,6 +37,7 @@ public class SimilarAsinCozeClient { private final SimilarAsinProperties properties; private final ObjectMapper objectMapper; private final CozeCredentialPoolService cozeCredentialPoolService; + private final com.nanri.aiimage.metrics.ExternalCallMetricsRecorder externalCallMetrics; private final AtomicLong credentialCursor = new AtomicLong(); private final AtomicLong historyResponseLogCounter = new AtomicLong(); /** @@ -206,6 +205,9 @@ public class SimilarAsinCozeClient { failureMessage(ex)); } if (attemptIndex < 3) { + if (externalCallMetrics != null) { + externalCallMetrics.recordRetry("coze"); + } sleepBeforeRetry(attemptIndex); } } @@ -751,9 +753,12 @@ public class SimilarAsinCozeClient { } synchronized (this) { if (sharedRestClient == null) { - sharedRestClient = RestClient.builder() - .requestFactory(HttpClientPool.requestFactory(properties.getCozeReadTimeoutMillis())) - .build(); + RestClient.Builder builder = RestClient.builder() + .requestFactory(HttpClientPool.requestFactory(properties.getCozeReadTimeoutMillis())); + if (externalCallMetrics != null) { + builder.requestInterceptor(externalCallMetrics.interceptor("coze")); + } + sharedRestClient = builder.build(); } return sharedRestClient; } @@ -872,6 +877,16 @@ public class SimilarAsinCozeClient { || !normalize(row.getPuzzleImg2()).isBlank(); } + public SimilarAsinCozeClient(SimilarAsinProperties properties, + ObjectMapper objectMapper, + CozeCredentialPoolService cozeCredentialPoolService, + com.nanri.aiimage.metrics.ExternalCallMetricsRecorder externalCallMetrics) { + this.properties = properties; + this.objectMapper = objectMapper; + this.cozeCredentialPoolService = cozeCredentialPoolService; + this.externalCallMetrics = externalCallMetrics; + } + private void ensureSuccess(JsonNode root) { if (root.path("code").asInt(-1) != 0) { throw new IllegalStateException(root.path("msg").asText("Coze response code is not 0")); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/client/ZiniaoClientImpl.java b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/client/ZiniaoClientImpl.java index 581c9883..5f587d92 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/client/ZiniaoClientImpl.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/client/ZiniaoClientImpl.java @@ -6,7 +6,6 @@ import com.nanri.aiimage.common.exception.BusinessException; import com.nanri.aiimage.config.ZiniaoProperties; import com.nanri.aiimage.modules.ziniao.model.cache.ZiniaoShopCacheDto; import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoStaffItemVo; -import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatusCode; import org.springframework.http.MediaType; import org.springframework.http.client.SimpleClientHttpRequestFactory; @@ -21,11 +20,23 @@ import java.util.Map; import java.util.Objects; @Component -@RequiredArgsConstructor public class ZiniaoClientImpl implements ZiniaoClient { private final ZiniaoProperties ziniaoProperties; private final ObjectMapper objectMapper; + private final com.nanri.aiimage.metrics.ExternalCallMetricsRecorder externalCallMetrics; + + public ZiniaoClientImpl(ZiniaoProperties ziniaoProperties, ObjectMapper objectMapper) { + this(ziniaoProperties, objectMapper, null); + } + + public ZiniaoClientImpl(ZiniaoProperties ziniaoProperties, + ObjectMapper objectMapper, + com.nanri.aiimage.metrics.ExternalCallMetricsRecorder externalCallMetrics) { + this.ziniaoProperties = ziniaoProperties; + this.objectMapper = objectMapper; + this.externalCallMetrics = externalCallMetrics; + } /** Task 77:单例 RestClient(共享连接池),避免每次调用新建短命客户端。 */ private volatile RestClient sharedRestClient; @@ -257,9 +268,12 @@ public class ZiniaoClientImpl implements ZiniaoClient { } synchronized (this) { if (sharedRestClient == null) { - sharedRestClient = RestClient.builder() - .requestFactory(HttpClientPool.requestFactory(ziniaoProperties.getReadTimeoutSeconds() * 1000)) - .build(); + RestClient.Builder builder = RestClient.builder() + .requestFactory(HttpClientPool.requestFactory(ziniaoProperties.getReadTimeoutSeconds() * 1000)); + if (externalCallMetrics != null) { + builder.requestInterceptor(externalCallMetrics.interceptor("ziniao")); + } + sharedRestClient = builder.build(); } return sharedRestClient; } diff --git a/backend-java/src/test/java/com/nanri/aiimage/config/HttpClientConnectionReuseTest.java b/backend-java/src/test/java/com/nanri/aiimage/config/HttpClientConnectionReuseTest.java index a4db64b2..5bf4a3fd 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/config/HttpClientConnectionReuseTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/config/HttpClientConnectionReuseTest.java @@ -87,7 +87,7 @@ class HttpClientConnectionReuseTest { void test_task_077_brand_normal_default_path() throws Exception { // 默认路径:品牌检查客户端通过共享池创建单例 RestClient, // 工厂为带 keep-alive 连接池的 JdkClientHttpRequestFactory。 - BrandCheckClient client = new BrandCheckClient(new BrandCheckProperties()); + BrandCheckClient client = new BrandCheckClient(new BrandCheckProperties(), null); BrandCheckBatchResult result = client.checkTitleText(" "); assertTrue(result.brands().isEmpty(), "空标题安全跳过,不创建无效资源"); @@ -98,8 +98,8 @@ class HttpClientConnectionReuseTest { void test_task_077_brand_normal_multiple_items() throws Exception { // 批量场景:Coze/品牌/紫鸟三个客户端各自持有独立 RestClient, // 但底层连接池共用同一 HttpClient 实例,不重复创建。 - SimilarAsinCozeClient coze = new SimilarAsinCozeClient(new SimilarAsinProperties(), new ObjectMapper(), null); - BrandCheckClient brand = new BrandCheckClient(new BrandCheckProperties()); + SimilarAsinCozeClient coze = new SimilarAsinCozeClient(new SimilarAsinProperties(), new ObjectMapper(), null, null); + BrandCheckClient brand = new BrandCheckClient(new BrandCheckProperties(), null); ZiniaoClientImpl ziniao = new ZiniaoClientImpl(new ZiniaoProperties(), new ObjectMapper()); HttpClient cozeClient = clientOf(factoryOf(restClientOf(coze))); @@ -115,7 +115,7 @@ class HttpClientConnectionReuseTest { void test_task_077_brand_normal_repeated_operation_is_idempotent() throws Exception { // 幂等:同一客户端重复触发请求创建逻辑只产生一个 RestClient, // 重复调用返回同一实例,不重复创建客户端对象。 - BrandCheckClient brand = new BrandCheckClient(new BrandCheckProperties()); + BrandCheckClient brand = new BrandCheckClient(new BrandCheckProperties(), null); assertSame(restClientOf(brand), restClientOf(brand), "品牌客户端复用同一 RestClient"); ZiniaoClientImpl ziniao = new ZiniaoClientImpl(new ZiniaoProperties(), new ObjectMapper()); @@ -125,7 +125,7 @@ class HttpClientConnectionReuseTest { @Test void test_task_077_brand_boundary_empty_input() throws Exception { // 空输入:空品牌列表不发起任何 HTTP 请求、不创建客户端资源。 - BrandCheckClient brand = new BrandCheckClient(new BrandCheckProperties()); + BrandCheckClient brand = new BrandCheckClient(new BrandCheckProperties(), null); BrandCheckBatchResult result = brand.checkAll(List.of(), "Terms"); assertTrue(result.brands().isEmpty()); @@ -136,7 +136,7 @@ class HttpClientConnectionReuseTest { @Test void test_task_077_brand_boundary_single_item() throws Exception { // 单元素:单客户端单请求走共享池,工厂带连接池,行为与批量一致。 - BrandCheckClient brand = new BrandCheckClient(new BrandCheckProperties()); + BrandCheckClient brand = new BrandCheckClient(new BrandCheckProperties(), null); assertPooled(factoryOf(restClientOf(brand))); } @@ -146,7 +146,7 @@ class HttpClientConnectionReuseTest { // 不随实例数量线性增长连接资源。 int instances = 8; for (int i = 0; i < instances; i++) { - restClientOf(new BrandCheckClient(new BrandCheckProperties())); + restClientOf(new BrandCheckClient(new BrandCheckProperties(), null)); restClientOf(new ZiniaoClientImpl(new ZiniaoProperties(), new ObjectMapper())); } assertSame(HttpClientPool.sharedHttpClient(), HttpClientPool.sharedHttpClient(), diff --git a/backend-java/src/test/java/com/nanri/aiimage/metrics/ExternalCallMetricsRecorderTest.java b/backend-java/src/test/java/com/nanri/aiimage/metrics/ExternalCallMetricsRecorderTest.java new file mode 100644 index 00000000..eff508e2 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/metrics/ExternalCallMetricsRecorderTest.java @@ -0,0 +1,388 @@ +package com.nanri.aiimage.metrics; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nanri.aiimage.config.BrandCheckProperties; +import com.nanri.aiimage.config.SimilarAsinProperties; +import com.nanri.aiimage.config.ZiniaoProperties; +import com.nanri.aiimage.metrics.ExternalCallMetricsRecorder; +import com.nanri.aiimage.modules.brand.client.BrandCheckClient; +import com.nanri.aiimage.modules.coze.service.CozeCredentialPoolService; +import com.nanri.aiimage.modules.similarasin.client.SimilarAsinCozeClient; +import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinResultRowDto; +import com.nanri.aiimage.modules.ziniao.client.ZiniaoClientImpl; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.DistributionSummary; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Task 78:为所有外部调用(Coze / 品牌检查 / 紫鸟)统一增加耗时、重试、 + * 失败率和 payload 字节指标。全部用例通过本地 HttpServer 发起真实 HTTP 调用, + * 在 SimpleMeterRegistry 上断言指标语义(无 mock 外部客户端)。 + */ +class ExternalCallMetricsRecorderTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + private final SimpleMeterRegistry registry = new SimpleMeterRegistry(); + private HttpServer server; + private int port; + private final ExecutorService serverExecutor = Executors.newCachedThreadPool(); + private final AtomicInteger cozeSubmitCount = new AtomicInteger(); + private final AtomicInteger ziniaoCount = new AtomicInteger(); + private final AtomicInteger brandCount = new AtomicInteger(); + private final AtomicBoolean cozeFailNext = new AtomicBoolean(); + + /** 可复用 Coze 凭据池:返回一个固定凭据,避免真实 HTTP 调用被凭据检查拦截。 */ + private static CozeCredentialPoolService credentialPool() { + CozeCredentialPoolService pool = mock(CozeCredentialPoolService.class); + when(pool.listEnabled(anyString())).thenReturn(List.of( + new CozeCredentialPoolService.CozeCredential("test", "wf-1", "token", Integer.MAX_VALUE))); + return pool; + } + + @BeforeEach + void setUp() throws IOException { + server = HttpServer.create(new InetSocketAddress(0), 0); + server.setExecutor(serverExecutor); + server.createContext("/brand_check", this::handleBrandCheck); + server.createContext("/app/builtin/company", this::handleZiniaoCompany); + server.createContext("/v1/workflow/run", this::handleCozeSubmit); + server.start(); + port = server.getAddress().getPort(); + } + + @AfterEach + void tearDown() { + server.stop(0); + serverExecutor.shutdownNow(); + } + + private void handleBrandCheck(HttpExchange exchange) throws IOException { + brandCount.incrementAndGet(); + sendJson(exchange, 200, + "{\"faild_data\":[],\"query_faild_data\":[]}".getBytes(StandardCharsets.UTF_8)); + } + + private void handleZiniaoCompany(HttpExchange exchange) throws IOException { + ziniaoCount.incrementAndGet(); + sendJson(exchange, 200, + "{\"code\":\"0\",\"data\":{\"companyId\":1001}}".getBytes(StandardCharsets.UTF_8)); + } + + private void handleCozeSubmit(HttpExchange exchange) throws IOException { + int count = cozeSubmitCount.incrementAndGet(); + if (cozeFailNext.getAndSet(false)) { + exchange.sendResponseHeaders(500, 0); + exchange.close(); + return; + } + String executeId = "exec-" + count; + String payload = "{\"data\":[{\"asin\":\"B0TEST78\",\"country\":\"US\",\"result\":\"ok\",\"conclusion\":\"ok\"}]}"; + String response = "{\"code\":0,\"data\":{\"execute_id\":\"" + executeId + + "\",\"status\":\"Success\",\"data\":" + payload + "}}"; + sendJson(exchange, 200, response.getBytes(StandardCharsets.UTF_8)); + } + + private String readBody(HttpExchange exchange) throws IOException { + return new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + } + + private void sendJson(HttpExchange exchange, int status, byte[] body) throws IOException { + exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8"); + exchange.sendResponseHeaders(status, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + } + + private void awaitMetric(String name, String... tags) throws InterruptedException { + for (int i = 0; i < 500; i++) { + if (name.endsWith(".duration")) { + if (findTimer(name, tags) != null) return; + } else if (name.endsWith(".bytes")) { + if (findSummary(name, tags) != null) return; + } else { + if (findCounter(name, tags) != null && findCounter(name, tags).count() > 0) return; + } + Thread.sleep(10); + } + throw new AssertionError("metric not recorded: " + name); + } + + private Timer findTimer(String name, String... tags) { + for (Timer timer : registry.find(name).timers()) { + if (matchesTags(timer.getId().getTags(), tags)) return timer; + } + return null; + } + + private DistributionSummary findSummary(String name, String... tags) { + for (DistributionSummary summary : registry.find(name).summaries()) { + if (matchesTags(summary.getId().getTags(), tags)) return summary; + } + return null; + } + + private Counter findCounter(String name, String... tags) { + for (Counter counter : registry.find(name).counters()) { + if (matchesTags(counter.getId().getTags(), tags)) return counter; + } + return null; + } + + private double counterCount(String name, String... tags) { + Counter counter = findCounter(name, tags); + return counter == null ? 0.0 : counter.count(); + } + + private boolean matchesTags(Iterable tags, String... expected) { + Map map = new java.util.HashMap<>(); + tags.forEach(tag -> map.put(tag.getKey(), tag.getValue())); + for (int i = 0; i + 1 < expected.length; i += 2) { + if (!expected[i + 1].equals(map.get(expected[i]))) return false; + } + return true; + } + + // ---- 1. 正常默认路径:Coze 批量检查走本地服务,耗时/payload 字节全部记录 ---- + + @Test + void test_task_078_payload_metrics_normal_default_path() throws Exception { + SimilarAsinProperties props = cozeProps(); + SimilarAsinCozeClient client = + new SimilarAsinCozeClient(props, objectMapper, credentialPool(), new ExternalCallMetricsRecorder(registry)); + SimilarAsinResultRowDto row = new SimilarAsinResultRowDto(); + row.setAsin("B0TEST78"); + row.setTitle("Test"); + row.setSku("SKU-1"); + row.setCountry("US"); + + List result = client.inspect(List.of(row), "", "test-key"); + + assertEquals(1, result.size(), "默认成功路径必须返回完整结果"); + assertEquals("ok", result.getFirst().getConclusion(), "主输出必须解析到 Coze 结果"); + assertNotNull(findTimer("aiimage.external-call.duration", "client", "coze"), + "必须记录 Coze 调用耗时"); + assertTrue(findSummary("aiimage.external-call.payload.bytes", "client", "coze").totalAmount() > 0, + "必须记录 payload 字节指标"); + } + + // ---- 2. 批量:多行顺序稳定、无丢失 ---- + + @Test + void test_task_078_payload_metrics_normal_multiple_items() throws Exception { + SimilarAsinProperties props = cozeProps(); + SimilarAsinCozeClient client = + new SimilarAsinCozeClient(props, objectMapper, credentialPool(), new ExternalCallMetricsRecorder(registry)); + List rows = new ArrayList<>(); + for (int i = 0; i < 3; i++) { + SimilarAsinResultRowDto row = new SimilarAsinResultRowDto(); + row.setAsin("B0BATCH" + i); + row.setCountry("US"); + row.setTitle("Batch " + i); + row.setSku("SKU-" + i); + rows.add(row); + } + + List result = client.inspect(rows, "", "test-key"); + + assertEquals(3, result.size(), "批量结果不丢失"); + assertEquals("B0BATCH0", result.get(0).getAsin(), "顺序稳定"); + assertEquals("B0BATCH2", result.get(2).getAsin(), "顺序稳定"); + awaitMetric("aiimage.external-call.duration", "client", "coze"); + + // 全部外部客户端(Coze / 品牌检查 / 紫鸟)在同一次批量中各自记录指标 + BrandCheckClient brand = new BrandCheckClient(brandProps(), new ExternalCallMetricsRecorder(registry)); + brand.checkAll(List.of("Nintendo", "LEGO", "Sony"), "Terms"); + ZiniaoClientImpl ziniao = new ZiniaoClientImpl(ziniaoProps(), objectMapper, new ExternalCallMetricsRecorder(registry)); + assertEquals(1001L, ziniao.getCompanyIdByApiKey("test-api-key")); + + awaitMetric("aiimage.external-call.duration", "client", "brand"); + awaitMetric("aiimage.external-call.duration", "client", "ziniao"); + assertEquals(3.0, counterCount("aiimage.external-call.total", + "client", "brand", "result", "success"), "品牌批量 3 次成功全部记录"); + assertEquals(1.0, counterCount("aiimage.external-call.total", + "client", "ziniao", "result", "success"), "紫鸟单次成功记录"); + } + + // ---- 3. 重复操作幂等:指标按请求精确累加,不重复 ---- + + @Test + void test_task_078_payload_metrics_normal_repeated_operation_is_idempotent() throws Exception { + BrandCheckProperties props = brandProps(); + BrandCheckClient client = new BrandCheckClient(props, new ExternalCallMetricsRecorder(registry)); + + client.check("Nintendo"); + client.check("Nintendo"); + + assertEquals(2, brandCount.get(), "两次请求真实发出"); + assertEquals(2.0, counterCount("aiimage.external-call.total", + "client", "brand", "result", "success"), "指标按请求次数精确累加,不重复"); + assertNotNull(findTimer("aiimage.external-call.duration", "client", "brand"), + "重复调用均记录耗时"); + } + + // ---- 4. 空输入:无请求、无资源创建、无指标 ---- + + @Test + void test_task_078_payload_metrics_boundary_empty_input() throws Exception { + BrandCheckProperties props = brandProps(); + BrandCheckClient client = new BrandCheckClient(props, new ExternalCallMetricsRecorder(registry)); + + BrandCheckClient.BrandCheckBatchResult result = client.checkAll(List.of(), "Terms"); + + assertEquals(0, brandCount.get(), "空输入不发起外部调用"); + assertTrue(result.brands().isEmpty()); + assertEquals(0.0, counterCount("aiimage.external-call.total", + "client", "brand", "result", "success"), "无调用无指标"); + } + + // ---- 5. 单元素:走独立路径,指标正确 ---- + + @Test + void test_task_078_payload_metrics_boundary_single_item() throws Exception { + BrandCheckProperties props = brandProps(); + BrandCheckClient client = new BrandCheckClient(props, new ExternalCallMetricsRecorder(registry)); + + BrandCheckClient.BrandCheckResponse response = client.check("LEGO"); + + assertNotNull(response); + awaitMetric("aiimage.external-call.duration", "client", "brand"); + assertEquals(1.0, counterCount("aiimage.external-call.total", + "client", "brand", "result", "success")); + } + + // ---- 6. 上限/超限:固定线程池并发 20 个请求,指标按请求精确累加 ---- + + @Test + void test_task_078_payload_metrics_boundary_limit_and_overflow() throws Exception { + SimilarAsinProperties props = cozeProps(); + props.setCozeReadTimeoutMillis(5000); + ExternalCallMetricsRecorder recorder = new ExternalCallMetricsRecorder(registry); + ExecutorService pool = Executors.newFixedThreadPool(4); + try { + for (int i = 0; i < 20; i++) { + int index = i; + pool.submit(() -> { + SimilarAsinCozeClient client = new SimilarAsinCozeClient(props, objectMapper, credentialPool(), recorder); + SimilarAsinResultRowDto row = new SimilarAsinResultRowDto(); + row.setAsin("B0LIMIT" + index); + row.setCountry("US"); + row.setTitle("Limit " + index); + row.setSku("SKU-" + index); + try { + client.inspect(List.of(row), "", "test-key"); + } catch (Exception ignored) { + // 并发下结果失败也视为已处理 + } + }); + } + } finally { + pool.shutdown(); + } + + awaitMetric("aiimage.external-call.total", "client", "coze", "result", "success"); + for (int i = 0; i < 2000 && cozeSubmitCount.get() < 20; i++) { + Thread.sleep(10); + } + assertEquals(20, cozeSubmitCount.get(), "并发 20 请求全部真实发出"); + assertEquals(20.0, counterCount("aiimage.external-call.total", + "client", "coze", "result", "success"), "20 次成功全部记录,无重复"); + } + + // ---- 7. 非法参数:空列表拒绝,不发起请求,无指标 ---- + + @Test + void test_task_078_payload_metrics_invalid_input_rejected() throws Exception { + BrandCheckProperties props = brandProps(); + BrandCheckClient client = new BrandCheckClient(props, new ExternalCallMetricsRecorder(registry)); + + BrandCheckClient.BrandCheckBatchResult result = client.checkAll(null, "Terms"); + + assertTrue(result.brands().isEmpty(), "null 列表安全跳过"); + assertEquals(0, brandCount.get(), "非法输入不发起外部调用"); + assertEquals(0.0, counterCount("aiimage.external-call.total", + "client", "brand", "result", "success"), "非法输入无指标"); + } + + // ---- 8. 依赖失败:先 500 后成功,错误可恢复;重试与失败率指标被记录 ---- + + @Test + void test_task_078_payload_metrics_dependency_failure_releases_resources() throws Exception { + ExternalCallMetricsRecorder recorder = new ExternalCallMetricsRecorder(registry); + SimilarAsinProperties props = cozeProps(); + props.setCozeReadTimeoutMillis(5000); + SimilarAsinCozeClient client = + new SimilarAsinCozeClient(props, objectMapper, credentialPool(), recorder); + List rows = new ArrayList<>(); + SimilarAsinResultRowDto row = new SimilarAsinResultRowDto(); + row.setAsin("B0FAIL78"); + row.setCountry("US"); + row.setTitle("Fail"); + row.setSku("SKU-FAIL"); + rows.add(row); + + // 第一次调用走 500 失败路径,第二次调用恢复成功:错误可恢复 + cozeFailNext.set(true); + client.inspect(rows, "", "test-key"); + client.inspect(rows, "", "test-key"); + + awaitMetric("aiimage.external-call.total", "client", "coze", "result", "failure"); + assertNotNull(findTimer("aiimage.external-call.duration", "client", "coze"), + "失败调用同样记录耗时"); + awaitMetric("aiimage.external-call.total", "client", "coze", "result", "success"); + assertNotNull(findTimer("aiimage.external-call.duration", "client", "coze"), + "恢复后的成功调用也记录耗时"); + assertEquals(1.0, counterCount("aiimage.external-call.total", + "client", "coze", "result", "failure"), "失败率指标精确记录一次失败"); + assertTrue(counterCount("aiimage.external-call.retry.total", "client", "coze") >= 1.0, + "客户端重试循环记录重试次数指标"); + } + + private SimilarAsinProperties cozeProps() { + SimilarAsinProperties props = new SimilarAsinProperties(); + props.setCozeBaseUrl("http://127.0.0.1:" + port); + props.setCozeWorkflowPath("/v1/workflow/run"); + props.setCozeWorkflowHistoryPath("/v1/workflows/{workflow_id}/run_histories/{execute_id}"); + return props; + } + + private BrandCheckProperties brandProps() { + BrandCheckProperties props = new BrandCheckProperties(); + props.setBaseUrl("http://127.0.0.1:" + port); + props.setPath("/brand_check"); + props.setReadTimeoutMillis(5000); + return props; + } + + private ZiniaoProperties ziniaoProps() { + ZiniaoProperties props = new ZiniaoProperties(); + props.setBaseUrl("http://127.0.0.1:" + port); + props.setReadTimeoutSeconds(5); + return props; + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentCozeClientTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentCozeClientTest.java index 5d5f2050..b6059cf1 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentCozeClientTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentCozeClientTest.java @@ -22,7 +22,7 @@ class AppearancePatentCozeClientTest { new AppearancePatentProperties(), new ObjectMapper(), null, - new BrandCheckClient(new BrandCheckProperties()) + new BrandCheckClient(new BrandCheckProperties(), null) ); @Test diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/brand/client/BrandCheckClientTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/brand/client/BrandCheckClientTest.java index 3a4f1e42..abd2f948 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/brand/client/BrandCheckClientTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/brand/client/BrandCheckClientTest.java @@ -11,7 +11,7 @@ class BrandCheckClientTest { @Test void splitTitleTextSupportsCommonSeparatorsAndQuotes() { - BrandCheckClient client = new BrandCheckClient(new BrandCheckProperties()); + BrandCheckClient client = new BrandCheckClient(new BrandCheckProperties(), null); List brands = client.splitTitleText("'阿凡达,任天堂'; Disney、LEGO\nSony"); @@ -20,7 +20,7 @@ class BrandCheckClientTest { @Test void splitTitleTextDeduplicatesBlankValues() { - BrandCheckClient client = new BrandCheckClient(new BrandCheckProperties()); + BrandCheckClient client = new BrandCheckClient(new BrandCheckProperties(), null); List brands = client.splitTitleText(" 任天堂, ,任天堂,Sony "); diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinCozeClientLoggingTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinCozeClientLoggingTest.java index 6e7a916d..657af0ef 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinCozeClientLoggingTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinCozeClientLoggingTest.java @@ -127,7 +127,7 @@ class SimilarAsinCozeClientLoggingTest { row.setTitle("T".repeat(5000)); row.setSku("SKU-SECRET"); rows.add(row); - SimilarAsinCozeClient client = new SimilarAsinCozeClient(new SimilarAsinProperties(), objectMapper, null); + SimilarAsinCozeClient client = new SimilarAsinCozeClient(new SimilarAsinProperties(), objectMapper, null, null); Method maskMethod = SimilarAsinCozeClient.class.getDeclaredMethod( "maskCozeRequestBody", Map.class); maskMethod.setAccessible(true); diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinCozeClientTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinCozeClientTest.java index 6442f045..a1216244 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinCozeClientTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinCozeClientTest.java @@ -38,7 +38,7 @@ class SimilarAsinCozeClientTest { ] """, new TypeReference<>() { }); - SimilarAsinCozeClient client = new SimilarAsinCozeClient(new SimilarAsinProperties(), objectMapper, null); + SimilarAsinCozeClient client = new SimilarAsinCozeClient(new SimilarAsinProperties(), objectMapper, null, null); Method method = SimilarAsinCozeClient.class.getDeclaredMethod( "buildParameters", List.class, String.class, String.class, boolean.class); @@ -71,7 +71,7 @@ class SimilarAsinCozeClientTest { row.setPrice("8.50"); row.setTitle("Legacy title"); row.setSku("SKU-LEGACY"); - SimilarAsinCozeClient client = new SimilarAsinCozeClient(new SimilarAsinProperties(), objectMapper, null); + SimilarAsinCozeClient client = new SimilarAsinCozeClient(new SimilarAsinProperties(), objectMapper, null, null); Method method = SimilarAsinCozeClient.class.getDeclaredMethod( "buildParameters", List.class, String.class, String.class, boolean.class); @@ -102,7 +102,7 @@ class SimilarAsinCozeClientTest { ] """, new TypeReference<>() { }); - SimilarAsinCozeClient client = new SimilarAsinCozeClient(new SimilarAsinProperties(), objectMapper, null); + SimilarAsinCozeClient client = new SimilarAsinCozeClient(new SimilarAsinProperties(), objectMapper, null, null); Method method = SimilarAsinCozeClient.class.getDeclaredMethod( "buildParameters", List.class, String.class, String.class, boolean.class); @@ -128,7 +128,7 @@ class SimilarAsinCozeClientTest { row.setUrl("https://m.media-amazon.com/images/I/main.jpg"); row.setUrls(List.of("https://cbu01.alicdn.com/img/ibank/fallback.jpg")); row.setPrice(""); - SimilarAsinCozeClient client = new SimilarAsinCozeClient(new SimilarAsinProperties(), objectMapper, null); + SimilarAsinCozeClient client = new SimilarAsinCozeClient(new SimilarAsinProperties(), objectMapper, null, null); Method method = SimilarAsinCozeClient.class.getDeclaredMethod( "buildParameters", List.class, String.class, String.class, boolean.class); @@ -162,7 +162,7 @@ class SimilarAsinCozeClientTest { ] """, new TypeReference<>() { }); - SimilarAsinCozeClient client = new SimilarAsinCozeClient(new SimilarAsinProperties(), objectMapper, null); + SimilarAsinCozeClient client = new SimilarAsinCozeClient(new SimilarAsinProperties(), objectMapper, null, null); Method method = SimilarAsinCozeClient.class.getDeclaredMethod( "buildParameters", List.class, String.class, String.class, boolean.class); @@ -188,7 +188,7 @@ class SimilarAsinCozeClientTest { SimilarAsinResultRowDto row = new SimilarAsinResultRowDto(); row.setAsin("B0CATEGORY1"); row.setTitle("Category test"); - SimilarAsinCozeClient client = new SimilarAsinCozeClient(new SimilarAsinProperties(), objectMapper, null); + SimilarAsinCozeClient client = new SimilarAsinCozeClient(new SimilarAsinProperties(), objectMapper, null, null); Method method = SimilarAsinCozeClient.class.getDeclaredMethod( "buildParameters", List.class, String.class, String.class, boolean.class, boolean.class); @@ -201,7 +201,7 @@ class SimilarAsinCozeClientTest { @Test void imageOnlyWorkflowOutputIsExtractedAndMergedByAsin() throws Exception { - SimilarAsinCozeClient client = new SimilarAsinCozeClient(new SimilarAsinProperties(), objectMapper, null); + SimilarAsinCozeClient client = new SimilarAsinCozeClient(new SimilarAsinProperties(), objectMapper, null, null); String imageData = """ {"data":[{ "asin":"B0BQNHDP2F",