diff --git a/aspnet-core/LINGYUN.MicroService.Common.sln b/aspnet-core/LINGYUN.MicroService.Common.sln index e3b9965ee..c878eb1b2 100644 --- a/aspnet-core/LINGYUN.MicroService.Common.sln +++ b/aspnet-core/LINGYUN.MicroService.Common.sln @@ -321,6 +321,12 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Directory.Build.props = Directory.Build.props EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LINGYUN.Abp.AspNetCore.Mvc.Idempotent", "modules\mvc\LINGYUN.Abp.AspNetCore.Mvc.Idempotent\LINGYUN.Abp.AspNetCore.Mvc.Idempotent.csproj", "{347413DD-1B30-46B5-87A0-828A11FAA87D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LINGYUN.Abp.Idempotent", "modules\common\LINGYUN.Abp.Idempotent\LINGYUN.Abp.Idempotent.csproj", "{13FCEB03-E300-4CE2-A789-78D9F41C903E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LINGYUN.Abp.AspNetCore.Mvc.Idempotent.Wrapper", "modules\mvc\LINGYUN.Abp.AspNetCore.Mvc.Idempotent.Wrapper\LINGYUN.Abp.AspNetCore.Mvc.Idempotent.Wrapper.csproj", "{8B15AAB5-18BB-4A2E-86F1-4A2F04C9FAFF}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -823,6 +829,18 @@ Global {4059233C-C651-4DA2-A1BC-26196362062A}.Debug|Any CPU.Build.0 = Debug|Any CPU {4059233C-C651-4DA2-A1BC-26196362062A}.Release|Any CPU.ActiveCfg = Release|Any CPU {4059233C-C651-4DA2-A1BC-26196362062A}.Release|Any CPU.Build.0 = Release|Any CPU + {347413DD-1B30-46B5-87A0-828A11FAA87D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {347413DD-1B30-46B5-87A0-828A11FAA87D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {347413DD-1B30-46B5-87A0-828A11FAA87D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {347413DD-1B30-46B5-87A0-828A11FAA87D}.Release|Any CPU.Build.0 = Release|Any CPU + {13FCEB03-E300-4CE2-A789-78D9F41C903E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {13FCEB03-E300-4CE2-A789-78D9F41C903E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {13FCEB03-E300-4CE2-A789-78D9F41C903E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {13FCEB03-E300-4CE2-A789-78D9F41C903E}.Release|Any CPU.Build.0 = Release|Any CPU + {8B15AAB5-18BB-4A2E-86F1-4A2F04C9FAFF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8B15AAB5-18BB-4A2E-86F1-4A2F04C9FAFF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8B15AAB5-18BB-4A2E-86F1-4A2F04C9FAFF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8B15AAB5-18BB-4A2E-86F1-4A2F04C9FAFF}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -979,6 +997,9 @@ Global {4CEFE059-B30E-4121-AA12-10EC72709758} = {C12EEBC0-0407-4AEF-81C4-EDF5E22BB00E} {C9EC8CCF-5CA7-4332-B7B7-FF9B094FA418} = {C12EEBC0-0407-4AEF-81C4-EDF5E22BB00E} {4059233C-C651-4DA2-A1BC-26196362062A} = {C12EEBC0-0407-4AEF-81C4-EDF5E22BB00E} + {347413DD-1B30-46B5-87A0-828A11FAA87D} = {F55B987D-1DFF-4EB0-9949-8A7136A7B689} + {13FCEB03-E300-4CE2-A789-78D9F41C903E} = {086BE5BE-8594-4DA7-8819-935FEF76DABD} + {8B15AAB5-18BB-4A2E-86F1-4A2F04C9FAFF} = {F55B987D-1DFF-4EB0-9949-8A7136A7B689} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {06C707C6-02C0-411A-AD3B-2D0E13787CB8} diff --git a/aspnet-core/modules/common/LINGYUN.Abp.Idempotent/FodyWeavers.xml b/aspnet-core/modules/common/LINGYUN.Abp.Idempotent/FodyWeavers.xml new file mode 100644 index 000000000..1715698cc --- /dev/null +++ b/aspnet-core/modules/common/LINGYUN.Abp.Idempotent/FodyWeavers.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/aspnet-core/modules/common/LINGYUN.Abp.Idempotent/FodyWeavers.xsd b/aspnet-core/modules/common/LINGYUN.Abp.Idempotent/FodyWeavers.xsd new file mode 100644 index 000000000..11da52550 --- /dev/null +++ b/aspnet-core/modules/common/LINGYUN.Abp.Idempotent/FodyWeavers.xsd @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + 'true' to run assembly verification (PEVerify) on the target assembly after all weavers have been executed. + + + + + A comma-separated list of error codes that can be safely ignored in assembly verification. + + + + + 'false' to turn off automatic generation of the XML Schema file. + + + + + \ No newline at end of file diff --git a/aspnet-core/modules/common/LINGYUN.Abp.Idempotent/LINGYUN.Abp.Idempotent.csproj b/aspnet-core/modules/common/LINGYUN.Abp.Idempotent/LINGYUN.Abp.Idempotent.csproj new file mode 100644 index 000000000..8897ad0dc --- /dev/null +++ b/aspnet-core/modules/common/LINGYUN.Abp.Idempotent/LINGYUN.Abp.Idempotent.csproj @@ -0,0 +1,26 @@ + + + + + + + netstandard2.0 + enable + + + + + + + + + + + + + + + + + + diff --git a/aspnet-core/modules/common/LINGYUN.Abp.Idempotent/LINGYUN/Abp/Idempotent/AbpIdempotentModule.cs b/aspnet-core/modules/common/LINGYUN.Abp.Idempotent/LINGYUN/Abp/Idempotent/AbpIdempotentModule.cs new file mode 100644 index 000000000..81ca45879 --- /dev/null +++ b/aspnet-core/modules/common/LINGYUN.Abp.Idempotent/LINGYUN/Abp/Idempotent/AbpIdempotentModule.cs @@ -0,0 +1,36 @@ +using LINGYUN.Abp.Idempotent.Localization; +using Microsoft.Extensions.DependencyInjection; +using Volo.Abp.Application; +using Volo.Abp.DistributedLocking; +using Volo.Abp.Json; +using Volo.Abp.Localization; +using Volo.Abp.Modularity; +using Volo.Abp.VirtualFileSystem; + +namespace LINGYUN.Abp.Idempotent; + +[DependsOn( + typeof(AbpDddApplicationContractsModule), + typeof(AbpDistributedLockingAbstractionsModule), + typeof(AbpJsonAbstractionsModule))] +public class AbpIdempotentModule : AbpModule +{ + public override void PreConfigureServices(ServiceConfigurationContext context) + { + context.Services.OnRegistred(IdempotentInterceptorRegistrar.RegisterIfNeeded); + } + + public override void ConfigureServices(ServiceConfigurationContext context) + { + Configure(options => + { + options.FileSets.AddEmbedded(); + }); + + Configure(options => + { + options.Resources.Add() + .AddVirtualJson("/LINGYUN/Abp/Idempotent/Localization/Resources"); + }); + } +} diff --git a/aspnet-core/modules/common/LINGYUN.Abp.Idempotent/LINGYUN/Abp/Idempotent/AbpIdempotentOptions.cs b/aspnet-core/modules/common/LINGYUN.Abp.Idempotent/LINGYUN/Abp/Idempotent/AbpIdempotentOptions.cs new file mode 100644 index 000000000..885f4aa95 --- /dev/null +++ b/aspnet-core/modules/common/LINGYUN.Abp.Idempotent/LINGYUN/Abp/Idempotent/AbpIdempotentOptions.cs @@ -0,0 +1,15 @@ +namespace LINGYUN.Abp.Idempotent; +public class AbpIdempotentOptions +{ + public bool IsEnabled { get; set; } + public int DefaultTimeout { get; set; } + public string IdempotentTokenName { get; set; } + public int HttpStatusCode { get; set; } + public AbpIdempotentOptions() + { + DefaultTimeout = 30000; + IdempotentTokenName = "X-With-Idempotent-Token"; + // 默认使用 TooManyRequests(429)代码 + HttpStatusCode = 429; + } +} diff --git a/aspnet-core/modules/common/LINGYUN.Abp.Idempotent/LINGYUN/Abp/Idempotent/IIdempotentChecker.cs b/aspnet-core/modules/common/LINGYUN.Abp.Idempotent/LINGYUN/Abp/Idempotent/IIdempotentChecker.cs new file mode 100644 index 000000000..d0849d764 --- /dev/null +++ b/aspnet-core/modules/common/LINGYUN.Abp.Idempotent/LINGYUN/Abp/Idempotent/IIdempotentChecker.cs @@ -0,0 +1,7 @@ +using System.Threading.Tasks; + +namespace LINGYUN.Abp.Idempotent; +public interface IIdempotentChecker +{ + Task CheckAsync(IdempotentCheckContext context); +} diff --git a/aspnet-core/modules/common/LINGYUN.Abp.Idempotent/LINGYUN/Abp/Idempotent/IIdempotentDeniedHandler.cs b/aspnet-core/modules/common/LINGYUN.Abp.Idempotent/LINGYUN/Abp/Idempotent/IIdempotentDeniedHandler.cs new file mode 100644 index 000000000..ec7ade92e --- /dev/null +++ b/aspnet-core/modules/common/LINGYUN.Abp.Idempotent/LINGYUN/Abp/Idempotent/IIdempotentDeniedHandler.cs @@ -0,0 +1,5 @@ +namespace LINGYUN.Abp.Idempotent; +public interface IIdempotentDeniedHandler +{ + void Denied(IdempotentDeniedContext context); +} diff --git a/aspnet-core/modules/common/LINGYUN.Abp.Idempotent/LINGYUN/Abp/Idempotent/IIdempotentKeyNormalizer.cs b/aspnet-core/modules/common/LINGYUN.Abp.Idempotent/LINGYUN/Abp/Idempotent/IIdempotentKeyNormalizer.cs new file mode 100644 index 000000000..69a3a5b58 --- /dev/null +++ b/aspnet-core/modules/common/LINGYUN.Abp.Idempotent/LINGYUN/Abp/Idempotent/IIdempotentKeyNormalizer.cs @@ -0,0 +1,8 @@ +using Volo.Abp.DynamicProxy; + +namespace LINGYUN.Abp.Idempotent; + +public interface IIdempotentKeyNormalizer +{ + string NormalizeKey(IdempotentKeyNormalizerContext context); +} diff --git a/aspnet-core/modules/common/LINGYUN.Abp.Idempotent/LINGYUN/Abp/Idempotent/IdempotentAttribute.cs b/aspnet-core/modules/common/LINGYUN.Abp.Idempotent/LINGYUN/Abp/Idempotent/IdempotentAttribute.cs new file mode 100644 index 000000000..09b36e5ce --- /dev/null +++ b/aspnet-core/modules/common/LINGYUN.Abp.Idempotent/LINGYUN/Abp/Idempotent/IdempotentAttribute.cs @@ -0,0 +1,48 @@ +using System; + +namespace LINGYUN.Abp.Idempotent; + +[AttributeUsage(AttributeTargets.Method)] +public class IdempotentAttribute : Attribute +{ + /// + /// 超时等待时间 + /// + public int? Timeout { get; } + /// + /// 用作建立唯一标识的参数名称列表 + /// + /// + /// 通过查找参数列表中的值定义序列化唯一md5值 + /// + public string[]? KeyMap { get; } + /// + /// 资源重定向路径模板 + /// + /// + /// 定义一个可格式化的资源路径,当请求冲突时如果参数名称匹配成功则重定向目标路径 + ///
+ /// 例: /api/app/demo/{id} + ///
+ /// 例: /api/app/demo/method?id={id} + ///
+ public string? RedirectUrl { get; } + /// + /// 自定义的幂等key + /// + public string? IdempotentKey { get; } + public IdempotentAttribute() + { + + } + + public IdempotentAttribute( + int? timeout = null, + string? redirectUrl = null, + string[]? keyMap = null) + { + Timeout = timeout; + KeyMap = keyMap; + RedirectUrl = redirectUrl; + } +} diff --git a/aspnet-core/modules/common/LINGYUN.Abp.Idempotent/LINGYUN/Abp/Idempotent/IdempotentCheckContext.cs b/aspnet-core/modules/common/LINGYUN.Abp.Idempotent/LINGYUN/Abp/Idempotent/IdempotentCheckContext.cs new file mode 100644 index 000000000..13bd8ffd6 --- /dev/null +++ b/aspnet-core/modules/common/LINGYUN.Abp.Idempotent/LINGYUN/Abp/Idempotent/IdempotentCheckContext.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; +using System.Reflection; + +namespace LINGYUN.Abp.Idempotent; +public class IdempotentCheckContext +{ + public Type Target { get; } + public MethodInfo Method { get; } + public string IdempotentKey { get; } + public IReadOnlyDictionary ArgumentsDictionary { get; } + + public IdempotentCheckContext( + Type target, + MethodInfo method, + string idempotentKey, + IReadOnlyDictionary argumentsDictionary) + { + Target = target; + Method = method; + IdempotentKey = idempotentKey; + ArgumentsDictionary = argumentsDictionary; + } +} diff --git a/aspnet-core/modules/common/LINGYUN.Abp.Idempotent/LINGYUN/Abp/Idempotent/IdempotentChecker.cs b/aspnet-core/modules/common/LINGYUN.Abp.Idempotent/LINGYUN/Abp/Idempotent/IdempotentChecker.cs new file mode 100644 index 000000000..a7bfaf791 --- /dev/null +++ b/aspnet-core/modules/common/LINGYUN.Abp.Idempotent/LINGYUN/Abp/Idempotent/IdempotentChecker.cs @@ -0,0 +1,61 @@ +using Microsoft.Extensions.Options; +using System; +using System.Reflection; +using System.Threading.Tasks; +using Volo.Abp.DependencyInjection; +using Volo.Abp.DistributedLocking; + +namespace LINGYUN.Abp.Idempotent; + +public class IdempotentChecker : IIdempotentChecker, ITransientDependency +{ + private readonly IAbpDistributedLock _distributedLock; + private readonly AbpIdempotentOptions _idempotentOptions; + private readonly IIdempotentDeniedHandler _idempotentDeniedHandler; + + public IdempotentChecker( + IAbpDistributedLock distributedLock, + IOptions idempotentOptions, + IIdempotentDeniedHandler idempotentDeniedHandler) + { + _distributedLock = distributedLock; + _idempotentOptions = idempotentOptions.Value; + _idempotentDeniedHandler = idempotentDeniedHandler; + } + + public async virtual Task CheckAsync(IdempotentCheckContext context) + { + if (!_idempotentOptions.IsEnabled) + { + return; + } + + var attr = context.Method.GetCustomAttribute(); + + var methodLockTimeout = _idempotentOptions.DefaultTimeout; + + if (attr != null) + { + if (attr.Timeout.HasValue) + { + methodLockTimeout = attr.Timeout.Value; + } + } + + await using var handle = await _distributedLock.TryAcquireAsync(context.IdempotentKey, TimeSpan.FromMilliseconds(methodLockTimeout)); + + if (handle == null) + { + var deniedContext = new IdempotentDeniedContext( + context.IdempotentKey, + attr, + context.Method, + context.ArgumentsDictionary) + { + HttpStatusCode = _idempotentOptions.HttpStatusCode, + }; + + _idempotentDeniedHandler.Denied(deniedContext); + } + } +} diff --git a/aspnet-core/modules/common/LINGYUN.Abp.Idempotent/LINGYUN/Abp/Idempotent/IdempotentDeniedContext.cs b/aspnet-core/modules/common/LINGYUN.Abp.Idempotent/LINGYUN/Abp/Idempotent/IdempotentDeniedContext.cs new file mode 100644 index 000000000..3fc8be3ad --- /dev/null +++ b/aspnet-core/modules/common/LINGYUN.Abp.Idempotent/LINGYUN/Abp/Idempotent/IdempotentDeniedContext.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; +using System.Reflection; + +namespace LINGYUN.Abp.Idempotent; +public class IdempotentDeniedContext +{ + public IdempotentAttribute? Attribute { get; } + public string IdempotentKey { get; } + public MethodInfo Method { get; } + public IReadOnlyDictionary ArgumentsDictionary { get; } + public int HttpStatusCode { get; set; } + public IdempotentDeniedContext( + string idempotentKey, + IdempotentAttribute? attribute, + MethodInfo method, + IReadOnlyDictionary argumentsDictionary) + { + IdempotentKey = idempotentKey; + Attribute = attribute; + Method = method; + ArgumentsDictionary = argumentsDictionary; + } +} diff --git a/aspnet-core/modules/common/LINGYUN.Abp.Idempotent/LINGYUN/Abp/Idempotent/IdempotentDeniedException.cs b/aspnet-core/modules/common/LINGYUN.Abp.Idempotent/LINGYUN/Abp/Idempotent/IdempotentDeniedException.cs new file mode 100644 index 000000000..3f8d38dd5 --- /dev/null +++ b/aspnet-core/modules/common/LINGYUN.Abp.Idempotent/LINGYUN/Abp/Idempotent/IdempotentDeniedException.cs @@ -0,0 +1,30 @@ +using Microsoft.Extensions.Logging; +using System; +using System.Runtime.Serialization; +using Volo.Abp; +using Volo.Abp.ExceptionHandling; + +namespace LINGYUN.Abp.Idempotent; +public class IdempotentDeniedException : BusinessException, IHasHttpStatusCode +{ + public string IdempotentKey { get; } + + public int HttpStatusCode { get; set; } + + public IdempotentDeniedException( + string idempotentKey, + string? code = null, + string? message = null, + string? details = null, + Exception? innerException = null, + LogLevel logLevel = LogLevel.Warning) + : base(code, message, details, innerException, logLevel) + { + IdempotentKey = idempotentKey; + } + + public IdempotentDeniedException(SerializationInfo serializationInfo, StreamingContext context) + : base(serializationInfo, context) + { + } +} diff --git a/aspnet-core/modules/common/LINGYUN.Abp.Idempotent/LINGYUN/Abp/Idempotent/IdempotentDeniedHandler.cs b/aspnet-core/modules/common/LINGYUN.Abp.Idempotent/LINGYUN/Abp/Idempotent/IdempotentDeniedHandler.cs new file mode 100644 index 000000000..517c48467 --- /dev/null +++ b/aspnet-core/modules/common/LINGYUN.Abp.Idempotent/LINGYUN/Abp/Idempotent/IdempotentDeniedHandler.cs @@ -0,0 +1,62 @@ +using System; +using System.Text.RegularExpressions; +using Volo.Abp.DependencyInjection; + +namespace LINGYUN.Abp.Idempotent; + +public class IdempotentDeniedHandler : IIdempotentDeniedHandler, ISingletonDependency +{ + public virtual void Denied(IdempotentDeniedContext context) + { + var exception = new IdempotentDeniedException(context.IdempotentKey, IdempotentErrorCodes.IdempotentDenied) + .WithData(nameof(IdempotentAttribute.IdempotentKey), context.IdempotentKey); + + if (context.Attribute != null && !string.IsNullOrWhiteSpace(context.Attribute.RedirectUrl)) + { + var regex = new Regex("(?<={).+(?=})"); + if (regex.IsMatch(context.Attribute.RedirectUrl)) + { + var matchValue = regex.Match(context.Attribute.RedirectUrl).Value; + var replaceMatchKey = "{" + matchValue + "}"; + var redirectUrl = ""; + foreach (var arg in context.ArgumentsDictionary) + { + if (arg.Value != null && string.Equals(arg.Key, matchValue, StringComparison.InvariantCultureIgnoreCase)) + { + redirectUrl = context.Attribute.RedirectUrl!.Replace(replaceMatchKey, arg.Value.ToString()); + } + } + + if (redirectUrl.IsNullOrWhiteSpace()) + { + foreach (var arg in context.ArgumentsDictionary) + { + if (arg.Value == null) + { + continue; + } + var properties = arg.Value.GetType().GetProperties(); + foreach (var propertyInfo in properties) + { + if (string.Equals(propertyInfo.Name, matchValue, StringComparison.InvariantCultureIgnoreCase)) + { + var propValue = propertyInfo.GetValue(arg.Value); + if (propValue != null) + { + redirectUrl = context.Attribute.RedirectUrl!.Replace(replaceMatchKey, propValue.ToString()); + } + } + } + } + } + + if (!redirectUrl.IsNullOrWhiteSpace()) + { + exception.WithData(nameof(IdempotentAttribute.RedirectUrl), redirectUrl); + } + } + } + + throw exception; + } +} diff --git a/aspnet-core/modules/common/LINGYUN.Abp.Idempotent/LINGYUN/Abp/Idempotent/IdempotentErrorCodes.cs b/aspnet-core/modules/common/LINGYUN.Abp.Idempotent/LINGYUN/Abp/Idempotent/IdempotentErrorCodes.cs new file mode 100644 index 000000000..0ac89d147 --- /dev/null +++ b/aspnet-core/modules/common/LINGYUN.Abp.Idempotent/LINGYUN/Abp/Idempotent/IdempotentErrorCodes.cs @@ -0,0 +1,7 @@ +namespace LINGYUN.Abp.Idempotent; +public static class IdempotentErrorCodes +{ + private const string Namespace = "Idempotent"; + + public const string IdempotentDenied = Namespace + ":010001"; +} diff --git a/aspnet-core/modules/common/LINGYUN.Abp.Idempotent/LINGYUN/Abp/Idempotent/IdempotentInterceptor.cs b/aspnet-core/modules/common/LINGYUN.Abp.Idempotent/LINGYUN/Abp/Idempotent/IdempotentInterceptor.cs new file mode 100644 index 000000000..4cceef3d6 --- /dev/null +++ b/aspnet-core/modules/common/LINGYUN.Abp.Idempotent/LINGYUN/Abp/Idempotent/IdempotentInterceptor.cs @@ -0,0 +1,41 @@ +using System.Threading.Tasks; +using Volo.Abp.DependencyInjection; +using Volo.Abp.DynamicProxy; + +namespace LINGYUN.Abp.Idempotent; + +public class IdempotentInterceptor : AbpInterceptor, ITransientDependency +{ + private readonly IIdempotentChecker _idempotentChecker; + private readonly IIdempotentKeyNormalizer _idempotentKeyNormalizer; + + public IdempotentInterceptor( + IIdempotentChecker idempotentChecker, + IIdempotentKeyNormalizer idempotentKeyNormalizer) + { + _idempotentChecker = idempotentChecker; + _idempotentKeyNormalizer = idempotentKeyNormalizer; + } + + public async override Task InterceptAsync(IAbpMethodInvocation invocation) + { + var targetType = ProxyHelper.GetUnProxiedType(invocation.TargetObject); + + var keyNormalizerContext = new IdempotentKeyNormalizerContext( + targetType, + invocation.Method, + invocation.ArgumentsDictionary); + + var idempotentKey = _idempotentKeyNormalizer.NormalizeKey(keyNormalizerContext); + + var checkContext = new IdempotentCheckContext( + targetType, + invocation.Method, + idempotentKey, + invocation.ArgumentsDictionary); + + await _idempotentChecker.CheckAsync(checkContext); + + await invocation.ProceedAsync(); + } +} diff --git a/aspnet-core/modules/common/LINGYUN.Abp.Idempotent/LINGYUN/Abp/Idempotent/IdempotentInterceptorRegistrar.cs b/aspnet-core/modules/common/LINGYUN.Abp.Idempotent/LINGYUN/Abp/Idempotent/IdempotentInterceptorRegistrar.cs new file mode 100644 index 000000000..2fc1a0e1e --- /dev/null +++ b/aspnet-core/modules/common/LINGYUN.Abp.Idempotent/LINGYUN/Abp/Idempotent/IdempotentInterceptorRegistrar.cs @@ -0,0 +1,39 @@ +using System; +using System.Linq; +using System.Reflection; +using Volo.Abp.Application.Services; +using Volo.Abp.DependencyInjection; +using Volo.Abp.DynamicProxy; + +namespace LINGYUN.Abp.Idempotent; +public static class IdempotentInterceptorRegistrar +{ + public static void RegisterIfNeeded(IOnServiceRegistredContext context) + { + if (ShouldIntercept(context.ImplementationType)) + { + context.Interceptors.TryAdd(); + } + } + + private static bool ShouldIntercept(Type type) + { + return !DynamicProxyIgnoreTypes.Contains(type) && + (typeof(ICreateAppService<,>).IsAssignableFrom(type) || + typeof(IUpdateAppService<,>).IsAssignableFrom(type) || + typeof(IDeleteAppService<>).IsAssignableFrom(type) || + AnyMethodHasIdempotentAttribute(type)); + } + + private static bool AnyMethodHasIdempotentAttribute(Type implementationType) + { + return implementationType + .GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + .Any(HasIdempotentAttribute); + } + + private static bool HasIdempotentAttribute(MemberInfo methodInfo) + { + return methodInfo.IsDefined(typeof(IdempotentAttribute), true); + } +} diff --git a/aspnet-core/modules/common/LINGYUN.Abp.Idempotent/LINGYUN/Abp/Idempotent/IdempotentKeyNormalizer.cs b/aspnet-core/modules/common/LINGYUN.Abp.Idempotent/LINGYUN/Abp/Idempotent/IdempotentKeyNormalizer.cs new file mode 100644 index 000000000..dbd32af04 --- /dev/null +++ b/aspnet-core/modules/common/LINGYUN.Abp.Idempotent/LINGYUN/Abp/Idempotent/IdempotentKeyNormalizer.cs @@ -0,0 +1,99 @@ +using System; +using System.Reflection; +using System.Text; +using Volo.Abp.Application.Services; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Json; + +namespace LINGYUN.Abp.Idempotent; +public class IdempotentKeyNormalizer : IIdempotentKeyNormalizer, ITransientDependency +{ + private const string KeyFormat = "t:{0};m:{1};k:{}"; + + private readonly IJsonSerializer _jsonSerializer; + + public IdempotentKeyNormalizer( + IJsonSerializer jsonSerializer) + { + _jsonSerializer = jsonSerializer; + } + + public virtual string NormalizeKey(IdempotentKeyNormalizerContext context) + { + var methodIdBuilder = new StringBuilder(); + + if (context.Method.IsDefined(typeof(IdempotentAttribute))) + { + var attr = context.Method.GetCustomAttribute(); + if (!attr.IdempotentKey.IsNullOrWhiteSpace()) + { + return attr.IdempotentKey!; + } + if (attr.KeyMap != null) + { + var index = 0; + foreach (var key in attr.KeyMap) + { + if (context.ArgumentsDictionary.TryGetValue(key, out var value)) + { + var objectToString = _jsonSerializer.Serialize(value); + var objectMd5 = objectToString.ToMd5(); + methodIdBuilder.AppendFormat(";i:{0};v:{1}", key, objectMd5); + } + else + { + methodIdBuilder.AppendFormat(";i:{0}", key); + } + index++; + } + } + } + else + { + if (typeof(ICreateAppService<,>).IsAssignableFrom(context.Target) && + "CreateAsync".Equals(context.Method.Name, StringComparison.InvariantCultureIgnoreCase)) + { + if (context.ArgumentsDictionary.TryGetValue("input", out var args)) + { + var objectToString = _jsonSerializer.Serialize(args); + var objectMd5 = objectToString.ToMd5(); + methodIdBuilder.AppendFormat(";i:input;v:{0}", objectMd5); + } + } + + if (typeof(IUpdateAppService<,>).IsAssignableFrom(context.Target) && + "UpdateAsync".Equals(context.Method.Name, StringComparison.InvariantCultureIgnoreCase)) + { + if (context.ArgumentsDictionary.TryGetValue("id", out var idArgs) && idArgs != null) + { + var idMd5 = idArgs.ToString().ToMd5(); + methodIdBuilder.AppendFormat(";i:id;v:{0}", idMd5); + } + + if (context.ArgumentsDictionary.TryGetValue("input", out var inputArgs) && inputArgs != null) + { + var objectToString = _jsonSerializer.Serialize(inputArgs); + var objectMd5 = objectToString.ToMd5(); + methodIdBuilder.AppendFormat(";i:input;v:{0}", objectMd5); + } + } + + if (typeof(IDeleteAppService<>).IsAssignableFrom(context.Target) && + "DeleteAsync".Equals(context.Method.Name, StringComparison.InvariantCultureIgnoreCase)) + { + if (context.ArgumentsDictionary.TryGetValue("id", out var idArgs) && idArgs != null) + { + var idMd5 = idArgs.ToString().ToMd5(); + methodIdBuilder.AppendFormat(";i:id;v:{0}", idMd5); + } + } + } + + if (methodIdBuilder.Length <= 0) + { + methodIdBuilder.Append("unknown"); + } + + return string.Format(KeyFormat, context.Target.FullName, context.Method.Name, methodIdBuilder.ToString()); + } +} diff --git a/aspnet-core/modules/common/LINGYUN.Abp.Idempotent/LINGYUN/Abp/Idempotent/IdempotentKeyNormalizerContext.cs b/aspnet-core/modules/common/LINGYUN.Abp.Idempotent/LINGYUN/Abp/Idempotent/IdempotentKeyNormalizerContext.cs new file mode 100644 index 000000000..151e6443b --- /dev/null +++ b/aspnet-core/modules/common/LINGYUN.Abp.Idempotent/LINGYUN/Abp/Idempotent/IdempotentKeyNormalizerContext.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; +using System.Reflection; + +namespace LINGYUN.Abp.Idempotent; + +public class IdempotentKeyNormalizerContext +{ + public Type Target { get; } + public MethodInfo Method { get; } + public IReadOnlyDictionary ArgumentsDictionary { get; } + + public IdempotentKeyNormalizerContext( + Type target, + MethodInfo method, + IReadOnlyDictionary argumentsDictionary) + { + Target = target; + Method = method; + ArgumentsDictionary = argumentsDictionary; + } +} diff --git a/aspnet-core/modules/common/LINGYUN.Abp.Idempotent/LINGYUN/Abp/Idempotent/Localization/IdempotentResource.cs b/aspnet-core/modules/common/LINGYUN.Abp.Idempotent/LINGYUN/Abp/Idempotent/Localization/IdempotentResource.cs new file mode 100644 index 000000000..be20d8208 --- /dev/null +++ b/aspnet-core/modules/common/LINGYUN.Abp.Idempotent/LINGYUN/Abp/Idempotent/Localization/IdempotentResource.cs @@ -0,0 +1,8 @@ +using Volo.Abp.Localization; + +namespace LINGYUN.Abp.Idempotent.Localization; + +[LocalizationResourceName("Idempotent")] +public class IdempotentResource +{ +} diff --git a/aspnet-core/modules/common/LINGYUN.Abp.Idempotent/LINGYUN/Abp/Idempotent/Localization/Resources/en.json b/aspnet-core/modules/common/LINGYUN.Abp.Idempotent/LINGYUN/Abp/Idempotent/Localization/Resources/en.json new file mode 100644 index 000000000..d0701c831 --- /dev/null +++ b/aspnet-core/modules/common/LINGYUN.Abp.Idempotent/LINGYUN/Abp/Idempotent/Localization/Resources/en.json @@ -0,0 +1,6 @@ +{ + "culture": "en", + "texts": { + "Idempotent:010001": "Unable to submit multiple requests within the time limit, please try again later!" + } +} \ No newline at end of file diff --git a/aspnet-core/modules/common/LINGYUN.Abp.Idempotent/LINGYUN/Abp/Idempotent/Localization/Resources/zh-Hans.json b/aspnet-core/modules/common/LINGYUN.Abp.Idempotent/LINGYUN/Abp/Idempotent/Localization/Resources/zh-Hans.json new file mode 100644 index 000000000..db0fc0001 --- /dev/null +++ b/aspnet-core/modules/common/LINGYUN.Abp.Idempotent/LINGYUN/Abp/Idempotent/Localization/Resources/zh-Hans.json @@ -0,0 +1,6 @@ +{ + "culture": "zh-Hans", + "texts": { + "Idempotent:010001": "无法在限定时间内重复提交多次请求, 请稍后再试!" + } +} \ No newline at end of file diff --git a/aspnet-core/modules/common/LINGYUN.Abp.Idempotent/README.md b/aspnet-core/modules/common/LINGYUN.Abp.Idempotent/README.md new file mode 100644 index 000000000..ff294a45e --- /dev/null +++ b/aspnet-core/modules/common/LINGYUN.Abp.Idempotent/README.md @@ -0,0 +1,30 @@ +# LINGYUN.Abp.Idempotent + +接口幂等性检查模块 + +## 配置使用 + +```csharp +[DependsOn(typeof(AbpIdempotentModule))] +public class YouProjectModule : AbpModule +{ + public override void ConfigureServices(ServiceConfigurationContext context) + { + Configure(options => + { + // 全局启用幂等检查 + options.IsEnabled = true; + // 默认每个接口提供30秒超时 + options.DefaultTimeout = 30000; + // 幂等token名称, 通过HttpHeader传递 + options.IdempotentTokenName = "X-With-Idempotent-Token"; + // 幂等校验失败时Http响应代码 + options.HttpStatusCode = 429; + }); + } +} +``` +## 配置项说明 + +## 其他 + diff --git a/aspnet-core/modules/mvc/LINGYUN.Abp.AspNetCore.Mvc.Idempotent.Wrapper/FodyWeavers.xml b/aspnet-core/modules/mvc/LINGYUN.Abp.AspNetCore.Mvc.Idempotent.Wrapper/FodyWeavers.xml new file mode 100644 index 000000000..1715698cc --- /dev/null +++ b/aspnet-core/modules/mvc/LINGYUN.Abp.AspNetCore.Mvc.Idempotent.Wrapper/FodyWeavers.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/aspnet-core/modules/mvc/LINGYUN.Abp.AspNetCore.Mvc.Idempotent.Wrapper/FodyWeavers.xsd b/aspnet-core/modules/mvc/LINGYUN.Abp.AspNetCore.Mvc.Idempotent.Wrapper/FodyWeavers.xsd new file mode 100644 index 000000000..11da52550 --- /dev/null +++ b/aspnet-core/modules/mvc/LINGYUN.Abp.AspNetCore.Mvc.Idempotent.Wrapper/FodyWeavers.xsd @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + 'true' to run assembly verification (PEVerify) on the target assembly after all weavers have been executed. + + + + + A comma-separated list of error codes that can be safely ignored in assembly verification. + + + + + 'false' to turn off automatic generation of the XML Schema file. + + + + + \ No newline at end of file diff --git a/aspnet-core/modules/mvc/LINGYUN.Abp.AspNetCore.Mvc.Idempotent.Wrapper/LINGYUN.Abp.AspNetCore.Mvc.Idempotent.Wrapper.csproj b/aspnet-core/modules/mvc/LINGYUN.Abp.AspNetCore.Mvc.Idempotent.Wrapper/LINGYUN.Abp.AspNetCore.Mvc.Idempotent.Wrapper.csproj new file mode 100644 index 000000000..bf06a2e47 --- /dev/null +++ b/aspnet-core/modules/mvc/LINGYUN.Abp.AspNetCore.Mvc.Idempotent.Wrapper/LINGYUN.Abp.AspNetCore.Mvc.Idempotent.Wrapper.csproj @@ -0,0 +1,16 @@ + + + + + + + net7.0 + + + + + + + + + diff --git a/aspnet-core/modules/mvc/LINGYUN.Abp.AspNetCore.Mvc.Idempotent.Wrapper/LINGYUN/Abp/AspNetCore/Mvc/Idempotent/Wrapper/AbpAspNetCoreMvcIdempotentWrapperModule.cs b/aspnet-core/modules/mvc/LINGYUN.Abp.AspNetCore.Mvc.Idempotent.Wrapper/LINGYUN/Abp/AspNetCore/Mvc/Idempotent/Wrapper/AbpAspNetCoreMvcIdempotentWrapperModule.cs new file mode 100644 index 000000000..0a06ea19e --- /dev/null +++ b/aspnet-core/modules/mvc/LINGYUN.Abp.AspNetCore.Mvc.Idempotent.Wrapper/LINGYUN/Abp/AspNetCore/Mvc/Idempotent/Wrapper/AbpAspNetCoreMvcIdempotentWrapperModule.cs @@ -0,0 +1,12 @@ +using LINGYUN.Abp.AspNetCore.Mvc.Wrapper; +using Volo.Abp.Modularity; + +namespace LINGYUN.Abp.AspNetCore.Mvc.Idempotent.Wrapper; + +[DependsOn( + typeof(AbpAspNetCoreMvcIdempotentModule), + typeof(AbpAspNetCoreMvcWrapperModule))] +public class AbpAspNetCoreMvcIdempotentWrapperModule : AbpModule +{ + +} diff --git a/aspnet-core/modules/mvc/LINGYUN.Abp.AspNetCore.Mvc.Idempotent.Wrapper/LINGYUN/Abp/AspNetCore/Mvc/Idempotent/Wrapper/AbpIdempotentExceptionPageWrapResultFilter.cs b/aspnet-core/modules/mvc/LINGYUN.Abp.AspNetCore.Mvc.Idempotent.Wrapper/LINGYUN/Abp/AspNetCore/Mvc/Idempotent/Wrapper/AbpIdempotentExceptionPageWrapResultFilter.cs new file mode 100644 index 000000000..b854b37ec --- /dev/null +++ b/aspnet-core/modules/mvc/LINGYUN.Abp.AspNetCore.Mvc.Idempotent.Wrapper/LINGYUN/Abp/AspNetCore/Mvc/Idempotent/Wrapper/AbpIdempotentExceptionPageWrapResultFilter.cs @@ -0,0 +1,30 @@ +using LINGYUN.Abp.AspNetCore.Mvc.Wrapper.ExceptionHandling; +using LINGYUN.Abp.Idempotent; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.Options; +using System.Threading.Tasks; +using Volo.Abp.AspNetCore.Mvc; +using Volo.Abp.AspNetCore.Mvc.ExceptionHandling; +using Volo.Abp.DependencyInjection; + +namespace LINGYUN.Abp.AspNetCore.Mvc.Idempotent.Wrapper; + +[Dependency(ReplaceServices = true)] +[ExposeServices( + typeof(AbpExceptionPageFilter), + typeof(AbpExceptionPageWrapResultFilter))] +public class AbpIdempotentExceptionPageWrapResultFilter : AbpExceptionPageWrapResultFilter, ITransientDependency +{ + protected async override Task HandleAndWrapException(PageHandlerExecutedContext context) + { + if (context.Exception is IdempotentDeniedException deniedException) + { + var options = context.GetRequiredService>().Value; + if (!context.HttpContext.Items.ContainsKey(options.IdempotentTokenName)) + { + context.HttpContext.Items.Add(options.IdempotentTokenName, deniedException.IdempotentKey); + } + } + await base.HandleAndWrapException(context); + } +} diff --git a/aspnet-core/modules/mvc/LINGYUN.Abp.AspNetCore.Mvc.Idempotent.Wrapper/LINGYUN/Abp/AspNetCore/Mvc/Idempotent/Wrapper/AbpIdempotentExceptionWrapResultFilter.cs b/aspnet-core/modules/mvc/LINGYUN.Abp.AspNetCore.Mvc.Idempotent.Wrapper/LINGYUN/Abp/AspNetCore/Mvc/Idempotent/Wrapper/AbpIdempotentExceptionWrapResultFilter.cs new file mode 100644 index 000000000..9ba50c757 --- /dev/null +++ b/aspnet-core/modules/mvc/LINGYUN.Abp.AspNetCore.Mvc.Idempotent.Wrapper/LINGYUN/Abp/AspNetCore/Mvc/Idempotent/Wrapper/AbpIdempotentExceptionWrapResultFilter.cs @@ -0,0 +1,30 @@ +using LINGYUN.Abp.AspNetCore.Mvc.Wrapper.ExceptionHandling; +using LINGYUN.Abp.Idempotent; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.Options; +using System.Threading.Tasks; +using Volo.Abp.AspNetCore.Mvc; +using Volo.Abp.AspNetCore.Mvc.ExceptionHandling; +using Volo.Abp.DependencyInjection; + +namespace LINGYUN.Abp.AspNetCore.Mvc.Idempotent; + +[Dependency(ReplaceServices = true)] +[ExposeServices( + typeof(AbpExceptionFilter), + typeof(AbpExceptionWrapResultFilter))] +public class AbpIdempotentExceptionWrapResultFilter : AbpExceptionWrapResultFilter, ITransientDependency +{ + protected async override Task HandleAndWrapException(ExceptionContext context) + { + if (context.Exception is IdempotentDeniedException deniedException) + { + var options = context.GetRequiredService>().Value; + if (!context.HttpContext.Items.ContainsKey(options.IdempotentTokenName)) + { + context.HttpContext.Items.Add(options.IdempotentTokenName, deniedException.IdempotentKey); + } + } + await base.HandleAndWrapException(context); + } +} diff --git a/aspnet-core/modules/mvc/LINGYUN.Abp.AspNetCore.Mvc.Idempotent.Wrapper/LINGYUN/Abp/AspNetCore/Mvc/Idempotent/Wrapper/IdempotentHttpResponseWrapper.cs b/aspnet-core/modules/mvc/LINGYUN.Abp.AspNetCore.Mvc.Idempotent.Wrapper/LINGYUN/Abp/AspNetCore/Mvc/Idempotent/Wrapper/IdempotentHttpResponseWrapper.cs new file mode 100644 index 000000000..e9392129d --- /dev/null +++ b/aspnet-core/modules/mvc/LINGYUN.Abp.AspNetCore.Mvc.Idempotent.Wrapper/LINGYUN/Abp/AspNetCore/Mvc/Idempotent/Wrapper/IdempotentHttpResponseWrapper.cs @@ -0,0 +1,40 @@ +using LINGYUN.Abp.AspNetCore.Mvc.Wrapper; +using LINGYUN.Abp.Idempotent; +using LINGYUN.Abp.Wrapper; +using Microsoft.Extensions.Options; +using System.Collections.Generic; +using Volo.Abp.DependencyInjection; + +namespace LINGYUN.Abp.AspNetCore.Mvc.Idempotent.Wrapper; + +[Dependency(ReplaceServices = true)] +[ExposeServices( + typeof(IHttpResponseWrapper), + typeof(HttpResponseWrapper))] +public class IdempotentHttpResponseWrapper : HttpResponseWrapper, ITransientDependency +{ + protected AbpIdempotentOptions IdempotentOptions { get; } + public IdempotentHttpResponseWrapper( + IOptions wrapperOptions, + IOptions idempotentOptions) : base(wrapperOptions) + { + IdempotentOptions = idempotentOptions.Value; + } + + public override void Wrap(HttpResponseWrapperContext context) + { + if (context.HttpContext.Items.TryGetValue(nameof(IdempotentAttribute.RedirectUrl), out var redirectUrl) && redirectUrl != null) + { + context.HttpContext.Response.Headers.Add(AbpHttpWrapConsts.AbpWrapResult, "true"); + context.HttpContext.Response.Redirect(redirectUrl.ToString()); + return; + } + + if (context.HttpContext.Items.TryGetValue(IdempotentOptions.IdempotentTokenName, out var idempotentKey) && idempotentKey != null) + { + context.HttpHeaders.TryAdd(IdempotentOptions.IdempotentTokenName, idempotentKey.ToString()); + } + + base.Wrap(context); + } +} diff --git a/aspnet-core/modules/mvc/LINGYUN.Abp.AspNetCore.Mvc.Idempotent.Wrapper/README.md b/aspnet-core/modules/mvc/LINGYUN.Abp.AspNetCore.Mvc.Idempotent.Wrapper/README.md new file mode 100644 index 000000000..1daaa38f6 --- /dev/null +++ b/aspnet-core/modules/mvc/LINGYUN.Abp.AspNetCore.Mvc.Idempotent.Wrapper/README.md @@ -0,0 +1,17 @@ +# LINGYUN.Abp.AspNetCore.Mvc.Idempotent.Wrapper + +MVC 接口幂等性包装器模块, 启用包装器模块后, 写入校验失败的请求头 + +## 配置使用 + +```csharp +[DependsOn(typeof(AbpAspNetCoreMvcIdempotentWrapperModule))] +public class YouProjectModule : AbpModule +{ + +} +``` +## 配置项说明 + +## 其他 + diff --git a/aspnet-core/modules/mvc/LINGYUN.Abp.AspNetCore.Mvc.Idempotent/FodyWeavers.xml b/aspnet-core/modules/mvc/LINGYUN.Abp.AspNetCore.Mvc.Idempotent/FodyWeavers.xml new file mode 100644 index 000000000..1715698cc --- /dev/null +++ b/aspnet-core/modules/mvc/LINGYUN.Abp.AspNetCore.Mvc.Idempotent/FodyWeavers.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/aspnet-core/modules/mvc/LINGYUN.Abp.AspNetCore.Mvc.Idempotent/FodyWeavers.xsd b/aspnet-core/modules/mvc/LINGYUN.Abp.AspNetCore.Mvc.Idempotent/FodyWeavers.xsd new file mode 100644 index 000000000..11da52550 --- /dev/null +++ b/aspnet-core/modules/mvc/LINGYUN.Abp.AspNetCore.Mvc.Idempotent/FodyWeavers.xsd @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + 'true' to run assembly verification (PEVerify) on the target assembly after all weavers have been executed. + + + + + A comma-separated list of error codes that can be safely ignored in assembly verification. + + + + + 'false' to turn off automatic generation of the XML Schema file. + + + + + \ No newline at end of file diff --git a/aspnet-core/modules/mvc/LINGYUN.Abp.AspNetCore.Mvc.Idempotent/LINGYUN.Abp.AspNetCore.Mvc.Idempotent.csproj b/aspnet-core/modules/mvc/LINGYUN.Abp.AspNetCore.Mvc.Idempotent/LINGYUN.Abp.AspNetCore.Mvc.Idempotent.csproj new file mode 100644 index 000000000..1f95eb8d9 --- /dev/null +++ b/aspnet-core/modules/mvc/LINGYUN.Abp.AspNetCore.Mvc.Idempotent/LINGYUN.Abp.AspNetCore.Mvc.Idempotent.csproj @@ -0,0 +1,19 @@ + + + + + + + net7.0 + + + + + + + + + + + + diff --git a/aspnet-core/modules/mvc/LINGYUN.Abp.AspNetCore.Mvc.Idempotent/LINGYUN/Abp/AspNetCore/Mvc/Idempotent/AbpAspNetCoreMvcIdempotentModule.cs b/aspnet-core/modules/mvc/LINGYUN.Abp.AspNetCore.Mvc.Idempotent/LINGYUN/Abp/AspNetCore/Mvc/Idempotent/AbpAspNetCoreMvcIdempotentModule.cs new file mode 100644 index 000000000..025f9c02b --- /dev/null +++ b/aspnet-core/modules/mvc/LINGYUN.Abp.AspNetCore.Mvc.Idempotent/LINGYUN/Abp/AspNetCore/Mvc/Idempotent/AbpAspNetCoreMvcIdempotentModule.cs @@ -0,0 +1,21 @@ +using LINGYUN.Abp.Idempotent; +using Microsoft.AspNetCore.Mvc; +using Volo.Abp.AspNetCore.ExceptionHandling; +using Volo.Abp.AspNetCore.Mvc; +using Volo.Abp.Modularity; + +namespace LINGYUN.Abp.AspNetCore.Mvc.Idempotent; + +[DependsOn( + typeof(AbpIdempotentModule), + typeof(AbpAspNetCoreMvcModule))] +public class AbpAspNetCoreMvcIdempotentModule : AbpModule +{ + public override void ConfigureServices(ServiceConfigurationContext context) + { + Configure(options => + { + options.Filters.AddService(typeof(AbpIdempotentActionFilter)); + }); + } +} diff --git a/aspnet-core/modules/mvc/LINGYUN.Abp.AspNetCore.Mvc.Idempotent/LINGYUN/Abp/AspNetCore/Mvc/Idempotent/AbpAspNetCoreMvcIdempotentOptions.cs b/aspnet-core/modules/mvc/LINGYUN.Abp.AspNetCore.Mvc.Idempotent/LINGYUN/Abp/AspNetCore/Mvc/Idempotent/AbpAspNetCoreMvcIdempotentOptions.cs new file mode 100644 index 000000000..034e70c8d --- /dev/null +++ b/aspnet-core/modules/mvc/LINGYUN.Abp.AspNetCore.Mvc.Idempotent/LINGYUN/Abp/AspNetCore/Mvc/Idempotent/AbpAspNetCoreMvcIdempotentOptions.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; + +namespace LINGYUN.Abp.AspNetCore.Mvc.Idempotent; +public class AbpAspNetCoreMvcIdempotentOptions +{ + public List SupportedMethods { get; } + public AbpAspNetCoreMvcIdempotentOptions() + { + SupportedMethods = new List + { + "POST", + "PUT", + "PATCH", + // "DELETE" + }; + } +} diff --git a/aspnet-core/modules/mvc/LINGYUN.Abp.AspNetCore.Mvc.Idempotent/LINGYUN/Abp/AspNetCore/Mvc/Idempotent/AbpIdempotentActionFilter.cs b/aspnet-core/modules/mvc/LINGYUN.Abp.AspNetCore.Mvc.Idempotent/LINGYUN/Abp/AspNetCore/Mvc/Idempotent/AbpIdempotentActionFilter.cs new file mode 100644 index 000000000..33e5f6359 --- /dev/null +++ b/aspnet-core/modules/mvc/LINGYUN.Abp.AspNetCore.Mvc.Idempotent/LINGYUN/Abp/AspNetCore/Mvc/Idempotent/AbpIdempotentActionFilter.cs @@ -0,0 +1,77 @@ +using LINGYUN.Abp.Idempotent; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.Options; +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; +using Volo.Abp.AspNetCore.Mvc; +using Volo.Abp.DependencyInjection; + +namespace LINGYUN.Abp.AspNetCore.Mvc.Idempotent; + +public class AbpIdempotentActionFilter : IAsyncActionFilter, ITransientDependency +{ + public async virtual Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + if (!ShouldCheckIdempotent(context)) + { + await next(); + return; + } + + var idempotentChecker = context.GetRequiredService(); + var options = context.GetRequiredService>().Value; + + var targetType = context.ActionDescriptor.AsControllerActionDescriptor().ControllerTypeInfo.AsType(); + var methodInfo = context.ActionDescriptor.AsControllerActionDescriptor().MethodInfo; + + string idempotentKey; + // 可以用户传递幂等性token + // 否则通过用户定义action创建token + if (context.HttpContext.Request.Headers.TryGetValue(options.IdempotentTokenName, out var key)) + { + idempotentKey = key.ToString(); + } + else + { + + var idempotentKeyNormalizer = context.GetRequiredService(); + + var keyNormalizerContext = new IdempotentKeyNormalizerContext( + targetType, + methodInfo, + context.ActionArguments.ToImmutableDictionary()); + + idempotentKey = idempotentKeyNormalizer.NormalizeKey(keyNormalizerContext); + } + + var checkContext = new IdempotentCheckContext( + targetType, + methodInfo, + idempotentKey, + context.ActionArguments.ToImmutableDictionary()); + // 进行幂等性校验 + await idempotentChecker.CheckAsync(checkContext); + + await next(); + } + + protected virtual bool ShouldCheckIdempotent(ActionExecutingContext context) + { + var options = context.GetRequiredService>().Value; + if (!options.IsEnabled) + { + return false; + } + + var mvcIdempotentOptions = context.GetRequiredService>().Value; + if (mvcIdempotentOptions.SupportedMethods.Any() && + !mvcIdempotentOptions.SupportedMethods.Contains(context.HttpContext.Request.Method)) + { + return false; + } + + return true; + } +} diff --git a/aspnet-core/modules/mvc/LINGYUN.Abp.AspNetCore.Mvc.Idempotent/README.md b/aspnet-core/modules/mvc/LINGYUN.Abp.AspNetCore.Mvc.Idempotent/README.md new file mode 100644 index 000000000..e9347635c --- /dev/null +++ b/aspnet-core/modules/mvc/LINGYUN.Abp.AspNetCore.Mvc.Idempotent/README.md @@ -0,0 +1,24 @@ +# LINGYUN.Abp.AspNetCore.Mvc.Idempotent + +MVC 接口幂等性检查模块 + +## 配置使用 + +```csharp +[DependsOn(typeof(AbpAspNetCoreMvcIdempotentModule))] +public class YouProjectModule : AbpModule +{ + public override void ConfigureServices(ServiceConfigurationContext context) + { + Configure(options => + { + // 例如: 对 DELETE 请求方法进行幂等校验 + options.SupportedMethods.Add("DELETE"); + }); + } +} +``` +## 配置项说明 + +## 其他 + diff --git a/aspnet-core/modules/mvc/LINGYUN.Abp.AspNetCore.Mvc.Wrapper/LINGYUN/Abp/AspNetCore/Mvc/HttpResponseWrapper.cs b/aspnet-core/modules/mvc/LINGYUN.Abp.AspNetCore.Mvc.Wrapper/LINGYUN/Abp/AspNetCore/Mvc/HttpResponseWrapper.cs new file mode 100644 index 000000000..4e98dcdfe --- /dev/null +++ b/aspnet-core/modules/mvc/LINGYUN.Abp.AspNetCore.Mvc.Wrapper/LINGYUN/Abp/AspNetCore/Mvc/HttpResponseWrapper.cs @@ -0,0 +1,29 @@ +using LINGYUN.Abp.AspNetCore.Mvc.Wrapper; +using LINGYUN.Abp.Wrapper; +using Microsoft.Extensions.Options; +using Volo.Abp.DependencyInjection; + +namespace LINGYUN.Abp.AspNetCore.Mvc; + +public class HttpResponseWrapper : IHttpResponseWrapper, ITransientDependency +{ + protected AbpWrapperOptions Options { get; } + + public HttpResponseWrapper(IOptions options) + { + Options = options.Value; + } + + public virtual void Wrap(HttpResponseWrapperContext context) + { + context.HttpContext.Response.Headers.Add(AbpHttpWrapConsts.AbpWrapResult, "true"); + context.HttpContext.Response.StatusCode = context.HttpStatusCode; + if (context.HttpHeaders != null) + { + foreach (var header in context.HttpHeaders) + { + context.HttpContext.Response.Headers.Add(header.Key, header.Value); + } + } + } +} diff --git a/aspnet-core/modules/mvc/LINGYUN.Abp.AspNetCore.Mvc.Wrapper/LINGYUN/Abp/AspNetCore/Mvc/Wrapper/ExceptionHandling/AbpExceptionPageWrapResultFilter.cs b/aspnet-core/modules/mvc/LINGYUN.Abp.AspNetCore.Mvc.Wrapper/LINGYUN/Abp/AspNetCore/Mvc/Wrapper/ExceptionHandling/AbpExceptionPageWrapResultFilter.cs index b011aa1a3..b95572d30 100644 --- a/aspnet-core/modules/mvc/LINGYUN.Abp.AspNetCore.Mvc.Wrapper/LINGYUN/Abp/AspNetCore/Mvc/Wrapper/ExceptionHandling/AbpExceptionPageWrapResultFilter.cs +++ b/aspnet-core/modules/mvc/LINGYUN.Abp.AspNetCore.Mvc.Wrapper/LINGYUN/Abp/AspNetCore/Mvc/Wrapper/ExceptionHandling/AbpExceptionPageWrapResultFilter.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using System; +using System.Collections.Generic; using System.Text; using System.Threading.Tasks; using Volo.Abp.AspNetCore.ExceptionHandling; @@ -62,6 +63,7 @@ namespace LINGYUN.Abp.AspNetCore.Mvc.Wrapper.ExceptionHandling } else { + var httpResponseWrapper = context.GetRequiredService(); var statusCodFinder = context.GetRequiredService(); var exceptionWrapHandler = context.GetRequiredService(); var exceptionWrapContext = new ExceptionWrapContext( @@ -75,8 +77,19 @@ namespace LINGYUN.Abp.AspNetCore.Mvc.Wrapper.ExceptionHandling exceptionWrapContext.ErrorInfo.Message, exceptionWrapContext.ErrorInfo.Details)); - context.HttpContext.Response.Headers.Add(AbpHttpWrapConsts.AbpWrapResult, "true"); - context.HttpContext.Response.StatusCode = (int)wrapOptions.HttpStatusCode; + var wrapperHeaders = new Dictionary() + { + { AbpHttpWrapConsts.AbpWrapResult, "true" } + }; + var responseWrapperContext = new HttpResponseWrapperContext( + context.HttpContext, + (int)wrapOptions.HttpStatusCode, + wrapperHeaders); + + httpResponseWrapper.Wrap(responseWrapperContext); + + //context.HttpContext.Response.Headers.Add(AbpHttpWrapConsts.AbpWrapResult, "true"); + //context.HttpContext.Response.StatusCode = (int)wrapOptions.HttpStatusCode; } context.Exception = null; //Handled! diff --git a/aspnet-core/modules/mvc/LINGYUN.Abp.AspNetCore.Mvc.Wrapper/LINGYUN/Abp/AspNetCore/Mvc/Wrapper/ExceptionHandling/AbpExceptionWrapResultFilter.cs b/aspnet-core/modules/mvc/LINGYUN.Abp.AspNetCore.Mvc.Wrapper/LINGYUN/Abp/AspNetCore/Mvc/Wrapper/ExceptionHandling/AbpExceptionWrapResultFilter.cs index 45d85708c..efe21eb9a 100644 --- a/aspnet-core/modules/mvc/LINGYUN.Abp.AspNetCore.Mvc.Wrapper/LINGYUN/Abp/AspNetCore/Mvc/Wrapper/ExceptionHandling/AbpExceptionWrapResultFilter.cs +++ b/aspnet-core/modules/mvc/LINGYUN.Abp.AspNetCore.Mvc.Wrapper/LINGYUN/Abp/AspNetCore/Mvc/Wrapper/ExceptionHandling/AbpExceptionWrapResultFilter.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using System; +using System.Collections.Generic; using System.Text; using System.Threading.Tasks; using Volo.Abp.AspNetCore.ExceptionHandling; @@ -63,6 +64,7 @@ namespace LINGYUN.Abp.AspNetCore.Mvc.Wrapper.ExceptionHandling } else { + var httpResponseWrapper = context.GetRequiredService(); var statusCodFinder = context.GetRequiredService(); var exceptionWrapHandler = context.GetRequiredService(); var exceptionWrapContext = new ExceptionWrapContext( @@ -76,8 +78,19 @@ namespace LINGYUN.Abp.AspNetCore.Mvc.Wrapper.ExceptionHandling exceptionWrapContext.ErrorInfo.Message, exceptionWrapContext.ErrorInfo.Details)); - context.HttpContext.Response.Headers.Add(AbpHttpWrapConsts.AbpWrapResult, "true"); - context.HttpContext.Response.StatusCode = (int)wrapOptions.HttpStatusCode; + var wrapperHeaders = new Dictionary() + { + { AbpHttpWrapConsts.AbpWrapResult, "true" } + }; + var responseWrapperContext = new HttpResponseWrapperContext( + context.HttpContext, + (int)wrapOptions.HttpStatusCode, + wrapperHeaders); + + httpResponseWrapper.Wrap(responseWrapperContext); + + //context.HttpContext.Response.Headers.Add(AbpHttpWrapConsts.AbpWrapResult, "true"); + //context.HttpContext.Response.StatusCode = (int)wrapOptions.HttpStatusCode; } context.Exception = null; //Handled! diff --git a/aspnet-core/modules/mvc/LINGYUN.Abp.AspNetCore.Mvc.Wrapper/LINGYUN/Abp/AspNetCore/Mvc/Wrapper/Filters/AbpWrapResultFilter.cs b/aspnet-core/modules/mvc/LINGYUN.Abp.AspNetCore.Mvc.Wrapper/LINGYUN/Abp/AspNetCore/Mvc/Wrapper/Filters/AbpWrapResultFilter.cs index 85ef9c8b6..9325b4414 100644 --- a/aspnet-core/modules/mvc/LINGYUN.Abp.AspNetCore.Mvc.Wrapper/LINGYUN/Abp/AspNetCore/Mvc/Wrapper/Filters/AbpWrapResultFilter.cs +++ b/aspnet-core/modules/mvc/LINGYUN.Abp.AspNetCore.Mvc.Wrapper/LINGYUN/Abp/AspNetCore/Mvc/Wrapper/Filters/AbpWrapResultFilter.cs @@ -2,6 +2,7 @@ using LINGYUN.Abp.Wrapper; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.Extensions.Options; +using System.Collections.Generic; using System.Threading.Tasks; using Volo.Abp.AspNetCore.Mvc; using Volo.Abp.DependencyInjection; @@ -30,10 +31,23 @@ namespace LINGYUN.Abp.AspNetCore.Mvc.Wrapper.Filters protected virtual Task HandleAndWrapResult(ResultExecutingContext context) { var options = context.GetRequiredService>().Value; + var httpResponseWrapper = context.GetRequiredService(); var actionResultWrapperFactory = context.GetRequiredService(); actionResultWrapperFactory.CreateFor(context).Wrap(context); - context.HttpContext.Response.Headers.Add(AbpHttpWrapConsts.AbpWrapResult, "true"); - context.HttpContext.Response.StatusCode = (int)options.HttpStatusCode; + + var wrapperHeaders = new Dictionary() + { + { AbpHttpWrapConsts.AbpWrapResult, "true" } + }; + var responseWrapperContext = new HttpResponseWrapperContext( + context.HttpContext, + (int)options.HttpStatusCode, + wrapperHeaders); + + httpResponseWrapper.Wrap(responseWrapperContext); + + //context.HttpContext.Response.Headers.Add(AbpHttpWrapConsts.AbpWrapResult, "true"); + //context.HttpContext.Response.StatusCode = (int)options.HttpStatusCode; return Task.CompletedTask; } diff --git a/aspnet-core/modules/mvc/LINGYUN.Abp.AspNetCore.Mvc.Wrapper/LINGYUN/Abp/AspNetCore/Mvc/Wrapper/HttpResponseWrapperContext.cs b/aspnet-core/modules/mvc/LINGYUN.Abp.AspNetCore.Mvc.Wrapper/LINGYUN/Abp/AspNetCore/Mvc/Wrapper/HttpResponseWrapperContext.cs new file mode 100644 index 000000000..a7ad8c1cc --- /dev/null +++ b/aspnet-core/modules/mvc/LINGYUN.Abp.AspNetCore.Mvc.Wrapper/LINGYUN/Abp/AspNetCore/Mvc/Wrapper/HttpResponseWrapperContext.cs @@ -0,0 +1,19 @@ +using Microsoft.AspNetCore.Http; +using System.Collections.Generic; + +namespace LINGYUN.Abp.AspNetCore.Mvc.Wrapper; +public class HttpResponseWrapperContext +{ + public HttpContext HttpContext { get; } + public int HttpStatusCode { get; } + public IDictionary HttpHeaders { get; } + public HttpResponseWrapperContext( + HttpContext httpContext, + int httpStatusCode, + IDictionary httpHeaders = null) + { + HttpContext = httpContext; + HttpStatusCode = httpStatusCode; + HttpHeaders = httpHeaders ?? new Dictionary(); + } +} diff --git a/aspnet-core/modules/mvc/LINGYUN.Abp.AspNetCore.Mvc.Wrapper/LINGYUN/Abp/AspNetCore/Mvc/Wrapper/IHttpResponseWrapper.cs b/aspnet-core/modules/mvc/LINGYUN.Abp.AspNetCore.Mvc.Wrapper/LINGYUN/Abp/AspNetCore/Mvc/Wrapper/IHttpResponseWrapper.cs new file mode 100644 index 000000000..0729ddf6f --- /dev/null +++ b/aspnet-core/modules/mvc/LINGYUN.Abp.AspNetCore.Mvc.Wrapper/LINGYUN/Abp/AspNetCore/Mvc/Wrapper/IHttpResponseWrapper.cs @@ -0,0 +1,7 @@ +using Microsoft.AspNetCore.Http; + +namespace LINGYUN.Abp.AspNetCore.Mvc.Wrapper; +public interface IHttpResponseWrapper +{ + void Wrap(HttpResponseWrapperContext context); +}