401 自动刷新 Token 的完整架构设计(Dio 实战版)

------并发只刷新一次 + 原请求自动重试 + 刷新失败全局登出

适用:Flutter + Dio;页面并发请求多(首页/列表/消息)且需要登录态。

目标:当 accessToken 过期导致 401 时,自动 refresh,再把失败请求重试一次;并发场景只 refresh 一次,避免刷新风暴。

1. 真实场景:为什么会出现"一堆 401"?

首页常见并发请求:

Dart 复制代码
await Future.wait([
  api.profile(),
  api.orders(),
  api.unread(),
]);

当 token 过期时,A/B/C 三个请求都带着旧 token 发出去 → 各自返回 401。
多个 401 是正常现象 ,真正要解决的是:不能多个 401 触发多个 refresh

2. 方案总览(分层边界)

推荐网络层分层:

Dart 复制代码
UI/State → Repository → DioClient(唯一出口)
                         ├─ AuthInterceptor(请求注入 token)
                         ├─ RefreshInterceptor(401:刷新 + 重试)
                         └─ (可选) Error/Log 拦截器

职责一句话:

  • AuthInterceptor:正常请求都带上 token
  • RefreshInterceptor:只在 401 时介入,完成 refresh + retry
  • UI/Repository:不写任何 refresh 逻辑,只处理"登录过期"事件即可

3. 核心原理:共享 Future = 异步并发锁

刷新 token 这件事是全局临界区,必须"只执行一次"。

做法:用一个共享 Future(句柄)保存"正在刷新"的动作。

  • 第一个 401:创建 refresh Future 并 await
  • 后续 401:复用同一个 Future 并 await
  • refresh 成功:大家拿到同一个新 token,各自重试一次原请求

4. 最小可用实现(可直接拷项目)

4.1 TokenStore(存取 access/refresh)

Dart 复制代码
class TokenPair {
  final String accessToken;
  final String refreshToken;
  TokenPair(this.accessToken, this.refreshToken);
}

class TokenStore {
  static TokenPair? _pair;

  static TokenPair? get() => _pair;

  static Future<void> set(TokenPair pair) async {
    _pair = pair;
    // TODO: persist to secure storage
  }

  static Future<void> clear() async {
    _pair = null;
  }
}

4.2 RefreshManager(共享 Future:只刷新一次)

Dart 复制代码
class RefreshManager {
  Future<String?>? _refreshing; // new accessToken

  Future<String?> getOrCreate(Future<String?> Function() job) {
    _refreshing ??= job().whenComplete(() {
      _refreshing = null;
    });
    return _refreshing!;
  }
}

4.3 AuthInterceptor(每次请求自动带 token)

Dart 复制代码
import 'package:dio/dio.dart';

class AuthInterceptor extends Interceptor {
  @override
  void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
    final token = TokenStore.get()?.accessToken;
    if (token != null && token.isNotEmpty) {
      options.headers["Authorization"] = "Bearer $token";
    }
    handler.next(options);
  }
}

4.4 RefreshInterceptor(401 → refresh → retry)

两条保险丝必须有:

1)同一请求只重试一次(防死循环)

2)refresh 用干净 Dio(防递归)

Dart 复制代码
import 'package:dio/dio.dart';

class RefreshInterceptor extends Interceptor {
  final Dio dio;        // 主 Dio:用于重试原请求
  final Dio cleanDio;   // 干净 Dio:只用于 refresh
  final RefreshManager mgr;
  final void Function() onAuthExpired; // 刷新失败回调(全局登出)

  RefreshInterceptor({
    required this.dio,
    required this.cleanDio,
    required this.mgr,
    required this.onAuthExpired,
  });

