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
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
254 lines
9.7 KiB
Dart
254 lines
9.7 KiB
Dart
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 只改 accessToken,isLoggedIn 不变,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);
|
||
}
|