# NBTiktok 开放玩家属性 API v2

玩家属性 API 为每个游戏提供独立、可版本化的玩家状态。它适合等级、经验、体力、角色和小型进度；积分榜、货币流水、背包、任务历史等数据应使用对应的专用接口。

公开 Base URL：

```text
https://<平台域名>/openapi/v2
```

公开的 OpenAPI 3.1 规范可下载：`/openapi/game-api-v2.yaml`。不要直接访问容器端口、`/internal/*` 或 `/admin/api/*`。

## 快速接入

1. 调用 `POST /api/game-client/v2/login`，选择 `production` 或 `debug` 模式。
2. 从登录响应取得短期 `credentials.gameApi.token`。
3. 调用 Schema 接口读取当前字段、默认值和允许操作；不要把 Schema 写死在客户端。
4. 用单玩家或批量接口读取状态。未持久化的玩家会获得默认属性和 `revision: "0"`。
5. 为每个 mutation 生成唯一 `requestId`，使用 `set`、`increment` 或 `max` 修改属性。
6. 在 Session 的 `refreshAt` 到达时原子换入新凭证；网络结果不明时只能原样重试相同 mutation。

## 公开接口

| 能力 | Method / Path |
| --- | --- |
| 查询当前 Schema | `GET /openapi/v2/player-attributes/schema` |
| 查询单个玩家 | `GET /openapi/v2/player-attributes/players/:playerId` |
| 批量查询玩家 | `POST /openapi/v2/player-attributes/query` |
| 原子批量修改 | `POST /openapi/v2/player-attributes/mutations` |

所有请求都必须携带：

```http
Authorization: Bearer <gameApiToken>
Accept: application/json
```

JSON 请求还需要 `Content-Type: application/json`。稳定错误响应为：

```json
{
  "error": {
    "code": "ERROR_CODE",
    "requestId": "018f1f6d-7b2a-7e34-a6cc-5e5f3fb70420",
    "details": {}
  }
}
```

响应头 `X-Request-Id` 与 `error.requestId` 相同；可选 `error.details` 只包含稳定、非敏感的冲突上下文。

## 登录、鉴权与刷新

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

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

登录成功响应包含：

```json
{
  "session": {
    "gameId": "game_one",
    "mode": "production",
    "streamerId": "9f833c5b-ec62-4530-9ae4-0af8130a26b6"
  },
  "credentials": {
    "gameApi": {
      "token": "signed-game-api-token",
      "expiresAt": "2026-08-20T12:10:00.000Z"
    }
  }
}
```

Token 由服务端锁定 `gameId`、数据空间、`streamerId`、授权和 Session；请求不接受客户端覆盖这些上下文。`credentials.gameApi.token` 与游戏 access Token、一次性 refresh Token 和 WebSocket Ticket 不同，不得混用、持久化、记录或放入 URL。

`POST /api/game-client/v2/session/refresh` 返回新的 `credentials.sessionAccess`、`credentials.gameApi` 和 `credentials.refresh`。客户端必须串行化 refresh、原子安装整套新凭证，并立即停止主动使用旧 Token。完整 Session 合同见 [交互游戏接入 API](./开放游戏接入接口.md)。

## 数据空间与玩家标识

- Production 登录映射到 `production`；状态按“游戏 × regionCode × playerId”隔离。
- Debug 登录映射到 `sandbox`；数据额外按“主播 × 游戏”隔离。
- 路径、查询参数和请求体都不能切换空间。
- Production 的 `playerId` 应使用 canonical event 中的 `payload.player.sourcePlayerId`。
- Sandbox 只允许当前主播与游戏下已注册的模拟玩家；否则返回 `SANDBOX_PLAYER_NOT_FOUND`。
- `playerId` 是 1–256 个字符的不透明字符串。放入 URL 时必须按单个 path segment 做 UTF-8 percent-encoding。

`regionCode` 固定来自签名 Game API Token，仅可能是 `SG`、`MY`、`TH`、`ID`、`PH`、`TW`，客户端不能通过 path、query 或 body 覆盖。所有成功响应顶层都返回这个 `regionCode`，每个玩家状态返回服务端保存的 `displayName`。

