You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Flutter Dio拦截器问题:401响应处理与AccessToken刷新异常

Flutter Dio拦截器问题:Token更新不生效与401登出逻辑优化

问题分析

你的代码存在两个核心问题:

  1. Token更新后部分请求仍使用旧令牌:大概率是因为DioClient未采用单例模式,多个实例各自维护独立的拦截器和Token状态,导致Token更新无法全局生效;同时onResponse中修改response.requestOptions.headers的操作对后续请求无意义,属于冗余逻辑。
  2. 401登出时机错误:当前只要收到401就直接登出,未区分"access token过期"和"refresh token过期"的场景,应该优先尝试用refresh token刷新access token,仅当刷新失败时才触发登出。

解决方案

1. 确保Token更新全局生效

步骤1:将DioClient改为单例模式

保证整个应用只有一个Dio实例和拦截器,所有请求共享同一套Token逻辑:

class DioClient {
  // 单例实现
  static final DioClient _instance = DioClient._internal();
  factory DioClient() => _instance;
  
  Dio dio = Dio(BaseOptions(
    connectTimeout: const Duration(seconds: 10),
    receiveTimeout: const Duration(seconds: 100),
    receiveDataWhenStatusError: true,
    followRedirects: true,
    headers: {"Content-Type": 'application/json'},
  ));

  TokenManager tokenManager = TokenManager();
  AppPreference appPreference = AppPreference();
  bool _isRefreshing = false;
  final _refreshCompleter = Completer<void>();
  bool _isDialogShown = false;

  DioClient._internal() {
    _setupInterceptors();
  }

  void _setupInterceptors() {
    dio.interceptors.clear();
    dio.interceptors.add(InterceptorsWrapper(
      onRequest: (options, handler) async {
        // 等待Token刷新完成再发起请求,避免并发请求使用旧Token
        if (_isRefreshing) {
          await _refreshCompleter.future;
        }
        final accessToken = await tokenManager.getAccessToken();
        final refreshToken = await tokenManager.getRefreshToken();
        if (accessToken != null) {
          options.headers['Authorization'] = 'Bearer $accessToken';
        }
        if (refreshToken != null) {
          options.headers['Cookie'] = refreshToken;
        }
        handler.next(options);
      },
      onResponse: (response, handler) async {
        // 仅更新TokenManager中的Token,后续请求自动读取最新值
        if (response.statusCode == 200 && response.data != null) {
          Map<String, dynamic> responseData = response.data;
          if (responseData.containsKey("newAccessToken") && responseData["newAccessToken"] != null) {
            final newAccessToken = responseData["newAccessToken"];
            await tokenManager.writeAccessToken(newAccessToken);
          }
        }
        handler.next(response);
      },
      // 401处理逻辑见下文
      onError: (DioException error, handler) async {
        // ...
      },
    ));
  }
}

步骤2:检查TokenManager实现

确保writeAccessToken和getAccessToken正确读写持久化存储(如SharedPreferences、Hive),无异步读写不一致的问题。


2. 优化401登出逻辑

修改onError拦截器,先尝试刷新Token,仅当刷新失败时才执行登出,同时确保拦截器提前清除:

onError: (DioException error, handler) async {
  if (error.response?.statusCode == 401) {
    final requestOptions = error.response!.requestOptions;
    // 排除刷新Token本身的请求,避免死循环
    if (!requestOptions.path.contains('/refresh-token')) {
      if (!_isRefreshing) {
        _isRefreshing = true;
        try {
          final refreshToken = await tokenManager.getRefreshToken();
          // 调用后端刷新Token接口
          final refreshResponse = await dio.post(
            '/auth/refresh-token',
            options: Options(headers: {'Cookie': refreshToken}),
          );
          if (refreshResponse.statusCode == 200) {
            final newAccessToken = refreshResponse.data['newAccessToken'];
            final newRefreshToken = refreshResponse.data['newRefreshToken'];
            await tokenManager.writeAccessToken(newAccessToken);
            if (newRefreshToken != null) {
              await tokenManager.writeRefreshToken(newRefreshToken);
            }
            // 通知等待的请求刷新完成
            _refreshCompleter.complete();
            _refreshCompleter = Completer<void>();
            _isRefreshing = false;
            // 重试原请求
            requestOptions.headers['Authorization'] = 'Bearer $newAccessToken';
            requestOptions.headers['Cookie'] = newRefreshToken ?? refreshToken;
            return handler.resolve(await dio.fetch(requestOptions));
          } else {
            // 刷新失败,执行登出
            await _performLogout(error, handler);
          }
        } catch (e) {
          // 刷新过程出错,执行登出
          await _performLogout(error, handler);
        } finally {
          _isRefreshing = false;
          if (!_isRefreshing && !_refreshCompleter.isCompleted) {
            _refreshCompleter.completeError('Refresh failed');
          }
        }
      } else {
        // 等待刷新完成后重试
        await _refreshCompleter.future;
        requestOptions.headers['Authorization'] = 'Bearer ${await tokenManager.getAccessToken()}';
        requestOptions.headers['Cookie'] = await tokenManager.getRefreshToken();
        return handler.resolve(await dio.fetch(requestOptions));
      }
    } else {
      // 刷新Token的请求返回401,说明refresh token已过期,直接登出
      await _performLogout(error, handler);
    }
  }
  return handler.next(error);
},

// 封装登出逻辑
Future<void> _performLogout(DioException error, ErrorInterceptorHandler handler) async {
  // 先清除拦截器,阻止后续请求触发拦截逻辑
  dio.interceptors.clear();
  await tokenManager.deleteTokens();
  Hive.box<User>('userBox').clear();
  await appPreference.logOut();
  // 避免重复显示弹窗
  if (navigatorKey.currentContext != null && !_isDialogShown) {
    _isDialogShown = true;
    showSessionExpiredDialog(navigatorKey.currentContext!);
    WidgetsBinding.instance.addPostFrameCallback((_) {
      _isDialogShown = false;
    });
  }
  // 拒绝当前请求
  handler.reject(DioException(requestOptions: error.response!.requestOptions));
}

针对疑问的明确解答

疑问1:如何确保替换新AccessToken后,所有拦截器都使用新令牌?

  • 单例模式:全局唯一的DioClient保证所有请求共享同一套拦截器和Token状态,onRequest每次都会从TokenManager读取最新Token。
  • 并发请求处理:通过_isRefreshing标记和Completer让其他请求等待Token刷新完成,避免并发场景下使用旧Token。
  • 移除冗余逻辑:删除onResponse中修改response.requestOptions.headers的代码,该操作对后续请求无影响。

疑问2:如何确保在处理401错误并拒绝请求前,拦截器已被清除?

  • 封装登出方法:在_performLogout中优先执行dio.interceptors.clear(),再执行清除凭证、显示弹窗等操作,保证拦截器在后续逻辑执行前被完全清除。
  • 防重复执行:通过_isRefreshing和_isDialogShown标记,避免多个401请求同时触发登出逻辑,确保拦截器只被清除一次。

内容的提问来源于stack exchange,提问作者Bishal Rumba

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.06 03:44:51