API 目前处于 Early Preview 阶段, 可能存在诸多不完善与错误之处,欢迎加群交流反馈:加入官方交流群(1073434398)
概念:档案、成绩与最佳成绩桶
profile / score / bucket 三层概念,以及跨游戏归一化做到哪、没做到哪:rating 同名不同义、排序只在服务端、游标不透明、能力以 GET /v1/games 为准。
本页讲概念,不讲每个端点的字段细则(那些在游戏档案与成绩读取与写入)。
先记住一句话:平台只归一"形状"和"翻页/排序协议",不归一"数值语义"。
rating 在每个游戏里都叫 rating,但它是各游戏自己的分 —— 不能跨游戏比大小,也不能相加。
三层,各回答一个问题
| 层 | 回答什么 | 端点 |
|---|---|---|
| profile(档案) | 这个用户在这个游戏上是谁 | GET /v1/games/{game}/profiles、.../{profileId} |
| score(成绩) | 一条成绩长什么样 | .../{profileId}/scores |
| bucket(最佳成绩桶) | 这个游戏怎么算"总力" | .../{profileId}/bests |
三层都套在 v1 envelope 里({ code, message, description, data, requestId }),时间 RFC3339、字段 camelCase。
档案(profile)
一条档案 = 一个 RankHub 用户在一个游戏上的一张档案。它的 id 是 profileId(uuid),
这是 RankHub 的标识,游戏平台不认识它。
游戏原生标识放在 player.native 里(userId / friendCode / objectId / osuUserId 等),
两套东西没有换算关系。每个游戏的身份字段与必填组合见
怎么传数据(速查)。
一个用户在同一游戏上可能有多张档案(换过账号、或用不同标识各建过一张),所以列表接口才是列表。
每个游戏的原生身份字段(建档要填什么、哪些必填)见各游戏格式页的「身份字段」。
字段
{
"profileId": "8d1f4a0e-6c2b-4a1e-9d3f-2b7c5e9a0f11",
"game": "maimai",
"player": { "name": "Player", "rating": 15234, "native": { "userId": 12345678 } },
"isPublic": true,
"origin": "app",
"createdByClientId": "cli_7Q2…",
"syncCapable": false,
"lastSyncedAt": null,
"createdAt": "2026-10-21T18:40:00Z",
"updatedAt": "2026-10-21T18:40:00Z"
}| 字段 | 含义 | 注意 |
|---|---|---|
player.name | 显示名 | 没有就是 null |
player.rating | 该游戏的"总分" | 各游戏语义不同(见下方边界一节) |
player.native | 原生玩家对象 | 只用于展示/调试,别从这里取归一字段 |
isPublic | 是否允许别人看这张档案 | 不影响你自己读自己的档案 |
origin | user = 用户自己建;app = 第三方应用建的 | —— |
createdByClientId | 建号应用的公开 client id(cli_…) | origin: "user" 时为 null |
syncCapable | 布尔,只读:该档案是否处于自动更新模式 | 别拿它当写权限 |
lastSyncedAt | 时间戳,或 null | 只读 |
syncCapable与origin无关,也不是写权限:它只影响"档案会不会自动更新"。 你建的档案syncCapable为false,更新靠推成绩;取值一律读字段,不要假设。
「未解绑」与「删除申请」是两件事
| 未解绑 | 删除申请 | |
|---|---|---|
| 是什么 | 档案的状态(已从 RankHub 解绑) | 一次待用户确认的动作 |
| 谁触发 | 用户自己删、或用户批准删除申请后由平台执行 | 应用只能申请;批准权在用户 |
| 效果 | 档案从读接口消失,历史数据不丢 | 本身不改数据;批准后才执行解绑 |
读接口对"不存在 / 不是你的 / 已解绑"统一返回 404 common.notFound,刻意不区分 —— 不要用 404 反推档案是被删了还是解绑了。
成绩(score)
一条成绩 = 一张谱面 + 一次(或最佳一次)游玩结果。平台把跨游戏共有的部分提成顶层字段,
游戏特异的部分一律留在 native:
| 字段 | 含义 | 谁有 |
|---|---|---|
scoreId | 成绩行 id | 全部 |
songId | 曲目 id(字符串) | 全部(osu 是谱面 id) |
songName | 曲名 | 大部分 |
type | 谱面类型(standard / dx / utage) | 仅 maimai |
difficulty | 难度标识(字符串,各游戏自己的集合) | arcaea / phigros / rizline / milthm / notanote / musedash / chunithm / orzmic |
levelIndex | 难度下标 | maimai / arcaea / phigros / musedash |
level | 难度标签(13+、IN…) | maimai / musedash |
achievements | 达成率 / 准确率 | 大部分(口径随游戏) |
score | 原始得分 | phigros / arcaea / osu / musedash / rizline / milthm / chunithm / orzmic |
rating | 单曲 rating / potential / rks / pp | 全部(语义各不相同) |
rank | 评级(sss / EX+ / S…) | maimai / arcaea / phigros / osu / chunithm / orzmic |
dxScore / dxStar | maimai 的 DX 分数 / DX 星级 | 仅 maimai |
fc / fs | 全连 / 全同步标记(ap/fc/fs…) | maimai 两者都有;phigros / rizline / musedash 只有 fc 一侧 |
playTime | 游玩时间(RFC3339) | maimai / arcaea / osu;其余 null |
native | 游戏原生成绩对象(未归一) | 全部 |
逐游戏的「
achievements/score/rating各是什么、量纲多少、native里有什么」, 见各游戏格式页(也是GET /v1/games/{game}/format的内容)。 本页只讲概念,不重复游戏细节。
null 表示「这个游戏没有这个概念」
type 只有 maimai 有、playTime 有的游戏没有。请按 null 渲染占位,
不要当 0 或空字符串处理。跨游戏渲染只用归一字段,游戏特异的地方读 native。
最佳成绩桶(bucket)
旧接口按游戏分成 best50 / best30 / b40 / best100 / best……现在一个端点、一个形状:
{
"game": "maimai",
"computedAt": "2026-10-24T02:10:00Z",
"totals": { "total": 15234, "latest": 7234, "archive": 8000 },
"buckets": [
{ "key": "latest", "label": "当期", "total": 7234, "items": [ /* 与 scores 的 items 同形状 */ ] },
{ "key": "archive", "label": "往期", "total": 8000, "items": [ /* … */ ] }
]
}| 字段 | 含义 | 纪律 |
|---|---|---|
totals | 该游戏的"总力"相关数字,至少含 total(没有总分概念的游戏可能没有它) | 键集合随游戏不同,服务端算 |
buckets[].key | 稳定的桶键 | 按 key 渲染,不要按数组下标猜 |
buckets[].label | 展示名(中文,仅建议) | 你可以自己本地化 |
buckets[].total | 该桶合计(桶内各项 rating 之和;无该概念时 null) | 不是桶内 score 之和 |
buckets[].items | 桶内成绩,与 /scores 的 items 同形状 | —— |
computedAt | 本次计算时间 | 曲库更新后历史档案的展示值会变,用它判新鲜度 |
各游戏的桶(由服务端切,客户端不要重算)
桶键、成员规则与 totals 语义逐游戏不同(maimai 按谱面版本切、phigros 有 ϕ 桶、
rizline 在曲库元数据缺失时会退化、osu 的 key 带模式…)。完整表见
各游戏格式页的「最佳成绩桶」,或 GET /v1/games/{game}/format
的 score.buckets;本页只保留下面三条通用纪律。
三条纪律
- 按
key渲染,label只作兜底;顺序也按key,不要按数组位置。 - 未知
key原样展示(不要default: 丢弃):新游戏、新桶、osu的模式桶都会新增 key,不会改变已有 key 的含义。 - 不要自己按分数重算:桶成员与
total都是服务端给的。你算出来的数字一旦与平台不一致,用户会先怀疑你。
跨游戏归一化的边界
这一节是"别误用"的清单。
rating 同名,但语义各游戏独立
| 游戏 | rating 是什么 | 单曲值谁算 | 能跨游戏比吗 |
|---|---|---|---|
maimai | 单曲 DX rating(整数) | 平台(按定数 + 达成率算) | 不支持 |
arcaea | 单曲 potential(小数) | 平台(按定数 + 分数区间算) | 不支持 |
phigros | 单曲 rks(小数) | 你随成绩给 | 不支持 |
milthm / notanote / musedash | 单曲 reality / nrk / performance | 你随成绩给 | 不支持 |
rizline | 单曲 rks(与 phigros 不同源) | 你随成绩给 | 不支持 |
osu | 单曲 pp | 你随成绩给 | 不支持 |
chunithm / orzmic | 该游戏的 rating 原值 | 随原始成绩对象给 | 不支持 |
可以做:同一游戏内的排序与比较、同一游戏内的桶求和。 不要做:跨游戏的 rating 排行榜、跨游戏求和/平均、把不同游戏的 rating 放进同一个量纲。
想表达"玩家水平"跨游戏时,请用游戏内的相对位置(如段位、百分位)并明确标注是哪个游戏。
排序只在服务端
scores 的 sortBy 只有 playTime | achievements | rating 三键(order 为 asc/desc)。
- 三项是"跨游戏同名"的最小交集,不是所有游戏都完整支持;值为
null的条目不参与比较、排在最后; - 不传
sortBy⇒ 用各游戏的原生默认顺序(不要假设它是 rating 降序); - 排序只作用于单张档案的成绩列表 —— 跨游戏混排没有服务端实现,也不要在客户端拼。
difficulty 只是字符串
difficulty 是各游戏原生难度标识的透传(maimai 的 3、arcaea 的 2、phigros 的 IN…),
平台不校验枚举:合法取值集合因游戏而异;传一个该游戏不存在的值通常是空列表,而不是 400。
(个别游戏原生不支持难度过滤,该参数会被忽略。)
分页是不透明游标
列表统一 { items, nextCursor, hasMore }:nextCursor 原样回传,不要解析、不要当 offset;
换 sortBy / order / difficulty 必须从第一页重来。limit 默认 50、上限 100(超限 400)。
参数细节见业务 API 总览 · 分页与批量上限。
没有"跨游戏总榜"
因为没有跨游戏可比的标尺:rating 量纲不同、achievements 口径不同(百分比 / 0~1 / 0~100)、
难度集合不同、曲库完全不同。任何跨游戏排名都必须先由你定义换算规则,并在 UI 上说明。
写入:两个形状,问的是不同问题
POST /v1/games/{game}/profiles | POST .../{profileId}/scores | |
|---|---|---|
| 做什么 | 建一张档案(可带一批成绩) | 往已有档案推成绩 |
| 数据从哪来 | 你直接把玩家的身份与资料填进来 | 你把这一局的成绩填进来 |
| 成功 | 201 + 档案对象 | 200 + { rating, applied, ignored } |
| 幂等 | idempotencyKey(作用域 = 当前用户 + 当前应用),重复提交返回同一张档案 | 靠"只升不降"本身;重放不会改当前成绩 |
| 冲突 | 该游戏身份字段已被别的档案占用 ⇒ 409 | 档案不是本人/不存在 ⇒ 404 |
建号需要的是"身份字段"(同一请求体是超集,字段按游戏生效):具体字段与必填组合见 怎么传数据(速查);字段级定义见 游戏数据格式总览。
maimai/arcaea沿用各自的既有建号语义,字段比其余游戏多:maimai要userId或friendCode,arcaea还需要character(搭档)非空。这两族以错误信息为准。
建档与更新是两条独立的路
"能建号"和"能自动更新"是两件互不相关的事。
你建档(POST /profiles)
├─ 只写身份字段 + name(+ 同请求里可带的 scores)
├─ syncCapable = false
└─ 之后这条档案靠
① 你推成绩(POST .../{profileId}/scores) ← 10 个游戏都能用| 你想做的事 | 该用哪条 | 现在能不能 |
|---|---|---|
| 我有玩家在游戏里的身份,替他建一张档案 | POST /v1/games/{game}/profiles | 支持 10 个游戏 |
| 我算出了这批新成绩,帮他更新 | POST .../{profileId}/scores | 支持 10 个游戏(评分值自己给,见下) |
| 让档案自动保持更新 | 用户自己在 RankHub 里开启 | ⛔ 不是应用能力 |
评分的两个来源
同一批字段名(scores[].rating),在两类游戏里含义完全不同:
maimai / arcaea | 其余 8 个游戏 | |
|---|---|---|
单曲 rating | 平台算(有曲库定数与算分公式),你传了也无效 | 你给:rks / reality / nrk / RL / rks / pp(本地游戏放在 scores[].native 里) |
| 平台做什么 | 算单曲评分 → 重算总分与桶 | 只做"只升不降"合并 + 上界校验;不代算单曲评分 |
| 总分/桶 | 平台算 | 平台用你给的单曲值汇总 |
| 校验 | 谱面级(曲库存在、物量、定数、组合自洽) | 通用级(标识非空、数值合理、时间不在未来、native 是对象) |
三条纪律:
- 别指望平台帮你把评分算对:那 8 个游戏里评分是你给的事实输入,平台只挡畸形值;
- 本地游戏(
chunithm/orzmic)用scores[].native带上原始成绩对象,并用scores[].bucket指定落到哪个桶(缺省best30;chunithm还可selection10/new20,写错桶名 ⇒400); - 本地游戏不改"玩家头部":推成绩只写成绩与桶,
player/summary不在写侧。
写入语义(要点)
- 只升不降:更差的提交不改当前成绩,进
ignored[];但每次提交都会追加成绩历史。 - 异常写入要用户确认:低频异常 ⇒
428 game.writeConfirmationRequired,用户确认后原样重放同一份 payload; 令牌过期 / payload 变了 ⇒409。批量异常会暂停应用写权限(403 game.writeSuspended)。 - 删除只能申请:
DELETE直接删会403 game.deletionConsentRequired;流程、状态与令牌规则见 游戏档案 · 删除档案。
400 / 422 / 428 / 429 的完整语义与重试规则见写入格式。
能力矩阵:以服务端为准
不要把你需要的游戏写死。第一步永远是拉能力矩阵:
curl -sS https://api.rankhub.cc/v1/games -H "Authorization: Bearer $ACCESS_TOKEN"{
"code": "common.ok",
"data": { "games": [
{ "game": "maimai", "read": true, "pushProfile": true, "pushScores": true, "deletionRequest": true },
{ "game": "phigros", "read": true, "pushProfile": true, "pushScores": true, "deletionRequest": true }
] }
}| 列 | 含义 |
|---|---|
read | 可读档案、成绩、最佳成绩 |
pushProfile | 可建号(你填身份字段) |
pushScores | 可逐条推成绩 |
deletionRequest | 可提交删除申请 |
三条纪律:
- 别抄成本地常量:能力会变,路径与形状不变;按
false优雅降级(把入口藏起来); - 每列背后做的事不同:
pushProfile是"填字段建档"(身份字段见上),pushScores是"推成绩" (评分值口径见上);建档与推成绩是两个动作,建档后syncCapable仍是false; - 能力是"按游戏"的,不是"按档案"的:
pushScores: true仍可能404(不是你的档案)或403(停写)。