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;
|
|
/**
|
* 满意度模块的 <b>真实 Spring 容器</b>装配校验(不依赖 JUnit,也不依赖数据库)。
|
* <p>
|
* 为什么需要这个检查:本模块的几个"静态校验"(Mapper 一致性、逐行等价、策略等价)都只能证明
|
* <b>代码内容</b>正确,证明不了 <b>Spring 能把 Bean 装起来</b>。实际踩过两个坑:
|
* <ol>
|
* <li>{@link SatisfactionFacade} 有两个公开构造器,Spring 因"构造器不唯一"退回找无参构造器
|
* → 启动期 {@code No default constructor found};</li>
|
* <li>{@code SatisfactionApi} 的按类型查找会把门面<b>自己</b>当兜底候选
|
* → 门面把请求转发给自己 → {@code StackOverflowError}。</li>
|
* </ol>
|
* 所以这里直接起一个真实的 {@link AnnotationConfigApplicationContext},只注册本模块
|
* <b>不依赖 Mapper / 数据库</b>的那些 Bean,验证装配与开关行为。
|
* <p>
|
* 开关本身存在 {@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<String, Object> getSatisfactionCategories(String orgid) {
|
CALLS.incrementAndGet();
|
return Collections.singletonMap("fromStub", orgid);
|
}
|
|
@Override
|
public SatisfactionExportBundle getQuestionOptionRatioExport(List<Long> subtaskIds, String questiontext) {
|
return SatisfactionExportBundle.empty();
|
}
|
|
@Override
|
public boolean hasSatisfactionCategory(String orgid) {
|
return true;
|
}
|
|
@Override
|
public Map<String, Object> getSatisfactionIndex(String type, String code, String orgid,
|
Long ivrCategoryid, Long svyCategoryid) {
|
return SatisfactionFacade.emptySatisfactionIndex();
|
}
|
}
|
|
/**
|
* 探针:以"旁观者"身份注入 {@code ObjectProvider<SatisfactionApi>},看容器按类型能找到谁。
|
* <p>
|
* 它不是 {@code SatisfactionApi} 的实现,所以不存在"自引用被排除"的情况,
|
* 因此它看到的就是完整的候选集合 —— 用来证明"模块关闭时容器会把门面自己当候选"。
|
*/
|
public static class ProviderProbe {
|
|
@org.springframework.beans.factory.annotation.Autowired
|
private org.springframework.beans.factory.ObjectProvider<SatisfactionApi> 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);
|
}
|
}
|