Token自动刷新机制:QueuedInterceptor实现无感刷新,只有彻底过期才跳转登录

This commit is contained in:
2026-04-16 12:55:31 +08:00
parent bfd82b3c8a
commit 5c7aa09207
6 changed files with 53449 additions and 53014 deletions

View File

@@ -37,6 +37,6 @@ _flutter.buildConfig = {"engineRevision":"e4b8dca3f1b4ede4c30371002441c88c12187e
_flutter.loader.load({
serviceWorkerSettings: {
serviceWorkerVersion: "2477319696" /* Flutter's service worker is deprecated and will be removed in a future Flutter release. */
serviceWorkerVersion: "1911676977" /* Flutter's service worker is deprecated and will be removed in a future Flutter release. */
}
});

File diff suppressed because one or more lines are too long

View File

@@ -33,6 +33,9 @@ class ApiEndpoints {
/// 退出登錄
static const String logout = '/api/user/logout';
/// 刷新 Token後端尚未實現預留接口
static const String tokenRefresh = '/api/auth/refresh';
// ==================== 行情模塊 ====================
/// 獲取幣種列表

View File

@@ -14,6 +14,17 @@ class NetworkConfig {
static const Duration sendTimeout = Duration(seconds: 15);
}
/// 不需要 token 的接口路徑(不會觸發刷新/登出邏輯)
const _authFreePaths = [
'/api/user/login',
'/api/user/register',
'/api/auth/refresh',
];
bool _isAuthFreePath(String path) {
return _authFreePaths.any((p) => path.contains(p));
}
/// Dio 網絡客戶端
class DioClient {
late final Dio _dio;
@@ -21,7 +32,7 @@ class DioClient {
/// 全局導航 key用於未授權時跳轉登錄頁
GlobalKey<NavigatorState>? navigatorKey;
/// 未授權回調token 過期時觸發 AuthProvider.forceLogout
/// 未授權回調token 徹底過期時觸發 AuthProvider.forceLogout
VoidCallback? onForceLogout;
DioClient({this.navigatorKey}) {
@@ -42,7 +53,7 @@ class DioClient {
void _setupInterceptors() {
_dio.interceptors.addAll([
_AuthInterceptor(),
_TokenRefreshInterceptor(this),
_LoggingInterceptor(),
]);
}
@@ -93,25 +104,19 @@ class DioClient {
}
}
/// 處理響應 — 只做數據解析,不再觸發認證邏輯
ApiResponse<T> _handleResponse<T>(
Response response,
T Function(dynamic)? fromJson,
) {
final data = response.data;
if (data is Map<String, dynamic>) {
final apiResponse = ApiResponse.fromJson(data, fromJson);
// 檢測業務層未授權(後端返回 HTTP 200 + code "0002"
if (apiResponse.isUnauthorized) {
debugPrint('業務層未授權響應: ${apiResponse.message}');
_handleUnauthorized();
}
return apiResponse;
return ApiResponse.fromJson(data, fromJson);
}
return ApiResponse.fail('響應數據格式錯誤');
}
ApiResponse<T> _handleError<T>(DioException e) {
// 詳細錯誤日誌
debugPrint('=== Network Error ===');
debugPrint('Type: ${e.type}');
debugPrint('Message: ${e.message}');
@@ -120,8 +125,7 @@ class DioClient {
debugPrint('ResponseData: ${e.response?.data}');
debugPrint('====================');
if (_isUnauthorized(e)) {
_handleUnauthorized();
if (e.response?.statusCode == 401) {
return ApiResponse.unauthorized('登錄已過期,請重新登錄');
}
@@ -129,17 +133,9 @@ class DioClient {
return ApiResponse.fail(message);
}
bool _isUnauthorized(DioException e) {
return e.response?.statusCode == 401;
}
void _clearUserData() {
/// 徹底失效時清除數據並跳轉登錄頁
void forceLogout() {
LocalStorage.clearUserData();
}
/// 統一處理未授權:清除本地數據 → 通知 Provider → 全局跳轉登錄頁
void _handleUnauthorized() {
_clearUserData();
onForceLogout?.call();
final context = navigatorKey?.currentContext;
if (context != null) {
@@ -174,6 +170,181 @@ class DioClient {
}
}
/// Token 自動刷新攔截器
///
/// 使用 QueuedInterceptor 確保多個並發 401/0002 只刷新一次。
/// 核心流程:
/// 1. onRequest — 注入 Authorization header
/// 2. onResponse — 檢測業務層 code "0002"(後端返回 HTTP 200 + 0002
/// 3. onError — 檢測 HTTP 401
/// 4. 觸發刷新時,用獨立 Dio 實例調用 refresh 接口,避免循環
/// 5. 刷新成功 → 更新本地 token → 重試原始請求
/// 6. 刷新失敗 → forceLogout只有這裡才會真正跳轉登錄頁
class _TokenRefreshInterceptor extends QueuedInterceptor {
final DioClient _client;
bool _isRefreshing = false;
_TokenRefreshInterceptor(this._client);
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
final token = LocalStorage.getToken();
if (token?.isNotEmpty == true) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
}
@override
void onResponse(Response response, ResponseInterceptorHandler handler) {
final data = response.data;
if (data is Map<String, dynamic> && data['code'] == '0002') {
final path = response.requestOptions.path;
// 登錄、註冊等接口返回 0002 不觸發刷新,直接透傳
if (_isAuthFreePath(path)) {
handler.next(response);
return;
}
_handleTokenExpired(response.requestOptions, handler);
return;
}
handler.next(response);
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
if (err.response?.statusCode == 401) {
final path = err.requestOptions.path;
if (_isAuthFreePath(path)) {
handler.next(err);
return;
}
// 將 401 轉為刷新流程(用 _wrapAsResponseHandler 統一處理)
_handleTokenExpired(err.requestOptions, _ErrorAsResponseHandler(handler));
return;
}
handler.next(err);
}
/// 統一處理 token 過期:嘗試刷新 → 重試 / forceLogout
Future<void> _handleTokenExpired(
RequestOptions requestOptions,
ResponseInterceptorHandler handler,
) async {
if (_isRefreshing) {
// 正在刷新中,等刷新完成後用新 token 重試
// QueuedInterceptor 保證此請求會在刷新完成後才被處理
// 但此時 onRequest 已經執行過,需要手動更新 header
final newToken = LocalStorage.getToken();
if (newToken != null) {
requestOptions.headers['Authorization'] = 'Bearer $newToken';
}
try {
final retryResponse = await _client._dio.fetch(requestOptions);
handler.resolve(retryResponse);
} on DioException catch (e) {
handler.reject(e);
}
return;
}
_isRefreshing = true;
try {
final newToken = await _refreshToken();
if (newToken != null) {
// 刷新成功,更新 header 並重試原始請求
requestOptions.headers['Authorization'] = 'Bearer $newToken';
final retryResponse = await _client._dio.fetch(requestOptions);
handler.resolve(retryResponse);
} else {
// 刷新失敗(無 refreshToken 或後端未實現forceLogout
handler.next(Response(
requestOptions: requestOptions,
data: {'code': '0002', 'msg': 'Token 已過期,請重新登錄'},
statusCode: 200,
));
_client.forceLogout();
}
} catch (e) {
debugPrint('Token 刷新異常: $e');
handler.next(Response(
requestOptions: requestOptions,
data: {'code': '0002', 'msg': 'Token 刷新失敗,請重新登錄'},
statusCode: 200,
));
_client.forceLogout();
} finally {
_isRefreshing = false;
}
}
/// 調用刷新接口,返回新 token失敗返回 null
Future<String?> _refreshToken() async {
final refreshToken = LocalStorage.getRefreshToken();
if (refreshToken == null || refreshToken.isEmpty) {
debugPrint('無 refreshToken無法刷新需要重新登錄');
return null;
}
try {
// 用獨立 Dio 實例,避免被攔截器循環攔截
final refreshDio = Dio(BaseOptions(
baseUrl: NetworkConfig.baseUrl,
connectTimeout: NetworkConfig.connectTimeout,
headers: {'Content-Type': 'application/json'},
));
final response = await refreshDio.post(
ApiEndpoints.tokenRefresh,
data: {'refreshToken': refreshToken},
);
final data = response.data;
if (data is Map<String, dynamic> &&
data['code'] == '0000' &&
data['data'] != null) {
final newToken = data['data']['token'] as String?;
if (newToken != null) {
await LocalStorage.saveToken(newToken);
debugPrint('Token 刷新成功');
return newToken;
}
}
debugPrint('Token 刷新接口返回異常: $data');
return null;
} catch (e) {
debugPrint('Token 刷新請求失敗: $e');
return null;
}
}
}
/// 輔助類:將 ErrorInterceptorHandler 適配為 ResponseInterceptorHandler
/// 用於 onError 中檢測到 401 後統一走 _handleTokenExpired 流程
class _ErrorAsResponseHandler extends ResponseInterceptorHandler {
final ErrorInterceptorHandler _errorHandler;
_ErrorAsResponseHandler(this._errorHandler);
@override
void next(Response response) {
// 刷新成功後 resolve 重試的 response
_errorHandler.resolve(response);
}
@override
void resolve(Response response) {
_errorHandler.resolve(response);
}
@override
void reject(DioException error, [bool callFollowingErrorInterceptor = false]) {
_errorHandler.reject(error);
}
}
/// 日誌攔截器
class _LoggingInterceptor extends Interceptor {
@override
@@ -207,23 +378,3 @@ class _LoggingInterceptor extends Interceptor {
super.onError(err, handler);
}
}
/// 認證攔截器
class _AuthInterceptor extends Interceptor {
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
final token = LocalStorage.getToken();
if (token?.isNotEmpty == true) {
options.headers['Authorization'] = 'Bearer $token';
}
super.onRequest(options, handler);
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
if (err.response?.statusCode == 401) {
LocalStorage.clearUserData();
}
super.onError(err, handler);
}
}

View File

@@ -6,6 +6,7 @@ class LocalStorage {
LocalStorage._();
static const String _tokenKey = 'token';
static const String _refreshTokenKey = 'refreshToken';
static const String _userInfoKey = 'userInfo';
static SharedPreferences? _prefs;
@@ -43,6 +44,23 @@ class LocalStorage {
/// 是否已登錄
static bool get isLoggedIn => getToken() != null && getToken()!.isNotEmpty;
// ==================== Refresh Token 管理 ====================
/// 保存 Refresh Token
static Future<void> saveRefreshToken(String refreshToken) async {
await prefs.setString(_refreshTokenKey, refreshToken);
}
/// 獲取 Refresh Token
static String? getRefreshToken() {
return prefs.getString(_refreshTokenKey);
}
/// 移除 Refresh Token
static Future<void> removeRefreshToken() async {
await prefs.remove(_refreshTokenKey);
}
// ==================== 用戶信息管理 ====================
/// 保存用戶信息
@@ -96,6 +114,7 @@ class LocalStorage {
/// 清除用戶數據(退出登錄時調用)
static Future<void> clearUserData() async {
await removeToken();
await removeRefreshToken();
await removeUserInfo();
}

View File

@@ -90,6 +90,7 @@ class AuthProvider extends ChangeNotifier {
String? message,
) {
_token = data['token'] as String?;
final refreshToken = data['refreshToken'] as String?;
final userJson = data['user'] as Map<String, dynamic>? ??
data['userInfo'] as Map<String, dynamic>?;
@@ -97,6 +98,11 @@ class AuthProvider extends ChangeNotifier {
LocalStorage.saveToken(_token!);
}
// 保存 refreshToken後端實現後生效
if (refreshToken != null && refreshToken.isNotEmpty) {
LocalStorage.saveRefreshToken(refreshToken);
}
if (userJson != null) {
LocalStorage.saveUserInfo(userJson);
_user = User.fromJson(userJson);