feat(client): 慢请求监控(>500ms 记日志+上报);拆除列表 provider 的 _cache 失败兜底
- SlowRequestInterceptor:单次 API 调用超 500ms 即 debugPrint long-request 日志并经 ErrorReporter 上报(error_type=slow_api,按 method+归一路径 5 分钟 节流)。服务端 GIN 日志只见自身处理耗时,网络往返段只有客户端可观测。 - 拆除 10 个列表 provider 的 _cache 失败兜底:实测线上接口客户端视角典型 50-120ms、最坏约 270ms(列表类全部 <500ms),失败静默端旧数据弊大于利 (曾放大跨账号残留问题),改为明确进入错误态由用户重试。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@ import '../errors/error_reporter.dart';
|
||||
import '../../providers/connectivity_provider.dart';
|
||||
import '../../providers/license_provider.dart';
|
||||
import 'retry_interceptor.dart';
|
||||
import 'slow_request_interceptor.dart';
|
||||
|
||||
/// Public Dio instance for unauthenticated calls (login / refresh)
|
||||
final _publicDio = _buildPublicDio();
|
||||
@@ -19,6 +20,7 @@ Dio _buildPublicDio() {
|
||||
connectTimeout: AppConstants.publicConnectTimeout,
|
||||
receiveTimeout: AppConstants.publicReceiveTimeout,
|
||||
));
|
||||
dio.interceptors.add(SlowRequestInterceptor());
|
||||
dio.interceptors.add(RetryInterceptor(dio));
|
||||
return dio;
|
||||
}
|
||||
@@ -110,6 +112,9 @@ class ApiClient {
|
||||
},
|
||||
));
|
||||
|
||||
// 慢请求监控(最外层,量到的是用户感知耗时)
|
||||
_dio.interceptors.add(SlowRequestInterceptor());
|
||||
|
||||
// 网络层错误自动重试(须在错误处理拦截器之前,先重试再走 401/上报逻辑)
|
||||
_dio.interceptors.add(RetryInterceptor(_dio));
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../errors/error_reporter.dart';
|
||||
|
||||
/// 慢请求监控:单次 API 调用从发起到收到响应超过阈值(500ms)即记 long-request
|
||||
/// 日志(debugPrint)并上报服务端(error_type=slow_api)。
|
||||
///
|
||||
/// 服务端 GIN 日志只能看到自身处理耗时,网络往返这一段(弱网用户 / 网关劣化)
|
||||
/// 只有客户端能观测,故上报补齐用户感知视角。按 method+归一路径 5 分钟节流,
|
||||
/// 弱网下不至于刷量;上报本身走 ErrorReporter,fire-and-forget 失败静默。
|
||||
class SlowRequestInterceptor extends Interceptor {
|
||||
static const _threshold = Duration(milliseconds: 500);
|
||||
static const _reportInterval = Duration(minutes: 5);
|
||||
static const _startKey = '_slowReqStart';
|
||||
|
||||
/// 归一路径键 → 上次上报时间(节流窗口内只报一次)
|
||||
final Map<String, DateTime> _lastReported = {};
|
||||
|
||||
@override
|
||||
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
||||
options.extra[_startKey] = DateTime.now();
|
||||
handler.next(options);
|
||||
}
|
||||
|
||||
@override
|
||||
void onResponse(Response response, ResponseInterceptorHandler handler) {
|
||||
_check(response.requestOptions, response.statusCode);
|
||||
handler.next(response);
|
||||
}
|
||||
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) {
|
||||
_check(err.requestOptions, err.response?.statusCode);
|
||||
handler.next(err);
|
||||
}
|
||||
|
||||
void _check(RequestOptions options, int? status) {
|
||||
final start = options.extra[_startKey];
|
||||
if (start is! DateTime) return;
|
||||
final elapsed = DateTime.now().difference(start);
|
||||
if (elapsed < _threshold) return;
|
||||
// 含数字的路径段(id / 单号 / 店铺码)归一为 :id,同类慢请求共用节流键
|
||||
final path =
|
||||
options.path.replaceAll(RegExp(r'/[^/]*\d[^/]*'), '/:id');
|
||||
final key = '${options.method} $path';
|
||||
final msg = '[SlowAPI] $key ${elapsed.inMilliseconds}ms status=$status';
|
||||
debugPrint(msg);
|
||||
final last = _lastReported[key];
|
||||
final now = DateTime.now();
|
||||
if (last != null && now.difference(last) < _reportInterval) return;
|
||||
_lastReported[key] = now;
|
||||
ErrorReporter.instance.report(errorType: ErrorType.slowApi, errorMsg: msg);
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ class ErrorType {
|
||||
static const zoneError = 'zone_error';
|
||||
static const caughtException = 'caught_exception';
|
||||
static const apiError = 'api_error';
|
||||
static const slowApi = 'slow_api'; // 客户端感知的慢请求(>500ms),见 SlowRequestInterceptor
|
||||
}
|
||||
|
||||
class ErrorReporter {
|
||||
|
||||
@@ -35,20 +35,12 @@ class InventoryListNotifier extends AsyncNotifier<PageResult<Inventory>> {
|
||||
bool _sortAsc = true;
|
||||
List<String> _series = [];
|
||||
List<String> _spec = [];
|
||||
PageResult<Inventory>? _cache;
|
||||
|
||||
@override
|
||||
Future<PageResult<Inventory>> build() async {
|
||||
ref.watch(authStateProvider.select((s) => s.user?.shopId));
|
||||
ref.watch(networkRecoveryCountProvider); // 网络恢复时自动刷新
|
||||
try {
|
||||
final result = await _fetch();
|
||||
_cache = result;
|
||||
return result;
|
||||
} catch (_) {
|
||||
if (_cache != null) return _cache!;
|
||||
rethrow;
|
||||
}
|
||||
return _fetch();
|
||||
}
|
||||
|
||||
Future<PageResult<Inventory>> _fetch() {
|
||||
@@ -125,14 +117,9 @@ class InventoryListNotifier extends AsyncNotifier<PageResult<Inventory>> {
|
||||
state = const AsyncValue<PageResult<Inventory>>.loading()
|
||||
.copyWithPrevious(state);
|
||||
_fetch().then((result) {
|
||||
_cache = result;
|
||||
state = AsyncValue.data(result);
|
||||
}, onError: (e, st) {
|
||||
if (_cache != null) {
|
||||
state = AsyncValue.data(_cache!);
|
||||
} else {
|
||||
state = AsyncValue.error(e, st);
|
||||
}
|
||||
state = AsyncValue.error(e, st);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -145,20 +132,12 @@ final inventoryLogProvider =
|
||||
class InventoryLogNotifier extends AsyncNotifier<PageResult<InventoryLog>> {
|
||||
int _page = 1;
|
||||
int _pageSize = AppConstants.defaultPageSize;
|
||||
PageResult<InventoryLog>? _cache;
|
||||
|
||||
@override
|
||||
Future<PageResult<InventoryLog>> build() async {
|
||||
ref.watch(authStateProvider.select((s) => s.user?.shopId));
|
||||
ref.watch(networkRecoveryCountProvider);
|
||||
try {
|
||||
final result = await _fetch();
|
||||
_cache = result;
|
||||
return result;
|
||||
} catch (_) {
|
||||
if (_cache != null) return _cache!;
|
||||
rethrow;
|
||||
}
|
||||
return _fetch();
|
||||
}
|
||||
|
||||
Future<PageResult<InventoryLog>> _fetch() {
|
||||
@@ -182,14 +161,9 @@ class InventoryLogNotifier extends AsyncNotifier<PageResult<InventoryLog>> {
|
||||
void reload() {
|
||||
state = const AsyncValue.loading();
|
||||
_fetch().then((result) {
|
||||
_cache = result;
|
||||
state = AsyncValue.data(result);
|
||||
}, onError: (e, st) {
|
||||
if (_cache != null) {
|
||||
state = AsyncValue.data(_cache!);
|
||||
} else {
|
||||
state = AsyncValue.error(e, st);
|
||||
}
|
||||
state = AsyncValue.error(e, st);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,19 +17,11 @@ class PurchaseListNotifier extends AsyncNotifier<PurchaseListResult> {
|
||||
int _page = 1;
|
||||
int _pageSize = 10;
|
||||
String _status = ''; // '' | pending | paid | failed
|
||||
PurchaseListResult? _cache;
|
||||
|
||||
@override
|
||||
Future<PurchaseListResult> build() async {
|
||||
ref.watch(authStateProvider.select((s) => s.user?.shopId));
|
||||
try {
|
||||
final result = await _fetch();
|
||||
_cache = result;
|
||||
return result;
|
||||
} catch (_) {
|
||||
if (_cache != null) return _cache!;
|
||||
rethrow;
|
||||
}
|
||||
return _fetch();
|
||||
}
|
||||
|
||||
Future<PurchaseListResult> _fetch() {
|
||||
@@ -46,17 +38,12 @@ class PurchaseListNotifier extends AsyncNotifier<PurchaseListResult> {
|
||||
|
||||
void reload() {
|
||||
// 保留上一次数据(isReloading)→ 屏幕可继续渲染旧内容 + 叠加半透明进度,不白屏。
|
||||
state = const AsyncValue<PurchaseListResult>.loading()
|
||||
.copyWithPrevious(state);
|
||||
state =
|
||||
const AsyncValue<PurchaseListResult>.loading().copyWithPrevious(state);
|
||||
_fetch().then((result) {
|
||||
_cache = result;
|
||||
state = AsyncValue.data(result);
|
||||
}, onError: (Object e, StackTrace st) {
|
||||
if (_cache != null) {
|
||||
state = AsyncValue.data(_cache!);
|
||||
} else {
|
||||
state = AsyncValue.error(e, st);
|
||||
}
|
||||
state = AsyncValue.error(e, st);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -14,33 +14,19 @@ final numberRuleListProvider =
|
||||
);
|
||||
|
||||
class NumberRuleListNotifier extends AsyncNotifier<List<NumberRule>> {
|
||||
List<NumberRule> _cache = [];
|
||||
|
||||
@override
|
||||
Future<List<NumberRule>> build() async {
|
||||
ref.watch(authStateProvider.select((s) => s.user?.shopId));
|
||||
try {
|
||||
final result = await ref.read(numberRuleRepositoryProvider).list();
|
||||
_cache = result;
|
||||
return result;
|
||||
} catch (_) {
|
||||
if (_cache.isNotEmpty) return _cache;
|
||||
rethrow;
|
||||
}
|
||||
return ref.read(numberRuleRepositoryProvider).list();
|
||||
}
|
||||
|
||||
Future<void> reload() async {
|
||||
state = const AsyncValue.loading();
|
||||
try {
|
||||
final result = await ref.read(numberRuleRepositoryProvider).list();
|
||||
_cache = result;
|
||||
state = AsyncValue.data(result);
|
||||
} catch (e, st) {
|
||||
if (_cache.isNotEmpty) {
|
||||
state = AsyncValue.data(_cache);
|
||||
} else {
|
||||
state = AsyncValue.error(e, st);
|
||||
}
|
||||
state = AsyncValue.error(e, st);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -72,7 +72,6 @@ class PartnerListNotifier extends AsyncNotifier<PageResult<Partner>> {
|
||||
int _page = 1;
|
||||
int _pageSize;
|
||||
String _keyword = '';
|
||||
PageResult<Partner>? _cache;
|
||||
|
||||
PartnerListNotifier({this.type, int pageSize = AppConstants.defaultPageSize})
|
||||
: _pageSize = pageSize;
|
||||
@@ -81,14 +80,7 @@ class PartnerListNotifier extends AsyncNotifier<PageResult<Partner>> {
|
||||
Future<PageResult<Partner>> build() async {
|
||||
ref.watch(authStateProvider.select((s) => s.user?.shopId));
|
||||
ref.watch(networkRecoveryCountProvider);
|
||||
try {
|
||||
final result = await _fetch();
|
||||
_cache = result;
|
||||
return result;
|
||||
} catch (_) {
|
||||
if (_cache != null) return _cache!;
|
||||
rethrow;
|
||||
}
|
||||
return _fetch();
|
||||
}
|
||||
|
||||
Future<PageResult<Partner>> _fetch() {
|
||||
@@ -129,14 +121,9 @@ class PartnerListNotifier extends AsyncNotifier<PageResult<Partner>> {
|
||||
void reload() {
|
||||
state = const AsyncValue.loading();
|
||||
_fetch().then((result) {
|
||||
_cache = result;
|
||||
state = AsyncValue.data(result);
|
||||
}, onError: (e, st) {
|
||||
if (_cache != null) {
|
||||
state = AsyncValue.data(_cache!);
|
||||
} else {
|
||||
state = AsyncValue.error(e, st);
|
||||
}
|
||||
state = AsyncValue.error(e, st);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -21,20 +21,12 @@ class ProductListNotifier extends AsyncNotifier<PageResult<Product>> {
|
||||
int _pageSize = AppConstants.defaultPageSize;
|
||||
String _keyword = '';
|
||||
int? _categoryId;
|
||||
PageResult<Product>? _cache;
|
||||
|
||||
@override
|
||||
Future<PageResult<Product>> build() async {
|
||||
ref.watch(authStateProvider.select((s) => s.user?.shopId));
|
||||
ref.watch(networkRecoveryCountProvider);
|
||||
try {
|
||||
final result = await _fetch();
|
||||
_cache = result;
|
||||
return result;
|
||||
} catch (_) {
|
||||
if (_cache != null) return _cache!;
|
||||
rethrow;
|
||||
}
|
||||
return _fetch();
|
||||
}
|
||||
|
||||
Future<PageResult<Product>> _fetch() {
|
||||
@@ -73,14 +65,9 @@ class ProductListNotifier extends AsyncNotifier<PageResult<Product>> {
|
||||
void reload() {
|
||||
state = const AsyncValue.loading();
|
||||
_fetch().then((result) {
|
||||
_cache = result;
|
||||
state = AsyncValue.data(result);
|
||||
}, onError: (e, st) {
|
||||
if (_cache != null) {
|
||||
state = AsyncValue.data(_cache!);
|
||||
} else {
|
||||
state = AsyncValue.error(e, st);
|
||||
}
|
||||
state = AsyncValue.error(e, st);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -14,33 +14,19 @@ final sessionListProvider =
|
||||
);
|
||||
|
||||
class SessionListNotifier extends AsyncNotifier<List<DeviceSession>> {
|
||||
List<DeviceSession> _cache = [];
|
||||
|
||||
@override
|
||||
Future<List<DeviceSession>> build() async {
|
||||
ref.watch(authStateProvider.select((s) => s.user?.shopId));
|
||||
try {
|
||||
final result = await ref.read(sessionRepositoryProvider).list();
|
||||
_cache = result;
|
||||
return result;
|
||||
} catch (_) {
|
||||
if (_cache.isNotEmpty) return _cache;
|
||||
rethrow;
|
||||
}
|
||||
return ref.read(sessionRepositoryProvider).list();
|
||||
}
|
||||
|
||||
Future<void> reload() async {
|
||||
state = const AsyncValue.loading();
|
||||
try {
|
||||
final result = await ref.read(sessionRepositoryProvider).list();
|
||||
_cache = result;
|
||||
state = AsyncValue.data(result);
|
||||
} catch (e, st) {
|
||||
if (_cache.isNotEmpty) {
|
||||
state = AsyncValue.data(_cache);
|
||||
} else {
|
||||
state = AsyncValue.error(e, st);
|
||||
}
|
||||
state = AsyncValue.error(e, st);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -44,7 +44,6 @@ class StockInListNotifier extends AsyncNotifier<PageResult<StockInOrder>> {
|
||||
String? _endDate;
|
||||
String _keyword = '';
|
||||
Map<String, String> _detail = const {};
|
||||
PageResult<StockInOrder>? _cache;
|
||||
Timer? _searchDebounce;
|
||||
|
||||
@override
|
||||
@@ -52,14 +51,7 @@ class StockInListNotifier extends AsyncNotifier<PageResult<StockInOrder>> {
|
||||
ref.onDispose(() => _searchDebounce?.cancel());
|
||||
ref.watch(authStateProvider.select((s) => s.user?.shopId));
|
||||
ref.watch(networkRecoveryCountProvider);
|
||||
try {
|
||||
final result = await _fetch();
|
||||
_cache = result;
|
||||
return result;
|
||||
} catch (_) {
|
||||
if (_cache != null) return _cache!;
|
||||
rethrow;
|
||||
}
|
||||
return _fetch();
|
||||
}
|
||||
|
||||
Future<PageResult<StockInOrder>> _fetch() {
|
||||
@@ -130,14 +122,9 @@ class StockInListNotifier extends AsyncNotifier<PageResult<StockInOrder>> {
|
||||
state = const AsyncValue<PageResult<StockInOrder>>.loading()
|
||||
.copyWithPrevious(state);
|
||||
_fetch().then((result) {
|
||||
_cache = result;
|
||||
state = AsyncValue.data(result);
|
||||
}, onError: (e, st) {
|
||||
if (_cache != null) {
|
||||
state = AsyncValue.data(_cache!);
|
||||
} else {
|
||||
state = AsyncValue.error(e, st);
|
||||
}
|
||||
state = AsyncValue.error(e, st);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -45,7 +45,6 @@ class StockOutListNotifier extends AsyncNotifier<PageResult<StockOutOrder>> {
|
||||
String _keyword = '';
|
||||
String _productCode = '';
|
||||
Map<String, String> _detail = const {};
|
||||
PageResult<StockOutOrder>? _cache;
|
||||
Timer? _searchDebounce;
|
||||
|
||||
@override
|
||||
@@ -53,14 +52,7 @@ class StockOutListNotifier extends AsyncNotifier<PageResult<StockOutOrder>> {
|
||||
ref.onDispose(() => _searchDebounce?.cancel());
|
||||
ref.watch(authStateProvider.select((s) => s.user?.shopId));
|
||||
ref.watch(networkRecoveryCountProvider);
|
||||
try {
|
||||
final result = await _fetch();
|
||||
_cache = result;
|
||||
return result;
|
||||
} catch (_) {
|
||||
if (_cache != null) return _cache!;
|
||||
rethrow;
|
||||
}
|
||||
return _fetch();
|
||||
}
|
||||
|
||||
Future<PageResult<StockOutOrder>> _fetch() {
|
||||
@@ -139,14 +131,9 @@ class StockOutListNotifier extends AsyncNotifier<PageResult<StockOutOrder>> {
|
||||
state = const AsyncValue<PageResult<StockOutOrder>>.loading()
|
||||
.copyWithPrevious(state);
|
||||
_fetch().then((result) {
|
||||
_cache = result;
|
||||
state = AsyncValue.data(result);
|
||||
}, onError: (e, st) {
|
||||
if (_cache != null) {
|
||||
state = AsyncValue.data(_cache!);
|
||||
} else {
|
||||
state = AsyncValue.error(e, st);
|
||||
}
|
||||
state = AsyncValue.error(e, st);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -140,7 +140,9 @@ const _definedVersion = String.fromEnvironment('APP_VERSION');
|
||||
|
||||
Future<String> currentAppVersion() async {
|
||||
if (_definedVersion.isNotEmpty) {
|
||||
return _definedVersion.startsWith('v') ? _definedVersion : 'v$_definedVersion';
|
||||
return _definedVersion.startsWith('v')
|
||||
? _definedVersion
|
||||
: 'v$_definedVersion';
|
||||
}
|
||||
final info = await PackageInfo.fromPlatform();
|
||||
return 'v${info.version}';
|
||||
|
||||
@@ -13,33 +13,19 @@ final userListProvider = AsyncNotifierProvider<UserListNotifier, List<AppUser>>(
|
||||
);
|
||||
|
||||
class UserListNotifier extends AsyncNotifier<List<AppUser>> {
|
||||
List<AppUser> _cache = [];
|
||||
|
||||
@override
|
||||
Future<List<AppUser>> build() async {
|
||||
ref.watch(authStateProvider.select((s) => s.user?.shopId));
|
||||
try {
|
||||
final result = await ref.read(userRepositoryProvider).list();
|
||||
_cache = result;
|
||||
return result;
|
||||
} catch (_) {
|
||||
if (_cache.isNotEmpty) return _cache;
|
||||
rethrow;
|
||||
}
|
||||
return ref.read(userRepositoryProvider).list();
|
||||
}
|
||||
|
||||
Future<void> reload() async {
|
||||
state = const AsyncValue.loading();
|
||||
try {
|
||||
final result = await ref.read(userRepositoryProvider).list();
|
||||
_cache = result;
|
||||
state = AsyncValue.data(result);
|
||||
} catch (e, st) {
|
||||
if (_cache.isNotEmpty) {
|
||||
state = AsyncValue.data(_cache);
|
||||
} else {
|
||||
state = AsyncValue.error(e, st);
|
||||
}
|
||||
state = AsyncValue.error(e, st);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,33 +14,19 @@ final warehouseListProvider =
|
||||
);
|
||||
|
||||
class WarehouseListNotifier extends AsyncNotifier<List<Warehouse>> {
|
||||
List<Warehouse> _cache = [];
|
||||
|
||||
@override
|
||||
Future<List<Warehouse>> build() async {
|
||||
ref.watch(authStateProvider.select((s) => s.user?.shopId));
|
||||
try {
|
||||
final result = await ref.read(warehouseRepositoryProvider).list();
|
||||
_cache = result;
|
||||
return result;
|
||||
} catch (_) {
|
||||
if (_cache.isNotEmpty) return _cache;
|
||||
rethrow;
|
||||
}
|
||||
return ref.read(warehouseRepositoryProvider).list();
|
||||
}
|
||||
|
||||
Future<void> reload() async {
|
||||
state = const AsyncValue.loading();
|
||||
try {
|
||||
final result = await ref.read(warehouseRepositoryProvider).list();
|
||||
_cache = result;
|
||||
state = AsyncValue.data(result);
|
||||
} catch (e, st) {
|
||||
if (_cache.isNotEmpty) {
|
||||
state = AsyncValue.data(_cache);
|
||||
} else {
|
||||
state = AsyncValue.error(e, st);
|
||||
}
|
||||
state = AsyncValue.error(e, st);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user