Skip to content

fix: use shared BeeCount Cloud provider for config login - #434

Merged
TNT-Likely merged 2 commits into
TNT-Likely:mainfrom
tedzhouhk:agent/fix-beecount-cloud-2fa-session
Aug 12, 2026
Merged

fix: use shared BeeCount Cloud provider for config login#434
TNT-Likely merged 2 commits into
TNT-Likely:mainfrom
tedzhouhk:agent/fix-beecount-cloud-2fa-session

Conversation

@tedzhouhk

@tedzhouhk tedzhouhk commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

修复内容

  • 保存 BeeCount Cloud 配置后,显式重建共享的 beecountCloudProviderInstance
  • 等待共享 Provider 按新服务器地址初始化完成
  • 在 SyncEngine 使用的同一个认证实例上执行邮箱、密码和 2FA 登录

根因

云服务配置页原先通过 createCloudServices(cfg) 临时创建认证实例并完成登录。2FA session 虽然写入了临时实例和 SharedPreferences,但 SyncEngine 已经持有的共享实例内存中仍然是未登录状态,因此后续写操作可能报 CloudSyncUser not authenticated

本 PR 按维护者建议收窄,只修复配置页这一条确定的复现路径。跨实例 session 接管、rotating refresh token 竞态以及 Provider 统一分别拆到独立 PR 评审。

验证

  • 最终 diff 仅包含 lib/pages/cloud/cloud_service_page.dart
  • git diff --check 通过
  • 维护者已在上一版上手工复现并验证该核心修改可独立修复问题
  • 当前宿主机无 Flutter CLI,因此本次缩范围后未重复运行完整测试

Fixes #433

@tedzhouhk
tedzhouhk marked this pull request as ready for review August 9, 2026 04:19
@TNT-Likely

TNT-Likely commented Aug 12, 2026

Copy link
Copy Markdown
Owner

感谢 PR,也感谢你把 issue 写得那么详细 —— 我按你的步骤复现出来了,根因确认没问题。

不过想请你把范围收一下:核心修复其实只需要 cloud_service_page.dart 那一处,其余的建议拆成另一个 PR。下面说明。

根因确认

main 上 BeeCount Cloud 的 auth 实例确实是多份:authServiceProvidersync_providers.dart)和 beecountCloudProviderInstance 各自调 createCloudServices,配置页和 devices_page 又各建一个。而 initialize() 只在构造时读一次 prefs,之后 currentUser / requireAccessToken 只看内存 _session

触发条件是登录入口,不是「等几分钟」

先排除时间因素:access_token_expire_minutes 默认 60 分钟,2FA verify 复用 _issue_tokens 所以也是 60 分钟,App 侧 _isAccessTokenExpired 只留 30 秒 skew。所以跟 token 到点失效无关 —— 你原文写的「可能提示」「问题可能再次出现」是对的,这是竞态,「几分钟后」只是下次做写操作的时间点。

实测差异在两个登录入口:从「云服务配置页」点确认登录会出问题,从「云服务同步信息页」点「重新登录」不会。

云服务配置页(出问题) 同步信息页「重新登录」(正常)
登录打在哪个实例上 createCloudServices(cfg) 现场 new 的临时实例 ref.read(beecountCloudProviderInstance) —— 全 App 共享的那个实例
session 落到哪 临时实例内存 + prefs(SyncEngine 用的实例拿不到) 直接落在 SyncEngine 用的实例内存里
登录前是否 invalidate 配置 是,invalidate(activeCloudConfigProvider) 在登录之前 整个文件没有任何 invalidate
共享实例是否被重建 被重建,且在 prefs 还没有 session 的时刻 不重建

配置页那条路是两刀叠加:

  1. invalidate(activeCloudConfigProvider)beecountCloudProviderInstance 在「prefs 还没有 session」的瞬间重建(app.dartref.listenManual(syncServiceProvider, ...) 保证有监听者会立刻拉起)→ 新实例 initialize() 读不到 session → 紧接着 provider 内那句 await services.auth!.currentUser 触发静默密码登录 → 服务端返回 requires_2fa → 静默模式按失败处理 → 实例卡在 _session == null
  2. 然后交互式 2FA 登录打在临时实例上 → 登录是成功的,但那份 session 进不了共享实例的内存;而登录成功后只 invalidate 了 authServiceProvider / syncServiceProvider没有 invalidate beecountCloudProviderInstance → SyncEngine 继续用那个被污染的实例

而且它不会自愈:currentUser / requireAccessToken 只读内存 _session、不回读 prefs,initialize() 又只在构造时跑一次。所以要等重启或配置再变一次才恢复 —— 对应你说的「重新登录后可以短暂恢复」。

