Skip to content

快速开始 ​

本页以最短路径带你跑通 Notanote 第三方接入全流程:从申请凭证到拉取玩家存档,一共四个步骤。读完本页后,你将得到一个可运行的 Node.js 示例,并了解每一步在整体流程中的位置。

整体流程概览 ​

Notanote 第三方接入采用 OAuth 风格的网页授权流程,共四步:

  1. 换取临时会话 — 你的服务端用应用凭证换取一个 5 分钟有效的 auth-session
  2. 引导玩家授权 — 将玩家浏览器重定向到 Notanote 授权页
  3. 接收回调 / 获取 Token — 玩家确认授权后,通过回调地址接收 name 与 access_token,或从页面展示的授权码中解析得到
  4. 拉取存档 — 用 access_token 调用存档接口,获取玩家存档文件

两种回调模式

Notanote 支持两种回调方式:

  • URL 回调模式:callback 填你的回调地址,授权后浏览器自动跳转,携带 access_token
  • 展示模式(callback="show"):授权后直接在页面上展示授权码(格式为 {name}.{token}),用户复制后发给第三方。适用于 QQ 机器人等无法托管回调地址的场景

URL 回调模式:

mermaid
sequenceDiagram
    participant App as 你的服务端
    participant Nano as Notanote API
    participant User as 玩家浏览器

    App->>Nano: ① POST /webauth/get-session(换取 auth-session)
    Nano-->>App: 返回 auth-session(5 分钟有效)

    App->>User: ② 302 重定向到 Notanote 授权页
    User->>Nano: ③ 玩家登录并确认授权
    Nano-->>User: ④ 返回 redirect_url
    User->>App: ⑤ 浏览器跳转 callback?name=&access_token=&state=
    App->>App: ⑥ 校验 state(防 CSRF)
    App->>Nano: ⑦ 用 access_token 调用第三方接口
    Nano-->>App: ⑧ 返回存档下载链接

展示模式(callback="show"):

mermaid
sequenceDiagram
    participant App as 你的服务端
    participant Nano as Notanote API
    participant User as 玩家浏览器
    participant 3rd as 第三方接入者

    App->>Nano: ① POST /webauth/get-session(callback="show")
    Nano-->>App: 返回 auth-session(5 分钟有效)

    App->>User: ② 302 重定向到 Notanote 授权页
    User->>Nano: ③ 玩家登录并确认授权
    Nano-->>User: ④ 页面直接展示授权码(name.token)
    User->>3rd: ⑤ 玩家复制授权码,发送给第三方接入者
    3rd->>3rd: ⑥ 按第一个 "." 拆分,得到 name 与 access_token
    3rd->>Nano: ⑦ 用 name + access_token 调用第三方接口

前置条件 ​

在开始接入前,请先准备好以下凭证与环境。

申请应用凭证 ​

联系 contact@notanote.cn 申请第三方应用接入,审核通过后你将收到:

凭证类型说明
TIDnumber第三方应用 ID,全局唯一
SecretKeystring应用密钥,仅限服务端使用,禁止泄露到客户端

SecretKey 保管

SecretKey 等同于你的应用密码。泄露后攻击者可冒充你的应用发起任意请求,包括但不限于发起授权、拉取玩家存档。请勿写入前端代码、客户端配置或版本控制系统。

接口域名 ​

https://api.notanote.cn

仅支持 HTTPS,明文 HTTP 请求会被拒绝。所有接口统一使用 POST 方法,请求体与响应体均为 JSON。

接入清单 ​

