feat(成本): 密钥检测防抖 + 巡检降频隔日 + 客户端 4.0.16 更新日志
- 用户密钥「检测」90 秒新鲜期:同值重复检测复用上次通过结果, 代理提取不再因连点/手滑重复扣费(只缓存 passed,失败允许立即重试) - 连通性巡检由每日降为隔日(cron 0 30 4 */2 * *,双实例锁不变) - changelog 追加 4.0.16 条目
This commit is contained in:
+5
-3
@@ -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] 定时巡检已关闭,跳过本轮");
|
||||
|
||||
+62
-1
@@ -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 一并沿用,页面据此如实展示),改过值或从未检测则维持未检测。
|
||||
|
||||
+39
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user