Supabase 负责 App 账号体系(注册 / 登录 / 账号-设备绑定 / 登录会话 / 烹饪日志云同步),并承担账号级服务器区域锁定/读取与区域重置(profiles.server_region、lock_account_region、reset_region_data)。设备遥测/配对验证/设置下发这类设备实时数据通道仍全部走 MQTT broker(详见 MQTT 与云端);设备绑定列表的跨手机同步以前台 Supabase Realtime(public.devices 变更)为主;FCM 后台推送仅在 Android 已提供 culinatechFirebaseOptions、Firebase 项目完成配置时启用,否则 PushSyncService 会禁用并 no-op。即使 Firebase 已初始化,registerToken() 在 SupabaseConfig.useSupabase == false(stub/访客模式)时也会直接 return,不向 fcm_tokens 表写入。
broker 鉴权与 Supabase 的关系(2026-07-08 起分两种):生产区域 broker 里 cm4-sg 已切到直接读 Supabase 的 mqtt_accounts(未 mint 过的 CM4_* 设备暂时保留 legacy 共享密钥过渡),cm4-us / eu / hk 待切;开发 broker 仍读它本机 Postgres,不读 Supabase。
lib/core/services/supabase_bootstrap.dart —— 共享 ensureSupabaseInitialized():把 Supabase.initialize(...) 收口成单例/可重试初始化,供鉴权与 MQTT 凭据路径共用;它负责从本地恢复已持久化会话,并避免多处并发初始化触发重复初始化异常。
lib/core/config/supabase_config.dart —— project URL / anonKey / useSupabase 开关 / OAuth redirect
lib/features/auth/application/supabase_auth_service.dart —— 全部账号鉴权流 + delete_account
lib/features/auth/application/auth_providers.dart —— AuthController(StateNotifier)+ Supabase/Stub 选择
lib/core/services/device_binding_service.dart —— devices 表 bind / fetch / unbind + DeviceBindingRealtime
lib/core/services/account_session_service.dart —— 登录会话(≤4)注册/复用/心跳/幽灵清理
lib/core/services/cook_log_sync_service.dart —— sync_cook_logs RPC 封装(push+pull 单往返)
lib/core/services/account_mqtt_cred_service.dart —— 账号级 broker 凭据 + 配网时给盒子 mint 一机一密
lib/core/services/account_region_service.dart —— fetchLockedRegion() / lockRegion() / resetRegionData()
lib/core/services/push_sync_service.dart —— FCM 后台推送(见专页)
lib/core/providers/device_providers.dart —— syncAccountBoundDevices 编排、removeDevice、cook log 归属规则
lib/core/services/known_devices_service.dart —— 本地已知设备列表(SharedPreferences JSON 缓存)
lib/core/services/device_connection_manager.dart —— isWifiConfigured / markCloudConfigured / clearWifiConfig / cloud-primary 管理
backend/supabase/migrations/(0001–0010)—— 表、RLS、限额 trigger 与 RPC;0006 devices/sessions realtime、0007 一机一密用户密钥、0008 cook_logs 同步协议、0009 FCM 推送、0010 设备 MQTT 凭据跨账号重新绑定
auth.uid())Postgres 15,auth.users / auth.uid() 来自 Supabase Auth。所有业务表 enable row level security、按 auth.uid() 隔离。下面每张表在 App 客户端都已接线。
| 表 | 定义 | 作用 | 客户端接线 |
|---|---|---|---|
profiles |
0001:33-39 |
用户资料(id→auth.users、email、username),由 handle_new_user() SECURITY DEFINER trigger 自动填(0001:50-72);0004 加 server_region、0007 加 mqtt_secret |
读取账号锁定区域 + 账号级 MQTT 凭据(ensure_user_mqtt_cred()) |
devices |
0001:77-88 |
账号↔CM4 绑定:owner + device_name、unique(owner, device_name);enforce_device_limit() 上限 28/账号(0001:103-118) |
bind-on-connect / unbind-on-forget / fetch-on-login + Realtime 订阅 + FCM 推送触发源 |
account_sessions |
0001:123-130 |
登录会话;enforce_session_limit() 上限 4 登录/账号(0001:143-158);0006 进 realtime publication |
SupabaseAccountSessionService(TAPD #1003387):注册/触活/超限选择器/挤下线,详见下文§登录会话 |
cook_logs |
0001:163-175 |
云端烹饪日志:client 生成 id、payload jsonb、updated_at(LWW)、deleted 墓碑、server_synced_at 水位;0008 加 sync_cook_logs() RPC |
SupabaseCookLogSyncService + cookLogRealtimeProvider(TAPD #1003389),详见下文§烹饪日志云同步 |
mqtt_accounts |
0002:21-29 |
broker 鉴权:username PK(设备 CM4_XXXXXX 或用户 auth.uid())、password_hash(bcrypt)、kind CHECK('device','user');mint RPC 见 0002 + 0007 的 mint_device_mqtt_cred_v2(服务端生成密码) |
手机会话用账号级凭据(userCred()),配网时给盒子 mint 设备凭据并 M_ID=/M_PD= 写入,详见下文§一机一密 |
fcm_tokens |
0009 |
推送 token 注册表:token PK、owner、platform;owner RLS、边缘函数 service-role 读 |
PushSyncService(TAPD #1003381),见专页 |
另有 delete_account() RPC(0003:14-28,SECURITY DEFINER):删 auth.users 行 pinned 到 auth.uid(),级联清掉全部业务表,granted to authenticated。App 端的账号删除是验证码两步流:startAccountDeletion() 先向当前邮箱发 recovery OTP,confirmAccountDeletion() 用 OtpType.recovery 验证通过后才调 deleteAccount();代码里仍保留可直接调用的单步 AuthController.deleteAccount() 入口。
SupabaseAuthService(supabase_auth_service.dart)实现 AuthService 契约,由 SupabaseConfig.useSupabase(默认 true,自 2026-06-11)在 auth_providers.dart 选中(否则用 StubAuthService)。AuthController 是驱动 UI 的 StateNotifier。
register()→signUp;若 Supabase 后端关闭邮箱确认,AuthController.register() 会在 signUp 后检测到现成 session 并直接进入 signedIn;只有后端开启邮箱确认时才走 verifyCode()→verifyOTP(OtpType.signup)。6 位 OTP 走 dashboard 邮件模板,EmailCodeThrottle 限重发。login()→signInWithPassword,带 LOGIN_ATTEMPT/LOGIN_FAIL/LOGIN_OK 诊断(TAPD #1003251)。AuthRetryableFetchException 或包含主机查找、socket、连接重置、超时、TLS 握手等传输失败特征时,映射为 AuthError.network;已有 HTTP 响应的服务端错误不按网络故障处理。signInWithProvider()→signInWithOAuth(redirectTo: culinatech://login-callback),PKCE 浏览器回跳在 signedIn 事件 resolve;AuthController.cancelPendingOAuth() 可主动 supersede 挂起的 OAuth 并清掉 busy。resetPasswordForEmail→verifyOTP(recovery)→updateUser(TAPD #1003252,登出态三步重置)。UI 详见 忘记密码。startCredentialUpdate() 在 newEmail 为空或等于当前邮箱时走 recovery OTP 改密码;newEmail 是新地址时先 updateUser(email:) 触发 email-change OTP,OtpType.emailChange 验证后设新密码;resendCredentialCode() 已接。Supabase.initialize;ensureInitialized() 幂等,需要访问 Supabase 的鉴权方法开头调一次;cancelPendingOAuth() 仅取消本地挂起的 OAuth,不调用初始化。启动时 main.dart 的 _initKnownDevices() 在 mounted 后会先执行 _ensureAccountSession();只有它返回 false(如超限选择器取消,或该手机登录槽位已被撤销且账号仍存活)时才跳过后续账号同步。返回 true 时会继续依次 reconcileAccountRegion(ref)、syncAccountBoundDevices(ref),随后启动设备 Realtime、登录会话 Realtime、cook log Realtime,并立刻做一次 cookLogProvider.syncNow() 与账号存活监测;其中 SessionUnavailable(如 guest/stub/离线恢复/服务不可达)也属于继续执行的情况。⚠️ anonKey 硬编码在
supabase_config.dart(--dart-define可覆盖)。这是 Supabase 设计上的 public key,靠 RLS 兜底,但它确实编进了二进制。
SupabaseAccountSessionService(account_session_service.dart):
ensureRegistered(),登录/恢复会话时调一次):本机持久化过会话 id → 触活自己那行;行已被服务端删除 → SessionRevoked(被挤下线)。无本地 id(清数据/重装)→ 先按 device_name+platform 认领本机已有的行(最近活跃的那条,顺手删掉同名旧重复行),认领不到才 INSERT。enforce_session_limit() 拒绝(SQLSTATE 23514)→ 弹"选一台踢掉"选择器(第 5 台登录场景)。account_sessions 在 realtime publication 里(0006);自己的行被删 → 本机收到事件立即登出。60 秒心跳触活 last_seen_at。补充:客户端每次调用 sync_cook_logs 的默认拉取上限为 200 条,并将该值作为 p_limit 传给 RPC。响应中的 more: true 表示本页已满,调用方应继续使用返回的复合水位游标拉取后续页。
sync_cook_logs() RPC(0008)单往返 push+pull——上行带 dirty 行(guarded LWW upsert、tombstone 置空 payload),下行按复合游标 (server_synced_at, id) 拉增量;SECURITY INVOKER + 显式 owner 守卫。cookLogProvider 用 SupabaseCookLogSyncService(启用 Supabase 且非 mock 时);cookLogRealtimeProvider 会在当前账号的 cook_logs 收到任意 INSERT/UPDATE 后 debounce 再 syncNow(),并且每次成功 (re)subscribe 也会补触发一次 catch-up 同步;登录/恢复后启动 realtime 并立即同步一次。guest 历史在首次登录时标记待上传迁入账号;切换账号会清空本地 cook log 并重置同步元数据。云同步主键以客户端生成的 RFC-4122 v4 UUID(generateUuidV4())为准;旧版本遗留的 session_<epochMs>_<n> 本地 id 由 isUuidKey() 识别,首次同步时会被采纳/迁移为 UUID 键。模型:mqtt_accounts 按 kind 分设备凭据(CM4_XXXXXX)与用户凭据(auth.uid());密码只存 bcrypt。0007 加 profiles.mqtt_secret(账号的用户侧明文密钥,同账号 ≤4 台手机共享,RLS 保护)+ ensure_user_mqtt_cred() + mint_device_mqtt_cred_v2()(服务端 pgcrypto 生成随机密码,要求设备已在 devices 绑定)。
手机侧:cloudRegistryProvider 经 accountMqttCredServiceProvider.userCred() 给每个 cloud session 用账号级凭据登 broker;取不到才回退 legacy 共享密钥。clientId 带随机后缀避免多手机互踢。此外,userCred() 在冷启动时会先调用 ensureSupabaseInitialized().timeout(const Duration(seconds: 5)) 尝试恢复本地 Supabase 会话,目的是让第一次 broker 连接就尽量拿到账号级凭据,而不是先误用 legacy 共享密钥再等后续重试。
盒子侧:WiFi 配网时 App 调 mint_device_mqtt_cred_v2 给盒子 mint 专属凭据,通过 BLE M_ID=<设备号> / M_PD=<48位密钥> 写入盒子 NVS,并用 HOST= 把盒子钉到账号锁定区域的 broker(TAPD #1003153)。
mintDeviceCred() 会先以 5 秒超时恢复 Supabase 会话,尽量让首次配网即使用账号凭据。验证重发路径若传入 reuseRecent:true,会复用同一账号下本进程在 10 分钟内为该设备 mint 的密钥,避免重复轮换 broker 端密钥导致盒子短暂失联。
broker 侧:cm4-sg 已于 2026-07-08 切到 Supabase mqtt_accounts 鉴权(未 mint 的 CM4_* 暂留 legacy 密钥过渡);cm4-us / eu / hk 待切。
SupabaseDeviceBindingService —— 仅 CM4、best-effort(Postgrest/Socket 异常全吞,绝不抛进 connect 路径):
bind() gate 在 deviceId.boosterFamily.isCm4,upsert({owner, device_name}, onConflict:'owner,device_name', ignoreDuplicates:true),BLE 连接成功路径 fire-and-forget 调用。unbind() → delete().match({owner, device_name}),由 removeDevice 调用。实际 forgetDevice 流程还会先 endAllSessionsForDevice(deviceId) 结束该设备的进行中烹饪会话并归档到 cook log(镜像会话除外——归属在 owner 手机,见上节)。fetchBound() select('device_name')(RLS 自动 scope 到 owner),喂 syncAccountBoundDevices。这是 App 残余,不是服务器没删干净——服务器 unbind 正常工作。多台 CM4 WiFi 设备时,删掉一台非当前管理的,早期代码 stopManaging 空转、removeDevice 不碰云注册表 → 那台的 MQTT 会话带着 autoReconnect 继续连。
✅ 修复
7683b9b(2026-06-19):removeDevice无条件_cloud.disconnect(deviceId)(per-device + 幂等);clearWifiConfig(deviceId)清残余标志;syncAccountBoundDevices加isForgotten守卫,防止一次没到服务器的 best-effort unbind 在下次同步把设备重新拉起。
同步编排(syncAccountBoundDevices(WidgetRef)):
fetchBound() 拉账号绑定列表;失败/未登录返回 null → 立刻提前返回,绝不因一次失败同步误删本机设备。boosters.unforget(id)(本机以前忘记过、别的手机重新加回,也会重新放行);本地不存在的账号设备补成 KnownDevice(connectionMode:'cloud', wifiConfigured:true) + markCloudConfigured(id) + startManaging(id, bleOnly:false)。KnownDevicesService 里未被 forget、且"本机存有该设备 WiFi SSID"或"本机此刻真实持有其 BLE 连接"的 CM4 逐个 binding.bind()。removeDevice() 清掉;本机作为 host(仍有 SSID)或该设备当前 Booster.isBleConnected == true 时保留。这里代码检查的是合并后的连接标志;该标志由 BLE 直连或 wifiStatus != WifiStatus.disconnected 任一成立即可为 true。绑定变更如何到达其他手机(三层,详表见 FCM 专页):
DeviceBindingRealtime 订阅 public.devices——DELETE 从 oldRecord 取 device_name 立即 onDelete(权威删除,防止 re-bind 复活),随后 600ms debounce 全量 onSync。deviceBindingRealtimeProvider.stop(),再 _startDeviceRealtime() 重建通道,并主动执行 syncAccountBoundDevices(ref);同时还会执行 cookLogProvider.syncNow()。代码注释明确说明后台冻结后 realtime socket 可能被冻结并漏掉事件;恢复时不能假设 supabase_flutter 一定会再次触发 subscribed。相反,代码明确写了 frozen-then-thawed socket 可能在不重新发出 subscribed 的情况下恢复,而僵尸 channel 还会让 DeviceBindingRealtime.start() 直接 no-op,因此前台恢复时必须显式 deviceBindingRealtimeProvider.stop() → _startDeviceRealtime(),并主动执行一次 syncAccountBoundDevices(ref) 与 cookLogProvider.syncNow()。FirebaseMessaging.onMessage 收到 type=device-sync 的 data 消息也会直接 _wake(),执行与 Realtime onSync 相同的 syncAccountBoundDevices reconcile(不经 background isolate)。云连接管理:已配置 WiFi 且 broker 可达的 CM4 由 startManaging() 直接切 DeviceConnectionMode.cloud(MQTT-primary),同时 _ensureBleSession('mqtt-primary-hold-lock') 在后台保持/争抢 BLE 锁;既无 BLE 也无 MQTT 的 WiFi 设备由 _cloudRetryTimer 每 15 秒 _superviseCloud() 补试云连接,初次 TLS 建连失败不会永久卡死。
markCloudConfigured(deviceId):设本地 SharedPrefs 布尔 wifi_configured_<id>(不存 SSID——这台手机没见过那个网)。isWifiConfigured(deviceId):读本地 SharedPrefs,从不查服务器;在 startManaging + failover handler 里 gate 云资格(副手机靠 markCloudConfigured backfill 取得资格,TAPD #1003297,修复 b71ef77)。副手机确实会拉到绑定列表并真的开 MQTT 云连接,但卡片只有真 telemetry 到达才变在线(云侧掉线判定:silentFor > 60s,由 5 秒轮询触发)。仍灰的两个原因:
FEATURE_PLAN_MULTIPHONE.md)。App 没有任何本地数据库——无 sqflite / drift / isar / hive / objectbox。所有本地持久化都是 SharedPreferences 键值对(多为 JSON 字符串),不是 Supabase 的本地镜像。
known_devices(KnownDevice 列表 JSON),登录时 fetchBound() 拉取后追加/PRUNE(规则见上节);还持久化 remoteId(BLE 直连重连用)和 wifiMac。cook_log_sessions(上限 50 条)+ 进行中会话 active_cooking_sessions;云同步元数据 cook_log_sync.owner / .watermark / .dirty / .tombstones。wifi_configured_<id> / wifi_ssid_<id>;WiFi 密码不落地(配网时直接 BLE 下发给盒子)。SupabaseAccountMqttCredService 会把账号级 broker 明文密钥缓存在 SharedPreferences,本地 key 分别是 account_mqtt.secret 与 account_mqtt.owner;切账号或登出时会清除这份缓存,下一次登录再重新取回。SupabaseAccountSessionService 还会在 SharedPreferences 持久化 account_session.row_id 与 account_session.owner,供下次启动用本地 id 触活自己的 account_sessions 行;无 id 时则按 device_name+platform 认领已有行。supabase_flutter 默认 SharedPreferencesGotrueAsyncStorage → token 存明文 SharedPreferences,不是 Keychain/Keystore。登出清理(wipeLocalDeviceData):停 realtime、stopAll() 断云会话、逐设备 clearWifiConfig、清 KnownDevicesService 与内存设备列表;_teardownLocalDevices() 逐个 markForgotten / removeFromReconnectQueue / disconnectDevice,防止尾随 BLE 遥测复活卡片。纯本地清理,不调服务器 unbind,其他手机不受影响。
账号被删的跨手机收口:checkAccountLiveness() 只有 GoTrue 返回明确的"用户已不存在"信号(does not exist / user_not_found 等)才判 deleted——离线/DNS 失败/泛化 403 一律 unknown,宁可延后,不可误删本地数据。确认删除后走 wipeAllLocalData():比登出更彻底,清空整个 SharedPreferences(语言、温标、通知、引导标记、区域锁定等),并当场重置 region / 温标 / 语言 provider,本机回到接近首装状态。
0002:97-102):mint_device_mqtt_cred 与 devices 绑定无持有证明,CM4_XXXXXX 可被 BLE 扫描 → 预抢注 DoS。Beta/Liang territory。Supabase.initialize 传 flutter_secure_storage 后端的 gotrueAsyncStorage,目前没做。mqtt_accounts 鉴权(cm4-sg 已切,2026-07-08)。相关页:FCM 后台推送同步 · MQTT 与云端 · CM4 协议 · 仓库结构