  @override
  void onError(DioException err, ErrorInterceptorHandler handler) async {
    if (err.response?.statusCode != 401) {
      return handler.next(err);
    }

    final req = err.requestOptions;

    // 保险丝1:同一请求只重试一次
    if (req.extra["retried"] == true) {
      return handler.next(err);
    }

    final pair = TokenStore.get();
    if (pair == null || pair.refreshToken.isEmpty) {
      onAuthExpired();
      return handler.next(err);
    }

    // 核心:共享 Future,保证只刷新一次
    final newAccessToken = await mgr.getOrCreate(() async {
      try {
        // TODO: 改成你们的 refresh 接口 & 字段
        final res = await cleanDio.post(
          "/auth/refresh",
          data: {"refreshToken": pair.refreshToken},
        );

        final data = res.data as Map<String, dynamic>;
        final access = data["accessToken"] as String;
        final refresh = data["refreshToken"] as String;

        await TokenStore.set(TokenPair(access, refresh));
        return access;
      } catch (_) {
        await TokenStore.clear();
        return null;
      }
    });

    if (newAccessToken == null) {
      onAuthExpired();
      return handler.next(err);
    }

    // 刷新成功:重试原请求一次(带新 token + retried 标记)
    try {
      final retryResp = await dio.request(
        req.path,
        data: req.data,
        queryParameters: req.queryParameters,
        options: Options(
          method: req.method,
          headers: Map<String, dynamic>.from(req.headers)
            ..["Authorization"] = "Bearer $newAccessToken",
          extra: Map<String, dynamic>.from(req.extra)..["retried"] = true,
        ),
        cancelToken: req.cancelToken,
        onSendProgress: req.onSendProgress,
        onReceiveProgress: req.onReceiveProgress,
      );

      return handler.resolve(retryResp);
    } catch (_) {
      return handler.next(err);
    }
  }
}

4.5 DioClient(主 dio + cleanDio + 注册顺序)

顺序建议:AuthInterceptor 在前,RefreshInterceptor 在后(401 错误回来时先被 RefreshInterceptor 处理)

Dart 复制代码
import 'package:dio/dio.dart';

class DioClient {
  DioClient._();
  static final DioClient instance = DioClient._();

  late final Dio dio;
  late final Dio cleanDio;

  final RefreshManager mgr = RefreshManager();

  void init({required void Function() onAuthExpired}) {
    dio = Dio(BaseOptions(baseUrl: "https://api.example.com"));
    cleanDio = Dio(BaseOptions(baseUrl: "https://api.example.com"));

    dio.interceptors.addAll([
      AuthInterceptor(),
      RefreshInterceptor(
        dio: dio,
        cleanDio: cleanDio,
        mgr: mgr,
        onAuthExpired: onAuthExpired,
      ),
    ]);
  }
}

5. 首页 A/B/C 并发:发生了什么?

当 token 过期,A/B/C 都会 401,但:

  • 第一个进入 RefreshInterceptor 的 401:发 refresh(真正网络请求)
  • 其他两个 401:await 同一个 refresh Future(不再 refresh)
  • refresh 成功后:A/B/C 各自重试一次原请求 → 页面成功刷新
  • refresh 失败:触发 onAuthExpired(),统一登出/跳登录

一句话:401 可以多次出现,但 refresh 只能一次。

6. 刷新失败"只处理一次"的建议

onAuthExpired 建议做成"全局事件",只跳一次登录,避免多页面重复跳转。

全局 onAuthExpired:刷新失败时为什么一定要"统一登出/跳登录"?

很多教程只写到:401 → refresh → retry

但真实项目里还有一个不可忽略的分支:

  • refreshToken 也过期/失效

  • refresh 接口失败(401/403/400/网络错误)

  • 账号被踢、被禁用、权限变更导致持续 401

这时,继续 retry 没意义,你必须把系统切到"未登录态"。

为什么不能在每个接口里各自处理登出?

因为首页很常见是并发 A/B/C 多个请求:

  • token 过期 → A/B/C 全部 401

  • refresh 失败 → A/B/C 都会走到失败分支

  • 如果每个请求都执行 "logout + 跳登录",就会出现:

    • 重复弹 toast

    • 重复 push/login

    • 路由栈混乱

    • 状态重复清理

