diff --git a/admin-frontend-vue/src/api/user-secrets.ts b/admin-frontend-vue/src/api/user-secrets.ts index 3868e708..f9fa88e7 100644 --- a/admin-frontend-vue/src/api/user-secrets.ts +++ b/admin-frontend-vue/src/api/user-secrets.ts @@ -66,9 +66,10 @@ export async function fetchUserSecretList(params: UserSecretQuery): Promise(data) } -/** 立即检测该用户全部已配置项:POST /api/admin/user-secrets/{userId}/check */ +/** 立即检测该用户全部已配置项:POST /api/admin/user-secrets/{userId}/check + * 逐模块检测(每项最多「首查 + 传输层失败重试一次」,最坏约 31s/项),故放宽超时到 180s。 */ 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< Array<{ moduleKey: string diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretCheckService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretCheckService.java index a3dd0aa9..74f87a35 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretCheckService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretCheckService.java @@ -47,15 +47,22 @@ public class UserApiSecretCheckService { public static final String CODE_RATE_LIMITED = "rate_limited"; public static final String CODE_SERVER_ERROR = "server_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_INSUFFICIENT_BALANCE = "insufficient_balance"; /** 供应商欠费提示文案(前后端都按 code 识别展示)。 */ 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 int READ_TIMEOUT_MILLIS = 15_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 CHECK_MAX_TOKENS = 8; /** 代理探测目标:自家域名(http 无 CONNECT 依赖,兼容各类转发型代理)。 */ @@ -72,7 +79,14 @@ public class UserApiSecretCheckService { private volatile RestClient directClient; - /** 探测入口:代理模块走代理连通性探测,LLM 密钥模块优先经检测出口代理(若配置提取链接),代理网络不可达回退直连。 */ + /** + * 探测入口:代理模块走代理连通性探测,LLM 密钥模块优先经检测出口代理(若配置提取链接),代理网络不可达回退直连。 + * + *

2026-09-13:上游(ai.t8star.org)存在约 5%~10% 的单请求不响应(实测随机出现, + * 与协议、连接复用无关),检测只有一次机会时用户会看到「检测超时」。 + * 因此对最后一跳(直连)在传输层失败(超时/网络不可达)时重试一次; + * 代理模块不重试——jikip 按提取次数计费,重试会多扣一次。 + */ public CheckOutcome probe(UserSecretModule module, String plainApiKey) { if (module == UserSecretModule.PROXY) { return probeProxy(plainApiKey); @@ -86,10 +100,13 @@ public class UserApiSecretCheckService { } if (proxyUrl != null) { CheckOutcome viaProxy = probeOnce(module, plainApiKey, proxyUrl, true); - if (CODE_NETWORK_ERROR.equals(viaProxy.code())) { - log.warn("[user-secret][check] 经代理检测网络不可达 module={} proxy={},回退直连重试", - module.key(), proxyUrl); + if (isTransportFailure(viaProxy)) { + log.warn("[user-secret][check] 经代理检测失败 module={} proxy={} code={},回退直连重试", + module.key(), proxyUrl, viaProxy.code()); CheckOutcome direct = probeOnce(module, plainApiKey, null, false); + if (isTransportFailure(direct)) { + direct = retryOnce(module, plainApiKey, direct); + } return new CheckOutcome( direct.status(), direct.code(), @@ -99,7 +116,38 @@ public class UserApiSecretCheckService { } 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; log.warn("[user-secret][check] 代理提取接口不可达 latency={}ms err={}", latency, ex.getMessage()); return new CheckOutcome(STATUS_FAILED, CODE_NETWORK_ERROR, - "代理提取接口不可达:" + rootCauseMessage(ex), (int) latency, false); + "代理提取接口无法访问,请检查提取链接或稍后重试", (int) latency, false); } long extractLatency = System.currentTimeMillis() - startMillis; if (isInsufficientBalance(body)) { @@ -172,7 +220,7 @@ public class UserApiSecretCheckService { long latency = System.currentTimeMillis() - startMillis; log.warn("[user-secret][check] 代理转发失败 latency={}ms err={}", latency, ex.getMessage()); 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; } catch (Exception ex) { long latency = System.currentTimeMillis() - startMillis; - log.warn("[user-secret][check] {}探测异常 module={} latency={}ms err={}", - viaText, module.key(), latency, ex.getMessage()); - return new CheckOutcome(STATUS_ERROR, CODE_NETWORK_ERROR, - "网络不可达:" + rootCauseMessage(ex), (int) latency, viaProxy); + CheckOutcome outcome = classifyTransportFailure(ex, (int) latency, viaProxy); + log.warn("[user-secret][check] {}探测异常 module={} latency={}ms code={} err={}", + viaText, module.key(), latency, outcome.code(), ex.getMessage()); + 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 供单测覆盖分类矩阵)。 */ CheckOutcome classify(int statusCode, String body, int latencyMs, boolean viaProxy) { String responseBody = body == null ? "" : body; @@ -346,6 +422,7 @@ public class UserApiSecretCheckService { return body.contains("预扣费") && body.contains("额度"); } + /** 检测请求体:最简一次对话调用(max_tokens 极小),只验证密钥可用与链路连通。 */ private Map buildCheckBody(String model) { Map body = new LinkedHashMap<>(); body.put("model", model); @@ -354,7 +431,7 @@ public class UserApiSecretCheckService { List> messages = new ArrayList<>(1); Map userMessage = new LinkedHashMap<>(); userMessage.put("role", "user"); - userMessage.put("content", "ping"); + userMessage.put("content", "你好"); messages.add(userMessage); body.put("messages", messages); return body; @@ -427,18 +504,6 @@ public class UserApiSecretCheckService { 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) { return preferred == null || preferred.isBlank() ? fallback : preferred.trim(); } diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretCheckServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretCheckServiceTest.java index 98348312..f6de7064 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretCheckServiceTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretCheckServiceTest.java @@ -137,6 +137,67 @@ class UserApiSecretCheckServiceTest { 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 修复「直连自家站点假通过」)===== @Test diff --git a/frontend-vue/src/shared/api/types/modules/user-secret.ts b/frontend-vue/src/shared/api/types/modules/user-secret.ts index e75f9010..8253a037 100644 --- a/frontend-vue/src/shared/api/types/modules/user-secret.ts +++ b/frontend-vue/src/shared/api/types/modules/user-secret.ts @@ -71,11 +71,13 @@ export function deleteMyApiSecret(moduleKey: string) { } /** value 非空时检测输入值(不落库);为空时检测服务端已存密钥并把结果落库。 */ +/** 检测连通性:服务端最多「首查 + 传输层失败重试一次」,最坏约 31s,故单独放宽超时。 */ export function checkMyApiSecret(moduleKey: string, value?: string) { return unwrapJavaResponse( post, { value?: string }>( buildJavaUrl(API_ENDPOINTS.userSecret.check.replace('{moduleKey}', encodeURIComponent(moduleKey))), { value }, + { timeout: 60000 }, ), ) } diff --git a/frontend-vue/src/shared/components/ApiSecretSettingsPanel.vue b/frontend-vue/src/shared/components/ApiSecretSettingsPanel.vue index 5f4db3cf..5d886604 100644 --- a/frontend-vue/src/shared/components/ApiSecretSettingsPanel.vue +++ b/frontend-vue/src/shared/components/ApiSecretSettingsPanel.vue @@ -34,7 +34,7 @@ :disabled="busy || moduleStates[module.moduleKey].checking" @click="runCheck(module.moduleKey as ApiSecretModuleKey)" > - {{ moduleStates[module.moduleKey].checking ? '检测中...' : (moduleStates[module.moduleKey].input.trim() ? '检测输入值' : '检测已存密钥') }} + {{ moduleStates[module.moduleKey].checking ? '检测中...' : (moduleStates[module.moduleKey].input.trim() ? '检测输入值(未保存)' : '检测配置密钥') }} {{ statusTextOf(module.moduleKey) }} @@ -93,7 +93,7 @@ :disabled="!proxyReady || busy || proxyChecking" @click="runProxyCheck" > - {{ proxyChecking ? '检测中...' : (proxyUrl.trim() && proxyDirty ? '检测输入地址' : '检测已存代理') }} + {{ proxyChecking ? '检测中...' : (proxyUrl.trim() && proxyDirty ? '检测输入地址(未保存)' : '检测配置代理') }} {{ proxyStatusText }} @@ -147,13 +147,15 @@ type ModuleState = { checking: boolean result: UserApiSecretCheckResult | null error: string + /** 最近一次检测的来源:saved=密钥配置里已保存的密钥,input=输入框里的未保存值。 */ + lastCheckSource: 'saved' | 'input' | '' } const modules = ref(listApiSecretModules()) const moduleStates = reactive>({}) // 首次渲染即需可读:按模块清单预初始化状态(后续 refreshSnapshots 兜底补齐) 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>({}) const busy = ref(false) @@ -240,7 +242,7 @@ const balanceText = computed(() => { function ensureModuleState(moduleKey: string) { if (!moduleStates[moduleKey]) { - moduleStates[moduleKey] = { input: '', checking: false, result: null, error: '' } + moduleStates[moduleKey] = { input: '', checking: false, result: null, error: '', lastCheckSource: '' } } return moduleStates[moduleKey] } @@ -288,13 +290,18 @@ function formatTime(millis: number | null) { function statusTextOf(moduleKey: string) { const state = ensureModuleState(moduleKey) - if (state.checking) return '正在检测...' + if (state.checking) return state.input.trim() ? '正在检测输入值...' : '正在检测配置密钥...' if (state.result) { const latency = state.result.checkLatencyMs != null ? `(${state.result.checkLatencyMs}ms)` : '' const suffix = state.result.viaProxy === true ? ' · 经代理' : '' - if (state.result.checkStatus === 'passed') return `检测通过${latency}${suffix}` - if (state.result.checkCode === 'insufficient_balance') return `欠费:${state.result.checkMessage || '代理服务商余额不足'}${suffix}` - return `${state.result.checkMessage || '检测失败'}${suffix}` + const snapshot = snapshotOf(moduleKey) + // 明确展示本次检测的对象:输入值(未保存)或该用户密钥配置里保存的密钥(脱敏) + 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 const snapshot = snapshotOf(moduleKey) @@ -302,7 +309,8 @@ function statusTextOf(moduleKey: string) { const status = CHECK_STATUS_TEXT[snapshot.checkStatus] || '已保存' const time = formatTime(snapshot.checkedAt) 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) { @@ -322,7 +330,9 @@ async function runCheck(moduleKey: ApiSecretModuleKey) { state.error = '' state.result = null const inputValue = state.input.trim() + state.lastCheckSource = inputValue ? 'input' : 'saved' try { + // 不传 override 时服务端检测的是当前登录用户在密钥配置里保存的密钥(按 uid 绑定) const result = await checkApiSecret(moduleKey, inputValue || undefined) state.result = result if (result.checkStatus === 'failed') { @@ -330,7 +340,7 @@ async function runCheck(moduleKey: ApiSecretModuleKey) { } else if (result.checkStatus === 'error') { ElMessage.warning(result.checkMessage || '暂时无法判定密钥有效性') } else { - ElMessage.success(inputValue ? '输入值检测通过(未保存)' : '密钥检测通过') + ElMessage.success(inputValue ? '输入值检测通过(未保存)' : '配置密钥检测通过') } } catch (error) { state.error = error instanceof Error ? error.message : '检测失败'