- 修复安卓端 401 拦截器线程问题,确保 Toast 在主线程执行 - 修复 Web 端按键/触摸重复触发:增加按下状态跟踪,防止悬停触发动作和长按重复 - 修复 Web 端指针取消时未释放触摸状态导致被控端卡死 - 修复 TURN 未启用时返回 401 导致客户端异常登出,改为返回 enabled=false - 修复后台管理端 401 后路由守卫跳转失败 - 增加控制指令调试日志用于排查问题
703 lines
22 KiB
Markdown
703 lines
22 KiB
Markdown
# 客户端对接接口规范 v1
|
||
|
||
面向 5 个客户端:`WebRTCControlled`(被控端)、`WebRTCController`(主控端 Android)、
|
||
`WebRTCControllerWeb`、`webrtc_controller_flutter`、`webrtc_controller_ios`。
|
||
|
||
> **破坏性变更**:`/ws/signal` 不再接受匿名连接。未携带有效令牌的握手将被 **HTTP 401** 拒绝。
|
||
> 各端必须完成本文档的改造才能连接。
|
||
|
||
---
|
||
|
||
## 0. 基础约定
|
||
|
||
### 0.1 地址
|
||
|
||
现有各端硬编码的信令地址为 `wss://www.ttstd.com/signal`(经反向代理)。
|
||
新增的 HTTP 接口位于同域下的 `/api/**`:
|
||
|
||
| 用途 | 地址 |
|
||
|---|---|
|
||
| HTTP API 基址 | `https://www.ttstd.com` |
|
||
| WebSocket 信令 | `wss://www.ttstd.com/signal` |
|
||
|
||
> **运维需确认**:反向代理需将 `/api/**` 转发到信令服务的 `/api/**`,
|
||
> 且 `/signal` 转发到 `/ws/signal`。若代理仅配置了 `/signal`,需补充 `/api` 规则。
|
||
|
||
建议各端把「HTTP 基址」和「WS 地址」拆成两个可配置项,而非从 WS 地址推导。
|
||
|
||
### 0.2 通用响应
|
||
|
||
成功:`200 OK`,JSON body。
|
||
|
||
失败:
|
||
```json
|
||
{ "code": 401, "error": "UNAUTHORIZED", "message": "认证失败" }
|
||
```
|
||
|
||
> 服务端对外只返回**模糊提示**(不区分"账号不存在/密码错误"、"设备离线/被拉黑"),
|
||
> 详细原因仅记录在服务端日志。客户端不要试图解析 message 做业务分支,**请以 `error` 字段为准**。
|
||
|
||
### 0.3 通用错误码
|
||
|
||
| HTTP | error | 客户端应对 |
|
||
|---|---|---|
|
||
| 400 | `BAD_REQUEST` | 参数问题,提示用户修正 |
|
||
| 401 | `UNAUTHORIZED` | 令牌无效/过期 → 尝试刷新,失败则跳登录 |
|
||
| 403 | `FORBIDDEN` | 账号被封禁/设备被禁用 → 提示并登出 |
|
||
| 429 | `TOO_MANY_REQUESTS` | 触发限流 → 退避重试,勿立即重连 |
|
||
|
||
---
|
||
|
||
## 1. 主控端接口(Controller / Web / Flutter / iOS)
|
||
|
||
### 1.1 注册
|
||
|
||
```
|
||
POST /api/auth/register
|
||
Content-Type: application/json
|
||
|
||
{ "username": "alice", "password": "Passw0rd!" }
|
||
```
|
||
|
||
响应 `200`:
|
||
```json
|
||
{ "userId": "usr_xxxxxxxxxxxxxxxxxxxx", "username": "alice" }
|
||
```
|
||
|
||
约束:
|
||
- 用户名:3-32 位,`[a-zA-Z0-9_.-]`
|
||
- 密码:8-128 位,且至少包含「大写 / 小写 / 数字 / 符号」中的两类
|
||
- 限流:单 IP 每小时 5 次
|
||
- 可由服务端配置关闭(`ACCOUNT_REGISTRATION_ENABLED=false`),关闭时返回 403
|
||
|
||
---
|
||
|
||
### 1.2 登录
|
||
|
||
```
|
||
POST /api/auth/login
|
||
|
||
{ "username": "alice", "password": "Passw0rd!" }
|
||
```
|
||
|
||
响应 `200`:
|
||
```json
|
||
{
|
||
"accessToken": "eyJhbGc...",
|
||
"refreshToken": "ses_xxxx.AbCdEf...",
|
||
"expiresIn": 900,
|
||
"sessionId": "ses_xxxxxxxxxxxxxxxxxxxx",
|
||
"principalId": "usr_xxxxxxxxxxxxxxxxxxxx",
|
||
"displayName": "alice"
|
||
}
|
||
```
|
||
|
||
要点:
|
||
- `accessToken` 有效期 **900 秒**,用于 HTTP 与 WebSocket 握手
|
||
- `refreshToken` 有效期 **7 天**,格式为 `<sessionId>.<secret>`,**必须整体保存**
|
||
- 限流:单 IP 5 分钟内 10 次;连续密码错误 5 次锁定账号 15 分钟(返回 429)
|
||
|
||
> **开启 TOTP 的账号**:若响应为 `401` 且 `error` 为 `TOTP_REQUIRED`,
|
||
> 需引导用户输入 6 位动态码,并带 `totpCode` 字段重新请求登录。详见 §1.8。
|
||
|
||
**存储要求**:
|
||
|
||
| 端 | accessToken | refreshToken |
|
||
|---|---|---|
|
||
| Android | 内存 | EncryptedSharedPreferences |
|
||
| iOS | 内存 | **Keychain**(勿用 UserDefaults) |
|
||
| Flutter | 内存 | `flutter_secure_storage` |
|
||
| Web | 内存变量 | `sessionStorage`(勿用 localStorage) |
|
||
|
||
---
|
||
|
||
### 1.3 刷新令牌
|
||
|
||
```
|
||
POST /api/auth/refresh
|
||
|
||
{ "refreshToken": "ses_xxxx.AbCdEf..." }
|
||
```
|
||
|
||
响应结构同登录。
|
||
|
||
**关键规则(务必遵守)**:
|
||
- 刷新令牌**一次性**,每次刷新都会返回新的,**必须立即覆盖保存旧值**
|
||
- 服务端有**复用检测**:若用已被使用过的旧 refreshToken 再次请求,
|
||
会判定为令牌泄露并**吊销整个会话**(该账号在此设备上需重新登录)
|
||
- 因此**严禁并发刷新**。请加互斥锁/单飞(single-flight),
|
||
多个请求同时遇到 401 时只允许一个发起刷新,其余等待其结果
|
||
|
||
**刷新时机**:建议在 `accessToken` 剩余有效期不足 1/3(即约 300 秒)时主动刷新,
|
||
不要等到 401 才被动刷新(WebSocket 握手失败重连成本更高)。
|
||
|
||
---
|
||
|
||
### 1.4 登出
|
||
|
||
```
|
||
POST /api/auth/logout
|
||
Authorization: Bearer <accessToken>
|
||
```
|
||
|
||
```
|
||
POST /api/auth/logout-all # 全端登出
|
||
Authorization: Bearer <accessToken>
|
||
```
|
||
|
||
响应:`{ "success": true }`
|
||
|
||
登出后应清空本地令牌并断开 WebSocket。
|
||
|
||
---
|
||
|
||
### 1.5 修改密码
|
||
|
||
```
|
||
POST /api/auth/change-password
|
||
Authorization: Bearer <accessToken>
|
||
|
||
{ "oldPassword": "...", "newPassword": "..." }
|
||
```
|
||
|
||
响应:`{ "success": true, "message": "密码已修改,请重新登录" }`
|
||
|
||
> 成功后服务端会**吊销全部会话**,客户端必须清空令牌并跳转登录页。
|
||
|
||
---
|
||
|
||
### 1.6 查询当前身份
|
||
|
||
```
|
||
GET /api/auth/me
|
||
Authorization: Bearer <accessToken>
|
||
```
|
||
|
||
```json
|
||
{
|
||
"principalId": "usr_xxx",
|
||
"principalType": "USER",
|
||
"displayName": "alice",
|
||
"sessionId": "ses_xxx",
|
||
"signalDeviceId": "ctl_ses_xxx",
|
||
"admin": false
|
||
}
|
||
```
|
||
|
||
`signalDeviceId` 即该连接在信令网络中的 ID,**由服务端派生,客户端不可自定义**。
|
||
|
||
---
|
||
|
||
### 1.7 会话管理
|
||
|
||
```
|
||
GET /api/auth/sessions
|
||
Authorization: Bearer <accessToken>
|
||
```
|
||
|
||
```json
|
||
[
|
||
{
|
||
"sessionId": "ses_xxx",
|
||
"ip": "1.2.3.4",
|
||
"userAgent": "okhttp/4.12.0",
|
||
"createdAt": 1730000000000,
|
||
"lastSeenAt": 1730000600000,
|
||
"current": true
|
||
}
|
||
]
|
||
```
|
||
|
||
可用于「登录设备管理」界面,配合 §1.4 的 logout-all 实现异地下线。
|
||
|
||
---
|
||
|
||
### 1.8 TOTP 双因子(可选启用)
|
||
|
||
```
|
||
POST /api/auth/totp/setup # 生成密钥,返回 otpauth:// URI 供扫码
|
||
Authorization: Bearer <accessToken>
|
||
```
|
||
```json
|
||
{ "secret": "JBSWY3DP...", "otpauthUri": "otpauth://totp/...", "notice": "..." }
|
||
```
|
||
|
||
```
|
||
POST /api/auth/totp/enable # 输入一次动态码完成绑定
|
||
{ "code": "123456" }
|
||
```
|
||
|
||
```
|
||
POST /api/auth/totp/disable # 需当前密码 + 动态码
|
||
{ "password": "...", "code": "123456" }
|
||
```
|
||
|
||
启用后,`/api/auth/login` 需附带 `totpCode`:
|
||
```json
|
||
{ "username": "alice", "password": "Passw0rd!", "totpCode": "123456" }
|
||
```
|
||
|
||
---
|
||
|
||
### 1.9 令牌校验(建立长连前的预检)
|
||
|
||
客户端在发起 WebSocket 握手前,可先调用此接口确认令牌未过期 / 未失效,
|
||
避免握手阶段被服务端直接断开(关闭码 4001)。用户令牌与设备令牌均可使用。
|
||
|
||
```
|
||
GET /api/client/verify
|
||
Authorization: Bearer <token>
|
||
```
|
||
|
||
有效时响应 `200`:
|
||
```json
|
||
{
|
||
"valid": true,
|
||
"principalType": "DEVICE", // USER 或 DEVICE
|
||
"principalId": "dev_xxx",
|
||
"displayName": "Pixel 3",
|
||
"expiresAt": 1730000900, // 令牌过期时间(秒)
|
||
"remainingSeconds": 812, // 剩余有效秒数
|
||
"serverTime": 1730000088
|
||
}
|
||
```
|
||
|
||
无效时响应 `200`(HTTP 仍为 200,业务字段辨状态,避免泄露令牌是否存在):
|
||
```json
|
||
{ "valid": false, "error": "UNAUTHORIZED", "message": "认证失败" }
|
||
```
|
||
|
||
> 客户端策略:进入前台或准备重连时调用;若 `remainingSeconds` 低于 `accessTokenTtlSeconds * 0.33`,
|
||
> 用户端走 `/api/auth/refresh`,设备端重新执行 `/api/device/token`。
|
||
|
||
---
|
||
|
||
### 1.10 设备自助信息(被控端)
|
||
|
||
被控端用自身设备令牌查询当前状态与在线情况(需 `DEVICE` 令牌)。
|
||
|
||
```
|
||
GET /api/client/device/me
|
||
Authorization: Bearer <deviceToken>
|
||
```
|
||
|
||
```json
|
||
{
|
||
"deviceUid": "dev_xxx",
|
||
"model": "Pixel 3",
|
||
"status": "ACTIVE", // ACTIVE / SUSPENDED / BANNED
|
||
"usable": true, // 是否被禁用(false 时无法建立信令连接)
|
||
"provisionedAt": 1729000000,
|
||
"lastOnlineAt": 1730000088,
|
||
"statusReason": null // 被封禁/暂停原因(如有)
|
||
}
|
||
```
|
||
|
||
> 若 `usable == false`,客户端应停止尝试连接并提示用户设备已被禁用。
|
||
|
||
---
|
||
|
||
### 1.11 握手指引(自配置)
|
||
|
||
返回 WebSocket 握手所需的地址、子协议、关闭码与刷新阈值,便于各端在无硬编码的前提下自配置。
|
||
|
||
```
|
||
GET /api/client/ws-info
|
||
```
|
||
|
||
```json
|
||
{
|
||
"wsPath": "/ws/signal",
|
||
"subprotocol": "signal.v1",
|
||
"tokenMethods": [
|
||
"header:Authorization Bearer <token>",
|
||
"subprotocol:Sec-WebSocket-Protocol: signal.v1, auth.<token>",
|
||
"query:?token=<token>"
|
||
],
|
||
"closeCodes": { "NORMAL": 1000, "UNAUTHORIZED": 4001, "FORCE_LOGOUT": 4003 },
|
||
"idleTimeoutSeconds": 90,
|
||
"recommendedRefreshRatio": 0.33,
|
||
"accessTokenTtlSeconds": 900,
|
||
"deviceTokenTtlSeconds": 900
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### 1.12 绑定关系与黑名单(防骚扰核心)
|
||
|
||
被控端(设备)与主控端(用户账号)之间**必须先建立绑定**,主控端才能向其发起连接(OFFER)。
|
||
即便已绑定,被控端仍可将某主控端加入**黑名单**,黑名单优先级高于绑定,OFFER 会被服务端直接拒绝。
|
||
|
||
**默认行为(服务端强制)**:
|
||
- 未绑定 → OFFER 立即被拒,回送 `REQUEST_ERROR`:`"未与该设备建立绑定关系,无法发起连接"`。
|
||
- 已拉黑 → OFFER 立即被拒,回送 `REQUEST_ERROR`:`"该设备已拒绝来自你的连接"`。
|
||
- `DEVICE_LIST` 不再返回全局设备清单,主控端仅能看到自己已绑定设备的在线状态(消除被控端枚举)。
|
||
|
||
#### 被控端自助管理(设备令牌,`Authorization: Bearer <deviceToken>`)
|
||
|
||
| 方法 | 路径 | 说明 |
|
||
| --- | --- | --- |
|
||
| POST | `/api/client/device/bind` | 按用户名绑定某主控端:`{"username":"alice","alias":"客厅电视"}` |
|
||
| POST | `/api/client/device/unbind` | 解绑:`{"username":"alice"}` |
|
||
| POST | `/api/client/device/blacklist` | 拉黑:`{"username":"alice","reason":"骚扰"}` |
|
||
| POST | `/api/client/device/unblacklist` | 解除拉黑:`{"username":"alice"}` |
|
||
| GET | `/api/client/device/relations` | 查看本设备的绑定与黑名单列表 |
|
||
|
||
#### 主控端自助查询(用户令牌)
|
||
|
||
| 方法 | 路径 | 说明 |
|
||
| --- | --- | --- |
|
||
| GET | `/api/client/bindings` | 查看自己已绑定的设备及其在线状态 |
|
||
|
||
#### 管理员接口(`/api/admin`,需管理员令牌)
|
||
|
||
| 方法 | 路径 | 说明 |
|
||
| --- | --- | --- |
|
||
| GET | `/api/admin/devices/{deviceUid}/bindings` | 设备全部绑定关系 |
|
||
| GET | `/api/admin/devices/{deviceUid}/blacklist` | 设备黑名单 |
|
||
| POST | `/api/admin/devices/{deviceUid}/bind` | 建立绑定:`{"username","role":"MEMBER|OWNER","alias"}` |
|
||
| POST | `/api/admin/devices/{deviceUid}/unbind` | 解绑:`{"username"}` |
|
||
| POST | `/api/admin/devices/{deviceUid}/blacklist` | 拉黑:`{"username","reason"}` |
|
||
| POST | `/api/admin/devices/{deviceUid}/unblacklist` | 解除拉黑:`{"username"}` |
|
||
|
||
> 绑定由「被控端自助」或「管理员」创建;解绑为软删除(置 `REVOKED`),重新绑定可恢复。
|
||
|
||
---
|
||
|
||
### 1.13 配对码、TURN 凭证与骚扰举报
|
||
|
||
#### 配对码(建立绑定的用户友好入口)
|
||
被控端生成一次性配对码(8 位、去除易混淆字符、10 分钟有效、单次使用、错误 5 次失效),主控端输入码即建立绑定(无需管理员介入)。
|
||
|
||
| 方法 | 路径 | 令牌 | 说明 |
|
||
| --- | --- | --- | --- |
|
||
| POST | `/api/client/device/pairing-code` | 设备 | 生成配对码,返回 `{"code":"A2B9K7M4","expiresInSeconds":600}` |
|
||
| POST | `/api/client/pairing/redeem` | 用户 | 兑换:`{"code":"A2B9K7M4"}`,成功返回绑定信息 |
|
||
|
||
```bash
|
||
# 被控端生成
|
||
curl -X POST https://www.ttstd.com/api/client/device/pairing-code \
|
||
-H "Authorization: Bearer $DEVICE_TOKEN"
|
||
|
||
# 主控端兑换
|
||
curl -X POST https://www.ttstd.com/api/client/pairing/redeem \
|
||
-H "Authorization: Bearer $USER_TOKEN" \
|
||
-d '{"code":"A2B9K7M4"}'
|
||
```
|
||
|
||
#### TURN 短期凭证(中继,RFC 7635 风格)
|
||
客户端在 `new RTCPeerConnection` 前调用,获取时限内有效的 TURN 用户名/口令(HMAC-SHA1 由服务端密钥签名,到期需重新获取)。是否启用由服务端 `security.turn.enabled` 决定;`/api/client/ws-info` 的 `turnEnabled` 字段告知客户端。
|
||
|
||
| 方法 | 路径 | 令牌 | 说明 |
|
||
| --- | --- | --- | --- |
|
||
| GET | `/api/client/turn-credentials` | 用户/设备 | 返回 `enabled`、`iceServers`(含 urls/username/credential)、`expiresAt`、`ttlSeconds` |
|
||
|
||
```json
|
||
{
|
||
"enabled": true,
|
||
"iceServers": [
|
||
{ "urls": "turn:turn.ttstd.com:3478?transport=udp",
|
||
"username": "1730000000:ab12cd34ef56",
|
||
"credential": "Base64(HMAC-SHA1(sharedSecret, username))" }
|
||
],
|
||
"expiresAt": 1730003600,
|
||
"ttlSeconds": 3600
|
||
}
|
||
```
|
||
|
||
> 客户端应将 `iceServers` 直接传入 `RTCPeerConnection` 配置;凭证过期后重新 GET 本接口刷新。
|
||
> 服务端未启用 TURN 时返回 200 + `{"enabled": false, "iceServers": []}`(不会返回 401),客户端据此降级为仅 STUN。
|
||
|
||
#### 骚扰举报(P2 风控)
|
||
被控端遭遇骚扰时可举报某主控端账号,管理员在 `/api/admin/abuse-reports` 查看并处理。
|
||
|
||
| 方法 | 路径 | 令牌 | 说明 |
|
||
| --- | --- | --- | --- |
|
||
| POST | `/api/client/device/report` | 设备 | 举报:`{"username":"alice","reason":"持续骚扰"}` |
|
||
| GET | `/api/admin/abuse-reports` | 管理员 | 列表,支持 `status=PENDING` / `reportedUserId=` 过滤 |
|
||
| POST | `/api/admin/abuse-reports/{id}/handle` | 管理员 | 处理:`{"status":"HANDLED"}` 或 `DISMISSED` |
|
||
|
||
---
|
||
|
||
## 2. 被控端接口(WebRTCControlled)
|
||
|
||
被控端为系统签名应用(`android:sharedUserId="android.uid.system"`),
|
||
可通过 `DeviceUtils.getSerial()` 稳定获取 SN,但无法登录账号。
|
||
因此采用「SN + 内置密钥 HMAC」激活。
|
||
|
||
### 2.1 首次激活
|
||
|
||
```
|
||
POST /api/device/provision
|
||
|
||
{
|
||
"sn": "ABC123456789",
|
||
"model": "Pixel 3",
|
||
"nonce": "550e8400-e29b-41d4-a716-446655440000",
|
||
"timestamp": 1730000000,
|
||
"hmac": "3f2a...(64 位小写 hex)"
|
||
}
|
||
```
|
||
|
||
**HMAC 计算**:
|
||
```
|
||
hmac = HexLowercase( HMAC-SHA256( DEVICE_PROVISION_SECRET, sn + "|" + nonce + "|" + timestamp ) )
|
||
```
|
||
- `timestamp`:Unix **秒**(非毫秒),与服务端偏差超过 300 秒即拒绝
|
||
- `nonce`:每次请求唯一(建议 UUID),服务端做重放检测
|
||
- `DEVICE_PROVISION_SECRET`:内置于被控端 APK,建议放 NDK 层并配合 R8 混淆
|
||
|
||
响应 `200`:
|
||
```json
|
||
{
|
||
"deviceUid": "dev_xxxxxxxxxxxxxxxxxxxxxx",
|
||
"deviceSecret": "AbCdEf...",
|
||
"notice": "deviceSecret 仅返回一次,请立即安全存储"
|
||
}
|
||
```
|
||
|
||
> **`deviceSecret` 仅此一次明文返回**,必须立即写入 Android Keystore
|
||
> (或 EncryptedSharedPreferences)。丢失后只能重新激活。
|
||
|
||
**幂等性**:同一 SN 重复激活会复用同一 `deviceUid`,但会**轮换 deviceSecret 并使旧令牌立即失效**。
|
||
因此客户端应先检查本地是否已有 `deviceSecret`,**有则跳过激活**,避免自己把自己踢下线。
|
||
|
||
限流:单 IP 每小时 10 次。
|
||
|
||
### 2.2 换取访问令牌
|
||
|
||
```
|
||
POST /api/device/token
|
||
|
||
{ "deviceUid": "dev_xxx", "deviceSecret": "AbCdEf..." }
|
||
```
|
||
|
||
```json
|
||
{
|
||
"accessToken": "eyJhbGc...",
|
||
"refreshToken": null,
|
||
"expiresIn": 900,
|
||
"principalId": "dev_xxx",
|
||
"displayName": "Pixel 3"
|
||
}
|
||
```
|
||
|
||
设备令牌**没有 refreshToken**,过期后直接用 `deviceSecret` 重新换取即可。
|
||
建议在剩余有效期不足 1/3 时提前换取。
|
||
|
||
限流:单 IP 每小时 60 次。
|
||
|
||
### 2.3 被控端启动流程
|
||
|
||
```
|
||
读取本地 deviceSecret
|
||
├─ 不存在 → getSerial() → POST /api/device/provision → 存储 deviceUid + deviceSecret
|
||
└─ 已存在 → 跳过
|
||
↓
|
||
POST /api/device/token → accessToken
|
||
↓
|
||
WebSocket 握手(携带 accessToken)
|
||
↓
|
||
收到 REGISTER_SUCCESS,其中 deviceId 即服务端分配的 deviceUid
|
||
```
|
||
|
||
> **重要**:被控端界面上原本让用户手输的「设备ID」输入框应当**移除或改为只读展示**。
|
||
> 设备身份现在完全由 SN 激活决定,不再由用户输入。
|
||
|
||
---
|
||
|
||
## 3. WebSocket 握手改造(所有端)
|
||
|
||
### 3.1 令牌传递方式
|
||
|
||
**方式 A —— 推荐(Web 端必用)**:子协议
|
||
```
|
||
Sec-WebSocket-Protocol: signal.v1, auth.<token>
|
||
```
|
||
服务端会回显 `signal.v1` 完成协商,**不会回显含令牌的那一项**。
|
||
优点:令牌不进入 URL,不会被网关/代理写入访问日志。
|
||
|
||
**方式 B —— 原生客户端可用**:请求头
|
||
```
|
||
Authorization: Bearer <token>
|
||
```
|
||
|
||
**方式 C —— 兼容,不推荐**:查询参数
|
||
```
|
||
wss://www.ttstd.com/signal?token=<token>
|
||
```
|
||
服务端会打印告警。仅在前两种都无法实现时使用。
|
||
|
||
### 3.2 各端实现要点
|
||
|
||
**Android(OkHttp)** — 两端通用:
|
||
```java
|
||
Request request = new Request.Builder()
|
||
.url(serverUrl)
|
||
.addHeader("Authorization", "Bearer " + accessToken)
|
||
.build();
|
||
client.newWebSocket(request, listener);
|
||
```
|
||
|
||
**Web(浏览器 WebSocket)** — 浏览器无法设置请求头,**必须用子协议**:
|
||
```js
|
||
const ws = new WebSocket(serverUrl, ['signal.v1', 'auth.' + accessToken]);
|
||
```
|
||
|
||
**Flutter(web_socket_channel)**:
|
||
```dart
|
||
// IOWebSocketChannel 支持 headers(移动端)
|
||
final channel = IOWebSocketChannel.connect(
|
||
Uri.parse(serverUrl),
|
||
headers: {'Authorization': 'Bearer $accessToken'},
|
||
);
|
||
// 若需兼容 Flutter Web,改用子协议:
|
||
// WebSocketChannel.connect(uri, protocols: ['signal.v1', 'auth.$accessToken']);
|
||
```
|
||
|
||
**iOS(URLSessionWebSocketTask)**:
|
||
```swift
|
||
var request = URLRequest(url: url)
|
||
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
|
||
let task = session.webSocketTask(with: request)
|
||
```
|
||
|
||
### 3.3 连接建立后
|
||
|
||
**不再需要发送 `REGISTER`**。认证通过后服务端自动完成注册并主动下发:
|
||
|
||
```json
|
||
{
|
||
"type": "REGISTER_SUCCESS",
|
||
"deviceId": "ctl_ses_xxx",
|
||
"deviceType": "CONTROLLER",
|
||
"displayName": "alice"
|
||
}
|
||
```
|
||
|
||
客户端应以此消息中的 `deviceId` 作为自己的信令 ID。
|
||
|
||
> 旧客户端继续发送 `REGISTER` 不会报错(服务端仅回显同样的响应),但已无实际意义。
|
||
|
||
### 3.4 身份字段不再可信
|
||
|
||
消息中的 `fromDeviceId` 和 `deviceType` **会被服务端用鉴权结果强制覆盖**。
|
||
客户端填写任意值都无效(不一致时服务端记录 WARN 日志)。发送消息时可继续填写,也可省略。
|
||
|
||
### 3.5 关闭码处理
|
||
|
||
| 关闭码 | 含义 | 客户端应对 |
|
||
|---|---|---|
|
||
| 4001 | 未认证 / 令牌无效 | 刷新令牌后重连;刷新失败则跳登录 |
|
||
| 4003 | 被强制下线(封禁/踢出/设备禁用) | **停止自动重连**,提示用户,清空令牌跳登录 |
|
||
| 1000/1001 | 正常关闭 | 按既有逻辑 |
|
||
| 其他 | 网络异常 | 指数退避重连 |
|
||
|
||
**特别注意**:被控端 `WebRTCControlled` 现有的自动重连是指数退避(2s 起,上限 30s)。
|
||
收到 **4003** 时必须**关闭自动重连**,否则会造成无效重连风暴。
|
||
|
||
### 3.6 FORCE_LOGOUT 消息
|
||
|
||
服务端在强制断开前会先下发:
|
||
```json
|
||
{ "type": "FORCE_LOGOUT", "payload": "账号已被封禁:违规操作" }
|
||
```
|
||
客户端应展示 `payload` 给用户,然后清理本地状态。
|
||
|
||
### 3.7 握手限流
|
||
|
||
单 IP **60 秒内最多 30 次**握手,超限返回 **429**。
|
||
各端重连必须使用指数退避,避免触发。
|
||
|
||
---
|
||
|
||
## 4. 各端改造清单
|
||
|
||
### 4.1 WebRTCControlled(被控端)
|
||
|
||
| 项 | 说明 |
|
||
|---|---|
|
||
| 新增 | HTTP 客户端(复用已有 OkHttp)调用 provision / token |
|
||
| 新增 | `DeviceUtils.getSerial()` 接入激活流程(代码已存在,当前被注释) |
|
||
| 新增 | Keystore / EncryptedSharedPreferences 存储 `deviceUid` + `deviceSecret` |
|
||
| 新增 | 令牌过期前自动换取 |
|
||
| 修改 | `WebSocketClient` 构造增加 token,握手加 `Authorization` 头 |
|
||
| 修改 | 移除/只读化「设备ID」输入框(`activity_main.xml` 中硬编码的 `981964879` 应删除) |
|
||
| 修改 | 收到 4003 时停止自动重连 |
|
||
| 修改 | 服务器地址持久化(当前每次启动重置) |
|
||
|
||
### 4.2 WebRTCController(主控端 Android)
|
||
|
||
| 项 | 说明 |
|
||
|---|---|
|
||
| 新增 | 登录界面(作为 launcher,登录后再进主界面) |
|
||
| 新增 | HTTP 客户端调用 login / refresh |
|
||
| 新增 | EncryptedSharedPreferences 存 refreshToken |
|
||
| 新增 | 令牌自动刷新(加单飞锁) |
|
||
| 修改 | `WebSocketClient` 握手加 `Authorization` 头 |
|
||
| 修改 | 移除「设备ID」输入框,改用 `REGISTER_SUCCESS` 返回值 |
|
||
| 修改 | **补充心跳与重连**(当前完全没有,仅被控端有) |
|
||
| 修改 | 处理 4001 / 4003 / FORCE_LOGOUT |
|
||
|
||
### 4.3 WebRTCControllerWeb
|
||
|
||
| 项 | 说明 |
|
||
|---|---|
|
||
| 新增 | 登录页组件 + `authStore`(沿用现有 `reactive` 风格,无需引入 Pinia) |
|
||
| 新增 | `fetch` 封装(当前无任何 HTTP 能力),含 401 自动刷新 |
|
||
| 新增 | `sessionStorage` 存 refreshToken |
|
||
| 修改 | `SignalingClient.js` 用**子协议**传令牌(浏览器无法设请求头) |
|
||
| 修改 | 移除 `App.vue` 中 `'web-' + random` 的临时 deviceId 生成 |
|
||
| 修改 | 处理关闭码与 FORCE_LOGOUT |
|
||
|
||
### 4.4 webrtc_controller_flutter
|
||
|
||
| 项 | 说明 |
|
||
|---|---|
|
||
| 新增依赖 | `http`(或 `dio`)+ `flutter_secure_storage` |
|
||
| 新增 | 登录页 + 令牌管理 |
|
||
| 修改 | `signaling_client.dart` 改用 `IOWebSocketChannel.connect(..., headers:)` |
|
||
| 修改 | 移除 `DeviceUtils.getSerialNumber()` 作为 deviceId 的逻辑(主控端身份来自账号) |
|
||
| 修改 | 处理关闭码与 FORCE_LOGOUT |
|
||
|
||
### 4.5 webrtc_controller_ios
|
||
|
||
| 项 | 说明 |
|
||
|---|---|
|
||
| 新增 | 登录视图 + `AuthService`(URLSession HTTP) |
|
||
| 新增 | **Keychain** 存 refreshToken(当前 `DeviceUtils` 用的是 UserDefaults,不适合存凭据) |
|
||
| 修改 | `SignalingClient.swift` 用 `URLRequest` + `Authorization` 头 |
|
||
| 修改 | 移除 `DeviceUtils.deviceId()` 作为信令 ID 的逻辑 |
|
||
| 修改 | 处理关闭码与 FORCE_LOGOUT |
|
||
|
||
---
|
||
|
||
## 5. 联调顺序建议
|
||
|
||
1. **先确认反向代理**已放行 `/api/**`
|
||
2. 服务端配置 `JWT_SECRET`、`DEVICE_PROVISION_SECRET`、`BOOTSTRAP_ADMIN_PASSWORD`
|
||
3. 用 curl 验证 login / provision 流程跑通
|
||
4. 改造**被控端**(无 UI 依赖,最容易验证)
|
||
5. 改造**Web 端**(调试最快,可验证子协议方式)
|
||
6. 改造 Android 主控端 → Flutter → iOS
|
||
|
||
### curl 自测示例
|
||
|
||
```bash
|
||
# 注册
|
||
curl -X POST https://www.ttstd.com/api/auth/register \
|
||
-H 'Content-Type: application/json' \
|
||
-d '{"username":"tester","password":"Passw0rd!"}'
|
||
|
||
# 登录
|
||
curl -X POST https://www.ttstd.com/api/auth/login \
|
||
-H 'Content-Type: application/json' \
|
||
-d '{"username":"tester","password":"Passw0rd!"}'
|
||
|
||
# 设备激活(timestamp 用当前 Unix 秒,hmac 需按 §2.1 计算)
|
||
curl -X POST https://www.ttstd.com/api/device/provision \
|
||
-H 'Content-Type: application/json' \
|
||
-d '{"sn":"TESTSN001","model":"test","nonce":"'$(uuidgen)'","timestamp":'$(date +%s)',"hmac":"..."}'
|
||
```
|