liusheng
3 小时以前 c24d16a37523a49080392610357872ce580cbc38
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
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
package com.ruoyi.satisfaction.config;
 
import com.ruoyi.common.core.service.IConfigService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
 
/**
 * 满意度模块<b>总开关</b>:值存放在 {@code sys_config} 参数配置表中,<b>运行时读取、可热切换</b>。
 *
 * <h3>参数配置</h3>
 * <pre>
 * 参数键名:satisfaction.enabled
 * 参数键值:true  = 启用(默认)
 *          false = 关闭
 * </pre>
 * 位置:系统管理 → 参数设置 → 新增/修改。
 * <pre>
 * insert into sys_config (config_name, config_key, config_value, config_type, create_by, create_time, remark)
 * values ('满意度模块开关', 'satisfaction.enabled', 'true', 'Y', 'admin', sysdate(), 'true启用,false关闭(默认true)');
 * </pre>
 *
 * <h3>取值优先级</h3>
 * <ol>
 *   <li>{@code sys_config} 里 {@code satisfaction.enabled} 的值(<b>主用</b>,
 *       来自本项目 {@code IConfigService} 接口,实现在 ruoyi-system 的 {@code SysConfigServiceImpl});</li>
 *   <li>{@link SatisfactionProperties#isEnabled()}(<b>兜底</b>:仅当 sys_config 里没有这一行、
 *       或非 Spring 环境取不到配置服务时使用,默认 true)。</li>
 * </ol>
 *
 * <h3>为什么默认 true</h3>
 * 历史行为就是"默认开启"。参数行缺失时按 true 处理,保证升级到本版本后
 * 满意度功能不会因为少了一行配置而突然不可用(要关闭必须显式配置 false)。
 *
 * <h3>为什么读不到配置时也按 true(fail-open)</h3>
 * 读 {@code sys_config} 需要走数据库,数据库抖动时如果 fail-closed 会把整个满意度业务打死。
 * 这里选择与"参数行缺失"一致的处理:只打 WARN 并放行,宁可多开一会儿,也不做数据库抖动放大器。
 *
 * <h3>为什么有 10 秒缓存</h3>
 * {@code SysConfigServiceImpl#selectConfigByKey} 当前<b>每次都查库</b>(Redis 缓存那段被注释掉了),
 * 而本开关在每次满意度请求、每次登录取分类时都会被读到。这里加一个进程内 TTL 缓存,
 * 把查库降到"每个实例每 10 秒最多一次"。代价是参数改动最多 10 秒生效(多实例部署时各自计时)。
 */
@Slf4j
@Component
public class SatisfactionSwitch {
 
    /**
     * sys_config 里的参数键名
     */
    public static final String CONFIG_KEY = "satisfaction.enabled";
 
    /**
     * 满意度「综合得分口径」站点参数键名。
     * <p>
     * 值取 {@link com.ruoyi.satisfaction.service.strategy.SatisfactionSites} 的标识:
     * <ul>
     *   <li>{@code sltd}(默认)—— 省立同德口径:按选项文案判五档加权;</li>
     *   <li>{@code common}(别名 {@code nanhua})—— 通用/南华口径:按明细 score 求平均。</li>
     * </ul>
     * 本参数是 2026-10-21 新增的:{@code /smartor/serviceSubtaskDetail/sltdMydTotalByScore}
     * 原来把口径写死成 SLTD,南华调用时五档文案一条都匹配不上,综合得分恒为 0.00。
     */
    public static final String SITE_CONFIG_KEY = "satisfaction.site";
 
    /**
     * 未配置站点时的默认口径:保持历史行为(SLTD 口径)
     */
    private static final String DEFAULT_SITE = com.ruoyi.satisfaction.service.strategy.SatisfactionSites.SLTD;
 
    /**
     * 进程内缓存有效期(毫秒)
     */
    private static final long CACHE_TTL_MILLIS = 10_000L;
 
    private final SatisfactionProperties satisfactionProperties;
 
    /**
     * 参数配置服务(ruoyi-common 的接口,实现在 ruoyi-system)。
     * <p>
     * 用 {@code ObjectProvider} 惰性取:本模块只依赖 ruoyi-common,
     * 在单元测试/独立部署等"容器里没有实现"的场景下不能因此启动失败。
     */
    private final ObjectProvider<IConfigService> configServiceProvider;
 
    /**
     * 最近一次解析结果(含是否来自 sys_config)
     */
    private volatile Boolean cachedEnabled;
 
    /**
     * 最近一次解析的时间戳
     */
    private volatile long cachedAt;
 
    /**
     * 最近一次解析到的「综合得分口径」站点
     */
    private volatile String cachedSite;
 
    /**
     * 最近一次解析站点的时间戳
     */
    private volatile long cachedSiteAt;
 
    /**
     * Spring 使用的构造器。
     * <p>
     * ⚠️ <b>必须显式标注 {@code @Autowired}</b>:本类有两个公开构造器
     * (另一个给测试/非 Spring 环境用),不标注的话 Spring 会因"构造器不唯一"
     * 退回查找无参构造器,启动即报 {@code No default constructor found}。
     */
    @Autowired
    public SatisfactionSwitch(SatisfactionProperties satisfactionProperties,
                              ObjectProvider<IConfigService> configServiceProvider) {
        this.satisfactionProperties = satisfactionProperties;
        this.configServiceProvider = configServiceProvider;
    }
 
