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:
wangjia
2026-07-11 00:06:03 +08:00
parent a5fff28134
commit 262773a9de
8 changed files with 104 additions and 16 deletions
+1
View File
@@ -219,6 +219,7 @@ abstract class AppText {
String get copied; // 已复制 / Copied String get copied; // 已复制 / Copied
String get openAlipay; // 打开支付宝支付 / Pay with Alipay String get openAlipay; // 打开支付宝支付 / Pay with Alipay
String get openAlipayHint; // 完成支付后返回,本页会自动刷新 / Return after paying; this page refreshes automatically 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 awaitingPayment; // 等待付款 / Awaiting payment
String get paySucceeded; // 已开通 / Activated String get paySucceeded; // 已开通 / Activated
String get payExpiresAt; // 有效期至 / Valid until String get payExpiresAt; // 有效期至 / Valid until
+2
View File
@@ -370,6 +370,8 @@ class StringsEn extends AppText {
@override @override
String get openAlipayHint => 'Return after paying; this page refreshes automatically'; String get openAlipayHint => 'Return after paying; this page refreshes automatically';
@override @override
String get openAlipayFailed => "Couldn't open Alipay; check it's installed or try again";
@override
String get awaitingPayment => 'Awaiting payment'; String get awaitingPayment => 'Awaiting payment';
@override @override
String get paySucceeded => 'Activated'; String get paySucceeded => 'Activated';
+2
View File
@@ -364,6 +364,8 @@ class StringsZh extends AppText {
@override @override
String get openAlipayHint => '完成支付后返回,本页会自动刷新'; String get openAlipayHint => '完成支付后返回,本页会自动刷新';
@override @override
String get openAlipayFailed => '无法打开支付宝,请检查是否已安装或稍后重试';
@override
String get awaitingPayment => '等待付款'; String get awaitingPayment => '等待付款';
@override @override
String get paySucceeded => '已开通'; String get paySucceeded => '已开通';
+34 -11
View File
@@ -44,21 +44,23 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
@override @override
void dispose() { void dispose() {
// paymentFlowProvider 非 autoDispose、轮询 Timer 无 expiry 自停:离开支付页时, // paymentFlowProvider 非 autoDispose、轮询 Timer 无 expiry 自停:离开支付页时,
// 若订单仍在等待付款(唯一存在活跃 Timer 的相位),cancel() 停轮询 + 视同放弃本单 // 若订单仍在等待付款(唯一存在活跃 Timer 的相位),只停轮询 Timer
// (与「取消订单」按钮同一路径),避免永久轮询。 // (stopPolling(),不发远程 cancel、不改 state)——用户点其他导航 tab 离开支付页
// 不等于放弃订单,订单在后台仍然存活,回来时(或轮询式重进)状态还在。真正的
// 「放弃本单」只有显式点「取消订单」按钮时才走 cancel()(远程 cancel + 状态归 idle)。
// //
// 本 widget 自己正是 paymentFlowProvider 的 watcher:在自身 dispose() 里同步触发 // 本 widget 自己正是 paymentFlowProvider 的 watcher:在自身 dispose() 里同步触发
// cancel() 的 state= 会让 Riverpod 尝试 markNeedsBuild 一个此刻已 defunct 的 // state= 会让 Riverpod 尝试 markNeedsBuild 一个此刻已 defunct 的 Element,
// Element,炸框架断言;Riverpod 把该断言直接报给 zone(不走 Future 错误通道), // 炸框架断言;Riverpod 把该断言直接报给 zone(不走 Future 错误通道),
// catchError 接不住。用 scheduleMicrotask 挪到本次 unmount 收尾之后再执行—— // catchError 接不住。stopPolling() 本身不写 state,理论上不触发该问题,但仍用
// 3s 轮询周期下,微任务级别的延迟不影响“停轮询”这个目的。执行时点若容器已把 // scheduleMicrotask 挪到本次 unmount 收尾之后再执行,统一收口、避免任何时序脆弱性。
// controller 一并 dispose(如整页 ProviderScope 随之销毁),用公开的 `mounted` 短路, // 执行时点若容器已把 controller 一并 dispose(如整页 ProviderScope 随之销毁),用
// 不调用已销毁实例。 // 公开的 `mounted` 短路,不调用已销毁实例。
if (_phase == PaymentPhase.awaitingPayment) { if (_phase == PaymentPhase.awaitingPayment) {
final ctl = _ctl; final ctl = _ctl;
if (ctl != null) { if (ctl != null) {
scheduleMicrotask(() { 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); 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(真相源既有样式,非新造)。 // 卡片配方 = account_page.dart::_Card(真相源既有样式,非新造)。
Widget _card(PangolinScheme c, {required Widget child}) => Container( Widget _card(PangolinScheme c, {required Widget child}) => Container(
width: double.infinity, width: double.infinity,
@@ -129,8 +153,7 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
label: t.openAlipay, label: t.openAlipay,
icon: PangolinIcons.externalLink, icon: PangolinIcons.externalLink,
expand: true, expand: true,
onPressed: () => launchUrl(Uri.parse('${payload['url'] ?? ''}'), onPressed: () => _openRedirectUrl(context, '${payload['url'] ?? ''}'),
mode: LaunchMode.externalApplication),
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
Text(t.openAlipayHint, style: PangolinText.caption.copyWith(color: c.fg3)), Text(t.openAlipayHint, style: PangolinText.caption.copyWith(color: c.fg3)),
+1 -1
View File
@@ -70,7 +70,7 @@ class PurchaseScreen extends ConsumerWidget {
if (method == null || !context.mounted) return; if (method == null || !context.mounted) return;
await ref.read(paymentFlowProvider.notifier).start(item, method); await ref.read(paymentFlowProvider.notifier).start(item, method);
if (!context.mounted) return; if (!context.mounted) return;
if (ref.read(paymentFlowProvider).phase != PaymentPhase.idle) { if (ref.read(paymentFlowProvider).phase == PaymentPhase.awaitingPayment) {
onOrderCreated?.call(); onOrderCreated?.call();
} }
} }
+5
View File
@@ -138,6 +138,11 @@ class PaymentFlowController extends StateNotifier<PaymentFlowState> {
if (mounted) state = const PaymentFlowState(); if (mounted) state = const PaymentFlowState();
} }
/// 只停轮询 Timer,**不**远程 cancel、**不**改 state——供支付页 dispose()
/// 用(离开页面 ≠ 放弃订单)。与 [cancel] 区分:后者是「取消订单」按钮的显式
/// 放弃路径(远程 cancel + 状态归 idle)。
void stopPolling() => _stopPolling();
/// 单拍轮询(Timer 驱动;测试直接调用,不依赖真实时钟)。 /// 单拍轮询(Timer 驱动;测试直接调用,不依赖真实时钟)。
Future<void> pollOnce() async { Future<void> pollOnce() async {
final order = state.order; final order = state.order;
+20
View File
@@ -64,6 +64,26 @@ void main() {
expect(c.read(paymentFlowProvider).phase, PaymentPhase.succeeded); 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<void>.delayed(const Duration(milliseconds: 10));
expect(api.statusCalls, callsAfterStop);
});
test('switchMethod 遇 CURRENCY_MISMATCH → cancel 旧单 + 新 method 重新下单', () async { test('switchMethod 遇 CURRENCY_MISMATCH → cancel 旧单 + 新 method 重新下单', () async {
final api = _FakePaymentApi(); final api = _FakePaymentApi();
final c = _container(api); final c = _container(api);
+39 -4
View File
@@ -18,9 +18,10 @@ const _items = [
PayCatalogItem(sku: 'pro_year', plan: 'pro', days: 366, priceMinor: 19999, currency: 'CNY'), PayCatalogItem(sku: 'pro_year', plan: 'pro', days: 366, priceMinor: 19999, currency: 'CNY'),
]; ];
/// 轮询生命周期测试专用假 API:记 orderStatus 调用次数,其余按最小实现返回。 /// 轮询生命周期测试专用假 API:记 orderStatus / cancel 调用次数,其余按最小实现返回。
class _PollingFakePaymentApi implements PaymentApi { class _PollingFakePaymentApi implements PaymentApi {
int statusCalls = 0; int statusCalls = 0;
int cancelCalls = 0;
@override @override
Future<List<PayCatalogItem>> catalog() async => const []; Future<List<PayCatalogItem>> catalog() async => const [];
@@ -43,7 +44,9 @@ class _PollingFakePaymentApi implements PaymentApi {
const PayOrder(orderNo: 'pay1', session: PaySession(renderType: 'redirect', payload: {'url': 'https://x'})); const PayOrder(orderNo: 'pay1', session: PaySession(renderType: 'redirect', payload: {'url': 'https://x'}));
@override @override
Future<void> cancel(String orderNo) async {} Future<void> cancel(String orderNo) async {
cancelCalls++;
}
} }
void main() { void main() {
@@ -134,7 +137,7 @@ void main() {
expect(find.text(t.payDone), findsOneWidget); expect(find.text(t.payDone), findsOneWidget);
}); });
testWidgets('支付页 dispose 后停止轮询(离开页面不再调 orderStatus)', (tester) async { testWidgets('支付页 dispose 后停止轮询,但不取消订单(离开页面≠放弃订单)', (tester) async {
final api = _PollingFakePaymentApi(); final api = _PollingFakePaymentApi();
final container = ProviderContainer(overrides: [paymentApiProvider.overrideWithValue(api)]); final container = ProviderContainer(overrides: [paymentApiProvider.overrideWithValue(api)]);
addTearDown(container.dispose); addTearDown(container.dispose);
@@ -161,11 +164,43 @@ void main() {
final callsWhileMounted = api.statusCalls; final callsWhileMounted = api.statusCalls;
expect(callsWhileMounted, greaterThan(0)); expect(callsWhileMounted, greaterThan(0));
// 卸载支付页(触发 dispose)→ 停轮询,之后不应再新增 orderStatus 调用。 // 卸载支付页(触发 dispose)→ 停轮询,之后不应再新增 orderStatus 调用。
showPage.value = false; showPage.value = false;
await tester.pump(); await tester.pump();
await tester.pump(const Duration(seconds: 4)); await tester.pump(const Duration(seconds: 4));
await tester.pump(const Duration(seconds: 4)); await tester.pump(const Duration(seconds: 4));
expect(api.statusCalls, callsWhileMounted, reason: '支付页卸载后轮询应已停止'); 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');
}); });
} }