接入前请确认以下事项:

  • [ ] 已获得 TID 和 SecretKey
  • [ ] 已准备好可公网访问的回调地址(如 https://your-app.com/oauth/callback)
  • [ ] 服务端可发起 HTTPS 出站请求
  • [ ] 玩家端浏览器可访问 Notanote 授权页域名

第一步:换取临时会话 ​

你的服务端调用 POST /webauth/get-session,用 TID + SecretKey 换取一个 5 分钟有效的 auth-session。此步骤同时会传入 state(用于 CSRF 防护)和 callback(授权完成后的回跳地址)。

请求 ​

接口地址:POST https://api.notanote.cn/webauth/get-session

请求体:

字段类型必填说明
tidnumber是你的第三方应用 ID
secretkeystring是你的第三方应用密钥
scopestring是授权范围,目前仅支持 access_token
callbackstring是授权完成后的回跳地址(合法 HTTPS URL),或填入 "show" 启用展示模式
statestring是你生成的随机字符串,用于回调时防 CSRF,建议至少 16 字符

字段管理

此处为网页授权部分,为适合观感,此接口字段为全小写,请注意。

请求示例:

json
{
  "tid": 1001,
  "secretkey": "your-secret-key",
  "scope": "access_token",
  "callback": "https://your-app.com/oauth/callback",
  "state": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6"
}

展示模式请求示例(callback 设为 "show"):

json
{
  "tid": 1001,
  "secretkey": "your-secret-key",
  "scope": "access_token",
  "callback": "show",
  "state": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6"
}

响应 ​

成功响应示例:

json
{
  "Status": true,
  "Response": {
    "Identifier": "nano.api.v2.webauth.get-session.succeed",
    "Data": {
      "auth-session": "AbC123...XYz789",
      "expire_in": 300
    }
  }
}
字段类型说明
auth-sessionstring临时会话令牌,用于第二步授权页 URL
expire_innumber有效期(秒),固定为 300(5 分钟)

有效期管理

auth-session 仅在 5 分钟内有效。换取后应立即引导玩家进入授权页,避免会话过期导致授权失败。过期后需要重新调用本接口获取新的 auth-session。

失败情况 ​

Identifier说明处理建议
nano.api.v2.webauth.get-session.failed.TIDVerifyFailedtid 或 secretkey 错误检查凭证是否正确、应用是否被禁用
nano.api.v2.webauth.get-session.failed.serverError服务端临时故障稍后重试,持续失败请联系运营方

拿到 auth-session 后,立即进入第二步。

第二步:引导玩家授权 ​

将玩家浏览器重定向到 Notanote 授权页面,URL 中携带 auth-session。授权页面会展示你的应用名称和授权范围,玩家登录并确认后才会跳回你的回调地址。

授权页 URL ​

https://nano.notanote.cn/authorize?auth-session={你的 auth-session}

引导方式 ​

在你的前端点击「使用 Notanote 登录」按钮时,由服务端返回 302 重定向:

http
HTTP/1.1 302 Found
Location: https://nano.notanote.cn/authorize?auth-session=AbC123...XYz789

或者直接构造跳转链接,让浏览器发起跳转:

html
<a href="https://nano.notanote.cn/authorize?auth-session=AbC123...XYz789">
  使用 Notanote 登录
</a>

玩家侧体验 ​

  1. 玩家被重定向到 Notanote 授权页
  2. 授权页展示你的应用名称、作者和授权范围(如「读取存档」)
  3. 玩家输入 Notanote 账号密码并确认授权
  4. Notanote 校验身份后生成 access_token
  5. 浏览器自动跳转回你的 callback 地址

第三步:接收回调 / 获取 Token ​

URL 回调模式 ​

玩家确认授权后,浏览器会跳转到你在第一步传入的 callback 地址,URL 携带以下 query 参数:

参数类型说明
namestring玩家的 Notanote 用户名
access_tokenstring第三方授权令牌,用于调用后续接口
statestring你在第一步传入的 state,原样回传

回调 URL 示例:

https://your-app.com/oauth/callback?name=player1&access_token=xyz789&state=a1b2c3d4e5f6...

展示模式(callback="show") ​

当 callback 设为 "show" 时,玩家确认授权后不会跳转,而是在当前页面直接展示一个授权码。授权码是一个字符串,格式为:

{name}.{token}
组成部分含义
name玩家的 Notanote 用户名
.固定分隔符
token第三方授权令牌(即 access_token)

例如玩家 player1 授权成功后,页面展示的授权码形如:

player1.a1b2c3d4e5f6g7h8

玩家将授权码原样复制发送给第三方接入者即可,无需分别传递用户名和 Token。接入方收到授权码后,以第一个 . 为界拆分——前半部分作为 Name、后半部分作为 Token,即可调用第四步的存档接口:

js
// 解析展示模式授权码
const dot = code.indexOf(".");
const name = code.slice(0, dot);   // 玩家用户名
const token = code.slice(dot + 1); // access_token

适用场景

展示模式专为 QQ 机器人等无法提供回调地址的接入场景 设计。玩家在 Notanote 授权页完成授权后,页面直接展示授权码({name}.{token}),玩家复制后发送给第三方接入者(如 QQ 机器人客服),第三方拆分出 name 与 token 即可调用后续接口。

展示模式下的 state:state 在展示模式下仍然需要传入,但回调不在你控制范围内,state 校验由你的服务端在处理后续接口调用时酌情处理。

必须校验 state(URL 回调模式) ​

你的 callback 收到请求后,必须先校验 state 是否与第一步发出的一致。不一致说明这不是你发起的授权,应当拒绝处理。这是防御 CSRF 攻击的关键手段。

js
// 伪代码
if (req.query.state !== mySavedState) {
  // state 不匹配,拒绝本次回调,可能是 CSRF 攻击
  return res.status(403).send('state 校验失败');
}
// 校验通过,安全地存储 access_token

处理 access_token ​

access_token 会出现在浏览器地址栏和历史记录中。建议:

  • 服务端在校验 state 通过后立即持久化 access_token
  • 引导玩家离开回调页面,避免 token 持续暴露在地址栏
  • 不要将 access_token 写入前端可读的 Cookie 或 LocalStorage

校验通过后,进入第四步。

第四步:拉取玩家存档 ​

拿到玩家的 name 与 access_token 后(展示模式下从授权码 {name}.{token} 拆分获得),调用第三方存档接口即可读取该玩家的存档数据。完整流程为:先调 get 查询存档是否存在,再调 pull 获取临时下载链接,最后从对象存储节点直接下载存档文件。

js
// 伪代码
// 1. 查询存档元信息
const meta = await callApi('/third-party/save/get', {
  Name: playerName,
  TID: CONFIG.TID,
  SecretKey: CONFIG.SECRET_KEY,
  Token: accessToken
});

if (!meta.Response.Data.Save) {
  // 玩家尚无存档
  return;
}

// 2. 拉取预签名下载链接
const pull = await callApi('/third-party/save/pull', {
  Name: playerName,
  Token: accessToken,
  TID: CONFIG.TID,
  SecretKey: CONFIG.SECRET_KEY
});

// 3. 从对象存储节点直接下载存档文件
const file = await fetch(pull.Response.Data.Url);

TIP

存档文件直接从对象存储节点传输至你的服务端,不经过 Notanote 服务器中转。预签名下载链接有效期仅 60 秒,获取后请立即下载。

完整的接口参数与错误码请参考 第三方存档。

Node.js 完整示例 ​

以下是一个完整的 Express 示例,覆盖授权全流程:换取临时会话 → 引导授权 → 接收回调 → 拉取存档。可直接复制并填入你的凭证后运行。

依赖 ​

bash
npm install express crypto

服务端代码 ​

js
const express = require("express");
const crypto = require("crypto");

const app = express();

// ===================== 配置 =====================
const CONFIG = {
  TID: 1001,                              // 你的第三方应用 ID
  SECRET_KEY: "your-secret-key",          // 应用密钥(严禁泄露到前端)
  API_BASE: "https://api.notanote.cn",
  CALLBACK: "https://your-app.com/oauth/callback",
};

// ===================== 工具函数 =====================

/**
 * 调用 Notanote API(统一 POST JSON)
 * @param {string} path - 接口路径,如 "/webauth/get-session"
 * @param {object} body - 请求体
 * @returns {Promise<object>} 解析后的响应体
 */
async function callApi(path, body) {
  const res = await fetch(`${CONFIG.API_BASE}${path}`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });

  if (!res.ok) {
    throw new Error(`API 请求失败: HTTP ${res.status}`);
  }

  const data = await res.json();

  if (!data.Status) {
    const errCode = data.Response?.Identifier ?? "unknown";
    throw new Error(`业务错误: ${errCode}`);
  }

  return data;
}

