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