基于大模型的个人消费分析和理财助手:开发日志 6

Token 刷新拦截器重构:从复杂到简洁

背景与问题

用户认证体系的 Token 通常有较短的过期时间(如 15 分钟),配合较长的 Refresh Token(如 7 天)。当 Token 过期时,拦截器需要自动捕获 401 错误、刷新 Token、然后重试原始请求。

最初的实现方案采用了一种基于 UUID 的请求追踪机制:每个请求分配唯一 UUID 放入请求头,拦截器内部维护一个 Map<String, _PendingRequest> 映射表,跟踪哪些请求在等待重试,哪些已经重试过。这个方案在复杂度上走了弯路——一个 176 行的拦截器,代码相当难维护。

旧方案的问题

旧方案的核心流程:

1. 每个请求附加一个 UUID(x-dio-uuid 请求头)
2. onRequest 将请求信息存入 _pendingRequests 映射
3. onError 时,从错误中提取 UUID,查找对应的 pending request
4. 检查 hasTried 标记,决定是否重试
5. 刷新 Token 后,遍历 _pendingRequests 中的所有请求,逐个重试

问题在于:

  • 状态管理过于分散:UUID 生成、提取、映射表查找分散在多个方法中
  • 竞态处理复杂:多个请求同时过期时,需要等待刷新 Token 完成,然后批量重试——Completer + 映射表组合逻辑难以推理
  • 调试困难:UUID 不携带业务含义,无法从日志中追踪具体请求
  • 代码量膨胀_PendingRequest 数据类、_genUUID()_extractUUID()_retry() 四个辅助函数/实体

新方案:retryTimes + 直接重试

class AuthInterceptor extends Interceptor {
  static const MAX_RETRY_TIMES = 1;
  
  Completer<bool>? _refreshCompleter;
  // 注意:不再需要 _pendingRequests 映射表!
}

核心变更:

  1. retryTimes 替代 UUID 映射表
// onRequest —— 初始化重试次数
final retryTimes = options.extra['retryTimes'] as int?;
if (retryTimes == null) {
    options.extra['retryTimes'] = 0;
}

// onError —— 检查并递增
final retryTimes = err.requestOptions.extra['retryTimes'] as int;
if (retryTimes >= MAX_RETRY_TIMES) {
    return handler.next(err);  // 超过上限,直接返回错误
}
err.requestOptions.extra['retryTimes'] = retryTimes + 1;

options.extra 是 Dio 提供的请求附加数据容器,本质上是一个 Map<String, dynamic>。利用它来存储重试次数,将请求状态附着在请求对象本身,彻底消除了外部映射表。

  1. 直接重试而非通过 handler 转发
// 旧方案:通过 handler 转发
// _pendingRequests[uuid] = (...);
// 然后在刷新完成后逐个 pending.value.handler.resolve(resp)

// 新方案:直接重试
final resp = await _dio.fetch(err.requestOptions);
return handler.resolve(resp);

直接调用 _dio.fetch() 重新发送请求——dio.fetch 是一个底层方法,它会重新走拦截器链,包括 onRequest。这意味着:

  • 重试时会自动附加最新的 Token(因为 onRequest 中的 _attachToken 会读取最新的 token)
  • 重试次数 +1 后不会再次尝试刷新 Token(因为 retryTimes >= MAX_RETRY_TIMES 会短路)
  1. 区分 Token 过期和提前刷新
// Token 即将过期 —— 异步刷新,不阻塞请求
if (!JwtUtils.isExpired(userController.token.value) &&
    JwtUtils.isExpired(userController.token.value, leadSeconds: 60)) {
    _doRefresh(logoutWhenFailed: false);  // 静默刷新
    return handler.next(options);
}

// Token 已过期 —— 必须等待刷新后再重试
if (JwtUtils.isExpired(userController.token.value)) {
    // ...
    refreshSuccess = await _refreshCompleter?.future ?? false;
    // 确认刷新成功后,再走 _dio.fetch 重试
}

leadSeconds: 60 参数是关键设计——在 Token 实际过期前 60 秒就触发刷新。这样并发请求中某个慢请求执行到服务端时,Token 可能已经被刷新了,避免了一次不必要的 401。

logoutWhenFailed 参数的引入也很有讲究:

  • "提前刷新"场景下刷新失败 → 不退出登录,因为当前 Token 还有效
  • "过期刷新"场景下刷新失败 → 退出登录,因为连 Refresh Token 也可能过期了

附带的 Bug 修复:FormData 不可复用

在重写拦截器的过程中,发现一个隐蔽的坑:

// 重试请求
if (err.requestOptions.data is FormData) {
    // FormData 不可复用,需要克隆
    err.requestOptions.data = (err.requestOptions.data as FormData).clone();
}
final resp = await _dio.fetch(err.requestOptions);

Dio 的 FormData 设计为一次性消费——当它被 dio.post(data: formData) 发送后,内部的字节流已经被读取,不可回放。重试时如果直接复用原始的 RequestOptions,FormData 会是一个空流。这里使用 .clone() 创建一个新的 FormData 副本,解决重试时的数据丢失问题。

同时需要 import 'package:get/get.dart' hide FormData 隐藏 GetX 包中的 FormData 类型,避免与 Dio 的 FormData 命名冲突。

总结

方面 旧方案(UUID 映射) 新方案(retryTimes)
代码行数 176 行 69 行(-61%)
状态管理 外部映射表 options.extra 内嵌
重试逻辑 遍历映射表批量重试 单个请求直接重试
辅助类型 _PendingRequest 数据类 无需额外类型
可调试性 UUID 无意义 retryTimes 直接可读

这个重构的收益远不止代码量的减少——消除了全局状态的复杂度是最大的胜利。旧的映射表方案在并发场景下容易出现竞态条件(如刷新完成前就有新请求加入映射表),而新方案中每个请求独立管理自己的重试,互不干扰,显著降低了心智负担。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