Skip to content

用户身份与登录 ​

app_user_id 是平台为“用户 × 当前小程序”生成的稳定、隔离标识,不是黑盒用户 ID。它是不透明字符串,只能在当前小程序内使用,不能解析或跨小程序关联。

用户资料与 Steam 游戏库读取不依赖公开 network 权限,启用或关闭网络权限都可调用 user.getInfo()、user.getSteamGameList()。平台为当前版本启用 Steam 账号能力后,无网络的 local 模式调用 auth.login() 仍返回 SERVER_API_REQUIRED;兼容 legacy 模式保持既有行为。根据业务需要选择入口,并在 package.json#heybox.permissions 声明对应权限:

身份入口权限结果用途
auth.login({ scopes? })userInfo获取短期授权码,由开发者服务端换取 OpenAPI token
user.getInfo()userInfo读取当前用户的隔离身份及已授权资料
user.getSteamGameList()steamLibrary读取当前用户的 Steam 游戏库

根据业务需要选择资料读取或授权码入口。SDK 不向小程序页面提供 token、cookie、黑盒用户 ID 或平台私有凭据。

获取服务端授权码 ​

在登录、绑定账号或读取资料等明确的用户操作中调用 auth.login():

SDK 会在导入时自动握手,能力调用也会自动等待。不要在按钮回调中等待握手;用 getHandshakeState() 读取当前状态、用 onHandshakeStateChange() 订阅并立即接收状态,只在 status === 'ready' 时启用按钮,并在组件销毁时取消订阅。ready 生命周期事件不会重放,只是边沿通知,不能作为握手状态。

ts
import hbSDK, { HbMiniProgramSDKError } from '@heybox/hb-sdk'

// 这是你自己的后端接口,不是黑盒 OpenAPI 地址。

async function loginFromUserAction() {
  try {
    const { code, expiresIn, scopes } = await hbSDK.auth.login({ scopes: ['profile'] })
    // expiresIn 以服务端返回为准,单位秒;当前后端签发 300 秒一次性 code

    await hbSDK.network.request({
      url: 'https://api.example.com/session/heybox',
      method: 'POST',
      data: { code },
    })

    console.log(expiresIn, scopes)
  } catch (error) {
    if (error instanceof HbMiniProgramSDKError && error.code === 'AUTHORIZATION_CANCELLED') {
      return
    }
    throw error
  }
}

返回值为 { code, expiresIn, scopes }。当前服务端签发的一次性 code 有效期为 300 秒,实际时长以响应中的 expiresIn 为准:

  • code 是一次性短期授权码,只能提交给当前小程序的开发者服务端。
  • expiresIn 是服务端返回的有效期(当前为 300 秒);服务端应尽快使用,失败或过期后重新获取,不要在业务代码中硬编码该值。
  • scopes 是本次授权码实际包含的 scope。

identity 始终隐式包含,不需要也不能通过省略它来申请匿名授权。scopes 只填写业务确实需要的可选资料,例如 profile。平台为当前版本启用 Steam 账号能力后,需要主绑定 SteamID64 的联网小程序可申请 steam_account;不需要额外资料时直接调用 auth.login()。

小程序页面只把 code 发送到自己的服务端。开发者服务端再按后端 OpenAPI 文档,用服务端保存的应用凭据交换 token;页面不得自行交换,也不得持有应用私密凭据。auth.login() 不返回 user、profile、steam_id、app_user_id 或 token。

业务会话的账号一致性 ​

本能力适用于需要自行保存业务 token 的小程序。auth.getAccountState() 独立于资料读取和 code 登录, 无需声明权限、用户手势或新增后端接口;不会拉起登录 UI,也不会建立或确认用户授权。 Mobile Host 在小程序专用 account 适配层使用文档级快照:优先复用 Host 初始化时采集的原生环境,在小程序 Host 装配时确定账号并缓存,前台恢复和后续查询不再反复调用原生 getter。客户端注入的 HEYBOX_ENV 同样可以作为快照来源;页面 query 和启动时的通用 session 不用于补齐账号。切换 APP 账号后需关闭并重新打开小程序,以新文档重新采集账号。文档内只允许从明确未登录状态补全为登录成功后的账号;账号来源缺失、字段损坏或冲突时返回错误并保持待确认。此过程不刷新通用 Runtime session,也不请求后端验证接口,存档等私有能力继续由服务端复核登录和数据归属。

ts
const state = await hbSDK.auth.getAccountState()
// { isHeyboxAppLoggedIn: false, accountKey: null }
// { isHeyboxAppLoggedIn: true, accountKey: '本地不透明标识' }

