fix(client): dispose 不再静默取消支付订单,redirect launchUrl 加失败兜底
- 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() 单元测试。
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -364,6 +364,8 @@ class StringsZh extends AppText {
|
||||
@override
|
||||
String get openAlipayHint => '完成支付后返回,本页会自动刷新';
|
||||
@override
|
||||
String get openAlipayFailed => '无法打开支付宝,请检查是否已安装或稍后重试';
|
||||
@override
|
||||
String get awaitingPayment => '等待付款';
|
||||
@override
|
||||
String get paySucceeded => '已开通';
|
||||
|
||||
@@ -44,21 +44,23 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
|
||||
@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<PaymentScreen> {
|
||||
if (context.mounted) showPangolinToast(context, t.copied);
|
||||
}
|
||||
|
||||
// redirect 分支拉起外部 app(支付宝):桌面无支付宝、或平台层 PlatformException 时
|
||||
// launchUrl 会失败/抛异常——canLaunchUrl 判定 + try-catch 双保险,失败给可见 toast
|
||||
// 而非静默无反应。
|
||||
Future<void> _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<PaymentScreen> {
|
||||
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)),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,6 +138,11 @@ class PaymentFlowController extends StateNotifier<PaymentFlowState> {
|
||||
if (mounted) state = const PaymentFlowState();
|
||||
}
|
||||
|
||||
/// 只停轮询 Timer,**不**远程 cancel、**不**改 state——供支付页 dispose()
|
||||
/// 用(离开页面 ≠ 放弃订单)。与 [cancel] 区分:后者是「取消订单」按钮的显式
|
||||
/// 放弃路径(远程 cancel + 状态归 idle)。
|
||||
void stopPolling() => _stopPolling();
|
||||
|
||||
/// 单拍轮询(Timer 驱动;测试直接调用,不依赖真实时钟)。
|
||||
Future<void> pollOnce() async {
|
||||
final order = state.order;
|
||||
|
||||
Reference in New Issue
Block a user