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;
/**
* 种子标签配置解析器:把「字典配置值 + 配置文件兜底值」合并为一份生效快照。
*
* 追溯网址、生产经营者名称等标签要素此前只能改配置文件再重启服务,本次改为可在
* 「系统管理 → 字典管理」的字典 {@code mes_trace_config} 中直接修改,改完无需重启。
*
* 合并规则(四项配置各自独立判定):
*
* - 字典有启用项且值非空 → 用字典值;
* - 字典未建该字典类型 / 无启用项 / 值为空 → 回退配置文件(正常的「尚未配置」路径);
* - 追溯网址的字典值非空但**不合法**(占位符、本机、内网地址等)→ 记 ERROR 日志并回退配置文件。
*
* 第 3 条是有意为之的「降级而不阻断」:配置文件的值已被启动校验保证合法,回退不会印出不合规标签,
* 标签合规底线不受影响;反之若在此抛异常,字典里一次手误就会让全平台的标签导出停摆,代价更大。
* 「字典与配置文件都拿不到追溯网址」仍由 {@code MesProQrLabelBuilder} 抛异常拒绝导出。
*
* 本类**任何路径都不向外抛异常**:解析发生在二维码批量导出的高频循环中,一次数据库抖动
* 或字典读失败都不应中断整批导出。
*
* 性能:字典读取带 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 → (字典标签 → 字典键值),仅含启用项。
*
* 用本类持有的独立缓存而非 {@code DictFrameworkUtils},原因有二:一是后者把字典投影成
* {@code List} 会丢弃 {@code status},无法过滤被禁用的配置项;二是后者的
* {@code dictDataApi} 是静态字段、由自动配置注入,与本类首次调用无顺序保证,存在 NPE 风险。
*/
private final LoadingCache> dictCache = CacheUtils.buildAsyncReloadingCache(
Duration.ofSeconds(60L),
new CacheLoader>() {
@Override
public Map load(String dictType) {
return loadDictConfig(dictType);
}
});
@Resource
private DictDataApi dictDataApi;
@Resource
private MesProperties mesProperties;
/**
* 解析当前生效的种子标签配置快照
*
* @return 生效快照,各字段均已合并配置文件兜底值;绝不返回 null,也绝不抛异常
*/
public MesTraceConfigSnapshot resolveSnapshot() {
Map 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 getDictConfig() {
try {
Map dictConfig = dictCache.get(DICT_TYPE);
return dictConfig != null ? dictConfig : Collections.emptyMap();
} catch (Exception ex) {
log.error("[getDictConfig][读取字典({})失败,本次回退配置文件的值]", DICT_TYPE, ex);
return Collections.emptyMap();
}
}
/**
* 加载并整理字典配置:只保留启用项,整理成「字典标签 → 字典键值」的映射
*
* 同一标签重复配置时以先出现者为准——{@code DictDataApi#getDictDataList} 底层已按 {@code sort} 排序,
* 因此「先出现」即「排序值更小」,此处无需再排序。
*/
private Map loadDictConfig(String dictType) {
try {
List list = dictDataApi.getDictDataList(dictType);
if (CollUtil.isEmpty(list)) {
return Collections.emptyMap();
}
Map 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);
}
}