diff --git a/app/admin/controller/game/Live.php b/app/admin/controller/game/Live.php index 9f0b76d..25481a3 100644 --- a/app/admin/controller/game/Live.php +++ b/app/admin/controller/game/Live.php @@ -71,16 +71,20 @@ class Live extends Backend $adminId = $this->auth ? (int) ($this->auth->id ?? 0) : 0; $adminWs = GameWebSocketAuthHelper::issueAdminWsToken($adminId); $baseWsUrl = WebSocketConfigHelper::wsUrl($request); - $wsUrl = WebSocketConfigHelper::appendTokensToWsUrl($baseWsUrl, [ - 'admin_ws_token' => (string) ($adminWs['token'] ?? ''), - ]); + $wsUrl = GameWebSocketAuthHelper::buildAdminConnectUrl( + $baseWsUrl, + (string) ($adminWs['token'] ?? '') + ); return $this->success('', [ 'name' => 'ws.admin.live', 'ws_url' => $wsUrl, 'ws_base_url' => $baseWsUrl, - 'admin_ws_token' => (string) ($adminWs['token'] ?? ''), + 'admin-ws-token' => (string) ($adminWs['token'] ?? ''), 'admin_ws_token_ttl' => (int) ($adminWs['ttl'] ?? 0), + 'ws_query_params' => [ + GameWebSocketAuthHelper::QUERY_ADMIN_WS_TOKEN, + ], 'connect_tip' => 'The admin live page auto-subscribes topics for status, draw result and payout events.', 'subscribe_topics' => $topics, 'sample_messages' => [ diff --git a/app/admin/controller/test/GameCurrentStatus.php b/app/admin/controller/test/GameCurrentStatus.php index 3b3dde2..e6e2f6a 100644 --- a/app/admin/controller/test/GameCurrentStatus.php +++ b/app/admin/controller/test/GameCurrentStatus.php @@ -47,16 +47,20 @@ class GameCurrentStatus extends Backend $adminId = $this->auth ? (int) ($this->auth->id ?? 0) : 0; $adminWs = GameWebSocketAuthHelper::issueAdminWsToken($adminId); $baseWsUrl = WebSocketConfigHelper::wsUrl($request); - $wsUrl = WebSocketConfigHelper::appendTokensToWsUrl($baseWsUrl, [ - 'admin_ws_token' => (string) ($adminWs['token'] ?? ''), - ]); + $wsUrl = GameWebSocketAuthHelper::buildAdminConnectUrl( + $baseWsUrl, + (string) ($adminWs['token'] ?? '') + ); return $this->success('', [ 'name' => 'ws.period', 'ws_url' => $wsUrl, 'ws_base_url' => $baseWsUrl, - 'admin_ws_token' => (string) ($adminWs['token'] ?? ''), + 'admin-ws-token' => (string) ($adminWs['token'] ?? ''), 'admin_ws_token_ttl' => (int) ($adminWs['ttl'] ?? 0), + 'ws_query_params' => [ + GameWebSocketAuthHelper::QUERY_ADMIN_WS_TOKEN, + ], 'connect_tip' => '连接成功后将自动订阅下列主题。真实业务仅在有玩家下注/结算时推送赔率;本页联调会在订阅后额外推送带 is_test/preview 的演示帧(见下方测试玩家赔率)。', 'subscribe_topics' => $subscribeTopics, 'odds_push_topics' => $oddsPushTopics, diff --git a/app/common/library/admin/WebSocketConfigHelper.php b/app/common/library/admin/WebSocketConfigHelper.php index e01cbb3..ce928f9 100644 --- a/app/common/library/admin/WebSocketConfigHelper.php +++ b/app/common/library/admin/WebSocketConfigHelper.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace app\common\library\admin; +use app\common\service\GameWebSocketAuthHelper; use Webman\Http\Request; final class WebSocketConfigHelper @@ -41,32 +42,97 @@ final class WebSocketConfigHelper } /** - * 在基础 ws_url 上拼接握手鉴权 Query: - * - 后台用:auth_token + admin_ws_token(可观测全量主题,无 user_id 过滤) - * - H5 用:调用方传 user_token;与 auth_token 一起拼上去 + * 在基础 ws_url 上拼接握手鉴权 Query(**输出统一为连字符**:auth-token、user-token、admin-ws-token)。 * - * @param array{auth_token?: string, user_token?: string, admin_ws_token?: string} $tokens + * $tokens 的键可使用标准名或旧名下划线别名(auth_token 等),拼到 URL 时一律转为连字符。 + * + * @param array $tokens 如 auth-token、user-token、admin-ws-token + * @param array $extraQuery 如 device_id、lang */ - public static function appendTokensToWsUrl(string $wsUrl, array $tokens): string + public static function appendTokensToWsUrl(string $wsUrl, array $tokens, array $extraQuery = []): string { $wsUrl = trim($wsUrl); if ($wsUrl === '') { return $wsUrl; } - $pairs = []; - foreach (['auth_token', 'user_token', 'admin_ws_token'] as $key) { - $val = isset($tokens[$key]) && is_string($tokens[$key]) ? trim($tokens[$key]) : ''; - if ($val !== '') { - $pairs[] = $key . '=' . rawurlencode($val); + + $authVal = self::pickTokenFromMap($tokens, [ + GameWebSocketAuthHelper::QUERY_AUTH_TOKEN, + 'auth_token', + 'authToken', + ]); + $userVal = self::pickTokenFromMap($tokens, [ + GameWebSocketAuthHelper::QUERY_USER_TOKEN, + 'user_token', + 'userToken', + 'token', + ]); + $adminVal = self::pickTokenFromMap($tokens, [ + GameWebSocketAuthHelper::QUERY_ADMIN_WS_TOKEN, + 'admin_ws_token', + 'adminWsToken', + ]); + + $canonical = []; + if ($authVal !== '') { + $canonical[GameWebSocketAuthHelper::QUERY_AUTH_TOKEN] = $authVal; + } + if ($userVal !== '') { + $canonical[GameWebSocketAuthHelper::QUERY_USER_TOKEN] = $userVal; + } + if ($adminVal !== '') { + $canonical[GameWebSocketAuthHelper::QUERY_ADMIN_WS_TOKEN] = $adminVal; + } + + foreach ($extraQuery as $k => $v) { + if (!is_string($k) || $k === '') { + continue; + } + if (!is_scalar($v)) { + continue; + } + $s = trim((string) $v); + if ($s !== '') { + $canonical[$k] = $s; } } - if ($pairs === []) { + + if ($canonical === []) { return $wsUrl; } + + $pairs = []; + foreach ($canonical as $key => $val) { + $pairs[] = $key . '=' . rawurlencode($val); + } $sep = str_contains($wsUrl, '?') ? '&' : '?'; + return $wsUrl . $sep . implode('&', $pairs); } + /** + * @param array $tokens + * @param list $aliases + */ + private static function pickTokenFromMap(array $tokens, array $aliases): string + { + foreach ($aliases as $key) { + if (!isset($tokens[$key])) { + continue; + } + $val = $tokens[$key]; + if (!is_string($val)) { + continue; + } + $s = trim($val); + if ($s !== '') { + return $s; + } + } + + return ''; + } + private static function isLoopbackWsUrl(string $url): bool { $host = parse_url($url, PHP_URL_HOST); diff --git a/app/common/service/GameWebSocketAuthHelper.php b/app/common/service/GameWebSocketAuthHelper.php index 596718c..cb63b0d 100644 --- a/app/common/service/GameWebSocketAuthHelper.php +++ b/app/common/service/GameWebSocketAuthHelper.php @@ -12,11 +12,10 @@ use Throwable; /** * WebSocket 握手鉴权助手(与 HTTP §1.3 对齐): * - * 两种合法身份: - * 1) **mobile(H5/移动端)**:URL Query 必须带 `auth_token` + `user_token`,校验通过后绑定 user_id; + * 两种合法身份(URL Query 参数名统一为连字符,与 HTTP 请求头一致): + * 1) **mobile(H5/移动端)**:必须带 **`auth-token`** + **`user-token`**,校验通过后绑定 user_id; * 分发器对 user 级主题(bet.win 等)按 user_id 过滤,只发本人。 - * 2) **admin(后台联调/实时对局页)**:URL Query 必须带 `auth_token` + `admin_ws_token`; - * `admin_ws_token` 由后台 `wsConfig` 接口签发并写入 Redis(短时签名)。绑定 user_id=0, + * 2) **admin(后台联调/实时对局页)**:必须带 **`admin-ws-token`**(由后台 `wsConfig` 签发,写 Redis 短时签名)。绑定 user_id=0, * 分发器对该连接不做 user 级过滤,可观测全量推送(用于运维/联调)。 * * 任一身份通过即可建连;都不满足则拒绝握手。 @@ -35,7 +34,23 @@ use Throwable; */ final class GameWebSocketAuthHelper { - /** admin_ws_token 在 Redis 中的 key 前缀;value 存 admin_id,TTL 由 issueAdminWsToken 决定 */ + /** WebSocket 握手 Query 标准参数名(与 HTTP 头 auth-token / user-token 一致,一律用连字符) */ + public const QUERY_AUTH_TOKEN = 'auth-token'; + + public const QUERY_USER_TOKEN = 'user-token'; + + public const QUERY_ADMIN_WS_TOKEN = 'admin-ws-token'; + + /** @var list 兼容旧客户端的下划线/驼峰别名(解析时仍可读,拼 URL 时勿用) */ + private const LEGACY_AUTH_KEYS = ['auth_token', 'authToken']; + + /** @var list */ + private const LEGACY_USER_KEYS = ['user_token', 'userToken', 'token']; + + /** @var list */ + private const LEGACY_ADMIN_KEYS = ['admin_ws_token', 'adminWsToken']; + + /** admin-ws-token 在 Redis 中的 key 前缀;value 存 admin_id,TTL 由 issueAdminWsToken 决定 */ private const ADMIN_TOKEN_REDIS_PREFIX = 'dfw:v1:ws:admin_token:'; private const ADMIN_TOKEN_DEFAULT_TTL = 7200; @@ -45,9 +60,9 @@ final class GameWebSocketAuthHelper */ public static function authorize(array $query): array { - $authToken = self::pickFirstString($query, ['auth_token', 'auth-token', 'authToken']); - $userToken = self::pickFirstString($query, ['user_token', 'user-token', 'userToken', 'token']); - $adminWsToken = self::pickFirstString($query, ['admin_ws_token', 'admin-ws-token', 'adminWsToken']); + $authToken = self::pickFirstString($query, array_merge([self::QUERY_AUTH_TOKEN], self::LEGACY_AUTH_KEYS)); + $userToken = self::pickFirstString($query, array_merge([self::QUERY_USER_TOKEN], self::LEGACY_USER_KEYS)); + $adminWsToken = self::pickFirstString($query, array_merge([self::QUERY_ADMIN_WS_TOKEN], self::LEGACY_ADMIN_KEYS)); // ===== Admin 旁路:只校验 admin_ws_token(由后台 wsConfig 签发,已隐含管理员身份) ===== if ($adminWsToken !== '') { @@ -152,6 +167,29 @@ final class GameWebSocketAuthHelper return $adminId === false ? 0 : (int) $adminId; } + /** + * 拼装移动端 WebSocket 连接 URL(Query 固定为 auth-token、user-token)。 + * + * @param array $extraQuery 其它 Query,如 device_id、lang + */ + public static function buildMobileConnectUrl(string $baseWsUrl, string $authToken, string $userToken, array $extraQuery = []): string + { + return \app\common\library\admin\WebSocketConfigHelper::appendTokensToWsUrl($baseWsUrl, [ + self::QUERY_AUTH_TOKEN => $authToken, + self::QUERY_USER_TOKEN => $userToken, + ], $extraQuery); + } + + /** + * 拼装后台 WebSocket 连接 URL(Query 固定为 admin-ws-token)。 + */ + public static function buildAdminConnectUrl(string $baseWsUrl, string $adminWsToken): string + { + return \app\common\library\admin\WebSocketConfigHelper::appendTokensToWsUrl($baseWsUrl, [ + self::QUERY_ADMIN_WS_TOKEN => $adminWsToken, + ]); + } + /** * 从 ws header 中解析 GET 行 Query(Workerman 在 onWebSocketConnect($connection, $request) 时 * $request 可能为字符串或对象;为兼容,这里允许直接传 URI Query 字符串)。 diff --git a/app/process/GameWebSocketServer.php b/app/process/GameWebSocketServer.php index 69770bb..45dc99e 100644 --- a/app/process/GameWebSocketServer.php +++ b/app/process/GameWebSocketServer.php @@ -21,8 +21,8 @@ use Workerman\Timer; * 设计与 docs/36字花-移动端接口设计草案.md §7 对齐: * * - 握手鉴权(GameWebSocketAuthHelper) - * - mobile:URL Query `auth_token` + `user_token`,绑定 user_id,user 级主题按 user_id 过滤 - * - admin: URL Query `admin_ws_token`(后台 wsConfig 签发,写 Redis 短时签名),user_id=0, + * - mobile:URL Query **`auth-token`** + **`user-token`**(与 HTTP 头一致),绑定 user_id + * - admin: URL Query **`admin-ws-token`**(后台 wsConfig 签发),user_id=0, * user 级主题不过滤(运维/联调可观测全量) * - 客户端 -> 服务端:`{"action":"ping"}` / `{"action":"subscribe","topics":[...]}` * - 服务端 -> 客户端:`ws.connected` / `ws.subscribed` / `pong` / `ws.error` / 业务事件帧 diff --git a/docs/36字花-移动端接口设计草案.md b/docs/36字花-移动端接口设计草案.md index 71c9540..dbbe093 100644 --- a/docs/36字花-移动端接口设计草案.md +++ b/docs/36字花-移动端接口设计草案.md @@ -765,8 +765,8 @@ - **混合内容**:若 H5 页面为 **HTTPS**,浏览器要求 WebSocket 使用 **`wss://`**,否则会被拦截。 - **事件投递依赖 Redis**:HTTP 侧业务通过 **`GameWebSocketEventBus`**(Redis 列表)将事件投递到 WebSocket 进程;Redis 不可用或队列异常时,**除 `admin.live.snapshot` 外**的广播类推送可能收不到。后台若订阅了 `admin.live.snapshot`,服务端有**每秒直连构建快照**的兜底,不依赖队列。 - **握手鉴权(2026-05 重构后强制)**:`GameWebSocketServer::onWebSocketConnect` 通过 `GameWebSocketAuthHelper::authorize` 校验 URL Query。两种合法身份: - - **mobile(H5/移动端)**:必须同时携带 `auth_token`(同 HTTP `auth-token`)+ `user_token`(同 HTTP `user-token`,亦支持 `token` 同义)。校验通过后连接被绑定 `user_id`,分发器仅向其推送本人的 user 级主题。 - - **admin(后台/运维)**:必须携带 `admin_ws_token`(由后台 `wsConfig` 接口签发,写入 Redis Key `dfw:v1:ws:admin_token:{token}`,默认 TTL 7200s)。后台已 `wsConfig` 中把该 token 拼到 `ws_url` 一并返回,前端透传即可;admin 模式 `user_id=0`,可订阅任意主题并收到**全量** user 级推送(运维联调用)。 + - **mobile(H5/移动端)**:必须同时携带 Query **`auth-token`**、**`user-token`**(与 HTTP 请求头同名,**统一用连字符**)。校验通过后绑定 `user_id`,分发器仅向其推送本人的 user 级主题。服务端仍兼容旧别名 `auth_token` / `user_token` 解析,但**新接入请只用连字符**。 + - **admin(后台/运维)**:必须携带 Query **`admin-ws-token`**(由后台 `wsConfig` 签发,写入 Redis,默认 TTL 7200s)。`ws_url` 已自动拼接该参数;admin 模式 `user_id=0`,可观测全量推送。 - 任一身份不通过 → 服务端发送 `{"event":"ws.error","code":1101,"message":"Authentication failed: ..."}` 并立即 `close`。 - **服务端按 user_id 过滤(user 级主题)**:以下 topic 的 `data.user_id` 必须 **等于** 当前连接绑定的 `user_id` 才会下发——**`bet.win` / `user.streak` / `wallet.changed` / `bet.accepted` / `auto.spin.progress`**。其它 topic(`period.tick` / `period.opened` / `jackpot.hit` / `admin.*` 等)按订阅广播。admin 模式不参与此过滤。 - **心跳超时(服务端主动)**:连接 60s 内无任何上行报文(含 `ping`/`subscribe`)即被 server 主动 `close`,触发客户端走重连流程;避免半关闭的僵尸连接长期持有订阅却不能实际送达推送。 @@ -778,11 +778,11 @@ - **连接地址**:见 **§7.0**(环境变量 `H5_WEBSOCKET_URL` 或后台 `wsConfig` 返回的 `ws_url`) - **客户端**:浏览器原生 `WebSocket`(`ws://` / `wss://`) - **连接时必带 Query 参数(2026-05 起强制)**: - - **H5/移动端**:`auth_token=` + `user_token=`(亦支持 `token` 同义)。`device_id`、`lang` 仍可携带,但服务端不强制。 - - **后台**:`admin_ws_token=`(后台 `wsConfig` 已直接把它拼到 `ws_url`,前端透传即可)。 + - **H5/移动端**:`auth-token=` + `user-token=`。可选:`device_id`、`lang`。 + - **后台**:`admin-ws-token=`(已拼入 `ws_url`)。 - 示例: - - H5:`wss://ws.example.com/ws/?auth_token=xxx&user_token=yyy&device_id=ios_001&lang=zh` - - 后台:`wss://ws.example.com/ws/?admin_ws_token=zzz` + - H5:`wss://ws.example.com/ws/?auth-token=xxx&user-token=yyy&device_id=ios_001&lang=zh` + - 后台:`wss://ws.example.com/ws/?admin-ws-token=zzz` - 缺失任一必填字段或 token 失效 → 服务端回 `{"event":"ws.error","code":1101,...}` 后立即关闭连接。 - **连接成功首帧(当前实现)**: - `event`:`ws.connected`