---
url: /docs/api-docs/v1/oauth/actions.md
---
# 9 Action 完整字段表

## 1.1 captcha —— 图形验证码

**前置条件**：无
**频率限制**：同 IP 60s 内 30 次（超出 `40101005`）

**请求**：

```json
{ "action": "captcha", "payload": {} }
```

（payload 必须为空对象，多传字段 400）

**响应 data**：

| 字段 | 类型 | 说明 |
|---|---|---|
| `captchaId` | string (UUID) | 凭据，**120s** 有效，一次性 |
| `png` | string | **真 PNG dataURL**（`data:image/png;base64,…`，V2.0.43+）。前端唯一正解：原生 `<image :src="png">` |
| `svg` | string | SVG 源码（含抗指纹扰动）。仅回退/桌面端用途 |

**前端要点**：

* 一次消费即失效（**成败皆消费**）；任何失败分支后重新拉取
* 不做本地缓存复用；换网后直接刷新
* ⚠️ **渲染三定律（2026-09-17 真机三层实测，勿再踩）**：
  1. `uni_modules/svg-icon` APP 端 renderjs 对动态返回的 svg/dataURL **均渲染失败**（空白无报错）— 勿用 svg-icon 承载验证码
  2. **Android 原生 image 不渲染 `data:image/svg+xml` dataURL**（V2.0.42 及之前 `png` 字段名不副实，实为 svg dataURL → 页面空白）
  3. 唯一正解：**原生 `<image>` + `png` 字段 + 后端 ≥ V2.0.43（真 PNG）**，三条件缺一不可
* 前端自查清单：`res.data.png` 前缀应为 `data:image/png;base64,`（若是 `data:image/svg+xml` → 后端版本 < V2.0.43，先升后端）

***

## 1.2 sms-send —— 发送短信验证码

**前置条件**：先成功获取 `captcha`
**调用时机**：登录页 / 绑定手机页点「获取验证码」

**请求 payload**：

| 字段 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|
| `phone` | string | ✅ | `^1[3-9]\d{9}$` | 11 位大陆手机号 |
| `captchaId` | string | ✅ | 8~64 字符 | 1.1 返回的凭据 |
| `captchaCode` | string | ✅ | 1~8 字符 | 用户输入（不区分大小写比对） |
| `clientId` | string | ⬜ | `^[A-Za-z0-9_-]{1,32}$` | 短信码按 `(phone, clientId)` 隔离存储 |

**响应 data**：`{ "ok": true }`

**处理顺序**（服务端硬编码，联调排错按此序定位）：
图形码校验 → IP 30/min → 手机号 60s/1 → 手机号 24h/10 → 生成 4 位码（10min TTL）→ 腾讯云下发。

> ⚠️ **验证码只认真实下发**：生产环境**没有任何固定码 / 万能码绕过**，必须走完图形码 + 真实短信才能登录。
> 联调环境是否开启「固定码」捷径由**运维配置**（属内部信息）—— 需要时**找运维要码**，不要假设某个值可用。

**错误分支（全量）**：

| 层 | HTTP | code | 场景 | 前端处置 |
|---|---|---|---|---|
| Zod | 400 | — | 字段缺失/格式错（`msg` 定位字段） | 开发期修复 |
| 图形码 | 200 | `40101003` | 验证码错误 | 提示 + **重新拉 captcha** |
| 图形码 | 200 | `40101004` | >120s 过期 | 静默刷新 captcha |
| 限流 | 200 | `40101005` | 三层任一（msg 区分：IP/间隔/日上限） | 按 msg 提示，倒计时保持禁用 |
| 下发 | 200 | `40101006` | 短信通道故障 / **SMS 凭据未配置期** | 提示稍后再试 + 重新拉 captcha |

**前端要点**：

* ⚠️ **图形码无论成败即消费** —— 任何失败分支重试前必须重新 `captcha`
* 成功即启动 **60s 倒计时**（服务端 60s/1 条硬限）；短信码 4 位、10min 有效、**累计错 3 次即作废**

***

## 1.3 sms-verify —— 预验证短信码（可选）

**语义**：校验短信码是否正确，**`consume=false` 不消费**——同一短信码可多次调用（分步表单：第 1 步验码 ✅ → 用户回填其他字段 → 第 2 步真正提交）。
**调用时机**：绑定手机页等分步场景的「下一步」前置校验；普通登录页**跳过**。

