diff --git a/frontend/mobile-app/lib/core/network/api_client.dart b/frontend/mobile-app/lib/core/network/api_client.dart index a481d027..94c0ceb7 100644 --- a/frontend/mobile-app/lib/core/network/api_client.dart +++ b/frontend/mobile-app/lib/core/network/api_client.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; import '../storage/secure_storage.dart'; @@ -12,10 +14,18 @@ import '../services/auth_event_service.dart'; /// - 自动 Token 注入 /// - 错误处理 /// - 请求/响应日志 +/// - Token 自动刷新(带并发锁) class ApiClient { final Dio _dio; final SecureStorage _secureStorage; + /// Token 刷新锁,防止并发刷新导致的竞态条件 + /// 当多个请求同时收到 401 时,只有第一个会执行刷新,其他请求等待结果 + Completer? _refreshCompleter; + + /// 标记是否已经通知过 token 过期,避免重复通知 + bool _hasNotifiedTokenExpired = false; + ApiClient({ required SecureStorage secureStorage, String? baseUrl, @@ -98,72 +108,151 @@ class ApiClient { // 排除 auto-login 本身的 401 错误,避免递归 final isAutoLogin = error.requestOptions.path.contains('/user/auto-login'); if (error.response?.statusCode == 401 && !isAutoLogin) { - debugPrint('401 error detected, attempting token refresh...'); + debugPrint( + '[ApiClient] 401 error on ${error.requestOptions.path}, attempting token refresh...'); try { - final refreshed = await _tryRefreshToken(); + final refreshed = await _tryRefreshTokenWithLock(); if (refreshed) { - debugPrint('Token refresh successful, retrying original request...'); + debugPrint( + '[ApiClient] Token refresh successful, retrying: ${error.requestOptions.path}'); // 重试原请求 final response = await _retryRequest(error.requestOptions); return handler.resolve(response); } else { - // refresh token 不存在,需要重新登录 - debugPrint('Token refresh failed: no refresh token available'); - _notifyTokenExpired(); + // refresh token 不存在或刷新失败,需要重新登录 + debugPrint( + '[ApiClient] Token refresh failed, no valid refresh token'); + _notifyTokenExpiredOnce(); } } catch (e) { - debugPrint('Token refresh exception: $e'); + debugPrint('[ApiClient] Token refresh exception: $e'); // refresh token 刷新失败(可能过期),需要重新登录 if (e is DioException && e.response?.statusCode == 401) { - debugPrint('Refresh token expired, notifying token expired event'); - _notifyTokenExpired(); + debugPrint( + '[ApiClient] Refresh token expired (401), notifying token expired'); + _notifyTokenExpiredOnce(); } + // 其他异常(网络错误等)不触发重新登录,让请求正常失败 } } handler.next(error); } - /// 通知 token 过期事件 - void _notifyTokenExpired() { + /// 通知 token 过期事件(仅通知一次,避免重复弹窗) + void _notifyTokenExpiredOnce() { + if (_hasNotifiedTokenExpired) { + debugPrint('[ApiClient] Token expired already notified, skipping'); + return; + } + _hasNotifiedTokenExpired = true; + debugPrint('[ApiClient] Emitting token expired event'); AuthEventService().emitTokenExpired(message: '登录已过期,请重新登录'); } - /// 尝试刷新 Token - Future _tryRefreshToken() async { - final refreshToken = await _secureStorage.read(key: StorageKeys.refreshToken); - if (refreshToken == null) return false; + /// 重置 token 过期通知标记(在用户重新登录后调用) + void resetTokenExpiredFlag() { + _hasNotifiedTokenExpired = false; + debugPrint('[ApiClient] Token expired flag reset'); + } + + /// 带锁的 Token 刷新,防止并发刷新导致的竞态条件 + /// + /// 问题场景: + /// 1. 多个请求同时收到 401 + /// 2. 都调用 refresh token 接口 + /// 3. 第一个成功后,后端撤销了旧 refresh token + /// 4. 其他请求使用旧 refresh token 失败 + /// + /// 解决方案: + /// 使用 Completer 作为锁,确保同一时刻只有一个刷新请求 + /// 其他请求等待第一个刷新完成后复用结果 + Future _tryRefreshTokenWithLock() async { + // 如果已有刷新在进行中,等待其结果 + if (_refreshCompleter != null) { + debugPrint( + '[ApiClient] Token refresh already in progress, waiting for result...'); + return _refreshCompleter!.future; + } + + // 创建新的 Completer 作为锁 + _refreshCompleter = Completer(); + debugPrint('[ApiClient] Starting token refresh (acquired lock)'); try { - final deviceId = await _secureStorage.read(key: StorageKeys.deviceId); - final response = await _dio.post( - '/user/auto-login', - data: { - 'refreshToken': refreshToken, - 'deviceId': deviceId, - }, - options: Options(headers: {'Authorization': ''}), // 不带旧 Token + final result = await _doRefreshToken(); + _refreshCompleter!.complete(result); + debugPrint('[ApiClient] Token refresh completed with result: $result'); + return result; + } catch (e) { + debugPrint('[ApiClient] Token refresh failed with exception: $e'); + _refreshCompleter!.completeError(e); + rethrow; + } finally { + // 释放锁 + _refreshCompleter = null; + debugPrint('[ApiClient] Token refresh lock released'); + } + } + + /// 执行实际的 Token 刷新 + Future _doRefreshToken() async { + final refreshToken = + await _secureStorage.read(key: StorageKeys.refreshToken); + if (refreshToken == null || refreshToken.isEmpty) { + debugPrint('[ApiClient] No refresh token available'); + return false; + } + + final deviceId = await _secureStorage.read(key: StorageKeys.deviceId); + if (deviceId == null || deviceId.isEmpty) { + debugPrint('[ApiClient] No device ID available'); + return false; + } + + debugPrint('[ApiClient] Calling /user/auto-login with deviceId: $deviceId'); + + final response = await _dio.post( + '/user/auto-login', + data: { + 'refreshToken': refreshToken, + 'deviceId': deviceId, + }, + options: Options(headers: {'Authorization': ''}), // 不带旧 Token + ); + + // auto-login 成功可能返回 200 或 201 + if (response.statusCode == 200 || response.statusCode == 201) { + final responseData = response.data; + // API 返回格式: { success: true, data: { accessToken, refreshToken, ... } } + final data = responseData['data'] as Map; + + final newAccessToken = data['accessToken'] as String?; + final newRefreshToken = data['refreshToken'] as String?; + + if (newAccessToken == null || newRefreshToken == null) { + debugPrint('[ApiClient] Invalid response: missing tokens'); + return false; + } + + await _secureStorage.write( + key: StorageKeys.accessToken, + value: newAccessToken, + ); + await _secureStorage.write( + key: StorageKeys.refreshToken, + value: newRefreshToken, ); - // auto-login 成功可能返回 200 或 201 - if (response.statusCode == 200 || response.statusCode == 201) { - final responseData = response.data; - // API 返回格式: { success: true, data: { accessToken, refreshToken, ... } } - final data = responseData['data'] as Map; - await _secureStorage.write( - key: StorageKeys.accessToken, - value: data['accessToken'], - ); - await _secureStorage.write( - key: StorageKeys.refreshToken, - value: data['refreshToken'], - ); - debugPrint('Token refreshed successfully, new token saved'); - return true; - } - } catch (e) { - debugPrint('Token refresh failed: $e'); + // 刷新成功,重置过期通知标记 + _hasNotifiedTokenExpired = false; + + debugPrint('[ApiClient] Token refreshed successfully, new tokens saved'); + return true; } + + debugPrint( + '[ApiClient] Unexpected response status: ${response.statusCode}'); return false; } @@ -174,12 +263,6 @@ class ApiClient { return _dio.fetch(options); } - /// 清除认证数据 - Future _clearAuthData() async { - await _secureStorage.delete(key: StorageKeys.accessToken); - await _secureStorage.delete(key: StorageKeys.refreshToken); - } - /// GET 请求 Future> get( String path, {