From c184fa8a46b18adf429ef0390d0188ee3828091d Mon Sep 17 00:00:00 2001 From: zhenhui <1276357500@qq.com> Date: Sat, 18 Apr 2026 17:16:13 +0800 Subject: [PATCH] =?UTF-8?q?1.=E4=BC=98=E5=8C=96=E5=90=8E=E5=8F=B0=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E6=8E=A8=E9=80=81=E5=8A=9F=E8=83=BD=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=202.=E4=BC=98=E5=8C=96=E5=BC=80=E5=A5=96=E5=92=8C=E5=AE=9E?= =?UTF-8?q?=E6=97=B6=E5=AF=B9=E5=B1=80=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin/controller/game/Live.php | 12 +- app/api/controller/Game.php | 9 + app/common/model/GameRecord.php | 3 + app/common/service/GameBetSettleService.php | 73 +++- app/common/service/GameLiveService.php | 390 +++++++++++++++--- app/common/service/UserPushService.php | 61 +++ app/process/GameLiveTicker.php | 1 + web/src/lang/backend/en/game/live.ts | 8 +- .../lang/backend/en/test/pushGamePeriod.ts | 2 +- web/src/lang/backend/zh-cn/game/live.ts | 8 +- .../lang/backend/zh-cn/test/pushGamePeriod.ts | 2 +- web/src/utils/backend/pushChannelTest.ts | 2 + web/src/views/backend/game/live/index.vue | 87 +++- .../test/components/PushChannelTestPage.vue | 2 +- 14 files changed, 582 insertions(+), 78 deletions(-) create mode 100644 app/common/service/UserPushService.php diff --git a/app/admin/controller/game/Live.php b/app/admin/controller/game/Live.php index f829376..8153437 100644 --- a/app/admin/controller/game/Live.php +++ b/app/admin/controller/game/Live.php @@ -76,6 +76,9 @@ class Live extends Backend return $this->success((string) $res['msg'], $res); } + /** + * 预约本期开奖号码(倒计时结束后自动开奖,不立即开奖)。 + */ public function draw(WebmanRequest $request): Response { $response = $this->initializeBackend($request); @@ -88,10 +91,13 @@ class Live extends Backend $recordIdRaw = $request->post('record_id'); $recordId = is_numeric((string) $recordIdRaw) ? (int) $recordIdRaw : null; $manualRaw = $request->post('manual_number'); - $manualNumber = is_numeric((string) $manualRaw) ? (int) $manualRaw : null; - $res = GameLiveService::drawResult($recordId, $manualNumber); + if (!is_numeric((string) $manualRaw)) { + return $this->error('请填写开奖号码'); + } + $manualNumber = (int) $manualRaw; + $res = GameLiveService::scheduleDraw($recordId, $manualNumber); if (!($res['ok'] ?? false)) { - return $this->error((string) ($res['msg'] ?? '开奖失败')); + return $this->error((string) ($res['msg'] ?? '预约失败')); } return $this->success((string) $res['msg'], $res); } diff --git a/app/api/controller/Game.php b/app/api/controller/Game.php index 447b713..6bdf66b 100644 --- a/app/api/controller/Game.php +++ b/app/api/controller/Game.php @@ -9,6 +9,7 @@ use app\common\model\BetOrder; use app\common\model\GameConfig; use app\common\model\GameRecord; use app\common\model\UserWalletRecord; +use app\common\service\UserPushService; use support\think\Db; use Webman\Http\Request; use support\Response; @@ -217,6 +218,14 @@ class Game extends MobileBase 'update_time' => time(), ]); Db::commit(); + UserPushService::publish((int) $user->id, UserPushService::EVT_BET_ACCEPTED, [ + 'order_no' => $orderNo, + 'period_no' => (string) $period->period_no, + 'status' => 'accepted', + 'balance_after' => $after, + 'total_amount' => $totalAmount, + 'current_streak' => (int) ($user->current_streak ?? 0), + ]); } catch (\Throwable $e) { Db::rollback(); return $this->mobileError(5000, 'System is busy, please try again later', ['detail' => $e->getMessage()]); diff --git a/app/common/model/GameRecord.php b/app/common/model/GameRecord.php index fd6b5da..764f97a 100644 --- a/app/common/model/GameRecord.php +++ b/app/common/model/GameRecord.php @@ -18,6 +18,9 @@ class GameRecord extends Model 'draw_mode' => 'integer', 'preset_number' => 'integer', 'result_number' => 'integer', + 'ai_locked_number' => 'integer', + 'pending_draw_number' => 'integer', + 'payout_until' => 'integer', 'platform_profit_amount' => 'string', 'winner_user_count' => 'integer', ]; diff --git a/app/common/service/GameBetSettleService.php b/app/common/service/GameBetSettleService.php index 6860ce2..839d7ac 100644 --- a/app/common/service/GameBetSettleService.php +++ b/app/common/service/GameBetSettleService.php @@ -33,6 +33,9 @@ final class GameBetSettleService ->select() ->toArray(); + /** @var array}> */ + $aggregateByUser = []; + foreach ($bets as $bet) { $betId = (int) ($bet['id'] ?? 0); if ($betId <= 0) { @@ -59,11 +62,62 @@ final class GameBetSettleService // 结算刚刚成功(status 1 → 2):把本单下注总额 1:1 累加到用户打码量 self::creditUserBetFlow($bet, $now); - if (bccomp($win, '0', 4) <= 0) { + $userId = (int) ($bet['user_id'] ?? 0); + if ($userId <= 0) { continue; } - self::creditUserPayout($bet, $betId, $win, $now); + $balanceAfter = (string) (Db::name('user')->where('id', $userId)->value('coin') ?? '0'); + if (bccomp($win, '0', 4) > 0) { + $paid = self::creditUserPayout($bet, $betId, $win, $now); + if ($paid !== null) { + $balanceAfter = $paid; + } + } + + $periodNo = (string) ($bet['period_no'] ?? ''); + if (!isset($aggregateByUser[$userId])) { + $aggregateByUser[$userId] = [ + 'period_no' => $periodNo, + 'total_win' => '0.0000', + 'balance_after' => $balanceAfter, + 'orders' => [], + ]; + } + $aggregateByUser[$userId]['total_win'] = bcadd($aggregateByUser[$userId]['total_win'], $win, 4); + $aggregateByUser[$userId]['balance_after'] = $balanceAfter; + $aggregateByUser[$userId]['orders'][] = [ + 'order_no' => (string) $betId, + 'win_amount' => $win, + 'hit' => bccomp($win, '0', 4) > 0, + ]; + } + + foreach ($aggregateByUser as $userId => $agg) { + $hitOrderCount = 0; + foreach ($agg['orders'] as $o) { + if (($o['hit'] ?? false) === true) { + $hitOrderCount++; + } + } + UserPushService::publish((int) $userId, UserPushService::EVT_BET_SETTLED, [ + 'period_no' => $agg['period_no'], + 'result_number' => $resultNumber, + 'total_win_amount' => $agg['total_win'], + 'order_count' => count($agg['orders']), + 'hit_order_count' => $hitOrderCount, + 'balance_after' => $agg['balance_after'], + ]); + + if (bccomp($agg['total_win'], '0', 4) > 0) { + UserPushService::publish((int) $userId, UserPushService::EVT_WALLET_CHANGED, [ + 'reason' => 'payout', + 'ref_type' => 'game_period', + 'ref_id' => (string) $recordId, + 'delta' => $agg['total_win'], + 'balance_after' => $agg['balance_after'], + ]); + } } } @@ -161,21 +215,26 @@ final class GameBetSettleService ]); } - private static function creditUserPayout(array $bet, int $betId, string $winAmount, int $now): void + /** + * @return string|null 派彩后余额;已幂等入账过时返回当前余额;失败或未执行派彩返回 null + */ + private static function creditUserPayout(array $bet, int $betId, string $winAmount, int $now): ?string { $userId = (int) ($bet['user_id'] ?? 0); if ($userId <= 0) { - return; + return null; } $idem = 'payout_bet_' . $betId; if (Db::name('user_wallet_record')->where('idempotency_key', $idem)->value('id')) { - return; + $coin = Db::name('user')->where('id', $userId)->value('coin'); + + return (string) ($coin ?? '0'); } $user = Db::name('user')->where('id', $userId)->find(); if (!$user) { - return; + return null; } $before = (string) ($user['coin'] ?? '0'); @@ -201,5 +260,7 @@ final class GameBetSettleService 'coin' => $after, 'update_time' => $now, ]); + + return $after; } } diff --git a/app/common/service/GameLiveService.php b/app/common/service/GameLiveService.php index d7c3234..b1764d9 100644 --- a/app/common/service/GameLiveService.php +++ b/app/common/service/GameLiveService.php @@ -19,6 +19,7 @@ final class GameLiveService 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 KEY_PERIOD_SECONDS = 'period_seconds'; private const KEY_BET_SECONDS = 'bet_seconds'; private const KEY_PICK_MAX_NUMBER_COUNT = 'pick_max_number_count'; @@ -26,26 +27,21 @@ final class GameLiveService /** 开奖结果号码池:1 至此上限(与单注可选号码个数配置无关) */ private const DRAW_NUMBER_MAX = 36; + /** 开奖后派彩展示宽限期(秒),之后再创建下一期 */ + private const PAYOUT_GRACE_SECONDS = 3; + public static function buildSnapshot(?int $recordId = null): array { $record = self::resolveRecord($recordId); if (!$record) { - return [ - 'record' => null, - 'bets' => [], - 'candidate_numbers' => [], - 'ai_default_number' => null, - 'calc_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, - 'can_calculate' => false, - 'can_draw' => false, - 'server_time' => time(), - ]; + 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); @@ -54,19 +50,22 @@ final class GameLiveService $elapsed = max(0, time() - (int) $record['period_start_at']); $remaining = max(0, $periodSeconds - $elapsed); $betRemaining = max(0, $betSeconds - $elapsed); + $status = (int) $record['status']; + + $payoutUntil = isset($record['payout_until']) ? (int) $record['payout_until'] : 0; + $payoutRemaining = 0; + if ($status === 3 && $payoutUntil > 0) { + $payoutRemaining = max(0, $payoutUntil - time()); + } $bets = Db::name('bet_order') - ->where('period_id', (int) $record['id']) + ->where('period_id', $rid) ->order('id', 'desc') ->limit(200) ->select() ->toArray(); $candidates = []; - $bestNumber = null; - $bestLoss = null; - $bestNumbers = []; - $status = (int) $record['status']; $canCalculate = $elapsed >= $betSeconds && ($status === 0 || $status === 1); if ($canCalculate) { for ($n = 1; $n <= self::DRAW_NUMBER_MAX; $n++) { @@ -75,18 +74,28 @@ final class GameLiveService 'number' => $n, 'estimated_loss' => $loss, ]; - if ($bestLoss === null || bccomp((string) $loss, (string) $bestLoss, 4) < 0) { - $bestLoss = $loss; - $bestNumbers = [$n]; - continue; - } - if (bccomp((string) $loss, (string) $bestLoss, 4) === 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; + } + + $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; + return [ 'record' => $record, 'bets' => array_map(static function (array $row): array { @@ -101,16 +110,47 @@ final class GameLiveService ]; }, $bets), 'candidate_numbers' => $candidates, - 'ai_default_number' => $bestNumber, - 'calc_number' => $bestNumber, + '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' => $status === 3, 'can_calculate' => $canCalculate, - 'can_draw' => $canCalculate, + 'can_draw' => $canScheduleDraw, + 'can_schedule_draw' => $canScheduleDraw, + 'server_time' => time(), + ]; + } + + /** + * @return array + */ + private static function emptySnapshotPayload(): array + { + 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, + 'can_calculate' => false, + 'can_draw' => false, + 'can_schedule_draw' => false, 'server_time' => time(), ]; } @@ -130,12 +170,11 @@ final class GameLiveService if ($elapsed < $betSeconds) { return ['ok' => false, 'msg' => '下注开放时长未结束,暂不可计算']; } - if ((int) $record['status'] === 0) { - Db::name('game_record')->where('id', (int) $record['id'])->update([ - 'status' => 1, - 'update_time' => time(), - ]); - $record['status'] = 1; + + self::ensureAiLocked((int) $record['id']); + $record = self::reloadRecord((int) $record['id']); + if (!$record) { + return ['ok' => false, 'msg' => '未找到进行中的对局']; } $pickMax = self::getPickMaxNumberCount(); @@ -162,6 +201,12 @@ final class GameLiveService } $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.0000'; if ($finalNumber !== null) { @@ -177,31 +222,122 @@ final class GameLiveService 'pick_max_number_count' => $pickMax, 'draw_number_max' => self::DRAW_NUMBER_MAX, 'candidate_numbers' => $candidates, - 'ai_default_number' => $bestNumber, + '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' => '开奖号码超出允许范围']; + } + $record = self::resolveRecord($recordId); + if (!$record) { + return ['ok' => false, 'msg' => '未找到进行中的对局']; + } + if (!in_array((int) $record['status'], [0, 1], true)) { + return ['ok' => false, 'msg' => '当前对局状态不可预约开奖']; + } + $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' => '下注尚未结束,无法预约开奖']; + } + if ($elapsed >= $periodSeconds) { + return ['ok' => false, 'msg' => '本期倒计时已结束,请刷新页面']; + } + + self::ensureAiLocked((int) $record['id']); + Db::name('game_record')->where('id', (int) $record['id'])->update([ + 'pending_draw_number' => $manualNumber, + 'update_time' => time(), + ]); + self::publishSnapshot(null); + + return [ + 'ok' => true, + 'msg' => '已预约本期开奖号码,倒计时结束后将使用该号码开奖', + ]; + } + + /** + * 倒计时结束自动开奖(AI 或预约号码);派彩宽限期后由 finalizePayoutGrace 结单并开下一期。 + */ public static function drawResult(?int $recordId, ?int $manualNumber = null): array { - $calc = self::calculateResult($recordId, $manualNumber); - if (!($calc['ok'] ?? false)) { - return $calc; + $record = self::resolveRecord($recordId); + if (!$record) { + return ['ok' => false, 'msg' => '未找到进行中的对局']; } - $record = $calc['record']; - $finalNumber = (int) $calc['final_number']; + if (!in_array((int) $record['status'], [0, 1], true)) { + return ['ok' => false, 'msg' => '当前对局状态不可开奖']; + } + $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' => '下注开放时长未结束,不可开奖']; + } + if ($elapsed < $periodSeconds) { + return ['ok' => false, 'msg' => '本期倒计时未结束,无法开奖']; + } + + self::ensureAiLocked((int) $record['id']); + $record = self::reloadRecord((int) $record['id']); + if (!$record) { + return ['ok' => false, 'msg' => '未找到进行中的对局']; + } + + $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::PAYOUT_GRACE_SECONDS; + Db::startTrans(); try { Db::name('game_record')->where('id', (int) $record['id'])->update([ - 'status' => 4, + 'status' => 3, 'result_number' => $finalNumber, - 'draw_mode' => $manualNumber === null ? 0 : 1, + 'draw_mode' => $drawMode, + 'pending_draw_number' => null, + 'payout_until' => $payoutUntil, 'update_time' => $now, ]); GameBetSettleService::settleBetsForDraw((int) $record['id'], $finalNumber); - GameRecordService::createNextRecordAfterDraw(); Db::commit(); GameRecordStatService::refreshForRecordId((int) $record['id']); } catch (Throwable $e) { @@ -210,16 +346,50 @@ final class GameLiveService } self::publishPublicPeriodOpened((string) $record['period_no'], $finalNumber, $now); - + self::publishPublicPeriodPayout((string) $record['period_no'], $finalNumber, $payoutUntil); self::publishSnapshot(null); + return [ 'ok' => true, - 'msg' => '开奖完成', + 'msg' => '开奖完成,派彩中', 'result_number' => $finalNumber, - 'estimated_loss' => $calc['final_estimated_loss'], + 'estimated_loss' => $finalLoss, + 'payout_until' => $payoutUntil, ]; } + /** + * 派彩宽限期结束:将本期置为已结束并创建下一期。 + */ + public static function finalizePayoutGrace(): void + { + $row = Db::name('game_record') + ->where('status', 3) + ->where('payout_until', '>', 0) + ->where('payout_until', '<=', time()) + ->order('id', 'desc') + ->find(); + if (!$row) { + return; + } + $id = (int) $row['id']; + Db::startTrans(); + try { + Db::name('game_record')->where('id', $id)->update([ + 'status' => 4, + 'payout_until' => null, + 'update_time' => time(), + ]); + GameRecordService::createNextRecordAfterDraw(); + Db::commit(); + } catch (Throwable) { + Db::rollback(); + return; + } + GameRecordStatService::refreshForRecordId($id); + self::publishSnapshot(null); + } + public static function tickAutoDraw(): void { $record = self::resolveRecord(null); @@ -229,14 +399,12 @@ final class GameLiveService $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']); - if ($elapsed >= $betSeconds && (int) $record['status'] === 0) { - Db::name('game_record')->where('id', (int) $record['id'])->update([ - 'status' => 1, - 'update_time' => time(), - ]); - $record['status'] = 1; - self::publishPublicPeriodLocked($record); + 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; } @@ -272,6 +440,8 @@ final class GameLiveService $serverTime = (int) ($snapshot['server_time'] ?? time()); $remaining = (int) ($snapshot['remaining_seconds'] ?? 0); $betCloseIn = (int) ($snapshot['bet_remaining_seconds'] ?? 0); + $payoutRem = (int) ($snapshot['payout_remaining_seconds'] ?? 0); + $isPayout = !empty($snapshot['is_payout_phase']); $periodNo = ''; $dbStatus = 0; $resultNumber = null; @@ -292,6 +462,9 @@ final class GameLiveService 'status' => $status, 'countdown' => $remaining, 'bet_close_in'=> $betCloseIn, + 'payout_remaining_seconds' => $payoutRem, + 'is_payout_phase' => $isPayout, + 'payout_message' => $isPayout ? '派彩中,请稍候' : '', ]; if ($periodNo !== '' && $record !== null) { $start = (int) ($record['period_start_at'] ?? 0); @@ -341,6 +514,24 @@ final class GameLiveService } } + /** + * 派彩阶段开始(开奖后宽限期内推送) + */ + private static function publishPublicPeriodPayout(string $periodNo, int $resultNumber, int $payoutUntil): void + { + try { + $payload = [ + 'period_no' => $periodNo, + 'result_number' => $resultNumber, + 'payout_until' => $payoutUntil, + 'message' => '派彩中,请稍候', + ]; + $api = self::createPushApi(); + $api->trigger(self::CHANNEL_PUBLIC_GAME_PERIOD, self::EVT_PERIOD_PAYOUT, $payload); + } catch (Throwable) { + } + } + /** * 与文档 3.1/4.1 中 status 字符串对齐:betting / locked / settling / finished */ @@ -355,7 +546,10 @@ final class GameLiveService if ($dbStatus === 4) { return 'finished'; } - if ($dbStatus === 2 || $dbStatus === 3) { + if ($dbStatus === 3) { + return 'payouting'; + } + if ($dbStatus === 2) { return 'settling'; } @@ -373,6 +567,84 @@ final class GameLiveService return Db::name('game_record')->whereIn('status', [0, 1, 2, 3])->order('id', 'desc')->find(); } + private static function reloadRecord(int $id): ?array + { + $row = Db::name('game_record')->where('id', $id)->find(); + return $row ?: null; + } + + /** + * 封盘后计算并锁定 AI 号码(本期不变),并封盘(status 0→1)。 + */ + private static function ensureAiLocked(int $recordId): void + { + $record = Db::name('game_record')->where('id', $recordId)->find(); + 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(), + ]); + $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); + $record = array_merge($record, $update); + if ($st === 0) { + self::publishPublicPeriodLocked($record); + } + } + + /** + * @param array> $bets + */ + private static function computeBestNumberFromBets(array $bets): ?int + { + $bestLoss = null; + $bestNumbers = []; + for ($n = 1; $n <= self::DRAW_NUMBER_MAX; $n++) { + $loss = self::estimateLossForNumber($bets, $n); + if ($bestLoss === null || bccomp((string) $loss, (string) $bestLoss, 4) < 0) { + $bestLoss = $loss; + $bestNumbers = [$n]; + continue; + } + if (bccomp((string) $loss, (string) $bestLoss, 4) === 0) { + $bestNumbers[] = $n; + } + } + return self::pickRandomNumber($bestNumbers); + } + private static function getConfigInt(string $key, int $default): int { $row = Db::name('game_config')->where('config_key', $key)->find(); diff --git a/app/common/service/UserPushService.php b/app/common/service/UserPushService.php new file mode 100644 index 0000000..24851bf --- /dev/null +++ b/app/common/service/UserPushService.php @@ -0,0 +1,61 @@ +where('id', $userId)->value('uuid'); + + return is_string($u) && $u !== '' ? $u : null; + } + + /** + * @param array $data + */ + public static function publish(int $userId, string $event, array $data): void + { + $uuid = self::uuidForUserId($userId); + if ($uuid === null) { + return; + } + try { + self::createApi()->trigger(self::channelName($uuid), $event, $data); + } catch (Throwable) { + } + } +} diff --git a/app/process/GameLiveTicker.php b/app/process/GameLiveTicker.php index 4c5c535..f335b63 100644 --- a/app/process/GameLiveTicker.php +++ b/app/process/GameLiveTicker.php @@ -13,6 +13,7 @@ class GameLiveTicker public function onWorkerStart(): void { Timer::add(1, static function (): void { + GameLiveService::finalizePayoutGrace(); GameLiveService::tickAutoDraw(); GameLiveService::publishSnapshot(null); }); diff --git a/web/src/lang/backend/en/game/live.ts b/web/src/lang/backend/en/game/live.ts index d2bfc5a..634cfe4 100644 --- a/web/src/lang/backend/en/game/live.ts +++ b/web/src/lang/backend/en/game/live.ts @@ -1,12 +1,16 @@ export default { - tip: 'Listen to pushed bet stream in real time and show the AI default number (minimum estimated platform loss).', + tip: 'Realtime bets; after lock the AI default number is fixed for this round and used at countdown end (or your scheduled number). After draw, ~3s payout grace then next round.', current_record: 'Current round', ai_default_number: 'AI default number', + pending_draw: 'Scheduled draw number', countdown: 'Countdown', bet_countdown: 'Bet left', draw_countdown: 'Draw left', + payout_countdown: 'Payout left', + payout_na: '—', + payout_phase: 'Payout in progress', btn_calc: 'Calculate PnL', - btn_draw: 'Draw now', + btn_draw: 'Schedule draw', calc_result_number: 'Calculated number', calc_estimated_loss: 'Estimated payout', push_connected: 'Push connected, realtime updates running', diff --git a/web/src/lang/backend/en/test/pushGamePeriod.ts b/web/src/lang/backend/en/test/pushGamePeriod.ts index 86233ba..9d4533b 100644 --- a/web/src/lang/backend/en/test/pushGamePeriod.ts +++ b/web/src/lang/backend/en/test/pushGamePeriod.ts @@ -1,3 +1,3 @@ export default { - tip: 'Subscribe to public-game-period (global period channel) for period.tick / period.locked / period.opened. The server must publish to this channel.', + tip: 'Subscribe to public-game-period (global period channel) for period.tick / period.locked / period.opened / period.payout. The server must publish to this channel.', } diff --git a/web/src/lang/backend/zh-cn/game/live.ts b/web/src/lang/backend/zh-cn/game/live.ts index b68f475..153e9a3 100644 --- a/web/src/lang/backend/zh-cn/game/live.ts +++ b/web/src/lang/backend/zh-cn/game/live.ts @@ -1,12 +1,16 @@ export default { - tip: '实时监听页面推送的压注记录,并展示AI默认最优开奖号码(平台预估亏损最少)', + tip: '实时监听压注记录;封盘后 AI 默认号码会锁定,本期倒计时结束按该号码(或您预约的号码)开奖;开奖后约 3 秒派彩再进入下一期。', current_record: '当前对局', ai_default_number: 'AI默认开奖号码', + pending_draw: '已预约开奖号码', countdown: '倒计时', bet_countdown: '下注剩余', draw_countdown: '开奖剩余', + payout_countdown: '派彩剩余', + payout_na: '—', + payout_phase: '派彩中,请稍候', btn_calc: '计算法盈亏', - btn_draw: '开奖', + btn_draw: '预约开奖', calc_result_number: '计算开奖号码', calc_estimated_loss: '计算预估赔付', push_connected: '推送服务已连接,页面数据实时更新中', diff --git a/web/src/lang/backend/zh-cn/test/pushGamePeriod.ts b/web/src/lang/backend/zh-cn/test/pushGamePeriod.ts index a9ea69e..49f4a61 100644 --- a/web/src/lang/backend/zh-cn/test/pushGamePeriod.ts +++ b/web/src/lang/backend/zh-cn/test/pushGamePeriod.ts @@ -1,3 +1,3 @@ export default { - tip: '订阅文档中的「全局对局频道」public-game-period,用于验证 period.tick / period.locked / period.opened 等公共事件(需服务端向该频道推送)。', + tip: '订阅文档中的「全局对局频道」public-game-period,用于验证 period.tick / period.locked / period.opened / period.payout 等公共事件(需服务端向该频道推送)。', } diff --git a/web/src/utils/backend/pushChannelTest.ts b/web/src/utils/backend/pushChannelTest.ts index 6ce4aa0..87d1986 100644 --- a/web/src/utils/backend/pushChannelTest.ts +++ b/web/src/utils/backend/pushChannelTest.ts @@ -6,7 +6,9 @@ const DOC_EVENTS = [ 'period.tick', 'period.locked', 'period.opened', + 'period.payout', 'bet.accepted', + 'bet.settled', 'wallet.changed', 'notice.popout', 'withdraw.review_required', diff --git a/web/src/views/backend/game/live/index.vue b/web/src/views/backend/game/live/index.vue index 058fe91..c9f0e81 100644 --- a/web/src/views/backend/game/live/index.vue +++ b/web/src/views/backend/game/live/index.vue @@ -4,10 +4,14 @@ +
{{ t('game.live.current_record') }}: {{ snapshot.record?.period_no || '-' }}
{{ t('game.live.ai_default_number') }}: {{ snapshot.ai_default_number ?? '-' }}
+
+ {{ t('game.live.pending_draw') }}: {{ snapshot.pending_draw_number }} +
{{ t('game.live.countdown') }}: {{ countdownText }}
@@ -15,7 +19,7 @@ {{ t('game.live.btn_calc') }} - + {{ t('game.live.btn_draw') }} {{ t('Refresh') }} @@ -67,6 +71,7 @@ interface Snapshot { bets: anyObj[] candidate_numbers: anyObj[] ai_default_number: number | null + pending_draw_number: number | null period_seconds?: number bet_seconds?: number pick_max_number_count?: number @@ -74,8 +79,12 @@ interface Snapshot { draw_number_max?: number remaining_seconds?: number bet_remaining_seconds?: number + payout_remaining_seconds?: number + is_payout_phase?: boolean can_calculate?: boolean can_draw?: boolean + can_schedule_draw?: boolean + server_time?: number } const { t } = useI18n() @@ -87,14 +96,18 @@ const snapshot = reactive({ bets: [], candidate_numbers: [], ai_default_number: null, + pending_draw_number: null, period_seconds: 30, bet_seconds: 20, pick_max_number_count: 10, draw_number_max: 36, remaining_seconds: 0, bet_remaining_seconds: 0, + payout_remaining_seconds: 0, + is_payout_phase: false, can_calculate: false, can_draw: false, + can_schedule_draw: false, }) const calcLoading = ref(false) const drawLoading = ref(false) @@ -102,6 +115,12 @@ const manualNumber = ref(1) const calcResultNumber = ref(null) const calcEstimatedLoss = ref('0.0000') +/** 服务端 Unix 秒 − 本地 Unix 秒,用于派彩倒计时与服务器对齐 */ +const serverSkewSeconds = ref(0) +/** 每秒递增,驱动派彩剩余秒本地刷新 */ +const clockTick = ref(0) +let clockTimer: number | null = null + let pushClient: any = null let pushChannel: any = null let pollTimer: number | null = null @@ -113,6 +132,45 @@ function formatPicks(v: unknown): string { return '-' } +function syncServerClock(serverTime: unknown): void { + if (typeof serverTime === 'number' && Number.isFinite(serverTime)) { + serverSkewSeconds.value = serverTime - Math.floor(Date.now() / 1000) + snapshot.server_time = serverTime + } +} + +function readPayoutUntilUnix(rec: anyObj | null): number | null { + if (!rec) { + return null + } + const v = rec.payout_until + if (v === null || v === undefined || v === '') { + return null + } + if (typeof v === 'number' && Number.isFinite(v)) { + return v + } + if (typeof v === 'string' && /^\d+$/.test(v)) { + return parseInt(v, 10) + } + return null +} + +/** 派彩剩余秒:优先用 payout_until 与对时后的「服务器当前秒」计算,便于每秒递减 */ +const payoutRemainingLive = computed(() => { + clockTick.value + if (!snapshot.is_payout_phase) { + return null + } + const until = readPayoutUntilUnix(snapshot.record) + if (until !== null) { + const serverNow = Math.floor(Date.now() / 1000) + serverSkewSeconds.value + const diff = until - serverNow + return diff > 0 ? diff : 0 + } + return snapshot.payout_remaining_seconds ?? 0 +}) + async function loadSnapshot() { loading.value = true try { @@ -122,14 +180,20 @@ async function loadSnapshot() { snapshot.bets = res.data.bets || [] snapshot.candidate_numbers = res.data.candidate_numbers || [] snapshot.ai_default_number = res.data.ai_default_number + snapshot.pending_draw_number = + typeof res.data.pending_draw_number === 'number' ? res.data.pending_draw_number : null snapshot.period_seconds = res.data.period_seconds ?? 30 snapshot.bet_seconds = res.data.bet_seconds ?? 20 snapshot.pick_max_number_count = res.data.pick_max_number_count ?? 10 snapshot.draw_number_max = res.data.draw_number_max ?? 36 snapshot.remaining_seconds = res.data.remaining_seconds ?? 0 snapshot.bet_remaining_seconds = res.data.bet_remaining_seconds ?? 0 + snapshot.payout_remaining_seconds = res.data.payout_remaining_seconds ?? 0 + snapshot.is_payout_phase = !!res.data.is_payout_phase snapshot.can_calculate = !!res.data.can_calculate snapshot.can_draw = !!res.data.can_draw + snapshot.can_schedule_draw = !!res.data.can_schedule_draw || !!res.data.can_draw + syncServerClock(res.data.server_time) const dmax = res.data.draw_number_max ?? 36 if (manualNumber.value === null || manualNumber.value < 1 || manualNumber.value > dmax) manualNumber.value = 1 } @@ -172,14 +236,20 @@ async function initPush() { snapshot.bets = payload.bets || [] snapshot.candidate_numbers = payload.candidate_numbers || [] snapshot.ai_default_number = payload.ai_default_number ?? null + snapshot.pending_draw_number = + typeof payload.pending_draw_number === 'number' ? payload.pending_draw_number : null snapshot.period_seconds = payload.period_seconds ?? 30 snapshot.bet_seconds = payload.bet_seconds ?? 20 snapshot.pick_max_number_count = payload.pick_max_number_count ?? 10 snapshot.draw_number_max = payload.draw_number_max ?? 36 snapshot.remaining_seconds = payload.remaining_seconds ?? 0 snapshot.bet_remaining_seconds = payload.bet_remaining_seconds ?? 0 + snapshot.payout_remaining_seconds = payload.payout_remaining_seconds ?? 0 + snapshot.is_payout_phase = !!payload.is_payout_phase snapshot.can_calculate = !!payload.can_calculate snapshot.can_draw = !!payload.can_draw + snapshot.can_schedule_draw = !!payload.can_schedule_draw || !!payload.can_draw + syncServerClock(payload.server_time) }) } catch { pushConnected.value = false @@ -244,12 +314,19 @@ async function onDraw() { } const countdownText = computed(() => { - const total = snapshot.remaining_seconds ?? 0 const bet = snapshot.bet_remaining_seconds ?? 0 - return `${t('game.live.bet_countdown')} ${bet}s / ${t('game.live.draw_countdown')} ${total}s` + const draw = snapshot.remaining_seconds ?? 0 + let payoutPart = t('game.live.payout_na') + if (snapshot.is_payout_phase && payoutRemainingLive.value !== null) { + payoutPart = `${payoutRemainingLive.value}s` + } + return `${t('game.live.bet_countdown')} ${bet}s / ${t('game.live.draw_countdown')} ${draw}s / ${t('game.live.payout_countdown')} ${payoutPart}` }) onMounted(async () => { + clockTimer = window.setInterval(() => { + clockTick.value++ + }, 1000) await loadSnapshot() try { await initPush() @@ -269,6 +346,10 @@ onUnmounted(() => { } stopPolling() stopPushWatchdog() + if (clockTimer !== null) { + window.clearInterval(clockTimer) + clockTimer = null + } }) function startPolling() { diff --git a/web/src/views/backend/test/components/PushChannelTestPage.vue b/web/src/views/backend/test/components/PushChannelTestPage.vue index f5122ef..8627ff2 100644 --- a/web/src/views/backend/test/components/PushChannelTestPage.vue +++ b/web/src/views/backend/test/components/PushChannelTestPage.vue @@ -63,7 +63,7 @@ const logText = computed(() => { }) function appendLog(line: PushTestLogLine) { - logs.value = [...logs.value, line].slice(-200) + logs.value = [line, ...logs.value].slice(0, 200) } function clearLogs() {