# API thuộc tính người chơi NBTiktok v2

API thuộc tính người chơi lưu trạng thái có phiên bản riêng cho từng trò chơi, ví dụ cấp độ, kinh nghiệm, năng lượng, nhân vật và tiến trình nhỏ. Hãy dùng hệ thống chuyên dụng cho điểm xếp hạng, sổ cái tiền tệ, kho đồ, lịch sử nhiệm vụ và dữ liệu cần kiểm toán.

Base URL công khai:

```text
https://<platform-origin>/openapi/v2
```

Có thể tải OpenAPI 3.1 tại `/openapi/game-api-v2.yaml`. Không gọi cổng container, `/internal/*` hay `/admin/api/*`.

## Quy trình tích hợp

1. Gọi `POST /api/game-client/v2/login` với `mode: "production"` hoặc `mode: "debug"`.
2. Lấy `credentials.gameApi.token` ngắn hạn từ phản hồi.
3. Lấy Schema active; không ghi cứng định nghĩa thuộc tính trong client.
4. Đọc một hoặc tối đa 100 người chơi. Người chơi chưa ghi có giá trị mặc định và `revision: "0"`.
5. Tạo `requestId` duy nhất cho mỗi mutation; dùng `set`, `increment` hoặc `max`.
6. Thay toàn bộ credential một cách nguyên tử tại `refreshAt`. Khi kết quả mạng không rõ, chỉ gửi lại mutation ban đầu.

## Endpoint và xác thực

| Khả năng | Method / Path |
| --- | --- |
| Lấy Schema hiện tại | `GET /openapi/v2/player-attributes/schema` |
| Lấy một người chơi | `GET /openapi/v2/player-attributes/players/:playerId` |
| Truy vấn theo lô | `POST /openapi/v2/player-attributes/query` |
| Thay đổi nguyên tử theo lô | `POST /openapi/v2/player-attributes/mutations` |

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

Yêu cầu JSON cần `Content-Type: application/json`. Lỗi ổn định có dạng:

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

## Đăng nhập và 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"
    }
  }
}
```

Server suy ra `gameId`, không gian dữ liệu, `streamerId`, quyền và Session từ Token; request không thể ghi đè. `credentials.gameApi.token` khác Session access Token, refresh Token dùng một lần và WebSocket Ticket. Không trộn, lưu bền, ghi log, chia sẻ hay đặt trong URL.

`POST /api/game-client/v2/session/refresh` trả bộ credential mới. Tuần tự hóa refresh, cài toàn bộ một cách nguyên tử và bỏ credential cũ ngay. Xem [API tích hợp trò chơi tương tác](./game-integration.vi.md).

## Production, Sandbox và playerId

- Trạng thái Production cách ly theo game × `regionCode` × `playerId`.
- Debug chọn `sandbox`, cách ly thêm theo streamer × trò chơi.
- Path, query và body không thể chuyển không gian.
- Production dùng `payload.player.sourcePlayerId` trong canonical event.
- Sandbox chỉ chấp nhận simulation player đăng ký cho streamer và game hiện tại; nếu không trả `SANDBOX_PLAYER_NOT_FOUND`.
- `playerId` là chuỗi mờ 1–256 ký tự; percent-encode thành một UTF-8 path segment.

Production và Sandbox không bao giờ đọc hay sửa trạng thái hoặc bản ghi idempotency của nhau.
Game API Token đã ký cố định `regionCode` là `SG`, `MY`, `TH`, `ID`, `PH` hoặc `TW`; path, query hay body không thể ghi đè. Mọi phản hồi thành công trả `regionCode` ở cấp cao nhất và mỗi trạng thái người chơi có `displayName` đã lưu.

Mỗi mutation Production phải có `playerName` dài 1–200 ký tự để Game lưu cùng người chơi trong vùng đó. Mutation Sandbox phải bỏ qua `playerName`; tên lấy từ simulation player đã đăng ký.

## Schema và mô hình field

Quản trị viên publish Schema; client chỉ dùng field đã publish. Phiên bản hiện tại hỗ trợ tối đa 100 scalar field cấp cao nhất:

| Type | JSON | Ràng buộc | Operation |
| --- | --- | --- | --- |
| `integer` | number | JSON safe integer, `minimum` / `maximum` | `set`, `increment`, `max` |
| `number` | number | số hữu hạn, `minimum` / `maximum` | `set`, `increment`, `max` |
| `string` | string | `minLength` / `maxLength` | `set` |
| `boolean` | boolean | không | `set` |
| `enum` | string | `enumValues` không rỗng, duy nhất | `set` |

Key khớp `^[a-z][a-z0-9_]{0,63}$`. Mỗi field có mặc định hợp lệ. JSON object cuối phải qua active Schema và tối đa 64 KiB khi lưu.

`writeOperations` có thể là mảng rỗng, nghĩa là client chỉ đọc field. Khi quản trị viên bỏ qua nó, mặc định là `["set"]`. Toán hạng `increment`/`max` cho integer phải là JSON safe integer.

Mỗi field phải có `seasonReset`. `integer` và `number` hỗ trợ `keep`, `reset_to_default` hoặc `retain_percentage`; các type khác chỉ hỗ trợ giữ nguyên hay về mặc định. `basisPoints` là số nguyên `0..10000`; `50` nghĩa là `0.5%`. State Production được reset trong cùng transaction với chuyển mùa ở cấp game; Sandbox không bao giờ reset. Công thức là `mới = mặc định + (cũ - mặc định) × basisPoints / 10000`; integer làm tròn theo hướng về giá trị mặc định. Sáu vùng Production được tính độc lập.

Giới hạn 64 KiB có hai lớp. `PLAYER_ATTRIBUTES_TOO_LARGE` nghĩa là state hiện tại sau mutation đã vượt giới hạn. `PLAYER_ATTRIBUTE_SEASON_RESET_TOO_LARGE` nghĩa là state hiện tại vẫn hợp lệ nhưng cận trên kích thước bảo thủ cho bất kỳ projection reset season nào trong tương lai sẽ vượt 64 KiB. Lỗi 409 này chặn mutation, publish Schema hoặc transition trước khi dữ liệu không thể kết toán đi vào season.

Mọi response thành công đều có `season` chuẩn ở cấp cao nhất: `seasonId`, `seasonKey`, `startsAt`, `endsAt`, `status`. Tất cả leaderboard và player attribute dùng chung timeline này. Chỉ đóng season mà không transition sẽ không reset điểm hay thuộc tính.

## Lấy 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": "Cấp độ",
      "description": "Cấp hiện tại",
      "type": "integer",
      "defaultValue": 1,
      "minimum": 1,
      "maximum": 100,
      "writeOperations": ["set", "max"],
      "seasonReset": { "strategy": "retain_percentage", "basisPoints": 50 }
    },
    {
      "key": "class",
      "title": "Lớp nhân vật",
      "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": "Cấp độ", "default": 1, "minimum": 1, "maximum": 100 },
      "class": { "type": "string", "title": "Lớp nhân vật", "default": "warrior", "enum": ["warrior", "mage", "archer"] }
    },
    "required": ["level", "class"],
    "additionalProperties": false
  },
  "writeRules": { "level": ["set", "max"], "class": ["set"] }
}
```

