1652 lines
64 KiB
PHP
1652 lines
64 KiB
PHP
<?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;
|
||
}
|
||
|
||
$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;
|
||
}
|
||
|
||
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 {
|
||
$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);
|
||
}
|
||
|
||
public static function buildSnapshot(?int $recordId = null): array
|
||
{
|
||
// HTTP/WS 拉快照时也尝试结单,避免仅 gameLiveTicker 未跑时派彩倒计时归零后长期卡住
|
||
self::finalizePayoutGrace();
|
||
|
||
$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);
|
||
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);
|
||
|
||
$settleOut = ['jackpot_hits' => [], 'bet_wins' => []];
|
||
$finalNumber = 0;
|
||
$drawMode = 0;
|
||
$finalLoss = '0.00';
|
||
$payoutUntil = 0;
|
||
$periodNo = '';
|
||
$now = time();
|
||
|
||
Db::startTrans();
|
||
try {
|
||
$record = self::loadRecordRowFromDb($rid, true);
|
||
if (!$record) {
|
||
Db::rollback();
|
||
return ['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();
|
||
$periodNo = is_string($record['period_no'] ?? null) ? (string) $record['period_no'] : '';
|
||
GameHotDataCoordinator::afterGameRecordCommitted($rid);
|
||
self::publishSnapshot($rid);
|
||
|
||
return [
|
||
'ok' => true,
|
||
'msg' => __('Draw completed; paying out'),
|
||
'result_number' => $existingResult,
|
||
'estimated_loss' => '0.00',
|
||
'payout_until' => (int) ($record['payout_until'] ?? 0),
|
||
];
|
||
}
|
||
|
||
if (!in_array($st, [0, 1], true)) {
|
||
Db::rollback();
|
||
return ['ok' => false, 'msg' => __('Current game status does not allow drawing')];
|
||
}
|
||
|
||
$elapsedLocked = max(0, $now - (int) ($record['period_start_at'] ?? $now));
|
||
if ($elapsedLocked < $betSeconds || $elapsedLocked < $periodSeconds) {
|
||
Db::rollback();
|
||
return ['ok' => false, 'msg' => __('Period countdown has not ended; cannot draw yet')];
|
||
}
|
||
|
||
[$finalNumber, $drawMode] = self::resolveFinalDrawNumber($record, $manualNumber);
|
||
$bets = Db::name('bet_order')->where('period_id', $rid)->select()->toArray();
|
||
$finalLoss = self::estimateLossForNumber($bets, $finalNumber);
|
||
$payoutUntil = $now + self::getPayoutGraceSeconds();
|
||
$periodNo = is_string($record['period_no'] ?? null) ? (string) $record['period_no'] : '';
|
||
|
||
Db::name('game_record')->where('id', $rid)->update([
|
||
'status' => 3,
|
||
'result_number' => $finalNumber,
|
||
'draw_mode' => $drawMode,
|
||
'pending_draw_number' => null,
|
||
'payout_until' => $payoutUntil,
|
||
'update_time' => $now,
|
||
]);
|
||
$settleOut = GameBetSettleService::settleBetsForDraw($rid, $finalNumber);
|
||
Db::commit();
|
||
} catch (Throwable $e) {
|
||
Db::rollback();
|
||
return ['ok' => false, 'msg' => __('Game live: settlement error') . ': ' . $e->getMessage()];
|
||
}
|
||
|
||
GameBetSettleService::publishSettlementWinsAfterCommit(
|
||
$settleOut,
|
||
$rid,
|
||
$periodNo,
|
||
$finalNumber
|
||
);
|
||
|
||
GameHotDataCoordinator::afterGameRecordCommitted($rid);
|
||
|
||
try {
|
||
GameRecordStatService::refreshForRecordId($rid);
|
||
} catch (Throwable) {
|
||
}
|
||
self::publishPublicPeriodOpened($periodNo, $finalNumber, $drawMode, $now);
|
||
self::publishPublicPeriodPayout($rid, $periodNo, $finalNumber, $payoutUntil, $now);
|
||
$jackpotHits = is_array($settleOut['jackpot_hits'] ?? null) ? $settleOut['jackpot_hits'] : [];
|
||
GameWebSocketEventBus::publish('admin.live.opened', [
|
||
'period_id' => $rid,
|
||
'period_no' => $periodNo,
|
||
'result_number' => $finalNumber,
|
||
'draw_mode' => $drawMode,
|
||
'payout_until' => $payoutUntil,
|
||
'jackpot_hits' => $jackpotHits,
|
||
'server_time' => $now,
|
||
]);
|
||
self::publishSnapshot(null);
|
||
|
||
return [
|
||
'ok' => true,
|
||
'msg' => __('Draw completed; paying out'),
|
||
'result_number' => $finalNumber,
|
||
'draw_mode' => $drawMode,
|
||
'estimated_loss' => $finalLoss,
|
||
'payout_until' => $payoutUntil,
|
||
];
|
||
} finally {
|
||
GameHotDataLock::release(GameHotDataLock::TYPE_GAME_RECORD, (string) $rid, $lock['token'], $lock['redis_lock']);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 派彩宽限期结束:将本期置为已结束并创建下一期。
|
||
*/
|
||
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;
|
||
}
|
||
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();
|
||
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();
|
||
|
||
return;
|
||
}
|
||
Db::commit();
|
||
} catch (Throwable $e) {
|
||
Db::rollback();
|
||
Log::warning('finalizePayoutGrace failed: ' . $e->getMessage(), ['record_id' => $id]);
|
||
|
||
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);
|
||
self::publishPublicPeriodFinished($id, $periodNo, $resultNumber);
|
||
GameWebSocketEventBus::publish('admin.live.finalized', [
|
||
'period_id' => $id,
|
||
'period_no' => $periodNo,
|
||
'result_number' => $resultNumber,
|
||
'runtime_enabled' => GameRecordService::isLiveRuntimeEnabled(),
|
||
'maintenance_ui' => !GameRecordService::isLiveRuntimeEnabled()
|
||
&& !GameRecordService::hasActiveRecord(),
|
||
'server_time' => $now,
|
||
]);
|
||
self::publishSnapshot(null);
|
||
if (!GameRecordService::isLiveRuntimeEnabled()) {
|
||
self::voidRemainingOpenRoundsOnMaintenanceAfterFinalize();
|
||
GameHotDataRedis::gameRecordRefreshAggregateCaches();
|
||
self::publishSnapshot(null);
|
||
} elseif (is_string($newPeriodNo ?? null) && $newPeriodNo !== '') {
|
||
self::publishImmediateBettingTickAfterFinalize();
|
||
}
|
||
} finally {
|
||
GameHotDataLock::release(GameHotDataLock::TYPE_GAME_RECORD, (string) $id, $lock['token'], $lock['redis_lock']);
|
||
}
|
||
}
|
||
|
||
public static function tickAutoDraw(): void
|
||
{
|
||
$record = self::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'];
|
||
$out = self::drawResult($rid, null);
|
||
if (!($out['ok'] ?? false)) {
|
||
Log::warning('tickAutoDraw: drawResult failed', [
|
||
'record_id' => $rid,
|
||
'period_no' => $record['period_no'] ?? '',
|
||
'msg' => $out['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], true)) {
|
||
return ['ok' => false, 'msg' => __('Current period cannot be voided')];
|
||
}
|
||
$lock = GameHotDataLock::tryAcquireWithWait(GameHotDataLock::TYPE_GAME_RECORD, (string) $recordId, 3000);
|
||
if (!$lock['acquired']) {
|
||
return ['ok' => false, 'msg' => __('Another operation is in progress for this period; please try again later')];
|
||
}
|
||
$refundedUserIds = [];
|
||
try {
|
||
$now = time();
|
||
Db::startTrans();
|
||
try {
|
||
$refund = self::refundPendingBetsSummaryForPeriodLocked($recordId, $now);
|
||
$refundedUserIds = $refund['user_ids'];
|
||
Db::name('game_record')->where('id', $recordId)->update([
|
||
'status' => 5,
|
||
'void_reason' => $reason,
|
||
'pending_draw_number' => null,
|
||
'payout_until' => null,
|
||
'ai_locked_number' => null,
|
||
'update_time' => $now,
|
||
]);
|
||
Db::commit();
|
||
} catch (Throwable $e) {
|
||
Db::rollback();
|
||
return ['ok' => false, 'msg' => __('Void failed') . ': ' . $e->getMessage()];
|
||
}
|
||
GameHotDataCoordinator::afterGameRecordCommitted($recordId);
|
||
foreach ($refundedUserIds as $uid) {
|
||
if ($uid > 0) {
|
||
GameHotDataCoordinator::afterUserCommitted($uid);
|
||
}
|
||
}
|
||
return [
|
||
'ok' => true,
|
||
'msg' => __('Period voided'),
|
||
'refund' => $refund,
|
||
];
|
||
} finally {
|
||
GameHotDataLock::release(GameHotDataLock::TYPE_GAME_RECORD, (string) $recordId, $lock['token'], $lock['redis_lock']);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 作废当前期(仅 status 为下注/封盘):待开奖注单退款,本期置为已作废,并关闭运行开关。
|
||
*
|
||
* @return array{ok: bool, msg?: string, record?: array|null}
|
||
*/
|
||
public static function voidCurrentPeriod(?int $recordId, string $voidReason): array
|
||
{
|
||
$reason = trim($voidReason);
|
||
if ($reason === '') {
|
||
return ['ok' => false, 'msg' => __('Void reason is required')];
|
||
}
|
||
if (function_exists('mb_strlen')) {
|
||
if (mb_strlen($reason) > 255) {
|
||
return ['ok' => false, 'msg' => __('Void reason is too long')];
|
||
}
|
||
} elseif (strlen($reason) > 255) {
|
||
return ['ok' => false, 'msg' => __('Void reason is too long')];
|
||
}
|
||
if (strlen($reason) < 2) {
|
||
return ['ok' => false, 'msg' => __('Void reason is too short')];
|
||
}
|
||
|
||
$record = self::resolveRecord($recordId);
|
||
if (!$record) {
|
||
return ['ok' => false, 'msg' => __('No active game in progress')];
|
||
}
|
||
$st = (int) $record['status'];
|
||
if (!in_array($st, [0, 1], true)) {
|
||
return ['ok' => false, 'msg' => __('Current period cannot be voided')];
|
||
}
|
||
$rid = (int) $record['id'];
|
||
$internal = self::voidOpenPeriodInternal($rid, $reason);
|
||
if (!($internal['ok'] ?? false)) {
|
||
$errMsg = $internal['msg'] ?? null;
|
||
return ['ok' => false, 'msg' => is_string($errMsg) ? $errMsg : __('Void failed')];
|
||
}
|
||
GameRecordService::setAutoCreateEnabled(false);
|
||
GameHotDataCoordinator::afterGameConfigKeyCommitted(GameRecordService::KEY_AUTO_CREATE);
|
||
self::publishSnapshot(null);
|
||
$refund = $internal['refund'] ?? null;
|
||
|
||
return [
|
||
'ok' => true,
|
||
'msg' => __('Period voided'),
|
||
'record' => self::reloadRecord($rid),
|
||
'refund' => is_array($refund) ? $refund : null,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* @return list<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;
|
||
}
|
||
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(),
|
||
]);
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* 封盘至本期完全结束前均展示赔付预估(含已开奖/派彩中),供后台实时对局页保留表格数据。
|
||
*/
|
||
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;
|
||
}
|
||
}
|