feat: 批量操作交互逻辑统一&解耦规则与应用的状态

This commit is contained in:
尹峰
2026-07-27 16:54:06 +08:00
parent 3651d3d2df
commit ca77a71f2c
16 changed files with 487 additions and 172 deletions
@@ -23,6 +23,8 @@ namespace YLErp.DBModels
public string Description { get; set; }
/// <summary>取值表达式(C# 表达式,用于计算变量值)</summary>
public string VariableExpr { get; set; }
/// <summary>状态: 1=Active, 2=Disabled, 3=Deleted</summary>
public RiskRuleStatus Status { get; set; } = RiskRuleStatus.Active;
/// <summary>版本号(乐观锁)</summary>
[ConcurrencyCheck]
public int Version { get; set; } = 1;
@@ -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;
@@ -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;
@@ -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,
@@ -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<DisableRuleResult>("RuleId", typeof(long));
AssertProperty<DisableRuleResult>("AffectedActiveAppCount", typeof(int));
Assert.IsNull(typeof(DisableRuleResult).GetProperty("CascadedApplicationIds"));
}
[TestMethod]
public void DeleteRuleResult_ReportsAffectedApplications()
{
AssertProperty<DeleteRuleResult>("RuleId", typeof(long));
AssertProperty<DeleteRuleResult>("AffectedActiveAppCount", typeof(int));
AssertProperty<DeleteRuleResult>("CausedNoAvailableRuleAppIds", typeof(List<long>));
}
[TestMethod]
public void ApplicationDtos_ExposeAvailabilityFields()
{
AssertAvailabilityProperties<RiskApplicationListItem>();
AssertAvailabilityProperties<RiskApplicationDetail>();
}
[TestMethod]
public void BatchOperationResult_ExposesDecoupledApplicationImpactFields()
{
AssertProperty<BatchOperationItemResult>("AffectedApplicationIds", typeof(List<long>));
AssertProperty<BatchOperationItemResult>("CausedNoAvailableRuleApplicationIds", typeof(List<long>));
AssertProperty<BatchOperationResult>("AffectedApplicationIds", typeof(List<long>));
AssertProperty<BatchOperationResult>("CausedNoAvailableRuleApplicationIds", typeof(List<long>));
}
[TestMethod]
public void VariableDeleteReferenceMessage_ListsDistinctSortedRuleIds()
{
var method = typeof(RiskRuleService).GetMethod(
"BuildVariableDeleteReferenceMessage", BindingFlags.NonPublic | BindingFlags.Static);
Assert.IsNotNull(method);
var rules = new List<glms_risk_rule>
{
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<T>()
{
foreach (var propertyName in AvailabilityPropertyNames)
{
var expectedType = propertyName.EndsWith("Tooltip", StringComparison.Ordinal)
? typeof(string)
: propertyName.StartsWith("Can", StringComparison.Ordinal)
? typeof(bool)
: typeof(int);
AssertProperty<T>(propertyName, expectedType);
}
}
private static void AssertProperty<T>(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.");
}
}
}
@@ -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; }
/// <summary>操作影响到的应用 ID(不代表应用状态发生变化)</summary>
public List<long> AffectedApplicationIds { get; set; } = new List<long>();
/// <summary>操作后不再包含任何启用规则的应用 ID</summary>
public List<long> CausedNoAvailableRuleApplicationIds { get; set; } = new List<long>();
/// <summary>旧版级联字段,解耦后始终为空,仅为兼容旧客户端保留</summary>
public List<long> CascadedApplicationIds { get; set; } = new List<long>();
}
@@ -43,6 +49,11 @@ namespace YLErp.Modules.RiskEngine.Dto
public int FailedCount { get; set; }
public bool HasChanges { get; set; }
public List<BatchOperationItemResult> Items { get; set; } = new List<BatchOperationItemResult>();
/// <summary>所有请求项影响到的应用 ID 去重并集</summary>
public List<long> AffectedApplicationIds { get; set; } = new List<long>();
/// <summary>操作后不再包含任何启用规则的应用 ID 去重并集</summary>
public List<long> CausedNoAvailableRuleApplicationIds { get; set; } = new List<long>();
/// <summary>旧版级联字段,解耦后始终为空,仅为兼容旧客户端保留</summary>
public List<long> CascadedApplicationIds { get; set; } = new List<long>();
}
}
@@ -0,0 +1,19 @@
using System.Collections.Generic;
namespace YLErp.Modules.RiskEngine.Dto
{
/// <summary>
/// 删除规则结果
/// </summary>
public class DeleteRuleResult
{
/// <summary>规则 ID</summary>
public long RuleId { get; set; }
/// <summary>删除前引用该规则的启用中应用数量</summary>
public int AffectedActiveAppCount { get; set; }
/// <summary>删除后不再包含任何启用规则的应用 ID</summary>
public List<long> CausedNoAvailableRuleAppIds { get; set; } = new List<long>();
}
}
@@ -1,5 +1,3 @@
using System.Collections.Generic;
namespace YLErp.Modules.RiskEngine.Dto
{
/// <summary>
@@ -7,7 +5,10 @@ namespace YLErp.Modules.RiskEngine.Dto
/// </summary>
public class DisableRuleResult
{
/// <summary>级联停用的应用 ID 列表</summary>
public List<long> CascadedApplicationIds { get; set; } = new List<long>();
/// <summary>规则 ID</summary>
public long RuleId { get; set; }
/// <summary>引用该规则的启用中应用数量</summary>
public int AffectedActiveAppCount { get; set; }
}
}
@@ -14,5 +14,7 @@ namespace YLErp.Modules.RiskEngine.Dto
public RiskVariableDataType? DataType { get; set; }
/// <summary>变量名称</summary>
public string VariableName { get; set; }
/// <summary>状态筛选(不传则返回非 Deleted 的全部)</summary>
public RiskRuleStatus? Status { get; set; }
}
}
@@ -14,6 +14,18 @@ namespace YLErp.Modules.RiskEngine.Dto
public string RuleIds { get; set; }
/// <summary>应用说明</summary>
public string Description { get; set; }
/// <summary>关联规则数(不含已删除规则)</summary>
public int AssociatedRuleCount { get; set; }
/// <summary>生效规则数(关联规则中已启用的数量)</summary>
public int EffectiveRuleCount { get; set; }
/// <summary>当前状态下是否允许启用</summary>
public bool CanEnable { get; set; }
/// <summary>当前状态下是否允许停用</summary>
public bool CanDisable { get; set; }
/// <summary>启用按钮不可用时的提示</summary>
public string EnableButtonTooltip { get; set; }
/// <summary>停用按钮不可用时的提示</summary>
public string DisableButtonTooltip { get; set; }
/// <summary>应用配置状态</summary>
public RiskRuleStatus Status { get; set; }
/// <summary>风控策略</summary>
@@ -14,6 +14,18 @@ namespace YLErp.Modules.RiskEngine.Dto
public string RuleIds { get; set; }
/// <summary>应用说明</summary>
public string Description { get; set; }
/// <summary>关联规则数(不含已删除规则)</summary>
public int AssociatedRuleCount { get; set; }
/// <summary>生效规则数(关联规则中已启用的数量)</summary>
public int EffectiveRuleCount { get; set; }
/// <summary>当前状态下是否允许启用</summary>
public bool CanEnable { get; set; }
/// <summary>当前状态下是否允许停用</summary>
public bool CanDisable { get; set; }
/// <summary>启用按钮不可用时的提示</summary>
public string EnableButtonTooltip { get; set; }
/// <summary>停用按钮不可用时的提示</summary>
public string DisableButtonTooltip { get; set; }
/// <summary>应用配置状态</summary>
public int Status { get; set; }
/// <summary>风控策略</summary>
@@ -28,6 +28,8 @@ namespace YLErp.Modules.RiskEngine.Dto
public int Version { get; set; }
/// <summary>排序序号</summary>
public int SortOrder { get; set; }
/// <summary>状态</summary>
public RiskRuleStatus Status { get; set; }
/// <summary>创建人姓名</summary>
public string OptName { get; set; }
/// <summary>创建时间</summary>
@@ -27,5 +27,7 @@ namespace YLErp.Modules.RiskEngine.Dto
public int Version { get; set; }
/// <summary>排序序号</summary>
public int SortOrder { get; set; }
/// <summary>状态</summary>
public RiskRuleStatus Status { get; set; }
}
}
@@ -19,5 +19,7 @@ namespace YLErp.Modules.RiskEngine.Dto
public string Unit { get; set; }
/// <summary>取值表达式</summary>
public string VariableExpr { get; set; }
/// <summary>状态</summary>
public RiskRuleStatus Status { get; set; }
}
}
+296 -160
View File
@@ -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<long>) 批量删除规则(逐条保护检查,单事务
BatchDeleteRules(List<long>) 批量删除规则(同步移除应用关联,单次提交
BatchEnableRules(List<long>) 批量启用规则(逐条编译校验)
BatchDisableRules(List<long>) 批量停用规则(级联停用关联应用
BatchDisableRules(List<long>) 批量停用规则(不修改应用状态
GetRuleApplications(long) 获取规则关联的应用配置列表
GetAllRuleList() 获取所有启用规则(轻量字段,供应用配置下拉)
GetAllRuleList() 获取所有未删除规则(轻量字段,供应用配置下拉)
── 应用配置管理(9 个) ───────────────────────────────────────────
QueryApplicationList(QueryRiskApplicationReq) 查询应用列表(多条件筛选,内存关联规则名称
QueryApplicationList(QueryRiskApplicationReq) 查询应用列表(分页后批量计算规则统计
GetApplicationDetail(long) 获取应用详情(含关联规则信息)
CreateApplication(CreateRiskApplicationReq) 新建应用(关联规则 Active 校验,触发时点/Scope 校验)
UpdateApplication(long, UpdateRiskApplicationReq) 修改应用(乐观锁校验)
DeleteApplication(long) 删除应用(软删除)
BatchDeleteApplications(List<long>) 批量删除应用(软删除)
EnableApplication(long) 启用应用(校验关联规则状态
EnableApplication(long) 启用应用(至少一条关联规则 Active
DisableApplication(long) 停用应用
BatchEnableApplications(List<long>) 批量启用(逐条校验关联规则)
BatchEnableApplications(List<long>) 批量启用(逐条校验至少一条 Active 规则)
BatchDisableApplications(List<long>) 批量停用
── 变量管理(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<string> 缓存,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
/// </summary>
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;
}
/// <summary>
/// 解析数据库中已保存的规则 ID。删除应用关联的最后一条规则后允许为空。
/// </summary>
private static List<long> ParseStoredRuleIds(string ruleIds)
{
if (string.IsNullOrWhiteSpace(ruleIds))
return new List<long>();
var ids = new List<long>();
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<long, RiskRuleStatus> LoadRuleStatuses(IEnumerable<string> storedRuleIds)
{
var ids = storedRuleIds.SelectMany(ParseStoredRuleIds).Distinct().ToList();
if (ids.Count == 0)
return new Dictionary<long, RiskRuleStatus>();
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<long, RiskRuleStatus> 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<long, RiskRuleStatus> 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;
}
/// <summary>
/// 规范化逗号分隔的规则 ID:去除空格和重复项,统一为无空格格式。
/// </summary>
@@ -723,7 +804,8 @@ namespace YLErp.Modules.RiskEngine
}
private static BatchOperationItemResult BatchItem(long id, BatchOperationItemStatus status,
string code, string message, IEnumerable<long> cascadedApplicationIds = null)
string code, string message, IEnumerable<long> affectedApplicationIds = null,
IEnumerable<long> 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<long>()
AffectedApplicationIds = affectedApplicationIds?.Distinct().ToList() ?? new List<long>(),
CausedNoAvailableRuleApplicationIds = causedNoAvailableRuleApplicationIds?.Distinct().ToList() ?? new List<long>(),
CascadedApplicationIds = new List<long>()
};
}
@@ -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<long>()
};
result.HasChanges = result.SucceededCount > 0;
result.Outcome = result.FailedCount == 0
@@ -974,28 +1060,57 @@ namespace YLErp.Modules.RiskEngine
}
/// <summary>
/// 删除规则(软删除,有 Active 应用引用时拒绝
/// 删除规则(软删除,并从所有未删除应用中移除关联
/// </summary>
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<long>();
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()
};
}
/// <summary>
@@ -1025,7 +1140,7 @@ namespace YLErp.Modules.RiskEngine
}
/// <summary>
/// 停用规则(仅 Active → Disabled
/// 停用规则(仅 Active → Disabled,不修改关联应用状态
/// </summary>
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<long>();
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
};
}
/// <summary>
@@ -1117,7 +1209,7 @@ namespace YLErp.Modules.RiskEngine
}
/// <summary>
/// 批量删除规则(逐条保护检查)
/// 批量删除规则,并从所有未删除应用中移除成功删除的规则关联
/// </summary>
public BatchOperationResult BatchDeleteRules(List<long> 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<BatchOperationItemResult>();
var changedIds = new List<long>();
var changedIdSet = changedIds.ToHashSet();
var affectedApps = changedIds.Count == 0
? new List<glms_risk_rule_application>()
: 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<long>());
var noAvailableByRule = changedIds.ToDictionary(id => id, _ => new List<long>());
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<BatchOperationItemResult>();
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
}
/// <summary>
/// 批量停用规则(仅 Active → Disabled级联停用关联应用)
/// 批量停用规则(仅 Active → Disabled不修改关联应用状态
/// </summary>
public BatchOperationResult BatchDisableRules(List<long> 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<BatchOperationItemResult>();
var changedIds = new List<long>();
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<RiskApplicationListItem>();
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
/// <summary>
/// 查询应用配置列表(多条件筛选,内存关联规则名称
/// 查询应用配置列表(多条件筛选,批量计算关联规则统计
/// </summary>
public SearchListResult<RiskApplicationListItem> 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<RiskApplicationListItem>();
var ruleStatuses = LoadRuleStatuses(items.Select(item => item.RuleIds));
foreach (var item in items)
PopulateApplicationAvailability(item, ruleStatuses);
pagedResult.rows = items;
return pagedResult;
}
/// <summary>
/// 获取应用配置详情(含关联规则信息)
/// 获取应用配置详情(含关联规则信息和可用状态
/// </summary>
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;
}
/// <summary>
@@ -1636,7 +1742,7 @@ namespace YLErp.Modules.RiskEngine
}
/// <summary>
/// 启用应用配置(校验关联规则状态
/// 启用应用配置(至少存在一条 Active 关联规则)
/// </summary>
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
}
/// <summary>
/// 批量启用应用配置(逐条校验关联规则)
/// 批量启用应用配置(逐条校验至少一条关联规则已启用
/// </summary>
public BatchOperationResult BatchEnableApplications(List<long> 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();
}
/// <summary>
/// 生成变量删除被阻止时的引用规则提示。
/// </summary>
private static string BuildVariableDeleteReferenceMessage(IEnumerable<glms_risk_rule> referencedRules)
{
var ruleIds = referencedRules
.Select(rule => rule.id)
.Distinct()
.OrderBy(id => id)
.ToList();
return $"该变量被 {ruleIds.Count} 个规则引用(规则ID{string.Join("", ruleIds)}),无法删除";
}
/// <summary>
/// 根据结构化 ConditionJson 和当前变量池定义重新生成简洁 RuleExpr。
/// </summary>
@@ -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
/// </summary>
public SearchListResult<RiskVariableListItem> 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
}
/// <summary>
/// 删除变量(有 Active 引用时拒绝)
/// 删除变量(软删除:有未删除规则引用时拒绝)
/// </summary>
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
}
/// <summary>
/// 批量删除变量(不存在或被生效规则引用的变量将被跳过)
/// 批量删除变量(不存在或被未删除规则引用的变量将被跳过)
/// </summary>
public BatchOperationResult BatchDeleteVariables(List<long> 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<BatchOperationItemResult>();
@@ -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<RiskVariableSimpleItem> 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
}
}
}
+5 -6
View File
@@ -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("风险控制-风控变量删除")]
/// <summary>删除变量</summary>
/// <summary>删除变量(软删除)</summary>
public async Task<JsonResult> DeleteRiskVariable(long id)
{
try
@@ -680,7 +679,7 @@ namespace YLErp.Web.Controllers
[HttpDelete("risk-variables/batch")]
[MyAuthorize("风险控制-风控变量删除")]
/// <summary>批量删除变量</summary>
/// <summary>批量删除变量(软删除)</summary>
public async Task<JsonResult> BatchDeleteRiskVariables([FromBody] BatchIdsReq req)
{
try