AdminRealtimeFeed.php 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124
  1. <?php
  2. namespace App\Services\AdminRealtime;
  3. use App\Services\OnlineUserService;
  4. use Illuminate\Support\Facades\Cache;
  5. use Illuminate\Support\Facades\DB;
  6. use Illuminate\Support\Facades\Log;
  7. /**
  8. * 推送网关的拉取入口:按 id 递增读取事件表并解析成列表行,顺带扫描会员上下线。
  9. *
  10. * 写入方可能在长事务内插入事件(melbet_sport-api),小 id 可能晚于大 id 提交,
  11. * 因此网关会把跳过的 id 记为 gaps 在随后一段时间内补拉,本接口按 gaps 原样返回已出现的行。
  12. */
  13. final class AdminRealtimeFeed
  14. {
  15. private const ONLINE_SNAPSHOT = 'admin_realtime:online_snapshot';
  16. private const ONLINE_LOCK = 'admin_realtime:online_scan';
  17. /**
  18. * @param int $after 网关已处理的最大事件 id;小于 0 表示网关刚启动,只返回当前最大 id 不回放历史
  19. * @param list<int> $gaps 网关等待补拉的 id
  20. * @return array{last_id:int,ids:list<int>,events:list<array>,has_more:bool}
  21. */
  22. public static function pull(int $after, array $gaps = []): array
  23. {
  24. self::scanOnline();
  25. if ($after < 0) {
  26. return ['last_id' => (int) DB::table('admin_realtime_events')->max('id'), 'ids' => [], 'events' => [], 'has_more' => false];
  27. }
  28. $limit = max(1, min(1000, (int) config('admin_realtime.batch_size', 200)));
  29. $rows = DB::table('admin_realtime_events')->where('id', '>', $after)->orderBy('id')->limit($limit)->get();
  30. $hasMore = $rows->count() >= $limit;
  31. $gaps = array_values(array_filter(array_map('intval', $gaps), static fn(int $id): bool => $id > 0 && $id <= $after));
  32. if ($gaps) {
  33. $rows = DB::table('admin_realtime_events')->whereIn('id', array_slice($gaps, 0, 500))->orderBy('id')->get()->concat($rows);
  34. }
  35. return [
  36. 'last_id' => (int) ($rows->where('id', '>', $after)->max('id') ?? $after),
  37. 'ids' => $rows->pluck('id')->map(static fn($id): int => (int) $id)->values()->all(),
  38. 'events' => self::resolve($rows->all()),
  39. 'has_more' => $hasMore,
  40. ];
  41. }
  42. /** 同一记录在一批内多次变更只解析一次,取最后一条事件的 id;先新增后更新仍按 created 推送 */
  43. private static function resolve(array $rows): array
  44. {
  45. $groups = [];
  46. foreach ($rows as $row) {
  47. $key = $row->resource . ':' . $row->resource_id;
  48. $created = ($groups[$key]['created'] ?? false) || $row->action === 'created';
  49. $groups[$key] = ['row' => $row, 'created' => $created];
  50. }
  51. $events = [];
  52. foreach ($groups as $group) {
  53. $row = $group['row'];
  54. $action = $row->action === 'updated' && $group['created'] ? 'created' : (string) $row->action;
  55. try {
  56. $messages = AdminRealtimeRows::resolve((string) $row->resource, (string) $row->resource_id, $action);
  57. } catch (\Throwable $e) {
  58. Log::channel('realtime')->error('admin_realtime_resolve_failed', [
  59. 'event_id' => $row->id, 'resource' => $row->resource, 'resource_id' => $row->resource_id, 'error' => $e->getMessage(),
  60. ]);
  61. continue;
  62. }
  63. foreach ($messages as $message) {
  64. $events[] = [
  65. 'id' => (int) $row->id,
  66. 'channel' => $message['channel'],
  67. 'event' => self::eventName((string) $row->resource, $action),
  68. 'action' => $action,
  69. 'key' => $message['key'],
  70. 'data' => $message['data'],
  71. 'source' => (string) $row->source,
  72. 'created_at' => (string) $row->created_at,
  73. ];
  74. }
  75. }
  76. usort($events, static fn(array $a, array $b): int => $a['id'] <=> $b['id']);
  77. return $events;
  78. }
  79. private static function eventName(string $resource, string $action): string
  80. {
  81. if ($resource === AdminRealtime::USER_ONLINE) return 'user.' . $action;
  82. return ($resource === 'users' ? 'user.' : 'order.') . $action;
  83. }
  84. /** 在线判定含「最近活跃时间窗口」,下线不一定有写入,只能定期比对在线集合 */
  85. private static function scanOnline(): void
  86. {
  87. $interval = max(5, (int) config('admin_realtime.online_scan_seconds', 15));
  88. try {
  89. if (!Cache::add(self::ONLINE_LOCK, 1, $interval)) return;
  90. $current = OnlineUserService::onlineUserIds();
  91. $previous = Cache::get(self::ONLINE_SNAPSHOT);
  92. Cache::forever(self::ONLINE_SNAPSHOT, $current);
  93. if (!is_array($previous)) return;
  94. AdminRealtime::record(AdminRealtime::USER_ONLINE, array_diff($current, $previous), 'online');
  95. AdminRealtime::record(AdminRealtime::USER_ONLINE, array_diff($previous, $current), 'offline');
  96. } catch (\Throwable $e) {
  97. Log::channel('realtime')->error('admin_realtime_online_scan_failed', ['error' => $e->getMessage()]);
  98. }
  99. }
  100. public static function prune(): int
  101. {
  102. $hours = max(1, (int) config('admin_realtime.retention_hours', 72));
  103. $before = now('Asia/Shanghai')->subHours($hours)->format('Y-m-d H:i:s');
  104. $deleted = 0;
  105. do {
  106. $batch = DB::table('admin_realtime_events')->where('created_at', '<', $before)->orderBy('id')->limit(5000)->delete();
  107. $deleted += $batch;
  108. } while ($batch >= 5000);
  109. return $deleted;
  110. }
  111. }