基于大模型的个人消费分析和理财助手:开发日志 6
基于大模型的个人消费分析和理财助手:开发日志 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 映射表!
}
核心变更:
- 用
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>。利用它来存储重试次数,将请求状态附着在请求对象本身,彻底消除了外部映射表。
- 直接重试而非通过 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会短路)
- 区分 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 直接可读 |
这个重构的收益远不止代码量的减少——消除了全局状态的复杂度是最大的胜利。旧的映射表方案在并发场景下容易出现竞态条件(如刷新完成前就有新请求加入映射表),而新方案中每个请求独立管理自己的重试,互不干扰,显著降低了心智负担。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)