# NBTiktok 開放排行榜 API v2

開放排行榜 API 供已透過 NBTiktok 帳號與遊戲授權登入的用戶端探索榜單、查詢賽季、寫入十維積分，並讀取區域或全球排名。公開 Base URL 為：

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

所有請求都使用登入回應中的短期 `credentials.gameApi.token`，穩定驗證錯誤格式為 `{ "error": { "code": "ERROR_CODE", "requestId": "018f1f6d-7b2a-7e34-a6cc-5e5f3fb70420" } }`。不得直接存取容器埠、`/internal/*` 或 `/admin/api/*`。登入、Session、refresh 與 WebSocket 合同請參閱[互動遊戲串接 API](./game-integration.zh-TW.md)。

## 快速串接

1. 以 `mode: "production"` 或 `mode: "debug"` 呼叫 `POST /api/game-client/v2/login`。
2. 讀取 `credentials.gameApi.token` 與 `credentials.gameApi.expiresAt`。
3. 呼叫 `GET /openapi/v2/leaderboards` 探索榜單。
4. 只有 `currentSeason` 存在時才寫入積分。
5. 每名玩家都提交 `score1`～`score10`，並使用獨立 UUID `requestId`。
6. 查詢排行榜或單一玩家名次。
7. 到達 Session 的 `refreshAt` 時，原子切換到新的Game API Token。

## 公開接口與驗證

| 能力 | Method / Path |
| --- | --- |
| 探索榜單 | `GET /openapi/v2/leaderboards` |
| 查詢模擬玩家 | `GET /openapi/v2/leaderboards/simulation-players` |
| 查詢賽季 | `GET /openapi/v2/leaderboards/:boardKey/seasons` |
| 寫入十維積分 | `POST /openapi/v2/leaderboards/:boardKey/score-mutations` |
| 查詢排名列表 | `GET /openapi/v2/leaderboards/:boardKey/seasons/:seasonKey/rankings` |
| 查詢單一玩家排名 | `GET /openapi/v2/leaderboards/:boardKey/seasons/:seasonKey/players/:playerId/rank` |
| 查詢目前週榜 | `GET /openapi/v2/leaderboards/:boardKey/weeks/current/rankings` |
| 查詢單一玩家目前週排名 | `GET /openapi/v2/leaderboards/:boardKey/weeks/current/players/:playerId/rank` |

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

Token 是 Core 簽署的短期 JWT，綁定遊戲、主播與模式，並由 Game 本機驗簽；不得記錄或持久化。Production 對應 `production` 積分空間，Debug 對應 `sandbox`。

Token 鎖定 `gameId`、`space`、`streamerId`、`regionCode`、`authorizationId` 與 Session，用戶端不得覆蓋。經銷商成員的寫入區域來自經銷商；個人授權來自帳號區域。支援 `SG`、`MY`、`TH`、`ID`、`PH`、`TW`，寫入請求不能指定區域。Token 最長 10 分鐘，並可能受 Session 絕對期限縮短。

`credentials.gameApi.token` 與 Session access Token、refresh Token、WebSocket Ticket 都不同。Refresh 後必須原子安裝新憑證並立即停止主動使用舊 Game API Token；舊 Token 的安全有效性持續到自身 10 分鐘 `exp`，或更早收到 Session 撤銷投影。

## 資料模型

每個榜單都有穩定 `boardKey`、名稱、排序方向、支援的積分欄位、區域與可能為空的 `currentSeason`。升冪榜以較小分數優先，降冪榜以較大分數優先。

寫入接口沒有 `seasonKey`，伺服器會解析請求當下的活動賽季；沒有活動賽季時回傳 `409 CURRENT_SEASON_UNAVAILABLE`。

每次 mutation 必須恰好包含：

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

積分是有符號 64 位元整數，以十進位字串傳輸，應以 64 位整數或 BigInt 解析，不能使用 JSON number。值、運算結果和全球彙總都必須在 `-9223372036854775808`～`9223372036854775807`。

寫入只修改 Token `regionCode`。每個積分欄位的全球值是玩家最佳區域值：`desc` 取最大、`asc` 取最小，區域並列時取 `regionCode` 字典序最小者；十個最佳來源在同一交易重算。全球排名項目回傳該來源 `regionCode`，區域查詢回傳指定區域。

Production 區域分依遊戲 × 榜單 × 賽季 × 玩家 × `regionCode` 隔離，再推導玩家全球最佳值；Sandbox 額外依主播 × 遊戲隔離。兩個空間不共用分數或冪等紀錄。

