package com.ruoyi.satisfaction.api; import com.ruoyi.common.core.service.IConfigService; import com.ruoyi.common.exception.ServiceException; import com.ruoyi.satisfaction.config.SatisfactionAvailabilityInterceptor; import com.ruoyi.satisfaction.config.SatisfactionProperties; import com.ruoyi.satisfaction.config.SatisfactionSwitch; import com.ruoyi.satisfaction.config.SatisfactionWebConfig; import com.ruoyi.satisfaction.service.strategy.CommonSatisfactionScoreStrategy; import com.ruoyi.satisfaction.service.strategy.SatisfactionScoreStrategyResolver; import com.ruoyi.satisfaction.service.strategy.SatisfactionSites; import com.ruoyi.satisfaction.service.strategy.SltdSatisfactionScoreStrategy; import org.springframework.context.annotation.AnnotationConfigApplicationContext; import java.util.Collections; import java.util.List; import java.util.Map; /** * 满意度模块的 真实 Spring 容器装配校验(不依赖 JUnit,也不依赖数据库)。 *

* 为什么需要这个检查:本模块的几个"静态校验"(Mapper 一致性、逐行等价、策略等价)都只能证明 * 代码内容正确,证明不了 Spring 能把 Bean 装起来。实际踩过两个坑: *

    *
  1. {@link SatisfactionFacade} 有两个公开构造器,Spring 因"构造器不唯一"退回找无参构造器 * → 启动期 {@code No default constructor found};
  2. *
  3. {@code SatisfactionApi} 的按类型查找会把门面自己当兜底候选 * → 门面把请求转发给自己 → {@code StackOverflowError}。
  4. *
* 所以这里直接起一个真实的 {@link AnnotationConfigApplicationContext},只注册本模块 * 不依赖 Mapper / 数据库的那些 Bean,验证装配与开关行为。 *

* 开关本身存在 {@code sys_config} 参数表里(键 {@code satisfaction.enabled},运行时热读), * 因此这里用一个假的 {@link IConfigService} 模拟参数表,验证「参数表优先、yml 兜底」。 */ public final class SatisfactionContextCheck { private static int failed = 0; private SatisfactionContextCheck() { } /** * 假的内部实现(真实场景里是 DefaultSatisfactionApi,它需要 Mapper/Redis,起不了轻量容器) */ public static class StubApi implements SatisfactionApi { /** * 分类查询被调用的次数:用来证明"参数表关闭时门面确实没有转发给实现" */ static final java.util.concurrent.atomic.AtomicInteger CALLS = new java.util.concurrent.atomic.AtomicInteger(); @Override public boolean isEnabled() { return true; } @Override public Map getSatisfactionCategories(String orgid) { CALLS.incrementAndGet(); return Collections.singletonMap("fromStub", orgid); } @Override public SatisfactionExportBundle getQuestionOptionRatioExport(List subtaskIds, String questiontext) { return SatisfactionExportBundle.empty(); } @Override public boolean hasSatisfactionCategory(String orgid) { return true; } @Override public Map getSatisfactionIndex(String type, String code, String orgid, Long ivrCategoryid, Long svyCategoryid) { return SatisfactionFacade.emptySatisfactionIndex(); } } /** * 探针:以"旁观者"身份注入 {@code ObjectProvider},看容器按类型能找到谁。 *

* 它不是 {@code SatisfactionApi} 的实现,所以不存在"自引用被排除"的情况, * 因此它看到的就是完整的候选集合 —— 用来证明"模块关闭时容器会把门面自己当候选"。 */ public static class ProviderProbe { @org.springframework.beans.factory.annotation.Autowired private org.springframework.beans.factory.ObjectProvider provider; SatisfactionApi rawCandidate() { return provider.getIfAvailable(); } } public static void main(String[] args) { System.out.println("=== 场景 1:参数表 satisfaction.enabled=true,且存在内部实现(DefaultSatisfactionApi 的角色)==="); try (AnnotationConfigApplicationContext ctx = context(true, true, ContextCheckConfig.SYS_ON)) { SatisfactionFacade facade = ctx.getBean(SatisfactionFacade.class); check("门面能被 Spring 实例化(构造器不再歧义)", "true", String.valueOf(facade != null)); check("启用时 isEnabled()", "true", String.valueOf(facade.isEnabled())); check("取到的是【内部实现】而不是门面自己(stub 把 orgid 原样回填)", "ORG1", String.valueOf(facade.getSatisfactionCategories("ORG1").get("fromStub"))); check("门面同时是 ISatisfactionCategoryService", "true", String.valueOf(ctx.getBean(com.ruoyi.common.core.service.ISatisfactionCategoryService.class) == facade)); // 容器里应有 2 个 SatisfactionApi:门面自己 + 内部实现(门面也实现该接口,这是设计使然) check("容器内 SatisfactionApi 实现数 = 门面 1 + 内部实现 1", "2", String.valueOf(ctx.getBeansOfType(SatisfactionApi.class).size())); check("开关读取的键名是 sys_config 的 satisfaction.enabled", SatisfactionSwitch.CONFIG_KEY, String.valueOf(SatisfactionSwitch.CONFIG_KEY)); SatisfactionScoreStrategyResolver resolver = ctx.getBean(SatisfactionScoreStrategyResolver.class); check("站点策略解析器装配成功,支持站点 = [common, sltd]", "[common, sltd]", String.valueOf(resolver.supportedSites())); check("未知站点回落到通用口径", SatisfactionSites.COMMON, resolver.resolve("不存在的站点").site()); } System.out.println(); System.out.println("=== 场景 2:参数表 satisfaction.enabled=true,但【没有】内部实现(模块没被打进应用)==="); try (AnnotationConfigApplicationContext ctx = context(true, false, ContextCheckConfig.SYS_ON)) { SatisfactionFacade facade = ctx.getBean(SatisfactionFacade.class); check("无实现时 isEnabled() 为 false", "false", String.valueOf(facade.isEnabled())); // 关键:不能把门面自己当实现,否则这里会 StackOverflowError String result; try { result = String.valueOf(facade.getSatisfactionCategories("ORG1").isEmpty()); } catch (StackOverflowError e) { result = "StackOverflowError"; } check("无实现时分类查询返回空 Map(不会自引用递归)", "true", result); check("无实现时占比导出包为空", "true", String.valueOf(facade.getQuestionOptionRatioExport(Collections.singletonList(1L), null).isEmpty())); check("无实现时满意度指数为全零 Map", "0", String.valueOf(facade.getSatisfactionIndex("1", "D001", "ORG1", null, null).get("mydtmzl"))); // 反证:直接问容器"按类型有哪些 SatisfactionApi 候选",看它是否把门面自己算进去。 // 若这里返回的是门面自己,就说明"自引用兜底"确实存在,门面里的 self 过滤是必要的。 ProviderProbe probe = ctx.getBean(ProviderProbe.class); check("无实现时,容器按类型只找到门面自己(证明自引用兜底存在)", "true", String.valueOf(probe.rawCandidate() == facade)); check("但门面不会把'自己'当成实现(self 过滤生效)", "false", String.valueOf(facade.isEnabled())); } System.out.println(); System.out.println("=== 场景 3:参数表 satisfaction.enabled=false(实现 Bean 依然存在,但门面必须不转发)==="); StubApi.CALLS.set(0); try (AnnotationConfigApplicationContext ctx = context(true, true, ContextCheckConfig.SYS_OFF)) { SatisfactionFacade facade = ctx.getBean(SatisfactionFacade.class); check("关闭时 isEnabled() 为 false", "false", String.valueOf(facade.isEnabled())); String result; try { result = String.valueOf(facade.getSatisfactionCategories("ORG1").isEmpty()); } catch (StackOverflowError e) { result = "StackOverflowError"; } check("关闭时分类查询返回空 Map(不会自递归)", "true", result); check("关闭时不再调用内部实现(stub 调用次数 = 0)", "0", String.valueOf(StubApi.CALLS.get())); SatisfactionAvailabilityInterceptor interceptor = ctx.getBean(SatisfactionAvailabilityInterceptor.class); String thrown = "(未抛异常)"; try { interceptor.preHandle(null, null, null); } catch (Exception e) { thrown = e.getClass().getSimpleName() + "|" + e.getMessage(); } check("关闭时拦截器抛 ServiceException", "true", String.valueOf(thrown.startsWith("ServiceException|"))); check("关闭时错误提示明确", "true", String.valueOf(thrown.contains("satisfaction.enabled=false") && thrown.contains("接口不可用"))); check("关闭时受保护路径规则 7 条", "7", String.valueOf(SatisfactionWebConfig.guardedPathPatterns().size())); } System.out.println(); System.out.println("=== 场景 4:参数表【没有】satisfaction.enabled 这一行时,回落 application.yml(默认 true)==="); try (AnnotationConfigApplicationContext ctx = context(true, true, ContextCheckConfig.SYS_MISSING)) { SatisfactionFacade facade = ctx.getBean(SatisfactionFacade.class); check("参数行缺失、yml 默认 true 时 isEnabled() 为 true", "true", String.valueOf(facade.isEnabled())); } try (AnnotationConfigApplicationContext ctx = context(false, true, ContextCheckConfig.SYS_MISSING)) { SatisfactionFacade facade = ctx.getBean(SatisfactionFacade.class); check("参数行缺失、yml=false 时 isEnabled() 为 false", "false", String.valueOf(facade.isEnabled())); } System.out.println(); System.out.println("=== 场景 5:容器里【没有】IConfigService 时(独立/测试环境)必须能启动且按 yml 兜底 ==="); try (AnnotationConfigApplicationContext ctx = context(true, true, null, false)) { SatisfactionFacade facade = ctx.getBean(SatisfactionFacade.class); check("无配置服务时门面仍能装配并启动", "true", String.valueOf(facade != null)); check("无配置服务时按 yml 兜底(true)", "true", String.valueOf(facade.isEnabled())); } System.out.println(); if (failed == 0) { System.out.println("===== 满意度模块 Spring 装配校验全部通过 ====="); } else { System.out.println("===== 满意度模块 Spring 装配校验失败 " + failed + " 项 ====="); System.exit(1); } } /** * 参数表的模拟取值 */ static final class ContextCheckConfig { /** 参数行存在且为 true */ static final String SYS_ON = "true"; /** 参数行存在且为 false */ static final String SYS_OFF = "false"; /** 参数行不存在 */ static final String SYS_MISSING = null; private ContextCheckConfig() { } } /** * 起一个只含本模块"无 Mapper 依赖"Bean 的真实容器 * * @param ymlEnabled application.yml 里 satisfaction.enabled 的取值(兜底用) * @param withDelegate 是否注册内部实现(DefaultSatisfactionApi 的角色) * @param sysConfigValue 模拟 sys_config 里 satisfaction.enabled 的键值,null=没有这一行 */ private static AnnotationConfigApplicationContext context(boolean ymlEnabled, boolean withDelegate, final String sysConfigValue) { return context(ymlEnabled, withDelegate, sysConfigValue, true); } /** * @param withConfigService 容器里是否注册 {@link IConfigService}(false 模拟独立/测试环境) */ private static AnnotationConfigApplicationContext context(boolean ymlEnabled, boolean withDelegate, final String sysConfigValue, boolean withConfigService) { AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext(); SatisfactionProperties properties = new SatisfactionProperties(); properties.setEnabled(ymlEnabled); ctx.registerBean(SatisfactionProperties.class, () -> properties); ctx.register(SatisfactionFacade.class, SatisfactionSwitch.class, SatisfactionAvailabilityInterceptor.class, SatisfactionWebConfig.class, SatisfactionScoreStrategyResolver.class, CommonSatisfactionScoreStrategy.class, SltdSatisfactionScoreStrategy.class, ProviderProbe.class); if (withConfigService) { // 假的参数配置服务:只认 satisfaction.enabled 这一个键,值由 sysConfigValue 决定 ctx.registerBean("stubConfigService", IConfigService.class, () -> (IConfigService) configKey -> SatisfactionSwitch.CONFIG_KEY.equals(configKey) ? sysConfigValue : ""); } if (withDelegate) { ctx.registerBean("stubSatisfactionApi", SatisfactionApi.class, StubApi::new); } ctx.refresh(); return ctx; } private static void check(String key, String expected, String actual) { boolean ok = expected.equals(actual); if (!ok) { failed++; } System.out.println("[" + (ok ? "OK" : "FAIL") + "] " + key + " 期望=" + expected + " 实际=" + actual); } }