From 5d33b13c6fa8627cfc045644c582e8eeb6c1e0d9 Mon Sep 17 00:00:00 2001 From: zhenhui <1276357500@qq.com> Date: Tue, 26 May 2026 17:49:32 +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/common/service/GameHotDataLock.php | 17 ++ app/common/service/GameLiveService.php | 269 ++++++++++++++-------- web/src/views/backend/game/live/index.vue | 146 +----------- 3 files changed, 197 insertions(+), 235 deletions(-) diff --git a/app/common/service/GameHotDataLock.php b/app/common/service/GameHotDataLock.php index 266860a..85a3668 100644 --- a/app/common/service/GameHotDataLock.php +++ b/app/common/service/GameHotDataLock.php @@ -68,6 +68,23 @@ final class GameHotDataLock } } + /** + * 管理员强制操作前释放可能残留的互斥锁(仅删 key,不校验 token)。 + */ + public static function forceRelease(string $type, string $resourceKey): void + { + if ($resourceKey === '') { + return; + } + try { + $client = Redis::connection()->client(); + if (is_object($client) && method_exists($client, 'del')) { + $client->del(self::lockKey($type, $resourceKey)); + } + } catch (Throwable) { + } + } + public static function release(string $type, string $resourceKey, ?string $token, bool $redisLock): void { if ($resourceKey === '' || !$redisLock || $token === null || $token === '') { diff --git a/app/common/service/GameLiveService.php b/app/common/service/GameLiveService.php index e15cf2d..9759320 100644 --- a/app/common/service/GameLiveService.php +++ b/app/common/service/GameLiveService.php @@ -572,10 +572,10 @@ final class GameLiveService return ['ok' => false, 'msg' => __('Failed to save scheduled draw number; please try again')]; } GameHotDataCoordinator::afterGameRecordCommitted($rid); - self::publishSnapshot(null); } finally { GameHotDataLock::release(GameHotDataLock::TYPE_GAME_RECORD, (string) $rid, $lock['token'], $lock['redis_lock']); } + self::publishSnapshot(null); return [ 'ok' => true, @@ -610,6 +610,11 @@ final class GameLiveService if (!$lock['acquired']) { return ['ok' => false, 'msg' => __('Another operation is in progress for this period; please try again later')]; } + + /** @var null|callable(): void */ + $notifyAfterLock = null; + $result = ['ok' => false, 'msg' => __('Game live: settlement error')]; + try { self::ensureAiLocked($rid); @@ -620,102 +625,122 @@ final class GameLiveService $payoutUntil = 0; $periodNo = ''; $now = time(); + $drawCommitted = false; Db::startTrans(); try { $record = self::loadRecordRowFromDb($rid, true); if (!$record) { Db::rollback(); - return ['ok' => false, 'msg' => __('No active game in progress')]; + $result = ['ok' => false, 'msg' => __('No active game in progress')]; + } else { + $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'] : ''; + GameHotDataCoordinator::afterGameRecordCommitted($rid); + $notifyAfterLock = static function () use ($rid): void { + self::publishSnapshot($rid); + }; + $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, + ]); + $settleOut = GameBetSettleService::settleBetsForDraw($rid, $finalNumber); + Db::commit(); + $drawCommitted = true; + } + } } - - $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'] : ''; - GameHotDataCoordinator::afterGameRecordCommitted($rid); - self::publishSnapshot($rid); - - return [ - 'ok' => true, - 'msg' => __('Draw completed; paying out'), - 'result_number' => $existingResult, - 'estimated_loss' => '0.00', - 'payout_until' => (int) ($record['payout_until'] ?? 0), - ]; - } - - if (!in_array($st, [0, 1], true)) { - Db::rollback(); - return ['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, '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, - ]); - $settleOut = GameBetSettleService::settleBetsForDraw($rid, $finalNumber); - Db::commit(); } catch (Throwable $e) { Db::rollback(); - return ['ok' => false, 'msg' => __('Game live: settlement error') . ': ' . $e->getMessage()]; + $result = ['ok' => false, 'msg' => __('Game live: settlement error') . ': ' . $e->getMessage()]; } - GameBetSettleService::publishSettlementWinsAfterCommit( - $settleOut, - $rid, - $periodNo, - $finalNumber - ); + if ($drawCommitted) { + GameBetSettleService::publishSettlementWinsAfterCommit( + $settleOut, + $rid, + $periodNo, + $finalNumber + ); - GameHotDataCoordinator::afterGameRecordCommitted($rid); + GameHotDataCoordinator::afterGameRecordCommitted($rid); - try { - GameRecordStatService::refreshForRecordId($rid); - } catch (Throwable) { + try { + GameRecordStatService::refreshForRecordId($rid); + } catch (Throwable) { + } + + $notifyAfterLock = static function () use ( + $rid, + $periodNo, + $finalNumber, + $drawMode, + $payoutUntil, + $now, + $settleOut + ): void { + self::publishPublicPeriodOpened($periodNo, $finalNumber, $drawMode, $now); + self::publishPublicPeriodPayout($rid, $periodNo, $finalNumber, $payoutUntil, $now); + $jackpotHits = is_array($settleOut['jackpot_hits'] ?? null) ? $settleOut['jackpot_hits'] : []; + GameWebSocketEventBus::publish('admin.live.opened', [ + 'period_id' => $rid, + 'period_no' => $periodNo, + 'result_number' => $finalNumber, + 'draw_mode' => $drawMode, + 'payout_until' => $payoutUntil, + 'jackpot_hits' => $jackpotHits, + 'server_time' => $now, + ]); + self::publishSnapshot(null); + }; + + $result = [ + 'ok' => true, + 'msg' => __('Draw completed; paying out'), + 'result_number' => $finalNumber, + 'draw_mode' => $drawMode, + 'estimated_loss' => $finalLoss, + 'payout_until' => $payoutUntil, + ]; } - self::publishPublicPeriodOpened($periodNo, $finalNumber, $drawMode, $now); - self::publishPublicPeriodPayout($rid, $periodNo, $finalNumber, $payoutUntil, $now); - $jackpotHits = is_array($settleOut['jackpot_hits'] ?? null) ? $settleOut['jackpot_hits'] : []; - GameWebSocketEventBus::publish('admin.live.opened', [ - 'period_id' => $rid, - 'period_no' => $periodNo, - 'result_number' => $finalNumber, - 'draw_mode' => $drawMode, - 'payout_until' => $payoutUntil, - 'jackpot_hits' => $jackpotHits, - 'server_time' => $now, - ]); - self::publishSnapshot(null); - - return [ - 'ok' => true, - 'msg' => __('Draw completed; paying out'), - 'result_number' => $finalNumber, - 'draw_mode' => $drawMode, - 'estimated_loss' => $finalLoss, - 'payout_until' => $payoutUntil, - ]; } finally { GameHotDataLock::release(GameHotDataLock::TYPE_GAME_RECORD, (string) $rid, $lock['token'], $lock['redis_lock']); } + + if ($notifyAfterLock !== null) { + $notifyAfterLock(); + } + + return $result; } /** @@ -739,6 +764,10 @@ final class GameLiveService return; } + + /** @var null|callable(): void */ + $notifyAfterLock = null; + try { $periodNo = is_string($row['period_no'] ?? null) ? (string) $row['period_no'] : ''; $resultNumber = filter_var($row['result_number'] ?? 0, FILTER_VALIDATE_INT); @@ -747,6 +776,7 @@ final class GameLiveService } $newPeriodNo = null; $now = time(); + $finalized = false; Db::startTrans(); try { $affected = Db::name('game_record') @@ -759,16 +789,19 @@ final class GameLiveService ]); if ($affected < 1) { Db::rollback(); - - return; + } else { + Db::commit(); + $finalized = true; } - Db::commit(); } catch (Throwable $e) { Db::rollback(); Log::warning('finalizePayoutGrace failed: ' . $e->getMessage(), ['record_id' => $id]); + } + if (!$finalized) { return; } + if (GameRecordService::isAutoCreateEnabled()) { try { $newPeriodNo = GameRecordService::createNextRecordAfterDraw(); @@ -781,27 +814,41 @@ final class GameLiveService } 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(); + + $runtimeEnabled = GameRecordService::isLiveRuntimeEnabled(); + $notifyAfterLock = static function () use ( + $id, + $periodNo, + $resultNumber, + $now, + $runtimeEnabled, + $newPeriodNo + ): void { + self::publishPublicPeriodFinished($id, $periodNo, $resultNumber); + GameWebSocketEventBus::publish('admin.live.finalized', [ + 'period_id' => $id, + 'period_no' => $periodNo, + 'result_number' => $resultNumber, + 'runtime_enabled' => $runtimeEnabled, + 'maintenance_ui' => !$runtimeEnabled && !GameRecordService::hasActiveRecord(), + 'server_time' => $now, + ]); self::publishSnapshot(null); - } elseif (is_string($newPeriodNo ?? null) && $newPeriodNo !== '') { - self::publishImmediateBettingTickAfterFinalize(); - } + if (!$runtimeEnabled) { + self::voidRemainingOpenRoundsOnMaintenanceAfterFinalize(); + GameHotDataRedis::gameRecordRefreshAggregateCaches(); + self::publishSnapshot(null); + } elseif (is_string($newPeriodNo ?? null) && $newPeriodNo !== '') { + self::publishImmediateBettingTickAfterFinalize(); + } + }; } finally { GameHotDataLock::release(GameHotDataLock::TYPE_GAME_RECORD, (string) $id, $lock['token'], $lock['redis_lock']); } + + if ($notifyAfterLock !== null) { + $notifyAfterLock(); + } } public static function tickAutoDraw(): void @@ -919,7 +966,7 @@ final class GameLiveService if (!in_array($st, [0, 1], true)) { return ['ok' => false, 'msg' => __('Current period cannot be voided')]; } - $lock = GameHotDataLock::tryAcquireWithWait(GameHotDataLock::TYPE_GAME_RECORD, (string) $recordId, 3000); + $lock = self::acquireRecordLockForAdminMutation((string) $recordId, 3000); if (!$lock['acquired']) { return ['ok' => false, 'msg' => __('Another operation is in progress for this period; please try again later')]; } @@ -1592,6 +1639,26 @@ final class GameLiveService return $max; } + /** + * 管理员作废等操作:等待锁失败时清除残留锁再试一次,避免 ticker/开奖持锁导致无法作废。 + * + * @return array{acquired: bool, token: ?string, redis_lock: bool} + */ + private static function acquireRecordLockForAdminMutation(string $recordId, int $waitMs): array + { + if ($recordId === '') { + return ['acquired' => false, 'token' => null, 'redis_lock' => false]; + } + $lock = GameHotDataLock::tryAcquireWithWait(GameHotDataLock::TYPE_GAME_RECORD, $recordId, $waitMs); + if ($lock['acquired']) { + return $lock; + } + GameHotDataLock::forceRelease(GameHotDataLock::TYPE_GAME_RECORD, $recordId); + Log::warning('admin record lock force-released before void', ['record_id' => $recordId]); + + return GameHotDataLock::tryAcquireWithWait(GameHotDataLock::TYPE_GAME_RECORD, $recordId, 800); + } + /** * 封盘至本期完全结束前均展示赔付预估(含已开奖/派彩中),供后台实时对局页保留表格数据。 */ diff --git a/web/src/views/backend/game/live/index.vue b/web/src/views/backend/game/live/index.vue index 3f7fc4d..c35ae14 100644 --- a/web/src/views/backend/game/live/index.vue +++ b/web/src/views/backend/game/live/index.vue @@ -37,7 +37,6 @@ > {{ t('game.live.void_btn') }} - {{ t('Refresh') }} @@ -277,12 +276,7 @@ const serverSkewSeconds = ref(0) /** 每秒递增,驱动派彩剩余秒本地刷新 */ const clockTick = ref(0) 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 重复请求取消导致控制台报错 */ let snapshotLoadPromise: Promise | null = null @@ -350,16 +344,23 @@ function handleWsPayload(raw: unknown): void { snapshot.result_number = opened.result_number calcResultNumber.value = opened.result_number } - void loadSnapshot({ force: true }) + if (typeof opened.payout_until === 'number' && opened.payout_until > 0 && snapshot.record) { + snapshot.record.payout_until = opened.payout_until + snapshot.is_payout_phase = true + } return } if (event === 'admin.live.finalized' && parsed.data && typeof parsed.data === 'object') { const fin = parsed.data as anyObj + snapshot.is_payout_phase = false + snapshot.payout_remaining_seconds = 0 if (toBool(fin.maintenance_ui) === true) { - snapshot.is_payout_phase = false - snapshot.payout_remaining_seconds = 0 + snapshot.maintenance_ui = true + snapshot.record = null + } + if (toBool(fin.runtime_enabled) !== null) { + snapshot.runtime_enabled = toBool(fin.runtime_enabled) === true } - void loadSnapshot({ force: true }) return } if (event === 'period.payout' && parsed.data && typeof parsed.data === 'object') { @@ -393,30 +394,7 @@ function handleWsPayload(raw: unknown): void { } if (event === 'period.tick' && parsed.data && typeof parsed.data === 'object') { handlePeriodTickEvent(parsed.data as anyObj) - return } - if (event === 'bet.accepted' && parsed.data && typeof parsed.data === 'object') { - const betData = parsed.data as anyObj - const periodNo = typeof betData.period_no === 'string' ? betData.period_no : '' - const currentNo = typeof snapshot.record?.period_no === 'string' ? snapshot.record.period_no : '' - if (periodNo !== '' && periodNo === currentNo) { - scheduleBetStreamRefresh() - } else if (!wsConnected.value) { - void loadSnapshot({ force: true }) - } - return - } -} - -/** 有新下注时防抖拉取快照,补全 WS 每秒快照之间的下注列表 */ -function scheduleBetStreamRefresh(): void { - if (betStreamRefreshTimer !== null) { - window.clearTimeout(betStreamRefreshTimer) - } - betStreamRefreshTimer = window.setTimeout(() => { - betStreamRefreshTimer = null - void loadSnapshot({ force: true }) - }, 600) } /** 用 period.tick 轻量字段刷新倒计时(不触发 HTTP,避免与 WS 每秒 snapshot 冲突) */ @@ -457,18 +435,9 @@ function handlePeriodTickEvent(periodData: anyObj): void { if (runtimeOff && (status === 'betting' || status === 'locked')) { return } - if (status === 'betting' && periodNo !== '' && periodNo !== currentNo) { - void loadSnapshot({ force: true }) - return - } if (status === 'finished') { snapshot.is_payout_phase = false snapshot.payout_remaining_seconds = 0 - void loadSnapshot({ force: true }) - return - } - if (currentNo === '' && periodNo !== '' && !runtimeOff) { - void loadSnapshot({ force: true }) } } @@ -1015,96 +984,17 @@ const countdownParts = computed(() => { return { bet, draw, payout } }) -/** 派彩倒计时从 >0 变为 0 时主动拉 HTTP 快照 */ -watch(payoutRemainingLive, (remain, prev) => { - if (!snapshot.is_payout_phase || remain !== 0) { - return - } - if (prev !== null && prev !== undefined && prev > 0) { - schedulePayoutEndRefresh(400) - } -}) - -function schedulePayoutEndRefresh(delayMs: number): void { - if (payoutStuckRefreshTimer !== null) { - window.clearTimeout(payoutStuckRefreshTimer) - } - payoutStuckRefreshTimer = window.setTimeout(() => { - payoutStuckRefreshTimer = null - if (!snapshot.is_payout_phase) { - return - } - void loadSnapshot({ force: true }) - }, delayMs) -} - -/** 下注/开奖倒计时均已归零但仍未进入派彩(常见于 live ticker 未跑或开奖锁竞争失败) */ -function isPrePayoutDrawStuck(): boolean { - if (snapshot.is_payout_phase || !snapshot.can_calculate) { - return false - } - const bet = snapshot.bet_remaining_seconds ?? 0 - const draw = snapshot.remaining_seconds ?? 0 - if (bet > 0 || draw > 0) { - return false - } - const st = numberValue(snapshot.record?.status) - 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 - if (drawStuckRefreshTimer !== null) { - window.clearTimeout(drawStuckRefreshTimer) - drawStuckRefreshTimer = null - } - return - } - drawStuckSeconds++ - if (drawStuckSeconds < 8 || drawStuckRefreshTimer !== null) { - return - } - drawStuckRefreshTimer = window.setTimeout(() => { - drawStuckRefreshTimer = null - drawStuckSeconds = 0 - if (isPrePayoutDrawStuck()) { - void loadSnapshot({ force: true }) - } - }, 300) -} - onMounted(async () => { updateIsMobile() window.addEventListener('resize', updateIsMobile) clockTimer = window.setInterval(() => { clockTick.value++ - tickPrePayoutDrawStuckRecovery() - tickPayoutPhaseStuckRecovery() }, 1000) fallbackPollTimer = window.setInterval(() => { if (!wsConnected.value) { void loadSnapshot({ force: true }) } - }, 3000) + }, 15000) await loadSnapshot({ force: true }) await reloadWsConfig() connectWs() @@ -1121,18 +1011,6 @@ onUnmounted(() => { window.clearInterval(fallbackPollTimer) fallbackPollTimer = null } - if (payoutStuckRefreshTimer !== null) { - window.clearTimeout(payoutStuckRefreshTimer) - payoutStuckRefreshTimer = null - } - if (drawStuckRefreshTimer !== null) { - window.clearTimeout(drawStuckRefreshTimer) - drawStuckRefreshTimer = null - } - if (betStreamRefreshTimer !== null) { - window.clearTimeout(betStreamRefreshTimer) - betStreamRefreshTimer = null - } })