AdminRealtimeFeed.php 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212
  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. /** 已发现事件的持久游标;待发送批次另按 seq 优先恢复。 */
  20. public static function startCursor(): int
  21. {
  22. return (int) DB::table('admin_realtime_state')->where('id', 1)->value('last_event_id');
  23. }
  24. /**
  25. * 取出一批待广播事件并分配 seq,顺带扫描会员上下线
  26. *
  27. * @return array{cursor:int,ids:list<int>,events:list<array>,has_more:bool}
  28. */
  29. public static function next(int $cursor): array
  30. {
  31. self::scanOnline();
  32. AdminRealtimeSummaries::scan();
  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. [$cursor, $rows] = DB::transaction(function () use ($cursor, $limit, $gapFrom): array {
  36. $state = DB::table('admin_realtime_state')->where('id', 1)->lockForUpdate()->firstOrFail();
  37. $cursor = max($cursor, (int) $state->last_event_id);
  38. // 未确认发送的旧批次优先;不能让后续 seq 越过发送失败的事件。
  39. $rows = DB::table('admin_realtime_events')->whereNotNull('seq')->whereNull('published_at')
  40. ->orderBy('seq')->limit($limit)->get();
  41. if ($rows->isEmpty()) {
  42. $rows = DB::table('admin_realtime_events')
  43. ->whereNull('seq')
  44. ->where('id', '>', max(0, $cursor - self::GAP_WINDOW))
  45. ->where(fn($q) => $q->where('id', '>', $cursor)->orWhere('created_at', '>=', $gapFrom))
  46. ->orderBy('id')->limit($limit)->get();
  47. if ($rows->isNotEmpty()) {
  48. self::assignSeq($rows, (int) $state->last_seq);
  49. $cursor = max($cursor, (int) $rows->max('id'));
  50. DB::table('admin_realtime_state')->where('id', 1)->update([
  51. 'last_seq' => (int) $rows->last()->seq, 'last_event_id' => $cursor,
  52. ]);
  53. }
  54. }
  55. return [$cursor, $rows];
  56. });
  57. return [
  58. 'cursor' => $cursor,
  59. 'ids' => $rows->pluck('id')->map(static fn($id): int => (int) $id)->all(),
  60. 'events' => self::resolve($rows->all()),
  61. 'has_more' => $rows->count() >= $limit,
  62. ];
  63. }
  64. /** 调用方持有 state 行锁,分配和持久化在同一事务内完成。 */
  65. private static function assignSeq(Collection $rows, int $seq): void
  66. {
  67. $cases = '';
  68. foreach ($rows as $row) {
  69. $row->seq = ++$seq;
  70. $cases .= ' WHEN ' . (int) $row->id . ' THEN ' . $seq;
  71. }
  72. DB::table('admin_realtime_events')
  73. ->whereIn('id', $rows->pluck('id')->all())
  74. ->update(['seq' => DB::raw('CASE id' . $cases . ' END')]);
  75. }
  76. public static function lastSeq(): int
  77. {
  78. return (int) DB::table('admin_realtime_state')->where('id', 1)->value('last_seq');
  79. }
  80. /** 整批全部发送成功才确认;部分成功后的重试允许重复,由前端去重。 */
  81. public static function acknowledge(array $ids): void
  82. {
  83. if ($ids) {
  84. DB::table('admin_realtime_events')->whereIn('id', $ids)->whereNotNull('seq')
  85. ->update(['published_at' => now('Asia/Shanghai')->format('Y-m-d H:i:s')]);
  86. }
  87. }
  88. /**
  89. * 断线补发:返回 seq 大于 $afterSeq 的事件(按当前数据重新解析),只含请求且有权限的频道。
  90. * 游标已被清理、无效或积压过多时返回 resync,前端应重新拉取列表。
  91. *
  92. * @param list<string> $channels
  93. * @return array{resync:bool,reason?:string,last_seq:int,events:list<array>}
  94. */
  95. public static function replay(int $afterSeq, array $channels): array
  96. {
  97. $lastSeq = self::lastSeq();
  98. $resync = static fn(string $reason): array => ['resync' => true, 'reason' => $reason, 'last_seq' => $lastSeq, 'events' => []];
  99. if ($afterSeq < 0 || $afterSeq > $lastSeq) {
  100. return $resync('invalid_cursor');
  101. }
  102. if ($afterSeq === $lastSeq) {
  103. return ['resync' => false, 'last_seq' => $lastSeq, 'events' => []];
  104. }
  105. if ($afterSeq > 0 && !DB::table('admin_realtime_events')->where('seq', $afterSeq)->exists()) {
  106. return $resync('history_unavailable');
  107. }
  108. $limit = max(1, (int) config('admin_realtime.replay_limit', 1000));
  109. $rows = DB::table('admin_realtime_events')->where('seq', '>', $afterSeq)->where('seq', '<=', $lastSeq)
  110. ->orderBy('seq')->limit($limit + 1)->get();
  111. if ($rows->count() > $limit) {
  112. return $resync('too_many_events');
  113. }
  114. $wanted = array_flip($channels);
  115. return [
  116. 'resync' => false,
  117. 'last_seq' => $lastSeq,
  118. 'events' => array_values(array_filter(self::resolve($rows->all()), static fn(array $e): bool => isset($wanted[$e['channel']]))),
  119. ];
  120. }
  121. /** 同一记录在一批内多次变更只解析一次,取最后一条事件的 seq;先新增后更新仍按 created 推送 */
  122. private static function resolve(array $rows): array
  123. {
  124. $groups = [];
  125. foreach ($rows as $row) {
  126. $key = $row->resource . ':' . $row->resource_id;
  127. $created = ($groups[$key]['created'] ?? false) || $row->action === 'created';
  128. unset($groups[$key]);
  129. $groups[$key] = ['row' => $row, 'created' => $created];
  130. }
  131. $events = [];
  132. foreach ($groups as $group) {
  133. $row = $group['row'];
  134. $action = $row->action === 'updated' && $group['created'] ? 'created' : (string) $row->action;
  135. try {
  136. $messages = AdminRealtimeRows::resolve((string) $row->resource, (string) $row->resource_id, $action);
  137. } catch (\Throwable $e) {
  138. Log::channel('realtime')->error('admin_realtime_resolve_failed', [
  139. 'event_id' => $row->id, 'resource' => $row->resource, 'resource_id' => $row->resource_id, 'error' => $e->getMessage(),
  140. ]);
  141. throw $e;
  142. }
  143. foreach ($messages as $message) {
  144. $events[] = [
  145. 'seq' => (int) $row->seq,
  146. 'id' => (int) $row->id,
  147. 'channel' => $message['channel'],
  148. 'event' => self::eventName((string) $row->resource, $action),
  149. 'action' => $action,
  150. 'key' => $message['key'],
  151. 'data' => $message['data'],
  152. 'source' => (string) $row->source,
  153. 'created_at' => (string) $row->created_at,
  154. ];
  155. }
  156. }
  157. return $events;
  158. }
  159. private static function eventName(string $resource, string $action): string
  160. {
  161. if (isset(AdminRealtimeSummaries::DEFINITIONS[$resource])) return 'stats.updated';
  162. if ($resource === AdminRealtime::USER_ONLINE) return 'user.' . $action;
  163. return ($resource === 'users' ? 'user.' : 'order.') . $action;
  164. }
  165. /** 在线判定含「最近活跃时间窗口」,下线不一定有写入,只能定期比对在线集合 */
  166. private static function scanOnline(): void
  167. {
  168. $interval = max(5, (int) config('admin_realtime.online_scan_seconds', 15));
  169. try {
  170. if (!Cache::add(self::ONLINE_LOCK, 1, $interval)) return;
  171. $current = OnlineUserService::onlineUserIds();
  172. $previous = Cache::get(self::ONLINE_SNAPSHOT);
  173. Cache::forever(self::ONLINE_SNAPSHOT, $current);
  174. if (!is_array($previous)) return;
  175. AdminRealtime::record(AdminRealtime::USER_ONLINE, array_diff($current, $previous), 'online');
  176. AdminRealtime::record(AdminRealtime::USER_ONLINE, array_diff($previous, $current), 'offline');
  177. } catch (\Throwable $e) {
  178. Log::channel('realtime')->error('admin_realtime_online_scan_failed', ['error' => $e->getMessage()]);
  179. }
  180. }
  181. public static function prune(): int
  182. {
  183. $hours = max(1, (int) config('admin_realtime.retention_hours', 72));
  184. $before = now('Asia/Shanghai')->subHours($hours)->format('Y-m-d H:i:s');
  185. $deleted = 0;
  186. do {
  187. $batch = DB::table('admin_realtime_events')->where('created_at', '<', $before)
  188. ->where(fn($q) => $q->whereNotNull('published_at')->orWhereNull('seq'))
  189. ->orderBy('id')->limit(5000)->delete();
  190. $deleted += $batch;
  191. } while ($batch >= 5000);
  192. return $deleted;
  193. }
  194. }