This commit is contained in:
sion
2026-04-18 10:02:47 +08:00
parent a487302946
commit 0066615054
5256 changed files with 262726 additions and 224532 deletions

View File

@@ -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';
// ==================== 行情模塊 ====================
/// 獲取幣種列表

View File

@@ -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);
}
}

View 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;
}
}
}

View File

@@ -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();
}