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:
hailin 2026-02-23 04:05:53 -08:00
parent 94652857cd
commit 4e55e9a616
6 changed files with 237 additions and 10 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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