From f3ed6848c7e907588d9445ddec69f916c22167c5 Mon Sep 17 00:00:00 2001 From: zhenhui <1276357500@qq.com> Date: Wed, 27 May 2026 11:25:16 +0800 Subject: [PATCH] =?UTF-8?q?1.=E4=BC=98=E5=8C=96ws=E8=BF=94=E5=9B=9E?= =?UTF-8?q?=E5=8F=82=E6=95=B0=E4=B8=8D=E5=8C=85=E5=90=AB=E6=95=8F=E6=84=9F?= =?UTF-8?q?=E5=AD=97=E6=AE=B5user=5Fid=E7=AD=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/GameWebSocketDispatcher.php | 18 +++++- .../service/GameWebSocketPayloadHelper.php | 63 +++++++++++++++++++ app/process/GameWebSocketServer.php | 4 +- docs/36字花-移动端接口设计草案.md | 16 +++-- 4 files changed, 87 insertions(+), 14 deletions(-) diff --git a/app/common/service/GameWebSocketDispatcher.php b/app/common/service/GameWebSocketDispatcher.php index 4ed30fc..1aa7bfd 100644 --- a/app/common/service/GameWebSocketDispatcher.php +++ b/app/common/service/GameWebSocketDispatcher.php @@ -66,10 +66,13 @@ final class GameWebSocketDispatcher $payloadUserId = $parsed === false ? 0 : (int) $parsed; } + $rawData = is_array($event['data'] ?? null) ? $event['data'] : []; + $clientData = GameWebSocketPayloadHelper::sanitizeOutboundData($rawData); + $frame = json_encode([ 'event' => $event['event'] ?? $topic, 'topic' => $topic, - 'data' => $event['data'] ?? [], + 'data' => $clientData, 'server_time' => $event['server_time'] ?? time(), ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); if (!is_string($frame) || $frame === '') { @@ -128,9 +131,20 @@ final class GameWebSocketDispatcher */ public static function sendDirect(TcpConnection $connection, string $event, array $data, string $tag = ''): void { + $controlEvents = ['ws.connected', 'ws.subscribed', 'ws.error', 'pong']; + $payload = $data; + if (!in_array($event, $controlEvents, true)) { + if (isset($payload['data']) && is_array($payload['data'])) { + $payload['data'] = GameWebSocketPayloadHelper::sanitizeOutboundData($payload['data']); + } + } + if ($event === 'ws.connected') { + unset($payload['user_id']); + } + $frame = json_encode(array_merge([ 'event' => $event, - ], $data), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + ], $payload), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); if (!is_string($frame) || $frame === '') { return; } diff --git a/app/common/service/GameWebSocketPayloadHelper.php b/app/common/service/GameWebSocketPayloadHelper.php index c55aed1..da21e05 100644 --- a/app/common/service/GameWebSocketPayloadHelper.php +++ b/app/common/service/GameWebSocketPayloadHelper.php @@ -21,6 +21,22 @@ final class GameWebSocketPayloadHelper 'bet.accepted', ]; + /** + * 下发给客户端前从 data 中移除的字段(服务端入队/路由仍保留完整载荷)。 + * + * @var list + */ + public const OUTBOUND_STRIP_KEYS = [ + 'user_id', + 'uuid', + 'phone', + 'balance_before', + 'channel_id', + 'review_admin_id', + 'operator_admin_id', + 'idempotency_key', + ]; + /** * @return array{user_id: int, current_streak: int, streak_level: int, odds_factor: int, is_jackpot: bool} */ @@ -53,6 +69,53 @@ final class GameWebSocketPayloadHelper ]; } + /** + * 出站 WebSocket 帧 data 脱敏:移除 user_id 等(连接已绑定用户,无需在载荷中重复暴露)。 + * + * @param array $data + * @return array + */ + public static function sanitizeOutboundData(array $data): array + { + return self::stripSensitiveKeysRecursive($data, 0); + } + + /** + * @param array $data + * @return array + */ + private static function stripSensitiveKeysRecursive(array $data, int $depth): array + { + if ($depth > 8) { + return $data; + } + $out = []; + foreach ($data as $key => $value) { + if (!is_string($key)) { + continue; + } + if (in_array($key, self::OUTBOUND_STRIP_KEYS, true)) { + continue; + } + if (is_array($value)) { + $isList = array_is_list($value); + $child = []; + foreach ($value as $k => $item) { + if (is_array($item)) { + $child[$k] = self::stripSensitiveKeysRecursive($item, $depth + 1); + } else { + $child[$k] = $item; + } + } + $out[$key] = $isList ? array_values($child) : $child; + continue; + } + $out[$key] = $value; + } + + return $out; + } + /** * @param array $payload * @return array diff --git a/app/process/GameWebSocketServer.php b/app/process/GameWebSocketServer.php index 45dc99e..832ef9e 100644 --- a/app/process/GameWebSocketServer.php +++ b/app/process/GameWebSocketServer.php @@ -126,9 +126,7 @@ class GameWebSocketServer GameWebSocketDispatcher::sendDirect($connection, 'ws.connected', [ 'message' => 'WebSocket connected', - 'connection_id' => $connection->id, 'mode' => $auth['mode'], - 'user_id' => $auth['user_id'], 'server_time' => time(), 'heartbeat_interval' => 30, 'idle_timeout' => self::HEARTBEAT_IDLE_SECONDS, @@ -286,7 +284,7 @@ class GameWebSocketServer $payload = json_encode([ 'event' => 'admin.live.snapshot', 'topic' => 'admin.live.snapshot', - 'data' => $snapshot, + 'data' => GameWebSocketPayloadHelper::sanitizeOutboundData($snapshot), 'server_time' => time(), ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); if (!is_string($payload) || $payload === '') { diff --git a/docs/36字花-移动端接口设计草案.md b/docs/36字花-移动端接口设计草案.md index dbbe093..2d08996 100644 --- a/docs/36字花-移动端接口设计草案.md +++ b/docs/36字花-移动端接口设计草案.md @@ -788,8 +788,7 @@ - `event`:`ws.connected` - `message`:固定文案 `WebSocket connected`(便于联调日志) - `connection_id`:连接唯一标识(进程内) - - `mode`:`mobile` | `admin`(2026-05 新增;表明本连接的鉴权身份) - - `user_id`:int(2026-05 新增;mobile 模式为真实玩家 id,admin 模式为 0) + - `mode`:`mobile` | `admin`(表明本连接的鉴权身份;**不下发** `user_id`) - `server_time`:服务器时间戳(**秒**,int) - `heartbeat_interval`:建议心跳间隔(**秒**,当前实现固定为 `30`) - `idle_timeout`:服务端主动关闭的空闲秒数(**秒**,当前实现固定为 `60`;客户端 `idle_timeout - 心跳间隔` 内必须发出 `ping`,否则会被 server 主动 `close`) @@ -823,18 +822,18 @@ - 成功订阅后服务端返回:`{"event":"ws.subscribed","topics":[...]}`(已去重、按字典序排序,与提交顺序无关)。 - **`subscribe` 覆盖式生效**:每次发送都会**完全替换**该连接的订阅集合(不是累加)。需要追加请把已有列表一并发上来。 - 若未订阅主题,通常只能收到握手首帧(`ws.connected`)和心跳回包(`pong`)。 -- **服务端按 user_id 过滤**:mobile 模式连接只会收到 `data.user_id == 自己 user_id` 的 user 级主题(见 §7.0 列表);admin 模式不过滤,收到全量。**客户端仍应做一次防御性 `user_id` 过滤**,避免后续接口变更带来误处理。 +- **服务端按连接绑定用户过滤**:mobile 模式仅下发本人相关的 user 级主题;**出站 `data` 不含 `user_id`**(及其它敏感字段,见下)。客户端**无需**再按 `user_id` 过滤。 +- **出站脱敏字段(2026-05)**:`data` 中移除 `user_id`、`uuid`、`phone`、`balance_before`、`channel_id` 等;`jackpot.hit` 的 `hits[]` 仅保留 `nickname`、`period_no`、`total_win`、`result_number` 等展示字段。 - **不下发** `streak_win_reward` 全表(1~10 档);赔率仅通过 `user.streak` / `wallet.changed` / `bet.accepted` 及 `lobbyInit.user_snapshot` 推送**当前登录玩家**本局适用字段。 #### 7.1.2A 连胜赔率与连胜场次(WebSocket) - **`user.streak`**(开奖结算后推送;载荷为当前玩家本局适用赔率) - - `data.user_id`:int - `data.current_streak`:int - `data.streak_level`:int - `data.odds_factor`:int - `data.is_jackpot`:bool -- **`wallet.changed` / `bet.accepted` / `bet.win`**:在原有字段上合并同上 **`current_streak`**、**`streak_level`**、**`odds_factor`**、**`is_jackpot`**;客户端按 `user_id` 过滤,仅处理本用户。 +- **`wallet.changed` / `bet.accepted` / `bet.win`**:在原有字段上合并 **`current_streak`**、**`streak_level`**、**`odds_factor`**、**`is_jackpot`**(**不含** `user_id`)。 - **`bet.accepted` 与 `bet.win` 的 `is_jackpot` 区别**:`bet.accepted` 表示**下注时**本笔适用档位是否大奖档(赔率展示);**开奖中奖通知以 `bet.win` 为准**,其 `is_jackpot` 表示**结算时**该用户中奖注单是否含大奖档(`streak_at_bet` 对应 `streak_win_reward.is_jackpot=true`,通常为第 10 档)。 #### 7.1.3 推送频率与触发规则(当前实现) @@ -851,7 +850,7 @@ - `period.opened` / `period.payout` / `admin.live.opened`:按开奖流程阶段触发(事件触发型,非固定频率)。 - `wallet.changed`:仅在余额发生变更时推送(如下注扣款、充值入账、派彩入账)。派彩时 `biz_type=payout`,并带 `amount`(本次派彩金额)、`period_no`、`period_id`、`result_number`(若有)。 - **`bet.win`(本期中奖,小奖/大奖统一)**:开奖结算后,**凡本期有中奖的用户**均按用户聚合推送一帧(与 `wallet.changed(payout)` 同一结算批次);**个人中奖弹窗/横幅统一监听此主题**,用 `data.is_jackpot` 区分普通档与大奖档样式。**中大奖档用户同样会收到 `bet.win`**,无需仅依赖 `jackpot.hit`。 - - `data.user_id` / `data.period_id` / `data.period_no` / `data.result_number` + - `data.period_id` / `data.period_no` / `data.result_number`(**不含** `user_id`) - `data.total_win`:本期该用户派彩合计(已入账部分;若触发**后台大奖审核**(`win_amount >= game_config.jackpot_max_amount`)且注单为待审核,可能尚未入账,但仍会推送本事件) - `data.balance_after`:推送时用户余额(已派彩则为派彩后余额) - `data.bets[]`:`{ bet_id, win_amount }` 明细 @@ -867,8 +866,7 @@ - **推送顺序**:先 `bet.win`(按用户,含 `is_jackpot`)→ 再 `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}`**,供前端弹窗/横幅通知直接展示) + - `nickname`:string(用户昵称,供全站公告展示;**不含** `user_id`) - `period_no`:string - `total_win`:string(本期该用户的命中大奖派彩合计,金额字符串) - `result_number`:int @@ -925,7 +923,7 @@ php scripts/republish_bet_win.php --period-no=20260526-183418-c9c90ef1 3. `POST /api/game/lobbyInit` 拉首页初始化(请求头带 `auth-token`) 4. 取得 WebSocket 基址(**当前非 lobbyInit 下发**:与运维/打包配置中的 `H5_WEBSOCKET_URL` 或自建配置接口一致)后建立 WebSocket 连接,**立即发送 `subscribe`** 监听状态流(见 §7.0 / §7.1;**务必包含 `bet.win`**) 5. 用户下注调用 `POST /api/game/placeBet` -6. 下单后以 `placeBet.balance_after` 与 `wallet.changed` 同步余额;开奖结算后监听 **`bet.win`**(`data.user_id` 为本用户且 `is_win=true`)展示中奖,大奖档看 `data.is_jackpot` +6. 下单后以 `placeBet.balance_after` 与 `wallet.changed` 同步余额;开奖结算后监听 **`bet.win`**(`is_win=true`)展示中奖,大奖档看 `data.is_jackpot`(连接已绑定用户,载荷无 `user_id`) 7. 断线或页面回前台时,重连 WebSocket 并重新订阅主题回补实时状态 ## 8.2 充值到下注到提现闭环