# NBTiktok 開放玩家屬性 API v2

玩家屬性 API 儲存有版本的遊戲專屬狀態，例如等級、經驗、體力、角色與小型進度。排行榜分數、貨幣流水、背包、任務歷史等具關聯或審計需求的資料，應使用專用系統。

公開 Base URL：

```text
https://<platform-origin>/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. 讀取 active Schema，不要在用戶端寫死屬性定義。
4. 讀取單一或最多 100 名玩家；尚未寫入者會取得預設值與 `revision: "0"`。
5. 每個 mutation 使用唯一 `requestId`，以 `set`、`increment` 或 `max` 修改。
6. 於 Session `refreshAt` 原子更換凭證；網路結果不明時只能原樣重試。

## 介面與驗證

| 功能 | 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": {}
  }
}
```

## 登入與 refresh

```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` 與Session access Token、單次 refresh Token 和 WebSocket Ticket 不同；不得混用、儲存、記錄、分享或放入 URL。

`POST /api/game-client/v2/session/refresh` 會回傳新的完整凭證集。將 refresh 序列化、原子安裝新凭證，並立即丟棄舊值。完整合同請參閱[互動遊戲串接 API](./game-integration.zh-TW.md)。

## Production、Sandbox 與玩家 ID

- Production 狀態依「遊戲 × regionCode × playerId」隔離。
- Debug 對應 `sandbox`，再以「實況主 × 遊戲」隔離。
- 任何 path、query 或 body 都無法切換空間。
- Production 使用 canonical event 的 `payload.player.sourcePlayerId`。
- Sandbox 只接受目前實況主與遊戲已登記的模擬玩家，否則回傳 `SANDBOX_PLAYER_NOT_FOUND`。
- `playerId` 為 1–256 字元的不透明字串；放入 path 時作為單一 UTF-8 segment percent-encode。

簽署的 Game API Token 將 `regionCode` 固定為 `SG`、`MY`、`TH`、`ID`、`PH` 或 `TW`，path、query 與 body 都不能覆寫。每個成功回應頂層都回傳此 `regionCode`，每筆玩家狀態包含已保存的 `displayName`。

每筆 Production mutation 必須提交 1–200 字元的 `playerName`，Game 會與該區域玩家一併保存；Sandbox mutation 必須省略 `playerName`，名稱以已登記模擬玩家為準。

## Schema 與欄位

管理員發佈 Schema，用戶端只能使用已發佈欄位。目前版本最多支援 100 個頂層標量欄位：

| 類型 | JSON | 約束 | 操作 |
| --- | --- | --- | --- |
| `integer` | number | JSON safe integer，`minimum` / `maximum` | `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 必須通過 active Schema，儲存上限為 64 KiB。

`writeOperations` 可為空陣列，表示遊戲用戶端只讀；管理員定義時省略它會預設為 `["set"]`。integer 的 `increment`/`max` 運算元本身必須是 JSON safe integer。

每個欄位必須包含 `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": "class",
      "title": "職業",
      "type": "enum",
      "defaultValue": "warrior",
      "enumValues": ["warrior", "mage", "archer"],
      "writeOperations": ["set"],
      "seasonReset": { "strategy": "reset_to_default" }
    }
  ],
  "defaultAttributes": { "level": 1, "class": "warrior" },
  "jsonSchema": {
    "type": "object",
    "properties": {
      "level": { "type": "integer", "title": "等級", "default": 1, "minimum": 1, "maximum": 100 },
      "class": { "type": "string", "title": "職業", "default": "warrior", "enum": ["warrior", "mage", "archer"] }
    },
    "required": ["level", "class"],
    "additionalProperties": false
  },
  "writeRules": { "level": ["set", "max"], "class": ["set"] }
}
```

登入、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, "class": "mage" },
    "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, "class": "warrior" },
    "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，body 最大 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, "class": "mage" }, "updatedAt": "2026-08-20T08:00:00.000Z" },
    { "regionCode": "SG", "playerId": "player-2", "displayName": "Bob", "revision": "0", "attributes": { "level": 1, "class": "warrior" }, "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": "level", "operation": "max", "value": 12 }
      ]
    }
  ]
}
```

- 每批 1–20 個不重複玩家，body 最大 128 KiB。
- `requestId` 為批內唯一 UUID v1–v5；每個新業務修改使用新值。
- Production 必須提供 1–200 字元 `playerName`；Sandbox 必須省略。
- `expectedRevision` 為可選十進位字串；未寫入狀態為 `"0"`。
- 每人 1–20 個 operation，欄位不重複，並必須符合 Schema。
- `set` 指定值；數值欄位的 `increment` 相加，`max` 保留較大值。

若提供 `expectedRevision`，必須與當前版本相等。省略時服務器會鎖定後於最新狀態上操作，適合可交換的 increment/max。整批是單一事務；任一錯誤會回滾所有玩家，成功時每人 revision 增加一次。

```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, "class": "mage" },
      "updatedAt": "2026-08-20T08:01:00.000Z"
    }
  ]
}
```

### 幂等與 30 天視窗

Production 幂等範圍覆蓋所有正式資料，`requestId` 不得跨遊戲重用；Sandbox 範圍是目前實況主 × 遊戲。30 天內，相同 `requestId` 加完全相同內容回傳原結果，不重複寫入；內容不同回傳 `409 IDEMPOTENCY_CONFLICT`。記錄 30 天後過期，仍不得重用 UUID。可靠事件可由 `eventId` 為每名玩家派生 UUID，成功後才 ACK。

