From 19a7b34e84c8041ca14f17e44cf73d912dc009ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=B9=E5=B3=B0?= Date: Thu, 16 Jul 2026 12:32:17 +0800 Subject: [PATCH 1/6] =?UTF-8?q?bugfix:=E8=A7=84=E5=88=99=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E5=90=AF=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Modules/RiskEngine/Dto/BatchIdsReq.cs | 10 ++ .../Modules/RiskEngine/RiskRuleService.cs | 148 +++++++++++++++--- YLErpWeb/Controllers/RiskRuleController.cs | 20 ++- 3 files changed, 148 insertions(+), 30 deletions(-) create mode 100644 YLErpDAL/Modules/RiskEngine/Dto/BatchIdsReq.cs diff --git a/YLErpDAL/Modules/RiskEngine/Dto/BatchIdsReq.cs b/YLErpDAL/Modules/RiskEngine/Dto/BatchIdsReq.cs new file mode 100644 index 00000000..58650c8f --- /dev/null +++ b/YLErpDAL/Modules/RiskEngine/Dto/BatchIdsReq.cs @@ -0,0 +1,10 @@ +namespace YLErp.Modules.RiskEngine.Dto +{ + /// + /// 批量操作请求(兼容前端 {ids: [...]} 格式) + /// + public class BatchIdsReq + { + public List Ids { get; set; } + } +} diff --git a/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs b/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs index be59ce93..b927712d 100644 --- a/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs +++ b/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs @@ -540,6 +540,19 @@ namespace YLErp.Modules.RiskEngine return ids; } + /// + /// EF Core 可翻译的逗号分隔字段精确匹配表达式 + /// + private static Expression> RuleIdsMatchExpr(long ruleId) + { + var idStr = ruleId.ToString(); + return a => a.RuleIds != null && + (a.RuleIds == idStr || + a.RuleIds.StartsWith(idStr + ",") || + a.RuleIds.EndsWith("," + idStr) || + a.RuleIds.Contains("," + idStr + ",")); + } + /// /// 校验规则状态可用性并确保编译缓存就绪。 /// 返回不可用规则信息列表,列表为空表示全部通过。 @@ -722,7 +735,12 @@ namespace YLErp.Modules.RiskEngine OptDate = r.OptDate.GetValueOrDefault(), UpdateOptName = r.UpdateOptName, UpdateDate = r.UpdateDate ?? r.OptDate.GetValueOrDefault(), - ApplicationCount = DbContext.glms_risk_rule_application.Count(a => a.RuleIds.Contains(r.id.ToString()) && a.Status != RiskRuleStatus.Deleted) + ApplicationCount = DbContext.glms_risk_rule_application + .Count(a => a.RuleIds != null && + (a.RuleIds == r.id.ToString() || + a.RuleIds.StartsWith(r.id.ToString() + ",") || + a.RuleIds.EndsWith("," + r.id.ToString()) || + a.RuleIds.Contains("," + r.id.ToString() + ","))) }); return result.ToSearchList(req); @@ -735,9 +753,9 @@ namespace YLErp.Modules.RiskEngine { var rule = GetRuleOrThrow(ruleId); - var ruleIdStr = ruleId.ToString(); + var applications = DbContext.glms_risk_rule_application - .Where(a => a.RuleIds.Contains(ruleIdStr) && a.Status != RiskRuleStatus.Deleted) + .Where(RuleIdsMatchExpr(ruleId)) .Select(a => new RiskRuleApplicationSummary { Id = a.id, @@ -858,9 +876,10 @@ namespace YLErp.Modules.RiskEngine { var rule = GetRuleOrThrow(ruleId); - var ruleIdStr = ruleId.ToString(); + var activeCount = DbContext.glms_risk_rule_application - .Count(a => a.RuleIds.Contains(ruleIdStr) && a.Status == RiskRuleStatus.Active); + .Where(a => a.Status == RiskRuleStatus.Active) + .Count(RuleIdsMatchExpr(ruleId)); if (activeCount > 0) throw new ServiceException($"该规则仍有 {activeCount} 个生效中的应用配置,请先停用或删除相关应用"); @@ -965,24 +984,58 @@ namespace YLErp.Modules.RiskEngine /// public BatchOperationResult BatchDeleteRules(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(); - if (rules.Count != ruleIds.Count) - throw new ServiceException("部分规则不存在或已删除"); + _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 blockedRules = new List(); foreach (var rule in rules) { - var ruleIdStr = rule.id.ToString(); - var activeCount = DbContext.glms_risk_rule_application - .Count(a => a.RuleIds.Contains(ruleIdStr) && a.Status == RiskRuleStatus.Active); + _logger.Info($"[批量删除] 检查关联应用 - 规则ID: {rule.id}"); + var activeApps = DbContext.glms_risk_rule_application + .Where(a => a.Status == RiskRuleStatus.Active) + .Where(RuleIdsMatchExpr(rule.id)) + .Select(a => (long)a.id) + .ToList(); + var activeCount = activeApps.Count; + _logger.Info($"[批量删除] 关联应用检查结果 - 规则ID: {rule.id}, ActiveCount: {activeCount}, AppIds: [{string.Join(",", activeApps)}]"); if (activeCount > 0) - throw new ServiceException($"规则 '{rule.RuleName}' 仍有 {activeCount} 个生效中的应用配置,请先停用或删除相关应用"); + blockedRules.Add($"规则 '{rule.RuleName}'(ID:{rule.id}) 仍有 {activeCount} 个生效中的应用配置(IDs: {string.Join(",", activeApps)})"); + } + if (blockedRules.Any()) + { + return new BatchOperationResult + { + Success = false, + TotalCount = ruleIds.Count, + SuccessCount = 0, + ErrorMessage = string.Join(";", blockedRules) + }; } foreach (var rule in rules) { + _logger.Info($"[批量删除] 写入状态 - 规则ID: {rule.id}"); rule.Status = RiskRuleStatus.Deleted; rule.Version = rule.Version + 1; rule.UpdateOptId = UserId; @@ -992,11 +1045,14 @@ namespace YLErp.Modules.RiskEngine WriteAuditLog("RULE_BATCH_DELETE", "RULE", rule.id, rule.RuleName, "批量删除规则"); } + _logger.Info("[批量删除] 保存数据库..."); DbContext.SaveChanges(); + _logger.Info("[批量删除] 刷新规则缓存..."); foreach (var ruleId in ruleIds) { RiskEngineService.GetInstance().RefreshOneRuleCache(ruleId); } + _logger.Info("[批量删除] 完成"); return new BatchOperationResult { @@ -1013,9 +1069,8 @@ namespace YLErp.Modules.RiskEngine { GetRuleOrThrow(ruleId); - var ruleIdStr = ruleId.ToString(); var apps = DbContext.glms_risk_rule_application - .Where(a => a.RuleIds.Contains(ruleIdStr) && a.Status != RiskRuleStatus.Deleted) + .Where(RuleIdsMatchExpr(ruleId)) .OrderByDescending(a => a.UpdateDate) .ToList(); @@ -1093,8 +1148,12 @@ namespace YLErp.Modules.RiskEngine { query = query.Where(a => DbContext.glms_risk_rule .Any(r => r.Status != RiskRuleStatus.Deleted - && a.RuleIds.Contains(r.id.ToString()) - && r.RuleName.Contains(req.RuleName))); + && r.RuleName.Contains(req.RuleName) + && a.RuleIds != null + && (a.RuleIds == r.id.ToString() + || a.RuleIds.StartsWith(r.id.ToString() + ",") + || a.RuleIds.EndsWith("," + r.id.ToString()) + || a.RuleIds.Contains("," + r.id.ToString() + ",")))); } if (!string.IsNullOrWhiteSpace(req.ScopeClientIds)) @@ -1342,16 +1401,46 @@ namespace YLErp.Modules.RiskEngine /// public BatchOperationResult BatchEnableApplications(List applicationIds) { - _logger.Info($"[批量启用] 开始 - 传入应用数: {applicationIds?.Count ?? 0}, IDs: {string.Join(",", applicationIds ?? new List())}"); + 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.Disabled) + .Where(a => applicationIds.Contains(a.id)) .ToList(); - _logger.Info($"[批量启用] DB查询完成 - 匹配到 {apps.Count} 条已停用应用"); + _logger.Info($"[批量启用] DB查询完成 - 匹配到 {apps.Count} 条应用"); - if (apps.Count != applicationIds.Count) - throw new ServiceException("部分应用配置不存在或当前状态不允许启用"); + 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)}" + }; + } + + // 跳过已启用的应用,只处理已停用的 + var skippedIds = apps.Where(a => a.Status == RiskRuleStatus.Active).Select(a => (long)a.id).ToList(); + if (skippedIds.Any()) + _logger.Info($"[批量启用] 跳过已启用应用 - IDs: {string.Join(",", skippedIds)}"); + + apps = apps.Where(a => a.Status != RiskRuleStatus.Active).ToList(); + if (!apps.Any()) + { + _logger.Info("[批量启用] 所有应用均已启用,无需操作"); + return new BatchOperationResult + { + Success = true, + TotalCount = applicationIds.Count, + SuccessCount = applicationIds.Count + }; + } var allErrors = new List(); foreach (var app in apps) @@ -1404,7 +1493,10 @@ namespace YLErp.Modules.RiskEngine /// public BatchOperationResult BatchDisableApplications(List applicationIds) { - _logger.Info($"[批量停用] 开始 - 传入应用数: {applicationIds?.Count ?? 0}, IDs: {string.Join(",", applicationIds ?? new List())}"); + 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.Active) @@ -1412,8 +1504,18 @@ namespace YLErp.Modules.RiskEngine _logger.Info($"[批量停用] DB查询完成 - 匹配到 {apps.Count} 条已启用应用"); - if (apps.Count != applicationIds.Count) - throw new ServiceException("部分应用配置不存在或当前状态不允许停用"); + 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 = apps.Count, + ErrorMessage = $"以下应用配置不存在或当前状态不允许停用:{string.Join(", ", missingIds)}" + }; + } foreach (var app in apps) { diff --git a/YLErpWeb/Controllers/RiskRuleController.cs b/YLErpWeb/Controllers/RiskRuleController.cs index b30a905d..f46ed8b7 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; @@ -151,12 +151,14 @@ namespace YLErp.Web.Controllers [HttpDelete("risk-rules/batch")] [MyAuthorize("风险控制-风控规则删除")] /// 批量删除风控规则 - public async Task BatchDeleteRiskRules([FromBody] List ids) + public async Task BatchDeleteRiskRules([FromBody] BatchIdsReq req) { try { var service = GetRiskRuleService(); - var result = await Task.Run(() => service.BatchDeleteRules(ids)); + var result = await Task.Run(() => service.BatchDeleteRules(req?.Ids)); + if (!result.Success) + return Json(new { success = false, message = result.ErrorMessage }); return Json(new { success = true, data = result }); } catch (ServiceException ex) @@ -419,12 +421,14 @@ namespace YLErp.Web.Controllers [HttpPost("risk-applications/batch-enable")] [MyAuthorize("风险控制-风控应用启停")] /// 批量启用应用配置 - public async Task BatchEnableRiskApplications([FromBody] List ids) + public async Task BatchEnableRiskApplications([FromBody] BatchIdsReq req) { try { var service = GetRiskRuleService(); - var result = await Task.Run(() => service.BatchEnableApplications(ids)); + var result = await Task.Run(() => service.BatchEnableApplications(req?.Ids)); + if (!result.Success) + return Json(new { success = false, message = result.ErrorMessage }); return Json(new { success = true, data = result }); } catch (ServiceException ex) @@ -441,12 +445,14 @@ namespace YLErp.Web.Controllers [HttpPost("risk-applications/batch-disable")] [MyAuthorize("风险控制-风控应用启停")] /// 批量停用应用配置 - public async Task BatchDisableRiskApplications([FromBody] List ids) + public async Task BatchDisableRiskApplications([FromBody] BatchIdsReq req) { try { var service = GetRiskRuleService(); - var result = await Task.Run(() => service.BatchDisableApplications(ids)); + var result = await Task.Run(() => service.BatchDisableApplications(req?.Ids)); + if (!result.Success) + return Json(new { success = false, message = result.ErrorMessage }); return Json(new { success = true, data = result }); } catch (ServiceException ex) 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 2/6] =?UTF-8?q?=E6=89=B9=E9=87=8F=E5=90=AF=E5=81=9C?= =?UTF-8?q?=E8=A7=84=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) { From 43679393bb32e57830c4db728bcd4c44b47436c9 Mon Sep 17 00:00:00 2001 From: hjhan Date: Fri, 17 Jul 2026 08:23:50 +0800 Subject: [PATCH 3/6] =?UTF-8?q?test(swap):=20=E8=A1=A5=20e2fb456b=20InitUn?= =?UTF-8?q?wind=20=E9=BB=98=E8=AE=A4=20ClosePercent=20=E5=8D=95=E5=85=83?= =?UTF-8?q?=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 抽 CalcDefaultInitClosePercent 静态纯函数(PosiNotionalValue/NotionalValue), InitUnwind L270 改为调用此函数, 新增 6 个单元测试覆盖: 未平仓/已平30%/除零保护/GLMS-20260701-0013 真实快照/与 ToRemainingClosePercent 联动自洽不变式/全平完边界场景. 填补 e2fb456b 提交的测试覆盖缺口. --- .../InitUnwindDefaultClosePercentTest.cs | 103 ++++++++++++++++++ .../Modules/SwapModule/SwapDealService.cs | 16 ++- 2 files changed, 116 insertions(+), 3 deletions(-) create mode 100644 UnitTestProject/Modules/SwapModule/InitUnwindDefaultClosePercentTest.cs diff --git a/UnitTestProject/Modules/SwapModule/InitUnwindDefaultClosePercentTest.cs b/UnitTestProject/Modules/SwapModule/InitUnwindDefaultClosePercentTest.cs new file mode 100644 index 00000000..dfc268b9 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/InitUnwindDefaultClosePercentTest.cs @@ -0,0 +1,103 @@ +namespace YLErp.Modules.SwapModule +{ + /// + /// InitUnwind 默认 ClosePercent 计算的回归测试。 + /// --------------------------------------------------------------- + /// 守卫提交 e2fb456b "fix 平仓(InitUnwind L267)硬编码 1 而不是剩余平仓比例"。 + /// + /// 旧 bug:InitUnwind 默认把 ClosePercent 硬编码为 1(按"占剩余 100%"), + /// 但前端约定 ClosePercent 是"占期初(original)"口径(A),1 表示平掉原始本金的 100%。 + /// 多次部分平仓后剩余本金 < 期初本金,此时默认 1 在前端语义上意味着"还要平掉原始全部", + /// 与"平掉剩余全部"意图不符,且会触发后端 ToRemainingClosePercent 转换后 >1 被 cap 到 1, + /// 表面看无差异但语义混乱,且若前端 / 事件展示直接用此值会出错。 + /// + /// 修复:ClosePercent = PosiNotionalValue / NotionalValue(占期初口径的"平剩余全部")。 + /// 抽出为纯函数 CalcDefaultInitClosePercent 以支持无库单测。 + /// + [TestClass] + public class InitUnwindDefaultClosePercentTest + { + // ================================================================ + // 场景1:未平仓 PosiNotionalValue == NotionalValue → ClosePercent = 1 + // ================================================================ + [TestMethod] + public void 未平仓_剩余等于期初_默认ClosePercent为1() + { + var result = SwapDealService.CalcDefaultInitClosePercent( + notionalValue: 1_000_000m, posiNotionalValue: 1_000_000m); + + Assert.AreEqual(1m, result, "未平仓:默认应平 100%(占期初)"); + } + + // ================================================================ + // 场景2:已平 30%(剩 70%)→ ClosePercent = 0.7 + // ================================================================ + [TestMethod] + public void 已平30_剩70_默认ClosePercent为0_7() + { + var result = SwapDealService.CalcDefaultInitClosePercent( + notionalValue: 1_000_000m, posiNotionalValue: 700_000m); + + Assert.AreEqual(0.7m, result, 0.0001m, "已平 30% 剩 70%:默认 ClosePercent=0.7(占期初)"); + } + + // ================================================================ + // 场景3:除零保护 NotionalValue = 0 → 返回 1(容错) + // ================================================================ + [TestMethod] + public void 期初名义本金为零_返回1_容错不除零() + { + var result = SwapDealService.CalcDefaultInitClosePercent( + notionalValue: 0m, posiNotionalValue: 100_000m); + + Assert.AreEqual(1m, result, "期初本金为 0 时容错返回 1,不应抛除零异常"); + } + + // ================================================================ + // 场景4:GLMS-20260701-0013 真实快照(已平 2 次) + // 期初 NotionalValue = 980,000 / 剩余 PosiNotionalValue = 686,000.07 + // 期望 ClosePercent ≈ 0.7(686000.07/980000) + // ================================================================ + [TestMethod] + public void GLMS20260701_0013_已平两次_默认ClosePercent约为0_7() + { + var result = SwapDealService.CalcDefaultInitClosePercent( + notionalValue: 980_000m, posiNotionalValue: 686_000.07m); + + // 686000.07 / 980000 = 0.700000071... + Assert.AreEqual(0.7m, result, 0.0001m, + "GLMS-20260701-0013 已平两次:默认 ClosePercent 应≈0.7(占期初),旧 bug 会硬编码 1"); + } + + // ================================================================ + // 场景5:与 ToRemainingClosePercent 联动验证 + // 前端拿 InitUnwind 返回的 A(占期初) 默认值,经 ToRemainingClosePercent 转 B(占剩余), + // 应恰好 = 1.0(因为"平剩余全部"在占剩余语义下就是 100%) + // ================================================================ + [TestMethod] + public void 默认A经ToRemainingClosePercent转B应为1_平剩余全部() + { + const decimal notionalValue = 1_000_000m; + const decimal posiNotionalValue = 600_000m; // 已平 40%,剩 60% + + var defaultA = SwapDealService.CalcDefaultInitClosePercent(notionalValue, posiNotionalValue); + var convertedB = SwapDealService.ToRemainingClosePercent(defaultA, notionalValue, posiNotionalValue); + + Assert.AreEqual(0.6m, defaultA, 0.0001m, "占期初默认 A=0.6"); + Assert.AreEqual(1.0m, convertedB, 0.0001m, + "A=0.6 经 ToRemainingClosePercent 转换 → B=1.0(占剩余 100% = 平剩余全部),此为占期初/占剩余双语义自洽的关键不变式"); + } + + // ================================================================ + // 场景6:全平完(PosiNotionalValue=0)→ ClosePercent=0(边界,实际不会进 InitUnwind) + // ================================================================ + [TestMethod] + public void 全平完剩余为零_ClosePercent为零() + { + var result = SwapDealService.CalcDefaultInitClosePercent( + notionalValue: 1_000_000m, posiNotionalValue: 0m); + + Assert.AreEqual(0m, result, "剩余本金为 0 时 ClosePercent=0(边界场景,实际全部平完不会再进 InitUnwind)"); + } + } +} diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs index f27ec368..75f98fbd 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -267,9 +267,7 @@ namespace YLErp.Modules.SwapModule // 占期初(original)语义(A):默认"平掉剩余全部持仓" = 剩余名义本金/期初名义本金。 // 未平仓时 PosiNotionalValue==NotionalValue → 1(平100%);部分平仓后自动变为剩余比例(如已平10%则默认90%)。 // 与互换/提前终止 InitIncome(L447) 保持一致。 - unwindData.ClosePercent = unwindData.NotionalValue > 0 - ? unwindData.PosiNotionalValue / unwindData.NotionalValue - : 1; + unwindData.ClosePercent = CalcDefaultInitClosePercent(unwindData.NotionalValue, unwindData.PosiNotionalValue); unwindData.CloseNotionalValue = unwindData.PosiNotionalValue; unwindData.CloseQty = unwindData.PositionQty; if (position != null) @@ -755,6 +753,18 @@ namespace YLErp.Modules.SwapModule return remainingClosePercent * posiNotionalValue / notionalValue; } + /// + /// 计算 InitUnwind 默认占期初(A)平仓比例 = "平掉剩余全部持仓"对应的占期初比例。 + /// 即:ClosePercent(A) = PosiNotionalValue / NotionalValue。 + /// 未平仓时 PosiNotionalValue==NotionalValue → 1(平100%); + /// 部分平仓后自动变为剩余比例(如已平 30% 则默认 0.7)。 + /// 与互换/提前终止 InitIncome 保持一致。抽出为纯函数以支持无库单测。 + /// + public static decimal CalcDefaultInitClosePercent(decimal notionalValue, decimal posiNotionalValue) + { + return notionalValue > 0 ? posiNotionalValue / notionalValue : 1; + } + /// /// 获取固定利率 /// From 96a64ae82d5e4bd9a8e71a226a87ff822addcc1f Mon Sep 17 00:00:00 2001 From: hjhan Date: Fri, 17 Jul 2026 08:24:00 +0800 Subject: [PATCH 4/6] =?UTF-8?q?test(swap):=20=E4=BF=AE=E5=A4=8D=20GLMS2026?= =?UTF-8?q?0701DbDiagnoseTest=20=E7=BC=96=E8=AF=91=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E5=B9=B6=E5=8A=A0=202.3c=20=E6=A8=A1=E6=8B=9F=20API=20?= =?UTF-8?q?=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L208-209 Convert.ToDecimal 修复 double? decimal 隐式转换错误(CS0266)和非 nullable double ?? 0 错误(CS0019). 新增 2.3b 直接调用 ResolveInterestLegPositions 验证 Clone 行为, 2.3c 模拟 controller 完整流程(ToRemainingClosePercent + GetUnwindInterests) 等价于 HTTP API. 该测试带 [TestCategory(DbDiagnose)] 标记不进 CI, 作为可重复手动诊断工具保留. --- .../SwapModule/GLMS20260701DbDiagnoseTest.cs | 65 ++++++++++++++++--- 1 file changed, 56 insertions(+), 9 deletions(-) diff --git a/UnitTestProject/Modules/SwapModule/GLMS20260701DbDiagnoseTest.cs b/UnitTestProject/Modules/SwapModule/GLMS20260701DbDiagnoseTest.cs index 3bbea3ad..ad2b5f12 100644 --- a/UnitTestProject/Modules/SwapModule/GLMS20260701DbDiagnoseTest.cs +++ b/UnitTestProject/Modules/SwapModule/GLMS20260701DbDiagnoseTest.cs @@ -18,7 +18,8 @@ namespace YLErp.Modules.SwapModule [TestClass] public class GLMS20260701DbDiagnoseTest { - private const string TradeNumber = "GLMS-20260701-0008"; + private const string TradeNumber_0008 = "GLMS-20260701-0008"; + private const string TradeNumber_0013 = "GLMS-20260701-0013"; #region 1) 录真实数据快照(手动跑) @@ -31,8 +32,8 @@ namespace YLErp.Modules.SwapModule try { db = DbContextFactory.GetYLDbContext(); } catch (Exception ex) { Assert.Inconclusive($"无法连接测试库:{ex.Message}"); return; } - var td = db.trade.FirstOrDefault(t => t.TradeNumber == TradeNumber); - Assert.IsNotNull(td, $"测试库无交易 {TradeNumber},请确认环境"); + var td = db.trade.FirstOrDefault(t => t.TradeNumber == TradeNumber_0008); + Assert.IsNotNull(td, $"测试库无交易 {TradeNumber_0008},请确认环境"); var snapshot = new JObject { @@ -99,13 +100,25 @@ namespace YLErp.Modules.SwapModule [TestMethod] [TestCategory("DbDiagnose")] public void Diagnose_InterestPrincipalFix_Progression_And_UnwindResult() + { + DiagnoseTrade(TradeNumber_0008); + } + + [TestMethod] + [TestCategory("DbDiagnose")] + public void Diagnose_0013_InterestPrincipalFix_Progression_And_UnwindResult() + { + DiagnoseTrade(TradeNumber_0013); + } + + private void DiagnoseTrade(string tradeNumber) { YLContext db; try { db = DbContextFactory.GetYLDbContext(); } catch (Exception ex) { Assert.Inconclusive($"无法连接测试库:{ex.Message}"); return; } - var td = db.trade.FirstOrDefault(t => t.TradeNumber == TradeNumber); - if (td == null) { Assert.Inconclusive($"测试库无 {TradeNumber}"); return; } + var td = db.trade.FirstOrDefault(t => t.TradeNumber == tradeNumber); + if (td == null) { Assert.Inconclusive($"测试库无 {tradeNumber}"); return; } // 2.1 预付金腿 position.InterestPrincipalFix 当前值(多次平仓后应该已被扣减) var marginPositions = db.swap_position @@ -127,11 +140,11 @@ namespace YLErp.Modules.SwapModule .ToList(); Console.WriteLine("\n============== 全部 position 全景(对比 IsInitial 原始 vs !IsInitial 剩余) =============="); - Console.WriteLine($" {"Id",-8}{"Mode",-6}{"Dir",-6}{"IsInit",-8}{"Fix",-18}{"PosiNotional",-18}{"PosiQty",-12}{"UnderlyingCode",-15}"); + Console.WriteLine($" {"Id",-8}{"Mode",-6}{"IntDir",-7}{"PosiDir",-8}{"IsInit",-8}{"Fix",-18}{"PosiNotional",-18}{"PosiQty",-12}{"UnderlyingCode",-15}"); foreach (var p in allPositions) { var ul = p.UnderlyingCode ?? ""; - Console.WriteLine($" {p.id,-8}{p.InterestMode,-6}{p.InterestDirection,-6}{p.IsInitial,-8}{p.InterestPrincipalFix,-18}{p.PosiNotionalValue,-18}{p.PosiQuantity,-12}{ul,-15}"); + Console.WriteLine($" {p.id,-8}{p.InterestMode,-6}{p.InterestDirection,-7}{p.PosiDirection,-8}{p.IsInitial,-8}{p.InterestPrincipalFix,-18}{p.PosiNotionalValue,-18}{p.PosiQuantity,-12}{ul,-15}"); } // 2.1c 关键诊断:GetUnwindInterests 内部 origPositions vs realPostitions 差异 @@ -143,6 +156,14 @@ namespace YLErp.Modules.SwapModule Console.WriteLine($" origPositions(IsInitial=True) 预付金腿 Fix: {string.Join(",", origPositions.Where(x => x.InterestMode == 5 || x.InterestMode == 6).Select(x => x.InterestPrincipalFix))}"); Console.WriteLine($" realPostitions(IsInitial=False) 预付金腿 Fix: {string.Join(",", realPostitions.Where(x => x.InterestMode == 5 || x.InterestMode == 6).Select(x => x.InterestPrincipalFix))} ← 应为剩余值"); + // 2.1d 关键诊断:realLeg.PositionId == origPos.id 匹配校验(修复后端 Clone 是否会触发) + Console.WriteLine("\n============== realLeg.PositionId ↔ origPos.id 匹配校验(决定 Clone 是否生效)=============="); + foreach (var origPos in origPositions.Where(p => p.InterestMode == 5 || p.InterestMode == 6)) + { + var realLeg = realPostitions.FirstOrDefault(r => r.PositionId == origPos.id); + Console.WriteLine($" origPos.id={origPos.id} Fix={origPos.InterestPrincipalFix} | realLeg found={(realLeg != null)} | realLeg.id={realLeg?.id} realLeg.PositionId={realLeg?.PositionId} realLeg.Fix={realLeg?.InterestPrincipalFix} | 需Clone={(realLeg != null && realLeg.InterestPrincipalFix != origPos.InterestPrincipalFix)}"); + } + // 2.2 EOD 持仓 InterestPrincipalFix 逐日序列 var eodMarginSeq = db.eod_swap_position .Where(e => e.SwapTradeId == td.id && !e.Invalid @@ -171,12 +192,38 @@ namespace YLErp.Modules.SwapModule Console.WriteLine($" EventDate={f.EventDate:yyyy-MM-dd} PositionId={f.PositionId} InterestPrincipal={f.InterestPrincipal} InterestAmount={f.InterestAmount} Quantity={f.Quantity} TradingAmount={f.TradingAmount}"); } + // 2.3b 直接调 ResolveInterestLegPositions,验证 Clone 是否真的把 Fix 覆盖成 realLeg 值 + var resolved = SwapDealService.ResolveInterestLegPositions(origPositions, realPostitions); + Console.WriteLine("\n============== ResolveInterestLegPositions 直接调用结果 =============="); + foreach (var rp in resolved.Where(x => x.InterestMode == 5 || x.InterestMode == 6)) + { + Console.WriteLine($" resolved: id={rp.id} PositionId={rp.PositionId} Mode={rp.InterestMode} Fix={rp.InterestPrincipalFix} (期望=realLeg.Fix)"); + } + + // 2.3c 模拟前端调用 controller 完整流程:前端传 closePercent=0.7(占期初) + notionalValue/posiNotionalValue + // controller 调 ToRemainingClosePercent 转为占剩余,再调 GetUnwindInterests + // 等价于 HTTP POST /swaptrade2/GetUnwindInterestList + Console.WriteLine("\n============== 模拟 HTTP API 调用(前端 closePercent=0.7 占期初)=============="); + decimal frontClosePercent = 0.7m; + decimal frontNotionalValue = Convert.ToDecimal(td.OriginalStockEqvNotional ?? 0d); // 期初名义本金 + decimal frontPosiNotionalValue = Convert.ToDecimal(td.StockEqvNotional); // 剩余名义本金 + Console.WriteLine($" 前端参数: closePercent={frontClosePercent} notionalValue={frontNotionalValue} posiNotionalValue={frontPosiNotionalValue}"); + decimal convertedClosePercent = SwapDealService.ToRemainingClosePercent(frontClosePercent, frontNotionalValue, frontPosiNotionalValue); + Console.WriteLine($" ToRemainingClosePercent 转换后: closePercent={convertedClosePercent}(占剩余)"); + var svc = new SwapDealService(new OptUserInfo(1, "UnitTest", OptUserFrom.UnitTest)); + var apiInterests = svc.GetUnwindInterests(DateTime.Today, DateTime.Today, td.id, convertedClosePercent, (int)SwapEventTypeEnum.平仓); + Console.WriteLine($" GetUnwindInterests 返回 {apiInterests.Count} 条,预付金腿:"); + foreach (var ai in apiInterests.Where(x => x.InterestMode == (int)InterestModeEnum.初始预付金 || x.InterestMode == (int)InterestModeEnum.追加预付金)) + { + Console.WriteLine($" PositionId={ai.PositionId} Mode={ai.InterestMode} InterestPrincipal={ai.InterestPrincipal} InterestAmount={ai.InterestAmount}"); + } + // 2.4 直调后端 GetUnwindInterests(closePercent=1.0) 看"按全部平仓应返"的预付金值 try { var user = new OptUserInfo(0, nameof(GLMS20260701DbDiagnoseTest), OptUserFrom.UnitTest); - var svc = new SwapDealService(user); - var interests = svc.GetUnwindInterests(DateTime.Today, DateTime.Today, td.id, 1.0m, (int)SwapEventTypeEnum.平仓); + var svcFull = new SwapDealService(user); + var interests = svcFull.GetUnwindInterests(DateTime.Today, DateTime.Today, td.id, 1.0m, (int)SwapEventTypeEnum.平仓); Console.WriteLine("============== 后端 GetUnwindInterests(1.0) 实际返回值-预付金腿 =============="); foreach (var it in interests.Where(i => i.InterestMode == 5 || i.InterestMode == 6)) From 2395aa9856f6e6d2baf40091877958f1282ae38d Mon Sep 17 00:00:00 2001 From: hjhan Date: Fri, 17 Jul 2026 08:40:19 +0800 Subject: [PATCH 5/6] =?UTF-8?q?test(swap):=20=E8=A1=A5=206676b625=20?= =?UTF-8?q?=E6=8E=89=E6=9C=9F=E4=BF=9D=E8=AF=81=E9=87=91=E5=88=A9=E6=81=AF?= =?UTF-8?q?=E6=96=B9=E5=90=91=E5=8F=8D=E5=90=91=E5=8D=95=E5=85=83=E6=B5=8B?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 抽 CalculateSwapRealizedPnl 为 public static 纯函数(用 ConsTrade.InterestMarginModels 替代实例 marginTypes), 新增 8 个单元测试覆盖: 非保证金腿收取/支付原向, 保证金腿(初始预付金/追加预付金)收取/支付反向, 完整 5 字段汇总两种方向, RealizedInterest=0 边界. 锁定 6676b625 修复的保证金腿利息方向反向契约, 防止后续误改回归. --- .../SwapModule/SwapEodRealizedPnlCalcTest.cs | 206 ++++++++++++++++++ .../SwapModule/SwapEodPositionService.cs | 5 +- 2 files changed, 209 insertions(+), 2 deletions(-) create mode 100644 UnitTestProject/Modules/SwapModule/SwapEodRealizedPnlCalcTest.cs diff --git a/UnitTestProject/Modules/SwapModule/SwapEodRealizedPnlCalcTest.cs b/UnitTestProject/Modules/SwapModule/SwapEodRealizedPnlCalcTest.cs new file mode 100644 index 00000000..4be39977 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/SwapEodRealizedPnlCalcTest.cs @@ -0,0 +1,206 @@ +using YLErp.DBModels; +using YLErp.DBModels.Enums; + +namespace YLErp.Modules.SwapModule +{ + /// + /// SwapEodPositionService.CalculateSwapRealizedPnl 的回归测试。 + /// --------------------------------------------------------------- + /// 守卫张名锐提交 6676b625 "fix(swap): 修正掉期产品保证金利息计算逻辑"。 + /// + /// 旧 bug:eod_swap.RealizedPnL 直接 Sum(s.RealizedPnl),未对保证金腿利息做方向反向, + /// 导致"收取对手方保证金"产生的利息被错误计入我方收益(实际是我方支付给对手方的成本), + /// 框架合约已实现收益虚高。 + /// + /// 修复:新增 CalculateSwapRealizedPnl —— + /// 非保证金腿:interestRatio = Direction==收取 ? 1 : -1(维持数据库方向) + /// 保证金腿(初始预付金 5 / 追加预付金 6):interestRatio 反向 + /// 最终:RealizedInterest × interestRatio + 其他 4 字段 + /// + /// 抽为 public static 纯函数以支持无库单测(marginTypes 等价于 ConsTrade.InterestMarginModels)。 + /// 本测试直接锁定方向反向契约,防止后续误改回归。 + /// + [TestClass] + public class SwapEodRealizedPnlCalcTest + { + // ================================================================ + // 场景1:非保证金腿收取方向 → RealizedInterest × +1(维持原向) + // ================================================================ + [TestMethod] + public void 非保证金腿_收取方向_利息维持原向系数为1() + { + var pos = NewPosition( + interestMode: (int)InterestModeEnum.固定值, + interestDirection: (int)SwapDirectionEnum.收取, + realizedInterest: 1000m); + + var result = SwapEodPositionService.CalculateSwapRealizedPnl(pos); + + Assert.AreEqual(1000m, result, 0.0001m, + "非保证金腿收取方向:利息 ×(+1)=1000"); + } + + // ================================================================ + // 场景2:非保证金腿支付方向 → RealizedInterest × -1(维持原向) + // ================================================================ + [TestMethod] + public void 非保证金腿_支付方向_利息维持原向系数为负1() + { + var pos = NewPosition( + interestMode: (int)InterestModeEnum.固定值, + interestDirection: (int)SwapDirectionEnum.支付, + realizedInterest: 1000m); + + var result = SwapEodPositionService.CalculateSwapRealizedPnl(pos); + + Assert.AreEqual(-1000m, result, 0.0001m, + "非保证金腿支付方向:利息 ×(-1)=-1000"); + } + + // ================================================================ + // 场景3:保证金腿(初始预付金)收取方向 → 利息反向,系数 -1 + // 这是 6676b625 修复的核心场景:收取对手方保证金产生的利息是我方支付成本 + // ================================================================ + [TestMethod] + public void 保证金腿_初始预付金_收取方向_利息反向系数为负1() + { + var pos = NewPosition( + interestMode: (int)InterestModeEnum.初始预付金, + interestDirection: (int)SwapDirectionEnum.收取, + realizedInterest: 1000m); + + var result = SwapEodPositionService.CalculateSwapRealizedPnl(pos); + + Assert.AreEqual(-1000m, result, 0.0001m, + "保证金腿收取方向:利息应反向 ×(-1)=-1000(修复前会错误得 +1000)"); + } + + // ================================================================ + // 场景4:保证金腿(初始预付金)支付方向 → 利息反向,系数 +1 + // ================================================================ + [TestMethod] + public void 保证金腿_初始预付金_支付方向_利息反向系数为1() + { + var pos = NewPosition( + interestMode: (int)InterestModeEnum.初始预付金, + interestDirection: (int)SwapDirectionEnum.支付, + realizedInterest: 1000m); + + var result = SwapEodPositionService.CalculateSwapRealizedPnl(pos); + + Assert.AreEqual(1000m, result, 0.0001m, + "保证金腿支付方向:利息应反向 ×(+1)=1000"); + } + + // ================================================================ + // 场景5:追加预付金同初始预付金,同样走反向逻辑 + // ================================================================ + [TestMethod] + public void 保证金腿_追加预付金_收取方向_利息反向() + { + var pos = NewPosition( + interestMode: (int)InterestModeEnum.追加预付金, + interestDirection: (int)SwapDirectionEnum.收取, + realizedInterest: 500m); + + var result = SwapEodPositionService.CalculateSwapRealizedPnl(pos); + + Assert.AreEqual(-500m, result, 0.0001m, + "追加预付金(mode=6)与初始预付金(mode=5)同走反向逻辑"); + } + + // ================================================================ + // 场景6:完整 5 字段汇总(MtmPnL + Dividend + Fee + Interest×ratio + InterestFee) + // 保证金腿收取方向,Interest=200, 其他各 100 + // 期望:100 + 100 + 100 + 200×(-1) + 100 = 200 + // ================================================================ + [TestMethod] + public void 完整5字段汇总_保证金腿收取方向_利息反向后合计正确() + { + var pos = NewPosition( + interestMode: (int)InterestModeEnum.初始预付金, + interestDirection: (int)SwapDirectionEnum.收取, + realizedMtmPnL: 100m, + realizedDividend: 100m, + realizedFee: 100m, + realizedInterest: 200m, + realizedInterestFee: 100m); + + var result = SwapEodPositionService.CalculateSwapRealizedPnl(pos); + + // 100 + 100 + 100 + 200×(-1) + 100 = 200 + Assert.AreEqual(200m, result, 0.0001m, + "5 字段汇总:保证金腿收取方向,Interest×(-1) 后合计=200,验证所有字段都参与计算"); + } + + // ================================================================ + // 场景7:完整 5 字段汇总(非保证金腿收取方向) + // 非保证金腿收取方向,Interest=200, 其他各 100 + // 期望:100 + 100 + 100 + 200×(+1) + 100 = 600 + // ================================================================ + [TestMethod] + public void 完整5字段汇总_非保证金腿收取方向_利息原向合计正确() + { + var pos = NewPosition( + interestMode: (int)InterestModeEnum.固定值, + interestDirection: (int)SwapDirectionEnum.收取, + realizedMtmPnL: 100m, + realizedDividend: 100m, + realizedFee: 100m, + realizedInterest: 200m, + realizedInterestFee: 100m); + + var result = SwapEodPositionService.CalculateSwapRealizedPnl(pos); + + // 100 + 100 + 100 + 200×(+1) + 100 = 600 + Assert.AreEqual(600m, result, 0.0001m, + "5 字段汇总:非保证金腿收取方向,Interest×(+1) 后合计=600"); + } + + // ================================================================ + // 场景8:RealizedInterest=0 边界 —— 方向反向无影响,结果为其他 4 字段之和 + // ================================================================ + [TestMethod] + public void 利息为零_方向反向无影响_结果为其他4字段之和() + { + var pos = NewPosition( + interestMode: (int)InterestModeEnum.初始预付金, + interestDirection: (int)SwapDirectionEnum.收取, + realizedMtmPnL: 100m, + realizedDividend: 50m, + realizedFee: 30m, + realizedInterest: 0m, + realizedInterestFee: 20m); + + var result = SwapEodPositionService.CalculateSwapRealizedPnl(pos); + + // 100 + 50 + 30 + 0×(-1) + 20 = 200 + Assert.AreEqual(200m, result, 0.0001m, + "RealizedInterest=0 时方向反向无影响,结果为其他 4 字段之和"); + } + + // ================================================================ + // Helper:构造 eod_swap_position(只设置参与计算的 7 个字段) + // ================================================================ + private static eod_swap_position NewPosition( + int interestMode, + int interestDirection, + decimal realizedMtmPnL = 0m, + decimal realizedDividend = 0m, + decimal realizedFee = 0m, + decimal realizedInterest = 0m, + decimal realizedInterestFee = 0m) + { + return new eod_swap_position + { + InterestMode = interestMode, + InterestDirection = interestDirection, + RealizedMtmPnL = realizedMtmPnL, + RealizedDividend = realizedDividend, + RealizedFee = realizedFee, + RealizedInterest = realizedInterest, + RealizedInterestFee = realizedInterestFee + }; + } + } +} diff --git a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs index ce8e0eda..0c44f02a 100644 --- a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs @@ -2058,11 +2058,12 @@ namespace YLErp.Modules.SwapModule /// 浮动腿及普通利息腿维持数据库记录的方向;初始/追加预付金腿的利息 /// 则与保证金本金方向相反。这样“收取对手方保证金”产生的利息会作为 /// 我方支付给对手方的成本计入,而不会错误增加框架合约已实现收益。 + /// 抽为静态纯函数以支持无库单测(marginTypes 等价于 ConsTrade.InterestMarginModels)。 /// - private decimal CalculateSwapRealizedPnl(eod_swap_position position) + public static decimal CalculateSwapRealizedPnl(eod_swap_position position) { var interestRatio = position.InterestDirection == (int)SwapDirectionEnum.收取 ? 1m : -1m; - if (marginTypes.Contains(position.InterestMode)) + if (ConsTrade.InterestMarginModels.Contains(position.InterestMode)) { interestRatio = -interestRatio; } From 21b86c871f632e433ba7836d48367d295912cd02 Mon Sep 17 00:00:00 2001 From: hjhan Date: Fri, 17 Jul 2026 08:45:49 +0800 Subject: [PATCH 6/6] =?UTF-8?q?test(trade):=20=E8=A1=A5=2023108016=20?= =?UTF-8?q?=E4=BA=92=E6=8D=A2/=E6=9C=9F=E6=9D=83=E6=9C=AC=E6=AC=A1?= =?UTF-8?q?=E5=90=8D=E4=B9=89=E6=9C=AC=E9=87=91=E5=88=86=E6=94=AF=E5=8D=95?= =?UTF-8?q?=E5=85=83=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 抽 CalcSwapCloseNotionalFromEventData 和 CalcOptionCloseNotional 为 public static 纯函数, BuildTriggerContext 了结场景改为调用这两个函数. 新增 13 个单元测试覆盖: 互换分支 EventData null/空/非法JSON/正数/负数/零 容错与绝对值, 期权分支 两者null/单边null/正数相乘/负数容错 绝对值语义. 重点锁定静默 catch 容错契约, 防止 EventData 异常时名义本金变 0 绕过审批阈值. --- .../TradeServiceBaseCloseNotionalCalcTest.cs | 133 ++++++++++++++++++ .../Modules/TradeModule/TradeServiceBase.cs | 49 ++++--- 2 files changed, 165 insertions(+), 17 deletions(-) create mode 100644 UnitTestProject/Modules/TradeModule/TradeServiceBaseCloseNotionalCalcTest.cs diff --git a/UnitTestProject/Modules/TradeModule/TradeServiceBaseCloseNotionalCalcTest.cs b/UnitTestProject/Modules/TradeModule/TradeServiceBaseCloseNotionalCalcTest.cs new file mode 100644 index 00000000..9e8f364d --- /dev/null +++ b/UnitTestProject/Modules/TradeModule/TradeServiceBaseCloseNotionalCalcTest.cs @@ -0,0 +1,133 @@ +using Newtonsoft.Json; +using YLErp.DBModels; +using YLErp.Modules.TradeModule; + +namespace YLErp.Modules.TradeModule +{ + /// + /// TradeServiceBase.CalcSwapCloseNotionalFromEventData / CalcOptionCloseNotional 的回归测试。 + /// --------------------------------------------------------------- + /// 守卫锦麟王提交 23108016 "BugFix 互换本次名义本金取错"。 + /// + /// 旧 bug:BuildTriggerContext 了结场景统一用 trade_cash.UnwindPercentRate × 期初名义本金 + /// 算本次名义本金,但收益互换的 trade_cash.UnwindPercentRate 口径与期权不同, + /// 导致互换审批触发条件用错本金,可能绕过/误触发审批阈值。 + /// + /// 修复:互换分支从 swap_event.EventData 反序列化取 CloseNotionalValue 绝对值; + /// 期权分支保留旧逻辑(期初名义本金 × UnwindPercentRate 绝对值)。 + /// + /// 抽出两个静态纯函数以支持无库单测,重点验证容错(null/空/非法 JSON)不会抛异常 + /// 而是返回 0,避免静默吞异常导致名义本金为 0 进而绕过审批阈值。 + /// + [TestClass] + public class TradeServiceBaseCloseNotionalCalcTest + { + // ================================================================ + // 一、CalcSwapCloseNotionalFromEventData 容错与绝对值语义 + // ================================================================ + + [TestMethod] + public void 互换_EventData为null_返回0_不抛异常() + { + var result = TradeServiceBase.CalcSwapCloseNotionalFromEventData(null); + Assert.AreEqual(0d, result, 0.0001, "null EventData 应容错返回 0"); + } + + [TestMethod] + public void 互换_EventData为空字符串_返回0_不抛异常() + { + var result = TradeServiceBase.CalcSwapCloseNotionalFromEventData(""); + Assert.AreEqual(0d, result, 0.0001, "空字符串 EventData 应容错返回 0"); + } + + [TestMethod] + public void 互换_EventData为非法JSON_返回0_不抛异常() + { + // 旧实现 catch{} 静默吞异常,抽函数后必须保持此容错契约 + var result = TradeServiceBase.CalcSwapCloseNotionalFromEventData("not-a-json"); + Assert.AreEqual(0d, result, 0.0001, "非法 JSON 应被 catch 返回 0,不能抛异常"); + } + + [TestMethod] + public void 互换_EventData为合法JSON_正数CloseNotionalValue_原值返回() + { + var eventData = JsonConvert.SerializeObject(new UnwindData { CloseNotionalValue = 500_000m }); + var result = TradeServiceBase.CalcSwapCloseNotionalFromEventData(eventData); + Assert.AreEqual(500_000d, result, 0.01, "正数 CloseNotionalValue 应原值返回"); + } + + [TestMethod] + public void 互换_EventData为合法JSON_负数CloseNotionalValue_取绝对值() + { + // 修复的核心契约:Math.Abs 取绝对值,防止方向反向导致名义本金变负 + var eventData = JsonConvert.SerializeObject(new UnwindData { CloseNotionalValue = -500_000m }); + var result = TradeServiceBase.CalcSwapCloseNotionalFromEventData(eventData); + Assert.AreEqual(500_000d, result, 0.01, "负数 CloseNotionalValue 应取绝对值返回 500000"); + } + + [TestMethod] + public void 互换_EventData为合法JSON_CloseNotionalValue为零_返回0() + { + var eventData = JsonConvert.SerializeObject(new UnwindData { CloseNotionalValue = 0m }); + var result = TradeServiceBase.CalcSwapCloseNotionalFromEventData(eventData); + Assert.AreEqual(0d, result, 0.0001, "CloseNotionalValue=0 应返回 0"); + } + + // ================================================================ + // 二、CalcOptionCloseNotional 容错与绝对值语义 + // ================================================================ + + [TestMethod] + public void 期权_两者都为null_返回0() + { + var result = TradeServiceBase.CalcOptionCloseNotional(null, null); + Assert.AreEqual(0d, result, 0.0001, "两者 null 应返回 0"); + } + + [TestMethod] + public void 期权_期初名义本金为null_返回0() + { + var result = TradeServiceBase.CalcOptionCloseNotional(null, 0.5d); + Assert.AreEqual(0d, result, 0.0001, "originalStockEqvNotional=null 应返回 0"); + } + + [TestMethod] + public void 期权_平仓比例为null_返回0() + { + var result = TradeServiceBase.CalcOptionCloseNotional(1_000_000d, null); + Assert.AreEqual(0d, result, 0.0001, "unwindPercentRate=null 应返回 0"); + } + + [TestMethod] + public void 期权_两者都有值_正数相乘_返回乘积() + { + // 1,000,000 × 0.3 = 300,000 + var result = TradeServiceBase.CalcOptionCloseNotional(1_000_000d, 0.3d); + Assert.AreEqual(300_000d, result, 0.01, "1M × 0.3 = 300K"); + } + + [TestMethod] + public void 期权_期初名义本金为负数_取绝对值后相乘() + { + // 异常但容错:-1,000,000 × 0.3 → Abs → 300,000 + var result = TradeServiceBase.CalcOptionCloseNotional(-1_000_000d, 0.3d); + Assert.AreEqual(300_000d, result, 0.01, "期初名义本金为负数应取绝对值后相乘"); + } + + [TestMethod] + public void 期权_平仓比例为负数_取绝对值后相乘() + { + // 异常但容错:1,000,000 × -0.3 → Abs → 300,000 + var result = TradeServiceBase.CalcOptionCloseNotional(1_000_000d, -0.3d); + Assert.AreEqual(300_000d, result, 0.01, "平仓比例为负数应取绝对值后相乘"); + } + + [TestMethod] + public void 期权_两者都为负数_取绝对值后相乘() + { + // -1,000,000 × -0.3 = 300,000(先乘后取 Abs,结果一致) + var result = TradeServiceBase.CalcOptionCloseNotional(-1_000_000d, -0.3d); + Assert.AreEqual(300_000d, result, 0.01, "两者都为负数应取绝对值后相乘"); + } + } +} diff --git a/YLErpDAL/Modules/TradeModule/TradeServiceBase.cs b/YLErpDAL/Modules/TradeModule/TradeServiceBase.cs index e78e82ff..7d45a901 100644 --- a/YLErpDAL/Modules/TradeModule/TradeServiceBase.cs +++ b/YLErpDAL/Modules/TradeModule/TradeServiceBase.cs @@ -1,4 +1,4 @@ -using BaseOUDAL; +using BaseOUDAL; using YLErp.BLL; using YLErp.DBModels.Consts; using YLErp.DBModels.Enums; @@ -210,18 +210,7 @@ namespace YLErp.Modules.TradeModule && (x.EventType == (int)SwapEventTypeEnum.平仓 || x.EventType == (int)SwapEventTypeEnum.互换)) .OrderByDescending(x => x.id) .FirstOrDefault(); - if (swapEvent != null && !string.IsNullOrEmpty(swapEvent.EventData)) - { - try - { - var unwindData = Newtonsoft.Json.JsonConvert.DeserializeObject(swapEvent.EventData); - if (unwindData != null) - { - ctx.CurrentNotional = Math.Abs((double)unwindData.CloseNotionalValue); - } - } - catch { } - } + ctx.CurrentNotional = CalcSwapCloseNotionalFromEventData(swapEvent?.EventData); } else { @@ -230,15 +219,41 @@ namespace YLErp.Modules.TradeModule .Where(t => t.TradeId == td.id && t.ValidState == ConsGlobal.InValid && !t.IsDeleted) .OrderByDescending(t => t.id) .FirstOrDefault(); - if (tc != null && tc.UnwindPercentRate.HasValue && td.OriginalStockEqvNotional.HasValue) - { - ctx.CurrentNotional = Math.Abs(td.OriginalStockEqvNotional.Value * tc.UnwindPercentRate.Value); - } + ctx.CurrentNotional = CalcOptionCloseNotional(td.OriginalStockEqvNotional, tc?.UnwindPercentRate); } } return ctx; } + /// + /// 互换:从 swap_event.EventData(JSON) 反序列化取 CloseNotionalValue 绝对值。 + /// 容错:EventData 为 null/空/非法 JSON / unwindData=null 时返回 0(不影响审批阈值判断)。 + /// 抽为静态纯函数以支持无库单测。 + /// + public static double CalcSwapCloseNotionalFromEventData(string eventData) + { + if (string.IsNullOrEmpty(eventData)) return 0; + try + { + var unwindData = Newtonsoft.Json.JsonConvert.DeserializeObject(eventData); + return unwindData != null ? Math.Abs((double)unwindData.CloseNotionalValue) : 0; + } + catch + { + return 0; + } + } + + /// + /// 期权:当次平仓名义本金 = 期初名义本金 × 平仓比例(UnwindPercentRate),取绝对值。 + /// 容错:任一参数为 null 时返回 0。抽为静态纯函数以支持无库单测。 + /// + public static double CalcOptionCloseNotional(double? originalStockEqvNotional, double? unwindPercentRate) + { + if (!originalStockEqvNotional.HasValue || !unwindPercentRate.HasValue) return 0; + return Math.Abs(originalStockEqvNotional.Value * unwindPercentRate.Value); + } + /// /// 需求①:判断按触发条件是否需要审批。 /// 取该交易类别的审批流程,若所有节点配置的触发条件都不满足当前业务,则无需审批(返回 false)。