Production mutation 必须为每个玩家提交 1–200 字符的 `playerName`；Game 将它与该区域的玩家身份一起保存和更新。Sandbox mutation 必须省略 `playerName`，名称只来自已注册的模拟玩家。

## Schema 与属性模型

属性由平台管理员发布。游戏客户端只能读取及修改已发布字段，不能动态创建字段。当前版本使用顶层标量字段，最多 100 个：

| 类型 | JSON 类型 | 可用约束 | 可用操作 |
| --- | --- | --- | --- |
| `integer` | number | `minimum` / `maximum`，必须是 JSON 安全整数 | `set`, `increment`, `max` |
| `number` | number | 有限数值、`minimum` / `maximum` | `set`, `increment`, `max` |
| `string` | string | `minLength` / `maxLength` | `set` |
| `boolean` | boolean | 无 | `set` |
| `enum` | string | 非空、唯一的 `enumValues` | `set` |

字段 key 匹配 `^[a-z][a-z0-9_]{0,63}$`。每个字段都有合法默认值；状态是一个 JSON object，必须通过当前 Schema 校验，持久化后最多 64 KiB。精度高于 JSON number 的资产、积分或货币不应放在属性中。

`writeOperations` 可以是空数组，表示该字段对游戏客户端只读；管理员在定义中省略它时默认为 `["set"]`。

每个字段还必须发布 `seasonReset`。`integer`、`number` 支持 `keep`、`reset_to_default` 和 `retain_percentage`；其他类型只支持前两种。比例用 `basisPoints` 表示，范围 `0..10000`，其中 `50` 表示 `0.5%`。Production 在游戏级切季事务内按字段规则重置；Sandbox 永不重置。数值保留公式为 `新值 = 默认值 + (旧值 - 默认值) × basisPoints / 10000`，integer 结果向默认值方向取整。六个 Production 区域各自计算，不会跨区合并状态。

64 KiB 有两层保护：`PLAYER_ATTRIBUTES_TOO_LARGE` 表示本次 mutation 产生的当前完整状态已经超限；`PLAYER_ATTRIBUTE_SEASON_RESET_TOO_LARGE` 表示当前状态仍合法，但按已发布规则计算的任意后续赛季保守大小上界会超限。后者返回 409，并在 mutation、Schema 发布或切季前阻止操作，避免把无法结算的数据带入赛季。

所有成功响应顶层均包含权威游戏赛季 `season`：`seasonId`、`seasonKey`、`startsAt`、`endsAt`、`status`。所有榜单和玩家属性共享这条时间线；提前关闭赛季本身不会重置积分或属性。

## 查询当前 Schema

```http
GET /openapi/v2/player-attributes/schema
Authorization: Bearer <gameApiToken>
```

```json
{
  "gameId": "game_one",
  "space": "production",
  "regionCode": "SG",
  "schemaVersion": 2,
  "season": { "seasonId": "018f2474-0c41-7bd8-9caf-2676c116b112", "seasonKey": "2026-s2", "startsAt": "2026-08-01T00:00:00Z", "endsAt": "2026-09-01T00:00:00Z", "status": "active" },
  "fields": [
    {
      "key": "level",
      "title": "等级",
      "description": "玩家当前等级",
      "type": "integer",
      "defaultValue": 1,
      "minimum": 1,
      "maximum": 100,
      "writeOperations": ["set", "max"],
      "seasonReset": { "strategy": "retain_percentage", "basisPoints": 50 }
    },
    {
      "key": "exp",
      "title": "经验",
      "description": "累计经验",
      "type": "integer",
      "defaultValue": 0,
      "minimum": 0,
      "writeOperations": ["set", "increment", "max"],
      "seasonReset": { "strategy": "reset_to_default" }
    },
    {
      "key": "energy",
      "title": "体力",
      "description": "当前可用体力",
      "type": "integer",
      "defaultValue": 100,
      "minimum": 0,
      "maximum": 100,
      "writeOperations": ["set", "increment"],
      "seasonReset": { "strategy": "keep" }
    }
  ],
  "defaultAttributes": {
    "level": 1,
    "exp": 0,
    "energy": 100
  },
  "jsonSchema": {
    "type": "object",
    "properties": {
      "level": { "type": "integer", "title": "等级", "default": 1, "minimum": 1, "maximum": 100 },
      "exp": { "type": "integer", "title": "经验", "default": 0, "minimum": 0 },
      "energy": { "type": "integer", "title": "体力", "default": 100, "minimum": 0, "maximum": 100 }
    },
    "required": ["level", "exp", "energy"],
    "additionalProperties": false
  },
  "writeRules": {
    "level": ["set", "max"],
    "exp": ["set", "increment", "max"],
    "energy": ["set", "increment"]
  }
}
```

