v2 · 开发者指南
DOC / 04开放玩家属性 API
版本化属性、字段级赛季重置、原子批量修改与 Production/Debug 隔离。
玩家属性 API 为每个游戏提供独立、可版本化的玩家状态。它适合等级、经验、体力、角色和小型进度;积分榜、货币流水、背包、任务历史等数据应使用对应的专用接口。
公开 Base URL:
https://<平台域名>/openapi/v2
公开的 OpenAPI 3.1 规范可下载:/openapi/game-api-v2.yaml。不要直接访问容器端口、/internal/* 或 /admin/api/*。
#快速接入
- 调用
POST /api/game-client/v2/login,选择production或debug模式。 - 从登录响应取得短期
credentials.gameApi.token。 - 调用 Schema 接口读取当前字段、默认值和允许操作;不要把 Schema 写死在客户端。
- 用单玩家或批量接口读取状态。未持久化的玩家会获得默认属性和
revision: "0"。 - 为每个 mutation 生成唯一
requestId,使用set、increment或max修改属性。 - 在 Session 的
refreshAt到达时原子换入新凭证;网络结果不明时只能原样重试相同 mutation。
#公开接口
| 能力 | 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": {}
}
}
响应头 X-Request-Id 与 error.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.sessionAccess、credentials.gameApi 和 credentials.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,仅可能是 SG、MY、TH、ID、PH、TW,客户端不能通过 path、query 或 body 覆盖。所有成功响应顶层都返回这个 regionCode,每个玩家状态返回服务端保存的 displayName。
Production mutation 必须为每个玩家提交 1–200 字符的 playerName;Game 将它与该区域的玩家身份一起保存和更新。Sandbox mutation 必须省略 playerName,名称只来自已注册的模拟玩家。
#Schema 与属性模型
属性由平台管理员发布。游戏客户端只能读取及修改已发布字段,不能动态创建字段。当前版本使用顶层标量字段,最多 100 个:
| 类型 | JSON 类型 | 可用约束 | 可用操作 |
|---|---|---|---|
integer | number | minimum / maximum,必须是 JSON 安全整数 | 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,必须通过当前 Schema 校验,持久化后最多 64 KiB。精度高于 JSON number 的资产、积分或货币不应放在属性中。
writeOperations 可以是空数组,表示该字段对游戏客户端只读;管理员在定义中省略它时默认为 ["set"]。
每个字段还必须发布 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": "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 }
]
}
]
}
#请求约束
| 字段 | 规则 |
|---|---|
mutations | 1–20 个不重复玩家;请求体最大 128 KiB |
requestId | UUID v1–v5;批次内唯一,每个新业务变更必须使用新值 |
playerId | 1–256 字符;同一批次不得重复 |
playerName | Production 必填 1–200 字符;Sandbox 必须省略 |
expectedRevision | 可选十进制字符串;创建前默认状态为 "0" |
operations | 每个玩家 1–20 个;同一字段不得重复 |
field | 必须是当前 Schema 字段 |
operation | 必须在字段的 writeOperations 中 |
value | 类型正确;操作后结果满足所有范围和大小约束;integer 的 increment/max 操作数必须是 JSON 安全整数 |
操作语义:
set:after = value;increment:after = before + value,仅限数值字段;max:after = 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 | 错误码 | 含义 / 处理 |
|---|---|---|
| 400 | INVALID_PLAYER_ID | ID 为空、过长、重复或路径编码无效 |
| 400 | INVALID_REQUEST_BODY | POST body 缺失、JSON 无法解析或根值形状无效 |
| 400 | INVALID_PLAYER_IDS | playerIds 形状错误、重复、Sandbox ID 非 UUID 或不在 1–100 |
| 400 | INVALID_PLAYER_ATTRIBUTE_MUTATIONS | mutation/operation 形状、UUID 或批次约束错误 |
| 400 | INVALID_PLAYER_NAME | Production 缺少合法 playerName,或名称超过 200 字符 |
| 400 | INVALID_EXPECTED_REVISION | revision 不是可用的非负 bigint 十进制字符串 |
| 400 | PLAYER_ATTRIBUTE_NOT_DEFINED | 字段未在当前 Schema 中 |
| 400 | DUPLICATE_PLAYER_ATTRIBUTE_OPERATION | 同一玩家的 operations 重复修改一个字段 |
| 400 | PLAYER_ATTRIBUTE_OPERATION_NOT_ALLOWED | 字段未授权该操作 |
| 400 | PLAYER_ATTRIBUTE_VALUE_INVALID | 值的类型或枚举校验失败 |
| 400 | PLAYER_ATTRIBUTE_VALUE_OUT_OF_RANGE | 数值范围或字符串长度越界 |
| 400 | PLAYER_ATTRIBUTES_INVALID | 最终状态不是可用的 JSON object |
| 400 | PLAYER_ATTRIBUTES_TOO_LARGE | 操作后完整状态超过 64 KiB |
| 401 | INVALID_ACCESS_TOKEN | Authorization 头缺失或 Bearer 无法使用 |
| 401 | INVALID_ACCESS_TOKEN / SESSION_REVOKED | Token 过期、签名无效或 Session 已撤销 |
| 404 | GAME_NOT_FOUND | Token 对应游戏或 active Schema 不存在 |
| 404 | SANDBOX_PLAYER_NOT_FOUND | 模拟玩家不属于当前主播与游戏 |
| 409 | CURRENT_SEASON_UNAVAILABLE | 游戏没有 active 权威赛季;请管理员先配置或激活赛季 |
| 409 | PLAYER_ATTRIBUTE_SEASON_RESET_TOO_LARGE | 当前状态未超限,但后续赛季重置投影会超过 64 KiB;调整默认值或规则 |
| 409 | PLAYER_ATTRIBUTE_REVISION_CONFLICT | expectedRevision 已过期;重新读取并重算 |
| 409 | IDEMPOTENCY_CONFLICT | requestId 被不同请求复用;不要盲目换 UUID |
| 413 | REQUEST_BODY_TOO_LARGE | JSON 请求体超过 128 KiB |
| 500 | INTERNAL_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 或敏感属性。