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())
{