Skip to content

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登录页输入框✔
captchaIdcaptcha 响应(内存里的 captchaId)✔
captchaCode登录页图形码输入框✔
clientIdcurrentClientId() → 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 位)✔
clientIdcurrentClientId()(android:{设备UUID})✔ 自动
agreementAccepted硬编码 true(UI 门禁:未勾协议按钮直接 return)✔ 自动
deviceNamegetDeviceName()(如 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.idu0000xx 字符串(不是数字主键)—— 当 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.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 一致。
  • 验证码只认真实下发:生产环境没有任何固定码 / 万能码绕过,必须收到真实短信才能登录。 联调环境是否开启「固定码」捷径由运维配置(属内部信息,不在公开文档说明)—— 需要时找运维要码,不要假设某个值可用。

四、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[].currenttrue = 本机 → 不显示「下线」按钮(防自踢)

失败时的前端动作

情形前端动作
任意失败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:108 vs :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 顶部,新增码值只能改那里,别在页面里散写。

后端链接 ​