Skip to content

refactor: 统一云服务会话管理与生命周期 - #439

Open
tedzhouhk wants to merge 6 commits into
TNT-Likely:mainfrom
tedzhouhk:agent/unify-beecount-cloud-provider
Open

tedzhouhk wants to merge 6 commits into
TNT-Likely:mainfrom
tedzhouhk:agent/unify-beecount-cloud-provider

Conversation

@tedzhouhk

@tedzhouhk tedzhouhk commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

背景与范围

将本 PR 从局部 provider 复用扩展为 App 层会话生命周期重构,而不只修复「测试连接」。这次主动扩大了范围,与此前建议只保留连接测试改动的方向不同,请按新的范围审阅。

多个入口通过工厂各自创建服务时,持久化存储相同并不代表内存中的认证状态相同。2FA 登录、UI 认证和同步可能因此使用不同的会话。历史服务端日志能确认验证成功后仍出现登录挑战,但不能仅凭这些日志定位到某一个 Dart 实例;下面通过自动化测试验证实例关系。

实现

  • 新增 CloudSessionManager / CloudSession,按完整配置管理当前服务;相同配置复用实例,并发初始化合并,配置切换串行释放旧服务,过期初始化结果不会成为当前会话。
  • authServiceProvider、BeeCount Cloud provider、快照同步统一从活动会话取服务;保留工厂创建独立实例的语义,不引入工厂全局缓存。
  • BeeCount Cloud 和 S3 的活动配置连接测试复用当前服务;设备页也不再单独创建认证实例。S3 旧配置的 endpoint 协议前缀清理下沉到工厂。
  • 会话关闭通知同步引擎停止监听及定时任务、释放资源;拒绝已关闭引擎的新同步请求,并在部分异步返回/应用数据边界检查关闭状态。关闭监听器异常不会跳过其余清理。
  • 临时会话提供独立的 try/finally 生命周期;BeeCount Cloud 临时认证可禁用持久化读写,避免覆盖活动登录。该选项不宣称隔离 Supabase SDK 自身的全局认证持久化,也没有新增草稿配置 UI。
  • BeeCount Cloud 恢复持久化会话时检查配置邮箱,避免同服务器切换账号后加载另一账号的 session。

边界

  • 不修改服务端 2FA、refresh token 或设备信任期限;本 PR 不能据此保证固定 30 天免验证。
  • 不宣称取消所有已经发出的请求或回滚服务端已提交的数据;这里只处理会话资源所有权及关闭后的继续调度/部分迟到结果。
  • 尚未构建 APK 做真机长期 2FA 验证;仍需验证「登录 → 测试连接 → 等待 → 同步」、重启及账号/服务器切换。

验证

  • 6 个定向测试文件共 39 项通过:管理器、UI/provider 身份、真实认证服务的模拟 2FA、临时认证持久化隔离及邮箱匹配、旧引擎释放、连接测试复用和原有同步回归。
  • 随后补充关闭监听异常用例,管理器 7 项再次全部通过;当前合计覆盖 40 个不同用例。
  • git diff --check 通过。
  • 所有测试串行运行(--concurrency=1),Docker 限制 --memory=1536m --memory-swap=1536m --cpus=1 --pids-limit=256
  • 定向静态分析未完成:分析进程在相同内存限制内以 -9 退出,没有提高限制重跑。不能将此次分析记为通过。

Agentic review 后续修复

  • 同一 session 的 AsyncLoading / AsyncData 变化不再重建同步引擎;补充相同配置刷新后的引擎身份断言。
  • 账本同步按数据库对象跨引擎排队,覆盖查询、写入和 GC,避免旧操作未退出时新引擎并发查重插入。不同会话只等待旧操作完成,不复用旧服务器的查询结果;排队期间已关闭的引擎不再执行查询。
  • BeeCount Cloud / S3 手动测试连接遇到缓存的初始化错误时,显式失效并重新初始化;健康会话及正在初始化的会话仍复用,不无条件重新登录。
  • 本轮 4 个测试文件共 32 项通过(连接测试、引擎生命周期、跨引擎排队、原有同步回归)。新增 S3 用例验证失败后只重试一次,后续测试继续复用成功会话。
  • 测试仍串行并受 1.5GB 容器内存硬限制。本轮没有重跑静态分析,也没有进行真机验证。

