diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretCheckScheduler.java b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretCheckScheduler.java index 09c0f577..4cfe3b5c 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretCheckScheduler.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretCheckScheduler.java @@ -10,8 +10,10 @@ import org.springframework.stereotype.Service; import java.time.Duration; /** - * 用户密钥每日连通性巡检:默认每天 04:30(Asia/Shanghai)跑一轮, - * 双实例经 Redis 分布式锁互斥;单轮受条数与时间预算约束,超出顺延下一轮。 + * 用户密钥连通性巡检:默认每隔一天 04:30(Asia/Shanghai)跑一轮 + * (代理模块每次巡检真实提取一次,按次计费;2026-09-14 成本审查后由 + * 每日降为隔日),双实例经 Redis 分布式锁互斥;单轮受条数与时间预算 + * 约束,超出顺延下一轮。 */ @Service @Slf4j @@ -24,7 +26,7 @@ public class UserApiSecretCheckScheduler { private final DistributedJobLockService distributedJobLockService; private final UserSecretProperties properties; - @Scheduled(cron = "${aiimage.user-secret.check-cron:0 30 4 * * *}", zone = "Asia/Shanghai") + @Scheduled(cron = "${aiimage.user-secret.check-cron:0 30 4 */2 * *}", zone = "Asia/Shanghai") public void dailyCheck() { if (!properties.isCheckEnabled()) { log.info("[user-secret] 定时巡检已关闭,跳过本轮"); 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 30fa4dd3..f629ad74 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 @@ -78,6 +78,11 @@ public class UserApiSecretService implements UserSecretCleanupPort { private static final String VERIFIED_KEY_PREFIX = "aiimage:user-secret-verified"; /** 暂存有效期:覆盖「填写 → 检测 → 保存」的正常间隔,过期即要求重新检测。 */ private static final Duration VERIFIED_TTL = Duration.ofMinutes(30); + /** 「重复检测」新鲜期键前缀:窗口内对相同配置的再次检测直接复用上次结果—— + * 代理模块每次真实探测=一次提取计费,连点/手滑不应重复扣费(2026-09-14 成本审查)。 */ + private static final String FRESH_CHECK_KEY_PREFIX = "aiimage:user-secret-check-fresh"; + /** 新鲜期时长:覆盖连点/误触窗口;只缓存「通过」结果,失败允许立即重试。 */ + private static final Duration FRESH_CHECK_TTL = Duration.ofSeconds(90); /** 代理值支持两种形态:静态代理地址 http://[user:pass@]host:port,或供应商代理提取链接(无显式端口)。 */ private static final String PROXY_FORMAT_HINT = "代理地址格式不正确,请填写 http://host:port 形式的代理地址,或代理服务商的提取链接(https://...)"; @@ -212,7 +217,16 @@ public class UserApiSecretService implements UserSecretCleanupPort { } else if (module == UserSecretModule.PROXY) { validateProxyValue(plainKey); } - UserApiSecretCheckService.CheckOutcome outcome = checkService.probe(module, plainKey); + UserApiSecretCheckService.CheckOutcome outcome = reuseFreshOutcome(userId, module, plainKey); + if (outcome != null) { + log.info("[user-secret] 新鲜期内重复检测相同配置,复用上次结果(代理按次计费,避免连点重复提取)userId={} module={}", + userId, module.key()); + } else { + outcome = checkService.probe(module, plainKey); + if (UserApiSecretCheckService.STATUS_PASSED.equals(outcome.status())) { + rememberFreshOutcome(userId, module, plainKey, outcome); + } + } UserApiSecretCheckResultVo vo = toCheckResult(module, outcome); if (persist) { applyCheckOutcome(userId, module.key(), outcome); @@ -743,6 +757,53 @@ public class UserApiSecretService implements UserSecretCleanupPort { } } + /** 「重复检测」新鲜期键:按登录用户 + 模块隔离。 */ + private String freshCheckKey(Long userId, UserSecretModule module) { + return FRESH_CHECK_KEY_PREFIX + ":" + userId + ":" + module.key(); + } + + /** 读取新鲜期结果:同值且窗口内返回上次 outcome,否则 null(走真实探测)。 */ + private UserApiSecretCheckService.CheckOutcome reuseFreshOutcome(Long userId, UserSecretModule module, String plainValue) { + String hash = fingerprint(userId, module, plainValue); + if (hash.isEmpty()) { + return null; + } + try { + String raw = stringRedisTemplate.opsForValue().get(freshCheckKey(userId, module)); + if (raw == null || raw.isBlank()) { + return null; + } + PendingVerification record = objectMapper.readValue(raw, PendingVerification.class); + if (!hash.equals(record.hash())) { + return null; + } + return new UserApiSecretCheckService.CheckOutcome( + record.status(), record.code(), record.message(), record.latencyMs(), false); + } catch (Exception ex) { + log.warn("[user-secret] 读取检测新鲜期结果失败(按未命中处理)userId={} module={} err={}", + userId, module.key(), ex.getMessage()); + return null; + } + } + + /** 暂存「通过」的检测结果供新鲜期内重复检测复用;失败不暂存(允许立即重试)。 */ + private void rememberFreshOutcome(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(freshCheckKey(userId, module), payload, FRESH_CHECK_TTL); + } catch (Exception ex) { + log.warn("[user-secret] 暂存检测新鲜期结果失败(不影响本次检测)userId={} module={} err={}", + userId, module.key(), ex.getMessage()); + } + } + /** * 保存时沿用暂存结果:仅当保存值与最近一次「输入值检测」的值完全一致才落库该结果 * (passed/failed/error 一并沿用,页面据此如实展示),改过值或从未检测则维持未检测。 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 a38cf7b4..d76a1d5b 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 @@ -183,6 +183,45 @@ class UserApiSecretServiceTest { assertThat(existing.getCheckedAt()).isNull(); } + /** 连点/手滑:新鲜期内对同一值重复检测直接复用上次结果,不重复真实探测(代理每次探测=一次提取计费)。 */ + @Test + void repeatedCheckWithinFreshWindowReusesResult() { + UserApiSecretService service = newService(); + when(checkService.probe(any(), anyString())).thenReturn(outcome("passed", "ok", "代理可用", 120)); + + var first = service.check(7L, "proxy", "https://api.jikip.com/ip-get?num=1&key=abc"); + var second = service.check(7L, "proxy", "https://api.jikip.com/ip-get?num=1&key=abc"); + + verify(checkService, times(1)).probe(any(), anyString()); + assertThat(second.getCheckStatus()).isEqualTo("passed"); + assertThat(second.getCheckStatus()).isEqualTo(first.getCheckStatus()); + } + + /** 改了值就必须真实探测:新鲜期只认「同一个值」。 */ + @Test + void repeatedCheckProbesAgainWhenValueChanged() { + UserApiSecretService service = newService(); + when(checkService.probe(any(), anyString())).thenReturn(outcome("passed", "ok", "正常", 100)); + + service.check(7L, "proxy", "https://api.jikip.com/ip-get?num=1&key=aaa"); + service.check(7L, "proxy", "https://api.jikip.com/ip-get?num=1&key=bbb"); + + verify(checkService, times(2)).probe(any(), anyString()); + } + + /** 失败结果不进新鲜期缓存:余额不足充值后应能立即重检。 */ + @Test + void failedCheckIsNotReusedWithinFreshWindow() { + UserApiSecretService service = newService(); + when(checkService.probe(any(), anyString())) + .thenReturn(outcome("failed", "insufficient_balance", "代理服务商余额不足,请充值后重试", 80)); + + service.check(7L, "proxy", "https://api.jikip.com/ip-get?num=1&key=abc"); + service.check(7L, "proxy", "https://api.jikip.com/ip-get?num=1&key=abc"); + + verify(checkService, times(2)).probe(any(), anyString()); + } + private UserApiSecretEntity existingRow(String cipher) { UserApiSecretEntity existing = new UserApiSecretEntity(); existing.setId(5L); diff --git a/frontend-vue/src/shared/client-changelog.ts b/frontend-vue/src/shared/client-changelog.ts index f6961490..2e8781fa 100644 --- a/frontend-vue/src/shared/client-changelog.ts +++ b/frontend-vue/src/shared/client-changelog.ts @@ -24,6 +24,14 @@ export interface ClientChangelogEntry { /** 更新日志数据(新版本在前;发版时在数组最前追加一条) */ export const CLIENT_CHANGELOG: ClientChangelogEntry[] = [ + { + version: '4.0.16', + date: '2026-09-14', + items: [ + '品牌检测更省代理:线路被限流时不再反复更换,减少不必要的消耗', + '代理线路在有效期内被更充分地复用,减少重复提取次数', + ], + }, { version: '4.0.15', date: '2026-09-14',