accountKey 只用于当前安装和当前小程序内比较账号,不是 app_user_id、黑盒用户 ID 或服务端登录凭据。 同账号重新打开小程序、重启 APP 后保持稳定;不同账号和不同小程序分别隔离。 A → B → A 会恢复 A 的标识;清理 Host 数据或重装后允许变化并重新登录。 它不验证业务 token 是否过期、用户授权是否仍有效,也不撤销开发者后端的 token。

恢复缓存会话 ​

把 { token, accountKey } 作为一个整体保存。首次升级遇到只有 token 的旧记录时,需要重新登录,不能猜测归属。 启动时先订阅 heybox_app_login_change,再查询;回到前台的 show 同样重新查询。 确认前暂停带身份请求及旧用户数据展示,避免 B 短暂看到 A 的页面。

当前结果处理
已登录,key 与缓存一致可以恢复业务会话,token 自身仍由业务后端按原规则校验
未登录,或 key 不一致停用旧 token,清理用户缓存,进入游客态或展示登录入口
查询失败,事件缺少 key,或旧 Host 不支持保持待确认并暂停身份请求,允许重试或重新登录;不要把失败当作未登录或账号相同

映射读取需要异步完成。账号变化时可能先收到不含 key 的待确认通知,再收到带 key 的确认通知; 相同 key 的重复事件不应清理会话。旧 Host 的缺失 key 与新 Host 的待确认状态都不能恢复缓存会话。 查询期间发生账号事件时,SDK 会拒绝旧响应并返回 AUTHORIZATION_CONTEXT_CHANGED;不要用旧缓存覆盖新事件。

新登录与换票期间切号 ​

auth.login() 现在可同时返回与 code 绑定的 accountKey。必须使用这个值绑定后端返回的 token, 不能在换票完成后随便读取当前 key 并与旧 token 拼接。旧 Host 可能没有该字段:可以完成原有登录, 但不能保存为可跨启动自动恢复的已确认会话。

以下骨架中的 exchangeCode、saveSession 和状态回调由业务实现;不要求后端增加字段:

ts
let accountEpoch = 0
let confirmedKey: string | undefined

const stopAccount = hbSDK.on('heybox_app_login_change', state => {
  // 相同 key 的重复通知不打断当前请求;缺失 key 先进入待确认。
  const key = state.isHeyboxAppLoggedIn ? state.accountKey ?? undefined : null
  if (key !== confirmedKey) accountEpoch += 1
  confirmedKey = key ?? undefined
  // 同时按上表暂停、恢复或清理当前业务会话。
  reconcileSession(state)
})

// 直接从用户点击回调调用,不要先等待握手或其他异步工作。
async function loginFromUserAction() {
  const login = await hbSDK.auth.login()
  if (!login.accountKey) {
    showHostUpgradeOrSessionOnlyLogin()
    return
  }
  const epoch = accountEpoch
  const token = await exchangeCode(login.code) // 原有后端换票接口
  const current = await hbSDK.auth.getAccountState()
  if (epoch !== accountEpoch || !current.isHeyboxAppLoggedIn || current.accountKey !== login.accountKey) return
  saveSession({ token, accountKey: login.accountKey })
}

// 页面卸载时调用 stopAccount();show 时重新查询并执行 reconcileSession。

所有带身份请求也应捕获当前会话代次;账号失效或切换后,旧请求即使返回成功也不得更新用户页面、缓存或业务会话。 使用 cookie 自动携带身份的业务同样需要暂停请求并处理旧响应;仅删除内存 token 不会清除 cookie。

后端 OpenAPI ​

以下是当前对开发者服务端公开的用户身份接口,基地址为 https://api.xiaoheihe.cn。OpenAPI 不接收小黑盒公共参数,也不提供 CORS;两条路径都只能 从开发者服务端访问,不能通过页面的 network.request() 访问。

方法路径用途认证
POST/user_miniprogram/openapi/v1/oauth/token用一次性授权码换取 OpenAPI access tokenAuthorization: Basic base64(<user_miniprogram_id>:<user_miniprogram_secret>)
GET/user_miniprogram/openapi/v1/user读取当前授权用户的身份和已授权资料Authorization: Bearer <access_token>

POST /oauth/token ​

Basic 用户名是当前小程序的 user_miniprogram_id,密码是开发者后台显示的服务端密钥,对应后端字段 user_miniprogram_secret。

user_miniprogram_id 可以在开发者小程序列表或详情页查看。进入 开发者小程序列表:

在开发者小程序列表查看 user_miniprogram_id