    /**
     * 供<b>测试/非 Spring 环境</b>直接构造:{@code configValue} 就是 sys_config 里的键值,
     * 传 {@code null} 表示"参数行不存在"(此时回落到 {@link SatisfactionProperties})。
     */
    public SatisfactionSwitch(SatisfactionProperties satisfactionProperties, final String configValue) {
        this(satisfactionProperties, new FixedProvider(configValue));
        // 非 Spring 环境下没有"改配置后等 10 秒"的概念,直接禁用缓存,保证断言即时可见
        this.cachedEnabled = resolve();
        this.cachedAt = System.currentTimeMillis() + CACHE_TTL_MILLIS * 1000L;
        // 测试用的 FixedProvider 对任何 key 都返回同一个值,这里不要误把它当成站点配置
        this.cachedSite = DEFAULT_SITE;
        this.cachedSiteAt = System.currentTimeMillis() + CACHE_TTL_MILLIS * 1000L;
    }
 
    /**
     * 满意度功能是否启用(运行时热读,最多 10 秒延迟)
     */
    public boolean isEnabled() {
        long now = System.currentTimeMillis();
        Boolean cached = this.cachedEnabled;
        if (cached != null && now - this.cachedAt < CACHE_TTL_MILLIS) {
            return cached;
        }
        boolean resolved = resolve();
        this.cachedEnabled = resolved;
        this.cachedAt = now;
        return resolved;
    }
 
    /**
     * 清掉进程内缓存,下次调用立即重新读 sys_config(供测试/需要立即生效的场景)
     */
    public void invalidateCache() {
        this.cachedEnabled = null;
        this.cachedSite = null;
    }
 
    /**
     * 当前的<b>综合得分口径站点</b>({@code sys_config: satisfaction.site},运行时热读,最多 10 秒延迟)
     * <p>
     * 未配置时返回 {@link #DEFAULT_SITE}({@code sltd}),保持历史行为不变;
     * 南华部署请把该参数配成 {@code common}(别名 {@code nanhua})。
     *
     * @return 归一化后的站点标识(见 {@link com.ruoyi.satisfaction.service.strategy.SatisfactionSites})
     */
    public String site() {
        long now = System.currentTimeMillis();
        String cached = this.cachedSite;
        if (cached != null && now - this.cachedSiteAt < CACHE_TTL_MILLIS) {
            return cached;
        }
        String site = resolveSite();
        this.cachedSite = site;
        this.cachedSiteAt = now;
        return site;
    }
 
    /**
     * 解析站点口径:sys_config 优先,缺省用 {@link #DEFAULT_SITE}
     */
    private String resolveSite() {
        String value = readConfig(SITE_CONFIG_KEY);
        if (value != null) {
            String site = com.ruoyi.satisfaction.service.strategy.SatisfactionSites.normalize(value);
            log.debug("【满意度】综合得分口径取自 sys_config:{}={} -> site={}", SITE_CONFIG_KEY, value, site);
            return site;
        }
        log.debug("【满意度】sys_config 中未配置 {},回落默认站点 {}(同德口径)", SITE_CONFIG_KEY, DEFAULT_SITE);
        return DEFAULT_SITE;
    }
 
    /**
     * 解析当前开关值:sys_config 优先,其次 yml 兜底
     */
    private boolean resolve() {
        String value = readConfig(CONFIG_KEY);
        if (value != null) {
            if (!"true".equalsIgnoreCase(value) && !"false".equalsIgnoreCase(value)) {
                log.warn("【满意度】sys_config 中 {} 的值不是 true/false(实际=「{}」),将按「不是 false 即启用」处理",
                        CONFIG_KEY, value);
            }
            boolean enabled = !"false".equalsIgnoreCase(value);
            log.debug("【满意度】开关取自 sys_config:{}={} -> enabled={}", CONFIG_KEY, value, enabled);
            return enabled;
        }
        boolean enabled = satisfactionProperties.isEnabled();
        log.debug("【满意度】sys_config 中未配置 {},回落 satisfaction.enabled={}", CONFIG_KEY, enabled);
        return enabled;
    }
 
    /**
     * 读 sys_config 的原始键值;没有实现、没有这一行、或读库失败都返回 {@code null}
     */
    private String readConfig(String key) {
        if (configServiceProvider == null) {
            return null;
        }
        try {
            IConfigService configService = configServiceProvider.stream().findFirst().orElse(null);
            if (configService == null) {
                return null;
            }
            String value = configService.selectConfigByKey(key);
            if (value == null || value.trim().isEmpty()) {
                return null;
            }
            return value.trim();
        } catch (Exception e) {
            // 容器没有实现、数据源异常、SQL 报错……一律按"参数缺失"处理(fail-open),绝不影响主流程
            log.warn("【满意度】读取 sys_config 的 {} 失败,本次按未配置处理(fail-open):{}",
                    key, e.toString());
            return null;
        }
    }
 
    /**
     * 固定值的 {@code ObjectProvider}:只用于非 Spring 环境/测试
     */
    private static final class FixedProvider implements ObjectProvider<IConfigService> {
 
        private final IConfigService configService;
 
        private FixedProvider(final String configValue) {
            this.configService = configValue == null ? null : configKey -> configValue;
        }
 
        @Override
        public IConfigService getObject(Object... args) {
            return configService;
        }
 
        @Override
        public IConfigService getIfAvailable() {
            return configService;
        }
 
        @Override
        public IConfigService getIfUnique() {
            return configService;
        }
 
        @Override
        public IConfigService getObject() {
            return configService;
        }
 
        @Override
        public java.util.stream.Stream<IConfigService> stream() {
            return configService == null ? java.util.stream.Stream.empty() : java.util.stream.Stream.of(configService);
        }
    }
}