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

1376 lines
53 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
{
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;
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;
}
$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,
]);
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) {
}
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<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(),
]);
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;
}
Db::startTrans();
try {
Db::name('game_record')->where('id', $id)->update([
'status' => 4,
'payout_until' => null,
'update_time' => time(),
]);
if (GameRecordService::isLiveRuntimeEnabled()) {
GameRecordService::createNextRecordRowIfNoActive();
}
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);
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);
}
/**
* 作废当前期(仅 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'];
$lock = GameHotDataLock::tryAcquireWithWait(GameHotDataLock::TYPE_GAME_RECORD, (string) $rid, 3000);
if (!$lock['acquired']) {
return ['ok' => false, 'msg' => __('Another operation is in progress for this period; please try again later')];
}
$refundedUserIds = [];
try {
$now = time();
$refund = ['user_ids' => [], 'order_count' => 0, 'total_amount' => '0.00', 'order_ids' => []];
Db::startTrans();
try {
$refund = self::refundPendingBetsSummaryForPeriodLocked($rid, $now);
$refundedUserIds = $refund['user_ids'];
Db::name('game_record')->where('id', $rid)->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()];
}
GameRecordService::setAutoCreateEnabled(false);
GameHotDataCoordinator::afterGameRecordCommitted($rid);
GameHotDataCoordinator::afterGameConfigKeyCommitted(GameRecordService::KEY_AUTO_CREATE);
foreach ($refundedUserIds as $uid) {
if ($uid > 0) {
GameHotDataCoordinator::afterUserCommitted($uid);
}
}
self::publishSnapshot(null);
return [
'ok' => true,
'msg' => __('Period voided'),
'record' => self::reloadRecord($rid),
'refund' => $refund,
];
} finally {
GameHotDataLock::release(GameHotDataLock::TYPE_GAME_RECORD, (string) $rid, $lock['token'], $lock['redis_lock']);
}
}
/**
* @return list<int>
*/
private static function refundPendingBetsForPeriodLocked(int $periodId, int $now): array
{
$summary = self::refundPendingBetsSummaryForPeriodLocked($periodId, $now);
return $summary['user_ids'];
}
/**
* @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
{
$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<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(),
]);
}
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<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;
}
/**
* 封盘至本期完全结束前均展示赔付预估(含已开奖/派彩中),供后台实时对局页保留表格数据。
*/
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;
}
}