# NBTiktok 互動遊戲串接 API v2

本 API 供遊戲用戶端以 Email 與密碼建立 Session、選擇性登入獨立遊戲業務後台、透過 WebSocket 接收直播互動事件，以及在 Debug 模式測試相同流程。

請只使用 NBTiktok 提供的公開 HTTPS Origin；不得直接連線容器、診斷埠、`/internal/*` 或管理路由。

## 串接流程

1. 呼叫 `POST /api/game-client/v2/login`，提交 Email、密碼、`gameId`、`mode` 與 `clientBuild`；不使用 CAPTCHA。
2. 直接串接排行榜或玩家屬性時不送 `backend`，回應提供 `credentials.gameApi`。
3. 啟用獨立後台時，登入回傳 `backendAuth` 與 `credentials.gameBackend`；URL 由用戶端可信設定選擇。
4. `credentials.gameBackend` 只能傳到目前 build 選定的可信 URL。
5. 使用一次性 WebSocket Ticket 連線並驗證 `ready`；Production 持久化後 ACK，Debug 回覆 `debug_command`。
6. 在 `refreshAt` 輪換整組憑證並續簽獨立後台 WSS；平台事件連線斷線時申請新 Ticket，最後以 `DELETE /api/game-client/v2/session` 結束 Session。

## 公開路由

| 能力 | Method / Path | 驗證 |
| --- | --- | --- |
| 登入並建立 Session | `POST /api/game-client/v2/login` | 僅 Email、密碼、遊戲、模式與 build |
| 已移除的裝置授權 | `POST /api/game-client/v2/device-authorizations` 與 `/token` | 回傳 `410 DEVICE_AUTHORIZATION_REMOVED` |
| 已移除的 CAPTCHA | `GET /api/game-client/v2/captcha` | 回傳 `410 CAPTCHA_LOGIN_REMOVED` |
| 查詢 Session | `GET /api/game-client/v2/session` | `Bearer <sessionAccessToken>` |
| 輪換憑證 | `POST /api/game-client/v2/session/refresh` | `Bearer <refreshToken>`；空本文 |
| 申請 WebSocket Ticket | `POST /api/game-client/v2/session/websocket-ticket` | `Bearer <sessionAccessToken>`；空本文 |
| 登出 | `DELETE /api/game-client/v2/session` | `Bearer <sessionAccessToken>` |
| 事件連線 | `GET /ws/game-client/v2?ticket=...` | 60 秒、僅可使用一次的 Ticket |
| 瀏覽公開遊戲 | `GET /api/games/v2` | 無 |
| 查詢本地化玩法指南 | `GET /api/games/v2/:gameId/guide?locale=...` | 無 |
| 遊戲禮物目錄 | `GET /api/games/v2/:gameId/gifts` | 無 |
| 遊戲截圖 | `GET /api/games/v2/:gameId/screenshots/:position` | 無；`position` 為 1～5 |
| 排行榜與玩家屬性 | `/openapi/v2/*` | `Bearer <credentials.gameApi.token>` |
| 獨立後台 HTTP/WSS | 用戶端可信 URL | HTTP Bearer；WSS `nbt.auth` 首幀 |
| 後台驗簽公鑰 | `GET /.well-known/game-backend-jwks.json` | 無；依 `Cache-Control` 快取 |

除 `204` 與成功的截圖二進位回應外，HTTP 本文都是 JSON。登入使用扁平格式 `{ "error": "INVALID_CREDENTIALS" }`；Session、WebSocket Ticket、`/openapi/v2/*` 與網關限流使用 `{ "error": { "code": "ERROR_CODE", "requestId": "018f1f6d-7b2a-7e34-a6cc-5e5f3fb70420" } }`。

## Email 密碼登入

```http
POST /api/game-client/v2/login
Content-Type: application/json
```

