Files
VibeCoding/WebRTCSignalServer/docs/CLIENT_API.md
TongTongStudio 9b0e105647 fix: 修复 WebRTC 控制稳定性与输入事件重复问题
- 修复安卓端 401 拦截器线程问题,确保 Toast 在主线程执行
- 修复 Web 端按键/触摸重复触发:增加按下状态跟踪,防止悬停触发动作和长按重复
- 修复 Web 端指针取消时未释放触摸状态导致被控端卡死
- 修复 TURN 未启用时返回 401 导致客户端异常登出,改为返回 enabled=false
- 修复后台管理端 401 后路由守卫跳转失败
- 增加控制指令调试日志用于排查问题
2026-08-04 02:40:23 +08:00

703 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 客户端对接接口规范 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 各端实现要点
**AndroidOkHttp** — 两端通用:
```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]);
```
**Flutterweb_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']);
```
**iOSURLSessionWebSocketTask**
```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":"..."}'
```