AdminRealtimeFeed.php 9.9 KB

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