Files
conti-retail-app/docs/11-store-context-and-session.md
T
2026-08-17 15:29:55 +08:00

17 KiB
Raw Blame History

11. 门店上下文与会话管理

为什么单独一篇

门店上下文是贯穿整个 App 的隐式依赖:首页 tile、菜单、购物车、待办、预警、订单、缓存表、H5 页面全部与"当前门店"绑定(PRD §11.4:「当前门店影响所有业务数据」)。它不属于任何一个 feature_*,但每个 feature_* 都依赖它。

更关键的是切换门店时的级联失效——这是最容易漏、漏了就会出"看到别的门店数据"这种严重问题的地方。之前 01-10 里只在各自话题下提了一句(03 讲 provider 失效、06 讲缓存清理、10 讲 H5 失效),没有一个地方定义完整顺序。这一篇负责收口。

会话状态模型

AppSession
├── AuthState        登录态(token 生命周期,归 core_auth
├── UserContext      用户上下文(PRD §6.4.1
└── StoreContext     门店上下文(PRD §6.4.2
// 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 §11.3 要求「门店上下文缺失时引导重新选择门店」,这是一个独立页面,既不是登录页也不是首页)。sealed class 让 04-routing.md 的 redirect 能穷举分支,漏一个编译器就报错。

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 §6.4.2 明确菜单是门店维度的(同一个人在 A 店是店长、在 B 店是店员,菜单不同)。放错地方会导致切店后菜单不刷新。

唯一真相源

@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 一致):

  • 任何请求里带 storeId 的 provider,必须 ref.watch(currentStoreIdProvider) 拿它,不能 ref.read,也不能作为参数从上层传下来。这样切店时依赖图自动失效,不需要维护"哪些 provider 要手动 invalidate"的清单——那份清单一定会漏。
  • currentStoreId 在非 SessionActive抛异常而不是返回 0 或 null。能读到这个 provider 说明 UI 已经渲染到了业务页面,此时没有门店上下文是路由守卫的 bug,应该在开发期直接炸出来,而不是发一个 storeId=0 的请求让后端返回一堆空数据。

登录流程

PRD §10.1:「登录成功后必须立即获取门店上下文」。

输入手机号 + 验证码(或账号密码)
        ↓
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),冷启动/登录时用来预选,但必须先确认它在后端返回的可访问列表里——用户的门店权限可能已经被管理员回收了。
  • 0 个门店时必须登出,不能停在一个空白首页。PRD §10.1/§10.2 把"用户无门店权限"列为登录异常流程。

切换门店:级联失效清单

这是本篇的核心。PRD §11.4:「购物车、待办、预警、订单和 H5 页面上下文必须同步切换」。

顺序是有意义的,不能随便调:

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 §10.4:「清理 Token、门店上下文、本地用户信息和缓存」+「关闭所有已打开的 F6 H5 会话」。

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 上重建:

// 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)。这对客户端有两条硬约束,已经在 05-networking.mdAuthInterceptor 里实现,这里说明它和会话状态的关系:

  1. 刷新必须串行。并发刷新会把同一个 refresh token 用两次,后端判定为重放,撤销该用户所有设备的会话——用户会在自己毫无操作的情况下被全端踢下线。
  2. 刷新失败立即登出,不重试。失败意味着 refresh token 已失效(过期、被撤销、或已被重放),重试只会再触发一次重放判定。
// 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 关于 flutter_secure_storage 11.0.0 的说明)。启动崩溃是最难排查也最致命的一类问题。
  • 网络失败时不要把用户踢到登录页。门店里网络不稳是常态,本地有 token 就先按已登录处理,用缓存渲染首页,顶部提示"数据可能不是最新"。真正无效的 token 会在第一个业务请求返回 401 时被发现,那时再登出。
  • splash 有最长等待时间(3 秒)。超时就按"网络失败"分支走,不能无限转圈。

回到前台时的一致性校验

App 从后台回来时,服务端的门店权限可能已经变了(管理员回收了权限、门店被停用)。

// 冷时间超过 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 的分工原则)。客户端只补后端看不到的两件事:

事件 谁报 关键字段
登录成功/失败、门店切换 后端 接口日志即可,客户端不重复上报
logout 客户端 reasonuserInitiated / tokenExpired / sessionRevoked)。被动登出往往没有对应的接口调用——token 刷新失败是客户端本地判定的,后端只看到一个失败的刷新请求,看不到"用户因此被踢了出去"
session_restore_failed 客户端 失败阶段(读 storage / me / stores)。冷启动恢复失败在读 secure storage 这一步时完全不产生网络请求,后端无从知晓

logoutreason 分布是最有价值的一个指标——如果 tokenExpired 占比异常高,说明刷新逻辑有问题(很可能就是并发刷新触发了后端的重放撤销)。这个指标只能由客户端提供。

待确认项

  • /api/v1/stores/accessible/api/v1/stores/{id}/switch 的接口契约,以及切换是否需要服务端记录(影响多端一致性)。
  • 切店时"未完成写操作"的判定粒度:是全局阻止,还是只阻止发起写操作的那个 feature。
  • 购物车是否需要按门店分别保留(当前决策:清空)。
  • 前台一致性校验的触发阈值(当前定 5 分钟)需要跑一个迭代后按实际接口压力调整。

参考链接