---
url: /docs/guide/uniapp/api/oauth.md
---
# OAuth 登录链（前端调用视角 · 7 端点）

> 本文只回答「哪个页面在什么时机调、响应从哪一层读、失败时前端做什么」。
> **字段类型 / 可空 / 错误码码值 / curl 一律见 [前端联调 API 手册 · login](/api-docs/v1/frontend/login)** —— 本文零复述（分工边界见 [总览](./index.md)）。
> 封装：`src/api/login.ts`（6 个函数）；`rotate` 由 `src/utils/request.ts` 拦截器自动调用，**业务代码不手写**。
> 消费页：[pages/login](../pages/login.md) · [pages/mine-account-security](../pages/mine-account-security.md) · [pages/mine-account-security-device-sessions](../pages/mine-account-security-device-sessions.md)

## 调用链总览

```
登录页 onLoad ──► captcha（图形码）
   ↓ 用户输手机号 + 图形码
sendSmsCode ──► sms-send（图形码一次性消费；成功后前端起 60s 倒计时）
   ↓ 用户收码（真实短信下发；联调环境的固定码捷径见 sms-login 坑位）
smsLogin ──► sms-login（未注册自动注册）→ 写 4 个 storage 键
   ↓ user.isNewUser === true（仅注册当次）→ 弹三页向导，见 [api/user](./user.md)
任意业务请求遇 HTTP 401
   └─► rotateOnce()（Bearer refresh，并发单飞）→ 成功重放原请求一次；失败清会话回登录页
账号与安全页：mySessions → revokeSession（踢指定设备）· logout（⚠️ 见文末缺陷 O1）
```

7 个 action 全在 **`POST /api/v1/auth/oauth`**（`{action, payload}` 协议），无独立 URL。

## 一、captcha（`getCaptcha`）

**一句话**：登录页进入时拉一次图形码 —— 给「获取验证码」按钮做前置人机校验。

**前端函数签名**
| 项 | 值 |
|---|---|
| 封装 | `src/api/login.ts:19-20` → `getCaptcha()`（无入参） |
| 触发时机 | 登录页 `onLoad`（`src/pages/login/index.vue:156`）；不进页面不拉 |
| 鉴权 | `none`（登录前无 token） |

**必需 payload 键**
| 键 | 从哪来 | 必填 |
|---|---|---|
| `action` | 字面量 `'captcha'`（函数内写死） | ✔ 自动 |
| `payload` | `{}`（该 action 无入参） | ✔ 自动 |

**响应读法**

```ts
const res: any = await getCaptchaApi();
captchaId.value = res.data.captchaId;  // 中文注解：发码时要原样带回，必须存住
captchaUrl.value = res.data.png;       // 中文注解：png 是完整 data:image/png;base64，直接喂 <image :src>
```

