From 07b4ebe9833032e39b50a3a4191411eec3f9eaae 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 19:43:22 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E6=88=90=E6=9C=AC):=20=E5=AF=86=E9=92=A5?= =?UTF-8?q?=E6=A3=80=E6=B5=8B=E9=98=B2=E6=8A=96=20+=20=E5=B7=A1=E6=A3=80?= =?UTF-8?q?=E9=99=8D=E9=A2=91=E9=9A=94=E6=97=A5=20+=20=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=AB=AF=204.0.16=20=E6=9B=B4=E6=96=B0=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 用户密钥「检测」90 秒新鲜期:同值重复检测复用上次通过结果, 代理提取不再因连点/手滑重复扣费(只缓存 passed,失败允许立即重试) - 连通性巡检由每日降为隔日(cron 0 30 4 */2 * *,双实例锁不变) - changelog 追加 4.0.16 条目 --- .../service/UserApiSecretCheckScheduler.java | 8 ++- .../service/UserApiSecretService.java | 63 ++++++++++++++++++- .../service/UserApiSecretServiceTest.java | 39 ++++++++++++ frontend-vue/src/shared/client-changelog.ts | 8 +++ 4 files changed, 114 insertions(+), 4 deletions(-) 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',