---
url: /docs/api-docs/v1/frontend/login.md
---
# 前端联调 · 登录认证（login.ts）

> 归属：[前端联调 API 手册](./) → `src/api/login.ts`（6 端点）+ `src/utils/request.ts` rotateOnce 拦截器（1 端点）
> 端点：**7 个** · 全部为 oauth 域 **action 协议**（`POST /api/v1/auth/oauth`）
> 错误码段：`40101xxx` / `40001001` —— 全表见 [错误码注册表](/ai-contexts/error-code-registry)
> 取值语义：**[取值速查 · oauth](./values#oauth)** —— `clientId` 两种形态的区别、`scope` 的分支、`isNewUser` / `token_expiring` / `revoked` 的真实含义与缺省行为
> 深入说明：认证链细节（验证码三定律 / 限流三层 / clientId 语义 / 状态机）见 [OAuth API Docs](/api-docs/v1/oauth/)，本页只留接口级摘要

## 本页 curl 变量

```bash
BASE_URL=https://api.qingmulife.cn   # 本地：http://localhost:3000
ACCESS=<access_token>                # rotate 换成 REFRESH
REFRESH=<refresh_token>
ENV=Prd                              # Dev | Prd（大小写不敏感）—— 全程必须一致
```

> ⚠️ action 协议统一入口：`{"action": "<名称>", "payload": {...}}`。`payload` 必须是**对象**（多传字段会被忽略，非对象 → `40001001`）。

## 端点一览

| # | action | 鉴权 | 场景 |
|---|---|---|---|
| [1](#captcha) | `captcha` | 无 | 登录页进入 / 任何失败分支后刷新图形码 |
| [2](#sms-send) | `sms-send` | 无 | 点「获取验证码」 |
| [3](#sms-login) | `sms-login` | 无 | 手机号登录 / 注册 |
| [4](#rotate) | `rotate` | **Bearer refresh** | 拦截器自动续期 |
| [5](#mysessions) | `mySessions` | Bearer access | 设备会话列表页 |
| [6](#revokesession) | `revokeSession` | Bearer access | 踢掉某个设备 |
| [7](#logout) | `logout` | Bearer access | 设置页退出登录 ⚠️ 有已知缺陷 |

***

## captcha

**一句话**：取图形验证码 —— 登录页进入时拉一次，**任何失败分支后都要重拉**（一次性，成败皆消费）。

### curl

```bash
curl -s "$BASE_URL/api/v1/auth/oauth" \
  -H 'Content-Type: application/json' \
  -H 'X-Env: Prd' \
  -d '{"action":"captcha","payload":{}}'
```

### 请求

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `action` | string | ✔ | 固定 `"captcha"` |
| `payload` | object | ✖ | 忽略（captcha 无入参），传 `{}` |

### 响应 `data`

| 字段 | 类型 | 可空 | 说明 |
|---|---|---|---|
| `captchaId` | string (UUID) | 否 | 凭据，**120s 有效 + 一次性**；`sms-send` 要回传 |
| `png` | string | 否 | **完整 dataURL**（`data:image/png;base64,…`）—— 前端 `:src` 直接渲染原生 `<image>` |
| `svg` | string | 否 | SVG 源码，仅回退用途 |

> ⚠️ **渲染三定律**（真机实测，勿再踩）：① 不要用 `svg-icon` 组件承载验证码（APP 端 renderjs 对动态 svg/dataURL 均渲染失败）；② Android 原生 image **不渲染** `data:image/svg+xml`；③ 唯一正解 = **原生 `<image>` + `png` 字段 + 后端 ≥ V2.0.43**。详见 [OAuth actions#captcha](/api-docs/v1/oauth/actions)。

### 错误码

| 业务码 | HTTP | 触发条件 | 前端建议处置 |
|---|---|---|---|
| `40101005` | 200 | 同 IP 60s 内超 30 次 | 提示「操作过于频繁」，倒计时禁用 |
| `40001001` | 400 | `payload` 不是对象 | 修正请求体 |
| `50001001` | 200 | 服务异常 | toast + 重试 |

***

## sms-send

**一句话**：发短信验证码 —— 登录页 / 绑定手机页点「获取验证码」。

### curl

```bash
curl -s "$BASE_URL/api/v1/auth/oauth" \
  -H 'Content-Type: application/json' \
  -H 'X-Env: Prd' \
  -d '{"action":"sms-send","payload":{
        "phone":"13800138000",
        "captchaId":"<captchaId>",
        "captchaCode":"ABCD",
        "clientId":"android:550e8400-e29b-41d4-a716-446655440000"
      }}'
```

### 请求

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `action` | string | ✔ | 固定 `"sms-send"` |
| `payload.phone` | string | ✔ | 手机号 |
| `payload.captchaId` | string | ✔ | 上一步 `captcha` 返回的凭据 |
| `payload.captchaCode` | string | ✔ | 用户填的图形码 |
| `payload.clientId` | string | ✔ | **`{类型}:{设备UUID}`** —— 见下方坑位；缺省回退 `"default"` |

> 🔴 **`clientId` 复合格式只允许一个冒号**：`android:550e8400-…` ✔ / `android:a:b` ✘。
> 写成三段式被拒时返回的是**通用异常码 `40201007`（"服务异常"）而**不是参数错误 —— 排障时看到 `40201007` 先怀疑 `clientId` 格式。
> 正则：`^[A-Za-z0-9_-]{1,20}:[A-Za-z0-9-]{1,40}$`。[台账 #3](/internal-wiki/operations/frontend-integration-issues)
>
> **发码与登录必须用同一个 `clientId`**（短信码按 `(phone, clientId)` 隔离存储）。

### 响应 `data`

| 字段 | 类型 | 可空 | 说明 |
|---|---|---|---|
| `ok` | boolean | 否 | **恒 `true`** —— 失败是抛业务码，不会返 `false` |

### 错误码

| 业务码 | HTTP | 触发条件 | 前端建议处置 |
|---|---|---|---|
| `40101004` | 200 | 图形码 > 120s | 静默刷新 captcha，提示已过期 |
| `40101003` | 200 | 图形码错误 | 刷新 captcha + 清空输入框 |
| `40101005` | 200 | 限流三层（IP 30/min · 手机号 60s/1 · 手机号 24h/10） | 按 `msg` 区分文案，倒计时保持禁用 |
| `40101006` | 200 | 短信通道故障 / 凭据未配置 | toast「短信通道暂不可用」+ 重拉 captcha |
| `40201007` | 200 | **`clientId` 格式非法（常见）** | **不要当后端故障** —— 检查 `clientId` 是否多冒号 |
| `40001001` | 400 | 入参校验失败 | 修正表单 |

***

## sms-login

**一句话**：手机号登录 / 注册（不存在的号码自动建号）—— 登录页点「登录」。

### curl

```bash
curl -s "$BASE_URL/api/v1/auth/oauth" \
  -H 'Content-Type: application/json' \
  -H 'X-Env: Prd' \
  -d '{"action":"sms-login","payload":{
        "phone":"13800138000",
        "smsCode":"1234",
        "clientId":"android:550e8400-e29b-41d4-a716-446655440000",
        "agreementAccepted":true,
        "deviceName":"常智的 Android"
      }}'
```

### 请求

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `action` | string | ✔ | 固定 `"sms-login"` |
| `payload.phone` | string | ✔ | 手机号 |
| `payload.smsCode` | string | ✔ | 短信验证码 |
| `payload.clientId` | string | ✔ | 复合格式同 `sms-send`（**必须与发码时一致**） |
| `payload.agreementAccepted` | boolean | ✔ | **必须字面 `true`**，否则 `40101008` |
| `payload.nickname` | string | ✖ | 新用户不传则自动生成「用户XXXX」 |
| `payload.deviceName` | string | ✖ | 会话展示名（设备会话列表显示用） |

### 响应 `data`

| 字段 | 类型 | 可空 | 说明 |
|---|---|---|---|
| `tokens.access` | string (JWT) | 否 | access token |
| `tokens.refresh` | string (JWT) | 否 | refresh token（`rotate` 用） |
| `expiresAt.access` | string ISO | 否 | access 过期时刻 |
| `expiresAt.refresh` | string ISO | 否 | refresh 过期时刻 |
| `user.id` | string | 否 | 用户 id（前端存 storage 当 `userId`） |
| `user.nickname` | string | 否 | 昵称 |
| `user.phone` | string | null | **是** | **脱敏**（`138****8000`）—— 前端不消费 |
| `user.isNewUser` | boolean | 否 | **注册即 `true`（仅本次响应）** —— 前端弹「完善基础资料」向导的依据 |
| `token_expiring` | boolean | 否 | ⚠️ **snake\_case 孤儿键**（与 camelCase 契约风格不一致），前端未消费 |

### 错误码

| 业务码 | HTTP | 触发条件 | 前端建议处置 |
|---|---|---|---|
| `40101001` | 200 | 短信码不存在 / 过期 / 已被 3 次错作废 | 「验证码已失效」→ 回 `sms-send` 重发 |
| `40101003` | 200 | 短信码错误 | 「还剩 X 次机会」，接近 3 次时警告 |
| `40101008` | 200 | `agreementAccepted` 非字面 `true` | 提示勾选协议 |
| `40101013` | **403** | 账号被封禁 | 「账号已被封禁，请联系客服」，**禁止重试** |
| `40201007` | 200 | `clientId` 格式非法 | 同 `sms-send` —— 先查格式 |
| `40001001` | 400 | 入参校验失败 | 修正表单 |

***

## rotate

**一句话**：用 refresh 换新的 access（+ 新的 refresh）—— 由 `request.ts` 的 `rotateOnce` **单飞拦截器自动调用**，不是业务直调。

### curl

```bash
curl -s "$BASE_URL/api/v1/auth/oauth" \
  -H 'Content-Type: application/json' \
  -H 'X-Env: Prd' \
  -H "Authorization: Bearer $REFRESH" \
  -d '{"action":"rotate","payload":{}}'
```

### 请求

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `action` | string | ✔ | 固定 `"rotate"` |
| `payload` | object | ✖ | 忽略（无入参） |
| `Authorization` | header | ✔ | 🔴 **`Bearer <refresh_token>`** —— 不是 access |
| `X-Env` | header | ✔ | 双库路由必需（**随主请求一致**，否则查不到绑定行） |

### 响应 `data`

| 字段 | 类型 | 可空 | 说明 |
|---|---|---|---|
| `tokens.access` | string | 否 | 新 access（写 `storage.token`） |
| `tokens.refresh` | string | 否 | **旋转后的新 refresh**（旧的立即失效，务必覆盖存储） |
| `expiresAt.access` / `expiresAt.refresh` | string ISO | 否 | ✅ **2026-09-20 契约修复后已返回**，与 `sms-login` 同口径同形状 |
| `user.id` | string | 否 | 仅 `id` —— **与 `sms-login` 的 `user` 不同构**（无 nickname/phone），共享 user 类型的地方会踩 |
| `token_expiring` | boolean | 否 | snake\_case 孤儿键 |

### 错误码

| 业务码 | HTTP | 触发条件 | 前端建议处置 |
|---|---|---|---|
| `40101011` | **401** | 验签失败 / binding 非 active / 密文缺失 / 用户不存在 / *传了 access 而非 refresh* | 「登录已失效」→ **清空本地会话进登录页，禁止重试** |
| `40101012` | **401** | **重放**（旧 refresh 二次使用）—— 服务端已**连坐吊销该设备会话** | 唯一动作：清会话进登录页 |
| `40101014` | **401** | 设备信任 90 天窗口过期（滑动） | 引导重新登录验证 |
| `50001001` | 200 | 服务异常 | 可重试一次 |

***

## mySessions

**一句话**：当前账号的活跃设备会话列表 —— 设备会话页（`mine/account-security/device-sessions.vue`）。

### curl

```bash
curl -s "$BASE_URL/api/v1/auth/oauth" \
  -H 'Content-Type: application/json' \
  -H 'X-Env: Prd' \
  -H "Authorization: Bearer $ACCESS" \
  -d '{"action":"mySessions","payload":{}}'
```

### 请求

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `action` | string | ✔ | 固定 `"mySessions"` |
| `Authorization` | header | ✔ | `Bearer <access>`（须为 oauth\_access 且未被 denylist） |

### 响应 `data`

| 字段 | 类型 | 可空 | 说明 |
|---|---|---|---|
| `sessions[].clientId` | string | 否 | 会话键，如 `android:{uuid}` |
| `sessions[].deviceName` | string | null | 是 | 登录时传的 `deviceName` |
| `sessions[].lastActiveAt` | string ISO | 否 | `last_rotated_at \|\| created_at` |
| `sessions[].expiresAt` | string ISO | null | 是 | 会话过期时刻 |
| `sessions[].current` | boolean | 否 | 是否当前设备（token `cid` 比对） |

> ✅ **全表最干净的契约** —— 前后端字段名 / 可空性完全一致。`revokeSession` 的 `target` 直接传 `sessions[].clientId`。

### 错误码

| 业务码 | HTTP | 触发条件 | 前端建议处置 |
|---|---|---|---|
| `40101011` | 401 | 缺 / 非法 Authorization、非 access token、token 已吊销 | 走拦截器 401 分档 |
| `50001001` | 200 | token service 未装配 / 服务异常 | toast + 重试 |

***

## revokeSession

**一句话**：踢掉指定设备会话 —— 设备会话列表页点「下线」。

### curl

```bash
curl -s "$BASE_URL/api/v1/auth/oauth" \
  -H 'Content-Type: application/json' \
  -H 'X-Env: Prd' \
  -H "Authorization: Bearer $ACCESS" \
  -d '{"action":"revokeSession","payload":{"target":"android:550e8400-e29b-41d4-a716-446655440000"}}'
```

### 请求

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `action` | string | ✔ | 固定 `"revokeSession"` |
| `payload.target` | string | ✔ | 目标会话键 —— 页面传 `s.clientId`（旧字段名 `clientId` 也兼容；缺 → `40001001`） |

### 响应 `data`

| 字段 | 类型 | 可空 | 说明 |
|---|---|---|---|
| `revoked` | string | 否 | 被吊销的 target 回显（前端不消费，成功即 toast） |

### 错误码

| 业务码 | HTTP | 触发条件 | 前端建议处置 |
|---|---|---|---|
| `40101015` | **401** | 目标会话不存在或已下线 | **toast，不续期** —— 前端白名单已精确匹配 ✅ |
| `40001001` | 400 | 缺 `target` | 检查调用点 |
| `40101011` | 401 | Authorization 缺失 / 非法 | 走拦截器 401 分档 |

***

## logout

**一句话**：退出登录 —— 设置页用户主动退出。

> 🔴 **已知缺陷（决策：联调期再修）** —— 请看下面的「坑位」再动手。

### curl

```bash
# 现状（前端 src/api/login.ts 的写法）—— 吊销的是 oauth:default，no-op
curl -s "$BASE_URL/api/v1/auth/oauth" \
  -H 'Content-Type: application/json' \
  -H 'X-Env: Prd' \
  -H "Authorization: Bearer $ACCESS" \
  -d '{"action":"logout","payload":{}}'

# 修复后的写法（前端补传 clientId）
curl -s "$BASE_URL/api/v1/auth/oauth" \
  -H 'Content-Type: application/json' \
  -H 'X-Env: Prd' \
  -H "Authorization: Bearer $ACCESS" \
  -d '{"action":"logout","payload":{"clientId":"android:550e8400-e29b-41d4-a716-446655440000"}}'
```

### 请求

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `action` | string | ✔ | 固定 `"logout"` |
| `payload.scope` | string | ✖ | `device`（默认）| `all`；非法 → `40001001` |
| `payload.clientId` | string | ✖ | 🔴 **当前前端不传** → 后端取默认 `"default"` → **吊销 `oauth:default`（不存在的会话）→ no-op** |
| `Authorization` | header | ✔ | `Bearer <access>` —— access 本体**确实会**被 jti denylist 即时失效 |

### 响应 `data`

| 字段 | 类型 | 可空 | 说明 |
|---|---|---|---|
| `ok` | boolean | 否 | **恒 `true`** |
| `scope` | string | 否 | 回显 `"device"` |
| `revoked` | number | 否 | ⚠️ `scope=device` 时**恒 `1`** —— 即使没吊销到任何行。**此场景下该值会误导**（显示"成功吊销 1 个"） |
| | | | `scope=all` 时为实际吊销数 |

### 错误码

| 业务码 | HTTP | 触发条件 | 前端建议处置 |
|---|---|---|---|
| `40001001` | 400 | `scope` 非 `device`/`all` | 检查调用点 |
| `50001001` | 200 | 服务异常 | 本地清 token 已足够，可不阻塞 |

### 🔴 坑位 · logout 的 no-op 链路（已独立验证，非推测）

```
前端 login.ts:69        →  { action:'logout', payload:{} }     ← payload 空
后端 oauth.py 默认值     →  scope='device' · client_id='default'
logout.py:55            →  cz_revoke_by_cid(user_id, 'default')
真实会话行              →  'oauth:android:{uuid}'  ≠  'default'
                        →  吊销不存在的会话，真实会话未动
```

**后果**：

1. 服务端该设备会话**仍 active**（`mySessions` 仍显示在线）
2. 该设备 **refresh token 登出后仍可继续 `rotate` 换新 access**
   （access 本体被 denylist 即时失效 ✅ —— **部分失效**）

前端本地清 token 掩盖了这一点。后端 `logout.py:3` 代码注释自己写着「⚠️ scope=device 不传 clientId 会吊销 oauth:default（陷阱）」。

**候选修法（联调期决策）**：

* **A**：前端 `payload` 补传 `clientId`（它本地存了）→ 后端语义不变
* **B**：后端改语义（`scope=device` 且无 `clientId` → 吊销该用户**全部**会话）→ 更安全但会误踢其他设备

完整决策记录见 → [联调问题台账](/internal-wiki/operations/frontend-integration-issues)。
