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
+ };
+ }
+}