From 1fa7a8b79cb7ca1c632dc7a601d6cb5e534e100b Mon Sep 17 00:00:00 2001 From: ruisu Date: Wed, 24 Jun 2026 15:05:24 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B0=86Application=E4=B8=8E=E7=BC=96=E8=AF=91?= =?UTF-8?q?=E7=8B=AC=E7=AB=8B=EF=BC=8C=E8=B7=91=E9=80=9A=E5=9F=BA=E7=A1=80?= =?UTF-8?q?=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RiskEngine/Compile/RuleCompileResult.cs | 50 ++ .../RiskEngine/Compile/RuleCompiledCache.cs | 67 +++ .../RiskEngine/{ => Compile}/RuleCompiler.cs | 141 +++-- .../Modules/RiskEngine/RiskEngineService.cs | 480 ++++++++++++------ YLErpDAL/Modules/RiskEngine/RiskRule.cs | 37 +- .../Modules/RiskEngine/RiskRuleApplication.cs | 188 +++++++ .../Modules/RiskModule/QuotaMonitorService.cs | 7 +- 7 files changed, 728 insertions(+), 242 deletions(-) create mode 100644 YLErpDAL/Modules/RiskEngine/Compile/RuleCompileResult.cs create mode 100644 YLErpDAL/Modules/RiskEngine/Compile/RuleCompiledCache.cs rename YLErpDAL/Modules/RiskEngine/{ => Compile}/RuleCompiler.cs (63%) create mode 100644 YLErpDAL/Modules/RiskEngine/RiskRuleApplication.cs diff --git a/YLErpDAL/Modules/RiskEngine/Compile/RuleCompileResult.cs b/YLErpDAL/Modules/RiskEngine/Compile/RuleCompileResult.cs new file mode 100644 index 00000000..65a88901 --- /dev/null +++ b/YLErpDAL/Modules/RiskEngine/Compile/RuleCompileResult.cs @@ -0,0 +1,50 @@ +using System; + +namespace YLErp.Modules.RiskEngine +{ + /// + /// 规则编译结果。 + /// 用于承载公式校验、Roslyn 编译结果和错误信息。 + /// + public class RuleCompileResult + { + /// + /// 是否编译成功。 + /// + public bool Success { get; set; } + + /// + /// 编译失败时的错误信息。 + /// + public string ErrorMessage { get; set; } + + /// + /// 编译成功后的可执行委托。 + /// + public Func CompiledScript { get; set; } + + /// + /// 创建成功结果。 + /// + public static RuleCompileResult Ok(Func compiledScript) + { + return new RuleCompileResult + { + Success = true, + CompiledScript = compiledScript + }; + } + + /// + /// 创建失败结果。 + /// + public static RuleCompileResult Fail(string errorMessage) + { + return new RuleCompileResult + { + Success = false, + ErrorMessage = errorMessage + }; + } + } +} \ No newline at end of file diff --git a/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiledCache.cs b/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiledCache.cs new file mode 100644 index 00000000..59fe9099 --- /dev/null +++ b/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiledCache.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Concurrent; + +namespace YLErp.Modules.RiskEngine +{ + /// + /// 规则编译结果的进程内缓存。 + /// 当前第一版按 RuleCode 缓存 Roslyn 编译后的可执行委托。 + /// + public static class RuleCompiledCache + { + /// + /// 规则编译结果的进程内缓存。ConcurrentDictionary为线程安全的字典,用于缓存编译后的规则委托,性能略低。 + /// + private static readonly ConcurrentDictionary> Cache + = new ConcurrentDictionary>(StringComparer.OrdinalIgnoreCase); + + /// + /// 写入或更新规则编译结果。 + /// + public static void Set(string ruleCode, Func compiledScript) + { + if (string.IsNullOrWhiteSpace(ruleCode) || compiledScript == null) + { + return; + } + + Cache[ruleCode] = compiledScript; + } + + /// + /// 尝试获取规则编译结果。 + /// + public static bool TryGet(string ruleCode, out Func compiledScript) + { + compiledScript = null; + + if (string.IsNullOrWhiteSpace(ruleCode)) + { + return false; + } + + return Cache.TryGetValue(ruleCode, out compiledScript); + } + + /// + /// 删除指定规则的编译结果。 + /// + public static void Remove(string ruleCode) + { + if (string.IsNullOrWhiteSpace(ruleCode)) + { + return; + } + + Cache.TryRemove(ruleCode, out _); + } + + /// + /// 清空全部规则编译结果。 + /// + public static void Clear() + { + Cache.Clear(); + } + } +} \ No newline at end of file diff --git a/YLErpDAL/Modules/RiskEngine/RuleCompiler.cs b/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs similarity index 63% rename from YLErpDAL/Modules/RiskEngine/RuleCompiler.cs rename to YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs index ad21674d..b2e2a8ce 100644 --- a/YLErpDAL/Modules/RiskEngine/RuleCompiler.cs +++ b/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs @@ -42,6 +42,7 @@ namespace YLErp.Modules.RiskEngine /// 变量编码 → 类型全名的映射 /// 用于 Roslyn 脚本中的类型强转,如 ((YLErp.DBModels.trade)DataMap["trade"]) /// + static IYcLogger _logger =LogFactory.GetLogger("RuleCompiler"); private static readonly Dictionary VariableTypeMap = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["trade"] = "YLErp.DBModels.trade", @@ -62,30 +63,68 @@ namespace YLErp.Modules.RiskEngine /// public static void Compile(this RiskRule rule) { - if (string.IsNullOrWhiteSpace(rule.FormulaJson)) + var result = ValidateAndCompileRule(rule); + if (!result.Success) { - throw new ArgumentException("规则 FormulaJson 不能为空", nameof(rule.FormulaJson)); + throw new InvalidOperationException(result.ErrorMessage); } - - // 1. 解析 FormulaJson - var conditions = ParseFormulaJson(rule.FormulaJson); - - // 2. 生成 Roslyn C# 脚本代码 - string scriptCode = BuildScriptCode(conditions); - - // 3. 编译脚本(只编译一次,后续直接执行) - var compiled = CompileScript(scriptCode); - - // 4. 写入规则(不持久化,仅内存缓存) - rule.CompiledScript = compiled; } /// - /// 解析 FormulaJson,提取条件列表 + /// 校验并编译公式表达式。 + /// 当前 FormulaExp 要求是 Roslyn 可直接执行的 bool 表达式。 /// - private static List<(string variableCode, string op, object value, string variableType)> ParseFormulaJson(string formulaJson) + public static RuleCompileResult ValidateAndCompileFormula(string formulaExp) { - var result = new List<(string, string, object, string)>(); + if (string.IsNullOrWhiteSpace(formulaExp)) + { + return RuleCompileResult.Fail("规则 FormulaExp 不能为空"); + } + + try + { + var compiled = CompileScript(formulaExp); + return RuleCompileResult.Ok(compiled); + } + catch (Exception ex) + { + return RuleCompileResult.Fail(ex.Message); + } + } + + /// + /// 校验并编译规则,编译成功后同时写入规则对象和内存缓存。 + /// + public static RuleCompileResult ValidateAndCompileRule(RiskRule rule) + { + if (rule == null) + { + return RuleCompileResult.Fail("规则不能为空"); + } + + if (string.IsNullOrWhiteSpace(rule.RuleCode)) + { + return RuleCompileResult.Fail("规则 RuleCode 不能为空"); + } + + var result = ValidateAndCompileFormula(rule.FormulaExp); + if (!result.Success) + { + return RuleCompileResult.Fail($"规则[{rule.RuleCode}]编译失败:{result.ErrorMessage}"); + } + + rule.CompiledScript = result.CompiledScript; + RuleCompiledCache.Set(rule.RuleCode, result.CompiledScript); + return result; + } + + /// + /// 解析 FormulaJson,提取条件列表。 + /// 要求 expression 明确描述实际取值路径 + /// + private static List<(string variableCode, string expression, string op, object value, string variableType)> ParseFormulaJson(string formulaJson) + { + var result = new List<(string, string, string, object, string)>(); var jObj = JObject.Parse(formulaJson); var conditions = jObj["conditions"] as JArray; @@ -97,45 +136,35 @@ namespace YLErp.Modules.RiskEngine foreach (var cond in conditions) { string variableCode = cond["variableCode"]?.Value(); + string expression = cond["expression"]?.Value(); string op = cond["operator"]?.Value(); object value = cond["value"]?.Value(); string variableType = cond["variableType"]?.Value() ?? "numeric"; - if (string.IsNullOrWhiteSpace(variableCode) || string.IsNullOrWhiteSpace(op)) + if (string.IsNullOrWhiteSpace(expression) || string.IsNullOrWhiteSpace(op)) { - throw new ArgumentException("条件中缺少 variableCode 或 operator"); + throw new ArgumentException("条件中缺少 expression 或 operator"); } - result.Add((variableCode, op, value, variableType)); + result.Add((variableCode, expression, op, value, variableType)); } return result; } /// - /// 根据条件列表生成 Roslyn C# 脚本代码 - /// 多条件自动用 &&(AND)连接 + /// 根据条件列表生成 Roslyn C# 脚本代码。 + /// 多条件自动用 &&(AND)连接。 + /// 左值表达式统一基于 expression 构建。 /// - private static string BuildScriptCode(List<(string variableCode, string op, object value, string variableType)> conditions) + private static string BuildScriptCode(List<(string variableCode, string expression, string op, object value, string variableType)> conditions) { var exprParts = new List(); - foreach (var (variableCode, op, value, variableType) in conditions) + foreach (var (_, expression, op, value, variableType) in conditions) { - // 解析 variableCode:前缀.属性名 - var parts = variableCode.Split('.'); - if (parts.Length != 2) - { - throw new ArgumentException($"variableCode 格式不正确:{variableCode}"); - } - - string prefix = parts[0].Trim().ToLower(); - string propertyName = parts[1].Trim(); - - if (!VariableTypeMap.TryGetValue(prefix, out string typeFullName)) - { - throw new ArgumentException($"未知前缀:{prefix}"); - } + // 先根据 expression 构建左值访问表达式,再拼接比较符和右值字面量 + string leftExpr = BuildMemberAccessExpression(expression, variableType); // 格式化阈值 string valueLiteral = FormatValueLiteral(value, variableType); @@ -144,8 +173,7 @@ namespace YLErp.Modules.RiskEngine string csharpOp = FormatOperator(op); // 生成单条件表达式 - // Convert.ToDecimal(((YLErp.DBModels.trade)DataMap["trade"]).StockEqvNotional) > 100000000m - string expr = $"Convert.ToDecimal((({typeFullName})DataMap[\"{prefix}\"]).{propertyName}) {csharpOp} {valueLiteral}"; + string expr = $"{leftExpr} {csharpOp} {valueLiteral}"; exprParts.Add(expr); } @@ -153,6 +181,37 @@ namespace YLErp.Modules.RiskEngine return string.Join(" && ", exprParts); } + /// + /// 将前端传入的 expression 转成 Roslyn 可执行的成员访问表达式。 + /// 例如 trade.StockEqvNotional 会转成 ((YLErp.DBModels.trade)DataMap["trade"]).StockEqvNotional。 + /// 数值类型会自动包一层 Convert.ToDecimal,便于与 decimal 阈值比较。 + /// + private static string BuildMemberAccessExpression(string expression, string variableType) + { + var parts = expression.Split('.'); + if (parts.Length < 2) + { + throw new ArgumentException($"expression 格式不正确:{expression}"); + } + + string prefix = parts[0].Trim().ToLower(); + if (!VariableTypeMap.TryGetValue(prefix, out string typeFullName)) + { + throw new ArgumentException($"未知前缀:{prefix}"); + } + + // 除前缀外,其余部分都视为成员访问路径,便于后续扩展多级属性访问 + string memberAccess = string.Join(".", parts.Skip(1).Select(p => p.Trim())); + string objectExpr = $"(({typeFullName})DataMap[\"{prefix}\"]).{memberAccess}"; + + if (variableType == "numeric" || variableType == "number") + { + return $"Convert.ToDecimal({objectExpr})"; + } + + return objectExpr; + } + /// /// 用 Roslyn 编译 C# 脚本代码为可执行委托 /// @@ -193,7 +252,7 @@ namespace YLErp.Modules.RiskEngine catch (Exception ex) { // 脚本执行异常(如空引用、类型转换失败)视为规则不触发 - // TODO: 记录日志 + _logger.Error($"规则执行异常:{ex.Message}\n脚本代码:{scriptCode}"); return false; } }; diff --git a/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs b/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs index 4106052b..154a6a73 100644 --- a/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs +++ b/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs @@ -6,154 +6,105 @@ using YLErp.Model; /* ================================================================================ - 风控引擎服务 — RiskEngineService 技术方案与进度说明 + 风控引擎服务 — RiskEngineService 技术方案与当前实现说明 ================================================================================ 【项目背景】 - 当前 TRS 系统的限额监控模块(QuotaMonitorService)采用"固定指标 + 上下限阈值" - 的硬编码模式,存在规则维度单一、无法自由组合、不支持公式化配置、缺少三级控制 - 策略(禁止/审批/提示)等局限。 + 当前 TRS 系统已在 QuotaMonitorService 中接入第一版风控引擎,用于在交易关键 + 时点执行可配置规则判断。当前目标不是一次性做成最终版,而是在最小可运行链路 + 跑通后,逐步演进为可由前端配置、后端预编译、执行期直接命中的正式版本。 - 本模块旨在将现有限额监控升级为完整的风控引擎,实现规则的灵活配置、公式化执行、 - 策略判定与结果输出。 - -【最终设计】(以本注释为准,与设计文档不同的部分以本注释为准) +【当前实现口径】(以本注释和实际代码为准) 核心概念: - - RiskRule(风控规则):单条规则包含完整的公式定义 + 校验策略 + 触发时点 + 适用范围。 - 不需要 "一个 Rule 对应多个 Application" 的复杂关系。 - - Content:用户输入的规则内容字符串,如"名义本金>1亿" - - 规则编译:在规则定义时(创建/加载/缓存刷新时)调用 RuleCompiler.Compile(rule), - 将 Content 解析并编译为 Func 委托,写入 rule.CompiledScript。 - 判风控时直接执行委托,零解析开销。 - - RiskContext:一次风控检查的数据上下文(DataMap 机制) - - RiskResult:风控检查结果(Passed / Blocked / NeedApproval / Warnings / TriggeredRules) + - RiskRule(风控规则):规则定义本体,当前重点字段包括 RuleCode、RuleName、 + FormulaText、FormulaExp、Version、CompiledScript。 + - RiskRuleApplication(规则应用):与规则分离,承载启用状态、控制策略、触发时点、 + 适用范围(全局 / 账户 / 客户 / 标的类型 / 合约类型)。 + - FormulaExp:当前唯一主编译入口。要求内容是 Roslyn 可直接执行的 C# bool 表达式, + 例如直接访问 DataMap["trade"] 后做数值或日期比较。 + - FormulaJson:当前已在规则定义和样例代码中停用,不参与主执行链路。 + - RiskContext:一次风控检查的数据上下文,包含 TradeId、TriggerPoint 和 DataMap。 + - RuleCompiledCache:进程内编译结果缓存,按 RuleCode 缓存 Func。 - 编译流程: - 1. 解析 Content:提取变量名、条件符、阈值 - 2. 变量名映射:用户输入的"名义本金" → "trade.StockEqvNotional" - 3. 构建表达式树:Func = ctx => ... - 4. 编译为委托:rule.CompiledScript = lambda.Compile() - 5. 判风控时直接调用:rule.CompiledScript(context) → bool + 当前编译流程: + 1. 外部准备好规则对象,FormulaExp 中直接写最终可执行表达式 + 2. 调用 RuleCompiler.ValidateAndCompileRule(rule) + 3. 内部使用 Roslyn 编译 FormulaExp,生成 Func + 4. 编译成功后写入 rule.CompiledScript,并同步写入 RuleCompiledCache + 5. 执行阶段优先从 RuleCompiledCache 取委托执行 - 执行流程(EvaluateRisk): - 1. 从缓存/数据库加载 Active 状态的 RiskRule 列表 - 2. 筛选 TriggerPoints 包含当前 triggerPoint 的规则 - 3. 筛选 Scope 匹配当前交易上下文的规则(全局/账户/对手方/标的类型/合约类型) - 4. 对每条规则:执行 rule.CompiledScript(context) 判断是否触发 - 5. 按 ControlStrategy(禁止/审批/提示)聚合结果 - 6. 返回 RiskResult + 当前执行流程(EvaluateRisk): + 1. 加载示例规则列表和示例应用列表 + 2. 按应用状态、TriggerPoints 过滤有效应用 + 3. 按应用范围过滤交易是否命中(全局 / 账户 / 客户 / 标的类型 / 合约类型) + 4. 根据命中的 RuleCode 找到对应规则 + 5. 优先从 RuleCompiledCache 读取已编译委托,未命中时兜底编译一次 + 6. 执行规则委托,按 ControlStrategy 聚合为 Blocked / NeedApproval / Warnings + 7. 返回 RiskResult - 与旧设计文档的差异: - - 删除 RiskRuleApplication 模型,将策略/时点/范围合并到 RiskRule 中 - - 删除 FormulaJson 实时解析,改为 Content → 表达式树编译 - - 规则编译跟随规则生命周期(创建/加载时),判风控时零解析 + 当前维度匹配规则: + - 全局:ScopeIsGlobal=true 时直接命中 + - 同一维度内多选:并集(OR) + - 不同维度之间:交集(AND) + - 某维度留空:视为该维度不限制 -【架构设计】 - ┌─────────────────────────────────────────────────────────────────┐ - │ API 层(RiskRuleController / RiskEngineController) │ - │ - 规则 CRUD API、风控执行 API(EvaluateRisk / Trial) │ - └──────────────────────────┬──────────────────────────────────────┘ - │ - ┌──────────────────────────▼──────────────────────────────────────┐ - │ 风控引擎层(YLErpDAL/Modules/RiskEngine/) ← 本文件夹所在层 │ - │ │ - │ RiskEngineService(本文件) │ - │ - 内存缓存管理(规则列表) │ - │ - EvaluateRisk:筛选规则 → 执行预编译委托 → 聚合结果 │ - │ │ - │ RuleCompiler.cs │ - │ - ParseContent:解析 Content 字符串 → 变量 + 条件符 + 阈值 │ - │ - BuildExpression:构建表达式树(反射取值 + decimal 比较) │ - │ - Compile(RiskRule):编译为 Func 委托 │ - │ │ - │ RiskRule.cs │ - │ - 合并模型:公式定义 + 校验策略 + 触发时点 + 适用范围 │ - │ - CompiledScript:预编译的可执行委托(运行时内存字段) │ - │ │ - │ RiskContext.cs / RiskResult.cs / RiskFormula.cs │ - │ - 数据上下文 / 结果模型 / 公式模型(辅助) │ - └──────────────────────────┬──────────────────────────────────────┘ - │ - ┌──────────────────────────▼──────────────────────────────────────┐ - │ 数据访问层 │ - │ - RiskRuleRepository(规则表) │ - │ - 复用现有 DAL(Trade / Client / SwapPosition / Credit 等) │ - └─────────────────────────────────────────────────────────────────┘ +【与早期方案的主要差异】 + - 当前不是 Content 解析或表达式树主导,而是 FormulaExp 直编译主导 + - RiskRule 与 RiskRuleApplication 当前仍是分离模型,没有合并 + - 编译器文件已放入 RiskEngine/Compile 目录下 + - 当前已引入 RuleCompileResult、RuleCompiledCache,用于校验结果与进程内缓存 + - Rule2 这类“左值与右值都来自对象字段”的规则,当前通过 FormulaExp 直接表达 -【当前进度】(截至 2026-06-22) +【当前代码结构】 + 风控引擎层: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. 编译验证通过 + 1. RiskEngine 第一版执行链路已跑通:QuotaMonitorService -> RiskEngineService -> RuleCompiler + 2. 风控上下文通过 DataMap 传入 trade 对象,支持规则脚本直接访问交易字段 + 3. FormulaExp 直编译方案已接入,支持数值比较和日期比较 + 4. Application 通用维度匹配已支持:全局 / 账户 / 客户 / 标的类型 / 合约类型 + 5. 维度组合逻辑已按文档确认:同维度 OR,不同维度 AND + 6. RuleCompiler 已支持校验 + 编译 + 写缓存 + 7. RuleCompiledCache / RuleCompileResult 已落地到 Compile 目录 + 8. RiskEngineService 已改为执行时优先从 RuleCompiledCache 读取委托 -【待办事项 / TODO】(按优先级排序) - ⬜ 1. 内存缓存机制 - - _cachedRules: Dictionary<(int id, int version), RiskRule> - - RefreshCache():从数据库加载 Active 规则 + 逐个 Compile() - - 线程安全(lock / Interlocked.Exchange) - ⬜ 2. 数据库表创建(SQL) - - glms_risk_rule(合并模型:公式 + 策略 + 时点 + 范围) - ⬜ 3. 规则筛选逻辑 - - 按 Scope(全局/账户/对手方/标的类型/合约类型)过滤适用范围 - - 去重 - ⬜ 4. 更多变量映射(VariableNameMap) - - 行情类:market.LastClosePrice, market.FR007 等 - - 系统计算类:calc.CounterpartyTotalNotional, calc.CreditUsageRate 等 - - 布尔判断类:calc.IsLongDirection, calc.IsMarginPay 等 - ⬜ 5. 多条件支持(AND / OR) - - Content 解析:支持 "名义本金>1亿 且 保证金比例>50%" - - 表达式树构建:多个条件合并为单一委托 - ⬜ 6. 更多集成点 - - 平仓审核提交(CLOSE_REVIEW) - - 上传确认书(UPLOAD_CONFIRMATION) - - 事件发生时(EVENT_TRIGGER) - ⬜ 7. 前端"确认交易"弹窗展示优化(当前混在"风险预警"栏目中) - - 方案:QuotaTrial 模型新增 RiskEngineDetails 字段,前端独立渲染"风控检查"栏目 - ⬜ 8. 单元测试 - ⬜ 9. 前端规则配置 SPA(独立前端仓库) - ⬜ 10. 【暂不实现】考虑迁移到 CSScriptLib - - 原因:当前已采用 Roslyn(Microsoft.CodeAnalysis.CSharp.Scripting),无需额外包 - - 灵活性:Roslyn 已足够支持多条件 AND/OR、复杂表达式,CSScriptLib 并无明显优势 - - 优先级:低,当前版本暂不引入 - -【关键文件清单】 - 本模块: - - RiskEngineService.cs(本文件):引擎核心服务,EvaluateRisk 入口 - - RuleCompiler.cs:规则编译器,Content → 表达式树 → 委托 - - RiskRule.cs:合并后的规则模型(含 CompiledScript) - - RiskContext.cs / RiskResult.cs / RiskFormula.cs - - 现有集成点: - - QuotaMonitorService.cs(RiskModule 下):QuotaCheck() 末尾已接入 - - 设计文档: - - X:\尹峰\onederiv\风控引擎需求详细设计.md(旧设计,仅供参考) - -【命名空间】 - YLErp.Modules.RiskEngine(与 RiskModule 平级) +【待办事项 / TODO】 + ⬜ 1. 接入真实规则来源 + - 由数据库或前端提交替换当前 LoadRules / LoadApplications 示例数据 + ⬜ 2. 增加接口层 + - 提供前端提交 FormulaExp 后的校验接口 + - 提供规则保存 / 发布后预编译并写缓存的入口 + ⬜ 3. 启动预热与缓存刷新 + - 服务启动时批量加载有效规则并预编译 + - 支持规则更新后的缓存刷新 / 删除 + ⬜ 4. 完善执行期策略 + - 当前保留“缓存未命中时兜底编译”逻辑 + - 后续可收紧为“执行期只读缓存,未命中按发布失败处理” + ⬜ 5. 补测试样例 + - 数值比较、日期比较、维度过滤、非法公式、边界值 + ⬜ 6. 完善 FormulaExp 配套能力 + - 提供前端可用的表达式编辑、校验与错误提示 + - 约束可用变量、类型转换和脚本安全边界 【注意事项】 - 1. 规则编译跟随规则生命周期(创建/加载时),判风控时零解析开销 - 2. 数值统一使用 decimal 类型,避免浮点精度问题 - 3. 反射取值使用 BindingFlags.IgnoreCase,兼容大小写不敏感的属性名 - 4. 异常不阻断原有流程,只记录日志并返回 HasError=true + 1. 当前 FormulaExp 必须是 Roslyn 最终可执行的 C# bool 表达式,不是业务语义短句 + 2. DataMap 中的值由宿主业务代码准备,编译器与执行器本身不负责查库补数 + 3. 当前缓存的是 Func,这是规则判断函数,不是事件处理器 + 4. 业务可预期失败优先走结果返回,不要把高频校验失败都设计成异常 ================================================================================ */ @@ -193,41 +144,86 @@ namespace YLErp.Modules.RiskEngine _logger.Info($"[风控引擎] EvaluateRisk 开始 - TradeId: {context?.TradeId}, TriggerPoint: {triggerPoint}"); // ============================================================ - // Step 1: 加载规则列表(TODO: 后续接入真实内存缓存) + // Step 1: 加载规则定义和规则应用(TODO: 后续接入真实内存缓存) // ============================================================ var rules = LoadRules(); - _logger.Info($"[风控引擎] 加载规则数: {rules.Count}"); + var applications = LoadApplications(); + _logger.Info($"[风控引擎] 加载规则数: {rules.Count}, 应用数: {applications.Count}"); // ============================================================ - // Step 2: 筛选匹配当前 triggerPoint 且状态为 Active 的规则 + // Step 2: 先按 Application 过滤 Active 状态和 TriggerPoint 匹配的应用 // ============================================================ - var matchedRules = rules - .Where(r => r.Status == 1) // Active - .Where(r => !string.IsNullOrEmpty(r.TriggerPoints)) - .Where(r => r.TriggerPoints.Split(',', StringSplitOptions.RemoveEmptyEntries) - .Select(s => s.Trim()) - .Contains(triggerPoint)) + var activeApps = applications + .Where(a => a.Status == RiskRuleApplicationStatusEnum.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(); - _logger.Info($"[风控引擎] 匹配 TriggerPoint 的规则数: {matchedRules.Count}"); + // 再按应用范围过滤。 + // 统一走通用维度匹配: + // 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: 遍历匹配的规则,执行预编译委托 + // Step 3: 遍历匹配的应用,通过 RuleCode 关联规则并执行预编译委托 // ============================================================ - foreach (var rule in matchedRules) + foreach (var application in matchedApplications) { - // 确保规则已编译 - if (rule.CompiledScript == null) + // ------------------------------------------------------------ + // 3.1 通过 RuleCode 关联规则定义 + // ------------------------------------------------------------ + var rule = rules.FirstOrDefault(r => r.RuleCode == application.RuleCode); + if (rule == null) { - _logger.Info($"[风控引擎] 规则未编译,执行编译 - RuleCode: {rule.RuleCode}"); - RuleCompiler.Compile(rule); + _logger.Info($"[风控引擎] 未找到对应规则定义 - RuleCode: {application.RuleCode}"); + result.HasError = true; + result.ErrorMessage += $"未找到规则定义[{application.RuleCode}];"; + continue; } - // 执行预编译委托 + // ------------------------------------------------------------ + // 3.2 优先从编译缓存读取规则委托 + // ------------------------------------------------------------ + if (!RuleCompiledCache.TryGet(rule.RuleCode, out var compiledScript)) + { + _logger.Info($"[风控引擎] 缓存中未命中已编译规则,执行兜底编译 - RuleCode: {rule.RuleCode}"); + + var compileResult = RuleCompiler.ValidateAndCompileRule(rule); + if (!compileResult.Success) + { + _logger.Info($"[风控引擎] 规则编译失败 - RuleCode: {rule.RuleCode}, Error: {compileResult.ErrorMessage}"); + result.HasError = true; + result.ErrorMessage += $"规则[{rule.RuleCode}]编译失败:{compileResult.ErrorMessage};"; + continue; + } + + compiledScript = compileResult.CompiledScript; + } + + // ------------------------------------------------------------ + // 3.3 执行预编译委托 + // ------------------------------------------------------------ bool triggered = false; try { - triggered = rule.CompiledScript(context); + triggered = compiledScript(context); _logger.Info($"[风控引擎] 规则执行 - RuleCode: {rule.RuleCode}, Triggered: {triggered}"); } catch (Exception ex) @@ -238,14 +234,16 @@ namespace YLErp.Modules.RiskEngine continue; } + // ------------------------------------------------------------ + // 3.4 命中后按 Application 的控制策略聚合结果 + // ------------------------------------------------------------ if (triggered) { - _logger.Info($"[风控引擎] 规则触发 - RuleCode: {rule.RuleCode}, Strategy: {rule.ControlStrategy}"); + _logger.Info($"[风控引擎] 规则触发 - RuleCode: {rule.RuleCode}, Strategy: {application.ControlStrategy}"); - // 按 ControlStrategy 生成结果 - switch (rule.ControlStrategy) + switch (application.ControlStrategy) { - case 1: // 禁止 + case RiskControlStrategyEnum.Block: result.Blocked = true; result.Passed = false; result.TriggeredRules.Add(new TriggeredRuleInfo @@ -258,7 +256,7 @@ namespace YLErp.Modules.RiskEngine }); break; - case 2: // 审批 + case RiskControlStrategyEnum.Approval: result.NeedApproval = true; result.Passed = false; result.TriggeredRules.Add(new TriggeredRuleInfo @@ -271,15 +269,19 @@ namespace YLErp.Modules.RiskEngine }); break; - case 3: // 提示 + case RiskControlStrategyEnum.Warning: result.Warnings.Add($"规则[{rule.RuleName}]触发:提示 - {rule.FormulaText}"); break; default: - _logger.Info($"[风控引擎] 未知策略类型 - ControlStrategy: {rule.ControlStrategy}"); + _logger.Info($"[风控引擎] 未知策略类型 - ControlStrategy: {application.ControlStrategy}"); break; } } + else + { + _logger.Info($"[风控引擎] 规则未触发 - RuleCode: {rule.RuleCode}, TriggerPoint: {triggerPoint}"); + } } // ============================================================ @@ -316,23 +318,163 @@ namespace YLErp.Modules.RiskEngine /// private List LoadRules() { - // 示例规则:多条件逗号分隔(自动转为 AND) - var rule = new RiskRule + //名义本金:Convert.ToDecimal(((YLErp.DBModels.trade)DataMap[\"trade\"]).StockEqvNotional) + //挂钩标的到期日:((YLErp.DBModels.trade)DataMap[\"trade\"]).MaturityDate + //合约到期日:((YLErp.DBModels.trade)DataMap[\"trade\"]).ExerciseDate + + // 示例规则1:先保留一个最小可运行样例,只验证名义本金阈值判断 + var Rule1 = 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, + RuleName = "测试规则:名义本金超过1亿", + FormulaText = "名义本金>1亿", + // FormulaJson = "{ \"conditions\": [" + + // "{\"variableCode\":\"StockEqvNotional\",\"expression\":\"trade.StockEqvNotional\",\"operator\":\">\",\"value\":100000000,\"variableType\":\"number\"}" + + // "] }", + FormulaExp = "Convert.ToDecimal(((YLErp.DBModels.trade)DataMap[\"trade\"]).StockEqvNotional) > 100000000m", Version = 1 }; - RuleCompiler.Compile(rule); - return new List { rule }; + // 示例规则2:挂钩标的到期日小于合约到期日 + var Rule2 = new RiskRule + { + Id = 104, + RuleCode = "RISK-20260623-0004", + RuleName = "挂钩标的到期日小于合约到期日", + FormulaText = "挂钩标的到期日<合约到期日", + //FormulaJson = "{ \"conditions\": [{\"variableCode\":\"MaturityDate\",\"expression\":\"trade.MaturityDate\",\"operator\":\"<\",\"value\":\"trade.ExerciseDate\",\"variableType\":\"date\"}] }", + FormulaExp = "((YLErp.DBModels.trade)DataMap[\"trade\"]).MaturityDate < ((YLErp.DBModels.trade)DataMap[\"trade\"]).ExerciseDate", + Version = 1 + }; + + // 当前样例规则在加载阶段预编译并写入缓存,便于执行阶段直接取用 + RuleCompiler.Compile(Rule1); + RuleCompiler.Compile(Rule2); + + return new List { Rule1, Rule2 }; + } + /// + /// 加载application + /// 当前返回示例规则用于验证流程 + /// + private List LoadApplications() + { + var application1 = new RiskRuleApplication + { + Id = 1, + RuleCode = "RISK-20260622-0003", + Status = RiskRuleApplicationStatusEnum.Active, + ControlStrategy = RiskControlStrategyEnum.Block, + TriggerPoints = "BOOK_CONFIRM", + ScopeIsGlobal = true, + }; + + var application2 = new RiskRuleApplication + { + Id = 2, + RuleCode = "RISK-20260623-0004", + Status = RiskRuleApplicationStatusEnum.Active, + ControlStrategy = RiskControlStrategyEnum.Block, + TriggerPoints = "BOOK_CLICK,BOOK_CONFIRM", + ScopeIsGlobal = false, + ScopeTradeTypes = "收益互换", + }; + + return new List { application1, application2 }; + } + + /// + /// 判断应用配置是否命中当前交易。 + /// 匹配规则遵循设计文档: + /// 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; + } + + /// + /// 判断某个范围字段是否为空。 + /// 为空表示该维度不限制。 + /// + 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); } } } diff --git a/YLErpDAL/Modules/RiskEngine/RiskRule.cs b/YLErpDAL/Modules/RiskEngine/RiskRule.cs index 54c736f6..359b4fdd 100644 --- a/YLErpDAL/Modules/RiskEngine/RiskRule.cs +++ b/YLErpDAL/Modules/RiskEngine/RiskRule.cs @@ -3,12 +3,9 @@ using System; namespace YLErp.Modules.RiskEngine { /// - /// 风控规则(合并模型:规则定义 + 应用配置合一) - /// - /// 最终设计说明: - /// 业务上不需要 "一个 Rule 对应多个 Application" 的复杂关系。 - /// 每条规则自身包含:公式定义 + 校验策略 + 触发时点 + 适用范围。 - /// 规则创建时即完成脚本编译,判风控时直接执行已编译委托(零解析开销)。 + /// 风控规则定义。 + /// 只描述“规则是什么”,不包含状态、策略、触发时点、适用范围等应用配置。 + /// 这些执行维度由 RiskRuleApplication 承载。 /// public class RiskRule { @@ -29,35 +26,13 @@ namespace YLErp.Modules.RiskEngine /// {"conditions":[{"variableCode":"trade.StockEqvNotional","operator":">","value":100000000}]} /// 由 RuleCompiler 解析并编译为 CompiledScript /// - public string FormulaJson { get; set; } - - // === 校验策略(原 Application.ControlStrategy)=== + // public string FormulaJson { get; set; } /// - /// 校验策略:1=禁止, 2=审批, 3=提示 + /// 【核心】具体的计算公式 /// - public int ControlStrategy { get; set; } - - // === 触发时点(原 Application.TriggerPoints)=== - /// - /// 触发时点,逗号分隔(如 BOOK_CONFIRM,CLOSE_REVIEW) - /// - public string TriggerPoints { get; set; } - - // === 适用范围(原 Application.Scope*)=== - /// - /// 是否全局适用(true=所有交易适用,忽略其他 Scope 字段) - /// - public bool ScopeIsGlobal { get; set; } - public string ScopeAssetBookIds { get; set; } - public string ScopeClientIds { get; set; } - public string ScopeUnderlyingTypes { get; set; } - public string ScopeTradeTypes { get; set; } + public string FormulaExp { get; set; } // === 生命周期 === - /// - /// 状态:1=已生效(Active), 2=已停用(Disabled), 3=已删除(Deleted) - /// - public int Status { get; set; } public int Version { get; set; } public bool IsDeleted { get; set; } diff --git a/YLErpDAL/Modules/RiskEngine/RiskRuleApplication.cs b/YLErpDAL/Modules/RiskEngine/RiskRuleApplication.cs new file mode 100644 index 00000000..2a554d1e --- /dev/null +++ b/YLErpDAL/Modules/RiskEngine/RiskRuleApplication.cs @@ -0,0 +1,188 @@ +using System; + +namespace YLErp.Modules.RiskEngine +{ + /// + /// 风控规则应用配置。 + /// + /// 设计原因: + /// 依据设计文档,风控规则需要区分“规则定义”和“规则应用”两个层面。 + /// RiskRule 负责定义“规则是什么”,例如规则名称、规则编码、公式表达式、描述、版本等; + /// RiskRuleApplication 负责定义“规则在什么场景下如何执行”,例如启用状态、校验策略、触发时点、适用范围。 + /// + /// 拆分后的好处是: + /// 1. 同一条规则可以被多个应用配置复用,避免重复维护相同的公式定义。 + /// 2. 不同业务场景可以对同一条规则配置不同的执行策略,例如在簿记确认时禁止,在平仓审核时提示。 + /// 3. 调整触发时点、适用范围、控制策略时,不需要修改规则本身,降低配置耦合。 + /// 4. 后续如果要做规则管理页面、规则启停、按场景灰度投放,也更容易扩展。 + /// + /// 当前文件先定义第一版最小模型,用于支撑基础流程跑通和示例数据加载。 + /// + /// + /// 规则应用状态枚举。 + /// 用于描述一条规则应用配置当前是否参与风控执行。 + /// + public enum RiskRuleApplicationStatusEnum + { + /// + /// 已生效。 + /// 当前应用配置可正常参与风控判断。 + /// + Active = 1, + + /// + /// 已停用。 + /// 当前应用配置暂不参与风控判断,但配置仍被保留。 + /// + Disabled = 2, + + /// + /// 已删除。 + /// 当前应用配置逻辑上已删除,通常不再参与任何执行。 + /// + Deleted = 3 + } + + /// + /// 风控控制策略枚举。 + /// 用于描述规则命中后的处理方式。 + /// + public enum RiskControlStrategyEnum + { + /// + /// 禁止。 + /// 规则命中后直接拦截,不允许继续后续流程。 + /// + Block = 1, + + /// + /// 审批。 + /// 规则命中后不直接放行,需要进入审批流程。 + /// + Approval = 2, + + /// + /// 提示。 + /// 规则命中后只给出风险提示,不阻断流程。 + /// + Warning = 3 + } + public class RiskRuleApplication + { + /// + /// 应用配置主键ID。 + /// 用于唯一标识一条规则应用记录。 + /// + public int Id { get; set; } + + /// + /// 关联的规则编码。 + /// 通过该字段与 RiskRule.RuleCode 关联,表示当前应用配置对应哪一条规则定义。 + /// 第一版先用字符串编码关联,后续如有需要也可以扩展为 RuleId。 + /// + public string RuleCode { get; set; } + + /// + /// 应用状态。 + /// 约定: + /// 1 = Active(已生效) + /// 2 = Disabled(已停用) + /// 3 = Deleted(已删除) + /// 在实际执行时,通常只加载 Active 状态的应用配置。 + /// + public RiskRuleApplicationStatusEnum Status { get; set; } + + /// + /// 校验策略。 + /// 约定: + /// 1 = 禁止 + /// 2 = 审批 + /// 3 = 提示 + /// 当规则命中时,由该字段决定最终处理方式。 + /// + public RiskControlStrategyEnum ControlStrategy { get; set; } + + /// + /// 触发时点。 + /// 多个时点使用逗号分隔,例如: + /// BOOK_CONFIRM,CLOSE_REVIEW + /// 表示该应用配置会在这些业务时点参与风控判断。 + /// + public string TriggerPoints { get; set; } + + /// + /// 是否全局适用。 + /// true 表示对所有交易都适用,此时通常忽略其他 Scope 字段; + /// false 表示只在指定范围内适用,需要结合下方各 Scope 字段进行过滤。 + /// + public bool ScopeIsGlobal { get; set; } + + /// + /// 适用的资产簿记账户范围。 + /// 第一版使用逗号分隔的字符串保存,例如: + /// 123,456,789 + /// 后续执行时可按约定解析为账户ID集合。 + /// + public string ScopeAssetBookIds { get; set; } + + /// + /// 适用的客户范围。 + /// 第一版使用逗号分隔的字符串保存客户ID,例如: + /// 10001,10002 + /// 用于在执行前筛选该规则是否适用于当前客户。 + /// + public string ScopeClientIds { get; set; } + + /// + /// 适用的标的类型范围。 + /// 例如: + /// 股票,ETF,债券 + /// 用于控制规则仅对特定标的类型生效。 + /// + public string ScopeUnderlyingTypes { get; set; } + + /// + /// 适用的交易类型范围。 + /// 例如: + /// 簿记交易,平仓交易 + /// 用于控制规则仅在指定交易类型下生效。 + /// + public string ScopeTradeTypes { get; set; } + + /// + /// 创建人ID。 + /// 记录是谁创建了这条应用配置。 + /// + public int OptId { get; set; } + + /// + /// 创建人名称。 + /// 便于界面展示和审计追踪。 + /// + public string OptName { get; set; } + + /// + /// 创建时间。 + /// 记录应用配置的创建时刻。 + /// + public DateTime OptDate { get; set; } + + /// + /// 最后更新人ID。 + /// 记录最近一次修改该应用配置的操作人。 + /// + public int UpdateOptId { get; set; } + + /// + /// 最后更新人名称。 + /// 便于界面展示和审计追踪。 + /// + public string UpdateOptName { get; set; } + + /// + /// 最后更新时间。 + /// 记录最近一次修改该应用配置的时间。 + /// + public DateTime UpdateDate { get; set; } + } +} \ No newline at end of file diff --git a/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs b/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs index 385f2299..fa5132ab 100644 --- a/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs +++ b/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs @@ -4724,7 +4724,7 @@ namespace YLErp.Modules.RiskModule { _logger.Info($"[限额试算] 开始执行 - tradeId: {tradeId}, trialSource: {trialSource}"); var result = new QuotaTrial(); - var tradeObj = DbContext.trade.AsNoTracking().Where(O => O.id == tradeId).FirstOrDefault(); + trade tradeObj = DbContext.trade.AsNoTracking().Where(O => O.id == tradeId).FirstOrDefault(); // 检查交易对象是否存在 if (tradeObj == null) @@ -4939,6 +4939,11 @@ namespace YLErp.Modules.RiskModule TriggerPoint = "BOOK_CONFIRM" }; // 构造 DataMap(第一版只塞 trade 对象,后续按需扩展) + // 当前先用 demo 值验证“名义本金超过1亿”的规则是否能命中 + tradeObj.StockEqvNotional = 1000; + tradeObj.TradeType = "收益互换"; + tradeObj.MaturityDate = new DateTime(2026, 6, 19); + tradeObj.ExerciseDate = new DateTime(2026, 6, 21); riskContext.DataMap["trade"] = tradeObj; var riskResult = riskEngine.EvaluateRisk(riskContext, "BOOK_CONFIRM");