diff --git a/Directory.Build.props b/Directory.Build.props index 0cdea9d..c42ffd7 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,5 +1,7 @@  5.1.4 + 2.0.593 + 6.0.* \ No newline at end of file diff --git a/Sanhe.Abp.Framework.sln b/Sanhe.Abp.Framework.sln index 830e651..00d2952 100644 --- a/Sanhe.Abp.Framework.sln +++ b/Sanhe.Abp.Framework.sln @@ -25,6 +25,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sanhe.Abp.ExceptionHandling EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sanhe.Abp.ExceptionHandling.Emailing", "modules\common\Sanhe.Abp.ExceptionHandling.Emailing\Sanhe.Abp.ExceptionHandling.Emailing.csproj", "{0692C2EA-7119-4065-8504-D7FDA70135A9}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sanhe.Abp.Features.LimitValidation", "modules\common\Sanhe.Abp.Features.LimitValidation\Sanhe.Abp.Features.LimitValidation.csproj", "{BE246DD7-2DDB-4064-9017-7E1DD2E67195}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sanhe.Abp.Features.LimitValidation.Redis", "modules\common\Sanhe.Abp.Features.LimitValidation.Redis\Sanhe.Abp.Features.LimitValidation.Redis.csproj", "{A2BD1C66-505E-48BB-A356-38D74AA3AE0F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sanhe.Abp.Features.LimitValidation.Redis.Client", "modules\common\Sanhe.Abp.Features.LimitValidation.Redis.Client\Sanhe.Abp.Features.LimitValidation.Redis.Client.csproj", "{97DDB479-946C-489D-9089-6EAEBFEE97C6}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -51,6 +57,18 @@ Global {0692C2EA-7119-4065-8504-D7FDA70135A9}.Debug|Any CPU.Build.0 = Debug|Any CPU {0692C2EA-7119-4065-8504-D7FDA70135A9}.Release|Any CPU.ActiveCfg = Release|Any CPU {0692C2EA-7119-4065-8504-D7FDA70135A9}.Release|Any CPU.Build.0 = Release|Any CPU + {BE246DD7-2DDB-4064-9017-7E1DD2E67195}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BE246DD7-2DDB-4064-9017-7E1DD2E67195}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BE246DD7-2DDB-4064-9017-7E1DD2E67195}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BE246DD7-2DDB-4064-9017-7E1DD2E67195}.Release|Any CPU.Build.0 = Release|Any CPU + {A2BD1C66-505E-48BB-A356-38D74AA3AE0F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A2BD1C66-505E-48BB-A356-38D74AA3AE0F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A2BD1C66-505E-48BB-A356-38D74AA3AE0F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A2BD1C66-505E-48BB-A356-38D74AA3AE0F}.Release|Any CPU.Build.0 = Release|Any CPU + {97DDB479-946C-489D-9089-6EAEBFEE97C6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {97DDB479-946C-489D-9089-6EAEBFEE97C6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {97DDB479-946C-489D-9089-6EAEBFEE97C6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {97DDB479-946C-489D-9089-6EAEBFEE97C6}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -65,6 +83,9 @@ Global {64178F61-A488-4182-A409-C32AE51E33A1} = {928FDD8C-1EE8-455E-952F-11039B52FE03} {FBEB7703-CF8A-4E5B-B1C7-ED9DC1ABC7BD} = {2A768109-31B7-4C52-928C-3023DAB9F254} {0692C2EA-7119-4065-8504-D7FDA70135A9} = {2A768109-31B7-4C52-928C-3023DAB9F254} + {BE246DD7-2DDB-4064-9017-7E1DD2E67195} = {2A768109-31B7-4C52-928C-3023DAB9F254} + {A2BD1C66-505E-48BB-A356-38D74AA3AE0F} = {2A768109-31B7-4C52-928C-3023DAB9F254} + {97DDB479-946C-489D-9089-6EAEBFEE97C6} = {2A768109-31B7-4C52-928C-3023DAB9F254} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {AB69BFDE-9DDB-4D16-8CB8-72472C0319CD} diff --git a/modules/common/Sanhe.Abp.Features.LimitValidation.Redis.Client/FodyWeavers.xml b/modules/common/Sanhe.Abp.Features.LimitValidation.Redis.Client/FodyWeavers.xml new file mode 100644 index 0000000..1715698 --- /dev/null +++ b/modules/common/Sanhe.Abp.Features.LimitValidation.Redis.Client/FodyWeavers.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/modules/common/Sanhe.Abp.Features.LimitValidation.Redis.Client/Sanhe.Abp.Features.LimitValidation.Redis.Client.csproj b/modules/common/Sanhe.Abp.Features.LimitValidation.Redis.Client/Sanhe.Abp.Features.LimitValidation.Redis.Client.csproj new file mode 100644 index 0000000..0c7c64d --- /dev/null +++ b/modules/common/Sanhe.Abp.Features.LimitValidation.Redis.Client/Sanhe.Abp.Features.LimitValidation.Redis.Client.csproj @@ -0,0 +1,15 @@ + + + + + + + netstandard2.0 + + + + + + + + diff --git a/modules/common/Sanhe.Abp.Features.LimitValidation.Redis.Client/Sanhe/Abp/Features/LimitValidation/Redis/Client/AbpFeaturesValidationRedisClientModule.cs b/modules/common/Sanhe.Abp.Features.LimitValidation.Redis.Client/Sanhe/Abp/Features/LimitValidation/Redis/Client/AbpFeaturesValidationRedisClientModule.cs new file mode 100644 index 0000000..7ac8014 --- /dev/null +++ b/modules/common/Sanhe.Abp.Features.LimitValidation.Redis.Client/Sanhe/Abp/Features/LimitValidation/Redis/Client/AbpFeaturesValidationRedisClientModule.cs @@ -0,0 +1,9 @@ +using Volo.Abp.Modularity; + +namespace Sanhe.Abp.Features.LimitValidation.Redis.Client; + +[DependsOn(typeof(AbpFeaturesValidationRedisModule))] +public class AbpFeaturesValidationRedisClientModule : AbpModule +{ + +} diff --git a/modules/common/Sanhe.Abp.Features.LimitValidation.Redis.Client/Sanhe/Abp/Features/LimitValidation/Redis/Client/RedisClientLimitFeatureNamingNormalizer.cs b/modules/common/Sanhe.Abp.Features.LimitValidation.Redis.Client/Sanhe/Abp/Features/LimitValidation/Redis/Client/RedisClientLimitFeatureNamingNormalizer.cs new file mode 100644 index 0000000..bcb1210 --- /dev/null +++ b/modules/common/Sanhe.Abp.Features.LimitValidation.Redis.Client/Sanhe/Abp/Features/LimitValidation/Redis/Client/RedisClientLimitFeatureNamingNormalizer.cs @@ -0,0 +1,33 @@ +using Microsoft.Extensions.DependencyInjection; +using Volo.Abp.Clients; +using Volo.Abp.DependencyInjection; +using Volo.Abp.MultiTenancy; + +namespace Sanhe.Abp.Features.LimitValidation.Redis.Client; + +[Dependency(ServiceLifetime.Singleton, ReplaceServices = true)] +[ExposeServices( + typeof(IRedisLimitFeatureNamingNormalizer), + typeof(RedisLimitFeatureNamingNormalizer))] +public class RedisClientLimitFeatureNamingNormalizer : RedisLimitFeatureNamingNormalizer +{ + protected ICurrentClient CurrentClient { get; } + + public RedisClientLimitFeatureNamingNormalizer( + ICurrentClient currentClient, + ICurrentTenant currentTenant) : base(currentTenant) + { + CurrentClient = currentClient; + } + + public override string NormalizeFeatureName(string instance, RequiresLimitFeatureContext context) + { + if (CurrentClient.IsAuthenticated) + { + return CurrentTenant.IsAvailable + ? $"{instance}t:RequiresLimitFeature;t:{CurrentTenant.Id};c:{CurrentClient.Id};f:{context.LimitFeature}" + : $"{instance}tc:RequiresLimitFeature;c:{CurrentClient.Id};f:{context.LimitFeature}"; + } + return base.NormalizeFeatureName(instance, context); + } +} diff --git a/modules/common/Sanhe.Abp.Features.LimitValidation.Redis/FodyWeavers.xml b/modules/common/Sanhe.Abp.Features.LimitValidation.Redis/FodyWeavers.xml new file mode 100644 index 0000000..1715698 --- /dev/null +++ b/modules/common/Sanhe.Abp.Features.LimitValidation.Redis/FodyWeavers.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/modules/common/Sanhe.Abp.Features.LimitValidation.Redis/Sanhe.Abp.Features.LimitValidation.Redis.csproj b/modules/common/Sanhe.Abp.Features.LimitValidation.Redis/Sanhe.Abp.Features.LimitValidation.Redis.csproj new file mode 100644 index 0000000..16c520c --- /dev/null +++ b/modules/common/Sanhe.Abp.Features.LimitValidation.Redis/Sanhe.Abp.Features.LimitValidation.Redis.csproj @@ -0,0 +1,26 @@ + + + + + + + netstandard2.0 + + + + + + + + + + + + + + + + + + + diff --git a/modules/common/Sanhe.Abp.Features.LimitValidation.Redis/Sanhe/Abp/Features/LimitValidation/Redis/AbpFeaturesValidationRedisModule.cs b/modules/common/Sanhe.Abp.Features.LimitValidation.Redis/Sanhe/Abp/Features/LimitValidation/Redis/AbpFeaturesValidationRedisModule.cs new file mode 100644 index 0000000..31bf600 --- /dev/null +++ b/modules/common/Sanhe.Abp.Features.LimitValidation.Redis/Sanhe/Abp/Features/LimitValidation/Redis/AbpFeaturesValidationRedisModule.cs @@ -0,0 +1,24 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Volo.Abp.Modularity; +using Volo.Abp.VirtualFileSystem; + +namespace Sanhe.Abp.Features.LimitValidation.Redis; + +[DependsOn( + typeof(AbpFeaturesLimitValidationModule))] +public class AbpFeaturesValidationRedisModule : AbpModule +{ + public override void ConfigureServices(ServiceConfigurationContext context) + { + var configuration = context.Services.GetConfiguration(); + Configure(configuration.GetSection("Features:Validation:Redis")); + + Configure(options => + { + options.FileSets.AddEmbedded(); + }); + + context.Services.Replace(ServiceDescriptor.Singleton()); + } +} diff --git a/modules/common/Sanhe.Abp.Features.LimitValidation.Redis/Sanhe/Abp/Features/LimitValidation/Redis/AbpRedisRequiresLimitFeatureOptions.cs b/modules/common/Sanhe.Abp.Features.LimitValidation.Redis/Sanhe/Abp/Features/LimitValidation/Redis/AbpRedisRequiresLimitFeatureOptions.cs new file mode 100644 index 0000000..7ab5351 --- /dev/null +++ b/modules/common/Sanhe.Abp.Features.LimitValidation.Redis/Sanhe/Abp/Features/LimitValidation/Redis/AbpRedisRequiresLimitFeatureOptions.cs @@ -0,0 +1,22 @@ +using Microsoft.Extensions.Options; +using StackExchange.Redis; + +namespace Sanhe.Abp.Features.LimitValidation.Redis; + +public class AbpRedisRequiresLimitFeatureOptions : IOptions +{ + /// + /// Redis连接字符串 + /// + public string Configuration { get; set; } + /// + /// 前缀 + /// + public string InstanceName { get; set; } + /// + /// Redis连接配置 + /// + public ConfigurationOptions ConfigurationOptions { get; set; } + + AbpRedisRequiresLimitFeatureOptions IOptions.Value => this; +} diff --git a/modules/common/Sanhe.Abp.Features.LimitValidation.Redis/Sanhe/Abp/Features/LimitValidation/Redis/IRedisLimitFeatureNamingNormalizer.cs b/modules/common/Sanhe.Abp.Features.LimitValidation.Redis/Sanhe/Abp/Features/LimitValidation/Redis/IRedisLimitFeatureNamingNormalizer.cs new file mode 100644 index 0000000..0b27a56 --- /dev/null +++ b/modules/common/Sanhe.Abp.Features.LimitValidation.Redis/Sanhe/Abp/Features/LimitValidation/Redis/IRedisLimitFeatureNamingNormalizer.cs @@ -0,0 +1,6 @@ +namespace Sanhe.Abp.Features.LimitValidation.Redis; + +public interface IRedisLimitFeatureNamingNormalizer +{ + string NormalizeFeatureName(string instance, RequiresLimitFeatureContext context); +} diff --git a/modules/common/Sanhe.Abp.Features.LimitValidation.Redis/Sanhe/Abp/Features/LimitValidation/Redis/Lua/check.lua b/modules/common/Sanhe.Abp.Features.LimitValidation.Redis/Sanhe/Abp/Features/LimitValidation/Redis/Lua/check.lua new file mode 100644 index 0000000..d0ade3b --- /dev/null +++ b/modules/common/Sanhe.Abp.Features.LimitValidation.Redis/Sanhe/Abp/Features/LimitValidation/Redis/Lua/check.lua @@ -0,0 +1,4 @@ +if (redis.call('EXISTS', KEYS[1]) == 0) then + return 0 +end +return tonumber(redis.call('GET', KEYS[1])) \ No newline at end of file diff --git a/modules/common/Sanhe.Abp.Features.LimitValidation.Redis/Sanhe/Abp/Features/LimitValidation/Redis/Lua/process.lua b/modules/common/Sanhe.Abp.Features.LimitValidation.Redis/Sanhe/Abp/Features/LimitValidation/Redis/Lua/process.lua new file mode 100644 index 0000000..fc8ef53 --- /dev/null +++ b/modules/common/Sanhe.Abp.Features.LimitValidation.Redis/Sanhe/Abp/Features/LimitValidation/Redis/Lua/process.lua @@ -0,0 +1,6 @@ +if (redis.call('EXISTS',KEYS[1]) ~= 0) then + redis.call('INCRBY',KEYS[1], 1) +else + redis.call('SETEX',KEYS[1],ARGV[1],1) +end +return tonumber(redis.call('GET',KEYS[1])) \ No newline at end of file diff --git a/modules/common/Sanhe.Abp.Features.LimitValidation.Redis/Sanhe/Abp/Features/LimitValidation/Redis/RedisLimitFeatureNamingNormalizer.cs b/modules/common/Sanhe.Abp.Features.LimitValidation.Redis/Sanhe/Abp/Features/LimitValidation/Redis/RedisLimitFeatureNamingNormalizer.cs new file mode 100644 index 0000000..500d15e --- /dev/null +++ b/modules/common/Sanhe.Abp.Features.LimitValidation.Redis/Sanhe/Abp/Features/LimitValidation/Redis/RedisLimitFeatureNamingNormalizer.cs @@ -0,0 +1,24 @@ +using Volo.Abp.DependencyInjection; +using Volo.Abp.MultiTenancy; + +namespace Sanhe.Abp.Features.LimitValidation.Redis; + +public class RedisLimitFeatureNamingNormalizer : IRedisLimitFeatureNamingNormalizer, ISingletonDependency +{ + protected ICurrentTenant CurrentTenant { get; } + + public RedisLimitFeatureNamingNormalizer(ICurrentTenant currentTenant) + { + CurrentTenant = currentTenant; + } + + public virtual string NormalizeFeatureName(string instance, RequiresLimitFeatureContext context) + { + if (CurrentTenant.IsAvailable) + { + return $"{instance}t:RequiresLimitFeature;t:{CurrentTenant.Id};f:{context.LimitFeature}"; + } + + return $"{instance}c:RequiresLimitFeature;f:{context.LimitFeature}"; + } +} diff --git a/modules/common/Sanhe.Abp.Features.LimitValidation.Redis/Sanhe/Abp/Features/LimitValidation/Redis/RedisRequiresLimitFeatureChecker.cs b/modules/common/Sanhe.Abp.Features.LimitValidation.Redis/Sanhe/Abp/Features/LimitValidation/Redis/RedisRequiresLimitFeatureChecker.cs new file mode 100644 index 0000000..11a050e --- /dev/null +++ b/modules/common/Sanhe.Abp.Features.LimitValidation.Redis/Sanhe/Abp/Features/LimitValidation/Redis/RedisRequiresLimitFeatureChecker.cs @@ -0,0 +1,135 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using StackExchange.Redis; +using System; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Volo.Abp; +using Volo.Abp.DependencyInjection; +using Volo.Abp.VirtualFileSystem; + +namespace Sanhe.Abp.Features.LimitValidation.Redis; + +[DisableConventionalRegistration] +public class RedisRequiresLimitFeatureChecker : IRequiresLimitFeatureChecker +{ + private const string CHECK_LUA_SCRIPT = "/Sanhe/Abp/Features/LimitValidation/Redis/Lua/check.lua"; + private const string PROCESS_LUA_SCRIPT = "/Sanhe/Abp/Features/LimitValidation/Redis/Lua/process.lua"; + + public ILogger Logger { protected get; set; } + + private volatile ConnectionMultiplexer _connection; + private volatile ConfigurationOptions _redisConfig; + private IDatabaseAsync _redis; + private IServer _server; + + private readonly IVirtualFileProvider _virtualFileProvider; + private readonly IRedisLimitFeatureNamingNormalizer _featureNamingNormalizer; + private readonly AbpRedisRequiresLimitFeatureOptions _options; + private readonly string _instance; + + private readonly SemaphoreSlim _connectionLock = new(initialCount: 1, maxCount: 1); + + public RedisRequiresLimitFeatureChecker( + IVirtualFileProvider virtualFileProvider, + IRedisLimitFeatureNamingNormalizer featureNamingNormalizer, + IOptions optionsAccessor) + { + if (optionsAccessor == null) + { + throw new ArgumentNullException(nameof(optionsAccessor)); + } + + _options = optionsAccessor.Value; + _virtualFileProvider = virtualFileProvider; + _featureNamingNormalizer = featureNamingNormalizer; + + _instance = _options.InstanceName ?? string.Empty; + + Logger = NullLogger.Instance; + } + + public virtual async Task CheckAsync(RequiresLimitFeatureContext context, CancellationToken cancellation = default) + { + await ConnectAsync(cancellation); + + var result = await EvaluateAsync(CHECK_LUA_SCRIPT, context, cancellation); + return result + 1 <= context.Limit; + } + + public virtual async Task ProcessAsync(RequiresLimitFeatureContext context, CancellationToken cancellation = default) + { + await ConnectAsync(cancellation); + + await EvaluateAsync(PROCESS_LUA_SCRIPT, context, cancellation); + } + + private async Task EvaluateAsync(string luaScriptFilePath, RequiresLimitFeatureContext context, CancellationToken cancellation = default) + { + var luaScriptFile = _virtualFileProvider.GetFileInfo(luaScriptFilePath); + using var luaScriptFileStream = luaScriptFile.CreateReadStream(); + var fileBytes = await luaScriptFileStream.GetAllBytesAsync(cancellation); + + var luaSha1 = fileBytes.Sha1(); + if (!await _server.ScriptExistsAsync(luaSha1)) + { + var luaScript = Encoding.UTF8.GetString(fileBytes); + luaSha1 = await _server.ScriptLoadAsync(luaScript); + } + + var keys = new RedisKey[1] { NormalizeKey(context) }; + var values = new RedisValue[] { context.GetEffectTicks() }; + var result = await _redis.ScriptEvaluateAsync(luaSha1, keys, values); + + if (result.Type == ResultType.Error) + { + throw new AbpException($"Script evaluate error: {result}"); + } + + return (int)result; + } + + private string NormalizeKey(RequiresLimitFeatureContext context) + { + return _featureNamingNormalizer.NormalizeFeatureName(_instance, context); + } + + private async Task ConnectAsync(CancellationToken token = default) + { + token.ThrowIfCancellationRequested(); + + if (_redis != null) + { + return; + } + + await _connectionLock.WaitAsync(token); + try + { + if (_redis == null) + { + if (_options.ConfigurationOptions != null) + { + _redisConfig = _options.ConfigurationOptions; + } + else + { + _redisConfig = ConfigurationOptions.Parse(_options.Configuration); + } + _redisConfig.AllowAdmin = true; + _redisConfig.SetDefaultPorts(); + _connection = await ConnectionMultiplexer.ConnectAsync(_redisConfig); + // fix: 无需关注redis连接事件 + _redis = _connection.GetDatabase(); + _server = _connection.GetServer(_redisConfig.EndPoints[0]); + } + } + finally + { + _connectionLock.Release(); + } + } +} diff --git a/modules/common/Sanhe.Abp.Features.LimitValidation.Redis/System/BytesExtensions.cs b/modules/common/Sanhe.Abp.Features.LimitValidation.Redis/System/BytesExtensions.cs new file mode 100644 index 0000000..cba8afd --- /dev/null +++ b/modules/common/Sanhe.Abp.Features.LimitValidation.Redis/System/BytesExtensions.cs @@ -0,0 +1,15 @@ +using System.Security.Cryptography; + +namespace System; + +internal static class BytesExtensions +{ + public static byte[] Sha1(this byte[] data) + { + using (var sha = SHA1.Create()) + { + var hashBytes = sha.ComputeHash(data); + return hashBytes; + } + } +} diff --git a/modules/common/Sanhe.Abp.Features.LimitValidation/FodyWeavers.xml b/modules/common/Sanhe.Abp.Features.LimitValidation/FodyWeavers.xml new file mode 100644 index 0000000..1715698 --- /dev/null +++ b/modules/common/Sanhe.Abp.Features.LimitValidation/FodyWeavers.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/modules/common/Sanhe.Abp.Features.LimitValidation/README.md b/modules/common/Sanhe.Abp.Features.LimitValidation/README.md new file mode 100644 index 0000000..1e419d7 --- /dev/null +++ b/modules/common/Sanhe.Abp.Features.LimitValidation/README.md @@ -0,0 +1,85 @@ +# Sanhe.Abp.Features.LimitValidation + +功能上限验证组件 + +检查定义的功能调用次数,来限制特定的实体(租户、用户、客户端等)对于应用程序的调用 + +预先设定了如下几个策略 + +LimitPolicy.Minute 按分钟计算流量 +LimitPolicy.Hours 按小时计算流量 +LimitPolicy.Days 按天数计算流量 +LimitPolicy.Weeks 按周数计算流量 +LimitPolicy.Month 按月数计算流量 +LimitPolicy.Years 按年数计算流量 + +## 配置使用 + + +```csharp +[DependsOn(typeof(AbpFeaturesLimitValidationModule))] +public class YouProjectModule : AbpModule +{ + // other +} + +public static class FakeFeatureNames +{ + public const string GroupName = "FakeFeature.Tests"; + // 类型限制调用次数功能名称 + public const string ClassLimitFeature = GroupName + ".LimitFeature"; + // 方法限制调用次数功能名称 + public const string MethodLimitFeature = GroupName + ".MethodLimitFeature"; + // 限制调用间隔功能名称 + public const string IntervalFeature = GroupName + ".IntervalFeature"; +} + +// 流量限制依赖自定义功能 +public class FakeFeatureDefinitionProvider : FeatureDefinitionProvider +{ + public override void Define(IFeatureDefinitionContext context) + { + var featureGroup = context.AddGroup(FakeFeatureNames.GroupName); + featureGroup.AddFeature( + name: FakeFeatureNames.ClassLimitFeature, + defaultValue: 1000.ToString(), // 周期内最大允许调用1000次 + valueType: new ToggleStringValueType(new NumericValueValidator(1, 1000))); + featureGroup.AddFeature( + name: FakeFeatureNames.MethodLimitFeature, + defaultValue: 100.ToString(), // 周期内最大允许调用100次 + valueType: new ToggleStringValueType(new NumericValueValidator(1, 1000))); + featureGroup.AddFeature( + name: FakeFeatureNames.IntervalFeature, + defaultValue: 1.ToString(), // 限制周期 + valueType: new ToggleStringValueType(new NumericValueValidator(1, 1000))); + } +} + +// 按照预设的参数,类型在一天钟内仅允许调用1000次 +[RequiresLimitFeature(FakeFeatureNames.ClassLimitFeature, FakeFeatureNames.IntervalFeature, LimitPolicy.Days)] +public class FakeLimitClass +{ + // 按照预设的参数,方法在一分钟内仅允许调用100次 + [RequiresLimitFeature(FakeFeatureNames.MethodLimitFeature, FakeFeatureNames.IntervalFeature, LimitPolicy.Minute)] + public void LimitMethod() + { + // other... + } +} +``` + +如果需要自行处理功能限制策略时长,请覆盖对应策略的默认策略,返回的时钟刻度单位始终是秒 + +```csharp +[DependsOn(typeof(AbpFeaturesLimitValidationModule))] +public class YouProjectModule : AbpModule +{ + public override void PreConfigureServices(ServiceConfigurationContext context) + { + Configure(options => + { + options.MapEffectPolicy(LimitPolicy.Minute, (time) => return 60;); // 表示不管多少分钟(time),都只会限制60秒 + }); + } +} +``` diff --git a/modules/common/Sanhe.Abp.Features.LimitValidation/Sanhe.Abp.Features.LimitValidation.csproj b/modules/common/Sanhe.Abp.Features.LimitValidation/Sanhe.Abp.Features.LimitValidation.csproj new file mode 100644 index 0000000..6e9bfe8 --- /dev/null +++ b/modules/common/Sanhe.Abp.Features.LimitValidation/Sanhe.Abp.Features.LimitValidation.csproj @@ -0,0 +1,24 @@ + + + + + + + netstandard2.0 + + + + + + + + + + + + + + + + + diff --git a/modules/common/Sanhe.Abp.Features.LimitValidation/Sanhe/Abp/Features/LimitValidation/AbpFeatureLimitException.cs b/modules/common/Sanhe.Abp.Features.LimitValidation/Sanhe/Abp/Features/LimitValidation/AbpFeatureLimitException.cs new file mode 100644 index 0000000..babfd5a --- /dev/null +++ b/modules/common/Sanhe.Abp.Features.LimitValidation/Sanhe/Abp/Features/LimitValidation/AbpFeatureLimitException.cs @@ -0,0 +1,33 @@ +using Microsoft.Extensions.Localization; +using Sanhe.Abp.Features.LimitValidation.Localization; +using Volo.Abp; +using Volo.Abp.ExceptionHandling; +using Volo.Abp.Localization; + +namespace Sanhe.Abp.Features.LimitValidation; + +public class AbpFeatureLimitException : AbpException, ILocalizeErrorMessage, IBusinessException +{ + /// + /// 功能名称名称 + /// + public string Feature { get; } + /// + /// 上限 + /// + public int Limit { get; } + + public AbpFeatureLimitException(string feature, int limit) + : base($"Features {feature} has exceeded the maximum number of calls {limit}, please apply for the appropriate permission") + { + Feature = feature; + Limit = limit; + } + + public string LocalizeMessage(LocalizationContext context) + { + var localizer = context.LocalizerFactory.Create(); + + return localizer["FeaturesLimitException", Limit]; + } +} diff --git a/modules/common/Sanhe.Abp.Features.LimitValidation/Sanhe/Abp/Features/LimitValidation/AbpFeaturesLimitValidationModule.cs b/modules/common/Sanhe.Abp.Features.LimitValidation/Sanhe/Abp/Features/LimitValidation/AbpFeaturesLimitValidationModule.cs new file mode 100644 index 0000000..53d447c --- /dev/null +++ b/modules/common/Sanhe.Abp.Features.LimitValidation/Sanhe/Abp/Features/LimitValidation/AbpFeaturesLimitValidationModule.cs @@ -0,0 +1,34 @@ +using Microsoft.Extensions.DependencyInjection; +using Sanhe.Abp.Features.LimitValidation.Localization; +using Volo.Abp.Features; +using Volo.Abp.Localization; +using Volo.Abp.Modularity; +using Volo.Abp.VirtualFileSystem; + +namespace Sanhe.Abp.Features.LimitValidation; + +[DependsOn(typeof(AbpFeaturesModule))] +public class AbpFeaturesLimitValidationModule : AbpModule +{ + public override void PreConfigureServices(ServiceConfigurationContext context) + { + context.Services.OnRegistred(FeaturesLimitValidationInterceptorRegistrar.RegisterIfNeeded); + + Configure(options => + { + options.MapDefaultEffectPolicys(); + }); + + Configure(options => + { + options.FileSets.AddEmbedded(); + }); + + Configure(options => + { + options.Resources + .Add("en") + .AddVirtualJson("/Sanhe/Abp/Features/LimitValidation/Localization/Resources"); + }); + } +} diff --git a/modules/common/Sanhe.Abp.Features.LimitValidation/Sanhe/Abp/Features/LimitValidation/AbpFeaturesLimitValidationOptions.cs b/modules/common/Sanhe.Abp.Features.LimitValidation/Sanhe/Abp/Features/LimitValidation/AbpFeaturesLimitValidationOptions.cs new file mode 100644 index 0000000..7bc9df5 --- /dev/null +++ b/modules/common/Sanhe.Abp.Features.LimitValidation/Sanhe/Abp/Features/LimitValidation/AbpFeaturesLimitValidationOptions.cs @@ -0,0 +1,48 @@ +using JetBrains.Annotations; +using System; +using System.Collections.Generic; +using Volo.Abp; + +namespace Sanhe.Abp.Features.LimitValidation; + +public class AbpFeaturesLimitValidationOptions +{ + public IDictionary> EffectPolicys { get; } + + public AbpFeaturesLimitValidationOptions() + { + EffectPolicys = new Dictionary>(); + } + + /// + /// 变更功能限制策略时长计算方法 + /// + /// 限制策略 + /// 自定义的计算方法 + /// + /// 返回值一定要是秒钟刻度 + /// + public void MapEffectPolicy(LimitPolicy policy, [NotNull] Func func) + { + Check.NotNull(func, nameof(func)); + + if (EffectPolicys.ContainsKey(policy)) + { + EffectPolicys[policy] = func; + } + else + { + EffectPolicys.Add(policy, func); + } + } + + internal void MapDefaultEffectPolicys() + { + MapEffectPolicy(LimitPolicy.Minute, (time) => { return (long)(DateTimeOffset.UtcNow.AddMinutes(time) - DateTimeOffset.UtcNow).TotalSeconds; }); + MapEffectPolicy(LimitPolicy.Hours, (time) => { return (long)(DateTimeOffset.UtcNow.AddHours(time) - DateTimeOffset.UtcNow).TotalSeconds; }); + MapEffectPolicy(LimitPolicy.Days, (time) => { return (long)(DateTimeOffset.UtcNow.AddDays(time) - DateTimeOffset.UtcNow).TotalSeconds; }); + MapEffectPolicy(LimitPolicy.Weeks, (time) => { return (long)(DateTimeOffset.UtcNow.AddDays(time * 7) - DateTimeOffset.UtcNow).TotalSeconds; }); + MapEffectPolicy(LimitPolicy.Month, (time) => { return (long)(DateTimeOffset.UtcNow.AddMonths(time) - DateTimeOffset.UtcNow).TotalSeconds; }); + MapEffectPolicy(LimitPolicy.Years, (time) => { return (long)(DateTimeOffset.UtcNow.AddYears(time) - DateTimeOffset.UtcNow).TotalSeconds; }); + } +} diff --git a/modules/common/Sanhe.Abp.Features.LimitValidation/Sanhe/Abp/Features/LimitValidation/FeaturesLimitValidationInterceptor.cs b/modules/common/Sanhe.Abp.Features.LimitValidation/Sanhe/Abp/Features/LimitValidation/FeaturesLimitValidationInterceptor.cs new file mode 100644 index 0000000..007a460 --- /dev/null +++ b/modules/common/Sanhe.Abp.Features.LimitValidation/Sanhe/Abp/Features/LimitValidation/FeaturesLimitValidationInterceptor.cs @@ -0,0 +1,99 @@ +using Microsoft.Extensions.Options; +using System.Reflection; +using System.Threading.Tasks; +using Volo.Abp.Aspects; +using Volo.Abp.DependencyInjection; +using Volo.Abp.DynamicProxy; +using Volo.Abp.Features; +using Volo.Abp.Validation.StringValues; + +namespace Sanhe.Abp.Features.LimitValidation; + +public class FeaturesLimitValidationInterceptor : AbpInterceptor, ITransientDependency +{ + private readonly IFeatureChecker _featureChecker; + private readonly AbpFeaturesLimitValidationOptions _options; + private readonly IRequiresLimitFeatureChecker _limitFeatureChecker; + private readonly IFeatureDefinitionManager _featureDefinitionManager; + + public FeaturesLimitValidationInterceptor( + IFeatureChecker featureChecker, + IRequiresLimitFeatureChecker limitFeatureChecker, + IFeatureDefinitionManager featureDefinitionManager, + IOptions options) + { + _options = options.Value; + _featureChecker = featureChecker; + _limitFeatureChecker = limitFeatureChecker; + _featureDefinitionManager = featureDefinitionManager; + } + + public override async Task InterceptAsync(IAbpMethodInvocation invocation) + { + if (AbpCrossCuttingConcerns.IsApplied(invocation.TargetObject, AbpCrossCuttingConcerns.FeatureChecking)) + { + await invocation.ProceedAsync(); + return; + } + + var limitFeature = GetRequiresLimitFeature(invocation.Method); + + if (limitFeature == null) + { + await invocation.ProceedAsync(); + return; + } + + // 获取功能限制上限 + var limit = await _featureChecker.GetAsync(limitFeature.LimitFeature, limitFeature.DefaultLimit); + // 获取功能限制时长 + var interval = await _featureChecker.GetAsync(limitFeature.IntervalFeature, limitFeature.DefaultInterval); + // 必要的上下文参数 + var limitFeatureContext = new RequiresLimitFeatureContext(limitFeature.LimitFeature, _options, limitFeature.Policy, interval, limit); + // 检查次数限制 + await PreCheckFeatureAsync(limitFeatureContext); + // 执行代理方法 + await invocation.ProceedAsync(); + // 调用次数递增 + // TODO: 使用Redis结合Lua脚本? + await PostCheckFeatureAsync(limitFeatureContext); + } + + protected virtual async Task PreCheckFeatureAsync(RequiresLimitFeatureContext context) + { + var allowed = await _limitFeatureChecker.CheckAsync(context); + if (!allowed) + { + throw new AbpFeatureLimitException(context.LimitFeature, context.Limit); + } + } + + protected virtual async Task PostCheckFeatureAsync(RequiresLimitFeatureContext context) + { + await _limitFeatureChecker.ProcessAsync(context); + } + + protected virtual RequiresLimitFeatureAttribute GetRequiresLimitFeature(MethodInfo methodInfo) + { + var limitFeature = methodInfo.GetCustomAttribute(false); + if (limitFeature != null) + { + // 限制次数定义的不是范围参数,则不参与限制功能 + var featureLimitDefinition = _featureDefinitionManager.GetOrNull(limitFeature.LimitFeature); + if (featureLimitDefinition == null || + !typeof(NumericValueValidator).IsAssignableFrom(featureLimitDefinition.ValueType.Validator.GetType())) + { + return null; + } + + // 时长刻度定义的不是范围参数,则不参与限制功能 + var featureIntervalDefinition = _featureDefinitionManager.GetOrNull(limitFeature.IntervalFeature); + if (featureIntervalDefinition == null || + !typeof(NumericValueValidator).IsAssignableFrom(featureIntervalDefinition.ValueType.Validator.GetType())) + { + return null; + } + } + return limitFeature; + } +} diff --git a/modules/common/Sanhe.Abp.Features.LimitValidation/Sanhe/Abp/Features/LimitValidation/FeaturesLimitValidationInterceptorRegistrar.cs b/modules/common/Sanhe.Abp.Features.LimitValidation/Sanhe/Abp/Features/LimitValidation/FeaturesLimitValidationInterceptorRegistrar.cs new file mode 100644 index 0000000..029e9f5 --- /dev/null +++ b/modules/common/Sanhe.Abp.Features.LimitValidation/Sanhe/Abp/Features/LimitValidation/FeaturesLimitValidationInterceptorRegistrar.cs @@ -0,0 +1,37 @@ +using System; +using System.Linq; +using System.Reflection; +using Volo.Abp.DependencyInjection; +using Volo.Abp.DynamicProxy; + +namespace Sanhe.Abp.Features.LimitValidation; + +public static class FeaturesLimitValidationInterceptorRegistrar +{ + public static void RegisterIfNeeded(IOnServiceRegistredContext context) + { + if (ShouldIntercept(context.ImplementationType)) + { + context.Interceptors.TryAdd(); + } + } + + private static bool ShouldIntercept(Type type) + { + return !DynamicProxyIgnoreTypes.Contains(type) && + (type.IsDefined(typeof(RequiresLimitFeatureAttribute), true) || + AnyMethodHasRequiresLimitFeatureAttribute(type)); + } + + private static bool AnyMethodHasRequiresLimitFeatureAttribute(Type implementationType) + { + return implementationType + .GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + .Any(HasRequiresLimitFeatureAttribute); + } + + private static bool HasRequiresLimitFeatureAttribute(MemberInfo methodInfo) + { + return methodInfo.IsDefined(typeof(RequiresLimitFeatureAttribute), true); + } +} diff --git a/modules/common/Sanhe.Abp.Features.LimitValidation/Sanhe/Abp/Features/LimitValidation/IRequiresLimitFeatureChecker.cs b/modules/common/Sanhe.Abp.Features.LimitValidation/Sanhe/Abp/Features/LimitValidation/IRequiresLimitFeatureChecker.cs new file mode 100644 index 0000000..73eeccd --- /dev/null +++ b/modules/common/Sanhe.Abp.Features.LimitValidation/Sanhe/Abp/Features/LimitValidation/IRequiresLimitFeatureChecker.cs @@ -0,0 +1,11 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Sanhe.Abp.Features.LimitValidation; + +public interface IRequiresLimitFeatureChecker +{ + Task CheckAsync(RequiresLimitFeatureContext context, CancellationToken cancellation = default); + + Task ProcessAsync(RequiresLimitFeatureContext context, CancellationToken cancellation = default); +} diff --git a/modules/common/Sanhe.Abp.Features.LimitValidation/Sanhe/Abp/Features/LimitValidation/LimitPolicy.cs b/modules/common/Sanhe.Abp.Features.LimitValidation/Sanhe/Abp/Features/LimitValidation/LimitPolicy.cs new file mode 100644 index 0000000..0c31ca0 --- /dev/null +++ b/modules/common/Sanhe.Abp.Features.LimitValidation/Sanhe/Abp/Features/LimitValidation/LimitPolicy.cs @@ -0,0 +1,32 @@ +namespace Sanhe.Abp.Features.LimitValidation; + +/// +/// 限制策略。 +/// +public enum LimitPolicy : byte +{ + /// + /// 按分钟限制 + /// + Minute = 0, + /// + /// 按小时限制 + /// + Hours = 10, + /// + /// 按天限制 + /// + Days = 20, + /// + /// 按周限制 + /// + Weeks = 30, + /// + /// 按月限制 + /// + Month = 40, + /// + /// 按年限制 + /// + Years = 50 +} diff --git a/modules/common/Sanhe.Abp.Features.LimitValidation/Sanhe/Abp/Features/LimitValidation/Localization/FeaturesLimitValidationResource.cs b/modules/common/Sanhe.Abp.Features.LimitValidation/Sanhe/Abp/Features/LimitValidation/Localization/FeaturesLimitValidationResource.cs new file mode 100644 index 0000000..6a73e27 --- /dev/null +++ b/modules/common/Sanhe.Abp.Features.LimitValidation/Sanhe/Abp/Features/LimitValidation/Localization/FeaturesLimitValidationResource.cs @@ -0,0 +1,8 @@ +using Volo.Abp.Localization; + +namespace Sanhe.Abp.Features.LimitValidation.Localization; + +[LocalizationResourceName("AbpFeaturesLimitValidation")] +public class FeaturesLimitValidationResource +{ +} diff --git a/modules/common/Sanhe.Abp.Features.LimitValidation/Sanhe/Abp/Features/LimitValidation/Localization/Resources/en.json b/modules/common/Sanhe.Abp.Features.LimitValidation/Sanhe/Abp/Features/LimitValidation/Localization/Resources/en.json new file mode 100644 index 0000000..46799f1 --- /dev/null +++ b/modules/common/Sanhe.Abp.Features.LimitValidation/Sanhe/Abp/Features/LimitValidation/Localization/Resources/en.json @@ -0,0 +1,6 @@ +{ + "culture": "en", + "texts": { + "FeaturesLimitException": "Service has exceeded the maximum number of calls {0}. Please apply for the appropriate permissions" + } +} diff --git a/modules/common/Sanhe.Abp.Features.LimitValidation/Sanhe/Abp/Features/LimitValidation/Localization/Resources/zh-Hans.json b/modules/common/Sanhe.Abp.Features.LimitValidation/Sanhe/Abp/Features/LimitValidation/Localization/Resources/zh-Hans.json new file mode 100644 index 0000000..cb94820 --- /dev/null +++ b/modules/common/Sanhe.Abp.Features.LimitValidation/Sanhe/Abp/Features/LimitValidation/Localization/Resources/zh-Hans.json @@ -0,0 +1,6 @@ +{ + "culture": "zh-Hans", + "texts": { + "FeaturesLimitException": "服务已超过最大调用次数 {0},请申请适当的权限" + } +} diff --git a/modules/common/Sanhe.Abp.Features.LimitValidation/Sanhe/Abp/Features/LimitValidation/NullRequiresLimitFeatureChecker.cs b/modules/common/Sanhe.Abp.Features.LimitValidation/Sanhe/Abp/Features/LimitValidation/NullRequiresLimitFeatureChecker.cs new file mode 100644 index 0000000..b9e8df9 --- /dev/null +++ b/modules/common/Sanhe.Abp.Features.LimitValidation/Sanhe/Abp/Features/LimitValidation/NullRequiresLimitFeatureChecker.cs @@ -0,0 +1,18 @@ +using System.Threading; +using System.Threading.Tasks; +using Volo.Abp.DependencyInjection; + +namespace Sanhe.Abp.Features.LimitValidation; + +public class NullRequiresLimitFeatureChecker : IRequiresLimitFeatureChecker, ISingletonDependency +{ + public Task CheckAsync(RequiresLimitFeatureContext context, CancellationToken cancellation = default) + { + return Task.FromResult(true); + } + + public Task ProcessAsync(RequiresLimitFeatureContext context, CancellationToken cancellation = default) + { + return Task.CompletedTask; + } +} diff --git a/modules/common/Sanhe.Abp.Features.LimitValidation/Sanhe/Abp/Features/LimitValidation/RequiresLimitFeatureAttribute.cs b/modules/common/Sanhe.Abp.Features.LimitValidation/Sanhe/Abp/Features/LimitValidation/RequiresLimitFeatureAttribute.cs new file mode 100644 index 0000000..b03b596 --- /dev/null +++ b/modules/common/Sanhe.Abp.Features.LimitValidation/Sanhe/Abp/Features/LimitValidation/RequiresLimitFeatureAttribute.cs @@ -0,0 +1,53 @@ +using JetBrains.Annotations; +using System; +using Volo.Abp; + +namespace Sanhe.Abp.Features.LimitValidation; + +/// +/// 单个功能的调用量限制 +/// +/// +/// 需要对于限制时长和限制上限功能区分,以便于更细粒度的限制 +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false)] +public class RequiresLimitFeatureAttribute : Attribute +{ + /// + /// 功能限制策略 + /// + public LimitPolicy Policy { get; } + /// + /// 默认限制时长 + /// + public int DefaultLimit { get; } + /// + /// 限制上限名称 + /// + public string LimitFeature { get; } + /// + /// 默认限制时长 + /// + public int DefaultInterval { get; } + /// + /// 限制时长名称 + /// + public string IntervalFeature { get; } + + public RequiresLimitFeatureAttribute( + [NotNull] string limitFeature, + [NotNull] string intervalFeature, + LimitPolicy policy = LimitPolicy.Month, + int defaultLimit = 1, + int defaultInterval = 1) + { + Check.NotNullOrWhiteSpace(limitFeature, nameof(limitFeature)); + Check.NotNullOrWhiteSpace(intervalFeature, nameof(intervalFeature)); + + Policy = policy; + LimitFeature = limitFeature; + DefaultLimit = defaultLimit; + IntervalFeature = intervalFeature; + DefaultInterval = defaultInterval; + } +} diff --git a/modules/common/Sanhe.Abp.Features.LimitValidation/Sanhe/Abp/Features/LimitValidation/RequiresLimitFeatureContext.cs b/modules/common/Sanhe.Abp.Features.LimitValidation/Sanhe/Abp/Features/LimitValidation/RequiresLimitFeatureContext.cs new file mode 100644 index 0000000..a9e745a --- /dev/null +++ b/modules/common/Sanhe.Abp.Features.LimitValidation/Sanhe/Abp/Features/LimitValidation/RequiresLimitFeatureContext.cs @@ -0,0 +1,46 @@ +namespace Sanhe.Abp.Features.LimitValidation; + +public class RequiresLimitFeatureContext +{ + /// + /// 功能限制策略 + /// + public LimitPolicy Policy { get; } + /// + /// 限制时长 + /// + public int Interval { get; } + /// + /// 功能限制次数 + /// + public int Limit { get; } + /// + /// 功能限制次数名称 + /// + public string LimitFeature { get; } + + public AbpFeaturesLimitValidationOptions Options { get; } + + public RequiresLimitFeatureContext( + string limitFeature, + AbpFeaturesLimitValidationOptions options, + LimitPolicy policy = LimitPolicy.Month, + int interval = 1, + int limit = 1) + { + Limit = limit; + Policy = policy; + Interval = interval; + LimitFeature = limitFeature; + Options = options; + } + + /// + /// 获取生效时间跨度,单位:s + /// + /// + public long GetEffectTicks() + { + return Options.EffectPolicys[Policy](Interval); + } +}