Files
conti-docs/flutter-app/11-store-context-and-session.md
T

303 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<String> permissions; // 权限集
}
final class StoreContext {
final int storeId;
final String storeCode, storeName;
final int orgId;
final String? parentStoreId; // 所属总店,无则为分店/独立店
final List<MenuItem> menus; // 当前门店可访问菜单,见 04-routing.md 的 menuRouteMap
}
```
`menus` 放在 `StoreContext` 里而不是 `UserContext` 里——PRD 第 4.2.5 节(导航收敛与角色化配置)明确菜单是**门店维度**的(同一个人在 A 店是店长、在 B 店是店员,菜单不同)。放错地方会导致切店后菜单不刷新。
## 唯一真相源
```dart
@riverpod
class SessionNotifier extends _$SessionNotifier {
@override
Future<AppSession> 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 storagecore_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<void> 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<void> 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 树,不重建 providerprovider 挂在 `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 启动 → SessionLoadingsplash
读 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.mdrefresh token 轮换](../backend/04-security-auth.md)
- [Riverpod: Combining requests](https://riverpod.dev/docs/essentials/combining_requests)