
# 用户身份与登录

`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 token | `Authorization: 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` 可以在开发者小程序列表或详情页查看。进入
[开发者小程序列表](https://open.xiaoheihe.cn/zh_cn/user_miniprogram/list?scope=mine)：

![在开发者小程序列表查看 user_miniprogram_id](https://static.max-c.com/webapp/vs/2026_08_25/3165a861c0b337e2046964d3277f7577.jpeg)

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

![在开发者小程序详情页查看或重置 user_miniprogram_secret](https://static.max-c.com/webapp/vs/2026_08_25/32af15c4ee70c0401b68d66d3ea96ad9.jpeg)

密钥只能保存在服务端；不要把它写入小程序代码、浏览器环境变量或返回给页面。创建者或主体超级管理员
可以查看或重置密钥；重置后，旧密钥及其签发的授权码和 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`                  | 调用方处理                                                                             |
| -------------- | ------------------------ | -------------------------------------------------------------------------------------- |
| `400`          | `invalid_request`        | 修正 JSON、Basic header 或缺失字段，不重试原请求                                       |
| `401`          | `invalid_client`         | 检查服务端小程序 ID 和密钥；响应带 `WWW-Authenticate: Basic`，不能通过用户重新授权修复 |
| `400`          | `invalid_grant`          | code 无效、过期、已使用或不属于当前小程序；获取新 code，不重试旧 code                  |
| `400`          | `unsupported_grant_type` | 固定使用 `authorization_code`                                                          |
| `401`          | `invalid_token`          | 清理业务 session 并重新授权；响应带 `WWW-Authenticate: Bearer`                         |
| `403`          | `insufficient_scope`     | 在明确的用户操作中重新申请所需 scope；响应带 `WWW-Authenticate: Bearer`                |
| `429`          | `rate_limited`           | 等待响应头 `Retry-After` 指定的秒数后再试                                              |
| `500` 或 `503` | `server_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 内部路径不能通过该能力访问；身份交换必须由开发者服务端按公开后端文档完成。

完整权限配置与错误区别见[权限声明](./permissions.md)。

## 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。
