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 内切换)。
|
// orders_page.dart — 我的订单(列表 → 详情两屏,单 widget 内切换)。
|
||||||
//
|
//
|
||||||
// 数据走本地台账(ordersProvider / orderDetailProvider);金额/状态文案全经 AppText。
|
// 数据走本地台账(ordersProvider / orderDetailProvider);金额/状态文案全经 AppText。
|
||||||
// App 内无支付表单——「继续支付 / 重新购买」只引导到购买页(PurchaseScreen)。
|
// App 内无支付表单——「继续支付」= 恢复 created 单的原支付会话(paymentFlowProvider.resume,
|
||||||
|
// 走 retry,不新下单);「重新购买」(expired/canceled/paid 复购)才引导到购买页(PurchaseScreen)。
|
||||||
// 规格参考 design/prototype/screens/orders.html。
|
// 规格参考 design/prototype/screens/orders.html。
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
@@ -13,10 +14,12 @@ import '../models/payment.dart';
|
|||||||
import '../pangolin_theme.dart';
|
import '../pangolin_theme.dart';
|
||||||
import '../state/navigation_provider.dart';
|
import '../state/navigation_provider.dart';
|
||||||
import '../state/orders_provider.dart';
|
import '../state/orders_provider.dart';
|
||||||
|
import '../state/payment_provider.dart';
|
||||||
import '../widgets/page_body.dart';
|
import '../widgets/page_body.dart';
|
||||||
import '../widgets/sub_scaffold.dart';
|
import '../widgets/sub_scaffold.dart';
|
||||||
import '../widgets/pangolin_button.dart';
|
import '../widgets/pangolin_button.dart';
|
||||||
import '../widgets/pangolin_icons.dart';
|
import '../widgets/pangolin_icons.dart';
|
||||||
|
import '../widgets/pangolin_toast.dart';
|
||||||
import '../widgets/status_pill.dart';
|
import '../widgets/status_pill.dart';
|
||||||
import 'payment_page.dart';
|
import 'payment_page.dart';
|
||||||
import 'purchase_page.dart';
|
import 'purchase_page.dart';
|
||||||
@@ -211,7 +214,8 @@ class _OrderDetailView extends ConsumerWidget {
|
|||||||
final bool embedded;
|
final bool embedded;
|
||||||
final VoidCallback onBack;
|
final VoidCallback onBack;
|
||||||
|
|
||||||
// 「继续支付 / 重新购买」→ 购买页(desktop 内容区下钻;mobile/tablet 全屏 push)。
|
// 「重新购买」(expired/canceled 重下单;paid 复购)→ 购买页
|
||||||
|
// (desktop 内容区下钻;mobile/tablet 全屏 push)。
|
||||||
void _goPurchase(BuildContext context, WidgetRef ref) {
|
void _goPurchase(BuildContext context, WidgetRef ref) {
|
||||||
final isDesktop = context.formFactor == FormFactor.desktop;
|
final isDesktop = context.formFactor == FormFactor.desktop;
|
||||||
if (isDesktop) {
|
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 {
|
Future<void> _copy(BuildContext context, String text) async {
|
||||||
await Clipboard.setData(ClipboardData(text: text));
|
await Clipboard.setData(ClipboardData(text: text));
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
@@ -308,7 +345,7 @@ class _OrderDetailView extends ConsumerWidget {
|
|||||||
final list = <Widget>[];
|
final list = <Widget>[];
|
||||||
switch (o.status) {
|
switch (o.status) {
|
||||||
case 'created':
|
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 会话已失效,点击是重新下单而非续付 → 用「重新购买」避免歧义。
|
// 过期单 pay 会话已失效,点击是重新下单而非续付 → 用「重新购买」避免歧义。
|
||||||
case 'expired':
|
case 'expired':
|
||||||
case 'canceled':
|
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(跨结算币种)时,
|
/// 换支付方式:先 retry;pay 回 409 CURRENCY_MISMATCH(跨结算币种)时,
|
||||||
/// 按契约「换币种必须新单」→ cancel 旧单 + 新 method 重新下单。
|
/// 按契约「换币种必须新单」→ cancel 旧单 + 新 method 重新下单。
|
||||||
Future<void> switchMethod(String method) async {
|
Future<void> switchMethod(String method) async {
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ import 'package:pangolin_vpn/state/payment_provider.dart';
|
|||||||
|
|
||||||
class _FakePaymentApi implements PaymentApi {
|
class _FakePaymentApi implements PaymentApi {
|
||||||
_FakePaymentApi();
|
_FakePaymentApi();
|
||||||
int createCalls = 0, cancelCalls = 0, statusCalls = 0;
|
int createCalls = 0, cancelCalls = 0, statusCalls = 0, retryCalls = 0;
|
||||||
bool activated = false;
|
bool activated = false;
|
||||||
Exception? retryError;
|
Exception? retryError;
|
||||||
String lastMethod = '';
|
String lastMethod = '';
|
||||||
|
String lastOrderNo = '';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<PayCatalogItem>> catalog() async => const [];
|
Future<List<PayCatalogItem>> catalog() async => const [];
|
||||||
@@ -35,6 +36,9 @@ class _FakePaymentApi implements PaymentApi {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<PayOrder> retry(String orderNo, {required String method, Map<String, String>? metadata}) async {
|
Future<PayOrder> retry(String orderNo, {required String method, Map<String, String>? metadata}) async {
|
||||||
|
retryCalls++;
|
||||||
|
lastMethod = method;
|
||||||
|
lastOrderNo = orderNo;
|
||||||
if (retryError != null) throw retryError!;
|
if (retryError != null) throw retryError!;
|
||||||
return PayOrder(orderNo: orderNo, session: PaySession(renderType: 'redirect', payload: const {'url': 'https://y'}, expiresAt: null));
|
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(api.lastMethod, 'alipay');
|
||||||
expect(c.read(paymentFlowProvider).order?.orderNo, 'pay2');
|
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