From 4c76c2a70113b35142de012ca2f92f7b17e6d831 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Tue, 28 Jul 2026 09:17:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(routing):=20=E5=AD=98=E6=A1=A3=E6=A1=88?= =?UTF-8?q?=E5=90=8E=E8=BF=9E=E6=8E=A5=E6=80=81=E8=87=AA=E5=8A=A8=E9=87=8D?= =?UTF-8?q?=E8=BF=9E=E4=BD=BF=E6=96=B0=E8=A7=84=E5=88=99=E7=94=9F=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 9: RoutingProfileNotifier._persist() 保存成功后,若 connectionProvider.phase == on,触发 ConnectionController.reapplyRoutingProfile()(disconnect→connect, 不改选中节点)重连,让服务端按新档案渲染的规则生效;off 态不触发。 新增 l10n 键 routingRulesReconnecting(单源 design/i18n/strings.json → codegen), 「规则已更新,正在重连…」区别于既有 nodeReconnecting(弱网抖动语义)。 reapplyRoutingProfile 内先置 _userDisconnect=true 再 disconnect,避免其触发的 kernel off 事件被 _onKernelStatus 误判「意外掉线」、再抢跑一次 watchdog 自动重连。 --- client/lib/l10n/app_text.dart | 1 + client/lib/l10n/strings_en.dart | 2 + client/lib/l10n/strings_es.dart | 2 + client/lib/l10n/strings_ja.dart | 2 + client/lib/l10n/strings_ko.dart | 2 + client/lib/l10n/strings_ru.dart | 2 + client/lib/l10n/strings_zh.dart | 2 + client/lib/state/connection_provider.dart | 18 ++ client/lib/state/routing_provider.dart | 15 ++ client/test/unit/routing_reconnect_test.dart | 174 +++++++++++++++++++ design/i18n/strings.json | 8 + 11 files changed, 228 insertions(+) create mode 100644 client/test/unit/routing_reconnect_test.dart diff --git a/client/lib/l10n/app_text.dart b/client/lib/l10n/app_text.dart index 0b4ef8c..d06402b 100644 --- a/client/lib/l10n/app_text.dart +++ b/client/lib/l10n/app_text.dart @@ -387,6 +387,7 @@ abstract class AppText { String get nodeUnhealthySwitched; // 智能选择已自动切换 String get nodeUnhealthyError; // 手动节点:断开并提示 String get nodeReconnecting; // 弱网抖动:自动重连当前节点中(#18) + String get routingRulesReconnecting; // 分流档案保存后自动重连(Task 9) String get smartSub; String get recommended; diff --git a/client/lib/l10n/strings_en.dart b/client/lib/l10n/strings_en.dart index 5f7db51..9039b3e 100644 --- a/client/lib/l10n/strings_en.dart +++ b/client/lib/l10n/strings_en.dart @@ -89,6 +89,8 @@ class StringsEn extends AppText { @override String get nodeReconnecting => 'Network unstable, reconnecting…'; @override + String get routingRulesReconnecting => 'Rules updated, reconnecting…'; + @override String get smartSub => 'Picks the best node for your network'; @override String get recommended => 'Recommended'; diff --git a/client/lib/l10n/strings_es.dart b/client/lib/l10n/strings_es.dart index 50722b9..4f19091 100644 --- a/client/lib/l10n/strings_es.dart +++ b/client/lib/l10n/strings_es.dart @@ -89,6 +89,8 @@ class StringsEs extends AppText { @override String get nodeReconnecting => 'Red inestable, reconectando…'; @override + String get routingRulesReconnecting => 'Reglas actualizadas, reconectando…'; + @override String get smartSub => 'Elige el mejor nodo para tu red'; @override String get recommended => 'Recomendado'; diff --git a/client/lib/l10n/strings_ja.dart b/client/lib/l10n/strings_ja.dart index b14b2a2..5b4209b 100644 --- a/client/lib/l10n/strings_ja.dart +++ b/client/lib/l10n/strings_ja.dart @@ -89,6 +89,8 @@ class StringsJa extends AppText { @override String get nodeReconnecting => 'ネットワークが不安定です。再接続中…'; @override + String get routingRulesReconnecting => 'ルールが更新されました。再接続中…'; + @override String get smartSub => 'お使いのネットワークに最適なノードを選びます'; @override String get recommended => 'おすすめ'; diff --git a/client/lib/l10n/strings_ko.dart b/client/lib/l10n/strings_ko.dart index 0ecc8d9..0b735e6 100644 --- a/client/lib/l10n/strings_ko.dart +++ b/client/lib/l10n/strings_ko.dart @@ -89,6 +89,8 @@ class StringsKo extends AppText { @override String get nodeReconnecting => '네트워크가 불안정하여 다시 연결 중…'; @override + String get routingRulesReconnecting => '규칙이 업데이트되어 다시 연결 중…'; + @override String get smartSub => '네트워크에 가장 적합한 노드를 선택합니다'; @override String get recommended => '추천'; diff --git a/client/lib/l10n/strings_ru.dart b/client/lib/l10n/strings_ru.dart index b5931b7..b8dcf8a 100644 --- a/client/lib/l10n/strings_ru.dart +++ b/client/lib/l10n/strings_ru.dart @@ -89,6 +89,8 @@ class StringsRu extends AppText { @override String get nodeReconnecting => 'Сеть нестабильна, переподключение…'; @override + String get routingRulesReconnecting => 'Правила обновлены, переподключение…'; + @override String get smartSub => 'Подбирает лучший узел для вашей сети'; @override String get recommended => 'Рекомендуется'; diff --git a/client/lib/l10n/strings_zh.dart b/client/lib/l10n/strings_zh.dart index a05f384..e6a53bd 100644 --- a/client/lib/l10n/strings_zh.dart +++ b/client/lib/l10n/strings_zh.dart @@ -89,6 +89,8 @@ class StringsZh extends AppText { @override String get nodeReconnecting => '网络波动,正在重连…'; @override + String get routingRulesReconnecting => '规则已更新,正在重连…'; + @override String get smartSub => '根据当前网络环境,自动选择最优节点'; @override String get recommended => '推荐'; diff --git a/client/lib/state/connection_provider.dart b/client/lib/state/connection_provider.dart index 09a133b..b80df38 100644 --- a/client/lib/state/connection_provider.dart +++ b/client/lib/state/connection_provider.dart @@ -182,6 +182,24 @@ class ConnectionController extends StateNotifier { } } + /// 分流档案保存成功后触发(Task 9):仅当前已连接(on)才重连,使服务端按新档案 + /// 渲染的规则生效;off 态不触发(下次连接自然拿到新配置)。复刻 onNodeChanged 的 + /// disconnect→connect 序列(不改选中节点),附加「规则已更新」瞬态提示 —— + /// 与 _tryAutoReconnectCurrent 同法:重连发起后若仍在握手/已连上(非 off),把 + /// error 字段临时置成提示文案,连上后随下一次 copyWith(仅 phase/elapsed)自然清除。 + Future reapplyRoutingProfile() async { + if (state.phase != VpnPhase.on) return; + // 标记「非节点异常」的主动断开:否则 bridge.stop() 触发的 kernel off 事件会被 + // _onKernelStatus 当成意外掉线,拉着 _handleUnexpectedOff 再抢跑一次自动重连, + // 与这里的 _connect() 撞车、多连一次(同 toggle() 主动断开时的用法)。 + _userDisconnect = true; + await _disconnect(); + await _connect(); + if (mounted && state.phase != VpnPhase.off) { + state = ConnectionState(phase: state.phase, error: _ref.read(appTextProvider).routingRulesReconnecting); + } + } + // ── 内部 ───────────────────────────────────────────────────── Future _connect() async { diff --git a/client/lib/state/routing_provider.dart b/client/lib/state/routing_provider.dart index 5e96735..7bd730a 100644 --- a/client/lib/state/routing_provider.dart +++ b/client/lib/state/routing_provider.dart @@ -10,11 +10,14 @@ // **当前已存的 state**(此时已是乐观更新后的 next)重新 copyWithPrevious 一次, // 会把我们想还原的旧值又覆盖回 next,达不到「回退」效果。故失败时直接整体 // `state = AsyncData(previous)`(纯回退,无错误标记)+ rethrow 传递异常。 +import 'dart:async'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../models/routing_profile.dart'; import 'account_providers.dart'; import 'auth_provider.dart'; +import 'connection_provider.dart'; /// 当前账户的分流档案。未登录返回默认档案(不打后端)。 class RoutingProfileNotifier extends AsyncNotifier { @@ -81,6 +84,18 @@ class RoutingProfileNotifier extends AsyncNotifier { state = AsyncData(previous); rethrow; } + _reconnectIfConnected(); // Task 9:保存成功且已连接 → 重连使新规则生效 + } + + /// 用 ref.read(非 watch)单向读取 connectionProvider:仅本 notifier 在保存成功后 + /// 顺带触发一次重连,不建立「connectionProvider 依赖 routingProfileProvider」的反向 + /// 引用,provider 依赖图仍是单向(routing → connection),不成环。off 态不触发—— + /// 下次连接本就会按最新档案渲染配置。重连失败由 connection_provider 自身状态承接 + /// (沿用其既有错误提示),不 rethrow 到这里,不影响「保存」这个操作本身的成败。 + void _reconnectIfConnected() { + if (ref.read(connectionProvider).phase == VpnPhase.on) { + unawaited(ref.read(connectionProvider.notifier).reapplyRoutingProfile()); + } } } diff --git a/client/test/unit/routing_reconnect_test.dart b/client/test/unit/routing_reconnect_test.dart new file mode 100644 index 0000000..fdb0f33 --- /dev/null +++ b/client/test/unit/routing_reconnect_test.dart @@ -0,0 +1,174 @@ +// routing_reconnect_test.dart — Task 9:分流档案保存成功后,若当前连接态为 on, +// 应触发一次重连(disconnect→connect)使新规则生效;off 态不触发。 +// +// 复用 routing_provider_test.dart 的登录桩 + connection_controller_test.dart / +// flow_connect_test.dart 的连接驱动范式(VpnBridgeMock + MockClient 假 fetchConfig)。 +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:pangolin_vpn/bridge/vpn_bridge_mock.dart'; +import 'package:pangolin_vpn/bridge/vpn_bridge_provider.dart'; +import 'package:pangolin_vpn/models/node.dart'; +import 'package:pangolin_vpn/models/routing_profile.dart'; +import 'package:pangolin_vpn/services/account_api.dart'; +import 'package:pangolin_vpn/services/api_client.dart'; +import 'package:pangolin_vpn/services/connect_api.dart'; +import 'package:pangolin_vpn/services/token_store.dart'; +import 'package:pangolin_vpn/state/account_providers.dart'; +import 'package:pangolin_vpn/state/auth_provider.dart'; +import 'package:pangolin_vpn/state/connection_provider.dart'; +import 'package:pangolin_vpn/state/nodes_provider.dart'; +import 'package:pangolin_vpn/state/routing_provider.dart'; + +/// 已登录的桩 TokenStore(仿 routing_provider_test.dart)。 +class _LoggedInTokenStore implements TokenStore { + const _LoggedInTokenStore(); + @override + Future saveTokens({required String access, required String refresh}) async {} + @override + Future loadAccessToken() async => 'test-token'; + @override + Future loadRefreshToken() async => 'test-refresh'; + @override + Future clear() async {} + @override + Future markOnboarded() async {} + @override + Future isOnboarded() async => true; + @override + Future saveLastEmail(String email) async {} + @override + Future loadLastEmail() async => null; +} + +/// 假 AccountApi:桩 routingProfile()/saveRoutingProfile(),记录调用便于断言。 +class _FakeAccountApi extends AccountApi { + _FakeAccountApi() + : super(ApiClient( + baseUrl: 'http://test.local', + getToken: () => null, + refresh: () async => false, + )); + + RoutingProfile initial = const RoutingProfile(); + final List saved = []; + + @override + Future routingProfile() async => initial; + + @override + Future saveRoutingProfile(RoutingProfile p) async { + saved.add(p); + } +} + +/// 计数版 VpnBridgeMock:记录 start() 被调用的次数,用于断言「是否重连过」 +/// (重连 = disconnect→connect,即 start() 被再次调用)。 +class _CountingBridge extends VpnBridgeMock { + _CountingBridge() : super(connectDelay: const Duration(milliseconds: 10)); + int startCount = 0; + + @override + Future start(String configJson) async { + startCount++; + await super.start(configJson); + } +} + +const _readyNode = + Node(code: 'HK', nameZh: '香港', nameEn: 'Hong Kong', ping: 18, uuid: 'hk-01'); + +Future _waitLoggedIn(ProviderContainer c) async { + for (var i = 0; i < 60; i++) { + if (c.read(authProvider).isLoggedIn) return; + await Future.delayed(const Duration(milliseconds: 10)); + } + throw StateError('authProvider 未在预期时间内进入已登录态'); +} + +Future _until(bool Function() cond, + {Duration timeout = const Duration(seconds: 2)}) async { + final sw = Stopwatch()..start(); + while (!cond() && sw.elapsed < timeout) { + await Future.delayed(const Duration(milliseconds: 5)); + } +} + +ProviderContainer _makeContainer(_FakeAccountApi api, _CountingBridge bridge) { + final mock = MockClient((req) async => http.Response('{"fake":"singbox-config"}', 200)); + return ProviderContainer(overrides: [ + tokenStoreProvider.overrideWithValue(const _LoggedInTokenStore()), + accountApiProvider.overrideWithValue(api), + effectiveNodeProvider.overrideWithValue(_readyNode), + vpnBridgeProvider.overrideWithValue(bridge), + connectApiFactoryProvider.overrideWithValue( + (token) => ConnectApi(baseUrl: 'http://test.local', authToken: token, client: mock), + ), + ]); +} + +void main() { + test('连接态 on 时保存档案 → 触发重连(disconnect→connect)', () async { + final api = _FakeAccountApi(); + final bridge = _CountingBridge(); + final c = _makeContainer(api, bridge); + addTearDown(c.dispose); + + await _waitLoggedIn(c); + await c.read(routingProfileProvider.future); + + // 先连上。 + c.read(connectionProvider.notifier).toggle(); + await _until(() => c.read(connectionProvider).phase == VpnPhase.on); + expect(bridge.startCount, 1, reason: '首次连接应调用一次 start()'); + + // 保存档案(连接态 on)→ 应触发一次重连(再调 start())。 + await c.read(routingProfileProvider.notifier).save(); + await _until(() => bridge.startCount >= 2); + expect(bridge.startCount, 2, reason: '保存成功且 on 态应重连一次(再次 start())'); + + // 重连期间应给出瞬态提示,并最终重新回到 on。 + await _until(() => c.read(connectionProvider).phase == VpnPhase.on); + expect(c.read(connectionProvider).phase, VpnPhase.on, reason: '重连后应回到 on'); + }); + + test('连接态 off 时保存档案 → 不触发重连', () async { + final api = _FakeAccountApi(); + final bridge = _CountingBridge(); + final c = _makeContainer(api, bridge); + addTearDown(c.dispose); + + await _waitLoggedIn(c); + await c.read(routingProfileProvider.future); + + expect(c.read(connectionProvider).phase, VpnPhase.off, reason: '未连接'); + + await c.read(routingProfileProvider.notifier).save(); + // 给可能的(不应发生的)异步重连留出时间窗口。 + await Future.delayed(const Duration(milliseconds: 100)); + + expect(bridge.startCount, 0, reason: 'off 态不应触发重连'); + expect(c.read(connectionProvider).phase, VpnPhase.off); + }); + + test('addRule 触发的乐观保存(on 态)同样重连', () async { + final api = _FakeAccountApi(); + final bridge = _CountingBridge(); + final c = _makeContainer(api, bridge); + addTearDown(c.dispose); + + await _waitLoggedIn(c); + await c.read(routingProfileProvider.future); + + c.read(connectionProvider.notifier).toggle(); + await _until(() => c.read(connectionProvider).phase == VpnPhase.on); + expect(bridge.startCount, 1); + + const rule = RoutingRule(type: 'domain_suffix', value: 'x.com', action: 'direct'); + await c.read(routingProfileProvider.notifier).addRule(rule); + + await _until(() => bridge.startCount >= 2); + expect(bridge.startCount, 2, reason: 'addRule 内部落盘同样应触发重连'); + }); +} diff --git a/design/i18n/strings.json b/design/i18n/strings.json index da9bb79..6e749c9 100644 --- a/design/i18n/strings.json +++ b/design/i18n/strings.json @@ -313,6 +313,14 @@ "ru": "Сеть нестабильна, переподключение…", "es": "Red inestable, reconectando…" }, + "routingRulesReconnecting": { + "zh": "规则已更新,正在重连…", + "en": "Rules updated, reconnecting…", + "ja": "ルールが更新されました。再接続中…", + "ko": "규칙이 업데이트되어 다시 연결 중…", + "ru": "Правила обновлены, переподключение…", + "es": "Reglas actualizadas, reconectando…" + }, "smartSub": { "zh": "根据当前网络环境,自动选择最优节点", "en": "Picks the best node for your network",