**请求 payload**：

| 字段 | 类型 | 必填 | 约束 |
|---|---|---|---|
| `phone` | string | ✅ | `^1[3-9]\d{9}$` |
| `smsCode` | string | ✅ | `^\d{4}$`（**严格 4 位数字**） |
| `clientId` | string | ⬜ | 同 1.2（必须与发码/最终提交用**同一 clientId**） |

**响应 data**：`{ "ok": true }`

**错误分支**：

| code | 场景 | 处置 |
|---|---|---|
| `40101001` | 码不存在 / 已过期（10min）/ 已被 3 次错误作废 | 提示重新发码，回 1.2 |
| `40101003` | 码错误（msg=「短信验证码错误」）——**会累计尝试次数，第 3 次错码作废** | 提示错误；接近 3 次时建议引导重发 |

**前端要点**：错误尝试计数**跨 sms-verify 与 sms-login 共享**（同一个 Redis 计数器）—— 分步场景预验证输错 2 次后，最终提交只剩 1 次机会。

***

## 1.4 sms-login —— 手机号验证码登录

**前置条件**：`sms-send` 成功
**调用时机**：登录页点「登录/注册」（**新用户自动注册**，本接口即 OAuth 主注册通道）

**请求 payload**：

| 字段 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|
| `phone` | string | ✅ | `^1[3-9]\d{9}$` | |
| `smsCode` | string | ✅ | `^\d{4}$` | |
| `clientId` | string | ⬜ | ≤32 白名单 | 会话身份 |
| `agreementAccepted` | **literal true** | ✅ | 只接受 JSON `true` | 协议凭证；传 `"true"`/1 都报 `40101008`。**前端应在未勾选时禁用按钮** |
| `nickname` | string | ⬜ | 1~30 字符 | 仅注册时生效（如「用户8000」），老用户忽略 |
| `deviceInfo` | object | ⬜ | 见下 | consent 留痕 + 设备元数据 |

`deviceInfo` 子字段（全可选）：`platform`（enum `ios`/`android`/`mini`/`h5`，App 传 `"android"`）、`deviceModel`（≤64）、`deviceFingerprint`（≤128）。

**响应 data**（新老用户统一结构）：

```json
{
  "tokens": { "access": "<JWT·2h>", "refresh": "<JWT·30d>" },
  "expiresAt": { "access": "2026-09-11T12:00:00.000Z", "refresh": "2026-10-11T..." },
  "user": { /* 全字段见 1.4.1 */ },
  "token_expiring": false
}
```

### 1.4.1 `user` 全字段表（`toOAuthUserOutput` 真源，共 10 字段）

| 字段 | 类型 | 说明 / 前端用途 |
|---|---|---|
| `id` | string (cuid) | 用户主键，业务 API 的 userId |
| `nickname` | string | 注册时可传 nickname，否则系统生成 |
| `avatarUrl` | string | null | 头像（微信渠道有值；手机号注册为 null） |
| `phone` | string | null | **脱敏**（`138****8000`）；仅展示，不可当真值回传 |
| `memberLevel` | number | 会员等级（0=免费），控制会员 UI |
| `onboardingDone` | boolean | **路由分流**：`false` → Onboarding 向导；`true` → 主页。⚠️ **V2 实况（先跑通阶段）**：sms-login 暂以 `isNewUser` 替代「注册当次」分流，落库阶段接回本字段真分流 |
| `isNewUser` | boolean | **V2 扩展**：注册当次 `true`（仅 sms-login 响应携带；后续登录/rotate 均 `false`/不携带）→ 前端弹「完善基础资料」弹窗（向导 step1） |
| `profileCompleteLv` | number | 资料补充等级（1/2/2+），驱动 ProfileLevelCard |
| `profileCompletionPct` | number | 资料完成度 0~100 |
| `hasWechatMobile` | boolean | 是否绑定微信（App 渠道）—— 控制「绑定/解绑微信」入口显隐 |
| `hasWechatMini` | boolean | 是否绑定微信（小程序渠道） |

> 未返回字段（openid/unionid/openidMobile/openidMini）为服务端内部标识，**前端永远拿不到也不需要**。
>
> ⚠️ **V2 实现实况（2026-09-17）**：sms-login 当前仅返回 `id / nickname / phone / isNewUser`（V2 两阶段迁移中；上表 `avatarUrl / memberLevel / onboardingDone / profileComplete* / hasWechat*` 随 user 模块落库阶段补齐 — 参见 user 模块 `updateBasicProfile`）。