```json
{
  "email": "streamer@example.com",
  "password": "account-password",
  "gameId": "game_one",
  "mode": "production",
  "clientBuild": 120
}
```

密碼只提交給 NBTiktok 平台，請求結束後立即從記憶體清除，絕不可寫入設定、日誌、URL、當機報告或遙測。平台永遠不會把 Email、密碼、登入 Token 或 Cookie 傳給獨立後台。Email 密碼決定帳號及其 Sandbox 歸屬。

## 登入成功回應

```json
{
  "session": {
    "sessionId": "6fe3890b-bb7c-4e45-b2b2-1db2d77751ce",
    "streamerId": "9f833c5b-ec62-4530-9ae4-0af8130a26b6",
    "authorizationId": "20637113-6d4e-490c-ac6d-74242cd1de85",
    "gameId": "game_one",
    "mode": "production",
    "credentialSource": "user_session",
    "credentialId": null,
    "roomId": "creator-room",
    "clientBuild": 120,
    "minimumClientBuild": 100,
    "expiresAt": "2026-08-20T12:00:00.000Z",
    "absoluteExpiresAt": "2026-08-26T12:00:00.000Z"
  },
  "credentials": {
    "sessionAccess": {
      "token": "opaque-session-access-token",
      "expiresAt": "2026-08-19T12:30:00.000Z"
    },
    "gameApi": {
      "token": "signed-game-api-token",
      "expiresAt": "2026-08-19T12:10:00.000Z"
    },
    "gameBackend": {
      "token": "signed-game-backend-token",
      "expiresAt": "2026-08-19T12:30:00.000Z"
    },
    "refresh": {
      "token": "opaque-single-use-refresh-token",
      "expiresAt": "2026-08-20T12:00:00.000Z",
      "refreshAt": "2026-08-19T12:08:00.000Z"
    },
    "websocket": {
      "url": "/ws/game-client/v2",
      "ticket": "opaque-single-use-ticket",
      "expiresAt": "2026-08-19T12:01:00.000Z"
    }
  },
  "backendAuth": {
    "audience": "urn:nbtiktok:game-backend:game_one",
    "operatorName": "Example Studio",
    "privacyPolicyUrl": "https://game.example.com/privacy",
    "configVersion": 3
  }
}
```

伺服器回傳的 ID、房間、模式、版本策略、期限與路徑皆為權威資料。密碼、Token 與 Ticket 不得寫入 PlayerPrefs、設定檔、日誌、URL、當機報告或遙測。

session access Token 最長 30 分鐘，Game API Token 最長 10 分鐘；獨立後台 JWT 最長 30 分鐘。由於 `refreshAt` 取所有短期憑證刷新點的最早值，整組憑證通常仍在簽發後約 8 分鐘 refresh。refresh Token 僅可使用一次，Session 滑動期限最長 24 小時；Ticket 最長 60 秒且僅可成功消耗一次。

同帳號同時只允許一個 Production Session，新 Production 登入即使是不同遊戲也會撤銷舊 Session。Debug 依 `authorizationId` 保留一個 Session，可與 Production 同時存在；同一授權的新 Debug 登入會替換舊 Session。

### 登入與 Session 建立錯誤

| HTTP | `error` | 處理 |
| --- | --- | --- |
| 400 | `MODE_INVALID` | 修正 `mode` |
| 400 | `CLIENT_BUILD_INVALID` | 提交至少為 1 的安全整數 |
| 401 | `INVALID_CREDENTIALS` | Email 不存在、密碼錯誤或帳號暫時鎖定；顯示一致訊息 |
| 403 | `DEALER_UNAVAILABLE` | 檢查經銷商與成員狀態 |
| 403 | `GAME_DISABLED` | 停止登入已停用遊戲 |
| 403 | `GAME_NOT_AUTHORIZED` | 取得有效遊戲授權 |
| 404 | `GAME_NOT_FOUND` | 修正 `gameId` |
| 409 | `ROOM_ID_REQUIRED` | 在個人資料填寫直播間 ID |
| 409 | `PROFILE_INCOMPLETE` | 補齊區域資料 |
| 409 | `GIFT_CREDIT_EXHAUSTED` | 恢復禮物額度後再登入 |
| 409 | `BILLING_CONFIG_REQUIRED` | 等待 Production 計費設定完成 |
| 426 | `CLIENT_BUILD_UNSUPPORTED` | 升級至回應的 `minimumClientBuild` |
| 429 | `LOGIN_RATE_LIMITED` / `RATE_LIMITED` | 遵循 `Retry-After` 並退避 |
| 503 | `AUTH_UNAVAILABLE` / `BILLING_BACKLOG_PAUSED` | 指數退避並提示使用者 |

