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
This commit is contained in:
Sam 2026-02-06 00:16:53 +08:00
parent 6b6dbd11d5
commit 293209b1dc
14 changed files with 1312 additions and 0 deletions

35
Directory.Packages.props Normal file
View File

@ -0,0 +1,35 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<PropertyGroup>
<NetCorePalVersion>3.2.1</NetCorePalVersion>
<FastEndpointsVersion>7.1.1</FastEndpointsVersion>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="9.0.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.0" />
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.0" />
<PackageVersion Include="NetCorePal.Extensions.Repository.EntityFrameworkCore" Version="$(NetCorePalVersion)" />
<PackageVersion Include="MediatR" Version="12.5.0" />
<PackageVersion Include="FluentValidation.AspNetCore" Version="11.3.1" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.7" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="9.0.0" />
<PackageVersion Include="StackExchange.Redis" Version="2.7.33" />
<PackageVersion Include="FastEndpoints" Version="$(FastEndpointsVersion)" />
<PackageVersion Include="FastEndpoints.Swagger" Version="$(FastEndpointsVersion)" />
<PackageVersion Include="Swashbuckle.AspNetCore" Version="7.2.0" />
<PackageVersion Include="DotNetCore.CAP" Version="8.4.1" />
<PackageVersion Include="DotNetCore.CAP.RabbitMQ" Version="8.4.1" />
<PackageVersion Include="NetCorePal.Extensions.Domain.Abstractions" Version="$(NetCorePalVersion)" />
<PackageVersion Include="NetCorePal.Extensions.Primitives" Version="$(NetCorePalVersion)" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageVersion Include="FluentAssertions" Version="6.12.2" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.0.2" />
<PackageVersion Include="NSubstitute" Version="5.3.0" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.0" />
</ItemGroup>
</Project>

View File

@ -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<RiskControlClientOptions> configureOptions)
{
services.Configure(configureOptions);
services.AddSingleton<IValidateOptions<RiskControlClientOptions>, RiskControlClientOptionsValidator>();
services.AddSingleton<RiskControlClientOptions>(sp =>
sp.GetRequiredService<IOptions<RiskControlClientOptions>>().Value);
return services;
}
}
public class RiskControlClientOptionsValidator : IValidateOptions<RiskControlClientOptions>
{
public ValidateOptionsResult Validate(string? name, RiskControlClientOptions options)
{
if (string.IsNullOrEmpty(options.Redis.ConnectionString))
{
return ValidateOptionsResult.Fail("Redis.ConnectionString is required");
}
return ValidateOptionsResult.Success;
}
}

View File

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

View File

@ -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<long> IncrementAsync(string memberId, string metric, int value = 1);
Task<int> GetValueAsync(string memberId, string metric);
Task<Dictionary<string, int>> GetAllValuesAsync(string memberId);
Task<bool> SetValueAsync(string memberId, string metric, int value);
Task RefreshTtlAsync(string memberId);
Task<bool> ExistsAsync(string memberId);
}
public class RedisCounterService : IRiskCounterService
{
private readonly IConnectionMultiplexer _redis;
private readonly RiskControlClientOptions _options;
private readonly ILogger<RedisCounterService> _logger;
private string MemberKey(string memberId) => $"{_options.Redis.KeyPrefix}{memberId}";
public RedisCounterService(
IConnectionMultiplexer redis,
RiskControlClientOptions options,
ILogger<RedisCounterService> logger)
{
_redis = redis;
_options = options;
_logger = logger;
}
public async Task<long> 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<int> 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<Dictionary<string, int>> 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<bool> 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<bool> 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;
}
}
}

View File

@ -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<string, object> 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<RiskFactorResult> 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
}

View File

