From 4bd332a6ffecbe8a02f6426669963077f5d2c911 Mon Sep 17 00:00:00 2001 From: zhenhui <1276357500@qq.com> Date: Fri, 29 May 2026 14:25:59 +0800 Subject: [PATCH] =?UTF-8?q?1.=E6=96=B0=E5=A2=9E=E9=80=80=E5=87=BA=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E7=9A=84=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/controller/Auth.php | 28 ++++++++++ .../service/MobileAuthDeviceService.php | 15 +++++ config/route.php | 2 +- docs/36字花-移动端接口设计草案.md | 56 ++++++++++++++++--- docs/en/36zihua-mobile-api-design-draft.md | 56 ++++++++++++++++--- 5 files changed, 138 insertions(+), 19 deletions(-) diff --git a/app/api/controller/Auth.php b/app/api/controller/Auth.php index 7b0c78b..71b5e27 100644 --- a/app/api/controller/Auth.php +++ b/app/api/controller/Auth.php @@ -115,6 +115,34 @@ class Auth extends MobileBase return $this->mobileSuccess($this->buildLoginPayload()); } + public function userLogout(Request $request): Response + { + $response = $this->initializeMobile($request); + if ($response !== null) { + return $response; + } + + $refreshToken = trim((string) $request->post('refresh_token', '')); + if ($refreshToken === '') { + $refreshToken = trim((string) $request->post('refreshToken', '')); + } + + if ($this->auth->isLogin()) { + $userId = (int) $this->auth->id; + if ($refreshToken === '') { + $refreshToken = $this->auth->getRefreshToken(); + } + $this->auth->logout(); + MobileAuthDeviceService::onUserLogout($userId); + } + + if ($refreshToken !== '') { + Token::delete($refreshToken); + } + + return $this->mobileSuccess(); + } + public function tokenRefresh(Request $request): Response { $response = $this->initializeMobile($request); diff --git a/app/common/service/MobileAuthDeviceService.php b/app/common/service/MobileAuthDeviceService.php index 03d1714..f0b4e63 100644 --- a/app/common/service/MobileAuthDeviceService.php +++ b/app/common/service/MobileAuthDeviceService.php @@ -122,6 +122,21 @@ final class MobileAuthDeviceService * * @return string|null 失败时返回多语言 message key,成功返回 null */ + /** + * 用户主动退出:清除 Redis 中的活跃设备绑定。 + */ + public static function onUserLogout(int $userId): void + { + if ($userId <= 0) { + return; + } + try { + Redis::del(self::PREFIX_USER_DEVICE . $userId); + } catch (Throwable) { + // ignore + } + } + public static function validateUserDeviceSession(string $authToken, int $userId): ?string { if ($userId <= 0) { diff --git a/config/route.php b/config/route.php index d84d9de..0c0d164 100644 --- a/config/route.php +++ b/config/route.php @@ -73,7 +73,7 @@ Route::get('/api/v1/authToken', [\app\api\controller\V1::class, 'authToken']); // api/user(GET 获取配置,POST 登录/注册) Route::add(['GET', 'POST'], '/api/user/checkIn', [\app\api\controller\User::class, 'checkIn']); -Route::post('/api/user/logout', [\app\api\controller\User::class, 'logout']); +Route::post('/api/user/logout', [\app\api\controller\Auth::class, 'userLogout']); // api/install(安装流程多为 POST) Route::add(['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD'], '/api/install/terminal', [\app\api\controller\Install::class, 'terminal']); diff --git a/docs/36字花-移动端接口设计草案.md b/docs/36字花-移动端接口设计草案.md index b565f4b..d236438 100644 --- a/docs/36字花-移动端接口设计草案.md +++ b/docs/36字花-移动端接口设计草案.md @@ -5,6 +5,8 @@ **补充(2026-04)**:§**1.5** 描述服务端 **Redis 热点缓存**(`GameHotDataRedis`),**不改变**各接口 URL、参数与响应字段约定,仅供联调与运维对照。 +**补充(2026-05)**:§**1.3** / §**1.4** / §**2.3** 描述 **单设备登录** 与 **`POST /api/user/logout`** 退出接口(依赖 Redis 存储 `device_id` 绑定)。 + ## 1. 设计约定 ### 1.1 基础约定 @@ -63,6 +65,11 @@ ### 1.3 鉴权方式 - **接口鉴权(auth-token)**:所有移动端业务接口请求时必须携带请求头 `auth-token`(由 `/api/v1/authToken` 签发) - **用户登录鉴权(user-token)**:需要登录的接口携带请求头 `user-token`;token 失效后调用刷新或重新登录 +- **单设备登录(实现约束)**: + - 获取 `auth-token` 时 Query 中的 `device_id` 会与该 `auth-token` 绑定(Redis) + - 同一账号**仅允许一个活跃设备**:新设备登录成功后,会清除该用户其它 `user-token` / `refresh_token`,并绑定新 `device_id` + - 旧设备继续携带原 `user-token` 访问需登录接口时,返回 `code=1101`,`message` 为「您的账号已在其他设备登录,请重新登录」(`lang=en` 时为英文) + - 客户端应为每个安装实例生成**稳定且唯一**的 `device_id`(勿每次随机,否则会被视为新设备) ### 1.4 获取接口鉴权 Token(auth-token) - **GET** `/api/v1/authToken` @@ -87,6 +94,10 @@ - `expires_in`:int(含义:有效期秒数) - `server_time`:int(含义:服务器时间戳,用于校时) +服务端行为(与单设备登录相关): +- 签发成功后将 `device_id` 与 `auth_token` 写入 Redis,TTL 与 `expires_in` 一致 +- 后续登录、已登录业务接口、WebSocket 握手均以此 `auth-token` 所绑定的 `device_id` 与用户活跃设备比对 + 可能错误码: - `1001` 参数缺失 - `1002` 参数格式错误 @@ -128,13 +139,13 @@ ### 2.1 注册 - **POST** `/api/user/register` -- 用途:仅手机号注册并绑定邀请归属(admin/channel) +- 用途:仅手机号注册并绑定邀请归属(admin/channel);注册成功后会自动登录 +- 请求头:必带 `auth-token`(设备标识以该 token 绑定的 `device_id` 为准,见 §1.3) 请求参数: - `username`:string,手机号(含义:注册账号,仅支持大陆手机号) - `password`:string,明文经 HTTPS 传输(含义:登录密码,服务端需加盐哈希存储) - `invite_code`:string,必填(含义:子代理邀请码,用于绑定渠道 `channel_id` 与归属) -- `device_id`:string,可选(含义:设备标识,用于风控与登录记录) 返回参数: - `user-token`:string(含义:后续接口登录态令牌;用于需要登录的接口请求头) @@ -149,11 +160,14 @@ ### 2.2 登录 - **POST** `/api/user/login` +- 请求头:必带 `auth-token`(其绑定的 `device_id` 即本次登录设备,**无需**在 body 重复传 `device_id`) 请求参数: - `username`:string(含义:登录账号,当前支持手机号) - `password`:string(含义:登录密码) -- `device_id`:string,可选(含义:设备标识,辅助风控) + +服务端行为: +- 登录成功后会清除该用户其它会话 token,并将活跃设备设为当前 `auth-token` 对应 `device_id`(见 §1.3) 返回参数: - `user-token`:string(含义:访问令牌;用于需要登录的接口请求头) @@ -166,7 +180,29 @@ - `channel_id`:int(含义:归属渠道 ID) - `risk_flags`:int(含义:风控状态位) -### 2.3 获取当前用户信息 +### 2.3 退出登录 +- **POST** `/api/user/logout` +- 用途:主动退出当前账号,作废登录态并释放单设备占用 + +请求头: +- `auth-token`:必填 +- `user-token`:已登录时必填 + +请求参数(可选): +- `refresh_token`:string(含义:刷新令牌;建议传入以便服务端一并删除;未传且已登录时服务端尝试删除当前会话的 refresh_token) + +返回参数: +- `data`:空对象 `{}` + +服务端行为: +- 删除当前 `user-token`、清除 Redis 中该用户的活跃 `device_id` 绑定 +- 若提供 `refresh_token` 则删除对应 refresh 记录 +- **幂等**:未登录或 token 已失效时仍返回 `code=1`(便于客户端清理本地缓存) + +可能错误码: +- `1101`:`auth-token` 缺失或无效(与 §1.4 一致) + +### 2.4 获取当前用户信息 - **POST** `/api/user/profile` 返回参数(金额类字段统一 2 位小数字符串,与钱包展示口径一致): @@ -203,8 +239,9 @@ - `count`:int(当前待审核提现订单数) - `max`:int(单用户最多允许的待审核提现数,当前为 `3`;超过 `withdrawCreate` 返回 `code=2004`) -### 2.4 刷新令牌(可选) +### 2.5 刷新令牌(可选) - **POST** `/api/user/refreshToken` +- 请求头:必带 `auth-token`;已登录会话须与当前活跃 `device_id` 一致(§1.3) 请求参数: - `refresh_token`:string(含义:续签访问令牌的凭证) @@ -765,7 +802,7 @@ - **混合内容**:若 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/移动端)**:必须同时携带 Query **`auth-token`**、**`user-token`**(与 HTTP 请求头同名,**统一用连字符**)。校验通过后绑定 `user_id`,分发器仅向其推送本人的 user 级主题。服务端仍兼容旧别名 `auth_token` / `user_token` 解析,但**新接入请只用连字符**。 + - **mobile(H5/移动端)**:必须同时携带 Query **`auth-token`**、**`user-token`**(与 HTTP 请求头同名,**统一用连字符**)。校验通过后绑定 `user_id`,分发器仅向其推送本人的 user 级主题;并校验 `auth-token` 绑定 `device_id` 与用户活跃设备一致(§1.3),异设备会话将被拒绝。服务端仍兼容旧别名 `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 模式不参与此过滤。 @@ -918,13 +955,14 @@ php scripts/republish_bet_win.php --period-no=20260526-183418-c9c90ef1 ## 8. 移动端完整调用流程 ## 8.1 首次进入游戏 -1. `GET /api/v1/authToken?secret=xxx×tamp=xxx&device_id=xxx&signature=xxx` 获取 `auth-token` -2. `POST /api/user/login` 登录(请求头带 `auth-token`) -3. `POST /api/game/lobbyInit` 拉首页初始化(请求头带 `auth-token`) +1. `GET /api/v1/authToken?secret=xxx×tamp=xxx&device_id=xxx&signature=xxx` 获取 `auth-token`(`device_id` 须为客户端稳定设备码) +2. `POST /api/user/login` 登录(请求头带 `auth-token` + 登录成功后带 `user-token`) +3. `POST /api/game/lobbyInit` 拉首页初始化(请求头带 `auth-token` + `user-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`**(`is_win=true`)展示中奖,大奖档看 `data.is_jackpot`(连接已绑定用户,载荷无 `user_id`) 7. 断线或页面回前台时,重连 WebSocket 并重新订阅主题回补实时状态 +8. 用户主动退出:`POST /api/user/logout`(请求头 `auth-token` + `user-token`,body 可选 `refresh_token`),成功后清除本地 token 并关闭 WebSocket ## 8.2 充值到下注到提现闭环 1. 拉取档位:`POST /api/finance/depositTierList`(玩家选择一档,并记下该档 `channels[].code`) diff --git a/docs/en/36zihua-mobile-api-design-draft.md b/docs/en/36zihua-mobile-api-design-draft.md index a534ad5..52c7a86 100644 --- a/docs/en/36zihua-mobile-api-design-draft.md +++ b/docs/en/36zihua-mobile-api-design-draft.md @@ -6,6 +6,8 @@ Scope: **platform-wide single period number and single draw result**; channels a **Addendum (2026-04)**: §**1.5** describes server-side **Redis hot-spot caching** (`GameHotDataRedis`). It does **not** change any interface URL, parameter, or response field contracts; it is for integration testing and operations reference only. +**Addendum (2026-05)**: §**1.3** / §**1.4** / §**2.3** document **single-device login** and **`POST /api/user/logout`** (Redis-backed `device_id` binding). + ## 1. Design Conventions ### 1.1 Base Conventions @@ -64,6 +66,11 @@ Scope: **platform-wide single period number and single draw result**; channels a ### 1.3 Authentication - **API auth (`auth-token`)**: all mobile business APIs must send header `auth-token` (issued by `/api/v1/authToken`) - **User session (`user-token`)**: login-protected APIs send header `user-token`; on expiry, refresh or log in again +- **Single-device session (server rule)**: + - `device_id` in the `authToken` query is bound to that `auth-token` in Redis + - Each account allows **one active device** only: a successful login on a new device clears other `user-token` / `refresh_token` rows and binds the new `device_id` + - Old devices calling login-protected APIs get `code=1101` with message like “logged in on another device, please sign in again” (`lang=en` for English) + - Clients must use a **stable unique** `device_id` per install (do not randomize on every launch) ### 1.4 Obtain API Auth Token (`auth-token`) - **GET** `/api/v1/authToken` @@ -88,6 +95,10 @@ Response parameters: - `expires_in`: int (TTL in seconds) - `server_time`: int (server timestamp for clock sync) +Server behavior (single-device): +- On success, stores `device_id` for this `auth_token` in Redis with TTL = `expires_in` +- Login, login-protected HTTP APIs, and WebSocket handshake compare this `device_id` with the user’s active device + Possible error codes: - `1001` missing parameter - `1002` invalid parameter format @@ -129,13 +140,13 @@ Possible error codes: ### 2.1 Register - **POST** `/api/user/register` -- Purpose: phone-only registration with invite attribution (admin/channel) +- Purpose: phone-only registration with invite attribution (admin/channel); auto-login on success +- Headers: `auth-token` required (device id is taken from that token’s bound `device_id`; see §1.3) Request parameters: - `username`: string, phone number (registration account; mainland China mobile only) - `password`: string, plaintext over HTTPS (login password; server stores salted hash) - `invite_code`: string, required (sub-agent invite code; binds `channel_id` and ownership) -- `device_id`: string, optional (device id for risk control and login logs) Response parameters: - `user-token`: string (session token for login-protected APIs) @@ -150,11 +161,14 @@ Response parameters: ### 2.2 Login - **POST** `/api/user/login` +- Headers: `auth-token` required (bound `device_id` is the login device; **do not** send `device_id` in body) Request parameters: - `username`: string (login account; currently phone) - `password`: string (login password) -- `device_id`: string, optional (risk assist) + +Server behavior: +- On success, clears other sessions for this user and sets active device to the `auth-token`’s `device_id` (§1.3) Response parameters: - `user-token`: string (access token for login-protected APIs) @@ -167,7 +181,29 @@ Response parameters: - `channel_id`: int (attribution channel id) - `risk_flags`: int (risk status bitmask) -### 2.3 Current User Profile +### 2.3 Logout +- **POST** `/api/user/logout` +- Purpose: sign out, invalidate session, release single-device lock + +Headers: +- `auth-token`: required +- `user-token`: required when logged in + +Request parameters (optional): +- `refresh_token`: string (recommended; server deletes it; if omitted while logged in, server tries current session refresh token) + +Response: +- `data`: empty object `{}` + +Server behavior: +- Deletes current `user-token`, clears Redis active `device_id` for the user +- Deletes `refresh_token` when provided +- **Idempotent**: returns `code=1` even if already logged out (client can clear local storage) + +Possible error codes: +- `1101`: missing or invalid `auth-token` (same as §1.4) + +### 2.4 Current User Profile - **POST** `/api/user/profile` Response parameters (amount fields as 2-decimal strings, aligned with wallet display): @@ -204,8 +240,9 @@ Response parameters (amount fields as 2-decimal strings, aligned with wallet dis - `count`: int (pending-review withdraw orders) - `max`: int (max pending-review withdraws per user, currently `3`; exceeds → `withdrawCreate` returns `code=2004`) -### 2.4 Refresh Token (Optional) +### 2.5 Refresh Token (Optional) - **POST** `/api/user/refreshToken` +- Headers: `auth-token` required; active `device_id` must match (§1.3) Request parameters: - `refresh_token`: string (credential to renew access token) @@ -680,7 +717,7 @@ Aligned with `app/process/GameWebSocketServer.php`, `config/process.php`, `app/c - **Mixed content**: HTTPS pages require **`wss://`**. - **Redis event bus**: HTTP uses **`GameWebSocketEventBus`** (Redis list); if Redis down, broadcast pushes may fail except **`admin.live.snapshot`** has per-second direct snapshot fallback. - **Handshake auth (2026-05, mandatory)**: `GameWebSocketServer::onWebSocketConnect` via `GameWebSocketAuthHelper::authorize` on URL query: - - **mobile**: query **`auth-token`** + **`user-token`** (hyphenated; legacy `auth_token`/`user_token` parsed but **use hyphens for new clients**). Binds `user_id`; user topics only to owner. + - **mobile**: query **`auth-token`** + **`user-token`** (hyphenated; legacy `auth_token`/`user_token` parsed but **use hyphens for new clients**). Binds `user_id`; user topics only to owner. Also validates `auth-token`’s bound `device_id` against the user’s active device (§1.3); other-device sessions are rejected. - **admin**: query **`admin-ws-token`** (from admin `wsConfig`, Redis, TTL 7200s). `user_id=0`, full observability. - Failure → `{"event":"ws.error","code":1101,"message":"Authentication failed: ..."}` then `close`. - **Server filter (user topics)**: `bet.win`, `user.streak`, `wallet.changed`, `bet.accepted`, `auto.spin.progress` only if `data.user_id` equals connection `user_id`. Others broadcast by subscription. Admin mode not filtered. @@ -811,13 +848,14 @@ Integration: `php scripts/verify_ws_topic_subscribe.php`. ## 8. Mobile End-to-End Call Flows ## 8.1 First Game Entry -1. `GET /api/v1/authToken?secret=xxx×tamp=xxx&device_id=xxx&signature=xxx` → `auth-token` -2. `POST /api/user/login` (header `auth-token`) -3. `POST /api/game/lobbyInit` (header `auth-token`) +1. `GET /api/v1/authToken?secret=xxx×tamp=xxx&device_id=xxx&signature=xxx` → `auth-token` (stable `device_id` per install) +2. `POST /api/user/login` (headers `auth-token`; after login also `user-token`) +3. `POST /api/game/lobbyInit` (headers `auth-token` + `user-token`) 4. WebSocket base URL (**not in lobbyInit today**: ops/bundle `H5_WEBSOCKET_URL` or custom config) → connect, **send `subscribe` immediately** (§7.0/7.1; **include `bet.win`**) 5. `POST /api/game/placeBet` 6. Balance: `placeBet.balance_after` + `wallet.changed`; after draw, **`bet.win`** (`is_win=true`), jackpot style via `data.is_jackpot` (no `user_id` in payload) 7. On disconnect/foreground, reconnect and resubscribe +8. User logout: `POST /api/user/logout` (headers `auth-token` + `user-token`, optional body `refresh_token`); clear local tokens and close WebSocket ## 8.2 Deposit → Bet → Withdraw 1. `POST /api/finance/depositTierList` (pick tier + `channels[].code`)