304 lines
14 KiB
C#
304 lines
14 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.Linq;
|
||
using Qdp.Foundation.Utilities;
|
||
using YLErp.BLL;
|
||
using YLErp.DBModels;
|
||
using YLErp.Enums;
|
||
|
||
namespace YLErp.Modules.RiskEngine
|
||
{
|
||
/// <summary>
|
||
/// 新风控通用试算服务,负责按业务触发时点执行规则、保存试算结果并校验二次确认。
|
||
/// </summary>
|
||
public class RiskTrialService : YLBaseService
|
||
{
|
||
private readonly IYcLogger _logger = LogFactory.GetLogger("RiskTrialService");
|
||
|
||
/// <summary>
|
||
/// 使用当前业务服务的用户和数据库上下文创建新风控试算服务。
|
||
/// </summary>
|
||
/// <param name="baseService">当前业务服务。</param>
|
||
public RiskTrialService(YLBaseService baseService) : base(baseService)
|
||
{
|
||
}
|
||
|
||
/// <summary>
|
||
/// 执行一次独立于老风控的新风控试算并保存试算记录。
|
||
/// quotaTrial 在现有交易确认和平仓链路中按“每次试算快照/历史流水”使用,不作为审批状态表;二次确认会重新试算并生成新的快照。
|
||
/// </summary>
|
||
/// <param name="context">包含交易、触发时点及业务日期的风控上下文。</param>
|
||
/// <param name="trialSource">试算来源,由具体业务场景约定。</param>
|
||
/// <param name="confirmation">二次确认信息;首次试算传空。</param>
|
||
/// <returns>包含阻断、审批、提示及本次试算记录ID的统一结果。</returns>
|
||
public RiskTrialResult CheckRisk(RiskContext context, int trialSource, RiskTrialConfirmation confirmation = null)
|
||
{
|
||
if (context == null)
|
||
{
|
||
throw new ArgumentNullException(nameof(context));
|
||
}
|
||
if (context.TradeId <= 0)
|
||
{
|
||
throw new ArgumentException("交易ID必须大于0", nameof(context));
|
||
}
|
||
if (string.IsNullOrWhiteSpace(context.TriggerPoint))
|
||
{
|
||
throw new ArgumentException("风控触发时点不能为空", nameof(context));
|
||
}
|
||
|
||
var ignoreRiskRuleIds = new HashSet<string>();
|
||
var confirmationExpired = false;
|
||
if (confirmation != null)
|
||
{
|
||
confirmationExpired = !TryLoadIgnoreRiskRuleIds(context, trialSource, confirmation, ignoreRiskRuleIds);
|
||
}
|
||
|
||
var riskResult = new RiskEngineService(this).EvaluateRisk(context, context.TriggerPoint);
|
||
// 与交易确认流程保持一致:二次确认放行范围包含“需审批”和“提示”,避免已确认过的提示规则重复弹出。
|
||
var currentApprovalRules = riskResult.TriggeredRules
|
||
.Where(rule => rule.ControlStrategy == RiskControlStrategy.Approval)
|
||
.ToList();
|
||
var currentTipRules = riskResult.TriggeredRules
|
||
.Where(rule => rule.ControlStrategy == RiskControlStrategy.ShowTip)
|
||
.ToList();
|
||
// 二次提交时,若首次试算记录有效,则剔除已确认过的审批和提示规则;记录过期或上下文不匹配时重新展示全部当前命中的审批和提示规则。
|
||
var pendingApprovalRules = confirmationExpired
|
||
? currentApprovalRules
|
||
: currentApprovalRules.Where(rule => !ignoreRiskRuleIds.Contains(rule.RuleId)).ToList();
|
||
var pendingTipRules = confirmationExpired
|
||
? currentTipRules
|
||
: currentTipRules.Where(rule => !ignoreRiskRuleIds.Contains(rule.RuleId)).ToList();
|
||
var trial = CreateQuotaTrial(context, trialSource, riskResult, pendingApprovalRules, pendingTipRules);
|
||
// 与交易确认 RunQuotaTrial/RunNewRiskTrial 保持一致:每次风控执行保存一条 quotaTrial 快照,用于详情展示、历史追溯和二次确认时效校验,不回写上一条记录为已确认。
|
||
new Modules.RiskModule.QuotaMonitorService(this).SaveQuotaTrial(trial);
|
||
|
||
var result = new RiskTrialResult
|
||
{
|
||
Blocked = riskResult.Blocked,
|
||
NeedApproval = pendingApprovalRules.Any(),
|
||
ShowTip = pendingTipRules.Any(),
|
||
ConfirmationExpired = confirmationExpired,
|
||
TrialDataId = trial.id,
|
||
Message = trial.RiskWarningDetails,
|
||
ApprovalRuleIds = pendingApprovalRules.Concat(pendingTipRules)
|
||
.Select(rule => rule.RuleId)
|
||
.Where(ruleId => !string.IsNullOrWhiteSpace(ruleId))
|
||
.Distinct()
|
||
.ToList()
|
||
};
|
||
result.Passed = !result.Blocked && !result.NeedApproval;
|
||
|
||
_logger.Info($"[新风控试算] TradeId: {context.TradeId}, TriggerPoint: {context.TriggerPoint}, TrialSource: {trialSource}, TrialDataId: {result.TrialDataId}, Passed: {result.Passed}, Blocked: {result.Blocked}, NeedApproval: {result.NeedApproval}, ShowTip: {result.ShowTip}, ConfirmationExpired: {result.ConfirmationExpired}");
|
||
return result;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 校验二次确认记录,并恢复首次试算中已确认的审批和提示规则ID。
|
||
/// </summary>
|
||
private bool TryLoadIgnoreRiskRuleIds(RiskContext context, int trialSource, RiskTrialConfirmation confirmation, ISet<string> ignoreRiskRuleIds)
|
||
{
|
||
if (confirmation.TrialDataId <= 0 || confirmation.ExpireSeconds <= 0)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
var trial = DbContext.quotaTrial.FirstOrDefault(item => item.id == confirmation.TrialDataId);
|
||
if (trial == null
|
||
|| trial.TradeId != context.TradeId
|
||
|| trial.TrialSource != trialSource
|
||
|| !trial.OptDate.HasValue
|
||
|| !HasMatchingRequestContext(trial.RiskWarningDetails, context))
|
||
{
|
||
return false;
|
||
}
|
||
if (DateTime.Now - trial.OptDate.Value > TimeSpan.FromSeconds(confirmation.ExpireSeconds))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
foreach (var ruleId in GetIgnoreRiskRuleIds(confirmation, trial.RiskWarningDetails))
|
||
{
|
||
ignoreRiskRuleIds.Add(ruleId);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 校验二次提交与首次试算的触发点和平仓日期一致,避免复用其他业务参数生成的审批记录。
|
||
/// </summary>
|
||
private static bool HasMatchingRequestContext(string details, RiskContext context)
|
||
{
|
||
var expectedContext = BuildRequestContext(context);
|
||
return (details ?? string.Empty)
|
||
.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
|
||
.Any(line => string.Equals(line, expectedContext, StringComparison.Ordinal));
|
||
}
|
||
|
||
private static IEnumerable<string> GetIgnoreRiskRuleIds(RiskTrialConfirmation confirmation, string details)
|
||
{
|
||
var ignoreRiskRuleIds = confirmation.IgnoreRiskRuleIds ?? new List<string>();
|
||
return ignoreRiskRuleIds.Any()
|
||
? ignoreRiskRuleIds.Where(ruleId => !string.IsNullOrWhiteSpace(ruleId)).Select(ruleId => ruleId.Trim()).Distinct()
|
||
: ParseIgnoreRiskRuleIds(details);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 从系统生成的审批和提示规则明细中恢复规则ID,兼容未回传忽略规则ID的旧入口。
|
||
/// </summary>
|
||
private static IEnumerable<string> ParseIgnoreRiskRuleIds(string details)
|
||
{
|
||
const string ruleIdPrefix = "规则ID:";
|
||
var inConfirmableSection = false;
|
||
foreach (var line in (details ?? string.Empty).Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries))
|
||
{
|
||
if (line.StartsWith("[风控引擎] 规则触发:", StringComparison.Ordinal))
|
||
{
|
||
inConfirmableSection = line.StartsWith("[风控引擎] 规则触发:需审批", StringComparison.Ordinal)
|
||
|| line.StartsWith("[风控引擎] 规则触发:提示", StringComparison.Ordinal);
|
||
continue;
|
||
}
|
||
if (!inConfirmableSection)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
var ruleIdStart = line.IndexOf(ruleIdPrefix, StringComparison.Ordinal);
|
||
if (ruleIdStart < 0)
|
||
{
|
||
continue;
|
||
}
|
||
ruleIdStart += ruleIdPrefix.Length;
|
||
var ruleIdEnd = line.IndexOf(';', ruleIdStart);
|
||
var ruleId = (ruleIdEnd < 0 ? line.Substring(ruleIdStart) : line.Substring(ruleIdStart, ruleIdEnd - ruleIdStart)).Trim();
|
||
if (!string.IsNullOrWhiteSpace(ruleId))
|
||
{
|
||
yield return ruleId;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 将新风控引擎结果转换为现有试算记录,供详情页面和二次确认复用。
|
||
/// </summary>
|
||
private QuotaTrial CreateQuotaTrial(RiskContext context, int trialSource, RiskResult riskResult, IReadOnlyCollection<TriggeredRuleInfo> pendingApprovalRules, IReadOnlyCollection<TriggeredRuleInfo> pendingTipRules)
|
||
{
|
||
// 业务入口已通过Find加载交易,优先复用当前上下文跟踪的实体,避免重复查询数据库。
|
||
var trade = DbContext.trade.Local.FirstOrDefault(item => item.id == context.TradeId)
|
||
?? DbContext.trade.Find(context.TradeId);
|
||
var trial = new QuotaTrial
|
||
{
|
||
TradeId = context.TradeId,
|
||
TradeNumber = trade?.TradeNumber ?? string.Empty,
|
||
ClientName = trade?.ClientName ?? string.Empty,
|
||
TrialSource = trialSource,
|
||
TrialStatus = riskResult.Blocked
|
||
? QuotaTrialStatusEnum.Error
|
||
: pendingApprovalRules.Any() ? QuotaTrialStatusEnum.RiskWarning : QuotaTrialStatusEnum.Success,
|
||
NewRiskBlocked = riskResult.Blocked,
|
||
NewRiskNeedApproval = pendingApprovalRules.Any(),
|
||
RiskWarningDetails = BuildRiskDetails(context, riskResult, pendingApprovalRules, pendingTipRules)
|
||
};
|
||
trial.ApprovalRuleIds = pendingApprovalRules.Concat(pendingTipRules)
|
||
.Select(rule => rule.RuleId)
|
||
.Where(ruleId => !string.IsNullOrWhiteSpace(ruleId))
|
||
.Distinct()
|
||
.ToList();
|
||
return trial;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 按控制策略生成与现有试算详情页面兼容的规则明细。
|
||
/// </summary>
|
||
private static string BuildRiskDetails(RiskContext context, RiskResult riskResult, IReadOnlyCollection<TriggeredRuleInfo> pendingApprovalRules, IReadOnlyCollection<TriggeredRuleInfo> pendingTipRules)
|
||
{
|
||
var lines = new List<string>
|
||
{
|
||
BuildRequestContext(context)
|
||
};
|
||
AppendRuleDetails(lines, "禁止", riskResult.TriggeredRules.Where(rule => rule.ControlStrategy == RiskControlStrategy.Block));
|
||
AppendRuleDetails(lines, "需审批", pendingApprovalRules);
|
||
AppendRuleDetails(lines, "提示", pendingTipRules);
|
||
return string.Join("\n", lines);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 生成首次试算的业务请求标识,供二次确认严格校验业务参数。
|
||
/// </summary>
|
||
private static string BuildRequestContext(RiskContext context)
|
||
{
|
||
return $"[风控引擎] 请求上下文:触发点:{GetTriggerPointDisplayName(context.TriggerPoint)};平仓日期:{context.UnwindDate?.Date:yyyy-MM-dd};支付日期:{context.PayDate?.Date:yyyy-MM-dd}";
|
||
}
|
||
|
||
private static string GetTriggerPointDisplayName(string triggerPoint)
|
||
{
|
||
return triggerPoint switch
|
||
{
|
||
"BOOK_CONFIRM" => "簿记交易确认",
|
||
"CLOSE_REVIEW" => "平仓审核提交",
|
||
"UPLOAD_CONFIRMATION" => "上传确认书",
|
||
"EVENT_TRIGGER" => "事件发生时",
|
||
_ => triggerPoint ?? string.Empty
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// 追加指定控制策略的规则明细。
|
||
/// </summary>
|
||
private static void AppendRuleDetails(ICollection<string> lines, string strategyName, IEnumerable<TriggeredRuleInfo> rules)
|
||
{
|
||
var ruleList = rules.ToList();
|
||
if (!ruleList.Any())
|
||
{
|
||
return;
|
||
}
|
||
|
||
lines.Add($"[风控引擎] 规则触发:{strategyName}");
|
||
foreach (var rule in ruleList)
|
||
{
|
||
var detail = $"应用ID:{rule.ApplicationId?.ToString() ?? "-"};规则ID:{rule.RuleId};规则名称:{rule.RuleName};规则说明:{rule.RuleText}";
|
||
if (!string.IsNullOrWhiteSpace(rule.Message))
|
||
{
|
||
detail += $";信息:{rule.Message}";
|
||
}
|
||
lines.Add(detail);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 新风控二次确认参数。
|
||
/// </summary>
|
||
public class RiskTrialConfirmation
|
||
{
|
||
/// <summary>
|
||
/// 首次风控试算记录ID。
|
||
/// </summary>
|
||
public int TrialDataId { get; set; }
|
||
|
||
/// <summary>
|
||
/// 首次试算允许确认放行的有效时长,单位为秒。
|
||
/// </summary>
|
||
public int ExpireSeconds { get; set; }
|
||
|
||
/// <summary>
|
||
/// 首次试算返回并经前端回传的需忽略规则ID。
|
||
/// </summary>
|
||
public List<string> IgnoreRiskRuleIds { get; set; } = new List<string>();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 新风控通用试算结果。
|
||
/// </summary>
|
||
public class RiskTrialResult
|
||
{
|
||
public bool Passed { get; set; }
|
||
public bool Blocked { get; set; }
|
||
public bool NeedApproval { get; set; }
|
||
public bool ShowTip { get; set; }
|
||
public bool ConfirmationExpired { get; set; }
|
||
public int TrialDataId { get; set; }
|
||
public string Message { get; set; }
|
||
public List<string> ApprovalRuleIds { get; set; } = new List<string>();
|
||
}
|
||
}
|