From 66c002f522c677ce112ed7fb2325e43dfe82fb04 Mon Sep 17 00:00:00 2001 From: zhenhui <1276357500@qq.com> Date: Tue, 26 May 2026 16:10:06 +0800 Subject: [PATCH] =?UTF-8?q?1.=E4=BF=AE=E5=A4=8D=E5=85=B3=E9=97=AD=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=88=9B=E5=BB=BA=E4=B8=8B=E4=B8=80=E5=B1=80=E5=90=8E?= =?UTF-8?q?=E8=BF=98=E8=87=AA=E5=8A=A8=E5=88=9B=E5=BB=BA=E4=B8=8B=E4=B8=80?= =?UTF-8?q?=E6=9C=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin/controller/game/Live.php | 2 + app/api/controller/Game.php | 25 +++- app/common/service/GameLiveService.php | 194 +++++++++++++++++++------ 3 files changed, 171 insertions(+), 50 deletions(-) diff --git a/app/admin/controller/game/Live.php b/app/admin/controller/game/Live.php index dac1736..1e14114 100644 --- a/app/admin/controller/game/Live.php +++ b/app/admin/controller/game/Live.php @@ -144,6 +144,8 @@ class Live extends Backend GameRecordService::setAutoCreateEnabled($enabled); if ($enabled) { GameRecordService::bootstrapPeriodWhenRuntimeEnabled(); + } else { + GameLiveService::voidOrphanActiveRoundsOnRuntimeDisabled(); } return $this->success('', GameLiveService::buildSnapshot(null)); } diff --git a/app/api/controller/Game.php b/app/api/controller/Game.php index 2c400be..f5dd94c 100644 --- a/app/api/controller/Game.php +++ b/app/api/controller/Game.php @@ -30,7 +30,7 @@ class Game extends MobileBase return $response; } - $periodRow = GameHotDataRedis::gameRecordLatest(); + $periodRow = $this->resolveMobilePeriodRow(); $now = time(); $startAt = $periodRow ? $this->intValue($periodRow['period_start_at'] ?? 0) : $now; $lockAt = $startAt + 20; @@ -57,6 +57,7 @@ class Game extends MobileBase 'server_time' => $now, 'runtime_enabled' => GameRecordService::getConfigBool(GameRecordService::KEY_AUTO_CREATE), 'period' => [ + 'period_id' => $periodRow ? $this->intValue($periodRow['id'] ?? 0) : 0, 'period_no' => (string) ($periodRow['period_no'] ?? ''), 'status' => $this->mapPeriodStatus($periodRow['status'] ?? null), 'countdown' => $countdown, @@ -110,7 +111,7 @@ class Game extends MobileBase if ($response !== null) { return $response; } - $periodRow = GameHotDataRedis::gameRecordLatest(); + $periodRow = $this->resolveMobilePeriodRow(); if (!$periodRow) { return $this->mobileError(2002, 'Game period does not exist'); } @@ -118,7 +119,7 @@ class Game extends MobileBase $startAt = $this->intValue($periodRow['period_start_at'] ?? 0); return $this->mobileSuccess([ 'runtime_enabled' => GameRecordService::getConfigBool(GameRecordService::KEY_AUTO_CREATE), - 'period_id' => $periodRow['id'], + 'period_id' => $this->intValue($periodRow['id'] ?? 0), 'period_no' => $periodRow['period_no'], 'status' => $this->mapPeriodStatus($periodRow['status'] ?? null), 'countdown' => max(0, ($startAt + 30) - $now), @@ -193,6 +194,14 @@ class Game extends MobileBase if ($this->intValue($period->status) !== 0) { return $this->mobileError(3002, 'Betting is closed'); } + $activeRow = GameHotDataRedis::gameRecordActive(); + if ($activeRow !== null) { + $activeNo = trim((string) ($activeRow['period_no'] ?? '')); + $activeId = $this->intValue($activeRow['id'] ?? 0); + if ($activeNo !== '' && ($periodNo !== $activeNo || $this->intValue($period->id) !== $activeId)) { + return $this->mobileError(3004, 'Not the current period; please refresh period_no'); + } + } $userIdRaw = $this->auth->id ?? null; $userId = filter_var($userIdRaw, FILTER_VALIDATE_INT); @@ -529,6 +538,16 @@ class Game extends MobileBase return (string) $value; } + /** + * 移动端展示的当前期:优先进行中局(id 最大且 status∈0..3),避免 gameRecordLatest 指向已结束新局而客户端仍用旧 period_no 下注。 + * + * @return array|null + */ + private function resolveMobilePeriodRow(): ?array + { + return GameHotDataRedis::gameRecordActive() ?? GameHotDataRedis::gameRecordLatest(); + } + private function intValue($value): int { $result = filter_var($value, FILTER_VALIDATE_INT); diff --git a/app/common/service/GameLiveService.php b/app/common/service/GameLiveService.php index 8fe4072..fdae894 100644 --- a/app/common/service/GameLiveService.php +++ b/app/common/service/GameLiveService.php @@ -244,7 +244,7 @@ final class GameLiveService 'payout_until' => null, 'update_time' => $now, ]); - GameRecordService::createNextRecordAfterDraw(); + $newPeriodNo = GameRecordService::createNextRecordAfterDraw(); Db::commit(); } catch (Throwable $e) { Db::rollback(); @@ -259,6 +259,11 @@ final class GameLiveService GameRecordStatService::refreshForRecordId($recordId); } catch (Throwable) { } + if (!GameRecordService::isLiveRuntimeEnabled()) { + self::voidRemainingOpenRoundsOnMaintenanceAfterFinalize(); + } elseif (is_string($newPeriodNo ?? null) && $newPeriodNo !== '') { + self::publishImmediateBettingTickAfterFinalize(); + } self::publishSnapshot(null); } @@ -689,6 +694,7 @@ final class GameLiveService if ($resultNumber === false || $resultNumber < 1) { $resultNumber = null; } + $newPeriodNo = null; Db::startTrans(); try { Db::name('game_record')->where('id', $id)->update([ @@ -696,9 +702,7 @@ final class GameLiveService 'payout_until' => null, 'update_time' => time(), ]); - if (GameRecordService::isLiveRuntimeEnabled()) { - GameRecordService::createNextRecordRowIfNoActive(); - } + $newPeriodNo = GameRecordService::createNextRecordAfterDraw(); Db::commit(); } catch (Throwable $e) { Db::rollback(); @@ -710,7 +714,11 @@ final class GameLiveService GameRecordStatService::refreshForRecordId($id); self::publishPublicPeriodFinished($id, $periodNo, $resultNumber); self::publishSnapshot(null); - self::publishImmediateBettingTickAfterFinalize(); + if (!GameRecordService::isLiveRuntimeEnabled()) { + self::voidRemainingOpenRoundsOnMaintenanceAfterFinalize(); + } elseif (is_string($newPeriodNo ?? null) && $newPeriodNo !== '') { + self::publishImmediateBettingTickAfterFinalize(); + } } finally { GameHotDataLock::release(GameHotDataLock::TYPE_GAME_RECORD, (string) $id, $lock['token'], $lock['redis_lock']); } @@ -737,6 +745,126 @@ final class GameLiveService self::drawResult((int) $record['id'], null); } + /** + * 关闭「自动创建下一局」时:作废除当前 draining 局外的其它下注/封盘期(历史重复开局遗留),避免被 tickAutoDraw 当成新一局继续跑。 + */ + public static function voidOrphanActiveRoundsOnRuntimeDisabled(): void + { + if (GameRecordService::isLiveRuntimeEnabled()) { + return; + } + $canonical = GameHotDataRedis::gameRecordActive(); + if (!$canonical) { + return; + } + $keepId = filter_var($canonical['id'] ?? 0, FILTER_VALIDATE_INT); + if ($keepId === false || $keepId <= 0) { + return; + } + $reason = (string) __('Orphan period closed: auto-create next round is disabled'); + $rows = Db::name('game_record') + ->whereIn('status', [0, 1]) + ->where('id', '<>', $keepId) + ->order('id', 'asc') + ->select() + ->toArray(); + foreach ($rows as $row) { + $rid = filter_var($row['id'] ?? 0, FILTER_VALIDATE_INT); + if ($rid === false || $rid <= 0) { + continue; + } + self::voidOpenPeriodInternal($rid, $reason); + } + GameHotDataRedis::gameRecordRefreshAggregateCaches(); + self::publishSnapshot(null); + } + + /** + * 维护模式:本期派彩结单后,清理仍停留在下注/封盘的遗留对局(不插入新期)。 + */ + public static function voidRemainingOpenRoundsOnMaintenanceAfterFinalize(): void + { + if (GameRecordService::isLiveRuntimeEnabled()) { + return; + } + $reason = (string) __('Open period closed after payout: game is in maintenance'); + $rows = Db::name('game_record') + ->whereIn('status', [0, 1]) + ->order('id', 'asc') + ->select() + ->toArray(); + foreach ($rows as $row) { + $rid = filter_var($row['id'] ?? 0, FILTER_VALIDATE_INT); + if ($rid === false || $rid <= 0) { + continue; + } + self::voidOpenPeriodInternal($rid, $reason); + } + GameHotDataRedis::gameRecordRefreshAggregateCaches(); + } + + /** + * 作废单期(仅 status 0/1),不修改自动开局开关。 + * + * @return array{ok: bool, msg?: string} + */ + private static function voidOpenPeriodInternal(int $recordId, string $voidReason): array + { + if ($recordId <= 0) { + return ['ok' => false, 'msg' => __('Parameter error')]; + } + $reason = trim($voidReason); + if ($reason === '') { + return ['ok' => false, 'msg' => __('Void reason is required')]; + } + $record = Db::name('game_record')->where('id', $recordId)->find(); + if (!$record) { + return ['ok' => false, 'msg' => __('No active game in progress')]; + } + $st = (int) ($record['status'] ?? -1); + 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); + if (!$lock['acquired']) { + return ['ok' => false, 'msg' => __('Another operation is in progress for this period; please try again later')]; + } + $refundedUserIds = []; + try { + $now = time(); + Db::startTrans(); + try { + $refund = self::refundPendingBetsSummaryForPeriodLocked($recordId, $now); + $refundedUserIds = $refund['user_ids']; + Db::name('game_record')->where('id', $recordId)->update([ + 'status' => 5, + 'void_reason' => $reason, + 'pending_draw_number' => null, + 'payout_until' => null, + 'ai_locked_number' => null, + 'update_time' => $now, + ]); + Db::commit(); + } catch (Throwable $e) { + Db::rollback(); + return ['ok' => false, 'msg' => __('Void failed') . ': ' . $e->getMessage()]; + } + GameHotDataCoordinator::afterGameRecordCommitted($recordId); + foreach ($refundedUserIds as $uid) { + if ($uid > 0) { + GameHotDataCoordinator::afterUserCommitted($uid); + } + } + return [ + 'ok' => true, + 'msg' => __('Period voided'), + 'refund' => $refund, + ]; + } finally { + GameHotDataLock::release(GameHotDataLock::TYPE_GAME_RECORD, (string) $recordId, $lock['token'], $lock['redis_lock']); + } + } + /** * 作废当前期(仅 status 为下注/封盘):待开奖注单退款,本期置为已作废,并关闭运行开关。 * @@ -768,50 +896,22 @@ final class GameLiveService return ['ok' => false, 'msg' => __('Current period cannot be voided')]; } $rid = (int) $record['id']; - $lock = GameHotDataLock::tryAcquireWithWait(GameHotDataLock::TYPE_GAME_RECORD, (string) $rid, 3000); - if (!$lock['acquired']) { - return ['ok' => false, 'msg' => __('Another operation is in progress for this period; please try again later')]; + $internal = self::voidOpenPeriodInternal($rid, $reason); + if (!($internal['ok'] ?? false)) { + $errMsg = $internal['msg'] ?? null; + return ['ok' => false, 'msg' => is_string($errMsg) ? $errMsg : __('Void failed')]; } - $refundedUserIds = []; - try { - $now = time(); - $refund = ['user_ids' => [], 'order_count' => 0, 'total_amount' => '0.00', 'order_ids' => []]; - Db::startTrans(); - try { - $refund = self::refundPendingBetsSummaryForPeriodLocked($rid, $now); - $refundedUserIds = $refund['user_ids']; - Db::name('game_record')->where('id', $rid)->update([ - 'status' => 5, - 'void_reason' => $reason, - 'pending_draw_number' => null, - 'payout_until' => null, - 'ai_locked_number' => null, - 'update_time' => $now, - ]); - Db::commit(); - } catch (Throwable $e) { - Db::rollback(); - return ['ok' => false, 'msg' => __('Void failed') . ': ' . $e->getMessage()]; - } - GameRecordService::setAutoCreateEnabled(false); - GameHotDataCoordinator::afterGameRecordCommitted($rid); - GameHotDataCoordinator::afterGameConfigKeyCommitted(GameRecordService::KEY_AUTO_CREATE); - foreach ($refundedUserIds as $uid) { - if ($uid > 0) { - GameHotDataCoordinator::afterUserCommitted($uid); - } - } - self::publishSnapshot(null); + GameRecordService::setAutoCreateEnabled(false); + GameHotDataCoordinator::afterGameConfigKeyCommitted(GameRecordService::KEY_AUTO_CREATE); + self::publishSnapshot(null); + $refund = $internal['refund'] ?? null; - return [ - 'ok' => true, - 'msg' => __('Period voided'), - 'record' => self::reloadRecord($rid), - 'refund' => $refund, - ]; - } finally { - GameHotDataLock::release(GameHotDataLock::TYPE_GAME_RECORD, (string) $rid, $lock['token'], $lock['redis_lock']); - } + return [ + 'ok' => true, + 'msg' => __('Period voided'), + 'record' => self::reloadRecord($rid), + 'refund' => is_array($refund) ? $refund : null, + ]; } /**