diff --git a/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs b/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs index e9797395..5d7be9d3 100644 --- a/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs +++ b/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs @@ -1,4 +1,7 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Scripting; +using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Scripting; using Newtonsoft.Json; using System; @@ -46,7 +49,119 @@ namespace YLErp.Modules.RiskEngine /// public static class RuleCompiler { - static IYcLogger _logger = LogFactory.GetLogger("RuleCompiler"); + private static IYcLogger _logger = LogFactory.GetLogger("RuleCompiler"); + + /// + /// 高风险方法黑名单。 + /// 这里按方法名做语法层拦截,覆盖数据库写入、原生 SQL、反射、文件、进程、服务定位等入口。 + /// + private static readonly HashSet ForbiddenInvocationNames = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "SaveChanges", + "SaveChangesAsync", + "Add", + "AddAsync", + "AddRange", + "AddRangeAsync", + "AddOrUpdate", + "Update", + "UpdateRange", + "Remove", + "RemoveRange", + "Attach", + "AttachRange", + "Set", + "Entry", + "ExecuteSqlRaw", + "ExecuteSqlRawAsync", + "ExecuteSqlInterpolated", + "ExecuteSqlInterpolatedAsync", + "ExecuteSql", + "ExecuteSqlAsync", + "ExecuteSqlCommand", + "ExecuteSqlCommandAsync", + "ExecuteStoreCommand", + "ExecuteStoreQuery", + "ExecuteDelete", + "ExecuteDeleteAsync", + "ExecuteUpdate", + "ExecuteUpdateAsync", + "FromSqlRaw", + "FromSqlRawAsync", + "FromSqlInterpolated", + "FromSqlInterpolatedAsync", + "BulkInsert", + "BulkUpdate", + "BulkDelete", + "BulkMerge", + "GetType", + "Invoke", + "InvokeMember", + "GetMethod", + "GetProperty", + "GetField", + "CreateInstance", + "Load", + "LoadFrom", + "ReadAllText", + "ReadAllLines", + "ReadAllBytes", + "WriteAllText", + "WriteAllLines", + "WriteAllBytes", + "AppendAllText", + "Delete", + "Move", + "Copy", + "Open", + "OpenRead", + "OpenWrite", + "Create", + "CreateText", + "Start", + "Kill", + "Exit", + "GetEnvironmentVariable", + "SetEnvironmentVariable", + "GetService", + "GetRequiredService", + "CreateScope", + "Sleep" + }; + + /// + /// 高风险成员黑名单。 + /// DbContext.Database、ChangeTracker 等成员会绕过只读查询约束或暴露底层状态,禁止脚本访问。 + /// + private static readonly HashSet ForbiddenMemberNames = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "Database", + "ChangeTracker", + "Assembly", + "AppDomain" + }; + + /// + /// 高风险类型或标识符黑名单。 + /// 用于拦截 File、Process、Environment 等直接作为类型或变量名出现的绕过方式。 + /// + private static readonly HashSet ForbiddenTypeOrIdentifierNames = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "Activator", + "Assembly", + "AppDomain", + "Environment", + "File", + "Directory", + "Path", + "Process", + "HttpClient", + "WebClient", + "WebRequest", + "Socket", + "Thread", + "GC" + }; /// /// 校验并编译规则表达式。 @@ -79,6 +194,109 @@ namespace YLErp.Modules.RiskEngine } } + /// + /// 校验变量取值表达式。 + /// 变量表达式只要求能编译为 object,不在保存时执行,避免依赖真实交易和数据库数据。 + /// + public static RuleCompileResult ValidateVariableExpression(string variableExpr) + { + if (string.IsNullOrWhiteSpace(variableExpr)) + return RuleCompileResult.Fail("变量取值表达式不能为空"); + + var compiled = CompileVariableExpression(variableExpr, out var compileErrorMessage); + if (compiled == null) + return RuleCompileResult.Fail(compileErrorMessage ?? "变量取值表达式编译失败"); + + return RuleCompileResult.Ok(null); + } + + /// + /// 编译变量取值表达式。 + /// 结构化规则执行时使用,表达式返回值由执行器按变量 DataType 统一转换。 + /// + public static Func CompileValueExpression(string variableExpr, out string errorMessage) + { + if (string.IsNullOrWhiteSpace(variableExpr)) + { + errorMessage = "变量取值表达式不能为空"; + return null; + } + + return CompileVariableExpression(variableExpr, out errorMessage); + } + + /// + /// 编译变量取值表达式。优先兼容原有单表达式写法;失败后再按多语句脚本编译。 + /// 多语句脚本支持用 return 表达最终值,编译前会转换为 Roslyn Script 稳定支持的末尾表达式。 + /// + private static Func CompileVariableExpression(string variableExpr, out string errorMessage) + { + if (IsLikelyVariableScript(variableExpr)) + { + var normalizedScript = NormalizeVariableScript(variableExpr); + var scriptCompiled = CompileScript(null, normalizedScript, out errorMessage); + return scriptCompiled; + } + + // 原有变量表达式大多是单个查询表达式,包成 object 后可直接作为脚本返回值。 + var expressionScript = $"(object)({variableExpr})"; + var compiled = CompileScript(null, expressionScript, out var expressionErrorMessage); + if (compiled != null) + { + errorMessage = null; + return compiled; + } + + // 单表达式编译失败时,再按多语句脚本兜底,兼容包含局部变量、if、throw 的复杂变量。 + var normalizedFallbackScript = NormalizeVariableScript(variableExpr); + compiled = CompileScript(null, normalizedFallbackScript, out var scriptErrorMessage); + if (compiled != null) + { + errorMessage = null; + return compiled; + } + + errorMessage = $"单表达式编译失败:{expressionErrorMessage};多语句脚本编译失败:{scriptErrorMessage}"; + return null; + } + + /// + /// 粗略判断变量表达式是否更像多语句脚本。 + /// 多语句脚本不能再包成 (object)(...),需要直接按 C# Script 编译。 + /// + private static bool IsLikelyVariableScript(string variableExpr) + { + return variableExpr.IndexOf(';') >= 0 || + variableExpr.IndexOf("return", StringComparison.OrdinalIgnoreCase) >= 0 || + variableExpr.IndexOf("throw", StringComparison.OrdinalIgnoreCase) >= 0 || + variableExpr.IndexOf("if", StringComparison.OrdinalIgnoreCase) >= 0; + } + + /// + /// 规范化多语句变量脚本。 + /// Roslyn Script 更稳定的返回方式是“最后一行表达式”,这里把末尾 return 表达式转换为末尾表达式。 + /// + private static string NormalizeVariableScript(string variableExpr) + { + var syntaxTree = CSharpSyntaxTree.ParseText(variableExpr, new CSharpParseOptions(kind: SourceCodeKind.Script)); + var root = syntaxTree.GetCompilationUnitRoot(); + var lastStatement = root.Members + .OfType() + .Select(statement => statement.Statement) + .LastOrDefault(); + + if (lastStatement is ReturnStatementSyntax returnStatement && returnStatement.Expression != null) + { + var returnText = returnStatement.ToFullString(); + var expressionText = returnStatement.Expression.ToFullString(); + var index = variableExpr.LastIndexOf(returnText, StringComparison.Ordinal); + if (index >= 0) + return variableExpr.Substring(0, index) + expressionText; + } + + return variableExpr; + } + /// /// 校验并编译规则,编译成功后同时写入规则对象和内存缓存。 /// @@ -115,13 +333,28 @@ namespace YLErp.Modules.RiskEngine } /// - /// 用 Roslyn 编译 C# 脚本代码为可执行委托 + /// 用 Roslyn 编译 C# 布尔表达式为可执行委托。 /// private static Func CompileScript(long? ruleId, string scriptCode, out string errorMessage) + { + return CompileScript(ruleId, scriptCode, out errorMessage); + } + + /// + /// 用 Roslyn 编译 C# 脚本代码为指定返回类型的可执行委托。 + /// 规则公式编译为 bool,变量取值表达式编译为 object。 + /// + private static Func CompileScript(long? ruleId, string scriptCode, out string errorMessage) { errorMessage = null; var ruleIdText = ruleId.HasValue ? ruleId.Value.ToString() : "未落库"; + if (!ValidateScriptSafety(scriptCode, out errorMessage)) + { + _logger.Error($"规则脚本安全校验失败 - RuleId: {ruleIdText}, Error: {errorMessage}\n脚本代码:{scriptCode}"); + return null; + } + // 配置编译选项:引用必要的程序集 var options = ScriptOptions.Default .WithReferences( @@ -136,7 +369,7 @@ namespace YLErp.Modules.RiskEngine .WithImports("System", "System.Linq", "Newtonsoft.Json", "YLErp.DBModels", "YLErp.QdpModule"); // 创建脚本(尚未执行,仅编译) - var script = CSharpScript.Create(scriptCode, options, globalsType: typeof(ScriptGlobals)); + var script = CSharpScript.Create(scriptCode, options, globalsType: typeof(ScriptGlobals)); // 编译(提前发现语法错误) //后续编译需提供接口,返回前端编译信息,包含编译错误列表 @@ -153,7 +386,7 @@ namespace YLErp.Modules.RiskEngine // 生成可调用委托 var runner = script.CreateDelegate(); - // 包装为同步的 Func + // 包装为同步的 Func return ctx => { try @@ -174,6 +407,125 @@ namespace YLErp.Modules.RiskEngine } }; } + + /// + /// 脚本安全校验。 + /// 在 Roslyn 编译前先扫描语法树,禁止写库、反射、文件、网络、进程等高风险语法入口。 + /// + private static bool ValidateScriptSafety(string scriptCode, out string errorMessage) + { + var tree = CSharpSyntaxTree.ParseText(scriptCode, new CSharpParseOptions(kind: SourceCodeKind.Script)); + var root = tree.GetRoot(); + + // 允许变量声明初始化(如 decimal a = ...),但禁止后续赋值或复合赋值,避免脚本修改对象状态。 + if (root.DescendantNodes().OfType().Any()) + { + errorMessage = "规则表达式不允许包含赋值语句"; + return false; + } + + if (root.DescendantNodes().OfType().Any(IsIncrementOrDecrement) || + root.DescendantNodes().OfType().Any(IsIncrementOrDecrement)) + { + errorMessage = "规则表达式不允许包含自增或自减语句"; + return false; + } + + foreach (var memberAccess in root.DescendantNodes().OfType()) + { + var memberName = memberAccess.Name.Identifier.ValueText; + if (!string.IsNullOrWhiteSpace(memberName) && ForbiddenMemberNames.Contains(memberName)) + { + errorMessage = $"规则表达式不允许访问高风险成员:{memberName}"; + return false; + } + } + + // 只拦截独立标识符,避免把实体字段 p.File、p.Path 误判为高风险类型名。 + foreach (var identifier in root.DescendantNodes().OfType()) + { + var identifierName = identifier.Identifier.ValueText; + if (!IsMemberAccessName(identifier) && !string.IsNullOrWhiteSpace(identifierName) && ForbiddenTypeOrIdentifierNames.Contains(identifierName)) + { + errorMessage = $"规则表达式不允许访问高风险类型或标识符:{identifierName}"; + return false; + } + } + + foreach (var objectCreation in root.DescendantNodes().OfType()) + { + var typeName = GetTypeName(objectCreation.Type); + if (!string.IsNullOrWhiteSpace(typeName) && ForbiddenTypeOrIdentifierNames.Contains(typeName)) + { + errorMessage = $"规则表达式不允许创建高风险类型:{typeName}"; + return false; + } + } + + foreach (var invocation in root.DescendantNodes().OfType()) + { + var methodName = GetInvocationName(invocation.Expression); + if (!string.IsNullOrWhiteSpace(methodName) && ForbiddenInvocationNames.Contains(methodName)) + { + errorMessage = $"规则表达式不允许调用高风险方法:{methodName}"; + return false; + } + } + + errorMessage = null; + return true; + } + + /// + /// 判断前缀一元表达式是否为自增/自减。 + /// + private static bool IsIncrementOrDecrement(PrefixUnaryExpressionSyntax expression) + { + return expression.IsKind(SyntaxKind.PreIncrementExpression) || expression.IsKind(SyntaxKind.PreDecrementExpression); + } + + /// + /// 判断后缀一元表达式是否为自增/自减。 + /// + private static bool IsIncrementOrDecrement(PostfixUnaryExpressionSyntax expression) + { + return expression.IsKind(SyntaxKind.PostIncrementExpression) || expression.IsKind(SyntaxKind.PostDecrementExpression); + } + + /// + /// 判断标识符是否是成员访问右侧名称,例如 p.Path 中的 Path。 + /// + private static bool IsMemberAccessName(IdentifierNameSyntax identifier) + { + return identifier.Parent is MemberAccessExpressionSyntax memberAccess && memberAccess.Name == identifier; + } + + /// + /// 从调用表达式中提取方法名,兼容 SaveChanges() 和 DbContext.SaveChanges() 两种写法。 + /// + private static string GetInvocationName(ExpressionSyntax expression) + { + return expression switch + { + MemberAccessExpressionSyntax memberAccess => memberAccess.Name.Identifier.ValueText, + IdentifierNameSyntax identifier => identifier.Identifier.ValueText, + _ => null + }; + } + + /// + /// 从类型语法中提取类型短名,用于匹配高风险类型黑名单。 + /// + private static string GetTypeName(TypeSyntax type) + { + return type switch + { + IdentifierNameSyntax identifier => identifier.Identifier.ValueText, + QualifiedNameSyntax qualifiedName => qualifiedName.Right.Identifier.ValueText, + AliasQualifiedNameSyntax aliasQualifiedName => aliasQualifiedName.Name.Identifier.ValueText, + _ => type?.ToString() + }; + } } public class BuildMemberAccessResult diff --git a/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs b/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs index d16a4911..5bf02e41 100644 --- a/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs +++ b/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs @@ -21,26 +21,26 @@ using YLErp.Model; RuleText、RuleExpr、Version、CompiledScript。 - RiskRuleApplication(规则应用):与规则分离,承载启用状态、控制策略、触发时点、 适用范围(全局 / 账户 / 客户 / 标的类型 / 合约类型)。 - - RuleExpr:当前唯一主编译入口。要求内容是 Roslyn 可直接执行的 C# bool 表达式, + - RuleExpr:自由文本规则的编译入口。要求内容是 Roslyn 可直接执行的 C# bool 表达式, 例如通过 DbContext 和 TradeId 查询数据库后做数值或日期比较。 - - ConditionJson:当前已在规则定义中保留,但不参与主执行链路。 + - ConditionJson:结构化规则的主执行依据;有值时优先由结构化执行器执行。 - RiskContext:一次风控检查的数据上下文,包含 TradeId、TriggerPoint 和 DbContext。 - - RuleCompiledCache:进程内编译结果缓存,按规则 Id 缓存 Func。 + - RuleCompiledCache:进程内编译结果缓存,仅用于自由文本 RuleExpr 规则,按规则 Id 缓存 Func。 当前编译流程: - 1. 外部准备好规则对象,在 RuleExpr 中直接写最终可执行表达式 - 2. 调用 RuleCompiler.ValidateAndCompileRule(rule) - 3. 内部使用 Roslyn 编译 RuleExpr,生成 Func - 4. 编译成功后写入 rule.CompiledScript,并同步写入 RuleCompiledCache - 5. 执行阶段优先从 RuleCompiledCache 取委托执行 + 1. 结构化规则以 ConditionJson 为执行依据,预热时只做 JSON 结构解析校验 + 2. 自由文本规则在 RuleExpr 中直接写最终可执行表达式 + 3. 调用 RuleCompiler.ValidateAndCompileRule(rule) + 4. 内部使用 Roslyn 编译 RuleExpr,生成 Func + 5. 编译成功后写入 rule.CompiledScript,并同步写入 RuleCompiledCache 当前执行流程(EvaluateRisk): 1. 从数据库加载规则列表和应用列表 2. 按应用状态、TriggerPoints 过滤有效应用 3. 按应用范围过滤交易是否命中(全局 / 账户 / 客户 / 标的类型 / 合约类型) 4. 根据应用配置中的 RuleIds 找到对应规则 Id 并关联规则 - 5. 优先从 RuleCompiledCache 读取已编译委托,未命中时兜底编译一次 - 6. 执行规则委托,按 ControlStrategy 聚合为 Blocked / NeedApproval / Warnings + 5. ConditionJson 有值时走结构化执行器;否则从 RuleCompiledCache 读取已编译委托,未命中时兜底编译一次 + 6. 执行规则判断,按 ControlStrategy 聚合为 Blocked / NeedApproval / Warnings 7. 返回 RiskResult 当前维度匹配规则: @@ -50,7 +50,7 @@ using YLErp.Model; - 某维度留空:视为该维度不限制 【与早期方案的主要差异】 - - 当前不是 Content 解析或表达式树主导,而是 RuleExpr 直编译主导 + - 结构化规则已改为 ConditionJson 执行主导,自由文本规则继续保留 RuleExpr 直编译 - RiskRule 与 RiskRuleApplication 当前仍是分离模型,没有合并 - 编译器文件已放入 RiskEngine/Compile 目录下 - 当前已引入 RuleCompileResult、RuleCompiledCache,用于校验结果与进程内缓存 @@ -75,12 +75,12 @@ using YLErp.Model; ✅ 已完成: 1. RiskEngine 第一版执行链路已跑通:QuotaMonitorService -> RiskEngineService -> RuleCompiler 2. 风控上下文提供 DbContext 和 TradeId,支持规则脚本直接查询数据库 - 3. RuleExpr 直编译方案已接入,支持数值比较和日期比较 + 3. 结构化规则已支持 ConditionJson 执行,自由文本规则保留 RuleExpr 直编译 4. Application 通用维度匹配已支持:全局 / 账户 / 客户 / 标的类型 / 合约类型 5. 维度组合逻辑已按文档确认:同维度 OR,不同维度 AND 6. RuleCompiler 已支持校验 + 编译 + 写缓存 7. RuleCompiledCache / RuleCompileResult 已落地到 Compile 目录 - 8. RiskEngineService 已改为执行时优先从 RuleCompiledCache 读取委托 + 8. RiskEngineService 已改为结构化规则优先执行 ConditionJson,自由文本规则再读取 RuleCompiledCache 【待办事项 / TODO】 ⬜ 1. 接入真实规则来源 @@ -146,8 +146,8 @@ namespace YLErp.Modules.RiskEngine private static readonly object _cacheLock = new object(); /// - /// 预热:加载规则与应用到内存,并预编译所有规则到 RuleCompiledCache。 - /// 项目启动时调用一次;规则/应用更新后调用 RefreshCache 刷新。 + /// 预热:加载规则与应用到内存,并预编译自由文本规则到 RuleCompiledCache。 + /// 结构化规则运行时直接执行 ConditionJson,预热时只做轻量结构校验,不再预编译 RuleExpr。 /// public void Preload() { @@ -157,7 +157,7 @@ namespace YLErp.Modules.RiskEngine var rules = LoadRulesFromDb(); var applications = LoadApplicationsFromDb(); - // 预编译所有规则到 RuleCompiledCache + // 仅预编译自由文本规则;结构化规则运行时由 ConditionJson 执行。 foreach (var rule in rules) { var ruleId = rule.Id.ToString(); @@ -169,6 +169,20 @@ namespace YLErp.Modules.RiskEngine continue; } + if (!string.IsNullOrWhiteSpace(rule.ConditionJson)) + { + RuleCompiledCache.Remove(rule.Id.ToString()); + try + { + RuleConditionExpressionBuilder.DeserializeConditions(rule.ConditionJson); + _logger.Info($"[风控引擎] 结构化规则预热校验成功,跳过 RuleExpr 预编译 - RuleId: {rule.Id}"); + } + catch (Exception ex) + { + _logger.Info($"[风控引擎] 结构化规则预热校验失败 - RuleId: {rule.Id}, Error: {ex.Message}"); + } + continue; + } var compileResult = RuleCompiler.ValidateAndCompileRule(rule); if (compileResult.Success) @@ -231,6 +245,22 @@ namespace YLErp.Modules.RiskEngine return RuleCompileResult.Fail("规则不存在或非活跃状态"); } + if (!string.IsNullOrWhiteSpace(rule.ConditionJson)) + { + RuleCompiledCache.Remove(ruleId.ToString()); + try + { + RuleConditionExpressionBuilder.DeserializeConditions(rule.ConditionJson); + _logger.Info($"[风控引擎] RefreshOneRuleCache 结构化规则校验成功,跳过 RuleExpr 编译 - RuleId: {ruleId}"); + return RuleCompileResult.Ok(null); + } + catch (Exception ex) + { + _logger.Info($"[风控引擎] RefreshOneRuleCache 结构化规则校验失败 - RuleId: {ruleId}, Error: {ex.Message}"); + return RuleCompileResult.Fail(ex.Message); + } + } + var compileResult = RuleCompiler.ValidateAndCompileRule(rule); _logger.Info($"[风控引擎] RefreshOneRuleCache 完成 - RuleId: {ruleId}, Success: {compileResult.Success}, Error: {compileResult.ErrorMessage}"); return compileResult; @@ -323,6 +353,7 @@ namespace YLErp.Modules.RiskEngine // ============================================================ var rules = GetRules(); var applications = GetApplications(); + var variables = new Dictionary(); _logger.Info($"[风控引擎] 加载规则数: {rules.Count}, 应用数: {applications.Count}"); // ============================================================ @@ -395,40 +426,88 @@ namespace YLErp.Modules.RiskEngine { var ruleId = rule.Id.ToString(); - // ------------------------------------------------------------ - // 3.2 优先从编译缓存读取规则委托 - // ------------------------------------------------------------ - if (!RuleCompiledCache.TryGet(ruleId, out var compiledScript)) + bool triggered = false; + if (!string.IsNullOrWhiteSpace(rule.ConditionJson)) { - _logger.Info($"[风控引擎] 缓存中未命中已编译规则,执行兜底编译 - RuleId: {rule.Id}"); - - var compileResult = RuleCompiler.ValidateAndCompileRule(rule); - if (!compileResult.Success) + // 结构化规则优先使用 ConditionJson 执行,避免继续把条件整体拼成 Roslyn bool 公式。 + try { - var errorMessage = $"规则[{rule.RuleName}]编译失败,已按阻断处理,请检查规则表达式配置:{compileResult.ErrorMessage}"; - _logger.Info($"[风控引擎] 规则编译失败,按阻断处理 - RuleId: {rule.Id}, Error: {compileResult.ErrorMessage}"); - AddBlockError(result, rule.Id.ToString(), rule.RuleName, rule.RuleText, errorMessage); + var conditions = RuleConditionExpressionBuilder.DeserializeConditions(rule.ConditionJson); + var referencedVariableIds = RuleConditionExpressionBuilder.GetReferencedVariableIds(conditions); + var missingVariableIds = referencedVariableIds + .Where(id => !variables.ContainsKey(id)) + .ToList(); + if (missingVariableIds.Any()) + { + // 按本次结构化规则实际引用的变量懒加载,避免每次风控检查全量读取变量池。 + var loadedVariables = ruleDbContext.glms_risk_variable + .AsNoTracking() + .Where(v => missingVariableIds.Contains(v.id)) + .ToDictionary(v => v.id); + foreach (var variable in loadedVariables) + variables[variable.Key] = variable.Value; + } + + var ruleVariables = referencedVariableIds + .Where(variables.ContainsKey) + .ToDictionary(id => id, id => variables[id]); + + var executeResult = StructuredRuleExecutor.Execute(conditions, ruleVariables, context); + if (!executeResult.Success) + { + var errorMessage = $"规则[{rule.RuleName}]执行异常:{executeResult.ErrorMessage}"; + _logger.Error($"[风控引擎] 结构化规则执行异常,按阻断处理 - RuleId: {rule.Id}, Error: {executeResult.ErrorMessage}"); + AddBlockError(result, ruleId, rule.RuleName, rule.RuleText, errorMessage); + continue; + } + + triggered = executeResult.Triggered; + _logger.Info($"[风控引擎] 结构化规则执行 - RuleId: {rule.Id}, Triggered: {triggered}"); + } + catch (Exception ex) + { + var errorMessage = $"规则[{rule.RuleName}]执行异常:{ex.Message}"; + _logger.Error($"[风控引擎] 结构化规则执行异常,按阻断处理 - RuleId: {rule.Id}, Error: {ex.Message}"); + AddBlockError(result, ruleId, rule.RuleName, rule.RuleText, errorMessage); continue; } - - compiledScript = compileResult.CompiledScript; } + else + { + // ------------------------------------------------------------ + // 3.2 优先从编译缓存读取规则委托 + // ------------------------------------------------------------ + if (!RuleCompiledCache.TryGet(ruleId, out var compiledScript)) + { + _logger.Info($"[风控引擎] 缓存中未命中已编译规则,执行兜底编译 - RuleId: {rule.Id}"); - // ------------------------------------------------------------ - // 3.3 执行预编译委托 - // ------------------------------------------------------------ - bool triggered = false; - try - { - triggered = compiledScript(context); - _logger.Info($"[风控引擎] 规则执行 - RuleId: {rule.Id}, Triggered: {triggered}"); - } - catch (Exception ex) - { - var errorMessage = $"规则[{rule.RuleName}]执行异常:{ex.Message}"; - _logger.Error($"[风控引擎] 规则执行异常,按阻断处理 - RuleId: {rule.Id}, Error: {ex.Message}"); - AddBlockError(result, ruleId, rule.RuleName, rule.RuleText, errorMessage); - continue; + var compileResult = RuleCompiler.ValidateAndCompileRule(rule); + if (!compileResult.Success) + { + var errorMessage = $"规则[{rule.RuleName}]编译失败,已按阻断处理,请检查规则表达式配置:{compileResult.ErrorMessage}"; + _logger.Info($"[风控引擎] 规则编译失败,按阻断处理 - RuleId: {rule.Id}, Error: {compileResult.ErrorMessage}"); + AddBlockError(result, rule.Id.ToString(), rule.RuleName, rule.RuleText, errorMessage); + continue; + } + + compiledScript = compileResult.CompiledScript; + } + + // ------------------------------------------------------------ + // 3.3 执行预编译委托 + // ------------------------------------------------------------ + try + { + triggered = compiledScript(context); + _logger.Info($"[风控引擎] 规则执行 - RuleId: {rule.Id}, Triggered: {triggered}"); + } + catch (Exception ex) + { + var errorMessage = $"规则[{rule.RuleName}]执行异常:{ex.Message}"; + _logger.Error($"[风控引擎] 规则执行异常,按阻断处理 - RuleId: {rule.Id}, Error: {ex.Message}"); + AddBlockError(result, ruleId, rule.RuleName, rule.RuleText, errorMessage); + continue; + } } // ------------------------------------------------------------ diff --git a/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs b/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs index 92ade91b..bad1b920 100644 --- a/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs +++ b/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs @@ -180,10 +180,10 @@ using YLErp.Modules.RiskEngine.Dto; 【注意事项】 1. RiskEngineService 由另一个团队开发实现,本服务通过 GetInstance() 获取单例引用 - 2. 规则变更后调用 RefreshOneRuleCache(ruleId),应用变更后调用 RefreshApplication(),变量变更不触发引擎缓存刷新 + 2. 规则变更后调用 RefreshOneRuleCache(ruleId),应用变更后调用 RefreshApplication(),变量定义变更后刷新引用规则缓存 3. 变量删除为硬删除(物理删除),规则/应用删除为软删除(Status=Deleted) 4. ExportAuditLogs 按查询条件导出,并生成包含查询条件的文件名 - 5. VariableExpr 当前仅做基础长度校验(≤10000),完整编译校验待引入 Roslyn 库 + 5. VariableExpr 保存时校验长度,并通过 Roslyn 做编译与安全校验 6. ValidateScopeFields 的 ID 存在性校验已跳过(前端下拉选择器保证有效性) ================================================================================ */ @@ -353,11 +353,12 @@ namespace YLErp.Modules.RiskEngine } /// - /// 校验 ConditionJson 并生成服务端期望的简洁 RuleExpr。 + /// 校验 ConditionJson 结构和引用变量是否存在。 + /// 结构化规则运行时以 ConditionJson 为准,RuleExpr 只作为兼容/展示字段。 /// - private string ValidateConditionJson(string conditionJson) + private void ValidateConditionJson(string conditionJson) { - return BuildRuleExprFromConditionJson(conditionJson); + ValidateConditionJsonVariables(conditionJson); } /// /// 校验 RuleExpr(自由文本模式:括号匹配) @@ -511,7 +512,8 @@ namespace YLErp.Modules.RiskEngine } /// - /// 校验规则参数(名称 + ConditionJson + RuleExpr) + /// 校验规则参数(名称 + ConditionJson / RuleExpr)。 + /// 自由文本规则继续使用 RuleExpr;结构化规则允许保留 RuleExpr 作为兼容/展示字段,运行时以 ConditionJson 为准。 /// private void ValidateRuleParams(string ruleName, string conditionJson, string ruleExpr, long? ruleId = null) { @@ -520,22 +522,31 @@ namespace YLErp.Modules.RiskEngine if (ruleName.Length > 200) throw new ServiceException("规则名称长度不能超过200字符"); + if (!string.IsNullOrWhiteSpace(conditionJson)) + { + ValidateConditionJson(conditionJson); + return; + } + if (string.IsNullOrWhiteSpace(ruleExpr)) throw new ServiceException("规则表达式不能为空"); - if (!string.IsNullOrWhiteSpace(conditionJson)) - { - var expectedRuleExpr = ValidateConditionJson(conditionJson); - if (!string.Equals(expectedRuleExpr, ruleExpr, StringComparison.Ordinal)) - { - _logger.Info($"结构化规则公式一致性校验失败。RuleId={ruleId?.ToString() ?? "NEW"}; Expected={expectedRuleExpr}; Actual={ruleExpr}"); - throw new ServiceException("公式与结构化条件不一致,请刷新后重试"); - } - } - ValidateRuleExpr(ruleExpr); } + /// + /// 校验规则编译:结构化规则运行时以 ConditionJson 为准,只有自由文本规则必须编译 RuleExpr。 + /// + private void ValidateRuleCompile(long? ruleId, string conditionJson, string ruleExpr) + { + if (!string.IsNullOrWhiteSpace(conditionJson)) + return; + + var compileResult = RuleCompiler.ValidateAndCompileFormula(ruleId, ruleExpr); + if (!compileResult.Success) + throw new ServiceException($"规则表达式编译失败:{compileResult.ErrorMessage}"); + } + /// /// 解析逗号分隔的规则 ID 字符串为 List /// @@ -893,10 +904,7 @@ namespace YLErp.Modules.RiskEngine public RiskRuleDetail CreateRule(CreateRiskRuleReq req) { ValidateRuleParams(req.RuleName, req.ConditionJson, req.RuleExpr); - - var compileResult = RuleCompiler.ValidateAndCompileFormula(null, req.RuleExpr); - if (!compileResult.Success) - throw new ServiceException($"规则表达式编译失败:{compileResult.ErrorMessage}"); + ValidateRuleCompile(null, req.ConditionJson, req.RuleExpr); var entity = new glms_risk_rule { @@ -950,10 +958,7 @@ namespace YLErp.Modules.RiskEngine throw new ServiceException("规则已被其他用户修改,请重新加载后再编辑"); ValidateRuleParams(req.RuleName, req.ConditionJson, req.RuleExpr, ruleId); - - var compileResult = RuleCompiler.ValidateAndCompileFormula(ruleId, req.RuleExpr); - if (!compileResult.Success) - throw new ServiceException($"规则表达式编译失败:{compileResult.ErrorMessage}"); + ValidateRuleCompile(ruleId, req.ConditionJson, req.RuleExpr); rule.RuleName = req.RuleName; rule.RuleText = req.RuleText; @@ -1007,9 +1012,7 @@ namespace YLErp.Modules.RiskEngine if (rule == null) throw new ServiceException("仅已停用的规则可以启用"); - var compileResult = RuleCompiler.ValidateAndCompileFormula(ruleId, rule.RuleExpr); - if (!compileResult.Success) - throw new ServiceException($"规则表达式编译失败:{compileResult.ErrorMessage}"); + ValidateRuleCompile(ruleId, rule.ConditionJson, rule.RuleExpr); rule.Status = RiskRuleStatus.Active; @@ -1198,11 +1201,13 @@ namespace YLErp.Modules.RiskEngine continue; } - var compileResult = RuleCompiler.ValidateAndCompileFormula(rule.id, rule.RuleExpr); - if (!compileResult.Success) + try { - items.Add(BatchItem(id, BatchOperationItemStatus.Failed, "COMPILE_FAILED", - $"规则表达式编译失败:{compileResult.ErrorMessage}")); + ValidateRuleCompile(rule.id, rule.ConditionJson, rule.RuleExpr); + } + catch (ServiceException ex) + { + items.Add(BatchItem(id, BatchOperationItemStatus.Failed, "COMPILE_FAILED", ex.Message)); continue; } @@ -1807,23 +1812,36 @@ namespace YLErp.Modules.RiskEngine } /// - /// 根据结构化 ConditionJson 和当前变量池定义重新生成简洁 RuleExpr。 + /// 校验结构化 ConditionJson 引用的变量是否存在。 /// - private string BuildRuleExprFromConditionJson(string conditionJson) + private List ValidateConditionJsonVariables(string conditionJson) { var conditions = RuleConditionExpressionBuilder.DeserializeConditions(conditionJson); var variableIds = RuleConditionExpressionBuilder.GetReferencedVariableIds(conditions).ToList(); var variables = DbContext.glms_risk_variable .Where(variable => variableIds.Contains(variable.id)) - .ToList() - .ToDictionary(variable => (long)variable.id); + .ToList(); - var missingVariableIds = variableIds.Where(id => !variables.ContainsKey(id)).ToList(); + var variableMap = variables.ToDictionary(variable => (long)variable.id); + var missingVariableIds = variableIds.Where(id => !variableMap.ContainsKey(id)).ToList(); if (missingVariableIds.Any()) throw new ServiceException($"公式引用的变量不存在:{string.Join(",", missingVariableIds)}"); - return RuleConditionExpressionBuilder.Build(conditions, variables); + return variables; } + + + /// + /// 校验变量取值表达式是否可编译且不包含写库等危险调用。 + /// 这里只做静态编译与安全检查,不执行表达式,避免依赖真实交易数据。 + /// + private void ValidateVariableExpr(string variableExpr) + { + var compileResult = RuleCompiler.ValidateVariableExpression(variableExpr); + if (!compileResult.Success) + throw new ServiceException($"变量取值表达式校验失败:{compileResult.ErrorMessage}"); + } + #region Variable Management /// @@ -1907,6 +1925,7 @@ namespace YLErp.Modules.RiskEngine throw new ServiceException("取值表达式不能为空"); if (req.VariableExpr.Length > 10000) throw new ServiceException("取值表达式长度不能超过 10000 字符"); + ValidateVariableExpr(req.VariableExpr); var entity = new glms_risk_variable { @@ -1965,6 +1984,9 @@ namespace YLErp.Modules.RiskEngine throw new ServiceException("变量名称不能为空"); if (string.IsNullOrWhiteSpace(req.VariableExpr)) throw new ServiceException("取值表达式不能为空"); + if (req.VariableExpr.Length > 10000) + throw new ServiceException("取值表达式长度不能超过 10000 字符"); + ValidateVariableExpr(req.VariableExpr); if (req.DataType != variable.DataType) { @@ -2000,34 +2022,20 @@ namespace YLErp.Modules.RiskEngine { try { - // 在同一事务内完成引用发现、公式重建、编译和持久化;任一规则失败时整体回滚。 + // 结构化规则运行时直接按 ConditionJson 读取变量定义,不再把变量表达式展开回 RuleExpr。 + // 因此变量定义变化时只标记引用规则受影响,避免多语句变量表达式被拼成非法公式。 affectedRules = GetRulesByVariableId(variableId) - .Where(r => r.RuleExpr != null) .ToList(); - var rebuiltRuleExpressions = new Dictionary(); foreach (var rule in affectedRules) { - var rebuiltRuleExpr = BuildRuleExprFromConditionJson(rule.ConditionJson); - ValidateRuleExpr(rebuiltRuleExpr); - - var compileResult = RuleCompiler.ValidateAndCompileFormula(rule.id, rebuiltRuleExpr); - if (!compileResult.Success) - throw new ServiceException($"变量定义变更导致规则'{rule.RuleName}'(ID:{rule.id})编译失败:{compileResult.ErrorMessage}"); - - rebuiltRuleExpressions[rule.id] = rebuiltRuleExpr; - } - - foreach (var rule in affectedRules) - { - rule.RuleExpr = rebuiltRuleExpressions[rule.id]; rule.Version = rule.Version + 1; rule.UpdateOptId = UserId; rule.UpdateOptName = UserName; rule.UpdateDate = DateTime.Now; WriteAuditLog("VAR_CASCADE_UPDATE_RULE", "RULE", rule.id, rule.RuleName, - $"因变量'{variable.VariableName}'(ID:{variableId})定义变更,规则表达式被级联更新"); + $"因变量'{variable.VariableName}'(ID:{variableId})定义变更,结构化规则已标记更新"); } SaveChangesWithConcurrencyCheck(); diff --git a/YLErpDAL/Modules/RiskEngine/StructuredRuleExecutor.cs b/YLErpDAL/Modules/RiskEngine/StructuredRuleExecutor.cs new file mode 100644 index 00000000..af9e7171 --- /dev/null +++ b/YLErpDAL/Modules/RiskEngine/StructuredRuleExecutor.cs @@ -0,0 +1,461 @@ +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 +{ + /// + /// 结构化规则执行器。 + /// 用于执行现有 ConditionJson:变量取值、按 DataType 转换并比较阈值;复杂业务计算放在变量表达式中完成。 + /// + public static class StructuredRuleExecutor + { + private static readonly ConcurrentDictionary> VariableCompiledCache = new ConcurrentDictionary>(); + + 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" + }; + + public static StructuredRuleExecuteResult Execute( + IReadOnlyList conditions, + IReadOnlyDictionary variables, + RiskContext context) + { + try + { + if (conditions == null || conditions.Count == 0) + return StructuredRuleExecuteResult.Fail("公式条件列表不能为空"); + if (variables == null) + return StructuredRuleExecuteResult.Fail("变量列表不能为空"); + + for (var i = 0; i < conditions.Count; i++) + { + var conditionResult = ExecuteCondition(conditions[i], variables, context, i + 1); + if (!conditionResult.Success) + return conditionResult; + if (!conditionResult.Triggered) + return StructuredRuleExecuteResult.Ok(false); + } + + return StructuredRuleExecuteResult.Ok(true); + } + catch (Exception ex) + { + return StructuredRuleExecuteResult.Fail(ex.Message); + } + } + + private static StructuredRuleExecuteResult ExecuteCondition( + RuleCondition condition, + IReadOnlyDictionary 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}' 不存在"); + + 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.Value, 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); + } + + 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"; + return StructuredRuleExecuteResult.Ok((bool)value.Value == expected); + } + + private static StructuredRuleExecuteResult ExecuteRangeCondition( + RuleCondition condition, + glms_risk_variable variable, + object leftValue, + IReadOnlyDictionary 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(leftValue, lower.Value, variable.DataType, lowerOperator, label); + if (!lowerCompare.Success) + return lowerCompare; + var upperCompare = Compare(leftValue, 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); + } + + private static ValueExecuteResult GetThresholdValue( + string thresholdType, + object fixedValue, + long? thresholdVariableId, + glms_risk_variable conditionVariable, + IReadOnlyDictionary 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"); + } + + 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}"); + } + + var converted = ConvertValue(rawValue, dataType, label, valueLabel); + if (!converted.Success) + return ValueExecuteResult.Fail($"{label}:变量'{variable.VariableName}'(ID:{variable.id}){converted.ErrorMessage},原始值:{FormatRawValue(rawValue)}"); + + return converted; + } + + 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)}"; + 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; + } + + 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); + 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}:不支持的数据类型"); + } + + private static StructuredRuleExecuteResult Compare(object left, object right, RiskVariableDataType dataType, string ruleOperator, string label) + { + 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); + } + + 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); + } + + 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); + } + + 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); + } + + 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()); + return true; + } + return value is not string text || !string.IsNullOrWhiteSpace(text); + } + + private static string GetRawValue(object value) + { + return value is JValue jsonValue + ? Convert.ToString(jsonValue.Value, CultureInfo.InvariantCulture) + : Convert.ToString(value, CultureInfo.InvariantCulture); + } + + private static string FormatRawValue(object value) + { + if (value == null) + return ""; + var rawValue = GetRawValue(value); + return string.IsNullOrWhiteSpace(rawValue) ? "" : rawValue; + } + + 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 + { + RiskVariableDataType.Numeric => "数值", + RiskVariableDataType.Date => "日期", + RiskVariableDataType.Boolean => "布尔", + _ => "未知" + }; + } + } + + public class StructuredRuleExecuteResult + { + public bool Success { get; set; } + + public bool Triggered { get; set; } + + public string ErrorMessage { get; set; } + + public static StructuredRuleExecuteResult Ok(bool triggered) + { + return new StructuredRuleExecuteResult + { + Success = true, + Triggered = triggered + }; + } + + public static StructuredRuleExecuteResult Fail(string errorMessage) + { + return new StructuredRuleExecuteResult + { + Success = false, + Triggered = false, + ErrorMessage = errorMessage + }; + } + } + + internal class ValueExecuteResult + { + public bool Success { get; set; } + + public object Value { 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 + }; + } + } +} diff --git a/YLErpDAL/Modules/RiskEngine/测试用例.md b/YLErpDAL/Modules/RiskEngine/测试用例.md index 7d29142a..a319dcf0 100644 --- a/YLErpDAL/Modules/RiskEngine/测试用例.md +++ b/YLErpDAL/Modules/RiskEngine/测试用例.md @@ -1575,6 +1575,34 @@ WHERE t.id = @TradeId; 保证金支付金额 ÷ 开仓名义本金 > 50% ``` +变量 Roslyn 示例:变量名为“保证金支付比例”,DataType 为 Numeric;规则前端仍配置“保证金支付比例 > 0.5”。字段类型需为 decimal / decimal?,否则需要字段层调整或显式类型转换。 + +```csharp +decimal marginPaymentAmount = DbContext.swap_position + .Where(p => p.SwapTradeId == TradeId + && p.IsInitial + && !p.Invalid + && (p.InterestMode == 5 || p.InterestMode == 6) + && p.InterestDirection == 2) + .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,不能作为除数"); + +return marginPaymentAmount / openingNotional; +``` + 规则字段口径: ```text