**错误分支（全量，按服务端检查顺序）**：

| 层 | HTTP | code | 场景 | 处置 |
|---|---|---|---|---|
| Zod | 400 | — | 含 `agreementAccepted` 非 literal true | 前端禁按钮预防 |
| consent | 200 | `40101008` | 协议未同意 | 提示勾选 |
| 短信码 | 200 | `40101001` | 码不存在/过期/已被 3 次错作废 | 提示重发（回 1.2） |
| 短信码 | 200 | `40101003` | **码错误**（msg=「短信验证码错误」） | 提示错误 + 显示剩余尝试提示（3 次错作废） |
| 封禁 | 403 | — | 账号被封禁 | 提示联系客服 |

**注意**：短信码「错」与「不存在」是**两个不同 code**（40101003 vs 40101001），UI 文案应区分（错 → 可继续试；不存在 → 必须重发）。

***

## 1.5 callback —— 微信登录回调

**前置条件**：微信 OpenSDK 授权成功拿到 `code`（一次性，5min）
**调用时机**：登录页点微信按钮 → SDK 回调

**请求 payload**：

| 字段 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|
| `code` | string | ✅ | 1~128 | 微信授权码 |
| `source` | string | ✅ | `"app"` | `"mini"` | **App 端固定 `"app"`**（开放平台移动应用凭据） |
| `clientId` | string | ⬜ | ≤32 白名单 | |
| `agreementAccepted` | literal true | ✅ | 同 1.4 | |
| `nickname` / `avatarUrl` | string | ⬜ | ≤30 / URL ≤512 | 微信侧未返回时前端补传 |
| `deviceInfo` | object | ⬜ | 同 1.4 | |

**响应 data**：与 1.4 完全一致（`buildAuthResponse` 统一结构）。

**unionid 合并**（前端无感）：同微信 unionid 的 app/mini 双渠道自动合并同一账号 —— 用户先在 App 微信登录、后在小程序微信登录，是同一个账号。

**错误分支**：

| code | 场景 | 处置 |
|---|---|---|
| `40101010` | 微信接口失败 / **凭据未配置** | 提示「微信登录暂不可用」，手机号登录兜底 |
| `40101007` | 绑定失败（code 无效/已用） | 提示重试（重新走 SDK 授权） |
| `40101008` | 协议未同意 | 同 1.4 |

***

## 1.6 rotate —— 刷新 token（全局拦截器核心）

**鉴权**：`Authorization: Bearer <refresh_token>`（**不是 access**）
**调用时机**：① 任何业务请求 401；② `token_expiring=true`（access 剩余 <**30min**）主动续期

**请求**：`{ "action": "rotate", "payload": {} }`

**服务端验证链**：RS256 验签 → 按 `cid` claim 反查 DeviceBinding（`oauth:{cid}`）→ 校验 `status=active` → **库内密文解出与传入 token 双向比对** → 用户存在且未封禁 → 签发新双 token 并覆盖库内密文（旧 refresh 自然作废）。

**响应 data**：与 1.4 的 tokens/expiresAt 同构 + `user` + `token_expiring`。

**错误分支**：

| code | 场景 | 处置 |
|---|---|---|
| `40101011` | 验签失败 / binding 非 active / 密文缺失 / 用户不存在 | **清空本地会话 → 登录页，禁止重试** |
| `40101012` | 签名合法但与库内不匹配 = **旧 refresh 二次使用** | ⚠️ 服务端已**连坐吊销该设备会话**，前端唯一动作：清会话进登录页 |

***

## 1.7 logout —— 登出

**鉴权**：`Authorization: Bearer <access_token>`

**请求 payload**：

| 字段 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|
| `scope` | string | ⬜ | enum `device`（**默认**）| `all` | |
| `clientId` | string | ⬜ | ≤32 白名单 | **scope=device 时必传**（见下方语义） |

**响应 data**：`{ "ok": true, "scope": "all", "revoked": 3 }`（revoked = 吊销会话数）

**scope 语义（精确）**：

