feat: 实现 Auth 全局状态机与 401 统一处理机制
- 新增 AuthSessionInvalidated 事件处理 token 失效场景 - ApiInterceptor 新增 authFailureCallback 单飞机制 - AuthBloc 区分 manual logout 与 auto expiry 语义 - 新增 startup recovery fallback 防止启动卡死 feat: 重构 Calendar DayWeek 视图事件布局引擎 - 新增 DayEventLayoutEngine 解耦事件计算与渲染 - 新增 DayTimelineMetrics 统一时间轴常量 - 新增 DayViewScale 支持捏合缩放 feat: 新增 Settings 页面共享 UI 组件 - 新增 BackTitlePageHeader 统一页面 header - 新增 DetailHeaderActionMenu 统一操作菜单 - 新增 DestructiveActionSheet 统一删除确认 - 新增 AppToggleSwitch 统一开关组件 feat: Chat UI Schema 支持导航操作 - 支持 navigation 类型 action 触发内部路由跳转 - 新增路径验证与参数处理 chore: 更新相关测试覆盖 auth 失效路径
This commit is contained in:
@@ -15,7 +15,16 @@ class ApiClient implements IApiClient {
|
||||
required TokenStorage tokenStorage,
|
||||
Dio? dio,
|
||||
}) {
|
||||
final effectiveDio = dio ?? Dio(BaseOptions(baseUrl: baseUrl));
|
||||
final effectiveDio =
|
||||
dio ??
|
||||
Dio(
|
||||
BaseOptions(
|
||||
baseUrl: baseUrl,
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
receiveTimeout: const Duration(seconds: 20),
|
||||
sendTimeout: const Duration(seconds: 20),
|
||||
),
|
||||
);
|
||||
final interceptor = ApiInterceptor(
|
||||
tokenStorage: tokenStorage,
|
||||
dio: effectiveDio,
|
||||
@@ -50,6 +59,11 @@ class ApiClient implements IApiClient {
|
||||
};
|
||||
}
|
||||
|
||||
void setAuthFailureCallback(Future<void> Function() onAuthFailure) {
|
||||
_interceptor.onAuthFailure = onAuthFailure;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Response<T>> get<T>(String path, {Options? options}) async {
|
||||
try {
|
||||
return await _dio.get<T>(path, options: options);
|
||||
|
||||
@@ -6,7 +6,9 @@ class ApiInterceptor extends Interceptor {
|
||||
final Dio dio;
|
||||
final Duration refreshFailureCooldown;
|
||||
Future<bool> Function()? onTokenRefresh;
|
||||
Future<void> Function()? onAuthFailure;
|
||||
Future<bool>? _refreshFuture;
|
||||
Future<void>? _authFailureFuture;
|
||||
DateTime? _refreshBlockedUntil;
|
||||
|
||||
static const _retriedRequestKey = '_auth_retry_once';
|
||||
@@ -34,6 +36,10 @@ class ApiInterceptor extends Interceptor {
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) async {
|
||||
final requestOptions = err.requestOptions;
|
||||
final isUnauthorized = err.response?.statusCode == 401;
|
||||
final shouldHandleUnauthorized =
|
||||
isUnauthorized && _isAuthenticatedRequest(requestOptions);
|
||||
|
||||
if (err.response?.statusCode == 401 &&
|
||||
onTokenRefresh != null &&
|
||||
!_shouldSkipRefresh(requestOptions)) {
|
||||
@@ -57,11 +63,36 @@ class ApiInterceptor extends Interceptor {
|
||||
// Retry failed, proceed with original error.
|
||||
}
|
||||
}
|
||||
} else if (shouldHandleUnauthorized) {
|
||||
await _notifyAuthFailureSingleflight();
|
||||
}
|
||||
} else if (shouldHandleUnauthorized && _shouldSkipRefresh(requestOptions)) {
|
||||
await _notifyAuthFailureSingleflight();
|
||||
}
|
||||
handler.next(err);
|
||||
}
|
||||
|
||||
bool _isAuthenticatedRequest(RequestOptions options) {
|
||||
return options.headers['Authorization'] != null;
|
||||
}
|
||||
|
||||
Future<void> _notifyAuthFailureSingleflight() {
|
||||
final existing = _authFailureFuture;
|
||||
if (existing != null) {
|
||||
return existing;
|
||||
}
|
||||
final callback = onAuthFailure;
|
||||
if (callback == null) {
|
||||
return Future<void>.value();
|
||||
}
|
||||
|
||||
final future = callback().whenComplete(() {
|
||||
_authFailureFuture = null;
|
||||
});
|
||||
_authFailureFuture = future;
|
||||
return future;
|
||||
}
|
||||
|
||||
bool _shouldSkipRefresh(RequestOptions options) {
|
||||
final blockedUntil = _refreshBlockedUntil;
|
||||
if (blockedUntil != null && DateTime.now().isBefore(blockedUntil)) {
|
||||
@@ -101,6 +132,7 @@ class ApiInterceptor extends Interceptor {
|
||||
|
||||
void reset() {
|
||||
_refreshFuture = null;
|
||||
_authFailureFuture = null;
|
||||
_refreshBlockedUntil = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,12 +11,4 @@ class Env {
|
||||
}
|
||||
return 'http://localhost:5775';
|
||||
}
|
||||
|
||||
static bool get isMockApi {
|
||||
final fromDefine = const String.fromEnvironment('MOCK_API');
|
||||
if (fromDefine.isNotEmpty) {
|
||||
return fromDefine == 'true';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import '../../features/auth/data/auth_api.dart';
|
||||
import '../../features/auth/data/auth_repository.dart';
|
||||
import '../../features/auth/data/auth_repository_impl.dart';
|
||||
import '../../features/auth/presentation/bloc/auth_bloc.dart';
|
||||
import '../../features/auth/presentation/bloc/auth_event.dart';
|
||||
import '../../features/calendar/data/calendar_api.dart';
|
||||
import '../../features/calendar/data/services/calendar_service.dart';
|
||||
import '../../features/calendar/ui/calendar_state_manager.dart';
|
||||
@@ -26,12 +27,18 @@ Future<void> configureDependencies() async {
|
||||
await sl.reset();
|
||||
}
|
||||
|
||||
final IApiClient apiClient;
|
||||
final SecureTokenStorage tokenStorage;
|
||||
|
||||
final dio = Dio(BaseOptions(baseUrl: Env.apiUrl));
|
||||
tokenStorage = SecureTokenStorage(const FlutterSecureStorage());
|
||||
apiClient = ApiClient(
|
||||
tokenStorage = SecureTokenStorage(
|
||||
const FlutterSecureStorage(
|
||||
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
||||
iOptions: IOSOptions(
|
||||
accessibility: KeychainAccessibility.first_unlock_this_device,
|
||||
),
|
||||
),
|
||||
);
|
||||
final apiClient = ApiClient(
|
||||
baseUrl: Env.apiUrl,
|
||||
tokenStorage: tokenStorage,
|
||||
dio: dio,
|
||||
@@ -69,12 +76,15 @@ Future<void> configureDependencies() async {
|
||||
api: authApi,
|
||||
tokenStorage: tokenStorage,
|
||||
onLogout: () async {
|
||||
(apiClient as ApiClient).resetInterceptor();
|
||||
apiClient.resetInterceptor();
|
||||
},
|
||||
);
|
||||
sl.registerSingleton<AuthRepository>(authRepository);
|
||||
|
||||
(apiClient as ApiClient).setRefreshCallback((token) async {
|
||||
final authBloc = AuthBloc(authRepository);
|
||||
sl.registerSingleton<AuthBloc>(authBloc);
|
||||
|
||||
apiClient.setRefreshCallback((token) async {
|
||||
try {
|
||||
await authRepository.refreshSession(token);
|
||||
return true;
|
||||
@@ -83,6 +93,12 @@ Future<void> configureDependencies() async {
|
||||
}
|
||||
});
|
||||
|
||||
sl.registerSingleton<AuthBloc>(AuthBloc(authRepository));
|
||||
apiClient.setAuthFailureCallback(() async {
|
||||
authBloc.add(
|
||||
const AuthSessionInvalidated(
|
||||
source: AuthInvalidationSource.unauthorized401,
|
||||
),
|
||||
);
|
||||
});
|
||||
sl.registerSingleton<CalendarStateManager>(CalendarStateManager());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user