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:
qzl
2026-03-18 13:35:25 +08:00
parent 19981964fb
commit b34697660d
56 changed files with 2602 additions and 784 deletions
+15 -1
View File
@@ -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);
+32
View File
@@ -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;
}
}
-8
View File
@@ -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;
}
}
+22 -6
View File
@@ -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());
}