262773a9de
- 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() 单元测试。
196 lines
6.3 KiB
Dart
196 lines
6.3 KiB
Dart
// payment_provider.dart — 支付流状态机:选档下单 → 等待支付(轮询查单)→ 成功/失败。
|
|
// 轮询成功判据是 server 的 activated(webhook 已开通),不是 pay 的 succeeded——
|
|
// 保证用户看到「成功」时权益一定已到账。
|
|
import 'dart:async';
|
|
import 'dart:io' show Platform;
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../models/payment.dart';
|
|
import '../services/auth_api.dart';
|
|
import '../services/payment_api.dart';
|
|
import 'account_providers.dart';
|
|
|
|
final paymentApiProvider = Provider<PaymentApi>(
|
|
(ref) => PaymentApi(ref.watch(apiClientProvider)),
|
|
);
|
|
|
|
final payCatalogProvider = FutureProvider<List<PayCatalogItem>>(
|
|
(ref) => ref.watch(paymentApiProvider).catalog(),
|
|
);
|
|
|
|
enum PaymentPhase { idle, creating, awaitingPayment, succeeded, failed }
|
|
|
|
@immutable
|
|
class PaymentFlowState {
|
|
const PaymentFlowState({
|
|
this.phase = PaymentPhase.idle,
|
|
this.item,
|
|
this.method = '',
|
|
this.order,
|
|
this.status,
|
|
this.errorZh,
|
|
this.errorEn,
|
|
});
|
|
|
|
final PaymentPhase phase;
|
|
final PayCatalogItem? item;
|
|
final String method; // alipay | crypto
|
|
final PayOrder? order;
|
|
final PayOrderStatus? status;
|
|
final String? errorZh;
|
|
final String? errorEn;
|
|
|
|
PaymentFlowState copyWith({
|
|
PaymentPhase? phase,
|
|
PayCatalogItem? item,
|
|
String? method,
|
|
PayOrder? order,
|
|
PayOrderStatus? status,
|
|
String? errorZh,
|
|
String? errorEn,
|
|
}) =>
|
|
PaymentFlowState(
|
|
phase: phase ?? this.phase,
|
|
item: item ?? this.item,
|
|
method: method ?? this.method,
|
|
order: order ?? this.order,
|
|
status: status ?? this.status,
|
|
errorZh: errorZh,
|
|
errorEn: errorEn,
|
|
);
|
|
}
|
|
|
|
class PaymentFlowController extends StateNotifier<PaymentFlowState> {
|
|
PaymentFlowController(Ref ref) : _ref = ref, super(const PaymentFlowState());
|
|
|
|
/// 测试专用 fixture:直接给定固定状态,`_ref` 留空、不发网络、不起 Timer
|
|
/// (widget 测试用来渲染某一支付阶段的界面,不驱动状态机)。[cancel] 对
|
|
/// `_ref == null` 短路安全——支付页 dispose 时无条件可调,不炸 fixture。
|
|
@visibleForTesting
|
|
PaymentFlowController.fixed(super.state) : _ref = null;
|
|
|
|
final Ref? _ref;
|
|
Timer? _poll;
|
|
bool _polling = false; // 再入保护(轮询慢于间隔时跳过本拍)
|
|
|
|
static const _pollInterval = Duration(seconds: 3);
|
|
|
|
bool get _isMobile {
|
|
if (kIsWeb) return false;
|
|
return Platform.isAndroid || Platform.isIOS;
|
|
}
|
|
|
|
Map<String, String> _metadata(String method) =>
|
|
method == 'alipay' ? {'is_mobile': _isMobile ? '1' : '0'} : const {};
|
|
|
|
Future<void> start(PayCatalogItem item, String method) async {
|
|
_stopPolling();
|
|
state = PaymentFlowState(phase: PaymentPhase.creating, item: item, method: method);
|
|
try {
|
|
final order = await _ref!
|
|
.read(paymentApiProvider)
|
|
.createOrder(sku: item.sku, method: method, metadata: _metadata(method));
|
|
if (!mounted) return;
|
|
state = state.copyWith(phase: PaymentPhase.awaitingPayment, order: order);
|
|
_startPolling();
|
|
} on AuthApiException catch (e) {
|
|
if (!mounted) return;
|
|
state = state.copyWith(phase: PaymentPhase.failed, errorZh: e.messageZh, errorEn: e.messageEn);
|
|
}
|
|
}
|
|
|
|
/// 换支付方式:先 retry;pay 回 409 CURRENCY_MISMATCH(跨结算币种)时,
|
|
/// 按契约「换币种必须新单」→ cancel 旧单 + 新 method 重新下单。
|
|
Future<void> switchMethod(String method) async {
|
|
final item = state.item;
|
|
final order = state.order;
|
|
if (item == null || order == null) return;
|
|
try {
|
|
final next = await _ref!
|
|
.read(paymentApiProvider)
|
|
.retry(order.orderNo, method: method, metadata: _metadata(method));
|
|
if (!mounted) return;
|
|
state = state.copyWith(phase: PaymentPhase.awaitingPayment, method: method, order: next);
|
|
} on AuthApiException catch (e) {
|
|
if (!mounted) return;
|
|
if (e.code == 'CURRENCY_MISMATCH') {
|
|
try {
|
|
await _ref!.read(paymentApiProvider).cancel(order.orderNo);
|
|
} catch (_) {} // 旧单取消失败不阻断新单
|
|
await start(item, method);
|
|
return;
|
|
}
|
|
state = state.copyWith(phase: PaymentPhase.failed, errorZh: e.messageZh, errorEn: e.messageEn);
|
|
}
|
|
}
|
|
|
|
Future<void> cancel() async {
|
|
final order = state.order;
|
|
final ref = _ref;
|
|
_stopPolling();
|
|
if (order != null && ref != null) {
|
|
try {
|
|
await ref.read(paymentApiProvider).cancel(order.orderNo);
|
|
} catch (_) {}
|
|
}
|
|
if (mounted) state = const PaymentFlowState();
|
|
}
|
|
|
|
/// 只停轮询 Timer,**不**远程 cancel、**不**改 state——供支付页 dispose()
|
|
/// 用(离开页面 ≠ 放弃订单)。与 [cancel] 区分:后者是「取消订单」按钮的显式
|
|
/// 放弃路径(远程 cancel + 状态归 idle)。
|
|
void stopPolling() => _stopPolling();
|
|
|
|
/// 单拍轮询(Timer 驱动;测试直接调用,不依赖真实时钟)。
|
|
Future<void> pollOnce() async {
|
|
final order = state.order;
|
|
if (order == null || _polling) return;
|
|
_polling = true;
|
|
try {
|
|
final st = await _ref!.read(paymentApiProvider).orderStatus(order.orderNo);
|
|
if (!mounted) return;
|
|
if (st.activated) {
|
|
_stopPolling();
|
|
state = state.copyWith(phase: PaymentPhase.succeeded, status: st);
|
|
// 权益已变,best-effort 刷新「我的」——仅当 meProvider 已被真实页面读取过
|
|
// (ref.exists)才刷新;测试容器/尚未触达账户页时是空操作,不会凭空触发
|
|
// 首次网络请求(避免打真网络 / 触发 authProvider 的后台加载)。
|
|
if (_ref.exists(meProvider)) {
|
|
try {
|
|
await _ref.read(meProvider.notifier).refresh();
|
|
} catch (_) {}
|
|
}
|
|
} else {
|
|
state = state.copyWith(status: st);
|
|
}
|
|
} on AuthApiException {
|
|
// 单拍失败静默,下一拍重试(网络抖动不打断等待页)。
|
|
} finally {
|
|
_polling = false;
|
|
}
|
|
}
|
|
|
|
void _startPolling() {
|
|
_poll?.cancel();
|
|
_poll = Timer.periodic(_pollInterval, (_) => pollOnce());
|
|
}
|
|
|
|
void _stopPolling() {
|
|
_poll?.cancel();
|
|
_poll = null;
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_stopPolling();
|
|
super.dispose();
|
|
}
|
|
}
|
|
|
|
final paymentFlowProvider =
|
|
StateNotifierProvider<PaymentFlowController, PaymentFlowState>(
|
|
(ref) => PaymentFlowController(ref),
|
|
);
|