1.优化中奖事件统一使用bet.win订阅中奖消息
This commit is contained in:
@@ -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;仍空则返回空串(调用方自行兜底)。
|
||||
*
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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。
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '赔率系数',
|
||||
|
||||
@@ -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 : []
|
||||
|
||||
Reference in New Issue
Block a user