301e13f477
switchMethod 的首个 retry() 调用只 catch AuthApiException,网络/超时/未知异常会穿出到 无 try/catch 的 _pickAndSwitch,UI 卡在 awaitingPayment 无任何提示。补一个 catch-all, 与 start()/resume() 同一处理形状落 failed 相位(自动经 build() 切到 _failed() 呈现); payment_page.dart 的 _pickAndSwitch 再加一层防御性 try/catch + toast,防任何逃逸异常 变成无提示的 unhandled rejection。补单测覆盖非 Auth 异常路径。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P9G7E3wmAYL9KeYCVZVsqu
251 lines
8.8 KiB
Dart
251 lines
8.8 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; // nezha | 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;
|
|
}
|
|
|
|
// 法币渠道(nezha)带 is_mobile 提示;crypto 不需要。经 pangolin 代理白名单
|
|
// (is_mobile/render)透传到 pay。
|
|
Map<String, String> _metadata(String method) =>
|
|
method == 'nezha' ? {'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);
|
|
} catch (_) {
|
|
// 网络/解析等非 Auth 异常也要落 failed —— 否则状态机卡在 creating,
|
|
// 购买页看着「点了没反应」。
|
|
if (!mounted) return;
|
|
state = state.copyWith(
|
|
phase: PaymentPhase.failed,
|
|
errorZh: '下单失败,请检查网络后重试',
|
|
errorEn: 'Failed to create order, please check your network and retry',
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 恢复 `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 {
|
|
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);
|
|
} catch (_) {
|
|
// 网络/超时等非 Auth 异常也要落 failed —— 与 start()/resume() 同一形状,否则
|
|
// 穿出到无 try/catch 的 _pickAndSwitch,UI 卡在 awaitingPayment 无提示。
|
|
if (!mounted) return;
|
|
state = state.copyWith(
|
|
phase: PaymentPhase.failed,
|
|
errorZh: '下单失败,请检查网络后重试',
|
|
errorEn: 'Failed to create order, please check your network and retry',
|
|
);
|
|
}
|
|
}
|
|
|
|
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),
|
|
);
|