NBTiktokDOCS / 开发文档

v2 · 开发者指南

DOC / 03

开放积分榜 API

游戏级赛季、十维积分写入、区域与全球排名及 Debug 沙箱。

开放积分榜 API 面向已经通过 NBTiktok 账号和游戏授权登录的游戏客户端,提供榜单发现、赛季查询、十维积分写入、区域/全球排名和 Debug 沙箱能力。

公开 Base URL 为:

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

所有接口都使用登录响应中的短期 credentials.gameApi.token。不要直接访问容器端口、诊断端口或 /internal/*,也不要使用管理后台的 /admin/api/*

账号登录、Session、refresh 和 WebSocket 的完整合同见 交互游戏接入 API

#快速接入流程

  1. 调用 POST /api/game-client/v2/login,明确选择 mode: "production"mode: "debug"
  2. 从响应中读取 credentials.gameApi.tokencredentials.gameApi.expiresAt
  3. 调用 GET /openapi/v2/leaderboards,读取可用榜单、排序方向和当前赛季。
  4. 只有 currentSeason 非空时,才向该榜单提交积分。
  5. 每名玩家一次提交 score1score10 全部十个字段,并为每名玩家生成独立 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

所有请求都必须携带:

Authorization: Bearer <gameApiToken>
Accept: application/json

业务校验失败响应使用稳定错误码:

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

响应头 X-Request-Iderror.requestId 相同;可选 error.details 只包含该错误码声明的稳定、非敏感字段。

#登录与鉴权

Production 和 Debug 使用同一个登录接口,由 mode 决定积分空间:

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-19T12:10:00.000Z"
    }
  }
}

该 Token 由 Core 签名,Game 本地验签并锁定以下上下文,客户端不能在请求中覆盖:

上下文作用
gameId只能访问当前登录游戏的榜单
spaceProduction 映射为 production,Debug 映射为 sandbox
streamerId隔离 Debug 沙箱数据
regionCode决定积分写入的区域
authorizationId / Session授权、游戏或 Session 失效后 Token 同时失效

经销商成员的写入区域来自经销商区域;个人授权的写入区域来自账号个人区域。支持区域为 SGMYTHIDPHTW。客户端不能在写积分请求中提交或修改区域。

credentials.gameApi.token 与Session access Token、refresh Token 和 WebSocket Ticket 都不同。不要混用、写入 URL、持久化、跨客户端共享或输出到日志。Token 最长有效 10 分钟,并可能受 Session 绝对期限缩短。

调用 POST /api/game-client/v2/session/refresh 成功后,客户端必须原子安装新的 session access、refresh 和 Game API Token,并立即停止主动使用旧 Token。旧 Game API Token 的安全有效性持续到自身 10 分钟 exp,或更早收到 Session 撤销投影;refresh 本身不会即时撤销它。

#数据模型

#榜单与排序

每个游戏可以配置多个榜单,使用稳定的 boardKey 标识。scoreOrder 决定更好的分数方向:

  • desc:高分在前;
  • asc:低分在前。

游戏客户端只能读取平台管理员已经创建的榜单,不能通过 OpenAPI 创建或修改榜单。

#赛季

积分始终归属于具体赛季。查询路径中的 seasonKey 可以使用真实赛季标识,也可以使用 current 动态解析当前活动赛季。

写积分接口没有 seasonKey 参数,始终在服务端把请求解析到请求发生时的当前活动赛季。没有活动赛季时写入返回 409 CURRENT_SEASON_UNAVAILABLE

#十个积分字段

每名玩家在每个赛季拥有十个相互独立的有符号 64 位整数:

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

游戏可以自行约定每个字段的用途,例如总积分、击杀、贡献、连胜等。API 不解释业务含义。

所有积分值在 JSON 中都使用十进制字符串,不能使用 JSON number。客户端应使用 64 位整数或 BigInt 解析,避免 JavaScript Number 精度丢失。值、运算结果和全球汇总都必须保持在 PostgreSQL bigint 范围内:

-9223372036854775808 ~ 9223372036854775807

#区域分与全球分

每次写入只修改 Token 的 regionCode。每个积分字段的全球值不是区域求和,而是该玩家的最佳区域值:scoreOrder=desc 取最大值,scoreOrder=asc 取最小值;多个区域并列时取 regionCode 字典序最小者。一次写入会在同一事务内重新计算这十个最佳来源。

全球排行榜和单玩家全球排名的每一项都返回最佳来源 regionCode;区域查询返回请求中的区域。查询区域不受 Token 写入区域限制,但客户端不能通过请求改变写入区域。

#Production 与 Sandbox

Production 和 Sandbox 使用独立的分数表、全球汇总和幂等记录:

  • Production 区域分按“游戏 × 榜单 × 赛季 × 玩家 × regionCode”隔离,再为每名玩家计算全球最佳值;
  • Sandbox 额外按“主播 × 游戏”隔离;
  • 两个空间使用相同的榜单和赛季定义,但分数互不读取、互不修改;
  • 客户端不能通过路径、查询参数或请求体切换空间。

赛季是游戏级权威时间线,游戏下所有榜单同时切季并使用同一 scoreRetentionBasisPoints0..10000,默认 50 表示 0.5%)。Production 十个积分维度按该比例继承并向零取整;全球最佳值和来源区域会从继承后的区域值重新计算。切季事务还会按已发布 seasonReset 规则同步重置六个区域的 Production 玩家属性。Debug 沙箱积分不复制,Sandbox 玩家属性不重置;单纯提前关闭赛季不会触发任何积分或属性重置。管理员保存自动规则时,仅自动赛季按新规则更新时间;当前手工赛季保持显式结束时间,结束后才由自动规则接管。

#发现榜单

GET /openapi/v2/leaderboards
Authorization: Bearer <gameApiToken>
{
  "gameId": "game_one",
  "space": "production",
  "scoreFields": [
    "score1", "score2", "score3", "score4", "score5",
    "score6", "score7", "score8", "score9", "score10"
  ],
  "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
    }
  ]
}

boardsboardKey 排序。currentSeason: null 表示此刻没有活动赛季,客户端可以读取历史赛季,但不能向该榜单写分。

建议登录或 refresh 后先调用一次榜单发现接口,不要把 boardKey、排序方向或赛季时间写死在客户端。

#查询模拟玩家

该接口用于 Debug 工具获取当前 Token 所属主播和游戏的沙箱玩家:

GET /openapi/v2/leaderboards/simulation-players
Authorization: Bearer <gameApiToken>
{
  "players": [
    {
      "playerId": "0bcc8797-d963-4a62-8cc5-849d77676576",
      "displayName": "Sandbox Player 01"
    }
  ]
}

服务端会确保当前“主播 × 游戏”拥有平台预设模拟玩家,并同时返回用户创建的自定义玩家。该接口接受有效的 Production 或 Debug Game API Token,但返回的始终是对应主播和游戏的沙箱玩家;正式游戏不应把模拟玩家当作真实玩家。

Production 写分应使用 canonical event 中的 payload.player.sourcePlayerId 作为稳定 playerId,并提交 1–200 字符 playerName;Game 在对应区域保存名称。Sandbox 必须省略 playerName,名称来自注册的模拟玩家。

#查询赛季

GET /openapi/v2/leaderboards/main/seasons?limit=50
Authorization: Bearer <gameApiToken>

查询参数:

参数规则
limit可选,1–100,默认 50
cursor可选,上一页返回的不透明游标
{
  "seasons": [
    {
      "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",
      "source": "automatic",
      "scoreRetentionBasisPoints": 50
    },
    {
      "seasonId": "018f2474-0c41-7bd8-9caf-2676c116b113",
      "seasonKey": "season-2026-08-18",
      "startsAt": "2026-08-18T00:00:00.000Z",
      "endsAt": "2026-08-19T00:00:00.000Z",
      "status": "ended",
      "source": "manual",
      "scoreRetentionBasisPoints": 50
    }
  ],
  "nextCursor": null
}

赛季按 startsAt 从新到旧返回。statusscheduledactiveendedsourcemanualautomaticscoreRetentionBasisPoints 是该赛季创建时保存的积分保留比例。游标只能用于相同榜单的下一页,不得解析或修改。

#写入积分

POST /openapi/v2/leaderboards/main/score-mutations
Authorization: Bearer <gameApiToken>
Content-Type: application/json
{
  "mutations": [
    {
      "requestId": "6dd60d0e-2eef-4a38-b808-b21db220b8db",
      "playerId": "tiktok-user-id",
      "playerName": "Alice",
      "operation": "increment",
      "scores": {
        "score1": "100",
        "score2": "1",
        "score3": "0",
        "score4": "0",
        "score5": "0",
        "score6": "0",
        "score7": "0",
        "score8": "0",
        "score9": "0",
        "score10": "0"
      }
    }
  ]
}

#请求约束

字段规则
mutations必须包含 1–20 名玩家;整个请求体最大 128 KiB
requestIdUUID v1–v5;同一批内不得重复,建议全局唯一
playerId非空字符串,最多 256 个字符;URL 或日志中应按不透明值处理
playerNameProduction 必填 1–200 字符;Sandbox 必须省略
operationincrementsetmax
scores必须恰好包含 score1score10,不能缺少或增加字段
每个积分值带可选负号的十进制整数字符串,且运算后仍在有符号 64 位范围内

旧版单字段请求不再支持。以下形状会返回 400 INVALID_MUTATIONS

{
  "requestId": "6dd60d0e-2eef-4a38-b808-b21db220b8db",
  "playerId": "tiktok-user-id",
  "regionCode": "SG",
    "scoreField": "score1",
  "operation": "set",
  "value": "100"
}

一次 mutation 的 operation 会统一应用到全部十个字段:

operation计算方式
incrementafter = before + submitted
setafter = submitted
maxafter = max(before, submitted)

如果只想增加部分维度,使用 increment 并把不变维度提交为字符串 "0"set 会覆盖全部十个字段;max 也会分别比较全部十个字段,均不存在“缺省字段保持不变”的语义。

一个请求中的全部玩家和全部十维积分在同一个数据库事务中串行处理。任一 mutation、积分值或幂等检查失败,整批回滚。相同玩家和区域的并发写入会被串行化,区域分和全球分在同一事务中更新。

#成功响应

{
  "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": "6dd60d0e-2eef-4a38-b808-b21db220b8db",
      "playerId": "tiktok-user-id",
      "displayName": "Alice",
      "regionCode": "SG",
      "operation": "increment",
      "scores": {
        "score1": "1280",
        "score2": "6",
        "score3": "0",
        "score4": "0",
        "score5": "0",
        "score6": "0",
        "score7": "0",
        "score8": "0",
        "score9": "0",
        "score10": "0"
      }
    }
  ]
}

响应中的 season.seasonKey 是实际写入的赛季。每项返回已保存的 displayName 与 Token regionCodescores 是该区域写入后的十个新分数,不是全球最佳值。

#幂等与重试

requestId 对应一名玩家的一次十维 mutation。Production 的幂等作用域覆盖全部正式数据;Sandbox 的幂等作用域是“游戏 × 主播”。为避免误用,客户端仍应把每个新 mutation 的 UUID 视为全局唯一。

网络失败且无法确认结果时,必须复用完全相同的 requestIdplayerIdoperation 和十个积分值:

  • 请求上下文和内容完全一致:返回第一次写入的结果,不会重复加分;
  • 在同一幂等作用域内,使用同一 requestId 改变游戏、玩家、操作、区域或任何积分值:返回 409 IDEMPOTENCY_CONFLICT
  • 同一 HTTP 批次出现重复 requestId:返回 400 INVALID_MUTATIONS

新请求会解析当时的 current 赛季。即使重试时已经跨过赛季边界,完全相同的 requestId 和内容仍会返回第一次解析到的原赛季与原 mutation 结果,不会触碰新赛季数据。一个批次若混合全新 mutation 与历史重放,或历史重放来自不同的原赛季,则整批返回 409 IDEMPOTENCY_CONFLICT 并回滚。客户端必须可靠保存原请求,发生冲突时先对账,不能直接换新 UUID 再写一次。

处理可靠直播事件时,可以把 eventId 用作单玩家 mutation 的 requestId;一个事件影响多名玩家时,应为每名玩家确定性派生不同 UUID。积分写入成功应作为游戏效果成功的一部分,之后才能按交互游戏接入协议 ACK 原事件。

#查询排名列表

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

查询参数:

参数规则
scoreField必填,只接受 score1score10
scopeglobalregion;省略时为 global
regionscope=region 时必填,支持六个区域代码
limit可选,1–100,默认 100
cursor可选,上一页返回的不透明游标

seasonKey 可以是 current,也可以是赛季列表返回的真实标识。历史或已结束赛季可以查询,但不能再通过公开写入接口修改。

全球榜响应:

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

区域榜请求示例:

GET /openapi/v2/leaderboards/main/seasons/current/rankings?scoreField=score2&scope=region&region=SG&limit=50
Authorization: Bearer <gameApiToken>

每组“空间 + 游戏 + 沙箱主播 + 榜单 + 赛季 + 范围 + 区域 + 积分字段”最多公开排序后的前 100 行。limit 只在这 100 行内分页;读取完后 nextCursornull

相同分数使用 SQL RANK 规则共享名次,后续名次可能跳号;同分行使用 playerId 保证稳定顺序。全球结果的 regionCode 是该积分字段的最佳来源;并列区域按区域代码字典序选择。scoreOrder=asc 时分数越小排名越高。

排名游标绑定生成它的游戏、空间、沙箱主播、赛季、范围、区域和积分字段。不得解析、修改,或在更换任一筛选条件后继续使用;错误上下文返回 400 INVALID_CURSOR

#查询单个玩家排名

GET /openapi/v2/leaderboards/main/seasons/current/players/tiktok-user-id/rank?scoreField=score2&scope=region&region=SG
Authorization: Bearer <gameApiToken>

路径中的 boardKeyseasonKeyplayerId 都必须进行 URL 编码。

scoreField 在此接口中可选,省略时默认为 score1scope 省略时为全球;区域排名仍需提供合法 region

{
  "ranking": {
    "playerId": "tiktok-user-id",
    "regionCode": "SG",
    "scoreField": "score2",
    "score": "980",
    "rank": 137
  }
}

单玩家接口在完整榜单上计算名次,因此可以返回 100 名以外的排名。玩家在所选赛季、范围和积分字段中没有分数记录时返回:

{ "ranking": null }

#查询当前周榜

当前周固定使用 Asia/Shanghai,从周一 00:00 到下周一 00:00。周分是一套独立积分:每周从 0 开始,对每次成功写分原样执行同一个 incrementsetmax。管理员人工调分作为 increment 同步计入 Production 周榜;幂等重放不会重复更新周分。

周榜跨赛季连续统计,切季继承、清零和玩家属性重置都不会改变周分。Production 与 Sandbox 由 Game API Token 自动隔离,Sandbox 仍额外按主播 × 游戏隔离。

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

列表接口的 scoreField 必填;scoperegionlimitcursor、Top 100、并列排名、稳定顺序和全球最佳区域规则都与赛季榜相同。响应额外返回周区间和数据完整性:

{
  "space": "production",
  "week": {
    "startsAt": "2026-08-16T16:00:00.000Z",
    "endsAt": "2026-08-23T16:00:00.000Z",
    "timeZone": "Asia/Shanghai",
    "isPartial": true,
    "trackingStartedAt": "2026-08-22T01:00:00.000Z"
  },
  "scoreField": "score2",
  "scoreOrder": "desc",
  "rankings": [
    {
      "playerId": "tiktok-user-id",
      "regionCode": "SG",
      "score": "380",
      "rank": 1
    }
  ],
  "nextCursor": null
}

isPartial: true 表示周榜追踪在本周开始之后才上线,trackingStartedAt 是可保证完整统计的起点;到下一个完整自然周会自动变为 false。平台不会用缺少原始操作信息的旧流水进行不可靠回填。

单玩家当前周排名使用:

GET /openapi/v2/leaderboards/main/weeks/current/players/tiktok-user-id/rank?scoreField=score2&scope=region&region=SG
Authorization: Bearer <gameApiToken>

该接口的 scoreField 默认 score1,在完整周榜上计算名次,并返回同一个 spaceweek 对象;玩家本周没有周分记录时 rankingnull。周榜不要求此刻存在活动赛季,因此赛季结束后仍可读取本周已产生的数据。

#Debug 沙箱

Debug Token 使用与 Production 完全相同的 URL 和请求模型。服务端从 Token 自动解析:

space = sandbox
owner = streamerId + gameId
region = resolved regionCode

沙箱分数、全球汇总和 requestId 幂等记录都按“主播 × 游戏”隔离。同一主播重新登录、refresh 或更换 Session 后仍访问原沙箱;同一游戏的不同主播、同一主播的不同游戏互不共享。

沙箱仍使用平台管理员为游戏配置的榜单和赛季。Debug 客户端应先调用模拟玩家接口,再选择 playerId 写分。榜单发现和排名列表响应中的 space 必须是 sandbox;若与当前登录模式不符,应停止处理并清理凭证。

Debug 排名不会读取或影响 Production 数据,也不会进入正式运营统计。

#错误码与恢复策略

HTTPerror处理方式
400INVALID_REGION使用支持的区域;写入区域来自账号/经销商配置
400INVALID_LIMIT使用接口规定的分页范围
400INVALID_SCORE_FIELD使用 score1score10;列表排名必须显式传入
400INVALID_MUTATIONS修正批量大小、UUID、玩家、操作或十字段对象
400INVALID_SCORE使用合法十进制 bigint 字符串
400REGION_REQUIRED区域排名补充 region
400INVALID_CURSOR丢弃游标,从第一页重新查询
401INVALID_ACCESS_TOKEN / SESSION_REVOKED使用 refresh 轮换后的新 Game API Token,或重新登录
404SEASON_NOT_FOUND刷新赛季列表并检查 seasonKey
409CURRENT_SEASON_UNAVAILABLE等待或联系管理员开启赛季
409SEASON_NOT_ACTIVE刷新榜单与赛季状态
409IDEMPOTENCY_CONFLICT核对原请求、区域和赛季;不要盲目更换 UUID
413Fastify 请求体过大将写入拆成不超过 20 人且总计不超过 128 KiB 的批次
500INTERNAL_ERROR保留原 requestId,记录上下文并联系平台

读取请求遇到网络或 5xx 可以使用指数退避加随机抖动。写请求只有在原请求上下文和内容完全相同时才能复用原 requestId 重试。

Game API Token 失效时,如果 refresh Token 仍有效,先按交互游戏接入协议完成一次串行 refresh,再使用新Game API Token 重试。refresh 结果不确定时不得重复使用同一个 refresh Token。

#上线检查清单

  • 只调用平台 HTTPS Origin 下的 /openapi/v2/*
  • 只使用 credentials.gameApi.token,不混用游戏 access Token 或 WebSocket Ticket;
  • 登录和 refresh 后重新发现榜单,不写死当前赛季;
  • 写分前确认 currentSeason 存在;
  • 每名玩家提交十个字符串积分字段,使用 64 位整数或 BigInt;
  • 每名玩家生成稳定且唯一的 requestId,持久化请求与结果;
  • Production 使用 canonical event 的 sourcePlayerId,Debug 使用模拟玩家 ID;
  • 正确区分 productionsandbox 响应空间;
  • 游标仅用于原查询的下一页;
  • 遇到幂等冲突先核对原请求和赛季,不通过更换 UUID 重复写分;
  • 日志只记录 gameIdboardKey、实际 seasonKey、状态码和 requestId,绝不记录密码、Token 或完整 Authorization Header。