Lấy lại sau login, refresh, hoặc khi thấy `schemaVersion` mới; không poll tần suất cao.

## Lấy một người chơi

```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"
  }
}
```

Người chơi hợp lệ nhưng chưa ghi nhận trạng thái ảo, không tạo row:

```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
  }
}
```

Xem `revision` là chuỗi thập phân mờ, không lưu bằng JavaScript `Number`. Read chèn mặc định còn thiếu của Schema mới mà không ghi.

## Truy vấn theo lô

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

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

Cho phép 1–100 ID duy nhất, body tối đa 128 KiB. Thứ tự response giống request; input sai làm hỏng toàn request.

```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 }
  ]
}
```

## Mutation nguyên tử theo lô

```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 người chơi duy nhất; body tối đa 128 KiB.
- `requestId` là UUID v1–v5 duy nhất trong lô và mới cho mỗi thay đổi nghiệp vụ.
- Production bắt buộc `playerName` dài 1–200 ký tự; Sandbox phải bỏ qua trường này.
- `expectedRevision` là chuỗi thập phân tùy chọn; trạng thái chưa ghi là `"0"`.
- 1–20 operation mỗi người chơi, không lặp field, phải thỏa Schema.
- `set` gán giá trị; `increment` cộng và `max` giữ giá trị lớn hơn chỉ cho field số.

Khi có `expectedRevision`, nó phải bằng revision hiện tại. Khi bỏ qua, server khóa và thao tác trên trạng thái mới nhất; phù hợp cho increment/max có tính giao hoán. Toàn lô là một transaction; một lỗi rollback mọi người chơi. Mỗi revision thành công tăng đúng một.

```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"
    }
  ]
}
```

### Idempotency trong 30 ngày

Idempotency Production bao trùm toàn bộ dữ liệu chính thức, vì vậy không tái sử dụng `requestId` giữa các game; Sandbox theo streamer × game hiện tại. Trong 30 ngày, cùng `requestId` và nội dung y hệt trả kết quả đầu tiên, không ghi hai lần. Cùng ID với nội dung khác trả `409 IDEMPOTENCY_CONFLICT`. Sau 30 ngày bản ghi hết hạn; không bao giờ cố ý tái sử dụng UUID. Với event tin cậy, dẫn xuất UUID cho mỗi người chơi từ `eventId` và chỉ ACK sau khi thành công.

## Mã lỗi

| HTTP | Code | Cách xử lý |
| --- | --- | --- |
| 400 | `INVALID_PLAYER_ID` | Sửa ID rỗng, quá dài, trùng hoặc encode sai |
| 400 | `INVALID_REQUEST_BODY` | Gửi JSON POST body parse được với root shape bắt buộc |
| 400 | `INVALID_PLAYER_IDS` | Gửi 1–100 ID duy nhất; Sandbox ID phải là UUID |
| 400 | `INVALID_PLAYER_ATTRIBUTE_MUTATIONS` | Sửa lô, UUID hoặc hình dạng operation |
| 400 | `INVALID_PLAYER_NAME` | Production cần `playerName` đã trim dài 1–200 ký tự |
| 400 | `INVALID_EXPECTED_REVISION` | Gửi revision chuỗi thập phân bigint không âm |
| 400 | `PLAYER_ATTRIBUTE_NOT_DEFINED` | Lấy lại Schema và dùng field đã publish |
| 400 | `DUPLICATE_PLAYER_ATTRIBUTE_OPERATION` | Mỗi field chỉ thay đổi một lần cho mỗi player |
| 400 | `PLAYER_ATTRIBUTE_OPERATION_NOT_ALLOWED` | Dùng operation field cho phép |
| 400 | `PLAYER_ATTRIBUTE_VALUE_INVALID` | Sửa JSON type hoặc enum value |
| 400 | `PLAYER_ATTRIBUTE_VALUE_OUT_OF_RANGE` | Sửa numeric range hoặc độ dài string |
| 400 | `PLAYER_ATTRIBUTES_INVALID` | Trạng thái kết quả không phải JSON object hợp lệ |
| 400 | `PLAYER_ATTRIBUTES_TOO_LARGE` | Giữ trạng thái kết quả không quá 64 KiB |
| 401 | `INVALID_ACCESS_TOKEN` | Gửi Bearer hợp lệ |
| 401 | `INVALID_ACCESS_TOKEN` | Refresh hoặc login lại |
| 404 | `GAME_NOT_FOUND` | Game hoặc active Schema của Token không tồn tại |
| 404 | `SANDBOX_PLAYER_NOT_FOUND` | Dùng simulation player của Sandbox hiện tại |
| 409 | `CURRENT_SEASON_UNAVAILABLE` | Game chưa có canonical season active; quản trị viên phải cấu hình hoặc kích hoạt season |
| 409 | `PLAYER_ATTRIBUTE_SEASON_RESET_TOO_LARGE` | State hiện tại chưa quá giới hạn nhưng projection reset season tương lai vượt 64 KiB; sửa mặc định hoặc rule |
| 409 | `PLAYER_ATTRIBUTE_REVISION_CONFLICT` | Đọc lại và tính lại |
| 409 | `IDEMPOTENCY_CONFLICT` | Đối soát request trước; không đổi UUID mù quáng |
| 413 | `REQUEST_BODY_TOO_LARGE` | Chia nhỏ dưới 128 KiB |
| 500 | `INTERNAL_ERROR` | Giữ requestId và retry an toàn với backoff |

## Retry, curl, TypeScript và Unity

Retry GET sau lỗi mạng, 429 hoặc 5xx với exponential backoff và jitter. Mutation chỉ được replay bằng nội dung và `requestId` gốc. Sửa 400/404 trước khi retry; 409 revision cần read lại; 401 chỉ kích hoạt một Session refresh được tuần tự hóa.

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

```ts
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: crypto.randomUUID(), 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` không deserialize được object `attributes` động. Hãy dùng thư viện JSON đã kiểm tra hoặc sinh DTO kiểu mạnh từ Schema.

## Bảo mật, quyền riêng tư và checklist

- [ ] Không lưu mật khẩu, Token, credential thanh toán, hồ sơ thô hay dữ liệu nhạy cảm không cần thiết trong attributes.
- [ ] Log không chứa Token, player ID, giá trị attributes hay response đầy đủ.
- [ ] Chỉ gọi HTTPS origin công khai; không đặt Bearer trong query.
- [ ] Login/refresh cài Token nguyên tử; refetch khi Schema version thay đổi.
- [ ] Production dùng canonical `sourcePlayerId`; Debug chỉ dùng player của Sandbox hiện tại.
- [ ] Giữ revision là string; gửi `expectedRevision` khi write phụ thuộc giá trị đã đọc.
- [ ] Write không chắc chắn chỉ replay request gốc, không đổi requestId.
- [ ] Kiểm tra giới hạn batch/128 KiB và xử lý 401, 409, 429, 5xx.
- [ ] Mô hình hóa riêng tiền trả phí, quyền sở hữu vật phẩm, lịch sử kiểm toán và tập dữ liệu lớn.
