# API bảng xếp hạng NBTiktok v2

API bảng xếp hạng cho phép máy khách trò chơi đã được cấp quyền khám phá bảng, xem mùa giải, ghi điểm mười chiều và truy vấn thứ hạng khu vực hoặc toàn cầu. Base URL công khai:

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

Mọi yêu cầu dùng `credentials.gameApi.token` ngắn hạn từ phản hồi đăng nhập; lỗi xác thực ổn định có dạng `{ "error": { "code": "ERROR_CODE", "requestId": "018f1f6d-7b2a-7e34-a6cc-5e5f3fb70420" } }`. Không truy cập cổng container, `/internal/*` hay `/admin/api/*`. Xem [API tích hợp trò chơi tương tác](./game-integration.vi.md) để biết hợp đồng đăng nhập, Session, refresh và WebSocket.

## Tích hợp nhanh

1. Gọi `POST /api/game-client/v2/login` với `mode: "production"` hoặc `mode: "debug"`.
2. Đọc `credentials.gameApi.token` và `credentials.gameApi.expiresAt`.
3. Khám phá bảng bằng `GET /openapi/v2/leaderboards`.
4. Chỉ ghi điểm khi có `currentSeason`.
5. Gửi đủ `score1` đến `score10` cho mỗi người chơi và dùng `requestId` UUID riêng.
6. Truy vấn danh sách hoặc thứ hạng một người chơi.
7. Tại `refreshAt` của Session, chuyển nguyên tử sang Game API Token mới.

## Tuyến công khai và xác thực

| Khả năng | Method / Path |
| --- | --- |
| Khám phá bảng | `GET /openapi/v2/leaderboards` |
| Liệt kê người chơi mô phỏng | `GET /openapi/v2/leaderboards/simulation-players` |
| Liệt kê mùa giải | `GET /openapi/v2/leaderboards/:boardKey/seasons` |
| Ghi điểm mười chiều | `POST /openapi/v2/leaderboards/:boardKey/score-mutations` |
| Danh sách xếp hạng | `GET /openapi/v2/leaderboards/:boardKey/seasons/:seasonKey/rankings` |
| Hạng của một người chơi | `GET /openapi/v2/leaderboards/:boardKey/seasons/:seasonKey/players/:playerId/rank` |
| Xếp hạng tuần hiện tại | `GET /openapi/v2/leaderboards/:boardKey/weeks/current/rankings` |
| Hạng tuần của một người chơi | `GET /openapi/v2/leaderboards/:boardKey/weeks/current/players/:playerId/rank` |

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

Token là giá trị mờ, ngắn hạn, gắn với trò chơi, streamer và chế độ; nó mất hiệu lực cùng Session cha. Không lưu lâu dài hoặc ghi log. Production ánh xạ tới không gian `production`, Debug ánh xạ tới `sandbox`.

Token khóa `gameId`, `space`, `streamerId`, `regionCode`, `authorizationId` và Session; máy khách không thể ghi đè. Thành viên đại lý dùng vùng của đại lý, quyền cá nhân dùng vùng hồ sơ. Vùng ghi hỗ trợ là `SG`, `MY`, `TH`, `ID`, `PH`, `TW`; body không được chọn vùng. Token tối đa 10 phút và có thể ngắn hơn vì thời hạn tuyệt đối của Session.

`credentials.gameApi.token` khác Session access Token, refresh Token và WebSocket Ticket. Sau refresh, cài đặt nguyên tử bộ mới và ngừng chủ động dùng Game API Token cũ ngay. Hiệu lực bảo mật của Token cũ kéo dài tới `exp` 10 phút của chính nó, hoặc kết thúc sớm hơn khi Game nhận projection thu hồi Session; refresh không tự thu hồi ngay lập tức.

## Mô hình dữ liệu

Mỗi bảng có `boardKey` ổn định, tên, chiều sắp xếp, trường điểm, vùng hỗ trợ và `currentSeason` có thể rỗng. Bảng tăng dần ưu tiên điểm thấp; bảng giảm dần ưu tiên điểm cao.

