- ScopeIsGlobal 类型由 bool 改为 int 以兼容前端传输 0/1 - 修复 QueryApplicationList 中 Status 投影 EF Core enum 转换失败 - 新增 Scope 字段查询支持(逗号分隔集合精确匹配) - 新增变量列表 DataType 筛选 - 修复审计日志日期查询范围(EndDate 纳入整天数据) - 统一 Application 审计日志 OperationType 前缀(APP_xxx) - 审计日志 OperationDetail 枚举值映射为中文
325 lines
13 KiB
C#
325 lines
13 KiB
C#
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# 语法
|
||
/// - 可调试:生成的脚本代码可直接阅读和理解
|
||
///
|
||
/// 示例:
|
||
/// RuleExpr: Convert.ToDecimal(((YLErp.DBModels.trade)DataMap["trade"]).StockEqvNotional) > 100000000m
|
||
/// 编译结果:Func<RiskContext, bool>(内部通过 Roslyn 编译缓存)
|
||
/// </summary>
|
||
public static class RuleCompiler
|
||
{
|
||
/// <summary>
|
||
/// 变量编码 → 类型全名的映射
|
||
/// 用于 Roslyn 脚本中的类型强转,如 ((YLErp.DBModels.trade)DataMap["trade"])
|
||
/// </summary>
|
||
static IYcLogger _logger =LogFactory.GetLogger("RuleCompiler");
|
||
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)
|
||
{
|
||
var result = ValidateAndCompileRule(rule);
|
||
if (!result.Success)
|
||
{
|
||
throw new InvalidOperationException(result.ErrorMessage);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 校验并编译规则表达式。
|
||
/// 当前 RuleExpr 要求是 Roslyn 可直接执行的 bool 表达式。
|
||
/// </summary>
|
||
public static RuleCompileResult ValidateAndCompileFormula(string formulaExp)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(formulaExp))
|
||
{
|
||
return RuleCompileResult.Fail("规则 RuleExpr 不能为空");
|
||
}
|
||
|
||
try
|
||
{
|
||
var compiled = CompileScript(formulaExp);
|
||
return RuleCompileResult.Ok(compiled);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
return RuleCompileResult.Fail(ex.Message);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 校验并编译规则,编译成功后同时写入规则对象和内存缓存。
|
||
/// </summary>
|
||
public static RuleCompileResult ValidateAndCompileRule(RiskRule rule)
|
||
{
|
||
if (rule == null)
|
||
{
|
||
return RuleCompileResult.Fail("规则不能为空");
|
||
}
|
||
|
||
if (rule.Id <= 0)
|
||
{
|
||
return RuleCompileResult.Fail("规则 Id 不能为空");
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 解析 ConditionJson,提取条件列表。
|
||
/// 要求 expression 明确描述实际取值路径。
|
||
/// 当前主执行链不再使用该方法,仅作为早期方案保留。
|
||
/// </summary>
|
||
// private static List<(string variableCode, string expression, string op, object value, string variableType)> ParseConditionJson(string conditionJson)
|
||
// {
|
||
// var result = new List<(string, string, string, object, string)>();
|
||
// var jObj = JObject.Parse(formulaJson);
|
||
// var conditions = jObj["conditions"] as JArray;
|
||
|
||
// if (conditions == null || !conditions.Any())
|
||
// {
|
||
// throw new ArgumentException("ConditionJson 中缺少 conditions");
|
||
// }
|
||
|
||
// foreach (var cond in conditions)
|
||
// {
|
||
// string variableCode = cond["variableCode"]?.Value<string>();
|
||
// string expression = cond["expression"]?.Value<string>();
|
||
// string op = cond["operator"]?.Value<string>();
|
||
// object value = cond["value"]?.Value<object>();
|
||
// string variableType = cond["variableType"]?.Value<string>() ?? "numeric";
|
||
|
||
// if (string.IsNullOrWhiteSpace(expression) || string.IsNullOrWhiteSpace(op))
|
||
// {
|
||
// throw new ArgumentException("条件中缺少 expression 或 operator");
|
||
// }
|
||
|
||
// result.Add((variableCode, expression, op, value, variableType));
|
||
// }
|
||
|
||
// return result;
|
||
// }
|
||
|
||
/// <summary>
|
||
/// 根据条件列表生成 Roslyn C# 脚本代码。
|
||
/// 多条件自动用 &&(AND)连接。
|
||
/// 左值表达式统一基于 expression 构建。
|
||
/// </summary>
|
||
private static string BuildScriptCode(List<(string variableCode, string expression, string op, object value, string variableType)> conditions)
|
||
{
|
||
var exprParts = new List<string>();
|
||
|
||
foreach (var (_, expression, op, value, variableType) in conditions)
|
||
{
|
||
// 先根据 expression 构建左值访问表达式,再拼接比较符和右值字面量
|
||
string leftExpr = BuildMemberAccessExpression(expression, variableType);
|
||
|
||
// 格式化阈值
|
||
string valueLiteral = FormatValueLiteral(value, variableType);
|
||
|
||
// 格式化操作符
|
||
string csharpOp = FormatOperator(op);
|
||
|
||
// 生成单条件表达式
|
||
string expr = $"{leftExpr} {csharpOp} {valueLiteral}";
|
||
exprParts.Add(expr);
|
||
}
|
||
|
||
// 多条件用 && 连接
|
||
return string.Join(" && ", exprParts);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 将前端传入的 expression 转成 Roslyn 可执行的成员访问表达式。
|
||
/// 例如 trade.StockEqvNotional 会转成 ((YLErp.DBModels.trade)DataMap["trade"]).StockEqvNotional。
|
||
/// 数值类型会自动包一层 Convert.ToDecimal,便于与 decimal 阈值比较。
|
||
/// </summary>
|
||
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;
|
||
}
|
||
|
||
/// <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()));
|
||
_logger.Error($"规则编译失败:{errorMsg}\n脚本代码:{scriptCode}");
|
||
throw new InvalidOperationException($"脚本编译失败:{errorMsg}");
|
||
}
|
||
|
||
// 生成可调用委托
|
||
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)
|
||
{
|
||
// 脚本执行异常(如空引用、类型转换失败)视为规则不触发
|
||
_logger.Error($"规则执行异常:{ex.Message}\n脚本代码:{scriptCode}");
|
||
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
|
||
{
|
||
// 数值型 C# 符号
|
||
"=" or "==" => "==",
|
||
"!=" or "≠" => "!=",
|
||
">" => ">",
|
||
"<" => "<",
|
||
">=" => ">=",
|
||
"<=" => "<=",
|
||
// 日期型中文语义 → C# 符号
|
||
"早于" => "<",
|
||
"晚于" => ">",
|
||
"等于" => "==",
|
||
"不早于" => ">=",
|
||
"不晚于" => "<=",
|
||
_ => throw new ArgumentException($"不支持的操作符:{op}")
|
||
};
|
||
}
|
||
}
|
||
}
|