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(0); static final availability = ValueNotifier( 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.delayed( const Duration(seconds: 1), () => _handlingUnauthorized = false, ); } handler.next(error); }, ), ); return client; } Future saveToken(String token) async { await _storage.write(key: _tokenKey, value: token); await _storage.delete(key: _legacyTokenKey); } Future readToken() => _storage.read(key: _tokenKey); Future probe() async { try { await dio.get( '/api/public/brand', options: Options(receiveTimeout: const Duration(seconds: 10)), ); } catch (_) { // The interceptor owns the reachability state transition. } } Future 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 '出错了,请稍后再试'; }