三个条件要同时满足才命中,这也是我一开始复现不出来的原因:

  1. 账号启用 2FA —— 没开的话第 1 步那次静默密码登录会直接成功,当场自愈,问题隐形
  2. 云服务配置页登录,而不是同步信息页的「重新登录」
  3. 本地 prefs 里没有有效 session(首次配置 / 清过数据 / 全新安装)—— 有的话第 1 步重建后 initialize() 能读到,实例是健康的

为什么核心只需要配置页那一处

你在 cloud_service_page.dart 里的改法本质上就是把配置页改成跟同步信息页一致:拿共享实例、在它上面 signInWithEmail。这一处是自洽的,不依赖 PR 里其它任何改动 —— 它直接 ref.read(beecountCloudProviderInstance.future),跟 authServiceProvider 怎么实现无关。

两行都必要:invalidate(beecountCloudProviderInstance) 是为了让实例带着新的服务器地址重建(否则改了 baseUrl 还在老实例上登录),await .future + 在它上面 signInWithEmail 是为了让 session 落进 SyncEngine 用的那个内存。

我还确认了一个担心的点:第 1 步那次静默登录失败会置 30 秒冷却,但 _saveSession 里有「任何成功登录路径都清掉静默恢复冷却」,所以紧接着的交互式 2FA 成功后冷却会被清掉,不留尾巴。

所以这一处单独就能修掉复现路径,可以合。

请从本 PR 移除的部分

先说清楚:这些我只是希望不要混在这个 PR 里,并不是给你派活。以目前的实际反馈来看它们我暂时都不需要,就这么放着也没问题。如果你自己有兴趣继续贡献,欢迎另开 PR,我单独看;但完全不做也完全 OK,不要有压力。

  1. packages/flutter_cloud_sync/.../beecount_cloud_provider.dartexpectedRefreshToken 守卫 —— 这解决的是另一个 bug:跨实例 rotating refresh token 竞态,触发条件是 access token 到期(60 分钟)后两个实例拿同一个 refresh token 去刷新,跟本 issue 复现的路径无关。

    顺带说:这个问题是真实存在的,main 里 _refreshInFlight 那段注释已经把竞态写得很清楚了,只是当时只处理了单实例内部。所以它确实有独立价值。

  2. _restorePersistedSession 回读 prefs 的兜底 —— 有了配置页修复之后属于第二层防线。

  3. sync_providers.dartauthServiceProvider 复用共享实例 —— 方向我认同(让「全 App 唯一实例」名副其实),但它附带了两个行为变化,我不想在这个 PR 里一起评估:

    • ref.onDispose(provider.dispose()) 之后旧 provider 真的会被 dispose,而 activeCloudConfigProvider 有 7 处 invalidate;syncEngineProvider 是非 autoDispose 的 family,旧条目会继续持有已 dispose 的 provider(实际后果不严重,provider.auth 会抛 CloudConfigurationException 退化成错误日志,而且顺手把旧 WS 停了算改善,但值得单独确认)
    • 改成 await ref.watch(activeCloudConfigProvider.future) 之后,如果 loadActive() 抛错会变成 AsyncError 而不是 NoopAuthService()
  4. devices_page.dart —— 纯清理,跟上面任一条一起带走都行。

关于测试

坦白说这里有个权衡:你新增的两个单元测试测的正好是要拆走的那两个机制(回读 prefs、expectedRefreshToken),而配置页这一处恰恰最难写单测(要模拟 Riverpod provider 生命周期 + 交互式 2FA)。所以缩范围会让本 PR 暂时没有对应的自动化覆盖。

我接受这个权衡 —— 复现路径已经明确,我手工验证过。那两个测试跟着对应机制一起去掉就行;如果你后面真的另开 PR 做那两项,把它们一起带上就好。

顺带提一句,你那两个测试写得很好:我把它们单独拿到 main 上跑,两个都失败,第一个的报错正是 issue 标题里那句 CloudSyncException: User not authenticated,切到本 PR 都过。这种反向验证比只看 diff 可靠得多。

我本地补跑的部分

你 PR 里提到完整 flutter analyze 在容器里被 OOM kill 了,我用 Flutter 3.27.3 在本地补跑(针对你当前这一版):

  • flutter analyze:0 error,本 PR 改动的文件没有任何分析问题
  • 全量 flutter test:494 通过,失败项与 main 基线完全一致,无新增失败

工程质量本身没问题,这次收范围纯粹是想让每个 PR 只解决一件事。缩到只剩配置页那一处之后我就合。

@tedzhouhk tedzhouhk changed the title fix: preserve BeeCount Cloud sessions after 2FA login fix: use shared BeeCount Cloud provider for config login Aug 12, 2026
@tedzhouhk

Copy link
Copy Markdown
Contributor Author

已按建议完成拆分,谢谢详细定位和范围说明。

本 PR 最终 diff 现在只有一个文件。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] 启用 2FA 后 Android 云同步丢失会话并提示 CloudSyncUser not authenticated

2 participants