Files
pangolin/client/lib/state/nodes_provider.dart
T
wangjia a03de6c06a 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>
2026-06-30 01:23:13 +08:00

217 lines
8.3 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// nodes_provider.dart — 节点清单 + 当前选择 + 实测延迟
//
// 从 GET /v1/nodes 拉取真实节点(含 host/port);拉取后后台对各节点做 TCP 握手
// 实测 per-client 延迟并回填。未登录 / 失败返回空列表(不再伪造演示节点)。
import 'dart:async';
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import '../models/node.dart';
import '../services/api_config.dart';
import '../services/latency_probe.dart';
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;
/// 刷新限流:距上次成功拉取不足此间隔则跳过远端(下拉/按钮/进页多次触发只打一次)。
static const _minInterval = Duration(seconds: 10);
@override
Future<List<Node>> build() async {
ref.onDispose(() => _disposed = true);
final auth = ref.watch(authProvider);
if (!auth.isLoggedIn) return const [];
try {
final list = await _fetchWithRefresh();
_lastFetch = DateTime.now();
unawaited(_measure(list));
return list;
} catch (_) {
// 首拉失败(网络/鉴权):返回空列表而非抛错;鉴权真过期由 authProvider 登出跳登录页。
return const [];
}
}
/// 刷新节点列表(下拉/按钮/进页调用)。10s 限流;不清空当前列表(后台拉、好了再换),
/// 避免进页/下拉时列表闪空;失败保留旧数据。
Future<void> refresh() async {
final auth = ref.read(authProvider);
if (!auth.isLoggedIn) {
state = const AsyncData([]);
return;
}
if (_lastFetch != null && DateTime.now().difference(_lastFetch!) < _minInterval) {
return; // 限流:10s 内不重复请求远端
}
final result = await AsyncValue.guard(() async {
// ping 是客户端实测(direct TCP / 连接态 urltest 回写),服务端列表不带它(渲染为 0)。
// 刷新时保留已有 ping,否则会把刚测/回写的延迟清成 0 → 连接页延迟闪成 —。
final list = _mergePings(await _fetchWithRefresh());
_lastFetch = DateTime.now();
unawaited(_measure(list));
return list;
});
if (_disposed) return;
// 仅成功才换列表;失败(401/5xx/网络)保留旧列表——绝不因一次刷新失败把节点清空。
if (result.hasValue) state = result;
}
/// 用当前 state 里各节点已有的 ping 回填新拉到的列表(按 uuid;已有>0 才覆盖占位 0)。
List<Node> _mergePings(List<Node> fresh) {
final cur = state.valueOrNull;
if (cur == null) return fresh;
final byUuid = {for (final n in cur) n.uuid: n.ping};
return [
for (final n in fresh)
(byUuid[n.uuid] ?? 0) > 0 ? n.copyWith(ping: byUuid[n.uuid]!) : n
];
}
/// 回写某节点的实测延迟(ms)。连接态由 ConnectionController 用内核 urltest 回填连接节点,
/// 让「连接页延迟」与「节点列表延迟」同源(都读 node.ping)、显示一致。ms<=0 忽略。
void setLivePing(String uuid, int ms) {
if (ms <= 0 || _disposed) return;
final list = state.valueOrNull;
if (list == null) return;
var changed = false;
final updated = [
for (final n in list)
if (n.uuid == uuid && n.ping != ms) (() { changed = true; return n.copyWith(ping: ms); })() else n
];
if (changed) state = AsyncData(updated);
}
/// 后台实测各节点延迟,完成后回填(notifier 未销毁才更新)。
/// 探测失败(返回 0,如连接态全局 TUN 把到节点的 TCP 握手接住)时**保留旧 ping**,
/// 不把已知延迟覆盖成空——否则连上后节点列表延迟会整列消失。
Future<void> _measure(List<Node> list) async {
if (list.isEmpty) return;
final pings = await probeAll(
[for (final n in list) (uuid: n.uuid, host: n.host, port: n.port)],
);
if (_disposed) return;
final updated = [
for (final n in list)
n.copyWith(ping: (pings[n.uuid] ?? 0) > 0 ? pings[n.uuid]! : n.ping)
];
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));
// 非 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>? ?? [];
return rawList.map((e) {
final m = e as Map<String, dynamic>;
return Node(
uuid: m['id'] as String? ?? '',
code: m['region'] as String? ?? '??',
nameZh: m['name_zh'] as String? ?? '',
nameEn: m['name_en'] as String? ?? '',
tier: m['tier'] as String? ?? 'free',
host: m['host'] as String? ?? '',
port: (m['port'] as num?)?.toInt() ?? 0,
status: m['status'] as String? ?? 'up',
ping: 0, // 由 _measure 实测回填
);
}).toList();
}
}
final nodesProvider =
AsyncNotifierProvider<NodesNotifier, List<Node>>(NodesNotifier.new);
// ── 当前选中的节点 code'AUTO' 表示智能选择(默认)─────────────────
//
// 持久化:用户的选择(智能 / 具体节点)跨重启保存,启动时恢复,供自动连接复用上次选择。
const _kSelectedNode = 'selected_node_code';
class SelectedNodeController extends StateNotifier<String> {
SelectedNodeController() : super(kSmartNodeCode) {
_load();
}
/// 是否已从持久化恢复完(自动连接要等它 true 再读,避免用到未恢复的默认值)。
bool loaded = false;
Future<void> _load() async {
try {
final p = await SharedPreferences.getInstance();
if (!mounted) return;
final v = p.getString(_kSelectedNode);
if (v != null && v.isNotEmpty) state = v;
} catch (_) {
// 测试/无平台:保留默认(智能)。
}
loaded = true;
}
/// 选择节点(智能传 kSmartNodeCode)并持久化。
Future<void> select(String code) async {
state = code;
try {
final p = await SharedPreferences.getInstance();
await p.setString(_kSelectedNode, code);
} catch (_) {}
}
}
final selectedNodeCodeProvider =
StateNotifierProvider<SelectedNodeController, String>((ref) => SelectedNodeController());
/// 是否处于智能选择。
final isSmartSelectProvider = Provider<bool>(
(ref) => ref.watch(selectedNodeCodeProvider) == kSmartNodeCode,
);
/// 实际生效的节点:列表为空时返回占位;智能选择取实测延迟最小者(未测得排后)。
final effectiveNodeProvider = Provider<Node>((ref) {
final nodes = ref.watch(nodesProvider).valueOrNull ?? const <Node>[];
if (nodes.isEmpty) return kPlaceholderNode;
final code = ref.watch(selectedNodeCodeProvider);
if (code == kSmartNodeCode) {
return nodes.reduce((a, b) {
final pa = a.ping > 0 ? a.ping : 1 << 30;
final pb = b.ping > 0 ? b.ping : 1 << 30;
return pa <= pb ? a : b;
});
}
return nodes.firstWhere((n) => n.code == code, orElse: () => nodes.first);
});