liusheng
2026-10-12 b25c3b5188e402695f3792ad7da53e452682cd80
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
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";
 
    /**
     * 进程内缓存有效期(毫秒)
     */
    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;
 
    /**
     * 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;
    }
 
    /**
     * 满意度功能是否启用(运行时热读,最多 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;
    }
 
    /**
     * 解析当前开关值:sys_config 优先,其次 yml 兜底
     */
    private boolean resolve() {
        String value = readFromSysConfig();
        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 readFromSysConfig() {
        if (configServiceProvider == null) {
            return null;
        }
        try {
            IConfigService configService = configServiceProvider.stream().findFirst().orElse(null);
            if (configService == null) {
                return null;
            }
            String value = configService.selectConfigByKey(CONFIG_KEY);
            if (value == null || value.trim().isEmpty()) {
                return null;
            }
            return value.trim();
        } catch (Exception e) {
            // 容器没有实现、数据源异常、SQL 报错……一律按"参数缺失"处理(fail-open),绝不影响主流程
            log.warn("【满意度】读取 sys_config 的 {} 失败,本次按未配置处理(fail-open):{}",
                    CONFIG_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);
        }
    }
}