// ===================== 路由 =====================

/**
 * 第一步 + 第二步:发起授权 — 换取 auth-session 并重定向
 *
 * 你的前端点击「使用 Notanote 登录」时调用此接口。
 */
app.get("/oauth/login", async (req, res) => {
  try {
    // 1. 生成防 CSRF 的 state
    const state = crypto.randomBytes(32).toString("hex");

    // 2. 服务端持有 state,用于回调时校验(生产环境请存 Redis/DB)
    //    这里用内存 Map 示意,实际项目务必使用持久化存储。
    stateStore.set(state, { createdAt: Date.now() });

    // 3. 换取临时会话
    const result = await callApi("/webauth/get-session", {
      tid: CONFIG.TID,
      secretkey: CONFIG.SECRET_KEY,
      scope: "access_token",
      callback: CONFIG.CALLBACK,
      state,
    });

    const authSession = result.Response.Data["auth-session"];

    // 4. 302 重定向到 Notanote 授权页
    res.redirect(
      `https://nano.notanote.cn/authorize?auth-session=${encodeURIComponent(authSession)}`
    );
  } catch (err) {
    console.error("换取 auth-session 失败:", err.message);
    res.status(500).send("授权发起失败,请稍后再试。");
  }
});

/**
 * 第三步:接收授权回调
 *
 * 玩家确认授权后,Notanote 将浏览器重定向到此地址。
 * URL 形如: /oauth/callback?name=xxx&access_token=xxx&state=xxx
 */
