From 1110e2b3ef05e42b71210275b88c154e98cfafa6 Mon Sep 17 00:00:00 2001 From: ruisu Date: Fri, 17 Jul 2026 09:26:04 +0800 Subject: [PATCH 1/7] =?UTF-8?q?feat:=E4=BC=98=E5=8C=96=E7=89=B9=E6=89=B9?= =?UTF-8?q?=E5=A4=84=E7=90=86=E6=B5=81=E7=A8=8B=E4=B8=8E=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E8=90=BD=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Modules/RiskModule/QuotaMonitorService.cs | 79 +++++++++++++++++-- .../RiskModule/TradeRiskCheckLogService.cs | 21 ++++- .../Controllers/RiskCheckLogController.cs | 7 +- YLErpWeb/Controllers/tradeController.cs | 25 +++++- .../Scripts/app/riskchecklog/riskchecklog.js | 9 ++- .../Scripts/app/swaptrade/swapTradeView.js | 20 ----- 6 files changed, 120 insertions(+), 41 deletions(-) diff --git a/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs b/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs index f2449805..5cf1e08e 100644 --- a/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs +++ b/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs @@ -11,6 +11,7 @@ using Qdp.Foundation.Utilities; using Qdp.Pricing.Ecosystem.Trade.FixedIncome; using Qdp.Pricing.Library.Base.Utilities; using Qdp.Pricing.Library.Common.Products.Rates; +using Snowflake.Core; using System.Collections.Generic; using System.Data; using System.Linq; @@ -57,6 +58,7 @@ namespace YLErp.Modules.RiskModule { public class QuotaMonitorService : YLBaseService { + private const string RiskCheckTriggerRemark = "触发风控"; IYcLogger _logger = LogFactory.GetLogger("QuotaMonitorService"); static QuotaMonitorService() { @@ -4500,11 +4502,6 @@ namespace YLErp.Modules.RiskModule } return true; } - var lastTrial = trialService.QueryLastQuotaTrial(tradeId, true); - if (quotaObj.TrialStatus == QuotaTrialStatusEnum.Success) - { - new TradeRiskCheckLogService(UserInfo).AddLog(quotaObj); - } //否则的情况是上次没算,这次是预警,或上次算了,结果是不通过\通过或预警,这次是预警或不通过,提示用户; res.RetCode = TradeOpenRetCode.QuotaTrialError; res.TrialDataId = quotaObj.id; @@ -4544,6 +4541,10 @@ namespace YLErp.Modules.RiskModule } } res.RetCode = isRiskApprovalWarning ? TradeOpenRetCode.RiskWarning : TradeOpenRetCode.QuotaTrialError; + var isOldRiskErrorSpecialApproval = !isRiskApprovalWarning + && quotaObj.OldRiskTrialStatus == QuotaTrialStatusEnum.Error + && res.OldRiskNeedSpecialApproval; + AddRiskCheckLog(quotaObj, isOldRiskErrorSpecialApproval); return false; } @@ -4935,8 +4936,64 @@ namespace YLErp.Modules.RiskModule return result; } + private DateTime? QueryLatestRiskCheckTriggerTime(int tradeId) + { + var tradeNumber = DbContext.trade.Where(x => x.id == tradeId).Select(x => x.TradeNumber).FirstOrDefault(); + if (string.IsNullOrWhiteSpace(tradeNumber)) + { + return null; + } + + return DbContext.trade_risk_check_log + .Where(x => x.trade_number == tradeNumber && x.remark.StartsWith(RiskCheckTriggerRemark)) + .OrderByDescending(x => x.create_time) + .Select(x => (DateTime?)x.create_time) + .FirstOrDefault(); + } + + private void AddRiskCheckLog(QuotaTrial quotaTrial, bool isOldRiskErrorSpecialApproval = false) + { + var td = DbContext.trade.FirstOrDefault(x => x.id == quotaTrial.TradeId); + var log = new trade_risk_check_log(); + if (td != null) + { + var client = DataCacheProvider.GetClientDataSource().GetData(td.ClientId); + log.client_number = client?.Number; + } + var worker = new IdWorker(1, 1); + log.id = worker.NextId(); + log.trial_result = (int)quotaTrial.TrialStatus; + log.client_name = quotaTrial.ClientName; + log.trader = td?.TraderName; + log.trade_number = quotaTrial.TradeNumber; + var riskWarning = string.Empty; + if (!string.IsNullOrWhiteSpace(quotaTrial.QuotaWarningDetails)) + { + riskWarning = "限额预警: " + quotaTrial.QuotaWarningDetails; + } + if (!string.IsNullOrWhiteSpace(quotaTrial.RiskWarningDetails)) + { + if (!string.IsNullOrWhiteSpace(riskWarning)) + { + riskWarning += Environment.NewLine; + } + riskWarning += "风险预警: " + quotaTrial.RiskWarningDetails; + } + log.risk_warning = riskWarning; + log.limit_warning = quotaTrial.QuotaCheckDetails; + log.remark = RiskCheckTriggerRemark; + if (isOldRiskErrorSpecialApproval) + { + log.remark += ";老风控Error允许交易特批"; + } + log.create_user = UserId; + log.create_time = DateTime.Now; + DbContext.trade_risk_check_log.Add(log); + DbContext.SaveChanges(); + } + /// - /// 按 quotaTrial.id 校验试算结果是否已超过配置时效 + /// 按 quotaTrial.id 校验最近一次触发风控的记录是否已超过配置时效 /// /// quotaTrial.id /// 有效时长(秒) @@ -4944,11 +5001,17 @@ namespace YLErp.Modules.RiskModule public bool IsQuotaTrialExpired(int trialDataId, int expireSeconds) { var quotaTrial = QueryQuotaTrial(trialDataId); - if (quotaTrial == null || !quotaTrial.OptDate.HasValue) + if (quotaTrial == null) { return true; } - return DateTime.Now - quotaTrial.OptDate.Value > TimeSpan.FromSeconds(expireSeconds); + var latestRiskCheckTriggerTime = QueryLatestRiskCheckTriggerTime(quotaTrial.TradeId); + var riskCheckTime = latestRiskCheckTriggerTime ?? quotaTrial.OptDate; + if (!riskCheckTime.HasValue) + { + return true; + } + return DateTime.Now - riskCheckTime.Value > TimeSpan.FromSeconds(expireSeconds); } /// diff --git a/YLErpDAL/Modules/RiskModule/TradeRiskCheckLogService.cs b/YLErpDAL/Modules/RiskModule/TradeRiskCheckLogService.cs index 96d6303e..687754dd 100644 --- a/YLErpDAL/Modules/RiskModule/TradeRiskCheckLogService.cs +++ b/YLErpDAL/Modules/RiskModule/TradeRiskCheckLogService.cs @@ -95,13 +95,13 @@ namespace YLErp.Modules.RiskModule switch ((QuotaTrialStatusEnum)item.trial_result) { case QuotaTrialStatusEnum.Warning: - exportModel.trial_result = "预警"; + exportModel.trial_result = "老风控触发特批"; break; case QuotaTrialStatusEnum.RiskWarning: - exportModel.trial_result = "风控需审批"; + exportModel.trial_result = "新风控需审批"; break; case QuotaTrialStatusEnum.Error: - exportModel.trial_result = "限额"; + exportModel.trial_result = "禁止"; break; default: exportModel.trial_result = "通过"; @@ -140,7 +140,20 @@ namespace YLErp.Modules.RiskModule log.client_name = quotaTrial.ClientName; log.trader = td?.TraderName; log.trade_number = quotaTrial.TradeNumber; - log.risk_warning = quotaTrial.QuotaWarningDetails; + var riskWarning = string.Empty; + if (!string.IsNullOrWhiteSpace(quotaTrial.QuotaWarningDetails)) + { + riskWarning = "限额预警: " + quotaTrial.QuotaWarningDetails; + } + if (!string.IsNullOrWhiteSpace(quotaTrial.RiskWarningDetails)) + { + if (!string.IsNullOrWhiteSpace(riskWarning)) + { + riskWarning += Environment.NewLine; + } + riskWarning += "风控预警: " + quotaTrial.RiskWarningDetails; + } + log.risk_warning = riskWarning; log.limit_warning = quotaTrial.QuotaCheckDetails; log.remark = quotaTrial.Remark; log.create_user = UserId; diff --git a/YLErpWeb/Controllers/RiskCheckLogController.cs b/YLErpWeb/Controllers/RiskCheckLogController.cs index 3cc1bb4d..d8c84206 100644 --- a/YLErpWeb/Controllers/RiskCheckLogController.cs +++ b/YLErpWeb/Controllers/RiskCheckLogController.cs @@ -1,4 +1,4 @@ -using YLErp.Modules.RiskModule; +using YLErp.Modules.RiskModule; using YLErp.Modules.RiskModule.Dto; using YLErp.Modules.SwapModule; @@ -41,8 +41,9 @@ namespace YLErp.Web.Controllers { new SelectListItem() {Value="-1",Text="全部",Selected=true}, new SelectListItem() {Value="0",Text="通过"}, - new SelectListItem() {Value="1",Text="预警"}, - new SelectListItem() {Value="2",Text="限额"} + new SelectListItem() {Value="1",Text="老风控触发特批"}, + new SelectListItem() {Value="2",Text="新风控需审批"}, + new SelectListItem() {Value="3",Text="禁止"} }; return list; } diff --git a/YLErpWeb/Controllers/tradeController.cs b/YLErpWeb/Controllers/tradeController.cs index 7a380c0c..0a162c3c 100644 --- a/YLErpWeb/Controllers/tradeController.cs +++ b/YLErpWeb/Controllers/tradeController.cs @@ -2467,8 +2467,8 @@ namespace YLErp.Web.Controllers var ignoreMoneyCheck = tradeidArr.Count() == 1 && additionalProcessing == tradeBLL.LackOfMoney; var ignoreRiskWarning = tradeidArr.Count() == 1 && additionalProcessing == tradeBLL.RiskWarningConfirm; var ignoreRiskRuleIdArr = StringHelper.ConvertCommaValuesToStringArray(ignoreRiskRuleIds); - // 老风控交易特批与新风控二次审批统一基于 quotaTrial 做超时校验,超时后清空放行标记并重新校验。 - // 仅 swapTradeView.js 会回传 trialDataId 并触发 RiskWarningConfirm,按 quotaTrial.OptDate 精确校验; + // 老风控交易特批与新风控二次审批统一基于 trialDataId 做超时校验,超时后清空放行标记并重新校验。 + // 当前仅 swapTradeView.js 会回传 trialDataId,按最近一次“触发风控”日志时间校验;查不到日志时回退 quotaTrial.OptDate。 // tradeview.js / tradeConfirmList.js / swapTradeConfirmList.js / quotaMonitor.js 只发 LackOfMoney,不传 trialDataId,不进入超时校验。 if (ignoreMoneyCheck || ignoreRiskWarning) { @@ -2535,6 +2535,27 @@ namespace YLErp.Web.Controllers } else { + // 特批放行只有在交易确认最终成功后才记录,避免前端点击特批但后端超时重检失败时误记通过。 + if (trialDataId.HasValue) + { + var decision = string.Empty; + if (ignoreRiskWarning && ignoreMoneyCheck) + { + decision = "新风控特批、老风控交易特批通过"; + } + else if (ignoreRiskWarning) + { + decision = "新风控特批通过"; + } + else if (ignoreMoneyCheck) + { + decision = "老风控交易特批通过"; + } + if (!string.IsNullOrWhiteSpace(decision)) + { + new TradeRiskCheckLogService(CurUser).AddWarningDecisionLog(trialDataId.Value, decision); + } + } var successMsg = string.IsNullOrWhiteSpace(result.tipMsg) ? "操作完成" : result.tipMsg; if (result.changeConfirmPaths?.Count > 0) { diff --git a/YLErpWeb/wwwroot/Scripts/app/riskchecklog/riskchecklog.js b/YLErpWeb/wwwroot/Scripts/app/riskchecklog/riskchecklog.js index 04c77a28..11d3ec49 100644 --- a/YLErpWeb/wwwroot/Scripts/app/riskchecklog/riskchecklog.js +++ b/YLErpWeb/wwwroot/Scripts/app/riskchecklog/riskchecklog.js @@ -1,4 +1,4 @@ -var query_data = {}; +var query_data = {}; const colModelGrid = function () { function dateFmt(cellValue) { @@ -15,8 +15,9 @@ const colModelGrid = function () { name: 'trial_result', label: '预警结果', index: 'trial_result', width: 80, formatter(cellValue) { if (cellValue === 0) return '通过'; - if (cellValue === 1) return '预警'; - if (cellValue === 2) return '限额'; + if (cellValue === 1) return '老风控触发特批'; + if (cellValue === 2) return '新风控需审批'; + if (cellValue === 3) return '禁止'; return cellValue; } }, @@ -45,7 +46,7 @@ var g_grid = {}; $(function () { $(".datepicker").datepicker({ changeMonth: true, changeYear: true, showButtonPanel: true, showOtherMonths: true, selectOtherMonths: true }); - $("#TrialResult").val([1, 2]); + $("#TrialResult").val([1, 2, 3]); $("#TrialResult").selectpicker('refresh'); PostData = { TrialResults: $("#TrialResult").val(), diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeView.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeView.js index 7b832f3c..d0759efc 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeView.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeView.js @@ -119,16 +119,6 @@ var confirmFunc = function (id, additionalProcessing, ignoreRiskRuleIds, trialDa } 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: "提示", @@ -145,11 +135,6 @@ var confirmFunc = function (id, additionalProcessing, ignoreRiskRuleIds, trialDa if (!saveQuotaTrial(obj)) { return; } - if (isRiskWarningConfirm) { - if (!saveRiskWarningDecisionLog(obj.Data.id, "确认通过")) { - return; - } - } if (isOldRiskSpecialApproval) { obj.Data.Remark = (obj.Data.Remark || "").trim(); } @@ -157,11 +142,6 @@ var confirmFunc = function (id, additionalProcessing, ignoreRiskRuleIds, trialDa confirmFunc(id, additionalProcessingType, currentIgnoreRiskRuleIds, currentTrialDataId); }, 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(); } From d9002e8d36a2a6870a8c887b010d6d34ec145324 Mon Sep 17 00:00:00 2001 From: ruisu Date: Fri, 17 Jul 2026 09:27:25 +0800 Subject: [PATCH 2/7] =?UTF-8?q?feat:=E4=BF=AE=E6=94=B9=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Modules/RiskEngine/RiskEngineService.cs | 20 +- YLErpDAL/Modules/RiskEngine/测试用例.md | 1754 +++++++++++++++++ 2 files changed, 1764 insertions(+), 10 deletions(-) create mode 100644 YLErpDAL/Modules/RiskEngine/测试用例.md diff --git a/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs b/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs index 45d4432d..40942f70 100644 --- a/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs +++ b/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs @@ -706,8 +706,8 @@ namespace YLErp.Modules.RiskEngine //{ // Id = 1000012, // RuleName = "债券类净价偏离(本地)", - // RuleText = "取值字段:通过 DbContext.swap_position 按 TradeId 取 PosiNetNoFeePrice,对应债券类标的期初交割净价,库内为 1 左右原值;通过 DbContext.china_bond_valuation 按当前交易标的和交易日前日期取上一收盘日 net_price,库内为 100 左右报价。计算逻辑:按 ABS(PosiNetNoFeePrice×100-net_price) 计算绝对价差,价差大于 5 元时触发审批。", - // RuleExpr = "Math.Abs(DbContext.swap_position.First(p => p.SwapTradeId == TradeId).PosiNetNoFeePrice.Value * 100m - DbContext.china_bond_valuation.Where(v => v.bond_id == DbContext.trade.First(t => t.id == TradeId).UnderlyingCode && v.valuation_date < DbContext.trade.First(t => t.id == TradeId).TradeDate.Value.Date).OrderByDescending(v => v.credibility).ThenByDescending(v => v.valuation_date).First().net_price.Value) > 5m", + // RuleText = "取值字段:通过 DbContext.swap_position 按 TradeId 取 IsInitial=true、Invalid=false、PosiDirection=2 且有标的代码的浮动支付端 PosiNetNoFeePrice 和 UnderlyingCode,PosiNetNoFeePrice 对应债券类标的期初交割净价,库内为 1 左右原值;通过 DbContext.china_bond_valuation 按该浮动支付端标的和交易日前日期优先取 credibility=1 的上一收盘日 net_price,库内为 100 左右报价。计算逻辑:按 ABS(PosiNetNoFeePrice×100-net_price) 计算绝对价差,价差大于 5 元时触发审批。", + // RuleExpr = "Math.Abs(DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection == 2 && !string.IsNullOrEmpty(p.UnderlyingCode)).PosiNetNoFeePrice.Value * 100m - DbContext.china_bond_valuation.Where(v => v.bond_id == DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection == 2 && !string.IsNullOrEmpty(p.UnderlyingCode)).UnderlyingCode && v.valuation_date < DbContext.trade.First(t => t.id == TradeId).TradeDate.Value.Date).OrderBy(v => v.credibility).ThenByDescending(v => v.valuation_date).First().net_price.Value) > 5m", // Version = 1, // Status = RiskRuleStatus.Active, // OptId = 0, @@ -722,8 +722,8 @@ namespace YLErp.Modules.RiskEngine //{ // Id = 1000013, // RuleName = "债券类收益率偏离(本地)", - // RuleText = "取值字段:通过 DbContext.swap_position 按 TradeId 取 InitYtm,对应债券类标的期初成交收益率,库内为原值;通过 DbContext.china_bond_valuation 按当前交易标的和交易日前日期取上一收盘日 yield,库内为 1.5 到 2.2 左右百分数。计算逻辑:按 ABS(InitYtm×100-yield) 计算收益率绝对差,差值大于 1 时触发审批。", - // RuleExpr = "Math.Abs(DbContext.swap_position.First(p => p.SwapTradeId == TradeId).InitYtm.Value * 100m - DbContext.china_bond_valuation.Where(v => v.bond_id == DbContext.trade.First(t => t.id == TradeId).UnderlyingCode && v.valuation_date < DbContext.trade.First(t => t.id == TradeId).TradeDate.Value.Date).OrderByDescending(v => v.credibility).ThenByDescending(v => v.valuation_date).First().yield.Value) > 1m", + // RuleText = "取值字段:通过 DbContext.swap_position 按 TradeId 取 IsInitial=true、Invalid=false、PosiDirection=2 且有标的代码的浮动支付端 InitYtm 和 UnderlyingCode,InitYtm 对应债券类标的期初成交收益率,库内为原值;通过 DbContext.china_bond_valuation 按该浮动支付端标的和交易日前日期优先取 credibility=1 的上一收盘日 yield,库内为 1.5 到 2.2 左右百分数。计算逻辑:按 ABS(InitYtm×100-yield) 计算收益率绝对差,差值大于 1 时触发审批。", + // RuleExpr = "Math.Abs(DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection == 2 && !string.IsNullOrEmpty(p.UnderlyingCode)).InitYtm.Value * 100m - DbContext.china_bond_valuation.Where(v => v.bond_id == DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection == 2 && !string.IsNullOrEmpty(p.UnderlyingCode)).UnderlyingCode && v.valuation_date < DbContext.trade.First(t => t.id == TradeId).TradeDate.Value.Date).OrderBy(v => v.credibility).ThenByDescending(v => v.valuation_date).First().yield.Value) > 1m", // Version = 1, // Status = RiskRuleStatus.Active, // OptId = 0, @@ -738,8 +738,8 @@ namespace YLErp.Modules.RiskEngine //{ // Id = 1000014, // RuleName = "非债券类价格偏离(本地)", - // RuleText = "取值字段:通过 DbContext.swap_position 按 TradeId 取 PosiGrossPrice,对应普通收益互换页面填写的期初标的价格,库内为 1 左右原值;通过 DbContext.eod_commodity_future_price 按当前交易标的和交易日前日期取上一日收盘价 ClosePrice。计算逻辑:按 ABS(PosiGrossPrice×100-ClosePrice) 计算绝对价差,价差大于 5 时触发审批。", - // RuleExpr = "Math.Abs(DbContext.swap_position.First(p => p.SwapTradeId == TradeId).PosiGrossPrice * 100m - Convert.ToDecimal(DbContext.eod_commodity_future_price.Where(e => e.UnderlyingCode == DbContext.trade.First(t => t.id == TradeId).UnderlyingCode && e.ValueDate < DbContext.trade.First(t => t.id == TradeId).TradeDate.Value.Date).OrderByDescending(e => e.ValueDate).First().ClosePrice)) > 5m", + // RuleText = "取值字段:通过 DbContext.swap_position 按 TradeId 取 IsInitial=true、Invalid=false、PosiDirection=2 且有标的代码的浮动支付端 PosiGrossPrice 和 UnderlyingCode,PosiGrossPrice 对应普通收益互换页面填写的期初标的价格,库内为 1 左右原值;通过 DbContext.eod_commodity_future_price 按该浮动支付端标的和交易日前日期取上一日收盘价 ClosePrice。注意:eod_commodity_future_price 模型属性 UnderlyingCode 实际映射数据库列 FutureContractId,数据库排查时应使用 FutureContractId 与 swap_position.UnderlyingCode 关联。计算逻辑:按 ABS(PosiGrossPrice×100-ClosePrice) 计算绝对价差,价差大于 5 时触发审批。", + // RuleExpr = "Math.Abs(DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection == 2 && !string.IsNullOrEmpty(p.UnderlyingCode)).PosiGrossPrice * 100m - Convert.ToDecimal(DbContext.eod_commodity_future_price.Where(e => e.UnderlyingCode == DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection == 2 && !string.IsNullOrEmpty(p.UnderlyingCode)).UnderlyingCode && e.ValueDate < DbContext.trade.First(t => t.id == TradeId).TradeDate.Value.Date).OrderByDescending(e => e.ValueDate).First().ClosePrice)) > 5m", // Version = 1, // Status = RiskRuleStatus.Active, // OptId = 0, @@ -754,8 +754,8 @@ namespace YLErp.Modules.RiskEngine //{ // Id = 1000015, // RuleName = "单一交易对手累计标的数量超阈值(本地)", - // RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易对手 ClientId,再查询同一交易对手存续/审批中交易对应的 swap_position.UnderlyingCode 去重数量。计算逻辑:同一交易对手累计标的数量超过 10 个时触发审批。", - // RuleExpr= DbContext.swap_position.Where(p => !string.IsNullOrEmpty(p.UnderlyingCode) && p.IsInitial && !p.Invalid && DbContext.trade.Any(t => t.id == p.SwapTradeId && t.ValidState != "InValid" && t.ClientId == DbContext.trade.First(x => x.id == TradeId).ClientId && t.ParentTradeId == 0 && (ConsTrade.NeedMarginTradeStatusList.Contains(t.TradeStatus) || t.TradeStatus == "审批中"))).Select(p => p.UnderlyingCode).Distinct().Count() > 10 + // RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易对手 ClientId,再查询同一交易对手有效交易对应的实时存续持仓 swap_position.UnderlyingCode 去重数量。实时存续持仓口径:IsInitial=false、PosiQuantity>0、Invalid=false、PosiDirection>0 且 UnderlyingCode 非空。计算逻辑:同一交易对手累计标的数量超过 10 个时触发审批。", + // RuleExpr= DbContext.swap_position.Where(p => !string.IsNullOrEmpty(p.UnderlyingCode) && !p.IsInitial && p.PosiQuantity > 0 && !p.Invalid && p.PosiDirection > 0 && DbContext.trade.Any(t => t.id == p.SwapTradeId && t.ValidState != "InValid" && t.ClientId == DbContext.trade.First(x => x.id == TradeId).ClientId)).Select(p => p.UnderlyingCode).Distinct().Count() > 10 // Version = 1, // Status = RiskRuleStatus.Active, // OptId = 0, @@ -770,8 +770,8 @@ namespace YLErp.Modules.RiskEngine //{ // Id = 1000016, // RuleName = "多头支付固定端利率偏离(本地)", - // RuleText = "取值字段:通过 DbContext.swap_position 按 TradeId 取利息端支付方向记录的 InterestRateDefault,对应互换交易-利息端-利率文本框数值;通过 DbContext.eod_commodity_future_price 按 FutureContractId=FR007(模型属性 UnderlyingCode)和交易日前日期取最近一条 ClosePrice。计算逻辑:按 ABS(InterestRateDefault-FR007) 计算利率差,差值小于 5% 时触发审批。", - // RuleExpr = "Math.Abs(DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.InterestDirection == 2).InterestRateDefault - Convert.ToDecimal(DbContext.eod_commodity_future_price.Where(e => e.UnderlyingCode == "FR007" && e.ValueDate < DbContext.trade.First(t => t.id == TradeId).TradeDate.Value.Date).OrderByDescending(e => e.ValueDate).First().ClosePrice)) < 0.05m", + // RuleText = "取值字段:通过 DbContext.swap_position 按 TradeId 取利息端收入固定利息方向记录的 InterestRateDefault。InterestRateDefault 只代表利率文本框中 + 号后的点差,不包含 FR007 基准利率,库内为小数原值,界面按百分比显示。计算逻辑:按 ABS(InterestRateDefault×100) 计算点差百分比绝对值,绝对值小于 5 时触发审批。", + // RuleExpr = "Math.Abs(DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.InterestDirection == 1).InterestRateDefault * 100m) < 5m", // Version = 1, // Status = RiskRuleStatus.Active, // OptId = 0, diff --git a/YLErpDAL/Modules/RiskEngine/测试用例.md b/YLErpDAL/Modules/RiskEngine/测试用例.md new file mode 100644 index 00000000..00050f5c --- /dev/null +++ b/YLErpDAL/Modules/RiskEngine/测试用例.md @@ -0,0 +1,1754 @@ +## 风控测试用例 + +### 1. 测试范围 + +覆盖交易确认链路中老风控与新风控的组合结果,重点验证最终返回给前端的处理类型是否符合优先级。 + +本用例主要覆盖: + +- 老风控通过 +- 老风控触发特批 +- 老风控绝对失败 +- 新风控不触发 +- 新风控提示 ShowTip +- 新风控需审批 NeedApproval +- 新风控禁止 Block +- 新风控多策略叠加 +- 老风控特批二次确认 +- 新风控审批二次确认 +- 新风控关闭后的 master 兼容行为 + +### 2. 状态优先级 + +最终风控状态按以下优先级处理: + +```text +Error > Warning > RiskWarning > Success +``` + +含义: + +```text +Error:绝对失败,不允许审批或特批放行,只展示详情。 +Warning:老风控触发 master 原有交易特批/二次处理入口,前端 code/typecode 可仍为 QuotaTrialError。 +RiskWarning:新风控需审批,需要前端二次确认 RiskWarningConfirm。 +Success:风控通过,可能携带 ShowTip 提示。 +``` + +### 3. 前端返回约定 + +#### 3.1 普通成功 + +```text +success = true +msg = 操作完成 +obj.TrialDataId 可有可无 +``` + +前端表现: + +```text +关闭交易确认窗口,刷新列表。 +``` + +#### 3.2 成功但有提示 + +```text +success = true +msg != 操作完成 +obj.TrialDataId = quotaTrial.id +``` + +前端表现: + +```text +交易确认成功后,打开 /trade/showQuotaTrial?id={TrialDataId}。 +按钮:关闭。 +``` + +#### 3.3 老风控交易特批入口 + +```text +success = true +obj.proccessType = AdditionalProcessing +obj.source = OldRiskQuotaTrialError +obj.typecode = QuotaTrialError +obj.type = LackOfMoney 或空字符串 +obj.TrialDataId = quotaTrial.id +``` + +前端表现: + +```text +打开 /trade/showQuotaTrial?id={TrialDataId}。 +如果 obj.type = LackOfMoney,显示交易特批按钮。 +如果 obj.type = 空字符串,只展示详情,不显示交易特批按钮。 +``` + +`obj.type` 判断规则应延用 master: + +```text +公司为天风 或 SpecialOperateForTrade = 1:type = LackOfMoney +否则:type = 空字符串 +``` + +#### 3.4 新风控需审批 + +```text +success = true +obj.proccessType = AdditionalProcessing +obj.source = RiskWarning +obj.type = RiskWarningConfirm +obj.typecode = RiskWarning +obj.ignoreRiskRuleIds = 本次命中的审批/提示规则ID +obj.TrialDataId = quotaTrial.id +``` + +前端表现: + +```text +打开 /trade/showQuotaTrial?id={TrialDataId}。 +按钮:交易特批、取消。 +二次确认时回传 additionalProcessing = RiskWarningConfirm。 +``` + +#### 3.5 绝对失败 + +```text +success = true +obj.proccessType = QuotaTrialError +obj.typecode = QuotaTrialError +obj.TrialDataId = quotaTrial.id +``` + +前端表现: + +```text +打开 /trade/showQuotaTrial?id={TrialDataId}。 +按钮:关闭。 +不允许交易特批或新风控审批。 +``` + +### 4. 老风控分类约定 + +#### 4.1 老风控通过 + +```text +OldRiskTrialStatus = Success +OldRiskNeedSpecialApproval = false +``` + +#### 4.2 老风控触发 master 特批/二次处理入口 + +```text +OldRiskTrialStatus != Success +OldRiskNeedSpecialApproval = true +``` + +说明: + +```text +这里必须覆盖 Warning 和 Error。 +不能只把 Warning 当成可进入 master 特批入口。 +master 中老风控非 Success 会进入 AdditionalProcessing,是否显示交易特批按钮由 type 决定。 +``` + +#### 4.3 老风控绝对失败 + +```text +OldRiskNeedSpecialApproval = false +RetCode = QuotaTrialError +``` + +说明: + +```text +如果当前业务完全延用 master,则老风控自身通常不单独区分这类场景。 +如果未来引入老风控绝对失败,需要有明确标志,不能仅依赖 TrialStatus = Error 判断。 +``` + +### 5. 新风控分类约定 + +新风控策略可以单独触发,也可以叠加触发。 + +```text +无触发 +ShowTip +NeedApproval +Block +ShowTip + NeedApproval +ShowTip + Block +NeedApproval + Block +ShowTip + NeedApproval + Block +``` + +处理优先级: + +```text +Block 优先于 NeedApproval。 +NeedApproval 优先于 ShowTip。 +ShowTip 只有在最终成交成功时才作为成功后提示。 +``` + +--- + +## 6. 老风控 Success 组合用例 + +### 用例 001:老风控 Success + 新风控无触发 + +前置条件: + +```text +老风控:Success +新风控:无 Block、无 NeedApproval、无 ShowTip +``` + +预期结果: + +```text +最终状态:Success +前端返回:普通成功 +前端表现:直接关闭/刷新 +``` + +不得出现: + +```text +AdditionalProcessing +QuotaTrialError +RiskWarningConfirm +ShowTip 弹窗 +``` + +### 用例 002:老风控 Success + 新风控 ShowTip + +前置条件: + +```text +老风控:Success +新风控:ShowTip +``` + +预期结果: + +```text +最终状态:Success +前端返回:成功但有提示 +前端表现:交易确认成功后,弹 showQuotaTrial 详情,只能关闭 +``` + +不得出现: + +```text +AdditionalProcessing +RiskWarningConfirm +QuotaTrialError +``` + +### 用例 003:老风控 Success + 新风控 NeedApproval + +前置条件: + +```text +老风控:Success +新风控:NeedApproval +``` + +预期结果: + +```text +最终状态:RiskWarning +RetCode:RiskWarning +OldRiskNeedSpecialApproval:false +前端返回:新风控需审批 +source:RiskWarning +type:RiskWarningConfirm +typecode:RiskWarning +ignoreRiskRuleIds:包含本次需审批规则ID +``` + +前端表现: + +```text +打开 showQuotaTrial。 +显示新风控审批按钮。 +``` + +不得出现: + +```text +OldRiskQuotaTrialError +QuotaTrialError 只关闭分支 +普通成功 +``` + +### 用例 004:老风控 Success + 新风控 Block + +前置条件: + +```text +老风控:Success +新风控:Block +``` + +预期结果: + +```text +最终状态:Error +RetCode:QuotaTrialError +OldRiskNeedSpecialApproval:false +前端返回:绝对失败 +前端表现:弹 showQuotaTrial,只能关闭 +``` + +不得出现: + +```text +RiskWarningConfirm +OldRiskQuotaTrialError +交易特批按钮 +普通成功 +``` + +### 用例 005:老风控 Success + 新风控 ShowTip + NeedApproval + +前置条件: + +```text +老风控:Success +新风控:ShowTip + NeedApproval +``` + +预期结果: + +```text +最终状态:RiskWarning +RetCode:RiskWarning +前端返回:新风控需审批 +source:RiskWarning +type:RiskWarningConfirm +typecode:RiskWarning +ignoreRiskRuleIds:包含本次需审批规则ID;当前实现也会把已展示的提示规则ID纳入 processedRiskRuleIds +``` + +说明: + +```text +NeedApproval 优先于 ShowTip。 +交易尚未确认成功,不应走成功后 ShowTip。 +``` + +不得出现: + +```text +成功后 ShowTip 弹窗 +QuotaTrialError +OldRiskQuotaTrialError +``` + +### 用例 006:老风控 Success + 新风控 ShowTip + Block + +前置条件: + +```text +老风控:Success +新风控:ShowTip + Block +``` + +预期结果: + +```text +最终状态:Error +RetCode:QuotaTrialError +前端返回:绝对失败 +前端表现:弹 showQuotaTrial,只能关闭 +``` + +说明: + +```text +Block 优先于 ShowTip。 +ShowTip 信息可以进入详情,但不能导致成功后提示。 +``` + +不得出现: + +```text +成功后 ShowTip 弹窗 +RiskWarningConfirm +OldRiskQuotaTrialError +``` + +### 用例 007:老风控 Success + 新风控 NeedApproval + Block + +前置条件: + +```text +老风控:Success +新风控:NeedApproval + Block +``` + +预期结果: + +```text +最终状态:Error +RetCode:QuotaTrialError +前端返回:绝对失败 +前端表现:弹 showQuotaTrial,只能关闭 +``` + +说明: + +```text +Block 优先于 NeedApproval。 +即使命中需审批规则,也不允许审批放行。 +``` + +不得出现: + +```text +RiskWarningConfirm +普通成功 +交易特批按钮 +``` + +### 用例 008:老风控 Success + 新风控 ShowTip + NeedApproval + Block + +前置条件: + +```text +老风控:Success +新风控:ShowTip + NeedApproval + Block +``` + +预期结果: + +```text +最终状态:Error +RetCode:QuotaTrialError +前端返回:绝对失败 +前端表现:弹 showQuotaTrial,只能关闭 +``` + +说明: + +```text +Block 最高优先级。 +NeedApproval 和 ShowTip 只能作为详情展示,不应改变失败结果。 +``` + +不得出现: + +```text +RiskWarningConfirm +成功后 ShowTip +OldRiskQuotaTrialError +``` + +--- + +## 7. 老风控触发特批组合用例 + +说明: + +```text +老风控触发特批表示 OldRiskNeedSpecialApproval = true。 +按 master 兼容逻辑,OldRiskTrialStatus != Success 即应进入该分支。 +是否显示交易特批按钮仍由 type 决定。 +``` + +### 用例 009:老风控触发特批 + 新风控无触发 + +前置条件: + +```text +老风控:触发特批 +新风控:无 Block、无 NeedApproval、无 ShowTip +``` + +预期结果: + +```text +最终优先级:Warning(老风控特批) +RetCode:QuotaTrialError +OldRiskNeedSpecialApproval:true +前端返回:老风控交易特批入口 +source:OldRiskQuotaTrialError +typecode:QuotaTrialError +type:LackOfMoney 或空字符串 +``` + +前端表现: + +```text +如果 type = LackOfMoney:显示交易特批按钮。 +如果 type = 空字符串:弹详情,但不显示交易特批按钮。 +``` + +### 用例 010:老风控触发特批 + 新风控 ShowTip + +前置条件: + +```text +老风控:触发特批 +新风控:ShowTip +``` + +预期结果: + +```text +最终优先级:Warning(老风控特批) +前端返回:老风控交易特批入口 +source:OldRiskQuotaTrialError +typecode:QuotaTrialError +``` + +说明: + +```text +老风控特批优先于成功提示。 +交易尚未确认成功,不能走成功后 ShowTip。 +``` + +不得出现: + +```text +成功后 ShowTip 弹窗 +RiskWarningConfirm +QuotaTrialError 只关闭分支 +``` + +### 用例 011:老风控触发特批 + 新风控 NeedApproval + +前置条件: + +```text +老风控:触发特批 +新风控:NeedApproval +``` + +预期结果: + +```text +最终优先级:Warning(老风控特批) +前端返回:老风控交易特批入口 +source:OldRiskQuotaTrialError +typecode:QuotaTrialError +``` + +说明: + +```text +老风控特批优先于新风控需审批。 +不能返回 RiskWarningConfirm。 +``` + +不得出现: + +```text +source = RiskWarning +type = RiskWarningConfirm +``` + +### 用例 012:老风控触发特批 + 新风控 Block + +前置条件: + +```text +老风控:触发特批 +新风控:Block +``` + +预期结果: + +```text +最终优先级:Error +前端返回:绝对失败 +前端表现:只展示详情,只能关闭 +``` + +说明: + +```text +新风控 Block 是绝对失败,优先级高于老风控特批。 +``` + +不得出现: + +```text +AdditionalProcessing + OldRiskQuotaTrialError +交易特批按钮 +RiskWarningConfirm +``` + +风险提示: + +```text +当前实现需要有明确的新风控 Block 标志。 +否则 Controller 只看到 RetCode = QuotaTrialError 且 OldRiskNeedSpecialApproval = true 时,可能误走老风控特批分支。 +``` + +### 用例 013:老风控触发特批 + 新风控 ShowTip + NeedApproval + +前置条件: + +```text +老风控:触发特批 +新风控:ShowTip + NeedApproval +``` + +预期结果: + +```text +最终优先级:Warning(老风控特批) +前端返回:老风控交易特批入口 +source:OldRiskQuotaTrialError +typecode:QuotaTrialError +``` + +说明: + +```text +老风控特批优先于新风控 NeedApproval。 +ShowTip 只进入详情,不走成功提示。 +``` + +不得出现: + +```text +RiskWarningConfirm +成功后 ShowTip +QuotaTrialError 只关闭分支 +``` + +### 用例 014:老风控触发特批 + 新风控 ShowTip + Block + +前置条件: + +```text +老风控:触发特批 +新风控:ShowTip + Block +``` + +预期结果: + +```text +最终优先级:Error +前端返回:绝对失败 +前端表现:只展示详情,只能关闭 +``` + +说明: + +```text +Block 优先于老风控特批和 ShowTip。 +``` + +不得出现: + +```text +AdditionalProcessing +RiskWarningConfirm +成功后 ShowTip +``` + +### 用例 015:老风控触发特批 + 新风控 NeedApproval + Block + +前置条件: + +```text +老风控:触发特批 +新风控:NeedApproval + Block +``` + +预期结果: + +```text +最终优先级:Error +前端返回:绝对失败 +前端表现:只展示详情,只能关闭 +``` + +说明: + +```text +Block 优先级最高。 +NeedApproval 不应被允许审批放行。 +老风控特批也不应覆盖 Block。 +``` + +不得出现: + +```text +OldRiskQuotaTrialError +RiskWarningConfirm +交易特批按钮 +``` + +### 用例 016:老风控触发特批 + 新风控 ShowTip + NeedApproval + Block + +前置条件: + +```text +老风控:触发特批 +新风控:ShowTip + NeedApproval + Block +``` + +预期结果: + +```text +最终优先级:Error +前端返回:绝对失败 +前端表现:只展示详情,只能关闭 +``` + +说明: + +```text +Block 优先级最高。 +其余策略只能进入详情展示。 +``` + +不得出现: + +```text +AdditionalProcessing +RiskWarningConfirm +成功后 ShowTip +``` + +--- + +## 8. 老风控绝对失败组合用例 + +说明: + +```text +老风控绝对失败表示 RetCode = QuotaTrialError 且 OldRiskNeedSpecialApproval = false。 +如果当前业务完全延用 master,则老风控自身通常不单独区分此类场景。 +如果未来需要支持,必须有明确标志,不能仅用 TrialStatus = Error 判断。 +``` + +### 用例 017:老风控绝对失败 + 新风控无触发 + +前置条件: + +```text +老风控:绝对失败 +新风控:无触发 +``` + +预期结果: + +```text +最终优先级:Error +RetCode:QuotaTrialError +OldRiskNeedSpecialApproval:false +前端返回:绝对失败 +前端表现:只展示详情,只能关闭 +``` + +不得出现: + +```text +AdditionalProcessing +交易特批按钮 +RiskWarningConfirm +普通成功 +``` + +### 用例 018:老风控绝对失败 + 新风控 ShowTip + +前置条件: + +```text +老风控:绝对失败 +新风控:ShowTip +``` + +预期结果: + +```text +最终优先级:Error +前端返回:绝对失败 +``` + +说明: + +```text +老风控绝对失败优先于 ShowTip。 +``` + +不得出现: + +```text +成功后 ShowTip 弹窗 +AdditionalProcessing +RiskWarningConfirm +``` + +### 用例 019:老风控绝对失败 + 新风控 NeedApproval + +前置条件: + +```text +老风控:绝对失败 +新风控:NeedApproval +``` + +预期结果: + +```text +最终优先级:Error +前端返回:绝对失败 +``` + +说明: + +```text +老风控绝对失败优先于新风控需审批。 +``` + +不得出现: + +```text +RiskWarningConfirm +AdditionalProcessing +``` + +### 用例 020:老风控绝对失败 + 新风控 Block + +前置条件: + +```text +老风控:绝对失败 +新风控:Block +``` + +预期结果: + +```text +最终优先级:Error +前端返回:绝对失败 +``` + +说明: + +```text +老风控绝对失败和新风控 Block 都是 Error。 +``` + +### 用例 021:老风控绝对失败 + 新风控 ShowTip + NeedApproval + +前置条件: + +```text +老风控:绝对失败 +新风控:ShowTip + NeedApproval +``` + +预期结果: + +```text +最终优先级:Error +前端返回:绝对失败 +``` + +不得出现: + +```text +RiskWarningConfirm +成功后 ShowTip +AdditionalProcessing +``` + +### 用例 022:老风控绝对失败 + 新风控 ShowTip + Block + +前置条件: + +```text +老风控:绝对失败 +新风控:ShowTip + Block +``` + +预期结果: + +```text +最终优先级:Error +前端返回:绝对失败 +``` + +不得出现: + +```text +成功后 ShowTip +RiskWarningConfirm +AdditionalProcessing +``` + +### 用例 023:老风控绝对失败 + 新风控 NeedApproval + Block + +前置条件: + +```text +老风控:绝对失败 +新风控:NeedApproval + Block +``` + +预期结果: + +```text +最终优先级:Error +前端返回:绝对失败 +``` + +不得出现: + +```text +RiskWarningConfirm +AdditionalProcessing +``` + +### 用例 024:老风控绝对失败 + 新风控 ShowTip + NeedApproval + Block + +前置条件: + +```text +老风控:绝对失败 +新风控:ShowTip + NeedApproval + Block +``` + +预期结果: + +```text +最终优先级:Error +前端返回:绝对失败 +``` + +不得出现: + +```text +RiskWarningConfirm +AdditionalProcessing +成功后 ShowTip +``` + +--- + +## 9. 新风控关闭专项回归用例 + +### 用例 025:新风控关闭 + 老风控 Success + +前置条件: + +```text +新风控 RULE 全部非 Active +新风控 APPLICATION 全部非 Active +老风控:Success +``` + +预期结果: + +```text +最终状态:Success +前端返回:普通成功 +表现与 master 一致 +``` + +### 用例 026:新风控关闭 + 老风控 Warning + +前置条件: + +```text +新风控 RULE 全部非 Active +新风控 APPLICATION 全部非 Active +老风控:Warning +``` + +预期结果: + +```text +OldRiskNeedSpecialApproval = true +RetCode = QuotaTrialError +前端返回:老风控交易特批入口 +source = OldRiskQuotaTrialError +typecode = QuotaTrialError +type = LackOfMoney 或空字符串 +``` + +表现要求: + +```text +是否显示交易特批按钮与 master 一致。 +判断条件:天风 || SpecialOperateForTrade = 1。 +``` + +### 用例 027:新风控关闭 + 老风控 Error + +前置条件: + +```text +新风控 RULE 全部非 Active +新风控 APPLICATION 全部非 Active +老风控:Error +``` + +预期结果: + +```text +OldRiskNeedSpecialApproval = true +RetCode = QuotaTrialError +前端返回:老风控交易特批入口 +source = OldRiskQuotaTrialError +typecode = QuotaTrialError +type = LackOfMoney 或空字符串 +``` + +表现要求: + +```text +表现与 master 一致。 +不得因为 TrialStatus = Error 而走 QuotaTrialError 只关闭分支。 +``` + +--- + +## 10. 二次确认叠加用例 + +### 用例 028:老风控特批二次确认 + 新风控无触发 + +首次前置条件: + +```text +老风控:触发特批 +新风控:无触发 +``` + +首次预期返回: + +```text +AdditionalProcessing +source = OldRiskQuotaTrialError +type = LackOfMoney +``` + +二次请求: + +```text +additionalProcessing = LackOfMoney +``` + +二次预期: + +```text +ignoreMoneyCheck = true +成交成功 +历史记录包含:交易特批-确认交易 +``` + +### 用例 029:老风控特批二次确认 + 新风控 NeedApproval + +首次前置条件: + +```text +老风控:触发特批 +新风控:无触发 +``` + +二次确认前置条件: + +```text +新风控规则变为 NeedApproval +``` + +二次请求: + +```text +additionalProcessing = LackOfMoney +``` + +预期结果需要业务确认: + +```text +如果老风控特批后仍应跑新风控:应返回 RiskWarningConfirm。 +如果沿用当前 ignoreMoneyCheck 跳过 QuotaCheck:会直接成交。 +``` + +### 用例 030:老风控特批二次确认 + 新风控 Block + +首次前置条件: + +```text +老风控:触发特批 +新风控:无触发 +``` + +二次确认前置条件: + +```text +新风控规则变为 Block +``` + +二次请求: + +```text +additionalProcessing = LackOfMoney +``` + +预期结果需要业务确认: + +```text +如果老风控特批后仍应跑新风控:应返回 QuotaTrialError,只能关闭。 +如果沿用当前 ignoreMoneyCheck 跳过 QuotaCheck:会直接成交。 +``` + +### 用例 031:新风控需审批二次确认 + 老风控仍 Success + 无新增新风控规则 + +首次前置条件: + +```text +老风控:Success +新风控:NeedApproval,命中规则 A +``` + +首次预期返回: + +```text +RiskWarningConfirm +ignoreRiskRuleIds = A +``` + +二次请求: + +```text +additionalProcessing = RiskWarningConfirm +ignoreRiskRuleIds = A +``` + +二次预期: + +```text +成交成功 +历史记录包含:风控预警确认-确认交易 +保存确认通过日志 +``` + +### 用例 032:新风控需审批二次确认 + 老风控变为触发特批 + +首次前置条件: + +```text +老风控:Success +新风控:NeedApproval +``` + +二次确认前置条件: + +```text +老风控变为非 Success +``` + +二次请求: + +```text +additionalProcessing = RiskWarningConfirm +``` + +二次预期: + +```text +老风控特批优先于新风控审批放行 +返回 AdditionalProcessing +source = OldRiskQuotaTrialError +typecode = QuotaTrialError +不应直接成交 +``` + +### 用例 033:新风控需审批二次确认 + 老风控绝对失败 + +首次前置条件: + +```text +老风控:Success +新风控:NeedApproval +``` + +二次确认前置条件: + +```text +老风控变为绝对失败 +``` + +二次请求: + +```text +additionalProcessing = RiskWarningConfirm +``` + +二次预期: + +```text +Error 优先 +返回 QuotaTrialError +只展示详情 +不应直接成交 +``` + +### 用例 034:新风控需审批二次确认 + 新增 NeedApproval 规则 + +首次前置条件: + +```text +新风控命中审批规则 A +``` + +二次确认前置条件: + +```text +仍命中 A,同时新增命中审批规则 B +``` + +二次请求: + +```text +additionalProcessing = RiskWarningConfirm +ignoreRiskRuleIds = A +``` + +二次预期: + +```text +规则 A 被忽略 +规则 B 未被忽略 +继续返回 RiskWarningConfirm +ignoreRiskRuleIds 应包含 B +不成交 +``` + +### 用例 035:新风控需审批二次确认 + 新增 Block 规则 + +首次前置条件: + +```text +新风控命中审批规则 A +``` + +二次确认前置条件: + +```text +新增命中新风控 Block 规则 C +``` + +二次请求: + +```text +additionalProcessing = RiskWarningConfirm +ignoreRiskRuleIds = A +``` + +二次预期: + +```text +Block 优先 +返回 QuotaTrialError +只展示详情 +不成交 +``` + +### 用例 036:新风控需审批二次确认 + 新增 ShowTip 规则 + +首次前置条件: + +```text +新风控命中审批规则 A +``` + +二次确认前置条件: + +```text +规则 A 被忽略 +新增 ShowTip 规则 D +无老风控失败 +无新增 NeedApproval +无 Block +``` + +二次请求: + +```text +additionalProcessing = RiskWarningConfirm +ignoreRiskRuleIds = A +``` + +二次预期: + +```text +成交成功 +成功后展示 ShowTip +``` + +--- + +## 11. 高风险回归用例 + +优先执行以下用例: + +```text +用例 027:新风控关闭 + 老风控 Error +用例 012:老风控触发特批 + 新风控 Block +用例 015:老风控触发特批 + 新风控 NeedApproval + Block +用例 030:老风控特批二次确认 + 新风控 Block +用例 032:新风控审批二次确认 + 老风控变为触发特批 +用例 035:新风控审批二次确认 + 新增 Block +``` + +这些用例重点验证: + +```text +老风控 Error 不应被误判为不可进入 master 特批入口。 +新风控 Block 不应被老风控特批覆盖。 +二次确认时不应跳过必须执行的新风控或老风控校验。 +新风控审批二次确认后,如果老风控状态变化,不应直接成交。 +``` + +--- + +## 12. 规则 12 查询结果排查 SQL + +规则 12:债券类净价偏离(本地)。用于核对浮动支付端期初交割净价和交易日前中债估值净价;同一标的同一估值日存在不同可信度时,优先取 credibility=1。 + +取数流程: + +```text +1. 根据 TradeId 查 swap_position。 +2. 限定 IsInitial=1、Invalid=0、PosiDirection=2、UnderlyingCode 非空,取浮动支付端。 +3. 从浮动支付端取 PosiNetNoFeePrice 和 UnderlyingCode。 +4. 用 swap_position.UnderlyingCode 关联 china_bond_valuation.bond_id。 +5. 限定 valuation_date < trade.TradeDate,取交易日前估值。 +6. 按 credibility ASC、valuation_date DESC 排序,优先 credibility=1,再取最近估值日。 +7. 计算 ABS(PosiNetNoFeePrice * 100 - net_price),大于 5 则命中。 +``` + +规则公式: + +```text +ABS(浮动支付端.PosiNetNoFeePrice * 100 - 上一收盘日中债估值.net_price) > 5 +``` + +注释规则定义: + +```csharp +//rules.Add(new RiskRule +//{ +// Id = 1000012, +// RuleName = "债券类净价偏离(本地)", +// RuleText = "取值字段:通过 DbContext.swap_position 按 TradeId 取 IsInitial=true、Invalid=false、PosiDirection=2 且有标的代码的浮动支付端 PosiNetNoFeePrice 和 UnderlyingCode,PosiNetNoFeePrice 对应债券类标的期初交割净价,库内为 1 左右原值;通过 DbContext.china_bond_valuation 按该浮动支付端标的和交易日前日期优先取 credibility=1 的上一收盘日 net_price,库内为 100 左右报价。计算逻辑:按 ABS(PosiNetNoFeePrice×100-net_price) 计算绝对价差,价差大于 5 元时触发审批。", +// RuleExpr = "Math.Abs(DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection == 2 && !string.IsNullOrEmpty(p.UnderlyingCode)).PosiNetNoFeePrice.Value * 100m - DbContext.china_bond_valuation.Where(v => v.bond_id == DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection == 2 && !string.IsNullOrEmpty(p.UnderlyingCode)).UnderlyingCode && v.valuation_date < DbContext.trade.First(t => t.id == TradeId).TradeDate.Value.Date).OrderBy(v => v.credibility).ThenByDescending(v => v.valuation_date).First().net_price.Value) > 5m", +// Version = 1, +// Status = RiskRuleStatus.Active, +// OptId = 0, +// OptName = "system", +// OptDate = DateTime.Now, +// UpdateOptId = 0, +// UpdateOptName = "system", +// UpdateDate = DateTime.Now +//}); +``` + +```sql +SET @TradeId = 3001699; + +SELECT + t.id AS TradeId, + t.TradeDate, + + sp.id AS SwapPositionId, + sp.SwapTradeId, + sp.IsInitial, + sp.Invalid, + sp.PosiDirection, + sp.UnderlyingCode, + sp.UnderlyingInstrumentType, + sp.PosiNetNoFeePrice, + sp.PosiNetNoFeePrice * 100 AS PosiNetNoFeePrice_100, + + bv.id AS BondValuationId, + bv.bond_id, + bv.valuation_date, + bv.credibility, + bv.net_price AS BondValuationNetPrice, + + ABS(sp.PosiNetNoFeePrice * 100 - bv.net_price) AS DiffAbs, + CASE + WHEN ABS(sp.PosiNetNoFeePrice * 100 - bv.net_price) > 5 THEN 1 + ELSE 0 + END AS IsGreaterThan5 +FROM trade t +INNER JOIN swap_position sp + ON sp.SwapTradeId = t.id + AND sp.IsInitial = 1 + AND sp.Invalid = 0 + AND sp.PosiDirection = 2 + AND sp.UnderlyingCode IS NOT NULL + AND sp.UnderlyingCode <> '' +LEFT JOIN china_bond_valuation bv + ON bv.bond_id = sp.UnderlyingCode + AND bv.valuation_date < DATE(t.TradeDate) +WHERE t.id = @TradeId +ORDER BY + bv.credibility ASC, + bv.valuation_date DESC +LIMIT 1; +``` + +--- + +## 13. 规则 13 查询结果排查 SQL + +规则 13:债券类收益率偏离(本地)。用于核对浮动支付端期初成交收益率和交易日前中债估值收益率;同一标的同一估值日存在不同可信度时,优先取 credibility=1。 + +取数流程: + +```text +1. 根据 TradeId 查 swap_position。 +2. 限定 IsInitial=1、Invalid=0、PosiDirection=2、UnderlyingCode 非空,取浮动支付端。 +3. 从浮动支付端取 InitYtm 和 UnderlyingCode。 +4. 用 swap_position.UnderlyingCode 关联 china_bond_valuation.bond_id。 +5. 限定 valuation_date < trade.TradeDate,取交易日前估值。 +6. 按 credibility ASC、valuation_date DESC 排序,优先 credibility=1,再取最近估值日。 +7. 计算 ABS(InitYtm * 100 - yield),大于 1 则命中。 +``` + +规则公式: + +```text +ABS(浮动支付端.InitYtm * 100 - 上一收盘日中债估值.yield) > 1 +``` + +注释规则定义: + +```csharp +//rules.Add(new RiskRule +//{ +// Id = 1000013, +// RuleName = "债券类收益率偏离(本地)", +// RuleText = "取值字段:通过 DbContext.swap_position 按 TradeId 取 IsInitial=true、Invalid=false、PosiDirection=2 且有标的代码的浮动支付端 InitYtm 和 UnderlyingCode,InitYtm 对应债券类标的期初成交收益率,库内为原值;通过 DbContext.china_bond_valuation 按该浮动支付端标的和交易日前日期优先取 credibility=1 的上一收盘日 yield,库内为 1.5 到 2.2 左右百分数。计算逻辑:按 ABS(InitYtm×100-yield) 计算收益率绝对差,差值大于 1 时触发审批。", +// RuleExpr = "Math.Abs(DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection == 2 && !string.IsNullOrEmpty(p.UnderlyingCode)).InitYtm.Value * 100m - DbContext.china_bond_valuation.Where(v => v.bond_id == DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection == 2 && !string.IsNullOrEmpty(p.UnderlyingCode)).UnderlyingCode && v.valuation_date < DbContext.trade.First(t => t.id == TradeId).TradeDate.Value.Date).OrderBy(v => v.credibility).ThenByDescending(v => v.valuation_date).First().yield.Value) > 1m", +// Version = 1, +// Status = RiskRuleStatus.Active, +// OptId = 0, +// OptName = "system", +// OptDate = DateTime.Now, +// UpdateOptId = 0, +// UpdateOptName = "system", +// UpdateDate = DateTime.Now +//}); +``` + +```sql +SET @TradeId = 3001699; + +SELECT + t.id AS TradeId, + t.TradeDate, + + sp.id AS SwapPositionId, + sp.SwapTradeId, + sp.IsInitial, + sp.Invalid, + sp.PosiDirection, + sp.UnderlyingCode, + sp.UnderlyingInstrumentType, + sp.InitYtm, + sp.InitYtm * 100 AS InitYtm_100, + + bv.id AS BondValuationId, + bv.bond_id, + bv.valuation_date, + bv.credibility, + bv.yield AS BondValuationYield, + + ABS(sp.InitYtm * 100 - bv.yield) AS DiffAbs, + CASE + WHEN ABS(sp.InitYtm * 100 - bv.yield) > 1 THEN 1 + ELSE 0 + END AS IsGreaterThan1 +FROM trade t +INNER JOIN swap_position sp + ON sp.SwapTradeId = t.id + AND sp.IsInitial = 1 + AND sp.Invalid = 0 + AND sp.PosiDirection = 2 + AND sp.UnderlyingCode IS NOT NULL + AND sp.UnderlyingCode <> '' +LEFT JOIN china_bond_valuation bv + ON bv.bond_id = sp.UnderlyingCode + AND bv.valuation_date < DATE(t.TradeDate) +WHERE t.id = @TradeId +ORDER BY + bv.credibility ASC, + bv.valuation_date DESC +LIMIT 1; +``` + +--- + +## 14. 规则 14 查询结果排查 SQL + +规则 14:非债券类价格偏离(本地)。用于核对普通收益互换页面填写的期初标的价格和交易日前上一日收盘价。 + +取数流程: + +```text +1. 根据 TradeId 查 trade。 +2. 根据 TradeId 查 swap_positionce od_commodity_flyingCode 实际映射数据库列 FutureContractId,数据库排查时看 FutureContractId。 +3. 限定 ValueDate < trade.TradeDate,取交易日前行情。 +5. 按 ValueDate DESC 排序,取最近一日收盘价。 +4. 计算 ABS(PosiGrossPrice * 100 - ClosePrice),大于 5 则命中。 +5`` +6 +规则公式: + +```text +ABS(浮动支付端.PosiGrossPrice * 100 - 上一日行情.ClosePrice) > 5 +``` + +注释规则定义: + +```csharp +//rules.Add(new RiskRule +//{ +// Id = 1000014, +// RuleName = "非债券类价格偏离(本地)", +// RuleText = "取值字段:通过 DbContext.swap_position 按 TradeId 取 IsInitial=true、Invalid=false、PosiDirection=2 且有标的代码的浮动支付端 PosiGrossPrice 和 UnderlyingCode,PosiGrossPrice 对应普通收益互换页面填写的期初标的价格,库内为 1 左右原值;通过 DbContext.eod_commodity_future_price 按该浮动支付端标的和交易日前日期取上一日收盘价 ClosePrice。注意:eod_commodity_future_price 模型属性 UnderlyingCode 实际映射数据库列 FutureContractId,数据库排查时应使用 FutureContractId 与 swap_position.UnderlyingCode 关联。计算逻辑:按 ABS(PosiGrossPrice×100-ClosePrice) 计算绝对价差,价差大于 5 时触发审批。", +// 字段映射:RuleExpr 中 eod_commodity_future_price.UnderlyingCode 是 C# 模型属性,实际数据库列为 FutureContractId;SQL 排查时应写 e.FutureContractId。 +// RuleExpr = "Math.Abs(DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection == 2 && !string.IsNullOrEmpty(p.UnderlyingCode)).PosiGrossPrice * 100m - Convert.ToDecimal(DbContext.eod_commodity_future_price.Where(e => e.UnderlyingCode == DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection == 2 && !string.IsNullOrEmpty(p.UnderlyingCode)).UnderlyingCode && e.ValueDate < DbContext.trade.First(t => t.id == TradeId).TradeDate.Value.Date).OrderByDescending(e => e.ValueDate).First().ClosePrice)) > 5m", +// Version = 1, +// Status = RiskRuleStatus.Active, +// OptId = 0, +// OptName = "system", +// OptDate = DateTime.Now, +// UpdateOptId = 0, +// UpdateOptName = "system", +// UpdateDate = DateTime.Now +//}); +``` + +```sql +SET @TradeId = 3001699; + +SELECT + t.id AS TradeId, + t.UnderlyingCode AS TradeUnderlyingCode, + t.TradeDate, + + sp.id AS SwapPositionId, + sp.SwapTradeId, + sp.IsInitial, + sp.Invalid, + sp.PosiDirection, + sp.UnderlyingCode AS SwapPositionUnderlyingCode, + sp.UnderlyingInstrumentType, + sp.PosiGrossPrice, + sp.PosiGrossPrice * 100 AS PosiGrossPrice_100, + + e.id AS EodPriceId, + e.FutureContractId AS EodFutureContractId, + e.ValueDate, + e.ClosePrice, + + ABS(sp.PosiGrossPrice * 100 - e.ClosePrice) AS DiffAbs, + CASE + WHEN ABS(sp.PosiGrossPrice * 100 - e.ClosePrice) > 5 THEN 1 + ELSE 0 + END AS IsGreaterThan5 +FROM trade t +INNER JOIN swap_position sp + ON sp.SwapTradeId = t.id + AND sp.IsInitial = 1 + AND sp.Invalid = 0 + AND sp.PosiDirection = 2 + AND sp.UnderlyingCode IS NOT NULL + AND sp.UnderlyingCode <> '' +LEFT JOIN eod_commodity_future_price e + ON e.FutureContractId = sp.UnderlyingCode + AND e.ValueDate < DATE(t.TradeDate) +WHERE t.id = @TradeId +ORDER BY + e.ValueDate DESC +LIMIT 1; +``` + +--- + +## 15. 规则 15 查询结果排查 SQL + +规则 15:单一交易对手累计标的数量超阈值(本地)。用于核对同一交易对手实时存续持仓下的去重标的数量是否超过 10 个。 + +取数流程: + +```text +1. 根据当前 TradeId 查 trade,取当前交易对手 ClientId。 +2. 查询同一 ClientId 下的交易。 +3. 限定交易有效:ValidState != 'InValid'。 +4. 关联 swap_position,按实时持仓判断是否存续:IsInitial=0、PosiQuantity>0、Invalid=0、PosiDirection>0。 +5. 限定 UnderlyingCode 非空。 +6. 对 swap_position.UnderlyingCode 去重计数。 +7. 去重数量 > 10 时命中。 +``` + +规则公式: + +```text +COUNT(DISTINCT swap_position.UnderlyingCode) > 10 +``` + +注释规则定义: + +```csharp +//rules.Add(new RiskRule +//{ +// Id = 1000015, +// RuleName = "单一交易对手累计标的数量超阈值(本地)", +// RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易对手 ClientId,再查询同一交易对手有效交易对应的实时存续持仓 swap_position.UnderlyingCode 去重数量。实时存续持仓口径:IsInitial=false、PosiQuantity>0、Invalid=false、PosiDirection>0 且 UnderlyingCode 非空。计算逻辑:同一交易对手累计标的数量超过 10 个时触发审批。", +// RuleExpr= DbContext.swap_position.Where(p => !string.IsNullOrEmpty(p.UnderlyingCode) && !p.IsInitial && p.PosiQuantity > 0 && !p.Invalid && p.PosiDirection > 0 && DbContext.trade.Any(t => t.id == p.SwapTradeId && t.ValidState != "InValid" && t.ClientId == DbContext.trade.First(x => x.id == TradeId).ClientId)).Select(p => p.UnderlyingCode).Distinct().Count() > 10 +// Version = 1, +// Status = RiskRuleStatus.Active, +// OptId = 0, +// OptName = "system", +// OptDate = DateTime.Now, +// UpdateOptId = 0, +// UpdateOptName = "system", +// UpdateDate = DateTime.Now +//}); +``` + +汇总 SQL: + +```sql +SET @TradeId = 3001699; + +SELECT + cur.id AS CurrentTradeId, + cur.ClientId, + COUNT(DISTINCT sp.UnderlyingCode) AS DistinctUnderlyingCount, + CASE + WHEN COUNT(DISTINCT sp.UnderlyingCode) > 10 THEN 1 + ELSE 0 + END AS IsGreaterThan10 +FROM trade cur +INNER JOIN trade t + ON t.ClientId = cur.ClientId + AND t.ValidState <> 'InValid' +INNER JOIN swap_position sp + ON sp.SwapTradeId = t.id + AND sp.IsInitial = 0 + AND sp.PosiQuantity > 0 + AND sp.Invalid = 0 + AND sp.PosiDirection > 0 + AND sp.UnderlyingCode IS NOT NULL + AND sp.UnderlyingCode <> '' +WHERE cur.id = @TradeId +GROUP BY + cur.id, + cur.ClientId; +``` + +标的明细 SQL:用于查看当前交易对手实时存续持仓下到底有哪些去重标的;是否超限以汇总 SQL 的 `DistinctUnderlyingCount` 为准。 + +```sql +SET @TradeId = 3001699; + +SELECT + cur.id AS CurrentTradeId, + cur.ClientId, + sp.UnderlyingCode, + MAX(sp.UnderlyingInstrumentType) AS UnderlyingInstrumentType, + COUNT(DISTINCT t.id) AS RelatedTradeCount, + COUNT(*) AS PositionCount, + SUM(sp.PosiQuantity) AS TotalPosiQuantity +FROM trade cur +INNER JOIN trade t + ON t.ClientId = cur.ClientId + AND t.ValidState <> 'InValid' +INNER JOIN swap_position sp + ON sp.SwapTradeId = t.id + AND sp.IsInitial = 0 + AND sp.PosiQuantity > 0 + AND sp.Invalid = 0 + AND sp.PosiDirection > 0 + AND sp.UnderlyingCode IS NOT NULL + AND sp.UnderlyingCode <> '' +WHERE cur.id = @TradeId +GROUP BY + cur.id, + cur.ClientId, + sp.UnderlyingCode +ORDER BY + sp.UnderlyingCode; +``` + +--- + +## 16. 规则 16 查询结果排查 SQL + +规则 16:多头支付固定端利率偏离(本地)。用于核对利息端收入固定利息方向的点差百分比绝对值是否低于阈值。 + +取数流程: + +```text +1. 根据 TradeId 查 swap_position。 +2. 限定 InterestDirection=1,取固定利息端收取方向记录。 +3. 从该利息端记录取 InterestRateDefault。 +4. InterestRateDefault 只代表利率文本框中 + 号后的点差,不包含 FR007 基准利率,库内为小数原值,界面按百分比显示。 +5. 计算 ABS(InterestRateDefault * 100),大于 5 时命中。 +``` + +规则公式: + +```text +ABS(利息端.InterestRateDefault * 100) > 5 +``` + +注释规则定义: + +```csharp +//rules.Add(new RiskRule +//{ +// Id = 1000016, +// RuleName = "多头支付固定端利率偏离(本地)", +// RuleText = "取值字段:通过 DbContext.swap_position 按 TradeId 取利息端收入固定利息方向记录的 InterestRateDefault。InterestRateDefault 只代表利率文本框中 + 号后的点差,不包含 FR007 基准利率。计算逻辑:按 ABS(InterestRateDefault) 计算点差绝对值,绝对值小于 5% 时触发审批。", +// RuleExpr = "Math.Abs(DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.InterestDirection == 1).InterestRateDefault)*100m > 5m", +// Version = 1, +// Status = RiskRuleStatus.Active, +// OptId = 0, +// OptName = "system", +// OptDate = DateTime.Now, +// UpdateOptId = 0, +// UpdateOptName = "system", +// UpdateDate = DateTime.Now +//}); +``` + +```sql +SET @TradeId = 3001699; + +SELECT + sp.id AS SwapPositionId, + sp.SwapTradeId, + sp.IsInitial, + sp.Invalid, + sp.PosiDirection, + sp.InterestDirection, + sp.FloatRateUnderlyingCode, + sp.InterestRateDefault, + sp.InterestRateDefault * 100 AS InterestRateDefault_100, + ABS(sp.InterestRateDefault * 100) AS DiffAbs, + CASE + WHEN ABS(sp.InterestRateDefault * 100) > 3 THEN 1 + ELSE 0 + END AS IsGreaterThan +FROM swap_position sp +WHERE sp.SwapTradeId = @TradeId + AND sp.InterestDirection = 1; +``` From 9dad4cb8d0c12f0a85bff04b824c5c229dd13dab Mon Sep 17 00:00:00 2001 From: ruisu Date: Fri, 17 Jul 2026 10:28:21 +0800 Subject: [PATCH 3/7] =?UTF-8?q?fix:=E9=A3=8E=E6=8E=A7=E5=BC=95=E6=93=8E?= =?UTF-8?q?=E7=9A=84=E6=95=B0=E6=8D=AE=E5=BA=93=E5=AF=B9=E8=B1=A1=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E9=87=87=E7=94=A8=E5=85=A8=E5=B1=80=EF=BC=8C=E9=98=B2?= =?UTF-8?q?=E6=AD=A2=E8=BF=BD=E8=B8=AA=E6=89=80=E6=9C=89=E6=9F=A5=E8=AF=A2?= =?UTF-8?q?=E5=87=BA=E7=9A=84=E5=AF=B9=E8=B1=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Modules/RiskEngine/RiskEngineService.cs | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs b/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs index aca95a5b..36e9222c 100644 --- a/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs +++ b/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs @@ -1,4 +1,4 @@ -using Newtonsoft.Json; +using Newtonsoft.Json; using Qdp.Foundation.Utilities; using System.Reflection; using YLErp.BLL; @@ -309,11 +309,14 @@ namespace YLErp.Modules.RiskEngine try { - _logger.Info($"[风控引擎] EvaluateRisk 开始 - TradeId: {context?.TradeId}, TriggerPoint: {triggerPoint}"); - if (context != null && context.DbContext == null) - { - context.DbContext = DbContext; - } + using var ruleDbContext = DbContextFactory.GetYLDbContext(); + ruleDbContext.ChangeTracker.QueryTrackingBehavior = Microsoft.EntityFrameworkCore.QueryTrackingBehavior.NoTracking; + + context ??= new RiskContext(); + context.TriggerPoint = triggerPoint; + context.DbContext = ruleDbContext; + + _logger.Info($"[风控引擎] EvaluateRisk 开始 - TradeId: {context.TradeId}, TriggerPoint: {triggerPoint}"); // ============================================================ // Step 1: 从内存缓存读取规则定义和规则应用(启动时已预热) @@ -343,8 +346,8 @@ namespace YLErp.Modules.RiskEngine // 2. 同一维度内多选按并集处理; // 3. 不同维度之间按交集处理; // 4. 某维度留空表示该维度不限制。 - var trade = context != null && context.TradeId > 0 - ? DbContext.trade.FirstOrDefault(t => t.id == context.TradeId) + var trade = context.TradeId > 0 + ? context.DbContext.trade.AsNoTracking().FirstOrDefault(t => t.id == context.TradeId) : null; var matchedApplications = triggerMatchedApps From 180f113433a9093b062fd44b81c40828509a7277 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=B9=E5=B3=B0?= Date: Fri, 17 Jul 2026 11:03:43 +0800 Subject: [PATCH 4/7] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=8F=98=E9=87=8F?= =?UTF-8?q?=E4=BF=9D=E5=AD=98=E6=A0=A1=E9=AA=8C=E5=92=8C=E5=8F=98=E9=87=8F?= =?UTF-8?q?=E5=85=AC=E5=BC=8F=E6=9B=BF=E6=8D=A2=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Modules/RiskEngine/RiskRuleService.cs | 231 ++++++++++++++++-- YLErpWeb/Controllers/RiskRuleController.cs | 4 +- 2 files changed, 211 insertions(+), 24 deletions(-) diff --git a/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs b/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs index d12d3be2..c9fa2dc8 100644 --- a/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs +++ b/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs @@ -1,5 +1,7 @@ using BaseOUDAL; using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using System.Globalization; using System.Linq; using System.Linq.Expressions; using System.Text.RegularExpressions; @@ -105,9 +107,10 @@ using YLErp.Modules.RiskEngine.Dto; DeleteVariable(long) 删除变量(引用保护:有 Active 规则引用时拒绝,硬删除) GetAllVariableList() 获取所有已实现变量(轻量字段,供规则编辑器下拉) - ── 审计日志(3 个) ─────────────────────────────────────────────── + ── 审计日志(4 个) ─────────────────────────────────────────────── QueryAuditLogs(QueryRiskAuditLogReq) 查询审计日志(多条件筛选,分页) - ExportAuditLogs(QueryRiskAuditLogReq) 导出审计日志(占位实现,后续迭代 Excel 导出) + BuildAuditLogExportFileName(...) 根据查询条件生成导出文件名 + ExportAuditLogs(QueryRiskAuditLogReq) 导出审计日志 Excel(ID 保持整数文本、类型中文映射) GetAuditLogDetail(long) 获取审计日志详情(含完整 SnapshotData) ── 私有辅助方法 ─────────────────────────────────────────────────── @@ -180,7 +183,7 @@ using YLErp.Modules.RiskEngine.Dto; 1. RiskEngineService 由另一个团队开发实现,本服务通过 GetInstance() 获取单例引用 2. 规则变更后调用 RefreshOneRuleCache(ruleId),应用变更后调用 RefreshApplication(),变量变更不触发引擎缓存刷新 3. 变量删除为硬删除(物理删除),规则/应用删除为软删除(Status=Deleted) - 4. ExportAuditLogs 当前为占位实现,Excel 导出功能待后续迭代 + 4. ExportAuditLogs 按查询条件导出,并生成包含查询条件的文件名 5. VariableExpr 当前仅做基础长度校验(≤10000),完整编译校验待引入 Roslyn 库 6. ValidateScopeFields 的 ID 存在性校验已跳过(前端下拉选择器保证有效性) ================================================================================ @@ -291,6 +294,7 @@ namespace YLErp.Modules.RiskEngine var sanitized = Regex.Replace(value.Trim(), @"[\\/:*?""<>|\r\n]+", "_"); return sanitized.Length <= 40 ? sanitized : sanitized.Substring(0, 40); } + /// /// 写入审计日志 /// @@ -801,9 +805,10 @@ namespace YLErp.Modules.RiskEngine if (req.VariableId.HasValue) { - var varIdStr = req.VariableId.Value.ToString(); - query = query.Where(r => (r.ConditionJson != null && r.ConditionJson.Contains(varIdStr)) - || (r.RuleExpr != null && r.RuleExpr.Contains(varIdStr))); + var referencedRuleIds = GetRulesByVariableId(req.VariableId.Value) + .Select(r => (long)r.id) + .ToList(); + query = query.Where(r => referencedRuleIds.Contains(r.id)); } var result = query.OrderByDescending(r => r.UpdateDate) @@ -822,7 +827,8 @@ namespace YLErp.Modules.RiskEngine UpdateOptName = r.UpdateOptName, UpdateDate = r.UpdateDate ?? r.OptDate.GetValueOrDefault(), ApplicationCount = DbContext.glms_risk_rule_application - .Count(a => a.RuleIds != null && + .Count(a => a.Status != RiskRuleStatus.Deleted && + a.RuleIds != null && (a.RuleIds == r.id.ToString() || a.RuleIds.StartsWith(r.id.ToString() + ",") || a.RuleIds.EndsWith("," + r.id.ToString()) || @@ -841,6 +847,7 @@ namespace YLErp.Modules.RiskEngine var applications = DbContext.glms_risk_rule_application + .Where(a => a.Status != RiskRuleStatus.Deleted) .Where(RuleIdsMatchExpr(ruleId)) .Select(a => new RiskRuleApplicationSummary { @@ -1946,6 +1953,183 @@ namespace YLErp.Modules.RiskEngine ).ToList(); } + /// + /// 根据结构化 ConditionJson 和当前变量池定义重新生成可执行 RuleExpr。 + /// ConditionJson 是结构化规则的事实来源,避免通过字符串 Replace 级联修改表达式。 + /// + private string BuildRuleExprFromConditionJson(string conditionJson) + { + List conditions; + try + { + conditions = JsonConvert.DeserializeObject>(conditionJson); + } + catch + { + throw new ServiceException("公式表达式 JSON 格式不合法"); + } + + if (conditions == null || conditions.Count == 0) + throw new ServiceException("公式条件列表不能为空"); + + var variableIds = conditions + .Select(c => c.VariableId) + .Concat(conditions.Where(c => c.ThresholdVariableId.HasValue) + .Select(c => c.ThresholdVariableId.Value)) + .Distinct() + .ToList(); + + var variables = DbContext.glms_risk_variable + .Where(v => variableIds.Contains(v.id)) + .ToList() + .ToDictionary(v => (long)v.id); + + var missingVariableIds = variableIds.Where(id => !variables.ContainsKey(id)).ToList(); + if (missingVariableIds.Any()) + throw new ServiceException($"公式引用的变量不存在:{string.Join(",", missingVariableIds)}"); + + var conditionExpressions = conditions + .Select((condition, index) => BuildConditionExpression(condition, variables, index + 1)) + .ToList(); + + return string.Join(" && ", conditionExpressions.Select(expr => $"({expr})")); + } + + private string BuildConditionExpression( + RuleCondition condition, + IReadOnlyDictionary variables, + int conditionIndex) + { + var variable = variables[condition.VariableId]; + if (string.IsNullOrWhiteSpace(variable.VariableExpr)) + throw new ServiceException($"条件{conditionIndex}:变量'{variable.VariableName}'的取值表达式为空"); + + if (!ValidOperatorsByType.TryGetValue(variable.DataType, out var validOperators) || + !validOperators.Contains(condition.Operator)) + { + throw new ServiceException($"条件{conditionIndex}:操作符'{condition.Operator}'不适用于{GetDataTypeName(variable.DataType)}类型变量"); + } + + if (variable.DataType == RiskVariableDataType.Boolean) + { + return condition.Operator switch + { + "是" => $"({variable.VariableExpr}) == true", + "否" => $"({variable.VariableExpr}) == false", + _ => throw new ServiceException($"条件{conditionIndex}:不支持的布尔操作符'{condition.Operator}'") + }; + } + + var leftExpression = BuildComparableExpression(variable); + if (condition.Operator == "介于" || condition.Operator == "不介于") + { + if (!string.Equals(condition.ThresholdType, "Fixed", StringComparison.Ordinal)) + throw new ServiceException($"条件{conditionIndex}:介于/不介于暂只支持固定上下限"); + if (condition.Value is not JArray range || range.Count != 2) + throw new ServiceException($"条件{conditionIndex}:介于/不介于的阈值必须为双元素数组"); + + var lowerExpression = BuildFixedValueExpression(range[0], variable.DataType, conditionIndex); + var upperExpression = BuildFixedValueExpression(range[1], variable.DataType, conditionIndex); + var lowerOperator = condition.IncludeLowerBound ? ">=" : ">"; + var upperOperator = condition.IncludeUpperBound ? "<=" : "<"; + var rangeExpression = $"(({leftExpression}) {lowerOperator} ({lowerExpression}) && ({leftExpression}) {upperOperator} ({upperExpression}))"; + + return condition.Operator == "不介于" ? $"!{rangeExpression}" : rangeExpression; + } + + var rightExpression = BuildThresholdExpression(condition, variable.DataType, variables, conditionIndex); + var comparisonOperator = GetComparisonOperator(condition.Operator, conditionIndex); + return $"({leftExpression}) {comparisonOperator} ({rightExpression})"; + } + + private static string BuildComparableExpression(glms_risk_variable variable) + { + return variable.DataType == RiskVariableDataType.Numeric + ? $"Convert.ToDecimal(({variable.VariableExpr}))" + : variable.VariableExpr; + } + + private string BuildThresholdExpression( + RuleCondition condition, + RiskVariableDataType dataType, + IReadOnlyDictionary variables, + int conditionIndex) + { + if (string.Equals(condition.ThresholdType, "Fixed", StringComparison.Ordinal)) + return BuildFixedValueExpression(condition.Value, dataType, conditionIndex); + + if (string.Equals(condition.ThresholdType, "Variable", StringComparison.Ordinal)) + { + if (!condition.ThresholdVariableId.HasValue || + !variables.TryGetValue(condition.ThresholdVariableId.Value, out var thresholdVariable)) + { + throw new ServiceException($"条件{conditionIndex}:阈值变量不存在"); + } + + if (thresholdVariable.DataType != dataType) + throw new ServiceException($"条件{conditionIndex}:阈值变量与条件变量的数据类型不一致"); + if (string.IsNullOrWhiteSpace(thresholdVariable.VariableExpr)) + throw new ServiceException($"条件{conditionIndex}:阈值变量'{thresholdVariable.VariableName}'的取值表达式为空"); + + return BuildComparableExpression(thresholdVariable); + } + + throw new ServiceException($"条件{conditionIndex}:阈值类型'{condition.ThresholdType}'不合法,仅支持 Fixed/Variable"); + } + + private static string BuildFixedValueExpression(object value, RiskVariableDataType dataType, int conditionIndex) + { + if (value == null) + throw new ServiceException($"条件{conditionIndex}:固定阈值不能为空"); + + var rawValue = value is JValue jsonValue + ? Convert.ToString(jsonValue.Value, CultureInfo.InvariantCulture) + : Convert.ToString(value, CultureInfo.InvariantCulture); + + if (dataType == RiskVariableDataType.Numeric) + { + if (!decimal.TryParse(rawValue, NumberStyles.Number | NumberStyles.AllowExponent, + CultureInfo.InvariantCulture, out var numericValue)) + { + throw new ServiceException($"条件{conditionIndex}:数值型阈值必须为数字"); + } + + return numericValue.ToString(CultureInfo.InvariantCulture) + "m"; + } + + if (dataType == RiskVariableDataType.Date) + { + if (!DateTime.TryParse(rawValue, CultureInfo.InvariantCulture, + DateTimeStyles.AllowWhiteSpaces | DateTimeStyles.RoundtripKind, out var dateValue) && + !DateTime.TryParse(rawValue, out dateValue)) + { + throw new ServiceException($"条件{conditionIndex}:日期型阈值必须为合法日期"); + } + + return $"new DateTime({dateValue.Ticks}L, DateTimeKind.{dateValue.Kind})"; + } + + throw new ServiceException($"条件{conditionIndex}:不支持的数据类型"); + } + + private static string GetComparisonOperator(string ruleOperator, int conditionIndex) + { + return ruleOperator switch + { + ">" => ">", + "<" => "<", + ">=" => ">=", + "<=" => "<=", + "=" => "==", + "≠" => "!=", + "早于" => "<", + "晚于" => ">", + "等于" => "==", + "不早于" => ">=", + "不晚于" => "<=", + _ => throw new ServiceException($"条件{conditionIndex}:不支持的操作符'{ruleOperator}'") + }; + } #region Variable Management /// @@ -1995,11 +2179,8 @@ namespace YLErp.Modules.RiskEngine { var variable = GetVariableOrThrow(variableId); - var varExpr = variable.VariableExpr; - var referenceCount = DbContext.glms_risk_rule - .Count(r => r.Status == RiskRuleStatus.Active && - ((r.ConditionJson != null && r.ConditionJson.Contains(variable.id.ToString())) || - (r.RuleExpr != null && varExpr != null && r.RuleExpr.Contains(varExpr)))); + var referenceCount = GetRulesByVariableId(variableId) + .Count(r => r.Status == RiskRuleStatus.Active); return new RiskVariableDetail { @@ -2123,14 +2304,18 @@ namespace YLErp.Modules.RiskEngine .Where(r => r.RuleExpr != null) .ToList(); - var compileTasks = affectedRules.Select(r => - Task.Run(() => RuleCompiler.ValidateAndCompileFormula( - r.id, r.RuleExpr.Replace(oldVariableExpr, req.VariableExpr))) - ); - var compileResults = Task.WhenAll(compileTasks).GetAwaiter().GetResult(); - var failed = compileResults.FirstOrDefault(r => !r.Success); - if (failed is not null) - throw new ServiceException($"变量表达式变更导致规则编译失败:{failed.ErrorMessage}"); + var rebuiltRuleExpressions = new Dictionary(); + foreach (var rule in affectedRules) + { + var rebuiltRuleExpr = BuildRuleExprFromConditionJson(rule.ConditionJson); + ValidateRuleExpr(rebuiltRuleExpr); + + var compileResult = RuleCompiler.ValidateAndCompileFormula(rule.id, rebuiltRuleExpr); + if (!compileResult.Success) + throw new ServiceException($"变量表达式变更导致规则'{rule.RuleName}'(ID:{rule.id})编译失败:{compileResult.ErrorMessage}"); + + rebuiltRuleExpressions[rule.id] = rebuiltRuleExpr; + } using (var transaction = DbContext.Database.BeginTransaction()) { @@ -2138,7 +2323,7 @@ namespace YLErp.Modules.RiskEngine { foreach (var rule in affectedRules) { - rule.RuleExpr = rule.RuleExpr.Replace(oldVariableExpr, req.VariableExpr); + rule.RuleExpr = rebuiltRuleExpressions[rule.id]; rule.Version = rule.Version + 1; rule.UpdateOptId = UserId; rule.UpdateOptName = UserName; @@ -2314,13 +2499,13 @@ namespace YLErp.Modules.RiskEngine } var operationTypeNames = operationTypes .Distinct(StringComparer.OrdinalIgnoreCase) - .Select(GetAuditOperationTypeName) + .Select(type => SanitizeFileNamePart(GetAuditOperationTypeName(type))) .ToList(); if (operationTypeNames.Any()) conditions.Add($"操作类型-{string.Join("+", operationTypeNames)}"); if (!string.IsNullOrWhiteSpace(req.TargetType)) - conditions.Add($"目标类型-{GetAuditTargetTypeName(req.TargetType)}"); + conditions.Add($"目标类型-{SanitizeFileNamePart(GetAuditTargetTypeName(req.TargetType))}"); if (!string.IsNullOrWhiteSpace(req.TargetName)) conditions.Add($"目标名称-{SanitizeFileNamePart(req.TargetName)}"); if (!string.IsNullOrWhiteSpace(req.OptName)) diff --git a/YLErpWeb/Controllers/RiskRuleController.cs b/YLErpWeb/Controllers/RiskRuleController.cs index 028c0fba..7dbda6ed 100644 --- a/YLErpWeb/Controllers/RiskRuleController.cs +++ b/YLErpWeb/Controllers/RiskRuleController.cs @@ -773,7 +773,9 @@ namespace YLErp.Web.Controllers var service = GetRiskRuleService(); var bytes = await Task.Run(() => service.ExportAuditLogs(req)); var fileName = service.BuildAuditLogExportFileName(req); - return File(bytes, xlsxMimeType, fileName); + var encodedFileName = Uri.EscapeDataString(fileName); + Response.Headers["Content-Disposition"] = $"attachment; filename*=UTF-8''{encodedFileName}"; + return File(bytes, xlsxMimeType); } catch (ServiceException ex) { From b51d39e0ff0947cdde843440a2c35bbcee8a6b3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=B9=E5=B3=B0?= Date: Fri, 17 Jul 2026 14:27:27 +0800 Subject: [PATCH 5/7] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=8F=98=E9=87=8F?= =?UTF-8?q?=E6=89=B9=E9=87=8F=E5=88=A0=E9=99=A4=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RiskEngine/Dto/BatchOperationResult.cs | 15 ++++++ .../Modules/RiskEngine/RiskRuleService.cs | 52 +++++++++++++++++++ YLErpWeb/Controllers/RiskRuleController.cs | 22 ++++++++ 3 files changed, 89 insertions(+) diff --git a/YLErpDAL/Modules/RiskEngine/Dto/BatchOperationResult.cs b/YLErpDAL/Modules/RiskEngine/Dto/BatchOperationResult.cs index 7d9dccb1..c6c7aafd 100644 --- a/YLErpDAL/Modules/RiskEngine/Dto/BatchOperationResult.cs +++ b/YLErpDAL/Modules/RiskEngine/Dto/BatchOperationResult.cs @@ -14,4 +14,19 @@ namespace YLErp.Modules.RiskEngine.Dto /// 错误信息 public string ErrorMessage { get; set; } } + + /// + /// 批量删除变量结果 + /// + public class BatchDeleteVariablesResult : BatchOperationResult + { + /// 成功删除的变量 ID + public List DeletedIds { get; set; } + /// 不存在的变量 ID + public List MissingIds { get; set; } + /// 因被生效规则引用而跳过的变量 ID + public List BlockedIds { get; set; } + /// 被跳过变量的原因,Key 为变量 ID + public Dictionary BlockedReasons { get; set; } + } } diff --git a/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs b/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs index c9fa2dc8..737aa940 100644 --- a/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs +++ b/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs @@ -2383,6 +2383,58 @@ namespace YLErp.Modules.RiskEngine InvalidateVariableCache(); } + /// + /// 批量删除变量(不存在或被生效规则引用的变量将被跳过) + /// + public BatchDeleteVariablesResult BatchDeleteVariables(List variableIds) + { + if (variableIds == null || variableIds.Count == 0) + throw new ServiceException("变量ID列表不能为空"); + + var distinctIds = variableIds.Distinct().ToList(); + var variables = DbContext.glms_risk_variable + .Where(v => distinctIds.Contains(v.id)) + .ToList(); + var foundIds = variables.Select(v => (long)v.id).ToList(); + var missingIds = distinctIds.Except(foundIds).ToList(); + var blockedIds = new List(); + var blockedReasons = new Dictionary(); + var deletedIds = new List(); + + foreach (var variable in variables) + { + var activeRefCount = GetRulesByVariableId(variable.id) + .Count(r => r.Status == RiskRuleStatus.Active); + if (activeRefCount > 0) + { + blockedIds.Add(variable.id); + blockedReasons[variable.id] = $"该变量被 {activeRefCount} 个生效中的规则引用,无法删除"; + continue; + } + + DbContext.glms_risk_variable.Remove(variable); + WriteAuditLog("VAR_BATCH_DELETE", "VARIABLE", variable.id, variable.VariableName, $"批量删除变量:{variable.VariableName}"); + deletedIds.Add(variable.id); + } + + if (deletedIds.Count > 0) + { + DbContext.SaveChanges(); + InvalidateVariableCache(); + } + + return new BatchDeleteVariablesResult + { + Success = true, + TotalCount = distinctIds.Count, + SuccessCount = deletedIds.Count, + DeletedIds = deletedIds, + MissingIds = missingIds, + BlockedIds = blockedIds, + BlockedReasons = blockedReasons + }; + } + /// /// 获取所有变量列表(供下拉选择) /// diff --git a/YLErpWeb/Controllers/RiskRuleController.cs b/YLErpWeb/Controllers/RiskRuleController.cs index 7dbda6ed..e47aa4f7 100644 --- a/YLErpWeb/Controllers/RiskRuleController.cs +++ b/YLErpWeb/Controllers/RiskRuleController.cs @@ -685,6 +685,28 @@ namespace YLErp.Web.Controllers } } + [HttpDelete("risk-variables/batch")] + [MyAuthorize("风险控制-风控变量删除")] + /// 批量删除变量 + public async Task BatchDeleteRiskVariables([FromBody] BatchIdsReq req) + { + try + { + var service = GetRiskRuleService(); + var result = await Task.Run(() => service.BatchDeleteVariables(req?.Ids)); + return Json(new { success = true, data = result }); + } + catch (ServiceException ex) + { + return Json(new { success = false, message = ex.Message }); + } + catch (Exception ex) + { + _logger.Error(ex, "批量删除变量"); + return Json(new { success = false, message = "系统异常,请联系管理员" }); + } + } + #endregion #region Trade Types From 9b98a0dd887520a435afe473af34eb993d978c7b Mon Sep 17 00:00:00 2001 From: ruisu Date: Fri, 17 Jul 2026 14:29:44 +0800 Subject: [PATCH 6/7] =?UTF-8?q?feat:=E9=A3=8E=E6=8E=A7=E5=BC=95=E6=93=8E?= =?UTF-8?q?=E6=89=A7=E8=A1=8C=E5=BC=82=E5=B8=B8=E6=88=96=E8=80=85=E7=8E=B0?= =?UTF-8?q?=E5=9C=BA=E7=BC=96=E8=AF=91=E5=BC=82=E5=B8=B8=E6=97=B6=E8=A7=86?= =?UTF-8?q?=E4=B8=BA=E4=B8=8D=E9=80=9A=E8=BF=87=EF=BC=8C=E5=B9=B6=E4=B8=94?= =?UTF-8?q?=E8=BF=94=E5=9B=9E=E5=89=8D=E7=AB=AF=EF=BC=8C=E8=90=BD=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RiskEngine/Compile/RuleCompiler.cs | 4 +-- .../Modules/RiskEngine/RiskEngineService.cs | 36 +++++++++++++------ YLErpDAL/Modules/RiskEngine/RiskResult.cs | 5 +++ .../Modules/RiskModule/QuotaMonitorService.cs | 7 +++- 4 files changed, 38 insertions(+), 14 deletions(-) diff --git a/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs b/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs index 6d48174c..e9797395 100644 --- a/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs +++ b/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs @@ -168,9 +168,9 @@ namespace YLErp.Modules.RiskEngine } catch (Exception ex) { - // 脚本执行异常(如空引用、类型转换失败)视为规则不触发 + // 脚本执行异常(如空引用、类型转换失败)交由执行层按阻断处理 _logger.Error($"规则执行异常 - RuleId: {ruleIdText}, Error: {ex.Message}\n脚本代码:{scriptCode}"); - return false; + throw; } }; } diff --git a/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs b/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs index 36e9222c..d16a4911 100644 --- a/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs +++ b/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs @@ -405,7 +405,9 @@ namespace YLErp.Modules.RiskEngine var compileResult = RuleCompiler.ValidateAndCompileRule(rule); if (!compileResult.Success) { - _logger.Info($"[风控引擎] 规则编译失败 - RuleId: {rule.Id}, Error: {compileResult.ErrorMessage}"); + var errorMessage = $"规则[{rule.RuleName}]编译失败,已按阻断处理,请检查规则表达式配置:{compileResult.ErrorMessage}"; + _logger.Info($"[风控引擎] 规则编译失败,按阻断处理 - RuleId: {rule.Id}, Error: {compileResult.ErrorMessage}"); + AddBlockError(result, rule.Id.ToString(), rule.RuleName, rule.RuleText, errorMessage); continue; } @@ -423,7 +425,9 @@ namespace YLErp.Modules.RiskEngine } catch (Exception ex) { - _logger.Info($"[风控引擎] 规则执行异常 - RuleId: {rule.Id}, Error: {ex.Message}"); + var errorMessage = $"规则[{rule.RuleName}]执行异常:{ex.Message}"; + _logger.Error($"[风控引擎] 规则执行异常,按阻断处理 - RuleId: {rule.Id}, Error: {ex.Message}"); + AddBlockError(result, ruleId, rule.RuleName, rule.RuleText, errorMessage); continue; } @@ -502,21 +506,31 @@ namespace YLErp.Modules.RiskEngine } catch (Exception ex) { - result.NeedApproval = true; - result.Passed = false; - result.TriggeredRules.Add(new TriggeredRuleInfo - { - RuleId = "ENGINE_ERROR", - RuleName = "风控引擎执行异常", - RuleText = ex.Message, - Message = $"风控引擎异常:{ex.Message}" - }); + var errorMessage = $"风控引擎异常:{ex.Message}"; + AddBlockError(result, "ENGINE_ERROR", "风控引擎执行异常", ex.Message, errorMessage); _logger.Error($"[风控引擎] EvaluateRisk 异常 - TradeId: {context?.TradeId}, Error: {ex.Message}"); } return result; } + private static void AddBlockError(RiskResult result, string ruleId, string ruleName, string ruleText, string errorMessage) + { + result.Blocked = true; + result.Passed = false; + result.ErrorMessage = string.IsNullOrWhiteSpace(result.ErrorMessage) + ? errorMessage + : $"{result.ErrorMessage};{errorMessage}"; + result.TriggeredRules.Add(new TriggeredRuleInfo + { + RuleId = ruleId, + RuleName = ruleName, + ControlStrategy = RiskControlStrategy.Block, + RuleText = ruleText, + Message = errorMessage + }); + } + /// /// 从数据库加载规则列表 /// diff --git a/YLErpDAL/Modules/RiskEngine/RiskResult.cs b/YLErpDAL/Modules/RiskEngine/RiskResult.cs index 69edbd57..cbc12d79 100644 --- a/YLErpDAL/Modules/RiskEngine/RiskResult.cs +++ b/YLErpDAL/Modules/RiskEngine/RiskResult.cs @@ -27,6 +27,11 @@ namespace YLErp.Modules.RiskEngine /// public bool ShowTip { get; set; } + /// + /// 错误信息 + /// + public string ErrorMessage { get; set; } + /// /// 触发的规则列表 /// diff --git a/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs b/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs index 5cf1e08e..eab45af4 100644 --- a/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs +++ b/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs @@ -5265,7 +5265,12 @@ namespace YLErp.Modules.RiskModule result.RiskWarningDetails += "[风控引擎] 规则触发:禁止\n"; foreach (var triggeredRule in riskResult.TriggeredRules.Where(r => r.ControlStrategy == RiskControlStrategy.Block)) { - result.RiskWarningDetails += $"规则ID:{triggeredRule.RuleId};规则名称:{triggeredRule.RuleName};规则说明:{triggeredRule.RuleText}\n"; + result.RiskWarningDetails += $"规则ID:{triggeredRule.RuleId};规则名称:{triggeredRule.RuleName};规则说明:{triggeredRule.RuleText}"; + if (!string.IsNullOrWhiteSpace(triggeredRule.Message)) + { + result.RiskWarningDetails += $";信息:{triggeredRule.Message}"; + } + result.RiskWarningDetails += "\n"; } } if (riskResult.NeedApproval) From b2d5fb990045d638de8d61fb80ed48254fb65440 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=B9=E5=B3=B0?= Date: Fri, 17 Jul 2026 15:39:25 +0800 Subject: [PATCH 7/7] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E8=A7=84=E5=88=99&?= =?UTF-8?q?=E5=BA=94=E7=94=A8=E9=BB=98=E8=AE=A4=E7=8A=B6=E6=80=81=E4=B8=BA?= =?UTF-8?q?=E5=81=9C=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/prod.sql | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/prod.sql b/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/prod.sql index 448439e8..b971458f 100644 --- a/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/prod.sql +++ b/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/prod.sql @@ -7,7 +7,7 @@ CREATE TABLE `glms_risk_rule` ( `ConditionJson` text CHARACTER SET utf8 COLLATE utf8_general_ci NULL COMMENT '条件JSON(结构化模式, RuleCondition数组)', `RuleExpr` text CHARACTER SET utf8 COLLATE utf8_general_ci NULL COMMENT '规则表达式(自由文本模式, 类C#表达式)', `Description` varchar(500) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '描述', - `Status` tinyint NOT NULL DEFAULT 1 COMMENT '状态: 1=Active, 2=Disabled, 3=Deleted', + `Status` tinyint NOT NULL DEFAULT 2 COMMENT '状态: 1=Active, 2=Disabled, 3=Deleted', `Version` int NOT NULL DEFAULT 1 COMMENT '版本号(乐观锁)', `OptId` int NULL DEFAULT NULL COMMENT '创建人Id', `OptName` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '创建人名称', @@ -23,7 +23,7 @@ CREATE TABLE `glms_risk_rule_application` ( `id` int NOT NULL AUTO_INCREMENT COMMENT '主键Id', `RuleIds` varchar(500) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '关联规则Id列表(逗号分隔)', `Description` varchar(500) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '描述', - `Status` tinyint NOT NULL DEFAULT 1 COMMENT '状态: 1=Active, 2=Disabled, 3=Deleted', + `Status` tinyint NOT NULL DEFAULT 2 COMMENT '状态: 1=Active, 2=Disabled, 3=Deleted', `ControlStrategy` tinyint NOT NULL COMMENT '控制策略: 1=Block, 2=Approval, 3=Warning', `TriggerPoints` varchar(500) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '触发时点(逗号分隔)', `ScopeAssetBookIds` varchar(500) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '适用账户Id列表',