## 錯誤代碼

| HTTP | Code | 處理 |
| --- | --- | --- |
| 400 | `INVALID_PLAYER_ID` | 修正空值、過長、重複或編碼錯誤的 ID |
| 400 | `INVALID_REQUEST_BODY` | 提供可解析 JSON POST body 與正確根結構 |
| 400 | `INVALID_PLAYER_IDS` | 提供 1–100 個不重複 ID；Sandbox ID 必須是 UUID |
| 400 | `INVALID_PLAYER_ATTRIBUTE_MUTATIONS` | 修正批次、UUID 或 operation 格式 |
| 400 | `INVALID_PLAYER_NAME` | Production 必須提供去除首尾空白後 1–200 字元的 `playerName` |
| 400 | `INVALID_EXPECTED_REVISION` | 提供非負 bigint 十進位 revision 字串 |
| 400 | `PLAYER_ATTRIBUTE_NOT_DEFINED` | 重讀 Schema，使用已發佈欄位 |
| 400 | `DUPLICATE_PLAYER_ATTRIBUTE_OPERATION` | 每名玩家的同一欄位只修改一次 |
| 400 | `PLAYER_ATTRIBUTE_OPERATION_NOT_ALLOWED` | 使用欄位允許的操作 |
| 400 | `PLAYER_ATTRIBUTE_VALUE_INVALID` | 修正 JSON 類型或枚舉值 |
| 400 | `PLAYER_ATTRIBUTE_VALUE_OUT_OF_RANGE` | 修正數值範圍或字串長度 |
| 400 | `PLAYER_ATTRIBUTES_INVALID` | 最終狀態不是可用的 JSON object |
| 400 | `PLAYER_ATTRIBUTES_TOO_LARGE` | 修改後完整狀態不得超過 64 KiB |
| 401 | `INVALID_ACCESS_TOKEN` | 提供有效 Bearer |
| 401 | `INVALID_ACCESS_TOKEN` | refresh 或重新登入 |
| 404 | `GAME_NOT_FOUND` | Token 的遊戲或 active Schema 不存在 |
| 404 | `SANDBOX_PLAYER_NOT_FOUND` | 使用目前 Sandbox 已登記玩家 |
| 409 | `CURRENT_SEASON_UNAVAILABLE` | 遊戲沒有 active 權威賽季；請管理員先設定或啟用賽季 |
| 409 | `PLAYER_ATTRIBUTE_SEASON_RESET_TOO_LARGE` | 目前狀態未超限，但後續切季投影會超過 64 KiB；調整預設值或規則 |
| 409 | `PLAYER_ATTRIBUTE_REVISION_CONFLICT` | 讀取最新狀態後重算 |
| 409 | `IDEMPOTENCY_CONFLICT` | 對帳原請求，不要盲目更換 UUID |
| 413 | `REQUEST_BODY_TOO_LARGE` | 切分至 128 KiB 以下 |
| 500 | `INTERNAL_ERROR` | 保留 requestId，安全退避重試 |

## 重試、curl、TypeScript 與 Unity

GET 在網路、429 或 5xx 時使用指數退避與 jitter。Mutation 只能以相同內容與 `requestId` 重播。400/404 先修正；409 revision 先重讀；401 只進行一次序列化 refresh。

```bash
curl --fail-with-body \
  -H 'Authorization: Bearer <gameApiToken>' \
  'https://<platform-origin>/openapi/v2/player-attributes/players/tiktok-user-123'
```

```ts
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, playerName, expectedRevision: 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"}`);
```

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

[Serializable] public class Op { public string field; public string operation; public int value; }
[Serializable] public class Mutation { public string requestId; public string playerId; public string playerName; public string expectedRevision; public Op[] operations; }
[Serializable] public class Envelope { public Mutation[] mutations; }

public static IEnumerator AddExp(string origin, string token, string playerId, string playerName, string revision) {
    var json = UnityEngine.JsonUtility.ToJson(new Envelope { mutations = new[] {
        new Mutation { requestId = Guid.NewGuid().ToString(), playerId = playerId, playerName = playerName, expectedRevision = revision,
            operations = new[] { new Op { 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(json));
    request.downloadHandler = new DownloadHandlerBuffer();
    request.SetRequestHeader("Authorization", "Bearer " + token);
    request.SetRequestHeader("Content-Type", "application/json");
    yield return request.SendWebRequest();
}
```

Unity `JsonUtility` 無法解析動態 `attributes` object；請使用已審核 JSON 套件，或由 Schema 產生強類型 DTO。

## 安全、隱私與上線清單

- [ ] 不將密碼、Token、支付凭證、原始個資或不必要的敏感值放入屬性。
- [ ] 日誌不包含 Token、玩家 ID、屬性值或完整回應。
- [ ] 只使用公開 HTTPS Origin，Bearer 不放入 query。
- [ ] login/refresh 原子安裝 Token，Schema 版本改變時重讀。
- [ ] Production 使用 canonical `sourcePlayerId`，Debug 只用目前 Sandbox 玩家。
- [ ] revision 保留為字串，依賴舊值的寫入使用 `expectedRevision`。
- [ ] 不確定寫入只重播原請求，不更換 requestId。
- [ ] 預先強制批次與 128 KiB 限制，並處理 401、409、429、5xx。
- [ ] 付費貨幣、物品所有權、審計歷史與大型集合另行建模。