Schema 版本只在管理员发布后变化。登录、refresh 或遇到新 `schemaVersion` 时重新拉取，不要按固定时间高频轮询。

## 查询单个玩家

```http
GET /openapi/v2/player-attributes/players/tiktok-user-123
Authorization: Bearer <gameApiToken>
```

```json
{
  "gameId": "game_one",
  "space": "production",
  "regionCode": "SG",
  "schemaVersion": 2,
  "season": { "seasonId": "018f2474-0c41-7bd8-9caf-2676c116b112", "seasonKey": "2026-s2", "startsAt": "2026-08-01T00:00:00Z", "endsAt": "2026-09-01T00:00:00Z", "status": "active" },
  "player": {
    "regionCode": "SG",
    "playerId": "tiktok-user-123",
    "displayName": "Alice",
    "revision": "7",
    "attributes": {
      "level": 12,
      "exp": 1350,
      "energy": 80
    },
    "updatedAt": "2026-08-20T08:00:00.000Z"
  }
}
```

若玩家合法但尚未写入，查询不会创建数据，而是返回虚拟默认状态：

```json
{
  "gameId": "game_one",
  "space": "production",
  "regionCode": "SG",
  "schemaVersion": 2,
  "season": { "seasonId": "018f2474-0c41-7bd8-9caf-2676c116b112", "seasonKey": "2026-s2", "startsAt": "2026-08-01T00:00:00Z", "endsAt": "2026-09-01T00:00:00Z", "status": "active" },
  "player": {
    "regionCode": "SG",
    "playerId": "new-player",
    "displayName": "Alice",
    "revision": "0",
    "attributes": {
      "level": 1,
      "exp": 0,
      "energy": 100
    },
    "updatedAt": null
  }
}
```

`revision` 是十进制字符串，必须作为不透明并发版本传回，不要用 JavaScript `Number` 存储。查询时服务端会把新 Schema 的默认值补到旧状态的缺失字段，但不会产生写入。

## 批量查询

```http
POST /openapi/v2/player-attributes/query
Authorization: Bearer <gameApiToken>
Content-Type: application/json
```

```json
{
  "playerIds": ["player-1", "player-2"]
}
```

- `playerIds` 必须有 1–100 个不重复 ID；
- 整个请求体最大 128 KiB；
- 响应顺序与请求顺序一致；
- 任一输入无效时整个请求失败，不返回部分结果。

```json
{
  "gameId": "game_one",
  "space": "sandbox",
  "regionCode": "SG",
  "schemaVersion": 2,
  "season": { "seasonId": "018f2474-0c41-7bd8-9caf-2676c116b112", "seasonKey": "2026-s2", "startsAt": "2026-08-01T00:00:00Z", "endsAt": "2026-09-01T00:00:00Z", "status": "active" },
  "players": [
    {
      "regionCode": "SG",
      "playerId": "player-1",
      "displayName": "Alice",
      "revision": "4",
      "attributes": { "level": 5, "exp": 300, "energy": 90 },
      "updatedAt": "2026-08-20T08:00:00.000Z"
    },
    {
      "regionCode": "SG",
      "playerId": "player-2",
      "displayName": "Alice",
      "revision": "0",
      "attributes": { "level": 1, "exp": 0, "energy": 100 },
      "updatedAt": null
    }
  ]
}
```

## 原子批量修改

