v2 · 開發者指南
DOC / 04開放玩家屬性 API
版本化屬性、欄位級賽季重設、原子批次修改與 Production/Debug 隔離。
玩家屬性 API 儲存有版本的遊戲專屬狀態,例如等級、經驗、體力、角色與小型進度。排行榜分數、貨幣流水、背包、任務歷史等具關聯或審計需求的資料,應使用專用系統。
公開 Base URL:
https://<platform-origin>/openapi/v2
OpenAPI 3.1 規格可於 /openapi/game-api-v2.yaml 下載。不得直接存取容器連接埠、/internal/* 或 /admin/api/*。
#快速串接
- 呼叫
POST /api/game-client/v2/login,選擇production或debug。 - 從回應取得短期
credentials.gameApi.token。 - 讀取 active Schema,不要在用戶端寫死屬性定義。
- 讀取單一或最多 100 名玩家;尚未寫入者會取得預設值與
revision: "0"。 - 每個 mutation 使用唯一
requestId,以set、increment或max修改。 - 於 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 |
Authorization: Bearer <gameApiToken>
Accept: application/json
JSON 請求須再加 Content-Type: application/json。穩定錯誤格式為:
{
"error": {
"code": "ERROR_CODE",
"requestId": "018f1f6d-7b2a-7e34-a6cc-5e5f3fb70420",
"details": {}
}
}
#登入與 refresh
POST /api/game-client/v2/login
Content-Type: application/json
{
"email": "player@example.com",
"password": "password",
"gameId": "game_one",
"mode": "production",
"clientBuild": 120
}
{
"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。
#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
GET /openapi/v2/player-attributes/schema
Authorization: Bearer <gameApiToken>
{
"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 時重新取得,不要高頻輪詢。
#查詢單一玩家
GET /openapi/v2/player-attributes/players/tiktok-user-123
Authorization: Bearer <gameApiToken>
{
"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"
}
}
合法但尚未寫入的玩家不會因讀取而建立,而會得到:
{
"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 的缺少預設值,但不寫入。
#批次查詢
POST /openapi/v2/player-attributes/query
Authorization: Bearer <gameApiToken>
Content-Type: application/json
{
"playerIds": ["player-1", "player-2"]
}
playerIds 包含 1–100 個不重複 ID,body 最大 128 KiB。回應順序與請求相同;任一輸入無效時全部失敗。
{
"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 }
]
}
#原子批次修改
POST /openapi/v2/player-attributes/mutations
Authorization: Bearer <gameApiToken>
Content-Type: application/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 增加一次。
{
"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。
curl --fail-with-body \
-H 'Authorization: Bearer <gameApiToken>' \
'https://<platform-origin>/openapi/v2/player-attributes/players/tiktok-user-123'
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"}`);
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。
- 付費貨幣、物品所有權、審計歷史與大型集合另行建模。