user_miniprogram_secret 可以从开发者小程序列表进入目标小程序详情页查看,并可随时重置:

在开发者小程序详情页查看或重置 user_miniprogram_secret

密钥只能保存在服务端;不要把它写入小程序代码、浏览器环境变量或返回给页面。创建者或主体超级管理员 可以查看或重置密钥;重置后,旧密钥及其签发的授权码和 access token 都会失效。token 请求使用 JSON, 正文不能超过 4 KiB。

http
POST https://api.xiaoheihe.cn/user_miniprogram/openapi/v1/oauth/token
Authorization: Basic base64(<user_miniprogram_id>:<user_miniprogram_secret>)
Content-Type: application/json

{"grant_type":"authorization_code","code":"<auth.login 返回的 code>"}

成功响应是 JSON:

json
{
  "access_token": "<server-only token>",
  "token_type": "Bearer",
  "expires_in": 7200,
  "scopes": ["identity", "profile", "steam_account"],
  "identity": { "app_user_id": "au_example" }
}

成功响应包含 Cache-Control: no-store。expires_in 以实际响应为准,单位为秒。 scopes 是本次授权实际获得的 scope; identity 始终包含。授权码只能使用一次,过期或重复使用时应让页面重新调用 auth.login(),不要重试同一个 code。当前公开契约没有 refresh token 接口;access token 失效后重新走授权码流程。

GET /user ​

使用换取的 access token 查询当前用户:

http
GET https://api.xiaoheihe.cn/user_miniprogram/openapi/v1/user
Authorization: Bearer <access_token>

成功响应同样包含 Cache-Control: no-store,正文始终包含 app_user_id。只有本次授权的 scopes 包含 profile 时,响应才包含 nickname 和 avatar;包含 steam_account 且当前用户已有主绑定时,还会包含 string 类型的 steam_id,未绑定时省略:

json
{
  "app_user_id": "au_example",
  "nickname": "示例用户",
  "avatar": "https://example.com/avatar.png",
  "steam_id": "76561198000000000"
}

没有对应 scope 时不要假定资料字段存在。steam_account 只提供当前主绑定 SteamID64,不提供 Steam 游戏库。不要再请求已下线的 /user/identity 或 /user/profile;其他 current-user 资源不在当前公开接口列表中。

常见错误 ​

OpenAPI 错误响应固定包含 error、error_description 和 request_id:

json
{
  "error": "invalid_grant",
  "error_description": "the authorization grant is invalid",
  "request_id": "01J..."
}

服务端应按稳定的 error 分支处理,用 request_id 排查问题,不要依赖或向页面原样透传 error_description。错误响应同样包含 Cache-Control: no-store。

状态error调用方处理
400invalid_request修正 JSON、Basic header 或缺失字段,不重试原请求
401invalid_client检查服务端小程序 ID 和密钥;响应带 WWW-Authenticate: Basic,不能通过用户重新授权修复
400invalid_grantcode 无效、过期、已使用或不属于当前小程序;获取新 code,不重试旧 code
400unsupported_grant_type固定使用 authorization_code
401invalid_token清理业务 session 并重新授权;响应带 WWW-Authenticate: Bearer
403insufficient_scope在明确的用户操作中重新申请所需 scope;响应带 WWW-Authenticate: Bearer
429rate_limited等待响应头 Retry-After 指定的秒数后再试
500 或 503server_error按暂时不可用处理,保留 request_id 后返回通用错误

服务端接入示例 ​

下面的示例展示完整的“页面 code → 服务端 session”链路。示例中的 /session/heybox 是 开发者自己的业务接口,不是黑盒 OpenAPI 地址;生产环境应使用 HTTPS 和共享的带 TTL session 存储。

ts
const miniProgramId = process.env.USER_MINIPROGRAM_ID!
const clientSecret = process.env.USER_MINIPROGRAM_SECRET!
const openApiBase = 'https://api.xiaoheihe.cn'

