// 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'; import 'connection_provider.dart'; // ── 节点列表 AsyncNotifier ──────────────────────────────────────── /// /v1/nodes 返回 401:access token 过期。供 _fetchWithRefresh 捕获后续期重试。 class _NodesUnauthorized implements Exception { const _NodesUnauthorized(); } class NodesNotifier extends AsyncNotifier> { bool _disposed = false; DateTime? _lastFetch; /// 刷新限流:距上次成功拉取不足此间隔则跳过远端(下拉/按钮/进页多次触发只打一次)。 static const _minInterval = Duration(seconds: 10); @override Future> build() async { _disposed = false; // build 重跑表示 notifier 仍在用:必须复位,否则上一轮 onDispose 置的 // true 会让 setLivePing/_measure 永久静默(urltest 回写/延迟实测失效 → 延迟冻成 —)。 ref.onDispose(() => _disposed = true); // 只关心「是否登录」:token 续期(accessToken 变)不应重建 nodesProvider、清空已测延迟。 final loggedIn = ref.watch(authProvider.select((s) => s.isLoggedIn)); if (!loggedIn) return const []; try { final list = await _fetchWithRefresh(); _lastFetch = DateTime.now(); unawaited(_measure(list)); return list; } catch (_) { // 首拉失败(网络/鉴权):返回空列表而非抛错;鉴权真过期由 authProvider 登出跳登录页。 return const []; } } /// 刷新节点列表(下拉/按钮/进页调用)。10s 限流;不清空当前列表(后台拉、好了再换), /// 避免进页/下拉时列表闪空;失败保留旧数据。 Future 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 _mergePings(List 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 _measure(List list) async { if (list.isEmpty) return; // 连接态:全局 TUN 把「到节点的直连 TCP 握手」在本地接住,connect() 几毫秒就返回 // (假值,非真实 RTT)。这会覆盖掉内核 urltest 经 setLivePing 回写的真实延迟(连接页 // 因此显示 ~3ms)。连接时延迟只认 urltest,跳过直连实测;断开后再恢复实测。 if (ref.read(connectionProvider).phase == VpnPhase.on) 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> _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> _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; final rawList = body['nodes'] as List? ?? []; return rawList.map((e) { final m = e as Map; 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.new); // ── 当前选中的节点 code;'AUTO' 表示智能选择(默认)───────────────── // // 持久化:用户的选择(智能 / 具体节点)跨重启保存,启动时恢复,供自动连接复用上次选择。 const _kSelectedNode = 'selected_node_code'; class SelectedNodeController extends StateNotifier { SelectedNodeController() : super(kSmartNodeCode) { _load(); } /// 是否已从持久化恢复完(自动连接要等它 true 再读,避免用到未恢复的默认值)。 bool loaded = false; Future _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 select(String code) async { state = code; try { final p = await SharedPreferences.getInstance(); await p.setString(_kSelectedNode, code); } catch (_) {} } } final selectedNodeCodeProvider = StateNotifierProvider((ref) => SelectedNodeController()); /// 是否处于智能选择。 final isSmartSelectProvider = Provider( (ref) => ref.watch(selectedNodeCodeProvider) == kSmartNodeCode, ); /// 实际生效的节点:列表为空时返回占位;智能选择取实测延迟最小者(未测得排后)。 final effectiveNodeProvider = Provider((ref) { final nodes = ref.watch(nodesProvider).valueOrNull ?? const []; 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); });