fix(密钥配置): 保存沿用刚检测过的输入值结果,消除「三项检测通过却提示未检测」死循环

- 服务端:对「输入值(未保存)」的检测结果按 uid+模块+值指纹暂存 Redis(TTL 30min,
  Redis 异常降级为需重新检测,不阻断保存);保存同一个值时落库该结果
  (passed/failed/error 一并沿用),改过值或从未检测则维持未检测
- 前端:保存后清理本地「输入值(未保存)」绿字,展示统一走服务端快照,避免展示与门禁矛盾;
  门禁提示改列「模块名(掩码):未检测 / 检测失败:原因 / 未配置」,同名掩码也能分辨模块
- 测试:UserApiSecretServiceTest 补沿用 / 值不一致 / Redis 降级用例;
  新增前后端一致性守卫测试(保存后必须清检测结果、提示必须带模块名)
This commit is contained in:
2026-09-14 13:49:25 +08:00
parent 5ea52e5291
commit 8cab9d4bad
6 changed files with 321 additions and 16 deletions
@@ -49,7 +49,8 @@ public class UserApiSecretController {
}
@PutMapping("/{moduleKey}")
@Operation(summary = "保存密钥", description = "加密落库并重置检测状态为未检测,保存后客户端应立即触发一次检测。")
@Operation(summary = "保存密钥",
description = "加密落库并重置检测状态为未检测;若保存值与刚检测过的输入值一致,则沿用那次检测结果。")
public ApiResponse<UserApiSecretItemVo> 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<UserApiSecretCheckResultVo> check(
HttpServletRequest request,
@Parameter(description = "密钥模块 key", required = true) @PathVariable String moduleKey,
@@ -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<UserApiSecretEntity>()
.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) {
}
@@ -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<String, String> redisValues = new HashMap<>();
private ValueOperations<String, String> 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<String, String> mockValueOps() {
ValueOperations<String, String> 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();