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
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:
@@ -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':
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user