From 8cab9d4bad3b32498aebb1531efb1857cdfd2b28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Mon, 14 Sep 2026 13:49:25 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E5=AF=86=E9=92=A5=E9=85=8D=E7=BD=AE):=20?= =?UTF-8?q?=E4=BF=9D=E5=AD=98=E6=B2=BF=E7=94=A8=E5=88=9A=E6=A3=80=E6=B5=8B?= =?UTF-8?q?=E8=BF=87=E7=9A=84=E8=BE=93=E5=85=A5=E5=80=BC=E7=BB=93=E6=9E=9C?= =?UTF-8?q?=EF=BC=8C=E6=B6=88=E9=99=A4=E3=80=8C=E4=B8=89=E9=A1=B9=E6=A3=80?= =?UTF-8?q?=E6=B5=8B=E9=80=9A=E8=BF=87=E5=8D=B4=E6=8F=90=E7=A4=BA=E6=9C=AA?= =?UTF-8?q?=E6=A3=80=E6=B5=8B=E3=80=8D=E6=AD=BB=E5=BE=AA=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 服务端:对「输入值(未保存)」的检测结果按 uid+模块+值指纹暂存 Redis(TTL 30min, Redis 异常降级为需重新检测,不阻断保存);保存同一个值时落库该结果 (passed/failed/error 一并沿用),改过值或从未检测则维持未检测 - 前端:保存后清理本地「输入值(未保存)」绿字,展示统一走服务端快照,避免展示与门禁矛盾; 门禁提示改列「模块名(掩码):未检测 / 检测失败:原因 / 未配置」,同名掩码也能分辨模块 - 测试:UserApiSecretServiceTest 补沿用 / 值不一致 / Redis 降级用例; 新增前后端一致性守卫测试(保存后必须清检测结果、提示必须带模块名) --- .../controller/UserApiSecretController.java | 6 +- .../service/UserApiSecretService.java | 106 +++++++++++++++- .../service/UserApiSecretServiceTest.java | 119 +++++++++++++++++- .../pages/setup/DesktopSecretSetupPage.vue | 30 ++++- .../components/ApiSecretSettingsPanel.vue | 17 ++- .../secret-setup-gate-consistency.test.ts | 59 +++++++++ 6 files changed, 321 insertions(+), 16 deletions(-) create mode 100644 frontend-vue/tests/secret-setup-gate-consistency.test.ts diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/controller/UserApiSecretController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/controller/UserApiSecretController.java index ccb64398..92d80291 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/controller/UserApiSecretController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/controller/UserApiSecretController.java @@ -49,7 +49,8 @@ public class UserApiSecretController { } @PutMapping("/{moduleKey}") - @Operation(summary = "保存密钥", description = "加密落库并重置检测状态为未检测,保存后客户端应立即触发一次检测。") + @Operation(summary = "保存密钥", + description = "加密落库并重置检测状态为未检测;若保存值与刚检测过的输入值一致,则沿用那次检测结果。") public ApiResponse save( HttpServletRequest request, @Parameter(description = "密钥模块 key", required = true) @PathVariable String moduleKey, @@ -70,7 +71,8 @@ public class UserApiSecretController { @PostMapping("/{moduleKey}/check") @Operation(summary = "检测密钥连通性", - description = "value 非空时检测输入值(不落库);为空时检测服务端已存密钥并把结果落库。") + description = "value 非空时检测输入值:结果暂不落库,但随后保存同一个值时会沿用;" + + "value 为空时检测服务端已存密钥并把结果落库。") public ApiResponse check( HttpServletRequest request, @Parameter(description = "密钥模块 key", required = true) @PathVariable String moduleKey, diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretService.java index 65dcccd0..30fa4dd3 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretService.java @@ -1,6 +1,7 @@ package com.nanri.aiimage.modules.usersecret.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.fasterxml.jackson.databind.ObjectMapper; import com.nanri.aiimage.common.exception.BusinessException; import com.nanri.aiimage.modules.admin.spi.UserSecretCleanupPort; import com.nanri.aiimage.common.security.ShopCredentialCryptoService; @@ -28,10 +29,13 @@ import com.nanri.aiimage.modules.usersecret.model.vo.UserApiSecretItemVo; import com.nanri.aiimage.modules.usersecret.support.UserSecretModule; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; import java.time.Duration; import java.time.LocalDate; import java.time.LocalDateTime; @@ -70,6 +74,10 @@ public class UserApiSecretService implements UserSecretCleanupPort { private static final String STATUS_UNKNOWN = "unknown"; private static final int MASK_MIN_LENGTH = 8; private static final int MESSAGE_MAX_LENGTH = 500; + /** 「输入值检测」结果暂存键前缀:保存同一个值时沿用该结果,避免用户检完还要再检一次。 */ + private static final String VERIFIED_KEY_PREFIX = "aiimage:user-secret-verified"; + /** 暂存有效期:覆盖「填写 → 检测 → 保存」的正常间隔,过期即要求重新检测。 */ + private static final Duration VERIFIED_TTL = Duration.ofMinutes(30); /** 代理值支持两种形态:静态代理地址 http://[user:pass@]host:port,或供应商代理提取链接(无显式端口)。 */ private static final String PROXY_FORMAT_HINT = "代理地址格式不正确,请填写 http://host:port 形式的代理地址,或代理服务商的提取链接(https://...)"; @@ -84,6 +92,8 @@ public class UserApiSecretService implements UserSecretCleanupPort { private final UserDataScopeSupport userDataScopeSupport; private final NotificationDispatchService notificationDispatchService; private final UserSecretProperties userSecretProperties; + private final StringRedisTemplate stringRedisTemplate; + private final ObjectMapper objectMapper; /** 当前用户密钥包:仅必填模块(代理为选配,不下发)+ 服务端下发的必填清单 + 完整性判定。 */ public UserApiSecretBundleVo bundle(Long userId) { @@ -100,7 +110,11 @@ public class UserApiSecretService implements UserSecretCleanupPort { return vo; } - /** 保存密钥:加密落库并重置检测状态为 unknown(保存后由客户端立即触发检测)。 */ + /** + * 保存密钥:加密落库并重置检测状态为 unknown; + * 若该值与用户刚检测过的输入值完全一致,则沿用那次检测结果(见 {@link #applyPendingVerification}), + * 其余情况仍由用户在保存后手动点「检测」确认。 + */ @Transactional public UserApiSecretItemVo save(Long userId, String moduleKey, String value) { requireUserId(userId); @@ -113,7 +127,8 @@ public class UserApiSecretService implements UserSecretCleanupPort { validateProxyValue(plainValue); } upsert(userId, module.key(), plainValue, SOURCE_CLIENT); - log.info("[user-secret] 密钥已保存 userId={} module={}", userId, module.key()); + boolean carried = applyPendingVerification(userId, module, plainValue); + log.info("[user-secret] 密钥已保存 userId={} module={} 沿用检测结果={}", userId, module.key(), carried); return toItem(module, selectOne(userId, module.key())); } @@ -173,7 +188,10 @@ public class UserApiSecretService implements UserSecretCleanupPort { } } - /** 检测:传 overrideValue 时只检测输入值不落库;否则检测已存值并落库。 */ + /** + * 检测:传 overrideValue 时只检测输入值,结果暂存(不落库),保存同一个值时可沿用; + * 不传时检测已存值并直接落库。 + */ public UserApiSecretCheckResultVo check(Long userId, String moduleKey, String overrideValue) { requireUserId(userId); UserSecretModule module = requireModule(moduleKey); @@ -199,6 +217,8 @@ public class UserApiSecretService implements UserSecretCleanupPort { if (persist) { applyCheckOutcome(userId, module.key(), outcome); vo.setCheckedAt(LocalDateTime.now()); + } else { + rememberPendingVerification(userId, module, plainKey, outcome); } log.info("[user-secret] 检测完成 userId={} module={} status={} code={} viaProxy={} latency={}ms persist={}", userId, module.key(), outcome.status(), outcome.code(), outcome.viaProxy(), outcome.latencyMs(), persist); @@ -679,6 +699,82 @@ public class UserApiSecretService implements UserSecretCleanupPort { } } + /** 「输入值检测」暂存键:按登录用户 + 模块隔离。 */ + private String verifiedKey(Long userId, UserSecretModule module) { + return VERIFIED_KEY_PREFIX + ":" + userId + ":" + module.key(); + } + + /** + * 检测值指纹:SHA-256(uid|module|明文)。用于判断「要保存的值」是否就是刚检测过的那个值, + * 只在 Redis 暂存里做比对,不落库、不下发。 + */ + private String fingerprint(Long userId, UserSecretModule module, String plainValue) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] bytes = digest.digest((userId + "|" + module.key() + "|" + plainValue).getBytes(StandardCharsets.UTF_8)); + StringBuilder hex = new StringBuilder(bytes.length * 2); + for (byte value : bytes) { + hex.append(Character.forDigit((value >> 4) & 0xF, 16)).append(Character.forDigit(value & 0xF, 16)); + } + return hex.toString(); + } catch (Exception ex) { + log.warn("[user-secret] 生成检测指纹失败 userId={} module={} err={}", userId, module.key(), ex.getMessage()); + return ""; + } + } + + /** 暂存「输入值」检测结果:TTL 内保存同一个值即沿用;暂存失败仅影响体验(用户需再检一次),不阻断检测。 */ + private void rememberPendingVerification(Long userId, UserSecretModule module, String plainValue, + UserApiSecretCheckService.CheckOutcome outcome) { + String hash = fingerprint(userId, module, plainValue); + if (hash.isEmpty()) { + return; + } + try { + String payload = objectMapper.writeValueAsString(new PendingVerification( + hash, outcome.status(), outcome.code(), + truncate(outcome.message(), MESSAGE_MAX_LENGTH), outcome.latencyMs())); + stringRedisTemplate.opsForValue().set(verifiedKey(userId, module), payload, VERIFIED_TTL); + log.info("[user-secret] 输入值检测结果已暂存 userId={} module={} status={}", + userId, module.key(), outcome.status()); + } catch (Exception ex) { + log.warn("[user-secret] 输入值检测结果暂存失败(保存后需重新检测)userId={} module={} err={}", + userId, module.key(), ex.getMessage()); + } + } + + /** + * 保存时沿用暂存结果:仅当保存值与最近一次「输入值检测」的值完全一致才落库该结果 + * (passed/failed/error 一并沿用,页面据此如实展示),改过值或从未检测则维持未检测。 + */ + private boolean applyPendingVerification(Long userId, UserSecretModule module, String plainValue) { + String hash = fingerprint(userId, module, plainValue); + if (hash.isEmpty()) { + return false; + } + try { + String raw = stringRedisTemplate.opsForValue().get(verifiedKey(userId, module)); + if (raw == null || raw.isBlank()) { + return false; + } + PendingVerification pending = objectMapper.readValue(raw, PendingVerification.class); + if (pending == null || !hash.equals(pending.hash())) { + log.info("[user-secret] 暂存检测结果与保存值不一致,按未检测处理 userId={} module={}", + userId, module.key()); + return false; + } + applyCheckOutcome(userId, module.key(), new UserApiSecretCheckService.CheckOutcome( + pending.status(), pending.code(), pending.message(), pending.latencyMs(), false)); + log.info("[user-secret] 保存沿用输入值检测结果 userId={} module={} status={}", + userId, module.key(), pending.status()); + return true; + } catch (Exception ex) { + log.warn("[user-secret] 沿用暂存检测结果失败(按未检测处理)userId={} module={} err={}", + userId, module.key(), ex.getMessage()); + return false; + } + } + private UserApiSecretEntity selectOne(Long userId, String moduleKey) { return userApiSecretMapper.selectOne(new LambdaQueryWrapper() .eq(UserApiSecretEntity::getUserId, userId) @@ -888,6 +984,10 @@ public class UserApiSecretService implements UserSecretCleanupPort { return value != null && !value.trim().isEmpty(); } + /** 「输入值检测」暂存内容(仅 Redis,不落库、不下发)。 */ + record PendingVerification(String hash, String status, String code, String message, Integer latencyMs) { + } + /** 巡检统计。 */ public record CheckSummary(int checked, int passed, int failed, int errors, int skipped) { } diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretServiceTest.java index 2b1116b0..a38cf7b4 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretServiceTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretServiceTest.java @@ -1,5 +1,6 @@ package com.nanri.aiimage.modules.usersecret.service; +import com.fasterxml.jackson.databind.ObjectMapper; import com.nanri.aiimage.common.security.ShopCredentialCryptoService; import com.nanri.aiimage.config.UserSecretProperties; import com.nanri.aiimage.common.security.AdminAuthSupport; @@ -16,16 +17,24 @@ import com.nanri.aiimage.modules.usersecret.model.dto.UserApiSecretMigrateReques import com.nanri.aiimage.modules.usersecret.model.entity.UserApiSecretEntity; import com.nanri.aiimage.modules.usersecret.model.vo.AdminUserSecretModuleVo; import com.nanri.aiimage.modules.usersecret.model.vo.UserApiSecretBundleVo; +import com.nanri.aiimage.modules.usersecret.support.UserSecretModule; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import java.time.Duration; +import java.util.HashMap; import java.util.List; +import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import com.nanri.aiimage.common.mapper.ShopManageGroupMapper; @@ -43,6 +52,11 @@ class UserApiSecretServiceTest { private final UserDataScopeSupport userDataScopeSupport = mock(UserDataScopeSupport.class); private final NotificationDispatchService notificationDispatchService = mock(NotificationDispatchService.class); private final UserSecretProperties userSecretProperties = new UserSecretProperties(); + private final StringRedisTemplate stringRedisTemplate = mock(StringRedisTemplate.class); + private final ObjectMapper objectMapper = new ObjectMapper(); + /** 内存版 Redis 值操作:让「检测暂存 → 保存读取」的 JSON 往返在单测里真实走一遍。 */ + private final Map redisValues = new HashMap<>(); + private ValueOperations valueOps; private UserApiSecretService newService() { when(crypto.encrypt(anyString())).thenAnswer(inv -> "enc:" + inv.getArgument(0, String.class)); @@ -52,9 +66,24 @@ class UserApiSecretServiceTest { }); // 默认按主管(admin)判定;超管用例里单独改打桩。 when(adminAuthSupport.currentRole(any())).thenReturn("admin"); + redisValues.clear(); + valueOps = mockValueOps(); + when(stringRedisTemplate.opsForValue()).thenReturn(valueOps); return new UserApiSecretService( mapper, crypto, checkService, jikipProxyClient, adminUserMapper, adminAuthSupport, adminGroupMapper, - userDataScopeSupport, notificationDispatchService, userSecretProperties); + userDataScopeSupport, notificationDispatchService, userSecretProperties, + stringRedisTemplate, objectMapper); + } + + @SuppressWarnings("unchecked") + private ValueOperations mockValueOps() { + ValueOperations ops = mock(ValueOperations.class); + doAnswer(inv -> { + redisValues.put(inv.getArgument(0, String.class), inv.getArgument(1, String.class)); + return null; + }).when(ops).set(anyString(), anyString(), any(Duration.class)); + when(ops.get(anyString())).thenAnswer(inv -> redisValues.get(inv.getArgument(0, String.class))); + return ops; } @Test @@ -79,6 +108,94 @@ class UserApiSecretServiceTest { assertThat(updated.getCheckCode()).isEmpty(); } + /** + * 用户在输入框里对待保存的值做的检测不能丢: + * 保存同一个值时沿用那次检测结果,页面不需要再检一次。 + */ + @Test + void saveCarriesPassedCheckOfSameInputValue() { + UserApiSecretService service = newService(); + UserApiSecretEntity existing = existingRow("enc:old-key"); + when(mapper.selectOne(any())).thenReturn(existing); + when(checkService.probe(any(), anyString())).thenReturn(outcome("passed", "ok", "正常", 1678)); + + service.check(7L, "similar-asin", "sk-input"); + // 输入值检测只暂存,不落库 + verify(mapper, never()).updateById(any(UserApiSecretEntity.class)); + + service.save(7L, "similar-asin", "sk-input"); + + // 先按保存语义重置为未检测,再沿用暂存结果落回 passed + verify(mapper, times(2)).updateById(any(UserApiSecretEntity.class)); + assertThat(existing.getSecretValue()).isEqualTo("enc:sk-input"); + assertThat(existing.getCheckStatus()).isEqualTo("passed"); + assertThat(existing.getCheckCode()).isEqualTo("ok"); + assertThat(existing.getCheckLatencyMs()).isEqualTo(1678); + assertThat(existing.getCheckedAt()).isNotNull(); + } + + /** 检测后改过值:暂存的是另一个值的结果,不得沿用,必须重新检测。 */ + @Test + void saveKeepsUnknownWhenSavedValueDiffersFromCheckedOne() { + UserApiSecretService service = newService(); + UserApiSecretEntity existing = existingRow("enc:old-key"); + when(mapper.selectOne(any())).thenReturn(existing); + when(checkService.probe(any(), anyString())).thenReturn(outcome("passed", "ok", "正常", 900)); + + service.check(7L, "similar-asin", "sk-input"); + service.save(7L, "similar-asin", "sk-changed"); + + verify(mapper, times(1)).updateById(any(UserApiSecretEntity.class)); + assertThat(existing.getSecretValue()).isEqualTo("enc:sk-changed"); + assertThat(existing.getCheckStatus()).isEqualTo("unknown"); + assertThat(existing.getCheckedAt()).isNull(); + } + + /** 沿用失败也要如实回落到「未检测」:检测不通过同样沿用,页面据此展示失败原因。 */ + @Test + void saveCarriesFailedCheckOfSameInputValue() { + UserApiSecretService service = newService(); + UserApiSecretEntity existing = existingRow("enc:old-key"); + when(mapper.selectOne(any())).thenReturn(existing); + when(checkService.probe(any(), anyString())).thenReturn(outcome("failed", "invalid_key", "密钥无效", 300)); + + service.check(7L, "similar-asin", "sk-bad"); + service.save(7L, "similar-asin", "sk-bad"); + + assertThat(existing.getCheckStatus()).isEqualTo("failed"); + assertThat(existing.getCheckMessage()).isEqualTo("密钥无效"); + } + + /** Redis 不可用时降级:检测与保存照常成功,状态保持未检测(只影响体验,不阻断落库)。 */ + @Test + void saveDegradesQuietlyWhenVerifiedCacheUnavailable() { + UserApiSecretService service = newService(); + UserApiSecretEntity existing = existingRow("enc:old-key"); + when(mapper.selectOne(any())).thenReturn(existing); + when(checkService.probe(any(), anyString())).thenReturn(outcome("passed", "ok", "正常", 500)); + when(valueOps.get(anyString())).thenThrow(new IllegalStateException("redis down")); + + assertThat(service.check(7L, "similar-asin", "sk-input").getCheckStatus()).isEqualTo("passed"); + + service.save(7L, "similar-asin", "sk-input"); + + assertThat(existing.getCheckStatus()).isEqualTo("unknown"); + assertThat(existing.getCheckedAt()).isNull(); + } + + private UserApiSecretEntity existingRow(String cipher) { + UserApiSecretEntity existing = new UserApiSecretEntity(); + existing.setId(5L); + existing.setUserId(7L); + existing.setModuleKey("similar-asin"); + existing.setSecretValue(cipher); + return existing; + } + + private UserApiSecretCheckService.CheckOutcome outcome(String status, String code, String message, Integer latency) { + return new UserApiSecretCheckService.CheckOutcome(status, code, message, latency, false); + } + @Test void findPlainValueReturnsEmptyWhenDecryptFails() { UserApiSecretService service = newService(); diff --git a/frontend-vue/src/pages/setup/DesktopSecretSetupPage.vue b/frontend-vue/src/pages/setup/DesktopSecretSetupPage.vue index 37987f5a..13606301 100644 --- a/frontend-vue/src/pages/setup/DesktopSecretSetupPage.vue +++ b/frontend-vue/src/pages/setup/DesktopSecretSetupPage.vue @@ -14,7 +14,7 @@
完成密钥配置后即可使用
密钥保存在服务端并绑定当前账号,换设备登录后自动同步。 - 请填写以下密钥并保存,检测通过后即可进入工具台。 + 请填写以下密钥,点「检测」确认可用后保存;全部检测通过即可进入工具台。
@@ -48,7 +48,7 @@ const route = useRoute() const panelRef = ref | null>(null) const submitting = ref(false) const username = ref('') -const hint = ref('填写密钥并保存后,请点击对应模块的「检测」按钮确认可用,全部检测通过后即可进入工具台。') +const hint = ref('填写密钥后请先点「检测」确认可用再保存(检测通过的输入值保存时会沿用同一结果),全部检测通过后即可进入工具台。') onMounted(() => { try { @@ -58,7 +58,10 @@ onMounted(() => { } }) -/** 提交:保存后只校验检测状态;检测一律由用户手动点击「检测」触发(2026-09-13 起不再自动补检)。 */ +/** + * 提交:保存后只校验检测状态;检测一律由用户手动点「检测」触发(服务端会沿用刚检测过的同一个输入值, + * 因此「检测 → 保存并进入」一次即可通过;改过值或从未检测的模块仍需重新检测)。 + */ async function submit() { if (submitting.value) return const panel = panelRef.value @@ -71,9 +74,24 @@ async function submit() { const state = await loadApiSecrets({ force: true }) if (state === 'incomplete') { const failed = listApiSecretModules() - .map((module) => getStoredApiSecretSnapshot(module.moduleKey as ApiSecretModuleKey)) - .filter((snapshot) => !snapshot.exists || (snapshot.checkStatus !== 'passed' && snapshot.checkStatus !== 'error')) - hint.value = `以下密钥尚未通过检测:${failed.map((item) => item.masked || '未配置').join('、')},请点击上方的「检测」按钮逐项确认后重试。` + .map((module) => ({ + module, + snapshot: getStoredApiSecretSnapshot(module.moduleKey as ApiSecretModuleKey), + })) + .filter(({ snapshot }) => !snapshot.exists || (snapshot.checkStatus !== 'passed' && snapshot.checkStatus !== 'error')) + // 逐项列出「模块名(掩码)+ 未通过原因」:同一密钥填进多个模块时也能分清是哪个模块 + const detail = failed.map(({ module, snapshot }) => { + const masked = snapshot.masked || '未配置' + if (!snapshot.exists) { + return `${module.moduleLabel}(${masked})尚未配置` + } + if (snapshot.checkStatus === 'failed') { + const reason = snapshot.checkMessage ? `:${snapshot.checkMessage.slice(0, 40)}` : '' + return `${module.moduleLabel}(${masked})检测失败${reason}` + } + return `${module.moduleLabel}(${masked})尚未检测` + }) + hint.value = `以下密钥尚未通过检测:${detail.join(';')}。请点击上方对应模块的「检测」按钮确认后重试。` ElMessage.warning('密钥尚未全部检测通过,请点击「检测」按钮逐项确认') return } diff --git a/frontend-vue/src/shared/components/ApiSecretSettingsPanel.vue b/frontend-vue/src/shared/components/ApiSecretSettingsPanel.vue index 5d886604..db05c971 100644 --- a/frontend-vue/src/shared/components/ApiSecretSettingsPanel.vue +++ b/frontend-vue/src/shared/components/ApiSecretSettingsPanel.vue @@ -465,8 +465,8 @@ async function loadBalance() { /** * 保存:逐模块保存非空输入(空输入保留服务端原值),代理仅在改动时保存。 - * 保存后不再自动检测(2026-09-13 起检测一律由用户手动点击触发), - * 保存会把检测状态重置为未检测,需用户点「检测」确认可用性。 + * 检测一律由用户手动点击触发;保存时服务端会沿用「刚检测过的同一个输入值」的结果, + * 其余情况(改过值 / 从未检测)保持未检测,需用户点「检测」确认可用性。 */ async function saveAll(options: { requireAll?: boolean } = {}): Promise { if (busy.value || proxyLoading.value) return false @@ -486,8 +486,14 @@ async function saveAll(options: { requireAll?: boolean } = {}): Promise try { for (const module of pendingModules) { const moduleKey = module.moduleKey as ApiSecretModuleKey - await saveApiSecret(moduleKey, ensureModuleState(module.moduleKey).input.trim()) - ensureModuleState(module.moduleKey).input = '' + const state = ensureModuleState(module.moduleKey) + await saveApiSecret(moduleKey, state.input.trim()) + state.input = '' + // 输入值已保存:本地那次「检测输入值」的结果不再代表当前配置,清掉后展示统一走服务端快照 + // (保存前检测过的同一个值,服务端会沿用那次结果,快照里直接是「检测通过」) + state.result = null + state.lastCheckSource = '' + state.error = '' } secretsSaved = true refreshSnapshots() @@ -499,6 +505,9 @@ async function saveAll(options: { requireAll?: boolean } = {}): Promise await api.save_config(userProxyPatch(nextProxyUrl, proxyMode.value) as DesktopConfigUpdate) proxyUrl.value = nextProxyUrl proxyDirty.value = false + // 同上:地址已保存,本地「未保存」的检测文案不再适用 + proxyCheckResult.value = null + proxyCheckError.value = '' await syncProxyToServer(nextProxyUrl) } diff --git a/frontend-vue/tests/secret-setup-gate-consistency.test.ts b/frontend-vue/tests/secret-setup-gate-consistency.test.ts new file mode 100644 index 00000000..b080dde3 --- /dev/null +++ b/frontend-vue/tests/secret-setup-gate-consistency.test.ts @@ -0,0 +1,59 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { resolve, dirname } from 'node:path' + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const read = (rel: string) => readFileSync(resolve(repoRoot, rel), 'utf-8') + +/** 截取函数体:先按括号配平跳过参数列表,再按花括号配平取函数体(模板字面量里的 ${} 也是配对的,不影响计数)。 */ +function functionBody(source: string, signature: string): string { + const start = source.indexOf(signature) + assert.ok(start >= 0, `未找到 ${signature}`) + const openParen = source.indexOf('(', start) + let parenDepth = 0 + let cursor = openParen + for (; cursor < source.length; cursor += 1) { + if (source[cursor] === '(') parenDepth += 1 + else if (source[cursor] === ')') { + parenDepth -= 1 + if (parenDepth === 0) break + } + } + const openBrace = source.indexOf('{', cursor) + let depth = 0 + for (let i = openBrace; i < source.length; i += 1) { + if (source[i] === '{') depth += 1 + else if (source[i] === '}') { + depth -= 1 + if (depth === 0) return source.slice(openBrace, i + 1) + } + } + throw new Error('函数体未闭合') +} + +/** + * 密钥卡片保存后必须清掉本地「检测输入值(未保存)」的结果: + * 保存后输入框已清空,那份绿字不再代表当前配置,继续展示就会出现 + * 「三项都显示检测通过、页面却提示尚未通过检测」的自相矛盾状态(2026-09-14 修复)。 + */ +test('密钥面板保存后清理过期检测结果', () => { + const body = functionBody(read('src/shared/components/ApiSecretSettingsPanel.vue'), 'async function saveAll(') + const saveIndex = body.indexOf('await saveApiSecret(') + assert.ok(saveIndex >= 0, 'saveAll 应逐个保存模块输入值') + const afterSave = body.slice(saveIndex) + assert.match(afterSave, /state\.input = ''/, '保存后应清空输入框') + assert.match(afterSave, /state\.result = null/, '保存后应清掉本地检测结果') + assert.match(afterSave, /state\.lastCheckSource = ''/, '保存后应重置检测来源标记') +}) + +/** + * 门禁拦截提示必须带模块名:同一个密钥填进多个模块时, + * 只列脱敏值会出现「sk-P****9BgQ、sk-P****9BgQ」这种分不清是哪个模块的提示。 + */ +test('密钥门禁提示按模块名列出未通过项', () => { + const body = functionBody(read('src/pages/setup/DesktopSecretSetupPage.vue'), 'async function submit(') + assert.match(body, /module\.moduleLabel/, '未通过提示应包含模块名') + assert.match(body, /snapshot\.masked/, '未通过提示应包含脱敏值') +})