diff --git a/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs b/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs index 5d7be9d3..084b1e3f 100644 --- a/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs +++ b/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs @@ -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(scriptCode, options, globalsType: typeof(ScriptGlobals)); diff --git a/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs b/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs index 5bf02e41..58e95978 100644 --- a/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs +++ b/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs @@ -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(); _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(); 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 diff --git a/YLErpDAL/Modules/RiskEngine/RiskRule.cs b/YLErpDAL/Modules/RiskEngine/RiskRule.cs index 7b5375b6..c4e298d0 100644 --- a/YLErpDAL/Modules/RiskEngine/RiskRule.cs +++ b/YLErpDAL/Modules/RiskEngine/RiskRule.cs @@ -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 /// public Func CompiledScript { get; set; } + + /// + /// 结构化规则已解析条件缓存。 + /// 规则加载/刷新时由 ConditionJson 解析一次,运行时直接复用,避免每次风控执行反序列化 JSON。 + /// + public IReadOnlyList ParsedConditions { get; set; } } } diff --git a/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs b/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs index d285d795..00e18987 100644 --- a/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs +++ b/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs @@ -355,12 +355,15 @@ namespace YLErp.Modules.RiskEngine } /// - /// 校验 ConditionJson 结构和引用变量是否存在。 + /// 校验 ConditionJson 结构、引用变量以及操作符/阈值/类型一致性。 /// 结构化规则运行时以 ConditionJson 为准,RuleExpr 只作为兼容/展示字段。 /// 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); } /// /// 校验 RuleExpr(自由文本模式:括号匹配) @@ -1930,9 +1933,8 @@ namespace YLErp.Modules.RiskEngine /// /// 校验结构化 ConditionJson 引用的变量是否存在,并返回未删除的变量定义。 /// - private List ValidateConditionJsonVariables(string conditionJson) + private List ValidateConditionJsonVariables(IReadOnlyList 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); } /// @@ -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); diff --git a/YLErpDAL/Modules/RiskEngine/StructuredRuleExecutor.cs b/YLErpDAL/Modules/RiskEngine/StructuredRuleExecutor.cs index af9e7171..6f6be1cb 100644 --- a/YLErpDAL/Modules/RiskEngine/StructuredRuleExecutor.cs +++ b/YLErpDAL/Modules/RiskEngine/StructuredRuleExecutor.cs @@ -16,18 +16,76 @@ namespace YLErp.Modules.RiskEngine /// public static class StructuredRuleExecutor { + /// + /// 变量表达式编译缓存。Key 包含变量 ID、版本、更新时间和表达式哈希,避免变量变更后复用旧委托。 + /// private static readonly ConcurrentDictionary> VariableCompiledCache = new ConcurrentDictionary>(); + /// + /// 按变量 ID 增量移除变量表达式编译缓存。 + /// + 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 _); + } + } + + /// + /// 按变量 ID 集合批量移除变量表达式编译缓存。 + /// + public static void RemoveVariableCompiledCache(IEnumerable variableIds) + { + if (variableIds == null) + return; + + var idSet = new HashSet(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 _); + } + } + } + + /// + /// 清空全部变量表达式编译缓存。仅用于风控缓存全量刷新场景。 + /// + public static void ClearVariableCompiledCache() + { + VariableCompiledCache.Clear(); + } + + /// + /// 数值和日期类型支持的比较操作符。 + /// private static readonly HashSet ComparableOperators = new HashSet(StringComparer.Ordinal) { "gt", "lt", "gte", "lte", "eq", "ne", "between", "notBetween" }; + /// + /// 布尔类型支持的判断操作符。 + /// private static readonly HashSet BooleanOperators = new HashSet(StringComparer.Ordinal) { "isTrue", "isFalse" }; + /// + /// 执行一组结构化条件。当前规则条件之间按 AND 关系处理,任一条件不满足则整条规则不触发。 + /// public static StructuredRuleExecuteResult Execute( IReadOnlyList conditions, IReadOnlyDictionary variables, @@ -40,16 +98,21 @@ namespace YLErp.Modules.RiskEngine if (variables == null) return StructuredRuleExecuteResult.Fail("变量列表不能为空"); + var triggerMessages = new List(); 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 } } + /// + /// 执行单个结构化条件,负责按变量数据类型分派到布尔、普通比较或区间比较逻辑。 + /// private static StructuredRuleExecuteResult ExecuteCondition( RuleCondition condition, IReadOnlyDictionary 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); } + /// + /// 执行布尔条件。布尔变量只允许 isTrue/isFalse,不允许配置固定值、变量阈值或区间字段。 + /// 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); } + /// + /// 执行区间条件。下限和上限可以分别来自固定值或变量,并根据开闭区间转换为对应比较操作符。 + /// private static StructuredRuleExecuteResult ExecuteRangeCondition( RuleCondition condition, glms_risk_variable variable, - object leftValue, + ValueExecuteResult left, IReadOnlyDictionary 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); } + /// + /// 获取阈值。固定阈值直接转换;变量阈值先校验类型和单位,再执行阈值变量表达式。 + /// 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"); } + /// + /// 执行变量取值表达式,并将表达式返回值转换为变量声明的数据类型。 + /// 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); + // 变量表达式可选择返回 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(rawValue)}"); + return ValueExecuteResult.Fail($"{label}:变量'{variable.VariableName}'(ID:{variable.id}){converted.ErrorMessage},原始值:{FormatRawValue(actualRawValue)}"); + converted.DetailMessage = detail?.Message; return converted; } + /// + /// 获取变量表达式的编译委托。缓存 Key 绑定变量版本信息,变量内容变化后会自然生成新 Key。 + /// private static Func 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; } + /// + /// 按变量数据类型统一转换值。日期固定值只接受 yyyy-MM-dd,避免不同区域格式导致比较结果不一致。 + /// 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}:不支持的数据类型"); } + /// + /// 比较两个已转换为同一数据类型的值,并应用结构化规则操作符。 + /// 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); } + /// + /// 构建普通比较条件的命中说明。变量表达式如果返回 RiskVariableValueDetail,会优先带出自定义明细。 + /// + private static string BuildCompareMessage(glms_risk_variable variable, ValueExecuteResult left, ValueExecuteResult right, string ruleOperator) + { + var parts = new List(); + 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); + } + + /// + /// 构建区间条件的命中说明,包含实际值、上下限和区间开闭配置。 + /// + private static string BuildRangeMessage(glms_risk_variable variable, ValueExecuteResult left, ValueExecuteResult lower, ValueExecuteResult upper, bool includeLower, bool includeUpper, string ruleOperator) + { + var parts = new List(); + 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); + } + + /// + /// 构建布尔条件的命中说明。 + /// + private static string BuildBooleanMessage(glms_risk_variable variable, ValueExecuteResult value, bool expected) + { + var parts = new List(); + AddDetail(parts, value.DetailMessage); + parts.Add($"{variable.VariableName}为{FormatDisplayValue(value.Value)}"); + parts.Add($"期望为{FormatDisplayValue(expected)}"); + return string.Join(",", parts); + } + + /// + /// 添加变量表达式返回的自定义明细,避免空明细污染最终提示。 + /// + private static void AddDetail(List parts, string detailMessage) + { + if (!string.IsNullOrWhiteSpace(detailMessage)) + parts.Add(detailMessage.Trim()); + } + + /// + /// 校验非区间条件没有携带区间字段,避免前端残留字段影响规则语义。 + /// 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); } + /// + /// 校验布尔条件没有携带阈值或区间字段,布尔判断只由变量值和 isTrue/isFalse 决定。 + /// 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); } + /// + /// 当区间上下限都是固定值时,提前校验下限不能大于上限;变量上下限留到运行时按实际值比较。 + /// 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); } + /// + /// 判断普通对象或 JObject/JValue 字段是否有有效值,空字符串、null、undefined 都视为无值。 + /// 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); } + /// + /// 使用固定区域性取出原始字符串,保证数字和日期解析不受服务器区域设置影响。 + /// private static string GetRawValue(object value) { return value is JValue jsonValue @@ -379,6 +554,9 @@ namespace YLErp.Modules.RiskEngine : Convert.ToString(value, CultureInfo.InvariantCulture); } + /// + /// 格式化错误信息中的原始值,便于区分 null、空字符串和普通值。 + /// private static string FormatRawValue(object value) { if (value == null) @@ -387,11 +565,48 @@ namespace YLErp.Modules.RiskEngine return string.IsNullOrWhiteSpace(rawValue) ? "" : rawValue; } + /// + /// 格式化命中说明中的值,日期只展示日期部分,布尔值转为中文。 + /// + private static string FormatDisplayValue(object value) + { + if (value == null) + return ""; + 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); + } + + /// + /// 获取操作符中文说明,用于正常命中时解释实际值与阈值的关系。 + /// + private static string GetOperatorName(string ruleOperator) + { + return ruleOperator switch + { + "gt" => "大于阈值", + "lt" => "小于阈值", + "gte" => "大于等于阈值", + "lte" => "小于等于阈值", + "eq" => "等于阈值", + "ne" => "不等于阈值", + _ => ruleOperator + }; + } + + /// + /// 比较数值变量单位。空单位按空字符串处理,前后空格不参与比较。 + /// private static bool UnitsMatch(string left, string right) { return string.Equals(left?.Trim() ?? string.Empty, right?.Trim() ?? string.Empty, StringComparison.Ordinal); } + /// + /// 获取数据类型中文名称,用于拼接用户可读的错误信息。 + /// private static string GetDataTypeName(RiskVariableDataType dataType) { return dataType switch @@ -404,6 +619,9 @@ namespace YLErp.Modules.RiskEngine } } + /// + /// 结构化规则执行结果。Success 表示执行过程是否成功,Triggered 表示规则条件是否命中。 + /// 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) + /// + /// 正常命中时的可读说明,用于展示实际值、阈值和变量表达式返回的计算明细。 + /// + 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 } } + /// + /// 变量表达式可选返回模型。Value 参与规则比较,Message 用于正常命中时展示计算明细。 + /// + public class RiskVariableValueDetail + { + public RiskVariableValueDetail(object value, string message) + { + Value = value; + Message = message; + } + + public object Value { get; } + + public string Message { get; } + } + + /// + /// 单个值取值或转换结果,用于在内部传递转换后的值和错误信息。 + /// internal class ValueExecuteResult { public bool Success { get; set; } public object Value { get; set; } + /// + /// 变量表达式返回的计算明细,仅用于正常命中提示,不参与比较。 + /// + public string DetailMessage { get; set; } + public string ErrorMessage { get; set; } public static ValueExecuteResult Ok(object value) diff --git a/YLErpDAL/Modules/RiskEngine/测试用例.md b/YLErpDAL/Modules/RiskEngine/测试用例.md index a319dcf0..ebf769b8 100644 --- a/YLErpDAL/Modules/RiskEngine/测试用例.md +++ b/YLErpDAL/Modules/RiskEngine/测试用例.md @@ -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 和 ExerciseDate,ExerciseDate 对应合约到期日;通过 DbContext.underlying_manager 按 UnderlyingCode 取 MaturityDate,MaturityDate 对应挂钩标的到期日。计算逻辑:挂钩标的到期日小于合约到期日时触发禁止。", -// 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 diff --git a/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs b/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs index e2315e10..725fcd7b 100644 --- a/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs +++ b/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs @@ -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; } + + /// + /// 构建新风控触发规则明细。禁止、审批、提示三类规则都可能带结构化命中明细,统一格式避免不同策略展示字段不一致。 + /// + 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"; + } + /// /// 校验标的白名单 ///