`426` 回應使用 V2 envelope：`error.code` 為 `CLIENT_BUILD_UNSUPPORTED`、`error.requestId` 為追蹤 ID，`error.details` 帶有送出的 `clientBuild` 與目前 `minimumClientBuild`。

## 獨立後台路由

NBTiktok 不保存也不回傳業務 API URL。本機、測試與正式版本使用相同的 `credentials.gameBackend` JWT、精確遊戲 Audience 與 JWKS 驗證。用戶端從可信 build 設定讀取 URL；開發 build 可在本機覆寫。登入請求不傳 URL 或額外 Secret。

回應僅含 `backendAuth` 的 Audience、營運方、隱私政策與設定版本，不含路由。JWT 只能傳給用戶端選定的可信 Origin，並拒絕跨 Origin 重新導向。

HTTP 以 `Authorization: Bearer` 傳送 JWT。獨立後台 WebSocket 使用固定子協定 `nbt.game-backend.v1`，只在第一個 `{"type":"nbt.auth","token":"..."}` 文字幀傳送同一 JWT，絕不放入 URL；Session refresh 後以新 JWT 在原連線再次傳送該幀。這不是平台 `/ws/game-client/v2`，後者仍只使用一次性 `credentials.websocket.ticket`。

可信 build 應分別設定 `NBT_GAME_BACKEND_HTTP_URL` 與 `NBT_GAME_BACKEND_WS_URL`。明文 `http/ws` 僅允許 localhost、`127.0.0.0/8` 與 `[::1]`，遠端測試及正式環境必須使用 `https/wss`。

瀏覽器 Portal 使用 NBTiktok Email 密碼重新驗證及 Authorization Code + PKCE。正式回呼精確比對已登記 HTTPS URI；本機回呼只允許 `127.0.0.1` 或 `[::1]` 的 `/__nbt/callback`。

## 已退役端點


## Session 與憑證輪換

```http
GET /api/game-client/v2/session
Authorization: Bearer <sessionAccessToken>
```

所有 Session 都保留 `credentialSource="user_session"` 與 `credentialId=null`。請核對身份、模式、直播間、build 與到期欄位；後台路由仍由用戶端設定持有。Sandbox 的 `roomId` 可為 `null`。

```http
POST /api/game-client/v2/session/refresh
Authorization: Bearer <refreshToken>
```

空本文成功回應會提供新的 `session`、權威 `backendAuth`、access、Game API、可選 game backend、一次性 refresh Token，但不含 `websocket`。整組憑證需原子替換、停止用舊 Token 發起 HTTP、以新 Backend JWT 續簽獨立後台 WebSocket，並丟棄未使用的平台 Ticket。若不再回傳 `gameBackend`，立即關閉獨立後台連線並停止呼叫。

舊 refresh Token 重複使用會回傳 `REFRESH_TOKEN_REUSED`，撤銷整個 Session 並以 `4014` 關閉 WebSocket。未知或過期 Token 回傳 `401 REFRESH_TOKEN_INVALID`；Session 絕對期限或上下文失效時回傳 `401 SESSION_REAUTH_REQUIRED`。若網路錯誤導致無法判定是否已消耗，不得重送同一 Token；應清除記憶體憑證並重新登入。`refreshAt` 永遠是 ISO 8601 時間；若用戶端恢復時已到期，應立即執行一次串行 refresh；收到 `SESSION_REAUTH_REQUIRED` 時清除憑證並重新登入。

