解决的问题(TAPD #1003381):同一账号登录两台手机,副机放在后台时,主机绑定/删除设备,副机收不到——Android 会挂起后台应用的网络 socket,Supabase Realtime 通道断开,且重连后不会重放断线期间错过的事件。Liang 的需求是副机在后台就要收到数据同步,这在 Android 上只有一条被系统认可的通道:FCM(经 Google Play services 的常驻连接,豁免 Doze 限制)。
| 层 | 场景 | 机制 |
|---|---|---|
| 1. 前台实时 | App 在前台、realtime 通道活着 | Supabase Realtime 订阅 public.devices,DELETE 立即处理 + 600ms debounce 全量 reconcile(见 Supabase 账号与设备同步) |
| 2. 回前台补偿 | 后台断线→回前台 | realtime 通道每次(重新)达成 subscribed 都触发一次全量 fetchBound reconcile(device_binding_service.dart,TAPD #1003381 上半,commit a46437c) |
| 3. 后台推送 | App 仍在后台 | 本页主题:FCM data-only 推送唤醒 App,在系统给的短执行窗口内跑同一个 debounced 同步(TAPD #1003381 下半,commit f7dfb34) |
| 兜底 | 进程被杀 | 推送无处投递(isolate 端口不存在),下次启动的 startup sync 收口 |
主机 bind/unbind
→ public.devices INSERT / DELETE
→ notify_device_sync 触发器(migration 0009,AFTER INSERT OR DELETE)
→ pg_net 异步 HTTP POST(secret 存 private.webhook_config;行缺失时触发器 no-op)
→ push-device-sync 边缘函数(verify_jwt=false,校验 x-webhook-secret)
→ FCM v1 HTTP API:HIGH 优先级 data-only 消息 {type: "device-sync"}
→ 群发给 fcm_tokens 里该账号的全部 token;FCM 回 UNREGISTERED 的 token 当场删除
→ 副机被唤醒 → 后台 isolate 捅主 isolate → syncAccountBoundDevices
关键设计点:
owner,device_name 冲突做 no-op(ignoreDuplicates: true),触发器只挂 INSERT/DELETE。FCM_SERVICE_ACCOUNT(Firebase service-account JSON,2026-07-10 配置)经 WebCrypto RS256 自行换取 OAuth token,不依赖任何 SDK;secret 未配置时回 200 fcm-not-configured(链路照常走通,只是不发)。在一次真实的后台→前台转换后,App 会主动停止并重建 deviceBindingRealtimeProvider 的 devices realtime 通道,随后调用 syncAccountBoundDevices(ref) 全量拉齐设备列表。这覆盖了 socket 冻结后恢复却未重新触发 subscribed 的情形。
lib/core/config/firebase_push_config.dart 的 culinatechFirebaseOptions(apiKey / appId / messagingSenderId / projectId,Firebase 项目 culinatech-app,commit f11224f)。这些是客户端标识,本来就会编进 APK,不是机密。刻意只做 Dart 侧初始化、不用 google-services gradle 插件,构建不依赖 google-services.json 文件。置回 null 即整体休眠(PushSyncService.init() 记 PUSH_SYNC_DISABLED 直接返回)。163d893):init() 对非 Android 平台直接记 PUSH_SYNC_DISABLED 返回——Firebase 项目没有 iOS App/APNs 配置,把 Android 的 options 喂给 iOS SDK 有原生崩溃风险。iOS 维持第 1/2 层行为。main.dart 在 runApp() 之前 await PushSyncService.instance.init(),保证后台 handler 在任何推送可能到达前注册完毕。pushSyncBackgroundHandler,@pragma('vm:entry-point')):跑在独立 isolate,可能没有 Flutter App 活着,因此不碰 provider / Supabase / DevLog,唯一动作是通过 IsolateNameServer 的 culinatech_push_sync 端口捅主 isolate;主 isolate 侧 onDeviceSync 接的就是 realtime 同款 debounced syncAccountBoundDevices。前台收到推送则跳过 isolate 直接 _wake。另外,main.dart 里 push.onDeviceSync 还会并行调用 _checkAccountLiveness();也就是说,这条设备同步推送链路除了拉齐 devices 列表,还会顺带加速“账号已在另一台手机删除”这种状态在本机的收敛。POST_NOTIFICATIONS 权限、不显示任何 UI。| 时机 | 动作 |
|---|---|
| 登录 / 恢复会话 | registerToken():fcm_tokens upsert(token 为 PK——换账号登录时同一台手机的 token 行改归属,不产生重复行)+ 挂 onTokenRefresh 监听 |
| token 轮换 | onTokenRefresh → 同一 upsert |
| 退出登录 | best-effort delete(会话已失效时被 RLS 挡成 no-op,可接受:残留行只会把已登出的 App 唤醒去跑一次立即返回的同步) |
| FCM 报 UNREGISTERED | 边缘函数当场删除该 token 行(兜底清理) |
fcm_tokens 表(migration 0009):token PK、owner、platform;owner 范围 RLS,边缘函数用 service-role 读。
PUSH_SYNC_READY(FCM 接好)/ PUSH_SYNC_DISABLED(休眠及原因)/ PUSH_SYNC_WAKE(前台/后台推送唤醒)/ PUSH_SYNC_TOKEN(token 注册/失败)。lib/core/services/push_sync_service.dart —— 客户端全部逻辑(init / 后台 handler / token 生命周期)lib/core/config/firebase_push_config.dart —— Firebase 客户端配置(唯一的开关)lib/main.dart —— init() 时机、onDeviceSync 接线、登出时 unregisterToken()backend/supabase/migrations/0009_fcm_push_sync.sql —— fcm_tokens 表 + private.webhook_config + notify_device_sync 触发器backend/supabase/functions/push-device-sync/index.ts —— 边缘函数(OAuth 换token、FCM 发送、token 清理)相关页:Supabase 账号与设备同步 · MQTT 与云端