- Introduced a new document outlining SDK version locking, static analysis, formatting, generated artifacts management, branching and commit conventions, and CI gate checks. - Updated README to include the new conventions document. - Modified API design to use numeric error codes instead of strings, with a dedicated ErrorCode object for better maintainability. - Adjusted global exception handling to return numeric error codes. - Updated tests to reflect changes in error code handling.
17 KiB
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 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),冷启动/登录时用来预选,但必须先确认它在后端返回的可访问列表里——用户的门店权限可能已经被管理员回收了。 - 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 树,不重建 provider(provider 挂在 ProviderScope 上,在 KeyedSubtree 外面)。 真正让业务 provider 全部失效的是"它们都直接或间接 ref.watch(currentStoreIdProvider) / sessionNotifierProvider"这条规则 —— 状态一变,autoDispose 的 provider 自然重算,keepAlive 的少数几个(见 03 的三类白名单)必须在登出时显式 invalidate,清单就是那三类,是有限且可维护的。
与 refresh token 轮换的配合
后端采用一次性 refresh token + 重放即全量撤销(见 backend/04-security-auth.md)。这对客户端有两条硬约束,已经在 05-networking.md 的 AuthInterceptor 里实现,这里说明它和会话状态的关系:
- 刷新必须串行。并发刷新会把同一个 refresh token 用两次,后端判定为重放,撤销该用户所有设备的会话——用户会在自己毫无操作的情况下被全端踢下线。
- 刷新失败立即登出,不重试。失败意味着 refresh token 已失效(过期、被撤销、或已被重放),重试只会再触发一次重放判定。
// 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 关于
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 |
客户端 | 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 分钟)需要跑一个迭代后按实际接口压力调整。