liusheng
2026-10-12 0c1f8c479065739f3c7a5126de8e667035d00b58
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
package com.ruoyi.satisfaction.api;
 
import com.ruoyi.common.core.service.ISatisfactionCategoryService;
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.domain.ServiceSubtaskDetailRatioExport;
 
import java.util.Collections;
import java.util.List;
import java.util.Map;
 
/**
 * 满意度开关与门面行为校验(不依赖 JUnit,可直接用模块 classpath 运行)。
 * <p>
 * 开关已从 {@code application.yml} 挪到 {@code sys_config} 参数表的
 * {@code satisfaction.enabled}(运行时热读),因此这里验证三件事:
 * <ol>
 *   <li><b>参数表优先</b>:{@code sys_config} 有值时以它为准,同一份 yml 配置下可开可关;</li>
 *   <li><b>yml 兜底</b>:{@code sys_config} 没有这一行时回落 {@code satisfaction.enabled}(默认 true);</li>
 *   <li><b>关闭时不抛异常</b>:门面返回安全空值,且满意度端点拿到的是「明确错误」而不是 404。</li>
 * </ol>
 */
public final class SatisfactionSwitchCheck {
 
    private static int failed = 0;
 
    /** sys_config 里的「已开启」模拟值 */
    private static final String SYS_ON = "true";
 
    /** sys_config 里的「已关闭」模拟值 */
    private static final String SYS_OFF = "false";
 
    /** sys_config 里「没有这一行」 */
    private static final String SYS_MISSING = null;
 
    private SatisfactionSwitchCheck() {
    }
 
    /** 假实现:用于验证"启用"路径 */
    static class StubApi implements SatisfactionApi {
        @Override
        public boolean isEnabled() {
            return true;
        }
 
        @Override
        public Map<String, Object> getSatisfactionCategories(String orgid) {
            return Collections.singletonMap("voiceCategories", Collections.singletonList(orgid));
        }
 
        @Override
        public SatisfactionExportBundle getQuestionOptionRatioExport(List<Long> subtaskIds, String questiontext) {
            return new SatisfactionExportBundle(
                    Collections.singletonList(new ServiceSubtaskDetailRatioExport()),
                    ServiceSubtaskDetailRatioExport.class, workbook -> { });
        }
 
