From bb5ef82d4902b447ec3c643e81bcee47ba12c0e7 Mon Sep 17 00:00:00 2001 From: zhenhui <1276357500@qq.com> Date: Tue, 26 May 2026 18:30:19 +0800 Subject: [PATCH] =?UTF-8?q?1.=E4=BF=AE=E5=A4=8D=E8=87=AA=E5=8A=A8=E5=88=9B?= =?UTF-8?q?=E5=BB=BA=E4=B8=8B=E4=B8=80=E6=9C=9Fbug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin/controller/game/Live.php | 2 - .../library/admin/WebSocketConfigHelper.php | 28 +++ app/common/service/GameLiveService.php | 194 ++++++++++++------ web/src/lang/backend/en/game/live.ts | 3 +- web/src/lang/backend/zh-cn/game/live.ts | 3 +- web/src/views/backend/game/live/index.vue | 7 +- web/vite.config.ts | 1 + 7 files changed, 169 insertions(+), 69 deletions(-) diff --git a/app/admin/controller/game/Live.php b/app/admin/controller/game/Live.php index 07a8d1a..8184224 100644 --- a/app/admin/controller/game/Live.php +++ b/app/admin/controller/game/Live.php @@ -25,7 +25,6 @@ class Live extends Backend { $recordIdRaw = $this->request ? $this->request->get('record_id') : null; $recordId = is_numeric((string) $recordIdRaw) ? (int) $recordIdRaw : null; - GameLiveService::recoverLiveRoundState(); return $this->success('', GameLiveService::buildSnapshot($recordId)); } @@ -38,7 +37,6 @@ class Live extends Backend } $recordIdRaw = $request->get('record_id'); $recordId = is_numeric((string) $recordIdRaw) ? (int) $recordIdRaw : null; - GameLiveService::recoverLiveRoundState(); return $this->success('', GameLiveService::buildSnapshot($recordId)); } diff --git a/app/common/library/admin/WebSocketConfigHelper.php b/app/common/library/admin/WebSocketConfigHelper.php index 6888dfd..c55fbd6 100644 --- a/app/common/library/admin/WebSocketConfigHelper.php +++ b/app/common/library/admin/WebSocketConfigHelper.php @@ -11,6 +11,9 @@ final class WebSocketConfigHelper public static function wsUrl(?Request $request = null): string { $url = trim((string) env('H5_WEBSOCKET_URL', '')); + if ($url !== '' && $request !== null && self::isLoopbackWsUrl($url) && !self::isLoopbackRequestHost($request)) { + $url = ''; + } if ($url !== '') { return $url; } @@ -36,5 +39,30 @@ final class WebSocketConfigHelper return 'ws://127.0.0.1:3131/ws/'; } + + private static function isLoopbackWsUrl(string $url): bool + { + $host = parse_url($url, PHP_URL_HOST); + if (!is_string($host) || $host === '') { + return false; + } + $host = strtolower($host); + + return in_array($host, ['127.0.0.1', 'localhost', '::1'], true); + } + + private static function isLoopbackRequestHost(Request $request): bool + { + $host = strtolower(trim((string) $request->host(true))); + if ($host === '') { + $host = strtolower(trim((string) $request->header('host', ''))); + } + if ($host === '') { + return false; + } + $hostOnly = preg_split('/:/', $host)[0] ?? $host; + + return in_array($hostOnly, ['127.0.0.1', 'localhost', '::1'], true); + } } diff --git a/app/common/service/GameLiveService.php b/app/common/service/GameLiveService.php index f3df0cf..4010f00 100644 --- a/app/common/service/GameLiveService.php +++ b/app/common/service/GameLiveService.php @@ -641,60 +641,103 @@ final class GameLiveService $now = time(); $drawCommitted = false; - Db::startTrans(); - try { - $record = self::loadRecordRowFromDb($rid, true); - if (!$record) { - Db::rollback(); - $result = ['ok' => false, 'msg' => __('No active game in progress')]; - } else { + $txResult = self::withShortInnodbLockWait(3, static function () use ( + $rid, + $betSeconds, + $periodSeconds, + $manualNumber, + $now + ): array { + Db::startTrans(); + try { + $record = self::loadRecordRowFromDb($rid, true); + if (!$record) { + Db::rollback(); + + return ['ok' => false, 'draw_committed' => false, 'result' => ['ok' => false, 'msg' => __('No active game in progress')]]; + } $st = (int) ($record['status'] ?? -1); $existingResult = filter_var($record['result_number'] ?? 0, FILTER_VALIDATE_INT); if ($existingResult !== false && $existingResult >= 1 && $existingResult <= self::DRAW_NUMBER_MAX && $st >= 2) { Db::commit(); - $periodNo = is_string($record['period_no'] ?? null) ? (string) $record['period_no'] : ''; - $postLockWork = static function () use ($rid): void { - GameHotDataCoordinator::afterGameRecordCommitted($rid); - self::publishSnapshot($rid, false); - }; - $result = [ - 'ok' => true, - 'msg' => __('Draw completed; paying out'), - 'result_number' => $existingResult, - 'estimated_loss' => '0.00', - 'payout_until' => (int) ($record['payout_until'] ?? 0), - ]; - } elseif (!in_array($st, [0, 1], true)) { - Db::rollback(); - $result = ['ok' => false, 'msg' => __('Current game status does not allow drawing')]; - } else { - $elapsedLocked = max(0, $now - (int) ($record['period_start_at'] ?? $now)); - if ($elapsedLocked < $betSeconds || $elapsedLocked < $periodSeconds) { - Db::rollback(); - $result = ['ok' => false, 'msg' => __('Period countdown has not ended; cannot draw yet')]; - } else { - [$finalNumber, $drawMode] = self::resolveFinalDrawNumber($record, $manualNumber); - $bets = Db::name('bet_order')->where('period_id', $rid)->select()->toArray(); - $finalLoss = self::estimateLossForNumber($bets, $finalNumber); - $payoutUntil = $now + self::getPayoutGraceSeconds(); - $periodNo = is_string($record['period_no'] ?? null) ? (string) $record['period_no'] : ''; - Db::name('game_record')->where('id', $rid)->update([ - 'status' => 3, - 'result_number' => $finalNumber, - 'draw_mode' => $drawMode, - 'pending_draw_number' => null, - 'payout_until' => $payoutUntil, - 'update_time' => $now, - ]); - Db::commit(); - $drawCommitted = true; - } + return [ + 'ok' => true, + 'draw_committed' => false, + 'existing_result' => $existingResult, + 'record' => $record, + ]; } + if (!in_array($st, [0, 1], true)) { + Db::rollback(); + + return ['ok' => false, 'draw_committed' => false, 'result' => ['ok' => false, 'msg' => __('Current game status does not allow drawing')]]; + } + $elapsedLocked = max(0, $now - (int) ($record['period_start_at'] ?? $now)); + if ($elapsedLocked < $betSeconds || $elapsedLocked < $periodSeconds) { + Db::rollback(); + + return ['ok' => false, 'draw_committed' => false, 'result' => ['ok' => false, 'msg' => __('Period countdown has not ended; cannot draw yet')]]; + } + [$finalNumber, $drawMode] = self::resolveFinalDrawNumber($record, $manualNumber); + $bets = Db::name('bet_order')->where('period_id', $rid)->select()->toArray(); + $finalLoss = self::estimateLossForNumber($bets, $finalNumber); + $payoutUntil = $now + self::getPayoutGraceSeconds(); + $periodNo = is_string($record['period_no'] ?? null) ? (string) $record['period_no'] : ''; + + Db::name('game_record')->where('id', $rid)->update([ + 'status' => 3, + 'result_number' => $finalNumber, + 'draw_mode' => $drawMode, + 'pending_draw_number' => null, + 'payout_until' => $payoutUntil, + 'update_time' => $now, + ]); + Db::commit(); + + return [ + 'ok' => true, + 'draw_committed' => true, + 'final_number' => $finalNumber, + 'draw_mode' => $drawMode, + 'final_loss' => $finalLoss, + 'payout_until' => $payoutUntil, + 'period_no' => $periodNo, + ]; + } catch (Throwable $e) { + Db::rollback(); + + return [ + 'ok' => false, + 'draw_committed' => false, + 'result' => ['ok' => false, 'msg' => __('Game live: settlement error') . ': ' . $e->getMessage()], + ]; } - } catch (Throwable $e) { - Db::rollback(); - $result = ['ok' => false, 'msg' => __('Game live: settlement error') . ': ' . $e->getMessage()]; + }); + + if (!($txResult['ok'] ?? false)) { + $result = is_array($txResult['result'] ?? null) ? $txResult['result'] : ['ok' => false, 'msg' => __('Game live: settlement error')]; + } elseif (!empty($txResult['draw_committed'])) { + $drawCommitted = true; + $finalNumber = (int) ($txResult['final_number'] ?? 0); + $drawMode = (int) ($txResult['draw_mode'] ?? 0); + $finalLoss = is_string($txResult['final_loss'] ?? null) ? $txResult['final_loss'] : '0.00'; + $payoutUntil = (int) ($txResult['payout_until'] ?? 0); + $periodNo = is_string($txResult['period_no'] ?? null) ? (string) $txResult['period_no'] : ''; + } else { + $existingResult = (int) ($txResult['existing_result'] ?? 0); + $periodNo = is_string($txResult['record']['period_no'] ?? null) ? (string) $txResult['record']['period_no'] : ''; + $postLockWork = static function () use ($rid): void { + GameHotDataCoordinator::afterGameRecordCommitted($rid); + self::publishSnapshot($rid, false); + }; + $result = [ + 'ok' => true, + 'msg' => __('Draw completed; paying out'), + 'result_number' => $existingResult, + 'estimated_loss' => '0.00', + 'payout_until' => (int) ($txResult['record']['payout_until'] ?? 0), + ]; } if ($drawCommitted) { @@ -882,34 +925,33 @@ final class GameLiveService return; } $rid = (int) $record['id']; - if (GameHotDataRedis::isStaleOpenPeriodRecord($record, $periodSeconds)) { - GameHotDataLock::forceRelease(GameHotDataLock::TYPE_GAME_RECORD, (string) $rid); + $st = (int) ($record['status'] ?? 0); + $abnormalAfter = $periodSeconds + self::getPayoutGraceSeconds() + self::STARTUP_RECOVER_GRACE_SECONDS; + if ($elapsed > $abnormalAfter) { + $resultNumber = isset($record['result_number']) ? (int) $record['result_number'] : 0; + if ($resultNumber <= 0) { + GameHotDataLock::forceRelease(GameHotDataLock::TYPE_GAME_RECORD, (string) $rid); + self::markAbnormalAndRefundOnStartup($rid, $st); + } + + return; } + $out = self::drawResult($rid, null); if ($out['ok'] ?? false) { return; } $msg = is_string($out['msg'] ?? null) ? (string) $out['msg'] : ''; - if (!str_contains($msg, 'Another operation is in progress')) { + if ( + !str_contains($msg, 'Another operation is in progress') + && !str_contains($msg, 'Lock wait timeout') + && !str_contains($msg, '1205') + ) { Log::warning('tickAutoDraw: drawResult failed', [ 'record_id' => $rid, 'period_no' => $record['period_no'] ?? '', 'msg' => $msg, ]); - - return; - } - if (!GameHotDataRedis::isStaleOpenPeriodRecord($record, $periodSeconds)) { - return; - } - GameHotDataLock::forceRelease(GameHotDataLock::TYPE_GAME_RECORD, (string) $rid); - $retry = self::drawResult($rid, null); - if (!($retry['ok'] ?? false)) { - Log::warning('tickAutoDraw: drawResult failed after lock force-release', [ - 'record_id' => $rid, - 'period_no' => $record['period_no'] ?? '', - 'msg' => is_string($retry['msg'] ?? null) ? $retry['msg'] : '', - ]); } } @@ -1871,4 +1913,28 @@ final class GameLiveService } return $parsed; } + + /** + * 开奖事务使用较短行锁等待,避免 HTTP/定时任务被 InnoDB 默认 50s 锁等待拖死。 + * + * @template T + * @param callable(): T $fn + * @return T + */ + private static function withShortInnodbLockWait(int $seconds, callable $fn): mixed + { + $seconds = max(1, min(50, $seconds)); + try { + Db::execute('SET SESSION innodb_lock_wait_timeout = ' . $seconds); + } catch (Throwable) { + } + try { + return $fn(); + } finally { + try { + Db::execute('SET SESSION innodb_lock_wait_timeout = 50'); + } catch (Throwable) { + } + } + } } diff --git a/web/src/lang/backend/en/game/live.ts b/web/src/lang/backend/en/game/live.ts index 423e2a8..5e5488d 100644 --- a/web/src/lang/backend/en/game/live.ts +++ b/web/src/lang/backend/en/game/live.ts @@ -18,7 +18,8 @@ export default { push_connected: 'Realtime connection established', push_disconnected: 'Polling mode enabled (push removed)', ws_connected: 'Connected to real-time match', - ws_disconnected: 'Service unavailable, please check backend service', + ws_disconnected: 'Realtime push disconnected, using polling', + snapshot_load_failed: 'Failed to load match snapshot, please retry', ws_panel_title: 'Admin WebSocket (vs. mobile lightweight stream)', ws_reload_config: 'Load WS config', ws_connect: 'Connect WS', diff --git a/web/src/lang/backend/zh-cn/game/live.ts b/web/src/lang/backend/zh-cn/game/live.ts index 333e18d..92003ee 100644 --- a/web/src/lang/backend/zh-cn/game/live.ts +++ b/web/src/lang/backend/zh-cn/game/live.ts @@ -18,7 +18,8 @@ export default { push_connected: '实时连接已建立', push_disconnected: '已切换为轮询模式(无推送)', ws_connected: '已连接实时对局', - ws_disconnected: '服务异常,请检查服务端', + ws_disconnected: '实时推送未连接,已使用轮询刷新', + snapshot_load_failed: '对局快照加载失败,请稍后重试', ws_panel_title: '后台 WebSocket 连接(区别于前端轻量流)', ws_reload_config: '加载WS配置', ws_connect: '连接WS', diff --git a/web/src/views/backend/game/live/index.vue b/web/src/views/backend/game/live/index.vue index c3eb581..ecb7845 100644 --- a/web/src/views/backend/game/live/index.vue +++ b/web/src/views/backend/game/live/index.vue @@ -825,6 +825,7 @@ async function loadSnapshot(options?: { force?: boolean }): Promise { const res = await createAxios({ url: '/admin/game.Live/snapshot', method: 'get', + timeout: 30 * 1000, showCodeMessage: false, showErrorMessage: false, cancelDuplicateRequest: false, @@ -996,7 +997,11 @@ onMounted(async () => { void loadSnapshot({ force: true }) } }, 15000) - await loadSnapshot({ force: true }) + try { + await loadSnapshot({ force: true }) + } catch { + ElMessage.warning(t('game.live.snapshot_load_failed')) + } await reloadWsConfig() connectWs() }) diff --git a/web/vite.config.ts b/web/vite.config.ts index 728553e..4eab6db 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -33,6 +33,7 @@ const viteConfig = ({ mode }: ConfigEnv): UserConfig => { '/admin': { target: 'http://localhost:7979', changeOrigin: true }, '/install': { target: 'http://localhost:7979', changeOrigin: true }, '/plugin': { target: 'http://localhost:7979', changeOrigin: true }, + '/ws': { target: 'http://localhost:3131', changeOrigin: true, ws: true }, }, }, build: {