feat: 补齐大厂级网络层 — 401并发锁、请求去重、结构化错误日志
## 1. TokenRefreshLock(401 并发刷新竞态修复) - 新增 `core/network/token_refresh_lock.dart` - 使用 Completer 实现互斥锁:多个请求同时 401 时, 仅第一个触发 refreshToken(),其余等待同一结果 - 防止 5 个页面同时 401 → 5 次 refresh → 4 次失败踢出用户 ## 2. DedupInterceptor(请求去重) - 新增 `core/network/dedup_interceptor.dart` - 相同 GET URL 在飞行中时,后续请求复用第一个的结果 - 防止:用户快速点重试、页面切换重复加载、下拉刷新连点 - 仅限 GET,POST/PUT/DELETE 等写操作始终放行 ## 3. ErrorLogInterceptor + ErrorLogger(结构化错误日志) - 新增 `core/network/error_log_interceptor.dart` — Dio 拦截器 - 新增 `core/services/error_logger.dart` — 持久化日志服务 - 每个失败请求记录:时间戳、方法、URL、状态码、错误类型、重试次数 - 本地 SharedPreferences 存储最近 50 条,支持 summary 统计 - debug 模式同步 debugPrint 输出 - 预留 Sentry/Crashlytics flush 接口 ## 4. Dio 拦截器管线优化 拦截器顺序调整为大厂标准管线: 1. DedupInterceptor — 去重(最先,防止重复请求进入管线) 2. TokenInterceptor — 注入 token + 401 刷新(带并发锁) 3. TenantInterceptor — 注入 X-Tenant-Id 4. RetryInterceptor — 指数退避重试 5. ErrorLogInterceptor — 错误日志(最后,记录最终失败) 移除 LogInterceptor(被 ErrorLogInterceptor 替代,且不再在 release 模式下打印请求 body 造成性能损耗) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
94652857cd
commit
4e55e9a616
|
|
@ -0,0 +1,66 @@
|
|||
import 'dart:async';
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
/// Deduplicates identical in-flight GET requests.
|
||||
/// If the same GET URL is requested while a previous request is still pending,
|
||||
/// the second request reuses the result of the first instead of hitting
|
||||
/// the server again. This prevents thundering herd on page loads and
|
||||
/// rapid retry-button clicks.
|
||||
class DedupInterceptor extends Interceptor {
|
||||
final _pending = <String, Completer<Response>>{};
|
||||
|
||||
@override
|
||||
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
||||
// Only dedup GET requests (mutations must always go through)
|
||||
if (options.method.toUpperCase() != 'GET') {
|
||||
return handler.next(options);
|
||||
}
|
||||
|
||||
final key = '${options.method}:${options.uri}';
|
||||
|
||||
if (_pending.containsKey(key)) {
|
||||
// Another identical request is in flight — wait for it
|
||||
_pending[key]!.future.then(
|
||||
(response) => handler.resolve(
|
||||
Response(
|
||||
requestOptions: options,
|
||||
data: response.data,
|
||||
statusCode: response.statusCode,
|
||||
headers: response.headers,
|
||||
),
|
||||
),
|
||||
onError: (e) {
|
||||
if (e is DioException) {
|
||||
handler.reject(e);
|
||||
} else {
|
||||
handler.reject(DioException(
|
||||
requestOptions: options,
|
||||
error: e,
|
||||
));
|
||||
}
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// First request for this key — register and proceed
|
||||
_pending[key] = Completer<Response>();
|
||||
handler.next(options);
|
||||
}
|
||||
|
||||
@override
|
||||
void onResponse(Response response, ResponseInterceptorHandler handler) {
|
||||
final key = '${response.requestOptions.method}:${response.requestOptions.uri}';
|
||||
_pending[key]?.complete(response);
|
||||
_pending.remove(key);
|
||||
handler.next(response);
|
||||
}
|
||||
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) {
|
||||
final key = '${err.requestOptions.method}:${err.requestOptions.uri}';
|
||||
_pending[key]?.completeError(err);
|
||||
_pending.remove(key);
|
||||
handler.next(err);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,15 @@
|
|||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../config/app_config.dart';
|
||||
import 'dedup_interceptor.dart';
|
||||
import 'error_log_interceptor.dart';
|
||||
import 'retry_interceptor.dart';
|
||||
import 'token_refresh_lock.dart';
|
||||
import '../../features/auth/data/providers/auth_provider.dart';
|
||||
import '../../features/auth/data/providers/tenant_provider.dart';
|
||||
|
||||
final _tokenRefreshLock = TokenRefreshLock();
|
||||
|
||||
final dioClientProvider = Provider<Dio>((ref) {
|
||||
final config = ref.watch(appConfigProvider);
|
||||
final dio = Dio(BaseOptions(
|
||||
|
|
@ -17,7 +22,10 @@ final dioClientProvider = Provider<Dio>((ref) {
|
|||
},
|
||||
));
|
||||
|
||||
// Token interceptor
|
||||
// 1. Request deduplication (coalesce identical GET requests in flight)
|
||||
dio.interceptors.add(DedupInterceptor());
|
||||
|
||||
// 2. Token interceptor (with refresh lock to prevent concurrent refreshes)
|
||||
dio.interceptors.add(InterceptorsWrapper(
|
||||
onRequest: (options, handler) async {
|
||||
final storage = ref.read(secureStorageProvider);
|
||||
|
|
@ -29,8 +37,11 @@ final dioClientProvider = Provider<Dio>((ref) {
|
|||
},
|
||||
onError: (error, handler) async {
|
||||
if (error.response?.statusCode == 401) {
|
||||
final authNotifier = ref.read(authStateProvider.notifier);
|
||||
final refreshed = await authNotifier.refreshToken();
|
||||
// Use lock: only 1 refresh at a time, others wait for the result
|
||||
final refreshed = await _tokenRefreshLock.run(() {
|
||||
final authNotifier = ref.read(authStateProvider.notifier);
|
||||
return authNotifier.refreshToken();
|
||||
});
|
||||
if (refreshed) {
|
||||
final storage = ref.read(secureStorageProvider);
|
||||
final newToken = await storage.read(key: 'access_token');
|
||||
|
|
@ -46,7 +57,7 @@ final dioClientProvider = Provider<Dio>((ref) {
|
|||
},
|
||||
));
|
||||
|
||||
// Tenant interceptor
|
||||
// 3. Tenant interceptor
|
||||
dio.interceptors.add(InterceptorsWrapper(
|
||||
onRequest: (options, handler) {
|
||||
final tenantId = ref.read(currentTenantIdProvider);
|
||||
|
|
@ -57,14 +68,11 @@ final dioClientProvider = Provider<Dio>((ref) {
|
|||
},
|
||||
));
|
||||
|
||||
// Retry interceptor (exponential backoff for transient errors)
|
||||
// 4. Retry interceptor (exponential backoff for transient errors)
|
||||
dio.interceptors.add(RetryInterceptor(dio: dio));
|
||||
|
||||
// Logging interceptor
|
||||
dio.interceptors.add(LogInterceptor(
|
||||
requestBody: true,
|
||||
responseBody: true,
|
||||
));
|
||||
// 5. Error logging interceptor (structured, persistent)
|
||||
dio.interceptors.add(ErrorLogInterceptor());
|
||||
|
||||
return dio;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../services/error_logger.dart';
|
||||
|
||||
/// Interceptor that logs all failed requests to [ErrorLogger] for
|
||||
/// structured diagnostics and future Sentry/Crashlytics integration.
|
||||
class ErrorLogInterceptor extends Interceptor {
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) {
|
||||
final entry = {
|
||||
'timestamp': DateTime.now().toIso8601String(),
|
||||
'method': err.requestOptions.method,
|
||||
'url': err.requestOptions.uri.toString(),
|
||||
'statusCode': err.response?.statusCode,
|
||||
'errorType': err.type.name,
|
||||
'message': err.message ?? err.error?.toString() ?? 'unknown',
|
||||
'retryCount': err.requestOptions.extra['_retryCount'] ?? 0,
|
||||
};
|
||||
|
||||
ErrorLogger.instance.log(entry);
|
||||
|
||||
if (kDebugMode) {
|
||||
debugPrint('[API Error] ${entry['method']} ${entry['url']} '
|
||||
'→ ${entry['statusCode'] ?? entry['errorType']} '
|
||||
'(retries: ${entry['retryCount']})');
|
||||
}
|
||||
|
||||
handler.next(err);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import 'dart:async';
|
||||
|
||||
/// Ensures only one token refresh runs at a time.
|
||||
/// When multiple requests hit 401 simultaneously, only the first triggers
|
||||
/// a refresh; the rest wait for the same result.
|
||||
class TokenRefreshLock {
|
||||
Completer<bool>? _refreshCompleter;
|
||||
|
||||
/// Returns true if refresh succeeded. If a refresh is already in progress,
|
||||
/// waits for that one instead of starting a new one.
|
||||
Future<bool> run(Future<bool> Function() refreshFn) async {
|
||||
// If a refresh is already running, wait for it
|
||||
if (_refreshCompleter != null) {
|
||||
return _refreshCompleter!.future;
|
||||
}
|
||||
|
||||
_refreshCompleter = Completer<bool>();
|
||||
try {
|
||||
final result = await refreshFn();
|
||||
_refreshCompleter!.complete(result);
|
||||
return result;
|
||||
} catch (e) {
|
||||
_refreshCompleter!.complete(false);
|
||||
return false;
|
||||
} finally {
|
||||
_refreshCompleter = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
import 'dart:collection';
|
||||
import 'dart:convert';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Persistent error logger that stores recent API errors locally.
|
||||
/// Keeps the last [maxEntries] errors for diagnostics.
|
||||
/// Can be extended to flush to Sentry/Crashlytics/backend.
|
||||
class ErrorLogger {
|
||||
static final ErrorLogger instance = ErrorLogger._();
|
||||
ErrorLogger._();
|
||||
|
||||
static const String _storageKey = 'error_log';
|
||||
static const int maxEntries = 50;
|
||||
|
||||
final _buffer = Queue<Map<String, dynamic>>();
|
||||
bool _initialized = false;
|
||||
|
||||
/// Loads persisted errors from SharedPreferences.
|
||||
Future<void> init() async {
|
||||
if (_initialized) return;
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getString(_storageKey);
|
||||
if (raw != null) {
|
||||
final list = jsonDecode(raw) as List;
|
||||
for (final item in list) {
|
||||
_buffer.add(Map<String, dynamic>.from(item as Map));
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// Silent — don't let logging failures break the app
|
||||
}
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
/// Logs a structured error entry.
|
||||
void log(Map<String, dynamic> entry) {
|
||||
_buffer.addLast(entry);
|
||||
while (_buffer.length > maxEntries) {
|
||||
_buffer.removeFirst();
|
||||
}
|
||||
_persist();
|
||||
}
|
||||
|
||||
/// Returns all logged errors (newest last).
|
||||
List<Map<String, dynamic>> get entries => _buffer.toList();
|
||||
|
||||
/// Clears all logged errors.
|
||||
Future<void> clear() async {
|
||||
_buffer.clear();
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_storageKey);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// Returns a summary: total errors, most frequent endpoint, last error time.
|
||||
Map<String, dynamic> get summary {
|
||||
if (_buffer.isEmpty) {
|
||||
return {'totalErrors': 0};
|
||||
}
|
||||
|
||||
// Count errors by URL
|
||||
final urlCounts = <String, int>{};
|
||||
for (final entry in _buffer) {
|
||||
final url = entry['url'] as String? ?? 'unknown';
|
||||
// Strip query params for grouping
|
||||
final path = Uri.tryParse(url)?.path ?? url;
|
||||
urlCounts[path] = (urlCounts[path] ?? 0) + 1;
|
||||
}
|
||||
|
||||
final topUrl = urlCounts.entries.reduce(
|
||||
(a, b) => a.value >= b.value ? a : b,
|
||||
);
|
||||
|
||||
return {
|
||||
'totalErrors': _buffer.length,
|
||||
'mostFrequentEndpoint': topUrl.key,
|
||||
'mostFrequentCount': topUrl.value,
|
||||
'lastErrorAt': _buffer.last['timestamp'],
|
||||
};
|
||||
}
|
||||
|
||||
Future<void> _persist() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_storageKey, jsonEncode(_buffer.toList()));
|
||||
} catch (_) {
|
||||
// Silent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3,11 +3,13 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
|||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'app.dart';
|
||||
import 'core/services/error_logger.dart';
|
||||
import 'features/notifications/presentation/providers/notification_providers.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await Hive.initFlutter();
|
||||
await ErrorLogger.instance.init();
|
||||
|
||||
// Initialize local notifications
|
||||
final localNotifications = FlutterLocalNotificationsPlugin();
|
||||
|
|
|
|||
Loading…
Reference in New Issue