```http
POST /openapi/v2/player-attributes/mutations
Authorization: Bearer <gameApiToken>
Content-Type: application/json
```

```json
{
  "mutations": [
    {
      "requestId": "7747fe63-ed9d-4e8f-ae15-c90d42f20f0a",
      "playerId": "tiktok-user-123",
      "playerName": "Alice",
      "expectedRevision": "7",
      "operations": [
        { "field": "exp", "operation": "increment", "value": 50 },
        { "field": "energy", "operation": "set", "value": 70 },
        { "field": "level", "operation": "max", "value": 12 }
      ]
    }
  ]
}
```

### 请求约束

| 字段 | 规则 |
| --- | --- |
| `mutations` | 1–20 个不重复玩家；请求体最大 128 KiB |
| `requestId` | UUID v1–v5；批次内唯一，每个新业务变更必须使用新值 |
| `playerId` | 1–256 字符；同一批次不得重复 |
| `playerName` | Production 必填 1–200 字符；Sandbox 必须省略 |
| `expectedRevision` | 可选十进制字符串；创建前默认状态为 `"0"` |
| `operations` | 每个玩家 1–20 个；同一字段不得重复 |
| `field` | 必须是当前 Schema 字段 |
| `operation` | 必须在字段的 `writeOperations` 中 |
| `value` | 类型正确；操作后结果满足所有范围和大小约束；integer 的 `increment`/`max` 操作数必须是 JSON 安全整数 |

操作语义：

- `set`：`after = value`；
- `increment`：`after = before + value`，仅限数值字段；
- `max`：`after = max(before, value)`，仅限数值字段。

`expectedRevision` 存在时，必须等于服务端当前 revision，否则整批返回 `409 PLAYER_ATTRIBUTE_REVISION_CONFLICT`。不提供时，服务端会锁定玩家并在最新状态上原子执行；这适合可交换的 `increment`/`max`，不适合依赖旧值的覆盖。

整批在一个数据库事务中执行。任一玩家、操作、revision 或幂等检查失败，所有玩家都不会修改。成功时每个玩家 revision 恰好增加 1：

```json
{
  "gameId": "game_one",
  "space": "production",
  "regionCode": "SG",
  "schemaVersion": 2,
  "season": { "seasonId": "018f2474-0c41-7bd8-9caf-2676c116b112", "seasonKey": "2026-s2", "startsAt": "2026-08-01T00:00:00Z", "endsAt": "2026-09-01T00:00:00Z", "status": "active" },
  "mutations": [
    {
      "requestId": "7747fe63-ed9d-4e8f-ae15-c90d42f20f0a",
      "regionCode": "SG",
      "playerId": "tiktok-user-123",
      "displayName": "Alice",
      "revision": "8",
      "attributes": { "level": 12, "exp": 1400, "energy": 70 },
      "updatedAt": "2026-08-20T08:01:00.000Z"
    }
  ]
}
```

### 幂等与 30 天重试窗口

Production 幂等作用域覆盖全部正式数据，即 `requestId` 不得在不同游戏复用；Sandbox 作用域是“当前主播 × 游戏”。客户端应将 UUID 视为全局唯一。

- 30 天内，相同 `requestId` 与完全相同的请求上下文、玩家、revision 和操作会返回第一次结果，不会重复修改。
- 相同 `requestId` 搭配不同内容返回 `409 IDEMPOTENCY_CONFLICT`。
- 幂等记录保留 30 天；之后同一 UUID 可能被当作新请求，所以不得在业务中重用 UUID。
- 处理可靠直播事件时，可为每个玩家由 `eventId` 确定性派生 UUID；只有属性写入确认成功后才 ACK 原事件。

## 错误码

