fix(client/pay): 优化付款流程两处体感——点击即时反馈 + 到账更快反映

复盘反馈:①点「立即购买」有一点卡顿 ②付完刷新不及时、等了十几秒。

① 卡顿:_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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEVUXAbFT6bF1Qw27RHWoD
This commit is contained in:
wangjia
2026-09-05 19:52:43 +08:00
parent 721371d806
commit 55f6b1a19e
3 changed files with 46 additions and 18 deletions
+15 -13
View File
@@ -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<PurchaseScreen> {
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<PurchaseScreen> {
_ => sku,
};
bool _buying = false; // 防连点:导航切走前的窗口内挡住第二次 tap,避免下双单
Future<void> _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;
}
}
+18 -5
View File
@@ -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<PaymentFlowState> {
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<PaymentFlowState> {
void _startPolling() {
_poll?.cancel();
// 立刻查一次:Timer.periodic 首拍要等满一个间隔才发,下单/续单后先补一拍,
// 把「已付款但还没轮到第一拍」的空窗抹掉。
unawaited(pollOnce());
_poll = Timer.periodic(_pollInterval, (_) => pollOnce());
}
@@ -239,6 +251,7 @@ class PaymentFlowController extends StateNotifier<PaymentFlowState> {
@override
void dispose() {
_lifecycle?.dispose();
_stopPolling();
super.dispose();
}
+13
View File
@@ -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<void>.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);