From bdd66f7bd9c179cd8f2c3e89cf942e00e70df2e9 Mon Sep 17 00:00:00 2001 From: zhenhui <1276357500@qq.com> Date: Tue, 26 May 2026 18:08:57 +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/GameBetSettleService.php | 22 ++ app/common/service/GameLiveService.php | 299 ++++++++++++++------ app/process/GameWebSocketServer.php | 1 - web/src/views/backend/game/live/index.vue | 2 +- 4 files changed, 240 insertions(+), 84 deletions(-) diff --git a/app/common/service/GameBetSettleService.php b/app/common/service/GameBetSettleService.php index 24b48d2..47a5f24 100644 --- a/app/common/service/GameBetSettleService.php +++ b/app/common/service/GameBetSettleService.php @@ -54,6 +54,20 @@ final class GameBetSettleService ]; } + $periodStatus = filter_var( + Db::name('game_record')->where('id', $recordId)->value('status'), + FILTER_VALIDATE_INT + ); + if ($periodStatus === 5) { + return [ + 'jackpot_hits' => [], + 'bet_wins' => [], + 'user_streak_events' => [], + 'wallet_events' => [], + 'settled_order_count' => 0, + ]; + } + $now = time(); $jackpotMaxAmount = self::jackpotMaxAmount(); $bets = Db::name('bet_order') @@ -84,6 +98,14 @@ final class GameBetSettleService $settledOrderCount = 0; foreach ($bets as $bet) { + $periodStatusNow = filter_var( + Db::name('game_record')->where('id', $recordId)->value('status'), + FILTER_VALIDATE_INT + ); + if ($periodStatusNow === 5) { + break; + } + $betId = (int) ($bet['id'] ?? 0); if ($betId <= 0) { continue; diff --git a/app/common/service/GameLiveService.php b/app/common/service/GameLiveService.php index fcb8481..f3df0cf 100644 --- a/app/common/service/GameLiveService.php +++ b/app/common/service/GameLiveService.php @@ -15,6 +15,9 @@ use Throwable; final class GameLiveService { + /** @var array 防止同进程内 recover→draw 重入导致 Redis 自锁 */ + private static array $drawingRecordIds = []; + private const CHANNEL = 'game-live'; private const EVENT = 'bet-updated'; @@ -612,15 +615,20 @@ final class GameLiveService } $rid = (int) $record['id']; - $lock = GameHotDataLock::tryAcquireWithWait(GameHotDataLock::TYPE_GAME_RECORD, (string) $rid, 2000); + if (isset(self::$drawingRecordIds[$rid])) { + return ['ok' => false, 'msg' => __('Another operation is in progress for this period; please try again later')]; + } + + $lock = GameHotDataLock::tryAcquireWithWait(GameHotDataLock::TYPE_GAME_RECORD, (string) $rid, 500); 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; + $postLockWork = null; $result = ['ok' => false, 'msg' => __('Game live: settlement error')]; + self::$drawingRecordIds[$rid] = true; try { self::ensureAiLocked($rid); @@ -645,7 +653,7 @@ final class GameLiveService 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'] : ''; - $notifyAfterLock = static function () use ($rid): void { + $postLockWork = static function () use ($rid): void { GameHotDataCoordinator::afterGameRecordCommitted($rid); self::publishSnapshot($rid, false); }; @@ -679,7 +687,6 @@ final class GameLiveService 'payout_until' => $payoutUntil, 'update_time' => $now, ]); - $settleOut = GameBetSettleService::settleBetsForDraw($rid, $finalNumber); Db::commit(); $drawCommitted = true; } @@ -691,15 +698,24 @@ final class GameLiveService } if ($drawCommitted) { - $notifyAfterLock = static function () use ( + $postLockWork = static function () use ( $rid, $periodNo, $finalNumber, $drawMode, $payoutUntil, - $now, - $settleOut + $now ): void { + try { + $settleOut = GameBetSettleService::settleBetsForDraw($rid, $finalNumber); + } catch (Throwable $e) { + Log::warning('drawResult settle after lock failed', [ + 'record_id' => $rid, + 'error' => $e->getMessage(), + ]); + + return; + } GameBetSettleService::publishSettlementWinsAfterCommit( $settleOut, $rid, @@ -736,11 +752,12 @@ final class GameLiveService ]; } } finally { + unset(self::$drawingRecordIds[$rid]); GameHotDataLock::release(GameHotDataLock::TYPE_GAME_RECORD, (string) $rid, $lock['token'], $lock['redis_lock']); } - if ($notifyAfterLock !== null) { - $notifyAfterLock(); + if ($postLockWork !== null) { + $postLockWork(); } return $result; @@ -865,12 +882,33 @@ final class GameLiveService return; } $rid = (int) $record['id']; + if (GameHotDataRedis::isStaleOpenPeriodRecord($record, $periodSeconds)) { + GameHotDataLock::forceRelease(GameHotDataLock::TYPE_GAME_RECORD, (string) $rid); + } $out = self::drawResult($rid, null); - if (!($out['ok'] ?? false)) { + if ($out['ok'] ?? false) { + return; + } + $msg = is_string($out['msg'] ?? null) ? (string) $out['msg'] : ''; + if (!str_contains($msg, 'Another operation is in progress')) { Log::warning('tickAutoDraw: drawResult failed', [ 'record_id' => $rid, 'period_no' => $record['period_no'] ?? '', - 'msg' => $out['msg'] ?? '', + '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'] : '', ]); } } @@ -965,10 +1003,10 @@ final class GameLiveService return ['ok' => false, 'msg' => __('No active game in progress')]; } $st = (int) ($record['status'] ?? -1); - if (!in_array($st, [0, 1], true)) { + if (!in_array($st, [0, 1, 3], true)) { return ['ok' => false, 'msg' => __('Current period cannot be voided')]; } - $lock = self::acquireRecordLockForAdminMutation((string) $recordId, 1500); + $lock = self::acquireRecordLockForAdminMutation((string) $recordId, 8000); if (!$lock['acquired']) { return ['ok' => false, 'msg' => __('Another operation is in progress for this period; please try again later')]; } @@ -976,23 +1014,16 @@ final class GameLiveService $refund = ['user_ids' => [], 'order_count' => 0, 'total_amount' => '0.00', 'order_ids' => []]; 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()]; + $marked = self::markPeriodVoidedInDb($recordId, $reason, $now); + if (!($marked['ok'] ?? false)) { + $errMsg = $marked['msg'] ?? null; + + return ['ok' => false, 'msg' => is_string($errMsg) ? $errMsg : __('Void failed')]; } + $refund = self::refundPendingBetsSummaryForPeriod($recordId, $now); + $refundedUserIds = $refund['user_ids']; + } catch (Throwable $e) { + return ['ok' => false, 'msg' => __('Void failed') . ': ' . $e->getMessage()]; } finally { GameHotDataLock::release(GameHotDataLock::TYPE_GAME_RECORD, (string) $recordId, $lock['token'], $lock['redis_lock']); } @@ -1037,7 +1068,7 @@ final class GameLiveService return ['ok' => false, 'msg' => __('No active game in progress')]; } $st = (int) $record['status']; - if (!in_array($st, [0, 1], true)) { + if (!in_array($st, [0, 1, 3], true)) { return ['ok' => false, 'msg' => __('Current period cannot be voided')]; } $rid = (int) $record['id']; @@ -1081,10 +1112,71 @@ final class GameLiveService return $summary['user_ids']; } + /** + * 先将本期标记为作废(短事务、尽快释放 game_record 行锁),再逐笔退款。 + * + * @return array{ok: bool, msg?: string} + */ + private static function markPeriodVoidedInDb(int $recordId, string $reason, int $now): array + { + $attempts = 0; + while ($attempts < 4) { + $attempts++; + Db::startTrans(); + try { + $row = self::loadRecordRowFromDb($recordId, true); + if (!$row) { + Db::rollback(); + + return ['ok' => false, 'msg' => __('No active game in progress')]; + } + $st = (int) ($row['status'] ?? -1); + if (!in_array($st, [0, 1, 3], true)) { + Db::rollback(); + + return ['ok' => false, 'msg' => __('Current period cannot be voided')]; + } + Db::name('game_record')->where('id', $recordId)->update([ + 'status' => 5, + 'void_reason' => $reason, + 'pending_draw_number' => null, + 'payout_until' => null, + 'ai_locked_number' => $st === 3 ? ($row['ai_locked_number'] ?? null) : null, + 'update_time' => $now, + ]); + Db::commit(); + + return ['ok' => true]; + } catch (Throwable $e) { + Db::rollback(); + $msg = $e->getMessage(); + if ($attempts < 4 && str_contains($msg, '1205')) { + usleep(200_000); + + continue; + } + + return ['ok' => false, 'msg' => __('Void failed') . ': ' . $msg]; + } + } + + return ['ok' => false, 'msg' => __('Void failed') . ': lock wait timeout']; + } + /** * @return array{user_ids:list,order_count:int,total_amount:string,order_ids:list} */ private static function refundPendingBetsSummaryForPeriodLocked(int $periodId, int $now): array + { + return self::refundPendingBetsSummaryForPeriod($periodId, $now); + } + + /** + * 逐笔退款(每笔独立短事务),避免与开奖结算共用一个长事务抢 InnoDB 行锁。 + * + * @return array{user_ids:list,order_count:int,total_amount:string,order_ids:list} + */ + private static function refundPendingBetsSummaryForPeriod(int $periodId, int $now): array { $userIdSet = []; $orderCount = 0; @@ -1097,59 +1189,16 @@ final class GameLiveService ->select() ->toArray(); foreach ($bets as $bet) { - $betId = (int) ($bet['id'] ?? 0); - $userId = (int) ($bet['user_id'] ?? 0); - $totalRaw = $bet['total_amount'] ?? '0'; - $total = is_string($totalRaw) ? $totalRaw : (string) $totalRaw; - if ($betId <= 0) { + $single = self::refundSinglePendingBet($bet, $now); + if ($single === null) { continue; } - if ($userId <= 0 || bccomp($total, '0', 2) <= 0) { - Db::name('bet_order')->where('id', $betId)->where('status', 1)->update([ - 'status' => 3, - 'update_time' => $now, - ]); - $orderCount++; - $orderIds[] = $betId; - continue; + if ($single['user_id'] > 0) { + $userIdSet[$single['user_id']] = true; } - $before = (string) (Db::name('user')->where('id', $userId)->value('coin') ?? '0'); - $after = bcadd($before, $total, 2); - $u = Db::name('user')->where('id', $userId)->where('coin', $before)->update([ - 'coin' => $after, - 'update_time' => $now, - ]); - if ($u !== 1) { - throw new \RuntimeException((string) __('Concurrent balance update; please retry')); - } - $bo = Db::name('bet_order')->where('id', $betId)->where('status', 1)->update([ - 'status' => 3, - 'update_time' => $now, - ]); - if ($bo !== 1) { - throw new \RuntimeException((string) __('Bet order state changed; please retry')); - } - $channelIdRaw = $bet['channel_id'] ?? null; - $channelId = filter_var($channelIdRaw, FILTER_VALIDATE_INT); - if ($channelId === false) { - $channelId = null; - } - UserWalletRecord::create([ - 'user_id' => $userId, - 'channel_id' => $channelId, - 'biz_type' => 'void_refund', - 'direction' => 1, - 'amount' => $total, - 'balance_before' => $before, - 'balance_after' => $after, - 'ref_type' => 'bet_order', - 'remark' => (string) __('Period void refund'), - 'create_time' => $now, - ]); - $userIdSet[$userId] = true; $orderCount++; - $totalAmount = bcadd($totalAmount, $total, 2); - $orderIds[] = $betId; + $totalAmount = bcadd($totalAmount, $single['amount'], 2); + $orderIds[] = $single['bet_id']; } $out = []; @@ -1165,9 +1214,96 @@ final class GameLiveService ]; } + /** + * @param array $bet + * @return array{user_id: int, bet_id: int, amount: string}|null + */ + private static function refundSinglePendingBet(array $bet, int $now): ?array + { + $betId = (int) ($bet['id'] ?? 0); + if ($betId <= 0) { + return null; + } + $userId = (int) ($bet['user_id'] ?? 0); + $totalRaw = $bet['total_amount'] ?? '0'; + $total = is_string($totalRaw) ? $totalRaw : (string) $totalRaw; + + $attempts = 0; + while ($attempts < 4) { + $attempts++; + Db::startTrans(); + try { + if ($userId <= 0 || bccomp($total, '0', 2) <= 0) { + $bo = Db::name('bet_order')->where('id', $betId)->where('status', 1)->update([ + 'status' => 3, + 'update_time' => $now, + ]); + if ($bo !== 1) { + Db::rollback(); + + return null; + } + Db::commit(); + + return ['user_id' => 0, 'bet_id' => $betId, 'amount' => '0.00']; + } + + $before = (string) (Db::name('user')->where('id', $userId)->value('coin') ?? '0'); + $after = bcadd($before, $total, 2); + $u = Db::name('user')->where('id', $userId)->where('coin', $before)->update([ + 'coin' => $after, + 'update_time' => $now, + ]); + if ($u !== 1) { + throw new \RuntimeException((string) __('Concurrent balance update; please retry')); + } + $bo = Db::name('bet_order')->where('id', $betId)->where('status', 1)->update([ + 'status' => 3, + 'update_time' => $now, + ]); + if ($bo !== 1) { + Db::rollback(); + + return null; + } + $channelIdRaw = $bet['channel_id'] ?? null; + $channelId = filter_var($channelIdRaw, FILTER_VALIDATE_INT); + if ($channelId === false) { + $channelId = null; + } + UserWalletRecord::create([ + 'user_id' => $userId, + 'channel_id' => $channelId, + 'biz_type' => 'void_refund', + 'direction' => 1, + 'amount' => $total, + 'balance_before' => $before, + 'balance_after' => $after, + 'ref_type' => 'bet_order', + 'remark' => (string) __('Period void refund'), + 'create_time' => $now, + ]); + Db::commit(); + + return ['user_id' => $userId, 'bet_id' => $betId, 'amount' => $total]; + } catch (Throwable $e) { + Db::rollback(); + $msg = $e->getMessage(); + if ($attempts < 4 && str_contains($msg, '1205')) { + usleep(200_000); + + continue; + } + throw $e; + } + } + + throw new \RuntimeException((string) __('Void failed') . ': lock wait timeout'); + } + public static function publishSnapshot(?int $recordId = null, bool $runRecovery = true): void { - if ($runRecovery) { + if ($runRecovery && empty(self::$drawingRecordIds)) { self::recoverLiveRoundState(); } $snapshot = self::buildSnapshot($recordId); @@ -1669,7 +1805,6 @@ final class GameLiveService if ($recordId === '') { return ['acquired' => false, 'token' => null, 'redis_lock' => false]; } - GameHotDataLock::forceRelease(GameHotDataLock::TYPE_GAME_RECORD, $recordId); $lock = GameHotDataLock::tryAcquireWithWait(GameHotDataLock::TYPE_GAME_RECORD, $recordId, $waitMs); if ($lock['acquired']) { return $lock; @@ -1677,7 +1812,7 @@ final class GameLiveService GameHotDataLock::forceRelease(GameHotDataLock::TYPE_GAME_RECORD, $recordId); Log::warning('admin record lock force-released before void retry', ['record_id' => $recordId]); - return GameHotDataLock::tryAcquire(GameHotDataLock::TYPE_GAME_RECORD, $recordId); + return GameHotDataLock::tryAcquireWithWait(GameHotDataLock::TYPE_GAME_RECORD, $recordId, 2000); } /** diff --git a/app/process/GameWebSocketServer.php b/app/process/GameWebSocketServer.php index c93c158..d73be55 100644 --- a/app/process/GameWebSocketServer.php +++ b/app/process/GameWebSocketServer.php @@ -85,7 +85,6 @@ class GameWebSocketServer if (!$hasAdminSubscriber) { return; } - GameLiveService::recoverLiveRoundState(); $snapshot = GameLiveService::buildSnapshot(null); $payload = json_encode([ 'event' => 'admin.live.snapshot', diff --git a/web/src/views/backend/game/live/index.vue b/web/src/views/backend/game/live/index.vue index 05bfdd6..c3eb581 100644 --- a/web/src/views/backend/game/live/index.vue +++ b/web/src/views/backend/game/live/index.vue @@ -668,7 +668,7 @@ const canVoidPeriod = computed(() => { return false } const s = Number(r.status) - return s === 0 || s === 1 + return s === 0 || s === 1 || s === 3 }) /** 派彩结束后的完整维护态:操作区除顶部开关外全部锁定 */