From e163090bc21efa6cebd462324d80f0e2a8797c24 Mon Sep 17 00:00:00 2001 From: zhenhui <1276357500@qq.com> Date: Tue, 26 May 2026 18:44:42 +0800 Subject: [PATCH] =?UTF-8?q?1.ws=E4=BC=98=E5=8C=96bet.win=E8=AE=A2=E9=98=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env-example | 5 +- app/common/service/GameBetSettleService.php | 180 ++++++++++++++++---- docs/36字花-数据库与实施计划.md | 24 ++- docs/36字花-移动端接口设计草案.md | 50 ++++-- 4 files changed, 207 insertions(+), 52 deletions(-) diff --git a/.env-example b/.env-example index da27b98..02008c4 100644 --- a/.env-example +++ b/.env-example @@ -40,8 +40,9 @@ GAME_HOT_CACHE_QUEUE_CONSUMER_BATCH = 80 # 移动端接口鉴权(/api/v1/authToken) AUTH_TOKEN_SECRET = -# H5/后台联调共用:WebSocket 连接地址(建议带 /ws/ 路径) -# HTTPS 域名请使用 wss:// +# H5/后台联调共用:WebSocket 连接地址(建议带 /ws/ 路径,进程 gameWebSocketServer 默认监听 3131) +# HTTPS 页面必须用 wss://;生产勿填 127.0.0.1(外网浏览器无法连接,服务端会按请求 Host 推导 ws(s)://域名/ws/) +# Nginx 需将 location /ws/ 反代到 websocket://127.0.0.1:3131 # 示例:H5_WEBSOCKET_URL = wss://zihua-api.h55555game.top/ws/ H5_WEBSOCKET_URL = wss://zihua-api.h55555game.top/ws/ diff --git a/app/common/service/GameBetSettleService.php b/app/common/service/GameBetSettleService.php index 47a5f24..648a745 100644 --- a/app/common/service/GameBetSettleService.php +++ b/app/common/service/GameBetSettleService.php @@ -32,6 +32,9 @@ final class GameBetSettleService /** 每期结算推送去重(避免事务重试 / recover 重复推 user.streak、wallet.changed) */ private const SETTLE_NOTIFY_DEDUP_PREFIX = 'dfw:v1:settle:notify:'; + /** 每期每用户 bet.win 去重(与 streak/wallet 分离,避免整期 dedup 吞掉中奖推送) */ + private const BET_WIN_NOTIFY_DEDUP_PREFIX = 'dfw:v1:ws:betwin:'; + /** * 对指定期次按开奖号码结算所有「待开奖」注单;同一注单幂等(仅 status=1 会更新)。 * @@ -310,26 +313,88 @@ final class GameBetSettleService * * @param list> $betWins */ - public static function publishBetWinsAfterCommit(array $betWins): void + public static function publishBetWinsAfterCommit(array $betWins, int $periodId = 0): void { $now = time(); foreach ($betWins as $payload) { - if (!is_array($payload) || empty($payload['user_id'])) { + if (!is_array($payload)) { continue; } - $userId = filter_var($payload['user_id'], FILTER_VALIDATE_INT); + $userId = filter_var($payload['user_id'] ?? 0, FILTER_VALIDATE_INT); if ($userId === false || $userId <= 0) { continue; } + if ($periodId > 0 && !self::markBetWinNotifyOnce($periodId, $userId)) { + continue; + } $isJackpot = !empty($payload['is_jackpot']); - $data = array_merge($payload, [ + $data = GameWebSocketPayloadHelper::mergeUserStreakInto(array_merge($payload, [ 'is_jackpot' => $isJackpot, + 'is_win' => true, 'server_time' => $now, - ]); + ]), $userId); GameWebSocketEventBus::publish(self::TOPIC_BET_WIN, $data); } } + /** + * 从库内已结算中奖注单重建 bet.win 载荷(结算内存聚合缺失或推送被 dedup 拦截时的补偿)。 + * + * @return list> + */ + public static function buildBetWinPayloadsFromSettledOrders(int $periodId, int $resultNumber): array + { + if ($periodId <= 0 || $resultNumber < 1) { + return []; + } + $rows = Db::name('bet_order') + ->where('period_id', $periodId) + ->whereIn('status', [self::PLAY_STATUS_SETTLED, self::PLAY_STATUS_PENDING_REVIEW]) + ->order('id', 'asc') + ->select() + ->toArray(); + + /** @var array> $winByUser */ + $winByUser = []; + foreach ($rows as $bet) { + if (!is_array($bet)) { + continue; + } + $win = bcadd((string) ($bet['win_amount'] ?? '0'), '0', 2); + if (bccomp($win, '0', 2) <= 0) { + continue; + } + $userId = filter_var($bet['user_id'] ?? 0, FILTER_VALIDATE_INT); + if ($userId === false || $userId <= 0) { + continue; + } + if (!isset($winByUser[$userId])) { + $coin = Db::name('user')->where('id', $userId)->value('coin'); + $winByUser[$userId] = [ + 'user_id' => $userId, + 'period_id' => $periodId, + 'period_no' => (string) ($bet['period_no'] ?? ''), + 'result_number' => $resultNumber, + 'total_win' => '0.00', + 'balance_after' => (string) ($coin ?? '0'), + 'is_jackpot' => false, + 'bets' => [], + ]; + } + if (StreakWinReward::isJackpotForStreakAtBet((int) ($bet['streak_at_bet'] ?? 0))) { + $winByUser[$userId]['is_jackpot'] = true; + } + $winByUser[$userId]['total_win'] = bcadd((string) $winByUser[$userId]['total_win'], $win, 2); + $betId = filter_var($bet['id'] ?? 0, FILTER_VALIDATE_INT); + $winByUser[$userId]['bets'][] = [ + 'bet_id' => $betId === false ? 0 : $betId, + 'win_amount' => $win, + ]; + } + + return array_values($winByUser); + } + /** * 大奖档命中时额外推送公共频道 jackpot.hit(与 bet.win 同一结算时刻,先后发出)。 * @@ -364,47 +429,73 @@ final class GameBetSettleService { $settledCount = filter_var($settleOut['settled_order_count'] ?? 0, FILTER_VALIDATE_INT); $betWins = is_array($settleOut['bet_wins'] ?? null) ? $settleOut['bet_wins'] : []; - $hasWins = $betWins !== []; $hasStreak = is_array($settleOut['user_streak_events'] ?? null) && $settleOut['user_streak_events'] !== []; $hasWallet = is_array($settleOut['wallet_events'] ?? null) && $settleOut['wallet_events'] !== []; if ($settledCount === false || $settledCount <= 0) { - if (!$hasWins && !$hasStreak && !$hasWallet) { - return; + if ($betWins === [] && !$hasStreak && !$hasWallet) { + if ($periodId <= 0 || $resultNumber < 1) { + return; + } + $betWins = self::buildBetWinPayloadsFromSettledOrders($periodId, $resultNumber); + if ($betWins === []) { + return; + } } } - if (!self::markSettlementNotifyOnce($periodId)) { - return; - } - $streakEvents = is_array($settleOut['user_streak_events'] ?? null) ? $settleOut['user_streak_events'] : []; - foreach ($streakEvents as $row) { - if (!is_array($row)) { - continue; + if (($settledCount !== false && $settledCount > 0) || $hasStreak || $hasWallet) { + if (self::markSettlementNotifyOnce($periodId)) { + $streakEvents = is_array($settleOut['user_streak_events'] ?? null) ? $settleOut['user_streak_events'] : []; + foreach ($streakEvents as $row) { + if (!is_array($row)) { + continue; + } + $userId = filter_var($row['user_id'] ?? 0, FILTER_VALIDATE_INT); + if ($userId === false || $userId <= 0) { + continue; + } + $streak = filter_var($row['current_streak'] ?? 0, FILTER_VALIDATE_INT); + $extra = is_array($row['extra'] ?? null) ? $row['extra'] : []; + GameWebSocketPayloadHelper::publishUserStreak($userId, $streak === false ? 0 : $streak, $extra); + } + + $walletEvents = is_array($settleOut['wallet_events'] ?? null) ? $settleOut['wallet_events'] : []; + foreach ($walletEvents as $payload) { + if (!is_array($payload)) { + continue; + } + $userId = filter_var($payload['user_id'] ?? 0, FILTER_VALIDATE_INT); + if ($userId === false || $userId <= 0) { + continue; + } + GameWebSocketEventBus::publish('wallet.changed', GameWebSocketPayloadHelper::mergeUserStreakInto($payload, $userId)); + } + + $jackpotHits = is_array($settleOut['jackpot_hits'] ?? null) ? $settleOut['jackpot_hits'] : []; + self::publishJackpotHitsAfterCommit($jackpotHits, $periodId, $periodNo, $resultNumber); } - $userId = filter_var($row['user_id'] ?? 0, FILTER_VALIDATE_INT); - if ($userId === false || $userId <= 0) { - continue; - } - $streak = filter_var($row['current_streak'] ?? 0, FILTER_VALIDATE_INT); - $extra = is_array($row['extra'] ?? null) ? $row['extra'] : []; - GameWebSocketPayloadHelper::publishUserStreak($userId, $streak === false ? 0 : $streak, $extra); } - $walletEvents = is_array($settleOut['wallet_events'] ?? null) ? $settleOut['wallet_events'] : []; - foreach ($walletEvents as $payload) { - if (!is_array($payload) || empty($payload['user_id'])) { - continue; - } - $userId = filter_var($payload['user_id'], FILTER_VALIDATE_INT); - if ($userId === false || $userId <= 0) { - continue; - } - GameWebSocketEventBus::publish('wallet.changed', GameWebSocketPayloadHelper::mergeUserStreakInto($payload, $userId)); + $effectiveBetWins = $betWins; + if ($effectiveBetWins === [] && $periodId > 0 && $resultNumber > 0) { + $effectiveBetWins = self::buildBetWinPayloadsFromSettledOrders($periodId, $resultNumber); } + self::publishBetWinsAfterCommit($effectiveBetWins, $periodId); + } - $jackpotHits = is_array($settleOut['jackpot_hits'] ?? null) ? $settleOut['jackpot_hits'] : []; - self::publishBetWinsAfterCommit($betWins); - self::publishJackpotHitsAfterCommit($jackpotHits, $periodId, $periodNo, $resultNumber); + private static function markBetWinNotifyOnce(int $periodId, int $userId): bool + { + if ($periodId <= 0 || $userId <= 0) { + return true; + } + $key = self::BET_WIN_NOTIFY_DEDUP_PREFIX . $periodId . ':' . $userId; + try { + $ok = Redis::set($key, '1', ['nx', 'ex' => 86400]); + + return $ok === true || $ok === 'OK'; + } catch (Throwable) { + return true; + } } private static function markSettlementNotifyOnce(int $periodId): bool @@ -524,6 +615,25 @@ final class GameBetSettleService } catch (\Throwable) { } + $periodId = filter_var($row['period_id'] ?? 0, FILTER_VALIDATE_INT); + $periodNo = is_string($row['period_no'] ?? null) ? (string) $row['period_no'] : ''; + $resultNumber = filter_var( + Db::name('game_record')->where('id', $periodId)->value('result_number'), + FILTER_VALIDATE_INT + ); + if ($periodId !== false && $periodId > 0 && $resultNumber !== false && $resultNumber > 0 && bccomp($winAmount, '0', 2) > 0) { + self::publishBetWinsAfterCommit([[ + 'user_id' => $userId, + 'period_id' => $periodId, + 'period_no' => $periodNo, + 'result_number' => $resultNumber, + 'total_win' => $winAmount, + 'balance_after' => is_string($balanceAfter ?? null) ? $balanceAfter : (string) (Db::name('user')->where('id', $userId)->value('coin') ?? '0'), + 'is_jackpot' => StreakWinReward::isJackpotForStreakAtBet((int) ($row['streak_at_bet'] ?? 0)), + 'bets' => [['bet_id' => $playRecordId, 'win_amount' => $winAmount]], + ]], $periodId); + } + $out = ['ok' => true, 'msg' => __('Approved')]; if (is_string($balanceAfter)) { $out['balance_after'] = $balanceAfter; diff --git a/docs/36字花-数据库与实施计划.md b/docs/36字花-数据库与实施计划.md index 453e383..c9c2555 100644 --- a/docs/36字花-数据库与实施计划.md +++ b/docs/36字花-数据库与实施计划.md @@ -109,9 +109,12 @@ | `streak_win_reward` | JSON:`rows[]` 每项含 `streak`(1~10)、`odds_factor`(与 33 相乘为整段赔率)、`is_jackpot`(是否大奖)。派彩公式:`total_amount × odds_factor × 33`。默认第 10 档 `is_jackpot=true`。 | | `deposit_tier` | 仍由「充值档位」独立菜单维护,**不出现在**「常规配置」列表。 | -开奖结算后更新 **`user.current_streak`**:本期有中奖注单则 `min(streak_at_bet+1, 10)`,否则 **连胜归 0**(无档位配置)。**中奖 WebSocket(同一结算时刻)**: -- 向相关用户推送 **`bet.win`**(小奖/大奖统一;`data.is_jackpot` 标记是否大奖档;含 `total_win`、`balance_after`、`bets[]` 等)。 -- 若存在大奖档命中,**再**向公共频道推送 **`jackpot.hit`**(`hits[]` 每项含 `user_id`、**`nickname`**(昵称优先、空则 fallback 到 username)、`period_no`、`total_win`、`result_number`),供全站公告;移动端个人弹窗以 **`bet.win`** 为准。 +开奖结算后更新 **`user.current_streak`**:本期有中奖注单则 `min(streak_at_bet+1, 10)`,否则 **连胜归 0**(无档位配置)。**中奖 WebSocket(同一结算批次)**(实现:`GameBetSettleService::publishSettlementWinsAfterCommit`): +- 向相关用户推送 **`bet.win`**(小奖/大奖统一;**个人中奖弹窗只认此主题**)。载荷含 `total_win`、`balance_after`、`bets[]`、`is_win=true`,并合并 `current_streak` / `streak_level` / `odds_factor` / **`is_jackpot`**(是否**大奖档**,由 `streak_at_bet` 对应 `streak_win_reward.is_jackpot` 决定,非 `bet.accepted` 的下注时展示含义)。 +- 若存在大奖档命中,**再**向公共频道推送 **`jackpot.hit`**(`hits[]` 每项含 `user_id`、**`nickname`**、`period_no`、`total_win`、`result_number`),供全站跑马灯;**个人弹窗仍以 `bet.win` 为准**。 +- **去重**:`user.streak` / `wallet.changed` / `jackpot.hit` 使用 `dfw:v1:settle:notify:{period_id}`;**`bet.win` 独立** `dfw:v1:ws:betwin:{period_id}:{user_id}`,避免整期 dedup 吞掉中奖推送。 +- **补偿**:库内已结算中奖但内存未聚合时,从 `bet_order` 重建并补发;运维可用 `php scripts/republish_bet_win.php`(见移动端草案 §7.1.4)。 +- **大奖审核**:`win_amount >= jackpot_max_amount` 时注单可先 `status=待审核`,仍推 `bet.win`;后台审核通过后再推一次 `bet.win`(`approveJackpot`)。 > **派彩期间 `period.tick` 静默规则**:开奖到派彩宽限期结束(`status=payouting`)期间**不再推送 `period.tick`**,避免覆盖中奖动画;本期进入 `finished`/`void` 时各推一帧边界帧(每期号每状态去重,Redis Key `dfw:v1:ws:tick:boundary:{period_no}:{status}`,TTL 300s),下一期 `betting` 时恢复每秒推送。详见《36字花-移动端接口设计草案》§7.1.3。 @@ -282,6 +285,21 @@ | 后台快照 `maintenance_ui` | `!game_live_runtime_enabled && !hasActiveRecord()` 时为 `true`:表示**当局已完全结束**(含派彩),此时后台页展示「维护中」并锁定操作区(仅顶部「游戏运行」开关可用);当局尚在 0–3 状态时为 `false`,倒计时与操作区保持可用直至收尾完成 | | 作废本局(后台按钮) | 仅当本期为 **下注开放 / 已封盘**(`game_record.status` 为 `0` 或 `1`):将所有 **待开奖** 注单(`bet_order.status = 1`)按 `total_amount` 退回余额并置 `bet_order.status = 3`,写钱包流水 `biz_type = bet_void`;本期 **`game_record.status = 5`**,写入 **`void_reason`**;并 **强制将 `game_live_runtime_enabled` 置为 `0`**。需管理员在后台再次打开「游戏运行」开关后,才恢复下注与后续期次创建 | | 后台接口 | `POST /admin/game.Live/runtime`(body:`enabled` 0/1);`POST /admin/game.Live/voidPeriod`(body:`record_id` 可选、`void_reason` 必填) | +| 实时快照 HTTP | `GET /admin/game.Live/snapshot`:**只读** `buildSnapshot`,不执行 `recoverLiveRoundState`,避免接口超时;对局自愈/自动开奖由 **`gameLiveTicker`**(每秒 `publishSnapshot`)与 `drawResult` 链路负责 | +| WebSocket | `GET /admin/game.Live/wsConfig` 返回 `ws_url` 与推荐 `subscribe_topics`(含 `admin.live.snapshot`、`bet.win` 等);`admin.live.snapshot` 由 WS 进程每秒直推(有订阅者时) | +| WS 地址 | `WebSocketConfigHelper`:`.env` 中 `H5_WEBSOCKET_URL` 为 `127.0.0.1` 且请求 Host 非本机时自动改用 `ws(s)://{host}/ws/`;生产需 Nginx 将 `/ws/` 反代至 **3131** | + +#### 8.2.2 WebSocket 主题与 `bet.win`(联调清单) + +| 主题 | 触发 | 移动端 | +|------|------|--------| +| `bet.accepted` | 下注成功 | 扣款确认;`is_jackpot` 为下注时档位 | +| `bet.win` | 开奖结算后中奖 | **中奖弹窗**;`is_jackpot` 为大奖档 | +| `jackpot.hit` | 本期有大奖档命中 | 全站公告(可选) | +| `wallet.changed` | 余额变动 | 含 `biz_type=payout` 派彩 | +| `user.streak` | 每期结算后 | 连胜更新 | + +详见《36字花-移动端接口设计草案》§7.1.3、§7.1.4。 ### 8.3 代理与结算(逻辑就绪后) diff --git a/docs/36字花-移动端接口设计草案.md b/docs/36字花-移动端接口设计草案.md index f92c133..e250fec 100644 --- a/docs/36字花-移动端接口设计草案.md +++ b/docs/36字花-移动端接口设计草案.md @@ -760,7 +760,7 @@ 以下与代码 `app/process/GameWebSocketServer.php`、`config/process.php`、`app/common/library/admin/WebSocketConfigHelper.php` 一致,**文档此前未写明的条件**在此补齐: - **独立进程**:需在运行环境中启动 Webman 自定义进程 **`gameWebSocketServer`**(见 `config/process.php`),默认监听 **`H5_WEBSOCKET_LISTEN`**(缺省为 `websocket://0.0.0.0:3131`)。未启动进程则客户端无法建连或立刻断线。 -- **连接 URL**:优先使用环境变量 **`H5_WEBSOCKET_URL`**(完整 `ws://` / `wss://` 地址,建议带路径,如 `.env-example` 所示)。未配置时,后台 **`GET /admin/test.GameCurrentStatus/wsConfig`**(及游戏实时页同类接口)会通过 `WebSocketConfigHelper` 按当前请求的 `Host` / `X-Forwarded-*` 推导 `ws(s)://{host}/ws/`,本地兜底 **`ws://127.0.0.1:3131/ws/`**。 +- **连接 URL**:优先使用环境变量 **`H5_WEBSOCKET_URL`**(完整 `ws://` / `wss://` 地址,建议带路径,如 `.env-example` 所示)。**生产环境勿将 `H5_WEBSOCKET_URL` 配成 `127.0.0.1` / `localhost`**:浏览器无法访问服务端本机地址。若 `.env` 中为回环地址且 HTTP 请求来自外网域名,`WebSocketConfigHelper` 会**忽略该配置**,改按当前请求的 `Host` / `X-Forwarded-*` 推导 `ws(s)://{host}/ws/`。未配置时,后台 **`GET /admin/test.GameCurrentStatus/wsConfig`**、**`GET /admin/game.Live/wsConfig`** 等同理;本地兜底 **`ws://127.0.0.1:3131/ws/`**。HTTPS 站点需在 Nginx 等反向代理将 **`/ws/`** 转发至 `gameWebSocketServer` 监听端口(默认 **3131**)。 - **移动端配置缺口**:**`POST /api/game/lobbyInit` 当前不下发 WebSocket 地址**;H5 需与运维约定同一套 `H5_WEBSOCKET_URL`(打包进前端配置、远程配置中心等),与 HTTP API 基址可不同域。 - **混合内容**:若 H5 页面为 **HTTPS**,浏览器要求 WebSocket 使用 **`wss://`**,否则会被拦截。 - **事件投递依赖 Redis**:HTTP 侧业务通过 **`GameWebSocketEventBus`**(Redis 列表)将事件投递到 WebSocket 进程;Redis 不可用或队列异常时,**除 `admin.live.snapshot` 外**的广播类推送可能收不到。后台若订阅了 `admin.live.snapshot`,服务端有**每秒直连构建快照**的兜底,不依赖队列。 @@ -819,7 +819,8 @@ - `data.streak_level`:int - `data.odds_factor`:int - `data.is_jackpot`:bool -- **`wallet.changed` / `bet.accepted`**:在原有字段上合并同上 **`current_streak`**、**`streak_level`**、**`odds_factor`**、**`is_jackpot`**;客户端按 `user_id` 过滤,仅处理本用户 +- **`wallet.changed` / `bet.accepted` / `bet.win`**:在原有字段上合并同上 **`current_streak`**、**`streak_level`**、**`odds_factor`**、**`is_jackpot`**;客户端按 `user_id` 过滤,仅处理本用户。 +- **`bet.accepted` 与 `bet.win` 的 `is_jackpot` 区别**:`bet.accepted` 表示**下注时**本笔适用档位是否大奖档(赔率展示);**开奖中奖通知以 `bet.win` 为准**,其 `is_jackpot` 表示**结算时**该用户中奖注单是否含大奖档(`streak_at_bet` 对应 `streak_win_reward.is_jackpot=true`,通常为第 10 档)。 #### 7.1.3 推送频率与触发规则(当前实现) @@ -834,15 +835,20 @@ - `admin.live.snapshot`:**每秒一次**(后台实时对局页全量快照;不受派彩静默期影响)。 - `period.opened` / `period.payout` / `admin.live.opened`:按开奖流程阶段触发(事件触发型,非固定频率)。 - `wallet.changed`:仅在余额发生变更时推送(如下注扣款、充值入账、派彩入账)。派彩时 `biz_type=payout`,并带 `amount`(本次派彩金额)、`period_no`、`period_id`、`result_number`(若有)。 -- **`bet.win`(本期中奖,小奖/大奖统一)**:开奖结算后,**凡本期有中奖的用户**均按用户聚合推送一帧(与 `wallet.changed(payout)` 同一结算时刻);客户端**统一监听此事件**做中奖弹窗/横幅,用 `data.is_jackpot` 区分展示样式。 +- **`bet.win`(本期中奖,小奖/大奖统一)**:开奖结算后,**凡本期有中奖的用户**均按用户聚合推送一帧(与 `wallet.changed(payout)` 同一结算批次);**个人中奖弹窗/横幅统一监听此主题**,用 `data.is_jackpot` 区分普通档与大奖档样式。**中大奖档用户同样会收到 `bet.win`**,无需仅依赖 `jackpot.hit`。 - `data.user_id` / `data.period_id` / `data.period_no` / `data.result_number` - - `data.total_win`:本期该用户派彩合计(已结算入账部分;待审核大奖可能尚未入账,但仍会推送本事件) + - `data.total_win`:本期该用户派彩合计(已入账部分;若触发**后台大奖审核**(`win_amount >= game_config.jackpot_max_amount`)且注单为待审核,可能尚未入账,但仍会推送本事件) - `data.balance_after`:推送时用户余额(已派彩则为派彩后余额) - `data.bets[]`:`{ bet_id, win_amount }` 明细 - - **`data.is_jackpot`**:`bool`,`true` 表示该用户本注适用档位为大奖(`streak_win_reward` 对应档 `is_jackpot=true`),`false` 为普通档 + - **`data.is_jackpot`**:`bool`,`true` 表示该用户本期中奖注单含**大奖档**(`streak_win_reward` 中 `is_jackpot=true` 的档位,与下注时 `streak_at_bet` 对应),`false` 为普通档 + - **`data.is_win`**:`bool`,固定为 `true`(便于与 `user.streak` 的 `extra.is_win` 对齐) + - **合并赔率字段**(与 §7.1.2A 一致):`current_streak`、`streak_level`、`odds_factor`、`is_jackpot` - `data.server_time`:Unix 秒 -- **`jackpot.hit`(公共大奖广播,补充)**:在 **`bet.win` 之后**(同一结算批次内),若本期存在**大奖档命中**用户,**额外**向公共频道推送一帧,供全站公告/跑马灯;无大奖命中则不推送。 - - **推送顺序**:先 `bet.win`(按用户,含 `is_jackpot`)→ 再 `jackpot.hit`(仅大奖) + - **服务端去重**:Redis Key `dfw:v1:ws:betwin:{period_id}:{user_id}`(TTL 86400s),**每期每用户至多推送一次**;与 `user.streak` / `wallet.changed` 的整期去重键 `dfw:v1:settle:notify:{period_id}` **分离**,避免后者先占位导致 `bet.win` 被吞。 + - **补偿**:若内存聚合 `bet_wins` 为空但库内已有本期已结算中奖注单,结算服务会从库重建载荷并补发(`buildBetWinPayloadsFromSettledOrders`)。 + - **大奖审核通过后**:后台 `approveJackpot` 会再次向该用户推送 `bet.win`(入账后)。 +- **`jackpot.hit`(公共大奖广播,补充)**:在 **`bet.win` 之后**(同一结算批次内),若本期存在**大奖档命中**用户,**额外**向公共频道推送一帧,供全站公告/跑马灯;无大奖命中则不推送。**个人弹窗仍以 `bet.win` 为主**;`jackpot.hit` 用于全站展示昵称与金额。 + - **推送顺序**:先 `bet.win`(按用户,含 `is_jackpot`)→ 再 `jackpot.hit`(仅大奖档) - **载荷字段**:`period_id` / `period_no` / `result_number` / `hits[]` / `server_time`。 - `hits[]` 数组每项字段: - `user_id`:int(中奖用户 ID) @@ -851,11 +857,31 @@ - `total_win`:string(本期该用户的命中大奖派彩合计,金额字符串) - `result_number`:int +#### 7.1.4 结算推送去重与运维补发(2026-05) + +| Redis Key | 作用 | +|-----------|------| +| `dfw:v1:settle:notify:{period_id}` | 整期 `user.streak` / `wallet.changed`(结算批次内)/ `jackpot.hit` 仅推一次 | +| `dfw:v1:ws:betwin:{period_id}:{user_id}` | 该用户本期 `bet.win` 仅推一次(与上表独立) | + +**运维补发**(已结算中奖但客户端未收到 `bet.win` 时): + +```bash +php scripts/republish_bet_win.php --play-record-id=1370 +php scripts/republish_bet_win.php --period-id=123 +php scripts/republish_bet_win.php --period-no=20260526-183418-c9c90ef1 +# 强制忽略 dedup 再推:加 --force +``` + +联调脚本:`php scripts/verify_ws_topic_subscribe.php`(含 `bet.win` 入队/订阅校验)。 + ### 7.1A 后台连接方式(管理端联调) -- 后台菜单:仅保留一个菜单 `连接服务器websocket`,用于统一联调 WebSocket -- 后台连接入口: +- 后台菜单:`连接服务器websocket`(联调)、**`游戏管理` → `游戏实时对局`**(`/admin/game/live`,生产监控) +- 后台 WebSocket 配置入口: - `/admin/test.GameCurrentStatus/wsConfig` + - `/admin/game.Live/wsConfig`(实时对局页自动拉取并订阅,含 `admin.live.snapshot`、`bet.win` 等) +- **HTTP 快照**:`GET /admin/game.Live/snapshot` 为**只读**快照(`buildSnapshot`),**不在此接口执行** `recoverLiveRoundState` / 自动开奖,避免请求超时;对局推进由进程 **`gameLiveTicker`** 与开奖流程负责。 - 后台页面能力: - 读取 `ws_url`、`connect_tip`、`sample_messages` - 手动连接/断开 WebSocket @@ -881,9 +907,9 @@ 1. `GET /api/v1/authToken?secret=xxx×tamp=xxx&device_id=xxx&signature=xxx` 获取 `auth-token` 2. `POST /api/user/login` 登录(请求头带 `auth-token`) 3. `POST /api/game/lobbyInit` 拉首页初始化(请求头带 `auth-token`) -4. 取得 WebSocket 基址(**当前非 lobbyInit 下发**:与运维/打包配置中的 `H5_WEBSOCKET_URL` 或自建配置接口一致)后建立 WebSocket 连接,**立即发送 `subscribe`** 监听状态流(见 §7.0 / §7.1) +4. 取得 WebSocket 基址(**当前非 lobbyInit 下发**:与运维/打包配置中的 `H5_WEBSOCKET_URL` 或自建配置接口一致)后建立 WebSocket 连接,**立即发送 `subscribe`** 监听状态流(见 §7.0 / §7.1;**务必包含 `bet.win`**) 5. 用户下注调用 `POST /api/game/placeBet` -6. 下单后以 `placeBet.balance_after` 与 `wallet.changed` 同步余额 +6. 下单后以 `placeBet.balance_after` 与 `wallet.changed` 同步余额;开奖结算后监听 **`bet.win`**(`data.user_id` 为本用户且 `is_win=true`)展示中奖,大奖档看 `data.is_jackpot` 7. 断线或页面回前台时,重连 WebSocket 并重新订阅主题回补实时状态 ## 8.2 充值到下注到提现闭环 @@ -915,7 +941,7 @@ flowchart TD E --> F[等待 wallet.changed 同步余额] D -- 否 --> G[进入封盘与开奖阶段] G --> H[服务端算票与开奖] - H --> I[WebSocket 推送状态变化] + H --> I[WebSocket: period.opened / bet.win / wallet.changed 等] I --> J[断线重连并重新订阅] J --> C ```