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:
parent
6b6dbd11d5
commit
293209b1dc
35
Directory.Packages.props
Normal file
35
Directory.Packages.props
Normal 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>
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
142
Fengling.RiskControl.Client/Counter/RedisCounterService.cs
Normal file
142
Fengling.RiskControl.Client/Counter/RedisCounterService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
228
Fengling.RiskControl.Client/Evaluation/RiskEvaluator.cs
Normal file
228
Fengling.RiskControl.Client/Evaluation/RiskEvaluator.cs
Normal 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;
|
||||
}
|
||||
120
Fengling.RiskControl.Client/Events/CapEventPublisher.cs
Normal file
120
Fengling.RiskControl.Client/Events/CapEventPublisher.cs
Normal 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;
|
||||
}
|
||||
176
Fengling.RiskControl.Client/Failover/FailoverStrategy.cs
Normal file
176
Fengling.RiskControl.Client/Failover/FailoverStrategy.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
9
Fengling.RiskControl.Client/IRiskRuleLoader.cs
Normal file
9
Fengling.RiskControl.Client/IRiskRuleLoader.cs
Normal file
@ -0,0 +1,9 @@
|
||||
using Fengling.RiskControl.Domain.Aggregates.RiskRules;
|
||||
|
||||
namespace Fengling.RiskControl;
|
||||
|
||||
public interface IRiskRuleLoader
|
||||
{
|
||||
Task<List<RiskRule>> GetActiveRulesAsync();
|
||||
Task RefreshRulesAsync();
|
||||
}
|
||||
141
Fengling.RiskControl.Client/RiskControlClient.cs
Normal file
141
Fengling.RiskControl.Client/RiskControlClient.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
131
Fengling.RiskControl.Client/Rules/RedisRuleLoader.cs
Normal file
131
Fengling.RiskControl.Client/Rules/RedisRuleLoader.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
67
Fengling.RiskControl.Client/Sampling/SamplingService.cs
Normal file
67
Fengling.RiskControl.Client/Sampling/SamplingService.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user