TrafficGuard.php 2.7 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273
  1. <?php
  2. declare(strict_types=1);
  3. namespace app\miniapi\protection;
  4. use app\miniapi\exception\ApiException;
  5. use support\Log;
  6. /** Transport-independent quota policy. Identities must come from the verified session. */
  7. final class TrafficGuard
  8. {
  9. private static int $lastFailureLog = 0;
  10. public function check(string $scope, string $identity, string $endpoint, int $listCost, string $requestId, bool $listOnly = false): void
  11. {
  12. if (!config('mini_protection.enabled', false)) {
  13. return;
  14. }
  15. if (!in_array($scope, ['ip', 'account'], true)) {
  16. throw new \LogicException('Unknown protection scope');
  17. }
  18. $subject = hash('sha256', $identity);
  19. $buckets = $listOnly ? [] : [
  20. $this->bucket($scope . '_requests', $subject, 1),
  21. $this->bucket($scope . '_endpoint', $subject . ':' . hash('sha256', $endpoint), 1),
  22. ];
  23. if ($listCost > 0) {
  24. $buckets[] = $this->bucket($scope . '_list_rows', $subject, $listCost);
  25. }
  26. try {
  27. [$blocked, $retry, $notice] = (new RedisQuota())->consume($buckets);
  28. } catch (\Throwable $error) {
  29. // Do not log connection strings, request data or exception messages.
  30. if (time() - self::$lastFailureLog >= 60) {
  31. self::$lastFailureLog = time();
  32. Log::channel('mini_protection')->error('mini_api.protection_unavailable', [
  33. 'request_id' => $requestId,
  34. 'exception_type' => get_class($error),
  35. ]);
  36. }
  37. throw new ApiException(50303, '访问保护服务暂时不可用,请稍后重试', 503, ['retry_after' => 5]);
  38. }
  39. if (!$blocked) {
  40. return;
  41. }
  42. if ($notice) {
  43. Log::channel('mini_protection')->warning('mini_api.quota_exceeded', [
  44. 'request_id' => $requestId,
  45. 'scope' => $scope,
  46. 'subject_hash' => $subject,
  47. 'endpoint' => $endpoint,
  48. 'rule' => $buckets[$blocked - 1]['rule'],
  49. 'retry_after' => $retry,
  50. ]);
  51. }
  52. throw new ApiException(42902, '访问过于频繁,请在' . $retry . '秒后重试', 429, ['retry_after' => $retry]);
  53. }
  54. private function bucket(string $rule, string $subject, int $cost): array
  55. {
  56. [$limit, $seconds] = config('mini_protection.limits.' . $rule);
  57. return [
  58. 'key' => config('mini_protection.prefix') . $rule . ':' . $subject,
  59. 'limit' => (int) $limit,
  60. 'seconds' => (int) $seconds,
  61. 'cost' => $cost,
  62. 'rule' => $rule,
  63. ];
  64. }
  65. }