AdminRealtimeFeed.php 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189
  1. <?php
  2. namespace App\Services\AdminRealtime;
  3. use App\Services\OnlineUserService;
  4. use Illuminate\Support\Collection;
  5. use Illuminate\Support\Facades\Cache;
  6. use Illuminate\Support\Facades\DB;
  7. use Illuminate\Support\Facades\Log;
  8. /**
  9. * 事件表的读取端:relay 按 id 递增取出未广播的事件、分配连续 seq 并解析成列表行;前端断线后按 seq 补发。
  10. *
  11. * 写入方可能在长事务内插入事件(melbet_sport-api),小 id 可能晚于大 id 提交,
  12. * 因此 relay 在 gap_seconds 内仍会补发游标之前未广播的行;seq 按广播先后分配,补发不会被前端漏掉。
  13. */
  14. final class AdminRealtimeFeed
  15. {
  16. private const ONLINE_SNAPSHOT = 'admin_realtime:online_snapshot';
  17. private const ONLINE_LOCK = 'admin_realtime:online_scan';
  18. private const GAP_WINDOW = 10000;
  19. /** relay 启动时的游标:继续上次广播到的位置;从未广播过则从当前最大 id 开始,不回放历史 */
  20. public static function startCursor(): int
  21. {
  22. $delivered = DB::table('admin_realtime_events')->whereNotNull('seq')->max('id');
  23. return (int) ($delivered ?? DB::table('admin_realtime_events')->max('id') ?? 0);
  24. }
  25. /**
  26. * 取出一批待广播事件并分配 seq,顺带扫描会员上下线
  27. *
  28. * @return array{cursor:int,events:list<array>,has_more:bool}
  29. */
  30. public static function next(int $cursor): array
  31. {
  32. self::scanOnline();
  33. $limit = max(1, min(1000, (int) config('admin_realtime.batch_size', 200)));
  34. $gapFrom = now('Asia/Shanghai')->subSeconds(max(0, (int) config('admin_realtime.gap_seconds', 60)))->format('Y-m-d H:i:s');
  35. $rows = DB::table('admin_realtime_events')
  36. ->whereNull('seq')
  37. ->where('id', '>', max(0, $cursor - self::GAP_WINDOW))
  38. ->where(fn($q) => $q->where('id', '>', $cursor)->orWhere('created_at', '>=', $gapFrom))
  39. ->orderBy('id')->limit($limit)->get();
  40. if ($rows->isEmpty()) {
  41. return ['cursor' => $cursor, 'events' => [], 'has_more' => false];
  42. }
  43. self::assignSeq($rows);
  44. return [
  45. 'cursor' => max($cursor, (int) $rows->max('id')),
  46. 'events' => self::resolve($rows->all()),
  47. 'has_more' => $rows->count() >= $limit,
  48. ];
  49. }
  50. /** relay 单实例运行,seq 从当前最大值连续递增 */
  51. private static function assignSeq(Collection $rows): void
  52. {
  53. $seq = (int) DB::table('admin_realtime_events')->max('seq');
  54. $cases = '';
  55. foreach ($rows as $row) {
  56. $row->seq = ++$seq;
  57. $cases .= ' WHEN ' . (int) $row->id . ' THEN ' . $seq;
  58. }
  59. DB::table('admin_realtime_events')
  60. ->whereIn('id', $rows->pluck('id')->all())
  61. ->update(['seq' => DB::raw('CASE id' . $cases . ' END')]);
  62. }
  63. public static function lastSeq(): int
  64. {
  65. return (int) DB::table('admin_realtime_events')->max('seq');
  66. }
  67. /**
  68. * 断线补发:返回 seq 大于 $afterSeq 的事件(按当前数据重新解析),只含请求且有权限的频道。
  69. * 游标已被清理、无效或积压过多时返回 resync,前端应重新拉取列表。
  70. *
  71. * @param list<string> $channels
  72. * @return array{resync:bool,reason?:string,last_seq:int,events:list<array>}
  73. */
  74. public static function replay(int $afterSeq, array $channels): array
  75. {
  76. $lastSeq = self::lastSeq();
  77. $resync = static fn(string $reason): array => ['resync' => true, 'reason' => $reason, 'last_seq' => $lastSeq, 'events' => []];
  78. if ($afterSeq < 0 || $afterSeq > $lastSeq) {
  79. return $resync('invalid_cursor');
  80. }
  81. if ($afterSeq === $lastSeq) {
  82. return ['resync' => false, 'last_seq' => $lastSeq, 'events' => []];
  83. }
  84. if ($afterSeq > 0 && !DB::table('admin_realtime_events')->where('seq', $afterSeq)->exists()) {
  85. return $resync('history_unavailable');
  86. }
  87. $limit = max(1, (int) config('admin_realtime.replay_limit', 1000));
  88. $rows = DB::table('admin_realtime_events')->where('seq', '>', $afterSeq)->where('seq', '<=', $lastSeq)
  89. ->orderBy('seq')->limit($limit + 1)->get();
  90. if ($rows->count() > $limit) {
  91. return $resync('too_many_events');
  92. }
  93. $wanted = array_flip($channels);
  94. return [
  95. 'resync' => false,
  96. 'last_seq' => $lastSeq,
  97. 'events' => array_values(array_filter(self::resolve($rows->all()), static fn(array $e): bool => isset($wanted[$e['channel']]))),
  98. ];
  99. }
  100. /** 同一记录在一批内多次变更只解析一次,取最后一条事件的 seq;先新增后更新仍按 created 推送 */
  101. private static function resolve(array $rows): array
  102. {
  103. $groups = [];
  104. foreach ($rows as $row) {
  105. $key = $row->resource . ':' . $row->resource_id;
  106. $created = ($groups[$key]['created'] ?? false) || $row->action === 'created';
  107. unset($groups[$key]);
  108. $groups[$key] = ['row' => $row, 'created' => $created];
  109. }
  110. $events = [];
  111. foreach ($groups as $group) {
  112. $row = $group['row'];
  113. $action = $row->action === 'updated' && $group['created'] ? 'created' : (string) $row->action;
  114. try {
  115. $messages = AdminRealtimeRows::resolve((string) $row->resource, (string) $row->resource_id, $action);
  116. } catch (\Throwable $e) {
  117. Log::channel('realtime')->error('admin_realtime_resolve_failed', [
  118. 'event_id' => $row->id, 'resource' => $row->resource, 'resource_id' => $row->resource_id, 'error' => $e->getMessage(),
  119. ]);
  120. continue;
  121. }
  122. foreach ($messages as $message) {
  123. $events[] = [
  124. 'seq' => (int) $row->seq,
  125. 'id' => (int) $row->id,
  126. 'channel' => $message['channel'],
  127. 'event' => self::eventName((string) $row->resource, $action),
  128. 'action' => $action,
  129. 'key' => $message['key'],
  130. 'data' => $message['data'],
  131. 'source' => (string) $row->source,
  132. 'created_at' => (string) $row->created_at,
  133. ];
  134. }
  135. }
  136. return $events;
  137. }
  138. private static function eventName(string $resource, string $action): string
  139. {
  140. if ($resource === AdminRealtime::USER_ONLINE) return 'user.' . $action;
  141. return ($resource === 'users' ? 'user.' : 'order.') . $action;
  142. }
  143. /** 在线判定含「最近活跃时间窗口」,下线不一定有写入,只能定期比对在线集合 */
  144. private static function scanOnline(): void
  145. {
  146. $interval = max(5, (int) config('admin_realtime.online_scan_seconds', 15));
  147. try {
  148. if (!Cache::add(self::ONLINE_LOCK, 1, $interval)) return;
  149. $current = OnlineUserService::onlineUserIds();
  150. $previous = Cache::get(self::ONLINE_SNAPSHOT);
  151. Cache::forever(self::ONLINE_SNAPSHOT, $current);
  152. if (!is_array($previous)) return;
  153. AdminRealtime::record(AdminRealtime::USER_ONLINE, array_diff($current, $previous), 'online');
  154. AdminRealtime::record(AdminRealtime::USER_ONLINE, array_diff($previous, $current), 'offline');
  155. } catch (\Throwable $e) {
  156. Log::channel('realtime')->error('admin_realtime_online_scan_failed', ['error' => $e->getMessage()]);
  157. }
  158. }
  159. public static function prune(): int
  160. {
  161. $hours = max(1, (int) config('admin_realtime.retention_hours', 72));
  162. $before = now('Asia/Shanghai')->subHours($hours)->format('Y-m-d H:i:s');
  163. $deleted = 0;
  164. do {
  165. $batch = DB::table('admin_realtime_events')->where('created_at', '<', $before)->orderBy('id')->limit(5000)->delete();
  166. $deleted += $batch;
  167. } while ($batch >= 5000);
  168. return $deleted;
  169. }
  170. }