diff --git a/backend-java/src/main/java/com/nanri/aiimage/common/retry/LlmRetryBackoff.java b/backend-java/src/main/java/com/nanri/aiimage/common/retry/LlmRetryBackoff.java
new file mode 100644
index 00000000..5fc9da02
--- /dev/null
+++ b/backend-java/src/main/java/com/nanri/aiimage/common/retry/LlmRetryBackoff.java
@@ -0,0 +1,35 @@
+package com.nanri.aiimage.common.retry;
+
+import java.util.concurrent.ThreadLocalRandom;
+
+/**
+ * LLM 重试等待策略:指数退避 + 抖动。
+ *
+ *
背景(2026-09-13 生产实测):上游 ai.t8star.org 存在持续数十秒的劣化窗口,
+ * 窗口内所有请求都不应答;原来的 1.5s/3s 密集重试会整批落在窗口内,三连失败后
+ * 行降级(外观识别异常)。退避拉到 2s/10s 并带 ±30% 抖动,可覆盖更长的窗口,
+ * 同时避免同批多行在同一时刻齐发重试形成尖峰。
+ */
+public final class LlmRetryBackoff {
+
+ private static final long[] BASE_DELAYS_MILLIS = {2_000L, 10_000L};
+ private static final double JITTER_RATIO = 0.3d;
+ private static final long MIN_DELAY_MILLIS = 200L;
+
+ private LlmRetryBackoff() {
+ }
+
+ /** 第 attemptIndex 次失败后的等待毫秒数(attemptIndex 从 1 开始);超出档位取最后一档。 */
+ public static long delayMillis(int attemptIndex) {
+ return delayMillis(attemptIndex, ThreadLocalRandom.current().nextDouble());
+ }
+
+ /** 固定抖动入口(测试用):random 取值 [0,1),0.5 表示无抖动。 */
+ static long delayMillis(int attemptIndex, double random) {
+ int index = Math.min(Math.max(1, attemptIndex), BASE_DELAYS_MILLIS.length) - 1;
+ long base = BASE_DELAYS_MILLIS[index];
+ double bounded = Math.min(Math.max(random, 0d), 1d);
+ double factor = 1d + (bounded * 2d - 1d) * JITTER_RATIO;
+ return Math.max(MIN_DELAY_MILLIS, Math.round(base * factor));
+ }
+}
diff --git a/backend-java/src/main/java/com/nanri/aiimage/config/AppearancePatentProperties.java b/backend-java/src/main/java/com/nanri/aiimage/config/AppearancePatentProperties.java
index ead29a4c..cbd56877 100644
--- a/backend-java/src/main/java/com/nanri/aiimage/config/AppearancePatentProperties.java
+++ b/backend-java/src/main/java/com/nanri/aiimage/config/AppearancePatentProperties.java
@@ -22,6 +22,12 @@ public class AppearancePatentProperties {
private int llmMaxTokens = 64000;
private int llmConnectTimeoutMillis = 10000;
private int llmReadTimeoutMillis = 180000;
+ /**
+ * 首次尝试读超时(毫秒):上游偶发单请求不应答(挂满超时才失败),
+ * 首查用更短预算快速失败并重试;生产实测成功调用 p99≈34s、超 60s 仅 0.02%,
+ * 故默认 60s 既覆盖正常慢响应又能把单次挂死代价从 90s 降到 60s。
+ */
+ private int llmFirstAttemptReadTimeoutMillis = 60000;
private int llmBatchSize = 10;
/**
* 批内行级并发数,默认等于批量大小
diff --git a/backend-java/src/main/java/com/nanri/aiimage/config/SimilarAsinProperties.java b/backend-java/src/main/java/com/nanri/aiimage/config/SimilarAsinProperties.java
index 6b255123..be573e7c 100644
--- a/backend-java/src/main/java/com/nanri/aiimage/config/SimilarAsinProperties.java
+++ b/backend-java/src/main/java/com/nanri/aiimage/config/SimilarAsinProperties.java
@@ -161,6 +161,11 @@ public class SimilarAsinProperties {
private int llmMaxTokens = 64000;
private int llmConnectTimeoutMillis = 10000;
private int llmReadTimeoutMillis = 180000;
+ /**
+ * 首次尝试读超时(毫秒):上游偶发单请求不应答,首查用更短预算快速失败后重试;
+ * 生产实测成功调用 p99≈34s、超 60s 仅 0.02%,默认 60s 兼顾慢响应与快速失败。
+ */
+ private int llmFirstAttemptReadTimeoutMillis = 60000;
private int llmRetryTimes = 3;
/** 批内行级并发上限:每行最多 2 次图片对比 + 1 次合规 + 3 次类目匹配。 */
diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentLlmClient.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentLlmClient.java
index d1838be7..5aab26d1 100644
--- a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentLlmClient.java
+++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentLlmClient.java
@@ -2,6 +2,7 @@ package com.nanri.aiimage.modules.appearancepatent.client;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
+import com.nanri.aiimage.common.retry.LlmRetryBackoff;
import com.nanri.aiimage.config.AppearancePatentProperties;
import com.nanri.aiimage.config.HttpClientPool;
import com.nanri.aiimage.metrics.ExternalCallMetricsRecorder;
@@ -103,6 +104,8 @@ public class AppearancePatentLlmClient {
private final UserSecretUsageService userSecretUsageService;
private volatile RestClient sharedRestClient;
+ /** 首次尝试专用客户端(更短读超时,快速失败);配置未生效时与 sharedRestClient 相同。 */
+ private volatile RestClient firstAttemptRestClient;
private volatile ExecutorService rowExecutor;
/**
@@ -192,15 +195,20 @@ public class AppearancePatentLlmClient {
String appearanceStatus = "";
String appearanceReason = "";
boolean appearanceFailed = false;
- try {
- AppearanceJudgement judgement = invokeAppearance(row, prompt, apiKey);
- appearanceStatus = judgement.status();
- appearanceReason = judgement.reason();
- } catch (Exception ex) {
- appearanceFailed = true;
- appearanceReason = firstNonBlank(ex.getMessage(), "外观识别失败");
- log.warn("[appearance-patent] appearance llm failed asin={} title={} url={} err={}",
- row.getAsin(), abbreviate(row.getTitle(), 120), abbreviate(row.getUrl(), 120), failureMessage(ex));
+ if (titleFailed) {
+ // 标题识别失败时本行必走回退分支(外观结果会被丢弃),再发外观请求只会在劣化中的上游白等一轮重试
+ log.warn("[appearance-patent] 标题识别失败,跳过外观识别(本行同样走回退)asin={}", row.getAsin());
+ } else {
+ try {
+ AppearanceJudgement judgement = invokeAppearance(row, prompt, apiKey);
+ appearanceStatus = judgement.status();
+ appearanceReason = judgement.reason();
+ } catch (Exception ex) {
+ appearanceFailed = true;
+ appearanceReason = firstNonBlank(ex.getMessage(), "外观识别失败");
+ log.warn("[appearance-patent] appearance llm failed asin={} title={} url={} err={}",
+ row.getAsin(), abbreviate(row.getTitle(), 120), abbreviate(row.getUrl(), 120), failureMessage(ex));
+ }
}
if (titleFailed || appearanceFailed) {
String titleReason = titleFailed ? titleError : firstNonBlank(rawTitle, MISSING_ROW_DATA);
@@ -274,15 +282,16 @@ public class AppearancePatentLlmClient {
Exception lastFailure = null;
for (int attempt = 1; attempt <= attempts; attempt++) {
try {
- return invokeChatOnce(model, system, userText, images, apiKey, responseFormat);
+ return invokeChatOnce(model, system, userText, images, apiKey, responseFormat, attempt);
} catch (Exception ex) {
lastFailure = ex;
if (attempt >= attempts) {
break;
}
- log.warn("[appearance-patent] llm retryable failure attempt={} model={} err={}",
- attempt, model, failureMessage(ex));
- sleepBeforeRetry(attempt);
+ long retryDelayMillis = LlmRetryBackoff.delayMillis(attempt);
+ log.warn("[appearance-patent] llm retryable failure attempt={} model={} 下次重试等待={}ms err={}",
+ attempt, model, retryDelayMillis, failureMessage(ex));
+ sleepQuietly(retryDelayMillis);
}
}
throw lastFailure == null ? new IllegalStateException("LLM call failed") : lastFailure;
@@ -293,13 +302,14 @@ public class AppearancePatentLlmClient {
String userText,
List images,
String apiKey,
- String responseFormat) {
+ String responseFormat,
+ int attempt) {
recordSecretUsage();
Map body = buildChatBody(model, system, userText, images, responseFormat);
- log.info("[appearance-patent] llm request model={} url={} body={}",
- model, joinUrl(properties.getLlmHost(), "/v1/chat/completions"),
+ log.info("[appearance-patent] llm request attempt={} model={} url={} body={}",
+ attempt, model, joinUrl(properties.getLlmHost(), "/v1/chat/completions"),
writeJson(maskChatBody(body)));
- RestClient.RequestBodySpec request = restClient().post()
+ RestClient.RequestBodySpec request = restClient(attempt).post()
.uri(joinUrl(properties.getLlmHost(), "/v1/chat/completions"))
.headers(headers -> {
headers.setBearerAuth(stripBearer(apiKey));
@@ -748,24 +758,49 @@ public class AppearancePatentLlmClient {
}
}
- private RestClient restClient() {
+ /**
+ * 按尝试次数取客户端:第 1 次用「首次尝试超时」(快速失败,单次挂死代价更小),
+ * 重试用完整读超时,避免把慢但成功的正常调用误杀(实测成功调用 p99≈34s)。
+ */
+ private RestClient restClient(int attempt) {
+ if (attempt == 1 && firstAttemptTimeoutMillis() < Math.max(1, properties.getLlmReadTimeoutMillis())) {
+ RestClient client = firstAttemptRestClient;
+ if (client != null) {
+ return client;
+ }
+ synchronized (this) {
+ if (firstAttemptRestClient == null) {
+ firstAttemptRestClient = buildRestClient(firstAttemptTimeoutMillis());
+ }
+ return firstAttemptRestClient;
+ }
+ }
RestClient client = sharedRestClient;
if (client != null) {
return client;
}
synchronized (this) {
if (sharedRestClient == null) {
- RestClient.Builder builder = RestClient.builder()
- .requestFactory(HttpClientPool.requestFactory(properties.getLlmReadTimeoutMillis()));
- if (externalCallMetrics != null) {
- builder.requestInterceptor(externalCallMetrics.interceptor("llm"));
- }
- sharedRestClient = builder.build();
+ sharedRestClient = buildRestClient(properties.getLlmReadTimeoutMillis());
}
return sharedRestClient;
}
}
+ private int firstAttemptTimeoutMillis() {
+ int configured = properties.getLlmFirstAttemptReadTimeoutMillis();
+ return configured > 0 ? configured : Math.max(1, properties.getLlmReadTimeoutMillis());
+ }
+
+ private RestClient buildRestClient(int readTimeoutMillis) {
+ RestClient.Builder builder = RestClient.builder()
+ .requestFactory(HttpClientPool.requestFactory(readTimeoutMillis));
+ if (externalCallMetrics != null) {
+ builder.requestInterceptor(externalCallMetrics.interceptor("llm"));
+ }
+ return builder.build();
+ }
+
private ExecutorService rowExecutor() {
ExecutorService executor = rowExecutor;
if (executor != null) {
@@ -844,10 +879,6 @@ public class AppearancePatentLlmClient {
return normalized.substring(0, Math.max(0, maxLength - 3)) + "...";
}
- private void sleepBeforeRetry(int attemptIndex) {
- sleepQuietly(Math.max(1, attemptIndex) * 1500L);
- }
-
/** 密钥调用计次:每次真实 HTTP 请求(含重试)计 1 次;无任务上下文(无人归属)时跳过。 */
private void recordSecretUsage() {
SecretUsageContext.Context context = SecretUsageContext.current();
diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinLlmClient.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinLlmClient.java
index 4e359ff0..fae2521e 100644
--- a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinLlmClient.java
+++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinLlmClient.java
@@ -2,6 +2,7 @@ package com.nanri.aiimage.modules.similarasin.client;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
+import com.nanri.aiimage.common.retry.LlmRetryBackoff;
import com.nanri.aiimage.config.HttpClientPool;
import com.nanri.aiimage.config.SimilarAsinProperties;
import com.nanri.aiimage.metrics.ExternalCallMetricsRecorder;
@@ -43,6 +44,8 @@ public class SimilarAsinLlmClient {
private final UserSecretUsageService userSecretUsageService;
private volatile RestClient sharedRestClient;
+ /** 首次尝试专用客户端(更短读超时,快速失败);配置未生效时与 sharedRestClient 相同。 */
+ private volatile RestClient firstAttemptRestClient;
public SimilarAsinLlmClient(SimilarAsinProperties properties,
ObjectMapper objectMapper,
@@ -75,15 +78,16 @@ public class SimilarAsinLlmClient {
Exception lastFailure = null;
for (int attempt = 1; attempt <= attempts; attempt++) {
try {
- return invokeChatOnce(model, system, userText, images, resolvedKey, responseFormat);
+ return invokeChatOnce(model, system, userText, images, resolvedKey, responseFormat, attempt);
} catch (Exception ex) {
lastFailure = ex;
if (attempt >= attempts) {
break;
}
- log.warn("[similar-asin][llm] retryable failure attempt={} model={} err={}",
- attempt, model, failureMessage(ex));
- sleepBeforeRetry(attempt);
+ long retryDelayMillis = LlmRetryBackoff.delayMillis(attempt);
+ log.warn("[similar-asin][llm] retryable failure attempt={} model={} 下次重试等待={}ms err={}",
+ attempt, model, retryDelayMillis, failureMessage(ex));
+ sleepQuietly(retryDelayMillis);
}
}
throw lastFailure == null
@@ -110,13 +114,14 @@ public class SimilarAsinLlmClient {
String userText,
List images,
String apiKey,
- String responseFormat) {
+ String responseFormat,
+ int attempt) {
recordSecretUsage();
Map body = buildChatBody(model, system, userText, images, responseFormat);
- log.debug("[similar-asin][llm] request model={} url={} body={}",
- model, joinUrl(properties.getLlmHost(), "/v1/chat/completions"),
+ log.debug("[similar-asin][llm] request attempt={} model={} url={} body={}",
+ attempt, model, joinUrl(properties.getLlmHost(), "/v1/chat/completions"),
writeJson(maskChatBody(body)));
- RestClient.RequestBodySpec request = restClient().post()
+ RestClient.RequestBodySpec request = restClient(attempt).post()
.uri(joinUrl(properties.getLlmHost(), "/v1/chat/completions"))
.headers(headers -> {
headers.setBearerAuth(stripBearer(apiKey));
@@ -310,27 +315,52 @@ public class SimilarAsinLlmClient {
userSecretUsageService.record(context.userId(), UserSecretModule.SIMILAR_ASIN.key(), 1);
}
- private RestClient restClient() {
+ /**
+ * 按尝试次数取客户端:第 1 次用「首次尝试超时」(快速失败,单次挂死代价更小),
+ * 重试用完整读超时,避免把慢但成功的正常调用误杀(实测成功调用 p99≈34s)。
+ */
+ private RestClient restClient(int attempt) {
+ if (attempt == 1 && firstAttemptTimeoutMillis() < Math.max(1, properties.getLlmReadTimeoutMillis())) {
+ RestClient client = firstAttemptRestClient;
+ if (client != null) {
+ return client;
+ }
+ synchronized (this) {
+ if (firstAttemptRestClient == null) {
+ firstAttemptRestClient = buildRestClient(firstAttemptTimeoutMillis());
+ }
+ return firstAttemptRestClient;
+ }
+ }
RestClient client = sharedRestClient;
if (client != null) {
return client;
}
synchronized (this) {
if (sharedRestClient == null) {
- RestClient.Builder builder = RestClient.builder()
- .requestFactory(HttpClientPool.requestFactory(properties.getLlmReadTimeoutMillis()));
- if (externalCallMetrics != null) {
- builder.requestInterceptor(externalCallMetrics.interceptor("llm"));
- }
- sharedRestClient = builder.build();
+ sharedRestClient = buildRestClient(properties.getLlmReadTimeoutMillis());
}
return sharedRestClient;
}
}
- private void sleepBeforeRetry(int attemptIndex) {
+ private int firstAttemptTimeoutMillis() {
+ int configured = properties.getLlmFirstAttemptReadTimeoutMillis();
+ return configured > 0 ? configured : Math.max(1, properties.getLlmReadTimeoutMillis());
+ }
+
+ private RestClient buildRestClient(int readTimeoutMillis) {
+ RestClient.Builder builder = RestClient.builder()
+ .requestFactory(HttpClientPool.requestFactory(readTimeoutMillis));
+ if (externalCallMetrics != null) {
+ builder.requestInterceptor(externalCallMetrics.interceptor("llm"));
+ }
+ return builder.build();
+ }
+
+ private void sleepQuietly(long delayMillis) {
try {
- Thread.sleep(Math.max(1, attemptIndex) * 1500L);
+ Thread.sleep(delayMillis);
} catch (InterruptedException interruptedException) {
Thread.currentThread().interrupt();
throw new IllegalStateException("LLM retry interrupted", interruptedException);
diff --git a/backend-java/src/main/resources/application.yml b/backend-java/src/main/resources/application.yml
index 54fe2434..f89eaa83 100644
--- a/backend-java/src/main/resources/application.yml
+++ b/backend-java/src/main/resources/application.yml
@@ -251,6 +251,7 @@ aiimage:
llm-max-tokens: ${AIIMAGE_APPEARANCE_PATENT_LLM_MAX_TOKENS:64000}
llm-connect-timeout-millis: ${AIIMAGE_APPEARANCE_PATENT_LLM_CONNECT_TIMEOUT_MILLIS:10000}
llm-read-timeout-millis: ${AIIMAGE_APPEARANCE_PATENT_LLM_READ_TIMEOUT_MILLIS:180000}
+ llm-first-attempt-read-timeout-millis: ${AIIMAGE_APPEARANCE_PATENT_LLM_FIRST_ATTEMPT_READ_TIMEOUT_MILLIS:60000}
llm-batch-size: ${AIIMAGE_APPEARANCE_PATENT_LLM_BATCH_SIZE:10}
llm-row-concurrency: ${AIIMAGE_APPEARANCE_PATENT_LLM_ROW_CONCURRENCY:10}
max-parse-rows: ${AIIMAGE_APPEARANCE_PATENT_MAX_PARSE_ROWS:50000}
@@ -283,6 +284,7 @@ aiimage:
llm-max-tokens: ${AIIMAGE_SIMILAR_ASIN_LLM_MAX_TOKENS:64000}
llm-connect-timeout-millis: ${AIIMAGE_SIMILAR_ASIN_LLM_CONNECT_TIMEOUT_MILLIS:10000}
llm-read-timeout-millis: ${AIIMAGE_SIMILAR_ASIN_LLM_READ_TIMEOUT_MILLIS:180000}
+ llm-first-attempt-read-timeout-millis: ${AIIMAGE_SIMILAR_ASIN_LLM_FIRST_ATTEMPT_READ_TIMEOUT_MILLIS:60000}
llm-retry-times: ${AIIMAGE_SIMILAR_ASIN_LLM_RETRY_TIMES:3}
llm-row-concurrency: ${AIIMAGE_SIMILAR_ASIN_LLM_ROW_CONCURRENCY:5}
llm-image-download-timeout-seconds: ${AIIMAGE_SIMILAR_ASIN_LLM_IMAGE_DOWNLOAD_TIMEOUT_SECONDS:10}
diff --git a/backend-java/src/test/java/com/nanri/aiimage/common/retry/LlmRetryBackoffTest.java b/backend-java/src/test/java/com/nanri/aiimage/common/retry/LlmRetryBackoffTest.java
new file mode 100644
index 00000000..5fbadc18
--- /dev/null
+++ b/backend-java/src/test/java/com/nanri/aiimage/common/retry/LlmRetryBackoffTest.java
@@ -0,0 +1,47 @@
+package com.nanri.aiimage.common.retry;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** 重试退避策略:档位、上下界与抖动范围(固定 random 消除随机性)。 */
+class LlmRetryBackoffTest {
+
+ @Test
+ void firstRetryWaitsTwoSeconds() {
+ assertThat(LlmRetryBackoff.delayMillis(1, 0.5d)).isEqualTo(2_000L);
+ }
+
+ @Test
+ void secondRetryWaitsTenSeconds() {
+ assertThat(LlmRetryBackoff.delayMillis(2, 0.5d)).isEqualTo(10_000L);
+ }
+
+ @Test
+ void attemptsBeyondTableUseLastTier() {
+ assertThat(LlmRetryBackoff.delayMillis(3, 0.5d)).isEqualTo(10_000L);
+ assertThat(LlmRetryBackoff.delayMillis(9, 0.5d)).isEqualTo(10_000L);
+ }
+
+ @Test
+ void jitterStaysWithinThirtyPercent() {
+ assertThat(LlmRetryBackoff.delayMillis(1, 0d)).isEqualTo(1_400L);
+ assertThat(LlmRetryBackoff.delayMillis(1, 1d)).isEqualTo(2_600L);
+ assertThat(LlmRetryBackoff.delayMillis(2, 0d)).isEqualTo(7_000L);
+ assertThat(LlmRetryBackoff.delayMillis(2, 1d)).isEqualTo(13_000L);
+ }
+
+ @Test
+ void attemptIndexBelowOneIsClampedToFirstTier() {
+ assertThat(LlmRetryBackoff.delayMillis(0, 0.5d)).isEqualTo(2_000L);
+ assertThat(LlmRetryBackoff.delayMillis(-3, 0.5d)).isEqualTo(2_000L);
+ }
+
+ @Test
+ void randomDelayAlwaysPositiveAndBounded() {
+ for (int i = 0; i < 200; i++) {
+ long delay = LlmRetryBackoff.delayMillis(1);
+ assertThat(delay).isBetween(1_400L, 2_600L);
+ }
+ }
+}
diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentLlmClientHttpTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentLlmClientHttpTest.java
index 52a0f8de..bf131c9d 100644
--- a/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentLlmClientHttpTest.java
+++ b/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentLlmClientHttpTest.java
@@ -31,6 +31,10 @@ class AppearancePatentLlmClientHttpTest {
private final ObjectMapper objectMapper = new ObjectMapper();
private final Map callCounts = new ConcurrentHashMap<>();
private final List capturedBodies = new ArrayList<>();
+ /** 纯文本(标题提取)请求计数:用于验证重试次数与「跳过外观」行为。 */
+ private final AtomicInteger textRequestCount = new AtomicInteger();
+ private volatile boolean failTextRequests = false;
+ private volatile long firstTextRequestDelayMillis = 0L;
private AppearancePatentLlmClient client;
@@ -60,6 +64,16 @@ class AppearancePatentLlmClientHttpTest {
private void handleChat(HttpExchange exchange) throws IOException {
String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
capturedBodies.add(body);
+ if (!body.contains("\"type\":\"image_url\"")) {
+ int sequence = textRequestCount.incrementAndGet();
+ if (sequence == 1 && firstTextRequestDelayMillis > 0L) {
+ sleep(firstTextRequestDelayMillis);
+ }
+ if (failTextRequests) {
+ respond(exchange, 502, "{\"error\":{\"message\":\"bad gateway\"}}");
+ return;
+ }
+ }
try {
@SuppressWarnings("unchecked")
Map request = objectMapper.readValue(body, Map.class);
@@ -103,6 +117,32 @@ class AppearancePatentLlmClientHttpTest {
}
}
+ private void respond(HttpExchange exchange, int status, String body) throws IOException {
+ byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
+ exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8");
+ exchange.sendResponseHeaders(status, bytes.length);
+ try (OutputStream os = exchange.getResponseBody()) {
+ os.write(bytes);
+ }
+ }
+
+ private void sleep(long millis) {
+ try {
+ Thread.sleep(millis);
+ } catch (InterruptedException interruptedException) {
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ /** 用自定义超时/重试参数构建客户端(setUp 里的 client 用属性默认值)。 */
+ private AppearancePatentLlmClient buildClient(java.util.function.Consumer customizer) {
+ AppearancePatentProperties properties = new AppearancePatentProperties();
+ properties.setLlmHost("http://127.0.0.1:" + server.getAddress().getPort());
+ customizer.accept(properties);
+ return new AppearancePatentLlmClient(properties, objectMapper, null,
+ new BrandCheckClient(new BrandCheckProperties(), null), mock(UserSecretUsageService.class));
+ }
+
private boolean messagesBodyContains(HttpExchange exchange, String text) {
return capturedBodies.stream().anyMatch(b -> b.contains(text));
}
@@ -194,4 +234,43 @@ class AppearancePatentLlmClientHttpTest {
assertThat(appearanceBody).contains("\"type\":\"json_object\"");
assertThat(appearanceBody).contains("产品描述:普通数据线");
}
+
+ /** 标题识别失败 → 本行必走回退(外观结果会被丢弃),外观请求不再发出:劣化窗口下少一轮无效重试。 */
+ @Test
+ void titleFailureSkipsAppearanceCallAndKeepsFallback() {
+ failTextRequests = true;
+ AppearancePatentLlmClient singleAttemptClient = buildClient(p -> {
+ p.setLlmRetryTimes(1);
+ p.setLlmFirstAttemptReadTimeoutMillis(5_000);
+ p.setLlmReadTimeoutMillis(5_000);
+ });
+
+ List rows = singleAttemptClient.inspectRows(
+ List.of(row("7", "B007", "任意标题", "", "https://img.example.com/7.jpg")), null, "test-key");
+
+ assertThat(rows.get(0).getAppearanceRisk()).isEqualTo("外观识别异常");
+ assertThat(capturedBodies).noneMatch(b -> b.contains("\"type\":\"image_url\""));
+ assertThat(textRequestCount.get()).isEqualTo(1);
+ }
+
+ /** 首查用更短超时快速失败、重试走完整超时并成功(上游偶发单请求不响应的兜底路径)。 */
+ @Test
+ void firstAttemptTimeoutRetriesWithFullBudgetAndSucceeds() {
+ firstTextRequestDelayMillis = 1_200L;
+ AppearancePatentLlmClient twoAttemptClient = buildClient(p -> {
+ p.setLlmRetryTimes(2);
+ p.setLlmFirstAttemptReadTimeoutMillis(400);
+ p.setLlmReadTimeoutMillis(8_000);
+ });
+
+ long startedAt = System.nanoTime();
+ List rows = twoAttemptClient.inspectRows(
+ List.of(row("8", "B008", "普通数据线", "", "https://img.example.com/8.jpg")), null, "test-key");
+ long elapsedMs = (System.nanoTime() - startedAt) / 1_000_000;
+
+ AppearancePatentResultRowDto result = rows.get(0);
+ assertThat(result.getAppearanceRisk()).isEqualTo("无侵权");
+ assertThat(textRequestCount.get()).isEqualTo(2);
+ assertThat(elapsedMs).isGreaterThanOrEqualTo(400L);
+ }
}