# 客户端对接接口规范 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` | 用户/设备 | 返回 `iceServers`（含 urls/username/credential）、`expiresAt`、`ttlSeconds` |

```json
{
  "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 本接口刷新。

#### 骚扰举报（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":"..."}'
```
