liusheng
8 天以前 34f78281cf395a24e1f3f1c3b861186ff8a03b7b
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
package com.ruoyi.inhospvisit.config;
 
import com.ruoyi.common.core.service.IConfigService;
import com.ruoyi.inhospvisit.InhospVisitModule;
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>
 * 参数键名:inhospvisit.enabled
 * 参数键值:true  = 启用(默认)
 *          false = 关闭
 * </pre>
 * 建参数的幂等 SQL 见 {@code sql/出入院随访开关_sys_config.sql}。
 *
 * <h3>⚠️ 本开关的作用范围(2025 更正)</h3>
 * <ul>
 *   <li><b>HTTP 触发端点</b>:由 {@code InhospVisitAvailabilityInterceptor} 拦下,报明确业务错误;</li>
 *   <li><b>定时任务</b>:<b>受本开关控制</b>。{@code RyTask} 的 5 个入口已接开关 ——
 *       {@code dealOutHospInfo} / {@code dealOutHospInfoBySchedule} /
 *       {@code dealTaskGroupOutHosp} / {@code rollTaskGroupCycle} 走门面,
 *       {@code dealSubtaskDeadline} 走入口守卫;关闭时打
 *       「【出入院随访】模块未启用(参数配置 inhospvisit.enabled=false),… 跳过」;</li>
 *   <li><b>基础出入院信息读写</b>({@code InhospVisitDataService}):<b>刻意不受本开关管</b> ——
 *       关掉随访不该让 HIS 采集与出入院记录管理瘫痪。</li>
 * </ul>
 * <p>
 * ⚠️ <b>本注释此前写的是"本开关不作用于定时任务"</b>,理由是"一次误操作就能让当天全院随访停摆,
 * 而定时任务没有反馈渠道"。该说法<b>与事实不符且已更正</b>:
 * 当时定时任务走 smartor 的遗留实现 {@code PatMedInhospServiceImpl},
 * 它<b>从不调用门面的编排方法</b>(只用门面取数据,而数据方法按设计不受开关管),
 * 所以真实状态是"开关对定时任务无效、且无任何提示"——
 * 这比"受控"更糟:它让运维以为已经关掉了,实际还在生成随访。
 * 现在定时任务走门面,关闭有明确日志,参数在「系统管理 → 参数设置」里可见可改。
 * 要暂停定时任务仍可去「系统管理 → 定时任务」暂停 {@code sys_job},两条路都有效。
 * 详见 {@code doc/模块开关有效性核查与修复.md}。
 *
 * <h3>取值优先级</h3>
 * <ol>
 *   <li>{@code sys_config} 里 {@code inhospvisit.enabled}(<b>主用</b>);</li>
 *   <li>{@link InhospVisitProperties#isEnabled()}(<b>兜底</b>,默认 true)。</li>
 * </ol>
 *
 * <h3>为什么读不到配置也按 true(fail-open)</h3>
 * 读 {@code sys_config} 要走数据库,数据库抖动时 fail-closed 会把出入院随访整体打死 ——
 * 那是把"参数读取"变成"业务停摆放大器"。所以读失败只打 WARN 并放行。
 * <p>
 * ⚠️ 注意本模块另有一个"类似但不该合并"的口径:yml 里的 {@code noLongTask}
 * ({@code ServiceExternalServiceImpl.java:83-84})按 orgid 直接跳过长期任务生成。
 * 语义相近但链路不同(它在 {@code ServiceExternalServiceImpl},不在随访扫描链上),
 * <b>本次不合并</b>,避免行为变更。
 *
 * <h3>为什么有 10 秒缓存</h3>
 * {@code SysConfigServiceImpl#selectConfigByKey} 当前<b>每次都查库</b>
 * (Redis 缓存那段被注释掉了),而本开关每次随访请求都会被读到。
 * 进程内 TTL 缓存把查库降到"每实例每 10 秒最多一次",代价是改动最多 10 秒生效。
 */
@Slf4j
@Component
public class InhospVisitSwitch {
 
    /**
     * sys_config 里的参数键名(与 {@link InhospVisitModule#SWITCH_KEY} 保持一致)
     */
    public static final String CONFIG_KEY = InhospVisitModule.SWITCH_KEY;
 
    /**
     * 进程内缓存有效期(毫秒)
     */
    private static final long CACHE_TTL_MILLIS = 10_000L;
 
    private final InhospVisitProperties properties;
 
    /**
     * 参数配置服务(ruoyi-common 的接口,实现在 ruoyi-system)。
     * <p>
     * 用 {@code ObjectProvider} 惰性取:本模块只依赖 ruoyi-common,
     * 在测试/独立部署等"容器里没有实现"的场景下不能因此启动失败。
     */
    private final ObjectProvider<IConfigService> configServiceProvider;
 
    private volatile Boolean cachedEnabled;
 
    private volatile long cachedAt;
 
    /**
     * Spring 使用的构造器。
     * <p>
     * ⚠️ <b>必须显式标注 {@code @Autowired}</b>:本类有两个公开构造器
     * (另一个给测试/非 Spring 环境用),不标注的话 Spring 会因"构造器不唯一"
     * 退回查找无参构造器,启动即报 {@code No default constructor found}。
     * —— 这是满意度模块实际踩到的坑,此处刻意保留同样的写法与注释。
     */
    @Autowired
    public InhospVisitSwitch(InhospVisitProperties properties,
                             ObjectProvider<IConfigService> configServiceProvider) {
        this.properties = properties;
        this.configServiceProvider = configServiceProvider;
    }
 
    /**
     * 供<b>测试/非 Spring 环境</b>直接构造:{@code configValue} 就是 sys_config 里的键值,
     * 传 {@code null} 表示"参数行不存在"(此时回落到 {@link InhospVisitProperties})。
     */
    public InhospVisitSwitch(InhospVisitProperties properties, final String configValue) {
        this(properties, 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 = properties.isEnabled();
        log.debug("【出入院随访】sys_config 中未配置 {},回落 inhospvisit.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);
        }
    }
}