research.md 6.4 KB

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<InfoUserOauth 同款>,实体 @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: 无(一致性要求)。