        @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();
        }
    }
 
    public static void main(String[] args) throws Exception {
        // ---------------- 启用:yml 兜底 ----------------
        SatisfactionProperties on = new SatisfactionProperties();   // 默认 enabled=true
        SatisfactionFacade onFacade = new SatisfactionFacade(on, new StubApi(), SYS_MISSING);
 
        check("启用时 isEnabled()", "true", String.valueOf(onFacade.isEnabled()));
        check("启用时分类查询有结果", "true",
                String.valueOf(!onFacade.getSatisfactionCategories("ORG1").isEmpty()));
        SatisfactionExportBundle onBundle = onFacade.getQuestionOptionRatioExport(Collections.singletonList(1L), null);
        check("启用时占比导出包有行", "true", String.valueOf(!onBundle.isEmpty()));
        check("启用时占比导出包带行类型与图表构建器", "true",
                String.valueOf(onBundle.getRowClass() != null && onBundle.getChartBuilder() != null));
        check("启用时 hasSatisfactionCategory", "true", String.valueOf(onFacade.hasSatisfactionCategory("ORG1")));
 
        // ---------------- 启用:yml 为 false,但参数表说 true(参数表优先) ----------------
        SatisfactionProperties ymlOff = new SatisfactionProperties();
        ymlOff.setEnabled(false);
        SatisfactionFacade sysOnFacade = new SatisfactionFacade(ymlOff, new StubApi(), SYS_ON);
        check("参数表=true 覆盖 yml=false 时 isEnabled()", "true", String.valueOf(sysOnFacade.isEnabled()));
        check("参数表=true 覆盖 yml=false 时分类查询有结果", "true",
                String.valueOf(!sysOnFacade.getSatisfactionCategories("ORG1").isEmpty()));
 
        // ---------------- 关闭:参数表 false,实现 Bean 依然存在 ----------------
        SatisfactionFacade offFacade = new SatisfactionFacade(on, new StubApi(), SYS_OFF);
 
        check("关闭时 isEnabled()", "false", String.valueOf(offFacade.isEnabled()));
        check("关闭时分类查询返回空 Map(即使实现还在也不转发)", "true",
                String.valueOf(offFacade.getSatisfactionCategories("ORG1").isEmpty()));
        SatisfactionExportBundle offBundle = offFacade.getQuestionOptionRatioExport(Collections.singletonList(1L), null);
        check("关闭时占比导出包为空", "true", String.valueOf(offBundle.isEmpty()));
        check("关闭时占比导出包不带图表构建器(导出照常完成)", "true",
                String.valueOf(offBundle.getChartBuilder() == null));
        check("关闭时 hasSatisfactionCategory", "false", String.valueOf(offFacade.hasSatisfactionCategory("ORG1")));
 
        // ---------------- 关闭:参数表缺失 + yml false(兜底生效) ----------------
        SatisfactionFacade ymlOnlyOff = new SatisfactionFacade(ymlOff, new StubApi(), SYS_MISSING);
        check("参数表缺失、yml=false 时 isEnabled()", "false", String.valueOf(ymlOnlyOff.isEnabled()));
 
        // ---------------- 关闭:参数表缺失 + 无实现 Bean(模块根本不在依赖里) ----------------
        SatisfactionFacade noImplFacade = new SatisfactionFacade(on, (SatisfactionApi) null, SYS_MISSING);
        check("参数表缺失、无实现 Bean 时 isEnabled()", "false", String.valueOf(noImplFacade.isEnabled()));
 
        // 关闭时反复调用不应抛异常
        boolean noException = true;
        try {
            for (int i = 0; i < 3; i++) {
                offFacade.getSatisfactionCategories(null);
                offFacade.getQuestionOptionRatioExport(null, null);
                offFacade.hasSatisfactionCategory(null);
            }
        } catch (Exception e) {
            noException = false;
            System.out.println("     异常: " + e);
        }
        check("关闭时反复调用不抛异常", "true", String.valueOf(noException));
 
        // ---------------- 登录链路的接口暴露(第 9 步) ----------------
        // ruoyi-system 通过 ruoyi-common 的 ISatisfactionCategoryService 按类型查找实现,
        // 因此门面必须实现该接口,才能"关闭时返回空 Map 而不影响登录"。
        check("门面实现 ISatisfactionCategoryService(登录链路按接口查找)", "true",
                String.valueOf(onFacade instanceof ISatisfactionCategoryService));
        check("按接口调用(启用)返回非空", "true",
                String.valueOf(!((ISatisfactionCategoryService) onFacade).getSatisfactionCategories("ORG1").isEmpty()));
        check("按接口调用(关闭)返回空 Map 且不抛异常", "true",
                String.valueOf(((ISatisfactionCategoryService) offFacade).getSatisfactionCategories("ORG1").isEmpty()));
 
        // ---------------- 满意度指数(关闭时必须是可用的全零 Map,不能让调用方 NPE) ----------------
        Map<String, Object> offIndex = offFacade.getSatisfactionIndex("1", "D001", "ORG1", null, null);
        check("关闭时满意度指数返回全零 mydtmzl", "0", String.valueOf(offIndex.get("mydtmzl")));
        check("关闭时满意度指数返回全零 mydtbl", "0", String.valueOf(offIndex.get("mydtbl")));
        check("关闭时满意度指数返回全零 wcbl", "0.0", String.valueOf(offIndex.get("wcbl")));
        check("关闭时满意度指数键齐全(不会让调用方 NPE)", "true",
                String.valueOf(offIndex.containsKey("mydtmzl") && offIndex.containsKey("mydtbl")
                        && offIndex.containsKey("wcbl")));
 
        // ---------------- 开关热更新:改 sys_config 后自动生效(10 秒缓存过期) ----------------
        SatisfactionProperties hot = new SatisfactionProperties();
        final String[] configValue = { SYS_OFF };
        SatisfactionSwitch hotSwitch = hotSwitch(hot, configValue);
        check("热更新前(参数表=false)", "false", String.valueOf(hotSwitch.isEnabled()));
        configValue[0] = SYS_ON;
        check("缓存未过期时仍返回旧值(进程内 10 秒缓存)", "false", String.valueOf(hotSwitch.isEnabled()));
        hotSwitch.invalidateCache();
        check("缓存失效后读到新值(改成 true 立即生效)", "true", String.valueOf(hotSwitch.isEnabled()));
 
        // ---------------- 关闭时调用满意度端点必须拿到「明确错误」而不是 404 ----------------
        SatisfactionAvailabilityInterceptor offInterceptor =
                new SatisfactionAvailabilityInterceptor(offFacade.getSatisfactionSwitch());
        SatisfactionAvailabilityInterceptor onInterceptor =
                new SatisfactionAvailabilityInterceptor(onFacade.getSatisfactionSwitch());
 
        check("启用时拦截器放行", "true", String.valueOf(onInterceptor.preHandle(null, null, null)));
 
        String thrown = "(未抛异常)";
        try {
            offInterceptor.preHandle(null, null, null);
        } catch (Exception e) {
            thrown = e.getClass().getName() + "|" + e.getMessage();
        }
        check("关闭时拦截器抛 ServiceException", "true",
                String.valueOf(thrown.startsWith("com.ruoyi.common.exception.ServiceException|")));
        check("关闭时错误提示明确(含 satisfaction.enabled=false 与接口不可用)", "true",
                String.valueOf(thrown.contains("satisfaction.enabled=false") && thrown.contains("接口不可用")));
 
        // 被拦截的端点必须是本模块的 17 个,且不能碰到随访/其它业务的路径
        List<String> guarded = SatisfactionWebConfig.guardedPathPatterns();
        check("受开关保护的路径数量 = 1 个通配 + 5 个同德 + 1 个异常配置题 = 7 条规则", "7",
                String.valueOf(guarded.size()));
        check("受保护路径不含随访导出等其它业务端点", "false",
                String.valueOf(guarded.contains("/smartor/serviceSubtask/patItemExport")
                        || guarded.contains("/smartor/serviceSubtask/getSfStatisticsJoy")
                        || guarded.contains("/smartor/subtaskAnswer/saveMYDQuestionAnswer")));
 
        System.out.println();
        if (failed == 0) {
            System.out.println("===== 满意度开关与门面校验全部通过 =====");
        } else {
            System.out.println("===== 满意度开关与门面校验失败 " + failed + " 项 =====");
            System.exit(1);
        }
    }
 
    /**
     * 造一个「{@code sys_config} 的值可以被中途改掉」的开关,用于验证缓存与热更新。
     */
    private static SatisfactionSwitch hotSwitch(SatisfactionProperties properties, final String[] configValue) {
        return new SatisfactionSwitch(properties, new org.springframework.beans.factory.ObjectProvider<com.ruoyi.common.core.service.IConfigService>() {
            private final com.ruoyi.common.core.service.IConfigService service =
                    configKey -> configValue[0];
 
            @Override
            public com.ruoyi.common.core.service.IConfigService getObject(Object... args) {
                return service;
            }
 
            @Override
            public com.ruoyi.common.core.service.IConfigService getIfAvailable() {
                return service;
            }
 
            @Override
            public com.ruoyi.common.core.service.IConfigService getIfUnique() {
                return service;
            }
 
            @Override
            public com.ruoyi.common.core.service.IConfigService getObject() {
                return service;
            }
 
            @Override
            public java.util.stream.Stream<com.ruoyi.common.core.service.IConfigService> stream() {
                return java.util.stream.Stream.of(service);
            }
        });
    }
 
    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);
    }
}