Token自动刷新机制:QueuedInterceptor实现无感刷新,只有彻底过期才跳转登录
This commit is contained in:
@@ -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
@@ -33,6 +33,9 @@ class ApiEndpoints {
|
||||
/// 退出登錄
|
||||
static const String logout = '/api/user/logout';
|
||||
|
||||
/// 刷新 Token(後端尚未實現,預留接口)
|
||||
static const String tokenRefresh = '/api/auth/refresh';
|
||||
|
||||
// ==================== 行情模塊 ====================
|
||||
|
||||
/// 獲取幣種列表
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user