Files
jiu/client/lib/core/api/api_client.dart
T
wangjia dc6e68154b fix(client): 修复启动时 token 刷新导致 Dio adapter closed 崩溃
apiClientProvider 改用 select(isLoggedIn),token refresh 不触发 provider
重建,避免旧 Dio 被 close 后仍执行 fetch。同时在拦截器内原地更新
_dio.options.headers,保证后续请求使用新 token。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 08:34:29 +08:00

121 lines
4.1 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../auth/auth_state.dart';
const _baseUrl = 'http://localhost:8080/api/v1';
/// Public Dio instance for unauthenticated calls (login / refresh)
final _publicDio = Dio(BaseOptions(
baseUrl: _baseUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 30),
headers: {'Content-Type': 'application/json'},
));
final apiClientProvider = Provider<ApiClient>((ref) {
// 只监听登录/登出,不监听 token 内容变化。
// token refresh 只改 accessTokenisLoggedIn 不变,provider 不重建,
// 避免旧 Dio 被 close 后还在执行 fetch 导致 "adapter was closed"。
ref.watch(authStateProvider.select((s) => s.isLoggedIn));
final user = ref.read(authStateProvider).user;
final client = ApiClient(
token: user?.accessToken,
refreshToken: user?.refreshToken,
onTokenRefreshed: (newToken) {
ref.read(authStateProvider.notifier).updateAccessToken(newToken);
},
onAuthFailed: () {
if (ref.read(authStateProvider).isLoggedIn) {
ref.read(authStateProvider.notifier).logout();
}
},
);
ref.onDispose(client.dispose);
return client;
});
class ApiClient {
late final Dio _dio;
bool _disposed = false;
ApiClient({
String? token,
String? refreshToken,
void Function(String newToken)? onTokenRefreshed,
void Function()? onAuthFailed,
}) {
_dio = Dio(BaseOptions(
baseUrl: _baseUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 30),
headers: {
'Content-Type': 'application/json',
if (token != null) 'Authorization': 'Bearer $token',
},
));
// 401 auto-refresh 拦截器
if (refreshToken != null) {
_dio.interceptors.add(
InterceptorsWrapper(
onError: (DioException e, ErrorInterceptorHandler handler) async {
// 如果这个 client 已被替换,忽略所有回调
if (_disposed) {
return handler.next(e);
}
if (e.response?.statusCode == 401 && refreshToken.isNotEmpty) {
debugPrint('[ApiClient] got 401 on ${e.requestOptions.path}, trying refresh...');
try {
final resp = await _publicDio.post('/auth/refresh', data: {
'refresh_token': refreshToken,
});
final newToken =
resp.data['data']['access_token'] as String;
// 原地更新 Dio 默认 headers,后续所有请求生效
_dio.options.headers['Authorization'] = 'Bearer $newToken';
if (!_disposed) onTokenRefreshed?.call(newToken);
// 用新 token 重试原请求(此时 _dio 仍存活,不会 adapter closed
final opts = e.requestOptions;
opts.headers['Authorization'] = 'Bearer $newToken';
final retryResp = await _dio.fetch(opts);
return handler.resolve(retryResp);
} catch (refreshErr) {
debugPrint('[ApiClient] refresh failed: $refreshErr');
if (!_disposed) onAuthFailed?.call();
}
} else if (e.response?.statusCode == 401) {
debugPrint('[ApiClient] got 401 on ${e.requestOptions.path}, no refresh token');
}
return handler.next(e);
},
),
);
}
}
/// 取消所有进行中的请求,标记实例为已废弃
void dispose() {
_disposed = true;
_dio.close(force: true);
}
Future<Response> get(String path, {Map<String, dynamic>? params}) =>
_dio.get(path, queryParameters: params);
Future<Response> post(String path, {dynamic data}) =>
_dio.post(path, data: data);
Future<Response> put(String path, {dynamic data}) =>
_dio.put(path, data: data);
Future<Response> delete(String path) => _dio.delete(path);
}
/// Unauthenticated client for login/refresh
class PublicApiClient {
static Future<Response> post(String path, {dynamic data}) =>
_publicDio.post(path, data: data);
}