Files
pangolin/client/lib/state/payment_provider.dart
T
wangjia 262773a9de fix(client): dispose 不再静默取消支付订单,redirect launchUrl 加失败兜底
- 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() 单元测试。
2026-07-11 00:06:03 +08:00

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),
);