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; /** * 出入院随访模块总开关:值存放在 {@code sys_config} 参数配置表中,运行时读取、可热切换。 * *

参数配置

*
 * 参数键名:inhospvisit.enabled
 * 参数键值:true  = 启用(默认)
 *          false = 关闭
 * 
* 建参数的幂等 SQL 见 {@code sql/出入院随访开关_sys_config.sql}。 * *

⚠️ 本开关的作用范围(2025 更正)

* *

* ⚠️ 本注释此前写的是"本开关不作用于定时任务",理由是"一次误操作就能让当天全院随访停摆, * 而定时任务没有反馈渠道"。该说法与事实不符且已更正: * 当时定时任务走 smartor 的遗留实现 {@code PatMedInhospServiceImpl}, * 它从不调用门面的编排方法(只用门面取数据,而数据方法按设计不受开关管), * 所以真实状态是"开关对定时任务无效、且无任何提示"—— * 这比"受控"更糟:它让运维以为已经关掉了,实际还在生成随访。 * 现在定时任务走门面,关闭有明确日志,参数在「系统管理 → 参数设置」里可见可改。 * 要暂停定时任务仍可去「系统管理 → 定时任务」暂停 {@code sys_job},两条路都有效。 * 详见 {@code doc/模块开关有效性核查与修复.md}。 * *

取值优先级

*
    *
  1. {@code sys_config} 里 {@code inhospvisit.enabled}(主用);
  2. *
  3. {@link InhospVisitProperties#isEnabled()}(兜底,默认 true)。
  4. *
* *

为什么读不到配置也按 true(fail-open)

* 读 {@code sys_config} 要走数据库,数据库抖动时 fail-closed 会把出入院随访整体打死 —— * 那是把"参数读取"变成"业务停摆放大器"。所以读失败只打 WARN 并放行。 *

* ⚠️ 注意本模块另有一个"类似但不该合并"的口径:yml 里的 {@code noLongTask} * ({@code ServiceExternalServiceImpl.java:83-84})按 orgid 直接跳过长期任务生成。 * 语义相近但链路不同(它在 {@code ServiceExternalServiceImpl},不在随访扫描链上), * 本次不合并,避免行为变更。 * *

为什么有 10 秒缓存

* {@code SysConfigServiceImpl#selectConfigByKey} 当前每次都查库 * (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)。 *

* 用 {@code ObjectProvider} 惰性取:本模块只依赖 ruoyi-common, * 在测试/独立部署等"容器里没有实现"的场景下不能因此启动失败。 */ private final ObjectProvider configServiceProvider; private volatile Boolean cachedEnabled; private volatile long cachedAt; /** * Spring 使用的构造器。 *

* ⚠️ 必须显式标注 {@code @Autowired}:本类有两个公开构造器 * (另一个给测试/非 Spring 环境用),不标注的话 Spring 会因"构造器不唯一" * 退回查找无参构造器,启动即报 {@code No default constructor found}。 * —— 这是满意度模块实际踩到的坑,此处刻意保留同样的写法与注释。 */ @Autowired public InhospVisitSwitch(InhospVisitProperties properties, ObjectProvider configServiceProvider) { this.properties = properties; this.configServiceProvider = configServiceProvider; } /** * 供测试/非 Spring 环境直接构造:{@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 { 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 stream() { return configService == null ? java.util.stream.Stream.empty() : java.util.stream.Stream.of(configService); } } }