Files
jiu/client/lib/core/api/api_client.dart
T
wangjia 36c7ad8b43
Deploy Client / build-client-web (push) Successful in 38s
Deploy Client / build-windows (push) Successful in 1m52s
Deploy Client / build-macos (push) Successful in 1m55s
Deploy Client / build-android (push) Successful in 1m0s
Deploy Client / build-ios (push) Successful in 2m47s
Deploy Client / release-deploy-client (push) Successful in 1m21s
chore: release client-v1.0.61
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 08:56:02 +08:00

254 lines
9.7 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';
import '../config/app_config.dart';
import '../config/app_constants.dart';
import '../config/license_copy.dart';
import '../errors/error_reporter.dart';
import '../../providers/connectivity_provider.dart';
import '../../providers/license_provider.dart';
import 'retry_interceptor.dart';
/// Public Dio instance for unauthenticated calls (login / refresh)
final _publicDio = _buildPublicDio();
Dio _buildPublicDio() {
final dio = Dio(BaseOptions(
baseUrl: AppConfig.apiBaseUrl,
connectTimeout: AppConstants.publicConnectTimeout,
receiveTimeout: AppConstants.publicReceiveTimeout,
));
dio.interceptors.add(RetryInterceptor(dio));
return dio;
}
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,
onTokensRefreshed: (accessToken, refreshToken) {
ref
.read(authStateProvider.notifier)
.updateAccessToken(accessToken, refreshToken);
},
onAuthFailed: (reason) {
if (ref.read(authStateProvider).isLoggedIn) {
if (reason != null) {
ref.read(sessionEndedMessageProvider.notifier).state = reason;
}
ref.read(authStateProvider.notifier).logout();
}
},
onConnectionError: () {
ref.read(connectivityProvider.notifier).forceCheck();
},
onForbidden: (body) {
final map = body is Map ? body : const <String, dynamic>{};
final code = map['code'];
final phase = map['phase'];
// 只读角色写被拒:直接提示(无需刷新授权)。
if (code == 'READONLY_USER') {
ref.read(apiMessageProvider.notifier).state =
LicenseCopy.readonlyUserToast;
return;
}
// 授权过期(readonly/locked)写被拒:刷新授权令横幅/按钮即时降级,并提示。
if (phase == 'readonly' || phase == 'locked') {
ref.read(licenseProvider.notifier).refresh().then((_) {
final lic = ref.read(licenseProvider).valueOrNull;
ref.read(apiMessageProvider.notifier).state = lic != null
? LicenseCopy.writeBlockedToast(lic)
: '授权已过期,无法执行写操作';
});
}
// 其它 403(管理员/超管权限不足等)不在此统一处理,交由调用方。
},
);
ref.onDispose(client.dispose);
return client;
});
class ApiClient {
late final Dio _dio;
bool _disposed = false;
/// 当前 refresh token。续期会轮换它,故必须可变并随响应更新——
/// provider 不会因 token 变化重建本实例(只监听 isLoggedIn)。
String? _refreshToken;
/// 单飞:并发 401 共享同一次刷新。否则每个 401 各发一次 /auth/refresh
/// 各自轮换 jti,后到的请求重放已被取代的 jti → 触发盗用检测吊销整条会话。
Future<String?>? _refreshing;
void Function(String accessToken, String refreshToken)? _onTokensRefreshed;
void Function(String? reason)? _onAuthFailed;
ApiClient({
String? token,
String? refreshToken,
void Function(String accessToken, String refreshToken)? onTokensRefreshed,
void Function(String? reason)? onAuthFailed,
void Function()? onConnectionError,
void Function(dynamic body)? onForbidden,
}) {
_refreshToken = refreshToken;
_onTokensRefreshed = onTokensRefreshed;
_onAuthFailed = onAuthFailed;
_dio = Dio(BaseOptions(
baseUrl: AppConfig.apiBaseUrl,
connectTimeout: AppConstants.publicConnectTimeout,
receiveTimeout: AppConstants.publicReceiveTimeout,
headers: {
if (token != null) 'Authorization': 'Bearer $token',
},
));
// 网络层错误自动重试(须在错误处理拦截器之前,先重试再走 401/上报逻辑)
_dio.interceptors.add(RetryInterceptor(_dio));
// 网络错误 + 401 拦截器
_dio.interceptors.add(
InterceptorsWrapper(
onError: (DioException e, ErrorInterceptorHandler handler) async {
if (_disposed) return handler.next(e);
// 网络层错误(连接拒绝、超时等)→ 立即触发连通性检测
final isNetworkError = e.type == DioExceptionType.connectionError ||
e.type == DioExceptionType.connectionTimeout ||
e.type == DioExceptionType.receiveTimeout ||
e.type == DioExceptionType.sendTimeout;
if (isNetworkError) {
onConnectionError?.call();
return handler.next(e);
}
// 服务端 5xx 错误上报(4xx 是业务/权限错误,不上报)
final statusCode = e.response?.statusCode ?? 0;
if (statusCode >= 500) {
reportError(
'[${e.requestOptions.method}] ${e.requestOptions.path}$statusCode',
e.stackTrace,
errorType: ErrorType.apiError,
);
}
// 403:写权限被拒(只读角色 / 授权过期)。交给上层刷新授权 + 提示。
// 后端契约:body 带 code=READONLY_USER 或 phase=readonly|locked。
if (e.response?.statusCode == 403) {
if (!_disposed) onForbidden?.call(e.response?.data);
return handler.next(e);
}
// 账号被停用/删除:中间件返回 401 + code=USER_DISABLED。这是终态,
// 续期也救不回(refresh 会因 is_active=0 再次失败),直接强制重新登录。
if (e.response?.statusCode == 401 &&
e.response?.data is Map &&
e.response?.data['code'] == 'USER_DISABLED') {
if (!_disposed) {
_onAuthFailed?.call('您的账号已被停用或删除,请重新登录或联系管理员');
}
return handler.next(e);
}
if (e.response?.statusCode == 401 && (_refreshToken ?? '').isNotEmpty) {
debugPrint('[ApiClient] got 401 on ${e.requestOptions.path}, trying refresh...');
final newToken = await _refreshAccessToken();
if (newToken == null) {
return handler.next(e);
}
try {
final opts = e.requestOptions;
opts.headers['Authorization'] = 'Bearer $newToken';
final retryResp = await _dio.fetch(opts);
return handler.resolve(retryResp);
} catch (retryErr) {
return handler.next(retryErr is DioException ? retryErr : e);
}
} else if (e.response?.statusCode == 401) {
debugPrint('[ApiClient] got 401 on ${e.requestOptions.path}, no refresh token');
}
return handler.next(e);
},
),
);
}
/// 单飞刷新:并发 401 复用同一次 /auth/refresh,避免多次轮换 jti 触发盗用检测。
/// 成功返回新的 access token;失败(含会话已撤销)返回 null 并通知 onAuthFailed。
Future<String?> _refreshAccessToken() {
return _refreshing ??= _doRefresh().whenComplete(() => _refreshing = null);
}
Future<String?> _doRefresh() async {
try {
final resp = await _publicDio.post('/auth/refresh', data: {
'refresh_token': _refreshToken,
});
final data = resp.data['data'] as Map;
final newAccess = data['access_token'] as String;
// 后端轮换 refresh token,必须采纳新值(无则沿用旧值,兼容老后端)。
final newRefresh = (data['refresh_token'] as String?) ?? _refreshToken;
_refreshToken = newRefresh;
_dio.options.headers['Authorization'] = 'Bearer $newAccess';
if (!_disposed && newRefresh != null) {
_onTokensRefreshed?.call(newAccess, newRefresh);
}
return newAccess;
} catch (refreshErr) {
debugPrint('[ApiClient] refresh failed: $refreshErr');
// 区分「被踢/会话失效」与普通登录过期,便于登录页给出明确提示
String? reason;
if (refreshErr is DioException &&
refreshErr.response?.data is Map &&
refreshErr.response?.data['code'] == 'SESSION_REVOKED') {
reason = '您的账号已在其他设备登录,或登录已失效,请重新登录';
}
if (!_disposed) _onAuthFailed?.call(reason);
return null;
}
}
/// 仅供测试:访问内部 Dio,以便挂载 mock adapter 走真实拦截器(401/403 等)。
@visibleForTesting
Dio get dioForTest => _dio;
/// 取消所有进行中的请求,标记实例为已废弃
void dispose() {
_disposed = true;
_dio.close(force: true);
}
Future<Response> get(String path, {Map<String, dynamic>? params}) =>
_dio.get(path, queryParameters: params);
Future<List<int>> getBytes(String path) async {
final resp = await _dio.get<List<int>>(path,
options: Options(responseType: ResponseType.bytes));
return resp.data ?? [];
}
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> patch(String path, {dynamic data}) =>
_dio.patch(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);
}