diff --git a/app/common/service/GameBetSettleService.php b/app/common/service/GameBetSettleService.php index eb1a049..7c75e76 100644 --- a/app/common/service/GameBetSettleService.php +++ b/app/common/service/GameBetSettleService.php @@ -25,7 +25,7 @@ final class GameBetSettleService /** * 对指定期次按开奖号码结算所有「待开奖」注单;同一注单幂等(仅 status=1 会更新)。 * - * @return array{jackpot_hits: list} + * @return array{jackpot_hits: list} * * @throws Throwable */ @@ -147,6 +147,7 @@ final class GameBetSettleService } $jackpotHits = []; + $hitUserIds = []; foreach ($jackpotNotify as $uid => $_) { if (!isset($aggregateByUser[$uid])) { continue; @@ -155,8 +156,14 @@ final class GameBetSettleService if (bccomp($agg['total_win'], '0', 2) <= 0) { continue; } + $hitUserIds[] = (int) $uid; + } + $userNameMap = self::loadUserDisplayNames($hitUserIds); + foreach ($hitUserIds as $uid) { + $agg = $aggregateByUser[$uid]; $jackpotHits[] = [ - 'user_id' => (int) $uid, + 'user_id' => $uid, + 'nickname' => $userNameMap[$uid] ?? ('用户' . $uid), 'period_no' => (string) ($agg['period_no'] ?? ''), 'total_win' => (string) $agg['total_win'], 'result_number' => $resultNumber, @@ -166,6 +173,41 @@ final class GameBetSettleService return ['jackpot_hits' => $jackpotHits]; } + /** + * 批量读取用户展示名:nickname 优先;空则 fallback 到 username;仍空则返回空串(调用方自行兜底)。 + * + * @param list $userIds + * @return array + */ + private static function loadUserDisplayNames(array $userIds): array + { + $userIds = array_values(array_unique(array_filter($userIds, static fn ($v): bool => $v > 0))); + if ($userIds === []) { + return []; + } + $rows = Db::name('user') + ->whereIn('id', $userIds) + ->field(['id', 'nickname', 'username']) + ->select() + ->toArray(); + $out = []; + foreach ($rows as $row) { + $uid = isset($row['id']) && is_numeric($row['id']) ? (int) $row['id'] : 0; + if ($uid <= 0) { + continue; + } + $nickname = isset($row['nickname']) && is_string($row['nickname']) ? trim($row['nickname']) : ''; + if ($nickname !== '') { + $out[$uid] = $nickname; + continue; + } + $username = isset($row['username']) && is_string($row['username']) ? trim($row['username']) : ''; + $out[$uid] = $username; + } + + return $out; + } + /** * 大奖审核通过后派彩(幂等):仅当 play_record.status=待审核 且 win_amount>=阈值时执行。 * diff --git a/app/common/service/GameLiveService.php b/app/common/service/GameLiveService.php index 489f1d4..cde4276 100644 --- a/app/common/service/GameLiveService.php +++ b/app/common/service/GameLiveService.php @@ -9,6 +9,7 @@ use app\common\model\UserWalletRecord; use app\common\service\GameHotDataCoordinator; use app\common\service\GameHotDataLock; use support\Log; +use support\Redis; use support\think\Db; use Throwable; @@ -23,6 +24,10 @@ final class GameLiveService private const EVT_PERIOD_LOCKED = 'period.locked'; private const EVT_PERIOD_OPENED = 'period.opened'; private const EVT_PERIOD_PAYOUT = 'period.payout'; + + /** period.tick 边界帧去重(finished / void 每期只推一次),TTL 兼顾跨进程与跨期重启 */ + private const TICK_BOUNDARY_DEDUP_KEY_PREFIX = 'dfw:v1:ws:tick:boundary:'; + private const TICK_BOUNDARY_DEDUP_TTL_SECONDS = 300; private const KEY_PERIOD_SECONDS = 'period_seconds'; private const KEY_BET_SECONDS = 'bet_seconds'; private const KEY_PAYOUT_SECONDS = 'payout_seconds'; @@ -923,9 +928,53 @@ final class GameLiveService 'server_time' => time(), ]; + if (!self::shouldPublishPeriodTick($status, $periodNo)) { + return; + } + GameWebSocketEventBus::publish(self::EVT_PERIOD_TICK, $payload); } + /** + * period.tick 状态过滤(与《36字花-移动端接口设计草案》7.1.3 推送规则对齐): + * - betting / locked:保持每秒推送 + * - payouting:完全静默(中奖信息已通过 period.opened / period.payout / jackpot.hit / wallet.changed 通知) + * - finished / void:每个期号只推一次边界帧,告知前端本期收尾,随后静默直到下一期 betting + */ + private static function shouldPublishPeriodTick(string $status, string $periodNo): bool + { + if ($status === 'payouting') { + return false; + } + if ($status === 'finished' || $status === 'void') { + if ($periodNo === '') { + return true; + } + return self::markBoundaryFrameOnce($periodNo, $status); + } + + return true; + } + + /** + * 边界帧去重:SET NX EX,占位成功(首次)返回 true;已存在返回 false。Redis 异常时降级为放行。 + */ + private static function markBoundaryFrameOnce(string $periodNo, string $status): bool + { + $key = self::TICK_BOUNDARY_DEDUP_KEY_PREFIX . $periodNo . ':' . $status; + try { + $client = Redis::connection()->client(); + if (!is_object($client) || !method_exists($client, 'set')) { + return true; + } + $ok = $client->set($key, '1', ['nx', 'ex' => self::TICK_BOUNDARY_DEDUP_TTL_SECONDS]); + + return $ok === true; + } catch (Throwable) { + return true; + } + } + /** * @param array $record game_record 行 */ diff --git a/docs/36字花-数据库与实施计划.md b/docs/36字花-数据库与实施计划.md index 59f2745..e80f097 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_no`、`user_id`、`total_win_amount`、`result_number` 等)。 +开奖结算后更新 **`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`,供前端弹窗/通知直接展示)。 + +> **派彩期间 `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 9ae5359..d46b2b2 100644 --- a/docs/36字花-移动端接口设计草案.md +++ b/docs/36字花-移动端接口设计草案.md @@ -823,12 +823,25 @@ #### 7.1.3 推送频率与触发规则(当前实现) -- `period.tick`:**每秒一次**(用于倒计时、状态同步;**不含**赔率全表)。 +- `period.tick`:**仅在 `status ∈ {betting, locked}` 时每秒推送**(用于倒计时、状态同步;**不含**赔率全表)。 + - **派彩静默期**:`status=payouting`(开奖到派彩宽限期结束)期间**完全不推**,避免覆盖前端的中奖动画/弹窗。中奖玩家依靠同时触发的 `period.opened` / `period.payout` / `jackpot.hit` / `wallet.changed(biz_type=payout)` 完成展示。 + - **边界帧(每期仅一次)**: + - `status=finished`:派彩宽限期结束、本期进入收尾时推送一帧,告知前端可以清理本期 UI。 + - `status=void`:本期被作废时推送一帧作为通知。 + - **恢复推送**:下一期创建并进入 `betting` 后,按新 `period_no` 重新开始每秒推送。 + - 服务端去重:边界帧通过 Redis Key `dfw:v1:ws:tick:boundary:{period_no}:{status}`(TTL 300s)保证同一期号同一状态只推一次。 - `user.streak`:每期结算更新用户连胜后按用户推送(未中奖也会推送,`current_streak` 可能归零)。 -- `admin.live.snapshot`:**每秒一次**(后台实时对局页全量快照)。 +- `admin.live.snapshot`:**每秒一次**(后台实时对局页全量快照;不受派彩静默期影响)。 - `period.opened` / `period.payout` / `admin.live.opened`:按开奖流程阶段触发(事件触发型,非固定频率)。 - `wallet.changed`:仅在余额发生变更时推送(如下注扣款、充值入账、派彩入账)。 - `jackpot.hit`:**仅在本期存在中大奖命中用户时推送**;无命中不推送。 + - **载荷字段**:`period_id` / `period_no` / `result_number` / `hits[]` / `server_time`。 + - `hits[]` 数组每项字段: + - `user_id`:int(中奖用户 ID) + - `nickname`:string(用户昵称,**优先取 `user.nickname`,为空时 fallback 到 `user.username`,再为空则使用 `用户{user_id}`**,供前端弹窗/横幅通知直接展示) + - `period_no`:string + - `total_win`:string(本期该用户的命中大奖派彩合计,金额字符串) + - `result_number`:int ### 7.1A 后台连接方式(管理端联调)