优化后台实时对局页面
This commit is contained in:
@@ -3,6 +3,7 @@
|
|||||||
namespace app\admin\controller\game;
|
namespace app\admin\controller\game;
|
||||||
|
|
||||||
use app\common\controller\Backend;
|
use app\common\controller\Backend;
|
||||||
|
use app\common\library\admin\WebSocketConfigHelper;
|
||||||
use app\common\service\GameLiveService;
|
use app\common\service\GameLiveService;
|
||||||
use app\common\service\GameRecordService;
|
use app\common\service\GameRecordService;
|
||||||
use support\Response;
|
use support\Response;
|
||||||
@@ -38,6 +39,41 @@ class Live extends Backend
|
|||||||
return $this->success('', GameLiveService::buildSnapshot($recordId));
|
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
|
public function calculate(WebmanRequest $request): Response
|
||||||
{
|
{
|
||||||
$response = $this->initializeBackend($request);
|
$response = $this->initializeBackend($request);
|
||||||
|
|||||||
@@ -385,6 +385,14 @@ final class GameLiveService
|
|||||||
}
|
}
|
||||||
self::publishPublicPeriodOpened((string) $record['period_no'], $finalNumber, $now);
|
self::publishPublicPeriodOpened((string) $record['period_no'], $finalNumber, $now);
|
||||||
self::publishPublicPeriodPayout((string) $record['period_no'], $finalNumber, $payoutUntil);
|
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);
|
self::publishSnapshot(null);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -610,6 +618,7 @@ final class GameLiveService
|
|||||||
{
|
{
|
||||||
$snapshot = self::buildSnapshot($recordId);
|
$snapshot = self::buildSnapshot($recordId);
|
||||||
self::publishPublicPeriodTick($snapshot);
|
self::publishPublicPeriodTick($snapshot);
|
||||||
|
GameWebSocketEventBus::publish('admin.live.snapshot', $snapshot);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace app\process;
|
namespace app\process;
|
||||||
|
|
||||||
use app\common\service\GameWebSocketEventBus;
|
use app\common\service\GameWebSocketEventBus;
|
||||||
|
use app\common\service\GameLiveService;
|
||||||
use Workerman\Connection\TcpConnection;
|
use Workerman\Connection\TcpConnection;
|
||||||
use Workerman\Timer;
|
use Workerman\Timer;
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ class GameWebSocketServer
|
|||||||
private static array $connections = [];
|
private static array $connections = [];
|
||||||
|
|
||||||
private static bool $eventBusConsumerStarted = false;
|
private static bool $eventBusConsumerStarted = false;
|
||||||
|
private static bool $adminSnapshotTickerStarted = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从 Redis 队列拉取事件并推送给已订阅连接。
|
* 从 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
|
public function onWorkerStart(): void
|
||||||
{
|
{
|
||||||
self::ensureEventBusConsumer();
|
self::ensureEventBusConsumer();
|
||||||
|
self::ensureAdminLiveSnapshotTicker();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function onConnect(TcpConnection $connection): void
|
public function onConnect(TcpConnection $connection): void
|
||||||
@@ -74,6 +119,7 @@ class GameWebSocketServer
|
|||||||
public function onWebSocketConnect(TcpConnection $connection): void
|
public function onWebSocketConnect(TcpConnection $connection): void
|
||||||
{
|
{
|
||||||
self::ensureEventBusConsumer();
|
self::ensureEventBusConsumer();
|
||||||
|
self::ensureAdminLiveSnapshotTicker();
|
||||||
$connection->send(json_encode([
|
$connection->send(json_encode([
|
||||||
'event' => 'ws.connected',
|
'event' => 'ws.connected',
|
||||||
'message' => 'WebSocket connected',
|
'message' => 'WebSocket connected',
|
||||||
|
|||||||
@@ -17,6 +17,13 @@ export default {
|
|||||||
calc_estimated_loss: 'Estimated payout',
|
calc_estimated_loss: 'Estimated payout',
|
||||||
push_connected: 'Realtime connection established',
|
push_connected: 'Realtime connection established',
|
||||||
push_disconnected: 'Polling mode enabled (push removed)',
|
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',
|
candidate_title: 'Candidate payout estimates',
|
||||||
number: 'Number',
|
number: 'Number',
|
||||||
estimated_loss: 'Estimated payout',
|
estimated_loss: 'Estimated payout',
|
||||||
@@ -30,8 +37,7 @@ export default {
|
|||||||
countdown_maintenance: 'Maintenance',
|
countdown_maintenance: 'Maintenance',
|
||||||
runtime_draining_banner:
|
runtime_draining_banner:
|
||||||
'Game stopped: the current round will run through draw, settlement and payout. Full maintenance UI appears after payout completes.',
|
'Game stopped: the current round will run through draw, settlement and payout. Full maintenance UI appears after payout completes.',
|
||||||
runtime_maintenance_banner:
|
runtime_maintenance_banner: 'Maintenance: player betting is disabled. Turn runtime on to resume; a new round is created when idle.',
|
||||||
'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.',
|
runtime_off_tip: 'When turning runtime on with no active round, a new period is created immediately.',
|
||||||
void_btn: 'Void round',
|
void_btn: 'Void round',
|
||||||
void_dialog_title: 'Void current round',
|
void_dialog_title: 'Void current round',
|
||||||
|
|||||||
@@ -17,6 +17,13 @@ export default {
|
|||||||
calc_estimated_loss: '计算预估赔付',
|
calc_estimated_loss: '计算预估赔付',
|
||||||
push_connected: '实时连接已建立',
|
push_connected: '实时连接已建立',
|
||||||
push_disconnected: '已切换为轮询模式(无推送)',
|
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: '候选号码赔付预估',
|
candidate_title: '候选号码赔付预估',
|
||||||
number: '号码',
|
number: '号码',
|
||||||
estimated_loss: '预估赔付',
|
estimated_loss: '预估赔付',
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="default-main">
|
<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.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
|
<el-alert
|
||||||
v-if="snapshot.runtime_enabled === false && !snapshot.maintenance_ui"
|
v-if="snapshot.runtime_enabled === false && !snapshot.maintenance_ui"
|
||||||
type="warning"
|
type="warning"
|
||||||
@@ -255,7 +255,101 @@ const serverSkewSeconds = ref(0)
|
|||||||
const clockTick = ref(0)
|
const clockTick = ref(0)
|
||||||
let clockTimer: number | null = null
|
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 {
|
function formatPicks(v: unknown): string {
|
||||||
if (Array.isArray(v)) return JSON.stringify(v)
|
if (Array.isArray(v)) return JSON.stringify(v)
|
||||||
@@ -359,6 +453,10 @@ async function loadSnapshot() {
|
|||||||
|
|
||||||
async function onRuntimeSwitch(val: boolean | string | number): void {
|
async function onRuntimeSwitch(val: boolean | string | number): void {
|
||||||
const on = val === true || val === 'true' || val === 1
|
const on = val === true || val === 'true' || val === 1
|
||||||
|
// 防止某些场景下 model-value 变化触发重复 change 事件,造成 runtime 接口循环调用
|
||||||
|
if (on === !!snapshot.runtime_enabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
runtimeSwitchLoading.value = true
|
runtimeSwitchLoading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await createAxios({
|
const res = await createAxios({
|
||||||
@@ -471,33 +569,18 @@ onMounted(async () => {
|
|||||||
clockTick.value++
|
clockTick.value++
|
||||||
}, 1000)
|
}, 1000)
|
||||||
await loadSnapshot()
|
await loadSnapshot()
|
||||||
startPolling()
|
await reloadWsConfig()
|
||||||
|
connectWs()
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
stopPolling()
|
disconnectWs()
|
||||||
if (clockTimer !== null) {
|
if (clockTimer !== null) {
|
||||||
window.clearInterval(clockTimer)
|
window.clearInterval(clockTimer)
|
||||||
clockTimer = null
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
|
|||||||
Reference in New Issue
Block a user