You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

367 lines
15 KiB
C#

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

// ============================================================================
// 【文件说明】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 的中间件机制,可以在请求到达控制器前后执行代码。
//
// 执行顺序:
// 请求 -> 中间件 -> FilterOnActionExecuting -> 控制器方法 -> FilterOnActionExecuted -> 响应
//
// 对比 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&lt;HwPortalAjaxResult&gt; 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当前登录用户如果有
// - RequestServicesDI 服务容器
//
// 对比 Java Spring
// Java 通常通过注入 HttpServletRequest
// @Autowired private HttpServletRequest request;
// 或者在 Controller 方法参数中声明:
// public Result method(HttpServletRequest request) { ... }
HttpContext httpContext = context.HttpContext;
// 【从 DI 容器获取服务】
// httpContext.RequestServices.GetRequiredService&lt;SysCacheService&gt;()
//
// RequestServices 是"请求级 DI 容器"。
// GetRequiredService&lt;T&gt;() 获取服务实例,如果不存在会抛异常。
//
// 对比 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; }
}
}