diff --git a/src/main/java/com/yunhai/platform/gateway/route/RouteChangeBroadcaster.java b/src/main/java/com/yunhai/platform/gateway/route/RouteChangeBroadcaster.java index 7d44f2f..b5a0374 100644 --- a/src/main/java/com/yunhai/platform/gateway/route/RouteChangeBroadcaster.java +++ b/src/main/java/com/yunhai/platform/gateway/route/RouteChangeBroadcaster.java @@ -4,10 +4,10 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.DisposableBean; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.data.redis.connection.Message; import org.springframework.data.redis.connection.MessageListener; -import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.listener.ChannelTopic; import org.springframework.data.redis.listener.RedisMessageListenerContainer; @@ -41,19 +41,26 @@ public class RouteChangeBroadcaster implements MessageListener, DisposableBean { @Autowired private RouteRegistry routeRegistry; + /** + * 广播通道走应用主库模板(bean 名 {@code stringRedisTemplate}),而非 appauth starter 的 + * 密钥专用 {@code signKeyStringRedisTemplate}(那个直连 db0 专供 {@code sign:app:*})。 + * 两者同类型,不显式 {@code @Qualifier} 会因注入歧义启动失败;选错则发布与订阅落在不同 db, + * 广播静默失效(比启动失败更糟——看不出问题)。 + */ @Autowired + @Qualifier("stringRedisTemplate") private StringRedisTemplate redisTemplate; - @Autowired - private RedisConnectionFactory connectionFactory; - private RedisMessageListenerContainer container; @PostConstruct void subscribe() { try { RedisMessageListenerContainer c = new RedisMessageListenerContainer(); - c.setConnectionFactory(connectionFactory); + // 订阅连接直接取用上面 template 的连接工厂,保证「发布」与「订阅」必然同库: + // 另注一个 RedisConnectionFactory 会引入同类型多 bean 歧义,且可能被解析到 + // appauth 的 db0 工厂上与主库模板不匹配。 + c.setConnectionFactory(redisTemplate.getConnectionFactory()); c.addMessageListener(this, new ChannelTopic(CHANNEL)); c.afterPropertiesSet(); // 连接不可用时异步重连,不阻塞启动 container = c; diff --git a/src/test/java/com/yunhai/platform/gateway/route/RouteChangeBroadcasterWiringTest.java b/src/test/java/com/yunhai/platform/gateway/route/RouteChangeBroadcasterWiringTest.java new file mode 100644 index 0000000..2b37519 --- /dev/null +++ b/src/test/java/com/yunhai/platform/gateway/route/RouteChangeBroadcasterWiringTest.java @@ -0,0 +1,102 @@ +package com.yunhai.platform.gateway.route; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor; +import org.springframework.beans.factory.annotation.QualifierAnnotationAutowireCandidateResolver; +import org.springframework.beans.factory.support.DefaultListableBeanFactory; +import org.springframework.beans.factory.support.RootBeanDefinition; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.StringRedisTemplate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; + +/** + * 回归测试(2026-09-24 线上事故):{@link RouteChangeBroadcaster} 的 Redis 注入必须显式选主库模板, + * 且必须能容忍「两个同类型 StringRedisTemplate 共存」的上下文。 + *

+ * 事故:{@code yh-appauth} 的 {@code SignKeyRedisAutoConfiguration} 注册了同名类型的 + * {@code signKeyStringRedisTemplate}(直连 db0,专供 {@code sign:app:*})。本类原先裸 + * {@code @Autowired StringRedisTemplate} → {@code NoUniqueBeanDefinitionException} → + * 网关启动即失败、容器无限重启(RestartCount=11)。 + *

+ * 既有测试为何漏掉:测试 profile 一律 {@code yh.gateway.redis-broadcast-enabled=false} + * 把本类整个关掉,装配分支从未被触碰。 + *

+ * 为什么不用 @SpringBootTest:需要一个恰好只有这两个模板的最小 bean 集合来复现歧义, + * 全量上下文会被组件扫描、数据源等噪声干扰。这里手工驱动 + * {@link AutowiredAnnotationBeanPostProcessor}——它与生产环境用的是同一套注入逻辑, + * 因此能真实复现「裸 @Autowired 会炸」这一行为。 + */ +class RouteChangeBroadcasterWiringTest { + + /** appauth starter 密钥模板的 bean 名(与 SignKeyRedisAutoConfiguration.SIGN_TEMPLATE_BEAN 一致) */ + private static final String SIGN_TEMPLATE_BEAN = "signKeyStringRedisTemplate"; + + private static final String MAIN_TEMPLATE_BEAN = "stringRedisTemplate"; + + /** 复现环境:注册两个同类型模板 + 一个 RouteRegistry */ + private DefaultListableBeanFactory twoTemplatesFactory() { + DefaultListableBeanFactory factory = new DefaultListableBeanFactory(); + factory.setAutowireCandidateResolver(new QualifierAnnotationAutowireCandidateResolver()); + factory.registerSingleton(MAIN_TEMPLATE_BEAN, + new StringRedisTemplate(mock(RedisConnectionFactory.class))); + factory.registerSingleton(SIGN_TEMPLATE_BEAN, + new StringRedisTemplate(mock(RedisConnectionFactory.class))); + factory.registerSingleton("routeRegistry", mock(RouteRegistry.class)); + return factory; + } + + /** 用生产同款注入逻辑装配一个 RouteChangeBroadcaster 实例 */ + private RouteChangeBroadcaster build(DefaultListableBeanFactory factory) { + RootBeanDefinition def = new RootBeanDefinition(RouteChangeBroadcaster.class); + factory.registerBeanDefinition("routeChangeBroadcaster", def); + AutowiredAnnotationBeanPostProcessor processor = new AutowiredAnnotationBeanPostProcessor(); + processor.setBeanFactory(factory); + RouteChangeBroadcaster bean = new RouteChangeBroadcaster(); + processor.postProcessProperties(null, bean, "routeChangeBroadcaster"); + return bean; + } + + @Test + void 两个同类型模板共存时_广播器仍可装配() { + RouteChangeBroadcaster bean = build(twoTemplatesFactory()); + assertThat(bean) + .as("stringRedisTemplate 与 signKeyStringRedisTemplate 共存时广播器必须装配成功") + .isNotNull(); + } + + @Test + void 广播器绑定主库模板而非密钥专用模板() { + DefaultListableBeanFactory factory = twoTemplatesFactory(); + RouteChangeBroadcaster bean = build(factory); + StringRedisTemplate bound = (StringRedisTemplate) org.springframework.test.util.ReflectionTestUtils + .getField(bean, "redisTemplate"); + assertThat(bound) + .as("必须绑定应用主库的 stringRedisTemplate(选错 = 发布订阅异库、广播静默失效)") + .isSameAs(factory.getBean(MAIN_TEMPLATE_BEAN, StringRedisTemplate.class)); + } + + /** + * 反向保护:钉死「裸 @Autowired 确实会失败」这一事实,防止有人误以为本测试是多余的。 + * 若哪天歧义被 Spring 默认策略自动消解(例如某方加了 @Primary),本用例会失败并提醒复核。 + */ + @Test + void 无Qualifier时同类型双Bean必然注入歧义() { + DefaultListableBeanFactory factory = twoTemplatesFactory(); + factory.registerBeanDefinition("bareBroadcaster", new RootBeanDefinition(BareInjectProbe.class)); + AutowiredAnnotationBeanPostProcessor processor = new AutowiredAnnotationBeanPostProcessor(); + processor.setBeanFactory(factory); + BareInjectProbe probe = new BareInjectProbe(); + assertThatThrownBy(() -> processor.postProcessProperties(null, probe, "bareBroadcaster")) + .as("两个同类型模板下裸 @Autowired 必须失败——这正是事故成因") + .isInstanceOf(org.springframework.beans.factory.UnsatisfiedDependencyException.class); + } + + /** 与 RouteChangeBroadcaster 改前同形的裸注入探针,仅用于反向断言 */ + static class BareInjectProbe { + @org.springframework.beans.factory.annotation.Autowired + private StringRedisTemplate redisTemplate; + } +}