# 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、房间、模式、版本策略、期限与路径均为权威数据。启用独立后台时 `backendAuth` 为对象且提供 `credentials.gameBackend`；未启用时 `backendAuth` 为 `null` 且省略该凭证。密码、Token 与 Ticket 不得写入 PlayerPrefs、配置文件、日志、URL、崩溃报告或遥测。

session access Token 最长 30 分钟，Game API Token 最长 10 分钟，独立后台 JWT 最长 30 分钟。由于 `refreshAt` 取所有短期凭证刷新点的最早值，整组凭证通常仍在签发后约 8 分钟 refresh。refresh Token 仅可使用一次，Session 滑动期限最长 24 小时，`absoluteExpiresAt` 最晚为登录后 7 天；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` 登录错误仍使用扁平响应：`{ "error": "CLIENT_BUILD_UNSUPPORTED" }`；客户端升级到当前 `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`，连接后 5 秒内只在首个 `{"type":"nbt.auth","token":"..."}` 文本帧发送同一 JWT，绝不写入 URL、Cookie 或子协议。平台 Session refresh 返回新 Backend JWT 后，在原连接再次发送同一认证帧完成续签；若不再返回 `gameBackend`，立即关闭第三方连接。

第三方后台 WSS 与平台 `/ws/game-client/v2` 完全独立：前者使用 Backend JWT，后者继续使用一次性 `credentials.websocket.ticket`。可信构建分别配置 `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`。

## 已退役端点

`POST /api/game-client/v2/device-authorizations`、`POST /api/game-client/v2/device-authorizations/token` 与 `GET /api/game-client/v2/captcha` 已退役，分别固定返回 `410 DEVICE_AUTHORIZATION_REMOVED` 或 `410 CAPTCHA_LOGIN_REMOVED`。新客户端只能使用 Email 密码登录，不得回退到设备码或 CAPTCHA 流程。


## 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`、`credentials.sessionAccess`、`credentials.gameApi`、可选的 `credentials.gameBackend` 与一次性 `credentials.refresh`，但不含 `websocket`。整组凭证需原子替换，停止使用旧 Token 发起 HTTP，以新 Backend JWT 在原第三方 WSS 连接发送 `nbt.auth` 续签，并丢弃未使用的平台 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": "cf8cbe61-95c3-45f8-a883-2555e99d408f",
  "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.length` 且 `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` | 管理或发布下线 | 否 | 显示原因并视情况重新登录 |
| `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`；若事件的 `providerGiftId` 不在目录中，不得猜测或拼接 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`，不能使用 Session access Token；完整协议请参阅[开放积分榜 API](./开放积分榜接口.md)。

## 上线检查清单

- 仅连接平台公开 HTTPS/WSS 与构建配置中的可信独立后台，不访问 `/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 并清除内存凭证。
