feat(risk-control): add application services

This commit is contained in:
Sam 2026-02-05 15:04:02 +08:00
parent d6f5c00554
commit 352291c68b
10 changed files with 533 additions and 0 deletions

View File

@ -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
});
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View 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);
}

View File

@ -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();
}

View File

@ -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);
}

View 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);
}
}

View File

@ -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();
}
}

View File

@ -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();
}

View File

@ -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);
}
}