AdminRealtimeAccess.php 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133
  1. <?php
  2. namespace App\Services\AdminRealtime;
  3. use App\Services\Agent\AgentAdminAccess;
  4. use App\Services\MenuService;
  5. use App\Services\RoleService;
  6. use App\Services\RoleUserService;
  7. use Illuminate\Support\Facades\Broadcast;
  8. use Illuminate\Support\Facades\Cache;
  9. use Illuminate\Support\Facades\Log;
  10. use Pusher\Pusher;
  11. use RuntimeException;
  12. /**
  13. * 后台实时推送的访问控制:频道授权与登录过期断开。
  14. *
  15. * 频道授权响应在 channel_data 中携带「会话 user_id」(管理员 id + JWT 过期时间),Reverb 把它记在连接上;
  16. * relay 定期把已过期的会话登记出来,调用 Reverb 的 terminate 接口断开这些连接。
  17. * 断开后前端自动重连会重新授权,过期的 JWT 授权失败(401),前端据此跳转登录。
  18. */
  19. final class AdminRealtimeAccess
  20. {
  21. private const SESSIONS = 'admin_realtime:sessions';
  22. /** @return list<string> 管理员可订阅的频道,口径与 CheckButtonPermission 对列表接口的按钮权限一致 */
  23. public static function allowedChannels(int $adminId): array
  24. {
  25. $ttl = max(0, (int) config('admin_realtime.permission_cache_seconds', 60));
  26. if ($ttl === 0) {
  27. return self::resolveChannels($adminId);
  28. }
  29. return Cache::remember('admin_realtime:channels:' . $adminId, $ttl, static fn(): array => self::resolveChannels($adminId));
  30. }
  31. private static function resolveChannels(int $adminId): array
  32. {
  33. $channels = array_keys(AdminRealtimeRows::CHANNELS);
  34. if ($adminId === 1) {
  35. return $channels;
  36. }
  37. if (!AgentAdminAccess::allowed($adminId)) {
  38. $channels = array_values(array_diff($channels, AdminRealtimeRows::AGENT_CHANNELS));
  39. }
  40. $roleIds = collect(RoleUserService::findAll(['user_id' => $adminId]))->pluck('role_id')->toArray() ?: [-1];
  41. $granted = [];
  42. foreach (RoleService::model()::with(['menus' => function ($query) {
  43. $query->where('type', MenuService::model()::TYPE_BUTTON);
  44. }])->whereIn('id', $roleIds)->get() as $role) {
  45. $granted = array_merge($granted, $role['menus_uris'] ?: []);
  46. }
  47. return array_values(array_filter($channels, static function (string $channel) use ($granted): bool {
  48. $uri = AdminRealtimeRows::CHANNELS[$channel][1];
  49. $menu = MenuService::findOne(['uri' => $uri, 'type' => MenuService::model()::TYPE_BUTTON, 'status' => MenuService::model()::STATUS_SHOW]);
  50. return empty($menu) || in_array($uri, $granted, true);
  51. }));
  52. }
  53. /**
  54. * Pusher 协议私有频道授权,返回 {auth, channel_data}
  55. *
  56. * @param string $channelName 带 private- 前缀的频道名
  57. * @throws \Pusher\PusherException socket_id 或频道名格式不合法
  58. */
  59. public static function authorize(int $adminId, ?int $sessionExpiresAt, string $channelName, string $socketId): array
  60. {
  61. if (!$sessionExpiresAt) {
  62. throw new RuntimeException('后台推送要求 JWT 带有过期时间');
  63. }
  64. $userId = self::sessionUserId($adminId, $sessionExpiresAt);
  65. $auth = self::pusher()->authorizeChannel($channelName, $socketId, json_encode(['user_id' => $userId]));
  66. if ($sessionExpiresAt) {
  67. self::rememberSession($userId, $sessionExpiresAt);
  68. }
  69. return json_decode($auth, true);
  70. }
  71. public static function sessionUserId(int $adminId, ?int $sessionExpiresAt): string
  72. {
  73. return $adminId . ':' . (int) $sessionExpiresAt;
  74. }
  75. /** 断开 JWT 已过期的会话,由 relay 定期调用;返回断开的会话 user_id */
  76. public static function terminateExpiredSessions(): array
  77. {
  78. $sessions = Cache::get(self::SESSIONS, []);
  79. $terminated = [];
  80. foreach ($sessions as $userId => $exp) {
  81. if ($exp > time()) continue;
  82. try {
  83. self::pusher()->terminateUserConnections((string) $userId);
  84. $terminated[(string) $userId] = $exp;
  85. } catch (\Throwable $e) {
  86. Log::channel('realtime')->error('admin_realtime_terminate_failed', ['user_id' => $userId, 'error' => $e->getMessage()]);
  87. }
  88. }
  89. if ($terminated) {
  90. Cache::lock(self::SESSIONS . ':lock', 5)->block(3, function () use ($terminated) {
  91. $current = Cache::get(self::SESSIONS, []);
  92. foreach ($terminated as $userId => $exp) {
  93. if (($current[$userId] ?? null) === $exp) unset($current[$userId]);
  94. }
  95. if (!Cache::forever(self::SESSIONS, $current)) {
  96. throw new RuntimeException('保存后台推送会话状态失败');
  97. }
  98. });
  99. }
  100. return array_keys($terminated);
  101. }
  102. /** 同一会话订阅多个频道会重复授权,已登记的不再加锁写缓存 */
  103. private static function rememberSession(string $userId, int $exp): void
  104. {
  105. if (isset(Cache::get(self::SESSIONS, [])[$userId])) {
  106. return;
  107. }
  108. Cache::lock(self::SESSIONS . ':lock', 5)->block(3, function () use ($userId, $exp) {
  109. $sessions = Cache::get(self::SESSIONS, []);
  110. $sessions[$userId] = $exp;
  111. if (!Cache::forever(self::SESSIONS, $sessions)) {
  112. throw new RuntimeException('登记后台推送会话失败');
  113. }
  114. });
  115. }
  116. private static function pusher(): Pusher
  117. {
  118. return Broadcast::connection(config('admin_realtime.connection'))->getPusher();
  119. }
  120. }