feat(client): 网络请求重试 + 离线提示可感知重试按钮
修复①请求级重试:新增 RetryInterceptor,网络层失败重试 3 次间隔递增 1s→2s→4s;GET 全重试,写操作仅在连接未建立时重试以防重复提交。 connectTimeout 5s→8s。 修复②连通性:启动首检改退避重试 4s→8s→12s,根治跨境冷启动假离线; 新增检测中状态 + retry();登录页/全局离线提示加 NetworkRetryButton, 重试中转圈、结果 SnackBar 反馈。 121 个测试通过,analyze 无 error。todo #52。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -5,13 +5,20 @@ import '../auth/auth_state.dart';
|
||||
import '../config/app_config.dart';
|
||||
import '../errors/error_reporter.dart';
|
||||
import '../../providers/connectivity_provider.dart';
|
||||
import 'retry_interceptor.dart';
|
||||
|
||||
/// Public Dio instance for unauthenticated calls (login / refresh)
|
||||
final _publicDio = Dio(BaseOptions(
|
||||
baseUrl: AppConfig.apiBaseUrl,
|
||||
connectTimeout: const Duration(seconds: 5),
|
||||
receiveTimeout: const Duration(seconds: 15),
|
||||
));
|
||||
final _publicDio = _buildPublicDio();
|
||||
|
||||
Dio _buildPublicDio() {
|
||||
final dio = Dio(BaseOptions(
|
||||
baseUrl: AppConfig.apiBaseUrl,
|
||||
connectTimeout: const Duration(seconds: 8),
|
||||
receiveTimeout: const Duration(seconds: 15),
|
||||
));
|
||||
dio.interceptors.add(RetryInterceptor(dio));
|
||||
return dio;
|
||||
}
|
||||
|
||||
final apiClientProvider = Provider<ApiClient>((ref) {
|
||||
// 只监听登录/登出,不监听 token 内容变化。
|
||||
@@ -52,13 +59,16 @@ class ApiClient {
|
||||
}) {
|
||||
_dio = Dio(BaseOptions(
|
||||
baseUrl: AppConfig.apiBaseUrl,
|
||||
connectTimeout: const Duration(seconds: 5),
|
||||
connectTimeout: const Duration(seconds: 8),
|
||||
receiveTimeout: const Duration(seconds: 15),
|
||||
headers: {
|
||||
if (token != null) 'Authorization': 'Bearer $token',
|
||||
},
|
||||
));
|
||||
|
||||
// 网络层错误自动重试(须在错误处理拦截器之前,先重试再走 401/上报逻辑)
|
||||
_dio.interceptors.add(RetryInterceptor(_dio));
|
||||
|
||||
// 网络错误 + 401 拦截器
|
||||
_dio.interceptors.add(
|
||||
InterceptorsWrapper(
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
/// 网络层错误自动重试拦截器。
|
||||
///
|
||||
/// 仅对「网络层」失败(连接超时 / 连接失败 / 读写超时)重试,**不**对带响应的
|
||||
/// HTTP 错误(4xx/5xx 业务错误)重试。重试 3 次,间隔递增(1s → 2s → 4s)。
|
||||
///
|
||||
/// 安全策略(避免重复入库/出库等副作用):
|
||||
/// - GET:幂等,所有网络错误都重试。
|
||||
/// - POST/PUT/PATCH/DELETE:仅在「连接尚未建立」(connectTimeout / connectionError,
|
||||
/// 服务器还没收到请求)时重试;receiveTimeout/sendTimeout 时服务器可能已处理,
|
||||
/// 不重试,避免重复提交。
|
||||
class RetryInterceptor extends Interceptor {
|
||||
final Dio dio;
|
||||
final List<Duration> delays;
|
||||
|
||||
RetryInterceptor(
|
||||
this.dio, {
|
||||
this.delays = const [
|
||||
Duration(seconds: 1),
|
||||
Duration(seconds: 2),
|
||||
Duration(seconds: 4),
|
||||
],
|
||||
});
|
||||
|
||||
static const _attemptKey = 'retry_attempt';
|
||||
|
||||
bool _retriable(DioException e) {
|
||||
final method = (e.requestOptions.method).toUpperCase();
|
||||
final isIdempotent = method == 'GET' || method == 'HEAD';
|
||||
switch (e.type) {
|
||||
case DioExceptionType.connectionTimeout:
|
||||
case DioExceptionType.connectionError:
|
||||
// 连接未建立 → 服务器未收到 → 任何方法都可安全重试
|
||||
return true;
|
||||
case DioExceptionType.receiveTimeout:
|
||||
case DioExceptionType.sendTimeout:
|
||||
// 服务器可能已处理 → 仅幂等方法重试
|
||||
return isIdempotent;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) async {
|
||||
final attempt = (err.requestOptions.extra[_attemptKey] as int?) ?? 0;
|
||||
|
||||
if (_retriable(err) && attempt < delays.length) {
|
||||
await Future.delayed(delays[attempt]);
|
||||
err.requestOptions.extra[_attemptKey] = attempt + 1;
|
||||
try {
|
||||
final resp = await dio.fetch(err.requestOptions);
|
||||
return handler.resolve(resp);
|
||||
} on DioException catch (e) {
|
||||
return handler.next(e);
|
||||
} catch (_) {
|
||||
return handler.next(err);
|
||||
}
|
||||
}
|
||||
return handler.next(err);
|
||||
}
|
||||
}
|
||||
@@ -7,34 +7,55 @@ import '../core/config/app_config.dart';
|
||||
/// 数据 provider 通过 watch 此值实现网络恢复后自动刷新。
|
||||
final networkRecoveryCountProvider = StateProvider<int>((ref) => 0);
|
||||
|
||||
/// 是否正在进行一次「带重试的连通性检测」(供 UI 显示「重试中…」转圈)。
|
||||
final connectivityCheckingProvider = StateProvider<bool>((ref) => false);
|
||||
|
||||
final connectivityProvider =
|
||||
StateNotifierProvider<ConnectivityNotifier, bool>((ref) {
|
||||
return ConnectivityNotifier(
|
||||
onRecovered: () {
|
||||
ref.read(networkRecoveryCountProvider.notifier).update((s) => s + 1);
|
||||
},
|
||||
onCheckingChanged: (checking) {
|
||||
ref.read(connectivityCheckingProvider.notifier).state = checking;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
class ConnectivityNotifier extends StateNotifier<bool> {
|
||||
final void Function()? onRecovered;
|
||||
final void Function(bool checking)? onCheckingChanged;
|
||||
|
||||
ConnectivityNotifier({this.onRecovered, bool skipInit = false}) : super(true) {
|
||||
ConnectivityNotifier({
|
||||
this.onRecovered,
|
||||
this.onCheckingChanged,
|
||||
bool skipInit = false,
|
||||
}) : super(true) {
|
||||
if (!skipInit) {
|
||||
_check();
|
||||
// 启动首检走「带重试」:跨境冷启动 DNS+TLS 握手较慢,单次 4s 易误判离线,
|
||||
// 退避重试 4s→8s→12s 可显著降低首屏假离线。
|
||||
_check(withRetry: true);
|
||||
_startOnlineTimer();
|
||||
}
|
||||
}
|
||||
|
||||
Timer? _timer;
|
||||
|
||||
// 独立轻量 Dio:短超时,无拦截器
|
||||
// 独立轻量 Dio:无拦截器。连接预算用 Future.timeout 逐次控制,
|
||||
// 故 BaseOptions 超时放宽到 15s 作为兜底。
|
||||
final _dio = Dio(BaseOptions(
|
||||
connectTimeout: const Duration(seconds: 3),
|
||||
receiveTimeout: const Duration(seconds: 3),
|
||||
connectTimeout: const Duration(seconds: 15),
|
||||
receiveTimeout: const Duration(seconds: 15),
|
||||
));
|
||||
|
||||
/// 在线时:每 30 秒检测一次
|
||||
/// 每次尝试的超时预算,逐次拉长(越来越长)。
|
||||
static const _budgets = <Duration>[
|
||||
Duration(seconds: 4),
|
||||
Duration(seconds: 8),
|
||||
Duration(seconds: 12),
|
||||
];
|
||||
|
||||
/// 在线时:每 30 秒轻量检测一次(单次,不重试)
|
||||
void _startOnlineTimer() {
|
||||
_timer?.cancel();
|
||||
_timer = Timer.periodic(const Duration(seconds: 30), (_) => _check());
|
||||
@@ -46,25 +67,57 @@ class ConnectivityNotifier extends StateNotifier<bool> {
|
||||
_timer = Timer.periodic(const Duration(minutes: 1), (_) => _check());
|
||||
}
|
||||
|
||||
/// 立即触发一次检测(供外部调用,如 API 请求失败 / 启动时)
|
||||
/// 立即触发一次检测(供外部调用,如 API 请求失败 / 启动时)。单次,不重试。
|
||||
Future<void> forceCheck() => _check();
|
||||
|
||||
Future<void> _check() async {
|
||||
/// 用户手动重试:带退避重试,并对外广播「检测中」状态,返回最终是否在线。
|
||||
Future<bool> retry() async {
|
||||
onCheckingChanged?.call(true);
|
||||
try {
|
||||
await _dio.get(AppConfig.healthUrl);
|
||||
return await _check(withRetry: true);
|
||||
} finally {
|
||||
onCheckingChanged?.call(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// 单次 ping /health,[budget] 为本次超时预算。
|
||||
Future<bool> _ping(Duration budget) async {
|
||||
try {
|
||||
await _dio.get(AppConfig.healthUrl).timeout(budget);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 连通性检测。[withRetry]=true 时按 [_budgets] 退避重试,间隔递增。
|
||||
Future<bool> _check({bool withRetry = false}) async {
|
||||
final budgets = withRetry ? _budgets : const [Duration(seconds: 4)];
|
||||
var ok = false;
|
||||
for (var i = 0; i < budgets.length; i++) {
|
||||
ok = await _ping(budgets[i]);
|
||||
if (ok) break;
|
||||
// 最后一次失败后不再等待;中间失败按递增间隔退避(0.6s, 1.2s…)
|
||||
if (i < budgets.length - 1) {
|
||||
await Future.delayed(Duration(milliseconds: 600 * (i + 1)));
|
||||
}
|
||||
}
|
||||
|
||||
if (ok) {
|
||||
if (!state) {
|
||||
// 离线 → 在线:切回高频检测,广播恢复事件
|
||||
state = true;
|
||||
onRecovered?.call();
|
||||
_startOnlineTimer();
|
||||
}
|
||||
} catch (_) {
|
||||
} else {
|
||||
if (state) {
|
||||
// 在线 → 离线:切换为低频嗅探
|
||||
state = false;
|
||||
_startOfflineTimer();
|
||||
}
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -11,6 +11,7 @@ import '../../core/theme/app_theme.dart';
|
||||
import '../../core/storage/login_history.dart';
|
||||
import '../../providers/connectivity_provider.dart';
|
||||
import '../../repositories/auth_repository.dart';
|
||||
import '../../widgets/network_retry_button.dart';
|
||||
|
||||
class LoginScreen extends ConsumerStatefulWidget {
|
||||
const LoginScreen({super.key});
|
||||
@@ -426,6 +427,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
fontSize: 13),
|
||||
),
|
||||
),
|
||||
NetworkRetryButton(
|
||||
foreground: Color(0xFF5D4037)),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -14,6 +14,7 @@ import '../../providers/shop_provider.dart';
|
||||
import '../../providers/update_provider.dart';
|
||||
import '../../core/update/app_updater.dart';
|
||||
import '../../providers/license_provider.dart';
|
||||
import '../../widgets/network_retry_button.dart';
|
||||
|
||||
class AppShell extends ConsumerStatefulWidget {
|
||||
final Widget child;
|
||||
@@ -382,13 +383,16 @@ class _AppShellState extends ConsumerState<AppShell> {
|
||||
Icon(Icons.wifi_off,
|
||||
size: 16, color: Colors.white),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'网络连接已断开 · 当前显示离线缓存数据,恢复后将自动刷新',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'网络连接已断开 · 当前显示离线缓存数据,恢复后将自动刷新',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
NetworkRetryButton(),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../core/theme/app_theme.dart';
|
||||
import '../providers/connectivity_provider.dart';
|
||||
|
||||
/// 离线提示里的「重试」按钮:
|
||||
/// - 检测中显示转圈 +「重试中…」(过程可感知)
|
||||
/// - 重试结束用 SnackBar 反馈成功/失败(结果可感知)
|
||||
///
|
||||
/// [foreground] 适配不同底色(红色横幅用白字,琥珀框用深色)。
|
||||
class NetworkRetryButton extends ConsumerWidget {
|
||||
final Color foreground;
|
||||
const NetworkRetryButton({super.key, this.foreground = Colors.white});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final checking = ref.watch(connectivityCheckingProvider);
|
||||
|
||||
return TextButton(
|
||||
onPressed: checking
|
||||
? null
|
||||
: () async {
|
||||
final ok =
|
||||
await ref.read(connectivityProvider.notifier).retry();
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(SnackBar(
|
||||
content: Text(ok ? '已重新连接服务器' : '仍无法连接服务器,请稍后重试'),
|
||||
backgroundColor: ok ? AppTheme.success : AppTheme.danger,
|
||||
duration: const Duration(seconds: 2),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
));
|
||||
},
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: foreground,
|
||||
minimumSize: const Size(0, 32),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (checking)
|
||||
SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(foreground),
|
||||
),
|
||||
)
|
||||
else
|
||||
Icon(Icons.refresh, size: 16, color: foreground),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
checking ? '重试中…' : '重试',
|
||||
style: TextStyle(
|
||||
color: foreground, fontSize: 13, fontWeight: FontWeight.w600),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user