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