diff --git a/YLErpDAL/Modules/RiskEngine/Dto/BatchOperationResult.cs b/YLErpDAL/Modules/RiskEngine/Dto/BatchOperationResult.cs index 7d9dccb1..c6c7aafd 100644 --- a/YLErpDAL/Modules/RiskEngine/Dto/BatchOperationResult.cs +++ b/YLErpDAL/Modules/RiskEngine/Dto/BatchOperationResult.cs @@ -14,4 +14,19 @@ namespace YLErp.Modules.RiskEngine.Dto /// 错误信息 public string ErrorMessage { get; set; } } + + /// + /// 批量删除变量结果 + /// + public class BatchDeleteVariablesResult : BatchOperationResult + { + /// 成功删除的变量 ID + public List DeletedIds { get; set; } + /// 不存在的变量 ID + public List MissingIds { get; set; } + /// 因被生效规则引用而跳过的变量 ID + public List BlockedIds { get; set; } + /// 被跳过变量的原因,Key 为变量 ID + public Dictionary BlockedReasons { get; set; } + } } diff --git a/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs b/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs index d12d3be2..737aa940 100644 --- a/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs +++ b/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs @@ -1,5 +1,7 @@ using BaseOUDAL; using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using System.Globalization; using System.Linq; using System.Linq.Expressions; using System.Text.RegularExpressions; @@ -105,9 +107,10 @@ using YLErp.Modules.RiskEngine.Dto; DeleteVariable(long) 删除变量(引用保护:有 Active 规则引用时拒绝,硬删除) GetAllVariableList() 获取所有已实现变量(轻量字段,供规则编辑器下拉) - ── 审计日志(3 个) ─────────────────────────────────────────────── + ── 审计日志(4 个) ─────────────────────────────────────────────── QueryAuditLogs(QueryRiskAuditLogReq) 查询审计日志(多条件筛选,分页) - ExportAuditLogs(QueryRiskAuditLogReq) 导出审计日志(占位实现,后续迭代 Excel 导出) + BuildAuditLogExportFileName(...) 根据查询条件生成导出文件名 + ExportAuditLogs(QueryRiskAuditLogReq) 导出审计日志 Excel(ID 保持整数文本、类型中文映射) GetAuditLogDetail(long) 获取审计日志详情(含完整 SnapshotData) ── 私有辅助方法 ─────────────────────────────────────────────────── @@ -180,7 +183,7 @@ using YLErp.Modules.RiskEngine.Dto; 1. RiskEngineService 由另一个团队开发实现,本服务通过 GetInstance() 获取单例引用 2. 规则变更后调用 RefreshOneRuleCache(ruleId),应用变更后调用 RefreshApplication(),变量变更不触发引擎缓存刷新 3. 变量删除为硬删除(物理删除),规则/应用删除为软删除(Status=Deleted) - 4. ExportAuditLogs 当前为占位实现,Excel 导出功能待后续迭代 + 4. ExportAuditLogs 按查询条件导出,并生成包含查询条件的文件名 5. VariableExpr 当前仅做基础长度校验(≤10000),完整编译校验待引入 Roslyn 库 6. ValidateScopeFields 的 ID 存在性校验已跳过(前端下拉选择器保证有效性) ================================================================================ @@ -291,6 +294,7 @@ namespace YLErp.Modules.RiskEngine var sanitized = Regex.Replace(value.Trim(), @"[\\/:*?""<>|\r\n]+", "_"); return sanitized.Length <= 40 ? sanitized : sanitized.Substring(0, 40); } + /// /// 写入审计日志 /// @@ -801,9 +805,10 @@ namespace YLErp.Modules.RiskEngine if (req.VariableId.HasValue) { - var varIdStr = req.VariableId.Value.ToString(); - query = query.Where(r => (r.ConditionJson != null && r.ConditionJson.Contains(varIdStr)) - || (r.RuleExpr != null && r.RuleExpr.Contains(varIdStr))); + var referencedRuleIds = GetRulesByVariableId(req.VariableId.Value) + .Select(r => (long)r.id) + .ToList(); + query = query.Where(r => referencedRuleIds.Contains(r.id)); } var result = query.OrderByDescending(r => r.UpdateDate) @@ -822,7 +827,8 @@ namespace YLErp.Modules.RiskEngine UpdateOptName = r.UpdateOptName, UpdateDate = r.UpdateDate ?? r.OptDate.GetValueOrDefault(), ApplicationCount = DbContext.glms_risk_rule_application - .Count(a => a.RuleIds != null && + .Count(a => a.Status != RiskRuleStatus.Deleted && + a.RuleIds != null && (a.RuleIds == r.id.ToString() || a.RuleIds.StartsWith(r.id.ToString() + ",") || a.RuleIds.EndsWith("," + r.id.ToString()) || @@ -841,6 +847,7 @@ namespace YLErp.Modules.RiskEngine var applications = DbContext.glms_risk_rule_application + .Where(a => a.Status != RiskRuleStatus.Deleted) .Where(RuleIdsMatchExpr(ruleId)) .Select(a => new RiskRuleApplicationSummary { @@ -1946,6 +1953,183 @@ namespace YLErp.Modules.RiskEngine ).ToList(); } + /// + /// 根据结构化 ConditionJson 和当前变量池定义重新生成可执行 RuleExpr。 + /// ConditionJson 是结构化规则的事实来源,避免通过字符串 Replace 级联修改表达式。 + /// + private string BuildRuleExprFromConditionJson(string conditionJson) + { + List conditions; + try + { + conditions = JsonConvert.DeserializeObject>(conditionJson); + } + catch + { + throw new ServiceException("公式表达式 JSON 格式不合法"); + } + + if (conditions == null || conditions.Count == 0) + throw new ServiceException("公式条件列表不能为空"); + + var variableIds = conditions + .Select(c => c.VariableId) + .Concat(conditions.Where(c => c.ThresholdVariableId.HasValue) + .Select(c => c.ThresholdVariableId.Value)) + .Distinct() + .ToList(); + + var variables = DbContext.glms_risk_variable + .Where(v => variableIds.Contains(v.id)) + .ToList() + .ToDictionary(v => (long)v.id); + + var missingVariableIds = variableIds.Where(id => !variables.ContainsKey(id)).ToList(); + if (missingVariableIds.Any()) + throw new ServiceException($"公式引用的变量不存在:{string.Join(",", missingVariableIds)}"); + + var conditionExpressions = conditions + .Select((condition, index) => BuildConditionExpression(condition, variables, index + 1)) + .ToList(); + + return string.Join(" && ", conditionExpressions.Select(expr => $"({expr})")); + } + + private string BuildConditionExpression( + RuleCondition condition, + IReadOnlyDictionary variables, + int conditionIndex) + { + var variable = variables[condition.VariableId]; + if (string.IsNullOrWhiteSpace(variable.VariableExpr)) + throw new ServiceException($"条件{conditionIndex}:变量'{variable.VariableName}'的取值表达式为空"); + + if (!ValidOperatorsByType.TryGetValue(variable.DataType, out var validOperators) || + !validOperators.Contains(condition.Operator)) + { + throw new ServiceException($"条件{conditionIndex}:操作符'{condition.Operator}'不适用于{GetDataTypeName(variable.DataType)}类型变量"); + } + + if (variable.DataType == RiskVariableDataType.Boolean) + { + return condition.Operator switch + { + "是" => $"({variable.VariableExpr}) == true", + "否" => $"({variable.VariableExpr}) == false", + _ => throw new ServiceException($"条件{conditionIndex}:不支持的布尔操作符'{condition.Operator}'") + }; + } + + var leftExpression = BuildComparableExpression(variable); + if (condition.Operator == "介于" || condition.Operator == "不介于") + { + if (!string.Equals(condition.ThresholdType, "Fixed", StringComparison.Ordinal)) + throw new ServiceException($"条件{conditionIndex}:介于/不介于暂只支持固定上下限"); + if (condition.Value is not JArray range || range.Count != 2) + throw new ServiceException($"条件{conditionIndex}:介于/不介于的阈值必须为双元素数组"); + + var lowerExpression = BuildFixedValueExpression(range[0], variable.DataType, conditionIndex); + var upperExpression = BuildFixedValueExpression(range[1], variable.DataType, conditionIndex); + var lowerOperator = condition.IncludeLowerBound ? ">=" : ">"; + var upperOperator = condition.IncludeUpperBound ? "<=" : "<"; + var rangeExpression = $"(({leftExpression}) {lowerOperator} ({lowerExpression}) && ({leftExpression}) {upperOperator} ({upperExpression}))"; + + return condition.Operator == "不介于" ? $"!{rangeExpression}" : rangeExpression; + } + + var rightExpression = BuildThresholdExpression(condition, variable.DataType, variables, conditionIndex); + var comparisonOperator = GetComparisonOperator(condition.Operator, conditionIndex); + return $"({leftExpression}) {comparisonOperator} ({rightExpression})"; + } + + private static string BuildComparableExpression(glms_risk_variable variable) + { + return variable.DataType == RiskVariableDataType.Numeric + ? $"Convert.ToDecimal(({variable.VariableExpr}))" + : variable.VariableExpr; + } + + private string BuildThresholdExpression( + RuleCondition condition, + RiskVariableDataType dataType, + IReadOnlyDictionary variables, + int conditionIndex) + { + if (string.Equals(condition.ThresholdType, "Fixed", StringComparison.Ordinal)) + return BuildFixedValueExpression(condition.Value, dataType, conditionIndex); + + if (string.Equals(condition.ThresholdType, "Variable", StringComparison.Ordinal)) + { + if (!condition.ThresholdVariableId.HasValue || + !variables.TryGetValue(condition.ThresholdVariableId.Value, out var thresholdVariable)) + { + throw new ServiceException($"条件{conditionIndex}:阈值变量不存在"); + } + + if (thresholdVariable.DataType != dataType) + throw new ServiceException($"条件{conditionIndex}:阈值变量与条件变量的数据类型不一致"); + if (string.IsNullOrWhiteSpace(thresholdVariable.VariableExpr)) + throw new ServiceException($"条件{conditionIndex}:阈值变量'{thresholdVariable.VariableName}'的取值表达式为空"); + + return BuildComparableExpression(thresholdVariable); + } + + throw new ServiceException($"条件{conditionIndex}:阈值类型'{condition.ThresholdType}'不合法,仅支持 Fixed/Variable"); + } + + private static string BuildFixedValueExpression(object value, RiskVariableDataType dataType, int conditionIndex) + { + if (value == null) + throw new ServiceException($"条件{conditionIndex}:固定阈值不能为空"); + + var rawValue = value is JValue jsonValue + ? Convert.ToString(jsonValue.Value, CultureInfo.InvariantCulture) + : Convert.ToString(value, CultureInfo.InvariantCulture); + + if (dataType == RiskVariableDataType.Numeric) + { + if (!decimal.TryParse(rawValue, NumberStyles.Number | NumberStyles.AllowExponent, + CultureInfo.InvariantCulture, out var numericValue)) + { + throw new ServiceException($"条件{conditionIndex}:数值型阈值必须为数字"); + } + + return numericValue.ToString(CultureInfo.InvariantCulture) + "m"; + } + + if (dataType == RiskVariableDataType.Date) + { + if (!DateTime.TryParse(rawValue, CultureInfo.InvariantCulture, + DateTimeStyles.AllowWhiteSpaces | DateTimeStyles.RoundtripKind, out var dateValue) && + !DateTime.TryParse(rawValue, out dateValue)) + { + throw new ServiceException($"条件{conditionIndex}:日期型阈值必须为合法日期"); + } + + return $"new DateTime({dateValue.Ticks}L, DateTimeKind.{dateValue.Kind})"; + } + + throw new ServiceException($"条件{conditionIndex}:不支持的数据类型"); + } + + private static string GetComparisonOperator(string ruleOperator, int conditionIndex) + { + return ruleOperator switch + { + ">" => ">", + "<" => "<", + ">=" => ">=", + "<=" => "<=", + "=" => "==", + "≠" => "!=", + "早于" => "<", + "晚于" => ">", + "等于" => "==", + "不早于" => ">=", + "不晚于" => "<=", + _ => throw new ServiceException($"条件{conditionIndex}:不支持的操作符'{ruleOperator}'") + }; + } #region Variable Management /// @@ -1995,11 +2179,8 @@ namespace YLErp.Modules.RiskEngine { var variable = GetVariableOrThrow(variableId); - var varExpr = variable.VariableExpr; - var referenceCount = DbContext.glms_risk_rule - .Count(r => r.Status == RiskRuleStatus.Active && - ((r.ConditionJson != null && r.ConditionJson.Contains(variable.id.ToString())) || - (r.RuleExpr != null && varExpr != null && r.RuleExpr.Contains(varExpr)))); + var referenceCount = GetRulesByVariableId(variableId) + .Count(r => r.Status == RiskRuleStatus.Active); return new RiskVariableDetail { @@ -2123,14 +2304,18 @@ namespace YLErp.Modules.RiskEngine .Where(r => r.RuleExpr != null) .ToList(); - var compileTasks = affectedRules.Select(r => - Task.Run(() => RuleCompiler.ValidateAndCompileFormula( - r.id, r.RuleExpr.Replace(oldVariableExpr, req.VariableExpr))) - ); - var compileResults = Task.WhenAll(compileTasks).GetAwaiter().GetResult(); - var failed = compileResults.FirstOrDefault(r => !r.Success); - if (failed is not null) - throw new ServiceException($"变量表达式变更导致规则编译失败:{failed.ErrorMessage}"); + var rebuiltRuleExpressions = new Dictionary(); + foreach (var rule in affectedRules) + { + var rebuiltRuleExpr = BuildRuleExprFromConditionJson(rule.ConditionJson); + ValidateRuleExpr(rebuiltRuleExpr); + + var compileResult = RuleCompiler.ValidateAndCompileFormula(rule.id, rebuiltRuleExpr); + if (!compileResult.Success) + throw new ServiceException($"变量表达式变更导致规则'{rule.RuleName}'(ID:{rule.id})编译失败:{compileResult.ErrorMessage}"); + + rebuiltRuleExpressions[rule.id] = rebuiltRuleExpr; + } using (var transaction = DbContext.Database.BeginTransaction()) { @@ -2138,7 +2323,7 @@ namespace YLErp.Modules.RiskEngine { foreach (var rule in affectedRules) { - rule.RuleExpr = rule.RuleExpr.Replace(oldVariableExpr, req.VariableExpr); + rule.RuleExpr = rebuiltRuleExpressions[rule.id]; rule.Version = rule.Version + 1; rule.UpdateOptId = UserId; rule.UpdateOptName = UserName; @@ -2198,6 +2383,58 @@ namespace YLErp.Modules.RiskEngine InvalidateVariableCache(); } + /// + /// 批量删除变量(不存在或被生效规则引用的变量将被跳过) + /// + public BatchDeleteVariablesResult BatchDeleteVariables(List variableIds) + { + if (variableIds == null || variableIds.Count == 0) + throw new ServiceException("变量ID列表不能为空"); + + var distinctIds = variableIds.Distinct().ToList(); + var variables = DbContext.glms_risk_variable + .Where(v => distinctIds.Contains(v.id)) + .ToList(); + var foundIds = variables.Select(v => (long)v.id).ToList(); + var missingIds = distinctIds.Except(foundIds).ToList(); + var blockedIds = new List(); + var blockedReasons = new Dictionary(); + var deletedIds = new List(); + + foreach (var variable in variables) + { + var activeRefCount = GetRulesByVariableId(variable.id) + .Count(r => r.Status == RiskRuleStatus.Active); + if (activeRefCount > 0) + { + blockedIds.Add(variable.id); + blockedReasons[variable.id] = $"该变量被 {activeRefCount} 个生效中的规则引用,无法删除"; + continue; + } + + DbContext.glms_risk_variable.Remove(variable); + WriteAuditLog("VAR_BATCH_DELETE", "VARIABLE", variable.id, variable.VariableName, $"批量删除变量:{variable.VariableName}"); + deletedIds.Add(variable.id); + } + + if (deletedIds.Count > 0) + { + DbContext.SaveChanges(); + InvalidateVariableCache(); + } + + return new BatchDeleteVariablesResult + { + Success = true, + TotalCount = distinctIds.Count, + SuccessCount = deletedIds.Count, + DeletedIds = deletedIds, + MissingIds = missingIds, + BlockedIds = blockedIds, + BlockedReasons = blockedReasons + }; + } + /// /// 获取所有变量列表(供下拉选择) /// @@ -2314,13 +2551,13 @@ namespace YLErp.Modules.RiskEngine } var operationTypeNames = operationTypes .Distinct(StringComparer.OrdinalIgnoreCase) - .Select(GetAuditOperationTypeName) + .Select(type => SanitizeFileNamePart(GetAuditOperationTypeName(type))) .ToList(); if (operationTypeNames.Any()) conditions.Add($"操作类型-{string.Join("+", operationTypeNames)}"); if (!string.IsNullOrWhiteSpace(req.TargetType)) - conditions.Add($"目标类型-{GetAuditTargetTypeName(req.TargetType)}"); + conditions.Add($"目标类型-{SanitizeFileNamePart(GetAuditTargetTypeName(req.TargetType))}"); if (!string.IsNullOrWhiteSpace(req.TargetName)) conditions.Add($"目标名称-{SanitizeFileNamePart(req.TargetName)}"); if (!string.IsNullOrWhiteSpace(req.OptName)) diff --git a/YLErpWeb/Controllers/RiskRuleController.cs b/YLErpWeb/Controllers/RiskRuleController.cs index 028c0fba..e47aa4f7 100644 --- a/YLErpWeb/Controllers/RiskRuleController.cs +++ b/YLErpWeb/Controllers/RiskRuleController.cs @@ -685,6 +685,28 @@ namespace YLErp.Web.Controllers } } + [HttpDelete("risk-variables/batch")] + [MyAuthorize("风险控制-风控变量删除")] + /// 批量删除变量 + public async Task BatchDeleteRiskVariables([FromBody] BatchIdsReq req) + { + try + { + var service = GetRiskRuleService(); + var result = await Task.Run(() => service.BatchDeleteVariables(req?.Ids)); + 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 Trade Types @@ -773,7 +795,9 @@ namespace YLErp.Web.Controllers var service = GetRiskRuleService(); var bytes = await Task.Run(() => service.ExportAuditLogs(req)); var fileName = service.BuildAuditLogExportFileName(req); - return File(bytes, xlsxMimeType, fileName); + var encodedFileName = Uri.EscapeDataString(fileName); + Response.Headers["Content-Disposition"] = $"attachment; filename*=UTF-8''{encodedFileName}"; + return File(bytes, xlsxMimeType); } catch (ServiceException ex) {