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/Compile/RuleCompiler.cs b/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs index 5d7be9d3..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, @@ -366,7 +368,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/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/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/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..00e18987 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) @@ -180,8 +180,8 @@ using YLErp.Modules.RiskEngine.Dto; 【注意事项】 1. RiskEngineService 由另一个团队开发实现,本服务通过 GetInstance() 获取单例引用 - 2. 规则变更后调用 RefreshOneRuleCache(ruleId),应用变更后调用 RefreshApplication(),变量定义变更后刷新引用规则缓存 - 3. 变量删除为硬删除(物理删除),规则/应用删除为软删除(Status=Deleted) + 2. 规则变更后调用 RefreshOneRuleCache(ruleId),应用变更后调用 RefreshApplication(),变量变更不触发引擎缓存刷新 + 3. 变量/规则/应用删除均为软删除(Status=Deleted),新建变量默认 Active 4. ExportAuditLogs 按查询条件导出,并生成包含查询条件的文件名 5. VariableExpr 保存时校验长度,并通过 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", "修改变量" }, @@ -353,12 +355,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(自由文本模式:括号匹配) @@ -505,9 +510,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; } @@ -570,6 +576,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:去除空格和重复项,统一为无空格格式。 /// @@ -734,7 +818,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 { @@ -742,7 +827,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() }; } @@ -758,7 +845,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 @@ -979,28 +1068,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() + }; } /// @@ -1028,7 +1146,7 @@ namespace YLErp.Modules.RiskEngine } /// - /// 停用规则(仅 Active → Disabled) + /// 停用规则(仅 Active → Disabled,不修改关联应用状态) /// public DisableRuleResult DisableRule(long ruleId) { @@ -1036,47 +1154,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 + }; } /// @@ -1120,7 +1215,7 @@ namespace YLErp.Modules.RiskEngine } /// - /// 批量删除规则(逐条保护检查) + /// 批量删除规则,并从所有未删除应用中移除成功删除的规则关联 /// public BatchOperationResult BatchDeleteRules(List ruleIds) { @@ -1129,45 +1224,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); @@ -1236,7 +1361,7 @@ namespace YLErp.Modules.RiskEngine } /// - /// 批量停用规则(仅 Active → Disabled,级联停用关联应用) + /// 批量停用规则(仅 Active → Disabled,不修改关联应用状态) /// public BatchOperationResult BatchDisableRules(List ruleIds) { @@ -1245,14 +1370,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) { @@ -1267,38 +1389,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); @@ -1318,14 +1430,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, @@ -1339,7 +1452,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; @@ -1368,7 +1483,7 @@ namespace YLErp.Modules.RiskEngine #region Application Management /// - /// 查询应用配置列表(多条件筛选,内存关联规则名称) + /// 查询应用配置列表(多条件筛选,批量计算关联规则统计) /// public SearchListResult QueryApplicationList(QueryRiskApplicationReq req) { @@ -1376,20 +1491,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 @@ -1401,16 +1507,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); @@ -1436,11 +1538,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) { @@ -1449,7 +1556,7 @@ namespace YLErp.Modules.RiskEngine if (app == null) throw new ServiceException("应用配置不存在或已删除"); - return new RiskApplicationDetail + var result = new RiskApplicationDetail { Id = app.id, RuleIds = app.RuleIds, @@ -1468,6 +1575,8 @@ namespace YLErp.Modules.RiskEngine UpdateOptName = app.UpdateOptName, UpdateDate = app.UpdateDate ?? app.OptDate.GetValueOrDefault() }; + PopulateApplicationAvailability(result, LoadRuleStatuses(new[] { app.RuleIds })); + return result; } /// @@ -1641,7 +1750,7 @@ namespace YLErp.Modules.RiskEngine } /// - /// 启用应用配置(校验关联规则状态) + /// 启用应用配置(至少存在一条 Active 关联规则) /// public void EnableApplication(long applicationId) { @@ -1649,12 +1758,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; @@ -1662,7 +1769,6 @@ namespace YLErp.Modules.RiskEngine WriteAuditLog("APP_ENABLE", "APPLICATION", applicationId, ResolveRuleNames(app.RuleIds), "启用应用配置"); SaveChangesWithConcurrencyCheck(); - RiskEngineService.GetInstance().RefreshApplication(); } @@ -1689,7 +1795,7 @@ namespace YLErp.Modules.RiskEngine } /// - /// 批量启用应用配置(逐条校验关联规则) + /// 批量启用应用配置(逐条校验至少一条关联规则已启用) /// public BatchOperationResult BatchEnableApplications(List applicationIds) { @@ -1718,11 +1824,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; } @@ -1812,14 +1917,27 @@ namespace YLErp.Modules.RiskEngine } /// - /// 校验结构化 ConditionJson 引用的变量是否存在。 + /// 生成变量删除被阻止时的引用规则提示。 /// - private List ValidateConditionJsonVariables(string conditionJson) + 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 引用的变量是否存在,并返回未删除的变量定义。 + /// + 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)) + .Where(variable => variableIds.Contains(variable.id) && variable.Status != RiskRuleStatus.Deleted) .ToList(); var variableMap = variables.ToDictionary(variable => (long)variable.id); @@ -1849,7 +1967,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) { @@ -1866,6 +1985,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 { @@ -1878,7 +2002,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); @@ -1906,6 +2031,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, @@ -1936,6 +2062,7 @@ namespace YLErp.Modules.RiskEngine ValueDomain = req.ValueDomain, Description = req.Description, VariableExpr = req.VariableExpr, + Status = RiskRuleStatus.Active, SortOrder = req.SortOrder, Version = 1 }; @@ -2053,6 +2180,7 @@ namespace YLErp.Modules.RiskEngine } InvalidateVariableCache(); + StructuredRuleExecutor.RemoveVariableCompiledCache(variableId); foreach (var rule in affectedRules) { RiskEngineService.GetInstance().RefreshOneRuleCache(rule.id); @@ -2063,33 +2191,38 @@ namespace YLErp.Modules.RiskEngine WriteAuditLog("VAR_UPDATE", "VARIABLE", variableId, req.VariableName, $"修改变量:{variable.VariableName}"); SaveChangesWithConcurrencyCheck(); InvalidateVariableCache(); + StructuredRuleExecutor.RemoveVariableCompiledCache(variableId); } return GetVariableDetail(variableId); } /// - /// 删除变量(有 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(); InvalidateVariableCache(); + StructuredRuleExecutor.RemoveVariableCompiledCache(variableId); } /// - /// 批量删除变量(不存在或被生效规则引用的变量将被跳过) + /// 批量删除变量(不存在或被未删除规则引用的变量将被跳过) /// public BatchOperationResult BatchDeleteVariables(List variableIds) { @@ -2097,9 +2230,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(); @@ -2107,20 +2242,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", "变量已删除")); } @@ -2129,8 +2269,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); @@ -2143,7 +2287,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 { @@ -2152,7 +2296,8 @@ namespace YLErp.Modules.RiskEngine Category = v.Category, DataType = v.DataType, Unit = v.Unit, - VariableExpr = v.VariableExpr + VariableExpr = v.VariableExpr, + Status = v.Status }) .ToList(); @@ -2339,4 +2484,4 @@ namespace YLErp.Modules.RiskEngine #endregion } -} \ No newline at end of file +} diff --git a/YLErpDAL/Modules/RiskEngine/StructuredRuleExecutor.cs b/YLErpDAL/Modules/RiskEngine/StructuredRuleExecutor.cs index af9e7171..7ba84352 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,54 @@ 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 ? "是" : "否"; + 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); + } + + /// + /// 获取操作符中文说明,用于正常命中时解释实际值与阈值的关系。 + /// + 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 +625,9 @@ namespace YLErp.Modules.RiskEngine } } + /// + /// 结构化规则执行结果。Success 表示执行过程是否成功,Triggered 表示规则条件是否命中。 + /// public class StructuredRuleExecuteResult { public bool Success { get; set; } @@ -412,12 +636,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 +662,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..f3397446 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, @@ -1511,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 @@ -1578,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) @@ -1600,7 +1665,21 @@ decimal openingNotional = (decimal)openingNotionalRaw.Value; if (openingNotional == 0m) throw new Exception("开仓名义本金为0,不能作为除数"); -return marginPaymentAmount / openingNotional; +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},{marginPaymentMessage}"); ``` 规则字段口径: @@ -1708,6 +1787,46 @@ ORDER BY ABS((预付金返息率 - 1) * 100) > 阈值 ``` +变量 Roslyn 示例:变量名为“保证金利率偏离”,DataType 为 Numeric;规则前端仍配置“保证金利率偏离 > 阈值”。脚本会查询当前 TradeId 下所有符合口径的保证金记录,变量值返回最大偏离值用于判断,命中说明列出所有存在偏离的记录。`InterestRateDefault` 实体字段类型为 decimal,可直接参与 decimal 计算。 + +```csharp +var marginRateItems = DbContext.swap_position + .Where(p => p.SwapTradeId == TradeId + && p.IsInitial + && !p.Invalid + && (p.InterestMode == 5 || p.InterestMode == 6)) + .Select(p => new + { + p.id, + p.InterestRateDefault, + InterestRateDeviation = Math.Abs((p.InterestRateDefault - 1m) * 100m) + }) + .ToList(); + +if (!marginRateItems.Any()) + throw new Exception("预付金记录不存在"); + +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( + maxDeviationItem.InterestRateDeviation, + deviationMessage); +``` + 规则字段口径: ```text @@ -1812,6 +1931,56 @@ ORDER BY 保证金收取金额 ÷ 开仓名义本金 < 20% ``` +变量 Roslyn 示例:变量名为“保证金收取比例”,DataType 为 Numeric;规则前端仍配置“保证金收取比例 < 0.2”。字段类型需为 decimal / decimal?,否则需要字段层调整或显式类型转换。 + +```csharp +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 => 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) + .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; + +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},{marginReceiveMessage}"); +``` + 规则字段口径: ```text @@ -1916,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 @@ -2245,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 @@ -2328,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 则命中。 ``` 规则公式: @@ -2343,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 @@ -2366,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, @@ -2392,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 @@ -2399,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; ``` --- @@ -2631,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 e2315e10..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) @@ -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,32 @@ 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"; + } + + 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/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 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()) {