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
+39 -4
View File
@@ -18,9 +18,10 @@ const _items = [
PayCatalogItem(sku: 'pro_year', plan: 'pro', days: 366, priceMinor: 19999, currency: 'CNY'),
];
/// 轮询生命周期测试专用假 API:记 orderStatus 调用次数,其余按最小实现返回。
/// 轮询生命周期测试专用假 API:记 orderStatus / cancel 调用次数,其余按最小实现返回。
class _PollingFakePaymentApi implements PaymentApi {
int statusCalls = 0;
int cancelCalls = 0;
@override
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'}));
@override
Future<void> cancel(String orderNo) async {}
Future<void> cancel(String orderNo) async {
cancelCalls++;
}
}
void main() {
@@ -134,7 +137,7 @@ void main() {
expect(find.text(t.payDone), findsOneWidget);
});
testWidgets('支付页 dispose 后停止轮询(离开页面不再调 orderStatus)', (tester) async {
testWidgets('支付页 dispose 后停止轮询,但不取消订单(离开页面≠放弃订单)', (tester) async {
final api = _PollingFakePaymentApi();
final container = ProviderContainer(overrides: [paymentApiProvider.overrideWithValue(api)]);
addTearDown(container.dispose);
@@ -161,11 +164,43 @@ void main() {
final callsWhileMounted = api.statusCalls;
expect(callsWhileMounted, greaterThan(0));
// 卸载支付页(触发 dispose)→ 停轮询,之后不应再新增 orderStatus 调用。
// 卸载支付页(触发 dispose)→ 停轮询,之后不应再新增 orderStatus 调用。
showPage.value = false;
await tester.pump();
await tester.pump(const Duration(seconds: 4));
await tester.pump(const Duration(seconds: 4));
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');
});
}