package cn.iocoder.yudao.module.mes.framework.config;
|
|
import cn.hutool.core.collection.CollUtil;
|
import cn.hutool.core.util.StrUtil;
|
import cn.iocoder.yudao.framework.common.biz.system.dict.dto.DictDataRespDTO;
|
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
|
import cn.iocoder.yudao.framework.common.util.cache.CacheUtils;
|
import cn.iocoder.yudao.module.system.api.dict.DictDataApi;
|
import com.google.common.cache.CacheLoader;
|
import com.google.common.cache.LoadingCache;
|
import jakarta.annotation.Resource;
|
import lombok.extern.slf4j.Slf4j;
|
import org.springframework.stereotype.Component;
|
|
import java.time.Duration;
|
import java.util.Collections;
|
import java.util.HashMap;
|
import java.util.List;
|
import java.util.Map;
|
import java.util.Objects;
|
|
/**
|
* 种子标签配置解析器:把「字典配置值 + 配置文件兜底值」合并为一份生效快照。
|
* <p>
|
* 追溯网址、生产经营者名称等标签要素此前只能改配置文件再重启服务,本次改为可在
|
* 「系统管理 → 字典管理」的字典 {@code mes_trace_config} 中直接修改,改完无需重启。
|
* <p>
|
* 合并规则(四项配置各自独立判定):
|
* <ol>
|
* <li>字典有启用项且值非空 → 用字典值;</li>
|
* <li>字典未建该字典类型 / 无启用项 / 值为空 → 回退配置文件(正常的「尚未配置」路径);</li>
|
* <li>追溯网址的字典值非空但**不合法**(占位符、本机、内网地址等)→ 记 ERROR 日志并回退配置文件。</li>
|
* </ol>
|
* 第 3 条是有意为之的「降级而不阻断」:配置文件的值已被启动校验保证合法,回退不会印出不合规标签,
|
* 标签合规底线不受影响;反之若在此抛异常,字典里一次手误就会让全平台的标签导出停摆,代价更大。
|
* 「字典与配置文件都拿不到追溯网址」仍由 {@code MesProQrLabelBuilder} 抛异常拒绝导出。
|
* <p>
|
* 本类**任何路径都不向外抛异常**:解析发生在二维码批量导出的高频循环中,一次数据库抖动
|
* 或字典读失败都不应中断整批导出。
|
* <p>
|
* 性能:字典读取带 60 秒本地缓存({@link CacheUtils#buildAsyncReloadingCache}),
|
* 单次快照只需一次内存查表;数据库查询每 60 秒最多一次,与标签数量无关。
|
* 因此字典修改后**约 1 分钟内生效**,且因异步刷新,稀疏调用下可能需再触发一次才切换。
|
*/
|
@Slf4j
|
@Component
|
public class MesTraceConfigResolver {
|
|
/**
|
* 字典类型:种子标签追溯配置
|
*/
|
public static final String DICT_TYPE = "mes_trace_config";
|
|
/**
|
* 字典标签(固定标识,管理员可改的是「字典键值」,标签名不要改)
|
*/
|
private static final String LABEL_TRACE_URL = "追溯网址";
|
private static final String LABEL_OPERATOR_NAME = "生产经营者名称";
|
private static final String LABEL_IMPORTER_NAME = "进口商名称";
|
private static final String LABEL_IMPORTED = "是否进口种子";
|
|
/**
|
* 字典配置缓存:dictType → (字典标签 → 字典键值),仅含启用项。
|
* <p>
|
* 用本类持有的独立缓存而非 {@code DictFrameworkUtils},原因有二:一是后者把字典投影成
|
* {@code List<String>} 会丢弃 {@code status},无法过滤被禁用的配置项;二是后者的
|
* {@code dictDataApi} 是静态字段、由自动配置注入,与本类首次调用无顺序保证,存在 NPE 风险。
|
*/
|
private final LoadingCache<String, Map<String, String>> dictCache = CacheUtils.buildAsyncReloadingCache(
|
Duration.ofSeconds(60L),
|
new CacheLoader<String, Map<String, String>>() {
|
|
@Override
|
public Map<String, String> load(String dictType) {
|
return loadDictConfig(dictType);
|
}
|
|
});
|
|
@Resource
|
private DictDataApi dictDataApi;
|
|
@Resource
|
private MesProperties mesProperties;
|
|
/**
|
* 解析当前生效的种子标签配置快照
|
*
|
* @return 生效快照,各字段均已合并配置文件兜底值;绝不返回 null,也绝不抛异常
|
*/
|
public MesTraceConfigSnapshot resolveSnapshot() {
|
Map<String, String> dict = getDictConfig();
|
|
// 1. 追溯网址:字典优先,但必须先过合规校验,否则回退配置文件
|
String dictTraceUrl = dict.get(LABEL_TRACE_URL);
|
String yamlTraceUrl = StrUtil.trimToEmpty(mesProperties.getTraceBaseUrl());
|
String traceUrl;
|
MesTraceUrlSource traceUrlSource;
|
if (StrUtil.isEmpty(dictTraceUrl)) {
|
// 字典未配置(或留空/被禁用):正常的回退路径,不是错误
|
traceUrl = yamlTraceUrl;
|
traceUrlSource = MesTraceUrlSource.YAML;
|
} else if (MesTraceUrlValidator.isValid(dictTraceUrl)) {
|
traceUrl = dictTraceUrl;
|
traceUrlSource = MesTraceUrlSource.DICT;
|
} else {
|
log.error("[resolveSnapshot][字典({})的「{}」配置了非法地址:{}({}),已回退配置文件的值 {},请尽快修正]",
|
DICT_TYPE, LABEL_TRACE_URL, dictTraceUrl,
|
MesTraceUrlValidator.invalidReason(dictTraceUrl).orElse(""), yamlTraceUrl);
|
traceUrl = yamlTraceUrl;
|
traceUrlSource = MesTraceUrlSource.YAML_INVALID_DICT;
|
}
|
|
// 2. 生产经营者名称 / 进口商名称:文本类配置无需合规校验,字典非空即生效
|
String operatorName = firstNonBlank(dict.get(LABEL_OPERATOR_NAME), mesProperties.getOperatorName());
|
String importerName = firstNonBlank(dict.get(LABEL_IMPORTER_NAME), mesProperties.getImporterName());
|
|
return new MesTraceConfigSnapshot(traceUrl, traceUrlSource, operatorName, importerName,
|
resolveImported(dict.get(LABEL_IMPORTED)));
|
}
|
|
/**
|
* 读取字典配置(带缓存),读取失败时返回空表以回退配置文件,绝不抛异常
|
*/
|
private Map<String, String> getDictConfig() {
|
try {
|
Map<String, String> dictConfig = dictCache.get(DICT_TYPE);
|
return dictConfig != null ? dictConfig : Collections.emptyMap();
|
} catch (Exception ex) {
|
log.error("[getDictConfig][读取字典({})失败,本次回退配置文件的值]", DICT_TYPE, ex);
|
return Collections.emptyMap();
|
}
|
}
|
|
/**
|
* 加载并整理字典配置:只保留启用项,整理成「字典标签 → 字典键值」的映射
|
* <p>
|
* 同一标签重复配置时以先出现者为准——{@code DictDataApi#getDictDataList} 底层已按 {@code sort} 排序,
|
* 因此「先出现」即「排序值更小」,此处无需再排序。
|
*/
|
private Map<String, String> loadDictConfig(String dictType) {
|
try {
|
List<DictDataRespDTO> list = dictDataApi.getDictDataList(dictType);
|
if (CollUtil.isEmpty(list)) {
|
return Collections.emptyMap();
|
}
|
Map<String, String> result = new HashMap<>();
|
for (DictDataRespDTO data : list) {
|
// 被禁用的字典项视为「未配置」:这是管理员在不删数据的前提下停用某项配置的手段
|
if (!Objects.equals(CommonStatusEnum.ENABLE.getStatus(), data.getStatus())) {
|
continue;
|
}
|
if (StrUtil.isBlank(data.getLabel())) {
|
continue;
|
}
|
result.putIfAbsent(data.getLabel().trim(), StrUtil.trimToEmpty(data.getValue()));
|
}
|
return result;
|
} catch (Exception ex) {
|
// 缓存首次 get 时加载异常会直接抛给调用方,必须在此吞掉,否则会中断整批标签导出
|
log.error("[loadDictConfig][读取字典({})失败,本次回退配置文件的值]", dictType, ex);
|
return Collections.emptyMap();
|
}
|
}
|
|
/**
|
* 解析「是否进口种子」:字典项为空或取值无法识别时回退配置文件
|
*/
|
private boolean resolveImported(String dictValue) {
|
String value = StrUtil.trim(dictValue);
|
if (StrUtil.isEmpty(value)) {
|
return mesProperties.isImported();
|
}
|
if (StrUtil.equalsAnyIgnoreCase(value, "true", "1", "y", "yes", "是")) {
|
return true;
|
}
|
if (StrUtil.equalsAnyIgnoreCase(value, "false", "0", "n", "no", "否")) {
|
return false;
|
}
|
log.error("[resolveImported][字典({})的「{}」取值无法识别:{},已回退配置文件的值 {},请填写 true 或 false]",
|
DICT_TYPE, LABEL_IMPORTED, value, mesProperties.isImported());
|
return mesProperties.isImported();
|
}
|
|
/**
|
* 取首个非空值:字典值优先,为空时回退配置文件值
|
*/
|
private static String firstNonBlank(String preferred, String fallback) {
|
return StrUtil.isNotBlank(preferred) ? preferred.trim() : StrUtil.trimToEmpty(fallback);
|
}
|
|
}
|