2026-08-13 19:28:36 +08:00
|
|
|
|
# 11. 门店上下文与会话管理
|
|
|
|
|
|
|
|
|
|
|
|
## 为什么单独一篇
|
|
|
|
|
|
|
2026-08-21 13:46:56 +08:00
|
|
|
|
门店上下文是**贯穿整个 App 的隐式依赖**:首页 tile、菜单、购物车、待办、预警、订单、缓存表、H5 页面全部与"当前门店"绑定(PRD REQ-LGN-010:当前门店影响所有业务数据)。它不属于任何一个 `feature_*`,但每个 `feature_*` 都依赖它。
|
2026-08-13 19:28:36 +08:00
|
|
|
|
|
|
|
|
|
|
更关键的是**切换门店时的级联失效**——这是最容易漏、漏了就会出"看到别的门店数据"这种严重问题的地方。之前 01-10 里只在各自话题下提了一句(03 讲 provider 失效、06 讲缓存清理、10 讲 H5 失效),没有一个地方定义完整顺序。这一篇负责收口。
|
|
|
|
|
|
|
|
|
|
|
|
## 会话状态模型
|
|
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
AppSession
|
|
|
|
|
|
├── AuthState 登录态(token 生命周期,归 core_auth)
|
2026-08-21 13:46:56 +08:00
|
|
|
|
├── UserContext 用户上下文(PRD 第 4.1 节)
|
|
|
|
|
|
└── StoreContext 门店上下文(PRD REQ-LGN-010)
|
2026-08-13 19:28:36 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
```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;
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-08-21 13:46:56 +08:00
|
|
|
|
**四个状态,不是布尔值。** 用 `bool isLoggedIn` 表达会立刻遇到两个说不清的场景:冷启动期间算不算已登录(算,会闪一下首页;不算,会闪一下登录页),以及"已登录但没门店"该去哪(PRD REQ-LGN-010 要求登录后必须确定唯一「当前门店」,缺失时需引导重新选择,这是一个独立页面,既不是登录页也不是首页)。sealed class 让 `04-routing.md` 的 redirect 能穷举分支,漏一个编译器就报错。
|
2026-08-13 19:28:36 +08:00
|
|
|
|
|
|
|
|
|
|
```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
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-08-21 13:46:56 +08:00
|
|
|
|
`menus` 放在 `StoreContext` 里而不是 `UserContext` 里——PRD 第 4.2.5 节(导航收敛与角色化配置)明确菜单是**门店维度**的(同一个人在 A 店是店长、在 B 店是店员,菜单不同)。放错地方会导致切店后菜单不刷新。
|
2026-08-13 19:28:36 +08:00
|
|
|
|
|
|
|
|
|
|
## 唯一真相源
|
|
|
|
|
|
|
|
|
|
|
|
```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` 的请求让后端返回一堆空数据。
|
|
|
|
|
|
|
|
|
|
|
|
## 登录流程
|
|
|
|
|
|
|
2026-08-21 13:46:56 +08:00
|
|
|
|
PRD REQ-LGN-010:登录后必须确定唯一「当前门店」。
|
2026-08-13 19:28:36 +08:00
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
输入手机号 + 验证码(或账号密码)
|
|
|
|
|
|
↓
|
|
|
|
|
|
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)),冷启动/登录时用来预选,但**必须先确认它在后端返回的可访问列表里**——用户的门店权限可能已经被管理员回收了。
|
2026-08-21 13:46:56 +08:00
|
|
|
|
- **0 个门店时必须登出**,不能停在一个空白首页。PRD 第 4.1.1 节的异常流程把「账号无任何门店归属」列为阻断登录的分支。
|
2026-08-13 19:28:36 +08:00
|
|
|
|
|
|
|
|
|
|
## 切换门店:级联失效清单
|
|
|
|
|
|
|
2026-08-21 13:46:56 +08:00
|
|
|
|
这是本篇的核心。PRD REQ-LGN-010:切换门店时级联失效所有门店相关缓存与在途请求。
|
2026-08-13 19:28:36 +08:00
|
|
|
|
|
|
|
|
|
|
**顺序是有意义的**,不能随便调:
|
|
|
|
|
|
|
|
|
|
|
|
```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`,改动只在这一处。
|
|
|
|
|
|
|
|
|
|
|
|
## 登出:清理清单
|
|
|
|
|
|
|
2026-08-21 13:46:56 +08:00
|
|
|
|
PRD REQ-LGN-008(登出):清理本地会话、门店上下文、缓存的业务数据与 WebView Cookie。
|
2026-08-13 19:28:36 +08:00
|
|
|
|
|
|
|
|
|
|
```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 树,不重建 provider(provider 挂在 `ProviderScope` 上,在 `KeyedSubtree` 外面)。** 真正让业务 provider 全部失效的是"它们都直接或间接 `ref.watch(currentStoreIdProvider)` / `sessionNotifierProvider`"这条规则 —— 状态一变,`autoDispose` 的 provider 自然重算,`keepAlive` 的少数几个(见 03 的三类白名单)**必须在登出时显式 invalidate**,清单就是那三类,是有限且可维护的。
|
|
|
|
|
|
|
|
|
|
|
|
## 与 refresh token 轮换的配合
|
|
|
|
|
|
|
2026-08-21 13:46:56 +08:00
|
|
|
|
后端采用**一次性 refresh token + 重放即全量撤销**(见 [backend/04-security-auth.md](../backend/04-security-auth.md))。这对客户端有两条硬约束,已经在 [05-networking.md](./05-networking.md) 的 `AuthInterceptor` 里实现,这里说明它和会话状态的关系:
|
2026-08-13 19:28:36 +08:00
|
|
|
|
|
|
|
|
|
|
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 分钟)需要跑一个迭代后按实际接口压力调整。
|
|
|
|
|
|
|
|
|
|
|
|
## 参考链接
|
|
|
|
|
|
|
2026-08-21 13:46:56 +08:00
|
|
|
|
- [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)
|
2026-08-13 19:28:36 +08:00
|
|
|
|
- [Riverpod: Combining requests](https://riverpod.dev/docs/essentials/combining_requests)
|