diff --git a/client/lib/screens/orders_page.dart b/client/lib/screens/orders_page.dart index 10b2445..351fbd9 100644 --- a/client/lib/screens/orders_page.dart +++ b/client/lib/screens/orders_page.dart @@ -1,7 +1,8 @@ // orders_page.dart — 我的订单(列表 → 详情两屏,单 widget 内切换)。 // // 数据走本地台账(ordersProvider / orderDetailProvider);金额/状态文案全经 AppText。 -// App 内无支付表单——「继续支付 / 重新购买」只引导到购买页(PurchaseScreen)。 +// App 内无支付表单——「继续支付」= 恢复 created 单的原支付会话(paymentFlowProvider.resume, +// 走 retry,不新下单);「重新购买」(expired/canceled/paid 复购)才引导到购买页(PurchaseScreen)。 // 规格参考 design/prototype/screens/orders.html。 import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -13,10 +14,12 @@ import '../models/payment.dart'; import '../pangolin_theme.dart'; import '../state/navigation_provider.dart'; import '../state/orders_provider.dart'; +import '../state/payment_provider.dart'; import '../widgets/page_body.dart'; import '../widgets/sub_scaffold.dart'; import '../widgets/pangolin_button.dart'; import '../widgets/pangolin_icons.dart'; +import '../widgets/pangolin_toast.dart'; import '../widgets/status_pill.dart'; import 'payment_page.dart'; import 'purchase_page.dart'; @@ -211,7 +214,8 @@ class _OrderDetailView extends ConsumerWidget { final bool embedded; final VoidCallback onBack; - // 「继续支付 / 重新购买」→ 购买页(desktop 内容区下钻;mobile/tablet 全屏 push)。 + // 「重新购买」(expired/canceled 重下单;paid 复购)→ 购买页 + // (desktop 内容区下钻;mobile/tablet 全屏 push)。 void _goPurchase(BuildContext context, WidgetRef ref) { final isDesktop = context.formFactor == FormFactor.desktop; if (isDesktop) { @@ -238,6 +242,39 @@ class _OrderDetailView extends ConsumerWidget { } } + // 「继续支付」(created 单)→ 恢复原单支付会话(retry),**不**新下单、不经购买页。 + // 成功:awaitingPayment → 打开支付页(desktop 内容区下钻;mobile/tablet 全屏 push)。 + // 失败(含 409 ORDER_NOT_PENDING 自纠——订单其实已变化,常见已被 webhook 开通): + // 不导航,toast 报错 + 失效列表/详情,让页面刷出订单最新状态。 + Future _resumePay(BuildContext context, WidgetRef ref) async { + await ref.read(paymentFlowProvider.notifier).resume(order.orderNo, order.method); + if (!context.mounted) return; + final flow = ref.read(paymentFlowProvider); + if (flow.phase == PaymentPhase.failed) { + final msg = (t.lang == AppLang.zh ? flow.errorZh : flow.errorEn) ?? t.orderContinuePay; + showPangolinToast(context, msg); + ref.invalidate(ordersProvider); + ref.invalidate(orderDetailProvider(order.orderNo)); + return; + } + final isDesktop = context.formFactor == FormFactor.desktop; + if (isDesktop) { + ref.read(navViewProvider.notifier).state = NavView.payment; + } else { + Navigator.of(context).push(MaterialPageRoute( + builder: (_) => PaymentScreen( + t: t, + onDone: () { + Navigator.of(context).pop(); + // 支付页「完成/取消」回到订单详情:失效列表 + 本单详情,拉新状态。 + ref.invalidate(ordersProvider); + ref.invalidate(orderDetailProvider(order.orderNo)); + }, + ), + )); + } + } + Future _copy(BuildContext context, String text) async { await Clipboard.setData(ClipboardData(text: text)); if (context.mounted) { @@ -308,7 +345,7 @@ class _OrderDetailView extends ConsumerWidget { final list = []; switch (o.status) { case 'created': - list.add(PangolinButton(label: t.orderContinuePay, expand: true, onPressed: () => _goPurchase(context, ref))); + list.add(PangolinButton(label: t.orderContinuePay, expand: true, onPressed: () => _resumePay(context, ref))); // 过期单 pay 会话已失效,点击是重新下单而非续付 → 用「重新购买」避免歧义。 case 'expired': case 'canceled': diff --git a/client/lib/state/payment_provider.dart b/client/lib/state/payment_provider.dart index 36a26e5..b8ecfbf 100644 --- a/client/lib/state/payment_provider.dart +++ b/client/lib/state/payment_provider.dart @@ -112,6 +112,41 @@ class PaymentFlowController extends StateNotifier { } } + /// 恢复 `created` 单的支付会话(订单页「继续支付」)——**不是**新下单。 + /// 走 retry 拿回(或续出)同一 orderNo 的支付会话;409 ORDER_NOT_PENDING 说明 + /// 订单在这期间已变化(常见:webhook 已到账但列表还没刷新)→ 落 failed 自纠, + /// 由调用方(订单页)据此刷新列表,用户会看到订单其实已开通,不再重复下单。 + Future resume(String orderNo, String method) async { + _stopPolling(); + state = PaymentFlowState(phase: PaymentPhase.creating, method: method); + try { + final next = await _ref! + .read(paymentApiProvider) + .retry(orderNo, method: method, metadata: _metadata(method)); + if (!mounted) return; + state = state.copyWith(phase: PaymentPhase.awaitingPayment, order: next); + _startPolling(); + } on AuthApiException catch (e) { + if (!mounted) return; + if (e.code == 'ORDER_NOT_PENDING') { + state = state.copyWith( + phase: PaymentPhase.failed, + errorZh: '订单状态已变化,请刷新查看最新状态', + errorEn: 'Order status has changed, please refresh to see the latest status', + ); + return; + } + state = state.copyWith(phase: PaymentPhase.failed, errorZh: e.messageZh, errorEn: e.messageEn); + } catch (_) { + if (!mounted) return; + state = state.copyWith( + phase: PaymentPhase.failed, + errorZh: '下单失败,请检查网络后重试', + errorEn: 'Failed to create order, please check your network and retry', + ); + } + } + /// 换支付方式:先 retry;pay 回 409 CURRENCY_MISMATCH(跨结算币种)时, /// 按契约「换币种必须新单」→ cancel 旧单 + 新 method 重新下单。 Future switchMethod(String method) async { diff --git a/client/test/unit/payment_flow_test.dart b/client/test/unit/payment_flow_test.dart index b151449..d9ff3de 100644 --- a/client/test/unit/payment_flow_test.dart +++ b/client/test/unit/payment_flow_test.dart @@ -7,10 +7,11 @@ import 'package:pangolin_vpn/state/payment_provider.dart'; class _FakePaymentApi implements PaymentApi { _FakePaymentApi(); - int createCalls = 0, cancelCalls = 0, statusCalls = 0; + int createCalls = 0, cancelCalls = 0, statusCalls = 0, retryCalls = 0; bool activated = false; Exception? retryError; String lastMethod = ''; + String lastOrderNo = ''; @override Future> catalog() async => const []; @@ -35,6 +36,9 @@ class _FakePaymentApi implements PaymentApi { @override Future retry(String orderNo, {required String method, Map? metadata}) async { + retryCalls++; + lastMethod = method; + lastOrderNo = orderNo; if (retryError != null) throw retryError!; return PayOrder(orderNo: orderNo, session: PaySession(renderType: 'redirect', payload: const {'url': 'https://y'}, expiresAt: null)); } @@ -100,4 +104,35 @@ void main() { expect(api.lastMethod, 'alipay'); expect(c.read(paymentFlowProvider).order?.orderNo, 'pay2'); }); + + test('resume() 成功 → retry 恢复原单会话(不新下单),phase=awaitingPayment', () async { + final api = _FakePaymentApi(); + final c = _container(api); + final ctl = c.read(paymentFlowProvider.notifier); + + await ctl.resume('o-month', 'alipay'); + + expect(api.retryCalls, 1); + expect(api.createCalls, 0, reason: '继续支付不应新下单'); + expect(api.lastOrderNo, 'o-month'); + expect(api.lastMethod, 'alipay'); + expect(c.read(paymentFlowProvider).phase, PaymentPhase.awaitingPayment); + expect(c.read(paymentFlowProvider).order?.orderNo, 'o-month'); + }); + + test('resume() 遇 409 ORDER_NOT_PENDING(订单其实已变化)→ failed 自纠,不 crash', () async { + final api = _FakePaymentApi(); + final c = _container(api); + final ctl = c.read(paymentFlowProvider.notifier); + api.retryError = const AuthApiException( + statusCode: 409, messageZh: 'x', messageEn: 'x', code: 'ORDER_NOT_PENDING'); + + await ctl.resume('o-month', 'alipay'); + + expect(c.read(paymentFlowProvider).phase, PaymentPhase.failed); + expect(c.read(paymentFlowProvider).errorZh, isNotNull); + expect(c.read(paymentFlowProvider).errorZh, isNotEmpty); + expect(c.read(paymentFlowProvider).errorEn, isNotNull); + expect(c.read(paymentFlowProvider).errorEn, isNotEmpty); + }); }