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 1/9] =?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 2/9] =?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 3/9] =?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()) { From 807ac15417cd717f69e936933300319240995969 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=A9=AC=E5=86=B0=E5=86=B0?= <437394478@qq.com> Date: Thu, 30 Jul 2026 17:46:14 +0800 Subject: [PATCH 4/9] =?UTF-8?q?feature:=E7=BB=AD=E4=BD=9C=E6=8C=89?= =?UTF-8?q?=E9=92=AE=E5=BA=94=E5=BD=93=E6=9C=89=E8=A7=92=E8=89=B2=E6=9D=83?= =?UTF-8?q?=E9=99=90=E6=8E=A7=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- YLErpWeb/App_Data/FunctionRight.xml | 3 ++- YLErpWeb/Common/UserInfoRight.cs | 7 ++++++- YLErpWeb/Controllers/SwapTrade2Controller.cs | 4 ++++ YLErpWeb/Views/SwapTrade2/header.cshtml | 5 ++++- 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/YLErpWeb/App_Data/FunctionRight.xml b/YLErpWeb/App_Data/FunctionRight.xml index 3d23be45..397e458d 100644 --- a/YLErpWeb/App_Data/FunctionRight.xml +++ b/YLErpWeb/App_Data/FunctionRight.xml @@ -12,6 +12,7 @@ + @@ -254,4 +255,4 @@ - \ No newline at end of file + diff --git a/YLErpWeb/Common/UserInfoRight.cs b/YLErpWeb/Common/UserInfoRight.cs index bc15c964..5db38dd8 100644 --- a/YLErpWeb/Common/UserInfoRight.cs +++ b/YLErpWeb/Common/UserInfoRight.cs @@ -406,6 +406,11 @@ namespace YLErp.Web /// public bool 交易管理_交易新增 => HasRight("交易管理-交易新增"); + /// + /// 交易管理-交易续做 + /// + public bool 交易管理_交易续做 => HasRight("交易管理-交易续做"); + /// /// 交易管理-交易编辑 /// @@ -712,4 +717,4 @@ namespace YLErp.Web public bool 发送证券报告 => HasRight("监管报告-发送证券报告"); } -} \ No newline at end of file +} diff --git a/YLErpWeb/Controllers/SwapTrade2Controller.cs b/YLErpWeb/Controllers/SwapTrade2Controller.cs index b0115d2f..c8c78e65 100644 --- a/YLErpWeb/Controllers/SwapTrade2Controller.cs +++ b/YLErpWeb/Controllers/SwapTrade2Controller.cs @@ -48,6 +48,10 @@ namespace YLErp.Web.Controllers public ActionResult TradeEdit(string enid, string renewEnid = null, bool isUseApproval = false) { ViewBag.isUseApproval = isUseApproval; + if (!string.IsNullOrWhiteSpace(renewEnid) && !CurUser.交易管理_交易续做) + { + return ShowError("没有续做交易权限"); + } // The new/renew flow uses the literal "0" to indicate that no trade exists yet. var intid = enid == "0" ? 0 : DecryptInt(enid); trade r = null; diff --git a/YLErpWeb/Views/SwapTrade2/header.cshtml b/YLErpWeb/Views/SwapTrade2/header.cshtml index d6706aff..333c8915 100644 --- a/YLErpWeb/Views/SwapTrade2/header.cshtml +++ b/YLErpWeb/Views/SwapTrade2/header.cshtml @@ -157,7 +157,10 @@ { @MyControls.Btn("收益结算", string.Format("unWindLongShortSwap('{0}')", tradeModel.EncryptId)) } - @MyControls.Btn("续做", string.Format("renewTrade('{0}')", tradeModel.EncryptId)) + @if (CurUser.交易管理_交易续做) + { + @MyControls.Btn("续作", string.Format("renewTrade('{0}')", tradeModel.EncryptId)) + } } From 20e4b82ae38e2d810ea87e31b4ab6c6522777636 Mon Sep 17 00:00:00 2001 From: tengyufan <1532636164@qq.com> Date: Thu, 30 Jul 2026 17:46:18 +0800 Subject: [PATCH 5/9] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=9B=BD=E8=81=94?= =?UTF-8?q?=E6=94=B6=E7=9B=8A=E4=BA=92=E6=8D=A2=E4=BA=A4=E6=98=93=E7=BC=96?= =?UTF-8?q?=E5=8F=B7=E9=A6=96=E6=AC=A1=E7=94=9F=E6=88=90=E5=A4=A7=E5=B0=8F?= =?UTF-8?q?=E5=86=99=E4=B8=8D=E4=B8=80=E8=87=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 首次入库后按 UpperTradeNumber 配置统一交易编号大小写 - 避免自动生成小写编号在后续编辑保存时被转为大写 - 补充交易编号大小写配置的单元测试 --- .../GuolianContractNoGeneratorTest.cs | 13 +++++++++++++ .../DocGenerateModule/GuolianContractNoGenerator.cs | 11 ++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/UnitTestProject/Modules/TradeModule/DocGenerateModule/GuolianContractNoGeneratorTest.cs b/UnitTestProject/Modules/TradeModule/DocGenerateModule/GuolianContractNoGeneratorTest.cs index 14580fd3..28e41c9d 100644 --- a/UnitTestProject/Modules/TradeModule/DocGenerateModule/GuolianContractNoGeneratorTest.cs +++ b/UnitTestProject/Modules/TradeModule/DocGenerateModule/GuolianContractNoGeneratorTest.cs @@ -33,6 +33,19 @@ namespace YLErp.UnitTestProject.Modules.TradeModule.DocGenerateModule blankTrade, CompanyEnum.国泰君安)); } + [DataTestMethod] + [DataRow("GLMS-st0728-20260729-FICC-Q-210210IB", true, "GLMS-ST0728-20260729-FICC-Q-210210IB")] + [DataRow("GLMS-st0728-20260729-FICC-Q-210210IB", false, "GLMS-st0728-20260729-FICC-Q-210210IB")] + public void NormalizeTradeNumberCase_AppliesUpperTradeNumberConfiguration( + string tradeNumber, + bool upperTradeNumber, + string expected) + { + var actual = GuolianContractNoGenerator.NormalizeTradeNumberCase(tradeNumber, upperTradeNumber); + + Assert.AreEqual(expected, actual); + } + [TestMethod] public void BuildTradeNumber_ClientTrade_UsesNumericSequenceAndSanitizesUnderlyingCode() { diff --git a/YLErpDAL/Modules/TradeModule/DocGenerateModule/GuolianContractNoGenerator.cs b/YLErpDAL/Modules/TradeModule/DocGenerateModule/GuolianContractNoGenerator.cs index b1d666a0..6a26328a 100644 --- a/YLErpDAL/Modules/TradeModule/DocGenerateModule/GuolianContractNoGenerator.cs +++ b/YLErpDAL/Modules/TradeModule/DocGenerateModule/GuolianContractNoGenerator.cs @@ -62,10 +62,19 @@ namespace YLErp.Modules.TradeModule.DocGenerateModule throw new ServiceException("对手方代码缩写缺失,请联系运营组同事维护"); } - trade.TradeNumber = Generate(dbContext, trade, clientCode); + trade.TradeNumber = NormalizeTradeNumberCase( + Generate(dbContext, trade, clientCode), + PS.Config.ErpElement.UpperTradeNumber); return true; } + public static string NormalizeTradeNumberCase(string tradeNumber, bool upperTradeNumber) + { + return upperTradeNumber && !string.IsNullOrEmpty(tradeNumber) + ? tradeNumber.ToUpperInvariant() + : tradeNumber; + } + /// /// 按国联确认书编号规则拼装编号(纯函数,不依赖数据库,便于单测)。 /// 格式:GLMS-{clientCode}-{成交日期(yyyyMMdd)}-FICC-{序号}-{标的代码(去点)} From cb2076d9ffdb53dd05a9083e0ece7bd1675aeba7 Mon Sep 17 00:00:00 2001 From: tengyufan <1532636164@qq.com> Date: Thu, 30 Jul 2026 17:58:51 +0800 Subject: [PATCH 6/9] =?UTF-8?q?fix:=20=E4=BA=A4=E6=98=93=E7=A1=AE=E8=AE=A4?= =?UTF-8?q?=E4=B9=A6=E7=BC=96=E5=8F=B7=E7=AD=9B=E9=80=89=E9=A1=B9=E4=BF=9D?= =?UTF-8?q?=E6=8C=81=E5=8D=95=E8=A1=8C=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 国联交易确认书页面加宽交易确认书编号筛选项标签和容器 - 避免交易确认书编号标签自动换行 --- YLErpWeb/Views/TradeConfirmBook/Index.cshtml | 21 +++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/YLErpWeb/Views/TradeConfirmBook/Index.cshtml b/YLErpWeb/Views/TradeConfirmBook/Index.cshtml index 91a7b06e..d1d54d2b 100644 --- a/YLErpWeb/Views/TradeConfirmBook/Index.cshtml +++ b/YLErpWeb/Views/TradeConfirmBook/Index.cshtml @@ -44,6 +44,15 @@ .no-skin { position: static !important; } + + .searchdiv .contract-code-search-group { + width: 272px; + } + + .searchdiv .contract-code-search-group .search-label { + width: 110px; + white-space: nowrap; + } } @section JS{ @@ -57,7 +66,17 @@
@Html.ShortInput("TradeNumber", "交易编号") - @Html.ShortInput("ContractCode", pageObj.isGuoLian ? "交易确认书编号" : "合约编号") + @if (pageObj.isGuoLian) + { +
+ 交易确认书编号 + +
+ } + else + { + @Html.ShortInput("ContractCode", "合约编号") + } @Html.MyAceDropdownInput("ClientId", "客户名称", ClientDataModel.GetAllClient()) @Html.MyAceDropdownInput("AssetId", "簿记账户", AssetunitController.GetClientassetunit()) @Html.MyAceDropdownInput("GroupId", "簿记账户组", AssetUnitModel.GetAllAssetUnitGroupItem()) From 74c434bdd8377e74669f188f1db6349555237163 Mon Sep 17 00:00:00 2001 From: hjhan Date: Fri, 31 Jul 2026 09:07:08 +0800 Subject: [PATCH 7/9] =?UTF-8?q?fix(swaptrade):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E9=87=8D=E8=BE=93/=E9=87=8D=E8=B4=B4=E5=8E=9F=E5=80=BC?= =?UTF-8?q?=E5=90=8E=E5=9B=9E=E8=BD=A6=E4=B8=8D=E8=A7=A6=E5=8F=91=E8=AE=A1?= =?UTF-8?q?=E7=AE=97=E3=80=81=E7=B2=98=E8=B4=B4=E4=B8=8D=E6=A0=87=E8=AE=B0?= =?UTF-8?q?REV=E7=9A=84=E9=9D=99=E9=BB=98=E5=A4=B1=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - swapPricePrecisionHelper: onKeydown 回车时无条件派发 input+enter, 不再依赖仅值变化才触发的原生 change;onPaste 粘贴即派发 input 标 REV; onChange 不再派发 enter(改由 onKeydown 唯一派发,避免重复计算) - 新增 fe-tests/swapPriceInput.component.test.js 回归守卫(6 用例, 回退旧版即变红,锁定该 QA 高优 Bug 修复) - bundle/bundleV2: otcDebug 调试开关改为 localStorage 持久化 + URL ?otcdebug=1 触发,便于排查价格计算链路 --- .../fe-tests/swapPriceInput.component.test.js | 100 ++++++++++++++++++ .../app/swaptrade/swapPricePrecisionHelper.js | 18 +++- YLErpWeb/wwwroot/Statics/bundles/bundle.js | 33 ++++-- YLErpWeb/wwwroot/Statics/bundles/bundleV2.js | 32 ++++-- 4 files changed, 161 insertions(+), 22 deletions(-) create mode 100644 YLErpWeb/fe-tests/swapPriceInput.component.test.js diff --git a/YLErpWeb/fe-tests/swapPriceInput.component.test.js b/YLErpWeb/fe-tests/swapPriceInput.component.test.js new file mode 100644 index 00000000..7080f8cd --- /dev/null +++ b/YLErpWeb/fe-tests/swapPriceInput.component.test.js @@ -0,0 +1,100 @@ +/** + * swapPriceInput.component.test.js — vue-swap-price-input 事件派发回归守卫 + * ============================================================================ + * 目的:锁定债券三字段输入组件「粘贴 / 重输原值 不派发 input/enter」的 bug。 + * 症状:粘贴或重输原值后,点空白(标REV/清其他标识)与回车(触发计算)均无反应。 + * 根因:组件把 input/enter 全部锁在原生 change 事件里,而 change 仅在「值相对聚焦时 + * 变动」才触发;onPaste 甚至完全不派发任何事件。 + * + * 做法:直接实例化组件 options 对象(createVueInputComponent),用 mock this + $emit 间谍 + * 驱动 onPaste/onKeydown/onChange,断言事件派发。不依赖 Vue 运行时挂载、不改动源文件。 + * + * 运行:cd YLErpWeb/fe-tests && npx jest swapPriceInput.component + * 设 SWAP_PRICE_HELPER 指向其它版本文件可做对照(如改之前的旧代码应使用例变红)。 + */ + +const fs = require('fs'); +const path = require('path'); + +function loadComponent() { + const rel = process.env.SWAP_PRICE_HELPER + || '../wwwroot/Scripts/app/swaptrade/swapPricePrecisionHelper.js'; + const file = path.resolve(__dirname, rel); + const code = fs.readFileSync(file, 'utf8'); + // swapPricePrecisionHelper.js: var swapPricePrecision = (function(global){...})(window); + // 在受控作用域求值并取出 createVueInputComponent(IIFE 不挂 window,故用 new Function 取返回值)。 + const factory = new Function('window', 'global', 'console', code + '\n;return swapPricePrecision;'); + const sandbox = {}; + const swapPricePrecision = factory(sandbox, sandbox, console); + return swapPricePrecision.createVueInputComponent(); +} + +function makeCtx(component, opts) { + opts = opts || {}; + const emitted = []; + const ctx = { + text: opts.text != null ? String(opts.text) : '', + enterPressed: false, + formatSnapshot: '{}', + format: opts.format || { integerDigits: 2, precision: 4, percent: true }, + $emit: function (name, payload) { emitted.push({ name: name, payload: payload }); }, + }; + Object.keys(component.methods).forEach(function (m) { + ctx[m] = component.methods[m].bind(ctx); + }); + return { ctx: ctx, emitted: emitted }; +} + +describe('vue-swap-price-input 事件派发(粘贴 / 重输原值场景)', () => { + const component = loadComponent(); + + test('粘贴原值应派发 input(点空白标REV / 清其他标识)', () => { + const { ctx, emitted } = makeCtx(component, { text: '3.5', format: { integerDigits: 2, precision: 4, percent: true } }); + const evt = { preventDefault() {}, clipboardData: { getData: () => '3.5' }, target: { value: '' } }; + ctx.onPaste(evt); + const inputs = emitted.filter(e => e.name === 'input'); + expect(inputs.length).toBe(1); + expect(inputs[0].payload).toBe('0.035'); // percent:true → ÷100 + }); + + test('回车(原值未变)应派发 enter(触发自动计算)', () => { + const { ctx, emitted } = makeCtx(component, { text: '3.5', format: { integerDigits: 2, precision: 4, percent: true } }); + const evt = { which: 13, keyCode: 13, key: 'Enter', target: { blur() {}, value: '3.5' } }; + ctx.onKeydown(evt); + const enters = emitted.filter(e => e.name === 'enter'); + expect(enters.length).toBe(1); + expect(enters[0].payload).toBe('0.035'); + }); + + test('回车(值已改)应派发 input+enter 各一次,且不重复派发 enter', () => { + const { ctx, emitted } = makeCtx(component, { text: '3.6', format: { percent: true } }); + const evt = { which: 13, keyCode: 13, key: 'Enter', target: { blur() {}, value: '3.6' } }; + ctx.onKeydown(evt); + expect(emitted.filter(e => e.name === 'input').length).toBe(1); + expect(emitted.filter(e => e.name === 'enter').length).toBe(1); + }); + + test('非 Enter 按键不应派发 enter / input', () => { + const { ctx, emitted } = makeCtx(component, { text: '3.5', format: { percent: true } }); + const evt = { which: 65, keyCode: 65, key: 'a', target: { blur() {} } }; + ctx.onKeydown(evt); + expect(emitted.filter(e => e.name === 'enter').length).toBe(0); + expect(emitted.filter(e => e.name === 'input').length).toBe(0); + }); + + test('失焦(onChange)即使值未变也应派发 input(点空白标REV),且不再派发 enter', () => { + const { ctx, emitted } = makeCtx(component, { text: '3.5', format: { percent: true } }); + const evt = { target: { value: '3.5' } }; + ctx.onChange(evt); + expect(emitted.filter(e => e.name === 'input').length).toBe(1); + expect(emitted.filter(e => e.name === 'enter').length).toBe(0); + }); + + test('粘贴但格式为 percent:false 也应派发 input(原值透传)', () => { + const { ctx, emitted } = makeCtx(component, { text: '3.5', format: { integerDigits: 2, precision: 4, percent: false } }); + const evt = { preventDefault() {}, clipboardData: { getData: () => '3.5' }, target: { value: '' } }; + ctx.onPaste(evt); + expect(emitted.filter(e => e.name === 'input').length).toBe(1); + expect(emitted.filter(e => e.name === 'input')[0].payload).toBe('3.5'); + }); +}); diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapPricePrecisionHelper.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapPricePrecisionHelper.js index be9e3ed6..7bacf7a3 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapPricePrecisionHelper.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapPricePrecisionHelper.js @@ -208,22 +208,32 @@ var swapPricePrecision = (function (global) { event.preventDefault(); this.updateValue(clipboard.getData('text'), true, false); event.target.value = this.text; + // 粘贴即显式赋值意图:直接派发 input,使「单击空白处标REV/其余清空」「回车前已标记」等行为生效。 + // 原实现只把粘贴内容写回DOM、等失焦时的原生change才派发,而原值粘贴时change不触发→静默无反应。 + this.$emit('input', this.toModel(this.text)); }, onKeydown: function (event) { this.enterPressed = false; this.$emit('keydown', event); const keyCode = event.which || event.keyCode; if (event.key !== 'Enter' && keyCode !== 13 && keyCode !== 108) return; - this.enterPressed = true; - event.target.blur(); + // 回车=用户显式提交意图:无论值是否相对聚焦时变化,都直接派发 input(标REV) + enter(触发计算)。 + // 原实现把 enter 锁在原生 change 里,而 change 仅在值变化时触发, + // 导致「重输/重贴原值 + 回车」静默不计算(QA 高优 Bug)。 + this.updateValue(this.text, true, false); + const value = this.toModel(this.text); + event.target.blur(); // 同步触发原生 change(值变化时再派发一次 input,幂等无害) + this.$emit('input', value); + this.$emit('enter', value); + this.enterPressed = false; }, onChange: function (event) { if (this.text.endsWith('.')) this.text = this.text.substring(0, this.text.length - 1); this.updateValue(this.text, true, false); const value = this.toModel(this.text); + // 失焦(非回车)→ 仅派发 input 标 REV;enter(计算) 改由 onKeydown 直接派发, + // 避免 change 不触发时丢失,也避免与 onKeydown 重复派发 enter 造成重复计算。 this.$emit('input', value); - if (this.enterPressed) this.$emit('enter', value); - this.enterPressed = false; event.target.value = this.text; } }, diff --git a/YLErpWeb/wwwroot/Statics/bundles/bundle.js b/YLErpWeb/wwwroot/Statics/bundles/bundle.js index cec1dbb7..915524de 100644 --- a/YLErpWeb/wwwroot/Statics/bundles/bundle.js +++ b/YLErpWeb/wwwroot/Statics/bundles/bundle.js @@ -14667,6 +14667,7 @@ $.fn.selectpicker.Constructor.DEFAULTS = Object.assign($.fn.selectpicker.Constru }; }(window.FastVue)); + //日期选择组件FastVue.DatePicker (function (global) { global.vueDatePicker = function () { @@ -15009,21 +15010,35 @@ $.fn.selectpicker.Constructor.DEFAULTS = Object.assign($.fn.selectpicker.Constru //依赖类库:lodash.js + jquery + jquery.validate + bootstrap.emodal.js // 统一调试日志工具:仅在 otcdebug 开启时输出,默认静默(生产零噪音)。 -// 开启方式:URL 加 ?otcdebug=1,或控制台 localStorage.setItem('otcdebug','1')。 -// 业务脚本逐步改用 otcDebug.banner / otcDebug.log 替代裸 console.log, -// 既能在排查时一键全开,又能避免版本标记散落硬编码(见 swapTradeEdit.js / fastVue.base.js 用法)。 -var __otcDiag = (window.ylotc && window.ylotc.__diag) || { debug: false }; +// 开启:URL 加 ?otcdebug=1(会自动写入 localStorage,同源 iframe/弹窗自动继承); +// 或控制台 localStorage.setItem('otcdebug','1')。关闭:localStorage.removeItem('otcdebug')。 +// 业务脚本用 otcDebug.banner / otcDebug.log 替代裸 console.log(见 swapTradeEdit.js / fastVue.base.js)。 +// +// 命名约定(避免 __私有对象 + otcDebug.API 大小写混搭): +// __diag —— 版本元数据对象(仅 _MainLayout 灌入 ylotc.__diag 时存在,仅供 banner 展示,不参与开关判定) +// __debug —— 调试开关布尔值(唯一真相来源:localStorage 优先 → URL 参数 → 兜底 __diag.debug) +var __diag = (window.ylotc && window.ylotc.__diag) || {}; +var __debug = (function () { + try { + // 1) URL 参数优先(排查最直观);命中即写入 localStorage,供同源 iframe/弹窗继承 + if (/[?&]otcdebug=1/.test(location.search)) { localStorage.setItem('otcdebug', '1'); return true; } + // 2) 已持久化的开关(父页面开过一次,_InfoLayout 弹窗自动生效) + if (localStorage.getItem('otcdebug') === '1') return true; + } catch (e) { /* localStorage 不可用时忽略,继续走兜底 */ } + // 3) 兜底:layout 灌入的 ylotc.__diag.debug(仅 _MainLayout 有) + return !!__diag.debug; +})(); window.otcDebug = { - // 模块加载横幅:F12 一眼看到模块名+bundle版本+git sha,用于排查"是不是加载了旧代码/旧缓存" + // 模块加载横幅:F12 一眼看到模块名+版本,用于排查"是不是加载了旧代码/旧缓存" banner: function (name, ver) { - if (!__otcDiag.debug || !window.console) return; - var git = (__otcDiag.git || '').slice(0, 7); - console.log('%c[' + name + '] v' + ver + ' (bundle=' + __otcDiag.jsVersion + ', git=' + git + ', built=' + __otcDiag.built + ')', + if (!__debug || !window.console) return; + var git = (__diag.git || '').slice(0, 7); + console.log('%c[' + name + '] v' + ver + ' (bundle=' + __diag.jsVersion + ', git=' + git + ', built=' + __diag.built + ')', 'color:#06c;font-weight:bold'); }, // 普通调试日志:透传参数,仅 debug 开启时输出 log: function () { - if (!__otcDiag.debug || !window.console) return; + if (!__debug || !window.console) return; console.log.apply(console, arguments); } }; diff --git a/YLErpWeb/wwwroot/Statics/bundles/bundleV2.js b/YLErpWeb/wwwroot/Statics/bundles/bundleV2.js index ed738bb6..bee09b9e 100644 --- a/YLErpWeb/wwwroot/Statics/bundles/bundleV2.js +++ b/YLErpWeb/wwwroot/Statics/bundles/bundleV2.js @@ -802,21 +802,35 @@ An}();typeof define=="function"&&typeof define.amd=="object"&&define.amd?($n._=r //依赖类库:lodash.js + jquery + jquery.validate + bootstrap.emodal.js // 统一调试日志工具:仅在 otcdebug 开启时输出,默认静默(生产零噪音)。 -// 开启方式:URL 加 ?otcdebug=1,或控制台 localStorage.setItem('otcdebug','1')。 -// 业务脚本逐步改用 otcDebug.banner / otcDebug.log 替代裸 console.log, -// 既能在排查时一键全开,又能避免版本标记散落硬编码(见 swapTradeEdit.js / fastVue.base.js 用法)。 -var __otcDiag = (window.ylotc && window.ylotc.__diag) || { debug: false }; +// 开启:URL 加 ?otcdebug=1(会自动写入 localStorage,同源 iframe/弹窗自动继承); +// 或控制台 localStorage.setItem('otcdebug','1')。关闭:localStorage.removeItem('otcdebug')。 +// 业务脚本用 otcDebug.banner / otcDebug.log 替代裸 console.log(见 swapTradeEdit.js / fastVue.base.js)。 +// +// 命名约定(避免 __私有对象 + otcDebug.API 大小写混搭): +// __diag —— 版本元数据对象(仅 _MainLayout 灌入 ylotc.__diag 时存在,仅供 banner 展示,不参与开关判定) +// __debug —— 调试开关布尔值(唯一真相来源:localStorage 优先 → URL 参数 → 兜底 __diag.debug) +var __diag = (window.ylotc && window.ylotc.__diag) || {}; +var __debug = (function () { + try { + // 1) URL 参数优先(排查最直观);命中即写入 localStorage,供同源 iframe/弹窗继承 + if (/[?&]otcdebug=1/.test(location.search)) { localStorage.setItem('otcdebug', '1'); return true; } + // 2) 已持久化的开关(父页面开过一次,_InfoLayout 弹窗自动生效) + if (localStorage.getItem('otcdebug') === '1') return true; + } catch (e) { /* localStorage 不可用时忽略,继续走兜底 */ } + // 3) 兜底:layout 灌入的 ylotc.__diag.debug(仅 _MainLayout 有) + return !!__diag.debug; +})(); window.otcDebug = { - // 模块加载横幅:F12 一眼看到模块名+bundle版本+git sha,用于排查"是不是加载了旧代码/旧缓存" + // 模块加载横幅:F12 一眼看到模块名+版本,用于排查"是不是加载了旧代码/旧缓存" banner: function (name, ver) { - if (!__otcDiag.debug || !window.console) return; - var git = (__otcDiag.git || '').slice(0, 7); - console.log('%c[' + name + '] v' + ver + ' (bundle=' + __otcDiag.jsVersion + ', git=' + git + ', built=' + __otcDiag.built + ')', + if (!__debug || !window.console) return; + var git = (__diag.git || '').slice(0, 7); + console.log('%c[' + name + '] v' + ver + ' (bundle=' + __diag.jsVersion + ', git=' + git + ', built=' + __diag.built + ')', 'color:#06c;font-weight:bold'); }, // 普通调试日志:透传参数,仅 debug 开启时输出 log: function () { - if (!__otcDiag.debug || !window.console) return; + if (!__debug || !window.console) return; console.log.apply(console, arguments); } }; From a871bcb9fe0467180a52193de1656a89dcd006e7 Mon Sep 17 00:00:00 2001 From: hjhan Date: Fri, 31 Jul 2026 09:10:05 +0800 Subject: [PATCH 8/9] =?UTF-8?q?fix(login):=20=E7=99=BB=E5=BD=95=E9=A1=B5?= =?UTF-8?q?=20NeedCaptcha=3Dfalse=20=E6=97=B6=20CaptchaClick=20=E9=81=BF?= =?UTF-8?q?=E5=85=8D=20null.setAttribute=20=E6=8A=A5=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 页面加载时无条件调用 CaptchaClick(),但 仅在 NeedCaptcha=true 时渲染,导致无需验证码时 getElementById 返回 null, null.setAttribute 抛错。增加 if (!img) return 守卫,img 存在时行为不变。 --- YLErpWeb/Views/Account/Login.cshtml | 1 + 1 file changed, 1 insertion(+) diff --git a/YLErpWeb/Views/Account/Login.cshtml b/YLErpWeb/Views/Account/Login.cshtml index c3e8ae00..f71d0ea7 100644 --- a/YLErpWeb/Views/Account/Login.cshtml +++ b/YLErpWeb/Views/Account/Login.cshtml @@ -34,6 +34,7 @@ }) function CaptchaClick(){ var img = document.getElementById('captcha'); + if (!img) return; // NeedCaptcha=false 时验证码 img 不渲染,避免 null.setAttribute 报错 var newSrc = '@captchaUrl' + '&t=' + new Date().getTime(); img.setAttribute('src', newSrc); } From ca74095e0c26d9fba9a5cb6a40811ebaa5ec11f3 Mon Sep 17 00:00:00 2001 From: hjhan Date: Fri, 31 Jul 2026 09:10:18 +0800 Subject: [PATCH 9/9] =?UTF-8?q?fix(sac):=20=E4=BF=AE=E5=A4=8D=E9=A2=84?= =?UTF-8?q?=E8=A7=88=E9=A1=B5=E5=BA=8F=E5=88=97=E5=8C=96=20SacInfo=20?= =?UTF-8?q?=E6=97=B6=20TypeId=20=E8=A7=A6=E5=8F=91=20IsGenericParameter=20?= =?UTF-8?q?=E5=BC=82=E5=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SacDescriptionAttribute 增加 ShouldSerializeTypeId() 返回 false, 跳过 Attribute.TypeId(System.Type)的序列化,避免 Newtonsoft 抛 "Method may only be called on a Type for which Type.IsGenericParameter is true." (13.0.1 安全版对 Type 成员序列化行为变更后暴露) - ExceptionMiddleware 改用 exception.ToString() 记录完整异常(类型+ 内层 message+堆栈),替代仅取最内层 message,便于定位此类反射异常根因; 该信息仅写入日志,不返回前端 --- .../SAC/Common/Sac_TranslateHelper.cs | 6 ++++++ YLErpWeb/App/ExceptionMiddleware.cs | 17 ++++------------- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/YLErpDAL/Modules/SuperviseReportModule/SAC/Common/Sac_TranslateHelper.cs b/YLErpDAL/Modules/SuperviseReportModule/SAC/Common/Sac_TranslateHelper.cs index 822f4c2e..4a2bc9a5 100644 --- a/YLErpDAL/Modules/SuperviseReportModule/SAC/Common/Sac_TranslateHelper.cs +++ b/YLErpDAL/Modules/SuperviseReportModule/SAC/Common/Sac_TranslateHelper.cs @@ -117,6 +117,12 @@ namespace YLErp.Modules.SuperviseReportModule.SAC.Common [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field, AllowMultiple = true)] public class SacDescriptionAttribute : Attribute { + // Attribute.TypeId 本质是 System.Type,Newtonsoft 序列化会触发 + // "Method may only be called on a Type for which Type.IsGenericParameter is true." + // 预览页用 ToJson() 序列化 SacInfo/List 时崩溃,故显式跳过该成员。 + // (13.0.1 安全版对 Type 成员序列化行为变更后暴露此问题) + public bool ShouldSerializeTypeId() => false; + /// /// 字段名 /// diff --git a/YLErpWeb/App/ExceptionMiddleware.cs b/YLErpWeb/App/ExceptionMiddleware.cs index a0c469d2..5163baaa 100644 --- a/YLErpWeb/App/ExceptionMiddleware.cs +++ b/YLErpWeb/App/ExceptionMiddleware.cs @@ -37,13 +37,15 @@ namespace YLErp.Web.App try { - message = GetInnerExceptionMessage(exception); + // 暴露完整异常(类型 + 所有内层 message + 堆栈),不再只取最内层 message, + // 便于定位根因(如 SacInfo 序列化 TypeId 触发的 IsGenericParameter 反射异常)。 + message = exception.ToString(); if (serviceExpcetion == null || serviceExpcetion.IsFaultError) { var result = await request.BodyReader.ReadAsync(); var reqBody = ConvertBufferToString(result.Buffer); - LogFactory.GetLogger(context.Request.Path.Value).Error(serviceExpcetion ?? exception, $"[query]:{request.QueryString.Value};[body]:{reqBody}"); + LogFactory.GetLogger(context.Request.Path.Value).Error(serviceExpcetion ?? exception, $"[query]:{request.QueryString.Value};[body]:{reqBody}\r\n{message}"); } } catch (Exception ex) @@ -77,16 +79,5 @@ namespace YLErp.Web.App ReadOnlySpan span = readOnlySequence.IsSingleSegment ? readOnlySequence.First.Span : readOnlySequence.ToArray().AsSpan(); return System.Text.Encoding.UTF8.GetString(span); } - - private static string GetInnerExceptionMessage(Exception ex) - { - var exceptionStr = ex.Message; - while (ex.InnerException != null) - { - exceptionStr = ex.InnerException.Message; - ex = ex.InnerException; - } - return exceptionStr; - } } } \ No newline at end of file