131 lines
4.5 KiB
Dart
131 lines
4.5 KiB
Dart
import 'package:dio/dio.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
|
import 'package:miaoji_zhang/shared/api/backend_identity.dart';
|
|
import 'package:miaoji_zhang/shared/services/session_store.dart';
|
|
|
|
enum BackendAvailability { unknown, online, offline }
|
|
|
|
class ApiClient {
|
|
ApiClient._();
|
|
static final ApiClient instance = ApiClient._();
|
|
|
|
static const _storage = FlutterSecureStorage();
|
|
static const _legacyTokenKey = 'auth_token';
|
|
static final _tokenKey = 'auth_token_${BackendIdentity.scope}';
|
|
static final sessionExpired = ValueNotifier<int>(0);
|
|
static final availability = ValueNotifier<BackendAvailability>(
|
|
BackendAvailability.unknown,
|
|
);
|
|
static bool _handlingUnauthorized = false;
|
|
|
|
static const String baseUrl = BackendIdentity.baseUrl;
|
|
|
|
static bool get isInternalBuild => BackendIdentity.internalBuild;
|
|
|
|
late final Dio dio = _createDio();
|
|
|
|
Dio _createDio() {
|
|
final client =
|
|
Dio(
|
|
BaseOptions(
|
|
baseUrl: baseUrl,
|
|
connectTimeout: const Duration(seconds: 10),
|
|
receiveTimeout: const Duration(seconds: 120),
|
|
),
|
|
)
|
|
..interceptors.add(
|
|
InterceptorsWrapper(
|
|
onResponse: (response, handler) {
|
|
availability.value = BackendAvailability.online;
|
|
handler.next(response);
|
|
},
|
|
onRequest: (options, handler) async {
|
|
final token = await _storage.read(key: _tokenKey);
|
|
if (token != null) {
|
|
options.headers['Authorization'] = 'Bearer $token';
|
|
}
|
|
handler.next(options);
|
|
},
|
|
onError: (error, handler) async {
|
|
if (isConnectivityError(error)) {
|
|
availability.value = BackendAvailability.offline;
|
|
} else if (error.response != null) {
|
|
availability.value = BackendAvailability.online;
|
|
}
|
|
final unauthorized = error.response?.statusCode == 401;
|
|
final data = error.response?.data;
|
|
final aiDenied =
|
|
error.response?.statusCode == 403 &&
|
|
data is Map &&
|
|
data['code'] == 'AI_PERMISSION_DENIED';
|
|
if (aiDenied) {
|
|
await SessionStore.instance.setAiEnabled(false);
|
|
}
|
|
final isAuthRequest = error.requestOptions.path.startsWith(
|
|
'/api/auth/',
|
|
);
|
|
if (unauthorized && !isAuthRequest && !_handlingUnauthorized) {
|
|
_handlingUnauthorized = true;
|
|
await SessionStore.instance.markNeedsReauth();
|
|
sessionExpired.value++;
|
|
Future<void>.delayed(
|
|
const Duration(seconds: 1),
|
|
() => _handlingUnauthorized = false,
|
|
);
|
|
}
|
|
handler.next(error);
|
|
},
|
|
),
|
|
);
|
|
|
|
return client;
|
|
}
|
|
|
|
Future<void> saveToken(String token) async {
|
|
await _storage.write(key: _tokenKey, value: token);
|
|
await _storage.delete(key: _legacyTokenKey);
|
|
}
|
|
|
|
Future<String?> readToken() => _storage.read(key: _tokenKey);
|
|
|
|
Future<void> probe() async {
|
|
try {
|
|
await dio.get<void>(
|
|
'/api/public/brand',
|
|
options: Options(receiveTimeout: const Duration(seconds: 10)),
|
|
);
|
|
} catch (_) {
|
|
// The interceptor owns the reachability state transition.
|
|
}
|
|
}
|
|
|
|
Future<void> clearToken() async {
|
|
await _storage.delete(key: _tokenKey);
|
|
await _storage.delete(key: _legacyTokenKey);
|
|
}
|
|
}
|
|
|
|
bool isConnectivityError(Object error) =>
|
|
error is DioException &&
|
|
(error.type == DioExceptionType.connectionTimeout ||
|
|
error.type == DioExceptionType.connectionError ||
|
|
error.type == DioExceptionType.receiveTimeout ||
|
|
error.type == DioExceptionType.sendTimeout);
|
|
String apiErrorMessage(Object e) {
|
|
if (e is OnlineFeatureRequiredException) return e.message;
|
|
if (e is StateError) return e.message;
|
|
if (e is DioException) {
|
|
final data = e.response?.data;
|
|
if (data is Map && data['message'] != null) {
|
|
return data['message'] as String;
|
|
}
|
|
if (e.type == DioExceptionType.connectionTimeout ||
|
|
e.type == DioExceptionType.connectionError ||
|
|
e.type == DioExceptionType.receiveTimeout) {
|
|
return '无法连接服务器,请检查网络';
|
|
}
|
|
}
|
|
return '出错了,请稍后再试';
|
|
}
|