From 220657cb5c0a9484d7c52c07398f882df30497b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=B9=E5=B3=B0?= Date: Thu, 16 Jul 2026 19:58:26 +0800 Subject: [PATCH] =?UTF-8?q?=E6=89=B9=E9=87=8F=E5=90=AF=E5=81=9C=E8=A7=84?= =?UTF-8?q?=E5=88=99=E5=92=8C=E5=BA=94=E7=94=A8=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RiskEngine/Dto/BatchDisableRulesResult.cs | 21 + .../RiskEngine/Dto/DisableRuleResult.cs | 13 + .../RiskEngine/Dto/RiskRuleSimpleItem.cs | 4 + .../Modules/RiskEngine/RiskEngineService.cs | 13 +- .../Modules/RiskEngine/RiskRuleService.cs | 504 +++++++++++++++++- YLErpWeb/Controllers/RiskRuleController.cs | 94 +++- 6 files changed, 618 insertions(+), 31 deletions(-) create mode 100644 YLErpDAL/Modules/RiskEngine/Dto/BatchDisableRulesResult.cs create mode 100644 YLErpDAL/Modules/RiskEngine/Dto/DisableRuleResult.cs diff --git a/YLErpDAL/Modules/RiskEngine/Dto/BatchDisableRulesResult.cs b/YLErpDAL/Modules/RiskEngine/Dto/BatchDisableRulesResult.cs new file mode 100644 index 00000000..cb2e2fcd --- /dev/null +++ b/YLErpDAL/Modules/RiskEngine/Dto/BatchDisableRulesResult.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; + +namespace YLErp.Modules.RiskEngine.Dto +{ + /// + /// 批量停用规则结果 + /// + public class BatchDisableRulesResult + { + /// 是否全部成功 + public bool Success { get; set; } + /// 总数量 + public int TotalCount { get; set; } + /// 成功数量 + public int SuccessCount { get; set; } + /// 错误信息 + public string ErrorMessage { get; set; } + /// 级联停用的应用 ID 列表 + public List CascadedApplicationIds { get; set; } = new List(); + } +} diff --git a/YLErpDAL/Modules/RiskEngine/Dto/DisableRuleResult.cs b/YLErpDAL/Modules/RiskEngine/Dto/DisableRuleResult.cs new file mode 100644 index 00000000..e5702b2e --- /dev/null +++ b/YLErpDAL/Modules/RiskEngine/Dto/DisableRuleResult.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; + +namespace YLErp.Modules.RiskEngine.Dto +{ + /// + /// 停用规则结果 + /// + public class DisableRuleResult + { + /// 级联停用的应用 ID 列表 + public List CascadedApplicationIds { get; set; } = new List(); + } +} diff --git a/YLErpDAL/Modules/RiskEngine/Dto/RiskRuleSimpleItem.cs b/YLErpDAL/Modules/RiskEngine/Dto/RiskRuleSimpleItem.cs index d2d98568..e04c6ddc 100644 --- a/YLErpDAL/Modules/RiskEngine/Dto/RiskRuleSimpleItem.cs +++ b/YLErpDAL/Modules/RiskEngine/Dto/RiskRuleSimpleItem.cs @@ -1,3 +1,5 @@ +using YLErp.DBModels; + namespace YLErp.Modules.RiskEngine.Dto { /// @@ -11,5 +13,7 @@ namespace YLErp.Modules.RiskEngine.Dto public string RuleName { get; set; } /// 规则描述文本 public string RuleText { get; set; } + /// 规则状态 + public RiskRuleStatus Status { get; set; } } } diff --git a/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs b/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs index 45d4432d..471f302f 100644 --- a/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs +++ b/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs @@ -1,4 +1,4 @@ -using Newtonsoft.Json; +using Newtonsoft.Json; using Qdp.Foundation.Utilities; using System.Reflection; using YLErp.BLL; @@ -918,6 +918,17 @@ namespace YLErp.Modules.RiskEngine return true; } + var hasAnyScope = + !IsScopeEmpty(application.ScopeAssetBookIds) || + !IsScopeEmpty(application.ScopeClientIds) || + !IsScopeEmpty(application.ScopeUnderlyingTypes) || + !IsScopeEmpty(application.ScopeTradeTypes); + if (!hasAnyScope) + { + _logger.Error($"[风控引擎] 非全局应用未配置任何适用范围,拒绝匹配 - ApplicationId: {application.Id}"); + return false; + } + if (trade == null) { return false; diff --git a/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs b/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs index b927712d..d12d3be2 100644 --- a/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs +++ b/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs @@ -1,9 +1,10 @@ -using BaseOUDAL; +using BaseOUDAL; using Newtonsoft.Json; using System.Linq; using System.Linq.Expressions; using System.Text.RegularExpressions; using YLErp.BLL; +using YLErp.Commons; using YLErp.DBModels; using YLErp.Modules.RiskEngine.Dto; @@ -76,9 +77,11 @@ using YLErp.Modules.RiskEngine.Dto; UpdateRule(long, UpdateRiskRuleReq) 修改规则(乐观锁校验,Version+1) DeleteRule(long) 删除规则(强保护:有 Active 应用时拒绝) EnableRule(long) 启用规则(仅 Disabled → Active) - DisableRule(long) 停用规则(仅 Active → Disabled) + DisableRule(long) 停用规则(仅 Active → Disabled,级联停用关联应用) GetRuleVersions(long) 获取规则版本历史(从审计日志 SnapshotData 提取版本号) BatchDeleteRules(List) 批量删除规则(逐条保护检查,单事务) + BatchEnableRules(List) 批量启用规则(逐条编译校验) + BatchDisableRules(List) 批量停用规则(级联停用关联应用) GetRuleApplications(long) 获取规则关联的应用配置列表 GetAllRuleList() 获取所有启用规则(轻量字段,供应用配置下拉) @@ -88,6 +91,7 @@ using YLErp.Modules.RiskEngine.Dto; CreateApplication(CreateRiskApplicationReq) 新建应用(关联规则 Active 校验,触发时点/Scope 校验) UpdateApplication(long, UpdateRiskApplicationReq) 修改应用(乐观锁校验) DeleteApplication(long) 删除应用(软删除) + BatchDeleteApplications(List) 批量删除应用(软删除) EnableApplication(long) 启用应用(校验关联规则状态) DisableApplication(long) 停用应用 BatchEnableApplications(List) 批量启用(逐条校验关联规则) @@ -207,6 +211,39 @@ namespace YLErp.Modules.RiskEngine { RiskVariableDataType.Boolean, new HashSet { "是", "否" } } }; + private static readonly Dictionary AuditOperationTypeNames = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "RULE_CREATE", "新建规则" }, + { "RULE_UPDATE", "修改规则" }, + { "RULE_DELETE", "删除规则" }, + { "RULE_ENABLE", "启用规则" }, + { "RULE_DISABLE", "停用规则" }, + { "RULE_BATCH_DELETE", "批量删除规则" }, + { "RULE_BATCH_ENABLE", "批量启用规则" }, + { "RULE_BATCH_DISABLE", "批量停用规则" }, + { "APP_CREATE", "新建应用" }, + { "APP_UPDATE", "修改应用" }, + { "APP_DELETE", "删除应用" }, + { "APP_ENABLE", "启用应用" }, + { "APP_DISABLE", "停用应用" }, + { "APP_BATCH_ENABLE", "批量启用应用" }, + { "APP_BATCH_DISABLE", "批量停用应用" }, + { "APP_BATCH_DELETE", "批量删除应用" }, + { "RULE_CASCADE_DISABLE_APP", "规则级联停用应用" }, + { "VAR_CREATE", "新增变量" }, + { "VARIABLE_CREATE", "新增变量" }, + { "VAR_UPDATE", "修改变量" }, + { "VAR_DELETE", "删除变量" }, + { "VAR_CASCADE_UPDATE_RULE", "变量级联更新规则" } + }; + + private static readonly Dictionary AuditTargetTypeNames = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "RULE", "规则" }, + { "APPLICATION", "应用" }, + { "VARIABLE", "变量" } + }; + public RiskRuleService(OptUserInfo userInfo) : base(userInfo) { } @@ -221,6 +258,39 @@ namespace YLErp.Modules.RiskEngine #region Private Helpers + private static string GetAuditOperationTypeName(string operationType) + { + if (string.IsNullOrWhiteSpace(operationType)) + return string.Empty; + + return AuditOperationTypeNames.TryGetValue(operationType.Trim(), out var name) + ? name + : operationType; + } + + private static string GetAuditTargetTypeName(string targetType) + { + if (string.IsNullOrWhiteSpace(targetType)) + return string.Empty; + + return AuditTargetTypeNames.TryGetValue(targetType.Trim(), out var name) + ? name + : targetType; + } + + private static string FormatAuditId(object value) + { + return value == null ? string.Empty : value.ToString(); + } + + private static string SanitizeFileNamePart(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return string.Empty; + + var sanitized = Regex.Replace(value.Trim(), @"[\\/:*?""<>|\r\n]+", "_"); + return sanitized.Length <= 40 ? sanitized : sanitized.Substring(0, 40); + } /// /// 写入审计日志 /// @@ -398,24 +468,32 @@ namespace YLErp.Modules.RiskEngine /// private void ValidateScopeFields(CreateRiskApplicationReq req) { + var hasAnyScope = + !string.IsNullOrWhiteSpace(req.ScopeAssetBookIds) || + !string.IsNullOrWhiteSpace(req.ScopeClientIds) || + !string.IsNullOrWhiteSpace(req.ScopeUnderlyingTypes) || + !string.IsNullOrWhiteSpace(req.ScopeTradeTypes); + if (req.ScopeIsGlobal == 1) { - if (!string.IsNullOrEmpty(req.ScopeAssetBookIds) || - !string.IsNullOrEmpty(req.ScopeClientIds) || - !string.IsNullOrEmpty(req.ScopeUnderlyingTypes) || - !string.IsNullOrEmpty(req.ScopeTradeTypes)) + if (hasAnyScope) { throw new ServiceException("全局适用时,不应指定其他适用范围维度"); } return; } - if (!string.IsNullOrEmpty(req.ScopeAssetBookIds)) + if (!hasAnyScope) + { + throw new ServiceException("非全局应用至少需要指定一个适用范围"); + } + + if (!string.IsNullOrWhiteSpace(req.ScopeAssetBookIds)) { ValidateIdList(req.ScopeAssetBookIds, "适用账户"); } - if (!string.IsNullOrEmpty(req.ScopeClientIds)) + if (!string.IsNullOrWhiteSpace(req.ScopeClientIds)) { ValidateIdList(req.ScopeClientIds, "适用对手方"); } @@ -540,6 +618,14 @@ namespace YLErp.Modules.RiskEngine return ids; } + /// + /// 规范化逗号分隔的规则 ID:去除空格和重复项,统一为无空格格式。 + /// + private string NormalizeRuleIds(string ruleIds) + { + return string.Join(",", ParseRuleIds(ruleIds).Distinct()); + } + /// /// EF Core 可翻译的逗号分隔字段精确匹配表达式 /// @@ -922,7 +1008,7 @@ namespace YLErp.Modules.RiskEngine /// /// 停用规则(仅 Active → Disabled) /// - public void DisableRule(long ruleId) + public DisableRuleResult DisableRule(long ruleId) { var rule = DbContext.glms_risk_rule.FirstOrDefault(r => r.id == ruleId && r.Status == RiskRuleStatus.Active); if (rule == null) @@ -934,9 +1020,38 @@ namespace YLErp.Modules.RiskEngine 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.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)}"); + } + DbContext.SaveChanges(); RiskEngineService.GetInstance().RefreshOneRuleCache(ruleId); + if (cascadedIds.Any()) + RiskEngineService.GetInstance().RefreshApplication(); + + return new DisableRuleResult { CascadedApplicationIds = cascadedIds }; } /// @@ -1062,6 +1177,210 @@ namespace YLErp.Modules.RiskEngine }; } + /// + /// 批量启用规则(仅 Disabled → Active,逐条编译校验) + /// + public BatchOperationResult BatchEnableRules(List ruleIds) + { + if (ruleIds == null || ruleIds.Count == 0) + throw new ServiceException("规则ID列表不能为空"); + + _logger.Info($"[批量启用规则] 开始 - 传入规则数: {ruleIds.Count}, IDs: {string.Join(",", ruleIds)}"); + + var rules = DbContext.glms_risk_rule + .Where(r => ruleIds.Contains(r.id) && r.Status != RiskRuleStatus.Deleted) + .ToList(); + + _logger.Info($"[批量启用规则] DB查询完成 - 匹配到 {rules.Count} 条规则"); + + var foundIds = rules.Select(r => (long)r.id).ToList(); + var missingIds = ruleIds.Except(foundIds).ToList(); + if (missingIds.Any()) + { + return new BatchOperationResult + { + Success = false, + TotalCount = ruleIds.Count, + SuccessCount = 0, + ErrorMessage = $"以下规则不存在或已删除:{string.Join(", ", missingIds)}" + }; + } + + // 跳过已启用的规则 + var skippedIds = rules.Where(r => r.Status == RiskRuleStatus.Active).Select(r => (long)r.id).ToList(); + if (skippedIds.Any()) + _logger.Info($"[批量启用规则] 跳过已启用规则 - IDs: {string.Join(",", skippedIds)}"); + + rules = rules.Where(r => r.Status != RiskRuleStatus.Active).ToList(); + if (!rules.Any()) + { + _logger.Info("[批量启用规则] 所有规则均已启用,无需操作"); + return new BatchOperationResult + { + Success = true, + TotalCount = ruleIds.Count, + SuccessCount = ruleIds.Count + }; + } + + // 逐条编译校验,任一失败则整体拒绝 + var errors = new List(); + foreach (var rule in rules) + { + var compileResult = RuleCompiler.ValidateAndCompileFormula(rule.id, rule.RuleExpr); + if (!compileResult.Success) + errors.Add($"规则'{rule.RuleName}'(ID:{rule.id}):{compileResult.ErrorMessage}"); + } + if (errors.Any()) + { + return new BatchOperationResult + { + Success = false, + TotalCount = ruleIds.Count, + SuccessCount = 0, + ErrorMessage = $"规则表达式编译失败:{string.Join(";", errors)}" + }; + } + + foreach (var rule in rules) + { + _logger.Info($"[批量启用规则] 写入状态 - 规则ID: {rule.id}"); + rule.Status = RiskRuleStatus.Active; + rule.UpdateOptId = UserId; + rule.UpdateOptName = UserName; + rule.UpdateDate = DateTime.Now; + } + + var enabledIds = rules.Select(r => r.id).ToList(); + WriteAuditLog("RULE_BATCH_ENABLE", "RULE", 0, "", + $"批量启用规则(IDs: {string.Join(",", enabledIds)})"); + + _logger.Info("[批量启用规则] 保存数据库..."); + DbContext.SaveChanges(); + _logger.Info("[批量启用规则] 刷新规则缓存..."); + foreach (var rule in rules) + { + RiskEngineService.GetInstance().RefreshOneRuleCache(rule.id); + } + _logger.Info("[批量启用规则] 完成"); + + return new BatchOperationResult + { + Success = true, + TotalCount = ruleIds.Count, + SuccessCount = ruleIds.Count + }; + } + + /// + /// 批量停用规则(仅 Active → Disabled,级联停用关联应用) + /// + public BatchDisableRulesResult BatchDisableRules(List ruleIds) + { + if (ruleIds == null || ruleIds.Count == 0) + throw new ServiceException("规则ID列表不能为空"); + + _logger.Info($"[批量停用规则] 开始 - 传入规则数: {ruleIds.Count}, IDs: {string.Join(",", ruleIds)}"); + + var rules = DbContext.glms_risk_rule + .Where(r => ruleIds.Contains(r.id) && r.Status != RiskRuleStatus.Deleted) + .ToList(); + + _logger.Info($"[批量停用规则] DB查询完成 - 匹配到 {rules.Count} 条规则"); + + var foundIds = rules.Select(r => (long)r.id).ToList(); + var missingIds = ruleIds.Except(foundIds).ToList(); + if (missingIds.Any()) + { + return new BatchDisableRulesResult + { + Success = false, + TotalCount = ruleIds.Count, + SuccessCount = 0, + ErrorMessage = $"以下规则不存在或已删除:{string.Join(", ", missingIds)}" + }; + } + + // 跳过已停用的规则 + var skippedIds = rules.Where(r => r.Status == RiskRuleStatus.Disabled).Select(r => (long)r.id).ToList(); + if (skippedIds.Any()) + _logger.Info($"[批量停用规则] 跳过已停用规则 - IDs: {string.Join(",", skippedIds)}"); + + rules = rules.Where(r => r.Status != RiskRuleStatus.Disabled).ToList(); + if (!rules.Any()) + { + _logger.Info("[批量停用规则] 所有规则均已停用,无需操作"); + return new BatchDisableRulesResult + { + Success = true, + TotalCount = ruleIds.Count, + SuccessCount = ruleIds.Count + }; + } + + var allCascadedAppIds = new List(); + foreach (var rule in rules) + { + _logger.Info($"[批量停用规则] 写入状态 - 规则ID: {rule.id}"); + rule.Status = RiskRuleStatus.Disabled; + rule.UpdateOptId = UserId; + rule.UpdateOptName = UserName; + rule.UpdateDate = DateTime.Now; + + // 级联停用关联的 Active 应用配置 + var activeApps = DbContext.glms_risk_rule_application + .Where(a => a.Status == RiskRuleStatus.Active) + .Where(RuleIdsMatchExpr(rule.id)) + .ToList(); + + var cascadedIds = new List(); + foreach (var app in activeApps) + { + // 同一应用可能同时关联多条待停用规则,只对实际仍为 Active 的应用处理一次。 + if (app.Status != RiskRuleStatus.Active) + continue; + + app.Status = RiskRuleStatus.Disabled; + 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:{rule.id})停用,应用配置被级联停用"); + } + + if (cascadedIds.Any()) + { + _logger.Info($"[批量停用规则] 规则ID: {rule.id}, 级联停用应用: {string.Join(",", cascadedIds)}"); + allCascadedAppIds.AddRange(cascadedIds); + } + } + + var disabledIds = rules.Select(r => r.id).ToList(); + WriteAuditLog("RULE_BATCH_DISABLE", "RULE", 0, "", + $"批量停用规则(IDs: {string.Join(",", disabledIds)})"); + + _logger.Info("[批量停用规则] 保存数据库..."); + DbContext.SaveChanges(); + _logger.Info("[批量停用规则] 刷新规则缓存..."); + foreach (var rule in rules) + { + RiskEngineService.GetInstance().RefreshOneRuleCache(rule.id); + } + if (allCascadedAppIds.Any()) + RiskEngineService.GetInstance().RefreshApplication(); + _logger.Info("[批量停用规则] 完成"); + + return new BatchDisableRulesResult + { + Success = true, + TotalCount = ruleIds.Count, + SuccessCount = ruleIds.Count, + CascadedApplicationIds = allCascadedAppIds + }; + } + /// /// 获取规则关联的应用配置列表 /// @@ -1070,6 +1389,7 @@ namespace YLErp.Modules.RiskEngine GetRuleOrThrow(ruleId); var apps = DbContext.glms_risk_rule_application + .Where(a => a.Status != RiskRuleStatus.Deleted) .Where(RuleIdsMatchExpr(ruleId)) .OrderByDescending(a => a.UpdateDate) .ToList(); @@ -1101,18 +1421,19 @@ namespace YLErp.Modules.RiskEngine } /// - /// 获取所有启用规则列表(轻量字段,供下拉选择) + /// 获取所有未删除规则列表(轻量字段,供下拉选择) /// public List GetAllRuleList() { return DbContext.glms_risk_rule - .Where(r => r.Status == RiskRuleStatus.Active) + .Where(r => r.Status != RiskRuleStatus.Deleted) .OrderBy(r => r.id) .Select(r => new RiskRuleSimpleItem { Id = r.id, RuleName = r.RuleName, - RuleText = r.RuleText + RuleText = r.RuleText, + Status = r.Status }) .ToList(); } @@ -1229,7 +1550,8 @@ namespace YLErp.Modules.RiskEngine /// public RiskApplicationDetail CreateApplication(CreateRiskApplicationReq req) { - var errors = ValidateAndEnsureRuleCache(req.RuleIds); + var normalizedRuleIds = NormalizeRuleIds(req.RuleIds); + var errors = ValidateAndEnsureRuleCache(normalizedRuleIds); if (errors.Any()) throw new ServiceException($"以下关联规则不可用:{string.Join(";", errors)}"); ValidateTriggerPoints(req.TriggerPoints); @@ -1237,7 +1559,7 @@ namespace YLErp.Modules.RiskEngine var entity = new glms_risk_rule_application { - RuleIds = req.RuleIds, + RuleIds = normalizedRuleIds, Description = req.Description, Status = RiskRuleStatus.Active, ControlStrategy = req.ControlStrategy, @@ -1262,7 +1584,7 @@ namespace YLErp.Modules.RiskEngine DbContext.glms_risk_rule_application.Add(entity); DbContext.SaveChanges(); - WriteAuditLog("APP_CREATE", "APPLICATION", entity.id, ResolveRuleNames(req.RuleIds), + WriteAuditLog("APP_CREATE", "APPLICATION", entity.id, ResolveRuleNames(entity.RuleIds), $"创建应用配置:策略={GetStrategyName(req.ControlStrategy)}, 触发时点={MapTriggerPointsToChinese(req.TriggerPoints)}", snapshotData: JsonConvert.SerializeObject(new { Version = entity.Version })); DbContext.SaveChanges(); @@ -1291,9 +1613,11 @@ namespace YLErp.Modules.RiskEngine if (app.Version != req.Version) throw new ServiceException("应用配置已被其他用户修改,请重新加载后再编辑"); + string normalizedRuleIds = null; if (!string.IsNullOrWhiteSpace(req.RuleIds)) { - var errors = ValidateAndEnsureRuleCache(req.RuleIds); + normalizedRuleIds = NormalizeRuleIds(req.RuleIds); + var errors = ValidateAndEnsureRuleCache(normalizedRuleIds); if (errors.Any()) throw new ServiceException($"以下关联规则不可用:{string.Join(";", errors)}"); } @@ -1309,8 +1633,8 @@ namespace YLErp.Modules.RiskEngine }; ValidateScopeFields(scopeReq); - if (!string.IsNullOrWhiteSpace(req.RuleIds)) - app.RuleIds = req.RuleIds; + if (normalizedRuleIds != null) + app.RuleIds = normalizedRuleIds; app.Description = req.Description; app.ControlStrategy = req.ControlStrategy; app.TriggerPoints = req.TriggerPoints; @@ -1352,6 +1676,63 @@ namespace YLErp.Modules.RiskEngine RiskEngineService.GetInstance().RefreshApplication(); } + /// + /// 批量删除应用配置(软删除) + /// + public BatchOperationResult BatchDeleteApplications(List applicationIds) + { + if (applicationIds == null || applicationIds.Count == 0) + throw new ServiceException("应用配置ID列表不能为空"); + + _logger.Info($"[批量删除应用] 开始 - 传入应用数: {applicationIds.Count}, IDs: {string.Join(",", applicationIds)}"); + + var apps = DbContext.glms_risk_rule_application + .Where(a => applicationIds.Contains(a.id) && a.Status != RiskRuleStatus.Deleted) + .ToList(); + + _logger.Info($"[批量删除应用] DB查询完成 - 匹配到 {apps.Count} 条应用"); + + var foundIds = apps.Select(a => (long)a.id).ToList(); + var missingIds = applicationIds.Except(foundIds).ToList(); + if (missingIds.Any()) + { + return new BatchOperationResult + { + Success = false, + TotalCount = applicationIds.Count, + SuccessCount = 0, + ErrorMessage = $"以下应用配置不存在或已删除:{string.Join(", ", missingIds)}" + }; + } + + foreach (var app in apps) + { + _logger.Info($"[批量删除应用] 写入状态 - 应用ID: {app.id}"); + app.Status = RiskRuleStatus.Deleted; + app.Version = app.Version + 1; + app.UpdateOptId = UserId; + app.UpdateOptName = UserName; + app.UpdateDate = DateTime.Now; + } + + var deletedIds = apps.Select(a => a.id).ToList(); + WriteAuditLog("APP_BATCH_DELETE", "APPLICATION", 0, "", + $"批量删除应用配置(IDs: {string.Join(",", deletedIds)})"); + + _logger.Info("[批量删除应用] 保存数据库..."); + DbContext.SaveChanges(); + _logger.Info("[批量删除应用] 刷新应用缓存..."); + RiskEngineService.GetInstance().RefreshApplication(); + _logger.Info("[批量删除应用] 完成"); + + return new BatchOperationResult + { + Success = true, + TotalCount = applicationIds.Count, + SuccessCount = applicationIds.Count + }; + } + /// /// 启用应用配置(校验关联规则状态) /// @@ -1449,7 +1830,7 @@ namespace YLErp.Modules.RiskEngine var errors = ValidateAndEnsureRuleCache(app.RuleIds); _logger.Info($"[批量启用] 规则校验结果 - 应用ID: {app.id}, Errors: {(errors.Any() ? string.Join(";", errors) : "无")}"); if (errors.Any()) - allErrors.Add($"应用 '{app.Description}':{string.Join(";", errors)}"); + allErrors.Add($"应用 '{app.id}':{string.Join(";", errors)}"); } if (allErrors.Any()) @@ -1762,16 +2143,15 @@ namespace YLErp.Modules.RiskEngine rule.UpdateOptId = UserId; rule.UpdateOptName = UserName; rule.UpdateDate = DateTime.Now; + + WriteAuditLog("VAR_CASCADE_UPDATE_RULE", "RULE", rule.id, rule.RuleName, + $"因变量'{variable.VariableName}'(ID:{variableId})表达式变更,规则表达式被级联更新"); } DbContext.SaveChanges(); WriteAuditLog("VAR_UPDATE", "VARIABLE", variableId, req.VariableName, $"修改变量:{variable.VariableName}"); - var cascadedRuleIds = affectedRules.Select(r => r.id.ToString()).ToList(); - WriteAuditLog("VAR_UPDATE_CASCADE", "VARIABLE", variableId, req.VariableName, - $"变量 [{variable.VariableName}] VariableExpr 变更,级联更新规则 {string.Join("、", cascadedRuleIds)}"); - DbContext.SaveChanges(); transaction.Commit(); } @@ -1848,6 +2228,11 @@ namespace YLErp.Modules.RiskEngine /// 查询审计日志列表(分页) /// public SearchListResult QueryAuditLogs(QueryRiskAuditLogReq req) + { + return BuildAuditLogQuery(req).ToSearchList(req); + } + + private IQueryable BuildAuditLogQuery(QueryRiskAuditLogReq req) { var query = DbContext.glms_risk_rule_audit_log.AsQueryable(); @@ -1908,7 +2293,55 @@ namespace YLErp.Modules.RiskEngine OptDate = l.OptDate }); - return result.ToSearchList(req); + return result; + } + + /// + /// 根据查询条件生成审计日志导出文件名 + /// + public string BuildAuditLogExportFileName(QueryRiskAuditLogReq req) + { + req ??= new QueryRiskAuditLogReq(); + var conditions = new List(); + + var operationTypes = new List(); + if (!string.IsNullOrWhiteSpace(req.OperationType)) + operationTypes.Add(req.OperationType.Trim()); + if (!string.IsNullOrWhiteSpace(req.OperationTypes)) + { + operationTypes.AddRange(req.OperationTypes.Split(',', + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); + } + var operationTypeNames = operationTypes + .Distinct(StringComparer.OrdinalIgnoreCase) + .Select(GetAuditOperationTypeName) + .ToList(); + if (operationTypeNames.Any()) + conditions.Add($"操作类型-{string.Join("+", operationTypeNames)}"); + + if (!string.IsNullOrWhiteSpace(req.TargetType)) + conditions.Add($"目标类型-{GetAuditTargetTypeName(req.TargetType)}"); + if (!string.IsNullOrWhiteSpace(req.TargetName)) + conditions.Add($"目标名称-{SanitizeFileNamePart(req.TargetName)}"); + if (!string.IsNullOrWhiteSpace(req.OptName)) + conditions.Add($"操作人-{SanitizeFileNamePart(req.OptName)}"); + + if (req.StartDate.HasValue && req.EndDate.HasValue) + conditions.Add($"日期-{req.StartDate.Value:yyyyMMdd}至{req.EndDate.Value:yyyyMMdd}"); + else if (req.StartDate.HasValue) + conditions.Add($"开始日期-{req.StartDate.Value:yyyyMMdd}"); + else if (req.EndDate.HasValue) + conditions.Add($"结束日期-{req.EndDate.Value:yyyyMMdd}"); + + if (!string.IsNullOrWhiteSpace(req.Keyword)) + conditions.Add($"关键词-{SanitizeFileNamePart(req.Keyword)}"); + + var suffix = conditions.Any() ? string.Join("_", conditions) : "全部"; + var fileNameWithoutExtension = $"风控操作日志_{suffix}"; + if (fileNameWithoutExtension.Length > 180) + fileNameWithoutExtension = fileNameWithoutExtension.Substring(0, 180); + + return $"{fileNameWithoutExtension}.xlsx"; } /// @@ -1916,7 +2349,28 @@ namespace YLErp.Modules.RiskEngine /// public byte[] ExportAuditLogs(QueryRiskAuditLogReq req) { - throw new ServiceException("导出功能暂未实现"); + var logs = BuildAuditLogQuery(req).ToList(); + var columns = new List + { + new ExcelHelper.DataColumnModel("日志ID", "Id", typeof(string), + (value, _) => FormatAuditId(value)), + new ExcelHelper.DataColumnModel("操作类型", "OperationType", typeof(string), + (value, _) => GetAuditOperationTypeName(value?.ToString())), + new ExcelHelper.DataColumnModel("目标类型", "TargetType", typeof(string), + (value, _) => GetAuditTargetTypeName(value?.ToString())), + new ExcelHelper.DataColumnModel("目标ID", "TargetId", typeof(string), + (value, _) => FormatAuditId(value)), + new ExcelHelper.DataColumnModel("目标名称", "TargetName", typeof(string)), + new ExcelHelper.DataColumnModel("操作详情", "OperationDetail", typeof(string)), + new ExcelHelper.DataColumnModel("操作结果", "Result", typeof(string)), + new ExcelHelper.DataColumnModel("操作人", "OptName", typeof(string)), + new ExcelHelper.DataColumnModel("操作时间", "OptDate", (value, _) => + value == null ? string.Empty : ((DateTime)value).ToString("yyyy-MM-dd HH:mm:ss")) + }; + + new ExcelHelper().ListToExcel( + columns.ToArray(), logs, "风控操作日志", true, out var buffer); + return buffer; } /// diff --git a/YLErpWeb/Controllers/RiskRuleController.cs b/YLErpWeb/Controllers/RiskRuleController.cs index f46ed8b7..028c0fba 100644 --- a/YLErpWeb/Controllers/RiskRuleController.cs +++ b/YLErpWeb/Controllers/RiskRuleController.cs @@ -1,4 +1,4 @@ -using Qdp.Foundation.Utilities; +using Qdp.Foundation.Utilities; using System.Threading.Tasks; using YLErp.DBModels; using YLErp.Modules.RiskEngine; @@ -202,8 +202,13 @@ namespace YLErp.Web.Controllers try { var service = GetRiskRuleService(); - await Task.Run(() => service.DisableRule(id)); - return Json(new { success = true }); + var result = await Task.Run(() => service.DisableRule(id)); + return Json(new + { + success = true, + cascadedAppIds = result.CascadedApplicationIds, + cascadedAppCount = result.CascadedApplicationIds.Count + }); } catch (ServiceException ex) { @@ -216,6 +221,60 @@ namespace YLErp.Web.Controllers } } + [HttpPost("risk-rules/batch-enable")] + [MyAuthorize("风险控制-风控规则启停")] + /// 批量启用风控规则 + public async Task BatchEnableRiskRules([FromBody] BatchIdsReq req) + { + try + { + var service = GetRiskRuleService(); + var result = await Task.Run(() => service.BatchEnableRules(req?.Ids)); + if (!result.Success) + return Json(new { success = false, message = result.ErrorMessage }); + return Json(new { success = true, data = result }); + } + catch (ServiceException ex) + { + return Json(new { success = false, message = ex.Message }); + } + catch (Exception ex) + { + _logger.Error(ex, "批量启用风控规则"); + return Json(new { success = false, message = "系统异常,请联系管理员" }); + } + } + + [HttpPost("risk-rules/batch-disable")] + [MyAuthorize("风险控制-风控规则启停")] + /// 批量停用风控规则 + public async Task BatchDisableRiskRules([FromBody] BatchIdsReq req) + { + try + { + var service = GetRiskRuleService(); + var result = await Task.Run(() => service.BatchDisableRules(req?.Ids)); + if (!result.Success) + return Json(new { success = false, message = result.ErrorMessage }); + return Json(new + { + success = true, + data = result, + cascadedAppIds = result.CascadedApplicationIds, + cascadedAppCount = result.CascadedApplicationIds.Count + }); + } + catch (ServiceException ex) + { + return Json(new { success = false, message = ex.Message }); + } + catch (Exception ex) + { + _logger.Error(ex, "批量停用风控规则"); + return Json(new { success = false, message = "系统异常,请联系管理员" }); + } + } + [HttpGet("risk-rules/{id}/applications")] [MyAuthorize("风险控制-风控规则查看")] /// 获取规则关联的应用配置列表 @@ -240,7 +299,7 @@ namespace YLErp.Web.Controllers [HttpGet("risk-rules/list")] [MyAuthorize("风险控制-风控规则查看")] - /// 获取所有启用规则列表(供下拉选择) + /// 获取所有未删除规则列表(供下拉选择) public async Task GetAllRiskRules() { try @@ -466,6 +525,30 @@ namespace YLErp.Web.Controllers } } + [HttpDelete("risk-applications/batch")] + [MyAuthorize("风险控制-风控应用删除")] + /// 批量删除应用配置 + public async Task BatchDeleteRiskApplications([FromBody] BatchIdsReq req) + { + try + { + var service = GetRiskRuleService(); + var result = await Task.Run(() => service.BatchDeleteApplications(req?.Ids)); + if (!result.Success) + return Json(new { success = false, message = result.ErrorMessage }); + return Json(new { success = true, data = result }); + } + catch (ServiceException ex) + { + return Json(new { success = false, message = ex.Message }); + } + catch (Exception ex) + { + _logger.Error(ex, "批量删除应用配置"); + return Json(new { success = false, message = "系统异常,请联系管理员" }); + } + } + #endregion #region Variable Management @@ -689,7 +772,8 @@ namespace YLErp.Web.Controllers { var service = GetRiskRuleService(); var bytes = await Task.Run(() => service.ExportAuditLogs(req)); - return File(bytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "风控操作日志.xlsx"); + var fileName = service.BuildAuditLogExportFileName(req); + return File(bytes, xlsxMimeType, fileName); } catch (ServiceException ex) {