app scaffold

This commit is contained in:
Guangfei.Zhao
2026-08-17 15:29:55 +08:00
commit 681688dfae
301 changed files with 18414 additions and 0 deletions
+13
View File
@@ -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';
+166
View File
@@ -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_router01)。反过来由 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;
}
}
}