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)} 拦截器,统一记录:
+ *
+ * - 耗时:{@code aiimage.external-call.duration}(client + result 标签);
+ * - 失败率:{@code aiimage.external-call.total}(result=success/failure,2xx 之外计失败);
+ * - payload 字节:{@code aiimage.external-call.payload.bytes}(请求体字节数);
+ * - 重试次数:{@code aiimage.external-call.retry.total}(客户端重试循环内调用)。
+ *
+ * 指标注册表通过 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",