## WebSocket 與連線圍欄

將公開 HTTPS Origin 轉成 `wss://`，再附加相對路徑：

```text
wss://<platform-origin>/ws/game-client/v2?ticket=<single-use-ticket>
```

URL 只能放 Ticket。Ticket 無效、已用或過期時握手為 `401`；計費狀態可能回傳 `409`；容量或直播來源問題可能回傳 `503`。斷線後，以仍有效的 access Token 呼叫 `POST /api/game-client/v2/session/websocket-ticket` 取得新 Ticket。

第一個業務訊息必須是：

```json
{
  "type": "ready",
  "sessionId": "6fe3890b-bb7c-4e45-b2b2-1db2d77751ce",
  "userId": "account-user-id",
  "streamerId": "9f833c5b-ec62-4530-9ae4-0af8130a26b6",
  "authorizationId": "20637113-6d4e-490c-ac6d-74242cd1de85",
  "gameId": "game_one",
  "mode": "production",
  "credentialSource": "user_session",
  "credentialId": null,
  "roomId": "creator-room",
  "clientBuild": 120,
  "minimumClientBuild": 100,
  "leaseGeneration": 4
}
```

逐項比對 `sessionId`、`streamerId`、`authorizationId`、`gameId`、`mode`、`roomId`、`clientBuild` 與 `minimumClientBuild`，並要求 `userId` 是 UUID、`leaseGeneration` 是正整數。任何不符都拒絕連線。所有事件與 ACK 佇列要綁定 `gameId` 與 `leaseGeneration`；舊連線狀態不得重用。心跳使用 WebSocket 協定 Ping/Pong，不要傳送自訂 `{ "type": "pong" }`。

## Production 事件與可靠 ACK

`ready` 後拉取未確認事件；`limit` 預設 100、最大 200：

```json
{
  "type": "pull",
  "gameId": "game_one",
  "leaseGeneration": 4,
  "limit": 100
}
```

事件 envelope：

```json
{
  "type": "event",
  "gameId": "game_one",
  "leaseGeneration": 4,
  "event": {
    "eventId": "2d2a0ef5-2388-45de-a255-2dcc73543d3b",
    "eventType": "gift",
    "occurredAt": "2026-08-19T12:00:00.000Z",
    "payload": {
      "player": { "sourcePlayerId": "viewer-id", "displayName": "Viewer" },
      "gift": {
        "providerGiftId": "5655",
        "displayName": "Rose",
        "totalCount": 1,
        "deltaCount": 1,
        "unitDiamondCount": 5,
        "giftCoins": 5,
        "missingPrice": false,
        "groupId": "gift-group",
        "repeatEnd": true
      }
    }
  }
}
```

`eventId` 是 UUID，`occurredAt` 是 ISO 8601；`gameId` 與 `leaseGeneration` 僅存在外層 envelope。`eventType` 包含 `comment`、`like`、`gift`、`stream_end`。留言使用 `payload.player` 與 `content`；按讚使用 `payload.player` 與 `count`；禮物使用上述巢狀 `player`、`gift`。

```json
[
  {
    "eventId": "b59fc69c-4c6a-4c09-990b-e0d6514b03fd",
    "eventType": "comment",
    "occurredAt": "2026-08-19T12:00:01.123Z",
    "payload": { "player": { "sourcePlayerId": "viewer-id", "displayName": "Viewer" }, "content": "join" }
  },
  {
    "eventId": "3a83d2f9-2095-4821-926d-ff8a80a0e771",
    "eventType": "like",
    "occurredAt": "2026-08-19T12:00:02.000Z",
    "payload": { "player": { "sourcePlayerId": "viewer-id", "displayName": "Viewer" }, "count": 12 }
  }
]
```

