Files
zszq-trs/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs
T

566 lines
26 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 Newtonsoft.Json;
using Qdp.Foundation.Utilities;
using System.Reflection;
using YLErp.BLL;
using YLErp.Model;
/*
================================================================================
风控引擎服务 — RiskEngineService 技术方案与当前实现说明
================================================================================
【项目背景】
当前 TRS 系统已在 QuotaMonitorService 中接入第一版风控引擎,用于在交易关键
时点执行可配置规则判断。当前目标不是一次性做成最终版,而是在最小可运行链路
跑通后,逐步演进为可由前端配置、后端预编译、执行期直接命中的正式版本。
【当前实现口径】(以本注释和实际代码为准)
核心概念:
- RiskRule(风控规则):单条规则包含完整的公式定义 + 校验策略 + 触发时点 + 适用范围。
不需要 "一个 Rule 对应多个 Application" 的复杂关系。
- Content:用户输入的规则内容字符串,如"名义本金>1亿"
- 规则编译:在规则定义时(创建/加载/缓存刷新时)调用 RuleCompiler.Compile(rule)
将 Content 解析并编译为 Func<RiskContext, bool> 委托,写入 rule.CompiledScript。
判风控时直接执行委托,零解析开销。
- RiskContext:一次风控检查的数据上下文(DataMap 机制)
- RiskResult:风控检查结果(Passed / Blocked / NeedApproval / Warnings / TriggeredRules
当前编译流程:
1. 外部准备好规则对象,在 RuleExpr 中直接写最终可执行表达式
2. 调用 RuleCompiler.ValidateAndCompileRule(rule)
3. 内部使用 Roslyn 编译 RuleExpr,生成 Func<RiskContext, bool>
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 直接表达
【当前代码结构】
风控引擎层:YLErpDAL/Modules/RiskEngine/
- RiskEngineService.cs:执行入口,负责规则筛选、应用过滤、委托执行、结果聚合
- RiskRule.cs:规则定义模型,含 CompiledScript 运行时字段
- RiskRuleApplication.cs:规则应用模型,承载策略、触发点和适用范围
- RiskContext.cs / RiskResult.cs:执行上下文与结果模型
编译相关:YLErpDAL/Modules/RiskEngine/Compile/
- RuleCompiler.csRoslyn 编译入口,提供 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.csFormulaJson 反序列化模型(辅助)
3. RuleCompiler.cs
- ParseContent:解析 Content 字符串(支持 > < >= <= = != ≠)
- ParseValue:解析数值(支持 万/亿/千/百分比)
- BuildExpression:表达式树构建(反射取值 + decimal 比较)
4. RiskEngineService.EvaluateRisk
- 加载示例规则列表
- 筛选 TriggerPoint 匹配的规则
- 调用 rule.CompiledScript(context) 执行预编译委托
- 按 ControlStrategy 聚合结果(禁止/审批/提示)
5. 集成到现有流程:
- QuotaMonitorService.QuotaCheck() 末尾已接入风控引擎
- 触发时点:BOOK_CONFIRM(簿记交易确认)
6. 编译验证通过
【待办事项 / TODO】
⬜ 1. 接入真实规则来源
- 由数据库或前端提交替换当前 LoadRules / LoadApplications 的规则来源
⬜ 2. 增加接口层
- 提供前端提交 RuleExpr 后的校验接口
- 提供规则保存 / 发布后预编译并写缓存的入口
⬜ 3. 启动预热与缓存刷新
- 服务启动时批量加载有效规则并预编译
- 支持规则更新后的缓存刷新 / 删除
⬜ 4. 完善执行期策略
- 当前保留“缓存未命中时兜底编译”逻辑
- 后续可收紧为“执行期只读缓存,未命中按发布失败处理”
⬜ 5. 补测试样例
- 数值比较、日期比较、维度过滤、非法公式、边界值
⬜ 6. 完善 RuleExpr 配套能力
- 提供前端可用的表达式编辑、校验与错误提示
- 约束可用变量、类型转换和脚本安全边界
【注意事项】
1. 当前 RuleExpr 必须是 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>
private List<RiskRule> GetRules()
{
var rules = _cachedRules;
if (rules != null)
{
return rules;
}
lock (_cacheLock)
{
if (_cachedRules != null)
{
return _cachedRules;
}
var loaded = LoadRulesFromDb();
_cachedRules = loaded;
return loaded;
}
}
/// <summary>
/// 从内存缓存获取应用列表;缓存为空时兜底加载并填充缓存。
/// </summary>
private List<RiskRuleApplication> GetApplications()
{
var applications = _cachedApplications;
if (applications != null)
{
return applications;
}
lock (_cacheLock)
{
if (_cachedApplications != null)
{
return _cachedApplications;
}
var loaded = LoadApplicationsFromDb();
_cachedApplications = loaded;
return loaded;
}
}
/// <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: 从内存缓存读取规则定义和规则应用(启动时已预热)
// ============================================================
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}");
}
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},需审批通过"
});
_logger.Error($"[风控引擎] EvaluateRisk 异常 - TradeId: {context?.TradeId}, Error: {ex.Message}");
}
return result;
}
/// <summary>
/// 从数据库加载规则列表
/// </summary>
private List<RiskRule> 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);
/// <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>
/// 解析应用配置中的规则ID列表。
/// 多个规则ID使用逗号分隔,返回去空格后的 long 集合。
/// </summary>
private List<long> ParseRuleIds(string ruleIds)
{
if (string.IsNullOrWhiteSpace(ruleIds))
{
return new List<long>();
}
var ids = new List<long>();
foreach (var part in ruleIds.Split(',', StringSplitOptions.RemoveEmptyEntries))
{
if (long.TryParse(part.Trim(), out long id))
{
ids.Add(id);
}
}
return ids;
}
/// <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);
}
}
}