# 11. 门店上下文与会话管理 ## 为什么单独一篇 门店上下文是**贯穿整个 App 的隐式依赖**:首页 tile、菜单、购物车、待办、预警、订单、缓存表、H5 页面全部与"当前门店"绑定(PRD REQ-LGN-010:当前门店影响所有业务数据)。它不属于任何一个 `feature_*`,但每个 `feature_*` 都依赖它。 更关键的是**切换门店时的级联失效**——这是最容易漏、漏了就会出"看到别的门店数据"这种严重问题的地方。之前 01-10 里只在各自话题下提了一句(03 讲 provider 失效、06 讲缓存清理、10 讲 H5 失效),没有一个地方定义完整顺序。这一篇负责收口。 ## 会话状态模型 ``` AppSession ├── AuthState 登录态(token 生命周期,归 core_auth) ├── UserContext 用户上下文(PRD 第 4.1 节) └── StoreContext 门店上下文(PRD REQ-LGN-010) ``` ```dart // packages/core_auth/lib/src/model/app_session.dart sealed class AppSession {} /// 冷启动读本地态期间,UI 停在 splash class SessionLoading extends AppSession {} class SessionUnauthenticated extends AppSession { final LogoutReason? reason; // 主动登出 / token 失效 / 被踢,用于登录页提示文案 } /// 已登录但还没确定门店(多门店用户需要选,或门店列表拉取失败) class SessionAwaitingStore extends AppSession { final UserContext user; } class SessionActive extends AppSession { final UserContext user; final StoreContext store; } ``` **四个状态,不是布尔值。** 用 `bool isLoggedIn` 表达会立刻遇到两个说不清的场景:冷启动期间算不算已登录(算,会闪一下首页;不算,会闪一下登录页),以及"已登录但没门店"该去哪(PRD REQ-LGN-010 要求登录后必须确定唯一「当前门店」,缺失时需引导重新选择,这是一个独立页面,既不是登录页也不是首页)。sealed class 让 `04-routing.md` 的 redirect 能穷举分支,漏一个编译器就报错。 ```dart final class UserContext { final String userId, employeeId, phone, roleCode, channel; final Set permissions; // 权限集 } final class StoreContext { final int storeId; final String storeCode, storeName; final int orgId; final String? parentStoreId; // 所属总店,无则为分店/独立店 final List menus; // 当前门店可访问菜单,见 04-routing.md 的 menuRouteMap } ``` `menus` 放在 `StoreContext` 里而不是 `UserContext` 里——PRD 第 4.2.5 节(导航收敛与角色化配置)明确菜单是**门店维度**的(同一个人在 A 店是店长、在 B 店是店员,菜单不同)。放错地方会导致切店后菜单不刷新。 ## 唯一真相源 ```dart @riverpod class SessionNotifier extends _$SessionNotifier { @override Future build() async { ... } } /// 全 App 读 storeId 的唯一入口 @riverpod int currentStoreId(Ref ref) { final session = ref.watch(sessionNotifierProvider).valueOrNull; return switch (session) { SessionActive(:final store) => store.storeId, _ => throw StateError('在没有门店上下文时访问了 currentStoreId'), }; } ``` 规则(与 [03-state-management.md](./03-state-management.md) 一致): - **任何请求里带 storeId 的 provider,必须 `ref.watch(currentStoreIdProvider)` 拿它**,不能 `ref.read`,也不能作为参数从上层传下来。这样切店时依赖图自动失效,不需要维护"哪些 provider 要手动 invalidate"的清单——那份清单一定会漏。 - `currentStoreId` 在非 `SessionActive` 时**抛异常而不是返回 0 或 null**。能读到这个 provider 说明 UI 已经渲染到了业务页面,此时没有门店上下文是路由守卫的 bug,应该在开发期直接炸出来,而不是发一个 `storeId=0` 的请求让后端返回一堆空数据。 ## 登录流程 PRD REQ-LGN-010:登录后必须确定唯一「当前门店」。 ``` 输入手机号 + 验证码(或账号密码) ↓ POST /api/v1/auth/login → { accessToken, refreshToken, user } ↓ 写入 secure storage(core_auth 独占,见 06) ↓ GET /api/v1/stores/accessible → 门店列表 ↓ ┌────┴────┬──────────────┐ 0 个 1 个 多个 ↓ ↓ ↓ "无门店权限" 直接选中 上次门店仍在列表 → 选中 提示 + 登出 否则 → 门店选择页 ↓ POST /api/v1/stores/{id}/switch → StoreContext(含菜单) ↓ SessionActive → 跳首页 ``` 几个容易做错的点: - **`stores/accessible` 失败不等于登录失败**。token 已经拿到了,此时应该进 `SessionAwaitingStore` 并展示一个可重试的页面,而不是回登录页让用户重新发一遍验证码。 - **"上次门店"只是一个提示,不是权限依据**。它存在 `shared_preferences`(非敏感,见 [06-local-storage.md](./06-local-storage.md)),冷启动/登录时用来预选,但**必须先确认它在后端返回的可访问列表里**——用户的门店权限可能已经被管理员回收了。 - **0 个门店时必须登出**,不能停在一个空白首页。PRD 第 4.1.1 节的异常流程把「账号无任何门店归属」列为阻断登录的分支。 ## 切换门店:级联失效清单 这是本篇的核心。PRD REQ-LGN-010:切换门店时级联失效所有门店相关缓存与在途请求。 **顺序是有意义的**,不能随便调: ```dart Future switchStore(int targetStoreId) async { // ── 0. 前置:有未完成的写操作就拦住 ────────────────── if (ref.read(pendingWriteProvider).isNotEmpty) { throw const PreconditionException('有未完成的操作,请稍后再试'); // 本地判定,不编后端错误码,见 12 } // ── 1. 先让 UI 进入切换中,挡住用户继续操作 ────────── state = const AsyncLoading(); // ── 2. 服务端切换(失败则整个流程中止,本地状态不动)── final newStore = await _repo.switchStore(targetStoreId); // ── 3. 关闭 H5 会话(不清 Cookie,见 10)───────────── await ref.read(webViewSessionProvider).invalidateAll(clearCookies: false); // ── 4. 清本地业务缓存(事务内,见 06)──────────────── await ref.read(appDatabaseProvider).clearBusinessCache(); // ── 5. 落新的门店上下文 → 依赖 currentStoreId 的 provider 自动失效 ── state = AsyncData(SessionActive(user: _user, store: newStore)); // ── 6. 路由清栈回首页(见 04)──────────────────────── ref.read(goRouterProvider).go('/home'); // ── 7. 记住这次选择,供下次冷启动预选 ──────────────── await ref.read(prefsProvider).setInt('last_store_id', newStore.storeId); // ── 8. 同步观测上下文(见 13)──────────────────────── ref.read(crashReporterProvider).setTag('storeId', '${newStore.storeId}'); ref.read(analyticsProvider).registerSuperProperties({'storeId': newStore.storeId}); // 切店事件本身由后端从 /stores/{id}/switch 的接口日志出,客户端不重复上报,见 13 } ``` | 步 | 为什么必须在这个位置 | |---|---| | 2 在 3/4 之前 | 服务端切换失败(网络断、权限被回收)时**本地必须原样不动**。反过来先清缓存再请求,一旦失败用户就停在一个"门店没变但数据全没了"的状态 | | 3 在 5 之前 | H5 页面里可能有在途请求。先 `about:blank` 停掉,再换上下文,否则旧门店的 H5 请求会带着新门店的票据回来 | | 4 在 5 之前 | 缓存表带 `storeId`(见 06),但**清理和新上下文之间不能有窗口期**:如果先落新上下文,provider 立刻失效并重新请求,可能在清理完成前就把新数据写进去,然后被 `clearBusinessCache()` 一起删掉 | | 6 在 5 之后 | 清栈时目标页面(首页)要用新上下文渲染 | | 8 在 5 之后 | 观测上下文要和业务上下文保持一致;漏了这一步的表现是**切店后的崩溃和埋点还挂在旧门店名下**,按门店维度分析时数据是错的,而且错得很隐蔽 | **关于步骤 0(未完成写操作)**:切店时用户可能正在提交订单或上传图片。默认策略是**阻止切换并提示**,而不是静默取消——取消一个已经发出去的下单请求,客户端不知道服务端到底成没成。`pendingWriteProvider` 由发起写操作的 feature 自己注册/注销。 **关于购物车**:PRD 要求切店后购物车同步切换。购物车走 `clearBusinessCache()` 一起清(它是门店维度的业务数据)。如果后续产品要求"每个门店各自保留购物车",那就改成按 `storeId` 分区保留而不是清空——表结构已经带 `storeId`,改动只在这一处。 ## 登出:清理清单 PRD REQ-LGN-008(登出):清理本地会话、门店上下文、缓存的业务数据与 WebView Cookie。 ```dart Future logout({LogoutReason reason = LogoutReason.userInitiated}) async { // 1. 通知服务端撤销 refresh token(尽力而为,失败不阻断本地登出) if (reason == LogoutReason.userInitiated) { await _repo.revokeSession().timeout(const Duration(seconds: 3)).catchError((_) {}); } // 2. H5 会话 + Cookie/LocalStorage/Cache 全清(见 10) await ref.read(webViewSessionProvider).invalidateAll(clearCookies: true); // 3. 本地数据 await ref.read(appDatabaseProvider).clearAllUserData(); // Drift 业务表 await ref.read(secureStorageProvider).deleteAll(); // token await ref.read(prefsProvider).clearUserScoped(); // 只清用户相关的 key // 4. 状态置为未登录 → 路由守卫自动跳登录页 state = AsyncData(SessionUnauthenticated(reason: reason)); // 5. 断开观测/埋点的用户关联(门店设备是共用的,不断开会让下一个人的数据串到上一个人身上) ref.read(analyticsProvider) ..track(AnalyticsEvent.logout, {'reason': reason.name}) // 报完再 reset,顺序不能反 ..reset(); ref.read(crashReporterProvider).setUser(''); // 6. 兜底:清掉所有 provider 缓存 ref.invalidate(...); // 或在 ProviderScope 层重建,见下文 } ``` 要点: - **第 1 步失败不能阻断登出**。网络不通时用户点登出必须能退出去,否则用户体验是"这个 App 退不出来"。服务端 token 会自然过期,不撤销的代价可以接受。加 3 秒超时。 - **`prefs.clearUserScoped()` 而不是 `prefs.clear()`**。`shared_preferences` 里还有"是否同意过协议""夜间模式偏好""是否看过新手引导"这类设备级配置,全清会导致下一个用户看一遍新手引导。约定:用户相关的 key 统一加 `u_` 前缀,`clearUserScoped()` 按前缀删。 - **必须等第 2/3 步完成再切状态**。fire-and-forget 会出现"新用户已经登录进首页了,上一个用户的缓存清理才刚跑完",然后把新用户的数据也删了。门店共用设备上这不是理论问题。 - **`clearCookies: true` 在登出时是硬要求**。不清的话下一个人打开 H5 会直接落进上一个人的 F6 会话——这是本项目最有可能出现的一个真实安全事故。 ### 登出兜底:为什么还要一步 provider 清理 `ref.invalidate` 一个个点名会漏。更稳的做法是让整个业务 provider 树挂在一个 key 上重建: ```dart // main.dart ProviderScope( retry: (_, __) => null, child: Consumer(builder: (context, ref, _) { final sessionKey = ref.watch(sessionKeyProvider); // 每次登录/登出自增 return KeyedSubtree(key: ValueKey(sessionKey), child: const ContiApp()); }), ) ``` **注意这只重建 widget 树,不重建 provider(provider 挂在 `ProviderScope` 上,在 `KeyedSubtree` 外面)。** 真正让业务 provider 全部失效的是"它们都直接或间接 `ref.watch(currentStoreIdProvider)` / `sessionNotifierProvider`"这条规则 —— 状态一变,`autoDispose` 的 provider 自然重算,`keepAlive` 的少数几个(见 03 的三类白名单)**必须在登出时显式 invalidate**,清单就是那三类,是有限且可维护的。 ## 与 refresh token 轮换的配合 后端采用**一次性 refresh token + 重放即全量撤销**(见 [backend/04-security-auth.md](../backend/04-security-auth.md))。这对客户端有两条硬约束,已经在 [05-networking.md](./05-networking.md) 的 `AuthInterceptor` 里实现,这里说明它和会话状态的关系: 1. **刷新必须串行**。并发刷新会把同一个 refresh token 用两次,后端判定为重放,**撤销该用户所有设备的会话**——用户会在自己毫无操作的情况下被全端踢下线。 2. **刷新失败立即登出,不重试**。失败意味着 refresh token 已失效(过期、被撤销、或已被重放),重试只会再触发一次重放判定。 ```dart // TokenRefresher 刷新失败 → 通知会话层 void _onRefreshFailed() { ref.read(sessionNotifierProvider.notifier).logout(reason: LogoutReason.tokenExpired); } ``` `LogoutReason.tokenExpired` 让登录页能显示「登录已过期,请重新登录」而不是一个没有解释的空登录页。用户在别处被踢(`sessionRevoked`)时提示文案也不同。 ## 冷启动恢复 ``` App 启动 → SessionLoading(splash) ↓ 读 secure storage 的 token ↓ 没有 → SessionUnauthenticated 有 → GET /api/v1/auth/me + /stores/accessible ↓ ┌───┴────────────────┬─────────────────┐ 成功 401 网络失败 ↓ ↓ ↓ 预选 last_store_id → 登出 进首页 + 用本地缓存渲染 → SessionActive (见 12 的降级约定) ``` - **secure storage 读失败要当作未登录处理**,不能让异常冒到启动流程里(见 [06-local-storage.md](./06-local-storage.md) 关于 `flutter_secure_storage 11.0.0` 的说明)。启动崩溃是最难排查也最致命的一类问题。 - **网络失败时不要把用户踢到登录页**。门店里网络不稳是常态,本地有 token 就先按已登录处理,用缓存渲染首页,顶部提示"数据可能不是最新"。真正无效的 token 会在第一个业务请求返回 401 时被发现,那时再登出。 - splash 有**最长等待时间**(3 秒)。超时就按"网络失败"分支走,不能无限转圈。 ## 回到前台时的一致性校验 App 从后台回来时,服务端的门店权限可能已经变了(管理员回收了权限、门店被停用)。 ```dart // 冷时间超过 5 分钟才校验,避免频繁切前后台打接口 if (elapsedSinceBackground > const Duration(minutes: 5)) { final stores = await _repo.fetchAccessibleStores(); if (!stores.any((s) => s.storeId == currentStoreId)) { // 当前门店已不可访问 await switchStore(stores.first.storeId); // 或引导重选 showToast('您对当前门店的权限已变更,已切换到 ${stores.first.storeName}'); } } ``` 不做这个校验的后果是:用户带着一个已失效的 storeId 继续操作,每个请求都被后端拒绝,界面上表现为"什么都点不动但也不说为什么"。 ## 埋点 会话相关事件大部分**由后端从自己的接口日志出**(登录、切店都是接口调用),客户端不重复报(见 [13-observability-analytics.md](./13-observability-analytics.md) 的分工原则)。客户端只补后端看不到的两件事: | 事件 | 谁报 | 关键字段 | |---|---|---| | 登录成功/失败、门店切换 | **后端** | 接口日志即可,客户端不重复上报 | | `logout` | **客户端** | `reason`(userInitiated / tokenExpired / sessionRevoked)。**被动登出往往没有对应的接口调用**——token 刷新失败是客户端本地判定的,后端只看到一个失败的刷新请求,看不到"用户因此被踢了出去" | | `session_restore_failed` | **客户端** | 失败阶段(读 storage / me / stores)。冷启动恢复失败在读 secure storage 这一步时**完全不产生网络请求**,后端无从知晓 | `logout` 的 `reason` 分布是最有价值的一个指标——如果 `tokenExpired` 占比异常高,说明刷新逻辑有问题(很可能就是并发刷新触发了后端的重放撤销)。这个指标只能由客户端提供。 ## 待确认项 - `/api/v1/stores/accessible` 与 `/api/v1/stores/{id}/switch` 的接口契约,以及切换是否需要服务端记录(影响多端一致性)。 - 切店时"未完成写操作"的判定粒度:是全局阻止,还是只阻止发起写操作的那个 feature。 - 购物车是否需要按门店分别保留(当前决策:清空)。 - 前台一致性校验的触发阈值(当前定 5 分钟)需要跑一个迭代后按实际接口压力调整。 ## 参考链接 - [PRD 第 4.1 节 账号登录(REQ-LGN-008 登出 / REQ-LGN-010 门店上下文)](../prd/Continental-Retail-APP-PRD.md) - [backend/04-security-auth.md:refresh token 轮换](../backend/04-security-auth.md) - [Riverpod: Combining requests](https://riverpod.dev/docs/essentials/combining_requests)