禮物連擊的 `totalCount` 是累計、`deltaCount` 是本事件增量；遊戲發獎應使用 `deltaCount`。`groupId` 可為 null，`repeatEnd` 表示連擊結束。`unitDiamondCount` 可為 0 且 `missingPrice: true`；遊戲不得從事件推斷平台扣費，也不能把 `ack_result.consumed` 當成鑽石數。

只有 `comment`、`gift` 持久化重播並需要 ACK；`like` 僅即時、不 ACK；`stream_end` 不重播、不 ACK，之後以 `4007` 關閉。未知新增欄位應忽略，但必須以權威 `eventId` 做冪等。

ACK 表示效果與處理狀態都已可靠保存，而非只收到 WebSocket。失敗事件不得 ACK，讓 Core 重播；已完成的重播不得重做效果，只重新 ACK 同一 `eventId`。

```json
{
  "type": "ack",
  "gameId": "game_one",
  "leaseGeneration": 4,
  "eventIds": ["2d2a0ef5-2388-45de-a255-2dcc73543d3b"]
}
```

單批硬上限為 500 個不重複 UUID；建議 25ms 或累計 100 個事件即送出，以先到者為準，且同連線只保留一個在途批次。

```json
{
  "type": "ack_result",
  "gameId": "game_one",
  "leaseGeneration": 4,
  "acknowledged": 1,
  "consumed": 1,
  "duplicate": 0
}
```

`acknowledged` 是首次確認數，`consumed` 是首次進入禮物消費稽核的事件數（不是鑽石），`duplicate` 是先前已確認數。必須滿足 `acknowledged + duplicate === 本批 eventIds 數量` 與 `consumed <= acknowledged`；整批重播可為 `acknowledged = 0, duplicate = N`。無法匹配或計數錯誤代表協定失步：關閉連線、清空記憶體 ACK 佇列、申請新 Ticket，再以重播與本機狀態恢復。

## Debug 協定

Debug 的登入、refresh、Ticket、`ready` 與圍欄相同。`debug_command` 包含 `commandId`、`requiresAck: true`、`transient: true`，以及具有 `eventId`、`eventType`、`occurredAt`、`payload` 的完整 `event`。指令不持久化、不重播、不進入 Production 事件、消費稽核或扣費，通常應在 15 秒內回覆。

```json
{
  "type": "debug_command",
  "commandId": "da6c668b-9e00-48d0-8113-97fbd52dc43f",
  "gameId": "game_one",
  "leaseGeneration": 4,
  "requiresAck": true,
  "transient": true,
  "event": {
    "eventId": "75c93fa7-3e1e-4fbc-b736-37b21c49e3d6",
    "eventType": "gift",
    "occurredAt": "2026-08-19T12:00:03.000Z",
    "payload": {}
  }
}
```

執行後必須明確回覆：

```json
{
  "type": "debug_command_ack",
  "gameId": "game_one",
  "leaseGeneration": 4,
  "commandId": "79ac82bb-a842-4817-aeb8-cf345db37639",
  "outcome": "processed"
}
```

失敗時使用 `"outcome": "failed"`，並提供 1–128 字元的 `errorCode`，例如 `EFFECT_REJECTED`；成功時必須省略 `errorCode`。Core 回傳 `type: "debug_command_ack_received"`，`status` 為 `processed` 或 `failed`。Debug 用戶端不得傳 Production `pull` 或 `ack`。

## 錯誤、禮物目錄與登出

協定錯誤格式是 `{ "type": "error", "error": "ERROR_CODE" }`。`SESSION_FENCE_STALE`、`ACK_EVENT_INVALID`、`ACK_FAILED` 或 Debug 圍欄錯誤都應斷線並由持久化狀態恢復。

