NBTiktokDOCS / Tài liệu phát triển

v2 · Hướng dẫn dành cho nhà phát triển

DOC / 04

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

Thuộc tính có phiên bản, reset theo field mỗi mùa, mutation nguyên tử và cách ly Production/Debug.

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:

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ăngMethod / Path
Lấy Schema hiện tạiGET /openapi/v2/player-attributes/schema
Lấy một người chơiGET /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
Authorization: Bearer <gameApiToken>
Accept: application/json

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

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

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

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.

#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 regionCodeSG, 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:

TypeJSONRàng buộcOperation
integernumberJSON safe integer, minimum / maximumset, increment, max
numbernumbersố hữu hạn, minimum / maximumset, increment, max
stringstringminLength / maxLengthset
booleanbooleankhôngset
enumstringenumValues không rỗng, duy nhấtset

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. integernumber 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

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": "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

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

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

{
  "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ô

POST /openapi/v2/player-attributes/query
Authorization: Bearer <gameApiToken>
Content-Type: application/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.

{
  "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ô

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 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.

{
  "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

HTTPCodeCách xử lý
400INVALID_PLAYER_IDSửa ID rỗng, quá dài, trùng hoặc encode sai
400INVALID_REQUEST_BODYGửi JSON POST body parse được với root shape bắt buộc
400INVALID_PLAYER_IDSGửi 1–100 ID duy nhất; Sandbox ID phải là UUID
400INVALID_PLAYER_ATTRIBUTE_MUTATIONSSửa lô, UUID hoặc hình dạng operation
400INVALID_PLAYER_NAMEProduction cần playerName đã trim dài 1–200 ký tự
400INVALID_EXPECTED_REVISIONGửi revision chuỗi thập phân bigint không âm
400PLAYER_ATTRIBUTE_NOT_DEFINEDLấy lại Schema và dùng field đã publish
400DUPLICATE_PLAYER_ATTRIBUTE_OPERATIONMỗi field chỉ thay đổi một lần cho mỗi player
400PLAYER_ATTRIBUTE_OPERATION_NOT_ALLOWEDDùng operation field cho phép
400PLAYER_ATTRIBUTE_VALUE_INVALIDSửa JSON type hoặc enum value
400PLAYER_ATTRIBUTE_VALUE_OUT_OF_RANGESửa numeric range hoặc độ dài string
400PLAYER_ATTRIBUTES_INVALIDTrạng thái kết quả không phải JSON object hợp lệ
400PLAYER_ATTRIBUTES_TOO_LARGEGiữ trạng thái kết quả không quá 64 KiB
401INVALID_ACCESS_TOKENGửi Bearer hợp lệ
401INVALID_ACCESS_TOKENRefresh hoặc login lại
404GAME_NOT_FOUNDGame hoặc active Schema của Token không tồn tại
404SANDBOX_PLAYER_NOT_FOUNDDùng simulation player của Sandbox hiện tại
409CURRENT_SEASON_UNAVAILABLEGame chưa có canonical season active; quản trị viên phải cấu hình hoặc kích hoạt season
409PLAYER_ATTRIBUTE_SEASON_RESET_TOO_LARGEState 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
409PLAYER_ATTRIBUTE_REVISION_CONFLICTĐọc lại và tính lại
409IDEMPOTENCY_CONFLICTĐối soát request trước; không đổi UUID mù quáng
413REQUEST_BODY_TOO_LARGEChia nhỏ dưới 128 KiB
500INTERNAL_ERRORGiữ 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.

curl --fail-with-body \
  -H 'Authorization: Bearer <gameApiToken>' \
  'https://<platform-origin>/openapi/v2/player-attributes/players/tiktok-user-123'
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"}`);
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.