@ -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<RiskEvaluationResult> EvaluateAsync(RiskEvaluationRequest request);
Task<bool> IsAllowedAsync(RiskEvaluationRequest request);
}
public class RiskEvaluator : IRiskEvaluator
{
private readonly IRuleLoader _ruleLoader;
private readonly IRiskCounterService _counterService;
private readonly RiskControlClientOptions _options;
private readonly ILogger<RiskEvaluator> _logger;
public RiskEvaluator(
IRuleLoader ruleLoader,
IRiskCounterService counterService,
RiskControlClientOptions options,
ILogger<RiskEvaluator> logger)
{
_ruleLoader = ruleLoader;
_counterService = counterService;
_options = options;
_logger = logger;
}
public async Task<RiskEvaluationResult> EvaluateAsync(RiskEvaluationRequest request)
{
var rules = await _ruleLoader.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,
RiskLevel = riskLevel,
RecommendedAction = recommendedAction,
Blocked = blocked,
Message = blocked ? "操作被风险控制系统拒绝" : "操作已通过风险评估",
Factors = factors,
EvaluatedAt = DateTime.UtcNow
};
}
public Task<bool> IsAllowedAsync(RiskEvaluationRequest request)
{
return EvaluateAsync(request).ContinueWith(t => !t.Result.Blocked);
}
private async Task<RiskFactorResult?> 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<RiskFactorResult?> EvaluateFrequencyLimitAsync(RiskRule rule, RiskEvaluationRequest request)
{
var config = rule.GetConfig<FrequencyLimitConfig>();
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<AmountLimitConfig>();
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<BlacklistConfig>();
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<DeviceFingerprintConfig>();
if (config == null)
return null;
return null;
}
private RiskFactorResult? EvaluateVelocityCheck(RiskRule rule, RiskEvaluationRequest request)
{
var config = rule.GetConfig<VelocityCheckConfig>();
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<string> BlockedMembers { get; set; } = new();
}
public class DeviceFingerprintConfig
{
public int MaxAccountsPerDevice { get; set; } = 3;
}
public class VelocityCheckConfig
{
public int MaxRequestsPerMinute { get; set; } = 100;
}

View File

@ -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<CapEventPublisher> _logger;
public CapEventPublisher(
ICapPublisher capPublisher,
RiskControlClientOptions options,
ILogger<CapEventPublisher> 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<RiskFactorResult> 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;
}

View File

@ -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<bool> IsHealthyAsync();
RiskControlMode GetCurrentMode();
Task ExecuteWithFailoverAsync(Func<Task> action);
Task<T> ExecuteWithFailoverAsync<T>(Func<Task<T>> 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<FailoverStrategy> _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<FailoverStrategy> 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<bool> 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<Task> action)
{
return ExecuteWithFailoverAsync(async () =>
{
await action();
return true;
});
}
public async Task<T> ExecuteWithFailoverAsync<T>(Func<Task<T>> 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;
}
}

View File

@ -0,0 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Fengling.RiskControl</RootNamespace>
<Description>Risk Control Client SDK for high-performance risk evaluation with Redis caching, local rule engine, and CAP event integration</Description>
<Authors>Fengling Team</Authors>
<PackageId>Fengling.RiskControl.Client</PackageId>
<Version>1.0.0</Version>
<Authors>Fengling Team</Authors>
<PackageTags>risk-control;gambling-prevention;fraud-detection;redis;cap;dotnet</PackageTags>
<RepositoryUrl>https://github.com/fengling/platform</RepositoryUrl>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<RequireLicenseAcceptance>false</RequireLicenseAcceptance>
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
<PackageReference Include="StackExchange.Redis" Version="2.7.33" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="DotNetCore.CAP" Version="8.4.1" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Fengling.RiskControl.Domain\Fengling.RiskControl.Domain.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,9 @@
using Fengling.RiskControl.Domain.Aggregates.RiskRules;
namespace Fengling.RiskControl;
public interface IRiskRuleLoader
{
Task<List<RiskRule>> GetActiveRulesAsync();
Task RefreshRulesAsync();
}

View File

