From 365e64307266471ddf2b071178fb1455e517e0d1 Mon Sep 17 00:00:00 2001 From: zhenhui <1276357500@qq.com> Date: Tue, 26 May 2026 17:17:27 +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 | 183 +++++++++++++++++--- app/common/service/GameLiveService.php | 43 ++++- 2 files changed, 195 insertions(+), 31 deletions(-) diff --git a/app/common/service/GameBetSettleService.php b/app/common/service/GameBetSettleService.php index d4b265c..24b48d2 100644 --- a/app/common/service/GameBetSettleService.php +++ b/app/common/service/GameBetSettleService.php @@ -6,6 +6,7 @@ namespace app\common\service; use app\common\library\game\StreakWinReward; use support\Log; +use support\Redis; use support\think\Db; use Throwable; @@ -28,6 +29,9 @@ final class GameBetSettleService public const TOPIC_JACKPOT_HIT = 'jackpot.hit'; + /** 每期结算推送去重(避免事务重试 / recover 重复推 user.streak、wallet.changed) */ + private const SETTLE_NOTIFY_DEDUP_PREFIX = 'dfw:v1:settle:notify:'; + /** * 对指定期次按开奖号码结算所有「待开奖」注单;同一注单幂等(仅 status=1 会更新)。 * @@ -41,7 +45,13 @@ final class GameBetSettleService public static function settleBetsForDraw(int $recordId, int $resultNumber): array { if ($recordId <= 0 || $resultNumber < 1) { - return ['jackpot_hits' => [], 'bet_wins' => []]; + return [ + 'jackpot_hits' => [], + 'bet_wins' => [], + 'user_streak_events' => [], + 'wallet_events' => [], + 'settled_order_count' => 0, + ]; } $now = time(); @@ -65,6 +75,14 @@ final class GameBetSettleService /** @var array}> */ $winByUser = []; + /** @var list}> */ + $userStreakEvents = []; + + /** @var list> */ + $walletEvents = []; + + $settledOrderCount = 0; + foreach ($bets as $bet) { $betId = (int) ($bet['id'] ?? 0); if ($betId <= 0) { @@ -98,6 +116,8 @@ final class GameBetSettleService continue; } + $settledOrderCount++; + self::creditUserBetFlow($bet, $now); if ($userId > 0) { @@ -115,9 +135,13 @@ final class GameBetSettleService $balanceAfter = (string) (Db::name('user')->where('id', $userId)->value('coin') ?? '0'); if (!$needReview && bccomp($win, '0', 2) > 0) { - $paid = self::creditUserPayout($bet, $betId, $win, $now, null, '压注派彩', $resultNumber); - if ($paid !== null) { - $balanceAfter = $paid; + $paid = self::creditUserPayout($bet, $betId, $win, $now, null, '压注派彩', $resultNumber, false); + if (is_array($paid)) { + $balanceAfter = (string) ($paid['balance_after'] ?? $balanceAfter); + $walletPayload = $paid['wallet_payload'] ?? null; + if (is_array($walletPayload)) { + $walletEvents[] = $walletPayload; + } } } @@ -182,14 +206,17 @@ final class GameBetSettleService ]); GameHotDataCoordinator::afterUserCommitted($userId); $periodNo = isset($aggregateByUser[$userId]['period_no']) ? (string) $aggregateByUser[$userId]['period_no'] : ''; - GameWebSocketPayloadHelper::publishUserStreak($userId, $next, [ - // 明确标记本期结算结果,客户端可直接判断“当前用户是否中奖”。 - 'is_win' => $hadWin, - 'period_id' => $recordId, - 'period_no' => $periodNo, - 'result_number' => $resultNumber, - 'settled_at' => $now, - ]); + $userStreakEvents[] = [ + 'user_id' => $userId, + 'current_streak' => $next, + 'extra' => [ + 'is_win' => $hadWin, + 'period_id' => $recordId, + 'period_no' => $periodNo, + 'result_number' => $resultNumber, + 'settled_at' => $now, + ], + ]; } // 兜底:若已判定本期中奖(is_win=true),但聚合中奖事件意外缺失,补一条 bet.win,保证客户端可感知中奖。 @@ -247,7 +274,13 @@ final class GameBetSettleService $betWins = array_values($winByUser); - return ['jackpot_hits' => $jackpotHits, 'bet_wins' => $betWins]; + return [ + 'jackpot_hits' => $jackpotHits, + 'bet_wins' => $betWins, + 'user_streak_events' => $userStreakEvents, + 'wallet_events' => $walletEvents, + 'settled_order_count' => $settledOrderCount, + ]; } /** @@ -295,18 +328,78 @@ final class GameBetSettleService } /** - * 结算提交后统一推送:先 bet.win(全员中奖),再 jackpot.hit(仅大奖档)。 + * 结算提交后统一推送:user.streak / wallet.changed / bet.win / jackpot.hit(每期仅推一次)。 * - * @param array{jackpot_hits?: list>, bet_wins?: list>} $settleOut + * @param array{ + * jackpot_hits?: list>, + * bet_wins?: list>, + * user_streak_events?: list}>, + * wallet_events?: list>, + * settled_order_count?: int + * } $settleOut */ public static function publishSettlementWinsAfterCommit(array $settleOut, int $periodId, string $periodNo, int $resultNumber): void { + $settledCount = filter_var($settleOut['settled_order_count'] ?? 0, FILTER_VALIDATE_INT); $betWins = is_array($settleOut['bet_wins'] ?? null) ? $settleOut['bet_wins'] : []; + $hasWins = $betWins !== []; + $hasStreak = is_array($settleOut['user_streak_events'] ?? null) && $settleOut['user_streak_events'] !== []; + $hasWallet = is_array($settleOut['wallet_events'] ?? null) && $settleOut['wallet_events'] !== []; + if ($settledCount === false || $settledCount <= 0) { + if (!$hasWins && !$hasStreak && !$hasWallet) { + return; + } + } + if (!self::markSettlementNotifyOnce($periodId)) { + return; + } + + $streakEvents = is_array($settleOut['user_streak_events'] ?? null) ? $settleOut['user_streak_events'] : []; + foreach ($streakEvents as $row) { + if (!is_array($row)) { + continue; + } + $userId = filter_var($row['user_id'] ?? 0, FILTER_VALIDATE_INT); + if ($userId === false || $userId <= 0) { + continue; + } + $streak = filter_var($row['current_streak'] ?? 0, FILTER_VALIDATE_INT); + $extra = is_array($row['extra'] ?? null) ? $row['extra'] : []; + GameWebSocketPayloadHelper::publishUserStreak($userId, $streak === false ? 0 : $streak, $extra); + } + + $walletEvents = is_array($settleOut['wallet_events'] ?? null) ? $settleOut['wallet_events'] : []; + foreach ($walletEvents as $payload) { + if (!is_array($payload) || empty($payload['user_id'])) { + continue; + } + $userId = filter_var($payload['user_id'], FILTER_VALIDATE_INT); + if ($userId === false || $userId <= 0) { + continue; + } + GameWebSocketEventBus::publish('wallet.changed', GameWebSocketPayloadHelper::mergeUserStreakInto($payload, $userId)); + } + $jackpotHits = is_array($settleOut['jackpot_hits'] ?? null) ? $settleOut['jackpot_hits'] : []; self::publishBetWinsAfterCommit($betWins); self::publishJackpotHitsAfterCommit($jackpotHits, $periodId, $periodNo, $resultNumber); } + private static function markSettlementNotifyOnce(int $periodId): bool + { + if ($periodId <= 0) { + return false; + } + $key = self::SETTLE_NOTIFY_DEDUP_PREFIX . $periodId; + try { + $ok = Redis::set($key, '1', ['nx', 'ex' => 86400]); + + return $ok === true || $ok === 'OK'; + } catch (Throwable) { + return true; + } + } + /** * 批量读取用户展示名:nickname 优先;空则 fallback 到 username;仍空则返回空串(调用方自行兜底)。 * @@ -376,7 +469,19 @@ final class GameBetSettleService $now = time(); $balanceAfter = null; if (bccomp($winAmount, '0', 2) > 0) { - $balanceAfter = self::creditUserPayout($row, $playRecordId, $winAmount, $now, $operatorAdminId > 0 ? $operatorAdminId : null, '大奖审核通过派彩'); + $paid = self::creditUserPayout( + $row, + $playRecordId, + $winAmount, + $now, + $operatorAdminId > 0 ? $operatorAdminId : null, + '大奖审核通过派彩', + null, + true + ); + if (is_array($paid)) { + $balanceAfter = (string) ($paid['balance_after'] ?? '0'); + } } $reviewRemark = trim($remark); if ($reviewRemark === '') { @@ -474,8 +579,10 @@ final class GameBetSettleService } Db::startTrans(); try { - self::settleBetsForDraw($rid, $rn); + $settleOut = self::settleBetsForDraw($rid, $rn); Db::commit(); + $periodNo = (string) Db::name('game_record')->where('id', $rid)->value('period_no'); + self::publishSettlementWinsAfterCommit($settleOut, $rid, $periodNo, $rn); $count++; } catch (Throwable $e) { Db::rollback(); @@ -540,10 +647,18 @@ final class GameBetSettleService } /** - * @return string|null 派彩后余额;已幂等入账过时返回当前余额;失败或未执行派彩返回 null + * @return array{balance_after: string, wallet_payload: array}|null */ - private static function creditUserPayout(array $bet, int $betId, string $winAmount, int $now, ?int $operatorAdminId, string $remark, ?int $resultNumber = null): ?string - { + private static function creditUserPayout( + array $bet, + int $betId, + string $winAmount, + int $now, + ?int $operatorAdminId, + string $remark, + ?int $resultNumber = null, + bool $emitWalletEvent = true + ): ?array { $userId = (int) ($bet['user_id'] ?? 0); if ($userId <= 0) { return null; @@ -552,8 +667,25 @@ final class GameBetSettleService $idem = 'payout_bet_' . $betId; if (Db::name('user_wallet_record')->where('idempotency_key', $idem)->value('id')) { $coin = Db::name('user')->where('id', $userId)->value('coin'); + $balanceAfter = (string) ($coin ?? '0'); + $walletPayload = [ + 'user_id' => $userId, + 'balance_after' => $balanceAfter, + 'biz_type' => 'payout', + 'ref_id' => $betId, + 'amount' => $winAmount, + 'period_no' => (string) ($bet['period_no'] ?? ''), + 'period_id' => isset($bet['period_id']) && is_numeric($bet['period_id']) ? (int) $bet['period_id'] : 0, + 'changed_at' => $now, + ]; + if ($resultNumber !== null && $resultNumber > 0) { + $walletPayload['result_number'] = $resultNumber; + } - return (string) ($coin ?? '0'); + return [ + 'balance_after' => $balanceAfter, + 'wallet_payload' => $walletPayload, + ]; } $user = Db::name('user')->where('id', $userId)->find(); @@ -598,9 +730,14 @@ final class GameBetSettleService if ($resultNumber !== null && $resultNumber > 0) { $walletPayload['result_number'] = $resultNumber; } - GameWebSocketEventBus::publish('wallet.changed', GameWebSocketPayloadHelper::mergeUserStreakInto($walletPayload, $userId)); + if ($emitWalletEvent) { + GameWebSocketEventBus::publish('wallet.changed', GameWebSocketPayloadHelper::mergeUserStreakInto($walletPayload, $userId)); + } - return $after; + return [ + 'balance_after' => $after, + 'wallet_payload' => $walletPayload, + ]; } private static function jackpotMaxAmount(): string diff --git a/app/common/service/GameLiveService.php b/app/common/service/GameLiveService.php index 0629592..ac94ada 100644 --- a/app/common/service/GameLiveService.php +++ b/app/common/service/GameLiveService.php @@ -99,12 +99,25 @@ final class GameLiveService return; } + $pendingCount = (int) Db::name('bet_order') + ->where('period_id', $recordId) + ->where('status', GameBetSettleService::PLAY_STATUS_PENDING_DRAW) + ->count(); + $now = time(); $payoutUntil = isset($row['payout_until']) ? (int) $row['payout_until'] : 0; - $settleOut = ['jackpot_hits' => [], 'bet_wins' => []]; + $settleOut = [ + 'jackpot_hits' => [], + 'bet_wins' => [], + 'user_streak_events' => [], + 'wallet_events' => [], + 'settled_order_count' => 0, + ]; Db::startTrans(); try { - $settleOut = GameBetSettleService::settleBetsForDraw($recordId, $resultNumber); + if ($pendingCount > 0) { + $settleOut = GameBetSettleService::settleBetsForDraw($recordId, $resultNumber); + } if ($status === 2) { if ($payoutUntil <= 0) { $payoutUntil = $now + self::getPayoutGraceSeconds(); @@ -743,12 +756,14 @@ final class GameLiveService public static function tickAutoDraw(): void { if (!GameRecordService::isAutoCreateEnabled()) { - $openCount = (int) Db::name('game_record')->whereIn('status', [0, 1])->count(); - if ($openCount <= 0) { - return; - } + $record = Db::name('game_record') + ->whereIn('status', [0, 1]) + ->order('id', 'asc') + ->find(); + $record = is_array($record) ? $record : null; + } else { + $record = self::resolveRecordForAutoDraw(); } - $record = self::resolveRecordForAutoDraw(); if (!$record || !in_array((int) $record['status'], [0, 1], true)) { return; } @@ -804,7 +819,7 @@ final class GameLiveService } $reason = (string) __('Open period closed after payout: game is in maintenance'); $rows = Db::name('game_record') - ->whereIn('status', [0, 1]) + ->whereIn('status', [0, 1, 2]) ->order('id', 'asc') ->select() ->toArray(); @@ -813,6 +828,18 @@ final class GameLiveService if ($rid === false || $rid <= 0) { continue; } + $st = (int) ($row['status'] ?? -1); + if ($st === 2) { + Db::name('game_record')->where('id', $rid)->update([ + 'status' => 5, + 'void_reason' => $reason, + 'pending_draw_number' => null, + 'payout_until' => null, + 'update_time' => time(), + ]); + GameHotDataCoordinator::afterGameRecordCommitted($rid); + continue; + } self::voidOpenPeriodInternal($rid, $reason); } GameHotDataRedis::gameRecordRefreshAggregateCaches();