优化后台实时对局页面
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '预估赔付',
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user