# Research: 设备信任免绑手机号 **Date**: 2026-09-20 | **Status**: 全部决策已与用户确认,无未解决 NEEDS CLARIFICATION 本 feature 的关键决策均在头脑风暴阶段与用户逐条确认,来源为对现有代码(017-oauth-login 实现即 `InfoUserController` / `LineCallbackController` / `OAuthVerifyService`)的实地调查。以下记录决策、理由与被否方案。 ## D1: 设备信任的存储——独立新表 `info_user_device` - **Decision**: 新建独立表,`device_id` 为主键,一台设备一行(device_id → phone → user_id → 时间戳)。 - **Rationale**: 设备与用户是一对多(共用设备换号登录),挂 `info_user` 字段表达不了"设备维度最近一次验证的手机号";独立表 upsert 语义简单(按 PK insert-or-update),不动用户大表。 - **Alternatives**: ① 在 `info_user` 上加 `device_id` 字段——只能记"该用户最后登录的设备",语义相反,共用设备会互相覆盖且查不出"设备信任谁";② 复用 `info_user_oauth` 加 provider='device'——语义混淆,破坏 UNIQUE(provider, provider_uid) 的三方账号语义。均否。 ## D2: 免绑交互——免短信一键确认(脱敏手机号只读) - **Decision**: `oauthLogin` 未绑定时若设备有信任记录,返回 `{status:"deviceConfirm", tempKey, maskedPhone}`;App 弹"将绑定 098\*\*\*\*789 并登录"确认框,手机号**只读不可改**,确认后调新接口 `oauthDeviceConfirm` 完成绑定登录,不发短信。 - **Rationale**: 用户拍板选"一键确认"而非"静默绑定":共用设备场景下用户能看到将绑定给谁,防止无声进错账号;同时相比短信验证仍零输入。只读是用户的明确要求——换号走取消后手动绑定。 - **Alternatives**: ① 静默绑定(零点击)——被否,共用设备误绑风险;② 确认框里允许改手机号——被用户明确否决(只能看不能改)。 ## D3: 设备标识与临时凭证的绑定方式——正交 Redis key - **Decision**: 沿用 `oauth:bind:{tempKey}` = `provider@providerUid`(**值格式不变**),deviceConfirm 分支额外写 `oauth:bind:dev:{tempKey}` = `deviceId`(同 TTL 5 分钟)。`oauthDeviceConfirm` 校验两键齐全且 dev 值与入参 deviceId 相等。 - **Rationale**: 现有 `oauthBindPhone` 用 `indexOf('@')` 解析值,若把 deviceId 拼进值里会产生解析歧义与跨流程误用(deviceConfirm 的 tempKey 被喂给 oauthBindPhone 会拼出错误的 providerUid)。正交 key 让现有代码零改动、两种 tempKey 天然隔离。 - **Alternatives**: ① 值扩为 `provider@uid@deviceId`——解析歧义,需同时改两处解析,否;② tempKey 存 JSON——现有值为纯字符串,引入序列化不一致,否。 ## D4: LINE 回调携带设备标识——复用 `state` 参数 - **Decision**: 前端打开 LINE 授权 URL 时把 deviceId 放进 `state`(现状为前端随机串,后端不校验);`LineCallbackController.callback` 把 `state` 当 deviceId 用。未绑定分支:设备有信任 → 302 回跳 `deviceConfirm=1&tempKey=xxx&maskedPhone=xxx`;无信任 → 维持 `needPhone=1&tempKey=xxx`。已绑定路径也记录设备信任。 - **Rationale**: LINE 官方 authorize 支持 `state` 且原样回传,现网已在传(仅作 CSRF 占位,后端未校验);复用它零流程变更,还顺带解决了 017 遗留问题"回调链路没有 App 上下文"。 - **Alternatives**: ① 回调前先让 App 调一次后端预登记(额外接口 + 时序耦合)——否;② LINE 分支不做免绑(用户明确要求做)——否。 ## D5: 设备标识来源与契约——后端只收字符串,生成归前端 - **Decision**: 后端约定 `deviceId`:可选、string、≤64;缺省/空串/超长一律按未传处理,走原流程。生成与持久化(iOS 建议 Keychain、Android 建议 ANDROID_ID,均跨卸载重装)由 uni-app 前端负责,写入前端契约文档。 - **Rationale**: 后端不关心标识怎么来;可选参数保证老版本 App 100% 兼容。 - **Alternatives**: ① 复用现有 `cid`(uni 推送客户端 ID)——重装即变且 cidType 现状传空,稳定性不满足"绑定一次"预期,否;② 后端采集 IP/UA 指纹——不可靠且涉及隐私,否。 ## D6: 信任记录的写入点——四种登录成功点收敛到一个服务方法 - **Decision**: `DeviceTrustService.recordLogin(deviceId, phone, userId)`(按 PK upsert,最近一次覆盖),在四处调用:`lodeing` 成功、`oauthLogin` 已绑定直登成功、`oauthBindPhone` 成功、`oauthDeviceConfirm` 成功;LINE 回调已绑定路径同样调用。 - **Rationale**: 规则一句话"手机号验证通过 + 登录成功 + 带了 deviceId → 记录";收敛到一处便于单测与后续扩展。共用设备语义 = 最近一次登录的手机号(D2 的确认框可让用户看见当前绑谁)。 - **Alternatives**: 在每处 Controller 里各自写 mapper 调用——重复且易漏,否。 ## D7: 数据访问模式——MyBatis-Plus BaseMapper,无 XML - **Decision**: `InfoUserDeviceMapper extends BaseMapper`,实体 `@TableName("info_user_device")`,不用 XML resultMap。 - **Rationale**: 仓库先例 `InfoUserOauthMapper` 注释明确"仅用 MP CRUD,无自定义查询、无 XML";本 feature 只有按 PK 的 select/insert/update,MP 原生覆盖。 - **Alternatives**: 手写 XML mapper——项目规范"全栈字段清单"针对业务查询字段,纯 PK CRUD 用 XML 是过度工程,否。 ## D8: 脱敏手机号格式——复用现有 `maskPhone` 规则 - **Decision**: maskedPhone 复用 `InfoUserController.maskPhone`(前 3 + `****` + 后 4),抽到 `DeviceTrustService` 共用。 - **Rationale**: 与现有日志脱敏风格一致,避免两套规则。 - **Alternatives**: 前 3 后 3——与现网工具不一致,否。 ## D9: 账号停用/删除处理——与现有绑定流程同一套校验 - **Decision**: `oauthDeviceConfirm` 复用 `oauthBindPhone` 的账号校验:`status != 0` 停用 → `no.user.stop` 错误;get-or-create(`getuser(phone)`,不存在则按现有规则新建 + 建钱包);期间已被绑定的容错直登也保持一致。 - **Rationale**: 一键确认是绑定流程的"免短信变体",账号语义必须与短信绑定完全一致,防止绕过停用。 - **Alternatives**: 无(一致性要求)。