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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user