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; } $now = time(); $payoutUntil = isset($row['payout_until']) ? (int) $row['payout_until'] : 0; $settleOut = ['jackpot_hits' => [], 'bet_wins' => []]; Db::startTrans(); try { $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; } GameBetSettleService::publishSettlementWinsAfterCommit( $settleOut, $recordId, is_string($row['period_no'] ?? null) ? (string) $row['period_no'] : '', (int) $resultNumber ); 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 { Db::name('game_record')->where('id', $recordId)->where('status', 3)->update([ 'status' => 4, 'payout_until' => null, 'update_time' => $now, ]); $newPeriodNo = GameRecordService::createNextRecordAfterDraw(); Db::commit(); } catch (Throwable $e) { Db::rollback(); Log::warning('game live startup finalize payout failed', [ 'record_id' => $recordId, 'error' => $e->getMessage(), ]); return; } GameHotDataCoordinator::afterGameRecordCommitted($recordId); try { GameRecordStatService::refreshForRecordId($recordId); } catch (Throwable) { } if (!GameRecordService::isLiveRuntimeEnabled()) { self::voidRemainingOpenRoundsOnMaintenanceAfterFinalize(); } elseif (is_string($newPeriodNo ?? null) && $newPeriodNo !== '') { self::publishImmediateBettingTickAfterFinalize(); } self::publishSnapshot(null); } 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(), ]); GameHotDataCoordinator::afterGameRecordCommitted($rid); self::publishSnapshot(null); } finally { GameHotDataLock::release(GameHotDataLock::TYPE_GAME_RECORD, (string) $rid, $lock['token'], $lock['redis_lock']); } 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']; $lock = GameHotDataLock::tryAcquireWithWait(GameHotDataLock::TYPE_GAME_RECORD, (string) $rid, 2000); if (!$lock['acquired']) { return ['ok' => false, 'msg' => __('Another operation is in progress for this period; please try again later')]; } try { self::ensureAiLocked($rid); $record = self::reloadRecord($rid); if (!$record) { return ['ok' => false, 'msg' => __('No active game in progress')]; } $useManual = $manualNumber; if ($useManual === null) { $p = $record['pending_draw_number'] ?? null; if ($p !== null && $p !== '' && is_numeric((string) $p)) { $pn = (int) $p; if ($pn >= 1 && $pn <= self::DRAW_NUMBER_MAX) { $useManual = $pn; } } } $finalNumber = null; $drawMode = 0; if ($useManual !== null && $useManual >= 1 && $useManual <= self::DRAW_NUMBER_MAX) { $finalNumber = $useManual; $drawMode = 1; } else { $al = $record['ai_locked_number'] ?? null; if ($al !== null && $al !== '' && is_numeric((string) $al)) { $finalNumber = (int) $al; } } if ($finalNumber === null || $finalNumber < 1) { $bets = Db::name('bet_order')->where('period_id', (int) $record['id'])->select()->toArray(); $finalNumber = self::computeBestNumberFromBets($bets) ?? 1; $drawMode = 0; } $bets = Db::name('bet_order')->where('period_id', (int) $record['id'])->select()->toArray(); $finalLoss = self::estimateLossForNumber($bets, $finalNumber); $now = time(); $payoutUntil = $now + self::getPayoutGraceSeconds(); $settleOut = ['jackpot_hits' => [], 'bet_wins' => []]; Db::startTrans(); try { Db::name('game_record')->where('id', (int) $record['id'])->update([ 'status' => 3, 'result_number' => $finalNumber, 'draw_mode' => $drawMode, 'pending_draw_number' => null, 'payout_until' => $payoutUntil, 'update_time' => $now, ]); $settleOut = GameBetSettleService::settleBetsForDraw((int) $record['id'], $finalNumber); Db::commit(); } catch (Throwable $e) { Db::rollback(); return ['ok' => false, 'msg' => __('Game live: settlement error') . ': ' . $e->getMessage()]; } GameBetSettleService::publishSettlementWinsAfterCommit( $settleOut, $rid, (string) $record['period_no'], $finalNumber ); GameHotDataCoordinator::afterGameRecordCommitted($rid); try { GameRecordStatService::refreshForRecordId($rid); } catch (Throwable) { } self::publishPublicPeriodOpened((string) $record['period_no'], $finalNumber, $now); self::publishPublicPeriodPayout($rid, (string) $record['period_no'], $finalNumber, $payoutUntil, $now); $jackpotHits = is_array($settleOut['jackpot_hits'] ?? null) ? $settleOut['jackpot_hits'] : []; GameWebSocketEventBus::publish('admin.live.opened', [ 'period_id' => $rid, 'period_no' => (string) $record['period_no'], 'result_number' => $finalNumber, 'payout_until' => $payoutUntil, 'jackpot_hits' => $jackpotHits, 'server_time' => $now, ]); self::publishSnapshot(null); return [ 'ok' => true, 'msg' => __('Draw completed; paying out'), 'result_number' => $finalNumber, 'estimated_loss' => $finalLoss, 'payout_until' => $payoutUntil, ]; } finally { GameHotDataLock::release(GameHotDataLock::TYPE_GAME_RECORD, (string) $rid, $lock['token'], $lock['redis_lock']); } } /** * 派彩宽限期结束:将本期置为已结束并创建下一期。 */ 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']) { return; } 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; Db::startTrans(); try { Db::name('game_record')->where('id', $id)->update([ 'status' => 4, 'payout_until' => null, 'update_time' => time(), ]); $newPeriodNo = GameRecordService::createNextRecordAfterDraw(); Db::commit(); } catch (Throwable $e) { Db::rollback(); Log::warning('finalizePayoutGrace failed: ' . $e->getMessage(), ['record_id' => $id]); return; } GameHotDataCoordinator::afterGameRecordCommitted($id); GameRecordStatService::refreshForRecordId($id); self::publishPublicPeriodFinished($id, $periodNo, $resultNumber); self::publishSnapshot(null); 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']); } } public static function tickAutoDraw(): void { $record = self::resolveRecord(null); if (!$record || !in_array((int) $record['status'], [0, 1], true)) { return; } $betSeconds = self::getConfigInt(self::KEY_BET_SECONDS, 20); $periodSeconds = self::getConfigInt(self::KEY_PERIOD_SECONDS, 30); $elapsed = max(0, time() - (int) $record['period_start_at']); self::ensureAiLocked((int) $record['id']); $record = self::reloadRecord((int) $record['id']); if (!$record) { return; } $elapsed = max(0, time() - (int) $record['period_start_at']); if ($elapsed < $periodSeconds) { return; } 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 为下注/封盘):待开奖注单退款,本期置为已作废,并关闭运行开关。 * * @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], 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); self::publishSnapshot(null); $refund = $internal['refund'] ?? null; return [ 'ok' => true, 'msg' => __('Period voided'), 'record' => self::reloadRecord($rid), 'refund' => is_array($refund) ? $refund : null, ]; } /** * @return list */ private static function refundPendingBetsForPeriodLocked(int $periodId, int $now): array { $summary = self::refundPendingBetsSummaryForPeriodLocked($periodId, $now); return $summary['user_ids']; } /** * @return array{user_ids:list,order_count:int,total_amount:string,order_ids:list} */ private static function refundPendingBetsSummaryForPeriodLocked(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) { $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) { 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; } $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; } $out = []; foreach (array_keys($userIdSet) as $uid) { $out[] = (int) $uid; } return [ 'user_ids' => $out, 'order_count' => $orderCount, 'total_amount' => $totalAmount, 'order_ids' => $orderIds, ]; } public static function publishSnapshot(?int $recordId = null): void { $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, ]); } /** * 派彩结束并创建新期后,立即推送一帧 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; } 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(), ]); } private static function publishPublicPeriodOpened(string $periodNo, int $resultNumber, int $openTime): void { GameWebSocketEventBus::publish(self::EVT_PERIOD_OPENED, [ 'period_no' => $periodNo, 'result_number' => $resultNumber, '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; } /** * 封盘后计算并锁定 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; } /** * 封盘至本期完全结束前均展示赔付预估(含已开奖/派彩中),供后台实时对局页保留表格数据。 */ 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; } }