feat(成本): 密钥检测防抖 + 巡检降频隔日 + 客户端 4.0.16 更新日志

- 用户密钥「检测」90 秒新鲜期:同值重复检测复用上次通过结果,
  代理提取不再因连点/手滑重复扣费(只缓存 passed,失败允许立即重试)
- 连通性巡检由每日降为隔日(cron 0 30 4 */2 * *,双实例锁不变)
- changelog 追加 4.0.16 条目
This commit is contained in:
2026-09-14 19:43:22 +08:00
parent 0b2b9303d0
commit 07b4ebe983
4 changed files with 114 additions and 4 deletions
@@ -10,8 +10,10 @@ import org.springframework.stereotype.Service;
import java.time.Duration;
/**
* 用户密钥每日连通性巡检:默认每天 04:30Asia/Shanghai)跑一轮
* 双实例经 Redis 分布式锁互斥;单轮受条数与时间预算约束,超出顺延下一轮。
* 用户密钥连通性巡检:默认每隔一天 04:30Asia/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] 定时巡检已关闭,跳过本轮");
@@ -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 一并沿用,页面据此如实展示),改过值或从未检测则维持未检测。
@@ -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);
@@ -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',