所以需要一个全局入口:onAuthExpired 只触发一次,把登出流程收敛到一个地方。

推荐做法:网络层只"发信号",App 层只"处理一次"

1)AuthEventBus:全局事件(最通用,和你用什么状态管理无关)

Dart 复制代码
import 'dart:async';

enum AuthEvent { expired }

class AuthEventBus {
  AuthEventBus._();
  static final AuthEventBus I = AuthEventBus._();

  final _c = StreamController<AuthEvent>.broadcast();
  Stream<AuthEvent> get stream => _c.stream;

  void emit(AuthEvent e) => _c.add(e);
}

2)AuthExpiredGate:防止并发重复触发(只 fire 一次)

Dart 复制代码
class AuthExpiredGate {
  bool _fired = false;

  void fireOnce(void Function() action) {
    if (_fired) return;
    _fired = true;
    action();
  }

  void reset() => _fired = false; // 登录成功后可 reset
}

3)在 DioClient.init() 注入 onAuthExpired(给 RefreshInterceptor 用)

Dart 复制代码
final _gate = AuthExpiredGate();

void onAuthExpired() {
  _gate.fireOnce(() {
    AuthEventBus.I.emit(AuthEvent.expired);
  });
}

class DioClient {
  DioClient._();
  static final DioClient instance = DioClient._();

  late final Dio dio;
  late final Dio cleanDio;

  final RefreshManager mgr = RefreshManager();

  void init() {
    dio = Dio(BaseOptions(baseUrl: "https://api.example.com"));
    cleanDio = Dio(BaseOptions(baseUrl: "https://api.example.com"));

    dio.interceptors.addAll([
      AuthInterceptor(),
      RefreshInterceptor(
        dio: dio,
        cleanDio: cleanDio,
        mgr: mgr,
        onAuthExpired: onAuthExpired,
      ),
    ]);
  }
}

这样当 refresh 失败时,拦截器里调用 onAuthExpired(),全局只会发一次 "expired"。

App 层统一登出/跳登录(NavigatorKey 方案,最稳)

final navigatorKey = GlobalKey<NavigatorState>();

MaterialApp:

MaterialApp(

navigatorKey: navigatorKey,

// routes...

)

2)应用启动时订阅 AuthEventBus(只订阅一次)

Dart 复制代码
late final StreamSubscription _authSub;

void setupAuthListener() {
  _authSub = AuthEventBus.I.stream.listen((e) async {
    if (e == AuthEvent.expired) {
      // 1) 清 token(保险)
      await TokenStore.clear();

      // 2) 清全局用户态(你用 Riverpod/Bloc/Provider 都在这里统一处理)
      // userStore.logout();

      // 3) 跳登录(只做一次)
      navigatorKey.currentState
          ?.pushNamedAndRemoveUntil('/login', (route) => false);

      // 4) 可选:toast 一次
      // showToast("登录已过期,请重新登录");
    }
  });
}

void disposeAuthListener() {
  _authSub.cancel();
}

结果:并发 N 个请求同时失败,也只会登出/跳转一次,UI 不乱。

结束。

下一篇:

401 刷新 Token 的队列版(请求挂起排队 + 刷新后统一重放/统一失败)

======================================================

增强版本(可以不看)

队列版不是"必须",而是当项目复杂到一定程度,共享 Future 版会暴露一些工程痛点;队列版是为了解决这些痛点,让行为更可控、更像"中间件"。

下面我把"为什么要搞队列版"讲透,你就知道什么时候该用、什么时候不需要。

1)共享 Future 版已经能解决什么?

共享 Future 版核心是:

  • 多个 401 → 只刷新一次

  • 其他 401 等刷新结果 → 各自重试一次

它最大的优点:简单、好写、足够稳

所以小中型 App 直接用它就行。

2)那队列版解决的是哪些"更工程化"的问题?

A. "刷新期间请求怎么处理"更可控

