From 37e275d81e2b84d347f32c1c6965365ef5776d9f Mon Sep 17 00:00:00 2001 From: ruisu Date: Mon, 20 Jul 2026 15:06:58 +0800 Subject: [PATCH 1/8] =?UTF-8?q?feat=EF=BC=9A=E6=8B=86=E5=88=86=E6=96=B0?= =?UTF-8?q?=E8=80=81=E9=A3=8E=E6=8E=A7=E6=A0=A1=E9=AA=8C=E9=80=BB=E8=BE=91?= =?UTF-8?q?=EF=BC=8C=E4=B8=BA=E4=B9=8B=E5=90=8E=E8=80=81=E9=A3=8E=E6=8E=A7?= =?UTF-8?q?=E4=B8=8B=E7=BA=BF=E5=81=9A=E5=87=86=E5=A4=87=E3=80=82=E5=90=8C?= =?UTF-8?q?=E6=97=B6=E4=BF=AE=E5=A4=8D=E8=80=81=E9=A3=8E=E6=8E=A7=E7=89=B9?= =?UTF-8?q?=E6=89=B9=E8=B7=B3=E8=BF=87=E6=96=B0=E9=A3=8E=E6=8E=A7=E7=9A=84?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Modules/RiskModule/QuotaMonitorService.cs | 104 ++++++++++++++++-- .../DealModule/TradeConfirmService.cs | 46 +++++--- YLErpWeb/Controllers/tradeController.cs | 2 +- 3 files changed, 124 insertions(+), 28 deletions(-) diff --git a/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs b/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs index eab45af4..e2315e10 100644 --- a/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs +++ b/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs @@ -4465,12 +4465,20 @@ namespace YLErp.Modules.RiskModule return result; } - public bool QuotaCheck(ref TradeOpenResult res) + /// + /// 交易确认风控检查入口:执行完整风控试算(老风控 + 新风控),并把试算结果转换为交易确认结果。 + /// 返回 true 表示确认流程可以继续;返回 false 表示需要阻断、提示或审批。 + /// + public bool CheckTradeConfirmRisk(ref TradeOpenResult res) { - return QuotaCheck(ref res, false); + return CheckTradeConfirmRisk(ref res, false); } - public bool QuotaCheck(ref TradeOpenResult res, bool ignoreRiskWarning, IEnumerable ignoreRiskRuleIds = null) + /// + /// 交易确认风控检查入口:执行完整风控试算(老风控 + 新风控)。 + /// ignoreRiskWarning/ignoreRiskRuleIds 只用于新风控“需审批”规则的二次确认,不影响老风控结果。 + /// + public bool CheckTradeConfirmRisk(ref TradeOpenResult res, bool ignoreRiskWarning, IEnumerable ignoreRiskRuleIds = null) { if (res == null) { @@ -4483,9 +4491,39 @@ namespace YLErp.Modules.RiskModule } var tradeId = (res.Trade.ParentTradeId == 0 || res.Trade.IsGroup != 0) ? res.Trade.id : res.Trade.ParentTradeId; - var tradeNumber = DbContext.trade.Where(O => O.id == tradeId).Select(O => O.TradeNumber).FirstOrDefault(); var trialService = new QuotaMonitorService(this); - var quotaObj = trialService.QuotaCheck(tradeId, 1); + var quotaObj = trialService.RunQuotaTrial(tradeId, 1); + return ApplyQuotaCheckResult(ref res, quotaObj, ignoreRiskWarning, ignoreRiskRuleIds); + } + + /// + /// 交易确认新风控检查入口:只执行新风控试算。 + /// 用于老风控已被特批放行(ignoreMoneyCheck = true)的场景,避免老风控特批误跳过新风控。 + /// + public bool CheckTradeConfirmNewRisk(ref TradeOpenResult res, bool ignoreRiskWarning, IEnumerable ignoreRiskRuleIds = null) + { + if (res == null) + { + throw new ArgumentNullException(nameof(res)); + } + + if (res.Trade == null) + { + throw new ArgumentNullException(nameof(res.Trade)); + } + + var tradeId = (res.Trade.ParentTradeId == 0 || res.Trade.IsGroup != 0) ? res.Trade.id : res.Trade.ParentTradeId; + var trialService = new QuotaMonitorService(this); + var quotaObj = trialService.RunNewRiskTrial(tradeId, 1); + return ApplyQuotaCheckResult(ref res, quotaObj, ignoreRiskWarning, ignoreRiskRuleIds); + } + + /// + /// 将风控试算结果转换为交易确认结果。 + /// 这里不重新执行风控,只负责设置 RetCode、ErrorMsg、TipMsg、TrialDataId 等返回给确认流程的字段。 + /// + private bool ApplyQuotaCheckResult(ref TradeOpenResult res, QuotaTrial quotaObj, bool ignoreRiskWarning, IEnumerable ignoreRiskRuleIds = null) + { if (quotaObj.TrialStatus == QuotaTrialStatusEnum.Success)//这次计算结果是通过,直接通过 { if (!string.IsNullOrWhiteSpace(quotaObj.AvailableForClient)) // 返回用户资金情况 @@ -5026,12 +5064,13 @@ namespace YLErp.Modules.RiskModule } /// - /// 交易试算 + /// 完整风控试算:先执行老风控资金/限额试算,再叠加新风控试算结果。 + /// 后续老风控下线时,调用方可以保留该入口,内部改为直接调用 RunNewRiskTrial。 /// /// 交易Id,结构化交易请传主交易Id /// 试算来源,0:手动点击试算按钮;1:确认成交时自动调用; /// - public QuotaTrial QuotaCheck(int tradeId, int trialSource) + public QuotaTrial RunQuotaTrial(int tradeId, int trialSource) { _logger.Info($"[限额试算] 开始执行 - tradeId: {tradeId}, trialSource: {trialSource}"); var result = new QuotaTrial(); @@ -5076,8 +5115,20 @@ namespace YLErp.Modules.RiskModule tradeList = tradeList.Where(O => !string.IsNullOrEmpty(O.UnderlyingCode) && O.TradeType != "结构化交易" && O.TradeType != "现金流交易").ToList(); if (tradeList.Count == 0) { + // 老风控没有可试算的标的时,老逻辑认为试算通过;这里仍需继续执行新风控,避免普通确认漏掉新风控检查。 //("当前交易不需要试算"); - return new QuotaTrial() { TrialStatus = QuotaTrialStatusEnum.Success }; + result.TrialStatus = QuotaTrialStatusEnum.Success; + result.TradeId = tradeObj.id; + result.TradeNumber = tradeObj.TradeNumber ?? ""; + result.ClientName = tradeObj.ClientName ?? ""; + result.FundCheckDetails = fundMsg ?? ""; + result.RiskWarningDetails = riskWarningMsg ?? ""; + result.AvailableForClient = availableMsg ?? ""; + result.TrialSource = trialSource; + result.OldRiskTrialStatus = result.TrialStatus; + result = ApplyNewRiskTrial(tradeId, result); + SaveQuotaTrial(result); + return result; } string quotaWarningMsg = "", quotaMsg = ""; bool quotaWarningStatus = true, quotaStatus = true; @@ -5243,8 +5294,42 @@ namespace YLErp.Modules.RiskModule result.AvailableForClient = availableMsg ?? ""; result.TrialSource = trialSource; result.OldRiskTrialStatus = result.TrialStatus; - _logger.Info($"[限额试算] 试算完成 - tradeId: {tradeId}, TrialStatus: {result.TrialStatus}, FundCheck: {fundStatus}, QuotaCheck: {quotaStatus}"); + // 保留老风控原始状态,随后叠加新风控结果;这样二次特批时可以区分是老风控失败还是新风控需审批。 + result = ApplyNewRiskTrial(tradeId, result); + SaveQuotaTrial(result); + return result; + } + /// + /// 新风控独立试算:不执行老风控资金/限额检查,只调用新风控引擎。 + /// 用于老风控特批后的确认流程,也可作为后续老风控下线后的主试算入口。 + /// + public QuotaTrial RunNewRiskTrial(int tradeId, int trialSource) + { + var result = new QuotaTrial + { + TradeId = tradeId, + TrialSource = trialSource, + TrialStatus = QuotaTrialStatusEnum.Success, + OldRiskTrialStatus = QuotaTrialStatusEnum.Success + }; + var tradeObj = DbContext.trade.AsNoTracking().Where(O => O.id == tradeId).FirstOrDefault(); + if (tradeObj != null) + { + result.TradeNumber = tradeObj.TradeNumber ?? ""; + result.ClientName = tradeObj.ClientName ?? ""; + } + result = ApplyNewRiskTrial(tradeId, result); + SaveQuotaTrial(result); + return result; + } + + /// + /// 将新风控引擎结果合并到 QuotaTrial。 + /// 如果传入的是完整试算结果,则是在老风控结果基础上叠加新风控;如果传入的是新建结果,则表示只跑新风控。 + /// + private QuotaTrial ApplyNewRiskTrial(int tradeId, QuotaTrial result) + { // ===== 新风控引擎接入点(设计文档 4.6.1) ===== try { @@ -5318,7 +5403,6 @@ namespace YLErp.Modules.RiskModule } // ===== 新风控引擎接入结束 ===== - SaveQuotaTrial(result); return result; } /// diff --git a/YLErpDAL/Modules/TradeModule/DealModule/TradeConfirmService.cs b/YLErpDAL/Modules/TradeModule/DealModule/TradeConfirmService.cs index 7bca1074..018cac68 100644 --- a/YLErpDAL/Modules/TradeModule/DealModule/TradeConfirmService.cs +++ b/YLErpDAL/Modules/TradeModule/DealModule/TradeConfirmService.cs @@ -203,26 +203,38 @@ namespace YLErp.Modules.TradeModule.DealModule result.ErrorMsg = $"未找到客户信息:{td.ClientName}"; return result; } - if (!ignoreMoneyCheck && !continueQuotaCheck) + if (!continueQuotaCheck) { - result = CheckTradeConfirmRule(result, td, isSkipApproval); - - if (!string.IsNullOrWhiteSpace(result.ErrorMsg)) - { - return result; - } - - result = CheckClientRating(result, client, td); - - if (!string.IsNullOrWhiteSpace(result.ErrorMsg)) - { - return result; - } - var trialService = new QuotaMonitorService(this); - if (!trialService.QuotaCheck(ref result, ignoreRiskWarning, ignoreRiskRuleIds)) + if (ignoreMoneyCheck) { - return result; + // ignoreMoneyCheck 表示老风控资金/限额检查已被特批放行,只跳过老风控;新风控仍需独立执行。 + if (!trialService.CheckTradeConfirmNewRisk(ref result, ignoreRiskWarning, ignoreRiskRuleIds)) + { + return result; + } + } + else + { + // 正常确认流程:先执行原有交易确认规则和客户评级检查,再执行完整风控(老风控 + 新风控)。 + result = CheckTradeConfirmRule(result, td, isSkipApproval); + + if (!string.IsNullOrWhiteSpace(result.ErrorMsg)) + { + return result; + } + + result = CheckClientRating(result, client, td); + + if (!string.IsNullOrWhiteSpace(result.ErrorMsg)) + { + return result; + } + + if (!trialService.CheckTradeConfirmRisk(ref result, ignoreRiskWarning, ignoreRiskRuleIds)) + { + return result; + } } } var history = "确认交易"; diff --git a/YLErpWeb/Controllers/tradeController.cs b/YLErpWeb/Controllers/tradeController.cs index 0a162c3c..cf12800c 100644 --- a/YLErpWeb/Controllers/tradeController.cs +++ b/YLErpWeb/Controllers/tradeController.cs @@ -8428,7 +8428,7 @@ namespace YLErp.Web.Controllers /// public ActionResult quotaTrial(int id, int trialSource = 0) { - var quotaTrial = new QuotaMonitorService(CurUser).QuotaCheck(id, trialSource); + var quotaTrial = new QuotaMonitorService(CurUser).RunQuotaTrial(id, trialSource); return View(quotaTrial); } From 165ab4e695e1c7407c47aa523761fd222768e51a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=B9=E5=B3=B0?= Date: Tue, 21 Jul 2026 14:38:17 +0800 Subject: [PATCH 2/8] =?UTF-8?q?feat:=20=E9=A3=8E=E6=8E=A7=E6=89=B9?= =?UTF-8?q?=E9=87=8F=E6=93=8D=E4=BD=9C=E5=89=8D=E5=90=8E=E7=AB=AF=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E6=94=B9=E9=80=A0=EF=BC=8C=E5=85=81=E8=AE=B8=E9=83=A8?= =?UTF-8?q?=E5=88=86=E6=88=90=E5=8A=9F=E5=B9=B6=E4=BC=98=E5=8C=96=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../YLErp.Core/DBModels/glms_risk_rule.cs | 4 +- .../DBModels/glms_risk_rule_application.cs | 4 +- .../YLErp.Core/DBModels/glms_risk_variable.cs | 2 + .../RiskApplicationQueryContractTest.cs | 36 + .../RiskEngine/RiskConcurrencyTokenTest.cs | 23 + .../RiskEngine/RiskRuleDefaultStatusTest.cs | 22 + .../RiskEngine/Dto/BatchDisableRulesResult.cs | 21 - .../RiskEngine/Dto/BatchOperationResult.cs | 64 +- .../RiskEngine/Dto/QueryRiskApplicationReq.cs | 6 +- .../Modules/RiskEngine/RiskRuleService.cs | 670 +++++++----------- YLErpWeb/Controllers/RiskRuleController.cs | 22 +- 11 files changed, 407 insertions(+), 467 deletions(-) create mode 100644 UnitTestProject/Modules/RiskEngine/RiskApplicationQueryContractTest.cs create mode 100644 UnitTestProject/Modules/RiskEngine/RiskConcurrencyTokenTest.cs create mode 100644 UnitTestProject/Modules/RiskEngine/RiskRuleDefaultStatusTest.cs delete mode 100644 YLErpDAL/Modules/RiskEngine/Dto/BatchDisableRulesResult.cs diff --git a/Framework/YLErp.Core/DBModels/glms_risk_rule.cs b/Framework/YLErp.Core/DBModels/glms_risk_rule.cs index 8359f712..87b0d880 100644 --- a/Framework/YLErp.Core/DBModels/glms_risk_rule.cs +++ b/Framework/YLErp.Core/DBModels/glms_risk_rule.cs @@ -1,3 +1,4 @@ +using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; namespace YLErp.DBModels @@ -19,8 +20,9 @@ namespace YLErp.DBModels /// 规则说明 public string Description { get; set; } /// 规则状态 - public RiskRuleStatus Status { get; set; } = RiskRuleStatus.Active; + public RiskRuleStatus Status { get; set; } = RiskRuleStatus.Disabled; /// 版本号(乐观锁) + [ConcurrencyCheck] public int Version { get; set; } = 1; /// 最后更新人工号 public int? UpdateOptId { get; set; } diff --git a/Framework/YLErp.Core/DBModels/glms_risk_rule_application.cs b/Framework/YLErp.Core/DBModels/glms_risk_rule_application.cs index 89da2889..81f0de94 100644 --- a/Framework/YLErp.Core/DBModels/glms_risk_rule_application.cs +++ b/Framework/YLErp.Core/DBModels/glms_risk_rule_application.cs @@ -1,3 +1,4 @@ +using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; namespace YLErp.DBModels @@ -13,7 +14,7 @@ namespace YLErp.DBModels /// 应用说明 public string Description { get; set; } /// 应用配置状态 - public RiskRuleStatus Status { get; set; } = RiskRuleStatus.Active; + public RiskRuleStatus Status { get; set; } = RiskRuleStatus.Disabled; /// 风控策略(预警/阻断) public RiskControlStrategy ControlStrategy { get; set; } /// 触发点列表(JSON 数组) @@ -29,6 +30,7 @@ namespace YLErp.DBModels /// 是否全局生效(0:否 1:是) public int ScopeIsGlobal { get; set; } /// 版本号(乐观锁) + [ConcurrencyCheck] public int Version { get; set; } = 1; /// 最后更新人工号 public int? UpdateOptId { get; set; } diff --git a/Framework/YLErp.Core/DBModels/glms_risk_variable.cs b/Framework/YLErp.Core/DBModels/glms_risk_variable.cs index 2a856819..5dad1ea6 100644 --- a/Framework/YLErp.Core/DBModels/glms_risk_variable.cs +++ b/Framework/YLErp.Core/DBModels/glms_risk_variable.cs @@ -1,3 +1,4 @@ +using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; namespace YLErp.DBModels @@ -23,6 +24,7 @@ namespace YLErp.DBModels /// 取值表达式(C# 表达式,用于计算变量值) public string VariableExpr { get; set; } /// 版本号(乐观锁) + [ConcurrencyCheck] public int Version { get; set; } = 1; /// 排序序号 public int SortOrder { get; set; } diff --git a/UnitTestProject/Modules/RiskEngine/RiskApplicationQueryContractTest.cs b/UnitTestProject/Modules/RiskEngine/RiskApplicationQueryContractTest.cs new file mode 100644 index 00000000..419eb075 --- /dev/null +++ b/UnitTestProject/Modules/RiskEngine/RiskApplicationQueryContractTest.cs @@ -0,0 +1,36 @@ +using YLErp.Modules.RiskEngine.Dto; + +namespace YLErp.Modules.RiskEngine +{ + [TestClass] + public class RiskApplicationQueryContractTest + { + [TestMethod] + public void QueryRequest_UsesFrontendParameterNames() + { + var request = new QueryRiskApplicationReq + { + page = 1, + rows = 20, + Keyword = "本金", + ControlStrategy = (RiskControlStrategy)2, + Status = (RiskRuleStatus)1, + ScopeAssetBookIds = "2000041", + ScopeClientIds = "1000017", + ScopeUnderlyingTypes = "深交所债券ETF", + ScopeTradeTypes = "收益互换" + }; + + Assert.AreEqual("本金", request.Keyword); + Assert.AreEqual(RiskControlStrategy.Approval, request.ControlStrategy); + Assert.AreEqual(RiskRuleStatus.Active, request.Status); + Assert.AreEqual("2000041", request.ScopeAssetBookIds); + Assert.AreEqual("1000017", request.ScopeClientIds); + Assert.AreEqual("深交所债券ETF", request.ScopeUnderlyingTypes); + Assert.AreEqual("收益互换", request.ScopeTradeTypes); + + Assert.IsNull(typeof(QueryRiskApplicationReq).GetProperty("RuleName")); + Assert.IsNull(typeof(QueryRiskApplicationReq).GetProperty("Strategy")); + } + } +} \ No newline at end of file diff --git a/UnitTestProject/Modules/RiskEngine/RiskConcurrencyTokenTest.cs b/UnitTestProject/Modules/RiskEngine/RiskConcurrencyTokenTest.cs new file mode 100644 index 00000000..62ef4ab6 --- /dev/null +++ b/UnitTestProject/Modules/RiskEngine/RiskConcurrencyTokenTest.cs @@ -0,0 +1,23 @@ +using System.ComponentModel.DataAnnotations; +using System.Reflection; + +namespace YLErp.Modules.RiskEngine +{ + [TestClass] + public class RiskConcurrencyTokenTest + { + [DataTestMethod] + [DataRow(typeof(glms_risk_rule))] + [DataRow(typeof(glms_risk_rule_application))] + [DataRow(typeof(glms_risk_variable))] + public void Version_IsConfiguredAsConcurrencyToken(Type entityType) + { + var versionProperty = entityType.GetProperty("Version", BindingFlags.Instance | BindingFlags.Public); + + Assert.IsNotNull(versionProperty, $"{entityType.Name} 缺少 Version 属性"); + Assert.IsNotNull( + versionProperty.GetCustomAttribute(), + $"{entityType.Name}.Version 未配置为 EF concurrency token"); + } + } +} \ No newline at end of file diff --git a/UnitTestProject/Modules/RiskEngine/RiskRuleDefaultStatusTest.cs b/UnitTestProject/Modules/RiskEngine/RiskRuleDefaultStatusTest.cs new file mode 100644 index 00000000..4b3c8453 --- /dev/null +++ b/UnitTestProject/Modules/RiskEngine/RiskRuleDefaultStatusTest.cs @@ -0,0 +1,22 @@ +namespace YLErp.Modules.RiskEngine +{ + [TestClass] + public class RiskRuleDefaultStatusTest + { + [TestMethod] + public void NewRiskRule_DefaultStatus_IsDisabled() + { + var rule = new glms_risk_rule(); + + Assert.AreEqual(RiskRuleStatus.Disabled, rule.Status); + } + + [TestMethod] + public void NewRiskRuleApplication_DefaultStatus_IsDisabled() + { + var application = new glms_risk_rule_application(); + + Assert.AreEqual(RiskRuleStatus.Disabled, application.Status); + } + } +} \ No newline at end of file diff --git a/YLErpDAL/Modules/RiskEngine/Dto/BatchDisableRulesResult.cs b/YLErpDAL/Modules/RiskEngine/Dto/BatchDisableRulesResult.cs deleted file mode 100644 index cb2e2fcd..00000000 --- a/YLErpDAL/Modules/RiskEngine/Dto/BatchDisableRulesResult.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.Collections.Generic; - -namespace YLErp.Modules.RiskEngine.Dto -{ - /// - /// 批量停用规则结果 - /// - public class BatchDisableRulesResult - { - /// 是否全部成功 - public bool Success { get; set; } - /// 总数量 - public int TotalCount { get; set; } - /// 成功数量 - public int SuccessCount { get; set; } - /// 错误信息 - public string ErrorMessage { get; set; } - /// 级联停用的应用 ID 列表 - public List CascadedApplicationIds { get; set; } = new List(); - } -} diff --git a/YLErpDAL/Modules/RiskEngine/Dto/BatchOperationResult.cs b/YLErpDAL/Modules/RiskEngine/Dto/BatchOperationResult.cs index c6c7aafd..0ae0cdcb 100644 --- a/YLErpDAL/Modules/RiskEngine/Dto/BatchOperationResult.cs +++ b/YLErpDAL/Modules/RiskEngine/Dto/BatchOperationResult.cs @@ -1,32 +1,48 @@ +using System.Runtime.Serialization; +using System.Text.Json.Serialization; + namespace YLErp.Modules.RiskEngine.Dto { - /// - /// 批量操作结果 - /// - public class BatchOperationResult + [JsonConverter(typeof(JsonStringEnumConverter))] + public enum BatchOperationOutcome { - /// 是否全部成功 - public bool Success { get; set; } - /// 总数量 - public int TotalCount { get; set; } - /// 成功数量 - public int SuccessCount { get; set; } - /// 错误信息 - public string ErrorMessage { get; set; } + [EnumMember(Value = "AllSucceeded")] + AllSucceeded, + [EnumMember(Value = "PartiallySucceeded")] + PartiallySucceeded, + [EnumMember(Value = "AllFailed")] + AllFailed } - /// - /// 批量删除变量结果 - /// - public class BatchDeleteVariablesResult : BatchOperationResult + [JsonConverter(typeof(JsonStringEnumConverter))] + public enum BatchOperationItemStatus { - /// 成功删除的变量 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; } + [EnumMember(Value = "Succeeded")] + Succeeded, + [EnumMember(Value = "Unchanged")] + Unchanged, + [EnumMember(Value = "Failed")] + Failed + } + + public class BatchOperationItemResult + { + public long Id { get; set; } + public BatchOperationItemStatus Status { get; set; } + public string Code { get; set; } + public string Message { get; set; } + public List CascadedApplicationIds { get; set; } = new List(); + } + + public class BatchOperationResult + { + public BatchOperationOutcome Outcome { get; set; } + public int TotalCount { get; set; } + public int SucceededCount { get; set; } + public int UnchangedCount { get; set; } + public int FailedCount { get; set; } + public bool HasChanges { get; set; } + public List Items { get; set; } = new List(); + public List CascadedApplicationIds { get; set; } = new List(); } } diff --git a/YLErpDAL/Modules/RiskEngine/Dto/QueryRiskApplicationReq.cs b/YLErpDAL/Modules/RiskEngine/Dto/QueryRiskApplicationReq.cs index 0a501867..36916100 100644 --- a/YLErpDAL/Modules/RiskEngine/Dto/QueryRiskApplicationReq.cs +++ b/YLErpDAL/Modules/RiskEngine/Dto/QueryRiskApplicationReq.cs @@ -8,12 +8,12 @@ namespace YLErp.Modules.RiskEngine.Dto /// public class QueryRiskApplicationReq : BaseSearchReq { - /// 规则名称 - public string RuleName { get; set; } + /// 规则名称关键词 + public string Keyword { get; set; } /// 应用配置状态 public RiskRuleStatus? Status { get; set; } /// 风控策略 - public RiskControlStrategy? Strategy { get; set; } + public RiskControlStrategy? ControlStrategy { get; set; } /// 触发点 public string TriggerPoint { get; set; } /// 适用范围-客户 ID 列表 diff --git a/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs b/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs index 737aa940..d0abff89 100644 --- a/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs +++ b/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs @@ -1,4 +1,5 @@ -using BaseOUDAL; +using BaseOUDAL; +using Microsoft.EntityFrameworkCore; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using System.Globalization; @@ -261,6 +262,23 @@ namespace YLErp.Modules.RiskEngine #region Private Helpers + /// + /// 保存变更,并将 EF 乐观并发冲突转换为可识别的业务异常。 + /// Version 仍由业务代码手工递增;并发令牌只负责将原 Version 加入 UPDATE/DELETE 条件。 + /// + private void SaveChangesWithConcurrencyCheck() + { + try + { + DbContext.SaveChanges(); + } + catch (DbUpdateConcurrencyException ex) + { + _logger.Error("风控配置发生并发修改冲突", ex); + throw new ServiceException("数据已被其他用户修改,请重新加载后再操作", ex); + } + } + private static string GetAuditOperationTypeName(string operationType) { if (string.IsNullOrWhiteSpace(operationType)) @@ -780,6 +798,44 @@ namespace YLErp.Modules.RiskEngine return string.Join(", ", translated); } + private static List DistinctIds(List ids) + { + return ids.Distinct().ToList(); + } + + private static BatchOperationItemResult BatchItem(long id, BatchOperationItemStatus status, + string code, string message, IEnumerable cascadedApplicationIds = null) + { + return new BatchOperationItemResult + { + Id = id, + Status = status, + Code = code, + Message = message, + CascadedApplicationIds = cascadedApplicationIds?.Distinct().ToList() ?? new List() + }; + } + + private static BatchOperationResult BuildBatchResult(List items) + { + var result = new BatchOperationResult + { + Items = items, + TotalCount = items.Count, + SucceededCount = items.Count(i => i.Status == BatchOperationItemStatus.Succeeded), + UnchangedCount = items.Count(i => i.Status == BatchOperationItemStatus.Unchanged), + FailedCount = items.Count(i => i.Status == BatchOperationItemStatus.Failed), + CascadedApplicationIds = items.SelectMany(i => i.CascadedApplicationIds).Distinct().ToList() + }; + result.HasChanges = result.SucceededCount > 0; + result.Outcome = result.FailedCount == 0 + ? BatchOperationOutcome.AllSucceeded + : result.FailedCount == result.TotalCount + ? BatchOperationOutcome.AllFailed + : BatchOperationOutcome.PartiallySucceeded; + return result; + } + #endregion #region Rule Management @@ -894,7 +950,7 @@ namespace YLErp.Modules.RiskEngine ConditionJson = req.ConditionJson, RuleExpr = req.RuleExpr, Description = req.Description, - Status = RiskRuleStatus.Active, + Status = RiskRuleStatus.Disabled, Version = 1 }; @@ -908,11 +964,11 @@ namespace YLErp.Modules.RiskEngine try { DbContext.glms_risk_rule.Add(entity); - DbContext.SaveChanges(); + SaveChangesWithConcurrencyCheck(); WriteAuditLog("RULE_CREATE", "RULE", entity.id, req.RuleName, "创建规则", snapshotData: JsonConvert.SerializeObject(new { Version = entity.Version })); - DbContext.SaveChanges(); + SaveChangesWithConcurrencyCheck(); transaction.Commit(); } @@ -955,7 +1011,7 @@ namespace YLErp.Modules.RiskEngine rule.UpdateDate = DateTime.Now; WriteAuditLog("RULE_UPDATE", "RULE", ruleId, req.RuleName, "修改规则"); - DbContext.SaveChanges(); + SaveChangesWithConcurrencyCheck(); RiskEngineService.GetInstance().RefreshOneRuleCache(ruleId); @@ -983,7 +1039,7 @@ namespace YLErp.Modules.RiskEngine rule.UpdateDate = DateTime.Now; WriteAuditLog("RULE_DELETE", "RULE", ruleId, rule.RuleName, "删除规则"); - DbContext.SaveChanges(); + SaveChangesWithConcurrencyCheck(); RiskEngineService.GetInstance().RefreshOneRuleCache(ruleId); } @@ -1002,12 +1058,14 @@ namespace YLErp.Modules.RiskEngine throw new ServiceException($"规则表达式编译失败:{compileResult.ErrorMessage}"); rule.Status = RiskRuleStatus.Active; + + rule.Version = rule.Version + 1; rule.UpdateOptId = UserId; rule.UpdateOptName = UserName; rule.UpdateDate = DateTime.Now; WriteAuditLog("RULE_ENABLE", "RULE", ruleId, rule.RuleName, "启用规则"); - DbContext.SaveChanges(); + SaveChangesWithConcurrencyCheck(); RiskEngineService.GetInstance().RefreshOneRuleCache(ruleId); } @@ -1022,6 +1080,8 @@ namespace YLErp.Modules.RiskEngine throw new ServiceException("仅已启用的规则可以停用"); rule.Status = RiskRuleStatus.Disabled; + + rule.Version = rule.Version + 1; rule.UpdateOptId = UserId; rule.UpdateOptName = UserName; rule.UpdateDate = DateTime.Now; @@ -1038,6 +1098,7 @@ namespace YLErp.Modules.RiskEngine foreach (var app in activeApps) { app.Status = RiskRuleStatus.Disabled; + app.Version = app.Version + 1; app.UpdateOptId = UserId; app.UpdateOptName = UserName; app.UpdateDate = DateTime.Now; @@ -1052,7 +1113,7 @@ namespace YLErp.Modules.RiskEngine _logger.Info($"[停用规则] 规则ID: {ruleId}, 级联停用应用: {string.Join(",", cascadedIds)}"); } - DbContext.SaveChanges(); + SaveChangesWithConcurrencyCheck(); RiskEngineService.GetInstance().RefreshOneRuleCache(ruleId); if (cascadedIds.Any()) @@ -1109,79 +1170,48 @@ namespace YLErp.Modules.RiskEngine if (ruleIds == null || ruleIds.Count == 0) throw new ServiceException("规则ID列表不能为空"); - _logger.Info($"[批量删除] 开始 - 传入规则数: {ruleIds.Count}, IDs: {string.Join(",", ruleIds)}"); - - var rules = DbContext.glms_risk_rule - .Where(r => ruleIds.Contains(r.id) && r.Status != RiskRuleStatus.Deleted) + var ids = DistinctIds(ruleIds); + var rules = DbContext.glms_risk_rule.Where(r => ids.Contains(r.id)).ToDictionary(r => (long)r.id); + var activeApps = DbContext.glms_risk_rule_application + .Where(a => a.Status == RiskRuleStatus.Active) .ToList(); + var items = new List(); + var changedIds = new List(); - _logger.Info($"[批量删除] DB查询完成 - 匹配到 {rules.Count} 条规则"); - - var foundIds = rules.Select(r => (long)r.id).ToList(); - var missingIds = ruleIds.Except(foundIds).ToList(); - if (missingIds.Any()) + foreach (var id in ids) { - return new BatchOperationResult + if (!rules.TryGetValue(id, out var rule) || rule.Status == RiskRuleStatus.Deleted) { - Success = false, - TotalCount = ruleIds.Count, - SuccessCount = 0, - ErrorMessage = $"以下规则不存在或已删除:{string.Join(", ", missingIds)}" - }; - } + items.Add(BatchItem(id, BatchOperationItemStatus.Failed, "NOT_FOUND", "规则不存在或已删除")); + continue; + } - var blockedRules = new List(); - foreach (var rule in rules) - { - _logger.Info($"[批量删除] 检查关联应用 - 规则ID: {rule.id}"); - var activeApps = DbContext.glms_risk_rule_application - .Where(a => a.Status == RiskRuleStatus.Active) - .Where(RuleIdsMatchExpr(rule.id)) - .Select(a => (long)a.id) - .ToList(); - var activeCount = activeApps.Count; - _logger.Info($"[批量删除] 关联应用检查结果 - 规则ID: {rule.id}, ActiveCount: {activeCount}, AppIds: [{string.Join(",", activeApps)}]"); - if (activeCount > 0) - blockedRules.Add($"规则 '{rule.RuleName}'(ID:{rule.id}) 仍有 {activeCount} 个生效中的应用配置(IDs: {string.Join(",", activeApps)})"); - } - if (blockedRules.Any()) - { - return new BatchOperationResult + var referenceCount = activeApps.Count(a => ParseRuleIds(a.RuleIds).Contains(id)); + if (referenceCount > 0) { - Success = false, - TotalCount = ruleIds.Count, - SuccessCount = 0, - ErrorMessage = string.Join(";", blockedRules) - }; - } + items.Add(BatchItem(id, BatchOperationItemStatus.Failed, "ACTIVE_APPLICATION_REFERENCE", + $"该规则仍有 {referenceCount} 个生效中的应用配置,请先停用或删除相关应用")); + continue; + } - foreach (var rule in rules) - { - _logger.Info($"[批量删除] 写入状态 - 规则ID: {rule.id}"); rule.Status = RiskRuleStatus.Deleted; - rule.Version = rule.Version + 1; + rule.Version++; rule.UpdateOptId = UserId; rule.UpdateOptName = UserName; rule.UpdateDate = DateTime.Now; - WriteAuditLog("RULE_BATCH_DELETE", "RULE", rule.id, rule.RuleName, "批量删除规则"); + changedIds.Add(id); + items.Add(BatchItem(id, BatchOperationItemStatus.Succeeded, "DELETED", "规则已删除")); } - _logger.Info("[批量删除] 保存数据库..."); - DbContext.SaveChanges(); - _logger.Info("[批量删除] 刷新规则缓存..."); - foreach (var ruleId in ruleIds) + if (changedIds.Any()) { - RiskEngineService.GetInstance().RefreshOneRuleCache(ruleId); + SaveChangesWithConcurrencyCheck(); + foreach (var id in changedIds) + RiskEngineService.GetInstance().RefreshOneRuleCache(id); } - _logger.Info("[批量删除] 完成"); - return new BatchOperationResult - { - Success = true, - TotalCount = ruleIds.Count, - SuccessCount = ruleIds.Count - }; + return BuildBatchResult(items); } /// @@ -1192,200 +1222,115 @@ namespace YLErp.Modules.RiskEngine if (ruleIds == null || ruleIds.Count == 0) throw new ServiceException("规则ID列表不能为空"); - _logger.Info($"[批量启用规则] 开始 - 传入规则数: {ruleIds.Count}, IDs: {string.Join(",", ruleIds)}"); + var ids = DistinctIds(ruleIds); + var rules = DbContext.glms_risk_rule.Where(r => ids.Contains(r.id)).ToDictionary(r => (long)r.id); + var items = new List(); + var changedIds = new List(); - var rules = DbContext.glms_risk_rule - .Where(r => ruleIds.Contains(r.id) && r.Status != RiskRuleStatus.Deleted) - .ToList(); - - _logger.Info($"[批量启用规则] DB查询完成 - 匹配到 {rules.Count} 条规则"); - - var foundIds = rules.Select(r => (long)r.id).ToList(); - var missingIds = ruleIds.Except(foundIds).ToList(); - if (missingIds.Any()) + foreach (var id in ids) { - return new BatchOperationResult + if (!rules.TryGetValue(id, out var rule) || rule.Status == RiskRuleStatus.Deleted) { - Success = false, - TotalCount = ruleIds.Count, - SuccessCount = 0, - ErrorMessage = $"以下规则不存在或已删除:{string.Join(", ", missingIds)}" - }; - } - - // 跳过已启用的规则 - var skippedIds = rules.Where(r => r.Status == RiskRuleStatus.Active).Select(r => (long)r.id).ToList(); - if (skippedIds.Any()) - _logger.Info($"[批量启用规则] 跳过已启用规则 - IDs: {string.Join(",", skippedIds)}"); - - rules = rules.Where(r => r.Status != RiskRuleStatus.Active).ToList(); - if (!rules.Any()) - { - _logger.Info("[批量启用规则] 所有规则均已启用,无需操作"); - return new BatchOperationResult + items.Add(BatchItem(id, BatchOperationItemStatus.Failed, "NOT_FOUND", "规则不存在或已删除")); + continue; + } + if (rule.Status == RiskRuleStatus.Active) { - Success = true, - TotalCount = ruleIds.Count, - SuccessCount = ruleIds.Count - }; - } + items.Add(BatchItem(id, BatchOperationItemStatus.Unchanged, "ALREADY_ENABLED", "规则已启用,无需重复操作")); + continue; + } - // 逐条编译校验,任一失败则整体拒绝 - var errors = new List(); - foreach (var rule in rules) - { var compileResult = RuleCompiler.ValidateAndCompileFormula(rule.id, rule.RuleExpr); if (!compileResult.Success) - errors.Add($"规则'{rule.RuleName}'(ID:{rule.id}):{compileResult.ErrorMessage}"); - } - if (errors.Any()) - { - return new BatchOperationResult { - Success = false, - TotalCount = ruleIds.Count, - SuccessCount = 0, - ErrorMessage = $"规则表达式编译失败:{string.Join(";", errors)}" - }; - } + items.Add(BatchItem(id, BatchOperationItemStatus.Failed, "COMPILE_FAILED", + $"规则表达式编译失败:{compileResult.ErrorMessage}")); + continue; + } - foreach (var rule in rules) - { - _logger.Info($"[批量启用规则] 写入状态 - 规则ID: {rule.id}"); rule.Status = RiskRuleStatus.Active; + rule.Version++; rule.UpdateOptId = UserId; rule.UpdateOptName = UserName; rule.UpdateDate = DateTime.Now; + WriteAuditLog("RULE_BATCH_ENABLE", "RULE", rule.id, rule.RuleName, "批量启用规则"); + changedIds.Add(id); + items.Add(BatchItem(id, BatchOperationItemStatus.Succeeded, "ENABLED", "规则已启用")); } - var enabledIds = rules.Select(r => r.id).ToList(); - WriteAuditLog("RULE_BATCH_ENABLE", "RULE", 0, "", - $"批量启用规则(IDs: {string.Join(",", enabledIds)})"); - - _logger.Info("[批量启用规则] 保存数据库..."); - DbContext.SaveChanges(); - _logger.Info("[批量启用规则] 刷新规则缓存..."); - foreach (var rule in rules) + if (changedIds.Any()) { - RiskEngineService.GetInstance().RefreshOneRuleCache(rule.id); + SaveChangesWithConcurrencyCheck(); + foreach (var id in changedIds) + RiskEngineService.GetInstance().RefreshOneRuleCache(id); } - _logger.Info("[批量启用规则] 完成"); - return new BatchOperationResult - { - Success = true, - TotalCount = ruleIds.Count, - SuccessCount = ruleIds.Count - }; + return BuildBatchResult(items); } /// /// 批量停用规则(仅 Active → Disabled,级联停用关联应用) /// - public BatchDisableRulesResult BatchDisableRules(List ruleIds) + public BatchOperationResult BatchDisableRules(List ruleIds) { if (ruleIds == null || ruleIds.Count == 0) throw new ServiceException("规则ID列表不能为空"); - _logger.Info($"[批量停用规则] 开始 - 传入规则数: {ruleIds.Count}, IDs: {string.Join(",", ruleIds)}"); - - var rules = DbContext.glms_risk_rule - .Where(r => ruleIds.Contains(r.id) && r.Status != RiskRuleStatus.Deleted) + var ids = DistinctIds(ruleIds); + var rules = DbContext.glms_risk_rule.Where(r => ids.Contains(r.id)).ToDictionary(r => (long)r.id); + var activeRules = ids.Where(id => rules.TryGetValue(id, out var rule) && rule.Status == RiskRuleStatus.Active).ToList(); + var activeApps = DbContext.glms_risk_rule_application + .Where(a => a.Status == RiskRuleStatus.Active) .ToList(); + var cascadesByRule = activeRules.ToDictionary( + id => id, + id => activeApps.Where(a => ParseRuleIds(a.RuleIds).Contains(id)).Select(a => (long)a.id).Distinct().ToList()); + var items = new List(); - _logger.Info($"[批量停用规则] DB查询完成 - 匹配到 {rules.Count} 条规则"); - - var foundIds = rules.Select(r => (long)r.id).ToList(); - var missingIds = ruleIds.Except(foundIds).ToList(); - if (missingIds.Any()) + foreach (var id in ids) { - return new BatchDisableRulesResult + if (!rules.TryGetValue(id, out var rule) || rule.Status == RiskRuleStatus.Deleted) { - Success = false, - TotalCount = ruleIds.Count, - SuccessCount = 0, - ErrorMessage = $"以下规则不存在或已删除:{string.Join(", ", missingIds)}" - }; - } - - // 跳过已停用的规则 - var skippedIds = rules.Where(r => r.Status == RiskRuleStatus.Disabled).Select(r => (long)r.id).ToList(); - if (skippedIds.Any()) - _logger.Info($"[批量停用规则] 跳过已停用规则 - IDs: {string.Join(",", skippedIds)}"); - - rules = rules.Where(r => r.Status != RiskRuleStatus.Disabled).ToList(); - if (!rules.Any()) - { - _logger.Info("[批量停用规则] 所有规则均已停用,无需操作"); - return new BatchDisableRulesResult + items.Add(BatchItem(id, BatchOperationItemStatus.Failed, "NOT_FOUND", "规则不存在或已删除")); + continue; + } + if (rule.Status == RiskRuleStatus.Disabled) { - Success = true, - TotalCount = ruleIds.Count, - SuccessCount = ruleIds.Count - }; - } + items.Add(BatchItem(id, BatchOperationItemStatus.Unchanged, "ALREADY_DISABLED", "规则已停用,无需重复操作")); + continue; + } - var allCascadedAppIds = new List(); - foreach (var rule in rules) - { - _logger.Info($"[批量停用规则] 写入状态 - 规则ID: {rule.id}"); rule.Status = RiskRuleStatus.Disabled; + rule.Version++; rule.UpdateOptId = UserId; rule.UpdateOptName = UserName; rule.UpdateDate = DateTime.Now; + WriteAuditLog("RULE_BATCH_DISABLE", "RULE", rule.id, rule.RuleName, "批量停用规则"); + items.Add(BatchItem(id, BatchOperationItemStatus.Succeeded, "DISABLED", "规则已停用", cascadesByRule[id])); + } - // 级联停用关联的 Active 应用配置 - var activeApps = DbContext.glms_risk_rule_application - .Where(a => a.Status == RiskRuleStatus.Active) - .Where(RuleIdsMatchExpr(rule.id)) - .ToList(); - - var cascadedIds = new List(); - foreach (var app in activeApps) - { - // 同一应用可能同时关联多条待停用规则,只对实际仍为 Active 的应用处理一次。 - if (app.Status != RiskRuleStatus.Active) - continue; - - app.Status = RiskRuleStatus.Disabled; - app.UpdateOptId = UserId; - app.UpdateOptName = UserName; - app.UpdateDate = DateTime.Now; - cascadedIds.Add(app.id); - - WriteAuditLog("RULE_CASCADE_DISABLE_APP", "APPLICATION", app.id, $"应用{app.id}", - $"因规则'{rule.RuleName}'(ID:{rule.id})停用,应用配置被级联停用"); - } + var cascadedIds = items.SelectMany(i => i.CascadedApplicationIds).Distinct().ToHashSet(); + foreach (var app in activeApps.Where(a => cascadedIds.Contains(a.id))) + { + app.Status = RiskRuleStatus.Disabled; + app.Version++; + app.UpdateOptId = UserId; + app.UpdateOptName = UserName; + app.UpdateDate = DateTime.Now; + WriteAuditLog("RULE_CASCADE_DISABLE_APP", "APPLICATION", app.id, $"应用{app.id}", + $"因规则批量停用,应用配置被级联停用(规则IDs:{string.Join(",", activeRules.Where(id => cascadesByRule[id].Contains(app.id)))})"); + } + if (items.Any(i => i.Status == BatchOperationItemStatus.Succeeded)) + { + SaveChangesWithConcurrencyCheck(); + foreach (var id in activeRules) + RiskEngineService.GetInstance().RefreshOneRuleCache(id); if (cascadedIds.Any()) - { - _logger.Info($"[批量停用规则] 规则ID: {rule.id}, 级联停用应用: {string.Join(",", cascadedIds)}"); - allCascadedAppIds.AddRange(cascadedIds); - } + RiskEngineService.GetInstance().RefreshApplication(); } - var disabledIds = rules.Select(r => r.id).ToList(); - WriteAuditLog("RULE_BATCH_DISABLE", "RULE", 0, "", - $"批量停用规则(IDs: {string.Join(",", disabledIds)})"); - - _logger.Info("[批量停用规则] 保存数据库..."); - DbContext.SaveChanges(); - _logger.Info("[批量停用规则] 刷新规则缓存..."); - foreach (var rule in rules) - { - RiskEngineService.GetInstance().RefreshOneRuleCache(rule.id); - } - if (allCascadedAppIds.Any()) - RiskEngineService.GetInstance().RefreshApplication(); - _logger.Info("[批量停用规则] 完成"); - - return new BatchDisableRulesResult - { - Success = true, - TotalCount = ruleIds.Count, - SuccessCount = ruleIds.Count, - CascadedApplicationIds = allCascadedAppIds - }; + return BuildBatchResult(items); } /// @@ -1462,9 +1407,9 @@ namespace YLErp.Modules.RiskEngine query = query.Where(a => a.Status == req.Status.Value); } - if (req.Strategy.HasValue) + if (req.ControlStrategy.HasValue) { - query = query.Where(a => a.ControlStrategy == req.Strategy.Value); + query = query.Where(a => a.ControlStrategy == req.ControlStrategy.Value); } if (!string.IsNullOrWhiteSpace(req.TriggerPoint)) @@ -1472,11 +1417,11 @@ namespace YLErp.Modules.RiskEngine query = query.Where(a => a.TriggerPoints.Contains(req.TriggerPoint)); } - if (!string.IsNullOrWhiteSpace(req.RuleName)) + if (!string.IsNullOrWhiteSpace(req.Keyword)) { query = query.Where(a => DbContext.glms_risk_rule .Any(r => r.Status != RiskRuleStatus.Deleted - && r.RuleName.Contains(req.RuleName) + && r.RuleName.Contains(req.Keyword) && a.RuleIds != null && (a.RuleIds == r.id.ToString() || a.RuleIds.StartsWith(r.id.ToString() + ",") @@ -1568,7 +1513,7 @@ namespace YLErp.Modules.RiskEngine { RuleIds = normalizedRuleIds, Description = req.Description, - Status = RiskRuleStatus.Active, + Status = RiskRuleStatus.Disabled, ControlStrategy = req.ControlStrategy, TriggerPoints = req.TriggerPoints, ScopeAssetBookIds = req.ScopeAssetBookIds, @@ -1589,12 +1534,12 @@ namespace YLErp.Modules.RiskEngine try { DbContext.glms_risk_rule_application.Add(entity); - DbContext.SaveChanges(); + SaveChangesWithConcurrencyCheck(); WriteAuditLog("APP_CREATE", "APPLICATION", entity.id, ResolveRuleNames(entity.RuleIds), $"创建应用配置:策略={GetStrategyName(req.ControlStrategy)}, 触发时点={MapTriggerPointsToChinese(req.TriggerPoints)}", snapshotData: JsonConvert.SerializeObject(new { Version = entity.Version })); - DbContext.SaveChanges(); + SaveChangesWithConcurrencyCheck(); transaction.Commit(); } @@ -1657,7 +1602,7 @@ namespace YLErp.Modules.RiskEngine WriteAuditLog("APP_UPDATE", "APPLICATION", applicationId, ResolveRuleNames(app.RuleIds), $"修改应用配置:策略={GetStrategyName(req.ControlStrategy)}, 触发时点={MapTriggerPointsToChinese(req.TriggerPoints)}"); - DbContext.SaveChanges(); + SaveChangesWithConcurrencyCheck(); RiskEngineService.GetInstance().RefreshApplication(); @@ -1678,7 +1623,7 @@ namespace YLErp.Modules.RiskEngine app.UpdateDate = DateTime.Now; WriteAuditLog("APP_DELETE", "APPLICATION", applicationId, ResolveRuleNames(app.RuleIds), "删除应用配置"); - DbContext.SaveChanges(); + SaveChangesWithConcurrencyCheck(); RiskEngineService.GetInstance().RefreshApplication(); } @@ -1691,53 +1636,32 @@ namespace YLErp.Modules.RiskEngine if (applicationIds == null || applicationIds.Count == 0) throw new ServiceException("应用配置ID列表不能为空"); - _logger.Info($"[批量删除应用] 开始 - 传入应用数: {applicationIds.Count}, IDs: {string.Join(",", applicationIds)}"); - - var apps = DbContext.glms_risk_rule_application - .Where(a => applicationIds.Contains(a.id) && a.Status != RiskRuleStatus.Deleted) - .ToList(); - - _logger.Info($"[批量删除应用] DB查询完成 - 匹配到 {apps.Count} 条应用"); - - var foundIds = apps.Select(a => (long)a.id).ToList(); - var missingIds = applicationIds.Except(foundIds).ToList(); - if (missingIds.Any()) + var ids = DistinctIds(applicationIds); + var apps = DbContext.glms_risk_rule_application.Where(a => ids.Contains(a.id)).ToDictionary(a => (long)a.id); + var items = new List(); + foreach (var id in ids) { - return new BatchOperationResult + if (!apps.TryGetValue(id, out var app) || app.Status == RiskRuleStatus.Deleted) { - Success = false, - TotalCount = applicationIds.Count, - SuccessCount = 0, - ErrorMessage = $"以下应用配置不存在或已删除:{string.Join(", ", missingIds)}" - }; - } + items.Add(BatchItem(id, BatchOperationItemStatus.Failed, "NOT_FOUND", "应用配置不存在或已删除")); + continue; + } - foreach (var app in apps) - { - _logger.Info($"[批量删除应用] 写入状态 - 应用ID: {app.id}"); app.Status = RiskRuleStatus.Deleted; - app.Version = app.Version + 1; + app.Version++; app.UpdateOptId = UserId; app.UpdateOptName = UserName; app.UpdateDate = DateTime.Now; + WriteAuditLog("APP_BATCH_DELETE", "APPLICATION", app.id, ResolveRuleNames(app.RuleIds), "批量删除应用配置"); + items.Add(BatchItem(id, BatchOperationItemStatus.Succeeded, "DELETED", "应用配置已删除")); } - var deletedIds = apps.Select(a => a.id).ToList(); - WriteAuditLog("APP_BATCH_DELETE", "APPLICATION", 0, "", - $"批量删除应用配置(IDs: {string.Join(",", deletedIds)})"); - - _logger.Info("[批量删除应用] 保存数据库..."); - DbContext.SaveChanges(); - _logger.Info("[批量删除应用] 刷新应用缓存..."); - RiskEngineService.GetInstance().RefreshApplication(); - _logger.Info("[批量删除应用] 完成"); - - return new BatchOperationResult + if (items.Any(i => i.Status == BatchOperationItemStatus.Succeeded)) { - Success = true, - TotalCount = applicationIds.Count, - SuccessCount = applicationIds.Count - }; + SaveChangesWithConcurrencyCheck(); + RiskEngineService.GetInstance().RefreshApplication(); + } + return BuildBatchResult(items); } /// @@ -1754,12 +1678,14 @@ namespace YLErp.Modules.RiskEngine throw new ServiceException($"以下关联规则不可用:{string.Join(";", errors)}"); app.Status = RiskRuleStatus.Active; + + app.Version = app.Version + 1; app.UpdateOptId = UserId; app.UpdateOptName = UserName; app.UpdateDate = DateTime.Now; WriteAuditLog("APP_ENABLE", "APPLICATION", applicationId, ResolveRuleNames(app.RuleIds), "启用应用配置"); - DbContext.SaveChanges(); + SaveChangesWithConcurrencyCheck(); RiskEngineService.GetInstance().RefreshApplication(); } @@ -1774,12 +1700,14 @@ namespace YLErp.Modules.RiskEngine throw new ServiceException("仅已启用的应用配置可以停用"); app.Status = RiskRuleStatus.Disabled; + + app.Version = app.Version + 1; app.UpdateOptId = UserId; app.UpdateOptName = UserName; app.UpdateDate = DateTime.Now; WriteAuditLog("APP_DISABLE", "APPLICATION", applicationId, ResolveRuleNames(app.RuleIds), "停用应用配置"); - DbContext.SaveChanges(); + SaveChangesWithConcurrencyCheck(); RiskEngineService.GetInstance().RefreshApplication(); } @@ -1792,88 +1720,50 @@ namespace YLErp.Modules.RiskEngine if (applicationIds == null || applicationIds.Count == 0) throw new ServiceException("应用配置ID列表不能为空"); - _logger.Info($"[批量启用] 开始 - 传入应用数: {applicationIds.Count}, IDs: {string.Join(",", applicationIds)}"); - - var apps = DbContext.glms_risk_rule_application - .Where(a => applicationIds.Contains(a.id)) - .ToList(); - - _logger.Info($"[批量启用] DB查询完成 - 匹配到 {apps.Count} 条应用"); - - var foundIds = apps.Select(a => (long)a.id).ToList(); - var missingIds = applicationIds.Except(foundIds).ToList(); - if (missingIds.Any()) + var ids = DistinctIds(applicationIds); + var apps = DbContext.glms_risk_rule_application.Where(a => ids.Contains(a.id)).ToDictionary(a => (long)a.id); + var items = new List(); + foreach (var id in ids) { - return new BatchOperationResult + if (!apps.TryGetValue(id, out var app)) { - Success = false, - TotalCount = applicationIds.Count, - SuccessCount = 0, - ErrorMessage = $"以下应用配置不存在:{string.Join(", ", missingIds)}" - }; - } - - // 跳过已启用的应用,只处理已停用的 - var skippedIds = apps.Where(a => a.Status == RiskRuleStatus.Active).Select(a => (long)a.id).ToList(); - if (skippedIds.Any()) - _logger.Info($"[批量启用] 跳过已启用应用 - IDs: {string.Join(",", skippedIds)}"); - - apps = apps.Where(a => a.Status != RiskRuleStatus.Active).ToList(); - if (!apps.Any()) - { - _logger.Info("[批量启用] 所有应用均已启用,无需操作"); - return new BatchOperationResult + items.Add(BatchItem(id, BatchOperationItemStatus.Failed, "NOT_FOUND", "应用配置不存在")); + continue; + } + if (app.Status == RiskRuleStatus.Deleted) { - Success = true, - TotalCount = applicationIds.Count, - SuccessCount = applicationIds.Count - }; - } + items.Add(BatchItem(id, BatchOperationItemStatus.Failed, "DELETED_ENTITY", "已删除应用不能启用")); + continue; + } + if (app.Status == RiskRuleStatus.Active) + { + items.Add(BatchItem(id, BatchOperationItemStatus.Unchanged, "ALREADY_ENABLED", "应用配置已启用,无需重复操作")); + continue; + } - var allErrors = new List(); - foreach (var app in apps) - { - _logger.Info($"[批量启用] 校验规则 - 应用ID: {app.id}, RuleIds: {app.RuleIds}"); var errors = ValidateAndEnsureRuleCache(app.RuleIds); - _logger.Info($"[批量启用] 规则校验结果 - 应用ID: {app.id}, Errors: {(errors.Any() ? string.Join(";", errors) : "无")}"); if (errors.Any()) - allErrors.Add($"应用 '{app.id}':{string.Join(";", errors)}"); - } - - if (allErrors.Any()) - { - return new BatchOperationResult { - Success = false, - TotalCount = applicationIds.Count, - SuccessCount = 0, - ErrorMessage = string.Join(";", allErrors) - }; - } + items.Add(BatchItem(id, BatchOperationItemStatus.Failed, "RELATED_RULE_UNAVAILABLE", + $"关联规则不可用:{string.Join(";", errors)}")); + continue; + } - foreach (var app in apps) - { - _logger.Info($"[批量启用] 写入状态 - 应用ID: {app.id}"); app.Status = RiskRuleStatus.Active; + app.Version++; app.UpdateOptId = UserId; app.UpdateOptName = UserName; app.UpdateDate = DateTime.Now; - WriteAuditLog("APP_BATCH_ENABLE", "APPLICATION", app.id, ResolveRuleNames(app.RuleIds), "批量启用应用配置"); + items.Add(BatchItem(id, BatchOperationItemStatus.Succeeded, "ENABLED", "应用配置已启用")); } - _logger.Info("[批量启用] 保存数据库..."); - DbContext.SaveChanges(); - _logger.Info("[批量启用] 刷新应用缓存..."); - RiskEngineService.GetInstance().RefreshApplication(); - _logger.Info("[批量启用] 完成"); - - return new BatchOperationResult + if (items.Any(i => i.Status == BatchOperationItemStatus.Succeeded)) { - Success = true, - TotalCount = applicationIds.Count, - SuccessCount = applicationIds.Count - }; + SaveChangesWithConcurrencyCheck(); + RiskEngineService.GetInstance().RefreshApplication(); + } + return BuildBatchResult(items); } /// @@ -1884,50 +1774,42 @@ namespace YLErp.Modules.RiskEngine if (applicationIds == null || applicationIds.Count == 0) throw new ServiceException("应用配置ID列表不能为空"); - _logger.Info($"[批量停用] 开始 - 传入应用数: {applicationIds.Count}, IDs: {string.Join(",", applicationIds)}"); - - var apps = DbContext.glms_risk_rule_application - .Where(a => applicationIds.Contains(a.id) && a.Status == RiskRuleStatus.Active) - .ToList(); - - _logger.Info($"[批量停用] DB查询完成 - 匹配到 {apps.Count} 条已启用应用"); - - var foundIds = apps.Select(a => (long)a.id).ToList(); - var missingIds = applicationIds.Except(foundIds).ToList(); - if (missingIds.Any()) + var ids = DistinctIds(applicationIds); + var apps = DbContext.glms_risk_rule_application.Where(a => ids.Contains(a.id)).ToDictionary(a => (long)a.id); + var items = new List(); + foreach (var id in ids) { - return new BatchOperationResult + if (!apps.TryGetValue(id, out var app)) { - Success = false, - TotalCount = applicationIds.Count, - SuccessCount = apps.Count, - ErrorMessage = $"以下应用配置不存在或当前状态不允许停用:{string.Join(", ", missingIds)}" - }; - } + items.Add(BatchItem(id, BatchOperationItemStatus.Failed, "NOT_FOUND", "应用配置不存在")); + continue; + } + if (app.Status == RiskRuleStatus.Deleted) + { + items.Add(BatchItem(id, BatchOperationItemStatus.Failed, "DELETED_ENTITY", "已删除应用不能停用")); + continue; + } + if (app.Status == RiskRuleStatus.Disabled) + { + items.Add(BatchItem(id, BatchOperationItemStatus.Unchanged, "ALREADY_DISABLED", "应用配置已停用,无需重复操作")); + continue; + } - foreach (var app in apps) - { - _logger.Info($"[批量停用] 写入状态 - 应用ID: {app.id}"); app.Status = RiskRuleStatus.Disabled; + app.Version++; app.UpdateOptId = UserId; app.UpdateOptName = UserName; app.UpdateDate = DateTime.Now; - WriteAuditLog("APP_BATCH_DISABLE", "APPLICATION", app.id, ResolveRuleNames(app.RuleIds), "批量停用应用配置"); + items.Add(BatchItem(id, BatchOperationItemStatus.Succeeded, "DISABLED", "应用配置已停用")); } - _logger.Info("[批量停用] 保存数据库..."); - DbContext.SaveChanges(); - _logger.Info("[批量停用] 刷新应用缓存..."); - RiskEngineService.GetInstance().RefreshApplication(); - _logger.Info("[批量停用] 完成"); - - return new BatchOperationResult + if (items.Any(i => i.Status == BatchOperationItemStatus.Succeeded)) { - Success = true, - TotalCount = applicationIds.Count, - SuccessCount = applicationIds.Count - }; + SaveChangesWithConcurrencyCheck(); + RiskEngineService.GetInstance().RefreshApplication(); + } + return BuildBatchResult(items); } #endregion @@ -2237,11 +2119,11 @@ namespace YLErp.Modules.RiskEngine try { DbContext.glms_risk_variable.Add(entity); - DbContext.SaveChanges(); + SaveChangesWithConcurrencyCheck(); WriteAuditLog("VARIABLE_CREATE", "VARIABLE", entity.id, req.VariableName, $"创建变量:{req.VariableName}", snapshotData: JsonConvert.SerializeObject(new { Version = entity.Version })); - DbContext.SaveChanges(); + SaveChangesWithConcurrencyCheck(); transaction.Commit(); } @@ -2333,11 +2215,11 @@ namespace YLErp.Modules.RiskEngine $"因变量'{variable.VariableName}'(ID:{variableId})表达式变更,规则表达式被级联更新"); } - DbContext.SaveChanges(); + SaveChangesWithConcurrencyCheck(); WriteAuditLog("VAR_UPDATE", "VARIABLE", variableId, req.VariableName, $"修改变量:{variable.VariableName}"); - DbContext.SaveChanges(); + SaveChangesWithConcurrencyCheck(); transaction.Commit(); } catch @@ -2356,7 +2238,7 @@ namespace YLErp.Modules.RiskEngine else { WriteAuditLog("VAR_UPDATE", "VARIABLE", variableId, req.VariableName, $"修改变量:{variable.VariableName}"); - DbContext.SaveChanges(); + SaveChangesWithConcurrencyCheck(); InvalidateVariableCache(); } @@ -2378,7 +2260,7 @@ namespace YLErp.Modules.RiskEngine DbContext.glms_risk_variable.Remove(variable); WriteAuditLog("VAR_DELETE", "VARIABLE", variableId, variable.VariableName, $"删除变量:{variable.VariableName}"); - DbContext.SaveChanges(); + SaveChangesWithConcurrencyCheck(); InvalidateVariableCache(); } @@ -2386,53 +2268,47 @@ namespace YLErp.Modules.RiskEngine /// /// 批量删除变量(不存在或被生效规则引用的变量将被跳过) /// - public BatchDeleteVariablesResult BatchDeleteVariables(List variableIds) + public BatchOperationResult 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)) + var ids = DistinctIds(variableIds); + var variables = DbContext.glms_risk_variable.Where(v => ids.Contains(v.id)).ToDictionary(v => (long)v.id); + var activeRules = DbContext.glms_risk_rule + .Where(r => r.Status == RiskRuleStatus.Active && r.ConditionJson != null) .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(); + var items = new List(); - foreach (var variable in variables) + foreach (var id in ids) { - var activeRefCount = GetRulesByVariableId(variable.id) - .Count(r => r.Status == RiskRuleStatus.Active); + if (!variables.TryGetValue(id, out var variable)) + { + items.Add(BatchItem(id, BatchOperationItemStatus.Failed, "NOT_FOUND", "变量不存在")); + continue; + } + + var idPattern = $@"""VariableId""\s*:\s*{id}\b"; + var thresholdPattern = $@"""ThresholdVariableId""\s*:\s*{id}\b"; + var activeRefCount = activeRules.Count(r => Regex.IsMatch(r.ConditionJson, idPattern) || Regex.IsMatch(r.ConditionJson, thresholdPattern)); if (activeRefCount > 0) { - blockedIds.Add(variable.id); - blockedReasons[variable.id] = $"该变量被 {activeRefCount} 个生效中的规则引用,无法删除"; + items.Add(BatchItem(id, BatchOperationItemStatus.Failed, "ACTIVE_RULE_REFERENCE", + $"该变量被 {activeRefCount} 个生效中的规则引用,无法删除")); continue; } DbContext.glms_risk_variable.Remove(variable); WriteAuditLog("VAR_BATCH_DELETE", "VARIABLE", variable.id, variable.VariableName, $"批量删除变量:{variable.VariableName}"); - deletedIds.Add(variable.id); + items.Add(BatchItem(id, BatchOperationItemStatus.Succeeded, "DELETED", "变量已删除")); } - if (deletedIds.Count > 0) + if (items.Any(i => i.Status == BatchOperationItemStatus.Succeeded)) { - DbContext.SaveChanges(); + SaveChangesWithConcurrencyCheck(); InvalidateVariableCache(); } - - return new BatchDeleteVariablesResult - { - Success = true, - TotalCount = distinctIds.Count, - SuccessCount = deletedIds.Count, - DeletedIds = deletedIds, - MissingIds = missingIds, - BlockedIds = blockedIds, - BlockedReasons = blockedReasons - }; + return BuildBatchResult(items); } /// diff --git a/YLErpWeb/Controllers/RiskRuleController.cs b/YLErpWeb/Controllers/RiskRuleController.cs index e47aa4f7..e90a2087 100644 --- a/YLErpWeb/Controllers/RiskRuleController.cs +++ b/YLErpWeb/Controllers/RiskRuleController.cs @@ -1,4 +1,4 @@ -using Qdp.Foundation.Utilities; +using Qdp.Foundation.Utilities; using System.Threading.Tasks; using YLErp.DBModels; using YLErp.Modules.RiskEngine; @@ -157,8 +157,6 @@ namespace YLErp.Web.Controllers { var service = GetRiskRuleService(); var result = await Task.Run(() => service.BatchDeleteRules(req?.Ids)); - if (!result.Success) - return Json(new { success = false, message = result.ErrorMessage }); return Json(new { success = true, data = result }); } catch (ServiceException ex) @@ -230,8 +228,6 @@ namespace YLErp.Web.Controllers { var service = GetRiskRuleService(); var result = await Task.Run(() => service.BatchEnableRules(req?.Ids)); - if (!result.Success) - return Json(new { success = false, message = result.ErrorMessage }); return Json(new { success = true, data = result }); } catch (ServiceException ex) @@ -254,15 +250,7 @@ namespace YLErp.Web.Controllers { var service = GetRiskRuleService(); var result = await Task.Run(() => service.BatchDisableRules(req?.Ids)); - if (!result.Success) - return Json(new { success = false, message = result.ErrorMessage }); - return Json(new - { - success = true, - data = result, - cascadedAppIds = result.CascadedApplicationIds, - cascadedAppCount = result.CascadedApplicationIds.Count - }); + return Json(new { success = true, data = result }); } catch (ServiceException ex) { @@ -486,8 +474,6 @@ namespace YLErp.Web.Controllers { var service = GetRiskRuleService(); var result = await Task.Run(() => service.BatchEnableApplications(req?.Ids)); - if (!result.Success) - return Json(new { success = false, message = result.ErrorMessage }); return Json(new { success = true, data = result }); } catch (ServiceException ex) @@ -510,8 +496,6 @@ namespace YLErp.Web.Controllers { var service = GetRiskRuleService(); var result = await Task.Run(() => service.BatchDisableApplications(req?.Ids)); - if (!result.Success) - return Json(new { success = false, message = result.ErrorMessage }); return Json(new { success = true, data = result }); } catch (ServiceException ex) @@ -534,8 +518,6 @@ namespace YLErp.Web.Controllers { var service = GetRiskRuleService(); var result = await Task.Run(() => service.BatchDeleteApplications(req?.Ids)); - if (!result.Success) - return Json(new { success = false, message = result.ErrorMessage }); return Json(new { success = true, data = result }); } catch (ServiceException ex) From 3651d3d2df6e1a1ea094e0df0aae3307979e7e3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=B9=E5=B3=B0?= Date: Thu, 23 Jul 2026 18:30:05 +0800 Subject: [PATCH 3/8] =?UTF-8?q?feat:=20=E9=A3=8E=E6=8E=A7=E5=BC=95?= =?UTF-8?q?=E6=93=8E=E5=89=8D=E5=90=8E=E7=AB=AF=E7=BB=9F=E4=B8=80=E6=93=8D?= =?UTF-8?q?=E4=BD=9C=E7=AC=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...{prod.sql => create_risk_engine_table.sql} | 14 +- ...g.sql => insert_risk_engine_appconfig.sql} | 0 .../Ver-5.6.0/insert_risk_engine_sys_menu.sql | 55 +++ .../migrate_condition_operator_tokens.sql | 31 ++ .../rollback_condition_operator_tokens.sql | 23 + .../DbUpdate/Ver-5.6.0/seed_applications.sql | 135 ------ .../Ver-5.6.0/seed_menu_risk_alert.sql | 50 -- .../seed_risk_engine_applications.sql | 121 +++++ .../Ver-5.6.0/seed_risk_engine_rules.sql | 121 +++++ .../Ver-5.6.0/seed_risk_engine_variables.sql | 112 +++++ .../DbUpdate/Ver-5.6.0/seed_rules.sql | 292 ------------ .../DbUpdate/Ver-5.6.0/seed_variables.sql | 73 --- .../RuleConditionExpressionBuilderTest.cs | 322 +++++++++++++ UnitTestProject/Program.cs | 3 + .../Modules/RiskEngine/Dto/RuleCondition.cs | 32 +- .../Modules/RiskEngine/RiskRuleService.cs | 442 ++++++------------ .../RuleConditionExpressionBuilder.cs | 349 ++++++++++++++ YLErpWeb/Controllers/RiskRuleController.cs | 25 +- 18 files changed, 1313 insertions(+), 887 deletions(-) rename Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/{prod.sql => create_risk_engine_table.sql} (95%) rename Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/{seed_appconfig.sql => insert_risk_engine_appconfig.sql} (100%) create mode 100644 Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/insert_risk_engine_sys_menu.sql create mode 100644 Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/migrate_condition_operator_tokens.sql create mode 100644 Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/rollback_condition_operator_tokens.sql delete mode 100644 Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/seed_applications.sql delete mode 100644 Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/seed_menu_risk_alert.sql create mode 100644 Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/seed_risk_engine_applications.sql create mode 100644 Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/seed_risk_engine_rules.sql create mode 100644 Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/seed_risk_engine_variables.sql delete mode 100644 Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/seed_rules.sql delete mode 100644 Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/seed_variables.sql create mode 100644 UnitTestProject/Modules/RiskEngine/RuleConditionExpressionBuilderTest.cs create mode 100644 YLErpDAL/Modules/RiskEngine/RuleConditionExpressionBuilder.cs diff --git a/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/prod.sql b/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/create_risk_engine_table.sql similarity index 95% rename from Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/prod.sql rename to Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/create_risk_engine_table.sql index b971458f..07c2d87c 100644 --- a/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/prod.sql +++ b/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/create_risk_engine_table.sql @@ -1,6 +1,4 @@ -SET FOREIGN_KEY_CHECKS=0; - -CREATE TABLE `glms_risk_rule` ( +CREATE TABLE `yltrs_ylcms`.`glms_risk_rule` ( `id` int NOT NULL AUTO_INCREMENT COMMENT '主键Id', `RuleName` varchar(200) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '规则名称', `RuleText` varchar(500) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '规则文本', @@ -19,7 +17,7 @@ CREATE TABLE `glms_risk_rule` ( INDEX `idx_status`(`Status` ASC) USING BTREE ) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci COMMENT = '风控规则定义表' ROW_FORMAT = Dynamic; -CREATE TABLE `glms_risk_rule_application` ( +CREATE TABLE `yltrs_ylcms`.`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 '描述', @@ -42,7 +40,7 @@ CREATE TABLE `glms_risk_rule_application` ( INDEX `idx_status`(`Status` ASC) USING BTREE ) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci COMMENT = '风控规则应用配置表' ROW_FORMAT = Dynamic; -CREATE TABLE `glms_risk_rule_audit_log` ( +CREATE TABLE `yltrs_ylcms`.`glms_risk_rule_audit_log` ( `id` int NOT NULL AUTO_INCREMENT COMMENT '主键Id', `OperationType` varchar(50) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '操作类型(RULE_CREATE/RULE_UPDATE等)', `TargetType` varchar(50) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '目标类型(RULE/APPLICATION/VARIABLE)', @@ -61,7 +59,7 @@ CREATE TABLE `glms_risk_rule_audit_log` ( INDEX `idx_target_id`(`TargetId` ASC) USING BTREE ) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci COMMENT = '风控规则操作审计日志表' ROW_FORMAT = Dynamic; -CREATE TABLE `glms_risk_variable` ( +CREATE TABLE `yltrs_ylcms`.`glms_risk_variable` ( `id` int NOT NULL AUTO_INCREMENT COMMENT '主键Id', `VariableName` varchar(200) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '变量名称', `Category` tinyint NOT NULL COMMENT '分类: 1=BookingElement, 2=MarketData, 3=SystemCalc, 4=BooleanCheck', @@ -79,6 +77,4 @@ CREATE TABLE `glms_risk_variable` ( `UpdateOptName` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '最后修改人名称', `UpdateDate` datetime NULL DEFAULT NULL COMMENT '最后修改时间', PRIMARY KEY (`id`) USING BTREE -) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci COMMENT = '风控变量池定义表' ROW_FORMAT = Dynamic; - -SET FOREIGN_KEY_CHECKS=1; +) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci COMMENT = '风控变量池定义表' ROW_FORMAT = Dynamic; \ No newline at end of file diff --git a/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/seed_appconfig.sql b/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/insert_risk_engine_appconfig.sql similarity index 100% rename from Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/seed_appconfig.sql rename to Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/insert_risk_engine_appconfig.sql diff --git a/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/insert_risk_engine_sys_menu.sql b/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/insert_risk_engine_sys_menu.sql new file mode 100644 index 00000000..b7dd81ce --- /dev/null +++ b/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/insert_risk_engine_sys_menu.sql @@ -0,0 +1,55 @@ +-- 异常交易监控页面 - 操作权限(按钮)数据 +-- 依赖:风险控制目录(name='风险控制',type=0)必须已存在 +-- 对应 FunctionRight.xml 风险控制下风控相关操作权限(共16个按钮) +-- type: 0=目录 1=菜单 2=按钮 +-- 注意:以下 sys_menu 和 sys_role_menu 两个 INSERT 依赖用户变量 @menu_id,必须在同一个 Session 中执行 +-- ============================================================ + +-- ---------------------------- +-- 3.1. sys_menu 页面+按钮(type=1,2) +-- ---------------------------- +SELECT IFNULL(MAX(id), 0) INTO @menu_id FROM `yltrs_admin`.`sys_menu`; +SELECT id INTO @risk_control_id FROM `yltrs_admin`.`sys_menu` WHERE `name` = '风险控制' AND `type` = 0 LIMIT 1; + +INSERT INTO `yltrs_admin`.`sys_menu` (`id`, `pid`, `pids`, `name`, `code`, `type`, `icon`, `router`, `component`, `permission`, `application`, `open_type`, `visible`, `link`, `redirect`, `weight`, `sort`, `remark`, `status`, `create_time`, `create_user`, `update_time`, `update_user`) VALUES +(@menu_id + 1, @risk_control_id, NULL, '异常交易监控', NULL, 1, NULL, NULL, NULL, '异常交易监控', NULL, NULL, NULL, NULL, NULL, NULL, 0, '', 0, NULL, NULL, NULL, NULL), +(@menu_id + 2, @menu_id + 1, NULL, '风控规则查看', NULL, 2, NULL, NULL, NULL, '风控规则查看', NULL, NULL, NULL, NULL, NULL, NULL, 1, '', 0, NULL, NULL, NULL, NULL), +(@menu_id + 3, @menu_id + 1, NULL, '风控规则新增', NULL, 2, NULL, NULL, NULL, '风控规则新增', NULL, NULL, NULL, NULL, NULL, NULL, 2, '', 0, NULL, NULL, NULL, NULL), +(@menu_id + 4, @menu_id + 1, NULL, '风控规则编辑', NULL, 2, NULL, NULL, NULL, '风控规则编辑', NULL, NULL, NULL, NULL, NULL, NULL, 3, '', 0, NULL, NULL, NULL, NULL), +(@menu_id + 5, @menu_id + 1, NULL, '风控规则删除', NULL, 2, NULL, NULL, NULL, '风控规则删除', NULL, NULL, NULL, NULL, NULL, NULL, 4, '', 0, NULL, NULL, NULL, NULL), +(@menu_id + 6, @menu_id + 1, NULL, '风控规则启停', NULL, 2, NULL, NULL, NULL, '风控规则启停', NULL, NULL, NULL, NULL, NULL, NULL, 5, '', 0, NULL, NULL, NULL, NULL), +(@menu_id + 7, @menu_id + 1, NULL, '风控应用查看', NULL, 2, NULL, NULL, NULL, '风控应用查看', NULL, NULL, NULL, NULL, NULL, NULL, 6, '', 0, NULL, NULL, NULL, NULL), +(@menu_id + 8, @menu_id + 1, NULL, '风控应用新增', NULL, 2, NULL, NULL, NULL, '风控应用新增', NULL, NULL, NULL, NULL, NULL, NULL, 7, '', 0, NULL, NULL, NULL, NULL), +(@menu_id + 9, @menu_id + 1, NULL, '风控应用编辑', NULL, 2, NULL, NULL, NULL, '风控应用编辑', NULL, NULL, NULL, NULL, NULL, NULL, 8, '', 0, NULL, NULL, NULL, NULL), +(@menu_id + 10, @menu_id + 1, NULL, '风控应用删除', NULL, 2, NULL, NULL, NULL, '风控应用删除', NULL, NULL, NULL, NULL, NULL, NULL, 9, '', 0, NULL, NULL, NULL, NULL), +(@menu_id + 11, @menu_id + 1, NULL, '风控应用启停', NULL, 2, NULL, NULL, NULL, '风控应用启停', NULL, NULL, NULL, NULL, NULL, NULL, 10, '', 0, NULL, NULL, NULL, NULL), +(@menu_id + 12, @menu_id + 1, NULL, '风控变量查看', NULL, 2, NULL, NULL, NULL, '风控变量查看', NULL, NULL, NULL, NULL, NULL, NULL, 11, '', 0, NULL, NULL, NULL, NULL), +(@menu_id + 13, @menu_id + 1, NULL, '风控变量新增', NULL, 2, NULL, NULL, NULL, '风控变量新增', NULL, NULL, NULL, NULL, NULL, NULL, 12, '', 0, NULL, NULL, NULL, NULL), +(@menu_id + 14, @menu_id + 1, NULL, '风控变量编辑', NULL, 2, NULL, NULL, NULL, '风控变量编辑', NULL, NULL, NULL, NULL, NULL, NULL, 13, '', 0, NULL, NULL, NULL, NULL), +(@menu_id + 15, @menu_id + 1, NULL, '风控变量删除', NULL, 2, NULL, NULL, NULL, '风控变量删除', NULL, NULL, NULL, NULL, NULL, NULL, 14, '', 0, NULL, NULL, NULL, NULL), +(@menu_id + 16, @menu_id + 1, NULL, '风控日志查看', NULL, 2, NULL, NULL, NULL, '风控日志查看', NULL, NULL, NULL, NULL, NULL, NULL, 15, '', 0, NULL, NULL, NULL, NULL), +(@menu_id + 17, @menu_id + 1, NULL, '风控日志导出', NULL, 2, NULL, NULL, NULL, '风控日志导出', NULL, NULL, NULL, NULL, NULL, NULL, 16, '', 0, NULL, NULL, NULL, NULL); + +-- ---------------------------- +-- 3.2. sys_role_menu 角色1的按钮权限(menu_id 引用上方 sys_menu 自增 id) +-- ---------------------------- +SELECT IFNULL(MAX(id), 0) INTO @role_menu_id FROM `yltrs_admin`.`sys_role_menu`; + +INSERT INTO `yltrs_admin`.`sys_role_menu` (`id`, `role_id`, `menu_id`) VALUES +(@role_menu_id + 1, 1, @menu_id + 1), +(@role_menu_id + 2, 1, @menu_id + 2), +(@role_menu_id + 3, 1, @menu_id + 3), +(@role_menu_id + 4, 1, @menu_id + 4), +(@role_menu_id + 5, 1, @menu_id + 5), +(@role_menu_id + 6, 1, @menu_id + 6), +(@role_menu_id + 7, 1, @menu_id + 7), +(@role_menu_id + 8, 1, @menu_id + 8), +(@role_menu_id + 9, 1, @menu_id + 9), +(@role_menu_id + 10, 1, @menu_id + 10), +(@role_menu_id + 11, 1, @menu_id + 11), +(@role_menu_id + 12, 1, @menu_id + 12), +(@role_menu_id + 13, 1, @menu_id + 13), +(@role_menu_id + 14, 1, @menu_id + 14), +(@role_menu_id + 15, 1, @menu_id + 15), +(@role_menu_id + 16, 1, @menu_id + 16), +(@role_menu_id + 17, 1, @menu_id + 17); \ No newline at end of file diff --git a/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/migrate_condition_operator_tokens.sql b/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/migrate_condition_operator_tokens.sql new file mode 100644 index 00000000..7d8e479f --- /dev/null +++ b/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/migrate_condition_operator_tokens.sql @@ -0,0 +1,31 @@ +-- ConditionJson 操作符一次性迁移(基于当前生产 seed 数据) +-- 原因:ConditionJson 改用稳定 token,避免 >、< 在接口传输中发生 HTML 转义。 +-- 仅修改规则 3、14、15、16 的 ConditionJson;RuleExpr、RuleText 和自由文本规则不变。 + +USE `yltrs_ylcms`; + +-- 执行前确认当前值,并保存查询结果作为人工备份。 +SELECT id, RuleName, ConditionJson, RuleExpr +FROM glms_risk_rule +WHERE id IN (3, 14, 15, 16) +ORDER BY id; + +START TRANSACTION; + +UPDATE glms_risk_rule +SET ConditionJson = REPLACE(ConditionJson, '"Operator":">"', '"Operator":"gt"') +WHERE id IN (3, 14, 15, 16) + AND ConditionJson LIKE '%"Operator":">"%'; + +UPDATE glms_risk_rule +SET ConditionJson = REPLACE(ConditionJson, '"Operator":"<"', '"Operator":"lt"') +WHERE id IN (3, 14, 15, 16) + AND ConditionJson LIKE '%"Operator":"<"%'; + +COMMIT; + +-- 执行后确认:ConditionJson 使用 token,RuleExpr 保持原简洁公式。 +SELECT id, RuleName, ConditionJson, RuleExpr +FROM glms_risk_rule +WHERE id IN (3, 14, 15, 16) +ORDER BY id; diff --git a/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/rollback_condition_operator_tokens.sql b/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/rollback_condition_operator_tokens.sql new file mode 100644 index 00000000..9655026a --- /dev/null +++ b/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/rollback_condition_operator_tokens.sql @@ -0,0 +1,23 @@ +-- ConditionJson 操作符迁移回滚(仅用于应用版本整体回退) +-- 只恢复规则 3、14、15、16 的 ConditionJson 操作符,不修改 RuleExpr。 + +USE `yltrs_ylcms`; + +START TRANSACTION; + +UPDATE glms_risk_rule +SET ConditionJson = REPLACE(ConditionJson, '"Operator":"gt"', '"Operator":">"') +WHERE id IN (3, 14, 15, 16) + AND ConditionJson LIKE '%"Operator":"gt"%'; + +UPDATE glms_risk_rule +SET ConditionJson = REPLACE(ConditionJson, '"Operator":"lt"', '"Operator":"<"') +WHERE id IN (3, 14, 15, 16) + AND ConditionJson LIKE '%"Operator":"lt"%'; + +COMMIT; + +SELECT id, RuleName, ConditionJson, RuleExpr +FROM glms_risk_rule +WHERE id IN (3, 14, 15, 16) +ORDER BY id; diff --git a/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/seed_applications.sql b/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/seed_applications.sql deleted file mode 100644 index 4573b7d0..00000000 --- a/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/seed_applications.sql +++ /dev/null @@ -1,135 +0,0 @@ --- ============================================================ --- 规则应用初始数据(对应设计文档 §4.9.1 通用规则预置参考) --- 依赖:seed_rules.sql(规则数据需先插入) --- --- 每条规则对应一条应用配置(1:1),定义"何时、对谁、怎么处理" --- ControlStrategy: 1=Block(禁止), 2=Approval(审批), 3=Warning(提示) --- TriggerPoints: BOOK_CONFIRM=簿记交易确认 --- ScopeIsGlobal: 1=全局适用, 0=按维度配置(Scope字段为NULL表示"全部") --- ============================================================ - --- 规则1:挂钩标的集中度超阈值(审批,全局) -INSERT INTO `glms_risk_rule_application` (`RuleIds`, `Status`, `ControlStrategy`, `TriggerPoints`, `ScopeAssetBookIds`, `ScopeClientIds`, `ScopeUnderlyingTypes`, `ScopeTradeTypes`, `ScopeIsGlobal`, `Version`, `OptId`, `OptName`, `OptDate`) -SELECT - CAST(r.id AS CHAR), 1, 2, 'BOOK_CONFIRM', NULL, NULL, NULL, NULL, 1, 1, 0, 'SYSTEM', NOW() -FROM `glms_risk_rule` r WHERE r.RuleName = '挂钩标的集中度超阈值'; - --- 规则2:挂钩标的到期日小于合约到期日(禁止,全局) -INSERT INTO `glms_risk_rule_application` (`RuleIds`, `Status`, `ControlStrategy`, `TriggerPoints`, `ScopeAssetBookIds`, `ScopeClientIds`, `ScopeUnderlyingTypes`, `ScopeTradeTypes`, `ScopeIsGlobal`, `Version`, `OptId`, `OptName`, `OptDate`) -SELECT - CAST(r.id AS CHAR), 1, 1, 'BOOK_CONFIRM', NULL, NULL, NULL, NULL, 1, 1, 0, 'SYSTEM', NOW() -FROM `glms_risk_rule` r WHERE r.RuleName = '挂钩标的到期日小于合约到期日'; - --- 规则3:名义本金超阈值(审批,账户/合约类型) -INSERT INTO `glms_risk_rule_application` (`RuleIds`, `Status`, `ControlStrategy`, `TriggerPoints`, `ScopeAssetBookIds`, `ScopeClientIds`, `ScopeUnderlyingTypes`, `ScopeTradeTypes`, `ScopeIsGlobal`, `Version`, `OptId`, `OptName`, `OptDate`) -SELECT - CAST(r.id AS CHAR), 1, 2, 'BOOK_CONFIRM', NULL, NULL, NULL, NULL, 0, 1, 0, 'SYSTEM', NOW() -FROM `glms_risk_rule` r WHERE r.RuleName = '名义本金超阈值'; - --- 规则4:保证金支付比例超阈值(审批,账户/标的类型) -INSERT INTO `glms_risk_rule_application` (`RuleIds`, `Status`, `ControlStrategy`, `TriggerPoints`, `ScopeAssetBookIds`, `ScopeClientIds`, `ScopeUnderlyingTypes`, `ScopeTradeTypes`, `ScopeIsGlobal`, `Version`, `OptId`, `OptName`, `OptDate`) -SELECT - CAST(r.id AS CHAR), 1, 2, 'BOOK_CONFIRM', NULL, NULL, NULL, NULL, 0, 1, 0, 'SYSTEM', NOW() -FROM `glms_risk_rule` r WHERE r.RuleName = '保证金支付比例超阈值'; - --- 规则5:保证金利率偏离(审批,账户) -INSERT INTO `glms_risk_rule_application` (`RuleIds`, `Status`, `ControlStrategy`, `TriggerPoints`, `ScopeAssetBookIds`, `ScopeClientIds`, `ScopeUnderlyingTypes`, `ScopeTradeTypes`, `ScopeIsGlobal`, `Version`, `OptId`, `OptName`, `OptDate`) -SELECT - CAST(r.id AS CHAR), 1, 2, 'BOOK_CONFIRM', NULL, NULL, NULL, NULL, 0, 1, 0, 'SYSTEM', NOW() -FROM `glms_risk_rule` r WHERE r.RuleName = '保证金利率偏离'; - --- 规则6:保证金收取比例低于最低标准(审批,账户/标的类型) -INSERT INTO `glms_risk_rule_application` (`RuleIds`, `Status`, `ControlStrategy`, `TriggerPoints`, `ScopeAssetBookIds`, `ScopeClientIds`, `ScopeUnderlyingTypes`, `ScopeTradeTypes`, `ScopeIsGlobal`, `Version`, `OptId`, `OptName`, `OptDate`) -SELECT - CAST(r.id AS CHAR), 1, 2, 'BOOK_CONFIRM', NULL, NULL, NULL, NULL, 0, 1, 0, 'SYSTEM', NOW() -FROM `glms_risk_rule` r WHERE r.RuleName = '保证金收取比例低于最低标准'; - --- 规则7:起息日早于当前日期(审批,全局) -INSERT INTO `glms_risk_rule_application` (`RuleIds`, `Status`, `ControlStrategy`, `TriggerPoints`, `ScopeAssetBookIds`, `ScopeClientIds`, `ScopeUnderlyingTypes`, `ScopeTradeTypes`, `ScopeIsGlobal`, `Version`, `OptId`, `OptName`, `OptDate`) -SELECT - CAST(r.id AS CHAR), 1, 2, 'BOOK_CONFIRM', NULL, NULL, NULL, NULL, 1, 1, 0, 'SYSTEM', NOW() -FROM `glms_risk_rule` r WHERE r.RuleName = '起息日早于当前日期'; - --- 规则8:支付日为银行间交易日(审批,全局) -INSERT INTO `glms_risk_rule_application` (`RuleIds`, `Status`, `ControlStrategy`, `TriggerPoints`, `ScopeAssetBookIds`, `ScopeClientIds`, `ScopeUnderlyingTypes`, `ScopeTradeTypes`, `ScopeIsGlobal`, `Version`, `OptId`, `OptName`, `OptDate`) -SELECT - CAST(r.id AS CHAR), 1, 2, 'BOOK_CONFIRM', NULL, NULL, NULL, NULL, 1, 1, 0, 'SYSTEM', NOW() -FROM `glms_risk_rule` r WHERE r.RuleName = '支付日为银行间交易日'; - --- 规则9:到期日为银行间交易日(审批,全局) -INSERT INTO `glms_risk_rule_application` (`RuleIds`, `Status`, `ControlStrategy`, `TriggerPoints`, `ScopeAssetBookIds`, `ScopeClientIds`, `ScopeUnderlyingTypes`, `ScopeTradeTypes`, `ScopeIsGlobal`, `Version`, `OptId`, `OptName`, `OptDate`) -SELECT - CAST(r.id AS CHAR), 1, 2, 'BOOK_CONFIRM', NULL, NULL, NULL, NULL, 1, 1, 0, 'SYSTEM', NOW() -FROM `glms_risk_rule` r WHERE r.RuleName = '到期日为银行间交易日'; - --- 规则10:平仓日为银行间交易日(审批,全局) -INSERT INTO `glms_risk_rule_application` (`RuleIds`, `Status`, `ControlStrategy`, `TriggerPoints`, `ScopeAssetBookIds`, `ScopeClientIds`, `ScopeUnderlyingTypes`, `ScopeTradeTypes`, `ScopeIsGlobal`, `Version`, `OptId`, `OptName`, `OptDate`) -SELECT - CAST(r.id AS CHAR), 1, 2, 'BOOK_CONFIRM', NULL, NULL, NULL, NULL, 1, 1, 0, 'SYSTEM', NOW() -FROM `glms_risk_rule` r WHERE r.RuleName = '平仓日为银行间交易日'; - --- 规则11:合约期限超阈值(审批,账户) -INSERT INTO `glms_risk_rule_application` (`RuleIds`, `Status`, `ControlStrategy`, `TriggerPoints`, `ScopeAssetBookIds`, `ScopeClientIds`, `ScopeUnderlyingTypes`, `ScopeTradeTypes`, `ScopeIsGlobal`, `Version`, `OptId`, `OptName`, `OptDate`) -SELECT - CAST(r.id AS CHAR), 1, 2, 'BOOK_CONFIRM', NULL, NULL, NULL, NULL, 0, 1, 0, 'SYSTEM', NOW() -FROM `glms_risk_rule` r WHERE r.RuleName = '合约期限超阈值'; - --- 规则12:债券类净价偏离(审批,全局) -INSERT INTO `glms_risk_rule_application` (`RuleIds`, `Status`, `ControlStrategy`, `TriggerPoints`, `ScopeAssetBookIds`, `ScopeClientIds`, `ScopeUnderlyingTypes`, `ScopeTradeTypes`, `ScopeIsGlobal`, `Version`, `OptId`, `OptName`, `OptDate`) -SELECT - CAST(r.id AS CHAR), 1, 2, 'BOOK_CONFIRM', NULL, NULL, NULL, NULL, 1, 1, 0, 'SYSTEM', NOW() -FROM `glms_risk_rule` r WHERE r.RuleName = '债券类净价偏离'; - --- 规则13:债券类收益率偏离(审批,全局) -INSERT INTO `glms_risk_rule_application` (`RuleIds`, `Status`, `ControlStrategy`, `TriggerPoints`, `ScopeAssetBookIds`, `ScopeClientIds`, `ScopeUnderlyingTypes`, `ScopeTradeTypes`, `ScopeIsGlobal`, `Version`, `OptId`, `OptName`, `OptDate`) -SELECT - CAST(r.id AS CHAR), 1, 2, 'BOOK_CONFIRM', NULL, NULL, NULL, NULL, 1, 1, 0, 'SYSTEM', NOW() -FROM `glms_risk_rule` r WHERE r.RuleName = '债券类收益率偏离'; - --- 规则14:非债券类价格偏离(审批,全局) -INSERT INTO `glms_risk_rule_application` (`RuleIds`, `Status`, `ControlStrategy`, `TriggerPoints`, `ScopeAssetBookIds`, `ScopeClientIds`, `ScopeUnderlyingTypes`, `ScopeTradeTypes`, `ScopeIsGlobal`, `Version`, `OptId`, `OptName`, `OptDate`) -SELECT - CAST(r.id AS CHAR), 1, 2, 'BOOK_CONFIRM', NULL, NULL, NULL, NULL, 1, 1, 0, 'SYSTEM', NOW() -FROM `glms_risk_rule` r WHERE r.RuleName = '非债券类价格偏离'; - --- 规则15:单一交易对手累计标的数量超阈值(审批,对手方) -INSERT INTO `glms_risk_rule_application` (`RuleIds`, `Status`, `ControlStrategy`, `TriggerPoints`, `ScopeAssetBookIds`, `ScopeClientIds`, `ScopeUnderlyingTypes`, `ScopeTradeTypes`, `ScopeIsGlobal`, `Version`, `OptId`, `OptName`, `OptDate`) -SELECT - CAST(r.id AS CHAR), 1, 2, 'BOOK_CONFIRM', NULL, NULL, NULL, NULL, 0, 1, 0, 'SYSTEM', NOW() -FROM `glms_risk_rule` r WHERE r.RuleName = '单一交易对手累计标的数量超阈值'; - --- 规则16:多头支付固定端利率偏离(审批,账户) -INSERT INTO `glms_risk_rule_application` (`RuleIds`, `Status`, `ControlStrategy`, `TriggerPoints`, `ScopeAssetBookIds`, `ScopeClientIds`, `ScopeUnderlyingTypes`, `ScopeTradeTypes`, `ScopeIsGlobal`, `Version`, `OptId`, `OptName`, `OptDate`) -SELECT - CAST(r.id AS CHAR), 1, 2, 'BOOK_CONFIRM', NULL, NULL, NULL, NULL, 0, 1, 0, 'SYSTEM', NOW() -FROM `glms_risk_rule` r WHERE r.RuleName = '多头支付固定端利率偏离'; - --- 规则17:空头利率减点借贷加权偏离(审批,账户) -INSERT INTO `glms_risk_rule_application` (`RuleIds`, `Status`, `ControlStrategy`, `TriggerPoints`, `ScopeAssetBookIds`, `ScopeClientIds`, `ScopeUnderlyingTypes`, `ScopeTradeTypes`, `ScopeIsGlobal`, `Version`, `OptId`, `OptName`, `OptDate`) -SELECT - CAST(r.id AS CHAR), 1, 2, 'BOOK_CONFIRM', NULL, NULL, NULL, NULL, 0, 1, 0, 'SYSTEM', NOW() -FROM `glms_risk_rule` r WHERE r.RuleName = '空头利率减点借贷加权偏离'; - --- 规则18:账户授权收支方向不匹配(禁止,账户) -INSERT INTO `glms_risk_rule_application` (`RuleIds`, `Status`, `ControlStrategy`, `TriggerPoints`, `ScopeAssetBookIds`, `ScopeClientIds`, `ScopeUnderlyingTypes`, `ScopeTradeTypes`, `ScopeIsGlobal`, `Version`, `OptId`, `OptName`, `OptDate`) -SELECT - CAST(r.id AS CHAR), 1, 1, 'BOOK_CONFIRM', NULL, NULL, NULL, NULL, 0, 1, 0, 'SYSTEM', NOW() -FROM `glms_risk_rule` r WHERE r.RuleName = '账户授权收支方向不匹配'; - --- 规则19:执行价偏离超阈值(审批,账户) -INSERT INTO `glms_risk_rule_application` (`RuleIds`, `Status`, `ControlStrategy`, `TriggerPoints`, `ScopeAssetBookIds`, `ScopeClientIds`, `ScopeUnderlyingTypes`, `ScopeTradeTypes`, `ScopeIsGlobal`, `Version`, `OptId`, `OptName`, `OptDate`) -SELECT - CAST(r.id AS CHAR), 1, 2, 'BOOK_CONFIRM', NULL, NULL, NULL, NULL, 0, 1, 0, 'SYSTEM', NOW() -FROM `glms_risk_rule` r WHERE r.RuleName = '执行价偏离超阈值'; - --- 规则20:希腊字母限额超阈值(提示,账户/标的,预留接口) -INSERT INTO `glms_risk_rule_application` (`RuleIds`, `Status`, `ControlStrategy`, `TriggerPoints`, `ScopeAssetBookIds`, `ScopeClientIds`, `ScopeUnderlyingTypes`, `ScopeTradeTypes`, `ScopeIsGlobal`, `Version`, `OptId`, `OptName`, `OptDate`) -SELECT - CAST(r.id AS CHAR), 1, 3, 'BOOK_CONFIRM', NULL, NULL, NULL, NULL, 0, 1, 0, 'SYSTEM', NOW() -FROM `glms_risk_rule` r WHERE r.RuleName = '希腊字母限额超阈值'; - --- 规则21:接近/触发敲入敲出价(提示,全局,预留接口) -INSERT INTO `glms_risk_rule_application` (`RuleIds`, `Status`, `ControlStrategy`, `TriggerPoints`, `ScopeAssetBookIds`, `ScopeClientIds`, `ScopeUnderlyingTypes`, `ScopeTradeTypes`, `ScopeIsGlobal`, `Version`, `OptId`, `OptName`, `OptDate`) -SELECT - CAST(r.id AS CHAR), 1, 3, 'BOOK_CONFIRM', NULL, NULL, NULL, NULL, 1, 1, 0, 'SYSTEM', NOW() -FROM `glms_risk_rule` r WHERE r.RuleName = '接近/触发敲入敲出价'; diff --git a/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/seed_menu_risk_alert.sql b/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/seed_menu_risk_alert.sql deleted file mode 100644 index 68a57b0d..00000000 --- a/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/seed_menu_risk_alert.sql +++ /dev/null @@ -1,50 +0,0 @@ --- ============================================================ --- 异常交易监控页面 - 操作权限(按钮)数据 --- 依赖:异常交易监控菜单 id=1077 (type=1) 需先插入 --- 对应 FunctionRight.xml 风险控制下风控相关操作权限(共16个按钮) --- type: 0=目录 1=菜单 2=按钮 --- ============================================================ - --- ---------------------------- --- 1. sys_menu 页面+按钮(type=1,2) --- ---------------------------- -INSERT INTO `yltrs_admin`.`sys_menu` (`id`, `pid`, `pids`, `name`, `code`, `type`, `icon`, `router`, `component`, `permission`, `application`, `open_type`, `visible`, `link`, `redirect`, `weight`, `sort`, `remark`, `status`, `create_time`, `create_user`, `update_time`, `update_user`) VALUES -(1077, 99, NULL, '异常交易监控', NULL, 1, NULL, NULL, NULL, '异常交易监控', NULL, NULL, NULL, NULL, NULL, NULL, 0, '', 0, NULL, NULL, NULL, NULL), -(1078, 1077, NULL, '风控规则查看', NULL, 2, NULL, NULL, NULL, '风控规则查看', NULL, NULL, NULL, NULL, NULL, NULL, 1, '', 0, NULL, NULL, NULL, NULL), -(1079, 1077, NULL, '风控规则新增', NULL, 2, NULL, NULL, NULL, '风控规则新增', NULL, NULL, NULL, NULL, NULL, NULL, 2, '', 0, NULL, NULL, NULL, NULL), -(1080, 1077, NULL, '风控规则编辑', NULL, 2, NULL, NULL, NULL, '风控规则编辑', NULL, NULL, NULL, NULL, NULL, NULL, 3, '', 0, NULL, NULL, NULL, NULL), -(1081, 1077, NULL, '风控规则删除', NULL, 2, NULL, NULL, NULL, '风控规则删除', NULL, NULL, NULL, NULL, NULL, NULL, 4, '', 0, NULL, NULL, NULL, NULL), -(1082, 1077, NULL, '风控规则启停', NULL, 2, NULL, NULL, NULL, '风控规则启停', NULL, NULL, NULL, NULL, NULL, NULL, 5, '', 0, NULL, NULL, NULL, NULL), -(1083, 1077, NULL, '风控应用查看', NULL, 2, NULL, NULL, NULL, '风控应用查看', NULL, NULL, NULL, NULL, NULL, NULL, 6, '', 0, NULL, NULL, NULL, NULL), -(1084, 1077, NULL, '风控应用新增', NULL, 2, NULL, NULL, NULL, '风控应用新增', NULL, NULL, NULL, NULL, NULL, NULL, 7, '', 0, NULL, NULL, NULL, NULL), -(1085, 1077, NULL, '风控应用编辑', NULL, 2, NULL, NULL, NULL, '风控应用编辑', NULL, NULL, NULL, NULL, NULL, NULL, 8, '', 0, NULL, NULL, NULL, NULL), -(1086, 1077, NULL, '风控应用删除', NULL, 2, NULL, NULL, NULL, '风控应用删除', NULL, NULL, NULL, NULL, NULL, NULL, 9, '', 0, NULL, NULL, NULL, NULL), -(1087, 1077, NULL, '风控应用启停', NULL, 2, NULL, NULL, NULL, '风控应用启停', NULL, NULL, NULL, NULL, NULL, NULL, 10, '', 0, NULL, NULL, NULL, NULL), -(1088, 1077, NULL, '风控变量查看', NULL, 2, NULL, NULL, NULL, '风控变量查看', NULL, NULL, NULL, NULL, NULL, NULL, 11, '', 0, NULL, NULL, NULL, NULL), -(1089, 1077, NULL, '风控变量新增', NULL, 2, NULL, NULL, NULL, '风控变量新增', NULL, NULL, NULL, NULL, NULL, NULL, 12, '', 0, NULL, NULL, NULL, NULL), -(1090, 1077, NULL, '风控变量编辑', NULL, 2, NULL, NULL, NULL, '风控变量编辑', NULL, NULL, NULL, NULL, NULL, NULL, 13, '', 0, NULL, NULL, NULL, NULL), -(1091, 1077, NULL, '风控变量删除', NULL, 2, NULL, NULL, NULL, '风控变量删除', NULL, NULL, NULL, NULL, NULL, NULL, 14, '', 0, NULL, NULL, NULL, NULL), -(1092, 1077, NULL, '风控日志查看', NULL, 2, NULL, NULL, NULL, '风控日志查看', NULL, NULL, NULL, NULL, NULL, NULL, 15, '', 0, NULL, NULL, NULL, NULL), -(1093, 1077, NULL, '风控日志导出', NULL, 2, NULL, NULL, NULL, '风控日志导出', NULL, NULL, NULL, NULL, NULL, NULL, 16, '', 0, NULL, NULL, NULL, NULL); - --- ---------------------------- --- 2. sys_role_menu 角色1的按钮权限 --- ---------------------------- -INSERT INTO `yltrs_admin`.`sys_role_menu` (`id`, `role_id`, `menu_id`) VALUES -(1097, 1, 1077), -(1098, 1, 1078), -(1099, 1, 1079), -(1100, 1, 1080), -(1101, 1, 1081), -(1102, 1, 1082), -(1103, 1, 1083), -(1104, 1, 1084), -(1105, 1, 1085), -(1106, 1, 1086), -(1107, 1, 1087), -(1108, 1, 1088), -(1109, 1, 1089), -(1110, 1, 1090), -(1111, 1, 1091), -(1112, 1, 1092), -(1113, 1, 1093); diff --git a/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/seed_risk_engine_applications.sql b/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/seed_risk_engine_applications.sql new file mode 100644 index 00000000..c5d396c1 --- /dev/null +++ b/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/seed_risk_engine_applications.sql @@ -0,0 +1,121 @@ +-- ============================================================ +-- 规则应用初始数据(对应当前 glms_risk_rule_application 数据库内容) +-- 依赖:seed_risk_engine_rules.sql(规则数据需先插入) +-- +-- 本次仅同步依赖脚本文件名,不修改应用业务数据和 RuleIds。 +-- 每条记录对应一条应用配置,定义"何时、对谁、怎么处理" +-- ControlStrategy: 1=Block(禁止), 2=Approval(审批), 3=Warning(提示) +-- TriggerPoints: BOOK_CONFIRM=簿记交易确认 +-- ScopeIsGlobal: 1=全局适用, 0=按维度配置(Scope字段为空字符串表示"全部") +-- ============================================================ + +-- 插入当前数据库中的 6 条规则应用,保留原始 ID 和 RuleIds +INSERT INTO `yltrs_ylcms`.`glms_risk_rule_application` (`id`, `RuleIds`, `Description`, `Status`, `ControlStrategy`, `TriggerPoints`, `ScopeAssetBookIds`, `ScopeClientIds`, `ScopeUnderlyingTypes`, `ScopeTradeTypes`, `ScopeIsGlobal`, `Version`, `OptId`, `OptName`, `OptDate`, `UpdateOptId`, `UpdateOptName`, `UpdateDate`) VALUES +(23, + '3', + '', + 2, + 2, + 'BOOK_CONFIRM', + '', + '', + '', + '', + 1, + 8, + 1, + '初始用户', + '2026-07-13 13:24:03', + 1, + '初始用户', + '2026-07-17 14:49:39'), +(24, + '12', + '', + 2, + 2, + 'BOOK_CONFIRM', + '', + '', + '', + '', + 1, + 10, + 1, + '初始用户', + '2026-07-14 15:46:51', + 1, + '初始用户', + '2026-07-17 14:49:42'), +(25, + '13', + NULL, + 2, + 2, + 'BOOK_CONFIRM', + '', + '', + '', + '', + 1, + 1, + 1, + '初始用户', + '2026-07-15 14:06:10', + 1, + '初始用户', + '2026-07-17 14:50:00'), +(26, + '14', + '', + 2, + 2, + 'BOOK_CONFIRM', + '', + '', + '', + '', + 1, + 2, + 1, + '初始用户', + '2026-07-15 15:19:30', + 1, + '初始用户', + '2026-07-17 14:50:05'), +(27, + '16', + NULL, + 2, + 2, + 'BOOK_CONFIRM', + '', + '', + '', + '', + 1, + 4, + 1, + '初始用户', + '2026-07-15 17:44:02', + 1, + '初始用户', + '2026-07-17 14:54:11'), +(28, + '15', + '', + 2, + 2, + 'BOOK_CONFIRM', + '', + '', + '', + '', + 1, + 2, + 1, + '初始用户', + '2026-07-15 17:49:56', + 1, + '初始用户', + '2026-07-17 14:50:03'); diff --git a/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/seed_risk_engine_rules.sql b/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/seed_risk_engine_rules.sql new file mode 100644 index 00000000..a657c326 --- /dev/null +++ b/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/seed_risk_engine_rules.sql @@ -0,0 +1,121 @@ +-- ============================================================ +-- 规则初始数据(对应当前 glms_risk_rule 数据库内容) +-- 依赖:seed_risk_engine_variables.sql(变量池数据需先插入) +-- +-- 变量 ID 映射(seed_risk_engine_variables.sql 自增): +-- 1=合约名义本金 2=合约起息日 3=合约到期日 4=合约平仓日 +-- 5=合约支付日 6=期初净价 7=期初全价 8=期初收益率 +-- 9=期初价格 10=期末全价 11=期末价格 12=保证金利率 +-- 13=保证金比例 14=客户授信额度 +-- 15=上一收盘日中债估值净价 16=上一收盘日中债估值全价 17=上一收盘日中债估值收益率 +-- 18=上一日收盘价 19=借贷加权费率 20=FR007 21=当前日期 +-- 22=挂钩标的到期日 23=标的发行余额 +-- 24=挂钩标的集中度 25=授信占用率 26=合约期限 27=Delta +-- 28=Gamma 29=Vega 30=Theta 31=利息端利率 +-- 32=对手方累计标的数量 33=同一标的累计名义本金 34=同一客户累计名义本金 +-- 35=总持仓名义本金 +-- 36=到期日是否银行间交易日 37=平仓日是否银行间交易日 38=支付日是否银行间交易日 +-- 39=利息端/浮动端方向是否同向 40=关键业务要素是否一致 +-- 41=多空方向为多头 42=多空方向为空头 +-- 43=保证金收支方向为支付 44=保证金收支方向为收取 +-- 45=期初净价偏离度 46=期初收益率偏离度 47=期初价格偏离度 +-- 48=利息端利率与FR007偏离度 49=利息端利率与借贷加权费率偏离度 +-- 50=执行价偏离度 51=执行价 52=客户品种最低保证金率 53=参考价格 +-- +-- ConditionJson 仅保存执行所需字段:普通比较使用 VariableId/Operator/ThresholdType/Value/ThresholdVariableId; +-- 区间比较使用 Lower/UpperThresholdType、Lower/UpperValue、Lower/UpperThresholdVariableId、IncludeLower/IncludeUpper。 +-- 修改原因:Operator 使用 gt/lt 等稳定 token,避免 >、< 在接口传输中发生 HTML 转义。 +-- RuleExpr 保持现有简洁 C# 公式;编译前类型转换由 RiskEngineCompiler 负责。 +-- 展示字段(VariableName/VariableType/Unit/ThresholdVariableName)从变量接口关联获取 +-- ============================================================ + + +-- ============================================================ +-- 插入当前数据库中的 6 条规则,保留原始 ID 以匹配规则应用表 RuleIds +-- ============================================================ + +INSERT INTO `yltrs_ylcms`.`glms_risk_rule` (`id`, `RuleName`, `RuleText`, `ConditionJson`, `RuleExpr`, `Description`, `Status`, `Version`, `OptId`, `OptName`, `OptDate`, `UpdateOptId`, `UpdateOptName`, `UpdateDate`) VALUES +(3, + '名义本金超阈值', + '合约名义本金 > 1亿', + '[{"VariableId":1,"Operator":"gt","ThresholdType":"Fixed","Value":100000000}]', + 'DbContext.trade.First(t => t.id == TradeId).StockEqvNotional > 100000000', + '', + 2, + 14, + 0, + 'SYSTEM', + '2026-06-24 15:51:47', + 1, + '初始用户', + '2026-07-17 14:50:33'), +(12, + '债券类净价偏离', + '期初净价与上一收盘日中债估值净价绝对价差大于5元时触发审批', + '', + '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', + NULL, + 2, + 9, + 0, + 'SYSTEM', + '2026-06-24 15:51:47', + 1, + '初始用户', + '2026-07-17 14:50:29'), +(13, + '债券类收益率偏离', + '', + '', + '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) > 0.4m', + '', + 2, + 10, + 0, + 'SYSTEM', + '2026-06-24 15:51:47', + 1, + '初始用户', + '2026-07-17 14:50:21'), +(14, + '非债券类价格偏离', + 'TRS非债券价格偏离绝对值 > 5元', + '[{"VariableId":47,"Operator":"gt","ThresholdType":"Fixed","Value":5}]', + 'Math.Abs(DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection == 2 && !string.IsNullOrEmpty(p.UnderlyingCode)).PosiGrossPrice * 100 - 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)) > 5', + '', + 2, + 8, + 0, + 'SYSTEM', + '2026-06-24 15:51:47', + 1, + '初始用户', + '2026-07-17 14:50:37'), +(15, + '单一交易对手累计标的数量超阈值', + '对手方累计标的数量 > 10个', + '[{"VariableId":32,"Operator":"gt","ThresholdType":"Fixed","Value":10}]', + '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', + '', + 2, + 8, + 0, + 'SYSTEM', + '2026-06-24 15:51:47', + 1, + '初始用户', + '2026-07-17 14:50:18'), +(16, + '多头支付固定端利率偏离', + '利息端利率与FR007偏离度 > 3%', + '[{"VariableId":48,"Operator":"gt","ThresholdType":"Fixed","Value":3}]', + 'Math.Abs(DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.InterestDirection == 1).InterestRateDefault)*100m > 3', + '', + 2, + 5, + 0, + 'SYSTEM', + '2026-06-24 15:51:47', + 1, + '初始用户', + '2026-07-17 14:54:18'); diff --git a/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/seed_risk_engine_variables.sql b/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/seed_risk_engine_variables.sql new file mode 100644 index 00000000..0801f0d0 --- /dev/null +++ b/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/seed_risk_engine_variables.sql @@ -0,0 +1,112 @@ +-- ============================================================ +-- 变量池初始数据(对应当前 glms_risk_variable 数据库内容) +-- ============================================================ + +-- 4.4.1 簿记要素类(Category=1) +-- 4.4.2 行情类(Category=2,统一取上一交易日收盘价) +-- 4.4.3 系统计算值类(Category=3) +-- 新增变量(ID 45~53): +-- 45=期初净价偏离度 46=期初收益率偏离度 47=期初价格偏离度 +-- 48=利息端利率与FR007偏离度 49=利息端利率与借贷加权费率偏离度 +-- 50=执行价偏离度 51=执行价 52=客户品种最低保证金率 53=参考价格 +-- 4.4.4 布尔判断类(Category=4) +-- 说明:结构化 RuleExpr 的固定数值不追加 m;本文件 VariableExpr 为人工维护的 C# 表达式,原有 m 后缀保持不变。 + +-- 插入当前数据库中的 6 条变量,保留原始 ID 以匹配规则 ConditionJson 中的 VariableId +INSERT INTO `yltrs_ylcms`.`glms_risk_variable` (`id`, `VariableName`, `Category`, `DataType`, `Unit`, `ValueDomain`, `Description`, `VariableExpr`, `Version`, `SortOrder`, `OptId`, `OptName`, `OptDate`, `UpdateOptId`, `UpdateOptName`, `UpdateDate`) VALUES +(1, + '合约名义本金', + 1, + 1, + '元', + '≥ 0', + '', + 'DbContext.trade.First(t => t.id == TradeId).StockEqvNotional', + 18, + 0, + 0, + 'SYSTEM', + '2026-06-23 20:13:16', + 1, + '初始用户', + '2026-07-17 10:02:27'), +(32, + '对手方累计标的数量', + 3, + 1, + '个', + '≥ 0', + '当前交易对手方所有存续交易涉及的标的数量(去重)合计(含本笔)', + '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()', + 3, + 0, + 0, + 'SYSTEM', + '2026-06-23 20:13:16', + 1, + '初始用户', + '2026-07-17 11:20:06'), +(45, + 'TRS债券净价偏离绝对值', + 3, + 1, + '%', + '≥ 0', + 'abs(期初标的交割净价% - 上一收盘日中债估值净价)', + 'Math.Abs(DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection == 2 && !string.IsNullOrEmpty(p.UnderlyingCode)).PosiNetNoFeePrice.Value * 100 - 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).OrderByDescending(v => v.valuation_date).First().net_price.Value)', + 6, + 0, + 0, + 'SYSTEM', + '2026-06-24 15:51:47', + 1, + '初始用户', + '2026-07-17 13:28:12'), +(46, + 'TRS债券收益率偏离绝对值', + 3, + 1, + '%', + '≥ 0', + 'abs(期初标的成交收益率% - 上一收盘日中债估值收益率)', + 'Math.Abs(DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection == 2 && !string.IsNullOrEmpty(p.UnderlyingCode)).InitYtm.Value * 100 - 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)', + 3, + 0, + 0, + 'SYSTEM', + '2026-06-24 15:51:47', + 1, + '初始用户', + '2026-07-17 13:28:30'), +(47, + 'TRS非债券价格偏离绝对值', + 3, + 1, + '元', + '≥ 0', + 'abs(期初标的交割全价% - 上一日标的收盘价)', + 'Math.Abs(DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection == 2 && !string.IsNullOrEmpty(p.UnderlyingCode)).PosiGrossPrice * 100 - 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))', + 4, + 0, + 0, + 'SYSTEM', + '2026-06-24 15:51:47', + 1, + '初始用户', + '2026-07-17 13:29:43'), +(48, + '利息端利率与FR007偏离度', + 3, + 1, + '%', + '≥ 0', + 'ABS(利息端利率-FR007)/FR007*100', + 'Math.Abs(DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.InterestDirection == 1).InterestRateDefault)*100m', + 2, + 0, + 0, + 'SYSTEM', + '2026-06-24 15:51:47', + 1, + '初始用户', + '2026-07-16 19:23:05'); diff --git a/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/seed_rules.sql b/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/seed_rules.sql deleted file mode 100644 index 9751a1d9..00000000 --- a/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/seed_rules.sql +++ /dev/null @@ -1,292 +0,0 @@ --- ============================================================ --- 规则初始数据(对应设计文档 §4.9.1 通用规则预置参考) --- 依赖:seed_variables.sql(变量池数据需先插入,ID 1~44) --- --- 变量 ID 映射(seed_variables.sql 自增): --- 1=合约名义本金 2=合约起息日 3=合约到期日 4=合约平仓日 --- 5=合约支付日 6=期初净价 7=期初全价 8=期初收益率 --- 9=期初价格 10=期末全价 11=期末价格 12=保证金利率 --- 13=保证金比例 14=客户授信额度 --- 15=上一收盘日中债估值净价 16=上一收盘日中债估值全价 17=上一收盘日中债估值收益率 --- 18=上一日收盘价 19=借贷加权费率 20=FR007 21=当前日期 --- 22=挂钩标的到期日 23=标的发行余额 --- 24=挂钩标的集中度 25=授信占用率 26=合约期限 27=Delta --- 28=Gamma 29=Vega 30=Theta 31=利息端利率 --- 32=对手方累计标的数量 33=同一标的累计名义本金 34=同一客户累计名义本金 --- 35=总持仓名义本金 --- 36=到期日是否银行间交易日 37=平仓日是否银行间交易日 38=支付日是否银行间交易日 --- 39=利息端/浮动端方向是否同向 40=关键业务要素是否一致 --- 41=多空方向为多头 42=多空方向为空头 --- 43=保证金收支方向为支付 44=保证金收支方向为收取 --- 45=期初净价偏离度 46=期初收益率偏离度 47=期初价格偏离度 --- 48=利息端利率与FR007偏离度 49=利息端利率与借贷加权费率偏离度 --- 50=执行价偏离度 51=执行价 52=客户品种最低保证金率 53=参考价格 --- --- ConditionJson 精简设计:只存 VariableId/Operator/ThresholdType/Value/ThresholdVariableId --- 展示字段(VariableName/VariableType/Unit/ThresholdVariableName)从变量接口关联获取 --- ============================================================ - - --- ============================================================ --- 插入 21 条通用规则 --- ============================================================ - --- 规则1:挂钩标的集中度超阈值(审批,全局) --- 条件:挂钩标的集中度(ID=24) > 30% -INSERT INTO `glms_risk_rule` (`RuleName`, `RuleText`, `ConditionJson`, `RuleExpr`, `Status`, `Version`, `OptId`, `OptName`, `OptDate`) VALUES -('挂钩标的集中度超阈值', - '挂钩标的集中度超过阈值(默认30%)时触发审批', - JSON_ARRAY(JSON_OBJECT( - 'VariableId', 24, - 'Operator', '>', 'ThresholdType', 'Fixed', 'Value', 30 - )), - 'calc.UnderlyingConcentration > 30', - 1, 1, 0, 'SYSTEM', NOW()); - --- 规则2:挂钩标的到期日小于合约到期日(禁止,全局) --- 条件:挂钩标的到期日(ID=22) < 合约到期日(ID=3) -INSERT INTO `glms_risk_rule` (`RuleName`, `RuleText`, `ConditionJson`, `RuleExpr`, `Status`, `Version`, `OptId`, `OptName`, `OptDate`) VALUES -('挂钩标的到期日小于合约到期日', - '挂钩标的到期日早于合约到期日时禁止交易', - JSON_ARRAY(JSON_OBJECT( - 'VariableId', 22, - 'Operator', '早于', 'ThresholdType', 'Variable', - 'ThresholdVariableId', 3 - )), - 'market.UnderlyingMaturityDate < trade.ExerciseDate', - 1, 1, 0, 'SYSTEM', NOW()); - --- 规则3:名义本金超阈值(审批,账户/合约类型) --- 条件:合约名义本金(ID=1) > 100000000(1亿元) -INSERT INTO `glms_risk_rule` (`RuleName`, `RuleText`, `ConditionJson`, `RuleExpr`, `Status`, `Version`, `OptId`, `OptName`, `OptDate`) VALUES -('名义本金超阈值', - '合约名义本金超过阈值(默认1亿元)时触发审批', - JSON_ARRAY(JSON_OBJECT( - 'VariableId', 1, - 'Operator', '>', 'ThresholdType', 'Fixed', 'Value', 100000000 - )), - 'trade.StockEqvNotional > 100000000', - 1, 1, 0, 'SYSTEM', NOW()); - --- 规则4:保证金支付比例超阈值(审批,账户/标的类型) --- 条件:保证金比例(ID=13) > 50% -INSERT INTO `glms_risk_rule` (`RuleName`, `RuleText`, `ConditionJson`, `RuleExpr`, `Status`, `Version`, `OptId`, `OptName`, `OptDate`) VALUES -('保证金支付比例超阈值', - '保证金比例超过阈值(默认50%)时触发审批', - JSON_ARRAY(JSON_OBJECT( - 'VariableId', 13, - 'Operator', '>', 'ThresholdType', 'Fixed', 'Value', 50 - )), - 'trade.MarginRate > 50', - 1, 1, 0, 'SYSTEM', NOW()); - --- 规则5:保证金利率偏离(审批,账户) --- 条件:保证金利率(ID=12) 不介于 [2%, 5%] -INSERT INTO `glms_risk_rule` (`RuleName`, `RuleText`, `ConditionJson`, `RuleExpr`, `Status`, `Version`, `OptId`, `OptName`, `OptDate`) VALUES -('保证金利率偏离', - '保证金利率不在配置区间内(默认2%~5%)时触发审批', - JSON_ARRAY(JSON_OBJECT( - 'VariableId', 12, - 'Operator', '不介于', 'ThresholdType', 'Fixed', 'Value', JSON_ARRAY(2, 5), - 'IncludeLowerBound', true, 'IncludeUpperBound', true - )), - '!(client_marginrate.InitMarginRebateRate >= 2 && client_marginrate.InitMarginRebateRate <= 5)', - 1, 1, 0, 'SYSTEM', NOW()); - --- 规则6:保证金收取比例低于最低标准(审批,账户/标的类型) --- 条件:保证金比例(ID=13) < 客户品种最低保证金率(ID=52) -INSERT INTO `glms_risk_rule` (`RuleName`, `RuleText`, `ConditionJson`, `RuleExpr`, `Status`, `Version`, `OptId`, `OptName`, `OptDate`) VALUES -('保证金收取比例低于最低标准', - '保证金比例低于客户品种最低保证金率时触发审批', - JSON_ARRAY(JSON_OBJECT( - 'VariableId', 13, - 'Operator', '<', 'ThresholdType', 'Variable', - 'ThresholdVariableId', 52 - )), - 'trade.MarginRate < config.MinMarginRate', - 1, 1, 0, 'SYSTEM', NOW()); - --- 规则7:起息日早于当前日期(审批,全局) --- 条件:合约起息日(ID=2) < 当前日期(ID=21) -INSERT INTO `glms_risk_rule` (`RuleName`, `RuleText`, `ConditionJson`, `RuleExpr`, `Status`, `Version`, `OptId`, `OptName`, `OptDate`) VALUES -('起息日早于当前日期', - '合约起息日早于当前日期时触发审批', - JSON_ARRAY(JSON_OBJECT( - 'VariableId', 2, - 'Operator', '早于', 'ThresholdType', 'Variable', - 'ThresholdVariableId', 21 - )), - 'trade.StartDate < sys.CurrentDate', - 1, 1, 0, 'SYSTEM', NOW()); - --- 规则8:支付日为银行间交易日(审批,全局) --- 条件:支付日是否银行间交易日(ID=38) = 是 -INSERT INTO `glms_risk_rule` (`RuleName`, `RuleText`, `ConditionJson`, `RuleExpr`, `Status`, `Version`, `OptId`, `OptName`, `OptDate`) VALUES -('支付日为银行间交易日', - '支付日为银行间交易日时触发审批', - JSON_ARRAY(JSON_OBJECT( - 'VariableId', 38, - 'Operator', '是', 'ThresholdType', 'Fixed', 'Value', true - )), - 'calc.IsSettlementDateTradingDay == true', - 1, 1, 0, 'SYSTEM', NOW()); - --- 规则9:到期日为银行间交易日(审批,全局) --- 条件:到期日是否银行间交易日(ID=36) = 是 -INSERT INTO `glms_risk_rule` (`RuleName`, `RuleText`, `ConditionJson`, `RuleExpr`, `Status`, `Version`, `OptId`, `OptName`, `OptDate`) VALUES -('到期日为银行间交易日', - '到期日为银行间交易日时触发审批', - JSON_ARRAY(JSON_OBJECT( - 'VariableId', 36, - 'Operator', '是', 'ThresholdType', 'Fixed', 'Value', true - )), - 'calc.IsExerciseDateTradingDay == true', - 1, 1, 0, 'SYSTEM', NOW()); - --- 规则10:平仓日为银行间交易日(审批,全局) --- 条件:平仓日是否银行间交易日(ID=37) = 是 -INSERT INTO `glms_risk_rule` (`RuleName`, `RuleText`, `ConditionJson`, `RuleExpr`, `Status`, `Version`, `OptId`, `OptName`, `OptDate`) VALUES -('平仓日为银行间交易日', - '平仓日为银行间交易日时触发审批', - JSON_ARRAY(JSON_OBJECT( - 'VariableId', 37, - 'Operator', '是', 'ThresholdType', 'Fixed', 'Value', true - )), - 'calc.IsUnwindDateTradingDay == true', - 1, 1, 0, 'SYSTEM', NOW()); - --- 规则11:合约期限超阈值(审批,账户) --- 条件:合约期限(ID=26) > 365天 -INSERT INTO `glms_risk_rule` (`RuleName`, `RuleText`, `ConditionJson`, `RuleExpr`, `Status`, `Version`, `OptId`, `OptName`, `OptDate`) VALUES -('合约期限超阈值', - '合约期限超过阈值(默认365天)时触发审批', - JSON_ARRAY(JSON_OBJECT( - 'VariableId', 26, - 'Operator', '>', 'ThresholdType', 'Fixed', 'Value', 365 - )), - 'calc.MaturityDays > 365', - 1, 1, 0, 'SYSTEM', NOW()); - --- 规则12:债券类净价偏离(审批,全局) --- 条件:期初净价偏离度(ID=45) > 5% -INSERT INTO `glms_risk_rule` (`RuleName`, `RuleText`, `ConditionJson`, `RuleExpr`, `Status`, `Version`, `OptId`, `OptName`, `OptDate`) VALUES -('债券类净价偏离', - '期初净价与上一收盘日中债估值净价偏离度超阈值(默认5%)时触发审批', - JSON_ARRAY(JSON_OBJECT( - 'VariableId', 45, - 'Operator', '>', 'ThresholdType', 'Fixed', 'Value', 5 - )), - 'Math.Abs(swap_position.PosiNetNoFeePrice - market.CBValuationNetPrice) / market.CBValuationNetPrice * 100 > 5', - 1, 1, 0, 'SYSTEM', NOW()); - --- 规则13:债券类收益率偏离(审批,全局) --- 条件:期初收益率偏离度(ID=46) > 5% -INSERT INTO `glms_risk_rule` (`RuleName`, `RuleText`, `ConditionJson`, `RuleExpr`, `Status`, `Version`, `OptId`, `OptName`, `OptDate`) VALUES -('债券类收益率偏离', - '期初收益率与上一收盘日中债估值收益率偏离度超阈值(默认5%)时触发审批', - JSON_ARRAY(JSON_OBJECT( - 'VariableId', 46, - 'Operator', '>', 'ThresholdType', 'Fixed', 'Value', 5 - )), - 'Math.Abs(trade.InitYtm - market.CBValuationYtm) / market.CBValuationYtm * 100 > 5', - 1, 1, 0, 'SYSTEM', NOW()); - --- 规则14:非债券类价格偏离(审批,全局) --- 条件:期初价格偏离度(ID=47) > 5% -INSERT INTO `glms_risk_rule` (`RuleName`, `RuleText`, `ConditionJson`, `RuleExpr`, `Status`, `Version`, `OptId`, `OptName`, `OptDate`) VALUES -('非债券类价格偏离', - '期初价格与上一日收盘价偏离度超阈值(默认5%)时触发审批', - JSON_ARRAY(JSON_OBJECT( - 'VariableId', 47, - 'Operator', '>', 'ThresholdType', 'Fixed', 'Value', 5 - )), - 'Math.Abs(trade.SpotPrice - market.LastClosePrice) / market.LastClosePrice * 100 > 5', - 1, 1, 0, 'SYSTEM', NOW()); - --- 规则15:单一交易对手累计标的数量超阈值(审批,对手方) --- 条件:对手方累计标的数量(ID=32) > 10个 -INSERT INTO `glms_risk_rule` (`RuleName`, `RuleText`, `ConditionJson`, `RuleExpr`, `Status`, `Version`, `OptId`, `OptName`, `OptDate`) VALUES -('单一交易对手累计标的数量超阈值', - '对手方累计标的数量超过阈值(默认10个)时触发审批', - JSON_ARRAY(JSON_OBJECT( - 'VariableId', 32, - 'Operator', '>', 'ThresholdType', 'Fixed', 'Value', 10 - )), - 'calc.CounterpartyUnderlyingCount > 10', - 1, 1, 0, 'SYSTEM', NOW()); - --- 规则16:多头支付固定端利率偏离(审批,账户) --- 条件:多空方向为多头(ID=41) AND 利息端利率与FR007偏离度(ID=48) > 3% -INSERT INTO `glms_risk_rule` (`RuleName`, `RuleText`, `ConditionJson`, `RuleExpr`, `Status`, `Version`, `OptId`, `OptName`, `OptDate`) VALUES -('多头支付固定端利率偏离', - '多头方向支付固定端场景下,利息端利率与FR007偏离度超阈值(默认3%)时触发审批', - JSON_ARRAY( - JSON_OBJECT( - 'VariableId', 41, - 'Operator', '是', 'ThresholdType', 'Fixed', 'Value', true - ), - JSON_OBJECT( - 'VariableId', 48, - 'Operator', '>', 'ThresholdType', 'Fixed', 'Value', 3 - ) - ), - 'calc.IsLongDirection == true && Math.Abs(calc.InterestRate - market.FR007) / market.FR007 * 100 > 3', - 1, 1, 0, 'SYSTEM', NOW()); - --- 规则17:空头利率减点借贷加权偏离(审批,账户) --- 条件:多空方向为空头(ID=42) AND 利息端利率与借贷加权费率偏离度(ID=49) > 2% -INSERT INTO `glms_risk_rule` (`RuleName`, `RuleText`, `ConditionJson`, `RuleExpr`, `Status`, `Version`, `OptId`, `OptName`, `OptDate`) VALUES -('空头利率减点借贷加权偏离', - '空头方向场景下,利息端利率与借贷加权费率偏离度超阈值(默认2%)时触发审批', - JSON_ARRAY( - JSON_OBJECT( - 'VariableId', 42, - 'Operator', '是', 'ThresholdType', 'Fixed', 'Value', true - ), - JSON_OBJECT( - 'VariableId', 49, - 'Operator', '>', 'ThresholdType', 'Fixed', 'Value', 2 - ) - ), - 'calc.IsShortDirection == true && Math.Abs(calc.InterestRate - market.BondLendingRate) / market.BondLendingRate * 100 > 2', - 1, 1, 0, 'SYSTEM', NOW()); - --- 规则18:账户授权收支方向不匹配(禁止,账户) --- 条件:保证金收支方向为支付(ID=43)(实际需结合账户授权方向判断,此处简化) -INSERT INTO `glms_risk_rule` (`RuleName`, `RuleText`, `ConditionJson`, `RuleExpr`, `Status`, `Version`, `OptId`, `OptName`, `OptDate`) VALUES -('账户授权收支方向不匹配', - '保证金收支方向与账户授权方向不匹配时禁止交易', - JSON_ARRAY(JSON_OBJECT( - 'VariableId', 43, - 'Operator', '是', 'ThresholdType', 'Fixed', 'Value', true - )), - 'calc.IsMarginPay == true', - 1, 1, 0, 'SYSTEM', NOW()); - --- 规则19:执行价偏离超阈值(审批,账户) --- 条件:执行价偏离度(ID=50) > 5% -INSERT INTO `glms_risk_rule` (`RuleName`, `RuleText`, `ConditionJson`, `RuleExpr`, `Status`, `Version`, `OptId`, `OptName`, `OptDate`) VALUES -('执行价偏离超阈值', - '执行价与参考价格偏离度超阈值(默认5%)时触发审批', - JSON_ARRAY(JSON_OBJECT( - 'VariableId', 50, - 'Operator', '>', 'ThresholdType', 'Fixed', 'Value', 5 - )), - 'Math.Abs(trade.StrikePrice - market.ReferencePrice) / market.ReferencePrice * 100 > 5', - 1, 1, 0, 'SYSTEM', NOW()); - --- 规则20:希腊字母限额超阈值(提示,预留接口,一期不纳入) -INSERT INTO `glms_risk_rule` (`RuleName`, `RuleText`, `ConditionJson`, `RuleExpr`, `Status`, `Version`, `OptId`, `OptName`, `OptDate`) VALUES -('希腊字母限额超阈值', - 'Delta/Gamma/Vega/Theta任一超阈值时提示(预留接口,一期不纳入)', - NULL, - NULL, - 1, 1, 0, 'SYSTEM', NOW()); - --- 规则21:接近/触发敲入敲出价(提示,预留接口,需确认具体判断逻辑) -INSERT INTO `glms_risk_rule` (`RuleName`, `RuleText`, `ConditionJson`, `RuleExpr`, `Status`, `Version`, `OptId`, `OptName`, `OptDate`) VALUES -('接近/触发敲入敲出价', - '标的价格接近敲入/敲出价时提示(预留接口,需确认具体判断逻辑)', - NULL, - NULL, - 1, 1, 0, 'SYSTEM', NOW()); diff --git a/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/seed_variables.sql b/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/seed_variables.sql deleted file mode 100644 index 934a3a27..00000000 --- a/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/seed_variables.sql +++ /dev/null @@ -1,73 +0,0 @@ --- ============================================================ --- 变量池初始数据(对应设计文档 §4.4 变量池完整清单) --- ============================================================ - --- 4.4.1 簿记要素类(Category=1) -INSERT INTO `glms_risk_variable` (`VariableName`, `Category`, `DataType`, `Unit`, `ValueDomain`, `Description`, `VariableExpr`, `SortOrder`, `OptId`, `OptName`, `OptDate`) VALUES -('合约名义本金', 1, 1, '元', '≥ 0', 'trade.StockEqvNotional 或 swap_position.PosiNotionalValue', 'trade.StockEqvNotional', 101, 0, 'SYSTEM', NOW()), -('合约起息日', 1, 2, NULL, NULL, 'trade.StartDate 或 swap_position.PosiStartDate', 'trade.StartDate', 102, 0, 'SYSTEM', NOW()), -('合约到期日', 1, 2, NULL, NULL, 'trade.ExerciseDate 或 swap_position.PosiMatuirityDate', 'trade.ExerciseDate', 103, 0, 'SYSTEM', NOW()), -('合约平仓日', 1, 2, NULL, NULL, 'trade.UnWindDate', 'trade.UnWindDate', 104, 0, 'SYSTEM', NOW()), -('合约支付日', 1, 2, NULL, NULL, 'trade.SettlementDate', 'trade.SettlementDate', 105, 0, 'SYSTEM', NOW()), -('期初净价', 1, 1, '元', '≥ 0', 'swap_position.PosiNetNoFeePrice(债券 TRS)', 'swap_position.PosiNetNoFeePrice', 106, 0, 'SYSTEM', NOW()), -('期初全价', 1, 1, '元', '≥ 0', 'swap_position.PosiNetFeePrice(债券 TRS)/ trade.SpotPrice(其他)', 'swap_position.PosiNetFeePrice', 107, 0, 'SYSTEM', NOW()), -('期初收益率', 1, 1, '%', NULL, 'trade.InitYtm 或 swap_position.InitYtm', 'trade.InitYtm', 108, 0, 'SYSTEM', NOW()), -('期初价格', 1, 1, '元', '≥ 0', 'trade.SpotPrice(非债券类)', 'trade.SpotPrice', 109, 0, 'SYSTEM', NOW()), -('期末全价', 1, 1, '元', '≥ 0', 'eod_swap_position.UnderlyingPrice(债券 TRS)', 'eod_swap_position.UnderlyingPrice', 110, 0, 'SYSTEM', NOW()), -('期末价格', 1, 1, '元', '≥ 0', 'trade.FinalPrice', 'trade.FinalPrice', 111, 0, 'SYSTEM', NOW()), -('保证金利率', 1, 1, '%', NULL, 'client_marginrate.InitMarginRebateRate', 'client_marginrate.InitMarginRebateRate', 112, 0, 'SYSTEM', NOW()), -('保证金比例', 1, 1, '%', '0~100', 'trade.MarginRate', 'trade.MarginRate', 113, 0, 'SYSTEM', NOW()), -('客户授信额度', 1, 1, '元', '≥ 0', 'credit.Credit', 'credit.Credit', 114, 0, 'SYSTEM', NOW()); - --- 4.4.2 行情类(Category=2,统一取上一交易日收盘价) -INSERT INTO `glms_risk_variable` (`VariableName`, `Category`, `DataType`, `Unit`, `ValueDomain`, `Description`, `VariableExpr`, `SortOrder`, `OptId`, `OptName`, `OptDate`) VALUES -('上一收盘日中债估值净价', 2, 1, '元', '≥ 0', '资讯数据', 'market.CBValuationNetPrice', 201, 0, 'SYSTEM', NOW()), -('上一收盘日中债估值全价', 2, 1, '元', '≥ 0', '资讯数据', 'market.CBValuationFullPrice', 202, 0, 'SYSTEM', NOW()), -('上一收盘日中债估值收益率', 2, 1, '%', NULL, '资讯数据', 'market.CBValuationYtm', 203, 0, 'SYSTEM', NOW()), -('上一日收盘价', 2, 1, '元', '≥ 0', '行情数据,按标的区分', 'market.LastClosePrice', 204, 0, 'SYSTEM', NOW()), -('借贷加权费率', 2, 1, '%', NULL, 'CMDM 标的债券借贷费率行情表', 'market.BondLendingRate', 205, 0, 'SYSTEM', NOW()), -('FR007', 2, 1, '%', NULL, '上一交易日收盘价', 'market.FR007', 206, 0, 'SYSTEM', NOW()), -('当前日期', 2, 2, NULL, NULL, 'DateTime.Today', 'sys.CurrentDate', 207, 0, 'SYSTEM', NOW()), -('挂钩标的到期日', 2, 2, NULL, NULL, '资讯数据', 'market.UnderlyingMaturityDate', 208, 0, 'SYSTEM', NOW()), -('标的发行余额', 2, 1, '元', '≥ 0', '资讯数据', 'market.UnderlyingIssueBalance', 209, 0, 'SYSTEM', NOW()); - --- 4.4.3 系统计算值类(Category=3) -INSERT INTO `glms_risk_variable` (`VariableName`, `Category`, `DataType`, `Unit`, `ValueDomain`, `Description`, `VariableExpr`, `SortOrder`, `OptId`, `OptName`, `OptDate`) VALUES -('挂钩标的集中度', 3, 1, '%', '0~100', '同一标的存续交易总名义本金 ÷ 标的发行余额 × 100', 'calc.UnderlyingConcentration', 301, 0, 'SYSTEM', NOW()), -('授信占用率', 3, 1, '%', '0~100', '(已占用授信 + 本笔授信占用) ÷ 授信总额 × 100', 'calc.CreditUsageRate', 302, 0, 'SYSTEM', NOW()), -('合约期限', 3, 1, '天', '≥ 0', '(ExerciseDate - StartDate).Days', 'calc.MaturityDays', 303, 0, 'SYSTEM', NOW()), -('Delta', 3, 1, NULL, NULL, 'realtime_trade_risk.Delta(预留接口,一期不纳入)', 'realtime_trade_risk.Delta', 304, 0, 'SYSTEM', NOW()), -('Gamma', 3, 1, NULL, NULL, 'realtime_trade_risk.Gamma(预留接口,一期不纳入)', 'realtime_trade_risk.Gamma', 305, 0, 'SYSTEM', NOW()), -('Vega', 3, 1, NULL, NULL, 'realtime_trade_risk.Vega(预留接口,一期不纳入)', 'realtime_trade_risk.Vega', 306, 0, 'SYSTEM', NOW()), -('Theta', 3, 1, NULL, NULL, 'realtime_trade_risk.Theta(预留接口,一期不纳入)', 'realtime_trade_risk.Theta', 307, 0, 'SYSTEM', NOW()), -('利息端利率', 3, 1, '%', NULL, '固定利率 或 FR007 ± 加点', 'calc.InterestRate', 308, 0, 'SYSTEM', NOW()), -('对手方累计标的数量', 3, 1, '个', '≥ 0', 'COUNT(DISTINCT UnderlyingId) 该对手方所有存续交易,含本笔', 'calc.CounterpartyUnderlyingCount', 309, 0, 'SYSTEM', NOW()), -('同一标的累计名义本金', 3, 1, '元', '≥ 0', 'SUM(该标的所有存续交易的 StockEqvNotional),含本笔', 'calc.SameUnderlyingTotalNotional', 310, 0, 'SYSTEM', NOW()), -('同一客户累计名义本金', 3, 1, '元', '≥ 0', 'SUM(该客户所有存续交易的 StockEqvNotional),含本笔', 'calc.SameClientTotalNotional', 311, 0, 'SYSTEM', NOW()), -('总持仓名义本金', 3, 1, '元', '≥ 0', 'SUM(所有存续交易的 StockEqvNotional)', 'calc.TotalPositionNotional', 312, 0, 'SYSTEM', NOW()); --- 新增变量(ID 45~53): --- 45=期初净价偏离度 46=期初收益率偏离度 47=期初价格偏离度 --- 48=利息端利率与FR007偏离度 49=利息端利率与借贷加权费率偏离度 --- 50=执行价偏离度 51=执行价 52=客户品种最低保证金率 53=参考价格 -INSERT INTO `glms_risk_variable` (`VariableName`, `Category`, `DataType`, `Unit`, `ValueDomain`, `Description`, `VariableExpr`, `SortOrder`, `OptId`, `OptName`, `OptDate`) VALUES -('期初净价偏离度', 3, 1, '%', '≥ 0', 'ABS(期初净价-中债估值净价)/中债估值净价*100', 'Math.Abs(swap_position.PosiNetNoFeePrice - market.CBValuationNetPrice) / market.CBValuationNetPrice * 100', 313, 0, 'SYSTEM', NOW()), -('期初收益率偏离度', 3, 1, '%', '≥ 0', 'ABS(期初收益率-中债估值收益率)/中债估值收益率*100', 'Math.Abs(trade.InitYtm - market.CBValuationYtm) / market.CBValuationYtm * 100', 314, 0, 'SYSTEM', NOW()), -('期初价格偏离度', 3, 1, '%', '≥ 0', 'ABS(期初价格-上一日收盘价)/上一日收盘价*100', 'Math.Abs(trade.SpotPrice - market.LastClosePrice) / market.LastClosePrice * 100', 315, 0, 'SYSTEM', NOW()), -('利息端利率与FR007偏离度', 3, 1, '%', '≥ 0', 'ABS(利息端利率-FR007)/FR007*100', 'Math.Abs(calc.InterestRate - market.FR007) / market.FR007 * 100', 316, 0, 'SYSTEM', NOW()), -('利息端利率与借贷加权费率偏离度', 3, 1, '%', '≥ 0', 'ABS(利息端利率-借贷加权费率)/借贷加权费率*100', 'Math.Abs(calc.InterestRate - market.BondLendingRate) / market.BondLendingRate * 100', 317, 0, 'SYSTEM', NOW()), -('执行价偏离度', 3, 1, '%', '≥ 0', 'ABS(执行价-参考价格)/参考价格*100', 'Math.Abs(trade.StrikePrice - market.ReferencePrice) / market.ReferencePrice * 100', 318, 0, 'SYSTEM', NOW()), -('执行价', 1, 1, '元', '≥ 0', 'trade.StrikePrice', 'trade.StrikePrice', 115, 0, 'SYSTEM', NOW()), -('客户品种最低保证金率', 1, 1, '%', '0~100', '客户品种最低保证金率(由账户/标的配置决定)', 'config.MinMarginRate', 116, 0, 'SYSTEM', NOW()), -('参考价格', 2, 1, '元', '≥ 0', '执行价参考价格(行情数据)', 'market.ReferencePrice', 210, 0, 'SYSTEM', NOW()); - --- 4.4.4 布尔判断类(Category=4) -INSERT INTO `glms_risk_variable` (`VariableName`, `Category`, `DataType`, `Unit`, `ValueDomain`, `Description`, `VariableExpr`, `SortOrder`, `OptId`, `OptName`, `OptDate`) VALUES -('到期日是否银行间交易日', 4, 3, NULL, NULL, '查询银行间交易日历', 'calc.IsExerciseDateTradingDay', 401, 0, 'SYSTEM', NOW()), -('平仓日是否银行间交易日', 4, 3, NULL, NULL, '查询银行间交易日历', 'calc.IsUnwindDateTradingDay', 402, 0, 'SYSTEM', NOW()), -('支付日是否银行间交易日', 4, 3, NULL, NULL, '查询银行间交易日历', 'calc.IsSettlementDateTradingDay', 403, 0, 'SYSTEM', NOW()), -('利息端/浮动端方向是否同向', 4, 3, NULL, NULL, '利息端"收取"↔浮动端"多头",利息端"支付"↔浮动端"空头"', 'calc.IsInterestFloatSameDirection', 404, 0, 'SYSTEM', NOW()), -('关键业务要素是否一致', 4, 3, NULL, NULL, '交易确认书 vs 簿记要素(大模型方案)', 'calc.IsKeyElementsConsistent', 405, 0, 'SYSTEM', NOW()), -('多空方向为多头', 4, 3, NULL, NULL, 'trade.BuySell == "买入" 或浮动端为多头', 'calc.IsLongDirection', 406, 0, 'SYSTEM', NOW()), -('多空方向为空头', 4, 3, NULL, NULL, '与多头互斥', 'calc.IsShortDirection', 407, 0, 'SYSTEM', NOW()), -('保证金收支方向为支付', 4, 3, NULL, NULL, '保证金方向为支付', 'calc.IsMarginPay', 408, 0, 'SYSTEM', NOW()), -('保证金收支方向为收取', 4, 3, NULL, NULL, '与支付互斥', 'calc.IsMarginReceive', 409, 0, 'SYSTEM', NOW()); diff --git a/UnitTestProject/Modules/RiskEngine/RuleConditionExpressionBuilderTest.cs b/UnitTestProject/Modules/RiskEngine/RuleConditionExpressionBuilderTest.cs new file mode 100644 index 00000000..e083b39f --- /dev/null +++ b/UnitTestProject/Modules/RiskEngine/RuleConditionExpressionBuilderTest.cs @@ -0,0 +1,322 @@ +using YLErp.Commons; +using YLErp.DBModels; +using YLErp.Modules.RiskEngine.Dto; + +namespace YLErp.Modules.RiskEngine +{ + [TestClass] + public class RuleConditionExpressionBuilderTest + { + [TestMethod] + public void Build_NumericFixedValue_UsesNormalizedLiteralWithoutSuffix() + { + var expression = Build( + new RuleCondition + { + VariableId = 1, + Operator = "gt", + ThresholdType = "Fixed", + Value = "123.4500" + }, + Variable(1, RiskVariableDataType.Numeric, "trade.Amount")); + + Assert.AreEqual( + "trade.Amount > 123.45", + expression); + Assert.IsFalse(expression.Contains("123.45m")); + } + + [DataTestMethod] + [DataRow("0", "0")] + [DataRow("-0", "0")] + [DataRow("123.4500", "123.45")] + [DataRow("1e3", "1000")] + [DataRow("1e-28", "0.0000000000000000000000000001")] + public void Build_NumericFixedValue_NormalizesLikeFrontend(string input, string expectedValue) + { + var expression = Build( + new RuleCondition + { + VariableId = 1, + Operator = "eq", + ThresholdType = "Fixed", + Value = input + }, + Variable(1, RiskVariableDataType.Numeric, "amount")); + + Assert.AreEqual( + $"amount == {expectedValue}", + expression); + } + + [TestMethod] + public void Build_NumericFixedValue_RejectsThousandsSeparator() + { + Assert.ThrowsException(() => Build( + new RuleCondition + { + VariableId = 1, + Operator = "eq", + ThresholdType = "Fixed", + Value = "1,000" + }, + Variable(1, RiskVariableDataType.Numeric, "amount"))); + } + + [DataTestMethod] + [DataRow("gt", ">")] + [DataRow("lt", "<")] + [DataRow("gte", ">=")] + [DataRow("lte", "<=")] + [DataRow("eq", "==")] + [DataRow("ne", "!=")] + public void Build_ComparisonTokens_MapToCSharpOperators(string token, string expectedOperator) + { + var expression = Build( + new RuleCondition + { + VariableId = 1, + Operator = token, + ThresholdType = "Fixed", + Value = "0" + }, + Variable(1, RiskVariableDataType.Numeric, "amount")); + + StringAssert.Contains(expression, $" {expectedOperator} "); + } + + [TestMethod] + public void Build_NumericVariableThreshold_UsesStoredExpressions() + { + var expression = Build( + new RuleCondition + { + VariableId = 1, + Operator = "lte", + ThresholdType = "Variable", + ThresholdVariableId = 2 + }, + Variable(1, RiskVariableDataType.Numeric, "trade.Amount", "元"), + Variable(2, RiskVariableDataType.Numeric, "limit.Amount", "元")); + + Assert.AreEqual( + "trade.Amount <= limit.Amount", + expression); + } + + [TestMethod] + public void Build_NumericVariableThreshold_RejectsDifferentUnit() + { + var exception = Assert.ThrowsException(() => Build( + new RuleCondition + { + VariableId = 1, + Operator = "lte", + ThresholdType = "Variable", + ThresholdVariableId = 2 + }, + Variable(1, RiskVariableDataType.Numeric, "trade.Amount", "元"), + Variable(2, RiskVariableDataType.Numeric, "trade.Count", "笔"))); + + StringAssert.Contains(exception.Message, "单位不一致"); + } + + [TestMethod] + public void Build_NumericRangeVariableBound_RejectsDifferentUnit() + { + var exception = Assert.ThrowsException(() => Build( + new RuleCondition + { + VariableId = 1, + Operator = "between", + LowerThresholdType = "Fixed", + LowerValue = "0", + UpperThresholdType = "Variable", + UpperThresholdVariableId = 2, + IncludeLower = true, + IncludeUpper = true + }, + Variable(1, RiskVariableDataType.Numeric, "trade.Amount", "元"), + Variable(2, RiskVariableDataType.Numeric, "trade.Count", "笔"))); + + StringAssert.Contains(exception.Message, "单位不一致"); + } + + [TestMethod] + public void Build_FixedDate_UsesDateConstructorTemplate() + { + var expression = Build( + new RuleCondition + { + VariableId = 1, + Operator = "eq", + ThresholdType = "Fixed", + Value = "2026-07-21" + }, + Variable(1, RiskVariableDataType.Date, "trade.TradeDate")); + + Assert.AreEqual("trade.TradeDate == new DateTime(2026, 7, 21)", expression); + } + + [DataTestMethod] + [DataRow("isTrue", "trade.IsConfirmed == true")] + [DataRow("isFalse", "trade.IsConfirmed == false")] + public void Build_BooleanToken_UsesBooleanLiteral(string token, string expected) + { + var expression = Build( + new RuleCondition { VariableId = 1, Operator = token }, + Variable(1, RiskVariableDataType.Boolean, "trade.IsConfirmed")); + + Assert.AreEqual(expected, expression); + } + + [TestMethod] + public void Build_Between_AllowsMixedFixedAndVariableBounds() + { + var expression = Build( + new RuleCondition + { + VariableId = 1, + Operator = "between", + LowerThresholdType = "Fixed", + LowerValue = "-1.25", + UpperThresholdType = "Variable", + UpperThresholdVariableId = 2, + IncludeLower = false, + IncludeUpper = true + }, + Variable(1, RiskVariableDataType.Numeric, "trade.Amount"), + Variable(2, RiskVariableDataType.Numeric, "limit.Upper")); + + Assert.AreEqual( + "trade.Amount > -1.25 && trade.Amount <= limit.Upper", + expression); + } + + [TestMethod] + public void Build_NotBetween_NegatesCompleteRangeExpression() + { + var expression = Build( + new RuleCondition + { + VariableId = 1, + Operator = "notBetween", + LowerThresholdType = "Fixed", + LowerValue = "2026-01-01", + UpperThresholdType = "Fixed", + UpperValue = "2026-12-31", + IncludeLower = true, + IncludeUpper = false + }, + Variable(1, RiskVariableDataType.Date, "trade.TradeDate")); + + Assert.AreEqual( + "!(trade.TradeDate >= new DateTime(2026, 1, 1) && trade.TradeDate < new DateTime(2026, 12, 31))", + expression); + } + + [TestMethod] + public void Build_MultipleConditions_UsesExactAndSeparator() + { + var conditions = new[] + { + new RuleCondition { VariableId = 1, Operator = "gt", ThresholdType = "Fixed", Value = "0" }, + new RuleCondition { VariableId = 2, Operator = "isTrue" } + }; + var variables = Variables( + Variable(1, RiskVariableDataType.Numeric, "amount"), + Variable(2, RiskVariableDataType.Boolean, "isValid")); + + var expression = RuleConditionExpressionBuilder.Build(conditions, variables); + + Assert.AreEqual( + "amount > 0 && isValid == true", + expression); + } + + [TestMethod] + public void Build_RejectsNonRangeConditionWithRangeFields() + { + var condition = new RuleCondition + { + VariableId = 1, + Operator = "gt", + ThresholdType = "Fixed", + Value = "0", + IncludeLower = true + }; + + Assert.ThrowsException(() => Build( + condition, + Variable(1, RiskVariableDataType.Numeric, "amount"))); + } + + [TestMethod] + public void ReferencedVariableIds_IgnoreNullConditionItems() + { + var conditions = new RuleCondition[] + { + null, + new RuleCondition { VariableId = 7 } + }; + + CollectionAssert.AreEqual( + new long[] { 7 }, + RuleConditionExpressionBuilder.GetReferencedVariableIds(conditions).ToArray()); + } + + [TestMethod] + public void ReferencedVariableIds_IncludeAllFourFields() + { + var conditions = new[] + { + new RuleCondition + { + VariableId = 1, + ThresholdVariableId = 2, + LowerThresholdVariableId = 3, + UpperThresholdVariableId = 4 + } + }; + + CollectionAssert.AreEquivalent( + new long[] { 1, 2, 3, 4 }, + RuleConditionExpressionBuilder.GetReferencedVariableIds(conditions).ToArray()); + } + + [TestMethod] + public void IsVariableReferenced_MatchesAllFieldsWithoutIdPrefixCollision() + { + const string json = "[{\"VariableId\":10,\"ThresholdVariableId\":20,\"LowerThresholdVariableId\":30,\"UpperThresholdVariableId\":40}]"; + + Assert.IsTrue(RuleConditionExpressionBuilder.IsVariableReferenced(json, 10)); + Assert.IsTrue(RuleConditionExpressionBuilder.IsVariableReferenced(json, 20)); + Assert.IsTrue(RuleConditionExpressionBuilder.IsVariableReferenced(json, 30)); + Assert.IsTrue(RuleConditionExpressionBuilder.IsVariableReferenced(json, 40)); + Assert.IsFalse(RuleConditionExpressionBuilder.IsVariableReferenced(json, 1)); + Assert.IsFalse(RuleConditionExpressionBuilder.IsVariableReferenced(json, 4)); + } + + private static string Build(RuleCondition condition, params glms_risk_variable[] variables) + { + return RuleConditionExpressionBuilder.Build(new[] { condition }, Variables(variables)); + } + + private static IReadOnlyDictionary Variables(params glms_risk_variable[] variables) + { + return variables.ToDictionary(variable => (long)variable.id); + } + + private static glms_risk_variable Variable(long id, RiskVariableDataType dataType, string expression, string unit = null) + { + return new glms_risk_variable + { + id = (int)id, + VariableName = $"V{id}", + DataType = dataType, + VariableExpr = expression, + Unit = unit + }; + } + } +} diff --git a/UnitTestProject/Program.cs b/UnitTestProject/Program.cs index 557a48aa..0f768067 100644 --- a/UnitTestProject/Program.cs +++ b/UnitTestProject/Program.cs @@ -15,6 +15,9 @@ namespace YLErp [ModuleInitializer] internal static void M1() { + if (string.Equals(Environment.GetEnvironmentVariable("YLErp_UNIT_TEST_SKIP_INITIALIZATION"), "1", StringComparison.Ordinal)) + return; + Console.WriteLine("version--" + AppManager.Version); var logger = NLog.LogManager.Setup().GetCurrentClassLogger(); diff --git a/YLErpDAL/Modules/RiskEngine/Dto/RuleCondition.cs b/YLErpDAL/Modules/RiskEngine/Dto/RuleCondition.cs index 071be483..7a5ee98e 100644 --- a/YLErpDAL/Modules/RiskEngine/Dto/RuleCondition.cs +++ b/YLErpDAL/Modules/RiskEngine/Dto/RuleCondition.cs @@ -1,23 +1,37 @@ namespace YLErp.Modules.RiskEngine.Dto { /// - /// 规则条件项 + /// 结构化规则条件项。Operator 使用稳定语义 token,不存储 UI 文案或 C# 操作符。 /// public class RuleCondition { /// 条件变量 ID public long VariableId { get; set; } - /// 操作符(如 Equal、GreaterThan、Between 等) + /// 操作符 token:gt/lt/gte/lte/eq/ne/between/notBetween/isTrue/isFalse public string Operator { get; set; } - /// 阈值类型(FixedValue / VariableRef) + + /// 普通比较阈值类型:Fixed / Variable public string ThresholdType { get; set; } - /// 阈值(固定值或变量引用) + /// 普通比较固定阈值 public object Value { get; set; } - /// 阈值变量 ID(当 ThresholdType 为 VariableRef 时) + /// 普通比较阈值变量 ID public long? ThresholdVariableId { get; set; } - /// 介于/不介于的下边界是否包含,默认 true - public bool IncludeLowerBound { get; set; } = true; - /// 介于/不介于的上边界是否包含,默认 true - public bool IncludeUpperBound { get; set; } = true; + + /// 区间下限阈值类型:Fixed / Variable + public string LowerThresholdType { get; set; } + /// 区间固定下限 + public object LowerValue { get; set; } + /// 区间下限变量 ID + public long? LowerThresholdVariableId { get; set; } + /// 区间上限阈值类型:Fixed / Variable + public string UpperThresholdType { get; set; } + /// 区间固定上限 + public object UpperValue { get; set; } + /// 区间上限变量 ID + public long? UpperThresholdVariableId { get; set; } + /// 是否包含下限 + public bool? IncludeLower { get; set; } + /// 是否包含上限 + public bool? IncludeUpper { get; set; } } } diff --git a/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs b/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs index d0abff89..92ade91b 100644 --- a/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs +++ b/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs @@ -1,8 +1,6 @@ using BaseOUDAL; using Microsoft.EntityFrameworkCore; using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using System.Globalization; using System.Linq; using System.Linq.Expressions; using System.Text.RegularExpressions; @@ -208,12 +206,6 @@ namespace YLErp.Modules.RiskEngine "FUND_PAYMENT" }; - private static readonly Dictionary> ValidOperatorsByType = new Dictionary> - { - { RiskVariableDataType.Numeric, new HashSet { ">", "<", ">=", "<=", "=", "≠", "介于", "不介于" } }, - { RiskVariableDataType.Date, new HashSet { "早于", "晚于", "等于", "不早于", "不晚于", "介于", "不介于" } }, - { RiskVariableDataType.Boolean, new HashSet { "是", "否" } } - }; private static readonly Dictionary AuditOperationTypeNames = new Dictionary(StringComparer.OrdinalIgnoreCase) { @@ -361,90 +353,12 @@ namespace YLErp.Modules.RiskEngine } /// - /// 校验 ConditionJson(结构化模式:变量存在性、操作符、类型匹配) + /// 校验 ConditionJson 并生成服务端期望的简洁 RuleExpr。 /// - private void ValidateConditionJson(string conditionJson) + private string ValidateConditionJson(string conditionJson) { - List conditions; - try - { - conditions = JsonConvert.DeserializeObject>(conditionJson); - } - catch - { - throw new ServiceException("公式表达式 JSON 格式不合法"); - } - - if (conditions == null || conditions.Count == 0) - throw new ServiceException("公式条件列表不能为空"); - - for (int i = 0; i < conditions.Count; i++) - { - var cond = conditions[i]; - var condLabel = $"条件{i + 1}"; - - var variableDef = DbContext.glms_risk_variable - .FirstOrDefault(v => v.id == cond.VariableId); - if (variableDef == null) - throw new ServiceException($"{condLabel}:变量 ID '{cond.VariableId}' 不存在"); - - var validOperators = ValidOperatorsByType[variableDef.DataType]; - if (!validOperators.Contains(cond.Operator)) - throw new ServiceException($"{condLabel}:操作符 '{cond.Operator}' 不适用于{GetDataTypeName(variableDef.DataType)}类型变量"); - - if (cond.ThresholdType == "Fixed") - { - if (cond.Value == null) - throw new ServiceException($"{condLabel}:固定阈值不能为空"); - - if (cond.Operator == "介于" || cond.Operator == "不介于") - { - // 介于/不介于:Value 必须为双元素数组 [下限, 上限] - if (!(cond.Value is Newtonsoft.Json.Linq.JArray arr && arr.Count == 2)) - throw new ServiceException($"{condLabel}:操作符 '{cond.Operator}' 的阈值必须为双元素数组 [下限, 上限]"); - if (variableDef.DataType == RiskVariableDataType.Numeric) - { - if (!decimal.TryParse(arr[0].ToString(), out _) || !decimal.TryParse(arr[1].ToString(), out _)) - throw new ServiceException($"{condLabel}:数值型介于/不介于的下限和上限必须为数字"); - } - if (variableDef.DataType == RiskVariableDataType.Date) - { - if (!DateTime.TryParse(arr[0].ToString(), out _) || !DateTime.TryParse(arr[1].ToString(), out _)) - throw new ServiceException($"{condLabel}:日期型介于/不介于的起始和结束日期必须为合法日期"); - } - } - else - { - if (variableDef.DataType == RiskVariableDataType.Numeric) - { - if (!decimal.TryParse(cond.Value.ToString(), out _)) - throw new ServiceException($"{condLabel}:数值型变量的阈值必须为数字"); - } - if (variableDef.DataType == RiskVariableDataType.Date) - { - if (!DateTime.TryParse(cond.Value.ToString(), out _)) - throw new ServiceException($"{condLabel}:日期型变量的阈值必须为合法日期"); - } - } - } - else if (cond.ThresholdType == "Variable") - { - if (!cond.ThresholdVariableId.HasValue) - throw new ServiceException($"{condLabel}:变量阈值引用的变量 ID 不能为空"); - var thresholdVarDef = DbContext.glms_risk_variable - .FirstOrDefault(v => v.id == cond.ThresholdVariableId.Value); - if (thresholdVarDef == null) - throw new ServiceException($"{condLabel}:阈值变量 ID '{cond.ThresholdVariableId.Value}' 不存在"); - if (thresholdVarDef.DataType != variableDef.DataType) - throw new ServiceException($"{condLabel}:阈值变量与条件变量的数据类型不一致"); - } - else - { - throw new ServiceException($"{condLabel}:阈值类型 '{cond.ThresholdType}' 不合法,仅支持 Fixed/Variable"); - } - } + return BuildRuleExprFromConditionJson(conditionJson); } - /// /// 校验 RuleExpr(自由文本模式:括号匹配) /// @@ -599,7 +513,7 @@ namespace YLErp.Modules.RiskEngine /// /// 校验规则参数(名称 + ConditionJson + RuleExpr) /// - private void ValidateRuleParams(string ruleName, string conditionJson, string ruleExpr) + private void ValidateRuleParams(string ruleName, string conditionJson, string ruleExpr, long? ruleId = null) { if (string.IsNullOrWhiteSpace(ruleName)) throw new ServiceException("规则名称不能为空"); @@ -611,7 +525,12 @@ namespace YLErp.Modules.RiskEngine if (!string.IsNullOrWhiteSpace(conditionJson)) { - ValidateConditionJson(conditionJson); + var expectedRuleExpr = ValidateConditionJson(conditionJson); + if (!string.Equals(expectedRuleExpr, ruleExpr, StringComparison.Ordinal)) + { + _logger.Info($"结构化规则公式一致性校验失败。RuleId={ruleId?.ToString() ?? "NEW"}; Expected={expectedRuleExpr}; Actual={ruleExpr}"); + throw new ServiceException("公式与结构化条件不一致,请刷新后重试"); + } } ValidateRuleExpr(ruleExpr); @@ -816,6 +735,9 @@ namespace YLErp.Modules.RiskEngine }; } + /// + /// 汇总逐项结果。业务失败彼此隔离,只有 Succeeded 表示实体已实际变更。 + /// private static BatchOperationResult BuildBatchResult(List items) { var result = new BatchOperationResult @@ -836,6 +758,39 @@ namespace YLErp.Modules.RiskEngine return result; } + /// + /// 记录批量业务结果。预期业务失败使用 Info,避免触发生产 Error 告警。 + /// + private void LogBatchResult(string operationName, BatchOperationResult result) + { + const int maxFailedItems = 20; + var failedItems = result.Items + .Where(item => item.Status == BatchOperationItemStatus.Failed) + .Take(maxFailedItems) + .Select(item => $"{item.Id}:{item.Code}") + .ToList(); + var failedSummary = failedItems.Any() ? string.Join(",", failedItems) : "无"; + var truncated = result.FailedCount > maxFailedItems ? ",...(已截断)" : string.Empty; + + _logger.Info($"[{operationName}] 批量操作完成。Total={result.TotalCount}, Succeeded={result.SucceededCount}, Unchanged={result.UnchangedCount}, Failed={result.FailedCount}, FailedItems={failedSummary}{truncated}"); + } + + /// + /// 数据库提交完成后刷新缓存;刷新失败仅记录 Error 告警,不将已提交操作返回为失败。 + /// + private void TryRefreshCache(Action refreshAction, string cacheName, IEnumerable affectedIds) + { + try + { + refreshAction(); + } + catch (Exception ex) + { + var ids = affectedIds?.Distinct().Take(20).ToList() ?? new List(); + _logger.Error($"{cacheName}刷新失败,AffectedIds={string.Join(",", ids)}", ex); + } + } + #endregion #region Rule Management @@ -994,7 +949,7 @@ namespace YLErp.Modules.RiskEngine if (rule.Version != req.Version) throw new ServiceException("规则已被其他用户修改,请重新加载后再编辑"); - ValidateRuleParams(req.RuleName, req.ConditionJson, req.RuleExpr); + ValidateRuleParams(req.RuleName, req.ConditionJson, req.RuleExpr, ruleId); var compileResult = RuleCompiler.ValidateAndCompileFormula(ruleId, req.RuleExpr); if (!compileResult.Success) @@ -1025,7 +980,6 @@ namespace YLErp.Modules.RiskEngine { var rule = GetRuleOrThrow(ruleId); - var activeCount = DbContext.glms_risk_rule_application .Where(a => a.Status == RiskRuleStatus.Active) .Count(RuleIdsMatchExpr(ruleId)); @@ -1206,12 +1160,16 @@ namespace YLErp.Modules.RiskEngine if (changedIds.Any()) { + // 所有成功项一次原子提交;提交完成后,缓存失败仅告警,不改变数据库成功结果。 SaveChangesWithConcurrencyCheck(); - foreach (var id in changedIds) - RiskEngineService.GetInstance().RefreshOneRuleCache(id); + TryRefreshCache( + () => changedIds.ForEach(id => RiskEngineService.GetInstance().RefreshOneRuleCache(id)), + "风控规则缓存", changedIds); } - return BuildBatchResult(items); + var result = BuildBatchResult(items); + LogBatchResult("批量删除规则", result); + return result; } /// @@ -1260,12 +1218,16 @@ namespace YLErp.Modules.RiskEngine if (changedIds.Any()) { + // 仅成功启用项参与同一次提交和审计,编译失败不影响其他规则。 SaveChangesWithConcurrencyCheck(); - foreach (var id in changedIds) - RiskEngineService.GetInstance().RefreshOneRuleCache(id); + TryRefreshCache( + () => changedIds.ForEach(id => RiskEngineService.GetInstance().RefreshOneRuleCache(id)), + "风控规则缓存", changedIds); } - return BuildBatchResult(items); + var result = BuildBatchResult(items); + LogBatchResult("批量启用规则", result); + return result; } /// @@ -1309,6 +1271,7 @@ namespace YLErp.Modules.RiskEngine items.Add(BatchItem(id, BatchOperationItemStatus.Succeeded, "DISABLED", "规则已停用", cascadesByRule[id])); } + // 多条规则可能关联同一应用;实际状态修改、审计和顶层级联结果均按应用 ID 去重。 var cascadedIds = items.SelectMany(i => i.CascadedApplicationIds).Distinct().ToHashSet(); foreach (var app in activeApps.Where(a => cascadedIds.Contains(a.id))) { @@ -1323,14 +1286,19 @@ namespace YLErp.Modules.RiskEngine if (items.Any(i => i.Status == BatchOperationItemStatus.Succeeded)) { + // 规则及其级联应用在同一次数据库提交中生效。 SaveChangesWithConcurrencyCheck(); - foreach (var id in activeRules) - RiskEngineService.GetInstance().RefreshOneRuleCache(id); + TryRefreshCache( + () => activeRules.ForEach(id => RiskEngineService.GetInstance().RefreshOneRuleCache(id)), + "风控规则缓存", activeRules); if (cascadedIds.Any()) - RiskEngineService.GetInstance().RefreshApplication(); + TryRefreshCache(() => RiskEngineService.GetInstance().RefreshApplication(), + "风控应用缓存", cascadedIds); } - return BuildBatchResult(items); + var result = BuildBatchResult(items); + LogBatchResult("批量停用规则", result); + return result; } /// @@ -1659,9 +1627,12 @@ namespace YLErp.Modules.RiskEngine if (items.Any(i => i.Status == BatchOperationItemStatus.Succeeded)) { SaveChangesWithConcurrencyCheck(); - RiskEngineService.GetInstance().RefreshApplication(); + TryRefreshCache(() => RiskEngineService.GetInstance().RefreshApplication(), + "风控应用缓存", items.Where(i => i.Status == BatchOperationItemStatus.Succeeded).Select(i => i.Id)); } - return BuildBatchResult(items); + var result = BuildBatchResult(items); + LogBatchResult("批量删除应用配置", result); + return result; } /// @@ -1730,6 +1701,7 @@ namespace YLErp.Modules.RiskEngine items.Add(BatchItem(id, BatchOperationItemStatus.Failed, "NOT_FOUND", "应用配置不存在")); continue; } + // Deleted 是终态,不能通过批量启用恢复,避免已删除配置重新参与风控执行。 if (app.Status == RiskRuleStatus.Deleted) { items.Add(BatchItem(id, BatchOperationItemStatus.Failed, "DELETED_ENTITY", "已删除应用不能启用")); @@ -1761,9 +1733,12 @@ namespace YLErp.Modules.RiskEngine if (items.Any(i => i.Status == BatchOperationItemStatus.Succeeded)) { SaveChangesWithConcurrencyCheck(); - RiskEngineService.GetInstance().RefreshApplication(); + TryRefreshCache(() => RiskEngineService.GetInstance().RefreshApplication(), + "风控应用缓存", items.Where(i => i.Status == BatchOperationItemStatus.Succeeded).Select(i => i.Id)); } - return BuildBatchResult(items); + var result = BuildBatchResult(items); + LogBatchResult("批量启用应用配置", result); + return result; } /// @@ -1807,210 +1782,47 @@ namespace YLErp.Modules.RiskEngine if (items.Any(i => i.Status == BatchOperationItemStatus.Succeeded)) { SaveChangesWithConcurrencyCheck(); - RiskEngineService.GetInstance().RefreshApplication(); + TryRefreshCache(() => RiskEngineService.GetInstance().RefreshApplication(), + "风控应用缓存", items.Where(i => i.Status == BatchOperationItemStatus.Succeeded).Select(i => i.Id)); } - return BuildBatchResult(items); + var result = BuildBatchResult(items); + LogBatchResult("批量停用应用配置", result); + return result; } #endregion /// - /// 查询 ConditionJson 中引用了指定变量 ID 的规则(仅结构化模式) - /// 精确匹配 "VariableId":{id} 或 "ThresholdVariableId":{id},\b 防止 id=1 误匹配 id=10 + /// 查询 ConditionJson 中四类变量引用字段命中指定变量 ID 的规则(仅结构化模式)。 /// private List GetRulesByVariableId(long variableId) { var allRules = DbContext.glms_risk_rule - .Where(r => r.Status != RiskRuleStatus.Deleted && r.ConditionJson != null) + .Where(r => r.Status != RiskRuleStatus.Deleted && r.ConditionJson != null && r.ConditionJson != "") .ToList(); - if (allRules.Count == 0) return allRules; - - var idPattern = $@"""VariableId""\s*:\s*{variableId}\b"; - var thresholdPattern = $@"""ThresholdVariableId""\s*:\s*{variableId}\b"; - - return allRules.Where(r => - Regex.IsMatch(r.ConditionJson, idPattern) || - Regex.IsMatch(r.ConditionJson, thresholdPattern) - ).ToList(); + return allRules + .Where(rule => RuleConditionExpressionBuilder.IsVariableReferenced(rule.ConditionJson, variableId)) + .ToList(); } /// - /// 根据结构化 ConditionJson 和当前变量池定义重新生成可执行 RuleExpr。 - /// ConditionJson 是结构化规则的事实来源,避免通过字符串 Replace 级联修改表达式。 + /// 根据结构化 ConditionJson 和当前变量池定义重新生成简洁 RuleExpr。 /// 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 conditions = RuleConditionExpressionBuilder.DeserializeConditions(conditionJson); + var variableIds = RuleConditionExpressionBuilder.GetReferencedVariableIds(conditions).ToList(); var variables = DbContext.glms_risk_variable - .Where(v => variableIds.Contains(v.id)) + .Where(variable => variableIds.Contains(variable.id)) .ToList() - .ToDictionary(v => (long)v.id); + .ToDictionary(variable => (long)variable.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}'") - }; + return RuleConditionExpressionBuilder.Build(conditions, variables); } #region Variable Management @@ -2163,6 +1975,7 @@ namespace YLErp.Modules.RiskEngine } var oldVariableExpr = variable.VariableExpr; + var oldDataType = variable.DataType; variable.VariableName = req.VariableName; variable.Category = req.Category; @@ -2177,32 +1990,34 @@ namespace YLErp.Modules.RiskEngine variable.UpdateOptName = UserName; variable.UpdateDate = DateTime.Now; - var variableExprChanged = oldVariableExpr != req.VariableExpr; + var variableDefinitionChanged = + oldVariableExpr != req.VariableExpr || oldDataType != req.DataType; - if (variableExprChanged) + if (variableDefinitionChanged) { - // 结构化模式:通过 ConditionJson 中的 VariableId 找到受影响的规则 - var affectedRules = GetRulesByVariableId(variableId) - .Where(r => r.RuleExpr != null) - .ToList(); - - 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; - } - + List affectedRules; using (var transaction = DbContext.Database.BeginTransaction()) { try { + // 在同一事务内完成引用发现、公式重建、编译和持久化;任一规则失败时整体回滚。 + affectedRules = GetRulesByVariableId(variableId) + .Where(r => r.RuleExpr != null) + .ToList(); + + 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; + } + foreach (var rule in affectedRules) { rule.RuleExpr = rebuiltRuleExpressions[rule.id]; @@ -2212,7 +2027,7 @@ namespace YLErp.Modules.RiskEngine rule.UpdateDate = DateTime.Now; WriteAuditLog("VAR_CASCADE_UPDATE_RULE", "RULE", rule.id, rule.RuleName, - $"因变量'{variable.VariableName}'(ID:{variableId})表达式变更,规则表达式被级联更新"); + $"因变量'{variable.VariableName}'(ID:{variableId})定义变更,规则表达式被级联更新"); } SaveChangesWithConcurrencyCheck(); @@ -2288,9 +2103,8 @@ namespace YLErp.Modules.RiskEngine continue; } - var idPattern = $@"""VariableId""\s*:\s*{id}\b"; - var thresholdPattern = $@"""ThresholdVariableId""\s*:\s*{id}\b"; - var activeRefCount = activeRules.Count(r => Regex.IsMatch(r.ConditionJson, idPattern) || Regex.IsMatch(r.ConditionJson, thresholdPattern)); + var activeRefCount = activeRules.Count(rule => + RuleConditionExpressionBuilder.IsVariableReferenced(rule.ConditionJson, id)); if (activeRefCount > 0) { items.Add(BatchItem(id, BatchOperationItemStatus.Failed, "ACTIVE_RULE_REFERENCE", @@ -2305,10 +2119,14 @@ namespace YLErp.Modules.RiskEngine if (items.Any(i => i.Status == BatchOperationItemStatus.Succeeded)) { + // 只有实际删除项写审计并参与一次提交;缺失和引用阻塞项不影响其他变量。 SaveChangesWithConcurrencyCheck(); - InvalidateVariableCache(); + TryRefreshCache(InvalidateVariableCache, "风控变量缓存", + items.Where(i => i.Status == BatchOperationItemStatus.Succeeded).Select(i => i.Id)); } - return BuildBatchResult(items); + var result = BuildBatchResult(items); + LogBatchResult("批量删除变量", result); + return result; } /// diff --git a/YLErpDAL/Modules/RiskEngine/RuleConditionExpressionBuilder.cs b/YLErpDAL/Modules/RiskEngine/RuleConditionExpressionBuilder.cs new file mode 100644 index 00000000..46945ca0 --- /dev/null +++ b/YLErpDAL/Modules/RiskEngine/RuleConditionExpressionBuilder.cs @@ -0,0 +1,349 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using System.Globalization; +using System.Text.RegularExpressions; +using YLErp.Commons; +using YLErp.DBModels; +using YLErp.Modules.RiskEngine.Dto; + +namespace YLErp.Modules.RiskEngine +{ + /// + /// ConditionJson 契约校验和简洁 RuleExpr 生成器。 + /// + public static class RuleConditionExpressionBuilder + { + private static readonly HashSet ComparableOperators = new HashSet(StringComparer.Ordinal) + { + "gt", "lt", "gte", "lte", "eq", "ne", "between", "notBetween" + }; + + private static readonly HashSet BooleanOperators = new HashSet(StringComparer.Ordinal) + { + "isTrue", "isFalse" + }; + + private static readonly string[] VariableReferenceFields = + { + "VariableId", "ThresholdVariableId", "LowerThresholdVariableId", "UpperThresholdVariableId" + }; + + public static List DeserializeConditions(string conditionJson) + { + try + { + var conditions = JsonConvert.DeserializeObject>(conditionJson); + if (conditions == null || conditions.Count == 0) + throw new ServiceException("公式条件列表不能为空"); + return conditions; + } + catch (ServiceException) + { + throw; + } + catch + { + throw new ServiceException("公式表达式 JSON 格式不合法"); + } + } + + public static IReadOnlyCollection GetReferencedVariableIds(IEnumerable conditions) + { + return conditions + .SelectMany(condition => condition == null + ? Array.Empty() + : new long?[] + { + condition.VariableId, + condition.ThresholdVariableId, + condition.LowerThresholdVariableId, + condition.UpperThresholdVariableId + }) + .Where(id => id.HasValue && id.Value > 0) + .Select(id => id.Value) + .Distinct() + .ToList(); + } + + public static bool IsVariableReferenced(string conditionJson, long variableId) + { + if (string.IsNullOrWhiteSpace(conditionJson) || variableId <= 0) + return false; + + var idText = variableId.ToString(CultureInfo.InvariantCulture); + return VariableReferenceFields.Any(field => + Regex.IsMatch(conditionJson, $@"""{field}""\s*:\s*{idText}\b", RegexOptions.CultureInvariant)); + } + + public static string Build( + IReadOnlyList conditions, + IReadOnlyDictionary variables) + { + if (conditions == null || conditions.Count == 0) + throw new ServiceException("公式条件列表不能为空"); + + var expressions = conditions + .Select((condition, index) => BuildConditionExpression(condition, variables, index + 1)); + return string.Join(" && ", expressions); + } + + private static string BuildConditionExpression( + RuleCondition condition, + IReadOnlyDictionary variables, + int conditionIndex) + { + var label = $"条件{conditionIndex}"; + if (condition == null) + throw new ServiceException($"{label}不能为空"); + if (!variables.TryGetValue(condition.VariableId, out var variable)) + throw new ServiceException($"{label}:变量 ID '{condition.VariableId}' 不存在"); + if (string.IsNullOrWhiteSpace(variable.VariableExpr)) + throw new ServiceException($"{label}:变量'{variable.VariableName}'的取值表达式为空"); + + if (variable.DataType == RiskVariableDataType.Boolean) + return BuildBooleanExpression(condition, variable, label); + + if (variable.DataType != RiskVariableDataType.Numeric && variable.DataType != RiskVariableDataType.Date) + throw new ServiceException($"{label}:不支持的数据类型"); + if (!ComparableOperators.Contains(condition.Operator)) + throw new ServiceException($"{label}:操作符 '{condition.Operator}' 不适用于{GetDataTypeName(variable.DataType)}类型变量"); + + var leftExpression = BuildComparableExpression(variable, label); + if (condition.Operator == "between" || condition.Operator == "notBetween") + return BuildRangeExpression(condition, variable, leftExpression, variables, label); + + EnsureNoRangeFields(condition, label); + var rightExpression = BuildThresholdExpression( + condition.ThresholdType, + condition.Value, + condition.ThresholdVariableId, + variable, + variables, + label, + "阈值"); + var comparisonOperator = GetComparisonOperator(condition.Operator, label); + return $"{leftExpression} {comparisonOperator} {rightExpression}"; + } + + private static string BuildBooleanExpression(RuleCondition condition, glms_risk_variable variable, string label) + { + if (!BooleanOperators.Contains(condition.Operator)) + throw new ServiceException($"{label}:操作符 '{condition.Operator}' 不适用于布尔类型变量"); + EnsureNoThresholdFields(condition, label); + return $"{variable.VariableExpr} == {(condition.Operator == "isTrue" ? "true" : "false")}"; + } + + private static string BuildRangeExpression( + RuleCondition condition, + glms_risk_variable conditionVariable, + string leftExpression, + IReadOnlyDictionary variables, + string label) + { + if (condition.ThresholdType != null || HasValue(condition.Value) || condition.ThresholdVariableId.HasValue) + throw new ServiceException($"{label}:区间条件不得携带普通阈值字段"); + if (!condition.IncludeLower.HasValue || !condition.IncludeUpper.HasValue) + throw new ServiceException($"{label}:区间开闭配置不完整"); + + var lowerExpression = BuildThresholdExpression( + condition.LowerThresholdType, + condition.LowerValue, + condition.LowerThresholdVariableId, + conditionVariable, + variables, + label, + "下限"); + var upperExpression = BuildThresholdExpression( + condition.UpperThresholdType, + condition.UpperValue, + condition.UpperThresholdVariableId, + conditionVariable, + variables, + label, + "上限"); + + ValidateFixedRangeOrder(condition, conditionVariable.DataType, label); + + var lowerOperator = condition.IncludeLower.Value ? ">=" : ">"; + var upperOperator = condition.IncludeUpper.Value ? "<=" : "<"; + var rangeExpression = $"{leftExpression} {lowerOperator} {lowerExpression} && {leftExpression} {upperOperator} {upperExpression}"; + return condition.Operator == "notBetween" ? $"!({rangeExpression})" : rangeExpression; + } + + private static string BuildThresholdExpression( + string thresholdType, + object fixedValue, + long? thresholdVariableId, + glms_risk_variable conditionVariable, + IReadOnlyDictionary variables, + string label, + string thresholdLabel) + { + var dataType = conditionVariable.DataType; + if (string.Equals(thresholdType, "Fixed", StringComparison.Ordinal)) + { + if (thresholdVariableId.HasValue) + throw new ServiceException($"{label}:固定{thresholdLabel}不得携带变量 ID"); + return BuildFixedValueExpression(fixedValue, dataType, label, thresholdLabel); + } + + if (string.Equals(thresholdType, "Variable", StringComparison.Ordinal)) + { + if (HasValue(fixedValue)) + throw new ServiceException($"{label}:变量{thresholdLabel}不得携带固定值"); + if (!thresholdVariableId.HasValue) + throw new ServiceException($"{label}:{thresholdLabel}变量 ID 不能为空"); + if (!variables.TryGetValue(thresholdVariableId.Value, out var thresholdVariable)) + throw new ServiceException($"{label}:{thresholdLabel}变量 ID '{thresholdVariableId.Value}' 不存在"); + if (thresholdVariable.DataType != dataType) + throw new ServiceException($"{label}:{thresholdLabel}变量与条件变量的数据类型不一致"); + if (dataType == RiskVariableDataType.Numeric && !UnitsMatch(conditionVariable.Unit, thresholdVariable.Unit)) + throw new ServiceException($"{label}:{thresholdLabel}变量与条件变量的单位不一致"); + return BuildComparableExpression(thresholdVariable, label); + } + + throw new ServiceException($"{label}:{thresholdLabel}类型 '{thresholdType}' 不合法,仅支持 Fixed/Variable"); + } + + private static string BuildComparableExpression(glms_risk_variable variable, string label) + { + if (string.IsNullOrWhiteSpace(variable.VariableExpr)) + throw new ServiceException($"{label}:变量'{variable.VariableName}'的取值表达式为空"); + return variable.VariableExpr; + } + + private static string BuildFixedValueExpression( + object value, + RiskVariableDataType dataType, + string label, + string thresholdLabel) + { + if (!HasValue(value)) + throw new ServiceException($"{label}:固定{thresholdLabel}不能为空"); + + var rawValue = GetRawValue(value); + if (dataType == RiskVariableDataType.Numeric) + { + if (!decimal.TryParse(rawValue, NumberStyles.Float, + CultureInfo.InvariantCulture, out var numericValue)) + { + throw new ServiceException($"{label}:数值型{thresholdLabel}必须为数字"); + } + + return FormatFixedDecimal(numericValue); + } + + if (dataType == RiskVariableDataType.Date) + { + if (!DateTime.TryParseExact(rawValue, "yyyy-MM-dd", CultureInfo.InvariantCulture, + DateTimeStyles.None, out var dateValue)) + { + throw new ServiceException($"{label}:日期型{thresholdLabel}必须为 yyyy-MM-dd 格式的合法日期"); + } + + return $"new DateTime({dateValue.Year}, {dateValue.Month}, {dateValue.Day})"; + } + + throw new ServiceException($"{label}:不支持的数据类型"); + } + + private static string FormatFixedDecimal(decimal value) + { + return value.ToString("0.############################", CultureInfo.InvariantCulture); + } + + private static void ValidateFixedRangeOrder(RuleCondition condition, RiskVariableDataType dataType, string label) + { + if (condition.LowerThresholdType != "Fixed" || condition.UpperThresholdType != "Fixed") + return; + + var lower = GetRawValue(condition.LowerValue); + var upper = GetRawValue(condition.UpperValue); + if (dataType == RiskVariableDataType.Numeric && + decimal.TryParse(lower, NumberStyles.Float, CultureInfo.InvariantCulture, out var lowerNumber) && + decimal.TryParse(upper, NumberStyles.Float, CultureInfo.InvariantCulture, out var upperNumber) && + lowerNumber > upperNumber) + { + throw new ServiceException($"{label}:下限不能大于上限"); + } + + if (dataType == RiskVariableDataType.Date && + DateTime.TryParseExact(lower, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var lowerDate) && + DateTime.TryParseExact(upper, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var upperDate) && + lowerDate > upperDate) + { + throw new ServiceException($"{label}:下限不能晚于上限"); + } + } + + private static void EnsureNoRangeFields(RuleCondition condition, string label) + { + if (condition.LowerThresholdType != null || HasValue(condition.LowerValue) || condition.LowerThresholdVariableId.HasValue || + condition.UpperThresholdType != null || HasValue(condition.UpperValue) || condition.UpperThresholdVariableId.HasValue || + condition.IncludeLower.HasValue || condition.IncludeUpper.HasValue) + { + throw new ServiceException($"{label}:非区间条件不得携带区间字段"); + } + } + + private static void EnsureNoThresholdFields(RuleCondition condition, string label) + { + if (condition.ThresholdType != null || HasValue(condition.Value) || condition.ThresholdVariableId.HasValue || + condition.LowerThresholdType != null || HasValue(condition.LowerValue) || condition.LowerThresholdVariableId.HasValue || + condition.UpperThresholdType != null || HasValue(condition.UpperValue) || condition.UpperThresholdVariableId.HasValue || + condition.IncludeLower.HasValue || condition.IncludeUpper.HasValue) + { + throw new ServiceException($"{label}:布尔条件不得携带阈值字段"); + } + } + + private static bool HasValue(object value) + { + if (value == null) return false; + if (value is JValue jsonValue) + { + if (jsonValue.Type == JTokenType.Null || jsonValue.Type == JTokenType.Undefined) return false; + if (jsonValue.Type == JTokenType.String) return !string.IsNullOrWhiteSpace(jsonValue.Value()); + return true; + } + return value is not string text || !string.IsNullOrWhiteSpace(text); + } + + private static string GetRawValue(object value) + { + return value is JValue jsonValue + ? Convert.ToString(jsonValue.Value, CultureInfo.InvariantCulture) + : Convert.ToString(value, CultureInfo.InvariantCulture); + } + + private static bool UnitsMatch(string left, string right) + { + return string.Equals(left?.Trim() ?? string.Empty, right?.Trim() ?? string.Empty, StringComparison.Ordinal); + } + + private static string GetComparisonOperator(string ruleOperator, string label) + { + return ruleOperator switch + { + "gt" => ">", + "lt" => "<", + "gte" => ">=", + "lte" => "<=", + "eq" => "==", + "ne" => "!=", + _ => throw new ServiceException($"{label}:不支持的操作符'{ruleOperator}'") + }; + } + + private static string GetDataTypeName(RiskVariableDataType dataType) + { + return dataType switch + { + RiskVariableDataType.Numeric => "数值", + RiskVariableDataType.Date => "日期", + RiskVariableDataType.Boolean => "布尔", + _ => "未知" + }; + } + } +} diff --git a/YLErpWeb/Controllers/RiskRuleController.cs b/YLErpWeb/Controllers/RiskRuleController.cs index e90a2087..b5730fd6 100644 --- a/YLErpWeb/Controllers/RiskRuleController.cs +++ b/YLErpWeb/Controllers/RiskRuleController.cs @@ -14,6 +14,17 @@ namespace YLErp.Web.Controllers private RiskRuleService GetRiskRuleService() => new RiskRuleService(CurUser); + /// + /// 格式化批量请求上下文,限制 ID 数量以避免异常日志过长。 + /// + private static string FormatBatchRequestContext(BatchIdsReq req) + { + var ids = req?.Ids?.Distinct().ToList() ?? new List(); + var displayedIds = string.Join(",", ids.Take(20)); + var truncated = ids.Count > 20 ? ",...(已截断)" : string.Empty; + return $"Count={ids.Count}, Ids={displayedIds}{truncated}"; + } + #region Rule Management [HttpGet("risk-rules")] @@ -165,7 +176,7 @@ namespace YLErp.Web.Controllers } catch (Exception ex) { - _logger.Error(ex, "批量删除风控规则"); + _logger.Error(ex, $"批量删除风控规则异常,{FormatBatchRequestContext(req)}"); return Json(new { success = false, message = "系统异常,请联系管理员" }); } } @@ -236,7 +247,7 @@ namespace YLErp.Web.Controllers } catch (Exception ex) { - _logger.Error(ex, "批量启用风控规则"); + _logger.Error(ex, $"批量启用风控规则异常,{FormatBatchRequestContext(req)}"); return Json(new { success = false, message = "系统异常,请联系管理员" }); } } @@ -258,7 +269,7 @@ namespace YLErp.Web.Controllers } catch (Exception ex) { - _logger.Error(ex, "批量停用风控规则"); + _logger.Error(ex, $"批量停用风控规则异常,{FormatBatchRequestContext(req)}"); return Json(new { success = false, message = "系统异常,请联系管理员" }); } } @@ -482,7 +493,7 @@ namespace YLErp.Web.Controllers } catch (Exception ex) { - _logger.Error(ex, "批量启用应用配置"); + _logger.Error(ex, $"批量启用应用配置异常,{FormatBatchRequestContext(req)}"); return Json(new { success = false, message = "系统异常,请联系管理员" }); } } @@ -504,7 +515,7 @@ namespace YLErp.Web.Controllers } catch (Exception ex) { - _logger.Error(ex, "批量停用应用配置"); + _logger.Error(ex, $"批量停用应用配置异常,{FormatBatchRequestContext(req)}"); return Json(new { success = false, message = "系统异常,请联系管理员" }); } } @@ -526,7 +537,7 @@ namespace YLErp.Web.Controllers } catch (Exception ex) { - _logger.Error(ex, "批量删除应用配置"); + _logger.Error(ex, $"批量删除应用配置异常,{FormatBatchRequestContext(req)}"); return Json(new { success = false, message = "系统异常,请联系管理员" }); } } @@ -684,7 +695,7 @@ namespace YLErp.Web.Controllers } catch (Exception ex) { - _logger.Error(ex, "批量删除变量"); + _logger.Error(ex, $"批量删除变量异常,{FormatBatchRequestContext(req)}"); return Json(new { success = false, message = "系统异常,请联系管理员" }); } } From 6e11b17d9877465a547afe3f20ff9272907d68ed Mon Sep 17 00:00:00 2001 From: ruisu Date: Fri, 24 Jul 2026 13:41:47 +0800 Subject: [PATCH 4/8] =?UTF-8?q?feat=EF=BC=9A=E4=BF=AE=E6=94=B9=E7=94=A8?= =?UTF-8?q?=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- YLErpDAL/Modules/RiskEngine/测试用例.md | 1081 ++++++++++++++++++++++- 1 file changed, 1076 insertions(+), 5 deletions(-) diff --git a/YLErpDAL/Modules/RiskEngine/测试用例.md b/YLErpDAL/Modules/RiskEngine/测试用例.md index 00050f5c..7d29142a 100644 --- a/YLErpDAL/Modules/RiskEngine/测试用例.md +++ b/YLErpDAL/Modules/RiskEngine/测试用例.md @@ -1313,7 +1313,987 @@ ignoreRiskRuleIds = A --- -## 12. 规则 12 查询结果排查 SQL +## 12. 规则 1 查询结果排查 SQL + +规则 1:挂钩标的集中度(本地)。用于核对同一标的在本笔及所有存续/审批中交易中的总名义本金占该标的发行余额的比例。 + +取数流程: + +```text +1. 根据 TradeId 查当前 trade,取 UnderlyingId、UnderlyingCode。 +2. 查询 trade 表中同一 UnderlyingId 的有效交易。 +3. 限定 ParentTradeId = 0,且交易状态为存续状态或“审批中”。 +4. 汇总这些交易的 StockEqvNotional,作为同一标的在本笔及所有存续/审批中交易中的总名义本金。 +5. 根据当前交易 UnderlyingCode 查 underlying_manager.ExJson 中债券 IssueSize。 +6. IssueSize 库内单位为亿,乘以 100000000 还原为元,作为该标的发行余额。 +7. 计算 总名义本金 ÷ 发行余额 × 100%,大于 30% 时命中。 +``` + +规则公式: + +```text +同一标的在本笔及所有存续/审批中交易中的总名义本金 ÷ 该标的发行余额 × 100% > 30% +``` + +注释规则定义: + +```csharp +//rules.Add(new RiskRule +//{ +// Id = 1000001, +// RuleName = "挂钩标的集中度校验(本地)", +// RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易;分子查询 trade 表同一标的存续/审批中交易 StockEqvNotional 汇总;分母查询 underlying_manager.ExJson 中债券 IssueSize(亿)。计算逻辑:同一标的总名义本金 ÷ 发行量 × 100%,发行量乘 100000000 还原为元,结果大于 30% 时触发审批。", +// RuleExpr = "Convert.ToDecimal(DbContext.trade.Where(t => t.ValidState != \"InValid\" && t.UnderlyingId == DbContext.trade.First(x => x.id == TradeId).UnderlyingId && t.ParentTradeId == 0 && (ConsTrade.NeedMarginTradeStatusList.Contains(t.TradeStatus) || t.TradeStatus == \"审批中\")).Sum(t => (double?)t.StockEqvNotional) ?? 0d) / (JsonConvert.DeserializeObject(DbContext.underlying_manager.Where(u => u.UnderlyingCode == DbContext.trade.First(x => x.id == TradeId).UnderlyingCode).Select(u => u.ExJson).FirstOrDefault()).IssueSize.Value * 100000000m) * 100m > 30m", +// Version = 1, +// Status = RiskRuleStatus.Active, +// OptId = 0, +// OptName = "system", +// OptDate = DateTime.Now, +// UpdateOptId = 0, +// UpdateOptName = "system", +// UpdateDate = DateTime.Now +//}); +``` + +```sql +SET @TradeId = 3001699; + +SELECT + cur.id AS CurrentTradeId, + cur.UnderlyingId, + cur.UnderlyingCode, + + SUM(IFNULL(t.StockEqvNotional, 0)) AS TotalStockEqvNotional, + CAST(JSON_UNQUOTE(JSON_EXTRACT(um.ExJson, '$.IssueSize')) AS DECIMAL(30, 10)) AS IssueSizeYi, + CAST(JSON_UNQUOTE(JSON_EXTRACT(um.ExJson, '$.IssueSize')) AS DECIMAL(30, 10)) * 100000000 AS IssueSizeAmount, + + SUM(IFNULL(t.StockEqvNotional, 0)) + / (CAST(JSON_UNQUOTE(JSON_EXTRACT(um.ExJson, '$.IssueSize')) AS DECIMAL(30, 10)) * 100000000) + * 100 AS ConcentrationPercent, + CASE + WHEN SUM(IFNULL(t.StockEqvNotional, 0)) + / (CAST(JSON_UNQUOTE(JSON_EXTRACT(um.ExJson, '$.IssueSize')) AS DECIMAL(30, 10)) * 100000000) + * 100 > 30 THEN 1 + ELSE 0 + END AS IsGreaterThan30 +FROM trade cur +INNER JOIN trade t + ON t.UnderlyingId = cur.UnderlyingId + AND t.ValidState <> 'InValid' + AND t.ParentTradeId = 0 + AND t.TradeStatus IN ('确认成交', '平仓待复核', '提前终止拒绝', '行权待复核', '互换待复核', '审批中') +LEFT JOIN underlying_manager um + ON um.UnderlyingCode = cur.UnderlyingCode +WHERE cur.id = @TradeId +GROUP BY + cur.id, + cur.UnderlyingId, + cur.UnderlyingCode, + um.ExJson; +``` + +交易明细 SQL:用于核对分子中纳入汇总的交易明细;是否超限以汇总 SQL 的 `ConcentrationPercent` 为准。 + +```sql +SET @TradeId = 3001699; + +SELECT + cur.id AS CurrentTradeId, + cur.UnderlyingId, + cur.UnderlyingCode, + + t.id AS RelatedTradeId, + t.TradeStatus, + t.ValidState, + t.ParentTradeId, + t.StockEqvNotional +FROM trade cur +INNER JOIN trade t + ON t.UnderlyingId = cur.UnderlyingId + AND t.ValidState <> 'InValid' + AND t.ParentTradeId = 0 + AND t.TradeStatus IN ('确认成交', '平仓待复核', '提前终止拒绝', '行权待复核', '互换待复核', '审批中') +WHERE cur.id = @TradeId +ORDER BY + t.id; +``` + +--- + +## 13. 规则 2 查询结果排查 SQL + +规则 2:挂钩标的到期日小于合约到期日(本地)。用于核对当前交易的合约到期日是否晚于挂钩标的到期日。 + +取数流程: + +```text +1. 根据 TradeId 查当前 trade,取 UnderlyingCode、ExerciseDate。 +2. 根据 trade.UnderlyingCode 关联 underlying_manager。 +3. 从 underlying_manager 取 MaturityDate,作为挂钩标的到期日。 +4. 比较 underlying_manager.MaturityDate 和 trade.ExerciseDate。 +5. 当挂钩标的到期日 < 合约到期日时命中。 +``` + +规则公式: + +```text +挂钩标的到期日 < 合约到期日 +``` + +规则字段口径: + +```text +挂钩标的到期日:underlying_manager.MaturityDate +合约到期日:trade.ExerciseDate +``` + +注释规则定义: + +```csharp +//rules.Add(new RiskRule +//{ +// Id = 1000002, +// RuleName = "挂钩标的到期日小于合约到期日(本地)", +// RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易的 UnderlyingCode 和 ExerciseDate,ExerciseDate 对应合约到期日;通过 DbContext.underlying_manager 按 UnderlyingCode 取 MaturityDate,MaturityDate 对应挂钩标的到期日。计算逻辑:挂钩标的到期日小于合约到期日时触发禁止。", +// RuleExpr = "DbContext.underlying_manager.First(u => u.UnderlyingCode == DbContext.trade.First(t => t.id == TradeId).UnderlyingCode).MaturityDate.Value < DbContext.trade.First(t => t.id == TradeId).ExerciseDate.Value", +// 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.TradeType, + t.UnderlyingCode, + t.ExerciseDate AS ContractExerciseDate, + + um.id AS UnderlyingManagerId, + um.UnderlyingCode AS UnderlyingManagerCode, + um.UnderlyingName, + um.MaturityDate AS UnderlyingMaturityDate, + + CASE + WHEN um.MaturityDate < t.ExerciseDate THEN 1 + ELSE 0 + END AS IsUnderlyingMaturityLessThanContractExerciseDate +FROM trade t +LEFT JOIN underlying_manager um + ON um.UnderlyingCode = t.UnderlyingCode +WHERE t.id = @TradeId; +``` + +--- + +## 14. 规则 3 查询结果排查 SQL + +规则 3:名义本金超阈值(本地)。用于核对当前交易的开仓名义本金是否超过阈值。 + +取数流程: + +```text +1. 根据 TradeId 查当前 trade。 +2. 取 trade.OriginalStockEqvNotional,作为开仓名义本金。 +3. 当 OriginalStockEqvNotional > 100000000 时命中。 +``` + +规则公式: + +```text +开仓名义本金 > 100000000 +``` + +规则字段口径: + +```text +开仓名义本金:trade.OriginalStockEqvNotional +``` + +注释规则定义: + +```csharp +//rules.Add(new RiskRule +//{ +// Id = 1000003, +// RuleName = "名义本金超阈值(本地)", +// RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易的 OriginalStockEqvNotional,对应 trade 表开仓名义本金字段。计算逻辑:OriginalStockEqvNotional 大于 100000000 时触发审批。", +// RuleExpr = "DbContext.trade.First(t => t.id == TradeId).OriginalStockEqvNotional > 100000000", +// 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.TradeType, + t.OriginalStockEqvNotional, + CASE + WHEN t.OriginalStockEqvNotional > 100000000 THEN 1 + ELSE 0 + END AS IsOriginalStockEqvNotionalGreaterThan100Million +FROM trade t +WHERE t.id = @TradeId; +``` + +--- + +## 15. 规则 4 查询结果排查 SQL + +规则 4:保证金支付比例超阈值(本地)。用于核对当前交易预付金区域中支付方向的保证金金额占开仓名义本金的比例是否超过阈值。 + +取数流程: + +```text +1. 根据 TradeId 查当前 trade,取 OriginalStockEqvNotional,作为开仓名义本金。 +2. 查询 swap_position 中当前交易的预付金行。 +3. 预付金行口径:SwapTradeId = TradeId、IsInitial = 1、Invalid = 0、InterestMode in (5, 6)。 +4. 支付方向口径:InterestDirection = 2。 +5. 汇总 InterestPrincipalFix,作为保证金支付金额。 +6. 计算 保证金支付金额 ÷ 开仓名义本金,大于 0.5 时命中。 +``` + +规则公式: + +```text +保证金支付金额 ÷ 开仓名义本金 > 50% +``` + +规则字段口径: + +```text +保证金金额:swap_position.InterestPrincipalFix +保证金方向:swap_position.InterestDirection,1=收取,2=支付 +保证金类型:swap_position.InterestMode,5=初始预付金,6=追加预付金 +开仓名义本金:trade.OriginalStockEqvNotional +``` + +注释规则定义: + +```csharp +//rules.Add(new RiskRule +//{ +// Id = 1000004, +// RuleName = "保证金支付比例超阈值(本地)", +// RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易的 OriginalStockEqvNotional,作为开仓名义本金;通过 DbContext.swap_position 按 TradeId 取 IsInitial=true、Invalid=false、InterestMode 为 5 或 6 且 InterestDirection=2 的预付金支付方向记录,汇总 InterestPrincipalFix 作为保证金支付金额。计算逻辑:保证金支付金额 ÷ 开仓名义本金 大于 50% 时触发审批。", +// RuleExpr = "DbContext.swap_position.Where(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && (p.InterestMode == 5 || p.InterestMode == 6) && p.InterestDirection == 2).Sum(p => (decimal?)p.InterestPrincipalFix).Value / Convert.ToDecimal(DbContext.trade.First(t => t.id == TradeId).OriginalStockEqvNotional) > 0.5m", +// 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 + t.id AS TradeId, + t.OriginalStockEqvNotional, + + SUM(IFNULL(sp.InterestPrincipalFix, 0)) AS PayMarginAmount, + SUM(IFNULL(sp.InterestPrincipalFix, 0)) / t.OriginalStockEqvNotional AS PayMarginRate, + CASE + WHEN SUM(IFNULL(sp.InterestPrincipalFix, 0)) / t.OriginalStockEqvNotional > 0.5 THEN 1 + ELSE 0 + END AS IsGreaterThan50Percent +FROM trade t +LEFT JOIN swap_position sp + ON sp.SwapTradeId = t.id + AND sp.IsInitial = 1 + AND sp.Invalid = 0 + AND sp.InterestMode IN (5, 6) + AND sp.InterestDirection = 2 +WHERE t.id = @TradeId +GROUP BY + t.id, + t.OriginalStockEqvNotional; +``` + +明细 SQL:用于查看纳入保证金支付金额汇总的预付金明细;是否超限以汇总 SQL 的 `PayMarginRate` 为准。 + +```sql +SET @TradeId = 3001699; + +SELECT + sp.id AS SwapPositionId, + sp.SwapTradeId, + sp.IsInitial, + sp.Invalid, + sp.InterestMode, + sp.InterestDirection, + sp.InterestPrincipalFix, + sp.InterestRateDefault, + sp.HappenDate +FROM swap_position sp +WHERE sp.SwapTradeId = @TradeId + AND sp.IsInitial = 1 + AND sp.Invalid = 0 + AND sp.InterestMode IN (5, 6) + AND sp.InterestDirection = 2 +ORDER BY + sp.HappenDate, + sp.id; +``` + +--- + +## 16. 规则 5 查询结果排查 SQL + +规则 5:保证金利率偏离(本地)。用于核对当前交易预付金区域中的预付金返息率是否偏离基准值。 + +取数流程: + +```text +1. 查询 swap_position 中当前交易的预付金行。 +2. 预付金行口径:SwapTradeId = TradeId、IsInitial = 1、Invalid = 0、InterestMode in (5, 6)。 +3. 取 swap_position.InterestRateDefault,作为预付金返息率数据库原值。 +4. 数据库保存的是原值,公式计算时先和 1 比较,再乘以 100 还原为百分比偏离值。 +5. 计算 ABS((InterestRateDefault - 1) * 100),大于阈值时命中。 +``` + +规则公式: + +```text +ABS((预付金返息率 - 1) * 100) > 阈值 +``` + +规则字段口径: + +```text +预付金返息率:swap_position.InterestRateDefault +保证金类型:swap_position.InterestMode,5=初始预付金,6=追加预付金 +``` + +注释规则定义: + +```csharp +//rules.Add(new RiskRule +//{ +// Id = 1000005, +// RuleName = "保证金利率偏离(本地)", +// RuleText = "取值字段:通过 DbContext.swap_position 按 TradeId 取 IsInitial=true、Invalid=false、InterestMode 为 5 或 6 的预付金记录,取 InterestRateDefault 作为预付金返息率数据库原值。计算逻辑:数据库保存的是原值,公式中先和 1 比较,再乘以 100 还原为百分比偏离值,即 ABS((预付金返息率 - 1) * 100),结果大于阈值时触发审批。", +// RuleExpr = "Math.Abs((Convert.ToDecimal(DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && (p.InterestMode == 5 || p.InterestMode == 6)).InterestRateDefault) - 1m) * 100m) > 阈值", +// Version = 1, +// Status = RiskRuleStatus.Active, +// OptId = 0, +// OptName = "system", +// OptDate = DateTime.Now, +// UpdateOptId = 0, +// UpdateOptName = "system", +// UpdateDate = DateTime.Now +//}); +``` + +汇总 SQL:用于查看当前交易预付金返息率偏离情况;执行前将 `@Threshold` 替换为实际阈值。 + +```sql +SET @TradeId = 3001699; +SET @Threshold = 0.5; + +SELECT + sp.SwapTradeId AS TradeId, + MAX(ABS((IFNULL(sp.InterestRateDefault, 0) - 1) * 100)) AS MaxInterestRateDeviation, + CASE + WHEN MAX(ABS((IFNULL(sp.InterestRateDefault, 0) - 1) * 100)) > @Threshold THEN 1 + ELSE 0 + END AS IsInterestRateDeviationGreaterThanThreshold +FROM swap_position sp +WHERE sp.SwapTradeId = @TradeId + AND sp.IsInitial = 1 + AND sp.Invalid = 0 + AND sp.InterestMode IN (5, 6) +GROUP BY + sp.SwapTradeId; +``` + +明细 SQL:用于查看每条预付金记录的返息率原值、乘以 100 后的页面口径及偏离值。 + +```sql +SET @TradeId = 3001699; +SET @Threshold = 0.5; + +SELECT + sp.id AS SwapPositionId, + sp.SwapTradeId, + sp.IsInitial, + sp.Invalid, + sp.InterestMode, + sp.InterestDirection, + sp.InterestPrincipalFix, + sp.InterestRateDefault AS InterestRateRawValue, + sp.InterestRateDefault * 100 AS InterestRatePercentValue, + ABS((IFNULL(sp.InterestRateDefault, 0) - 1) * 100) AS InterestRateDeviation, + CASE + WHEN ABS((IFNULL(sp.InterestRateDefault, 0) - 1) * 100) > @Threshold THEN 1 + ELSE 0 + END AS IsGreaterThanThreshold, + sp.HappenDate +FROM swap_position sp +WHERE sp.SwapTradeId = @TradeId + AND sp.IsInitial = 1 + AND sp.Invalid = 0 + AND sp.InterestMode IN (5, 6) +ORDER BY + sp.HappenDate, + sp.id; +``` + +--- + +## 17. 规则 6 查询结果排查 SQL + +规则 6:保证金收取比例低于最低标准(本地)。用于核对当前交易预付金区域中收取方向的保证金金额占开仓名义本金的比例是否低于最低标准。 + +取数流程: + +```text +1. 根据 TradeId 查当前 trade,取 OriginalStockEqvNotional,作为开仓名义本金。 +2. 查询 swap_position 中当前交易的预付金行。 +3. 预付金行口径:SwapTradeId = TradeId、IsInitial = 1、Invalid = 0、InterestMode in (5, 6)。 +4. 收取方向口径:InterestDirection = 1。 +5. 汇总 InterestPrincipalFix,作为保证金收取金额。 +6. 计算 保证金收取金额 ÷ 开仓名义本金,小于 0.2 时命中。 +``` + +规则公式: + +```text +保证金收取金额 ÷ 开仓名义本金 < 20% +``` + +规则字段口径: + +```text +保证金金额:swap_position.InterestPrincipalFix +保证金方向:swap_position.InterestDirection,1=收取,2=支付 +保证金类型:swap_position.InterestMode,5=初始预付金,6=追加预付金 +开仓名义本金:trade.OriginalStockEqvNotional +``` + +注释规则定义: + +```csharp +//rules.Add(new RiskRule +//{ +// Id = 1000006, +// RuleName = "保证金收取比例低于最低标准(本地)", +// RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易的 OriginalStockEqvNotional,作为开仓名义本金;通过 DbContext.swap_position 按 TradeId 取 IsInitial=true、Invalid=false、InterestMode 为 5 或 6 且 InterestDirection=1 的预付金收取方向记录,汇总 InterestPrincipalFix 作为保证金收取金额。计算逻辑:保证金收取金额 ÷ 开仓名义本金 小于 20% 时触发审批。", +// RuleExpr = "DbContext.swap_position.Where(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && (p.InterestMode == 5 || p.InterestMode == 6) && p.InterestDirection == 1).Sum(p => (decimal?)p.InterestPrincipalFix).Value / Convert.ToDecimal(DbContext.trade.First(t => t.id == TradeId).OriginalStockEqvNotional) < 0.2m", +// 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 + t.id AS TradeId, + t.OriginalStockEqvNotional, + + SUM(IFNULL(sp.InterestPrincipalFix, 0)) AS ReceiveMarginAmount, + SUM(IFNULL(sp.InterestPrincipalFix, 0)) / t.OriginalStockEqvNotional AS ReceiveMarginRate, + CASE + WHEN SUM(IFNULL(sp.InterestPrincipalFix, 0)) / t.OriginalStockEqvNotional < 0.2 THEN 1 + ELSE 0 + END AS IsLessThan20Percent +FROM trade t +LEFT JOIN swap_position sp + ON sp.SwapTradeId = t.id + AND sp.IsInitial = 1 + AND sp.Invalid = 0 + AND sp.InterestMode IN (5, 6) + AND sp.InterestDirection = 1 +WHERE t.id = @TradeId +GROUP BY + t.id, + t.OriginalStockEqvNotional; +``` + +明细 SQL:用于查看纳入保证金收取金额汇总的预付金明细;是否低于最低标准以汇总 SQL 的 `ReceiveMarginRate` 为准。 + +```sql +SET @TradeId = 3001699; + +SELECT + sp.id AS SwapPositionId, + sp.SwapTradeId, + sp.IsInitial, + sp.Invalid, + sp.InterestMode, + sp.InterestDirection, + sp.InterestPrincipalFix, + sp.InterestRateDefault, + sp.HappenDate +FROM swap_position sp +WHERE sp.SwapTradeId = @TradeId + AND sp.IsInitial = 1 + AND sp.Invalid = 0 + AND sp.InterestMode IN (5, 6) + AND sp.InterestDirection = 1 +ORDER BY + sp.HappenDate, + sp.id; +``` + +--- + +## 18. 规则 7 查询结果排查 SQL + +规则 7:起息日早于当前日期(本地)。用于核对互换交易界面的开始日期是否早于当前日期。 + +取数流程: + +```text +1. 根据 TradeId 查当前 trade。 +2. 取 trade.StartDate,作为互换交易界面的开始日期,即起息日。 +3. 取系统当前日期 DateTime.Today。 +4. 当 StartDate 有值且 StartDate.Date < DateTime.Today 时命中。 +``` + +规则公式: + +```text +起息日 < 当前日期 +``` + +规则字段口径: + +```text +起息日:trade.StartDate,对应互换交易界面的开始日期 +当前日期:DateTime.Today +``` + +注释规则定义: + +```csharp +//rules.Add(new RiskRule +//{ +// Id = 1000007, +// RuleName = "起息日早于当前日期(本地)", +// RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易的 StartDate,StartDate 对应互换交易界面的开始日期,即起息日。计算逻辑:StartDate 有值且日期早于系统当天 DateTime.Today 时触发审批。", +// RuleExpr = "DbContext.trade.First(t => t.id == TradeId).StartDate.HasValue && DbContext.trade.First(t => t.id == TradeId).StartDate.Value.Date < DateTime.Today", +// 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.TradeType, + t.TradeDate, + t.StartDate AS ValueDate, + CURDATE() AS CurrentDate, + CASE + WHEN t.StartDate IS NOT NULL AND DATE(t.StartDate) < CURDATE() THEN 1 + ELSE 0 + END AS IsStartDateLessThanCurrentDate +FROM trade t +WHERE t.id = @TradeId; +``` + +--- + +## 19. 规则 8 查询结果排查 SQL + +规则 8:支付日为银行间交易日(本地)。用于核对交易平仓窗口的支付日期是否为银行间交易日;若支付日期未落在 IB 日历的非交易日列表中,则命中。 + +取数流程: + +```text +1. 根据 TradeId 查 swap_flow_event。 +2. 限定 SwapTradeId = TradeId、EventType = 2、DataState <> 0,取平仓事件。 +3. 取 swap_flow_event.PayDate,作为交易平仓窗口的支付日期。 +4. 根据 PayDate.Value.Year 查询 calendar 表中 Country = 'IB' 的银行间日历。 +5. calendar.HolidayJson 存储该年非交易日,格式为 yyyy,MM,dd。 +6. 若 HolidayJson 不包含支付日期对应的 yyyy,MM,dd,则说明支付日期是银行间交易日,命中规则。 +``` + +规则公式: + +```text +支付日期 ∉ IB 日历非交易日 +``` + +规则字段口径: + +```text +支付日期:swap_flow_event.PayDate,对应交易平仓窗口的支付日期 +平仓事件:swap_flow_event.EventType = 2 +银行间日历:calendar.Country = 'IB' +日历年份:calendar.Year = swap_flow_event.PayDate.Year +非交易日:calendar.HolidayJson +``` + +参考页面字段: + +```cshtml + + +``` + +注释规则定义: + +```csharp +//rules.Add(new RiskRule +//{ +// Id = 1000008, +// RuleName = "支付日为银行间交易日(本地)", +// RuleText = "取值字段:通过 DbContext.swap_flow_event 按 SwapTradeId=TradeId、EventType=2、DataState<>0 取平仓事件的 PayDate,PayDate 对应交易平仓窗口的支付日期;通过 DbContext.calendar 按 Country='IB' 且 Year=PayDate.Year 取银行间日历,HolidayJson 存储该年非交易日。计算逻辑:若支付日期对应的 yyyy,MM,dd 不存在于 IB 日历 HolidayJson 中,则说明支付日期是银行间交易日,触发审批。", +// RuleExpr = "DbContext.swap_flow_event.Any(e => e.SwapTradeId == TradeId && e.EventType == 2 && e.DataState != 0 && e.PayDate.HasValue) && !DbContext.calendar.First(c => c.Country == \"IB\" && c.Year == DbContext.swap_flow_event.Where(e => e.SwapTradeId == TradeId && e.EventType == 2 && e.DataState != 0 && e.PayDate.HasValue).OrderByDescending(e => e.id).First().PayDate.Value.Year && c.ValidState != \"InValid\").HolidayJson.Contains(DbContext.swap_flow_event.Where(e => e.SwapTradeId == TradeId && e.EventType == 2 && e.DataState != 0 && e.PayDate.HasValue).OrderByDescending(e => e.id).First().PayDate.Value.ToString(\"yyyy,MM,dd\"))", +// 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, + sfe.id AS SwapFlowEventId, + sfe.EventType, + sfe.DataState, + sfe.PayDate, + c.Country AS CalendarCountry, + c.Year AS CalendarYear, + DATE_FORMAT(sfe.PayDate, '%Y,%m,%d') AS PayDateText, + CASE + WHEN sfe.PayDate IS NOT NULL + AND c.HolidayJson NOT LIKE CONCAT('%', DATE_FORMAT(sfe.PayDate, '%Y,%m,%d'), '%') THEN 1 + ELSE 0 + END AS IsPayDateIBTradingDay +FROM trade t +LEFT JOIN ( + SELECT * + FROM swap_flow_event + WHERE SwapTradeId = @TradeId + AND EventType = 2 + AND DataState <> 0 + ORDER BY id DESC + LIMIT 1 +) sfe ON sfe.SwapTradeId = t.id +LEFT JOIN calendar c + ON c.Country = 'IB' + AND c.Year = YEAR(sfe.PayDate) + AND c.ValidState <> 'InValid' +WHERE t.id = @TradeId; +``` + +--- + +## 20. 规则 9 查询结果排查 SQL + +规则 9:到期日为银行间交易日(本地)。用于核对互换交易界面的到期日是否为银行间交易日;若到期日未落在 IB 日历的非交易日列表中,则命中。 + +取数流程: + +```text +1. 根据 TradeId 查当前 trade。 +2. 取 trade.ExerciseDate,作为互换交易界面的到期日。 +3. 根据 ExerciseDate.Value.Year 查询 calendar 表中 Country = 'IB' 的银行间日历。 +4. calendar.HolidayJson 存储该年非交易日,格式为 yyyy,MM,dd。 +5. 若 HolidayJson 不包含到期日对应的 yyyy,MM,dd,则说明到期日是银行间交易日,命中规则。 +``` + +规则公式: + +```text +到期日 ∉ IB 日历非交易日 +``` + +规则字段口径: + +```text +到期日:trade.ExerciseDate,对应互换交易界面的到期日 +银行间日历:calendar.Country = 'IB' +日历年份:calendar.Year = trade.ExerciseDate.Year +非交易日:calendar.HolidayJson +``` + +注释规则定义: + +```csharp +//rules.Add(new RiskRule +//{ +// Id = 1000009, +// RuleName = "到期日为银行间交易日(本地)", +// RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易的 ExerciseDate,ExerciseDate 对应互换交易界面的到期日;通过 DbContext.calendar 按 Country='IB' 且 Year=ExerciseDate.Year 取银行间日历,HolidayJson 存储该年非交易日。计算逻辑:若到期日对应的 yyyy,MM,dd 不存在于 IB 日历 HolidayJson 中,则说明到期日是银行间交易日,触发审批。", +// RuleExpr = "DbContext.trade.First(t => t.id == TradeId).ExerciseDate.HasValue && !DbContext.calendar.First(c => c.Country == \"IB\" && c.Year == DbContext.trade.First(t => t.id == TradeId).ExerciseDate.Value.Year && c.ValidState != \"InValid\").HolidayJson.Contains(DbContext.trade.First(t => t.id == TradeId).ExerciseDate.Value.ToString(\"yyyy,MM,dd\"))", +// 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.TradeType, + t.ExerciseDate AS MaturityDate, + c.Country AS CalendarCountry, + c.Year AS CalendarYear, + DATE_FORMAT(t.ExerciseDate, '%Y,%m,%d') AS MaturityDateText, + CASE + WHEN t.ExerciseDate IS NOT NULL + AND c.HolidayJson NOT LIKE CONCAT('%', DATE_FORMAT(t.ExerciseDate, '%Y,%m,%d'), '%') THEN 1 + ELSE 0 + END AS IsMaturityDateIBTradingDay +FROM trade t +LEFT JOIN calendar c + ON c.Country = 'IB' + AND c.Year = YEAR(t.ExerciseDate) + AND c.ValidState <> 'InValid' +WHERE t.id = @TradeId; +``` + +--- + +## 21. 规则 10 查询结果排查 SQL + +规则 10:平仓日为银行间交易日(本地)。用于核对交易平仓窗口的平仓日期是否为银行间交易日;若平仓日期未落在 IB 日历的非交易日列表中,则命中。 + +取数流程: + +```text +1. 根据 TradeId 查 swap_flow_event。 +2. 限定 SwapTradeId = TradeId、EventType = 2、DataState <> 0,取平仓事件。 +3. 取 swap_flow_event.UnwindDate,作为交易平仓窗口的平仓日期。 +4. 根据 UnwindDate.Value.Year 查询 calendar 表中 Country = 'IB' 的银行间日历。 +5. calendar.HolidayJson 存储该年非交易日,格式为 yyyy,MM,dd。 +6. 若 HolidayJson 不包含平仓日期对应的 yyyy,MM,dd,则说明平仓日期是银行间交易日,命中规则。 +``` + +规则公式: + +```text +平仓日期 ∉ IB 日历非交易日 +``` + +规则字段口径: + +```text +平仓日期:swap_flow_event.UnwindDate,对应交易平仓窗口的平仓日期 +平仓事件:swap_flow_event.EventType = 2 +银行间日历:calendar.Country = 'IB' +日历年份:calendar.Year = swap_flow_event.UnwindDate.Year +非交易日:calendar.HolidayJson +``` + +参考页面字段: + +```cshtml + + +``` + +注释规则定义: + +```csharp +//rules.Add(new RiskRule +//{ +// Id = 1000010, +// RuleName = "平仓日为银行间交易日(本地)", +// RuleText = "取值字段:通过 DbContext.swap_flow_event 按 SwapTradeId=TradeId、EventType=2、DataState<>0 取平仓事件的 UnwindDate,UnwindDate 对应交易平仓窗口的平仓日期;通过 DbContext.calendar 按 Country='IB' 且 Year=UnwindDate.Year 取银行间日历,HolidayJson 存储该年非交易日。计算逻辑:若平仓日期对应的 yyyy,MM,dd 不存在于 IB 日历 HolidayJson 中,则说明平仓日期是银行间交易日,触发审批。", +// RuleExpr = "DbContext.swap_flow_event.Any(e => e.SwapTradeId == TradeId && e.EventType == 2 && e.DataState != 0 && e.UnwindDate.HasValue) && !DbContext.calendar.First(c => c.Country == \"IB\" && c.Year == DbContext.swap_flow_event.Where(e => e.SwapTradeId == TradeId && e.EventType == 2 && e.DataState != 0 && e.UnwindDate.HasValue).OrderByDescending(e => e.id).First().UnwindDate.Value.Year && c.ValidState != \"InValid\").HolidayJson.Contains(DbContext.swap_flow_event.Where(e => e.SwapTradeId == TradeId && e.EventType == 2 && e.DataState != 0 && e.UnwindDate.HasValue).OrderByDescending(e => e.id).First().UnwindDate.Value.ToString(\"yyyy,MM,dd\"))", +// 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, + sfe.id AS SwapFlowEventId, + sfe.EventType, + sfe.DataState, + sfe.UnwindDate, + c.Country AS CalendarCountry, + c.Year AS CalendarYear, + DATE_FORMAT(sfe.UnwindDate, '%Y,%m,%d') AS UnwindDateText, + CASE + WHEN sfe.UnwindDate IS NOT NULL + AND c.HolidayJson NOT LIKE CONCAT('%', DATE_FORMAT(sfe.UnwindDate, '%Y,%m,%d'), '%') THEN 1 + ELSE 0 + END AS IsUnwindDateIBTradingDay +FROM trade t +LEFT JOIN ( + SELECT * + FROM swap_flow_event + WHERE SwapTradeId = @TradeId + AND EventType = 2 + AND DataState <> 0 + ORDER BY id DESC + LIMIT 1 +) sfe ON sfe.SwapTradeId = t.id +LEFT JOIN calendar c + ON c.Country = 'IB' + AND c.Year = YEAR(sfe.UnwindDate) + AND c.ValidState <> 'InValid' +WHERE t.id = @TradeId; +``` + +--- + +## 22. 规则 11 查询结果排查 SQL + +规则 11:合约期限超阈值(本地)。用于核对互换交易合约期限;合约期限按起始日与到期日之间的自然日天数计算,并根据计息方式决定是否算头、算尾,超过阈值时命中。 + +取数流程: + +```text +1. 根据 TradeId 查 trade 表,取 StartDate 作为起始日,取 ExerciseDate 作为到期日。 +2. 根据 TradeId 查 trade_extend 表,取 ExtendJson 中的 InterestCalcMode 作为计息方式。 +3. InterestCalcMode 口径:00=不算头不算尾,01=不算头算尾,10=算头不算尾,11=算头算尾,缺省值=11。 +4. 先计算自然日基础天数:(到期日.Date - 起始日.Date).Days。 +5. 基础天数按“不算头算尾”口径理解;若算头则 +1,若不算尾则 -1。 +6. 计算结果大于阈值时命中。 +``` + +规则公式: + +```text +(到期日.Date - 起始日.Date).Days + 是否算头 - 是否不算尾 > 阈值 +``` + +规则字段口径: + +```text +起始日:trade.StartDate,对应互换交易界面的开始日期 +到期日:trade.ExerciseDate,对应互换交易界面的到期日期 +计息方式:trade_extend.ExtendJson.InterestCalcMode +计息方式枚举:00=不算头不算尾,01=不算头算尾,10=算头不算尾,11=算头算尾 +``` + +注释规则定义: + +```csharp +//rules.Add(new RiskRule +//{ +// Id = 1000011, +// RuleName = "合约期限超阈值(本地)", +// RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易的 StartDate 和 ExerciseDate;通过 DbContext.trade_extend 按 TradeId 取 ExtendJson 中的 InterestCalcMode,InterestCalcMode 口径为 00=不算头不算尾、01=不算头算尾、10=算头不算尾、11=算头算尾。计算逻辑:先计算 (ExerciseDate.Date-StartDate.Date).Days 作为自然日基础天数,基础天数按不算头算尾口径理解;若 InterestCalcMode 首位为 1 则算头加 1,若末位为 0 则不算尾减 1,最终合约期限大于阈值时触发审批。", +// RuleExpr = "DbContext.trade.First(t => t.id == TradeId).StartDate.HasValue && DbContext.trade.First(t => t.id == TradeId).ExerciseDate.HasValue && ((DbContext.trade.First(t => t.id == TradeId).ExerciseDate.Value.Date - DbContext.trade.First(t => t.id == TradeId).StartDate.Value.Date).Days + (DbContext.trade_extend.First(e => e.TradeId == TradeId).ExtendObj.InterestCalcMode.StartsWith(\"1\") ? 1 : 0) - (DbContext.trade_extend.First(e => e.TradeId == TradeId).ExtendObj.InterestCalcMode.EndsWith(\"1\") ? 0 : 1)) > 阈值", +// Version = 1, +// Status = RiskRuleStatus.Active, +// OptId = 0, +// OptName = "system", +// OptDate = DateTime.Now, +// UpdateOptId = 0, +// UpdateOptName = "system", +// UpdateDate = DateTime.Now +//}); +``` + +```sql +SET @TradeId = 3001699; +SET @Threshold = 365; + +SELECT + t.id AS TradeId, + t.StartDate, + t.ExerciseDate, + COALESCE(JSON_UNQUOTE(JSON_EXTRACT(te.ExtendJson, '$.InterestCalcMode')), '11') AS InterestCalcMode, + DATEDIFF(DATE(t.ExerciseDate), DATE(t.StartDate)) AS BaseNaturalDays, + CASE + WHEN LEFT(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(te.ExtendJson, '$.InterestCalcMode')), '11'), 1) = '1' THEN 1 + ELSE 0 + END AS CalcFirstDays, + CASE + WHEN RIGHT(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(te.ExtendJson, '$.InterestCalcMode')), '11'), 1) = '1' THEN 0 + ELSE -1 + END AS NotCalcLastDays, + DATEDIFF(DATE(t.ExerciseDate), DATE(t.StartDate)) + + CASE + WHEN LEFT(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(te.ExtendJson, '$.InterestCalcMode')), '11'), 1) = '1' THEN 1 + ELSE 0 + END + + CASE + WHEN RIGHT(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(te.ExtendJson, '$.InterestCalcMode')), '11'), 1) = '1' THEN 0 + ELSE -1 + END AS ContractNaturalDays, + CASE + WHEN DATEDIFF(DATE(t.ExerciseDate), DATE(t.StartDate)) + + CASE + WHEN LEFT(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(te.ExtendJson, '$.InterestCalcMode')), '11'), 1) = '1' THEN 1 + ELSE 0 + END + + CASE + WHEN RIGHT(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(te.ExtendJson, '$.InterestCalcMode')), '11'), 1) = '1' THEN 0 + ELSE -1 + END > @Threshold THEN 1 + ELSE 0 + END AS IsContractTermGreaterThanThreshold +FROM trade t +LEFT JOIN trade_extend te + ON te.TradeId = t.id +WHERE t.id = @TradeId; +``` + +--- + +## 23. 规则 12 查询结果排查 SQL 规则 12:债券类净价偏离(本地)。用于核对浮动支付端期初交割净价和交易日前中债估值净价;同一标的同一估值日存在不同可信度时,优先取 credibility=1。 @@ -1403,7 +2383,7 @@ LIMIT 1; --- -## 13. 规则 13 查询结果排查 SQL +## 24. 规则 13 查询结果排查 SQL 规则 13:债券类收益率偏离(本地)。用于核对浮动支付端期初成交收益率和交易日前中债估值收益率;同一标的同一估值日存在不同可信度时,优先取 credibility=1。 @@ -1493,7 +2473,7 @@ LIMIT 1; --- -## 14. 规则 14 查询结果排查 SQL +## 22. 规则 14 查询结果排查 SQL 规则 14:非债券类价格偏离(本地)。用于核对普通收益互换页面填写的期初标的价格和交易日前上一日收盘价。 @@ -1581,7 +2561,7 @@ LIMIT 1; --- -## 15. 规则 15 查询结果排查 SQL +## 23. 规则 15 查询结果排查 SQL 规则 15:单一交易对手累计标的数量超阈值(本地)。用于核对同一交易对手实时存续持仓下的去重标的数量是否超过 10 个。 @@ -1690,7 +2670,7 @@ ORDER BY --- -## 16. 规则 16 查询结果排查 SQL +## 24. 规则 16 查询结果排查 SQL 规则 16:多头支付固定端利率偏离(本地)。用于核对利息端收入固定利息方向的点差百分比绝对值是否低于阈值。 @@ -1752,3 +2732,94 @@ FROM swap_position sp WHERE sp.SwapTradeId = @TradeId AND sp.InterestDirection = 1; ``` + +--- + +## 28. 规则 18 查询结果排查 SQL + +规则 18:账户授权收支方向不匹配(本地)。用于核对账户授权的收支方向与交易浮动端多空方向是否匹配;不符合允许场景时命中。 + +校验内容: + +```text +不符合以下场景则不通过: +1. 利息端 = 收取,且浮动端 = 空头 +2. 利息端 = 支付,且浮动端 = 多头 +``` + +取数流程: + +```text +1. 根据 TradeId 查 swap_position。 +2. 限定 IsInitial=1、Invalid=0、PosiDirection>0,取浮动端初始持仓记录。 +3. 取 swap_position.InterestDirection 作为利息端收支方向,1=收取,2=支付。 +4. 取 swap_position.PositionType 作为浮动端多空方向,1=多头,2=空头。 +5. 若不是“利息端收取且浮动端空头”,也不是“利息端支付且浮动端多头”,则命中。 +``` + +规则公式: + +```text +NOT ((InterestDirection = 1 AND PositionType = 2) OR (InterestDirection = 2 AND PositionType = 1)) +``` + +规则字段口径: + +```text +利息端收支方向:swap_position.InterestDirection,1=收取,2=支付 +浮动端多空方向:swap_position.PositionType,1=多头,2=空头 +浮动端记录:swap_position.IsInitial=1、Invalid=0、PosiDirection>0 +``` + +注释规则定义: + +```csharp +//rules.Add(new RiskRule +//{ +// Id = 1000018, +// RuleName = "账户授权收支方向不匹配(本地)", +// RuleText = "取值字段:通过 DbContext.swap_position 按 TradeId 取 IsInitial=true、Invalid=false、PosiDirection>0 的浮动端初始持仓记录;InterestDirection 表示利息端收支方向,1=收取、2=支付;PositionType 表示浮动端多空方向,1=多头、2=空头。计算逻辑:仅允许利息端=收取且浮动端=空头,或利息端=支付且浮动端=多头;其他组合触发审批。", +// RuleExpr = "!(((DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection > 0).InterestDirection == 1) && (DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection > 0).PositionType == 2)) || ((DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection > 0).InterestDirection == 2) && (DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection > 0).PositionType == 1)))", +// 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, + CASE sp.InterestDirection + WHEN 1 THEN '收取' + WHEN 2 THEN '支付' + ELSE '未知' + END AS InterestDirectionText, + sp.PositionType, + CASE sp.PositionType + WHEN 1 THEN '多头' + WHEN 2 THEN '空头' + ELSE '未知' + END AS PositionTypeText, + CASE + WHEN (sp.InterestDirection = 1 AND sp.PositionType = 2) + OR (sp.InterestDirection = 2 AND sp.PositionType = 1) THEN 0 + ELSE 1 + END AS IsDirectionMismatch +FROM swap_position sp +WHERE sp.SwapTradeId = @TradeId + AND sp.IsInitial = 1 + AND sp.Invalid = 0 + AND sp.PosiDirection > 0; +``` From 2977f658f7c3f5d2b5a516d998ed70107f7cb95f Mon Sep 17 00:00:00 2001 From: ruisu Date: Mon, 27 Jul 2026 15:01:51 +0800 Subject: [PATCH 5/8] =?UTF-8?q?feat:=E6=94=AF=E6=8C=81=E5=8F=98=E9=87=8F?= =?UTF-8?q?=E4=B8=BA=E5=A4=8D=E6=9D=82=E8=A1=A8=E8=BE=BE=E5=BC=8F=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RiskEngine/Compile/RuleCompiler.cs | 360 +++++++++++++- .../Modules/RiskEngine/RiskEngineService.cs | 167 +++++-- .../Modules/RiskEngine/RiskRuleService.cs | 116 +++-- .../RiskEngine/StructuredRuleExecutor.cs | 461 ++++++++++++++++++ YLErpDAL/Modules/RiskEngine/测试用例.md | 28 ++ 5 files changed, 1030 insertions(+), 102 deletions(-) create mode 100644 YLErpDAL/Modules/RiskEngine/StructuredRuleExecutor.cs diff --git a/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs b/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs index e9797395..5d7be9d3 100644 --- a/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs +++ b/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs @@ -1,4 +1,7 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Scripting; +using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Scripting; using Newtonsoft.Json; using System; @@ -46,7 +49,119 @@ namespace YLErp.Modules.RiskEngine /// public static class RuleCompiler { - static IYcLogger _logger = LogFactory.GetLogger("RuleCompiler"); + private static IYcLogger _logger = LogFactory.GetLogger("RuleCompiler"); + + /// + /// 高风险方法黑名单。 + /// 这里按方法名做语法层拦截,覆盖数据库写入、原生 SQL、反射、文件、进程、服务定位等入口。 + /// + private static readonly HashSet ForbiddenInvocationNames = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "SaveChanges", + "SaveChangesAsync", + "Add", + "AddAsync", + "AddRange", + "AddRangeAsync", + "AddOrUpdate", + "Update", + "UpdateRange", + "Remove", + "RemoveRange", + "Attach", + "AttachRange", + "Set", + "Entry", + "ExecuteSqlRaw", + "ExecuteSqlRawAsync", + "ExecuteSqlInterpolated", + "ExecuteSqlInterpolatedAsync", + "ExecuteSql", + "ExecuteSqlAsync", + "ExecuteSqlCommand", + "ExecuteSqlCommandAsync", + "ExecuteStoreCommand", + "ExecuteStoreQuery", + "ExecuteDelete", + "ExecuteDeleteAsync", + "ExecuteUpdate", + "ExecuteUpdateAsync", + "FromSqlRaw", + "FromSqlRawAsync", + "FromSqlInterpolated", + "FromSqlInterpolatedAsync", + "BulkInsert", + "BulkUpdate", + "BulkDelete", + "BulkMerge", + "GetType", + "Invoke", + "InvokeMember", + "GetMethod", + "GetProperty", + "GetField", + "CreateInstance", + "Load", + "LoadFrom", + "ReadAllText", + "ReadAllLines", + "ReadAllBytes", + "WriteAllText", + "WriteAllLines", + "WriteAllBytes", + "AppendAllText", + "Delete", + "Move", + "Copy", + "Open", + "OpenRead", + "OpenWrite", + "Create", + "CreateText", + "Start", + "Kill", + "Exit", + "GetEnvironmentVariable", + "SetEnvironmentVariable", + "GetService", + "GetRequiredService", + "CreateScope", + "Sleep" + }; + + /// + /// 高风险成员黑名单。 + /// DbContext.Database、ChangeTracker 等成员会绕过只读查询约束或暴露底层状态,禁止脚本访问。 + /// + private static readonly HashSet ForbiddenMemberNames = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "Database", + "ChangeTracker", + "Assembly", + "AppDomain" + }; + + /// + /// 高风险类型或标识符黑名单。 + /// 用于拦截 File、Process、Environment 等直接作为类型或变量名出现的绕过方式。 + /// + private static readonly HashSet ForbiddenTypeOrIdentifierNames = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "Activator", + "Assembly", + "AppDomain", + "Environment", + "File", + "Directory", + "Path", + "Process", + "HttpClient", + "WebClient", + "WebRequest", + "Socket", + "Thread", + "GC" + }; /// /// 校验并编译规则表达式。 @@ -79,6 +194,109 @@ namespace YLErp.Modules.RiskEngine } } + /// + /// 校验变量取值表达式。 + /// 变量表达式只要求能编译为 object,不在保存时执行,避免依赖真实交易和数据库数据。 + /// + public static RuleCompileResult ValidateVariableExpression(string variableExpr) + { + if (string.IsNullOrWhiteSpace(variableExpr)) + return RuleCompileResult.Fail("变量取值表达式不能为空"); + + var compiled = CompileVariableExpression(variableExpr, out var compileErrorMessage); + if (compiled == null) + return RuleCompileResult.Fail(compileErrorMessage ?? "变量取值表达式编译失败"); + + return RuleCompileResult.Ok(null); + } + + /// + /// 编译变量取值表达式。 + /// 结构化规则执行时使用,表达式返回值由执行器按变量 DataType 统一转换。 + /// + public static Func CompileValueExpression(string variableExpr, out string errorMessage) + { + if (string.IsNullOrWhiteSpace(variableExpr)) + { + errorMessage = "变量取值表达式不能为空"; + return null; + } + + return CompileVariableExpression(variableExpr, out errorMessage); + } + + /// + /// 编译变量取值表达式。优先兼容原有单表达式写法;失败后再按多语句脚本编译。 + /// 多语句脚本支持用 return 表达最终值,编译前会转换为 Roslyn Script 稳定支持的末尾表达式。 + /// + private static Func CompileVariableExpression(string variableExpr, out string errorMessage) + { + if (IsLikelyVariableScript(variableExpr)) + { + var normalizedScript = NormalizeVariableScript(variableExpr); + var scriptCompiled = CompileScript(null, normalizedScript, out errorMessage); + return scriptCompiled; + } + + // 原有变量表达式大多是单个查询表达式,包成 object 后可直接作为脚本返回值。 + var expressionScript = $"(object)({variableExpr})"; + var compiled = CompileScript(null, expressionScript, out var expressionErrorMessage); + if (compiled != null) + { + errorMessage = null; + return compiled; + } + + // 单表达式编译失败时,再按多语句脚本兜底,兼容包含局部变量、if、throw 的复杂变量。 + var normalizedFallbackScript = NormalizeVariableScript(variableExpr); + compiled = CompileScript(null, normalizedFallbackScript, out var scriptErrorMessage); + if (compiled != null) + { + errorMessage = null; + return compiled; + } + + errorMessage = $"单表达式编译失败:{expressionErrorMessage};多语句脚本编译失败:{scriptErrorMessage}"; + return null; + } + + /// + /// 粗略判断变量表达式是否更像多语句脚本。 + /// 多语句脚本不能再包成 (object)(...),需要直接按 C# Script 编译。 + /// + private static bool IsLikelyVariableScript(string variableExpr) + { + return variableExpr.IndexOf(';') >= 0 || + variableExpr.IndexOf("return", StringComparison.OrdinalIgnoreCase) >= 0 || + variableExpr.IndexOf("throw", StringComparison.OrdinalIgnoreCase) >= 0 || + variableExpr.IndexOf("if", StringComparison.OrdinalIgnoreCase) >= 0; + } + + /// + /// 规范化多语句变量脚本。 + /// Roslyn Script 更稳定的返回方式是“最后一行表达式”,这里把末尾 return 表达式转换为末尾表达式。 + /// + private static string NormalizeVariableScript(string variableExpr) + { + var syntaxTree = CSharpSyntaxTree.ParseText(variableExpr, new CSharpParseOptions(kind: SourceCodeKind.Script)); + var root = syntaxTree.GetCompilationUnitRoot(); + var lastStatement = root.Members + .OfType() + .Select(statement => statement.Statement) + .LastOrDefault(); + + if (lastStatement is ReturnStatementSyntax returnStatement && returnStatement.Expression != null) + { + var returnText = returnStatement.ToFullString(); + var expressionText = returnStatement.Expression.ToFullString(); + var index = variableExpr.LastIndexOf(returnText, StringComparison.Ordinal); + if (index >= 0) + return variableExpr.Substring(0, index) + expressionText; + } + + return variableExpr; + } + /// /// 校验并编译规则,编译成功后同时写入规则对象和内存缓存。 /// @@ -115,13 +333,28 @@ namespace YLErp.Modules.RiskEngine } /// - /// 用 Roslyn 编译 C# 脚本代码为可执行委托 + /// 用 Roslyn 编译 C# 布尔表达式为可执行委托。 /// private static Func CompileScript(long? ruleId, string scriptCode, out string errorMessage) + { + return CompileScript(ruleId, scriptCode, out errorMessage); + } + + /// + /// 用 Roslyn 编译 C# 脚本代码为指定返回类型的可执行委托。 + /// 规则公式编译为 bool,变量取值表达式编译为 object。 + /// + private static Func CompileScript(long? ruleId, string scriptCode, out string errorMessage) { errorMessage = null; var ruleIdText = ruleId.HasValue ? ruleId.Value.ToString() : "未落库"; + if (!ValidateScriptSafety(scriptCode, out errorMessage)) + { + _logger.Error($"规则脚本安全校验失败 - RuleId: {ruleIdText}, Error: {errorMessage}\n脚本代码:{scriptCode}"); + return null; + } + // 配置编译选项:引用必要的程序集 var options = ScriptOptions.Default .WithReferences( @@ -136,7 +369,7 @@ namespace YLErp.Modules.RiskEngine .WithImports("System", "System.Linq", "Newtonsoft.Json", "YLErp.DBModels", "YLErp.QdpModule"); // 创建脚本(尚未执行,仅编译) - var script = CSharpScript.Create(scriptCode, options, globalsType: typeof(ScriptGlobals)); + var script = CSharpScript.Create(scriptCode, options, globalsType: typeof(ScriptGlobals)); // 编译(提前发现语法错误) //后续编译需提供接口,返回前端编译信息,包含编译错误列表 @@ -153,7 +386,7 @@ namespace YLErp.Modules.RiskEngine // 生成可调用委托 var runner = script.CreateDelegate(); - // 包装为同步的 Func + // 包装为同步的 Func return ctx => { try @@ -174,6 +407,125 @@ namespace YLErp.Modules.RiskEngine } }; } + + /// + /// 脚本安全校验。 + /// 在 Roslyn 编译前先扫描语法树,禁止写库、反射、文件、网络、进程等高风险语法入口。 + /// + private static bool ValidateScriptSafety(string scriptCode, out string errorMessage) + { + var tree = CSharpSyntaxTree.ParseText(scriptCode, new CSharpParseOptions(kind: SourceCodeKind.Script)); + var root = tree.GetRoot(); + + // 允许变量声明初始化(如 decimal a = ...),但禁止后续赋值或复合赋值,避免脚本修改对象状态。 + if (root.DescendantNodes().OfType().Any()) + { + errorMessage = "规则表达式不允许包含赋值语句"; + return false; + } + + if (root.DescendantNodes().OfType().Any(IsIncrementOrDecrement) || + root.DescendantNodes().OfType().Any(IsIncrementOrDecrement)) + { + errorMessage = "规则表达式不允许包含自增或自减语句"; + return false; + } + + foreach (var memberAccess in root.DescendantNodes().OfType()) + { + var memberName = memberAccess.Name.Identifier.ValueText; + if (!string.IsNullOrWhiteSpace(memberName) && ForbiddenMemberNames.Contains(memberName)) + { + errorMessage = $"规则表达式不允许访问高风险成员:{memberName}"; + return false; + } + } + + // 只拦截独立标识符,避免把实体字段 p.File、p.Path 误判为高风险类型名。 + foreach (var identifier in root.DescendantNodes().OfType()) + { + var identifierName = identifier.Identifier.ValueText; + if (!IsMemberAccessName(identifier) && !string.IsNullOrWhiteSpace(identifierName) && ForbiddenTypeOrIdentifierNames.Contains(identifierName)) + { + errorMessage = $"规则表达式不允许访问高风险类型或标识符:{identifierName}"; + return false; + } + } + + foreach (var objectCreation in root.DescendantNodes().OfType()) + { + var typeName = GetTypeName(objectCreation.Type); + if (!string.IsNullOrWhiteSpace(typeName) && ForbiddenTypeOrIdentifierNames.Contains(typeName)) + { + errorMessage = $"规则表达式不允许创建高风险类型:{typeName}"; + return false; + } + } + + foreach (var invocation in root.DescendantNodes().OfType()) + { + var methodName = GetInvocationName(invocation.Expression); + if (!string.IsNullOrWhiteSpace(methodName) && ForbiddenInvocationNames.Contains(methodName)) + { + errorMessage = $"规则表达式不允许调用高风险方法:{methodName}"; + return false; + } + } + + errorMessage = null; + return true; + } + + /// + /// 判断前缀一元表达式是否为自增/自减。 + /// + private static bool IsIncrementOrDecrement(PrefixUnaryExpressionSyntax expression) + { + return expression.IsKind(SyntaxKind.PreIncrementExpression) || expression.IsKind(SyntaxKind.PreDecrementExpression); + } + + /// + /// 判断后缀一元表达式是否为自增/自减。 + /// + private static bool IsIncrementOrDecrement(PostfixUnaryExpressionSyntax expression) + { + return expression.IsKind(SyntaxKind.PostIncrementExpression) || expression.IsKind(SyntaxKind.PostDecrementExpression); + } + + /// + /// 判断标识符是否是成员访问右侧名称,例如 p.Path 中的 Path。 + /// + private static bool IsMemberAccessName(IdentifierNameSyntax identifier) + { + return identifier.Parent is MemberAccessExpressionSyntax memberAccess && memberAccess.Name == identifier; + } + + /// + /// 从调用表达式中提取方法名,兼容 SaveChanges() 和 DbContext.SaveChanges() 两种写法。 + /// + private static string GetInvocationName(ExpressionSyntax expression) + { + return expression switch + { + MemberAccessExpressionSyntax memberAccess => memberAccess.Name.Identifier.ValueText, + IdentifierNameSyntax identifier => identifier.Identifier.ValueText, + _ => null + }; + } + + /// + /// 从类型语法中提取类型短名,用于匹配高风险类型黑名单。 + /// + private static string GetTypeName(TypeSyntax type) + { + return type switch + { + IdentifierNameSyntax identifier => identifier.Identifier.ValueText, + QualifiedNameSyntax qualifiedName => qualifiedName.Right.Identifier.ValueText, + AliasQualifiedNameSyntax aliasQualifiedName => aliasQualifiedName.Name.Identifier.ValueText, + _ => type?.ToString() + }; + } } public class BuildMemberAccessResult diff --git a/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs b/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs index d16a4911..5bf02e41 100644 --- a/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs +++ b/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs @@ -21,26 +21,26 @@ using YLErp.Model; RuleText、RuleExpr、Version、CompiledScript。 - RiskRuleApplication(规则应用):与规则分离,承载启用状态、控制策略、触发时点、 适用范围(全局 / 账户 / 客户 / 标的类型 / 合约类型)。 - - RuleExpr:当前唯一主编译入口。要求内容是 Roslyn 可直接执行的 C# bool 表达式, + - RuleExpr:自由文本规则的编译入口。要求内容是 Roslyn 可直接执行的 C# bool 表达式, 例如通过 DbContext 和 TradeId 查询数据库后做数值或日期比较。 - - ConditionJson:当前已在规则定义中保留,但不参与主执行链路。 + - ConditionJson:结构化规则的主执行依据;有值时优先由结构化执行器执行。 - RiskContext:一次风控检查的数据上下文,包含 TradeId、TriggerPoint 和 DbContext。 - - RuleCompiledCache:进程内编译结果缓存,按规则 Id 缓存 Func。 + - RuleCompiledCache:进程内编译结果缓存,仅用于自由文本 RuleExpr 规则,按规则 Id 缓存 Func。 当前编译流程: - 1. 外部准备好规则对象,在 RuleExpr 中直接写最终可执行表达式 - 2. 调用 RuleCompiler.ValidateAndCompileRule(rule) - 3. 内部使用 Roslyn 编译 RuleExpr,生成 Func - 4. 编译成功后写入 rule.CompiledScript,并同步写入 RuleCompiledCache - 5. 执行阶段优先从 RuleCompiledCache 取委托执行 + 1. 结构化规则以 ConditionJson 为执行依据,预热时只做 JSON 结构解析校验 + 2. 自由文本规则在 RuleExpr 中直接写最终可执行表达式 + 3. 调用 RuleCompiler.ValidateAndCompileRule(rule) + 4. 内部使用 Roslyn 编译 RuleExpr,生成 Func + 5. 编译成功后写入 rule.CompiledScript,并同步写入 RuleCompiledCache 当前执行流程(EvaluateRisk): 1. 从数据库加载规则列表和应用列表 2. 按应用状态、TriggerPoints 过滤有效应用 3. 按应用范围过滤交易是否命中(全局 / 账户 / 客户 / 标的类型 / 合约类型) 4. 根据应用配置中的 RuleIds 找到对应规则 Id 并关联规则 - 5. 优先从 RuleCompiledCache 读取已编译委托,未命中时兜底编译一次 - 6. 执行规则委托,按 ControlStrategy 聚合为 Blocked / NeedApproval / Warnings + 5. ConditionJson 有值时走结构化执行器;否则从 RuleCompiledCache 读取已编译委托,未命中时兜底编译一次 + 6. 执行规则判断,按 ControlStrategy 聚合为 Blocked / NeedApproval / Warnings 7. 返回 RiskResult 当前维度匹配规则: @@ -50,7 +50,7 @@ using YLErp.Model; - 某维度留空:视为该维度不限制 【与早期方案的主要差异】 - - 当前不是 Content 解析或表达式树主导,而是 RuleExpr 直编译主导 + - 结构化规则已改为 ConditionJson 执行主导,自由文本规则继续保留 RuleExpr 直编译 - RiskRule 与 RiskRuleApplication 当前仍是分离模型,没有合并 - 编译器文件已放入 RiskEngine/Compile 目录下 - 当前已引入 RuleCompileResult、RuleCompiledCache,用于校验结果与进程内缓存 @@ -75,12 +75,12 @@ using YLErp.Model; ✅ 已完成: 1. RiskEngine 第一版执行链路已跑通:QuotaMonitorService -> RiskEngineService -> RuleCompiler 2. 风控上下文提供 DbContext 和 TradeId,支持规则脚本直接查询数据库 - 3. RuleExpr 直编译方案已接入,支持数值比较和日期比较 + 3. 结构化规则已支持 ConditionJson 执行,自由文本规则保留 RuleExpr 直编译 4. Application 通用维度匹配已支持:全局 / 账户 / 客户 / 标的类型 / 合约类型 5. 维度组合逻辑已按文档确认:同维度 OR,不同维度 AND 6. RuleCompiler 已支持校验 + 编译 + 写缓存 7. RuleCompiledCache / RuleCompileResult 已落地到 Compile 目录 - 8. RiskEngineService 已改为执行时优先从 RuleCompiledCache 读取委托 + 8. RiskEngineService 已改为结构化规则优先执行 ConditionJson,自由文本规则再读取 RuleCompiledCache 【待办事项 / TODO】 ⬜ 1. 接入真实规则来源 @@ -146,8 +146,8 @@ namespace YLErp.Modules.RiskEngine private static readonly object _cacheLock = new object(); /// - /// 预热:加载规则与应用到内存,并预编译所有规则到 RuleCompiledCache。 - /// 项目启动时调用一次;规则/应用更新后调用 RefreshCache 刷新。 + /// 预热:加载规则与应用到内存,并预编译自由文本规则到 RuleCompiledCache。 + /// 结构化规则运行时直接执行 ConditionJson,预热时只做轻量结构校验,不再预编译 RuleExpr。 /// public void Preload() { @@ -157,7 +157,7 @@ namespace YLErp.Modules.RiskEngine var rules = LoadRulesFromDb(); var applications = LoadApplicationsFromDb(); - // 预编译所有规则到 RuleCompiledCache + // 仅预编译自由文本规则;结构化规则运行时由 ConditionJson 执行。 foreach (var rule in rules) { var ruleId = rule.Id.ToString(); @@ -169,6 +169,20 @@ namespace YLErp.Modules.RiskEngine continue; } + if (!string.IsNullOrWhiteSpace(rule.ConditionJson)) + { + RuleCompiledCache.Remove(rule.Id.ToString()); + try + { + RuleConditionExpressionBuilder.DeserializeConditions(rule.ConditionJson); + _logger.Info($"[风控引擎] 结构化规则预热校验成功,跳过 RuleExpr 预编译 - RuleId: {rule.Id}"); + } + catch (Exception ex) + { + _logger.Info($"[风控引擎] 结构化规则预热校验失败 - RuleId: {rule.Id}, Error: {ex.Message}"); + } + continue; + } var compileResult = RuleCompiler.ValidateAndCompileRule(rule); if (compileResult.Success) @@ -231,6 +245,22 @@ namespace YLErp.Modules.RiskEngine return RuleCompileResult.Fail("规则不存在或非活跃状态"); } + if (!string.IsNullOrWhiteSpace(rule.ConditionJson)) + { + RuleCompiledCache.Remove(ruleId.ToString()); + try + { + RuleConditionExpressionBuilder.DeserializeConditions(rule.ConditionJson); + _logger.Info($"[风控引擎] RefreshOneRuleCache 结构化规则校验成功,跳过 RuleExpr 编译 - RuleId: {ruleId}"); + return RuleCompileResult.Ok(null); + } + catch (Exception ex) + { + _logger.Info($"[风控引擎] RefreshOneRuleCache 结构化规则校验失败 - RuleId: {ruleId}, Error: {ex.Message}"); + return RuleCompileResult.Fail(ex.Message); + } + } + var compileResult = RuleCompiler.ValidateAndCompileRule(rule); _logger.Info($"[风控引擎] RefreshOneRuleCache 完成 - RuleId: {ruleId}, Success: {compileResult.Success}, Error: {compileResult.ErrorMessage}"); return compileResult; @@ -323,6 +353,7 @@ namespace YLErp.Modules.RiskEngine // ============================================================ var rules = GetRules(); var applications = GetApplications(); + var variables = new Dictionary(); _logger.Info($"[风控引擎] 加载规则数: {rules.Count}, 应用数: {applications.Count}"); // ============================================================ @@ -395,40 +426,88 @@ namespace YLErp.Modules.RiskEngine { var ruleId = rule.Id.ToString(); - // ------------------------------------------------------------ - // 3.2 优先从编译缓存读取规则委托 - // ------------------------------------------------------------ - if (!RuleCompiledCache.TryGet(ruleId, out var compiledScript)) + bool triggered = false; + if (!string.IsNullOrWhiteSpace(rule.ConditionJson)) { - _logger.Info($"[风控引擎] 缓存中未命中已编译规则,执行兜底编译 - RuleId: {rule.Id}"); - - var compileResult = RuleCompiler.ValidateAndCompileRule(rule); - if (!compileResult.Success) + // 结构化规则优先使用 ConditionJson 执行,避免继续把条件整体拼成 Roslyn bool 公式。 + try { - var errorMessage = $"规则[{rule.RuleName}]编译失败,已按阻断处理,请检查规则表达式配置:{compileResult.ErrorMessage}"; - _logger.Info($"[风控引擎] 规则编译失败,按阻断处理 - RuleId: {rule.Id}, Error: {compileResult.ErrorMessage}"); - AddBlockError(result, rule.Id.ToString(), rule.RuleName, rule.RuleText, errorMessage); + var conditions = RuleConditionExpressionBuilder.DeserializeConditions(rule.ConditionJson); + var referencedVariableIds = RuleConditionExpressionBuilder.GetReferencedVariableIds(conditions); + var missingVariableIds = referencedVariableIds + .Where(id => !variables.ContainsKey(id)) + .ToList(); + if (missingVariableIds.Any()) + { + // 按本次结构化规则实际引用的变量懒加载,避免每次风控检查全量读取变量池。 + var loadedVariables = ruleDbContext.glms_risk_variable + .AsNoTracking() + .Where(v => missingVariableIds.Contains(v.id)) + .ToDictionary(v => v.id); + foreach (var variable in loadedVariables) + variables[variable.Key] = variable.Value; + } + + var ruleVariables = referencedVariableIds + .Where(variables.ContainsKey) + .ToDictionary(id => id, id => variables[id]); + + var executeResult = StructuredRuleExecutor.Execute(conditions, ruleVariables, context); + if (!executeResult.Success) + { + var errorMessage = $"规则[{rule.RuleName}]执行异常:{executeResult.ErrorMessage}"; + _logger.Error($"[风控引擎] 结构化规则执行异常,按阻断处理 - RuleId: {rule.Id}, Error: {executeResult.ErrorMessage}"); + AddBlockError(result, ruleId, rule.RuleName, rule.RuleText, errorMessage); + continue; + } + + triggered = executeResult.Triggered; + _logger.Info($"[风控引擎] 结构化规则执行 - RuleId: {rule.Id}, Triggered: {triggered}"); + } + catch (Exception ex) + { + var errorMessage = $"规则[{rule.RuleName}]执行异常:{ex.Message}"; + _logger.Error($"[风控引擎] 结构化规则执行异常,按阻断处理 - RuleId: {rule.Id}, Error: {ex.Message}"); + AddBlockError(result, ruleId, rule.RuleName, rule.RuleText, errorMessage); continue; } - - compiledScript = compileResult.CompiledScript; } + else + { + // ------------------------------------------------------------ + // 3.2 优先从编译缓存读取规则委托 + // ------------------------------------------------------------ + if (!RuleCompiledCache.TryGet(ruleId, out var compiledScript)) + { + _logger.Info($"[风控引擎] 缓存中未命中已编译规则,执行兜底编译 - RuleId: {rule.Id}"); - // ------------------------------------------------------------ - // 3.3 执行预编译委托 - // ------------------------------------------------------------ - bool triggered = false; - try - { - triggered = compiledScript(context); - _logger.Info($"[风控引擎] 规则执行 - RuleId: {rule.Id}, Triggered: {triggered}"); - } - catch (Exception ex) - { - var errorMessage = $"规则[{rule.RuleName}]执行异常:{ex.Message}"; - _logger.Error($"[风控引擎] 规则执行异常,按阻断处理 - RuleId: {rule.Id}, Error: {ex.Message}"); - AddBlockError(result, ruleId, rule.RuleName, rule.RuleText, errorMessage); - continue; + var compileResult = RuleCompiler.ValidateAndCompileRule(rule); + if (!compileResult.Success) + { + 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; + } + + compiledScript = compileResult.CompiledScript; + } + + // ------------------------------------------------------------ + // 3.3 执行预编译委托 + // ------------------------------------------------------------ + try + { + triggered = compiledScript(context); + _logger.Info($"[风控引擎] 规则执行 - RuleId: {rule.Id}, Triggered: {triggered}"); + } + catch (Exception ex) + { + var errorMessage = $"规则[{rule.RuleName}]执行异常:{ex.Message}"; + _logger.Error($"[风控引擎] 规则执行异常,按阻断处理 - RuleId: {rule.Id}, Error: {ex.Message}"); + AddBlockError(result, ruleId, rule.RuleName, rule.RuleText, errorMessage); + continue; + } } // ------------------------------------------------------------ diff --git a/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs b/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs index 92ade91b..bad1b920 100644 --- a/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs +++ b/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs @@ -180,10 +180,10 @@ using YLErp.Modules.RiskEngine.Dto; 【注意事项】 1. RiskEngineService 由另一个团队开发实现,本服务通过 GetInstance() 获取单例引用 - 2. 规则变更后调用 RefreshOneRuleCache(ruleId),应用变更后调用 RefreshApplication(),变量变更不触发引擎缓存刷新 + 2. 规则变更后调用 RefreshOneRuleCache(ruleId),应用变更后调用 RefreshApplication(),变量定义变更后刷新引用规则缓存 3. 变量删除为硬删除(物理删除),规则/应用删除为软删除(Status=Deleted) 4. ExportAuditLogs 按查询条件导出,并生成包含查询条件的文件名 - 5. VariableExpr 当前仅做基础长度校验(≤10000),完整编译校验待引入 Roslyn 库 + 5. VariableExpr 保存时校验长度,并通过 Roslyn 做编译与安全校验 6. ValidateScopeFields 的 ID 存在性校验已跳过(前端下拉选择器保证有效性) ================================================================================ */ @@ -353,11 +353,12 @@ namespace YLErp.Modules.RiskEngine } /// - /// 校验 ConditionJson 并生成服务端期望的简洁 RuleExpr。 + /// 校验 ConditionJson 结构和引用变量是否存在。 + /// 结构化规则运行时以 ConditionJson 为准,RuleExpr 只作为兼容/展示字段。 /// - private string ValidateConditionJson(string conditionJson) + private void ValidateConditionJson(string conditionJson) { - return BuildRuleExprFromConditionJson(conditionJson); + ValidateConditionJsonVariables(conditionJson); } /// /// 校验 RuleExpr(自由文本模式:括号匹配) @@ -511,7 +512,8 @@ namespace YLErp.Modules.RiskEngine } /// - /// 校验规则参数(名称 + ConditionJson + RuleExpr) + /// 校验规则参数(名称 + ConditionJson / RuleExpr)。 + /// 自由文本规则继续使用 RuleExpr;结构化规则允许保留 RuleExpr 作为兼容/展示字段,运行时以 ConditionJson 为准。 /// private void ValidateRuleParams(string ruleName, string conditionJson, string ruleExpr, long? ruleId = null) { @@ -520,22 +522,31 @@ namespace YLErp.Modules.RiskEngine if (ruleName.Length > 200) throw new ServiceException("规则名称长度不能超过200字符"); + if (!string.IsNullOrWhiteSpace(conditionJson)) + { + ValidateConditionJson(conditionJson); + return; + } + if (string.IsNullOrWhiteSpace(ruleExpr)) throw new ServiceException("规则表达式不能为空"); - if (!string.IsNullOrWhiteSpace(conditionJson)) - { - var expectedRuleExpr = ValidateConditionJson(conditionJson); - if (!string.Equals(expectedRuleExpr, ruleExpr, StringComparison.Ordinal)) - { - _logger.Info($"结构化规则公式一致性校验失败。RuleId={ruleId?.ToString() ?? "NEW"}; Expected={expectedRuleExpr}; Actual={ruleExpr}"); - throw new ServiceException("公式与结构化条件不一致,请刷新后重试"); - } - } - ValidateRuleExpr(ruleExpr); } + /// + /// 校验规则编译:结构化规则运行时以 ConditionJson 为准,只有自由文本规则必须编译 RuleExpr。 + /// + private void ValidateRuleCompile(long? ruleId, string conditionJson, string ruleExpr) + { + if (!string.IsNullOrWhiteSpace(conditionJson)) + return; + + var compileResult = RuleCompiler.ValidateAndCompileFormula(ruleId, ruleExpr); + if (!compileResult.Success) + throw new ServiceException($"规则表达式编译失败:{compileResult.ErrorMessage}"); + } + /// /// 解析逗号分隔的规则 ID 字符串为 List /// @@ -893,10 +904,7 @@ namespace YLErp.Modules.RiskEngine public RiskRuleDetail CreateRule(CreateRiskRuleReq req) { ValidateRuleParams(req.RuleName, req.ConditionJson, req.RuleExpr); - - var compileResult = RuleCompiler.ValidateAndCompileFormula(null, req.RuleExpr); - if (!compileResult.Success) - throw new ServiceException($"规则表达式编译失败:{compileResult.ErrorMessage}"); + ValidateRuleCompile(null, req.ConditionJson, req.RuleExpr); var entity = new glms_risk_rule { @@ -950,10 +958,7 @@ namespace YLErp.Modules.RiskEngine throw new ServiceException("规则已被其他用户修改,请重新加载后再编辑"); ValidateRuleParams(req.RuleName, req.ConditionJson, req.RuleExpr, ruleId); - - var compileResult = RuleCompiler.ValidateAndCompileFormula(ruleId, req.RuleExpr); - if (!compileResult.Success) - throw new ServiceException($"规则表达式编译失败:{compileResult.ErrorMessage}"); + ValidateRuleCompile(ruleId, req.ConditionJson, req.RuleExpr); rule.RuleName = req.RuleName; rule.RuleText = req.RuleText; @@ -1007,9 +1012,7 @@ namespace YLErp.Modules.RiskEngine if (rule == null) throw new ServiceException("仅已停用的规则可以启用"); - var compileResult = RuleCompiler.ValidateAndCompileFormula(ruleId, rule.RuleExpr); - if (!compileResult.Success) - throw new ServiceException($"规则表达式编译失败:{compileResult.ErrorMessage}"); + ValidateRuleCompile(ruleId, rule.ConditionJson, rule.RuleExpr); rule.Status = RiskRuleStatus.Active; @@ -1198,11 +1201,13 @@ namespace YLErp.Modules.RiskEngine continue; } - var compileResult = RuleCompiler.ValidateAndCompileFormula(rule.id, rule.RuleExpr); - if (!compileResult.Success) + try { - items.Add(BatchItem(id, BatchOperationItemStatus.Failed, "COMPILE_FAILED", - $"规则表达式编译失败:{compileResult.ErrorMessage}")); + ValidateRuleCompile(rule.id, rule.ConditionJson, rule.RuleExpr); + } + catch (ServiceException ex) + { + items.Add(BatchItem(id, BatchOperationItemStatus.Failed, "COMPILE_FAILED", ex.Message)); continue; } @@ -1807,23 +1812,36 @@ namespace YLErp.Modules.RiskEngine } /// - /// 根据结构化 ConditionJson 和当前变量池定义重新生成简洁 RuleExpr。 + /// 校验结构化 ConditionJson 引用的变量是否存在。 /// - private string BuildRuleExprFromConditionJson(string conditionJson) + private List ValidateConditionJsonVariables(string conditionJson) { var conditions = RuleConditionExpressionBuilder.DeserializeConditions(conditionJson); var variableIds = RuleConditionExpressionBuilder.GetReferencedVariableIds(conditions).ToList(); var variables = DbContext.glms_risk_variable .Where(variable => variableIds.Contains(variable.id)) - .ToList() - .ToDictionary(variable => (long)variable.id); + .ToList(); - var missingVariableIds = variableIds.Where(id => !variables.ContainsKey(id)).ToList(); + var variableMap = variables.ToDictionary(variable => (long)variable.id); + var missingVariableIds = variableIds.Where(id => !variableMap.ContainsKey(id)).ToList(); if (missingVariableIds.Any()) throw new ServiceException($"公式引用的变量不存在:{string.Join(",", missingVariableIds)}"); - return RuleConditionExpressionBuilder.Build(conditions, variables); + return variables; } + + + /// + /// 校验变量取值表达式是否可编译且不包含写库等危险调用。 + /// 这里只做静态编译与安全检查,不执行表达式,避免依赖真实交易数据。 + /// + private void ValidateVariableExpr(string variableExpr) + { + var compileResult = RuleCompiler.ValidateVariableExpression(variableExpr); + if (!compileResult.Success) + throw new ServiceException($"变量取值表达式校验失败:{compileResult.ErrorMessage}"); + } + #region Variable Management /// @@ -1907,6 +1925,7 @@ namespace YLErp.Modules.RiskEngine throw new ServiceException("取值表达式不能为空"); if (req.VariableExpr.Length > 10000) throw new ServiceException("取值表达式长度不能超过 10000 字符"); + ValidateVariableExpr(req.VariableExpr); var entity = new glms_risk_variable { @@ -1965,6 +1984,9 @@ namespace YLErp.Modules.RiskEngine throw new ServiceException("变量名称不能为空"); if (string.IsNullOrWhiteSpace(req.VariableExpr)) throw new ServiceException("取值表达式不能为空"); + if (req.VariableExpr.Length > 10000) + throw new ServiceException("取值表达式长度不能超过 10000 字符"); + ValidateVariableExpr(req.VariableExpr); if (req.DataType != variable.DataType) { @@ -2000,34 +2022,20 @@ namespace YLErp.Modules.RiskEngine { try { - // 在同一事务内完成引用发现、公式重建、编译和持久化;任一规则失败时整体回滚。 + // 结构化规则运行时直接按 ConditionJson 读取变量定义,不再把变量表达式展开回 RuleExpr。 + // 因此变量定义变化时只标记引用规则受影响,避免多语句变量表达式被拼成非法公式。 affectedRules = GetRulesByVariableId(variableId) - .Where(r => r.RuleExpr != null) .ToList(); - 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; - } - - foreach (var rule in affectedRules) - { - rule.RuleExpr = rebuiltRuleExpressions[rule.id]; rule.Version = rule.Version + 1; rule.UpdateOptId = UserId; rule.UpdateOptName = UserName; rule.UpdateDate = DateTime.Now; WriteAuditLog("VAR_CASCADE_UPDATE_RULE", "RULE", rule.id, rule.RuleName, - $"因变量'{variable.VariableName}'(ID:{variableId})定义变更,规则表达式被级联更新"); + $"因变量'{variable.VariableName}'(ID:{variableId})定义变更,结构化规则已标记更新"); } SaveChangesWithConcurrencyCheck(); diff --git a/YLErpDAL/Modules/RiskEngine/StructuredRuleExecutor.cs b/YLErpDAL/Modules/RiskEngine/StructuredRuleExecutor.cs new file mode 100644 index 00000000..af9e7171 --- /dev/null +++ b/YLErpDAL/Modules/RiskEngine/StructuredRuleExecutor.cs @@ -0,0 +1,461 @@ +using Newtonsoft.Json.Linq; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using YLErp.Commons; +using YLErp.DBModels; +using YLErp.Modules.RiskEngine.Dto; + +namespace YLErp.Modules.RiskEngine +{ + /// + /// 结构化规则执行器。 + /// 用于执行现有 ConditionJson:变量取值、按 DataType 转换并比较阈值;复杂业务计算放在变量表达式中完成。 + /// + public static class StructuredRuleExecutor + { + private static readonly ConcurrentDictionary> VariableCompiledCache = new ConcurrentDictionary>(); + + private static readonly HashSet ComparableOperators = new HashSet(StringComparer.Ordinal) + { + "gt", "lt", "gte", "lte", "eq", "ne", "between", "notBetween" + }; + + private static readonly HashSet BooleanOperators = new HashSet(StringComparer.Ordinal) + { + "isTrue", "isFalse" + }; + + public static StructuredRuleExecuteResult Execute( + IReadOnlyList conditions, + IReadOnlyDictionary variables, + RiskContext context) + { + try + { + if (conditions == null || conditions.Count == 0) + return StructuredRuleExecuteResult.Fail("公式条件列表不能为空"); + if (variables == null) + return StructuredRuleExecuteResult.Fail("变量列表不能为空"); + + for (var i = 0; i < conditions.Count; i++) + { + var conditionResult = ExecuteCondition(conditions[i], variables, context, i + 1); + if (!conditionResult.Success) + return conditionResult; + if (!conditionResult.Triggered) + return StructuredRuleExecuteResult.Ok(false); + } + + return StructuredRuleExecuteResult.Ok(true); + } + catch (Exception ex) + { + return StructuredRuleExecuteResult.Fail(ex.Message); + } + } + + private static StructuredRuleExecuteResult ExecuteCondition( + RuleCondition condition, + IReadOnlyDictionary variables, + RiskContext context, + int conditionIndex) + { + var label = $"条件{conditionIndex}"; + if (condition == null) + return StructuredRuleExecuteResult.Fail($"{label}不能为空"); + if (!variables.TryGetValue(condition.VariableId, out var variable)) + return StructuredRuleExecuteResult.Fail($"{label}:变量 ID '{condition.VariableId}' 不存在"); + + if (variable.DataType == RiskVariableDataType.Boolean) + return ExecuteBooleanCondition(condition, variable, context, label); + + if (variable.DataType != RiskVariableDataType.Numeric && variable.DataType != RiskVariableDataType.Date) + return StructuredRuleExecuteResult.Fail($"{label}:不支持的数据类型"); + if (!ComparableOperators.Contains(condition.Operator)) + return StructuredRuleExecuteResult.Fail($"{label}:操作符 '{condition.Operator}' 不适用于{GetDataTypeName(variable.DataType)}类型变量"); + + var left = GetVariableValue(variable, variable.DataType, context, label, "条件变量"); + if (!left.Success) + return StructuredRuleExecuteResult.Fail(left.ErrorMessage); + + if (condition.Operator == "between" || condition.Operator == "notBetween") + return ExecuteRangeCondition(condition, variable, left.Value, variables, context, label); + + var rangeFieldCheck = EnsureNoRangeFields(condition, label); + if (!rangeFieldCheck.Success) + return rangeFieldCheck; + + var right = GetThresholdValue( + condition.ThresholdType, + condition.Value, + condition.ThresholdVariableId, + variable, + variables, + context, + label, + "阈值"); + if (!right.Success) + return StructuredRuleExecuteResult.Fail(right.ErrorMessage); + + var compareResult = Compare(left.Value, right.Value, variable.DataType, condition.Operator, label); + if (!compareResult.Success) + return compareResult; + + return StructuredRuleExecuteResult.Ok(compareResult.Triggered); + } + + private static StructuredRuleExecuteResult ExecuteBooleanCondition( + RuleCondition condition, + glms_risk_variable variable, + RiskContext context, + string label) + { + if (!BooleanOperators.Contains(condition.Operator)) + return StructuredRuleExecuteResult.Fail($"{label}:操作符 '{condition.Operator}' 不适用于布尔类型变量"); + var thresholdFieldCheck = EnsureNoThresholdFields(condition, label); + if (!thresholdFieldCheck.Success) + return thresholdFieldCheck; + + var value = GetVariableValue(variable, RiskVariableDataType.Boolean, context, label, "条件变量"); + if (!value.Success) + return StructuredRuleExecuteResult.Fail(value.ErrorMessage); + + var expected = condition.Operator == "isTrue"; + return StructuredRuleExecuteResult.Ok((bool)value.Value == expected); + } + + private static StructuredRuleExecuteResult ExecuteRangeCondition( + RuleCondition condition, + glms_risk_variable variable, + object leftValue, + IReadOnlyDictionary variables, + RiskContext context, + string label) + { + if (condition.ThresholdType != null || HasValue(condition.Value) || condition.ThresholdVariableId.HasValue) + return StructuredRuleExecuteResult.Fail($"{label}:区间条件不得携带普通阈值字段"); + if (!condition.IncludeLower.HasValue || !condition.IncludeUpper.HasValue) + return StructuredRuleExecuteResult.Fail($"{label}:区间开闭配置不完整"); + + var fixedRangeCheck = ValidateFixedRangeOrder(condition, variable.DataType, label); + if (!fixedRangeCheck.Success) + return fixedRangeCheck; + + var lower = GetThresholdValue(condition.LowerThresholdType, condition.LowerValue, condition.LowerThresholdVariableId, variable, variables, context, label, "下限"); + if (!lower.Success) + return StructuredRuleExecuteResult.Fail(lower.ErrorMessage); + + var upper = GetThresholdValue(condition.UpperThresholdType, condition.UpperValue, condition.UpperThresholdVariableId, variable, variables, context, label, "上限"); + if (!upper.Success) + return StructuredRuleExecuteResult.Fail(upper.ErrorMessage); + + var lowerOperator = condition.IncludeLower == true ? "gte" : "gt"; + var upperOperator = condition.IncludeUpper == true ? "lte" : "lt"; + var lowerCompare = Compare(leftValue, lower.Value, variable.DataType, lowerOperator, label); + if (!lowerCompare.Success) + return lowerCompare; + var upperCompare = Compare(leftValue, upper.Value, variable.DataType, upperOperator, label); + if (!upperCompare.Success) + return upperCompare; + + var inRange = lowerCompare.Triggered && upperCompare.Triggered; + return StructuredRuleExecuteResult.Ok(condition.Operator == "notBetween" ? !inRange : inRange); + } + + private static ValueExecuteResult GetThresholdValue( + string thresholdType, + object fixedValue, + long? thresholdVariableId, + glms_risk_variable conditionVariable, + IReadOnlyDictionary variables, + RiskContext context, + string label, + string thresholdLabel) + { + if (string.Equals(thresholdType, "Fixed", StringComparison.Ordinal)) + { + if (thresholdVariableId.HasValue) + return ValueExecuteResult.Fail($"{label}:固定{thresholdLabel}不得携带变量 ID"); + var fixedResult = ConvertValue(fixedValue, conditionVariable.DataType, label, $"固定{thresholdLabel}"); + if (!fixedResult.Success) + return ValueExecuteResult.Fail($"{fixedResult.ErrorMessage},原始值:{FormatRawValue(fixedValue)}"); + return fixedResult; + } + + if (string.Equals(thresholdType, "Variable", StringComparison.Ordinal)) + { + if (HasValue(fixedValue)) + return ValueExecuteResult.Fail($"{label}:变量{thresholdLabel}不得携带固定值"); + if (!thresholdVariableId.HasValue) + return ValueExecuteResult.Fail($"{label}:{thresholdLabel}变量 ID 不能为空"); + if (!variables.TryGetValue(thresholdVariableId.Value, out var thresholdVariable)) + return ValueExecuteResult.Fail($"{label}:{thresholdLabel}变量 ID '{thresholdVariableId.Value}' 不存在"); + if (thresholdVariable.DataType != conditionVariable.DataType) + return ValueExecuteResult.Fail($"{label}:{thresholdLabel}变量与条件变量的数据类型不一致"); + if (conditionVariable.DataType == RiskVariableDataType.Numeric && !UnitsMatch(conditionVariable.Unit, thresholdVariable.Unit)) + return ValueExecuteResult.Fail($"{label}:{thresholdLabel}变量与条件变量的单位不一致"); + + return GetVariableValue(thresholdVariable, conditionVariable.DataType, context, label, thresholdLabel); + } + + return ValueExecuteResult.Fail($"{label}:{thresholdLabel}类型 '{thresholdType}' 不合法,仅支持 Fixed/Variable"); + } + + private static ValueExecuteResult GetVariableValue( + glms_risk_variable variable, + RiskVariableDataType dataType, + RiskContext context, + string label, + string valueLabel) + { + if (string.IsNullOrWhiteSpace(variable.VariableExpr)) + return ValueExecuteResult.Fail($"{label}:变量'{variable.VariableName}'(ID:{variable.id})的取值表达式为空"); + + var compiled = GetCompiledVariableExpression(variable, out var errorMessage); + if (compiled == null) + return ValueExecuteResult.Fail($"{label}:变量'{variable.VariableName}'(ID:{variable.id})编译失败:{errorMessage}"); + + object rawValue; + try + { + rawValue = compiled(context); + } + catch (Exception ex) + { + return ValueExecuteResult.Fail($"{label}:变量'{variable.VariableName}'(ID:{variable.id})执行失败:{ex.Message}"); + } + + var converted = ConvertValue(rawValue, dataType, label, valueLabel); + if (!converted.Success) + return ValueExecuteResult.Fail($"{label}:变量'{variable.VariableName}'(ID:{variable.id}){converted.ErrorMessage},原始值:{FormatRawValue(rawValue)}"); + + return converted; + } + + private static Func GetCompiledVariableExpression(glms_risk_variable variable, out string errorMessage) + { + var cacheKey = $"{variable.id}:{variable.Version}:{variable.UpdateDate?.Ticks ?? 0}:{StringComparer.Ordinal.GetHashCode(variable.VariableExpr ?? string.Empty)}"; + if (VariableCompiledCache.TryGetValue(cacheKey, out var compiled)) + { + errorMessage = null; + return compiled; + } + + // 变量表达式只负责取原始值,类型转换统一在结构化执行器中处理;这里缓存编译结果,避免每次执行重复 Roslyn 编译。 + compiled = RuleCompiler.CompileValueExpression(variable.VariableExpr, out errorMessage); + if (compiled != null) + VariableCompiledCache[cacheKey] = compiled; + return compiled; + } + + private static ValueExecuteResult ConvertValue(object value, RiskVariableDataType dataType, string label, string valueLabel) + { + if (!HasValue(value)) + return ValueExecuteResult.Fail($"{valueLabel}为空"); + + if (dataType == RiskVariableDataType.Numeric) + { + if (value is decimal decimalValue) + return ValueExecuteResult.Ok(decimalValue); + if (decimal.TryParse(GetRawValue(value), NumberStyles.Float, CultureInfo.InvariantCulture, out var numericValue)) + return ValueExecuteResult.Ok(numericValue); + return ValueExecuteResult.Fail($"{valueLabel}必须为数字"); + } + + if (dataType == RiskVariableDataType.Date) + { + if (value is DateTime dateValue) + return ValueExecuteResult.Ok(dateValue.Date); + if (DateTime.TryParseExact(GetRawValue(value), "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var fixedDate)) + return ValueExecuteResult.Ok(fixedDate.Date); + return ValueExecuteResult.Fail($"{valueLabel}必须为 yyyy-MM-dd 格式的合法日期"); + } + + if (dataType == RiskVariableDataType.Boolean) + { + if (value is bool boolValue) + return ValueExecuteResult.Ok(boolValue); + if (bool.TryParse(GetRawValue(value), out var parsedBool)) + return ValueExecuteResult.Ok(parsedBool); + return ValueExecuteResult.Fail($"{valueLabel}必须为布尔值"); + } + + return ValueExecuteResult.Fail($"{label}:不支持的数据类型"); + } + + private static StructuredRuleExecuteResult Compare(object left, object right, RiskVariableDataType dataType, string ruleOperator, string label) + { + var compare = dataType switch + { + RiskVariableDataType.Numeric => ((decimal)left).CompareTo((decimal)right), + RiskVariableDataType.Date => ((DateTime)left).CompareTo((DateTime)right), + _ => throw new ServiceException($"{label}:不支持的数据类型") + }; + + var result = ruleOperator switch + { + "gt" => compare > 0, + "lt" => compare < 0, + "gte" => compare >= 0, + "lte" => compare <= 0, + "eq" => compare == 0, + "ne" => compare != 0, + _ => throw new ServiceException($"{label}:不支持的操作符'{ruleOperator}'") + }; + + return StructuredRuleExecuteResult.Ok(result); + } + + private static StructuredRuleExecuteResult EnsureNoRangeFields(RuleCondition condition, string label) + { + if (condition.LowerThresholdType != null || HasValue(condition.LowerValue) || condition.LowerThresholdVariableId.HasValue || + condition.UpperThresholdType != null || HasValue(condition.UpperValue) || condition.UpperThresholdVariableId.HasValue || + condition.IncludeLower.HasValue || condition.IncludeUpper.HasValue) + { + return StructuredRuleExecuteResult.Fail($"{label}:非区间条件不得携带区间字段"); + } + + return StructuredRuleExecuteResult.Ok(false); + } + + private static StructuredRuleExecuteResult EnsureNoThresholdFields(RuleCondition condition, string label) + { + if (condition.ThresholdType != null || HasValue(condition.Value) || condition.ThresholdVariableId.HasValue || + condition.LowerThresholdType != null || HasValue(condition.LowerValue) || condition.LowerThresholdVariableId.HasValue || + condition.UpperThresholdType != null || HasValue(condition.UpperValue) || condition.UpperThresholdVariableId.HasValue || + condition.IncludeLower.HasValue || condition.IncludeUpper.HasValue) + { + return StructuredRuleExecuteResult.Fail($"{label}:布尔条件不得携带阈值字段"); + } + + return StructuredRuleExecuteResult.Ok(false); + } + + private static StructuredRuleExecuteResult ValidateFixedRangeOrder(RuleCondition condition, RiskVariableDataType dataType, string label) + { + if (condition.LowerThresholdType != "Fixed" || condition.UpperThresholdType != "Fixed") + return StructuredRuleExecuteResult.Ok(false); + + var lower = GetRawValue(condition.LowerValue); + var upper = GetRawValue(condition.UpperValue); + if (dataType == RiskVariableDataType.Numeric && + decimal.TryParse(lower, NumberStyles.Float, CultureInfo.InvariantCulture, out var lowerNumber) && + decimal.TryParse(upper, NumberStyles.Float, CultureInfo.InvariantCulture, out var upperNumber) && + lowerNumber > upperNumber) + { + return StructuredRuleExecuteResult.Fail($"{label}:下限不能大于上限"); + } + + if (dataType == RiskVariableDataType.Date && + DateTime.TryParseExact(lower, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var lowerDate) && + DateTime.TryParseExact(upper, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var upperDate) && + lowerDate > upperDate) + { + return StructuredRuleExecuteResult.Fail($"{label}:下限不能晚于上限"); + } + + return StructuredRuleExecuteResult.Ok(false); + } + + private static bool HasValue(object value) + { + if (value == null) return false; + if (value is JValue jsonValue) + { + if (jsonValue.Type == JTokenType.Null || jsonValue.Type == JTokenType.Undefined) return false; + if (jsonValue.Type == JTokenType.String) return !string.IsNullOrWhiteSpace(jsonValue.Value()); + return true; + } + return value is not string text || !string.IsNullOrWhiteSpace(text); + } + + private static string GetRawValue(object value) + { + return value is JValue jsonValue + ? Convert.ToString(jsonValue.Value, CultureInfo.InvariantCulture) + : Convert.ToString(value, CultureInfo.InvariantCulture); + } + + private static string FormatRawValue(object value) + { + if (value == null) + return ""; + var rawValue = GetRawValue(value); + return string.IsNullOrWhiteSpace(rawValue) ? "" : rawValue; + } + + private static bool UnitsMatch(string left, string right) + { + return string.Equals(left?.Trim() ?? string.Empty, right?.Trim() ?? string.Empty, StringComparison.Ordinal); + } + + private static string GetDataTypeName(RiskVariableDataType dataType) + { + return dataType switch + { + RiskVariableDataType.Numeric => "数值", + RiskVariableDataType.Date => "日期", + RiskVariableDataType.Boolean => "布尔", + _ => "未知" + }; + } + } + + public class StructuredRuleExecuteResult + { + public bool Success { get; set; } + + public bool Triggered { get; set; } + + public string ErrorMessage { get; set; } + + public static StructuredRuleExecuteResult Ok(bool triggered) + { + return new StructuredRuleExecuteResult + { + Success = true, + Triggered = triggered + }; + } + + public static StructuredRuleExecuteResult Fail(string errorMessage) + { + return new StructuredRuleExecuteResult + { + Success = false, + Triggered = false, + ErrorMessage = errorMessage + }; + } + } + + internal class ValueExecuteResult + { + public bool Success { get; set; } + + public object Value { get; set; } + + public string ErrorMessage { get; set; } + + public static ValueExecuteResult Ok(object value) + { + return new ValueExecuteResult + { + Success = true, + Value = value + }; + } + + public static ValueExecuteResult Fail(string errorMessage) + { + return new ValueExecuteResult + { + Success = false, + ErrorMessage = errorMessage + }; + } + } +} diff --git a/YLErpDAL/Modules/RiskEngine/测试用例.md b/YLErpDAL/Modules/RiskEngine/测试用例.md index 7d29142a..a319dcf0 100644 --- a/YLErpDAL/Modules/RiskEngine/测试用例.md +++ b/YLErpDAL/Modules/RiskEngine/测试用例.md @@ -1575,6 +1575,34 @@ WHERE t.id = @TradeId; 保证金支付金额 ÷ 开仓名义本金 > 50% ``` +变量 Roslyn 示例:变量名为“保证金支付比例”,DataType 为 Numeric;规则前端仍配置“保证金支付比例 > 0.5”。字段类型需为 decimal / decimal?,否则需要字段层调整或显式类型转换。 + +```csharp +decimal marginPaymentAmount = DbContext.swap_position + .Where(p => p.SwapTradeId == TradeId + && p.IsInitial + && !p.Invalid + && (p.InterestMode == 5 || p.InterestMode == 6) + && p.InterestDirection == 2) + .Select(p => p.InterestPrincipalFix) + .Sum(); + +double? openingNotionalRaw = DbContext.trade + .Where(t => t.id == TradeId) + .Select(t => t.OriginalStockEqvNotional) + .FirstOrDefault(); + +if (!openingNotionalRaw.HasValue) + throw new Exception("开仓名义本金为空"); + +decimal openingNotional = (decimal)openingNotionalRaw.Value; + +if (openingNotional == 0m) + throw new Exception("开仓名义本金为0,不能作为除数"); + +return marginPaymentAmount / openingNotional; +``` + 规则字段口径: ```text From ca77a71f2c3e52b5854ff15e4a6a481b5bf8ddce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=B9=E5=B3=B0?= Date: Mon, 27 Jul 2026 16:54:06 +0800 Subject: [PATCH 6/8] =?UTF-8?q?feat:=20=E6=89=B9=E9=87=8F=E6=93=8D?= =?UTF-8?q?=E4=BD=9C=E4=BA=A4=E4=BA=92=E9=80=BB=E8=BE=91=E7=BB=9F=E4=B8=80?= =?UTF-8?q?&=E8=A7=A3=E8=80=A6=E8=A7=84=E5=88=99=E4=B8=8E=E5=BA=94?= =?UTF-8?q?=E7=94=A8=E7=9A=84=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../YLErp.Core/DBModels/glms_risk_variable.cs | 2 + .../Ver-5.6.0/add_risk_variable_status.sql | 12 + .../Ver-5.6.0/create_risk_engine_table.sql | 4 +- .../Ver-5.6.0/seed_risk_engine_variables.sql | 8 +- ...leApplicationStatusDecoupleContractTest.cs | 95 ++++ .../RiskEngine/Dto/BatchOperationResult.cs | 11 + .../RiskEngine/Dto/DeleteRuleResult.cs | 19 + .../RiskEngine/Dto/DisableRuleResult.cs | 9 +- .../RiskEngine/Dto/QueryRiskVariableReq.cs | 2 + .../RiskEngine/Dto/RiskApplicationDetail.cs | 12 + .../RiskEngine/Dto/RiskApplicationListItem.cs | 12 + .../RiskEngine/Dto/RiskVariableDetail.cs | 2 + .../RiskEngine/Dto/RiskVariableListItem.cs | 2 + .../RiskEngine/Dto/RiskVariableSimpleItem.cs | 2 + .../Modules/RiskEngine/RiskRuleService.cs | 456 ++++++++++++------ YLErpWeb/Controllers/RiskRuleController.cs | 11 +- 16 files changed, 487 insertions(+), 172 deletions(-) create mode 100644 Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/add_risk_variable_status.sql create mode 100644 UnitTestProject/Modules/RiskEngine/RiskRuleApplicationStatusDecoupleContractTest.cs create mode 100644 YLErpDAL/Modules/RiskEngine/Dto/DeleteRuleResult.cs diff --git a/Framework/YLErp.Core/DBModels/glms_risk_variable.cs b/Framework/YLErp.Core/DBModels/glms_risk_variable.cs index 5dad1ea6..59d478cb 100644 --- a/Framework/YLErp.Core/DBModels/glms_risk_variable.cs +++ b/Framework/YLErp.Core/DBModels/glms_risk_variable.cs @@ -23,6 +23,8 @@ namespace YLErp.DBModels public string Description { get; set; } /// 取值表达式(C# 表达式,用于计算变量值) public string VariableExpr { get; set; } + /// 状态: 1=Active, 2=Disabled, 3=Deleted + public RiskRuleStatus Status { get; set; } = RiskRuleStatus.Active; /// 版本号(乐观锁) [ConcurrencyCheck] public int Version { get; set; } = 1; diff --git a/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/add_risk_variable_status.sql b/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/add_risk_variable_status.sql new file mode 100644 index 00000000..803d4dd1 --- /dev/null +++ b/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/add_risk_variable_status.sql @@ -0,0 +1,12 @@ +-- ============================================================ +-- 增量迁移:为 glms_risk_variable 表增加 Status 字段 +-- 目的:将变量删除由硬删除改造为软删除,与 glms_risk_rule / glms_risk_rule_application 保持一致 +-- 状态:1=Active, 2=Disabled, 3=Deleted +-- 默认值:1(Active),存量数据自动标记为可用 +-- ============================================================ + +ALTER TABLE `yltrs_ylcms`.`glms_risk_variable` + ADD COLUMN `Status` tinyint NOT NULL DEFAULT 1 COMMENT '状态: 1=Active, 2=Disabled, 3=Deleted' AFTER `VariableExpr`; + +ALTER TABLE `yltrs_ylcms`.`glms_risk_variable` + ADD INDEX `idx_status`(`Status` ASC) USING BTREE; diff --git a/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/create_risk_engine_table.sql b/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/create_risk_engine_table.sql index 07c2d87c..2ec76d9a 100644 --- a/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/create_risk_engine_table.sql +++ b/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/create_risk_engine_table.sql @@ -68,6 +68,7 @@ CREATE TABLE `yltrs_ylcms`.`glms_risk_variable` ( `ValueDomain` varchar(500) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '值域描述', `Description` varchar(500) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '描述', `VariableExpr` text CHARACTER SET utf8 COLLATE utf8_general_ci NULL COMMENT '变量表达式(C#表达式)', + `Status` tinyint NOT NULL DEFAULT 1 COMMENT '状态: 1=Active, 2=Disabled, 3=Deleted', `Version` int NOT NULL DEFAULT 1 COMMENT '版本号(乐观锁)', `SortOrder` int NOT NULL DEFAULT 0 COMMENT '排序', `OptId` int NULL DEFAULT NULL COMMENT '创建人Id', @@ -76,5 +77,6 @@ CREATE TABLE `yltrs_ylcms`.`glms_risk_variable` ( `UpdateOptId` int NULL DEFAULT NULL COMMENT '最后修改人Id', `UpdateOptName` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '最后修改人名称', `UpdateDate` datetime NULL DEFAULT NULL COMMENT '最后修改时间', - PRIMARY KEY (`id`) USING BTREE + PRIMARY KEY (`id`) USING BTREE, + INDEX `idx_status`(`Status` ASC) USING BTREE ) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci COMMENT = '风控变量池定义表' ROW_FORMAT = Dynamic; \ No newline at end of file diff --git a/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/seed_risk_engine_variables.sql b/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/seed_risk_engine_variables.sql index 0801f0d0..7bd66361 100644 --- a/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/seed_risk_engine_variables.sql +++ b/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/seed_risk_engine_variables.sql @@ -13,7 +13,7 @@ -- 说明:结构化 RuleExpr 的固定数值不追加 m;本文件 VariableExpr 为人工维护的 C# 表达式,原有 m 后缀保持不变。 -- 插入当前数据库中的 6 条变量,保留原始 ID 以匹配规则 ConditionJson 中的 VariableId -INSERT INTO `yltrs_ylcms`.`glms_risk_variable` (`id`, `VariableName`, `Category`, `DataType`, `Unit`, `ValueDomain`, `Description`, `VariableExpr`, `Version`, `SortOrder`, `OptId`, `OptName`, `OptDate`, `UpdateOptId`, `UpdateOptName`, `UpdateDate`) VALUES +INSERT INTO `yltrs_ylcms`.`glms_risk_variable` (`id`, `VariableName`, `Category`, `DataType`, `Unit`, `ValueDomain`, `Description`, `VariableExpr`, `Status`, `Version`, `SortOrder`, `OptId`, `OptName`, `OptDate`, `UpdateOptId`, `UpdateOptName`, `UpdateDate`) VALUES (1, '合约名义本金', 1, @@ -22,6 +22,7 @@ INSERT INTO `yltrs_ylcms`.`glms_risk_variable` (`id`, `VariableName`, `Category` '≥ 0', '', 'DbContext.trade.First(t => t.id == TradeId).StockEqvNotional', + 1, 18, 0, 0, @@ -38,6 +39,7 @@ INSERT INTO `yltrs_ylcms`.`glms_risk_variable` (`id`, `VariableName`, `Category` '≥ 0', '当前交易对手方所有存续交易涉及的标的数量(去重)合计(含本笔)', '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()', + 1, 3, 0, 0, @@ -54,6 +56,7 @@ INSERT INTO `yltrs_ylcms`.`glms_risk_variable` (`id`, `VariableName`, `Category` '≥ 0', 'abs(期初标的交割净价% - 上一收盘日中债估值净价)', 'Math.Abs(DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection == 2 && !string.IsNullOrEmpty(p.UnderlyingCode)).PosiNetNoFeePrice.Value * 100 - 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).OrderByDescending(v => v.valuation_date).First().net_price.Value)', + 1, 6, 0, 0, @@ -70,6 +73,7 @@ INSERT INTO `yltrs_ylcms`.`glms_risk_variable` (`id`, `VariableName`, `Category` '≥ 0', 'abs(期初标的成交收益率% - 上一收盘日中债估值收益率)', 'Math.Abs(DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection == 2 && !string.IsNullOrEmpty(p.UnderlyingCode)).InitYtm.Value * 100 - 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)', + 1, 3, 0, 0, @@ -86,6 +90,7 @@ INSERT INTO `yltrs_ylcms`.`glms_risk_variable` (`id`, `VariableName`, `Category` '≥ 0', 'abs(期初标的交割全价% - 上一日标的收盘价)', 'Math.Abs(DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection == 2 && !string.IsNullOrEmpty(p.UnderlyingCode)).PosiGrossPrice * 100 - 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))', + 1, 4, 0, 0, @@ -102,6 +107,7 @@ INSERT INTO `yltrs_ylcms`.`glms_risk_variable` (`id`, `VariableName`, `Category` '≥ 0', 'ABS(利息端利率-FR007)/FR007*100', 'Math.Abs(DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.InterestDirection == 1).InterestRateDefault)*100m', + 1, 2, 0, 0, diff --git a/UnitTestProject/Modules/RiskEngine/RiskRuleApplicationStatusDecoupleContractTest.cs b/UnitTestProject/Modules/RiskEngine/RiskRuleApplicationStatusDecoupleContractTest.cs new file mode 100644 index 00000000..ba6c7515 --- /dev/null +++ b/UnitTestProject/Modules/RiskEngine/RiskRuleApplicationStatusDecoupleContractTest.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using YLErp.DBModels; +using YLErp.Modules.RiskEngine.Dto; + +namespace YLErp.Modules.RiskEngine +{ + [TestClass] + public class RiskRuleApplicationStatusDecoupleContractTest + { + private static readonly string[] AvailabilityPropertyNames = + { + "AssociatedRuleCount", + "EffectiveRuleCount", + "CanEnable", + "CanDisable", + "EnableButtonTooltip", + "DisableButtonTooltip" + }; + + [TestMethod] + public void DisableRuleResult_UsesDecoupledContract() + { + AssertProperty("RuleId", typeof(long)); + AssertProperty("AffectedActiveAppCount", typeof(int)); + Assert.IsNull(typeof(DisableRuleResult).GetProperty("CascadedApplicationIds")); + } + + [TestMethod] + public void DeleteRuleResult_ReportsAffectedApplications() + { + AssertProperty("RuleId", typeof(long)); + AssertProperty("AffectedActiveAppCount", typeof(int)); + AssertProperty("CausedNoAvailableRuleAppIds", typeof(List)); + } + + [TestMethod] + public void ApplicationDtos_ExposeAvailabilityFields() + { + AssertAvailabilityProperties(); + AssertAvailabilityProperties(); + } + + [TestMethod] + public void BatchOperationResult_ExposesDecoupledApplicationImpactFields() + { + AssertProperty("AffectedApplicationIds", typeof(List)); + AssertProperty("CausedNoAvailableRuleApplicationIds", typeof(List)); + AssertProperty("AffectedApplicationIds", typeof(List)); + AssertProperty("CausedNoAvailableRuleApplicationIds", typeof(List)); + } + + [TestMethod] + public void VariableDeleteReferenceMessage_ListsDistinctSortedRuleIds() + { + var method = typeof(RiskRuleService).GetMethod( + "BuildVariableDeleteReferenceMessage", BindingFlags.NonPublic | BindingFlags.Static); + Assert.IsNotNull(method); + + var rules = new List + { + new glms_risk_rule { id = 12 }, + new glms_risk_rule { id = 10 }, + new glms_risk_rule { id = 12 } + }; + + var message = (string)method.Invoke(null, new object[] { rules }); + + Assert.AreEqual("该变量被 2 个规则引用(规则ID:10、12),无法删除", message); + } + + private static void AssertAvailabilityProperties() + { + foreach (var propertyName in AvailabilityPropertyNames) + { + var expectedType = propertyName.EndsWith("Tooltip", StringComparison.Ordinal) + ? typeof(string) + : propertyName.StartsWith("Can", StringComparison.Ordinal) + ? typeof(bool) + : typeof(int); + + AssertProperty(propertyName, expectedType); + } + } + + private static void AssertProperty(string propertyName, Type expectedType) + { + var property = typeof(T).GetProperty(propertyName); + Assert.IsNotNull(property, $"{typeof(T).Name}.{propertyName} should exist."); + Assert.AreEqual(expectedType, property.PropertyType, $"{typeof(T).Name}.{propertyName} type mismatch."); + } + } +} diff --git a/YLErpDAL/Modules/RiskEngine/Dto/BatchOperationResult.cs b/YLErpDAL/Modules/RiskEngine/Dto/BatchOperationResult.cs index 0ae0cdcb..ceac3f2b 100644 --- a/YLErpDAL/Modules/RiskEngine/Dto/BatchOperationResult.cs +++ b/YLErpDAL/Modules/RiskEngine/Dto/BatchOperationResult.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.Runtime.Serialization; using System.Text.Json.Serialization; @@ -31,6 +32,11 @@ namespace YLErp.Modules.RiskEngine.Dto public BatchOperationItemStatus Status { get; set; } public string Code { get; set; } public string Message { get; set; } + /// 操作影响到的应用 ID(不代表应用状态发生变化) + public List AffectedApplicationIds { get; set; } = new List(); + /// 操作后不再包含任何启用规则的应用 ID + public List CausedNoAvailableRuleApplicationIds { get; set; } = new List(); + /// 旧版级联字段,解耦后始终为空,仅为兼容旧客户端保留 public List CascadedApplicationIds { get; set; } = new List(); } @@ -43,6 +49,11 @@ namespace YLErp.Modules.RiskEngine.Dto public int FailedCount { get; set; } public bool HasChanges { get; set; } public List Items { get; set; } = new List(); + /// 所有请求项影响到的应用 ID 去重并集 + public List AffectedApplicationIds { get; set; } = new List(); + /// 操作后不再包含任何启用规则的应用 ID 去重并集 + public List CausedNoAvailableRuleApplicationIds { get; set; } = new List(); + /// 旧版级联字段,解耦后始终为空,仅为兼容旧客户端保留 public List CascadedApplicationIds { get; set; } = new List(); } } diff --git a/YLErpDAL/Modules/RiskEngine/Dto/DeleteRuleResult.cs b/YLErpDAL/Modules/RiskEngine/Dto/DeleteRuleResult.cs new file mode 100644 index 00000000..b49505b6 --- /dev/null +++ b/YLErpDAL/Modules/RiskEngine/Dto/DeleteRuleResult.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; + +namespace YLErp.Modules.RiskEngine.Dto +{ + /// + /// 删除规则结果 + /// + public class DeleteRuleResult + { + /// 规则 ID + public long RuleId { get; set; } + + /// 删除前引用该规则的启用中应用数量 + public int AffectedActiveAppCount { get; set; } + + /// 删除后不再包含任何启用规则的应用 ID + public List CausedNoAvailableRuleAppIds { get; set; } = new List(); + } +} diff --git a/YLErpDAL/Modules/RiskEngine/Dto/DisableRuleResult.cs b/YLErpDAL/Modules/RiskEngine/Dto/DisableRuleResult.cs index e5702b2e..07a870a7 100644 --- a/YLErpDAL/Modules/RiskEngine/Dto/DisableRuleResult.cs +++ b/YLErpDAL/Modules/RiskEngine/Dto/DisableRuleResult.cs @@ -1,5 +1,3 @@ -using System.Collections.Generic; - namespace YLErp.Modules.RiskEngine.Dto { /// @@ -7,7 +5,10 @@ namespace YLErp.Modules.RiskEngine.Dto /// public class DisableRuleResult { - /// 级联停用的应用 ID 列表 - public List CascadedApplicationIds { get; set; } = new List(); + /// 规则 ID + public long RuleId { get; set; } + + /// 引用该规则的启用中应用数量 + public int AffectedActiveAppCount { get; set; } } } diff --git a/YLErpDAL/Modules/RiskEngine/Dto/QueryRiskVariableReq.cs b/YLErpDAL/Modules/RiskEngine/Dto/QueryRiskVariableReq.cs index ed4b9901..7a4cf95b 100644 --- a/YLErpDAL/Modules/RiskEngine/Dto/QueryRiskVariableReq.cs +++ b/YLErpDAL/Modules/RiskEngine/Dto/QueryRiskVariableReq.cs @@ -14,5 +14,7 @@ namespace YLErp.Modules.RiskEngine.Dto public RiskVariableDataType? DataType { get; set; } /// 变量名称 public string VariableName { get; set; } + /// 状态筛选(不传则返回非 Deleted 的全部) + public RiskRuleStatus? Status { get; set; } } } diff --git a/YLErpDAL/Modules/RiskEngine/Dto/RiskApplicationDetail.cs b/YLErpDAL/Modules/RiskEngine/Dto/RiskApplicationDetail.cs index 11d5cf7f..18141b5e 100644 --- a/YLErpDAL/Modules/RiskEngine/Dto/RiskApplicationDetail.cs +++ b/YLErpDAL/Modules/RiskEngine/Dto/RiskApplicationDetail.cs @@ -14,6 +14,18 @@ namespace YLErp.Modules.RiskEngine.Dto public string RuleIds { get; set; } /// 应用说明 public string Description { get; set; } + /// 关联规则数(不含已删除规则) + public int AssociatedRuleCount { get; set; } + /// 生效规则数(关联规则中已启用的数量) + public int EffectiveRuleCount { get; set; } + /// 当前状态下是否允许启用 + public bool CanEnable { get; set; } + /// 当前状态下是否允许停用 + public bool CanDisable { get; set; } + /// 启用按钮不可用时的提示 + public string EnableButtonTooltip { get; set; } + /// 停用按钮不可用时的提示 + public string DisableButtonTooltip { get; set; } /// 应用配置状态 public RiskRuleStatus Status { get; set; } /// 风控策略 diff --git a/YLErpDAL/Modules/RiskEngine/Dto/RiskApplicationListItem.cs b/YLErpDAL/Modules/RiskEngine/Dto/RiskApplicationListItem.cs index f50283ed..ca73b6cb 100644 --- a/YLErpDAL/Modules/RiskEngine/Dto/RiskApplicationListItem.cs +++ b/YLErpDAL/Modules/RiskEngine/Dto/RiskApplicationListItem.cs @@ -14,6 +14,18 @@ namespace YLErp.Modules.RiskEngine.Dto public string RuleIds { get; set; } /// 应用说明 public string Description { get; set; } + /// 关联规则数(不含已删除规则) + public int AssociatedRuleCount { get; set; } + /// 生效规则数(关联规则中已启用的数量) + public int EffectiveRuleCount { get; set; } + /// 当前状态下是否允许启用 + public bool CanEnable { get; set; } + /// 当前状态下是否允许停用 + public bool CanDisable { get; set; } + /// 启用按钮不可用时的提示 + public string EnableButtonTooltip { get; set; } + /// 停用按钮不可用时的提示 + public string DisableButtonTooltip { get; set; } /// 应用配置状态 public int Status { get; set; } /// 风控策略 diff --git a/YLErpDAL/Modules/RiskEngine/Dto/RiskVariableDetail.cs b/YLErpDAL/Modules/RiskEngine/Dto/RiskVariableDetail.cs index c3dee5a8..3e768956 100644 --- a/YLErpDAL/Modules/RiskEngine/Dto/RiskVariableDetail.cs +++ b/YLErpDAL/Modules/RiskEngine/Dto/RiskVariableDetail.cs @@ -28,6 +28,8 @@ namespace YLErp.Modules.RiskEngine.Dto public int Version { get; set; } /// 排序序号 public int SortOrder { get; set; } + /// 状态 + public RiskRuleStatus Status { get; set; } /// 创建人姓名 public string OptName { get; set; } /// 创建时间 diff --git a/YLErpDAL/Modules/RiskEngine/Dto/RiskVariableListItem.cs b/YLErpDAL/Modules/RiskEngine/Dto/RiskVariableListItem.cs index 538cdcb9..69c2b6f8 100644 --- a/YLErpDAL/Modules/RiskEngine/Dto/RiskVariableListItem.cs +++ b/YLErpDAL/Modules/RiskEngine/Dto/RiskVariableListItem.cs @@ -27,5 +27,7 @@ namespace YLErp.Modules.RiskEngine.Dto public int Version { get; set; } /// 排序序号 public int SortOrder { get; set; } + /// 状态 + public RiskRuleStatus Status { get; set; } } } diff --git a/YLErpDAL/Modules/RiskEngine/Dto/RiskVariableSimpleItem.cs b/YLErpDAL/Modules/RiskEngine/Dto/RiskVariableSimpleItem.cs index f884a03e..c3be993e 100644 --- a/YLErpDAL/Modules/RiskEngine/Dto/RiskVariableSimpleItem.cs +++ b/YLErpDAL/Modules/RiskEngine/Dto/RiskVariableSimpleItem.cs @@ -19,5 +19,7 @@ namespace YLErp.Modules.RiskEngine.Dto public string Unit { get; set; } /// 取值表达式 public string VariableExpr { get; set; } + /// 状态 + public RiskRuleStatus Status { get; set; } } } diff --git a/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs b/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs index 92ade91b..d0579068 100644 --- a/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs +++ b/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs @@ -53,7 +53,7 @@ using YLErp.Modules.RiskEngine.Dto; │ - ConditionJson 完整校验(结构化模式:变量存在性、操作符、类型匹配)│ │ - RuleExpr 基础语法校验(自由文本模式:括号匹配、变量引用校验) │ │ - 乐观锁并发控制(Version 字段) │ - │ - 删除保护(有 Active 引用时拒绝删除) │ + │ - 规则删除同步解除应用关联;变量被任一未删除规则引用时禁止删除 │ │ - CRUD 后触发 RiskEngineService.RefreshCache() │ │ │ │ RiskEngineService(另一团队开发,单例) │ @@ -76,26 +76,26 @@ using YLErp.Modules.RiskEngine.Dto; GetRuleDetail(long) 获取规则详情(含关联应用摘要) CreateRule(CreateRiskRuleReq) 新建规则(ConditionJson 完整校验) UpdateRule(long, UpdateRiskRuleReq) 修改规则(乐观锁校验,Version+1) - DeleteRule(long) 删除规则(强保护:有 Active 应用时拒绝) + DeleteRule(long) 删除规则(从未删除应用中移除关联) EnableRule(long) 启用规则(仅 Disabled → Active) - DisableRule(long) 停用规则(仅 Active → Disabled,级联停用关联应用) + DisableRule(long) 停用规则(仅 Active → Disabled,不修改应用状态) GetRuleVersions(long) 获取规则版本历史(从审计日志 SnapshotData 提取版本号) - BatchDeleteRules(List) 批量删除规则(逐条保护检查,单事务) + BatchDeleteRules(List) 批量删除规则(同步移除应用关联,单次提交) BatchEnableRules(List) 批量启用规则(逐条编译校验) - BatchDisableRules(List) 批量停用规则(级联停用关联应用) + BatchDisableRules(List) 批量停用规则(不修改应用状态) GetRuleApplications(long) 获取规则关联的应用配置列表 - GetAllRuleList() 获取所有启用规则(轻量字段,供应用配置下拉) + GetAllRuleList() 获取所有未删除规则(轻量字段,供应用配置下拉) ── 应用配置管理(9 个) ─────────────────────────────────────────── - QueryApplicationList(QueryRiskApplicationReq) 查询应用列表(多条件筛选,内存关联规则名称) + QueryApplicationList(QueryRiskApplicationReq) 查询应用列表(分页后批量计算规则统计) GetApplicationDetail(long) 获取应用详情(含关联规则信息) CreateApplication(CreateRiskApplicationReq) 新建应用(关联规则 Active 校验,触发时点/Scope 校验) UpdateApplication(long, UpdateRiskApplicationReq) 修改应用(乐观锁校验) DeleteApplication(long) 删除应用(软删除) BatchDeleteApplications(List) 批量删除应用(软删除) - EnableApplication(long) 启用应用(校验关联规则状态) + EnableApplication(long) 启用应用(至少一条关联规则 Active) DisableApplication(long) 停用应用 - BatchEnableApplications(List) 批量启用(逐条校验关联规则) + BatchEnableApplications(List) 批量启用(逐条校验至少一条 Active 规则) BatchDisableApplications(List) 批量停用 ── 变量管理(6 个) ─────────────────────────────────────────────── @@ -103,7 +103,7 @@ using YLErp.Modules.RiskEngine.Dto; GetVariableDetail(long) 获取变量详情(含 ReferenceCount 引用统计) CreateVariable(CreateRiskVariableReq) 新建变量(VariableExpr 长度校验 ≤10000) UpdateVariable(long, UpdateRiskVariableReq) 修改变量(乐观锁,DataType 变更保护) - DeleteVariable(long) 删除变量(引用保护:有 Active 规则引用时拒绝,硬删除) + DeleteVariable(long) 删除变量(引用保护:有未删除规则引用时拒绝,软删除) GetAllVariableList() 获取所有已实现变量(轻量字段,供规则编辑器下拉) ── 审计日志(4 个) ─────────────────────────────────────────────── @@ -128,14 +128,14 @@ using YLErp.Modules.RiskEngine.Dto; 1. 数据访问:直接使用 EF Core DbContext(无独立 Repository,与项目风格一致) 2. 事务策略:Create* 方法使用 BeginTransaction 包裹双 SaveChanges(实体 + 审计日志同事务) 3. 并发控制:乐观锁(Version 字段),修改时版本不匹配抛 ServiceException - 4. 删除保护:强保护模式,有 Active 引用时拒绝删除规则/变量 + 4. 删除策略:规则删除同步从应用移除;变量删除保护所有未删除规则引用 5. ConditionJson 校验:变量存在性(按 VariableId)+ 操作符合法性(按 DataType 映射)+ 类型匹配 + 阈值变量引用 6. RuleExpr:自由文本模式下前端直接编辑,后端做基础语法校验(括号匹配 + 变量引用校验) 7. Scope 字段:逗号分隔字符串存储,全局适用时不允许指定其他维度 8. 缓存刷新:规则变更后调用 RefreshOneRuleCache(ruleId),应用变更后调用 RefreshApplication() - 9. 批量操作:单事务批量,全部成功或全部失败 + 9. 批量操作:逐项返回成功/未变更/失败,成功项统一提交 10. 变量缓存:5 分钟本地 HashSet 缓存,CRUD 后 InvalidateVariableCache() - 11. 审计日志:16 种操作类型,CRUD + 审计日志同一事务,SnapshotData 存储版本快照 + 11. 审计日志:CRUD + 审计日志同次提交,SnapshotData 存储版本快照 12. 多规则支持:Application 通过 RuleIds(逗号分隔)关联多条规则,内存解析关联关系 【数据库表】(MySQL,迁移脚本位于 Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/prod.sql) @@ -181,7 +181,7 @@ using YLErp.Modules.RiskEngine.Dto; 【注意事项】 1. RiskEngineService 由另一个团队开发实现,本服务通过 GetInstance() 获取单例引用 2. 规则变更后调用 RefreshOneRuleCache(ruleId),应用变更后调用 RefreshApplication(),变量变更不触发引擎缓存刷新 - 3. 变量删除为硬删除(物理删除),规则/应用删除为软删除(Status=Deleted) + 3. 变量/规则/应用删除均为软删除(Status=Deleted),新建变量默认 Active 4. ExportAuditLogs 按查询条件导出,并生成包含查询条件的文件名 5. VariableExpr 当前仅做基础长度校验(≤10000),完整编译校验待引入 Roslyn 库 6. ValidateScopeFields 的 ID 存在性校验已跳过(前端下拉选择器保证有效性) @@ -225,7 +225,9 @@ namespace YLErp.Modules.RiskEngine { "APP_BATCH_ENABLE", "批量启用应用" }, { "APP_BATCH_DISABLE", "批量停用应用" }, { "APP_BATCH_DELETE", "批量删除应用" }, + // 仅用于历史审计日志展示;解耦后不再写入该操作类型。 { "RULE_CASCADE_DISABLE_APP", "规则级联停用应用" }, + { "RULE_REMOVE_FROM_APP", "规则从应用移除" }, { "VAR_CREATE", "新增变量" }, { "VARIABLE_CREATE", "新增变量" }, { "VAR_UPDATE", "修改变量" }, @@ -504,9 +506,10 @@ namespace YLErp.Modules.RiskEngine /// private glms_risk_variable GetVariableOrThrow(long variableId) { - var variable = DbContext.glms_risk_variable.FirstOrDefault(v => v.id == variableId); + var variable = DbContext.glms_risk_variable + .FirstOrDefault(v => v.id == variableId && v.Status != RiskRuleStatus.Deleted); if (variable == null) - throw new ServiceException("变量不存在"); + throw new ServiceException("变量不存在或已删除"); return variable; } @@ -559,6 +562,84 @@ namespace YLErp.Modules.RiskEngine return ids; } + /// + /// 解析数据库中已保存的规则 ID。删除应用关联的最后一条规则后允许为空。 + /// + private static List ParseStoredRuleIds(string ruleIds) + { + if (string.IsNullOrWhiteSpace(ruleIds)) + return new List(); + + var ids = new List(); + foreach (var part in ruleIds.Split(',')) + { + var value = part.Trim(); + if (string.IsNullOrEmpty(value)) + continue; + if (!long.TryParse(value, out var id)) + throw new ServiceException($"规则 ID 列表格式不合法:'{value}'"); + ids.Add(id); + } + return ids.Distinct().ToList(); + } + + private bool HasAnyActiveRule(string ruleIds) + { + var ids = ParseStoredRuleIds(ruleIds); + return ids.Count > 0 && DbContext.glms_risk_rule + .Any(r => ids.Contains(r.id) && r.Status == RiskRuleStatus.Active); + } + + private Dictionary LoadRuleStatuses(IEnumerable storedRuleIds) + { + var ids = storedRuleIds.SelectMany(ParseStoredRuleIds).Distinct().ToList(); + if (ids.Count == 0) + return new Dictionary(); + + return DbContext.glms_risk_rule + .Where(r => ids.Contains(r.id)) + .Select(r => new { r.id, r.Status }) + .ToDictionary(r => (long)r.id, r => r.Status); + } + + private static void PopulateApplicationAvailability( + RiskApplicationListItem item, IReadOnlyDictionary ruleStatuses) + { + var statuses = ParseStoredRuleIds(item.RuleIds) + .Where(ruleStatuses.ContainsKey) + .Select(id => ruleStatuses[id]) + .ToList(); + item.AssociatedRuleCount = statuses.Count(status => status != RiskRuleStatus.Deleted); + item.EffectiveRuleCount = statuses.Count(status => status == RiskRuleStatus.Active); + item.CanEnable = item.Status == (int)RiskRuleStatus.Disabled && item.EffectiveRuleCount > 0; + item.CanDisable = item.Status == (int)RiskRuleStatus.Active && item.EffectiveRuleCount > 0; + item.EnableButtonTooltip = item.Status == (int)RiskRuleStatus.Disabled && !item.CanEnable + ? "该应用下所有规则均已全局停用,无法启用应用。请先在规则管理中启用至少一条规则" + : string.Empty; + item.DisableButtonTooltip = item.Status == (int)RiskRuleStatus.Active && !item.CanDisable + ? "该应用下所有规则均已全局停用,应用无法生效。请先在规则管理中启用至少一条关联规则" + : string.Empty; + } + + private static void PopulateApplicationAvailability( + RiskApplicationDetail item, IReadOnlyDictionary ruleStatuses) + { + var statuses = ParseStoredRuleIds(item.RuleIds) + .Where(ruleStatuses.ContainsKey) + .Select(id => ruleStatuses[id]) + .ToList(); + item.AssociatedRuleCount = statuses.Count(status => status != RiskRuleStatus.Deleted); + item.EffectiveRuleCount = statuses.Count(status => status == RiskRuleStatus.Active); + item.CanEnable = item.Status == RiskRuleStatus.Disabled && item.EffectiveRuleCount > 0; + item.CanDisable = item.Status == RiskRuleStatus.Active && item.EffectiveRuleCount > 0; + item.EnableButtonTooltip = item.Status == RiskRuleStatus.Disabled && !item.CanEnable + ? "该应用下所有规则均已全局停用,无法启用应用。请先在规则管理中启用至少一条规则" + : string.Empty; + item.DisableButtonTooltip = item.Status == RiskRuleStatus.Active && !item.CanDisable + ? "该应用下所有规则均已全局停用,应用无法生效。请先在规则管理中启用至少一条关联规则" + : string.Empty; + } + /// /// 规范化逗号分隔的规则 ID:去除空格和重复项,统一为无空格格式。 /// @@ -723,7 +804,8 @@ namespace YLErp.Modules.RiskEngine } private static BatchOperationItemResult BatchItem(long id, BatchOperationItemStatus status, - string code, string message, IEnumerable cascadedApplicationIds = null) + string code, string message, IEnumerable affectedApplicationIds = null, + IEnumerable causedNoAvailableRuleApplicationIds = null) { return new BatchOperationItemResult { @@ -731,7 +813,9 @@ namespace YLErp.Modules.RiskEngine Status = status, Code = code, Message = message, - CascadedApplicationIds = cascadedApplicationIds?.Distinct().ToList() ?? new List() + AffectedApplicationIds = affectedApplicationIds?.Distinct().ToList() ?? new List(), + CausedNoAvailableRuleApplicationIds = causedNoAvailableRuleApplicationIds?.Distinct().ToList() ?? new List(), + CascadedApplicationIds = new List() }; } @@ -747,7 +831,9 @@ namespace YLErp.Modules.RiskEngine SucceededCount = items.Count(i => i.Status == BatchOperationItemStatus.Succeeded), UnchangedCount = items.Count(i => i.Status == BatchOperationItemStatus.Unchanged), FailedCount = items.Count(i => i.Status == BatchOperationItemStatus.Failed), - CascadedApplicationIds = items.SelectMany(i => i.CascadedApplicationIds).Distinct().ToList() + AffectedApplicationIds = items.SelectMany(i => i.AffectedApplicationIds).Distinct().ToList(), + CausedNoAvailableRuleApplicationIds = items.SelectMany(i => i.CausedNoAvailableRuleApplicationIds).Distinct().ToList(), + CascadedApplicationIds = new List() }; result.HasChanges = result.SucceededCount > 0; result.Outcome = result.FailedCount == 0 @@ -974,28 +1060,57 @@ namespace YLErp.Modules.RiskEngine } /// - /// 删除规则(软删除,有 Active 应用引用时拒绝) + /// 删除规则(软删除,并从所有未删除应用中移除关联) /// - public void DeleteRule(long ruleId) + public DeleteRuleResult DeleteRule(long ruleId) { var rule = GetRuleOrThrow(ruleId); - - var activeCount = DbContext.glms_risk_rule_application - .Where(a => a.Status == RiskRuleStatus.Active) - .Count(RuleIdsMatchExpr(ruleId)); - if (activeCount > 0) - throw new ServiceException($"该规则仍有 {activeCount} 个生效中的应用配置,请先停用或删除相关应用"); + var affectedApps = DbContext.glms_risk_rule_application + .Where(a => a.Status != RiskRuleStatus.Deleted) + .Where(RuleIdsMatchExpr(ruleId)) + .ToList(); + var affectedActiveAppCount = affectedApps.Count(a => a.Status == RiskRuleStatus.Active); + var activeRuleIds = DbContext.glms_risk_rule + .Where(r => r.Status == RiskRuleStatus.Active && r.id != ruleId) + .Select(r => (long)r.id) + .ToHashSet(); + var causedNoAvailableRuleAppIds = new List(); rule.Status = RiskRuleStatus.Deleted; rule.Version = rule.Version + 1; rule.UpdateOptId = UserId; rule.UpdateOptName = UserName; rule.UpdateDate = DateTime.Now; - WriteAuditLog("RULE_DELETE", "RULE", ruleId, rule.RuleName, "删除规则"); - SaveChangesWithConcurrencyCheck(); - RiskEngineService.GetInstance().RefreshOneRuleCache(ruleId); + foreach (var app in affectedApps) + { + var remainingIds = ParseStoredRuleIds(app.RuleIds).Where(id => id != ruleId).ToList(); + app.RuleIds = string.Join(",", remainingIds); + app.Version++; + app.UpdateOptId = UserId; + app.UpdateOptName = UserName; + app.UpdateDate = DateTime.Now; + if (!remainingIds.Any(activeRuleIds.Contains)) + causedNoAvailableRuleAppIds.Add(app.id); + + WriteAuditLog("RULE_REMOVE_FROM_APP", "APPLICATION", app.id, $"应用{app.id}", + $"删除规则'{rule.RuleName}'(ID:{ruleId}),已从应用关联规则中移除"); + } + + SaveChangesWithConcurrencyCheck(); + TryRefreshCache(() => RiskEngineService.GetInstance().RefreshOneRuleCache(ruleId), + "风控规则缓存", new[] { ruleId }); + if (affectedApps.Any()) + TryRefreshCache(() => RiskEngineService.GetInstance().RefreshApplication(), + "风控应用缓存", affectedApps.Select(a => (long)a.id)); + + return new DeleteRuleResult + { + RuleId = ruleId, + AffectedActiveAppCount = affectedActiveAppCount, + CausedNoAvailableRuleAppIds = causedNoAvailableRuleAppIds.Distinct().ToList() + }; } /// @@ -1025,7 +1140,7 @@ namespace YLErp.Modules.RiskEngine } /// - /// 停用规则(仅 Active → Disabled) + /// 停用规则(仅 Active → Disabled,不修改关联应用状态) /// public DisableRuleResult DisableRule(long ruleId) { @@ -1033,47 +1148,24 @@ namespace YLErp.Modules.RiskEngine if (rule == null) throw new ServiceException("仅已启用的规则可以停用"); - rule.Status = RiskRuleStatus.Disabled; + var affectedActiveAppCount = DbContext.glms_risk_rule_application + .Where(a => a.Status == RiskRuleStatus.Active) + .Count(RuleIdsMatchExpr(ruleId)); + rule.Status = RiskRuleStatus.Disabled; rule.Version = rule.Version + 1; rule.UpdateOptId = UserId; rule.UpdateOptName = UserName; rule.UpdateDate = DateTime.Now; - WriteAuditLog("RULE_DISABLE", "RULE", ruleId, rule.RuleName, "停用规则"); - - // 级联停用关联的 Active 应用配置 - var activeApps = DbContext.glms_risk_rule_application - .Where(a => a.Status == RiskRuleStatus.Active) - .Where(RuleIdsMatchExpr(ruleId)) - .ToList(); - - var cascadedIds = new List(); - foreach (var app in activeApps) - { - app.Status = RiskRuleStatus.Disabled; - app.Version = app.Version + 1; - app.UpdateOptId = UserId; - app.UpdateOptName = UserName; - app.UpdateDate = DateTime.Now; - cascadedIds.Add(app.id); - - WriteAuditLog("RULE_CASCADE_DISABLE_APP", "APPLICATION", app.id, $"应用{app.id}", - $"因规则'{rule.RuleName}'(ID:{ruleId})停用,应用配置被级联停用"); - } - - if (cascadedIds.Any()) - { - _logger.Info($"[停用规则] 规则ID: {ruleId}, 级联停用应用: {string.Join(",", cascadedIds)}"); - } - SaveChangesWithConcurrencyCheck(); RiskEngineService.GetInstance().RefreshOneRuleCache(ruleId); - if (cascadedIds.Any()) - RiskEngineService.GetInstance().RefreshApplication(); - - return new DisableRuleResult { CascadedApplicationIds = cascadedIds }; + return new DisableRuleResult + { + RuleId = ruleId, + AffectedActiveAppCount = affectedActiveAppCount + }; } /// @@ -1117,7 +1209,7 @@ namespace YLErp.Modules.RiskEngine } /// - /// 批量删除规则(逐条保护检查) + /// 批量删除规则,并从所有未删除应用中移除成功删除的规则关联 /// public BatchOperationResult BatchDeleteRules(List ruleIds) { @@ -1126,45 +1218,75 @@ namespace YLErp.Modules.RiskEngine var ids = DistinctIds(ruleIds); var rules = DbContext.glms_risk_rule.Where(r => ids.Contains(r.id)).ToDictionary(r => (long)r.id); - var activeApps = DbContext.glms_risk_rule_application - .Where(a => a.Status == RiskRuleStatus.Active) + var changedIds = ids + .Where(id => rules.TryGetValue(id, out var rule) && rule.Status != RiskRuleStatus.Deleted) .ToList(); - var items = new List(); - var changedIds = new List(); + var changedIdSet = changedIds.ToHashSet(); + var affectedApps = changedIds.Count == 0 + ? new List() + : DbContext.glms_risk_rule_application + .Where(a => a.Status != RiskRuleStatus.Deleted && a.RuleIds != null && a.RuleIds != "") + .ToList() + .Where(a => ParseStoredRuleIds(a.RuleIds).Any(changedIdSet.Contains)) + .ToList(); + var affectedByRule = changedIds.ToDictionary(id => id, _ => new List()); + var noAvailableByRule = changedIds.ToDictionary(id => id, _ => new List()); + var activeRuleIdsAfterDelete = DbContext.glms_risk_rule + .Where(r => r.Status == RiskRuleStatus.Active && !changedIds.Contains(r.id)) + .Select(r => (long)r.id) + .ToHashSet(); + foreach (var app in affectedApps) + { + var currentIds = ParseStoredRuleIds(app.RuleIds); + var removedIds = currentIds.Where(changedIdSet.Contains).Distinct().ToList(); + var remainingIds = currentIds.Where(id => !changedIdSet.Contains(id)).ToList(); + app.RuleIds = string.Join(",", remainingIds); + app.Version++; + app.UpdateOptId = UserId; + app.UpdateOptName = UserName; + app.UpdateDate = DateTime.Now; + var hasActiveRule = remainingIds.Any(activeRuleIdsAfterDelete.Contains); + + foreach (var removedId in removedIds) + { + affectedByRule[removedId].Add(app.id); + if (!hasActiveRule) + noAvailableByRule[removedId].Add(app.id); + WriteAuditLog("RULE_REMOVE_FROM_APP", "APPLICATION", app.id, $"应用{app.id}", + $"批量删除规则'{rules[removedId].RuleName}'(ID:{removedId}),已从应用关联规则中移除"); + } + } + + var items = new List(); foreach (var id in ids) { - if (!rules.TryGetValue(id, out var rule) || rule.Status == RiskRuleStatus.Deleted) + if (!changedIdSet.Contains(id)) { items.Add(BatchItem(id, BatchOperationItemStatus.Failed, "NOT_FOUND", "规则不存在或已删除")); continue; } - var referenceCount = activeApps.Count(a => ParseRuleIds(a.RuleIds).Contains(id)); - if (referenceCount > 0) - { - items.Add(BatchItem(id, BatchOperationItemStatus.Failed, "ACTIVE_APPLICATION_REFERENCE", - $"该规则仍有 {referenceCount} 个生效中的应用配置,请先停用或删除相关应用")); - continue; - } - + var rule = rules[id]; rule.Status = RiskRuleStatus.Deleted; rule.Version++; rule.UpdateOptId = UserId; rule.UpdateOptName = UserName; rule.UpdateDate = DateTime.Now; WriteAuditLog("RULE_BATCH_DELETE", "RULE", rule.id, rule.RuleName, "批量删除规则"); - changedIds.Add(id); - items.Add(BatchItem(id, BatchOperationItemStatus.Succeeded, "DELETED", "规则已删除")); + items.Add(BatchItem(id, BatchOperationItemStatus.Succeeded, "DELETED", "规则已删除", + affectedByRule[id], noAvailableByRule[id])); } if (changedIds.Any()) { - // 所有成功项一次原子提交;提交完成后,缓存失败仅告警,不改变数据库成功结果。 SaveChangesWithConcurrencyCheck(); TryRefreshCache( () => changedIds.ForEach(id => RiskEngineService.GetInstance().RefreshOneRuleCache(id)), "风控规则缓存", changedIds); + if (affectedApps.Any()) + TryRefreshCache(() => RiskEngineService.GetInstance().RefreshApplication(), + "风控应用缓存", affectedApps.Select(a => (long)a.id)); } var result = BuildBatchResult(items); @@ -1231,7 +1353,7 @@ namespace YLErp.Modules.RiskEngine } /// - /// 批量停用规则(仅 Active → Disabled,级联停用关联应用) + /// 批量停用规则(仅 Active → Disabled,不修改关联应用状态) /// public BatchOperationResult BatchDisableRules(List ruleIds) { @@ -1240,14 +1362,11 @@ namespace YLErp.Modules.RiskEngine var ids = DistinctIds(ruleIds); var rules = DbContext.glms_risk_rule.Where(r => ids.Contains(r.id)).ToDictionary(r => (long)r.id); - var activeRules = ids.Where(id => rules.TryGetValue(id, out var rule) && rule.Status == RiskRuleStatus.Active).ToList(); var activeApps = DbContext.glms_risk_rule_application - .Where(a => a.Status == RiskRuleStatus.Active) + .Where(a => a.Status == RiskRuleStatus.Active && a.RuleIds != null && a.RuleIds != "") .ToList(); - var cascadesByRule = activeRules.ToDictionary( - id => id, - id => activeApps.Where(a => ParseRuleIds(a.RuleIds).Contains(id)).Select(a => (long)a.id).Distinct().ToList()); var items = new List(); + var changedIds = new List(); foreach (var id in ids) { @@ -1262,38 +1381,28 @@ namespace YLErp.Modules.RiskEngine continue; } + var affectedApplicationIds = activeApps + .Where(a => ParseStoredRuleIds(a.RuleIds).Contains(id)) + .Select(a => (long)a.id) + .Distinct() + .ToList(); rule.Status = RiskRuleStatus.Disabled; rule.Version++; rule.UpdateOptId = UserId; rule.UpdateOptName = UserName; rule.UpdateDate = DateTime.Now; WriteAuditLog("RULE_BATCH_DISABLE", "RULE", rule.id, rule.RuleName, "批量停用规则"); - items.Add(BatchItem(id, BatchOperationItemStatus.Succeeded, "DISABLED", "规则已停用", cascadesByRule[id])); + changedIds.Add(id); + items.Add(BatchItem(id, BatchOperationItemStatus.Succeeded, "DISABLED", "规则已停用", + affectedApplicationIds)); } - // 多条规则可能关联同一应用;实际状态修改、审计和顶层级联结果均按应用 ID 去重。 - var cascadedIds = items.SelectMany(i => i.CascadedApplicationIds).Distinct().ToHashSet(); - foreach (var app in activeApps.Where(a => cascadedIds.Contains(a.id))) + if (changedIds.Any()) { - app.Status = RiskRuleStatus.Disabled; - app.Version++; - app.UpdateOptId = UserId; - app.UpdateOptName = UserName; - app.UpdateDate = DateTime.Now; - WriteAuditLog("RULE_CASCADE_DISABLE_APP", "APPLICATION", app.id, $"应用{app.id}", - $"因规则批量停用,应用配置被级联停用(规则IDs:{string.Join(",", activeRules.Where(id => cascadesByRule[id].Contains(app.id)))})"); - } - - if (items.Any(i => i.Status == BatchOperationItemStatus.Succeeded)) - { - // 规则及其级联应用在同一次数据库提交中生效。 SaveChangesWithConcurrencyCheck(); TryRefreshCache( - () => activeRules.ForEach(id => RiskEngineService.GetInstance().RefreshOneRuleCache(id)), - "风控规则缓存", activeRules); - if (cascadedIds.Any()) - TryRefreshCache(() => RiskEngineService.GetInstance().RefreshApplication(), - "风控应用缓存", cascadedIds); + () => changedIds.ForEach(id => RiskEngineService.GetInstance().RefreshOneRuleCache(id)), + "风控规则缓存", changedIds); } var result = BuildBatchResult(items); @@ -1313,14 +1422,15 @@ namespace YLErp.Modules.RiskEngine .Where(RuleIdsMatchExpr(ruleId)) .OrderByDescending(a => a.UpdateDate) .ToList(); - + var ruleStatuses = LoadRuleStatuses(apps.Select(a => a.RuleIds)); var result = new List(); foreach (var app in apps) { - result.Add(new RiskApplicationListItem + var item = new RiskApplicationListItem { Id = app.id, RuleIds = app.RuleIds, + Description = app.Description, Status = (int)app.Status, ControlStrategy = app.ControlStrategy, TriggerPoints = app.TriggerPoints, @@ -1334,7 +1444,9 @@ namespace YLErp.Modules.RiskEngine OptDate = app.OptDate.GetValueOrDefault(), UpdateOptName = app.UpdateOptName, UpdateDate = app.UpdateDate ?? app.OptDate.GetValueOrDefault() - }); + }; + PopulateApplicationAvailability(item, ruleStatuses); + result.Add(item); } return result; @@ -1363,7 +1475,7 @@ namespace YLErp.Modules.RiskEngine #region Application Management /// - /// 查询应用配置列表(多条件筛选,内存关联规则名称) + /// 查询应用配置列表(多条件筛选,批量计算关联规则统计) /// public SearchListResult QueryApplicationList(QueryRiskApplicationReq req) { @@ -1371,20 +1483,11 @@ namespace YLErp.Modules.RiskEngine .Where(a => a.Status != RiskRuleStatus.Deleted); if (req.Status.HasValue) - { query = query.Where(a => a.Status == req.Status.Value); - } - if (req.ControlStrategy.HasValue) - { query = query.Where(a => a.ControlStrategy == req.ControlStrategy.Value); - } - if (!string.IsNullOrWhiteSpace(req.TriggerPoint)) - { query = query.Where(a => a.TriggerPoints.Contains(req.TriggerPoint)); - } - if (!string.IsNullOrWhiteSpace(req.Keyword)) { query = query.Where(a => DbContext.glms_risk_rule @@ -1396,16 +1499,12 @@ namespace YLErp.Modules.RiskEngine || a.RuleIds.EndsWith("," + r.id.ToString()) || a.RuleIds.Contains("," + r.id.ToString() + ",")))); } - if (!string.IsNullOrWhiteSpace(req.ScopeClientIds)) query = ApplyScopeFilter(query, a => a.ScopeClientIds, req.ScopeClientIds); - if (!string.IsNullOrWhiteSpace(req.ScopeUnderlyingTypes)) query = ApplyScopeFilter(query, a => a.ScopeUnderlyingTypes, req.ScopeUnderlyingTypes); - if (!string.IsNullOrWhiteSpace(req.ScopeTradeTypes)) query = ApplyScopeFilter(query, a => a.ScopeTradeTypes, req.ScopeTradeTypes); - if (!string.IsNullOrWhiteSpace(req.ScopeAssetBookIds)) query = ApplyScopeFilter(query, a => a.ScopeAssetBookIds, req.ScopeAssetBookIds); @@ -1431,11 +1530,16 @@ namespace YLErp.Modules.RiskEngine }) .ToSearchList(req); + var items = pagedResult.rows?.ToList() ?? new List(); + var ruleStatuses = LoadRuleStatuses(items.Select(item => item.RuleIds)); + foreach (var item in items) + PopulateApplicationAvailability(item, ruleStatuses); + pagedResult.rows = items; return pagedResult; } /// - /// 获取应用配置详情(含关联规则信息) + /// 获取应用配置详情(含关联规则信息和可用状态) /// public RiskApplicationDetail GetApplicationDetail(long applicationId) { @@ -1444,7 +1548,7 @@ namespace YLErp.Modules.RiskEngine if (app == null) throw new ServiceException("应用配置不存在或已删除"); - return new RiskApplicationDetail + var result = new RiskApplicationDetail { Id = app.id, RuleIds = app.RuleIds, @@ -1463,6 +1567,8 @@ namespace YLErp.Modules.RiskEngine UpdateOptName = app.UpdateOptName, UpdateDate = app.UpdateDate ?? app.OptDate.GetValueOrDefault() }; + PopulateApplicationAvailability(result, LoadRuleStatuses(new[] { app.RuleIds })); + return result; } /// @@ -1636,7 +1742,7 @@ namespace YLErp.Modules.RiskEngine } /// - /// 启用应用配置(校验关联规则状态) + /// 启用应用配置(至少存在一条 Active 关联规则) /// public void EnableApplication(long applicationId) { @@ -1644,12 +1750,10 @@ namespace YLErp.Modules.RiskEngine if (app == null) throw new ServiceException("仅已停用的应用配置可以启用"); - var errors = ValidateAndEnsureRuleCache(app.RuleIds); - if (errors.Any()) - throw new ServiceException($"以下关联规则不可用:{string.Join(";", errors)}"); + if (!HasAnyActiveRule(app.RuleIds)) + throw new ServiceException("该应用下所有规则均已停用,无法启用应用。请先在规则管理中启用至少一条关联规则"); app.Status = RiskRuleStatus.Active; - app.Version = app.Version + 1; app.UpdateOptId = UserId; app.UpdateOptName = UserName; @@ -1657,7 +1761,6 @@ namespace YLErp.Modules.RiskEngine WriteAuditLog("APP_ENABLE", "APPLICATION", applicationId, ResolveRuleNames(app.RuleIds), "启用应用配置"); SaveChangesWithConcurrencyCheck(); - RiskEngineService.GetInstance().RefreshApplication(); } @@ -1684,7 +1787,7 @@ namespace YLErp.Modules.RiskEngine } /// - /// 批量启用应用配置(逐条校验关联规则) + /// 批量启用应用配置(逐条校验至少一条关联规则已启用) /// public BatchOperationResult BatchEnableApplications(List applicationIds) { @@ -1713,11 +1816,10 @@ namespace YLErp.Modules.RiskEngine continue; } - var errors = ValidateAndEnsureRuleCache(app.RuleIds); - if (errors.Any()) + if (!HasAnyActiveRule(app.RuleIds)) { - items.Add(BatchItem(id, BatchOperationItemStatus.Failed, "RELATED_RULE_UNAVAILABLE", - $"关联规则不可用:{string.Join(";", errors)}")); + items.Add(BatchItem(id, BatchOperationItemStatus.Failed, "NO_ACTIVE_RULE", + "该应用下所有规则均已停用,无法启用应用。请先在规则管理中启用至少一条关联规则")); continue; } @@ -1806,6 +1908,20 @@ namespace YLErp.Modules.RiskEngine .ToList(); } + /// + /// 生成变量删除被阻止时的引用规则提示。 + /// + private static string BuildVariableDeleteReferenceMessage(IEnumerable referencedRules) + { + var ruleIds = referencedRules + .Select(rule => rule.id) + .Distinct() + .OrderBy(id => id) + .ToList(); + + return $"该变量被 {ruleIds.Count} 个规则引用(规则ID:{string.Join("、", ruleIds)}),无法删除"; + } + /// /// 根据结构化 ConditionJson 和当前变量池定义重新生成简洁 RuleExpr。 /// @@ -1814,7 +1930,7 @@ namespace YLErp.Modules.RiskEngine var conditions = RuleConditionExpressionBuilder.DeserializeConditions(conditionJson); var variableIds = RuleConditionExpressionBuilder.GetReferencedVariableIds(conditions).ToList(); var variables = DbContext.glms_risk_variable - .Where(variable => variableIds.Contains(variable.id)) + .Where(variable => variableIds.Contains(variable.id) && variable.Status != RiskRuleStatus.Deleted) .ToList() .ToDictionary(variable => (long)variable.id); @@ -1831,7 +1947,8 @@ namespace YLErp.Modules.RiskEngine /// public SearchListResult QueryVariableList(QueryRiskVariableReq req) { - var query = DbContext.glms_risk_variable.AsQueryable(); + var query = DbContext.glms_risk_variable + .Where(v => v.Status != RiskRuleStatus.Deleted); if (req.Category.HasValue) { @@ -1848,6 +1965,11 @@ namespace YLErp.Modules.RiskEngine query = query.Where(v => v.VariableName.Contains(req.VariableName)); } + if (req.Status.HasValue) + { + query = query.Where(v => v.Status == req.Status.Value); + } + var result = query.OrderBy(v => v.SortOrder).ThenBy(v => v.VariableName) .Select(v => new RiskVariableListItem { @@ -1860,7 +1982,8 @@ namespace YLErp.Modules.RiskEngine Description = v.Description, VariableExpr = v.VariableExpr, Version = v.Version, - SortOrder = v.SortOrder + SortOrder = v.SortOrder, + Status = v.Status }); return result.ToSearchList(req); @@ -1888,6 +2011,7 @@ namespace YLErp.Modules.RiskEngine VariableExpr = variable.VariableExpr, Version = variable.Version, SortOrder = variable.SortOrder, + Status = variable.Status, OptName = variable.OptName, OptDate = variable.OptDate.GetValueOrDefault(), UpdateOptName = variable.UpdateOptName, @@ -1917,6 +2041,7 @@ namespace YLErp.Modules.RiskEngine ValueDomain = req.ValueDomain, Description = req.Description, VariableExpr = req.VariableExpr, + Status = RiskRuleStatus.Active, SortOrder = req.SortOrder, Version = 1 }; @@ -2061,18 +2186,21 @@ namespace YLErp.Modules.RiskEngine } /// - /// 删除变量(有 Active 引用时拒绝) + /// 删除变量(软删除:有未删除规则引用时拒绝) /// public void DeleteVariable(long variableId) { var variable = GetVariableOrThrow(variableId); - var activeRefCount = GetRulesByVariableId(variableId) - .Count(r => r.Status == RiskRuleStatus.Active); - if (activeRefCount > 0) - throw new ServiceException($"该变量被 {activeRefCount} 个生效中的规则引用,无法删除"); + var referencedRules = GetRulesByVariableId(variableId); + if (referencedRules.Count > 0) + throw new ServiceException(BuildVariableDeleteReferenceMessage(referencedRules)); - DbContext.glms_risk_variable.Remove(variable); + variable.Status = RiskRuleStatus.Deleted; + variable.Version = variable.Version + 1; + variable.UpdateOptId = UserId; + variable.UpdateOptName = UserName; + variable.UpdateDate = DateTime.Now; WriteAuditLog("VAR_DELETE", "VARIABLE", variableId, variable.VariableName, $"删除变量:{variable.VariableName}"); SaveChangesWithConcurrencyCheck(); @@ -2081,7 +2209,7 @@ namespace YLErp.Modules.RiskEngine } /// - /// 批量删除变量(不存在或被生效规则引用的变量将被跳过) + /// 批量删除变量(不存在或被未删除规则引用的变量将被跳过) /// public BatchOperationResult BatchDeleteVariables(List variableIds) { @@ -2089,9 +2217,11 @@ namespace YLErp.Modules.RiskEngine throw new ServiceException("变量ID列表不能为空"); var ids = DistinctIds(variableIds); - var variables = DbContext.glms_risk_variable.Where(v => ids.Contains(v.id)).ToDictionary(v => (long)v.id); - var activeRules = DbContext.glms_risk_rule - .Where(r => r.Status == RiskRuleStatus.Active && r.ConditionJson != null) + var variables = DbContext.glms_risk_variable + .Where(v => ids.Contains(v.id) && v.Status != RiskRuleStatus.Deleted) + .ToDictionary(v => (long)v.id); + var rules = DbContext.glms_risk_rule + .Where(r => r.Status != RiskRuleStatus.Deleted && r.ConditionJson != null && r.ConditionJson != "") .ToList(); var items = new List(); @@ -2099,20 +2229,25 @@ namespace YLErp.Modules.RiskEngine { if (!variables.TryGetValue(id, out var variable)) { - items.Add(BatchItem(id, BatchOperationItemStatus.Failed, "NOT_FOUND", "变量不存在")); + items.Add(BatchItem(id, BatchOperationItemStatus.Failed, "NOT_FOUND", "变量不存在或已删除")); continue; } - var activeRefCount = activeRules.Count(rule => - RuleConditionExpressionBuilder.IsVariableReferenced(rule.ConditionJson, id)); - if (activeRefCount > 0) + var referencedRules = rules.Where(rule => + RuleConditionExpressionBuilder.IsVariableReferenced(rule.ConditionJson, id)) + .ToList(); + if (referencedRules.Count > 0) { - items.Add(BatchItem(id, BatchOperationItemStatus.Failed, "ACTIVE_RULE_REFERENCE", - $"该变量被 {activeRefCount} 个生效中的规则引用,无法删除")); + items.Add(BatchItem(id, BatchOperationItemStatus.Failed, "RULE_REFERENCE", + BuildVariableDeleteReferenceMessage(referencedRules))); continue; } - DbContext.glms_risk_variable.Remove(variable); + variable.Status = RiskRuleStatus.Deleted; + variable.Version = variable.Version + 1; + variable.UpdateOptId = UserId; + variable.UpdateOptName = UserName; + variable.UpdateDate = DateTime.Now; WriteAuditLog("VAR_BATCH_DELETE", "VARIABLE", variable.id, variable.VariableName, $"批量删除变量:{variable.VariableName}"); items.Add(BatchItem(id, BatchOperationItemStatus.Succeeded, "DELETED", "变量已删除")); } @@ -2135,7 +2270,7 @@ namespace YLErp.Modules.RiskEngine public List GetAllVariableList() { var result = DbContext.glms_risk_variable - .Where(v => v.VariableExpr != null && v.VariableExpr != "") + .Where(v => v.VariableExpr != null && v.VariableExpr != "" && v.Status != RiskRuleStatus.Deleted) .OrderBy(v => v.SortOrder) .Select(v => new RiskVariableSimpleItem { @@ -2144,7 +2279,8 @@ namespace YLErp.Modules.RiskEngine Category = v.Category, DataType = v.DataType, Unit = v.Unit, - VariableExpr = v.VariableExpr + VariableExpr = v.VariableExpr, + Status = v.Status }) .ToList(); @@ -2331,4 +2467,4 @@ namespace YLErp.Modules.RiskEngine #endregion } -} \ No newline at end of file +} diff --git a/YLErpWeb/Controllers/RiskRuleController.cs b/YLErpWeb/Controllers/RiskRuleController.cs index b5730fd6..643776bb 100644 --- a/YLErpWeb/Controllers/RiskRuleController.cs +++ b/YLErpWeb/Controllers/RiskRuleController.cs @@ -145,8 +145,8 @@ namespace YLErp.Web.Controllers try { var service = GetRiskRuleService(); - await Task.Run(() => service.DeleteRule(id)); - return Json(new { success = true }); + var result = await Task.Run(() => service.DeleteRule(id)); + return Json(new { success = true, data = result }); } catch (ServiceException ex) { @@ -215,8 +215,7 @@ namespace YLErp.Web.Controllers return Json(new { success = true, - cascadedAppIds = result.CascadedApplicationIds, - cascadedAppCount = result.CascadedApplicationIds.Count + affectedActiveAppCount = result.AffectedActiveAppCount }); } catch (ServiceException ex) @@ -658,7 +657,7 @@ namespace YLErp.Web.Controllers [HttpDelete("risk-variables/{id}")] [MyAuthorize("风险控制-风控变量删除")] - /// 删除变量 + /// 删除变量(软删除) public async Task DeleteRiskVariable(long id) { try @@ -680,7 +679,7 @@ namespace YLErp.Web.Controllers [HttpDelete("risk-variables/batch")] [MyAuthorize("风险控制-风控变量删除")] - /// 批量删除变量 + /// 批量删除变量(软删除) public async Task BatchDeleteRiskVariables([FromBody] BatchIdsReq req) { try From c77d44b54a36754a818265c60ac28a4f481e2c08 Mon Sep 17 00:00:00 2001 From: ruisu Date: Tue, 28 Jul 2026 16:45:32 +0800 Subject: [PATCH 7/8] =?UTF-8?q?feat:=E6=94=AF=E6=8C=81=E5=8F=98=E9=87=8F?= =?UTF-8?q?=E7=9A=84=E8=A1=A8=E8=BE=BE=E5=BC=8F=E5=85=B7=E4=BD=93=E4=B8=9A?= =?UTF-8?q?=E5=8A=A1=E5=80=BC=E6=8A=A5=E8=AD=A6=E3=80=82=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E6=80=A7=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RiskEngine/Compile/RuleCompiler.cs | 2 +- .../Modules/RiskEngine/RiskEngineService.cs | 70 +++-- YLErpDAL/Modules/RiskEngine/RiskRule.cs | 8 + .../Modules/RiskEngine/RiskRuleService.cs | 23 +- .../RiskEngine/StructuredRuleExecutor.cs | 272 +++++++++++++++++- YLErpDAL/Modules/RiskEngine/测试用例.md | 99 ++++++- .../Modules/RiskModule/QuotaMonitorService.cs | 34 ++- 7 files changed, 454 insertions(+), 54 deletions(-) diff --git a/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs b/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs index 5d7be9d3..084b1e3f 100644 --- a/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs +++ b/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs @@ -366,7 +366,7 @@ namespace YLErp.Modules.RiskEngine typeof(Microsoft.EntityFrameworkCore.DbContext).Assembly, typeof(Queryable).Assembly ) - .WithImports("System", "System.Linq", "Newtonsoft.Json", "YLErp.DBModels", "YLErp.QdpModule"); + .WithImports("System", "System.Linq", "Newtonsoft.Json", "YLErp.DBModels", "YLErp.QdpModule", "YLErp.Modules.RiskEngine"); // 创建脚本(尚未执行,仅编译) var script = CSharpScript.Create(scriptCode, options, globalsType: typeof(ScriptGlobals)); diff --git a/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs b/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs index 5bf02e41..58e95978 100644 --- a/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs +++ b/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs @@ -174,7 +174,9 @@ namespace YLErp.Modules.RiskEngine RuleCompiledCache.Remove(rule.Id.ToString()); try { - RuleConditionExpressionBuilder.DeserializeConditions(rule.ConditionJson); + // 结构化条件在加载阶段已解析到 ParsedConditions;这里仅确认缓存存在,运行时不再反序列化 ConditionJson。 + if (rule.ParsedConditions == null || rule.ParsedConditions.Count == 0) + rule.ParsedConditions = RuleConditionExpressionBuilder.DeserializeConditions(rule.ConditionJson); _logger.Info($"[风控引擎] 结构化规则预热校验成功,跳过 RuleExpr 预编译 - RuleId: {rule.Id}"); } catch (Exception ex) @@ -214,6 +216,7 @@ namespace YLErp.Modules.RiskEngine _cachedRules = null; _cachedApplications = null; RuleCompiledCache.Clear(); + StructuredRuleExecutor.ClearVariableCompiledCache(); } Preload(); @@ -250,7 +253,8 @@ namespace YLErp.Modules.RiskEngine RuleCompiledCache.Remove(ruleId.ToString()); try { - RuleConditionExpressionBuilder.DeserializeConditions(rule.ConditionJson); + // 单规则刷新时同步刷新结构化条件缓存,避免后续执行继续解析旧 JSON 或重复反序列化。 + rule.ParsedConditions = RuleConditionExpressionBuilder.DeserializeConditions(rule.ConditionJson); _logger.Info($"[风控引擎] RefreshOneRuleCache 结构化规则校验成功,跳过 RuleExpr 编译 - RuleId: {ruleId}"); return RuleCompileResult.Ok(null); } @@ -353,6 +357,8 @@ namespace YLErp.Modules.RiskEngine // ============================================================ var rules = GetRules(); var applications = GetApplications(); + // 规则列表在本次执行内转为字典,应用按 RuleIds 关联规则时直接按 ID 查找,避免每个应用重复扫描规则列表。 + var ruleMap = rules.ToDictionary(r => r.Id); var variables = new Dictionary(); _logger.Info($"[风控引擎] 加载规则数: {rules.Count}, 应用数: {applications.Count}"); @@ -402,24 +408,23 @@ namespace YLErp.Modules.RiskEngine continue; } - var applicationRules = rules - .Where(r => applicationRuleIds.Contains(r.Id) && r.Status == RiskRuleStatus.Active) - .ToList(); - - // 区分"规则不存在"与"规则非活跃"两种情况,分别记录日志 - var ruleDict = rules.Where(r => applicationRuleIds.Contains(r.Id)) - .ToDictionary(r => r.Id); - + var applicationRules = new List(); foreach (var ruleId in applicationRuleIds) { - if (!ruleDict.TryGetValue(ruleId, out var ruleDef)) + // 通过本次执行预构建的规则字典按 ID 查找,避免每个应用都 Where 扫描全量规则列表。 + if (!ruleMap.TryGetValue(ruleId, out var ruleDef)) { _logger.Info($"[风控引擎] 未找到对应规则定义 - RuleId: {ruleId}, ApplicationRuleIds: {application.RuleIds}"); + continue; } - else if (ruleDef.Status != RiskRuleStatus.Active) + + if (ruleDef.Status != RiskRuleStatus.Active) { _logger.Info($"[风控引擎] 规则非活跃,跳过执行 - RuleId: {ruleId}, Status: {ruleDef.Status}, ApplicationRuleIds: {application.RuleIds}"); + continue; } + + applicationRules.Add(ruleDef); } foreach (var rule in applicationRules) @@ -427,12 +432,17 @@ namespace YLErp.Modules.RiskEngine var ruleId = rule.Id.ToString(); bool triggered = false; + StructuredRuleExecuteResult executeResult = null; if (!string.IsNullOrWhiteSpace(rule.ConditionJson)) { // 结构化规则优先使用 ConditionJson 执行,避免继续把条件整体拼成 Roslyn bool 公式。 try { - var conditions = RuleConditionExpressionBuilder.DeserializeConditions(rule.ConditionJson); + // 结构化条件在规则加载/刷新时已解析到内存缓存,运行时直接复用,避免每次执行反序列化 ConditionJson。 + var conditions = rule.ParsedConditions; + if (conditions == null || conditions.Count == 0) + throw new InvalidOperationException("结构化规则条件缓存为空"); + var referencedVariableIds = RuleConditionExpressionBuilder.GetReferencedVariableIds(conditions); var missingVariableIds = referencedVariableIds .Where(id => !variables.ContainsKey(id)) @@ -452,7 +462,7 @@ namespace YLErp.Modules.RiskEngine .Where(variables.ContainsKey) .ToDictionary(id => id, id => variables[id]); - var executeResult = StructuredRuleExecutor.Execute(conditions, ruleVariables, context); + executeResult = StructuredRuleExecutor.Execute(conditions, ruleVariables, context); if (!executeResult.Success) { var errorMessage = $"规则[{rule.RuleName}]执行异常:{executeResult.ErrorMessage}"; @@ -516,6 +526,8 @@ namespace YLErp.Modules.RiskEngine if (triggered) { _logger.Info($"[风控引擎] 规则触发 - RuleId: {rule.Id}, Strategy: {application.ControlStrategy}"); + // 结构化规则执行器会返回实际值、阈值和变量计算明细;纯文本规则没有该信息时保持原提示。 + var triggerMessageDetail = executeResult?.Message; switch (application.ControlStrategy) { @@ -528,7 +540,7 @@ namespace YLErp.Modules.RiskEngine RuleName = rule.RuleName, ControlStrategy = RiskControlStrategy.Block, RuleText = rule.RuleText, - Message = $"规则[{rule.RuleName}]触发:禁止" + Message = BuildTriggeredMessage($"规则[{rule.RuleName}]触发:禁止", triggerMessageDetail) }); break; @@ -541,7 +553,7 @@ namespace YLErp.Modules.RiskEngine RuleName = rule.RuleName, ControlStrategy = RiskControlStrategy.Approval, RuleText = rule.RuleText, - Message = $"规则[{rule.RuleName}]触发:需审批" + Message = BuildTriggeredMessage($"规则[{rule.RuleName}]触发:需审批", triggerMessageDetail) }); break; @@ -553,7 +565,7 @@ namespace YLErp.Modules.RiskEngine RuleName = rule.RuleName, ControlStrategy = RiskControlStrategy.ShowTip, RuleText = rule.RuleText, - Message = $"规则[{rule.RuleName}]触发:提示" + Message = BuildTriggeredMessage($"规则[{rule.RuleName}]触发:提示", triggerMessageDetail) }); break; @@ -593,6 +605,13 @@ namespace YLErp.Modules.RiskEngine return result; } + private static string BuildTriggeredMessage(string baseMessage, string detailMessage) + { + return string.IsNullOrWhiteSpace(detailMessage) + ? baseMessage + : $"{baseMessage}。{detailMessage}"; + } + private static void AddBlockError(RiskResult result, string ruleId, string ruleName, string ruleText, string errorMessage) { result.Blocked = true; @@ -635,7 +654,22 @@ namespace YLErp.Modules.RiskEngine UpdateOptId = r.UpdateOptId ?? 0, UpdateOptName = r.UpdateOptName, UpdateDate = r.UpdateDate ?? r.OptDate ?? DateTime.MinValue - }).ToList() ; + }).ToList(); + + // 结构化规则的 ConditionJson 在加载阶段解析一次并缓存到规则对象,运行时直接复用 ParsedConditions。 + // 如果历史脏数据解析失败,不中断整批规则加载,后续执行该规则时仍按单条规则异常处理。 + foreach (var rule in rules.Where(r => !string.IsNullOrWhiteSpace(r.ConditionJson))) + { + try + { + rule.ParsedConditions = RuleConditionExpressionBuilder.DeserializeConditions(rule.ConditionJson); + } + catch (Exception ex) + { + _logger.Info($"[风控引擎] 结构化规则条件解析失败 - RuleId: {rule.Id}, Error: {ex.Message}"); + } + } + return rules; //#region 测试本地规则 //rules.Add(new RiskRule diff --git a/YLErpDAL/Modules/RiskEngine/RiskRule.cs b/YLErpDAL/Modules/RiskEngine/RiskRule.cs index 7b5375b6..c4e298d0 100644 --- a/YLErpDAL/Modules/RiskEngine/RiskRule.cs +++ b/YLErpDAL/Modules/RiskEngine/RiskRule.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Generic; +using YLErp.Modules.RiskEngine.Dto; namespace YLErp.Modules.RiskEngine { @@ -47,5 +49,11 @@ namespace YLErp.Modules.RiskEngine /// 判风控时直接调用:rule.CompiledScript(context) → bool /// public Func CompiledScript { get; set; } + + /// + /// 结构化规则已解析条件缓存。 + /// 规则加载/刷新时由 ConditionJson 解析一次,运行时直接复用,避免每次风控执行反序列化 JSON。 + /// + public IReadOnlyList ParsedConditions { get; set; } } } diff --git a/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs b/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs index bad1b920..47f6b25b 100644 --- a/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs +++ b/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs @@ -353,12 +353,15 @@ namespace YLErp.Modules.RiskEngine } /// - /// 校验 ConditionJson 结构和引用变量是否存在。 + /// 校验 ConditionJson 结构、引用变量以及操作符/阈值/类型一致性。 /// 结构化规则运行时以 ConditionJson 为准,RuleExpr 只作为兼容/展示字段。 /// private void ValidateConditionJson(string conditionJson) { - ValidateConditionJsonVariables(conditionJson); + var conditions = RuleConditionExpressionBuilder.DeserializeConditions(conditionJson); + var variables = ValidateConditionJsonVariables(conditions); + var variableMap = variables.ToDictionary(variable => (long)variable.id); + RuleConditionExpressionBuilder.Build(conditions, variableMap); } /// /// 校验 RuleExpr(自由文本模式:括号匹配) @@ -1812,11 +1815,10 @@ namespace YLErp.Modules.RiskEngine } /// - /// 校验结构化 ConditionJson 引用的变量是否存在。 + /// 校验结构化条件引用的变量是否存在。 /// - private List ValidateConditionJsonVariables(string conditionJson) + private List ValidateConditionJsonVariables(IReadOnlyList conditions) { - var conditions = RuleConditionExpressionBuilder.DeserializeConditions(conditionJson); var variableIds = RuleConditionExpressionBuilder.GetReferencedVariableIds(conditions).ToList(); var variables = DbContext.glms_risk_variable .Where(variable => variableIds.Contains(variable.id)) @@ -2053,6 +2055,7 @@ namespace YLErp.Modules.RiskEngine } InvalidateVariableCache(); + StructuredRuleExecutor.RemoveVariableCompiledCache(variableId); foreach (var rule in affectedRules) { RiskEngineService.GetInstance().RefreshOneRuleCache(rule.id); @@ -2063,6 +2066,7 @@ namespace YLErp.Modules.RiskEngine WriteAuditLog("VAR_UPDATE", "VARIABLE", variableId, req.VariableName, $"修改变量:{variable.VariableName}"); SaveChangesWithConcurrencyCheck(); InvalidateVariableCache(); + StructuredRuleExecutor.RemoveVariableCompiledCache(variableId); } return GetVariableDetail(variableId); @@ -2086,6 +2090,7 @@ namespace YLErp.Modules.RiskEngine SaveChangesWithConcurrencyCheck(); InvalidateVariableCache(); + StructuredRuleExecutor.RemoveVariableCompiledCache(variableId); } /// @@ -2129,8 +2134,12 @@ namespace YLErp.Modules.RiskEngine { // 只有实际删除项写审计并参与一次提交;缺失和引用阻塞项不影响其他变量。 SaveChangesWithConcurrencyCheck(); - TryRefreshCache(InvalidateVariableCache, "风控变量缓存", - items.Where(i => i.Status == BatchOperationItemStatus.Succeeded).Select(i => i.Id)); + var changedIds = items.Where(i => i.Status == BatchOperationItemStatus.Succeeded).Select(i => i.Id).ToList(); + TryRefreshCache(() => + { + InvalidateVariableCache(); + StructuredRuleExecutor.RemoveVariableCompiledCache(changedIds); + }, "风控变量缓存", changedIds); } var result = BuildBatchResult(items); LogBatchResult("批量删除变量", result); diff --git a/YLErpDAL/Modules/RiskEngine/StructuredRuleExecutor.cs b/YLErpDAL/Modules/RiskEngine/StructuredRuleExecutor.cs index af9e7171..6f6be1cb 100644 --- a/YLErpDAL/Modules/RiskEngine/StructuredRuleExecutor.cs +++ b/YLErpDAL/Modules/RiskEngine/StructuredRuleExecutor.cs @@ -16,18 +16,76 @@ namespace YLErp.Modules.RiskEngine /// public static class StructuredRuleExecutor { + /// + /// 变量表达式编译缓存。Key 包含变量 ID、版本、更新时间和表达式哈希,避免变量变更后复用旧委托。 + /// private static readonly ConcurrentDictionary> VariableCompiledCache = new ConcurrentDictionary>(); + /// + /// 按变量 ID 增量移除变量表达式编译缓存。 + /// + public static void RemoveVariableCompiledCache(long variableId) + { + var keyPrefix = variableId + ":"; + // 缓存 Key 以变量 ID 开头,变量修改/删除时只清理该变量相关的编译结果。 + foreach (var key in VariableCompiledCache.Keys.Where(k => k.StartsWith(keyPrefix, StringComparison.Ordinal))) + { + VariableCompiledCache.TryRemove(key, out _); + } + } + + /// + /// 按变量 ID 集合批量移除变量表达式编译缓存。 + /// + public static void RemoveVariableCompiledCache(IEnumerable variableIds) + { + if (variableIds == null) + return; + + var idSet = new HashSet(variableIds.Distinct()); + if (idSet.Count == 0) + return; + + // 批量移除时只扫描一次缓存 Key,避免变量数较多时重复遍历整个缓存。 + foreach (var key in VariableCompiledCache.Keys) + { + var separatorIndex = key.IndexOf(':'); + if (separatorIndex <= 0) + continue; + if (long.TryParse(key.Substring(0, separatorIndex), NumberStyles.None, CultureInfo.InvariantCulture, out var variableId) && idSet.Contains(variableId)) + { + VariableCompiledCache.TryRemove(key, out _); + } + } + } + + /// + /// 清空全部变量表达式编译缓存。仅用于风控缓存全量刷新场景。 + /// + public static void ClearVariableCompiledCache() + { + VariableCompiledCache.Clear(); + } + + /// + /// 数值和日期类型支持的比较操作符。 + /// private static readonly HashSet ComparableOperators = new HashSet(StringComparer.Ordinal) { "gt", "lt", "gte", "lte", "eq", "ne", "between", "notBetween" }; + /// + /// 布尔类型支持的判断操作符。 + /// private static readonly HashSet BooleanOperators = new HashSet(StringComparer.Ordinal) { "isTrue", "isFalse" }; + /// + /// 执行一组结构化条件。当前规则条件之间按 AND 关系处理,任一条件不满足则整条规则不触发。 + /// public static StructuredRuleExecuteResult Execute( IReadOnlyList conditions, IReadOnlyDictionary variables, @@ -40,16 +98,21 @@ namespace YLErp.Modules.RiskEngine if (variables == null) return StructuredRuleExecuteResult.Fail("变量列表不能为空"); + var triggerMessages = new List(); for (var i = 0; i < conditions.Count; i++) { var conditionResult = ExecuteCondition(conditions[i], variables, context, i + 1); if (!conditionResult.Success) return conditionResult; + // 结构化规则当前按 AND 执行,任一条件不命中即可短路返回,避免后续变量表达式继续查库。 if (!conditionResult.Triggered) return StructuredRuleExecuteResult.Ok(false); + if (!string.IsNullOrWhiteSpace(conditionResult.Message)) + triggerMessages.Add(conditionResult.Message); } - return StructuredRuleExecuteResult.Ok(true); + // 所有条件都命中时,合并每个条件的实际值/阈值说明,供最终风控结果展示。 + return StructuredRuleExecuteResult.Ok(true, string.Join(";", triggerMessages)); } catch (Exception ex) { @@ -57,6 +120,9 @@ namespace YLErp.Modules.RiskEngine } } + /// + /// 执行单个结构化条件,负责按变量数据类型分派到布尔、普通比较或区间比较逻辑。 + /// private static StructuredRuleExecuteResult ExecuteCondition( RuleCondition condition, IReadOnlyDictionary variables, @@ -69,6 +135,7 @@ namespace YLErp.Modules.RiskEngine if (!variables.TryGetValue(condition.VariableId, out var variable)) return StructuredRuleExecuteResult.Fail($"{label}:变量 ID '{condition.VariableId}' 不存在"); + // 布尔变量没有阈值概念,只能走 isTrue/isFalse 分支,避免被后续数值/日期比较逻辑误处理。 if (variable.DataType == RiskVariableDataType.Boolean) return ExecuteBooleanCondition(condition, variable, context, label); @@ -77,12 +144,14 @@ namespace YLErp.Modules.RiskEngine if (!ComparableOperators.Contains(condition.Operator)) return StructuredRuleExecuteResult.Fail($"{label}:操作符 '{condition.Operator}' 不适用于{GetDataTypeName(variable.DataType)}类型变量"); + // 左侧条件变量统一先执行并转换成声明的数据类型;后续普通比较和区间比较都复用这个结果。 var left = GetVariableValue(variable, variable.DataType, context, label, "条件变量"); if (!left.Success) return StructuredRuleExecuteResult.Fail(left.ErrorMessage); + // 区间操作符使用下限/上限字段,不能继续走普通阈值字段。 if (condition.Operator == "between" || condition.Operator == "notBetween") - return ExecuteRangeCondition(condition, variable, left.Value, variables, context, label); + return ExecuteRangeCondition(condition, variable, left, variables, context, label); var rangeFieldCheck = EnsureNoRangeFields(condition, label); if (!rangeFieldCheck.Success) @@ -104,9 +173,15 @@ namespace YLErp.Modules.RiskEngine if (!compareResult.Success) return compareResult; - return StructuredRuleExecuteResult.Ok(compareResult.Triggered); + return StructuredRuleExecuteResult.Ok( + compareResult.Triggered, + // 未命中时不构造提示,减少字符串拼接开销;只有真正触发才展示实际值和阈值。 + compareResult.Triggered ? BuildCompareMessage(variable, left, right, condition.Operator) : null); } + /// + /// 执行布尔条件。布尔变量只允许 isTrue/isFalse,不允许配置固定值、变量阈值或区间字段。 + /// private static StructuredRuleExecuteResult ExecuteBooleanCondition( RuleCondition condition, glms_risk_variable variable, @@ -124,13 +199,19 @@ namespace YLErp.Modules.RiskEngine return StructuredRuleExecuteResult.Fail(value.ErrorMessage); var expected = condition.Operator == "isTrue"; - return StructuredRuleExecuteResult.Ok((bool)value.Value == expected); + var triggered = (bool)value.Value == expected; + return StructuredRuleExecuteResult.Ok( + triggered, + triggered ? BuildBooleanMessage(variable, value, expected) : null); } + /// + /// 执行区间条件。下限和上限可以分别来自固定值或变量,并根据开闭区间转换为对应比较操作符。 + /// private static StructuredRuleExecuteResult ExecuteRangeCondition( RuleCondition condition, glms_risk_variable variable, - object leftValue, + ValueExecuteResult left, IReadOnlyDictionary variables, RiskContext context, string label) @@ -140,6 +221,7 @@ namespace YLErp.Modules.RiskEngine if (!condition.IncludeLower.HasValue || !condition.IncludeUpper.HasValue) return StructuredRuleExecuteResult.Fail($"{label}:区间开闭配置不完整"); + // 固定上下限可以提前比较顺序;变量上下限依赖运行时查询结果,留到后面按实际值比较。 var fixedRangeCheck = ValidateFixedRangeOrder(condition, variable.DataType, label); if (!fixedRangeCheck.Success) return fixedRangeCheck; @@ -152,19 +234,27 @@ namespace YLErp.Modules.RiskEngine if (!upper.Success) return StructuredRuleExecuteResult.Fail(upper.ErrorMessage); + // 将区间开闭配置转换为普通比较:闭区间使用 >=/<=,开区间使用 >/<。 var lowerOperator = condition.IncludeLower == true ? "gte" : "gt"; var upperOperator = condition.IncludeUpper == true ? "lte" : "lt"; - var lowerCompare = Compare(leftValue, lower.Value, variable.DataType, lowerOperator, label); + var lowerCompare = Compare(left.Value, lower.Value, variable.DataType, lowerOperator, label); if (!lowerCompare.Success) return lowerCompare; - var upperCompare = Compare(leftValue, upper.Value, variable.DataType, upperOperator, label); + var upperCompare = Compare(left.Value, upper.Value, variable.DataType, upperOperator, label); if (!upperCompare.Success) return upperCompare; var inRange = lowerCompare.Triggered && upperCompare.Triggered; - return StructuredRuleExecuteResult.Ok(condition.Operator == "notBetween" ? !inRange : inRange); + // between 要求落在区间内,notBetween 则取反;提示同样只在命中时构造。 + var triggered = condition.Operator == "notBetween" ? !inRange : inRange; + return StructuredRuleExecuteResult.Ok( + triggered, + triggered ? BuildRangeMessage(variable, left, lower, upper, condition.IncludeLower == true, condition.IncludeUpper == true, condition.Operator) : null); } + /// + /// 获取阈值。固定阈值直接转换;变量阈值先校验类型和单位,再执行阈值变量表达式。 + /// private static ValueExecuteResult GetThresholdValue( string thresholdType, object fixedValue, @@ -195,6 +285,7 @@ namespace YLErp.Modules.RiskEngine return ValueExecuteResult.Fail($"{label}:{thresholdLabel}变量 ID '{thresholdVariableId.Value}' 不存在"); if (thresholdVariable.DataType != conditionVariable.DataType) return ValueExecuteResult.Fail($"{label}:{thresholdLabel}变量与条件变量的数据类型不一致"); + // 数值变量允许配置单位;变量阈值必须与条件变量单位一致,避免“金额”和“比例”等不同口径被直接比较。 if (conditionVariable.DataType == RiskVariableDataType.Numeric && !UnitsMatch(conditionVariable.Unit, thresholdVariable.Unit)) return ValueExecuteResult.Fail($"{label}:{thresholdLabel}变量与条件变量的单位不一致"); @@ -204,6 +295,9 @@ namespace YLErp.Modules.RiskEngine return ValueExecuteResult.Fail($"{label}:{thresholdLabel}类型 '{thresholdType}' 不合法,仅支持 Fixed/Variable"); } + /// + /// 执行变量取值表达式,并将表达式返回值转换为变量声明的数据类型。 + /// private static ValueExecuteResult GetVariableValue( glms_risk_variable variable, RiskVariableDataType dataType, @@ -228,13 +322,21 @@ namespace YLErp.Modules.RiskEngine return ValueExecuteResult.Fail($"{label}:变量'{variable.VariableName}'(ID:{variable.id})执行失败:{ex.Message}"); } - var converted = ConvertValue(rawValue, dataType, label, valueLabel); + // 变量表达式可选择返回 RiskVariableValueDetail:Value 参与比较,Message 只用于命中说明。 + // 普通变量表达式仍返回原始值,不需要修改历史变量配置。 + var detail = rawValue as RiskVariableValueDetail; + var actualRawValue = detail?.Value ?? rawValue; + var converted = ConvertValue(actualRawValue, dataType, label, valueLabel); if (!converted.Success) - return ValueExecuteResult.Fail($"{label}:变量'{variable.VariableName}'(ID:{variable.id}){converted.ErrorMessage},原始值:{FormatRawValue(rawValue)}"); + return ValueExecuteResult.Fail($"{label}:变量'{variable.VariableName}'(ID:{variable.id}){converted.ErrorMessage},原始值:{FormatRawValue(actualRawValue)}"); + converted.DetailMessage = detail?.Message; return converted; } + /// + /// 获取变量表达式的编译委托。缓存 Key 绑定变量版本信息,变量内容变化后会自然生成新 Key。 + /// private static Func GetCompiledVariableExpression(glms_risk_variable variable, out string errorMessage) { var cacheKey = $"{variable.id}:{variable.Version}:{variable.UpdateDate?.Ticks ?? 0}:{StringComparer.Ordinal.GetHashCode(variable.VariableExpr ?? string.Empty)}"; @@ -251,6 +353,9 @@ namespace YLErp.Modules.RiskEngine return compiled; } + /// + /// 按变量数据类型统一转换值。日期固定值只接受 yyyy-MM-dd,避免不同区域格式导致比较结果不一致。 + /// private static ValueExecuteResult ConvertValue(object value, RiskVariableDataType dataType, string label, string valueLabel) { if (!HasValue(value)) @@ -260,6 +365,7 @@ namespace YLErp.Modules.RiskEngine { if (value is decimal decimalValue) return ValueExecuteResult.Ok(decimalValue); + // 数据库字段可能返回 double/int/string/JValue,统一按 InvariantCulture 转 decimal,避免服务器区域设置影响小数点解析。 if (decimal.TryParse(GetRawValue(value), NumberStyles.Float, CultureInfo.InvariantCulture, out var numericValue)) return ValueExecuteResult.Ok(numericValue); return ValueExecuteResult.Fail($"{valueLabel}必须为数字"); @@ -286,8 +392,12 @@ namespace YLErp.Modules.RiskEngine return ValueExecuteResult.Fail($"{label}:不支持的数据类型"); } + /// + /// 比较两个已转换为同一数据类型的值,并应用结构化规则操作符。 + /// private static StructuredRuleExecuteResult Compare(object left, object right, RiskVariableDataType dataType, string ruleOperator, string label) { + // 前置 ConvertValue 已保证左右值类型一致,这里只做最终大小关系计算。 var compare = dataType switch { RiskVariableDataType.Numeric => ((decimal)left).CompareTo((decimal)right), @@ -309,6 +419,59 @@ namespace YLErp.Modules.RiskEngine return StructuredRuleExecuteResult.Ok(result); } + /// + /// 构建普通比较条件的命中说明。变量表达式如果返回 RiskVariableValueDetail,会优先带出自定义明细。 + /// + private static string BuildCompareMessage(glms_risk_variable variable, ValueExecuteResult left, ValueExecuteResult right, string ruleOperator) + { + var parts = new List(); + AddDetail(parts, left.DetailMessage); + parts.Add($"{variable.VariableName}为{FormatDisplayValue(left.Value)}"); + AddDetail(parts, right.DetailMessage); + parts.Add($"阈值为{FormatDisplayValue(right.Value)}"); + parts.Add($"比较关系:{GetOperatorName(ruleOperator)}"); + return string.Join(",", parts); + } + + /// + /// 构建区间条件的命中说明,包含实际值、上下限和区间开闭配置。 + /// + private static string BuildRangeMessage(glms_risk_variable variable, ValueExecuteResult left, ValueExecuteResult lower, ValueExecuteResult upper, bool includeLower, bool includeUpper, string ruleOperator) + { + var parts = new List(); + AddDetail(parts, left.DetailMessage); + parts.Add($"{variable.VariableName}为{FormatDisplayValue(left.Value)}"); + AddDetail(parts, lower.DetailMessage); + AddDetail(parts, upper.DetailMessage); + parts.Add($"阈值区间为{(includeLower ? "[" : "(")}{FormatDisplayValue(lower.Value)}, {FormatDisplayValue(upper.Value)}{(includeUpper ? "]" : ")")}"); + parts.Add(ruleOperator == "notBetween" ? "要求不在区间内" : "要求在区间内"); + return string.Join(",", parts); + } + + /// + /// 构建布尔条件的命中说明。 + /// + private static string BuildBooleanMessage(glms_risk_variable variable, ValueExecuteResult value, bool expected) + { + var parts = new List(); + AddDetail(parts, value.DetailMessage); + parts.Add($"{variable.VariableName}为{FormatDisplayValue(value.Value)}"); + parts.Add($"期望为{FormatDisplayValue(expected)}"); + return string.Join(",", parts); + } + + /// + /// 添加变量表达式返回的自定义明细,避免空明细污染最终提示。 + /// + private static void AddDetail(List parts, string detailMessage) + { + if (!string.IsNullOrWhiteSpace(detailMessage)) + parts.Add(detailMessage.Trim()); + } + + /// + /// 校验非区间条件没有携带区间字段,避免前端残留字段影响规则语义。 + /// private static StructuredRuleExecuteResult EnsureNoRangeFields(RuleCondition condition, string label) { if (condition.LowerThresholdType != null || HasValue(condition.LowerValue) || condition.LowerThresholdVariableId.HasValue || @@ -321,6 +484,9 @@ namespace YLErp.Modules.RiskEngine return StructuredRuleExecuteResult.Ok(false); } + /// + /// 校验布尔条件没有携带阈值或区间字段,布尔判断只由变量值和 isTrue/isFalse 决定。 + /// private static StructuredRuleExecuteResult EnsureNoThresholdFields(RuleCondition condition, string label) { if (condition.ThresholdType != null || HasValue(condition.Value) || condition.ThresholdVariableId.HasValue || @@ -334,6 +500,9 @@ namespace YLErp.Modules.RiskEngine return StructuredRuleExecuteResult.Ok(false); } + /// + /// 当区间上下限都是固定值时,提前校验下限不能大于上限;变量上下限留到运行时按实际值比较。 + /// private static StructuredRuleExecuteResult ValidateFixedRangeOrder(RuleCondition condition, RiskVariableDataType dataType, string label) { if (condition.LowerThresholdType != "Fixed" || condition.UpperThresholdType != "Fixed") @@ -360,6 +529,9 @@ namespace YLErp.Modules.RiskEngine return StructuredRuleExecuteResult.Ok(false); } + /// + /// 判断普通对象或 JObject/JValue 字段是否有有效值,空字符串、null、undefined 都视为无值。 + /// private static bool HasValue(object value) { if (value == null) return false; @@ -372,6 +544,9 @@ namespace YLErp.Modules.RiskEngine return value is not string text || !string.IsNullOrWhiteSpace(text); } + /// + /// 使用固定区域性取出原始字符串,保证数字和日期解析不受服务器区域设置影响。 + /// private static string GetRawValue(object value) { return value is JValue jsonValue @@ -379,6 +554,9 @@ namespace YLErp.Modules.RiskEngine : Convert.ToString(value, CultureInfo.InvariantCulture); } + /// + /// 格式化错误信息中的原始值,便于区分 null、空字符串和普通值。 + /// private static string FormatRawValue(object value) { if (value == null) @@ -387,11 +565,48 @@ namespace YLErp.Modules.RiskEngine return string.IsNullOrWhiteSpace(rawValue) ? "" : rawValue; } + /// + /// 格式化命中说明中的值,日期只展示日期部分,布尔值转为中文。 + /// + private static string FormatDisplayValue(object value) + { + if (value == null) + return ""; + if (value is DateTime dateValue) + return dateValue.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); + if (value is bool boolValue) + return boolValue ? "是" : "否"; + return Convert.ToString(value, CultureInfo.InvariantCulture); + } + + /// + /// 获取操作符中文说明,用于正常命中时解释实际值与阈值的关系。 + /// + private static string GetOperatorName(string ruleOperator) + { + return ruleOperator switch + { + "gt" => "大于阈值", + "lt" => "小于阈值", + "gte" => "大于等于阈值", + "lte" => "小于等于阈值", + "eq" => "等于阈值", + "ne" => "不等于阈值", + _ => ruleOperator + }; + } + + /// + /// 比较数值变量单位。空单位按空字符串处理,前后空格不参与比较。 + /// private static bool UnitsMatch(string left, string right) { return string.Equals(left?.Trim() ?? string.Empty, right?.Trim() ?? string.Empty, StringComparison.Ordinal); } + /// + /// 获取数据类型中文名称,用于拼接用户可读的错误信息。 + /// private static string GetDataTypeName(RiskVariableDataType dataType) { return dataType switch @@ -404,6 +619,9 @@ namespace YLErp.Modules.RiskEngine } } + /// + /// 结构化规则执行结果。Success 表示执行过程是否成功,Triggered 表示规则条件是否命中。 + /// public class StructuredRuleExecuteResult { public bool Success { get; set; } @@ -412,12 +630,18 @@ namespace YLErp.Modules.RiskEngine public string ErrorMessage { get; set; } - public static StructuredRuleExecuteResult Ok(bool triggered) + /// + /// 正常命中时的可读说明,用于展示实际值、阈值和变量表达式返回的计算明细。 + /// + public string Message { get; set; } + + public static StructuredRuleExecuteResult Ok(bool triggered, string message = null) { return new StructuredRuleExecuteResult { Success = true, - Triggered = triggered + Triggered = triggered, + Message = message }; } @@ -432,12 +656,36 @@ namespace YLErp.Modules.RiskEngine } } + /// + /// 变量表达式可选返回模型。Value 参与规则比较,Message 用于正常命中时展示计算明细。 + /// + public class RiskVariableValueDetail + { + public RiskVariableValueDetail(object value, string message) + { + Value = value; + Message = message; + } + + public object Value { get; } + + public string Message { get; } + } + + /// + /// 单个值取值或转换结果,用于在内部传递转换后的值和错误信息。 + /// internal class ValueExecuteResult { public bool Success { get; set; } public object Value { get; set; } + /// + /// 变量表达式返回的计算明细,仅用于正常命中提示,不参与比较。 + /// + public string DetailMessage { get; set; } + public string ErrorMessage { get; set; } public static ValueExecuteResult Ok(object value) diff --git a/YLErpDAL/Modules/RiskEngine/测试用例.md b/YLErpDAL/Modules/RiskEngine/测试用例.md index a319dcf0..ebf769b8 100644 --- a/YLErpDAL/Modules/RiskEngine/测试用例.md +++ b/YLErpDAL/Modules/RiskEngine/测试用例.md @@ -1440,6 +1440,41 @@ ORDER BY 挂钩标的到期日 < 合约到期日 ``` +变量 Roslyn 示例:配置两个 Date 类型变量;规则前端配置“挂钩标的到期日 < 当前交易合约到期日”。 + +变量 1:挂钩标的到期日,DataType 为 Date。 + +```csharp +var underlyingCode = DbContext.trade + .Where(t => t.id == TradeId) + .Select(t => t.UnderlyingCode) + .FirstOrDefault(); + +var maturityDate = DbContext.underlying_manager + .Where(u => u.UnderlyingCode == underlyingCode) + .Select(u => u.MaturityDate) + .FirstOrDefault(); + +if (!maturityDate.HasValue) + throw new Exception("挂钩标的到期日为空"); + +return maturityDate.Value.Date; +``` + +变量 2:当前交易合约到期日,DataType 为 Date。 + +```csharp +var exerciseDate = DbContext.trade + .Where(t => t.id == TradeId) + .Select(t => t.ExerciseDate) + .FirstOrDefault(); + +if (!exerciseDate.HasValue) + throw new Exception("当前交易合约到期日为空"); + +return exerciseDate.Value.Date; +``` + 规则字段口径: ```text @@ -1455,7 +1490,7 @@ ORDER BY // Id = 1000002, // RuleName = "挂钩标的到期日小于合约到期日(本地)", // RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易的 UnderlyingCode 和 ExerciseDate,ExerciseDate 对应合约到期日;通过 DbContext.underlying_manager 按 UnderlyingCode 取 MaturityDate,MaturityDate 对应挂钩标的到期日。计算逻辑:挂钩标的到期日小于合约到期日时触发禁止。", -// RuleExpr = "DbContext.underlying_manager.First(u => u.UnderlyingCode == DbContext.trade.First(t => t.id == TradeId).UnderlyingCode).MaturityDate.Value < DbContext.trade.First(t => t.id == TradeId).ExerciseDate.Value", +// RuleExpr = "DbContext.underlying_manager.First(u => u.UnderlyingCode == DbContext.trade.First(t => t.id == TradeId).UnderlyingCode).MaturityDate.HasValue && DbContext.trade.First(t => t.id == TradeId).ExerciseDate.HasValue && DateTime.Compare(DbContext.underlying_manager.First(u => u.UnderlyingCode == DbContext.trade.First(t => t.id == TradeId).UnderlyingCode).MaturityDate.Value.Date, DbContext.trade.First(t => t.id == TradeId).ExerciseDate.Value.Date) < 0", // Version = 1, // Status = RiskRuleStatus.Active, // OptId = 0, @@ -1600,7 +1635,11 @@ decimal openingNotional = (decimal)openingNotionalRaw.Value; if (openingNotional == 0m) throw new Exception("开仓名义本金为0,不能作为除数"); -return marginPaymentAmount / openingNotional; +decimal marginPaymentRatio = marginPaymentAmount / openingNotional; + +return new RiskVariableValueDetail( + marginPaymentRatio, + $"保证金支付金额为{marginPaymentAmount},开仓名义本金为{openingNotional}"); ``` 规则字段口径: @@ -1708,6 +1747,30 @@ ORDER BY ABS((预付金返息率 - 1) * 100) > 阈值 ``` +变量 Roslyn 示例:变量名为“保证金利率偏离”,DataType 为 Numeric;规则前端仍配置“保证金利率偏离 > 阈值”。`InterestRateDefault` 实体字段类型为 decimal,可直接参与 decimal 计算。 + +```csharp +var marginPosition = DbContext.swap_position + .Where(p => p.SwapTradeId == TradeId + && p.IsInitial + && !p.Invalid + && (p.InterestMode == 5 || p.InterestMode == 6)) + .OrderBy(p => p.HappenDate) + .ThenBy(p => p.id) + .FirstOrDefault(); + +if (marginPosition == null) + throw new Exception("预付金记录不存在"); + +decimal interestRateRawValue = marginPosition.InterestRateDefault; +decimal interestRatePercentValue = interestRateRawValue * 100m; +decimal interestRateDeviation = Math.Abs((interestRateRawValue - 1m) * 100m); + +return new RiskVariableValueDetail( + interestRateDeviation, + $"预付金返息率原值为{interestRateRawValue},页面百分比口径为{interestRatePercentValue},保证金利率偏离为{interestRateDeviation}"); +``` + 规则字段口径: ```text @@ -1812,6 +1875,38 @@ ORDER BY 保证金收取金额 ÷ 开仓名义本金 < 20% ``` +变量 Roslyn 示例:变量名为“保证金收取比例”,DataType 为 Numeric;规则前端仍配置“保证金收取比例 < 0.2”。字段类型需为 decimal / decimal?,否则需要字段层调整或显式类型转换。 + +```csharp +decimal marginReceiveAmount = DbContext.swap_position + .Where(p => p.SwapTradeId == TradeId + && p.IsInitial + && !p.Invalid + && (p.InterestMode == 5 || p.InterestMode == 6) + && p.InterestDirection == 1) + .Select(p => p.InterestPrincipalFix) + .Sum(); + +double? openingNotionalRaw = DbContext.trade + .Where(t => t.id == TradeId) + .Select(t => t.OriginalStockEqvNotional) + .FirstOrDefault(); + +if (!openingNotionalRaw.HasValue) + throw new Exception("开仓名义本金为空"); + +decimal openingNotional = (decimal)openingNotionalRaw.Value; + +if (openingNotional == 0m) + throw new Exception("开仓名义本金为0,不能作为除数"); + +decimal marginReceiveRatio = marginReceiveAmount / openingNotional; + +return new RiskVariableValueDetail( + marginReceiveRatio, + $"保证金收取金额为{marginReceiveAmount},开仓名义本金为{openingNotional}"); +``` + 规则字段口径: ```text diff --git a/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs b/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs index e2315e10..725fcd7b 100644 --- a/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs +++ b/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs @@ -5350,12 +5350,7 @@ 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}"; - if (!string.IsNullOrWhiteSpace(triggeredRule.Message)) - { - result.RiskWarningDetails += $";信息:{triggeredRule.Message}"; - } - result.RiskWarningDetails += "\n"; + result.RiskWarningDetails += BuildTriggeredRuleDetail(triggeredRule); } } if (riskResult.NeedApproval) @@ -5380,19 +5375,15 @@ namespace YLErp.Modules.RiskModule result.ApprovalRuleIds = processedRiskRuleIds; foreach (var triggeredRule in approvalTriggeredRules) { - result.RiskWarningDetails += $"规则ID:{triggeredRule.RuleId};规则名称:{triggeredRule.RuleName};规则说明:{triggeredRule.RuleText}\n"; + result.RiskWarningDetails += BuildTriggeredRuleDetail(triggeredRule); } } if (riskResult.ShowTip) { - var warningMessages = riskResult.TriggeredRules - .Where(r => r.ControlStrategy == RiskControlStrategy.ShowTip) - .Select(r => r.Message) - .Where(r => !string.IsNullOrWhiteSpace(r)) - .ToList(); - if (warningMessages.Count > 0) + result.RiskWarningDetails += "[风控引擎] 规则触发:提示\n"; + foreach (var triggeredRule in riskResult.TriggeredRules.Where(r => r.ControlStrategy == RiskControlStrategy.ShowTip)) { - result.RiskWarningDetails += "[风控引擎] 提示:" + string.Join(";", warningMessages) + "\n"; + result.RiskWarningDetails += BuildTriggeredRuleDetail(triggeredRule); } } } @@ -5405,6 +5396,21 @@ namespace YLErp.Modules.RiskModule return result; } + + /// + /// 构建新风控触发规则明细。禁止、审批、提示三类规则都可能带结构化命中明细,统一格式避免不同策略展示字段不一致。 + /// + private static string BuildTriggeredRuleDetail(TriggeredRuleInfo triggeredRule) + { + var detail = $"规则ID:{triggeredRule.RuleId};规则名称:{triggeredRule.RuleName};规则说明:{triggeredRule.RuleText}"; + if (!string.IsNullOrWhiteSpace(triggeredRule.Message)) + { + detail += $";信息:{triggeredRule.Message}"; + } + + return detail + "\n"; + } + /// /// 校验标的白名单 /// From 3df280d9f01430f21b68ac980df7962c00706644 Mon Sep 17 00:00:00 2001 From: ruisu Date: Thu, 30 Jul 2026 13:32:47 +0800 Subject: [PATCH 8/8] =?UTF-8?q?feat=EF=BC=9A=E5=A2=9E=E5=8A=A0=E6=97=A5?= =?UTF-8?q?=E5=8E=86=E4=B8=8E=E4=BB=B7=E6=A0=BC=E5=81=8F=E7=A6=BB=E8=BE=85?= =?UTF-8?q?=E5=8A=A9=E5=87=BD=E6=95=B0=EF=BC=8C=E4=BF=AE=E5=A4=8D=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E8=BF=87=E9=95=BF=E6=97=A0=E6=B3=95=E8=90=BD=E5=BA=93?= =?UTF-8?q?=E7=9A=84=E9=97=AE=E9=A2=98=EF=BC=8C=E4=BF=AE=E5=A4=8D=E8=80=81?= =?UTF-8?q?=E9=A3=8E=E6=8E=A7=E7=89=B9=E6=89=B9=E3=80=81=E6=96=B0=E9=A3=8E?= =?UTF-8?q?=E6=8E=A7=E4=B8=8D=E6=89=B9=E6=97=B6=EF=BC=8C=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E5=B1=95=E7=A4=BA=E4=B8=BA=E6=8F=90=E7=A4=BA=E6=A1=86=EF=BC=8C?= =?UTF-8?q?=E4=B8=8D=E6=98=AF=E7=BB=86=E8=8A=82=E6=A1=86=E7=9A=84=E9=97=AE?= =?UTF-8?q?=E9=A2=98=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RiskEngine/Compile/RuleCompiler.cs | 2 + .../RiskEngine/Helper/RiskCalendarHelper.cs | 87 +++++ .../Helper/RiskMarketDeviationHelper.cs | 340 ++++++++++++++++++ .../RiskEngine/StructuredRuleExecutor.cs | 6 + YLErpDAL/Modules/RiskEngine/测试用例.md | 311 ++++++++++++++-- .../Modules/RiskModule/QuotaMonitorService.cs | 13 +- .../RiskModule/TradeRiskCheckLogService.cs | 15 +- YLErpWeb/Controllers/tradeController.cs | 4 +- 8 files changed, 742 insertions(+), 36 deletions(-) create mode 100644 YLErpDAL/Modules/RiskEngine/Helper/RiskCalendarHelper.cs create mode 100644 YLErpDAL/Modules/RiskEngine/Helper/RiskMarketDeviationHelper.cs diff --git a/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs b/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs index 084b1e3f..d827a6db 100644 --- a/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs +++ b/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs @@ -359,6 +359,8 @@ namespace YLErp.Modules.RiskEngine var options = ScriptOptions.Default .WithReferences( typeof(RiskContext).Assembly, + typeof(RiskCalendarHelper).Assembly, + typeof(RiskMarketDeviationHelper).Assembly, typeof(YLContext).Assembly, typeof(YLErp.DBModels.trade).Assembly, typeof(QdpCalendarHelper).Assembly, diff --git a/YLErpDAL/Modules/RiskEngine/Helper/RiskCalendarHelper.cs b/YLErpDAL/Modules/RiskEngine/Helper/RiskCalendarHelper.cs new file mode 100644 index 00000000..c2b4758c --- /dev/null +++ b/YLErpDAL/Modules/RiskEngine/Helper/RiskCalendarHelper.cs @@ -0,0 +1,87 @@ +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using YLErp.BLL; + +namespace YLErp.Modules.RiskEngine +{ + /// + /// 风控规则专用日历辅助类。 + /// 当前主要用于债券类规则按银行间日历确认“上一收盘日”,避免简单按估值表倒序取最近日期导致口径偏差。 + /// + public static class RiskCalendarHelper + { + /// + /// 获取指定日期的上一银行间交易日。 + /// + /// 当前风控执行使用的数据库上下文。 + /// 当前交易日或业务基准日。 + /// 严格早于入参日期的上一银行间交易日。 + /// 数据库上下文为空。 + /// 缺少银行间日历、日历内容异常或在保护范围内找不到上一交易日。 + public static DateTime GetPreviousInterbankTradingDay(YLContext dbContext, DateTime date) + { + if (dbContext == null) + throw new ArgumentNullException(nameof(dbContext)); + + var holidayCache = new Dictionary>(); + var currentDate = date.Date.AddDays(-1); + + // 最多向前查 370 天,既覆盖跨年和长假场景,也避免日历配置异常时出现无限循环。 + for (var i = 0; i < 370; i++) + { + var holidays = GetInterbankHolidays(dbContext, currentDate.Year, holidayCache); + var currentDateText = currentDate.ToString("yyyy,MM,dd", CultureInfo.InvariantCulture); + + // calendar.HolidayJson 存的是非交易日;不在非交易日集合内,即认为是银行间交易日。 + if (!holidays.Contains(currentDateText)) + return currentDate; + + currentDate = currentDate.AddDays(-1); + } + + throw new Exception($"未找到{date:yyyy-MM-dd}的上一银行间交易日"); + } + + /// + /// 获取指定年份的银行间非交易日集合。 + /// + /// 当前风控执行使用的数据库上下文。 + /// 日历年份。 + /// 单次查询过程内的年份级缓存,跨年查找时避免重复读取同一年日历。 + /// 格式为 yyyy,MM,dd 的非交易日集合。 + private static HashSet GetInterbankHolidays(YLContext dbContext, int year, Dictionary> holidayCache) + { + if (holidayCache.TryGetValue(year, out var holidays)) + return holidays; + + // 同一年可能存在多种市场日历;规则 12 明确使用 Country=IB 的银行间日历。 + var calendar = dbContext.calendar + .Where(c => c.Year == year && (c.ValidState == null || c.ValidState != ConsGlobal.InValid)) + .ToList() + .FirstOrDefault(c => string.Equals(c.Country, "IB", StringComparison.OrdinalIgnoreCase)); + + if (calendar == null) + throw new Exception($"未找到{year}年银行间日历"); + + if (string.IsNullOrWhiteSpace(calendar.HolidayJson)) + throw new Exception($"{year}年银行间日历HolidayJson为空"); + + List holidayList; + try + { + holidayList = JsonConvert.DeserializeObject>(calendar.HolidayJson); + } + catch (Exception ex) + { + throw new Exception($"{year}年银行间日历HolidayJson解析失败", ex); + } + + holidays = new HashSet(holidayList ?? new List()); + holidayCache[year] = holidays; + return holidays; + } + } +} diff --git a/YLErpDAL/Modules/RiskEngine/Helper/RiskMarketDeviationHelper.cs b/YLErpDAL/Modules/RiskEngine/Helper/RiskMarketDeviationHelper.cs new file mode 100644 index 00000000..0af2c96f --- /dev/null +++ b/YLErpDAL/Modules/RiskEngine/Helper/RiskMarketDeviationHelper.cs @@ -0,0 +1,340 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using YLErp.BLL; + +namespace YLErp.Modules.RiskEngine +{ + /// + /// 风控行情偏离类变量辅助方法,统一封装债券中债估值偏离和非债券行情价格偏离的取数、计算和命中明细生成逻辑。 + /// + public static class RiskMarketDeviationHelper + { + /// + /// 计算当前交易所有浮动支付端的债券类净价偏离值,返回最大偏离值用于规则比较。 + /// + /// 数据库上下文。 + /// 当前交易ID。 + /// 包含最大净价偏离值和逐笔偏离明细的变量返回值。 + public static RiskVariableValueDetail GetBondNetPriceDeviation(YLContext dbContext, int tradeId) + { + return GetBondValuationDeviation( + dbContext, + tradeId, + "债券类净价偏离", + "期初交割净价", + "中债估值净价", + p => p.PosiNetNoFeePrice, + v => v.net_price); + } + + /// + /// 计算当前交易所有浮动支付端的债券类收益率偏离值,返回最大偏离值用于规则比较。 + /// + /// 数据库上下文。 + /// 当前交易ID。 + /// 包含最大收益率偏离值和逐笔偏离明细的变量返回值。 + public static RiskVariableValueDetail GetBondYieldDeviation(YLContext dbContext, int tradeId) + { + return GetBondValuationDeviation( + dbContext, + tradeId, + "债券类收益率偏离", + "期初成交收益率", + "中债估值收益率", + p => p.InitYtm, + v => v.yield); + } + + /// + /// 计算当前交易所有浮动支付端的非债券类价格偏离值,返回最大偏离值用于规则比较。 + /// + /// 数据库上下文。 + /// 当前交易ID。 + /// 包含最大价格偏离值和逐笔偏离明细的变量返回值。 + public static RiskVariableValueDetail GetNonBondPriceDeviation(YLContext dbContext, int tradeId) + { + if (dbContext == null) + throw new ArgumentNullException(nameof(dbContext)); + + var tradeDate = GetTradeDate(dbContext, tradeId); + var floatingPositions = GetFloatingPaymentPositions(dbContext, tradeId) + .Select(p => new + { + p.id, + p.UnderlyingCode, + p.PosiGrossPrice + }) + .ToList(); + + if (!floatingPositions.Any()) + throw new Exception("浮动支付端记录不存在"); + + var underlyingCodes = floatingPositions + .Select(p => p.UnderlyingCode) + .Distinct() + .ToList(); + + // 非债券类取交易日前最近一条行情,不使用银行间日历,也不要求行情日等于上一银行间交易日。 + var eodRows = dbContext.eod_commodity_future_price + .Where(e => underlyingCodes.Contains(e.UnderlyingCode) + && e.ValueDate < tradeDate) + .Select(e => new + { + e.id, + e.UnderlyingCode, + e.ValueDate, + e.ClosePrice + }) + .ToList(); + + // 先按标的批量查出行情,再在内存中分组取最近日,避免每条浮动支付端单独访问数据库。 + var eodByUnderlyingCode = eodRows + .GroupBy(e => e.UnderlyingCode) + .ToDictionary( + g => g.Key, + g => g.OrderByDescending(e => e.ValueDate).ThenBy(e => e.id).First()); + + var valuationItems = floatingPositions + .Select(p => new + { + Position = p, + Eod = eodByUnderlyingCode.ContainsKey(p.UnderlyingCode) ? eodByUnderlyingCode[p.UnderlyingCode] : null + }) + .ToList(); + + var missingEodItems = valuationItems + .Where(x => x.Eod == null) + .Select(x => $"记录ID {x.Position.id},标的{x.Position.UnderlyingCode}") + .ToList(); + + if (missingEodItems.Any()) + throw new Exception($"未找到交易日前行情收盘价:" + string.Join(";", missingEodItems)); + + var diffItems = valuationItems + .Select(x => new + { + PositionId = x.Position.id, + UnderlyingCode = x.Position.UnderlyingCode, + PositionPrice = x.Position.PosiGrossPrice * 100m, + MarketDate = x.Eod.ValueDate, + MarketPrice = Convert.ToDecimal(x.Eod.ClosePrice), + DiffAbs = Math.Abs(x.Position.PosiGrossPrice * 100m - Convert.ToDecimal(x.Eod.ClosePrice)) + }) + .ToList(); + + return BuildDeviationDetail( + diffItems.Select(x => new DeviationItem + { + PositionId = x.PositionId, + UnderlyingCode = x.UnderlyingCode, + PositionValue = x.PositionPrice, + MarketDate = x.MarketDate, + MarketValue = x.MarketPrice, + DiffAbs = x.DiffAbs + }).ToList(), + "非债券类价格偏离", + "期初标的价格", + "上一行情收盘价"); + } + + /// + /// 债券类中债估值偏离的公共计算入口,净价偏离和收益率偏离仅通过字段选择器区分取值字段。 + /// + /// 数据库上下文。 + /// 当前交易ID。 + /// 偏离规则名称,用于生成命中明细。 + /// 交易侧取值名称,用于生成命中明细。 + /// 市场估值取值名称,用于生成命中明细。 + /// 交易侧字段选择器。 + /// 中债估值字段选择器。 + /// 包含最大偏离值和逐笔偏离明细的变量返回值。 + private static RiskVariableValueDetail GetBondValuationDeviation( + YLContext dbContext, + int tradeId, + string deviationName, + string positionValueName, + string marketValueName, + Func positionValueSelector, + Func valuationValueSelector) + { + if (dbContext == null) + throw new ArgumentNullException(nameof(dbContext)); + + var tradeDate = GetTradeDate(dbContext, tradeId); + var previousTradingDay = RiskCalendarHelper.GetPreviousInterbankTradingDay(dbContext, tradeDate); + var nextTradingDate = previousTradingDay.AddDays(1); + var floatingPositions = GetFloatingPaymentPositions(dbContext, tradeId).ToList(); + + if (!floatingPositions.Any()) + throw new Exception("浮动支付端记录不存在"); + + var positionItems = floatingPositions + .Select(p => new + { + p.id, + p.UnderlyingCode, + PositionValue = positionValueSelector(p) + }) + .ToList(); + + var missingPositionValueIds = positionItems + .Where(p => !p.PositionValue.HasValue) + .Select(p => p.id.ToString()) + .ToList(); + + if (missingPositionValueIds.Any()) + throw new Exception($"浮动支付端{positionValueName}为空,记录ID:" + string.Join("、", missingPositionValueIds)); + + var underlyingCodes = positionItems + .Select(p => p.UnderlyingCode) + .Distinct() + .ToList(); + + // 债券类必须严格匹配上一银行间交易日当天的中债估值,不能简单取交易日前最近估值日。 + var valuationRows = dbContext.china_bond_valuation + .Where(v => underlyingCodes.Contains(v.bond_id) + && v.valuation_date >= previousTradingDay + && v.valuation_date < nextTradingDate) + .ToList() + .Select(v => new + { + v.id, + v.bond_id, + v.valuation_date, + v.credibility, + ValuationValue = valuationValueSelector(v) + }) + .Where(v => v.ValuationValue.HasValue) + .ToList(); + + // 同一标的同一估值日可能有多条来源,按可信度优先,ID兜底稳定排序。 + var valuationByBondId = valuationRows + .GroupBy(v => v.bond_id) + .ToDictionary( + g => g.Key, + g => g.OrderBy(v => v.credibility).ThenBy(v => v.id).First()); + + var valuationItems = positionItems + .Select(p => new + { + Position = p, + Valuation = valuationByBondId.ContainsKey(p.UnderlyingCode) ? valuationByBondId[p.UnderlyingCode] : null + }) + .ToList(); + + var missingValuationItems = valuationItems + .Where(x => x.Valuation == null) + .Select(x => $"记录ID {x.Position.id},标的{x.Position.UnderlyingCode}") + .ToList(); + + if (missingValuationItems.Any()) + throw new Exception($"未找到上一银行间交易日{previousTradingDay:yyyy-MM-dd}的{marketValueName}:" + string.Join(";", missingValuationItems)); + + var diffItems = valuationItems + .Select(x => new DeviationItem + { + PositionId = x.Position.id, + UnderlyingCode = x.Position.UnderlyingCode, + PositionValue = x.Position.PositionValue.Value * 100m, + MarketDate = x.Valuation.valuation_date, + MarketValue = x.Valuation.ValuationValue.Value, + DiffAbs = Math.Abs(x.Position.PositionValue.Value * 100m - x.Valuation.ValuationValue.Value) + }) + .ToList(); + + return BuildDeviationDetail(diffItems, deviationName, positionValueName, marketValueName); + } + + /// + /// 获取当前交易的交易日,所有行情偏离规则都以交易日作为市场数据取数基准。 + /// + /// 数据库上下文。 + /// 当前交易ID。 + /// 交易日日期部分。 + private static DateTime GetTradeDate(YLContext dbContext, int tradeId) + { + var tradeDate = dbContext.trade + .Where(t => t.id == tradeId) + .Select(t => t.TradeDate) + .FirstOrDefault(); + + if (!tradeDate.HasValue) + throw new Exception("交易日为空"); + + return tradeDate.Value.Date; + } + + /// + /// 获取当前交易下全部浮动支付端记录,行情偏离类规则需要遍历同一TradeId下所有浮动支付端。 + /// + /// 数据库上下文。 + /// 当前交易ID。 + /// 浮动支付端记录查询对象。 + private static IQueryable GetFloatingPaymentPositions(YLContext dbContext, int tradeId) + { + return dbContext.swap_position + .Where(p => p.SwapTradeId == tradeId + && p.IsInitial + && !p.Invalid + && p.PosiDirection == 2 + && !string.IsNullOrEmpty(p.UnderlyingCode)); + } + + /// + /// 统一生成行情偏离类变量返回值,变量值取最大偏离值,命中说明保留逐笔偏离明细。 + /// + /// 逐笔偏离结果。 + /// 偏离规则名称。 + /// 交易侧取值名称。 + /// 市场侧取值名称。 + /// 包含最大偏离值和逐笔偏离明细的变量返回值。 + private static RiskVariableValueDetail BuildDeviationDetail( + List diffItems, + string deviationName, + string positionValueName, + string marketValueName) + { + var maxDiffItem = diffItems + .OrderByDescending(x => x.DiffAbs) + .ThenBy(x => x.PositionId) + .First(); + + var deviatedItems = diffItems + .Where(x => x.DiffAbs > 0m) + .OrderByDescending(x => x.DiffAbs) + .ThenBy(x => x.PositionId) + .Select(x => $"记录ID {x.PositionId},标的{x.UnderlyingCode}:{positionValueName}{FormatDecimal(x.PositionValue)},{x.MarketDate:yyyy-MM-dd}{marketValueName}{FormatDecimal(x.MarketValue)},偏离{FormatDecimal(x.DiffAbs)}") + .ToList(); + + string diffMessage = deviatedItems.Any() + ? $"存在{deviationName}的浮动支付端记录:" + string.Join(";", deviatedItems) + : $"未发现{deviationName}记录"; + + return new RiskVariableValueDetail(maxDiffItem.DiffAbs, diffMessage); + } + + /// + /// 格式化风控命中说明中的数值,避免展示过长小数。 + /// + /// 待格式化数值。 + /// 最多9位小数的展示文本。 + private static string FormatDecimal(decimal value) + { + return value.ToString("0.#########"); + } + + /// + /// 行情偏离计算的中间结果模型,用于把债券和非债券两类计算结果统一交给明细构建逻辑。 + /// + private class DeviationItem + { + public long PositionId { get; set; } + public string UnderlyingCode { get; set; } + public decimal PositionValue { get; set; } + public DateTime MarketDate { get; set; } + public decimal MarketValue { get; set; } + public decimal DiffAbs { get; set; } + } + } +} diff --git a/YLErpDAL/Modules/RiskEngine/StructuredRuleExecutor.cs b/YLErpDAL/Modules/RiskEngine/StructuredRuleExecutor.cs index 6f6be1cb..7ba84352 100644 --- a/YLErpDAL/Modules/RiskEngine/StructuredRuleExecutor.cs +++ b/YLErpDAL/Modules/RiskEngine/StructuredRuleExecutor.cs @@ -576,6 +576,12 @@ namespace YLErp.Modules.RiskEngine return dateValue.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); if (value is bool boolValue) return boolValue ? "是" : "否"; + if (value is decimal decimalValue) + return decimalValue.ToString("0.#########", CultureInfo.InvariantCulture); + if (value is double doubleValue) + return doubleValue.ToString("0.#########", CultureInfo.InvariantCulture); + if (value is float floatValue) + return floatValue.ToString("0.#########", CultureInfo.InvariantCulture); return Convert.ToString(value, CultureInfo.InvariantCulture); } diff --git a/YLErpDAL/Modules/RiskEngine/测试用例.md b/YLErpDAL/Modules/RiskEngine/测试用例.md index ebf769b8..f3397446 100644 --- a/YLErpDAL/Modules/RiskEngine/测试用例.md +++ b/YLErpDAL/Modules/RiskEngine/测试用例.md @@ -1546,6 +1546,28 @@ WHERE t.id = @TradeId; 开仓名义本金 > 100000000 ``` +变量 Roslyn 示例:变量名为“开仓名义本金”,DataType 为 Numeric;规则前端配置“开仓名义本金 > 阈值”。`OriginalStockEqvNotional` 在实体模型中为 `double?`,脚本中转为 `decimal` 后参与数值比较。 + +```csharp +double? openingNotionalRaw = DbContext.trade + .Where(t => t.id == TradeId) + .Select(t => t.OriginalStockEqvNotional) + .FirstOrDefault(); + +if (!openingNotionalRaw.HasValue) +{ + return new RiskVariableValueDetail( + 0m, + "开仓名义本金为空,本规则不命中"); +} + +decimal openingNotional = (decimal)openingNotionalRaw.Value; + +return new RiskVariableValueDetail( + openingNotional, + $"开仓名义本金为{openingNotional}"); +``` + 规则字段口径: ```text @@ -1613,14 +1635,22 @@ WHERE t.id = @TradeId; 变量 Roslyn 示例:变量名为“保证金支付比例”,DataType 为 Numeric;规则前端仍配置“保证金支付比例 > 0.5”。字段类型需为 decimal / decimal?,否则需要字段层调整或显式类型转换。 ```csharp -decimal marginPaymentAmount = DbContext.swap_position +var marginPaymentItems = DbContext.swap_position .Where(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && (p.InterestMode == 5 || p.InterestMode == 6) && p.InterestDirection == 2) - .Select(p => p.InterestPrincipalFix) - .Sum(); + .Select(p => new + { + p.id, + p.InterestPrincipalFix, + p.InterestRateDefault, + p.HappenDate + }) + .ToList(); + +decimal marginPaymentAmount = marginPaymentItems.Sum(p => p.InterestPrincipalFix); double? openingNotionalRaw = DbContext.trade .Where(t => t.id == TradeId) @@ -1637,9 +1667,19 @@ if (openingNotional == 0m) decimal marginPaymentRatio = marginPaymentAmount / openingNotional; +var marginPaymentDetails = marginPaymentItems + .OrderBy(p => p.HappenDate) + .ThenBy(p => p.id) + .Select(p => $"保证金记录ID为{p.id},支付金额为{p.InterestPrincipalFix},返息率为{p.InterestRateDefault},发生日期为{p.HappenDate}") + .ToList(); + +string marginPaymentMessage = marginPaymentDetails.Any() + ? string.Join(";", marginPaymentDetails) + : "未查询到保证金支付记录"; + return new RiskVariableValueDetail( marginPaymentRatio, - $"保证金支付金额为{marginPaymentAmount},开仓名义本金为{openingNotional}"); + $"保证金支付总金额为{marginPaymentAmount},开仓名义本金为{openingNotional},{marginPaymentMessage}"); ``` 规则字段口径: @@ -1747,28 +1787,44 @@ ORDER BY ABS((预付金返息率 - 1) * 100) > 阈值 ``` -变量 Roslyn 示例:变量名为“保证金利率偏离”,DataType 为 Numeric;规则前端仍配置“保证金利率偏离 > 阈值”。`InterestRateDefault` 实体字段类型为 decimal,可直接参与 decimal 计算。 +变量 Roslyn 示例:变量名为“保证金利率偏离”,DataType 为 Numeric;规则前端仍配置“保证金利率偏离 > 阈值”。脚本会查询当前 TradeId 下所有符合口径的保证金记录,变量值返回最大偏离值用于判断,命中说明列出所有存在偏离的记录。`InterestRateDefault` 实体字段类型为 decimal,可直接参与 decimal 计算。 ```csharp -var marginPosition = DbContext.swap_position +var marginRateItems = DbContext.swap_position .Where(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && (p.InterestMode == 5 || p.InterestMode == 6)) - .OrderBy(p => p.HappenDate) - .ThenBy(p => p.id) - .FirstOrDefault(); + .Select(p => new + { + p.id, + p.InterestRateDefault, + InterestRateDeviation = Math.Abs((p.InterestRateDefault - 1m) * 100m) + }) + .ToList(); -if (marginPosition == null) +if (!marginRateItems.Any()) throw new Exception("预付金记录不存在"); -decimal interestRateRawValue = marginPosition.InterestRateDefault; -decimal interestRatePercentValue = interestRateRawValue * 100m; -decimal interestRateDeviation = Math.Abs((interestRateRawValue - 1m) * 100m); +var maxDeviationItem = marginRateItems + .OrderByDescending(p => p.InterestRateDeviation) + .ThenBy(p => p.id) + .First(); + +var deviatedItems = marginRateItems + .Where(p => p.InterestRateDeviation > 0m) + .OrderByDescending(p => p.InterestRateDeviation) + .ThenBy(p => p.id) + .Select(p => $"记录ID {p.id}:返息率{(p.InterestRateDefault * 100m).ToString("0.#########")}%,偏离{p.InterestRateDeviation.ToString("0.#########")}%") + .ToList(); + +string deviationMessage = deviatedItems.Any() + ? "存在偏离的预付金记录:" + string.Join(";", deviatedItems) + : "未发现保证金利率偏离记录"; return new RiskVariableValueDetail( - interestRateDeviation, - $"预付金返息率原值为{interestRateRawValue},页面百分比口径为{interestRatePercentValue},保证金利率偏离为{interestRateDeviation}"); + maxDeviationItem.InterestRateDeviation, + deviationMessage); ``` 规则字段口径: @@ -1878,14 +1934,22 @@ ORDER BY 变量 Roslyn 示例:变量名为“保证金收取比例”,DataType 为 Numeric;规则前端仍配置“保证金收取比例 < 0.2”。字段类型需为 decimal / decimal?,否则需要字段层调整或显式类型转换。 ```csharp -decimal marginReceiveAmount = DbContext.swap_position +var marginReceiveItems = DbContext.swap_position .Where(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && (p.InterestMode == 5 || p.InterestMode == 6) && p.InterestDirection == 1) - .Select(p => p.InterestPrincipalFix) - .Sum(); + .Select(p => new + { + p.id, + p.InterestPrincipalFix, + p.InterestRateDefault, + p.HappenDate + }) + .ToList(); + +decimal marginReceiveAmount = marginReceiveItems.Sum(p => p.InterestPrincipalFix); double? openingNotionalRaw = DbContext.trade .Where(t => t.id == TradeId) @@ -1902,9 +1966,19 @@ if (openingNotional == 0m) decimal marginReceiveRatio = marginReceiveAmount / openingNotional; +var marginReceiveDetails = marginReceiveItems + .OrderBy(p => p.HappenDate) + .ThenBy(p => p.id) + .Select(p => $"保证金记录ID为{p.id},收取金额为{p.InterestPrincipalFix},返息率为{p.InterestRateDefault},发生日期为{p.HappenDate}") + .ToList(); + +string marginReceiveMessage = marginReceiveDetails.Any() + ? string.Join(";", marginReceiveDetails) + : "未查询到保证金收取记录"; + return new RiskVariableValueDetail( marginReceiveRatio, - $"保证金收取金额为{marginReceiveAmount},开仓名义本金为{openingNotional}"); + $"保证金收取总金额为{marginReceiveAmount},开仓名义本金为{openingNotional},{marginReceiveMessage}"); ``` 规则字段口径: @@ -2011,6 +2085,36 @@ ORDER BY 起息日 < 当前日期 ``` +变量 Roslyn 示例:拆成两个 Date 类型变量,规则前端配置“起息日 < 今日”,右侧阈值类型选择变量“今日”。 + +变量 1:变量名为“起息日”,DataType 为 Date。为保持原公式 `StartDate.HasValue && StartDate.Value.Date < DateTime.Today` 的语义,起息日为空时返回当前日期,使本规则不命中,避免空值被当作执行异常。 + +```csharp +DateTime? startDate = DbContext.trade + .Where(t => t.id == TradeId) + .Select(t => t.StartDate) + .FirstOrDefault(); + +if (!startDate.HasValue) +{ + return new RiskVariableValueDetail( + DateTime.Today, + "起息日为空,本规则不命中"); +} + +return new RiskVariableValueDetail( + startDate.Value.Date, + $"起息日为{startDate.Value.Date:yyyy-MM-dd}"); +``` + +变量 2:变量名为“今日”,DataType 为 Date。 + +```csharp +return new RiskVariableValueDetail( + DateTime.Today, + $"当前日期为{DateTime.Today:yyyy-MM-dd}"); +``` + 规则字段口径: ```text @@ -2340,6 +2444,68 @@ WHERE t.id = @TradeId; (到期日.Date - 起始日.Date).Days + 是否算头 - 是否不算尾 > 阈值 ``` +变量 Roslyn 示例:变量名为“合约期限天数”,DataType 为 Numeric;规则前端配置“合约期限天数 > 阈值”。变量内部统一读取起始日、到期日和计息方式,并返回实际合约期限天数。 + +```csharp +var tradeInfo = DbContext.trade + .Where(t => t.id == TradeId) + .Select(t => new + { + t.StartDate, + t.ExerciseDate + }) + .FirstOrDefault(); + +if (tradeInfo == null) + throw new Exception("交易不存在"); + +if (!tradeInfo.StartDate.HasValue || !tradeInfo.ExerciseDate.HasValue) +{ + return new RiskVariableValueDetail( + 0m, + $"起始日或到期日为空,起始日为{tradeInfo.StartDate?.ToString("yyyy-MM-dd") ?? "空"},到期日为{tradeInfo.ExerciseDate?.ToString("yyyy-MM-dd") ?? "空"},本规则不命中"); +} + +var tradeExtend = DbContext.trade_extend + .Where(e => e.TradeId == TradeId) + .FirstOrDefault(); + +string interestCalcModeRaw = tradeExtend?.ExtendObj?.InterestCalcMode; +string interestCalcMode = string.IsNullOrWhiteSpace(interestCalcModeRaw) ? "11" : interestCalcModeRaw; + +if (interestCalcMode.Length != 2 + || (interestCalcMode[0] != '0' && interestCalcMode[0] != '1') + || (interestCalcMode[1] != '0' && interestCalcMode[1] != '1')) +{ + throw new Exception($"计息方式不合法:{interestCalcMode}"); +} + +DateTime startDate = tradeInfo.StartDate.Value.Date; +DateTime exerciseDate = tradeInfo.ExerciseDate.Value.Date; +int baseNaturalDays = (exerciseDate - startDate).Days; +int calcFirstDays = interestCalcMode.StartsWith("1") ? 1 : 0; +int notCalcLastDays = interestCalcMode.EndsWith("1") ? 0 : -1; +int contractNaturalDays = baseNaturalDays + calcFirstDays + notCalcLastDays; +string interestCalcModeText = interestCalcMode == "00" + ? "不计头不计尾" + : interestCalcMode == "01" + ? "不计头计尾" + : interestCalcMode == "10" + ? "计头不计尾" + : "计头计尾"; +string interestCalcModeDescription = interestCalcMode == "00" + ? "不计入起始日,不计入到期日" + : interestCalcMode == "01" + ? "不计入起始日,计入到期日" + : interestCalcMode == "10" + ? "计入起始日,不计入到期日" + : "计入起始日,计入到期日"; + +return new RiskVariableValueDetail( + contractNaturalDays, + $"起始日为{startDate:yyyy-MM-dd},到期日为{exerciseDate:yyyy-MM-dd},计息方式为{interestCalcModeText},{interestCalcModeDescription}"); +``` + 规则字段口径: ```text @@ -2423,13 +2589,14 @@ WHERE t.id = @TradeId; 取数流程: ```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 则命中。 +1. 根据 TradeId 查 trade.TradeDate。 +2. 通过银行间日历 Country=IB 计算交易日的上一银行间交易日,查不到日历或上一交易日时报异常。 +3. 根据 TradeId 查 swap_position。 +4. 限定 IsInitial=1、Invalid=0、PosiDirection=2、UnderlyingCode 非空,取所有浮动支付端。 +5. 从浮动支付端取 PosiNetNoFeePrice 和 UnderlyingCode。 +6. 用 swap_position.UnderlyingCode 关联 china_bond_valuation.bond_id。 +7. 限定 valuation_date 为上一银行间交易日当天,优先取 credibility=1。 +8. 计算 ABS(PosiNetNoFeePrice * 100 - net_price),大于 5 则命中。 ``` 规则公式: @@ -2438,6 +2605,13 @@ WHERE t.id = @TradeId; ABS(浮动支付端.PosiNetNoFeePrice * 100 - 上一收盘日中债估值.net_price) > 5 ``` +变量 Roslyn 示例:变量名为“债券类净价偏离值”,DataType 为 Numeric;规则前端配置“债券类净价偏离值 > 阈值”。具体取数、上一银行间交易日确认、估值匹配、债券/非债券差异处理统一放在 `RiskMarketDeviationHelper` 中,变量公式只保留公共方法调用。 + +```csharp +return YLErp.Modules.RiskEngine.RiskMarketDeviationHelper.GetBondNetPriceDeviation(DbContext, TradeId); +``` + + 注释规则定义: ```csharp @@ -2461,9 +2635,42 @@ ABS(浮动支付端.PosiNetNoFeePrice * 100 - 上一收盘日中债估值.net_pr ```sql SET @TradeId = 3001699; +WITH RECURSIVE candidate_dates AS ( + SELECT DATE(t.TradeDate) - INTERVAL 1 DAY AS CandidateDate + FROM trade t + WHERE t.id = @TradeId + + UNION ALL + + SELECT CandidateDate - INTERVAL 1 DAY + FROM candidate_dates + WHERE CandidateDate > DATE_SUB((SELECT DATE(TradeDate) FROM trade WHERE id = @TradeId), INTERVAL 370 DAY) +), +previous_trading_day AS ( + SELECT cd.CandidateDate + FROM candidate_dates cd + INNER JOIN calendar c + ON c.Year = YEAR(cd.CandidateDate) + AND UPPER(c.Country) = 'IB' + AND (c.ValidState IS NULL OR c.ValidState <> 'InValid') + WHERE NOT JSON_CONTAINS(c.HolidayJson, JSON_QUOTE(DATE_FORMAT(cd.CandidateDate, '%Y,%m,%d'))) + ORDER BY cd.CandidateDate DESC + LIMIT 1 +), +bond_valuation_ranked AS ( + SELECT + bv.*, + ROW_NUMBER() OVER (PARTITION BY bv.bond_id ORDER BY bv.credibility ASC, bv.id ASC) AS RowNo + FROM china_bond_valuation bv + INNER JOIN previous_trading_day ptd + ON bv.valuation_date >= ptd.CandidateDate + AND bv.valuation_date < DATE_ADD(ptd.CandidateDate, INTERVAL 1 DAY) + WHERE bv.net_price IS NOT NULL +) SELECT t.id AS TradeId, t.TradeDate, + ptd.CandidateDate AS PreviousInterbankTradingDay, sp.id AS SwapPositionId, sp.SwapTradeId, @@ -2487,6 +2694,7 @@ SELECT ELSE 0 END AS IsGreaterThan5 FROM trade t +CROSS JOIN previous_trading_day ptd INNER JOIN swap_position sp ON sp.SwapTradeId = t.id AND sp.IsInitial = 1 @@ -2494,14 +2702,13 @@ INNER JOIN swap_position sp AND sp.PosiDirection = 2 AND sp.UnderlyingCode IS NOT NULL AND sp.UnderlyingCode <> '' -LEFT JOIN china_bond_valuation bv +LEFT JOIN bond_valuation_ranked bv ON bv.bond_id = sp.UnderlyingCode - AND bv.valuation_date < DATE(t.TradeDate) + AND bv.RowNo = 1 WHERE t.id = @TradeId ORDER BY - bv.credibility ASC, - bv.valuation_date DESC -LIMIT 1; + ABS(sp.PosiNetNoFeePrice * 100 - bv.net_price) DESC, + sp.id ASC; ``` --- @@ -2726,6 +2933,48 @@ COUNT(DISTINCT swap_position.UnderlyingCode) > 10 //}); ``` +变量形式: + +变量名:单一交易对手累计标的数量 +DataType:Numeric +规则配置:单一交易对手累计标的数量 > 阈值(示例 10) + +变量取值表达式: + +```csharp +int? currentClientId = DbContext.trade + .Where(t => t.id == TradeId) + .Select(t => (int?)t.ClientId) + .FirstOrDefault(); + +if (!currentClientId.HasValue) + throw new Exception("当前交易不存在或交易对手为空"); + +// 查询同一交易对手下所有有效交易的实时存续持仓,对标的去重计数 +var underlyingCodes = DbContext.swap_position + .Where(p => !p.IsInitial + && p.PosiQuantity > 0 + && !p.Invalid + && p.PosiDirection > 0 + && !string.IsNullOrEmpty(p.UnderlyingCode) + && DbContext.trade.Any(t => t.id == p.SwapTradeId + && t.ValidState != "InValid" + && t.ClientId == currentClientId.Value)) + .Select(p => p.UnderlyingCode) + .Distinct() + .ToList(); + +int distinctCount = underlyingCodes.Count; + +string underlyingList = underlyingCodes.Any() + ? string.Join("、", underlyingCodes.OrderBy(c => c)) + : "无"; + +return new RiskVariableValueDetail( + distinctCount, + $"交易对手ID为{currentClientId},存续标的共{distinctCount}个:{underlyingList}"); +``` + 汇总 SQL: ```sql diff --git a/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs b/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs index 725fcd7b..e090ee27 100644 --- a/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs +++ b/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs @@ -5017,7 +5017,7 @@ namespace YLErp.Modules.RiskModule } riskWarning += "风险预警: " + quotaTrial.RiskWarningDetails; } - log.risk_warning = riskWarning; + log.risk_warning = TruncateRiskCheckLogText(riskWarning); log.limit_warning = quotaTrial.QuotaCheckDetails; log.remark = RiskCheckTriggerRemark; if (isOldRiskErrorSpecialApproval) @@ -5411,6 +5411,17 @@ namespace YLErp.Modules.RiskModule return detail + "\n"; } + private const int RiskCheckLogTextMaxLength = 500; + + private static string TruncateRiskCheckLogText(string text) + { + if (string.IsNullOrEmpty(text) || text.Length <= RiskCheckLogTextMaxLength) + return text; + + const string suffix = "……(日志内容过长已截断)"; + return text.Substring(0, RiskCheckLogTextMaxLength - suffix.Length) + suffix; + } + /// /// 校验标的白名单 /// diff --git a/YLErpDAL/Modules/RiskModule/TradeRiskCheckLogService.cs b/YLErpDAL/Modules/RiskModule/TradeRiskCheckLogService.cs index 687754dd..f045e2f5 100644 --- a/YLErpDAL/Modules/RiskModule/TradeRiskCheckLogService.cs +++ b/YLErpDAL/Modules/RiskModule/TradeRiskCheckLogService.cs @@ -22,6 +22,16 @@ namespace YLErp.Modules.RiskModule { } + private const int RiskCheckLogTextMaxLength = 500; + + private static string TruncateRiskCheckLogText(string text) + { + if (string.IsNullOrEmpty(text) || text.Length <= RiskCheckLogTextMaxLength) + return text; + + const string suffix = "……(日志内容过长已截断)"; + return text.Substring(0, RiskCheckLogTextMaxLength - suffix.Length) + suffix; + } public SearchListResult Search(SearchTradeRiskCheckLogRequest req) { @@ -153,7 +163,7 @@ namespace YLErp.Modules.RiskModule } riskWarning += "风控预警: " + quotaTrial.RiskWarningDetails; } - log.risk_warning = riskWarning; + log.risk_warning = TruncateRiskCheckLogText(riskWarning); log.limit_warning = quotaTrial.QuotaCheckDetails; log.remark = quotaTrial.Remark; log.create_user = UserId; @@ -202,9 +212,10 @@ namespace YLErp.Modules.RiskModule log.client_name = quotaTrial.ClientName; log.trader = td?.TraderName; log.trade_number = quotaTrial.TradeNumber; - log.risk_warning = string.IsNullOrWhiteSpace(quotaTrial.RiskWarningDetails) + var riskWarning = string.IsNullOrWhiteSpace(quotaTrial.RiskWarningDetails) ? $"[风控预警处理]{decision}" : quotaTrial.RiskWarningDetails + Environment.NewLine + $"[风控预警处理]{decision}"; + log.risk_warning = TruncateRiskCheckLogText(riskWarning); log.limit_warning = quotaTrial.QuotaCheckDetails; log.remark = string.IsNullOrWhiteSpace(quotaTrial.Remark) ? $"风控预警处理结果:{decision}" diff --git a/YLErpWeb/Controllers/tradeController.cs b/YLErpWeb/Controllers/tradeController.cs index 39714b40..827bc8a6 100644 --- a/YLErpWeb/Controllers/tradeController.cs +++ b/YLErpWeb/Controllers/tradeController.cs @@ -2493,8 +2493,8 @@ namespace YLErp.Web.Controllers } var result = new TradeConfirmService(CurUser).tradeConfirm(tradeidArr, ignoreMoneyCheck, isSkipApproval, ignoreRiskWarning, ignoreRiskRuleIdArr); - //如果客户缺少资金而操作者有交易特批权限 - if (!ignoreMoneyCheck && !ignoreRiskWarning && result.LackOfMoney) + //如果需要前端确认信息,触发新老风控 + if (result.LackOfMoney) { if (result.type == TradeOpenRetCode.RiskWarning.ToString()) {