Appearance
OAuth 登录链(前端调用视角 · 7 端点)
本文只回答「哪个页面在什么时机调、响应从哪一层读、失败时前端做什么」。 字段类型 / 可空 / 错误码码值 / curl 一律见 前端联调 API 手册 · login —— 本文零复述(分工边界见 总览)。 封装:
src/api/login.ts(6 个函数);rotate由src/utils/request.ts拦截器自动调用,业务代码不手写。 消费页:pages/login · pages/mine-account-security · pages/mine-account-security-device-sessions
调用链总览
登录页 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 就是为绕开它(详见 平台差异坑速查表)。- 失败只 toast(
验证码拉取失败: <msg|code>),无自动重试 —— 靠用户点图重拉。
失败时的前端动作
| 情形 | 前端动作 |
|---|---|
| 拉取失败(网络 / 限流) | 停在页面,点图重拉 —— 码值与触发条件见 手册 · captcha |
⚠️ 坑位
- 图形码一次性 + 有时效:
sms-send会消费它;发码失败要重新拉图,拿旧captchaId重试必然失败。 - 触屏重拉必须绑具名方法(
@click="getCaptcha"),模板内联箭头在 App 端崩(t.__f__—— 铁律 3)。
二、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 |
| 触发限流(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。
失败时的前端动作
| 情形 | 前端动作 |
|---|---|
| 短信码不存在 / 过期 / 错 3 次已作废 | 提示「验证码已失效」→ 回 sms-send 重发 |
| 短信码错误(未到 3 次) | toast,不重发(后端已记一次错,3 次销毁) |
| 协议未接受 | 不正常出现(前端已硬编码 true);若出现提示勾选协议 |
| 账号封禁 | 「账号已被封禁,请联系客服」,禁止重试 —— 该档 HTTP 状态与其余档不同,见 手册 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.py661 行 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 一致。- 验证码只认真实下发:生产环境没有任何固定码 / 万能码绕过,必须收到真实短信才能登录。 联调环境是否开启「固定码」捷径由运维配置(属内部信息,不在公开文档说明)—— 需要时找运维要码,不要假设某个值可用。
四、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 |
⚠️ 坑位
- 页面文案「最多保留 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 |
| 其他失败 | 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:108vs:71-80)→ 运行时 TypeError → 其后的 toast 与reLaunch都不执行。 ⇒ 表现:点「退出登录」不跳页;被踢下线(strong-logout 码)也不会被带回登录页(token 已清、页面卡住、后续请求继续 401)。 修法(前端侧,不在本轮):给storage.user补remove(),或给clearStorage加逐项保护。详见 联调台账。
关键前端语义(跨端点)
request不解包:响应是完整信封,业务字段一律res.data.*(漏?.data= 远端 TypeError,已有真机实锤 —— 铁律 1)。- 两套失败契约并存:本域(oauth)失败多为 HTTP 200 + 业务码非
"0";user / device / training / stats / upload 域失败多为 HTTP 400/401/404 + 码。前端统一由request.ts判定(非"0"即 reject + toast),业务代码不要自己解析码值 —— 见 手册 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 · 手册 · 通用约定 G1-G7
- 从零施工(拷 SDK / 自定义基座 / 微信登录 manifest):OAuth 登录链落地手册(其 §7 平台差异表已并入 本目录铁律)
- 缺陷与决策台账:联调台账 · 双库路由
X-Env:comparison.md - 启动链上游(登录前最后一步):api/splash —— 启动页免登录判断 token 有无后才落到本页登录流程