NBTiktokDOCS / 开发文档

v2 · 开发者指南

DOC / 04

开放玩家属性 API

版本化属性、字段级赛季重置、原子批量修改与 Production/Debug 隔离。

玩家属性 API 为每个游戏提供独立、可版本化的玩家状态。它适合等级、经验、体力、角色和小型进度;积分榜、货币流水、背包、任务历史等数据应使用对应的专用接口。

公开 Base URL:

https://<平台域名>/openapi/v2

公开的 OpenAPI 3.1 规范可下载:/openapi/game-api-v2.yaml。不要直接访问容器端口、/internal/*/admin/api/*

#快速接入

  1. 调用 POST /api/game-client/v2/login,选择 productiondebug 模式。
  2. 从登录响应取得短期 credentials.gameApi.token
  3. 调用 Schema 接口读取当前字段、默认值和允许操作;不要把 Schema 写死在客户端。
  4. 用单玩家或批量接口读取状态。未持久化的玩家会获得默认属性和 revision: "0"
  5. 为每个 mutation 生成唯一 requestId,使用 setincrementmax 修改属性。
  6. 在 Session 的 refreshAt 到达时原子换入新凭证;网络结果不明时只能原样重试相同 mutation。

#公开接口

能力Method / Path
查询当前 SchemaGET /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": {}
  }
}

响应头 X-Request-Iderror.requestId 相同;可选 error.details 只包含稳定、非敏感的冲突上下文。

#登录、鉴权与刷新

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 与游戏 access Token、一次性 refresh Token 和 WebSocket Ticket 不同,不得混用、持久化、记录或放入 URL。

POST /api/game-client/v2/session/refresh 返回新的 credentials.sessionAccesscredentials.gameApicredentials.refresh。客户端必须串行化 refresh、原子安装整套新凭证,并立即停止主动使用旧 Token。完整 Session 合同见 交互游戏接入 API

#数据空间与玩家标识

  • Production 登录映射到 production;状态按“游戏 × regionCode × playerId”隔离。
  • Debug 登录映射到 sandbox;数据额外按“主播 × 游戏”隔离。
  • 路径、查询参数和请求体都不能切换空间。
  • Production 的 playerId 应使用 canonical event 中的 payload.player.sourcePlayerId
  • Sandbox 只允许当前主播与游戏下已注册的模拟玩家;否则返回 SANDBOX_PLAYER_NOT_FOUND
  • playerId 是 1–256 个字符的不透明字符串。放入 URL 时必须按单个 path segment 做 UTF-8 percent-encoding。

regionCode 固定来自签名 Game API Token,仅可能是 SGMYTHIDPHTW,客户端不能通过 path、query 或 body 覆盖。所有成功响应顶层都返回这个 regionCode,每个玩家状态返回服务端保存的 displayName

Production mutation 必须为每个玩家提交 1–200 字符的 playerName;Game 将它与该区域的玩家身份一起保存和更新。Sandbox mutation 必须省略 playerName,名称只来自已注册的模拟玩家。

#Schema 与属性模型

属性由平台管理员发布。游戏客户端只能读取及修改已发布字段,不能动态创建字段。当前版本使用顶层标量字段,最多 100 个:

类型JSON 类型可用约束可用操作
integernumberminimum / maximum,必须是 JSON 安全整数set, increment, max
numbernumber有限数值、minimum / maximumset, increment, max
stringstringminLength / maxLengthset
booleanbooleanset
enumstring非空、唯一的 enumValuesset

字段 key 匹配 ^[a-z][a-z0-9_]{0,63}$。每个字段都有合法默认值;状态是一个 JSON object,必须通过当前 Schema 校验,持久化后最多 64 KiB。精度高于 JSON number 的资产、积分或货币不应放在属性中。

writeOperations 可以是空数组,表示该字段对游戏客户端只读;管理员在定义中省略它时默认为 ["set"]

每个字段还必须发布 seasonResetintegernumber 支持 keepreset_to_defaultretain_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 发布或切季前阻止操作,避免把无法结算的数据带入赛季。

所有成功响应顶层均包含权威游戏赛季 seasonseasonIdseasonKeystartsAtendsAtstatus。所有榜单和玩家属性共享这条时间线;提前关闭赛季本身不会重置积分或属性。

#查询当前 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": "exp",
      "title": "经验",
      "description": "累计经验",
      "type": "integer",
      "defaultValue": 0,
      "minimum": 0,
      "writeOperations": ["set", "increment", "max"],
      "seasonReset": { "strategy": "reset_to_default" }
    },
    {
      "key": "energy",
      "title": "体力",
      "description": "当前可用体力",
      "type": "integer",
      "defaultValue": 100,
      "minimum": 0,
      "maximum": 100,
      "writeOperations": ["set", "increment"],
      "seasonReset": { "strategy": "keep" }
    }
  ],
  "defaultAttributes": {
    "level": 1,
    "exp": 0,
    "energy": 100
  },
  "jsonSchema": {
    "type": "object",
    "properties": {
      "level": { "type": "integer", "title": "等级", "default": 1, "minimum": 1, "maximum": 100 },
      "exp": { "type": "integer", "title": "经验", "default": 0, "minimum": 0 },
      "energy": { "type": "integer", "title": "体力", "default": 100, "minimum": 0, "maximum": 100 }
    },
    "required": ["level", "exp", "energy"],
    "additionalProperties": false
  },
  "writeRules": {
    "level": ["set", "max"],
    "exp": ["set", "increment", "max"],
    "energy": ["set", "increment"]
  }
}

Schema 版本只在管理员发布后变化。登录、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,
      "exp": 1350,
      "energy": 80
    },
    "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,
      "exp": 0,
      "energy": 100
    },
    "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;
  • 整个请求体最大 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, "exp": 300, "energy": 90 },
      "updatedAt": "2026-08-20T08:00:00.000Z"
    },
    {
      "regionCode": "SG",
      "playerId": "player-2",
      "displayName": "Alice",
      "revision": "0",
      "attributes": { "level": 1, "exp": 0, "energy": 100 },
      "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": "energy", "operation": "set", "value": 70 },
        { "field": "level", "operation": "max", "value": 12 }
      ]
    }
  ]
}

#请求约束

字段规则
mutations1–20 个不重复玩家;请求体最大 128 KiB
requestIdUUID v1–v5;批次内唯一,每个新业务变更必须使用新值
playerId1–256 字符;同一批次不得重复
playerNameProduction 必填 1–200 字符;Sandbox 必须省略
expectedRevision可选十进制字符串;创建前默认状态为 "0"
operations每个玩家 1–20 个;同一字段不得重复
field必须是当前 Schema 字段
operation必须在字段的 writeOperations
value类型正确;操作后结果满足所有范围和大小约束;integer 的 increment/max 操作数必须是 JSON 安全整数

操作语义:

  • setafter = value
  • incrementafter = before + value,仅限数值字段;
  • maxafter = max(before, value),仅限数值字段。

expectedRevision 存在时,必须等于服务端当前 revision,否则整批返回 409 PLAYER_ATTRIBUTE_REVISION_CONFLICT。不提供时,服务端会锁定玩家并在最新状态上原子执行;这适合可交换的 increment/max,不适合依赖旧值的覆盖。

整批在一个数据库事务中执行。任一玩家、操作、revision 或幂等检查失败,所有玩家都不会修改。成功时每个玩家 revision 恰好增加 1:

{
  "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, "energy": 70 },
      "updatedAt": "2026-08-20T08:01:00.000Z"
    }
  ]
}

#幂等与 30 天重试窗口

Production 幂等作用域覆盖全部正式数据,即 requestId 不得在不同游戏复用;Sandbox 作用域是“当前主播 × 游戏”。客户端应将 UUID 视为全局唯一。

  • 30 天内,相同 requestId 与完全相同的请求上下文、玩家、revision 和操作会返回第一次结果,不会重复修改。
  • 相同 requestId 搭配不同内容返回 409 IDEMPOTENCY_CONFLICT
  • 幂等记录保留 30 天;之后同一 UUID 可能被当作新请求,所以不得在业务中重用 UUID。
  • 处理可靠直播事件时,可为每个玩家由 eventId 确定性派生 UUID;只有属性写入确认成功后才 ACK 原事件。

#错误码

HTTP错误码含义 / 处理
400INVALID_PLAYER_IDID 为空、过长、重复或路径编码无效
400INVALID_REQUEST_BODYPOST body 缺失、JSON 无法解析或根值形状无效
400INVALID_PLAYER_IDSplayerIds 形状错误、重复、Sandbox ID 非 UUID 或不在 1–100
400INVALID_PLAYER_ATTRIBUTE_MUTATIONSmutation/operation 形状、UUID 或批次约束错误
400INVALID_PLAYER_NAMEProduction 缺少合法 playerName,或名称超过 200 字符
400INVALID_EXPECTED_REVISIONrevision 不是可用的非负 bigint 十进制字符串
400PLAYER_ATTRIBUTE_NOT_DEFINED字段未在当前 Schema 中
400DUPLICATE_PLAYER_ATTRIBUTE_OPERATION同一玩家的 operations 重复修改一个字段
400PLAYER_ATTRIBUTE_OPERATION_NOT_ALLOWED字段未授权该操作
400PLAYER_ATTRIBUTE_VALUE_INVALID值的类型或枚举校验失败
400PLAYER_ATTRIBUTE_VALUE_OUT_OF_RANGE数值范围或字符串长度越界
400PLAYER_ATTRIBUTES_INVALID最终状态不是可用的 JSON object
400PLAYER_ATTRIBUTES_TOO_LARGE操作后完整状态超过 64 KiB
401INVALID_ACCESS_TOKENAuthorization 头缺失或 Bearer 无法使用
401INVALID_ACCESS_TOKEN / SESSION_REVOKEDToken 过期、签名无效或 Session 已撤销
404GAME_NOT_FOUNDToken 对应游戏或 active Schema 不存在
404SANDBOX_PLAYER_NOT_FOUND模拟玩家不属于当前主播与游戏
409CURRENT_SEASON_UNAVAILABLE游戏没有 active 权威赛季;请管理员先配置或激活赛季
409PLAYER_ATTRIBUTE_SEASON_RESET_TOO_LARGE当前状态未超限,但后续赛季重置投影会超过 64 KiB;调整默认值或规则
409PLAYER_ATTRIBUTE_REVISION_CONFLICTexpectedRevision 已过期;重新读取并重算
409IDEMPOTENCY_CONFLICTrequestId 被不同请求复用;不要盲目换 UUID
413REQUEST_BODY_TOO_LARGEJSON 请求体超过 128 KiB
500INTERNAL_ERROR未预期服务错误;保留 requestId 并退避重试

非 JSON 或无效 Content-Type 可能在业务路由前被拒绝。公开边缘限流可直接返回 HTTP 429,其 body 不属于稳定 JSON 错误合同。客户端必须先依据 HTTP 状态,再在 JSON 可用时读取 error,不要解析人类文案。

#重试策略

  • GET 在网络失败、429 或 5xx 后使用带 jitter 的指数退避;尊重 Retry-After
  • mutation 只能重放原始字节级业务内容与同一 requestId;不得为结果不明的变更生成新 UUID。
  • 400/404 为输入或配置错误,修正前不重试。409 revision 冲突需重新读取;幂等冲突需人工或业务对账。
  • 401 时只进行一次串行化 Session refresh;如 refresh 失败,回到登录。不要并发重放一次性 refresh Token。

#curl 示例

curl --fail-with-body \
  -H 'Authorization: Bearer <gameApiToken>' \
  -H 'Accept: application/json' \
  'https://<平台域名>/openapi/v2/player-attributes/players/tiktok-user-123'
curl --fail-with-body \
  -X POST \
  -H 'Authorization: Bearer <gameApiToken>' \
  -H 'Content-Type: application/json' \
  --data '{"mutations":[{"requestId":"7747fe63-ed9d-4e8f-ae15-c90d42f20f0a","playerId":"tiktok-user-123","playerName":"Alice","expectedRevision":"7","operations":[{"field":"exp","operation":"increment","value":50}]}]}' \
  'https://<平台域名>/openapi/v2/player-attributes/mutations'

#TypeScript 示例

type PlayerState = {
  regionCode: "SG" | "MY" | "TH" | "ID" | "PH" | "TW";
  playerId: string;
  displayName: string;
  revision: string;
  attributes: Record<string, string | number | boolean>;
  updatedAt: string | null;
};

async function mutateExp(origin: string, token: string, player: PlayerState) {
  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: player.playerId,
        playerName: player.displayName,
        expectedRevision: player.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"}`);
  return body.mutations[0] as PlayerState & { requestId: string };
}

#Unity / C# 示例

using System;
using System.Collections;
using System.Text;
using UnityEngine.Networking;

[Serializable] public class AttributeOperation {
    public string field;
    public string operation;
    public int value;
}
[Serializable] public class AttributeMutation {
    public string requestId;
    public string playerId;
    public string playerName;
    public string expectedRevision;
    public AttributeOperation[] operations;
}
[Serializable] public class MutationEnvelope { public AttributeMutation[] mutations; }

public static IEnumerator AddExp(string origin, string token, string playerId, string playerName, string revision) {
    var body = new MutationEnvelope { mutations = new[] {
        new AttributeMutation {
            requestId = Guid.NewGuid().ToString(), playerId = playerId, playerName = playerName,
            expectedRevision = revision,
            operations = new[] { new AttributeOperation { 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(UnityEngine.JsonUtility.ToJson(body)));
    request.downloadHandler = new DownloadHandlerBuffer();
    request.SetRequestHeader("Authorization", "Bearer " + token);
    request.SetRequestHeader("Content-Type", "application/json");
    yield return request.SendWebRequest();
    if (request.result != UnityWebRequest.Result.Success)
        throw new Exception(request.responseCode + " " + request.downloadHandler.text);
}

Unity JsonUtility 不支持动态 object 字段;解析 attributes 时建议使用经审核的 JSON 库,或按 Schema 生成强类型 DTO。

#安全与隐私

  • 属性不应包含密码、Token、支付凭证、直播昵称、原始个人资料或不必要的敏感数据。
  • 不要把玩家 ID、属性值、Token 或完整响应记录到公开日志;只记录经脱敏的 requestId、错误码和耗时。
  • 只通过公开 HTTPS Origin 调用。不在客户端内置管理员凭证,也不把 Bearer 放入查询串。
  • 通用属性只是当前状态,不提供会计流水。付费货币、道具所有权、审计历史和大量数据必须独立建模。

#上线检查清单

  • 登录和 refresh 都会原子安装新 credentials.gameApi.token
  • 客户端在登录/refresh/Schema 版本改变后拉取 Schema。
  • Production 使用 canonical sourcePlayerId,Debug 只使用当前沙箱玩家。
  • revision 作为字符串,对依赖旧值的写入提供 expectedRevision
  • 每个新 mutation 生成唯一 UUID,并保存完整请求直至结果确认。
  • 结果不明时重放原请求,不更换 requestId
  • 正确处理 401、409、429、5xx 与 Retry-After
  • 限制单批大小,不超过 128 KiB,并对玩家 ID 做 URL 编码。
  • 日志、分析和崩溃报告不包含 Token 或敏感属性。