481 lines
23 KiB
C#
481 lines
23 KiB
C#
using Newtonsoft.Json;
|
||
using Qdp.Foundation.Utilities;
|
||
using System.Reflection;
|
||
using YLErp.BLL;
|
||
using YLErp.Model;
|
||
|
||
/*
|
||
================================================================================
|
||
风控引擎服务 — RiskEngineService 技术方案与当前实现说明
|
||
================================================================================
|
||
|
||
【项目背景】
|
||
当前 TRS 系统已在 QuotaMonitorService 中接入第一版风控引擎,用于在交易关键
|
||
时点执行可配置规则判断。当前目标不是一次性做成最终版,而是在最小可运行链路
|
||
跑通后,逐步演进为可由前端配置、后端预编译、执行期直接命中的正式版本。
|
||
|
||
【当前实现口径】(以本注释和实际代码为准)
|
||
|
||
核心概念:
|
||
- RiskRule(风控规则):规则定义本体,当前重点字段包括 RuleCode、RuleName、
|
||
FormulaText、FormulaExp、Version、CompiledScript。
|
||
- RiskRuleApplication(规则应用):与规则分离,承载启用状态、控制策略、触发时点、
|
||
适用范围(全局 / 账户 / 客户 / 标的类型 / 合约类型)。
|
||
- FormulaExp:当前唯一主编译入口。要求内容是 Roslyn 可直接执行的 C# bool 表达式,
|
||
例如直接访问 DataMap["trade"] 后做数值或日期比较。
|
||
- FormulaJson:当前已在规则定义和样例代码中停用,不参与主执行链路。
|
||
- RiskContext:一次风控检查的数据上下文,包含 TradeId、TriggerPoint 和 DataMap。
|
||
- RuleCompiledCache:进程内编译结果缓存,按 RuleCode 缓存 Func<RiskContext, bool>。
|
||
|
||
当前编译流程:
|
||
1. 外部准备好规则对象,FormulaExp 中直接写最终可执行表达式
|
||
2. 调用 RuleCompiler.ValidateAndCompileRule(rule)
|
||
3. 内部使用 Roslyn 编译 FormulaExp,生成 Func<RiskContext, bool>
|
||
4. 编译成功后写入 rule.CompiledScript,并同步写入 RuleCompiledCache
|
||
5. 执行阶段优先从 RuleCompiledCache 取委托执行
|
||
|
||
当前执行流程(EvaluateRisk):
|
||
1. 加载示例规则列表和示例应用列表
|
||
2. 按应用状态、TriggerPoints 过滤有效应用
|
||
3. 按应用范围过滤交易是否命中(全局 / 账户 / 客户 / 标的类型 / 合约类型)
|
||
4. 根据命中的 RuleCode 找到对应规则
|
||
5. 优先从 RuleCompiledCache 读取已编译委托,未命中时兜底编译一次
|
||
6. 执行规则委托,按 ControlStrategy 聚合为 Blocked / NeedApproval / Warnings
|
||
7. 返回 RiskResult
|
||
|
||
当前维度匹配规则:
|
||
- 全局:ScopeIsGlobal=true 时直接命中
|
||
- 同一维度内多选:并集(OR)
|
||
- 不同维度之间:交集(AND)
|
||
- 某维度留空:视为该维度不限制
|
||
|
||
【与早期方案的主要差异】
|
||
- 当前不是 Content 解析或表达式树主导,而是 FormulaExp 直编译主导
|
||
- RiskRule 与 RiskRuleApplication 当前仍是分离模型,没有合并
|
||
- 编译器文件已放入 RiskEngine/Compile 目录下
|
||
- 当前已引入 RuleCompileResult、RuleCompiledCache,用于校验结果与进程内缓存
|
||
- Rule2 这类“左值与右值都来自对象字段”的规则,当前通过 FormulaExp 直接表达
|
||
|
||
【当前代码结构】
|
||
风控引擎层: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 第一版执行链路已跑通:QuotaMonitorService -> RiskEngineService -> RuleCompiler
|
||
2. 风控上下文通过 DataMap 传入 trade 对象,支持规则脚本直接访问交易字段
|
||
3. FormulaExp 直编译方案已接入,支持数值比较和日期比较
|
||
4. Application 通用维度匹配已支持:全局 / 账户 / 客户 / 标的类型 / 合约类型
|
||
5. 维度组合逻辑已按文档确认:同维度 OR,不同维度 AND
|
||
6. RuleCompiler 已支持校验 + 编译 + 写缓存
|
||
7. RuleCompiledCache / RuleCompileResult 已落地到 Compile 目录
|
||
8. RiskEngineService 已改为执行时优先从 RuleCompiledCache 读取委托
|
||
|
||
【待办事项 / TODO】
|
||
⬜ 1. 接入真实规则来源
|
||
- 由数据库或前端提交替换当前 LoadRules / LoadApplications 示例数据
|
||
⬜ 2. 增加接口层
|
||
- 提供前端提交 FormulaExp 后的校验接口
|
||
- 提供规则保存 / 发布后预编译并写缓存的入口
|
||
⬜ 3. 启动预热与缓存刷新
|
||
- 服务启动时批量加载有效规则并预编译
|
||
- 支持规则更新后的缓存刷新 / 删除
|
||
⬜ 4. 完善执行期策略
|
||
- 当前保留“缓存未命中时兜底编译”逻辑
|
||
- 后续可收紧为“执行期只读缓存,未命中按发布失败处理”
|
||
⬜ 5. 补测试样例
|
||
- 数值比较、日期比较、维度过滤、非法公式、边界值
|
||
⬜ 6. 完善 FormulaExp 配套能力
|
||
- 提供前端可用的表达式编辑、校验与错误提示
|
||
- 约束可用变量、类型转换和脚本安全边界
|
||
|
||
【注意事项】
|
||
1. 当前 FormulaExp 必须是 Roslyn 最终可执行的 C# bool 表达式,不是业务语义短句
|
||
2. DataMap 中的值由宿主业务代码准备,编译器与执行器本身不负责查库补数
|
||
3. 当前缓存的是 Func<RiskContext, bool>,这是规则判断函数,不是事件处理器
|
||
4. 业务可预期失败优先走结果返回,不要把高频校验失败都设计成异常
|
||
================================================================================
|
||
*/
|
||
|
||
namespace YLErp.Modules.RiskEngine
|
||
{
|
||
/// <summary>
|
||
/// 风控引擎服务(第一版 — 骨架版,先跑通)
|
||
/// </summary>
|
||
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)
|
||
{
|
||
}
|
||
|
||
/// <summary>
|
||
/// 执行风控检查(最终设计版:预编译委托 + 规则筛选 + 策略判定)
|
||
/// </summary>
|
||
/// <param name="context">风控上下文</param>
|
||
/// <param name="triggerPoint">触发时点,如 BOOK_CONFIRM</param>
|
||
/// <returns>风控结果</returns>
|
||
public RiskResult EvaluateRisk(RiskContext context, string triggerPoint)
|
||
{
|
||
var result = new RiskResult();
|
||
|
||
try
|
||
{
|
||
_logger.Info($"[风控引擎] EvaluateRisk 开始 - TradeId: {context?.TradeId}, TriggerPoint: {triggerPoint}");
|
||
|
||
// ============================================================
|
||
// Step 1: 加载规则定义和规则应用(TODO: 后续接入真实内存缓存)
|
||
// ============================================================
|
||
var rules = LoadRules();
|
||
var applications = LoadApplications();
|
||
_logger.Info($"[风控引擎] 加载规则数: {rules.Count}, 应用数: {applications.Count}");
|
||
|
||
// ============================================================
|
||
// Step 2: 先按 Application 过滤 Active 状态和 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();
|
||
|
||
// 再按应用范围过滤。
|
||
// 统一走通用维度匹配:
|
||
// 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: 遍历匹配的应用,通过 RuleCode 关联规则并执行预编译委托
|
||
// ============================================================
|
||
foreach (var application in matchedApplications)
|
||
{
|
||
// ------------------------------------------------------------
|
||
// 3.1 通过 RuleCode 关联规则定义
|
||
// ------------------------------------------------------------
|
||
var rule = rules.FirstOrDefault(r => r.RuleCode == application.RuleCode);
|
||
if (rule == null)
|
||
{
|
||
_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 = 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;
|
||
}
|
||
|
||
// ------------------------------------------------------------
|
||
// 3.4 命中后按 Application 的控制策略聚合结果
|
||
// ------------------------------------------------------------
|
||
if (triggered)
|
||
{
|
||
_logger.Info($"[风控引擎] 规则触发 - RuleCode: {rule.RuleCode}, Strategy: {application.ControlStrategy}");
|
||
|
||
switch (application.ControlStrategy)
|
||
{
|
||
case RiskControlStrategyEnum.Block:
|
||
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 RiskControlStrategyEnum.Approval:
|
||
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 RiskControlStrategyEnum.Warning:
|
||
result.Warnings.Add($"规则[{rule.RuleName}]触发:提示 - {rule.FormulaText}");
|
||
break;
|
||
|
||
default:
|
||
_logger.Info($"[风控引擎] 未知策略类型 - ControlStrategy: {application.ControlStrategy}");
|
||
break;
|
||
}
|
||
}
|
||
else
|
||
{
|
||
_logger.Info($"[风控引擎] 规则未触发 - RuleCode: {rule.RuleCode}, TriggerPoint: {triggerPoint}");
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// Step 4: 聚合最终结果
|
||
// ============================================================
|
||
if (!result.Blocked && !result.NeedApproval && result.Warnings.Count == 0 && !result.HasError)
|
||
{
|
||
result.Passed = true;
|
||
}
|
||
|
||
_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
|
||
{
|
||
RuleCode = "ENGINE_ERROR",
|
||
RuleName = "风控引擎执行异常",
|
||
ControlStrategy = "审批",
|
||
FormulaText = ex.Message,
|
||
Message = $"风控引擎异常:{ex.Message},需审批通过"
|
||
});
|
||
_logger.Error($"[风控引擎] EvaluateRisk 异常 - TradeId: {context?.TradeId}, Error: {ex.Message}");
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 加载规则列表(TODO: 后续接入真实内存缓存和数据库查询)
|
||
/// 当前返回示例规则用于验证流程
|
||
/// </summary>
|
||
private List<RiskRule> LoadRules()
|
||
{
|
||
//名义本金: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亿",
|
||
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
|
||
};
|
||
|
||
// 示例规则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<RiskRule> { Rule1, Rule2 };
|
||
}
|
||
/// <summary>
|
||
/// 加载application
|
||
/// 当前返回示例规则用于验证流程
|
||
/// </summary>
|
||
private List<RiskRuleApplication> 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<RiskRuleApplication> { application1, application2 };
|
||
}
|
||
|
||
/// <summary>
|
||
/// 判断应用配置是否命中当前交易。
|
||
/// 匹配规则遵循设计文档:
|
||
/// 1. 全局命中时直接返回 true;
|
||
/// 2. 同一维度内多选按并集处理;
|
||
/// 3. 不同维度之间按交集处理;
|
||
/// 4. 某维度留空表示该维度不限制。
|
||
/// </summary>
|
||
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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 判断某个范围字段是否为空。
|
||
/// 为空表示该维度不限制。
|
||
/// </summary>
|
||
private bool IsScopeEmpty(string scopeValue)
|
||
{
|
||
return string.IsNullOrWhiteSpace(scopeValue);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 判断单个值是否命中逗号分隔的范围配置。
|
||
/// 同一维度内多选按并集处理,只要命中任一值即返回 true。
|
||
/// </summary>
|
||
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));
|
||
}
|
||
|
||
/// <summary>
|
||
/// 读取交易的资产簿账户ID。
|
||
/// 当前先按 BookId 取值;如果后续真实字段不是 BookId,再统一调整这里即可。
|
||
/// </summary>
|
||
private object GetTradeAssetBookId(YLErp.DBModels.trade trade)
|
||
{
|
||
var property = trade.GetType().GetProperty("BookId");
|
||
return property?.GetValue(trade);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 读取交易的标的类型。
|
||
/// 当前先按 UnderlyingInstrumentType 取值;如果后续真实字段名不同,再统一调整这里即可。
|
||
/// </summary>
|
||
private object GetTradeUnderlyingType(YLErp.DBModels.trade trade)
|
||
{
|
||
var property = trade.GetType().GetProperty("UnderlyingInstrumentType");
|
||
return property?.GetValue(trade);
|
||
}
|
||
}
|
||
}
|