1.优化中奖事件统一使用bet.win订阅中奖消息

This commit is contained in:
2026-05-26 14:55:10 +08:00
parent 24f30b5ef2
commit 1b26539ac5
7 changed files with 98 additions and 46 deletions

View File

@@ -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<int, true> */
$jackpotNotify = [];
/** @var array<int, array{user_id: int, period_id: int, period_no: string, result_number: int, total_win: string, balance_after: string, bets: list<array{bet_id: int, win_amount: string}>}> */
$smallWinByUser = [];
/** @var array<int, array{user_id: int, period_id: int, period_no: string, result_number: int, total_win: string, balance_after: string, is_jackpot: bool, bets: list<array{bet_id: int, win_amount: string}>}> */
$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<array<string, mixed>> $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<array<string, mixed>> $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<array<string, mixed>>, bet_wins?: list<array<string, mixed>>} $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仍空则返回空串调用方自行兜底
*

View File

@@ -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 [

View File

@@ -109,7 +109,9 @@
| `streak_win_reward` | JSON`rows[]` 每项含 `streak`110`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。

View File

@@ -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

View File

@@ -1,5 +1,5 @@
export default {
desc: 'Streak levels 110: 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 110: 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',

View File

@@ -1,5 +1,5 @@
export default {
desc: '110 档连胜:派彩 = 压注总额 × 赔率系数odds_factor勾选「大奖」的档位在中奖时会对玩家私有频道、public-game-period 与 public-operation-notice 推送 jackpot.hit。',
desc: '110 档连胜:派彩 = 压注总额 × 赔率系数odds_factor本期中奖统一推送 bet.windata.is_jackpot 标记是否大奖档);勾选「大奖」的档位在中奖时另推送公共 jackpot.hit。',
btn_save: '保存',
streak: '连胜档(局)',
odds_factor: '赔率系数',

View File

@@ -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 : []