Compare commits
3 Commits
ff1ffbbfa3
...
db6869b77e
| Author | SHA1 | Date | |
|---|---|---|---|
| db6869b77e | |||
| 864c22ffc7 | |||
| fd614004b0 |
@@ -32,7 +32,9 @@ test('test_dup_console_render_ledger_tab_normal_variant_input', async ({ page })
|
|||||||
await expect(page.locator('.dup-head h2')).toHaveText('店铺数据撞款监控')
|
await expect(page.locator('.dup-head h2')).toHaveText('店铺数据撞款监控')
|
||||||
await page.getByRole('button', { name: /全部ASIN台账/ }).click()
|
await page.getByRole('button', { name: /全部ASIN台账/ }).click()
|
||||||
await expect(page.locator('table.ledger tbody tr').first()).toBeVisible({ timeout: 15000 })
|
await expect(page.locator('table.ledger tbody tr').first()).toBeVisible({ timeout: 15000 })
|
||||||
await expect(page.locator('.old-pagination')).toBeVisible()
|
// 台账 tab 下页面同时有「店铺上架分布」分页器(常显,见 tests/align-dup-console.test.ts)与台账分页器,
|
||||||
|
// 全页 .old-pagination 会命中 2 个元素触发 strict mode;限定到台账表格(.table-wrap)紧邻的分页器。
|
||||||
|
await expect(page.locator('.table-wrap + .old-pagination')).toBeVisible()
|
||||||
expect(errors).toEqual([])
|
expect(errors).toEqual([])
|
||||||
await page.locator('.table-wrap').screenshot({ path: 'test-results/dup-console-admin-ledger.jpg' })
|
await page.locator('.table-wrap').screenshot({ path: 'test-results/dup-console-admin-ledger.jpg' })
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -66,9 +66,10 @@ export async function fetchUserSecretList(params: UserSecretQuery): Promise<Admi
|
|||||||
return unwrap<AdminUserSecretPage>(data)
|
return unwrap<AdminUserSecretPage>(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 立即检测该用户全部已配置项:POST /api/admin/user-secrets/{userId}/check */
|
/** 立即检测该用户全部已配置项:POST /api/admin/user-secrets/{userId}/check
|
||||||
|
* 逐模块检测(每项最多「首查 + 传输层失败重试一次」,最坏约 31s/项),故放宽超时到 180s。 */
|
||||||
export async function checkUserSecret(userId: number) {
|
export async function checkUserSecret(userId: number) {
|
||||||
const { data } = await http.post(`/api/admin/user-secrets/${userId}/check`)
|
const { data } = await http.post(`/api/admin/user-secrets/${userId}/check`, undefined, { timeout: 180_000 })
|
||||||
return unwrap<
|
return unwrap<
|
||||||
Array<{
|
Array<{
|
||||||
moduleKey: string
|
moduleKey: string
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.nanri.aiimage.common.retry;
|
||||||
|
|
||||||
|
import java.util.concurrent.ThreadLocalRandom;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LLM 重试等待策略:指数退避 + 抖动。
|
||||||
|
*
|
||||||
|
* <p>背景(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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,12 @@ public class AppearancePatentProperties {
|
|||||||
private int llmMaxTokens = 64000;
|
private int llmMaxTokens = 64000;
|
||||||
private int llmConnectTimeoutMillis = 10000;
|
private int llmConnectTimeoutMillis = 10000;
|
||||||
private int llmReadTimeoutMillis = 180000;
|
private int llmReadTimeoutMillis = 180000;
|
||||||
|
/**
|
||||||
|
* 首次尝试读超时(毫秒):上游偶发单请求不应答(挂满超时才失败),
|
||||||
|
* 首查用更短预算快速失败并重试;生产实测成功调用 p99≈34s、超 60s 仅 0.02%,
|
||||||
|
* 故默认 60s 既覆盖正常慢响应又能把单次挂死代价从 90s 降到 60s。
|
||||||
|
*/
|
||||||
|
private int llmFirstAttemptReadTimeoutMillis = 60000;
|
||||||
private int llmBatchSize = 10;
|
private int llmBatchSize = 10;
|
||||||
/**
|
/**
|
||||||
* 批内行级并发数,默认等于批量大小
|
* 批内行级并发数,默认等于批量大小
|
||||||
|
|||||||
@@ -161,6 +161,11 @@ public class SimilarAsinProperties {
|
|||||||
private int llmMaxTokens = 64000;
|
private int llmMaxTokens = 64000;
|
||||||
private int llmConnectTimeoutMillis = 10000;
|
private int llmConnectTimeoutMillis = 10000;
|
||||||
private int llmReadTimeoutMillis = 180000;
|
private int llmReadTimeoutMillis = 180000;
|
||||||
|
/**
|
||||||
|
* 首次尝试读超时(毫秒):上游偶发单请求不应答,首查用更短预算快速失败后重试;
|
||||||
|
* 生产实测成功调用 p99≈34s、超 60s 仅 0.02%,默认 60s 兼顾慢响应与快速失败。
|
||||||
|
*/
|
||||||
|
private int llmFirstAttemptReadTimeoutMillis = 60000;
|
||||||
private int llmRetryTimes = 3;
|
private int llmRetryTimes = 3;
|
||||||
|
|
||||||
/** 批内行级并发上限:每行最多 2 次图片对比 + 1 次合规 + 3 次类目匹配。 */
|
/** 批内行级并发上限:每行最多 2 次图片对比 + 1 次合规 + 3 次类目匹配。 */
|
||||||
|
|||||||
+50
-19
@@ -2,6 +2,7 @@ package com.nanri.aiimage.modules.appearancepatent.client;
|
|||||||
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.nanri.aiimage.common.retry.LlmRetryBackoff;
|
||||||
import com.nanri.aiimage.config.AppearancePatentProperties;
|
import com.nanri.aiimage.config.AppearancePatentProperties;
|
||||||
import com.nanri.aiimage.config.HttpClientPool;
|
import com.nanri.aiimage.config.HttpClientPool;
|
||||||
import com.nanri.aiimage.metrics.ExternalCallMetricsRecorder;
|
import com.nanri.aiimage.metrics.ExternalCallMetricsRecorder;
|
||||||
@@ -103,6 +104,8 @@ public class AppearancePatentLlmClient {
|
|||||||
private final UserSecretUsageService userSecretUsageService;
|
private final UserSecretUsageService userSecretUsageService;
|
||||||
|
|
||||||
private volatile RestClient sharedRestClient;
|
private volatile RestClient sharedRestClient;
|
||||||
|
/** 首次尝试专用客户端(更短读超时,快速失败);配置未生效时与 sharedRestClient 相同。 */
|
||||||
|
private volatile RestClient firstAttemptRestClient;
|
||||||
private volatile ExecutorService rowExecutor;
|
private volatile ExecutorService rowExecutor;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -192,6 +195,10 @@ public class AppearancePatentLlmClient {
|
|||||||
String appearanceStatus = "";
|
String appearanceStatus = "";
|
||||||
String appearanceReason = "";
|
String appearanceReason = "";
|
||||||
boolean appearanceFailed = false;
|
boolean appearanceFailed = false;
|
||||||
|
if (titleFailed) {
|
||||||
|
// 标题识别失败时本行必走回退分支(外观结果会被丢弃),再发外观请求只会在劣化中的上游白等一轮重试
|
||||||
|
log.warn("[appearance-patent] 标题识别失败,跳过外观识别(本行同样走回退)asin={}", row.getAsin());
|
||||||
|
} else {
|
||||||
try {
|
try {
|
||||||
AppearanceJudgement judgement = invokeAppearance(row, prompt, apiKey);
|
AppearanceJudgement judgement = invokeAppearance(row, prompt, apiKey);
|
||||||
appearanceStatus = judgement.status();
|
appearanceStatus = judgement.status();
|
||||||
@@ -202,6 +209,7 @@ public class AppearancePatentLlmClient {
|
|||||||
log.warn("[appearance-patent] appearance llm failed asin={} title={} url={} err={}",
|
log.warn("[appearance-patent] appearance llm failed asin={} title={} url={} err={}",
|
||||||
row.getAsin(), abbreviate(row.getTitle(), 120), abbreviate(row.getUrl(), 120), failureMessage(ex));
|
row.getAsin(), abbreviate(row.getTitle(), 120), abbreviate(row.getUrl(), 120), failureMessage(ex));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (titleFailed || appearanceFailed) {
|
if (titleFailed || appearanceFailed) {
|
||||||
String titleReason = titleFailed ? titleError : firstNonBlank(rawTitle, MISSING_ROW_DATA);
|
String titleReason = titleFailed ? titleError : firstNonBlank(rawTitle, MISSING_ROW_DATA);
|
||||||
return applyRowFallback(resultRow, rawTitle, titleReason, appearanceFailed ? appearanceReason : "");
|
return applyRowFallback(resultRow, rawTitle, titleReason, appearanceFailed ? appearanceReason : "");
|
||||||
@@ -274,15 +282,16 @@ public class AppearancePatentLlmClient {
|
|||||||
Exception lastFailure = null;
|
Exception lastFailure = null;
|
||||||
for (int attempt = 1; attempt <= attempts; attempt++) {
|
for (int attempt = 1; attempt <= attempts; attempt++) {
|
||||||
try {
|
try {
|
||||||
return invokeChatOnce(model, system, userText, images, apiKey, responseFormat);
|
return invokeChatOnce(model, system, userText, images, apiKey, responseFormat, attempt);
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
lastFailure = ex;
|
lastFailure = ex;
|
||||||
if (attempt >= attempts) {
|
if (attempt >= attempts) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
log.warn("[appearance-patent] llm retryable failure attempt={} model={} err={}",
|
long retryDelayMillis = LlmRetryBackoff.delayMillis(attempt);
|
||||||
attempt, model, failureMessage(ex));
|
log.warn("[appearance-patent] llm retryable failure attempt={} model={} 下次重试等待={}ms err={}",
|
||||||
sleepBeforeRetry(attempt);
|
attempt, model, retryDelayMillis, failureMessage(ex));
|
||||||
|
sleepQuietly(retryDelayMillis);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw lastFailure == null ? new IllegalStateException("LLM call failed") : lastFailure;
|
throw lastFailure == null ? new IllegalStateException("LLM call failed") : lastFailure;
|
||||||
@@ -293,13 +302,14 @@ public class AppearancePatentLlmClient {
|
|||||||
String userText,
|
String userText,
|
||||||
List<String> images,
|
List<String> images,
|
||||||
String apiKey,
|
String apiKey,
|
||||||
String responseFormat) {
|
String responseFormat,
|
||||||
|
int attempt) {
|
||||||
recordSecretUsage();
|
recordSecretUsage();
|
||||||
Map<String, Object> body = buildChatBody(model, system, userText, images, responseFormat);
|
Map<String, Object> body = buildChatBody(model, system, userText, images, responseFormat);
|
||||||
log.info("[appearance-patent] llm request model={} url={} body={}",
|
log.info("[appearance-patent] llm request attempt={} model={} url={} body={}",
|
||||||
model, joinUrl(properties.getLlmHost(), "/v1/chat/completions"),
|
attempt, model, joinUrl(properties.getLlmHost(), "/v1/chat/completions"),
|
||||||
writeJson(maskChatBody(body)));
|
writeJson(maskChatBody(body)));
|
||||||
RestClient.RequestBodySpec request = restClient().post()
|
RestClient.RequestBodySpec request = restClient(attempt).post()
|
||||||
.uri(joinUrl(properties.getLlmHost(), "/v1/chat/completions"))
|
.uri(joinUrl(properties.getLlmHost(), "/v1/chat/completions"))
|
||||||
.headers(headers -> {
|
.headers(headers -> {
|
||||||
headers.setBearerAuth(stripBearer(apiKey));
|
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;
|
RestClient client = sharedRestClient;
|
||||||
if (client != null) {
|
if (client != null) {
|
||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
synchronized (this) {
|
synchronized (this) {
|
||||||
if (sharedRestClient == null) {
|
if (sharedRestClient == null) {
|
||||||
RestClient.Builder builder = RestClient.builder()
|
sharedRestClient = buildRestClient(properties.getLlmReadTimeoutMillis());
|
||||||
.requestFactory(HttpClientPool.requestFactory(properties.getLlmReadTimeoutMillis()));
|
|
||||||
if (externalCallMetrics != null) {
|
|
||||||
builder.requestInterceptor(externalCallMetrics.interceptor("llm"));
|
|
||||||
}
|
|
||||||
sharedRestClient = builder.build();
|
|
||||||
}
|
}
|
||||||
return sharedRestClient;
|
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() {
|
private ExecutorService rowExecutor() {
|
||||||
ExecutorService executor = rowExecutor;
|
ExecutorService executor = rowExecutor;
|
||||||
if (executor != null) {
|
if (executor != null) {
|
||||||
@@ -844,10 +879,6 @@ public class AppearancePatentLlmClient {
|
|||||||
return normalized.substring(0, Math.max(0, maxLength - 3)) + "...";
|
return normalized.substring(0, Math.max(0, maxLength - 3)) + "...";
|
||||||
}
|
}
|
||||||
|
|
||||||
private void sleepBeforeRetry(int attemptIndex) {
|
|
||||||
sleepQuietly(Math.max(1, attemptIndex) * 1500L);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 密钥调用计次:每次真实 HTTP 请求(含重试)计 1 次;无任务上下文(无人归属)时跳过。 */
|
/** 密钥调用计次:每次真实 HTTP 请求(含重试)计 1 次;无任务上下文(无人归属)时跳过。 */
|
||||||
private void recordSecretUsage() {
|
private void recordSecretUsage() {
|
||||||
SecretUsageContext.Context context = SecretUsageContext.current();
|
SecretUsageContext.Context context = SecretUsageContext.current();
|
||||||
|
|||||||
+47
-17
@@ -2,6 +2,7 @@ package com.nanri.aiimage.modules.similarasin.client;
|
|||||||
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.nanri.aiimage.common.retry.LlmRetryBackoff;
|
||||||
import com.nanri.aiimage.config.HttpClientPool;
|
import com.nanri.aiimage.config.HttpClientPool;
|
||||||
import com.nanri.aiimage.config.SimilarAsinProperties;
|
import com.nanri.aiimage.config.SimilarAsinProperties;
|
||||||
import com.nanri.aiimage.metrics.ExternalCallMetricsRecorder;
|
import com.nanri.aiimage.metrics.ExternalCallMetricsRecorder;
|
||||||
@@ -43,6 +44,8 @@ public class SimilarAsinLlmClient {
|
|||||||
private final UserSecretUsageService userSecretUsageService;
|
private final UserSecretUsageService userSecretUsageService;
|
||||||
|
|
||||||
private volatile RestClient sharedRestClient;
|
private volatile RestClient sharedRestClient;
|
||||||
|
/** 首次尝试专用客户端(更短读超时,快速失败);配置未生效时与 sharedRestClient 相同。 */
|
||||||
|
private volatile RestClient firstAttemptRestClient;
|
||||||
|
|
||||||
public SimilarAsinLlmClient(SimilarAsinProperties properties,
|
public SimilarAsinLlmClient(SimilarAsinProperties properties,
|
||||||
ObjectMapper objectMapper,
|
ObjectMapper objectMapper,
|
||||||
@@ -75,15 +78,16 @@ public class SimilarAsinLlmClient {
|
|||||||
Exception lastFailure = null;
|
Exception lastFailure = null;
|
||||||
for (int attempt = 1; attempt <= attempts; attempt++) {
|
for (int attempt = 1; attempt <= attempts; attempt++) {
|
||||||
try {
|
try {
|
||||||
return invokeChatOnce(model, system, userText, images, resolvedKey, responseFormat);
|
return invokeChatOnce(model, system, userText, images, resolvedKey, responseFormat, attempt);
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
lastFailure = ex;
|
lastFailure = ex;
|
||||||
if (attempt >= attempts) {
|
if (attempt >= attempts) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
log.warn("[similar-asin][llm] retryable failure attempt={} model={} err={}",
|
long retryDelayMillis = LlmRetryBackoff.delayMillis(attempt);
|
||||||
attempt, model, failureMessage(ex));
|
log.warn("[similar-asin][llm] retryable failure attempt={} model={} 下次重试等待={}ms err={}",
|
||||||
sleepBeforeRetry(attempt);
|
attempt, model, retryDelayMillis, failureMessage(ex));
|
||||||
|
sleepQuietly(retryDelayMillis);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw lastFailure == null
|
throw lastFailure == null
|
||||||
@@ -110,13 +114,14 @@ public class SimilarAsinLlmClient {
|
|||||||
String userText,
|
String userText,
|
||||||
List<String> images,
|
List<String> images,
|
||||||
String apiKey,
|
String apiKey,
|
||||||
String responseFormat) {
|
String responseFormat,
|
||||||
|
int attempt) {
|
||||||
recordSecretUsage();
|
recordSecretUsage();
|
||||||
Map<String, Object> body = buildChatBody(model, system, userText, images, responseFormat);
|
Map<String, Object> body = buildChatBody(model, system, userText, images, responseFormat);
|
||||||
log.debug("[similar-asin][llm] request model={} url={} body={}",
|
log.debug("[similar-asin][llm] request attempt={} model={} url={} body={}",
|
||||||
model, joinUrl(properties.getLlmHost(), "/v1/chat/completions"),
|
attempt, model, joinUrl(properties.getLlmHost(), "/v1/chat/completions"),
|
||||||
writeJson(maskChatBody(body)));
|
writeJson(maskChatBody(body)));
|
||||||
RestClient.RequestBodySpec request = restClient().post()
|
RestClient.RequestBodySpec request = restClient(attempt).post()
|
||||||
.uri(joinUrl(properties.getLlmHost(), "/v1/chat/completions"))
|
.uri(joinUrl(properties.getLlmHost(), "/v1/chat/completions"))
|
||||||
.headers(headers -> {
|
.headers(headers -> {
|
||||||
headers.setBearerAuth(stripBearer(apiKey));
|
headers.setBearerAuth(stripBearer(apiKey));
|
||||||
@@ -310,27 +315,52 @@ public class SimilarAsinLlmClient {
|
|||||||
userSecretUsageService.record(context.userId(), UserSecretModule.SIMILAR_ASIN.key(), 1);
|
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;
|
RestClient client = sharedRestClient;
|
||||||
if (client != null) {
|
if (client != null) {
|
||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
synchronized (this) {
|
synchronized (this) {
|
||||||
if (sharedRestClient == null) {
|
if (sharedRestClient == null) {
|
||||||
RestClient.Builder builder = RestClient.builder()
|
sharedRestClient = buildRestClient(properties.getLlmReadTimeoutMillis());
|
||||||
.requestFactory(HttpClientPool.requestFactory(properties.getLlmReadTimeoutMillis()));
|
|
||||||
if (externalCallMetrics != null) {
|
|
||||||
builder.requestInterceptor(externalCallMetrics.interceptor("llm"));
|
|
||||||
}
|
|
||||||
sharedRestClient = builder.build();
|
|
||||||
}
|
}
|
||||||
return sharedRestClient;
|
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 {
|
try {
|
||||||
Thread.sleep(Math.max(1, attemptIndex) * 1500L);
|
Thread.sleep(delayMillis);
|
||||||
} catch (InterruptedException interruptedException) {
|
} catch (InterruptedException interruptedException) {
|
||||||
Thread.currentThread().interrupt();
|
Thread.currentThread().interrupt();
|
||||||
throw new IllegalStateException("LLM retry interrupted", interruptedException);
|
throw new IllegalStateException("LLM retry interrupted", interruptedException);
|
||||||
|
|||||||
+89
-24
@@ -47,15 +47,22 @@ public class UserApiSecretCheckService {
|
|||||||
public static final String CODE_RATE_LIMITED = "rate_limited";
|
public static final String CODE_RATE_LIMITED = "rate_limited";
|
||||||
public static final String CODE_SERVER_ERROR = "server_error";
|
public static final String CODE_SERVER_ERROR = "server_error";
|
||||||
public static final String CODE_NETWORK_ERROR = "network_error";
|
public static final String CODE_NETWORK_ERROR = "network_error";
|
||||||
|
public static final String CODE_TIMEOUT = "timeout";
|
||||||
public static final String CODE_PROVIDER_ERROR = "provider_error";
|
public static final String CODE_PROVIDER_ERROR = "provider_error";
|
||||||
public static final String CODE_INSUFFICIENT_BALANCE = "insufficient_balance";
|
public static final String CODE_INSUFFICIENT_BALANCE = "insufficient_balance";
|
||||||
|
|
||||||
/** 供应商欠费提示文案(前后端都按 code 识别展示)。 */
|
/** 供应商欠费提示文案(前后端都按 code 识别展示)。 */
|
||||||
public static final String INSUFFICIENT_BALANCE_MESSAGE = "代理服务商余额不足,请充值后重试";
|
public static final String INSUFFICIENT_BALANCE_MESSAGE = "代理服务商余额不足,请充值后重试";
|
||||||
|
|
||||||
|
/** 超时与网络不可达提示文案:面向用户展示,技术细节只进服务端日志。 */
|
||||||
|
public static final String TIMEOUT_MESSAGE = "检测超时:上游服务响应超时,请稍后重试";
|
||||||
|
public static final String NETWORK_ERROR_MESSAGE = "网络不可达:无法连接上游服务,请检查网络后重试";
|
||||||
|
|
||||||
private static final MediaType APPLICATION_JSON_UTF8 = new MediaType("application", "json", StandardCharsets.UTF_8);
|
private static final MediaType APPLICATION_JSON_UTF8 = new MediaType("application", "json", StandardCharsets.UTF_8);
|
||||||
private static final int READ_TIMEOUT_MILLIS = 15_000;
|
private static final int READ_TIMEOUT_MILLIS = 15_000;
|
||||||
private static final int PROXY_READ_TIMEOUT_MILLIS = 10_000;
|
private static final int PROXY_READ_TIMEOUT_MILLIS = 10_000;
|
||||||
|
/** 传输层失败后的重试间隔(单次检测总耗时上界:15s + 0.8s + 15s ≈ 31s)。 */
|
||||||
|
private static final long RETRY_BACKOFF_MILLIS = 800L;
|
||||||
private static final int MAX_RESPONSE_BYTES = 1024 * 1024;
|
private static final int MAX_RESPONSE_BYTES = 1024 * 1024;
|
||||||
private static final int CHECK_MAX_TOKENS = 8;
|
private static final int CHECK_MAX_TOKENS = 8;
|
||||||
/** 代理探测目标:自家域名(http 无 CONNECT 依赖,兼容各类转发型代理)。 */
|
/** 代理探测目标:自家域名(http 无 CONNECT 依赖,兼容各类转发型代理)。 */
|
||||||
@@ -72,7 +79,14 @@ public class UserApiSecretCheckService {
|
|||||||
|
|
||||||
private volatile RestClient directClient;
|
private volatile RestClient directClient;
|
||||||
|
|
||||||
/** 探测入口:代理模块走代理连通性探测,LLM 密钥模块优先经检测出口代理(若配置提取链接),代理网络不可达回退直连。 */
|
/**
|
||||||
|
* 探测入口:代理模块走代理连通性探测,LLM 密钥模块优先经检测出口代理(若配置提取链接),代理网络不可达回退直连。
|
||||||
|
*
|
||||||
|
* <p>2026-09-13:上游(ai.t8star.org)存在约 5%~10% 的单请求不响应(实测随机出现,
|
||||||
|
* 与协议、连接复用无关),检测只有一次机会时用户会看到「检测超时」。
|
||||||
|
* 因此对最后一跳(直连)在传输层失败(超时/网络不可达)时重试一次;
|
||||||
|
* 代理模块不重试——jikip 按提取次数计费,重试会多扣一次。
|
||||||
|
*/
|
||||||
public CheckOutcome probe(UserSecretModule module, String plainApiKey) {
|
public CheckOutcome probe(UserSecretModule module, String plainApiKey) {
|
||||||
if (module == UserSecretModule.PROXY) {
|
if (module == UserSecretModule.PROXY) {
|
||||||
return probeProxy(plainApiKey);
|
return probeProxy(plainApiKey);
|
||||||
@@ -86,10 +100,13 @@ public class UserApiSecretCheckService {
|
|||||||
}
|
}
|
||||||
if (proxyUrl != null) {
|
if (proxyUrl != null) {
|
||||||
CheckOutcome viaProxy = probeOnce(module, plainApiKey, proxyUrl, true);
|
CheckOutcome viaProxy = probeOnce(module, plainApiKey, proxyUrl, true);
|
||||||
if (CODE_NETWORK_ERROR.equals(viaProxy.code())) {
|
if (isTransportFailure(viaProxy)) {
|
||||||
log.warn("[user-secret][check] 经代理检测网络不可达 module={} proxy={},回退直连重试",
|
log.warn("[user-secret][check] 经代理检测失败 module={} proxy={} code={},回退直连重试",
|
||||||
module.key(), proxyUrl);
|
module.key(), proxyUrl, viaProxy.code());
|
||||||
CheckOutcome direct = probeOnce(module, plainApiKey, null, false);
|
CheckOutcome direct = probeOnce(module, plainApiKey, null, false);
|
||||||
|
if (isTransportFailure(direct)) {
|
||||||
|
direct = retryOnce(module, plainApiKey, direct);
|
||||||
|
}
|
||||||
return new CheckOutcome(
|
return new CheckOutcome(
|
||||||
direct.status(),
|
direct.status(),
|
||||||
direct.code(),
|
direct.code(),
|
||||||
@@ -99,7 +116,38 @@ public class UserApiSecretCheckService {
|
|||||||
}
|
}
|
||||||
return viaProxy;
|
return viaProxy;
|
||||||
}
|
}
|
||||||
return probeOnce(module, plainApiKey, null, false);
|
CheckOutcome direct = probeOnce(module, plainApiKey, null, false);
|
||||||
|
return isTransportFailure(direct) ? retryOnce(module, plainApiKey, direct) : direct;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 传输层失败重试一次(间隔 {@link #RETRY_BACKOFF_MILLIS}):抗上游偶发单请求不响应。 */
|
||||||
|
private CheckOutcome retryOnce(UserSecretModule module, String plainApiKey, CheckOutcome failed) {
|
||||||
|
log.warn("[user-secret][check] 直连探测失败 module={} code={} latency={}ms,{}ms 后重试一次",
|
||||||
|
module.key(), failed.code(), failed.latencyMs(), RETRY_BACKOFF_MILLIS);
|
||||||
|
sleepQuietly(RETRY_BACKOFF_MILLIS);
|
||||||
|
CheckOutcome retried = probeOnce(module, plainApiKey, null, false);
|
||||||
|
if (isTransportFailure(retried)) {
|
||||||
|
log.warn("[user-secret][check] 重试仍失败 module={} code={} latency={}ms",
|
||||||
|
module.key(), retried.code(), retried.latencyMs());
|
||||||
|
} else {
|
||||||
|
log.info("[user-secret][check] 重试成功 module={} status={} code={} latency={}ms",
|
||||||
|
module.key(), retried.status(), retried.code(), retried.latencyMs());
|
||||||
|
}
|
||||||
|
return retried;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 传输层失败(超时/网络不可达)才值得重试;HTTP 层的 401/429/5xx 等重试无意义。 */
|
||||||
|
static boolean isTransportFailure(CheckOutcome outcome) {
|
||||||
|
return outcome != null
|
||||||
|
&& (CODE_NETWORK_ERROR.equals(outcome.code()) || CODE_TIMEOUT.equals(outcome.code()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sleepQuietly(long millis) {
|
||||||
|
try {
|
||||||
|
Thread.sleep(millis);
|
||||||
|
} catch (InterruptedException interruptedException) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -132,7 +180,7 @@ public class UserApiSecretCheckService {
|
|||||||
long latency = System.currentTimeMillis() - startMillis;
|
long latency = System.currentTimeMillis() - startMillis;
|
||||||
log.warn("[user-secret][check] 代理提取接口不可达 latency={}ms err={}", latency, ex.getMessage());
|
log.warn("[user-secret][check] 代理提取接口不可达 latency={}ms err={}", latency, ex.getMessage());
|
||||||
return new CheckOutcome(STATUS_FAILED, CODE_NETWORK_ERROR,
|
return new CheckOutcome(STATUS_FAILED, CODE_NETWORK_ERROR,
|
||||||
"代理提取接口不可达:" + rootCauseMessage(ex), (int) latency, false);
|
"代理提取接口无法访问,请检查提取链接或稍后重试", (int) latency, false);
|
||||||
}
|
}
|
||||||
long extractLatency = System.currentTimeMillis() - startMillis;
|
long extractLatency = System.currentTimeMillis() - startMillis;
|
||||||
if (isInsufficientBalance(body)) {
|
if (isInsufficientBalance(body)) {
|
||||||
@@ -172,7 +220,7 @@ public class UserApiSecretCheckService {
|
|||||||
long latency = System.currentTimeMillis() - startMillis;
|
long latency = System.currentTimeMillis() - startMillis;
|
||||||
log.warn("[user-secret][check] 代理转发失败 latency={}ms err={}", latency, ex.getMessage());
|
log.warn("[user-secret][check] 代理转发失败 latency={}ms err={}", latency, ex.getMessage());
|
||||||
return new CheckOutcome(STATUS_FAILED, CODE_NETWORK_ERROR,
|
return new CheckOutcome(STATUS_FAILED, CODE_NETWORK_ERROR,
|
||||||
"代理转发失败:" + rootCauseMessage(ex), (int) latency, true);
|
"代理转发失败,请检查代理地址或稍后重试", (int) latency, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,13 +316,41 @@ public class UserApiSecretCheckService {
|
|||||||
return outcome;
|
return outcome;
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
long latency = System.currentTimeMillis() - startMillis;
|
long latency = System.currentTimeMillis() - startMillis;
|
||||||
log.warn("[user-secret][check] {}探测异常 module={} latency={}ms err={}",
|
CheckOutcome outcome = classifyTransportFailure(ex, (int) latency, viaProxy);
|
||||||
viaText, module.key(), latency, ex.getMessage());
|
log.warn("[user-secret][check] {}探测异常 module={} latency={}ms code={} err={}",
|
||||||
return new CheckOutcome(STATUS_ERROR, CODE_NETWORK_ERROR,
|
viaText, module.key(), latency, outcome.code(), ex.getMessage());
|
||||||
"网络不可达:" + rootCauseMessage(ex), (int) latency, viaProxy);
|
return outcome;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 传输层失败分类(超时 / 网络不可达):用户可见文案固定中文,
|
||||||
|
* 具体异常类型与消息只进服务端日志,避免面板出现英文异常串。
|
||||||
|
* package-private 供单测覆盖。
|
||||||
|
*/
|
||||||
|
CheckOutcome classifyTransportFailure(Throwable error, int latencyMs, boolean viaProxy) {
|
||||||
|
if (isTimeout(error)) {
|
||||||
|
return new CheckOutcome(STATUS_ERROR, CODE_TIMEOUT, TIMEOUT_MESSAGE, latencyMs, viaProxy);
|
||||||
|
}
|
||||||
|
return new CheckOutcome(STATUS_ERROR, CODE_NETWORK_ERROR, NETWORK_ERROR_MESSAGE, latencyMs, viaProxy);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 超时判定:异常链上任一层为 JDK 请求超时或并发超时都算。 */
|
||||||
|
private boolean isTimeout(Throwable error) {
|
||||||
|
Throwable current = error;
|
||||||
|
while (current != null) {
|
||||||
|
if (current instanceof java.net.http.HttpTimeoutException
|
||||||
|
|| current instanceof java.util.concurrent.TimeoutException) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (current.getCause() == current) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
current = current.getCause();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/** 按 HTTP 状态码与响应体分类检测结果(package-private 供单测覆盖分类矩阵)。 */
|
/** 按 HTTP 状态码与响应体分类检测结果(package-private 供单测覆盖分类矩阵)。 */
|
||||||
CheckOutcome classify(int statusCode, String body, int latencyMs, boolean viaProxy) {
|
CheckOutcome classify(int statusCode, String body, int latencyMs, boolean viaProxy) {
|
||||||
String responseBody = body == null ? "" : body;
|
String responseBody = body == null ? "" : body;
|
||||||
@@ -346,6 +422,7 @@ public class UserApiSecretCheckService {
|
|||||||
return body.contains("预扣费") && body.contains("额度");
|
return body.contains("预扣费") && body.contains("额度");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 检测请求体:最简一次对话调用(max_tokens 极小),只验证密钥可用与链路连通。 */
|
||||||
private Map<String, Object> buildCheckBody(String model) {
|
private Map<String, Object> buildCheckBody(String model) {
|
||||||
Map<String, Object> body = new LinkedHashMap<>();
|
Map<String, Object> body = new LinkedHashMap<>();
|
||||||
body.put("model", model);
|
body.put("model", model);
|
||||||
@@ -354,7 +431,7 @@ public class UserApiSecretCheckService {
|
|||||||
List<Map<String, Object>> messages = new ArrayList<>(1);
|
List<Map<String, Object>> messages = new ArrayList<>(1);
|
||||||
Map<String, Object> userMessage = new LinkedHashMap<>();
|
Map<String, Object> userMessage = new LinkedHashMap<>();
|
||||||
userMessage.put("role", "user");
|
userMessage.put("role", "user");
|
||||||
userMessage.put("content", "ping");
|
userMessage.put("content", "你好");
|
||||||
messages.add(userMessage);
|
messages.add(userMessage);
|
||||||
body.put("messages", messages);
|
body.put("messages", messages);
|
||||||
return body;
|
return body;
|
||||||
@@ -427,18 +504,6 @@ public class UserApiSecretCheckService {
|
|||||||
return normalized.regionMatches(true, 0, "Bearer ", 0, 7) ? normalized.substring(7).trim() : normalized;
|
return normalized.regionMatches(true, 0, "Bearer ", 0, 7) ? normalized.substring(7).trim() : normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
private String rootCauseMessage(Throwable throwable) {
|
|
||||||
Throwable current = throwable;
|
|
||||||
while (current.getCause() != null && current.getCause() != current) {
|
|
||||||
current = current.getCause();
|
|
||||||
}
|
|
||||||
String message = current.getMessage();
|
|
||||||
if (message == null || message.isBlank()) {
|
|
||||||
return current.getClass().getSimpleName();
|
|
||||||
}
|
|
||||||
return current.getClass().getSimpleName() + ": " + message;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String firstNonBlank(String preferred, String fallback) {
|
private String firstNonBlank(String preferred, String fallback) {
|
||||||
return preferred == null || preferred.isBlank() ? fallback : preferred.trim();
|
return preferred == null || preferred.isBlank() ? fallback : preferred.trim();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -251,6 +251,7 @@ aiimage:
|
|||||||
llm-max-tokens: ${AIIMAGE_APPEARANCE_PATENT_LLM_MAX_TOKENS:64000}
|
llm-max-tokens: ${AIIMAGE_APPEARANCE_PATENT_LLM_MAX_TOKENS:64000}
|
||||||
llm-connect-timeout-millis: ${AIIMAGE_APPEARANCE_PATENT_LLM_CONNECT_TIMEOUT_MILLIS:10000}
|
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-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-batch-size: ${AIIMAGE_APPEARANCE_PATENT_LLM_BATCH_SIZE:10}
|
||||||
llm-row-concurrency: ${AIIMAGE_APPEARANCE_PATENT_LLM_ROW_CONCURRENCY:10}
|
llm-row-concurrency: ${AIIMAGE_APPEARANCE_PATENT_LLM_ROW_CONCURRENCY:10}
|
||||||
max-parse-rows: ${AIIMAGE_APPEARANCE_PATENT_MAX_PARSE_ROWS:50000}
|
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-max-tokens: ${AIIMAGE_SIMILAR_ASIN_LLM_MAX_TOKENS:64000}
|
||||||
llm-connect-timeout-millis: ${AIIMAGE_SIMILAR_ASIN_LLM_CONNECT_TIMEOUT_MILLIS:10000}
|
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-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-retry-times: ${AIIMAGE_SIMILAR_ASIN_LLM_RETRY_TIMES:3}
|
||||||
llm-row-concurrency: ${AIIMAGE_SIMILAR_ASIN_LLM_ROW_CONCURRENCY:5}
|
llm-row-concurrency: ${AIIMAGE_SIMILAR_ASIN_LLM_ROW_CONCURRENCY:5}
|
||||||
llm-image-download-timeout-seconds: ${AIIMAGE_SIMILAR_ASIN_LLM_IMAGE_DOWNLOAD_TIMEOUT_SECONDS:10}
|
llm-image-download-timeout-seconds: ${AIIMAGE_SIMILAR_ASIN_LLM_IMAGE_DOWNLOAD_TIMEOUT_SECONDS:10}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+79
@@ -31,6 +31,10 @@ class AppearancePatentLlmClientHttpTest {
|
|||||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
private final Map<String, AtomicInteger> callCounts = new ConcurrentHashMap<>();
|
private final Map<String, AtomicInteger> callCounts = new ConcurrentHashMap<>();
|
||||||
private final List<String> capturedBodies = new ArrayList<>();
|
private final List<String> capturedBodies = new ArrayList<>();
|
||||||
|
/** 纯文本(标题提取)请求计数:用于验证重试次数与「跳过外观」行为。 */
|
||||||
|
private final AtomicInteger textRequestCount = new AtomicInteger();
|
||||||
|
private volatile boolean failTextRequests = false;
|
||||||
|
private volatile long firstTextRequestDelayMillis = 0L;
|
||||||
|
|
||||||
private AppearancePatentLlmClient client;
|
private AppearancePatentLlmClient client;
|
||||||
|
|
||||||
@@ -60,6 +64,16 @@ class AppearancePatentLlmClientHttpTest {
|
|||||||
private void handleChat(HttpExchange exchange) throws IOException {
|
private void handleChat(HttpExchange exchange) throws IOException {
|
||||||
String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
|
String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
|
||||||
capturedBodies.add(body);
|
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 {
|
try {
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
Map<String, Object> request = objectMapper.readValue(body, Map.class);
|
Map<String, Object> 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<AppearancePatentProperties> 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) {
|
private boolean messagesBodyContains(HttpExchange exchange, String text) {
|
||||||
return capturedBodies.stream().anyMatch(b -> b.contains(text));
|
return capturedBodies.stream().anyMatch(b -> b.contains(text));
|
||||||
}
|
}
|
||||||
@@ -194,4 +234,43 @@ class AppearancePatentLlmClientHttpTest {
|
|||||||
assertThat(appearanceBody).contains("\"type\":\"json_object\"");
|
assertThat(appearanceBody).contains("\"type\":\"json_object\"");
|
||||||
assertThat(appearanceBody).contains("产品描述:普通数据线");
|
assertThat(appearanceBody).contains("产品描述:普通数据线");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 标题识别失败 → 本行必走回退(外观结果会被丢弃),外观请求不再发出:劣化窗口下少一轮无效重试。 */
|
||||||
|
@Test
|
||||||
|
void titleFailureSkipsAppearanceCallAndKeepsFallback() {
|
||||||
|
failTextRequests = true;
|
||||||
|
AppearancePatentLlmClient singleAttemptClient = buildClient(p -> {
|
||||||
|
p.setLlmRetryTimes(1);
|
||||||
|
p.setLlmFirstAttemptReadTimeoutMillis(5_000);
|
||||||
|
p.setLlmReadTimeoutMillis(5_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
List<AppearancePatentResultRowDto> 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<AppearancePatentResultRowDto> 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+61
@@ -137,6 +137,67 @@ class UserApiSecretCheckServiceTest {
|
|||||||
assertThat(outcome.code()).isEqualTo(UserApiSecretCheckService.CODE_SERVER_ERROR);
|
assertThat(outcome.code()).isEqualTo(UserApiSecretCheckService.CODE_SERVER_ERROR);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 传输层失败分类(2026-09-13:面板不再出现英文异常串,超时单列 code)=====
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void transportTimeoutIsClassifiedAsTimeoutWithChineseMessage() {
|
||||||
|
// 生产形态:ResourceAccessException 包装 HttpTimeoutException
|
||||||
|
Exception failure = new org.springframework.web.client.ResourceAccessException(
|
||||||
|
"I/O error on POST request", new java.net.http.HttpTimeoutException("request timed out"));
|
||||||
|
UserApiSecretCheckService.CheckOutcome outcome = service.classifyTransportFailure(failure, 15001, false);
|
||||||
|
|
||||||
|
assertThat(outcome.status()).isEqualTo(UserApiSecretCheckService.STATUS_ERROR);
|
||||||
|
assertThat(outcome.code()).isEqualTo(UserApiSecretCheckService.CODE_TIMEOUT);
|
||||||
|
assertThat(outcome.message()).isEqualTo(UserApiSecretCheckService.TIMEOUT_MESSAGE);
|
||||||
|
assertThat(outcome.message()).doesNotContain("HttpTimeoutException");
|
||||||
|
assertThat(outcome.latencyMs()).isEqualTo(15001);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void concurrentTimeoutExceptionAlsoClassifiedAsTimeout() {
|
||||||
|
UserApiSecretCheckService.CheckOutcome outcome = service.classifyTransportFailure(
|
||||||
|
new java.util.concurrent.TimeoutException("timed out"), 100, true);
|
||||||
|
|
||||||
|
assertThat(outcome.code()).isEqualTo(UserApiSecretCheckService.CODE_TIMEOUT);
|
||||||
|
assertThat(outcome.viaProxy()).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void transportConnectFailureIsClassifiedAsNetworkError() {
|
||||||
|
UserApiSecretCheckService.CheckOutcome outcome = service.classifyTransportFailure(
|
||||||
|
new java.net.ConnectException("Connection refused"), 300, true);
|
||||||
|
|
||||||
|
assertThat(outcome.status()).isEqualTo(UserApiSecretCheckService.STATUS_ERROR);
|
||||||
|
assertThat(outcome.code()).isEqualTo(UserApiSecretCheckService.CODE_NETWORK_ERROR);
|
||||||
|
assertThat(outcome.message()).isEqualTo(UserApiSecretCheckService.NETWORK_ERROR_MESSAGE);
|
||||||
|
assertThat(outcome.viaProxy()).isTrue();
|
||||||
|
assertThat(outcome.message()).doesNotContain("ConnectException");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void transportNestedTimeoutInsideGenericIOExceptionIsTimeout() {
|
||||||
|
Exception failure = new java.io.IOException("boom", new java.util.concurrent.TimeoutException("timed out"));
|
||||||
|
UserApiSecretCheckService.CheckOutcome outcome = service.classifyTransportFailure(failure, 200, false);
|
||||||
|
|
||||||
|
assertThat(outcome.code()).isEqualTo(UserApiSecretCheckService.CODE_TIMEOUT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 只有传输层失败值得重试;HTTP 层的 401/429/5xx 重试无意义。 */
|
||||||
|
@Test
|
||||||
|
void onlyTransportFailuresAreRetryable() {
|
||||||
|
assertThat(UserApiSecretCheckService.isTransportFailure(outcome("error", UserApiSecretCheckService.CODE_TIMEOUT))).isTrue();
|
||||||
|
assertThat(UserApiSecretCheckService.isTransportFailure(outcome("error", UserApiSecretCheckService.CODE_NETWORK_ERROR))).isTrue();
|
||||||
|
assertThat(UserApiSecretCheckService.isTransportFailure(outcome("error", UserApiSecretCheckService.CODE_RATE_LIMITED))).isFalse();
|
||||||
|
assertThat(UserApiSecretCheckService.isTransportFailure(outcome("error", UserApiSecretCheckService.CODE_SERVER_ERROR))).isFalse();
|
||||||
|
assertThat(UserApiSecretCheckService.isTransportFailure(outcome("failed", UserApiSecretCheckService.CODE_INVALID_KEY))).isFalse();
|
||||||
|
assertThat(UserApiSecretCheckService.isTransportFailure(outcome("passed", UserApiSecretCheckService.CODE_OK))).isFalse();
|
||||||
|
assertThat(UserApiSecretCheckService.isTransportFailure(null)).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static UserApiSecretCheckService.CheckOutcome outcome(String status, String code) {
|
||||||
|
return new UserApiSecretCheckService.CheckOutcome(status, code, "msg", 100, false);
|
||||||
|
}
|
||||||
|
|
||||||
// ===== 代理配置探测(提取链接语义;2026-09-13 修复「直连自家站点假通过」)=====
|
// ===== 代理配置探测(提取链接语义;2026-09-13 修复「直连自家站点假通过」)=====
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -71,11 +71,13 @@ export function deleteMyApiSecret(moduleKey: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** value 非空时检测输入值(不落库);为空时检测服务端已存密钥并把结果落库。 */
|
/** value 非空时检测输入值(不落库);为空时检测服务端已存密钥并把结果落库。 */
|
||||||
|
/** 检测连通性:服务端最多「首查 + 传输层失败重试一次」,最坏约 31s,故单独放宽超时。 */
|
||||||
export function checkMyApiSecret(moduleKey: string, value?: string) {
|
export function checkMyApiSecret(moduleKey: string, value?: string) {
|
||||||
return unwrapJavaResponse(
|
return unwrapJavaResponse(
|
||||||
post<JavaApiResponse<UserApiSecretCheckResult>, { value?: string }>(
|
post<JavaApiResponse<UserApiSecretCheckResult>, { value?: string }>(
|
||||||
buildJavaUrl(API_ENDPOINTS.userSecret.check.replace('{moduleKey}', encodeURIComponent(moduleKey))),
|
buildJavaUrl(API_ENDPOINTS.userSecret.check.replace('{moduleKey}', encodeURIComponent(moduleKey))),
|
||||||
{ value },
|
{ value },
|
||||||
|
{ timeout: 60000 },
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
:disabled="busy || moduleStates[module.moduleKey].checking"
|
:disabled="busy || moduleStates[module.moduleKey].checking"
|
||||||
@click="runCheck(module.moduleKey as ApiSecretModuleKey)"
|
@click="runCheck(module.moduleKey as ApiSecretModuleKey)"
|
||||||
>
|
>
|
||||||
{{ moduleStates[module.moduleKey].checking ? '检测中...' : (moduleStates[module.moduleKey].input.trim() ? '检测输入值' : '检测已存密钥') }}
|
{{ moduleStates[module.moduleKey].checking ? '检测中...' : (moduleStates[module.moduleKey].input.trim() ? '检测输入值(未保存)' : '检测配置密钥') }}
|
||||||
</button>
|
</button>
|
||||||
<span class="check-result" :class="resultClassOf(module.moduleKey)">
|
<span class="check-result" :class="resultClassOf(module.moduleKey)">
|
||||||
{{ statusTextOf(module.moduleKey) }}
|
{{ statusTextOf(module.moduleKey) }}
|
||||||
@@ -93,7 +93,7 @@
|
|||||||
:disabled="!proxyReady || busy || proxyChecking"
|
:disabled="!proxyReady || busy || proxyChecking"
|
||||||
@click="runProxyCheck"
|
@click="runProxyCheck"
|
||||||
>
|
>
|
||||||
{{ proxyChecking ? '检测中...' : (proxyUrl.trim() && proxyDirty ? '检测输入地址' : '检测已存代理') }}
|
{{ proxyChecking ? '检测中...' : (proxyUrl.trim() && proxyDirty ? '检测输入地址(未保存)' : '检测配置代理') }}
|
||||||
</button>
|
</button>
|
||||||
<span class="check-result" :class="proxyResultClass">
|
<span class="check-result" :class="proxyResultClass">
|
||||||
{{ proxyStatusText }}
|
{{ proxyStatusText }}
|
||||||
@@ -147,13 +147,15 @@ type ModuleState = {
|
|||||||
checking: boolean
|
checking: boolean
|
||||||
result: UserApiSecretCheckResult | null
|
result: UserApiSecretCheckResult | null
|
||||||
error: string
|
error: string
|
||||||
|
/** 最近一次检测的来源:saved=密钥配置里已保存的密钥,input=输入框里的未保存值。 */
|
||||||
|
lastCheckSource: 'saved' | 'input' | ''
|
||||||
}
|
}
|
||||||
|
|
||||||
const modules = ref(listApiSecretModules())
|
const modules = ref(listApiSecretModules())
|
||||||
const moduleStates = reactive<Record<string, ModuleState>>({})
|
const moduleStates = reactive<Record<string, ModuleState>>({})
|
||||||
// 首次渲染即需可读:按模块清单预初始化状态(后续 refreshSnapshots 兜底补齐)
|
// 首次渲染即需可读:按模块清单预初始化状态(后续 refreshSnapshots 兜底补齐)
|
||||||
for (const module of modules.value) {
|
for (const module of modules.value) {
|
||||||
moduleStates[module.moduleKey] = { input: '', checking: false, result: null, error: '' }
|
moduleStates[module.moduleKey] = { input: '', checking: false, result: null, error: '', lastCheckSource: '' }
|
||||||
}
|
}
|
||||||
const snapshots = ref<Record<string, ApiSecretSnapshot>>({})
|
const snapshots = ref<Record<string, ApiSecretSnapshot>>({})
|
||||||
const busy = ref(false)
|
const busy = ref(false)
|
||||||
@@ -240,7 +242,7 @@ const balanceText = computed(() => {
|
|||||||
|
|
||||||
function ensureModuleState(moduleKey: string) {
|
function ensureModuleState(moduleKey: string) {
|
||||||
if (!moduleStates[moduleKey]) {
|
if (!moduleStates[moduleKey]) {
|
||||||
moduleStates[moduleKey] = { input: '', checking: false, result: null, error: '' }
|
moduleStates[moduleKey] = { input: '', checking: false, result: null, error: '', lastCheckSource: '' }
|
||||||
}
|
}
|
||||||
return moduleStates[moduleKey]
|
return moduleStates[moduleKey]
|
||||||
}
|
}
|
||||||
@@ -288,13 +290,18 @@ function formatTime(millis: number | null) {
|
|||||||
|
|
||||||
function statusTextOf(moduleKey: string) {
|
function statusTextOf(moduleKey: string) {
|
||||||
const state = ensureModuleState(moduleKey)
|
const state = ensureModuleState(moduleKey)
|
||||||
if (state.checking) return '正在检测...'
|
if (state.checking) return state.input.trim() ? '正在检测输入值...' : '正在检测配置密钥...'
|
||||||
if (state.result) {
|
if (state.result) {
|
||||||
const latency = state.result.checkLatencyMs != null ? `(${state.result.checkLatencyMs}ms)` : ''
|
const latency = state.result.checkLatencyMs != null ? `(${state.result.checkLatencyMs}ms)` : ''
|
||||||
const suffix = state.result.viaProxy === true ? ' · 经代理' : ''
|
const suffix = state.result.viaProxy === true ? ' · 经代理' : ''
|
||||||
if (state.result.checkStatus === 'passed') return `检测通过${latency}${suffix}`
|
const snapshot = snapshotOf(moduleKey)
|
||||||
if (state.result.checkCode === 'insufficient_balance') return `欠费:${state.result.checkMessage || '代理服务商余额不足'}${suffix}`
|
// 明确展示本次检测的对象:输入值(未保存)或该用户密钥配置里保存的密钥(脱敏)
|
||||||
return `${state.result.checkMessage || '检测失败'}${suffix}`
|
const prefix = state.lastCheckSource === 'input'
|
||||||
|
? '输入值(未保存):'
|
||||||
|
: `配置密钥${snapshot.masked ? ` ${snapshot.masked}` : ''}:`
|
||||||
|
if (state.result.checkStatus === 'passed') return `${prefix}检测通过${latency}${suffix}`
|
||||||
|
if (state.result.checkCode === 'insufficient_balance') return `${prefix}欠费:${state.result.checkMessage || '代理服务商余额不足'}${suffix}`
|
||||||
|
return `${prefix}${state.result.checkMessage || '检测失败'}${suffix}`
|
||||||
}
|
}
|
||||||
if (state.error) return state.error
|
if (state.error) return state.error
|
||||||
const snapshot = snapshotOf(moduleKey)
|
const snapshot = snapshotOf(moduleKey)
|
||||||
@@ -302,7 +309,8 @@ function statusTextOf(moduleKey: string) {
|
|||||||
const status = CHECK_STATUS_TEXT[snapshot.checkStatus] || '已保存'
|
const status = CHECK_STATUS_TEXT[snapshot.checkStatus] || '已保存'
|
||||||
const time = formatTime(snapshot.checkedAt)
|
const time = formatTime(snapshot.checkedAt)
|
||||||
const message = snapshot.checkStatus === 'unknown' ? '' : `:${snapshot.checkMessage || ''}`
|
const message = snapshot.checkStatus === 'unknown' ? '' : `:${snapshot.checkMessage || ''}`
|
||||||
return `${status}${message}${time ? ` · ${time}` : ''}`
|
const masked = snapshot.masked ? `配置密钥 ${snapshot.masked} · ` : ''
|
||||||
|
return `${masked}${status}${message}${time ? ` · ${time}` : ''}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function resultClassOf(moduleKey: string) {
|
function resultClassOf(moduleKey: string) {
|
||||||
@@ -322,7 +330,9 @@ async function runCheck(moduleKey: ApiSecretModuleKey) {
|
|||||||
state.error = ''
|
state.error = ''
|
||||||
state.result = null
|
state.result = null
|
||||||
const inputValue = state.input.trim()
|
const inputValue = state.input.trim()
|
||||||
|
state.lastCheckSource = inputValue ? 'input' : 'saved'
|
||||||
try {
|
try {
|
||||||
|
// 不传 override 时服务端检测的是当前登录用户在密钥配置里保存的密钥(按 uid 绑定)
|
||||||
const result = await checkApiSecret(moduleKey, inputValue || undefined)
|
const result = await checkApiSecret(moduleKey, inputValue || undefined)
|
||||||
state.result = result
|
state.result = result
|
||||||
if (result.checkStatus === 'failed') {
|
if (result.checkStatus === 'failed') {
|
||||||
@@ -330,7 +340,7 @@ async function runCheck(moduleKey: ApiSecretModuleKey) {
|
|||||||
} else if (result.checkStatus === 'error') {
|
} else if (result.checkStatus === 'error') {
|
||||||
ElMessage.warning(result.checkMessage || '暂时无法判定密钥有效性')
|
ElMessage.warning(result.checkMessage || '暂时无法判定密钥有效性')
|
||||||
} else {
|
} else {
|
||||||
ElMessage.success(inputValue ? '输入值检测通过(未保存)' : '密钥检测通过')
|
ElMessage.success(inputValue ? '输入值检测通过(未保存)' : '配置密钥检测通过')
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
state.error = error instanceof Error ? error.message : '检测失败'
|
state.error = error instanceof Error ? error.message : '检测失败'
|
||||||
|
|||||||
Reference in New Issue
Block a user