fix(client): 节点拉取 401 自愈+失败不清空列表;看门狗 path A 跨端(urltest 陈旧判活)
bug:周期刷新拿到 401(token 过期)时 _fetchNodes return const [] → 把节点列表清空, 用户「看不到节点」误以为 server 挂。修:① 非 200 抛异常,refresh 失败保留旧列表(绝不清空); ② 401 用 refresh token 续期重试一次自愈,续期失败才登出。 看门狗 path A 全平台统一:不再只靠原生报 off/error(libbox/NE 行为不一),改用内核 urltest 陈旧度(经 proxy 探测,四端都有 stats)——urltest 超 45s 未成功即判上游死 → _onNodeUnhealthy。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,11 @@ import 'auth_provider.dart';
|
||||
|
||||
// ── 节点列表 AsyncNotifier ────────────────────────────────────────
|
||||
|
||||
/// /v1/nodes 返回 401:access token 过期。供 _fetchWithRefresh 捕获后续期重试。
|
||||
class _NodesUnauthorized implements Exception {
|
||||
const _NodesUnauthorized();
|
||||
}
|
||||
|
||||
class NodesNotifier extends AsyncNotifier<List<Node>> {
|
||||
bool _disposed = false;
|
||||
DateTime? _lastFetch;
|
||||
@@ -28,10 +33,15 @@ class NodesNotifier extends AsyncNotifier<List<Node>> {
|
||||
ref.onDispose(() => _disposed = true);
|
||||
final auth = ref.watch(authProvider);
|
||||
if (!auth.isLoggedIn) return const [];
|
||||
final list = await _fetchNodes(auth.accessToken!);
|
||||
_lastFetch = DateTime.now();
|
||||
unawaited(_measure(list));
|
||||
return list;
|
||||
try {
|
||||
final list = await _fetchWithRefresh();
|
||||
_lastFetch = DateTime.now();
|
||||
unawaited(_measure(list));
|
||||
return list;
|
||||
} catch (_) {
|
||||
// 首拉失败(网络/鉴权):返回空列表而非抛错;鉴权真过期由 authProvider 登出跳登录页。
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
/// 刷新节点列表(下拉/按钮/进页调用)。10s 限流;不清空当前列表(后台拉、好了再换),
|
||||
@@ -48,13 +58,14 @@ class NodesNotifier extends AsyncNotifier<List<Node>> {
|
||||
final result = await AsyncValue.guard(() async {
|
||||
// ping 是客户端实测(direct TCP / 连接态 urltest 回写),服务端列表不带它(渲染为 0)。
|
||||
// 刷新时保留已有 ping,否则会把刚测/回写的延迟清成 0 → 连接页延迟闪成 —。
|
||||
final list = _mergePings(await _fetchNodes(auth.accessToken!));
|
||||
final list = _mergePings(await _fetchWithRefresh());
|
||||
_lastFetch = DateTime.now();
|
||||
unawaited(_measure(list));
|
||||
return list;
|
||||
});
|
||||
if (_disposed) return;
|
||||
if (result.hasValue) state = result; // 成功才换;失败保留旧列表
|
||||
// 仅成功才换列表;失败(401/5xx/网络)保留旧列表——绝不因一次刷新失败把节点清空。
|
||||
if (result.hasValue) state = result;
|
||||
}
|
||||
|
||||
/// 用当前 state 里各节点已有的 ping 回填新拉到的列表(按 uuid;已有>0 才覆盖占位 0)。
|
||||
@@ -98,13 +109,30 @@ class NodesNotifier extends AsyncNotifier<List<Node>> {
|
||||
state = AsyncData(updated);
|
||||
}
|
||||
|
||||
/// 拉节点;401 时用 refresh token 续期重试一次(自愈),续期失败由 authProvider 登出。
|
||||
/// 非 200 一律抛异常 → 调用方(refresh)保留旧列表,不把列表清空。
|
||||
Future<List<Node>> _fetchWithRefresh() async {
|
||||
final token = ref.read(authProvider).accessToken ?? '';
|
||||
try {
|
||||
return await _fetchNodes(token);
|
||||
} on _NodesUnauthorized {
|
||||
final ok = await ref.read(authProvider.notifier).refresh();
|
||||
if (!ok) rethrow; // 续期也失败 = 真过期,交给 authProvider 触发登出
|
||||
return await _fetchNodes(ref.read(authProvider).accessToken ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
static Future<List<Node>> _fetchNodes(String accessToken) async {
|
||||
final uri = Uri.parse('$kApiBaseUrl/v1/nodes');
|
||||
final resp = await http.get(uri, headers: {
|
||||
'Authorization': 'Bearer $accessToken',
|
||||
}).timeout(const Duration(seconds: 10));
|
||||
|
||||
if (resp.statusCode != 200) return const [];
|
||||
// 非 200 抛异常(401 单独类型,供续期重试):绝不返回空列表覆盖掉已有节点。
|
||||
if (resp.statusCode == 401) throw const _NodesUnauthorized();
|
||||
if (resp.statusCode != 200) {
|
||||
throw Exception('nodes fetch ${resp.statusCode}');
|
||||
}
|
||||
|
||||
final body = jsonDecode(resp.body) as Map<String, dynamic>;
|
||||
final rawList = body['nodes'] as List<dynamic>? ?? [];
|
||||
|
||||
Reference in New Issue
Block a user