app scaffold
This commit is contained in:
@@ -0,0 +1 @@
|
||||
include: ../../analysis_options.yaml
|
||||
@@ -0,0 +1,13 @@
|
||||
/// 会话与门店上下文。来源:conti-docs/11-store-context-and-session.md。
|
||||
///
|
||||
/// 依赖约束(01,编译期强制):本包**没有任何 `core_*` 出边**。
|
||||
/// 11 的伪代码里那些跨包调用全部通过 `src/session_ports.dart` 的接口反转,
|
||||
/// 由 app 层组装。`core_auth ↛ core_network` 尤其重要——否则 `AuthInterceptor`
|
||||
/// 和刷新逻辑会互相纠缠成环,这也是 [TokenRefresher] 用裸 Dio 的原因。
|
||||
library;
|
||||
|
||||
export 'src/models.dart';
|
||||
export 'src/session_notifier.dart';
|
||||
export 'src/session_ports.dart';
|
||||
export 'src/token_refresher.dart';
|
||||
export 'src/token_storage.dart';
|
||||
@@ -0,0 +1,166 @@
|
||||
/// 会话与上下文的数据模型。来源:conti-docs/11-store-context-and-session.md。
|
||||
library;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// 用户上下文(PRD §6.4.1)。
|
||||
///
|
||||
/// 只放**跨门店恒定**的信息。菜单不在这里——同一个人在 A 店是店长、在 B 店是
|
||||
/// 店员,菜单是门店维度的,见 [StoreContext.menus]。
|
||||
@immutable
|
||||
class UserContext {
|
||||
/// 构造。
|
||||
const UserContext({
|
||||
required this.userId,
|
||||
required this.employeeId,
|
||||
required this.phone,
|
||||
required this.roleCode,
|
||||
required this.channel,
|
||||
this.permissions = const <String>{},
|
||||
});
|
||||
|
||||
/// 用户唯一标识。
|
||||
final String userId;
|
||||
|
||||
/// 员工工号。
|
||||
final String employeeId;
|
||||
|
||||
/// 手机号。**打日志前必须脱敏**(见 core_logging 的 `maskPhone`)。
|
||||
final String phone;
|
||||
|
||||
/// 角色编码。
|
||||
final String roleCode;
|
||||
|
||||
/// 渠道。
|
||||
final String channel;
|
||||
|
||||
/// 权限集。
|
||||
final Set<String> permissions;
|
||||
}
|
||||
|
||||
/// 菜单项。路由映射见 04 的 `menuRouteMap`。
|
||||
@immutable
|
||||
class MenuItem {
|
||||
/// 构造。
|
||||
const MenuItem({required this.code, required this.name, this.children = const <MenuItem>[]});
|
||||
|
||||
/// 后端下发的稳定编码,客户端据此查本地路由表。
|
||||
final String code;
|
||||
|
||||
/// 展示名。
|
||||
final String name;
|
||||
|
||||
/// 子菜单。
|
||||
final List<MenuItem> children;
|
||||
}
|
||||
|
||||
/// 门店上下文(PRD §6.4.2)。
|
||||
@immutable
|
||||
class StoreContext {
|
||||
/// 构造。
|
||||
const StoreContext({
|
||||
required this.storeId,
|
||||
required this.storeCode,
|
||||
required this.storeName,
|
||||
required this.orgId,
|
||||
this.parentStoreId,
|
||||
this.menus = const <MenuItem>[],
|
||||
});
|
||||
|
||||
/// 门店 ID。全 App 只能通过 `currentStoreIdProvider` 读它。
|
||||
final int storeId;
|
||||
|
||||
/// 门店编码。
|
||||
final String storeCode;
|
||||
|
||||
/// 门店名。
|
||||
final String storeName;
|
||||
|
||||
/// 组织 ID。
|
||||
final int orgId;
|
||||
|
||||
/// 所属总店;为空表示分店 / 独立店。
|
||||
final String? parentStoreId;
|
||||
|
||||
/// 当前门店可访问的菜单。
|
||||
final List<MenuItem> menus;
|
||||
}
|
||||
|
||||
/// 登出原因。决定登录页展示什么提示文案,也是 `logout` 埋点的关键字段。
|
||||
enum LogoutReason {
|
||||
/// 用户主动点了退出。
|
||||
userInitiated,
|
||||
|
||||
/// refresh token 失效(过期 / 被撤销 / 被判定重放)。
|
||||
tokenExpired,
|
||||
|
||||
/// 在别处登录被踢。
|
||||
sessionRevoked,
|
||||
}
|
||||
|
||||
/// 会话状态。
|
||||
///
|
||||
/// **四个状态,不是一个 `bool isLoggedIn`**(11 §会话状态模型):冷启动读本地态
|
||||
/// 期间、以及"已登录但还没确定门店"这两种情况用布尔值表达不了,而后者需要跳到
|
||||
/// 一个既不是登录页也不是首页的独立页面。
|
||||
///
|
||||
/// `sealed` 让 04 的路由 redirect 能穷举分支——漏一个状态编译器就报错。
|
||||
sealed class AppSession {
|
||||
/// 构造。
|
||||
const AppSession();
|
||||
}
|
||||
|
||||
/// 冷启动读本地态期间,UI 停在 splash。
|
||||
final class SessionLoading extends AppSession {
|
||||
/// 构造。
|
||||
const SessionLoading();
|
||||
}
|
||||
|
||||
/// 未登录。
|
||||
final class SessionUnauthenticated extends AppSession {
|
||||
/// 构造。
|
||||
const SessionUnauthenticated({this.reason});
|
||||
|
||||
/// 为空表示从未登录过(首次安装),非空则是被登出的原因。
|
||||
final LogoutReason? reason;
|
||||
}
|
||||
|
||||
/// 已登录但还没确定门店:多门店用户需要选,或门店列表拉取失败需要重试。
|
||||
final class SessionAwaitingStore extends AppSession {
|
||||
/// 构造。
|
||||
const SessionAwaitingStore({required this.user, this.candidates = const <StoreContext>[]});
|
||||
|
||||
/// 已确定的用户上下文。
|
||||
final UserContext user;
|
||||
|
||||
/// 可选门店列表。拉取失败时为空,此时页面应展示重试而不是"无门店权限"。
|
||||
final List<StoreContext> candidates;
|
||||
}
|
||||
|
||||
/// 完整会话。只有这个状态下业务页面才允许渲染。
|
||||
final class SessionActive extends AppSession {
|
||||
/// 构造。
|
||||
const SessionActive({required this.user, required this.store});
|
||||
|
||||
/// 用户上下文。
|
||||
final UserContext user;
|
||||
|
||||
/// 当前门店上下文。
|
||||
final StoreContext store;
|
||||
}
|
||||
|
||||
/// 一对 token。
|
||||
///
|
||||
/// 后端采用**一次性 refresh token + 重放即全量撤销**,所以刷新拿到的新
|
||||
/// refreshToken 必须立刻覆盖存储,旧的已经作废了(见 05 / backend 04)。
|
||||
@immutable
|
||||
class TokenPair {
|
||||
/// 构造。
|
||||
const TokenPair({required this.accessToken, required this.refreshToken});
|
||||
|
||||
/// 访问令牌。
|
||||
final String accessToken;
|
||||
|
||||
/// 刷新令牌。**一次性**,用过即废。
|
||||
final String refreshToken;
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
/// 会话唯一真相源。来源:conti-docs/11-store-context-and-session.md。
|
||||
library;
|
||||
|
||||
import 'package:core_foundation/core_foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import 'models.dart';
|
||||
import 'session_ports.dart';
|
||||
import 'token_refresher.dart';
|
||||
import 'token_storage.dart';
|
||||
|
||||
part 'session_notifier.g.dart';
|
||||
|
||||
/// 全 App 唯一的会话状态持有者。
|
||||
@Riverpod(keepAlive: true)
|
||||
class SessionNotifier extends _$SessionNotifier {
|
||||
/// 冷启动恢复(11 §冷启动恢复)。
|
||||
///
|
||||
/// 三条约定:
|
||||
/// - 读 secure storage 失败当作未登录([TokenStorage] 内部已兜底)。
|
||||
/// - **网络失败时不要把用户踢到登录页**。门店里网络不稳是常态,本地有 token
|
||||
/// 就先按已登录处理;真正无效的 token 会在第一个业务请求返回 401 时被发现。
|
||||
/// - 门店列表拉取失败 → [SessionAwaitingStore],不是回登录页。
|
||||
@override
|
||||
Future<AppSession> build() async {
|
||||
final String? token = await ref.read(tokenStorageProvider).readAccessToken();
|
||||
if (token == null || token.isEmpty) {
|
||||
return const SessionUnauthenticated();
|
||||
}
|
||||
|
||||
final SessionRemote remote = ref.read(sessionRemoteProvider);
|
||||
|
||||
final UserContext user;
|
||||
try {
|
||||
user = await remote.fetchCurrentUser();
|
||||
} on UnauthorizedException {
|
||||
_notifyRestoreFailed('me');
|
||||
await ref.read(tokenStorageProvider).clear();
|
||||
return const SessionUnauthenticated(reason: LogoutReason.tokenExpired);
|
||||
}
|
||||
_notifyUser(user);
|
||||
|
||||
final List<StoreContext> stores;
|
||||
try {
|
||||
stores = await remote.fetchAccessibleStores();
|
||||
} on Object {
|
||||
_notifyRestoreFailed('stores');
|
||||
// 拿不到门店列表就停在选店页并允许重试,而不是登出。
|
||||
return SessionAwaitingStore(user: user);
|
||||
}
|
||||
|
||||
return _resolveStore(user, stores);
|
||||
}
|
||||
|
||||
/// 登录成功后调用:写 token → 拉门店 → 定上下文(11 §登录流程)。
|
||||
///
|
||||
/// 具体的账号密码 / 验证码请求由 `feature_auth` 发,这里只接手 token 之后的
|
||||
/// 部分——**「登录成功后必须立即获取门店上下文」**(PRD §10.1)。
|
||||
Future<void> onLoggedIn({required TokenPair tokens, required UserContext user}) async {
|
||||
state = const AsyncLoading<AppSession>();
|
||||
await ref.read(tokenStorageProvider).save(tokens);
|
||||
_notifyUser(user);
|
||||
|
||||
state = await AsyncValue.guard(() async {
|
||||
final List<StoreContext> stores = await ref
|
||||
.read(sessionRemoteProvider)
|
||||
.fetchAccessibleStores();
|
||||
|
||||
// 0 个门店必须登出,不能停在空白首页(PRD §10.1/§10.2 把「用户无门店权限」
|
||||
// 列为登录异常流程)。
|
||||
if (stores.isEmpty) {
|
||||
await logout(reason: LogoutReason.userInitiated);
|
||||
throw const BusinessException(-1, '您当前没有可访问的门店,请联系管理员');
|
||||
}
|
||||
|
||||
return _resolveStore(user, stores);
|
||||
});
|
||||
}
|
||||
|
||||
/// 切换门店。
|
||||
///
|
||||
/// **下面每一步的顺序都是有意义的,改动前先读 11 §切换门店的那张表。**
|
||||
Future<void> switchStore(int targetStoreId) async {
|
||||
final AppSession? current = state.value;
|
||||
if (current is! SessionActive && current is! SessionAwaitingStore) {
|
||||
throw const PreconditionException('当前不在可切换门店的状态');
|
||||
}
|
||||
|
||||
// ── 0. 前置:有未完成的写操作就拦住 ────────────────────────────────
|
||||
// 默认阻止而不是静默取消:取消一个已经发出去的下单请求,客户端不知道
|
||||
// 服务端到底成没成。本地判定,不编后端错误码(12)。
|
||||
if (ref.read(pendingWritesProvider).isNotEmpty) {
|
||||
throw const PreconditionException('有未完成的操作,请稍后再试');
|
||||
}
|
||||
|
||||
final UserContext user = switch (current) {
|
||||
SessionActive(:final UserContext user) => user,
|
||||
SessionAwaitingStore(:final UserContext user) => user,
|
||||
_ => throw const PreconditionException('当前不在可切换门店的状态'),
|
||||
};
|
||||
|
||||
// ── 1. 先让 UI 进入切换中,挡住用户继续操作 ────────────────────────
|
||||
state = const AsyncLoading<AppSession>();
|
||||
|
||||
// ── 2. 服务端切换。失败则整个流程中止,本地状态原样不动 ──────────────
|
||||
// 反过来先清缓存再请求,一旦失败用户就停在"门店没变但数据全没了"的状态。
|
||||
final StoreContext newStore;
|
||||
try {
|
||||
newStore = await ref.read(sessionRemoteProvider).switchStore(targetStoreId);
|
||||
} on Object {
|
||||
// 本地状态原样回滚。不用 AsyncError.copyWithPrevious——它在 Riverpod 3 里
|
||||
// 是 internal;而且这里本来就该回到"什么都没发生",错误由 rethrow 交给
|
||||
// 调用方(切店页)自己展示。
|
||||
state = AsyncData<AppSession>(current!);
|
||||
rethrow;
|
||||
}
|
||||
|
||||
// ── 3 & 4. 关 H5 会话(不清 Cookie)+ 清本地业务缓存 ────────────────
|
||||
// 必须在第 5 步之前:H5 页面里可能有在途请求,先停掉,否则旧门店的 H5
|
||||
// 请求会带着新门店的票据回来;缓存清理和新上下文之间也不能有窗口期,
|
||||
// 否则新数据可能刚写进去就被一起删掉。
|
||||
for (final SessionScopedStore store in ref.read(sessionScopedStoresProvider)) {
|
||||
await store.onStoreChanged();
|
||||
}
|
||||
|
||||
// ── 5. 落新上下文 → 依赖 currentStoreId 的 provider 自动失效 ─────────
|
||||
state = AsyncData<AppSession>(SessionActive(user: user, store: newStore));
|
||||
|
||||
// ── 6. 路由清栈回首页 ───────────────────────────────────────────────
|
||||
// core_auth 不能依赖 core_router(01)。反过来由 core_router 监听本
|
||||
// provider 做 refresh + 清栈,见 core_router 的 goRouterProvider。
|
||||
|
||||
// ── 7 & 8. 记住这次选择 + 同步观测上下文 ────────────────────────────
|
||||
// 第 8 步漏掉的表现是切店后的崩溃和埋点还挂在旧门店名下,很隐蔽。
|
||||
for (final SessionObserver observer in ref.read(sessionObserversProvider)) {
|
||||
observer.onStoreChanged(newStore);
|
||||
}
|
||||
}
|
||||
|
||||
/// 登出(11 §登出:清理清单)。
|
||||
Future<void> logout({LogoutReason reason = LogoutReason.userInitiated}) async {
|
||||
// ── 1. 通知服务端撤销 refresh token ─────────────────────────────────
|
||||
// 尽力而为。网络不通时用户点登出必须能退出去,否则体验是"这个 App 退不出来"。
|
||||
// 服务端 token 会自然过期,不撤销的代价可以接受。
|
||||
if (reason == LogoutReason.userInitiated) {
|
||||
try {
|
||||
await ref.read(sessionRemoteProvider).revokeSession().timeout(const Duration(seconds: 3));
|
||||
} on Object {
|
||||
// 故意吞掉。
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2 & 3. 清 H5 会话(含 Cookie)+ 清本地数据 ───────────────────────
|
||||
// **必须等这两步完成再切状态**:fire-and-forget 会出现"新用户已经进首页了,
|
||||
// 上一个用户的缓存清理才刚跑完",然后把新用户的数据也删了。门店共用设备上
|
||||
// 这不是理论问题。
|
||||
for (final SessionScopedStore store in ref.read(sessionScopedStoresProvider)) {
|
||||
try {
|
||||
await store.onSessionEnded();
|
||||
} on Object {
|
||||
// 某一处清理失败不能卡住登出,继续清剩下的。
|
||||
}
|
||||
}
|
||||
await ref.read(tokenStorageProvider).clear();
|
||||
|
||||
// ── 4. 状态置未登录 → 路由守卫自动跳登录页 ──────────────────────────
|
||||
state = AsyncData<AppSession>(SessionUnauthenticated(reason: reason));
|
||||
|
||||
// ── 5. 断开观测/埋点的用户关联 ──────────────────────────────────────
|
||||
// 门店设备是共用的,不断开会让下一个人的数据串到上一个人身上。
|
||||
for (final SessionObserver observer in ref.read(sessionObserversProvider)) {
|
||||
observer.onSessionEnded(reason);
|
||||
}
|
||||
|
||||
// ── 6. 兜底 provider 清理 ───────────────────────────────────────────
|
||||
// 不逐个 ref.invalidate 点名(一定会漏)。业务 provider 全部直接或间接
|
||||
// watch 本 provider 或 currentStoreIdProvider,状态一变 autoDispose 的自然
|
||||
// 重算;少数 keepAlive 的白名单由 app 层在 sessionScopedStores 里处理。
|
||||
}
|
||||
|
||||
/// 门店列表拉取失败后的重试入口,给 [SessionAwaitingStore] 页面用。
|
||||
Future<void> retryStoreLoad() async {
|
||||
final AppSession? current = state.value;
|
||||
if (current is! SessionAwaitingStore) return;
|
||||
|
||||
state = const AsyncLoading<AppSession>();
|
||||
state = await AsyncValue.guard(() async {
|
||||
final List<StoreContext> stores = await ref
|
||||
.read(sessionRemoteProvider)
|
||||
.fetchAccessibleStores();
|
||||
return _resolveStore(current.user, stores);
|
||||
});
|
||||
}
|
||||
|
||||
/// 按 11 §登录流程的三分支决定落到哪个状态。
|
||||
Future<AppSession> _resolveStore(UserContext user, List<StoreContext> stores) async {
|
||||
if (stores.isEmpty) {
|
||||
return SessionAwaitingStore(user: user);
|
||||
}
|
||||
|
||||
// "上次门店"只是一个提示,不是权限依据:**必须先确认它在后端返回的可访问
|
||||
// 列表里**,用户的门店权限可能已经被管理员回收了。
|
||||
// 上次门店 ID 由 app 层通过 lastStoreIdProvider 从 prefs 读入(core_auth
|
||||
// 不能依赖 core_storage)。
|
||||
final int? lastStoreId = ref.read(lastStoreIdProvider);
|
||||
final StoreContext? preselected = stores
|
||||
.where((StoreContext s) => s.storeId == lastStoreId)
|
||||
.firstOrNull;
|
||||
|
||||
final StoreContext? target = preselected ?? (stores.length == 1 ? stores.first : null);
|
||||
if (target == null) {
|
||||
// 多门店且没有可用的上次选择 → 选店页。
|
||||
return SessionAwaitingStore(user: user, candidates: stores);
|
||||
}
|
||||
|
||||
// 走一次服务端 switch 才能拿到菜单(列表接口不下发菜单)。
|
||||
final StoreContext store = await ref.read(sessionRemoteProvider).switchStore(target.storeId);
|
||||
for (final SessionObserver observer in ref.read(sessionObserversProvider)) {
|
||||
observer.onStoreChanged(store);
|
||||
}
|
||||
return SessionActive(user: user, store: store);
|
||||
}
|
||||
|
||||
void _notifyUser(UserContext user) {
|
||||
for (final SessionObserver observer in ref.read(sessionObserversProvider)) {
|
||||
observer.onUserIdentified(user);
|
||||
}
|
||||
}
|
||||
|
||||
void _notifyRestoreFailed(String stage) {
|
||||
for (final SessionObserver observer in ref.read(sessionObserversProvider)) {
|
||||
observer.onSessionRestoreFailed(stage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [TokenStorage] 的注入点。测试里 override 成假实现。
|
||||
@Riverpod(keepAlive: true)
|
||||
TokenStorage tokenStorage(Ref ref) => TokenStorage();
|
||||
|
||||
/// [TokenRefresher] 的注入点。
|
||||
///
|
||||
/// **必须 keepAlive**:串行刷新靠的是实例内部那个共享的在途 Future,实例被
|
||||
/// 回收重建就等于失去了串行保证,而并发刷新会触发后端的重放撤销(11)。
|
||||
@Riverpod(keepAlive: true)
|
||||
TokenRefresher tokenRefresher(Ref ref) => TokenRefresher(storage: ref.watch(tokenStorageProvider));
|
||||
|
||||
/// 上次选中的门店 ID,用于冷启动/登录时预选。
|
||||
///
|
||||
/// 值来自 `shared_preferences`(非敏感),由 app 层 override——core_auth 不能
|
||||
/// 依赖 core_storage。默认 null 表示不预选。
|
||||
final Provider<int?> lastStoreIdProvider = Provider<int?>((Ref ref) => null);
|
||||
|
||||
/// 未完成的写操作。切店的前置检查读它(11 §步骤 0)。
|
||||
///
|
||||
/// 由发起写操作的 feature 自己 add/remove,值是一个能在日志里认出来的标识。
|
||||
@Riverpod(keepAlive: true)
|
||||
class PendingWrites extends _$PendingWrites {
|
||||
@override
|
||||
Set<String> build() => const <String>{};
|
||||
|
||||
/// 登记一个在途写操作。
|
||||
void add(String tag) => state = <String>{...state, tag};
|
||||
|
||||
/// 注销。**必须放在 finally 里**,否则一次失败的下单会永久挡住切店。
|
||||
void remove(String tag) => state = <String>{...state}..remove(tag);
|
||||
}
|
||||
|
||||
/// 全 App 读 storeId 的唯一入口。
|
||||
///
|
||||
/// **任何请求里带 storeId 的 provider 必须 `ref.watch` 它**,不能 `ref.read`,
|
||||
/// 也不能作为参数从上层传下来。这样切店时依赖图自动失效,不需要维护
|
||||
/// "哪些 provider 要手动 invalidate"的清单——那份清单一定会漏(11 / 03)。
|
||||
///
|
||||
/// 非 [SessionActive] 时**抛异常而不是返回 0 或 null**:能读到这个 provider
|
||||
/// 说明 UI 已经渲染到业务页面了,此时没有门店上下文是路由守卫的 bug,应该在
|
||||
/// 开发期直接炸出来,而不是发一个 `storeId=0` 的请求让后端返回一堆空数据。
|
||||
@riverpod
|
||||
int currentStoreId(Ref ref) {
|
||||
final int? storeId = ref.watch(currentStoreIdOrNullProvider);
|
||||
if (storeId == null) {
|
||||
throw StateError('在没有门店上下文时访问了 currentStoreId');
|
||||
}
|
||||
return storeId;
|
||||
}
|
||||
|
||||
/// 可空版本。
|
||||
///
|
||||
/// **只给合法地在选店之前就要运行的基础设施用**——目前只有 core_network 的
|
||||
/// `HeaderInterceptor`(登录、拉门店列表这些请求本身就发生在有门店之前)。
|
||||
/// 业务代码一律用 [currentStoreIdProvider],用可空版会把"忘了选店"变成
|
||||
/// 一个安静的空数据页。
|
||||
@riverpod
|
||||
int? currentStoreIdOrNull(Ref ref) {
|
||||
final AppSession? session = ref.watch(sessionProvider).value;
|
||||
return switch (session) {
|
||||
SessionActive(:final StoreContext store) => store.storeId,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/// core_auth 的对外端口。
|
||||
///
|
||||
/// ---------------------------------------------------------------------------
|
||||
/// 为什么需要这个文件
|
||||
///
|
||||
/// 11 的 `switchStore` / `logout` 伪代码直接 `ref.read` 了
|
||||
/// `webViewSessionProvider`(core_webview)、`appDatabaseProvider`(core_storage)、
|
||||
/// `prefsProvider`(core_storage)、`goRouterProvider`(core_router)、
|
||||
/// `crashReporterProvider`(core_logging)、`analyticsProvider`(core_analytics)。
|
||||
///
|
||||
/// 但 01 的依赖约束里,`core_auth` 一条 `core_*` 出边都没有(连允许的三条例外
|
||||
/// 都是别人指向它)。照抄伪代码会把 core_auth 变成整个 core 层的汇聚点,
|
||||
/// 循环依赖立刻出现。
|
||||
///
|
||||
/// 裁决:**依赖反转**。core_auth 只声明"级联时需要有人做这些事",具体由谁做、
|
||||
/// 怎么做,在 app 层组装时 override 进来。级联顺序(11 §切换门店的核心价值)
|
||||
/// 仍然完整保留在 SessionNotifier 里。
|
||||
///
|
||||
/// 见根目录 SCAFFOLD-NOTES.md。
|
||||
/// ---------------------------------------------------------------------------
|
||||
library;
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'models.dart';
|
||||
|
||||
/// 会话相关的服务端调用。
|
||||
///
|
||||
/// 实现放在 `feature_auth`(它同时依赖 core_auth 和 core_network),
|
||||
/// 在 bootstrap 里 override 进来。core_auth 自己**不能**依赖 core_network。
|
||||
abstract interface class SessionRemote {
|
||||
/// 冷启动恢复:用本地 token 换用户上下文。token 无效时抛 `UnauthorizedException`。
|
||||
Future<UserContext> fetchCurrentUser();
|
||||
|
||||
/// 拉可访问门店列表。
|
||||
///
|
||||
/// **失败不等于登录失败**(11 §登录流程):token 已经拿到了,应该进
|
||||
/// [SessionAwaitingStore] 展示可重试的页面,而不是回登录页让用户重发验证码。
|
||||
Future<List<StoreContext>> fetchAccessibleStores();
|
||||
|
||||
/// 服务端切店,返回带菜单的新门店上下文。
|
||||
Future<StoreContext> switchStore(int storeId);
|
||||
|
||||
/// 撤销 refresh token。**尽力而为**,失败不阻断本地登出。
|
||||
Future<void> revokeSession();
|
||||
}
|
||||
|
||||
/// 会话结束 / 门店切换时需要被级联清理的一方。
|
||||
///
|
||||
/// 由持有用户态数据的包各自实现(core_webview 的 H5 会话、core_storage 的
|
||||
/// 缓存与 prefs),在 app 层注册进 [sessionScopedStoresProvider]。
|
||||
///
|
||||
/// **新增一处用户态存储时,实现这个接口并注册**,就自动进入了级联清理清单——
|
||||
/// 比维护一份"哪些东西要清"的文档清单可靠,那份清单一定会漏。
|
||||
abstract interface class SessionScopedStore {
|
||||
/// 出问题时能在日志里认出是谁。
|
||||
String get debugName;
|
||||
|
||||
/// 切店:清门店维度的数据。
|
||||
///
|
||||
/// H5 会话在这里要 `invalidateAll(clearCookies: false)`——**不清 Cookie**,
|
||||
/// 用户还是同一个人,清了等于让 H5 重新登录一次(10 / 11)。
|
||||
Future<void> onStoreChanged();
|
||||
|
||||
/// 登出:清全部用户数据。
|
||||
///
|
||||
/// H5 会话在这里必须 `clearCookies: true`。不清的话下一个人打开 H5 会直接
|
||||
/// 落进上一个人的 F6 会话——门店设备是共用的,这是本项目最可能出现的
|
||||
/// 真实安全事故(11)。
|
||||
Future<void> onSessionEnded();
|
||||
}
|
||||
|
||||
/// 会话变化时需要同步的观测 / 埋点侧。
|
||||
///
|
||||
/// 由 app 层用 core_logging 的 `CrashReporter` 和 core_analytics 的 `Analytics`
|
||||
/// 实现。**漏了这一步的表现是切店后的崩溃和埋点还挂在旧门店名下**,按门店维度
|
||||
/// 分析时数据是错的,而且错得很隐蔽(11 步骤 8)。
|
||||
abstract interface class SessionObserver {
|
||||
/// 门店上下文已更新。
|
||||
void onStoreChanged(StoreContext store);
|
||||
|
||||
/// 用户已确定。
|
||||
void onUserIdentified(UserContext user);
|
||||
|
||||
/// 已登出。实现里必须**先报事件再 reset**,顺序不能反(11 步骤 5)。
|
||||
void onSessionEnded(LogoutReason reason);
|
||||
|
||||
/// 冷启动恢复失败。失败发生在读 secure storage 这一步时**完全不产生网络请求**,
|
||||
/// 后端无从知晓,只能客户端报(13 / 11 §埋点)。
|
||||
void onSessionRestoreFailed(String stage);
|
||||
}
|
||||
|
||||
/// [SessionRemote] 的注入点。必须在 bootstrap 里 override。
|
||||
final Provider<SessionRemote> sessionRemoteProvider = Provider<SessionRemote>(
|
||||
(Ref ref) => throw UnimplementedError('sessionRemoteProvider 必须在 bootstrap() 里 override'),
|
||||
);
|
||||
|
||||
/// 级联清理的注册表。默认空——app 层负责把各包的实现装进来。
|
||||
final Provider<List<SessionScopedStore>> sessionScopedStoresProvider =
|
||||
Provider<List<SessionScopedStore>>((Ref ref) => const <SessionScopedStore>[]);
|
||||
|
||||
/// 观测侧的注册表。默认空,测试里不用管。
|
||||
final Provider<List<SessionObserver>> sessionObserversProvider = Provider<List<SessionObserver>>(
|
||||
(Ref ref) => const <SessionObserver>[],
|
||||
);
|
||||
@@ -0,0 +1,87 @@
|
||||
/// token 刷新。来源:conti-docs/05-networking.md §core_auth 侧的刷新实现。
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:core_foundation/core_foundation.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import 'models.dart';
|
||||
import 'token_storage.dart';
|
||||
|
||||
/// 刷新 access token。
|
||||
///
|
||||
/// 两条硬约束来自后端的**一次性 refresh token + 重放即全量撤销**机制
|
||||
/// (见 11 §与 refresh token 轮换的配合):
|
||||
///
|
||||
/// 1. **刷新必须串行**。并发刷新会把同一个 refresh token 用两次,后端判定为
|
||||
/// 重放,撤销该用户**所有设备**的会话——用户会在自己毫无操作的情况下被全端
|
||||
/// 踢下线。这里用一个共享的 Future 保证同一时刻只有一个在途刷新,后来者
|
||||
/// 等同一个结果。
|
||||
/// 2. **失败立即登出,不重试**。失败意味着 refresh token 已失效,重试只会再
|
||||
/// 触发一次重放判定。所以这里没有任何 retry 逻辑,这是有意的。
|
||||
class TokenRefresher {
|
||||
/// [dio] 仅供测试注入。
|
||||
///
|
||||
/// 生产走下面那个**裸 Dio**:不装任何拦截器。装了 AuthInterceptor 的话,
|
||||
/// 刷新请求本身返回 401 会再触发一轮刷新,无限递归。
|
||||
TokenRefresher({required this.storage, Dio? dio})
|
||||
: _bare =
|
||||
dio ??
|
||||
Dio(
|
||||
BaseOptions(
|
||||
baseUrl: AppEnv.current.apiBaseUrl,
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
receiveTimeout: const Duration(seconds: 10),
|
||||
),
|
||||
);
|
||||
|
||||
/// token 存取。
|
||||
final TokenStorage storage;
|
||||
final Dio _bare;
|
||||
|
||||
Future<TokenPair>? _inFlight;
|
||||
|
||||
/// 刷新一次。并发调用共享同一个在途请求。
|
||||
///
|
||||
/// 成功时新 token **已经写回 secure storage** 才返回——写回必须在通知会话层
|
||||
/// 之前完成(05)。
|
||||
Future<TokenPair> refresh() {
|
||||
return _inFlight ??= _refresh().whenComplete(() => _inFlight = null);
|
||||
}
|
||||
|
||||
Future<TokenPair> _refresh() async {
|
||||
final String? refreshToken = await storage.readRefreshToken();
|
||||
if (refreshToken == null || refreshToken.isEmpty) {
|
||||
throw const UnauthorizedException();
|
||||
}
|
||||
|
||||
final Response<Map<String, dynamic>> res;
|
||||
try {
|
||||
res = await _bare.post<Map<String, dynamic>>(
|
||||
'/api/v1/auth/refresh',
|
||||
data: <String, String>{'refreshToken': refreshToken},
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
// 网络问题和 token 失效在这里不区分:两种情况都不能重试(见上),
|
||||
// 上层一律按登出处理。
|
||||
throw UnauthorizedException('登录已过期(${e.type.name})');
|
||||
}
|
||||
|
||||
// 裸 Dio 没有 ApiResultInterceptor,得手动解包一层 data。
|
||||
final Object? payload = res.data?['data'];
|
||||
if (payload is! Map<String, dynamic>) {
|
||||
throw const UnauthorizedException('刷新响应格式异常');
|
||||
}
|
||||
|
||||
final Object? access = payload['accessToken'];
|
||||
final Object? refresh = payload['refreshToken'];
|
||||
if (access is! String || refresh is! String) {
|
||||
throw const UnauthorizedException('刷新响应缺少 token');
|
||||
}
|
||||
|
||||
final TokenPair pair = TokenPair(accessToken: access, refreshToken: refresh);
|
||||
await storage.save(pair);
|
||||
return pair;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/// token 的唯一存取点。来源:conti-docs/06-local-storage.md §secure storage。
|
||||
library;
|
||||
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
import 'models.dart';
|
||||
|
||||
/// 包装 secure storage 的 token 读写。
|
||||
///
|
||||
/// **全仓库唯一直接持有 [FlutterSecureStorage] 实例的类**(06 §secure storage
|
||||
/// 使用示例)。其他包只能通过 core_auth 暴露的 provider 间接读写 token——
|
||||
/// 这条约束是"token 到底存在哪、有几份拷贝"这个问题永远只有一个答案的前提。
|
||||
class TokenStorage {
|
||||
/// [storage] 仅供测试注入。
|
||||
TokenStorage({FlutterSecureStorage? storage, this.onReadFailure})
|
||||
: _storage = storage ?? const FlutterSecureStorage();
|
||||
|
||||
static const String _kAccessToken = 'access_token';
|
||||
static const String _kRefreshToken = 'refresh_token';
|
||||
|
||||
final FlutterSecureStorage _storage;
|
||||
|
||||
/// 读失败时的回调。由 app 层接到 core_logging 上——core_auth 不依赖 core_logging。
|
||||
final void Function(Object error, StackTrace stackTrace)? onReadFailure;
|
||||
|
||||
/// 写入一对 token。
|
||||
///
|
||||
/// 刷新拿到新 token 时也走这里:后端轮换机制下旧 refreshToken 立即作废,
|
||||
/// **必须在通知会话层之前完成写回**,否则进程被杀后下次启动会用旧 token
|
||||
/// 触发重放判定,导致全端被踢(05 / 11)。
|
||||
Future<void> save(TokenPair pair) async {
|
||||
await _storage.write(key: _kAccessToken, value: pair.accessToken);
|
||||
await _storage.write(key: _kRefreshToken, value: pair.refreshToken);
|
||||
}
|
||||
|
||||
/// 读 access token。
|
||||
Future<String?> readAccessToken() => _read(_kAccessToken);
|
||||
|
||||
/// 读 refresh token。
|
||||
Future<String?> readRefreshToken() => _read(_kRefreshToken);
|
||||
|
||||
/// 清空。
|
||||
Future<void> clear() => _storage.deleteAll();
|
||||
|
||||
/// 读失败一律按未登录处理:清空 + 返回 null。
|
||||
///
|
||||
/// **绝对不能让 secure storage 的异常向上冒到启动流程**(06)——那会变成
|
||||
/// "升级后一打开就白屏",比让用户重新登录一次严重得多。
|
||||
/// `flutter_secure_storage 11.0.0` 改了 Android 侧的默认加密实现,旧版本
|
||||
/// 写入的数据在升级后确实有读不出来的风险,这个兜底不是防御性编程。
|
||||
Future<String?> _read(String key) async {
|
||||
try {
|
||||
return await _storage.read(key: key);
|
||||
} on Object catch (e, st) {
|
||||
onReadFailure?.call(e, st);
|
||||
try {
|
||||
await clear();
|
||||
} on Object {
|
||||
// 连清都清不掉就只能放弃,但绝不抛出去。
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
name: core_auth
|
||||
description: 会话与门店上下文。token 存取、刷新串行化、登出/切店级联。
|
||||
publish_to: none
|
||||
version: 0.1.0
|
||||
resolution: workspace
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 依赖约束(01 §依赖约束,硬规则):
|
||||
# core_auth ↛ core_network —— 否则 AuthInterceptor 与刷新逻辑会互相纠缠成环
|
||||
# core_auth ↛ core_storage —— 清缓存通过下面的 SessionScopedStore 接口反转依赖
|
||||
# 因此本包里的 token 刷新用的是一个**不带任何拦截器的裸 Dio**。
|
||||
# ---------------------------------------------------------------------------
|
||||
environment:
|
||||
sdk: ^3.12.0
|
||||
|
||||
dependencies:
|
||||
core_foundation: ^0.1.0
|
||||
dio: ^5.11.0
|
||||
flutter:
|
||||
sdk: flutter
|
||||
flutter_riverpod: ^3.3.2
|
||||
flutter_secure_storage: ^11.0.0
|
||||
riverpod_annotation: ^4.0.3
|
||||
|
||||
dev_dependencies:
|
||||
build_runner: ^2.4.13
|
||||
flutter_lints: ^6.0.0
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
mocktail: ^1.0.5
|
||||
riverpod_generator: ^4.0.4
|
||||
@@ -0,0 +1,231 @@
|
||||
// core_auth 的两个高价值断言:
|
||||
// 1. 刷新必须串行——并发刷新会触发后端的重放判定,把用户全端踢下线(11)。
|
||||
// 2. 切店的级联顺序——服务端失败时本地必须原样不动(11 的那张表)。
|
||||
// 其余都是数据搬运,不写用例。
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:core_auth/core_auth.dart';
|
||||
import 'package:core_foundation/core_foundation.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
/// 内存版 secure storage。
|
||||
class _FakeTokenStorage implements TokenStorage {
|
||||
TokenPair? saved = const TokenPair(accessToken: 'a', refreshToken: 'r0');
|
||||
int clearCount = 0;
|
||||
|
||||
@override
|
||||
void Function(Object, StackTrace)? get onReadFailure => null;
|
||||
|
||||
@override
|
||||
Future<String?> readAccessToken() async => saved?.accessToken;
|
||||
|
||||
@override
|
||||
Future<String?> readRefreshToken() async => saved?.refreshToken;
|
||||
|
||||
@override
|
||||
Future<void> save(TokenPair pair) async => saved = pair;
|
||||
|
||||
@override
|
||||
Future<void> clear() async {
|
||||
clearCount++;
|
||||
saved = null;
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeRemote implements SessionRemote {
|
||||
_FakeRemote({this.stores = const <StoreContext>[]});
|
||||
|
||||
List<StoreContext> stores;
|
||||
bool switchFails = false;
|
||||
int switchCalls = 0;
|
||||
|
||||
@override
|
||||
Future<UserContext> fetchCurrentUser() async => const UserContext(
|
||||
userId: 'u1',
|
||||
employeeId: 'e1',
|
||||
phone: '13800001111',
|
||||
roleCode: 'manager',
|
||||
channel: 'app',
|
||||
);
|
||||
|
||||
@override
|
||||
Future<List<StoreContext>> fetchAccessibleStores() async => stores;
|
||||
|
||||
@override
|
||||
Future<StoreContext> switchStore(int storeId) async {
|
||||
switchCalls++;
|
||||
if (switchFails) throw const NetworkException('网络不通');
|
||||
return stores.firstWhere((StoreContext s) => s.storeId == storeId);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> revokeSession() async {}
|
||||
}
|
||||
|
||||
class _RecordingStore implements SessionScopedStore {
|
||||
final List<String> calls = <String>[];
|
||||
|
||||
@override
|
||||
String get debugName => 'recording';
|
||||
|
||||
@override
|
||||
Future<void> onStoreChanged() async => calls.add('store');
|
||||
|
||||
@override
|
||||
Future<void> onSessionEnded() async => calls.add('session');
|
||||
}
|
||||
|
||||
StoreContext _store(int id) =>
|
||||
StoreContext(storeId: id, storeCode: 'S$id', storeName: '门店$id', orgId: 1);
|
||||
|
||||
void main() {
|
||||
group('TokenRefresher', () {
|
||||
setUpAll(() {
|
||||
AppEnv.resetForTest();
|
||||
AppEnv.install(
|
||||
const AppEnv(
|
||||
flavor: AppFlavor.dev,
|
||||
apiBaseUrl: 'https://example.test',
|
||||
enableLog: false,
|
||||
sentryDsn: '',
|
||||
h5AllowedHosts: <String>{},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('并发刷新只发一个请求——多发一个就会被后端判定为 refresh token 重放', () async {
|
||||
int requests = 0;
|
||||
final Dio dio = Dio()
|
||||
..httpClientAdapter = _StubAdapter(() {
|
||||
requests++;
|
||||
return <String, dynamic>{
|
||||
'data': <String, dynamic>{'accessToken': 'a1', 'refreshToken': 'r1'},
|
||||
};
|
||||
});
|
||||
|
||||
final _FakeTokenStorage storage = _FakeTokenStorage();
|
||||
final TokenRefresher refresher = TokenRefresher(storage: storage, dio: dio);
|
||||
|
||||
final List<TokenPair> results = await Future.wait<TokenPair>(<Future<TokenPair>>[
|
||||
refresher.refresh(),
|
||||
refresher.refresh(),
|
||||
refresher.refresh(),
|
||||
]);
|
||||
|
||||
expect(requests, 1);
|
||||
expect(results.every((TokenPair p) => p.accessToken == 'a1'), isTrue);
|
||||
// 新 token 必须在返回前就写回,否则进程被杀会留下一个作废的 refreshToken。
|
||||
expect(storage.saved?.refreshToken, 'r1');
|
||||
});
|
||||
|
||||
test('没有 refresh token 时直接抛 UnauthorizedException,不发请求', () async {
|
||||
final _FakeTokenStorage storage = _FakeTokenStorage()..saved = null;
|
||||
final TokenRefresher refresher = TokenRefresher(storage: storage, dio: Dio());
|
||||
|
||||
await expectLater(refresher.refresh(), throwsA(isA<UnauthorizedException>()));
|
||||
});
|
||||
});
|
||||
|
||||
group('SessionNotifier.switchStore', () {
|
||||
late _FakeTokenStorage storage;
|
||||
late _FakeRemote remote;
|
||||
late _RecordingStore scoped;
|
||||
|
||||
ProviderContainer makeContainer() {
|
||||
storage = _FakeTokenStorage();
|
||||
remote = _FakeRemote(stores: <StoreContext>[_store(1), _store(2)]);
|
||||
scoped = _RecordingStore();
|
||||
return ProviderContainer(
|
||||
overrides: [
|
||||
tokenStorageProvider.overrideWithValue(storage),
|
||||
sessionRemoteProvider.overrideWithValue(remote),
|
||||
sessionScopedStoresProvider.overrideWithValue(<SessionScopedStore>[scoped]),
|
||||
lastStoreIdProvider.overrideWithValue(1),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
test('服务端切换失败时本地状态原样不动,级联清理一次都不能跑', () async {
|
||||
final ProviderContainer container = makeContainer();
|
||||
addTearDown(container.dispose);
|
||||
|
||||
final AppSession initial = await container.read(sessionProvider.future);
|
||||
expect(initial, isA<SessionActive>());
|
||||
scoped.calls.clear();
|
||||
|
||||
remote.switchFails = true;
|
||||
await expectLater(
|
||||
container.read(sessionProvider.notifier).switchStore(2),
|
||||
throwsA(isA<NetworkException>()),
|
||||
);
|
||||
|
||||
// 先清缓存再请求的写法,会让用户停在"门店没变但数据全没了"的状态。
|
||||
expect(scoped.calls, isEmpty);
|
||||
expect((container.read(sessionProvider).value! as SessionActive).store.storeId, 1);
|
||||
});
|
||||
|
||||
test('有未完成的写操作时拒绝切店', () async {
|
||||
final ProviderContainer container = makeContainer();
|
||||
addTearDown(container.dispose);
|
||||
await container.read(sessionProvider.future);
|
||||
|
||||
container.read(pendingWritesProvider.notifier).add('submitOrder');
|
||||
|
||||
await expectLater(
|
||||
container.read(sessionProvider.notifier).switchStore(2),
|
||||
throwsA(isA<PreconditionException>()),
|
||||
);
|
||||
expect(remote.switchCalls, 1); // 只有冷启动那次
|
||||
});
|
||||
|
||||
test('登出会清 token 并跑完整的级联清理', () async {
|
||||
final ProviderContainer container = makeContainer();
|
||||
addTearDown(container.dispose);
|
||||
await container.read(sessionProvider.future);
|
||||
scoped.calls.clear();
|
||||
|
||||
await container.read(sessionProvider.notifier).logout();
|
||||
|
||||
expect(scoped.calls, <String>['session']);
|
||||
expect(storage.saved, isNull);
|
||||
expect(
|
||||
container.read(sessionProvider).value,
|
||||
isA<SessionUnauthenticated>().having(
|
||||
(SessionUnauthenticated s) => s.reason,
|
||||
'reason',
|
||||
LogoutReason.userInitiated,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// 固定返回一份 JSON 的 adapter,省掉起 http server。
|
||||
class _StubAdapter implements HttpClientAdapter {
|
||||
_StubAdapter(this.body);
|
||||
|
||||
final Map<String, dynamic> Function() body;
|
||||
|
||||
@override
|
||||
Future<ResponseBody> fetch(
|
||||
RequestOptions options,
|
||||
Stream<Uint8List>? requestStream,
|
||||
Future<void>? cancelFuture,
|
||||
) async {
|
||||
final Map<String, dynamic> payload = body();
|
||||
return ResponseBody.fromString(
|
||||
jsonEncode(payload),
|
||||
200,
|
||||
headers: <String, List<String>>{
|
||||
Headers.contentTypeHeader: <String>[Headers.jsonContentType],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void close({bool force = false}) {}
|
||||
}
|
||||
Reference in New Issue
Block a user