From 7edc3ec010162bd06dff1d5f06c6a59162b45694 Mon Sep 17 00:00:00 2001 From: zhenhui <1276357500@qq.com> Date: Tue, 26 May 2026 11:26:09 +0800 Subject: [PATCH] =?UTF-8?q?1.=E4=BC=98=E5=8C=96ws=E6=B8=B8=E6=88=8F?= =?UTF-8?q?=E5=AF=B9=E5=B1=80=E6=8E=A8=E9=80=81=E6=B6=88=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/common/service/GameLiveService.php | 122 ++++++++++++++++++++++++- docs/36字花-移动端接口设计草案.md | 2 +- 2 files changed, 119 insertions(+), 5 deletions(-) diff --git a/app/common/service/GameLiveService.php b/app/common/service/GameLiveService.php index f8cef97..15a4476 100644 --- a/app/common/service/GameLiveService.php +++ b/app/common/service/GameLiveService.php @@ -24,6 +24,8 @@ final class GameLiveService 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:'; @@ -252,16 +254,16 @@ final class GameLiveService self::publishSnapshot(null); } - public static function buildSnapshot(?int $recordId = null): array + public static function buildSnapshot(?int $recordId = null, bool $freshFromDb = false): array { - $record = self::resolveRecord($recordId); + $record = $freshFromDb ? self::resolveRecordFromDb($recordId) : self::resolveRecord($recordId); if (!$record) { return self::emptySnapshotPayload(); } $rid = (int) $record['id']; self::ensureAiLocked($rid); - $record = self::reloadRecord($rid); + $record = $freshFromDb ? self::reloadRecordFromDb($rid) : self::reloadRecord($rid); if (!$record) { return self::emptySnapshotPayload(); } @@ -681,8 +683,11 @@ final class GameLiveService } GameHotDataCoordinator::afterGameRecordCommitted($id); GameRecordStatService::refreshForRecordId($id); + GameHotDataRedis::gameRecordForget($id); + GameHotDataRedis::gameRecordForget(null); self::publishPublicPeriodFinished($id, $periodNo, $resultNumber); self::publishSnapshot(null); + self::publishImmediateBettingTickAfterFinalize(); } finally { GameHotDataLock::release(GameHotDataLock::TYPE_GAME_RECORD, (string) $id, $lock['token'], $lock['redis_lock']); } @@ -881,10 +886,87 @@ final class GameLiveService public static function publishSnapshot(?int $recordId = null): void { - $snapshot = self::buildSnapshot($recordId); + $snapshot = self::buildSnapshot($recordId, true); + 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; + } + $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'] : ''; + $now = time(); + $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 = self::resolveRecordFromDb(null); + 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 语义) */ @@ -1088,12 +1170,44 @@ final class GameLiveService return GameHotDataRedis::gameRecordActive(); } + /** + * WebSocket 推送专用:始终读库,避免 game_record 热缓存仍指向上一期 payouting 导致长时间不推 period.tick。 + * + * @return array|null + */ + private static function resolveRecordFromDb(?int $recordId): ?array + { + if ($recordId !== null && $recordId > 0) { + $row = Db::name('game_record')->where('id', $recordId)->find(); + return is_array($row) ? $row : null; + } + $row = Db::name('game_record') + ->whereIn('status', [0, 1, 2, 3]) + ->order('id', 'desc') + ->find(); + + return is_array($row) ? $row : null; + } + private static function reloadRecord(int $id): ?array { $row = GameHotDataRedis::gameRecordById($id); return $row ?: null; } + /** + * @return array|null + */ + private static function reloadRecordFromDb(int $id): ?array + { + if ($id <= 0) { + return null; + } + $row = Db::name('game_record')->where('id', $id)->find(); + + return is_array($row) ? $row : null; + } + /** * 封盘后计算并锁定 AI 号码(本期不变),并封盘(status 0→1)。 */ diff --git a/docs/36字花-移动端接口设计草案.md b/docs/36字花-移动端接口设计草案.md index d46b2b2..0ce1ccc 100644 --- a/docs/36字花-移动端接口设计草案.md +++ b/docs/36字花-移动端接口设计草案.md @@ -824,7 +824,7 @@ #### 7.1.3 推送频率与触发规则(当前实现) - `period.tick`:**仅在 `status ∈ {betting, locked}` 时每秒推送**(用于倒计时、状态同步;**不含**赔率全表)。 - - **派彩静默期**:`status=payouting`(开奖到派彩宽限期结束)期间**完全不推**,避免覆盖前端的中奖动画/弹窗。中奖玩家依靠同时触发的 `period.opened` / `period.payout` / `jackpot.hit` / `wallet.changed(biz_type=payout)` 完成展示。 + - **派彩静默期**:`status=payouting` 期间**不推** `period.tick`(避免彩池/倒计时干扰中奖动画)。改为每秒推送 **`period.payout.tick`**,仅含 `payout_remaining_seconds` / `payout_until` 等派彩倒计时字段。中奖展示仍靠 `period.opened` / `period.payout` / `jackpot.hit` / `wallet.changed(biz_type=payout)`。 - **边界帧(每期仅一次)**: - `status=finished`:派彩宽限期结束、本期进入收尾时推送一帧,告知前端可以清理本期 UI。 - `status=void`:本期被作废时推送一帧作为通知。