賽季是遊戲級權威時間線，遊戲內所有榜單同時切季並共用 `scoreRetentionBasisPoints`（`0..10000`，預設 `50` 即 `0.5%`）。Production 十個積分欄位依比例繼承並向零取整；全球最佳值與來源區域會重新計算。同一交易也會按已發佈 `seasonReset` 重置六個區域的 Production 玩家屬性。Debug 沙箱積分不複製，Sandbox 玩家屬性不重置；只提前關閉賽季不會重置任何資料。管理員儲存自動規則時，只有自動賽季依新規則更新時間；目前手動賽季保留明確結束時間，結束後才由自動規則接管。

## 探索榜單

```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": "總積分榜",
      "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": "最快通關",
      "scoreOrder": "asc",
      "currentSeason": null
    }
  ]
}
```

`boards` 依 `boardKey` 排序。`currentSeason: null` 表示可讀歷史賽季但不能寫入。登入或 refresh 後應重新探索，不要寫死榜單、排序或賽季時間。

## 模擬玩家與賽季

Debug 用戶端可呼叫 `GET /openapi/v2/leaderboards/simulation-players` 取得目前主播 × 遊戲的預設與自訂模擬玩家。Production Token 也能呼叫，但回傳始終是 Sandbox 玩家。Production 寫入使用 canonical `sourcePlayerId` 並另傳 `playerName`；排行榜回傳 `playerId` 與來源 `regionCode`，不回直播暱稱。

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

賽季請求為 `GET /openapi/v2/leaderboards/main/seasons?limit=50`。`limit` 可選 1–100，預設 50；`cursor` 是同榜單上一頁的不透明游標。回應 `seasons` 依 `startsAt` 新到舊，含 `seasonKey`、`startsAt`、`endsAt`、`status`（`scheduled`、`active`、`ended`）、`source`（`manual`、`automatic`）及建立賽季時保存的 `scoreRetentionBasisPoints`，另有 `nextCursor`。路徑可用真實 `seasonKey` 或 `current`；歷史賽季可查但不能公開寫入。

## 寫入積分

```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` 必須包含 1–20 名玩家，整個本文最大 128 KiB。每個 `requestId` 是 UUID v1–v5，同批不得重複，建議全域唯一；`playerId` 是非空、不超過 256 字元的不透明值；`scores` 恰好含 `score1`～`score10` 的帶可選負號十進位字串。`operation` 只能是 `increment`、`set` 或 `max`。

Production 每名玩家必須提供 1–200 字元 `playerName`，Game 會保存該區域名稱；Sandbox 必須省略，名稱使用已登記模擬玩家。

一次 operation 套用全部十欄。只增加部分欄位時，其餘用 `"0"`；`set` 覆蓋全部，`max` 逐欄比較，不存在省略欄位保持不變。舊版單欄 `scoreField`、`value` 形狀會回傳 `400 INVALID_MUTATIONS`。

整批在同一資料庫交易中序列處理；任一 mutation、積分或冪等檢查失敗都會回滾。相同玩家與區域的並行寫入會被序列化，區域分與十個全球最佳來源一起更新。

```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` 是實際寫入賽季；每項包含已保存 `displayName` 與 Token `regionCode`，`scores` 是該區域寫入後的十個新值，不是全球最佳值。

## 冪等與重試

`requestId` 代表單一玩家的一次十維 mutation。Production 的冪等範圍涵蓋全部正式資料；Sandbox 的範圍是遊戲 × 主播。兩種模式都應為新 mutation 產生全域唯一 UUID。

- 完全相同的重送會回傳原結果，不重複寫入。
- 在同一範圍重用 `requestId`，但改變遊戲、玩家、操作、區域或任何積分，回傳 `409 IDEMPOTENCY_CONFLICT`。
- 逾時或暫時性錯誤後，以相同 `requestId` 重送完全相同的內容。
- 不得以新 UUID 掩蓋可能已提交的資料。

新請求會解析當時的 `current` 賽季。即使重試跨過賽季邊界，完全相同的 `requestId` 與內容仍回傳最初解析的原賽季和原 mutation 結果，不會寫入新賽季。批次若混合全新 mutation 與歷史重播，或重播來自不同原賽季，整批回傳 `409 IDEMPOTENCY_CONFLICT` 並回滾。同一 HTTP 批次重複 `requestId` 回傳 `400 INVALID_MUTATIONS`。必須保存原請求並核對，不能換 UUID。可靠直播事件影響單玩家時可用 `eventId` 作為 `requestId`；多玩家需為每人確定性產生不同 UUID。積分寫入成功後才能 ACK 原事件。

## 排名查詢

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

`scoreField` 必填，只接受 `score1`～`score10`。`scope` 為 `global` 或 `region`；區域查詢還要提供合法 `region`。`limit` 最大 100；下一頁使用不透明 `cursor`。

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

