diff --git a/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs b/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs deleted file mode 100644 index 55115803..00000000 --- a/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs +++ /dev/null @@ -1,297 +0,0 @@ -using Microsoft.CodeAnalysis.CSharp.Scripting; -using Microsoft.CodeAnalysis.Scripting; -using Newtonsoft.Json.Linq; -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; - -namespace YLErp.Modules.RiskEngine -{ - /// - /// Roslyn 脚本全局变量容器 - /// CSharpScript 执行时通过此对象传递 DataMap - /// - public class ScriptGlobals - { - /// - /// 数据字典:key=表名/前缀,value=对应数据对象 - /// - public Dictionary DataMap { get; set; } - } - - /// - /// 规则编译器(Roslyn 版) - /// - /// 编译时机:规则定义时(零运行时解析开销) - /// 执行方式:判风控时直接调用 rule.CompiledScript(context) → bool - /// - /// 相比表达式树方案的优势: - /// - 代码简洁:生成 C# 字符串即可,无需手动拼接 Expression 节点 - /// - 灵活性高:天然支持多条件 AND/OR、复杂运算、未来可扩展任意 C# 语法 - /// - 可调试:生成的脚本代码可直接阅读和理解 - /// - /// 示例: - /// FormulaJson: { "conditions": [{"variableCode":"trade.StockEqvNotional","operator":">","value":100000000}] } - /// 生成脚本:Convert.ToDecimal(((YLErp.DBModels.trade)DataMap["trade"]).StockEqvNotional) > 100000000m - /// 编译结果:Func<RiskContext, bool>(内部通过 Roslyn 编译缓存) - /// - public static class RuleCompiler - { - /// - /// 变量编码 → 类型全名的映射 - /// 用于 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", - ["swap_position"] = "YLErp.DBModels.swap_position", - ["client"] = "YLErp.DBModels.client", - ["credit"] = "YLErp.DBModels.credit", - ["client_marginrate"] = "YLErp.DBModels.client_marginrate", - ["market"] = "YLErp.DBModels.market", - ["calc"] = "YLErp.DBModels.calc", - ["sys"] = "YLErp.DBModels.sys", - ["eod_swap_position"] = "YLErp.DBModels.eod_swap_position", - ["realtime_trade_risk"] = "YLErp.DBModels.realtime_trade_risk", - }; - - /// - /// 将规则编译为可执行委托,并写入 rule.CompiledScript - /// 调用时机:规则创建时 / 规则加载时 / 缓存刷新时 - /// - public static void Compile(this RiskRule rule) - { - if (string.IsNullOrWhiteSpace(rule.FormulaJson)) - { - throw new ArgumentException("规则 FormulaJson 不能为空", nameof(rule.FormulaJson)); - } - - // 1. 解析 FormulaJson - var conditions = ParseFormulaJson(rule.FormulaJson); - - // 2. 生成 Roslyn C# 脚本代码 - string scriptCode = BuildScriptCode(conditions); - - // 3. 编译脚本(只编译一次,后续直接执行) - var compiled = CompileScript(scriptCode); - - // 4. 写入规则(不持久化,仅内存缓存) - rule.CompiledScript = compiled; - } - - /// - /// 解析 FormulaJson,提取条件列表 - /// - private static List<(string variableCode, string op, object value, string variableType)> ParseFormulaJson(string formulaJson) - { - var result = new List<(string, string, object, string)>(); - var jObj = JObject.Parse(formulaJson); - var conditions = jObj["conditions"] as JArray; - - if (conditions == null || !conditions.Any()) - { - throw new ArgumentException("FormulaJson 中缺少 conditions"); - } - - try - { - string variableCode = cond["variableCode"]?.Value(); - string op = cond["operator"]?.Value(); - object value = cond["value"]?.Value(); - string variableType = cond["variableType"]?.Value() ?? "numeric"; - - if (string.IsNullOrWhiteSpace(variableCode) || string.IsNullOrWhiteSpace(op)) - { - throw new ArgumentException("条件中缺少 variableCode 或 operator"); - } - - result.Add((variableCode, op, value, variableType)); - } - - var result = ValidateAndCompileFormula(rule.RuleExpr); - if (!result.Success) - { - return RuleCompileResult.Fail($"规则[{rule.Id}]编译失败:{result.ErrorMessage}"); - } - - rule.CompiledScript = result.CompiledScript; - RuleCompiledCache.Set(rule.Id.ToString(), result.CompiledScript); - return result; - } - - /// - /// 根据条件列表生成 Roslyn C# 脚本代码 - /// 多条件自动用 &&(AND)连接 - /// - private static string BuildScriptCode(List<(string variableCode, string op, object value, string variableType)> conditions) - { - var exprParts = new List(); - - foreach (var (variableCode, 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}"); - } - - // 格式化阈值 - string valueLiteral = FormatValueLiteral(value, variableType); - - // 格式化操作符 - string csharpOp = FormatOperator(op); - - // 生成单条件表达式 - string expr = $"{leftExpr} {csharpOp} {valueLiteral}"; - exprParts.Add(expr); - } - - // 多条件用 && 连接 - 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# 脚本代码为可执行委托 - /// - private static Func CompileScript(string scriptCode) - { - // 配置编译选项:引用必要的程序集 - var options = ScriptOptions.Default - .WithReferences( - typeof(RiskContext).Assembly, // YLErpDAL - typeof(YLErp.DBModels.trade).Assembly // Model 所在程序集 - ) - .WithImports("System"); - - // 创建脚本(尚未执行,仅编译) - var script = CSharpScript.Create(scriptCode, options, globalsType: typeof(ScriptGlobals)); - - // 编译(提前发现语法错误) - //后续编译需提供接口,返回前端编译信息,包含编译错误列表 - var compilation = script.GetCompilation(); - var diagnostics = compilation.GetDiagnostics(); - var errors = diagnostics.Where(d => d.Severity == Microsoft.CodeAnalysis.DiagnosticSeverity.Error).ToList(); - if (errors.Any()) - { - string errorMsg = string.Join("; ", errors.Select(e => e.GetMessage())); - _logger.Error($"规则编译失败:{errorMsg}\n脚本代码:{scriptCode}"); - throw new InvalidOperationException($"脚本编译失败:{errorMsg}"); - } - - // 生成可调用委托 - var runner = script.CreateDelegate(); - - // 包装为同步的 Func - return ctx => - { - var globals = new ScriptGlobals { DataMap = ctx.DataMap }; - try - { - return runner(globals).GetAwaiter().GetResult(); - } - catch (Exception ex) - { - // 脚本执行异常(如空引用、类型转换失败)视为规则不触发 - _logger.Error($"规则执行异常:{ex.Message}\n脚本代码:{scriptCode}"); - return false; - } - }; - } - - /// - /// 格式化阈值为 C# 字面量 - /// - private static string FormatValueLiteral(object value, string variableType) - { - if (value == null) return "null"; - - string strValue = value.ToString(); - - // 数值类型统一加 m 后缀(decimal) - if (variableType == "numeric" || variableType == "number") - { - if (decimal.TryParse(strValue, NumberStyles.Any, CultureInfo.InvariantCulture, out decimal d)) - { - return d.ToString(CultureInfo.InvariantCulture) + "m"; - } - } - - // 布尔 - if (variableType == "boolean" || variableType == "bool") - { - if (bool.TryParse(strValue, out bool b)) - { - return b ? "true" : "false"; - } - } - - // 日期 - if (variableType == "date" && DateTime.TryParse(strValue, out DateTime dt)) - { - return $"DateTime.Parse(\"{dt:yyyy-MM-dd}\")"; - } - - // 字符串(兜底) - return $"\"{strValue.Replace("\\", "\\\\").Replace("\"", "\\\"")}\""; - } - - /// - /// 将操作符统一为 C# 操作符 - /// - private static string FormatOperator(string op) - { - return op?.Trim() switch - { - "=" or "==" => "==", - "!=" or "≠" => "!=", - ">" => ">", - "<" => "<", - ">=" => ">=", - "<=" => "<=", - _ => throw new ArgumentException($"不支持的操作符:{op}") - }; - } - } -} diff --git a/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs b/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs index 433684ee..c165f109 100644 --- a/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs +++ b/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs @@ -1,6 +1,4 @@ -using Newtonsoft.Json; using Qdp.Foundation.Utilities; -using System.Reflection; using YLErp.BLL; using YLErp.Model; @@ -14,45 +12,50 @@ using YLErp.Model; 时点执行可配置规则判断。当前目标不是一次性做成最终版,而是在最小可运行链路 跑通后,逐步演进为可由前端配置、后端预编译、执行期直接命中的正式版本。 -【当前实现口径】(以本注释和实际代码为准) + 本模块旨在将现有限额监控升级为完整的风控引擎,实现规则的灵活配置、公式化执行、 + 策略判定与结果输出。 + +【设计文档】 + 详细设计文档路径: + X:\尹峰\onederiv\风控引擎需求详细设计.md 核心概念: - - RiskRule(风控规则):单条规则包含完整的公式定义 + 校验策略 + 触发时点 + 适用范围。 - 不需要 "一个 Rule 对应多个 Application" 的复杂关系。 - - Content:用户输入的规则内容字符串,如"名义本金>1亿" - - 规则编译:在规则定义时(创建/加载/缓存刷新时)调用 RuleCompiler.Compile(rule), - 将 Content 解析并编译为 Func 委托,写入 rule.CompiledScript。 - 判风控时直接执行委托,零解析开销。 + - Rule(规则):定义"什么情况下触发",由变量 + 操作符 + 阈值组成的条件列表 + - Application(应用):定义"何时、对谁、怎么处理",关联规则 + 策略/时点/范围 + - Variable(变量):变量池中的可选项,用于构建规则条件 - RiskContext:一次风控检查的数据上下文(DataMap 机制) - - RiskResult:风控检查结果(Passed / Blocked / NeedApproval / Warnings / TriggeredRules) + - RiskResult:风控检查结果(Passed / Blocked / NeedApproval / Warnings) - 当前编译流程: - 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 直接表达 +【架构设计】 + ┌─────────────────────────────────────────────────────────────────┐ + │ API 层(RiskRuleController / RiskEngineController) │ + │ - 规则 CRUD API、应用配置 CRUD API、变量池 CRUD API │ + │ - 风控执行 API(EvaluateRisk / Trial) │ + └──────────────────────────┬──────────────────────────────────────┘ + │ + ┌──────────────────────────▼──────────────────────────────────────┐ + │ 风控引擎层(YLErpDAL/Modules/RiskEngine/) ← 本文件夹所在层 │ + │ │ + │ RiskEngineService(本文件) │ + │ - 内存缓存管理(规则 + 应用 + 变量 + 编译后脚本) │ + │ - 构造 RiskContext → 解析 FormulaJson → 执行条件判断 → 输出结果 │ + │ │ + │ RiskVariableProvider(待实现) │ + │ - 接收已编译委托 + RiskContext → 执行计算 → 返回变量值 │ + │ │ + │ RiskRuleService(待实现) │ + │ - 规则 / 应用 / 变量 / 日志的 CRUD │ + │ - CRUD 后触发 RiskEngineService.RefreshCache() │ + │ │ + │ RiskAuditLogService(待实现) │ + │ - 操作日志写入 / 查询 │ + └──────────────────────────┬──────────────────────────────────────┘ + │ + ┌──────────────────────────▼──────────────────────────────────────┐ + │ 数据访问层 │ + │ - RiskRuleRepository(规则 + 应用 + 变量 + 日志) │ + │ - 复用现有 DAL(Trade / Client / SwapPosition / Credit 等) │ + └─────────────────────────────────────────────────────────────────┘ 【当前代码结构】 风控引擎层:YLErpDAL/Modules/RiskEngine/ @@ -72,48 +75,92 @@ using YLErp.Model; 【当前进度】(截至 2026-06-24) ✅ 已完成: 1. 创建 RiskEngine 文件夹,与 RiskModule 平级,实现代码隔离 - 2. 核心模型: - - RiskRule.cs:合并模型(公式 + 策略 + 时点 + 范围 + CompiledScript) + 2. 创建核心模型: - 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() 末尾已接入风控引擎 + 3. RiskEngineService 骨架: + - 继承 YLBaseService,保留三个构造函数 + - Evaluate():1==1 占位方法,验证服务可实例化 + - EvaluateRisk(RiskContext, triggerPoint):按设计文档预演的入口方法 + 4. 集成到现有流程: + - QuotaMonitorService.QuotaCheck() 末尾已插入风控引擎调用 - 触发时点:BOOK_CONFIRM(簿记交易确认) - 6. 编译验证通过 + - 构造 RiskContext,传入 trade 对象到 DataMap + - 按策略(Blocked/NeedApproval/Warnings)映射回 QuotaTrialStatusEnum + 5. 编译验证通过(YLErpDAL.csproj 0 error, 80 warnings 为旧代码) -【待办事项 / TODO】 - ⬜ 1. 接入真实规则来源 - - 由数据库或前端提交替换当前 LoadRules / LoadApplications 的规则来源 - ⬜ 2. 增加接口层 - - 提供前端提交 RuleExpr 后的校验接口 - - 提供规则保存 / 发布后预编译并写缓存的入口 - ⬜ 3. 启动预热与缓存刷新 - - 服务启动时批量加载有效规则并预编译 - - 支持规则更新后的缓存刷新 / 删除 - ⬜ 4. 完善执行期策略 - - 当前保留“缓存未命中时兜底编译”逻辑 - - 后续可收紧为“执行期只读缓存,未命中按发布失败处理” - ⬜ 5. 补测试样例 - - 数值比较、日期比较、维度过滤、非法公式、边界值 - ⬜ 6. 完善 RuleExpr 配套能力 - - 提供前端可用的表达式编辑、校验与错误提示 - - 约束可用变量、类型转换和脚本安全边界 +【待办事项 / TODO】(按优先级排序) + ⬜ 1. 内存缓存机制 + - 添加 _cachedRules / _cachedApplications / _cachedVariables / _compiledScripts + - 实现 RefreshCache() 方法,从数据库加载 Active 规则/应用/变量 + - CRUD 操作后即时刷新(由 RiskRuleService 调用) + ⬜ 2. 数据库表创建(SQL) + - glms_risk_rule(规则定义表) + - glms_risk_rule_application(规则应用表) + - glms_risk_variable(变量池定义表) + - glms_risk_rule_audit_log(操作日志表) + ⬜ 3. 规则筛选逻辑 + - 按 TriggerPoint 过滤应用配置 + - 按 Scope(全局/账户/对手方/标的类型/合约类型)过滤适用范围 + - 去重(同一规则可能被多个应用引用) + ⬜ 4. 公式解析与执行 + - 解析 FormulaJson(条件列表,AND 关系) + - 数值型 / 日期型 / 布尔型 的条件判断 + - 变量取值:通过 RiskVariableProvider 执行已编译委托 + ⬜ 5. RiskVariableProvider 实现 + - 接收已编译委托 + RiskContext,执行计算并返回变量值 + - 异常捕获与详细错误信息(如"变量 XXX 取值失败:空引用") + ⬜ 6. RiskContext 数据加载(按需加载) + - trade 对象(必须) + - swap_position / client / credit / market / calc / client_marginrate(按需) + - 参考 QuotaMonitorService 中现有查询模式(AsNoTracking, DataCacheProvider) + ⬜ 7. 脚本编译引擎 + - 将变量 ImplementationScript 编译为可执行委托(Expression.Lambda) + - 支持安全表达式求值,杜绝注入风险 + - 按 (id, version) 缓存编译结果 + ⬜ 8. 更多集成点 + - 平仓审核提交(CLOSE_REVIEW):SwapTrade2Controller / unwindSwapTrade.js + - 上传确认书(UPLOAD_CONFIRMATION):ConfirmationGenerateService + - 事件发生时(EVENT_TRIGGER):二期实现 + ⬜ 9. 单元测试 + - 变量计算器取值逻辑 + - 公式解析与执行 + - 策略判定逻辑 + - 完整风控检查流程(API → 结果返回) + ⬜ 10. 前端规则配置 SPA(独立前端仓库) + - 路由:/risk-config(单页面多 Tab:规则管理 / 规则应用 / 变量池 / 操作日志) + ⬜ 11. 前端"确认交易"弹窗展示优化(当前混在"风险预警"栏目中,待独立) + - 问题:风控引擎返回的 Blocked/NeedApproval/Warnings 目前通过 RiskWarningDetails + 映射到前端 quotaTrial.cshtml,统一显示在"风险预警"栏目(橙色表头) + - 方案A(最小改动):前端判断 RiskWarningDetails 是否包含"[风控引擎]"标签, + 单独渲染"风控检查"栏目(如紫色表头),与原有"风险预警"并列展示 + - 方案B(干净做法):QuotaTrial 模型新增 RiskEngineDetails 字段,后端往独立字段写, + 前端新增独立渲染逻辑,彻底分离"风险预警"(旧限额)和"风控检查"(新引擎) + - 相关文件: + → QuotaMonitorService.cs(RiskWarningDetails 赋值逻辑) + → quotaTrial.cshtml(前端渲染,检查类别列"风险预警"写死在前端 HTML) + +【关键文件清单】 + 本模块: + - RiskEngineService.cs(本文件):引擎核心服务 + - RiskContext.cs:风控上下文 + - RiskResult.cs:风控结果 + + 现有集成点: + - QuotaMonitorService.cs(RiskModule 下):QuotaCheck() 末尾已接入 + + 设计文档: + - X:\尹峰\onederiv\风控引擎需求详细设计.md + +【命名空间】 + YLErp.Modules.RiskEngine(与 RiskModule 平级) 【注意事项】 - 1. 当前 RuleExpr 必须是 Roslyn 最终可执行的 C# bool 表达式,不是业务语义短句 - 2. DataMap 中的值由宿主业务代码准备,编译器与执行器本身不负责查库补数 - 3. 当前缓存的是 Func,这是规则判断函数,不是事件处理器 - 4. 业务可预期失败优先走结果返回,不要把高频校验失败都设计成异常 + 1. 本期不对接现有审批模块,规则创建后直接可用(Active) + 2. 旧 QuotaMonitorService 功能保持不变,新旧引擎独立运行 + 3. 变量新增仅需在变量池管理页面添加记录,无需修改引擎核心代码 + 4. 所有查询使用 AsNoTracking() 避免 EF 变更追踪开销 + 5. 数值统一使用 decimal 类型,避免浮点精度问题 ================================================================================ */ @@ -138,55 +185,16 @@ namespace YLErp.Modules.RiskEngine } /// - /// 从内存缓存获取规则列表;缓存为空时兜底加载并填充缓存。 + /// 执行风控检查(第一版:一句简单的 1==1,先跑通) /// - private List GetRules() + public bool Evaluate() { - var rules = _cachedRules; - if (rules != null) - { - return rules; - } - - lock (_cacheLock) - { - if (_cachedRules != null) - { - return _cachedRules; - } - - var loaded = LoadRulesFromDb(); - _cachedRules = loaded; - return loaded; - } + // 第一版占位:脚本 1==1,永远返回 true,验证服务能正常实例化和调用 + return 1 == 1; } /// - /// 从内存缓存获取应用列表;缓存为空时兜底加载并填充缓存。 - /// - 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 @@ -197,369 +205,22 @@ namespace YLErp.Modules.RiskEngine try { - _logger.Info($"[风控引擎] EvaluateRisk 开始 - TradeId: {context?.TradeId}, TriggerPoint: {triggerPoint}"); + // 调试模式:模拟触发审批(NeedApproval),验证前端界面展示 + // TODO: 后续接入真实规则缓存和公式执行逻辑后删除此行 + result.NeedApproval = true; + result.Passed = false; + result.Warnings.Add("【风控引擎】规则触发:需审批 - 模拟审批触发(调试模式)"); - // ============================================================ - // 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}"); + _logger.Info($"[风控引擎] EvaluateRisk 被调用 - TradeId: {context?.TradeId}, TriggerPoint: {triggerPoint}, NeedApproval: {result.NeedApproval}"); } 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},需审批通过" - }); + result.HasError = true; + result.ErrorMessage = $"风控引擎执行异常: {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); - } } } diff --git a/YLErpDAL/Modules/RiskEngine/RiskRule.cs b/YLErpDAL/Modules/RiskEngine/RiskRule.cs deleted file mode 100644 index 86b9b09b..00000000 --- a/YLErpDAL/Modules/RiskEngine/RiskRule.cs +++ /dev/null @@ -1,51 +0,0 @@ -using System; - -namespace YLErp.Modules.RiskEngine -{ - /// - /// 风控规则定义。 - /// 只描述“规则是什么”,不包含状态、策略、触发时点、适用范围等应用配置。 - /// 这些执行维度由 RiskRuleApplication 承载。 - /// - public class RiskRule - { - // === 基础信息 === - public int Id { get; set; } - public string RuleCode { get; set; } - public string RuleName { get; set; } - public string Description { get; set; } - - /// - /// 【核心】用户输入的规则内容字符串,如"名义本金>1亿" - /// 在规则创建/加载时由 RuleCompiler 解析并编译为 CompiledScript - /// - public string FormulaText { get; set; } - - /// - /// 【核心】公式 JSON(条件列表的结构化存储),如 - /// {"conditions":[{"variableCode":"trade.StockEqvNotional","operator":">","value":100000000}]} - /// 由 RuleCompiler 解析并编译为 CompiledScript - /// - public string FormulaJson { get; set; } - - // === 生命周期 === - public int Version { get; set; } - public bool IsDeleted { get; set; } - - // === 审计字段 === - public int OptId { get; set; } - public string OptName { get; set; } - public DateTime OptDate { get; set; } - public int UpdateOptId { get; set; } - public string UpdateOptName { get; set; } - public DateTime UpdateDate { get; set; } - - // === 运行时内存字段(不持久化到数据库)=== - /// - /// 【核心】预编译的可执行委托 - /// 在规则创建/加载时由 RuleCompiler.Compile(this) 生成 - /// 判风控时直接调用:rule.CompiledScript(context) → bool - /// - public Func CompiledScript { get; set; } - } -} diff --git a/YLErpDAL/YLErpDAL.csproj b/YLErpDAL/YLErpDAL.csproj index e9d409ac..83cf8334 100644 --- a/YLErpDAL/YLErpDAL.csproj +++ b/YLErpDAL/YLErpDAL.csproj @@ -30,8 +30,6 @@ - - @@ -42,7 +40,7 @@ - +