- 修复安卓端 401 拦截器线程问题,确保 Toast 在主线程执行 - 修复 Web 端按键/触摸重复触发:增加按下状态跟踪,防止悬停触发动作和长按重复 - 修复 Web 端指针取消时未释放触摸状态导致被控端卡死 - 修复 TURN 未启用时返回 401 导致客户端异常登出,改为返回 enabled=false - 修复后台管理端 401 后路由守卫跳转失败 - 增加控制指令调试日志用于排查问题
22 KiB
客户端对接接口规范 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。
失败:
{ "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:
{ "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:
{
"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>
{
"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>
[
{
"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>
{ "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:
{ "username": "alice", "password": "Passw0rd!", "totpCode": "123456" }
1.9 令牌校验(建立长连前的预检)
客户端在发起 WebSocket 握手前,可先调用此接口确认令牌未过期 / 未失效, 避免握手阶段被服务端直接断开(关闭码 4001)。用户令牌与设备令牌均可使用。
GET /api/client/verify
Authorization: Bearer <token>
有效时响应 200:
{
"valid": true,
"principalType": "DEVICE", // USER 或 DEVICE
"principalId": "dev_xxx",
"displayName": "Pixel 3",
"expiresAt": 1730000900, // 令牌过期时间(秒)
"remainingSeconds": 812, // 剩余有效秒数
"serverTime": 1730000088
}
无效时响应 200(HTTP 仍为 200,业务字段辨状态,避免泄露令牌是否存在):
{ "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>
{
"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
{
"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 |
| 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"},成功返回绑定信息 |
# 被控端生成
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 |
{
"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:
{
"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..." }
{
"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) — 两端通用:
Request request = new Request.Builder()
.url(serverUrl)
.addHeader("Authorization", "Bearer " + accessToken)
.build();
client.newWebSocket(request, listener);
Web(浏览器 WebSocket) — 浏览器无法设置请求头,必须用子协议:
const ws = new WebSocket(serverUrl, ['signal.v1', 'auth.' + accessToken]);
Flutter(web_socket_channel):
// 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):
var request = URLRequest(url: url)
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
let task = session.webSocketTask(with: request)
3.3 连接建立后
不再需要发送 REGISTER。认证通过后服务端自动完成注册并主动下发:
{
"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 消息
服务端在强制断开前会先下发:
{ "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. 联调顺序建议
- 先确认反向代理已放行
/api/** - 服务端配置
JWT_SECRET、DEVICE_PROVISION_SECRET、BOOTSTRAP_ADMIN_PASSWORD - 用 curl 验证 login / provision 流程跑通
- 改造被控端(无 UI 依赖,最容易验证)
- 改造Web 端(调试最快,可验证子协议方式)
- 改造 Android 主控端 → Flutter → iOS
curl 自测示例
# 注册
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":"..."}'