diff --git a/app/common/service/GameBetSettleService.php b/app/common/service/GameBetSettleService.php index c88b73f..e5440b2 100644 --- a/app/common/service/GameBetSettleService.php +++ b/app/common/service/GameBetSettleService.php @@ -328,7 +328,7 @@ final class GameBetSettleService if ($userId === false || $userId <= 0) { continue; } - if ($periodId > 0 && !self::markBetWinNotifyOnce($periodId, $userId)) { + if ($periodId > 0 && self::hasBetWinNotifyMarked($periodId, $userId)) { continue; } $isJackpot = !empty($payload['is_jackpot']); @@ -340,6 +340,15 @@ final class GameBetSettleService 'server_time' => $now, ]), $userId); GameWebSocketEventBus::publish(self::TOPIC_BET_WIN, $data); + if ($periodId > 0) { + self::markBetWinNotifyOnce($periodId, $userId); + } + Log::info('bet.win published', [ + 'period_id' => $periodId, + 'user_id' => $userId, + 'total_win' => $payload['total_win'] ?? '', + 'is_jackpot' => $isJackpot, + ]); } } @@ -489,9 +498,13 @@ final class GameBetSettleService } } - $effectiveBetWins = $betWins; - if ($effectiveBetWins === [] && $periodId > 0 && $resultNumber > 0) { - $effectiveBetWins = self::buildBetWinPayloadsFromSettledOrders($periodId, $resultNumber); + $effectiveBetWins = $periodId > 0 && $resultNumber > 0 + ? self::buildBetWinPayloadsFromSettledOrders($periodId, $resultNumber) + : []; + if ($effectiveBetWins === []) { + $effectiveBetWins = $betWins; + } else { + $effectiveBetWins = self::mergeBetWinPayloads($betWins, $effectiveBetWins); } self::publishBetWinsAfterCommit($effectiveBetWins, $periodId); if ($periodId > 0 && $resultNumber > 0) { @@ -520,13 +533,8 @@ final class GameBetSettleService if ($userId === false || $userId <= 0) { continue; } - $key = self::BET_WIN_NOTIFY_DEDUP_PREFIX . $periodId . ':' . $userId; - try { - $existing = Redis::get($key); - if ($existing !== false && $existing !== null && $existing !== '') { - continue; - } - } catch (Throwable) { + if (self::hasBetWinNotifyMarked($periodId, $userId)) { + continue; } $missing[] = $payload; } @@ -540,21 +548,65 @@ final class GameBetSettleService } } - private static function markBetWinNotifyOnce(int $periodId, int $userId): bool + private static function hasBetWinNotifyMarked(int $periodId, int $userId): bool { if ($periodId <= 0 || $userId <= 0) { - return true; + return false; } $key = self::BET_WIN_NOTIFY_DEDUP_PREFIX . $periodId . ':' . $userId; try { - $ok = Redis::set($key, '1', ['nx', 'ex' => 86400]); + $existing = Redis::get($key); - return $ok === true || $ok === 'OK'; + return $existing !== false && $existing !== null && $existing !== ''; } catch (Throwable) { - return true; + return false; } } + private static function markBetWinNotifyOnce(int $periodId, int $userId): void + { + if ($periodId <= 0 || $userId <= 0) { + return; + } + $key = self::BET_WIN_NOTIFY_DEDUP_PREFIX . $periodId . ':' . $userId; + try { + Redis::setEx($key, 86400, '1'); + } catch (Throwable) { + } + } + + /** + * @param list> $primary + * @param list> $secondary + * @return list> + */ + private static function mergeBetWinPayloads(array $primary, array $secondary): array + { + /** @var array> $byUser */ + $byUser = []; + foreach (array_merge($primary, $secondary) as $payload) { + if (!is_array($payload)) { + continue; + } + $userId = filter_var($payload['user_id'] ?? 0, FILTER_VALIDATE_INT); + if ($userId === false || $userId <= 0) { + continue; + } + if (!isset($byUser[$userId])) { + $byUser[$userId] = $payload; + continue; + } + if (!empty($payload['is_jackpot'])) { + $byUser[$userId]['is_jackpot'] = true; + } + if (!empty($payload['payout_pending_review'])) { + $byUser[$userId]['payout_pending_review'] = true; + } + } + + return array_values($byUser); + } + private static function markSettlementNotifyOnce(int $periodId): bool { if ($periodId <= 0) { diff --git a/docs/36字花-移动端接口设计草案.md b/docs/36字花-移动端接口设计草案.md index 0142894..f0d3197 100644 --- a/docs/36字花-移动端接口设计草案.md +++ b/docs/36字花-移动端接口设计草案.md @@ -845,7 +845,7 @@ - **`data.payout_pending_review`**:`bool`,`true` 表示已中奖但派彩待后台大奖审核,尚未入账(仍应展示中奖 UI) - **合并赔率字段**(与 §7.1.2A 一致):`current_streak`、`streak_level`、`odds_factor`、`is_jackpot` - `data.server_time`:Unix 秒 - - **服务端去重**:Redis Key `dfw:v1:ws:betwin:{period_id}:{user_id}`(TTL 86400s),**每期每用户至多推送一次**;与 `user.streak` / `wallet.changed` 的整期去重键 `dfw:v1:settle:notify:{period_id}` **分离**,避免后者先占位导致 `bet.win` 被吞。 + - **服务端去重**:Redis Key `dfw:v1:ws:betwin:{period_id}:{user_id}`(TTL 86400s),**入队成功后再写入**,每期每用户至多推送一次;与 `dfw:v1:settle:notify:{period_id}` **分离**。结算推送以库内已结算中奖注单为准重建载荷,避免内存聚合丢失。 - **补偿**:若内存聚合 `bet_wins` 为空但库内已有本期已结算中奖注单,结算服务会从库重建载荷并补发(`buildBetWinPayloadsFromSettledOrders`)。 - **大奖审核通过后**:后台 `approveJackpot` 会再次向该用户推送 `bet.win`(入账后)。 - **`jackpot.hit`(公共大奖广播,补充)**:在 **`bet.win` 之后**(同一结算批次内),若本期存在**大奖档命中**用户,**额外**向公共频道推送一帧,供全站公告/跑马灯;无大奖命中则不推送。**个人弹窗仍以 `bet.win` 为主**;`jackpot.hit` 用于全站展示昵称与金额。 diff --git a/scripts/debug_period_bet_win.php b/scripts/debug_period_bet_win.php new file mode 100644 index 0000000..172fc77 --- /dev/null +++ b/scripts/debug_period_bet_win.php @@ -0,0 +1,21 @@ +where('period_no', $periodNo)->find(); +if (!is_array($gr)) { + fwrite(STDERR, "period not found: {$periodNo}\n"); + exit(1); +} +$pid = (int) $gr['id']; +$bets = Db::name('bet_order')->where('period_id', $pid)->select()->toArray(); +echo "period_id={$pid} status={$gr['status']} result={$gr['result_number']}\n"; +foreach ($bets as $b) { + echo "bet {$b['id']} user={$b['user_id']} status={$b['status']} win={$b['win_amount']}\n"; +}