Appearance
OAuth 前后联调实录 —— 实测差异与调试循环
读者:前端开发者(qm-frontend / uniapp 基座真机调试) 性质:代码看着对、真机必踩的实测差异清单 + 后台↔前端调试循环规程 基线:后端 V2.0.42(生产 api.qingmulife.cn HTTPS)· 2026-09-17 首版 · 2026-09-20 补 D9-D11(登录 API 审查)铁律:契约以本文 + actions.md 实测版为准,不以下方任何单方代码为准
⚠️ D9-D11 需要后端先上迁移 0021/0022 —— 前端联调前请确认生产已部署对应版本。
一、实测差异清单(按踩坑顺序)
D1 · captcha 渲染:三层坑,唯一正解「原生 image + 真 PNG + 后端 ≥V2.0.43」
- 层 1(svg-icon 组件):
uni_modules/svg-iconAPP-PLUS 分支走 renderjs 手绘(svgPayload/change:监听),对网络动态返回的 svg 源码与 dataURL 均渲染失败(空白、无报错)→ 弃用 svg-icon,换原生<image> - 层 2(svg dataURL):后端 V2.0.42 及之前
png字段名不副实(实为data:image/svg+xml;base64)。Android 原生 image 不渲染 SVG dataURL(位图解码器不含 SVG)→ 依然空白 - 层 3(修复,V2.0.43):后端 Pillow 画真 PNG(
data:image/png;base64),逐字符 ±15° 旋转 + 干扰线,抗指纹扰动保留;svg 回退字段不变 - 前端正解(三个条件缺一不可):html
<image :src="res.data.png" mode="aspectFit" @error="..." @load="..." />- 用原生
image(不是 svg-icon) - 用
png字段 - 确认后端 ≥ V2.0.43(
png前缀必须是data:image/png;base64,;data:image/svg+xml= 后端旧版,先升后端)
- 用原生
- 前端自查:绑定
@load/@error观测加载态;catch 分支 toast 错误(勿静默 — 静默会让空白页无从定位)
D2 · mySessions 响应结构
data.sessions[]—— 包一层sessions数组,不是裸数组- 当前设备标记字段是
current,不是isCurrent(前端第一版写错,模板与类型两处都要对)
D3 · revokeSession 入参键
- 权威键
target(clientId为兼容旧键);缺 key →40001001 - ⚠️ 后端 V2.0.41 及之前只读
clientId,传target会炸成40201007兜底 — 后端 < V2.0.42 时前端传clientId键
D4 · HTTP 401 三档语义(前端拦截器必读)
| 业务码 | 含义 | 前端动作 |
|---|---|---|
40101011 | access 无效/过期 | rotate 单飞 → 重放一次 → 仍 401 才登出 |
40101012 | refresh 重放连坐 | 直接登出(rotate 无意义) |
40101014 | 设备信任 90 天窗口过 | 直接登出(引导重新登录) |
40101015 | revoke 目标会话不存在 | toast 即止(业务性 401,操作者会话不受影响) |
- ⚠️ 宽限窗:rotate 后 60s 内同 refresh 重放 → 返回同一对新 token(code 0,Auth0 overlap 设计)— 不是漏洞;60s 后重放 →
40101012
D5 · rotate 请求形态(uni-app 版本坑)
- 本工程(Vue3 现代 uni-app)
await uni.request()直接 resolve 响应对象,不是[err, res]元组(旧版约定) - 前端 request 层统一用 callback 形式(与 utils/request.ts 惯例一致),零版本风险
- rotate 的
Authorization带 refresh token;logout/unbind/revokeSession 带 access token
D6 · 限流组合拳(联调脚本/手工测试必读)
- 同手机号 sms-send 60s 冷却(
40101005「发送间隔过短」) - 图形验证码一次性消费(每次发码都要 fresh captcha,
40101003) - 连续登录测试的编排:换号 或 sleep 62s 或 直接库造数据
D7 · clientId 复合格式(设备级隔离核心)
- 格式:
android:{设备UUID}(UUID 首装随机生成并持久化;后端校验^[A-Za-z0-9_-]{1,20}:[A-Za-z0-9-]{1,40}$) - 卸载重装 = 新设备(符合安全语义)
- 同账号 >10 台设备 → LRU 自动淘汰最旧(当前设备豁免)
D8 · 双 token 存储统一
- access/refresh/expiresAt 统一走
utils/storage.ts(禁止散落uni.setStorageSync('key_xxx')) - 设备身份(deviceId/clientId/deviceName)独立于会话生命周期 — 登出不清设备身份
D9 · ⭐ user.phone 可能是假的:占位号陷阱(前端必读)
微信登录用户从来没有真实手机号,后端为其 phone 填入派生占位号的脱敏值。 它长这样:
json
"user": { "phone": "138****6623", "phoneIsPlaceholder": true }外观与真实绑定号完全一致 —— 光看 phone 无法分辨。若前端据此渲染:
- ❌ 会给从没绑过手机的微信用户显示「已绑定 138****6623」
- ❌ 无法决定要不要展示「绑定手机号」入口
正解:读 phoneIsPlaceholder(本次新增字段,两处登录响应都有):
| 字段 | 含义 | 前端动作 |
|---|---|---|
phoneIsPlaceholder: true | phone 是占位号,未真实绑定 | 展示「绑定手机号」入口 |
phoneIsPlaceholder: false | phone 是真实的脱敏号 | 展示「已绑定 138****XXXX」 |
⚠️ phone 本身语义不变(仍是脱敏串,非空),不要改成判断 phone == null。
D10 · bind-phone / unbind 契约(含一处行为变更)
bind-phone(请求)
json
{ "action": "bind-phone",
"payload": { "userId": "...", "phone": "13900009301", "smsCode": "****", "clientId": "ios" } }Authorization: Bearer <access>(必需)- 目标号需先发码(
sms-send),否则40101001 - 响应只有
{ "ok": true, "token_expiring": false }—— ⚠️ 不回显绑定后的号码, 前端要显示新号须自行刷新用户信息(已知缺口,见台账 D10 备注)
🔴 行为变更(本次修复):此前该接口对任何已登录用户都返回 40101007 「已绑定手机号」—— 微信用户永远绑不上。修复后占位号不再算「已绑定」。
unbind(请求)
json
{ "action": "unbind", "payload": { "userId": "...", "target": "mobile" } }- ⚠️
target只接受""/"mobile"/"mini"—— 它们是微信渠道, 不是手机号。传"phone"会得到40001001(参数非法)。 - 约束:解绑后必须仍保留至少一种登录方式,否则
40101007(例:只绑了微信、没真手机号的用户不能解绑微信)
D11 · 微信登录:同 openid 必须复用同一账号
同一微信身份(unionid / openid)再次登录 → 返回同一个 user.id,不是新建账号。
⚠️ 生产此前不是这样(后端缺陷:反查恒为空 → 每次登录建新号)。 本次修复后行为才正确 —— 前端若观察过「同一微信登录出多个 user.id」,那正是该缺陷, 升级后端后即消失。
二、调试循环规程(后台 ↔ 前端)
┌─ 后台(AI/服务端侧)
│ 1. 服务端日志/watch 发现问题 → 定位根因
│ 2. 修复代码(后端 fix / 前端 fix 建议)
│ 3. 【本文档】差异与结论沉淀到 D 清单 + actions.md
│ 4. 告知前端:改了什么、要重新编译基座吗
│ ↓
├─ 前端(HBuilderX 侧)
│ 5. 编译/重新运行到手机(HBuilder 基座)
│ ↓
└─ USB 调试循环(后台 adb 驱动)
6. adb logcat 监听 + screencap 截屏 + input tap 模拟操作
7. 服务端 docker logs 双端对照
8. 发现新差异 → 回到 1(如此反复)双端观测通道:
| 通道 | 命令/位置 |
|---|---|
| 手机 console | adb logcat --pid=<pid> -v time(uniapp console 不一定进 logcat,以 HBuilderX 控制台为准) |
| 手机屏幕 | adb exec-out screencap -p > x.png |
| 模拟操作 | adb shell input tap <x> <y>(截图坐标 ×1.2 = 物理像素) |
| 服务端请求 | ssh prod "docker logs qm-serverv2-app --since 1m" |
| 服务端业务码 | 同上 grep POST /api/v1/auth/oauth |
循环纪律:
- 每轮只改一件事(一个 fix = 一次基座重编译),改完必记录
- 「数据到了没」看服务端日志,「渲染对不对」看截屏 — 两头对照定位,不猜
- 新差异按 D 编号追加,已解决的标注修复版本
三、差异台账
| # | 状态 | 修复版本 | 一句话 |
|---|---|---|---|
| D1 | ✅ 已收口(真机验证) | 后端 V2.0.43(真 PNG)+ 前端 2026-09-17(image 兜底) | captcha 三层坑:svg-icon→svg dataURL→真 PNG;2026-09-17 11:28 Redmi 真机渲染成功 + 点击刷新正常 |
| D2 | ✅ 已修(前端对齐) | 前端 2026-09-17 | sessions[] + current |
| D3 | ✅ 已修(后端兼容双键) | V2.0.42 | revokeSession target 权威 |
| D4 | ✅ 已修(前端分档) | 前端 2026-09-17 | 401 三档:rotate/强登/toast |
| D5 | ✅ 已修(前端 callback 形式) | 前端 2026-09-17 | uni.request 形态坑 |
| D6 | 认知项(无需修) | — | 限流组合拳 |
| D7 | ✅ 已接入(前端 device.ts) | 前端 2026-09-17 | 复合 clientId |
| D8 | ✅ 已修(前端 storage 统一) | 前端 2026-09-17 | 双 token 统一存取 |
| D9 | ⬆️ 待前端适配(后端已就绪) | 后端 2026-09-20(新增 phoneIsPlaceholder) | user.phone 可能是占位号假号 — 必须读 phoneIsPlaceholder 区分 |
| D10 | ⬆️ 待前端适配(含行为变更) | 后端 2026-09-20(bind-phone 修复) | bind-phone 此前恒失败,现已可用;unbind 的 target 只收 ""/mobile/mini |
| D11 | ✅ 后端已修 | 后端 2026-09-20(cz_find_by_openid 实现) | 同 openid 复用同一账号(此前生产每次登录新建号) |
四、当前联调状态(2026-09-17 11:35 — D1 收口)
| 项 | 状态 |
|---|---|
| 后端 | V2.0.43 已部署生产(api.qingmulife.cn HTTPS,真 PNG captcha;curl 实测 png 前缀 data:image/png;base64,iVBORw0K) |
| 前端 qm-frontend | 512be67(诊断版)已编译推送真机(uniapp CLI 3.0.0-5020420260813001 编译 + 基座 www 资源替换重签) |
| 手机 | Redmi Note 12 Turbo(adb c9e5cc0f),muhehealth 诊断包运行中 |
| D1 验证结果 | ✅ 三条件全通:① logcat [captcha] res.code=0、keys=[captchaId,png,svg]、前缀 data:image/png;base64, ② 验证码图渲染成功 ③ 点击刷新正常(0090→3783) |
| 收尾项 | 前端 512be67 的 [captcha] 诊断打点确认无用后可删;uniapp CLI 编译骨架(package.json/vite.config.ts)可沉淀回 CNB 仓库建 build/cli 分支,脱离 HBuilderX 编译 |