using Newtonsoft.Json; using Qdp.Foundation.Utilities; using System.Reflection; using YLErp.BLL; using YLErp.Model; /* ================================================================================ 风控引擎服务 — RiskEngineService 技术方案与当前实现说明 ================================================================================ 【项目背景】 当前 TRS 系统已在 QuotaMonitorService 中接入第一版风控引擎,用于在交易关键 时点执行可配置规则判断。当前目标不是一次性做成最终版,而是在最小可运行链路 跑通后,逐步演进为可由前端配置、后端预编译、执行期直接命中的正式版本。 【当前实现口径】(以本注释和实际代码为准) 核心概念: - RiskRule(风控规则):单条规则包含完整的公式定义 + 校验策略 + 触发时点 + 适用范围。 不需要 "一个 Rule 对应多个 Application" 的复杂关系。 - Content:用户输入的规则内容字符串,如"名义本金>1亿" - 规则编译:在规则定义时(创建/加载/缓存刷新时)调用 RuleCompiler.Compile(rule), 将 Content 解析并编译为 Func 委托,写入 rule.CompiledScript。 判风控时直接执行委托,零解析开销。 - RiskContext:一次风控检查的数据上下文(DataMap 机制) - RiskResult:风控检查结果(Passed / Blocked / NeedApproval / Warnings / TriggeredRules) 当前编译流程: 1. 外部准备好规则对象,在 RuleExpr 中直接写最终可执行表达式 2. 调用 RuleCompiler.ValidateAndCompileRule(rule) 3. 内部使用 Roslyn 编译 RuleExpr,生成 Func 4. 编译成功后写入 rule.CompiledScript,并同步写入 RuleCompiledCache 5. 执行阶段优先从 RuleCompiledCache 取委托执行 当前执行流程(EvaluateRisk): 1. 从数据库加载规则列表和应用列表 2. 按应用状态、TriggerPoints 过滤有效应用 3. 按应用范围过滤交易是否命中(全局 / 账户 / 客户 / 标的类型 / 合约类型) 4. 根据应用配置中的 RuleIds 找到对应规则 Id 并关联规则 5. 优先从 RuleCompiledCache 读取已编译委托,未命中时兜底编译一次 6. 执行规则委托,按 ControlStrategy 聚合为 Blocked / NeedApproval / Warnings 7. 返回 RiskResult 与旧设计文档的差异: - 删除 RiskRuleApplication 模型,将策略/时点/范围合并到 RiskRule 中 - 删除 FormulaJson 实时解析,改为 Content → 表达式树编译 - 规则编译跟随规则生命周期(创建/加载时),判风控时零解析 【与早期方案的主要差异】 - 当前不是 Content 解析或表达式树主导,而是 RuleExpr 直编译主导 - RiskRule 与 RiskRuleApplication 当前仍是分离模型,没有合并 - 编译器文件已放入 RiskEngine/Compile 目录下 - 当前已引入 RuleCompileResult、RuleCompiledCache,用于校验结果与进程内缓存 - Rule2 这类“左值与右值都来自对象字段”的规则,当前通过 RuleExpr 直接表达 【当前代码结构】 风控引擎层:YLErpDAL/Modules/RiskEngine/ - RiskEngineService.cs:执行入口,负责规则筛选、应用过滤、委托执行、结果聚合 - RiskRule.cs:规则定义模型,含 CompiledScript 运行时字段 - RiskRuleApplication.cs:规则应用模型,承载策略、触发点和适用范围 - RiskContext.cs / RiskResult.cs:执行上下文与结果模型 编译相关:YLErpDAL/Modules/RiskEngine/Compile/ - RuleCompiler.cs:Roslyn 编译入口,提供 ValidateAndCompileFormula / ValidateAndCompileRule - RuleCompileResult.cs:编译结果模型 - RuleCompiledCache.cs:编译结果缓存 当前集成点: - QuotaMonitorService.cs:构造 RiskContext,并将 trade 对象放入 DataMap["trade"] 【当前进度】(截至 2026-06-24) ✅ 已完成: 1. 创建 RiskEngine 文件夹,与 RiskModule 平级,实现代码隔离 2. 核心模型: - RiskRule.cs:合并模型(公式 + 策略 + 时点 + 范围 + CompiledScript) - RiskContext.cs:风控上下文(TradeId, TriggerPoint, DataMap) - RiskResult.cs:风控结果(Passed, Blocked, NeedApproval, Warnings, TriggeredRules) - RiskFormula.cs:FormulaJson 反序列化模型(辅助) 3. RuleCompiler.cs: - ParseContent:解析 Content 字符串(支持 > < >= <= = != ≠) - ParseValue:解析数值(支持 万/亿/千/百分比) - BuildExpression:表达式树构建(反射取值 + decimal 比较) 4. RiskEngineService.EvaluateRisk: - 加载示例规则列表 - 筛选 TriggerPoint 匹配的规则 - 调用 rule.CompiledScript(context) 执行预编译委托 - 按 ControlStrategy 聚合结果(禁止/审批/提示) 5. 集成到现有流程: - QuotaMonitorService.QuotaCheck() 末尾已接入风控引擎 - 触发时点:BOOK_CONFIRM(簿记交易确认) 6. 编译验证通过 【待办事项 / TODO】 ⬜ 1. 接入真实规则来源 - 由数据库或前端提交替换当前 LoadRules / LoadApplications 的规则来源 ⬜ 2. 增加接口层 - 提供前端提交 RuleExpr 后的校验接口 - 提供规则保存 / 发布后预编译并写缓存的入口 ⬜ 3. 启动预热与缓存刷新 - 服务启动时批量加载有效规则并预编译 - 支持规则更新后的缓存刷新 / 删除 ⬜ 4. 完善执行期策略 - 当前保留“缓存未命中时兜底编译”逻辑 - 后续可收紧为“执行期只读缓存,未命中按发布失败处理” ⬜ 5. 补测试样例 - 数值比较、日期比较、维度过滤、非法公式、边界值 ⬜ 6. 完善 RuleExpr 配套能力 - 提供前端可用的表达式编辑、校验与错误提示 - 约束可用变量、类型转换和脚本安全边界 【注意事项】 1. 当前 RuleExpr 必须是 Roslyn 最终可执行的 C# bool 表达式,不是业务语义短句 2. DataMap 中的值由宿主业务代码准备,编译器与执行器本身不负责查库补数 3. 当前缓存的是 Func,这是规则判断函数,不是事件处理器 4. 业务可预期失败优先走结果返回,不要把高频校验失败都设计成异常 ================================================================================ */ namespace YLErp.Modules.RiskEngine { /// /// 风控引擎服务(第一版 — 骨架版,先跑通) /// public class RiskEngineService : YLBaseService { IYcLogger _logger = LogFactory.GetLogger("RiskEngineService"); public RiskEngineService(OptUserInfo userInfo) : base(userInfo) { } public RiskEngineService(YLBaseService baseService) : base(baseService) { } public RiskEngineService(OptUserInfo optUser, YLContext dbContext) : base(optUser, dbContext) { } /// /// 从内存缓存获取规则列表;缓存为空时兜底加载并填充缓存。 /// private List GetRules() { var rules = _cachedRules; if (rules != null) { return rules; } lock (_cacheLock) { if (_cachedRules != null) { return _cachedRules; } var loaded = LoadRulesFromDb(); _cachedRules = loaded; return loaded; } } /// /// 从内存缓存获取应用列表;缓存为空时兜底加载并填充缓存。 /// private List GetApplications() { var applications = _cachedApplications; if (applications != null) { return applications; } lock (_cacheLock) { if (_cachedApplications != null) { return _cachedApplications; } var loaded = LoadApplicationsFromDb(); _cachedApplications = loaded; return loaded; } } /// /// 执行风控检查(最终设计版:预编译委托 + 规则筛选 + 策略判定) /// /// 风控上下文 /// 触发时点,如 BOOK_CONFIRM /// 风控结果 public RiskResult EvaluateRisk(RiskContext context, string triggerPoint) { var result = new RiskResult(); try { _logger.Info($"[风控引擎] EvaluateRisk 开始 - TradeId: {context?.TradeId}, TriggerPoint: {triggerPoint}"); // ============================================================ // Step 1: 从内存缓存读取规则定义和规则应用(启动时已预热) // ============================================================ var rules = GetRules(); var applications = GetApplications(); _logger.Info($"[风控引擎] 加载规则数: {rules.Count}, 应用数: {applications.Count}"); // ============================================================ // Step 2: 先按 Application 过滤 Active 状态和 TriggerPoint 匹配的应用 // ============================================================ var activeApps = applications .Where(a => a.Status == RiskRuleStatus.Active) .ToList(); var apps = activeApps .Where(a => !string.IsNullOrEmpty(a.TriggerPoints)) .ToList(); var triggerMatchedApps = apps .Where(a => a.TriggerPoints.Split(',', StringSplitOptions.RemoveEmptyEntries) .Select(s => s.Trim()) .Contains(triggerPoint)) .ToList(); // 再按应用范围过滤。 // 统一走通用维度匹配: // 1. 全局命中时直接通过; // 2. 同一维度内多选按并集处理; // 3. 不同维度之间按交集处理; // 4. 某维度留空表示该维度不限制。 var trade = context?.DataMap != null && context.DataMap.ContainsKey("trade") ? context.DataMap["trade"] as YLErp.DBModels.trade : null; var matchedApplications = triggerMatchedApps .Where(a => IsApplicationMatched(a, trade)) .ToList(); _logger.Info($"[风控引擎] 匹配 TriggerPoint 的应用数: {matchedApplications.Count}"); // ============================================================ // Step 3: 遍历匹配的应用,通过 RuleIds 关联规则并执行预编译委托 // ============================================================ foreach (var application in matchedApplications) { // ------------------------------------------------------------ // 3.1 通过 RuleIds 关联规则定义 // ------------------------------------------------------------ var applicationRuleIds = ParseRuleIds(application.RuleIds); if (!applicationRuleIds.Any()) { _logger.Info($"[风控引擎] 规则未编译,执行编译 - RuleCode: {rule.RuleCode}"); RuleCompiler.Compile(rule); } // 执行预编译委托 bool triggered = false; try { triggered = rule.CompiledScript(context); _logger.Info($"[风控引擎] 规则执行 - RuleCode: {rule.RuleCode}, Triggered: {triggered}"); } catch (Exception ex) { _logger.Info($"[风控引擎] 规则执行异常 - RuleCode: {rule.RuleCode}, Error: {ex.Message}"); result.HasError = true; result.ErrorMessage += $"规则[{rule.RuleCode}]执行异常:{ex.Message};"; continue; } var applicationRules = rules .Where(r => applicationRuleIds.Contains(r.Id) && r.Id == 3) .ToList(); var missingRuleIds = applicationRuleIds .Where(ruleId => applicationRules.All(r => r.Id != ruleId)) .ToList(); foreach (var missingRuleId in missingRuleIds) { _logger.Info($"[风控引擎] 规则触发 - RuleCode: {rule.RuleCode}, Strategy: {rule.ControlStrategy}"); foreach (var rule in applicationRules) { var ruleId = rule.Id.ToString(); // ------------------------------------------------------------ // 3.2 优先从编译缓存读取规则委托 // ------------------------------------------------------------ if (!RuleCompiledCache.TryGet(ruleId, out var compiledScript)) { case 1: // 禁止 result.Blocked = true; result.Passed = false; result.TriggeredRules.Add(new TriggeredRuleInfo { RuleCode = rule.RuleCode, RuleName = rule.RuleName, ControlStrategy = "禁止", FormulaText = rule.FormulaText, Message = $"规则[{rule.RuleName}]触发:禁止" }); break; case 2: // 审批 result.NeedApproval = true; result.Passed = false; result.TriggeredRules.Add(new TriggeredRuleInfo { RuleCode = rule.RuleCode, RuleName = rule.RuleName, ControlStrategy = "审批", FormulaText = rule.FormulaText, Message = $"规则[{rule.RuleName}]触发:需审批" }); break; case 3: // 提示 result.Warnings.Add($"规则[{rule.RuleName}]触发:提示 - {rule.FormulaText}"); break; // ------------------------------------------------------------ // 3.3 执行预编译委托 // ------------------------------------------------------------ bool triggered = false; try { triggered = compiledScript(context); _logger.Info($"[风控引擎] 规则执行 - RuleId: {rule.Id}, Triggered: {triggered}"); } catch (Exception ex) { _logger.Info($"[风控引擎] 规则执行异常 - RuleId: {rule.Id}, Error: {ex.Message}"); continue; } // ------------------------------------------------------------ // 3.4 命中后按 Application 的控制策略聚合结果 // ------------------------------------------------------------ if (triggered) { _logger.Info($"[风控引擎] 规则触发 - RuleId: {rule.Id}, Strategy: {application.ControlStrategy}"); switch (application.ControlStrategy) { case RiskControlStrategy.Block: break; result.Blocked = true; result.Passed = false; result.TriggeredRules.Add(new TriggeredRuleInfo { RuleId = rule.Id.ToString(), RuleName = rule.RuleName, ControlStrategy = RiskControlStrategy.Block, RuleText = rule.RuleText, Message = $"规则[{rule.RuleName}]触发:禁止" }); break; case RiskControlStrategy.Approval: result.NeedApproval = true; result.Passed = false; result.TriggeredRules.Add(new TriggeredRuleInfo { RuleId = ruleId, RuleName = rule.RuleName, ControlStrategy = RiskControlStrategy.Approval, RuleText = rule.RuleText, Message = $"规则[{rule.RuleName}]触发:需审批" }); break; case RiskControlStrategy.ShowTip: result.ShowTip = true; result.TriggeredRules.Add(new TriggeredRuleInfo { RuleId = ruleId, RuleName = rule.RuleName, ControlStrategy = RiskControlStrategy.ShowTip, RuleText = rule.RuleText, Message = $"规则[{rule.RuleName}]触发:提示" }); break; default: _logger.Info($"[风控引擎] 未知策略类型 - ControlStrategy: {application.ControlStrategy}"); break; } } else { _logger.Info($"[风控引擎] 规则未触发 - RuleId: {rule.Id}, TriggerPoint: {triggerPoint}"); } } } // ============================================================ // Step 4: 聚合最终结果 // ============================================================ if (!result.Blocked && !result.NeedApproval) { result.Passed = true; } else { result.Passed = false; } _logger.Info($"[风控引擎] EvaluateRisk 完成 - TradeId: {context?.TradeId}, Passed: {result.Passed}, Blocked: {result.Blocked}, NeedApproval: {result.NeedApproval}, TriggeredRules: {result.TriggeredRules.Count}"); } catch (Exception ex) { result.NeedApproval = true; result.Passed = false; result.TriggeredRules.Add(new TriggeredRuleInfo { RuleId = "ENGINE_ERROR", RuleName = "风控引擎执行异常", ControlStrategy = "审批", FormulaText = ex.Message, Message = $"风控引擎异常:{ex.Message},需审批通过" }); _logger.Error($"[风控引擎] EvaluateRisk 异常 - TradeId: {context?.TradeId}, Error: {ex.Message}"); } return result; } /// /// 从数据库加载规则列表 /// private List LoadRulesFromDb() { // 示例规则:多条件逗号分隔(自动转为 AND) var rule = new RiskRule { Id = 103, RuleCode = "RISK-20260622-0003", RuleName = "测试规则:名义本金超过1亿且保证金比例超过50%禁止", FormulaText = "名义本金>1亿, 保证金比例>50%", // 逗号分隔,解析时自动转为 AND FormulaJson = "{ \"conditions\": [{\"variableCode\":\"trade.StockEqvNotional\",\"operator\":\">\",\"value\":100000000},{\"variableCode\":\"trade.MarginRate\",\"operator\":\">\",\"value\":0.5}] }", Status = 1, // Active ControlStrategy = 1, // 禁止 TriggerPoints = "BOOK_CONFIRM", ScopeIsGlobal = true, Version = 1 }; RuleCompiler.Compile(rule); /// /// 判断应用配置是否命中当前交易。 /// 匹配规则遵循设计文档: /// 1. 全局命中时直接返回 true; /// 2. 同一维度内多选按并集处理; /// 3. 不同维度之间按交集处理; /// 4. 某维度留空表示该维度不限制。 /// private bool IsApplicationMatched(RiskRuleApplication application, YLErp.DBModels.trade trade) { if (application == null) { return false; } if (application.ScopeIsGlobal) { return true; } if (trade == null) { return false; } var accountMatched = IsScopeEmpty(application.ScopeAssetBookIds) || IsValueMatched(application.ScopeAssetBookIds, GetTradeAssetBookId(trade)); var clientMatched = IsScopeEmpty(application.ScopeClientIds) || IsValueMatched(application.ScopeClientIds, trade.ClientId); var underlyingTypeMatched = IsScopeEmpty(application.ScopeUnderlyingTypes) || IsValueMatched(application.ScopeUnderlyingTypes, GetTradeUnderlyingType(trade)); var tradeTypeMatched = IsScopeEmpty(application.ScopeTradeTypes) || IsValueMatched(application.ScopeTradeTypes, trade.TradeType); return accountMatched && clientMatched && underlyingTypeMatched && tradeTypeMatched; } /// /// 解析应用配置中的规则ID列表。 /// 多个规则ID使用逗号分隔,返回去空格后的 long 集合。 /// private List ParseRuleIds(string ruleIds) { if (string.IsNullOrWhiteSpace(ruleIds)) { return new List(); } var ids = new List(); foreach (var part in ruleIds.Split(',', StringSplitOptions.RemoveEmptyEntries)) { if (long.TryParse(part.Trim(), out long id)) { ids.Add(id); } } return ids; } /// /// 判断某个范围字段是否为空。 /// 为空表示该维度不限制。 /// private bool IsScopeEmpty(string scopeValue) { return string.IsNullOrWhiteSpace(scopeValue); } /// /// 判断单个值是否命中逗号分隔的范围配置。 /// 同一维度内多选按并集处理,只要命中任一值即返回 true。 /// private bool IsValueMatched(string scopeValue, object currentValue) { if (string.IsNullOrWhiteSpace(scopeValue)) { return true; } if (currentValue == null) { return false; } var currentText = currentValue.ToString()?.Trim(); if (string.IsNullOrWhiteSpace(currentText)) { return false; } return scopeValue .Split(',', StringSplitOptions.RemoveEmptyEntries) .Select(s => s.Trim()) .Any(s => string.Equals(s, currentText, StringComparison.OrdinalIgnoreCase)); } /// /// 读取交易的资产簿账户ID。 /// 当前先按 BookId 取值;如果后续真实字段不是 BookId,再统一调整这里即可。 /// private object GetTradeAssetBookId(YLErp.DBModels.trade trade) { var property = trade.GetType().GetProperty("BookId"); return property?.GetValue(trade); } /// /// 读取交易的标的类型。 /// 当前先按 UnderlyingInstrumentType 取值;如果后续真实字段名不同,再统一调整这里即可。 /// private object GetTradeUnderlyingType(YLErp.DBModels.trade trade) { var property = trade.GetType().GetProperty("UnderlyingInstrumentType"); return property?.GetValue(trade); } } }