|
|
// ============================================================================
|
|
|
// 【文件说明】HwPortalIpRateLimitAttribute.cs - IP限流特性
|
|
|
// ============================================================================
|
|
|
// 这是一个"限流过滤器",用于防止接口被恶意刷调用。
|
|
|
//
|
|
|
// 【业务场景】
|
|
|
// 比如用户留言接口,如果不限流,恶意用户可以1秒提交100次,
|
|
|
// 导致数据库被打爆、短信接口被刷爆。所以需要限制:
|
|
|
// "同一IP在60秒内最多提交10次"。
|
|
|
//
|
|
|
// 【什么是 Attribute(特性)?】
|
|
|
// 特性是 C# 的"声明式编程"机制,可以给代码元素(类、方法等)贴标签。
|
|
|
//
|
|
|
// 对比 Java 注解:
|
|
|
// Java: @RateLimit(key = "contact", time = 60, count = 10)
|
|
|
// C#: [HwPortalIpRateLimit("contact", 60, 10)]
|
|
|
//
|
|
|
// 两者概念完全一样,只是:
|
|
|
// - Java 用 @ 符号,叫"注解"(Annotation)
|
|
|
// - C# 用 [] 方括号,叫"特性"(Attribute)
|
|
|
//
|
|
|
// 【什么是 Filter(过滤器)?】
|
|
|
// 过滤器是 ASP.NET Core 的中间件机制,可以在请求到达控制器前后执行代码。
|
|
|
//
|
|
|
// 执行顺序:
|
|
|
// 请求 -> 中间件 -> Filter(OnActionExecuting) -> 控制器方法 -> Filter(OnActionExecuted) -> 响应
|
|
|
//
|
|
|
// 对比 Java Spring:
|
|
|
// Java Spring 用 HandlerInterceptor 或 AOP @Aspect 实现类似功能。
|
|
|
// 若依框架的限流就是用 AOP 切面实现的。
|
|
|
// ============================================================================
|
|
|
|
|
|
namespace Admin.NET.Plugin.HwPortal;
|
|
|
|
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
|
|
|
|
/// <summary>
|
|
|
/// IP限流特性。
|
|
|
/// <para>
|
|
|
/// 【C# 语法知识点 - 特性定义】
|
|
|
/// public sealed class HwPortalIpRateLimitAttribute : Attribute, IAsyncActionFilter
|
|
|
///
|
|
|
/// 1. 继承 Attribute:这是定义特性的基础要求
|
|
|
/// 2. 实现 IAsyncActionFilter:这是 ASP.NET Core 的过滤器接口
|
|
|
/// 3. sealed 关键字:密封类,不能被继承(特性通常不需要继承)
|
|
|
///
|
|
|
/// 对比 Java:
|
|
|
/// Java 定义注解用 @interface 关键字:
|
|
|
/// @Target(ElementType.METHOD)
|
|
|
/// @Retention(RetentionPolicy.RUNTIME)
|
|
|
/// public @interface RateLimit {
|
|
|
/// String key();
|
|
|
/// int time() default 60;
|
|
|
/// int count() default 10;
|
|
|
/// }
|
|
|
///
|
|
|
/// C# 的特性是真正的类,可以有构造函数、属性、方法。
|
|
|
/// Java 的注解更像接口,只能定义属性签名。
|
|
|
/// </para>
|
|
|
/// <para>
|
|
|
/// 【AttributeUsage 特性】
|
|
|
/// [AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = true)]
|
|
|
///
|
|
|
/// 这是"元特性"——给特性本身贴的特性:
|
|
|
/// - AttributeTargets.Method:只能用在方法上(不能用在类上)
|
|
|
/// - AllowMultiple = false:同一个方法只能贴一个
|
|
|
/// - Inherited = true:子类继承父类方法时,特性也会继承
|
|
|
///
|
|
|
/// 对比 Java:
|
|
|
/// Java 用 @Target 和 @Retention 注解定义:
|
|
|
/// @Target(ElementType.METHOD) // 只能用在方法上
|
|
|
/// @Retention(RetentionPolicy.RUNTIME) // 运行时保留
|
|
|
/// </para>
|
|
|
/// </summary>
|
|
|
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = true)]
|
|
|
public sealed class HwPortalIpRateLimitAttribute : Attribute, IAsyncActionFilter
|
|
|
{
|
|
|
/// <summary>
|
|
|
/// 限流键名(用于区分不同业务场景)。
|
|
|
/// </summary>
|
|
|
public string Key { get; }
|
|
|
|
|
|
/// <summary>
|
|
|
/// 时间窗口(秒)。
|
|
|
/// </summary>
|
|
|
public int Time { get; }
|
|
|
|
|
|
/// <summary>
|
|
|
/// 最大访问次数。
|
|
|
/// </summary>
|
|
|
public int Count { get; }
|
|
|
|
|
|
/// <summary>
|
|
|
/// 限流提示消息。
|
|
|
/// <para>
|
|
|
/// 【C# 语法知识点 - 属性默认值】
|
|
|
/// public string Message { get; set; } = "访问过于频繁,请稍后再试";
|
|
|
///
|
|
|
/// = "..." 是属性的默认值初始化。
|
|
|
/// 调用方可以覆盖:[HwPortalIpRateLimit("key", 60, 10, Message = "自定义消息")]
|
|
|
///
|
|
|
/// 对比 Java:
|
|
|
/// Java 注解用 default 关键字:
|
|
|
/// String message() default "访问过于频繁,请稍后再试";
|
|
|
/// </para>
|
|
|
/// </summary>
|
|
|
public string Message { get; set; } = "访问过于频繁,请稍后再试";
|
|
|
|
|
|
/// <summary>
|
|
|
/// 构造函数。
|
|
|
/// <para>
|
|
|
/// 【C# 语法知识点 - 构造函数参数】
|
|
|
/// public HwPortalIpRateLimitAttribute(string key, int time, int count)
|
|
|
///
|
|
|
/// 使用方式:
|
|
|
/// [HwPortalIpRateLimit("contact", 60, 10)]
|
|
|
/// public async Task<HwPortalAjaxResult> AddContactUsInfo(...) { }
|
|
|
///
|
|
|
/// 对比 Java:
|
|
|
/// Java 注解没有构造函数,使用时写属性名:
|
|
|
/// @RateLimit(key = "contact", time = 60, count = 10)
|
|
|
///
|
|
|
/// C# 特性更接近普通类,构造函数参数是位置参数(必填),
|
|
|
/// 属性是命名参数(可选)。
|
|
|
/// </para>
|
|
|
/// <para>
|
|
|
/// 【设计意图】
|
|
|
/// 把限流配置固化在特性参数里,让控制器方法声明时就能看出限流策略:
|
|
|
/// - 一眼看出限流配置
|
|
|
/// - 不用翻配置文件
|
|
|
/// - 代码即文档
|
|
|
/// </para>
|
|
|
/// </summary>
|
|
|
/// <param name="key">限流键名</param>
|
|
|
/// <param name="time">时间窗口(秒)</param>
|
|
|
/// <param name="count">最大次数</param>
|
|
|
public HwPortalIpRateLimitAttribute(string key, int time, int count)
|
|
|
{
|
|
|
Key = key;
|
|
|
Time = time;
|
|
|
Count = count;
|
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
|
/// 异步过滤器执行方法。
|
|
|
/// <para>
|
|
|
/// 【C# 语法知识点 - IAsyncActionFilter 接口】
|
|
|
/// Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
|
|
|
///
|
|
|
/// 这是 ASP.NET Core 的过滤器核心接口:
|
|
|
/// - ActionExecutingContext:执行前上下文(可以拿到请求信息、修改结果)
|
|
|
/// - ActionExecutionDelegate next:继续执行的委托(调用它就放行)
|
|
|
/// - Task:异步方法返回类型
|
|
|
///
|
|
|
/// 执行流程:
|
|
|
/// 1. 框架调用 OnActionExecutionAsync
|
|
|
/// 2. 过滤器做前置处理(限流检查)
|
|
|
/// 3. 如果通过,调用 await next() 放行
|
|
|
/// 4. 控制器方法执行
|
|
|
/// 5. 过滤器做后置处理(本例没有)
|
|
|
///
|
|
|
/// 对比 Java Spring:
|
|
|
/// Java 用 HandlerInterceptor 的三个方法:
|
|
|
/// preHandle() -> 对应 next() 之前的代码
|
|
|
/// postHandle() -> 对应 next() 之后的代码
|
|
|
/// afterCompletion() -> 完成后的清理
|
|
|
///
|
|
|
/// 或者用 AOP @Around:
|
|
|
/// @Around("切点表达式")
|
|
|
/// public Object around(ProceedingJoinPoint pjp) {
|
|
|
/// // 前置处理
|
|
|
/// Object result = pjp.proceed(); // 相当于 await next()
|
|
|
/// // 后置处理
|
|
|
/// return result;
|
|
|
/// }
|
|
|
/// </para>
|
|
|
/// </summary>
|
|
|
/// <param name="context">执行上下文</param>
|
|
|
/// <param name="next">继续执行的委托</param>
|
|
|
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
|
|
|
{
|
|
|
// 【获取 HTTP 上下文】
|
|
|
// context.HttpContext 包含当前请求的所有信息:
|
|
|
// - Request:请求对象(Headers、Query、Body、Cookies 等)
|
|
|
// - Response:响应对象
|
|
|
// - User:当前登录用户(如果有)
|
|
|
// - RequestServices:DI 服务容器
|
|
|
//
|
|
|
// 对比 Java Spring:
|
|
|
// Java 通常通过注入 HttpServletRequest:
|
|
|
// @Autowired private HttpServletRequest request;
|
|
|
// 或者在 Controller 方法参数中声明:
|
|
|
// public Result method(HttpServletRequest request) { ... }
|
|
|
HttpContext httpContext = context.HttpContext;
|
|
|
|
|
|
// 【从 DI 容器获取服务】
|
|
|
// httpContext.RequestServices.GetRequiredService<SysCacheService>()
|
|
|
//
|
|
|
// RequestServices 是"请求级 DI 容器"。
|
|
|
// GetRequiredService<T>() 获取服务实例,如果不存在会抛异常。
|
|
|
//
|
|
|
// 对比 Java Spring:
|
|
|
// Java 用 @Autowired 注入:
|
|
|
// @Autowired private RedisCache redisCache;
|
|
|
//
|
|
|
// C# 的写法更显式,从容器直接获取。
|
|
|
// 两种方式本质一样:都是依赖注入。
|
|
|
SysCacheService cacheService = httpContext.RequestServices.GetRequiredService<SysCacheService>();
|
|
|
|
|
|
// 获取请求 IP。
|
|
|
string requestIp = HwPortalContextHelper.CurrentRequestIp(httpContext);
|
|
|
|
|
|
// 获取当前方法名(用于日志和区分不同接口)。
|
|
|
// context.ActionDescriptor.DisplayName 是方法的完整名称。
|
|
|
// ?? string.Empty 是空合并运算符:如果 DisplayName 为 null,就用空字符串。
|
|
|
string actionName = context.ActionDescriptor.DisplayName ?? string.Empty;
|
|
|
|
|
|
// 【构建缓存键】
|
|
|
// 格式:hwportal:ratelimit:{key}:{ip}:{action}
|
|
|
//
|
|
|
// 为什么要拼接这么多信息?
|
|
|
// 1. key:区分不同业务场景(留言、搜索等)
|
|
|
// 2. ip:区分不同用户
|
|
|
// 3. action:区分同一控制器的不同方法
|
|
|
//
|
|
|
// 这样可以做到:
|
|
|
// - 同一IP访问不同接口,各自独立计数
|
|
|
// - 同一接口不同IP,各自独立计数
|
|
|
//
|
|
|
// 对比 Java 若依:
|
|
|
// 若依的限流键也是类似拼接:
|
|
|
// String key = config.getKey() + ":" + ip + ":" + methodName;
|
|
|
string cacheKey = $"hwportal:ratelimit:{Key}:{requestIp}:{actionName}";
|
|
|
|
|
|
// 时间窗口和最大次数的兜底处理。
|
|
|
// 如果传入的值 <= 0,就用默认值 60。
|
|
|
int windowSeconds = Time > 0 ? Time : 60;
|
|
|
int maxCount = Count > 0 ? Count : 60;
|
|
|
|
|
|
// 【分布式锁】
|
|
|
// 为什么需要锁?
|
|
|
// 限流必须原子性,避免并发请求同时穿透。
|
|
|
// 比如当前计数=9,两个请求同时读取,都认为可以通过,结果变成11次。
|
|
|
//
|
|
|
// BeginCacheLock 在缓存层获取分布式锁:
|
|
|
// - "lock:{cacheKey}":锁的键名
|
|
|
// - 500:获取锁的超时时间(毫秒)
|
|
|
// - 5000:锁的持有时间(毫秒)
|
|
|
// - throwOnFailure: false:获取失败不抛异常,返回 null
|
|
|
//
|
|
|
// using 语法:自动释放锁(离开作用域时调用 Dispose)
|
|
|
//
|
|
|
// 对比 Java 若依:
|
|
|
// 若依用 Redis 的 SETNX 实现分布式锁:
|
|
|
// Boolean locked = redisCache.setCacheObject(lockKey, "1", 5, TimeUnit.SECONDS);
|
|
|
using IDisposable? cacheLock = cacheService.BeginCacheLock($"lock:{cacheKey}", 500, 5000, throwOnFailure: false);
|
|
|
|
|
|
if (cacheLock == null)
|
|
|
{
|
|
|
// 获取锁失败,说明并发太高,直接拒绝。
|
|
|
// 业务取舍:宁可错杀少量请求,也不能放开限流。
|
|
|
//
|
|
|
// 【短路返回】
|
|
|
// context.Result = ... 会直接设置响应结果,不再执行控制器方法。
|
|
|
// 这就是"拦截"的实现方式。
|
|
|
//
|
|
|
// ObjectResult 是 ASP.NET Core 的返回类型,
|
|
|
// 会自动把对象序列化成 JSON 响应。
|
|
|
context.Result = new ObjectResult(HwPortalAjaxResult.Error(Message));
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
// 【读取计数器】
|
|
|
// 从缓存获取当前时间窗口的访问次数。
|
|
|
// HwPortalRateLimitCounter 是内部类,包含 Count 和 ExpireAt。
|
|
|
HwPortalRateLimitCounter counter = cacheService.Get<HwPortalRateLimitCounter>(cacheKey);
|
|
|
DateTime now = DateTime.UtcNow;
|
|
|
|
|
|
if (counter == null || counter.ExpireAt <= now)
|
|
|
{
|
|
|
// 【新建计数窗口】
|
|
|
// 没有计数器,或窗口已过期,创建新窗口。
|
|
|
//
|
|
|
// 为什么新窗口 Count = 1?
|
|
|
// 当前请求本身也算一次,所以从 1 开始。
|
|
|
counter = new HwPortalRateLimitCounter
|
|
|
{
|
|
|
Count = 1,
|
|
|
ExpireAt = now.AddSeconds(windowSeconds)
|
|
|
};
|
|
|
|
|
|
// 写入缓存,设置过期时间。
|
|
|
// 缓存到期后自动删除,不需要后台清理。
|
|
|
cacheService.Set(cacheKey, counter, TimeSpan.FromSeconds(windowSeconds));
|
|
|
|
|
|
// 【放行】
|
|
|
// await next() 调用下一个过滤器或控制器方法。
|
|
|
// 如果前面已经 return,就说明请求被拦截了。
|
|
|
await next();
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
if (counter.Count >= maxCount)
|
|
|
{
|
|
|
// 【限流触发】
|
|
|
// 计数器已达到上限,拒绝请求。
|
|
|
//
|
|
|
// 注意:这里返回的是正常响应(code=500),不是抛异常。
|
|
|
// 限流是"预期中的业务拒绝",不应该走全局异常处理。
|
|
|
context.Result = new ObjectResult(HwPortalAjaxResult.Error(Message));
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
// 【计数器+1】
|
|
|
// 窗口内后续请求,只增加计数,不重置过期时间。
|
|
|
// 这样是"固定窗口"限流,不是"滑动窗口"。
|
|
|
counter.Count++;
|
|
|
|
|
|
// 计算剩余时间。
|
|
|
TimeSpan expire = counter.ExpireAt - now;
|
|
|
|
|
|
// 更新缓存,用剩余时间作为过期时间。
|
|
|
cacheService.Set(cacheKey, counter, expire > TimeSpan.Zero ? expire : TimeSpan.FromSeconds(windowSeconds));
|
|
|
|
|
|
// 放行请求。
|
|
|
await next();
|
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
|
/// 限流计数器(内部类)。
|
|
|
/// <para>
|
|
|
/// 【C# 语法知识点 - 嵌套类】
|
|
|
/// private sealed class HwPortalRateLimitCounter
|
|
|
///
|
|
|
/// 嵌套类定义在另一个类内部:
|
|
|
/// - private:只能在外部类中使用
|
|
|
/// - sealed:不能被继承
|
|
|
///
|
|
|
/// 对比 Java:
|
|
|
/// Java 的内部类有两种:
|
|
|
/// - 静态嵌套类:static class Inner { } - 类似 C# 的嵌套类
|
|
|
/// - 非静态内部类:class Inner { } - 可以访问外部类实例成员
|
|
|
///
|
|
|
/// C# 的嵌套类默认是"静态"的(不能访问外部类实例成员),
|
|
|
/// 除非显式传入外部类引用。
|
|
|
/// </para>
|
|
|
/// <para>
|
|
|
/// 【为什么定义为嵌套类?】
|
|
|
/// 1. 只在这个特性内使用,不需要暴露给外部
|
|
|
/// 2. 封装性:外部代码不需要知道这个类的存在
|
|
|
/// 3. 避免污染命名空间
|
|
|
/// </para>
|
|
|
/// </summary>
|
|
|
private sealed class HwPortalRateLimitCounter
|
|
|
{
|
|
|
/// <summary>
|
|
|
/// 访问次数。
|
|
|
/// </summary>
|
|
|
public int Count { get; set; }
|
|
|
|
|
|
/// <summary>
|
|
|
/// 窗口过期时间。
|
|
|
/// </summary>
|
|
|
public DateTime ExpireAt { get; set; }
|
|
|
}
|
|
|
}
|