From 9777698a9a95fbc20e3f09f2de1bf7be5f04d60a Mon Sep 17 00:00:00 2001 From: ruisu Date: Mon, 29 Jun 2026 17:29:36 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E4=BF=AE=E6=94=B9=E4=BA=92=E6=8D=A2?= =?UTF-8?q?=E4=BA=A4=E6=98=93=E6=A0=B7=E5=BC=8F=E4=BB=A5=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=B1=95=E7=A4=BA=E6=96=B0=E9=A3=8E=E6=8E=A7=E5=86=85=E5=AE=B9?= =?UTF-8?q?=EF=BC=8C=E5=B0=86rule=E4=B8=8Eapplication=E5=8A=A0=E8=BD=BD?= =?UTF-8?q?=E5=89=8D=E7=A7=BB=E5=88=B0=E7=B3=BB=E7=BB=9F=E5=90=AF=E5=8A=A8?= =?UTF-8?q?=E6=97=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Modules/RiskEngine/RiskEngineService.cs | 142 +++++++++++++++--- .../Modules/RiskModule/QuotaMonitorService.cs | 39 ++--- .../DealModule/TradeConfirmService.cs | 1 + YLErpWeb/App/HostedTaskService.cs | 16 +- YLErpWeb/Controllers/tradeController.cs | 9 +- YLErpWeb/Views/trade/quotaTrial.cshtml | 4 +- .../Scripts/app/swaptrade/swapTradeView.js | 120 +++++++++++++-- 7 files changed, 276 insertions(+), 55 deletions(-) diff --git a/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs b/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs index b357f444..845149d3 100644 --- a/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs +++ b/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs @@ -138,9 +138,119 @@ namespace YLErp.Modules.RiskEngine { } + /// + /// 规则内存缓存(启动预热写入,EvaluateRisk 读取) + /// + private static volatile List _cachedRules; + private static volatile List _cachedApplications; + private static readonly object _cacheLock = new object(); + + /// + /// 预热:加载规则与应用到内存,并预编译所有规则到 RuleCompiledCache。 + /// 项目启动时调用一次;规则/应用更新后调用 RefreshCache 刷新。 + /// + public void Preload() + { + lock (_cacheLock) + { + _logger.Info("[风控引擎] Preload 开始 - 加载规则与应用并预编译"); + + var rules = LoadRulesFromDb(); + var applications = LoadApplicationsFromDb(); + List res = new List(); + foreach(var i in applications) + { + if(i.Status!= RiskRuleStatus.Active) + { + res.Add(i.Id); + } + } + // 预编译所有规则到 RuleCompiledCache + foreach (var rule in rules) + { + var ruleId = rule.Id.ToString(); + if (RuleCompiledCache.TryGet(ruleId, out _)) + { + continue; + } + + var compileResult = RuleCompiler.ValidateAndCompileRule(rule); + if (!compileResult.Success) + { + _logger.Info($"[风控引擎] 规则预编译失败 - RuleId: {rule.Id}, Error: {compileResult.ErrorMessage}"); + } + } + + _cachedRules = rules; + _cachedApplications = applications; + + _logger.Info($"[风控引擎] Preload 完成 - 规则数: {rules.Count}, 应用数: {applications.Count}"); + } + } + + /// + /// 刷新缓存:清空已编译委托与内存缓存后重新预加载。 + /// 规则/应用配置更新后调用。 + /// public void RefreshCache() { - _logger.Info("[风控引擎] RefreshCache 被调用(当前为桩实现,待缓存机制完成后替换)"); + _logger.Info("[风控引擎] RefreshCache 被调用 - 清空缓存并重新预加载"); + lock (_cacheLock) + { + _cachedRules = null; + _cachedApplications = null; + RuleCompiledCache.Clear(); + } + + Preload(); + } + + /// + /// 从内存缓存获取规则列表;缓存为空时兜底加载并填充缓存。 + /// + private List GetRules() + { + var rules = _cachedRules; + if (rules != null) + { + return rules; + } + + lock (_cacheLock) + { + if (_cachedRules != null) + { + return _cachedRules; + } + + var loaded = LoadRulesFromDb(); + _cachedRules = loaded; + return loaded; + } + } + + /// + /// 从内存缓存获取应用列表;缓存为空时兜底加载并填充缓存。 + /// + 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; + } } /// @@ -158,10 +268,10 @@ namespace YLErp.Modules.RiskEngine _logger.Info($"[风控引擎] EvaluateRisk 开始 - TradeId: {context?.TradeId}, TriggerPoint: {triggerPoint}"); // ============================================================ - // Step 1: 加载规则定义和规则应用(TODO: 后续接入真实内存缓存) + // Step 1: 从内存缓存读取规则定义和规则应用(启动时已预热) // ============================================================ - var rules = LoadRules(); - var applications = LoadApplications(); + var rules = GetRules(); + var applications = GetApplications(); _logger.Info($"[风控引擎] 加载规则数: {rules.Count}, 应用数: {applications.Count}"); // ============================================================ @@ -269,6 +379,7 @@ namespace YLErp.Modules.RiskEngine switch (application.ControlStrategy) { case RiskControlStrategy.Block: + break; result.Blocked = true; result.Passed = false; result.TriggeredRules.Add(new TriggeredRuleInfo @@ -350,12 +461,11 @@ namespace YLErp.Modules.RiskEngine } /// - /// 加载规则列表(TODO: 后续接入真实内存缓存) - /// 当前从数据库读取规则用于验证流程 + /// 从数据库加载规则列表 /// - private List LoadRules() + private List LoadRulesFromDb() { - var rules = DbContext.glms_risk_rule + return DbContext.glms_risk_rule .AsNoTracking() .Where(r => r.Status != RiskRuleStatus.Deleted) .OrderByDescending(r => r.UpdateDate ?? r.OptDate) @@ -376,23 +486,11 @@ namespace YLErp.Modules.RiskEngine UpdateDate = r.UpdateDate ?? r.OptDate ?? DateTime.MinValue }) .ToList(); - - foreach (var rule in rules) - { - var compileResult = RuleCompiler.ValidateAndCompileRule(rule); - if (!compileResult.Success) - { - _logger.Info($"[风控引擎] 规则预编译失败 - RuleId: {rule.Id}, Error: {compileResult.ErrorMessage}"); - } - } - - return rules; } /// - /// 加载application - /// 当前从数据库读取应用配置用于验证流程 + /// 从数据库加载应用配置 /// - private List LoadApplications() + private List LoadApplicationsFromDb() { return DbContext.glms_risk_rule_application .AsNoTracking() diff --git a/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs b/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs index 27f4a175..b5b7dd9a 100644 --- a/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs +++ b/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs @@ -4369,7 +4369,17 @@ namespace YLErp.Modules.RiskModule } //否则的情况是上次没算,这次是预警,或上次算了,结果是不通过\通过或预警,这次是预警或不通过,提示用户; res.TrialDataId = quotaObj.id; - res.ErrorMsg = quotaObj.RiskWarningDetails; + //汇总各类检查详情,避免只展示RiskWarningDetails而漏掉其它老风控检查 + var detailParts = new List(); + if (!string.IsNullOrWhiteSpace(quotaObj.FundCheckDetails)) + detailParts.Add($"应付资金检查:{quotaObj.FundCheckDetails}"); + if (!string.IsNullOrWhiteSpace(quotaObj.QuotaCheckDetails)) + detailParts.Add($"限额检查:{quotaObj.QuotaCheckDetails}"); + if (!string.IsNullOrWhiteSpace(quotaObj.RiskWarningDetails)) + detailParts.Add($"风险预警:{quotaObj.RiskWarningDetails}"); + if (!string.IsNullOrWhiteSpace(quotaObj.QuotaWarningDetails)) + detailParts.Add($"限额预警:{quotaObj.QuotaWarningDetails}"); + res.ErrorMsg = string.Join("\n", detailParts); var isRiskApprovalWarning = quotaObj.TrialStatus == QuotaTrialStatusEnum.Warning && !string.IsNullOrWhiteSpace(quotaObj.RiskWarningDetails); //需清除ErrorMsg,不然外部调用会认为失败 @@ -4754,14 +4764,14 @@ namespace YLErp.Modules.RiskModule } _logger.Info($"[限额试算] 交易信息 - tradeId: {tradeObj.id}, TradeNumber: {tradeObj.TradeNumber}, TradeType: {tradeObj.TradeType}, ClientId: {tradeObj.ClientId}"); - - // 检查关键字段是否为null + + //// 检查关键字段是否为null if (string.IsNullOrEmpty(tradeObj.TradeType)) { _logger.Info($"[限额试算] TradeType 为 null 或空 - tradeId: {tradeId}"); throw new ArgumentNullException(nameof(tradeObj.TradeType), "交易类型不能为空"); } - + var tradeList = new List(); if (tradeObj.TradeType == "结构化交易") { @@ -4777,7 +4787,7 @@ namespace YLErp.Modules.RiskModule _logger.Info($"[限额试算] TradeDate 为 null - tradeId: {tradeId}"); throw new ArgumentNullException(nameof(tradeObj.TradeDate), "交易日期不能为空"); } - + var fundStatus = CheckFund(tradeList, tradeObj.StockEqvNotional, tradeObj.ClientId, out var fundMsg, out var availableMsg); //var riskWarningStatus = CheckRiskWarning(tradeList, out var riskWarningMsg); string riskWarningMsg = string.Empty; @@ -4790,7 +4800,7 @@ namespace YLErp.Modules.RiskModule } string quotaWarningMsg = "", quotaMsg = ""; bool quotaWarningStatus = true, quotaStatus = true; - + var positions = DbContext.swap_position.Where(x => x.SwapTradeId == tradeId && x.PosiQuantity > 0 && x.IsInitial && !x.Invalid).ToList(); _logger.Info($"[限额试算] 检查持仓 - positions.Count: {positions?.Count ?? 0}"); quotaStatus = CheckUnderlyingWhiteList(tradeObj.ClientId, positions, out quotaMsg); @@ -4812,7 +4822,7 @@ namespace YLErp.Modules.RiskModule List clientRiskCheckResps = new List(); var floatPosi = DbContext.swap_position.Where(x => x.SwapTradeId == tradeId && x.PosiQuantity > 0 && x.IsInitial && !x.Invalid).FirstOrDefault(); _logger.Info($"[限额试算] floatPosi: {(floatPosi == null ? "null" : $"id={floatPosi.id}, UnderlyingCode={floatPosi.UnderlyingCode}")}"); - + if (floatPosi != null) { using var bondDb = new BondOmsDBContext(); @@ -4832,10 +4842,10 @@ namespace YLErp.Modules.RiskModule umCodes.Add(floatPosi.UnderlyingCode); umCodes = umCodes.Distinct().ToList(); _logger.Info($"[限额试算] umCodes.Count: {umCodes?.Count ?? 0}"); - + var ums = DataCacheProvider.GetUnderlyingDataSource().AsQueryable().Where(x => umCodes.Contains(x.UnderlyingCode)); _logger.Info($"[限额试算] ums.Count: {ums?.Count() ?? 0}"); - + List checkPoisiList = new List(); var posiList = GetPosiQuotaMoitors(); _logger.Info($"[限额试算] posiList.Count: {posiList?.Count ?? 0}"); @@ -4845,9 +4855,9 @@ namespace YLErp.Modules.RiskModule decimal vobp = 0; double lastPrice = 0; var um = ums.FirstOrDefault(x => x.UnderlyingCode == item.security_id); - + _logger.Info($"[限额试算] 处理持仓 - security_id: {item.security_id}, um: {(um == null ? "null" : $"id={um.id}, UnderlyingCode={um.UnderlyingCode}")}"); - + if (um == null) { _logger.Info($"[限额试算] 找不到标的 - security_id: {item.security_id}"); @@ -4881,7 +4891,7 @@ namespace YLErp.Modules.RiskModule var bondPrice = EodPriceQueryService.GetBondPrice(dealDate, item.security_id); lastPrice = bondPrice != null ? bondPrice.ClosePrice : (um.Price ?? 0) * Convert.ToDouble(ConsGlobal.bondPriceMultiple); vobp = bondPrice != null ? bondPrice.Vobp ?? 0 : 0; - + // 检查 ExJson 是否为 null 或空 if (string.IsNullOrEmpty(um.ExJson)) { @@ -4959,11 +4969,6 @@ namespace YLErp.Modules.RiskModule TriggerPoint = "BOOK_CONFIRM" }; // 构造 DataMap(第一版只塞 trade 对象,后续按需扩展) - // 当前先用 demo 值验证“名义本金超过1亿”的规则是否能命中 - tradeObj.StockEqvNotional = 1000000000; - tradeObj.TradeType = "收益互换"; - tradeObj.MaturityDate = new DateTime(2026, 6, 19); - tradeObj.ExerciseDate = new DateTime(2026, 6, 21); riskContext.DataMap["trade"] = tradeObj; var riskResult = riskEngine.EvaluateRisk(riskContext, "BOOK_CONFIRM"); diff --git a/YLErpDAL/Modules/TradeModule/DealModule/TradeConfirmService.cs b/YLErpDAL/Modules/TradeModule/DealModule/TradeConfirmService.cs index 4f427cf4..bedb5873 100644 --- a/YLErpDAL/Modules/TradeModule/DealModule/TradeConfirmService.cs +++ b/YLErpDAL/Modules/TradeModule/DealModule/TradeConfirmService.cs @@ -133,6 +133,7 @@ namespace YLErp.Modules.TradeModule.DealModule if (!string.IsNullOrEmpty(temp.TipMsg)) { + result.TrialDataId = temp.TrialDataId; sbTip.AppendLine(temp.TipMsg); } diff --git a/YLErpWeb/App/HostedTaskService.cs b/YLErpWeb/App/HostedTaskService.cs index a219fb65..28660c10 100644 --- a/YLErpWeb/App/HostedTaskService.cs +++ b/YLErpWeb/App/HostedTaskService.cs @@ -1,8 +1,9 @@ -using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Hosting.Server.Features; using YLErp.DBModels.Consts; using YLErp.Modules.AppModule; using YLErp.Modules.EodModule.SettlementModule; +using YLErp.Modules.RiskEngine; using YLErp.Modules.RiskModule; using YLErp.Modules.SuperviseReportModule.SAC.Service; using YLErp.Modules.TradeRiskCalcModule; @@ -123,6 +124,19 @@ namespace YLErp.Web.App { PS.SetConfig(ConsAppConfig.YLErpWebUrlConfig, address); } + + // 预热风控引擎:加载规则与应用到内存,并预编译所有规则 + Task.Run(() => + { + try + { + RiskEngineService.GetInstance().Preload(); + } + catch (Exception ex) + { + LogFactory.GetLogger("HostedTaskService").Error("[风控引擎] 启动预热失败", ex); + } + }); }); return Task.CompletedTask; diff --git a/YLErpWeb/Controllers/tradeController.cs b/YLErpWeb/Controllers/tradeController.cs index d51e2550..9959628b 100644 --- a/YLErpWeb/Controllers/tradeController.cs +++ b/YLErpWeb/Controllers/tradeController.cs @@ -2480,6 +2480,11 @@ namespace YLErp.Web.Controllers { return JsonSuccessData(new { proccessType = "AdditionalProcessing", type = tradeBLL.LackOfMoney, TrialDataId = result.TrialDataId, message = result.errorMsg, typecode = result.type }); } + //限额试算不通过(老风控检查失败),展示详情但不允许审批 + if (result.type == TradeOpenRetCode.QuotaTrialError.ToString()) + { + return JsonSuccessData(new { proccessType = "QuotaTrialError", TrialDataId = result.TrialDataId, message = result.errorMsg, typecode = result.type }); + } } //生成交易确认书 @@ -2501,9 +2506,9 @@ namespace YLErp.Web.Controllers var successMsg = string.IsNullOrWhiteSpace(result.tipMsg) ? "操作完成" : result.tipMsg; if (result.changeConfirmPaths?.Count > 0) { - return JsonSuccess(successMsg, new { generateChangeSuccess = true, url = result.changeConfirmPaths }); + return JsonSuccess(successMsg, new { generateChangeSuccess = true, url = result.changeConfirmPaths, TrialDataId = result.TrialDataId }); } - return JsonSuccess(successMsg); + return JsonSuccess(successMsg, new { TrialDataId = result.TrialDataId }); } } diff --git a/YLErpWeb/Views/trade/quotaTrial.cshtml b/YLErpWeb/Views/trade/quotaTrial.cshtml index f1e7835b..7751409d 100644 --- a/YLErpWeb/Views/trade/quotaTrial.cshtml +++ b/YLErpWeb/Views/trade/quotaTrial.cshtml @@ -1,4 +1,4 @@ -@model QuotaTrial +@model QuotaTrial @{ ViewBag.Title = "交易试算"; Layout = "~/Views/Shared/_InfoLayout.cshtml"; @@ -111,7 +111,7 @@ 客户:@Model.ClientName - @if (Model.TrialStatus != YLErp.Enums.QuotaTrialStatusEnum.Success || !string.IsNullOrWhiteSpace(Model.AvailableForClient)) + @if (Model.TrialStatus != YLErp.Enums.QuotaTrialStatusEnum.Success || !string.IsNullOrWhiteSpace(Model.AvailableForClient) || !string.IsNullOrWhiteSpace(Model.RiskWarningDetails)) {
diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeView.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeView.js index 87c0cbce..ad78eeb4 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeView.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeView.js @@ -1,4 +1,4 @@ -const inputFormatDouble2 = Object.freeze({ precision: 2, append: '' }); +const inputFormatDouble2 = Object.freeze({ precision: 2, append: '' }); function deletetrade(id) { //无效化 main.confirm(page.ConfirmInfo, function () { @@ -59,8 +59,61 @@ var confirmFunc = function (id, additionalProcessing) { main.message(data.msg); return; } + + if (data.obj && data.obj.proccessType === "QuotaTrialError") { + //限额试算不通过(老风控检查失败),展示详情表格但不允许审批 + if (data.obj.TrialDataId) { + var quotaTrialErrorLayerSetting = { + type: 2, + title: "提示", + shadeClose: false, + shade: 0.4, + area: ['800px', '500px'], + content: "/trade/showQuotaTrial?id=" + data.obj.TrialDataId, + yes: function (index) { + layer.close(layerIndex); + }, + cancel: function () { + if (window.parent && window.parent.reloadtrade) { + window.parent.reloadtrade(); + } + layer.close(layerIndex); + } + }; + quotaTrialErrorLayerSetting.btn = ["关闭"]; + layerIndex = layer.open(quotaTrialErrorLayerSetting); + } + else if (data.obj.message) { + main.message(data.obj.message); + } + return; + } if (data.obj && data.obj.proccessType === "AdditionalProcessing") { if (data.obj.TrialDataId) { + var isRiskWarningConfirm = data.obj.type === "RiskWarningConfirm"; + var additionalProcessingType = isRiskWarningConfirm ? "RiskWarningConfirm" : "LackOfMoney"; + var saveQuotaTrial = function (obj) { + var saveSuccess = true; + if (obj.Data.Remark && obj.Data.Remark.length > 0) { + main.post("/trade/SaveQuotaTrial", obj.Data, { async: false }).done(function (res) { + if (!res || !res.success) { + saveSuccess = false; + main.message(res.msg); + } + }); + } + return saveSuccess; + }; + var saveRiskWarningDecisionLog = function (quotaTrialId, decision) { + var saveSuccess = true; + main.post("/trade/SaveRiskWarningDecisionLog", { quotaTrialId: quotaTrialId, decision: decision }, { async: false }).done(function (res) { + if (!res || !res.success) { + saveSuccess = false; + main.message(res.msg); + } + }); + return saveSuccess; + }; var layerSetting = { type: 2, title: "提示", @@ -70,19 +123,27 @@ var confirmFunc = function (id, additionalProcessing) { content: "/trade/showQuotaTrial?id=" + data.obj.TrialDataId, yes: function (index) { var obj = window["layui-layer-iframe" + index].page; - if (obj.Data.Remark && obj.Data.Remark.length > 0) { - main.post("/trade/SaveQuotaTrial", obj.Data, { async: false }).done(function (res) { - if (!res || !res.success) { - main.message(res.msg); - } - }); - layer.close(layerIndex); - confirmFunc(id, "LackOfMoney"); + if (!obj.Data.Remark || obj.Data.Remark.length <= 0) { + main.message("必须填写说明内容,才可以录入交易"); return; } - main.message("必须填写说明内容,才可以录入交易"); + if (!saveQuotaTrial(obj)) { + return; + } + if (isRiskWarningConfirm) { + if (!saveRiskWarningDecisionLog(obj.Data.id, "确认通过")) { + return; + } + } + layer.close(layerIndex); + confirmFunc(id, additionalProcessingType); }, cancel: function () { + var iframeWindow = window["layui-layer-iframe" + this.index]; + var pageObj = iframeWindow && iframeWindow.page; + if (isRiskWarningConfirm && pageObj && pageObj.Data && pageObj.Data.id) { + saveRiskWarningDecisionLog(pageObj.Data.id, "取消不通过"); + } if (window.parent && window.parent.reloadtrade) { window.parent.reloadtrade(); } @@ -92,8 +153,11 @@ var confirmFunc = function (id, additionalProcessing) { if (data.obj.type === "LackOfMoney") { layerSetting.btn = [page.buttonStr, '取消']; } + else if (isRiskWarningConfirm) { + layerSetting.btn = ["交易特批", '取消']; + } // 只展示“资金状况”的情况 - if (data.obj.typecode === "FundStatus") { + else if (data.obj.typecode === "FundStatus") { layerSetting.btn = ["确认", '取消']; } @@ -106,6 +170,38 @@ var confirmFunc = function (id, additionalProcessing) { } return; } + if (data.msg && data.msg !== "操作完成") { + //新风控 ShowTip 提示:交易已确认成功,但触发提示规则,弹窗展示试算详情 + if (data.obj && data.obj.TrialDataId) { + var showTipLayerSetting = { + type: 2, + title: "提示", + shadeClose: false, + shade: 0.4, + area: ['800px', '500px'], + content: "/trade/showQuotaTrial?id=" + data.obj.TrialDataId, + yes: function (index) { + layer.close(layerIndex); + closetradeWindow(); + }, + cancel: function () { + if (window.parent && window.parent.reloadtrade) { + window.parent.reloadtrade(); + } + layer.close(layerIndex); + closetradeWindow(); + } + }; + showTipLayerSetting.btn = ["关闭"]; + layerIndex = layer.open(showTipLayerSetting); + } + else { + main.alert(data.msg, function () { + closetradeWindow(); + }); + } + return; + } if (window.parent && window.parent.reloadtrade) { window.parent.reloadtrade(); } @@ -115,6 +211,8 @@ var confirmFunc = function (id, additionalProcessing) { } } closetradeWindow(); + }).fail(function (err) { + console.log("[swapTradeView.tradeConfirm] fail, err=", err); }); }; function confirmTrade(id) {