Yêu cầu ghi không chứa `seasonKey`. Máy chủ tự chọn mùa đang hoạt động tại thời điểm yêu cầu; nếu không có, trả `409 CURRENT_SEASON_UNAVAILABLE`.

Mỗi mutation phải có chính xác mười trường:

```text
score1, score2, score3, score4, score5,
score6, score7, score8, score9, score10
```

Điểm là số nguyên 64 bit có dấu, truyền dưới dạng chuỗi thập phân và phải phân tích bằng số 64 bit hoặc BigInt, không dùng JSON number. Giá trị, kết quả và tổng toàn cầu phải trong `-9223372036854775808` đến `9223372036854775807`.

Ghi chỉ sửa `regionCode` của Token. Với mỗi trường điểm, global là giá trị vùng tốt nhất của người chơi: `desc` chọn lớn nhất, `asc` chọn nhỏ nhất; nếu hòa, chọn `regionCode` nhỏ nhất theo thứ tự từ điển. Mười nguồn tốt nhất được tính lại trong cùng transaction. Kết quả global trả `regionCode` nguồn đó; truy vấn vùng trả vùng đã yêu cầu.

Điểm vùng Production cách ly theo game × bảng × mùa × người chơi × `regionCode`, rồi Game suy ra global tốt nhất. Sandbox thêm cách ly streamer × trò chơi; hai không gian không chia sẻ điểm hay bản ghi idempotency.

Season là timeline chuẩn ở cấp game: mọi bảng chuyển mùa cùng lúc với một `scoreRetentionBasisPoints` (`0..10000`, mặc định `50` nghĩa là `0,5%`). Cả mười trường điểm Production giữ tỷ lệ đó và cắt về 0; Game tính lại global best cùng vùng nguồn. Cùng transaction sẽ reset player attribute Production ở sáu vùng theo `seasonReset` đã publish. Điểm Debug không được copy và thuộc tính Sandbox không reset. Chỉ đóng season mà không transition sẽ không reset điểm hay thuộc tính. Khi lưu rule tự động, chỉ timing của season tự động được cập nhật; season thủ công hiện tại giữ thời gian kết thúc đã đặt, sau đó rule tự động mới tiếp quản.

## Khám phá bảng

```http
GET /openapi/v2/leaderboards
Authorization: Bearer <gameApiToken>
```

```json
{
  "space": "production",
  "scoreFields": [
    "score1", "score2", "score3", "score4", "score5",
    "score6", "score7", "score8", "score9", "score10"
  ],
  "gameId": "game_one",
  "boards": [
    {
      "boardKey": "main",
      "displayName": "Total score",
      "scoreOrder": "desc",
      "currentSeason": {
        "seasonId": "018f2474-0c41-7bd8-9caf-2676c116b112",
        "seasonKey": "auto-20260819000000-r3",
        "startsAt": "2026-08-19T00:00:00.000Z",
        "endsAt": "2026-08-20T00:00:00.000Z",
        "status": "active"
      }
    },
    {
      "boardKey": "speedrun",
      "displayName": "Fastest completion",
      "scoreOrder": "asc",
      "currentSeason": null
    }
  ]
}
```

`boards` sắp theo `boardKey`. `currentSeason: null` cho phép đọc lịch sử nhưng không ghi. Khám phá lại sau đăng nhập hoặc refresh; không mã hóa cứng bảng, thứ tự hay thời gian mùa.

## Người chơi mô phỏng và mùa giải

Máy khách Debug gọi `GET /openapi/v2/leaderboards/simulation-players` để lấy simulation player của streamer × trò chơi. Production dùng canonical `sourcePlayerId` và gửi riêng `playerName`; xếp hạng trả `playerId` cùng `regionCode` nguồn, không trả biệt danh live.

```json
{
  "players": [
    { "playerId": "debug-player-1", "displayName": "Debug Player 1" }
  ]
}
```

