From 55f6b1a19e6ddae1ab41dc619e344216fbadb3b6 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Sat, 5 Sep 2026 19:52:43 +0800 Subject: [PATCH] =?UTF-8?q?fix(client/pay):=20=E4=BC=98=E5=8C=96=E4=BB=98?= =?UTF-8?q?=E6=AC=BE=E6=B5=81=E7=A8=8B=E4=B8=A4=E5=A4=84=E4=BD=93=E6=84=9F?= =?UTF-8?q?=E2=80=94=E2=80=94=E7=82=B9=E5=87=BB=E5=8D=B3=E6=97=B6=E5=8F=8D?= =?UTF-8?q?=E9=A6=88=20+=20=E5=88=B0=E8=B4=A6=E6=9B=B4=E5=BF=AB=E5=8F=8D?= =?UTF-8?q?=E6=98=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复盘反馈:①点「立即购买」有一点卡顿 ②付完刷新不及时、等了十几秒。 ① 卡顿:_buy 原本 await createOrder 网络往返、返回才导航,期间购买页零反馈 → 改为 start()(同步先置 creating)后立刻导航到支付页,用户点击即见页面切换+转圈 (支付页已有 creating/failed/succeeded 全相位渲染);加 _buying 防连点(避免下双单)。 ② 刷新慢(客户端可控部分): - _startPolling 首拍立即发(原 Timer.periodic 要干等一个周期才首查); - 新增 AppLifecycleListener,付款后从微信/支付宝返回 app 即刻补查一拍 (等待付款期切后台,iOS 会挂起 Dart Timer,回前台首拍可能迟到十几秒——主因); - 轮询间隔 3s→2s。 测试:payment_flow 新增「start 立即补一拍→已到账不等周期直接 succeeded」, 共 7 个单测 + 支付页 widget 全绿;flutter analyze 干净。 注:十几秒还有服务端一半——GetOrder 的 activated 只认本地 row=paid(仅 webhook 落), 即便主动查 pay 网关已 paid 也不即时开通。reconcile-on-read(查单时网关已成功即幂等 settle) 是更大且触及结算逻辑的改动,另议。 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01FEVUXAbFT6bF1Qw27RHWoD --- client/lib/screens/purchase_page.dart | 28 +++++++++++++------------ client/lib/state/payment_provider.dart | 23 +++++++++++++++----- client/test/unit/payment_flow_test.dart | 13 ++++++++++++ 3 files changed, 46 insertions(+), 18 deletions(-) diff --git a/client/lib/screens/purchase_page.dart b/client/lib/screens/purchase_page.dart index 9317bb7..0264a1d 100644 --- a/client/lib/screens/purchase_page.dart +++ b/client/lib/screens/purchase_page.dart @@ -12,7 +12,6 @@ import '../state/payment_provider.dart'; import '../widgets/page_body.dart'; import '../widgets/pangolin_icons.dart'; import '../widgets/sub_scaffold.dart'; -import '../widgets/pangolin_toast.dart'; import '../widgets/plan_card.dart'; import '../widgets/seg_switch.dart'; @@ -39,7 +38,6 @@ class PurchaseScreen extends ConsumerStatefulWidget { class _PurchaseScreenState extends ConsumerState { AppText get t => widget.t; - bool get _zh => t.lang == AppLang.zh; // 支付渠道:默认 USDT·加密货币。切换即刷新套餐卡显价币种。 PayChannel _channel = PayChannel.usdt; @@ -51,18 +49,22 @@ class _PurchaseScreenState extends ConsumerState { _ => sku, }; + bool _buying = false; // 防连点:导航切走前的窗口内挡住第二次 tap,避免下双单 + Future _buy(PayCatalogItem item) async { - await ref.read(paymentFlowProvider.notifier).start(item, _channel.method); - if (!mounted) return; - final st = ref.read(paymentFlowProvider); - if (st.phase == PaymentPhase.awaitingPayment) { - widget.onOrderCreated?.call(); - } else if (st.phase == PaymentPhase.failed) { - // 下单失败给可见 toast,别让用户以为「点了没反应」。 - showPangolinToast( - context, - (_zh ? st.errorZh : st.errorEn) ?? t.orderCreateFailed, - ); + if (_buying) return; + _buying = true; + final notifier = ref.read(paymentFlowProvider.notifier); + // start() 同步先把相位置 creating,随即导航到支付页 —— 用户点「立即购买」即刻 + // 看到页面切换 + 转圈,不再在购买页干等 createOrder 网络往返(原「点了卡一下」的 + // 错觉根因)。下单结果(awaitingPayment/failed)由支付页自渲染(failed 带重试/ + // 返回),这里不再回吐 toast。 + final fut = notifier.start(item, _channel.method); + widget.onOrderCreated?.call(); + try { + await fut; + } finally { + _buying = false; } } diff --git a/client/lib/state/payment_provider.dart b/client/lib/state/payment_provider.dart index 394ab82..27770b1 100644 --- a/client/lib/state/payment_provider.dart +++ b/client/lib/state/payment_provider.dart @@ -5,6 +5,7 @@ import 'dart:async'; import 'dart:io' show Platform; import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart' show AppLifecycleListener; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../models/payment.dart'; @@ -63,19 +64,27 @@ class PaymentFlowState { } class PaymentFlowController extends StateNotifier { - PaymentFlowController(Ref ref) : _ref = ref, super(const PaymentFlowState()); + PaymentFlowController(Ref ref) : _ref = ref, super(const PaymentFlowState()) { + // 用户付款后从微信/支付宝返回 app → 立刻补查一次单,不必干等下一个轮询拍。 + // (等待付款期切后台去付款,iOS 会挂起 Dart Timer,回前台首拍可能迟到十几秒—— + // 正是「付完刷新不及时」的主因;回前台即查把这段砍到一次网络往返。) + _lifecycle = AppLifecycleListener(onResume: () { + if (state.phase == PaymentPhase.awaitingPayment) unawaited(pollOnce()); + }); + } - /// 测试专用 fixture:直接给定固定状态,`_ref` 留空、不发网络、不起 Timer - /// (widget 测试用来渲染某一支付阶段的界面,不驱动状态机)。[cancel] 对 - /// `_ref == null` 短路安全——支付页 dispose 时无条件可调,不炸 fixture。 + /// 测试专用 fixture:直接给定固定状态,`_ref` 留空、不发网络、不起 Timer、 + /// 不挂生命周期监听(widget 测试用来渲染某一支付阶段的界面,不驱动状态机)。 + /// [cancel] 对 `_ref == null` 短路安全——支付页 dispose 时无条件可调,不炸 fixture。 @visibleForTesting PaymentFlowController.fixed(super.state) : _ref = null; final Ref? _ref; Timer? _poll; + AppLifecycleListener? _lifecycle; bool _polling = false; // 再入保护(轮询慢于间隔时跳过本拍) - static const _pollInterval = Duration(seconds: 3); + static const _pollInterval = Duration(seconds: 2); bool get _isMobile { if (kIsWeb) return false; @@ -229,6 +238,9 @@ class PaymentFlowController extends StateNotifier { void _startPolling() { _poll?.cancel(); + // 立刻查一次:Timer.periodic 首拍要等满一个间隔才发,下单/续单后先补一拍, + // 把「已付款但还没轮到第一拍」的空窗抹掉。 + unawaited(pollOnce()); _poll = Timer.periodic(_pollInterval, (_) => pollOnce()); } @@ -239,6 +251,7 @@ class PaymentFlowController extends StateNotifier { @override void dispose() { + _lifecycle?.dispose(); _stopPolling(); super.dispose(); } diff --git a/client/test/unit/payment_flow_test.dart b/client/test/unit/payment_flow_test.dart index 91ac817..427f84b 100644 --- a/client/test/unit/payment_flow_test.dart +++ b/client/test/unit/payment_flow_test.dart @@ -71,6 +71,19 @@ void main() { expect(c.read(paymentFlowProvider).phase, PaymentPhase.succeeded); }); + test('start() 立即补一拍轮询:付款已到账则不等首个周期直接 succeeded', () async { + // 修复「付完刷新不及时」:_startPolling 首拍立即发,而非干等一个 _pollInterval。 + final api = _FakePaymentApi()..activated = true; + final c = _container(api); + final ctl = c.read(paymentFlowProvider.notifier); + await ctl.start(item, 'crypto'); + // 首拍是 unawaited,给微任务队列一次排空机会再断言。 + await Future.delayed(Duration.zero); + expect(api.statusCalls, greaterThanOrEqualTo(1), + reason: '下单后应立刻补查一拍,不干等首个 Timer 周期'); + expect(c.read(paymentFlowProvider).phase, PaymentPhase.succeeded); + }); + test('stopPolling() 只停 Timer,不远程 cancel、不重置 state', () async { final api = _FakePaymentApi(); final c = _container(api);