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

303 lines
17 KiB
Markdown
Raw Normal View History

# 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)