// ============================================================================ // 【文件说明】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; /// /// IP限流特性。 /// /// 【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 的注解更像接口,只能定义属性签名。 /// /// /// 【AttributeUsage 特性】 /// [AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = true)] /// /// 这是"元特性"——给特性本身贴的特性: /// - AttributeTargets.Method:只能用在方法上(不能用在类上) /// - AllowMultiple = false:同一个方法只能贴一个 /// - Inherited = true:子类继承父类方法时,特性也会继承 /// /// 对比 Java: /// Java 用 @Target 和 @Retention 注解定义: /// @Target(ElementType.METHOD) // 只能用在方法上 /// @Retention(RetentionPolicy.RUNTIME) // 运行时保留 /// /// [AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = true)] public sealed class HwPortalIpRateLimitAttribute : Attribute, IAsyncActionFilter { /// /// 限流键名(用于区分不同业务场景)。 /// public string Key { get; } /// /// 时间窗口(秒)。 /// public int Time { get; } /// /// 最大访问次数。 /// public int Count { get; } /// /// 限流提示消息。 /// /// 【C# 语法知识点 - 属性默认值】 /// public string Message { get; set; } = "访问过于频繁,请稍后再试"; /// /// = "..." 是属性的默认值初始化。 /// 调用方可以覆盖:[HwPortalIpRateLimit("key", 60, 10, Message = "自定义消息")] /// /// 对比 Java: /// Java 注解用 default 关键字: /// String message() default "访问过于频繁,请稍后再试"; /// /// public string Message { get; set; } = "访问过于频繁,请稍后再试"; /// /// 构造函数。 /// /// 【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# 特性更接近普通类,构造函数参数是位置参数(必填), /// 属性是命名参数(可选)。 /// /// /// 【设计意图】 /// 把限流配置固化在特性参数里,让控制器方法声明时就能看出限流策略: /// - 一眼看出限流配置 /// - 不用翻配置文件 /// - 代码即文档 /// /// /// 限流键名 /// 时间窗口(秒) /// 最大次数 public HwPortalIpRateLimitAttribute(string key, int time, int count) { Key = key; Time = time; Count = count; } /// /// 异步过滤器执行方法。 /// /// 【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; /// } /// /// /// 执行上下文 /// 继续执行的委托 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(); // 获取请求 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(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(); } /// /// 限流计数器(内部类)。 /// /// 【C# 语法知识点 - 嵌套类】 /// private sealed class HwPortalRateLimitCounter /// /// 嵌套类定义在另一个类内部: /// - private:只能在外部类中使用 /// - sealed:不能被继承 /// /// 对比 Java: /// Java 的内部类有两种: /// - 静态嵌套类:static class Inner { } - 类似 C# 的嵌套类 /// - 非静态内部类:class Inner { } - 可以访问外部类实例成员 /// /// C# 的嵌套类默认是"静态"的(不能访问外部类实例成员), /// 除非显式传入外部类引用。 /// /// /// 【为什么定义为嵌套类?】 /// 1. 只在这个特性内使用,不需要暴露给外部 /// 2. 封装性:外部代码不需要知道这个类的存在 /// 3. 避免污染命名空间 /// /// private sealed class HwPortalRateLimitCounter { /// /// 访问次数。 /// public int Count { get; set; } /// /// 窗口过期时间。 /// public DateTime ExpireAt { get; set; } } }