// +---------------------------------------------------------------------- declare(strict_types=1); namespace ywxapp\middleware; use Closure; use think\App; use think\exception\HttpException; use think\facade\Cache; use think\Request; use think\Response; /** * API 限流中间件(固定窗口计数,按 IP 维度)。 * * - 规则读取 config/ratelimit.php:rules 为「正则 => {limit, window}」, * 按声明顺序匹配 pathinfo,命中即用;未命中走 default 兜底。 * - 存储驱动跟随 config/cache.php:配了 Redis 自动分布式生效, * File 驱动单机生效。 * - 注册位置:app/api/middleware.php(放在 CrossDomain 之后, * 预检 OPTIONS 由 CrossDomain 提前返回,不计入配额)。 */ class RateLimit { public function __construct(protected App $app) { } public function handle(Request $request, Closure $next): Response { $default = (array) $this->app->config->get('ratelimit.default', ['limit' => 120, 'window' => 60]); $bucket = 'default'; $limit = (int) ($default['limit'] ?? 120); $window = max(1, (int) ($default['window'] ?? 60)); $path = strtolower($request->pathinfo()); foreach ((array) $this->app->config->get('ratelimit.rules', []) as $pattern => $rule) { if (!is_string($pattern) || !is_array($rule)) { continue; } if (preg_match($pattern, $path)) { $bucket = $pattern; $limit = (int) ($rule['limit'] ?? 0); $window = max(1, (int) ($rule['window'] ?? 60)); break; } } if ($limit <= 0) { return $next($request); // 0 = 该桶不限流 } $key = 'ratelimit:' . ($this->app->http->getName() ?: 'index') . ':' . md5($bucket) . ':' . $request->ip(); $hit = (int) Cache::get($key, 0); if ($hit >= $limit) { return $this->deny($request, $window, $hit); } Cache::set($key, $hit + 1, $window); return $next($request); } /** * 超限响应:api 应用或 Ajax 统一 JSON(结构与 Result 一致),其余抛 HttpException。 */ protected function deny(Request $request, int $window, int $hit): Response { $appName = $this->app->http->getName() ?: ''; $isApi = $appName === 'api' || $request->isAjax(); if (!$isApi) { throw new HttpException(429, '请求过于频繁,请稍后再试'); } $payload = [ 'code' => 429, 'message' => '请求过于频繁,请稍后再试', 'timestamp' => time(), ]; return Response::create($payload, 'json', 429)->header([ 'Retry-After' => (string) $window, 'X-RateLimit-Limit' => (string) $hit, ]); } }