Merge branch 'feature/p132_74-risk-engine' of https://gitee.glmszq.com/gsty/onederiv/trs into feature/p132_74-risk-engine
This commit is contained in:
@@ -14,4 +14,19 @@ namespace YLErp.Modules.RiskEngine.Dto
|
||||
/// <summary>错误信息</summary>
|
||||
public string ErrorMessage { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量删除变量结果
|
||||
/// </summary>
|
||||
public class BatchDeleteVariablesResult : BatchOperationResult
|
||||
{
|
||||
/// <summary>成功删除的变量 ID</summary>
|
||||
public List<long> DeletedIds { get; set; }
|
||||
/// <summary>不存在的变量 ID</summary>
|
||||
public List<long> MissingIds { get; set; }
|
||||
/// <summary>因被生效规则引用而跳过的变量 ID</summary>
|
||||
public List<long> BlockedIds { get; set; }
|
||||
/// <summary>被跳过变量的原因,Key 为变量 ID</summary>
|
||||
public Dictionary<long, string> BlockedReasons { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 写入审计日志
|
||||
/// </summary>
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据结构化 ConditionJson 和当前变量池定义重新生成可执行 RuleExpr。
|
||||
/// ConditionJson 是结构化规则的事实来源,避免通过字符串 Replace 级联修改表达式。
|
||||
/// </summary>
|
||||
private string BuildRuleExprFromConditionJson(string conditionJson)
|
||||
{
|
||||
List<RuleCondition> conditions;
|
||||
try
|
||||
{
|
||||
conditions = JsonConvert.DeserializeObject<List<RuleCondition>>(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<long, glms_risk_variable> 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<long, glms_risk_variable> 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
|
||||
|
||||
/// <summary>
|
||||
@@ -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<long, string>();
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量删除变量(不存在或被生效规则引用的变量将被跳过)
|
||||
/// </summary>
|
||||
public BatchDeleteVariablesResult BatchDeleteVariables(List<long> 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<long>();
|
||||
var blockedReasons = new Dictionary<long, string>();
|
||||
var deletedIds = new List<long>();
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取所有变量列表(供下拉选择)
|
||||
/// </summary>
|
||||
@@ -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))
|
||||
|
||||
@@ -685,6 +685,28 @@ namespace YLErp.Web.Controllers
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("risk-variables/batch")]
|
||||
[MyAuthorize("风险控制-风控变量删除")]
|
||||
/// <summary>批量删除变量</summary>
|
||||
public async Task<JsonResult> 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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user