新老风控组合校验: 老风控Error/Warning,新风控需审批——点击特批即两边都放行 老风控Error/Warning,新风控禁止——不展示 老风控Error/Warning——展示特批,且只特批老风控。 新增风控流程日志
812 lines
38 KiB
C#
812 lines
38 KiB
C#
using Newtonsoft.Json.Linq;
|
||
using System;
|
||
using System.Collections.Concurrent;
|
||
using System.Collections.Generic;
|
||
using System.Globalization;
|
||
using System.Linq;
|
||
using YLErp.Commons;
|
||
using YLErp.DBModels;
|
||
using YLErp.Modules.RiskEngine.Dto;
|
||
|
||
namespace YLErp.Modules.RiskEngine
|
||
{
|
||
/// <summary>
|
||
/// 结构化规则执行器。
|
||
/// 用于执行现有 ConditionJson:变量取值、按 DataType 转换并比较阈值;复杂业务计算放在变量表达式中完成。
|
||
/// </summary>
|
||
public static class StructuredRuleExecutor
|
||
{
|
||
/// <summary>
|
||
/// 变量表达式编译缓存。Key 包含变量 ID、版本、更新时间和表达式哈希,避免变量变更后复用旧委托。
|
||
/// </summary>
|
||
private static readonly ConcurrentDictionary<string, Func<RiskContext, object>> VariableCompiledCache = new ConcurrentDictionary<string, Func<RiskContext, object>>();
|
||
|
||
/// <summary>
|
||
/// 按变量 ID 增量移除变量表达式编译缓存。
|
||
/// </summary>
|
||
public static void RemoveVariableCompiledCache(long variableId)
|
||
{
|
||
var keyPrefix = variableId + ":";
|
||
// 缓存 Key 以变量 ID 开头,变量修改/删除时只清理该变量相关的编译结果。
|
||
foreach (var key in VariableCompiledCache.Keys.Where(k => k.StartsWith(keyPrefix, StringComparison.Ordinal)))
|
||
{
|
||
VariableCompiledCache.TryRemove(key, out _);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 按变量 ID 集合批量移除变量表达式编译缓存。
|
||
/// </summary>
|
||
public static void RemoveVariableCompiledCache(IEnumerable<long> variableIds)
|
||
{
|
||
if (variableIds == null)
|
||
return;
|
||
|
||
var idSet = new HashSet<long>(variableIds.Distinct());
|
||
if (idSet.Count == 0)
|
||
return;
|
||
|
||
// 批量移除时只扫描一次缓存 Key,避免变量数较多时重复遍历整个缓存。
|
||
foreach (var key in VariableCompiledCache.Keys)
|
||
{
|
||
var separatorIndex = key.IndexOf(':');
|
||
if (separatorIndex <= 0)
|
||
continue;
|
||
if (long.TryParse(key.Substring(0, separatorIndex), NumberStyles.None, CultureInfo.InvariantCulture, out var variableId) && idSet.Contains(variableId))
|
||
{
|
||
VariableCompiledCache.TryRemove(key, out _);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 清空全部变量表达式编译缓存。仅用于风控缓存全量刷新场景。
|
||
/// </summary>
|
||
public static void ClearVariableCompiledCache()
|
||
{
|
||
VariableCompiledCache.Clear();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 数值和日期类型支持的比较操作符。
|
||
/// </summary>
|
||
private static readonly HashSet<string> ComparableOperators = new HashSet<string>(StringComparer.Ordinal)
|
||
{
|
||
"gt", "lt", "gte", "lte", "eq", "ne", "between", "notBetween"
|
||
};
|
||
|
||
/// <summary>
|
||
/// 布尔类型支持的判断操作符。
|
||
/// </summary>
|
||
private static readonly HashSet<string> BooleanOperators = new HashSet<string>(StringComparer.Ordinal)
|
||
{
|
||
"isTrue", "isFalse"
|
||
};
|
||
|
||
/// <summary>
|
||
/// 执行一组结构化条件。当前规则条件之间按 AND 关系处理,任一条件不满足则整条规则不触发。
|
||
/// </summary>
|
||
public static StructuredRuleExecuteResult Execute(
|
||
IReadOnlyList<RuleCondition> conditions,
|
||
IReadOnlyDictionary<long, glms_risk_variable> variables,
|
||
RiskContext context)
|
||
{
|
||
try
|
||
{
|
||
if (conditions == null || conditions.Count == 0)
|
||
return StructuredRuleExecuteResult.Fail("公式条件列表不能为空");
|
||
if (variables == null)
|
||
return StructuredRuleExecuteResult.Fail("变量列表不能为空");
|
||
|
||
var triggerMessages = new List<string>();
|
||
for (var i = 0; i < conditions.Count; i++)
|
||
{
|
||
var conditionResult = ExecuteCondition(conditions[i], variables, context, i + 1);
|
||
if (!conditionResult.Success)
|
||
return conditionResult;
|
||
// 结构化规则当前按 AND 执行,任一条件不命中即可短路返回,避免后续变量表达式继续查库。
|
||
if (!conditionResult.Triggered)
|
||
return StructuredRuleExecuteResult.Ok(false);
|
||
if (!string.IsNullOrWhiteSpace(conditionResult.Message))
|
||
triggerMessages.Add(conditionResult.Message);
|
||
}
|
||
|
||
// 所有条件都命中时,合并每个条件的实际值/阈值说明,供最终风控结果展示。
|
||
return StructuredRuleExecuteResult.Ok(true, string.Join(";", triggerMessages));
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
return StructuredRuleExecuteResult.Fail(ex.Message);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 执行单个结构化条件,负责按变量数据类型分派到布尔、普通比较或区间比较逻辑。
|
||
/// </summary>
|
||
private static StructuredRuleExecuteResult ExecuteCondition(
|
||
RuleCondition condition,
|
||
IReadOnlyDictionary<long, glms_risk_variable> variables,
|
||
RiskContext context,
|
||
int conditionIndex)
|
||
{
|
||
var label = $"条件{conditionIndex}";
|
||
if (condition == null)
|
||
return StructuredRuleExecuteResult.Fail($"{label}不能为空");
|
||
if (!variables.TryGetValue(condition.VariableId, out var variable))
|
||
return StructuredRuleExecuteResult.Fail($"{label}:变量 ID '{condition.VariableId}' 不存在");
|
||
|
||
// 布尔变量没有阈值概念,只能走 isTrue/isFalse 分支,避免被后续数值/日期比较逻辑误处理。
|
||
if (variable.DataType == RiskVariableDataType.Boolean)
|
||
return ExecuteBooleanCondition(condition, variable, context, label);
|
||
|
||
if (variable.DataType != RiskVariableDataType.Numeric && variable.DataType != RiskVariableDataType.Date)
|
||
return StructuredRuleExecuteResult.Fail($"{label}:不支持的数据类型");
|
||
if (!ComparableOperators.Contains(condition.Operator))
|
||
return StructuredRuleExecuteResult.Fail($"{label}:操作符 '{condition.Operator}' 不适用于{GetDataTypeName(variable.DataType)}类型变量");
|
||
|
||
// 左侧条件变量统一先执行并转换成声明的数据类型;后续普通比较和区间比较都复用这个结果。
|
||
var left = GetVariableValue(variable, variable.DataType, context, label, "条件变量");
|
||
if (!left.Success)
|
||
return StructuredRuleExecuteResult.Fail(left.ErrorMessage);
|
||
|
||
// 区间操作符使用下限/上限字段,不能继续走普通阈值字段。
|
||
if (condition.Operator == "between" || condition.Operator == "notBetween")
|
||
return ExecuteRangeCondition(condition, variable, left, variables, context, label);
|
||
|
||
var rangeFieldCheck = EnsureNoRangeFields(condition, label);
|
||
if (!rangeFieldCheck.Success)
|
||
return rangeFieldCheck;
|
||
|
||
var right = GetThresholdValue(
|
||
condition.ThresholdType,
|
||
condition.Value,
|
||
condition.ThresholdVariableId,
|
||
variable,
|
||
variables,
|
||
context,
|
||
label,
|
||
"阈值");
|
||
if (!right.Success)
|
||
return StructuredRuleExecuteResult.Fail(right.ErrorMessage);
|
||
|
||
var compareResult = Compare(left.Value, right.Value, variable.DataType, condition.Operator, label);
|
||
if (!compareResult.Success)
|
||
return compareResult;
|
||
|
||
return StructuredRuleExecuteResult.Ok(
|
||
compareResult.Triggered,
|
||
// 未命中时不构造提示,减少字符串拼接开销;只有真正触发才展示实际值和阈值。
|
||
compareResult.Triggered ? BuildCompareMessage(variable, left, right, condition.Operator) : null);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 执行布尔条件。布尔变量只允许 isTrue/isFalse,不允许配置固定值、变量阈值或区间字段。
|
||
/// </summary>
|
||
private static StructuredRuleExecuteResult ExecuteBooleanCondition(
|
||
RuleCondition condition,
|
||
glms_risk_variable variable,
|
||
RiskContext context,
|
||
string label)
|
||
{
|
||
if (!BooleanOperators.Contains(condition.Operator))
|
||
return StructuredRuleExecuteResult.Fail($"{label}:操作符 '{condition.Operator}' 不适用于布尔类型变量");
|
||
var thresholdFieldCheck = EnsureNoThresholdFields(condition, label);
|
||
if (!thresholdFieldCheck.Success)
|
||
return thresholdFieldCheck;
|
||
|
||
var value = GetVariableValue(variable, RiskVariableDataType.Boolean, context, label, "条件变量");
|
||
if (!value.Success)
|
||
return StructuredRuleExecuteResult.Fail(value.ErrorMessage);
|
||
|
||
var expected = condition.Operator == "isTrue";
|
||
var triggered = (bool)value.Value == expected;
|
||
return StructuredRuleExecuteResult.Ok(
|
||
triggered,
|
||
triggered ? BuildBooleanMessage(variable, value, expected) : null);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 执行区间条件。下限和上限可以分别来自固定值或变量,并根据开闭区间转换为对应比较操作符。
|
||
/// </summary>
|
||
private static StructuredRuleExecuteResult ExecuteRangeCondition(
|
||
RuleCondition condition,
|
||
glms_risk_variable variable,
|
||
ValueExecuteResult left,
|
||
IReadOnlyDictionary<long, glms_risk_variable> variables,
|
||
RiskContext context,
|
||
string label)
|
||
{
|
||
if (condition.ThresholdType != null || HasValue(condition.Value) || condition.ThresholdVariableId.HasValue)
|
||
return StructuredRuleExecuteResult.Fail($"{label}:区间条件不得携带普通阈值字段");
|
||
if (!condition.IncludeLower.HasValue || !condition.IncludeUpper.HasValue)
|
||
return StructuredRuleExecuteResult.Fail($"{label}:区间开闭配置不完整");
|
||
|
||
// 固定上下限可以提前比较顺序;变量上下限依赖运行时查询结果,留到后面按实际值比较。
|
||
var fixedRangeCheck = ValidateFixedRangeOrder(condition, variable.DataType, label);
|
||
if (!fixedRangeCheck.Success)
|
||
return fixedRangeCheck;
|
||
|
||
var lower = GetThresholdValue(condition.LowerThresholdType, condition.LowerValue, condition.LowerThresholdVariableId, variable, variables, context, label, "下限");
|
||
if (!lower.Success)
|
||
return StructuredRuleExecuteResult.Fail(lower.ErrorMessage);
|
||
|
||
var upper = GetThresholdValue(condition.UpperThresholdType, condition.UpperValue, condition.UpperThresholdVariableId, variable, variables, context, label, "上限");
|
||
if (!upper.Success)
|
||
return StructuredRuleExecuteResult.Fail(upper.ErrorMessage);
|
||
|
||
// 将区间开闭配置转换为普通比较:闭区间使用 >=/<=,开区间使用 >/<。
|
||
var lowerOperator = condition.IncludeLower == true ? "gte" : "gt";
|
||
var upperOperator = condition.IncludeUpper == true ? "lte" : "lt";
|
||
var lowerCompare = Compare(left.Value, lower.Value, variable.DataType, lowerOperator, label);
|
||
if (!lowerCompare.Success)
|
||
return lowerCompare;
|
||
var upperCompare = Compare(left.Value, upper.Value, variable.DataType, upperOperator, label);
|
||
if (!upperCompare.Success)
|
||
return upperCompare;
|
||
|
||
var inRange = lowerCompare.Triggered && upperCompare.Triggered;
|
||
// between 要求落在区间内,notBetween 则取反;提示同样只在命中时构造。
|
||
var triggered = condition.Operator == "notBetween" ? !inRange : inRange;
|
||
return StructuredRuleExecuteResult.Ok(
|
||
triggered,
|
||
triggered ? BuildRangeMessage(variable, left, lower, upper, condition.IncludeLower == true, condition.IncludeUpper == true, condition.Operator) : null);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 获取阈值。固定阈值直接转换;变量阈值先校验类型和单位,再执行阈值变量表达式。
|
||
/// </summary>
|
||
private static ValueExecuteResult GetThresholdValue(
|
||
string thresholdType,
|
||
object fixedValue,
|
||
long? thresholdVariableId,
|
||
glms_risk_variable conditionVariable,
|
||
IReadOnlyDictionary<long, glms_risk_variable> variables,
|
||
RiskContext context,
|
||
string label,
|
||
string thresholdLabel)
|
||
{
|
||
if (string.Equals(thresholdType, "Fixed", StringComparison.Ordinal))
|
||
{
|
||
if (thresholdVariableId.HasValue)
|
||
return ValueExecuteResult.Fail($"{label}:固定{thresholdLabel}不得携带变量 ID");
|
||
var fixedResult = ConvertValue(fixedValue, conditionVariable.DataType, label, $"固定{thresholdLabel}");
|
||
if (!fixedResult.Success)
|
||
return ValueExecuteResult.Fail($"{fixedResult.ErrorMessage},原始值:{FormatRawValue(fixedValue)}");
|
||
return fixedResult;
|
||
}
|
||
|
||
if (string.Equals(thresholdType, "Variable", StringComparison.Ordinal))
|
||
{
|
||
if (HasValue(fixedValue))
|
||
return ValueExecuteResult.Fail($"{label}:变量{thresholdLabel}不得携带固定值");
|
||
if (!thresholdVariableId.HasValue)
|
||
return ValueExecuteResult.Fail($"{label}:{thresholdLabel}变量 ID 不能为空");
|
||
if (!variables.TryGetValue(thresholdVariableId.Value, out var thresholdVariable))
|
||
return ValueExecuteResult.Fail($"{label}:{thresholdLabel}变量 ID '{thresholdVariableId.Value}' 不存在");
|
||
if (thresholdVariable.DataType != conditionVariable.DataType)
|
||
return ValueExecuteResult.Fail($"{label}:{thresholdLabel}变量与条件变量的数据类型不一致");
|
||
// 数值变量允许配置单位;变量阈值必须与条件变量单位一致,避免“金额”和“比例”等不同口径被直接比较。
|
||
if (conditionVariable.DataType == RiskVariableDataType.Numeric && !UnitsMatch(conditionVariable.Unit, thresholdVariable.Unit))
|
||
return ValueExecuteResult.Fail($"{label}:{thresholdLabel}变量与条件变量的单位不一致");
|
||
|
||
return GetVariableValue(thresholdVariable, conditionVariable.DataType, context, label, thresholdLabel);
|
||
}
|
||
|
||
return ValueExecuteResult.Fail($"{label}:{thresholdLabel}类型 '{thresholdType}' 不合法,仅支持 Fixed/Variable");
|
||
}
|
||
|
||
/// <summary>
|
||
/// 执行变量取值表达式,并将表达式返回值转换为变量声明的数据类型。
|
||
/// </summary>
|
||
private static ValueExecuteResult GetVariableValue(
|
||
glms_risk_variable variable,
|
||
RiskVariableDataType dataType,
|
||
RiskContext context,
|
||
string label,
|
||
string valueLabel)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(variable.VariableExpr))
|
||
return ValueExecuteResult.Fail($"{label}:变量'{variable.VariableName}'(ID:{variable.id})的取值表达式为空");
|
||
|
||
var compiled = GetCompiledVariableExpression(variable, out var errorMessage);
|
||
if (compiled == null)
|
||
return ValueExecuteResult.Fail($"{label}:变量'{variable.VariableName}'(ID:{variable.id})编译失败:{errorMessage}");
|
||
|
||
object rawValue;
|
||
try
|
||
{
|
||
rawValue = compiled(context);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
return ValueExecuteResult.Fail($"{label}:变量'{variable.VariableName}'(ID:{variable.id})执行失败:{ex.Message}");
|
||
}
|
||
|
||
// 变量表达式可选择返回 RiskVariableValueDetail:Value 参与比较,Message 只用于命中说明。
|
||
// 普通变量表达式仍返回原始值,不需要修改历史变量配置。
|
||
var detail = rawValue as RiskVariableValueDetail;
|
||
var actualRawValue = detail?.Value ?? rawValue;
|
||
var converted = ConvertValue(actualRawValue, dataType, label, valueLabel);
|
||
if (!converted.Success)
|
||
return ValueExecuteResult.Fail($"{label}:变量'{variable.VariableName}'(ID:{variable.id}){converted.ErrorMessage},原始值:{FormatRawValue(actualRawValue)}");
|
||
|
||
converted.DetailMessage = detail?.Message;
|
||
converted.DetailItems = detail?.Items;
|
||
return converted;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 获取变量表达式的编译委托。缓存 Key 绑定变量版本信息,变量内容变化后会自然生成新 Key。
|
||
/// </summary>
|
||
private static Func<RiskContext, object> GetCompiledVariableExpression(glms_risk_variable variable, out string errorMessage)
|
||
{
|
||
var cacheKey = $"{variable.id}:{variable.Version}:{variable.UpdateDate?.Ticks ?? 0}:{StringComparer.Ordinal.GetHashCode(variable.VariableExpr ?? string.Empty)}";
|
||
if (VariableCompiledCache.TryGetValue(cacheKey, out var compiled))
|
||
{
|
||
errorMessage = null;
|
||
return compiled;
|
||
}
|
||
|
||
// 变量表达式只负责取原始值,类型转换统一在结构化执行器中处理;这里缓存编译结果,避免每次执行重复 Roslyn 编译。
|
||
compiled = RuleCompiler.CompileValueExpression(variable.VariableExpr, out errorMessage);
|
||
if (compiled != null)
|
||
VariableCompiledCache[cacheKey] = compiled;
|
||
return compiled;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 按变量数据类型统一转换值。日期固定值只接受 yyyy-MM-dd,避免不同区域格式导致比较结果不一致。
|
||
/// </summary>
|
||
private static ValueExecuteResult ConvertValue(object value, RiskVariableDataType dataType, string label, string valueLabel)
|
||
{
|
||
if (!HasValue(value))
|
||
return ValueExecuteResult.Fail($"{valueLabel}为空");
|
||
|
||
if (dataType == RiskVariableDataType.Numeric)
|
||
{
|
||
if (value is decimal decimalValue)
|
||
return ValueExecuteResult.Ok(decimalValue);
|
||
// 数据库字段可能返回 double/int/string/JValue,统一按 InvariantCulture 转 decimal,避免服务器区域设置影响小数点解析。
|
||
if (decimal.TryParse(GetRawValue(value), NumberStyles.Float, CultureInfo.InvariantCulture, out var numericValue))
|
||
return ValueExecuteResult.Ok(numericValue);
|
||
return ValueExecuteResult.Fail($"{valueLabel}必须为数字");
|
||
}
|
||
|
||
if (dataType == RiskVariableDataType.Date)
|
||
{
|
||
if (value is DateTime dateValue)
|
||
return ValueExecuteResult.Ok(dateValue.Date);
|
||
if (DateTime.TryParseExact(GetRawValue(value), "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var fixedDate))
|
||
return ValueExecuteResult.Ok(fixedDate.Date);
|
||
return ValueExecuteResult.Fail($"{valueLabel}必须为 yyyy-MM-dd 格式的合法日期");
|
||
}
|
||
|
||
if (dataType == RiskVariableDataType.Boolean)
|
||
{
|
||
if (value is bool boolValue)
|
||
return ValueExecuteResult.Ok(boolValue);
|
||
if (bool.TryParse(GetRawValue(value), out var parsedBool))
|
||
return ValueExecuteResult.Ok(parsedBool);
|
||
return ValueExecuteResult.Fail($"{valueLabel}必须为布尔值");
|
||
}
|
||
|
||
return ValueExecuteResult.Fail($"{label}:不支持的数据类型");
|
||
}
|
||
|
||
/// <summary>
|
||
/// 比较两个已转换为同一数据类型的值,并应用结构化规则操作符。
|
||
/// </summary>
|
||
private static StructuredRuleExecuteResult Compare(object left, object right, RiskVariableDataType dataType, string ruleOperator, string label)
|
||
{
|
||
// 前置 ConvertValue 已保证左右值类型一致,这里只做最终大小关系计算。
|
||
var compare = dataType switch
|
||
{
|
||
RiskVariableDataType.Numeric => ((decimal)left).CompareTo((decimal)right),
|
||
RiskVariableDataType.Date => ((DateTime)left).CompareTo((DateTime)right),
|
||
_ => throw new ServiceException($"{label}:不支持的数据类型")
|
||
};
|
||
|
||
var result = ruleOperator switch
|
||
{
|
||
"gt" => compare > 0,
|
||
"lt" => compare < 0,
|
||
"gte" => compare >= 0,
|
||
"lte" => compare <= 0,
|
||
"eq" => compare == 0,
|
||
"ne" => compare != 0,
|
||
_ => throw new ServiceException($"{label}:不支持的操作符'{ruleOperator}'")
|
||
};
|
||
|
||
return StructuredRuleExecuteResult.Ok(result);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 构建普通比较条件的命中说明。变量表达式如果返回 RiskVariableValueDetail,会优先带出自定义明细。
|
||
/// </summary>
|
||
private static string BuildCompareMessage(glms_risk_variable variable, ValueExecuteResult left, ValueExecuteResult right, string ruleOperator)
|
||
{
|
||
var parts = new List<string>();
|
||
parts.Add($"触发判断:{variable.VariableName}为{FormatDisplayValue(left.Value)},阈值为{FormatDisplayValue(right.Value)},比较关系:{GetOperatorName(ruleOperator)}");
|
||
AddCompareDetails(parts, variable, left, right, ruleOperator);
|
||
AddDetail(parts, right.DetailMessage);
|
||
return string.Join(";", parts);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 按当前规则阈值重新判断变量返回的逐条明细,并将明细拆分为超过阈值和未超过阈值两组。
|
||
/// 该逻辑只在规则已经命中且需要构造提示时执行,不参与规则主判断;未提供逐条明细时保留原始明细文本。
|
||
/// </summary>
|
||
private static void AddCompareDetails(List<string> parts, glms_risk_variable variable, ValueExecuteResult left, ValueExecuteResult right, string ruleOperator)
|
||
{
|
||
var detailItems = left.DetailItems;
|
||
if (detailItems == null || detailItems.Count == 0)
|
||
{
|
||
AddDetail(parts, left.DetailMessage);
|
||
return;
|
||
}
|
||
|
||
AddDetail(parts, left.DetailMessage);
|
||
var triggeredItems = new List<string>(detailItems.Count);
|
||
var untriggeredItems = new List<string>();
|
||
foreach (var detailItem in detailItems)
|
||
{
|
||
if (detailItem == null || string.IsNullOrWhiteSpace(detailItem.Message))
|
||
continue;
|
||
if (!TryEvaluateDetailTrigger(detailItem.Value, right.Value, variable.DataType, ruleOperator, out var triggered))
|
||
continue;
|
||
|
||
if (triggered)
|
||
triggeredItems.Add(detailItem.Message);
|
||
else
|
||
untriggeredItems.Add(detailItem.Message);
|
||
}
|
||
|
||
if (triggeredItems.Count > 0)
|
||
parts.Add($"{variable.VariableName}超过阈值的记录:" + string.Join(";", triggeredItems));
|
||
if (untriggeredItems.Count > 0)
|
||
parts.Add($"{variable.VariableName}未超过阈值的其他偏离记录:" + string.Join(";", untriggeredItems));
|
||
}
|
||
|
||
/// <summary>
|
||
/// 复用普通比较条件的类型转换口径,判断单条变量明细是否满足当前条件。
|
||
/// 为减少命中提示阶段的额外开销,这里只做一次明细值转换,并直接用 CompareTo 完成比较。
|
||
/// 返回 false 表示明细值无法转换或操作符不适用于当前类型,此类异常明细不影响主规则结果。
|
||
/// </summary>
|
||
private static bool TryEvaluateDetailTrigger(object detailValue, object thresholdValue, RiskVariableDataType dataType, string ruleOperator, out bool triggered)
|
||
{
|
||
triggered = false;
|
||
if (dataType != RiskVariableDataType.Numeric && dataType != RiskVariableDataType.Date)
|
||
return false;
|
||
|
||
var itemValue = ConvertValue(detailValue, dataType, "明细", "明细值");
|
||
if (!itemValue.Success)
|
||
return false;
|
||
|
||
var compare = dataType == RiskVariableDataType.Numeric
|
||
? ((decimal)itemValue.Value).CompareTo((decimal)thresholdValue)
|
||
: ((DateTime)itemValue.Value).CompareTo((DateTime)thresholdValue);
|
||
|
||
triggered = ruleOperator switch
|
||
{
|
||
"gt" => compare > 0,
|
||
"lt" => compare < 0,
|
||
"gte" => compare >= 0,
|
||
"lte" => compare <= 0,
|
||
"eq" => compare == 0,
|
||
"ne" => compare != 0,
|
||
_ => false
|
||
};
|
||
return ruleOperator is "gt" or "lt" or "gte" or "lte" or "eq" or "ne";
|
||
}
|
||
|
||
/// <summary>
|
||
/// 构建区间条件的命中说明,包含实际值、上下限和区间开闭配置。
|
||
/// </summary>
|
||
private static string BuildRangeMessage(glms_risk_variable variable, ValueExecuteResult left, ValueExecuteResult lower, ValueExecuteResult upper, bool includeLower, bool includeUpper, string ruleOperator)
|
||
{
|
||
var parts = new List<string>();
|
||
AddDetail(parts, left.DetailMessage);
|
||
parts.Add($"{variable.VariableName}为{FormatDisplayValue(left.Value)}");
|
||
AddDetail(parts, lower.DetailMessage);
|
||
AddDetail(parts, upper.DetailMessage);
|
||
parts.Add($"阈值区间为{(includeLower ? "[" : "(")}{FormatDisplayValue(lower.Value)}, {FormatDisplayValue(upper.Value)}{(includeUpper ? "]" : ")")}");
|
||
parts.Add(ruleOperator == "notBetween" ? "要求不在区间内" : "要求在区间内");
|
||
return string.Join(",", parts);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 构建布尔条件的命中说明。
|
||
/// </summary>
|
||
private static string BuildBooleanMessage(glms_risk_variable variable, ValueExecuteResult value, bool expected)
|
||
{
|
||
var parts = new List<string>();
|
||
AddDetail(parts, value.DetailMessage);
|
||
parts.Add($"{variable.VariableName}为{FormatDisplayValue(value.Value)}");
|
||
parts.Add($"期望为{FormatDisplayValue(expected)}");
|
||
return string.Join(",", parts);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 添加变量表达式返回的自定义明细,避免空明细污染最终提示。
|
||
/// </summary>
|
||
private static void AddDetail(List<string> parts, string detailMessage)
|
||
{
|
||
if (!string.IsNullOrWhiteSpace(detailMessage))
|
||
parts.Add(detailMessage.Trim());
|
||
}
|
||
|
||
/// <summary>
|
||
/// 校验非区间条件没有携带区间字段,避免前端残留字段影响规则语义。
|
||
/// </summary>
|
||
private static StructuredRuleExecuteResult EnsureNoRangeFields(RuleCondition condition, string label)
|
||
{
|
||
if (condition.LowerThresholdType != null || HasValue(condition.LowerValue) || condition.LowerThresholdVariableId.HasValue ||
|
||
condition.UpperThresholdType != null || HasValue(condition.UpperValue) || condition.UpperThresholdVariableId.HasValue ||
|
||
condition.IncludeLower.HasValue || condition.IncludeUpper.HasValue)
|
||
{
|
||
return StructuredRuleExecuteResult.Fail($"{label}:非区间条件不得携带区间字段");
|
||
}
|
||
|
||
return StructuredRuleExecuteResult.Ok(false);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 校验布尔条件没有携带阈值或区间字段,布尔判断只由变量值和 isTrue/isFalse 决定。
|
||
/// </summary>
|
||
private static StructuredRuleExecuteResult EnsureNoThresholdFields(RuleCondition condition, string label)
|
||
{
|
||
if (condition.ThresholdType != null || HasValue(condition.Value) || condition.ThresholdVariableId.HasValue ||
|
||
condition.LowerThresholdType != null || HasValue(condition.LowerValue) || condition.LowerThresholdVariableId.HasValue ||
|
||
condition.UpperThresholdType != null || HasValue(condition.UpperValue) || condition.UpperThresholdVariableId.HasValue ||
|
||
condition.IncludeLower.HasValue || condition.IncludeUpper.HasValue)
|
||
{
|
||
return StructuredRuleExecuteResult.Fail($"{label}:布尔条件不得携带阈值字段");
|
||
}
|
||
|
||
return StructuredRuleExecuteResult.Ok(false);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 当区间上下限都是固定值时,提前校验下限不能大于上限;变量上下限留到运行时按实际值比较。
|
||
/// </summary>
|
||
private static StructuredRuleExecuteResult ValidateFixedRangeOrder(RuleCondition condition, RiskVariableDataType dataType, string label)
|
||
{
|
||
if (condition.LowerThresholdType != "Fixed" || condition.UpperThresholdType != "Fixed")
|
||
return StructuredRuleExecuteResult.Ok(false);
|
||
|
||
var lower = GetRawValue(condition.LowerValue);
|
||
var upper = GetRawValue(condition.UpperValue);
|
||
if (dataType == RiskVariableDataType.Numeric &&
|
||
decimal.TryParse(lower, NumberStyles.Float, CultureInfo.InvariantCulture, out var lowerNumber) &&
|
||
decimal.TryParse(upper, NumberStyles.Float, CultureInfo.InvariantCulture, out var upperNumber) &&
|
||
lowerNumber > upperNumber)
|
||
{
|
||
return StructuredRuleExecuteResult.Fail($"{label}:下限不能大于上限");
|
||
}
|
||
|
||
if (dataType == RiskVariableDataType.Date &&
|
||
DateTime.TryParseExact(lower, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var lowerDate) &&
|
||
DateTime.TryParseExact(upper, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var upperDate) &&
|
||
lowerDate > upperDate)
|
||
{
|
||
return StructuredRuleExecuteResult.Fail($"{label}:下限不能晚于上限");
|
||
}
|
||
|
||
return StructuredRuleExecuteResult.Ok(false);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 判断普通对象或 JObject/JValue 字段是否有有效值,空字符串、null、undefined 都视为无值。
|
||
/// </summary>
|
||
private static bool HasValue(object value)
|
||
{
|
||
if (value == null) return false;
|
||
if (value is JValue jsonValue)
|
||
{
|
||
if (jsonValue.Type == JTokenType.Null || jsonValue.Type == JTokenType.Undefined) return false;
|
||
if (jsonValue.Type == JTokenType.String) return !string.IsNullOrWhiteSpace(jsonValue.Value<string>());
|
||
return true;
|
||
}
|
||
return value is not string text || !string.IsNullOrWhiteSpace(text);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 使用固定区域性取出原始字符串,保证数字和日期解析不受服务器区域设置影响。
|
||
/// </summary>
|
||
private static string GetRawValue(object value)
|
||
{
|
||
return value is JValue jsonValue
|
||
? Convert.ToString(jsonValue.Value, CultureInfo.InvariantCulture)
|
||
: Convert.ToString(value, CultureInfo.InvariantCulture);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 格式化错误信息中的原始值,便于区分 null、空字符串和普通值。
|
||
/// </summary>
|
||
private static string FormatRawValue(object value)
|
||
{
|
||
if (value == null)
|
||
return "<null>";
|
||
var rawValue = GetRawValue(value);
|
||
return string.IsNullOrWhiteSpace(rawValue) ? "<empty>" : rawValue;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 格式化命中说明中的值,日期只展示日期部分,布尔值转为中文。
|
||
/// </summary>
|
||
private static string FormatDisplayValue(object value)
|
||
{
|
||
if (value == null)
|
||
return "<null>";
|
||
if (value is DateTime dateValue)
|
||
return dateValue.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||
if (value is bool boolValue)
|
||
return boolValue ? "是" : "否";
|
||
if (value is decimal decimalValue)
|
||
return decimalValue.ToString("0.#########", CultureInfo.InvariantCulture);
|
||
if (value is double doubleValue)
|
||
return doubleValue.ToString("0.#########", CultureInfo.InvariantCulture);
|
||
if (value is float floatValue)
|
||
return floatValue.ToString("0.#########", CultureInfo.InvariantCulture);
|
||
return Convert.ToString(value, CultureInfo.InvariantCulture);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 获取操作符中文说明,用于正常命中时解释实际值与阈值的关系。
|
||
/// </summary>
|
||
private static string GetOperatorName(string ruleOperator)
|
||
{
|
||
return ruleOperator switch
|
||
{
|
||
"gt" => "大于阈值",
|
||
"lt" => "小于阈值",
|
||
"gte" => "大于等于阈值",
|
||
"lte" => "小于等于阈值",
|
||
"eq" => "等于阈值",
|
||
"ne" => "不等于阈值",
|
||
_ => ruleOperator
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// 比较数值变量单位。空单位按空字符串处理,前后空格不参与比较。
|
||
/// </summary>
|
||
private static bool UnitsMatch(string left, string right)
|
||
{
|
||
return string.Equals(left?.Trim() ?? string.Empty, right?.Trim() ?? string.Empty, StringComparison.Ordinal);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 获取数据类型中文名称,用于拼接用户可读的错误信息。
|
||
/// </summary>
|
||
private static string GetDataTypeName(RiskVariableDataType dataType)
|
||
{
|
||
return dataType switch
|
||
{
|
||
RiskVariableDataType.Numeric => "数值",
|
||
RiskVariableDataType.Date => "日期",
|
||
RiskVariableDataType.Boolean => "布尔",
|
||
_ => "未知"
|
||
};
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 结构化规则执行结果。Success 表示执行过程是否成功,Triggered 表示规则条件是否命中。
|
||
/// </summary>
|
||
public class StructuredRuleExecuteResult
|
||
{
|
||
public bool Success { get; set; }
|
||
|
||
public bool Triggered { get; set; }
|
||
|
||
public string ErrorMessage { get; set; }
|
||
|
||
/// <summary>
|
||
/// 正常命中时的可读说明,用于展示实际值、阈值和变量表达式返回的计算明细。
|
||
/// </summary>
|
||
public string Message { get; set; }
|
||
|
||
public static StructuredRuleExecuteResult Ok(bool triggered, string message = null)
|
||
{
|
||
return new StructuredRuleExecuteResult
|
||
{
|
||
Success = true,
|
||
Triggered = triggered,
|
||
Message = message
|
||
};
|
||
}
|
||
|
||
public static StructuredRuleExecuteResult Fail(string errorMessage)
|
||
{
|
||
return new StructuredRuleExecuteResult
|
||
{
|
||
Success = false,
|
||
Triggered = false,
|
||
ErrorMessage = errorMessage
|
||
};
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 变量表达式可选返回模型。Value 参与规则比较,Message 用于正常命中时展示计算明细。
|
||
/// 仅保证金等需要逐条判断的变量应返回 Items,普通单交易变量只需返回 Value 和当前取值来源说明。
|
||
/// </summary>
|
||
public class RiskVariableValueDetail
|
||
{
|
||
public RiskVariableValueDetail(object value, string message)
|
||
: this(value, message, null)
|
||
{
|
||
}
|
||
|
||
public RiskVariableValueDetail(object value, string message, IEnumerable<RiskVariableValueDetailItem> items)
|
||
{
|
||
Value = value;
|
||
Message = message;
|
||
Items = items?.ToList() ?? new List<RiskVariableValueDetailItem>();
|
||
}
|
||
|
||
public object Value { get; }
|
||
|
||
public string Message { get; }
|
||
|
||
public IReadOnlyList<RiskVariableValueDetailItem> Items { get; }
|
||
}
|
||
|
||
/// <summary>
|
||
/// 需要遍历的变量所返回的单条计算明细。Value 用于按规则阈值复判该条明细是否命中,Message 用于展示该条记录。
|
||
/// </summary>
|
||
public class RiskVariableValueDetailItem
|
||
{
|
||
public RiskVariableValueDetailItem(object value, string message)
|
||
{
|
||
Value = value;
|
||
Message = message;
|
||
}
|
||
|
||
public object Value { get; }
|
||
|
||
public string Message { get; }
|
||
}
|
||
|
||
/// <summary>
|
||
/// 单个值取值或转换结果,用于在内部传递转换后的值和错误信息。
|
||
/// </summary>
|
||
internal class ValueExecuteResult
|
||
{
|
||
public bool Success { get; set; }
|
||
|
||
public object Value { get; set; }
|
||
|
||
/// <summary>
|
||
/// 变量表达式返回的计算明细,仅用于正常命中提示,不参与比较。
|
||
/// </summary>
|
||
public string DetailMessage { get; set; }
|
||
|
||
/// <summary>
|
||
/// 变量表达式返回的逐条计算明细,仅用于命中提示阶段按当前阈值拆分展示。
|
||
/// </summary>
|
||
public IReadOnlyList<RiskVariableValueDetailItem> DetailItems { get; set; }
|
||
|
||
public string ErrorMessage { get; set; }
|
||
|
||
public static ValueExecuteResult Ok(object value)
|
||
{
|
||
return new ValueExecuteResult
|
||
{
|
||
Success = true,
|
||
Value = value
|
||
};
|
||
}
|
||
|
||
public static ValueExecuteResult Fail(string errorMessage)
|
||
{
|
||
return new ValueExecuteResult
|
||
{
|
||
Success = false,
|
||
ErrorMessage = errorMessage
|
||
};
|
||
}
|
||
}
|
||
}
|