* **`svg` 本链路不用**：App 端 `rich-text` 渲染 SVG 不可靠 —— 服务端直出 PNG 就是为绕开它（详见 [平台差异坑速查表](../index.md#平台差异坑速查表-app-h5-小程序)）。
* 失败只 toast（`验证码拉取失败: <msg|code>`），**无自动重试** —— 靠用户点图重拉。

**失败时的前端动作**
| 情形 | 前端动作 |
|---|---|
| 拉取失败（网络 / 限流） | 停在页面，点图重拉 —— 码值与触发条件见 [手册 · captcha](/api-docs/v1/frontend/login#captcha) |

**⚠️ 坑位**

* 图形码**一次性 + 有时效**：`sms-send` 会消费它；发码失败要重新拉图，拿旧 `captchaId` 重试必然失败。
* 触屏重拉必须绑**具名方法**（`@click="getCaptcha"`），模板内联箭头在 App 端崩（`t.__f__` —— [铁律 3](../index.md#关键铁律-新人必读-血泪沉淀)）。

## 二、sms-send（`sendSmsCode`）

**一句话**：用户点「获取验证码」→ 图形码 + 手机号换一条短信码。

**前端函数签名**
| 项 | 值 |
|---|---|
| 封装 | `src/api/login.ts:23-31` → `sendSmsCode({ phone, captchaId, captchaCode })` |
| 触发时机 | 点「获取验证码」（`src/pages/login/index.vue:179`）；页面自己拦倒计时/手机号/图形码 |
| 鉴权 | `none` |

**必需 payload 键**
| 键 | 从哪来 | 必填 |
|---|---|---|
| `phone` | 登录页输入框 | ✔ |
| `captchaId` | `captcha` 响应（内存里的 `captchaId`） | ✔ |
| `captchaCode` | 登录页图形码输入框 | ✔ |
| `clientId` | `currentClientId()` → `src/utils/device.ts` 自动生成 `android:{UUID}` | ✔ **自动** |

**响应读法**

```ts
sendSmsCodeApi({ phone, captchaId, captchaCode })
  .then(() => { countdown.value = 60; /* 中文注解：60s 是前端自限，同时后端也有 60s/1 的硬限流 */ });
```

* 响应体本前端**不读字段**（只判 resolve/reject）—— 界面语义 = 「成功即起倒计时」。

**失败时的前端动作**
| 情形 | 前端动作 |
|---|---|
| 图形码错 / 已过期 / 已被消费 | toast → 用户点图重拉并重填 —— 码值见 [手册 · sms-send](/api-docs/v1/frontend/login#sms-send) |
| 触发限流（IP / 手机号 60s / 手机号 24h 三层） | toast 后端文案（「发送间隔过短，请 60 秒后再试」/「今日发送次数已达上限」）；**前端倒计时不能替代后端限流**，别据此判断"能发" |

**⚠️ 坑位**

* **倒计时是 60s**（`login/index.vue:194`）—— 早期文档/生成页写的 58s 是错的，别照抄。
* 倒计时只在**本次页面生命周期**内有效（`onBeforeUnmount` 清 timer，重进页面归零），与后端限流不是一回事。

## 三、sms-login（`smsLogin`）

**一句话**：登录页「登录」按钮 → 短信码换 token + 用户信息；`user.isNewUser === true` 时**当次**弹三页向导。

**前端函数签名**
| 项 | 值 |
|---|---|
| 封装 | `src/api/login.ts:34-50` → `smsLogin({ phone, smsCode, nickname?, deviceName? })` |
| 触发时机 | 输入 6 位短信码 + 勾选协议 + 点「登录」（`src/pages/login/index.vue:206`） |
| 鉴权 | `none`（登录前无 token） |

**必需 payload 键**（函数内部自动补 `clientId` / `agreementAccepted`）
| 键 | 从哪来 | 必填 |
|---|---|---|
| `phone` | 登录页输入框 | ✔ |
| `smsCode` | 登录页输入框（6 位） | ✔ |
| `clientId` | `currentClientId()`（`android:{设备UUID}`） | ✔ **自动** |
| `agreementAccepted` | **硬编码 `true`**（UI 门禁：未勾协议按钮直接 return） | ✔ **自动** |
| `deviceName` | `getDeviceName()`（如 `Xiaomi 2210132C`，会话列表展示名） | ✖ |
| `nickname` | 本链路**不传**（服务端给新用户生成「用户XXXX」） | ✖ |

**响应读法**

```ts
const res = await smsLogin({ phone, smsCode, deviceName: getDeviceName() });
// 1) 双 token —— 三个键都要存，缺一个 rotate 链就断
storage.token.set(res.data.tokens.access);            // 中文注解：业务请求用这个
storage.refreshToken.set(res.data.tokens.refresh);    // 中文注解：rotate 用这个做 Bearer（不是 access）
storage.tokenExpires.set(res.data.expiresAt || {});   // 中文注解：{access, refresh} 两个到期时间
// 2) 用户对象 —— 后续 finishWizard 要用 user.id 调 training/enroll
storage.user.set(res.data.user);
// 3) 新用户分流 —— isNewUser 只在"注册当次"为 true
if (res.data.user.isNewUser) showProfileSetup.value = true;   // 弹向导，不弹"登录成功"
else uni.switchTab({ url: '/pages/index/index' });
```

**响应真值**
| 路径 | 含义 |
|---|---|
| `data.tokens.access` / `.refresh` | 双 token（`refresh` 每次 rotate 后会被换掉，**务必覆盖存储**） |
| `data.expiresAt` | `{access, refresh}` 到期时间（ISO 字符串） |
| `data.user.id` | **`u0000xx` 字符串**（不是数字主键）—— 当 `userId` 透传给 training 等域 |
| `data.user.phone` | **脱敏**号（`138****8000`），不是原号 |
| `data.user.isNewUser` | 注册当次 `true` |
| `data.token_expiring` ⚠️ | **孤儿键**：snake\_case，与全表 camelCase 不一致 —— 前端不消费，勿当命名规范 |

> 完整字段表 / 错误码见 [手册 · sms-login](/api-docs/v1/frontend/login#sms-login)。

**失败时的前端动作**
| 情形 | 前端动作 |
|---|---|
| 短信码不存在 / 过期 / 错 3 次已作废 | 提示「验证码已失效」→ 回 `sms-send` 重发 |
| 短信码错误（未到 3 次） | toast，**不重发**（后端已记一次错，3 次销毁） |
| 协议未接受 | 不正常出现（前端已硬编码 `true`）；若出现提示勾选协议 |
| 账号封禁 | 「账号已被封禁，请联系客服」，**禁止重试** —— 该档 HTTP 状态与其余档不同，见 [手册 G4](/api-docs/v1/frontend/#g4-两套错误契约并存) |
| 通用「服务异常」 | ⚠️ **先怀疑 `clientId` 格式**（见下），不要先怀疑后端挂了 |

**⚠️ 坑位**

* **`clientId` 复合格式只允许一个冒号**：`android:550e8400-…` ✔ / `android:a:b` ✘（正则 `^[A-Za-z0-9_-]{1,20}:[A-Za-z0-9-]{1,40}$`）。
  写成三段式时，`ClientId` 构造抛的是**裸 `ValueError`**（不是参数校验错误）→ 落到全局兜底 handler → 前端看到的是**通用异常码 `40201007`（"服务异常，请稍后重试"）**。
  机制：全仓只有 oauth 与 admin 两个域注册了 `Exception` 兜底 handler，**admin 后注册故生效**（`main.py` 661 行 oauth → 662 行 admin）—— 排障看到 `40201007` 先查 `clientId`，别去翻 admin 日志。
* **发码与登录必须同一个 `clientId`**（短信码按 `(phone, clientId)` 隔离存储）—— 由 `currentClientId()` 保证，**不要手写 clientId**；清缓存/卸载重装会换 UUID（旧会话由后端 LRU 自然淘汰）。
* **`X-Env` 全程必须一致**：token 绑 `X-Env`，跨库即失效（表现像"token 无效"）。
  App 产线包在 `src/config/index.ts` 固定 `Prd`，并由 `build-prod.sh` 构建期硬断言 —— 与 [手册 G2](/api-docs/v1/frontend/#g2-请求头) 一致。
* **验证码只认真实下发**：生产环境**没有任何固定码 / 万能码绕过**，必须收到真实短信才能登录。
  联调环境是否开启「固定码」捷径由**运维配置**（属内部信息，不在公开文档说明）——
  需要时**找运维要码**，不要假设某个值可用。

## 四、rotate（`request.ts` 拦截器自动调用，业务代码不手写）

**一句话**：任意带 `Bearer access` 的请求遇到 HTTP 401 → 拦截器用 refresh 换一对新 token → 成功后**重放原请求一次**。

**前端函数签名**
| 项 | 值 |
|---|---|
| 封装 | `src/utils/request.ts:49-86` → `rotateOnce()`（**并发单飞**：多个 401 共享同一次续期） |
| 触发时机 | 业务请求收到 **HTTP 401** 且非「分档码」时自动触发 —— ⚠️ 拦截器**不看鉴权档位**（`auth: 'none'` 的请求若真收到 401 也会走这条链），只看状态码 |
| 鉴权 | **`Bearer refresh`**（用 refresh token，不是 access） |

**必需 payload 键**
| 键 | 从哪来 | 必填 |
|---|---|---|
| `action` | 字面量 `'rotate'`（拦截器内写死） | ✔ 自动 |
| `payload` | `{}` | ✔ 自动 |

**响应读法**

```ts
// 拦截器内部（业务代码不要复制这段）
if (res.statusCode === 200 && body?.code === '0' && body?.data?.tokens) {
  storage.token.set(body.data.tokens.access);
  storage.refreshToken.set(body.data.tokens.refresh);   // 中文注解：refresh 也会被旋转，必须覆盖
  storage.tokenExpires.set(body.data.expiresAt || {});  // 中文注解：缺字段时会被写成 {}
  // → 重放原请求一次（_retry 标记，防死循环）
}
```

* **401 分档**（`request.ts` 顶部的两个集合，前端唯一分支点）：强登出码 → 清 storage 回登录页（不 rotate）；业务性 401 码（如 revokeSession 目标不存在）→ 仅 toast（不 rotate、不登出）；其余 → rotate。

**失败时的前端动作**
| 情形 | 前端动作 |
|---|---|
| rotate 成功 | 重放原请求一次；再 401 则强制登出（`_retry` 防死循环） |
| rotate 失败 / 本地无 refresh | 清会话 → toast「登录已失效」→ 回登录页 |
| 强登出码 | 直接清会话回登录页，不做续期尝试 |

**⚠️ 坑位**

* **`expiresAt` 存疑（待确认）**：矩阵 ep4 记「rotate 响应不返回 `expiresAt`」，但后端源码 `src/app/oauth/interface/api/oauth.py` 的 rotate 分支已于 2026-09-20（`e935a47`）补上该字段并注明"契约修复"。
  卡在哪：矩阵未随该修复同步，生产 v2.0.49 的 L4 对该字段是非强断言，**无法据此判定线上是否已返回**。
  前端影响：若确实缺失，`|| {}` 会把本地过期表**静默清空**（当前无读取方，故无实害）。
* 双库语义：rotate 也带 `X-Env`，**换库（Dev↔Prd）后旧 refresh 一律失效** —— 这不是 bug，是 token 绑库设计。

## 五、mySessions（`listSessions`）

**一句话**：账号与安全 → 登录设备管理页 `onShow` 拉会话列表。

**前端函数签名**
| 项 | 值 |
|---|---|
| 封装 | `src/api/login.ts:53-58` → `listSessions()`（无入参） |
| 触发时机 | `device-sessions.vue:72-73` `onShow`（每次进页/回页都拉） |
| 鉴权 | `Bearer access` |

**必需 payload 键**
| 键 | 从哪来 | 必填 |
|---|---|---|
| `action` | 字面量 `'mySessions'`（函数内写死） | ✔ 自动 |
| `payload` | `{}` | ✔ 自动 |

**响应读法**（⚠️ 有 **`sessions` 包裹键** —— 少了它拿整个 data 当数组 `.map` 会远端报错）

```ts
const res: any = await listSessions();
sessions.value = res?.data?.sessions || [];   // 中文注解：不是 data 本身，也不是 .list（那是 device 域的键）
```

| 路径 | 含义 |
|---|---|
| `data.sessions[].clientId` | 会话键（`android:{uuid}`）—— `revokeSession` 的 `target` 就用它 |
| `data.sessions[].deviceName` | 展示名（`null` → 页面兜底「未知设备」） |
| `data.sessions[].lastActiveAt` | 「最近活跃」时间（ISO；页面本地格式化，解析失败原样显示） |
| `data.sessions[].current` | `true` = 本机 → **不显示「下线」按钮**（防自踢） |

**失败时的前端动作**
| 情形 | 前端动作 |
|---|---|
| 任意失败 | `catch` 空处理，列表停在旧值/空态（request 层已 toast）—— 码值见 [手册 · mySessions](/api-docs/v1/frontend/login#mysessions) |

**⚠️ 坑位**

* 页面文案「最多保留 10 台设备，超出后最早登录的设备将被自动下线」是**产品口径**；淘汰由后端 LRU 执行，前端不参与判断。

## 六、revokeSession（`revokeSession`）

**一句话**：设备列表点「下线」→ 二次确认后踢掉该设备。

**前端函数签名**
| 项 | 值 |
|---|---|
| 封装 | `src/api/login.ts:61-66` → `revokeSession(target)` |
| 触发时机 | `device-sessions.vue:92`（弹窗确认后） |
| 鉴权 | `Bearer access` |

**必需 payload 键**
| 键 | 从哪来 | 必填 |
|---|---|---|
| `target` | 列表行的 `s.clientId`（**本机行不渲染按钮**） | ✔ |

**响应读法**

```ts
await revokeSession(s.clientId);   // 中文注解：成功即 toast + 重拉列表，响应字段前端不消费
loadSessions();                    // 中文注解：重拉而非本地删除 —— 以服务端为准
```

**失败时的前端动作**
| 情形 | 前端动作 |
|---|---|
| 目标会话不存在 | **该档是业务性 401**：request 层只 toast，**不 rotate、不登出**（操作者会话不受影响）；列表重拉后该行自然消失 —— 见 [手册 · revokeSession](/api-docs/v1/frontend/login#revokesession) |
| 其他失败 | request 层已 toast，`catch` 空处理 |

## 七、logout（`logout`）

**一句话**：设计意图是「退出登录时同时吊销服务端该设备会话」——⚠️ **当前前端没有任何调用点**（见缺陷 O1）。

**前端函数签名**
| 项 | 值 |
|---|---|
| 封装 | `src/api/login.ts:69-74` → `logout()`（payload 恒为 `{}`） |
| 触发时机 | **无** —— 账号与安全页「退出登录」只做本地清理（`src/pages/mine/account-security/index.vue:79-88`） |
| 鉴权 | `Bearer access` |

**必需 payload 键**
| 键 | 从哪来 | 必填 |
|---|---|---|
| `action` | 字面量 `'logout'`（函数内写死） | ✔ 自动 |
| `payload` | `{}` —— ⚠️ **没传 `clientId`**，这正是 O1 的根因之一 | ✖ |

**响应真值**
| 路径 | 含义 |
|---|---|
| `data.ok` / `data.scope` / `data.revoked` | 前端**均不消费**（当前无调用点；且 `revoked` 在 `scope=device` 下恒为 `1`，**不代表真的吊销到了行**） |

**失败时的前端动作**
| 情形 | 前端动作 |
|---|---|
| 当前实际行为 | 不调接口，只 `storage.clearStorage()` + `reLaunch` 登录页 —— 见下 |

**⚠️ 坑位 · 已知缺陷（台账口径：**联调再修**，本文不改代码）**

* **O1 · 服务端从未被通知登出**：① `logout()` 无调用点；② 即便调，`payload: {}` 会让后端吊销 `oauth:default`（不存在的行 → 静默 no-op），真实会话键是 `oauth:android:{uuid}`。
  后果：设备仍在会话列表里显示在线；**refresh token 仍可继续 rotate**（access 本身会因 denylist 即时失效）。前端"清本地"掩盖了这一切。
* **O2 · 更硬的一层（本地清理本身也会抛）**：`storage.clearStorage()` 调了 `storage.user.remove()`，而 `storage` 的 `user` 只实现了 `get/set`（`src/utils/storage.ts:108` vs `:71-80`）→ **运行时 TypeError** → 其后的 toast 与 `reLaunch` 都不执行。
  ⇒ 表现：**点「退出登录」不跳页；被踢下线（strong-logout 码）也不会被带回登录页**（token 已清、页面卡住、后续请求继续 401）。
  修法（前端侧，不在本轮）：给 `storage.user` 补 `remove()`，或给 `clearStorage` 加逐项保护。详见 [联调台账](/internal-wiki/operations/frontend-integration-issues)。

## 关键前端语义（跨端点）

* **`request` 不解包**：响应是完整信封，业务字段一律 `res.data.*`（漏 `?.data` = 远端 TypeError，已有真机实锤 —— [铁律 1](../index.md#关键铁律-新人必读-血泪沉淀)）。
* **两套失败契约并存**：本域（oauth）失败多为 **HTTP 200 + 业务码非 `"0"`**；user / device / training / stats / upload 域失败多为 **HTTP 400/401/404 + 码**。前端统一由 `request.ts` 判定（非 `"0"` 即 reject + toast），**业务代码不要自己解析码值** —— 见 [手册 G4](/api-docs/v1/frontend/#g4-两套错误契约并存)。
* **storage 键名**（`src/utils/storage.ts`）：`key_token` / `key_refresh_token` / `key_token_expires_at` / `key_user_info` / `key_client_id` / `key_device_uuid` / `key_device_name`。
  `clearStorage()` **刻意保留 `deviceId` / `clientId`**（设备身份不随会话失效 —— 重登仍落到同一设备槽位）。
* **401 分档的判定集合在 `request.ts` 顶部**，新增码值只能改那里，别在页面里散写。

## 后端链接

* 契约（权威）：[手册 · login](/api-docs/v1/frontend/login) · [手册 · 通用约定 G1-G7](/api-docs/v1/frontend/#通用约定-7-条-·-所有端点共用)
* 从零施工（拷 SDK / 自定义基座 / 微信登录 manifest）：[OAuth 登录链落地手册](/api-docs/v1/oauth/uniapp-dev-guide)（其 §7 平台差异表已并入 [本目录铁律](../index.md#平台差异坑速查表-app-h5-小程序)）
* 缺陷与决策台账：[联调台账](/internal-wiki/operations/frontend-integration-issues) · 双库路由 `X-Env`：[comparison.md](../comparison.md)
* 启动链上游（登录前最后一步）：[api/splash](./splash.md) —— 启动页免登录判断 token 有无后才落到本页登录流程
