RankHub

概念:档案、成绩与最佳成绩桶

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)
maimaiuserId(机台 id)、friendCode(15 位好友码)
arcaeaarcaeaId(9 位)、friendCode
phigrosobjectId、shortId
milthmuserId、region
rizlineplayerId
musedash、notanoteuid
osuosuUserId
chunithm、orzmicexternalId(外加整份玩家快照)

一个用户在同一游戏上可能有多张档案(换过账号、或用不同标识各建过一张),所以列表接口才是列表。

字段

{
  "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是否允许别人看这张档案不影响你自己读自己的档案
originuser = 用户自己建;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 / dxStarmaimai 的 DX 分数 / DX 星级仅 maimai
fc / fs全连 / 全同步标记(ap/fc/fs…)maimai 两者都有;phigros / rizline / musedash 只有 fc 一侧
playTime游玩时间(RFC3339)maimai / arcaea / osu;其余 null
source游戏原生成绩对象(未归一)全部

几个对照(避免"按字段名猜"):

游戏achievementsscorerating备注
maimai达成率null单曲 DX rating谱面身份是 (songId, type, levelIndex)
arcaeanull原始分单曲 potential(小数)谱面身份是 (songId, difficulty);near/miss 在 source
phigrosacc原始分单曲 rksrank 是 ϕ / V / S 这类评级
osuaccuracytotal_score单曲 ppsongId 是谱面 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桶内成员怎么来的
maimailatest / archive按谱面所属版本切:当期版本的前 15 + 往期版本的前 35。不是"分数最高的 15/35"
arcaeabest50按单曲 potential 降序前 50
phigrosphi + best30ϕ(满分评级)最多 3 条 + 其余按 rks 前 27 条(27+3=30)
milthmbest20前 20
notanotebest31前 31
musedashbest30前 30
rizlineah5 + best35,或 top40正常情况下 AH(全命中)优先的最多 5 首 + 其余前 35;曲库元数据不完整时退化成单一 Top 40 —— 同一游戏不同档案的桶数可能不同
osubest100-<mode>该档案主模式(osu/taiko/fruits/mania)的 Best 100,模式在 key 里
chunithmbest30 / selection10 / new20由该游戏的成绩快照分组给出
orzmicbest30同上

三条纪律

  1. 按 key 渲染,label 只作兜底;顺序也按 key,不要按数组位置。
  2. 未知 key 原样展示(不要 default: 丢弃):新游戏、新桶、osu 的模式桶都会新增 key,不会改变已有 key 的含义。
  3. 不要自己按分数重算:桶成员与 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}/profilesPOST .../{profileId}/scores
做什么建一张档案(可带一批成绩)往已有档案推成绩
数据从哪来你直接把玩家的身份与资料填进来你把这一局的成绩填进来
成功201 + 档案对象200 + { rating, applied, ignored }
幂等idempotencyKey(作用域 = 当前用户 + 当前应用),重复提交返回同一张档案靠"只升不降"本身;重放不会改当前成绩
冲突该游戏身份字段已被别的档案占用 ⇒ 409档案不是本人/不存在 ⇒ 404

建号需要的是"身份字段",不是凭据(同一个请求体是"超集",字段按游戏生效;name 必填):

游戏身份字段(必填其一)
maimaifriendCode 或 userId(机台侧 id)
arcaeaarcaeaId
chunithmfriendCode(街机侧身份就是好友码,不是别的 id)
orzmicexternalId
phigrosobjectId
milthmmilthmUserId + region
notanotenotanoteUid
musedashmusedashUid
rizlineplayerId
osuosuUserId

缺身份字段 ⇒ 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 是对象)

三条纪律:

  1. 别指望平台帮你把评分算对:那 8 个游戏里评分是你给的事实输入,平台只挡畸形值;
  2. 本地游戏(chunithm / orzmic)用 scores[].raw 带上原始成绩对象,并用 scores[].bucket 指定落到哪个桶(缺省 best30;chunithm 还可 selection10 / new20,写错桶名 ⇒ 400);
  3. 本地游戏不改"玩家头部":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可提交删除申请

三条纪律:

  1. 别抄成本地常量:能力会变,路径与形状不变;按 false 优雅降级(把入口藏起来);
  2. 每列背后做的事不同:pushProfile 是"填字段建档"(身份字段见上),pushScores 是"推成绩" (评分值口径见上);建号 ≠ 同步,建档后 syncCapable 仍是 false;
  3. 能力是"按游戏"的,不是"按档案"的:pushScores: true 仍可能 404(不是你的档案)或 403(停写)。

下一步

本页内容