共享 Future 版是:

每个 401 都自己在 onError 里 await → 然后自己 retry

队列版是:

401 先"挂起入队",等刷新结束后统一重放/统一失败

好处:

  • 你能统一控制重放顺序(按进入顺序)

  • 你能做限流重放(一次重放 N 个,避免刷新后瞬时洪峰)

  • 你能做合并/去重(同一个接口短时间重复,只保留一个)

如果你的首页一刷新会触发几十个接口,这个控制会很有价值。

B. 刷新失败时"一锅端"更干净

共享 Future 版里,刷新失败后每个请求都会走自己的错误链,容易出现:

  • 多个请求同时触发 UI 提示(toast 多次)

  • 多个地方同时触发登出逻辑(重复跳转)

队列版天然适合:

  • refresh 失败 → 队列里所有等待请求统一 reject

  • 同时只发一次 AuthExpired 事件

体验上更"整洁"。

C. 更容易"挂起而不是立刻失败"

有些项目希望:

刷新期间不要让业务层立刻收到错误(比如页面下拉刷新时不想闪红错误),而是:

  • token 刷新成功 → 无感恢复请求

  • token 刷新失败 → 再统一报错/跳登录

队列版天生就是"挂起等待"的模式。

D. 更方便做"统一重试策略"

队列版可以统一做很多策略,比如:

  • 每个请求最多重试 1 次(你现在已有)

  • 某些接口 不允许重试(比如支付/下单等非幂等请求,避免重复提交)

  • 对 GET 允许重试,对 POST/PUT 做白名单

共享 Future 也能做,但分散在每个请求的 retry 分支里,队列版更集中。

3)队列版的代价是什么?(你也要知道)

队列版不是白给的,它会带来:

  • 实现复杂度更高(要保存 handler、要 drain 队列)

  • 需要更谨慎处理:

    • 取消请求(CancelToken)

    • 队列过大(内存)

    • 重放时的并发/顺序策略

所以它适合"接口多、并发多、状态敏感"的项目。

4)什么时候你应该用队列版?

满足任意一条就建议上队列版:

  • 首页/核心页一次会并发几十个请求

  • 刷新成功后重试洪峰明显(服务器/弱网下抖动)

  • 你想做重试限流、合并去重、顺序控制

  • 你需要"刷新期间请求挂起,不要立刻报错"

  • 你们后端 refreshToken 一次性、并发失败概率高,希望统一收敛处理

5)什么时候共享 Future 版就足够?

  • 接口数量不大(并发 3~10 个)

  • 只需要"只刷新一次 + 自动重试"这两个核心能力

  • 你还在搭项目早期,想先用简单可靠的方案

结论(帮你直接选型)

  • 90% App:共享 Future 版(足够、简单、稳定)

  • 接口量大/并发大/体验要求高:队列版(更工程化)

相关推荐
wadesir2 小时前
Rust中的条件变量详解(使用Condvar的wait方法实现线程同步)
开发语言·算法·rust
tap.AI2 小时前
RAG系列(二)数据准备与向量索引
开发语言·人工智能
console.log('npc')2 小时前
Table,vue3在父组件调用子组件columns列的方法展示弹窗文件预览效果
前端·javascript·vue.js
阿蒙Amon2 小时前
C#每日面试题-重写和重载的区别
开发语言·c#
用户47949283569152 小时前
React Hooks 的“天条”:为啥绝对不能写在 if 语句里?
前端·react.js
是一个Bug2 小时前
Java基础20道经典面试题(二)
java·开发语言
Z_Easen2 小时前
Spring 之元编程
java·开发语言
liliangcsdn2 小时前
python下载并转存http文件链接的示例
开发语言·python
我命由我123452 小时前
SVG - SVG 引入(SVG 概述、SVG 基本使用、SVG 使用 CSS、SVG 使用 JavaScript、SVG 实例实操)
开发语言·前端·javascript·css·学习·ecmascript·学习方法