| HTTP | 错误码 | 含义 / 处理 |
| --- | --- | --- |
| 400 | `INVALID_PLAYER_ID` | ID 为空、过长、重复或路径编码无效 |
| 400 | `INVALID_REQUEST_BODY` | POST body 缺失、JSON 无法解析或根值形状无效 |
| 400 | `INVALID_PLAYER_IDS` | `playerIds` 形状错误、重复、Sandbox ID 非 UUID 或不在 1–100 |
| 400 | `INVALID_PLAYER_ATTRIBUTE_MUTATIONS` | mutation/operation 形状、UUID 或批次约束错误 |
| 400 | `INVALID_PLAYER_NAME` | Production 缺少合法 `playerName`，或名称超过 200 字符 |
| 400 | `INVALID_EXPECTED_REVISION` | revision 不是可用的非负 bigint 十进制字符串 |
| 400 | `PLAYER_ATTRIBUTE_NOT_DEFINED` | 字段未在当前 Schema 中 |
| 400 | `DUPLICATE_PLAYER_ATTRIBUTE_OPERATION` | 同一玩家的 operations 重复修改一个字段 |
| 400 | `PLAYER_ATTRIBUTE_OPERATION_NOT_ALLOWED` | 字段未授权该操作 |
| 400 | `PLAYER_ATTRIBUTE_VALUE_INVALID` | 值的类型或枚举校验失败 |
| 400 | `PLAYER_ATTRIBUTE_VALUE_OUT_OF_RANGE` | 数值范围或字符串长度越界 |
| 400 | `PLAYER_ATTRIBUTES_INVALID` | 最终状态不是可用的 JSON object |
| 400 | `PLAYER_ATTRIBUTES_TOO_LARGE` | 操作后完整状态超过 64 KiB |
| 401 | `INVALID_ACCESS_TOKEN` | Authorization 头缺失或 Bearer 无法使用 |
| 401 | `INVALID_ACCESS_TOKEN` / `SESSION_REVOKED` | Token 过期、签名无效或 Session 已撤销 |
| 404 | `GAME_NOT_FOUND` | Token 对应游戏或 active Schema 不存在 |
| 404 | `SANDBOX_PLAYER_NOT_FOUND` | 模拟玩家不属于当前主播与游戏 |
| 409 | `CURRENT_SEASON_UNAVAILABLE` | 游戏没有 active 权威赛季；请管理员先配置或激活赛季 |
| 409 | `PLAYER_ATTRIBUTE_SEASON_RESET_TOO_LARGE` | 当前状态未超限，但后续赛季重置投影会超过 64 KiB；调整默认值或规则 |
| 409 | `PLAYER_ATTRIBUTE_REVISION_CONFLICT` | `expectedRevision` 已过期；重新读取并重算 |
| 409 | `IDEMPOTENCY_CONFLICT` | `requestId` 被不同请求复用；不要盲目换 UUID |
| 413 | `REQUEST_BODY_TOO_LARGE` | JSON 请求体超过 128 KiB |
| 500 | `INTERNAL_ERROR` | 未预期服务错误；保留 requestId 并退避重试 |

非 JSON 或无效 `Content-Type` 可能在业务路由前被拒绝。公开边缘限流可直接返回 HTTP 429，其 body 不属于稳定 JSON 错误合同。客户端必须先依据 HTTP 状态，再在 JSON 可用时读取 `error`，不要解析人类文案。

## 重试策略

- GET 在网络失败、429 或 5xx 后使用带 jitter 的指数退避；尊重 `Retry-After`。
- mutation 只能重放原始字节级业务内容与同一 `requestId`；不得为结果不明的变更生成新 UUID。
- 400/404 为输入或配置错误，修正前不重试。409 revision 冲突需重新读取；幂等冲突需人工或业务对账。
- 401 时只进行一次串行化 Session refresh；如 refresh 失败，回到登录。不要并发重放一次性 refresh Token。

## curl 示例

```bash
curl --fail-with-body \
  -H 'Authorization: Bearer <gameApiToken>' \
  -H 'Accept: application/json' \
  'https://<平台域名>/openapi/v2/player-attributes/players/tiktok-user-123'
```

```bash
curl --fail-with-body \
  -X POST \
  -H 'Authorization: Bearer <gameApiToken>' \
  -H 'Content-Type: application/json' \
  --data '{"mutations":[{"requestId":"7747fe63-ed9d-4e8f-ae15-c90d42f20f0a","playerId":"tiktok-user-123","playerName":"Alice","expectedRevision":"7","operations":[{"field":"exp","operation":"increment","value":50}]}]}' \
  'https://<平台域名>/openapi/v2/player-attributes/mutations'
```

