---
url: /docs/api-docs/v1/oauth/integration-notes.md
---
# OAuth 前后联调实录 —— 实测差异与调试循环

> **读者**：前端开发者（qm-frontend / uniapp 基座真机调试）
> **性质**：代码看着对、真机必踩的实测差异清单 + 后台↔前端调试循环规程
> **基线**：后端 V2.0.42（生产 api.qingmulife.cn HTTPS）· 2026-09-17 首版 · **2026-09-20 补 D9-D11（登录 API 审查）**
> **铁律**：契约以本文 + [actions.md](./actions.md) 实测版为准，不以下方任何单方代码为准
>
> ⚠️ **D9-D11 需要后端先上迁移 0021/0022** —— 前端联调前请确认生产已部署对应版本。

***

## 一、实测差异清单（按踩坑顺序）

### D1 · captcha 渲染：三层坑，唯一正解「原生 image + 真 PNG + 后端 ≥V2.0.43」

* **层 1（svg-icon 组件）**：`uni_modules/svg-icon` APP-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="..." />
  ```
  1. 用原生 `image`（不是 svg-icon）
  2. 用 `png` 字段
  3. 确认后端 ≥ 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` |

**循环纪律**：

1. 每轮只改一件事（一个 fix = 一次基座重编译），改完必记录
2. 「数据到了没」看服务端日志，「渲染对不对」看截屏 — 两头对照定位，不猜
3. 新差异按 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 编译 |
