feat(risk-control): add application services
This commit is contained in:
parent
d6f5c00554
commit
352291c68b
@ -0,0 +1,35 @@
|
||||
using Fengling.RiskControl.Application.Services;
|
||||
using MediatR;
|
||||
|
||||
namespace Fengling.RiskControl.Application.Commands;
|
||||
|
||||
public record EvaluateRiskCommand : IRequest<RiskEvaluationResult>
|
||||
{
|
||||
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<string, object> Context { get; init; } = new();
|
||||
}
|
||||
|
||||
public class EvaluateRiskCommandHandler : IRequestHandler<EvaluateRiskCommand, RiskEvaluationResult>
|
||||
{
|
||||
private readonly IRiskEvaluationService _riskService;
|
||||
|
||||
public EvaluateRiskCommandHandler(IRiskEvaluationService riskService)
|
||||
{
|
||||
_riskService = riskService;
|
||||
}
|
||||
|
||||
public async Task<RiskEvaluationResult> 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
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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<LotteryCompletedEvent>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
@ -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<RiskAlertTriggeredEvent>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
37
Fengling.RiskControl.Application/Services/ILotteryService.cs
Normal file
37
Fengling.RiskControl.Application/Services/ILotteryService.cs
Normal file
@ -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<LotteryParticipationResult> ParticipateAsync(LotteryParticipationRequest request);
|
||||
Task<LotteryActivity?> GetActivityAsync(long activityId);
|
||||
Task<IEnumerable<LotteryActivity>> GetMemberActivitiesAsync(long memberId);
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
using Fengling.RiskControl.Domain.Aggregates.RiskAlerts;
|
||||
|
||||
namespace Fengling.RiskControl.Application.Services;
|
||||
|
||||
public interface IRiskAlertService
|
||||
{
|
||||
Task<RiskAlert> CreateAlertAsync(long memberId, string alertType, string description,
|
||||
RiskAlertPriority priority = RiskAlertPriority.Medium);
|
||||
Task<RiskAlert> ResolveAlertAsync(long alertId, string notes);
|
||||
Task<RiskAlert> DismissAlertAsync(long alertId, string notes);
|
||||
Task<RiskAlert> EscalateAlertAsync(long alertId);
|
||||
Task<IEnumerable<RiskAlert>> GetMemberAlertsAsync(long memberId);
|
||||
Task<IEnumerable<RiskAlert>> GetPendingAlertsAsync();
|
||||
}
|
||||
@ -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<string, object> Context { get; init; } = new();
|
||||
}
|
||||
|
||||
public record RiskEvaluationResult
|
||||
{
|
||||
public int TotalScore { get; init; }
|
||||
public RiskLevel RiskLevel { get; init; }
|
||||
public RiskRuleAction RecommendedAction { get; init; }
|
||||
public List<RiskFactorResult> 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<RiskEvaluationResult> EvaluateRiskAsync(RiskEvaluationRequest request);
|
||||
Task<bool> IsAllowedAsync(RiskEvaluationRequest request);
|
||||
}
|
||||
92
Fengling.RiskControl.Application/Services/LotteryService.cs
Normal file
92
Fengling.RiskControl.Application/Services/LotteryService.cs
Normal file
@ -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<LotteryParticipationResult> ParticipateAsync(LotteryParticipationRequest request)
|
||||
{
|
||||
var riskResult = await _riskService.EvaluateRiskAsync(new RiskEvaluationRequest
|
||||
{
|
||||
MemberId = request.MemberId,
|
||||
EntityType = "lottery",
|
||||
EntityId = request.ActivityType,
|
||||
ActionType = "execute",
|
||||
Context = new Dictionary<string, object>
|
||||
{
|
||||
["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<LotteryActivity?> GetActivityAsync(long activityId)
|
||||
{
|
||||
return _activityRepository.GetByIdAsync(activityId);
|
||||
}
|
||||
|
||||
public Task<IEnumerable<LotteryActivity>> GetMemberActivitiesAsync(long memberId)
|
||||
{
|
||||
return _activityRepository.GetByMemberIdAsync(memberId);
|
||||
}
|
||||
}
|
||||
@ -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<RiskAlert> 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<RiskAlert> 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<RiskAlert> 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<RiskAlert> 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<IEnumerable<RiskAlert>> GetMemberAlertsAsync(long memberId)
|
||||
{
|
||||
return _alertRepository.GetByMemberIdAsync(memberId);
|
||||
}
|
||||
|
||||
public Task<IEnumerable<RiskAlert>> GetPendingAlertsAsync()
|
||||
{
|
||||
return _alertRepository.GetPendingAlertsAsync();
|
||||
}
|
||||
}
|
||||
@ -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<RiskEvaluationResult> EvaluateRiskAsync(RiskEvaluationRequest request)
|
||||
{
|
||||
var rules = await _ruleRepository.GetActiveRulesAsync();
|
||||
var factors = new List<RiskFactorResult>();
|
||||
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<bool> IsAllowedAsync(RiskEvaluationRequest request)
|
||||
{
|
||||
var result = await EvaluateRiskAsync(request);
|
||||
return !result.Blocked;
|
||||
}
|
||||
|
||||
private async Task<RiskFactorResult?> 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<RiskFactorResult?> EvaluateFrequencyLimitAsync(RiskRule rule, RiskEvaluationRequest request)
|
||||
{
|
||||
var config = rule.GetConfig<FrequencyLimitConfig>();
|
||||
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<RiskFactorResult?> EvaluateAmountLimitAsync(RiskRule rule, RiskEvaluationRequest request)
|
||||
{
|
||||
var config = rule.GetConfig<AmountLimitConfig>();
|
||||
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<BlacklistConfig>();
|
||||
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<long> MemberIds { get; set; } = new();
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
using Fengling.RiskControl.Application.Commands;
|
||||
using FluentValidation;
|
||||
|
||||
namespace Fengling.RiskControl.Application.Validators;
|
||||
|
||||
public class EvaluateRiskCommandValidator : AbstractValidator<EvaluateRiskCommand>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user