Truy vấn mùa bằng `GET /openapi/v2/leaderboards/main/seasons?limit=50`. `limit` tùy chọn 1–100, mặc định 50; `cursor` là giá trị mờ của trang trước trên cùng bảng. `seasons` mới nhất trước, có `seasonKey`, `startsAt`, `endsAt`, `status` (`scheduled`, `active`, `ended`), `source` (`manual`, `automatic`), `scoreRetentionBasisPoints` được chụp khi tạo mùa và `nextCursor`. Đường dẫn nhận khóa thật hoặc `current`; mùa cũ đọc được nhưng không ghi công khai.

## Ghi điểm

```http
POST /openapi/v2/leaderboards/main/score-mutations
Authorization: Bearer <gameApiToken>
Content-Type: application/json
```

```json
{
  "mutations": [
    {
      "requestId": "533b2994-b638-45a8-9db2-f0fc926fe9e5",
      "playerId": "tiktok-user-id",
      "playerName": "Alice",
      "operation": "increment",
      "scores": {
        "score1": "100",
        "score2": "0",
        "score3": "0",
        "score4": "0",
        "score5": "0",
        "score6": "0",
        "score7": "0",
        "score8": "0",
        "score9": "0",
        "score10": "0"
      }
    }
  ]
}
```

`mutations` chứa 1–20 người chơi và body không quá 128 KiB. Mỗi `requestId` là UUID v1–v5, không lặp trong lô, nên duy nhất toàn cục; `playerId` là chuỗi mờ không rỗng, tối đa 256 ký tự; `scores` chứa đúng `score1`–`score10` dạng chuỗi số nguyên thập phân có thể âm. `operation` là `increment`, `set` hoặc `max`.

Production bắt buộc `playerName` dài 1–200 ký tự và Game lưu tên theo vùng; Sandbox phải bỏ qua trường này và dùng tên simulation player đã đăng ký.

Một operation áp dụng cho cả mười trường. Muốn chỉ tăng vài trường, gửi `"0"` cho phần còn lại. `set` thay cả mười, `max` so từng trường; không có nghĩa “bỏ trường thì giữ nguyên”. Hình dạng một trường cũ với `scoreField` và `value` trả `400 INVALID_MUTATIONS`.

Toàn bộ lô được xử lý tuần tự trong một giao dịch cơ sở dữ liệu. Bất kỳ mutation, điểm hoặc kiểm tra chống lặp sai đều hoàn tác cả lô. Ghi đồng thời cho cùng người chơi và vùng được tuần tự hóa; điểm vùng và mười nguồn global tốt nhất thay đổi cùng nhau.

```json
{
  "season": {
    "seasonId": "018f2474-0c41-7bd8-9caf-2676c116b112",
    "seasonKey": "auto-20260819000000-r3",
    "startsAt": "2026-08-19T00:00:00.000Z",
    "endsAt": "2026-08-20T00:00:00.000Z",
    "status": "active"
  },
  "mutations": [
    {
      "requestId": "533b2994-b638-45a8-9db2-f0fc926fe9e5",
      "playerId": "tiktok-user-id",
      "displayName": "Alice",
      "regionCode": "SG",
      "operation": "increment",
      "scores": {
        "score1": "1280",
        "score2": "0",
        "score3": "0",
        "score4": "0",
        "score5": "0",
        "score6": "0",
        "score7": "0",
        "score8": "0",
        "score9": "0",
        "score10": "0"
      }
    }
  ]
}
```

`season.seasonKey` là mùa thực đã ghi; mỗi item có `displayName` đã lưu và `regionCode` của Token, còn `scores` là mười giá trị mới trong vùng, không phải global tốt nhất.

## Chống lặp và thử lại

`requestId` nhận diện một mutation mười trường của một người chơi. Phạm vi chống lặp Production bao phủ toàn bộ dữ liệu chính thức; Sandbox dùng phạm vi trò chơi × streamer. Hãy tạo UUID duy nhất toàn cục cho mọi mutation mới trong cả hai chế độ.

