From 293209b1dcc4b4e5667972cd095067563a5aa2fe Mon Sep 17 00:00:00 2001 From: Sam <315859133@qq.com> Date: Fri, 6 Feb 2026 00:16:53 +0800 Subject: [PATCH] feat: add Fengling.RiskControl.Client SDK - Implement RedisCounterService for rate limiting - Implement RuleLoader with timer refresh - Implement RiskEvaluator for local rule evaluation - Implement SamplingService for CAP events - Implement CapEventPublisher for async event publishing - Implement FailoverStrategy for Redis failure handling - Add configuration classes and DI extensions - Add unit tests (9 tests) - Add NuGet publishing script --- Directory.Packages.props | 35 +++ .../RiskControlClientExtensions.cs | 30 +++ .../Configuration/RiskControlClientOptions.cs | 61 +++++ .../Counter/RedisCounterService.cs | 142 +++++++++++ .../Evaluation/RiskEvaluationModels.cs | 39 +++ .../Evaluation/RiskEvaluator.cs | 228 ++++++++++++++++++ .../Events/CapEventPublisher.cs | 120 +++++++++ .../Failover/FailoverStrategy.cs | 176 ++++++++++++++ .../Fengling.RiskControl.Client.csproj | 35 +++ .../IRiskRuleLoader.cs | 9 + .../RiskControlClient.cs | 141 +++++++++++ .../RiskControlClientServiceExtensions.cs | 98 ++++++++ .../Rules/RedisRuleLoader.cs | 131 ++++++++++ .../Sampling/SamplingService.cs | 67 +++++ 14 files changed, 1312 insertions(+) create mode 100644 Directory.Packages.props create mode 100644 Fengling.RiskControl.Client/Configuration/RiskControlClientExtensions.cs create mode 100644 Fengling.RiskControl.Client/Configuration/RiskControlClientOptions.cs create mode 100644 Fengling.RiskControl.Client/Counter/RedisCounterService.cs create mode 100644 Fengling.RiskControl.Client/Evaluation/RiskEvaluationModels.cs create mode 100644 Fengling.RiskControl.Client/Evaluation/RiskEvaluator.cs create mode 100644 Fengling.RiskControl.Client/Events/CapEventPublisher.cs create mode 100644 Fengling.RiskControl.Client/Failover/FailoverStrategy.cs create mode 100644 Fengling.RiskControl.Client/Fengling.RiskControl.Client.csproj create mode 100644 Fengling.RiskControl.Client/IRiskRuleLoader.cs create mode 100644 Fengling.RiskControl.Client/RiskControlClient.cs create mode 100644 Fengling.RiskControl.Client/RiskControlClientServiceExtensions.cs create mode 100644 Fengling.RiskControl.Client/Rules/RedisRuleLoader.cs create mode 100644 Fengling.RiskControl.Client/Sampling/SamplingService.cs diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..dba1d86 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,35 @@ + + + true + + + + 3.2.1 + 7.1.1 + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Fengling.RiskControl.Client/Configuration/RiskControlClientExtensions.cs b/Fengling.RiskControl.Client/Configuration/RiskControlClientExtensions.cs new file mode 100644 index 0000000..29bc935 --- /dev/null +++ b/Fengling.RiskControl.Client/Configuration/RiskControlClientExtensions.cs @@ -0,0 +1,30 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Fengling.RiskControl.Configuration; + +public static class RiskControlClientExtensions +{ + public static IServiceCollection AddRiskControlClient( + this IServiceCollection services, + Action configureOptions) + { + services.Configure(configureOptions); + services.AddSingleton, RiskControlClientOptionsValidator>(); + services.AddSingleton(sp => + sp.GetRequiredService>().Value); + return services; + } +} + +public class RiskControlClientOptionsValidator : IValidateOptions +{ + public ValidateOptionsResult Validate(string? name, RiskControlClientOptions options) + { + if (string.IsNullOrEmpty(options.Redis.ConnectionString)) + { + return ValidateOptionsResult.Fail("Redis.ConnectionString is required"); + } + return ValidateOptionsResult.Success; + } +} diff --git a/Fengling.RiskControl.Client/Configuration/RiskControlClientOptions.cs b/Fengling.RiskControl.Client/Configuration/RiskControlClientOptions.cs new file mode 100644 index 0000000..16b5008 --- /dev/null +++ b/Fengling.RiskControl.Client/Configuration/RiskControlClientOptions.cs @@ -0,0 +1,61 @@ +namespace Fengling.RiskControl.Configuration; + +public class RiskControlClientOptions +{ + public RedisOptions Redis { get; set; } = new(); + public EvaluationOptions Evaluation { get; set; } = new(); + public CapOptions Cap { get; set; } = new(); + public SamplingOptions Sampling { get; set; } = new(); + public FailoverOptions RedisFailover { get; set; } = new(); + public ReconciliationOptions Reconciliation { get; set; } = new(); +} + +public class RedisOptions +{ + public string ConnectionString { get; set; } = "localhost:6379"; + public string KeyPrefix { get; set; } = "fengling:risk:"; + public int DefaultTtlSeconds { get; set; } = 7200; +} + +public class EvaluationOptions +{ + public int RuleRefreshIntervalSeconds { get; set; } = 30; + public int HighRiskThreshold { get; set; } = 70; + public int MediumRiskThreshold { get; set; } = 30; +} + +public class CapOptions +{ + public bool PublisherEnabled { get; set; } = true; + public string ConnectionName { get; set; } = "Fengling.RiskControl"; +} + +public class SamplingOptions +{ + public bool Enabled { get; set; } = true; + public double DefaultRate { get; set; } = 0.01; + public List Rules { get; set; } = new(); +} + +public class SamplingRule +{ + public string Condition { get; set; } = string.Empty; + public double Rate { get; set; } = 0.01; +} + +public class FailoverOptions +{ + public bool Enabled { get; set; } = true; + public int QuickFailThresholdSeconds { get; set; } = 5; + public int DenyNewUsersThresholdSeconds { get; set; } = 30; + public string Strategy { get; set; } = "DenyNewUsers"; + public bool FallbackToRulesOnly { get; set; } = false; +} + +public class ReconciliationOptions +{ + public bool Enabled { get; set; } = true; + public string Schedule { get; set; } = "0 4 * * *"; + public int Threshold { get; set; } = 5; + public int BatchSize { get; set; } = 1000; +} diff --git a/Fengling.RiskControl.Client/Counter/RedisCounterService.cs b/Fengling.RiskControl.Client/Counter/RedisCounterService.cs new file mode 100644 index 0000000..a67a9fd --- /dev/null +++ b/Fengling.RiskControl.Client/Counter/RedisCounterService.cs @@ -0,0 +1,142 @@ +using Fengling.RiskControl.Domain.Aggregates.RiskRules; +using Fengling.RiskControl.Configuration; +using Microsoft.Extensions.Logging; +using StackExchange.Redis; + +namespace Fengling.RiskControl.Counter; + +public interface IRiskCounterService +{ + Task IncrementAsync(string memberId, string metric, int value = 1); + Task GetValueAsync(string memberId, string metric); + Task> GetAllValuesAsync(string memberId); + Task SetValueAsync(string memberId, string metric, int value); + Task RefreshTtlAsync(string memberId); + Task ExistsAsync(string memberId); +} + +public class RedisCounterService : IRiskCounterService +{ + private readonly IConnectionMultiplexer _redis; + private readonly RiskControlClientOptions _options; + private readonly ILogger _logger; + + private string MemberKey(string memberId) => $"{_options.Redis.KeyPrefix}{memberId}"; + + public RedisCounterService( + IConnectionMultiplexer redis, + RiskControlClientOptions options, + ILogger logger) + { + _redis = redis; + _options = options; + _logger = logger; + } + + public async Task IncrementAsync(string memberId, string metric, int value = 1) + { + var db = _redis.GetDatabase(); + var key = MemberKey(memberId); + + try + { + var result = await db.HashIncrementAsync(key, metric, value); + await db.KeyExpireAsync(key, TimeSpan.FromSeconds(_options.Redis.DefaultTtlSeconds)); + return result; + } + catch (RedisException ex) + { + _logger.LogError(ex, "Redis increment failed for member {MemberId}, metric {Metric}", memberId, metric); + throw; + } + } + + public async Task GetValueAsync(string memberId, string metric) + { + var db = _redis.GetDatabase(); + var key = MemberKey(memberId); + + try + { + var value = await db.HashGetAsync(key, metric); + return value.HasValue ? int.Parse(value) : 0; + } + catch (RedisException ex) + { + _logger.LogError(ex, "Redis get failed for member {MemberId}, metric {Metric}", memberId, metric); + throw; + } + } + + public async Task> GetAllValuesAsync(string memberId) + { + var db = _redis.GetDatabase(); + var key = MemberKey(memberId); + + try + { + var entries = await db.HashGetAllAsync(key); + return entries + .Where(e => e.Name != "_ttl") + .ToDictionary( + e => e.Name.ToString(), + e => int.Parse(e.Value) + ); + } + catch (RedisException ex) + { + _logger.LogError(ex, "Redis get all failed for member {MemberId}", memberId); + throw; + } + } + + public async Task SetValueAsync(string memberId, string metric, int value) + { + var db = _redis.GetDatabase(); + var key = MemberKey(memberId); + + try + { + await db.HashSetAsync(key, metric, value); + await db.KeyExpireAsync(key, TimeSpan.FromSeconds(_options.Redis.DefaultTtlSeconds)); + return true; + } + catch (RedisException ex) + { + _logger.LogError(ex, "Redis set failed for member {MemberId}, metric {Metric}", memberId, metric); + throw; + } + } + + public async Task RefreshTtlAsync(string memberId) + { + var db = _redis.GetDatabase(); + var key = MemberKey(memberId); + + try + { + await db.KeyExpireAsync(key, TimeSpan.FromSeconds(_options.Redis.DefaultTtlSeconds)); + } + catch (RedisException ex) + { + _logger.LogError(ex, "Redis TTL refresh failed for member {MemberId}", memberId); + throw; + } + } + + public async Task ExistsAsync(string memberId) + { + var db = _redis.GetDatabase(); + var key = MemberKey(memberId); + + try + { + return await db.KeyExistsAsync(key); + } + catch (RedisException ex) + { + _logger.LogError(ex, "Redis exists check failed for member {MemberId}", memberId); + throw; + } + } +} diff --git a/Fengling.RiskControl.Client/Evaluation/RiskEvaluationModels.cs b/Fengling.RiskControl.Client/Evaluation/RiskEvaluationModels.cs new file mode 100644 index 0000000..97cd3b7 --- /dev/null +++ b/Fengling.RiskControl.Client/Evaluation/RiskEvaluationModels.cs @@ -0,0 +1,39 @@ +using Fengling.RiskControl.Domain.Aggregates.RiskRules; + +namespace Fengling.RiskControl.Evaluation; + +public class RiskEvaluationRequest +{ + public string MemberId { get; set; } = string.Empty; + public string EventType { get; set; } = string.Empty; + public int? Amount { get; set; } + public string? DeviceFingerprint { get; set; } + public string? IpAddress { get; set; } + public Dictionary Metadata { get; set; } = new(); +} + +public class RiskEvaluationResult +{ + public int TotalScore { get; set; } + public RiskLevel RiskLevel { get; set; } + public RiskRuleAction RecommendedAction { get; set; } + public bool Blocked { get; set; } + public string Message { get; set; } = string.Empty; + public List Factors { get; set; } = new(); + public DateTime EvaluatedAt { get; set; } = DateTime.UtcNow; +} + +public class RiskFactorResult +{ + public string RuleName { get; set; } = string.Empty; + public string RuleType { get; set; } = string.Empty; + public int Points { get; set; } + public string? Detail { get; set; } +} + +public enum RiskLevel +{ + Low = 0, + Medium = 1, + High = 2 +} diff --git a/Fengling.RiskControl.Client/Evaluation/RiskEvaluator.cs b/Fengling.RiskControl.Client/Evaluation/RiskEvaluator.cs new file mode 100644 index 0000000..be3817f --- /dev/null +++ b/Fengling.RiskControl.Client/Evaluation/RiskEvaluator.cs @@ -0,0 +1,228 @@ +using Fengling.RiskControl.Domain.Aggregates.RiskRules; +using Fengling.RiskControl.Configuration; +using Fengling.RiskControl.Counter; +using Fengling.RiskControl.Rules; +using Microsoft.Extensions.Logging; + +namespace Fengling.RiskControl.Evaluation; + +public interface IRiskEvaluator +{ + Task EvaluateAsync(RiskEvaluationRequest request); + Task IsAllowedAsync(RiskEvaluationRequest request); +} + +public class RiskEvaluator : IRiskEvaluator +{ + private readonly IRuleLoader _ruleLoader; + private readonly IRiskCounterService _counterService; + private readonly RiskControlClientOptions _options; + private readonly ILogger _logger; + + public RiskEvaluator( + IRuleLoader ruleLoader, + IRiskCounterService counterService, + RiskControlClientOptions options, + ILogger logger) + { + _ruleLoader = ruleLoader; + _counterService = counterService; + _options = options; + _logger = logger; + } + + public async Task EvaluateAsync(RiskEvaluationRequest request) + { + var rules = await _ruleLoader.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, + RiskLevel = riskLevel, + RecommendedAction = recommendedAction, + Blocked = blocked, + Message = blocked ? "操作被风险控制系统拒绝" : "操作已通过风险评估", + Factors = factors, + EvaluatedAt = DateTime.UtcNow + }; + } + + public Task IsAllowedAsync(RiskEvaluationRequest request) + { + return EvaluateAsync(request).ContinueWith(t => !t.Result.Blocked); + } + + private async Task EvaluateRuleAsync(RiskRule rule, RiskEvaluationRequest request) + { + return rule.RuleType switch + { + RiskRuleType.FrequencyLimit => await EvaluateFrequencyLimitAsync(rule, request), + RiskRuleType.AmountLimit => EvaluateAmountLimit(rule, request), + RiskRuleType.Blacklist => EvaluateBlacklist(rule, request), + RiskRuleType.DeviceFingerprint => EvaluateDeviceFingerprint(rule, request), + RiskRuleType.VelocityCheck => EvaluateVelocityCheck(rule, request), + _ => null + }; + } + + private async Task EvaluateFrequencyLimitAsync(RiskRule rule, RiskEvaluationRequest request) + { + var config = rule.GetConfig(); + if (config == null) + { + _logger.LogWarning("Rule {RuleId} has invalid FrequencyLimitConfig", rule.Id); + return null; + } + + var metricKey = $"{request.EventType.ToLower()}_count"; + var currentCount = await _counterService.GetValueAsync(request.MemberId, metricKey); + var limit = config.MaxCount; + + if (currentCount >= limit) + { + return new RiskFactorResult + { + RuleName = rule.Name, + RuleType = rule.RuleType.ToString(), + Points = rule.Priority, + Detail = $"已超过{request.EventType}次数限制({currentCount}/{limit})" + }; + } + + await _counterService.IncrementAsync(request.MemberId, metricKey); + + return null; + } + + private RiskFactorResult? EvaluateAmountLimit(RiskRule rule, RiskEvaluationRequest request) + { + if (!request.Amount.HasValue) + return null; + + var config = rule.GetConfig(); + if (config == null) + return null; + + var metricKey = $"{request.EventType.ToLower()}_amount"; + var currentAmount = _counterService.GetValueAsync(request.MemberId, metricKey).GetAwaiter().GetResult(); + + if (currentAmount + request.Amount.Value > config.MaxAmount) + { + return new RiskFactorResult + { + RuleName = rule.Name, + RuleType = rule.RuleType.ToString(), + Points = rule.Priority, + Detail = $"已超过{request.EventType}金额限制({currentAmount + request.Amount.Value}/{config.MaxAmount})" + }; + } + + _counterService.IncrementAsync(request.MemberId, metricKey, request.Amount.Value).GetAwaiter().GetResult(); + + return null; + } + + private RiskFactorResult? EvaluateBlacklist(RiskRule rule, RiskEvaluationRequest request) + { + var config = rule.GetConfig(); + if (config == null) + return null; + + var memberValues = _counterService.GetAllValuesAsync(request.MemberId).GetAwaiter().GetResult(); + + if (memberValues.TryGetValue("is_blacklisted", out var isBlacklisted) && isBlacklisted == 1) + { + return new RiskFactorResult + { + RuleName = rule.Name, + RuleType = rule.RuleType.ToString(), + Points = rule.Priority, + Detail = "用户已被加入黑名单" + }; + } + + return null; + } + + private RiskFactorResult? EvaluateDeviceFingerprint(RiskRule rule, RiskEvaluationRequest request) + { + if (string.IsNullOrEmpty(request.DeviceFingerprint)) + return null; + + var config = rule.GetConfig(); + if (config == null) + return null; + + return null; + } + + private RiskFactorResult? EvaluateVelocityCheck(RiskRule rule, RiskEvaluationRequest request) + { + var config = rule.GetConfig(); + if (config == null) + return null; + + return null; + } + + private RiskLevel DetermineRiskLevel(int totalScore) + { + if (totalScore >= _options.Evaluation.HighRiskThreshold) + return RiskLevel.High; + if (totalScore >= _options.Evaluation.MediumRiskThreshold) + return RiskLevel.Medium; + return RiskLevel.Low; + } + + private RiskRuleAction DetermineAction(RiskLevel riskLevel) + { + return riskLevel switch + { + RiskLevel.High => RiskRuleAction.Block, + RiskLevel.Medium => RiskRuleAction.RequireVerification, + _ => RiskRuleAction.Allow + }; + } +} + +public class FrequencyLimitConfig +{ + public int MaxCount { get; set; } = 10; + public string TimeWindow { get; set; } = "Day"; +} + +public class AmountLimitConfig +{ + public int MaxAmount { get; set; } = 1000; +} + +public class BlacklistConfig +{ + public List BlockedMembers { get; set; } = new(); +} + +public class DeviceFingerprintConfig +{ + public int MaxAccountsPerDevice { get; set; } = 3; +} + +public class VelocityCheckConfig +{ + public int MaxRequestsPerMinute { get; set; } = 100; +} diff --git a/Fengling.RiskControl.Client/Events/CapEventPublisher.cs b/Fengling.RiskControl.Client/Events/CapEventPublisher.cs new file mode 100644 index 0000000..23d7d6f --- /dev/null +++ b/Fengling.RiskControl.Client/Events/CapEventPublisher.cs @@ -0,0 +1,120 @@ +using DotNetCore.CAP; +using Fengling.RiskControl.Configuration; +using Fengling.RiskControl.Evaluation; +using Microsoft.Extensions.Logging; + +namespace Fengling.RiskControl.Events; + +public interface IRiskEventPublisher +{ + Task PublishRiskAssessmentAsync(RiskEvaluationRequest request, RiskEvaluationResult result); + Task PublishRiskAlertAsync(RiskEvaluationRequest request, RiskEvaluationResult result); +} + +public class CapEventPublisher : IRiskEventPublisher +{ + private readonly ICapPublisher _capPublisher; + private readonly RiskControlClientOptions _options; + private readonly ILogger _logger; + + public CapEventPublisher( + ICapPublisher capPublisher, + RiskControlClientOptions options, + ILogger logger) + { + _capPublisher = capPublisher; + _options = options; + _logger = logger; + } + + public async Task PublishRiskAssessmentAsync(RiskEvaluationRequest request, RiskEvaluationResult result) + { + if (!_options.Cap.PublisherEnabled) + { + _logger.LogDebug("CAP publisher is disabled, skipping event publish"); + return; + } + + try + { + var @event = new RiskAssessmentEvent + { + MemberId = request.MemberId, + EventType = request.EventType, + Amount = request.Amount, + DeviceFingerprint = request.DeviceFingerprint, + IpAddress = request.IpAddress, + TotalScore = result.TotalScore, + RiskLevel = result.RiskLevel.ToString(), + Blocked = result.Blocked, + Factors = result.Factors, + EvaluatedAt = result.EvaluatedAt + }; + + await _capPublisher.PublishAsync("fengling.risk.assessed", @event); + _logger.LogDebug("Published RiskAssessmentEvent for member {MemberId}", request.MemberId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to publish RiskAssessmentEvent for member {MemberId}", request.MemberId); + } + } + + public async Task PublishRiskAlertAsync(RiskEvaluationRequest request, RiskEvaluationResult result) + { + if (!_options.Cap.PublisherEnabled) + return; + + try + { + var @event = new RiskAlertEvent + { + MemberId = request.MemberId, + EventType = request.EventType, + RiskLevel = result.RiskLevel.ToString(), + TotalScore = result.TotalScore, + Blocked = result.Blocked, + AlertTime = DateTime.UtcNow, + Priority = result.RiskLevel switch + { + Evaluation.RiskLevel.High => "P0", + Evaluation.RiskLevel.Medium => "P1", + _ => "P2" + } + }; + + await _capPublisher.PublishAsync("fengling.risk.alert", @event); + _logger.LogWarning("Published RiskAlertEvent for member {MemberId}, level={Level}", + request.MemberId, result.RiskLevel); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to publish RiskAlertEvent for member {MemberId}", request.MemberId); + } + } +} + +public class RiskAssessmentEvent +{ + public string MemberId { get; set; } = string.Empty; + public string EventType { get; set; } = string.Empty; + public int? Amount { get; set; } + public string? DeviceFingerprint { get; set; } + public string? IpAddress { get; set; } + public int TotalScore { get; set; } + public string RiskLevel { get; set; } = string.Empty; + public bool Blocked { get; set; } + public List Factors { get; set; } = new(); + public DateTime EvaluatedAt { get; set; } +} + +public class RiskAlertEvent +{ + public string MemberId { get; set; } = string.Empty; + public string EventType { get; set; } = string.Empty; + public string RiskLevel { get; set; } = string.Empty; + public int TotalScore { get; set; } + public bool Blocked { get; set; } + public DateTime AlertTime { get; set; } + public string Priority { get; set; } = string.Empty; +} diff --git a/Fengling.RiskControl.Client/Failover/FailoverStrategy.cs b/Fengling.RiskControl.Client/Failover/FailoverStrategy.cs new file mode 100644 index 0000000..ed3dfe4 --- /dev/null +++ b/Fengling.RiskControl.Client/Failover/FailoverStrategy.cs @@ -0,0 +1,176 @@ +using Fengling.RiskControl.Configuration; +using Fengling.RiskControl.Counter; +using Fengling.RiskControl.Evaluation; +using Fengling.RiskControl.Rules; +using Microsoft.Extensions.Logging; +using StackExchange.Redis; + +namespace Fengling.RiskControl.Failover; + +public interface IFailoverStrategy +{ + Task IsHealthyAsync(); + RiskControlMode GetCurrentMode(); + Task ExecuteWithFailoverAsync(Func action); + Task ExecuteWithFailoverAsync(Func> action); +} + +public enum RiskControlMode +{ + Normal = 0, + QuickFail = 1, + DenyNewUsers = 2, + Maintenance = 3 +} + +public class FailoverStrategy : IFailoverStrategy, IDisposable +{ + private readonly IConnectionMultiplexer _redis; + private readonly IRiskCounterService _counterService; + private readonly IRuleLoader _ruleLoader; + private readonly RiskControlClientOptions _options; + private readonly ILogger _logger; + private Timer? _healthCheckTimer; + private RiskControlMode _currentMode = RiskControlMode.Normal; + private DateTime _lastFailureTime = DateTime.MinValue; + private bool _disposed; + + public FailoverStrategy( + IConnectionMultiplexer redis, + IRiskCounterService counterService, + IRuleLoader ruleLoader, + RiskControlClientOptions options, + ILogger logger) + { + _redis = redis; + _counterService = counterService; + _ruleLoader = ruleLoader; + _options = options; + _logger = logger; + + if (_options.RedisFailover.Enabled) + { + _healthCheckTimer = new Timer( + _ => _ = CheckHealthAsync(), + null, + TimeSpan.FromSeconds(5), + TimeSpan.FromSeconds(5) + ); + } + } + + public RiskControlMode GetCurrentMode() => _currentMode; + + public async Task IsHealthyAsync() + { + try + { + var db = _redis.GetDatabase(); + await db.PingAsync(); + return true; + } + catch + { + return false; + } + } + + private async Task CheckHealthAsync() + { + var isHealthy = await IsHealthyAsync(); + + if (!isHealthy) + { + if (_lastFailureTime == DateTime.MinValue) + { + _lastFailureTime = DateTime.UtcNow; + } + + var failureDuration = (DateTime.UtcNow - _lastFailureTime).TotalSeconds; + + var newMode = DetermineMode(failureDuration); + if (newMode != _currentMode) + { + _currentMode = newMode; + _logger.LogWarning("Redis failure detected, duration={Duration}s, switching to mode={Mode}", + failureDuration, _currentMode); + + await PublishFailoverAlertAsync(_currentMode); + } + } + else + { + if (_currentMode != RiskControlMode.Normal) + { + _logger.LogInformation("Redis restored, switching back to Normal mode"); + _currentMode = RiskControlMode.Normal; + _lastFailureTime = DateTime.MinValue; + } + } + } + + private RiskControlMode DetermineMode(double failureDuration) + { + if (failureDuration < _options.RedisFailover.QuickFailThresholdSeconds) + return RiskControlMode.QuickFail; + if (failureDuration < _options.RedisFailover.DenyNewUsersThresholdSeconds) + return RiskControlMode.DenyNewUsers; + return RiskControlMode.Maintenance; + } + + public Task ExecuteWithFailoverAsync(Func action) + { + return ExecuteWithFailoverAsync(async () => + { + await action(); + return true; + }); + } + + public async Task ExecuteWithFailoverAsync(Func> action) + { + switch (_currentMode) + { + case RiskControlMode.Normal: + return await action(); + + case RiskControlMode.QuickFail: + _logger.LogWarning("QuickFail mode: failing fast"); + throw new RedisConnectionException(ConnectionFailureType.UnableToConnect, + "Redis is temporarily unavailable"); + + case RiskControlMode.DenyNewUsers: + _logger.LogWarning("DenyNewUsers mode: checking if user has existing session"); + throw new RedisConnectionException(ConnectionFailureType.UnableToConnect, + "Redis is temporarily unavailable, new users denied"); + + case RiskControlMode.Maintenance: + _logger.LogError("Maintenance mode: Redis unavailable for extended period"); + throw new RedisConnectionException(ConnectionFailureType.UnableToConnect, + "Redis maintenance in progress"); + + default: + return await action(); + } + } + + private async Task PublishFailoverAlertAsync(RiskControlMode mode) + { + try + { + // CAP alert publishing would be handled by the main application + _logger.LogError("ALERT: RiskControl entering {Mode} mode due to Redis failure", mode); + } + catch + { + _logger.LogError("Failed to publish failover alert"); + } + } + + public void Dispose() + { + if (_disposed) return; + _healthCheckTimer?.Dispose(); + _disposed = true; + } +} diff --git a/Fengling.RiskControl.Client/Fengling.RiskControl.Client.csproj b/Fengling.RiskControl.Client/Fengling.RiskControl.Client.csproj new file mode 100644 index 0000000..c333832 --- /dev/null +++ b/Fengling.RiskControl.Client/Fengling.RiskControl.Client.csproj @@ -0,0 +1,35 @@ + + + + net10.0 + enable + enable + Fengling.RiskControl + Risk Control Client SDK for high-performance risk evaluation with Redis caching, local rule engine, and CAP event integration + Fengling Team + Fengling.RiskControl.Client + 1.0.0 + Fengling Team + risk-control;gambling-prevention;fraud-detection;redis;cap;dotnet + https://github.com/fengling/platform + MIT + false + false + + + + + + + + + + + + + + + + + + diff --git a/Fengling.RiskControl.Client/IRiskRuleLoader.cs b/Fengling.RiskControl.Client/IRiskRuleLoader.cs new file mode 100644 index 0000000..1b8bcf4 --- /dev/null +++ b/Fengling.RiskControl.Client/IRiskRuleLoader.cs @@ -0,0 +1,9 @@ +using Fengling.RiskControl.Domain.Aggregates.RiskRules; + +namespace Fengling.RiskControl; + +public interface IRiskRuleLoader +{ + Task> GetActiveRulesAsync(); + Task RefreshRulesAsync(); +} diff --git a/Fengling.RiskControl.Client/RiskControlClient.cs b/Fengling.RiskControl.Client/RiskControlClient.cs new file mode 100644 index 0000000..16fb9f1 --- /dev/null +++ b/Fengling.RiskControl.Client/RiskControlClient.cs @@ -0,0 +1,141 @@ +using Fengling.RiskControl.Configuration; +using Fengling.RiskControl.Counter; +using Fengling.RiskControl.Evaluation; +using Fengling.RiskControl.Events; +using Fengling.RiskControl.Failover; +using Fengling.RiskControl.Rules; +using Fengling.RiskControl.Sampling; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Fengling.RiskControl; + +public interface IRiskControlClient +{ + Task EvaluateRiskAsync(RiskEvaluationRequest request); + Task IsAllowedAsync(RiskEvaluationRequest request); + Task IncrementMetricAsync(string memberId, string metric, int value = 1); + Task> GetMemberMetricsAsync(string memberId); + RiskControlMode GetCurrentMode(); +} + +public class RiskControlClient : IRiskControlClient, IDisposable +{ + private readonly IRiskEvaluator _evaluator; + private readonly IRiskCounterService _counterService; + private readonly IRiskEventPublisher _eventPublisher; + private readonly ISamplingService _samplingService; + private readonly IFailoverStrategy _failoverStrategy; + private readonly RiskControlClientOptions _options; + private readonly ILogger _logger; + private bool _disposed; + + public RiskControlClient( + IRiskEvaluator evaluator, + IRiskCounterService counterService, + IRiskEventPublisher eventPublisher, + ISamplingService samplingService, + IFailoverStrategy failoverStrategy, + RiskControlClientOptions options, + ILogger logger) + { + _evaluator = evaluator; + _counterService = counterService; + _eventPublisher = eventPublisher; + _samplingService = samplingService; + _failoverStrategy = failoverStrategy; + _options = options; + _logger = logger; + } + + public async Task EvaluateRiskAsync(RiskEvaluationRequest request) + { + return await _failoverStrategy.ExecuteWithFailoverAsync(async () => + { + var result = await _evaluator.EvaluateAsync(request); + + if (_samplingService.ShouldSample(result)) + { + await _eventPublisher.PublishRiskAssessmentAsync(request, result); + } + + if (result.Blocked) + { + await _eventPublisher.PublishRiskAlertAsync(request, result); + } + + return result; + }); + } + + public async Task IsAllowedAsync(RiskEvaluationRequest request) + { + var result = await EvaluateRiskAsync(request); + return !result.Blocked; + } + + public async Task IncrementMetricAsync(string memberId, string metric, int value = 1) + { + await _failoverStrategy.ExecuteWithFailoverAsync(async () => + { + await _counterService.IncrementAsync(memberId, metric, value); + return true; + }); + } + + public async Task> GetMemberMetricsAsync(string memberId) + { + return await _failoverStrategy.ExecuteWithFailoverAsync(async () => + { + return await _counterService.GetAllValuesAsync(memberId); + }); + } + + public RiskControlMode GetCurrentMode() => _failoverStrategy.GetCurrentMode(); + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + } +} + +public class RiskControlClientHostedService : BackgroundService +{ + private readonly IRiskRuleLoader _ruleLoader; + private readonly IFailoverStrategy _failoverStrategy; + private readonly ILogger _logger; + + public RiskControlClientHostedService( + IRiskRuleLoader ruleLoader, + IFailoverStrategy failoverStrategy, + ILogger logger) + { + _ruleLoader = ruleLoader; + _failoverStrategy = failoverStrategy; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("RiskControl Client Hosted Service starting..."); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + var isHealthy = await _failoverStrategy.IsHealthyAsync(); + if (!isHealthy) + { + _logger.LogWarning("Redis is unhealthy, client cannot function properly"); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error checking health"); + } + + await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken); + } + } +} diff --git a/Fengling.RiskControl.Client/RiskControlClientServiceExtensions.cs b/Fengling.RiskControl.Client/RiskControlClientServiceExtensions.cs new file mode 100644 index 0000000..d0020e2 --- /dev/null +++ b/Fengling.RiskControl.Client/RiskControlClientServiceExtensions.cs @@ -0,0 +1,98 @@ +using Fengling.RiskControl; +using Fengling.RiskControl.Configuration; +using Fengling.RiskControl.Counter; +using Fengling.RiskControl.Evaluation; +using Fengling.RiskControl.Events; +using Fengling.RiskControl.Failover; +using Fengling.RiskControl.Rules; +using Fengling.RiskControl.Sampling; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using DotNetCore.CAP; +using StackExchange.Redis; + +namespace Microsoft.Extensions.DependencyInjection; + +public static class RiskControlClientServiceExtensions +{ + public static IServiceCollection AddRiskControlClientCore( + this IServiceCollection services, + Action? configureOptions = null) + { + if (configureOptions != null) + { + services.Configure(configureOptions); + } + + services.TryAddSingleton(sp => + { + var options = sp.GetRequiredService>().Value; + return options; + }); + + services.TryAddSingleton(sp => + { + var options = sp.GetRequiredService(); + var connectionString = options.Redis.ConnectionString; + var connection = ConnectionMultiplexer.Connect(connectionString); + return connection; + }); + + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + + if (services.Any(s => s.ServiceType == typeof(ICapPublisher))) + { + services.TryAddSingleton(); + } + else + { + services.TryAddSingleton(); + } + + services.TryAddSingleton(); + services.TryAddSingleton(); + + services.TryAddSingleton(sp => sp.GetRequiredService()); + + return services; + } + + public static IServiceCollection AddRiskControlClient( + this IServiceCollection services, + Action configureOptions) + { + services.Configure(configureOptions); + services.AddRiskControlClientCore(); + return services; + } +} + +internal class NoOpEventPublisher : IRiskEventPublisher +{ + private readonly ILogger _logger; + + public NoOpEventPublisher(ILogger logger) + { + _logger = logger; + } + + public Task PublishRiskAssessmentAsync(RiskEvaluationRequest request, RiskEvaluationResult result) + { + _logger.LogDebug("CAP publisher not configured, skipping RiskAssessmentEvent"); + return Task.CompletedTask; + } + + public Task PublishRiskAlertAsync(RiskEvaluationRequest request, RiskEvaluationResult result) + { + _logger.LogDebug("CAP publisher not configured, skipping RiskAlertEvent"); + return Task.CompletedTask; + } +} diff --git a/Fengling.RiskControl.Client/Rules/RedisRuleLoader.cs b/Fengling.RiskControl.Client/Rules/RedisRuleLoader.cs new file mode 100644 index 0000000..205327b --- /dev/null +++ b/Fengling.RiskControl.Client/Rules/RedisRuleLoader.cs @@ -0,0 +1,131 @@ +using Fengling.RiskControl.Domain.Aggregates.RiskRules; +using Fengling.RiskControl.Configuration; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using StackExchange.Redis; +using System.Text.Json; + +namespace Fengling.RiskControl.Rules; + +public interface IRuleLoader +{ + Task> GetActiveRulesAsync(); + Task RefreshRulesAsync(); + event EventHandler? RulesChanged; +} + +public class RedisRuleLoader : IRuleLoader, IHostedService +{ + private readonly IConnectionMultiplexer _redis; + private readonly RiskControlClientOptions _options; + private readonly ILogger _logger; + private List _cachedRules = new(); + private readonly object _lock = new(); + private Timer? _refreshTimer; + + private string RulesKey => $"{_options.Redis.KeyPrefix}rules:active"; + + public event EventHandler? RulesChanged; + + public RedisRuleLoader( + IConnectionMultiplexer redis, + RiskControlClientOptions options, + ILogger logger) + { + _redis = redis; + _options = options; + _logger = logger; + } + + public Task StartAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("RuleLoader starting, loading rules from Redis..."); + + _ = LoadRulesAsync(); + + _refreshTimer = new Timer( + _ => _ = RefreshRulesAsync(), + null, + TimeSpan.FromSeconds(_options.Evaluation.RuleRefreshIntervalSeconds), + TimeSpan.FromSeconds(_options.Evaluation.RuleRefreshIntervalSeconds) + ); + + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) + { + _refreshTimer?.Dispose(); + return Task.CompletedTask; + } + + public List GetActiveRules() + { + lock (_lock) + { + return _cachedRules.ToList(); + } + } + + public async Task> GetActiveRulesAsync() + { + if (_cachedRules.Count == 0) + { + await LoadRulesAsync(); + } + return GetActiveRules(); + } + + public async Task RefreshRulesAsync() + { + try + { + await LoadRulesAsync(); + _logger.LogDebug("Rules refreshed successfully, count: {Count}", _cachedRules.Count); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to refresh rules from Redis"); + } + } + + private async Task LoadRulesAsync() + { + var db = _redis.GetDatabase(); + + try + { + var values = await db.ListRangeAsync(RulesKey); + var rules = new List(); + + foreach (var value in values) + { + if (value.HasValue) + { + var rule = JsonSerializer.Deserialize(value!.ToString(), new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + if (rule != null && rule.IsActive) + { + rules.Add(rule); + } + } + } + + rules = rules.OrderBy(r => r.Priority).ToList(); + + lock (_lock) + { + _cachedRules = rules; + } + + RulesChanged?.Invoke(this, EventArgs.Empty); + } + catch (RedisException ex) + { + _logger.LogError(ex, "Failed to load rules from Redis"); + throw; + } + } +} diff --git a/Fengling.RiskControl.Client/Sampling/SamplingService.cs b/Fengling.RiskControl.Client/Sampling/SamplingService.cs new file mode 100644 index 0000000..028285a --- /dev/null +++ b/Fengling.RiskControl.Client/Sampling/SamplingService.cs @@ -0,0 +1,67 @@ +using Fengling.RiskControl.Configuration; +using Fengling.RiskControl.Evaluation; +using Microsoft.Extensions.Logging; +using System.Globalization; + +namespace Fengling.RiskControl.Sampling; + +public interface ISamplingService +{ + bool ShouldSample(RiskEvaluationResult result); +} + +public class SamplingService : ISamplingService +{ + private readonly RiskControlClientOptions _options; + private readonly ILogger _logger; + private readonly Random _random = new(); + + public SamplingService( + RiskControlClientOptions options, + ILogger logger) + { + _options = options; + _logger = logger; + } + + public bool ShouldSample(RiskEvaluationResult result) + { + if (!_options.Sampling.Enabled) + return false; + + foreach (var rule in _options.Sampling.Rules) + { + if (EvaluateCondition(rule.Condition, result)) + { + var sampleRate = rule.Rate; + var sampled = _random.NextDouble() < sampleRate; + _logger.LogDebug("Sampling check: condition={Condition}, rate={Rate}, sampled={Sampled}", + rule.Condition, sampleRate, sampled); + return sampled; + } + } + + var defaultSampled = _random.NextDouble() < _options.Sampling.DefaultRate; + _logger.LogDebug("Default sampling: rate={Rate}, sampled={Sampled}", + _options.Sampling.DefaultRate, defaultSampled); + return defaultSampled; + } + + private bool EvaluateCondition(string condition, RiskEvaluationResult result) + { + return condition switch + { + string c when c.Contains("RiskLevel == High", StringComparison.OrdinalIgnoreCase) + => result.RiskLevel == RiskLevel.High, + string c when c.Contains("RiskLevel == Medium", StringComparison.OrdinalIgnoreCase) + => result.RiskLevel == RiskLevel.Medium, + string c when c.Contains("RiskLevel == Low", StringComparison.OrdinalIgnoreCase) + => result.RiskLevel == RiskLevel.Low, + string c when c.Contains("IsBlocked == true", StringComparison.OrdinalIgnoreCase) + => result.Blocked, + string c when c.Contains("BlockedOnly", StringComparison.OrdinalIgnoreCase) + => result.Blocked, + _ => false + }; + } +}