每組空間 + 遊戲 + Sandbox 主播 + 榜單 + 賽季 + 範圍 + 區域 + 積分欄位最多公開排序後前 100 行；`limit` 僅在其中分頁。相同分數使用 SQL `RANK` 共用名次，後續名次可跳號，以 `playerId` 穩定同分順序；`scoreOrder=asc` 時低分較前。游標綁定原查詢上下文，不得解析、修改或換條件重用，否則 `400 INVALID_CURSOR`。

單一玩家接口：

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

此接口的 `scoreField` 預設為 `score1`，`scope` 預設全球。`ranking.regionCode` 在全球範圍代表最佳來源區域，在區域範圍等於查詢區域。單玩家接口在完整榜單上計算名次，可超過 100；沒有資料時為 `{ "ranking": null }`。

## 目前週榜

目前週固定依 `Asia/Shanghai`，從週一 `00:00` 到下週一 `00:00`。週分每週從 0 開始，獨立執行每次成功寫入的相同 `increment`、`set` 或 `max`；Production 管理員調分以週分 increment 計入，冪等重放不會重複累計。週分跨賽季延續，不受切季繼承或清零影響。

列表使用 `GET /openapi/v2/leaderboards/main/weeks/current/rankings?scoreField=score2&scope=global&limit=100`，單玩家使用 `GET /openapi/v2/leaderboards/main/weeks/current/players/tiktok-user-id/rank?scoreField=score2`。篩選、前 100 名分頁、SQL `RANK`、排序方向與最佳來源區域都沿用賽季榜；單玩家 `scoreField` 預設 `score1` 並在完整週榜計算。

兩個回應都包含 `space` 與 `week`，其欄位為 `startsAt`、`endsAt`、`timeZone`、`isPartial`、`trackingStartedAt`。若追蹤在本週週一之後才上線，部署首週 `isPartial` 為 true；下一個完整週自動變為 false，平台不猜測或回填舊 mutation。Production、Sandbox 仍由 Token 決定並隔離，讀取週榜不要求目前存在活動賽季。

## Debug 沙箱

Debug 登入的回應空間固定是 `sandbox`。沙箱分數、全球彙總與 `requestId` 依主播 × 遊戲隔離；相同主播與遊戲重新登入或 refresh 後仍可存取，同遊戲不同主播或同主播不同遊戲不共享。榜單與賽季仍由平台管理員配置。

## 錯誤與恢復

| HTTP | 錯誤 | 處理 |
| --- | --- | --- |
| 400 | `INVALID_REGION` | 使用支援區域，寫入區域來自帳號權威 |
| 400 | `INVALID_LIMIT` | 使用接口規定的分頁範圍 |
| 400 | `INVALID_SCORE_FIELD` | 使用 `score1`～`score10` |
| 400 | `INVALID_MUTATIONS` / `INVALID_SCORE` | 修正完整十欄 mutation |
| 400 | `REGION_REQUIRED` | 區域排名提供 `region` |
| 400 | `INVALID_CURSOR` | 丟棄游標，從第一頁開始 |
| 401 | `INVALID_ACCESS_TOKEN` / `INVALID_ACCESS_TOKEN` | 透過 refresh 或登入更換憑證 |
| 404 | `SEASON_NOT_FOUND` | 重新整理賽季並檢查 `seasonKey` |
| 409 | `CURRENT_SEASON_UNAVAILABLE` | 等待活動賽季或聯絡管理員 |
| 409 | `SEASON_NOT_ACTIVE` | 重新整理榜單與賽季 |
| 409 | `IDEMPOTENCY_CONFLICT` | 核對原請求，不得重複寫入 |
| 413 | 本文過大 | 拆成最多 20 人且不超過 128 KiB |
| 500 | `INTERNAL_ERROR` | 保留 `requestId` 與上下文並聯絡平台 |

讀取遇到網路或 5xx 使用指數退避加隨機抖動；寫入只有原上下文、內容、`requestId` 完全相同才能重試。Token 失效時先序列 refresh，結果不確定時不能重用一次性 refresh Token。

## 上線檢查清單

- 只呼叫公開 HTTPS Origin 的 `/openapi/v2/*`，且只使用Game API Token；
- 登入與 refresh 後重新探索榜單，寫入前確認 `currentSeason`；
- 每名玩家提交十個字串積分，以 64 位整數或 BigInt 處理；
- 持久化唯一 `requestId`、請求與結果；
- Production 使用 canonical `sourcePlayerId`，Debug 使用模擬玩家；
- 區分 `production` 與 `sandbox`；游標只用於原查詢下一頁；
- 衝突時核對原請求與賽季，不換 UUID 重複寫入；
- 日誌僅記錄 `gameId`、`boardKey`、實際 `seasonKey`、狀態碼、`requestId`，絕不記錄 Authorization Header。
