diff --git a/app/admin/controller/game/Live.php b/app/admin/controller/game/Live.php index 1e14114..f5ab7cf 100644 --- a/app/admin/controller/game/Live.php +++ b/app/admin/controller/game/Live.php @@ -52,6 +52,7 @@ class Live extends Backend $topics = [ 'admin.live.snapshot', 'admin.live.opened', + 'admin.live.finalized', 'jackpot.hit', 'period.tick', 'period.locked', diff --git a/app/common/service/GameLiveService.php b/app/common/service/GameLiveService.php index 1919dca..e15cf2d 100644 --- a/app/common/service/GameLiveService.php +++ b/app/common/service/GameLiveService.php @@ -252,12 +252,16 @@ final class GameLiveService $now = time(); Db::startTrans(); try { - Db::name('game_record')->where('id', $recordId)->where('status', 3)->update([ + $affected = Db::name('game_record')->where('id', $recordId)->where('status', 3)->update([ 'status' => 4, 'payout_until' => null, 'update_time' => $now, ]); - $newPeriodNo = GameRecordService::createNextRecordAfterDraw(); + if ($affected < 1) { + Db::rollback(); + + return; + } Db::commit(); } catch (Throwable $e) { Db::rollback(); @@ -267,6 +271,17 @@ final class GameLiveService ]); return; } + $newPeriodNo = null; + if (GameRecordService::isAutoCreateEnabled()) { + try { + $newPeriodNo = GameRecordService::createNextRecordAfterDraw(); + } catch (Throwable $e) { + Log::warning('game live startup create next record failed', [ + 'record_id' => $recordId, + 'error' => $e->getMessage(), + ]); + } + } GameHotDataCoordinator::afterGameRecordCommitted($recordId); try { GameRecordStatService::refreshForRecordId($recordId); @@ -283,6 +298,9 @@ final class GameLiveService public static function buildSnapshot(?int $recordId = null): array { + // HTTP/WS 拉快照时也尝试结单,避免仅 gameLiveTicker 未跑时派彩倒计时归零后长期卡住 + self::finalizePayoutGrace(); + $record = self::resolveRecord($recordId); if ($record) { $periodSeconds = self::getConfigInt(self::KEY_PERIOD_SECONDS, 30); @@ -717,6 +735,8 @@ final class GameLiveService $id = (int) $row['id']; $lock = GameHotDataLock::tryAcquireWithWait(GameHotDataLock::TYPE_GAME_RECORD, (string) $id, 2000); if (!$lock['acquired']) { + Log::warning('finalizePayoutGrace: lock not acquired', ['record_id' => $id]); + return; } try { @@ -726,14 +746,22 @@ final class GameLiveService $resultNumber = null; } $newPeriodNo = null; + $now = time(); Db::startTrans(); try { - Db::name('game_record')->where('id', $id)->update([ - 'status' => 4, - 'payout_until' => null, - 'update_time' => time(), - ]); - $newPeriodNo = GameRecordService::createNextRecordAfterDraw(); + $affected = Db::name('game_record') + ->where('id', $id) + ->where('status', 3) + ->update([ + 'status' => 4, + 'payout_until' => null, + 'update_time' => $now, + ]); + if ($affected < 1) { + Db::rollback(); + + return; + } Db::commit(); } catch (Throwable $e) { Db::rollback(); @@ -741,13 +769,33 @@ final class GameLiveService return; } + if (GameRecordService::isAutoCreateEnabled()) { + try { + $newPeriodNo = GameRecordService::createNextRecordAfterDraw(); + } catch (Throwable $e) { + Log::warning('finalizePayoutGrace: create next record failed', [ + 'record_id' => $id, + 'error' => $e->getMessage(), + ]); + } + } GameHotDataCoordinator::afterGameRecordCommitted($id); GameRecordStatService::refreshForRecordId($id); self::publishPublicPeriodFinished($id, $periodNo, $resultNumber); + GameWebSocketEventBus::publish('admin.live.finalized', [ + 'period_id' => $id, + 'period_no' => $periodNo, + 'result_number' => $resultNumber, + 'runtime_enabled' => GameRecordService::isLiveRuntimeEnabled(), + 'maintenance_ui' => !GameRecordService::isLiveRuntimeEnabled() + && !GameRecordService::hasActiveRecord(), + 'server_time' => $now, + ]); self::publishSnapshot(null); if (!GameRecordService::isLiveRuntimeEnabled()) { self::voidRemainingOpenRoundsOnMaintenanceAfterFinalize(); GameHotDataRedis::gameRecordRefreshAggregateCaches(); + self::publishSnapshot(null); } elseif (is_string($newPeriodNo ?? null) && $newPeriodNo !== '') { self::publishImmediateBettingTickAfterFinalize(); } diff --git a/web/src/views/backend/game/live/index.vue b/web/src/views/backend/game/live/index.vue index 6b24095..3f7fc4d 100644 --- a/web/src/views/backend/game/live/index.vue +++ b/web/src/views/backend/game/live/index.vue @@ -280,6 +280,7 @@ let clockTimer: number | null = null let payoutStuckRefreshTimer: number | null = null let drawStuckRefreshTimer: number | null = null let drawStuckSeconds = 0 +let payoutPhaseStuckSeconds = 0 let fallbackPollTimer: number | null = null let betStreamRefreshTimer: number | null = null /** 合并并发 snapshot 请求,避免 axios 重复请求取消导致控制台报错 */ @@ -352,6 +353,25 @@ function handleWsPayload(raw: unknown): void { void loadSnapshot({ force: true }) return } + if (event === 'admin.live.finalized' && parsed.data && typeof parsed.data === 'object') { + const fin = parsed.data as anyObj + if (toBool(fin.maintenance_ui) === true) { + snapshot.is_payout_phase = false + snapshot.payout_remaining_seconds = 0 + } + void loadSnapshot({ force: true }) + return + } + if (event === 'period.payout' && parsed.data && typeof parsed.data === 'object') { + mergePeriodPayoutTick(parsed.data as anyObj) + const payoutData = parsed.data as anyObj + if (typeof payoutData.result_number === 'number') { + snapshot.result_number = payoutData.result_number + calcResultNumber.value = payoutData.result_number + } + snapshot.is_payout_phase = true + return + } if (event === 'bet.win' && parsed.data && typeof parsed.data === 'object') { const winData = parsed.data as anyObj if (winData.is_jackpot === true) { @@ -419,7 +439,11 @@ function mergePeriodPayoutTick(data: anyObj): void { const remain = numberValue(data.payout_remaining_seconds) if (remain !== null) { snapshot.payout_remaining_seconds = Math.max(0, remain) - snapshot.is_payout_phase = remain > 0 || snapshot.is_payout_phase === true + snapshot.is_payout_phase = remain > 0 + } + const until = readPayoutUntilUnix(data) + if (until !== null && snapshot.record && typeof snapshot.record === 'object') { + snapshot.record.payout_until = until } } @@ -437,10 +461,10 @@ function handlePeriodTickEvent(periodData: anyObj): void { void loadSnapshot({ force: true }) return } - if (status === 'finished' && snapshot.is_payout_phase) { - if (!wsConnected.value) { - void loadSnapshot() - } + if (status === 'finished') { + snapshot.is_payout_phase = false + snapshot.payout_remaining_seconds = 0 + void loadSnapshot({ force: true }) return } if (currentNo === '' && periodNo !== '' && !runtimeOff) { @@ -709,6 +733,11 @@ function mergeLiveSnapshot(data: anyObj): void { const serverMaintenance = data.maintenance_ui === true if (runtimeOff && serverMaintenance) { snapshot.record = null + snapshot.is_payout_phase = false + snapshot.payout_remaining_seconds = 0 + snapshot.can_calculate = false + snapshot.can_draw = false + snapshot.can_schedule_draw = false } else { snapshot.record = data.record } @@ -1023,6 +1052,24 @@ function isPrePayoutDrawStuck(): boolean { return st === 0 || st === 1 } +/** 派彩倒计时已为 0 但 is_payout_phase 仍为 true(关服排水时常见) */ +function tickPayoutPhaseStuckRecovery(): void { + if (!snapshot.is_payout_phase) { + payoutPhaseStuckSeconds = 0 + return + } + const remain = payoutRemainingLive.value + if (remain === null || remain > 0) { + payoutPhaseStuckSeconds = 0 + return + } + payoutPhaseStuckSeconds++ + if (payoutPhaseStuckSeconds >= 4) { + payoutPhaseStuckSeconds = 0 + void loadSnapshot({ force: true }) + } +} + function tickPrePayoutDrawStuckRecovery(): void { if (!isPrePayoutDrawStuck()) { drawStuckSeconds = 0 @@ -1051,6 +1098,7 @@ onMounted(async () => { clockTimer = window.setInterval(() => { clockTick.value++ tickPrePayoutDrawStuckRecovery() + tickPayoutPhaseStuckRecovery() }, 1000) fallbackPollTimer = window.setInterval(() => { if (!wsConnected.value) {