From 262773a9de335c9b42b3d38a6584727fe978e172 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Sat, 11 Jul 2026 00:06:03 +0800 Subject: [PATCH] =?UTF-8?q?fix(client):=20dispose=20=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E9=9D=99=E9=BB=98=E5=8F=96=E6=B6=88=E6=94=AF=E4=BB=98=E8=AE=A2?= =?UTF-8?q?=E5=8D=95,redirect=20launchUrl=20=E5=8A=A0=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E5=85=9C=E5=BA=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PaymentFlowController 新增 stopPolling():只停轮询 Timer,不发远程 cancel、 不改 state;PaymentScreen.dispose() 改调它,不再复用 cancel()——离开支付页 (切左导航 tab)不再等同于放弃订单,cancel() 只留给「取消订单」按钮的显式路径。 - redirect 分支 launchUrl 前加 canLaunchUrl 判定 + try-catch,失败时用 showPangolinToast 给可见提示(新增 l10n openAlipayFailed,zh/en 均补)。 - purchase_page.dart::_choose 下单后严格判 phase == awaitingPayment 才跳转 支付页,建单失败(failed)不再误跳。 - 回归测试:payment_pages_test.dart 补两条用例区分 dispose(不触发 cancel、 state 不归 idle)与「取消订单」按钮(远程 cancel + state 归 idle); payment_flow_test.dart 补 stopPolling() 单元测试。 --- client/lib/l10n/app_text.dart | 1 + client/lib/l10n/strings_en.dart | 2 + client/lib/l10n/strings_zh.dart | 2 + client/lib/screens/payment_page.dart | 45 ++++++++++++++++------ client/lib/screens/purchase_page.dart | 2 +- client/lib/state/payment_provider.dart | 5 +++ client/test/unit/payment_flow_test.dart | 20 ++++++++++ client/test/widget/payment_pages_test.dart | 43 +++++++++++++++++++-- 8 files changed, 104 insertions(+), 16 deletions(-) diff --git a/client/lib/l10n/app_text.dart b/client/lib/l10n/app_text.dart index be4ecb6..46c2000 100644 --- a/client/lib/l10n/app_text.dart +++ b/client/lib/l10n/app_text.dart @@ -219,6 +219,7 @@ abstract class AppText { String get copied; // 已复制 / Copied String get openAlipay; // 打开支付宝支付 / Pay with Alipay String get openAlipayHint; // 完成支付后返回,本页会自动刷新 / Return after paying; this page refreshes automatically + String get openAlipayFailed; // 无法打开支付宝,请检查是否已安装或稍后重试 / Couldn't open Alipay; check it's installed or try again String get awaitingPayment; // 等待付款 / Awaiting payment String get paySucceeded; // 已开通 / Activated String get payExpiresAt; // 有效期至 / Valid until diff --git a/client/lib/l10n/strings_en.dart b/client/lib/l10n/strings_en.dart index 6f93355..9d556ca 100644 --- a/client/lib/l10n/strings_en.dart +++ b/client/lib/l10n/strings_en.dart @@ -370,6 +370,8 @@ class StringsEn extends AppText { @override String get openAlipayHint => 'Return after paying; this page refreshes automatically'; @override + String get openAlipayFailed => "Couldn't open Alipay; check it's installed or try again"; + @override String get awaitingPayment => 'Awaiting payment'; @override String get paySucceeded => 'Activated'; diff --git a/client/lib/l10n/strings_zh.dart b/client/lib/l10n/strings_zh.dart index 1ab4019..e2099ea 100644 --- a/client/lib/l10n/strings_zh.dart +++ b/client/lib/l10n/strings_zh.dart @@ -364,6 +364,8 @@ class StringsZh extends AppText { @override String get openAlipayHint => '完成支付后返回,本页会自动刷新'; @override + String get openAlipayFailed => '无法打开支付宝,请检查是否已安装或稍后重试'; + @override String get awaitingPayment => '等待付款'; @override String get paySucceeded => '已开通'; diff --git a/client/lib/screens/payment_page.dart b/client/lib/screens/payment_page.dart index b3a56c4..9d5be3b 100644 --- a/client/lib/screens/payment_page.dart +++ b/client/lib/screens/payment_page.dart @@ -44,21 +44,23 @@ class _PaymentScreenState extends ConsumerState { @override void dispose() { // paymentFlowProvider 非 autoDispose、轮询 Timer 无 expiry 自停:离开支付页时, - // 若订单仍在等待付款(唯一存在活跃 Timer 的相位),cancel() 停轮询 + 视同放弃本单 - // (与「取消订单」按钮同一路径),避免永久轮询。 + // 若订单仍在等待付款(唯一存在活跃 Timer 的相位),只停轮询 Timer + // (stopPolling(),不发远程 cancel、不改 state)——用户点其他导航 tab 离开支付页 + // 不等于放弃订单,订单在后台仍然存活,回来时(或轮询式重进)状态还在。真正的 + // 「放弃本单」只有显式点「取消订单」按钮时才走 cancel()(远程 cancel + 状态归 idle)。 // // 本 widget 自己正是 paymentFlowProvider 的 watcher:在自身 dispose() 里同步触发 - // cancel() 的 state= 会让 Riverpod 尝试 markNeedsBuild 一个此刻已 defunct 的 - // Element,炸框架断言;Riverpod 把该断言直接报给 zone(不走 Future 错误通道), - // 光 catchError 接不住。用 scheduleMicrotask 挪到本次 unmount 收尾之后再执行—— - // 3s 轮询周期下,微任务级别的延迟不影响“停轮询”这个目的。执行时点若容器已把 - // controller 一并 dispose(如整页 ProviderScope 随之销毁),用公开的 `mounted` 短路, - // 不调用已销毁实例。 + // state= 会让 Riverpod 尝试 markNeedsBuild 一个此刻已 defunct 的 Element, + // 炸框架断言;Riverpod 把该断言直接报给 zone(不走 Future 错误通道),光 + // catchError 接不住。stopPolling() 本身不写 state,理论上不触发该问题,但仍用 + // scheduleMicrotask 挪到本次 unmount 收尾之后再执行,统一收口、避免任何时序脆弱性。 + // 执行时点若容器已把 controller 一并 dispose(如整页 ProviderScope 随之销毁),用 + // 公开的 `mounted` 短路,不调用已销毁实例。 if (_phase == PaymentPhase.awaitingPayment) { final ctl = _ctl; if (ctl != null) { scheduleMicrotask(() { - if (ctl.mounted) ctl.cancel(); + if (ctl.mounted) ctl.stopPolling(); }); } } @@ -72,6 +74,28 @@ class _PaymentScreenState extends ConsumerState { if (context.mounted) showPangolinToast(context, t.copied); } + // redirect 分支拉起外部 app(支付宝):桌面无支付宝、或平台层 PlatformException 时 + // launchUrl 会失败/抛异常——canLaunchUrl 判定 + try-catch 双保险,失败给可见 toast + // 而非静默无反应。 + Future _openRedirectUrl(BuildContext context, String url) async { + final uri = Uri.tryParse(url); + if (uri == null) { + if (context.mounted) showPangolinToast(context, t.openAlipayFailed); + return; + } + try { + final can = await canLaunchUrl(uri); + if (!can) { + if (context.mounted) showPangolinToast(context, t.openAlipayFailed); + return; + } + final ok = await launchUrl(uri, mode: LaunchMode.externalApplication); + if (!ok && context.mounted) showPangolinToast(context, t.openAlipayFailed); + } catch (_) { + if (context.mounted) showPangolinToast(context, t.openAlipayFailed); + } + } + // 卡片配方 = account_page.dart::_Card(真相源既有样式,非新造)。 Widget _card(PangolinScheme c, {required Widget child}) => Container( width: double.infinity, @@ -129,8 +153,7 @@ class _PaymentScreenState extends ConsumerState { label: t.openAlipay, icon: PangolinIcons.externalLink, expand: true, - onPressed: () => launchUrl(Uri.parse('${payload['url'] ?? ''}'), - mode: LaunchMode.externalApplication), + onPressed: () => _openRedirectUrl(context, '${payload['url'] ?? ''}'), ), const SizedBox(height: 10), Text(t.openAlipayHint, style: PangolinText.caption.copyWith(color: c.fg3)), diff --git a/client/lib/screens/purchase_page.dart b/client/lib/screens/purchase_page.dart index 02f6bf2..357af97 100644 --- a/client/lib/screens/purchase_page.dart +++ b/client/lib/screens/purchase_page.dart @@ -70,7 +70,7 @@ class PurchaseScreen extends ConsumerWidget { if (method == null || !context.mounted) return; await ref.read(paymentFlowProvider.notifier).start(item, method); if (!context.mounted) return; - if (ref.read(paymentFlowProvider).phase != PaymentPhase.idle) { + if (ref.read(paymentFlowProvider).phase == PaymentPhase.awaitingPayment) { onOrderCreated?.call(); } } diff --git a/client/lib/state/payment_provider.dart b/client/lib/state/payment_provider.dart index 9cbbc7e..9060697 100644 --- a/client/lib/state/payment_provider.dart +++ b/client/lib/state/payment_provider.dart @@ -138,6 +138,11 @@ class PaymentFlowController extends StateNotifier { if (mounted) state = const PaymentFlowState(); } + /// 只停轮询 Timer,**不**远程 cancel、**不**改 state——供支付页 dispose() + /// 用(离开页面 ≠ 放弃订单)。与 [cancel] 区分:后者是「取消订单」按钮的显式 + /// 放弃路径(远程 cancel + 状态归 idle)。 + void stopPolling() => _stopPolling(); + /// 单拍轮询(Timer 驱动;测试直接调用,不依赖真实时钟)。 Future pollOnce() async { final order = state.order; diff --git a/client/test/unit/payment_flow_test.dart b/client/test/unit/payment_flow_test.dart index e5d21e8..6ebeb31 100644 --- a/client/test/unit/payment_flow_test.dart +++ b/client/test/unit/payment_flow_test.dart @@ -64,6 +64,26 @@ void main() { expect(c.read(paymentFlowProvider).phase, PaymentPhase.succeeded); }); + test('stopPolling() 只停 Timer,不远程 cancel、不重置 state', () async { + final api = _FakePaymentApi(); + final c = _container(api); + final ctl = c.read(paymentFlowProvider.notifier); + await ctl.start(item, 'crypto'); + expect(c.read(paymentFlowProvider).phase, PaymentPhase.awaitingPayment); + + ctl.stopPolling(); + + expect(api.cancelCalls, 0); + expect(c.read(paymentFlowProvider).phase, PaymentPhase.awaitingPayment); + expect(c.read(paymentFlowProvider).order?.orderNo, 'pay1'); + + // Timer 已停:再等一拍轮询周期,statusCalls 不应继续增长(pollOnce 只由 + // Timer 或测试显式调用驱动,这里断言的是"没有 Timer 在背后继续触发")。 + final callsAfterStop = api.statusCalls; + await Future.delayed(const Duration(milliseconds: 10)); + expect(api.statusCalls, callsAfterStop); + }); + test('switchMethod 遇 CURRENCY_MISMATCH → cancel 旧单 + 新 method 重新下单', () async { final api = _FakePaymentApi(); final c = _container(api); diff --git a/client/test/widget/payment_pages_test.dart b/client/test/widget/payment_pages_test.dart index 64dd82d..aec726e 100644 --- a/client/test/widget/payment_pages_test.dart +++ b/client/test/widget/payment_pages_test.dart @@ -18,9 +18,10 @@ const _items = [ PayCatalogItem(sku: 'pro_year', plan: 'pro', days: 366, priceMinor: 19999, currency: 'CNY'), ]; -/// 轮询生命周期测试专用假 API:只记 orderStatus 调用次数,其余按最小实现返回。 +/// 轮询生命周期测试专用假 API:记 orderStatus / cancel 调用次数,其余按最小实现返回。 class _PollingFakePaymentApi implements PaymentApi { int statusCalls = 0; + int cancelCalls = 0; @override Future> catalog() async => const []; @@ -43,7 +44,9 @@ class _PollingFakePaymentApi implements PaymentApi { const PayOrder(orderNo: 'pay1', session: PaySession(renderType: 'redirect', payload: {'url': 'https://x'})); @override - Future cancel(String orderNo) async {} + Future cancel(String orderNo) async { + cancelCalls++; + } } void main() { @@ -134,7 +137,7 @@ void main() { expect(find.text(t.payDone), findsOneWidget); }); - testWidgets('支付页 dispose 后停止轮询(离开页面不再调 orderStatus)', (tester) async { + testWidgets('支付页 dispose 后停止轮询,但不取消订单(离开页面≠放弃订单)', (tester) async { final api = _PollingFakePaymentApi(); final container = ProviderContainer(overrides: [paymentApiProvider.overrideWithValue(api)]); addTearDown(container.dispose); @@ -161,11 +164,43 @@ void main() { final callsWhileMounted = api.statusCalls; expect(callsWhileMounted, greaterThan(0)); - // 卸载支付页(触发 dispose)→ 停止轮询,之后不应再新增 orderStatus 调用。 + // 卸载支付页(触发 dispose)→ 只停轮询,之后不应再新增 orderStatus 调用。 showPage.value = false; await tester.pump(); await tester.pump(const Duration(seconds: 4)); await tester.pump(const Duration(seconds: 4)); expect(api.statusCalls, callsWhileMounted, reason: '支付页卸载后轮询应已停止'); + + // 关键回归断言:dispose 绝不能是「隐式取消订单」——不发远程 cancel, + // controller state 也不应被重置为 idle,订单在后台仍然存活。 + expect(api.cancelCalls, 0, reason: 'dispose 不应远程取消订单'); + expect(container.read(paymentFlowProvider).phase, PaymentPhase.awaitingPayment, + reason: 'dispose 后订单状态应保持 awaitingPayment,不应被静默重置为 idle'); + expect(container.read(paymentFlowProvider).order?.orderNo, 'pay1'); + }); + + testWidgets('支付页「取消订单」按钮:确实远程取消 + 状态归 idle(与 dispose 路径区分)', (tester) async { + final api = _PollingFakePaymentApi(); + final container = ProviderContainer(overrides: [paymentApiProvider.overrideWithValue(api)]); + addTearDown(container.dispose); + await container.read(paymentFlowProvider.notifier).start(_items.first, 'crypto'); + expect(container.read(paymentFlowProvider).phase, PaymentPhase.awaitingPayment); + + await tester.pumpWidget(UncontrolledProviderScope( + container: container, + child: MaterialApp( + debugShowCheckedModeBanner: false, + theme: PangolinTheme.light, + home: PaymentScreen(t: t, embedded: true), + ), + )); + await tester.pump(); + + await tester.tap(find.text(t.cancelOrder)); + await tester.pumpAndSettle(); + + expect(api.cancelCalls, 1, reason: '显式点「取消订单」应远程取消'); + expect(container.read(paymentFlowProvider).phase, PaymentPhase.idle, + reason: '显式取消后状态应归 idle'); }); }