# 客户端对接接口规范 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 天**,格式为 `.`,**必须整体保存** - 限流:单 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 ``` ``` POST /api/auth/logout-all # 全端登出 Authorization: Bearer ``` 响应:`{ "success": true }` 登出后应清空本地令牌并断开 WebSocket。 --- ### 1.5 修改密码 ``` POST /api/auth/change-password Authorization: Bearer { "oldPassword": "...", "newPassword": "..." } ``` 响应:`{ "success": true, "message": "密码已修改,请重新登录" }` > 成功后服务端会**吊销全部会话**,客户端必须清空令牌并跳转登录页。 --- ### 1.6 查询当前身份 ``` GET /api/auth/me Authorization: Bearer ``` ```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 ``` ```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 ``` ```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 ``` 有效时响应 `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 ``` ```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 ", "subprotocol:Sec-WebSocket-Protocol: signal.v1, auth.", "query:?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 `) | 方法 | 路径 | 说明 | | --- | --- | --- | | 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. ``` 服务端会回显 `signal.v1` 完成协商,**不会回显含令牌的那一项**。 优点:令牌不进入 URL,不会被网关/代理写入访问日志。 **方式 B —— 原生客户端可用**:请求头 ``` Authorization: Bearer ``` **方式 C —— 兼容,不推荐**:查询参数 ``` wss://www.ttstd.com/signal?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":"..."}' ```