fix conflict

This commit is contained in:
尹峰
2026-07-30 13:31:19 +08:00
7 changed files with 453 additions and 53 deletions
@@ -366,7 +366,7 @@ namespace YLErp.Modules.RiskEngine
typeof(Microsoft.EntityFrameworkCore.DbContext).Assembly,
typeof(Queryable).Assembly
)
.WithImports("System", "System.Linq", "Newtonsoft.Json", "YLErp.DBModels", "YLErp.QdpModule");
.WithImports("System", "System.Linq", "Newtonsoft.Json", "YLErp.DBModels", "YLErp.QdpModule", "YLErp.Modules.RiskEngine");
// 创建脚本(尚未执行,仅编译)
var script = CSharpScript.Create<T>(scriptCode, options, globalsType: typeof(ScriptGlobals));
@@ -174,7 +174,9 @@ namespace YLErp.Modules.RiskEngine
RuleCompiledCache.Remove(rule.Id.ToString());
try
{
RuleConditionExpressionBuilder.DeserializeConditions(rule.ConditionJson);
// 结构化条件在加载阶段已解析到 ParsedConditions;这里仅确认缓存存在,运行时不再反序列化 ConditionJson。
if (rule.ParsedConditions == null || rule.ParsedConditions.Count == 0)
rule.ParsedConditions = RuleConditionExpressionBuilder.DeserializeConditions(rule.ConditionJson);
_logger.Info($"[风控引擎] 结构化规则预热校验成功,跳过 RuleExpr 预编译 - RuleId: {rule.Id}");
}
catch (Exception ex)
@@ -214,6 +216,7 @@ namespace YLErp.Modules.RiskEngine
_cachedRules = null;
_cachedApplications = null;
RuleCompiledCache.Clear();
StructuredRuleExecutor.ClearVariableCompiledCache();
}
Preload();
@@ -250,7 +253,8 @@ namespace YLErp.Modules.RiskEngine
RuleCompiledCache.Remove(ruleId.ToString());
try
{
RuleConditionExpressionBuilder.DeserializeConditions(rule.ConditionJson);
// 单规则刷新时同步刷新结构化条件缓存,避免后续执行继续解析旧 JSON 或重复反序列化。
rule.ParsedConditions = RuleConditionExpressionBuilder.DeserializeConditions(rule.ConditionJson);
_logger.Info($"[风控引擎] RefreshOneRuleCache 结构化规则校验成功,跳过 RuleExpr 编译 - RuleId: {ruleId}");
return RuleCompileResult.Ok(null);
}
@@ -353,6 +357,8 @@ namespace YLErp.Modules.RiskEngine
// ============================================================
var rules = GetRules();
var applications = GetApplications();
// 规则列表在本次执行内转为字典,应用按 RuleIds 关联规则时直接按 ID 查找,避免每个应用重复扫描规则列表。
var ruleMap = rules.ToDictionary(r => r.Id);
var variables = new Dictionary<long, glms_risk_variable>();
_logger.Info($"[风控引擎] 加载规则数: {rules.Count}, 应用数: {applications.Count}");
@@ -402,24 +408,23 @@ namespace YLErp.Modules.RiskEngine
continue;
}
var applicationRules = rules
.Where(r => applicationRuleIds.Contains(r.Id) && r.Status == RiskRuleStatus.Active)
.ToList();
// 区分"规则不存在"与"规则非活跃"两种情况,分别记录日志
var ruleDict = rules.Where(r => applicationRuleIds.Contains(r.Id))
.ToDictionary(r => r.Id);
var applicationRules = new List<RiskRule>();
foreach (var ruleId in applicationRuleIds)
{
if (!ruleDict.TryGetValue(ruleId, out var ruleDef))
// 通过本次执行预构建的规则字典按 ID 查找,避免每个应用都 Where 扫描全量规则列表。
if (!ruleMap.TryGetValue(ruleId, out var ruleDef))
{
_logger.Info($"[风控引擎] 未找到对应规则定义 - RuleId: {ruleId}, ApplicationRuleIds: {application.RuleIds}");
continue;
}
else if (ruleDef.Status != RiskRuleStatus.Active)
if (ruleDef.Status != RiskRuleStatus.Active)
{
_logger.Info($"[风控引擎] 规则非活跃,跳过执行 - RuleId: {ruleId}, Status: {ruleDef.Status}, ApplicationRuleIds: {application.RuleIds}");
continue;
}
applicationRules.Add(ruleDef);
}
foreach (var rule in applicationRules)
@@ -427,12 +432,17 @@ namespace YLErp.Modules.RiskEngine
var ruleId = rule.Id.ToString();
bool triggered = false;
StructuredRuleExecuteResult executeResult = null;
if (!string.IsNullOrWhiteSpace(rule.ConditionJson))
{
// 结构化规则优先使用 ConditionJson 执行,避免继续把条件整体拼成 Roslyn bool 公式。
try
{
var conditions = RuleConditionExpressionBuilder.DeserializeConditions(rule.ConditionJson);
// 结构化条件在规则加载/刷新时已解析到内存缓存,运行时直接复用,避免每次执行反序列化 ConditionJson。
var conditions = rule.ParsedConditions;
if (conditions == null || conditions.Count == 0)
throw new InvalidOperationException("结构化规则条件缓存为空");
var referencedVariableIds = RuleConditionExpressionBuilder.GetReferencedVariableIds(conditions);
var missingVariableIds = referencedVariableIds
.Where(id => !variables.ContainsKey(id))
@@ -452,7 +462,7 @@ namespace YLErp.Modules.RiskEngine
.Where(variables.ContainsKey)
.ToDictionary(id => id, id => variables[id]);
var executeResult = StructuredRuleExecutor.Execute(conditions, ruleVariables, context);
executeResult = StructuredRuleExecutor.Execute(conditions, ruleVariables, context);
if (!executeResult.Success)
{
var errorMessage = $"规则[{rule.RuleName}]执行异常:{executeResult.ErrorMessage}";
@@ -516,6 +526,8 @@ namespace YLErp.Modules.RiskEngine
if (triggered)
{
_logger.Info($"[风控引擎] 规则触发 - RuleId: {rule.Id}, Strategy: {application.ControlStrategy}");
// 结构化规则执行器会返回实际值、阈值和变量计算明细;纯文本规则没有该信息时保持原提示。
var triggerMessageDetail = executeResult?.Message;
switch (application.ControlStrategy)
{
@@ -528,7 +540,7 @@ namespace YLErp.Modules.RiskEngine
RuleName = rule.RuleName,
ControlStrategy = RiskControlStrategy.Block,
RuleText = rule.RuleText,
Message = $"规则[{rule.RuleName}]触发:禁止"
Message = BuildTriggeredMessage($"规则[{rule.RuleName}]触发:禁止", triggerMessageDetail)
});
break;
@@ -541,7 +553,7 @@ namespace YLErp.Modules.RiskEngine
RuleName = rule.RuleName,
ControlStrategy = RiskControlStrategy.Approval,
RuleText = rule.RuleText,
Message = $"规则[{rule.RuleName}]触发:需审批"
Message = BuildTriggeredMessage($"规则[{rule.RuleName}]触发:需审批", triggerMessageDetail)
});
break;
@@ -553,7 +565,7 @@ namespace YLErp.Modules.RiskEngine
RuleName = rule.RuleName,
ControlStrategy = RiskControlStrategy.ShowTip,
RuleText = rule.RuleText,
Message = $"规则[{rule.RuleName}]触发:提示"
Message = BuildTriggeredMessage($"规则[{rule.RuleName}]触发:提示", triggerMessageDetail)
});
break;
@@ -593,6 +605,13 @@ namespace YLErp.Modules.RiskEngine
return result;
}
private static string BuildTriggeredMessage(string baseMessage, string detailMessage)
{
return string.IsNullOrWhiteSpace(detailMessage)
? baseMessage
: $"{baseMessage}。{detailMessage}";
}
private static void AddBlockError(RiskResult result, string ruleId, string ruleName, string ruleText, string errorMessage)
{
result.Blocked = true;
@@ -635,7 +654,22 @@ namespace YLErp.Modules.RiskEngine
UpdateOptId = r.UpdateOptId ?? 0,
UpdateOptName = r.UpdateOptName,
UpdateDate = r.UpdateDate ?? r.OptDate ?? DateTime.MinValue
}).ToList() ;
}).ToList();
// 结构化规则的 ConditionJson 在加载阶段解析一次并缓存到规则对象,运行时直接复用 ParsedConditions。
// 如果历史脏数据解析失败,不中断整批规则加载,后续执行该规则时仍按单条规则异常处理。
foreach (var rule in rules.Where(r => !string.IsNullOrWhiteSpace(r.ConditionJson)))
{
try
{
rule.ParsedConditions = RuleConditionExpressionBuilder.DeserializeConditions(rule.ConditionJson);
}
catch (Exception ex)
{
_logger.Info($"[风控引擎] 结构化规则条件解析失败 - RuleId: {rule.Id}, Error: {ex.Message}");
}
}
return rules;
//#region 测试本地规则
//rules.Add(new RiskRule
+8
View File
@@ -1,4 +1,6 @@
using System;
using System.Collections.Generic;
using YLErp.Modules.RiskEngine.Dto;
namespace YLErp.Modules.RiskEngine
{
@@ -47,5 +49,11 @@ namespace YLErp.Modules.RiskEngine
/// 判风控时直接调用:rule.CompiledScript(context) → bool
/// </summary>
public Func<RiskContext, bool> CompiledScript { get; set; }
/// <summary>
/// 结构化规则已解析条件缓存。
/// 规则加载/刷新时由 ConditionJson 解析一次,运行时直接复用,避免每次风控执行反序列化 JSON。
/// </summary>
public IReadOnlyList<RuleCondition> ParsedConditions { get; set; }
}
}
+15 -6
View File
@@ -355,12 +355,15 @@ namespace YLErp.Modules.RiskEngine
}
/// <summary>
/// 校验 ConditionJson 结构引用变量是否存在
/// 校验 ConditionJson 结构引用变量以及操作符/阈值/类型一致性
/// 结构化规则运行时以 ConditionJson 为准,RuleExpr 只作为兼容/展示字段。
/// </summary>
private void ValidateConditionJson(string conditionJson)
{
ValidateConditionJsonVariables(conditionJson);
var conditions = RuleConditionExpressionBuilder.DeserializeConditions(conditionJson);
var variables = ValidateConditionJsonVariables(conditions);
var variableMap = variables.ToDictionary(variable => (long)variable.id);
RuleConditionExpressionBuilder.Build(conditions, variableMap);
}
/// <summary>
/// 校验 RuleExpr(自由文本模式:括号匹配)
@@ -1930,9 +1933,8 @@ namespace YLErp.Modules.RiskEngine
/// <summary>
/// 校验结构化 ConditionJson 引用的变量是否存在,并返回未删除的变量定义。
/// </summary>
private List<glms_risk_variable> ValidateConditionJsonVariables(string conditionJson)
private List<glms_risk_variable> ValidateConditionJsonVariables(IReadOnlyList<RuleCondition> conditions)
{
var conditions = RuleConditionExpressionBuilder.DeserializeConditions(conditionJson);
var variableIds = RuleConditionExpressionBuilder.GetReferencedVariableIds(conditions).ToList();
var variables = DbContext.glms_risk_variable
.Where(variable => variableIds.Contains(variable.id) && variable.Status != RiskRuleStatus.Deleted)
@@ -2178,6 +2180,7 @@ namespace YLErp.Modules.RiskEngine
}
InvalidateVariableCache();
StructuredRuleExecutor.RemoveVariableCompiledCache(variableId);
foreach (var rule in affectedRules)
{
RiskEngineService.GetInstance().RefreshOneRuleCache(rule.id);
@@ -2188,6 +2191,7 @@ namespace YLErp.Modules.RiskEngine
WriteAuditLog("VAR_UPDATE", "VARIABLE", variableId, req.VariableName, $"修改变量:{variable.VariableName}");
SaveChangesWithConcurrencyCheck();
InvalidateVariableCache();
StructuredRuleExecutor.RemoveVariableCompiledCache(variableId);
}
return GetVariableDetail(variableId);
@@ -2214,6 +2218,7 @@ namespace YLErp.Modules.RiskEngine
SaveChangesWithConcurrencyCheck();
InvalidateVariableCache();
StructuredRuleExecutor.RemoveVariableCompiledCache(variableId);
}
/// <summary>
@@ -2264,8 +2269,12 @@ namespace YLErp.Modules.RiskEngine
{
// 只有实际删除项写审计并参与一次提交;缺失和引用阻塞项不影响其他变量。
SaveChangesWithConcurrencyCheck();
TryRefreshCache(InvalidateVariableCache, "风控变量缓存",
items.Where(i => i.Status == BatchOperationItemStatus.Succeeded).Select(i => i.Id));
var changedIds = items.Where(i => i.Status == BatchOperationItemStatus.Succeeded).Select(i => i.Id).ToList();
TryRefreshCache(() =>
{
InvalidateVariableCache();
StructuredRuleExecutor.RemoveVariableCompiledCache(changedIds);
}, "风控变量缓存", changedIds);
}
var result = BuildBatchResult(items);
LogBatchResult("批量删除变量", result);
@@ -16,18 +16,76 @@ namespace YLErp.Modules.RiskEngine
/// </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,
@@ -40,16 +98,21 @@ namespace YLErp.Modules.RiskEngine
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);
// 所有条件都命中时,合并每个条件的实际值/阈值说明,供最终风控结果展示。
return StructuredRuleExecuteResult.Ok(true, string.Join("", triggerMessages));
}
catch (Exception ex)
{
@@ -57,6 +120,9 @@ namespace YLErp.Modules.RiskEngine
}
}
/// <summary>
/// 执行单个结构化条件,负责按变量数据类型分派到布尔、普通比较或区间比较逻辑。
/// </summary>
private static StructuredRuleExecuteResult ExecuteCondition(
RuleCondition condition,
IReadOnlyDictionary<long, glms_risk_variable> variables,
@@ -69,6 +135,7 @@ namespace YLErp.Modules.RiskEngine
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);
@@ -77,12 +144,14 @@ namespace YLErp.Modules.RiskEngine
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.Value, variables, context, label);
return ExecuteRangeCondition(condition, variable, left, variables, context, label);
var rangeFieldCheck = EnsureNoRangeFields(condition, label);
if (!rangeFieldCheck.Success)
@@ -104,9 +173,15 @@ namespace YLErp.Modules.RiskEngine
if (!compareResult.Success)
return compareResult;
return StructuredRuleExecuteResult.Ok(compareResult.Triggered);
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,
@@ -124,13 +199,19 @@ namespace YLErp.Modules.RiskEngine
return StructuredRuleExecuteResult.Fail(value.ErrorMessage);
var expected = condition.Operator == "isTrue";
return StructuredRuleExecuteResult.Ok((bool)value.Value == expected);
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,
object leftValue,
ValueExecuteResult left,
IReadOnlyDictionary<long, glms_risk_variable> variables,
RiskContext context,
string label)
@@ -140,6 +221,7 @@ namespace YLErp.Modules.RiskEngine
if (!condition.IncludeLower.HasValue || !condition.IncludeUpper.HasValue)
return StructuredRuleExecuteResult.Fail($"{label}:区间开闭配置不完整");
// 固定上下限可以提前比较顺序;变量上下限依赖运行时查询结果,留到后面按实际值比较。
var fixedRangeCheck = ValidateFixedRangeOrder(condition, variable.DataType, label);
if (!fixedRangeCheck.Success)
return fixedRangeCheck;
@@ -152,19 +234,27 @@ namespace YLErp.Modules.RiskEngine
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(leftValue, lower.Value, variable.DataType, lowerOperator, label);
var lowerCompare = Compare(left.Value, lower.Value, variable.DataType, lowerOperator, label);
if (!lowerCompare.Success)
return lowerCompare;
var upperCompare = Compare(leftValue, upper.Value, variable.DataType, upperOperator, label);
var upperCompare = Compare(left.Value, upper.Value, variable.DataType, upperOperator, label);
if (!upperCompare.Success)
return upperCompare;
var inRange = lowerCompare.Triggered && upperCompare.Triggered;
return StructuredRuleExecuteResult.Ok(condition.Operator == "notBetween" ? !inRange : inRange);
// 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,
@@ -195,6 +285,7 @@ namespace YLErp.Modules.RiskEngine
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}变量与条件变量的单位不一致");
@@ -204,6 +295,9 @@ namespace YLErp.Modules.RiskEngine
return ValueExecuteResult.Fail($"{label}{thresholdLabel}类型 '{thresholdType}' 不合法,仅支持 Fixed/Variable");
}
/// <summary>
/// 执行变量取值表达式,并将表达式返回值转换为变量声明的数据类型。
/// </summary>
private static ValueExecuteResult GetVariableValue(
glms_risk_variable variable,
RiskVariableDataType dataType,
@@ -228,13 +322,21 @@ namespace YLErp.Modules.RiskEngine
return ValueExecuteResult.Fail($"{label}:变量'{variable.VariableName}'(ID:{variable.id})执行失败:{ex.Message}");
}
var converted = ConvertValue(rawValue, dataType, label, valueLabel);
// 变量表达式可选择返回 RiskVariableValueDetailValue 参与比较,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(rawValue)}");
return ValueExecuteResult.Fail($"{label}:变量'{variable.VariableName}'(ID:{variable.id}){converted.ErrorMessage},原始值:{FormatRawValue(actualRawValue)}");
converted.DetailMessage = detail?.Message;
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)}";
@@ -251,6 +353,9 @@ namespace YLErp.Modules.RiskEngine
return compiled;
}
/// <summary>
/// 按变量数据类型统一转换值。日期固定值只接受 yyyy-MM-dd,避免不同区域格式导致比较结果不一致。
/// </summary>
private static ValueExecuteResult ConvertValue(object value, RiskVariableDataType dataType, string label, string valueLabel)
{
if (!HasValue(value))
@@ -260,6 +365,7 @@ namespace YLErp.Modules.RiskEngine
{
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}必须为数字");
@@ -286,8 +392,12 @@ namespace YLErp.Modules.RiskEngine
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),
@@ -309,6 +419,59 @@ namespace YLErp.Modules.RiskEngine
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>();
AddDetail(parts, left.DetailMessage);
parts.Add($"{variable.VariableName}为{FormatDisplayValue(left.Value)}");
AddDetail(parts, right.DetailMessage);
parts.Add($"阈值为{FormatDisplayValue(right.Value)}");
parts.Add($"比较关系:{GetOperatorName(ruleOperator)}");
return string.Join("", parts);
}
/// <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 ||
@@ -321,6 +484,9 @@ namespace YLErp.Modules.RiskEngine
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 ||
@@ -334,6 +500,9 @@ namespace YLErp.Modules.RiskEngine
return StructuredRuleExecuteResult.Ok(false);
}
/// <summary>
/// 当区间上下限都是固定值时,提前校验下限不能大于上限;变量上下限留到运行时按实际值比较。
/// </summary>
private static StructuredRuleExecuteResult ValidateFixedRangeOrder(RuleCondition condition, RiskVariableDataType dataType, string label)
{
if (condition.LowerThresholdType != "Fixed" || condition.UpperThresholdType != "Fixed")
@@ -360,6 +529,9 @@ namespace YLErp.Modules.RiskEngine
return StructuredRuleExecuteResult.Ok(false);
}
/// <summary>
/// 判断普通对象或 JObject/JValue 字段是否有有效值,空字符串、null、undefined 都视为无值。
/// </summary>
private static bool HasValue(object value)
{
if (value == null) return false;
@@ -372,6 +544,9 @@ namespace YLErp.Modules.RiskEngine
return value is not string text || !string.IsNullOrWhiteSpace(text);
}
/// <summary>
/// 使用固定区域性取出原始字符串,保证数字和日期解析不受服务器区域设置影响。
/// </summary>
private static string GetRawValue(object value)
{
return value is JValue jsonValue
@@ -379,6 +554,9 @@ namespace YLErp.Modules.RiskEngine
: Convert.ToString(value, CultureInfo.InvariantCulture);
}
/// <summary>
/// 格式化错误信息中的原始值,便于区分 null、空字符串和普通值。
/// </summary>
private static string FormatRawValue(object value)
{
if (value == null)
@@ -387,11 +565,48 @@ namespace YLErp.Modules.RiskEngine
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 ? "是" : "否";
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
@@ -404,6 +619,9 @@ namespace YLErp.Modules.RiskEngine
}
}
/// <summary>
/// 结构化规则执行结果。Success 表示执行过程是否成功,Triggered 表示规则条件是否命中。
/// </summary>
public class StructuredRuleExecuteResult
{
public bool Success { get; set; }
@@ -412,12 +630,18 @@ namespace YLErp.Modules.RiskEngine
public string ErrorMessage { get; set; }
public static StructuredRuleExecuteResult Ok(bool triggered)
/// <summary>
/// 正常命中时的可读说明,用于展示实际值、阈值和变量表达式返回的计算明细。
/// </summary>
public string Message { get; set; }
public static StructuredRuleExecuteResult Ok(bool triggered, string message = null)
{
return new StructuredRuleExecuteResult
{
Success = true,
Triggered = triggered
Triggered = triggered,
Message = message
};
}
@@ -432,12 +656,36 @@ namespace YLErp.Modules.RiskEngine
}
}
/// <summary>
/// 变量表达式可选返回模型。Value 参与规则比较,Message 用于正常命中时展示计算明细。
/// </summary>
public class RiskVariableValueDetail
{
public RiskVariableValueDetail(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; }
public string ErrorMessage { get; set; }
public static ValueExecuteResult Ok(object value)
+97 -2
View File
@@ -1440,6 +1440,41 @@ ORDER BY
挂钩标的到期日 < 合约到期日
```
变量 Roslyn 示例:配置两个 Date 类型变量;规则前端配置“挂钩标的到期日 < 当前交易合约到期日”。
变量 1:挂钩标的到期日,DataType 为 Date。
```csharp
var underlyingCode = DbContext.trade
.Where(t => t.id == TradeId)
.Select(t => t.UnderlyingCode)
.FirstOrDefault();
var maturityDate = DbContext.underlying_manager
.Where(u => u.UnderlyingCode == underlyingCode)
.Select(u => u.MaturityDate)
.FirstOrDefault();
if (!maturityDate.HasValue)
throw new Exception("挂钩标的到期日为空");
return maturityDate.Value.Date;
```
变量 2:当前交易合约到期日,DataType 为 Date。
```csharp
var exerciseDate = DbContext.trade
.Where(t => t.id == TradeId)
.Select(t => t.ExerciseDate)
.FirstOrDefault();
if (!exerciseDate.HasValue)
throw new Exception("当前交易合约到期日为空");
return exerciseDate.Value.Date;
```
规则字段口径:
```text
@@ -1455,7 +1490,7 @@ ORDER BY
// Id = 1000002,
// RuleName = "挂钩标的到期日小于合约到期日(本地)",
// RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易的 UnderlyingCode 和 ExerciseDateExerciseDate 对应合约到期日;通过 DbContext.underlying_manager 按 UnderlyingCode 取 MaturityDateMaturityDate 对应挂钩标的到期日。计算逻辑:挂钩标的到期日小于合约到期日时触发禁止。",
// RuleExpr = "DbContext.underlying_manager.First(u => u.UnderlyingCode == DbContext.trade.First(t => t.id == TradeId).UnderlyingCode).MaturityDate.Value < DbContext.trade.First(t => t.id == TradeId).ExerciseDate.Value",
// RuleExpr = "DbContext.underlying_manager.First(u => u.UnderlyingCode == DbContext.trade.First(t => t.id == TradeId).UnderlyingCode).MaturityDate.HasValue && DbContext.trade.First(t => t.id == TradeId).ExerciseDate.HasValue && DateTime.Compare(DbContext.underlying_manager.First(u => u.UnderlyingCode == DbContext.trade.First(t => t.id == TradeId).UnderlyingCode).MaturityDate.Value.Date, DbContext.trade.First(t => t.id == TradeId).ExerciseDate.Value.Date) < 0",
// Version = 1,
// Status = RiskRuleStatus.Active,
// OptId = 0,
@@ -1600,7 +1635,11 @@ decimal openingNotional = (decimal)openingNotionalRaw.Value;
if (openingNotional == 0m)
throw new Exception("开仓名义本金为0,不能作为除数");
return marginPaymentAmount / openingNotional;
decimal marginPaymentRatio = marginPaymentAmount / openingNotional;
return new RiskVariableValueDetail(
marginPaymentRatio,
$"保证金支付金额为{marginPaymentAmount},开仓名义本金为{openingNotional}");
```
规则字段口径:
@@ -1708,6 +1747,30 @@ ORDER BY
ABS((预付金返息率 - 1) * 100) > 阈值
```
变量 Roslyn 示例:变量名为“保证金利率偏离”,DataType 为 Numeric;规则前端仍配置“保证金利率偏离 > 阈值”。`InterestRateDefault` 实体字段类型为 decimal,可直接参与 decimal 计算。
```csharp
var marginPosition = DbContext.swap_position
.Where(p => p.SwapTradeId == TradeId
&& p.IsInitial
&& !p.Invalid
&& (p.InterestMode == 5 || p.InterestMode == 6))
.OrderBy(p => p.HappenDate)
.ThenBy(p => p.id)
.FirstOrDefault();
if (marginPosition == null)
throw new Exception("预付金记录不存在");
decimal interestRateRawValue = marginPosition.InterestRateDefault;
decimal interestRatePercentValue = interestRateRawValue * 100m;
decimal interestRateDeviation = Math.Abs((interestRateRawValue - 1m) * 100m);
return new RiskVariableValueDetail(
interestRateDeviation,
$"预付金返息率原值为{interestRateRawValue},页面百分比口径为{interestRatePercentValue},保证金利率偏离为{interestRateDeviation}");
```
规则字段口径:
```text
@@ -1812,6 +1875,38 @@ ORDER BY
保证金收取金额 ÷ 开仓名义本金 < 20%
```
变量 Roslyn 示例:变量名为“保证金收取比例”,DataType 为 Numeric;规则前端仍配置“保证金收取比例 < 0.2”。字段类型需为 decimal / decimal?,否则需要字段层调整或显式类型转换。
```csharp
decimal marginReceiveAmount = DbContext.swap_position
.Where(p => p.SwapTradeId == TradeId
&& p.IsInitial
&& !p.Invalid
&& (p.InterestMode == 5 || p.InterestMode == 6)
&& p.InterestDirection == 1)
.Select(p => p.InterestPrincipalFix)
.Sum();
double? openingNotionalRaw = DbContext.trade
.Where(t => t.id == TradeId)
.Select(t => t.OriginalStockEqvNotional)
.FirstOrDefault();
if (!openingNotionalRaw.HasValue)
throw new Exception("开仓名义本金为空");
decimal openingNotional = (decimal)openingNotionalRaw.Value;
if (openingNotional == 0m)
throw new Exception("开仓名义本金为0,不能作为除数");
decimal marginReceiveRatio = marginReceiveAmount / openingNotional;
return new RiskVariableValueDetail(
marginReceiveRatio,
$"保证金收取金额为{marginReceiveAmount},开仓名义本金为{openingNotional}");
```
规则字段口径:
```text
@@ -5350,12 +5350,7 @@ namespace YLErp.Modules.RiskModule
result.RiskWarningDetails += "[风控引擎] 规则触发:禁止\n";
foreach (var triggeredRule in riskResult.TriggeredRules.Where(r => r.ControlStrategy == RiskControlStrategy.Block))
{
result.RiskWarningDetails += $"规则ID:{triggeredRule.RuleId};规则名称:{triggeredRule.RuleName};规则说明:{triggeredRule.RuleText}";
if (!string.IsNullOrWhiteSpace(triggeredRule.Message))
{
result.RiskWarningDetails += $";信息:{triggeredRule.Message}";
}
result.RiskWarningDetails += "\n";
result.RiskWarningDetails += BuildTriggeredRuleDetail(triggeredRule);
}
}
if (riskResult.NeedApproval)
@@ -5380,19 +5375,15 @@ namespace YLErp.Modules.RiskModule
result.ApprovalRuleIds = processedRiskRuleIds;
foreach (var triggeredRule in approvalTriggeredRules)
{
result.RiskWarningDetails += $"规则ID:{triggeredRule.RuleId};规则名称:{triggeredRule.RuleName};规则说明:{triggeredRule.RuleText}\n";
result.RiskWarningDetails += BuildTriggeredRuleDetail(triggeredRule);
}
}
if (riskResult.ShowTip)
{
var warningMessages = riskResult.TriggeredRules
.Where(r => r.ControlStrategy == RiskControlStrategy.ShowTip)
.Select(r => r.Message)
.Where(r => !string.IsNullOrWhiteSpace(r))
.ToList();
if (warningMessages.Count > 0)
result.RiskWarningDetails += "[风控引擎] 规则触发:提示\n";
foreach (var triggeredRule in riskResult.TriggeredRules.Where(r => r.ControlStrategy == RiskControlStrategy.ShowTip))
{
result.RiskWarningDetails += "[风控引擎] 提示:" + string.Join("", warningMessages) + "\n";
result.RiskWarningDetails += BuildTriggeredRuleDetail(triggeredRule);
}
}
}
@@ -5405,6 +5396,21 @@ namespace YLErp.Modules.RiskModule
return result;
}
/// <summary>
/// 构建新风控触发规则明细。禁止、审批、提示三类规则都可能带结构化命中明细,统一格式避免不同策略展示字段不一致。
/// </summary>
private static string BuildTriggeredRuleDetail(TriggeredRuleInfo triggeredRule)
{
var detail = $"规则ID:{triggeredRule.RuleId};规则名称:{triggeredRule.RuleName};规则说明:{triggeredRule.RuleText}";
if (!string.IsNullOrWhiteSpace(triggeredRule.Message))
{
detail += $";信息:{triggeredRule.Message}";
}
return detail + "\n";
}
/// <summary>
/// 校验标的白名单
/// </summary>