Files
webman-buildadmin/app/common/service/GameLiveService.php

2050 lines
79 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace app\common\service;
use app\common\library\game\StreakWinReward;
use app\common\model\UserWalletRecord;
use app\common\service\GameHotDataCoordinator;
use app\common\service\GameHotDataLock;
use support\Log;
use support\Redis;
use support\think\Db;
use Throwable;
final class GameLiveService
{
/** @var array<int, true> 防止同进程内 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();
}
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<string, mixed>
*/
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]);
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);
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' => $record['period_no'] ?? '',
'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<string, mixed>
*/
public static function buildSnapshotAfterVoid(): array
{
GameHotDataRedis::gameRecordRefreshAggregateCaches();
$snapshot = self::buildSnapshot(null);
$snapshot['runtime_enabled'] = false;
$snapshot['maintenance_ui'] = true;
return $snapshot;
}
/**
* @return list<int>
*/
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<int>,order_count:int,total_amount:string,order_ids:list<int>}
*/
private static function refundPendingBetsSummaryForPeriodLocked(int $periodId, int $now): array
{
return self::refundPendingBetsSummaryForPeriod($periodId, $now);
}
/**
* 逐笔退款(每笔独立短事务),避免与开奖结算共用一个长事务抢 InnoDB 行锁。
*
* @return array{user_ids:list<int>,order_count:int,total_amount:string,order_ids:list<int>}
*/
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<string, mixed> $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,
]);
}
/**
* 派彩结束并创建新期后,立即推送一帧 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<string, mixed> $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<string, mixed>|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<string, mixed>|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<int, array<string, mixed>> $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) {
}
}
}
}