Browse Source

docs: 029 用户端 uni-app 前端改造指南(deviceId 生成/一键确认弹窗/LINE state 契约)

qmj 2 days ago
parent
commit
b3d3665487
1 changed files with 198 additions and 0 deletions
  1. 198 0
      specs/029-device-trust-phone-bind/frontend-guide.md

+ 198 - 0
specs/029-device-trust-phone-bind/frontend-guide.md

@@ -0,0 +1,198 @@
+# 用户端(uni-app)前端改造指南:同设备三方登录免绑手机号
+
+**Date**: 2026-09-20 | **后端已合并**: `test` 分支 `cb8721b` | **接口契约**: [contracts/api-contract.md](contracts/api-contract.md)(字段/错误码以此为准)
+
+> **一句话效果**:这台手机只要验证过一次手机号(短信登录或三方绑号),之后用任何三方登录(Apple/Google/LINE,包括全新的三方账号)都不再收短信,只弹一个**只读**确认框"将绑定 098\*\*\*\*4321 并登录",点确认即登录。
+
+---
+
+## 改动总览(6 项)
+
+| # | 改动 | 性质 |
+|---|---|---|
+| 1 | 新增设备标识模块:生成/持久化/读取 `deviceId` | 新增 |
+| 2 | 3 个登录请求体加 `deviceId` 字段 | 小改 |
+| 3 | `oauthLogin` 返回处理新增 `deviceConfirm` 分支 | 小改 |
+| 4 | 新增一键确认弹窗 + 调新接口 `oauthDeviceConfirm` | 新增 |
+| 5 | LINE 授权 URL 的 `state` 改传 `deviceId`;回跳参数新增 `deviceConfirm` 分支 | 小改 |
+| 6 | 弹窗 i18n 文案(zh/tw/en/vi) | 新增 |
+
+**老版本兼容**:所有改动都是增量的——不传 `deviceId` 时后端行为与现在完全一致。可以分版本上线,不需要强同步。
+
+---
+
+## 1. 设备标识 deviceId(核心新增)
+
+### 生成与持久化规则
+
+- 首次启动生成一次 UUID(如 32 位小写十六进制),**之后永远复用同一个值**;
+- 长度 ≤64;三端(请求体、LINE state)用**同一个值**;
+- 取不到稳定标识时降级自生成 UUID,功能仍可用(只是卸载重装后要重新绑一次)。
+
+### 各平台建议
+
+| 平台 | 首选 | 说明 |
+|---|---|---|
+| iOS | **Keychain 原生插件**存 UUID | Keychain 数据卸载重装不丢 → 重装后依然免绑。uni-app 插件市场搜 "Keychain"(uts/原生插件) |
+| iOS(降级) | `plus.storage` / `uni.setStorageSync` | 重装后 UUID 会重新生成 → 重装后需重新绑一次(可接受) |
+| Android | **ANDROID_ID** | 同设备 + 同签名跨重装稳定、免权限、恢复出厂才变(见下方代码) |
+| Android(降级) | 自生成 UUID 存 storage | 同上 |
+
+### 示例代码(按项目实际封装调整)
+
+```js
+// device-id.js —— App 启动时调用一次 getDeviceId(),之后全局复用返回值
+const KEY = 'app_device_id';
+
+export function getDeviceId() {
+  let id = uni.getStorageSync(KEY);
+  if (id) return id;
+
+  // Android:优先系统 ANDROID_ID(跨重装稳定)
+  // #ifdef APP-PLUS
+  if (uni.getSystemInfoSync().platform === 'android') {
+    try {
+      const Secure = plus.android.importClass('android.provider.Settings$Secure');
+      id = Secure.getString(plus.android.runtimeMainActivity().getContentResolver(), 'android_id');
+    } catch (e) { /* 忽略,走降级 */ }
+  }
+  // iOS:有 Keychain 插件则读写插件,见插件文档(本示例省略)
+  // #endif
+
+  if (!id || id.length > 64) {
+    id = genUuid(); // 32 位小写十六进制
+  }
+  uni.setStorageSync(KEY, id);
+  return id;
+}
+
+function genUuid() {
+  return 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'.replace(/x/g, () =>
+    Math.floor(Math.random() * 16).toString(16));
+}
+```
+
+---
+
+## 2. 登录请求体加 deviceId(3 个接口)
+
+在**现有请求对象**上加一个可选字段即可:
+
+| 接口 | 改动 |
+|---|---|
+| `POST /infouser/user/lodeing` | body + `deviceId` |
+| `POST /infouser/user/oauthLogin` | body + `deviceId` |
+| `POST /infouser/user/oauthBindPhone` | body + `deviceId` |
+
+```json
+{ "phone": "0987654321", "code": "123456", "deviceId": "a3f8c2..." }
+```
+
+---
+
+## 3. oauthLogin 返回三分支处理
+
+| `data` 内容 | 条件 | 处理 |
+|---|---|---|
+| `token` + 用户信息 | 三方身份已绑定 | 现状不变:直接登录 |
+| `{status:"needPhone", tempKey}` | 未绑定、设备无信任 | 现状不变:弹手机号+短信绑定 UI |
+| `{status:"deviceConfirm", tempKey, maskedPhone}` **新增** | 未绑定、设备已信任 | 弹一键确认框(第 4 节) |
+
+`maskedPhone` 格式:前 3 + `****` + 后 4(如 `098****4321`),**仅用于展示**,不要当手机号提交。
+
+---
+
+## 4. 一键确认弹窗 + 新接口(新增)
+
+### 弹窗规范
+
+```
+标题:绑定手机号
+正文:将绑定 {maskedPhone} 并登录
+按钮:[确认]  [取消]
+```
+
+- 手机号**只读展示,不可编辑**,弹窗内不出现手机号输入框和验证码输入框(后端也不支持换号,换号走"取消后手动绑定");
+- 取消:直接关闭弹窗,无任何副作用(后端什么都没发生),回到登录页。
+
+### 确认后调用
+
+```
+POST /infouser/user/oauthDeviceConfirm
+Content-Type: application/json
+
+{ "tempKey": "oauthLogin 返回的 tempKey", "deviceId": "本机 deviceId" }
+```
+
+**成功**:响应结构与 `oauthBindPhone` 成功完全一致(`msg` + `user` + `token`),按现有登录成功逻辑处理(存 token、跳首页)。
+
+**失败**处理(`code=500`,`msg` 为后端 i18n 文案,可直接 toast `msg`):
+
+| 错误(按 msg 判断或直接透传) | 建议 UI |
+|---|---|
+| 登录凭证已过期(`no.oauth.tempkey.expired`) | 提示后回登录页,用户重新点三方登录拿新 tempKey |
+| 设备确认无效(`no.oauth.device.mismatch`) | 提示后回登录页(可回落到手动绑定流程) |
+| 账号已停用(`no.user.stop`) | 提示账号已停用,回登录页 |
+
+**注意**:确认期间该三方身份若已被其它端绑定,接口**不会报错**而是直接返回 token 登录成功——按成功处理即可。
+
+---
+
+## 5. LINE 登录改造(网页回调链路)
+
+### 唯一必改点:authorize URL 的 `state`
+
+把原来的随机串换成 `deviceId`(其余参数不动):
+
+```
+https://access.line.me/oauth2/v2.1/authorize
+  ?response_type=code
+  &client_id=...
+  &redirect_uri=https%3A%2F%2Fapi.awayqtw.com%2Fauth%2Fline%2Fcallback
+  &scope=profile%20openid
+  &state={deviceId}          ← 原来是随机串,现在传 deviceId
+```
+
+### 302 回跳 scheme 参数分支(`com.twanmsdyh.app://oauthLogin?...`)
+
+| 参数 | 条件 | 处理 |
+|---|---|---|
+| `token=xxx` | 已绑定 | 现状不变 |
+| `needPhone=1&tempKey=xxx` | 未绑定、无信任 | 现状不变(手动绑定 UI) |
+| `deviceConfirm=1&tempKey=xxx&maskedPhone=098****4321` **新增** | 未绑定、设备已信任 | 弹第 4 节同款确认框(`maskedPhone` 记得 URL 解码),确认 → 调 `oauthDeviceConfirm` |
+| `error=xxx` | 异常 | 现状不变 |
+
+> 前端自拿 code 走 `POST /oauthLogin` 的场景(H5 等):body 带 `deviceId` 即可自动生效,返回分支同第 3 节。
+
+---
+
+## 6. 弹窗 i18n 文案建议(key 用英文驼峰,与现有语言文件层级规范一致)
+
+| key | zh | tw | en | vi |
+|---|---|---|---|---|
+| `deviceConfirmTitle` | 绑定手机号 | 綁定手機號 | Bind Phone | Liên kết số điện thoại |
+| `deviceConfirmMsg` | 将绑定 {phone} 并登录 | 將綁定 {phone} 並登入 | Will bind {phone} and log in | Sẽ liên kết {phone} và đăng nhập |
+| `deviceConfirmBtn` | 确认绑定 | 確認綁定 | Confirm | Xác nhận |
+
+(取消按钮、错误 toast 建议复用现有通用 key;`{phone}` 用后端返回的 `maskedPhone` 填充。)
+
+---
+
+## 7. 联调自测清单
+
+1. 手机号+短信登录(带 deviceId)→ 再用**全新**的 Apple/Google 身份登录 → 应弹确认框,`maskedPhone` = 刚登录的号。
+2. 确认 → 直接进 App,"我的"页手机号 = 该账号。
+3. 取消 → 再换 Google 新身份登录 → 仍弹确认框(未产生绑定)。
+4. 不带 deviceId(模拟老版本)→ 三方登录走原 needPhone 流程,行为与现在一致。
+5. 等待 5 分钟后点确认 → toast "登录凭证已过期",回登录页重试成功。
+6. LINE:state 传 deviceId → 已信任设备走 `deviceConfirm=1` 回跳分支,弹窗与确认流程同 1-2。
+7. 卸载重装(iOS Keychain / Android ANDROID_ID 生效时)→ 三方登录仍免绑。
+
+---
+
+## 8. 注意事项
+
+- `tempKey` 5 分钟有效,**不要缓存复用**,每次弹窗都用当次返回的;
+- `deviceId` 异常值(空/超长)后端按未传处理,不会报错;
+- LINE 的 `state` 从随机串改为 deviceId 不影响安全性(后端原本就不校验 state 的 CSRF 随机性);
+- 后端接口详细字段、错误码以 [contracts/api-contract.md](contracts/api-contract.md) 为准,两边文档冲突时以契约为准并反馈后端修正。