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)
{