防止同进程内 recover→draw 重入导致 Redis 自锁 */ private static array $drawingRecordIds = []; private const CHANNEL = 'game-live'; private const EVENT = 'bet-updated'; /** 与《36字花-移动端接口设计草案》7.1 对齐:公共对局频道 */ private const CHANNEL_PUBLIC_GAME_PERIOD = 'public-game-period'; private const EVT_PERIOD_TICK = 'period.tick'; private const EVT_PERIOD_LOCKED = 'period.locked'; private const EVT_PERIOD_OPENED = 'period.opened'; private const EVT_PERIOD_PAYOUT = 'period.payout'; /** 派彩宽限期内每秒倒计时(不含彩池/下注列表,仅剩余秒数) */ private const EVT_PERIOD_PAYOUT_TICK = 'period.payout.tick'; /** period.tick 边界帧去重(finished / void 每期只推一次),TTL 兼顾跨进程与跨期重启 */ private const TICK_BOUNDARY_DEDUP_KEY_PREFIX = 'dfw:v1:ws:tick:boundary:'; private const TICK_BOUNDARY_DEDUP_TTL_SECONDS = 300; /** 开奖/派彩阶段公共推送去重(与 bet.win 分离;recover 补偿路径也会写) */ private const PERIOD_OPENED_NOTIFY_DEDUP_PREFIX = 'dfw:v1:ws:period_opened:'; private const PERIOD_PAYOUT_NOTIFY_DEDUP_PREFIX = 'dfw:v1:ws:period_payout:'; private const PERIOD_DRAW_NOTIFY_DEDUP_TTL = 86400; private const KEY_PERIOD_SECONDS = 'period_seconds'; private const KEY_BET_SECONDS = 'bet_seconds'; private const KEY_PAYOUT_SECONDS = 'payout_seconds'; private const KEY_PICK_MAX_NUMBER_COUNT = 'pick_max_number_count'; /** 开奖结果号码池:1 至此上限(与单注可选号码个数配置无关) */ private const DRAW_NUMBER_MAX = 36; /** 派彩展示宽限期默认值(秒),可被 game_config.payout_seconds 覆盖 */ private const DEFAULT_PAYOUT_SECONDS = 3; /** 启动自愈:判定“异常卡局”的最小超时冗余秒数 */ private const STARTUP_RECOVER_GRACE_SECONDS = 10; /** * 服务重启后自动巡检上一局:若长时间卡在进行中状态,则自动作废并退款待开奖注单。 */ public static function recoverAbnormalPeriodOnStartup(): void { $row = Db::name('game_record') ->whereIn('status', [0, 1, 2, 3]) ->order('id', 'desc') ->find(); if (!$row) { return; } $recordId = (int) ($row['id'] ?? 0); if ($recordId <= 0) { return; } $status = (int) ($row['status'] ?? 0); $resultNumber = isset($row['result_number']) ? (int) $row['result_number'] : 0; if ($resultNumber > 0 && in_array($status, [0, 1, 2, 3], true)) { self::recoverPayoutForRecordOnStartup($recordId); return; } $periodStartAt = (int) ($row['period_start_at'] ?? 0); if ($periodStartAt <= 0) { return; } $periodSeconds = self::getConfigInt(self::KEY_PERIOD_SECONDS, 30); $timeoutAt = $periodStartAt + $periodSeconds + self::getPayoutGraceSeconds() + self::STARTUP_RECOVER_GRACE_SECONDS; if (time() <= $timeoutAt) { return; } self::markAbnormalAndRefundOnStartup($recordId, $status); } private static function recoverPayoutForRecordOnStartup(int $recordId): void { $lock = GameHotDataLock::tryAcquireWithWait(GameHotDataLock::TYPE_GAME_RECORD, (string) $recordId, 3000); if (!$lock['acquired']) { return; } try { $row = Db::name('game_record')->where('id', $recordId)->find(); if (!$row) { return; } $status = (int) ($row['status'] ?? 0); if (!in_array($status, [0, 1, 2, 3], true)) { return; } $resultNumber = isset($row['result_number']) ? (int) $row['result_number'] : 0; if ($resultNumber <= 0) { 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' => [], 'user_streak_events' => [], 'wallet_events' => [], 'settled_order_count' => 0, ]; Db::startTrans(); try { if ($pendingCount > 0) { $settleOut = GameBetSettleService::settleBetsForDraw($recordId, $resultNumber); } if ($status === 2) { if ($payoutUntil <= 0) { $payoutUntil = $now + self::getPayoutGraceSeconds(); } Db::name('game_record')->where('id', $recordId)->update([ 'status' => 3, 'payout_until' => $payoutUntil, 'update_time' => $now, ]); } elseif ($status === 3) { if ($payoutUntil <= 0) { $payoutUntil = $now; Db::name('game_record')->where('id', $recordId)->update([ 'payout_until' => $payoutUntil, 'update_time' => $now, ]); } } else { $payoutUntil = $now; Db::name('game_record')->where('id', $recordId)->update([ 'status' => 3, 'payout_until' => $payoutUntil, 'update_time' => $now, ]); } Db::commit(); } catch (Throwable $e) { Db::rollback(); Log::warning('game live startup payout recover failed', [ 'record_id' => $recordId, 'error' => $e->getMessage(), ]); return; } $periodNo = is_string($row['period_no'] ?? null) ? (string) $row['period_no'] : ''; $drawMode = filter_var($row['draw_mode'] ?? 0, FILTER_VALIDATE_INT); GameBetSettleService::publishSettlementWinsAfterCommit( $settleOut, $recordId, $periodNo, (int) $resultNumber ); GameBetSettleService::ensurePeriodBetWinNotifications($recordId, (int) $resultNumber); self::ensurePeriodDrawNotifications( $recordId, $periodNo, (int) $resultNumber, $drawMode === false ? 0 : $drawMode, $payoutUntil > 0 ? $payoutUntil : $now + self::getPayoutGraceSeconds(), $now ); GameHotDataCoordinator::afterGameRecordCommitted($recordId); self::publishSnapshot(null); if ($payoutUntil <= $now) { self::finalizePayoutForRecordLocked($recordId); } } finally { GameHotDataLock::release(GameHotDataLock::TYPE_GAME_RECORD, (string) $recordId, $lock['token'], $lock['redis_lock']); } } private static function markAbnormalAndRefundOnStartup(int $recordId, int $status): void { $lock = GameHotDataLock::tryAcquireWithWait(GameHotDataLock::TYPE_GAME_RECORD, (string) $recordId, 3000); if (!$lock['acquired']) { return; } try { $fresh = Db::name('game_record')->where('id', $recordId)->find(); if (!$fresh) { return; } $freshStatus = (int) ($fresh['status'] ?? 0); if (!in_array($freshStatus, [0, 1, 2, 3], true)) { return; } $freshResultNumber = isset($fresh['result_number']) ? (int) $fresh['result_number'] : 0; if ($freshResultNumber > 0) { return; } $now = time(); $refund = ['user_ids' => [], 'order_count' => 0, 'total_amount' => '0.00']; Db::startTrans(); try { $refund = self::refundPendingBetsSummaryForPeriodLocked($recordId, $now); $refundedUserCount = count($refund['user_ids']); $refundedOrderCount = (int) ($refund['order_count'] ?? 0); $refundedTotalAmount = is_string($refund['total_amount'] ?? null) ? $refund['total_amount'] : '0.00'; $reason = sprintf( 'system_recover:from=%d|users=%d|orders=%d|amount=%s', $freshStatus, $refundedUserCount, $refundedOrderCount, $refundedTotalAmount ); 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(); Log::warning('game live startup abnormal recover failed', [ 'record_id' => $recordId, 'status' => $status, 'error' => $e->getMessage(), ]); return; } GameHotDataCoordinator::afterGameRecordCommitted($recordId); foreach ($refund['user_ids'] as $uid) { if ($uid > 0) { GameHotDataCoordinator::afterUserCommitted($uid); } } // 异常对局作废后:自动暂停游戏,不自动创建新一期;需管理员手动开启「游戏运行」才会重新开局 GameRecordService::setAutoCreateEnabled(false); GameHotDataCoordinator::afterGameConfigKeyCommitted(GameRecordService::KEY_AUTO_CREATE); self::publishSnapshot(null); Log::info('game live startup marked abnormal and refunded', [ 'record_id' => $recordId, 'old_status' => $freshStatus, 'refunded_user_count' => count($refund['user_ids']), 'refunded_order_count' => (int) ($refund['order_count'] ?? 0), 'refunded_total_amount' => is_string($refund['total_amount'] ?? null) ? $refund['total_amount'] : '0.00', ]); } finally { GameHotDataLock::release(GameHotDataLock::TYPE_GAME_RECORD, (string) $recordId, $lock['token'], $lock['redis_lock']); } } private static function finalizePayoutForRecordLocked(int $recordId): void { $now = time(); Db::startTrans(); try { $affected = Db::name('game_record')->where('id', $recordId)->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(); Log::warning('game live startup finalize payout failed', [ 'record_id' => $recordId, 'error' => $e->getMessage(), ]); 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); } catch (Throwable) { } if (!GameRecordService::isLiveRuntimeEnabled()) { self::voidRemainingOpenRoundsOnMaintenanceAfterFinalize(); GameHotDataRedis::gameRecordRefreshAggregateCaches(); } elseif (is_string($newPeriodNo ?? null) && $newPeriodNo !== '') { self::publishImmediateBettingTickAfterFinalize(); } self::publishSnapshot(null); } /** * 定时任务/WS 推送前:结单 + 超时自动开奖(勿在已持期号锁时调用)。 */ public static function recoverLiveRoundState(): void { self::finalizePayoutGrace(); self::tickAutoDraw(); GameLiveStuckDiagnostic::inspectAfterRecovery(); } public static function buildSnapshot(?int $recordId = null): array { $record = self::resolveRecord($recordId); if ($record) { $periodSeconds = self::getConfigInt(self::KEY_PERIOD_SECONDS, 30); GameHotDataRedis::gameRecordRevalidateFromDbIfStale($record, $periodSeconds); $record = self::resolveRecord($recordId); } if (!$record) { return self::emptySnapshotPayload(); } $rid = (int) $record['id']; self::ensureAiLocked($rid); $record = self::reloadRecord($rid); if (!$record) { return self::emptySnapshotPayload(); } $periodSeconds = self::getConfigInt(self::KEY_PERIOD_SECONDS, 30); $betSeconds = self::getConfigInt(self::KEY_BET_SECONDS, 20); $pickMax = self::getPickMaxNumberCount(); $elapsed = max(0, time() - (int) $record['period_start_at']); $remaining = max(0, $periodSeconds - $elapsed); $betRemaining = max(0, $betSeconds - $elapsed); $status = (int) $record['status']; $now = time(); $payoutUntil = isset($record['payout_until']) ? (int) $record['payout_until'] : 0; $payoutRemaining = 0; $isPayoutPhase = $status === 3 && $payoutUntil > $now; if ($isPayoutPhase) { $payoutRemaining = $payoutUntil - $now; } $bets = Db::name('bet_order') ->alias('bo') ->leftJoin('user gu', 'gu.id = bo.user_id') ->where('bo.period_id', $rid) ->order('bo.id', 'desc') ->limit(200) ->field('bo.id,bo.user_id,bo.period_no,bo.pick_numbers,bo.total_amount,bo.streak_at_bet,bo.win_amount,bo.status as bet_status,bo.create_time,gu.username as user_username') ->select() ->toArray(); $candidates = []; $canCalculate = $elapsed >= $betSeconds && ($status === 0 || $status === 1); if (self::shouldBuildCandidateEstimates($status, $elapsed, $betSeconds)) { for ($n = 1; $n <= self::DRAW_NUMBER_MAX; $n++) { $loss = self::estimateLossForNumber($bets, $n); $candidates[] = [ 'number' => $n, 'estimated_loss' => $loss, ]; } } $resultNumber = isset($record['result_number']) ? (int) $record['result_number'] : 0; $aiLocked = $record['ai_locked_number'] ?? null; $aiDisplay = null; if ($aiLocked !== null && $aiLocked !== '' && is_numeric((string) $aiLocked)) { $aiDisplay = (int) $aiLocked; } $pendingRaw = $record['pending_draw_number'] ?? null; $pendingDraw = null; if ($pendingRaw !== null && $pendingRaw !== '' && is_numeric((string) $pendingRaw)) { $pd = (int) $pendingRaw; if ($pd >= 1 && $pd <= self::DRAW_NUMBER_MAX) { $pendingDraw = $pd; } } $canScheduleDraw = ($status === 0 || $status === 1) && $elapsed >= $betSeconds && $elapsed < $periodSeconds; $runtimeEnabled = GameRecordService::isLiveRuntimeEnabled(); /** 关服且已无进行中局:派彩结束后的「完整维护」态(仅此时展示维护中 UI) */ $maintenanceUi = !$runtimeEnabled && !in_array($status, [0, 1, 2, 3], true); return [ 'record' => $record, 'bets' => array_map(static function (array $row): array { return [ 'id' => (int) $row['id'], 'user_id' => (int) $row['user_id'], 'username' => isset($row['user_username']) && is_string($row['user_username']) ? $row['user_username'] : '', 'period_no' => (string) $row['period_no'], 'pick_numbers' => $row['pick_numbers'], 'total_amount' => (string) $row['total_amount'], 'streak_at_bet' => (int) $row['streak_at_bet'], 'win_amount' => (string) ($row['win_amount'] ?? '0.00'), 'bet_status' => (int) ($row['bet_status'] ?? 0), 'create_time' => (int) $row['create_time'], ]; }, $bets), 'candidate_numbers' => $candidates, 'result_number' => $resultNumber > 0 ? $resultNumber : null, 'show_settlement_preview' => self::shouldBuildCandidateEstimates($status, $elapsed, $betSeconds), 'ai_default_number' => $aiDisplay, 'calc_number' => $aiDisplay, 'pending_draw_number' => $pendingDraw, 'period_seconds' => $periodSeconds, 'bet_seconds' => $betSeconds, 'pick_max_number_count' => $pickMax, 'draw_number_max' => self::DRAW_NUMBER_MAX, 'remaining_seconds' => $remaining, 'bet_remaining_seconds' => $betRemaining, 'payout_remaining_seconds' => $payoutRemaining, 'is_payout_phase' => $isPayoutPhase, 'runtime_enabled' => $runtimeEnabled, 'maintenance_ui' => $maintenanceUi, /** 关闭游戏(维护)时仍允许完成当局、计算与预约开奖;仅阻止新用户下注与结束后自动开新期 */ 'can_calculate' => $canCalculate, 'can_draw' => $canScheduleDraw, 'can_schedule_draw' => $canScheduleDraw, 'server_time' => $now, ]; } /** * @return array */ private static function emptySnapshotPayload(): array { $runtimeEnabled = GameRecordService::isLiveRuntimeEnabled(); $active = GameHotDataRedis::gameRecordActive(); $hasActiveRound = is_array($active) && in_array((int) ($active['status'] ?? -1), [0, 1, 2, 3], true); $maintenanceUi = !$runtimeEnabled && !$hasActiveRound; return [ 'record' => null, 'bets' => [], 'candidate_numbers' => [], 'ai_default_number' => null, 'calc_number' => null, 'pending_draw_number' => null, 'period_seconds' => self::getConfigInt(self::KEY_PERIOD_SECONDS, 30), 'bet_seconds' => self::getConfigInt(self::KEY_BET_SECONDS, 20), 'pick_max_number_count' => self::getPickMaxNumberCount(), 'draw_number_max' => self::DRAW_NUMBER_MAX, 'remaining_seconds' => 0, 'bet_remaining_seconds' => 0, 'payout_remaining_seconds' => 0, 'is_payout_phase' => false, 'runtime_enabled' => $runtimeEnabled, 'maintenance_ui' => $maintenanceUi, 'can_calculate' => false, 'can_draw' => false, 'can_schedule_draw' => false, 'result_number' => null, 'show_settlement_preview' => false, 'server_time' => time(), ]; } public static function calculateResult(?int $recordId, ?int $manualNumber = null): array { $record = self::resolveRecord($recordId); if (!$record) { return ['ok' => false, 'msg' => __('No active game in progress')]; } if (!in_array((int) $record['status'], [0, 1], true)) { return ['ok' => false, 'msg' => __('Current game status does not allow calculation')]; } $periodSeconds = self::getConfigInt(self::KEY_PERIOD_SECONDS, 30); $betSeconds = self::getConfigInt(self::KEY_BET_SECONDS, 20); $elapsed = max(0, time() - (int) $record['period_start_at']); if ($elapsed < $betSeconds) { return ['ok' => false, 'msg' => __('Betting period has not ended; calculation is not available yet')]; } self::ensureAiLocked((int) $record['id']); $record = self::reloadRecord((int) $record['id']); if (!$record) { return ['ok' => false, 'msg' => __('No active game in progress')]; } $pickMax = self::getPickMaxNumberCount(); if ($manualNumber !== null && ($manualNumber < 1 || $manualNumber > self::DRAW_NUMBER_MAX)) { return ['ok' => false, 'msg' => __('Manual draw number is out of the allowed range')]; } $bets = Db::name('bet_order')->where('period_id', (int) $record['id'])->select()->toArray(); $candidates = []; $bestNumber = null; $bestLoss = null; $bestNumbers = []; for ($n = 1; $n <= self::DRAW_NUMBER_MAX; $n++) { $loss = self::estimateLossForNumber($bets, $n); $candidates[] = ['number' => $n, 'estimated_loss' => $loss]; if ($bestLoss === null || bccomp((string) $loss, (string) $bestLoss, 2) < 0) { $bestLoss = $loss; $bestNumbers = [$n]; continue; } if (bccomp((string) $loss, (string) $bestLoss, 2) === 0) { $bestNumbers[] = $n; } } $bestNumber = self::pickRandomNumber($bestNumbers); $aiLocked = $record['ai_locked_number'] ?? null; $aiDisplay = null; if ($aiLocked !== null && $aiLocked !== '' && is_numeric((string) $aiLocked)) { $aiDisplay = (int) $aiLocked; } $finalNumber = $manualNumber ?? $bestNumber; $finalLoss = '0.00'; if ($finalNumber !== null) { $finalLoss = self::estimateLossForNumber($bets, $finalNumber); } return [ 'ok' => true, 'msg' => __('Calculation completed'), 'record' => $record, 'period_seconds' => $periodSeconds, 'bet_seconds' => $betSeconds, 'pick_max_number_count' => $pickMax, 'draw_number_max' => self::DRAW_NUMBER_MAX, 'candidate_numbers' => $candidates, 'ai_default_number' => $aiDisplay, 'final_number' => $finalNumber, 'final_estimated_loss' => $finalLoss, ]; } /** * 管理员预约本期开奖号码(倒计时结束后由 tick 自动开奖,不立即开奖)。 */ public static function scheduleDraw(?int $recordId, int $manualNumber): array { if ($manualNumber < 1 || $manualNumber > self::DRAW_NUMBER_MAX) { return ['ok' => false, 'msg' => __('Draw number is out of the allowed range')]; } $record = self::resolveRecord($recordId); if (!$record) { return ['ok' => false, 'msg' => __('No active game in progress')]; } if (!in_array((int) $record['status'], [0, 1], true)) { return ['ok' => false, 'msg' => __('Current game status does not allow scheduling the draw')]; } $periodSeconds = self::getConfigInt(self::KEY_PERIOD_SECONDS, 30); $betSeconds = self::getConfigInt(self::KEY_BET_SECONDS, 20); $elapsed = max(0, time() - (int) $record['period_start_at']); if ($elapsed < $betSeconds) { return ['ok' => false, 'msg' => __('Betting has not ended; cannot schedule the draw')]; } if ($elapsed >= $periodSeconds) { return ['ok' => false, 'msg' => __('This period has ended; please refresh the page')]; } $rid = (int) $record['id']; $lock = GameHotDataLock::tryAcquireWithWait(GameHotDataLock::TYPE_GAME_RECORD, (string) $rid, 1200); if (!$lock['acquired']) { return ['ok' => false, 'msg' => __('Another operation is in progress for this period; please try again later')]; } try { self::ensureAiLocked($rid); Db::name('game_record')->where('id', $rid)->update([ 'pending_draw_number' => $manualNumber, 'update_time' => time(), ]); $saved = Db::name('game_record')->where('id', $rid)->value('pending_draw_number'); $savedParsed = filter_var($saved, FILTER_VALIDATE_INT); if ($savedParsed === false || $savedParsed !== $manualNumber) { return ['ok' => false, 'msg' => __('Failed to save scheduled draw number; please try again')]; } GameHotDataCoordinator::afterGameRecordCommitted($rid); } finally { GameHotDataLock::release(GameHotDataLock::TYPE_GAME_RECORD, (string) $rid, $lock['token'], $lock['redis_lock']); } self::publishSnapshot(null); return [ 'ok' => true, 'msg' => __('Draw number scheduled; it will be used when the countdown ends'), ]; } /** * 倒计时结束自动开奖(AI 或预约号码);派彩宽限期后由 finalizePayoutGrace 结单并开下一期。 */ public static function drawResult(?int $recordId, ?int $manualNumber = null): array { $record = self::resolveRecord($recordId); if (!$record) { return ['ok' => false, 'msg' => __('No active game in progress')]; } if (!in_array((int) $record['status'], [0, 1], true)) { return ['ok' => false, 'msg' => __('Current game status does not allow drawing')]; } $periodSeconds = self::getConfigInt(self::KEY_PERIOD_SECONDS, 30); $betSeconds = self::getConfigInt(self::KEY_BET_SECONDS, 20); $elapsed = max(0, time() - (int) $record['period_start_at']); if ($elapsed < $betSeconds) { return ['ok' => false, 'msg' => __('Betting period has not ended; drawing is not available yet')]; } if ($elapsed < $periodSeconds) { return ['ok' => false, 'msg' => __('Period countdown has not ended; cannot draw yet')]; } $rid = (int) $record['id']; 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 */ $postLockWork = null; $result = ['ok' => false, 'msg' => __('Game live: settlement error')]; self::$drawingRecordIds[$rid] = true; try { self::ensureAiLocked($rid); $settleOut = ['jackpot_hits' => [], 'bet_wins' => []]; $finalNumber = 0; $drawMode = 0; $finalLoss = '0.00'; $payoutUntil = 0; $periodNo = ''; $now = time(); $drawCommitted = false; $txResult = self::withShortInnodbLockWait(3, static function () use ( $rid, $betSeconds, $periodSeconds, $manualNumber, $now ): array { Db::startTrans(); try { $record = self::loadRecordRowFromDb($rid, true); if (!$record) { Db::rollback(); return ['ok' => false, 'draw_committed' => false, 'result' => ['ok' => false, 'msg' => __('No active game in progress')]]; } $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(); return [ 'ok' => true, 'draw_committed' => false, 'existing_result' => $existingResult, 'record' => $record, ]; } if (!in_array($st, [0, 1], true)) { Db::rollback(); return ['ok' => false, 'draw_committed' => false, 'result' => ['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, 'draw_committed' => false, 'result' => ['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, ]); Db::commit(); return [ 'ok' => true, 'draw_committed' => true, 'final_number' => $finalNumber, 'draw_mode' => $drawMode, 'final_loss' => $finalLoss, 'payout_until' => $payoutUntil, 'period_no' => $periodNo, ]; } catch (Throwable $e) { Db::rollback(); return [ 'ok' => false, 'draw_committed' => false, 'result' => ['ok' => false, 'msg' => __('Game live: settlement error') . ': ' . $e->getMessage()], ]; } }); if (!($txResult['ok'] ?? false)) { $result = is_array($txResult['result'] ?? null) ? $txResult['result'] : ['ok' => false, 'msg' => __('Game live: settlement error')]; } elseif (!empty($txResult['draw_committed'])) { $drawCommitted = true; $finalNumber = (int) ($txResult['final_number'] ?? 0); $drawMode = (int) ($txResult['draw_mode'] ?? 0); $finalLoss = is_string($txResult['final_loss'] ?? null) ? $txResult['final_loss'] : '0.00'; $payoutUntil = (int) ($txResult['payout_until'] ?? 0); $periodNo = is_string($txResult['period_no'] ?? null) ? (string) $txResult['period_no'] : ''; } else { $existingResult = (int) ($txResult['existing_result'] ?? 0); $periodNo = is_string($txResult['record']['period_no'] ?? null) ? (string) $txResult['record']['period_no'] : ''; $postLockWork = static function () use ($rid): void { GameHotDataCoordinator::afterGameRecordCommitted($rid); self::publishSnapshot($rid, false); }; $result = [ 'ok' => true, 'msg' => __('Draw completed; paying out'), 'result_number' => $existingResult, 'estimated_loss' => '0.00', 'payout_until' => (int) ($txResult['record']['payout_until'] ?? 0), ]; } if ($drawCommitted) { $postLockWork = static function () use ( $rid, $periodNo, $finalNumber, $drawMode, $payoutUntil, $now ): void { // 开奖号已写入 DB:先推 period.opened / period.payout,再结算(避免 settle 异常导致永远收不到开奖推送) self::ensurePeriodDrawNotifications($rid, $periodNo, $finalNumber, $drawMode, $payoutUntil, $now); $settleOut = [ 'jackpot_hits' => [], 'bet_wins' => [], 'user_streak_events' => [], 'wallet_events' => [], 'settled_order_count' => 0, ]; try { $settleOut = GameBetSettleService::settleBetsForDraw($rid, $finalNumber); } catch (Throwable $e) { Log::warning('drawResult settle after lock failed', [ 'record_id' => $rid, 'error' => $e->getMessage(), ]); } GameBetSettleService::publishSettlementWinsAfterCommit( $settleOut, $rid, $periodNo, $finalNumber ); GameBetSettleService::ensurePeriodBetWinNotifications($rid, $finalNumber); GameHotDataCoordinator::afterGameRecordCommitted($rid); try { GameRecordStatService::refreshForRecordId($rid); } catch (Throwable) { } $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, false); }; $result = [ 'ok' => true, 'msg' => __('Draw completed; paying out'), 'result_number' => $finalNumber, 'draw_mode' => $drawMode, 'estimated_loss' => $finalLoss, 'payout_until' => $payoutUntil, ]; } } finally { unset(self::$drawingRecordIds[$rid]); GameHotDataLock::release(GameHotDataLock::TYPE_GAME_RECORD, (string) $rid, $lock['token'], $lock['redis_lock']); } if ($postLockWork !== null) { $postLockWork(); } return $result; } /** * 派彩宽限期结束:将本期置为已结束并创建下一期。 */ public static function finalizePayoutGrace(): void { $now = time(); $row = Db::name('game_record') ->where('status', 3) ->whereRaw('((payout_until > 0 AND payout_until <= ?) OR payout_until IS NULL OR payout_until = 0)', [$now]) ->order('id', 'desc') ->find(); if (!$row) { return; } $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]); GameLiveStuckDiagnostic::report('payout_finalize_lock', [ 'record_id' => $id, 'period_no' => is_string($row['period_no'] ?? null) ? (string) $row['period_no'] : '', 'phase_label' => '派彩结单 finalizePayoutGrace 拿不到 Redis 行锁', 'hint' => '键名 dfw:v1:lock:mut:gr:' . $id . ';2s 内未拿到锁,每秒重试', ], $id); 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); if ($resultNumber === false || $resultNumber < 1) { $resultNumber = null; } $newPeriodNo = null; $now = time(); $finalized = false; Db::startTrans(); try { $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(); } else { Db::commit(); $finalized = true; } } catch (Throwable $e) { Db::rollback(); Log::warning('finalizePayoutGrace failed: ' . $e->getMessage(), ['record_id' => $id]); } if (!$finalized) { 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); $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, ]); if (!$runtimeEnabled) { self::voidRemainingOpenRoundsOnMaintenanceAfterFinalize(); GameHotDataRedis::gameRecordRefreshAggregateCaches(); } elseif (is_string($newPeriodNo ?? null) && $newPeriodNo !== '') { self::publishImmediateBettingTickAfterFinalize(); } self::publishSnapshot(null, false); }; } finally { GameHotDataLock::release(GameHotDataLock::TYPE_GAME_RECORD, (string) $id, $lock['token'], $lock['redis_lock']); } if ($notifyAfterLock !== null) { $notifyAfterLock(); } } public static function tickAutoDraw(): void { $record = self::resolveRecordForAutoDraw(); if (!$record || !in_array((int) $record['status'], [0, 1], true)) { return; } $periodSeconds = self::getConfigInt(self::KEY_PERIOD_SECONDS, 30); $elapsed = max(0, time() - (int) $record['period_start_at']); if ($elapsed < $periodSeconds) { return; } $rid = (int) $record['id']; $st = (int) ($record['status'] ?? 0); $abnormalAfter = $periodSeconds + self::getPayoutGraceSeconds() + self::STARTUP_RECOVER_GRACE_SECONDS; if ($elapsed > $abnormalAfter) { $resultNumber = isset($record['result_number']) ? (int) $record['result_number'] : 0; if ($resultNumber <= 0) { GameHotDataLock::forceRelease(GameHotDataLock::TYPE_GAME_RECORD, (string) $rid); self::markAbnormalAndRefundOnStartup($rid, $st); } return; } $out = self::drawResult($rid, null); $periodNo = is_string($record['period_no'] ?? null) ? (string) $record['period_no'] : ''; GameLiveStuckDiagnostic::noteDrawAttempt($rid, $periodNo, $out); if ($out['ok'] ?? false) { return; } $msg = is_string($out['msg'] ?? null) ? (string) $out['msg'] : ''; if ( !str_contains($msg, 'Another operation is in progress') && !str_contains($msg, 'Lock wait timeout') && !str_contains($msg, '1205') ) { Log::warning('tickAutoDraw: drawResult failed', [ 'record_id' => $rid, 'period_no' => $periodNo, 'msg' => $msg, ]); } } /** * 关闭「自动创建下一局」时:作废除当前 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, 2]) ->order('id', 'asc') ->select() ->toArray(); foreach ($rows as $row) { $rid = filter_var($row['id'] ?? 0, FILTER_VALIDATE_INT); 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(); self::publishSnapshot(null); } /** * 作废单期(仅 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, 3], true)) { return ['ok' => false, 'msg' => __('Current period cannot be voided')]; } $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')]; } $refundedUserIds = []; $refund = ['user_ids' => [], 'order_count' => 0, 'total_amount' => '0.00', 'order_ids' => []]; try { $now = time(); $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']); } GameHotDataCoordinator::afterGameRecordCommitted($recordId); foreach ($refundedUserIds as $uid) { if ($uid > 0) { GameHotDataCoordinator::afterUserCommitted($uid); } } return [ 'ok' => true, 'msg' => __('Period voided'), 'refund' => $refund, ]; } /** * 作废当前期(仅 status 为下注/封盘):待开奖注单退款,本期置为已作废,并关闭运行开关。 * * @return array{ok: bool, msg?: string, record?: array|null} */ public static function voidCurrentPeriod(?int $recordId, string $voidReason): array { $reason = trim($voidReason); if ($reason === '') { return ['ok' => false, 'msg' => __('Void reason is required')]; } if (function_exists('mb_strlen')) { if (mb_strlen($reason) > 255) { return ['ok' => false, 'msg' => __('Void reason is too long')]; } } elseif (strlen($reason) > 255) { return ['ok' => false, 'msg' => __('Void reason is too long')]; } if (strlen($reason) < 2) { return ['ok' => false, 'msg' => __('Void reason is too short')]; } $record = self::resolveRecord($recordId); if (!$record) { return ['ok' => false, 'msg' => __('No active game in progress')]; } $st = (int) $record['status']; if (!in_array($st, [0, 1, 3], true)) { return ['ok' => false, 'msg' => __('Current period cannot be voided')]; } $rid = (int) $record['id']; $internal = self::voidOpenPeriodInternal($rid, $reason); if (!($internal['ok'] ?? false)) { $errMsg = $internal['msg'] ?? null; return ['ok' => false, 'msg' => is_string($errMsg) ? $errMsg : __('Void failed')]; } GameRecordService::setAutoCreateEnabled(false); GameHotDataCoordinator::afterGameConfigKeyCommitted(GameRecordService::KEY_AUTO_CREATE); $refund = $internal['refund'] ?? null; return [ 'ok' => true, 'msg' => __('Period voided'), 'refund' => is_array($refund) ? $refund : null, ]; } /** * 作废本局后返回给前端的轻量快照(不再触发 publishSnapshot,避免 HTTP 超时)。 * * @return array */ public static function buildSnapshotAfterVoid(): array { GameHotDataRedis::gameRecordRefreshAggregateCaches(); $snapshot = self::buildSnapshot(null); $snapshot['runtime_enabled'] = false; $snapshot['maintenance_ui'] = true; return $snapshot; } /** * @return list */ private static function refundPendingBetsForPeriodLocked(int $periodId, int $now): array { $summary = self::refundPendingBetsSummaryForPeriodLocked($periodId, $now); 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; $totalAmount = '0.00'; $orderIds = []; $bets = Db::name('bet_order') ->where('period_id', $periodId) ->where('status', 1) ->order('id', 'asc') ->select() ->toArray(); foreach ($bets as $bet) { $single = self::refundSinglePendingBet($bet, $now); if ($single === null) { continue; } if ($single['user_id'] > 0) { $userIdSet[$single['user_id']] = true; } $orderCount++; $totalAmount = bcadd($totalAmount, $single['amount'], 2); $orderIds[] = $single['bet_id']; } $out = []; foreach (array_keys($userIdSet) as $uid) { $out[] = (int) $uid; } return [ 'user_ids' => $out, 'order_count' => $orderCount, 'total_amount' => $totalAmount, 'order_ids' => $orderIds, ]; } /** * @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 && empty(self::$drawingRecordIds)) { self::recoverLiveRoundState(); } $snapshot = self::buildSnapshot($recordId); self::publishPublicPeriodPayoutCountdown($snapshot); self::publishPublicPeriodTick($snapshot); } /** * 派彩宽限期内每秒推送倒计时(仅剩余秒数,不含彩池变化)。 */ private static function publishPublicPeriodPayoutCountdown(array $snapshot): void { $record = $snapshot['record'] ?? null; if (!is_array($record)) { return; } $dbStatus = filter_var($record['status'] ?? 0, FILTER_VALIDATE_INT); if ($dbStatus !== 3) { return; } $payoutUntil = filter_var($record['payout_until'] ?? 0, FILTER_VALIDATE_INT); if ($payoutUntil === false || $payoutUntil <= 0) { return; } $now = time(); if ($payoutUntil <= $now) { return; } $periodId = filter_var($record['id'] ?? 0, FILTER_VALIDATE_INT); if ($periodId === false) { $periodId = 0; } $periodNo = is_string($record['period_no'] ?? null) ? (string) $record['period_no'] : ''; $resultNumber = null; $resultParsed = filter_var($record['result_number'] ?? null, FILTER_VALIDATE_INT); if ($resultParsed !== false && $resultParsed > 0) { $resultNumber = $resultParsed; } GameWebSocketEventBus::publish(self::EVT_PERIOD_PAYOUT_TICK, [ 'period_id' => $periodId, 'period_no' => $periodNo, 'status' => 'payouting', 'payout_until' => $payoutUntil, 'payout_seconds' => self::getPayoutGraceSeconds(), 'payout_remaining_seconds' => max(0, $payoutUntil - $now), 'result_number' => $resultNumber, 'server_time' => $now, ]); // 兜底:派彩期内每秒触发一次“最终一致”补偿(有独立去重键,最多各推一次)。 // 用于处理“开奖推送已发,但结算推送 bet.win/jackpot.hit 因瞬时 Redis/入队失败丢失”的场景。 if ($periodId > 0 && $periodNo !== '' && is_int($resultNumber) && $resultNumber > 0) { GameBetSettleService::ensurePeriodWinAndJackpotNotifications($periodId, $periodNo, $resultNumber); } } /** * 派彩结束并创建新期后,立即推送一帧 betting(避免热缓存仍指向上一期 payouting 导致长时间无 tick)。 */ private static function publishImmediateBettingTickAfterFinalize(): void { $record = GameHotDataRedis::gameRecordActive(); if (!is_array($record)) { return; } $dbStatus = filter_var($record['status'] ?? 0, FILTER_VALIDATE_INT); if ($dbStatus !== 0) { return; } $periodId = filter_var($record['id'] ?? 0, FILTER_VALIDATE_INT); if ($periodId === false || $periodId <= 0) { return; } $periodNo = is_string($record['period_no'] ?? null) ? (string) $record['period_no'] : ''; if ($periodNo === '') { return; } $periodSeconds = self::getConfigInt(self::KEY_PERIOD_SECONDS, 30); $betSeconds = self::getConfigInt(self::KEY_BET_SECONDS, 20); $elapsed = max(0, time() - (int) ($record['period_start_at'] ?? time())); GameWebSocketEventBus::publish(self::EVT_PERIOD_TICK, [ 'period_id' => $periodId, 'period_no' => $periodNo, 'status' => 'betting', 'countdown' => max(0, $periodSeconds - $elapsed), 'bet_close_in' => max(0, $betSeconds - $elapsed), 'result_number' => null, 'runtime_enabled' => GameRecordService::isLiveRuntimeEnabled(), 'server_time' => time(), ]); } /** * 移动端公共频道:每秒心跳,含期号、倒计时、阶段(对齐 lobbyInit/periodCurrent 语义) */ private static function publishPublicPeriodTick(array $snapshot): void { $record = $snapshot['record'] ?? null; $periodNo = ''; $periodId = 0; $status = 'finished'; $resultNumber = null; if (is_array($record)) { $periodNoRaw = $record['period_no'] ?? ''; if (is_string($periodNoRaw)) { $periodNo = $periodNoRaw; } $periodIdRaw = $record['id'] ?? 0; $periodIdParsed = filter_var($periodIdRaw, FILTER_VALIDATE_INT); if ($periodIdParsed !== false && $periodIdParsed > 0) { $periodId = $periodIdParsed; } $dbStatusRaw = $record['status'] ?? 4; $dbStatus = filter_var($dbStatusRaw, FILTER_VALIDATE_INT); if ($dbStatus === false) { $dbStatus = 4; } $betRemainingRaw = $snapshot['bet_remaining_seconds'] ?? 0; $betRemaining = filter_var($betRemainingRaw, FILTER_VALIDATE_INT); if ($betRemaining === false || $betRemaining < 0) { $betRemaining = 0; } $status = self::mapPublicPeriodStatus($dbStatus, $betRemaining); $resultRaw = $record['result_number'] ?? null; $resultParsed = filter_var($resultRaw, FILTER_VALIDATE_INT); if ($resultParsed !== false && $resultParsed > 0) { $resultNumber = $resultParsed; } } $payload = [ 'period_id' => $periodId, 'period_no' => $periodNo, 'status' => $status, 'countdown' => max(0, self::safeInt($snapshot['remaining_seconds'] ?? 0)), 'bet_close_in' => max(0, self::safeInt($snapshot['bet_remaining_seconds'] ?? 0)), 'result_number' => $resultNumber, 'runtime_enabled' => !empty($snapshot['runtime_enabled']), 'server_time' => time(), ]; if (!self::shouldPublishPeriodTick($status, $periodNo)) { return; } if (empty($snapshot['runtime_enabled']) && in_array($status, ['betting', 'locked'], true)) { return; } GameWebSocketEventBus::publish(self::EVT_PERIOD_TICK, $payload); } /** * period.tick 状态过滤(与《36字花-移动端接口设计草案》7.1.3 推送规则对齐): * - betting / locked:保持每秒推送 * - payouting:完全静默(中奖信息已通过 period.opened / period.payout / jackpot.hit / wallet.changed 通知) * - finished / void:每个期号只推一次边界帧,告知前端本期收尾,随后静默直到下一期 betting */ private static function shouldPublishPeriodTick(string $status, string $periodNo): bool { if ($status === 'payouting') { return false; } if ($status === 'finished' || $status === 'void') { if ($periodNo === '') { return true; } return self::markBoundaryFrameOnce($periodNo, $status); } return true; } /** * 边界帧去重:SET NX EX,占位成功(首次)返回 true;已存在返回 false。Redis 异常时降级为放行。 */ private static function markBoundaryFrameOnce(string $periodNo, string $status): bool { $key = self::TICK_BOUNDARY_DEDUP_KEY_PREFIX . $periodNo . ':' . $status; try { $client = Redis::connection()->client(); if (!is_object($client) || !method_exists($client, 'set')) { return true; } $ok = $client->set($key, '1', ['nx', 'ex' => self::TICK_BOUNDARY_DEDUP_TTL_SECONDS]); return $ok === true; } catch (Throwable) { return true; } } /** * @param array $record game_record 行 */ private static function publishPublicPeriodLocked(array $record): void { $periodNo = is_string($record['period_no'] ?? null) ? $record['period_no'] : ''; $periodId = filter_var($record['id'] ?? 0, FILTER_VALIDATE_INT); if ($periodId === false) { $periodId = 0; } GameWebSocketEventBus::publish(self::EVT_PERIOD_LOCKED, [ 'period_id' => $periodId, 'period_no' => $periodNo, 'status' => 'locked', 'server_time' => time(), ]); } /** * 开奖后推送 period.opened + period.payout(每期各至多一次;入队成功后才写 dedup)。 * recover / drawResult 补偿路径均会调用,避免仅 recover 结算时漏推 period.opened。 */ public static function ensurePeriodDrawNotifications( int $periodId, string $periodNo, int $resultNumber, int $drawMode, int $payoutUntil, int $openTime ): void { if ($periodId <= 0 || $periodNo === '' || $resultNumber < 1) { return; } $openedKey = self::PERIOD_OPENED_NOTIFY_DEDUP_PREFIX . $periodId; $payoutKey = self::PERIOD_PAYOUT_NOTIFY_DEDUP_PREFIX . $periodId; if (!self::hasPeriodNotifyMarked($openedKey)) { $ok = GameWebSocketEventBus::publish(self::EVT_PERIOD_OPENED, [ 'period_no' => $periodNo, 'result_number' => $resultNumber, 'draw_mode' => $drawMode, 'open_time' => $openTime, ]); if ($ok) { self::markPeriodNotifyOnce($openedKey); Log::channel('ws')->info('period.opened published', [ 'period_id' => $periodId, 'period_no' => $periodNo, 'result_number' => $resultNumber, ]); } else { Log::channel('ws')->warning('period.opened publish failed', [ 'period_id' => $periodId, 'period_no' => $periodNo, ]); } } if (!self::hasPeriodNotifyMarked($payoutKey)) { $grace = self::getPayoutGraceSeconds(); $remaining = max(0, $payoutUntil - $openTime); $ok = GameWebSocketEventBus::publish(self::EVT_PERIOD_PAYOUT, [ 'period_id' => $periodId, 'period_no' => $periodNo, 'result_number' => $resultNumber, 'payout_until' => $payoutUntil, 'payout_seconds' => $grace, 'payout_remaining_seconds' => $remaining, 'server_time' => $openTime, ]); if ($ok) { self::markPeriodNotifyOnce($payoutKey); Log::channel('ws')->info('period.payout published', [ 'period_id' => $periodId, 'period_no' => $periodNo, ]); } else { Log::channel('ws')->warning('period.payout publish failed', [ 'period_id' => $periodId, 'period_no' => $periodNo, ]); } } } private static function hasPeriodNotifyMarked(string $key): bool { try { $existing = Redis::get($key); return $existing !== false && $existing !== null && $existing !== ''; } catch (Throwable) { return false; } } private static function markPeriodNotifyOnce(string $key): void { try { Redis::setEx($key, self::PERIOD_DRAW_NOTIFY_DEDUP_TTL, '1'); } catch (Throwable) { } } private static function publishPublicPeriodOpened(string $periodNo, int $resultNumber, int $drawMode, int $openTime): void { GameWebSocketEventBus::publish(self::EVT_PERIOD_OPENED, [ 'period_no' => $periodNo, 'result_number' => $resultNumber, 'draw_mode' => $drawMode, 'open_time' => $openTime, ]); } /** * 派彩阶段开始(开奖后宽限期内推送)。 * 客户端应以 payout_until 与 server_time 做倒计时,勿用 period.tick 或上期 countdown。 */ private static function publishPublicPeriodPayout(int $periodId, string $periodNo, int $resultNumber, int $payoutUntil, int $serverTime): void { $grace = self::getPayoutGraceSeconds(); $remaining = max(0, $payoutUntil - $serverTime); GameWebSocketEventBus::publish(self::EVT_PERIOD_PAYOUT, [ 'period_id' => $periodId, 'period_no' => $periodNo, 'result_number' => $resultNumber, 'payout_until' => $payoutUntil, 'payout_seconds' => $grace, 'payout_remaining_seconds' => $remaining, 'server_time' => $serverTime, ]); } /** * 派彩宽限期结束、进入下一期前:推送本期收尾帧(每期一次)。 */ private static function publishPublicPeriodFinished(int $periodId, string $periodNo, ?int $resultNumber): void { if ($periodNo === '') { return; } if (!self::markBoundaryFrameOnce($periodNo, 'finished')) { return; } GameWebSocketEventBus::publish(self::EVT_PERIOD_TICK, [ 'period_id' => $periodId, 'period_no' => $periodNo, 'status' => 'finished', 'countdown' => 0, 'bet_close_in' => 0, 'result_number' => $resultNumber, 'runtime_enabled' => GameRecordService::isLiveRuntimeEnabled(), 'server_time' => time(), ]); } /** * 与文档 3.1/4.1 中 status 字符串对齐:betting / locked / settling / finished */ private static function mapPublicPeriodStatus(int $dbStatus, int $betCloseIn): string { if ($dbStatus === 5) { return 'void'; } if ($dbStatus === 0) { return $betCloseIn > 0 ? 'betting' : 'locked'; } if ($dbStatus === 1) { return 'locked'; } if ($dbStatus === 4) { return 'finished'; } if ($dbStatus === 3) { return 'payouting'; } if ($dbStatus === 2) { return 'settling'; } return 'finished'; } private static function resolveRecord(?int $recordId): ?array { if ($recordId !== null && $recordId > 0) { $row = GameHotDataRedis::gameRecordById($recordId); if ($row) { return $row; } } return GameHotDataRedis::gameRecordActive(); } private static function reloadRecord(int $id): ?array { $row = GameHotDataRedis::gameRecordById($id); return $row ?: null; } /** * 自动开奖目标期:优先取已预约开奖号码的进行中局,避免开错期。 * * @return array|null */ private static function resolveRecordForAutoDraw(): ?array { $pendingRow = Db::name('game_record') ->whereIn('status', [0, 1]) ->where('pending_draw_number', '>', 0) ->order('id', 'desc') ->find(); if (is_array($pendingRow)) { return $pendingRow; } $row = Db::name('game_record') ->whereIn('status', [0, 1]) ->order('id', 'desc') ->find(); return is_array($row) ? $row : null; } /** * @return array|null */ private static function loadRecordRowFromDb(int $recordId, bool $forUpdate = false): ?array { if ($recordId <= 0) { return null; } $query = Db::name('game_record')->where('id', $recordId); if ($forUpdate) { $query->lock(true); } $row = $query->find(); return is_array($row) ? $row : null; } /** * 开奖号码优先级:显式入参 > 预约开奖 pending_draw_number > AI 锁定号 > 按注单估算。 * * @return array{0: int, 1: int} [finalNumber, drawMode] drawMode: 0=AI/估算 1=预约/手动 */ private static function resolveFinalDrawNumber(array $record, ?int $manualOverride): array { $max = self::DRAW_NUMBER_MAX; if ($manualOverride !== null && $manualOverride >= 1 && $manualOverride <= $max) { return [$manualOverride, 1]; } $pending = $record['pending_draw_number'] ?? null; if ($pending !== null && $pending !== '' && is_numeric((string) $pending)) { $pendingNumber = (int) $pending; if ($pendingNumber >= 1 && $pendingNumber <= $max) { return [$pendingNumber, 1]; } } $aiLocked = $record['ai_locked_number'] ?? null; if ($aiLocked !== null && $aiLocked !== '' && is_numeric((string) $aiLocked)) { $aiNumber = (int) $aiLocked; if ($aiNumber >= 1 && $aiNumber <= $max) { return [$aiNumber, 0]; } } $recordId = filter_var($record['id'] ?? 0, FILTER_VALIDATE_INT); $bets = []; if ($recordId !== false && $recordId > 0) { $bets = Db::name('bet_order')->where('period_id', $recordId)->select()->toArray(); } return [self::computeBestNumberFromBets($bets) ?? 1, 0]; } /** * 封盘后计算并锁定 AI 号码(本期不变),并封盘(status 0→1)。 */ private static function ensureAiLocked(int $recordId): void { $record = GameHotDataRedis::gameRecordById($recordId); if (!$record) { return; } $betSeconds = self::getConfigInt(self::KEY_BET_SECONDS, 20); $elapsed = max(0, time() - (int) $record['period_start_at']); if ($elapsed < $betSeconds) { return; } $st = (int) $record['status']; if ($st !== 0 && $st !== 1) { return; } $existing = $record['ai_locked_number'] ?? null; if ($existing !== null && $existing !== '' && is_numeric((string) $existing) && (int) $existing > 0) { if ($st === 0) { Db::name('game_record')->where('id', $recordId)->update([ 'status' => 1, 'update_time' => time(), ]); GameHotDataCoordinator::afterGameRecordCommitted($recordId); $record['status'] = 1; self::publishPublicPeriodLocked($record); } return; } $bets = Db::name('bet_order')->where('period_id', $recordId)->select()->toArray(); $best = self::computeBestNumberFromBets($bets); if ($best === null || $best < 1) { $best = 1; } $update = [ 'ai_locked_number' => $best, 'update_time' => time(), ]; if ($st === 0) { $update['status'] = 1; } Db::name('game_record')->where('id', $recordId)->update($update); GameHotDataCoordinator::afterGameRecordCommitted($recordId); $record = array_merge($record, $update); if ($st === 0) { self::publishPublicPeriodLocked($record); } } /** * @param array> $bets */ private static function computeBestNumberFromBets(array $bets): ?int { $bestLoss = null; $bestNumbers = []; for ($n = 1; $n <= self::DRAW_NUMBER_MAX; $n++) { $loss = self::estimateLossForNumber($bets, $n); if ($bestLoss === null || bccomp((string) $loss, (string) $bestLoss, 2) < 0) { $bestLoss = $loss; $bestNumbers = [$n]; continue; } if (bccomp((string) $loss, (string) $bestLoss, 2) === 0) { $bestNumbers[] = $n; } } return self::pickRandomNumber($bestNumbers); } private static function getConfigInt(string $key, int $default): int { $row = GameHotDataRedis::gameConfigRow($key); if (!$row) { return $default; } $v = $row['config_value'] ?? null; if ($v === null || $v === '') { return $default; } if (!is_numeric((string) $v)) { return $default; } return (int) $v; } private static function getPayoutGraceSeconds(): int { $seconds = self::getConfigInt(self::KEY_PAYOUT_SECONDS, self::DEFAULT_PAYOUT_SECONDS); if ($seconds < 1) { return 1; } if ($seconds > 300) { return 300; } return $seconds; } private static function getPickMaxNumberCount(): int { $max = self::getConfigInt(self::KEY_PICK_MAX_NUMBER_COUNT, 36); if ($max < 1) { return 1; } if ($max > 36) { return 36; } 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 retry', ['record_id' => $recordId]); return GameHotDataLock::tryAcquireWithWait(GameHotDataLock::TYPE_GAME_RECORD, $recordId, 2000); } /** * 封盘至本期完全结束前均展示赔付预估(含已开奖/派彩中),供后台实时对局页保留表格数据。 */ private static function shouldBuildCandidateEstimates(int $status, int $elapsed, int $betSeconds): bool { if ($elapsed < $betSeconds) { return false; } return in_array($status, [0, 1, 2, 3, 4], true); } private static function estimateLossForNumber(array $bets, int $number): string { $payout = '0.00'; foreach ($bets as $bet) { $pickNumbers = $bet['pick_numbers']; if (is_string($pickNumbers)) { $decoded = json_decode($pickNumbers, true); $pickNumbers = is_array($decoded) ? $decoded : []; } if (!is_array($pickNumbers)) { $pickNumbers = []; } if (!in_array($number, array_map('intval', $pickNumbers), true)) { continue; } $total = (string) ($bet['total_amount'] ?? '0'); $streak = (int) ($bet['streak_at_bet'] ?? 0); $odds = StreakWinReward::totalOddsMultiplierForStreakAtBet($streak); $orderPayout = bcmul($total, $odds, 2); $payout = bcadd($payout, $orderPayout, 2); } return $payout; } private static function pickRandomNumber(array $numbers): ?int { if ($numbers === []) { return null; } if (count($numbers) === 1) { return $numbers[0]; } $index = random_int(0, count($numbers) - 1); return $numbers[$index]; } private static function safeInt($value): int { $parsed = filter_var($value, FILTER_VALIDATE_INT); if ($parsed === false) { return 0; } return $parsed; } /** * 开奖事务使用较短行锁等待,避免 HTTP/定时任务被 InnoDB 默认 50s 锁等待拖死。 * * @template T * @param callable(): T $fn * @return T */ private static function withShortInnodbLockWait(int $seconds, callable $fn): mixed { $seconds = max(1, min(50, $seconds)); try { Db::execute('SET SESSION innodb_lock_wait_timeout = ' . $seconds); } catch (Throwable) { } try { return $fn(); } finally { try { Db::execute('SET SESSION innodb_lock_wait_timeout = 50'); } catch (Throwable) { } } } }