app.get("/oauth/callback", async (req, res) => {
  const { name, access_token, state } = req.query;

  try {
    // 1. 校验 state — 防 CSRF 攻击,这是强制性步骤
    if (!state || !stateStore.has(state)) {
      res.status(403).send("state 校验失败,请求被拒绝。");
      return;
    }

    // 2. state 一次性使用,用完即删,防止重放
    stateStore.delete(state);

    // 3. 持久化存储 access_token(生产环境写入数据库)
    //    这里仅打印示意
    console.log(`用户 ${name} 授权成功,access_token: ${access_token}`);

    // 4. 你的业务逻辑:比如生成自己的 JWT、写入用户表等
    //    ...

    // 5. 引导到你的前端页面
    res.redirect(`https://your-app.com/welcome?user=${encodeURIComponent(name)}`);
  } catch (err) {
    console.error("回调处理失败:", err.message);
    res.status(500).send("授权回调处理失败。");
  }
});

/**
 * 第四步:用 access_token 拉取玩家存档
 *
 * 你的前端/服务端在需要读取存档时调用。
 */
async function fetchPlayerSave(playerName, accessToken) {
  // 1. 查询存档元信息
  const meta = await callApi("/third-party/save/get", {
    Name: playerName,
    TID: CONFIG.TID,
    SecretKey: CONFIG.SECRET_KEY,
    Token: accessToken,
  });

  if (!meta.Response.Data.Save) {
    throw new Error("玩家尚无存档");
  }

  // 2. 拉取预签名下载链接
  const pull = await callApi("/third-party/save/pull", {
    Name: playerName,
    Token: accessToken,
    TID: CONFIG.TID,
    SecretKey: CONFIG.SECRET_KEY,
  });

  // 3. 返回预签名 URL,由调用方自行下载
  return pull.Response.Data;
}

// ===================== 内存 state 存储(仅示意) =====================
const stateStore = new Map();

// 可选:定时清理过期的 state(auth-session 只有 5 分钟寿命)
setInterval(() => {
  const now = Date.now();
  for (const [key, val] of stateStore) {
    if (now - val.createdAt > 5 * 60 * 1000) {
      stateStore.delete(key);
    }
  }
}, 60_000);

// ===================== 启动 =====================
app.listen(3000, () => {
  console.log("Server running on http://localhost:3000");
});

展示模式简化示例 ​

如果你的应用面向 QQ 机器人等无法托管回调地址的场景,只需将 callback 改为 "show",省去回调路由:

js
const express = require("express");
const crypto = require("crypto");

const app = express();

const CONFIG = {
  TID: 1001,
  SECRET_KEY: "your-secret-key",
  API_BASE: "https://api.notanote.cn",
};

async function callApi(path, body) {
  const res = await fetch(`${CONFIG.API_BASE}${path}`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  const data = await res.json();
  if (!data.Status) {
    throw new Error(data.Response?.Identifier ?? "unknown");
  }
  return data;
}

/**
 * 发起授权(展示模式)
 * callback 设为 "show",授权完成后页面直接展示授权码(name.token),
 * 玩家复制授权码发送给第三方接入者,由接入方拆分后调用后续接口。
 */
app.get("/oauth/login-show", async (req, res) => {
  try {
    const state = crypto.randomBytes(32).toString("hex");

    const result = await callApi("/webauth/get-session", {
      tid: CONFIG.TID,
      secretkey: CONFIG.SECRET_KEY,
      scope: "access_token",
      callback: "show",   // 关键:设为 "show"
      state,
    });

    const authSession = result.Response.Data["auth-session"];

    // 重定向到 Notanote 授权页
    res.redirect(
      `https://nano.notanote.cn/authorize?auth-session=${encodeURIComponent(authSession)}`
    );
  } catch (err) {
    console.error("换取 auth-session 失败:", err.message);
    res.status(500).send("授权发起失败,请稍后再试。");
  }
});

// 展示模式无需回调路由:玩家把授权码(name.token)发给你后,
// 按第一个 "." 拆分即可得到 name 与 access_token
function parseAuthCode(code) {
  const dot = code.indexOf(".");
  return {
    name: code.slice(0, dot),   // 玩家用户名
    token: code.slice(dot + 1), // access_token
  };
}

app.listen(3000, () => {
  console.log("Server running on http://localhost:3000");
});

展示模式注意事项

  • 展示模式下授权码直接展示给玩家,玩家只需复制一个授权码({name}.{token})发给第三方接入者,无需分别传递用户名和 Token
  • 接入方收到授权码后按第一个 . 拆分出 Name 与 Token,即可直接调用存档接口,无需额外回调校验步骤
  • 建议在你的服务端提供授权码绑定接口,让第三方将收到的授权码提交给你解析并存储

接下来 ​