概念:档案、成绩与最佳成绩桶
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.source 里,两套东西没有换算关系:
| 游戏 | 原生标识(player.source) |
|---|---|
maimai | userId(机台 id)、friendCode(15 位好友码) |
arcaea | arcaeaId(9 位)、friendCode |
phigros | objectId、shortId |
milthm | userId、region |
rizline | playerId |
musedash、notanote | uid |
osu | osuUserId |
chunithm、orzmic | externalId(外加整份玩家快照) |
一个用户在同一游戏上可能有多张档案(换过账号、或用不同标识各建过一张),所以列表接口才是列表。
字段
{
"profileId": "8d1f4a0e-6c2b-4a1e-9d3f-2b7c5e9a0f11",
"game": "maimai",
"player": { "name": "Player", "rating": 15234, "source": { "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.source | 原生玩家对象 | 只用于展示/调试,别从这里取归一字段 |
isPublic | 是否允许别人看这张档案 | 不影响你自己读自己的档案 |
origin | user = 用户自己建;app = 第三方应用建的 | —— |
createdByClientId | 建号应用的公开 client id(cli_…) | origin: "user" 时为 null |
syncCapable | 该档案当前是否具备"从游戏平台自动同步"的能力 | 见下方 |
lastSyncedAt | 上次从上游同步的时间 | 有的游戏没有这个概念,恒 null |
syncCapable 与 origin 是两件事
syncCapable 的判据是用户是否在 RankHub 里完成了这个游戏的账号/凭据绑定 ——
不是"这张档案是谁建的":
- 你的应用建的档案只落库、不带凭据 ⇒ 建号时
syncCapable: false(用户还没绑定); - 之后这条档案只能靠你的应用推成绩来更新(推成绩), 平台不会替你去游戏平台抓数据;
- 用户之后在 RankHub App 里完成绑定 ⇒ 才变成
syncCapable: true,那时由 RankHub 同步; - 有的游戏没有"可长期持有的凭据"(导入是一次性动作),这类档案的
syncCapable恒为false—— 那只是"没有自动同步这条路",不代表档案不能更新。
所以:origin: "app" 不蕴含 syncCapable: false;也不要拿 syncCapable 当"能不能写"的判据 ——
syncCapable: false 的档案恰好就是"只能靠推成绩更新"的那一类。
「未解绑」与「删除申请」是两件事
| 未解绑 | 删除申请 | |
|---|---|---|
| 是什么 | 档案的状态(已从 RankHub 解绑) | 一次待用户确认的动作 |
| 谁触发 | 用户自己删、或用户批准删除申请后由平台执行 | 应用只能申请;批准权在用户 |
| 效果 | 档案从读接口消失,历史数据不丢 | 本身不改数据;批准后才执行解绑 |
读接口对"不存在 / 不是你的 / 已解绑"统一返回 404 common.notFound,刻意不区分 —— 不要用 404 反推档案是被删了还是解绑了。
成绩(score)
一条成绩 = 一张谱面 + 一次(或最佳一次)游玩结果。平台把跨游戏共有的部分提成顶层字段,
游戏特异的部分一律留在 source:
| 字段 | 含义 | 谁有 |
|---|---|---|
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 |
source | 游戏原生成绩对象(未归一) | 全部 |
几个对照(避免"按字段名猜"):
| 游戏 | achievements | score | rating | 备注 |
|---|---|---|---|---|
maimai | 达成率 | null | 单曲 DX rating | 谱面身份是 (songId, type, levelIndex) |
arcaea | null | 原始分 | 单曲 potential(小数) | 谱面身份是 (songId, difficulty);near/miss 在 source |
phigros | acc | 原始分 | 单曲 rks | rank 是 ϕ / V / S 这类评级 |
osu | accuracy | total_score | 单曲 pp | songId 是谱面 id,difficulty 是模式 |
rizline / milthm / notanote / musedash | 各自口径 | 原始分 | 单曲 rks / reality / nrk / performance | 详细字段在 source |
null 表示「这个游戏没有这个概念」
type 只有 maimai 有、playTime 有的游戏没有。请按 null 渲染占位,
不要当 0 或空字符串处理。跨游戏渲染只用归一字段,游戏特异的地方读 source。
最佳成绩桶(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 | 本次计算时间 | 曲库更新后历史档案的展示值会变,用它判新鲜度 |
各游戏的桶(由服务端切,客户端不要重算)
| 游戏 | 桶 key | 桶内成员怎么来的 |
|---|---|---|
maimai | latest / archive | 按谱面所属版本切:当期版本的前 15 + 往期版本的前 35。不是"分数最高的 15/35" |
arcaea | best50 | 按单曲 potential 降序前 50 |
phigros | phi + best30 | ϕ(满分评级)最多 3 条 + 其余按 rks 前 27 条(27+3=30) |
milthm | best20 | 前 20 |
notanote | best31 | 前 31 |
musedash | best30 | 前 30 |
rizline | ah5 + best35,或 top40 | 正常情况下 AH(全命中)优先的最多 5 首 + 其余前 35;曲库元数据不完整时退化成单一 Top 40 —— 同一游戏不同档案的桶数可能不同 |
osu | best100-<mode> | 该档案主模式(osu/taiko/fruits/mania)的 Best 100,模式在 key 里 |
chunithm | best30 / selection10 / new20 | 由该游戏的成绩快照分组给出 |
orzmic | best30 | 同上 |
三条纪律
- 按
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 };首页不传cursor,之后把nextCursor原样传回; - 游标对客户端不透明:不要解析它、不要自己拼、不要把它当 offset 算"第 N 页";
- 换
sortBy/order/difficulty必须从第一页重来(游标属于某一个具体排序结果); - 非法游标返回
400 common.invalidArgument(不会静默回退到第一页)。
limit
limit 默认 50、上限 100。超过上限是 400,不是自动截断。
没有"跨游戏总榜"
因为没有跨游戏可比的标尺:rating 量纲不同、achievements 口径不同(百分比 / 01 / 0100)、
难度集合不同、曲库完全不同。任何跨游戏排名都必须先由你定义换算规则,并在 UI 上说明。
写入:两个形状,问的是不同问题
POST /v1/games/{game}/profiles | POST .../{profileId}/scores | |
|---|---|---|
| 做什么 | 建一张档案(可带一批成绩) | 往已有档案推成绩 |
| 数据从哪来 | 你直接把玩家的身份与资料填进来 | 你把这一局的成绩填进来 |
| 成功 | 201 + 档案对象 | 200 + { rating, applied, ignored } |
| 幂等 | idempotencyKey(作用域 = 当前用户 + 当前应用),重复提交返回同一张档案 | 靠"只升不降"本身;重放不会改当前成绩 |
| 冲突 | 该游戏身份字段已被别的档案占用 ⇒ 409 | 档案不是本人/不存在 ⇒ 404 |
建号需要的是"身份字段",不是凭据(同一个请求体是"超集",字段按游戏生效;name 必填):
| 游戏 | 身份字段(必填其一) |
|---|---|
maimai | friendCode 或 userId(机台侧 id) |
arcaea | arcaeaId |
chunithm | friendCode(街机侧身份就是好友码,不是别的 id) |
orzmic | externalId |
phigros | objectId |
milthm | milthmUserId + region |
notanote | notanoteUid |
musedash | musedashUid |
rizline | playerId |
osu | osuUserId |
缺身份字段 ⇒ 400。具体字段名与错误文案以各游戏小节 / 错误信息为准 —— 不要凭本文猜必填项,
先看 GET /v1/games 的 pushProfile。
maimai/arcaea沿用各自的既有建号语义,字段比其余游戏多:maimai要userId或friendCode,arcaea还需要character(搭档)非空。这两族以错误信息为准。
建档与更新是两条独立的路
这是本页最容易被读错的一条:"能建号"和"能同步"是两件互不相关的事。
你建档(POST /profiles)
├─ 只落库:身份字段 + name(+ 同请求里可带的 scores)
├─ 不拉上游、不存凭据 ⇒ syncCapable = false
└─ 之后这条档案靠
① 你推成绩(POST .../{profileId}/scores) ← 10 个游戏都能用
② 用户在 RankHub App 里绑定账号 ← 绑定后才由 RankHub 同步| 你想做的事 | 该用哪条 | 现在能不能 |
|---|---|---|
| 我有玩家在游戏里的身份,替他建一张档案 | POST /v1/games/{game}/profiles | ✅ 10 个游戏 |
| 我算出了这批新成绩,帮他更新 | POST .../{profileId}/scores | ✅ 10 个游戏(评分值自己给,见下) |
| 让 RankHub 直接去游戏平台把这个玩家的数据抓一遍 | 第一方的票据式建档 / 导入 / 同步 | ⛔ 403 game.firstPartyOnly |
| 让档案自动保持同步 | 用户在 RankHub App 里绑定账号 | ⛔ 不是应用能力 |
评分的两个来源
同一批字段名(scores[].rating),在两类游戏里含义完全不同:
maimai / arcaea | 其余 8 个游戏 | |
|---|---|---|
单曲 rating | 平台算(有曲库定数与算分公式),你传了也无效 | 你给:rks / reality / nrk / RL / rks / pp(本地游戏放在 scores[].raw 里) |
| 平台做什么 | 算单曲评分 → 重算总分与桶 | 只做"只升不降"合并 + 上界校验;不代算单曲评分 |
| 总分/桶 | 平台算 | 平台用你给的单曲值汇总 |
| 校验 | 谱面级(曲库存在、物量、定数、组合自洽) | 通用级(标识非空、数值合理、时间不在未来、raw 是对象) |
三条纪律:
- 别指望平台帮你把评分算对:那 8 个游戏里评分是你给的事实输入,平台只挡畸形值;
- 本地游戏(
chunithm/orzmic)用scores[].raw带上原始成绩对象,并用scores[].bucket指定落到哪个桶(缺省best30;chunithm还可selection10/new20,写错桶名 ⇒400); - 本地游戏不改"玩家头部":
player/summary属于"绑定后同步一次"的数据,第三方推成绩只补成绩与桶。
写入语义
| 规则 | 说明 |
|---|---|
| 谱面不存在就插入 | 新谱面直接落库 |
| 只升不降 | 更差的提交不改当前成绩;该条进 ignored[],同时在 applied[] 里留一条 improved: false |
| 每次提交写历史 | 无论是否更好都会追加一条成绩历史 |
| 评分重算 | 重算该游戏的总分与桶;有变化才追加评分历史 |
| 硬上界 | 谱面必须存在、取值合理、时间不能是未来 ⇒ 越界 400 / 422,不进统计 |
| 需要用户确认 | 低频异常写入 ⇒ 428 game.writeConfirmationRequired,带 data.confirmation;用户确认后原样重放同一份 payload + confirmationToken |
| 确认令牌失效 | 过期 / 已用 / payload 变了 ⇒ 409 common.conflict,需重新发起 |
| 写权限被暂停 | 403 game.writeSuspended(只拦写,读与登录不受影响) |
| 频率过高 | 429 common.rateLimited + Retry-After |
删除:你只能申请
| 步骤 | 谁做 | 结果 |
|---|---|---|
| 提交申请 | 你的应用 | 202 + { requestId, status: "pending", approveUrl, expiresAt } |
| 查询状态 | 你的应用(只看得到自己发的) | pending / approved / denied / expired |
| 决定 | 用户在 RankHub 里 | 批准 ⇒ 执行解绑;拒绝或超时 ⇒ 不删 |
直接 DELETE | 你的应用 | 403 game.deletionConsentRequired |
| 代替用户决定 | 你的应用 | 403 game.firstPartyOnly |
同一张档案、同一个应用已有待处理申请时,再次申请返回同一条(requestId 不变),
但会轮换确认链接里的令牌(旧链接失效)—— 幂等的是申请,不是令牌。
能力矩阵:以服务端为准
不要把你需要的游戏写死。第一步永远是拉能力矩阵:
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(停写)。