API 目前处于 Early Preview 阶段, 可能存在诸多不完善与错误之处,欢迎加群交流反馈:加入官方交流群(1073434398)
成绩读取与写入
在 /v1/games/** 上读成绩与统一形状的最佳成绩;10 个游戏都能推成绩(评分值由调用方给),只升不降、单批上限、428 用户确认与 403 停写。
成绩数据分两条路:
- 读:
GET /v1/games/{game}/profiles/{profileId}/scores与GET .../bests(最佳成绩,所有游戏一个形状)。 - 写:10 个游戏都支持批量写入。单曲评分值由你给;
maimai/arcaea例外(服务端自己算分)。
写的是「用户数据」
批量写入代表用户修改其 RankHub 档案。只提交用户在你这一侧真实产生的成绩;
不要伪造、不要批量灌库。所有写入都会进入成绩历史,并可能触发评分重算与通知。
所有游戏都有通用上界校验(越界直接 400/422);maimai / arcaea 另有标定分类,
低频异常可能要求用户确认后重试,批量或连续的异常会暂停该应用的写权限。
读取成绩
所有游戏一条路径,字段跨游戏归一(游戏原生字段在 native):
curl -sS "https://api.rankhub.cc/v1/games/maimai/profiles/$PROFILE_ID/scores?limit=100&sortBy=rating&order=desc" \
-H "Authorization: Bearer $ACCESS_TOKEN"| 查询参数 | 说明 |
|---|---|
limit | 页大小,最大 100(默认 50) |
cursor | 上一页的 nextCursor,对客户端不透明 |
sortBy | playTime / achievements / rating(跨游戏归一的三键) |
order | asc / desc(默认 desc) |
difficulty | 按各游戏原生难度标识过滤(maimai 的 3、arcaea 的 2…) |
不传 sortBy 时按各游戏的默认顺序(与旧接口一致)。响应是
{ items, nextCursor, hasMore }:
{
"code": "common.ok",
"data": {
"items": [
{
"scoreId": "b1f0c2d3-4e5f-4a6b-8c7d-9e0f1a2b3c4d",
"songId": 1234,
"songName": "Example Song",
"type": "dx",
"difficulty": null,
"levelIndex": 3,
"level": "13+",
"achievements": 100.5125,
"score": null,
"rating": 320,
"rank": "sss",
"dxScore": 2500,
"dxStar": 5,
"fc": "ap",
"fs": "fs",
"playTime": "2026-10-20T12:00:00Z",
"native": { "dx_rating": 320 }
}
],
"nextCursor": null,
"hasMore": false
}
}| 字段 | 说明 |
|---|---|
songId | 曲目 ID(maimai 是数字串;osu 是 beatmap id) |
type / difficulty / levelIndex / level | 谱面标识;difficulty 的取值集合随游戏(osu 是模式名) |
achievements / score | 达成率/准确率 与 原始得分,按游戏填其中一个(另一个 null,量纲随游戏) |
rating | 单曲 rating / potential / rks / pp(服务端算或调用方给,见下) |
native | 游戏原生成绩对象(未归一,仅供展示/调试) |
完整字段表(含 maimai 的
dxScore/dxStar/fc/fs、rank、playTime)见 游戏数据格式总览;每个游戏score.native里有什么见各游戏格式页。
null 表示「这个游戏没有这个概念」
type 只有 maimai 有、difficulty 只有 arcaea 等游戏有。请按 null 渲染占位,
不要当 0 或空字符串处理。跨游戏渲染请只用归一字段,游戏特异的地方读 native。
最佳成绩:一个统一形状
旧接口按游戏分成 best50 / best30 / b40 / best100 / best,
现在是同一个端点、同一个形状:
curl -sS https://api.rankhub.cc/v1/games/maimai/profiles/$PROFILE_ID/bests \
-H "Authorization: Bearer $ACCESS_TOKEN"{
"code": "common.ok",
"data": {
"game": "maimai",
"computedAt": "2026-10-21T18:40:00Z",
"totals": { "total": 15234, "latest": 7234, "archive": 8000 },
"buckets": [
{ "key": "latest", "label": "当期", "total": 7234, "items": [ /* 与 scores 同形状 */ ] },
{ "key": "archive", "label": "往期", "total": 8000, "items": [] }
]
}
}| 字段 | 说明 |
|---|---|
game | 游戏键 |
computedAt | 本次计算时间。曲库更新后历史档案的展示值会变,用它判断新鲜度 |
totals | 至少含 total(该游戏的"总力");各游戏可另加键(maimai 的 latest / archive) |
buckets[].key | 稳定的桶键,客户端据此选标签 / 顺序,不要按数组位置猜 |
buckets[].label | 展示名(zh-CN,仅建议;你可以自行本地化) |
buckets[].total | 该桶合计;该游戏无此概念时为 null |
buckets[].items | 桶内成绩,形状与 scores 的 items 完全一致 |
不要自己算分
分桶口径与算法在服务端(maimai 是「当期 15 / 往期 35」,按谱面所属版本分桶,
不是按 standard / dx 谱面类型)。客户端自己算出来的数字一旦与平台不一致,用户会先怀疑你。
桶键是稳定的,桶的数量与含义随游戏不同 —— 渲染前先看 key,不要断言"一定是两个桶"。
每个游戏的桶键、成员规则与 totals 语义,见游戏格式各页的「最佳成绩桶」,
或 GET /v1/games/{game}/format 的 score.buckets。
推成绩(批量写入)
10 个游戏都可用(能力矩阵 pushScores 全为真)。一条路径、一套形状;
差异只在"评分值从哪来"——见下面的评分口径。
要抄请求体?看写入格式页
本页讲接口语义(只升不降、428 确认、停写、限流)。player 超集、scores[] 共享字段、
每个游戏必须给哪几个字段与可直接抄的 JSON,见写入格式(建档 / 推成绩);
单游戏细节见各游戏格式页的「写入(第三方)」。
curl -sS -X POST https://api.rankhub.cc/v1/games/maimai/profiles/$PROFILE_ID/scores \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"scores": [
{
"songId": 1234,
"type": "dx",
"levelIndex": 3,
"achievements": 100.5125,
"dxScore": 2500,
"dxStar": 5,
"fc": "ap",
"fs": "fs",
"playTime": "2026-10-20T12:00:00Z"
}
]
}'# phigros:单曲评分值(rks)由你给 —— 服务端不代算
curl -sS -X POST https://api.rankhub.cc/v1/games/phigros/profiles/$PROFILE_ID/scores \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"scores": [
{ "songId": "…", "difficulty": "IN", "score": 1000000, "accuracy": 100,
"rating": 15.2, "playTime": "2026-10-20T12:00:00Z" }
]
}'# 本地游戏(chunithm / orzmic):native 原样带上,bucket 指定落到哪个桶
curl -sS -X POST https://api.rankhub.cc/v1/games/chunithm/profiles/$PROFILE_ID/scores \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"scores": [
{ "songId": "…", "bucket": "selection10", "native": { "…": "上游算好的成绩对象" },
"playTime": "2026-10-20T12:00:00Z" }
]
}'单曲评分值:scores[].rating
评分值由调用方给:你在自己一侧已经算出/拿到了玩家这一局的单曲评分,就把它放进
scores[].rating。服务端不代算,只做上界校验与"只升不降"合并。
每个游戏这个字段叫什么(rks / reality / nrk / RL / pp),见 游戏数据格式总览与各游戏格式页的「评分语义」一节。
maimai / arcaea 例外:这两族服务端自己算分,传 rating 也无效,行为与旧接口一致。
本地游戏(chunithm / orzmic)不一样:成绩是结构不透明的原始 JSON ——
用 scores[].native 原样带上,bucket 指定落到哪个最佳成绩桶(缺省 best30,
chunithm 还可以 selection10 / new20、orzmic 只有 best30)。
本地游戏的玩家头部不会被改写
chunithm / orzmic 的 player / summary(玩家头部)不会被你推的成绩改写;
推成绩只影响成绩行与对应桶。
字段是超集(按游戏生效,不适用的被忽略):
- 谱面身份:maimai 是
(songId, type, levelIndex);arcaea 是(songId, difficulty); 其余游戏用songId(部分游戏可另给chartId/uid/trackId/chartKey覆盖默认拼法); - 成绩内容:
achievements/score/accuracy按游戏填;scores[].rating见上; 各游戏还有自己的可选字段(maimai 的dxScore/dxStar/fc/fs、 arcaea 的perfectCount/nearCount等、milthm的realityFormula、osu的mode/position、rizline的maxHit……); playTime:RFC3339;缺省用服务端当前时间(沿用旧语义);idempotencyKey:可选。不传也没关系 —— "只升不降"本身已经让重放无害。
成功是 200:
{
"code": "common.ok",
"data": {
"rating": {
"before": { "total": 15234 },
"after": { "total": 15250 },
"delta": 16
},
"applied": [ { "songId": 1234, "improved": true, "currentAchievements": 100.5125 } ],
"ignored": []
}
}applied[]:这一批里的每一条都有一条结果;improved: false的条目同时出现在ignored[]里 (reason是稳定原因,如notImproved= 只升不降拦下、unknownChart= 谱面不存在), 方便你把它渲染成"被忽略"的那一支;rating:评分变化,delta是after.total - before.total(不是单曲成绩)。 只升不降通常让它 ≥ 0,但曲库定数调整后重算也可能让它变小。
写入语义
| 规则 | 说明 |
|---|---|
| 谱面不存在就插入 | 新谱面直接落库 |
| 只升不降 | 更差的提交不改当前成绩(进 ignored);当前成绩保持旧值 |
| 每次提交写历史 | 无论是否更好都会追加一条成绩历史(时间取 playTime,缺省服务端时间) |
| 评分重算 | 按全量成绩重算 best 桶与总分(maimai / arcaea 自己算分,其余游戏以你给的 rating 为准);有变化才追加一条评分历史 |
| 幂等 | 同批重放不改变当前成绩(只升不降 + 按谱面 upsert),网络超时可直接重试;但每次提交都会再写一条历史,业务层重放请自行判重 |
| 硬上界 | 谱面必须存在、取值必须在游戏规则范围内、playTime 不能是未来 —— 越界直接 400 common.invalidArgument / 422 common.unprocessable,不进统计 |
| 单批条数 | 默认上限 500(服务端可调),超限 400 common.invalidArgument |
| 请求体 | ≤ 1 MiB,超出 413 |
| 档案不存在 / 不是本人的 | 404 common.notFound |
| 账号在注销流程中 | 403 |
可能要用户确认:428 与确认重试
平台的写入守卫会对可疑写入做校验。低频、单次的越界不会直接拒绝,而是回
428 game.writeConfirmationRequired,让你请用户在 RankHub 里确认这次写入。
428 今天只出现在 maimai / arcaea
只有 maimai / arcaea 有标定分类(它们本来就是服务端算分)。其余 8 个游戏只有
通用上界校验:越界直接 400 common.invalidArgument / 422 common.unprocessable,
不会返回 428,也不会因为这个触发停写。
{
"code": "game.writeConfirmationRequired",
"message": "该写入需要用户确认后重试",
"description": null,
"data": {
"confirmation": {
"confirmationToken": "wc_7Q2…",
"approveUrl": "rankhub://write-confirmation?token=wc_…",
"expiresAt": "2026-10-22T19:10:00Z"
}
},
"requestId": "req_…"
}428 是唯一 data 非空的失败响应 —— 确认令牌必须机读,这是刻意的例外。
用户确认后,用同一份 payload 加上 confirmationToken 原样重放:
{
"scores": [ /* 与上次完全一致 */ ],
"confirmationToken": "wc_7Q2…"
}- 令牌一次性,绑定「应用 + 档案 + payload 摘要」,默认 30 分钟内有效;
- payload 变了就得重新发起(改一个字段也算变)——你应当重放的是同一批数据, 不是"带上令牌把新数据也一起写";
- 令牌过期 / 已用 / payload 不匹配 ⇒
409 common.conflict,需要重新提交(会再走一次判定)。
不要把 428 当 500 或 403 处理
428 不是失败,是"差一个用户动作"。正确做法:把 approveUrl 展示/推送给用户,
等用户点完再重放;不要自动重试同一个请求(会拿到 409),也不要把它当永久拒绝。
写入被暂停:403 game.writeSuspended
如果写入批量、连续或明显异常,平台会暂停该应用的写权限:
- 返回
403 game.writeSuspended("该应用的写权限已被暂停"); - 只拦写:读接口、登录、令牌刷新都不受影响;
- 暂停是应用级的:你的所有用户、所有游戏的写都会一起停(触发只可能来自
maimai/arcaea的标定分类,但一旦停了,写 10 个游戏都会被拦); - 恢复只能联系平台处理,不要靠换令牌 / 换 key / 重试绕过。
开发者平台的应用详情里能看到停写状态与原因(见注册应用与凭据)。 遇到它请停下来看数据来源,而不是加大重试。
限流
按应用限流,命中返回 429 + common.rateLimited + Retry-After(秒),请按该头退避;
单批条数超限是 400。全局限流口径见业务 API 总览。