fix(client): refresh token single-flight — 修跨平台「放久了/更新后被踢下线」

refresh token 是单次使用+轮换(服务端每次刷新 DEL 旧 JTI 发新的)。冷启动/进
页面会并发打多个接口,access(15min)过期后同时多次 401,各自拿同一个旧 refresh
token 去刷新:第一个成功轮换,其余拿已作废的 JTI 必被拒 → 误触发 logout 把用户
踢回登录页。与平台无关(共用 auth 逻辑),表现为「放久了/覆盖更新后要重新登录」。

修法:AuthNotifier.refresh() 用共享 in-flight future(single-flight)合并并发刷新,
旧 token 只被消费一次。新增 auth_refresh_singleflight_test 覆盖并发只刷一次 +
刷新后可再刷。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-29 11:05:16 +08:00
parent eafc7167e2
commit 29a6aa9112
2 changed files with 112 additions and 1 deletions
+14 -1
View File
@@ -33,6 +33,13 @@ class AuthNotifier extends StateNotifier<AuthState> {
final TokenStore _store;
final AuthApi _api;
/// 进行中的刷新(single-flight)。refresh token 是「单次使用 + 轮换」的——
/// 服务端每次刷新会作废旧 JTI、发新的。冷启动/进页面会并发打多个接口,过期后
/// 会同时多次 401 → 各自拿同一个旧 refresh token 去刷新:第一个成功轮换,其余
/// 拿已作废的 JTI 必被拒 → 误触发 logout 把用户踢下线。用一个共享的 in-flight
/// future 合并并发刷新,旧 token 只被消费一次。
Future<bool>? _refreshInFlight;
Future<void> _loadFromStore() async {
try {
final token = await _store.loadAccessToken();
@@ -54,7 +61,13 @@ class AuthNotifier extends StateNotifier<AuthState> {
/// 用存储的 refresh token 刷新令牌(供 ApiClient 在 401 时调用)。
/// 成功写入新令牌并更新状态返回 true;无 refresh token / 刷新失败返回 false。
Future<bool> refresh() async {
/// single-flight:并发调用共用同一次刷新,避免单次使用的 refresh token 被多个
/// 并发 401 重复消费 → 误判失效把用户踢下线。
Future<bool> refresh() {
return _refreshInFlight ??= _doRefresh().whenComplete(() => _refreshInFlight = null);
}
Future<bool> _doRefresh() async {
final rt = await _store.loadRefreshToken();
if (rt == null || rt.isEmpty) return false;
try {