优化后台实时对局页面

This commit is contained in:
2026-04-24 15:03:41 +08:00
parent fd324f2882
commit 2ce128e63a
6 changed files with 209 additions and 22 deletions

View File

@@ -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);

View File

@@ -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);
}
/**

View File

@@ -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',

View File

@@ -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',

View File

@@ -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: '预估赔付',

View File

@@ -1,7 +1,7 @@
<template>
<div class="default-main">
<el-alert type="info" :title="t('game.live.tip')" show-icon class="mb-12" />
<el-alert type="info" :title="t('game.live.push_disconnected')" show-icon class="mb-12" />
<el-alert :type="wsConnected ? 'success' : 'warning'" :title="wsConnected ? t('game.live.ws_connected') : t('game.live.ws_disconnected')" show-icon class="mb-12" />
<el-alert
v-if="snapshot.runtime_enabled === false && !snapshot.maintenance_ui"
type="warning"
@@ -255,7 +255,101 @@ const serverSkewSeconds = ref(0)
const clockTick = ref(0)
let clockTimer: number | null = null
let pollTimer: number | null = null
const wsLoading = ref(false)
const wsReady = ref(false)
const wsConnected = ref(false)
const wsUrl = ref('')
const wsTopics = ref<string[]>([])
const wsClient = ref<WebSocket | null>(null)
async function reloadWsConfig(): Promise<void> {
wsLoading.value = true
try {
const res = await createAxios({
url: '/admin/game.Live/wsConfig',
method: 'get',
showCodeMessage: true,
})
if (res.code !== 1 || !res.data) {
wsReady.value = false
return
}
wsUrl.value = String(res.data.ws_url || '')
if (Array.isArray(res.data.subscribe_topics)) {
wsTopics.value = res.data.subscribe_topics.filter((topic: unknown): topic is string => typeof topic === 'string' && topic.trim() !== '')
} else {
wsTopics.value = []
}
wsReady.value = wsUrl.value !== ''
} finally {
wsLoading.value = false
}
}
function handleWsPayload(raw: unknown): void {
let parsed: anyObj | null = null
if (typeof raw === 'string') {
try {
parsed = JSON.parse(raw)
} catch {
return
}
} else if (raw && typeof raw === 'object') {
parsed = raw as anyObj
}
if (!parsed) {
return
}
const event = typeof parsed.event === 'string' ? parsed.event : ''
if (event === 'admin.live.snapshot' && parsed.data && typeof parsed.data === 'object') {
mergeLiveSnapshot(parsed.data as anyObj)
return
}
if (event === 'period.tick' && parsed.data && typeof parsed.data === 'object') {
const periodData = parsed.data as anyObj
if (typeof periodData.server_time === 'number') {
syncServerClock(periodData.server_time)
}
}
}
function connectWs(): void {
if (!wsReady.value || !wsUrl.value) {
return
}
disconnectWs()
const socket = new WebSocket(wsUrl.value)
wsClient.value = socket
socket.onopen = () => {
wsConnected.value = true
const topics = wsTopics.value
const payload = JSON.stringify({ action: 'subscribe', topics })
socket.send(payload)
}
socket.onmessage = (event) => {
handleWsPayload(event.data)
}
socket.onerror = () => {
wsConnected.value = false
}
socket.onclose = () => {
wsConnected.value = false
wsClient.value = null
window.setTimeout(() => {
if (!wsConnected.value) {
connectWs()
}
}, 1200)
}
}
function disconnectWs(): void {
if (wsClient.value) {
wsClient.value.close()
wsClient.value = null
}
wsConnected.value = false
}
function formatPicks(v: unknown): string {
if (Array.isArray(v)) return JSON.stringify(v)
@@ -359,6 +453,10 @@ async function loadSnapshot() {
async function onRuntimeSwitch(val: boolean | string | number): void {
const on = val === true || val === 'true' || val === 1
// 防止某些场景下 model-value 变化触发重复 change 事件,造成 runtime 接口循环调用
if (on === !!snapshot.runtime_enabled) {
return
}
runtimeSwitchLoading.value = true
try {
const res = await createAxios({
@@ -471,33 +569,18 @@ onMounted(async () => {
clockTick.value++
}, 1000)
await loadSnapshot()
startPolling()
await reloadWsConfig()
connectWs()
})
onUnmounted(() => {
stopPolling()
disconnectWs()
if (clockTimer !== null) {
window.clearInterval(clockTimer)
clockTimer = null
}
})
function startPolling() {
if (pollTimer !== null) {
return
}
pollTimer = window.setInterval(() => {
void loadSnapshot()
}, 2000)
}
function stopPolling() {
if (pollTimer !== null) {
window.clearInterval(pollTimer)
pollTimer = null
}
}
</script>
<style scoped lang="scss">