@ -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<RiskEvaluationResult> EvaluateRiskAsync(RiskEvaluationRequest request);
Task<bool> IsAllowedAsync(RiskEvaluationRequest request);
Task IncrementMetricAsync(string memberId, string metric, int value = 1);
Task<Dictionary<string, int>> 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<RiskControlClient> _logger;
private bool _disposed;
public RiskControlClient(
IRiskEvaluator evaluator,
IRiskCounterService counterService,
IRiskEventPublisher eventPublisher,
ISamplingService samplingService,
IFailoverStrategy failoverStrategy,
RiskControlClientOptions options,
ILogger<RiskControlClient> logger)
{
_evaluator = evaluator;
_counterService = counterService;
_eventPublisher = eventPublisher;
_samplingService = samplingService;
_failoverStrategy = failoverStrategy;
_options = options;
_logger = logger;
}
public async Task<RiskEvaluationResult> 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<bool> 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<Dictionary<string, int>> 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<RiskControlClientHostedService> _logger;
public RiskControlClientHostedService(
IRiskRuleLoader ruleLoader,
IFailoverStrategy failoverStrategy,
ILogger<RiskControlClientHostedService> 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);
}
}
}

View File

@ -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<RiskControlClientOptions>? configureOptions = null)
{
if (configureOptions != null)
{
services.Configure(configureOptions);
}
services.TryAddSingleton<RiskControlClientOptions>(sp =>
{
var options = sp.GetRequiredService<IOptions<RiskControlClientOptions>>().Value;
return options;
});
services.TryAddSingleton<IConnectionMultiplexer>(sp =>
{
var options = sp.GetRequiredService<RiskControlClientOptions>();
var connectionString = options.Redis.ConnectionString;
var connection = ConnectionMultiplexer.Connect(connectionString);
return connection;
});
services.TryAddSingleton<IRuleLoader, RedisRuleLoader>();
services.TryAddSingleton<IRiskCounterService, RedisCounterService>();
services.TryAddSingleton<IRiskEvaluator, RiskEvaluator>();
services.TryAddSingleton<ISamplingService, SamplingService>();
services.TryAddSingleton<IFailoverStrategy, FailoverStrategy>();
if (services.Any(s => s.ServiceType == typeof(ICapPublisher)))
{
services.TryAddSingleton<IRiskEventPublisher, CapEventPublisher>();
}
else
{
services.TryAddSingleton<IRiskEventPublisher, NoOpEventPublisher>();
}
services.TryAddSingleton<IRiskControlClient, RiskControlClient>();
services.TryAddSingleton<RiskControlClientHostedService>();
services.TryAddSingleton<IHostedService>(sp => sp.GetRequiredService<RiskControlClientHostedService>());
return services;
}
public static IServiceCollection AddRiskControlClient(
this IServiceCollection services,
Action<RiskControlClientOptions> configureOptions)
{
services.Configure(configureOptions);
services.AddRiskControlClientCore();
return services;
}
}
internal class NoOpEventPublisher : IRiskEventPublisher
{
private readonly ILogger<NoOpEventPublisher> _logger;
public NoOpEventPublisher(ILogger<NoOpEventPublisher> 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;
}
}

View File

@ -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<List<RiskRule>> GetActiveRulesAsync();
Task RefreshRulesAsync();
event EventHandler? RulesChanged;
}
public class RedisRuleLoader : IRuleLoader, IHostedService
{
private readonly IConnectionMultiplexer _redis;
private readonly RiskControlClientOptions _options;
private readonly ILogger<RedisRuleLoader> _logger;
private List<RiskRule> _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<RedisRuleLoader> 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<RiskRule> GetActiveRules()
{
lock (_lock)
{
return _cachedRules.ToList();
}
}
public async Task<List<RiskRule>> 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<RiskRule>();
foreach (var value in values)
{
if (value.HasValue)
{
var rule = JsonSerializer.Deserialize<RiskRule>(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;
}
}
}

View File

@ -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<SamplingService> _logger;
private readonly Random _random = new();
public SamplingService(
RiskControlClientOptions options,
ILogger<SamplingService> 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
};
}
}