From 352291c68b7ef08efd02241758dcc3f25d170165 Mon Sep 17 00:00:00 2001 From: Sam <315859133@qq.com> Date: Thu, 5 Feb 2026 15:04:02 +0800 Subject: [PATCH] feat(risk-control): add application services --- .../Commands/EvaluateRiskCommand.cs | 35 ++++ .../Events/LotteryCompletedEventHandler.cs | 32 ++++ .../Events/RiskAlertTriggeredEventHandler.cs | 36 ++++ .../Services/ILotteryService.cs | 37 ++++ .../Services/IRiskAlertService.cs | 14 ++ .../Services/IRiskEvaluationService.cs | 37 ++++ .../Services/LotteryService.cs | 92 ++++++++++ .../Services/RiskAlertService.cs | 76 +++++++++ .../Services/RiskEvaluationService.cs | 159 ++++++++++++++++++ .../EvaluateRiskCommandValidator.cs | 15 ++ 10 files changed, 533 insertions(+) create mode 100644 Fengling.RiskControl.Application/Commands/EvaluateRiskCommand.cs create mode 100644 Fengling.RiskControl.Application/Events/LotteryCompletedEventHandler.cs create mode 100644 Fengling.RiskControl.Application/Events/RiskAlertTriggeredEventHandler.cs create mode 100644 Fengling.RiskControl.Application/Services/ILotteryService.cs create mode 100644 Fengling.RiskControl.Application/Services/IRiskAlertService.cs create mode 100644 Fengling.RiskControl.Application/Services/IRiskEvaluationService.cs create mode 100644 Fengling.RiskControl.Application/Services/LotteryService.cs create mode 100644 Fengling.RiskControl.Application/Services/RiskAlertService.cs create mode 100644 Fengling.RiskControl.Application/Services/RiskEvaluationService.cs create mode 100644 Fengling.RiskControl.Application/Validators/EvaluateRiskCommandValidator.cs diff --git a/Fengling.RiskControl.Application/Commands/EvaluateRiskCommand.cs b/Fengling.RiskControl.Application/Commands/EvaluateRiskCommand.cs new file mode 100644 index 0000000..6ded6fe --- /dev/null +++ b/Fengling.RiskControl.Application/Commands/EvaluateRiskCommand.cs @@ -0,0 +1,35 @@ +using Fengling.RiskControl.Application.Services; +using MediatR; + +namespace Fengling.RiskControl.Application.Commands; + +public record EvaluateRiskCommand : IRequest +{ + public long MemberId { get; init; } + public string EntityType { get; init; } = string.Empty; + public string EntityId { get; init; } = string.Empty; + public string ActionType { get; init; } = string.Empty; + public Dictionary Context { get; init; } = new(); +} + +public class EvaluateRiskCommandHandler : IRequestHandler +{ + private readonly IRiskEvaluationService _riskService; + + public EvaluateRiskCommandHandler(IRiskEvaluationService riskService) + { + _riskService = riskService; + } + + public async Task Handle(EvaluateRiskCommand request, CancellationToken cancellationToken) + { + return await _riskService.EvaluateRiskAsync(new RiskEvaluationRequest + { + MemberId = request.MemberId, + EntityType = request.EntityType, + EntityId = request.EntityId, + ActionType = request.ActionType, + Context = request.Context + }); + } +} diff --git a/Fengling.RiskControl.Application/Events/LotteryCompletedEventHandler.cs b/Fengling.RiskControl.Application/Events/LotteryCompletedEventHandler.cs new file mode 100644 index 0000000..5863ff5 --- /dev/null +++ b/Fengling.RiskControl.Application/Events/LotteryCompletedEventHandler.cs @@ -0,0 +1,32 @@ +using Fengling.RiskControl.Domain.Aggregates.RiskScores; +using Fengling.RiskControl.Domain.Events; +using Fengling.RiskControl.Domain.Repositories; +using MediatR; + +namespace Fengling.RiskControl.Application.Events; + +public class LotteryCompletedEventHandler : INotificationHandler +{ + private readonly IRiskScoreRepository _scoreRepository; + + public LotteryCompletedEventHandler(IRiskScoreRepository scoreRepository) + { + _scoreRepository = scoreRepository; + } + + public async Task Handle(LotteryCompletedEvent notification, CancellationToken cancellationToken) + { + var score = RiskScore.Create( + notification.MemberId, + "lottery_result", + notification.ActivityId.ToString(), + expiresAt: DateTime.UtcNow.AddDays(1)); + + if (notification.IsWin && notification.WinAmount > notification.StakePoints * 5) + { + score.AddRiskFactor("big_win", 20, "赢得超过投入5倍"); + } + + await _scoreRepository.AddAsync(score); + } +} diff --git a/Fengling.RiskControl.Application/Events/RiskAlertTriggeredEventHandler.cs b/Fengling.RiskControl.Application/Events/RiskAlertTriggeredEventHandler.cs new file mode 100644 index 0000000..64b0211 --- /dev/null +++ b/Fengling.RiskControl.Application/Events/RiskAlertTriggeredEventHandler.cs @@ -0,0 +1,36 @@ +using Fengling.RiskControl.Application.Services; +using Fengling.RiskControl.Domain.Aggregates.RiskAlerts; +using Fengling.RiskControl.Domain.Events; +using MediatR; + +namespace Fengling.RiskControl.Application.Events; + +public class RiskAlertTriggeredEventHandler : INotificationHandler +{ + private readonly IRiskAlertService _alertService; + + public RiskAlertTriggeredEventHandler(IRiskAlertService alertService) + { + _alertService = alertService; + } + + public async Task Handle(RiskAlertTriggeredEvent notification, CancellationToken cancellationToken) + { + if (notification.RiskScore < 30) + return; + + var priority = notification.RiskScore switch + { + >= 80 => RiskAlertPriority.Critical, + >= 60 => RiskAlertPriority.High, + >= 40 => RiskAlertPriority.Medium, + _ => RiskAlertPriority.Low + }; + + await _alertService.CreateAlertAsync( + notification.MemberId, + notification.AlertType, + notification.Reason, + priority); + } +} diff --git a/Fengling.RiskControl.Application/Services/ILotteryService.cs b/Fengling.RiskControl.Application/Services/ILotteryService.cs new file mode 100644 index 0000000..4f36e35 --- /dev/null +++ b/Fengling.RiskControl.Application/Services/ILotteryService.cs @@ -0,0 +1,37 @@ +using Fengling.RiskControl.Domain.Aggregates.LotteryActivities; + +namespace Fengling.RiskControl.Application.Services; + +public record LotteryParticipationRequest +{ + public long MemberId { get; init; } + public string ActivityType { get; init; } = string.Empty; + public int StakePoints { get; init; } + public string? IpAddress { get; init; } + public string? DeviceId { get; init; } +} + +public record LotteryParticipationResult +{ + public long ActivityId { get; init; } + public LotteryParticipationStatus Status { get; init; } + public int? WinAmount { get; init; } + public string Message { get; init; } = string.Empty; + public RiskEvaluationResult? RiskResult { get; init; } +} + +public enum LotteryParticipationStatus +{ + Allowed = 0, + Blocked = 1, + PendingVerification = 2, + InsufficientPoints = 3, + Failed = 4 +} + +public interface ILotteryService +{ + Task ParticipateAsync(LotteryParticipationRequest request); + Task GetActivityAsync(long activityId); + Task> GetMemberActivitiesAsync(long memberId); +} diff --git a/Fengling.RiskControl.Application/Services/IRiskAlertService.cs b/Fengling.RiskControl.Application/Services/IRiskAlertService.cs new file mode 100644 index 0000000..f2b2977 --- /dev/null +++ b/Fengling.RiskControl.Application/Services/IRiskAlertService.cs @@ -0,0 +1,14 @@ +using Fengling.RiskControl.Domain.Aggregates.RiskAlerts; + +namespace Fengling.RiskControl.Application.Services; + +public interface IRiskAlertService +{ + Task CreateAlertAsync(long memberId, string alertType, string description, + RiskAlertPriority priority = RiskAlertPriority.Medium); + Task ResolveAlertAsync(long alertId, string notes); + Task DismissAlertAsync(long alertId, string notes); + Task EscalateAlertAsync(long alertId); + Task> GetMemberAlertsAsync(long memberId); + Task> GetPendingAlertsAsync(); +} diff --git a/Fengling.RiskControl.Application/Services/IRiskEvaluationService.cs b/Fengling.RiskControl.Application/Services/IRiskEvaluationService.cs new file mode 100644 index 0000000..88defd5 --- /dev/null +++ b/Fengling.RiskControl.Application/Services/IRiskEvaluationService.cs @@ -0,0 +1,37 @@ +using Fengling.RiskControl.Domain.Aggregates.RiskRules; +using Fengling.RiskControl.Domain.Aggregates.RiskScores; + +namespace Fengling.RiskControl.Application.Services; + +public record RiskEvaluationRequest +{ + public long MemberId { get; init; } + public string EntityType { get; init; } = string.Empty; + public string EntityId { get; init; } = string.Empty; + public string ActionType { get; init; } = string.Empty; + public Dictionary Context { get; init; } = new(); +} + +public record RiskEvaluationResult +{ + public int TotalScore { get; init; } + public RiskLevel RiskLevel { get; init; } + public RiskRuleAction RecommendedAction { get; init; } + public List Factors { get; init; } = new(); + public bool Blocked { get; init; } + public string Message { get; init; } = string.Empty; +} + +public record RiskFactorResult +{ + public string FactorType { get; init; } = string.Empty; + public int Points { get; init; } + public string Description { get; init; } = string.Empty; + public string RuleName { get; init; } = string.Empty; +} + +public interface IRiskEvaluationService +{ + Task EvaluateRiskAsync(RiskEvaluationRequest request); + Task IsAllowedAsync(RiskEvaluationRequest request); +} diff --git a/Fengling.RiskControl.Application/Services/LotteryService.cs b/Fengling.RiskControl.Application/Services/LotteryService.cs new file mode 100644 index 0000000..546a102 --- /dev/null +++ b/Fengling.RiskControl.Application/Services/LotteryService.cs @@ -0,0 +1,92 @@ +using Fengling.RiskControl.Domain.Aggregates.LotteryActivities; +using Fengling.RiskControl.Domain.Events; +using Fengling.RiskControl.Domain.Repositories; +using MediatR; + +namespace Fengling.RiskControl.Application.Services; + +public class LotteryService : ILotteryService +{ + private readonly ILotteryActivityRepository _activityRepository; + private readonly IRiskEvaluationService _riskService; + private readonly IMediator _mediator; + + public LotteryService( + ILotteryActivityRepository activityRepository, + IRiskEvaluationService riskService, + IMediator mediator) + { + _activityRepository = activityRepository; + _riskService = riskService; + _mediator = mediator; + } + + public async Task ParticipateAsync(LotteryParticipationRequest request) + { + var riskResult = await _riskService.EvaluateRiskAsync(new RiskEvaluationRequest + { + MemberId = request.MemberId, + EntityType = "lottery", + EntityId = request.ActivityType, + ActionType = "execute", + Context = new Dictionary + { + ["stakePoints"] = request.StakePoints, + ["activityType"] = request.ActivityType + } + }); + + if (riskResult.Blocked) + { + return new LotteryParticipationResult + { + Status = LotteryParticipationStatus.Blocked, + Message = riskResult.Message, + RiskResult = riskResult + }; + } + + var activity = LotteryActivity.Create( + request.MemberId, + request.ActivityType, + request.StakePoints, + request.IpAddress, + request.DeviceId); + + activity.MarkProcessing(); + await _activityRepository.AddAsync(activity); + + var (winAmount, isWin) = SimulateLotteryOutcome(request.StakePoints); + activity.Complete(winAmount, isWin); + + await _mediator.Publish(new LotteryCompletedEvent( + activity.Id, request.MemberId, request.StakePoints, winAmount, isWin, riskResult.TotalScore)); + + return new LotteryParticipationResult + { + ActivityId = activity.Id, + Status = LotteryParticipationStatus.Allowed, + WinAmount = winAmount, + Message = isWin ? $"恭喜中奖! 获得 {winAmount} 积分" : "未中奖", + RiskResult = riskResult + }; + } + + private (int winAmount, bool isWin) SimulateLotteryOutcome(int stakePoints) + { + var random = new Random(); + var isWin = random.NextDouble() < 0.3; + var winAmount = isWin ? stakePoints * random.Next(2, 10) : 0; + return (winAmount, isWin); + } + + public Task GetActivityAsync(long activityId) + { + return _activityRepository.GetByIdAsync(activityId); + } + + public Task> GetMemberActivitiesAsync(long memberId) + { + return _activityRepository.GetByMemberIdAsync(memberId); + } +} diff --git a/Fengling.RiskControl.Application/Services/RiskAlertService.cs b/Fengling.RiskControl.Application/Services/RiskAlertService.cs new file mode 100644 index 0000000..8f07247 --- /dev/null +++ b/Fengling.RiskControl.Application/Services/RiskAlertService.cs @@ -0,0 +1,76 @@ +using Fengling.RiskControl.Domain.Aggregates.RiskAlerts; +using Fengling.RiskControl.Domain.Events; +using Fengling.RiskControl.Domain.Repositories; +using MediatR; + +namespace Fengling.RiskControl.Application.Services; + +public class RiskAlertService : IRiskAlertService +{ + private readonly IRiskAlertRepository _alertRepository; + private readonly IMediator _mediator; + + public RiskAlertService(IRiskAlertRepository alertRepository, IMediator mediator) + { + _alertRepository = alertRepository; + _mediator = mediator; + } + + public async Task CreateAlertAsync(long memberId, string alertType, string description, + RiskAlertPriority priority = RiskAlertPriority.Medium) + { + var alert = RiskAlert.Create(memberId, alertType, description, priority); + await _alertRepository.AddAsync(alert); + + await _mediator.Publish(new RiskAlertTriggeredEvent( + alert.Id, memberId, alertType, 0, description)); + + return alert; + } + + public async Task ResolveAlertAsync(long alertId, string notes) + { + var alert = await _alertRepository.GetByIdAsync(alertId); + if (alert == null) + throw new KeyNotFoundException($"Alert not found: {alertId}"); + + alert.Resolve(notes); + await _alertRepository.UpdateAsync(alert); + + return alert; + } + + public async Task DismissAlertAsync(long alertId, string notes) + { + var alert = await _alertRepository.GetByIdAsync(alertId); + if (alert == null) + throw new KeyNotFoundException($"Alert not found: {alertId}"); + + alert.Dismiss(notes); + await _alertRepository.UpdateAsync(alert); + + return alert; + } + + public async Task EscalateAlertAsync(long alertId) + { + var alert = await _alertRepository.GetByIdAsync(alertId); + if (alert == null) + throw new KeyNotFoundException($"Alert not found: {alertId}"); + + alert.Escalate(); + await _alertRepository.UpdateAsync(alert); + + return alert; + } + + public Task> GetMemberAlertsAsync(long memberId) + { + return _alertRepository.GetByMemberIdAsync(memberId); + } + + public Task> GetPendingAlertsAsync() + { + return _alertRepository.GetPendingAlertsAsync(); + } +} diff --git a/Fengling.RiskControl.Application/Services/RiskEvaluationService.cs b/Fengling.RiskControl.Application/Services/RiskEvaluationService.cs new file mode 100644 index 0000000..3974510 --- /dev/null +++ b/Fengling.RiskControl.Application/Services/RiskEvaluationService.cs @@ -0,0 +1,159 @@ +using Fengling.RiskControl.Domain.Aggregates.RiskRules; +using Fengling.RiskControl.Domain.Aggregates.RiskScores; +using Fengling.RiskControl.Domain.Repositories; +using MediatR; + +namespace Fengling.RiskControl.Application.Services; + +public class RiskEvaluationService : IRiskEvaluationService +{ + private readonly IRiskRuleRepository _ruleRepository; + private readonly IRiskScoreRepository _scoreRepository; + private readonly IMediator _mediator; + + public RiskEvaluationService( + IRiskRuleRepository ruleRepository, + IRiskScoreRepository scoreRepository, + IMediator mediator) + { + _ruleRepository = ruleRepository; + _scoreRepository = scoreRepository; + _mediator = mediator; + } + + public async Task EvaluateRiskAsync(RiskEvaluationRequest request) + { + var rules = await _ruleRepository.GetActiveRulesAsync(); + var factors = new List(); + var totalScore = 0; + + foreach (var rule in rules) + { + var factor = await EvaluateRuleAsync(rule, request); + if (factor != null) + { + factors.Add(factor); + totalScore += factor.Points; + } + } + + var riskLevel = DetermineRiskLevel(totalScore); + var recommendedAction = DetermineAction(riskLevel); + var blocked = recommendedAction == RiskRuleAction.Block; + + return new RiskEvaluationResult + { + TotalScore = totalScore, + Factors = factors, + RiskLevel = riskLevel, + RecommendedAction = recommendedAction, + Blocked = blocked, + Message = blocked ? "操作被风险控制系统拒绝" : "操作已通过风险评估" + }; + } + + public async Task IsAllowedAsync(RiskEvaluationRequest request) + { + var result = await EvaluateRiskAsync(request); + return !result.Blocked; + } + + private async Task EvaluateRuleAsync(RiskRule rule, RiskEvaluationRequest request) + { + return rule.RuleType switch + { + RiskRuleType.FrequencyLimit => await EvaluateFrequencyLimitAsync(rule, request), + RiskRuleType.AmountLimit => await EvaluateAmountLimitAsync(rule, request), + RiskRuleType.Blacklist => EvaluateBlacklist(rule, request), + _ => null + }; + } + + private async Task EvaluateFrequencyLimitAsync(RiskRule rule, RiskEvaluationRequest request) + { + var config = rule.GetConfig(); + var recentCount = 0; + if (recentCount >= config.MaxCount) + { + return new RiskFactorResult + { + FactorType = "frequency_limit", + Points = config.Points, + Description = $"超过频率限制: {recentCount}/{config.MaxCount}", + RuleName = rule.Name + }; + } + return null; + } + + private async Task EvaluateAmountLimitAsync(RiskRule rule, RiskEvaluationRequest request) + { + var config = rule.GetConfig(); + if (request.Context.TryGetValue("amount", out var amountObj) && amountObj is int amount) + { + if (amount > config.MaxAmount) + { + return new RiskFactorResult + { + FactorType = "amount_limit", + Points = config.Points, + Description = $"超过金额限制: {amount}/{config.MaxAmount}", + RuleName = rule.Name + }; + } + } + return null; + } + + private RiskFactorResult? EvaluateBlacklist(RiskRule rule, RiskEvaluationRequest request) + { + var blacklist = rule.GetConfig(); + if (blacklist.MemberIds.Contains(request.MemberId)) + { + return new RiskFactorResult + { + FactorType = "blacklist", + Points = 100, + Description = "用户在黑名单中", + RuleName = rule.Name + }; + } + return null; + } + + private RiskLevel DetermineRiskLevel(int score) + { + return score >= 70 ? RiskLevel.High : + score >= 30 ? RiskLevel.Medium : + RiskLevel.Low; + } + + private RiskRuleAction DetermineAction(RiskLevel level) + { + return level switch + { + RiskLevel.Critical => RiskRuleAction.Block, + RiskLevel.High => RiskRuleAction.RequireVerification, + RiskLevel.Medium => RiskRuleAction.FlagForReview, + _ => RiskRuleAction.Allow + }; + } +} + +public class FrequencyLimitConfig +{ + public int MaxCount { get; set; } + public int WindowMinutes { get; set; } + public int Points { get; set; } = 30; +} + +public class AmountLimitConfig +{ + public int MaxAmount { get; set; } + public int Points { get; set; } = 40; +} + +public class BlacklistConfig +{ + public HashSet MemberIds { get; set; } = new(); +} diff --git a/Fengling.RiskControl.Application/Validators/EvaluateRiskCommandValidator.cs b/Fengling.RiskControl.Application/Validators/EvaluateRiskCommandValidator.cs new file mode 100644 index 0000000..6230099 --- /dev/null +++ b/Fengling.RiskControl.Application/Validators/EvaluateRiskCommandValidator.cs @@ -0,0 +1,15 @@ +using Fengling.RiskControl.Application.Commands; +using FluentValidation; + +namespace Fengling.RiskControl.Application.Validators; + +public class EvaluateRiskCommandValidator : AbstractValidator +{ + public EvaluateRiskCommandValidator() + { + RuleFor(x => x.MemberId).GreaterThan(0); + RuleFor(x => x.EntityType).NotEmpty().MaximumLength(50); + RuleFor(x => x.EntityId).NotEmpty().MaximumLength(100); + RuleFor(x => x.ActionType).NotEmpty().MaximumLength(50); + } +}