fix(client/orders): 继续支付改为 retry 恢复原单会话(原误进购买页致重复下单);409 已付自纠刷新
ci-pangolin / Lint — shellcheck (pull_request) Successful in 12s
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (pull_request) Successful in 25s
ci-pangolin / Cleartext Scan — Android 禁明文 (pull_request) Successful in 20s
ci-pangolin / OpenAPI Sync Check (pull_request) Successful in 32s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (pull_request) Successful in 21s
ci-pangolin / Flutter — analyze + test (pull_request) Successful in 38s
ci-pangolin / Codegen Drift — token 生成物未漂移 (pull_request) Successful in 2s
ci-pangolin / DS-flow — 原型/跨端同源/代码色单源闸 (pull_request) Successful in 5s
ci-pangolin / Go — build + test (pull_request) Failing after 13s
ci-pangolin / E2E Smoke — L4 进程级端到端 (pull_request) Failing after 10s
ci-pangolin / Go — integration (mysql/redis testcontainers) (pull_request) Failing after 4m50s
ci-pangolin / Golden — 视觉回归 (全量:components/auth/desktop/tablet) (pull_request) Failing after 20s

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9G7E3wmAYL9KeYCVZVsqu
This commit is contained in:
wangjia
2026-07-13 11:29:25 +08:00
parent 07c339c18e
commit 92943258ab
3 changed files with 111 additions and 4 deletions
+40 -3
View File
@@ -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<void> _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<void> _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 = <Widget>[];
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':
+35
View File
@@ -112,6 +112,41 @@ class PaymentFlowController extends StateNotifier<PaymentFlowState> {
}
}
/// 恢复 `created` 单的支付会话(订单页「继续支付」)——**不是**新下单。
/// 走 retry 拿回(或续出)同一 orderNo 的支付会话;409 ORDER_NOT_PENDING 说明
/// 订单在这期间已变化(常见:webhook 已到账但列表还没刷新)→ 落 failed 自纠,
/// 由调用方(订单页)据此刷新列表,用户会看到订单其实已开通,不再重复下单。
Future<void> 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<void> switchMethod(String method) async {
+36 -1
View File
@@ -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<List<PayCatalogItem>> catalog() async => const [];
@@ -35,6 +36,9 @@ class _FakePaymentApi implements PaymentApi {
@override
Future<PayOrder> retry(String orderNo, {required String method, Map<String, String>? 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);
});
}