| 關閉碼 | 原因 | 是否續用 Session | 行為 |
| --- | --- | --- | --- |
| `4001` | `SESSION_REPLACED` | 否 | 停止舊用戶端並清除憑證 |
| `4002` | `CLIENT_LOGOUT` | 否 | 正常退出 |
| `4003` | `SESSION_INVALID` / `SESSION_EXPIRED` / `SESSION_REAUTH_REQUIRED` | 否 | 重新登入 |
| `4004` | `AUTHORIZATION_INVALID` / `AUTHORIZATION_EXPIRED` | 否 | 修復授權後登入 |
| `4005` | `GAME_DELETED` / `GAME_DISABLED` | 否 | 停止遊戲 |
| `4006` | `CLIENT_BUILD_UNSUPPORTED` | 否 | 升級用戶端 |
| `4007` | `STREAM_ENDED` | 否 | 結束本場 Production |
| `4008` | `SOURCE_BINDING_CHANGED` | 否 | 更新個人資料後登入 |
| `4009` | 管理或發布下線 | 否 | 顯示 reason 並視情況登入 |
| `4010` | `SLOW_CONSUMER` | 是 | 降低阻塞，退避重連 |
| `4011` | `SOURCE_RELEASED` | 是 | 退避後申請新 Ticket |
| `4012` | `BALANCE_EXHAUSTED` | 否 | 清除憑證並補充額度 |
| `4013` | `HEARTBEAT_TIMEOUT` | 是 | 檢查網路後退避重連 |
| `4014` | `REFRESH_TOKEN_REUSED` | 否 | 清除全部憑證再登入 |

只有 `4010`、`4011`、`4013` 可用有上限的指數退避加隨機抖動重連；其他皆為 Session 終止。

禮物目錄使用 `GET /api/games/v2/game_one/gifts`，無需驗證。回應含 `game.gameId`、`game.displayName`，每個有效禮物含必填 `key`、`catalogGiftId`、`providerGiftId`、`displayName`、`diamondCount`、可為 null 的 `iconUrl`。`key` 是當前遊戲內唯一的效果觸發識別，不同遊戲可重複使用；載入或更新目錄時應建立 `providerGiftId → key` 對照。WebSocket 禮物事件本身仍只帶 `providerGiftId`，不帶 `key`；若事件 ID 不在目錄中，不得猜測或拼接 key，應安全略過效果或使用遊戲定義的通用降級效果。只回傳已啟用且身份、價格完整的禮物；Production canonical gift event 仍是執行時事實來源。

結束時呼叫：

```http
DELETE /api/game-client/v2/session
Authorization: Bearer <sessionAccessToken>
```

成功為 `204 No Content`，WebSocket 以 `4002 CLIENT_LOGOUT` 關閉；隨後清除密碼、Token 與 Ticket。排行榜必須使用獨立 `credentials.gameApi.token`，不能使用遊戲 access Token；完整合同請參閱[開放排行榜 API](./leaderboard.zh-TW.md)。

## 上線檢查清單

- 只連線平台公開 HTTPS/WSS 與 build 設定的可信獨立後台，不存取 `/internal/*`；
- 驗證密碼登入、refresh、Ticket、`ready` 和事件結構；
- 驗證獨立後台 HTTP Bearer、`nbt.auth`、原連線續簽、到期與可信 URL 規則；
- 憑證只在記憶體，日誌與遙測脫敏；
- refresh 序列執行並處理不確定網路結果；
- 每次重連取得新 Ticket、更新 `leaseGeneration`；
- `comment`、`gift` 以持久化 `eventId` 冪等，失敗不得 ACK；
- ACK 使用 25ms / 100 筆，單連線只有一個在途批次；
- Debug 區分 `debug_command` 與 Production `event`；
- 區分三個可重連碼與所有終止碼；
- 正常退出呼叫 DELETE 並清除記憶體憑證。