app.post('/session/heybox', async (req, res) => {
  const { code } = req.body
  if (typeof code !== 'string' || !code) {
    return res.status(400).json({ error: 'invalid_request' })
  }

  const basic = Buffer.from(`${miniProgramId}:${clientSecret}`).toString('base64')
  const tokenResponse = await fetch(`${openApiBase}/user_miniprogram/openapi/v1/oauth/token`, {
    method: 'POST',
    headers: {
      Authorization: `Basic ${basic}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ grant_type: 'authorization_code', code }),
  })
  if (!tokenResponse.ok) {
    const upstream = await tokenResponse.json()
    if (upstream.error === 'invalid_client') {
      console.error('OpenAPI credential invalid', { requestId: upstream.request_id })
      return res.status(503).json({ error: 'server_configuration_error' })
    }
    if (upstream.error === 'invalid_grant') {
      return res.status(401).json({ error: 'login_required' })
    }
    return res.status(502).json({ error: 'openapi_failed' })
  }

  const token = await tokenResponse.json()
  const userResponse = await fetch(`${openApiBase}/user_miniprogram/openapi/v1/user`, {
    headers: { Authorization: `Bearer ${token.access_token}` },
  })
  if (!userResponse.ok) {
    const upstream = await userResponse.json()
    if (upstream.error === 'invalid_token') {
      await deleteBusinessSession(req)
      return res.status(401).json({ error: 'login_required' })
    }
    return res.status(502).json({ error: 'openapi_failed' })
  }

  const user = await userResponse.json()
  // saveBusinessSession 只保存服务端 session,不要把 access_token 返回给页面。
  await saveBusinessSession({
    appUserId: user.app_user_id,
    profile: token.scopes.includes('profile')
      ? { nickname: user.nickname, avatar: user.avatar }
      : null,
    scopes: token.scopes,
    accessToken: token.access_token,
    expiresIn: token.expires_in,
  })
  return res.json({ authenticated: true, identity: { appUserId: user.app_user_id } })
})

实际项目还应补充 session cookie 的 HttpOnly、Secure、SameSite 属性,校验精确的 页面 Origin,设置请求超时和限流。只有 invalid_token 表示当前业务 session 对应的 token 需要清理并重新授权;invalid_client 是服务端凭据配置问题,不能要求用户重复登录。

服务端完成换票后,应只把业务需要的 app_user_id、nickname、avatar 等资料或业务 session 返回给页面;页面不接触 access token。

用户操作和错误 ​

需要展示登录或授权 UI 时,调用必须来自按钮点击等可信用户手势,否则返回 USER_GESTURE_REQUIRED。用户取消、拒绝或关闭授权页面时返回 AUTHORIZATION_CANCELLED,业务应停留在当前页面并允许稍后重试。

首次授权时,Host 授权页会展示当前小程序的名称和图标;申请 profile scope 时,还会预览当前小黑盒账号的昵称和头像,帮助用户确认资料披露对象。Runtime 优先复用 Host 启动阶段已经解析的资料,读取补充资料失败或超时时使用中性占位,不影响用户继续授权。账号切换、授权撤销或 Runtime 终止会关闭当前确认界面;这些 Host 展示字段不会加入 auth.login() 返回值。

用户已授权所需 scope 时,auth.login() 可以不展示 UI,静默返回新的短期授权码。业务仍应把可能出现的授权 UI 设计在明确的用户操作之后,不要在页面初始化阶段自动调用。

auth.login() 内部使用 Host 的受信请求,方法权限只要求 userInfo。但平台启用 Steam 账号能力后,仍需当前运行实例的有效 network 权限才能进入服务端身份模式;无网络的 local 模式会返回 SERVER_API_REQUIRED。如果页面需要把 code 通过 network.request() 发给开发者服务端,则该请求本身仍需声明并获得 network 的平台批准。

OpenAPI 凭据、授权码和 access token 的生命周期也不由 network 权限开启或关闭。

撤销当前小程序授权 ​

任何小程序都可以在明确的用户操作中撤销授权;user.revokeAuthorization() 位于无需声明白名单:

ts
async function revokeAuthorizationFromUserAction() {
  const confirmed = window.confirm('确定撤销当前小程序的用户授权吗?')
  if (!confirmed) return

  await hbSDK.user.revokeAuthorization()
  clearLocalUserState()
}

user.revokeAuthorization() 不接收参数。小程序页面不能提交小程序 ID、黑盒用户 ID 或手势标记;Runtime 从 Host 启动上下文和用户操作状态注入可信值。调用缺少可信用户手势时返回 USER_GESTURE_REQUIRED。

撤销成功后,后续 auth.login() 会重新进入授权流程;再次调用 user.getInfo() 时会按现有用户授权规则读取身份。业务必须立即停止使用并清理此前保存的 app_user_id、用户资料和开发者服务端会话;SDK 不会删除业务自己的 localStorage、数据库或 cookie。

读取当前用户资料 ​

需要当前用户在本小程序内的稳定隔离身份和已授权资料时调用:

ts
import hbSDK from '@heybox/hb-sdk'

async function getCurrentUser() {
  const result = await hbSDK.user.getInfo()
  return result.userInfo
}

user.getInfo() 必须声明 userInfo。它延续现有交互:不展示授权弹窗,也不要求可信用户手势。未登录时返回 isHeyboxAppLoggedIn: false;已登录时按授权状态返回当前小程序隔离的 app_user_id、profile.nickname 和 profile.avatar。平台为当前版本启用 Steam 账号能力后,小程序会隐式获得 steam_account,当前用户有主绑定时返回可选 steam_id;开启网络权限后仍可通过该 API 读取这些资料。用户授权与开发者权限声明是两层独立门禁,不能用声明代替用户同意。

Steam 游戏库使用 user.getSteamGameList(options),要求声明 steamLibrary,同样不依赖 network。Host current-user capabilities 的既有账号绑定和数据可用性规则保持不变。

游戏条目中的 portraitImage 提供可选的竖版封面,可使用 game.portraitImage ?? game.image 选择封面。上游未提供有效竖图,或 Host 尚未支持该字段时,portraitImage 会省略;加载失败时 也应回退到 image 或占位图。该字段需要 Host 配套更新,仅升级小程序 SDK 不会让旧 Host 返回竖图。

游戏库条目不提供价格信息。

Host Runtime 默认缓存相同查询的成功游戏页 24 小时,游戏新增、游玩时长和隐藏状态 可能延迟更新。支持 sandbox 文件操作的宿主可以跨关闭复用,其他宿主只在当前 Runtime 内复用。 命中仍检查当前账号与绑定,缓存有效期由 Host Runtime 策略决定;首次全量读取仍需逐页请求, 不同页不保证属于同一时间点的快照。 系统或用户清理缓存目录后会重新请求;一天有效期不保证文件一定保留一天。

网络请求边界 ​

network.request() 只用于访问开发者自己的业务服务。平台保留的 runtime auth 与 OpenAPI 内部路径不能通过该能力访问;身份交换必须由开发者服务端按公开后端文档完成。

完整权限配置与错误区别见权限声明。

CLI 登录与用户登录 ​

hb-sdk login 只给本地开发和远端管理命令建立 CLI 登录态,与小程序页面的 auth.login()、user.getInfo() 和用户授权完全无关。

关注小程序作者 ​

点击按钮时直接调用 user.requestFollowAuthor();不要先 await 其他操作,否则可能丢失可信手势。无业务入参,作者由平台绑定,无需声明 userInfo、network 或先调用 auth.login()。

未登录时先由宿主完成小黑盒登录,未关注时展示统一居中确认框。已关注(含互关)和作者本人直接返回,不展示、不计数。user.getFollowStatus() 可用于刷新自绘按钮,不触发登录、不弹框、不计数;未登录返回 notFollowing。

ts
import hbSDK, { AuthorFollowStatus } from '@heybox/hb-sdk'

const labels = {
  [AuthorFollowStatus.NotFollowing]: '关注作者',
  [AuthorFollowStatus.Following]: '已关注',
  [AuthorFollowStatus.Self]: '作者本人',
}
async function refreshStatus() {
  const result = await hbSDK.user.getFollowStatus()
  followButton.textContent = labels[result]
}
async function onFollowClick() {
  try {
    const result = await hbSDK.user.requestFollowAuthor()
    followButton.textContent = labels[result]
  } catch (error) {
    if (error.code === 'FOLLOW_CANCELLED' || error.code === 'FOLLOW_IN_PROGRESS') return
    if (error.code === 'FOLLOW_GUIDANCE_LIMIT_REACHED') {
      followButton.textContent = '今日关注引导次数已达上限'
      return
    }
    console.error(error)
  }
}
  • 两个方法直接返回 AuthorFollowStatus 枚举值,不包装对象;用 AuthorFollowStatus.NotFollowing、AuthorFollowStatus.Following、AuthorFollowStatus.Self 分别判断未关注、已关注和作者本人。枚举常量保留对应的字符串值,兼容已有字符串比较。
  • 同账号同小程序所有设备每天最多领取 10 次弹框额度,按北京时间自然日重置;取消确认、点击蒙层或关闭也计次。网络丢失领取响应可能占用一次额度。
  • 确认未结束时再次调用会以 FOLLOW_IN_PROGRESS 拒绝;取消确认或放弃登录为 FOLLOW_CANCELLED。
  • 业务查询和关注失败沿用 SDK 的 reject 约定,失败不缓存;并非小红书 follow_status: -1 的 resolve 约定。
  • 需更新 Host 与后端,旧 Runtime 可能返回 METHOD_NOT_FOUND;SDK 查询不暴露作者或当前用户的黑盒 ID。