@tedzhouhk
tedzhouhk marked this pull request as ready for review August 12, 2026 03:54
@TNT-Likely

Copy link
Copy Markdown
Owner

感谢拆分,#434 已经合并了。这个 PR 我先提一件事,可能会影响你要不要保留其中一部分改动。

devices_page.dart 是一个从未接入的页面

我查了一下,这个页面全仓没有任何人引用

  • 没有任何文件 import
  • 没有任何地方构造 DevicesPage((除了它自己的构造函数声明)
  • 它的两个 l10n key cloudCollabDevicesPageTitle / cloudCollabDevicesPageSubtitle 只在这个文件内部使用
  • 云同步相关页面里也没有残留的入口或注释

从历史看,它是在 dfd5fc5(feat: BeeCount Cloud V2 双向同步 + 跨设备实时协同)那个大提交里进来的,之后一次都没有被修改过。所以是「写完了但一直没挂进导航」的孤儿页面。

也就是说,这个 PR 里对 devices_page.dart 的那 22 行改动落在不可达代码上,跑不到、也没法验证。

建议

devices_page.dart 从本 PR 摘掉,只保留 sync_providers.dartauthServiceProvider 复用共享实例那部分 + 对应测试。这样评审面就只有一个主题,我也好判断那两个行为变化。

设备页本身怎么处理我另外记了 todo,会单独决定 —— 它调的 listDevices() / revokeDevice() 服务端能力其实都是齐的,属于「后端和 UI 都做好了、就是没挂入口」,所以未必是删,也可能是补个入口。但不管哪种都该单独一个 PR,不该混在这次重构里。

关于保留部分,我仍然想确认的两点

这两条是我在 #434 里提到、希望单独评估的行为变化,麻烦你在这个 PR 里说明一下考虑:

  1. ref.onDispose(() => unawaited(provider.dispose())) 之后旧 provider 真的会被 dispose,而 activeCloudConfigProvider 有 7 处 invalidate。syncEngineProvider 是非 autoDispose 的 Provider.family,旧条目会继续持有已 dispose 的 provider。

    我自己的判断是实际后果不严重:provider.auth 会抛 CloudConfigurationException,退化成一条错误日志,而不是像以前那样悄悄再跑一轮重复同步;而且顺手把旧 WS 停了,算改善。但想听你的确认,尤其有没有在切换云服务配置的场景下实测过。

  2. authServiceProvider 改成 await ref.watch(activeCloudConfigProvider.future) 之后,如果 loadActive() 抛错会变成 AsyncError,而不是以前的 NoopAuthService()。它只读 SharedPreferences,概率很低,但 mine_page / cloud_sync_page 都在 watch 这个 provider,想确认一下它们的 .when 分支能兜住。

另外顺带说一句:_getCloudProvider() 里把 AppLocalizations.of(context) 提到 await 之前,顺手修掉了一个 BuildContext 跨异步间隙,这个改得对 —— 如果设备页最后决定保留,这一处值得留着。

@TNT-Likely

Copy link
Copy Markdown
Owner

补充一下上一条评论。除了摘掉 devices_page.dart,我还想请你补上问题描述,否则我倾向关掉这个 PR。

我需要的信息

对剩下的 authServiceProvider 复用共享实例这一项,麻烦说明:

  1. 现象 —— 用户或你实际观察到了什么?(报错、日志、复现步骤)
  2. 根因 —— 是从实际故障反推的,还是从代码推导出「理论上可能」的?
  3. 怎么验证 —— 真机上复现过原始故障、并确认修复后消失了吗?

#434 之所以能合,是因为这三条都齐了:issue #433 有明确报错文案、有用户报告、我按你的描述真机复现出来了、根因定位到两个登录入口的时序差异。这个 PR 目前没有对应的现象描述。

具体想问的是:#434 合并之后,authServiceProvider 那个独立实例还会造成什么可观察的问题?

我的理解是配置页、同步信息页和 SyncEngine 现在都走共享实例了,authServiceProvider 的独立实例只剩下 UI 登录态展示(mine_page / cloud_sync_page / beecount_cloud_sync_page 在 watch 它)。而那几处在登录成功后都有 ref.invalidate(authServiceProvider) 兜着,重建时会从 prefs 读到 session。所以我暂时想不出用户能看到什么异常 —— 如果你有具体场景,请说出来。

需要一起定的方向问题

这个 PR 和 #440同一个问题的两种解法

两个都做是重复投资。我倾向本 PR 这条路 —— 更小、而且让架构朝「全 App 唯一实例」收敛,#440 那些跨实例守卫在单实例下就没有存在意义了。

所以如果你要保这个 PR,请顺带说明你怎么看这个方向选择;如果你认为必须走 #440 那条,也请说明为什么消除多实例不够。

上一条提到的两点仍然需要确认

  1. ref.onDispose(() => unawaited(provider.dispose())) 之后旧 provider 真的会被 dispose,而 activeCloudConfigProvider 有 7 处 invalidate,syncEngineProvider 是非 autoDispose 的 Provider.family,旧条目会继续持有已 dispose 的 provider。有没有在切换云服务配置的场景下实测过?

  2. 改成 await ref.watch(activeCloudConfigProvider.future) 之后,loadActive() 抛错会变成 AsyncError 而不是 NoopAuthService()mine_page / cloud_sync_page.when 分支能兜住吗?

所以

  • 拿不出实际现象、也说不出这一项独立的必要性 → 建议关掉
  • 有具体场景 → 摘掉 devices_page.dart、补上说明,我评估后合

不是否定你的分析质量。只是在没有实测故障支撑的情况下改认证和 provider 生命周期,回归风险我没法评估。

@tedzhouhk

tedzhouhk commented Aug 15, 2026

Copy link
Copy Markdown
Contributor Author

已按建议处理,并补上 #434 合并后的实测现象:

  1. devices_page.dart 已恢复为主线版本,提交为 0052464。当前 PR diff 只剩:
    • lib/providers/sync_providers.dart
    • test/providers/beecount_cloud_auth_provider_test.dart
  2. 先前的 provider dispose() 生命周期改动已经移除,不会让缓存的 syncEngineProvider.family 持有已释放实例。
  3. activeCloudConfigProvider.future 的异常会进入现有 watch 页面的 .when(error: ...);直接 await 的登录/配置路径也已有异常处理。
  4. 已在 PR 描述补充 3.7.1 真机复现证据。简要时间线是:
    • 2FA verify 成功;
    • App refresh 返回 200;
    • WebSocket 正常建立;
    • 数秒后 App 再次发起 login 并拿到 2FA challenge;
    • 随后每 30 秒重复一次 login,间隔与 _silentRecoveryCooldown 完全一致。

这说明 #434 合并后,独立的 authServiceProvider 仍然有可观察故障,而不只是理论风险。针对这个现象,我同意优先采用本 PR 的小方案:让 UI auth 复用 SyncEngine 的 provider;#440 的完整跨实例锁与接管逻辑暂不纳入。

定向测试已在 Flutter 3.27.3、1.5GB 内存、2 CPU、单并发下通过:

flutter test --concurrency=1 test/providers/beecount_cloud_auth_provider_test.dart
00:13 +1: All tests passed!

@tedzhouhk

Copy link
Copy Markdown
Contributor Author

@TNT-Likely gentle ping

@TNT-Likely

Copy link
Copy Markdown
Owner

@tedzhouhk 我上次试了还没办法复现你这个问题

@tedzhouhk

Copy link
Copy Markdown
Contributor Author

@TNT-Likely 等一段时间了吗?刚登录是可用的。pr description里我attach了ai读取的log

20:03:03  2fa.verify.success
20:03:05  app auth.refresh -> 200
20:03:06  app WebSocket accepted
20:03:08  app auth.login -> requires_2fa
20:14:08  app auth.login -> requires_2fa
20:14:38  app auth.login -> requires_2fa
20:15:08  app auth.login -> requires_2fa

@TNT-Likely

Copy link
Copy Markdown
Owner

@tedzhouhk 等了 可以再看看提供下更详细的复现步骤

@tedzhouhk
tedzhouhk force-pushed the agent/unify-beecount-cloud-provider branch from 0052464 to b117465 Compare September 7, 2026 22:09
@tedzhouhk

Copy link
Copy Markdown
Contributor Author

@TNT-Likely 今天已在官方 3.8.0 Android 真机上稳定复现,步骤已缩短到:

  1. App 中已有可用的 BeeCount Cloud 配置(非首次配置)。
  2. 网页开启 2FA。
  3. App 重新登录并完成 TOTP。
  4. 登录后点「测试连接」。

不需要创建标签、切换本地模式或等 token 过期。脱敏日志:

21:47:13  app 2fa.verify -> 200
21:47:13  app sync.pull -> 200
21:47:14  app auth.login -> requires_2fa
21:47:15  app auth.refresh -> 200
21:47:17  app sync.pull -> 200
21:47:44  app auth.login -> requires_2fa

2FA 成功后不到 1 秒,另一套 auth 就重新进入 challenge,而持有正确 session 的实例仍在正常 refresh/sync;31 秒后重试与 _silentRecoveryCooldown 一致。复现中还曾在 0.2 秒内出现 3 次 challenge,而单实例有 _recoveryInFlight 去重,这也能直接证明存在多实例。

我已将分支 rebase 到 3.8.0/main,并按这次复现把 BeeCount Cloud「测试连接」也改为复用 beecountCloudProviderInstance。当前 PR 同时消除 UI auth 和测试连接的重复 provider 创建点。

两个定向测试在 1.5GB / 2 CPU / --concurrency=1 下通过(+2: All tests passed)。修复版 APK 的同步骤真机复测还没做,这一点已在 PR 描述中明确保留。

@TNT-Likely

Copy link
Copy Markdown
Owner

@tedzhouhk 感谢补齐 3.8.0 真机复现步骤和日志,这个现象与「测试连接额外创建一套 BeeCount Cloud auth/session」是一致的。

我重新审视了当前最终 diff,准备把这件事按两个层次处理:

这次 PR 建议保留

请保留 BeeCount Cloud 的「测试连接」改为复用 beecountCloudProviderInstance 的改动,以及它对应的 widget test / fake 计数断言。

这条路径直接覆盖了你给出的复现步骤,并且页面不再自行 createCloudServices,可以避免在 2FA 成功后又创建第二套 session。

请从本 PR 移除

请移除 sync_providers.dartauthServiceProvider 针对 BeeCount Cloud 的特判,以及只服务于该特判的 auth provider 测试。

它作为短期修复可以工作,但会让本应通用的 authServiceProvider 知道具体后端,并反向依赖 beecountCloudProviderInstance。服务实例的创建、复用、配置切换和释放因此会散落在多个 Provider 中;后续新增后端或处理临时配置测试时,容易继续累积特判。

后续较完整的方向

createCloudServices(config) 应保持「按配置创建一套新服务」的纯工厂语义;正式激活配置的复用则应由独立的 CloudSessionManager / activeCloudServicesProvider 统一持有:

active config
  -> CloudSessionManager(单实例、切换时释放旧会话)
      -> auth / storage / realtime
          -> 同步引擎、登录 UI、连接测试、设备页

这样所有正式业务路径取得的都是同一个 CloudSessionauthServiceProvider 和页面层都不需要写 BeeCount Cloud 特判;未保存的草稿配置则创建临时 session,测试结束后释放。

这部分属于一次独立的生命周期重构。目前问题集中在 BeeCount Cloud + 2FA 路径,且测试连接改动已经能修掉明确复现入口,我认为完整重构优先级不高,不应阻塞当前的小修复或 3.8.1。你如果有兴趣可以另开设计/实现 PR,我会很乐意一起评审;否则我们后续统一规划也可以。

@tedzhouhk tedzhouhk changed the title refactor: share one BeeCount Cloud provider instance refactor: 统一云服务会话管理与生命周期 Sep 13, 2026
@tedzhouhk

tedzhouhk commented Sep 13, 2026

Copy link
Copy Markdown
Contributor Author

@TNT-Likely 感谢回复和建议!我理解您希望先缩小修改范围,不过我考虑了一下,觉得单独修改「测试连接」的收益比较有限,还是希望从会话管理这一层统一处理,所以已经将这个 PR 调整为统一会话管理,并补充了相关回归测试。

2FA 对我来说是硬需求:我个人要求所有暴露在公网的服务都强制启用 2FA,因此 App 在开启 2FA 后能稳定登录和同步,对我非常重要。

这次调整扩大了 review 的范围,辛苦您了。方便的话,麻烦帮忙 review 一下,也欢迎指出实现中需要调整的地方。感谢!

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.

2 participants