From 1b26539ac5e7706437b04707be0cc16bcfd1b92c Mon Sep 17 00:00:00 2001 From: zhenhui <1276357500@qq.com> Date: Tue, 26 May 2026 14:55:10 +0800 Subject: [PATCH] =?UTF-8?q?1.=E4=BC=98=E5=8C=96=E4=B8=AD=E5=A5=96=E4=BA=8B?= =?UTF-8?q?=E4=BB=B6=E7=BB=9F=E4=B8=80=E4=BD=BF=E7=94=A8bet.win=E8=AE=A2?= =?UTF-8?q?=E9=98=85=E4=B8=AD=E5=A5=96=E6=B6=88=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/common/service/GameBetSettleService.php | 90 ++++++++++++++----- app/common/service/GameLiveService.php | 25 +++--- docs/36字花-数据库与实施计划.md | 4 +- docs/36字花-移动端接口设计草案.md | 14 +-- .../lang/backend/en/config/streakWinReward.ts | 2 +- .../backend/zh-cn/config/streakWinReward.ts | 2 +- web/src/views/backend/game/live/index.vue | 7 ++ 7 files changed, 98 insertions(+), 46 deletions(-) diff --git a/app/common/service/GameBetSettleService.php b/app/common/service/GameBetSettleService.php index 8e2f89b..d901a5f 100644 --- a/app/common/service/GameBetSettleService.php +++ b/app/common/service/GameBetSettleService.php @@ -22,9 +22,11 @@ final class GameBetSettleService public const CONFIG_KEY_JACKPOT_MAX_AMOUNT = 'jackpot_max_amount'; - /** 小奖(非大奖档)中奖:移动端弹窗/通知专用 */ + /** 本期中奖(含小奖/大奖档):移动端弹窗/通知统一入口,以 is_jackpot 区分 */ public const TOPIC_BET_WIN = 'bet.win'; + public const TOPIC_JACKPOT_HIT = 'jackpot.hit'; + /** * 对指定期次按开奖号码结算所有「待开奖」注单;同一注单幂等(仅 status=1 会更新)。 * @@ -59,8 +61,8 @@ final class GameBetSettleService /** @var array */ $jackpotNotify = []; - /** @var array}> */ - $smallWinByUser = []; + /** @var array}> */ + $winByUser = []; foreach ($bets as $bet) { $betId = (int) ($bet['id'] ?? 0); @@ -116,26 +118,32 @@ final class GameBetSettleService if ($paid !== null) { $balanceAfter = $paid; } + } + + if (bccomp($win, '0', 2) > 0) { $streakAtBet = (int) ($bet['streak_at_bet'] ?? 0); - if ($paid !== null && !StreakWinReward::isJackpotForStreakAtBet($streakAtBet)) { - if (!isset($smallWinByUser[$userId])) { - $smallWinByUser[$userId] = [ - 'user_id' => $userId, - 'period_id' => $recordId, - 'period_no' => (string) ($bet['period_no'] ?? ''), - 'result_number' => $resultNumber, - 'total_win' => '0.00', - 'balance_after' => $balanceAfter, - 'bets' => [], - ]; - } - $smallWinByUser[$userId]['total_win'] = bcadd($smallWinByUser[$userId]['total_win'], $win, 2); - $smallWinByUser[$userId]['balance_after'] = $balanceAfter; - $smallWinByUser[$userId]['bets'][] = [ - 'bet_id' => $betId, - 'win_amount' => $win, + $isJackpotTier = StreakWinReward::isJackpotForStreakAtBet($streakAtBet); + if (!isset($winByUser[$userId])) { + $winByUser[$userId] = [ + 'user_id' => $userId, + 'period_id' => $recordId, + 'period_no' => (string) ($bet['period_no'] ?? ''), + 'result_number' => $resultNumber, + 'total_win' => '0.00', + 'balance_after' => $balanceAfter, + 'is_jackpot' => false, + 'bets' => [], ]; } + if ($isJackpotTier) { + $winByUser[$userId]['is_jackpot'] = true; + } + $winByUser[$userId]['total_win'] = bcadd($winByUser[$userId]['total_win'], $win, 2); + $winByUser[$userId]['balance_after'] = $balanceAfter; + $winByUser[$userId]['bets'][] = [ + 'bet_id' => $betId, + 'win_amount' => $win, + ]; } $periodNo = (string) ($bet['period_no'] ?? ''); @@ -199,18 +207,19 @@ final class GameBetSettleService ]; } - $betWins = array_values($smallWinByUser); + $betWins = array_values($winByUser); return ['jackpot_hits' => $jackpotHits, 'bet_wins' => $betWins]; } /** - * 事务提交后推送小奖中奖(bet.win)。 + * 事务提交后推送本期中奖(bet.win,小奖/大奖统一;data.is_jackpot 区分档位)。 * * @param list> $betWins */ public static function publishBetWinsAfterCommit(array $betWins): void { + $now = time(); foreach ($betWins as $payload) { if (!is_array($payload) || empty($payload['user_id'])) { continue; @@ -219,14 +228,47 @@ final class GameBetSettleService if ($userId === false || $userId <= 0) { continue; } + $isJackpot = !empty($payload['is_jackpot']); $data = array_merge($payload, [ - 'is_jackpot' => false, - 'server_time' => time(), + 'is_jackpot' => $isJackpot, + 'server_time' => $now, ]); GameWebSocketEventBus::publish(self::TOPIC_BET_WIN, $data); } } + /** + * 大奖档命中时额外推送公共频道 jackpot.hit(与 bet.win 同一结算时刻,先后发出)。 + * + * @param list> $jackpotHits + */ + public static function publishJackpotHitsAfterCommit(array $jackpotHits, int $periodId, string $periodNo, int $resultNumber): void + { + if ($jackpotHits === [] || $periodId <= 0 || $resultNumber < 1) { + return; + } + GameWebSocketEventBus::publish(self::TOPIC_JACKPOT_HIT, [ + 'period_id' => $periodId, + 'period_no' => $periodNo, + 'result_number' => $resultNumber, + 'hits' => $jackpotHits, + 'server_time' => time(), + ]); + } + + /** + * 结算提交后统一推送:先 bet.win(全员中奖),再 jackpot.hit(仅大奖档)。 + * + * @param array{jackpot_hits?: list>, bet_wins?: list>} $settleOut + */ + public static function publishSettlementWinsAfterCommit(array $settleOut, int $periodId, string $periodNo, int $resultNumber): void + { + $betWins = is_array($settleOut['bet_wins'] ?? null) ? $settleOut['bet_wins'] : []; + $jackpotHits = is_array($settleOut['jackpot_hits'] ?? null) ? $settleOut['jackpot_hits'] : []; + self::publishBetWinsAfterCommit($betWins); + self::publishJackpotHitsAfterCommit($jackpotHits, $periodId, $periodNo, $resultNumber); + } + /** * 批量读取用户展示名:nickname 优先;空则 fallback 到 username;仍空则返回空串(调用方自行兜底)。 * diff --git a/app/common/service/GameLiveService.php b/app/common/service/GameLiveService.php index 8057a2f..4ac87e5 100644 --- a/app/common/service/GameLiveService.php +++ b/app/common/service/GameLiveService.php @@ -140,8 +140,12 @@ final class GameLiveService return; } - $betWins = is_array($settleOut['bet_wins'] ?? null) ? $settleOut['bet_wins'] : []; - GameBetSettleService::publishBetWinsAfterCommit($betWins); + GameBetSettleService::publishSettlementWinsAfterCommit( + $settleOut, + $recordId, + is_string($row['period_no'] ?? null) ? (string) $row['period_no'] : '', + (int) $resultNumber + ); GameHotDataCoordinator::afterGameRecordCommitted($recordId); self::publishSnapshot(null); @@ -618,8 +622,12 @@ final class GameLiveService return ['ok' => false, 'msg' => __('Game live: settlement error') . ': ' . $e->getMessage()]; } - $betWins = is_array($settleOut['bet_wins'] ?? null) ? $settleOut['bet_wins'] : []; - GameBetSettleService::publishBetWinsAfterCommit($betWins); + GameBetSettleService::publishSettlementWinsAfterCommit( + $settleOut, + $rid, + (string) $record['period_no'], + $finalNumber + ); GameHotDataCoordinator::afterGameRecordCommitted($rid); @@ -638,15 +646,6 @@ final class GameLiveService 'jackpot_hits' => $jackpotHits, 'server_time' => $now, ]); - if ($jackpotHits !== []) { - GameWebSocketEventBus::publish('jackpot.hit', [ - 'period_id' => $rid, - 'period_no' => (string) $record['period_no'], - 'result_number' => $finalNumber, - 'hits' => $jackpotHits, - 'server_time' => $now, - ]); - } self::publishSnapshot(null); return [ diff --git a/docs/36字花-数据库与实施计划.md b/docs/36字花-数据库与实施计划.md index e80f097..453e383 100644 --- a/docs/36字花-数据库与实施计划.md +++ b/docs/36字花-数据库与实施计划.md @@ -109,7 +109,9 @@ | `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**(无档位配置)。若命中大奖档,向玩家 **私有频道** `private-user-{uuid}` 与 **公共频道** `public-game-period` 推送事件 **`jackpot.hit`**(负载含 `period_id`、`period_no`、`result_number`、`server_time` 与 `hits[]`;`hits[]` 每项含 `user_id`、**`nickname`**(昵称优先、空则 fallback 到 username)、`period_no`、`total_win`、`result_number`,供前端弹窗/通知直接展示)。 +开奖结算后更新 **`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`** 为准。 > **派彩期间 `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。 diff --git a/docs/36字花-移动端接口设计草案.md b/docs/36字花-移动端接口设计草案.md index 12e6882..f92c133 100644 --- a/docs/36字花-移动端接口设计草案.md +++ b/docs/36字花-移动端接口设计草案.md @@ -824,7 +824,7 @@ #### 7.1.3 推送频率与触发规则(当前实现) - `period.tick`:**仅在 `status ∈ {betting, locked}` 时每秒推送**(用于倒计时、状态同步;**不含**赔率全表)。 - - **派彩静默期**:`status=payouting` 期间**不推** `period.tick`(避免彩池/倒计时干扰中奖动画)。改为每秒推送 **`period.payout.tick`**,仅含 `payout_remaining_seconds` / `payout_until` 等派彩倒计时字段。中奖展示仍靠 `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` / **`bet.win`** / `jackpot.hit`(大奖补充)/ `wallet.changed(biz_type=payout)`。 - **边界帧(每期仅一次)**: - `status=finished`:派彩宽限期结束、本期进入收尾时推送一帧,告知前端可以清理本期 UI。 - `status=void`:本期被作废时推送一帧作为通知。 @@ -834,13 +834,15 @@ - `admin.live.snapshot`:**每秒一次**(后台实时对局页全量快照;不受派彩静默期影响)。 - `period.opened` / `period.payout` / `admin.live.opened`:按开奖流程阶段触发(事件触发型,非固定频率)。 - `wallet.changed`:仅在余额发生变更时推送(如下注扣款、充值入账、派彩入账)。派彩时 `biz_type=payout`,并带 `amount`(本次派彩金额)、`period_no`、`period_id`、`result_number`(若有)。 -- **`bet.win`(小奖)**:本期中奖且**非大奖档**(`streak_win_reward` 中 `is_jackpot=false`)时,按用户聚合推送一帧;用于弹窗/横幅,**比 `wallet.changed` 更适合做「你中了小奖」展示**。 +- **`bet.win`(本期中奖,小奖/大奖统一)**:开奖结算后,**凡本期有中奖的用户**均按用户聚合推送一帧(与 `wallet.changed(payout)` 同一结算时刻);客户端**统一监听此事件**做中奖弹窗/横幅,用 `data.is_jackpot` 区分展示样式。 - `data.user_id` / `data.period_id` / `data.period_no` / `data.result_number` - - `data.total_win`:本期该用户小奖派彩合计 - - `data.balance_after`:派彩后余额 + - `data.total_win`:本期该用户派彩合计(已结算入账部分;待审核大奖可能尚未入账,但仍会推送本事件) + - `data.balance_after`:推送时用户余额(已派彩则为派彩后余额) - `data.bets[]`:`{ bet_id, win_amount }` 明细 - - `data.is_jackpot`:固定 `false` -- `jackpot.hit`:**仅在本期存在中大奖命中用户时推送**;无命中不推送。 + - **`data.is_jackpot`**:`bool`,`true` 表示该用户本注适用档位为大奖(`streak_win_reward` 对应档 `is_jackpot=true`),`false` 为普通档 + - `data.server_time`:Unix 秒 +- **`jackpot.hit`(公共大奖广播,补充)**:在 **`bet.win` 之后**(同一结算批次内),若本期存在**大奖档命中**用户,**额外**向公共频道推送一帧,供全站公告/跑马灯;无大奖命中则不推送。 + - **推送顺序**:先 `bet.win`(按用户,含 `is_jackpot`)→ 再 `jackpot.hit`(仅大奖) - **载荷字段**:`period_id` / `period_no` / `result_number` / `hits[]` / `server_time`。 - `hits[]` 数组每项字段: - `user_id`:int(中奖用户 ID) diff --git a/web/src/lang/backend/en/config/streakWinReward.ts b/web/src/lang/backend/en/config/streakWinReward.ts index a335dfc..28bfb5a 100644 --- a/web/src/lang/backend/en/config/streakWinReward.ts +++ b/web/src/lang/backend/en/config/streakWinReward.ts @@ -1,5 +1,5 @@ export default { - desc: 'Streak levels 1–10: payout = bet total × odds_factor. Jackpot rows trigger jackpot.hit on the user private channel, public-game-period, and public-operation-notice when won.', + desc: 'Streak levels 1–10: payout = bet total × odds_factor. All wins publish bet.win (data.is_jackpot marks jackpot tier); jackpot rows also publish public jackpot.hit.', btn_save: 'Save', streak: 'Streak (rounds)', odds_factor: 'Odds factor', diff --git a/web/src/lang/backend/zh-cn/config/streakWinReward.ts b/web/src/lang/backend/zh-cn/config/streakWinReward.ts index 8efba56..3c39d59 100644 --- a/web/src/lang/backend/zh-cn/config/streakWinReward.ts +++ b/web/src/lang/backend/zh-cn/config/streakWinReward.ts @@ -1,5 +1,5 @@ export default { - desc: '1~10 档连胜:派彩 = 压注总额 × 赔率系数(odds_factor)。勾选「大奖」的档位在中奖时会对玩家私有频道、public-game-period 与 public-operation-notice 推送 jackpot.hit。', + desc: '1~10 档连胜:派彩 = 压注总额 × 赔率系数(odds_factor)。本期中奖统一推送 bet.win(data.is_jackpot 标记是否大奖档);勾选「大奖」的档位在中奖时另推送公共 jackpot.hit。', btn_save: '保存', streak: '连胜档(局)', odds_factor: '赔率系数', diff --git a/web/src/views/backend/game/live/index.vue b/web/src/views/backend/game/live/index.vue index 25d0cd7..d71afa0 100644 --- a/web/src/views/backend/game/live/index.vue +++ b/web/src/views/backend/game/live/index.vue @@ -336,6 +336,13 @@ function handleWsPayload(raw: unknown): void { void loadSnapshot({ force: true }) return } + if (event === 'bet.win' && parsed.data && typeof parsed.data === 'object') { + const winData = parsed.data as anyObj + if (winData.is_jackpot === true) { + ElMessage.success(t('game.live.jackpot_hit_tip')) + } + return + } if (event === 'jackpot.hit' && parsed.data && typeof parsed.data === 'object') { const jackpotData = parsed.data as anyObj const hits = Array.isArray(jackpotData.hits) ? jackpotData.hits : []