diff --git a/Framework/YLErp.Core/DBModels/glms_risk_variable.cs b/Framework/YLErp.Core/DBModels/glms_risk_variable.cs
index 5dad1ea6..59d478cb 100644
--- a/Framework/YLErp.Core/DBModels/glms_risk_variable.cs
+++ b/Framework/YLErp.Core/DBModels/glms_risk_variable.cs
@@ -23,6 +23,8 @@ namespace YLErp.DBModels
public string Description { get; set; }
/// 取值表达式(C# 表达式,用于计算变量值)
public string VariableExpr { get; set; }
+ /// 状态: 1=Active, 2=Disabled, 3=Deleted
+ public RiskRuleStatus Status { get; set; } = RiskRuleStatus.Active;
/// 版本号(乐观锁)
[ConcurrencyCheck]
public int Version { get; set; } = 1;
diff --git a/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/add_risk_variable_status.sql b/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/add_risk_variable_status.sql
new file mode 100644
index 00000000..803d4dd1
--- /dev/null
+++ b/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/add_risk_variable_status.sql
@@ -0,0 +1,12 @@
+-- ============================================================
+-- 增量迁移:为 glms_risk_variable 表增加 Status 字段
+-- 目的:将变量删除由硬删除改造为软删除,与 glms_risk_rule / glms_risk_rule_application 保持一致
+-- 状态:1=Active, 2=Disabled, 3=Deleted
+-- 默认值:1(Active),存量数据自动标记为可用
+-- ============================================================
+
+ALTER TABLE `yltrs_ylcms`.`glms_risk_variable`
+ ADD COLUMN `Status` tinyint NOT NULL DEFAULT 1 COMMENT '状态: 1=Active, 2=Disabled, 3=Deleted' AFTER `VariableExpr`;
+
+ALTER TABLE `yltrs_ylcms`.`glms_risk_variable`
+ ADD INDEX `idx_status`(`Status` ASC) USING BTREE;
diff --git a/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/create_risk_engine_table.sql b/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/create_risk_engine_table.sql
index 07c2d87c..2ec76d9a 100644
--- a/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/create_risk_engine_table.sql
+++ b/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/create_risk_engine_table.sql
@@ -68,6 +68,7 @@ CREATE TABLE `yltrs_ylcms`.`glms_risk_variable` (
`ValueDomain` varchar(500) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '值域描述',
`Description` varchar(500) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '描述',
`VariableExpr` text CHARACTER SET utf8 COLLATE utf8_general_ci NULL COMMENT '变量表达式(C#表达式)',
+ `Status` tinyint NOT NULL DEFAULT 1 COMMENT '状态: 1=Active, 2=Disabled, 3=Deleted',
`Version` int NOT NULL DEFAULT 1 COMMENT '版本号(乐观锁)',
`SortOrder` int NOT NULL DEFAULT 0 COMMENT '排序',
`OptId` int NULL DEFAULT NULL COMMENT '创建人Id',
@@ -76,5 +77,6 @@ CREATE TABLE `yltrs_ylcms`.`glms_risk_variable` (
`UpdateOptId` int NULL DEFAULT NULL COMMENT '最后修改人Id',
`UpdateOptName` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '最后修改人名称',
`UpdateDate` datetime NULL DEFAULT NULL COMMENT '最后修改时间',
- PRIMARY KEY (`id`) USING BTREE
+ PRIMARY KEY (`id`) USING BTREE,
+ INDEX `idx_status`(`Status` ASC) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci COMMENT = '风控变量池定义表' ROW_FORMAT = Dynamic;
\ No newline at end of file
diff --git a/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/seed_risk_engine_variables.sql b/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/seed_risk_engine_variables.sql
index 0801f0d0..7bd66361 100644
--- a/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/seed_risk_engine_variables.sql
+++ b/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/seed_risk_engine_variables.sql
@@ -13,7 +13,7 @@
-- 说明:结构化 RuleExpr 的固定数值不追加 m;本文件 VariableExpr 为人工维护的 C# 表达式,原有 m 后缀保持不变。
-- 插入当前数据库中的 6 条变量,保留原始 ID 以匹配规则 ConditionJson 中的 VariableId
-INSERT INTO `yltrs_ylcms`.`glms_risk_variable` (`id`, `VariableName`, `Category`, `DataType`, `Unit`, `ValueDomain`, `Description`, `VariableExpr`, `Version`, `SortOrder`, `OptId`, `OptName`, `OptDate`, `UpdateOptId`, `UpdateOptName`, `UpdateDate`) VALUES
+INSERT INTO `yltrs_ylcms`.`glms_risk_variable` (`id`, `VariableName`, `Category`, `DataType`, `Unit`, `ValueDomain`, `Description`, `VariableExpr`, `Status`, `Version`, `SortOrder`, `OptId`, `OptName`, `OptDate`, `UpdateOptId`, `UpdateOptName`, `UpdateDate`) VALUES
(1,
'合约名义本金',
1,
@@ -22,6 +22,7 @@ INSERT INTO `yltrs_ylcms`.`glms_risk_variable` (`id`, `VariableName`, `Category`
'≥ 0',
'',
'DbContext.trade.First(t => t.id == TradeId).StockEqvNotional',
+ 1,
18,
0,
0,
@@ -38,6 +39,7 @@ INSERT INTO `yltrs_ylcms`.`glms_risk_variable` (`id`, `VariableName`, `Category`
'≥ 0',
'当前交易对手方所有存续交易涉及的标的数量(去重)合计(含本笔)',
'DbContext.swap_position.Where(p => !string.IsNullOrEmpty(p.UnderlyingCode) && !p.IsInitial && p.PosiQuantity > 0 && !p.Invalid && p.PosiDirection > 0 && DbContext.trade.Any(t => t.id == p.SwapTradeId && t.ValidState != "InValid" && t.ClientId == DbContext.trade.First(x => x.id == TradeId).ClientId)).Select(p => p.UnderlyingCode).Distinct().Count()',
+ 1,
3,
0,
0,
@@ -54,6 +56,7 @@ INSERT INTO `yltrs_ylcms`.`glms_risk_variable` (`id`, `VariableName`, `Category`
'≥ 0',
'abs(期初标的交割净价% - 上一收盘日中债估值净价)',
'Math.Abs(DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection == 2 && !string.IsNullOrEmpty(p.UnderlyingCode)).PosiNetNoFeePrice.Value * 100 - DbContext.china_bond_valuation.Where(v => v.bond_id == DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection == 2 && !string.IsNullOrEmpty(p.UnderlyingCode)).UnderlyingCode && v.valuation_date < DbContext.trade.First(t => t.id == TradeId).TradeDate.Value.Date).OrderByDescending(v => v.valuation_date).First().net_price.Value)',
+ 1,
6,
0,
0,
@@ -70,6 +73,7 @@ INSERT INTO `yltrs_ylcms`.`glms_risk_variable` (`id`, `VariableName`, `Category`
'≥ 0',
'abs(期初标的成交收益率% - 上一收盘日中债估值收益率)',
'Math.Abs(DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection == 2 && !string.IsNullOrEmpty(p.UnderlyingCode)).InitYtm.Value * 100 - DbContext.china_bond_valuation.Where(v => v.bond_id == DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection == 2 && !string.IsNullOrEmpty(p.UnderlyingCode)).UnderlyingCode && v.valuation_date < DbContext.trade.First(t => t.id == TradeId).TradeDate.Value.Date).OrderBy(v => v.credibility).ThenByDescending(v => v.valuation_date).First().yield.Value)',
+ 1,
3,
0,
0,
@@ -86,6 +90,7 @@ INSERT INTO `yltrs_ylcms`.`glms_risk_variable` (`id`, `VariableName`, `Category`
'≥ 0',
'abs(期初标的交割全价% - 上一日标的收盘价)',
'Math.Abs(DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection == 2 && !string.IsNullOrEmpty(p.UnderlyingCode)).PosiGrossPrice * 100 - Convert.ToDecimal(DbContext.eod_commodity_future_price.Where(e => e.UnderlyingCode == DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection == 2 && !string.IsNullOrEmpty(p.UnderlyingCode)).UnderlyingCode && e.ValueDate < DbContext.trade.First(t => t.id == TradeId).TradeDate.Value.Date).OrderByDescending(e => e.ValueDate).First().ClosePrice))',
+ 1,
4,
0,
0,
@@ -102,6 +107,7 @@ INSERT INTO `yltrs_ylcms`.`glms_risk_variable` (`id`, `VariableName`, `Category`
'≥ 0',
'ABS(利息端利率-FR007)/FR007*100',
'Math.Abs(DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.InterestDirection == 1).InterestRateDefault)*100m',
+ 1,
2,
0,
0,
diff --git a/UnitTestProject/Modules/RiskEngine/RiskRuleApplicationStatusDecoupleContractTest.cs b/UnitTestProject/Modules/RiskEngine/RiskRuleApplicationStatusDecoupleContractTest.cs
new file mode 100644
index 00000000..ba6c7515
--- /dev/null
+++ b/UnitTestProject/Modules/RiskEngine/RiskRuleApplicationStatusDecoupleContractTest.cs
@@ -0,0 +1,95 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Reflection;
+using YLErp.DBModels;
+using YLErp.Modules.RiskEngine.Dto;
+
+namespace YLErp.Modules.RiskEngine
+{
+ [TestClass]
+ public class RiskRuleApplicationStatusDecoupleContractTest
+ {
+ private static readonly string[] AvailabilityPropertyNames =
+ {
+ "AssociatedRuleCount",
+ "EffectiveRuleCount",
+ "CanEnable",
+ "CanDisable",
+ "EnableButtonTooltip",
+ "DisableButtonTooltip"
+ };
+
+ [TestMethod]
+ public void DisableRuleResult_UsesDecoupledContract()
+ {
+ AssertProperty("RuleId", typeof(long));
+ AssertProperty("AffectedActiveAppCount", typeof(int));
+ Assert.IsNull(typeof(DisableRuleResult).GetProperty("CascadedApplicationIds"));
+ }
+
+ [TestMethod]
+ public void DeleteRuleResult_ReportsAffectedApplications()
+ {
+ AssertProperty("RuleId", typeof(long));
+ AssertProperty("AffectedActiveAppCount", typeof(int));
+ AssertProperty("CausedNoAvailableRuleAppIds", typeof(List));
+ }
+
+ [TestMethod]
+ public void ApplicationDtos_ExposeAvailabilityFields()
+ {
+ AssertAvailabilityProperties();
+ AssertAvailabilityProperties();
+ }
+
+ [TestMethod]
+ public void BatchOperationResult_ExposesDecoupledApplicationImpactFields()
+ {
+ AssertProperty("AffectedApplicationIds", typeof(List));
+ AssertProperty("CausedNoAvailableRuleApplicationIds", typeof(List));
+ AssertProperty("AffectedApplicationIds", typeof(List));
+ AssertProperty("CausedNoAvailableRuleApplicationIds", typeof(List));
+ }
+
+ [TestMethod]
+ public void VariableDeleteReferenceMessage_ListsDistinctSortedRuleIds()
+ {
+ var method = typeof(RiskRuleService).GetMethod(
+ "BuildVariableDeleteReferenceMessage", BindingFlags.NonPublic | BindingFlags.Static);
+ Assert.IsNotNull(method);
+
+ var rules = new List
+ {
+ new glms_risk_rule { id = 12 },
+ new glms_risk_rule { id = 10 },
+ new glms_risk_rule { id = 12 }
+ };
+
+ var message = (string)method.Invoke(null, new object[] { rules });
+
+ Assert.AreEqual("该变量被 2 个规则引用(规则ID:10、12),无法删除", message);
+ }
+
+ private static void AssertAvailabilityProperties()
+ {
+ foreach (var propertyName in AvailabilityPropertyNames)
+ {
+ var expectedType = propertyName.EndsWith("Tooltip", StringComparison.Ordinal)
+ ? typeof(string)
+ : propertyName.StartsWith("Can", StringComparison.Ordinal)
+ ? typeof(bool)
+ : typeof(int);
+
+ AssertProperty(propertyName, expectedType);
+ }
+ }
+
+ private static void AssertProperty(string propertyName, Type expectedType)
+ {
+ var property = typeof(T).GetProperty(propertyName);
+ Assert.IsNotNull(property, $"{typeof(T).Name}.{propertyName} should exist.");
+ Assert.AreEqual(expectedType, property.PropertyType, $"{typeof(T).Name}.{propertyName} type mismatch.");
+ }
+ }
+}
diff --git a/YLErpDAL/Modules/RiskEngine/Dto/BatchOperationResult.cs b/YLErpDAL/Modules/RiskEngine/Dto/BatchOperationResult.cs
index 0ae0cdcb..ceac3f2b 100644
--- a/YLErpDAL/Modules/RiskEngine/Dto/BatchOperationResult.cs
+++ b/YLErpDAL/Modules/RiskEngine/Dto/BatchOperationResult.cs
@@ -1,3 +1,4 @@
+using System.Collections.Generic;
using System.Runtime.Serialization;
using System.Text.Json.Serialization;
@@ -31,6 +32,11 @@ namespace YLErp.Modules.RiskEngine.Dto
public BatchOperationItemStatus Status { get; set; }
public string Code { get; set; }
public string Message { get; set; }
+ /// 操作影响到的应用 ID(不代表应用状态发生变化)
+ public List AffectedApplicationIds { get; set; } = new List();
+ /// 操作后不再包含任何启用规则的应用 ID
+ public List CausedNoAvailableRuleApplicationIds { get; set; } = new List();
+ /// 旧版级联字段,解耦后始终为空,仅为兼容旧客户端保留
public List CascadedApplicationIds { get; set; } = new List();
}
@@ -43,6 +49,11 @@ namespace YLErp.Modules.RiskEngine.Dto
public int FailedCount { get; set; }
public bool HasChanges { get; set; }
public List Items { get; set; } = new List();
+ /// 所有请求项影响到的应用 ID 去重并集
+ public List AffectedApplicationIds { get; set; } = new List();
+ /// 操作后不再包含任何启用规则的应用 ID 去重并集
+ public List CausedNoAvailableRuleApplicationIds { get; set; } = new List();
+ /// 旧版级联字段,解耦后始终为空,仅为兼容旧客户端保留
public List CascadedApplicationIds { get; set; } = new List();
}
}
diff --git a/YLErpDAL/Modules/RiskEngine/Dto/DeleteRuleResult.cs b/YLErpDAL/Modules/RiskEngine/Dto/DeleteRuleResult.cs
new file mode 100644
index 00000000..b49505b6
--- /dev/null
+++ b/YLErpDAL/Modules/RiskEngine/Dto/DeleteRuleResult.cs
@@ -0,0 +1,19 @@
+using System.Collections.Generic;
+
+namespace YLErp.Modules.RiskEngine.Dto
+{
+ ///
+ /// 删除规则结果
+ ///
+ public class DeleteRuleResult
+ {
+ /// 规则 ID
+ public long RuleId { get; set; }
+
+ /// 删除前引用该规则的启用中应用数量
+ public int AffectedActiveAppCount { get; set; }
+
+ /// 删除后不再包含任何启用规则的应用 ID
+ public List CausedNoAvailableRuleAppIds { get; set; } = new List();
+ }
+}
diff --git a/YLErpDAL/Modules/RiskEngine/Dto/DisableRuleResult.cs b/YLErpDAL/Modules/RiskEngine/Dto/DisableRuleResult.cs
index e5702b2e..07a870a7 100644
--- a/YLErpDAL/Modules/RiskEngine/Dto/DisableRuleResult.cs
+++ b/YLErpDAL/Modules/RiskEngine/Dto/DisableRuleResult.cs
@@ -1,5 +1,3 @@
-using System.Collections.Generic;
-
namespace YLErp.Modules.RiskEngine.Dto
{
///
@@ -7,7 +5,10 @@ namespace YLErp.Modules.RiskEngine.Dto
///
public class DisableRuleResult
{
- /// 级联停用的应用 ID 列表
- public List CascadedApplicationIds { get; set; } = new List();
+ /// 规则 ID
+ public long RuleId { get; set; }
+
+ /// 引用该规则的启用中应用数量
+ public int AffectedActiveAppCount { get; set; }
}
}
diff --git a/YLErpDAL/Modules/RiskEngine/Dto/QueryRiskVariableReq.cs b/YLErpDAL/Modules/RiskEngine/Dto/QueryRiskVariableReq.cs
index ed4b9901..7a4cf95b 100644
--- a/YLErpDAL/Modules/RiskEngine/Dto/QueryRiskVariableReq.cs
+++ b/YLErpDAL/Modules/RiskEngine/Dto/QueryRiskVariableReq.cs
@@ -14,5 +14,7 @@ namespace YLErp.Modules.RiskEngine.Dto
public RiskVariableDataType? DataType { get; set; }
/// 变量名称
public string VariableName { get; set; }
+ /// 状态筛选(不传则返回非 Deleted 的全部)
+ public RiskRuleStatus? Status { get; set; }
}
}
diff --git a/YLErpDAL/Modules/RiskEngine/Dto/RiskApplicationDetail.cs b/YLErpDAL/Modules/RiskEngine/Dto/RiskApplicationDetail.cs
index 11d5cf7f..18141b5e 100644
--- a/YLErpDAL/Modules/RiskEngine/Dto/RiskApplicationDetail.cs
+++ b/YLErpDAL/Modules/RiskEngine/Dto/RiskApplicationDetail.cs
@@ -14,6 +14,18 @@ namespace YLErp.Modules.RiskEngine.Dto
public string RuleIds { get; set; }
/// 应用说明
public string Description { get; set; }
+ /// 关联规则数(不含已删除规则)
+ public int AssociatedRuleCount { get; set; }
+ /// 生效规则数(关联规则中已启用的数量)
+ public int EffectiveRuleCount { get; set; }
+ /// 当前状态下是否允许启用
+ public bool CanEnable { get; set; }
+ /// 当前状态下是否允许停用
+ public bool CanDisable { get; set; }
+ /// 启用按钮不可用时的提示
+ public string EnableButtonTooltip { get; set; }
+ /// 停用按钮不可用时的提示
+ public string DisableButtonTooltip { get; set; }
/// 应用配置状态
public RiskRuleStatus Status { get; set; }
/// 风控策略
diff --git a/YLErpDAL/Modules/RiskEngine/Dto/RiskApplicationListItem.cs b/YLErpDAL/Modules/RiskEngine/Dto/RiskApplicationListItem.cs
index f50283ed..ca73b6cb 100644
--- a/YLErpDAL/Modules/RiskEngine/Dto/RiskApplicationListItem.cs
+++ b/YLErpDAL/Modules/RiskEngine/Dto/RiskApplicationListItem.cs
@@ -14,6 +14,18 @@ namespace YLErp.Modules.RiskEngine.Dto
public string RuleIds { get; set; }
/// 应用说明
public string Description { get; set; }
+ /// 关联规则数(不含已删除规则)
+ public int AssociatedRuleCount { get; set; }
+ /// 生效规则数(关联规则中已启用的数量)
+ public int EffectiveRuleCount { get; set; }
+ /// 当前状态下是否允许启用
+ public bool CanEnable { get; set; }
+ /// 当前状态下是否允许停用
+ public bool CanDisable { get; set; }
+ /// 启用按钮不可用时的提示
+ public string EnableButtonTooltip { get; set; }
+ /// 停用按钮不可用时的提示
+ public string DisableButtonTooltip { get; set; }
/// 应用配置状态
public int Status { get; set; }
/// 风控策略
diff --git a/YLErpDAL/Modules/RiskEngine/Dto/RiskVariableDetail.cs b/YLErpDAL/Modules/RiskEngine/Dto/RiskVariableDetail.cs
index c3dee5a8..3e768956 100644
--- a/YLErpDAL/Modules/RiskEngine/Dto/RiskVariableDetail.cs
+++ b/YLErpDAL/Modules/RiskEngine/Dto/RiskVariableDetail.cs
@@ -28,6 +28,8 @@ namespace YLErp.Modules.RiskEngine.Dto
public int Version { get; set; }
/// 排序序号
public int SortOrder { get; set; }
+ /// 状态
+ public RiskRuleStatus Status { get; set; }
/// 创建人姓名
public string OptName { get; set; }
/// 创建时间
diff --git a/YLErpDAL/Modules/RiskEngine/Dto/RiskVariableListItem.cs b/YLErpDAL/Modules/RiskEngine/Dto/RiskVariableListItem.cs
index 538cdcb9..69c2b6f8 100644
--- a/YLErpDAL/Modules/RiskEngine/Dto/RiskVariableListItem.cs
+++ b/YLErpDAL/Modules/RiskEngine/Dto/RiskVariableListItem.cs
@@ -27,5 +27,7 @@ namespace YLErp.Modules.RiskEngine.Dto
public int Version { get; set; }
/// 排序序号
public int SortOrder { get; set; }
+ /// 状态
+ public RiskRuleStatus Status { get; set; }
}
}
diff --git a/YLErpDAL/Modules/RiskEngine/Dto/RiskVariableSimpleItem.cs b/YLErpDAL/Modules/RiskEngine/Dto/RiskVariableSimpleItem.cs
index f884a03e..c3be993e 100644
--- a/YLErpDAL/Modules/RiskEngine/Dto/RiskVariableSimpleItem.cs
+++ b/YLErpDAL/Modules/RiskEngine/Dto/RiskVariableSimpleItem.cs
@@ -19,5 +19,7 @@ namespace YLErp.Modules.RiskEngine.Dto
public string Unit { get; set; }
/// 取值表达式
public string VariableExpr { get; set; }
+ /// 状态
+ public RiskRuleStatus Status { get; set; }
}
}
diff --git a/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs b/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs
index 92ade91b..d0579068 100644
--- a/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs
+++ b/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs
@@ -53,7 +53,7 @@ using YLErp.Modules.RiskEngine.Dto;
│ - ConditionJson 完整校验(结构化模式:变量存在性、操作符、类型匹配)│
│ - RuleExpr 基础语法校验(自由文本模式:括号匹配、变量引用校验) │
│ - 乐观锁并发控制(Version 字段) │
- │ - 删除保护(有 Active 引用时拒绝删除) │
+ │ - 规则删除同步解除应用关联;变量被任一未删除规则引用时禁止删除 │
│ - CRUD 后触发 RiskEngineService.RefreshCache() │
│ │
│ RiskEngineService(另一团队开发,单例) │
@@ -76,26 +76,26 @@ using YLErp.Modules.RiskEngine.Dto;
GetRuleDetail(long) 获取规则详情(含关联应用摘要)
CreateRule(CreateRiskRuleReq) 新建规则(ConditionJson 完整校验)
UpdateRule(long, UpdateRiskRuleReq) 修改规则(乐观锁校验,Version+1)
- DeleteRule(long) 删除规则(强保护:有 Active 应用时拒绝)
+ DeleteRule(long) 删除规则(从未删除应用中移除关联)
EnableRule(long) 启用规则(仅 Disabled → Active)
- DisableRule(long) 停用规则(仅 Active → Disabled,级联停用关联应用)
+ DisableRule(long) 停用规则(仅 Active → Disabled,不修改应用状态)
GetRuleVersions(long) 获取规则版本历史(从审计日志 SnapshotData 提取版本号)
- BatchDeleteRules(List) 批量删除规则(逐条保护检查,单事务)
+ BatchDeleteRules(List) 批量删除规则(同步移除应用关联,单次提交)
BatchEnableRules(List) 批量启用规则(逐条编译校验)
- BatchDisableRules(List) 批量停用规则(级联停用关联应用)
+ BatchDisableRules(List) 批量停用规则(不修改应用状态)
GetRuleApplications(long) 获取规则关联的应用配置列表
- GetAllRuleList() 获取所有启用规则(轻量字段,供应用配置下拉)
+ GetAllRuleList() 获取所有未删除规则(轻量字段,供应用配置下拉)
── 应用配置管理(9 个) ───────────────────────────────────────────
- QueryApplicationList(QueryRiskApplicationReq) 查询应用列表(多条件筛选,内存关联规则名称)
+ QueryApplicationList(QueryRiskApplicationReq) 查询应用列表(分页后批量计算规则统计)
GetApplicationDetail(long) 获取应用详情(含关联规则信息)
CreateApplication(CreateRiskApplicationReq) 新建应用(关联规则 Active 校验,触发时点/Scope 校验)
UpdateApplication(long, UpdateRiskApplicationReq) 修改应用(乐观锁校验)
DeleteApplication(long) 删除应用(软删除)
BatchDeleteApplications(List) 批量删除应用(软删除)
- EnableApplication(long) 启用应用(校验关联规则状态)
+ EnableApplication(long) 启用应用(至少一条关联规则 Active)
DisableApplication(long) 停用应用
- BatchEnableApplications(List) 批量启用(逐条校验关联规则)
+ BatchEnableApplications(List) 批量启用(逐条校验至少一条 Active 规则)
BatchDisableApplications(List) 批量停用
── 变量管理(6 个) ───────────────────────────────────────────────
@@ -103,7 +103,7 @@ using YLErp.Modules.RiskEngine.Dto;
GetVariableDetail(long) 获取变量详情(含 ReferenceCount 引用统计)
CreateVariable(CreateRiskVariableReq) 新建变量(VariableExpr 长度校验 ≤10000)
UpdateVariable(long, UpdateRiskVariableReq) 修改变量(乐观锁,DataType 变更保护)
- DeleteVariable(long) 删除变量(引用保护:有 Active 规则引用时拒绝,硬删除)
+ DeleteVariable(long) 删除变量(引用保护:有未删除规则引用时拒绝,软删除)
GetAllVariableList() 获取所有已实现变量(轻量字段,供规则编辑器下拉)
── 审计日志(4 个) ───────────────────────────────────────────────
@@ -128,14 +128,14 @@ using YLErp.Modules.RiskEngine.Dto;
1. 数据访问:直接使用 EF Core DbContext(无独立 Repository,与项目风格一致)
2. 事务策略:Create* 方法使用 BeginTransaction 包裹双 SaveChanges(实体 + 审计日志同事务)
3. 并发控制:乐观锁(Version 字段),修改时版本不匹配抛 ServiceException
- 4. 删除保护:强保护模式,有 Active 引用时拒绝删除规则/变量
+ 4. 删除策略:规则删除同步从应用移除;变量删除保护所有未删除规则引用
5. ConditionJson 校验:变量存在性(按 VariableId)+ 操作符合法性(按 DataType 映射)+ 类型匹配 + 阈值变量引用
6. RuleExpr:自由文本模式下前端直接编辑,后端做基础语法校验(括号匹配 + 变量引用校验)
7. Scope 字段:逗号分隔字符串存储,全局适用时不允许指定其他维度
8. 缓存刷新:规则变更后调用 RefreshOneRuleCache(ruleId),应用变更后调用 RefreshApplication()
- 9. 批量操作:单事务批量,全部成功或全部失败
+ 9. 批量操作:逐项返回成功/未变更/失败,成功项统一提交
10. 变量缓存:5 分钟本地 HashSet 缓存,CRUD 后 InvalidateVariableCache()
- 11. 审计日志:16 种操作类型,CRUD + 审计日志同一事务,SnapshotData 存储版本快照
+ 11. 审计日志:CRUD + 审计日志同次提交,SnapshotData 存储版本快照
12. 多规则支持:Application 通过 RuleIds(逗号分隔)关联多条规则,内存解析关联关系
【数据库表】(MySQL,迁移脚本位于 Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/prod.sql)
@@ -181,7 +181,7 @@ using YLErp.Modules.RiskEngine.Dto;
【注意事项】
1. RiskEngineService 由另一个团队开发实现,本服务通过 GetInstance() 获取单例引用
2. 规则变更后调用 RefreshOneRuleCache(ruleId),应用变更后调用 RefreshApplication(),变量变更不触发引擎缓存刷新
- 3. 变量删除为硬删除(物理删除),规则/应用删除为软删除(Status=Deleted)
+ 3. 变量/规则/应用删除均为软删除(Status=Deleted),新建变量默认 Active
4. ExportAuditLogs 按查询条件导出,并生成包含查询条件的文件名
5. VariableExpr 当前仅做基础长度校验(≤10000),完整编译校验待引入 Roslyn 库
6. ValidateScopeFields 的 ID 存在性校验已跳过(前端下拉选择器保证有效性)
@@ -225,7 +225,9 @@ namespace YLErp.Modules.RiskEngine
{ "APP_BATCH_ENABLE", "批量启用应用" },
{ "APP_BATCH_DISABLE", "批量停用应用" },
{ "APP_BATCH_DELETE", "批量删除应用" },
+ // 仅用于历史审计日志展示;解耦后不再写入该操作类型。
{ "RULE_CASCADE_DISABLE_APP", "规则级联停用应用" },
+ { "RULE_REMOVE_FROM_APP", "规则从应用移除" },
{ "VAR_CREATE", "新增变量" },
{ "VARIABLE_CREATE", "新增变量" },
{ "VAR_UPDATE", "修改变量" },
@@ -504,9 +506,10 @@ namespace YLErp.Modules.RiskEngine
///
private glms_risk_variable GetVariableOrThrow(long variableId)
{
- var variable = DbContext.glms_risk_variable.FirstOrDefault(v => v.id == variableId);
+ var variable = DbContext.glms_risk_variable
+ .FirstOrDefault(v => v.id == variableId && v.Status != RiskRuleStatus.Deleted);
if (variable == null)
- throw new ServiceException("变量不存在");
+ throw new ServiceException("变量不存在或已删除");
return variable;
}
@@ -559,6 +562,84 @@ namespace YLErp.Modules.RiskEngine
return ids;
}
+ ///
+ /// 解析数据库中已保存的规则 ID。删除应用关联的最后一条规则后允许为空。
+ ///
+ private static List ParseStoredRuleIds(string ruleIds)
+ {
+ if (string.IsNullOrWhiteSpace(ruleIds))
+ return new List();
+
+ var ids = new List();
+ foreach (var part in ruleIds.Split(','))
+ {
+ var value = part.Trim();
+ if (string.IsNullOrEmpty(value))
+ continue;
+ if (!long.TryParse(value, out var id))
+ throw new ServiceException($"规则 ID 列表格式不合法:'{value}'");
+ ids.Add(id);
+ }
+ return ids.Distinct().ToList();
+ }
+
+ private bool HasAnyActiveRule(string ruleIds)
+ {
+ var ids = ParseStoredRuleIds(ruleIds);
+ return ids.Count > 0 && DbContext.glms_risk_rule
+ .Any(r => ids.Contains(r.id) && r.Status == RiskRuleStatus.Active);
+ }
+
+ private Dictionary LoadRuleStatuses(IEnumerable storedRuleIds)
+ {
+ var ids = storedRuleIds.SelectMany(ParseStoredRuleIds).Distinct().ToList();
+ if (ids.Count == 0)
+ return new Dictionary();
+
+ return DbContext.glms_risk_rule
+ .Where(r => ids.Contains(r.id))
+ .Select(r => new { r.id, r.Status })
+ .ToDictionary(r => (long)r.id, r => r.Status);
+ }
+
+ private static void PopulateApplicationAvailability(
+ RiskApplicationListItem item, IReadOnlyDictionary ruleStatuses)
+ {
+ var statuses = ParseStoredRuleIds(item.RuleIds)
+ .Where(ruleStatuses.ContainsKey)
+ .Select(id => ruleStatuses[id])
+ .ToList();
+ item.AssociatedRuleCount = statuses.Count(status => status != RiskRuleStatus.Deleted);
+ item.EffectiveRuleCount = statuses.Count(status => status == RiskRuleStatus.Active);
+ item.CanEnable = item.Status == (int)RiskRuleStatus.Disabled && item.EffectiveRuleCount > 0;
+ item.CanDisable = item.Status == (int)RiskRuleStatus.Active && item.EffectiveRuleCount > 0;
+ item.EnableButtonTooltip = item.Status == (int)RiskRuleStatus.Disabled && !item.CanEnable
+ ? "该应用下所有规则均已全局停用,无法启用应用。请先在规则管理中启用至少一条规则"
+ : string.Empty;
+ item.DisableButtonTooltip = item.Status == (int)RiskRuleStatus.Active && !item.CanDisable
+ ? "该应用下所有规则均已全局停用,应用无法生效。请先在规则管理中启用至少一条关联规则"
+ : string.Empty;
+ }
+
+ private static void PopulateApplicationAvailability(
+ RiskApplicationDetail item, IReadOnlyDictionary ruleStatuses)
+ {
+ var statuses = ParseStoredRuleIds(item.RuleIds)
+ .Where(ruleStatuses.ContainsKey)
+ .Select(id => ruleStatuses[id])
+ .ToList();
+ item.AssociatedRuleCount = statuses.Count(status => status != RiskRuleStatus.Deleted);
+ item.EffectiveRuleCount = statuses.Count(status => status == RiskRuleStatus.Active);
+ item.CanEnable = item.Status == RiskRuleStatus.Disabled && item.EffectiveRuleCount > 0;
+ item.CanDisable = item.Status == RiskRuleStatus.Active && item.EffectiveRuleCount > 0;
+ item.EnableButtonTooltip = item.Status == RiskRuleStatus.Disabled && !item.CanEnable
+ ? "该应用下所有规则均已全局停用,无法启用应用。请先在规则管理中启用至少一条规则"
+ : string.Empty;
+ item.DisableButtonTooltip = item.Status == RiskRuleStatus.Active && !item.CanDisable
+ ? "该应用下所有规则均已全局停用,应用无法生效。请先在规则管理中启用至少一条关联规则"
+ : string.Empty;
+ }
+
///
/// 规范化逗号分隔的规则 ID:去除空格和重复项,统一为无空格格式。
///
@@ -723,7 +804,8 @@ namespace YLErp.Modules.RiskEngine
}
private static BatchOperationItemResult BatchItem(long id, BatchOperationItemStatus status,
- string code, string message, IEnumerable cascadedApplicationIds = null)
+ string code, string message, IEnumerable affectedApplicationIds = null,
+ IEnumerable causedNoAvailableRuleApplicationIds = null)
{
return new BatchOperationItemResult
{
@@ -731,7 +813,9 @@ namespace YLErp.Modules.RiskEngine
Status = status,
Code = code,
Message = message,
- CascadedApplicationIds = cascadedApplicationIds?.Distinct().ToList() ?? new List()
+ AffectedApplicationIds = affectedApplicationIds?.Distinct().ToList() ?? new List(),
+ CausedNoAvailableRuleApplicationIds = causedNoAvailableRuleApplicationIds?.Distinct().ToList() ?? new List(),
+ CascadedApplicationIds = new List()
};
}
@@ -747,7 +831,9 @@ namespace YLErp.Modules.RiskEngine
SucceededCount = items.Count(i => i.Status == BatchOperationItemStatus.Succeeded),
UnchangedCount = items.Count(i => i.Status == BatchOperationItemStatus.Unchanged),
FailedCount = items.Count(i => i.Status == BatchOperationItemStatus.Failed),
- CascadedApplicationIds = items.SelectMany(i => i.CascadedApplicationIds).Distinct().ToList()
+ AffectedApplicationIds = items.SelectMany(i => i.AffectedApplicationIds).Distinct().ToList(),
+ CausedNoAvailableRuleApplicationIds = items.SelectMany(i => i.CausedNoAvailableRuleApplicationIds).Distinct().ToList(),
+ CascadedApplicationIds = new List()
};
result.HasChanges = result.SucceededCount > 0;
result.Outcome = result.FailedCount == 0
@@ -974,28 +1060,57 @@ namespace YLErp.Modules.RiskEngine
}
///
- /// 删除规则(软删除,有 Active 应用引用时拒绝)
+ /// 删除规则(软删除,并从所有未删除应用中移除关联)
///
- public void DeleteRule(long ruleId)
+ public DeleteRuleResult DeleteRule(long ruleId)
{
var rule = GetRuleOrThrow(ruleId);
-
- var activeCount = DbContext.glms_risk_rule_application
- .Where(a => a.Status == RiskRuleStatus.Active)
- .Count(RuleIdsMatchExpr(ruleId));
- if (activeCount > 0)
- throw new ServiceException($"该规则仍有 {activeCount} 个生效中的应用配置,请先停用或删除相关应用");
+ var affectedApps = DbContext.glms_risk_rule_application
+ .Where(a => a.Status != RiskRuleStatus.Deleted)
+ .Where(RuleIdsMatchExpr(ruleId))
+ .ToList();
+ var affectedActiveAppCount = affectedApps.Count(a => a.Status == RiskRuleStatus.Active);
+ var activeRuleIds = DbContext.glms_risk_rule
+ .Where(r => r.Status == RiskRuleStatus.Active && r.id != ruleId)
+ .Select(r => (long)r.id)
+ .ToHashSet();
+ var causedNoAvailableRuleAppIds = new List();
rule.Status = RiskRuleStatus.Deleted;
rule.Version = rule.Version + 1;
rule.UpdateOptId = UserId;
rule.UpdateOptName = UserName;
rule.UpdateDate = DateTime.Now;
-
WriteAuditLog("RULE_DELETE", "RULE", ruleId, rule.RuleName, "删除规则");
- SaveChangesWithConcurrencyCheck();
- RiskEngineService.GetInstance().RefreshOneRuleCache(ruleId);
+ foreach (var app in affectedApps)
+ {
+ var remainingIds = ParseStoredRuleIds(app.RuleIds).Where(id => id != ruleId).ToList();
+ app.RuleIds = string.Join(",", remainingIds);
+ app.Version++;
+ app.UpdateOptId = UserId;
+ app.UpdateOptName = UserName;
+ app.UpdateDate = DateTime.Now;
+ if (!remainingIds.Any(activeRuleIds.Contains))
+ causedNoAvailableRuleAppIds.Add(app.id);
+
+ WriteAuditLog("RULE_REMOVE_FROM_APP", "APPLICATION", app.id, $"应用{app.id}",
+ $"删除规则'{rule.RuleName}'(ID:{ruleId}),已从应用关联规则中移除");
+ }
+
+ SaveChangesWithConcurrencyCheck();
+ TryRefreshCache(() => RiskEngineService.GetInstance().RefreshOneRuleCache(ruleId),
+ "风控规则缓存", new[] { ruleId });
+ if (affectedApps.Any())
+ TryRefreshCache(() => RiskEngineService.GetInstance().RefreshApplication(),
+ "风控应用缓存", affectedApps.Select(a => (long)a.id));
+
+ return new DeleteRuleResult
+ {
+ RuleId = ruleId,
+ AffectedActiveAppCount = affectedActiveAppCount,
+ CausedNoAvailableRuleAppIds = causedNoAvailableRuleAppIds.Distinct().ToList()
+ };
}
///
@@ -1025,7 +1140,7 @@ namespace YLErp.Modules.RiskEngine
}
///
- /// 停用规则(仅 Active → Disabled)
+ /// 停用规则(仅 Active → Disabled,不修改关联应用状态)
///
public DisableRuleResult DisableRule(long ruleId)
{
@@ -1033,47 +1148,24 @@ namespace YLErp.Modules.RiskEngine
if (rule == null)
throw new ServiceException("仅已启用的规则可以停用");
- rule.Status = RiskRuleStatus.Disabled;
+ var affectedActiveAppCount = DbContext.glms_risk_rule_application
+ .Where(a => a.Status == RiskRuleStatus.Active)
+ .Count(RuleIdsMatchExpr(ruleId));
+ rule.Status = RiskRuleStatus.Disabled;
rule.Version = rule.Version + 1;
rule.UpdateOptId = UserId;
rule.UpdateOptName = UserName;
rule.UpdateDate = DateTime.Now;
-
WriteAuditLog("RULE_DISABLE", "RULE", ruleId, rule.RuleName, "停用规则");
-
- // 级联停用关联的 Active 应用配置
- var activeApps = DbContext.glms_risk_rule_application
- .Where(a => a.Status == RiskRuleStatus.Active)
- .Where(RuleIdsMatchExpr(ruleId))
- .ToList();
-
- var cascadedIds = new List();
- foreach (var app in activeApps)
- {
- app.Status = RiskRuleStatus.Disabled;
- app.Version = app.Version + 1;
- app.UpdateOptId = UserId;
- app.UpdateOptName = UserName;
- app.UpdateDate = DateTime.Now;
- cascadedIds.Add(app.id);
-
- WriteAuditLog("RULE_CASCADE_DISABLE_APP", "APPLICATION", app.id, $"应用{app.id}",
- $"因规则'{rule.RuleName}'(ID:{ruleId})停用,应用配置被级联停用");
- }
-
- if (cascadedIds.Any())
- {
- _logger.Info($"[停用规则] 规则ID: {ruleId}, 级联停用应用: {string.Join(",", cascadedIds)}");
- }
-
SaveChangesWithConcurrencyCheck();
RiskEngineService.GetInstance().RefreshOneRuleCache(ruleId);
- if (cascadedIds.Any())
- RiskEngineService.GetInstance().RefreshApplication();
-
- return new DisableRuleResult { CascadedApplicationIds = cascadedIds };
+ return new DisableRuleResult
+ {
+ RuleId = ruleId,
+ AffectedActiveAppCount = affectedActiveAppCount
+ };
}
///
@@ -1117,7 +1209,7 @@ namespace YLErp.Modules.RiskEngine
}
///
- /// 批量删除规则(逐条保护检查)
+ /// 批量删除规则,并从所有未删除应用中移除成功删除的规则关联
///
public BatchOperationResult BatchDeleteRules(List ruleIds)
{
@@ -1126,45 +1218,75 @@ namespace YLErp.Modules.RiskEngine
var ids = DistinctIds(ruleIds);
var rules = DbContext.glms_risk_rule.Where(r => ids.Contains(r.id)).ToDictionary(r => (long)r.id);
- var activeApps = DbContext.glms_risk_rule_application
- .Where(a => a.Status == RiskRuleStatus.Active)
+ var changedIds = ids
+ .Where(id => rules.TryGetValue(id, out var rule) && rule.Status != RiskRuleStatus.Deleted)
.ToList();
- var items = new List();
- var changedIds = new List();
+ var changedIdSet = changedIds.ToHashSet();
+ var affectedApps = changedIds.Count == 0
+ ? new List()
+ : DbContext.glms_risk_rule_application
+ .Where(a => a.Status != RiskRuleStatus.Deleted && a.RuleIds != null && a.RuleIds != "")
+ .ToList()
+ .Where(a => ParseStoredRuleIds(a.RuleIds).Any(changedIdSet.Contains))
+ .ToList();
+ var affectedByRule = changedIds.ToDictionary(id => id, _ => new List());
+ var noAvailableByRule = changedIds.ToDictionary(id => id, _ => new List());
+ var activeRuleIdsAfterDelete = DbContext.glms_risk_rule
+ .Where(r => r.Status == RiskRuleStatus.Active && !changedIds.Contains(r.id))
+ .Select(r => (long)r.id)
+ .ToHashSet();
+ foreach (var app in affectedApps)
+ {
+ var currentIds = ParseStoredRuleIds(app.RuleIds);
+ var removedIds = currentIds.Where(changedIdSet.Contains).Distinct().ToList();
+ var remainingIds = currentIds.Where(id => !changedIdSet.Contains(id)).ToList();
+ app.RuleIds = string.Join(",", remainingIds);
+ app.Version++;
+ app.UpdateOptId = UserId;
+ app.UpdateOptName = UserName;
+ app.UpdateDate = DateTime.Now;
+ var hasActiveRule = remainingIds.Any(activeRuleIdsAfterDelete.Contains);
+
+ foreach (var removedId in removedIds)
+ {
+ affectedByRule[removedId].Add(app.id);
+ if (!hasActiveRule)
+ noAvailableByRule[removedId].Add(app.id);
+ WriteAuditLog("RULE_REMOVE_FROM_APP", "APPLICATION", app.id, $"应用{app.id}",
+ $"批量删除规则'{rules[removedId].RuleName}'(ID:{removedId}),已从应用关联规则中移除");
+ }
+ }
+
+ var items = new List();
foreach (var id in ids)
{
- if (!rules.TryGetValue(id, out var rule) || rule.Status == RiskRuleStatus.Deleted)
+ if (!changedIdSet.Contains(id))
{
items.Add(BatchItem(id, BatchOperationItemStatus.Failed, "NOT_FOUND", "规则不存在或已删除"));
continue;
}
- var referenceCount = activeApps.Count(a => ParseRuleIds(a.RuleIds).Contains(id));
- if (referenceCount > 0)
- {
- items.Add(BatchItem(id, BatchOperationItemStatus.Failed, "ACTIVE_APPLICATION_REFERENCE",
- $"该规则仍有 {referenceCount} 个生效中的应用配置,请先停用或删除相关应用"));
- continue;
- }
-
+ var rule = rules[id];
rule.Status = RiskRuleStatus.Deleted;
rule.Version++;
rule.UpdateOptId = UserId;
rule.UpdateOptName = UserName;
rule.UpdateDate = DateTime.Now;
WriteAuditLog("RULE_BATCH_DELETE", "RULE", rule.id, rule.RuleName, "批量删除规则");
- changedIds.Add(id);
- items.Add(BatchItem(id, BatchOperationItemStatus.Succeeded, "DELETED", "规则已删除"));
+ items.Add(BatchItem(id, BatchOperationItemStatus.Succeeded, "DELETED", "规则已删除",
+ affectedByRule[id], noAvailableByRule[id]));
}
if (changedIds.Any())
{
- // 所有成功项一次原子提交;提交完成后,缓存失败仅告警,不改变数据库成功结果。
SaveChangesWithConcurrencyCheck();
TryRefreshCache(
() => changedIds.ForEach(id => RiskEngineService.GetInstance().RefreshOneRuleCache(id)),
"风控规则缓存", changedIds);
+ if (affectedApps.Any())
+ TryRefreshCache(() => RiskEngineService.GetInstance().RefreshApplication(),
+ "风控应用缓存", affectedApps.Select(a => (long)a.id));
}
var result = BuildBatchResult(items);
@@ -1231,7 +1353,7 @@ namespace YLErp.Modules.RiskEngine
}
///
- /// 批量停用规则(仅 Active → Disabled,级联停用关联应用)
+ /// 批量停用规则(仅 Active → Disabled,不修改关联应用状态)
///
public BatchOperationResult BatchDisableRules(List ruleIds)
{
@@ -1240,14 +1362,11 @@ namespace YLErp.Modules.RiskEngine
var ids = DistinctIds(ruleIds);
var rules = DbContext.glms_risk_rule.Where(r => ids.Contains(r.id)).ToDictionary(r => (long)r.id);
- var activeRules = ids.Where(id => rules.TryGetValue(id, out var rule) && rule.Status == RiskRuleStatus.Active).ToList();
var activeApps = DbContext.glms_risk_rule_application
- .Where(a => a.Status == RiskRuleStatus.Active)
+ .Where(a => a.Status == RiskRuleStatus.Active && a.RuleIds != null && a.RuleIds != "")
.ToList();
- var cascadesByRule = activeRules.ToDictionary(
- id => id,
- id => activeApps.Where(a => ParseRuleIds(a.RuleIds).Contains(id)).Select(a => (long)a.id).Distinct().ToList());
var items = new List();
+ var changedIds = new List();
foreach (var id in ids)
{
@@ -1262,38 +1381,28 @@ namespace YLErp.Modules.RiskEngine
continue;
}
+ var affectedApplicationIds = activeApps
+ .Where(a => ParseStoredRuleIds(a.RuleIds).Contains(id))
+ .Select(a => (long)a.id)
+ .Distinct()
+ .ToList();
rule.Status = RiskRuleStatus.Disabled;
rule.Version++;
rule.UpdateOptId = UserId;
rule.UpdateOptName = UserName;
rule.UpdateDate = DateTime.Now;
WriteAuditLog("RULE_BATCH_DISABLE", "RULE", rule.id, rule.RuleName, "批量停用规则");
- items.Add(BatchItem(id, BatchOperationItemStatus.Succeeded, "DISABLED", "规则已停用", cascadesByRule[id]));
+ changedIds.Add(id);
+ items.Add(BatchItem(id, BatchOperationItemStatus.Succeeded, "DISABLED", "规则已停用",
+ affectedApplicationIds));
}
- // 多条规则可能关联同一应用;实际状态修改、审计和顶层级联结果均按应用 ID 去重。
- var cascadedIds = items.SelectMany(i => i.CascadedApplicationIds).Distinct().ToHashSet();
- foreach (var app in activeApps.Where(a => cascadedIds.Contains(a.id)))
+ if (changedIds.Any())
{
- app.Status = RiskRuleStatus.Disabled;
- app.Version++;
- app.UpdateOptId = UserId;
- app.UpdateOptName = UserName;
- app.UpdateDate = DateTime.Now;
- WriteAuditLog("RULE_CASCADE_DISABLE_APP", "APPLICATION", app.id, $"应用{app.id}",
- $"因规则批量停用,应用配置被级联停用(规则IDs:{string.Join(",", activeRules.Where(id => cascadesByRule[id].Contains(app.id)))})");
- }
-
- if (items.Any(i => i.Status == BatchOperationItemStatus.Succeeded))
- {
- // 规则及其级联应用在同一次数据库提交中生效。
SaveChangesWithConcurrencyCheck();
TryRefreshCache(
- () => activeRules.ForEach(id => RiskEngineService.GetInstance().RefreshOneRuleCache(id)),
- "风控规则缓存", activeRules);
- if (cascadedIds.Any())
- TryRefreshCache(() => RiskEngineService.GetInstance().RefreshApplication(),
- "风控应用缓存", cascadedIds);
+ () => changedIds.ForEach(id => RiskEngineService.GetInstance().RefreshOneRuleCache(id)),
+ "风控规则缓存", changedIds);
}
var result = BuildBatchResult(items);
@@ -1313,14 +1422,15 @@ namespace YLErp.Modules.RiskEngine
.Where(RuleIdsMatchExpr(ruleId))
.OrderByDescending(a => a.UpdateDate)
.ToList();
-
+ var ruleStatuses = LoadRuleStatuses(apps.Select(a => a.RuleIds));
var result = new List();
foreach (var app in apps)
{
- result.Add(new RiskApplicationListItem
+ var item = new RiskApplicationListItem
{
Id = app.id,
RuleIds = app.RuleIds,
+ Description = app.Description,
Status = (int)app.Status,
ControlStrategy = app.ControlStrategy,
TriggerPoints = app.TriggerPoints,
@@ -1334,7 +1444,9 @@ namespace YLErp.Modules.RiskEngine
OptDate = app.OptDate.GetValueOrDefault(),
UpdateOptName = app.UpdateOptName,
UpdateDate = app.UpdateDate ?? app.OptDate.GetValueOrDefault()
- });
+ };
+ PopulateApplicationAvailability(item, ruleStatuses);
+ result.Add(item);
}
return result;
@@ -1363,7 +1475,7 @@ namespace YLErp.Modules.RiskEngine
#region Application Management
///
- /// 查询应用配置列表(多条件筛选,内存关联规则名称)
+ /// 查询应用配置列表(多条件筛选,批量计算关联规则统计)
///
public SearchListResult QueryApplicationList(QueryRiskApplicationReq req)
{
@@ -1371,20 +1483,11 @@ namespace YLErp.Modules.RiskEngine
.Where(a => a.Status != RiskRuleStatus.Deleted);
if (req.Status.HasValue)
- {
query = query.Where(a => a.Status == req.Status.Value);
- }
-
if (req.ControlStrategy.HasValue)
- {
query = query.Where(a => a.ControlStrategy == req.ControlStrategy.Value);
- }
-
if (!string.IsNullOrWhiteSpace(req.TriggerPoint))
- {
query = query.Where(a => a.TriggerPoints.Contains(req.TriggerPoint));
- }
-
if (!string.IsNullOrWhiteSpace(req.Keyword))
{
query = query.Where(a => DbContext.glms_risk_rule
@@ -1396,16 +1499,12 @@ namespace YLErp.Modules.RiskEngine
|| a.RuleIds.EndsWith("," + r.id.ToString())
|| a.RuleIds.Contains("," + r.id.ToString() + ","))));
}
-
if (!string.IsNullOrWhiteSpace(req.ScopeClientIds))
query = ApplyScopeFilter(query, a => a.ScopeClientIds, req.ScopeClientIds);
-
if (!string.IsNullOrWhiteSpace(req.ScopeUnderlyingTypes))
query = ApplyScopeFilter(query, a => a.ScopeUnderlyingTypes, req.ScopeUnderlyingTypes);
-
if (!string.IsNullOrWhiteSpace(req.ScopeTradeTypes))
query = ApplyScopeFilter(query, a => a.ScopeTradeTypes, req.ScopeTradeTypes);
-
if (!string.IsNullOrWhiteSpace(req.ScopeAssetBookIds))
query = ApplyScopeFilter(query, a => a.ScopeAssetBookIds, req.ScopeAssetBookIds);
@@ -1431,11 +1530,16 @@ namespace YLErp.Modules.RiskEngine
})
.ToSearchList(req);
+ var items = pagedResult.rows?.ToList() ?? new List();
+ var ruleStatuses = LoadRuleStatuses(items.Select(item => item.RuleIds));
+ foreach (var item in items)
+ PopulateApplicationAvailability(item, ruleStatuses);
+ pagedResult.rows = items;
return pagedResult;
}
///
- /// 获取应用配置详情(含关联规则信息)
+ /// 获取应用配置详情(含关联规则信息和可用状态)
///
public RiskApplicationDetail GetApplicationDetail(long applicationId)
{
@@ -1444,7 +1548,7 @@ namespace YLErp.Modules.RiskEngine
if (app == null)
throw new ServiceException("应用配置不存在或已删除");
- return new RiskApplicationDetail
+ var result = new RiskApplicationDetail
{
Id = app.id,
RuleIds = app.RuleIds,
@@ -1463,6 +1567,8 @@ namespace YLErp.Modules.RiskEngine
UpdateOptName = app.UpdateOptName,
UpdateDate = app.UpdateDate ?? app.OptDate.GetValueOrDefault()
};
+ PopulateApplicationAvailability(result, LoadRuleStatuses(new[] { app.RuleIds }));
+ return result;
}
///
@@ -1636,7 +1742,7 @@ namespace YLErp.Modules.RiskEngine
}
///
- /// 启用应用配置(校验关联规则状态)
+ /// 启用应用配置(至少存在一条 Active 关联规则)
///
public void EnableApplication(long applicationId)
{
@@ -1644,12 +1750,10 @@ namespace YLErp.Modules.RiskEngine
if (app == null)
throw new ServiceException("仅已停用的应用配置可以启用");
- var errors = ValidateAndEnsureRuleCache(app.RuleIds);
- if (errors.Any())
- throw new ServiceException($"以下关联规则不可用:{string.Join(";", errors)}");
+ if (!HasAnyActiveRule(app.RuleIds))
+ throw new ServiceException("该应用下所有规则均已停用,无法启用应用。请先在规则管理中启用至少一条关联规则");
app.Status = RiskRuleStatus.Active;
-
app.Version = app.Version + 1;
app.UpdateOptId = UserId;
app.UpdateOptName = UserName;
@@ -1657,7 +1761,6 @@ namespace YLErp.Modules.RiskEngine
WriteAuditLog("APP_ENABLE", "APPLICATION", applicationId, ResolveRuleNames(app.RuleIds), "启用应用配置");
SaveChangesWithConcurrencyCheck();
-
RiskEngineService.GetInstance().RefreshApplication();
}
@@ -1684,7 +1787,7 @@ namespace YLErp.Modules.RiskEngine
}
///
- /// 批量启用应用配置(逐条校验关联规则)
+ /// 批量启用应用配置(逐条校验至少一条关联规则已启用)
///
public BatchOperationResult BatchEnableApplications(List applicationIds)
{
@@ -1713,11 +1816,10 @@ namespace YLErp.Modules.RiskEngine
continue;
}
- var errors = ValidateAndEnsureRuleCache(app.RuleIds);
- if (errors.Any())
+ if (!HasAnyActiveRule(app.RuleIds))
{
- items.Add(BatchItem(id, BatchOperationItemStatus.Failed, "RELATED_RULE_UNAVAILABLE",
- $"关联规则不可用:{string.Join(";", errors)}"));
+ items.Add(BatchItem(id, BatchOperationItemStatus.Failed, "NO_ACTIVE_RULE",
+ "该应用下所有规则均已停用,无法启用应用。请先在规则管理中启用至少一条关联规则"));
continue;
}
@@ -1806,6 +1908,20 @@ namespace YLErp.Modules.RiskEngine
.ToList();
}
+ ///
+ /// 生成变量删除被阻止时的引用规则提示。
+ ///
+ private static string BuildVariableDeleteReferenceMessage(IEnumerable referencedRules)
+ {
+ var ruleIds = referencedRules
+ .Select(rule => rule.id)
+ .Distinct()
+ .OrderBy(id => id)
+ .ToList();
+
+ return $"该变量被 {ruleIds.Count} 个规则引用(规则ID:{string.Join("、", ruleIds)}),无法删除";
+ }
+
///
/// 根据结构化 ConditionJson 和当前变量池定义重新生成简洁 RuleExpr。
///
@@ -1814,7 +1930,7 @@ namespace YLErp.Modules.RiskEngine
var conditions = RuleConditionExpressionBuilder.DeserializeConditions(conditionJson);
var variableIds = RuleConditionExpressionBuilder.GetReferencedVariableIds(conditions).ToList();
var variables = DbContext.glms_risk_variable
- .Where(variable => variableIds.Contains(variable.id))
+ .Where(variable => variableIds.Contains(variable.id) && variable.Status != RiskRuleStatus.Deleted)
.ToList()
.ToDictionary(variable => (long)variable.id);
@@ -1831,7 +1947,8 @@ namespace YLErp.Modules.RiskEngine
///
public SearchListResult QueryVariableList(QueryRiskVariableReq req)
{
- var query = DbContext.glms_risk_variable.AsQueryable();
+ var query = DbContext.glms_risk_variable
+ .Where(v => v.Status != RiskRuleStatus.Deleted);
if (req.Category.HasValue)
{
@@ -1848,6 +1965,11 @@ namespace YLErp.Modules.RiskEngine
query = query.Where(v => v.VariableName.Contains(req.VariableName));
}
+ if (req.Status.HasValue)
+ {
+ query = query.Where(v => v.Status == req.Status.Value);
+ }
+
var result = query.OrderBy(v => v.SortOrder).ThenBy(v => v.VariableName)
.Select(v => new RiskVariableListItem
{
@@ -1860,7 +1982,8 @@ namespace YLErp.Modules.RiskEngine
Description = v.Description,
VariableExpr = v.VariableExpr,
Version = v.Version,
- SortOrder = v.SortOrder
+ SortOrder = v.SortOrder,
+ Status = v.Status
});
return result.ToSearchList(req);
@@ -1888,6 +2011,7 @@ namespace YLErp.Modules.RiskEngine
VariableExpr = variable.VariableExpr,
Version = variable.Version,
SortOrder = variable.SortOrder,
+ Status = variable.Status,
OptName = variable.OptName,
OptDate = variable.OptDate.GetValueOrDefault(),
UpdateOptName = variable.UpdateOptName,
@@ -1917,6 +2041,7 @@ namespace YLErp.Modules.RiskEngine
ValueDomain = req.ValueDomain,
Description = req.Description,
VariableExpr = req.VariableExpr,
+ Status = RiskRuleStatus.Active,
SortOrder = req.SortOrder,
Version = 1
};
@@ -2061,18 +2186,21 @@ namespace YLErp.Modules.RiskEngine
}
///
- /// 删除变量(有 Active 引用时拒绝)
+ /// 删除变量(软删除:有未删除规则引用时拒绝)
///
public void DeleteVariable(long variableId)
{
var variable = GetVariableOrThrow(variableId);
- var activeRefCount = GetRulesByVariableId(variableId)
- .Count(r => r.Status == RiskRuleStatus.Active);
- if (activeRefCount > 0)
- throw new ServiceException($"该变量被 {activeRefCount} 个生效中的规则引用,无法删除");
+ var referencedRules = GetRulesByVariableId(variableId);
+ if (referencedRules.Count > 0)
+ throw new ServiceException(BuildVariableDeleteReferenceMessage(referencedRules));
- DbContext.glms_risk_variable.Remove(variable);
+ variable.Status = RiskRuleStatus.Deleted;
+ variable.Version = variable.Version + 1;
+ variable.UpdateOptId = UserId;
+ variable.UpdateOptName = UserName;
+ variable.UpdateDate = DateTime.Now;
WriteAuditLog("VAR_DELETE", "VARIABLE", variableId, variable.VariableName, $"删除变量:{variable.VariableName}");
SaveChangesWithConcurrencyCheck();
@@ -2081,7 +2209,7 @@ namespace YLErp.Modules.RiskEngine
}
///
- /// 批量删除变量(不存在或被生效规则引用的变量将被跳过)
+ /// 批量删除变量(不存在或被未删除规则引用的变量将被跳过)
///
public BatchOperationResult BatchDeleteVariables(List variableIds)
{
@@ -2089,9 +2217,11 @@ namespace YLErp.Modules.RiskEngine
throw new ServiceException("变量ID列表不能为空");
var ids = DistinctIds(variableIds);
- var variables = DbContext.glms_risk_variable.Where(v => ids.Contains(v.id)).ToDictionary(v => (long)v.id);
- var activeRules = DbContext.glms_risk_rule
- .Where(r => r.Status == RiskRuleStatus.Active && r.ConditionJson != null)
+ var variables = DbContext.glms_risk_variable
+ .Where(v => ids.Contains(v.id) && v.Status != RiskRuleStatus.Deleted)
+ .ToDictionary(v => (long)v.id);
+ var rules = DbContext.glms_risk_rule
+ .Where(r => r.Status != RiskRuleStatus.Deleted && r.ConditionJson != null && r.ConditionJson != "")
.ToList();
var items = new List();
@@ -2099,20 +2229,25 @@ namespace YLErp.Modules.RiskEngine
{
if (!variables.TryGetValue(id, out var variable))
{
- items.Add(BatchItem(id, BatchOperationItemStatus.Failed, "NOT_FOUND", "变量不存在"));
+ items.Add(BatchItem(id, BatchOperationItemStatus.Failed, "NOT_FOUND", "变量不存在或已删除"));
continue;
}
- var activeRefCount = activeRules.Count(rule =>
- RuleConditionExpressionBuilder.IsVariableReferenced(rule.ConditionJson, id));
- if (activeRefCount > 0)
+ var referencedRules = rules.Where(rule =>
+ RuleConditionExpressionBuilder.IsVariableReferenced(rule.ConditionJson, id))
+ .ToList();
+ if (referencedRules.Count > 0)
{
- items.Add(BatchItem(id, BatchOperationItemStatus.Failed, "ACTIVE_RULE_REFERENCE",
- $"该变量被 {activeRefCount} 个生效中的规则引用,无法删除"));
+ items.Add(BatchItem(id, BatchOperationItemStatus.Failed, "RULE_REFERENCE",
+ BuildVariableDeleteReferenceMessage(referencedRules)));
continue;
}
- DbContext.glms_risk_variable.Remove(variable);
+ variable.Status = RiskRuleStatus.Deleted;
+ variable.Version = variable.Version + 1;
+ variable.UpdateOptId = UserId;
+ variable.UpdateOptName = UserName;
+ variable.UpdateDate = DateTime.Now;
WriteAuditLog("VAR_BATCH_DELETE", "VARIABLE", variable.id, variable.VariableName, $"批量删除变量:{variable.VariableName}");
items.Add(BatchItem(id, BatchOperationItemStatus.Succeeded, "DELETED", "变量已删除"));
}
@@ -2135,7 +2270,7 @@ namespace YLErp.Modules.RiskEngine
public List GetAllVariableList()
{
var result = DbContext.glms_risk_variable
- .Where(v => v.VariableExpr != null && v.VariableExpr != "")
+ .Where(v => v.VariableExpr != null && v.VariableExpr != "" && v.Status != RiskRuleStatus.Deleted)
.OrderBy(v => v.SortOrder)
.Select(v => new RiskVariableSimpleItem
{
@@ -2144,7 +2279,8 @@ namespace YLErp.Modules.RiskEngine
Category = v.Category,
DataType = v.DataType,
Unit = v.Unit,
- VariableExpr = v.VariableExpr
+ VariableExpr = v.VariableExpr,
+ Status = v.Status
})
.ToList();
@@ -2331,4 +2467,4 @@ namespace YLErp.Modules.RiskEngine
#endregion
}
-}
\ No newline at end of file
+}
diff --git a/YLErpWeb/Controllers/RiskRuleController.cs b/YLErpWeb/Controllers/RiskRuleController.cs
index b5730fd6..643776bb 100644
--- a/YLErpWeb/Controllers/RiskRuleController.cs
+++ b/YLErpWeb/Controllers/RiskRuleController.cs
@@ -145,8 +145,8 @@ namespace YLErp.Web.Controllers
try
{
var service = GetRiskRuleService();
- await Task.Run(() => service.DeleteRule(id));
- return Json(new { success = true });
+ var result = await Task.Run(() => service.DeleteRule(id));
+ return Json(new { success = true, data = result });
}
catch (ServiceException ex)
{
@@ -215,8 +215,7 @@ namespace YLErp.Web.Controllers
return Json(new
{
success = true,
- cascadedAppIds = result.CascadedApplicationIds,
- cascadedAppCount = result.CascadedApplicationIds.Count
+ affectedActiveAppCount = result.AffectedActiveAppCount
});
}
catch (ServiceException ex)
@@ -658,7 +657,7 @@ namespace YLErp.Web.Controllers
[HttpDelete("risk-variables/{id}")]
[MyAuthorize("风险控制-风控变量删除")]
- /// 删除变量
+ /// 删除变量(软删除)
public async Task DeleteRiskVariable(long id)
{
try
@@ -680,7 +679,7 @@ namespace YLErp.Web.Controllers
[HttpDelete("risk-variables/batch")]
[MyAuthorize("风险控制-风控变量删除")]
- /// 批量删除变量
+ /// 批量删除变量(软删除)
public async Task BatchDeleteRiskVariables([FromBody] BatchIdsReq req)
{
try