From f558f6303eacd1f21b9ee4e544b8db5a3931f605 Mon Sep 17 00:00:00 2001 From: hongawen <83944980@qq.com> Date: Fri, 10 Jul 2026 03:12:06 +0100 Subject: [PATCH] =?UTF-8?q?feat(mq-starter-idempotent-redis):=20mq.idempot?= =?UTF-8?q?ent.*=20=E4=B8=AD=E7=AB=8B=E9=85=8D=E7=BD=AE=20+=20=E4=B8=8D?= =?UTF-8?q?=E6=8C=89=20mq.type=20=E9=97=A8=E6=8E=A7=E7=9A=84=E5=8E=BB?= =?UTF-8?q?=E9=87=8D=20store=20=E8=87=AA=E5=8A=A8=E8=A3=85=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../redis/MqIdempotentProperties.java | 42 +++++++++ .../MqIdempotentRedisAutoConfiguration.java | 44 ++++++++++ .../main/resources/META-INF/spring.factories | 2 + .../redis/MqIdempotentPropertiesTest.java | 24 ++++++ ...qIdempotentRedisAutoConfigurationTest.java | 85 +++++++++++++++++++ 5 files changed, 197 insertions(+) create mode 100644 mq-starter-idempotent-redis/src/main/java/com/njcn/mq/idempotent/redis/MqIdempotentProperties.java create mode 100644 mq-starter-idempotent-redis/src/main/java/com/njcn/mq/idempotent/redis/MqIdempotentRedisAutoConfiguration.java create mode 100644 mq-starter-idempotent-redis/src/main/resources/META-INF/spring.factories create mode 100644 mq-starter-idempotent-redis/src/test/java/com/njcn/mq/idempotent/redis/MqIdempotentPropertiesTest.java create mode 100644 mq-starter-idempotent-redis/src/test/java/com/njcn/mq/idempotent/redis/MqIdempotentRedisAutoConfigurationTest.java diff --git a/mq-starter-idempotent-redis/src/main/java/com/njcn/mq/idempotent/redis/MqIdempotentProperties.java b/mq-starter-idempotent-redis/src/main/java/com/njcn/mq/idempotent/redis/MqIdempotentProperties.java new file mode 100644 index 0000000..2415449 --- /dev/null +++ b/mq-starter-idempotent-redis/src/main/java/com/njcn/mq/idempotent/redis/MqIdempotentProperties.java @@ -0,0 +1,42 @@ +package com.njcn.mq.idempotent.redis; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.time.Duration; + +/** + * 消费去重配置(driver 中立,前缀 {@code mq.idempotent})。 + * + *

自 mq.redis-stream.idempotent.* 中立化迁移而来,默认值与迁移前完全一致,不改变现有行为。 + * 旧键仍向后兼容(见 MqIdempotentRedisAutoConfiguration 的 Binder 回退),但会打弃用告警。 + * + *

+ * mq:
+ *   idempotent:
+ *     enabled: true                # 逃生开关:显式 false 关闭去重 store 装配
+ *     key-prefix: "mq:idem:"
+ *     processing-ttl: 60s          # 应大于单条业务处理时延
+ *     success-ttl: 300s            # 防重复消费窗口
+ *     fail-ttl: 300s               # 允许重新消费前的保留窗口
+ * 
+ */ +@Data +@ConfigurationProperties(prefix = "mq.idempotent") +public class MqIdempotentProperties { + + /** 逃生开关:显式 false 关闭去重 store 装配。默认开。 */ + private boolean enabled = true; + + /** 去重键前缀。 */ + private String keyPrefix = "mq:idem:"; + + /** processing 标记 TTL:应大于单条业务处理时延。 */ + private Duration processingTtl = Duration.ofSeconds(60); + + /** success 标记 TTL:防重复消费窗口。 */ + private Duration successTtl = Duration.ofSeconds(300); + + /** fail 标记 TTL:允许重新消费前的保留窗口。 */ + private Duration failTtl = Duration.ofSeconds(300); +} 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 new file mode 100644 index 0000000..876690f --- /dev/null +++ b/mq-starter-idempotent-redis/src/main/java/com/njcn/mq/idempotent/redis/MqIdempotentRedisAutoConfiguration.java @@ -0,0 +1,44 @@ +package com.njcn.mq.idempotent.redis; + +import com.njcn.mq.autoconfig.MqCoreAutoConfiguration; +import com.njcn.mq.spi.MqIdempotentStore; +import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.boot.autoconfigure.AutoConfigureBefore; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +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.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.core.StringRedisTemplate; + +/** + * 消费去重 store 自动装配(driver 中立)。 + * + *

刻意不按 mq.type 门控,只按「Redis 在场」门控 —— rocketmq / redis-stream / 未来任何 + * driver 部署下,只要 classpath 有 spring-data-redis 且上下文有 StringRedisTemplate 就装配。 + * + *

启动安全约束:本装配与 store 构造均不得 eager 连接 / PING Redis(保持 lettuce 懒连接)。 + * classpath 有 lettuce 时 Spring Boot 会用 localhost:6379 默认值自动造出 StringRedisTemplate, + * 因此 @ConditionalOnBean 几乎恒真,它不代表 Redis 已正确配置;真正的安全网是懒连接 + + * core 仅在 idempotent=true 时才调用 store(未开去重时 bean 造了也无人调用,零副作用)。 + */ +@Configuration +@AutoConfigureAfter(RedisAutoConfiguration.class) +@AutoConfigureBefore(MqCoreAutoConfiguration.class) +@ConditionalOnClass(StringRedisTemplate.class) +@ConditionalOnProperty(name = "mq.idempotent.enabled", havingValue = "true", matchIfMissing = true) +@EnableConfigurationProperties(MqIdempotentProperties.class) +public class MqIdempotentRedisAutoConfiguration { + + @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()); + } +} diff --git a/mq-starter-idempotent-redis/src/main/resources/META-INF/spring.factories b/mq-starter-idempotent-redis/src/main/resources/META-INF/spring.factories new file mode 100644 index 0000000..264c6f3 --- /dev/null +++ b/mq-starter-idempotent-redis/src/main/resources/META-INF/spring.factories @@ -0,0 +1,2 @@ +org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ +com.njcn.mq.idempotent.redis.MqIdempotentRedisAutoConfiguration diff --git a/mq-starter-idempotent-redis/src/test/java/com/njcn/mq/idempotent/redis/MqIdempotentPropertiesTest.java b/mq-starter-idempotent-redis/src/test/java/com/njcn/mq/idempotent/redis/MqIdempotentPropertiesTest.java new file mode 100644 index 0000000..024d7d4 --- /dev/null +++ b/mq-starter-idempotent-redis/src/test/java/com/njcn/mq/idempotent/redis/MqIdempotentPropertiesTest.java @@ -0,0 +1,24 @@ +package com.njcn.mq.idempotent.redis; + +import org.junit.jupiter.api.Test; + +import java.time.Duration; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * {@link MqIdempotentProperties} 默认值守护:必须与迁移前 RedisStreamMqProperties.Idempotent + * 完全一致(processing 60s / success 300s / fail 300s / 前缀 mq:idem:),保证迁移不改变现有行为。 + */ +class MqIdempotentPropertiesTest { + + @Test + void defaults_matchLegacyRedisStreamIdempotentDefaults() { + MqIdempotentProperties p = new MqIdempotentProperties(); + assertTrue(p.isEnabled(), "去重装配默认开启,仅显式 false 关闭"); + assertEquals("mq:idem:", p.getKeyPrefix()); + assertEquals(Duration.ofSeconds(60), p.getProcessingTtl()); + assertEquals(Duration.ofSeconds(300), p.getSuccessTtl()); + assertEquals(Duration.ofSeconds(300), p.getFailTtl()); + } +} diff --git a/mq-starter-idempotent-redis/src/test/java/com/njcn/mq/idempotent/redis/MqIdempotentRedisAutoConfigurationTest.java b/mq-starter-idempotent-redis/src/test/java/com/njcn/mq/idempotent/redis/MqIdempotentRedisAutoConfigurationTest.java new file mode 100644 index 0000000..68b82a4 --- /dev/null +++ b/mq-starter-idempotent-redis/src/test/java/com/njcn/mq/idempotent/redis/MqIdempotentRedisAutoConfigurationTest.java @@ -0,0 +1,85 @@ +package com.njcn.mq.idempotent.redis; + +import com.njcn.mq.autoconfig.MqCoreAutoConfiguration; +import com.njcn.mq.spi.MqIdempotentStore; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.boot.autoconfigure.AutoConfigureBefore; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.core.io.support.SpringFactoriesLoader; +import org.springframework.data.redis.core.StringRedisTemplate; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +/** + * 装配条件矩阵(ApplicationContextRunner,不连真实 Redis): + * 关键契约是「不按 mq.type 门控、只按 Redis 在场门控」—— rocketmq 部署也能装出 store。 + */ +class MqIdempotentRedisAutoConfigurationTest { + + private final ApplicationContextRunner runner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(MqIdempotentRedisAutoConfiguration.class)); + + @Test + void withStringRedisTemplate_storeAutoConfigured_regardlessOfMqType() { + runner.withBean(StringRedisTemplate.class, () -> mock(StringRedisTemplate.class)) + // 故意设 mq.type=rocketmq:装配必须与 mq.type 无关(这正是 rocketmq 用上去重的关键) + .withPropertyValues("mq.type=rocketmq") + .run(ctx -> { + assertThat(ctx).hasSingleBean(MqIdempotentStore.class); + assertThat(ctx.getBean(MqIdempotentStore.class)).isInstanceOf(RedisMqIdempotentStore.class); + }); + } + + @Test + void withoutRedisBean_noStoreAndNoStartupFailure() { + runner.run(ctx -> { + assertThat(ctx).hasNotFailed(); + assertThat(ctx).doesNotHaveBean(MqIdempotentStore.class); + }); + } + + @Test + void enabledFalse_disablesStore() { + runner.withBean(StringRedisTemplate.class, () -> mock(StringRedisTemplate.class)) + .withPropertyValues("mq.idempotent.enabled=false") + .run(ctx -> assertThat(ctx).doesNotHaveBean(MqIdempotentStore.class)); + } + + @Test + void userProvidedStore_backsOff() { + MqIdempotentStore custom = mock(MqIdempotentStore.class); + runner.withBean(StringRedisTemplate.class, () -> mock(StringRedisTemplate.class)) + .withBean("customStore", MqIdempotentStore.class, () -> custom) + .run(ctx -> { + assertThat(ctx).hasSingleBean(MqIdempotentStore.class); + assertThat(ctx.getBean(MqIdempotentStore.class)).isSameAs(custom); + }); + } + + @Test + void declaresAutoConfigureOrderContract() { + // 真实 Spring Boot 按类名字母序处理 autoconfig:com.njcn.mq.autoconfig(core) 先于 + // com.njcn.mq.idempotent(本模块)。显式声明顺序契约,守护 spec §4.4 的装配语义。 + AutoConfigureBefore before = MqIdempotentRedisAutoConfiguration.class.getAnnotation(AutoConfigureBefore.class); + assertThat(before).as("必须声明 @AutoConfigureBefore(MqCoreAutoConfiguration)").isNotNull(); + assertThat(before.value()).contains(MqCoreAutoConfiguration.class); + + AutoConfigureAfter after = MqIdempotentRedisAutoConfiguration.class.getAnnotation(AutoConfigureAfter.class); + assertThat(after).as("必须声明 @AutoConfigureAfter(RedisAutoConfiguration),否则 @ConditionalOnBean(StringRedisTemplate) 求值时模板尚未注册").isNotNull(); + assertThat(after.value()).contains(RedisAutoConfiguration.class); + } + + @Test + void registeredInSpringFactories() { + List names = SpringFactoriesLoader.loadFactoryNames(EnableAutoConfiguration.class, + getClass().getClassLoader()); + assertThat(names).contains(MqIdempotentRedisAutoConfiguration.class.getName()); + } +}