原生桌面应用接入
分发出去的桌面客户端没有机密可言。应用密钥躺在用户机器上的可执行文件里,strings 一遍就出来,抓一次 HTTPS 也出来——而泄漏的是你的密钥:配额、限流、封禁都算在你的应用头上,吊销一次所有用户一起断。
所以 /v2/catalog 只读面同时接受两种凭据(二选一),也可以完全不带:
| 凭据 | 代表谁 | 按什么计配额 | 适合谁 |
|---|---|---|---|
应用密钥 nmk_live_… |
你的应用 | 按密钥(tier 决定速率与日配额) | 服务端、你自己控制的后端 |
| 用户访问令牌 | 授权给你的那个用户 | 按用户,跨该用户授权过的所有应用共池 | 分发出去的原生客户端 |
| 不带凭据 | 任何人 | 按每台机器自己的 IPv4 地址或 IPv6 /64(默认 30 次/分钟、1,000 次/天) | 只读目录、用量小、不想让用户先登录的客户端 |
一条请求只带一个凭据。服务端按
Authorization里那一个值的前缀分道:nmk_是应用密钥,其余按用户令牌解析;一种失败了不会再当另一种试一次。
GET /v2/catalog/claim-events 与整个 /v2/store 是例外,仍然只收应用密钥。
不登录:匿名额度怎么花
本地游戏管理器只想给用户的游戏库补元数据时,可以完全不带凭据。额度按每台机器自己的公网 IP 计(IPv6 按 /64),默认每分钟 30 次、每天 1,000 次,用户之间互不影响。
补元数据只用 /v2/catalog/works。 /v2/catalog/search 是跨实体搜索(作品、角色、厂商、标签……一起搜),命中行只有名字、外部来源和分级,拿不到封面和开发商。works?q= 查的是同一个搜索索引,但每条结果就是完整的作品对象,能用 include= 带上下表里的任何一块。
要什么,带什么。 下表对 works 列表、works?q= 搜索和 ids= / refs= 批量全部适用:
| 想要 | 字段 | 参数 |
|---|---|---|
| 封面、发售日(最早一次发行)、分级、原语言 | cover、release_date、content_rating、olang |
默认就有 |
| 各语言的正式译名 | latin、localized |
include=titles |
| 简介 | intros |
include=intros |
| 开发商 / 发行商 | companies[],按 attribution_role 分(developer、publisher……) |
include=companies |
| 标签 | tags |
include=tags |
| 评分,含 NextMoe 用户评分 | ratings[],NextMoe 的是 source: "nextmoe" |
include=ratings |
| 其他站点的 id(VNDB、Bangumi、Steam、DLsite、ErogameScape……) | refs[] |
include=refs |
| 平均游玩时长 | playtimes[],单位分钟,按来源分开给,不要跨源平均 |
include=playtimes |
| 全部别名、各平台的发行记录 | titles[]、releases[] |
只在 GET /v2/catalog/works/{id}?include=titles,releases |
view=full 等于 include=titles,refs,intros,covers,companies,ratings,tags;时长要另外点名,所以一份「全都要」的请求是 view=full&include=playtimes。
按下面的方式调用,每款游戏最多花 1 次,几百款的库在匿名额度里绰绰有余:
- 手上有外部 id 就批量反查。 DLsite 的 RJ 号、Steam appid、VNDB / Bangumi / ErogameScape 的 id 都能直接查:
GET /v2/catalog/works?refs=dlsite:RJ007987,steam:4484600,vndb:v17&nsfw=true&view=full&include=playtimes,一次最多 100 个,没命中的原样回在missing[]里。1,000 款游戏的库只要 10 次调用。- 带上
nsfw=true。 不带时 R18 作品一律当作不存在,会和真没收录的一起落进missing[];游戏库里 R18 作品往往占多数。要不要给用户显示 R18 内容,由你在本地决定,不要靠漏查来过滤。 - 用
refs[]对回你的游戏。 结果按作品 id 排序,不按你请求的顺序,也不回显是哪个 id 命中的。view=full已经含refs;只点名include=时记得带上refs。
- 带上
- 只有标题时才逐条搜。
GET /v2/catalog/works?q=<标题>&limit=5&nsfw=true&include=companies每款 1 次,候选上的封面、发售日、开发商足够让用户挑。首次匹配几百款要分几分钟跑完:读响应头RateLimit里的remaining与reset,用完了就等reset秒,不要撞上 429 再重试。 - 选定后批量取详情。 把匹配到的作品 id 攒起来,
GET /v2/catalog/works?ids=…&nsfw=true&view=full&include=playtimes,一次 100 部。不要对每款再搜一次或再读一次详情。 - 别名和发行记录等用户点开时再取。
GET /v2/catalog/works/{id}?nsfw=true&include=titles,releases是逐部读,放在详情页打开时,不要放进导入流程。截图、角色、制作人员同理,各有/v2/catalog/works/{id}/…子资源。 - 刷新不要全量重扫。 定期用第 3 步的
ids=请求把库里的作品重读一遍,500 款的库每次 5 次调用。 - 设一个认得出的 User-Agent,例如
MyGalManager/1.4 (+https://github.com/you/repo),出问题时我们能找到你。
库很大(上千款,而且首次匹配全靠标题)时,一天的匿名额度不够。要么把首次匹配分几天做完,要么让用户登录、改用下面的用户访问令牌:每人每分钟 100 次、每天 10,000 次。
1 · 注册应用
在控制台建应用后,进入应用详情页的「用户登录」卡片开启并配置(user_login)——回调地址与 scope 随时可改,全程自助,无需联系平台管理员。落下来的配置形如下面这份,三件事随之定死:
{
"name": "Kurumi",
"user_login": {
"redirect_uris": ["http://127.0.0.1/callback"],
"scopes": ["openid", "profile", "catalog:read"]
}
}
- 强制 PKCE。开了用户登录的应用一律是 public client,OAuth 服务在缺
code_challenge时拒签授权码。你不需要、也不应该在客户端里放client_secret。 - 回调只收环回。
http://127.0.0.1/callback与http://[::1]/callback是仅有的明文形状。localhost按名拒——它过主机名解析,可以被指向别处,127.0.0.1不能。自定义 scheme(myapp://callback)不支持,注册时就被拒。 - 端口无关匹配(RFC 8252 §7.3)。注册时写不写端口都行,服务端比对环回回调时忽略端口,scheme / host / path / query 仍精确匹配。运行时监听哪个临时端口由你决定,不必回控制台改注册。
2026-09 有下游读到这一节后在控制台里找不到对应的表单,据此判定回调地址只能由平台管理员代注册——那时门户确实没有这张表单,「用户登录」卡片就是为补上这个缺口而加的。
移动端(Android / iOS)走同样的两条路,不需要自定义 scheme。 环回监听在手机上同样成立——绑
127.0.0.1:0,端口无关匹配的规则与桌面完全一致。自定义 scheme 不开是刻意的:scheme 在移动系统上不可认领,任何应用都能抢注同一个 scheme 拦走授权码,而 PKCE 防不住由拦截方自己发起的流程。有自己域名的应用推荐第二条路:注册https://回调(本来就收),在系统侧配成 App Links / Universal Links(RFC 8252 §7.2)——域名归属由操作系统验证,授权完成后直接跳回应用,安全性与体验都优于环回。
2 · 起监听、开浏览器
先绑 127.0.0.1:0 让内核分配临时端口,拿到端口再拼 redirect_uri;code_verifier 取 43–128 字符的高熵随机串,code_challenge = BASE64URL(SHA256(verifier));state 另取一个,回调里逐字比对。
GET https://account.nextmoe.com/api/v1/oauth/authorize
?client_id=<your-client-id>
&redirect_uri=http%3A%2F%2F127.0.0.1%3A53682%2Fcallback
&response_type=code
&scope=openid%20profile%20catalog%3Aread
&state=<random>
&code_challenge=<S256>
&code_challenge_method=S256
该端点 302 到登录/同意页,用户同意后浏览器跳回你的环回地址,带 code 与 state。
用系统浏览器,绝不用内嵌 WebView(RFC 8252 §8.12)。内嵌视图里应用能读到用户输入的口令和 OP 的 cookie,用户也无从判断自己是不是在真的 OP 上——同意页上那个「第三方应用」标记会因此失去全部意义。Tauri 用 opener / shell 插件,Wails 用
runtime.BrowserOpenURL。
3 · 换码
POST 到 https://account.nextmoe.com/api/v1/oauth/token,application/x-www-form-urlencoded 或 JSON 皆可,不带 client_secret,带 code_verifier:
POST /api/v1/oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=<授权码>
&redirect_uri=<与第 2 步逐字节相同>
&client_id=<your-client-id>
&code_verifier=<第 2 步的 verifier>
响应是裸 RFC 6749(没有 {code,message,data} 外壳):access_token(JWT,15 分钟)、refresh_token(不透明串)、expires_in、scope。失败是 {"error": "...", "error_description": "..."}。
把令牌交给操作系统钥匙串,不要写明文文件或应用配置目录。Tauri 用 keyring / stronghold 插件,Wails(Go)用 github.com/zalando/go-keyring。access token 短命,留在内存里就行;refresh token 必须落钥匙串。
4 · 刷新与调用
刷新只走同一个端点:grant_type=refresh_token + client_id,同样不带 secret。每次刷新都会轮换——旧的立即失效,拿到新的必须原地覆盖钥匙串里那一条。
第一方
/api/v1/auth/refresh会拒绝 client-bound 的 OAuth session。已经有集成方在这里撞过:它不是一条可替代的路径。
GET https://api.nextmoe.dev/v2/catalog/works?limit=20
Authorization: Bearer <access token>
令牌必须持有 catalog:read,否则是 403 SCOPE_REQUIRED。2026-09-06 之前签发的令牌不带这个 scope,也不做追认——让用户重新授权一次即可。
配额按用户算:同一个人授权了三个管理器,三个共用一个桶(默认 100 次/分钟、10000 次/UTC 日)。这是有意的——否则「多注册几个应用」就是一条绕开配额的路。要更高吞吐就该用应用密钥,而那意味着你需要一个自己的服务端。
5 · 代码骨架
两份都省去了错误处理与日志,只留形状。
Tauri(Rust):
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use serde::Deserialize;
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use tiny_http::Server;
const OAUTH: &str = "https://account.nextmoe.com/api/v1";
const CLIENT_ID: &str = "your-client-id";
#[derive(Deserialize)]
struct TokenResponse {
access_token: String,
refresh_token: String,
}
#[tauri::command]
async fn sign_in(app: tauri::AppHandle) -> Result<String, String> {
let verifier = URL_SAFE_NO_PAD.encode(rand::random::<[u8; 32]>());
let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()));
let state = URL_SAFE_NO_PAD.encode(rand::random::<[u8; 16]>());
// 端口 0:内核挑端口,注册的环回回调按 RFC 8252 §7.3 忽略端口
let server = Server::http("127.0.0.1:0").map_err(|e| e.to_string())?;
let port = server.server_addr().to_ip().unwrap().port();
let redirect = format!("http://127.0.0.1:{port}/callback");
let url = format!(
"{OAUTH}/oauth/authorize?client_id={CLIENT_ID}&redirect_uri={}\
&response_type=code&scope={}&state={state}\
&code_challenge={challenge}&code_challenge_method=S256",
urlencoding::encode(&redirect),
urlencoding::encode("openid profile catalog:read"),
);
// 系统浏览器,永远不用内嵌 webview
tauri_plugin_opener::open_url(&url, None::<&str>).map_err(|e| e.to_string())?;
let req = server.recv().map_err(|e| e.to_string())?;
let q: HashMap<String, String> = form_urlencoded::parse(
req.url().split_once('?').map(|(_, q)| q).unwrap_or("").as_bytes(),
)
.into_owned()
.collect();
req.respond(tiny_http::Response::from_string("可以关闭此页面。")).ok();
if q.get("state") != Some(&state) {
return Err("state mismatch".into());
}
let tokens: TokenResponse = reqwest::Client::new()
.post(format!("{OAUTH}/oauth/token"))
.form(&[
("grant_type", "authorization_code"),
("code", q.get("code").ok_or("no code")?),
("redirect_uri", &redirect),
("client_id", CLIENT_ID),
("code_verifier", &verifier),
])
.send()
.await
.map_err(|e| e.to_string())?
.json()
.await
.map_err(|e| e.to_string())?;
// refresh token 每次用都轮换:覆盖,不是追加
app.keyring()
.set_password("nextmoe", "refresh_token", &tokens.refresh_token)
.map_err(|e| e.to_string())?;
Ok(tokens.access_token)
}
Wails(Go):
const (
oauthBase = "https://account.nextmoe.com/api/v1"
clientID = "your-client-id"
scopes = "openid profile catalog:read"
)
func (a *App) SignIn(ctx context.Context) (string, error) {
verifier := randomURLSafe(32)
sum := sha256.Sum256([]byte(verifier))
challenge := base64.RawURLEncoding.EncodeToString(sum[:])
state := randomURLSafe(16)
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return "", err
}
defer ln.Close()
redirect := fmt.Sprintf("http://127.0.0.1:%d/callback", ln.Addr().(*net.TCPAddr).Port)
q := url.Values{
"client_id": {clientID}, "redirect_uri": {redirect},
"response_type": {"code"}, "scope": {scopes}, "state": {state},
"code_challenge": {challenge}, "code_challenge_method": {"S256"},
}
runtime.BrowserOpenURL(ctx, oauthBase+"/oauth/authorize?"+q.Encode())
codeCh := make(chan string, 1)
srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("state") != state {
http.Error(w, "state mismatch", http.StatusBadRequest)
return
}
fmt.Fprint(w, "可以关闭此页面。")
codeCh <- r.URL.Query().Get("code")
})}
go srv.Serve(ln)
defer srv.Close()
var code string
select {
case code = <-codeCh:
case <-time.After(5 * time.Minute):
return "", errors.New("authorization timed out")
}
// public client:用 code_verifier,没有 client secret
resp, err := http.PostForm(oauthBase+"/oauth/token", url.Values{
"grant_type": {"authorization_code"}, "code": {code},
"redirect_uri": {redirect}, "client_id": {clientID},
"code_verifier": {verifier},
})
if err != nil {
return "", err
}
defer resp.Body.Close()
var tok struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
}
if err := json.NewDecoder(resp.Body).Decode(&tok); err != nil {
return "", err
}
// 轮换:这是覆盖,不是新增
if err := keyring.Set("nextmoe", "refresh_token", tok.RefreshToken); err != nil {
return "", err
}
return tok.AccessToken, nil
}
6 · 收藏夹同步
管理器还有一半工作在用户自己的库上。/v2/me/folders 是这份库在平台侧的规范存放处:收藏夹本身九个操作,加上夹内条目的读、增、删。收藏夹、条目、公开读取和审核的语义在 收藏夹;这一节只写游戏管理器怎么同步。
folder:read覆盖 GET / HEAD,folder:write覆盖其余方法并且同时满足读——只申请了写的管理器仍读得回自己写的东西。两个都要加进应用的user_login.scopes,并在授权 URL 的scope里一并请求;缺了是403 SCOPE_REQUIRED,响应里点名缺哪一个。/v2/me上不只有这一族看 scope:/v2/me/works与收藏夹一样要folder:read,认领、提案、封面投票、编辑能力和编辑审核队列要catalog:edit。见 接入用户数据。
冷启动:全量各拉一次。前者按 id 升序,后者按 updated_at 升序,都用 next_cursor 续页,null 即到底。
GET /v2/me/folders?limit=100
GET /v2/me/folders/<id>/items?limit=100
Authorization: Bearer <access token>
稳态:条目游标就是水位线。夹内条目按 updated_at 升序做 keyset 翻页,游标是不透明的 cur_ 前缀字符串——不要解析、不要自己构造。把最后一页的 next_cursor 存下来,下次原样回放,拿到的就是这之后变过的条目。
GET /v2/me/folders/<id>/items?cursor=<上次存的 next_cursor>&limit=100
- 重复添加不动水位线。
PUT一条已经在夹里的条目是完全的空操作,updated_at不变。所以每次启动整库上传一遍是安全的——若这一下会刷新时间戳,该用户其他设备上的客户端每次都得把整个收藏夹重拉一遍。 - 删除不会在增量里回放。 游标只走还存在的行。要检测删除得重新全量拉一次该夹再与本地取差集;夹自身的
item_count与updated_at可以用来判断值不值得拉。
写:逐条幂等,或者一次一百条。
PUT /v2/me/folders/<id>/items/<work_id> → 200,已存在则原样返回
DELETE /v2/me/folders/<id>/items/<work_id> → 204,本来就不在也是 204
POST /v2/me/folders/<id>/items → 207,{"items":[{"work_id":"..."}]}
批量一次最多 100 条,响应是 207 Multi-Status:items[] 与请求逐位对应,每项要么是 {status:200, object:"folder_item", work_id},要么带一个完整的 problem 对象。整体不是事务,部分成功是正常结果——按项读 status,不要看 HTTP 状态码。
边界:每人最多 200 个收藏夹,每夹最多 10,000 条,超出 422。加入的作品必须是 live,隔离或不存在的 id 是 404。is_default 全用户单持有——设到另一个夹上会自动摘掉原持有者,传 false 是 422,默认夹在标记移走之前删不掉。visibility: public 的夹出现在 /v2/folders,见 收藏夹 · 公开读取。
合并会移动条目:目录把两部作品判为同一部时,指向被退役 id 的条目改指幸存者,并且故意刷新 updated_at。这是条目唯一一次在没人动它的情况下出现在增量里,因为你手上那个 id 已经不解析了。同一个夹里两边都收藏过的会合成一条,item_count 随之重算。
常见错误
| 症状 | 原因 |
|---|---|
授权时 15006 |
请求的 scope 没在应用注册的 scope 里。到应用详情页「用户登录」卡片勾上缺的那个,重新授权。catalog:read 不会触发它——每个应用无需注册即可请求。 |
换码 invalid_grant |
redirect_uri 与授权那步不是逐字节相同,或 code_verifier 对不上 challenge,或码已用过(授权码一次性)。 |
403 SCOPE_REQUIRED |
打 /v2/catalog 而令牌不带 catalog:read,打 /v2/me/folders 或 /v2/me/works 而不带 folder:read / folder:write,或打认领、提案、封面投票而不带 catalog:edit。响应会点名缺哪一个;旧令牌不追认,重新走一次授权。 |
401 INVALID_CREDENTIAL |
令牌过期,或者你把它打到了 claim-events / /v2/store——那两处只收应用密钥。 |
| 刷新 401 而令牌确实没过期 | 用了第一方 /api/v1/auth/refresh。OAuth session 只能经 /oauth/token 刷新。 |
| 注册时回调被拒 | localhost、自定义 scheme、带 fragment、或非环回的明文 http。移动端不必等 scheme 放开:环回照用,或注册 https:// 回调配 App Links / Universal Links,见 §1。 |