| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420 |
- import 'dart:async';
- import 'dart:developer' as developer;
- import 'package:dio/dio.dart';
- import 'package:flutter_riverpod/flutter_riverpod.dart';
- import 'package:shared_preferences/shared_preferences.dart';
- import '../core/config/app_config.dart';
- import '../data/models/node/service_node.dart';
- import 'app_provider.dart';
- // ── State ─────────────────────────────────────────────────
- class NodeState {
- final List<ServiceNode> nodes;
- final ServiceNode? currentNode;
- final bool isLoading;
- final bool isSpeedTesting;
- /// nodeId → latency(ms)。null 表示测速中或超时
- final Map<int, int?> latencyMap;
- const NodeState({
- this.nodes = const [],
- this.currentNode,
- this.isLoading = false,
- this.isSpeedTesting = false,
- this.latencyMap = const {},
- });
- NodeState copyWith({
- List<ServiceNode>? nodes,
- ServiceNode? currentNode,
- bool? isLoading,
- bool? isSpeedTesting,
- Map<int, int?>? latencyMap,
- bool clearCurrentNode = false,
- }) {
- return NodeState(
- nodes: nodes ?? this.nodes,
- currentNode: clearCurrentNode ? null : (currentNode ?? this.currentNode),
- isLoading: isLoading ?? this.isLoading,
- isSpeedTesting: isSpeedTesting ?? this.isSpeedTesting,
- latencyMap: latencyMap ?? this.latencyMap,
- );
- }
- }
- // ── Notifier ──────────────────────────────────────────────
- class NodeNotifier extends Notifier<NodeState> {
- // 熔断内部状态(不暴露给 UI)
- final Map<int, int> _failCounts = {};
- final Map<int, DateTime> _circuitOpenTimes = {};
- @override
- NodeState build() {
- Future.microtask(_init);
- return const NodeState(isLoading: true);
- }
- SharedPreferences get _prefs => ref.read(sharedPreferencesProvider);
- /// Debug / Release 分 key 缓存,避免 debug 构建复用 release 写入的生产节点缓存
- String get _nodeListCacheKey =>
- AppConfig.isDebug ? 'node_list_cache_debug' : 'node_list_cache';
- String get _selectedNodeIdKey =>
- AppConfig.isDebug ? 'node_selected_id_debug' : 'node_selected_id';
- // ── 初始化 ────────────────────────────────────────────
- Future<void> _init() async {
- // 1. 加载本地缓存;若缓存为空,用编译期 fallbackHosts 构造虚拟节点垫底,
- // 避免首屏在拉到节点列表前出现"无节点可切"的窗口
- final cachedNodes = _loadCachedNodes();
- final savedId = _prefs.getInt(_selectedNodeIdKey);
- final initialNodes =
- cachedNodes.isNotEmpty ? cachedNodes : _buildFallbackNodes();
- ServiceNode? selected;
- if (savedId != null) {
- selected = initialNodes.where((n) => n.id == savedId).firstOrNull;
- }
- selected ??= initialNodes.firstOrNull;
- state = state.copyWith(
- nodes: initialNodes,
- currentNode: selected,
- isLoading: false,
- );
- // 1.5 主动探活:缓存的 currentNode 可能已下线,先 ping 一次。
- // 失败立即 failover,避免首屏业务请求承担探活成本(用户看到红 toast)。
- // 不 await——与 fetchNodeList/测速 并行,最终都会收敛到正确节点。
- unawaited(_probeCurrentNode());
- // 2. 从远程 CDN 拉取最新兜底列表,合并到 fallbackHosts 头部
- final remoteFallbacks = await _fetchRemoteFallbackHosts();
- // 3. 后台拉取最新节点列表(优先使用远程兜底 + 本地硬编码兜底)
- await fetchNodeList(extraHosts: remoteFallbacks);
- // 4. 测速
- if (state.nodes.isNotEmpty) {
- await speedTestAll();
- }
- }
- /// 主动探活当前节点:用一次轻量 GET 请求验证 currentNode 是否可达,
- /// 失败立即触发 immediate failover 切下一个候选。
- /// 与 _init 中的 fetchNodeList / speedTestAll 并发执行,竞争状态下最后写入者胜。
- Future<void> _probeCurrentNode() async {
- final node = state.currentNode;
- if (node == null) return;
- final dio = Dio(BaseOptions(
- connectTimeout:
- const Duration(milliseconds: AppConfig.speedTestTimeoutMs),
- receiveTimeout:
- const Duration(milliseconds: AppConfig.speedTestTimeoutMs),
- ));
- try {
- await dio.get('${node.normalizedApiHost}contract/watchlist/test');
- developer.log('Node probe ok: ${node.name}', name: 'Node');
- } catch (e) {
- developer.log(
- 'Node probe failed: ${node.name} → $e, switching node',
- name: 'Node',
- );
- reportFailure(immediate: true);
- }
- }
- /// 用 AppConfig.fallbackHosts 构造一组兜底虚拟节点,id 用负数与真节点隔离。
- /// 仅在没有缓存节点 / 节点列表 API 不可达时作为应急节点池使用。
- List<ServiceNode> _buildFallbackNodes() {
- final hosts = AppConfig.fallbackHosts;
- return List<ServiceNode>.generate(hosts.length, (i) {
- final api = hosts[i];
- var ws = api.replaceFirst(RegExp(r'^https?://'), 'wss://');
- if (ws.endsWith('/')) ws = ws.substring(0, ws.length - 1);
- ws = '$ws/market-new';
- return ServiceNode(
- id: -(i + 1),
- name: 'Fallback ${i + 1}',
- apiHost: api,
- wsHost: ws,
- status: 1,
- );
- });
- }
- /// 从远程 CDN 拉取兜底 Host 列表,失败时静默返回空列表
- Future<List<String>> _fetchRemoteFallbackHosts() async {
- final dio = Dio(BaseOptions(
- connectTimeout:
- const Duration(milliseconds: AppConfig.speedTestTimeoutMs),
- receiveTimeout:
- const Duration(milliseconds: AppConfig.speedTestTimeoutMs),
- ));
- for (final url in AppConfig.remoteConfigUrls) {
- try {
- final response = await dio.get<Map<String, dynamic>>(url);
- final hosts = (response.data?['fallbackHosts'] as List<dynamic>?)
- ?.whereType<String>()
- .where((h) => h.startsWith('http'))
- .toList();
- if (hosts != null && hosts.isNotEmpty) {
- developer.log('Remote config loaded from $url: ${hosts.length} hosts',
- name: 'Node');
- return hosts;
- }
- } catch (e) {
- developer.log('Remote config fetch failed from $url: $e', name: 'Node');
- }
- }
- return [];
- }
- List<ServiceNode> _loadCachedNodes() {
- final json = _prefs.getString(_nodeListCacheKey);
- if (json == null || json.isEmpty) return [];
- try {
- return ServiceNode.listFromJson(json);
- } catch (_) {
- return [];
- }
- }
- void _saveCachedNodes(List<ServiceNode> nodes) {
- _prefs.setString(_nodeListCacheKey, ServiceNode.listToJson(nodes));
- }
- // ── 拉取节点列表 ─────────────────────────────────────
- Future<void> fetchNodeList({List<String> extraHosts = const []}) async {
- final dio = Dio(BaseOptions(
- connectTimeout:
- const Duration(milliseconds: AppConfig.speedTestTimeoutMs),
- receiveTimeout:
- const Duration(milliseconds: AppConfig.speedTestTimeoutMs),
- headers: {'Content-Type': 'application/json'},
- ));
- // 远程兜底列表优先,再用硬编码列表;去重保留顺序
- final seen = <String>{};
- final hostsToTry = [...extraHosts, ...AppConfig.fallbackHosts]
- .where((h) => seen.add(h))
- .toList();
- for (final host in hostsToTry) {
- try {
- final response =
- await dio.get<Map<String, dynamic>>('${host}contract/node/list');
- final body = response.data;
- if (body == null) continue;
- final code = body['code'] as int? ?? -1;
- if (code != 0) continue;
- final list = (body['data'] as List<dynamic>)
- .map((e) => ServiceNode.fromJson(e as Map<String, dynamic>))
- .where((n) => n.isValid)
- .toList();
- if (list.isEmpty) continue;
- // 更新缓存
- _saveCachedNodes(list);
- // 保持当前选择(如果仍在新列表中)
- final currentId = state.currentNode?.id;
- ServiceNode? selected;
- if (currentId != null) {
- selected = list.where((n) => n.id == currentId).firstOrNull;
- }
- selected ??= list.first;
- state = state.copyWith(
- nodes: list,
- currentNode: selected,
- isLoading: false,
- );
- return;
- } catch (e) {
- developer.log('fetchNodeList from $host failed: $e', name: 'Node');
- continue;
- }
- }
- // 全部失败,保持当前状态
- state = state.copyWith(isLoading: false);
- }
- // ── 测速 ──────────────────────────────────────────────
- Future<void> speedTestAll() async {
- if (state.nodes.isEmpty) return;
- state = state.copyWith(isSpeedTesting: true, latencyMap: {});
- final results = <int, int?>{};
- final futures = <Future<void>>[];
- for (final node in state.nodes) {
- futures.add(
- _speedTest(node).then((ms) => results[node.id] = ms),
- );
- }
- await Future.wait(futures);
- state = state.copyWith(
- isSpeedTesting: false,
- latencyMap: results,
- );
- // 如果没有用户手动选择,自动切到最快节点
- final savedId = _prefs.getInt(_selectedNodeIdKey);
- if (savedId == null) {
- _autoSelectBestNode(results);
- }
- }
- Future<int?> _speedTest(ServiceNode node) async {
- final dio = Dio(BaseOptions(
- connectTimeout:
- const Duration(milliseconds: AppConfig.speedTestTimeoutMs),
- receiveTimeout:
- const Duration(milliseconds: AppConfig.speedTestTimeoutMs),
- ));
- final sw = Stopwatch()..start();
- try {
- await dio.get('${node.normalizedApiHost}contract/watchlist/test');
- sw.stop();
- return sw.elapsedMilliseconds;
- } catch (_) {
- sw.stop();
- return null; // 超时或不可达
- }
- }
- void _autoSelectBestNode(Map<int, int?> latencyMap) {
- final reachable = state.nodes.where((n) {
- final ms = latencyMap[n.id];
- return ms != null;
- }).toList();
- if (reachable.isEmpty) return;
- reachable.sort((a, b) {
- final la = latencyMap[a.id]!;
- final lb = latencyMap[b.id]!;
- return la.compareTo(lb);
- });
- final best = reachable.first;
- if (best.id != state.currentNode?.id) {
- state = state.copyWith(currentNode: best);
- }
- }
- // ── 用户手动选择 ─────────────────────────────────────
- void selectNode(ServiceNode node) {
- _prefs.setInt(_selectedNodeIdKey, node.id);
- _failCounts.remove(node.id);
- state = state.copyWith(currentNode: node);
- }
- // ── 故障转移(由 Dio 拦截器调用)────────────────────
- /// 报告请求失败,返回 true 表示已切换节点。
- ///
- /// [immediate] 为 true 时第一次失败就熔断当前节点切下一个;
- /// 用于 DNS / connect / timeout 等"硬"网络错误,避免连续等 N 次超时才切。
- /// 5xx 等服务端错误仍走累积阈值,避免单点抖动导致频繁切换。
- bool reportFailure({bool immediate = false}) {
- final node = state.currentNode;
- if (node == null || state.nodes.length <= 1) return false;
- final count = (_failCounts[node.id] ?? 0) + 1;
- _failCounts[node.id] = count;
- final shouldTrip = immediate || count >= AppConfig.failoverThreshold;
- if (shouldTrip) {
- // 熔断当前节点
- _circuitOpenTimes[node.id] = DateTime.now();
- final next = _getNextAvailableNode();
- if (next != null) {
- developer.log(
- 'Failover: ${node.name} → ${next.name} (immediate=$immediate)',
- name: 'Node',
- );
- _failCounts.remove(next.id);
- state = state.copyWith(currentNode: next);
- return true;
- }
- // 所有节点都熔断了,重置全部
- _circuitOpenTimes.clear();
- _failCounts.clear();
- }
- return false;
- }
- /// 报告请求成功,重置失败计数
- void reportSuccess() {
- final node = state.currentNode;
- if (node == null) return;
- if (_failCounts.containsKey(node.id)) {
- _failCounts.remove(node.id);
- }
- }
- ServiceNode? _getNextAvailableNode() {
- final currentId = state.currentNode?.id;
- final available = state.nodes.where((n) {
- if (n.id == currentId) return false;
- return !_isCircuitOpen(n.id);
- }).toList();
- if (available.isEmpty) return null;
- // 优先选延迟最低的
- available.sort((a, b) {
- final la = state.latencyMap[a.id];
- final lb = state.latencyMap[b.id];
- if (la == null && lb == null) return 0;
- if (la == null) return 1;
- if (lb == null) return -1;
- return la.compareTo(lb);
- });
- return available.first;
- }
- bool _isCircuitOpen(int nodeId) {
- final openTime = _circuitOpenTimes[nodeId];
- if (openTime == null) return false;
- if (DateTime.now().difference(openTime).inSeconds >
- AppConfig.circuitBreakDurationSec) {
- // 半开:超过冷却期,允许重试
- _circuitOpenTimes.remove(nodeId);
- _failCounts.remove(nodeId);
- return false;
- }
- return true;
- }
- }
- // ── Provider ──────────────────────────────────────────────
- final nodeProvider = NotifierProvider<NodeNotifier, NodeState>(
- NodeNotifier.new,
- );
|