111
This commit is contained in:
@@ -2,16 +2,13 @@
|
||||
class ApiEndpoints {
|
||||
ApiEndpoints._();
|
||||
|
||||
/// 環境類型
|
||||
static const String _env = String.fromEnvironment('ENV', defaultValue: 'dev');
|
||||
/// 基礎URL - 由 DomainNavigator 在啟動時動態設置
|
||||
static String get baseUrl => _baseUrl;
|
||||
static String _baseUrl = '';
|
||||
|
||||
/// 基礎URL - 根據環境自動切換
|
||||
static const String baseUrl = _env == 'prod'
|
||||
? 'http://8.155.172.147:5010'
|
||||
: 'http://localhost:5010';
|
||||
|
||||
/// 是否為生產環境
|
||||
static const bool isProduction = _env == 'prod';
|
||||
static void init(String url) {
|
||||
_baseUrl = url;
|
||||
}
|
||||
|
||||
// ==================== 用戶模塊 ====================
|
||||
|
||||
@@ -33,9 +30,6 @@ class ApiEndpoints {
|
||||
/// 退出登錄
|
||||
static const String logout = '/api/user/logout';
|
||||
|
||||
/// 刷新 Token
|
||||
static const String tokenRefresh = '/api/auth/refresh';
|
||||
|
||||
// ==================== 行情模塊 ====================
|
||||
|
||||
/// 獲取幣種列表
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import '../constants/api_endpoints.dart';
|
||||
import '../storage/local_storage.dart';
|
||||
import 'api_exception.dart';
|
||||
@@ -8,37 +7,20 @@ import 'api_response.dart';
|
||||
|
||||
/// 網絡配置常量
|
||||
class NetworkConfig {
|
||||
static const String baseUrl = ApiEndpoints.baseUrl;
|
||||
static String get baseUrl => ApiEndpoints.baseUrl;
|
||||
static const Duration connectTimeout = Duration(seconds: 15);
|
||||
static const Duration receiveTimeout = Duration(seconds: 15);
|
||||
static const Duration sendTimeout = Duration(seconds: 15);
|
||||
}
|
||||
|
||||
/// 不需要 token 的接口路徑(不會觸發刷新/登出邏輯)
|
||||
const _authFreePaths = [
|
||||
'/api/user/login',
|
||||
'/api/user/register',
|
||||
'/api/auth/refresh',
|
||||
];
|
||||
|
||||
bool _isAuthFreePath(String path) {
|
||||
return _authFreePaths.any((p) => path.contains(p));
|
||||
}
|
||||
|
||||
/// Dio 網絡客戶端
|
||||
class DioClient {
|
||||
late final Dio _dio;
|
||||
|
||||
/// 全局導航 key,用於未授權時跳轉登錄頁
|
||||
GlobalKey<NavigatorState>? navigatorKey;
|
||||
/// 未授權回調(token 過期時觸發)
|
||||
VoidCallback? onUnauthorized;
|
||||
|
||||
/// 未授權回調(token 徹底過期時觸發 AuthProvider.forceLogout)
|
||||
VoidCallback? onForceLogout;
|
||||
|
||||
/// 防止重複 forceLogout
|
||||
bool hasForceLoggedOut = false;
|
||||
|
||||
DioClient({this.navigatorKey}) {
|
||||
DioClient() {
|
||||
_dio = _createDio();
|
||||
_setupInterceptors();
|
||||
debugPrint('DioClient initialized with baseUrl: ${NetworkConfig.baseUrl}');
|
||||
@@ -56,7 +38,7 @@ class DioClient {
|
||||
|
||||
void _setupInterceptors() {
|
||||
_dio.interceptors.addAll([
|
||||
_TokenRefreshInterceptor(this),
|
||||
_AuthInterceptor(),
|
||||
_LoggingInterceptor(),
|
||||
]);
|
||||
}
|
||||
@@ -107,19 +89,28 @@ class DioClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// 處理響應 — 只做數據解析,不再觸發認證邏輯
|
||||
ApiResponse<T> _handleResponse<T>(
|
||||
Response response,
|
||||
T Function(dynamic)? fromJson,
|
||||
) {
|
||||
final data = response.data;
|
||||
if (data is Map<String, dynamic>) {
|
||||
return ApiResponse.fromJson(data, fromJson);
|
||||
final apiResponse = ApiResponse.fromJson(data, fromJson);
|
||||
// 檢測業務層未授權(後端返回 HTTP 200 + code "0002")
|
||||
// 注意:不再自動清除用戶數據,避免誤判
|
||||
// 只有在 HTTP 401 時才清除用戶數據
|
||||
if (apiResponse.isUnauthorized) {
|
||||
debugPrint('業務層未授權響應: ${apiResponse.message}');
|
||||
// 不再自動調用 onUnauthorized,避免刷新時誤判
|
||||
// onUnauthorized?.call();
|
||||
}
|
||||
return apiResponse;
|
||||
}
|
||||
return ApiResponse.fail('響應數據格式錯誤');
|
||||
}
|
||||
|
||||
ApiResponse<T> _handleError<T>(DioException e) {
|
||||
// 詳細錯誤日誌
|
||||
debugPrint('=== Network Error ===');
|
||||
debugPrint('Type: ${e.type}');
|
||||
debugPrint('Message: ${e.message}');
|
||||
@@ -128,7 +119,9 @@ class DioClient {
|
||||
debugPrint('ResponseData: ${e.response?.data}');
|
||||
debugPrint('====================');
|
||||
|
||||
if (e.response?.statusCode == 401) {
|
||||
if (_isUnauthorized(e)) {
|
||||
_clearUserData();
|
||||
onUnauthorized?.call();
|
||||
return ApiResponse.unauthorized('登錄已過期,請重新登錄');
|
||||
}
|
||||
|
||||
@@ -136,19 +129,12 @@ class DioClient {
|
||||
return ApiResponse.fail(message);
|
||||
}
|
||||
|
||||
/// 徹底失效時清除數據並跳轉登錄頁(防重複調用)
|
||||
void forceLogout() {
|
||||
if (hasForceLoggedOut) return;
|
||||
hasForceLoggedOut = true;
|
||||
bool _isUnauthorized(DioException e) {
|
||||
return e.response?.statusCode == 401;
|
||||
}
|
||||
|
||||
void _clearUserData() {
|
||||
LocalStorage.clearUserData();
|
||||
onForceLogout?.call();
|
||||
final context = navigatorKey?.currentContext;
|
||||
if (context != null) {
|
||||
Navigator.of(context).pushNamedAndRemoveUntil(
|
||||
'/login',
|
||||
(route) => false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _getErrorMessage(DioException e) {
|
||||
@@ -175,187 +161,6 @@ class DioClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Token 自動刷新攔截器
|
||||
///
|
||||
/// 使用 QueuedInterceptor 確保多個並發 401/0002 只刷新一次。
|
||||
/// 核心流程:
|
||||
/// 1. onRequest — 注入 Authorization header
|
||||
/// 2. onResponse — 檢測業務層 code "0002"(後端返回 HTTP 200 + 0002)
|
||||
/// 3. onError — 檢測 HTTP 401
|
||||
/// 4. 觸發刷新時,用獨立 Dio 實例調用 refresh 接口,避免循環
|
||||
/// 5. 刷新成功 → 更新本地 token → 重試原始請求
|
||||
/// 6. 刷新失敗 → forceLogout(只有這裡才會真正跳轉登錄頁)
|
||||
class _TokenRefreshInterceptor extends QueuedInterceptor {
|
||||
final DioClient _client;
|
||||
bool _isRefreshing = false;
|
||||
|
||||
_TokenRefreshInterceptor(this._client);
|
||||
|
||||
@override
|
||||
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
||||
final token = LocalStorage.getToken();
|
||||
if (token?.isNotEmpty == true) {
|
||||
options.headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
handler.next(options);
|
||||
}
|
||||
|
||||
@override
|
||||
void onResponse(Response response, ResponseInterceptorHandler handler) {
|
||||
final data = response.data;
|
||||
if (data is Map<String, dynamic> && data['code'] == '0002') {
|
||||
final path = response.requestOptions.path;
|
||||
// 登錄、註冊等接口返回 0002 不觸發刷新,直接透傳
|
||||
if (_isAuthFreePath(path)) {
|
||||
handler.next(response);
|
||||
return;
|
||||
}
|
||||
_handleTokenExpired(response.requestOptions, handler);
|
||||
return;
|
||||
}
|
||||
handler.next(response);
|
||||
}
|
||||
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) {
|
||||
if (err.response?.statusCode == 401) {
|
||||
final path = err.requestOptions.path;
|
||||
if (_isAuthFreePath(path)) {
|
||||
handler.next(err);
|
||||
return;
|
||||
}
|
||||
// 將 401 轉為刷新流程(用 _wrapAsResponseHandler 統一處理)
|
||||
_handleTokenExpired(err.requestOptions, _ErrorAsResponseHandler(handler));
|
||||
return;
|
||||
}
|
||||
handler.next(err);
|
||||
}
|
||||
|
||||
/// 統一處理 token 過期:嘗試刷新 → 重試 / forceLogout
|
||||
Future<void> _handleTokenExpired(
|
||||
RequestOptions requestOptions,
|
||||
ResponseInterceptorHandler handler,
|
||||
) async {
|
||||
if (_isRefreshing) {
|
||||
// 正在刷新中,等刷新完成後用新 token 重試
|
||||
// QueuedInterceptor 保證此請求會在刷新完成後才被處理
|
||||
// 但此時 onRequest 已經執行過,需要手動更新 header
|
||||
final newToken = LocalStorage.getToken();
|
||||
if (newToken != null) {
|
||||
requestOptions.headers['Authorization'] = 'Bearer $newToken';
|
||||
}
|
||||
try {
|
||||
final retryResponse = await _client._dio.fetch(requestOptions);
|
||||
handler.resolve(retryResponse);
|
||||
} on DioException catch (e) {
|
||||
handler.reject(e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
_isRefreshing = true;
|
||||
|
||||
try {
|
||||
final newToken = await _refreshToken();
|
||||
if (newToken != null) {
|
||||
// 刷新成功,重置 forceLogout 標記(用戶重新激活)
|
||||
_client.hasForceLoggedOut = false;
|
||||
// 刷新成功,更新 header 並重試原始請求
|
||||
requestOptions.headers['Authorization'] = 'Bearer $newToken';
|
||||
final retryResponse = await _client._dio.fetch(requestOptions);
|
||||
handler.resolve(retryResponse);
|
||||
} else {
|
||||
// 刷新失敗(無 refreshToken 或後端未實現),forceLogout
|
||||
handler.next(Response(
|
||||
requestOptions: requestOptions,
|
||||
data: {'code': '0002', 'msg': 'Token 已過期,請重新登錄'},
|
||||
statusCode: 200,
|
||||
));
|
||||
_client.forceLogout();
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Token 刷新異常: $e');
|
||||
handler.next(Response(
|
||||
requestOptions: requestOptions,
|
||||
data: {'code': '0002', 'msg': 'Token 刷新失敗,請重新登錄'},
|
||||
statusCode: 200,
|
||||
));
|
||||
_client.forceLogout();
|
||||
} finally {
|
||||
_isRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 調用刷新接口,返回新 token;失敗返回 null
|
||||
Future<String?> _refreshToken() async {
|
||||
final refreshToken = LocalStorage.getRefreshToken();
|
||||
if (refreshToken == null || refreshToken.isEmpty) {
|
||||
debugPrint('無 refreshToken,無法刷新,需要重新登錄');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// 用獨立 Dio 實例,避免被攔截器循環攔截
|
||||
final refreshDio = Dio(BaseOptions(
|
||||
baseUrl: NetworkConfig.baseUrl,
|
||||
connectTimeout: NetworkConfig.connectTimeout,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
));
|
||||
|
||||
final response = await refreshDio.post(
|
||||
ApiEndpoints.tokenRefresh,
|
||||
data: {'refreshToken': refreshToken},
|
||||
);
|
||||
|
||||
final data = response.data;
|
||||
if (data is Map<String, dynamic> &&
|
||||
data['code'] == '0000' &&
|
||||
data['data'] != null) {
|
||||
final newToken = data['data']['token'] as String?;
|
||||
final newRefreshToken = data['data']['refreshToken'] as String?;
|
||||
if (newToken != null) {
|
||||
await LocalStorage.saveToken(newToken);
|
||||
if (newRefreshToken != null) {
|
||||
await LocalStorage.saveRefreshToken(newRefreshToken);
|
||||
}
|
||||
debugPrint('Token 刷新成功');
|
||||
return newToken;
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint('Token 刷新接口返回異常: $data');
|
||||
return null;
|
||||
} catch (e) {
|
||||
debugPrint('Token 刷新請求失敗: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 輔助類:將 ErrorInterceptorHandler 適配為 ResponseInterceptorHandler
|
||||
/// 用於 onError 中檢測到 401 後統一走 _handleTokenExpired 流程
|
||||
class _ErrorAsResponseHandler extends ResponseInterceptorHandler {
|
||||
final ErrorInterceptorHandler _errorHandler;
|
||||
|
||||
_ErrorAsResponseHandler(this._errorHandler);
|
||||
|
||||
@override
|
||||
void next(Response response) {
|
||||
// 刷新成功後 resolve 重試的 response
|
||||
_errorHandler.resolve(response);
|
||||
}
|
||||
|
||||
@override
|
||||
void resolve(Response response) {
|
||||
_errorHandler.resolve(response);
|
||||
}
|
||||
|
||||
@override
|
||||
void reject(DioException error, [bool callFollowingErrorInterceptor = false]) {
|
||||
_errorHandler.reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
/// 日誌攔截器
|
||||
class _LoggingInterceptor extends Interceptor {
|
||||
@override
|
||||
@@ -389,3 +194,23 @@ class _LoggingInterceptor extends Interceptor {
|
||||
super.onError(err, handler);
|
||||
}
|
||||
}
|
||||
|
||||
/// 認證攔截器
|
||||
class _AuthInterceptor extends Interceptor {
|
||||
@override
|
||||
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
||||
final token = LocalStorage.getToken();
|
||||
if (token?.isNotEmpty == true) {
|
||||
options.headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
super.onRequest(options, handler);
|
||||
}
|
||||
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) {
|
||||
if (err.response?.statusCode == 401) {
|
||||
LocalStorage.clearUserData();
|
||||
}
|
||||
super.onError(err, handler);
|
||||
}
|
||||
}
|
||||
|
||||
137
flutter_monisuo/lib/core/network/domain_navigator.dart
Normal file
137
flutter_monisuo/lib/core/network/domain_navigator.dart
Normal file
@@ -0,0 +1,137 @@
|
||||
import 'dart:convert';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../storage/local_storage.dart';
|
||||
|
||||
/// 域名导航器 - 生产环境竞速获取 baseUrl
|
||||
class DomainNavigator {
|
||||
DomainNavigator._();
|
||||
|
||||
// Cloudflare Workers 导航地址(竞速)
|
||||
static final List<String> _navEndpoints = [
|
||||
'https://cdn.jsdelivr.net/gh/wumanli/fortis-api@main/address.json?t=${DateTime.now().millisecondsSinceEpoch}',
|
||||
'https://raw.githubusercontent.com/wumanli/fortis-api/main/address.json'
|
||||
];
|
||||
|
||||
// Debug 环境固定地址
|
||||
static const String _debugUrl = 'http://localhost:5010';
|
||||
|
||||
static const String _cachedDomainKey = 'active_domain';
|
||||
|
||||
/// 当前生效的 baseUrl
|
||||
static String _activeUrl = '';
|
||||
|
||||
/// 获取当前 baseUrl(全局随时可读)
|
||||
static String get activeUrl {
|
||||
if (_activeUrl.isEmpty) {
|
||||
return kDebugMode ? _debugUrl : '';
|
||||
}
|
||||
return _activeUrl;
|
||||
}
|
||||
|
||||
/// 是否已初始化
|
||||
static bool get isInitialized => _activeUrl.isNotEmpty;
|
||||
|
||||
/// 初始化域名 — 必须在 main() 中 DioClient 创建前调用
|
||||
static Future<String> init() async {
|
||||
if (kDebugMode) {
|
||||
_activeUrl = _debugUrl;
|
||||
debugPrint('[DomainNavigator] Debug mode, use: $_activeUrl');
|
||||
return _activeUrl;
|
||||
}
|
||||
|
||||
// Release 模式:先尝试本地缓存
|
||||
final cached = LocalStorage.getString(_cachedDomainKey);
|
||||
if (cached != null && cached.isNotEmpty) {
|
||||
// 快速验证缓存域名是否可用
|
||||
final ok = await _quickCheck(cached);
|
||||
if (ok) {
|
||||
_activeUrl = cached;
|
||||
debugPrint('[DomainNavigator] Cache hit: $_activeUrl');
|
||||
return _activeUrl;
|
||||
}
|
||||
// 缓存验证失败但不删除,作为后续兜底
|
||||
debugPrint('[DomainNavigator] Cache check failed, keeping as fallback: $cached');
|
||||
}
|
||||
|
||||
// 竞速请求导航地址
|
||||
final resolved = await _raceResolve();
|
||||
_activeUrl = resolved;
|
||||
|
||||
// 缓存到本地
|
||||
await LocalStorage.setString(_cachedDomainKey, _activeUrl);
|
||||
|
||||
debugPrint('[DomainNavigator] Resolved: $_activeUrl');
|
||||
return _activeUrl;
|
||||
}
|
||||
|
||||
/// 竞速模式:同时请求多个导航地址,取最快返回
|
||||
static Future<String> _raceResolve() async {
|
||||
try {
|
||||
// 用 Completer 不需要 import,直接用 Future.any
|
||||
final result = await Future.any<String>(
|
||||
_navEndpoints.map((url) => _fetchNavUrl(url)).toList(),
|
||||
).timeout(const Duration(seconds: 5));
|
||||
|
||||
if (result.isNotEmpty) return result;
|
||||
} catch (_) {
|
||||
debugPrint('[DomainNavigator] Race resolve timeout or failed');
|
||||
}
|
||||
|
||||
// 所有导航失败,尝试使用上次缓存
|
||||
final cached = LocalStorage.getString(_cachedDomainKey);
|
||||
if (cached != null && cached.isNotEmpty) {
|
||||
debugPrint('[DomainNavigator] All nav failed, use cache: $cached');
|
||||
return cached;
|
||||
}
|
||||
|
||||
// 没有缓存,返回空字符串(应用会显示网络错误)
|
||||
debugPrint('[DomainNavigator] All nav failed, no cache available');
|
||||
return '';
|
||||
}
|
||||
|
||||
/// 从单个导航地址获取 baseUrl
|
||||
static Future<String> _fetchNavUrl(String navUrl) async {
|
||||
final dio = Dio(BaseOptions(
|
||||
connectTimeout: const Duration(seconds: 5),
|
||||
receiveTimeout: const Duration(seconds: 5),
|
||||
));
|
||||
|
||||
final response = await dio.get(navUrl);
|
||||
final data = response.data;
|
||||
|
||||
if (data is Map<String, dynamic> && data['baseUrl'] != null) {
|
||||
final url = data['baseUrl'] as String;
|
||||
if (url.startsWith('http')) return url;
|
||||
}
|
||||
|
||||
// 如果返回的是 JSON 字符串
|
||||
if (data is String) {
|
||||
final json = jsonDecode(data) as Map<String, dynamic>;
|
||||
if (json['baseUrl'] != null) {
|
||||
final url = json['baseUrl'] as String;
|
||||
if (url.startsWith('http')) return url;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 快速检查域名是否可达(3s 超时)
|
||||
/// 只要服务器有响应(包括 401/404)就说明域名可用
|
||||
static Future<bool> _quickCheck(String url) async {
|
||||
try {
|
||||
final dio = Dio(BaseOptions(
|
||||
connectTimeout: const Duration(seconds: 5),
|
||||
receiveTimeout: const Duration(seconds: 5),
|
||||
));
|
||||
await dio.get('$url/api/domain/active');
|
||||
return true;
|
||||
} on DioException catch (e) {
|
||||
// 401/404/403 都说明网络通了,域名可用
|
||||
if (e.response?.statusCode != null) return true;
|
||||
return false;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ class LocalStorage {
|
||||
LocalStorage._();
|
||||
|
||||
static const String _tokenKey = 'token';
|
||||
static const String _refreshTokenKey = 'refreshToken';
|
||||
static const String _userInfoKey = 'userInfo';
|
||||
|
||||
static SharedPreferences? _prefs;
|
||||
@@ -44,23 +43,6 @@ class LocalStorage {
|
||||
/// 是否已登錄
|
||||
static bool get isLoggedIn => getToken() != null && getToken()!.isNotEmpty;
|
||||
|
||||
// ==================== Refresh Token 管理 ====================
|
||||
|
||||
/// 保存 Refresh Token
|
||||
static Future<void> saveRefreshToken(String refreshToken) async {
|
||||
await prefs.setString(_refreshTokenKey, refreshToken);
|
||||
}
|
||||
|
||||
/// 獲取 Refresh Token
|
||||
static String? getRefreshToken() {
|
||||
return prefs.getString(_refreshTokenKey);
|
||||
}
|
||||
|
||||
/// 移除 Refresh Token
|
||||
static Future<void> removeRefreshToken() async {
|
||||
await prefs.remove(_refreshTokenKey);
|
||||
}
|
||||
|
||||
// ==================== 用戶信息管理 ====================
|
||||
|
||||
/// 保存用戶信息
|
||||
@@ -114,7 +96,6 @@ class LocalStorage {
|
||||
/// 清除用戶數據(退出登錄時調用)
|
||||
static Future<void> clearUserData() async {
|
||||
await removeToken();
|
||||
await removeRefreshToken();
|
||||
await removeUserInfo();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user