diff --git a/app/common/service/GameLiveService.php b/app/common/service/GameLiveService.php index c209161..0629592 100644 --- a/app/common/service/GameLiveService.php +++ b/app/common/service/GameLiveService.php @@ -535,6 +535,11 @@ final class GameLiveService '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); self::publishSnapshot(null); } finally { @@ -575,96 +580,105 @@ final class GameLiveService 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')]; - } + self::ensureAiLocked($rid); - $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; + $settleOut = ['jackpot_hits' => [], 'bet_wins' => []]; + $finalNumber = 0; $drawMode = 0; - } + $finalLoss = '0.00'; + $payoutUntil = 0; + $periodNo = ''; + $now = time(); - $bets = Db::name('bet_order')->where('period_id', (int) $record['id'])->select()->toArray(); - $finalLoss = self::estimateLossForNumber($bets, $finalNumber); - $now = time(); - $payoutUntil = $now + self::getPayoutGraceSeconds(); + Db::startTrans(); + try { + $record = self::loadRecordRowFromDb($rid, true); + if (!$record) { + Db::rollback(); + return ['ok' => false, 'msg' => __('No active game in progress')]; + } - $settleOut = ['jackpot_hits' => [], 'bet_wins' => []]; - Db::startTrans(); - try { - Db::name('game_record')->where('id', (int) $record['id'])->update([ - 'status' => 3, + $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(); + $periodNo = is_string($record['period_no'] ?? null) ? (string) $record['period_no'] : ''; + return [ + 'ok' => true, + 'msg' => __('Draw completed; paying out'), + 'result_number' => $existingResult, + 'estimated_loss' => '0.00', + 'payout_until' => (int) ($record['payout_until'] ?? 0), + ]; + } + + if (!in_array($st, [0, 1], true)) { + Db::rollback(); + return ['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, '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, + ]); + $settleOut = GameBetSettleService::settleBetsForDraw($rid, $finalNumber); + Db::commit(); + } catch (Throwable $e) { + Db::rollback(); + return ['ok' => false, 'msg' => __('Game live: settlement error') . ': ' . $e->getMessage()]; + } + + GameBetSettleService::publishSettlementWinsAfterCommit( + $settleOut, + $rid, + $periodNo, + $finalNumber + ); + + GameHotDataCoordinator::afterGameRecordCommitted($rid); + + try { + GameRecordStatService::refreshForRecordId($rid); + } catch (Throwable) { + } + self::publishPublicPeriodOpened($periodNo, $finalNumber, $drawMode, $now); + self::publishPublicPeriodPayout($rid, $periodNo, $finalNumber, $payoutUntil, $now); + $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, - 'pending_draw_number' => null, 'payout_until' => $payoutUntil, - 'update_time' => $now, + 'jackpot_hits' => $jackpotHits, + 'server_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()]; - } + self::publishSnapshot(null); - 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, - ]; + return [ + 'ok' => true, + 'msg' => __('Draw completed; paying out'), + 'result_number' => $finalNumber, + 'draw_mode' => $drawMode, + 'estimated_loss' => $finalLoss, + 'payout_until' => $payoutUntil, + ]; } finally { GameHotDataLock::release(GameHotDataLock::TYPE_GAME_RECORD, (string) $rid, $lock['token'], $lock['redis_lock']); } @@ -734,19 +748,12 @@ final class GameLiveService return; } } - $record = self::resolveRecord(null); + $record = self::resolveRecordForAutoDraw(); 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; } @@ -1219,11 +1226,12 @@ final class GameLiveService ]); } - private static function publishPublicPeriodOpened(string $periodNo, int $resultNumber, int $openTime): void + 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, ]); } @@ -1314,6 +1322,84 @@ final class GameLiveService 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)。 */