手机号用户登录状态丢失

知与行 / iOS App 开发复盘 ·

24h 后登录状态丢失、Kill App 不静默重登、邮箱无此现象。根因:legacyPhoneUserMissingPassword 误判 + 本地凭据同步清空。

by 于尘 ·

问题背景

手机号登录用户出现三类异常:

  1. 24h 后登录状态丢失,无法用本地"记住密码"恢复
  2. 切换回 App 闪到登录页后才恢复
  3. Kill App 重开直接回登录页,不走静默重登

邮箱登录用户无此现象。

问题列表

问题一:手机号用户被误判为"老用户"后强制登出

根因分析

  • why1: 用户被 forceLegacyPhonePasswordMigration 弹窗登出,本地 REMEMBER_* 凭据同时被清,24h 后没有可静默重登的凭据
  • why2: legacyPhoneUserMissingPassword 对所有有 phone 字段的非匿名用户返回 true
  • why3: 判断依赖 user_metadata.hasPassword === true,但该字段在 CloudBase SDK 中永远是 undefined(实测:getLoginState().usercurrentUsergetSession().user 都没有真实 password 字段,唯一可靠来源是 SDK 内部 localStorage user_info_{envId}.content.password === 'SET'

方案

  • 短期方案:保留 legacyPhoneUserMissingPassword,改为从 SDK 内部缓存读 password === 'SET',新增 getAuthUser() / AuthUser 抽象
  • 优点:改动小,老用户迁移语义保留
  • 缺点:依赖 SDK 非公开缓存路径,大版本升级有风险;防御链路本身仍然复杂
  • 长远方案:sms-only——手机号登录统一走 resetPasswordForPhone → updateUser({ nonce, password }),注册 / 登录 / 忘记密码 / 补设密合并为一条路径,hasPassword 永远为 true,整套"老用户"防御不再需要
  • 优点:路径单一,无需读 SDK 私有字段;删 phone-code 中间页 + 防御链路约 200 行
  • 缺点:存量"老用户"(旧短信登录、SDK 会话至今未失效)冷启动多走一次"被踢回登录页 → 重新短信 → 设密",最坏一次性体验降级

采纳方案:长远方案(sms-only)。短期方案 5-21 已实现过一版,5-22 全量替换为长远方案,连带删除 legacyPhoneUserMissingPassword / forceLegacyPhonePasswordMigration / PHONE_PASSWORD_MIGRATION_PENDING_KEY / getAuthUser / AuthUser / isLegacyPhoneUser / loginMethod 字段。

补充用例

  • 新手机号首次短信 + 设密 → 写本地凭据 → 进 Tab
  • 已设密手机号 24h 后冷启动 → 用本地凭据静默重登成功
  • 已设密手机号"忘记密码" → 重新短信 + 改密 → 进 Tab
  • Kill App 重开 → SDK 会话有效则直进 Tab,无效则静默重登

问题二:手机号 / 邮箱注册路径不对称且分流复杂

根因分析

  • why1: phone-logincheck-user-password-status 云函数判断 has_password,分流到 signInWithPhoneSms(已设密)或 resetPasswordForPhone(未设密);email-loginisSignUp 状态切换"登录 / 注册"两个 button,按钮文案与意图不一致(注册按钮叫"登录"),且已注册邮箱走注册会失败
  • why2: 历史上把"登录"和"注册"视为不同动作,分别接 SDK 不同 API(signInWithPhoneSms / signUp
  • why3: SDK 的 resetPasswordForEmail / resetPasswordForPhone 已经支持"未注册账号自动建号 + 设密 + 登录"一条龙,原设计未利用

方案

  • 短期方案:保留分流,补全分支(补"忘记密码"入口、补 is_user 判断、修文案)
  • 优点:改动局部
  • 缺点:分支多,需要维护 check-user-password-status / set-user-password-meta 两个云函数
  • 长远方案:手机号和邮箱都用 reset 链路作为统一入口,注册 / 登录(验证码)/ 忘记密码合并为同一条路径
  • 优点:两侧对称(phone-loginemail-loginphone-set-passwordemail-set-password),删除 2 个云函数 + phone-code 中间页
  • 缺点:邮箱端发码前需要先判 is_user,已注册邮箱不能走 reset 链路注册(产品决策:自动切回密码登录 + 提示)

采纳方案:长远方案。email-login 引入 password | code 两个 mode,code mode 内 handleSendCode 检查 is_user,命中则自动切回 password mode 并 toast。

补充用例

  • 未注册邮箱发码 → email-set-password → 进 Tab
  • 已注册邮箱发码 → 自动切回密码登录 + toast 提示
  • 已注册邮箱忘记密码 → code mode 走 reset → 改密 → 进 Tab
  • email-set-password / phone-set-password 返回按钮可用,不会回到错误页面

问题三:本地凭据持久化不一致,静默重登路径残缺

根因分析

  • why1: 24h 后 access_token 过期,refresh 失败时唯一兜底是 trySilentReloginWithStoredPassword(REMEMBER_ACCOUNT_KEY + REMEMBER_PASSWORD_KEY)
  • why2: 但 AuthContext.signIn(邮箱密码登录)路径没有写 REMEMBER_*,只有手机号设密页写了
  • why3: 各登录 / 设密路径各自维护本地凭据,散落多处,容易遗漏

方案

  • 单一方案:所有"账号 + 密码登录成功"或"设密成功"路径统一写 REMEMBER_ACCOUNT_KEY + REMEMBER_PASSWORD_KEYsignOut 统一清三个 key(含废弃的 REMEMBER_EMAIL_KEY 兼容清理)

采纳方案:单一方案。涉及 AuthContext.signIn / phone-set-password.tsx / email-set-password.tsx 三处统一写入。

补充用例

  • 邮箱密码登录 → 24h 后冷启动 → 静默重登成功
  • 手机号设密 → 24h 后冷启动 → 静默重登成功
  • 邮箱设密(注册或忘记密码)→ 24h 后冷启动 → 静默重登成功
  • 登出后 24h 内冷启动 → 不应静默重登(凭据已清)

问题四:sms-only / 邮箱统一改造后大量代码成为孤儿

根因分析

  • why1: phone-code.tsx(中间页)、useAuth 暴露的 7 个接口(signUp / signUpWithPhone / getVerification / verify / signInWithVerificationToken / sendPhoneCode / signInWithPhoneSms)、cloudbase.ts 中对应 7 个函数 + AuthUser 接口 + getAuthUserlegacyPhoneUserMissingPassword 整套防御、PHONE_PASSWORD_MIGRATION_PENDING_KEY 全部失去 caller
  • why2: 改造时按 surgical 原则只清"自己引入的孤儿",pre-existing 死代码未动

方案

  • 短期方案:保留死代码(karpathy: 默认不删 pre-existing)
  • 优点:风险低
  • 缺点:复杂度持续累积
  • 长远方案:用户明确请求降低复杂度后,按"无 caller / 0 reader / 零防御价值"三个口径系统化清除

采纳方案:长远方案。先列清单按风险分级让用户确认,再批量删除。

补充用例

  • npx eslint 修改文件 0 新增错误
  • rg 全 App 0 残留引用
  • AuthContext 暴露面从 14 个字段收敛到 8 个

小结

最初问题是"手机号 24h 后登录状态丢失",根因是 legacyPhoneUserMissingPassword 误判 + 本地凭据被同步清除。5-21 走过一版"修字段读取"的短期方案(getAuthUser 从 SDK 内部缓存读 password === 'SET'),但 5-22 重审后改走 sms-only / reset 链路统一的长远方案:把"登录 / 注册 / 忘记密码 / 补设密"四种意图合并为同一条 reset 路径,让 hasPassword 在任何路径完成后都为 true,整套老用户防御不再有触发条件。

由此连带:

  • 删除 phone-code 中间页 / AuthUser / getAuthUser / legacyPhoneUserMissingPassword / forceLegacyPhonePasswordMigration / isLegacyPhoneUser / loginMethod / passwordUpdatedAt / PHONE_PASSWORD_MIGRATION_PENDING_KEY
  • 删除 useAuth 7 个无 caller 接口 + cloudbaseClient.auth 对应 8 处暴露
  • 删除 check-user-password-status / set-user-password-meta 云函数的 App 端调用
  • 邮箱 / 手机号两端 UI 与状态机完全对称(*-login*-set-passwordpendingPhonePasswordResetpendingEmailPasswordReset
  • 全部登录 / 设密路径统一写 REMEMBER_ACCOUNT_KEY + REMEMBER_PASSWORD_KEY

净删除 ~1900 行,AuthContext 暴露面从 14 字段缩到 8 字段,lint 修改文件 0 新增错误。核心教训:当一个防御链路依赖 SDK 私有字段才能工作时,往往真正该改的是上游路径设计,不是防御本身

← 所有文章