## TypeScript 示例

```ts
type PlayerState = {
  regionCode: "SG" | "MY" | "TH" | "ID" | "PH" | "TW";
  playerId: string;
  displayName: string;
  revision: string;
  attributes: Record<string, string | number | boolean>;
  updatedAt: string | null;
};

async function mutateExp(origin: string, token: string, player: PlayerState) {
  const requestId = crypto.randomUUID();
  const response = await fetch(`${origin}/openapi/v2/player-attributes/mutations`, {
    method: "POST",
    headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
    body: JSON.stringify({
      mutations: [{
        requestId,
        playerId: player.playerId,
        playerName: player.displayName,
        expectedRevision: player.revision,
        operations: [{ field: "exp", operation: "increment", value: 50 }],
      }],
    }),
  });
  const body = await response.json();
  if (!response.ok) throw new Error(`${response.status} ${body.error ?? "UNKNOWN_ERROR"}`);
  return body.mutations[0] as PlayerState & { requestId: string };
}
```

## Unity / C# 示例

```csharp
using System;
using System.Collections;
using System.Text;
using UnityEngine.Networking;

[Serializable] public class AttributeOperation {
    public string field;
    public string operation;
    public int value;
}
[Serializable] public class AttributeMutation {
    public string requestId;
    public string playerId;
    public string playerName;
    public string expectedRevision;
    public AttributeOperation[] operations;
}
[Serializable] public class MutationEnvelope { public AttributeMutation[] mutations; }

public static IEnumerator AddExp(string origin, string token, string playerId, string playerName, string revision) {
    var body = new MutationEnvelope { mutations = new[] {
        new AttributeMutation {
            requestId = Guid.NewGuid().ToString(), playerId = playerId, playerName = playerName,
            expectedRevision = revision,
            operations = new[] { new AttributeOperation { field = "exp", operation = "increment", value = 50 } }
        }
    }};
    using var request = new UnityWebRequest(origin + "/openapi/v2/player-attributes/mutations", "POST");
    request.uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(UnityEngine.JsonUtility.ToJson(body)));
    request.downloadHandler = new DownloadHandlerBuffer();
    request.SetRequestHeader("Authorization", "Bearer " + token);
    request.SetRequestHeader("Content-Type", "application/json");
    yield return request.SendWebRequest();
    if (request.result != UnityWebRequest.Result.Success)
        throw new Exception(request.responseCode + " " + request.downloadHandler.text);
}
```

Unity `JsonUtility` 不支持动态 object 字段；解析 `attributes` 时建议使用经审核的 JSON 库，或按 Schema 生成强类型 DTO。

## 安全与隐私

- 属性不应包含密码、Token、支付凭证、直播昵称、原始个人资料或不必要的敏感数据。
- 不要把玩家 ID、属性值、Token 或完整响应记录到公开日志；只记录经脱敏的 requestId、错误码和耗时。
- 只通过公开 HTTPS Origin 调用。不在客户端内置管理员凭证，也不把 Bearer 放入查询串。
- 通用属性只是当前状态，不提供会计流水。付费货币、道具所有权、审计历史和大量数据必须独立建模。

## 上线检查清单

- [ ] 登录和 refresh 都会原子安装新 `credentials.gameApi.token`。
- [ ] 客户端在登录/refresh/Schema 版本改变后拉取 Schema。
- [ ] Production 使用 canonical `sourcePlayerId`，Debug 只使用当前沙箱玩家。
- [ ] 将 `revision` 作为字符串，对依赖旧值的写入提供 `expectedRevision`。
- [ ] 每个新 mutation 生成唯一 UUID，并保存完整请求直至结果确认。
- [ ] 结果不明时重放原请求，不更换 `requestId`。
- [ ] 正确处理 401、409、429、5xx 与 `Retry-After`。
- [ ] 限制单批大小，不超过 128 KiB，并对玩家 ID 做 URL 编码。
- [ ] 日志、分析和崩溃报告不包含 Token 或敏感属性。