| scope | 服务端动作 | 连坐范围 |
|---|---|---|
| `device` | 吊销 `oauth:{clientId ?? 'default'}` 这**一个**会话 | ⚠️ 不传 clientId 时吊销的是 `oauth:default` —— 如果多设备都用缺省值，会**一起被登出**。传 clientId 才是「只登本设备」 |
| `all` | 吊销该用户**全部** `oauth:*` 会话 | 所有设备的 OAuth 会话全下线；**不影响**设备厂商绑定（garmin:/honor: 等非 oauth: vendor） |

**前端要点**：无论成败（含 401），本地一律清空 token 进登录页（登出语义本地优先）。

***

## 1.8 bind-phone / unbind —— 绑定 / 解绑手机号与微信

### bind-phone（Bearer access）

微信登录用户补手机号。payload = `phone`（`^1[3-9]\d{9}$`）+ `smsCode`（`^\d{4}$`）+ `clientId`（可选）。先走 1.2 发码。
响应：`{ "ok": true, "token_expiring": false }`

| code | 场景 | 处置 |
|---|---|---|
| `40101001` | 短信码不存在/过期 | 重发 |
| `40101003` | 短信码错误（3 次错作废规则同） | 提示 |
| Zod 400 | phone/smsCode 格式错 | 修正表单 |

### unbind（Bearer access）

**请求 payload**：

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `target` | string | ⬜ | enum `"mobile"` | `"mini"`；**缺省 = mobile+mini 两者全解**（⚠️ 不是「按当前账号判断」） |

**硬约束**：解绑后必须仍保留**至少一种登录方式**（手机号或任一微信渠道），否则 `40101007`。
**响应**：`{ "ok": true, "token_expiring": false }`

***

## 1.9 mySessions —— 登录设备/会话列表（V2.0.40+）

**前置条件**：`Authorization: Bearer <access_token>`
**频率限制**：无特殊限制

**请求**：

```json
{ "action": "mySessions", "payload": {} }
```

**响应 data**（⚠️ 实测结构：`sessions` 包一层数组）：

```json
{
  "sessions": [
    {
      "clientId": "android:550e8400-e29b-41d4-a716-446655440000",
      "deviceName": "Xiaomi 23049RAD8C",
      "lastActiveAt": "2026-09-17T00:23:48.586222",
      "expiresAt": "2026-12-16T00:23:48.586222",
      "current": true
    }
  ]
}
```

| 字段 | 类型 | 说明 |
|---|---|---|
| `clientId` | string | 会话标识（复合格式 `类型:设备UUID`；存量旧格式无 `:` 为类型级共享） |
| `deviceName` | string|null | 登录时 `deviceName` 传入的会话展示名（旧会话可能为 null） |
| `lastActiveAt` | string (ISO) | 最近活跃时间（rotate 或登录时间） |
| `expiresAt` | string|null | 设备信任 90 天滑动窗口到期 |
| `current` | boolean | **当前设备标记（字段名是 `current`，不是 `isCurrent`）** |

**前端要点**：

* 设备上限 10 台（LRU 自动淘汰最旧，当前设备豁免）— UI 建议展示该上限提示
* 列表按 lastActiveAt 降序返回
* `current=true` 的设备不可（也不应）对自己 revokeSession

***

## 1.10 revokeSession —— 踢下线指定会话（V2.0.40+）

**前置条件**：`Authorization: Bearer <access_token>`（⚠️ 是 access，不是 refresh）

**请求**：

```json
{ "action": "revokeSession", "payload": { "target": "android:550e8400-..." } }
```

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `target` | string | ✅ | 目标会话的 clientId（**权威键**；`clientId` 为兼容旧键，二选一） |

**响应 data**：`{ "revoked": "android:550e8400-..." }`

**错误分支**：

| code | HTTP | 场景 |
|---|---|---|
| `40001001` | 400 | payload 缺 target（V2.0.41 及之前会误返 `40201007` 兜底） |
| `40101015` | 401 | 目标会话不存在或已下线（**业务性 401** — 前端 toast 即止，勿触发 rotate/登出逻辑） |

**前端要点**：

* 被踢设备下次任意请求 → 其 refresh 已 revoke → `40101011/40101012` → 强登出
* 操作者自身会话不受影响（设备级隔离）
* UI 必须二次确认（踢下线是不可逆动作，对端需重新完整登录）

**前端要点**：`hasWechatMobile` + `hasWechatMini` + `phone` 三个字段决定解绑入口显隐与 target 传法 —— 只绑了一个渠道时解绑它会被拒（`40101007`），UI 应提前禁用并解释。