- Gửi lại nội dung giống hệt trong cùng phạm vi trả kết quả cũ và không ghi lần nữa.
- Tái sử dụng `requestId` nhưng đổi trò chơi, người chơi, thao tác, vùng hoặc điểm trả `409 IDEMPOTENCY_CONFLICT`.
- Sau timeout hoặc lỗi tạm thời, thử lại mutation gốc giống hệt với cùng `requestId`.
- Không tạo UUID mới để che giấu dữ liệu có thể đã commit.

Request mới chọn season `current` tại thời điểm đó. Dù retry sau khi đã chuyển season, replay có cùng `requestId` và payload vẫn trả season cùng kết quả mutation gốc, không chạm dữ liệu season mới. Một batch trộn mutation mới với replay cũ, hoặc replay từ các season gốc khác nhau, trả `409 IDEMPOTENCY_CONFLICT` và rollback toàn bộ. `requestId` lặp trong cùng batch trả `400 INVALID_MUTATIONS`. Hãy lưu request gốc để đối soát, không đổi UUID. Sự kiện live tin cậy ảnh hưởng một người có thể dùng `eventId` làm `requestId`; nhiều người cần UUID xác định riêng. Chỉ ACK sự kiện nguồn sau khi ghi điểm thành công.

## Truy vấn danh sách xếp hạng

```http
GET /openapi/v2/leaderboards/main/seasons/current/rankings?scoreField=score2&scope=global&limit=100
Authorization: Bearer <gameApiToken>
```

`scoreField` là bắt buộc, chỉ nhận `score1`–`score10`. `scope` là `global` hoặc `region`; truy vấn vùng cần `region` hợp lệ. `limit` tối đa 100. Dùng `cursor` mờ từ trang trước để tiếp tục.

```json
{
  "scoreField": "score2",
  "scoreOrder": "desc",
  "rankings": [
    {
      "rank": 1,
      "playerId": "tiktok-user-id",
      "regionCode": "SG",
      "score": "980"
    }
  ],
  "nextCursor": null,
  "space": "production"
}
```

Mỗi tổ hợp không gian + trò chơi + streamer Sandbox + bảng + mùa + phạm vi + vùng + trường điểm chỉ công khai 100 hàng đầu; `limit` phân trang trong đó. Điểm bằng nhau dùng SQL `RANK`, nên hạng sau có thể nhảy; `playerId` ổn định thứ tự cùng điểm. Với `scoreOrder=asc`, điểm thấp xếp cao. Cursor gắn với ngữ cảnh đã tạo; không phân tích, sửa hoặc dùng sau khi đổi bộ lọc, nếu sai trả `400 INVALID_CURSOR`.

## Thứ hạng một người chơi

```http
GET /openapi/v2/leaderboards/main/seasons/current/players/tiktok-user-id/rank?scoreField=score2&scope=region&region=SG
```

Ở endpoint này, `scoreField` mặc định `score1`, `scope` mặc định toàn cầu. `ranking.regionCode` là vùng nguồn tốt nhất cho global, hoặc vùng truy vấn cho scope vùng. Hạng được tính trên toàn bảng và có thể lớn hơn 100; không có dữ liệu trả `{ "ranking": null }`.

## Xếp hạng tuần hiện tại

Tuần hiện tại cố định từ thứ Hai 00:00 đến thứ Hai kế tiếp 00:00 theo `Asia/Shanghai`. Điểm tuần bắt đầu từ 0 và độc lập áp dụng cùng thao tác `increment`, `set` hoặc `max` đã thành công. Điều chỉnh Production của quản trị viên được tính như increment; replay idempotent không cộng lại. Điểm tuần tiếp tục qua chuyển season và không nhận carryover hay reset của season.

Danh sách dùng `GET /openapi/v2/leaderboards/main/weeks/current/rankings?scoreField=score2&scope=global&limit=100`; một người chơi dùng `GET /openapi/v2/leaderboards/main/weeks/current/players/tiktok-user-id/rank?scoreField=score2`. Bộ lọc, phân trang top 100, SQL `RANK`, chiều sắp xếp và vùng tốt nhất giống bảng season. Endpoint một người chơi mặc định `scoreField=score1` và tính trên toàn bảng tuần.

