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