diff --git a/mq-starter-idempotent-redis/src/main/java/com/njcn/mq/idempotent/redis/MqIdempotentRedisAutoConfiguration.java b/mq-starter-idempotent-redis/src/main/java/com/njcn/mq/idempotent/redis/MqIdempotentRedisAutoConfiguration.java index 876690f..a9496fc 100644 --- a/mq-starter-idempotent-redis/src/main/java/com/njcn/mq/idempotent/redis/MqIdempotentRedisAutoConfiguration.java +++ b/mq-starter-idempotent-redis/src/main/java/com/njcn/mq/idempotent/redis/MqIdempotentRedisAutoConfiguration.java @@ -2,6 +2,7 @@ package com.njcn.mq.idempotent.redis; import com.njcn.mq.autoconfig.MqCoreAutoConfiguration; import com.njcn.mq.spi.MqIdempotentStore; +import lombok.extern.slf4j.Slf4j; import org.springframework.boot.autoconfigure.AutoConfigureAfter; import org.springframework.boot.autoconfigure.AutoConfigureBefore; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; @@ -10,10 +11,16 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration; import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.context.properties.bind.Binder; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; import org.springframework.data.redis.core.StringRedisTemplate; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + /** * 消费去重 store 自动装配(driver 中立)。 * @@ -24,7 +31,12 @@ import org.springframework.data.redis.core.StringRedisTemplate; * classpath 有 lettuce 时 Spring Boot 会用 localhost:6379 默认值自动造出 StringRedisTemplate, * 因此 @ConditionalOnBean 几乎恒真,它不代表 Redis 已正确配置;真正的安全网是懒连接 + * core 仅在 idempotent=true 时才调用 store(未开去重时 bean 造了也无人调用,零副作用)。 + * + *

旧键兼容:mq.redis-stream.idempotent.* 已中立化为 mq.idempotent.*。用 Binder 逐键回退 + * (而非 env.getProperty 精确键,后者会漏 relaxed binding 变体):中立键显式设置→中立;否则旧键 + * 显式设置→旧键值+弃用告警;否则默认。 */ +@Slf4j @Configuration @AutoConfigureAfter(RedisAutoConfiguration.class) @AutoConfigureBefore(MqCoreAutoConfiguration.class) @@ -33,12 +45,42 @@ import org.springframework.data.redis.core.StringRedisTemplate; @EnableConfigurationProperties(MqIdempotentProperties.class) public class MqIdempotentRedisAutoConfiguration { + private static final String NEUTRAL_PREFIX = "mq.idempotent."; + private static final String LEGACY_PREFIX = "mq.redis-stream.idempotent."; + @Bean @ConditionalOnBean(StringRedisTemplate.class) @ConditionalOnMissingBean(MqIdempotentStore.class) - public MqIdempotentStore mqIdempotentStore(StringRedisTemplate redis, MqIdempotentProperties props) { - return new RedisMqIdempotentStore(redis, props.getKeyPrefix(), - props.getProcessingTtl().getSeconds(), props.getSuccessTtl().getSeconds(), - props.getFailTtl().getSeconds()); + public MqIdempotentStore mqIdempotentStore(StringRedisTemplate redis, + MqIdempotentProperties props, + Environment environment) { + Binder binder = Binder.get(environment); + List usedLegacyKeys = new ArrayList<>(); + String keyPrefix = resolve(binder, "key-prefix", String.class, props.getKeyPrefix(), usedLegacyKeys); + Duration processingTtl = resolve(binder, "processing-ttl", Duration.class, props.getProcessingTtl(), usedLegacyKeys); + Duration successTtl = resolve(binder, "success-ttl", Duration.class, props.getSuccessTtl(), usedLegacyKeys); + Duration failTtl = resolve(binder, "fail-ttl", Duration.class, props.getFailTtl(), usedLegacyKeys); + if (!usedLegacyKeys.isEmpty()) { + log.warn("[mq] 检测到已弃用配置 mq.redis-stream.idempotent.*,请迁移到 mq.idempotent.*;本次已按旧键取值:{}", usedLegacyKeys); + } + return new RedisMqIdempotentStore(redis, keyPrefix, + processingTtl.getSeconds(), successTtl.getSeconds(), failTtl.getSeconds()); + } + + /** + * 单字段回退:中立键显式设置则以中立为准(props 已按中立键绑定,字段值即中立值); + * 否则旧键显式设置则用旧键值并记入弃用清单;否则用默认(同样取 props 字段值)。 + */ + private static T resolve(Binder binder, String field, Class type, + T neutralOrDefault, List usedLegacyKeys) { + if (binder.bind(NEUTRAL_PREFIX + field, type).isBound()) { + return neutralOrDefault; + } + T legacy = binder.bind(LEGACY_PREFIX + field, type).orElse(null); + if (legacy != null) { + usedLegacyKeys.add(LEGACY_PREFIX + field); + return legacy; + } + return neutralOrDefault; } } diff --git a/mq-starter-idempotent-redis/src/test/java/com/njcn/mq/idempotent/redis/MqIdempotentLegacyKeyCompatTest.java b/mq-starter-idempotent-redis/src/test/java/com/njcn/mq/idempotent/redis/MqIdempotentLegacyKeyCompatTest.java new file mode 100644 index 0000000..4b633ac --- /dev/null +++ b/mq-starter-idempotent-redis/src/test/java/com/njcn/mq/idempotent/redis/MqIdempotentLegacyKeyCompatTest.java @@ -0,0 +1,81 @@ +package com.njcn.mq.idempotent.redis; + +import com.njcn.mq.spi.MqIdempotentStore; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +import java.time.Duration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +/** + * 旧键 mq.redis-stream.idempotent.* 向后兼容(Binder 逐键回退)。 + * + *

规则:中立键 mq.idempotent.* 显式设置则以中立为准;否则旧键显式设置则用旧键值并打一条 + * 聚合弃用 WARN;否则用默认。store 字段私有(类内容不改),故通过 mock Redis 的行为断言验证生效值。 + */ +@ExtendWith(OutputCaptureExtension.class) +class MqIdempotentLegacyKeyCompatTest { + + @SuppressWarnings("unchecked") + private final ValueOperations ops = mock(ValueOperations.class); + private final StringRedisTemplate redis = mock(StringRedisTemplate.class); + + private ApplicationContextRunner runner() { + when(redis.opsForValue()).thenReturn(ops); + return new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(MqIdempotentRedisAutoConfiguration.class)) + .withBean(StringRedisTemplate.class, () -> redis); + } + + @Test + void legacyKeysOnly_takeEffect_andLogDeprecationWarn(CapturedOutput output) { + runner().withPropertyValues( + "mq.redis-stream.idempotent.key-prefix=legacy:idem:", + "mq.redis-stream.idempotent.processing-ttl=15s") + .run(ctx -> { + MqIdempotentStore store = ctx.getBean(MqIdempotentStore.class); + store.tryAcquire("k"); + // 旧键前缀 + 旧键 processing TTL 均生效 + verify(ops).setIfAbsent("legacy:idem:k", "processing", Duration.ofSeconds(15)); + // 弃用告警必须点名旧前缀,给使用者迁移线索 + assertThat(output).contains("mq.redis-stream.idempotent"); + }); + } + + @Test + void neutralKeyWins_whenBothSet_noWarnForOverriddenLegacy(CapturedOutput output) { + runner().withPropertyValues( + "mq.idempotent.processing-ttl=20s", + "mq.redis-stream.idempotent.processing-ttl=15s") + .run(ctx -> { + MqIdempotentStore store = ctx.getBean(MqIdempotentStore.class); + store.tryAcquire("k"); + // 中立键优先;key-prefix 未设,用默认 mq:idem: + verify(ops).setIfAbsent("mq:idem:k", "processing", Duration.ofSeconds(20)); + // 没有任何字段实际落到旧键 → 不打弃用告警 + assertThat(output).doesNotContain("mq.redis-stream.idempotent"); + }); + } + + @Test + void successAndFailTtl_alsoFallBackToLegacy() { + runner().withPropertyValues( + "mq.redis-stream.idempotent.success-ttl=111s", + "mq.redis-stream.idempotent.fail-ttl=222s") + .run(ctx -> { + MqIdempotentStore store = ctx.getBean(MqIdempotentStore.class); + store.markSuccess("k"); + verify(ops).set("mq:idem:k", "success", Duration.ofSeconds(111)); + store.markFail("k"); + verify(ops).set("mq:idem:k", "fail", Duration.ofSeconds(222)); + }); + } +}