Hai phản hồi đều có `space` và `week` gồm `startsAt`, `endsAt`, `timeZone`, `isPartial`, `trackingStartedAt`. Trong tuần triển khai, `isPartial` là true nếu tracking bắt đầu sau mốc thứ Hai; sang tuần đầy đủ kế tiếp nó tự thành false, không phỏng đoán hay backfill mutation cũ. Production và Sandbox vẫn do Token chọn và cách ly; đọc tuần không cần season đang hoạt động.

## Sandbox Debug

Đăng nhập Debug luôn cho phản hồi `space = sandbox`. Điểm Sandbox, tổng hợp toàn cầu và `requestId` được tách theo streamer × trò chơi. Cùng streamer và trò chơi vẫn truy cập dữ liệu sau khi đăng nhập lại hoặc refresh; các streamer hay trò chơi khác không chia sẻ. Cấu hình bảng và mùa vẫn do quản trị viên nền tảng cung cấp.

## Lỗi và khôi phục

| HTTP | Lỗi | Hành động |
| --- | --- | --- |
| 400 | `INVALID_REGION` | Dùng vùng hỗ trợ; vùng ghi đến từ thẩm quyền tài khoản |
| 400 | `INVALID_LIMIT` | Dùng phạm vi phân trang đã quy định |
| 400 | `INVALID_SCORE_FIELD` | Dùng `score1`–`score10` |
| 400 | `INVALID_MUTATIONS` / `INVALID_SCORE` | Sửa đầy đủ mutation mười trường |
| 400 | `REGION_REQUIRED` | Thêm `region` cho xếp hạng vùng |
| 400 | `INVALID_CURSOR` | Bỏ cursor và bắt đầu từ trang đầu |
| 401 | `INVALID_ACCESS_TOKEN` / `INVALID_ACCESS_TOKEN` | Thay Token qua refresh hoặc đăng nhập |
| 404 | `SEASON_NOT_FOUND` | Tải lại mùa và kiểm tra `seasonKey` |
| 409 | `CURRENT_SEASON_UNAVAILABLE` | Chờ mùa hoạt động hoặc liên hệ quản trị viên |
| 409 | `SEASON_NOT_ACTIVE` | Tải lại bảng và mùa |
| 409 | `IDEMPOTENCY_CONFLICT` | So sánh yêu cầu gốc; không ghi lại |
| 413 | Body quá lớn | Chia tối đa 20 người và 128 KiB |
| 500 | `INTERNAL_ERROR` | Giữ `requestId`, ghi ngữ cảnh và liên hệ nền tảng |

Đọc gặp mạng hoặc 5xx dùng backoff lũy thừa có jitter. Chỉ retry ghi với ngữ cảnh, nội dung, `requestId` y hệt. Token bảng hết hạn thì refresh Session tuần tự; nếu kết quả refresh không rõ, không dùng lại refresh Token một lần.

## Danh sách kiểm tra phát hành

- Chỉ gọi `/openapi/v2/*` trên HTTPS Origin công khai bằng Game API Token.
- Sau đăng nhập/refresh, khám phá lại bảng; chỉ ghi khi có `currentSeason`.
- Gửi mười chuỗi điểm mỗi người, xử lý bằng số 64 bit hoặc BigInt.
- Lưu `requestId` duy nhất, yêu cầu và kết quả.
- Production dùng canonical `sourcePlayerId`; Debug dùng người mô phỏng.
- Phân biệt `production` và `sandbox`; cursor chỉ cho trang kế của truy vấn gốc.
- Khi xung đột, đối chiếu yêu cầu và mùa, không đổi UUID để ghi lại.
- Log chỉ `gameId`, `boardKey`, `seasonKey` thực, status, `requestId`; không log Authorization Header.
