diff --git a/app/admin/controller/game/Live.php b/app/admin/controller/game/Live.php index 576ae74..60df060 100644 --- a/app/admin/controller/game/Live.php +++ b/app/admin/controller/game/Live.php @@ -3,6 +3,7 @@ namespace app\admin\controller\game; use app\common\controller\Backend; +use app\common\library\admin\WebSocketConfigHelper; use app\common\service\GameLiveService; use app\common\service\GameRecordService; use support\Response; @@ -38,6 +39,41 @@ class Live extends Backend return $this->success('', GameLiveService::buildSnapshot($recordId)); } + /** + * 后台实时对局 WebSocket 配置(管理员联调专用)。 + */ + public function wsConfig(WebmanRequest $request): Response + { + $response = $this->initializeBackend($request); + if ($response !== null) { + return $response; + } + + $topics = [ + 'admin.live.snapshot', + 'admin.live.opened', + 'period.tick', + 'period.locked', + 'period.opened', + 'period.payout', + 'bet.accepted', + 'wallet.changed', + 'auto.spin.progress', + ]; + + return $this->success('', [ + 'name' => 'ws.admin.live', + 'ws_url' => WebSocketConfigHelper::wsUrl(), + 'connect_tip' => '后台实时对局页将自动订阅管理员全量主题(含本局下注、候选号、开奖与派彩信息)。', + 'subscribe_topics' => $topics, + 'sample_messages' => [ + '{"action":"ping"}', + '{"action":"subscribe","topics":["admin.live.snapshot","admin.live.opened"]}', + '{"action":"subscribe","topics":["period.tick","period.opened","wallet.changed"]}', + ], + ]); + } + public function calculate(WebmanRequest $request): Response { $response = $this->initializeBackend($request); diff --git a/app/common/service/GameLiveService.php b/app/common/service/GameLiveService.php index 01ce2d9..134b86c 100644 --- a/app/common/service/GameLiveService.php +++ b/app/common/service/GameLiveService.php @@ -385,6 +385,14 @@ final class GameLiveService } self::publishPublicPeriodOpened((string) $record['period_no'], $finalNumber, $now); self::publishPublicPeriodPayout((string) $record['period_no'], $finalNumber, $payoutUntil); + GameWebSocketEventBus::publish('admin.live.opened', [ + 'period_id' => $rid, + 'period_no' => (string) $record['period_no'], + 'result_number' => $finalNumber, + 'payout_until' => $payoutUntil, + 'jackpot_hits' => is_array($settleOut['jackpot_hits'] ?? null) ? $settleOut['jackpot_hits'] : [], + 'server_time' => $now, + ]); self::publishSnapshot(null); return [ @@ -610,6 +618,7 @@ final class GameLiveService { $snapshot = self::buildSnapshot($recordId); self::publishPublicPeriodTick($snapshot); + GameWebSocketEventBus::publish('admin.live.snapshot', $snapshot); } /** diff --git a/app/process/GameWebSocketServer.php b/app/process/GameWebSocketServer.php index 9ba0195..717fa22 100644 --- a/app/process/GameWebSocketServer.php +++ b/app/process/GameWebSocketServer.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace app\process; use app\common\service\GameWebSocketEventBus; +use app\common\service\GameLiveService; use Workerman\Connection\TcpConnection; use Workerman\Timer; @@ -17,6 +18,7 @@ class GameWebSocketServer private static array $connections = []; private static bool $eventBusConsumerStarted = false; + private static bool $adminSnapshotTickerStarted = false; /** * 从 Redis 队列拉取事件并推送给已订阅连接。 @@ -60,9 +62,52 @@ class GameWebSocketServer }); } + /** + * 兜底直推:admin.live.snapshot 每秒主动构建并广播。 + * 目的:即使 Redis 队列不可用,也能保证 /admin/game/live 实时看到对局变化。 + */ + private static function ensureAdminLiveSnapshotTicker(): void + { + if (self::$adminSnapshotTickerStarted) { + return; + } + self::$adminSnapshotTickerStarted = true; + Timer::add(1, static function (): void { + $hasAdminSubscriber = false; + foreach (self::$connections as $connection) { + $topics = $connection->topics ?? []; + if (is_array($topics) && in_array('admin.live.snapshot', $topics, true)) { + $hasAdminSubscriber = true; + break; + } + } + if (!$hasAdminSubscriber) { + return; + } + $snapshot = GameLiveService::buildSnapshot(null); + $payload = json_encode([ + 'event' => 'admin.live.snapshot', + 'topic' => 'admin.live.snapshot', + 'data' => $snapshot, + 'server_time' => time(), + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if (!is_string($payload) || $payload === '') { + return; + } + foreach (self::$connections as $connection) { + $topics = $connection->topics ?? []; + if (!is_array($topics) || !in_array('admin.live.snapshot', $topics, true)) { + continue; + } + $connection->send($payload); + } + }); + } + public function onWorkerStart(): void { self::ensureEventBusConsumer(); + self::ensureAdminLiveSnapshotTicker(); } public function onConnect(TcpConnection $connection): void @@ -74,6 +119,7 @@ class GameWebSocketServer public function onWebSocketConnect(TcpConnection $connection): void { self::ensureEventBusConsumer(); + self::ensureAdminLiveSnapshotTicker(); $connection->send(json_encode([ 'event' => 'ws.connected', 'message' => 'WebSocket connected', diff --git a/web/src/lang/backend/en/game/live.ts b/web/src/lang/backend/en/game/live.ts index 266860c..286617c 100644 --- a/web/src/lang/backend/en/game/live.ts +++ b/web/src/lang/backend/en/game/live.ts @@ -17,6 +17,13 @@ export default { calc_estimated_loss: 'Estimated payout', push_connected: 'Realtime connection established', push_disconnected: 'Polling mode enabled (push removed)', + ws_connected: 'Connected to real-time match', + ws_disconnected: 'WebSocket disconnected (HTTP polling fallback only)', + ws_panel_title: 'Admin WebSocket (vs. mobile lightweight stream)', + ws_reload_config: 'Load WS config', + ws_connect: 'Connect WS', + ws_disconnect: 'Disconnect WS', + ws_log_empty: 'No WebSocket logs yet.', candidate_title: 'Candidate payout estimates', number: 'Number', estimated_loss: 'Estimated payout', @@ -30,8 +37,7 @@ export default { countdown_maintenance: 'Maintenance', runtime_draining_banner: 'Game stopped: the current round will run through draw, settlement and payout. Full maintenance UI appears after payout completes.', - runtime_maintenance_banner: - 'Maintenance: player betting is disabled. Turn runtime on to resume; a new round is created when idle.', + runtime_maintenance_banner: 'Maintenance: player betting is disabled. Turn runtime on to resume; a new round is created when idle.', runtime_off_tip: 'When turning runtime on with no active round, a new period is created immediately.', void_btn: 'Void round', void_dialog_title: 'Void current round', diff --git a/web/src/lang/backend/zh-cn/game/live.ts b/web/src/lang/backend/zh-cn/game/live.ts index 39dd462..aa36bb8 100644 --- a/web/src/lang/backend/zh-cn/game/live.ts +++ b/web/src/lang/backend/zh-cn/game/live.ts @@ -17,6 +17,13 @@ export default { calc_estimated_loss: '计算预估赔付', push_connected: '实时连接已建立', push_disconnected: '已切换为轮询模式(无推送)', + ws_connected: '已连接实时对局', + ws_disconnected: 'WebSocket 未连接(仅 HTTP 轮询兜底)', + ws_panel_title: '后台 WebSocket 连接(区别于前端轻量流)', + ws_reload_config: '加载WS配置', + ws_connect: '连接WS', + ws_disconnect: '断开WS', + ws_log_empty: '暂无 WebSocket 日志。', candidate_title: '候选号码赔付预估', number: '号码', estimated_loss: '预估赔付', diff --git a/web/src/views/backend/game/live/index.vue b/web/src/views/backend/game/live/index.vue index 55a1d83..25d30a7 100644 --- a/web/src/views/backend/game/live/index.vue +++ b/web/src/views/backend/game/live/index.vue @@ -1,7 +1,7 @@