Files
zszq-trs/YLErpDAL/Modules/RiskEngine/RuleCompiler.cs
T
陈斐andClaude 3e357312c2 feat: 风控引擎核心重构 - 表达式树编译 + Roslyn Scripting + 规则模型合并
本次提交实现风控引擎从占位代码到可运行版本的核心升级:

【模型合并】
- RiskRule.cs:删除 RiskRuleApplication,将策略/时点/范围合并到 RiskRule
- 新增 FormulaJson 字段,用于 Roslyn 脚本解析
- 新增 CompiledScript 运行时字段(预编译委托,不持久化)

【规则编译器 - RuleCompiler.cs】
- ParseFormulaJson:解析 FormulaJson 条件列表
- BuildScriptCode:生成 Roslyn C# 脚本代码
- CompileScript:用 Roslyn 编译为 Func<RiskContext, bool> 委托
- 支持多条件 AND(逗号分隔自动拆分)
- 变量类型映射:YLErp.DBModels.trade 等

【EvaluateRisk 升级】
- LoadRules → 筛选 TriggerPoint → 执行 CompiledScript → 按策略聚合
- 异常处理:返回 NeedApproval(审批类,可审批通过)
- 示例规则:名义本金>1亿 且 保证金比例>50% → 禁止

【项目依赖】
- 新增 Microsoft.CodeAnalysis.CSharp (4.12.0)
- 升级 System.Text.Encoding.CodePages 6.0.0 → 7.0.0

【验证】
- 簿记交易确认(BOOK_CONFIRM)已接入 QuotaMonitorService
- 编译通过,运行验证通过

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-22 19:31:20 +08:00

257 lines
9.9 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
{
/// <summary>
/// Roslyn 脚本全局变量容器
/// CSharpScript 执行时通过此对象传递 DataMap
/// </summary>
public class ScriptGlobals
{
/// <summary>
/// 数据字典:key=表名/前缀,value=对应数据对象
/// </summary>
public Dictionary<string, object> DataMap { get; set; }
}
/// <summary>
/// 规则编译器(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&lt;RiskContext, bool&gt;(内部通过 Roslyn 编译缓存)
/// </summary>
public static class RuleCompiler
{
/// <summary>
/// 变量编码 → 类型全名的映射
/// 用于 Roslyn 脚本中的类型强转,如 ((YLErp.DBModels.trade)DataMap["trade"])
/// </summary>
private static readonly Dictionary<string, string> VariableTypeMap = new Dictionary<string, string>(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",
};
/// <summary>
/// 将规则编译为可执行委托,并写入 rule.CompiledScript
/// 调用时机:规则创建时 / 规则加载时 / 缓存刷新时
/// </summary>
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;
}
/// <summary>
/// 解析 FormulaJson,提取条件列表
/// </summary>
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");
}
foreach (var cond in conditions)
{
string variableCode = cond["variableCode"]?.Value<string>();
string op = cond["operator"]?.Value<string>();
object value = cond["value"]?.Value<object>();
string variableType = cond["variableType"]?.Value<string>() ?? "numeric";
if (string.IsNullOrWhiteSpace(variableCode) || string.IsNullOrWhiteSpace(op))
{
throw new ArgumentException("条件中缺少 variableCode 或 operator");
}
result.Add((variableCode, op, value, variableType));
}
return result;
}
/// <summary>
/// 根据条件列表生成 Roslyn C# 脚本代码
/// 多条件自动用 &&AND)连接
/// </summary>
private static string BuildScriptCode(List<(string variableCode, string op, object value, string variableType)> conditions)
{
var exprParts = new List<string>();
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);
// 生成单条件表达式
// Convert.ToDecimal(((YLErp.DBModels.trade)DataMap["trade"]).StockEqvNotional) > 100000000m
string expr = $"Convert.ToDecimal((({typeFullName})DataMap[\"{prefix}\"]).{propertyName}) {csharpOp} {valueLiteral}";
exprParts.Add(expr);
}
// 多条件用 && 连接
return string.Join(" && ", exprParts);
}
/// <summary>
/// 用 Roslyn 编译 C# 脚本代码为可执行委托
/// </summary>
private static Func<RiskContext, bool> CompileScript(string scriptCode)
{
// 配置编译选项:引用必要的程序集
var options = ScriptOptions.Default
.WithReferences(
typeof(RiskContext).Assembly, // YLErpDAL
typeof(YLErp.DBModels.trade).Assembly // Model 所在程序集
)
.WithImports("System");
// 创建脚本(尚未执行,仅编译)
var script = CSharpScript.Create<bool>(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()));
throw new InvalidOperationException($"脚本编译失败:{errorMsg}\n脚本代码:{scriptCode}");
}
// 生成可调用委托
var runner = script.CreateDelegate();
// 包装为同步的 Func<RiskContext, bool>
return ctx =>
{
var globals = new ScriptGlobals { DataMap = ctx.DataMap };
try
{
return runner(globals).GetAwaiter().GetResult();
}
catch (Exception ex)
{
// 脚本执行异常(如空引用、类型转换失败)视为规则不触发
// TODO: 记录日志
return false;
}
};
}
/// <summary>
/// 格式化阈值为 C# 字面量
/// </summary>
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("\"", "\\\"")}\"";
}
/// <summary>
/// 将操作符统一为 C# 操作符
/// </summary>
private static string FormatOperator(string op)
{
return op?.Trim() switch
{
"=" or "==" => "==",
"!=" or "≠" => "!=",
">" => ">",
"<" => "<",
">=" => ">=",
"<=" => "<=",
_ => throw new ArgumentException($"不支持的操作符:{op}")
};
}
}
}